UNPKG

@tldraw/editor

Version:

tldraw infinite canvas SDK (editor).

1,587 lines 286 kB
var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __decorateClass = (decorators, target, key, kind) => { var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target; for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result; if (kind && result) __defProp(target, key, result); return result; }; import { EMPTY_ARRAY, atom, computed, react, transact, unsafe__withoutCapture } from "@tldraw/state"; import { reverseRecordsDiff } from "@tldraw/store"; import { CameraRecordType, InstancePageStateRecordType, PageRecordType, TLDOCUMENT_ID, TLINSTANCE_ID, UserRecordType, createBindingId, createShapeId, createUserId, getShapePropKeysByStyle, isPageId, isShapeId } from "@tldraw/tlschema"; import { FileHelpers, PerformanceTracker, Result, ZERO_INDEX_KEY, annotateError, assert, assertExists, bind, compact, debounce, dedupe, exhaustiveSwitchError, fetch, getIndexAbove, getIndexBetween, getIndices, getIndicesAbove, getIndicesBetween, getOwnProperty, hasOwnProperty, last, lerp, minBy, sortById, sortByIndex, structuredClone, uniqueId } from "@tldraw/utils"; import EventEmitter from "eventemitter3"; import { createTLCurrentUser } from "../config/createTLCurrentUser.mjs"; import { checkAssets } from "../config/defaultAssets.mjs"; import { checkBindings } from "../config/defaultBindings.mjs"; import { checkShapesAndAddCore } from "../config/defaultShapes.mjs"; import { getSnapshot, loadSnapshot } from "../config/TLEditorSnapshot.mjs"; import { DEFAULT_ANIMATION_OPTIONS, DEFAULT_CAMERA_OPTIONS, INTERNAL_POINTER_IDS, LEFT_MOUSE_BUTTON, MIDDLE_MOUSE_BUTTON, RIGHT_MOUSE_BUTTON, STYLUS_ERASER_BUTTON } from "../constants.mjs"; import { getOwnerWindow } from "../exports/domUtils.mjs"; import { exportToSvg } from "../exports/exportToSvg.mjs"; import { getSvgAsImageWithOptions, trimSvgToContent } from "../exports/getSvgAsImage.mjs"; import { tlmenus } from "../globals/menus.mjs"; import { tltime } from "../globals/time.mjs"; import { defaultTldrawOptions } from "../options.mjs"; import { Box } from "../primitives/Box.mjs"; import { EASINGS } from "../primitives/easings.mjs"; import { Group2d } from "../primitives/geometry/Group2d.mjs"; import { intersectPolygonPolygon } from "../primitives/intersect.mjs"; import { Mat } from "../primitives/Mat.mjs"; import { PI, approximately, areAnglesCompatible, clamp, pointInPolygon } from "../primitives/utils.mjs"; import { Vec } from "../primitives/Vec.mjs"; import { areShapesContentEqual } from "../utils/areShapesContentEqual.mjs"; import { dataUrlToFile } from "../utils/assets.mjs"; import { debugFlags } from "../utils/debug-flags.mjs"; import { createDeepLinkString, parseDeepLinkString } from "../utils/deepLinks.mjs"; import { getIncrementedName } from "../utils/getIncrementedName.mjs"; import { getReorderingShapesChanges } from "../utils/reorderShapes.mjs"; import { getDroppedShapesToNewParents, kickoutOccludedShapes } from "../utils/reparenting.mjs"; import { applyRotationToSnapshotShapes, getRotationSnapshot } from "../utils/rotation.mjs"; import { SharedStyleMap } from "../utils/SharedStylesMap.mjs"; import { bindingsIndex } from "./derivations/bindingsIndex.mjs"; import { notVisibleShapes } from "./derivations/notVisibleShapes.mjs"; import { parentsToChildren } from "./derivations/parentsToChildren.mjs"; import { deriveShapeIdsInCurrentPage } from "./derivations/shapeIdsInCurrentPage.mjs"; import { ClickManager } from "./managers/ClickManager/ClickManager.mjs"; import { CollaboratorsManager } from "./managers/CollaboratorsManager/CollaboratorsManager.mjs"; import { EdgeScrollManager } from "./managers/EdgeScrollManager/EdgeScrollManager.mjs"; import { FocusManager } from "./managers/FocusManager/FocusManager.mjs"; import { FontManager } from "./managers/FontManager/FontManager.mjs"; import { HistoryManager } from "./managers/HistoryManager/HistoryManager.mjs"; import { InputsManager } from "./managers/InputsManager/InputsManager.mjs"; import { PerformanceManager } from "./managers/PerformanceManager/PerformanceManager.mjs"; import { ScribbleManager } from "./managers/ScribbleManager/ScribbleManager.mjs"; import { SnapManager } from "./managers/SnapManager/SnapManager.mjs"; import { SpatialIndexManager } from "./managers/SpatialIndexManager/SpatialIndexManager.mjs"; import { TextManager } from "./managers/TextManager/TextManager.mjs"; import { ThemeManager, resolveThemes } from "./managers/ThemeManager/ThemeManager.mjs"; import { TickManager } from "./managers/TickManager/TickManager.mjs"; import { UserPreferencesManager } from "./managers/UserPreferencesManager/UserPreferencesManager.mjs"; import { OverlayManager } from "./overlays/OverlayManager.mjs"; import { RootState } from "./tools/RootState.mjs"; class Editor extends EventEmitter { id = uniqueId(); constructor({ store, user, shapeUtils, bindingUtils, assetUtils: assetUtilConstructors, overlayUtils: overlayUtilConstructors, tools, getContainer, // needs to be here for backwards compatibility with TldrawEditor // eslint-disable-next-line @typescript-eslint/no-deprecated cameraOptions, initialState, autoFocus, options: _options, // needs to be here for backwards compatibility with TldrawEditor // eslint-disable-next-line @typescript-eslint/no-deprecated textOptions: _textOptions, getShapeVisibility, colorScheme, fontAssetUrls, themes, initialTheme }) { super(); this._getShapeVisibility = getShapeVisibility; const options = _textOptions ? { ..._options, text: _options?.text ?? _textOptions } : _options; this.options = { ...defaultTldrawOptions, ...options }; this.store = store; this.history = new HistoryManager({ store, annotateError: (error) => { this.annotateError(error, { origin: "history.batch", willCrashApp: true }); this.crash(error); } }); this.snaps = new SnapManager(this); this._spatialIndex = new SpatialIndexManager(this); this.disposables.add(() => this._spatialIndex.dispose()); this.disposables.add(this.timers.dispose); this._cameraOptions.set({ ...DEFAULT_CAMERA_OPTIONS, ...cameraOptions, ...options?.camera }); this.getContainer = getContainer; this._textOptions = atom("text options", options?.text ?? null); this.user = new UserPreferencesManager(user ?? createTLCurrentUser(), colorScheme ?? "light"); this.disposables.add(() => this.user.dispose()); this.textMeasure = new TextManager(this); this.disposables.add(() => this.textMeasure.dispose()); this._themeManager = new ThemeManager(this, { themes: resolveThemes(themes), initial: initialTheme ?? "default" }); this.disposables.add(() => this._themeManager.dispose()); this._tickManager = new TickManager(this); this.disposables.add(() => this._tickManager.dispose()); this.disposables.add(() => { this.off("tick", this._decayCameraStateTimeout); this._setCameraState("idle"); }); this.fonts = new FontManager(this, fontAssetUrls); this.inputs = new InputsManager(this); this.performance = new PerformanceManager(this); this.disposables.add(() => this.performance.dispose()); this.collaborators = new CollaboratorsManager(this); class NewRoot extends RootState { static initial = initialState ?? ""; } this.root = new NewRoot(this); this.root.children = {}; this.markEventAsHandled = this.markEventAsHandled.bind(this); const allShapeUtils = checkShapesAndAddCore(shapeUtils); const _shapeUtils = {}; const _styleProps = {}; const allStylesById = /* @__PURE__ */ new Map(); for (const Util of allShapeUtils) { const util = new Util(this); _shapeUtils[Util.type] = util; const propKeysByStyle = getShapePropKeysByStyle(Util.props ?? {}); _styleProps[Util.type] = propKeysByStyle; for (const style of propKeysByStyle.keys()) { if (!allStylesById.has(style.id)) { allStylesById.set(style.id, style); } else if (allStylesById.get(style.id) !== style) { throw Error( `Multiple style props with id "${style.id}" in use. Style prop IDs must be unique.` ); } } } this.shapeUtils = _shapeUtils; this.styleProps = _styleProps; const _shapeUtilsByAssetType = {}; for (const Util of allShapeUtils) { const assetTypes = Util.handledAssetTypes; if (assetTypes) { for (const assetType of assetTypes) { _shapeUtilsByAssetType[assetType] = _shapeUtils[Util.type]; } } } this._shapeUtilsByAssetType = _shapeUtilsByAssetType; const allBindingUtils = checkBindings(bindingUtils); const _bindingUtils = {}; for (const Util of allBindingUtils) { const util = new Util(this); _bindingUtils[Util.type] = util; } this.bindingUtils = _bindingUtils; if (assetUtilConstructors) { const allAssetUtils = checkAssets(assetUtilConstructors); const _assetUtils = {}; for (const Util of allAssetUtils) { const util = new Util(this); _assetUtils[Util.type] = util; } this.assetUtils = _assetUtils; } for (const Tool of [...tools]) { if (hasOwnProperty(this.root.children, Tool.id)) { throw Error(`Can't override tool with id "${Tool.id}"`); } this.root.children[Tool.id] = new Tool(this, this.root); } this.scribbles = new ScribbleManager(this); this.overlays = new OverlayManager(this); if (overlayUtilConstructors) { for (const Util of overlayUtilConstructors) { const util = new Util(this); this.overlays.registerUtil(util); } } const cleanupInstancePageState = (prevPageState, shapesNoLongerInPage) => { let nextPageState = null; const selectedShapeIds = prevPageState.selectedShapeIds.filter( (id) => !shapesNoLongerInPage.has(id) ); if (selectedShapeIds.length !== prevPageState.selectedShapeIds.length) { if (!nextPageState) nextPageState = { ...prevPageState }; nextPageState.selectedShapeIds = selectedShapeIds; } const erasingShapeIds = prevPageState.erasingShapeIds.filter( (id) => !shapesNoLongerInPage.has(id) ); if (erasingShapeIds.length !== prevPageState.erasingShapeIds.length) { if (!nextPageState) nextPageState = { ...prevPageState }; nextPageState.erasingShapeIds = erasingShapeIds; } if (prevPageState.hoveredShapeId && shapesNoLongerInPage.has(prevPageState.hoveredShapeId)) { if (!nextPageState) nextPageState = { ...prevPageState }; nextPageState.hoveredShapeId = null; } if (prevPageState.editingShapeId && shapesNoLongerInPage.has(prevPageState.editingShapeId)) { if (!nextPageState) nextPageState = { ...prevPageState }; nextPageState.editingShapeId = null; } const hintingShapeIds = prevPageState.hintingShapeIds.filter( (id) => !shapesNoLongerInPage.has(id) ); if (hintingShapeIds.length !== prevPageState.hintingShapeIds.length) { if (!nextPageState) nextPageState = { ...prevPageState }; nextPageState.hintingShapeIds = hintingShapeIds; } if (prevPageState.focusedGroupId && shapesNoLongerInPage.has(prevPageState.focusedGroupId)) { if (!nextPageState) nextPageState = { ...prevPageState }; nextPageState.focusedGroupId = null; } return nextPageState; }; this.sideEffects = this.store.sideEffects; let deletedBindings = /* @__PURE__ */ new Map(); const deletedShapeIds = /* @__PURE__ */ new Set(); const invalidParents = /* @__PURE__ */ new Set(); let invalidBindingTypes = /* @__PURE__ */ new Set(); this.disposables.add( this.sideEffects.registerOperationCompleteHandler(() => { deletedShapeIds.clear(); for (const parentId of invalidParents) { invalidParents.delete(parentId); const parent = this.getShape(parentId); if (!parent) continue; const util = this.getShapeUtil(parent); const changes = util.onChildrenChange?.(parent); if (changes?.length) { this.updateShapes(changes); } } if (invalidBindingTypes.size) { const t = invalidBindingTypes; invalidBindingTypes = /* @__PURE__ */ new Set(); for (const type of t) { const util = this.getBindingUtil(type); util.onOperationComplete?.(); } } if (deletedBindings.size) { const t = deletedBindings; deletedBindings = /* @__PURE__ */ new Map(); for (const opts of t.values()) { this.getBindingUtil(opts.binding).onAfterDelete?.(opts); } } this.emit("update"); }) ); this.disposables.add( this.sideEffects.register({ shape: { afterChange: (shapeBefore, shapeAfter) => { for (const binding of this.getBindingsInvolvingShape(shapeAfter)) { invalidBindingTypes.add(binding.type); if (binding.fromId === shapeAfter.id) { this.getBindingUtil(binding).onAfterChangeFromShape?.({ binding, shapeBefore, shapeAfter, reason: "self" }); } if (binding.toId === shapeAfter.id) { this.getBindingUtil(binding).onAfterChangeToShape?.({ binding, shapeBefore, shapeAfter, reason: "self" }); } } if (shapeBefore.parentId !== shapeAfter.parentId) { const notifyBindingAncestryChange = (id) => { const descendantShape = this.getShape(id); if (!descendantShape) return; for (const binding of this.getBindingsInvolvingShape(descendantShape)) { invalidBindingTypes.add(binding.type); if (binding.fromId === descendantShape.id) { this.getBindingUtil(binding).onAfterChangeFromShape?.({ binding, shapeBefore: descendantShape, shapeAfter: descendantShape, reason: "ancestry" }); } if (binding.toId === descendantShape.id) { this.getBindingUtil(binding).onAfterChangeToShape?.({ binding, shapeBefore: descendantShape, shapeAfter: descendantShape, reason: "ancestry" }); } } }; notifyBindingAncestryChange(shapeAfter.id); this.visitDescendants(shapeAfter.id, notifyBindingAncestryChange); } if (shapeBefore.parentId !== shapeAfter.parentId && isPageId(shapeAfter.parentId)) { const allMovingIds = /* @__PURE__ */ new Set([shapeBefore.id]); this.visitDescendants(shapeBefore.id, (id) => { allMovingIds.add(id); }); for (const instancePageState of this.getPageStates()) { if (instancePageState.pageId === shapeAfter.parentId) continue; const nextPageState = cleanupInstancePageState(instancePageState, allMovingIds); if (nextPageState) { this.store.put([nextPageState]); } } } if (shapeBefore.parentId && isShapeId(shapeBefore.parentId)) { invalidParents.add(shapeBefore.parentId); } if (shapeAfter.parentId !== shapeBefore.parentId && isShapeId(shapeAfter.parentId)) { invalidParents.add(shapeAfter.parentId); } }, beforeDelete: (shape) => { if (deletedShapeIds.has(shape.id)) return; if (shape.parentId && isShapeId(shape.parentId)) { invalidParents.add(shape.parentId); } deletedShapeIds.add(shape.id); const deleteBindingIds = []; for (const binding of this.getBindingsInvolvingShape(shape)) { invalidBindingTypes.add(binding.type); deleteBindingIds.push(binding.id); const util = this.getBindingUtil(binding); if (binding.fromId === shape.id) { util.onBeforeIsolateToShape?.({ binding, removedShape: shape }); util.onBeforeDeleteFromShape?.({ binding, shape }); } else { util.onBeforeIsolateFromShape?.({ binding, removedShape: shape }); util.onBeforeDeleteToShape?.({ binding, shape }); } } if (deleteBindingIds.length) { this.deleteBindings(deleteBindingIds); } const deletedIds = /* @__PURE__ */ new Set([shape.id]); const updates = compact( this.getPageStates().map((pageState) => { return cleanupInstancePageState(pageState, deletedIds); }) ); if (updates.length) { this.store.put(updates); } } }, binding: { beforeCreate: (binding) => { const next = this.getBindingUtil(binding).onBeforeCreate?.({ binding }); if (next) return next; return binding; }, afterCreate: (binding) => { invalidBindingTypes.add(binding.type); this.getBindingUtil(binding).onAfterCreate?.({ binding }); }, beforeChange: (bindingBefore, bindingAfter) => { const updated = this.getBindingUtil(bindingAfter).onBeforeChange?.({ bindingBefore, bindingAfter }); if (updated) return updated; return bindingAfter; }, afterChange: (bindingBefore, bindingAfter) => { invalidBindingTypes.add(bindingAfter.type); this.getBindingUtil(bindingAfter).onAfterChange?.({ bindingBefore, bindingAfter }); }, beforeDelete: (binding) => { this.getBindingUtil(binding).onBeforeDelete?.({ binding }); }, afterDelete: (binding) => { this.getBindingUtil(binding).onAfterDelete?.({ binding }); invalidBindingTypes.add(binding.type); } }, page: { afterCreate: (record) => { const cameraId = CameraRecordType.createId(record.id); const _pageStateId = InstancePageStateRecordType.createId(record.id); if (!this.store.has(cameraId)) { this.store.put([CameraRecordType.create({ id: cameraId })]); } if (!this.store.has(_pageStateId)) { this.store.put([ InstancePageStateRecordType.create({ id: _pageStateId, pageId: record.id }) ]); } }, afterDelete: (record, source) => { if (this.getInstanceState()?.currentPageId === record.id) { const backupPageId = this.getPages().find((p) => p.id !== record.id)?.id; if (backupPageId) { this.store.put([{ ...this.getInstanceState(), currentPageId: backupPageId }]); } else if (source === "user") { this.store.ensureStoreIsUsable(); } } const cameraId = CameraRecordType.createId(record.id); const instance_PageStateId = InstancePageStateRecordType.createId(record.id); this.store.remove([cameraId, instance_PageStateId]); } }, instance: { afterChange: (prev, next, source) => { if (!this.store.has(next.currentPageId)) { const backupPageId = this.store.has(prev.currentPageId) ? prev.currentPageId : this.getPages()[0]?.id; if (backupPageId) { this.store.update(next.id, (instance) => ({ ...instance, currentPageId: backupPageId })); } else if (source === "user") { this.store.ensureStoreIsUsable(); } } } }, instance_page_state: { afterChange: (prev, next) => { if (prev?.selectedShapeIds !== next?.selectedShapeIds) { const filtered = next.selectedShapeIds.filter((id) => { let parentId = this.getShape(id)?.parentId; while (isShapeId(parentId)) { if (next.selectedShapeIds.includes(parentId)) { return false; } parentId = this.getShape(parentId)?.parentId; } return true; }); let nextFocusedGroupId = null; if (filtered.length > 0) { const commonGroupAncestor = this.findCommonAncestor( compact(filtered.map((id) => this.getShape(id))), (shape) => this.isShapeOfType(shape, "group") ); if (commonGroupAncestor) { nextFocusedGroupId = commonGroupAncestor; } } else { if (next?.focusedGroupId) { nextFocusedGroupId = next.focusedGroupId; } } if (filtered.length !== next.selectedShapeIds.length || nextFocusedGroupId !== next.focusedGroupId) { this.store.put([ { ...next, selectedShapeIds: filtered, focusedGroupId: nextFocusedGroupId ?? null } ]); } } } } }) ); this._currentPageShapeIds = deriveShapeIdsInCurrentPage( this.store, () => this.getCurrentPageId() ); this._parentIdsToChildIds = parentsToChildren(this.store); this.disposables.add( this.store.listen((changes) => { this.emit("change", changes); }) ); this.disposables.add(this.history.dispose); this.run( () => { this.store.ensureStoreIsUsable(); this._updateCurrentPageState({ editingShapeId: null, hoveredShapeId: null, erasingShapeIds: [] }); }, { history: "ignore" } ); if (initialState && this.root.children[initialState] === void 0) { throw Error(`No state found for initialState "${initialState}".`); } this.root.enter(void 0, "initial"); this.edgeScrollManager = new EdgeScrollManager(this); this.focusManager = new FocusManager(this, autoFocus); this.disposables.add(this.focusManager.dispose.bind(this.focusManager)); if (this.getInstanceState().followingUserId) { this.stopFollowingUser(); } this.on("tick", this._flushEventsForTick); this.timers.requestAnimationFrame(() => { this._tickManager.start(); }); this.performanceTracker = new PerformanceTracker(); if (this.store.props.collaboration?.mode) { const mode = this.store.props.collaboration.mode; this.disposables.add( react("update collaboration mode", () => { this.store.put([{ ...this.getInstanceState(), isReadonly: mode.get() === "readonly" }]); }) ); } this.disposables.add( react("sync current user record", () => { const user2 = this.store.props.users.currentUser.get(); if (user2) { this._ensureUserRecord(user2); } }) ); } _getShapeVisibility; getIsShapeHiddenCache() { if (!this._getShapeVisibility) return null; return this.store.createComputedCache("isShapeHidden", (shape) => { const visibility = this._getShapeVisibility(shape, this); const isParentHidden = PageRecordType.isId(shape.parentId) ? false : this.isShapeHidden(shape.parentId); if (isParentHidden) return visibility !== "visible"; return visibility === "hidden"; }); } isShapeHidden(shapeOrId) { if (!this._getShapeVisibility) return false; return !!this.getIsShapeHiddenCache().get( typeof shapeOrId === "string" ? shapeOrId : shapeOrId.id ); } options; contextId = uniqueId(); /** * The editor's store * * @public */ store; /** * The root state of the statechart. * * @public */ root; /** * Set a tool. Useful if you need to add a tool to the state chart on demand, * after the editor has already been initialized. * * @param Tool - The tool to set. * @param parent - The parent state node to set the tool on. * * @public */ setTool(Tool, parent) { parent ??= this.root; if (hasOwnProperty(parent.children, Tool.id)) { throw Error(`Can't override tool with id "${Tool.id}"`); } parent.children[Tool.id] = new Tool(this, parent); } /** * Remove a tool. Useful if you need to remove a tool from the state chart on demand, * after the editor has already been initialized. * * @param Tool - The tool to delete. * @param parent - The parent state node to remove the tool from. * * @public */ removeTool(Tool, parent) { parent ??= this.root; if (hasOwnProperty(parent.children, Tool.id)) { delete parent.children[Tool.id]; } } /** * A set of functions to call when the editor is disposed. * * @public */ disposables = /* @__PURE__ */ new Set(); /** * Whether the editor is disposed. * * @public */ isDisposed = false; /** * A manager for the editor's tick events. * * @internal */ _tickManager; /** * A manager for the editor's input state. * * @public */ inputs; /** * A manager for the editor's snapping feature. * * @public */ snaps; /** * A manager for performance measurement hooks. * * @public */ performance; /** * A manager for the spatial index, tracking where shapes exist on the canvas. * * @internal */ _spatialIndex; /** * A manager for the any asynchronous events and making sure they're * cleaned up upon disposal. * * @public */ timers = tltime.forContext(this.contextId); /** * A manager for remote peer collaborators connected to this editor. * * @public */ collaborators; /** * A manager for the user and their preferences. * * @public */ user; /** * A manager for the editor's themes. * * @internal */ _themeManager; /** * A helper for measuring text. * * @public */ textMeasure; /** * A utility for managing the set of fonts that should be rendered in the document. * * @public */ fonts; /** * A manager for the editor's scribbles. * * @public */ scribbles; /** * A manager for canvas overlay UI elements (selection handles, shape handles, etc.). * * @public */ overlays; /** * A manager for side effects and correct state enforcement. See {@link @tldraw/store#StoreSideEffects} for details. * * @public */ sideEffects; /** * A manager for moving the camera when the mouse is at the edge of the screen. * * @public */ edgeScrollManager; /** * A manager for ensuring correct focus. See FocusManager for details. * * @internal */ focusManager; /** * The current HTML element containing the editor. * * @example * ```ts * const container = editor.getContainer() * ``` * * @public */ getContainer; /** * The document that the editor's container element belongs to. * Use this instead of the global `document` to support cross-window embedding. * * @internal */ getContainerDocument() { return this.getContainer().ownerDocument; } /** * The window that the editor's container element belongs to. * Use this instead of the global `window` to support cross-window embedding. * * @internal */ getContainerWindow() { return getOwnerWindow(this.getContainer()); } /** * Dispose the editor. * * @public */ dispose() { this.stopCameraAnimation(); if (this.getInstanceState().followingUserId) { this.stopFollowingUser(); } this.disposables.forEach((dispose) => dispose()); this.disposables.clear(); this.menus.clearOpenMenus(); this.store.dispose(); this.isDisposed = true; this.emit("dispose"); } /* ------------------ Themes (shadowing the theme manager) ------------------ */ /** * Get the current color mode (`'light'` or `'dark'`), based on the user's dark mode preference. * * @public */ getColorMode() { return this._themeManager.getColorMode(); } /** * Set the color mode. Note that this is a convenience method that passes the mode to * `user.updateUserPreferences`, which is the source of truth for the user's color mode preference. * * @public */ setColorMode(mode) { this.user.updateUserPreferences({ colorScheme: mode }); return this; } /** * Get the id of the current theme. * * @public */ getCurrentThemeId() { return this._themeManager.getCurrentThemeId(); } /** * Get the current theme definition. * * @public */ getCurrentTheme() { return this._themeManager.getCurrentTheme(); } /** * Set the current theme by id. * * @public */ setCurrentTheme(id) { this._themeManager.setCurrentTheme(id); return this; } /** * Get all registered theme definitions. * * @public */ getThemes() { return this._themeManager.getThemes(); } /** * Get a single theme definition by id. * * @public */ getTheme(id) { return this._themeManager.getTheme(id); } /** * Replace all theme definitions, or update them via a callback that receives a deep copy. * The `'default'` theme must always be present in the result. * * @example * ```ts * // Replace all themes * editor.updateThemes({ default: myDefaultTheme, ocean: myOceanTheme }) * * // Update via callback * editor.updateThemes((themes) => { * delete themes.ocean * return themes * }) * ``` * * @public */ updateThemes(themes) { this._themeManager.updateThemes(themes); return this; } /** * Register or update a single theme definition. The theme is keyed by its `id` property. * * @example * ```ts * // Override a property on the default theme * editor.updateTheme({ ...editor.getTheme('default')!, fontSize: 24 }) * * // Register a new theme * editor.updateTheme({ id: 'ocean', ...myOceanTheme }) * ``` * * @public */ updateTheme(theme) { this._themeManager.updateTheme(theme); return this; } /* ------------------- Shape Utils ------------------ */ /** * A map of shape utility classes (TLShapeUtils) by shape type. * * @public */ shapeUtils; /** @internal */ _shapeUtilsByAssetType = {}; styleProps; getShapeUtil(arg) { const type = typeof arg === "string" ? arg : arg.type; const shapeUtil = getOwnProperty(this.shapeUtils, type); assert(shapeUtil, `No shape util found for type "${type}"`); return shapeUtil; } hasShapeUtil(arg) { const type = typeof arg === "string" ? arg : arg.type; return hasOwnProperty(this.shapeUtils, type); } /** * Get the shape util that handles the given asset type. * Returns the shape util whose {@link ShapeUtil.handledAssetTypes} includes * the given asset type, or undefined if none matches. * * @param assetType - The asset type string. * @public */ getShapeUtilForAssetType(assetType) { return getOwnProperty(this._shapeUtilsByAssetType, assetType); } /* ------------------- Binding Utils ------------------ */ /** * A map of shape utility classes (TLShapeUtils) by shape type. * * @public */ bindingUtils; getBindingUtil(arg) { const type = typeof arg === "string" ? arg : arg.type; const bindingUtil = getOwnProperty(this.bindingUtils, type); assert(bindingUtil, `No binding util found for type "${type}"`); return bindingUtil; } /* ------------------- Asset Utils ------------------ */ /** * A map of asset utility classes by asset type. * * @public */ assetUtils = {}; getAssetUtil(arg) { const type = typeof arg === "string" ? arg : arg.type; const assetUtil = getOwnProperty(this.assetUtils, type); assert(assetUtil, `No asset util found for type "${type}"`); return assetUtil; } /** * Returns true if the editor has an asset util for the given asset type. * * @public */ hasAssetUtil(arg) { const type = typeof arg === "string" ? arg : arg.type; return hasOwnProperty(this.assetUtils, type); } /** * Get the asset util that accepts the given MIME type. * Returns null if no registered asset util accepts the MIME type. * * @public */ getAssetUtilForMimeType(mimeType) { for (const util of Object.values(this.assetUtils)) { if (util && util.acceptsMimeType(mimeType)) { return util; } } return null; } /* --------------------- History -------------------- */ /** * A manager for the editor's history. * * @readonly */ history; /** * Undo to the last mark. * * @example * ```ts * editor.undo() * ``` * * @public */ undo() { this._flushEventsForTick(0); this.complete(); this.history.undo(); this.performance._notifyUndoRedo("undo", this.history.getNumUndos(), this.history.getNumRedos()); return this; } canUndo() { return this.history.getNumUndos() > 0; } getCanUndo() { return this.canUndo(); } /** * Redo to the next mark. * * @example * ```ts * editor.redo() * ``` * * @public */ redo() { this._flushEventsForTick(0); this.complete(); this.history.redo(); this.performance._notifyUndoRedo("redo", this.history.getNumUndos(), this.history.getNumRedos()); return this; } canRedo() { return this.history.getNumRedos() > 0; } getCanRedo() { return this.canRedo(); } clearHistory() { this.history.clear(); return this; } /** * Create a new "mark", or stopping point, in the undo redo history. Creating a mark will clear * any redos. You typically want to do this just before a user interaction begins or is handled. * * @example * ```ts * editor.markHistoryStoppingPoint() * editor.flipShapes(editor.getSelectedShapes()) * ``` * @example * ```ts * const beginRotateMark = editor.markHistoryStoppingPoint() * // if the use cancels the rotation, you can bail back to this mark * editor.bailToMark(beginRotateMark) * ``` * * @public * @param name - The name of the mark, useful for debugging the undo/redo stacks * @returns a unique id for the mark that can be used with `squashToMark` or `bailToMark`. */ markHistoryStoppingPoint(name) { const id = `[${name ?? "stop"}]_${uniqueId()}`; this.history._mark(id); return id; } /** * @internal this is only used to implement some backwards-compatibility logic. Should be fine to delete after 6 months or whatever. */ getMarkIdMatching(idSubstring) { return this.history.getMarkIdMatching(idSubstring); } /** * Coalesces all changes since the given mark into a single change, removing any intermediate marks. * * This is useful if you need to 'compress' the recent history to simplify the undo/redo experience of a complex interaction. * * @example * ```ts * const bumpShapesMark = editor.markHistoryStoppingPoint() * // ... some changes * editor.squashToMark(bumpShapesMark) * ``` * * @param markId - The mark id to squash to. */ squashToMark(markId) { this.history.squashToMark(markId); return this; } /** * Undo to the closest mark, discarding the changes so they cannot be redone. * * @example * ```ts * editor.bail() * ``` * * @public */ bail() { this.history.bail(); return this; } /** * Undo to the given mark, discarding the changes so they cannot be redone. * * @example * ```ts * const beginDrag = editor.markHistoryStoppingPoint() * // ... some changes * editor.bailToMark(beginDrag) * ``` * * @public */ bailToMark(id) { this.history.bailToMark(id); return this; } _shouldIgnoreShapeLock = false; /** * Run a function in a transaction with optional options for context. * You can use the options to change the way that history is treated * or allow changes to locked shapes. * * @example * ```ts * // updating with * editor.run(() => { * editor.updateShape({ ...myShape, x: 100 }) * }, { history: "ignore" }) * * // forcing changes / deletions for locked shapes * editor.toggleLock([myShape]) * editor.run(() => { * editor.updateShape({ ...myShape, x: 100 }) * editor.deleteShape(myShape) * }, { ignoreShapeLock: true }, ) * ``` * * @param fn - The callback function to run. * @param opts - The options for the batch. * * * @public */ run(fn, opts) { const previousIgnoreShapeLock = this._shouldIgnoreShapeLock; this._shouldIgnoreShapeLock = opts?.ignoreShapeLock ?? previousIgnoreShapeLock; try { this.history.batch(fn, opts); } finally { this._shouldIgnoreShapeLock = previousIgnoreShapeLock; } return this; } /* --------------------- Errors --------------------- */ /** @internal */ annotateError(error, { origin, willCrashApp, tags, extras }) { const defaultAnnotations = this.createErrorAnnotations(origin, willCrashApp); annotateError(error, { tags: { ...defaultAnnotations.tags, ...tags }, extras: { ...defaultAnnotations.extras, ...extras } }); if (willCrashApp) { this.store.markAsPossiblyCorrupted(); } return this; } /** @internal */ createErrorAnnotations(origin, willCrashApp) { try { const editingShapeId = this.getEditingShapeId(); return { tags: { origin, willCrashApp }, extras: { activeStateNode: this.root.getPath(), selectedShapes: this.getSelectedShapes().map((s) => { const { props, ...rest } = s; const { text: _text, richText: _richText, ...restProps } = props; return { ...rest, props: restProps }; }), selectionCount: this.getSelectedShapes().length, editingShape: editingShapeId ? this.getShape(editingShapeId) : void 0, inputs: this.inputs.toJson(), pageState: this.getCurrentPageState(), instanceState: this.getInstanceState(), collaboratorCount: this.getCollaboratorsOnCurrentPage().length } }; } catch { return { tags: { origin, willCrashApp }, extras: {} }; } } /** @internal */ _crashingError = null; /** * We can't use an `atom` here because there's a chance that when `crashAndReportError` is called, * we're in a transaction that's about to be rolled back due to the same error we're currently * reporting. * * Instead, to listen to changes to this value, you need to listen to editor's `crash` event. * * @internal */ getCrashingError() { return this._crashingError; } /** @internal */ crash(error) { this._crashingError = error; this.store.markAsPossiblyCorrupted(); this.emit("crash", { error }); return this; } getPath() { return this.root.getPath().split("root.")[1]; } /** * Get whether a certain tool (or other state node) is currently active. * * @example * ```ts * editor.isIn('select') * editor.isIn('select.brushing') * ``` * * @param path - The path of active states, separated by periods. * * @public */ isIn(path) { const ids = path.split(".").reverse(); let state = this.root; while (ids.length > 0) { const id = ids.pop(); if (!id) return true; const current = state.getCurrent(); if (current?.id === id) { if (ids.length === 0) return true; state = current; continue; } else return false; } return false; } /** * Get whether the state node is in any of the given active paths. * * @example * ```ts * state.isInAny('select', 'erase') * state.isInAny('select.brushing', 'erase.idle') * ``` * * @public */ isInAny(...paths) { return paths.some((path) => this.isIn(path)); } /** * Set the selected tool. * * @example * ```ts * editor.setCurrentTool('hand') * editor.setCurrentTool('hand', { date: Date.now() }) * ``` * * @param id - The id of the tool to select. * @param info - Arbitrary data to pass along into the transition. * * @public */ setCurrentTool(id, info = {}) { this.root.transition(id, info); return this; } getCurrentTool() { return this.root.getCurrent(); } getCurrentToolId() { const currentTool = this.getCurrentTool(); if (!currentTool) return ""; return currentTool.getCurrentToolIdMask() ?? currentTool.id; } /** * Get a descendant by its path. * * @example * ```ts * editor.getStateDescendant('select') * editor.getStateDescendant('select.brushing') * ``` * * @param path - The descendant's path of state ids, separated by periods. * * @public */ getStateDescendant(path) { const ids = path.split(".").reverse(); let state = this.root; while (ids.length > 0) { const id = ids.pop(); if (!id) return state; const childState = state.children?.[id]; if (!childState) return void 0; state = childState; } return state; } getDocumentSettings() { return this.store.get(TLDOCUMENT_ID); } /** * Update the global document settings that apply to all users. * * @public **/ updateDocumentSettings(settings) { this.run( () => { this.store.put([{ ...this.getDocumentSettings(), ...settings }]); }, { history: "ignore" } ); return this; } getInstanceState() { return this.store.get(TLINSTANCE_ID); } /** * Update the instance's state. * * @param partial - A partial object to update the instance state with. * @param historyOptions - History batch options. * * @public */ updateInstanceState(partial, historyOptions) { this._updateInstanceState(partial, { history: "ignore", ...historyOptions }); if (partial.isChangingStyle !== void 0) { clearTimeout(this._isChangingStyleTimeout); if (partial.isChangingStyle === true) { this._isChangingStyleTimeout = this.timers.setTimeout(() => { this._updateInstanceState({ isChangingStyle: false }, { history: "ignore" }); }, 1e3); } } return this; } /** @internal */ _updateInstanceState(partial, opts) { this.run(() => { this.store.put([ { ...this.getInstanceState(), ...partial } ]); }, opts); } /** @internal */ _isChangingStyleTimeout = -1; // Menus menus = tlmenus.forContext(this.contextId); /* --------------------- Cursor --------------------- */ /** * Set the cursor. * * No-op when the partial wouldn't change the current cursor — `setCursor` * is called from pointer-move hot paths (see `updateHoveredOverlayId`, * various tool states) and skipping redundant writes avoids needlessly * dirtying instance state. * * @param cursor - The cursor to set. * @public */ setCursor(cursor) { const current = this.getInstanceState().cursor; if ((cursor.type === void 0 || cursor.type === current.type) && (cursor.rotation === void 0 || cursor.rotation === current.rotation)) { return this; } this.updateInstanceState({ cursor: { ...current, ...cursor } }); return this; } getPageStates() { return this._getPageStatesQuery().get(); } _getPageStatesQuery() { return this.store.query.records("instance_page_state"); } getCurrentPageState() { return this.store.get(this._getCurrentPageStateId()); } _getCurrentPageStateId() { return InstancePageStateRecordType.createId(this.getCurrentPageId()); } /** * Update this instance's page state. * * @example * ```ts * editor.updateCurrentPageState({ id: 'page1', editingShapeId: 'shape:123' }) * ``` * * @param partial - The partial of the page state object containing the changes. * * @public */ updateCurrentPageState(partial) { this._updateCurrentPageState(partial); return this; } _updateCurrentPageState(partial) { this.store.update(partial.id ?? this.getCurrentPageState().id, (state) => ({ ...state, ...partial })); } getSelectedShapeIds() { return this.getCurrentPageState().selectedShapeIds; } getSelectedShapes() { return compact(this.getSelectedShapeIds().map((id) => this.store.get(id))); } /** * Select one or more shapes. * * @example * ```ts * editor.setSelectedShapes(['id1']) * editor.setSelectedShapes(['id1', 'id2']) * ``` * * @param shapes - The shape (or shape ids) to select. * * @public */ setSelectedShapes(shapes) { return this.run( () => { const ids = shapes.map((shape) => typeof shape === "string" ? shape : shape.id); const { selectedShapeIds: prevSelectedShapeIds } = this.getCurrentPageState(); const prevSet = new Set(prevSelectedShapeIds); if (ids.length === prevSet.size && ids.every((id) => prevSet.has(id))) return null; this.store.put([{ ...this.getCurrentPageState(), selectedShapeIds: ids }]); }, { history: "record-preserveRedoStack" } ); } /** * Determine whether or not any of a shape's ancestors are selected. * * @param shape - The shape (or shape id) of the shape to check. * * @public */ isAncestorSelected(shape) { const id = typeof shape === "string" ? shape : shape?.id ?? null; const _shape = this.getShape(id); if (!_shape) return false; const selectedShapeIds = this.getSelectedShapeIds(); return !!this.findShapeAncestor(_shape, (parent) => selectedShapeIds.includes(parent.id)); } /** * Select one or more shapes. * * @example * ```ts * editor.select('id1') * editor.select('id1', 'id2') * ``` * * @param shapes - The shape (or the shape ids) to select. * * @public */ select(...shapes) { const ids = typeof shapes[0] === "string" ? shapes : shapes.map((shape) => shape.id); this.setSelectedShapes(ids); return this; } /** * Remove a shape from the existing set of selected shapes. * * @example * ```ts * editor.deselect(shape.id) * ``` * * @public */ deselect(...shapes) { const ids = typeof shapes[0] === "string" ? shapes : shapes.map((shape) => shape.id); const selectedShapeIds = this.getSelectedShapeIds(); if (selectedShapeIds.length > 0 && ids.length > 0) { this.setSelectedShapes(selectedShapeIds.filter((id) => !ids.includes(id))); } return this; } /** * Select all shapes. If the user has selected shapes that share a parent, * select all shapes within that parent. If the user has not selected any shapes, * or if the shapes shapes are only on select all shapes on the current page. * * @example * ```ts * editor.selectAll() * ``` * * @public */ selectAll() { let parentToSelectWithinId = null; const selectedShapeIds = this.getSelectedShapeIds(); if (selectedShapeIds.length > 0) { for (const id of selectedShapeIds) { const shape = this.getShape(id); if (!shape) continue; if (parentToSelectWithinId === null) { parentToSelectWithinId = shape.parentId; } else if (parentToSelectWithinId !== shape.parentId) { return this; } } } if (!parentToSelectWithinId) { parentToSelectWithinId = this.getCurrentPageId(); } const ids = this.getSortedChildIdsForParent(parentToSelectWithinId); if (ids.length <= 0) return this; this.setSelectedShapes(this._getUnlockedShapeIds(ids)); return this; } /** * Select the next shape in the reading order or in cardinal order. * * @example * ```ts * editor.selectAdjacentShape('next') * ``` * * @public */ selectAdjacentShape(direction) { const selectedShapeIds = this.getSelectedShapeIds(); const firstParentId = selectedShapeIds[0] ? this.getShape(selectedShapeIds[0])?.parentId : null; const isSelectedWithinContainer = firstParentId && selectedShapeIds.every((shapeId) => this.getShape(shapeId)?.parentId === firstParentId) && !isPageId(firstParentId); const filteredShapes = isSelectedWithinContainer ? this.getCurrentPageShapes().filter((shape2) => shape2.parentId === firstParentId) : this.getCurrentPageShapes().filter((shape2) => isPageId(shape2.parentId)); const readingOrderShapes = isSelectedWithinContainer ? this._getShapesInRead