From 2590a863524952da1fa7fd327ad5b2f22d6b3e38 Mon Sep 17 00:00:00 2001 From: Jeff-Emmett Date: Tue, 21 Jan 2025 23:25:28 +0700 Subject: [PATCH] added scoped propagators (with javascript object on arrow edge to control) --- package-lock.json | 24 +++ package.json | 2 + src/App.tsx | 27 --- src/propagators/DeltaTime.ts | 23 ++ src/propagators/Geo.ts | 119 +++++++++++ src/propagators/ScopedPropagators.ts | 300 +++++++++++++++++++++++++++ src/propagators/SpatialIndex.ts | 165 +++++++++++++++ src/propagators/tlgraph.ts | 115 ++++++++++ src/propagators/utils.ts | 22 ++ src/routes/Board.tsx | 2 + 10 files changed, 772 insertions(+), 27 deletions(-) create mode 100644 src/propagators/DeltaTime.ts create mode 100644 src/propagators/Geo.ts create mode 100644 src/propagators/ScopedPropagators.ts create mode 100644 src/propagators/SpatialIndex.ts create mode 100644 src/propagators/tlgraph.ts create mode 100644 src/propagators/utils.ts diff --git a/package-lock.json b/package-lock.json index 8a2eedc..2c6341c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "jspdf": "^2.5.2", "lodash.throttle": "^4.1.1", "marked": "^15.0.4", + "rbush": "^4.0.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^7.0.2", @@ -39,6 +40,7 @@ "@cloudflare/types": "^6.0.0", "@cloudflare/workers-types": "^4.20240821.1", "@types/lodash.throttle": "^4", + "@types/rbush": "^4.0.0", "@types/react": "^19.0.1", "@types/react-dom": "^19.0.1", "@vitejs/plugin-react": "^4.0.3", @@ -2852,6 +2854,13 @@ "license": "MIT", "optional": true }, + "node_modules/@types/rbush": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/rbush/-/rbush-4.0.0.tgz", + "integrity": "sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.0.2", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.2.tgz", @@ -6940,6 +6949,12 @@ ], "license": "MIT" }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, "node_modules/raf": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", @@ -6993,6 +7008,15 @@ "node": ">=0.10.0" } }, + "node_modules/rbush": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/rbush/-/rbush-4.0.1.tgz", + "integrity": "sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==", + "license": "MIT", + "dependencies": { + "quickselect": "^3.0.0" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", diff --git a/package.json b/package.json index d19b1db..7b1a8db 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "jspdf": "^2.5.2", "lodash.throttle": "^4.1.1", "marked": "^15.0.4", + "rbush": "^4.0.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^7.0.2", @@ -45,6 +46,7 @@ "@cloudflare/types": "^6.0.0", "@cloudflare/workers-types": "^4.20240821.1", "@types/lodash.throttle": "^4", + "@types/rbush": "^4.0.0", "@types/react": "^19.0.1", "@types/react-dom": "^19.0.1", "@vitejs/plugin-react": "^4.0.3", diff --git a/src/App.tsx b/src/App.tsx index 4e3332f..f3a05b3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,39 +6,12 @@ import { BrowserRouter, Route, Routes } from "react-router-dom" import { Contact } from "@/routes/Contact" import { Board } from "./routes/Board" import { Inbox } from "./routes/Inbox" -import { ChatBoxShape } from "./shapes/ChatBoxShapeUtil" -import { VideoChatShape } from "./shapes/VideoChatShapeUtil" -import { ChatBoxTool } from "./tools/ChatBoxTool" -import { VideoChatTool } from "./tools/VideoChatTool" -import { EmbedTool } from "./tools/EmbedTool" -import { EmbedShape } from "./shapes/EmbedShapeUtil" -import { MycrozineTemplateTool } from './tools/MycrozineTemplateTool' -import { MycrozineTemplateShape } from './shapes/MycrozineTemplateShapeUtil' -import { MarkdownShape } from "./shapes/MarkdownShapeUtil" -import { MarkdownTool } from "./tools/MarkdownTool" import { createRoot } from "react-dom/client" -import { handleInitialPageLoad } from "./utils/handleInitialPageLoad" import { DailyProvider } from "@daily-co/daily-react" import Daily from "@daily-co/daily-js" - inject() -const customShapeUtils = [ - ChatBoxShape, - VideoChatShape, - EmbedShape, - MycrozineTemplateShape, - MarkdownShape, -] -const customTools = [ - ChatBoxTool, - VideoChatTool, - EmbedTool, - // MycrozineTemplateTool, - // MarkdownTool -] - const callObject = Daily.createCallObject() function App() { diff --git a/src/propagators/DeltaTime.ts b/src/propagators/DeltaTime.ts new file mode 100644 index 0000000..565aa06 --- /dev/null +++ b/src/propagators/DeltaTime.ts @@ -0,0 +1,23 @@ +export class DeltaTime { + private static lastTime = Date.now() + private static initialized = false + private static _dt = 0 + + static get dt(): number { + if (!DeltaTime.initialized) { + DeltaTime.lastTime = Date.now() + DeltaTime.initialized = true + window.requestAnimationFrame(DeltaTime.tick) + return 0 + } + const clamp = (min: number, max: number, value: number) => Math.min(max, Math.max(min, value)) + return clamp(0, 100, DeltaTime._dt) + } + + static tick(nowish: number) { + DeltaTime._dt = nowish - DeltaTime.lastTime + DeltaTime.lastTime = nowish + + window.requestAnimationFrame(DeltaTime.tick) + } + } \ No newline at end of file diff --git a/src/propagators/Geo.ts b/src/propagators/Geo.ts new file mode 100644 index 0000000..d667a77 --- /dev/null +++ b/src/propagators/Geo.ts @@ -0,0 +1,119 @@ +import { SpatialIndex } from "@/propagators/SpatialIndex" +import { Box, Editor, TLShape, TLShapeId, VecLike, polygonsIntersect } from "tldraw" + +export class Geo { + editor: Editor + spatialIndex: SpatialIndex + constructor(editor: Editor) { + this.editor = editor + this.spatialIndex = new SpatialIndex(editor) + } + intersects(shape: TLShape | TLShapeId): boolean { + const id = typeof shape === 'string' ? shape : shape?.id ?? null + if (!id) return false + const sourceTransform = this.editor.getShapePageTransform(id) + const sourceGeo = this.editor.getShapeGeometry(id) + const sourcePagespace = sourceTransform.applyToPoints(sourceGeo.vertices) + const sourceBounds = this.editor.getShapePageBounds(id) + + const shapesInBounds = this.spatialIndex.getShapeIdsInsideBounds(sourceBounds as Box) + for (const boundsShapeId of shapesInBounds) { + if (boundsShapeId === id) continue + const pageShape = this.editor.getShape(boundsShapeId) + if (!pageShape) continue + if (pageShape.type === 'arrow') continue + const pageShapeGeo = this.editor.getShapeGeometry(pageShape) + const pageShapeTransform = this.editor.getShapePageTransform(pageShape) + const pageShapePagespace = pageShapeTransform.applyToPoints(pageShapeGeo.vertices) + const pageShapeBounds = this.editor.getShapePageBounds(boundsShapeId) + if (polygonsIntersect(sourcePagespace, pageShapePagespace) || sourceBounds?.contains(pageShapeBounds as Box) || pageShapeBounds?.contains(sourceBounds as Box)) { + return true + } + } + return false + } + distance(a: TLShape | TLShapeId, b: TLShape | TLShapeId): VecLike { + const idA = typeof a === 'string' ? a : a?.id ?? null + const idB = typeof b === 'string' ? b : b?.id ?? null + if (!idA || !idB) return { x: 0, y: 0 } + const shapeA = this.editor.getShape(idA) + const shapeB = this.editor.getShape(idB) + if (!shapeA || !shapeB) return { x: 0, y: 0 } + return { x: shapeA.x - shapeB.x, y: shapeA.y - shapeB.y } + } + distanceCenter(a: TLShape | TLShapeId, b: TLShape | TLShapeId): VecLike { + const idA = typeof a === 'string' ? a : a?.id ?? null + const idB = typeof b === 'string' ? b : b?.id ?? null + if (!idA || !idB) return { x: 0, y: 0 } + const aBounds = this.editor.getShapePageBounds(idA) + const bBounds = this.editor.getShapePageBounds(idB) + if (!aBounds || !bBounds) return { x: 0, y: 0 } + const aCenter = aBounds.center + const bCenter = bBounds.center + return { x: aCenter.x - bCenter.x, y: aCenter.y - bCenter.y } + } + getIntersects(shape: TLShape | TLShapeId): TLShape[] { + const id = typeof shape === 'string' ? shape : shape?.id ?? null + if (!id) return [] + const sourceTransform = this.editor.getShapePageTransform(id) + const sourceGeo = this.editor.getShapeGeometry(id) + const sourcePagespace = sourceTransform.applyToPoints(sourceGeo.vertices) + const sourceBounds = this.editor.getShapePageBounds(id) + + const boundsShapes = this.spatialIndex.getShapeIdsInsideBounds(sourceBounds as Box) + const overlaps: TLShape[] = [] + for (const boundsShapeId of boundsShapes) { + if (boundsShapeId === id) continue + const pageShape = this.editor.getShape(boundsShapeId) + if (!pageShape) continue + if (pageShape.type === 'arrow') continue + const pageShapeGeo = this.editor.getShapeGeometry(pageShape) + const pageShapeTransform = this.editor.getShapePageTransform(pageShape) + const pageShapePagespace = pageShapeTransform.applyToPoints(pageShapeGeo.vertices) + const pageShapeBounds = this.editor.getShapePageBounds(boundsShapeId) + if (polygonsIntersect(sourcePagespace, pageShapePagespace) || sourceBounds?.contains(pageShapeBounds as Box) || pageShapeBounds?.contains(sourceBounds as Box )) { + overlaps.push(pageShape) + } + } + return overlaps + } + + contains(shape: TLShape | TLShapeId): boolean { + const id = typeof shape === 'string' ? shape : shape?.id ?? null + if (!id) return false + const sourceBounds = this.editor.getShapePageBounds(id) + + const boundsShapes = this.spatialIndex.getShapeIdsInsideBounds(sourceBounds as Box) + for (const boundsShapeId of boundsShapes) { + if (boundsShapeId === id) continue + const pageShape = this.editor.getShape(boundsShapeId) + if (!pageShape) continue + if (pageShape.type !== 'geo') continue + const pageShapeBounds = this.editor.getShapePageBounds(boundsShapeId) + if (sourceBounds?.contains(pageShapeBounds as Box)) { + return true + } + } + return false + } + + getContains(shape: TLShape | TLShapeId): TLShape[] { + const id = typeof shape === 'string' ? shape : shape?.id ?? null + if (!id) return [] + const sourceBounds = this.editor.getShapePageBounds(id) + + const boundsShapes = this.spatialIndex.getShapeIdsInsideBounds(sourceBounds as Box) + const contains: TLShape[] = [] + for (const boundsShapeId of boundsShapes) { + if (boundsShapeId === id) continue + const pageShape = this.editor.getShape(boundsShapeId) + if (!pageShape) continue + if (pageShape.type !== 'geo') continue + const pageShapeBounds = this.editor.getShapePageBounds(boundsShapeId) + if (sourceBounds?.contains(pageShapeBounds as Box)) { + contains.push(pageShape) + } + } + return contains + } +} \ No newline at end of file diff --git a/src/propagators/ScopedPropagators.ts b/src/propagators/ScopedPropagators.ts new file mode 100644 index 0000000..abf862e --- /dev/null +++ b/src/propagators/ScopedPropagators.ts @@ -0,0 +1,300 @@ +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; + }; + return { + id, + type, + x: Number(x), + y: Number(y), + rotation: Number(rotation), + props: { + ...props, + text: cast(props.text, String), + }, + 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) + } + } +} diff --git a/src/propagators/SpatialIndex.ts b/src/propagators/SpatialIndex.ts new file mode 100644 index 0000000..9b65fc1 --- /dev/null +++ b/src/propagators/SpatialIndex.ts @@ -0,0 +1,165 @@ +import { RESET_VALUE, computed, isUninitialized } from '@tldraw/state' +import { TLPageId, TLShapeId, isShape, isShapeId } from '@tldraw/tlschema' +import RBush from 'rbush' +import { Box, Editor } from 'tldraw' + +type Element = { + minX: number + minY: number + maxX: number + maxY: number + id: TLShapeId +} + +export class SpatialIndex { + private readonly spatialIndex: ReturnType + private lastPageId: TLPageId | null = null + private shapesInTree: Map + private rBush: RBush + + constructor(private editor: Editor) { + this.spatialIndex = this.createSpatialIndex() + this.shapesInTree = new Map() + this.rBush = new RBush() + } + + private addElement(id: TLShapeId, a: Element[], existingBounds?: Box) { + const e = this.getElement(id, existingBounds) + if (!e) return + a.push(e) + this.shapesInTree.set(id, e) + } + + private getElement(id: TLShapeId, existingBounds?: Box): Element | null { + const bounds = existingBounds ?? this.editor.getShapeMaskedPageBounds(id) + if (!bounds) return null + return { + minX: bounds.minX, + minY: bounds.minY, + maxX: bounds.maxX, + maxY: bounds.maxY, + id, + } + } + + private fromScratch(lastComputedEpoch: number) { + this.lastPageId = this.editor.getCurrentPageId() + this.shapesInTree = new Map() + const elementsToAdd: Element[] = [] + + this.editor.getCurrentPageShapeIds().forEach((id) => { + this.addElement(id, elementsToAdd) + }) + + this.rBush = new RBush().load(elementsToAdd) + + return lastComputedEpoch + } + + private createSpatialIndex() { + const shapeHistory = this.editor.store.query.filterHistory('shape') + + return computed('spatialIndex', (prevValue, lastComputedEpoch) => { + if (isUninitialized(prevValue)) { + return this.fromScratch(lastComputedEpoch) + } + + const diff = shapeHistory.getDiffSince(lastComputedEpoch) + if (diff === RESET_VALUE) { + return this.fromScratch(lastComputedEpoch) + } + + const currentPageId = this.editor.getCurrentPageId() + if (!this.lastPageId || this.lastPageId !== currentPageId) { + return this.fromScratch(lastComputedEpoch) + } + + let isDirty = false + for (const changes of diff) { + const elementsToAdd: Element[] = [] + for (const record of Object.values(changes.added)) { + if (isShape(record)) { + this.addElement(record.id, elementsToAdd) + } + } + + for (const [_from, to] of Object.values(changes.updated)) { + if (isShape(to)) { + const currentElement = this.shapesInTree.get(to.id) + const newBounds = this.editor.getShapeMaskedPageBounds(to.id) + if (currentElement) { + if ( + newBounds?.minX === currentElement.minX && + newBounds.minY === currentElement.minY && + newBounds.maxX === currentElement.maxX && + newBounds.maxY === currentElement.maxY + ) { + continue + } + this.shapesInTree.delete(to.id) + this.rBush.remove(currentElement) + isDirty = true + } + this.addElement(to.id, elementsToAdd, newBounds) + } + } + if (elementsToAdd.length) { + this.rBush.load(elementsToAdd) + isDirty = true + } + for (const id of Object.keys(changes.removed)) { + if (isShapeId(id)) { + const currentElement = this.shapesInTree.get(id) + if (currentElement) { + this.shapesInTree.delete(id) + this.rBush.remove(currentElement) + isDirty = true + } + } + } + } + + return isDirty ? lastComputedEpoch : prevValue + }) + } + + private _getVisibleShapes() { + return computed>('visible shapes', (prevValue) => { + // Make sure the spatial index is up to date + const _index = this.spatialIndex.get() + const newValue = this.rBush.search(this.editor.getViewportPageBounds()).map((s: Element) => s.id) + if (isUninitialized(prevValue)) { + return new Set(newValue) + } + const isSame = prevValue.size === newValue.length && newValue.every((id: TLShapeId) => prevValue.has(id)) + return isSame ? prevValue : new Set(newValue) + }) + } + + getVisibleShapes() { + return this._getVisibleShapes().get() + } + + _getNotVisibleShapes() { + return computed>('not visible shapes', (prevValue) => { + const visibleShapes = this._getVisibleShapes().get() + const pageShapes = this.editor.getCurrentPageShapeIds() + const nonVisibleShapes = [...pageShapes].filter((id) => !visibleShapes.has(id)) + if (isUninitialized(prevValue)) return new Set(nonVisibleShapes) + const isSame = + prevValue.size === nonVisibleShapes.length && + nonVisibleShapes.every((id) => prevValue.has(id)) + return isSame ? prevValue : new Set(nonVisibleShapes) + }) + } + + getNotVisibleShapes() { + return this._getNotVisibleShapes().get() + } + + getShapeIdsInsideBounds(bounds: Box) { + // Make sure the spatial index is up to date + const _index = this.spatialIndex.get() + return this.rBush.search(bounds).map((s: Element) => s.id) + } +} \ No newline at end of file diff --git a/src/propagators/tlgraph.ts b/src/propagators/tlgraph.ts new file mode 100644 index 0000000..c2f249b --- /dev/null +++ b/src/propagators/tlgraph.ts @@ -0,0 +1,115 @@ +import { isShapeOfType } from "@/propagators/utils"; +import { Editor, TLArrowBinding, TLArrowShape, TLShape, TLShapeId } from "tldraw"; + +export interface Edge { + arrowId: TLShapeId + from: TLShapeId + to: TLShapeId + text?: string +} + +export interface Graph { + nodes: TLShapeId[] + edges: Edge[] +} + +export function getEdge(shape: TLShape | undefined, editor: Editor): Edge | undefined { + if (!shape || !isShapeOfType(shape, 'arrow')) return undefined + const bindings = editor.getBindingsInvolvingShape(shape.id) + if (!bindings || bindings.length !== 2) return undefined + if (bindings[0].props.terminal === "end") { + return { + arrowId: shape.id, + from: bindings[1].toId, + to: bindings[0].toId, + text: shape.props.text + } + } + return { + arrowId: shape.id, + from: bindings[0].toId, + to: bindings[1].toId, + text: shape.props.text + } +} + +/** + * Returns the graph(s) of edges and nodes from a list of shapes + */ +export function getGraph(shapes: TLShape[], editor: Editor): Graph { + const nodes: Set = new Set() + const edges: Edge[] = [] + + for (const shape of shapes) { + const edge = getEdge(shape, editor) + if (edge) { + edges.push({ + arrowId: edge.arrowId, + from: edge.from, + to: edge.to, + text: edge.text + }) + nodes.add(edge.from) + nodes.add(edge.to) + } + } + + return { nodes: Array.from(nodes), edges } +} + +/** + * Returns the start and end nodes of a topologically sorted graph + */ +export function sortGraph(graph: Graph): { startNodes: TLShapeId[], endNodes: TLShapeId[] } { + const targetNodes = new Set(graph.edges.map(e => e.to)); + const sourceNodes = new Set(graph.edges.map(e => e.from)); + + const startNodes = []; + const endNodes = []; + + for (const node of graph.nodes) { + if (sourceNodes.has(node) && !targetNodes.has(node)) { + startNodes.push(node); + } else if (targetNodes.has(node) && !sourceNodes.has(node)) { + endNodes.push(node); + } + } + + return { startNodes, endNodes }; +} + +/** + * Returns the arrows starting from the given shape + */ +export function getArrowsFromShape(editor: Editor, shapeId: TLShapeId): TLShapeId[] { + const bindings = editor.getBindingsToShape(shapeId, 'arrow') + return bindings.filter(edge => edge.props.terminal === 'start').map(edge => edge.fromId) +} + +/** + * Returns the arrows ending at the given shape + */ +export function getArrowsToShape(editor: Editor, shapeId: TLShapeId): TLShapeId[] { + const bindings = editor.getBindingsToShape(shapeId, 'arrow') + return bindings.filter(edge => edge.props.terminal === 'end').map(edge => edge.fromId) +} + +/** + * Returns the arrows which share the same start shape as the given arrow + */ +export function getSiblingArrowIds(editor: Editor, arrow: TLShape): TLShapeId[] { + if (arrow.type !== 'arrow') return []; + + const bindings = editor.getBindingsInvolvingShape(arrow.id); + if (!bindings || bindings.length !== 2) return []; + + const startShapeId = bindings.find(binding => binding.props.terminal === 'start')?.toId; + if (!startShapeId) return []; + + const siblingBindings = editor.getBindingsToShape(startShapeId, 'arrow'); + const siblingArrows = siblingBindings + .filter(binding => binding.props.terminal === 'start' && binding.fromId !== arrow.id) + .map(binding => binding.fromId); + + return siblingArrows; +} \ No newline at end of file diff --git a/src/propagators/utils.ts b/src/propagators/utils.ts new file mode 100644 index 0000000..13846f8 --- /dev/null +++ b/src/propagators/utils.ts @@ -0,0 +1,22 @@ +import { Editor, TLShape, TLShapePartial } from "tldraw"; + +/** + * @returns true if the shape is of the given type + * @example + * ```ts + * isShapeOfType(shape, 'arrow') + * ``` + */ +export function isShapeOfType(shape: TLShape, type: T['type']): shape is T { + return shape.type === type; +} + +export function updateProps(editor: Editor, shape: T, props: Partial) { + editor.updateShape({ + ...shape, + props: { + ...shape.props, + ...props + }, + } as TLShapePartial) +} \ No newline at end of file diff --git a/src/routes/Board.tsx b/src/routes/Board.tsx index 8951453..7a073ef 100644 --- a/src/routes/Board.tsx +++ b/src/routes/Board.tsx @@ -19,6 +19,7 @@ import { unfurlBookmarkUrl } from "../utils/unfurlBookmarkUrl" import { handleInitialPageLoad } from "@/utils/handleInitialPageLoad" import { MycrozineTemplateTool } from "@/tools/MycrozineTemplateTool" import { MycrozineTemplateShape } from "@/shapes/MycrozineTemplateShapeUtil" +import { registerPropagators, ChangePropagator, TickPropagator, ClickPropagator } from "@/propagators/ScopedPropagators" // Default to production URL if env var isn't available export const WORKER_URL = "https://jeffemmett-canvas.jeffemmett.workers.dev" @@ -88,6 +89,7 @@ export function Board() { editor.registerExternalAssetHandler("url", unfurlBookmarkUrl) editor.setCurrentTool("hand") handleInitialPageLoad(editor) + registerPropagators(editor, [TickPropagator,ChangePropagator,ClickPropagator]) }} />