clean demo repo
This commit is contained in:
commit
e92668e8ed
|
|
@ -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
|
||||
|
|
@ -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`.
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.8.2/schema.json"
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
])
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 />
|
||||
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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;
|
||||
// }
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
})
|
||||
Loading…
Reference in New Issue