Explorar el Código

feat: Support custom elements in @excalidraw/excalidraw

ad1992 hace 3 años
padre
commit
39d0084a5e

+ 62 - 1
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<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,

+ 4 - 0
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)}
                   </div>
                 </>
               )}

+ 3 - 0
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, {});

+ 13 - 3
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,

+ 12 - 0
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<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.)
 //

+ 7 - 2
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 extends ExcalidrawElement> = TElement & {
   isDeleted: boolean;
@@ -133,7 +137,8 @@ export type ExcalidrawBindableElement =
   | ExcalidrawDiamondElement
   | ExcalidrawEllipseElement
   | ExcalidrawTextElement
-  | ExcalidrawImageElement;
+  | ExcalidrawImageElement
+  | ExcalidrawCustomElement;
 
 export type ExcalidrawTextContainer =
   | ExcalidrawRectangleElement

+ 62 - 1
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 = (
+  <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>
 

+ 7 - 2
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;
+}

+ 4 - 0
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}
       />
     </InitializeApp>
   );

+ 13 - 1
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}`);

+ 0 - 1
src/renderer/renderScene.ts

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

+ 2 - 1
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 = {

+ 13 - 1
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<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 = {