UNPKG

@tldraw/editor

Version:

tldraw infinite canvas SDK (editor).

1,869 lines (1,695 loc) • 305 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, TLArrowShape, TLAsset, TLAssetId, TLAssetPartial, TLBinding, TLBindingCreate, TLBindingId, TLBindingUpdate, TLCamera, TLCursor, TLCursorType, TLDOCUMENT_ID, TLDocument, TLFrameShape, TLGeoShape, TLGroupShape, TLHandle, TLINSTANCE_ID, TLImageAsset, TLInstance, TLInstancePageState, TLInstancePresence, TLNoteShape, TLPOINTER_ID, TLPage, TLPageId, TLParentId, TLRecord, TLShape, TLShapeId, TLShapePartial, TLStore, TLStoreSnapshot, TLUnknownBinding, TLUnknownShape, TLVideoAsset, createBindingId, createShapeId, getShapePropKeysByStyle, isPageId, isShapeId, } from '@tldraw/tlschema' import { FileHelpers, IndexKey, JsonObject, PerformanceTracker, Result, annotateError, assert, assertExists, bind, compact, debounce, dedupe, exhaustiveSwitchError, fetch, getIndexAbove, getIndexBetween, getIndices, getIndicesAbove, getIndicesBetween, getOwnProperty, hasOwnProperty, last, lerp, maxBy, minBy, sortById, sortByIndex, structuredClone, uniqueId, } from '@tldraw/utils' import EventEmitter from 'eventemitter3' import { TLEditorSnapshot, TLLoadSnapshotOptions, getSnapshot, loadSnapshot, } from '../config/TLEditorSnapshot' import { TLUser, createTLUser } from '../config/createTLUser' import { TLAnyBindingUtilConstructor, checkBindings } from '../config/defaultBindings' import { TLAnyShapeUtilConstructor, checkShapesAndAddCore } from '../config/defaultShapes' import { DEFAULT_ANIMATION_OPTIONS, DEFAULT_CAMERA_OPTIONS, INTERNAL_POINTER_IDS, LEFT_MOUSE_BUTTON, MIDDLE_MOUSE_BUTTON, RIGHT_MOUSE_BUTTON, STYLUS_ERASER_BUTTON, ZOOM_TO_FIT_PADDING, } from '../constants' import { exportToSvg } from '../exports/exportToSvg' import { getSvgAsImage } from '../exports/getSvgAsImage' import { tlenv } from '../globals/environment' import { tlmenus } from '../globals/menus' import { tltime } from '../globals/time' import { TldrawOptions, defaultTldrawOptions } from '../options' import { Box, BoxLike } from '../primitives/Box' import { Mat, MatLike } from '../primitives/Mat' import { Vec, VecLike } from '../primitives/Vec' import { EASINGS } from '../primitives/easings' import { Geometry2d } from '../primitives/geometry/Geometry2d' import { Group2d } from '../primitives/geometry/Group2d' import { intersectPolygonPolygon } from '../primitives/intersect' import { PI, approximately, areAnglesCompatible, clamp, pointInPolygon } from '../primitives/utils' import { ReadonlySharedStyleMap, SharedStyle, SharedStyleMap } from '../utils/SharedStylesMap' 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 { isAccelKey } from '../utils/keyboard' import { getReorderingShapesChanges } from '../utils/reorderShapes' import { TLTextOptions, TiptapEditor } from '../utils/richText' import { applyRotationToSnapshotShapes, getRotationSnapshot } from '../utils/rotation' 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 { EdgeScrollManager } from './managers/EdgeScrollManager/EdgeScrollManager' import { FocusManager } from './managers/FocusManager/FocusManager' import { FontManager } from './managers/FontManager/FontManager' import { HistoryManager } from './managers/HistoryManager/HistoryManager' import { ScribbleManager } from './managers/ScribbleManager/ScribbleManager' import { SnapManager } from './managers/SnapManager/SnapManager' import { TextManager } from './managers/TextManager/TextManager' import { TickManager } from './managers/TickManager/TickManager' import { UserPreferencesManager } from './managers/UserPreferencesManager/UserPreferencesManager' import { ShapeUtil, TLGeometryOpts, TLResizeMode } 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, TLPinchEventInfo, TLPointerEventInfo, TLWheelEventInfo, } from './types/event-types' import { TLExternalAsset, TLExternalContent } from './types/external-content' import { TLHistoryBatchOptions } from './types/history-types' import { OptionalKeys, RequiredKeys, TLCameraMoveOptions, TLCameraOptions, TLImageExportOptions, TLSvgExportOptions, } 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 app'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 tools to use in the editor. These will be used to handle events and manage user interactions in the editor. */ tools: readonly TLStateNodeConstructor[] /** * 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 /** * A user defined externally to replace the default user. */ user?: TLUser /** * The editor's initial active tool (or other state node id). */ initialState?: string /** * Whether to automatically focus the editor when it mounts. */ autoFocus?: boolean /** * Whether to infer dark mode from the user's system preferences. Defaults to false. */ inferDarkMode?: boolean /** * Options for the editor's camera. */ cameraOptions?: Partial<TLCameraOptions> textOptions?: TLTextOptions options?: Partial<TldrawOptions> licenseKey?: string fontAssetUrls?: { [key: string]: string | undefined } /** * A predicate that should return true if the given shape should be hidden. * * @deprecated Use {@link Editor#getShapeVisibility} instead. * * @param shape - The shape to check. * @param editor - The editor instance. */ isShapeHidden?(shape: TLShape, editor: Editor): boolean /** * 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 } /** * 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, tools, getContainer, cameraOptions, textOptions, initialState, autoFocus, inferDarkMode, options, // eslint-disable-next-line @typescript-eslint/no-deprecated isShapeHidden, getShapeVisibility, fontAssetUrls, }: TLEditorOptions) { super() assert( !(isShapeHidden && getShapeVisibility), 'Cannot use both isShapeHidden and getShapeVisibility' ) this._getShapeVisibility = isShapeHidden ? // eslint-disable-next-line @typescript-eslint/no-deprecated (shape: TLShape, editor: Editor) => (isShapeHidden(shape, editor) ? 'hidden' : 'inherit') : getShapeVisibility 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.disposables.add(this.timers.dispose) this._cameraOptions.set({ ...DEFAULT_CAMERA_OPTIONS, ...cameraOptions }) this._textOptions = atom('text options', textOptions ?? null) this.user = new UserPreferencesManager(user ?? createTLUser(), inferDarkMode ?? false) this.disposables.add(() => this.user.dispose()) this.getContainer = getContainer this.textMeasure = new TextManager(this) this.disposables.add(() => this.textMeasure.dispose()) this.fonts = new FontManager(this, fontAssetUrls) this._tickManager = new TickManager(this) class NewRoot extends RootState { static override initial = initialState ?? '' } this.root = new NewRoot(this) this.root.children = {} 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 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 // 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) // 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<string>() 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<TLGroupShape>(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' }]) }) ) } } 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 /** * A set of functions to call when the app is disposed. * * @public */ readonly disposables = new Set<() => void>() /** * Whether the editor is disposed. * * @public */ isDisposed = false /** @internal */ private readonly _tickManager /** * A manager for the app's snapping feature. * * @public */ readonly snaps: SnapManager /** * 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 the user and their preferences. * * @public */ readonly user: UserPreferencesManager /** * 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 environment. * * @deprecated This is deprecated and will be removed in a future version. Use the `tlenv` global export instead. * @public */ readonly environment = tlenv /** * A manager for the editor's scribbles. * * @public */ readonly scribbles: ScribbleManager /** * 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 /** * Dispose the editor. * * @public */ dispose() { this.disposables.forEach((dispose) => dispose()) this.disposables.clear() this.store.dispose() this.isDisposed = true } /* ------------------- Shape Utils ------------------ */ /** * A map of shape utility classes (TLShapeUtils) by shape type. * * @public */ shapeUtils: { readonly [K in string]?: ShapeUtil<TLUnknownShape> } 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<S extends TLUnknownShape>(shape: S | TLShapePartial<S>): ShapeUtil<S> getShapeUtil<S extends TLUnknownShape>(type: 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<S extends TLUnknownShape>(shape: S | TLShapePartial<S>): boolean hasShapeUtil<S extends TLUnknownShape>(type: S['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) } /* ------------------- Binding Utils ------------------ */ /** * A map of shape utility classes (TLShapeUtils) by shape type. * * @public */ bindingUtils: { readonly [K in string]?: BindingUtil<TLUnknownBinding> } /** * 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<S extends TLUnknownBinding>(binding: S | { type: S['type'] }): BindingUtil<S> getBindingUtil<S extends TLUnknownBinding>(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 } /* --------------------- History -------------------- */ /** * A manager for the app'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() return this } /** * Whether the app can undo. * * @public */ @computed getCanUndo(): boolean { return this.history.getNumUndos() > 0 } /** * Redo to the next mark. * * @example * ```ts * editor.redo() * ``` * * @public */ redo(): this { this._flushEventsForTick(0) this.complete() this.history.redo() return this } clearHistory() { this.history.clear() return this } /** * Whether the app can redo. * * @public */ @computed getCanRedo(): boolean { return this.history.getNumRedos() > 0 } /** * Create a new "mark", or stopping point, in the undo redo history. Creating a mark will clear * any redos. * * @example * ```ts * editor.mark() * editor.mark('flip shapes') * ``` * * @param markId - The mark's id, usually the reason for adding the mark. * * @public * @deprecated use {@link Editor.markHistoryStoppingPoint} instead */ mark(markId?: string): this { if (typeof markId === 'string') { console.warn( `[tldraw] \`editor.history.mark("${markId}")\` is deprecated. Please use \`const myMarkId = editor.markHistoryStoppingPoint()\` instead.` ) } else { console.warn( '[tldraw] `editor.mark()` is deprecated. Use `editor.markHistoryStoppingPoint()` instead.' ) } this.history._mark(markId ?? uniqueId()) 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 } /** * @deprecated Use `Editor.run` instead. */ batch(fn: () => void, opts?: TLEditorRunOptions): this { return this.run(fn, opts) } /* --------------------- 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, 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 app'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 } /** * The current selected tool. * * @public */ @computed getCurrentTool(): StateNode { return this.root.getCurrent()! } /** * The id of the current selected tool. * * @public */ @computed getCurrentToolId(): string { 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<T extends StateNode>(path: string): T | undefined { const ids = path.split('.').reverse() let state = this.root as StateNode while (ids.length > 0) { const id = ids.pop() if (!id) return state as T const childState = state.children?.[id] if (!childState) return undefined state = childState } return state as T } /* ---------------- Document Settings --------------- */ /** * The global document settings that apply to all users. * * @public **/ @computed getDocumentSettings() { return this.store.get(TLDOCUMENT_ID)! } /** * Update the global document settings that apply to all users. * * @public **/ updateDocumentSettings(settings: Partial<TLDocument>): this { this.run( () => { this.store.put([{ ...this.getDocumentSettings(), ...settings }]) }, { history: 'ignore' } ) return this } /* ----------------- Instance State ----------------- */ /** * The current instance's state. * * @public */ @computed getInstanceState(): TLInstance { 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: Partial<Omit<TLInstance, 'currentPageId'>>, historyOptions?: TLHistoryBatchOptions ): this { this._updateInstanceState(partial, { history: 'ignore', ...historyOptions }) if (partial.isChangingStyle !== undefined) { clearTimeout(this._isChangingStyleTimeout) if (partial.isChangingStyle === true) { // If we've set to true, set a new reset timeout to change the value back to false after 1 seconds this._isChangingStyleTimeout = this.timers.setTimeout(() => { this._updateInstanceState({ isChangingStyle: false }, { history: 'ignore' }) }, 1000) } } return this } /** @internal */ _updateInstanceState( partial: Partial<Omit<TLInstance, 'currentPageId'>>, opts?: TLHistoryBatchOptions ) { this.run(() => { this.store.put([ { ...this.getInstanceState(), ...partial, }, ]) }, opts) } /** @internal */ private _isChangingStyleTimeout = -1 as any // Menus menus = tlmenus.forContext(this.contextId) /** * @deprecated Use `editor.menus.getOpenMenus` instead. * * @public */ @computed getOpenMenus(): string[] { return this.menus.getOpenMenus() } /** * @deprecated Use `editor.menus.addOpenMenu` instead. * * @public */ addOpenMenu(id: string): this { this.menus.addOpenMenu(id) return this } /** * @deprecated Use `editor.menus.deleteOpenMenu` instead. * * @public */ deleteOpenMenu(id: string): this { this.menus.deleteOpenMenu(id) return this } /** * @deprecated Use `editor.menus.clearOpenMenus` instead. * * @public */ clearOpenMenus(): this { this.menus.clearOpenMenus() return this } /** * @deprecated Use `editor.menus.hasAnyOpenMenus` instead. * * @public */ @computed getIsMenuOpen(): boolean { return this.menus.hasAnyOpenMenus() } /* --------------------- Cursor --------------------- */ /** * Set the cursor. * * @param cursor - The cursor to set. * @public */ setCursor(cursor: Partial<TLCursor>) { this.updateInstanceState({ cursor: { ...this.getInstanceState().cursor, ...cursor } }) return this } /* ------------------- Page State ------------------- */ /** * Page states. * * @public */ @computed getPageStates(): TLInstancePageState[] { return this._getPageStatesQuery().get() } /** @internal */ @computed private _getPageStatesQuery() { return this.store.query.records('instance_page_state') } /** * The current page state. * * @public */ @computed getCurrentPageState(): TLInstancePageState { return this.store.get(this._getCurrentPageStateId())! } /** @internal */ @computed private _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: Partial< Omit<TLInstancePageState, 'selectedShapeIds' | 'editingShapeId' | 'pageId' | 'focusedGroupId'> > ): this { this._updateCurrentPageState(partial) return this } _updateCurrentPageState(partial: Partial<Omit<TLInstancePageState, 'selectedShapeIds'>>) { this.store.update(partial.id ?? this.getCurrentPageState().id, (state) => ({ ...state, ...partial, })) } /** * The current selected ids. * * @public */ @computed getSelectedShapeIds() { return this.getCurrentPageState().selectedShapeIds } /** * An array containing all of the currently selected shapes. * * @public * @readonly */ @computed getSelectedShapes(): TLShape[] { 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: TLShapeId[] | TLShape[]): this { 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: TLShape | TLShapeId): boolean { 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: TLShapeId[] | TLShape[]): this { const ids = typeof shapes[0] === 'string' ? (shapes as TLShapeId[]) : (shapes as TLShape[]).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: TLShapeId[] | TLShape[]): this { const ids = typeof shapes[0] === 'string' ? (shapes as TLShapeId[]) : (shapes as TLShape[]).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(): this { let parentToSelectWithinId: TLParentId | null = null const selectedShapeIds = this.getSelectedShapeIds() // If we have selected shapes, try to find a parent to select within if (selectedShapeIds.length > 0) { for (const id of selectedShapeIds) { const shape = this.getShape(id) if (!shape) continue if (parentToSelectWithinId === null) { // If we haven't found a parent yet, set this parent as the parent to select within parentToSelectWithinId = shape.parentId } else if (parentToSelectWithinId !== shape.parentId) { // If we've found two different parents, we can't select all, do nothing return this } } } // If we haven't found a parent from our selected shapes, select the current page if (!parentToSelectWithinId) { parentToSelectWithinId = this.getCurrentPageId() } // Select all the unlocked shapes within the parent 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: TLAdjacentDirection) { 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((shape) => shape.parentId === firstParentId) : this.getCurrentPageShapes().filter((shape) => isPageId(shape.parentId)) const readingOrderShapes = isSe