import clsx from "clsx"; import throttle from "lodash.throttle"; import React, { useContext } from "react"; import { flushSync } from "react-dom"; import rough from "roughjs/bin/rough"; import { nanoid } from "nanoid"; import { clamp, pointFrom, pointDistance, vector, pointRotateRads, vectorFromPoint, vectorSubtract, vectorDot, vectorNormalize, } from "@excalidraw/math"; import { COLOR_PALETTE, CODES, shouldResizeFromCenter, shouldMaintainAspectRatio, shouldRotateWithDiscreteAngle, isArrowKey, KEYS, APP_NAME, CURSOR_TYPE, DEFAULT_STROKE_STREAMLINE, DEFAULT_STROKE_STREAMLINE_PRECISE, DEFAULT_TRANSFORM_HANDLE_SPACING, DEFAULT_VERTICAL_ALIGN, DRAGGING_THRESHOLD, ELEMENT_SHIFT_TRANSLATE_AMOUNT, ELEMENT_TRANSLATE_AMOUNT, EVENT, FRAME_STYLE, IMAGE_MIME_TYPES, IMAGE_RENDER_TIMEOUT, LINE_CONFIRM_THRESHOLD, MIME_TYPES, MQ_RIGHT_SIDEBAR_MIN_WIDTH, POINTER_BUTTON, ROUNDNESS, SCROLL_TIMEOUT, TAP_TWICE_TIMEOUT, TEXT_TO_CENTER_SNAP_THRESHOLD, THEME, TOUCH_CTX_MENU_TIMEOUT, VERTICAL_ALIGN, YOUTUBE_STATES, POINTER_EVENTS, TOOL_TYPE, DEFAULT_COLLISION_THRESHOLD, DEFAULT_TEXT_ALIGN, ARROW_TYPE, DEFAULT_REDUCED_GLOBAL_ALPHA, DEFAULT_STICKY_NOTE_SIZE, isLocalLink, normalizeLink, toValidURL, getGridPoint, getLineHeight, debounce, distance, getFontString, getNearestScrollableContainer, isInputLike, isToolIcon, isWritableElement, sceneCoordsToViewportCoords, tupleToCoors, viewportCoordsToSceneCoords, wrapEvent, updateObject, updateActiveTool, isTransparent, muteFSAbortError, isTestEnv, isDevEnv, updateStable, addEventListener, normalizeEOL, getDateTime, isShallowEqual, arrayToMap, applyDarkModeFilter, AppEventBus, type EXPORT_IMAGE_TYPES, randomInteger, CLASSES, Emitter, MINIMUM_ARROW_SIZE, DOUBLE_TAP_POSITION_THRESHOLD, BIND_MODE_TIMEOUT, invariant, getFeatureFlag, createUserAgentDescriptor, getFormFactor, deriveStylesPanelMode, isIOS, isBrave, isSafari, type EditorInterface, type StylesPanelMode, loadDesktopUIModePreference, setDesktopUIMode, isSelectionLikeTool, oneOf, getStrokeWidthByKey, } from "@excalidraw/common"; import { getObservedAppState, getCommonBounds, getElementAbsoluteCoords, bindOrUnbindBindingElements, fixBindingsAfterDeletion, getHoveredElementForBinding, isBindingEnabled, updateBoundElements, LinearElementEditor, newElementWith, newFrameElement, newFreeDrawElement, newEmbeddableElement, newMagicFrameElement, newStickyNoteElement, newIframeElement, newArrowElement, newElement, newImageElement, newLinearElement, newTextElement, refreshTextDimensions, deepCopyElement, duplicateElements, hasBoundTextElement, isArrowElement, isBindingElement, isBindingElementType, isBoundToContainer, isFrameLikeElement, isImageElement, isEmbeddableElement, isInitializedImageElement, isLinearElement, isLinearElementType, isUsingAdaptiveRadius, isIframeElement, isIframeLikeElement, isMagicFrameElement, isTextBindableContainer, isElbowArrow, isBindableElement, isTextElement, isStickyNoteElement, getNormalizedDimensions, isElementCompletelyInViewport, isElementInViewport, isInvisiblySmallElement, getCornerRadius, isPathALoop, createSrcDoc, embeddableURLValidator, maybeParseEmbedSrc, getEmbedLink, getInitializedImageElements, normalizeSVG, updateImageCache as _updateImageCache, getBoundTextElement, getContainerCenter, getContainerElement, getColorUpdate, getStickyNoteLayout, getStickyNoteMinSize, isValidTextContainer, redrawTextBoundingBox, hasBoundingBox, getCommonFrameId, getFrameChildren, getFrameChildrenInsertionIndex, isCursorInFrame, addElementsToFrame, replaceAllElementsInFrame, removeElementsFromFrame, getElementsInResizingFrame, getElementsInNewFrame, getContainingFrame, elementOverlapsWithFrame, updateFrameMembershipOfSelectedElements, isElementInFrame, getFrameLikeTitle, getElementsOverlappingFrame, filterElementsEligibleAsFrameChildren, hitElementBoundText, hitElementBoundingBoxOnly, hitElementItself, getVisibleSceneBounds, cropElement, wrapText, isElementLink, isMeasureTextSupported, normalizeText, measureText, getLineHeightInPx, getApproxMinLineWidth, getApproxMinLineHeight, getMinTextElementWidth, ShapeCache, resolveElementRenderState, getRenderElementWithPositionOverride, editGroupForSelectedElement, getElementsInGroup, getSelectedGroupIdForElement, getSelectedGroupIds, isElementInGroup, isSelectedViaGroup, selectGroupsForSelectedElements, syncInvalidIndices, syncMovedIndices, excludeElementsInFramesFromSelection, getSelectionStateForElements, makeNextSelectedElementIds, getResizeOffsetXY, getResizeArrowDirection, transformElements, getCursorForResizingElement, getElementWithTransformHandleType, getTransformHandleTypeFromCoords, dragNewElement, dragSelectedElements, getDragOffsetXY, Scene, Store, CaptureUpdateAction, type ElementUpdate, hitElementBoundingBox, isLineElement, isSimpleArrow, StoreDelta, type ApplyToOptions, positionElementsOnGrid, calculateFixedPointForNonElbowArrowBinding, bindOrUnbindBindingElement, mutateElement, getElementBounds, doBoundsIntersect, isPointInElement, maxBindingDistance_simple, convertToExcalidrawElements, type ExcalidrawElementSkeleton, getSnapOutlineMidPoint, handleFocusPointDrag, handleFocusPointHover, handleFocusPointPointerDown, handleFocusPointPointerUp, maybeHandleArrowPointlikeDrag, getUncroppedWidthAndHeight, getActiveTextElement, isEligibleFrameChildType, getBindingStrategyForDraggingBindingElementEndpoints, isNonDeletedElement, DEFAULT_BOUND_TEXT_LABEL_POSITION, } from "@excalidraw/element"; import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math"; import type { ExcalidrawElement, ExcalidrawFreeDrawElement, ExcalidrawGenericElement, ExcalidrawLinearElement, ExcalidrawTextElement, NonDeleted, InitializedExcalidrawImageElement, ExcalidrawImageElement, FileId, NonDeletedExcalidrawElement, ExcalidrawTextContainer, ExcalidrawFrameLikeElement, ExcalidrawMagicFrameElement, ExcalidrawIframeLikeElement, IframeData, ExcalidrawIframeElement, ExcalidrawEmbeddableElement, Ordered, MagicGenerationData, ExcalidrawArrowElement, ExcalidrawElbowArrowElement, SceneElementsMap, NonDeletedSceneElementsMap, ExcalidrawBindableElement, } from "@excalidraw/element/types"; import type { ArrowEndpoint, TransformHandleDirection, } from "@excalidraw/element"; import type { Mutable, ValueOf } from "@excalidraw/common/utility-types"; 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, actionToggleArrowBinding, actionToggleMidpointSnapping, actionToggleCropEditor, } from "../actions"; import { actionWrapTextInContainer } from "../actions/actionBoundText"; import { actionPaste } from "../actions/actionClipboard"; import { actionCopyElementLink } from "../actions/actionElementLink"; import { actionUnlockAllElements } from "../actions/actionElementLock"; import { actionRemoveAllElementsFromFrame, actionSelectAllElementsInFrame, actionWrapSelectionInFrame, } from "../actions/actionFrame"; import { createRedoAction, createUndoAction } from "../actions/actionHistory"; import { actionTextAutoResize } from "../actions/actionTextAutoResize"; import { actionToggleViewMode } from "../actions/actionToggleViewMode"; import { ActionManager } from "../actions/manager"; import { actions } from "../actions/register"; import { getShortcutFromShortcutName } from "../actions/shortcuts"; import { trackEvent } from "../analytics"; import { getDefaultAppState, isEraserActive, isHandToolActive, } from "../appState"; import { copyTextToSystemClipboard, parseClipboard, parseDataTransferEvent, type ParsedDataTransferFile, } from "../clipboard"; import { exportCanvas, loadFromBlob } from "../data"; import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library"; import { restoreAppState, restoreElements } from "../data/restore"; import { getCenter, getDistance } from "../gesture"; import { copyElementRenderOverrides, getElementRenderOffsets, } from "../renderOverrides"; import { History } from "../history"; import { defaultLang, getLanguage, languages, setLanguage, t } from "../i18n"; import { getScrollToContentState, getElementsWithinSelection, getNormalizedZoom, getSelectedElements, hasBackground, isSomeElementSelected, } from "../scene"; import { dataURLToString, generateIdFromFile, getDataURL, getDataURL_sync, ImageURLToFile, isImageFileHandle, isSupportedImageFile, loadSceneOrLibraryFromBlob, normalizeFile, parseLibraryJSON, resizeImageFile, SVGStringToFile, } from "../data/blob"; import { fileOpen } from "../data/filesystem"; import { showHyperlinkTooltip, hideHyperlinkToolip, Hyperlink, } from "../components/hyperlink/Hyperlink"; import { Fonts } from "../fonts"; import { editorJotaiStore, type WritableAtom } from "../editor-jotai"; import { ImageSceneDataError } from "../errors"; import { getSnapLinesAtPointer, snapDraggedElements, isActiveToolNonLinearSnappable, snapNewElement, snapResizingElements, isSnappingEnabled, getVisibleGaps, getReferenceSnapPoints, SnapCache, isGridModeEnabled, } from "../snapping"; import { Renderer } from "../scene/Renderer"; import { type SetViewportOptions, getViewportForZoomWithScrollConstraints, } from "../viewport"; import { ElementCanvasButtons } from "../components/ElementCanvasButtons"; import { LaserTrails } from "../laserTrails"; import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils"; import { isPointHittingTextAutoResizeHandle } from "../textAutoResizeHandle"; import { textWysiwyg } from "../wysiwyg/textWysiwyg"; import { isOverScrollBars } from "../scene/scrollbars"; import { isMaybeMermaidDefinition } from "../mermaid"; import { LassoTrail } from "../lasso"; import { EraserTrail } from "../eraser"; import { getShortcutKey } from "../shortcut"; import { tryParseSpreadsheet } from "../charts"; import { getColorTargetAppStateUpdates, resolveColorTarget, } from "../actions/colorTargets"; import ConvertElementTypePopup, { getConversionTypeFromElements, convertElementTypePopupAtom, convertElementTypes, } from "./ConvertElementTypePopup"; import { activeConfirmDialogAtom } from "./ActiveConfirmDialog"; import { AppArrowText } from "./App.arrowText"; import { AppBucketFill } from "./App.bucketFill"; import { AppToolDrag, TOOL_DRAG_PREVIEW_OPACITY } from "./App.toolDrag"; import { AppCursor } from "./App.cursor"; import { AppDrawShape } from "./App.drawshape"; import { AppFlowchart } from "./App.flowchart"; import { AppViewport, RIGHT_SIDEBAR_WIDTH } from "./App.viewport"; import { AppWheel } from "./App.wheel"; import BraveMeasureTextError from "./BraveMeasureTextError"; import { ContextMenu, CONTEXT_MENU_SEPARATOR } from "./ContextMenu"; import { activeEyeDropperAtom } from "./EyeDropper"; import { ViewportStatusBorder } from "./ViewportStatusFrame/ViewportStatusFrame"; import LayerUI from "./LayerUI"; import { ElementCanvasButton } from "./MagicButton"; import { SVGLayer } from "./SVGLayer"; import Spinner from "./Spinner"; import { searchItemInFocusAtom } from "./SearchMenu"; import { isSidebarDockedAtom } from "./Sidebar/Sidebar"; import { StaticCanvas, InteractiveCanvas } from "./canvases"; import NewElementCanvas from "./canvases/NewElementCanvas"; import { isPointHittingLink } from "./hyperlink/helpers"; import { CursorHint, CursorHints } from "./CursorHint"; import { MagicIcon, copyIcon, fullscreenIcon } from "./icons"; import { AppStateObserver, type OnStateChange } from "./AppStateObserver"; import { findShapeByKey, TOGGLE_TOOLS } from "./Tools"; import UnlockPopup from "./UnlockPopup"; import type { ExcalidrawLibraryIds } from "../data/types"; import type { RenderInteractiveSceneCallback, ScrollBars, } from "../scene/types"; import type { ClipboardData, PastedMixedContent } from "../clipboard"; import type { ExportedElements } from "../data"; import type { ContextMenuItems } from "./ContextMenu"; import type { AppClassProperties, AppProps, AppState, ElementRenderOffsets, ElementRenderOverrides, BinaryFileData, ExcalidrawImperativeAPI, BinaryFiles, Gesture, GestureEvent, LibraryItems, PointerDownState, SceneData, FrameNameBoundsCache, SidebarName, SidebarTabName, KeyboardModifiersObject, CollaboratorPointer, ToolType, OnUserFollowedPayload, UnsubscribeCallback, EmbedsValidationStatus, ElementsPendingErasure, ExcalidrawImperativeAPIEventMap, GenerateDiagramToCode, NullableGridSize, UIConfig, } from "../types"; import type { RoughCanvas } from "roughjs/bin/canvas"; import type { Action, ActionResult } from "../actions/types"; const AppContext = React.createContext(null!); const AppPropsContext = React.createContext(null!); const editorInterfaceContextInitialValue: EditorInterface = { formFactor: "desktop", desktopUIMode: "full", userAgent: createUserAgentDescriptor( typeof navigator !== "undefined" ? navigator.userAgent : "", ), isTouchScreen: false, canFitSidebar: false, isLandscape: true, }; const EditorInterfaceContext = React.createContext( editorInterfaceContextInitialValue, ); EditorInterfaceContext.displayName = "EditorInterfaceContext"; const editorLifecycleEventBehavior = { "editor:mount": { cardinality: "once", replay: "last" }, "editor:initialize": { cardinality: "once", replay: "last" }, "editor:unmount": { cardinality: "once", replay: "last" }, } as const; 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({ ...getDefaultAppState(), width: 0, height: 0, offsetLeft: 0, offsetTop: 0, }); ExcalidrawAppStateContext.displayName = "ExcalidrawAppStateContext"; const ExcalidrawSetAppStateContext = React.createContext< React.Component["setState"] >(() => { console.warn("Uninitialized ExcalidrawSetAppStateContext context!"); }); ExcalidrawSetAppStateContext.displayName = "ExcalidrawSetAppStateContext"; const ExcalidrawActionManagerContext = React.createContext( null!, ); ExcalidrawActionManagerContext.displayName = "ExcalidrawActionManagerContext"; export const ExcalidrawAPIContext = React.createContext(null); ExcalidrawAPIContext.displayName = "ExcalidrawAPIContext"; export const ExcalidrawAPISetContext = React.createContext< ((api: ExcalidrawImperativeAPI | null) => void) | null >(null); ExcalidrawAPISetContext.displayName = "ExcalidrawAPISetContext"; export const useApp = () => useContext(AppContext); export const useAppProps = () => useContext(AppPropsContext); export const useEditorInterface = () => useContext(EditorInterfaceContext); export const useStylesPanelMode = () => deriveStylesPanelMode(useEditorInterface()); 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); /** * Requires wrapping your component in */ export const useExcalidrawAPI = () => useContext(ExcalidrawAPIContext); let didTapTwice: boolean = false; let tappedTwiceTimer = 0; let firstTapPosition: { x: number; y: number } | null = null; 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 >(); const MAX_EMBEDDABLE_VIEWPORT_SCALE = 4; let IS_PLAIN_PASTE = false; let IS_PLAIN_PASTE_TIMER = 0; let PLAIN_PASTE_TOAST_SHOWN = false; let lastPointerUp: (() => void) | null = null; /** applies the pointer movement the active drag-pan is holding back for its * next frame, if any */ let flushPanMove: (() => void) | null = null; const gesture: Gesture = { pointers: new Map(), lastCenter: null, initialDistance: null, initialScale: null, }; class App extends React.Component { canvas: AppClassProperties["canvas"]; interactiveCanvas: AppClassProperties["interactiveCanvas"] = null; public sessionExportThemeOverride: AppState["theme"] | undefined; rc: RoughCanvas; unmounted: boolean = false; actionManager: ActionManager; editorInterface: EditorInterface = editorInterfaceContextInitialValue; private stylesPanelMode: StylesPanelMode = deriveStylesPanelMode( editorInterfaceContextInitialValue, ); private excalidrawContainerRef = React.createRef(); public get ownerDocument(): Document { return ( this.props.ownerDocument ?? this.excalidrawContainerRef.current?.ownerDocument ?? document ); } public get ownerWindow(): Window & typeof globalThis { return (this.ownerDocument.defaultView ?? window) as Window & typeof globalThis; } public scene: Scene; public fonts: Fonts; public renderer: Renderer; public visibleElements: readonly NonDeletedExcalidrawElement[]; /** whether the last render had any renderable elements (excludes e.g. the * in-progress `newElement` and the edited text element) */ private hasRenderableElements: boolean = false; private resizeObserver: ResizeObserver | undefined; public library: AppClassProperties["library"]; public libraryItemsFromStorage: LibraryItems | undefined; public id: string; public 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(); /** * 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(); private elementsPendingErasure: ElementsPendingErasure = new Set(); private _initialized = false; private readonly editorLifecycleEvents = new AppEventBus< ExcalidrawImperativeAPIEventMap, typeof editorLifecycleEventBehavior >(editorLifecycleEventBehavior); public onEvent = this.editorLifecycleEvents.on.bind( this.editorLifecycleEvents, ) as AppEventBus< ExcalidrawImperativeAPIEventMap, typeof editorLifecycleEventBehavior >["on"]; private appStateObserver = new AppStateObserver(() => this.state); public onStateChange: OnStateChange = this.appStateObserver.onStateChange; public bucketFill: AppBucketFill = new AppBucketFill(this); public toolDrag: AppToolDrag = new AppToolDrag(this); public flowchart: AppFlowchart = new AppFlowchart(this); public cursor: AppCursor = new AppCursor(this); public arrowText: AppArrowText = new AppArrowText(this); public viewport: AppViewport = new AppViewport(this, { getContainer: () => this.excalidrawContainerRef.current, getStylesPanelMode: () => this.stylesPanelMode, isGestureActive: () => gesture.pointers.size >= 2 || isPanning, }); public wheel: AppWheel = new AppWheel(this, { isPanning: () => isPanning, flushPanMove: () => flushPanMove?.(), }); bindModeHandler: ReturnType | null = null; private textWysiwygSubmitHandler: ReturnType | null = null; hitLinkElement?: NonDeletedExcalidrawElement; lastPointerDownEvent: React.PointerEvent | null = null; /** * the handle of the resize in progress while `state.isResizing` — for UI * that words itself by handle (a sticky note's corners resize * proportionally, its edges freely); not app state, so it costs no * re-render of its own */ activeResizeHandle: TransformHandleDirection | null = null; lastPointerUpEvent: React.PointerEvent | PointerEvent | null = null; // TODO this is a hack and we should ideally unify touch and pointer events // and implement our own double click handling end-to-end (currently we're // using a mix of native browser for click events and manual for touch - // and browser doubleClick sucks to begin with) lastPointerUpIsDoubleClick: boolean = false; lastPointerMoveEvent: PointerEvent | null = null; /** current frame pointer cords */ lastPointerMoveCoords: { x: number; y: number } | null = null; private lastCompletedCanvasClicks: { x: number; y: number }[] = []; /** previous frame pointer coords */ previousPointerMoveCoords: { x: number; y: number } | null = null; drawShape = new AppDrawShape(this); laserTrails = new LaserTrails(this); eraserTrail = new EraserTrail(this); lassoTrail = new LassoTrail(this); cursorHints = new CursorHints(this); onChangeEmitter = new Emitter< [ elements: readonly ExcalidrawElement[], appState: AppState, files: BinaryFiles, ] >(); onPointerDownEmitter = new Emitter< [ activeTool: AppState["activeTool"], pointerDownState: PointerDownState, event: React.PointerEvent, ] >(); 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<[]>(); api: ExcalidrawImperativeAPI; private elementRenderOverrides: ElementRenderOverrides = new Map(); /** offsets of `elementRenderOverrides`; keeps its identity while they don't change */ private elementRenderOffsets: ElementRenderOffsets = new Map(); private renderOverridesUpdatePending = false; private getRenderOverrideConfig = () => ({ elementRenderOverrides: this.elementRenderOverrides, }); private getElementRenderState = ( element: ExcalidrawElement, overrides: ElementRenderOverrides | null = this.elementRenderOverrides, ) => resolveElementRenderState(element, this.scene.getNonDeletedElementsMap(), { elementRenderOverrides: overrides ?? undefined, elementsPendingErasure: this.elementsPendingErasure, pendingFlowchartNodes: null, }); private createExcalidrawAPI(): ExcalidrawImperativeAPI { const api: ExcalidrawImperativeAPI = { isDestroyed: false, updateScene: this.updateScene, applyDeltas: this.applyDeltas, mutateElement: this.mutateElement, updateLibrary: this.library.updateLibrary, addFiles: this.addFiles, resetScene: this.resetScene, getSceneElementsIncludingDeleted: this.getSceneElementsIncludingDeleted, getSceneElementsMapIncludingDeleted: this.getSceneElementsMapIncludingDeleted, history: { clear: this.resetHistory, }, setViewport: this.viewport.setViewport, getViewportOffsets: this.viewport.getOffsets, setElementRenderOverrides: this.setElementRenderOverrides, 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.cursor.set, resetCursor: this.cursor.reset, getEditorInterface: () => this.editorInterface, updateFrameRendering: this.updateFrameRendering, toggleSidebar: this.toggleSidebar, onChange: (cb) => this.onChangeEmitter.on(cb), onIncrement: (cb) => this.store.onStoreIncrementEmitter.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), onStateChange: this.onStateChange, onEvent: this.onEvent, }; return api; } constructor(props: AppProps) { super(props); const defaultAppState = getDefaultAppState(); const { viewModeEnabled = false, zenModeEnabled = false, gridModeEnabled = false, objectsSnapModeEnabled = false, theme = defaultAppState.theme, name = `${t("labels.untitled")}-${getDateTime()}`, } = props; // seed the host-forced tool so the first render already has it // (`handleForcedToolChange` keeps it in sync from then on) let forcedActiveTool: AppState["activeTool"] | null = null; if (props.activeTool) { if ((props.activeTool.type as string) === "image") { console.warn(`"image" tool cannot be forced via "props.activeTool"`); } else if (!this.isToolSupported(props.activeTool.type, props)) { console.warn( `"${props.activeTool.type}" tool ("props.activeTool") cannot be activated — disabled via "UIOptions.tools", or not enabled while non-interactive (see "interaction.enabled.tools")`, ); } else { forcedActiveTool = updateActiveTool(defaultAppState, props.activeTool); } } this.state = { ...defaultAppState, theme, exportWithDarkMode: theme === THEME.DARK, isLoading: true, ...this.getCanvasOffsets(), // non-interactive editor implies view mode so that all edit-mode // gates apply viewModeEnabled: this.isInteractionEnabled(props) ? viewModeEnabled : true, activeTool: forcedActiveTool ?? defaultAppState.activeTool, zenModeEnabled, objectsSnapModeEnabled, gridModeEnabled: gridModeEnabled ?? defaultAppState.gridModeEnabled, name, width: this.ownerWindow.innerWidth, height: this.ownerWindow.innerHeight, }; this.refreshEditorInterface(); this.stylesPanelMode = deriveStylesPanelMode(this.editorInterface); 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 = this.ownerDocument.createElement("canvas"); this.rc = rough.canvas(this.canvas); this.renderer = new Renderer(this.scene); this.visibleElements = []; this.store = new Store(this); this.history = new History(this.store); this.excalidrawContainerValue = { container: this.excalidrawContainerRef.current, id: this.id, }; this.fonts = new Fonts(this.scene, this.ownerDocument); this.history = new History(this.store); this.actionManager.registerAll(actions); this.actionManager.registerAction(createUndoAction(this.history)); this.actionManager.registerAction(createRedoAction(this.history)); // in case internal editor APIs call this early, otherwise we need // to construct this in componentDidMount because componentWillUnmount // will invalidate it (so in StrictMode, doing this in constructor alone // would be a problem) this.api = this.createExcalidrawAPI(); } /** * Whether the editor accepts user input (pointer, keyboard, wheel, touch, * clipboard, drag&drop). When `false`, the editor is fully inert for the * user, but remains controllable through the imperative API. * * All user-input entry points must consult this getter (directly or by * not being attached/rendered at all). */ public isInteractionEnabled( props: Pick = this.props, ): boolean { return props.interaction !== false && typeof props.interaction !== "object"; } /** * Whether element links render their link icon and are clickable. * True when fully interactive, or when `interaction: { enabled: { links: * true } }` * (in which case clicking anywhere on a linked element opens the link, * same as in view mode). */ public isLinksEnabled( props: Pick = this.props, ): boolean { if (typeof props.interaction === "object" && props.interaction !== null) { return ( props.interaction.enabled?.links === true || props.interaction.enabled?.interactiveContent === true ); } return props.interaction !== false; } /** * Whether canvas navigation — panning & zooming, view-mode style — is * enabled. True when fully interactive, or when `interaction: { enabled: * { navigation: true } }`. Respects `appState.scrollConstraints`. */ public isNavigationEnabled( props: Pick = this.props, ): boolean { if (typeof props.interaction === "object" && props.interaction !== null) { return props.interaction.enabled?.navigation === true; } return props.interaction !== false; } /** * Whether embeddable & iframe elements are interactive (hover & click to * activate, view-mode style). True when fully interactive, or when * allowed via `interaction.enabled.embeds` / `.interactiveContent`. */ public isEmbedsEnabled( props: Pick = this.props, ): boolean { if (typeof props.interaction === "object" && props.interaction !== null) { return ( props.interaction.enabled?.embeds === true || props.interaction.enabled?.interactiveContent === true ); } return props.interaction !== false; } /** * Whether the browser's own zoom (ctrl/cmd + wheel, pinch, keyboard * shortcuts) stays available over the non-interactive editor. * Prevented by default. */ public isBrowserZoomEnabled( props: Pick = this.props, ): boolean { if (typeof props.interaction === "object" && props.interaction !== null) { return props.interaction.enabled?.browserZoom === true; } return false; } /** * Whether the tool can be activated & driven by user input. False when * disabled via `UIOptions.tools`, or when the editor is non-interactive * and the tool isn't kept user-driven via `interaction.enabled.tools`. * * (Once UI tool availability is split from input availability — e.g. * `props.ui.tools` vs `interaction.disabled.tools` — the UI axis moves * out into its own predicate.) * * We purposely widen the `tool` type so this helper can be called with * any tool without having to type check it. */ public isToolSupported = ( tool: T, props: Pick = this.props, ): boolean => { if ( props.UIOptions.tools?.[ tool as Extract ] === false ) { return false; } if (this.isInteractionEnabled(props)) { return true; } const tools = typeof props.interaction === "object" && props.interaction !== null ? props.interaction.enabled?.tools : undefined; if (tool === "laser") { return tools?.laser === true; } if (tool === "custom") { return tools?.custom === true; } return false; }; /** * Whether the active tool is locked in place — via the tool lock * (padlock / Q) or by being host-forced (`props.activeTool`). A locked * tool doesn't revert to the selection tool after use, and elements drawn * with it aren't selected. Forcing deliberately does not mutate * `activeTool.locked`, which is the user's persisted padlock preference. */ public isToolLocked(): boolean { return this.state.activeTool.locked || this.props.activeTool != null; } /** * Whether the active tool captures the primary pointer instead of the * view-mode drag-to-pan — the laser and host-implemented custom tools do; * while non-interactive, any tool allowed via * `interaction.enabled.tools` does. (Editing tools capture the pointer * trivially since view mode implies they're not active; this predicate only * matters where view-mode gates apply.) */ public isActiveToolPointerCapturing(): boolean { if (!this.isInteractionEnabled()) { // an active tool that isn't allowed via `interaction.enabled.tools` // is inert — including the laser return this.isToolSupported(this.state.activeTool.type); } return ( this.state.activeTool.type === "laser" || this.state.activeTool.type === "custom" ); } /** Whether Excalidraw's full default UI is rendered. */ public isDefaultUIEnabled(props: Pick = this.props): boolean { return ( props.ui !== false && (typeof props.ui !== "object" || props.ui === null) ); } /** Whether an individual default UI control is rendered. */ public isUIControlEnabled( control: keyof UIConfig["enabled"], props: Pick = this.props, ): boolean { if (typeof props.ui === "object" && props.ui !== null) { return props.ui.enabled?.[control] === true; } return props.ui !== false; } updateEditorAtom = ( atom: WritableAtom, ...args: Args ): Result => { const result = editorJotaiStore.set(atom, ...args); this.triggerRender(); return result; }; 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 = this.ownerDocument.body.querySelectorAll( "iframe.excalidraw__embeddable", ); if (!iframes) { break; } for (const iframe of iframes as NodeListOf) { 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, ); } } break; } }; private handleSkipBindMode() { if ( this.state.selectedLinearElement?.initialState && !this.state.selectedLinearElement.initialState.arrowStartIsInside ) { invariant( this.lastPointerMoveCoords, "Missing last pointer move coords when changing bind skip mode for arrow start", ); const elementsMap = this.scene.getNonDeletedElementsMap(); const hoveredElement = getHoveredElementForBinding( pointFrom( this.lastPointerMoveCoords.x, this.lastPointerMoveCoords.y, ), this.scene.getNonDeletedElements(), elementsMap, ); const element = LinearElementEditor.getElement( this.state.selectedLinearElement.elementId, elementsMap, ); if ( element?.startBinding && hoveredElement?.id === element.startBinding.elementId ) { this.setState({ selectedLinearElement: { ...this.state.selectedLinearElement, initialState: { ...this.state.selectedLinearElement.initialState, arrowStartIsInside: true, }, }, }); } } if (this.state.bindMode === "orbit") { if (this.bindModeHandler) { clearTimeout(this.bindModeHandler); this.bindModeHandler = null; } // PERF: It's okay since it's a single trigger from a key handler // or single call from pointer move handler because the bindMode check // will not pass the second time flushSync(() => { this.setState({ bindMode: "skip", }); }); if ( this.lastPointerMoveCoords && this.state.selectedLinearElement?.selectedPointsIndices && this.state.selectedLinearElement?.selectedPointsIndices.length ) { const { x, y } = this.lastPointerMoveCoords; const event = this.lastPointerMoveEvent ?? this.lastPointerDownEvent?.nativeEvent; invariant(event, "Last event must exist"); const deltaX = x - this.state.selectedLinearElement.pointerOffset.x; const deltaY = y - this.state.selectedLinearElement.pointerOffset.y; const newState = this.state.multiElement ? LinearElementEditor.handlePointerMove( event, this, deltaX, deltaY, this.state.selectedLinearElement, ) : LinearElementEditor.handlePointDragging( event, this, deltaX, deltaY, this.state.selectedLinearElement, ); if (newState) { this.setState(newState); } } } } private resetDelayedBindMode() { if (this.bindModeHandler) { clearTimeout(this.bindModeHandler); this.bindModeHandler = null; } if (this.state.bindMode !== "orbit") { // We need this iteration to complete binding and change // back to orbit mode after that setTimeout(() => this.setState({ bindMode: "orbit", }), ); } } private previousHoveredBindableElement: NonDeletedExcalidrawElement | null = null; private handleDelayedBindModeChange( arrow: ExcalidrawArrowElement, hoveredElement: NonDeletedExcalidrawElement | null, ) { if (arrow.isDeleted || isElbowArrow(arrow)) { return; } const effector = () => { this.bindModeHandler = null; invariant( this.lastPointerMoveCoords, "Expected lastPointerMoveCoords to be set", ); if (!this.state.multiElement) { if ( !this.state.selectedLinearElement || !this.state.selectedLinearElement.selectedPointsIndices || !this.state.selectedLinearElement.selectedPointsIndices.length ) { return; } const startDragged = this.state.selectedLinearElement.selectedPointsIndices.includes(0); const endDragged = this.state.selectedLinearElement.selectedPointsIndices.includes( arrow.points.length - 1, ); // Check if the whole arrow is dragged by selecting all endpoints if ((!startDragged && !endDragged) || (startDragged && endDragged)) { return; } } const { x, y } = this.lastPointerMoveCoords; const hoveredElement = getHoveredElementForBinding( pointFrom(x, y), this.scene.getNonDeletedElements(), this.scene.getNonDeletedElementsMap(), ); if (hoveredElement && this.state.bindMode !== "skip") { invariant( this.state.selectedLinearElement?.elementId === arrow.id, "The selectedLinearElement is expected to not change while a bind mode timeout is ticking", ); // Once the start is set to inside binding, it remains so const arrowStartIsInside = this.state.selectedLinearElement.initialState.arrowStartIsInside || arrow.startBinding?.elementId === hoveredElement.id; // Change the global binding mode flushSync(() => { invariant( this.state.selectedLinearElement, "this.state.selectedLinearElement must exist", ); this.setState({ bindMode: "inside", selectedLinearElement: { ...this.state.selectedLinearElement, initialState: { ...this.state.selectedLinearElement.initialState, arrowStartIsInside, }, }, }); }); const event = this.lastPointerMoveEvent ?? this.lastPointerDownEvent?.nativeEvent; invariant(event, "Last event must exist"); const deltaX = x - this.state.selectedLinearElement.pointerOffset.x; const deltaY = y - this.state.selectedLinearElement.pointerOffset.y; const newState = this.state.multiElement ? LinearElementEditor.handlePointerMove( event, this, deltaX, deltaY, this.state.selectedLinearElement, ) : LinearElementEditor.handlePointDragging( event, this, deltaX, deltaY, this.state.selectedLinearElement, ); if (newState) { this.setState(newState); } } }; let isOverlapping = false; if (this.state.selectedLinearElement?.selectedPointsIndices) { const elementsMap = this.scene.getNonDeletedElementsMap(); const startDragged = this.state.selectedLinearElement.selectedPointsIndices.includes(0); const endDragged = this.state.selectedLinearElement.selectedPointsIndices.includes( arrow.points.length - 1, ); const startElement = startDragged ? hoveredElement : arrow.startBinding && elementsMap.get(arrow.startBinding.elementId); const endElement = endDragged ? hoveredElement : arrow.endBinding && elementsMap.get(arrow.endBinding.elementId); const startBounds = startElement && getElementBounds(startElement, elementsMap); const endBounds = endElement && getElementBounds(endElement, elementsMap); isOverlapping = !!( startBounds && endBounds && startElement.id !== endElement.id && doBoundsIntersect(startBounds, endBounds) ); } const startDragged = this.state.selectedLinearElement?.selectedPointsIndices?.includes(0); const endDragged = this.state.selectedLinearElement?.selectedPointsIndices?.includes( arrow.points.length - 1, ); const currentBinding = startDragged ? "startBinding" : endDragged ? "endBinding" : null; const otherBinding = startDragged ? "endBinding" : endDragged ? "startBinding" : null; const isAlreadyInsideBindingToSameElement = (otherBinding && arrow[otherBinding]?.mode === "inside" && arrow[otherBinding]?.elementId === hoveredElement?.id) || (currentBinding && arrow[currentBinding]?.mode === "inside" && hoveredElement?.id === arrow[currentBinding]?.elementId); if ( currentBinding && otherBinding && arrow[currentBinding]?.mode === "inside" && hoveredElement?.id !== arrow[currentBinding]?.elementId && arrow[otherBinding]?.elementId !== arrow[currentBinding]?.elementId ) { // Update binding out of place to orbit mode this.scene.mutateElement( arrow, { [currentBinding]: { ...arrow[currentBinding], mode: "orbit", }, }, { informMutation: false, isDragging: true, }, ); } if ( !hoveredElement || (this.previousHoveredBindableElement && hoveredElement.id !== this.previousHoveredBindableElement.id) ) { // Clear the timeout if we're not hovering a bindable if (this.bindModeHandler) { clearTimeout(this.bindModeHandler); this.bindModeHandler = null; } // Clear the inside binding mode too if (this.state.bindMode === "inside") { flushSync(() => { this.setState({ bindMode: "orbit", }); }); } this.previousHoveredBindableElement = null; } else if ( !this.bindModeHandler && (!this.state.newElement || !arrow.startBinding || isOverlapping) && !isAlreadyInsideBindingToSameElement ) { // We are hovering a bindable element this.bindModeHandler = setTimeout(effector, BIND_MODE_TIMEOUT); } this.previousHoveredBindableElement = hoveredElement; } 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 getTextCreationGridPoint = (x: number, y: number) => { const effectiveGridSize = this.getEffectiveGridSize(); if (effectiveGridSize === null) { return null; } const getTextCreationGridCoordinate = (coordinate: number) => { const topLeftGridPoint = Math.floor(coordinate / effectiveGridSize) * effectiveGridSize; return topLeftGridPoint; }; return { x: getTextCreationGridCoordinate(x), y: getTextCreationGridCoordinate(y), }; }; private getHTMLIFrameElement( element: ExcalidrawIframeLikeElement, ): HTMLIFrameElement | undefined { return this.iFrameRefs.get(element.id); } /** * AI-generated iframe elements aren't interactive while their generation * is still in progress (the partial content is render-only). */ private isIframeLikeInteractive(element: ExcalidrawElement): boolean { if (isIframeElement(element)) { const data = element.customData?.generationData ?? this.magicGenerations.get(element.id); return data?.status !== "pending"; } return true; } private handleIframeLikeElementHover = ({ hitElement, scenePointer, moveEvent, }: { hitElement: NonDeleted | null; scenePointer: { x: number; y: number }; moveEvent: React.PointerEvent; }): boolean => { if ( hitElement && isIframeLikeElement(hitElement) && this.isIframeLikeInteractive(hitElement) && (this.state.viewModeEnabled || this.state.activeTool.type === "laser" || this.isIframeLikeElementCenter( hitElement, moveEvent, scenePointer.x, scenePointer.y, )) ) { this.cursor.set(CURSOR_TYPE.POINTER); this.setState({ activeEmbeddable: { element: hitElement, state: "hover" }, }); return true; } else if (this.state.activeEmbeddable?.state === "hover") { this.setState({ activeEmbeddable: null }); } return false; }; /** @returns true if iframe-like element click handled */ private handleIframeLikeCenterClick(): boolean { if ( !this.lastPointerDownEvent || !this.lastPointerUpEvent || // middle-click or something other than primary this.lastPointerDownEvent.button !== POINTER_BUTTON.MAIN || // panning isHoldingSpace || // wrong tool !oneOf(this.state.activeTool.type, ["laser", "selection", "lasso"]) ) { return false; } const viewportClickStart_scenePoint = pointFrom( viewportCoordsToSceneCoords( { clientX: this.lastPointerDownEvent.clientX, clientY: this.lastPointerDownEvent.clientY, }, this.state, ), ); const viewportClickEnd_scenePoint = pointFrom( viewportCoordsToSceneCoords( { clientX: this.lastPointerUpEvent.clientX, clientY: this.lastPointerUpEvent.clientY, }, this.state, ), ); const draggedDistance = pointDistance( viewportClickStart_scenePoint, viewportClickEnd_scenePoint, ); if (draggedDistance > DRAGGING_THRESHOLD) { return false; } const hitElement = this.getElementAtPosition( viewportClickStart_scenePoint[0], viewportClickStart_scenePoint[1], ); const shouldActivate = hitElement && this.lastPointerUpEvent.timeStamp - this.lastPointerDownEvent.timeStamp <= 300 && gesture.pointers.size < 2 && isIframeLikeElement(hitElement) && this.isIframeLikeInteractive(hitElement) && (this.state.viewModeEnabled || this.state.activeTool.type === "laser" || this.isIframeLikeElementCenter( hitElement, this.lastPointerUpEvent, viewportClickEnd_scenePoint[0], viewportClickEnd_scenePoint[1], )); if (!shouldActivate) { return false; } const iframeLikeElement = hitElement; if ( this.state.activeEmbeddable?.element === iframeLikeElement && this.state.activeEmbeddable?.state === "active" ) { return true; } // 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: iframeLikeElement, state: "active" }, selectedElementIds: { [iframeLikeElement.id]: true }, newElement: null, selectionElement: null, }); }, 100); if (isIframeElement(iframeLikeElement)) { return true; } const iframe = this.getHTMLIFrameElement(iframeLikeElement); if (!iframe?.contentWindow) { return true; } if (iframe.src.includes("youtube")) { const state = YOUTUBE_VIDEO_STATES.get(iframeLikeElement.id); if (!state) { YOUTUBE_VIDEO_STATES.set( iframeLikeElement.id, YOUTUBE_STATES.UNSTARTED, ); iframe.contentWindow.postMessage( JSON.stringify({ event: "listening", id: iframeLikeElement.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 }), "*", ); } return true; } private isDoubleClick = ( lastPointerEvent: | PointerEvent | React.PointerEvent | undefined | null, currentPointerEvent: PointerEvent | React.PointerEvent, ) => { return ( lastPointerEvent != null && currentPointerEvent.timeStamp - lastPointerEvent.timeStamp <= TAP_TWICE_TIMEOUT ); }; private isIframeLikeElementCenter( el: ExcalidrawIframeLikeElement | null, event: React.PointerEvent | 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(); 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> => (isEmbeddableElement(el) && this.embedsValidationStatus.get(el.id) === true) || isIframeElement(el), ); return ( <> {embeddableElements.map((el) => { const renderState = this.getElementRenderState(el); const { x, y } = sceneCoordsToViewportCoords( { sceneX: el.x, sceneY: el.y }, this.state, ); const isVisible = isElementInViewport( getRenderElementWithPositionOverride(el, renderState.offset), 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; let isPendingGeneration = false; 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(` `); }, } as const; isPendingGeneration = true; } 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(`

Error!

${message}

`); }, } 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"; // scale video embeds based on zoom (capped) so that smaller embeds // on canvas when zoomed are still of legible quality // (note: for some embed types like gdrive, the quality is poor when // scaling mid playback and works only when you initially start the // playback at the higher zoom level) const shouldScaleEmbeddableViewport = src?.type === "video"; const embeddableViewportScale = clamp( shouldScaleEmbeddableViewport ? scale : 1, 0.75, MAX_EMBEDDABLE_VIEWPORT_SCALE, ); return (
{ 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 && (
{t("buttons.embeddableInteractionButton")}
)}
{(isEmbeddableElement(el) ? this.props.renderEmbeddable?.(el, this.state) : null) ?? (