feat: Support custom elements in @excalidraw/excalidraw

This commit is contained in:
ad1992 2022-03-23 19:04:00 +05:30
parent 2209e2c1e8
commit 39d0084a5e
13 changed files with 202 additions and 13 deletions

View File

@ -119,7 +119,11 @@ import {
} from "../element/binding";
import { LinearElementEditor } from "../element/linearElementEditor";
import { mutateElement, newElementWith } from "../element/mutateElement";
import { deepCopyElement, newFreeDrawElement } from "../element/newElement";
import {
deepCopyElement,
newCustomElement,
newFreeDrawElement,
} from "../element/newElement";
import {
hasBoundTextElement,
isBindingElement,
@ -327,6 +331,7 @@ class App extends React.Component<AppProps, AppState> {
lastPointerUp: React.PointerEvent<HTMLElement> | PointerEvent | null = null;
contextMenuOpen: boolean = false;
lastScenePointer: { x: number; y: number } | null = null;
customElementName: string | null = null;
constructor(props: AppProps) {
super(props);
@ -378,6 +383,7 @@ class App extends React.Component<AppProps, AppState> {
importLibrary: this.importLibraryFromUrl,
setToastMessage: this.setToastMessage,
id: this.id,
setCustomType: this.setCustomType,
} as const;
if (typeof excalidrawRef === "function") {
excalidrawRef(api);
@ -407,6 +413,48 @@ class App extends React.Component<AppProps, AppState> {
this.actionManager.registerAction(createRedoAction(this.history));
}
setCustomType = (name: string) => {
this.setState({ elementType: "custom" });
this.customElementName = name;
};
renderCustomElement = (
coords: { x: number; y: number },
name: string = "",
) => {
const config = this.props.customElementsConfig!.find(
(config) => config.name === name,
)!;
const [gridX, gridY] = getGridPoint(
coords.x,
coords.y,
this.state.gridSize,
);
const width = config.width || 40;
const height = config.height || 40;
const customElement = newCustomElement(name, {
type: "custom",
x: gridX - width / 2,
y: gridY - height / 2,
strokeColor: this.state.currentItemStrokeColor,
backgroundColor: this.state.currentItemBackgroundColor,
fillStyle: this.state.currentItemFillStyle,
strokeWidth: this.state.currentItemStrokeWidth,
strokeStyle: this.state.currentItemStrokeStyle,
roughness: this.state.currentItemRoughness,
opacity: this.state.currentItemOpacity,
strokeSharpness: this.state.currentItemLinearStrokeSharpness,
width,
height,
});
this.scene.replaceAllElements([
...this.scene.getElementsIncludingDeleted(),
customElement,
]);
};
private renderCanvas() {
const canvasScale = window.devicePixelRatio;
const {
@ -530,6 +578,7 @@ class App extends React.Component<AppProps, AppState> {
library={this.library}
id={this.id}
onImageAction={this.onImageAction}
renderCustomElementWidget={this.props.renderCustomElementWidget}
/>
<div className="excalidraw-textEditorContainer" />
<div className="excalidraw-contextMenuContainer" />
@ -1224,6 +1273,7 @@ class App extends React.Component<AppProps, AppState> {
imageCache: this.imageCache,
isExporting: false,
renderScrollbars: !this.deviceType.isMobile,
customElementsConfig: this.props.customElementsConfig,
},
);
@ -2986,6 +3036,17 @@ class App extends React.Component<AppProps, AppState> {
x,
y,
});
} else if (this.state.elementType === "custom") {
if (this.customElementName) {
setCursor(this.canvas, CURSOR_TYPE.CROSSHAIR);
this.renderCustomElement(
{
x: pointerDownState.origin.x,
y: pointerDownState.origin.y,
},
this.customElementName,
);
}
} else if (this.state.elementType === "freedraw") {
this.handleFreeDrawElementOnPointerDown(
event,

View File

@ -67,6 +67,7 @@ interface LayerUIProps {
library: Library;
id: string;
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
renderCustomElementWidget?: (appState: AppState) => void;
}
const LayerUI = ({
@ -94,6 +95,7 @@ const LayerUI = ({
library,
id,
onImageAction,
renderCustomElementWidget,
}: LayerUIProps) => {
const deviceType = useDeviceType();
@ -437,6 +439,8 @@ const LayerUI = ({
})}
>
{actionManager.renderAction("eraser", { size: "small" })}
{renderCustomElementWidget &&
renderCustomElementWidget(appState)}
</div>
</>
)}

View File

@ -44,6 +44,7 @@ export const AllowedExcalidrawElementTypes: Record<
arrow: true,
freedraw: true,
eraser: false,
custom: true,
};
export type RestoredDataState = {
@ -193,6 +194,8 @@ const restoreElement = (
y,
});
}
case "custom":
return restoreElementWithProperties(element, { name: "custom" });
// generic elements
case "ellipse":
return restoreElementWithProperties(element, {});

View File

@ -25,6 +25,7 @@ import {
ExcalidrawFreeDrawElement,
ExcalidrawImageElement,
ExcalidrawLinearElement,
ExcalidrawCustomElement,
} from "./types";
import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds";
@ -166,6 +167,7 @@ const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
case "text":
case "diamond":
case "ellipse":
case "custom":
const distance = distanceToBindableElement(args.element, args.point);
return args.check(distance, args.threshold);
case "freedraw": {
@ -199,6 +201,7 @@ export const distanceToBindableElement = (
case "rectangle":
case "image":
case "text":
case "custom":
return distanceToRectangle(element, point);
case "diamond":
return distanceToDiamond(element, point);
@ -228,7 +231,8 @@ const distanceToRectangle = (
| ExcalidrawRectangleElement
| ExcalidrawTextElement
| ExcalidrawFreeDrawElement
| ExcalidrawImageElement,
| ExcalidrawImageElement
| ExcalidrawCustomElement,
point: Point,
): number => {
const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
@ -504,6 +508,7 @@ export const determineFocusDistance = (
case "rectangle":
case "image":
case "text":
case "custom":
return c / (hwidth * (nabs + q * mabs));
case "diamond":
return mabs < nabs ? c / (nabs * hwidth) : c / (mabs * hheight);
@ -536,6 +541,7 @@ export const determineFocusPoint = (
case "image":
case "text":
case "diamond":
case "custom":
point = findFocusPointForRectangulars(element, focus, adjecentPointRel);
break;
case "ellipse":
@ -586,6 +592,7 @@ const getSortedElementLineIntersections = (
case "image":
case "text":
case "diamond":
case "custom":
const corners = getCorners(element);
intersections = corners
.flatMap((point, i) => {
@ -619,7 +626,8 @@ const getCorners = (
| ExcalidrawRectangleElement
| ExcalidrawImageElement
| ExcalidrawDiamondElement
| ExcalidrawTextElement,
| ExcalidrawTextElement
| ExcalidrawCustomElement,
scale: number = 1,
): GA.Point[] => {
const hx = (scale * element.width) / 2;
@ -628,6 +636,7 @@ const getCorners = (
case "rectangle":
case "image":
case "text":
case "custom":
return [
GA.point(hx, hy),
GA.point(hx, -hy),
@ -770,7 +779,8 @@ export const findFocusPointForRectangulars = (
| ExcalidrawRectangleElement
| ExcalidrawImageElement
| ExcalidrawDiamondElement
| ExcalidrawTextElement,
| ExcalidrawTextElement
| ExcalidrawCustomElement,
// Between -1 and 1 for how far away should the focus point be relative
// to the size of the element. Sign determines orientation.
relativeDistance: number,

View File

@ -12,6 +12,7 @@ import {
ExcalidrawFreeDrawElement,
FontFamilyValues,
ExcalidrawRectangleElement,
ExcalidrawCustomElement,
} from "../element/types";
import { getFontString, getUpdatedTimestamp, isTestEnv } from "../utils";
import { randomInteger, randomId } from "../random";
@ -318,6 +319,17 @@ export const newImageElement = (
};
};
export const newCustomElement = (
name: string,
opts: {
type: ExcalidrawCustomElement["type"];
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawCustomElement> => {
return {
..._newElementBase<ExcalidrawCustomElement>("custom", opts),
name,
};
};
// Simplified deep clone for the purpose of cloning ExcalidrawElement only
// (doesn't clone Date, RegExp, Map, Set, Typed arrays etc.)
//

View File

@ -83,6 +83,9 @@ export type ExcalidrawImageElement = _ExcalidrawElementBase &
scale: [number, number];
}>;
export type ExcalidrawCustomElement = _ExcalidrawElementBase &
Readonly<{ type: "custom"; name: string }>;
export type InitializedExcalidrawImageElement = MarkNonNullable<
ExcalidrawImageElement,
"fileId"
@ -107,7 +110,8 @@ export type ExcalidrawElement =
| ExcalidrawTextElement
| ExcalidrawLinearElement
| ExcalidrawFreeDrawElement
| ExcalidrawImageElement;
| ExcalidrawImageElement
| ExcalidrawCustomElement;
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
isDeleted: boolean;
@ -133,7 +137,8 @@ export type ExcalidrawBindableElement =
| ExcalidrawDiamondElement
| ExcalidrawEllipseElement
| ExcalidrawTextElement
| ExcalidrawImageElement;
| ExcalidrawImageElement
| ExcalidrawCustomElement;
export type ExcalidrawTextContainer =
| ExcalidrawRectangleElement

View File

@ -12,6 +12,18 @@ import { MIME_TYPES } from "../../../constants";
const { exportToCanvas, exportToSvg, exportToBlob } = window.Excalidraw;
const Excalidraw = window.Excalidraw.default;
const STAR_SVG = (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512">
<path d="M287.9 0C297.1 0 305.5 5.25 309.5 13.52L378.1 154.8L531.4 177.5C540.4 178.8 547.8 185.1 550.7 193.7C553.5 202.4 551.2 211.9 544.8 218.2L433.6 328.4L459.9 483.9C461.4 492.9 457.7 502.1 450.2 507.4C442.8 512.7 432.1 513.4 424.9 509.1L287.9 435.9L150.1 509.1C142.9 513.4 133.1 512.7 125.6 507.4C118.2 502.1 114.5 492.9 115.1 483.9L142.2 328.4L31.11 218.2C24.65 211.9 22.36 202.4 25.2 193.7C28.03 185.1 35.5 178.8 44.49 177.5L197.7 154.8L266.3 13.52C270.4 5.249 278.7 0 287.9 0L287.9 0zM287.9 78.95L235.4 187.2C231.9 194.3 225.1 199.3 217.3 200.5L98.98 217.9L184.9 303C190.4 308.5 192.9 316.4 191.6 324.1L171.4 443.7L276.6 387.5C283.7 383.7 292.2 383.7 299.2 387.5L404.4 443.7L384.2 324.1C382.9 316.4 385.5 308.5 391 303L476.9 217.9L358.6 200.5C350.7 199.3 343.9 194.3 340.5 187.2L287.9 78.95z" />
</svg>
);
const COMMENT_SVG = (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path d="M256 32C114.6 32 .0272 125.1 .0272 240c0 47.63 19.91 91.25 52.91 126.2c-14.88 39.5-45.87 72.88-46.37 73.25c-6.625 7-8.375 17.25-4.625 26C5.818 474.2 14.38 480 24 480c61.5 0 109.1-25.75 139.1-46.25C191.1 442.8 223.3 448 256 448c141.4 0 255.1-93.13 255.1-208S397.4 32 256 32zM256.1 400c-26.75 0-53.12-4.125-78.38-12.12l-22.75-7.125l-19.5 13.75c-14.25 10.12-33.88 21.38-57.5 29c7.375-12.12 14.37-25.75 19.88-40.25l10.62-28l-20.62-21.87C69.82 314.1 48.07 282.2 48.07 240c0-88.25 93.25-160 208-160s208 71.75 208 160S370.8 400 256.1 400z" />
</svg>
);
const resolvablePromise = () => {
let resolve;
let reject;
@ -140,6 +152,53 @@ export default function App() {
}
}, []);
const renderCustomElementWidget = () => {
return (
<>
<button
className="custom-element"
onClick={() => {
excalidrawRef.current.setCustomType("star");
}}
>
{STAR_SVG}
</button>
<button
className="custom-element"
onClick={() => {
excalidrawRef.current.setCustomType("comment");
}}
>
{COMMENT_SVG}
</button>
</>
);
};
const getCustomElementsConfig = () => {
return [
{
type: "custom",
name: "star",
svg: `data:${
MIME_TYPES.svg
}, ${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512">
<path d="M287.9 0C297.1 0 305.5 5.25 309.5 13.52L378.1 154.8L531.4 177.5C540.4 178.8 547.8 185.1 550.7 193.7C553.5 202.4 551.2 211.9 544.8 218.2L433.6 328.4L459.9 483.9C461.4 492.9 457.7 502.1 450.2 507.4C442.8 512.7 432.1 513.4 424.9 509.1L287.9 435.9L150.1 509.1C142.9 513.4 133.1 512.7 125.6 507.4C118.2 502.1 114.5 492.9 115.1 483.9L142.2 328.4L31.11 218.2C24.65 211.9 22.36 202.4 25.2 193.7C28.03 185.1 35.5 178.8 44.49 177.5L197.7 154.8L266.3 13.52C270.4 5.249 278.7 0 287.9 0L287.9 0zM287.9 78.95L235.4 187.2C231.9 194.3 225.1 199.3 217.3 200.5L98.98 217.9L184.9 303C190.4 308.5 192.9 316.4 191.6 324.1L171.4 443.7L276.6 387.5C283.7 383.7 292.2 383.7 299.2 387.5L404.4 443.7L384.2 324.1C382.9 316.4 385.5 308.5 391 303L476.9 217.9L358.6 200.5C350.7 199.3 343.9 194.3 340.5 187.2L287.9 78.95z" />
</svg>`)}`,
width: 60,
height: 60,
},
{
type: "custom",
name: "comment",
svg: `data:${
MIME_TYPES.svg
}, ${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path d="M256 32C114.6 32 .0272 125.1 .0272 240c0 47.63 19.91 91.25 52.91 126.2c-14.88 39.5-45.87 72.88-46.37 73.25c-6.625 7-8.375 17.25-4.625 26C5.818 474.2 14.38 480 24 480c61.5 0 109.1-25.75 139.1-46.25C191.1 442.8 223.3 448 256 448c141.4 0 255.1-93.13 255.1-208S397.4 32 256 32zM256.1 400c-26.75 0-53.12-4.125-78.38-12.12l-22.75-7.125l-19.5 13.75c-14.25 10.12-33.88 21.38-57.5 29c7.375-12.12 14.37-25.75 19.88-40.25l10.62-28l-20.62-21.87C69.82 314.1 48.07 282.2 48.07 240c0-88.25 93.25-160 208-160s208 71.75 208 160S370.8 400 256.1 400z" />
</svg>`)}`,
},
];
};
return (
<div className="App">
<h1> Excalidraw Example</h1>
@ -220,7 +279,7 @@ export default function App() {
onChange={(elements, state) =>
console.info("Elements :", elements, "State : ", state)
}
onPointerUpdate={(payload) => console.info(payload)}
//onPointerUpdate={(payload) => console.info(payload)}
onCollabButtonClick={() =>
window.alert("You clicked on collab button")
}
@ -233,6 +292,8 @@ export default function App() {
renderTopRightUI={renderTopRightUI}
renderFooter={renderFooter}
onLinkOpen={onLinkOpen}
renderCustomElementWidget={renderCustomElementWidget}
customElementsConfig={getCustomElementsConfig()}
/>
</div>

View File

@ -6,7 +6,7 @@
.button-wrapper button {
z-index: 1;
height: 40px;
max-width: 200px;
max-width: 250px;
margin: 10px;
padding: 5px;
}
@ -16,7 +16,7 @@
}
.excalidraw-wrapper {
height: 800px;
height: 600px;
margin: 50px;
}
@ -47,3 +47,8 @@
--color-primary-darkest: #e64980;
--color-primary-light: #fcc2d7;
}
.custom-element {
width: 2rem;
height: 2rem;
}

View File

@ -36,6 +36,8 @@ const Excalidraw = (props: ExcalidrawProps) => {
autoFocus = false,
generateIdForFile,
onLinkOpen,
renderCustomElementWidget,
customElementsConfig,
} = props;
const canvasActions = props.UIOptions?.canvasActions;
@ -98,6 +100,8 @@ const Excalidraw = (props: ExcalidrawProps) => {
autoFocus={autoFocus}
generateIdForFile={generateIdForFile}
onLinkOpen={onLinkOpen}
renderCustomElementWidget={renderCustomElementWidget}
customElementsConfig={customElementsConfig}
/>
</InitializeApp>
);

View File

@ -250,6 +250,16 @@ const drawElementOnCanvas = (
}
break;
}
case "custom": {
const config = renderConfig.customElementsConfig?.find(
(config) => config.name === element.name,
);
const img = document.createElement("img");
img.src = config!.svg;
context.drawImage(img, 0, 0, element.width, element.height);
break;
}
default: {
if (isTextElement(element)) {
const rtl = isRTL(element.text);
@ -779,7 +789,8 @@ export const renderElement = (
case "line":
case "arrow":
case "image":
case "text": {
case "text":
case "custom": {
generateElementShape(element, generator);
if (renderConfig.isExporting) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
@ -809,6 +820,7 @@ export const renderElement = (
}
break;
}
default: {
// @ts-ignore
throw new Error(`Unimplemented type ${element.type}`);

View File

@ -190,7 +190,6 @@ export const renderScene = (
if (canvas === null) {
return { atLeastOneVisibleElement: false };
}
const {
renderScrollbars = true,
renderSelection = true,

View File

@ -1,5 +1,5 @@
import { ExcalidrawTextElement } from "../element/types";
import { AppClassProperties, AppState } from "../types";
import { AppClassProperties, AppState, ExcalidrawProps } from "../types";
export type RenderConfig = {
// AppState values
@ -27,6 +27,7 @@ export type RenderConfig = {
/** when exporting the behavior is slightly different (e.g. we can't use
CSS filters), and we disable render optimizations for best output */
isExporting: boolean;
customElementsConfig?: ExcalidrawProps["customElementsConfig"];
};
export type SceneScroll = {

View File

@ -77,7 +77,7 @@ export type AppState = {
// (e.g. text element when typing into the input)
editingElement: NonDeletedExcalidrawElement | null;
editingLinearElement: LinearElementEditor | null;
elementType: typeof SHAPES[number]["value"] | "eraser";
elementType: typeof SHAPES[number]["value"] | "eraser" | "custom";
elementLocked: boolean;
penMode: boolean;
penDetected: boolean;
@ -206,6 +206,15 @@ export type ExcalidrawAPIRefValue =
ready?: false;
};
type CustomElementConfig = {
type: "custom";
name: string;
resize?: boolean;
rotate?: boolean;
svg: string;
width?: number;
height?: number;
};
export interface ExcalidrawProps {
onChange?: (
elements: readonly ExcalidrawElement[],
@ -253,6 +262,8 @@ export interface ExcalidrawProps {
nativeEvent: MouseEvent | React.PointerEvent<HTMLCanvasElement>;
}>,
) => void;
renderCustomElementWidget?: (appState: AppState) => void;
customElementsConfig?: CustomElementConfig[];
}
export type SceneData = {
@ -412,6 +423,7 @@ export type ExcalidrawImperativeAPI = {
readyPromise: ResolvablePromise<ExcalidrawImperativeAPI>;
ready: true;
id: string;
setCustomType: InstanceType<typeof App>["setCustomType"];
};
export type DeviceType = {