@tldraw/editor
Version:
tldraw infinite canvas SDK (editor).
288 lines (250 loc) • 7.49 kB
text/typescript
import { Atom, Computed, atom, computed } from '@tldraw/state'
import { PerformanceTracker } from '@tldraw/utils'
import { debugFlags } from '../../utils/debug-flags'
import type { Editor } from '../Editor'
import {
EVENT_NAME_MAP,
TLCancelEventInfo,
TLClickEventInfo,
TLCompleteEventInfo,
TLEventHandlers,
TLEventInfo,
TLInterruptEventInfo,
TLKeyboardEventInfo,
TLPinchEventInfo,
TLPointerEventInfo,
TLTickEventInfo,
TLWheelEventInfo,
} from '../types/event-types'
const STATE_NODES_TO_MEASURE = [
'brushing',
'cropping',
'dragging',
'dragging_handle',
'drawing',
'erasing',
'lasering',
'resizing',
'rotating',
'scribble_brushing',
'translating',
]
/** @public */
export interface TLStateNodeConstructor {
new (editor: Editor, parent?: StateNode): StateNode
id: string
initial?: string
children?(): TLStateNodeConstructor[]
isLockable: boolean
useCoalescedEvents: boolean
}
/** @public */
export abstract class StateNode implements Partial<TLEventHandlers> {
performanceTracker: PerformanceTracker
constructor(
public editor: Editor,
parent?: StateNode
) {
const { id, children, initial, isLockable, useCoalescedEvents } = this
.constructor as TLStateNodeConstructor
this.id = id
this._isActive = atom<boolean>('toolIsActive' + this.id, false)
this._current = atom<StateNode | undefined>('toolState' + this.id, undefined)
this._path = computed('toolPath' + this.id, () => {
const current = this.getCurrent()
return this.id + (current ? `.${current.getPath()}` : '')
})
this.parent = parent ?? ({} as any)
if (parent) {
if (children && initial) {
this.type = 'branch'
this.initial = initial
this.children = Object.fromEntries(
children().map((Ctor) => [Ctor.id, new Ctor(this.editor, this)])
)
this._current.set(this.children[this.initial])
} else {
this.type = 'leaf'
}
} else {
this.type = 'root'
if (children && initial) {
this.initial = initial
this.children = Object.fromEntries(
children().map((Ctor) => [Ctor.id, new Ctor(this.editor, this)])
)
this._current.set(this.children[this.initial])
}
}
this.isLockable = isLockable
this.useCoalescedEvents = useCoalescedEvents
this.performanceTracker = new PerformanceTracker()
}
static id: string
static initial?: string
static children?: () => TLStateNodeConstructor[]
static isLockable = true
static useCoalescedEvents = false
id: string
type: 'branch' | 'leaf' | 'root'
shapeType?: string
initial?: string
children?: Record<string, StateNode>
isLockable: boolean
useCoalescedEvents: boolean
parent: StateNode
/**
* This node's path of active state nodes
*
* @public
*/
getPath() {
return this._path.get()
}
_path: Computed<string>
/**
* This node's current active child node, if any.
*
* @public
*/
getCurrent() {
return this._current.get()
}
private _current: Atom<StateNode | undefined>
/**
* Whether this node is active.
*
* @public
*/
getIsActive() {
return this._isActive.get()
}
private _isActive: Atom<boolean>
/**
* Transition to a new active child state node.
*
* @example
* ```ts
* parentState.transition('childStateA')
* parentState.transition('childStateB', { myData: 4 })
*```
*
* @param id - The id of the child state node to transition to.
* @param info - Any data to pass to the `onEnter` and `onExit` handlers.
*
* @public
*/
transition(id: string, info: any = {}) {
const path = id.split('.')
let currState = this as StateNode
for (let i = 0; i < path.length; i++) {
const id = path[i]
const prevChildState = currState.getCurrent()
const nextChildState = currState.children?.[id]
if (!nextChildState) {
throw Error(`${currState.id} - no child state exists with the id ${id}.`)
}
if (prevChildState?.id !== nextChildState.id) {
prevChildState?.exit(info, id)
currState._current.set(nextChildState)
nextChildState.enter(info, prevChildState?.id || 'initial')
if (!nextChildState.getIsActive()) break
}
currState = nextChildState
}
return this
}
handleEvent(info: Exclude<TLEventInfo, TLPinchEventInfo>) {
const cbName = EVENT_NAME_MAP[info.name]
const currentActiveChild = this._current.__unsafe__getWithoutCapture()
this[cbName]?.(info as any)
if (
this._isActive.__unsafe__getWithoutCapture() &&
currentActiveChild &&
currentActiveChild === this._current.__unsafe__getWithoutCapture()
) {
currentActiveChild.handleEvent(info)
}
}
// todo: move this logic into transition
enter(info: any, from: string) {
if (debugFlags.measurePerformance.get() && STATE_NODES_TO_MEASURE.includes(this.id)) {
this.performanceTracker.start(this.id)
}
this._isActive.set(true)
this.onEnter?.(info, from)
if (this.children && this.initial && this.getIsActive()) {
const initial = this.children[this.initial]
this._current.set(initial)
initial.enter(info, from)
}
}
// todo: move this logic into transition
exit(info: any, to: string) {
if (debugFlags.measurePerformance.get() && this.performanceTracker.isStarted()) {
this.performanceTracker.stop()
}
this._isActive.set(false)
this.onExit?.(info, to)
if (!this.getIsActive()) {
this.getCurrent()?.exit(info, to)
}
}
/**
* This is a hack / escape hatch that will tell the editor to
* report a different state as active (in `getCurrentToolId()`) when
* this state is active. This is usually used when a tool transitions
* to a child of a different state for a certain interaction and then
* returns to the original tool when that interaction completes; and
* where we would want to show the original tool as active in the UI.
*
* @public
*/
_currentToolIdMask = atom('curent tool id mask', undefined as string | undefined)
getCurrentToolIdMask() {
return this._currentToolIdMask.get()
}
setCurrentToolIdMask(id: string | undefined) {
this._currentToolIdMask.set(id)
}
/**
* Add a child node to this state node.
*
* @public
*/
addChild(childConstructor: TLStateNodeConstructor): this {
if (this.type === 'leaf') {
throw new Error('StateNode.addChild: cannot add child to a leaf node')
}
// Initialize children if it's undefined (for root nodes without static children)
if (!this.children) {
this.children = {}
}
const child = new childConstructor(this.editor, this)
// Check if a child with this ID already exists
if (this.children[child.id]) {
throw new Error(`StateNode.addChild: a child with id '${child.id}' already exists`)
}
this.children[child.id] = child
return this
}
onWheel?(info: TLWheelEventInfo): void
onPointerDown?(info: TLPointerEventInfo): void
onPointerMove?(info: TLPointerEventInfo): void
onLongPress?(info: TLPointerEventInfo): void
onPointerUp?(info: TLPointerEventInfo): void
onDoubleClick?(info: TLClickEventInfo): void
onTripleClick?(info: TLClickEventInfo): void
onQuadrupleClick?(info: TLClickEventInfo): void
onRightClick?(info: TLPointerEventInfo): void
onMiddleClick?(info: TLPointerEventInfo): void
onKeyDown?(info: TLKeyboardEventInfo): void
onKeyUp?(info: TLKeyboardEventInfo): void
onKeyRepeat?(info: TLKeyboardEventInfo): void
onCancel?(info: TLCancelEventInfo): void
onComplete?(info: TLCompleteEventInfo): void
onInterrupt?(info: TLInterruptEventInfo): void
onTick?(info: TLTickEventInfo): void
onEnter?(info: any, from: string): void
onExit?(info: any, to: string): void
}