summaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/components/App.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'packages/excalidraw/components/App.tsx')
-rw-r--r--packages/excalidraw/components/App.tsx11180
1 files changed, 11180 insertions, 0 deletions
diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx
new file mode 100644
index 0000000..dc6d287
--- /dev/null
+++ b/packages/excalidraw/components/App.tsx
@@ -0,0 +1,11180 @@
+import React, { useContext } from "react";
+import { flushSync } from "react-dom";
+
+import type { RoughCanvas } from "roughjs/bin/canvas";
+import rough from "roughjs/bin/rough";
+import clsx from "clsx";
+import { nanoid } from "nanoid";
+import {
+ actionAddToLibrary,
+ actionBringForward,
+ actionBringToFront,
+ actionCopy,
+ actionCopyAsPng,
+ actionCopyAsSvg,
+ copyText,
+ actionCopyStyles,
+ actionCut,
+ actionDeleteSelected,
+ actionDuplicateSelection,
+ actionFinalize,
+ actionFlipHorizontal,
+ actionFlipVertical,
+ actionGroup,
+ actionPasteStyles,
+ actionSelectAll,
+ actionSendBackward,
+ actionSendToBack,
+ actionToggleGridMode,
+ actionToggleStats,
+ actionToggleZenMode,
+ actionUnbindText,
+ actionBindText,
+ actionUngroup,
+ actionLink,
+ actionToggleElementLock,
+ actionToggleLinearEditor,
+ actionToggleObjectsSnapMode,
+ actionToggleCropEditor,
+} from "../actions";
+import { createRedoAction, createUndoAction } from "../actions/actionHistory";
+import { ActionManager } from "../actions/manager";
+import { actions } from "../actions/register";
+import type { Action, ActionResult } from "../actions/types";
+import { trackEvent } from "../analytics";
+import {
+ getDefaultAppState,
+ isEraserActive,
+ isHandToolActive,
+} from "../appState";
+import type { PastedMixedContent } from "../clipboard";
+import { copyTextToSystemClipboard, parseClipboard } from "../clipboard";
+import {
+ APP_NAME,
+ CURSOR_TYPE,
+ DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT,
+ DEFAULT_VERTICAL_ALIGN,
+ DRAGGING_THRESHOLD,
+ ELEMENT_SHIFT_TRANSLATE_AMOUNT,
+ ELEMENT_TRANSLATE_AMOUNT,
+ ENV,
+ EVENT,
+ FRAME_STYLE,
+ IMAGE_MIME_TYPES,
+ IMAGE_RENDER_TIMEOUT,
+ isBrave,
+ LINE_CONFIRM_THRESHOLD,
+ MAX_ALLOWED_FILE_BYTES,
+ MIME_TYPES,
+ MQ_MAX_HEIGHT_LANDSCAPE,
+ MQ_MAX_WIDTH_LANDSCAPE,
+ MQ_MAX_WIDTH_PORTRAIT,
+ MQ_RIGHT_SIDEBAR_MIN_WIDTH,
+ POINTER_BUTTON,
+ ROUNDNESS,
+ SCROLL_TIMEOUT,
+ TAP_TWICE_TIMEOUT,
+ TEXT_TO_CENTER_SNAP_THRESHOLD,
+ THEME,
+ THEME_FILTER,
+ TOUCH_CTX_MENU_TIMEOUT,
+ VERTICAL_ALIGN,
+ YOUTUBE_STATES,
+ ZOOM_STEP,
+ POINTER_EVENTS,
+ TOOL_TYPE,
+ isIOS,
+ supportsResizeObserver,
+ DEFAULT_COLLISION_THRESHOLD,
+ DEFAULT_TEXT_ALIGN,
+ ARROW_TYPE,
+ DEFAULT_REDUCED_GLOBAL_ALPHA,
+ isSafari,
+ type EXPORT_IMAGE_TYPES,
+} from "../constants";
+import type { ExportedElements } from "../data";
+import { exportCanvas, loadFromBlob } from "../data";
+import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
+import { restore, restoreElements } from "../data/restore";
+import {
+ dragNewElement,
+ dragSelectedElements,
+ duplicateElement,
+ getCommonBounds,
+ getCursorForResizingElement,
+ getDragOffsetXY,
+ getElementWithTransformHandleType,
+ getNormalizedDimensions,
+ getResizeArrowDirection,
+ getResizeOffsetXY,
+ getLockedLinearCursorAlignSize,
+ getTransformHandleTypeFromCoords,
+ isInvisiblySmallElement,
+ isNonDeletedElement,
+ isTextElement,
+ newElement,
+ newLinearElement,
+ newTextElement,
+ newImageElement,
+ transformElements,
+ refreshTextDimensions,
+ redrawTextBoundingBox,
+ getElementAbsoluteCoords,
+} from "../element";
+import {
+ bindOrUnbindLinearElement,
+ bindOrUnbindLinearElements,
+ fixBindingsAfterDeletion,
+ fixBindingsAfterDuplication,
+ getHoveredElementForBinding,
+ isBindingEnabled,
+ isLinearElementSimpleAndAlreadyBound,
+ maybeBindLinearElement,
+ shouldEnableBindingForPointerEvent,
+ updateBoundElements,
+ getSuggestedBindingsForArrows,
+} from "../element/binding";
+import { LinearElementEditor } from "../element/linearElementEditor";
+import { mutateElement, newElementWith } from "../element/mutateElement";
+import {
+ deepCopyElement,
+ duplicateElements,
+ newFrameElement,
+ newFreeDrawElement,
+ newEmbeddableElement,
+ newMagicFrameElement,
+ newIframeElement,
+ newArrowElement,
+} from "../element/newElement";
+import {
+ hasBoundTextElement,
+ isArrowElement,
+ isBindingElement,
+ isBindingElementType,
+ isBoundToContainer,
+ isFrameLikeElement,
+ isImageElement,
+ isEmbeddableElement,
+ isInitializedImageElement,
+ isLinearElement,
+ isLinearElementType,
+ isUsingAdaptiveRadius,
+ isIframeElement,
+ isIframeLikeElement,
+ isMagicFrameElement,
+ isTextBindableContainer,
+ isElbowArrow,
+ isFlowchartNodeElement,
+ isBindableElement,
+} from "../element/typeChecks";
+import type {
+ ExcalidrawBindableElement,
+ ExcalidrawElement,
+ ExcalidrawFreeDrawElement,
+ ExcalidrawGenericElement,
+ ExcalidrawLinearElement,
+ ExcalidrawTextElement,
+ NonDeleted,
+ InitializedExcalidrawImageElement,
+ ExcalidrawImageElement,
+ FileId,
+ NonDeletedExcalidrawElement,
+ ExcalidrawTextContainer,
+ ExcalidrawFrameLikeElement,
+ ExcalidrawMagicFrameElement,
+ ExcalidrawIframeLikeElement,
+ IframeData,
+ ExcalidrawIframeElement,
+ ExcalidrawEmbeddableElement,
+ Ordered,
+ MagicGenerationData,
+ ExcalidrawNonSelectionElement,
+ ExcalidrawArrowElement,
+} from "../element/types";
+import { getCenter, getDistance } from "../gesture";
+import {
+ editGroupForSelectedElement,
+ getElementsInGroup,
+ getSelectedGroupIdForElement,
+ getSelectedGroupIds,
+ isElementInGroup,
+ isSelectedViaGroup,
+ selectGroupsForSelectedElements,
+} from "../groups";
+import { History } from "../history";
+import { defaultLang, getLanguage, languages, setLanguage, t } from "../i18n";
+import {
+ CODES,
+ shouldResizeFromCenter,
+ shouldMaintainAspectRatio,
+ shouldRotateWithDiscreteAngle,
+ isArrowKey,
+ KEYS,
+} from "../keys";
+import {
+ isElementCompletelyInViewport,
+ isElementInViewport,
+} from "../element/sizeHelpers";
+import {
+ calculateScrollCenter,
+ getElementsWithinSelection,
+ getNormalizedZoom,
+ getSelectedElements,
+ hasBackground,
+ isSomeElementSelected,
+} from "../scene";
+import Scene from "../scene/Scene";
+import type {
+ RenderInteractiveSceneCallback,
+ ScrollBars,
+} from "../scene/types";
+import { getStateForZoom } from "../scene/zoom";
+import {
+ findShapeByKey,
+ getBoundTextShape,
+ getCornerRadius,
+ getElementShape,
+ isPathALoop,
+} from "../shapes";
+import { getSelectionBoxShape } from "@excalidraw/utils/geometry/shape";
+import { isPointInShape } from "@excalidraw/utils/collision";
+import type {
+ AppClassProperties,
+ AppProps,
+ AppState,
+ BinaryFileData,
+ DataURL,
+ ExcalidrawImperativeAPI,
+ BinaryFiles,
+ Gesture,
+ GestureEvent,
+ LibraryItems,
+ PointerDownState,
+ SceneData,
+ Device,
+ FrameNameBoundsCache,
+ SidebarName,
+ SidebarTabName,
+ KeyboardModifiersObject,
+ CollaboratorPointer,
+ ToolType,
+ OnUserFollowedPayload,
+ UnsubscribeCallback,
+ EmbedsValidationStatus,
+ ElementsPendingErasure,
+ GenerateDiagramToCode,
+ NullableGridSize,
+ Offsets,
+} from "../types";
+import {
+ debounce,
+ distance,
+ getFontString,
+ getNearestScrollableContainer,
+ isInputLike,
+ isToolIcon,
+ isWritableElement,
+ sceneCoordsToViewportCoords,
+ tupleToCoors,
+ viewportCoordsToSceneCoords,
+ wrapEvent,
+ updateObject,
+ updateActiveTool,
+ getShortcutKey,
+ isTransparent,
+ easeToValuesRAF,
+ muteFSAbortError,
+ isTestEnv,
+ easeOut,
+ updateStable,
+ addEventListener,
+ normalizeEOL,
+ getDateTime,
+ isShallowEqual,
+ arrayToMap,
+} from "../utils";
+import {
+ createSrcDoc,
+ embeddableURLValidator,
+ maybeParseEmbedSrc,
+ getEmbedLink,
+} from "../element/embeddable";
+import type { ContextMenuItems } from "./ContextMenu";
+import { ContextMenu, CONTEXT_MENU_SEPARATOR } from "./ContextMenu";
+import LayerUI from "./LayerUI";
+import { Toast } from "./Toast";
+import { actionToggleViewMode } from "../actions/actionToggleViewMode";
+import {
+ dataURLToFile,
+ dataURLToString,
+ generateIdFromFile,
+ getDataURL,
+ getDataURL_sync,
+ getFileFromEvent,
+ ImageURLToFile,
+ isImageFileHandle,
+ isSupportedImageFile,
+ loadSceneOrLibraryFromBlob,
+ normalizeFile,
+ parseLibraryJSON,
+ resizeImageFile,
+ SVGStringToFile,
+} from "../data/blob";
+import {
+ getInitializedImageElements,
+ loadHTMLImageElement,
+ normalizeSVG,
+ updateImageCache as _updateImageCache,
+} from "../element/image";
+import throttle from "lodash.throttle";
+import type { FileSystemHandle } from "../data/filesystem";
+import { fileOpen } from "../data/filesystem";
+import {
+ bindTextToShapeAfterDuplication,
+ getBoundTextElement,
+ getContainerCenter,
+ getContainerElement,
+ isValidTextContainer,
+} from "../element/textElement";
+import {
+ showHyperlinkTooltip,
+ hideHyperlinkToolip,
+ Hyperlink,
+} from "../components/hyperlink/Hyperlink";
+import { isLocalLink, normalizeLink, toValidURL } from "../data/url";
+import { shouldShowBoundingBox } from "../element/transformHandles";
+import { actionUnlockAllElements } from "../actions/actionElementLock";
+import { Fonts, getLineHeight } from "../fonts";
+import {
+ getFrameChildren,
+ isCursorInFrame,
+ bindElementsToFramesAfterDuplication,
+ addElementsToFrame,
+ replaceAllElementsInFrame,
+ removeElementsFromFrame,
+ getElementsInResizingFrame,
+ getElementsInNewFrame,
+ getContainingFrame,
+ elementOverlapsWithFrame,
+ updateFrameMembershipOfSelectedElements,
+ isElementInFrame,
+ getFrameLikeTitle,
+ getElementsOverlappingFrame,
+ filterElementsEligibleAsFrameChildren,
+} from "../frame";
+import {
+ excludeElementsInFramesFromSelection,
+ makeNextSelectedElementIds,
+} from "../scene/selection";
+import { actionPaste } from "../actions/actionClipboard";
+import {
+ actionRemoveAllElementsFromFrame,
+ actionSelectAllElementsInFrame,
+ actionWrapSelectionInFrame,
+} from "../actions/actionFrame";
+import { actionToggleHandTool, zoomToFit } from "../actions/actionCanvas";
+import { editorJotaiStore } from "../editor-jotai";
+import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
+import { ImageSceneDataError } from "../errors";
+import {
+ getSnapLinesAtPointer,
+ snapDraggedElements,
+ isActiveToolNonLinearSnappable,
+ snapNewElement,
+ snapResizingElements,
+ isSnappingEnabled,
+ getVisibleGaps,
+ getReferenceSnapPoints,
+ SnapCache,
+ isGridModeEnabled,
+ getGridPoint,
+} from "../snapping";
+import { actionWrapTextInContainer } from "../actions/actionBoundText";
+import BraveMeasureTextError from "./BraveMeasureTextError";
+import { activeEyeDropperAtom } from "./EyeDropper";
+import type { ExcalidrawElementSkeleton } from "../data/transform";
+import { convertToExcalidrawElements } from "../data/transform";
+import type { ValueOf } from "../utility-types";
+import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
+import { StaticCanvas, InteractiveCanvas } from "./canvases";
+import { Renderer } from "../scene/Renderer";
+import { ShapeCache } from "../scene/ShapeCache";
+import { SVGLayer } from "./SVGLayer";
+import {
+ setEraserCursor,
+ setCursor,
+ resetCursor,
+ setCursorForShape,
+} from "../cursor";
+import { Emitter } from "../emitter";
+import { ElementCanvasButtons } from "../element/ElementCanvasButtons";
+import { COLOR_PALETTE } from "../colors";
+import { ElementCanvasButton } from "./MagicButton";
+import { MagicIcon, copyIcon, fullscreenIcon } from "./icons";
+import FollowMode from "./FollowMode/FollowMode";
+import { Store, CaptureUpdateAction } from "../store";
+import { AnimationFrameHandler } from "../animation-frame-handler";
+import { AnimatedTrail } from "../animated-trail";
+import { LaserTrails } from "../laser-trails";
+import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils";
+import { getRenderOpacity } from "../renderer/renderElement";
+import {
+ hitElementBoundText,
+ hitElementBoundingBoxOnly,
+ hitElementItself,
+} from "../element/collision";
+import { textWysiwyg } from "../element/textWysiwyg";
+import { isOverScrollBars } from "../scene/scrollbars";
+import { syncInvalidIndices, syncMovedIndices } from "../fractionalIndex";
+import {
+ isPointHittingLink,
+ isPointHittingLinkIcon,
+} from "./hyperlink/helpers";
+import { getShortcutFromShortcutName } from "../actions/shortcuts";
+import { actionTextAutoResize } from "../actions/actionTextAutoResize";
+import { getVisibleSceneBounds } from "../element/bounds";
+import { isMaybeMermaidDefinition } from "../mermaid";
+import NewElementCanvas from "./canvases/NewElementCanvas";
+import {
+ FlowChartCreator,
+ FlowChartNavigator,
+ getLinkDirectionFromKey,
+} from "../element/flowchart";
+import { searchItemInFocusAtom } from "./SearchMenu";
+import type { LocalPoint, Radians } from "@excalidraw/math";
+import {
+ clamp,
+ pointFrom,
+ pointDistance,
+ vector,
+ pointRotateRads,
+ vectorScale,
+ vectorFromPoint,
+ vectorSubtract,
+ vectorDot,
+ vectorNormalize,
+} from "@excalidraw/math";
+import { cropElement } from "../element/cropElement";
+import { wrapText } from "../element/textWrapping";
+import { actionCopyElementLink } from "../actions/actionElementLink";
+import { isElementLink, parseElementLinkFromURL } from "../element/elementLink";
+import {
+ isMeasureTextSupported,
+ normalizeText,
+ measureText,
+ getLineHeightInPx,
+ getApproxMinLineWidth,
+ getApproxMinLineHeight,
+ getMinTextElementWidth,
+} from "../element/textMeasurements";
+
+const AppContext = React.createContext<AppClassProperties>(null!);
+const AppPropsContext = React.createContext<AppProps>(null!);
+
+const deviceContextInitialValue = {
+ viewport: {
+ isMobile: false,
+ isLandscape: false,
+ },
+ editor: {
+ isMobile: false,
+ canFitSidebar: false,
+ },
+ isTouchScreen: false,
+};
+const DeviceContext = React.createContext<Device>(deviceContextInitialValue);
+DeviceContext.displayName = "DeviceContext";
+
+export const ExcalidrawContainerContext = React.createContext<{
+ container: HTMLDivElement | null;
+ id: string | null;
+}>({ container: null, id: null });
+ExcalidrawContainerContext.displayName = "ExcalidrawContainerContext";
+
+const ExcalidrawElementsContext = React.createContext<
+ readonly NonDeletedExcalidrawElement[]
+>([]);
+ExcalidrawElementsContext.displayName = "ExcalidrawElementsContext";
+
+const ExcalidrawAppStateContext = React.createContext<AppState>({
+ ...getDefaultAppState(),
+ width: 0,
+ height: 0,
+ offsetLeft: 0,
+ offsetTop: 0,
+});
+ExcalidrawAppStateContext.displayName = "ExcalidrawAppStateContext";
+
+const ExcalidrawSetAppStateContext = React.createContext<
+ React.Component<any, AppState>["setState"]
+>(() => {
+ console.warn("Uninitialized ExcalidrawSetAppStateContext context!");
+});
+ExcalidrawSetAppStateContext.displayName = "ExcalidrawSetAppStateContext";
+
+const ExcalidrawActionManagerContext = React.createContext<ActionManager>(
+ null!,
+);
+ExcalidrawActionManagerContext.displayName = "ExcalidrawActionManagerContext";
+
+export const useApp = () => useContext(AppContext);
+export const useAppProps = () => useContext(AppPropsContext);
+export const useDevice = () => useContext<Device>(DeviceContext);
+export const useExcalidrawContainer = () =>
+ useContext(ExcalidrawContainerContext);
+export const useExcalidrawElements = () =>
+ useContext(ExcalidrawElementsContext);
+export const useExcalidrawAppState = () =>
+ useContext(ExcalidrawAppStateContext);
+export const useExcalidrawSetAppState = () =>
+ useContext(ExcalidrawSetAppStateContext);
+export const useExcalidrawActionManager = () =>
+ useContext(ExcalidrawActionManagerContext);
+
+let didTapTwice: boolean = false;
+let tappedTwiceTimer = 0;
+let isHoldingSpace: boolean = false;
+let isPanning: boolean = false;
+let isDraggingScrollBar: boolean = false;
+let currentScrollBars: ScrollBars = { horizontal: null, vertical: null };
+let touchTimeout = 0;
+let invalidateContextMenu = false;
+
+/**
+ * Map of youtube embed video states
+ */
+const YOUTUBE_VIDEO_STATES = new Map<
+ ExcalidrawElement["id"],
+ ValueOf<typeof YOUTUBE_STATES>
+>();
+
+let IS_PLAIN_PASTE = false;
+let IS_PLAIN_PASTE_TIMER = 0;
+let PLAIN_PASTE_TOAST_SHOWN = false;
+
+let lastPointerUp: (() => void) | null = null;
+const gesture: Gesture = {
+ pointers: new Map(),
+ lastCenter: null,
+ initialDistance: null,
+ initialScale: null,
+};
+
+class App extends React.Component<AppProps, AppState> {
+ canvas: AppClassProperties["canvas"];
+ interactiveCanvas: AppClassProperties["interactiveCanvas"] = null;
+ rc: RoughCanvas;
+ unmounted: boolean = false;
+ actionManager: ActionManager;
+ device: Device = deviceContextInitialValue;
+
+ private excalidrawContainerRef = React.createRef<HTMLDivElement>();
+
+ public scene: Scene;
+ public fonts: Fonts;
+ public renderer: Renderer;
+ public visibleElements: readonly NonDeletedExcalidrawElement[];
+ private resizeObserver: ResizeObserver | undefined;
+ private nearestScrollableContainer: HTMLElement | Document | undefined;
+ public library: AppClassProperties["library"];
+ public libraryItemsFromStorage: LibraryItems | undefined;
+ public id: string;
+ private store: Store;
+ private history: History;
+ public excalidrawContainerValue: {
+ container: HTMLDivElement | null;
+ id: string;
+ };
+
+ public files: BinaryFiles = {};
+ public imageCache: AppClassProperties["imageCache"] = new Map();
+ private iFrameRefs = new Map<ExcalidrawElement["id"], HTMLIFrameElement>();
+ /**
+ * Indicates whether the embeddable's url has been validated for rendering.
+ * If value not set, indicates that the validation is pending.
+ * Initially or on url change the flag is not reset so that we can guarantee
+ * the validation came from a trusted source (the editor).
+ **/
+ private embedsValidationStatus: EmbedsValidationStatus = new Map();
+ /** embeds that have been inserted to DOM (as a perf optim, we don't want to
+ * insert to DOM before user initially scrolls to them) */
+ private initializedEmbeds = new Set<ExcalidrawIframeLikeElement["id"]>();
+
+ private elementsPendingErasure: ElementsPendingErasure = new Set();
+
+ public flowChartCreator: FlowChartCreator = new FlowChartCreator();
+ private flowChartNavigator: FlowChartNavigator = new FlowChartNavigator();
+
+ hitLinkElement?: NonDeletedExcalidrawElement;
+ lastPointerDownEvent: React.PointerEvent<HTMLElement> | null = null;
+ lastPointerUpEvent: React.PointerEvent<HTMLElement> | PointerEvent | null =
+ null;
+ lastPointerMoveEvent: PointerEvent | null = null;
+ lastPointerMoveCoords: { x: number; y: number } | null = null;
+ lastViewportPosition = { x: 0, y: 0 };
+
+ animationFrameHandler = new AnimationFrameHandler();
+
+ laserTrails = new LaserTrails(this.animationFrameHandler, this);
+ eraserTrail = new AnimatedTrail(this.animationFrameHandler, this, {
+ streamline: 0.2,
+ size: 5,
+ keepHead: true,
+ sizeMapping: (c) => {
+ const DECAY_TIME = 200;
+ const DECAY_LENGTH = 10;
+ const t = Math.max(0, 1 - (performance.now() - c.pressure) / DECAY_TIME);
+ const l =
+ (DECAY_LENGTH -
+ Math.min(DECAY_LENGTH, c.totalLength - c.currentIndex)) /
+ DECAY_LENGTH;
+
+ return Math.min(easeOut(l), easeOut(t));
+ },
+ fill: () =>
+ this.state.theme === THEME.LIGHT
+ ? "rgba(0, 0, 0, 0.2)"
+ : "rgba(255, 255, 255, 0.2)",
+ });
+
+ onChangeEmitter = new Emitter<
+ [
+ elements: readonly ExcalidrawElement[],
+ appState: AppState,
+ files: BinaryFiles,
+ ]
+ >();
+
+ onPointerDownEmitter = new Emitter<
+ [
+ activeTool: AppState["activeTool"],
+ pointerDownState: PointerDownState,
+ event: React.PointerEvent<HTMLElement>,
+ ]
+ >();
+
+ onPointerUpEmitter = new Emitter<
+ [
+ activeTool: AppState["activeTool"],
+ pointerDownState: PointerDownState,
+ event: PointerEvent,
+ ]
+ >();
+ onUserFollowEmitter = new Emitter<[payload: OnUserFollowedPayload]>();
+ onScrollChangeEmitter = new Emitter<
+ [scrollX: number, scrollY: number, zoom: AppState["zoom"]]
+ >();
+
+ missingPointerEventCleanupEmitter = new Emitter<
+ [event: PointerEvent | null]
+ >();
+ onRemoveEventListenersEmitter = new Emitter<[]>();
+
+ constructor(props: AppProps) {
+ super(props);
+ const defaultAppState = getDefaultAppState();
+ const {
+ excalidrawAPI,
+ viewModeEnabled = false,
+ zenModeEnabled = false,
+ gridModeEnabled = false,
+ objectsSnapModeEnabled = false,
+ theme = defaultAppState.theme,
+ name = `${t("labels.untitled")}-${getDateTime()}`,
+ } = props;
+ this.state = {
+ ...defaultAppState,
+ theme,
+ isLoading: true,
+ ...this.getCanvasOffsets(),
+ viewModeEnabled,
+ zenModeEnabled,
+ objectsSnapModeEnabled,
+ gridModeEnabled: gridModeEnabled ?? defaultAppState.gridModeEnabled,
+ name,
+ width: window.innerWidth,
+ height: window.innerHeight,
+ };
+
+ this.id = nanoid();
+ this.library = new Library(this);
+ this.actionManager = new ActionManager(
+ this.syncActionResult,
+ () => this.state,
+ () => this.scene.getElementsIncludingDeleted(),
+ this,
+ );
+ this.scene = new Scene();
+
+ this.canvas = document.createElement("canvas");
+ this.rc = rough.canvas(this.canvas);
+ this.renderer = new Renderer(this.scene);
+ this.visibleElements = [];
+
+ this.store = new Store();
+ this.history = new History();
+
+ if (excalidrawAPI) {
+ const api: ExcalidrawImperativeAPI = {
+ updateScene: this.updateScene,
+ updateLibrary: this.library.updateLibrary,
+ addFiles: this.addFiles,
+ resetScene: this.resetScene,
+ getSceneElementsIncludingDeleted: this.getSceneElementsIncludingDeleted,
+ history: {
+ clear: this.resetHistory,
+ },
+ scrollToContent: this.scrollToContent,
+ getSceneElements: this.getSceneElements,
+ getAppState: () => this.state,
+ getFiles: () => this.files,
+ getName: this.getName,
+ registerAction: (action: Action) => {
+ this.actionManager.registerAction(action);
+ },
+ refresh: this.refresh,
+ setToast: this.setToast,
+ id: this.id,
+ setActiveTool: this.setActiveTool,
+ setCursor: this.setCursor,
+ resetCursor: this.resetCursor,
+ updateFrameRendering: this.updateFrameRendering,
+ toggleSidebar: this.toggleSidebar,
+ onChange: (cb) => this.onChangeEmitter.on(cb),
+ onPointerDown: (cb) => this.onPointerDownEmitter.on(cb),
+ onPointerUp: (cb) => this.onPointerUpEmitter.on(cb),
+ onScrollChange: (cb) => this.onScrollChangeEmitter.on(cb),
+ onUserFollow: (cb) => this.onUserFollowEmitter.on(cb),
+ } as const;
+ if (typeof excalidrawAPI === "function") {
+ excalidrawAPI(api);
+ } else {
+ console.error("excalidrawAPI should be a function!");
+ }
+ }
+
+ this.excalidrawContainerValue = {
+ container: this.excalidrawContainerRef.current,
+ id: this.id,
+ };
+
+ this.fonts = new Fonts(this.scene);
+ this.history = new History();
+
+ this.actionManager.registerAll(actions);
+ this.actionManager.registerAction(
+ createUndoAction(this.history, this.store),
+ );
+ this.actionManager.registerAction(
+ createRedoAction(this.history, this.store),
+ );
+ }
+
+ private onWindowMessage(event: MessageEvent) {
+ if (
+ event.origin !== "https://player.vimeo.com" &&
+ event.origin !== "https://www.youtube.com"
+ ) {
+ return;
+ }
+
+ let data = null;
+ try {
+ data = JSON.parse(event.data);
+ } catch (e) {}
+ if (!data) {
+ return;
+ }
+
+ switch (event.origin) {
+ case "https://player.vimeo.com":
+ //Allowing for multiple instances of Excalidraw running in the window
+ if (data.method === "paused") {
+ let source: Window | null = null;
+ const iframes = document.body.querySelectorAll(
+ "iframe.excalidraw__embeddable",
+ );
+ if (!iframes) {
+ break;
+ }
+ for (const iframe of iframes as NodeListOf<HTMLIFrameElement>) {
+ if (iframe.contentWindow === event.source) {
+ source = iframe.contentWindow;
+ }
+ }
+ source?.postMessage(
+ JSON.stringify({
+ method: data.value ? "play" : "pause",
+ value: true,
+ }),
+ "*",
+ );
+ }
+ break;
+ case "https://www.youtube.com":
+ if (
+ data.event === "infoDelivery" &&
+ data.info &&
+ data.id &&
+ typeof data.info.playerState === "number"
+ ) {
+ const id = data.id;
+ const playerState = data.info.playerState as number;
+ if (
+ (Object.values(YOUTUBE_STATES) as number[]).includes(playerState)
+ ) {
+ YOUTUBE_VIDEO_STATES.set(
+ id,
+ playerState as ValueOf<typeof YOUTUBE_STATES>,
+ );
+ }
+ }
+ break;
+ }
+ }
+
+ private cacheEmbeddableRef(
+ element: ExcalidrawIframeLikeElement,
+ ref: HTMLIFrameElement | null,
+ ) {
+ if (ref) {
+ this.iFrameRefs.set(element.id, ref);
+ }
+ }
+
+ /**
+ * Returns gridSize taking into account `gridModeEnabled`.
+ * If disabled, returns null.
+ */
+ public getEffectiveGridSize = () => {
+ return (
+ isGridModeEnabled(this) ? this.state.gridSize : null
+ ) as NullableGridSize;
+ };
+
+ private getHTMLIFrameElement(
+ element: ExcalidrawIframeLikeElement,
+ ): HTMLIFrameElement | undefined {
+ return this.iFrameRefs.get(element.id);
+ }
+
+ private handleEmbeddableCenterClick(element: ExcalidrawIframeLikeElement) {
+ if (
+ this.state.activeEmbeddable?.element === element &&
+ this.state.activeEmbeddable?.state === "active"
+ ) {
+ return;
+ }
+
+ // The delay serves two purposes
+ // 1. To prevent first click propagating to iframe on mobile,
+ // else the click will immediately start and stop the video
+ // 2. If the user double clicks the frame center to activate it
+ // without the delay youtube will immediately open the video
+ // in fullscreen mode
+ setTimeout(() => {
+ this.setState({
+ activeEmbeddable: { element, state: "active" },
+ selectedElementIds: { [element.id]: true },
+ newElement: null,
+ selectionElement: null,
+ });
+ }, 100);
+
+ if (isIframeElement(element)) {
+ return;
+ }
+
+ const iframe = this.getHTMLIFrameElement(element);
+
+ if (!iframe?.contentWindow) {
+ return;
+ }
+
+ if (iframe.src.includes("youtube")) {
+ const state = YOUTUBE_VIDEO_STATES.get(element.id);
+ if (!state) {
+ YOUTUBE_VIDEO_STATES.set(element.id, YOUTUBE_STATES.UNSTARTED);
+ iframe.contentWindow.postMessage(
+ JSON.stringify({
+ event: "listening",
+ id: element.id,
+ }),
+ "*",
+ );
+ }
+ switch (state) {
+ case YOUTUBE_STATES.PLAYING:
+ case YOUTUBE_STATES.BUFFERING:
+ iframe.contentWindow?.postMessage(
+ JSON.stringify({
+ event: "command",
+ func: "pauseVideo",
+ args: "",
+ }),
+ "*",
+ );
+ break;
+ default:
+ iframe.contentWindow?.postMessage(
+ JSON.stringify({
+ event: "command",
+ func: "playVideo",
+ args: "",
+ }),
+ "*",
+ );
+ }
+ }
+
+ if (iframe.src.includes("player.vimeo.com")) {
+ iframe.contentWindow.postMessage(
+ JSON.stringify({
+ method: "paused", //video play/pause in onWindowMessage handler
+ }),
+ "*",
+ );
+ }
+ }
+
+ private isIframeLikeElementCenter(
+ el: ExcalidrawIframeLikeElement | null,
+ event: React.PointerEvent<HTMLElement> | PointerEvent,
+ sceneX: number,
+ sceneY: number,
+ ) {
+ return (
+ el &&
+ !event.altKey &&
+ !event.shiftKey &&
+ !event.metaKey &&
+ !event.ctrlKey &&
+ (this.state.activeEmbeddable?.element !== el ||
+ this.state.activeEmbeddable?.state === "hover" ||
+ !this.state.activeEmbeddable) &&
+ sceneX >= el.x + el.width / 3 &&
+ sceneX <= el.x + (2 * el.width) / 3 &&
+ sceneY >= el.y + el.height / 3 &&
+ sceneY <= el.y + (2 * el.height) / 3
+ );
+ }
+
+ private updateEmbedValidationStatus = (
+ element: ExcalidrawEmbeddableElement,
+ status: boolean,
+ ) => {
+ this.embedsValidationStatus.set(element.id, status);
+ ShapeCache.delete(element);
+ };
+
+ private updateEmbeddables = () => {
+ const iframeLikes = new Set<ExcalidrawIframeLikeElement["id"]>();
+
+ let updated = false;
+ this.scene.getNonDeletedElements().filter((element) => {
+ if (isEmbeddableElement(element)) {
+ iframeLikes.add(element.id);
+ if (!this.embedsValidationStatus.has(element.id)) {
+ updated = true;
+
+ const validated = embeddableURLValidator(
+ element.link,
+ this.props.validateEmbeddable,
+ );
+
+ this.updateEmbedValidationStatus(element, validated);
+ }
+ } else if (isIframeElement(element)) {
+ iframeLikes.add(element.id);
+ }
+ return false;
+ });
+
+ if (updated) {
+ this.scene.triggerUpdate();
+ }
+
+ // GC
+ this.iFrameRefs.forEach((ref, id) => {
+ if (!iframeLikes.has(id)) {
+ this.iFrameRefs.delete(id);
+ }
+ });
+ };
+
+ private renderEmbeddables() {
+ const scale = this.state.zoom.value;
+ const normalizedWidth = this.state.width;
+ const normalizedHeight = this.state.height;
+
+ const embeddableElements = this.scene
+ .getNonDeletedElements()
+ .filter(
+ (el): el is Ordered<NonDeleted<ExcalidrawIframeLikeElement>> =>
+ (isEmbeddableElement(el) &&
+ this.embedsValidationStatus.get(el.id) === true) ||
+ isIframeElement(el),
+ );
+
+ return (
+ <>
+ {embeddableElements.map((el) => {
+ const { x, y } = sceneCoordsToViewportCoords(
+ { sceneX: el.x, sceneY: el.y },
+ this.state,
+ );
+
+ const isVisible = isElementInViewport(
+ el,
+ normalizedWidth,
+ normalizedHeight,
+ this.state,
+ this.scene.getNonDeletedElementsMap(),
+ );
+ const hasBeenInitialized = this.initializedEmbeds.has(el.id);
+
+ if (isVisible && !hasBeenInitialized) {
+ this.initializedEmbeds.add(el.id);
+ }
+ const shouldRender = isVisible || hasBeenInitialized;
+
+ if (!shouldRender) {
+ return null;
+ }
+
+ let src: IframeData | null;
+
+ if (isIframeElement(el)) {
+ src = null;
+
+ const data: MagicGenerationData = (el.customData?.generationData ??
+ this.magicGenerations.get(el.id)) || {
+ status: "error",
+ message: "No generation data",
+ code: "ERR_NO_GENERATION_DATA",
+ };
+
+ if (data.status === "done") {
+ const html = data.html;
+ src = {
+ intrinsicSize: { w: el.width, h: el.height },
+ type: "document",
+ srcdoc: () => {
+ return html;
+ },
+ } as const;
+ } else if (data.status === "pending") {
+ src = {
+ intrinsicSize: { w: el.width, h: el.height },
+ type: "document",
+ srcdoc: () => {
+ return createSrcDoc(`
+ <style>
+ html, body {
+ width: 100%;
+ height: 100%;
+ color: ${
+ this.state.theme === THEME.DARK ? "white" : "black"
+ };
+ }
+ body {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-direction: column;
+ gap: 1rem;
+ }
+
+ .Spinner {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-left: auto;
+ margin-right: auto;
+ }
+
+ .Spinner svg {
+ animation: rotate 1.6s linear infinite;
+ transform-origin: center center;
+ width: 40px;
+ height: 40px;
+ }
+
+ .Spinner circle {
+ stroke: currentColor;
+ animation: dash 1.6s linear 0s infinite;
+ stroke-linecap: round;
+ }
+
+ @keyframes rotate {
+ 100% {
+ transform: rotate(360deg);
+ }
+ }
+
+ @keyframes dash {
+ 0% {
+ stroke-dasharray: 1, 300;
+ stroke-dashoffset: 0;
+ }
+ 50% {
+ stroke-dasharray: 150, 300;
+ stroke-dashoffset: -200;
+ }
+ 100% {
+ stroke-dasharray: 1, 300;
+ stroke-dashoffset: -280;
+ }
+ }
+ </style>
+ <div class="Spinner">
+ <svg
+ viewBox="0 0 100 100"
+ >
+ <circle
+ cx="50"
+ cy="50"
+ r="46"
+ stroke-width="8"
+ fill="none"
+ stroke-miter-limit="10"
+ />
+ </svg>
+ </div>
+ <div>Generating...</div>
+ `);
+ },
+ } as const;
+ } else {
+ let message: string;
+ if (data.code === "ERR_GENERATION_INTERRUPTED") {
+ message = "Generation was interrupted...";
+ } else {
+ message = data.message || "Generation failed";
+ }
+ src = {
+ intrinsicSize: { w: el.width, h: el.height },
+ type: "document",
+ srcdoc: () => {
+ return createSrcDoc(`
+ <style>
+ html, body {
+ height: 100%;
+ }
+ body {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ color: ${COLOR_PALETTE.red[3]};
+ }
+ h1, h3 {
+ margin-top: 0;
+ margin-bottom: 0.5rem;
+ }
+ </style>
+ <h1>Error!</h1>
+ <h3>${message}</h3>
+ `);
+ },
+ } as const;
+ }
+ } else {
+ src = getEmbedLink(toValidURL(el.link || ""));
+ }
+
+ const isActive =
+ this.state.activeEmbeddable?.element === el &&
+ this.state.activeEmbeddable?.state === "active";
+ const isHovered =
+ this.state.activeEmbeddable?.element === el &&
+ this.state.activeEmbeddable?.state === "hover";
+
+ return (
+ <div
+ key={el.id}
+ className={clsx("excalidraw__embeddable-container", {
+ "is-hovered": isHovered,
+ })}
+ style={{
+ transform: isVisible
+ ? `translate(${x - this.state.offsetLeft}px, ${
+ y - this.state.offsetTop
+ }px) scale(${scale})`
+ : "none",
+ display: isVisible ? "block" : "none",
+ opacity: getRenderOpacity(
+ el,
+ getContainingFrame(el, this.scene.getNonDeletedElementsMap()),
+ this.elementsPendingErasure,
+ null,
+ this.state.openDialog?.name === "elementLinkSelector"
+ ? DEFAULT_REDUCED_GLOBAL_ALPHA
+ : 1,
+ ),
+ ["--embeddable-radius" as string]: `${getCornerRadius(
+ Math.min(el.width, el.height),
+ el,
+ )}px`,
+ }}
+ >
+ <div
+ //this is a hack that addresses isse with embedded excalidraw.com embeddable
+ //https://github.com/excalidraw/excalidraw/pull/6691#issuecomment-1607383938
+ /*ref={(ref) => {
+ if (!this.excalidrawContainerRef.current) {
+ return;
+ }
+ const container = this.excalidrawContainerRef.current;
+ const sh = container.scrollHeight;
+ const ch = container.clientHeight;
+ if (sh !== ch) {
+ container.style.height = `${sh}px`;
+ setTimeout(() => {
+ container.style.height = `100%`;
+ });
+ }
+ }}*/
+ className="excalidraw__embeddable-container__inner"
+ style={{
+ width: isVisible ? `${el.width}px` : 0,
+ height: isVisible ? `${el.height}px` : 0,
+ transform: isVisible ? `rotate(${el.angle}rad)` : "none",
+ pointerEvents: isActive
+ ? POINTER_EVENTS.enabled
+ : POINTER_EVENTS.disabled,
+ }}
+ >
+ {isHovered && (
+ <div className="excalidraw__embeddable-hint">
+ {t("buttons.embeddableInteractionButton")}
+ </div>
+ )}
+ <div
+ className="excalidraw__embeddable__outer"
+ style={{
+ padding: `${el.strokeWidth}px`,
+ }}
+ >
+ {(isEmbeddableElement(el)
+ ? this.props.renderEmbeddable?.(el, this.state)
+ : null) ?? (
+ <iframe
+ ref={(ref) => this.cacheEmbeddableRef(el, ref)}
+ className="excalidraw__embeddable"
+ srcDoc={
+ src?.type === "document"
+ ? src.srcdoc(this.state.theme)
+ : undefined
+ }
+ src={
+ src?.type !== "document" ? src?.link ?? "" : undefined
+ }
+ // https://stackoverflow.com/q/18470015
+ scrolling="no"
+ referrerPolicy="no-referrer-when-downgrade"
+ title="Excalidraw Embedded Content"
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
+ allowFullScreen={true}
+ sandbox={`${
+ src?.sandbox?.allowSameOrigin ? "allow-same-origin" : ""
+ } allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation allow-downloads`}
+ />
+ )}
+ </div>
+ </div>
+ </div>
+ );
+ })}
+ </>
+ );
+ }
+
+ private getFrameNameDOMId = (frameElement: ExcalidrawElement) => {
+ return `${this.id}-frame-name-${frameElement.id}`;
+ };
+
+ frameNameBoundsCache: FrameNameBoundsCache = {
+ get: (frameElement) => {
+ let bounds = this.frameNameBoundsCache._cache.get(frameElement.id);
+ if (
+ !bounds ||
+ bounds.zoom !== this.state.zoom.value ||
+ bounds.versionNonce !== frameElement.versionNonce
+ ) {
+ const frameNameDiv = document.getElementById(
+ this.getFrameNameDOMId(frameElement),
+ );
+
+ if (frameNameDiv) {
+ const box = frameNameDiv.getBoundingClientRect();
+ const boxSceneTopLeft = viewportCoordsToSceneCoords(
+ { clientX: box.x, clientY: box.y },
+ this.state,
+ );
+ const boxSceneBottomRight = viewportCoordsToSceneCoords(
+ { clientX: box.right, clientY: box.bottom },
+ this.state,
+ );
+
+ bounds = {
+ x: boxSceneTopLeft.x,
+ y: boxSceneTopLeft.y,
+ width: boxSceneBottomRight.x - boxSceneTopLeft.x,
+ height: boxSceneBottomRight.y - boxSceneTopLeft.y,
+ angle: 0,
+ zoom: this.state.zoom.value,
+ versionNonce: frameElement.versionNonce,
+ };
+
+ this.frameNameBoundsCache._cache.set(frameElement.id, bounds);
+
+ return bounds;
+ }
+ return null;
+ }
+
+ return bounds;
+ },
+ /**
+ * @private
+ */
+ _cache: new Map(),
+ };
+
+ private resetEditingFrame = (frame: ExcalidrawFrameLikeElement | null) => {
+ if (frame) {
+ mutateElement(frame, { name: frame.name?.trim() || null });
+ }
+ this.setState({ editingFrame: null });
+ };
+
+ private renderFrameNames = () => {
+ if (!this.state.frameRendering.enabled || !this.state.frameRendering.name) {
+ if (this.state.editingFrame) {
+ this.resetEditingFrame(null);
+ }
+ return null;
+ }
+
+ const isDarkTheme = this.state.theme === THEME.DARK;
+
+ return this.scene.getNonDeletedFramesLikes().map((f) => {
+ if (
+ !isElementInViewport(
+ f,
+ this.canvas.width / window.devicePixelRatio,
+ this.canvas.height / window.devicePixelRatio,
+ {
+ offsetLeft: this.state.offsetLeft,
+ offsetTop: this.state.offsetTop,
+ scrollX: this.state.scrollX,
+ scrollY: this.state.scrollY,
+ zoom: this.state.zoom,
+ },
+ this.scene.getNonDeletedElementsMap(),
+ )
+ ) {
+ if (this.state.editingFrame === f.id) {
+ this.resetEditingFrame(f);
+ }
+ // if frame not visible, don't render its name
+ return null;
+ }
+
+ const { x: x1, y: y1 } = sceneCoordsToViewportCoords(
+ { sceneX: f.x, sceneY: f.y },
+ this.state,
+ );
+
+ const FRAME_NAME_EDIT_PADDING = 6;
+
+ let frameNameJSX;
+
+ const frameName = getFrameLikeTitle(f);
+
+ if (f.id === this.state.editingFrame) {
+ const frameNameInEdit = frameName;
+
+ frameNameJSX = (
+ <input
+ autoFocus
+ value={frameNameInEdit}
+ onChange={(e) => {
+ mutateElement(f, {
+ name: e.target.value,
+ });
+ }}
+ onFocus={(e) => e.target.select()}
+ onBlur={() => this.resetEditingFrame(f)}
+ onKeyDown={(event) => {
+ // for some inexplicable reason, `onBlur` triggered on ESC
+ // does not reset `state.editingFrame` despite being called,
+ // and we need to reset it here as well
+ if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
+ this.resetEditingFrame(f);
+ }
+ }}
+ style={{
+ background: this.state.viewBackgroundColor,
+ filter: isDarkTheme ? THEME_FILTER : "none",
+ zIndex: 2,
+ border: "none",
+ display: "block",
+ padding: `${FRAME_NAME_EDIT_PADDING}px`,
+ borderRadius: 4,
+ boxShadow: "inset 0 0 0 1px var(--color-primary)",
+ fontFamily: "Assistant",
+ fontSize: "14px",
+ transform: `translate(-${FRAME_NAME_EDIT_PADDING}px, ${FRAME_NAME_EDIT_PADDING}px)`,
+ color: "var(--color-gray-80)",
+ overflow: "hidden",
+ maxWidth: `${
+ document.body.clientWidth - x1 - FRAME_NAME_EDIT_PADDING
+ }px`,
+ }}
+ size={frameNameInEdit.length + 1 || 1}
+ dir="auto"
+ autoComplete="off"
+ autoCapitalize="off"
+ autoCorrect="off"
+ />
+ );
+ } else {
+ frameNameJSX = frameName;
+ }
+
+ return (
+ <div
+ id={this.getFrameNameDOMId(f)}
+ key={f.id}
+ style={{
+ position: "absolute",
+ // Positioning from bottom so that we don't to either
+ // calculate text height or adjust using transform (which)
+ // messes up input position when editing the frame name.
+ // This makes the positioning deterministic and we can calculate
+ // the same position when rendering to canvas / svg.
+ bottom: `${
+ this.state.height +
+ FRAME_STYLE.nameOffsetY -
+ y1 +
+ this.state.offsetTop
+ }px`,
+ left: `${x1 - this.state.offsetLeft}px`,
+ zIndex: 2,
+ fontSize: FRAME_STYLE.nameFontSize,
+ color: isDarkTheme
+ ? FRAME_STYLE.nameColorDarkTheme
+ : FRAME_STYLE.nameColorLightTheme,
+ lineHeight: FRAME_STYLE.nameLineHeight,
+ width: "max-content",
+ maxWidth: `${f.width}px`,
+ overflow: f.id === this.state.editingFrame ? "visible" : "hidden",
+ whiteSpace: "nowrap",
+ textOverflow: "ellipsis",
+ cursor: CURSOR_TYPE.MOVE,
+ pointerEvents: this.state.viewModeEnabled
+ ? POINTER_EVENTS.disabled
+ : POINTER_EVENTS.enabled,
+ }}
+ onPointerDown={(event) => this.handleCanvasPointerDown(event)}
+ onWheel={(event) => this.handleWheel(event)}
+ onContextMenu={this.handleCanvasContextMenu}
+ onDoubleClick={() => {
+ this.setState({
+ editingFrame: f.id,
+ });
+ }}
+ >
+ {frameNameJSX}
+ </div>
+ );
+ });
+ };
+
+ private toggleOverscrollBehavior(event: React.PointerEvent) {
+ // when pointer inside editor, disable overscroll behavior to prevent
+ // panning to trigger history back/forward on MacOS Chrome
+ document.documentElement.style.overscrollBehaviorX =
+ event.type === "pointerenter" ? "none" : "auto";
+ }
+
+ public render() {
+ const selectedElements = this.scene.getSelectedElements(this.state);
+ const { renderTopRightUI, renderCustomStats } = this.props;
+
+ const sceneNonce = this.scene.getSceneNonce();
+ const { elementsMap, visibleElements } =
+ this.renderer.getRenderableElements({
+ sceneNonce,
+ zoom: this.state.zoom,
+ offsetLeft: this.state.offsetLeft,
+ offsetTop: this.state.offsetTop,
+ scrollX: this.state.scrollX,
+ scrollY: this.state.scrollY,
+ height: this.state.height,
+ width: this.state.width,
+ editingTextElement: this.state.editingTextElement,
+ newElementId: this.state.newElement?.id,
+ pendingImageElementId: this.state.pendingImageElementId,
+ });
+ this.visibleElements = visibleElements;
+
+ const allElementsMap = this.scene.getNonDeletedElementsMap();
+
+ const shouldBlockPointerEvents =
+ // default back to `--ui-pointerEvents` flow if setPointerCapture
+ // not supported
+ "setPointerCapture" in HTMLElement.prototype
+ ? false
+ : this.state.selectionElement ||
+ this.state.newElement ||
+ this.state.selectedElementsAreBeingDragged ||
+ this.state.resizingElement ||
+ (this.state.activeTool.type === "laser" &&
+ // technically we can just test on this once we make it more safe
+ this.state.cursorButton === "down");
+
+ const firstSelectedElement = selectedElements[0];
+
+ return (
+ <div
+ className={clsx("excalidraw excalidraw-container", {
+ "excalidraw--view-mode":
+ this.state.viewModeEnabled ||
+ this.state.openDialog?.name === "elementLinkSelector",
+ "excalidraw--mobile": this.device.editor.isMobile,
+ })}
+ style={{
+ ["--ui-pointerEvents" as any]: shouldBlockPointerEvents
+ ? POINTER_EVENTS.disabled
+ : POINTER_EVENTS.enabled,
+ ["--right-sidebar-width" as any]: "302px",
+ }}
+ ref={this.excalidrawContainerRef}
+ onDrop={this.handleAppOnDrop}
+ tabIndex={0}
+ onKeyDown={
+ this.props.handleKeyboardGlobally ? undefined : this.onKeyDown
+ }
+ onPointerEnter={this.toggleOverscrollBehavior}
+ onPointerLeave={this.toggleOverscrollBehavior}
+ >
+ <AppContext.Provider value={this}>
+ <AppPropsContext.Provider value={this.props}>
+ <ExcalidrawContainerContext.Provider
+ value={this.excalidrawContainerValue}
+ >
+ <DeviceContext.Provider value={this.device}>
+ <ExcalidrawSetAppStateContext.Provider value={this.setAppState}>
+ <ExcalidrawAppStateContext.Provider value={this.state}>
+ <ExcalidrawElementsContext.Provider
+ value={this.scene.getNonDeletedElements()}
+ >
+ <ExcalidrawActionManagerContext.Provider
+ value={this.actionManager}
+ >
+ <LayerUI
+ canvas={this.canvas}
+ appState={this.state}
+ files={this.files}
+ setAppState={this.setAppState}
+ actionManager={this.actionManager}
+ elements={this.scene.getNonDeletedElements()}
+ onLockToggle={this.toggleLock}
+ onPenModeToggle={this.togglePenMode}
+ onHandToolToggle={this.onHandToolToggle}
+ langCode={getLanguage().code}
+ renderTopRightUI={renderTopRightUI}
+ renderCustomStats={renderCustomStats}
+ showExitZenModeBtn={
+ typeof this.props?.zenModeEnabled === "undefined" &&
+ this.state.zenModeEnabled
+ }
+ UIOptions={this.props.UIOptions}
+ onExportImage={this.onExportImage}
+ renderWelcomeScreen={
+ !this.state.isLoading &&
+ this.state.showWelcomeScreen &&
+ this.state.activeTool.type === "selection" &&
+ !this.state.zenModeEnabled &&
+ !this.scene.getElementsIncludingDeleted().length
+ }
+ app={this}
+ isCollaborating={this.props.isCollaborating}
+ generateLinkForSelection={
+ this.props.generateLinkForSelection
+ }
+ >
+ {this.props.children}
+ </LayerUI>
+
+ <div className="excalidraw-textEditorContainer" />
+ <div className="excalidraw-contextMenuContainer" />
+ <div className="excalidraw-eye-dropper-container" />
+ <SVGLayer
+ trails={[this.laserTrails, this.eraserTrail]}
+ />
+ {selectedElements.length === 1 &&
+ this.state.openDialog?.name !==
+ "elementLinkSelector" &&
+ this.state.showHyperlinkPopup && (
+ <Hyperlink
+ key={firstSelectedElement.id}
+ element={firstSelectedElement}
+ elementsMap={allElementsMap}
+ setAppState={this.setAppState}
+ onLinkOpen={this.props.onLinkOpen}
+ setToast={this.setToast}
+ updateEmbedValidationStatus={
+ this.updateEmbedValidationStatus
+ }
+ />
+ )}
+ {this.props.aiEnabled !== false &&
+ selectedElements.length === 1 &&
+ isMagicFrameElement(firstSelectedElement) && (
+ <ElementCanvasButtons
+ element={firstSelectedElement}
+ elementsMap={elementsMap}
+ >
+ <ElementCanvasButton
+ title={t("labels.convertToCode")}
+ icon={MagicIcon}
+ checked={false}
+ onChange={() =>
+ this.onMagicFrameGenerate(
+ firstSelectedElement,
+ "button",
+ )
+ }
+ />
+ </ElementCanvasButtons>
+ )}
+ {selectedElements.length === 1 &&
+ isIframeElement(firstSelectedElement) &&
+ firstSelectedElement.customData?.generationData
+ ?.status === "done" && (
+ <ElementCanvasButtons
+ element={firstSelectedElement}
+ elementsMap={elementsMap}
+ >
+ <ElementCanvasButton
+ title={t("labels.copySource")}
+ icon={copyIcon}
+ checked={false}
+ onChange={() =>
+ this.onIframeSrcCopy(firstSelectedElement)
+ }
+ />
+ <ElementCanvasButton
+ title="Enter fullscreen"
+ icon={fullscreenIcon}
+ checked={false}
+ onChange={() => {
+ const iframe =
+ this.getHTMLIFrameElement(
+ firstSelectedElement,
+ );
+ if (iframe) {
+ try {
+ iframe.requestFullscreen();
+ this.setState({
+ activeEmbeddable: {
+ element: firstSelectedElement,
+ state: "active",
+ },
+ selectedElementIds: {
+ [firstSelectedElement.id]: true,
+ },
+ newElement: null,
+ selectionElement: null,
+ });
+ } catch (err: any) {
+ console.warn(err);
+ this.setState({
+ errorMessage:
+ "Couldn't enter fullscreen",
+ });
+ }
+ }
+ }}
+ />
+ </ElementCanvasButtons>
+ )}
+ {this.state.toast !== null && (
+ <Toast
+ message={this.state.toast.message}
+ onClose={() => this.setToast(null)}
+ duration={this.state.toast.duration}
+ closable={this.state.toast.closable}
+ />
+ )}
+ {this.state.contextMenu && (
+ <ContextMenu
+ items={this.state.contextMenu.items}
+ top={this.state.contextMenu.top}
+ left={this.state.contextMenu.left}
+ actionManager={this.actionManager}
+ onClose={(callback) => {
+ this.setState({ contextMenu: null }, () => {
+ this.focusContainer();
+ callback?.();
+ });
+ }}
+ />
+ )}
+ <StaticCanvas
+ canvas={this.canvas}
+ rc={this.rc}
+ elementsMap={elementsMap}
+ allElementsMap={allElementsMap}
+ visibleElements={visibleElements}
+ sceneNonce={sceneNonce}
+ selectionNonce={
+ this.state.selectionElement?.versionNonce
+ }
+ scale={window.devicePixelRatio}
+ appState={this.state}
+ renderConfig={{
+ imageCache: this.imageCache,
+ isExporting: false,
+ renderGrid: isGridModeEnabled(this),
+ canvasBackgroundColor:
+ this.state.viewBackgroundColor,
+ embedsValidationStatus: this.embedsValidationStatus,
+ elementsPendingErasure: this.elementsPendingErasure,
+ pendingFlowchartNodes:
+ this.flowChartCreator.pendingNodes,
+ }}
+ />
+ {this.state.newElement && (
+ <NewElementCanvas
+ appState={this.state}
+ scale={window.devicePixelRatio}
+ rc={this.rc}
+ elementsMap={elementsMap}
+ allElementsMap={allElementsMap}
+ renderConfig={{
+ imageCache: this.imageCache,
+ isExporting: false,
+ renderGrid: false,
+ canvasBackgroundColor:
+ this.state.viewBackgroundColor,
+ embedsValidationStatus:
+ this.embedsValidationStatus,
+ elementsPendingErasure:
+ this.elementsPendingErasure,
+ pendingFlowchartNodes: null,
+ }}
+ />
+ )}
+ <InteractiveCanvas
+ containerRef={this.excalidrawContainerRef}
+ canvas={this.interactiveCanvas}
+ elementsMap={elementsMap}
+ visibleElements={visibleElements}
+ allElementsMap={allElementsMap}
+ selectedElements={selectedElements}
+ sceneNonce={sceneNonce}
+ selectionNonce={
+ this.state.selectionElement?.versionNonce
+ }
+ scale={window.devicePixelRatio}
+ appState={this.state}
+ device={this.device}
+ renderInteractiveSceneCallback={
+ this.renderInteractiveSceneCallback
+ }
+ handleCanvasRef={this.handleInteractiveCanvasRef}
+ onContextMenu={this.handleCanvasContextMenu}
+ onPointerMove={this.handleCanvasPointerMove}
+ onPointerUp={this.handleCanvasPointerUp}
+ onPointerCancel={this.removePointer}
+ onTouchMove={this.handleTouchMove}
+ onPointerDown={this.handleCanvasPointerDown}
+ onDoubleClick={this.handleCanvasDoubleClick}
+ />
+ {this.state.userToFollow && (
+ <FollowMode
+ width={this.state.width}
+ height={this.state.height}
+ userToFollow={this.state.userToFollow}
+ onDisconnect={this.maybeUnfollowRemoteUser}
+ />
+ )}
+ {this.renderFrameNames()}
+ </ExcalidrawActionManagerContext.Provider>
+ {this.renderEmbeddables()}
+ </ExcalidrawElementsContext.Provider>
+ </ExcalidrawAppStateContext.Provider>
+ </ExcalidrawSetAppStateContext.Provider>
+ </DeviceContext.Provider>
+ </ExcalidrawContainerContext.Provider>
+ </AppPropsContext.Provider>
+ </AppContext.Provider>
+ </div>
+ );
+ }
+
+ public focusContainer: AppClassProperties["focusContainer"] = () => {
+ this.excalidrawContainerRef.current?.focus();
+ };
+
+ public getSceneElementsIncludingDeleted = () => {
+ return this.scene.getElementsIncludingDeleted();
+ };
+
+ public getSceneElements = () => {
+ return this.scene.getNonDeletedElements();
+ };
+
+ public onInsertElements = (elements: readonly ExcalidrawElement[]) => {
+ this.addElementsFromPasteOrLibrary({
+ elements,
+ position: "center",
+ files: null,
+ });
+ };
+
+ public onExportImage = async (
+ type: keyof typeof EXPORT_IMAGE_TYPES,
+ elements: ExportedElements,
+ opts: { exportingFrame: ExcalidrawFrameLikeElement | null },
+ ) => {
+ trackEvent("export", type, "ui");
+ const fileHandle = await exportCanvas(
+ type,
+ elements,
+ this.state,
+ this.files,
+ {
+ exportBackground: this.state.exportBackground,
+ name: this.getName(),
+ viewBackgroundColor: this.state.viewBackgroundColor,
+ exportingFrame: opts.exportingFrame,
+ },
+ )
+ .catch(muteFSAbortError)
+ .catch((error) => {
+ console.error(error);
+ this.setState({ errorMessage: error.message });
+ });
+
+ if (
+ this.state.exportEmbedScene &&
+ fileHandle &&
+ isImageFileHandle(fileHandle)
+ ) {
+ this.setState({ fileHandle });
+ }
+ };
+
+ private magicGenerations = new Map<
+ ExcalidrawIframeElement["id"],
+ MagicGenerationData
+ >();
+
+ private updateMagicGeneration = ({
+ frameElement,
+ data,
+ }: {
+ frameElement: ExcalidrawIframeElement;
+ data: MagicGenerationData;
+ }) => {
+ if (data.status === "pending") {
+ // We don't wanna persist pending state to storage. It should be in-app
+ // state only.
+ // Thus reset so that we prefer local cache (if there was some
+ // generationData set previously)
+ mutateElement(
+ frameElement,
+ { customData: { generationData: undefined } },
+ false,
+ );
+ } else {
+ mutateElement(
+ frameElement,
+ { customData: { generationData: data } },
+ false,
+ );
+ }
+ this.magicGenerations.set(frameElement.id, data);
+ this.triggerRender();
+ };
+
+ public plugins: {
+ diagramToCode?: {
+ generate: GenerateDiagramToCode;
+ };
+ } = {};
+
+ public setPlugins(plugins: Partial<App["plugins"]>) {
+ Object.assign(this.plugins, plugins);
+ }
+
+ private async onMagicFrameGenerate(
+ magicFrame: ExcalidrawMagicFrameElement,
+ source: "button" | "upstream",
+ ) {
+ const generateDiagramToCode = this.plugins.diagramToCode?.generate;
+
+ if (!generateDiagramToCode) {
+ this.setState({
+ errorMessage: "No diagram to code plugin found",
+ });
+ return;
+ }
+
+ const magicFrameChildren = getElementsOverlappingFrame(
+ this.scene.getNonDeletedElements(),
+ magicFrame,
+ ).filter((el) => !isMagicFrameElement(el));
+
+ if (!magicFrameChildren.length) {
+ if (source === "button") {
+ this.setState({ errorMessage: "Cannot generate from an empty frame" });
+ trackEvent("ai", "generate (no-children)", "d2c");
+ } else {
+ this.setActiveTool({ type: "magicframe" });
+ }
+ return;
+ }
+
+ const frameElement = this.insertIframeElement({
+ sceneX: magicFrame.x + magicFrame.width + 30,
+ sceneY: magicFrame.y,
+ width: magicFrame.width,
+ height: magicFrame.height,
+ });
+
+ if (!frameElement) {
+ return;
+ }
+
+ this.updateMagicGeneration({
+ frameElement,
+ data: { status: "pending" },
+ });
+
+ this.setState({
+ selectedElementIds: { [frameElement.id]: true },
+ });
+
+ trackEvent("ai", "generate (start)", "d2c");
+ try {
+ const { html } = await generateDiagramToCode({
+ frame: magicFrame,
+ children: magicFrameChildren,
+ });
+
+ trackEvent("ai", "generate (success)", "d2c");
+
+ if (!html.trim()) {
+ this.updateMagicGeneration({
+ frameElement,
+ data: {
+ status: "error",
+ code: "ERR_OAI",
+ message: "Nothing genereated :(",
+ },
+ });
+ return;
+ }
+
+ const parsedHtml =
+ html.includes("<!DOCTYPE html>") && html.includes("</html>")
+ ? html.slice(
+ html.indexOf("<!DOCTYPE html>"),
+ html.indexOf("</html>") + "</html>".length,
+ )
+ : html;
+
+ this.updateMagicGeneration({
+ frameElement,
+ data: { status: "done", html: parsedHtml },
+ });
+ } catch (error: any) {
+ trackEvent("ai", "generate (failed)", "d2c");
+ this.updateMagicGeneration({
+ frameElement,
+ data: {
+ status: "error",
+ code: "ERR_OAI",
+ message: error.message || "Unknown error during generation",
+ },
+ });
+ }
+ }
+
+ private onIframeSrcCopy(element: ExcalidrawIframeElement) {
+ if (element.customData?.generationData?.status === "done") {
+ copyTextToSystemClipboard(element.customData.generationData.html);
+ this.setToast({
+ message: "copied to clipboard",
+ closable: false,
+ duration: 1500,
+ });
+ }
+ }
+
+ public onMagicframeToolSelect = () => {
+ const selectedElements = this.scene.getSelectedElements({
+ selectedElementIds: this.state.selectedElementIds,
+ });
+
+ if (selectedElements.length === 0) {
+ this.setActiveTool({ type: TOOL_TYPE.magicframe });
+ trackEvent("ai", "tool-select (empty-selection)", "d2c");
+ } else {
+ const selectedMagicFrame: ExcalidrawMagicFrameElement | false =
+ selectedElements.length === 1 &&
+ isMagicFrameElement(selectedElements[0]) &&
+ selectedElements[0];
+
+ // case: user selected elements containing frame-like(s) or are frame
+ // members, we don't want to wrap into another magicframe
+ // (unless the only selected element is a magic frame which we reuse)
+ if (
+ !selectedMagicFrame &&
+ selectedElements.some((el) => isFrameLikeElement(el) || el.frameId)
+ ) {
+ this.setActiveTool({ type: TOOL_TYPE.magicframe });
+ return;
+ }
+
+ trackEvent("ai", "tool-select (existing selection)", "d2c");
+
+ let frame: ExcalidrawMagicFrameElement;
+ if (selectedMagicFrame) {
+ // a single magicframe already selected -> use it
+ frame = selectedMagicFrame;
+ } else {
+ // selected elements aren't wrapped in magic frame yet -> wrap now
+
+ const [minX, minY, maxX, maxY] = getCommonBounds(selectedElements);
+ const padding = 50;
+
+ frame = newMagicFrameElement({
+ ...FRAME_STYLE,
+ x: minX - padding,
+ y: minY - padding,
+ width: maxX - minX + padding * 2,
+ height: maxY - minY + padding * 2,
+ opacity: 100,
+ locked: false,
+ });
+
+ this.scene.insertElement(frame);
+
+ for (const child of selectedElements) {
+ mutateElement(child, { frameId: frame.id });
+ }
+
+ this.setState({
+ selectedElementIds: { [frame.id]: true },
+ });
+ }
+
+ this.onMagicFrameGenerate(frame, "upstream");
+ }
+ };
+
+ private openEyeDropper = ({ type }: { type: "stroke" | "background" }) => {
+ editorJotaiStore.set(activeEyeDropperAtom, {
+ swapPreviewOnAlt: true,
+ colorPickerType:
+ type === "stroke" ? "elementStroke" : "elementBackground",
+ onSelect: (color, event) => {
+ const shouldUpdateStrokeColor =
+ (type === "background" && event.altKey) ||
+ (type === "stroke" && !event.altKey);
+ const selectedElements = this.scene.getSelectedElements(this.state);
+ if (
+ !selectedElements.length ||
+ this.state.activeTool.type !== "selection"
+ ) {
+ if (shouldUpdateStrokeColor) {
+ this.syncActionResult({
+ appState: { ...this.state, currentItemStrokeColor: color },
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ });
+ } else {
+ this.syncActionResult({
+ appState: { ...this.state, currentItemBackgroundColor: color },
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ });
+ }
+ } else {
+ this.updateScene({
+ elements: this.scene.getElementsIncludingDeleted().map((el) => {
+ if (this.state.selectedElementIds[el.id]) {
+ return newElementWith(el, {
+ [shouldUpdateStrokeColor ? "strokeColor" : "backgroundColor"]:
+ color,
+ });
+ }
+ return el;
+ }),
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ });
+ }
+ },
+ keepOpenOnAlt: false,
+ });
+ };
+
+ public dismissLinearEditor = () => {
+ setTimeout(() => {
+ this.setState({
+ editingLinearElement: null,
+ });
+ });
+ };
+
+ public syncActionResult = withBatchedUpdates((actionResult: ActionResult) => {
+ if (this.unmounted || actionResult === false) {
+ return;
+ }
+
+ if (actionResult.captureUpdate === CaptureUpdateAction.NEVER) {
+ this.store.shouldUpdateSnapshot();
+ } else if (actionResult.captureUpdate === CaptureUpdateAction.IMMEDIATELY) {
+ this.store.shouldCaptureIncrement();
+ }
+
+ let didUpdate = false;
+
+ let editingTextElement: AppState["editingTextElement"] | null = null;
+ if (actionResult.elements) {
+ this.scene.replaceAllElements(actionResult.elements);
+ didUpdate = true;
+ }
+
+ if (actionResult.files) {
+ this.addMissingFiles(actionResult.files, actionResult.replaceFiles);
+ this.addNewImagesToImageCache();
+ }
+
+ if (actionResult.appState || editingTextElement || this.state.contextMenu) {
+ let viewModeEnabled = actionResult?.appState?.viewModeEnabled || false;
+ let zenModeEnabled = actionResult?.appState?.zenModeEnabled || false;
+ const theme =
+ actionResult?.appState?.theme || this.props.theme || THEME.LIGHT;
+ const name = actionResult?.appState?.name ?? this.state.name;
+ const errorMessage =
+ actionResult?.appState?.errorMessage ?? this.state.errorMessage;
+ if (typeof this.props.viewModeEnabled !== "undefined") {
+ viewModeEnabled = this.props.viewModeEnabled;
+ }
+
+ if (typeof this.props.zenModeEnabled !== "undefined") {
+ zenModeEnabled = this.props.zenModeEnabled;
+ }
+
+ editingTextElement = actionResult.appState?.editingTextElement || null;
+
+ // make sure editingTextElement points to latest element reference
+ if (actionResult.elements && editingTextElement) {
+ actionResult.elements.forEach((element) => {
+ if (
+ editingTextElement?.id === element.id &&
+ editingTextElement !== element &&
+ isNonDeletedElement(element) &&
+ isTextElement(element)
+ ) {
+ editingTextElement = element;
+ }
+ });
+ }
+
+ if (editingTextElement?.isDeleted) {
+ editingTextElement = null;
+ }
+
+ this.setState((prevAppState) => {
+ const actionAppState = actionResult.appState || {};
+
+ return {
+ ...prevAppState,
+ ...actionAppState,
+ // NOTE this will prevent opening context menu using an action
+ // or programmatically from the host, so it will need to be
+ // rewritten later
+ contextMenu: null,
+ editingTextElement,
+ viewModeEnabled,
+ zenModeEnabled,
+ theme,
+ name,
+ errorMessage,
+ };
+ });
+
+ didUpdate = true;
+ }
+
+ if (
+ !didUpdate &&
+ actionResult.captureUpdate !== CaptureUpdateAction.EVENTUALLY
+ ) {
+ this.scene.triggerUpdate();
+ }
+ });
+
+ // Lifecycle
+
+ private onBlur = withBatchedUpdates(() => {
+ isHoldingSpace = false;
+ this.setState({ isBindingEnabled: true });
+ });
+
+ private onUnload = () => {
+ this.onBlur();
+ };
+
+ private disableEvent: EventListener = (event) => {
+ event.preventDefault();
+ };
+
+ private resetHistory = () => {
+ this.history.clear();
+ };
+
+ private resetStore = () => {
+ this.store.clear();
+ };
+
+ /**
+ * Resets scene & history.
+ * ! Do not use to clear scene user action !
+ */
+ private resetScene = withBatchedUpdates(
+ (opts?: { resetLoadingState: boolean }) => {
+ this.scene.replaceAllElements([]);
+ this.setState((state) => ({
+ ...getDefaultAppState(),
+ isLoading: opts?.resetLoadingState ? false : state.isLoading,
+ theme: this.state.theme,
+ }));
+ this.resetStore();
+ this.resetHistory();
+ },
+ );
+
+ private initializeScene = async () => {
+ if ("launchQueue" in window && "LaunchParams" in window) {
+ (window as any).launchQueue.setConsumer(
+ async (launchParams: { files: any[] }) => {
+ if (!launchParams.files.length) {
+ return;
+ }
+ const fileHandle = launchParams.files[0];
+ const blob: Blob = await fileHandle.getFile();
+ this.loadFileToCanvas(
+ new File([blob], blob.name || "", { type: blob.type }),
+ fileHandle,
+ );
+ },
+ );
+ }
+
+ if (this.props.theme) {
+ this.setState({ theme: this.props.theme });
+ }
+ if (!this.state.isLoading) {
+ this.setState({ isLoading: true });
+ }
+ let initialData = null;
+ try {
+ if (typeof this.props.initialData === "function") {
+ initialData = (await this.props.initialData()) || null;
+ } else {
+ initialData = (await this.props.initialData) || null;
+ }
+ if (initialData?.libraryItems) {
+ this.library
+ .updateLibrary({
+ libraryItems: initialData.libraryItems,
+ merge: true,
+ })
+ .catch((error) => {
+ console.error(error);
+ });
+ }
+ } catch (error: any) {
+ console.error(error);
+ initialData = {
+ appState: {
+ errorMessage:
+ error.message ||
+ "Encountered an error during importing or restoring scene data",
+ },
+ };
+ }
+ const scene = restore(initialData, null, null, { repairBindings: true });
+ scene.appState = {
+ ...scene.appState,
+ theme: this.props.theme || scene.appState.theme,
+ // we're falling back to current (pre-init) state when deciding
+ // whether to open the library, to handle a case where we
+ // update the state outside of initialData (e.g. when loading the app
+ // with a library install link, which should auto-open the library)
+ openSidebar: scene.appState?.openSidebar || this.state.openSidebar,
+ activeTool:
+ scene.appState.activeTool.type === "image"
+ ? { ...scene.appState.activeTool, type: "selection" }
+ : scene.appState.activeTool,
+ isLoading: false,
+ toast: this.state.toast,
+ };
+ if (initialData?.scrollToContent) {
+ scene.appState = {
+ ...scene.appState,
+ ...calculateScrollCenter(scene.elements, {
+ ...scene.appState,
+ width: this.state.width,
+ height: this.state.height,
+ offsetTop: this.state.offsetTop,
+ offsetLeft: this.state.offsetLeft,
+ }),
+ };
+ }
+
+ this.resetStore();
+ this.resetHistory();
+ this.syncActionResult({
+ ...scene,
+ captureUpdate: CaptureUpdateAction.NEVER,
+ });
+
+ // clear the shape and image cache so that any images in initialData
+ // can be loaded fresh
+ this.clearImageShapeCache();
+
+ // manually loading the font faces seems faster even in browsers that do fire the loadingdone event
+ this.fonts.loadSceneFonts().then((fontFaces) => {
+ this.fonts.onLoaded(fontFaces);
+ });
+
+ if (isElementLink(window.location.href)) {
+ this.scrollToContent(window.location.href, { animate: false });
+ }
+ };
+
+ private isMobileBreakpoint = (width: number, height: number) => {
+ return (
+ width < MQ_MAX_WIDTH_PORTRAIT ||
+ (height < MQ_MAX_HEIGHT_LANDSCAPE && width < MQ_MAX_WIDTH_LANDSCAPE)
+ );
+ };
+
+ private refreshViewportBreakpoints = () => {
+ const container = this.excalidrawContainerRef.current;
+ if (!container) {
+ return;
+ }
+
+ const { clientWidth: viewportWidth, clientHeight: viewportHeight } =
+ document.body;
+
+ const prevViewportState = this.device.viewport;
+
+ const nextViewportState = updateObject(prevViewportState, {
+ isLandscape: viewportWidth > viewportHeight,
+ isMobile: this.isMobileBreakpoint(viewportWidth, viewportHeight),
+ });
+
+ if (prevViewportState !== nextViewportState) {
+ this.device = { ...this.device, viewport: nextViewportState };
+ return true;
+ }
+ return false;
+ };
+
+ private refreshEditorBreakpoints = () => {
+ const container = this.excalidrawContainerRef.current;
+ if (!container) {
+ return;
+ }
+
+ const { width: editorWidth, height: editorHeight } =
+ container.getBoundingClientRect();
+
+ const sidebarBreakpoint =
+ this.props.UIOptions.dockedSidebarBreakpoint != null
+ ? this.props.UIOptions.dockedSidebarBreakpoint
+ : MQ_RIGHT_SIDEBAR_MIN_WIDTH;
+
+ const prevEditorState = this.device.editor;
+
+ const nextEditorState = updateObject(prevEditorState, {
+ isMobile: this.isMobileBreakpoint(editorWidth, editorHeight),
+ canFitSidebar: editorWidth > sidebarBreakpoint,
+ });
+
+ if (prevEditorState !== nextEditorState) {
+ this.device = { ...this.device, editor: nextEditorState };
+ return true;
+ }
+ return false;
+ };
+
+ private clearImageShapeCache(filesMap?: BinaryFiles) {
+ const files = filesMap ?? this.files;
+ this.scene.getNonDeletedElements().forEach((element) => {
+ if (isInitializedImageElement(element) && files[element.fileId]) {
+ this.imageCache.delete(element.fileId);
+ ShapeCache.delete(element);
+ }
+ });
+ }
+
+ public async componentDidMount() {
+ this.unmounted = false;
+ this.excalidrawContainerValue.container =
+ this.excalidrawContainerRef.current;
+
+ if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) {
+ const setState = this.setState.bind(this);
+ Object.defineProperties(window.h, {
+ state: {
+ configurable: true,
+ get: () => {
+ return this.state;
+ },
+ },
+ setState: {
+ configurable: true,
+ value: (...args: Parameters<typeof setState>) => {
+ return this.setState(...args);
+ },
+ },
+ app: {
+ configurable: true,
+ value: this,
+ },
+ history: {
+ configurable: true,
+ value: this.history,
+ },
+ store: {
+ configurable: true,
+ value: this.store,
+ },
+ fonts: {
+ configurable: true,
+ value: this.fonts,
+ },
+ });
+ }
+
+ this.store.onStoreIncrementEmitter.on((increment) => {
+ this.history.record(increment.elementsChange, increment.appStateChange);
+ });
+
+ this.scene.onUpdate(this.triggerRender);
+ this.addEventListeners();
+
+ if (this.props.autoFocus && this.excalidrawContainerRef.current) {
+ this.focusContainer();
+ }
+
+ if (
+ // bounding rects don't work in tests so updating
+ // the state on init would result in making the test enviro run
+ // in mobile breakpoint (0 width/height), making everything fail
+ !isTestEnv()
+ ) {
+ this.refreshViewportBreakpoints();
+ this.refreshEditorBreakpoints();
+ }
+
+ if (supportsResizeObserver && this.excalidrawContainerRef.current) {
+ this.resizeObserver = new ResizeObserver(() => {
+ this.refreshEditorBreakpoints();
+ this.updateDOMRect();
+ });
+ this.resizeObserver?.observe(this.excalidrawContainerRef.current);
+ }
+
+ const searchParams = new URLSearchParams(window.location.search.slice(1));
+
+ if (searchParams.has("web-share-target")) {
+ // Obtain a file that was shared via the Web Share Target API.
+ this.restoreFileFromShare();
+ } else {
+ this.updateDOMRect(this.initializeScene);
+ }
+
+ // note that this check seems to always pass in localhost
+ if (isBrave() && !isMeasureTextSupported()) {
+ this.setState({
+ errorMessage: <BraveMeasureTextError />,
+ });
+ }
+ }
+
+ public componentWillUnmount() {
+ (window as any).launchQueue?.setConsumer(() => {});
+ this.renderer.destroy();
+ this.scene.destroy();
+ this.scene = new Scene();
+ this.fonts = new Fonts(this.scene);
+ this.renderer = new Renderer(this.scene);
+ this.files = {};
+ this.imageCache.clear();
+ this.resizeObserver?.disconnect();
+ this.unmounted = true;
+ this.removeEventListeners();
+ this.library.destroy();
+ this.laserTrails.stop();
+ this.eraserTrail.stop();
+ this.onChangeEmitter.clear();
+ this.store.onStoreIncrementEmitter.clear();
+ ShapeCache.destroy();
+ SnapCache.destroy();
+ clearTimeout(touchTimeout);
+ isSomeElementSelected.clearCache();
+ selectGroupsForSelectedElements.clearCache();
+ touchTimeout = 0;
+ document.documentElement.style.overscrollBehaviorX = "";
+ }
+
+ private onResize = withBatchedUpdates(() => {
+ this.scene
+ .getElementsIncludingDeleted()
+ .forEach((element) => ShapeCache.delete(element));
+ this.refreshViewportBreakpoints();
+ this.updateDOMRect();
+ if (!supportsResizeObserver) {
+ this.refreshEditorBreakpoints();
+ }
+ this.setState({});
+ });
+
+ /** generally invoked only if fullscreen was invoked programmatically */
+ private onFullscreenChange = () => {
+ if (
+ // points to the iframe element we fullscreened
+ !document.fullscreenElement &&
+ this.state.activeEmbeddable?.state === "active"
+ ) {
+ this.setState({
+ activeEmbeddable: null,
+ });
+ }
+ };
+
+ private removeEventListeners() {
+ this.onRemoveEventListenersEmitter.trigger();
+ }
+
+ private addEventListeners() {
+ // remove first as we can add event listeners multiple times
+ this.removeEventListeners();
+
+ // -------------------------------------------------------------------------
+ // view+edit mode listeners
+ // -------------------------------------------------------------------------
+
+ if (this.props.handleKeyboardGlobally) {
+ this.onRemoveEventListenersEmitter.once(
+ addEventListener(document, EVENT.KEYDOWN, this.onKeyDown, false),
+ );
+ }
+
+ this.onRemoveEventListenersEmitter.once(
+ addEventListener(
+ this.excalidrawContainerRef.current,
+ EVENT.WHEEL,
+ this.handleWheel,
+ { passive: false },
+ ),
+ addEventListener(window, EVENT.MESSAGE, this.onWindowMessage, false),
+ addEventListener(document, EVENT.POINTER_UP, this.removePointer, {
+ passive: false,
+ }), // #3553
+ addEventListener(document, EVENT.COPY, this.onCopy, { passive: false }),
+ addEventListener(document, EVENT.KEYUP, this.onKeyUp, { passive: true }),
+ addEventListener(
+ document,
+ EVENT.POINTER_MOVE,
+ this.updateCurrentCursorPosition,
+ { passive: false },
+ ),
+ // rerender text elements on font load to fix #637 && #1553
+ addEventListener(
+ document.fonts,
+ "loadingdone",
+ (event) => {
+ const fontFaces = (event as FontFaceSetLoadEvent).fontfaces;
+ this.fonts.onLoaded(fontFaces);
+ },
+ { passive: false },
+ ),
+ // Safari-only desktop pinch zoom
+ addEventListener(
+ document,
+ EVENT.GESTURE_START,
+ this.onGestureStart as any,
+ false,
+ ),
+ addEventListener(
+ document,
+ EVENT.GESTURE_CHANGE,
+ this.onGestureChange as any,
+ false,
+ ),
+ addEventListener(
+ document,
+ EVENT.GESTURE_END,
+ this.onGestureEnd as any,
+ false,
+ ),
+ addEventListener(
+ window,
+ EVENT.FOCUS,
+ () => {
+ this.maybeCleanupAfterMissingPointerUp(null);
+ // browsers (chrome?) tend to free up memory a lot, which results
+ // in canvas context being cleared. Thus re-render on focus.
+ this.triggerRender(true);
+ },
+ { passive: false },
+ ),
+ );
+
+ if (this.state.viewModeEnabled) {
+ return;
+ }
+
+ // -------------------------------------------------------------------------
+ // edit-mode listeners only
+ // -------------------------------------------------------------------------
+
+ this.onRemoveEventListenersEmitter.once(
+ addEventListener(
+ document,
+ EVENT.FULLSCREENCHANGE,
+ this.onFullscreenChange,
+ { passive: false },
+ ),
+ addEventListener(document, EVENT.PASTE, this.pasteFromClipboard, {
+ passive: false,
+ }),
+ addEventListener(document, EVENT.CUT, this.onCut, { passive: false }),
+ addEventListener(window, EVENT.RESIZE, this.onResize, false),
+ addEventListener(window, EVENT.UNLOAD, this.onUnload, false),
+ addEventListener(window, EVENT.BLUR, this.onBlur, false),
+ addEventListener(
+ this.excalidrawContainerRef.current,
+ EVENT.WHEEL,
+ this.handleWheel,
+ { passive: false },
+ ),
+ addEventListener(
+ this.excalidrawContainerRef.current,
+ EVENT.DRAG_OVER,
+ this.disableEvent,
+ false,
+ ),
+ addEventListener(
+ this.excalidrawContainerRef.current,
+ EVENT.DROP,
+ this.disableEvent,
+ false,
+ ),
+ );
+
+ if (this.props.detectScroll) {
+ this.onRemoveEventListenersEmitter.once(
+ addEventListener(
+ getNearestScrollableContainer(this.excalidrawContainerRef.current!),
+ EVENT.SCROLL,
+ this.onScroll,
+ { passive: false },
+ ),
+ );
+ }
+ }
+
+ componentDidUpdate(prevProps: AppProps, prevState: AppState) {
+ this.updateEmbeddables();
+ const elements = this.scene.getElementsIncludingDeleted();
+ const elementsMap = this.scene.getElementsMapIncludingDeleted();
+ const nonDeletedElementsMap = this.scene.getNonDeletedElementsMap();
+
+ if (!this.state.showWelcomeScreen && !elements.length) {
+ this.setState({ showWelcomeScreen: true });
+ }
+
+ if (
+ prevProps.UIOptions.dockedSidebarBreakpoint !==
+ this.props.UIOptions.dockedSidebarBreakpoint
+ ) {
+ this.refreshEditorBreakpoints();
+ }
+
+ const hasFollowedPersonLeft =
+ prevState.userToFollow &&
+ !this.state.collaborators.has(prevState.userToFollow.socketId);
+
+ if (hasFollowedPersonLeft) {
+ this.maybeUnfollowRemoteUser();
+ }
+
+ if (
+ prevState.zoom.value !== this.state.zoom.value ||
+ prevState.scrollX !== this.state.scrollX ||
+ prevState.scrollY !== this.state.scrollY
+ ) {
+ this.props?.onScrollChange?.(
+ this.state.scrollX,
+ this.state.scrollY,
+ this.state.zoom,
+ );
+ this.onScrollChangeEmitter.trigger(
+ this.state.scrollX,
+ this.state.scrollY,
+ this.state.zoom,
+ );
+ }
+
+ if (prevState.userToFollow !== this.state.userToFollow) {
+ if (prevState.userToFollow) {
+ this.onUserFollowEmitter.trigger({
+ userToFollow: prevState.userToFollow,
+ action: "UNFOLLOW",
+ });
+ }
+
+ if (this.state.userToFollow) {
+ this.onUserFollowEmitter.trigger({
+ userToFollow: this.state.userToFollow,
+ action: "FOLLOW",
+ });
+ }
+ }
+
+ if (
+ Object.keys(this.state.selectedElementIds).length &&
+ isEraserActive(this.state)
+ ) {
+ this.setState({
+ activeTool: updateActiveTool(this.state, { type: "selection" }),
+ });
+ }
+ if (
+ this.state.activeTool.type === "eraser" &&
+ prevState.theme !== this.state.theme
+ ) {
+ setEraserCursor(this.interactiveCanvas, this.state.theme);
+ }
+ // Hide hyperlink popup if shown when element type is not selection
+ if (
+ prevState.activeTool.type === "selection" &&
+ this.state.activeTool.type !== "selection" &&
+ this.state.showHyperlinkPopup
+ ) {
+ this.setState({ showHyperlinkPopup: false });
+ }
+ if (prevProps.langCode !== this.props.langCode) {
+ this.updateLanguage();
+ }
+
+ if (isEraserActive(prevState) && !isEraserActive(this.state)) {
+ this.eraserTrail.endPath();
+ }
+
+ if (prevProps.viewModeEnabled !== this.props.viewModeEnabled) {
+ this.setState({ viewModeEnabled: !!this.props.viewModeEnabled });
+ }
+
+ if (prevState.viewModeEnabled !== this.state.viewModeEnabled) {
+ this.addEventListeners();
+ this.deselectElements();
+ }
+
+ // cleanup
+ if (
+ (prevState.openDialog?.name === "elementLinkSelector" ||
+ this.state.openDialog?.name === "elementLinkSelector") &&
+ prevState.openDialog?.name !== this.state.openDialog?.name
+ ) {
+ this.deselectElements();
+ this.setState({
+ hoveredElementIds: {},
+ });
+ }
+
+ if (prevProps.zenModeEnabled !== this.props.zenModeEnabled) {
+ this.setState({ zenModeEnabled: !!this.props.zenModeEnabled });
+ }
+
+ if (prevProps.theme !== this.props.theme && this.props.theme) {
+ this.setState({ theme: this.props.theme });
+ }
+
+ this.excalidrawContainerRef.current?.classList.toggle(
+ "theme--dark",
+ this.state.theme === THEME.DARK,
+ );
+
+ if (
+ this.state.editingLinearElement &&
+ !this.state.selectedElementIds[this.state.editingLinearElement.elementId]
+ ) {
+ // defer so that the shouldCaptureIncrement flag isn't reset via current update
+ setTimeout(() => {
+ // execute only if the condition still holds when the deferred callback
+ // executes (it can be scheduled multiple times depending on how
+ // many times the component renders)
+ this.state.editingLinearElement &&
+ this.actionManager.executeAction(actionFinalize);
+ });
+ }
+
+ // failsafe in case the state is being updated in incorrect order resulting
+ // in the editingTextElement being now a deleted element
+ if (this.state.editingTextElement?.isDeleted) {
+ this.setState({ editingTextElement: null });
+ }
+
+ if (
+ this.state.selectedLinearElement &&
+ !this.state.selectedElementIds[this.state.selectedLinearElement.elementId]
+ ) {
+ // To make sure `selectedLinearElement` is in sync with `selectedElementIds`, however this shouldn't be needed once
+ // we have a single API to update `selectedElementIds`
+ this.setState({ selectedLinearElement: null });
+ }
+
+ const { multiElement } = prevState;
+ if (
+ prevState.activeTool !== this.state.activeTool &&
+ multiElement != null &&
+ isBindingEnabled(this.state) &&
+ isBindingElement(multiElement, false)
+ ) {
+ maybeBindLinearElement(
+ multiElement,
+ this.state,
+ tupleToCoors(
+ LinearElementEditor.getPointAtIndexGlobalCoordinates(
+ multiElement,
+ -1,
+ nonDeletedElementsMap,
+ ),
+ ),
+ this.scene.getNonDeletedElementsMap(),
+ this.scene.getNonDeletedElements(),
+ );
+ }
+
+ this.store.commit(elementsMap, this.state);
+
+ // Do not notify consumers if we're still loading the scene. Among other
+ // potential issues, this fixes a case where the tab isn't focused during
+ // init, which would trigger onChange with empty elements, which would then
+ // override whatever is in localStorage currently.
+ if (!this.state.isLoading) {
+ this.props.onChange?.(elements, this.state, this.files);
+ this.onChangeEmitter.trigger(elements, this.state, this.files);
+ }
+ }
+
+ private renderInteractiveSceneCallback = ({
+ atLeastOneVisibleElement,
+ scrollBars,
+ elementsMap,
+ }: RenderInteractiveSceneCallback) => {
+ if (scrollBars) {
+ currentScrollBars = scrollBars;
+ }
+ const scrolledOutside =
+ // hide when editing text
+ this.state.editingTextElement
+ ? false
+ : !atLeastOneVisibleElement && elementsMap.size > 0;
+ if (this.state.scrolledOutside !== scrolledOutside) {
+ this.setState({ scrolledOutside });
+ }
+
+ this.scheduleImageRefresh();
+ };
+
+ private onScroll = debounce(() => {
+ const { offsetTop, offsetLeft } = this.getCanvasOffsets();
+ this.setState((state) => {
+ if (state.offsetLeft === offsetLeft && state.offsetTop === offsetTop) {
+ return null;
+ }
+ return { offsetTop, offsetLeft };
+ });
+ }, SCROLL_TIMEOUT);
+
+ // Copy/paste
+
+ private onCut = withBatchedUpdates((event: ClipboardEvent) => {
+ const isExcalidrawActive = this.excalidrawContainerRef.current?.contains(
+ document.activeElement,
+ );
+ if (!isExcalidrawActive || isWritableElement(event.target)) {
+ return;
+ }
+ this.actionManager.executeAction(actionCut, "keyboard", event);
+ event.preventDefault();
+ event.stopPropagation();
+ });
+
+ private onCopy = withBatchedUpdates((event: ClipboardEvent) => {
+ const isExcalidrawActive = this.excalidrawContainerRef.current?.contains(
+ document.activeElement,
+ );
+ if (!isExcalidrawActive || isWritableElement(event.target)) {
+ return;
+ }
+ this.actionManager.executeAction(actionCopy, "keyboard", event);
+ event.preventDefault();
+ event.stopPropagation();
+ });
+
+ private static resetTapTwice() {
+ didTapTwice = false;
+ }
+
+ private onTouchStart = (event: TouchEvent) => {
+ // fix for Apple Pencil Scribble (do not prevent for other devices)
+ if (isIOS) {
+ event.preventDefault();
+ }
+
+ if (!didTapTwice) {
+ didTapTwice = true;
+ clearTimeout(tappedTwiceTimer);
+ tappedTwiceTimer = window.setTimeout(
+ App.resetTapTwice,
+ TAP_TWICE_TIMEOUT,
+ );
+ return;
+ }
+ // insert text only if we tapped twice with a single finger
+ // event.touches.length === 1 will also prevent inserting text when user's zooming
+ if (didTapTwice && event.touches.length === 1) {
+ const touch = event.touches[0];
+ // @ts-ignore
+ this.handleCanvasDoubleClick({
+ clientX: touch.clientX,
+ clientY: touch.clientY,
+ });
+ didTapTwice = false;
+ clearTimeout(tappedTwiceTimer);
+ }
+
+ if (event.touches.length === 2) {
+ this.setState({
+ selectedElementIds: makeNextSelectedElementIds({}, this.state),
+ activeEmbeddable: null,
+ });
+ }
+ };
+
+ private onTouchEnd = (event: TouchEvent) => {
+ this.resetContextMenuTimer();
+ if (event.touches.length > 0) {
+ this.setState({
+ previousSelectedElementIds: {},
+ selectedElementIds: makeNextSelectedElementIds(
+ this.state.previousSelectedElementIds,
+ this.state,
+ ),
+ });
+ } else {
+ gesture.pointers.clear();
+ }
+ };
+
+ public pasteFromClipboard = withBatchedUpdates(
+ async (event: ClipboardEvent) => {
+ const isPlainPaste = !!IS_PLAIN_PASTE;
+
+ // #686
+ const target = document.activeElement;
+ const isExcalidrawActive =
+ this.excalidrawContainerRef.current?.contains(target);
+ if (event && !isExcalidrawActive) {
+ return;
+ }
+
+ const elementUnderCursor = document.elementFromPoint(
+ this.lastViewportPosition.x,
+ this.lastViewportPosition.y,
+ );
+ if (
+ event &&
+ (!(elementUnderCursor instanceof HTMLCanvasElement) ||
+ isWritableElement(target))
+ ) {
+ return;
+ }
+
+ const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
+ {
+ clientX: this.lastViewportPosition.x,
+ clientY: this.lastViewportPosition.y,
+ },
+ this.state,
+ );
+
+ // must be called in the same frame (thus before any awaits) as the paste
+ // event else some browsers (FF...) will clear the clipboardData
+ // (something something security)
+ let file = event?.clipboardData?.files[0];
+ const data = await parseClipboard(event, isPlainPaste);
+ if (!file && !isPlainPaste) {
+ if (data.mixedContent) {
+ return this.addElementsFromMixedContentPaste(data.mixedContent, {
+ isPlainPaste,
+ sceneX,
+ sceneY,
+ });
+ } else if (data.text) {
+ const string = data.text.trim();
+ if (string.startsWith("<svg") && string.endsWith("</svg>")) {
+ // ignore SVG validation/normalization which will be done during image
+ // initialization
+ file = SVGStringToFile(string);
+ }
+ }
+ }
+
+ // prefer spreadsheet data over image file (MS Office/Libre Office)
+ if (isSupportedImageFile(file) && !data.spreadsheet) {
+ if (!this.isToolSupported("image")) {
+ this.setState({ errorMessage: t("errors.imageToolNotSupported") });
+ return;
+ }
+
+ const imageElement = this.createImageElement({ sceneX, sceneY });
+ this.insertImageElement(imageElement, file);
+ this.initializeImageDimensions(imageElement);
+ this.setState({
+ selectedElementIds: makeNextSelectedElementIds(
+ {
+ [imageElement.id]: true,
+ },
+ this.state,
+ ),
+ });
+
+ return;
+ }
+
+ if (this.props.onPaste) {
+ try {
+ if ((await this.props.onPaste(data, event)) === false) {
+ return;
+ }
+ } catch (error: any) {
+ console.error(error);
+ }
+ }
+
+ if (data.errorMessage) {
+ this.setState({ errorMessage: data.errorMessage });
+ } else if (data.spreadsheet && !isPlainPaste) {
+ this.setState({
+ pasteDialog: {
+ data: data.spreadsheet,
+ shown: true,
+ },
+ });
+ } else if (data.elements) {
+ const elements = (
+ data.programmaticAPI
+ ? convertToExcalidrawElements(
+ data.elements as ExcalidrawElementSkeleton[],
+ )
+ : data.elements
+ ) as readonly ExcalidrawElement[];
+ // TODO remove formatting from elements if isPlainPaste
+ this.addElementsFromPasteOrLibrary({
+ elements,
+ files: data.files || null,
+ position: "cursor",
+ retainSeed: isPlainPaste,
+ });
+ } else if (data.text) {
+ if (data.text && isMaybeMermaidDefinition(data.text)) {
+ const api = await import("@excalidraw/mermaid-to-excalidraw");
+
+ try {
+ const { elements: skeletonElements, files } =
+ await api.parseMermaidToExcalidraw(data.text);
+
+ const elements = convertToExcalidrawElements(skeletonElements, {
+ regenerateIds: true,
+ });
+
+ this.addElementsFromPasteOrLibrary({
+ elements,
+ files,
+ position: "cursor",
+ });
+
+ return;
+ } catch (err: any) {
+ console.warn(
+ `parsing pasted text as mermaid definition failed: ${err.message}`,
+ );
+ }
+ }
+
+ const nonEmptyLines = normalizeEOL(data.text)
+ .split(/\n+/)
+ .map((s) => s.trim())
+ .filter(Boolean);
+
+ const embbeddableUrls = nonEmptyLines
+ .map((str) => maybeParseEmbedSrc(str))
+ .filter((string) => {
+ return (
+ embeddableURLValidator(string, this.props.validateEmbeddable) &&
+ (/^(http|https):\/\/[^\s/$.?#].[^\s]*$/.test(string) ||
+ getEmbedLink(string)?.type === "video")
+ );
+ });
+
+ if (
+ !IS_PLAIN_PASTE &&
+ embbeddableUrls.length > 0 &&
+ // if there were non-embeddable text (lines) mixed in with embeddable
+ // urls, ignore and paste as text
+ embbeddableUrls.length === nonEmptyLines.length
+ ) {
+ const embeddables: NonDeleted<ExcalidrawEmbeddableElement>[] = [];
+ for (const url of embbeddableUrls) {
+ const prevEmbeddable: ExcalidrawEmbeddableElement | undefined =
+ embeddables[embeddables.length - 1];
+ const embeddable = this.insertEmbeddableElement({
+ sceneX: prevEmbeddable
+ ? prevEmbeddable.x + prevEmbeddable.width + 20
+ : sceneX,
+ sceneY,
+ link: normalizeLink(url),
+ });
+ if (embeddable) {
+ embeddables.push(embeddable);
+ }
+ }
+ if (embeddables.length) {
+ this.setState({
+ selectedElementIds: Object.fromEntries(
+ embeddables.map((embeddable) => [embeddable.id, true]),
+ ),
+ });
+ }
+ return;
+ }
+ this.addTextFromPaste(data.text, isPlainPaste);
+ }
+ this.setActiveTool({ type: "selection" });
+ event?.preventDefault();
+ },
+ );
+
+ addElementsFromPasteOrLibrary = (opts: {
+ elements: readonly ExcalidrawElement[];
+ files: BinaryFiles | null;
+ position: { clientX: number; clientY: number } | "cursor" | "center";
+ retainSeed?: boolean;
+ fitToContent?: boolean;
+ }) => {
+ const elements = restoreElements(opts.elements, null, undefined);
+ const [minX, minY, maxX, maxY] = getCommonBounds(elements);
+
+ const elementsCenterX = distance(minX, maxX) / 2;
+ const elementsCenterY = distance(minY, maxY) / 2;
+
+ const clientX =
+ typeof opts.position === "object"
+ ? opts.position.clientX
+ : opts.position === "cursor"
+ ? this.lastViewportPosition.x
+ : this.state.width / 2 + this.state.offsetLeft;
+ const clientY =
+ typeof opts.position === "object"
+ ? opts.position.clientY
+ : opts.position === "cursor"
+ ? this.lastViewportPosition.y
+ : this.state.height / 2 + this.state.offsetTop;
+
+ const { x, y } = viewportCoordsToSceneCoords(
+ { clientX, clientY },
+ this.state,
+ );
+
+ const dx = x - elementsCenterX;
+ const dy = y - elementsCenterY;
+
+ const [gridX, gridY] = getGridPoint(dx, dy, this.getEffectiveGridSize());
+
+ const newElements = duplicateElements(
+ elements.map((element) => {
+ return newElementWith(element, {
+ x: element.x + gridX - minX,
+ y: element.y + gridY - minY,
+ });
+ }),
+ {
+ randomizeSeed: !opts.retainSeed,
+ },
+ );
+
+ const prevElements = this.scene.getElementsIncludingDeleted();
+ let nextElements = [...prevElements, ...newElements];
+
+ const mappedNewSceneElements = this.props.onDuplicate?.(
+ nextElements,
+ prevElements,
+ );
+
+ nextElements = mappedNewSceneElements || nextElements;
+
+ syncMovedIndices(nextElements, arrayToMap(newElements));
+
+ const topLayerFrame = this.getTopLayerFrameAtSceneCoords({ x, y });
+
+ if (topLayerFrame) {
+ const eligibleElements = filterElementsEligibleAsFrameChildren(
+ newElements,
+ topLayerFrame,
+ );
+ addElementsToFrame(
+ nextElements,
+ eligibleElements,
+ topLayerFrame,
+ this.state,
+ );
+ }
+
+ this.scene.replaceAllElements(nextElements);
+
+ newElements.forEach((newElement) => {
+ if (isTextElement(newElement) && isBoundToContainer(newElement)) {
+ const container = getContainerElement(
+ newElement,
+ this.scene.getElementsMapIncludingDeleted(),
+ );
+ redrawTextBoundingBox(
+ newElement,
+ container,
+ this.scene.getElementsMapIncludingDeleted(),
+ );
+ }
+ });
+
+ // paste event may not fire FontFace loadingdone event in Safari, hence loading font faces manually
+ if (isSafari) {
+ Fonts.loadElementsFonts(newElements).then((fontFaces) => {
+ this.fonts.onLoaded(fontFaces);
+ });
+ }
+
+ if (opts.files) {
+ this.addMissingFiles(opts.files);
+ }
+
+ this.store.shouldCaptureIncrement();
+
+ const nextElementsToSelect =
+ excludeElementsInFramesFromSelection(newElements);
+
+ this.setState(
+ {
+ ...this.state,
+ // keep sidebar (presumably the library) open if it's docked and
+ // can fit.
+ //
+ // Note, we should close the sidebar only if we're dropping items
+ // from library, not when pasting from clipboard. Alas.
+ openSidebar:
+ this.state.openSidebar &&
+ this.device.editor.canFitSidebar &&
+ editorJotaiStore.get(isSidebarDockedAtom)
+ ? this.state.openSidebar
+ : null,
+ ...selectGroupsForSelectedElements(
+ {
+ editingGroupId: null,
+ selectedElementIds: nextElementsToSelect.reduce(
+ (acc: Record<ExcalidrawElement["id"], true>, element) => {
+ if (!isBoundToContainer(element)) {
+ acc[element.id] = true;
+ }
+ return acc;
+ },
+ {},
+ ),
+ },
+ this.scene.getNonDeletedElements(),
+ this.state,
+ this,
+ ),
+ },
+ () => {
+ if (opts.files) {
+ this.addNewImagesToImageCache();
+ }
+ },
+ );
+ this.setActiveTool({ type: "selection" });
+
+ if (opts.fitToContent) {
+ this.scrollToContent(newElements, {
+ fitToContent: true,
+ canvasOffsets: this.getEditorUIOffsets(),
+ });
+ }
+ };
+
+ // TODO rewrite this to paste both text & images at the same time if
+ // pasted data contains both
+ private async addElementsFromMixedContentPaste(
+ mixedContent: PastedMixedContent,
+ {
+ isPlainPaste,
+ sceneX,
+ sceneY,
+ }: { isPlainPaste: boolean; sceneX: number; sceneY: number },
+ ) {
+ if (
+ !isPlainPaste &&
+ mixedContent.some((node) => node.type === "imageUrl") &&
+ this.isToolSupported("image")
+ ) {
+ const imageURLs = mixedContent
+ .filter((node) => node.type === "imageUrl")
+ .map((node) => node.value);
+ const responses = await Promise.all(
+ imageURLs.map(async (url) => {
+ try {
+ return { file: await ImageURLToFile(url) };
+ } catch (error: any) {
+ let errorMessage = error.message;
+ if (error.cause === "FETCH_ERROR") {
+ errorMessage = t("errors.failedToFetchImage");
+ } else if (error.cause === "UNSUPPORTED") {
+ errorMessage = t("errors.unsupportedFileType");
+ }
+ return { errorMessage };
+ }
+ }),
+ );
+ let y = sceneY;
+ let firstImageYOffsetDone = false;
+ const nextSelectedIds: Record<ExcalidrawElement["id"], true> = {};
+ for (const response of responses) {
+ if (response.file) {
+ const imageElement = this.createImageElement({
+ sceneX,
+ sceneY: y,
+ });
+
+ const initializedImageElement = await this.insertImageElement(
+ imageElement,
+ response.file,
+ );
+ if (initializedImageElement) {
+ // vertically center first image in the batch
+ if (!firstImageYOffsetDone) {
+ firstImageYOffsetDone = true;
+ y -= initializedImageElement.height / 2;
+ }
+ // hack to reset the `y` coord because we vertically center during
+ // insertImageElement
+ mutateElement(initializedImageElement, { y }, false);
+
+ y = imageElement.y + imageElement.height + 25;
+
+ nextSelectedIds[imageElement.id] = true;
+ }
+ }
+ }
+
+ this.setState({
+ selectedElementIds: makeNextSelectedElementIds(
+ nextSelectedIds,
+ this.state,
+ ),
+ });
+
+ const error = responses.find((response) => !!response.errorMessage);
+ if (error && error.errorMessage) {
+ this.setState({ errorMessage: error.errorMessage });
+ }
+ } else {
+ const textNodes = mixedContent.filter((node) => node.type === "text");
+ if (textNodes.length) {
+ this.addTextFromPaste(
+ textNodes.map((node) => node.value).join("\n\n"),
+ isPlainPaste,
+ );
+ }
+ }
+ }
+
+ private addTextFromPaste(text: string, isPlainPaste = false) {
+ const { x, y } = viewportCoordsToSceneCoords(
+ {
+ clientX: this.lastViewportPosition.x,
+ clientY: this.lastViewportPosition.y,
+ },
+ this.state,
+ );
+
+ const textElementProps = {
+ x,
+ y,
+ strokeColor: this.state.currentItemStrokeColor,
+ backgroundColor: this.state.currentItemBackgroundColor,
+ fillStyle: this.state.currentItemFillStyle,
+ strokeWidth: this.state.currentItemStrokeWidth,
+ strokeStyle: this.state.currentItemStrokeStyle,
+ roundness: null,
+ roughness: this.state.currentItemRoughness,
+ opacity: this.state.currentItemOpacity,
+ text,
+ fontSize: this.state.currentItemFontSize,
+ fontFamily: this.state.currentItemFontFamily,
+ textAlign: DEFAULT_TEXT_ALIGN,
+ verticalAlign: DEFAULT_VERTICAL_ALIGN,
+ locked: false,
+ };
+ const fontString = getFontString({
+ fontSize: textElementProps.fontSize,
+ fontFamily: textElementProps.fontFamily,
+ });
+ const lineHeight = getLineHeight(textElementProps.fontFamily);
+ const [x1, , x2] = getVisibleSceneBounds(this.state);
+ // long texts should not go beyond 800 pixels in width nor should it go below 200 px
+ const maxTextWidth = Math.max(Math.min((x2 - x1) * 0.5, 800), 200);
+ const LINE_GAP = 10;
+ let currentY = y;
+
+ const lines = isPlainPaste ? [text] : text.split("\n");
+ const textElements = lines.reduce(
+ (acc: ExcalidrawTextElement[], line, idx) => {
+ const originalText = normalizeText(line).trim();
+ if (originalText.length) {
+ const topLayerFrame = this.getTopLayerFrameAtSceneCoords({
+ x,
+ y: currentY,
+ });
+
+ let metrics = measureText(originalText, fontString, lineHeight);
+ const isTextUnwrapped = metrics.width > maxTextWidth;
+
+ const text = isTextUnwrapped
+ ? wrapText(originalText, fontString, maxTextWidth)
+ : originalText;
+
+ metrics = isTextUnwrapped
+ ? measureText(text, fontString, lineHeight)
+ : metrics;
+
+ const startX = x - metrics.width / 2;
+ const startY = currentY - metrics.height / 2;
+
+ const element = newTextElement({
+ ...textElementProps,
+ x: startX,
+ y: startY,
+ text,
+ originalText,
+ lineHeight,
+ autoResize: !isTextUnwrapped,
+ frameId: topLayerFrame ? topLayerFrame.id : null,
+ });
+ acc.push(element);
+ currentY += element.height + LINE_GAP;
+ } else {
+ const prevLine = lines[idx - 1]?.trim();
+ // add paragraph only if previous line was not empty, IOW don't add
+ // more than one empty line
+ if (prevLine) {
+ currentY +=
+ getLineHeightInPx(textElementProps.fontSize, lineHeight) +
+ LINE_GAP;
+ }
+ }
+
+ return acc;
+ },
+ [],
+ );
+
+ if (textElements.length === 0) {
+ return;
+ }
+
+ this.scene.insertElements(textElements);
+
+ this.setState({
+ selectedElementIds: makeNextSelectedElementIds(
+ Object.fromEntries(textElements.map((el) => [el.id, true])),
+ this.state,
+ ),
+ });
+
+ if (
+ !isPlainPaste &&
+ textElements.length > 1 &&
+ PLAIN_PASTE_TOAST_SHOWN === false &&
+ !this.device.editor.isMobile
+ ) {
+ this.setToast({
+ message: t("toast.pasteAsSingleElement", {
+ shortcut: getShortcutKey("CtrlOrCmd+Shift+V"),
+ }),
+ duration: 5000,
+ });
+ PLAIN_PASTE_TOAST_SHOWN = true;
+ }
+
+ this.store.shouldCaptureIncrement();
+ }
+
+ setAppState: React.Component<any, AppState>["setState"] = (
+ state,
+ callback,
+ ) => {
+ this.setState(state, callback);
+ };
+
+ removePointer = (event: React.PointerEvent<HTMLElement> | PointerEvent) => {
+ if (touchTimeout) {
+ this.resetContextMenuTimer();
+ }
+
+ gesture.pointers.delete(event.pointerId);
+ };
+
+ toggleLock = (source: "keyboard" | "ui" = "ui") => {
+ if (!this.state.activeTool.locked) {
+ trackEvent(
+ "toolbar",
+ "toggleLock",
+ `${source} (${this.device.editor.isMobile ? "mobile" : "desktop"})`,
+ );
+ }
+ this.setState((prevState) => {
+ return {
+ activeTool: {
+ ...prevState.activeTool,
+ ...updateActiveTool(
+ this.state,
+ prevState.activeTool.locked
+ ? { type: "selection" }
+ : prevState.activeTool,
+ ),
+ locked: !prevState.activeTool.locked,
+ },
+ };
+ });
+ };
+
+ updateFrameRendering = (
+ opts:
+ | Partial<AppState["frameRendering"]>
+ | ((
+ prevState: AppState["frameRendering"],
+ ) => Partial<AppState["frameRendering"]>),
+ ) => {
+ this.setState((prevState) => {
+ const next =
+ typeof opts === "function" ? opts(prevState.frameRendering) : opts;
+ return {
+ frameRendering: {
+ enabled: next?.enabled ?? prevState.frameRendering.enabled,
+ clip: next?.clip ?? prevState.frameRendering.clip,
+ name: next?.name ?? prevState.frameRendering.name,
+ outline: next?.outline ?? prevState.frameRendering.outline,
+ },
+ };
+ });
+ };
+
+ togglePenMode = (force: boolean | null) => {
+ this.setState((prevState) => {
+ return {
+ penMode: force ?? !prevState.penMode,
+ penDetected: true,
+ };
+ });
+ };
+
+ onHandToolToggle = () => {
+ this.actionManager.executeAction(actionToggleHandTool);
+ };
+
+ /**
+ * Zooms on canvas viewport center
+ */
+ zoomCanvas = (
+ /**
+ * Decimal fraction, auto-clamped between MIN_ZOOM and MAX_ZOOM.
+ * 1 = 100% zoom, 2 = 200% zoom, 0.5 = 50% zoom
+ */
+ value: number,
+ ) => {
+ this.setState({
+ ...getStateForZoom(
+ {
+ viewportX: this.state.width / 2 + this.state.offsetLeft,
+ viewportY: this.state.height / 2 + this.state.offsetTop,
+ nextZoom: getNormalizedZoom(value),
+ },
+ this.state,
+ ),
+ });
+ };
+
+ private cancelInProgressAnimation: (() => void) | null = null;
+
+ scrollToContent = (
+ /**
+ * target to scroll to
+ *
+ * - string - id of element or group, or url containing elementLink
+ * - ExcalidrawElement | ExcalidrawElement[] - element(s) objects
+ */
+ target:
+ | string
+ | ExcalidrawElement
+ | readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(),
+ opts?: (
+ | {
+ fitToContent?: boolean;
+ fitToViewport?: never;
+ viewportZoomFactor?: number;
+ animate?: boolean;
+ duration?: number;
+ }
+ | {
+ fitToContent?: never;
+ fitToViewport?: boolean;
+ /** when fitToViewport=true, how much screen should the content cover,
+ * between 0.1 (10%) and 1 (100%)
+ */
+ viewportZoomFactor?: number;
+ animate?: boolean;
+ duration?: number;
+ }
+ ) & {
+ minZoom?: number;
+ maxZoom?: number;
+ canvasOffsets?: Offsets;
+ },
+ ) => {
+ if (typeof target === "string") {
+ let id: string | null;
+ if (isElementLink(target)) {
+ id = parseElementLinkFromURL(target);
+ } else {
+ id = target;
+ }
+ if (id) {
+ const elements = this.scene.getElementsFromId(id);
+
+ if (elements?.length) {
+ this.scrollToContent(elements, {
+ fitToContent: opts?.fitToContent ?? true,
+ animate: opts?.animate ?? true,
+ });
+ } else if (isElementLink(target)) {
+ this.setState({
+ toast: {
+ message: t("elementLink.notFound"),
+ duration: 3000,
+ closable: true,
+ },
+ });
+ }
+ }
+ return;
+ }
+
+ this.cancelInProgressAnimation?.();
+
+ // convert provided target into ExcalidrawElement[] if necessary
+ const targetElements = Array.isArray(target) ? target : [target];
+
+ let zoom = this.state.zoom;
+ let scrollX = this.state.scrollX;
+ let scrollY = this.state.scrollY;
+
+ if (opts?.fitToContent || opts?.fitToViewport) {
+ const { appState } = zoomToFit({
+ canvasOffsets: opts.canvasOffsets,
+ targetElements,
+ appState: this.state,
+ fitToViewport: !!opts?.fitToViewport,
+ viewportZoomFactor: opts?.viewportZoomFactor,
+ minZoom: opts?.minZoom,
+ maxZoom: opts?.maxZoom,
+ });
+ zoom = appState.zoom;
+ scrollX = appState.scrollX;
+ scrollY = appState.scrollY;
+ } else {
+ // compute only the viewport location, without any zoom adjustment
+ const scroll = calculateScrollCenter(targetElements, this.state);
+ scrollX = scroll.scrollX;
+ scrollY = scroll.scrollY;
+ }
+
+ // when animating, we use RequestAnimationFrame to prevent the animation
+ // from slowing down other processes
+ if (opts?.animate) {
+ const origScrollX = this.state.scrollX;
+ const origScrollY = this.state.scrollY;
+ const origZoom = this.state.zoom.value;
+
+ const cancel = easeToValuesRAF({
+ fromValues: {
+ scrollX: origScrollX,
+ scrollY: origScrollY,
+ zoom: origZoom,
+ },
+ toValues: { scrollX, scrollY, zoom: zoom.value },
+ interpolateValue: (from, to, progress, key) => {
+ // for zoom, use different easing
+ if (key === "zoom") {
+ return from * Math.pow(to / from, easeOut(progress));
+ }
+ // handle using default
+ return undefined;
+ },
+ onStep: ({ scrollX, scrollY, zoom }) => {
+ this.setState({
+ scrollX,
+ scrollY,
+ zoom: { value: zoom },
+ });
+ },
+ onStart: () => {
+ this.setState({ shouldCacheIgnoreZoom: true });
+ },
+ onEnd: () => {
+ this.setState({ shouldCacheIgnoreZoom: false });
+ },
+ onCancel: () => {
+ this.setState({ shouldCacheIgnoreZoom: false });
+ },
+ duration: opts?.duration ?? 500,
+ });
+
+ this.cancelInProgressAnimation = () => {
+ cancel();
+ this.cancelInProgressAnimation = null;
+ };
+ } else {
+ this.setState({ scrollX, scrollY, zoom });
+ }
+ };
+
+ private maybeUnfollowRemoteUser = () => {
+ if (this.state.userToFollow) {
+ this.setState({ userToFollow: null });
+ }
+ };
+
+ /** use when changing scrollX/scrollY/zoom based on user interaction */
+ private translateCanvas: React.Component<any, AppState>["setState"] = (
+ state,
+ ) => {
+ this.cancelInProgressAnimation?.();
+ this.maybeUnfollowRemoteUser();
+ this.setState(state);
+ };
+
+ setToast = (
+ toast: {
+ message: string;
+ closable?: boolean;
+ duration?: number;
+ } | null,
+ ) => {
+ this.setState({ toast });
+ };
+
+ restoreFileFromShare = async () => {
+ try {
+ const webShareTargetCache = await caches.open("web-share-target");
+
+ const response = await webShareTargetCache.match("shared-file");
+ if (response) {
+ const blob = await response.blob();
+ const file = new File([blob], blob.name || "", { type: blob.type });
+ this.loadFileToCanvas(file, null);
+ await webShareTargetCache.delete("shared-file");
+ window.history.replaceState(null, APP_NAME, window.location.pathname);
+ }
+ } catch (error: any) {
+ this.setState({ errorMessage: error.message });
+ }
+ };
+
+ /**
+ * adds supplied files to existing files in the appState.
+ * NOTE if file already exists in editor state, the file data is not updated
+ * */
+ public addFiles: ExcalidrawImperativeAPI["addFiles"] = withBatchedUpdates(
+ (files) => {
+ const { addedFiles } = this.addMissingFiles(files);
+
+ this.clearImageShapeCache(addedFiles);
+ this.scene.triggerUpdate();
+
+ this.addNewImagesToImageCache();
+ },
+ );
+
+ private addMissingFiles = (
+ files: BinaryFiles | BinaryFileData[],
+ replace = false,
+ ) => {
+ const nextFiles = replace ? {} : { ...this.files };
+ const addedFiles: BinaryFiles = {};
+
+ const _files = Array.isArray(files) ? files : Object.values(files);
+
+ for (const fileData of _files) {
+ if (nextFiles[fileData.id]) {
+ continue;
+ }
+
+ addedFiles[fileData.id] = fileData;
+ nextFiles[fileData.id] = fileData;
+
+ if (fileData.mimeType === MIME_TYPES.svg) {
+ try {
+ const restoredDataURL = getDataURL_sync(
+ normalizeSVG(dataURLToString(fileData.dataURL)),
+ MIME_TYPES.svg,
+ );
+ if (fileData.dataURL !== restoredDataURL) {
+ // bump version so persistence layer can update the store
+ fileData.version = (fileData.version ?? 1) + 1;
+ fileData.dataURL = restoredDataURL;
+ }
+ } catch (error) {
+ console.error(error);
+ }
+ }
+ }
+
+ this.files = nextFiles;
+
+ return { addedFiles };
+ };
+
+ public updateScene = withBatchedUpdates(
+ <K extends keyof AppState>(sceneData: {
+ elements?: SceneData["elements"];
+ appState?: Pick<AppState, K> | null;
+ collaborators?: SceneData["collaborators"];
+ /**
+ * Controls which updates should be captured by the `Store`. Captured updates are emmitted and listened to by other components, such as `History` for undo / redo purposes.
+ *
+ * - `CaptureUpdateAction.IMMEDIATELY`: Updates are immediately undoable. Use for most local updates.
+ * - `CaptureUpdateAction.NEVER`: Updates never make it to undo/redo stack. Use for remote updates or scene initialization.
+ * - `CaptureUpdateAction.EVENTUALLY`: Updates will be eventually be captured as part of a future increment.
+ *
+ * Check [API docs](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props/excalidraw-api#captureUpdate) for more details.
+ *
+ * @default CaptureUpdateAction.EVENTUALLY
+ */
+ captureUpdate?: SceneData["captureUpdate"];
+ }) => {
+ const nextElements = syncInvalidIndices(sceneData.elements ?? []);
+
+ if (
+ sceneData.captureUpdate &&
+ sceneData.captureUpdate !== CaptureUpdateAction.EVENTUALLY
+ ) {
+ const prevCommittedAppState = this.store.snapshot.appState;
+ const prevCommittedElements = this.store.snapshot.elements;
+
+ const nextCommittedAppState = sceneData.appState
+ ? Object.assign({}, prevCommittedAppState, sceneData.appState) // new instance, with partial appstate applied to previously captured one, including hidden prop inside `prevCommittedAppState`
+ : prevCommittedAppState;
+
+ const nextCommittedElements = sceneData.elements
+ ? this.store.filterUncomittedElements(
+ this.scene.getElementsMapIncludingDeleted(), // Only used to detect uncomitted local elements
+ arrayToMap(nextElements), // We expect all (already reconciled) elements
+ )
+ : prevCommittedElements;
+
+ // WARN: store action always performs deep clone of changed elements, for ephemeral remote updates (i.e. remote dragging, resizing, drawing) we might consider doing something smarter
+ // do NOT schedule store actions (execute after re-render), as it might cause unexpected concurrency issues if not handled well
+ if (sceneData.captureUpdate === CaptureUpdateAction.IMMEDIATELY) {
+ this.store.captureIncrement(
+ nextCommittedElements,
+ nextCommittedAppState,
+ );
+ } else if (sceneData.captureUpdate === CaptureUpdateAction.NEVER) {
+ this.store.updateSnapshot(
+ nextCommittedElements,
+ nextCommittedAppState,
+ );
+ }
+ }
+
+ if (sceneData.appState) {
+ this.setState(sceneData.appState);
+ }
+
+ if (sceneData.elements) {
+ this.scene.replaceAllElements(nextElements);
+ }
+
+ if (sceneData.collaborators) {
+ this.setState({ collaborators: sceneData.collaborators });
+ }
+ },
+ );
+
+ private triggerRender = (
+ /** force always re-renders canvas even if no change */
+ force?: boolean,
+ ) => {
+ if (force === true) {
+ this.scene.triggerUpdate();
+ } else {
+ this.setState({});
+ }
+ };
+
+ /**
+ * @returns whether the menu was toggled on or off
+ */
+ public toggleSidebar = ({
+ name,
+ tab,
+ force,
+ }: {
+ name: SidebarName | null;
+ tab?: SidebarTabName;
+ force?: boolean;
+ }): boolean => {
+ let nextName;
+ if (force === undefined) {
+ nextName =
+ this.state.openSidebar?.name === name &&
+ this.state.openSidebar?.tab === tab
+ ? null
+ : name;
+ } else {
+ nextName = force ? name : null;
+ }
+
+ const nextState: AppState["openSidebar"] = nextName
+ ? { name: nextName }
+ : null;
+ if (nextState && tab) {
+ nextState.tab = tab;
+ }
+
+ this.setState({ openSidebar: nextState });
+
+ return !!nextName;
+ };
+
+ private updateCurrentCursorPosition = withBatchedUpdates(
+ (event: MouseEvent) => {
+ this.lastViewportPosition.x = event.clientX;
+ this.lastViewportPosition.y = event.clientY;
+ },
+ );
+
+ public getEditorUIOffsets = (): Offsets => {
+ const toolbarBottom =
+ this.excalidrawContainerRef?.current
+ ?.querySelector(".App-toolbar")
+ ?.getBoundingClientRect()?.bottom ?? 0;
+ const sidebarRect = this.excalidrawContainerRef?.current
+ ?.querySelector(".sidebar")
+ ?.getBoundingClientRect();
+ const propertiesPanelRect = this.excalidrawContainerRef?.current
+ ?.querySelector(".App-menu__left")
+ ?.getBoundingClientRect();
+
+ const PADDING = 16;
+
+ return getLanguage().rtl
+ ? {
+ top: toolbarBottom + PADDING,
+ right:
+ Math.max(
+ this.state.width -
+ (propertiesPanelRect?.left ?? this.state.width),
+ 0,
+ ) + PADDING,
+ bottom: PADDING,
+ left: Math.max(sidebarRect?.right ?? 0, 0) + PADDING,
+ }
+ : {
+ top: toolbarBottom + PADDING,
+ right: Math.max(
+ this.state.width -
+ (sidebarRect?.left ?? this.state.width) +
+ PADDING,
+ 0,
+ ),
+ bottom: PADDING,
+ left: Math.max(propertiesPanelRect?.right ?? 0, 0) + PADDING,
+ };
+ };
+
+ // Input handling
+ private onKeyDown = withBatchedUpdates(
+ (event: React.KeyboardEvent | KeyboardEvent) => {
+ // normalize `event.key` when CapsLock is pressed #2372
+
+ if (
+ "Proxy" in window &&
+ ((!event.shiftKey && /^[A-Z]$/.test(event.key)) ||
+ (event.shiftKey && /^[a-z]$/.test(event.key)))
+ ) {
+ event = new Proxy(event, {
+ get(ev: any, prop) {
+ const value = ev[prop];
+ if (typeof value === "function") {
+ // fix for Proxies hijacking `this`
+ return value.bind(ev);
+ }
+ return prop === "key"
+ ? // CapsLock inverts capitalization based on ShiftKey, so invert
+ // it back
+ event.shiftKey
+ ? ev.key.toUpperCase()
+ : ev.key.toLowerCase()
+ : value;
+ },
+ });
+ }
+
+ if (!isInputLike(event.target)) {
+ if (
+ (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
+ this.state.croppingElementId
+ ) {
+ this.finishImageCropping();
+ return;
+ }
+
+ const selectedElements = getSelectedElements(
+ this.scene.getNonDeletedElementsMap(),
+ this.state,
+ );
+
+ if (
+ selectedElements.length === 1 &&
+ isImageElement(selectedElements[0]) &&
+ event.key === KEYS.ENTER
+ ) {
+ this.startImageCropping(selectedElements[0]);
+ return;
+ }
+
+ if (
+ event.key === KEYS.ESCAPE &&
+ this.flowChartCreator.isCreatingChart
+ ) {
+ this.flowChartCreator.clear();
+ this.triggerRender(true);
+ return;
+ }
+
+ const arrowKeyPressed = isArrowKey(event.key);
+
+ if (event[KEYS.CTRL_OR_CMD] && arrowKeyPressed && !event.shiftKey) {
+ event.preventDefault();
+
+ const selectedElements = getSelectedElements(
+ this.scene.getNonDeletedElementsMap(),
+ this.state,
+ );
+
+ if (
+ selectedElements.length === 1 &&
+ isFlowchartNodeElement(selectedElements[0])
+ ) {
+ this.flowChartCreator.createNodes(
+ selectedElements[0],
+ this.scene.getNonDeletedElementsMap(),
+ this.state,
+ getLinkDirectionFromKey(event.key),
+ );
+ }
+
+ if (
+ this.flowChartCreator.pendingNodes?.length &&
+ !isElementCompletelyInViewport(
+ this.flowChartCreator.pendingNodes,
+ this.canvas.width / window.devicePixelRatio,
+ this.canvas.height / window.devicePixelRatio,
+ {
+ offsetLeft: this.state.offsetLeft,
+ offsetTop: this.state.offsetTop,
+ scrollX: this.state.scrollX,
+ scrollY: this.state.scrollY,
+ zoom: this.state.zoom,
+ },
+ this.scene.getNonDeletedElementsMap(),
+ this.getEditorUIOffsets(),
+ )
+ ) {
+ this.scrollToContent(this.flowChartCreator.pendingNodes, {
+ animate: true,
+ duration: 300,
+ fitToContent: true,
+ canvasOffsets: this.getEditorUIOffsets(),
+ });
+ }
+
+ return;
+ }
+
+ if (event.altKey) {
+ const selectedElements = getSelectedElements(
+ this.scene.getNonDeletedElementsMap(),
+ this.state,
+ );
+
+ if (selectedElements.length === 1 && arrowKeyPressed) {
+ event.preventDefault();
+
+ const nextId = this.flowChartNavigator.exploreByDirection(
+ selectedElements[0],
+ this.scene.getNonDeletedElementsMap(),
+ getLinkDirectionFromKey(event.key),
+ );
+
+ if (nextId) {
+ this.setState((prevState) => ({
+ selectedElementIds: makeNextSelectedElementIds(
+ {
+ [nextId]: true,
+ },
+ prevState,
+ ),
+ }));
+
+ const nextNode = this.scene
+ .getNonDeletedElementsMap()
+ .get(nextId);
+
+ if (
+ nextNode &&
+ !isElementCompletelyInViewport(
+ [nextNode],
+ this.canvas.width / window.devicePixelRatio,
+ this.canvas.height / window.devicePixelRatio,
+ {
+ offsetLeft: this.state.offsetLeft,
+ offsetTop: this.state.offsetTop,
+ scrollX: this.state.scrollX,
+ scrollY: this.state.scrollY,
+ zoom: this.state.zoom,
+ },
+ this.scene.getNonDeletedElementsMap(),
+ this.getEditorUIOffsets(),
+ )
+ ) {
+ this.scrollToContent(nextNode, {
+ animate: true,
+ duration: 300,
+ canvasOffsets: this.getEditorUIOffsets(),
+ });
+ }
+ }
+ return;
+ }
+ }
+ }
+
+ if (
+ event[KEYS.CTRL_OR_CMD] &&
+ event.key === KEYS.P &&
+ !event.shiftKey &&
+ !event.altKey
+ ) {
+ this.setToast({
+ message: t("commandPalette.shortcutHint", {
+ shortcut: getShortcutFromShortcutName("commandPalette"),
+ }),
+ });
+ event.preventDefault();
+ return;
+ }
+
+ if (event[KEYS.CTRL_OR_CMD] && event.key.toLowerCase() === KEYS.V) {
+ IS_PLAIN_PASTE = event.shiftKey;
+ clearTimeout(IS_PLAIN_PASTE_TIMER);
+ // reset (100ms to be safe that we it runs after the ensuing
+ // paste event). Though, technically unnecessary to reset since we
+ // (re)set the flag before each paste event.
+ IS_PLAIN_PASTE_TIMER = window.setTimeout(() => {
+ IS_PLAIN_PASTE = false;
+ }, 100);
+ }
+
+ // prevent browser zoom in input fields
+ if (event[KEYS.CTRL_OR_CMD] && isWritableElement(event.target)) {
+ if (event.code === CODES.MINUS || event.code === CODES.EQUAL) {
+ event.preventDefault();
+ return;
+ }
+ }
+
+ // bail if
+ if (
+ // inside an input
+ (isWritableElement(event.target) &&
+ // unless pressing escape (finalize action)
+ event.key !== KEYS.ESCAPE) ||
+ // or unless using arrows (to move between buttons)
+ (isArrowKey(event.key) && isInputLike(event.target))
+ ) {
+ return;
+ }
+
+ if (event.key === KEYS.QUESTION_MARK) {
+ this.setState({
+ openDialog: { name: "help" },
+ });
+ return;
+ } else if (
+ event.key.toLowerCase() === KEYS.E &&
+ event.shiftKey &&
+ event[KEYS.CTRL_OR_CMD]
+ ) {
+ event.preventDefault();
+ this.setState({ openDialog: { name: "imageExport" } });
+ return;
+ }
+
+ if (event.key === KEYS.PAGE_UP || event.key === KEYS.PAGE_DOWN) {
+ let offset =
+ (event.shiftKey ? this.state.width : this.state.height) /
+ this.state.zoom.value;
+ if (event.key === KEYS.PAGE_DOWN) {
+ offset = -offset;
+ }
+ if (event.shiftKey) {
+ this.translateCanvas((state) => ({
+ scrollX: state.scrollX + offset,
+ }));
+ } else {
+ this.translateCanvas((state) => ({
+ scrollY: state.scrollY + offset,
+ }));
+ }
+ }
+
+ if (this.state.openDialog?.name === "elementLinkSelector") {
+ return;
+ }
+
+ if (this.actionManager.handleKeyDown(event)) {
+ return;
+ }
+
+ if (this.state.viewModeEnabled) {
+ return;
+ }
+
+ if (event[KEYS.CTRL_OR_CMD] && this.state.isBindingEnabled) {
+ this.setState({ isBindingEnabled: false });
+ }
+
+ if (isArrowKey(event.key)) {
+ let selectedElements = this.scene.getSelectedElements({
+ selectedElementIds: this.state.selectedElementIds,
+ includeBoundTextElement: true,
+ includeElementsInFrames: true,
+ });
+
+ const elbowArrow = selectedElements.find(isElbowArrow) as
+ | ExcalidrawArrowElement
+ | undefined;
+
+ const arrowIdsToRemove = new Set<string>();
+
+ selectedElements
+ .filter(isElbowArrow)
+ .filter((arrow) => {
+ const startElementNotInSelection =
+ arrow.startBinding &&
+ !selectedElements.some(
+ (el) => el.id === arrow.startBinding?.elementId,
+ );
+ const endElementNotInSelection =
+ arrow.endBinding &&
+ !selectedElements.some(
+ (el) => el.id === arrow.endBinding?.elementId,
+ );
+ return startElementNotInSelection || endElementNotInSelection;
+ })
+ .forEach((arrow) => arrowIdsToRemove.add(arrow.id));
+
+ selectedElements = selectedElements.filter(
+ (el) => !arrowIdsToRemove.has(el.id),
+ );
+
+ const step =
+ (this.getEffectiveGridSize() &&
+ (event.shiftKey
+ ? ELEMENT_TRANSLATE_AMOUNT
+ : this.getEffectiveGridSize())) ||
+ (event.shiftKey
+ ? ELEMENT_SHIFT_TRANSLATE_AMOUNT
+ : ELEMENT_TRANSLATE_AMOUNT);
+
+ let offsetX = 0;
+ let offsetY = 0;
+
+ if (event.key === KEYS.ARROW_LEFT) {
+ offsetX = -step;
+ } else if (event.key === KEYS.ARROW_RIGHT) {
+ offsetX = step;
+ } else if (event.key === KEYS.ARROW_UP) {
+ offsetY = -step;
+ } else if (event.key === KEYS.ARROW_DOWN) {
+ offsetY = step;
+ }
+
+ selectedElements.forEach((element) => {
+ mutateElement(
+ element,
+ {
+ x: element.x + offsetX,
+ y: element.y + offsetY,
+ },
+ false,
+ );
+
+ updateBoundElements(element, this.scene.getNonDeletedElementsMap(), {
+ simultaneouslyUpdated: selectedElements,
+ });
+ });
+
+ this.setState({
+ suggestedBindings: getSuggestedBindingsForArrows(
+ selectedElements.filter(
+ (element) => element.id !== elbowArrow?.id || step !== 0,
+ ),
+ this.scene.getNonDeletedElementsMap(),
+ this.state.zoom,
+ ),
+ });
+
+ this.scene.triggerUpdate();
+
+ event.preventDefault();
+ } else if (event.key === KEYS.ENTER) {
+ const selectedElements = this.scene.getSelectedElements(this.state);
+ if (selectedElements.length === 1) {
+ const selectedElement = selectedElements[0];
+ if (event[KEYS.CTRL_OR_CMD]) {
+ if (isLinearElement(selectedElement)) {
+ if (
+ !this.state.editingLinearElement ||
+ this.state.editingLinearElement.elementId !==
+ selectedElements[0].id
+ ) {
+ this.store.shouldCaptureIncrement();
+ if (!isElbowArrow(selectedElement)) {
+ this.setState({
+ editingLinearElement: new LinearElementEditor(
+ selectedElement,
+ ),
+ });
+ }
+ }
+ }
+ } else if (
+ isTextElement(selectedElement) ||
+ isValidTextContainer(selectedElement)
+ ) {
+ let container;
+ if (!isTextElement(selectedElement)) {
+ container = selectedElement as ExcalidrawTextContainer;
+ }
+ const midPoint = getContainerCenter(
+ selectedElement,
+ this.state,
+ this.scene.getNonDeletedElementsMap(),
+ );
+ const sceneX = midPoint.x;
+ const sceneY = midPoint.y;
+ this.startTextEditing({
+ sceneX,
+ sceneY,
+ container,
+ });
+ event.preventDefault();
+ return;
+ } else if (isFrameLikeElement(selectedElement)) {
+ this.setState({
+ editingFrame: selectedElement.id,
+ });
+ }
+ }
+ } else if (
+ !event.ctrlKey &&
+ !event.altKey &&
+ !event.metaKey &&
+ !this.state.newElement &&
+ !this.state.selectionElement &&
+ !this.state.selectedElementsAreBeingDragged
+ ) {
+ const shape = findShapeByKey(event.key);
+ if (shape) {
+ if (this.state.activeTool.type !== shape) {
+ trackEvent(
+ "toolbar",
+ shape,
+ `keyboard (${
+ this.device.editor.isMobile ? "mobile" : "desktop"
+ })`,
+ );
+ }
+ if (shape === "arrow" && this.state.activeTool.type === "arrow") {
+ this.setState((prevState) => ({
+ currentItemArrowType:
+ prevState.currentItemArrowType === ARROW_TYPE.sharp
+ ? ARROW_TYPE.round
+ : prevState.currentItemArrowType === ARROW_TYPE.round
+ ? ARROW_TYPE.elbow
+ : ARROW_TYPE.sharp,
+ }));
+ }
+ this.setActiveTool({ type: shape });
+ event.stopPropagation();
+ } else if (event.key === KEYS.Q) {
+ this.toggleLock("keyboard");
+ event.stopPropagation();
+ }
+ }
+ if (event.key === KEYS.SPACE && gesture.pointers.size === 0) {
+ isHoldingSpace = true;
+ setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB);
+ event.preventDefault();
+ }
+
+ if (
+ (event.key === KEYS.G || event.key === KEYS.S) &&
+ !event.altKey &&
+ !event[KEYS.CTRL_OR_CMD]
+ ) {
+ const selectedElements = this.scene.getSelectedElements(this.state);
+ if (
+ this.state.activeTool.type === "selection" &&
+ !selectedElements.length
+ ) {
+ return;
+ }
+
+ if (
+ event.key === KEYS.G &&
+ (hasBackground(this.state.activeTool.type) ||
+ selectedElements.some((element) => hasBackground(element.type)))
+ ) {
+ this.setState({ openPopup: "elementBackground" });
+ event.stopPropagation();
+ }
+ if (event.key === KEYS.S) {
+ this.setState({ openPopup: "elementStroke" });
+ event.stopPropagation();
+ }
+ }
+
+ if (
+ !event[KEYS.CTRL_OR_CMD] &&
+ event.shiftKey &&
+ event.key.toLowerCase() === KEYS.F
+ ) {
+ const selectedElements = this.scene.getSelectedElements(this.state);
+
+ if (
+ this.state.activeTool.type === "selection" &&
+ !selectedElements.length
+ ) {
+ return;
+ }
+
+ if (
+ this.state.activeTool.type === "text" ||
+ selectedElements.find(
+ (element) =>
+ isTextElement(element) ||
+ getBoundTextElement(
+ element,
+ this.scene.getNonDeletedElementsMap(),
+ ),
+ )
+ ) {
+ event.preventDefault();
+ this.setState({ openPopup: "fontFamily" });
+ }
+ }
+
+ if (event.key === KEYS.K && !event.altKey && !event[KEYS.CTRL_OR_CMD]) {
+ if (this.state.activeTool.type === "laser") {
+ this.setActiveTool({ type: "selection" });
+ } else {
+ this.setActiveTool({ type: "laser" });
+ }
+ return;
+ }
+
+ if (
+ event[KEYS.CTRL_OR_CMD] &&
+ (event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE)
+ ) {
+ editorJotaiStore.set(activeConfirmDialogAtom, "clearCanvas");
+ }
+
+ // eye dropper
+ // -----------------------------------------------------------------------
+ const lowerCased = event.key.toLocaleLowerCase();
+ const isPickingStroke = lowerCased === KEYS.S && event.shiftKey;
+ const isPickingBackground =
+ event.key === KEYS.I || (lowerCased === KEYS.G && event.shiftKey);
+
+ if (isPickingStroke || isPickingBackground) {
+ this.openEyeDropper({
+ type: isPickingStroke ? "stroke" : "background",
+ });
+ }
+ // -----------------------------------------------------------------------
+ },
+ );
+
+ private onKeyUp = withBatchedUpdates((event: KeyboardEvent) => {
+ if (event.key === KEYS.SPACE) {
+ if (
+ this.state.viewModeEnabled ||
+ this.state.openDialog?.name === "elementLinkSelector"
+ ) {
+ setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB);
+ } else if (this.state.activeTool.type === "selection") {
+ resetCursor(this.interactiveCanvas);
+ } else {
+ setCursorForShape(this.interactiveCanvas, this.state);
+ this.setState({
+ selectedElementIds: makeNextSelectedElementIds({}, this.state),
+ selectedGroupIds: {},
+ editingGroupId: null,
+ activeEmbeddable: null,
+ });
+ }
+ isHoldingSpace = false;
+ }
+ if (!event[KEYS.CTRL_OR_CMD] && !this.state.isBindingEnabled) {
+ this.setState({ isBindingEnabled: true });
+ }
+ if (isArrowKey(event.key)) {
+ bindOrUnbindLinearElements(
+ this.scene.getSelectedElements(this.state).filter(isLinearElement),
+ this.scene.getNonDeletedElementsMap(),
+ this.scene.getNonDeletedElements(),
+ this.scene,
+ isBindingEnabled(this.state),
+ this.state.selectedLinearElement?.selectedPointsIndices ?? [],
+ this.state.zoom,
+ );
+ this.setState({ suggestedBindings: [] });
+ }
+
+ if (!event.altKey) {
+ if (this.flowChartNavigator.isExploring) {
+ this.flowChartNavigator.clear();
+ this.syncActionResult({
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ });
+ }
+ }
+
+ if (!event[KEYS.CTRL_OR_CMD]) {
+ if (this.flowChartCreator.isCreatingChart) {
+ if (this.flowChartCreator.pendingNodes?.length) {
+ this.scene.insertElements(this.flowChartCreator.pendingNodes);
+ }
+
+ const firstNode = this.flowChartCreator.pendingNodes?.[0];
+
+ if (firstNode) {
+ this.setState((prevState) => ({
+ selectedElementIds: makeNextSelectedElementIds(
+ {
+ [firstNode.id]: true,
+ },
+ prevState,
+ ),
+ }));
+
+ if (
+ !isElementCompletelyInViewport(
+ [firstNode],
+ this.canvas.width / window.devicePixelRatio,
+ this.canvas.height / window.devicePixelRatio,
+ {
+ offsetLeft: this.state.offsetLeft,
+ offsetTop: this.state.offsetTop,
+ scrollX: this.state.scrollX,
+ scrollY: this.state.scrollY,
+ zoom: this.state.zoom,
+ },
+ this.scene.getNonDeletedElementsMap(),
+ this.getEditorUIOffsets(),
+ )
+ ) {
+ this.scrollToContent(firstNode, {
+ animate: true,
+ duration: 300,
+ canvasOffsets: this.getEditorUIOffsets(),
+ });
+ }
+ }
+
+ this.flowChartCreator.clear();
+ this.syncActionResult({
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ });
+ }
+ }
+ });
+
+ // We purposely widen the `tool` type so this helper can be called with
+ // any tool without having to type check it
+ private isToolSupported = <T extends ToolType | "custom">(tool: T) => {
+ return (
+ this.props.UIOptions.tools?.[
+ tool as Extract<T, keyof AppProps["UIOptions"]["tools"]>
+ ] !== false
+ );
+ };
+
+ setActiveTool = (
+ tool: (
+ | (
+ | { type: Exclude<ToolType, "image"> }
+ | {
+ type: Extract<ToolType, "image">;
+ insertOnCanvasDirectly?: boolean;
+ }
+ )
+ | { type: "custom"; customType: string }
+ ) & { locked?: boolean },
+ ) => {
+ if (!this.isToolSupported(tool.type)) {
+ console.warn(
+ `"${tool.type}" tool is disabled via "UIOptions.canvasActions.tools.${tool.type}"`,
+ );
+ return;
+ }
+
+ const nextActiveTool = updateActiveTool(this.state, tool);
+ if (nextActiveTool.type === "hand") {
+ setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB);
+ } else if (!isHoldingSpace) {
+ setCursorForShape(this.interactiveCanvas, {
+ ...this.state,
+ activeTool: nextActiveTool,
+ });
+ }
+ if (isToolIcon(document.activeElement)) {
+ this.focusContainer();
+ }
+ if (!isLinearElementType(nextActiveTool.type)) {
+ this.setState({ suggestedBindings: [] });
+ }
+ if (nextActiveTool.type === "image") {
+ this.onImageAction({
+ insertOnCanvasDirectly:
+ (tool.type === "image" && tool.insertOnCanvasDirectly) ?? false,
+ });
+ }
+
+ this.setState((prevState) => {
+ const commonResets = {
+ snapLines: prevState.snapLines.length ? [] : prevState.snapLines,
+ originSnapOffset: null,
+ activeEmbeddable: null,
+ } as const;
+
+ if (nextActiveTool.type === "freedraw") {
+ this.store.shouldCaptureIncrement();
+ }
+
+ if (nextActiveTool.type !== "selection") {
+ return {
+ ...prevState,
+ activeTool: nextActiveTool,
+ selectedElementIds: makeNextSelectedElementIds({}, prevState),
+ selectedGroupIds: makeNextSelectedElementIds({}, prevState),
+ editingGroupId: null,
+ multiElement: null,
+ ...commonResets,
+ };
+ }
+ return {
+ ...prevState,
+ activeTool: nextActiveTool,
+ ...commonResets,
+ };
+ });
+ };
+
+ setOpenDialog = (dialogType: AppState["openDialog"]) => {
+ this.setState({ openDialog: dialogType });
+ };
+
+ private setCursor = (cursor: string) => {
+ setCursor(this.interactiveCanvas, cursor);
+ };
+
+ private resetCursor = () => {
+ resetCursor(this.interactiveCanvas);
+ };
+ /**
+ * returns whether user is making a gesture with >= 2 fingers (points)
+ * on o touch screen (not on a trackpad). Currently only relates to Darwin
+ * (iOS/iPadOS,MacOS), but may work on other devices in the future if
+ * GestureEvent is standardized.
+ */
+ private isTouchScreenMultiTouchGesture = () => {
+ // we don't want to deselect when using trackpad, and multi-point gestures
+ // only work on touch screens, so checking for >= pointers means we're on a
+ // touchscreen
+ return gesture.pointers.size >= 2;
+ };
+
+ public getName = () => {
+ return (
+ this.state.name ||
+ this.props.name ||
+ `${t("labels.untitled")}-${getDateTime()}`
+ );
+ };
+
+ // fires only on Safari
+ private onGestureStart = withBatchedUpdates((event: GestureEvent) => {
+ event.preventDefault();
+
+ // we only want to deselect on touch screens because user may have selected
+ // elements by mistake while zooming
+ if (this.isTouchScreenMultiTouchGesture()) {
+ this.setState({
+ selectedElementIds: makeNextSelectedElementIds({}, this.state),
+ activeEmbeddable: null,
+ });
+ }
+ gesture.initialScale = this.state.zoom.value;
+ });
+
+ // fires only on Safari
+ private onGestureChange = withBatchedUpdates((event: GestureEvent) => {
+ event.preventDefault();
+
+ // onGestureChange only has zoom factor but not the center.
+ // If we're on iPad or iPhone, then we recognize multi-touch and will
+ // zoom in at the right location in the touchmove handler
+ // (handleCanvasPointerMove).
+ //
+ // On Macbook trackpad, we don't have those events so will zoom in at the
+ // current location instead.
+ //
+ // As such, bail from this handler on touch devices.
+ if (this.isTouchScreenMultiTouchGesture()) {
+ return;
+ }
+
+ const initialScale = gesture.initialScale;
+ if (initialScale) {
+ this.setState((state) => ({
+ ...getStateForZoom(
+ {
+ viewportX: this.lastViewportPosition.x,
+ viewportY: this.lastViewportPosition.y,
+ nextZoom: getNormalizedZoom(initialScale * event.scale),
+ },
+ state,
+ ),
+ }));
+ }
+ });
+
+ // fires only on Safari
+ private onGestureEnd = withBatchedUpdates((event: GestureEvent) => {
+ event.preventDefault();
+ // reselect elements only on touch screens (see onGestureStart)
+ if (this.isTouchScreenMultiTouchGesture()) {
+ this.setState({
+ previousSelectedElementIds: {},
+ selectedElementIds: makeNextSelectedElementIds(
+ this.state.previousSelectedElementIds,
+ this.state,
+ ),
+ });
+ }
+ gesture.initialScale = null;
+ });
+
+ private handleTextWysiwyg(
+ element: ExcalidrawTextElement,
+ {
+ isExistingElement = false,
+ }: {
+ isExistingElement?: boolean;
+ },
+ ) {
+ const elementsMap = this.scene.getElementsMapIncludingDeleted();
+
+ const updateElement = (nextOriginalText: string, isDeleted: boolean) => {
+ this.scene.replaceAllElements([
+ // Not sure why we include deleted elements as well hence using deleted elements map
+ ...this.scene.getElementsIncludingDeleted().map((_element) => {
+ if (_element.id === element.id && isTextElement(_element)) {
+ return newElementWith(_element, {
+ originalText: nextOriginalText,
+ isDeleted: isDeleted ?? _element.isDeleted,
+ // returns (wrapped) text and new dimensions
+ ...refreshTextDimensions(
+ _element,
+ getContainerElement(_element, elementsMap),
+ elementsMap,
+ nextOriginalText,
+ ),
+ });
+ }
+ return _element;
+ }),
+ ]);
+ };
+
+ textWysiwyg({
+ id: element.id,
+ canvas: this.canvas,
+ getViewportCoords: (x, y) => {
+ const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords(
+ {
+ sceneX: x,
+ sceneY: y,
+ },
+ this.state,
+ );
+ return [
+ viewportX - this.state.offsetLeft,
+ viewportY - this.state.offsetTop,
+ ];
+ },
+ onChange: withBatchedUpdates((nextOriginalText) => {
+ updateElement(nextOriginalText, false);
+ if (isNonDeletedElement(element)) {
+ updateBoundElements(element, this.scene.getNonDeletedElementsMap());
+ }
+ }),
+ onSubmit: withBatchedUpdates(({ viaKeyboard, nextOriginalText }) => {
+ const isDeleted = !nextOriginalText.trim();
+ updateElement(nextOriginalText, isDeleted);
+ // select the created text element only if submitting via keyboard
+ // (when submitting via click it should act as signal to deselect)
+ if (!isDeleted && viaKeyboard) {
+ const elementIdToSelect = element.containerId
+ ? element.containerId
+ : element.id;
+
+ // needed to ensure state is updated before "finalize" action
+ // that's invoked on keyboard-submit as well
+ // TODO either move this into finalize as well, or handle all state
+ // updates in one place, skipping finalize action
+ flushSync(() => {
+ this.setState((prevState) => ({
+ selectedElementIds: makeNextSelectedElementIds(
+ {
+ ...prevState.selectedElementIds,
+ [elementIdToSelect]: true,
+ },
+ prevState,
+ ),
+ }));
+ });
+ }
+ if (isDeleted) {
+ fixBindingsAfterDeletion(this.scene.getNonDeletedElements(), [
+ element,
+ ]);
+ }
+ if (!isDeleted || isExistingElement) {
+ this.store.shouldCaptureIncrement();
+ }
+
+ flushSync(() => {
+ this.setState({
+ newElement: null,
+ editingTextElement: null,
+ });
+ });
+
+ if (this.state.activeTool.locked) {
+ setCursorForShape(this.interactiveCanvas, this.state);
+ }
+
+ this.focusContainer();
+ }),
+ element,
+ excalidrawContainer: this.excalidrawContainerRef.current,
+ app: this,
+ // when text is selected, it's hard (at least on iOS) to re-position the
+ // caret (i.e. deselect). There's not much use for always selecting
+ // the text on edit anyway (and users can select-all from contextmenu
+ // if needed)
+ autoSelect: !this.device.isTouchScreen,
+ });
+ // deselect all other elements when inserting text
+ this.deselectElements();
+
+ // do an initial update to re-initialize element position since we were
+ // modifying element's x/y for sake of editor (case: syncing to remote)
+ updateElement(element.originalText, false);
+ }
+
+ private deselectElements() {
+ this.setState({
+ selectedElementIds: makeNextSelectedElementIds({}, this.state),
+ selectedGroupIds: {},
+ editingGroupId: null,
+ activeEmbeddable: null,
+ });
+ }
+
+ private getTextElementAtPosition(
+ x: number,
+ y: number,
+ ): NonDeleted<ExcalidrawTextElement> | null {
+ const element = this.getElementAtPosition(x, y, {
+ includeBoundTextElement: true,
+ });
+ if (element && isTextElement(element) && !element.isDeleted) {
+ return element;
+ }
+ return null;
+ }
+
+ private getElementAtPosition(
+ x: number,
+ y: number,
+ opts?: {
+ preferSelected?: boolean;
+ includeBoundTextElement?: boolean;
+ includeLockedElements?: boolean;
+ },
+ ): NonDeleted<ExcalidrawElement> | null {
+ const allHitElements = this.getElementsAtPosition(
+ x,
+ y,
+ opts?.includeBoundTextElement,
+ opts?.includeLockedElements,
+ );
+
+ if (allHitElements.length > 1) {
+ if (opts?.preferSelected) {
+ for (let index = allHitElements.length - 1; index > -1; index--) {
+ if (this.state.selectedElementIds[allHitElements[index].id]) {
+ return allHitElements[index];
+ }
+ }
+ }
+ const elementWithHighestZIndex =
+ allHitElements[allHitElements.length - 1];
+
+ // If we're hitting element with highest z-index only on its bounding box
+ // while also hitting other element figure, the latter should be considered.
+ return hitElementItself({
+ x,
+ y,
+ element: elementWithHighestZIndex,
+ shape: getElementShape(
+ elementWithHighestZIndex,
+ this.scene.getNonDeletedElementsMap(),
+ ),
+ // when overlapping, we would like to be more precise
+ // this also avoids the need to update past tests
+ threshold: this.getElementHitThreshold() / 2,
+ frameNameBound: isFrameLikeElement(elementWithHighestZIndex)
+ ? this.frameNameBoundsCache.get(elementWithHighestZIndex)
+ : null,
+ })
+ ? elementWithHighestZIndex
+ : allHitElements[allHitElements.length - 2];
+ }
+ if (allHitElements.length === 1) {
+ return allHitElements[0];
+ }
+
+ return null;
+ }
+
+ private getElementsAtPosition(
+ x: number,
+ y: number,
+ includeBoundTextElement: boolean = false,
+ includeLockedElements: boolean = false,
+ ): NonDeleted<ExcalidrawElement>[] {
+ const iframeLikes: Ordered<ExcalidrawIframeElement>[] = [];
+
+ const elementsMap = this.scene.getNonDeletedElementsMap();
+
+ const elements = (
+ includeBoundTextElement && includeLockedElements
+ ? this.scene.getNonDeletedElements()
+ : this.scene
+ .getNonDeletedElements()
+ .filter(
+ (element) =>
+ (includeLockedElements || !element.locked) &&
+ (includeBoundTextElement ||
+ !(isTextElement(element) && element.containerId)),
+ )
+ )
+ .filter((el) => this.hitElement(x, y, el))
+ .filter((element) => {
+ // hitting a frame's element from outside the frame is not considered a hit
+ const containingFrame = getContainingFrame(element, elementsMap);
+ return containingFrame &&
+ this.state.frameRendering.enabled &&
+ this.state.frameRendering.clip
+ ? isCursorInFrame({ x, y }, containingFrame, elementsMap)
+ : true;
+ })
+ .filter((el) => {
+ // The parameter elements comes ordered from lower z-index to higher.
+ // We want to preserve that order on the returned array.
+ // Exception being embeddables which should be on top of everything else in
+ // terms of hit testing.
+ if (isIframeElement(el)) {
+ iframeLikes.push(el);
+ return false;
+ }
+ return true;
+ })
+ .concat(iframeLikes) as NonDeleted<ExcalidrawElement>[];
+
+ return elements;
+ }
+
+ private getElementHitThreshold() {
+ return DEFAULT_COLLISION_THRESHOLD / this.state.zoom.value;
+ }
+
+ private hitElement(
+ x: number,
+ y: number,
+ element: ExcalidrawElement,
+ considerBoundingBox = true,
+ ) {
+ // if the element is selected, then hit test is done against its bounding box
+ if (
+ considerBoundingBox &&
+ this.state.selectedElementIds[element.id] &&
+ shouldShowBoundingBox([element], this.state)
+ ) {
+ const selectionShape = getSelectionBoxShape(
+ element,
+ this.scene.getNonDeletedElementsMap(),
+ isImageElement(element) ? 0 : this.getElementHitThreshold(),
+ );
+
+ // if hitting the bounding box, return early
+ // but if not, we should check for other cases as well (e.g. frame name)
+ if (isPointInShape(pointFrom(x, y), selectionShape)) {
+ return true;
+ }
+ }
+
+ // take bound text element into consideration for hit collision as well
+ const hitBoundTextOfElement = hitElementBoundText(
+ x,
+ y,
+ getBoundTextShape(element, this.scene.getNonDeletedElementsMap()),
+ );
+ if (hitBoundTextOfElement) {
+ return true;
+ }
+
+ return hitElementItself({
+ x,
+ y,
+ element,
+ shape: getElementShape(element, this.scene.getNonDeletedElementsMap()),
+ threshold: this.getElementHitThreshold(),
+ frameNameBound: isFrameLikeElement(element)
+ ? this.frameNameBoundsCache.get(element)
+ : null,
+ });
+ }
+
+ private getTextBindableContainerAtPosition(x: number, y: number) {
+ const elements = this.scene.getNonDeletedElements();
+ const selectedElements = this.scene.getSelectedElements(this.state);
+ if (selectedElements.length === 1) {
+ return isTextBindableContainer(selectedElements[0], false)
+ ? selectedElements[0]
+ : null;
+ }
+ let hitElement = null;
+ // We need to do hit testing from front (end of the array) to back (beginning of the array)
+ for (let index = elements.length - 1; index >= 0; --index) {
+ if (elements[index].isDeleted) {
+ continue;
+ }
+ const [x1, y1, x2, y2] = getElementAbsoluteCoords(
+ elements[index],
+ this.scene.getNonDeletedElementsMap(),
+ );
+ if (
+ isArrowElement(elements[index]) &&
+ hitElementItself({
+ x,
+ y,
+ element: elements[index],
+ shape: getElementShape(
+ elements[index],
+ this.scene.getNonDeletedElementsMap(),
+ ),
+ threshold: this.getElementHitThreshold(),
+ })
+ ) {
+ hitElement = elements[index];
+ break;
+ } else if (x1 < x && x < x2 && y1 < y && y < y2) {
+ hitElement = elements[index];
+ break;
+ }
+ }
+
+ return isTextBindableContainer(hitElement, false) ? hitElement : null;
+ }
+
+ private startTextEditing = ({
+ sceneX,
+ sceneY,
+ insertAtParentCenter = true,
+ container,
+ autoEdit = true,
+ }: {
+ /** X position to insert text at */
+ sceneX: number;
+ /** Y position to insert text at */
+ sceneY: number;
+ /** whether to attempt to insert at element center if applicable */
+ insertAtParentCenter?: boolean;
+ container?: ExcalidrawTextContainer | null;
+ autoEdit?: boolean;
+ }) => {
+ let shouldBindToContainer = false;
+
+ let parentCenterPosition =
+ insertAtParentCenter &&
+ this.getTextWysiwygSnappedToCenterPosition(
+ sceneX,
+ sceneY,
+ this.state,
+ container,
+ );
+ if (container && parentCenterPosition) {
+ const boundTextElementToContainer = getBoundTextElement(
+ container,
+ this.scene.getNonDeletedElementsMap(),
+ );
+ if (!boundTextElementToContainer) {
+ shouldBindToContainer = true;
+ }
+ }
+ let existingTextElement: NonDeleted<ExcalidrawTextElement> | null = null;
+
+ const selectedElements = this.scene.getSelectedElements(this.state);
+
+ if (selectedElements.length === 1) {
+ if (isTextElement(selectedElements[0])) {
+ existingTextElement = selectedElements[0];
+ } else if (container) {
+ existingTextElement = getBoundTextElement(
+ selectedElements[0],
+ this.scene.getNonDeletedElementsMap(),
+ );
+ } else {
+ existingTextElement = this.getTextElementAtPosition(sceneX, sceneY);
+ }
+ } else {
+ existingTextElement = this.getTextElementAtPosition(sceneX, sceneY);
+ }
+
+ const fontFamily =
+ existingTextElement?.fontFamily || this.state.currentItemFontFamily;
+
+ const lineHeight =
+ existingTextElement?.lineHeight || getLineHeight(fontFamily);
+ const fontSize = this.state.currentItemFontSize;
+
+ if (
+ !existingTextElement &&
+ shouldBindToContainer &&
+ container &&
+ !isArrowElement(container)
+ ) {
+ const fontString = {
+ fontSize,
+ fontFamily,
+ };
+ const minWidth = getApproxMinLineWidth(
+ getFontString(fontString),
+ lineHeight,
+ );
+ const minHeight = getApproxMinLineHeight(fontSize, lineHeight);
+ const newHeight = Math.max(container.height, minHeight);
+ const newWidth = Math.max(container.width, minWidth);
+ mutateElement(container, { height: newHeight, width: newWidth });
+ sceneX = container.x + newWidth / 2;
+ sceneY = container.y + newHeight / 2;
+ if (parentCenterPosition) {
+ parentCenterPosition = this.getTextWysiwygSnappedToCenterPosition(
+ sceneX,
+ sceneY,
+ this.state,
+ container,
+ );
+ }
+ }
+
+ const topLayerFrame = this.getTopLayerFrameAtSceneCoords({
+ x: sceneX,
+ y: sceneY,
+ });
+
+ const element = existingTextElement
+ ? existingTextElement
+ : newTextElement({
+ x: parentCenterPosition
+ ? parentCenterPosition.elementCenterX
+ : sceneX,
+ y: parentCenterPosition
+ ? parentCenterPosition.elementCenterY
+ : sceneY,
+ 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,
+ text: "",
+ fontSize,
+ fontFamily,
+ textAlign: parentCenterPosition
+ ? "center"
+ : this.state.currentItemTextAlign,
+ verticalAlign: parentCenterPosition
+ ? VERTICAL_ALIGN.MIDDLE
+ : DEFAULT_VERTICAL_ALIGN,
+ containerId: shouldBindToContainer ? container?.id : undefined,
+ groupIds: container?.groupIds ?? [],
+ lineHeight,
+ angle: container?.angle ?? (0 as Radians),
+ frameId: topLayerFrame ? topLayerFrame.id : null,
+ });
+
+ if (!existingTextElement && shouldBindToContainer && container) {
+ mutateElement(container, {
+ boundElements: (container.boundElements || []).concat({
+ type: "text",
+ id: element.id,
+ }),
+ });
+ }
+ this.setState({ editingTextElement: element });
+
+ if (!existingTextElement) {
+ if (container && shouldBindToContainer) {
+ const containerIndex = this.scene.getElementIndex(container.id);
+ this.scene.insertElementAtIndex(element, containerIndex + 1);
+ } else {
+ this.scene.insertElement(element);
+ }
+ }
+
+ if (autoEdit || existingTextElement || container) {
+ this.handleTextWysiwyg(element, {
+ isExistingElement: !!existingTextElement,
+ });
+ } else {
+ this.setState({
+ newElement: element,
+ multiElement: null,
+ });
+ }
+ };
+
+ private startImageCropping = (image: ExcalidrawImageElement) => {
+ this.store.shouldCaptureIncrement();
+ this.setState({
+ croppingElementId: image.id,
+ });
+ };
+
+ private finishImageCropping = () => {
+ if (this.state.croppingElementId) {
+ this.store.shouldCaptureIncrement();
+ this.setState({
+ croppingElementId: null,
+ });
+ }
+ };
+
+ private handleCanvasDoubleClick = (
+ event: React.MouseEvent<HTMLCanvasElement>,
+ ) => {
+ // case: double-clicking with arrow/line tool selected would both create
+ // text and enter multiElement mode
+ if (this.state.multiElement) {
+ return;
+ }
+ // we should only be able to double click when mode is selection
+ if (this.state.activeTool.type !== "selection") {
+ return;
+ }
+
+ const selectedElements = this.scene.getSelectedElements(this.state);
+
+ let { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
+ event,
+ this.state,
+ );
+
+ if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
+ if (
+ event[KEYS.CTRL_OR_CMD] &&
+ (!this.state.editingLinearElement ||
+ this.state.editingLinearElement.elementId !==
+ selectedElements[0].id) &&
+ !isElbowArrow(selectedElements[0])
+ ) {
+ this.store.shouldCaptureIncrement();
+ this.setState({
+ editingLinearElement: new LinearElementEditor(selectedElements[0]),
+ });
+ return;
+ } else if (
+ this.state.selectedLinearElement &&
+ isElbowArrow(selectedElements[0])
+ ) {
+ const hitCoords = LinearElementEditor.getSegmentMidpointHitCoords(
+ this.state.selectedLinearElement,
+ { x: sceneX, y: sceneY },
+ this.state,
+ this.scene.getNonDeletedElementsMap(),
+ );
+ const midPoint = hitCoords
+ ? LinearElementEditor.getSegmentMidPointIndex(
+ this.state.selectedLinearElement,
+ this.state,
+ hitCoords,
+ this.scene.getNonDeletedElementsMap(),
+ )
+ : -1;
+
+ if (midPoint && midPoint > -1) {
+ this.store.shouldCaptureIncrement();
+ LinearElementEditor.deleteFixedSegment(selectedElements[0], midPoint);
+
+ const nextCoords = LinearElementEditor.getSegmentMidpointHitCoords(
+ {
+ ...this.state.selectedLinearElement,
+ segmentMidPointHoveredCoords: null,
+ },
+ { x: sceneX, y: sceneY },
+ this.state,
+ this.scene.getNonDeletedElementsMap(),
+ );
+ const nextIndex = nextCoords
+ ? LinearElementEditor.getSegmentMidPointIndex(
+ this.state.selectedLinearElement,
+ this.state,
+ nextCoords,
+ this.scene.getNonDeletedElementsMap(),
+ )
+ : null;
+
+ this.setState({
+ selectedLinearElement: {
+ ...this.state.selectedLinearElement,
+ pointerDownState: {
+ ...this.state.selectedLinearElement.pointerDownState,
+ segmentMidpoint: {
+ index: nextIndex,
+ value: hitCoords,
+ added: false,
+ },
+ },
+ segmentMidPointHoveredCoords: nextCoords,
+ },
+ });
+
+ return;
+ }
+ }
+ }
+
+ if (selectedElements.length === 1 && isImageElement(selectedElements[0])) {
+ this.startImageCropping(selectedElements[0]);
+ return;
+ }
+
+ resetCursor(this.interactiveCanvas);
+
+ const selectedGroupIds = getSelectedGroupIds(this.state);
+
+ if (selectedGroupIds.length > 0) {
+ const hitElement = this.getElementAtPosition(sceneX, sceneY);
+
+ const selectedGroupId =
+ hitElement &&
+ getSelectedGroupIdForElement(hitElement, this.state.selectedGroupIds);
+
+ if (selectedGroupId) {
+ this.store.shouldCaptureIncrement();
+ this.setState((prevState) => ({
+ ...prevState,
+ ...selectGroupsForSelectedElements(
+ {
+ editingGroupId: selectedGroupId,
+ selectedElementIds: { [hitElement!.id]: true },
+ },
+ this.scene.getNonDeletedElements(),
+ prevState,
+ this,
+ ),
+ }));
+ return;
+ }
+ }
+
+ resetCursor(this.interactiveCanvas);
+ if (!event[KEYS.CTRL_OR_CMD] && !this.state.viewModeEnabled) {
+ const hitElement = this.getElementAtPosition(sceneX, sceneY);
+
+ if (isIframeLikeElement(hitElement)) {
+ this.setState({
+ activeEmbeddable: { element: hitElement, state: "active" },
+ });
+ return;
+ }
+
+ const container = this.getTextBindableContainerAtPosition(sceneX, sceneY);
+
+ if (container) {
+ if (
+ hasBoundTextElement(container) ||
+ !isTransparent(container.backgroundColor) ||
+ hitElementItself({
+ x: sceneX,
+ y: sceneY,
+ element: container,
+ shape: getElementShape(
+ container,
+ this.scene.getNonDeletedElementsMap(),
+ ),
+ threshold: this.getElementHitThreshold(),
+ })
+ ) {
+ const midPoint = getContainerCenter(
+ container,
+ this.state,
+ this.scene.getNonDeletedElementsMap(),
+ );
+
+ sceneX = midPoint.x;
+ sceneY = midPoint.y;
+ }
+ }
+
+ this.startTextEditing({
+ sceneX,
+ sceneY,
+ insertAtParentCenter: !event.altKey,
+ container,
+ });
+ }
+ };
+
+ private getElementLinkAtPosition = (
+ scenePointer: Readonly<{ x: number; y: number }>,
+ hitElement: NonDeletedExcalidrawElement | null,
+ ): ExcalidrawElement | undefined => {
+ const elements = this.scene.getNonDeletedElements();
+ let hitElementIndex = -1;
+
+ for (let index = elements.length - 1; index >= 0; index--) {
+ const element = elements[index];
+ if (hitElement && element.id === hitElement.id) {
+ hitElementIndex = index;
+ }
+ if (
+ element.link &&
+ index >= hitElementIndex &&
+ isPointHittingLink(
+ element,
+ this.scene.getNonDeletedElementsMap(),
+ this.state,
+ pointFrom(scenePointer.x, scenePointer.y),
+ this.device.editor.isMobile,
+ )
+ ) {
+ return element;
+ }
+ }
+ };
+
+ private redirectToLink = (
+ event: React.PointerEvent<HTMLCanvasElement>,
+ isTouchScreen: boolean,
+ ) => {
+ const draggedDistance = pointDistance(
+ pointFrom(
+ this.lastPointerDownEvent!.clientX,
+ this.lastPointerDownEvent!.clientY,
+ ),
+ pointFrom(
+ this.lastPointerUpEvent!.clientX,
+ this.lastPointerUpEvent!.clientY,
+ ),
+ );
+ if (!this.hitLinkElement || draggedDistance > DRAGGING_THRESHOLD) {
+ return;
+ }
+ const lastPointerDownCoords = viewportCoordsToSceneCoords(
+ this.lastPointerDownEvent!,
+ this.state,
+ );
+ const elementsMap = this.scene.getNonDeletedElementsMap();
+ const lastPointerDownHittingLinkIcon = isPointHittingLink(
+ this.hitLinkElement,
+ elementsMap,
+ this.state,
+ pointFrom(lastPointerDownCoords.x, lastPointerDownCoords.y),
+ this.device.editor.isMobile,
+ );
+ const lastPointerUpCoords = viewportCoordsToSceneCoords(
+ this.lastPointerUpEvent!,
+ this.state,
+ );
+ const lastPointerUpHittingLinkIcon = isPointHittingLink(
+ this.hitLinkElement,
+ elementsMap,
+ this.state,
+ pointFrom(lastPointerUpCoords.x, lastPointerUpCoords.y),
+ this.device.editor.isMobile,
+ );
+ if (lastPointerDownHittingLinkIcon && lastPointerUpHittingLinkIcon) {
+ hideHyperlinkToolip();
+ let url = this.hitLinkElement.link;
+ if (url) {
+ url = normalizeLink(url);
+ let customEvent;
+ if (this.props.onLinkOpen) {
+ customEvent = wrapEvent(EVENT.EXCALIDRAW_LINK, event.nativeEvent);
+ this.props.onLinkOpen(
+ {
+ ...this.hitLinkElement,
+ link: url,
+ },
+ customEvent,
+ );
+ }
+ if (!customEvent?.defaultPrevented) {
+ const target = isLocalLink(url) ? "_self" : "_blank";
+ const newWindow = window.open(undefined, target);
+ // https://mathiasbynens.github.io/rel-noopener/
+ if (newWindow) {
+ newWindow.opener = null;
+ newWindow.location = url;
+ }
+ }
+ }
+ }
+ };
+
+ private getTopLayerFrameAtSceneCoords = (sceneCoords: {
+ x: number;
+ y: number;
+ }) => {
+ const elementsMap = this.scene.getNonDeletedElementsMap();
+ const frames = this.scene
+ .getNonDeletedFramesLikes()
+ .filter((frame): frame is ExcalidrawFrameLikeElement =>
+ isCursorInFrame(sceneCoords, frame, elementsMap),
+ );
+
+ return frames.length ? frames[frames.length - 1] : null;
+ };
+
+ private handleCanvasPointerMove = (
+ event: React.PointerEvent<HTMLCanvasElement>,
+ ) => {
+ this.savePointer(event.clientX, event.clientY, this.state.cursorButton);
+ this.lastPointerMoveEvent = event.nativeEvent;
+
+ if (gesture.pointers.has(event.pointerId)) {
+ gesture.pointers.set(event.pointerId, {
+ x: event.clientX,
+ y: event.clientY,
+ });
+ }
+
+ const initialScale = gesture.initialScale;
+ if (
+ gesture.pointers.size === 2 &&
+ gesture.lastCenter &&
+ initialScale &&
+ gesture.initialDistance
+ ) {
+ const center = getCenter(gesture.pointers);
+ const deltaX = center.x - gesture.lastCenter.x;
+ const deltaY = center.y - gesture.lastCenter.y;
+ gesture.lastCenter = center;
+
+ const distance = getDistance(Array.from(gesture.pointers.values()));
+ const scaleFactor =
+ this.state.activeTool.type === "freedraw" && this.state.penMode
+ ? 1
+ : distance / gesture.initialDistance;
+
+ const nextZoom = scaleFactor
+ ? getNormalizedZoom(initialScale * scaleFactor)
+ : this.state.zoom.value;
+
+ this.setState((state) => {
+ const zoomState = getStateForZoom(
+ {
+ viewportX: center.x,
+ viewportY: center.y,
+ nextZoom,
+ },
+ state,
+ );
+
+ this.translateCanvas({
+ zoom: zoomState.zoom,
+ // 2x multiplier is just a magic number that makes this work correctly
+ // on touchscreen devices (note: if we get report that panning is slower/faster
+ // than actual movement, consider swapping with devicePixelRatio)
+ scrollX: zoomState.scrollX + 2 * (deltaX / nextZoom),
+ scrollY: zoomState.scrollY + 2 * (deltaY / nextZoom),
+ shouldCacheIgnoreZoom: true,
+ });
+ });
+ this.resetShouldCacheIgnoreZoomDebounced();
+ } else {
+ gesture.lastCenter =
+ gesture.initialDistance =
+ gesture.initialScale =
+ null;
+ }
+
+ if (
+ isHoldingSpace ||
+ isPanning ||
+ isDraggingScrollBar ||
+ isHandToolActive(this.state)
+ ) {
+ return;
+ }
+
+ const isPointerOverScrollBars = isOverScrollBars(
+ currentScrollBars,
+ event.clientX - this.state.offsetLeft,
+ event.clientY - this.state.offsetTop,
+ );
+ const isOverScrollBar = isPointerOverScrollBars.isOverEither;
+ if (
+ !this.state.newElement &&
+ !this.state.selectionElement &&
+ !this.state.selectedElementsAreBeingDragged &&
+ !this.state.multiElement
+ ) {
+ if (isOverScrollBar) {
+ resetCursor(this.interactiveCanvas);
+ } else {
+ setCursorForShape(this.interactiveCanvas, this.state);
+ }
+ }
+
+ const scenePointer = viewportCoordsToSceneCoords(event, this.state);
+ const { x: scenePointerX, y: scenePointerY } = scenePointer;
+
+ if (
+ !this.state.newElement &&
+ isActiveToolNonLinearSnappable(this.state.activeTool.type)
+ ) {
+ const { originOffset, snapLines } = getSnapLinesAtPointer(
+ this.scene.getNonDeletedElements(),
+ this,
+ {
+ x: scenePointerX,
+ y: scenePointerY,
+ },
+ event,
+ this.scene.getNonDeletedElementsMap(),
+ );
+
+ this.setState((prevState) => {
+ const nextSnapLines = updateStable(prevState.snapLines, snapLines);
+ const nextOriginOffset = prevState.originSnapOffset
+ ? updateStable(prevState.originSnapOffset, originOffset)
+ : originOffset;
+
+ if (
+ prevState.snapLines === nextSnapLines &&
+ prevState.originSnapOffset === nextOriginOffset
+ ) {
+ return null;
+ }
+ return {
+ snapLines: nextSnapLines,
+ originSnapOffset: nextOriginOffset,
+ };
+ });
+ } else if (
+ !this.state.newElement &&
+ !this.state.selectedElementsAreBeingDragged &&
+ !this.state.selectionElement
+ ) {
+ this.setState((prevState) => {
+ if (prevState.snapLines.length) {
+ return {
+ snapLines: [],
+ };
+ }
+ return null;
+ });
+ }
+
+ if (
+ this.state.editingLinearElement &&
+ !this.state.editingLinearElement.isDragging
+ ) {
+ const editingLinearElement = LinearElementEditor.handlePointerMove(
+ event,
+ scenePointerX,
+ scenePointerY,
+ this,
+ this.scene.getNonDeletedElementsMap(),
+ );
+
+ if (
+ editingLinearElement &&
+ editingLinearElement !== this.state.editingLinearElement
+ ) {
+ // Since we are reading from previous state which is not possible with
+ // automatic batching in React 18 hence using flush sync to synchronously
+ // update the state. Check https://github.com/excalidraw/excalidraw/pull/5508 for more details.
+ flushSync(() => {
+ this.setState({
+ editingLinearElement,
+ });
+ });
+ }
+ if (editingLinearElement?.lastUncommittedPoint != null) {
+ this.maybeSuggestBindingAtCursor(
+ scenePointer,
+ editingLinearElement.elbowed,
+ );
+ } else {
+ // causes stack overflow if not sync
+ flushSync(() => {
+ this.setState({ suggestedBindings: [] });
+ });
+ }
+ }
+
+ if (isBindingElementType(this.state.activeTool.type)) {
+ // Hovering with a selected tool or creating new linear element via click
+ // and point
+ const { newElement } = this.state;
+ if (isBindingElement(newElement, false)) {
+ this.maybeSuggestBindingsForLinearElementAtCoords(
+ newElement,
+ [scenePointer],
+ this.state.startBoundElement,
+ );
+ } else {
+ this.maybeSuggestBindingAtCursor(scenePointer, false);
+ }
+ }
+
+ if (this.state.multiElement) {
+ const { multiElement } = this.state;
+ const { x: rx, y: ry } = multiElement;
+
+ const { points, lastCommittedPoint } = multiElement;
+ const lastPoint = points[points.length - 1];
+
+ setCursorForShape(this.interactiveCanvas, this.state);
+
+ if (lastPoint === lastCommittedPoint) {
+ // if we haven't yet created a temp point and we're beyond commit-zone
+ // threshold, add a point
+ if (
+ pointDistance(
+ pointFrom(scenePointerX - rx, scenePointerY - ry),
+ lastPoint,
+ ) >= LINE_CONFIRM_THRESHOLD
+ ) {
+ mutateElement(
+ multiElement,
+ {
+ points: [
+ ...points,
+ pointFrom<LocalPoint>(scenePointerX - rx, scenePointerY - ry),
+ ],
+ },
+ false,
+ );
+ } else {
+ setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
+ // in this branch, we're inside the commit zone, and no uncommitted
+ // point exists. Thus do nothing (don't add/remove points).
+ }
+ } else if (
+ points.length > 2 &&
+ lastCommittedPoint &&
+ pointDistance(
+ pointFrom(scenePointerX - rx, scenePointerY - ry),
+ lastCommittedPoint,
+ ) < LINE_CONFIRM_THRESHOLD
+ ) {
+ setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
+ mutateElement(
+ multiElement,
+ {
+ points: points.slice(0, -1),
+ },
+ false,
+ );
+ } else {
+ const [gridX, gridY] = getGridPoint(
+ scenePointerX,
+ scenePointerY,
+ event[KEYS.CTRL_OR_CMD] || isElbowArrow(multiElement)
+ ? null
+ : this.getEffectiveGridSize(),
+ );
+
+ const [lastCommittedX, lastCommittedY] =
+ multiElement?.lastCommittedPoint ?? [0, 0];
+
+ let dxFromLastCommitted = gridX - rx - lastCommittedX;
+ let dyFromLastCommitted = gridY - ry - lastCommittedY;
+
+ if (shouldRotateWithDiscreteAngle(event)) {
+ ({ width: dxFromLastCommitted, height: dyFromLastCommitted } =
+ getLockedLinearCursorAlignSize(
+ // actual coordinate of the last committed point
+ lastCommittedX + rx,
+ lastCommittedY + ry,
+ // cursor-grid coordinate
+ gridX,
+ gridY,
+ ));
+ }
+
+ if (isPathALoop(points, this.state.zoom.value)) {
+ setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
+ }
+ // update last uncommitted point
+ mutateElement(
+ multiElement,
+ {
+ points: [
+ ...points.slice(0, -1),
+ pointFrom<LocalPoint>(
+ lastCommittedX + dxFromLastCommitted,
+ lastCommittedY + dyFromLastCommitted,
+ ),
+ ],
+ },
+ false,
+ {
+ isDragging: true,
+ },
+ );
+
+ // in this path, we're mutating multiElement to reflect
+ // how it will be after adding pointer position as the next point
+ // trigger update here so that new element canvas renders again to reflect this
+ this.triggerRender(false);
+ }
+
+ return;
+ }
+
+ const hasDeselectedButton = Boolean(event.buttons);
+ if (
+ hasDeselectedButton ||
+ (this.state.activeTool.type !== "selection" &&
+ this.state.activeTool.type !== "text" &&
+ this.state.activeTool.type !== "eraser")
+ ) {
+ return;
+ }
+
+ const elements = this.scene.getNonDeletedElements();
+
+ const selectedElements = this.scene.getSelectedElements(this.state);
+ if (
+ selectedElements.length === 1 &&
+ !isOverScrollBar &&
+ !this.state.editingLinearElement
+ ) {
+ // for linear elements, we'd like to prioritize point dragging over edge resizing
+ // therefore, we update and check hovered point index first
+ if (this.state.selectedLinearElement) {
+ this.handleHoverSelectedLinearElement(
+ this.state.selectedLinearElement,
+ scenePointerX,
+ scenePointerY,
+ );
+ }
+
+ if (
+ (!this.state.selectedLinearElement ||
+ this.state.selectedLinearElement.hoverPointIndex === -1) &&
+ this.state.openDialog?.name !== "elementLinkSelector" &&
+ !(selectedElements.length === 1 && isElbowArrow(selectedElements[0]))
+ ) {
+ const elementWithTransformHandleType =
+ getElementWithTransformHandleType(
+ elements,
+ this.state,
+ scenePointerX,
+ scenePointerY,
+ this.state.zoom,
+ event.pointerType,
+ this.scene.getNonDeletedElementsMap(),
+ this.device,
+ );
+ if (
+ elementWithTransformHandleType &&
+ elementWithTransformHandleType.transformHandleType
+ ) {
+ setCursor(
+ this.interactiveCanvas,
+ getCursorForResizingElement(elementWithTransformHandleType),
+ );
+ return;
+ }
+ }
+ } else if (
+ selectedElements.length > 1 &&
+ !isOverScrollBar &&
+ this.state.openDialog?.name !== "elementLinkSelector"
+ ) {
+ const transformHandleType = getTransformHandleTypeFromCoords(
+ getCommonBounds(selectedElements),
+ scenePointerX,
+ scenePointerY,
+ this.state.zoom,
+ event.pointerType,
+ this.device,
+ );
+ if (transformHandleType) {
+ setCursor(
+ this.interactiveCanvas,
+ getCursorForResizingElement({
+ transformHandleType,
+ }),
+ );
+ return;
+ }
+ }
+
+ const hitElement = this.getElementAtPosition(
+ scenePointer.x,
+ scenePointer.y,
+ );
+
+ this.hitLinkElement = this.getElementLinkAtPosition(
+ scenePointer,
+ hitElement,
+ );
+ if (isEraserActive(this.state)) {
+ return;
+ }
+ if (
+ this.hitLinkElement &&
+ !this.state.selectedElementIds[this.hitLinkElement.id]
+ ) {
+ setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
+ showHyperlinkTooltip(
+ this.hitLinkElement,
+ this.state,
+ this.scene.getNonDeletedElementsMap(),
+ );
+ } else {
+ hideHyperlinkToolip();
+ if (
+ hitElement &&
+ (hitElement.link || isEmbeddableElement(hitElement)) &&
+ this.state.selectedElementIds[hitElement.id] &&
+ !this.state.contextMenu &&
+ !this.state.showHyperlinkPopup
+ ) {
+ this.setState({ showHyperlinkPopup: "info" });
+ } else if (this.state.activeTool.type === "text") {
+ setCursor(
+ this.interactiveCanvas,
+ isTextElement(hitElement) ? CURSOR_TYPE.TEXT : CURSOR_TYPE.CROSSHAIR,
+ );
+ } else if (this.state.viewModeEnabled) {
+ setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB);
+ } else if (this.state.openDialog?.name === "elementLinkSelector") {
+ setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
+ } else if (isOverScrollBar) {
+ setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
+ } else if (this.state.selectedLinearElement) {
+ this.handleHoverSelectedLinearElement(
+ this.state.selectedLinearElement,
+ scenePointerX,
+ scenePointerY,
+ );
+ } else if (
+ // if using cmd/ctrl, we're not dragging
+ !event[KEYS.CTRL_OR_CMD]
+ ) {
+ if (
+ (hitElement ||
+ this.isHittingCommonBoundingBoxOfSelectedElements(
+ scenePointer,
+ selectedElements,
+ )) &&
+ !hitElement?.locked
+ ) {
+ if (
+ hitElement &&
+ isIframeLikeElement(hitElement) &&
+ this.isIframeLikeElementCenter(
+ hitElement,
+ event,
+ scenePointerX,
+ scenePointerY,
+ )
+ ) {
+ setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
+ this.setState({
+ activeEmbeddable: { element: hitElement, state: "hover" },
+ });
+ } else if (!hitElement || !isElbowArrow(hitElement)) {
+ setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
+ if (this.state.activeEmbeddable?.state === "hover") {
+ this.setState({ activeEmbeddable: null });
+ }
+ }
+ }
+ } else {
+ setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
+ }
+ }
+
+ if (this.state.openDialog?.name === "elementLinkSelector" && hitElement) {
+ this.setState((prevState) => {
+ return {
+ hoveredElementIds: updateStable(
+ prevState.hoveredElementIds,
+ selectGroupsForSelectedElements(
+ {
+ editingGroupId: prevState.editingGroupId,
+ selectedElementIds: { [hitElement.id]: true },
+ },
+ this.scene.getNonDeletedElements(),
+ prevState,
+ this,
+ ).selectedElementIds,
+ ),
+ };
+ });
+ } else if (
+ this.state.openDialog?.name === "elementLinkSelector" &&
+ !hitElement
+ ) {
+ this.setState((prevState) => ({
+ hoveredElementIds: updateStable(prevState.hoveredElementIds, {}),
+ }));
+ }
+ };
+
+ private handleEraser = (
+ event: PointerEvent,
+ pointerDownState: PointerDownState,
+ scenePointer: { x: number; y: number },
+ ) => {
+ this.eraserTrail.addPointToPath(scenePointer.x, scenePointer.y);
+
+ let didChange = false;
+
+ const processedGroups = new Set<ExcalidrawElement["id"]>();
+ const nonDeletedElements = this.scene.getNonDeletedElements();
+
+ const processElements = (elements: ExcalidrawElement[]) => {
+ for (const element of elements) {
+ if (element.locked) {
+ return;
+ }
+
+ if (event.altKey) {
+ if (this.elementsPendingErasure.delete(element.id)) {
+ didChange = true;
+ }
+ } else if (!this.elementsPendingErasure.has(element.id)) {
+ didChange = true;
+ this.elementsPendingErasure.add(element.id);
+ }
+
+ // (un)erase groups atomically
+ if (didChange && element.groupIds?.length) {
+ const shallowestGroupId = element.groupIds.at(-1)!;
+ if (!processedGroups.has(shallowestGroupId)) {
+ processedGroups.add(shallowestGroupId);
+ const elems = getElementsInGroup(
+ nonDeletedElements,
+ shallowestGroupId,
+ );
+ for (const elem of elems) {
+ if (event.altKey) {
+ this.elementsPendingErasure.delete(elem.id);
+ } else {
+ this.elementsPendingErasure.add(elem.id);
+ }
+ }
+ }
+ }
+ }
+ };
+
+ const distance = pointDistance(
+ pointFrom(pointerDownState.lastCoords.x, pointerDownState.lastCoords.y),
+ pointFrom(scenePointer.x, scenePointer.y),
+ );
+ const threshold = this.getElementHitThreshold();
+ const p = { ...pointerDownState.lastCoords };
+ let samplingInterval = 0;
+ while (samplingInterval <= distance) {
+ const hitElements = this.getElementsAtPosition(p.x, p.y);
+ processElements(hitElements);
+
+ // Exit since we reached current point
+ if (samplingInterval === distance) {
+ break;
+ }
+
+ // Calculate next point in the line at a distance of sampling interval
+ samplingInterval = Math.min(samplingInterval + threshold, distance);
+
+ const distanceRatio = samplingInterval / distance;
+ const nextX = (1 - distanceRatio) * p.x + distanceRatio * scenePointer.x;
+ const nextY = (1 - distanceRatio) * p.y + distanceRatio * scenePointer.y;
+ p.x = nextX;
+ p.y = nextY;
+ }
+
+ pointerDownState.lastCoords.x = scenePointer.x;
+ pointerDownState.lastCoords.y = scenePointer.y;
+
+ if (didChange) {
+ for (const element of this.scene.getNonDeletedElements()) {
+ if (
+ isBoundToContainer(element) &&
+ (this.elementsPendingErasure.has(element.id) ||
+ this.elementsPendingErasure.has(element.containerId))
+ ) {
+ if (event.altKey) {
+ this.elementsPendingErasure.delete(element.id);
+ this.elementsPendingErasure.delete(element.containerId);
+ } else {
+ this.elementsPendingErasure.add(element.id);
+ this.elementsPendingErasure.add(element.containerId);
+ }
+ }
+ }
+
+ this.elementsPendingErasure = new Set(this.elementsPendingErasure);
+ this.triggerRender();
+ }
+ };
+
+ // set touch moving for mobile context menu
+ private handleTouchMove = (event: React.TouchEvent<HTMLCanvasElement>) => {
+ invalidateContextMenu = true;
+ };
+
+ handleHoverSelectedLinearElement(
+ linearElementEditor: LinearElementEditor,
+ scenePointerX: number,
+ scenePointerY: number,
+ ) {
+ const elementsMap = this.scene.getNonDeletedElementsMap();
+
+ const element = LinearElementEditor.getElement(
+ linearElementEditor.elementId,
+ elementsMap,
+ );
+
+ if (!element) {
+ return;
+ }
+ if (this.state.selectedLinearElement) {
+ let hoverPointIndex = -1;
+ let segmentMidPointHoveredCoords = null;
+ if (
+ hitElementItself({
+ x: scenePointerX,
+ y: scenePointerY,
+ element,
+ shape: getElementShape(
+ element,
+ this.scene.getNonDeletedElementsMap(),
+ ),
+ })
+ ) {
+ hoverPointIndex = LinearElementEditor.getPointIndexUnderCursor(
+ element,
+ elementsMap,
+ this.state.zoom,
+ scenePointerX,
+ scenePointerY,
+ );
+ segmentMidPointHoveredCoords =
+ LinearElementEditor.getSegmentMidpointHitCoords(
+ linearElementEditor,
+ { x: scenePointerX, y: scenePointerY },
+ this.state,
+ this.scene.getNonDeletedElementsMap(),
+ );
+ const isHoveringAPointHandle = isElbowArrow(element)
+ ? hoverPointIndex === 0 ||
+ hoverPointIndex === element.points.length - 1
+ : hoverPointIndex >= 0;
+ if (isHoveringAPointHandle || segmentMidPointHoveredCoords) {
+ setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
+ } else if (this.hitElement(scenePointerX, scenePointerY, element)) {
+ setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
+ }
+ } else if (this.hitElement(scenePointerX, scenePointerY, element)) {
+ if (
+ // Ebow arrows can only be moved when unconnected
+ !isElbowArrow(element) ||
+ !(element.startBinding || element.endBinding)
+ ) {
+ setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
+ }
+ }
+
+ if (
+ this.state.selectedLinearElement.hoverPointIndex !== hoverPointIndex
+ ) {
+ this.setState({
+ selectedLinearElement: {
+ ...this.state.selectedLinearElement,
+ hoverPointIndex,
+ },
+ });
+ }
+
+ if (
+ !LinearElementEditor.arePointsEqual(
+ this.state.selectedLinearElement.segmentMidPointHoveredCoords,
+ segmentMidPointHoveredCoords,
+ )
+ ) {
+ this.setState({
+ selectedLinearElement: {
+ ...this.state.selectedLinearElement,
+ segmentMidPointHoveredCoords,
+ },
+ });
+ }
+ } else {
+ setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
+ }
+ }
+
+ private handleCanvasPointerDown = (
+ event: React.PointerEvent<HTMLElement>,
+ ) => {
+ const target = event.target as HTMLElement;
+ // capture subsequent pointer events to the canvas
+ // this makes other elements non-interactive until pointer up
+ if (target.setPointerCapture) {
+ target.setPointerCapture(event.pointerId);
+ }
+
+ this.maybeCleanupAfterMissingPointerUp(event.nativeEvent);
+ this.maybeUnfollowRemoteUser();
+
+ if (this.state.searchMatches) {
+ this.setState((state) => ({
+ searchMatches: state.searchMatches.map((searchMatch) => ({
+ ...searchMatch,
+ focus: false,
+ })),
+ }));
+ editorJotaiStore.set(searchItemInFocusAtom, null);
+ }
+
+ // since contextMenu options are potentially evaluated on each render,
+ // and an contextMenu action may depend on selection state, we must
+ // close the contextMenu before we update the selection on pointerDown
+ // (e.g. resetting selection)
+ if (this.state.contextMenu) {
+ this.setState({ contextMenu: null });
+ }
+
+ if (this.state.snapLines) {
+ this.setAppState({ snapLines: [] });
+ }
+
+ this.updateGestureOnPointerDown(event);
+
+ // if dragging element is freedraw and another pointerdown event occurs
+ // a second finger is on the screen
+ // discard the freedraw element if it is very short because it is likely
+ // just a spike, otherwise finalize the freedraw element when the second
+ // finger is lifted
+ if (
+ event.pointerType === "touch" &&
+ this.state.newElement &&
+ this.state.newElement.type === "freedraw"
+ ) {
+ const element = this.state.newElement as ExcalidrawFreeDrawElement;
+ this.updateScene({
+ ...(element.points.length < 10
+ ? {
+ elements: this.scene
+ .getElementsIncludingDeleted()
+ .filter((el) => el.id !== element.id),
+ }
+ : {}),
+ appState: {
+ newElement: null,
+ editingTextElement: null,
+ startBoundElement: null,
+ suggestedBindings: [],
+ selectedElementIds: makeNextSelectedElementIds(
+ Object.keys(this.state.selectedElementIds)
+ .filter((key) => key !== element.id)
+ .reduce((obj: { [id: string]: true }, key) => {
+ obj[key] = this.state.selectedElementIds[key];
+ return obj;
+ }, {}),
+ this.state,
+ ),
+ },
+ captureUpdate:
+ this.state.openDialog?.name === "elementLinkSelector"
+ ? CaptureUpdateAction.EVENTUALLY
+ : CaptureUpdateAction.NEVER,
+ });
+ return;
+ }
+
+ // remove any active selection when we start to interact with canvas
+ // (mainly, we care about removing selection outside the component which
+ // would prevent our copy handling otherwise)
+ const selection = document.getSelection();
+ if (selection?.anchorNode) {
+ selection.removeAllRanges();
+ }
+ this.maybeOpenContextMenuAfterPointerDownOnTouchDevices(event);
+
+ //fires only once, if pen is detected, penMode is enabled
+ //the user can disable this by toggling the penMode button
+ if (!this.state.penDetected && event.pointerType === "pen") {
+ this.setState((prevState) => {
+ return {
+ penMode: true,
+ penDetected: true,
+ };
+ });
+ }
+
+ if (
+ !this.device.isTouchScreen &&
+ ["pen", "touch"].includes(event.pointerType)
+ ) {
+ this.device = updateObject(this.device, { isTouchScreen: true });
+ }
+
+ if (isPanning) {
+ return;
+ }
+
+ this.lastPointerDownEvent = event;
+
+ // we must exit before we set `cursorButton` state and `savePointer`
+ // else it will send pointer state & laser pointer events in collab when
+ // panning
+ if (this.handleCanvasPanUsingWheelOrSpaceDrag(event)) {
+ return;
+ }
+
+ this.setState({
+ lastPointerDownWith: event.pointerType,
+ cursorButton: "down",
+ });
+ this.savePointer(event.clientX, event.clientY, "down");
+
+ if (
+ event.button === POINTER_BUTTON.ERASER &&
+ this.state.activeTool.type !== TOOL_TYPE.eraser
+ ) {
+ this.setState(
+ {
+ activeTool: updateActiveTool(this.state, {
+ type: TOOL_TYPE.eraser,
+ lastActiveToolBeforeEraser: this.state.activeTool,
+ }),
+ },
+ () => {
+ this.handleCanvasPointerDown(event);
+ const onPointerUp = () => {
+ unsubPointerUp();
+ unsubCleanup?.();
+ if (isEraserActive(this.state)) {
+ this.setState({
+ activeTool: updateActiveTool(this.state, {
+ ...(this.state.activeTool.lastActiveTool || {
+ type: TOOL_TYPE.selection,
+ }),
+ lastActiveToolBeforeEraser: null,
+ }),
+ });
+ }
+ };
+
+ const unsubPointerUp = addEventListener(
+ window,
+ EVENT.POINTER_UP,
+ onPointerUp,
+ {
+ once: true,
+ },
+ );
+ let unsubCleanup: UnsubscribeCallback | undefined;
+ // subscribe inside rAF lest it'd be triggered on the same pointerdown
+ // if we start erasing while coming from blurred document since
+ // we cleanup pointer events on focus
+ requestAnimationFrame(() => {
+ unsubCleanup =
+ this.missingPointerEventCleanupEmitter.once(onPointerUp);
+ });
+ },
+ );
+ return;
+ }
+
+ // only handle left mouse button or touch
+ if (
+ event.button !== POINTER_BUTTON.MAIN &&
+ event.button !== POINTER_BUTTON.TOUCH &&
+ event.button !== POINTER_BUTTON.ERASER
+ ) {
+ return;
+ }
+
+ // don't select while panning
+ if (gesture.pointers.size > 1) {
+ return;
+ }
+
+ // State for the duration of a pointer interaction, which starts with a
+ // pointerDown event, ends with a pointerUp event (or another pointerDown)
+ const pointerDownState = this.initialPointerDownState(event);
+
+ this.setState({
+ selectedElementsAreBeingDragged: false,
+ });
+
+ if (this.handleDraggingScrollBar(event, pointerDownState)) {
+ return;
+ }
+
+ this.clearSelectionIfNotUsingSelection();
+ this.updateBindingEnabledOnPointerMove(event);
+
+ if (this.handleSelectionOnPointerDown(event, pointerDownState)) {
+ return;
+ }
+
+ const allowOnPointerDown =
+ !this.state.penMode ||
+ event.pointerType !== "touch" ||
+ this.state.activeTool.type === "selection" ||
+ this.state.activeTool.type === "text" ||
+ this.state.activeTool.type === "image";
+
+ if (!allowOnPointerDown) {
+ return;
+ }
+
+ if (this.state.activeTool.type === "text") {
+ this.handleTextOnPointerDown(event, pointerDownState);
+ } else if (
+ this.state.activeTool.type === "arrow" ||
+ this.state.activeTool.type === "line"
+ ) {
+ this.handleLinearElementOnPointerDown(
+ event,
+ this.state.activeTool.type,
+ pointerDownState,
+ );
+ } else if (this.state.activeTool.type === "image") {
+ // reset image preview on pointerdown
+ setCursor(this.interactiveCanvas, CURSOR_TYPE.CROSSHAIR);
+
+ // retrieve the latest element as the state may be stale
+ const pendingImageElement =
+ this.state.pendingImageElementId &&
+ this.scene.getElement(this.state.pendingImageElementId);
+
+ if (!pendingImageElement) {
+ return;
+ }
+
+ this.setState({
+ newElement: pendingImageElement as ExcalidrawNonSelectionElement,
+ pendingImageElementId: null,
+ multiElement: null,
+ });
+
+ const { x, y } = viewportCoordsToSceneCoords(event, this.state);
+
+ const frame = this.getTopLayerFrameAtSceneCoords({ x, y });
+
+ mutateElement(pendingImageElement, {
+ x,
+ y,
+ frameId: frame ? frame.id : null,
+ });
+ } else if (this.state.activeTool.type === "freedraw") {
+ this.handleFreeDrawElementOnPointerDown(
+ event,
+ this.state.activeTool.type,
+ pointerDownState,
+ );
+ } else if (this.state.activeTool.type === "custom") {
+ setCursorForShape(this.interactiveCanvas, this.state);
+ } else if (
+ this.state.activeTool.type === TOOL_TYPE.frame ||
+ this.state.activeTool.type === TOOL_TYPE.magicframe
+ ) {
+ this.createFrameElementOnPointerDown(
+ pointerDownState,
+ this.state.activeTool.type,
+ );
+ } else if (this.state.activeTool.type === "laser") {
+ this.laserTrails.startPath(
+ pointerDownState.lastCoords.x,
+ pointerDownState.lastCoords.y,
+ );
+ } else if (
+ this.state.activeTool.type !== "eraser" &&
+ this.state.activeTool.type !== "hand"
+ ) {
+ this.createGenericElementOnPointerDown(
+ this.state.activeTool.type,
+ pointerDownState,
+ );
+ }
+
+ this.props?.onPointerDown?.(this.state.activeTool, pointerDownState);
+ this.onPointerDownEmitter.trigger(
+ this.state.activeTool,
+ pointerDownState,
+ event,
+ );
+
+ if (this.state.activeTool.type === "eraser") {
+ this.eraserTrail.startPath(
+ pointerDownState.lastCoords.x,
+ pointerDownState.lastCoords.y,
+ );
+ }
+
+ const onPointerMove =
+ this.onPointerMoveFromPointerDownHandler(pointerDownState);
+
+ const onPointerUp =
+ this.onPointerUpFromPointerDownHandler(pointerDownState);
+
+ const onKeyDown = this.onKeyDownFromPointerDownHandler(pointerDownState);
+ const onKeyUp = this.onKeyUpFromPointerDownHandler(pointerDownState);
+
+ this.missingPointerEventCleanupEmitter.once((_event) =>
+ onPointerUp(_event || event.nativeEvent),
+ );
+
+ if (!this.state.viewModeEnabled || this.state.activeTool.type === "laser") {
+ window.addEventListener(EVENT.POINTER_MOVE, onPointerMove);
+ window.addEventListener(EVENT.POINTER_UP, onPointerUp);
+ window.addEventListener(EVENT.KEYDOWN, onKeyDown);
+ window.addEventListener(EVENT.KEYUP, onKeyUp);
+ pointerDownState.eventListeners.onMove = onPointerMove;
+ pointerDownState.eventListeners.onUp = onPointerUp;
+ pointerDownState.eventListeners.onKeyUp = onKeyUp;
+ pointerDownState.eventListeners.onKeyDown = onKeyDown;
+ }
+ };
+
+ private handleCanvasPointerUp = (
+ event: React.PointerEvent<HTMLCanvasElement>,
+ ) => {
+ this.removePointer(event);
+ this.lastPointerUpEvent = event;
+
+ const scenePointer = viewportCoordsToSceneCoords(
+ { clientX: event.clientX, clientY: event.clientY },
+ this.state,
+ );
+ const clicklength =
+ event.timeStamp - (this.lastPointerDownEvent?.timeStamp ?? 0);
+
+ if (this.device.editor.isMobile && clicklength < 300) {
+ const hitElement = this.getElementAtPosition(
+ scenePointer.x,
+ scenePointer.y,
+ );
+ if (
+ isIframeLikeElement(hitElement) &&
+ this.isIframeLikeElementCenter(
+ hitElement,
+ event,
+ scenePointer.x,
+ scenePointer.y,
+ )
+ ) {
+ this.handleEmbeddableCenterClick(hitElement);
+ return;
+ }
+ }
+
+ if (this.device.isTouchScreen) {
+ const hitElement = this.getElementAtPosition(
+ scenePointer.x,
+ scenePointer.y,
+ );
+ this.hitLinkElement = this.getElementLinkAtPosition(
+ scenePointer,
+ hitElement,
+ );
+ }
+
+ if (
+ this.hitLinkElement &&
+ !this.state.selectedElementIds[this.hitLinkElement.id]
+ ) {
+ if (
+ clicklength < 300 &&
+ isIframeLikeElement(this.hitLinkElement) &&
+ !isPointHittingLinkIcon(
+ this.hitLinkElement,
+ this.scene.getNonDeletedElementsMap(),
+ this.state,
+ pointFrom(scenePointer.x, scenePointer.y),
+ )
+ ) {
+ this.handleEmbeddableCenterClick(this.hitLinkElement);
+ } else {
+ this.redirectToLink(event, this.device.isTouchScreen);
+ }
+ } else if (this.state.viewModeEnabled) {
+ this.setState({
+ activeEmbeddable: null,
+ selectedElementIds: {},
+ });
+ }
+ };
+
+ private maybeOpenContextMenuAfterPointerDownOnTouchDevices = (
+ event: React.PointerEvent<HTMLElement>,
+ ): void => {
+ // deal with opening context menu on touch devices
+ if (event.pointerType === "touch") {
+ invalidateContextMenu = false;
+
+ if (touchTimeout) {
+ // If there's already a touchTimeout, this means that there's another
+ // touch down and we are doing another touch, so we shouldn't open the
+ // context menu.
+ invalidateContextMenu = true;
+ } else {
+ // open the context menu with the first touch's clientX and clientY
+ // if the touch is not moving
+ touchTimeout = window.setTimeout(() => {
+ touchTimeout = 0;
+ if (!invalidateContextMenu) {
+ this.handleCanvasContextMenu(event);
+ }
+ }, TOUCH_CTX_MENU_TIMEOUT);
+ }
+ }
+ };
+
+ private resetContextMenuTimer = () => {
+ clearTimeout(touchTimeout);
+ touchTimeout = 0;
+ invalidateContextMenu = false;
+ };
+
+ /**
+ * pointerup may not fire in certian cases (user tabs away...), so in order
+ * to properly cleanup pointerdown state, we need to fire any hanging
+ * pointerup handlers manually
+ */
+ private maybeCleanupAfterMissingPointerUp = (event: PointerEvent | null) => {
+ lastPointerUp?.();
+ this.missingPointerEventCleanupEmitter.trigger(event).clear();
+ };
+
+ // Returns whether the event is a panning
+ public handleCanvasPanUsingWheelOrSpaceDrag = (
+ event: React.PointerEvent<HTMLElement> | MouseEvent,
+ ): boolean => {
+ if (
+ !(
+ gesture.pointers.size <= 1 &&
+ (event.button === POINTER_BUTTON.WHEEL ||
+ (event.button === POINTER_BUTTON.MAIN && isHoldingSpace) ||
+ isHandToolActive(this.state) ||
+ this.state.viewModeEnabled)
+ )
+ ) {
+ return false;
+ }
+ isPanning = true;
+
+ // due to event.preventDefault below, container wouldn't get focus
+ // automatically
+ this.focusContainer();
+
+ // preventing defualt while text editing messes with cursor/focus
+ if (!this.state.editingTextElement) {
+ // necessary to prevent browser from scrolling the page if excalidraw
+ // not full-page #4489
+ //
+ // as such, the above is broken when panning canvas while in wysiwyg
+ event.preventDefault();
+ }
+
+ let nextPastePrevented = false;
+ const isLinux =
+ typeof window === undefined
+ ? false
+ : /Linux/.test(window.navigator.platform);
+
+ setCursor(this.interactiveCanvas, CURSOR_TYPE.GRABBING);
+ let { clientX: lastX, clientY: lastY } = event;
+ const onPointerMove = withBatchedUpdatesThrottled((event: PointerEvent) => {
+ const deltaX = lastX - event.clientX;
+ const deltaY = lastY - event.clientY;
+ lastX = event.clientX;
+ lastY = event.clientY;
+
+ /*
+ * Prevent paste event if we move while middle clicking on Linux.
+ * See issue #1383.
+ */
+ if (
+ isLinux &&
+ !nextPastePrevented &&
+ (Math.abs(deltaX) > 1 || Math.abs(deltaY) > 1)
+ ) {
+ nextPastePrevented = true;
+
+ /* Prevent the next paste event */
+ const preventNextPaste = (event: ClipboardEvent) => {
+ document.body.removeEventListener(EVENT.PASTE, preventNextPaste);
+ event.stopPropagation();
+ };
+
+ /*
+ * Reenable next paste in case of disabled middle click paste for
+ * any reason:
+ * - right click paste
+ * - empty clipboard
+ */
+ const enableNextPaste = () => {
+ setTimeout(() => {
+ document.body.removeEventListener(EVENT.PASTE, preventNextPaste);
+ window.removeEventListener(EVENT.POINTER_UP, enableNextPaste);
+ }, 100);
+ };
+
+ document.body.addEventListener(EVENT.PASTE, preventNextPaste);
+ window.addEventListener(EVENT.POINTER_UP, enableNextPaste);
+ }
+
+ this.translateCanvas({
+ scrollX: this.state.scrollX - deltaX / this.state.zoom.value,
+ scrollY: this.state.scrollY - deltaY / this.state.zoom.value,
+ });
+ });
+ const teardown = withBatchedUpdates(
+ (lastPointerUp = () => {
+ lastPointerUp = null;
+ isPanning = false;
+ if (!isHoldingSpace) {
+ if (this.state.viewModeEnabled) {
+ setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB);
+ } else {
+ setCursorForShape(this.interactiveCanvas, this.state);
+ }
+ }
+ this.setState({
+ cursorButton: "up",
+ });
+ this.savePointer(event.clientX, event.clientY, "up");
+ window.removeEventListener(EVENT.POINTER_MOVE, onPointerMove);
+ window.removeEventListener(EVENT.POINTER_UP, teardown);
+ window.removeEventListener(EVENT.BLUR, teardown);
+ onPointerMove.flush();
+ }),
+ );
+ window.addEventListener(EVENT.BLUR, teardown);
+ window.addEventListener(EVENT.POINTER_MOVE, onPointerMove, {
+ passive: true,
+ });
+ window.addEventListener(EVENT.POINTER_UP, teardown);
+ return true;
+ };
+
+ private updateGestureOnPointerDown(
+ event: React.PointerEvent<HTMLElement>,
+ ): void {
+ gesture.pointers.set(event.pointerId, {
+ x: event.clientX,
+ y: event.clientY,
+ });
+
+ if (gesture.pointers.size === 2) {
+ gesture.lastCenter = getCenter(gesture.pointers);
+ gesture.initialScale = this.state.zoom.value;
+ gesture.initialDistance = getDistance(
+ Array.from(gesture.pointers.values()),
+ );
+ }
+ }
+
+ private initialPointerDownState(
+ event: React.PointerEvent<HTMLElement>,
+ ): PointerDownState {
+ const origin = viewportCoordsToSceneCoords(event, this.state);
+ const selectedElements = this.scene.getSelectedElements(this.state);
+ const [minX, minY, maxX, maxY] = getCommonBounds(selectedElements);
+ const isElbowArrowOnly = selectedElements.findIndex(isElbowArrow) === 0;
+
+ return {
+ origin,
+ withCmdOrCtrl: event[KEYS.CTRL_OR_CMD],
+ originInGrid: tupleToCoors(
+ getGridPoint(
+ origin.x,
+ origin.y,
+ event[KEYS.CTRL_OR_CMD] || isElbowArrowOnly
+ ? null
+ : this.getEffectiveGridSize(),
+ ),
+ ),
+ scrollbars: isOverScrollBars(
+ currentScrollBars,
+ event.clientX - this.state.offsetLeft,
+ event.clientY - this.state.offsetTop,
+ ),
+ // we need to duplicate because we'll be updating this state
+ lastCoords: { ...origin },
+ originalElements: this.scene
+ .getNonDeletedElements()
+ .reduce((acc, element) => {
+ acc.set(element.id, deepCopyElement(element));
+ return acc;
+ }, new Map() as PointerDownState["originalElements"]),
+ resize: {
+ handleType: false,
+ isResizing: false,
+ offset: { x: 0, y: 0 },
+ arrowDirection: "origin",
+ center: { x: (maxX + minX) / 2, y: (maxY + minY) / 2 },
+ },
+ hit: {
+ element: null,
+ allHitElements: [],
+ wasAddedToSelection: false,
+ hasBeenDuplicated: false,
+ hasHitCommonBoundingBoxOfSelectedElements:
+ this.isHittingCommonBoundingBoxOfSelectedElements(
+ origin,
+ selectedElements,
+ ),
+ },
+ drag: {
+ hasOccurred: false,
+ offset: null,
+ },
+ eventListeners: {
+ onMove: null,
+ onUp: null,
+ onKeyUp: null,
+ onKeyDown: null,
+ },
+ boxSelection: {
+ hasOccurred: false,
+ },
+ };
+ }
+
+ // Returns whether the event is a dragging a scrollbar
+ private handleDraggingScrollBar(
+ event: React.PointerEvent<HTMLElement>,
+ pointerDownState: PointerDownState,
+ ): boolean {
+ if (
+ !(pointerDownState.scrollbars.isOverEither && !this.state.multiElement)
+ ) {
+ return false;
+ }
+ isDraggingScrollBar = true;
+ pointerDownState.lastCoords.x = event.clientX;
+ pointerDownState.lastCoords.y = event.clientY;
+ const onPointerMove = withBatchedUpdatesThrottled((event: PointerEvent) => {
+ const target = event.target;
+ if (!(target instanceof HTMLElement)) {
+ return;
+ }
+
+ this.handlePointerMoveOverScrollbars(event, pointerDownState);
+ });
+ const onPointerUp = withBatchedUpdates(() => {
+ lastPointerUp = null;
+ isDraggingScrollBar = false;
+ setCursorForShape(this.interactiveCanvas, this.state);
+ this.setState({
+ cursorButton: "up",
+ });
+ this.savePointer(event.clientX, event.clientY, "up");
+ window.removeEventListener(EVENT.POINTER_MOVE, onPointerMove);
+ window.removeEventListener(EVENT.POINTER_UP, onPointerUp);
+ onPointerMove.flush();
+ });
+
+ lastPointerUp = onPointerUp;
+
+ window.addEventListener(EVENT.POINTER_MOVE, onPointerMove);
+ window.addEventListener(EVENT.POINTER_UP, onPointerUp);
+ return true;
+ }
+
+ private clearSelectionIfNotUsingSelection = (): void => {
+ if (this.state.activeTool.type !== "selection") {
+ this.setState({
+ selectedElementIds: makeNextSelectedElementIds({}, this.state),
+ selectedGroupIds: {},
+ editingGroupId: null,
+ activeEmbeddable: null,
+ });
+ }
+ };
+
+ /**
+ * @returns whether the pointer event has been completely handled
+ */
+ private handleSelectionOnPointerDown = (
+ event: React.PointerEvent<HTMLElement>,
+ pointerDownState: PointerDownState,
+ ): boolean => {
+ if (this.state.activeTool.type === "selection") {
+ const elements = this.scene.getNonDeletedElements();
+ const elementsMap = this.scene.getNonDeletedElementsMap();
+ const selectedElements = this.scene.getSelectedElements(this.state);
+
+ if (
+ selectedElements.length === 1 &&
+ !this.state.editingLinearElement &&
+ !isElbowArrow(selectedElements[0]) &&
+ !(
+ this.state.selectedLinearElement &&
+ this.state.selectedLinearElement.hoverPointIndex !== -1
+ )
+ ) {
+ const elementWithTransformHandleType =
+ getElementWithTransformHandleType(
+ elements,
+ this.state,
+ pointerDownState.origin.x,
+ pointerDownState.origin.y,
+ this.state.zoom,
+ event.pointerType,
+ this.scene.getNonDeletedElementsMap(),
+ this.device,
+ );
+ if (elementWithTransformHandleType != null) {
+ if (
+ elementWithTransformHandleType.transformHandleType === "rotation"
+ ) {
+ this.setState({
+ resizingElement: elementWithTransformHandleType.element,
+ });
+ pointerDownState.resize.handleType =
+ elementWithTransformHandleType.transformHandleType;
+ } else if (this.state.croppingElementId) {
+ pointerDownState.resize.handleType =
+ elementWithTransformHandleType.transformHandleType;
+ } else {
+ this.setState({
+ resizingElement: elementWithTransformHandleType.element,
+ });
+ pointerDownState.resize.handleType =
+ elementWithTransformHandleType.transformHandleType;
+ }
+ }
+ } else if (selectedElements.length > 1) {
+ pointerDownState.resize.handleType = getTransformHandleTypeFromCoords(
+ getCommonBounds(selectedElements),
+ pointerDownState.origin.x,
+ pointerDownState.origin.y,
+ this.state.zoom,
+ event.pointerType,
+ this.device,
+ );
+ }
+ if (pointerDownState.resize.handleType) {
+ pointerDownState.resize.isResizing = true;
+ pointerDownState.resize.offset = tupleToCoors(
+ getResizeOffsetXY(
+ pointerDownState.resize.handleType,
+ selectedElements,
+ elementsMap,
+ pointerDownState.origin.x,
+ pointerDownState.origin.y,
+ ),
+ );
+ if (
+ selectedElements.length === 1 &&
+ isLinearElement(selectedElements[0]) &&
+ selectedElements[0].points.length === 2
+ ) {
+ pointerDownState.resize.arrowDirection = getResizeArrowDirection(
+ pointerDownState.resize.handleType,
+ selectedElements[0],
+ );
+ }
+ } else {
+ if (this.state.selectedLinearElement) {
+ const linearElementEditor =
+ this.state.editingLinearElement || this.state.selectedLinearElement;
+ const ret = LinearElementEditor.handlePointerDown(
+ event,
+ this,
+ this.store,
+ pointerDownState.origin,
+ linearElementEditor,
+ this.scene,
+ );
+ if (ret.hitElement) {
+ pointerDownState.hit.element = ret.hitElement;
+ }
+ if (ret.linearElementEditor) {
+ this.setState({ selectedLinearElement: ret.linearElementEditor });
+
+ if (this.state.editingLinearElement) {
+ this.setState({ editingLinearElement: ret.linearElementEditor });
+ }
+ }
+ if (ret.didAddPoint) {
+ return true;
+ }
+ }
+ // hitElement may already be set above, so check first
+ pointerDownState.hit.element =
+ pointerDownState.hit.element ??
+ this.getElementAtPosition(
+ pointerDownState.origin.x,
+ pointerDownState.origin.y,
+ );
+
+ this.hitLinkElement = this.getElementLinkAtPosition(
+ pointerDownState.origin,
+ pointerDownState.hit.element,
+ );
+
+ if (this.hitLinkElement) {
+ return true;
+ }
+
+ if (
+ this.state.croppingElementId &&
+ pointerDownState.hit.element?.id !== this.state.croppingElementId
+ ) {
+ this.finishImageCropping();
+ }
+
+ if (pointerDownState.hit.element) {
+ // Early return if pointer is hitting link icon
+ const hitLinkElement = this.getElementLinkAtPosition(
+ {
+ x: pointerDownState.origin.x,
+ y: pointerDownState.origin.y,
+ },
+ pointerDownState.hit.element,
+ );
+ if (hitLinkElement) {
+ return false;
+ }
+ }
+
+ // For overlapped elements one position may hit
+ // multiple elements
+ pointerDownState.hit.allHitElements = this.getElementsAtPosition(
+ pointerDownState.origin.x,
+ pointerDownState.origin.y,
+ );
+
+ const hitElement = pointerDownState.hit.element;
+ const someHitElementIsSelected =
+ pointerDownState.hit.allHitElements.some((element) =>
+ this.isASelectedElement(element),
+ );
+ if (
+ (hitElement === null || !someHitElementIsSelected) &&
+ !event.shiftKey &&
+ !pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements
+ ) {
+ this.clearSelection(hitElement);
+ }
+
+ if (this.state.editingLinearElement) {
+ this.setState({
+ selectedElementIds: makeNextSelectedElementIds(
+ {
+ [this.state.editingLinearElement.elementId]: true,
+ },
+ this.state,
+ ),
+ });
+ // If we click on something
+ } else if (hitElement != null) {
+ // on CMD/CTRL, drill down to hit element regardless of groups etc.
+ if (event[KEYS.CTRL_OR_CMD]) {
+ if (!this.state.selectedElementIds[hitElement.id]) {
+ pointerDownState.hit.wasAddedToSelection = true;
+ }
+ this.setState((prevState) => ({
+ ...editGroupForSelectedElement(prevState, hitElement),
+ previousSelectedElementIds: this.state.selectedElementIds,
+ }));
+ // mark as not completely handled so as to allow dragging etc.
+ return false;
+ }
+
+ // deselect if item is selected
+ // if shift is not clicked, this will always return true
+ // otherwise, it will trigger selection based on current
+ // state of the box
+ if (!this.state.selectedElementIds[hitElement.id]) {
+ // if we are currently editing a group, exiting editing mode and deselect the group.
+ if (
+ this.state.editingGroupId &&
+ !isElementInGroup(hitElement, this.state.editingGroupId)
+ ) {
+ this.setState({
+ selectedElementIds: makeNextSelectedElementIds({}, this.state),
+ selectedGroupIds: {},
+ editingGroupId: null,
+ activeEmbeddable: null,
+ });
+ }
+
+ // Add hit element to selection. At this point if we're not holding
+ // SHIFT the previously selected element(s) were deselected above
+ // (make sure you use setState updater to use latest state)
+ // With shift-selection, we want to make sure that frames and their containing
+ // elements are not selected at the same time.
+ if (
+ !someHitElementIsSelected &&
+ !pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements
+ ) {
+ this.setState((prevState) => {
+ let nextSelectedElementIds: { [id: string]: true } = {
+ ...prevState.selectedElementIds,
+ [hitElement.id]: true,
+ };
+
+ const previouslySelectedElements: ExcalidrawElement[] = [];
+
+ Object.keys(prevState.selectedElementIds).forEach((id) => {
+ const element = this.scene.getElement(id);
+ element && previouslySelectedElements.push(element);
+ });
+
+ // if hitElement is frame-like, deselect all of its elements
+ // if they are selected
+ if (isFrameLikeElement(hitElement)) {
+ getFrameChildren(
+ previouslySelectedElements,
+ hitElement.id,
+ ).forEach((element) => {
+ delete nextSelectedElementIds[element.id];
+ });
+ } else if (hitElement.frameId) {
+ // if hitElement is in a frame and its frame has been selected
+ // disable selection for the given element
+ if (nextSelectedElementIds[hitElement.frameId]) {
+ delete nextSelectedElementIds[hitElement.id];
+ }
+ } else {
+ // hitElement is neither a frame nor an element in a frame
+ // but since hitElement could be in a group with some frames
+ // this means selecting hitElement will have the frames selected as well
+ // because we want to keep the invariant:
+ // - frames and their elements are not selected at the same time
+ // we deselect elements in those frames that were previously selected
+
+ const groupIds = hitElement.groupIds;
+ const framesInGroups = new Set(
+ groupIds
+ .flatMap((gid) =>
+ getElementsInGroup(
+ this.scene.getNonDeletedElements(),
+ gid,
+ ),
+ )
+ .filter((element) => isFrameLikeElement(element))
+ .map((frame) => frame.id),
+ );
+
+ if (framesInGroups.size > 0) {
+ previouslySelectedElements.forEach((element) => {
+ if (
+ element.frameId &&
+ framesInGroups.has(element.frameId)
+ ) {
+ // deselect element and groups containing the element
+ delete nextSelectedElementIds[element.id];
+ element.groupIds
+ .flatMap((gid) =>
+ getElementsInGroup(
+ this.scene.getNonDeletedElements(),
+ gid,
+ ),
+ )
+ .forEach((element) => {
+ delete nextSelectedElementIds[element.id];
+ });
+ }
+ });
+ }
+ }
+
+ // Finally, in shape selection mode, we'd like to
+ // keep only one shape or group selected at a time.
+ // This means, if the hitElement is a different shape or group
+ // than the previously selected ones, we deselect the previous ones
+ // and select the hitElement
+ if (prevState.openDialog?.name === "elementLinkSelector") {
+ if (
+ !hitElement.groupIds.some(
+ (gid) => prevState.selectedGroupIds[gid],
+ )
+ ) {
+ nextSelectedElementIds = {
+ [hitElement.id]: true,
+ };
+ }
+ }
+
+ return {
+ ...selectGroupsForSelectedElements(
+ {
+ editingGroupId: prevState.editingGroupId,
+ selectedElementIds: nextSelectedElementIds,
+ },
+ this.scene.getNonDeletedElements(),
+ prevState,
+ this,
+ ),
+ showHyperlinkPopup:
+ hitElement.link || isEmbeddableElement(hitElement)
+ ? "info"
+ : false,
+ };
+ });
+ pointerDownState.hit.wasAddedToSelection = true;
+ }
+ }
+ }
+
+ this.setState({
+ previousSelectedElementIds: this.state.selectedElementIds,
+ });
+ }
+ }
+ return false;
+ };
+
+ private isASelectedElement(hitElement: ExcalidrawElement | null): boolean {
+ return hitElement != null && this.state.selectedElementIds[hitElement.id];
+ }
+
+ private isHittingCommonBoundingBoxOfSelectedElements(
+ point: Readonly<{ x: number; y: number }>,
+ selectedElements: readonly ExcalidrawElement[],
+ ): boolean {
+ if (selectedElements.length < 2) {
+ return false;
+ }
+
+ // How many pixels off the shape boundary we still consider a hit
+ const threshold = this.getElementHitThreshold();
+ const [x1, y1, x2, y2] = getCommonBounds(selectedElements);
+ return (
+ point.x > x1 - threshold &&
+ point.x < x2 + threshold &&
+ point.y > y1 - threshold &&
+ point.y < y2 + threshold
+ );
+ }
+
+ private handleTextOnPointerDown = (
+ event: React.PointerEvent<HTMLElement>,
+ pointerDownState: PointerDownState,
+ ): void => {
+ // if we're currently still editing text, clicking outside
+ // should only finalize it, not create another (irrespective
+ // of state.activeTool.locked)
+ if (this.state.editingTextElement) {
+ return;
+ }
+ let sceneX = pointerDownState.origin.x;
+ let sceneY = pointerDownState.origin.y;
+
+ const element = this.getElementAtPosition(sceneX, sceneY, {
+ includeBoundTextElement: true,
+ });
+
+ // FIXME
+ let container = this.getTextBindableContainerAtPosition(sceneX, sceneY);
+
+ if (hasBoundTextElement(element)) {
+ container = element as ExcalidrawTextContainer;
+ sceneX = element.x + element.width / 2;
+ sceneY = element.y + element.height / 2;
+ }
+ this.startTextEditing({
+ sceneX,
+ sceneY,
+ insertAtParentCenter: !event.altKey,
+ container,
+ autoEdit: false,
+ });
+
+ resetCursor(this.interactiveCanvas);
+ if (!this.state.activeTool.locked) {
+ this.setState({
+ activeTool: updateActiveTool(this.state, { type: "selection" }),
+ });
+ }
+ };
+
+ private handleFreeDrawElementOnPointerDown = (
+ event: React.PointerEvent<HTMLElement>,
+ elementType: ExcalidrawFreeDrawElement["type"],
+ pointerDownState: PointerDownState,
+ ) => {
+ // Begin a mark capture. This does not have to update state yet.
+ const [gridX, gridY] = getGridPoint(
+ pointerDownState.origin.x,
+ pointerDownState.origin.y,
+ null,
+ );
+
+ const topLayerFrame = this.getTopLayerFrameAtSceneCoords({
+ x: gridX,
+ y: gridY,
+ });
+
+ const simulatePressure = event.pressure === 0.5;
+
+ const element = newFreeDrawElement({
+ type: elementType,
+ x: gridX,
+ y: gridY,
+ 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,
+ roundness: null,
+ simulatePressure,
+ locked: false,
+ frameId: topLayerFrame ? topLayerFrame.id : null,
+ points: [pointFrom<LocalPoint>(0, 0)],
+ pressures: simulatePressure ? [] : [event.pressure],
+ });
+
+ this.scene.insertElement(element);
+
+ this.setState((prevState) => {
+ const nextSelectedElementIds = {
+ ...prevState.selectedElementIds,
+ };
+ delete nextSelectedElementIds[element.id];
+ return {
+ selectedElementIds: makeNextSelectedElementIds(
+ nextSelectedElementIds,
+ prevState,
+ ),
+ };
+ });
+
+ const boundElement = getHoveredElementForBinding(
+ pointerDownState.origin,
+ this.scene.getNonDeletedElements(),
+ this.scene.getNonDeletedElementsMap(),
+ this.state.zoom,
+ );
+
+ this.setState({
+ newElement: element,
+ startBoundElement: boundElement,
+ suggestedBindings: [],
+ });
+ };
+
+ public insertIframeElement = ({
+ sceneX,
+ sceneY,
+ width,
+ height,
+ }: {
+ sceneX: number;
+ sceneY: number;
+ width: number;
+ height: number;
+ }) => {
+ const [gridX, gridY] = getGridPoint(
+ sceneX,
+ sceneY,
+ this.lastPointerDownEvent?.[KEYS.CTRL_OR_CMD]
+ ? null
+ : this.getEffectiveGridSize(),
+ );
+
+ const element = newIframeElement({
+ type: "iframe",
+ x: gridX,
+ y: gridY,
+ strokeColor: "transparent",
+ backgroundColor: "transparent",
+ fillStyle: this.state.currentItemFillStyle,
+ strokeWidth: this.state.currentItemStrokeWidth,
+ strokeStyle: this.state.currentItemStrokeStyle,
+ roughness: this.state.currentItemRoughness,
+ roundness: this.getCurrentItemRoundness("iframe"),
+ opacity: this.state.currentItemOpacity,
+ locked: false,
+ width,
+ height,
+ });
+
+ this.scene.insertElement(element);
+
+ return element;
+ };
+
+ //create rectangle element with youtube top left on nearest grid point width / hight 640/360
+ public insertEmbeddableElement = ({
+ sceneX,
+ sceneY,
+ link,
+ }: {
+ sceneX: number;
+ sceneY: number;
+ link: string;
+ }) => {
+ const [gridX, gridY] = getGridPoint(
+ sceneX,
+ sceneY,
+ this.lastPointerDownEvent?.[KEYS.CTRL_OR_CMD]
+ ? null
+ : this.getEffectiveGridSize(),
+ );
+
+ const embedLink = getEmbedLink(link);
+
+ if (!embedLink) {
+ return;
+ }
+
+ if (embedLink.error instanceof URIError) {
+ this.setToast({
+ message: t("toast.unrecognizedLinkFormat"),
+ closable: true,
+ });
+ }
+
+ const element = newEmbeddableElement({
+ type: "embeddable",
+ x: gridX,
+ y: gridY,
+ strokeColor: "transparent",
+ backgroundColor: "transparent",
+ fillStyle: this.state.currentItemFillStyle,
+ strokeWidth: this.state.currentItemStrokeWidth,
+ strokeStyle: this.state.currentItemStrokeStyle,
+ roughness: this.state.currentItemRoughness,
+ roundness: this.getCurrentItemRoundness("embeddable"),
+ opacity: this.state.currentItemOpacity,
+ locked: false,
+ width: embedLink.intrinsicSize.w,
+ height: embedLink.intrinsicSize.h,
+ link,
+ });
+
+ this.scene.insertElement(element);
+
+ return element;
+ };
+
+ private createImageElement = ({
+ sceneX,
+ sceneY,
+ addToFrameUnderCursor = true,
+ }: {
+ sceneX: number;
+ sceneY: number;
+ addToFrameUnderCursor?: boolean;
+ }) => {
+ const [gridX, gridY] = getGridPoint(
+ sceneX,
+ sceneY,
+ this.lastPointerDownEvent?.[KEYS.CTRL_OR_CMD]
+ ? null
+ : this.getEffectiveGridSize(),
+ );
+
+ const topLayerFrame = addToFrameUnderCursor
+ ? this.getTopLayerFrameAtSceneCoords({
+ x: gridX,
+ y: gridY,
+ })
+ : null;
+
+ const element = newImageElement({
+ type: "image",
+ x: gridX,
+ y: gridY,
+ strokeColor: this.state.currentItemStrokeColor,
+ backgroundColor: this.state.currentItemBackgroundColor,
+ fillStyle: this.state.currentItemFillStyle,
+ strokeWidth: this.state.currentItemStrokeWidth,
+ strokeStyle: this.state.currentItemStrokeStyle,
+ roughness: this.state.currentItemRoughness,
+ roundness: null,
+ opacity: this.state.currentItemOpacity,
+ locked: false,
+ frameId: topLayerFrame ? topLayerFrame.id : null,
+ });
+
+ return element;
+ };
+
+ private handleLinearElementOnPointerDown = (
+ event: React.PointerEvent<HTMLElement>,
+ elementType: ExcalidrawLinearElement["type"],
+ pointerDownState: PointerDownState,
+ ): void => {
+ if (this.state.multiElement) {
+ const { multiElement } = this.state;
+
+ // finalize if completing a loop
+ if (
+ multiElement.type === "line" &&
+ isPathALoop(multiElement.points, this.state.zoom.value)
+ ) {
+ mutateElement(multiElement, {
+ lastCommittedPoint:
+ multiElement.points[multiElement.points.length - 1],
+ });
+ this.actionManager.executeAction(actionFinalize);
+ return;
+ }
+
+ // Elbow arrows cannot be created by putting down points
+ // only the start and end points can be defined
+ if (isElbowArrow(multiElement) && multiElement.points.length > 1) {
+ mutateElement(multiElement, {
+ lastCommittedPoint:
+ multiElement.points[multiElement.points.length - 1],
+ });
+ this.actionManager.executeAction(actionFinalize);
+ return;
+ }
+
+ const { x: rx, y: ry, lastCommittedPoint } = multiElement;
+
+ // clicking inside commit zone → finalize arrow
+ if (
+ multiElement.points.length > 1 &&
+ lastCommittedPoint &&
+ pointDistance(
+ pointFrom(
+ pointerDownState.origin.x - rx,
+ pointerDownState.origin.y - ry,
+ ),
+ lastCommittedPoint,
+ ) < LINE_CONFIRM_THRESHOLD
+ ) {
+ this.actionManager.executeAction(actionFinalize);
+ return;
+ }
+
+ this.setState((prevState) => ({
+ selectedElementIds: makeNextSelectedElementIds(
+ {
+ ...prevState.selectedElementIds,
+ [multiElement.id]: true,
+ },
+ prevState,
+ ),
+ }));
+ // clicking outside commit zone → update reference for last committed
+ // point
+ mutateElement(multiElement, {
+ lastCommittedPoint: multiElement.points[multiElement.points.length - 1],
+ });
+ setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
+ } else {
+ const [gridX, gridY] = getGridPoint(
+ pointerDownState.origin.x,
+ pointerDownState.origin.y,
+ event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
+ );
+
+ const topLayerFrame = this.getTopLayerFrameAtSceneCoords({
+ x: gridX,
+ y: gridY,
+ });
+
+ /* If arrow is pre-arrowheads, it will have undefined for both start and end arrowheads.
+ If so, we want it to be null for start and "arrow" for end. If the linear item is not
+ an arrow, we want it to be null for both. Otherwise, we want it to use the
+ values from appState. */
+
+ const { currentItemStartArrowhead, currentItemEndArrowhead } = this.state;
+ const [startArrowhead, endArrowhead] =
+ elementType === "arrow"
+ ? [currentItemStartArrowhead, currentItemEndArrowhead]
+ : [null, null];
+
+ const element =
+ elementType === "arrow"
+ ? newArrowElement({
+ type: elementType,
+ x: gridX,
+ y: gridY,
+ 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,
+ roundness:
+ this.state.currentItemArrowType === ARROW_TYPE.round
+ ? { type: ROUNDNESS.PROPORTIONAL_RADIUS }
+ : // note, roundness doesn't have any effect for elbow arrows,
+ // but it's best to set it to null as well
+ null,
+ startArrowhead,
+ endArrowhead,
+ locked: false,
+ frameId: topLayerFrame ? topLayerFrame.id : null,
+ elbowed: this.state.currentItemArrowType === ARROW_TYPE.elbow,
+ fixedSegments:
+ this.state.currentItemArrowType === ARROW_TYPE.elbow
+ ? []
+ : null,
+ })
+ : newLinearElement({
+ type: elementType,
+ x: gridX,
+ y: gridY,
+ 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,
+ roundness:
+ this.state.currentItemRoundness === "round"
+ ? { type: ROUNDNESS.PROPORTIONAL_RADIUS }
+ : null,
+ locked: false,
+ frameId: topLayerFrame ? topLayerFrame.id : null,
+ });
+ this.setState((prevState) => {
+ const nextSelectedElementIds = {
+ ...prevState.selectedElementIds,
+ };
+ delete nextSelectedElementIds[element.id];
+ return {
+ selectedElementIds: makeNextSelectedElementIds(
+ nextSelectedElementIds,
+ prevState,
+ ),
+ };
+ });
+ mutateElement(element, {
+ points: [...element.points, pointFrom<LocalPoint>(0, 0)],
+ });
+ const boundElement = getHoveredElementForBinding(
+ pointerDownState.origin,
+ this.scene.getNonDeletedElements(),
+ this.scene.getNonDeletedElementsMap(),
+ this.state.zoom,
+ isElbowArrow(element),
+ isElbowArrow(element),
+ );
+
+ this.scene.insertElement(element);
+ this.setState({
+ newElement: element,
+ startBoundElement: boundElement,
+ suggestedBindings: [],
+ });
+ }
+ };
+
+ private getCurrentItemRoundness(
+ elementType:
+ | "selection"
+ | "rectangle"
+ | "diamond"
+ | "ellipse"
+ | "iframe"
+ | "embeddable",
+ ) {
+ return this.state.currentItemRoundness === "round"
+ ? {
+ type: isUsingAdaptiveRadius(elementType)
+ ? ROUNDNESS.ADAPTIVE_RADIUS
+ : ROUNDNESS.PROPORTIONAL_RADIUS,
+ }
+ : null;
+ }
+
+ private createGenericElementOnPointerDown = (
+ elementType: ExcalidrawGenericElement["type"] | "embeddable",
+ pointerDownState: PointerDownState,
+ ): void => {
+ const [gridX, gridY] = getGridPoint(
+ pointerDownState.origin.x,
+ pointerDownState.origin.y,
+ this.lastPointerDownEvent?.[KEYS.CTRL_OR_CMD]
+ ? null
+ : this.getEffectiveGridSize(),
+ );
+
+ const topLayerFrame = this.getTopLayerFrameAtSceneCoords({
+ x: gridX,
+ y: gridY,
+ });
+
+ const baseElementAttributes = {
+ x: gridX,
+ y: gridY,
+ 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,
+ roundness: this.getCurrentItemRoundness(elementType),
+ locked: false,
+ frameId: topLayerFrame ? topLayerFrame.id : null,
+ } as const;
+
+ let element;
+ if (elementType === "embeddable") {
+ element = newEmbeddableElement({
+ type: "embeddable",
+ ...baseElementAttributes,
+ });
+ } else {
+ element = newElement({
+ type: elementType,
+ ...baseElementAttributes,
+ });
+ }
+
+ if (element.type === "selection") {
+ this.setState({
+ selectionElement: element,
+ });
+ } else {
+ this.scene.insertElement(element);
+ this.setState({
+ multiElement: null,
+ newElement: element,
+ });
+ }
+ };
+
+ private createFrameElementOnPointerDown = (
+ pointerDownState: PointerDownState,
+ type: Extract<ToolType, "frame" | "magicframe">,
+ ): void => {
+ const [gridX, gridY] = getGridPoint(
+ pointerDownState.origin.x,
+ pointerDownState.origin.y,
+ this.lastPointerDownEvent?.[KEYS.CTRL_OR_CMD]
+ ? null
+ : this.getEffectiveGridSize(),
+ );
+
+ const constructorOpts = {
+ x: gridX,
+ y: gridY,
+ opacity: this.state.currentItemOpacity,
+ locked: false,
+ ...FRAME_STYLE,
+ } as const;
+
+ const frame =
+ type === TOOL_TYPE.magicframe
+ ? newMagicFrameElement(constructorOpts)
+ : newFrameElement(constructorOpts);
+
+ this.scene.insertElement(frame);
+
+ this.setState({
+ multiElement: null,
+ newElement: frame,
+ });
+ };
+
+ private maybeCacheReferenceSnapPoints(
+ event: KeyboardModifiersObject,
+ selectedElements: ExcalidrawElement[],
+ recomputeAnyways: boolean = false,
+ ) {
+ if (
+ isSnappingEnabled({
+ event,
+ app: this,
+ selectedElements,
+ }) &&
+ (recomputeAnyways || !SnapCache.getReferenceSnapPoints())
+ ) {
+ SnapCache.setReferenceSnapPoints(
+ getReferenceSnapPoints(
+ this.scene.getNonDeletedElements(),
+ selectedElements,
+ this.state,
+ this.scene.getNonDeletedElementsMap(),
+ ),
+ );
+ }
+ }
+
+ private maybeCacheVisibleGaps(
+ event: KeyboardModifiersObject,
+ selectedElements: ExcalidrawElement[],
+ recomputeAnyways: boolean = false,
+ ) {
+ if (
+ isSnappingEnabled({
+ event,
+ app: this,
+ selectedElements,
+ }) &&
+ (recomputeAnyways || !SnapCache.getVisibleGaps())
+ ) {
+ SnapCache.setVisibleGaps(
+ getVisibleGaps(
+ this.scene.getNonDeletedElements(),
+ selectedElements,
+ this.state,
+ this.scene.getNonDeletedElementsMap(),
+ ),
+ );
+ }
+ }
+
+ private onKeyDownFromPointerDownHandler(
+ pointerDownState: PointerDownState,
+ ): (event: KeyboardEvent) => void {
+ return withBatchedUpdates((event: KeyboardEvent) => {
+ if (this.maybeHandleResize(pointerDownState, event)) {
+ return;
+ }
+ this.maybeDragNewGenericElement(pointerDownState, event);
+ });
+ }
+
+ private onKeyUpFromPointerDownHandler(
+ pointerDownState: PointerDownState,
+ ): (event: KeyboardEvent) => void {
+ return withBatchedUpdates((event: KeyboardEvent) => {
+ // Prevents focus from escaping excalidraw tab
+ event.key === KEYS.ALT && event.preventDefault();
+ if (this.maybeHandleResize(pointerDownState, event)) {
+ return;
+ }
+ this.maybeDragNewGenericElement(pointerDownState, event);
+ });
+ }
+
+ private onPointerMoveFromPointerDownHandler(
+ pointerDownState: PointerDownState,
+ ) {
+ return withBatchedUpdatesThrottled((event: PointerEvent) => {
+ if (this.state.openDialog?.name === "elementLinkSelector") {
+ return;
+ }
+ const pointerCoords = viewportCoordsToSceneCoords(event, this.state);
+
+ if (
+ this.state.selectedLinearElement &&
+ this.state.selectedLinearElement.elbowed &&
+ this.state.selectedLinearElement.pointerDownState.segmentMidpoint.index
+ ) {
+ const [gridX, gridY] = getGridPoint(
+ pointerCoords.x,
+ pointerCoords.y,
+ event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
+ );
+
+ let index =
+ this.state.selectedLinearElement.pointerDownState.segmentMidpoint
+ .index;
+ if (index < 0) {
+ const nextCoords = LinearElementEditor.getSegmentMidpointHitCoords(
+ {
+ ...this.state.selectedLinearElement,
+ segmentMidPointHoveredCoords: null,
+ },
+ { x: gridX, y: gridY },
+ this.state,
+ this.scene.getNonDeletedElementsMap(),
+ );
+ index = nextCoords
+ ? LinearElementEditor.getSegmentMidPointIndex(
+ this.state.selectedLinearElement,
+ this.state,
+ nextCoords,
+ this.scene.getNonDeletedElementsMap(),
+ )
+ : -1;
+ }
+
+ const ret = LinearElementEditor.moveFixedSegment(
+ this.state.selectedLinearElement,
+ index,
+ gridX,
+ gridY,
+ this.scene.getNonDeletedElementsMap(),
+ );
+
+ flushSync(() => {
+ if (this.state.selectedLinearElement) {
+ this.setState({
+ selectedLinearElement: {
+ ...this.state.selectedLinearElement,
+ segmentMidPointHoveredCoords: ret.segmentMidPointHoveredCoords,
+ pointerDownState: ret.pointerDownState,
+ },
+ });
+ }
+ });
+ return;
+ }
+
+ const lastPointerCoords =
+ this.lastPointerMoveCoords ?? pointerDownState.origin;
+ this.lastPointerMoveCoords = pointerCoords;
+
+ // We need to initialize dragOffsetXY only after we've updated
+ // `state.selectedElementIds` on pointerDown. Doing it here in pointerMove
+ // event handler should hopefully ensure we're already working with
+ // the updated state.
+ if (pointerDownState.drag.offset === null) {
+ pointerDownState.drag.offset = tupleToCoors(
+ getDragOffsetXY(
+ this.scene.getSelectedElements(this.state),
+ pointerDownState.origin.x,
+ pointerDownState.origin.y,
+ ),
+ );
+ }
+ const target = event.target;
+ if (!(target instanceof HTMLElement)) {
+ return;
+ }
+
+ if (this.handlePointerMoveOverScrollbars(event, pointerDownState)) {
+ return;
+ }
+
+ if (isEraserActive(this.state)) {
+ this.handleEraser(event, pointerDownState, pointerCoords);
+ return;
+ }
+
+ if (this.state.activeTool.type === "laser") {
+ this.laserTrails.addPointToPath(pointerCoords.x, pointerCoords.y);
+ }
+
+ const [gridX, gridY] = getGridPoint(
+ pointerCoords.x,
+ pointerCoords.y,
+ event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
+ );
+
+ // for arrows/lines, don't start dragging until a given threshold
+ // to ensure we don't create a 2-point arrow by mistake when
+ // user clicks mouse in a way that it moves a tiny bit (thus
+ // triggering pointermove)
+ if (
+ !pointerDownState.drag.hasOccurred &&
+ (this.state.activeTool.type === "arrow" ||
+ this.state.activeTool.type === "line")
+ ) {
+ if (
+ pointDistance(
+ pointFrom(pointerCoords.x, pointerCoords.y),
+ pointFrom(pointerDownState.origin.x, pointerDownState.origin.y),
+ ) < DRAGGING_THRESHOLD
+ ) {
+ return;
+ }
+ }
+ if (pointerDownState.resize.isResizing) {
+ pointerDownState.lastCoords.x = pointerCoords.x;
+ pointerDownState.lastCoords.y = pointerCoords.y;
+ if (this.maybeHandleCrop(pointerDownState, event)) {
+ return true;
+ }
+ if (this.maybeHandleResize(pointerDownState, event)) {
+ return true;
+ }
+ }
+ const elementsMap = this.scene.getNonDeletedElementsMap();
+
+ if (this.state.selectedLinearElement) {
+ const linearElementEditor =
+ this.state.editingLinearElement || this.state.selectedLinearElement;
+
+ if (
+ LinearElementEditor.shouldAddMidpoint(
+ this.state.selectedLinearElement,
+ pointerCoords,
+ this.state,
+ elementsMap,
+ )
+ ) {
+ const ret = LinearElementEditor.addMidpoint(
+ this.state.selectedLinearElement,
+ pointerCoords,
+ this,
+ !event[KEYS.CTRL_OR_CMD],
+ elementsMap,
+ );
+ if (!ret) {
+ return;
+ }
+
+ // Since we are reading from previous state which is not possible with
+ // automatic batching in React 18 hence using flush sync to synchronously
+ // update the state. Check https://github.com/excalidraw/excalidraw/pull/5508 for more details.
+
+ flushSync(() => {
+ if (this.state.selectedLinearElement) {
+ this.setState({
+ selectedLinearElement: {
+ ...this.state.selectedLinearElement,
+ pointerDownState: ret.pointerDownState,
+ selectedPointsIndices: ret.selectedPointsIndices,
+ },
+ });
+ }
+ if (this.state.editingLinearElement) {
+ this.setState({
+ editingLinearElement: {
+ ...this.state.editingLinearElement,
+ pointerDownState: ret.pointerDownState,
+ selectedPointsIndices: ret.selectedPointsIndices,
+ },
+ });
+ }
+ });
+
+ return;
+ } else if (
+ linearElementEditor.pointerDownState.segmentMidpoint.value !== null &&
+ !linearElementEditor.pointerDownState.segmentMidpoint.added
+ ) {
+ return;
+ }
+
+ const didDrag = LinearElementEditor.handlePointDragging(
+ event,
+ this,
+ pointerCoords.x,
+ pointerCoords.y,
+ (element, pointsSceneCoords) => {
+ this.maybeSuggestBindingsForLinearElementAtCoords(
+ element,
+ pointsSceneCoords,
+ );
+ },
+ linearElementEditor,
+ this.scene,
+ );
+ if (didDrag) {
+ pointerDownState.lastCoords.x = pointerCoords.x;
+ pointerDownState.lastCoords.y = pointerCoords.y;
+ pointerDownState.drag.hasOccurred = true;
+ if (
+ this.state.editingLinearElement &&
+ !this.state.editingLinearElement.isDragging
+ ) {
+ this.setState({
+ editingLinearElement: {
+ ...this.state.editingLinearElement,
+ isDragging: true,
+ },
+ });
+ }
+ if (!this.state.selectedLinearElement.isDragging) {
+ this.setState({
+ selectedLinearElement: {
+ ...this.state.selectedLinearElement,
+ isDragging: true,
+ },
+ });
+ }
+ return;
+ }
+ }
+
+ const hasHitASelectedElement = pointerDownState.hit.allHitElements.some(
+ (element) => this.isASelectedElement(element),
+ );
+
+ const isSelectingPointsInLineEditor =
+ this.state.editingLinearElement &&
+ event.shiftKey &&
+ this.state.editingLinearElement.elementId ===
+ pointerDownState.hit.element?.id;
+ if (
+ (hasHitASelectedElement ||
+ pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements) &&
+ !isSelectingPointsInLineEditor
+ ) {
+ const selectedElements = this.scene.getSelectedElements(this.state);
+
+ if (selectedElements.every((element) => element.locked)) {
+ return;
+ }
+
+ const selectedElementsHasAFrame = selectedElements.find((e) =>
+ isFrameLikeElement(e),
+ );
+ const topLayerFrame = this.getTopLayerFrameAtSceneCoords(pointerCoords);
+ const frameToHighlight =
+ topLayerFrame && !selectedElementsHasAFrame ? topLayerFrame : null;
+ // Only update the state if there is a difference
+ if (this.state.frameToHighlight !== frameToHighlight) {
+ flushSync(() => {
+ this.setState({ frameToHighlight });
+ });
+ }
+
+ // Marking that click was used for dragging to check
+ // if elements should be deselected on pointerup
+ pointerDownState.drag.hasOccurred = true;
+
+ // prevent dragging even if we're no longer holding cmd/ctrl otherwise
+ // it would have weird results (stuff jumping all over the screen)
+ // Checking for editingTextElement to avoid jump while editing on mobile #6503
+ if (
+ selectedElements.length > 0 &&
+ !pointerDownState.withCmdOrCtrl &&
+ !this.state.editingTextElement &&
+ this.state.activeEmbeddable?.state !== "active"
+ ) {
+ const dragOffset = {
+ x: pointerCoords.x - pointerDownState.origin.x,
+ y: pointerCoords.y - pointerDownState.origin.y,
+ };
+
+ const originalElements = [
+ ...pointerDownState.originalElements.values(),
+ ];
+
+ // We only drag in one direction if shift is pressed
+ const lockDirection = event.shiftKey;
+
+ if (lockDirection) {
+ const distanceX = Math.abs(dragOffset.x);
+ const distanceY = Math.abs(dragOffset.y);
+
+ const lockX = lockDirection && distanceX < distanceY;
+ const lockY = lockDirection && distanceX > distanceY;
+
+ if (lockX) {
+ dragOffset.x = 0;
+ }
+
+ if (lockY) {
+ dragOffset.y = 0;
+ }
+ }
+
+ // #region move crop region
+ if (this.state.croppingElementId) {
+ const croppingElement = this.scene
+ .getNonDeletedElementsMap()
+ .get(this.state.croppingElementId);
+
+ if (
+ croppingElement &&
+ isImageElement(croppingElement) &&
+ croppingElement.crop !== null &&
+ pointerDownState.hit.element === croppingElement
+ ) {
+ const crop = croppingElement.crop;
+ const image =
+ isInitializedImageElement(croppingElement) &&
+ this.imageCache.get(croppingElement.fileId)?.image;
+
+ if (image && !(image instanceof Promise)) {
+ const instantDragOffset = vectorScale(
+ vector(
+ pointerCoords.x - lastPointerCoords.x,
+ pointerCoords.y - lastPointerCoords.y,
+ ),
+ Math.max(this.state.zoom.value, 2),
+ );
+
+ const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
+ croppingElement,
+ elementsMap,
+ );
+
+ const topLeft = vectorFromPoint(
+ pointRotateRads(
+ pointFrom(x1, y1),
+ pointFrom(cx, cy),
+ croppingElement.angle,
+ ),
+ );
+ const topRight = vectorFromPoint(
+ pointRotateRads(
+ pointFrom(x2, y1),
+ pointFrom(cx, cy),
+ croppingElement.angle,
+ ),
+ );
+ const bottomLeft = vectorFromPoint(
+ pointRotateRads(
+ pointFrom(x1, y2),
+ pointFrom(cx, cy),
+ croppingElement.angle,
+ ),
+ );
+ const topEdge = vectorNormalize(
+ vectorSubtract(topRight, topLeft),
+ );
+ const leftEdge = vectorNormalize(
+ vectorSubtract(bottomLeft, topLeft),
+ );
+
+ // project instantDrafOffset onto leftEdge and topEdge to decompose
+ const offsetVector = vector(
+ vectorDot(instantDragOffset, topEdge),
+ vectorDot(instantDragOffset, leftEdge),
+ );
+
+ const nextCrop = {
+ ...crop,
+ x: clamp(
+ crop.x -
+ offsetVector[0] * Math.sign(croppingElement.scale[0]),
+ 0,
+ image.naturalWidth - crop.width,
+ ),
+ y: clamp(
+ crop.y -
+ offsetVector[1] * Math.sign(croppingElement.scale[1]),
+ 0,
+ image.naturalHeight - crop.height,
+ ),
+ };
+
+ mutateElement(croppingElement, {
+ crop: nextCrop,
+ });
+
+ return;
+ }
+ }
+ }
+
+ // Snap cache *must* be synchronously popuplated before initial drag,
+ // otherwise the first drag even will not snap, causing a jump before
+ // it snaps to its position if previously snapped already.
+ this.maybeCacheVisibleGaps(event, selectedElements);
+ this.maybeCacheReferenceSnapPoints(event, selectedElements);
+
+ const { snapOffset, snapLines } = snapDraggedElements(
+ originalElements,
+ dragOffset,
+ this,
+ event,
+ this.scene.getNonDeletedElementsMap(),
+ );
+
+ this.setState({ snapLines });
+
+ // when we're editing the name of a frame, we want the user to be
+ // able to select and interact with the text input
+ if (!this.state.editingFrame) {
+ dragSelectedElements(
+ pointerDownState,
+ selectedElements,
+ dragOffset,
+ this.scene,
+ snapOffset,
+ event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
+ );
+ }
+
+ this.setState({
+ selectedElementsAreBeingDragged: true,
+ // element is being dragged and selectionElement that was created on pointer down
+ // should be removed
+ selectionElement: null,
+ });
+
+ if (
+ selectedElements.length !== 1 ||
+ !isElbowArrow(selectedElements[0])
+ ) {
+ this.setState({
+ suggestedBindings: getSuggestedBindingsForArrows(
+ selectedElements,
+ this.scene.getNonDeletedElementsMap(),
+ this.state.zoom,
+ ),
+ });
+ }
+
+ // We duplicate the selected element if alt is pressed on pointer move
+ if (event.altKey && !pointerDownState.hit.hasBeenDuplicated) {
+ // Move the currently selected elements to the top of the z index stack, and
+ // put the duplicates where the selected elements used to be.
+ // (the origin point where the dragging started)
+
+ pointerDownState.hit.hasBeenDuplicated = true;
+
+ const nextElements = [];
+ const elementsToAppend = [];
+ const groupIdMap = new Map();
+ const oldIdToDuplicatedId = new Map();
+ const hitElement = pointerDownState.hit.element;
+ const selectedElementIds = new Set(
+ this.scene
+ .getSelectedElements({
+ selectedElementIds: this.state.selectedElementIds,
+ includeBoundTextElement: true,
+ includeElementsInFrames: true,
+ })
+ .map((element) => element.id),
+ );
+
+ const elements = this.scene.getElementsIncludingDeleted();
+
+ for (const element of elements) {
+ const isInSelection =
+ selectedElementIds.has(element.id) ||
+ // case: the state.selectedElementIds might not have been
+ // updated yet by the time this mousemove event is fired
+ (element.id === hitElement?.id &&
+ pointerDownState.hit.wasAddedToSelection);
+ // NOTE (mtolmacs): This is a temporary fix for very large scenes
+ if (
+ Math.abs(element.x) > 1e7 ||
+ Math.abs(element.x) > 1e7 ||
+ Math.abs(element.width) > 1e7 ||
+ Math.abs(element.height) > 1e7
+ ) {
+ console.error(
+ `Alt+dragging element in scene with invalid dimensions`,
+ element.x,
+ element.y,
+ element.width,
+ element.height,
+ isInSelection,
+ );
+
+ return;
+ }
+
+ if (isInSelection) {
+ const duplicatedElement = duplicateElement(
+ this.state.editingGroupId,
+ groupIdMap,
+ element,
+ );
+
+ // NOTE (mtolmacs): This is a temporary fix for very large scenes
+ if (
+ Math.abs(duplicatedElement.x) > 1e7 ||
+ Math.abs(duplicatedElement.x) > 1e7 ||
+ Math.abs(duplicatedElement.width) > 1e7 ||
+ Math.abs(duplicatedElement.height) > 1e7
+ ) {
+ console.error(
+ `Alt+dragging duplicated element with invalid dimensions`,
+ duplicatedElement.x,
+ duplicatedElement.y,
+ duplicatedElement.width,
+ duplicatedElement.height,
+ );
+
+ return;
+ }
+
+ const origElement = pointerDownState.originalElements.get(
+ element.id,
+ )!;
+
+ // NOTE (mtolmacs): This is a temporary fix for very large scenes
+ if (
+ Math.abs(origElement.x) > 1e7 ||
+ Math.abs(origElement.x) > 1e7 ||
+ Math.abs(origElement.width) > 1e7 ||
+ Math.abs(origElement.height) > 1e7
+ ) {
+ console.error(
+ `Alt+dragging duplicated element with invalid dimensions`,
+ origElement.x,
+ origElement.y,
+ origElement.width,
+ origElement.height,
+ );
+
+ return;
+ }
+
+ mutateElement(duplicatedElement, {
+ x: origElement.x,
+ y: origElement.y,
+ });
+
+ // put duplicated element to pointerDownState.originalElements
+ // so that we can snap to the duplicated element without releasing
+ pointerDownState.originalElements.set(
+ duplicatedElement.id,
+ duplicatedElement,
+ );
+
+ nextElements.push(duplicatedElement);
+ elementsToAppend.push(element);
+ oldIdToDuplicatedId.set(element.id, duplicatedElement.id);
+ } else {
+ nextElements.push(element);
+ }
+ }
+
+ let nextSceneElements: ExcalidrawElement[] = [
+ ...nextElements,
+ ...elementsToAppend,
+ ];
+
+ const mappedNewSceneElements = this.props.onDuplicate?.(
+ nextSceneElements,
+ elements,
+ );
+
+ nextSceneElements = mappedNewSceneElements || nextSceneElements;
+
+ syncMovedIndices(nextSceneElements, arrayToMap(elementsToAppend));
+
+ bindTextToShapeAfterDuplication(
+ nextElements,
+ elementsToAppend,
+ oldIdToDuplicatedId,
+ );
+ fixBindingsAfterDuplication(
+ nextSceneElements,
+ elementsToAppend,
+ oldIdToDuplicatedId,
+ "duplicatesServeAsOld",
+ );
+ bindElementsToFramesAfterDuplication(
+ nextSceneElements,
+ elementsToAppend,
+ oldIdToDuplicatedId,
+ );
+
+ this.scene.replaceAllElements(nextSceneElements);
+ this.maybeCacheVisibleGaps(event, selectedElements, true);
+ this.maybeCacheReferenceSnapPoints(event, selectedElements, true);
+ }
+
+ return;
+ }
+ }
+
+ if (this.state.selectionElement) {
+ pointerDownState.lastCoords.x = pointerCoords.x;
+ pointerDownState.lastCoords.y = pointerCoords.y;
+ this.maybeDragNewGenericElement(pointerDownState, event);
+ } else {
+ // It is very important to read this.state within each move event,
+ // otherwise we would read a stale one!
+ const newElement = this.state.newElement;
+
+ if (!newElement) {
+ return;
+ }
+
+ if (newElement.type === "freedraw") {
+ const points = newElement.points;
+ const dx = pointerCoords.x - newElement.x;
+ const dy = pointerCoords.y - newElement.y;
+
+ const lastPoint = points.length > 0 && points[points.length - 1];
+ const discardPoint =
+ lastPoint && lastPoint[0] === dx && lastPoint[1] === dy;
+
+ if (!discardPoint) {
+ const pressures = newElement.simulatePressure
+ ? newElement.pressures
+ : [...newElement.pressures, event.pressure];
+
+ mutateElement(
+ newElement,
+ {
+ points: [...points, pointFrom<LocalPoint>(dx, dy)],
+ pressures,
+ },
+ false,
+ );
+
+ this.setState({
+ newElement,
+ });
+ }
+ } else if (isLinearElement(newElement)) {
+ pointerDownState.drag.hasOccurred = true;
+ const points = newElement.points;
+ let dx = gridX - newElement.x;
+ let dy = gridY - newElement.y;
+
+ if (shouldRotateWithDiscreteAngle(event) && points.length === 2) {
+ ({ width: dx, height: dy } = getLockedLinearCursorAlignSize(
+ newElement.x,
+ newElement.y,
+ pointerCoords.x,
+ pointerCoords.y,
+ ));
+ }
+
+ if (points.length === 1) {
+ mutateElement(
+ newElement,
+ {
+ points: [...points, pointFrom<LocalPoint>(dx, dy)],
+ },
+ false,
+ );
+ } else if (
+ points.length === 2 ||
+ (points.length > 1 && isElbowArrow(newElement))
+ ) {
+ mutateElement(
+ newElement,
+ {
+ points: [...points.slice(0, -1), pointFrom<LocalPoint>(dx, dy)],
+ },
+ false,
+ { isDragging: true },
+ );
+ }
+
+ this.setState({
+ newElement,
+ });
+
+ if (isBindingElement(newElement, false)) {
+ // When creating a linear element by dragging
+ this.maybeSuggestBindingsForLinearElementAtCoords(
+ newElement,
+ [pointerCoords],
+ this.state.startBoundElement,
+ );
+ }
+ } else {
+ pointerDownState.lastCoords.x = pointerCoords.x;
+ pointerDownState.lastCoords.y = pointerCoords.y;
+ this.maybeDragNewGenericElement(pointerDownState, event, false);
+ }
+ }
+
+ if (this.state.activeTool.type === "selection") {
+ pointerDownState.boxSelection.hasOccurred = true;
+
+ const elements = this.scene.getNonDeletedElements();
+
+ // box-select line editor points
+ if (this.state.editingLinearElement) {
+ LinearElementEditor.handleBoxSelection(
+ event,
+ this.state,
+ this.setState.bind(this),
+ this.scene.getNonDeletedElementsMap(),
+ );
+ // regular box-select
+ } else {
+ let shouldReuseSelection = true;
+
+ if (!event.shiftKey && isSomeElementSelected(elements, this.state)) {
+ if (
+ pointerDownState.withCmdOrCtrl &&
+ pointerDownState.hit.element
+ ) {
+ this.setState((prevState) =>
+ selectGroupsForSelectedElements(
+ {
+ ...prevState,
+ selectedElementIds: {
+ [pointerDownState.hit.element!.id]: true,
+ },
+ },
+ this.scene.getNonDeletedElements(),
+ prevState,
+ this,
+ ),
+ );
+ } else {
+ shouldReuseSelection = false;
+ }
+ }
+ const elementsWithinSelection = this.state.selectionElement
+ ? getElementsWithinSelection(
+ elements,
+ this.state.selectionElement,
+ this.scene.getNonDeletedElementsMap(),
+ false,
+ )
+ : [];
+
+ this.setState((prevState) => {
+ const nextSelectedElementIds = {
+ ...(shouldReuseSelection && prevState.selectedElementIds),
+ ...elementsWithinSelection.reduce(
+ (acc: Record<ExcalidrawElement["id"], true>, element) => {
+ acc[element.id] = true;
+ return acc;
+ },
+ {},
+ ),
+ };
+
+ if (pointerDownState.hit.element) {
+ // if using ctrl/cmd, select the hitElement only if we
+ // haven't box-selected anything else
+ if (!elementsWithinSelection.length) {
+ nextSelectedElementIds[pointerDownState.hit.element.id] = true;
+ } else {
+ delete nextSelectedElementIds[pointerDownState.hit.element.id];
+ }
+ }
+
+ prevState = !shouldReuseSelection
+ ? { ...prevState, selectedGroupIds: {}, editingGroupId: null }
+ : prevState;
+
+ return {
+ ...selectGroupsForSelectedElements(
+ {
+ editingGroupId: prevState.editingGroupId,
+ selectedElementIds: nextSelectedElementIds,
+ },
+ this.scene.getNonDeletedElements(),
+ prevState,
+ this,
+ ),
+ // select linear element only when we haven't box-selected anything else
+ selectedLinearElement:
+ elementsWithinSelection.length === 1 &&
+ isLinearElement(elementsWithinSelection[0])
+ ? new LinearElementEditor(elementsWithinSelection[0])
+ : null,
+ showHyperlinkPopup:
+ elementsWithinSelection.length === 1 &&
+ (elementsWithinSelection[0].link ||
+ isEmbeddableElement(elementsWithinSelection[0]))
+ ? "info"
+ : false,
+ };
+ });
+ }
+ }
+ });
+ }
+
+ // Returns whether the pointer move happened over either scrollbar
+ private handlePointerMoveOverScrollbars(
+ event: PointerEvent,
+ pointerDownState: PointerDownState,
+ ): boolean {
+ if (pointerDownState.scrollbars.isOverHorizontal) {
+ const x = event.clientX;
+ const dx = x - pointerDownState.lastCoords.x;
+ this.translateCanvas({
+ scrollX: this.state.scrollX - dx / this.state.zoom.value,
+ });
+ pointerDownState.lastCoords.x = x;
+ return true;
+ }
+
+ if (pointerDownState.scrollbars.isOverVertical) {
+ const y = event.clientY;
+ const dy = y - pointerDownState.lastCoords.y;
+ this.translateCanvas({
+ scrollY: this.state.scrollY - dy / this.state.zoom.value,
+ });
+ pointerDownState.lastCoords.y = y;
+ return true;
+ }
+ return false;
+ }
+
+ private onPointerUpFromPointerDownHandler(
+ pointerDownState: PointerDownState,
+ ): (event: PointerEvent) => void {
+ return withBatchedUpdates((childEvent: PointerEvent) => {
+ this.removePointer(childEvent);
+ if (pointerDownState.eventListeners.onMove) {
+ pointerDownState.eventListeners.onMove.flush();
+ }
+ const {
+ newElement,
+ resizingElement,
+ croppingElementId,
+ multiElement,
+ activeTool,
+ isResizing,
+ isRotating,
+ isCropping,
+ } = this.state;
+
+ this.setState((prevState) => ({
+ isResizing: false,
+ isRotating: false,
+ isCropping: false,
+ resizingElement: null,
+ selectionElement: null,
+ frameToHighlight: null,
+ elementsToHighlight: null,
+ cursorButton: "up",
+ snapLines: updateStable(prevState.snapLines, []),
+ originSnapOffset: null,
+ }));
+
+ this.lastPointerMoveCoords = null;
+
+ SnapCache.setReferenceSnapPoints(null);
+ SnapCache.setVisibleGaps(null);
+
+ this.savePointer(childEvent.clientX, childEvent.clientY, "up");
+
+ this.setState({
+ selectedElementsAreBeingDragged: false,
+ });
+ const elementsMap = this.scene.getNonDeletedElementsMap();
+
+ if (
+ pointerDownState.drag.hasOccurred &&
+ pointerDownState.hit?.element?.id
+ ) {
+ const element = elementsMap.get(pointerDownState.hit.element.id);
+ if (isBindableElement(element)) {
+ // Renormalize elbow arrows when they are changed via indirect move
+ element.boundElements
+ ?.filter((e) => e.type === "arrow")
+ .map((e) => elementsMap.get(e.id))
+ .filter((e) => isElbowArrow(e))
+ .forEach((e) => {
+ !!e && mutateElement(e, {}, true);
+ });
+ }
+ }
+
+ // Handle end of dragging a point of a linear element, might close a loop
+ // and sets binding element
+ if (this.state.editingLinearElement) {
+ if (
+ !pointerDownState.boxSelection.hasOccurred &&
+ pointerDownState.hit?.element?.id !==
+ this.state.editingLinearElement.elementId
+ ) {
+ this.actionManager.executeAction(actionFinalize);
+ } else {
+ const editingLinearElement = LinearElementEditor.handlePointerUp(
+ childEvent,
+ this.state.editingLinearElement,
+ this.state,
+ this.scene,
+ );
+ if (editingLinearElement !== this.state.editingLinearElement) {
+ this.setState({
+ editingLinearElement,
+ suggestedBindings: [],
+ });
+ }
+ }
+ } else if (this.state.selectedLinearElement) {
+ // Normalize elbow arrow points, remove close parallel segments
+ if (this.state.selectedLinearElement.elbowed) {
+ const element = LinearElementEditor.getElement(
+ this.state.selectedLinearElement.elementId,
+ this.scene.getNonDeletedElementsMap(),
+ );
+ if (element) {
+ mutateElement(element, {}, true);
+ }
+ }
+
+ if (
+ pointerDownState.hit?.element?.id !==
+ this.state.selectedLinearElement.elementId
+ ) {
+ const selectedELements = this.scene.getSelectedElements(this.state);
+ // set selectedLinearElement to null if there is more than one element selected since we don't want to show linear element handles
+ if (selectedELements.length > 1) {
+ this.setState({ selectedLinearElement: null });
+ }
+ } else {
+ const linearElementEditor = LinearElementEditor.handlePointerUp(
+ childEvent,
+ this.state.selectedLinearElement,
+ this.state,
+ this.scene,
+ );
+
+ const { startBindingElement, endBindingElement } =
+ linearElementEditor;
+ const element = this.scene.getElement(linearElementEditor.elementId);
+ if (isBindingElement(element)) {
+ bindOrUnbindLinearElement(
+ element,
+ startBindingElement,
+ endBindingElement,
+ elementsMap,
+ this.scene,
+ );
+ }
+
+ if (linearElementEditor !== this.state.selectedLinearElement) {
+ this.setState({
+ selectedLinearElement: {
+ ...linearElementEditor,
+ selectedPointsIndices: null,
+ },
+ suggestedBindings: [],
+ });
+ }
+ }
+ }
+
+ this.missingPointerEventCleanupEmitter.clear();
+
+ window.removeEventListener(
+ EVENT.POINTER_MOVE,
+ pointerDownState.eventListeners.onMove!,
+ );
+ window.removeEventListener(
+ EVENT.POINTER_UP,
+ pointerDownState.eventListeners.onUp!,
+ );
+ window.removeEventListener(
+ EVENT.KEYDOWN,
+ pointerDownState.eventListeners.onKeyDown!,
+ );
+ window.removeEventListener(
+ EVENT.KEYUP,
+ pointerDownState.eventListeners.onKeyUp!,
+ );
+
+ if (this.state.pendingImageElementId) {
+ this.setState({ pendingImageElementId: null });
+ }
+
+ this.props?.onPointerUp?.(activeTool, pointerDownState);
+ this.onPointerUpEmitter.trigger(
+ this.state.activeTool,
+ pointerDownState,
+ childEvent,
+ );
+
+ if (newElement?.type === "freedraw") {
+ const pointerCoords = viewportCoordsToSceneCoords(
+ childEvent,
+ this.state,
+ );
+
+ const points = newElement.points;
+ let dx = pointerCoords.x - newElement.x;
+ let dy = pointerCoords.y - newElement.y;
+
+ // Allows dots to avoid being flagged as infinitely small
+ if (dx === points[0][0] && dy === points[0][1]) {
+ dy += 0.0001;
+ dx += 0.0001;
+ }
+
+ const pressures = newElement.simulatePressure
+ ? []
+ : [...newElement.pressures, childEvent.pressure];
+
+ mutateElement(newElement, {
+ points: [...points, pointFrom<LocalPoint>(dx, dy)],
+ pressures,
+ lastCommittedPoint: pointFrom<LocalPoint>(dx, dy),
+ });
+
+ this.actionManager.executeAction(actionFinalize);
+
+ return;
+ }
+ if (isImageElement(newElement)) {
+ const imageElement = newElement;
+ try {
+ this.initializeImageDimensions(imageElement);
+ this.setState(
+ {
+ selectedElementIds: makeNextSelectedElementIds(
+ { [imageElement.id]: true },
+ this.state,
+ ),
+ },
+ () => {
+ this.actionManager.executeAction(actionFinalize);
+ },
+ );
+ } catch (error: any) {
+ console.error(error);
+ this.scene.replaceAllElements(
+ this.scene
+ .getElementsIncludingDeleted()
+ .filter((el) => el.id !== imageElement.id),
+ );
+ this.actionManager.executeAction(actionFinalize);
+ }
+ return;
+ }
+
+ if (isLinearElement(newElement)) {
+ if (newElement!.points.length > 1) {
+ this.store.shouldCaptureIncrement();
+ }
+ const pointerCoords = viewportCoordsToSceneCoords(
+ childEvent,
+ this.state,
+ );
+
+ if (!pointerDownState.drag.hasOccurred && newElement && !multiElement) {
+ mutateElement(newElement, {
+ points: [
+ ...newElement.points,
+ pointFrom<LocalPoint>(
+ pointerCoords.x - newElement.x,
+ pointerCoords.y - newElement.y,
+ ),
+ ],
+ });
+ this.setState({
+ multiElement: newElement,
+ newElement,
+ });
+ } else if (pointerDownState.drag.hasOccurred && !multiElement) {
+ if (
+ isBindingEnabled(this.state) &&
+ isBindingElement(newElement, false)
+ ) {
+ maybeBindLinearElement(
+ newElement,
+ this.state,
+ pointerCoords,
+ this.scene.getNonDeletedElementsMap(),
+ this.scene.getNonDeletedElements(),
+ );
+ }
+ this.setState({ suggestedBindings: [], startBoundElement: null });
+ if (!activeTool.locked) {
+ resetCursor(this.interactiveCanvas);
+ this.setState((prevState) => ({
+ newElement: null,
+ activeTool: updateActiveTool(this.state, {
+ type: "selection",
+ }),
+ selectedElementIds: makeNextSelectedElementIds(
+ {
+ ...prevState.selectedElementIds,
+ [newElement.id]: true,
+ },
+ prevState,
+ ),
+ selectedLinearElement: new LinearElementEditor(newElement),
+ }));
+ } else {
+ this.setState((prevState) => ({
+ newElement: null,
+ }));
+ }
+ // so that the scene gets rendered again to display the newly drawn linear as well
+ this.scene.triggerUpdate();
+ }
+ return;
+ }
+
+ if (isTextElement(newElement)) {
+ const minWidth = getMinTextElementWidth(
+ getFontString({
+ fontSize: newElement.fontSize,
+ fontFamily: newElement.fontFamily,
+ }),
+ newElement.lineHeight,
+ );
+
+ if (newElement.width < minWidth) {
+ mutateElement(newElement, {
+ autoResize: true,
+ });
+ }
+
+ this.resetCursor();
+
+ this.handleTextWysiwyg(newElement, {
+ isExistingElement: true,
+ });
+ }
+
+ if (
+ activeTool.type !== "selection" &&
+ newElement &&
+ isInvisiblySmallElement(newElement)
+ ) {
+ // remove invisible element which was added in onPointerDown
+ // update the store snapshot, so that invisible elements are not captured by the store
+ this.updateScene({
+ elements: this.scene
+ .getElementsIncludingDeleted()
+ .filter((el) => el.id !== newElement.id),
+ appState: {
+ newElement: null,
+ },
+ captureUpdate: CaptureUpdateAction.NEVER,
+ });
+
+ return;
+ }
+
+ if (isFrameLikeElement(newElement)) {
+ const elementsInsideFrame = getElementsInNewFrame(
+ this.scene.getElementsIncludingDeleted(),
+ newElement,
+ this.scene.getNonDeletedElementsMap(),
+ );
+
+ this.scene.replaceAllElements(
+ addElementsToFrame(
+ this.scene.getElementsMapIncludingDeleted(),
+ elementsInsideFrame,
+ newElement,
+ this.state,
+ ),
+ );
+ }
+
+ if (newElement) {
+ mutateElement(newElement, getNormalizedDimensions(newElement));
+ // the above does not guarantee the scene to be rendered again, hence the trigger below
+ this.scene.triggerUpdate();
+ }
+
+ if (pointerDownState.drag.hasOccurred) {
+ const sceneCoords = viewportCoordsToSceneCoords(childEvent, this.state);
+
+ // when editing the points of a linear element, we check if the
+ // linear element still is in the frame afterwards
+ // if not, the linear element will be removed from its frame (if any)
+ if (
+ this.state.selectedLinearElement &&
+ this.state.selectedLinearElement.isDragging
+ ) {
+ const linearElement = this.scene.getElement(
+ this.state.selectedLinearElement.elementId,
+ );
+
+ if (linearElement?.frameId) {
+ const frame = getContainingFrame(linearElement, elementsMap);
+
+ if (frame && linearElement) {
+ if (
+ !elementOverlapsWithFrame(
+ linearElement,
+ frame,
+ this.scene.getNonDeletedElementsMap(),
+ )
+ ) {
+ // remove the linear element from all groups
+ // before removing it from the frame as well
+ mutateElement(linearElement, {
+ groupIds: [],
+ });
+
+ removeElementsFromFrame(
+ [linearElement],
+ this.scene.getNonDeletedElementsMap(),
+ );
+
+ this.scene.triggerUpdate();
+ }
+ }
+ }
+ } else {
+ // update the relationships between selected elements and frames
+ const topLayerFrame = this.getTopLayerFrameAtSceneCoords(sceneCoords);
+
+ const selectedElements = this.scene.getSelectedElements(this.state);
+ let nextElements = this.scene.getElementsMapIncludingDeleted();
+
+ const updateGroupIdsAfterEditingGroup = (
+ elements: ExcalidrawElement[],
+ ) => {
+ if (elements.length > 0) {
+ for (const element of elements) {
+ const index = element.groupIds.indexOf(
+ this.state.editingGroupId!,
+ );
+
+ mutateElement(
+ element,
+ {
+ groupIds: element.groupIds.slice(0, index),
+ },
+ false,
+ );
+ }
+
+ nextElements.forEach((element) => {
+ if (
+ element.groupIds.length &&
+ getElementsInGroup(
+ nextElements,
+ element.groupIds[element.groupIds.length - 1],
+ ).length < 2
+ ) {
+ mutateElement(
+ element,
+ {
+ groupIds: [],
+ },
+ false,
+ );
+ }
+ });
+
+ this.setState({
+ editingGroupId: null,
+ });
+ }
+ };
+
+ if (
+ topLayerFrame &&
+ !this.state.selectedElementIds[topLayerFrame.id]
+ ) {
+ const elementsToAdd = selectedElements.filter(
+ (element) =>
+ element.frameId !== topLayerFrame.id &&
+ isElementInFrame(element, nextElements, this.state),
+ );
+
+ if (this.state.editingGroupId) {
+ updateGroupIdsAfterEditingGroup(elementsToAdd);
+ }
+
+ nextElements = addElementsToFrame(
+ nextElements,
+ elementsToAdd,
+ topLayerFrame,
+ this.state,
+ );
+ } else if (!topLayerFrame) {
+ if (this.state.editingGroupId) {
+ const elementsToRemove = selectedElements.filter(
+ (element) =>
+ element.frameId &&
+ !isElementInFrame(element, nextElements, this.state),
+ );
+
+ updateGroupIdsAfterEditingGroup(elementsToRemove);
+ }
+ }
+
+ nextElements = updateFrameMembershipOfSelectedElements(
+ nextElements,
+ this.state,
+ this,
+ );
+
+ this.scene.replaceAllElements(nextElements);
+ }
+ }
+
+ if (resizingElement) {
+ this.store.shouldCaptureIncrement();
+ }
+
+ if (resizingElement && isInvisiblySmallElement(resizingElement)) {
+ // update the store snapshot, so that invisible elements are not captured by the store
+ this.updateScene({
+ elements: this.scene
+ .getElementsIncludingDeleted()
+ .filter((el) => el.id !== resizingElement.id),
+ captureUpdate: CaptureUpdateAction.NEVER,
+ });
+ }
+
+ // handle frame membership for resizing frames and/or selected elements
+ if (pointerDownState.resize.isResizing) {
+ let nextElements = updateFrameMembershipOfSelectedElements(
+ this.scene.getElementsIncludingDeleted(),
+ this.state,
+ this,
+ );
+
+ const selectedFrames = this.scene
+ .getSelectedElements(this.state)
+ .filter((element): element is ExcalidrawFrameLikeElement =>
+ isFrameLikeElement(element),
+ );
+
+ for (const frame of selectedFrames) {
+ nextElements = replaceAllElementsInFrame(
+ nextElements,
+ getElementsInResizingFrame(
+ this.scene.getElementsIncludingDeleted(),
+ frame,
+ this.state,
+ elementsMap,
+ ),
+ frame,
+ this,
+ );
+ }
+
+ this.scene.replaceAllElements(nextElements);
+ }
+
+ // Code below handles selection when element(s) weren't
+ // drag or added to selection on pointer down phase.
+ const hitElement = pointerDownState.hit.element;
+ if (
+ this.state.selectedLinearElement?.elementId !== hitElement?.id &&
+ isLinearElement(hitElement)
+ ) {
+ const selectedElements = this.scene.getSelectedElements(this.state);
+ // set selectedLinearElement when no other element selected except
+ // the one we've hit
+ if (selectedElements.length === 1) {
+ this.setState({
+ selectedLinearElement: new LinearElementEditor(hitElement),
+ });
+ }
+ }
+
+ // click outside the cropping region to exit
+ if (
+ // not in the cropping mode at all
+ !croppingElementId ||
+ // in the cropping mode
+ (croppingElementId &&
+ // not cropping and no hit element
+ ((!hitElement && !isCropping) ||
+ // hitting something else
+ (hitElement && hitElement.id !== croppingElementId)))
+ ) {
+ this.finishImageCropping();
+ }
+
+ const pointerStart = this.lastPointerDownEvent;
+ const pointerEnd = this.lastPointerUpEvent || this.lastPointerMoveEvent;
+
+ if (isEraserActive(this.state) && pointerStart && pointerEnd) {
+ this.eraserTrail.endPath();
+
+ const draggedDistance = pointDistance(
+ pointFrom(pointerStart.clientX, pointerStart.clientY),
+ pointFrom(pointerEnd.clientX, pointerEnd.clientY),
+ );
+
+ if (draggedDistance === 0) {
+ const scenePointer = viewportCoordsToSceneCoords(
+ {
+ clientX: pointerEnd.clientX,
+ clientY: pointerEnd.clientY,
+ },
+ this.state,
+ );
+ const hitElements = this.getElementsAtPosition(
+ scenePointer.x,
+ scenePointer.y,
+ );
+ hitElements.forEach((hitElement) =>
+ this.elementsPendingErasure.add(hitElement.id),
+ );
+ }
+ this.eraseElements();
+ return;
+ } else if (this.elementsPendingErasure.size) {
+ this.restoreReadyToEraseElements();
+ }
+
+ if (
+ hitElement &&
+ !pointerDownState.drag.hasOccurred &&
+ !pointerDownState.hit.wasAddedToSelection &&
+ // if we're editing a line, pointerup shouldn't switch selection if
+ // box selected
+ (!this.state.editingLinearElement ||
+ !pointerDownState.boxSelection.hasOccurred)
+ ) {
+ // when inside line editor, shift selects points instead
+ if (childEvent.shiftKey && !this.state.editingLinearElement) {
+ if (this.state.selectedElementIds[hitElement.id]) {
+ if (isSelectedViaGroup(this.state, hitElement)) {
+ this.setState((_prevState) => {
+ const nextSelectedElementIds = {
+ ..._prevState.selectedElementIds,
+ };
+
+ // We want to unselect all groups hitElement is part of
+ // as well as all elements that are part of the groups
+ // hitElement is part of
+ for (const groupedElement of hitElement.groupIds.flatMap(
+ (groupId) =>
+ getElementsInGroup(
+ this.scene.getNonDeletedElements(),
+ groupId,
+ ),
+ )) {
+ delete nextSelectedElementIds[groupedElement.id];
+ }
+
+ return {
+ selectedGroupIds: {
+ ..._prevState.selectedElementIds,
+ ...hitElement.groupIds
+ .map((gId) => ({ [gId]: false }))
+ .reduce((prev, acc) => ({ ...prev, ...acc }), {}),
+ },
+ selectedElementIds: makeNextSelectedElementIds(
+ nextSelectedElementIds,
+ _prevState,
+ ),
+ };
+ });
+ // if not dragging a linear element point (outside editor)
+ } else if (!this.state.selectedLinearElement?.isDragging) {
+ // remove element from selection while
+ // keeping prev elements selected
+
+ this.setState((prevState) => {
+ const newSelectedElementIds = {
+ ...prevState.selectedElementIds,
+ };
+ delete newSelectedElementIds[hitElement!.id];
+ const newSelectedElements = getSelectedElements(
+ this.scene.getNonDeletedElements(),
+ { selectedElementIds: newSelectedElementIds },
+ );
+
+ return {
+ ...selectGroupsForSelectedElements(
+ {
+ editingGroupId: prevState.editingGroupId,
+ selectedElementIds: newSelectedElementIds,
+ },
+ this.scene.getNonDeletedElements(),
+ prevState,
+ this,
+ ),
+ // set selectedLinearElement only if thats the only element selected
+ selectedLinearElement:
+ newSelectedElements.length === 1 &&
+ isLinearElement(newSelectedElements[0])
+ ? new LinearElementEditor(newSelectedElements[0])
+ : prevState.selectedLinearElement,
+ };
+ });
+ }
+ } else if (
+ hitElement.frameId &&
+ this.state.selectedElementIds[hitElement.frameId]
+ ) {
+ // when hitElement is part of a selected frame, deselect the frame
+ // to avoid frame and containing elements selected simultaneously
+ this.setState((prevState) => {
+ const nextSelectedElementIds: {
+ [id: string]: true;
+ } = {
+ ...prevState.selectedElementIds,
+ [hitElement.id]: true,
+ };
+ // deselect the frame
+ delete nextSelectedElementIds[hitElement.frameId!];
+
+ // deselect groups containing the frame
+ (this.scene.getElement(hitElement.frameId!)?.groupIds ?? [])
+ .flatMap((gid) =>
+ getElementsInGroup(this.scene.getNonDeletedElements(), gid),
+ )
+ .forEach((element) => {
+ delete nextSelectedElementIds[element.id];
+ });
+
+ return {
+ ...selectGroupsForSelectedElements(
+ {
+ editingGroupId: prevState.editingGroupId,
+ selectedElementIds: nextSelectedElementIds,
+ },
+ this.scene.getNonDeletedElements(),
+ prevState,
+ this,
+ ),
+ showHyperlinkPopup:
+ hitElement.link || isEmbeddableElement(hitElement)
+ ? "info"
+ : false,
+ };
+ });
+ } else {
+ // add element to selection while keeping prev elements selected
+ this.setState((_prevState) => ({
+ selectedElementIds: makeNextSelectedElementIds(
+ {
+ ..._prevState.selectedElementIds,
+ [hitElement!.id]: true,
+ },
+ _prevState,
+ ),
+ }));
+ }
+ } else {
+ this.setState((prevState) => ({
+ ...selectGroupsForSelectedElements(
+ {
+ editingGroupId: prevState.editingGroupId,
+ selectedElementIds: { [hitElement.id]: true },
+ },
+ this.scene.getNonDeletedElements(),
+ prevState,
+ this,
+ ),
+ selectedLinearElement:
+ isLinearElement(hitElement) &&
+ // Don't set `selectedLinearElement` if its same as the hitElement, this is mainly to prevent resetting the `hoverPointIndex` to -1.
+ // Future we should update the API to take care of setting the correct `hoverPointIndex` when initialized
+ prevState.selectedLinearElement?.elementId !== hitElement.id
+ ? new LinearElementEditor(hitElement)
+ : prevState.selectedLinearElement,
+ }));
+ }
+ }
+
+ if (
+ // not elbow midpoint dragged
+ !(hitElement && isElbowArrow(hitElement)) &&
+ // not dragged
+ !pointerDownState.drag.hasOccurred &&
+ // not resized
+ !this.state.isResizing &&
+ // only hitting the bounding box of the previous hit element
+ ((hitElement &&
+ hitElementBoundingBoxOnly(
+ {
+ x: pointerDownState.origin.x,
+ y: pointerDownState.origin.y,
+ element: hitElement,
+ shape: getElementShape(
+ hitElement,
+ this.scene.getNonDeletedElementsMap(),
+ ),
+ threshold: this.getElementHitThreshold(),
+ frameNameBound: isFrameLikeElement(hitElement)
+ ? this.frameNameBoundsCache.get(hitElement)
+ : null,
+ },
+ elementsMap,
+ )) ||
+ (!hitElement &&
+ pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements))
+ ) {
+ if (this.state.editingLinearElement) {
+ this.setState({ editingLinearElement: null });
+ } else {
+ // Deselect selected elements
+ this.setState({
+ selectedElementIds: makeNextSelectedElementIds({}, this.state),
+ selectedGroupIds: {},
+ editingGroupId: null,
+ activeEmbeddable: null,
+ });
+ }
+ // reset cursor
+ setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
+ return;
+ }
+
+ if (!activeTool.locked && activeTool.type !== "freedraw" && newElement) {
+ this.setState((prevState) => ({
+ selectedElementIds: makeNextSelectedElementIds(
+ {
+ ...prevState.selectedElementIds,
+ [newElement.id]: true,
+ },
+ prevState,
+ ),
+ showHyperlinkPopup:
+ isEmbeddableElement(newElement) && !newElement.link
+ ? "editor"
+ : prevState.showHyperlinkPopup,
+ }));
+ }
+
+ if (
+ activeTool.type !== "selection" ||
+ isSomeElementSelected(this.scene.getNonDeletedElements(), this.state) ||
+ !isShallowEqual(
+ this.state.previousSelectedElementIds,
+ this.state.selectedElementIds,
+ )
+ ) {
+ this.store.shouldCaptureIncrement();
+ }
+
+ if (
+ pointerDownState.drag.hasOccurred ||
+ isResizing ||
+ isRotating ||
+ isCropping
+ ) {
+ // We only allow binding via linear elements, specifically via dragging
+ // the endpoints ("start" or "end").
+ const linearElements = this.scene
+ .getSelectedElements(this.state)
+ .filter(isLinearElement);
+
+ bindOrUnbindLinearElements(
+ linearElements,
+ this.scene.getNonDeletedElementsMap(),
+ this.scene.getNonDeletedElements(),
+ this.scene,
+ isBindingEnabled(this.state),
+ this.state.selectedLinearElement?.selectedPointsIndices ?? [],
+ this.state.zoom,
+ );
+ }
+
+ if (activeTool.type === "laser") {
+ this.laserTrails.endPath();
+ return;
+ }
+
+ if (!activeTool.locked && activeTool.type !== "freedraw") {
+ resetCursor(this.interactiveCanvas);
+ this.setState({
+ newElement: null,
+ suggestedBindings: [],
+ activeTool: updateActiveTool(this.state, { type: "selection" }),
+ });
+ } else {
+ this.setState({
+ newElement: null,
+ suggestedBindings: [],
+ });
+ }
+
+ if (
+ hitElement &&
+ this.lastPointerUpEvent &&
+ this.lastPointerDownEvent &&
+ this.lastPointerUpEvent.timeStamp -
+ this.lastPointerDownEvent.timeStamp <
+ 300 &&
+ gesture.pointers.size <= 1 &&
+ isIframeLikeElement(hitElement) &&
+ this.isIframeLikeElementCenter(
+ hitElement,
+ this.lastPointerUpEvent,
+ pointerDownState.origin.x,
+ pointerDownState.origin.y,
+ )
+ ) {
+ this.handleEmbeddableCenterClick(hitElement);
+ }
+ });
+ }
+
+ private restoreReadyToEraseElements = () => {
+ this.elementsPendingErasure = new Set();
+ this.triggerRender();
+ };
+
+ private eraseElements = () => {
+ let didChange = false;
+ const elements = this.scene.getElementsIncludingDeleted().map((ele) => {
+ if (
+ this.elementsPendingErasure.has(ele.id) ||
+ (ele.frameId && this.elementsPendingErasure.has(ele.frameId)) ||
+ (isBoundToContainer(ele) &&
+ this.elementsPendingErasure.has(ele.containerId))
+ ) {
+ didChange = true;
+ return newElementWith(ele, { isDeleted: true });
+ }
+ return ele;
+ });
+
+ this.elementsPendingErasure = new Set();
+
+ if (didChange) {
+ this.store.shouldCaptureIncrement();
+ this.scene.replaceAllElements(elements);
+ }
+ };
+
+ private initializeImage = async ({
+ imageFile,
+ imageElement: _imageElement,
+ showCursorImagePreview = false,
+ }: {
+ imageFile: File;
+ imageElement: ExcalidrawImageElement;
+ showCursorImagePreview?: boolean;
+ }) => {
+ // at this point this should be guaranteed image file, but we do this check
+ // to satisfy TS down the line
+ if (!isSupportedImageFile(imageFile)) {
+ throw new Error(t("errors.unsupportedFileType"));
+ }
+ const mimeType = imageFile.type;
+
+ setCursor(this.interactiveCanvas, "wait");
+
+ if (mimeType === MIME_TYPES.svg) {
+ try {
+ imageFile = SVGStringToFile(
+ normalizeSVG(await imageFile.text()),
+ imageFile.name,
+ );
+ } catch (error: any) {
+ console.warn(error);
+ throw new Error(t("errors.svgImageInsertError"));
+ }
+ }
+
+ // generate image id (by default the file digest) before any
+ // resizing/compression takes place to keep it more portable
+ const fileId = await ((this.props.generateIdForFile?.(
+ imageFile,
+ ) as Promise<FileId>) || generateIdFromFile(imageFile));
+
+ if (!fileId) {
+ console.warn(
+ "Couldn't generate file id or the supplied `generateIdForFile` didn't resolve to one.",
+ );
+ throw new Error(t("errors.imageInsertError"));
+ }
+
+ const existingFileData = this.files[fileId];
+ if (!existingFileData?.dataURL) {
+ try {
+ imageFile = await resizeImageFile(imageFile, {
+ maxWidthOrHeight: DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT,
+ });
+ } catch (error: any) {
+ console.error(
+ "Error trying to resizing image file on insertion",
+ error,
+ );
+ }
+
+ if (imageFile.size > MAX_ALLOWED_FILE_BYTES) {
+ throw new Error(
+ t("errors.fileTooBig", {
+ maxSize: `${Math.trunc(MAX_ALLOWED_FILE_BYTES / 1024 / 1024)}MB`,
+ }),
+ );
+ }
+ }
+
+ if (showCursorImagePreview) {
+ const dataURL = this.files[fileId]?.dataURL;
+ // optimization so that we don't unnecessarily resize the original
+ // full-size file for cursor preview
+ // (it's much faster to convert the resized dataURL to File)
+ const resizedFile = dataURL && dataURLToFile(dataURL);
+
+ this.setImagePreviewCursor(resizedFile || imageFile);
+ }
+
+ const dataURL =
+ this.files[fileId]?.dataURL || (await getDataURL(imageFile));
+
+ const imageElement = mutateElement(
+ _imageElement,
+ {
+ fileId,
+ },
+ false,
+ ) as NonDeleted<InitializedExcalidrawImageElement>;
+
+ return new Promise<NonDeleted<InitializedExcalidrawImageElement>>(
+ async (resolve, reject) => {
+ try {
+ this.addMissingFiles([
+ {
+ mimeType,
+ id: fileId,
+ dataURL,
+ created: Date.now(),
+ lastRetrieved: Date.now(),
+ },
+ ]);
+ const cachedImageData = this.imageCache.get(fileId);
+ if (!cachedImageData) {
+ this.addNewImagesToImageCache();
+ await this.updateImageCache([imageElement]);
+ }
+ if (cachedImageData?.image instanceof Promise) {
+ await cachedImageData.image;
+ }
+ if (
+ this.state.pendingImageElementId !== imageElement.id &&
+ this.state.newElement?.id !== imageElement.id
+ ) {
+ this.initializeImageDimensions(imageElement, true);
+ }
+ resolve(imageElement);
+ } catch (error: any) {
+ console.error(error);
+ reject(new Error(t("errors.imageInsertError")));
+ } finally {
+ if (!showCursorImagePreview) {
+ resetCursor(this.interactiveCanvas);
+ }
+ }
+ },
+ );
+ };
+
+ /**
+ * inserts image into elements array and rerenders
+ */
+ insertImageElement = async (
+ imageElement: ExcalidrawImageElement,
+ imageFile: File,
+ showCursorImagePreview?: boolean,
+ ) => {
+ // we should be handling all cases upstream, but in case we forget to handle
+ // a future case, let's throw here
+ if (!this.isToolSupported("image")) {
+ this.setState({ errorMessage: t("errors.imageToolNotSupported") });
+ return;
+ }
+
+ this.scene.insertElement(imageElement);
+
+ try {
+ return await this.initializeImage({
+ imageFile,
+ imageElement,
+ showCursorImagePreview,
+ });
+ } catch (error: any) {
+ mutateElement(imageElement, {
+ isDeleted: true,
+ });
+ this.actionManager.executeAction(actionFinalize);
+ this.setState({
+ errorMessage: error.message || t("errors.imageInsertError"),
+ });
+ return null;
+ }
+ };
+
+ private setImagePreviewCursor = async (imageFile: File) => {
+ // mustn't be larger than 128 px
+ // https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Basic_User_Interface/Using_URL_values_for_the_cursor_property
+ const cursorImageSizePx = 96;
+ let imagePreview;
+
+ try {
+ imagePreview = await resizeImageFile(imageFile, {
+ maxWidthOrHeight: cursorImageSizePx,
+ });
+ } catch (e: any) {
+ if (e.cause === "UNSUPPORTED") {
+ throw new Error(t("errors.unsupportedFileType"));
+ }
+ throw e;
+ }
+
+ let previewDataURL = await getDataURL(imagePreview);
+
+ // SVG cannot be resized via `resizeImageFile` so we resize by rendering to
+ // a small canvas
+ if (imageFile.type === MIME_TYPES.svg) {
+ const img = await loadHTMLImageElement(previewDataURL);
+
+ let height = Math.min(img.height, cursorImageSizePx);
+ let width = height * (img.width / img.height);
+
+ if (width > cursorImageSizePx) {
+ width = cursorImageSizePx;
+ height = width * (img.height / img.width);
+ }
+
+ const canvas = document.createElement("canvas");
+ canvas.height = height;
+ canvas.width = width;
+ const context = canvas.getContext("2d")!;
+
+ context.drawImage(img, 0, 0, width, height);
+
+ previewDataURL = canvas.toDataURL(MIME_TYPES.svg) as DataURL;
+ }
+
+ if (this.state.pendingImageElementId) {
+ setCursor(this.interactiveCanvas, `url(${previewDataURL}) 4 4, auto`);
+ }
+ };
+
+ private onImageAction = async ({
+ insertOnCanvasDirectly,
+ }: {
+ insertOnCanvasDirectly: boolean;
+ }) => {
+ try {
+ const clientX = this.state.width / 2 + this.state.offsetLeft;
+ const clientY = this.state.height / 2 + this.state.offsetTop;
+
+ const { x, y } = viewportCoordsToSceneCoords(
+ { clientX, clientY },
+ this.state,
+ );
+
+ const imageFile = await fileOpen({
+ description: "Image",
+ extensions: Object.keys(
+ IMAGE_MIME_TYPES,
+ ) as (keyof typeof IMAGE_MIME_TYPES)[],
+ });
+
+ const imageElement = this.createImageElement({
+ sceneX: x,
+ sceneY: y,
+ addToFrameUnderCursor: false,
+ });
+
+ if (insertOnCanvasDirectly) {
+ this.insertImageElement(imageElement, imageFile);
+ this.initializeImageDimensions(imageElement);
+ this.setState(
+ {
+ selectedElementIds: makeNextSelectedElementIds(
+ { [imageElement.id]: true },
+ this.state,
+ ),
+ },
+ () => {
+ this.actionManager.executeAction(actionFinalize);
+ },
+ );
+ } else {
+ this.setState(
+ {
+ pendingImageElementId: imageElement.id,
+ },
+ () => {
+ this.insertImageElement(
+ imageElement,
+ imageFile,
+ /* showCursorImagePreview */ true,
+ );
+ },
+ );
+ }
+ } catch (error: any) {
+ if (error.name !== "AbortError") {
+ console.error(error);
+ } else {
+ console.warn(error);
+ }
+ this.setState(
+ {
+ pendingImageElementId: null,
+ newElement: null,
+ activeTool: updateActiveTool(this.state, { type: "selection" }),
+ },
+ () => {
+ this.actionManager.executeAction(actionFinalize);
+ },
+ );
+ }
+ };
+
+ initializeImageDimensions = (
+ imageElement: ExcalidrawImageElement,
+ forceNaturalSize = false,
+ ) => {
+ const image =
+ isInitializedImageElement(imageElement) &&
+ this.imageCache.get(imageElement.fileId)?.image;
+
+ if (!image || image instanceof Promise) {
+ if (
+ imageElement.width < DRAGGING_THRESHOLD / this.state.zoom.value &&
+ imageElement.height < DRAGGING_THRESHOLD / this.state.zoom.value
+ ) {
+ const placeholderSize = 100 / this.state.zoom.value;
+ mutateElement(imageElement, {
+ x: imageElement.x - placeholderSize / 2,
+ y: imageElement.y - placeholderSize / 2,
+ width: placeholderSize,
+ height: placeholderSize,
+ });
+ }
+
+ return;
+ }
+
+ if (
+ forceNaturalSize ||
+ // if user-created bounding box is below threshold, assume the
+ // intention was to click instead of drag, and use the image's
+ // intrinsic size
+ (imageElement.width < DRAGGING_THRESHOLD / this.state.zoom.value &&
+ imageElement.height < DRAGGING_THRESHOLD / this.state.zoom.value)
+ ) {
+ const minHeight = Math.max(this.state.height - 120, 160);
+ // max 65% of canvas height, clamped to <300px, vh - 120px>
+ const maxHeight = Math.min(
+ minHeight,
+ Math.floor(this.state.height * 0.5) / this.state.zoom.value,
+ );
+
+ const height = Math.min(image.naturalHeight, maxHeight);
+ const width = height * (image.naturalWidth / image.naturalHeight);
+
+ // add current imageElement width/height to account for previous centering
+ // of the placeholder image
+ const x = imageElement.x + imageElement.width / 2 - width / 2;
+ const y = imageElement.y + imageElement.height / 2 - height / 2;
+
+ mutateElement(imageElement, {
+ x,
+ y,
+ width,
+ height,
+ crop: null,
+ });
+ }
+ };
+
+ /** updates image cache, refreshing updated elements and/or setting status
+ to error for images that fail during <img> element creation */
+ private updateImageCache = async (
+ elements: readonly InitializedExcalidrawImageElement[],
+ files = this.files,
+ ) => {
+ const { updatedFiles, erroredFiles } = await _updateImageCache({
+ imageCache: this.imageCache,
+ fileIds: elements.map((element) => element.fileId),
+ files,
+ });
+ if (updatedFiles.size || erroredFiles.size) {
+ for (const element of elements) {
+ if (updatedFiles.has(element.fileId)) {
+ ShapeCache.delete(element);
+ }
+ }
+ }
+ if (erroredFiles.size) {
+ this.scene.replaceAllElements(
+ this.scene.getElementsIncludingDeleted().map((element) => {
+ if (
+ isInitializedImageElement(element) &&
+ erroredFiles.has(element.fileId)
+ ) {
+ return newElementWith(element, {
+ status: "error",
+ });
+ }
+ return element;
+ }),
+ );
+ }
+
+ return { updatedFiles, erroredFiles };
+ };
+
+ /** adds new images to imageCache and re-renders if needed */
+ private addNewImagesToImageCache = async (
+ imageElements: InitializedExcalidrawImageElement[] = getInitializedImageElements(
+ this.scene.getNonDeletedElements(),
+ ),
+ files: BinaryFiles = this.files,
+ ) => {
+ const uncachedImageElements = imageElements.filter(
+ (element) => !element.isDeleted && !this.imageCache.has(element.fileId),
+ );
+
+ if (uncachedImageElements.length) {
+ const { updatedFiles } = await this.updateImageCache(
+ uncachedImageElements,
+ files,
+ );
+ if (updatedFiles.size) {
+ this.scene.triggerUpdate();
+ }
+ }
+ };
+
+ /** generally you should use `addNewImagesToImageCache()` directly if you need
+ * to render new images. This is just a failsafe */
+ private scheduleImageRefresh = throttle(() => {
+ this.addNewImagesToImageCache();
+ }, IMAGE_RENDER_TIMEOUT);
+
+ private updateBindingEnabledOnPointerMove = (
+ event: React.PointerEvent<HTMLElement>,
+ ) => {
+ const shouldEnableBinding = shouldEnableBindingForPointerEvent(event);
+ if (this.state.isBindingEnabled !== shouldEnableBinding) {
+ this.setState({ isBindingEnabled: shouldEnableBinding });
+ }
+ };
+
+ private maybeSuggestBindingAtCursor = (
+ pointerCoords: {
+ x: number;
+ y: number;
+ },
+ considerAll: boolean,
+ ): void => {
+ const hoveredBindableElement = getHoveredElementForBinding(
+ pointerCoords,
+ this.scene.getNonDeletedElements(),
+ this.scene.getNonDeletedElementsMap(),
+ this.state.zoom,
+ false,
+ considerAll,
+ );
+ this.setState({
+ suggestedBindings:
+ hoveredBindableElement != null ? [hoveredBindableElement] : [],
+ });
+ };
+
+ private maybeSuggestBindingsForLinearElementAtCoords = (
+ linearElement: NonDeleted<ExcalidrawLinearElement>,
+ /** scene coords */
+ pointerCoords: {
+ x: number;
+ y: number;
+ }[],
+ // During line creation the start binding hasn't been written yet
+ // into `linearElement`
+ oppositeBindingBoundElement?: ExcalidrawBindableElement | null,
+ ): void => {
+ if (!pointerCoords.length) {
+ return;
+ }
+
+ const suggestedBindings = pointerCoords.reduce(
+ (acc: NonDeleted<ExcalidrawBindableElement>[], coords) => {
+ const hoveredBindableElement = getHoveredElementForBinding(
+ coords,
+ this.scene.getNonDeletedElements(),
+ this.scene.getNonDeletedElementsMap(),
+ this.state.zoom,
+ isElbowArrow(linearElement),
+ isElbowArrow(linearElement),
+ );
+ if (
+ hoveredBindableElement != null &&
+ !isLinearElementSimpleAndAlreadyBound(
+ linearElement,
+ oppositeBindingBoundElement?.id,
+ hoveredBindableElement,
+ )
+ ) {
+ acc.push(hoveredBindableElement);
+ }
+ return acc;
+ },
+ [],
+ );
+
+ this.setState({ suggestedBindings });
+ };
+
+ private clearSelection(hitElement: ExcalidrawElement | null): void {
+ this.setState((prevState) => ({
+ selectedElementIds: makeNextSelectedElementIds({}, prevState),
+ activeEmbeddable: null,
+ selectedGroupIds: {},
+ // Continue editing the same group if the user selected a different
+ // element from it
+ editingGroupId:
+ prevState.editingGroupId &&
+ hitElement != null &&
+ isElementInGroup(hitElement, prevState.editingGroupId)
+ ? prevState.editingGroupId
+ : null,
+ }));
+ this.setState({
+ selectedElementIds: makeNextSelectedElementIds({}, this.state),
+ activeEmbeddable: null,
+ previousSelectedElementIds: this.state.selectedElementIds,
+ });
+ }
+
+ private handleInteractiveCanvasRef = (canvas: HTMLCanvasElement | null) => {
+ // canvas is null when unmounting
+ if (canvas !== null) {
+ this.interactiveCanvas = canvas;
+
+ // -----------------------------------------------------------------------
+ // NOTE wheel, touchstart, touchend events must be registered outside
+ // of react because react binds them them passively (so we can't prevent
+ // default on them)
+ this.interactiveCanvas.addEventListener(
+ EVENT.TOUCH_START,
+ this.onTouchStart,
+ { passive: false },
+ );
+ this.interactiveCanvas.addEventListener(EVENT.TOUCH_END, this.onTouchEnd);
+ // -----------------------------------------------------------------------
+ } else {
+ this.interactiveCanvas?.removeEventListener(
+ EVENT.TOUCH_START,
+ this.onTouchStart,
+ );
+ this.interactiveCanvas?.removeEventListener(
+ EVENT.TOUCH_END,
+ this.onTouchEnd,
+ );
+ }
+ };
+
+ private handleAppOnDrop = async (event: React.DragEvent<HTMLDivElement>) => {
+ // must be retrieved first, in the same frame
+ const { file, fileHandle } = await getFileFromEvent(event);
+ const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
+ event,
+ this.state,
+ );
+
+ try {
+ // if image tool not supported, don't show an error here and let it fall
+ // through so we still support importing scene data from images. If no
+ // scene data encoded, we'll show an error then
+ if (isSupportedImageFile(file) && this.isToolSupported("image")) {
+ // first attempt to decode scene from the image if it's embedded
+ // ---------------------------------------------------------------------
+
+ if (file?.type === MIME_TYPES.png || file?.type === MIME_TYPES.svg) {
+ try {
+ const scene = await loadFromBlob(
+ file,
+ this.state,
+ this.scene.getElementsIncludingDeleted(),
+ fileHandle,
+ );
+ this.syncActionResult({
+ ...scene,
+ appState: {
+ ...(scene.appState || this.state),
+ isLoading: false,
+ },
+ replaceFiles: true,
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ });
+ return;
+ } catch (error: any) {
+ // Don't throw for image scene daa
+ if (error.name !== "EncodingError") {
+ throw new Error(t("alerts.couldNotLoadInvalidFile"));
+ }
+ }
+ }
+
+ // if no scene is embedded or we fail for whatever reason, fall back
+ // to importing as regular image
+ // ---------------------------------------------------------------------
+
+ const imageElement = this.createImageElement({ sceneX, sceneY });
+ this.insertImageElement(imageElement, file);
+ this.initializeImageDimensions(imageElement);
+ this.setState({
+ selectedElementIds: makeNextSelectedElementIds(
+ { [imageElement.id]: true },
+ this.state,
+ ),
+ });
+
+ return;
+ }
+ } catch (error: any) {
+ return this.setState({
+ isLoading: false,
+ errorMessage: error.message,
+ });
+ }
+
+ const libraryJSON = event.dataTransfer.getData(MIME_TYPES.excalidrawlib);
+ if (libraryJSON && typeof libraryJSON === "string") {
+ try {
+ const libraryItems = parseLibraryJSON(libraryJSON);
+ this.addElementsFromPasteOrLibrary({
+ elements: distributeLibraryItemsOnSquareGrid(libraryItems),
+ position: event,
+ files: null,
+ });
+ } catch (error: any) {
+ this.setState({ errorMessage: error.message });
+ }
+ return;
+ }
+
+ if (file) {
+ // Attempt to parse an excalidraw/excalidrawlib file
+ await this.loadFileToCanvas(file, fileHandle);
+ }
+
+ if (event.dataTransfer?.types?.includes("text/plain")) {
+ const text = event.dataTransfer?.getData("text");
+ if (
+ text &&
+ embeddableURLValidator(text, this.props.validateEmbeddable) &&
+ (/^(http|https):\/\/[^\s/$.?#].[^\s]*$/.test(text) ||
+ getEmbedLink(text)?.type === "video")
+ ) {
+ const embeddable = this.insertEmbeddableElement({
+ sceneX,
+ sceneY,
+ link: normalizeLink(text),
+ });
+ if (embeddable) {
+ this.setState({ selectedElementIds: { [embeddable.id]: true } });
+ }
+ }
+ }
+ };
+
+ loadFileToCanvas = async (
+ file: File,
+ fileHandle: FileSystemHandle | null,
+ ) => {
+ file = await normalizeFile(file);
+ try {
+ const elements = this.scene.getElementsIncludingDeleted();
+ let ret;
+ try {
+ ret = await loadSceneOrLibraryFromBlob(
+ file,
+ this.state,
+ elements,
+ fileHandle,
+ );
+ } catch (error: any) {
+ const imageSceneDataError = error instanceof ImageSceneDataError;
+ if (
+ imageSceneDataError &&
+ error.code === "IMAGE_NOT_CONTAINS_SCENE_DATA" &&
+ !this.isToolSupported("image")
+ ) {
+ this.setState({
+ isLoading: false,
+ errorMessage: t("errors.imageToolNotSupported"),
+ });
+ return;
+ }
+ const errorMessage = imageSceneDataError
+ ? t("alerts.cannotRestoreFromImage")
+ : t("alerts.couldNotLoadInvalidFile");
+ this.setState({
+ isLoading: false,
+ errorMessage,
+ });
+ }
+ if (!ret) {
+ return;
+ }
+
+ if (ret.type === MIME_TYPES.excalidraw) {
+ // restore the fractional indices by mutating elements
+ syncInvalidIndices(elements.concat(ret.data.elements));
+
+ // update the store snapshot for old elements, otherwise we would end up with duplicated fractional indices on undo
+ this.store.updateSnapshot(arrayToMap(elements), this.state);
+
+ this.setState({ isLoading: true });
+ this.syncActionResult({
+ ...ret.data,
+ appState: {
+ ...(ret.data.appState || this.state),
+ isLoading: false,
+ },
+ replaceFiles: true,
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ });
+ } else if (ret.type === MIME_TYPES.excalidrawlib) {
+ await this.library
+ .updateLibrary({
+ libraryItems: file,
+ merge: true,
+ openLibraryMenu: true,
+ })
+ .catch((error) => {
+ console.error(error);
+ this.setState({ errorMessage: t("errors.importLibraryError") });
+ });
+ }
+ } catch (error: any) {
+ this.setState({ isLoading: false, errorMessage: error.message });
+ }
+ };
+
+ private handleCanvasContextMenu = (
+ event: React.MouseEvent<HTMLElement | HTMLCanvasElement>,
+ ) => {
+ event.preventDefault();
+
+ if (
+ (("pointerType" in event.nativeEvent &&
+ event.nativeEvent.pointerType === "touch") ||
+ ("pointerType" in event.nativeEvent &&
+ event.nativeEvent.pointerType === "pen" &&
+ // always allow if user uses a pen secondary button
+ event.button !== POINTER_BUTTON.SECONDARY)) &&
+ this.state.activeTool.type !== "selection"
+ ) {
+ return;
+ }
+
+ const { x, y } = viewportCoordsToSceneCoords(event, this.state);
+ const element = this.getElementAtPosition(x, y, {
+ preferSelected: true,
+ includeLockedElements: true,
+ });
+
+ const selectedElements = this.scene.getSelectedElements(this.state);
+ const isHittingCommonBoundBox =
+ this.isHittingCommonBoundingBoxOfSelectedElements(
+ { x, y },
+ selectedElements,
+ );
+
+ const type = element || isHittingCommonBoundBox ? "element" : "canvas";
+
+ const container = this.excalidrawContainerRef.current!;
+ const { top: offsetTop, left: offsetLeft } =
+ container.getBoundingClientRect();
+ const left = event.clientX - offsetLeft;
+ const top = event.clientY - offsetTop;
+
+ trackEvent("contextMenu", "openContextMenu", type);
+
+ this.setState(
+ {
+ ...(element && !this.state.selectedElementIds[element.id]
+ ? {
+ ...this.state,
+ ...selectGroupsForSelectedElements(
+ {
+ editingGroupId: this.state.editingGroupId,
+ selectedElementIds: { [element.id]: true },
+ },
+ this.scene.getNonDeletedElements(),
+ this.state,
+ this,
+ ),
+ selectedLinearElement: isLinearElement(element)
+ ? new LinearElementEditor(element)
+ : null,
+ }
+ : this.state),
+ showHyperlinkPopup: false,
+ },
+ () => {
+ this.setState({
+ contextMenu: { top, left, items: this.getContextMenuItems(type) },
+ });
+ },
+ );
+ };
+
+ private maybeDragNewGenericElement = (
+ pointerDownState: PointerDownState,
+ event: MouseEvent | KeyboardEvent,
+ informMutation = true,
+ ): void => {
+ const selectionElement = this.state.selectionElement;
+ const pointerCoords = pointerDownState.lastCoords;
+ if (selectionElement && this.state.activeTool.type !== "eraser") {
+ dragNewElement({
+ newElement: selectionElement,
+ elementType: this.state.activeTool.type,
+ originX: pointerDownState.origin.x,
+ originY: pointerDownState.origin.y,
+ x: pointerCoords.x,
+ y: pointerCoords.y,
+ width: distance(pointerDownState.origin.x, pointerCoords.x),
+ height: distance(pointerDownState.origin.y, pointerCoords.y),
+ shouldMaintainAspectRatio: shouldMaintainAspectRatio(event),
+ shouldResizeFromCenter: shouldResizeFromCenter(event),
+ zoom: this.state.zoom.value,
+ informMutation,
+ });
+ return;
+ }
+
+ const newElement = this.state.newElement;
+ if (!newElement) {
+ return;
+ }
+
+ let [gridX, gridY] = getGridPoint(
+ pointerCoords.x,
+ pointerCoords.y,
+ event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
+ );
+
+ const image =
+ isInitializedImageElement(newElement) &&
+ this.imageCache.get(newElement.fileId)?.image;
+ const aspectRatio =
+ image && !(image instanceof Promise) ? image.width / image.height : null;
+
+ this.maybeCacheReferenceSnapPoints(event, [newElement]);
+
+ const { snapOffset, snapLines } = snapNewElement(
+ newElement,
+ this,
+ event,
+ {
+ x:
+ pointerDownState.originInGrid.x +
+ (this.state.originSnapOffset?.x ?? 0),
+ y:
+ pointerDownState.originInGrid.y +
+ (this.state.originSnapOffset?.y ?? 0),
+ },
+ {
+ x: gridX - pointerDownState.originInGrid.x,
+ y: gridY - pointerDownState.originInGrid.y,
+ },
+ this.scene.getNonDeletedElementsMap(),
+ );
+
+ gridX += snapOffset.x;
+ gridY += snapOffset.y;
+
+ this.setState({
+ snapLines,
+ });
+
+ dragNewElement({
+ newElement,
+ elementType: this.state.activeTool.type,
+ originX: pointerDownState.originInGrid.x,
+ originY: pointerDownState.originInGrid.y,
+ x: gridX,
+ y: gridY,
+ width: distance(pointerDownState.originInGrid.x, gridX),
+ height: distance(pointerDownState.originInGrid.y, gridY),
+ shouldMaintainAspectRatio: isImageElement(newElement)
+ ? !shouldMaintainAspectRatio(event)
+ : shouldMaintainAspectRatio(event),
+ shouldResizeFromCenter: shouldResizeFromCenter(event),
+ zoom: this.state.zoom.value,
+ widthAspectRatio: aspectRatio,
+ originOffset: this.state.originSnapOffset,
+ informMutation,
+ });
+
+ this.setState({
+ newElement,
+ });
+
+ // highlight elements that are to be added to frames on frames creation
+ if (
+ this.state.activeTool.type === TOOL_TYPE.frame ||
+ this.state.activeTool.type === TOOL_TYPE.magicframe
+ ) {
+ this.setState({
+ elementsToHighlight: getElementsInResizingFrame(
+ this.scene.getNonDeletedElements(),
+ newElement as ExcalidrawFrameLikeElement,
+ this.state,
+ this.scene.getNonDeletedElementsMap(),
+ ),
+ });
+ }
+ };
+
+ private maybeHandleCrop = (
+ pointerDownState: PointerDownState,
+ event: MouseEvent | KeyboardEvent,
+ ): boolean => {
+ // to crop, we must already be in the cropping mode, where croppingElement has been set
+ if (!this.state.croppingElementId) {
+ return false;
+ }
+
+ const transformHandleType = pointerDownState.resize.handleType;
+ const pointerCoords = pointerDownState.lastCoords;
+ const [x, y] = getGridPoint(
+ pointerCoords.x - pointerDownState.resize.offset.x,
+ pointerCoords.y - pointerDownState.resize.offset.y,
+ event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
+ );
+
+ const croppingElement = this.scene
+ .getNonDeletedElementsMap()
+ .get(this.state.croppingElementId);
+
+ if (
+ transformHandleType &&
+ croppingElement &&
+ isImageElement(croppingElement)
+ ) {
+ const croppingAtStateStart = pointerDownState.originalElements.get(
+ croppingElement.id,
+ );
+
+ const image =
+ isInitializedImageElement(croppingElement) &&
+ this.imageCache.get(croppingElement.fileId)?.image;
+
+ if (
+ croppingAtStateStart &&
+ isImageElement(croppingAtStateStart) &&
+ image &&
+ !(image instanceof Promise)
+ ) {
+ const [gridX, gridY] = getGridPoint(
+ pointerCoords.x,
+ pointerCoords.y,
+ event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
+ );
+
+ const dragOffset = {
+ x: gridX - pointerDownState.originInGrid.x,
+ y: gridY - pointerDownState.originInGrid.y,
+ };
+
+ this.maybeCacheReferenceSnapPoints(event, [croppingElement]);
+
+ const { snapOffset, snapLines } = snapResizingElements(
+ [croppingElement],
+ [croppingAtStateStart],
+ this,
+ event,
+ dragOffset,
+ transformHandleType,
+ );
+
+ mutateElement(
+ croppingElement,
+ cropElement(
+ croppingElement,
+ transformHandleType,
+ image.naturalWidth,
+ image.naturalHeight,
+ x + snapOffset.x,
+ y + snapOffset.y,
+ event.shiftKey
+ ? croppingAtStateStart.width / croppingAtStateStart.height
+ : undefined,
+ ),
+ );
+
+ updateBoundElements(
+ croppingElement,
+ this.scene.getNonDeletedElementsMap(),
+ {
+ newSize: {
+ width: croppingElement.width,
+ height: croppingElement.height,
+ },
+ },
+ );
+
+ this.setState({
+ isCropping: transformHandleType && transformHandleType !== "rotation",
+ snapLines,
+ });
+ }
+
+ return true;
+ }
+
+ return false;
+ };
+
+ private maybeHandleResize = (
+ pointerDownState: PointerDownState,
+ event: MouseEvent | KeyboardEvent,
+ ): boolean => {
+ const selectedElements = this.scene.getSelectedElements(this.state);
+ const selectedFrames = selectedElements.filter(
+ (element): element is ExcalidrawFrameLikeElement =>
+ isFrameLikeElement(element),
+ );
+
+ const transformHandleType = pointerDownState.resize.handleType;
+
+ if (
+ // Frames cannot be rotated.
+ (selectedFrames.length > 0 && transformHandleType === "rotation") ||
+ // Elbow arrows cannot be transformed (resized or rotated).
+ (selectedElements.length === 1 && isElbowArrow(selectedElements[0])) ||
+ // Do not resize when in crop mode
+ this.state.croppingElementId
+ ) {
+ return false;
+ }
+
+ this.setState({
+ // TODO: rename this state field to "isScaling" to distinguish
+ // it from the generic "isResizing" which includes scaling and
+ // rotating
+ isResizing: transformHandleType && transformHandleType !== "rotation",
+ isRotating: transformHandleType === "rotation",
+ activeEmbeddable: null,
+ });
+ const pointerCoords = pointerDownState.lastCoords;
+ let [resizeX, resizeY] = getGridPoint(
+ pointerCoords.x - pointerDownState.resize.offset.x,
+ pointerCoords.y - pointerDownState.resize.offset.y,
+ event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
+ );
+
+ const frameElementsOffsetsMap = new Map<
+ string,
+ {
+ x: number;
+ y: number;
+ }
+ >();
+
+ selectedFrames.forEach((frame) => {
+ const elementsInFrame = getFrameChildren(
+ this.scene.getNonDeletedElements(),
+ frame.id,
+ );
+
+ elementsInFrame.forEach((element) => {
+ frameElementsOffsetsMap.set(frame.id + element.id, {
+ x: element.x - frame.x,
+ y: element.y - frame.y,
+ });
+ });
+ });
+
+ // check needed for avoiding flickering when a key gets pressed
+ // during dragging
+ if (!this.state.selectedElementsAreBeingDragged) {
+ const [gridX, gridY] = getGridPoint(
+ pointerCoords.x,
+ pointerCoords.y,
+ event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(),
+ );
+
+ const dragOffset = {
+ x: gridX - pointerDownState.originInGrid.x,
+ y: gridY - pointerDownState.originInGrid.y,
+ };
+
+ const originalElements = [...pointerDownState.originalElements.values()];
+
+ this.maybeCacheReferenceSnapPoints(event, selectedElements);
+
+ const { snapOffset, snapLines } = snapResizingElements(
+ selectedElements,
+ getSelectedElements(originalElements, this.state),
+ this,
+ event,
+ dragOffset,
+ transformHandleType,
+ );
+
+ resizeX += snapOffset.x;
+ resizeY += snapOffset.y;
+
+ this.setState({
+ snapLines,
+ });
+ }
+
+ if (
+ transformElements(
+ pointerDownState.originalElements,
+ transformHandleType,
+ selectedElements,
+ this.scene.getElementsMapIncludingDeleted(),
+ this.scene,
+ shouldRotateWithDiscreteAngle(event),
+ shouldResizeFromCenter(event),
+ selectedElements.some((element) => isImageElement(element))
+ ? !shouldMaintainAspectRatio(event)
+ : shouldMaintainAspectRatio(event),
+ resizeX,
+ resizeY,
+ pointerDownState.resize.center.x,
+ pointerDownState.resize.center.y,
+ )
+ ) {
+ const suggestedBindings = getSuggestedBindingsForArrows(
+ selectedElements,
+ this.scene.getNonDeletedElementsMap(),
+ this.state.zoom,
+ );
+
+ const elementsToHighlight = new Set<ExcalidrawElement>();
+ selectedFrames.forEach((frame) => {
+ getElementsInResizingFrame(
+ this.scene.getNonDeletedElements(),
+ frame,
+ this.state,
+ this.scene.getNonDeletedElementsMap(),
+ ).forEach((element) => elementsToHighlight.add(element));
+ });
+
+ this.setState({
+ elementsToHighlight: [...elementsToHighlight],
+ suggestedBindings,
+ });
+
+ return true;
+ }
+ return false;
+ };
+
+ private getContextMenuItems = (
+ type: "canvas" | "element",
+ ): ContextMenuItems => {
+ const options: ContextMenuItems = [];
+
+ options.push(actionCopyAsPng, actionCopyAsSvg);
+
+ // canvas contextMenu
+ // -------------------------------------------------------------------------
+
+ if (type === "canvas") {
+ if (this.state.viewModeEnabled) {
+ return [
+ ...options,
+ actionToggleGridMode,
+ actionToggleZenMode,
+ actionToggleViewMode,
+ actionToggleStats,
+ ];
+ }
+
+ return [
+ actionPaste,
+ CONTEXT_MENU_SEPARATOR,
+ actionCopyAsPng,
+ actionCopyAsSvg,
+ copyText,
+ CONTEXT_MENU_SEPARATOR,
+ actionSelectAll,
+ actionUnlockAllElements,
+ CONTEXT_MENU_SEPARATOR,
+ actionToggleGridMode,
+ actionToggleObjectsSnapMode,
+ actionToggleZenMode,
+ actionToggleViewMode,
+ actionToggleStats,
+ ];
+ }
+
+ // element contextMenu
+ // -------------------------------------------------------------------------
+
+ options.push(copyText);
+
+ if (this.state.viewModeEnabled) {
+ return [actionCopy, ...options];
+ }
+
+ return [
+ CONTEXT_MENU_SEPARATOR,
+ actionCut,
+ actionCopy,
+ actionPaste,
+ CONTEXT_MENU_SEPARATOR,
+ actionSelectAllElementsInFrame,
+ actionRemoveAllElementsFromFrame,
+ actionWrapSelectionInFrame,
+ CONTEXT_MENU_SEPARATOR,
+ actionToggleCropEditor,
+ CONTEXT_MENU_SEPARATOR,
+ ...options,
+ CONTEXT_MENU_SEPARATOR,
+ actionCopyStyles,
+ actionPasteStyles,
+ CONTEXT_MENU_SEPARATOR,
+ actionGroup,
+ actionTextAutoResize,
+ actionUnbindText,
+ actionBindText,
+ actionWrapTextInContainer,
+ actionUngroup,
+ CONTEXT_MENU_SEPARATOR,
+ actionAddToLibrary,
+ CONTEXT_MENU_SEPARATOR,
+ actionSendBackward,
+ actionBringForward,
+ actionSendToBack,
+ actionBringToFront,
+ CONTEXT_MENU_SEPARATOR,
+ actionFlipHorizontal,
+ actionFlipVertical,
+ CONTEXT_MENU_SEPARATOR,
+ actionToggleLinearEditor,
+ CONTEXT_MENU_SEPARATOR,
+ actionLink,
+ actionCopyElementLink,
+ CONTEXT_MENU_SEPARATOR,
+ actionDuplicateSelection,
+ actionToggleElementLock,
+ CONTEXT_MENU_SEPARATOR,
+ actionDeleteSelected,
+ ];
+ };
+
+ private handleWheel = withBatchedUpdates(
+ (
+ event: WheelEvent | React.WheelEvent<HTMLDivElement | HTMLCanvasElement>,
+ ) => {
+ // if not scrolling on canvas/wysiwyg, ignore
+ if (
+ !(
+ event.target instanceof HTMLCanvasElement ||
+ event.target instanceof HTMLTextAreaElement ||
+ event.target instanceof HTMLIFrameElement
+ )
+ ) {
+ // prevent zooming the browser (but allow scrolling DOM)
+ if (event[KEYS.CTRL_OR_CMD]) {
+ event.preventDefault();
+ }
+
+ return;
+ }
+
+ event.preventDefault();
+
+ if (isPanning) {
+ return;
+ }
+
+ const { deltaX, deltaY } = event;
+ // note that event.ctrlKey is necessary to handle pinch zooming
+ if (event.metaKey || event.ctrlKey) {
+ const sign = Math.sign(deltaY);
+ const MAX_STEP = ZOOM_STEP * 100;
+ const absDelta = Math.abs(deltaY);
+ let delta = deltaY;
+ if (absDelta > MAX_STEP) {
+ delta = MAX_STEP * sign;
+ }
+
+ let newZoom = this.state.zoom.value - delta / 100;
+ // increase zoom steps the more zoomed-in we are (applies to >100% only)
+ newZoom +=
+ Math.log10(Math.max(1, this.state.zoom.value)) *
+ -sign *
+ // reduced amplification for small deltas (small movements on a trackpad)
+ Math.min(1, absDelta / 20);
+
+ this.translateCanvas((state) => ({
+ ...getStateForZoom(
+ {
+ viewportX: this.lastViewportPosition.x,
+ viewportY: this.lastViewportPosition.y,
+ nextZoom: getNormalizedZoom(newZoom),
+ },
+ state,
+ ),
+ shouldCacheIgnoreZoom: true,
+ }));
+ this.resetShouldCacheIgnoreZoomDebounced();
+ return;
+ }
+
+ // scroll horizontally when shift pressed
+ if (event.shiftKey) {
+ this.translateCanvas(({ zoom, scrollX }) => ({
+ // on Mac, shift+wheel tends to result in deltaX
+ scrollX: scrollX - (deltaY || deltaX) / zoom.value,
+ }));
+ return;
+ }
+
+ this.translateCanvas(({ zoom, scrollX, scrollY }) => ({
+ scrollX: scrollX - deltaX / zoom.value,
+ scrollY: scrollY - deltaY / zoom.value,
+ }));
+ },
+ );
+
+ private getTextWysiwygSnappedToCenterPosition(
+ x: number,
+ y: number,
+ appState: AppState,
+ container?: ExcalidrawTextContainer | null,
+ ) {
+ if (container) {
+ let elementCenterX = container.x + container.width / 2;
+ let elementCenterY = container.y + container.height / 2;
+
+ const elementCenter = getContainerCenter(
+ container,
+ appState,
+ this.scene.getNonDeletedElementsMap(),
+ );
+ if (elementCenter) {
+ elementCenterX = elementCenter.x;
+ elementCenterY = elementCenter.y;
+ }
+ const distanceToCenter = Math.hypot(
+ x - elementCenterX,
+ y - elementCenterY,
+ );
+ const isSnappedToCenter =
+ distanceToCenter < TEXT_TO_CENTER_SNAP_THRESHOLD;
+ if (isSnappedToCenter) {
+ const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords(
+ { sceneX: elementCenterX, sceneY: elementCenterY },
+ appState,
+ );
+ return { viewportX, viewportY, elementCenterX, elementCenterY };
+ }
+ }
+ }
+
+ private savePointer = (x: number, y: number, button: "up" | "down") => {
+ if (!x || !y) {
+ return;
+ }
+ const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
+ { clientX: x, clientY: y },
+ this.state,
+ );
+
+ if (isNaN(sceneX) || isNaN(sceneY)) {
+ // sometimes the pointer goes off screen
+ }
+
+ const pointer: CollaboratorPointer = {
+ x: sceneX,
+ y: sceneY,
+ tool: this.state.activeTool.type === "laser" ? "laser" : "pointer",
+ };
+
+ this.props.onPointerUpdate?.({
+ pointer,
+ button,
+ pointersMap: gesture.pointers,
+ });
+ };
+
+ private resetShouldCacheIgnoreZoomDebounced = debounce(() => {
+ if (!this.unmounted) {
+ this.setState({ shouldCacheIgnoreZoom: false });
+ }
+ }, 300);
+
+ private updateDOMRect = (cb?: () => void) => {
+ if (this.excalidrawContainerRef?.current) {
+ const excalidrawContainer = this.excalidrawContainerRef.current;
+ const {
+ width,
+ height,
+ left: offsetLeft,
+ top: offsetTop,
+ } = excalidrawContainer.getBoundingClientRect();
+ const {
+ width: currentWidth,
+ height: currentHeight,
+ offsetTop: currentOffsetTop,
+ offsetLeft: currentOffsetLeft,
+ } = this.state;
+
+ if (
+ width === currentWidth &&
+ height === currentHeight &&
+ offsetLeft === currentOffsetLeft &&
+ offsetTop === currentOffsetTop
+ ) {
+ if (cb) {
+ cb();
+ }
+ return;
+ }
+
+ this.setState(
+ {
+ width,
+ height,
+ offsetLeft,
+ offsetTop,
+ },
+ () => {
+ cb && cb();
+ },
+ );
+ }
+ };
+
+ public refresh = () => {
+ this.setState({ ...this.getCanvasOffsets() });
+ };
+
+ private getCanvasOffsets(): Pick<AppState, "offsetTop" | "offsetLeft"> {
+ if (this.excalidrawContainerRef?.current) {
+ const excalidrawContainer = this.excalidrawContainerRef.current;
+ const { left, top } = excalidrawContainer.getBoundingClientRect();
+ return {
+ offsetLeft: left,
+ offsetTop: top,
+ };
+ }
+ return {
+ offsetLeft: 0,
+ offsetTop: 0,
+ };
+ }
+
+ private async updateLanguage() {
+ const currentLang =
+ languages.find((lang) => lang.code === this.props.langCode) ||
+ defaultLang;
+ await setLanguage(currentLang);
+ this.setAppState({});
+ }
+}
+
+// -----------------------------------------------------------------------------
+// TEST HOOKS
+// -----------------------------------------------------------------------------
+declare global {
+ interface Window {
+ h: {
+ scene: Scene;
+ elements: readonly ExcalidrawElement[];
+ state: AppState;
+ setState: React.Component<any, AppState>["setState"];
+ app: InstanceType<typeof App>;
+ history: History;
+ store: Store;
+ };
+ }
+}
+
+export const createTestHook = () => {
+ if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) {
+ window.h = window.h || ({} as Window["h"]);
+
+ Object.defineProperties(window.h, {
+ elements: {
+ configurable: true,
+ get() {
+ return this.app?.scene.getElementsIncludingDeleted();
+ },
+ set(elements: ExcalidrawElement[]) {
+ return this.app?.scene.replaceAllElements(
+ syncInvalidIndices(elements),
+ );
+ },
+ },
+ scene: {
+ configurable: true,
+ get() {
+ return this.app?.scene;
+ },
+ },
+ });
+ }
+};
+
+createTestHook();
+export default App;