mirror of
				https://github.com/excalidraw/excalidraw.git
				synced 2025-11-04 04:44:31 +01:00 
			
		
		
		
	Compare commits
	
		
			28 Commits
		
	
	
		
			v0.18.0
			...
			aakansha-c
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					c4b951a0c5 | ||
| 
						 | 
					c93d8f4bd0 | ||
| 
						 | 
					645f9a5dc0 | ||
| 
						 | 
					128b7741c1 | ||
| 
						 | 
					1edde7291c | ||
| 
						 | 
					1ca56204b1 | ||
| 
						 | 
					f3ae7a8506 | ||
| 
						 | 
					5f57daa132 | ||
| 
						 | 
					db9c9eb3d2 | ||
| 
						 | 
					2e8c4d25f2 | ||
| 
						 | 
					4953828d86 | ||
| 
						 | 
					6eb0cf6a10 | ||
| 
						 | 
					ba48aa24a0 | ||
| 
						 | 
					4e75f10b2c | ||
| 
						 | 
					d2d3599661 | ||
| 
						 | 
					ed3eda3401 | ||
| 
						 | 
					d27b32dd2c | ||
| 
						 | 
					2337842f57 | ||
| 
						 | 
					5b78f50fe3 | ||
| 
						 | 
					a4a95a591a | ||
| 
						 | 
					3d459076fb | ||
| 
						 | 
					14a23c6c50 | ||
| 
						 | 
					5f4a5b1789 | ||
| 
						 | 
					47498796e0 | ||
| 
						 | 
					8706277d14 | ||
| 
						 | 
					3d0a1106ff | ||
| 
						 | 
					61699ff3c2 | ||
| 
						 | 
					39d0084a5e | 
