@tldraw/editor
Version:
tldraw infinite canvas SDK (editor).
1,807 lines (1,636 loc) • 332 kB
text/typescript
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