UNPKG

@tldraw/editor

Version:

tldraw infinite canvas SDK (editor).

1,807 lines (1,636 loc) • 332 kB
import { Atom, EMPTY_ARRAY, atom, computed, react, transact, unsafe__withoutCapture, } from '@tldraw/state' import { ComputedCache, RecordType, StoreSideEffects, StoreSnapshot, UnknownRecord, reverseRecordsDiff, } from '@tldraw/store' import { CameraRecordType, InstancePageStateRecordType, PageRecordType, StyleProp, StylePropValue, TLAsset, TLAssetId, TLAssetPartial, TLBinding, TLBindingCreate, TLBindingId, TLBindingUpdate, TLCamera, TLCreateShapePartial, TLCursor, TLCursorType, TLDOCUMENT_ID, TLDocument, TLGroupShape, TLHandle, TLINSTANCE_ID, TLImageAsset, TLInstance, TLInstancePageState, TLInstancePresence, TLPage, TLPageId, TLParentId, TLRecord, TLShape, TLShapeId, TLShapePartial, TLStore, TLStoreSnapshot, TLTheme, TLThemeId, TLThemes, TLUser, TLUserId, TLVideoAsset, UserRecordType, createBindingId, createShapeId, createUserId, getShapePropKeysByStyle, isPageId, isShapeId, } from '@tldraw/tlschema' import { FileHelpers, IndexKey, JsonObject, 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 { TLCurrentUser, createTLCurrentUser } from '../config/createTLCurrentUser' import { TLAnyAssetUtilConstructor, checkAssets } from '../config/defaultAssets' import { TLAnyBindingUtilConstructor, checkBindings } from '../config/defaultBindings' import { TLAnyShapeUtilConstructor, checkShapesAndAddCore } from '../config/defaultShapes' import { TLEditorSnapshot, TLLoadSnapshotOptions, getSnapshot, loadSnapshot, } from '../config/TLEditorSnapshot' import { DEFAULT_ANIMATION_OPTIONS, DEFAULT_CAMERA_OPTIONS, INTERNAL_POINTER_IDS, LEFT_MOUSE_BUTTON, MIDDLE_MOUSE_BUTTON, RIGHT_MOUSE_BUTTON, STYLUS_ERASER_BUTTON, } from '../constants' import { getOwnerWindow } from '../exports/domUtils' import { exportToSvg } from '../exports/exportToSvg' import { getSvgAsImageWithOptions, trimSvgToContent } from '../exports/getSvgAsImage' import { tlmenus } from '../globals/menus' import { tltime } from '../globals/time' import { TldrawOptions, defaultTldrawOptions } from '../options' import { Box, BoxLike } from '../primitives/Box' import { EASINGS } from '../primitives/easings' import { Geometry2d } from '../primitives/geometry/Geometry2d' import { Group2d } from '../primitives/geometry/Group2d' import { intersectPolygonPolygon } from '../primitives/intersect' import { Mat, MatLike } from '../primitives/Mat' import { PI, approximately, areAnglesCompatible, clamp, pointInPolygon } from '../primitives/utils' import { Vec, VecLike } from '../primitives/Vec' import { areShapesContentEqual } from '../utils/areShapesContentEqual' import { dataUrlToFile } from '../utils/assets' import { debugFlags } from '../utils/debug-flags' import { TLDeepLink, TLDeepLinkOptions, createDeepLinkString, parseDeepLinkString, } from '../utils/deepLinks' import { getIncrementedName } from '../utils/getIncrementedName' import { getReorderingShapesChanges } from '../utils/reorderShapes' import { getDroppedShapesToNewParents, kickoutOccludedShapes } from '../utils/reparenting' import { TLTextOptions, TiptapEditor } from '../utils/richText' import { applyRotationToSnapshotShapes, getRotationSnapshot } from '../utils/rotation' import { ReadonlySharedStyleMap, SharedStyle, SharedStyleMap } from '../utils/SharedStylesMap' import { AssetUtil } from './assets/AssetUtil' import { BindingOnDeleteOptions, BindingUtil } from './bindings/BindingUtil' import { bindingsIndex } from './derivations/bindingsIndex' import { notVisibleShapes } from './derivations/notVisibleShapes' import { parentsToChildren } from './derivations/parentsToChildren' import { deriveShapeIdsInCurrentPage } from './derivations/shapeIdsInCurrentPage' import { ClickManager } from './managers/ClickManager/ClickManager' import { CollaboratorsManager } from './managers/CollaboratorsManager/CollaboratorsManager' import { EdgeScrollManager } from './managers/EdgeScrollManager/EdgeScrollManager' import { FocusManager } from './managers/FocusManager/FocusManager' import { FontManager } from './managers/FontManager/FontManager' import { HistoryManager } from './managers/HistoryManager/HistoryManager' import { InputsManager } from './managers/InputsManager/InputsManager' import { PerformanceManager } from './managers/PerformanceManager/PerformanceManager' import { ScribbleManager } from './managers/ScribbleManager/ScribbleManager' import { SnapManager } from './managers/SnapManager/SnapManager' import { SpatialIndexManager } from './managers/SpatialIndexManager/SpatialIndexManager' import { TextManager } from './managers/TextManager/TextManager' import { ThemeManager, resolveThemes } from './managers/ThemeManager/ThemeManager' import { TickManager } from './managers/TickManager/TickManager' import { UserPreferencesManager } from './managers/UserPreferencesManager/UserPreferencesManager' import { OverlayManager } from './overlays/OverlayManager' import { TLAnyOverlayUtilConstructor } from './overlays/OverlayUtil' import { ShapeUtil, TLEditStartInfo, TLGeometryOpts, TLResizeMode, TLShapeUtilCanBeLaidOutOpts, TLShapeUtilCanBindOpts, } from './shapes/ShapeUtil' import { RootState } from './tools/RootState' import { StateNode, TLStateNodeConstructor } from './tools/StateNode' import { TLContent } from './types/clipboard-types' import { TLEventMap } from './types/emit-types' import { TLEventInfo, TLPointerEventInfo } from './types/event-types' import { TLExternalAsset, TLExternalContent } from './types/external-content' import { TLHistoryBatchOptions } from './types/history-types' import { OptionalKeys, RequiredKeys, TLCameraMoveOptions, TLCameraOptions, TLGetShapeAtPointOptions, TLImageExportOptions, TLSvgExportOptions, TLUpdatePointerOptions, } from './types/misc-types' import { TLAdjacentDirection, TLResizeHandle } from './types/selection-types' /** @public */ export type TLResizeShapeOptions = Partial<{ initialBounds: Box scaleOrigin: VecLike scaleAxisRotation: number initialShape: TLShape initialPageTransform: MatLike dragHandle: TLResizeHandle isAspectRatioLocked: boolean mode: TLResizeMode skipStartAndEndCallbacks: boolean }> /** @public */ export interface TLEditorOptions { /** * The Store instance to use for keeping the editor's data. This may be prepopulated, e.g. by loading * from a server or database. */ store: TLStore /** * An array of shapes to use in the editor. These will be used to create and manage shapes in the editor. */ shapeUtils: readonly TLAnyShapeUtilConstructor[] /** * An array of bindings to use in the editor. These will be used to create and manage bindings in the editor. */ bindingUtils: readonly TLAnyBindingUtilConstructor[] /** * An array of asset utils to use in the editor. These will be used to handle asset-type-specific behavior. */ assetUtils?: readonly TLAnyAssetUtilConstructor[] /** * An array of overlay utils to use in the editor. These define canvas overlay UI elements * like selection handles, rotation corners, shape handles, etc. */ overlayUtils?: readonly TLAnyOverlayUtilConstructor[] /** * An array of tools to use in the editor. These will be used to handle events and manage user interactions in the editor. */ tools: readonly TLStateNodeConstructor[] /** * A user defined externally to replace the default user. */ user?: TLCurrentUser /** * The editor's initial active tool (or other state node id). */ initialState?: string /** * Whether to automatically focus the editor when it mounts. */ autoFocus?: boolean licenseKey?: string fontAssetUrls?: { [key: string]: string | undefined } /** * Should return a containing html element which has all the styles applied to the editor. If not * given, the body element will be used. */ getContainer(): HTMLElement /** * Provides a way to hide shapes. * * @example * ```ts * getShapeVisibility={(shape, editor) => shape.meta.hidden ? 'hidden' : 'inherit'} * ``` * * - `'inherit' | undefined` - (default) The shape will be visible unless its parent is hidden. * - `'hidden'` - The shape will be hidden. * - `'visible'` - The shape will be visible. * * @param shape - The shape to check. * @param editor - The editor instance. */ getShapeVisibility?( shape: TLShape, editor: Editor ): 'visible' | 'hidden' | 'inherit' | null | undefined /** * Named theme definitions for the editor. Each theme contains shared * properties (font size, line height, stroke width) and color palettes * for both light and dark modes. */ themes?: Partial<TLThemes> /** * The id of the initially active theme. Defaults to `'default'`. */ initialTheme?: TLThemeId /** * The editor's color scheme preference, controls the default color mode. Defaults to `'light'`. * * - `'light'` - Always use light mode. * - `'dark'` - Always use dark mode. * - `'system'` - Follow the OS color scheme preference. */ colorScheme?: 'light' | 'dark' | 'system' /** * Additional configuration options for the tldraw editor. */ options?: Partial<TldrawOptions> // --- Deprecated ---- /** * Options for the editor's camera. * * @deprecated Use `options.cameraOptions` instead. This will be removed in a future release. */ cameraOptions?: Partial<TLCameraOptions> /** * Text options for the editor. * * @deprecated Use `options.text` instead. This prop will be removed in a future release. */ textOptions?: TLTextOptions } /** * Options for {@link Editor.(run:1)}. * @public */ export interface TLEditorRunOptions extends TLHistoryBatchOptions { ignoreShapeLock?: boolean } /** @public */ export interface TLRenderingShape { id: TLShapeId shape: TLShape util: ShapeUtil index: number backgroundIndex: number opacity: number } /** @public */ export class Editor extends EventEmitter<TLEventMap> { readonly 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, }: TLEditorOptions) { super() this._getShapeVisibility = getShapeVisibility // Merge deprecated textOptions prop with options.text // options.text takes precedence over the deprecated textOptions prop const options = _textOptions ? { ..._options, text: _options?.text ?? _textOptions } : _options this.options = { ...defaultTldrawOptions, ...options } this.store = store this.history = new HistoryManager<TLRecord>({ store, annotateError: (error: any) => { 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) // Merge camera options: options.cameraOptions takes precedence over deprecated cameraOptions prop 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(() => { // Reset camera state to 'idle' so the store isn't left stuck at 'moving' // when tick events stop (e.g. React strict mode disposes while camera is moving) 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 override initial = initialState ?? '' } this.root = new NewRoot(this) this.root.children = {} this.markEventAsHandled = this.markEventAsHandled.bind(this) const allShapeUtils = checkShapesAndAddCore(shapeUtils) const _shapeUtils = {} as Record<string, ShapeUtil<any>> const _styleProps = {} as Record<string, Map<StyleProp<unknown>, string>> const allStylesById = new Map<string, StyleProp<unknown>>() 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 = {} as Record<string, ShapeUtil<any>> 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 = {} as Record<string, BindingUtil<any>> for (const Util of allBindingUtils) { const util = new Util(this) _bindingUtils[Util.type] = util } this.bindingUtils = _bindingUtils // Asset utils if (assetUtilConstructors) { const allAssetUtils = checkAssets(assetUtilConstructors) const _assetUtils = {} as Record<string, AssetUtil<any>> for (const Util of allAssetUtils) { const util = new Util(this) _assetUtils[Util.type] = util } this.assetUtils = _assetUtils } // Tools. // Accept tools from constructor parameters which may not conflict with the root note's default or // "baked in" tools, select and zoom. 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) // Overlay utils this.overlays = new OverlayManager(this) if (overlayUtilConstructors) { for (const Util of overlayUtilConstructors) { const util = new Util(this) this.overlays.registerUtil(util) } } // Cleanup const cleanupInstancePageState = ( prevPageState: TLInstancePageState, shapesNoLongerInPage: Set<TLShapeId> ) => { let nextPageState = null as null | TLInstancePageState 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 = new Map<TLBindingId, BindingOnDeleteOptions<any>>() const deletedShapeIds = new Set<TLShapeId>() const invalidParents = new Set<TLShapeId>() let invalidBindingTypes = new Set<TLBinding['type']>() this.disposables.add( this.sideEffects.registerOperationCompleteHandler(() => { // this needs to be cleared here because further effects may delete more shapes // and we want the next invocation of this handler to handle those separately 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 = new Set() for (const type of t) { const util = this.getBindingUtil(type) util.onOperationComplete?.() } } if (deletedBindings.size) { const t = deletedBindings deletedBindings = 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 the shape's parent changed and it has a binding, update the binding if (shapeBefore.parentId !== shapeAfter.parentId) { const notifyBindingAncestryChange = (id: TLShapeId) => { 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 this shape moved to a new page, clean up any previous page's instance state if (shapeBefore.parentId !== shapeAfter.parentId && isPageId(shapeAfter.parentId)) { const allMovingIds = 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 we triggered this delete with a recursive call, don't do anything if (deletedShapeIds.has(shape.id)) return // if the deleted shape has a parent shape make sure we call it's onChildrenChange callback if (shape.parentId && isShapeId(shape.parentId)) { invalidParents.add(shape.parentId) } deletedShapeIds.add(shape.id) const deleteBindingIds: TLBindingId[] = [] 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 = 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) => { // page was deleted, need to check whether it's the current page and select another one if so 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') { // fall back to ensureStoreIsUsable: this.store.ensureStoreIsUsable() } } // delete the camera and state for the page if necessary const cameraId = CameraRecordType.createId(record.id) const instance_PageStateId = InstancePageStateRecordType.createId(record.id) this.store.remove([cameraId, instance_PageStateId]) }, }, instance: { afterChange: (prev, next, source) => { // instance should never be updated to a page that no longer exists (this can // happen when undoing a change that involves switching to a page that has since // been deleted by another user) 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') { // fall back to ensureStoreIsUsable: this.store.ensureStoreIsUsable() } } }, }, instance_page_state: { afterChange: (prev, next) => { if (prev?.selectedShapeIds !== next?.selectedShapeIds) { // ensure that descendants and ancestors are not selected at the same time 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 | TLShapeId = 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() // clear ephemeral state this._updateCurrentPageState({ editingShapeId: null, hoveredShapeId: null, erasingShapeIds: [], }) }, { history: 'ignore' } ) if (initialState && this.root.children[initialState] === undefined) { throw Error(`No state found for initialState "${initialState}".`) } this.root.enter(undefined, '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 user = this.store.props.users.currentUser.get() if (user) { this._ensureUserRecord(user) } }) ) } private readonly _getShapeVisibility?: TLEditorOptions['getShapeVisibility'] @computed private getIsShapeHiddenCache() { if (!this._getShapeVisibility) return null return this.store.createComputedCache<boolean, TLShape>('isShapeHidden', (shape: TLShape) => { 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: TLShape | TLShapeId): boolean { if (!this._getShapeVisibility) return false return !!this.getIsShapeHiddenCache!()!.get( typeof shapeOrId === 'string' ? shapeOrId : shapeOrId.id ) } readonly options: TldrawOptions readonly contextId = uniqueId() /** * The editor's store * * @public */ readonly store: TLStore /** * The root state of the statechart. * * @public */ readonly root: StateNode /** * 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: TLStateNodeConstructor, parent?: StateNode) { 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: TLStateNodeConstructor, parent?: StateNode) { 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 */ readonly disposables = new Set<() => void>() /** * Whether the editor is disposed. * * @public */ isDisposed = false /** * A manager for the editor's tick events. * * @internal */ private readonly _tickManager: TickManager /** * A manager for the editor's input state. * * @public */ readonly inputs: InputsManager /** * A manager for the editor's snapping feature. * * @public */ readonly snaps: SnapManager /** * A manager for performance measurement hooks. * * @public */ readonly performance: PerformanceManager /** * A manager for the spatial index, tracking where shapes exist on the canvas. * * @internal */ private readonly _spatialIndex: SpatialIndexManager /** * A manager for the any asynchronous events and making sure they're * cleaned up upon disposal. * * @public */ readonly timers = tltime.forContext(this.contextId) /** * A manager for remote peer collaborators connected to this editor. * * @public */ readonly collaborators: CollaboratorsManager /** * A manager for the user and their preferences. * * @public */ readonly user: UserPreferencesManager /** * A manager for the editor's themes. * * @internal */ private readonly _themeManager: ThemeManager /** * A helper for measuring text. * * @public */ readonly textMeasure: TextManager /** * A utility for managing the set of fonts that should be rendered in the document. * * @public */ readonly fonts: FontManager /** * A manager for the editor's scribbles. * * @public */ readonly scribbles: ScribbleManager /** * A manager for canvas overlay UI elements (selection handles, shape handles, etc.). * * @public */ readonly overlays: OverlayManager /** * A manager for side effects and correct state enforcement. See {@link @tldraw/store#StoreSideEffects} for details. * * @public */ readonly sideEffects: StoreSideEffects<TLRecord> /** * A manager for moving the camera when the mouse is at the edge of the screen. * * @public */ edgeScrollManager: EdgeScrollManager /** * A manager for ensuring correct focus. See FocusManager for details. * * @internal */ private focusManager: FocusManager /** * The current HTML element containing the editor. * * @example * ```ts * const container = editor.getContainer() * ``` * * @public */ getContainer: () => HTMLElement /** * The document that the editor's container element belongs to. * Use this instead of the global `document` to support cross-window embedding. * * @internal */ getContainerDocument(): Document { 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(): Window & typeof globalThis { return getOwnerWindow(this.getContainer()) } /** * Dispose the editor. * * @public */ dispose() { // Stop any in-progress camera animations and following before // running disposables, so their cleanup listeners fire first this.stopCameraAnimation() if (this.getInstanceState().followingUserId) { this.stopFollowingUser() } this.disposables.forEach((dispose) => dispose()) this.disposables.clear() // Clear any open menus for this editor's context 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(): 'light' | 'dark' { 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: 'light' | 'dark') { this.user.updateUserPreferences({ colorScheme: mode }) return this } /** * Get the id of the current theme. * * @public */ getCurrentThemeId(): TLThemeId { return this._themeManager.getCurrentThemeId() } /** * Get the current theme definition. * * @public */ getCurrentTheme(): TLTheme { return this._themeManager.getCurrentTheme() } /** * Set the current theme by id. * * @public */ setCurrentTheme(id: TLThemeId) { this._themeManager.setCurrentTheme(id) return this } /** * Get all registered theme definitions. * * @public */ getThemes(): TLThemes { return this._themeManager.getThemes() } /** * Get a single theme definition by id. * * @public */ getTheme(id: TLThemeId): TLTheme | undefined { 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: TLThemes | ((themes: TLThemes) => TLThemes)) { 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: TLTheme) { this._themeManager.updateTheme(theme) return this } /* ------------------- Shape Utils ------------------ */ /** * A map of shape utility classes (TLShapeUtils) by shape type. * * @public */ shapeUtils: { readonly [K in string]?: ShapeUtil<TLShape> } /** @internal */ private _shapeUtilsByAssetType: { readonly [K in string]?: ShapeUtil<TLShape> } = {} styleProps: { [key: string]: Map<StyleProp<any>, string> } /** * Get a shape util from a shape itself. * * @example * ```ts * const util = editor.getShapeUtil(myArrowShape) * const util = editor.getShapeUtil('arrow') * const util = editor.getShapeUtil<TLArrowShape>(myArrowShape) * const util = editor.getShapeUtil(TLArrowShape)('arrow') * ``` * * @param shape - A shape, shape partial, or shape type. * * @public */ getShapeUtil<K extends TLShape['type']>(type: K): ShapeUtil<Extract<TLShape, { type: K }>> getShapeUtil<S extends TLShape>(shape: S | TLShapePartial<S> | S['type']): ShapeUtil<S> getShapeUtil<T extends ShapeUtil>(type: T extends ShapeUtil<infer R> ? R['type'] : string): T getShapeUtil(arg: string | { type: string }) { 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 } /** * Returns true if the editor has a shape util for the given shape / shape type. * * @param shape - A shape, shape partial, or shape type. */ hasShapeUtil(shape: TLShape | TLShapePartial<TLShape>): boolean hasShapeUtil(type: TLShape['type']): boolean hasShapeUtil<T extends ShapeUtil>( type: T extends ShapeUtil<infer R> ? R['type'] : string ): boolean hasShapeUtil(arg: string | { type: string }): boolean { 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: string): ShapeUtil | undefined { return getOwnProperty(this._shapeUtilsByAssetType, assetType) } /* ------------------- Binding Utils ------------------ */ /** * A map of shape utility classes (TLShapeUtils) by shape type. * * @public */ bindingUtils: { readonly [K in string]?: BindingUtil<TLBinding> } /** * Get a binding util from a binding itself. * * @example * ```ts * const util = editor.getBindingUtil(myArrowBinding) * const util = editor.getBindingUtil('arrow') * const util = editor.getBindingUtil<TLArrowBinding>(myArrowBinding) * const util = editor.getBindingUtil(TLArrowBinding)('arrow') * ``` * * @param binding - A binding, binding partial, or binding type. * * @public */ getBindingUtil<K extends TLBinding['type']>(type: K): BindingUtil<Extract<TLBinding, { type: K }>> getBindingUtil<S extends TLBinding>(binding: S | { type: S['type'] }): BindingUtil<S> getBindingUtil<T extends BindingUtil>( type: T extends BindingUtil<infer R> ? R['type'] : string ): T getBindingUtil(arg: string | { type: string }) { 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: { readonly [K in string]?: AssetUtil<TLAsset> } = {} /** * Get an asset util from an asset or asset type. * * @param arg - An asset, asset type string, or object with type. * * @public */ getAssetUtil<S extends TLAsset>(asset: S | { type: S['type'] }): AssetUtil<S> getAssetUtil(type: string): AssetUtil getAssetUtil(arg: string | { type: string }) { 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: string | { type: string }): boolean { 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: string): AssetUtil | null { 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 */ protected readonly history: HistoryManager<TLRecord> /** * Undo to the last mark. * * @example * ```ts * editor.undo() * ``` * * @public */ undo(): this { this._flushEventsForTick(0) this.complete() this.history.undo() this.performance._notifyUndoRedo('undo', this.history.getNumUndos(), this.history.getNumRedos()) return this } /** * Whether the editor can undo. * * @public */ @computed canUndo(): boolean { return this.history.getNumUndos() > 0 } getCanUndo() { return this.canUndo() } /** * Redo to the next mark. * * @example * ```ts * editor.redo() * ``` * * @public */ redo(): this { this._flushEventsForTick(0) this.complete() this.history.redo() this.performance._notifyUndoRedo('redo', this.history.getNumUndos(), this.history.getNumRedos()) return this } /** * Whether the editor can redo. * * @public */ @computed canRedo(): boolean { 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?: string): string { 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: string) { 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: string): this { 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: string): this { this.history.bailToMark(id) return this } private _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: () => void, opts?: TLEditorRunOptions): this { 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: unknown, { origin, willCrashApp, tags, extras, }: { origin: string willCrashApp: boolean tags?: Record<string, string | boolean | number> extras?: Record<string, unknown> } ): this { 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: string, willCrashApp: boolean | 'unknown') { try { const editingShapeId = this.getEditingShapeId() return { tags: { origin: origin, willCrashApp, }, extras: { activeStateNode: this.root.getPath(), selectedShapes: this.getSelectedShapes().map((s) => { const { props, ...rest } = s const { text: _text, richText: _richText, ...restProps } = props as any return { ...rest, props: restProps, } }), selectionCount: this.getSelectedShapes().length, editingShape: editingShapeId ? this.getShape(editingShapeId) : undefined, inputs: this.inputs.toJson(), pageState: this.getCurrentPageState(), instanceState: this.getInstanceState(), collaboratorCount: this.getCollaboratorsOnCurrentPage().length, }, } } catch { return { tags: { origin: origin, willCrashApp, }, extras: {}, } } } /** @internal */ private _crashingError: unknown | null = 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: unknown): this { this._crashingError = error this.store.markAsPossiblyCorrupted() this.emit('crash', { error }) return this } /* ------------------- Statechart ------------------- */ /** * The editor's current path of active states. * * @example * ```ts * editor.getPath() // "select.idle" * ``` * * @public */ @computed 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: string): boolean { const ids = path.split('.').reverse() let state = this.root as StateNode 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: string[]): boolean { 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: string, info = {}): this { this.root.transition(id, info) return this