import { DeltaTime } from "@/propagators/DeltaTime" import { Geo } from "@/propagators/Geo" import { Edge, getArrowsFromShape, getEdge } from "@/propagators/tlgraph" import { isShapeOfType, updateProps } from "@/propagators/utils" import { Editor, TLArrowShape, TLBinding, TLGroupShape, TLShape, TLShapeId } from "tldraw" type Prefix = 'click' | 'tick' | 'geo' | '' export function registerDefaultPropagators(editor: Editor) { registerPropagators(editor, [ ChangePropagator, ClickPropagator, TickPropagator, SpatialPropagator, ]) } function isPropagatorOfType(arrow: TLShape, prefix: Prefix) { if (!isShapeOfType(arrow, 'arrow')) return false const regex = new RegExp(`^\\s*${prefix}\\s*\\{`) return regex.test(arrow.props.text) } function isExpandedPropagatorOfType(arrow: TLShape, prefix: Prefix) { if (!isShapeOfType(arrow, 'arrow')) return false const regex = new RegExp(`^\\s*${prefix}\\s*\\(\\)\\s*\\{`) return regex.test(arrow.props.text) } class ArrowFunctionCache { private cache: Map = new Map() /** returns undefined if the function could not be found or created */ get(editor: Editor, edge: Edge, prefix: Prefix): Function | undefined { if (this.cache.has(edge.arrowId)) { return this.cache.get(edge.arrowId) as Function | undefined } return this.set(editor, edge, prefix) } /** returns undefined if the function could not be created */ set(editor: Editor, edge: Edge, prefix: Prefix): Function | undefined { try { const arrowShape = editor.getShape(edge.arrowId) if (!arrowShape) throw new Error('Arrow shape not found') const textWithoutPrefix = edge.text?.replace(prefix, '') const isExpanded = isExpandedPropagatorOfType(arrowShape, prefix) const body = isExpanded ? textWithoutPrefix?.trim().replace(/^\s*\(\)\s*{|}$/g, '') : ` const mapping = ${textWithoutPrefix} editor.updateShape(_unpack({...to, ...mapping})) ` const func = new Function('editor', 'from', 'to', 'G', 'bounds', 'dt', '_unpack', body as string); this.cache.set(edge.arrowId, func) return func } catch (error) { this.cache.set(edge.arrowId, null) return undefined } } delete(edge: Edge): void { this.cache.delete(edge.arrowId) } } const packShape = (shape: TLShape) => { return { id: shape.id, type: shape.type, x: shape.x, y: shape.y, rotation: shape.rotation, ...shape.props, m: shape.meta, } } const unpackShape = (shape: any) => { const { id, type, x, y, rotation, m, ...props } = shape const cast = (prop: any, constructor: (value: any) => any) => { return prop !== undefined ? constructor(prop) : undefined; }; // Handle text shapes properly - convert text property to richText if needed const shapeProps = { ...props } if (type === 'text') { // Remove any text property as it's not valid for TLDraw text shapes if ('text' in shapeProps) { delete shapeProps.text } // Ensure richText exists for text shapes if (!shapeProps.richText) { shapeProps.richText = { content: [], type: 'doc' } } } return { id, type, x: Number(x), y: Number(y), rotation: Number(rotation), props: shapeProps, meta: m, } } function setArrowColor(editor: Editor, arrow: TLArrowShape, color: TLArrowShape['props']['color']): void { editor.updateShape({ ...arrow, props: { ...arrow.props, color: color, } }) } export function registerPropagators(editor: Editor, propagators: (new (editor: Editor) => Propagator)[]) { const _propagators = propagators.map((PropagatorClass) => new PropagatorClass(editor)) for (const prop of _propagators) { for (const shape of editor.getCurrentPageShapes()) { if (isShapeOfType(shape, 'arrow')) { prop.onArrowChange(editor, shape) } } editor.sideEffects.registerAfterChangeHandler<"shape">("shape", (_, next) => { if (isShapeOfType(next, 'group')) { const childIds = editor.getSortedChildIdsForParent(next.id) for (const childId of childIds) { const child = editor.getShape(childId) prop.afterChangeHandler?.(editor, child as TLShape) } return } prop.afterChangeHandler?.(editor, next) if (isShapeOfType(next, 'arrow')) { prop.onArrowChange(editor, next) } }) function updateOnBindingChange(editor: Editor, binding: TLBinding) { if (binding.type !== 'arrow') return const arrow = editor.getShape(binding.fromId) if (!arrow) return if (!isShapeOfType(arrow, 'arrow')) return prop.onArrowChange(editor, arrow) } // TODO: remove this when binding creation editor.sideEffects.registerAfterCreateHandler<"binding">("binding", (binding) => { updateOnBindingChange(editor, binding) }) // TODO: remove this when binding creation editor.sideEffects.registerAfterDeleteHandler<"binding">("binding", (binding) => { updateOnBindingChange(editor, binding) }) editor.on('event', (event) => { prop.eventHandler?.(event) }) editor.on('tick', () => { prop.tickHandler?.() }) } } // TODO: separate generic propagator setup from scope registration // TODO: handle cycles export abstract class Propagator { abstract prefix: Prefix protected listenerArrows: Set = new Set() protected listenerShapes: Set = new Set() protected arrowFunctionCache: ArrowFunctionCache = new ArrowFunctionCache() protected editor: Editor protected geo: Geo protected validateOnArrowChange: boolean = false constructor(editor: Editor) { this.editor = editor this.geo = new Geo(editor) } /** function to check if any listeners need to be added/removed * called on mount and when an arrow changes */ onArrowChange(editor: Editor, arrow: TLArrowShape): void { const edge = getEdge(arrow, editor) if (!edge) return const isPropagator = isPropagatorOfType(arrow, this.prefix) || isExpandedPropagatorOfType(arrow, this.prefix) if (isPropagator) { if (this.validateOnArrowChange && !this.propagate(editor, arrow.id)) { this.removeListener(arrow.id, edge) return } this.addListener(arrow.id, edge) // TODO: find a way to do this properly so we can run arrow funcs on change without chaos... // this.arrowFunc(editor, arrow.id) } else { this.removeListener(arrow.id, edge) } } private addListener(arrowId: TLShapeId, edge: Edge): void { this.listenerArrows.add(arrowId) this.listenerShapes.add(edge.from) this.listenerShapes.add(edge.to) this.arrowFunctionCache.set(this.editor, edge, this.prefix) } private removeListener(arrowId: TLShapeId, edge: Edge): void { this.listenerArrows.delete(arrowId) this.arrowFunctionCache.delete(edge) } /** the function to be called when side effect / event is triggered */ propagate(editor: Editor, arrow: TLShapeId): boolean { const edge = getEdge(editor.getShape(arrow), editor) if (!edge) return false const arrowShape = editor.getShape(arrow) as TLArrowShape const fromShape = editor.getShape(edge.from) const toShape = editor.getShape(edge.to) const fromShapePacked = packShape(fromShape as TLShape) const toShapePacked = packShape(toShape as TLShape) const bounds = (shape: TLShape) => editor.getShapePageBounds(shape.id) try { const func = this.arrowFunctionCache.get(editor, edge, this.prefix) const result = func?.(editor, fromShapePacked, toShapePacked, this.geo, bounds, DeltaTime.dt, unpackShape); if (result) { editor.updateShape(unpackShape({ ...toShapePacked, ...result })) } setArrowColor(editor, arrowShape, 'black') return true } catch (error) { setArrowColor(editor, arrowShape, 'orange') return false } } /** called after every shape change */ afterChangeHandler?(editor: Editor, next: TLShape): void /** called on every editor event */ eventHandler?(event: any): void /** called every tick */ tickHandler?(): void } export class ClickPropagator extends Propagator { prefix: Prefix = 'click' eventHandler(event: any): void { if (event.type !== 'pointer' || event.name !== 'pointer_down') return; const shapeAtPoint = this.editor.getShapeAtPoint(this.editor.inputs.currentPagePoint, { filter: (shape) => shape.type === 'geo' }); if (!shapeAtPoint) return if (!this.listenerShapes.has(shapeAtPoint.id)) return const edgesFromHovered = getArrowsFromShape(this.editor, shapeAtPoint.id) const visited = new Set() for (const edge of edgesFromHovered) { if (this.listenerArrows.has(edge) && !visited.has(edge)) { this.propagate(this.editor, edge) visited.add(edge) } } } } export class ChangePropagator extends Propagator { prefix: Prefix = '' afterChangeHandler(editor: Editor, next: TLShape): void { if (this.listenerShapes.has(next.id)) { const arrowsFromShape = getArrowsFromShape(editor, next.id) for (const arrow of arrowsFromShape) { if (this.listenerArrows.has(arrow)) { const bindings = editor.getBindingsInvolvingShape(arrow) if (bindings.length !== 2) continue // don't run func if its pointing to itself to avoid change-induced recursion error if (bindings[0].toId === bindings[1].toId) continue this.propagate(editor, arrow) } } } } } export class TickPropagator extends Propagator { prefix: Prefix = 'tick' validateOnArrowChange = true tickHandler(): void { for (const arrow of this.listenerArrows) { this.propagate(this.editor, arrow) } } } export class SpatialPropagator extends Propagator { prefix: Prefix = 'geo' // TODO: make this smarter, and scale sublinearly afterChangeHandler(editor: Editor, next: TLShape): void { if (next.type === 'arrow') return for (const arrowId of this.listenerArrows) { this.propagate(editor, arrowId) } } }