added scoped propagators (with javascript object on arrow edge to control)
This commit is contained in:
parent
bfbe7b8325
commit
1783d1b6eb
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
27
src/App.tsx
27
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() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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<TLArrowShape>(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<TLArrowShape>(arrow, 'arrow')) return false
|
||||
const regex = new RegExp(`^\\s*${prefix}\\s*\\(\\)\\s*\\{`)
|
||||
return regex.test(arrow.props.text)
|
||||
}
|
||||
|
||||
class ArrowFunctionCache {
|
||||
private cache: Map<string, Function | null> = new Map<string, Function | null>()
|
||||
|
||||
/** 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<TLArrowShape>(shape, 'arrow')) {
|
||||
prop.onArrowChange(editor, shape)
|
||||
}
|
||||
}
|
||||
editor.sideEffects.registerAfterChangeHandler<"shape">("shape", (_, next) => {
|
||||
if (isShapeOfType<TLGroupShape>(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<TLArrowShape>(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<TLArrowShape>(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<TLShapeId> = new Set<TLShapeId>()
|
||||
protected listenerShapes: Set<TLShapeId> = new Set<TLShapeId>()
|
||||
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<TLShapeId>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<typeof this.createSpatialIndex>
|
||||
private lastPageId: TLPageId | null = null
|
||||
private shapesInTree: Map<TLShapeId, Element>
|
||||
private rBush: RBush<Element>
|
||||
|
||||
constructor(private editor: Editor) {
|
||||
this.spatialIndex = this.createSpatialIndex()
|
||||
this.shapesInTree = new Map<TLShapeId, Element>()
|
||||
this.rBush = new RBush<Element>()
|
||||
}
|
||||
|
||||
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<TLShapeId, Element>()
|
||||
const elementsToAdd: Element[] = []
|
||||
|
||||
this.editor.getCurrentPageShapeIds().forEach((id) => {
|
||||
this.addElement(id, elementsToAdd)
|
||||
})
|
||||
|
||||
this.rBush = new RBush<Element>().load(elementsToAdd)
|
||||
|
||||
return lastComputedEpoch
|
||||
}
|
||||
|
||||
private createSpatialIndex() {
|
||||
const shapeHistory = this.editor.store.query.filterHistory('shape')
|
||||
|
||||
return computed<number>('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<Set<TLShapeId>>('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<Set<TLShapeId>>('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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<TLArrowShape>(shape, 'arrow')) return undefined
|
||||
const bindings = editor.getBindingsInvolvingShape<TLArrowBinding>(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<TLShapeId> = new Set<TLShapeId>()
|
||||
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<TLShapeId>(graph.edges.map(e => e.to));
|
||||
const sourceNodes = new Set<TLShapeId>(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<TLArrowBinding>(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<TLArrowBinding>(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<TLArrowBinding>(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<TLArrowBinding>(startShapeId, 'arrow');
|
||||
const siblingArrows = siblingBindings
|
||||
.filter(binding => binding.props.terminal === 'start' && binding.fromId !== arrow.id)
|
||||
.map(binding => binding.fromId);
|
||||
|
||||
return siblingArrows;
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import { Editor, TLShape, TLShapePartial } from "tldraw";
|
||||
|
||||
/**
|
||||
* @returns true if the shape is of the given type
|
||||
* @example
|
||||
* ```ts
|
||||
* isShapeOfType<TLArrowShape>(shape, 'arrow')
|
||||
* ```
|
||||
*/
|
||||
export function isShapeOfType<T extends TLShape>(shape: TLShape, type: T['type']): shape is T {
|
||||
return shape.type === type;
|
||||
}
|
||||
|
||||
export function updateProps<T extends TLShape>(editor: Editor, shape: T, props: Partial<T['props']>) {
|
||||
editor.updateShape({
|
||||
...shape,
|
||||
props: {
|
||||
...shape.props,
|
||||
...props
|
||||
},
|
||||
} as TLShapePartial)
|
||||
}
|
||||
|
|
@ -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])
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue