From 39d0084a5e3a3ee8aec57f493f55d2aaa1a355ab Mon Sep 17 00:00:00 2001 From: ad1992 Date: Wed, 23 Mar 2022 19:04:00 +0530 Subject: [PATCH] feat: Support custom elements in @excalidraw/excalidraw --- src/components/App.tsx | 63 +++++++++++++++++++++++- src/components/LayerUI.tsx | 4 ++ src/data/restore.ts | 3 ++ src/element/collision.ts | 16 ++++-- src/element/newElement.ts | 12 +++++ src/element/types.ts | 9 +++- src/packages/excalidraw/example/App.js | 63 +++++++++++++++++++++++- src/packages/excalidraw/example/App.scss | 9 +++- src/packages/excalidraw/index.tsx | 4 ++ src/renderer/renderElement.ts | 14 +++++- src/renderer/renderScene.ts | 1 - src/scene/types.ts | 3 +- src/types.ts | 14 +++++- 13 files changed, 202 insertions(+), 13 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index cec088fd0..8146a8ee3 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -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 { lastPointerUp: React.PointerEvent | 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 { 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 { 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 { library={this.library} id={this.id} onImageAction={this.onImageAction} + renderCustomElementWidget={this.props.renderCustomElementWidget} />
@@ -1224,6 +1273,7 @@ class App extends React.Component { imageCache: this.imageCache, isExporting: false, renderScrollbars: !this.deviceType.isMobile, + customElementsConfig: this.props.customElementsConfig, }, ); @@ -2986,6 +3036,17 @@ class App extends React.Component { 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, diff --git a/src/components/LayerUI.tsx b/src/components/LayerUI.tsx index ef1df4bb9..d4582595a 100644 --- a/src/components/LayerUI.tsx +++ b/src/components/LayerUI.tsx @@ -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)}
)} diff --git a/src/data/restore.ts b/src/data/restore.ts index c8e18c026..bb7952360 100644 --- a/src/data/restore.ts +++ b/src/data/restore.ts @@ -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, {}); diff --git a/src/element/collision.ts b/src/element/collision.ts index 44bce78d2..0c8a73caa 100644 --- a/src/element/collision.ts +++ b/src/element/collision.ts @@ -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, diff --git a/src/element/newElement.ts b/src/element/newElement.ts index 11a0f23dd..ba5efff50 100644 --- a/src/element/newElement.ts +++ b/src/element/newElement.ts @@ -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 => { + return { + ..._newElementBase("custom", opts), + name, + }; +}; // Simplified deep clone for the purpose of cloning ExcalidrawElement only // (doesn't clone Date, RegExp, Map, Set, Typed arrays etc.) // diff --git a/src/element/types.ts b/src/element/types.ts index f7177e44c..62317c871 100644 --- a/src/element/types.ts +++ b/src/element/types.ts @@ -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 & { isDeleted: boolean; @@ -133,7 +137,8 @@ export type ExcalidrawBindableElement = | ExcalidrawDiamondElement | ExcalidrawEllipseElement | ExcalidrawTextElement - | ExcalidrawImageElement; + | ExcalidrawImageElement + | ExcalidrawCustomElement; export type ExcalidrawTextContainer = | ExcalidrawRectangleElement diff --git a/src/packages/excalidraw/example/App.js b/src/packages/excalidraw/example/App.js index cca570695..5b50bba90 100644 --- a/src/packages/excalidraw/example/App.js +++ b/src/packages/excalidraw/example/App.js @@ -12,6 +12,18 @@ import { MIME_TYPES } from "../../../constants"; const { exportToCanvas, exportToSvg, exportToBlob } = window.Excalidraw; const Excalidraw = window.Excalidraw.default; +const STAR_SVG = ( + + + +); + +const COMMENT_SVG = ( + + + +); + const resolvablePromise = () => { let resolve; let reject; @@ -140,6 +152,53 @@ export default function App() { } }, []); + const renderCustomElementWidget = () => { + return ( + <> + + + + ); + }; + + const getCustomElementsConfig = () => { + return [ + { + type: "custom", + name: "star", + svg: `data:${ + MIME_TYPES.svg + }, ${encodeURIComponent(` + + `)}`, + width: 60, + height: 60, + }, + { + type: "custom", + name: "comment", + svg: `data:${ + MIME_TYPES.svg + }, ${encodeURIComponent(` + + `)}`, + }, + ]; + }; return (

Excalidraw Example

@@ -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()} />
diff --git a/src/packages/excalidraw/example/App.scss b/src/packages/excalidraw/example/App.scss index 6d7977147..e7fd7cb27 100644 --- a/src/packages/excalidraw/example/App.scss +++ b/src/packages/excalidraw/example/App.scss @@ -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; +} diff --git a/src/packages/excalidraw/index.tsx b/src/packages/excalidraw/index.tsx index f56711650..f6a376a70 100644 --- a/src/packages/excalidraw/index.tsx +++ b/src/packages/excalidraw/index.tsx @@ -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} /> ); diff --git a/src/renderer/renderElement.ts b/src/renderer/renderElement.ts index 92f48a76b..404998c90 100644 --- a/src/renderer/renderElement.ts +++ b/src/renderer/renderElement.ts @@ -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}`); diff --git a/src/renderer/renderScene.ts b/src/renderer/renderScene.ts index 00557aeba..1dc789305 100644 --- a/src/renderer/renderScene.ts +++ b/src/renderer/renderScene.ts @@ -190,7 +190,6 @@ export const renderScene = ( if (canvas === null) { return { atLeastOneVisibleElement: false }; } - const { renderScrollbars = true, renderSelection = true, diff --git a/src/scene/types.ts b/src/scene/types.ts index be05ddfde..1fd2df4c4 100644 --- a/src/scene/types.ts +++ b/src/scene/types.ts @@ -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 = { diff --git a/src/types.ts b/src/types.ts index ecd92c679..577b9cc99 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; }>, ) => void; + renderCustomElementWidget?: (appState: AppState) => void; + customElementsConfig?: CustomElementConfig[]; } export type SceneData = { @@ -412,6 +423,7 @@ export type ExcalidrawImperativeAPI = { readyPromise: ResolvablePromise; ready: true; id: string; + setCustomType: InstanceType["setCustomType"]; }; export type DeviceType = {