mirror of
https://github.com/excalidraw/excalidraw.git
synced 2026-10-19 16:15:56 +00:00
* refactor(editor): move the clipboard out of App into AppClipboard (app.clipboard) A pure move of the clipboard handling out of App.tsx (13,478 → 12,942 lines) into App.clipboard.ts: onCut and onCopy (the document's cut/copy events, through their actions), pasteFromClipboard (the paste event, parsed, and handed to a host's onPaste first), insertClipboardContent (inserting the pasted data as whatever it is: images or SVG code, elements, a Mermaid definition, embeddable URLs, a spreadsheet as a chart, mixed content, or text), addElementsFromMixedContentPaste and addTextFromPaste. The plain-paste flags move along as module-level state; Ctrl/Cmd+V's marking of a plain paste, and its reset, are App's former lines behind app.clipboard.onPasteShortcut and clearPlainPaste. Staying in App: addElementsFromPasteOrLibrary and insertImages, shared with drop, the library and text-to-diagram (insertImages becomes public for the clipboard), and onIframeSrcCopy, which belongs to the magic frame. Callers go through `app.clipboard`: no delegate is left on App, and AppClassProperties exposes `clipboard` (as it does `textTool`) instead of `pasteFromClipboard`. Beyond the move, `this.` becomes `this.app.` for what stays on App, onCut and onCopy become public, and the container ref comes in as a dependency. The moved code is identical to the removed, modulo those rewrites, whitespace and trailing commas (checked mechanically). Claude Opus 5.5 (1M), xhigh effort * test(editor): cover copy/cut events and more paste routes Only 80% of the clipboard code ran under the suite, and nothing went through the document's copy and cut events, which is how Ctrl/Cmd+C/X reach the editor in a browser. Now a copy event copies the selection and a cut event cuts it; a host's onPaste returning false keeps the paste from being inserted; a pasted spreadsheet opens the charts dialog; and with the image tool off, a paste of mixed content inserts its text. Claude Opus 5.5 (1M), xhigh effort
1608 lines
54 KiB
TypeScript
1608 lines
54 KiB
TypeScript
import type {
|
||
IMAGE_MIME_TYPES,
|
||
UserIdleState,
|
||
throttleRAF,
|
||
MIME_TYPES,
|
||
EditorInterface,
|
||
StrokeWidthKey,
|
||
} from "@excalidraw/common";
|
||
|
||
import type { LinearElementEditor } from "@excalidraw/element";
|
||
|
||
import type { MaybeTransformHandleType } from "@excalidraw/element";
|
||
|
||
import type {
|
||
PointerType,
|
||
ExcalidrawLinearElement,
|
||
NonDeletedExcalidrawElement,
|
||
NonDeleted,
|
||
TextAlign,
|
||
ExcalidrawElement,
|
||
GroupId,
|
||
ExcalidrawBindableElement,
|
||
ExcalidrawArrowElement,
|
||
Arrowhead,
|
||
FontFamilyValues,
|
||
FileId,
|
||
Theme,
|
||
StrokeRoundness,
|
||
ExcalidrawEmbeddableElement,
|
||
ExcalidrawMagicFrameElement,
|
||
ExcalidrawFrameLikeElement,
|
||
ExcalidrawElementType,
|
||
ExcalidrawIframeLikeElement,
|
||
OrderedExcalidrawElement,
|
||
ExcalidrawNonSelectionElement,
|
||
BindMode,
|
||
ExcalidrawTextElement,
|
||
StrokeVariability,
|
||
} from "@excalidraw/element/types";
|
||
|
||
import type {
|
||
Merge,
|
||
MaybePromise,
|
||
ValueOf,
|
||
MakeBrand,
|
||
} from "@excalidraw/common/utility-types";
|
||
|
||
import type {
|
||
CaptureUpdateActionType,
|
||
DurableIncrement,
|
||
EphemeralIncrement,
|
||
OnDuplicateData,
|
||
} from "@excalidraw/element";
|
||
import type { GlobalPoint } from "@excalidraw/math";
|
||
|
||
import type { Action } from "./actions/types";
|
||
import type { Spreadsheet } from "./charts";
|
||
import type { ClipboardData } from "./clipboard";
|
||
import type App from "./components/App";
|
||
import type Library from "./data/library";
|
||
import type { ContextMenuItems } from "./components/ContextMenu";
|
||
import type { SnapLine } from "./snapping";
|
||
import type { ImportedDataState } from "./data/types";
|
||
import type { SetViewportOptions } from "./viewport";
|
||
|
||
import type { Language } from "./i18n";
|
||
import type { isOverScrollBars } from "./scene/scrollbars";
|
||
import type React from "react";
|
||
import type { JSX } from "react";
|
||
|
||
export type { App };
|
||
|
||
export type SocketId = string & { _brand: "SocketId" };
|
||
|
||
export type Collaborator = Readonly<{
|
||
pointer?: CollaboratorPointer;
|
||
button?: "up" | "down";
|
||
selectedElementIds?: AppState["selectedElementIds"];
|
||
username?: string | null;
|
||
userState?: UserIdleState;
|
||
color?: {
|
||
background: string;
|
||
stroke: string;
|
||
};
|
||
// The url of the collaborator's avatar, defaults to username initials
|
||
// if not present
|
||
avatarUrl?: string;
|
||
// Stable user id. A user may have multiple collaborators, one per client.
|
||
id?: string;
|
||
socketId?: SocketId;
|
||
isCurrentUser?: boolean;
|
||
isInCall?: boolean;
|
||
isSpeaking?: boolean;
|
||
isMuted?: boolean;
|
||
}>;
|
||
|
||
export type CollaboratorPointer = {
|
||
x: number;
|
||
y: number;
|
||
tool: "pointer" | "laser";
|
||
/**
|
||
* Whether to render cursor + username. Useful when you only want to render
|
||
* laser trail.
|
||
*
|
||
* @default true
|
||
*/
|
||
renderCursor?: boolean;
|
||
/**
|
||
* Explicit laser color.
|
||
*
|
||
* @default string collaborator's cursor color
|
||
*/
|
||
laserColor?: string;
|
||
};
|
||
|
||
export type DataURL = string & { _brand: "DataURL" };
|
||
|
||
export type BinaryFileData = {
|
||
mimeType:
|
||
| ValueOf<typeof IMAGE_MIME_TYPES>
|
||
// future user or unknown file type
|
||
| typeof MIME_TYPES.binary;
|
||
id: FileId;
|
||
dataURL: DataURL;
|
||
/**
|
||
* Epoch timestamp in milliseconds
|
||
*/
|
||
created: number;
|
||
/**
|
||
* Indicates when the file was last retrieved from storage to be loaded
|
||
* onto the scene. We use this flag to determine whether to delete unused
|
||
* files from storage.
|
||
*
|
||
* Epoch timestamp in milliseconds.
|
||
*/
|
||
lastRetrieved?: number;
|
||
/**
|
||
* indicates the version of the file. This can be used to determine whether
|
||
* the file dataURL has changed e.g. as part of restore due to schema update.
|
||
*/
|
||
version?: number;
|
||
};
|
||
|
||
export type BinaryFileMetadata = Omit<BinaryFileData, "dataURL">;
|
||
|
||
export type BinaryFiles = Record<ExcalidrawElement["id"], BinaryFileData>;
|
||
|
||
export type ToolType =
|
||
| "selection"
|
||
| "lasso"
|
||
| "rectangle"
|
||
| "diamond"
|
||
| "ellipse"
|
||
| "arrow"
|
||
| "line"
|
||
| "freedraw"
|
||
| "text"
|
||
| "image"
|
||
| "eraser"
|
||
| "hand"
|
||
| "frame"
|
||
| "magicframe"
|
||
| "stickynote"
|
||
| "embeddable"
|
||
| "laser"
|
||
| "autoshape"
|
||
| "bucketfill";
|
||
|
||
export type ElementOrToolType = ExcalidrawElementType | ToolType | "custom";
|
||
|
||
export type ActiveTool =
|
||
| {
|
||
type: ToolType;
|
||
customType: null;
|
||
}
|
||
| {
|
||
type: "custom";
|
||
customType: string;
|
||
};
|
||
|
||
export type SidebarName = string;
|
||
export type SidebarTabName = string;
|
||
|
||
export type UserToFollow = {
|
||
socketId: SocketId;
|
||
username: string;
|
||
};
|
||
|
||
type _CommonCanvasAppState = {
|
||
zoom: AppState["zoom"];
|
||
scrollX: AppState["scrollX"];
|
||
scrollY: AppState["scrollY"];
|
||
width: AppState["width"];
|
||
height: AppState["height"];
|
||
viewModeEnabled: AppState["viewModeEnabled"];
|
||
openDialog: AppState["openDialog"];
|
||
editingGroupId: AppState["editingGroupId"]; // TODO: move to interactive canvas if possible
|
||
selectedElementIds: AppState["selectedElementIds"]; // TODO: move to interactive canvas if possible
|
||
frameToHighlight: AppState["frameToHighlight"]; // TODO: move to interactive canvas if possible
|
||
offsetLeft: AppState["offsetLeft"];
|
||
offsetTop: AppState["offsetTop"];
|
||
theme: AppState["theme"];
|
||
};
|
||
|
||
export type StaticCanvasAppState = Readonly<
|
||
_CommonCanvasAppState & {
|
||
shouldCacheIgnoreZoom: AppState["shouldCacheIgnoreZoom"];
|
||
/** null indicates transparent bg */
|
||
viewBackgroundColor: AppState["viewBackgroundColor"] | null;
|
||
exportScale: AppState["exportScale"];
|
||
selectedElementsAreBeingDragged: AppState["selectedElementsAreBeingDragged"];
|
||
gridSize: AppState["gridSize"];
|
||
gridStep: AppState["gridStep"];
|
||
frameRendering: AppState["frameRendering"];
|
||
currentHoveredFontFamily: AppState["currentHoveredFontFamily"];
|
||
hoveredElementIds: AppState["hoveredElementIds"];
|
||
suggestedBinding: AppState["suggestedBinding"];
|
||
// Cropping
|
||
croppingElementId: AppState["croppingElementId"];
|
||
}
|
||
>;
|
||
|
||
export type InteractiveCanvasAppState = Readonly<
|
||
_CommonCanvasAppState & {
|
||
activeTool: AppState["activeTool"];
|
||
// renderInteractiveScene
|
||
activeEmbeddable: AppState["activeEmbeddable"];
|
||
selectionElement: AppState["selectionElement"];
|
||
selectedGroupIds: AppState["selectedGroupIds"];
|
||
selectedLinearElement: AppState["selectedLinearElement"];
|
||
multiElement: AppState["multiElement"];
|
||
newElement: AppState["newElement"];
|
||
isBindingEnabled: AppState["isBindingEnabled"];
|
||
isMidpointSnappingEnabled: AppState["isMidpointSnappingEnabled"];
|
||
gridModeEnabled: AppState["gridModeEnabled"];
|
||
suggestedBinding: AppState["suggestedBinding"];
|
||
textToolHover: AppState["textToolHover"];
|
||
isRotating: AppState["isRotating"];
|
||
elementsToHighlight: AppState["elementsToHighlight"];
|
||
// Collaborators
|
||
collaborators: AppState["collaborators"];
|
||
// SnapLines
|
||
snapLines: AppState["snapLines"];
|
||
zenModeEnabled: AppState["zenModeEnabled"];
|
||
editingTextElement: AppState["editingTextElement"];
|
||
// Cropping
|
||
isCropping: AppState["isCropping"];
|
||
croppingElementId: AppState["croppingElementId"];
|
||
// Search matches
|
||
searchMatches: AppState["searchMatches"];
|
||
activeLockedId: AppState["activeLockedId"];
|
||
// Non-used but needed in binding highlight arrow overdraw
|
||
hoveredElementIds: AppState["hoveredElementIds"];
|
||
frameRendering: AppState["frameRendering"];
|
||
shouldCacheIgnoreZoom: AppState["shouldCacheIgnoreZoom"];
|
||
exportScale: AppState["exportScale"];
|
||
currentItemArrowType: AppState["currentItemArrowType"];
|
||
}
|
||
>;
|
||
|
||
export type ObservedAppState = ObservedStandaloneAppState &
|
||
ObservedElementsAppState;
|
||
|
||
export type ObservedStandaloneAppState = {
|
||
name: AppState["name"];
|
||
viewBackgroundColor: AppState["viewBackgroundColor"];
|
||
};
|
||
|
||
export type ObservedElementsAppState = {
|
||
editingGroupId: AppState["editingGroupId"];
|
||
selectedElementIds: AppState["selectedElementIds"];
|
||
selectedGroupIds: AppState["selectedGroupIds"];
|
||
selectedLinearElement: {
|
||
elementId: LinearElementEditor["elementId"];
|
||
isEditing: boolean;
|
||
} | null;
|
||
croppingElementId: AppState["croppingElementId"];
|
||
lockedMultiSelections: AppState["lockedMultiSelections"];
|
||
activeLockedId: AppState["activeLockedId"];
|
||
};
|
||
|
||
export type BoxSelectionMode = "contain" | "overlap";
|
||
|
||
/**
|
||
* The pointing device the wheel mappings are tuned for. `auto` is reserved
|
||
* for detecting it from the wheel events; see `resolveInputDevice`.
|
||
*/
|
||
export type InputDevice = "auto" | "mouse" | "trackpad";
|
||
|
||
/**
|
||
* A box, in scene coordinates, that pan & zoom are constrained to.
|
||
*
|
||
* This is a private type. For public API, only use specific properties,
|
||
* needed.
|
||
*/
|
||
export type ScrollConstraints = {
|
||
x: number;
|
||
y: number;
|
||
width: number;
|
||
height: number;
|
||
/** when set, panning is constrained so the viewport stays within the box */
|
||
lockScroll: boolean;
|
||
/** when set, the viewport cannot zoom out below `zoom` */
|
||
lockZoom: boolean;
|
||
/**
|
||
* The zoom resolved after the `setViewport` navigation settled.
|
||
*/
|
||
zoom: number;
|
||
/**
|
||
* Pixel amount the viewport may overscroll past its resting clamp before
|
||
* snapping back (rubberband). Screen pixels, zoom-independent. Resolved
|
||
* from `lock.overscroll` at the time the lock was installed (`true` →
|
||
* default give, `false` → 0).
|
||
*/
|
||
overscroll: number;
|
||
/**
|
||
* Extra scrollable margin around the box (CSS-style), letting the viewport
|
||
* scroll past each box edge to reveal that much empty space. Values are
|
||
* viewport pixels and zoom-independent (a fixed on-screen distance).
|
||
* Resolved from the `offsets` passed to `setViewport` (see
|
||
* {@link ViewportOffsets}) at the time the lock was installed.
|
||
*/
|
||
offsets?: Offsets;
|
||
};
|
||
|
||
export interface AppState {
|
||
contextMenu: {
|
||
items: ContextMenuItems;
|
||
top: number;
|
||
left: number;
|
||
} | null;
|
||
showWelcomeScreen: boolean;
|
||
isLoading: boolean;
|
||
errorMessage: React.ReactNode;
|
||
activeEmbeddable: {
|
||
element: NonDeletedExcalidrawElement;
|
||
state: "hover" | "active";
|
||
} | null;
|
||
/**
|
||
* for a newly created element
|
||
* - set on pointer down, updated during pointer move, used on pointer up
|
||
*/
|
||
newElement: NonDeleted<ExcalidrawNonSelectionElement> | null;
|
||
/**
|
||
* for a single element that's being resized
|
||
* - set on pointer down when it's selected and the active tool is selection
|
||
*/
|
||
resizingElement: NonDeletedExcalidrawElement | null;
|
||
/**
|
||
* multiElement is for multi-point linear element that's created by clicking as opposed to dragging
|
||
* - when set and present, the editor will handle linear element creation logic accordingly
|
||
*/
|
||
multiElement: NonDeleted<ExcalidrawLinearElement> | null;
|
||
/**
|
||
* decoupled from newElement, dragging selection only creates selectionElement
|
||
* - set on pointer down, updated during pointer move
|
||
*/
|
||
selectionElement: NonDeletedExcalidrawElement | null;
|
||
/**
|
||
* tracking current arrow binding editor state (takes into account
|
||
* `bindingPreference` and keyboard modifiers (ctrl/alt)
|
||
*/
|
||
isBindingEnabled: boolean;
|
||
/** user box selection preference; defaults to "contain" when unset */
|
||
boxSelectionMode: BoxSelectionMode;
|
||
/** user arrow binding preference */
|
||
bindingPreference: "enabled" | "disabled";
|
||
/** user preference whether arrow snap to midpoints while binding */
|
||
isMidpointSnappingEnabled: boolean;
|
||
/** user preference whether to show contextual hints above the toolbar */
|
||
showHints: boolean;
|
||
/**
|
||
* user preference for what the wheel does: with a `trackpad` a plain wheel
|
||
* pans; with a `mouse` a plain wheel zooms. Ctrl/cmd+wheel (how a pinch is
|
||
* delivered) zooms with either device. Shift+wheel pans horizontally;
|
||
* ctrl/cmd+shift+wheel pans vertically. `auto` resolves to `trackpad` until
|
||
* device detection exists — see `resolveInputDevice`
|
||
*/
|
||
inputDevice: InputDevice;
|
||
/**
|
||
* The bindable element the UI highlights for the user when an arrow is
|
||
* dragged or otherwise its endpoint being close to said element.
|
||
*/
|
||
suggestedBinding: {
|
||
element: NonDeleted<ExcalidrawBindableElement>;
|
||
midPoint?: GlobalPoint;
|
||
} | null;
|
||
/**
|
||
* What a text-tool click at the hovered position would act on — the text
|
||
* it would edit, the empty container it would label, or the arrow anchor
|
||
* (a free endpoint, or the midpoint for a label) it would attach text to.
|
||
* `null` when the click would create free text, or the tool isn't active.
|
||
* Drives the hover affordance only.
|
||
*/
|
||
textToolHover:
|
||
| { type: "text"; elementId: ExcalidrawElement["id"] }
|
||
| { type: "container"; elementId: ExcalidrawElement["id"] }
|
||
| {
|
||
type: "arrow";
|
||
elementId: ExcalidrawArrowElement["id"];
|
||
anchor: "start" | "end" | "label";
|
||
}
|
||
| null;
|
||
frameToHighlight: NonDeleted<ExcalidrawFrameLikeElement> | null;
|
||
frameRendering: {
|
||
enabled: boolean;
|
||
name: boolean;
|
||
outline: boolean;
|
||
clip: boolean;
|
||
};
|
||
/**
|
||
* frame-like element whose name is currently being edited
|
||
*/
|
||
editingFrame: ExcalidrawFrameLikeElement["id"] | null;
|
||
/**
|
||
* Elements the UI highlights with a bounding-box outline — those that
|
||
* would get added to a frame being dragged/resized.
|
||
*/
|
||
elementsToHighlight: readonly NonDeletedExcalidrawElement[] | null;
|
||
/**
|
||
* set when a new text is created or when an existing text is being edited
|
||
*/
|
||
editingTextElement: ExcalidrawTextElement | null;
|
||
activeTool: {
|
||
/**
|
||
* indicates a previous tool we should revert back to if we deselect the
|
||
* currently active tool. At the moment applies to `eraser` and `hand` tool.
|
||
*/
|
||
lastActiveTool: ActiveTool | null;
|
||
locked: boolean;
|
||
// indicates if the current tool is temporarily switched on from the selection tool
|
||
fromSelection: boolean;
|
||
} & ActiveTool;
|
||
preferredSelectionTool: {
|
||
type: "selection" | "lasso";
|
||
initialized: boolean;
|
||
};
|
||
|
||
// Pen handling
|
||
penMode: boolean;
|
||
penDetected: boolean;
|
||
|
||
exportBackground: boolean;
|
||
exportEmbedScene: boolean;
|
||
exportWithDarkMode: boolean;
|
||
exportScale: number;
|
||
currentItemStrokeColor: string;
|
||
currentItemStickynoteStrokeColor: string;
|
||
currentItemStickynoteBackgroundColor: string;
|
||
currentItemBackgroundColor: string;
|
||
currentItemFillStyle: ExcalidrawElement["fillStyle"];
|
||
currentItemStrokeWidthKey: StrokeWidthKey;
|
||
currentItemStrokeStyle: ExcalidrawElement["strokeStyle"];
|
||
currentItemRoughness: number;
|
||
currentItemStrokeVariability: StrokeVariability;
|
||
currentItemOpacity: number;
|
||
currentItemFontFamily: FontFamilyValues;
|
||
currentItemFontSize: number;
|
||
currentItemTextAlign: TextAlign;
|
||
currentItemStartArrowhead: Arrowhead | null;
|
||
currentItemEndArrowhead: Arrowhead | null;
|
||
currentHoveredFontFamily: FontFamilyValues | null;
|
||
currentItemRoundness: StrokeRoundness;
|
||
currentItemArrowType: "sharp" | "round" | "elbow";
|
||
viewBackgroundColor: string;
|
||
scrollX: number;
|
||
scrollY: number;
|
||
scrollConstraints: ScrollConstraints | null;
|
||
cursorButton: "up" | "down";
|
||
scrolledOutside: boolean;
|
||
name: string | null;
|
||
isResizing: boolean;
|
||
isRotating: boolean;
|
||
zoom: Zoom;
|
||
openMenu: "canvas" | null;
|
||
openPopup:
|
||
| "canvasBackground"
|
||
| "elementBackground"
|
||
| "elementStroke"
|
||
| "fontFamily"
|
||
| "compactTextProperties"
|
||
| "compactStrokeStyles"
|
||
| "compactOtherProperties"
|
||
| "compactArrowProperties"
|
||
| null;
|
||
openSidebar: { name: SidebarName; tab?: SidebarTabName } | null;
|
||
openDialog:
|
||
| null
|
||
| { name: "imageExport" | "help" | "jsonExport" }
|
||
| { name: "ttd"; tab: "text-to-diagram" | "mermaid" }
|
||
| { name: "commandPalette" }
|
||
| { name: "settings" }
|
||
| { name: "elementLinkSelector"; sourceElementId: ExcalidrawElement["id"] }
|
||
| { name: "charts"; data: Spreadsheet; rawText: string };
|
||
/**
|
||
* Reflects user preference for whether the default sidebar should be docked.
|
||
*
|
||
* NOTE this is only a user preference and does not reflect the actual docked
|
||
* state of the sidebar, because the host apps can override this through
|
||
* a DefaultSidebar prop, which is not reflected back to the appState.
|
||
*/
|
||
defaultSidebarDockedPreference: boolean;
|
||
|
||
lastPointerDownWith: PointerType;
|
||
selectedElementIds: Readonly<{ [id: string]: true }>;
|
||
hoveredElementIds: Readonly<{ [id: string]: true }>;
|
||
previousSelectedElementIds: { [id: string]: true };
|
||
selectedElementsAreBeingDragged: boolean;
|
||
shouldCacheIgnoreZoom: boolean;
|
||
toast: {
|
||
message: React.ReactNode;
|
||
closable?: boolean;
|
||
duration?: number;
|
||
} | null;
|
||
zenModeEnabled: boolean;
|
||
theme: Theme;
|
||
/** grid cell px size */
|
||
gridSize: number;
|
||
gridStep: number;
|
||
gridModeEnabled: boolean;
|
||
viewModeEnabled: boolean;
|
||
|
||
/** top-most selected groups (i.e. does not include nested groups) */
|
||
selectedGroupIds: { [groupId: string]: boolean };
|
||
/** group being edited when you drill down to its constituent element
|
||
(e.g. when you double-click on a group's element) */
|
||
editingGroupId: GroupId | null;
|
||
width: number;
|
||
height: number;
|
||
offsetTop: number;
|
||
offsetLeft: number;
|
||
|
||
fileHandle: FileSystemFileHandle | null;
|
||
collaborators: Map<SocketId, Collaborator>;
|
||
stats: {
|
||
open: boolean;
|
||
/** bitmap. Use `STATS_PANELS` bit values */
|
||
panels: number;
|
||
};
|
||
showHyperlinkPopup: false | "info" | "editor";
|
||
selectedLinearElement: LinearElementEditor | null;
|
||
snapLines: readonly SnapLine[];
|
||
originSnapOffset: {
|
||
x: number;
|
||
y: number;
|
||
} | null;
|
||
objectsSnapModeEnabled: boolean;
|
||
|
||
/** image cropping */
|
||
isCropping: boolean;
|
||
croppingElementId: ExcalidrawElement["id"] | null;
|
||
|
||
/** null if no search matches found / search closed */
|
||
searchMatches: Readonly<{
|
||
focusedId: ExcalidrawElement["id"] | null;
|
||
matches: readonly SearchMatch[];
|
||
}> | null;
|
||
|
||
/** the locked element/group that's active and shows unlock popup */
|
||
activeLockedId: string | null;
|
||
// when locking multiple units of elements together, we assign a temporary
|
||
// groupId to them so we can unlock them together;
|
||
// as elements are unlocked, we remove the groupId from the elements
|
||
// and also remove groupId from this map
|
||
lockedMultiSelections: { [groupId: string]: true };
|
||
// Stores the current bind mode which is detemined at various points during
|
||
// a drag operation (like pointer position vs bindable element) but needed
|
||
// globally for calculating the binding strategy
|
||
bindMode: BindMode;
|
||
/** user-customized color-picker top picks (pinned via drag & drop from the
|
||
* color picker popup). `null` means no customization (defaults, or
|
||
* host-supplied `topPicks`, are used). Kept per picker. */
|
||
colorTopPicks: {
|
||
elementStroke: readonly string[] | null;
|
||
elementBackground: readonly string[] | null;
|
||
/** the bucket-fill tool keeps a list separate from `elementBackground`
|
||
* even though both drive `currentItemBackgroundColor` (its defaults and
|
||
* use case differ — no transparent) */
|
||
bucketFill: readonly string[] | null;
|
||
/** sticky notes are their own color domain (own defaults, own picks) */
|
||
stickyNoteStroke: readonly string[] | null;
|
||
stickyNoteBackground: readonly string[] | null;
|
||
};
|
||
/** user-customized font-picker top picks (pinned via drag & drop from the
|
||
* font picker popup). `null` means no customization (defaults are used) */
|
||
fontTopPicks: readonly FontFamilyValues[] | null;
|
||
}
|
||
|
||
export type SearchMatch = {
|
||
id: string;
|
||
focus: boolean;
|
||
matchedLines: {
|
||
offsetX: number;
|
||
offsetY: number;
|
||
width: number;
|
||
height: number;
|
||
showOnCanvas: boolean;
|
||
}[];
|
||
};
|
||
|
||
export type UIAppState = Omit<
|
||
AppState,
|
||
// stripped from the UI-relevant app state so their (often per-frame)
|
||
// updates don't re-render the editor UI — canvas-only concerns.
|
||
// Subscribe via `useExcalidrawStateValue` if you need them in UI.
|
||
| "cursorButton"
|
||
| "scrollX"
|
||
| "scrollY"
|
||
| "zoom"
|
||
| "shouldCacheIgnoreZoom"
|
||
// canvas-interaction transients (snapping, binding & frame
|
||
// highlights) — identity churns per pointermove while interacting
|
||
| "snapLines"
|
||
| "originSnapOffset"
|
||
| "suggestedBinding"
|
||
| "textToolHover"
|
||
| "frameToHighlight"
|
||
| "elementsToHighlight"
|
||
>;
|
||
|
||
export type NormalizedZoomValue = number & { _brand: "normalizedZoom" };
|
||
|
||
export type Zoom = Readonly<{
|
||
value: NormalizedZoomValue;
|
||
}>;
|
||
|
||
export type PointerCoords = Readonly<{
|
||
x: number;
|
||
y: number;
|
||
}>;
|
||
|
||
export type Gesture = {
|
||
pointers: Map<number, PointerCoords>;
|
||
lastCenter: { x: number; y: number } | null;
|
||
initialDistance: number | null;
|
||
initialScale: number | null;
|
||
};
|
||
|
||
export declare class GestureEvent extends UIEvent {
|
||
readonly rotation: number;
|
||
readonly scale: number;
|
||
}
|
||
|
||
// libraries
|
||
// -----------------------------------------------------------------------------
|
||
/** @deprecated legacy: do not use outside of migration paths */
|
||
export type LibraryItem_v1 = readonly NonDeleted<ExcalidrawElement>[];
|
||
/** @deprecated legacy: do not use outside of migration paths */
|
||
type LibraryItems_v1 = readonly LibraryItem_v1[];
|
||
|
||
/** v2 library item */
|
||
export type LibraryItem = {
|
||
id: string;
|
||
status: "published" | "unpublished";
|
||
elements: readonly NonDeleted<ExcalidrawElement>[];
|
||
/** timestamp in epoch (ms) */
|
||
created: number;
|
||
name?: string;
|
||
error?: string;
|
||
};
|
||
export type LibraryItems = readonly LibraryItem[];
|
||
export type LibraryItems_anyVersion = LibraryItems | LibraryItems_v1;
|
||
|
||
export type LibraryItemsSource =
|
||
| ((
|
||
currentLibraryItems: LibraryItems,
|
||
) => MaybePromise<LibraryItems_anyVersion | Blob>)
|
||
| MaybePromise<LibraryItems_anyVersion | Blob>;
|
||
// -----------------------------------------------------------------------------
|
||
|
||
export type ExcalidrawInitialDataState = Merge<
|
||
ImportedDataState,
|
||
{
|
||
libraryItems?: MaybePromise<Required<ImportedDataState>["libraryItems"]>;
|
||
}
|
||
>;
|
||
|
||
export type ExcalidrawInitialState = {
|
||
viewport?: Omit<SetViewportOptions, "animation">;
|
||
};
|
||
|
||
export type OnUserFollowedPayload = {
|
||
userToFollow: UserToFollow;
|
||
action: "FOLLOW" | "UNFOLLOW";
|
||
};
|
||
|
||
export type ViewportStatusFrame = {
|
||
/** the badge (bottom-center pill) */
|
||
label?: {
|
||
label: React.ReactNode;
|
||
icon?: React.ReactNode;
|
||
/** badge background; defaults to var(--color-primary-hover) */
|
||
background?: string;
|
||
/** badge text color; defaults to var(--color-primary-light) */
|
||
color?: string;
|
||
/** makes the badge label interactive */
|
||
onClick?: () => void;
|
||
/** renders a close button when set */
|
||
onClose?: () => void;
|
||
};
|
||
/** viewport-edge border: CSS color, or `false` for none */
|
||
border: false | string;
|
||
};
|
||
|
||
export type OnExportProgress = {
|
||
type: "progress";
|
||
message?: React.ReactNode;
|
||
/** 0-1 range */
|
||
progress?: number;
|
||
};
|
||
|
||
export type InteractionConfig = {
|
||
/**
|
||
* Interactions that stay enabled while the editor is otherwise
|
||
* non-interactive. Opt-in: anything omitted or `false` is disabled.
|
||
*/
|
||
enabled?: {
|
||
/**
|
||
* Element links render their link icon and stay clickable — clicking
|
||
* anywhere on a linked element opens the link, same as in view mode.
|
||
* When disabled, link icons are not rendered at all.
|
||
*
|
||
* @default false
|
||
*/
|
||
links?: boolean;
|
||
/**
|
||
* Embeddable & iframe elements stay interactive — hovering & clicking
|
||
* activates them so their content can be used, same as in view mode.
|
||
*
|
||
* @default false
|
||
*/
|
||
embeds?: boolean;
|
||
/**
|
||
* Umbrella for all interactive content on canvas — shorthand for
|
||
* enabling `links` & `embeds` (and future interactive content kinds)
|
||
* together. Additive: `interactiveContent: true` enables them
|
||
* regardless of their individual values.
|
||
*
|
||
* @default false
|
||
*/
|
||
interactiveContent?: boolean;
|
||
/**
|
||
* Canvas navigation — panning (pointer drag, wheel, PageUp/PageDown)
|
||
* and zooming (ctrl/cmd + wheel, pinch, and the canvas zoom &
|
||
* zoom-to-fit shortcuts: ctrl/cmd +/-/0, shift+1/2/3), same as in view
|
||
* mode. Respects `appState.scrollConstraints` if set, so it composes
|
||
* with viewport locking. The rest of the keyboard stays disabled. Note
|
||
* the editor consumes wheel & touch input again when enabled, so the
|
||
* page no longer scrolls over the editor.
|
||
*
|
||
* @default false
|
||
*/
|
||
navigation?: boolean;
|
||
/**
|
||
* Whether the browser's own zoom remains available over the editor —
|
||
* ctrl/cmd + wheel, pinch, and (while the editor has focus)
|
||
* ctrl/cmd +/-/0 shortcuts. Prevented by default, mirroring the
|
||
* interactive editor. Regular page scrolling stays available either way.
|
||
* With `navigation` enabled, the zoom input (wheel, pinch, keyboard
|
||
* shortcuts) zooms the canvas instead either way, making this moot.
|
||
*
|
||
* @default false
|
||
*/
|
||
browserZoom?: boolean;
|
||
/**
|
||
* Tools that stay user-driven while the editor is otherwise
|
||
* non-interactive: pointer input keeps driving the listed tool when it's
|
||
* the active tool. Does not enable user-driven tool *switching* — the
|
||
* keyboard stays disabled and tool selection remains host-driven
|
||
* (`ExcalidrawAPI.setActiveTool`).
|
||
*
|
||
* Composes with `navigation`: the enabled tool wins the primary-pointer
|
||
* drag, while wheel input (and wheel-button drag) still pans/zooms.
|
||
*/
|
||
tools?: {
|
||
/**
|
||
* The laser pointer stays usable — pointer strokes draw laser trails
|
||
* and pointer positions keep broadcasting via `onPointerUpdate`, so
|
||
* e.g. collaborators see a presenter's laser & cursor.
|
||
*
|
||
* @default false
|
||
*/
|
||
laser?: boolean;
|
||
/**
|
||
* Custom tools (`activeTool.type === "custom"`) stay usable — the
|
||
* editor keeps dispatching `onPointerDown` / `onPointerUp` for them.
|
||
* Tool behavior is host-implemented; activate custom tools with
|
||
* `locked: true` or they revert to the selection tool (and go inert)
|
||
* after the first pointer interaction.
|
||
*
|
||
* @default false
|
||
*/
|
||
custom?: boolean;
|
||
};
|
||
};
|
||
};
|
||
|
||
export type UIConfig = {
|
||
/**
|
||
* Default UI controls that stay enabled while the rest of Excalidraw's
|
||
* default UI is hidden. Opt-in: anything omitted or `false` is disabled.
|
||
*/
|
||
enabled: {
|
||
/**
|
||
* The zoom-out, reset-zoom, and zoom-in controls.
|
||
*
|
||
* @default false
|
||
*/
|
||
zoom?: boolean;
|
||
/**
|
||
* The button shown when the viewport is scrolled away from all content.
|
||
*
|
||
* @default false
|
||
*/
|
||
scrollBackToContent?: boolean;
|
||
};
|
||
};
|
||
|
||
/** Supported visual changes. Geometry, content, bindings and styles are not overridable. */
|
||
export type ElementRenderOverride = Readonly<{
|
||
/** Absolute render opacity (0–100, clamped). Omitted: use element.opacity. */
|
||
opacity?: number;
|
||
/** Translation in scene units. Bound labels inherit their container's offset and ignore this field. */
|
||
offset?: Readonly<{ x: number; y: number }>;
|
||
}>;
|
||
|
||
/** see {@link ExcalidrawImperativeAPI.setElementRenderOverrides} for details */
|
||
export type ElementRenderOverrides = ReadonlyMap<
|
||
ExcalidrawElement["id"],
|
||
ElementRenderOverride
|
||
>;
|
||
|
||
/** The translation part of a snapshot: only the entries that carry an offset. */
|
||
export type ElementRenderOffsets = ReadonlyMap<
|
||
ExcalidrawElement["id"],
|
||
NonNullable<ElementRenderOverride["offset"]>
|
||
>;
|
||
|
||
export interface ExcalidrawProps {
|
||
className?: string;
|
||
/**
|
||
* Document that owns Excalidraw's mounted DOM.
|
||
*
|
||
* Set only when it differs from the global `document`, such as when code
|
||
* executing in a parent window mounts Excalidraw into an iframe document.
|
||
* The value must remain stable for the editor's lifetime.
|
||
*
|
||
* @default document
|
||
*/
|
||
ownerDocument?: Document;
|
||
onChange?: (
|
||
elements: readonly OrderedExcalidrawElement[],
|
||
appState: AppState,
|
||
files: BinaryFiles,
|
||
) => void;
|
||
onThemeChange?: (theme: Theme | "system") => void;
|
||
/**
|
||
* note: only subscribes if the props.onIncrement is defined on initial render
|
||
*/
|
||
onIncrement?: (event: DurableIncrement | EphemeralIncrement) => void;
|
||
initialData?:
|
||
| (() => MaybePromise<ExcalidrawInitialDataState | null>)
|
||
| MaybePromise<ExcalidrawInitialDataState | null>;
|
||
initialState?: ExcalidrawInitialState;
|
||
/**
|
||
* Invoked as soon as the Excalidraw API is available
|
||
* NOTE editor is not yet mounted, and state is not yet initialized
|
||
*/
|
||
onExcalidrawAPI?: (api: ExcalidrawImperativeAPI | null) => void;
|
||
/**
|
||
* Invoked once the editor root is mounted.
|
||
*/
|
||
onMount?: (payload: ExcalidrawMountPayload) => void;
|
||
/**
|
||
* Invoked when the editor root is unmounted.
|
||
*/
|
||
onUnmount?: () => void;
|
||
/**
|
||
* Invoked once the initial scene is loaded.
|
||
*/
|
||
onInitialize?: (api: ExcalidrawImperativeAPI) => void;
|
||
isCollaborating?: boolean;
|
||
onPointerUpdate?: (payload: {
|
||
pointer: { x: number; y: number; tool: "pointer" | "laser" };
|
||
button: "down" | "up";
|
||
pointersMap: Gesture["pointers"];
|
||
}) => void;
|
||
onPaste?: (
|
||
data: ClipboardData,
|
||
event: ClipboardEvent | null,
|
||
) => Promise<boolean> | boolean;
|
||
/**
|
||
* Called when element(s) are duplicated so you can listen or modify as
|
||
* needed.
|
||
*
|
||
* Called when duplicating via mouse-drag, keyboard, paste, library insert
|
||
* etc.
|
||
*
|
||
* Returned elements will be used in place of the next elements
|
||
* (you should return all elements, including deleted, and not mutate
|
||
* the element if changes are made).
|
||
*
|
||
* The duplicates are the elements in `nextElements` which are not in
|
||
* `prevElements` (see also `data.duplicateElements`). When pasting or
|
||
* inserting onto a frame, their `frameId` is already set. To change a
|
||
* duplicate, return a new object with the same `id`. It is shallow-merged
|
||
* into the duplicate (omitted properties are kept), and your changes are
|
||
* part of the duplication itself (same undo entry, same durable increment).
|
||
*
|
||
* To prevent an element from being duplicated, omit its duplicate from the
|
||
* returned array. References to it from the remaining duplicates are
|
||
* cleared, and a bound text isn't duplicated without its container. To
|
||
* prevent the duplication as a whole, return `false`. If no duplicate
|
||
* remains, the duplication is cancelled and the returned elements are
|
||
* ignored (alt-drag then moves the original elements instead).
|
||
*/
|
||
onDuplicate?: (
|
||
nextElements: readonly ExcalidrawElement[],
|
||
/** excludes the duplicated elements */
|
||
prevElements: readonly ExcalidrawElement[],
|
||
/** lookups covering just the elements taking part in the duplication */
|
||
data: OnDuplicateData,
|
||
) => ExcalidrawElement[] | void | false;
|
||
renderTopLeftUI?: (
|
||
isMobile: boolean,
|
||
appState: UIAppState,
|
||
) => JSX.Element | null;
|
||
renderTopRightUI?: (
|
||
isMobile: boolean,
|
||
appState: UIAppState,
|
||
) => JSX.Element | null;
|
||
langCode?: Language["code"];
|
||
viewModeEnabled?: boolean;
|
||
/**
|
||
* Whether the editor accepts user input (pointer, keyboard, wheel, touch,
|
||
* clipboard, drag&drop). When `false`, the scene still renders and reacts
|
||
* to programmatic updates (imperative API), but the user cannot affect it
|
||
* in any way. Implies view mode.
|
||
*
|
||
* Pass a config object to keep specific interactions enabled while the
|
||
* editor is otherwise non-interactive (see `InteractionConfig`):
|
||
*
|
||
* ```tsx
|
||
* <Excalidraw interaction={{ enabled: { links: true } }} />
|
||
* ```
|
||
*
|
||
* @default true
|
||
*/
|
||
interaction?: boolean | InteractionConfig;
|
||
/**
|
||
* Whether Excalidraw's default UI is rendered — toolbar, default menus,
|
||
* footer controls, sidebars, and canvas popups. Host UI passed through
|
||
* children (including exported components such as `MainMenu` and `Footer`)
|
||
* or render props continues to render, together with any supporting dialogs
|
||
* it opens.
|
||
*
|
||
* Canvas content (elements, text editing surface, frame names, embeds) still
|
||
* renders, and the editor remains interactive unless `interaction` is set to
|
||
* `false`.
|
||
*
|
||
* Pass a config object to keep specific default controls rendered while the
|
||
* rest of the default UI is hidden (see `UIConfig`):
|
||
*
|
||
* ```tsx
|
||
* <Excalidraw ui={{ enabled: { zoom: true } }} />
|
||
* ```
|
||
*
|
||
* NOTE: this is WIP and what default UI is/is not rendered when ui=false
|
||
* may yet change.
|
||
*
|
||
* @default true
|
||
*/
|
||
ui?: boolean | UIConfig;
|
||
/**
|
||
* Forces the active editor tool (controlled). While set, user- and
|
||
* API-driven tool switching is ignored — `setActiveTool` refuses with a
|
||
* console warning, non-forced toolbar buttons render disabled — and the
|
||
* editor snaps back if internal flows reset the tool. The forced tool
|
||
* behaves as if locked (see the tool lock / padlock): it doesn't revert to
|
||
* the selection tool after use, and elements drawn with it aren't
|
||
* auto-selected — without mutating `appState.activeTool.locked`, so the
|
||
* user's persisted padlock preference stays untouched. Unset to return
|
||
* tool control to the editor (the current tool stays active).
|
||
*
|
||
* The forced tool must be activatable to take effect: not disabled via
|
||
* `UIOptions.tools`, and — while the editor is non-interactive — allowed
|
||
* via `interaction.enabled.tools`. Otherwise the editor stays on (or, when
|
||
* non-interactive, resets to) the `selection` tool, and the forced tool is
|
||
* applied once it becomes activatable. `image` cannot be forced (its
|
||
* activation opens the file picker).
|
||
*/
|
||
activeTool?:
|
||
| { type: Exclude<ToolType, "image"> }
|
||
| { type: "custom"; customType: string };
|
||
zenModeEnabled?: boolean;
|
||
gridModeEnabled?: boolean;
|
||
objectsSnapModeEnabled?: boolean;
|
||
libraryReturnUrl?: string;
|
||
theme?: Theme;
|
||
// @TODO come with better API before v0.18.0
|
||
name?: string;
|
||
renderCustomStats?: (
|
||
elements: readonly NonDeletedExcalidrawElement[],
|
||
appState: UIAppState,
|
||
) => JSX.Element;
|
||
UIOptions?: Partial<UIOptions>;
|
||
/**
|
||
* dimensions and size constraints for inserted images
|
||
*/
|
||
imageOptions?: ImageOptions;
|
||
detectScroll?: boolean;
|
||
handleKeyboardGlobally?: boolean;
|
||
onLibraryChange?: (libraryItems: LibraryItems) => void | Promise<any>;
|
||
autoFocus?: boolean;
|
||
generateIdForFile?: (file: File) => string | Promise<string>;
|
||
generateLinkForSelection?: (id: string, type: "element" | "group") => string;
|
||
onLinkOpen?: (
|
||
element: NonDeletedExcalidrawElement,
|
||
event: CustomEvent<{
|
||
nativeEvent: MouseEvent | React.PointerEvent<HTMLCanvasElement>;
|
||
}>,
|
||
) => void;
|
||
onPointerDown?: (
|
||
activeTool: AppState["activeTool"],
|
||
pointerDownState: PointerDownState,
|
||
) => void;
|
||
onPointerUp?: (
|
||
activeTool: AppState["activeTool"],
|
||
pointerDownState: PointerDownState,
|
||
) => void;
|
||
onScrollChange?: (scrollX: number, scrollY: number, zoom: Zoom) => void;
|
||
onUserFollow?: (payload: OnUserFollowedPayload) => void;
|
||
children?: React.ReactNode;
|
||
validateEmbeddable?:
|
||
| boolean
|
||
| string[]
|
||
| RegExp
|
||
| RegExp[]
|
||
| ((link: string) => boolean | undefined);
|
||
renderEmbeddable?: (
|
||
element: NonDeleted<ExcalidrawEmbeddableElement>,
|
||
appState: AppState,
|
||
) => JSX.Element | null;
|
||
aiEnabled?: boolean;
|
||
showDeprecatedFonts?: boolean;
|
||
renderScrollbars?: boolean;
|
||
viewportStatusFrame?: ViewportStatusFrame | null;
|
||
/**
|
||
* Rendered inside the UserList "who's here" dropdown (desktop) and inline
|
||
* in the mobile menu's collaborators section, below a divider. Accepts a
|
||
* render function — called with `isMobile` so hosts can render different
|
||
* UI for each surface — in addition to a plain node.
|
||
*/
|
||
currentUserControls?:
|
||
| React.ReactNode
|
||
| ((isMobile: boolean) => React.ReactNode);
|
||
/**
|
||
* The user being followed on the canvas, if any. Controlled by the host —
|
||
* the editor never sets it; it emits follow/unfollow intents via
|
||
* `onUserFollow` (prop or imperative API) and renders the followed
|
||
* user's avatar highlight from this value.
|
||
*/
|
||
userToFollow?: UserToFollow | null;
|
||
/**
|
||
* Called before exporting to a file.
|
||
*
|
||
* Allows the host app to intercept and delay saving until async operations
|
||
* (e.g., images are loaded) complete.
|
||
*
|
||
* If Promise/AsyncGenerator is returned, a progress toast will be shown
|
||
* until the operation completes. Generator can yield progress updates.
|
||
*/
|
||
onExport?: (
|
||
/** type of export. Currently we only call for JSON exports or
|
||
* JSON-embedded PNG (which is also identified as `json` type here)*/
|
||
type: "json",
|
||
data: {
|
||
elements: readonly ExcalidrawElement[];
|
||
appState: AppState;
|
||
files: BinaryFiles;
|
||
},
|
||
options: {
|
||
/** signal that gets aborted if user cancels the export (e.g. closes
|
||
* the native file picker dialog). In that case, you can either
|
||
* return immediately, or throw AbortError.
|
||
*/
|
||
signal: AbortSignal;
|
||
},
|
||
) => MaybePromise<void> | AsyncGenerator<OnExportProgress, void>;
|
||
}
|
||
|
||
export type SceneData = {
|
||
/** Expects normalized elements; restore imported data before updating the scene. */
|
||
elements?: readonly ExcalidrawElement[] | null;
|
||
appState?: ImportedDataState["appState"];
|
||
collaborators?: Map<SocketId, Collaborator>;
|
||
captureUpdate?: CaptureUpdateActionType;
|
||
};
|
||
|
||
export type ExportOpts = {
|
||
saveFileToDisk?: boolean;
|
||
onExportToBackend?: (
|
||
exportedElements: readonly NonDeletedExcalidrawElement[],
|
||
appState: UIAppState,
|
||
files: BinaryFiles,
|
||
) => void;
|
||
renderCustomUI?: (
|
||
exportedElements: readonly NonDeletedExcalidrawElement[],
|
||
appState: UIAppState,
|
||
files: BinaryFiles,
|
||
canvas: HTMLCanvasElement,
|
||
) => JSX.Element;
|
||
};
|
||
|
||
export type ImageOptions = Partial<{
|
||
maxWidthOrHeight: number;
|
||
maxFileSizeBytes: number;
|
||
}>;
|
||
|
||
// NOTE at the moment, if action name corresponds to canvasAction prop, its
|
||
// truthiness value will determine whether the action is rendered or not
|
||
// (see manager renderAction). We also override canvasAction values in
|
||
// Excalidraw package index.tsx.
|
||
export type CanvasActions = Partial<{
|
||
changeViewBackgroundColor: boolean;
|
||
clearCanvas: boolean;
|
||
export: false | ExportOpts;
|
||
loadScene: boolean;
|
||
saveToActiveFile: boolean;
|
||
/**
|
||
* defaults to true if `props.theme` is omitted or `props.onThemeChange`
|
||
* is supplied (at which point the theme is considered as host-app controlled),
|
||
* else default to false
|
||
* */
|
||
toggleTheme: boolean | null;
|
||
saveAsImage: boolean;
|
||
}>;
|
||
|
||
export type UIOptions = Partial<{
|
||
dockedSidebarBreakpoint: number;
|
||
canvasActions: CanvasActions;
|
||
tools: {
|
||
image: boolean;
|
||
};
|
||
/**
|
||
* Optionally control the editor form factor and desktop UI mode from the host app.
|
||
* If not provided, we will take care of it internally.
|
||
*/
|
||
getFormFactor?: (
|
||
editorWidth: number,
|
||
editorHeight: number,
|
||
) => EditorInterface["formFactor"];
|
||
/** @deprecated does nothing. Will be removed in 0.15 */
|
||
welcomeScreen?: boolean;
|
||
}>;
|
||
|
||
export type AppProps = Merge<
|
||
ExcalidrawProps,
|
||
{
|
||
UIOptions: Merge<
|
||
UIOptions,
|
||
{
|
||
canvasActions: Required<CanvasActions> & { export: ExportOpts };
|
||
}
|
||
>;
|
||
imageOptions: Required<ImageOptions>;
|
||
detectScroll: boolean;
|
||
handleKeyboardGlobally: boolean;
|
||
isCollaborating: boolean;
|
||
children?: React.ReactNode;
|
||
aiEnabled: boolean;
|
||
}
|
||
>;
|
||
|
||
/** A subset of App class properties that we need to use elsewhere
|
||
* in the app, eg Manager. Factored out into a separate type to keep DRY. */
|
||
export type AppClassProperties = {
|
||
props: AppProps;
|
||
state: AppState;
|
||
readonly ownerDocument: Document;
|
||
readonly ownerWindow: Window & typeof globalThis;
|
||
api: App["api"];
|
||
sessionExportThemeOverride: App["sessionExportThemeOverride"];
|
||
interactiveCanvas: HTMLCanvasElement | null;
|
||
/** static canvas */
|
||
canvas: HTMLCanvasElement;
|
||
focusContainer(): void;
|
||
library: Library;
|
||
imageCache: Map<
|
||
FileId,
|
||
{
|
||
image: HTMLImageElement | Promise<HTMLImageElement>;
|
||
mimeType: ValueOf<typeof IMAGE_MIME_TYPES>;
|
||
}
|
||
>;
|
||
files: BinaryFiles;
|
||
editorInterface: App["editorInterface"];
|
||
scene: App["scene"];
|
||
syncActionResult: App["syncActionResult"];
|
||
fonts: App["fonts"];
|
||
clipboard: App["clipboard"];
|
||
id: App["id"];
|
||
onInsertElements: App["onInsertElements"];
|
||
onExportImage: App["onExportImage"];
|
||
viewport: App["viewport"];
|
||
addFiles: App["addFiles"];
|
||
addElementsFromPasteOrLibrary: App["addElementsFromPasteOrLibrary"];
|
||
togglePenMode: App["togglePenMode"];
|
||
toggleLock: App["toggleLock"];
|
||
setActiveTool: App["setActiveTool"];
|
||
setOpenDialog: App["setOpenDialog"];
|
||
insertEmbeddableElement: App["insertEmbeddableElement"];
|
||
onMagicframeToolSelect: App["onMagicframeToolSelect"];
|
||
getName: App["getName"];
|
||
dismissLinearEditor: App["dismissLinearEditor"];
|
||
flowchart: App["flowchart"];
|
||
drawShape: App["drawShape"];
|
||
arrowText: App["arrowText"];
|
||
textTool: App["textTool"];
|
||
cursor: App["cursor"];
|
||
bucketFill: App["bucketFill"];
|
||
duplicate: App["duplicate"];
|
||
toolDrag: App["toolDrag"];
|
||
activeResizeHandle: App["activeResizeHandle"];
|
||
isToolLocked: App["isToolLocked"];
|
||
getEffectiveGridSize: App["getEffectiveGridSize"];
|
||
setPlugins: App["setPlugins"];
|
||
plugins: App["plugins"];
|
||
visibleElements: App["visibleElements"];
|
||
excalidrawContainerValue: App["excalidrawContainerValue"];
|
||
|
||
onPointerUpEmitter: App["onPointerUpEmitter"];
|
||
updateEditorAtom: App["updateEditorAtom"];
|
||
onPointerDownEmitter: App["onPointerDownEmitter"];
|
||
onEvent: App["onEvent"];
|
||
onStateChange: App["onStateChange"];
|
||
|
||
lastPointerMoveCoords: App["lastPointerMoveCoords"];
|
||
lastPointerMoveEvent: App["lastPointerMoveEvent"];
|
||
bindModeHandler: App["bindModeHandler"];
|
||
|
||
emitUserFollowIntent: App["emitUserFollowIntent"];
|
||
requestUnfollow: App["requestUnfollow"];
|
||
|
||
setAppState: App["setAppState"];
|
||
|
||
isInteractionEnabled: App["isInteractionEnabled"];
|
||
isNavigationEnabled: App["isNavigationEnabled"];
|
||
};
|
||
|
||
export type PointerDownState = Readonly<{
|
||
// The first position at which pointerDown happened
|
||
origin: Readonly<{ x: number; y: number }>;
|
||
// Same as "origin" but snapped to the grid, if grid is on
|
||
originInGrid: Readonly<{ x: number; y: number }>;
|
||
// Scrollbar checks
|
||
scrollbars: ReturnType<typeof isOverScrollBars>;
|
||
// The previous pointer position
|
||
lastCoords: { x: number; y: number };
|
||
// original element frozen snapshots so we can access the original
|
||
// element attribute values at time of pointerdown
|
||
originalElements: Map<string, NonDeleted<ExcalidrawElement>>;
|
||
resize: {
|
||
// Handle when resizing, might change during the pointer interaction
|
||
handleType: MaybeTransformHandleType;
|
||
// This is determined on the initial pointer down event
|
||
isResizing: boolean;
|
||
// This is determined on the initial pointer down event
|
||
offset: { x: number; y: number };
|
||
// This is determined on the initial pointer down event
|
||
arrowDirection: "origin" | "end";
|
||
// This is a center point of selected elements determined on the initial pointer down event (for rotation only)
|
||
center: { x: number; y: number };
|
||
};
|
||
hit: {
|
||
// The element the pointer is "hitting", is determined on the initial
|
||
// pointer down event
|
||
element: NonDeleted<ExcalidrawElement> | null;
|
||
// The elements the pointer is "hitting", is determined on the initial
|
||
// pointer down event
|
||
allHitElements: NonDeleted<ExcalidrawElement>[];
|
||
// This is determined on the initial pointer down event
|
||
wasAddedToSelection: boolean;
|
||
// Whether selected element(s) were duplicated, might change during the
|
||
// pointer interaction
|
||
hasBeenDuplicated: boolean;
|
||
// Whether the pointer is hitting the common bounding box of selected
|
||
// elements, which is useful for discriminating between selecitng
|
||
// the entire selection vs a specific element
|
||
hasHitCommonBoundingBoxOfSelectedElements: boolean;
|
||
// Whether the pointer went down on the selected arrow's label, which
|
||
// makes the gesture a label drag along the arrow rather than a point drag
|
||
arrowLabel: boolean;
|
||
};
|
||
// This is determined on the initial pointer down event to
|
||
// set various interaction modalities
|
||
withCmdOrCtrl: boolean;
|
||
drag: {
|
||
// Might change during the pointer interaction
|
||
hasOccurred: boolean;
|
||
// Might change during the pointer interaction
|
||
offset: { x: number; y: number } | null;
|
||
// by default same as PointerDownState.origin. On alt-duplication, reset
|
||
// to current pointer position at time of duplication.
|
||
origin: { x: number; y: number };
|
||
// explicit flag for specific scenarios such as:
|
||
// - after lasso selection until the next pointer down
|
||
blockDragging: boolean;
|
||
};
|
||
// We need to have these in the state so that we can unsubscribe them
|
||
eventListeners: {
|
||
// It's defined on the initial pointer down event
|
||
onMove: null | ReturnType<typeof throttleRAF>;
|
||
// It's defined on the initial pointer down event
|
||
onUp: null | ((event: PointerEvent) => void);
|
||
// It's defined on the initial pointer down event
|
||
onKeyDown: null | ((event: KeyboardEvent) => void);
|
||
// It's defined on the initial pointer down event
|
||
onKeyUp: null | ((event: KeyboardEvent) => void);
|
||
};
|
||
boxSelection: {
|
||
// If the box selection tool is activated on pointer down
|
||
hasOccurred: boolean;
|
||
};
|
||
}>;
|
||
|
||
export type UnsubscribeCallback = () => void;
|
||
|
||
export type ExcalidrawMountPayload = {
|
||
excalidrawAPI: ExcalidrawImperativeAPI;
|
||
/*
|
||
*Excalidraw container.
|
||
* should never be null, but just to be safe
|
||
*/
|
||
container: HTMLDivElement | null;
|
||
};
|
||
|
||
export type ExcalidrawImperativeAPIEventMap = {
|
||
"editor:mount": [payload: ExcalidrawMountPayload];
|
||
"editor:initialize": [api: ExcalidrawImperativeAPI];
|
||
"editor:unmount": [];
|
||
};
|
||
|
||
export interface ExcalidrawImperativeAPI {
|
||
/** Whether the editor has been unmounted and the API is no longer usable. */
|
||
isDestroyed: boolean;
|
||
updateScene: InstanceType<typeof App>["updateScene"];
|
||
applyDeltas: InstanceType<typeof App>["applyDeltas"];
|
||
mutateElement: InstanceType<typeof App>["mutateElement"];
|
||
updateLibrary: InstanceType<typeof Library>["updateLibrary"];
|
||
resetScene: InstanceType<typeof App>["resetScene"];
|
||
getSceneElementsIncludingDeleted: InstanceType<
|
||
typeof App
|
||
>["getSceneElementsIncludingDeleted"];
|
||
getSceneElementsMapIncludingDeleted: InstanceType<
|
||
typeof App
|
||
>["getSceneElementsMapIncludingDeleted"];
|
||
history: {
|
||
clear: InstanceType<typeof App>["resetHistory"];
|
||
};
|
||
getSceneElements: InstanceType<typeof App>["getSceneElements"];
|
||
getAppState: () => InstanceType<typeof App>["state"];
|
||
getFiles: () => InstanceType<typeof App>["files"];
|
||
getName: InstanceType<typeof App>["getName"];
|
||
setViewport: InstanceType<typeof App>["viewport"]["setViewport"];
|
||
getViewportOffsets: InstanceType<typeof App>["viewport"]["getOffsets"];
|
||
/**
|
||
* Atomically replaces all transient visual overrides. Values are copied;
|
||
* omitted IDs/fields use document values, except for inherited label offsets.
|
||
* null clears the snapshot.
|
||
* Repaints without document changes, history entries or onChange events;
|
||
* an equivalent snapshot may still repaint (clearing an already clear
|
||
* snapshot does not), so submit only when something changed.
|
||
* Finite opacity is clamped to 0–100; non-finite values reject the snapshot.
|
||
* Unknown/deleted IDs are ignored when rendering. Reset/unmount clears it.
|
||
* Bound labels inherit their container's offset; offsets targeting them are
|
||
* ignored. Label opacity remains independent. Target frame children explicitly.
|
||
* Frame opacity still multiplies child opacity. Decorations follow their owner.
|
||
* Exports and interactive geometry (hit tests, selection, editing) use document
|
||
* values, including while authoring an animation preview in edit mode.
|
||
*/
|
||
setElementRenderOverrides: InstanceType<
|
||
typeof App
|
||
>["setElementRenderOverrides"];
|
||
registerAction: (action: Action) => void;
|
||
refresh: InstanceType<typeof App>["refresh"];
|
||
setToast: InstanceType<typeof App>["setToast"];
|
||
addFiles: (data: BinaryFileData[]) => void;
|
||
id: string;
|
||
setActiveTool: InstanceType<typeof App>["setActiveTool"];
|
||
setCursor: InstanceType<typeof App>["cursor"]["set"];
|
||
resetCursor: InstanceType<typeof App>["cursor"]["reset"];
|
||
toggleSidebar: InstanceType<typeof App>["toggleSidebar"];
|
||
getEditorInterface: () => EditorInterface;
|
||
/**
|
||
* Disables rendering of frames (including element clipping), but currently
|
||
* the frames are still interactive in edit mode. As such, this API should be
|
||
* used in conjunction with view mode (props.viewModeEnabled).
|
||
*/
|
||
updateFrameRendering: InstanceType<typeof App>["updateFrameRendering"];
|
||
onChange: (
|
||
callback: (
|
||
elements: readonly ExcalidrawElement[],
|
||
appState: AppState,
|
||
files: BinaryFiles,
|
||
) => void,
|
||
) => UnsubscribeCallback;
|
||
onIncrement: (
|
||
callback: (event: DurableIncrement | EphemeralIncrement) => void,
|
||
) => UnsubscribeCallback;
|
||
onPointerDown: (
|
||
callback: (
|
||
activeTool: AppState["activeTool"],
|
||
pointerDownState: PointerDownState,
|
||
event: React.PointerEvent<HTMLElement>,
|
||
) => void,
|
||
) => UnsubscribeCallback;
|
||
onPointerUp: (
|
||
callback: (
|
||
activeTool: AppState["activeTool"],
|
||
pointerDownState: PointerDownState,
|
||
event: PointerEvent,
|
||
) => void,
|
||
) => UnsubscribeCallback;
|
||
onScrollChange: (
|
||
callback: (scrollX: number, scrollY: number, zoom: Zoom) => void,
|
||
) => UnsubscribeCallback;
|
||
onUserFollow: (
|
||
callback: (payload: OnUserFollowedPayload) => void,
|
||
) => UnsubscribeCallback;
|
||
onStateChange: InstanceType<typeof App>["onStateChange"];
|
||
onEvent: InstanceType<typeof App>["onEvent"];
|
||
}
|
||
|
||
export type FrameNameBounds = {
|
||
x: number;
|
||
y: number;
|
||
width: number;
|
||
height: number;
|
||
};
|
||
|
||
export type FrameNameBoundsCache = {
|
||
get: (
|
||
frameElement: ExcalidrawFrameLikeElement | ExcalidrawMagicFrameElement,
|
||
) => FrameNameBounds | null;
|
||
_cache: Map<
|
||
string,
|
||
FrameNameBounds & {
|
||
zoom: AppState["zoom"]["value"];
|
||
versionNonce: ExcalidrawFrameLikeElement["versionNonce"];
|
||
}
|
||
>;
|
||
};
|
||
|
||
export type KeyboardModifiersObject = {
|
||
ctrlKey: boolean;
|
||
shiftKey: boolean;
|
||
altKey: boolean;
|
||
metaKey: boolean;
|
||
};
|
||
|
||
export type Primitive =
|
||
| number
|
||
| string
|
||
| boolean
|
||
| bigint
|
||
| symbol
|
||
| null
|
||
| undefined;
|
||
|
||
export type JSONValue = string | number | boolean | null | object;
|
||
|
||
export type EmbedsValidationStatus = Map<
|
||
ExcalidrawIframeLikeElement["id"],
|
||
boolean
|
||
>;
|
||
|
||
export type ElementsPendingErasure = Set<ExcalidrawElement["id"]>;
|
||
|
||
export type PendingExcalidrawElements = NonDeletedExcalidrawElement[];
|
||
|
||
/** Runtime gridSize value. Null indicates disabled grid. */
|
||
export type NullableGridSize =
|
||
| (AppState["gridSize"] & MakeBrand<"NullableGridSize">)
|
||
| null;
|
||
|
||
export type GenerateDiagramToCode = (props: {
|
||
frame: NonDeleted<ExcalidrawMagicFrameElement>;
|
||
children: readonly NonDeletedExcalidrawElement[];
|
||
/**
|
||
* Optional streaming hook. Call with the accumulated response text as it
|
||
* streams in so the editor can progressively render the partial HTML
|
||
* inside the generated frame.
|
||
*/
|
||
onPartial?: (html: string) => void;
|
||
}) => MaybePromise<{ html: string }>;
|
||
|
||
export type Offsets = Partial<{
|
||
top: number;
|
||
right: number;
|
||
bottom: number;
|
||
left: number;
|
||
}>;
|
||
|
||
/**
|
||
* Value of the `data-viewport-ui` attribute, marking a DOM node as a UI
|
||
* surface that occludes the canvas. Such nodes are measured by
|
||
* `getViewportOffsets` to compute the default per-side viewport offsets:
|
||
*
|
||
* - `top` / `bottom` — offsets that side by the node's bottom/top edge
|
||
* - `side` — a panel hugging the left or right edge. Which side is not
|
||
* declared but resolved geometrically: if the node's horizontal center
|
||
* lies in the left half of the viewport it counts against the left
|
||
* offset (by its right edge), otherwise against the right offset (by
|
||
* `viewportWidth - left edge`). Measuring the rendered position instead
|
||
* of declaring a side means RTL layouts and host-configurable docking
|
||
* (e.g. sidebar side) are handled for free — but it assumes the surface
|
||
* actually hugs one edge; don't mark a centered/near-full-width node as
|
||
* `side` (its midpoint would classify it to one side and the offset
|
||
* would swallow most of the viewport).
|
||
*
|
||
* The attribute should only be present while the surface is actually
|
||
* rendered — omit it (don't just hide the node) when the surface shouldn't
|
||
* push the viewport around.
|
||
*/
|
||
export type ViewportUIDock = "top" | "bottom" | "side";
|
||
|
||
/**
|
||
* Options for `getViewportOffsets` (and the `ui` key of
|
||
* {@link ViewportOffsets}), controlling how offsets are derived from the
|
||
* currently rendered editor UI.
|
||
*
|
||
* NOTE unlike the physical sides of {@link Offsets}, the horizontal values
|
||
* here are logical, i.e. flipped in RTL layouts (`left` refers to the
|
||
* reading-direction start side).
|
||
*/
|
||
export type ViewportOffsetsOptions = {
|
||
/** padding added to each measured side (default 24) */
|
||
padding?: number;
|
||
paddingTop?: number;
|
||
paddingRight?: number;
|
||
paddingBottom?: number;
|
||
paddingLeft?: number;
|
||
/** final value for the given side, replacing the measured UI size
|
||
* (padding is not added on top) */
|
||
top?: number;
|
||
bottom?: number;
|
||
left?: number;
|
||
right?: number;
|
||
/**
|
||
* Reserve space for the given conditionally-rendered surfaces even while
|
||
* they're hidden, so the resulting offsets don't shift when they
|
||
* (dis)appear. Uses the surface's last-measured footprint, falling back
|
||
* to an approximate default if it hasn't been rendered yet. Ignored on
|
||
* phones (where these surfaces never occlude the canvas).
|
||
*/
|
||
reserve?: {
|
||
/** styles panel (rendered when a tool or selection is active) */
|
||
stylesPanel?: boolean;
|
||
/** sidebar (e.g. library) */
|
||
sidebar?: boolean;
|
||
};
|
||
};
|
||
|
||
/**
|
||
* Viewport offsets accepted by the `setViewport`-family APIs (`setViewport`,
|
||
* `props.initialState.viewport`), insetting the usable viewport area per
|
||
* side so the target isn't fitted/centered underneath overlaid UI.
|
||
*
|
||
* Two (combinable) ways to specify:
|
||
*
|
||
* - **Static sides** (`top`/`right`/`bottom`/`left`) — absolute pixel
|
||
* values, used as-is: physical (not flipped in RTL), zoom-independent,
|
||
* no padding added. Sides not specified default to `0` (unless `ui` is
|
||
* set, see below).
|
||
*
|
||
* - **`ui`** — derive the offsets from the editor UI (toolbar, styles
|
||
* panel, sidebar...) as rendered at the time the viewport is set,
|
||
* equivalent to calling `getViewportOffsets()`. Pass `true` for the
|
||
* defaults, or options ({@link ViewportOffsetsOptions}) to customize
|
||
* padding or reserve space for currently-hidden surfaces.
|
||
*
|
||
* When both are given, a static side always wins for that side — it
|
||
* replaces whatever `ui` would yield (including `ui`'s own side overrides,
|
||
* which — unlike the physical static sides — are RTL-relative). The
|
||
* remaining sides fall back to the `ui`-derived values.
|
||
*
|
||
* @example
|
||
* { top: 40 } // top 40px, other sides 0
|
||
* { ui: true } // measured UI + default padding
|
||
* { ui: { reserve: { stylesPanel: true } } } // + keep space for hidden panel
|
||
* { top: 40, ui: true } // top exactly 40px, rest from UI
|
||
*/
|
||
export type ViewportOffsets = Offsets & {
|
||
ui?: true | ViewportOffsetsOptions;
|
||
};
|
||
|
||
/**
|
||
* Value of the `data-viewport-ui-name` attribute, identifying a
|
||
* conditionally-rendered surface (marked with `data-viewport-ui`) so that
|
||
* it can be measured on its own, and so that `getViewportOffsets` can
|
||
* reserve space for it while it's hidden (see the `reserve` option).
|
||
* Whenever a named surface is rendered, its measured footprint is
|
||
* remembered; reserving uses that remembered footprint, or an approximate
|
||
* default if the surface hasn't been rendered yet.
|
||
*/
|
||
export type ViewportUIName = "sidebar" | "stylesPanel" | "stats";
|