clean demo repo

This commit is contained in:
Orion Reed 2024-06-25 12:25:51 +01:00
commit e92668e8ed
19 changed files with 2434 additions and 0 deletions

28
.gitignore vendored Normal file
View File

@ -0,0 +1,28 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
.yarn
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
dbDir
*.tsbuildinfo

23
README.md Normal file
View File

@ -0,0 +1,23 @@
# scoped proagators
"Scoped propagators" are formed of a `scope` and a `propagator` which often looks like this:
`click { text: "foo" }`
The `scope` sets the events that cause propagation, such as clicks, ticks, or shape changes (not adding a scope will default to shape changes).
The `propagator` is a JS object (or function which returns one) that is applied to the shape.
## Notes
- shapes are passed both `from` and `to` shapes.
- Shapes are flattened before being passed to the propagator, and unpacked on the other side. So properties live alongside the `x`, `y`, and `rotation` values (e.g. `{ x: 100, y: 100, text: "foo" }`).
Current Issues (probably should be fixed before putting out a demo):
- cycles of `change` propagators cause infinite recursion.
- `geo` scopes are currently fired for any shape change, this should be localised to spatially local changes.
## Effects / Generic JS
You can create effects or run arbitrary JS code if you use the full function syntax:
`click () { return { text: "foo" } }`
This can be useful for larger propagators, or for doing arbitrary stuff with the `editor`.

3
biome.json Normal file
View File

@ -0,0 +1,3 @@
{
"$schema": "https://biomejs.dev/schemas/1.8.2/schema.json"
}

14
index.html Normal file
View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="./tldraw.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>scoped propagators
</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

27
package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "scoped-propagators",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tsc": "tsc"
},
"dependencies": {
"rbush": "^3.0.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"tldraw": "2.2.1"
},
"devDependencies": {
"@types/node": "^20.14.2",
"@types/rbush": "^3.0.3",
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@vitejs/plugin-react": "^4.0.3",
"typescript": "^5.5.2",
"vite": "^4.4.5"
}
}

32
src/App.tsx Normal file
View File

@ -0,0 +1,32 @@
import { Editor, TLArrowShape, Tldraw } from 'tldraw'
import { CustomMainMenu } from '@/CustomMainMenu'
import { ClickPropagator, ChangePropagator, TickPropagator, SpatialPropagator, registerPropagators } from '@/propagators/ScopedPropagators'
export default function YjsExample() {
return (
<div className="tldraw__editor">
<Tldraw
components={{
MainMenu: CustomMainMenu,
}}
onMount={onMount}
persistenceKey='funcArrows'
/>
</div>
)
}
function onMount(editor: Editor) {
//@ts-expect-error
window.editor = editor
// stop double click text creation
//@ts-expect-error
editor.getStateDescendant('select.idle').handleDoubleClickOnCanvas = () => void null;
registerPropagators(editor, [
ChangePropagator,
ClickPropagator,
TickPropagator,
SpatialPropagator,
])
}

57
src/CustomMainMenu.tsx Normal file
View File

@ -0,0 +1,57 @@
import {
DefaultMainMenu,
TldrawUiMenuItem,
Editor,
TLContent,
DefaultMainMenuContent,
useEditor,
useExportAs,
} from "tldraw";
export function CustomMainMenu() {
const editor = useEditor()
const exportAs = useExportAs()
const importJSON = (editor: Editor) => {
const input = document.createElement("input");
input.type = "file";
input.accept = ".json";
input.onchange = (event) => {
const file = (event.target as HTMLInputElement).files?.[0];
const reader = new FileReader();
reader.onload = (event) => {
if (typeof event.target.result !== 'string') {
return
}
const jsonData = JSON.parse(event.target.result) as TLContent
editor.putContentOntoCurrentPage(jsonData, { select: true })
};
reader.readAsText(file);
};
input.click();
};
const exportJSON = (editor: Editor) => {
const exportName = `props-${Math.round(+new Date() / 1000).toString().slice(5)}`
exportAs(Array.from(editor.getCurrentPageShapeIds()), 'json', exportName)
};
return (
<DefaultMainMenu>
<DefaultMainMenuContent />
<TldrawUiMenuItem
id="export"
label="Export JSON"
icon="external-link"
readonlyOk
onSelect={() => exportJSON(editor)}
/>
<TldrawUiMenuItem
id="import"
label="Import JSON"
icon="external-link"
readonlyOk
onSelect={() => importJSON(editor)}
/>
</DefaultMainMenu>
)
}

23
src/DeltaTime.ts Normal file
View File

@ -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) {
DeltaTime._dt = nowish - DeltaTime.lastTime
DeltaTime.lastTime = nowish
window.requestAnimationFrame(DeltaTime.tick)
}
}

119
src/Geo.ts Normal file
View File

@ -0,0 +1,119 @@
import { SpatialIndex } from "@/SpatialIndex"
import { 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)
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) || pageShapeBounds.contains(sourceBounds)) {
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)
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) || pageShapeBounds.contains(sourceBounds)) {
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)
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)) {
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)
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)) {
contains.push(pageShape)
}
}
return contains
}
}

165
src/SpatialIndex.ts Normal file
View File

@ -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) => s.id)
if (isUninitialized(prevValue)) {
return new Set(newValue)
}
const isSame = prevValue.size === newValue.length && newValue.every((id) => 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) => s.id)
}
}

31
src/index.css Normal file
View File

@ -0,0 +1,31 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap");
html,
body {
padding: 0;
margin: 0;
font-family: "Inter", sans-serif;
overscroll-behavior: none;
touch-action: none;
min-height: 100vh;
font-size: 16px;
/* mobile viewport bug fix */
min-height: -webkit-fill-available;
height: 100%;
}
html,
* {
box-sizing: border-box;
}
.tldraw__editor {
position: fixed;
inset: 0px;
overflow: hidden;
z-index: 0;
}
.tl-user-handles {
z-index: 101;
}

10
src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
import 'tldraw/tldraw.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<App />
)

View File

@ -0,0 +1,294 @@
import { DeltaTime } from "@/DeltaTime"
import { Geo } from "@/Geo"
import { Edge, getArrowsFromShape, getEdge } from "@/tlgraph"
import { isShapeOfType, updateProps } from "@/utils"
import { Editor, TLArrowShape, TLBinding, TLGroupShape, TLShape, TLShapeId } from "tldraw"
type Prefix = 'click' | 'tick' | 'geo' | ''
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)
}
console.log('creating func because it didnt exist')
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);
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)
}
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
const arrowShape = editor.getShape(arrow) as TLArrowShape
const fromShape = editor.getShape(edge.from)
const toShape = editor.getShape(edge.to)
const fromShapePacked = packShape(fromShape)
const toShapePacked = packShape(toShape)
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) {
console.error(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)
}
}
}

115
src/tlgraph.ts Normal file
View File

@ -0,0 +1,115 @@
import { isShapeOfType } from "@/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;
}

22
src/utils.ts Normal file
View File

@ -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)
}

10
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
// /// <reference types="vite/client" />
// interface ImportMetaEnv {
// readonly MODE: string;
// // Add other environment variables here if needed
// }
// interface ImportMeta {
// readonly env: ImportMetaEnv;
// }

23
tsconfig.json Normal file
View File

@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2021",
"composite": true,
"allowSyntheticDefaultImports": true,
"useDefineForClassFields": true,
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src","vite.config.ts"],
"types": ["vite/client"]
}

12
vite.config.ts Normal file
View File

@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { resolve } from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
}
})

1426
yarn.lock Normal file

File diff suppressed because it is too large Load Diff