@tldraw/editor
Version:
tldraw infinite canvas SDK (editor).
1,869 lines (1,695 loc) • 305 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,
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