@@ -304,21 +304,42 @@ export const actionErase = register({
 | 
			
		||||
  name: "eraser",
 | 
			
		||||
  trackEvent: { category: "toolbar" },
 | 
			
		||||
  perform: (elements, appState) => {
 | 
			
		||||
    const activeTool: any = { ...appState.activeTool };
 | 
			
		||||
 | 
			
		||||
    if (appState.activeTool.type !== "eraser") {
 | 
			
		||||
      if (appState.activeTool.type === "custom") {
 | 
			
		||||
        activeTool.lastActiveToolBeforeEraser = {
 | 
			
		||||
          type: "custom",
 | 
			
		||||
          customType: appState.activeTool.customType,
 | 
			
		||||
        };
 | 
			
		||||
      } else {
 | 
			
		||||
        activeTool.lastActiveToolBeforeEraser = appState.activeTool.type;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    if (isEraserActive(appState)) {
 | 
			
		||||
      if (appState.activeTool.lastActiveToolBeforeEraser) {
 | 
			
		||||
        if (
 | 
			
		||||
          typeof appState.activeTool.lastActiveToolBeforeEraser === "object" &&
 | 
			
		||||
          appState.activeTool.lastActiveToolBeforeEraser?.type === "custom"
 | 
			
		||||
        ) {
 | 
			
		||||
          activeTool.type = "custom";
 | 
			
		||||
          activeTool.customType =
 | 
			
		||||
            appState.activeTool.lastActiveToolBeforeEraser.customType;
 | 
			
		||||
        } else {
 | 
			
		||||
          activeTool.type = appState.activeTool.lastActiveToolBeforeEraser;
 | 
			
		||||
        }
 | 
			
		||||
      } else {
 | 
			
		||||
        activeTool.type = "selection";
 | 
			
		||||
      }
 | 
			
		||||
    } else {
 | 
			
		||||
      activeTool.type = "eraser";
 | 
			
		||||
    }
 | 
			
		||||
    return {
 | 
			
		||||
      appState: {
 | 
			
		||||
        ...appState,
 | 
			
		||||
        selectedElementIds: {},
 | 
			
		||||
        selectedGroupIds: {},
 | 
			
		||||
        activeTool: {
 | 
			
		||||
          ...appState.activeTool,
 | 
			
		||||
          type: isEraserActive(appState)
 | 
			
		||||
            ? appState.activeTool.lastActiveToolBeforeEraser ?? "selection"
 | 
			
		||||
            : "eraser",
 | 
			
		||||
          lastActiveToolBeforeEraser:
 | 
			
		||||
            appState.activeTool.type === "eraser" //node throws incorrect type error when using isEraserActive()
 | 
			
		||||
              ? null
 | 
			
		||||
              : appState.activeTool.type,
 | 
			
		||||
        },
 | 
			
		||||
        activeTool,
 | 
			
		||||
      },
 | 
			
		||||
      commitToHistory: true,
 | 
			
		||||
    };
 | 
			
		||||
 
 | 
			
		||||
@@ -136,7 +136,21 @@ export const actionFinalize = register({
 | 
			
		||||
    ) {
 | 
			
		||||
      resetCursor(canvas);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const activeTool: any = { ...appState.activeTool };
 | 
			
		||||
    if (appState.activeTool.lastActiveToolBeforeEraser) {
 | 
			
		||||
      if (
 | 
			
		||||
        typeof appState.activeTool.lastActiveToolBeforeEraser === "object" &&
 | 
			
		||||
        appState.activeTool.lastActiveToolBeforeEraser.type === "custom"
 | 
			
		||||
      ) {
 | 
			
		||||
        activeTool.type = appState.activeTool.lastActiveToolBeforeEraser.type;
 | 
			
		||||
        activeTool.customType =
 | 
			
		||||
          appState.activeTool.lastActiveToolBeforeEraser.customType;
 | 
			
		||||
      } else {
 | 
			
		||||
        activeTool.type = appState.activeTool.lastActiveToolBeforeEraser;
 | 
			
		||||
      }
 | 
			
		||||
    } else {
 | 
			
		||||
      activeTool.type = "selection";
 | 
			
		||||
    }
 | 
			
		||||
    return {
 | 
			
		||||
      elements: newElements,
 | 
			
		||||
      appState: {
 | 
			
		||||
@@ -147,14 +161,7 @@ export const actionFinalize = register({
 | 
			
		||||
            appState.activeTool.type === "freedraw") &&
 | 
			
		||||
          multiPointElement
 | 
			
		||||
            ? appState.activeTool
 | 
			
		||||
            : {
 | 
			
		||||
                ...appState.activeTool,
 | 
			
		||||
                type:
 | 
			
		||||
                  appState.activeTool.type === "eraser" &&
 | 
			
		||||
                  appState.activeTool.lastActiveToolBeforeEraser
 | 
			
		||||
                    ? appState.activeTool.lastActiveToolBeforeEraser
 | 
			
		||||
                    : "selection",
 | 
			
		||||
              },
 | 
			
		||||
            : activeTool,
 | 
			
		||||
        draggingElement: null,
 | 
			
		||||
        multiElement: null,
 | 
			
		||||
        editingElement: null,
 | 
			
		||||
 
 | 
			
		||||
@@ -119,12 +119,17 @@ 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,
 | 
			
		||||
  isBindingElementType,
 | 
			
		||||
  isBoundToContainer,
 | 
			
		||||
  isCustomElement,
 | 
			
		||||
  isImageElement,
 | 
			
		||||
  isInitializedImageElement,
 | 
			
		||||
  isLinearElement,
 | 
			
		||||
@@ -380,6 +385,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);
 | 
			
		||||
@@ -394,7 +400,7 @@ class App extends React.Component<AppProps, AppState> {
 | 
			
		||||
      id: this.id,
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    this.scene = new Scene();
 | 
			
		||||
    this.scene = new Scene(this);
 | 
			
		||||
    this.library = new Library(this);
 | 
			
		||||
    this.history = new History();
 | 
			
		||||
    this.actionManager = new ActionManager(
 | 
			
		||||
@@ -409,6 +415,59 @@ class App extends React.Component<AppProps, AppState> {
 | 
			
		||||
    this.actionManager.registerAction(createRedoAction(this.history));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  setCustomType = (customType: string) => {
 | 
			
		||||
    this.setState({
 | 
			
		||||
      activeTool: { ...this.state.activeTool, type: "custom", customType },
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  renderCustomElement = (coords: { x: number; y: number }) => {
 | 
			
		||||
    if (this.state.activeTool.type !== "custom") {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    const config =
 | 
			
		||||
      this.props.customElementsConfig?.[this.state.activeTool.customType];
 | 
			
		||||
 | 
			
		||||
    if (!config) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    const [gridX, gridY] = getGridPoint(
 | 
			
		||||
      coords.x,
 | 
			
		||||
      coords.y,
 | 
			
		||||
      this.state.gridSize,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    const width = config.width || 40;
 | 
			
		||||
    const height = config.height || 40;
 | 
			
		||||
    const customElement = newCustomElement(this.state.activeTool.customType, {
 | 
			
		||||
      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,
 | 
			
		||||
      locked: false,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    this.scene.replaceAllElements([
 | 
			
		||||
      ...this.scene.getElementsIncludingDeleted(),
 | 
			
		||||
      customElement,
 | 
			
		||||
    ]);
 | 
			
		||||
    const customElementConfig =
 | 
			
		||||
      this.props.customElementsConfig?.[customElement.customType];
 | 
			
		||||
 | 
			
		||||
    if (customElementConfig && customElementConfig.onCreate) {
 | 
			
		||||
      customElementConfig.onCreate(customElement);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  private renderCanvas() {
 | 
			
		||||
    const canvasScale = window.devicePixelRatio;
 | 
			
		||||
    const {
 | 
			
		||||
@@ -532,6 +591,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" />
 | 
			
		||||
@@ -1234,6 +1294,7 @@ class App extends React.Component<AppProps, AppState> {
 | 
			
		||||
        imageCache: this.imageCache,
 | 
			
		||||
        isExporting: false,
 | 
			
		||||
        renderScrollbars: !this.deviceType.isMobile,
 | 
			
		||||
        customElementsConfig: this.props.customElementsConfig,
 | 
			
		||||
      },
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
@@ -1591,14 +1652,18 @@ class App extends React.Component<AppProps, AppState> {
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
    this.setState((prevState) => {
 | 
			
		||||
      const activeTool: any = {
 | 
			
		||||
        ...prevState.activeTool,
 | 
			
		||||
        locked: !prevState.activeTool.locked,
 | 
			
		||||
        type: prevState.activeTool.locked
 | 
			
		||||
          ? "selection"
 | 
			
		||||
          : prevState.activeTool.type,
 | 
			
		||||
      };
 | 
			
		||||
      if (prevState.activeTool.type === "custom") {
 | 
			
		||||
        activeTool.customType = prevState.activeTool.customType;
 | 
			
		||||
      }
 | 
			
		||||
      return {
 | 
			
		||||
        activeTool: {
 | 
			
		||||
          ...prevState.activeTool,
 | 
			
		||||
          locked: !prevState.activeTool.locked,
 | 
			
		||||
          type: prevState.activeTool.locked
 | 
			
		||||
            ? "selection"
 | 
			
		||||
            : prevState.activeTool.type,
 | 
			
		||||
        },
 | 
			
		||||
        activeTool,
 | 
			
		||||
      };
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
@@ -2829,6 +2894,14 @@ class App extends React.Component<AppProps, AppState> {
 | 
			
		||||
          )) &&
 | 
			
		||||
        !hitElement?.locked
 | 
			
		||||
      ) {
 | 
			
		||||
        if (hitElement && isCustomElement(hitElement)) {
 | 
			
		||||
          const config =
 | 
			
		||||
            this.props.customElementsConfig?.[hitElement.customType];
 | 
			
		||||
 | 
			
		||||
          if (!config?.transformHandles) {
 | 
			
		||||
            return;
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
        setCursor(this.canvas, CURSOR_TYPE.MOVE);
 | 
			
		||||
      } else {
 | 
			
		||||
        setCursor(this.canvas, CURSOR_TYPE.AUTO);
 | 
			
		||||
@@ -3051,6 +3124,12 @@ class App extends React.Component<AppProps, AppState> {
 | 
			
		||||
        x,
 | 
			
		||||
        y,
 | 
			
		||||
      });
 | 
			
		||||
    } else if (this.state.activeTool.type === "custom") {
 | 
			
		||||
      setCursor(this.canvas, CURSOR_TYPE.CROSSHAIR);
 | 
			
		||||
      this.renderCustomElement({
 | 
			
		||||
        x: pointerDownState.origin.x,
 | 
			
		||||
        y: pointerDownState.origin.y,
 | 
			
		||||
      });
 | 
			
		||||
    } else if (this.state.activeTool.type === "freedraw") {
 | 
			
		||||
      this.handleFreeDrawElementOnPointerDown(
 | 
			
		||||
        event,
 | 
			
		||||
@@ -3091,15 +3170,13 @@ class App extends React.Component<AppProps, AppState> {
 | 
			
		||||
    event: React.PointerEvent<HTMLCanvasElement>,
 | 
			
		||||
  ) => {
 | 
			
		||||
    this.lastPointerUp = event;
 | 
			
		||||
    if (this.deviceType.isTouchScreen) {
 | 
			
		||||
    let hitElement;
 | 
			
		||||
    if (this.deviceType.isTouchScreen || this.props.onElementClick) {
 | 
			
		||||
      const scenePointer = viewportCoordsToSceneCoords(
 | 
			
		||||
        { clientX: event.clientX, clientY: event.clientY },
 | 
			
		||||
        this.state,
 | 
			
		||||
      );
 | 
			
		||||
      const hitElement = this.getElementAtPosition(
 | 
			
		||||
        scenePointer.x,
 | 
			
		||||
        scenePointer.y,
 | 
			
		||||
      );
 | 
			
		||||
      hitElement = this.getElementAtPosition(scenePointer.x, scenePointer.y);
 | 
			
		||||
      this.hitLinkElement = this.getElementLinkAtPosition(
 | 
			
		||||
        scenePointer,
 | 
			
		||||
        hitElement,
 | 
			
		||||
@@ -3112,6 +3189,24 @@ class App extends React.Component<AppProps, AppState> {
 | 
			
		||||
      this.redirectToLink(event, this.deviceType.isTouchScreen);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (
 | 
			
		||||
      event.button !== POINTER_BUTTON.SECONDARY &&
 | 
			
		||||
      this.state.activeTool.type === "selection" &&
 | 
			
		||||
      this.props.onElementClick &&
 | 
			
		||||
      hitElement
 | 
			
		||||
    ) {
 | 
			
		||||
      const threshold = 5;
 | 
			
		||||
      const isSinglePointClick =
 | 
			
		||||
        distance2d(
 | 
			
		||||
          this.lastPointerDown!.clientX,
 | 
			
		||||
          this.lastPointerDown!.clientY,
 | 
			
		||||
          this.lastPointerUp!.clientX,
 | 
			
		||||
          this.lastPointerUp!.clientY,
 | 
			
		||||
        ) <= threshold;
 | 
			
		||||
      if (isSinglePointClick) {
 | 
			
		||||
        this.props.onElementClick(hitElement, event);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    this.removePointer(event);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
@@ -4220,6 +4315,7 @@ class App extends React.Component<AppProps, AppState> {
 | 
			
		||||
          const elementsWithinSelection = getElementsWithinSelection(
 | 
			
		||||
            elements,
 | 
			
		||||
            draggingElement,
 | 
			
		||||
            this.props.customElementsConfig,
 | 
			
		||||
          );
 | 
			
		||||
          this.setState((prevState) =>
 | 
			
		||||
            selectGroupsForSelectedElements(
 | 
			
		||||
@@ -4513,6 +4609,7 @@ class App extends React.Component<AppProps, AppState> {
 | 
			
		||||
      // Code below handles selection when element(s) weren't
 | 
			
		||||
      // drag or added to selection on pointer down phase.
 | 
			
		||||
      const hitElement = pointerDownState.hit.element;
 | 
			
		||||
 | 
			
		||||
      if (isEraserActive(this.state)) {
 | 
			
		||||
        const draggedDistance = distance2d(
 | 
			
		||||
          this.lastPointerDown!.clientX,
 | 
			
		||||
@@ -4546,7 +4643,6 @@ class App extends React.Component<AppProps, AppState> {
 | 
			
		||||
      } else if (Object.keys(pointerDownState.elementIdsToErase).length) {
 | 
			
		||||
        this.restoreReadyToEraseElements(pointerDownState);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (
 | 
			
		||||
        hitElement &&
 | 
			
		||||
        !pointerDownState.drag.hasOccurred &&
 | 
			
		||||
@@ -5347,7 +5443,6 @@ class App extends React.Component<AppProps, AppState> {
 | 
			
		||||
    event: React.PointerEvent<HTMLCanvasElement>,
 | 
			
		||||
  ) => {
 | 
			
		||||
    event.preventDefault();
 | 
			
		||||
 | 
			
		||||
    if (
 | 
			
		||||
      (event.nativeEvent.pointerType === "touch" ||
 | 
			
		||||
        (event.nativeEvent.pointerType === "pen" &&
 | 
			
		||||
@@ -5364,6 +5459,16 @@ class App extends React.Component<AppProps, AppState> {
 | 
			
		||||
      includeLockedElements: true,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    let disableContextMenu = false;
 | 
			
		||||
    if (element && isCustomElement(element)) {
 | 
			
		||||
      const config = this.props.customElementsConfig?.[element.customType];
 | 
			
		||||
 | 
			
		||||
      disableContextMenu = !!config?.disableContextMenu;
 | 
			
		||||
    }
 | 
			
		||||
    if (disableContextMenu) {
 | 
			
		||||
      this.contextMenuOpen = true;
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
    const type = element ? "element" : "canvas";
 | 
			
		||||
 | 
			
		||||
    const container = this.excalidrawContainerRef.current!;
 | 
			
		||||
 
 | 
			
		||||
@@ -68,6 +68,7 @@ interface LayerUIProps {
 | 
			
		||||
  library: Library;
 | 
			
		||||
  id: string;
 | 
			
		||||
  onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
 | 
			
		||||
  renderCustomElementWidget?: (appState: AppState) => void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const LayerUI = ({
 | 
			
		||||
@@ -95,6 +96,7 @@ const LayerUI = ({
 | 
			
		||||
  library,
 | 
			
		||||
  id,
 | 
			
		||||
  onImageAction,
 | 
			
		||||
  renderCustomElementWidget,
 | 
			
		||||
}: LayerUIProps) => {
 | 
			
		||||
  const deviceType = useDeviceType();
 | 
			
		||||
 | 
			
		||||
@@ -439,6 +441,8 @@ const LayerUI = ({
 | 
			
		||||
                    })}
 | 
			
		||||
                  >
 | 
			
		||||
                    {actionManager.renderAction("eraser", { size: "small" })}
 | 
			
		||||
                    {renderCustomElementWidget &&
 | 
			
		||||
                      renderCustomElementWidget(appState)}
 | 
			
		||||
                  </div>
 | 
			
		||||
                </>
 | 
			
		||||
              )}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
import cssVariables from "./css/variables.module.scss";
 | 
			
		||||
import { AppProps } from "./types";
 | 
			
		||||
import { AppProps, CustomElementConfig } from "./types";
 | 
			
		||||
import { FontFamilyValues } from "./element/types";
 | 
			
		||||
 | 
			
		||||
export const APP_NAME = "Excalidraw";
 | 
			
		||||
@@ -155,6 +155,17 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const DEFAULT_CUSTOM_ELEMENT_CONFIG: Required<CustomElementConfig> = {
 | 
			
		||||
  type: "custom",
 | 
			
		||||
  customType: "custom",
 | 
			
		||||
  transformHandles: true,
 | 
			
		||||
  displayData: { content: "", type: "svg" },
 | 
			
		||||
  width: 40,
 | 
			
		||||
  height: 40,
 | 
			
		||||
  stackedOnTop: false,
 | 
			
		||||
  onCreate: () => {},
 | 
			
		||||
  disableContextMenu: false,
 | 
			
		||||
};
 | 
			
		||||
export const MQ_MAX_WIDTH_PORTRAIT = 730;
 | 
			
		||||
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
 | 
			
		||||
export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
 | 
			
		||||
 
 | 
			
		||||
@@ -48,6 +48,7 @@ export const AllowedExcalidrawActiveTools: Record<
 | 
			
		||||
  arrow: true,
 | 
			
		||||
  freedraw: true,
 | 
			
		||||
  eraser: false,
 | 
			
		||||
  custom: true,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type RestoredDataState = {
 | 
			
		||||
@@ -198,6 +199,10 @@ const restoreElement = (
 | 
			
		||||
        y,
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
    case "custom":
 | 
			
		||||
      return restoreElementWithProperties(element, {
 | 
			
		||||
        customType: element.customType || "custom",
 | 
			
		||||
      });
 | 
			
		||||
    // generic elements
 | 
			
		||||
    case "ellipse":
 | 
			
		||||
      return restoreElementWithProperties(element, {});
 | 
			
		||||
@@ -255,6 +260,19 @@ export const restoreAppState = (
 | 
			
		||||
        ? localValue
 | 
			
		||||
        : defaultValue;
 | 
			
		||||
  }
 | 
			
		||||
  const activeTool: any = {
 | 
			
		||||
    lastActiveToolBeforeEraser: null,
 | 
			
		||||
    locked: nextAppState.activeTool.locked ?? false,
 | 
			
		||||
    type: "selection",
 | 
			
		||||
  };
 | 
			
		||||
  if (AllowedExcalidrawActiveTools[nextAppState.activeTool.type]) {
 | 
			
		||||
    if (nextAppState.activeTool.type === "custom") {
 | 
			
		||||
      activeTool.type = "custom";
 | 
			
		||||
      activeTool.customType = nextAppState.activeTool.customType ?? "custom";
 | 
			
		||||
    } else {
 | 
			
		||||
      activeTool.type = nextAppState.activeTool.type;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  return {
 | 
			
		||||
    ...nextAppState,
 | 
			
		||||
    cursorButton: localAppState?.cursorButton || "up",
 | 
			
		||||
@@ -262,13 +280,7 @@ export const restoreAppState = (
 | 
			
		||||
    penDetected:
 | 
			
		||||
      localAppState?.penDetected ??
 | 
			
		||||
      (appState.penMode ? appState.penDetected ?? false : false),
 | 
			
		||||
    activeTool: {
 | 
			
		||||
      lastActiveToolBeforeEraser: null,
 | 
			
		||||
      locked: nextAppState.activeTool.locked ?? false,
 | 
			
		||||
      type: AllowedExcalidrawActiveTools[nextAppState.activeTool.type]
 | 
			
		||||
        ? nextAppState.activeTool.type ?? "selection"
 | 
			
		||||
        : "selection",
 | 
			
		||||
    },
 | 
			
		||||
    activeTool,
 | 
			
		||||
    // Migrates from previous version where appState.zoom was a number
 | 
			
		||||
    zoom:
 | 
			
		||||
      typeof appState.zoom === "number"
 | 
			
		||||
 
 | 
			
		||||
@@ -25,6 +25,7 @@ import {
 | 
			
		||||
  ExcalidrawFreeDrawElement,
 | 
			
		||||
  ExcalidrawImageElement,
 | 
			
		||||
  ExcalidrawLinearElement,
 | 
			
		||||
  ExcalidrawCustomElement,
 | 
			
		||||
} from "./types";
 | 
			
		||||
 | 
			
		||||
import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds";
 | 
			
		||||
@@ -32,13 +33,20 @@ import { Point } from "../types";
 | 
			
		||||
import { Drawable } from "roughjs/bin/core";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { getShapeForElement } from "../renderer/renderElement";
 | 
			
		||||
import { hasBoundTextElement, isImageElement } from "./typeChecks";
 | 
			
		||||
import {
 | 
			
		||||
  hasBoundTextElement,
 | 
			
		||||
  isCustomElement,
 | 
			
		||||
  isImageElement,
 | 
			
		||||
} from "./typeChecks";
 | 
			
		||||
import { isTextElement } from ".";
 | 
			
		||||
import { isTransparent } from "../utils";
 | 
			
		||||
 | 
			
		||||
const isElementDraggableFromInside = (
 | 
			
		||||
  element: NonDeletedExcalidrawElement,
 | 
			
		||||
): boolean => {
 | 
			
		||||
  if (isCustomElement(element)) {
 | 
			
		||||
    return true;
 | 
			
		||||
  }
 | 
			
		||||
  if (element.type === "arrow") {
 | 
			
		||||
    return false;
 | 
			
		||||
  }
 | 
			
		||||
@@ -166,6 +174,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 +208,7 @@ export const distanceToBindableElement = (
 | 
			
		||||
    case "rectangle":
 | 
			
		||||
    case "image":
 | 
			
		||||
    case "text":
 | 
			
		||||
    case "custom":
 | 
			
		||||
      return distanceToRectangle(element, point);
 | 
			
		||||
    case "diamond":
 | 
			
		||||
      return distanceToDiamond(element, point);
 | 
			
		||||
@@ -228,7 +238,8 @@ const distanceToRectangle = (
 | 
			
		||||
    | ExcalidrawRectangleElement
 | 
			
		||||
    | ExcalidrawTextElement
 | 
			
		||||
    | ExcalidrawFreeDrawElement
 | 
			
		||||
    | ExcalidrawImageElement,
 | 
			
		||||
    | ExcalidrawImageElement
 | 
			
		||||
    | ExcalidrawCustomElement,
 | 
			
		||||
  point: Point,
 | 
			
		||||
): number => {
 | 
			
		||||
  const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
 | 
			
		||||
@@ -504,6 +515,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 +548,7 @@ export const determineFocusPoint = (
 | 
			
		||||
    case "image":
 | 
			
		||||
    case "text":
 | 
			
		||||
    case "diamond":
 | 
			
		||||
    case "custom":
 | 
			
		||||
      point = findFocusPointForRectangulars(element, focus, adjecentPointRel);
 | 
			
		||||
      break;
 | 
			
		||||
    case "ellipse":
 | 
			
		||||
@@ -586,6 +599,7 @@ const getSortedElementLineIntersections = (
 | 
			
		||||
    case "image":
 | 
			
		||||
    case "text":
 | 
			
		||||
    case "diamond":
 | 
			
		||||
    case "custom":
 | 
			
		||||
      const corners = getCorners(element);
 | 
			
		||||
      intersections = corners
 | 
			
		||||
        .flatMap((point, i) => {
 | 
			
		||||
@@ -619,7 +633,8 @@ const getCorners = (
 | 
			
		||||
    | ExcalidrawRectangleElement
 | 
			
		||||
    | ExcalidrawImageElement
 | 
			
		||||
    | ExcalidrawDiamondElement
 | 
			
		||||
    | ExcalidrawTextElement,
 | 
			
		||||
    | ExcalidrawTextElement
 | 
			
		||||
    | ExcalidrawCustomElement,
 | 
			
		||||
  scale: number = 1,
 | 
			
		||||
): GA.Point[] => {
 | 
			
		||||
  const hx = (scale * element.width) / 2;
 | 
			
		||||
@@ -628,6 +643,7 @@ const getCorners = (
 | 
			
		||||
    case "rectangle":
 | 
			
		||||
    case "image":
 | 
			
		||||
    case "text":
 | 
			
		||||
    case "custom":
 | 
			
		||||
      return [
 | 
			
		||||
        GA.point(hx, hy),
 | 
			
		||||
        GA.point(hx, -hy),
 | 
			
		||||
@@ -770,7 +786,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,6 +12,7 @@ import {
 | 
			
		||||
  ExcalidrawFreeDrawElement,
 | 
			
		||||
  FontFamilyValues,
 | 
			
		||||
  ExcalidrawRectangleElement,
 | 
			
		||||
  ExcalidrawCustomElement,
 | 
			
		||||
} from "../element/types";
 | 
			
		||||
import { getFontString, getUpdatedTimestamp, isTestEnv } from "../utils";
 | 
			
		||||
import { randomInteger, randomId } from "../random";
 | 
			
		||||
@@ -320,6 +321,17 @@ export const newImageElement = (
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const newCustomElement = (
 | 
			
		||||
  customType: string,
 | 
			
		||||
  opts: {
 | 
			
		||||
    type: ExcalidrawCustomElement["type"];
 | 
			
		||||
  } & ElementConstructorOpts,
 | 
			
		||||
): NonDeleted<ExcalidrawCustomElement> => {
 | 
			
		||||
  return {
 | 
			
		||||
    ..._newElementBase<ExcalidrawCustomElement>("custom", opts),
 | 
			
		||||
    customType,
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
// Simplified deep clone for the purpose of cloning ExcalidrawElement only
 | 
			
		||||
// (doesn't clone Date, RegExp, Map, Set, Typed arrays etc.)
 | 
			
		||||
//
 | 
			
		||||
 
 | 
			
		||||
@@ -10,6 +10,7 @@ import {
 | 
			
		||||
  ExcalidrawImageElement,
 | 
			
		||||
  ExcalidrawTextElementWithContainer,
 | 
			
		||||
  ExcalidrawTextContainer,
 | 
			
		||||
  ExcalidrawCustomElement,
 | 
			
		||||
} from "./types";
 | 
			
		||||
 | 
			
		||||
export const isGenericElement = (
 | 
			
		||||
@@ -142,3 +143,7 @@ export const isBoundToContainer = (
 | 
			
		||||
    element !== null && isTextElement(element) && element.containerId !== null
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const isCustomElement = (
 | 
			
		||||
  element: ExcalidrawElement,
 | 
			
		||||
): element is ExcalidrawCustomElement => element && element.type === "custom";
 | 
			
		||||
 
 | 
			
		||||
@@ -84,6 +84,9 @@ export type ExcalidrawImageElement = _ExcalidrawElementBase &
 | 
			
		||||
    scale: [number, number];
 | 
			
		||||
  }>;
 | 
			
		||||
 | 
			
		||||
export type ExcalidrawCustomElement = _ExcalidrawElementBase &
 | 
			
		||||
  Readonly<{ type: "custom"; customType: string }>;
 | 
			
		||||
 | 
			
		||||
export type InitializedExcalidrawImageElement = MarkNonNullable<
 | 
			
		||||
  ExcalidrawImageElement,
 | 
			
		||||
  "fileId"
 | 
			
		||||
@@ -108,7 +111,8 @@ export type ExcalidrawElement =
 | 
			
		||||
  | ExcalidrawTextElement
 | 
			
		||||
  | ExcalidrawLinearElement
 | 
			
		||||
  | ExcalidrawFreeDrawElement
 | 
			
		||||
  | ExcalidrawImageElement;
 | 
			
		||||
  | ExcalidrawImageElement
 | 
			
		||||
  | ExcalidrawCustomElement;
 | 
			
		||||
 | 
			
		||||
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
 | 
			
		||||
  isDeleted: boolean;
 | 
			
		||||
@@ -134,7 +138,8 @@ export type ExcalidrawBindableElement =
 | 
			
		||||
  | ExcalidrawDiamondElement
 | 
			
		||||
  | ExcalidrawEllipseElement
 | 
			
		||||
  | ExcalidrawTextElement
 | 
			
		||||
  | ExcalidrawImageElement;
 | 
			
		||||
  | ExcalidrawImageElement
 | 
			
		||||
  | ExcalidrawCustomElement;
 | 
			
		||||
 | 
			
		||||
export type ExcalidrawTextContainer =
 | 
			
		||||
  | ExcalidrawRectangleElement
 | 
			
		||||
 
 | 
			
		||||
@@ -8,7 +8,6 @@ import initialData from "./initialData";
 | 
			
		||||
 | 
			
		||||
// This is so that we use the bundled excalidraw.development.js file instead
 | 
			
		||||
// of the actual source code
 | 
			
		||||
 | 
			
		||||
const {
 | 
			
		||||
  exportToCanvas,
 | 
			
		||||
  exportToSvg,
 | 
			
		||||
@@ -16,8 +15,27 @@ const {
 | 
			
		||||
  exportToClipboard,
 | 
			
		||||
  Excalidraw,
 | 
			
		||||
  MIME_TYPES,
 | 
			
		||||
  sceneCoordsToViewportCoords,
 | 
			
		||||
} = window.ExcalidrawLib;
 | 
			
		||||
 | 
			
		||||
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 THUMBS_UP_SVG = (
 | 
			
		||||
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
 | 
			
		||||
    <path d="M96 191.1H32c-17.67 0-32 14.33-32 31.1v223.1c0 17.67 14.33 31.1 32 31.1h64c17.67 0 32-14.33 32-31.1V223.1C128 206.3 113.7 191.1 96 191.1zM512 227c0-36.89-30.05-66.92-66.97-66.92h-99.86C354.7 135.1 360 113.5 360 100.8c0-33.8-26.2-68.78-70.06-68.78c-46.61 0-59.36 32.44-69.61 58.5c-31.66 80.5-60.33 66.39-60.33 93.47c0 12.84 10.36 23.99 24.02 23.99c5.256 0 10.55-1.721 14.97-5.26c76.76-61.37 57.97-122.7 90.95-122.7c16.08 0 22.06 12.75 22.06 20.79c0 7.404-7.594 39.55-25.55 71.59c-2.046 3.646-3.066 7.686-3.066 11.72c0 13.92 11.43 23.1 24 23.1h137.6C455.5 208.1 464 216.6 464 227c0 9.809-7.766 18.03-17.67 18.71c-12.66 .8593-22.36 11.4-22.36 23.94c0 15.47 11.39 15.95 11.39 28.91c0 25.37-35.03 12.34-35.03 42.15c0 11.22 6.392 13.03 6.392 22.25c0 22.66-29.77 13.76-29.77 40.64c0 4.515 1.11 5.961 1.11 9.456c0 10.45-8.516 18.95-18.97 18.95h-52.53c-25.62 0-51.02-8.466-71.5-23.81l-36.66-27.51c-4.315-3.245-9.37-4.811-14.38-4.811c-13.85 0-24.03 11.38-24.03 24.04c0 7.287 3.312 14.42 9.596 19.13l36.67 27.52C235 468.1 270.6 480 306.6 480h52.53c35.33 0 64.36-27.49 66.8-62.2c17.77-12.23 28.83-32.51 28.83-54.83c0-3.046-.2187-6.107-.6406-9.122c17.84-12.15 29.28-32.58 29.28-55.28c0-5.311-.6406-10.54-1.875-15.64C499.9 270.1 512 250.2 512 227z" />
 | 
			
		||||
  </svg>
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const resolvablePromise = () => {
 | 
			
		||||
  let resolve;
 | 
			
		||||
  let reject;
 | 
			
		||||
@@ -53,7 +71,7 @@ const renderFooter = () => {
 | 
			
		||||
 | 
			
		||||
export default function App() {
 | 
			
		||||
  const excalidrawRef = useRef(null);
 | 
			
		||||
 | 
			
		||||
  const excalidrawWrapperRef = useRef(null);
 | 
			
		||||
  const [viewModeEnabled, setViewModeEnabled] = useState(false);
 | 
			
		||||
  const [zenModeEnabled, setZenModeEnabled] = useState(false);
 | 
			
		||||
  const [gridModeEnabled, setGridModeEnabled] = useState(false);
 | 
			
		||||
@@ -151,6 +169,135 @@ 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>
 | 
			
		||||
        <button
 | 
			
		||||
          className="custom-element"
 | 
			
		||||
          onClick={() => {
 | 
			
		||||
            excalidrawRef.current.setCustomType("thumbsup");
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          {THUMBS_UP_SVG}
 | 
			
		||||
        </button>
 | 
			
		||||
      </>
 | 
			
		||||
    );
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const onCreate = (element) => {
 | 
			
		||||
    setTimeout(() => addTextArea(element), 0);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const getCustomElementsConfig = () => {
 | 
			
		||||
    return {
 | 
			
		||||
      star: {
 | 
			
		||||
        type: "custom",
 | 
			
		||||
        customType: "star",
 | 
			
		||||
        displayData: {
 | 
			
		||||
          type: "svg",
 | 
			
		||||
          content: `<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,
 | 
			
		||||
        disableContextMenu: true,
 | 
			
		||||
      },
 | 
			
		||||
      comment: {
 | 
			
		||||
        type: "custom",
 | 
			
		||||
        customType: "comment",
 | 
			
		||||
        displayData: {
 | 
			
		||||
          type: "svg",
 | 
			
		||||
          content: () =>
 | 
			
		||||
            `<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>`,
 | 
			
		||||
        },
 | 
			
		||||
        transformHandles: false,
 | 
			
		||||
        stackedOnTop: true,
 | 
			
		||||
        onCreate,
 | 
			
		||||
        disableContextMenu: true,
 | 
			
		||||
      },
 | 
			
		||||
      thumbsup: {
 | 
			
		||||
        type: "custom",
 | 
			
		||||
        customType: "thumbsup",
 | 
			
		||||
        displayData: {
 | 
			
		||||
          type: "dataURL",
 | 
			
		||||
          content: () => {
 | 
			
		||||
            return new Promise((resolve, reject) => {
 | 
			
		||||
              const image = document.createElement("img");
 | 
			
		||||
              image.crossOrigin = "Anonymous";
 | 
			
		||||
              image.src =
 | 
			
		||||
                "https://upload.wikimedia.org/wikipedia/commons/1/1f/SMirC-thumbsup.svg";
 | 
			
		||||
 | 
			
		||||
              image.onload = function () {
 | 
			
		||||
                const canvas = document.createElement("canvas");
 | 
			
		||||
                canvas.width = 30 * window.devicePixelRatio;
 | 
			
		||||
                canvas.height = 30 * window.devicePixelRatio;
 | 
			
		||||
                const context = canvas.getContext("2d");
 | 
			
		||||
                context.scale(window.devicePixelRatio, window.devicePixelRatio);
 | 
			
		||||
                context.drawImage(image, 5, 5, 20, 20);
 | 
			
		||||
                resolve(canvas.toDataURL());
 | 
			
		||||
              };
 | 
			
		||||
              image.onerror = (err) => reject(err);
 | 
			
		||||
            });
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
    };
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const addTextArea = (element) => {
 | 
			
		||||
    const { x: viewPortX, y: viewPortY } = sceneCoordsToViewportCoords(
 | 
			
		||||
      {
 | 
			
		||||
        sceneX: element.x,
 | 
			
		||||
        sceneY: element.y,
 | 
			
		||||
      },
 | 
			
		||||
      excalidrawRef.current.getAppState(),
 | 
			
		||||
    );
 | 
			
		||||
    const textarea = document.createElement("textarea");
 | 
			
		||||
    Object.assign(textarea.style, {
 | 
			
		||||
      position: "absolute",
 | 
			
		||||
      display: "inline-block",
 | 
			
		||||
      left: `${viewPortX + element.width / 2}px`,
 | 
			
		||||
      top: `${viewPortY + element.height / 2}px`,
 | 
			
		||||
      height: `${100}px`,
 | 
			
		||||
      width: `${100}px`,
 | 
			
		||||
      zIndex: 10,
 | 
			
		||||
      className: "comment-textarea",
 | 
			
		||||
      whiteSpace: "pre-wrap",
 | 
			
		||||
      fontSize: "13px",
 | 
			
		||||
    });
 | 
			
		||||
    textarea.placeholder = "Start typing your comments";
 | 
			
		||||
 | 
			
		||||
    textarea.onblur = () => {
 | 
			
		||||
      textarea.remove();
 | 
			
		||||
    };
 | 
			
		||||
    excalidrawWrapperRef.current.querySelector(".excalidraw").append(textarea);
 | 
			
		||||
    textarea.focus();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const onElementClick = (element) => {
 | 
			
		||||
    if (element.type === "custom" && element.customType === "comment") {
 | 
			
		||||
      addTextArea(element);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
  const onCopy = async (type) => {
 | 
			
		||||
    await exportToClipboard({
 | 
			
		||||
      elements: excalidrawRef.current.getSceneElements(),
 | 
			
		||||
@@ -160,6 +307,7 @@ export default function App() {
 | 
			
		||||
    });
 | 
			
		||||
    window.alert(`Copied to clipboard as ${type} sucessfully`);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="App">
 | 
			
		||||
      <h1> Excalidraw Example</h1>
 | 
			
		||||
@@ -275,14 +423,14 @@ export default function App() {
 | 
			
		||||
            </button>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div className="excalidraw-wrapper">
 | 
			
		||||
        <div className="excalidraw-wrapper" ref={excalidrawWrapperRef}>
 | 
			
		||||
          <Excalidraw
 | 
			
		||||
            ref={excalidrawRef}
 | 
			
		||||
            initialData={initialStatePromiseRef.current.promise}
 | 
			
		||||
            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")
 | 
			
		||||
            }
 | 
			
		||||
@@ -295,6 +443,9 @@ export default function App() {
 | 
			
		||||
            renderTopRightUI={renderTopRightUI}
 | 
			
		||||
            renderFooter={renderFooter}
 | 
			
		||||
            onLinkOpen={onLinkOpen}
 | 
			
		||||
            renderCustomElementWidget={renderCustomElementWidget}
 | 
			
		||||
            customElementsConfig={getCustomElementsConfig()}
 | 
			
		||||
            onElementClick={onElementClick}
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -69,6 +69,32 @@ export default {
 | 
			
		||||
      status: "pending",
 | 
			
		||||
      scale: [1, 1],
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      id: "z35XgE9DvTXlG1OzXmp2x",
 | 
			
		||||
      type: "custom",
 | 
			
		||||
      x: 147.13928993437958,
 | 
			
		||||
      y: 328.8974609375,
 | 
			
		||||
      width: 60,
 | 
			
		||||
      height: 60,
 | 
			
		||||
      angle: 0,
 | 
			
		||||
      strokeColor: "#000000",
 | 
			
		||||
      backgroundColor: "transparent",
 | 
			
		||||
      fillStyle: "hachure",
 | 
			
		||||
      strokeWidth: 1,
 | 
			
		||||
      strokeStyle: "solid",
 | 
			
		||||
      roughness: 1,
 | 
			
		||||
      opacity: 100,
 | 
			
		||||
      groupIds: [],
 | 
			
		||||
      strokeSharpness: "round",
 | 
			
		||||
      seed: 1483808630,
 | 
			
		||||
      version: 79,
 | 
			
		||||
      versionNonce: 861014250,
 | 
			
		||||
      isDeleted: false,
 | 
			
		||||
      boundElements: null,
 | 
			
		||||
      updated: 1648630123004,
 | 
			
		||||
      link: null,
 | 
			
		||||
      customType: "star",
 | 
			
		||||
    },
 | 
			
		||||
  ],
 | 
			
		||||
  appState: { viewBackgroundColor: "#AFEEEE", currentItemFontFamily: 1 },
 | 
			
		||||
  scrollToContent: true,
 | 
			
		||||
 
 | 
			
		||||
@@ -8,7 +8,10 @@ import "../../css/styles.scss";
 | 
			
		||||
 | 
			
		||||
import { AppProps, ExcalidrawAPIRefValue, ExcalidrawProps } from "../../types";
 | 
			
		||||
import { defaultLang } from "../../i18n";
 | 
			
		||||
import { DEFAULT_UI_OPTIONS } from "../../constants";
 | 
			
		||||
import {
 | 
			
		||||
  DEFAULT_UI_OPTIONS,
 | 
			
		||||
  DEFAULT_CUSTOM_ELEMENT_CONFIG,
 | 
			
		||||
} from "../../constants";
 | 
			
		||||
import { Provider } from "jotai";
 | 
			
		||||
import { jotaiScope, jotaiStore } from "../../jotai";
 | 
			
		||||
 | 
			
		||||
@@ -37,6 +40,8 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
 | 
			
		||||
    autoFocus = false,
 | 
			
		||||
    generateIdForFile,
 | 
			
		||||
    onLinkOpen,
 | 
			
		||||
    renderCustomElementWidget,
 | 
			
		||||
    onElementClick,
 | 
			
		||||
  } = props;
 | 
			
		||||
 | 
			
		||||
  const canvasActions = props.UIOptions?.canvasActions;
 | 
			
		||||
@@ -47,6 +52,10 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
 | 
			
		||||
      ...canvasActions,
 | 
			
		||||
    },
 | 
			
		||||
  };
 | 
			
		||||
  const customElementsConfig = {} as AppProps["customElementsConfig"];
 | 
			
		||||
  Object.entries(props.customElementsConfig || {}).forEach(([key, value]) => {
 | 
			
		||||
    customElementsConfig![key] = { ...DEFAULT_CUSTOM_ELEMENT_CONFIG, ...value };
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  if (canvasActions?.export) {
 | 
			
		||||
    UIOptions.canvasActions.export.saveFileToDisk =
 | 
			
		||||
@@ -100,6 +109,9 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
 | 
			
		||||
          autoFocus={autoFocus}
 | 
			
		||||
          generateIdForFile={generateIdForFile}
 | 
			
		||||
          onLinkOpen={onLinkOpen}
 | 
			
		||||
          renderCustomElementWidget={renderCustomElementWidget}
 | 
			
		||||
          customElementsConfig={customElementsConfig}
 | 
			
		||||
          onElementClick={onElementClick}
 | 
			
		||||
        />
 | 
			
		||||
      </Provider>
 | 
			
		||||
    </InitializeApp>
 | 
			
		||||
@@ -209,3 +221,5 @@ export {
 | 
			
		||||
  newElementWith,
 | 
			
		||||
  bumpVersion,
 | 
			
		||||
} from "../../element/mutateElement";
 | 
			
		||||
 | 
			
		||||
export { sceneCoordsToViewportCoords } from "../../utils";
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,7 @@ const devServerConfig = {
 | 
			
		||||
  },
 | 
			
		||||
  // Server Configuration options
 | 
			
		||||
  devServer: {
 | 
			
		||||
    port: 3001,
 | 
			
		||||
    //port: 3001,
 | 
			
		||||
    host: "localhost",
 | 
			
		||||
    hot: true,
 | 
			
		||||
    compress: true,
 | 
			
		||||
 
 | 
			
		||||
@@ -6,6 +6,7 @@ import {
 | 
			
		||||
  NonDeletedExcalidrawElement,
 | 
			
		||||
  ExcalidrawFreeDrawElement,
 | 
			
		||||
  ExcalidrawImageElement,
 | 
			
		||||
  ExcalidrawCustomElement,
 | 
			
		||||
} from "../element/types";
 | 
			
		||||
import {
 | 
			
		||||
  isTextElement,
 | 
			
		||||
@@ -189,6 +190,9 @@ const drawImagePlaceholder = (
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const customElementImgCache: {
 | 
			
		||||
  [key: ExcalidrawCustomElement["customType"]]: HTMLImageElement;
 | 
			
		||||
} = {};
 | 
			
		||||
const drawElementOnCanvas = (
 | 
			
		||||
  element: NonDeletedExcalidrawElement,
 | 
			
		||||
  rc: RoughCanvas,
 | 
			
		||||
@@ -250,6 +254,54 @@ const drawElementOnCanvas = (
 | 
			
		||||
      }
 | 
			
		||||
      break;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    case "custom": {
 | 
			
		||||
      const config = renderConfig.customElementsConfig?.[element.customType];
 | 
			
		||||
 | 
			
		||||
      if (!config) {
 | 
			
		||||
        break;
 | 
			
		||||
      }
 | 
			
		||||
      const cacheImage = (data: string, type: "svg" | "dataURL") => {
 | 
			
		||||
        if (!customElementImgCache[element.id]) {
 | 
			
		||||
          let url: string;
 | 
			
		||||
          if (type === "svg") {
 | 
			
		||||
            url = `data:${MIME_TYPES.svg}, ${encodeURIComponent(data)}`;
 | 
			
		||||
          } else {
 | 
			
		||||
            url = data;
 | 
			
		||||
          }
 | 
			
		||||
          const img = document.createElement("img");
 | 
			
		||||
          img.src = url;
 | 
			
		||||
          img.id = element.id;
 | 
			
		||||
          customElementImgCache[element.id] = img;
 | 
			
		||||
        }
 | 
			
		||||
      };
 | 
			
		||||
      const { type, content } = config.displayData;
 | 
			
		||||
      if (typeof content === "string") {
 | 
			
		||||
        cacheImage(content, type);
 | 
			
		||||
      } else {
 | 
			
		||||
        const contentData = content(element);
 | 
			
		||||
        if (contentData instanceof Promise) {
 | 
			
		||||
          contentData.then(
 | 
			
		||||
            (res) => {
 | 
			
		||||
              cacheImage(res, type);
 | 
			
		||||
            },
 | 
			
		||||
            (err) => console.error(err),
 | 
			
		||||
          );
 | 
			
		||||
        } else {
 | 
			
		||||
          cacheImage(contentData, type);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      if (customElementImgCache[element.id]) {
 | 
			
		||||
        context.drawImage(
 | 
			
		||||
          customElementImgCache[element.id],
 | 
			
		||||
          0,
 | 
			
		||||
          0,
 | 
			
		||||
          element.width,
 | 
			
		||||
          element.height,
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
      break;
 | 
			
		||||
    }
 | 
			
		||||
    default: {
 | 
			
		||||
      if (isTextElement(element)) {
 | 
			
		||||
        const rtl = isRTL(element.text);
 | 
			
		||||
@@ -779,7 +831,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 +862,7 @@ export const renderElement = (
 | 
			
		||||
      }
 | 
			
		||||
      break;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    default: {
 | 
			
		||||
      // @ts-ignore
 | 
			
		||||
      throw new Error(`Unimplemented type ${element.type}`);
 | 
			
		||||
 
 | 
			
		||||
@@ -190,7 +190,6 @@ export const renderScene = (
 | 
			
		||||
  if (canvas === null) {
 | 
			
		||||
    return { atLeastOneVisibleElement: false };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const {
 | 
			
		||||
    renderScrollbars = true,
 | 
			
		||||
    renderSelection = true,
 | 
			
		||||
@@ -305,24 +304,32 @@ export const renderScene = (
 | 
			
		||||
    !appState.editingLinearElement
 | 
			
		||||
  ) {
 | 
			
		||||
    const selections = elements.reduce((acc, element) => {
 | 
			
		||||
      const isCustom = element.type === "custom";
 | 
			
		||||
      let config;
 | 
			
		||||
      const selectionColors = [];
 | 
			
		||||
      // local user
 | 
			
		||||
      if (
 | 
			
		||||
        appState.selectedElementIds[element.id] &&
 | 
			
		||||
        !isSelectedViaGroup(appState, element)
 | 
			
		||||
      ) {
 | 
			
		||||
        selectionColors.push(oc.black);
 | 
			
		||||
 | 
			
		||||
      if (element.type === "custom") {
 | 
			
		||||
        config = renderConfig.customElementsConfig?.[element.customType];
 | 
			
		||||
      }
 | 
			
		||||
      // remote users
 | 
			
		||||
      if (renderConfig.remoteSelectedElementIds[element.id]) {
 | 
			
		||||
        selectionColors.push(
 | 
			
		||||
          ...renderConfig.remoteSelectedElementIds[element.id].map(
 | 
			
		||||
            (socketId) => {
 | 
			
		||||
              const { background } = getClientColors(socketId, appState);
 | 
			
		||||
              return background;
 | 
			
		||||
            },
 | 
			
		||||
          ),
 | 
			
		||||
        );
 | 
			
		||||
      if (!isCustom || (isCustom && config && config.transformHandles)) {
 | 
			
		||||
        // local user
 | 
			
		||||
        if (
 | 
			
		||||
          appState.selectedElementIds[element.id] &&
 | 
			
		||||
          !isSelectedViaGroup(appState, element)
 | 
			
		||||
        ) {
 | 
			
		||||
          selectionColors.push(oc.black);
 | 
			
		||||
        }
 | 
			
		||||
        // remote users
 | 
			
		||||
        if (renderConfig.remoteSelectedElementIds[element.id]) {
 | 
			
		||||
          selectionColors.push(
 | 
			
		||||
            ...renderConfig.remoteSelectedElementIds[element.id].map(
 | 
			
		||||
              (socketId) => {
 | 
			
		||||
                const { background } = getClientColors(socketId, appState);
 | 
			
		||||
                return background;
 | 
			
		||||
              },
 | 
			
		||||
            ),
 | 
			
		||||
          );
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      if (selectionColors.length) {
 | 
			
		||||
        const [elementX1, elementY1, elementX2, elementY2] =
 | 
			
		||||
@@ -352,7 +359,6 @@ export const renderScene = (
 | 
			
		||||
        selectionColors: [oc.black],
 | 
			
		||||
      });
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    for (const groupId of getSelectedGroupIds(appState)) {
 | 
			
		||||
      // TODO: support multiplayer selected group IDs
 | 
			
		||||
      addSelectionForGroupId(groupId);
 | 
			
		||||
@@ -372,19 +378,33 @@ export const renderScene = (
 | 
			
		||||
    context.save();
 | 
			
		||||
    context.translate(renderConfig.scrollX, renderConfig.scrollY);
 | 
			
		||||
    if (locallySelectedElements.length === 1) {
 | 
			
		||||
      context.fillStyle = oc.white;
 | 
			
		||||
      const transformHandles = getTransformHandles(
 | 
			
		||||
        locallySelectedElements[0],
 | 
			
		||||
        renderConfig.zoom,
 | 
			
		||||
        "mouse", // when we render we don't know which pointer type so use mouse
 | 
			
		||||
      );
 | 
			
		||||
      if (!appState.viewModeEnabled) {
 | 
			
		||||
        renderTransformHandles(
 | 
			
		||||
          context,
 | 
			
		||||
          renderConfig,
 | 
			
		||||
          transformHandles,
 | 
			
		||||
          locallySelectedElements[0].angle,
 | 
			
		||||
      let showTransformHandles = true;
 | 
			
		||||
      if (locallySelectedElements[0].type === "custom") {
 | 
			
		||||
        const config =
 | 
			
		||||
          renderConfig.customElementsConfig?.[
 | 
			
		||||
            locallySelectedElements[0].customType
 | 
			
		||||
          ];
 | 
			
		||||
 | 
			
		||||
        if (!config || !config.transformHandles) {
 | 
			
		||||
          showTransformHandles = false;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      if (showTransformHandles) {
 | 
			
		||||
        context.fillStyle = oc.white;
 | 
			
		||||
        const transformHandles = getTransformHandles(
 | 
			
		||||
          locallySelectedElements[0],
 | 
			
		||||
          renderConfig.zoom,
 | 
			
		||||
          "mouse", // when we render we don't know which pointer type so use mouse
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        if (!appState.viewModeEnabled) {
 | 
			
		||||
          renderTransformHandles(
 | 
			
		||||
            context,
 | 
			
		||||
            renderConfig,
 | 
			
		||||
            transformHandles,
 | 
			
		||||
            locallySelectedElements[0].angle,
 | 
			
		||||
          );
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    } else if (locallySelectedElements.length > 1 && !appState.isRotating) {
 | 
			
		||||
      const dashedLinePadding = 4 / renderConfig.zoom.value;
 | 
			
		||||
@@ -573,6 +593,7 @@ const renderTransformHandles = (
 | 
			
		||||
  renderConfig: RenderConfig,
 | 
			
		||||
  transformHandles: TransformHandles,
 | 
			
		||||
  angle: number,
 | 
			
		||||
  name?: string,
 | 
			
		||||
): void => {
 | 
			
		||||
  Object.keys(transformHandles).forEach((key) => {
 | 
			
		||||
    const transformHandle = transformHandles[key as TransformHandleType];
 | 
			
		||||
 
 | 
			
		||||
@@ -5,6 +5,8 @@ import {
 | 
			
		||||
} from "../element/types";
 | 
			
		||||
import { getNonDeletedElements, isNonDeletedElement } from "../element";
 | 
			
		||||
import { LinearElementEditor } from "../element/linearElementEditor";
 | 
			
		||||
import App from "../components/App";
 | 
			
		||||
import { isCustomElement } from "../element/typeChecks";
 | 
			
		||||
 | 
			
		||||
type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"];
 | 
			
		||||
type ElementKey = ExcalidrawElement | ElementIdKey;
 | 
			
		||||
@@ -26,7 +28,11 @@ class Scene {
 | 
			
		||||
 | 
			
		||||
  private static sceneMapByElement = new WeakMap<ExcalidrawElement, Scene>();
 | 
			
		||||
  private static sceneMapById = new Map<string, Scene>();
 | 
			
		||||
  private app: App;
 | 
			
		||||
 | 
			
		||||
  constructor(app: App) {
 | 
			
		||||
    this.app = app;
 | 
			
		||||
  }
 | 
			
		||||
  static mapElementToScene(elementKey: ElementKey, scene: Scene) {
 | 
			
		||||
    if (isIdKey(elementKey)) {
 | 
			
		||||
      this.sceneMapById.set(elementKey, scene);
 | 
			
		||||
@@ -91,12 +97,28 @@ class Scene {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  replaceAllElements(nextElements: readonly ExcalidrawElement[]) {
 | 
			
		||||
    this.elements = nextElements;
 | 
			
		||||
    this.elements = [];
 | 
			
		||||
    const elements: ExcalidrawElement[] = [];
 | 
			
		||||
    this.elementsMap.clear();
 | 
			
		||||
    const elementsToBeStackedOnTop: ExcalidrawElement[] = [];
 | 
			
		||||
    nextElements.forEach((element) => {
 | 
			
		||||
      if (isCustomElement(element)) {
 | 
			
		||||
        const config =
 | 
			
		||||
          this.app.props.customElementsConfig?.[element.customType];
 | 
			
		||||
 | 
			
		||||
        if (config?.stackedOnTop) {
 | 
			
		||||
          elementsToBeStackedOnTop.push(element);
 | 
			
		||||
        } else {
 | 
			
		||||
          elements.push(element);
 | 
			
		||||
        }
 | 
			
		||||
      } else {
 | 
			
		||||
        elements.push(element);
 | 
			
		||||
      }
 | 
			
		||||
      this.elementsMap.set(element.id, element);
 | 
			
		||||
      Scene.mapElementToScene(element, this);
 | 
			
		||||
    });
 | 
			
		||||
    elementsToBeStackedOnTop.forEach((ele) => elements.push(ele));
 | 
			
		||||
    this.elements = elements;
 | 
			
		||||
    this.nonDeletedElements = getNonDeletedElements(this.elements);
 | 
			
		||||
    this.informMutation();
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -6,6 +6,7 @@ import {
 | 
			
		||||
 | 
			
		||||
import { getElementAbsoluteCoords } from "../element";
 | 
			
		||||
import { isTextBindableContainer } from "../element/typeChecks";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
 | 
			
		||||
export const hasBackground = (type: string) =>
 | 
			
		||||
  type === "rectangle" ||
 | 
			
		||||
@@ -31,7 +32,7 @@ export const hasStrokeStyle = (type: string) =>
 | 
			
		||||
  type === "arrow" ||
 | 
			
		||||
  type === "line";
 | 
			
		||||
 | 
			
		||||
export const canChangeSharpness = (type: string) =>
 | 
			
		||||
export const canChangeSharpness = (type: AppState["activeTool"]["type"]) =>
 | 
			
		||||
  type === "rectangle" ||
 | 
			
		||||
  type === "arrow" ||
 | 
			
		||||
  type === "line" ||
 | 
			
		||||
 
 | 
			
		||||
@@ -3,20 +3,25 @@ import {
 | 
			
		||||
  NonDeletedExcalidrawElement,
 | 
			
		||||
} from "../element/types";
 | 
			
		||||
import { getElementAbsoluteCoords, getElementBounds } from "../element";
 | 
			
		||||
import { AppState } from "../types";
 | 
			
		||||
import { AppProps, AppState } from "../types";
 | 
			
		||||
import { isBoundToContainer } from "../element/typeChecks";
 | 
			
		||||
 | 
			
		||||
export const getElementsWithinSelection = (
 | 
			
		||||
  elements: readonly NonDeletedExcalidrawElement[],
 | 
			
		||||
  selection: NonDeletedExcalidrawElement,
 | 
			
		||||
  customElementConfig: AppProps["customElementsConfig"],
 | 
			
		||||
) => {
 | 
			
		||||
  const [selectionX1, selectionY1, selectionX2, selectionY2] =
 | 
			
		||||
    getElementAbsoluteCoords(selection);
 | 
			
		||||
  return elements.filter((element) => {
 | 
			
		||||
    const [elementX1, elementY1, elementX2, elementY2] =
 | 
			
		||||
      getElementBounds(element);
 | 
			
		||||
 | 
			
		||||
    const isCustom = element.type === "custom";
 | 
			
		||||
    const allowSelection = isCustom
 | 
			
		||||
      ? customElementConfig?.[element.customType]?.transformHandles
 | 
			
		||||
      : true;
 | 
			
		||||
    return (
 | 
			
		||||
      allowSelection &&
 | 
			
		||||
      element.locked === false &&
 | 
			
		||||
      element.type !== "selection" &&
 | 
			
		||||
      !isBoundToContainer(element) &&
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
import { ExcalidrawTextElement } from "../element/types";
 | 
			
		||||
import { AppClassProperties, AppState } from "../types";
 | 
			
		||||
import { AppClassProperties, AppProps, AppState } 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?: AppProps["customElementsConfig"];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type SceneScroll = {
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										49
									
								
								src/types.ts
									
									
									
									
									
								
							
							
						
						
									
										49
									
								
								src/types.ts
									
									
									
									
									
								
							@@ -66,6 +66,14 @@ export type BinaryFileMetadata = Omit<BinaryFileData, "dataURL">;
 | 
			
		||||
 | 
			
		||||
export type BinaryFiles = Record<ExcalidrawElement["id"], BinaryFileData>;
 | 
			
		||||
 | 
			
		||||
export type LastActiveToolBeforeEraser =
 | 
			
		||||
  | typeof SHAPES[number]["value"]
 | 
			
		||||
  | {
 | 
			
		||||
      type: "custom";
 | 
			
		||||
      customType: string;
 | 
			
		||||
    }
 | 
			
		||||
  | null;
 | 
			
		||||
 | 
			
		||||
export type AppState = {
 | 
			
		||||
  isLoading: boolean;
 | 
			
		||||
  errorMessage: string | null;
 | 
			
		||||
@@ -80,11 +88,18 @@ export type AppState = {
 | 
			
		||||
  // (e.g. text element when typing into the input)
 | 
			
		||||
  editingElement: NonDeletedExcalidrawElement | null;
 | 
			
		||||
  editingLinearElement: LinearElementEditor | null;
 | 
			
		||||
  activeTool: {
 | 
			
		||||
    type: typeof SHAPES[number]["value"] | "eraser";
 | 
			
		||||
    lastActiveToolBeforeEraser: typeof SHAPES[number]["value"] | null;
 | 
			
		||||
    locked: boolean;
 | 
			
		||||
  };
 | 
			
		||||
  activeTool:
 | 
			
		||||
    | {
 | 
			
		||||
        type: typeof SHAPES[number]["value"] | "eraser";
 | 
			
		||||
        lastActiveToolBeforeEraser: LastActiveToolBeforeEraser;
 | 
			
		||||
        locked: boolean;
 | 
			
		||||
      }
 | 
			
		||||
    | {
 | 
			
		||||
        type: "custom";
 | 
			
		||||
        customType: string;
 | 
			
		||||
        lastActiveToolBeforeEraser: LastActiveToolBeforeEraser;
 | 
			
		||||
        locked: boolean;
 | 
			
		||||
      };
 | 
			
		||||
  penMode: boolean;
 | 
			
		||||
  penDetected: boolean;
 | 
			
		||||
  exportBackground: boolean;
 | 
			
		||||
@@ -212,6 +227,22 @@ export type ExcalidrawAPIRefValue =
 | 
			
		||||
      ready?: false;
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
export type CustomElementConfig = {
 | 
			
		||||
  type: "custom";
 | 
			
		||||
  customType: string;
 | 
			
		||||
  transformHandles?: boolean;
 | 
			
		||||
  displayData: {
 | 
			
		||||
    type: "svg" | "dataURL";
 | 
			
		||||
    content:
 | 
			
		||||
      | string
 | 
			
		||||
      | ((element?: ExcalidrawElement) => string | Promise<string>);
 | 
			
		||||
  };
 | 
			
		||||
  width?: number;
 | 
			
		||||
  height?: number;
 | 
			
		||||
  stackedOnTop: boolean;
 | 
			
		||||
  onCreate?: (element: ExcalidrawElement) => void;
 | 
			
		||||
  disableContextMenu: boolean;
 | 
			
		||||
};
 | 
			
		||||
export type ExcalidrawInitialDataState = Merge<
 | 
			
		||||
  ImportedDataState,
 | 
			
		||||
  {
 | 
			
		||||
@@ -271,6 +302,12 @@ export interface ExcalidrawProps {
 | 
			
		||||
      nativeEvent: MouseEvent | React.PointerEvent<HTMLCanvasElement>;
 | 
			
		||||
    }>,
 | 
			
		||||
  ) => void;
 | 
			
		||||
  renderCustomElementWidget?: (appState: AppState) => void;
 | 
			
		||||
  customElementsConfig?: Record<string, CustomElementConfig>;
 | 
			
		||||
  onElementClick?: (
 | 
			
		||||
    element: NonDeleted<ExcalidrawElement>,
 | 
			
		||||
    event: React.PointerEvent<HTMLCanvasElement>,
 | 
			
		||||
  ) => void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type SceneData = {
 | 
			
		||||
@@ -324,6 +361,7 @@ export type AppProps = ExcalidrawProps & {
 | 
			
		||||
  detectScroll: boolean;
 | 
			
		||||
  handleKeyboardGlobally: boolean;
 | 
			
		||||
  isCollaborating: boolean;
 | 
			
		||||
  customElementsConfig: Required<CustomElementConfig>[] | undefined;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/** A subset of App class properties that we need to use elsewhere
 | 
			
		||||
@@ -431,6 +469,7 @@ export type ExcalidrawImperativeAPI = {
 | 
			
		||||
  readyPromise: ResolvablePromise<ExcalidrawImperativeAPI>;
 | 
			
		||||
  ready: true;
 | 
			
		||||
  id: string;
 | 
			
		||||
  setCustomType: InstanceType<typeof App>["setCustomType"];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type DeviceType = {
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user