|
@@ -1,8 +1,22 @@
|
|
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
|
-import { action, observable, makeObservable, computed, observe, reaction, toJS } from 'mobx'
|
|
|
|
|
-import { TLBinding, TLEventMap, TLResizeCorner } from '~types'
|
|
|
|
|
|
|
+import { intersectRayBounds } from '@tldraw/intersect'
|
|
|
|
|
+import Vec from '@tldraw/vec'
|
|
|
|
|
+import {
|
|
|
|
|
+ action,
|
|
|
|
|
+ autorun,
|
|
|
|
|
+ computed,
|
|
|
|
|
+ makeObservable,
|
|
|
|
|
+ observable,
|
|
|
|
|
+ observe,
|
|
|
|
|
+ reaction,
|
|
|
|
|
+ toJS,
|
|
|
|
|
+ transaction,
|
|
|
|
|
+} from 'mobx'
|
|
|
|
|
+import { BINDING_DISTANCE } from '~constants'
|
|
|
import type { TLApp, TLShape, TLShapeModel } from '~lib'
|
|
import type { TLApp, TLShape, TLShapeModel } from '~lib'
|
|
|
-import { BoundsUtils, deepCopy } from '~utils'
|
|
|
|
|
|
|
+import type { TLLineShape, TLShapeProps } from '~lib/shapes'
|
|
|
|
|
+import { TLBinding, TLBounds, TLEventMap, TLHandle, TLResizeCorner } from '~types'
|
|
|
|
|
+import { BoundsUtils, deepCopy, deepEqual, PointUtils } from '~utils'
|
|
|
|
|
|
|
|
export interface TLPageModel<S extends TLShape = TLShape> {
|
|
export interface TLPageModel<S extends TLShape = TLShape> {
|
|
|
id: string
|
|
id: string
|
|
@@ -30,10 +44,15 @@ export class TLPage<S extends TLShape = TLShape, E extends TLEventMap = TLEventM
|
|
|
makeObservable(this)
|
|
makeObservable(this)
|
|
|
|
|
|
|
|
reaction(
|
|
reaction(
|
|
|
- () => this.shapes.map(shape => toJS(shape.props)),
|
|
|
|
|
- // todo: recalculate binding positions
|
|
|
|
|
|
|
+ () => ({
|
|
|
|
|
+ id: this.id,
|
|
|
|
|
+ name: this.name,
|
|
|
|
|
+ shapes: this.shapes.map(shape => toJS(shape.props)),
|
|
|
|
|
+ bindings: toJS(this.bindings),
|
|
|
|
|
+ nonce: this.nonce,
|
|
|
|
|
+ }),
|
|
|
(curr, prev) => {
|
|
(curr, prev) => {
|
|
|
- console.log(curr, prev)
|
|
|
|
|
|
|
+ this.cleanup(curr, prev)
|
|
|
}
|
|
}
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
@@ -69,6 +88,11 @@ export class TLPage<S extends TLShape = TLShape, E extends TLEventMap = TLEventM
|
|
|
return this
|
|
return this
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ @action updateBindings(bindings: Record<string, TLBinding>) {
|
|
|
|
|
+ Object.assign(this.bindings, bindings)
|
|
|
|
|
+ return this
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
@action addShapes(...shapes: S[] | TLShapeModel[]) {
|
|
@action addShapes(...shapes: S[] | TLShapeModel[]) {
|
|
|
if (shapes.length === 0) return
|
|
if (shapes.length === 0) return
|
|
|
const shapeInstances =
|
|
const shapeInstances =
|
|
@@ -178,4 +202,325 @@ export class TLPage<S extends TLShape = TLShape, E extends TLEventMap = TLEventM
|
|
|
.sort((a, b) => b.nonce - a.nonce)
|
|
.sort((a, b) => b.nonce - a.nonce)
|
|
|
.map(s => s.id)
|
|
.map(s => s.id)
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ getShapeById = <T extends S>(id: string): T => {
|
|
|
|
|
+ const shape = this.shapes.find(shape => shape.id === id) as T
|
|
|
|
|
+ if (!shape) throw Error(`Could not find that shape: ${id}`)
|
|
|
|
|
+ return shape
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Recalculate binding positions
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param curr
|
|
|
|
|
+ * @param prev
|
|
|
|
|
+ */
|
|
|
|
|
+ @action
|
|
|
|
|
+ cleanup = (curr: TLPageModel, prev: TLPageModel) => {
|
|
|
|
|
+ const updated = deepCopy(curr)
|
|
|
|
|
+ const changedShapes: Record<string, TLShapeModel<S['props']> | undefined> = {}
|
|
|
|
|
+ const prevShapes = Object.fromEntries(prev.shapes.map(shape => [shape.id, shape]))
|
|
|
|
|
+ const currShapes = Object.fromEntries(curr.shapes.map(shape => [shape.id, shape]))
|
|
|
|
|
+
|
|
|
|
|
+ // TODO: deleted shapes?
|
|
|
|
|
+ curr.shapes.forEach(shape => {
|
|
|
|
|
+ if (deepEqual(shape, prevShapes[shape.id])) {
|
|
|
|
|
+ changedShapes[shape.id] = shape
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // Get bindings related to the changed shapes
|
|
|
|
|
+ const bindingsToUpdate =
|
|
|
|
|
+ Object.values(curr.bindings) ||
|
|
|
|
|
+ // fixme:
|
|
|
|
|
+ getRelatedBindings(curr, Object.keys(changedShapes))
|
|
|
|
|
+
|
|
|
|
|
+ const visitedShapes = new Set<TLShapeModel>()
|
|
|
|
|
+
|
|
|
|
|
+ // Update all of the bindings we've just collected
|
|
|
|
|
+ bindingsToUpdate.forEach(binding => {
|
|
|
|
|
+ if (!updated.bindings[binding.id]) {
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const toShape = currShapes[binding.toId]
|
|
|
|
|
+ const fromShape = currShapes[binding.fromId]
|
|
|
|
|
+
|
|
|
|
|
+ if (!(toShape && fromShape)) {
|
|
|
|
|
+ delete updated.bindings[binding.id]
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (visitedShapes.has(fromShape)) {
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // We only need to update the binding's "from" shape (an arrow)
|
|
|
|
|
+ // @ts-expect-error ???
|
|
|
|
|
+ const fromDelta = this.updateArrowBindings(this.getShapeById<TLLineShape>(fromShape.id))
|
|
|
|
|
+ visitedShapes.add(fromShape)
|
|
|
|
|
+
|
|
|
|
|
+ if (fromDelta) {
|
|
|
|
|
+ const nextShape = {
|
|
|
|
|
+ ...fromShape,
|
|
|
|
|
+ ...fromDelta,
|
|
|
|
|
+ }
|
|
|
|
|
+ const idx = updated.shapes.findIndex(s => s.id === nextShape.id)
|
|
|
|
|
+ if (idx !== -1) {
|
|
|
|
|
+ updated.shapes[idx] = nextShape
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ if (!deepEqual(updated, curr)) {
|
|
|
|
|
+ transaction(() => {
|
|
|
|
|
+ this.update({
|
|
|
|
|
+ bindings: updated.bindings,
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ updated.shapes.forEach(shape => {
|
|
|
|
|
+ this.getShapeById(shape.id).update(shape)
|
|
|
|
|
+ })
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ updateArrowBindings = (lineShape: TLLineShape) => {
|
|
|
|
|
+ const result = {
|
|
|
|
|
+ start: deepCopy(lineShape.props.handles.start),
|
|
|
|
|
+ end: deepCopy(lineShape.props.handles.end),
|
|
|
|
|
+ }
|
|
|
|
|
+ type HandleInfo = {
|
|
|
|
|
+ handle: TLHandle
|
|
|
|
|
+ point: number[] // in page space
|
|
|
|
|
+ } & (
|
|
|
|
|
+ | {
|
|
|
|
|
+ isBound: false
|
|
|
|
|
+ }
|
|
|
|
|
+ | {
|
|
|
|
|
+ isBound: true
|
|
|
|
|
+ hasDecoration: boolean
|
|
|
|
|
+ binding: TLBinding
|
|
|
|
|
+ target: TLShape
|
|
|
|
|
+ bounds: TLBounds
|
|
|
|
|
+ expandedBounds: TLBounds
|
|
|
|
|
+ intersectBounds: TLBounds
|
|
|
|
|
+ center: number[]
|
|
|
|
|
+ }
|
|
|
|
|
+ )
|
|
|
|
|
+ let start: HandleInfo = {
|
|
|
|
|
+ isBound: false,
|
|
|
|
|
+ handle: lineShape.props.handles.start,
|
|
|
|
|
+ point: Vec.add(lineShape.props.handles.start.point, lineShape.props.point),
|
|
|
|
|
+ }
|
|
|
|
|
+ let end: HandleInfo = {
|
|
|
|
|
+ isBound: false,
|
|
|
|
|
+ handle: lineShape.props.handles.end,
|
|
|
|
|
+ point: Vec.add(lineShape.props.handles.end.point, lineShape.props.point),
|
|
|
|
|
+ }
|
|
|
|
|
+ if (lineShape.props.handles.start.bindingId) {
|
|
|
|
|
+ const hasDecoration = lineShape.props.decorations?.start !== undefined
|
|
|
|
|
+ const handle = lineShape.props.handles.start
|
|
|
|
|
+ const binding = this.bindings[lineShape.props.handles.start.bindingId]
|
|
|
|
|
+ if (!binding) throw Error("Could not find a binding to match the start handle's bindingId")
|
|
|
|
|
+ const target = this.getShapeById(binding.toId)
|
|
|
|
|
+ const bounds = target.getBounds()
|
|
|
|
|
+ const expandedBounds = target.getExpandedBounds()
|
|
|
|
|
+ const intersectBounds = BoundsUtils.expandBounds(bounds, hasDecoration ? binding.distance : 1)
|
|
|
|
|
+ const { minX, minY, width, height } = expandedBounds
|
|
|
|
|
+ const anchorPoint = Vec.add(
|
|
|
|
|
+ [minX, minY],
|
|
|
|
|
+ Vec.mulV(
|
|
|
|
|
+ [width, height],
|
|
|
|
|
+ Vec.rotWith(binding.point, [0.5, 0.5], target.props.rotation || 0)
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ start = {
|
|
|
|
|
+ isBound: true,
|
|
|
|
|
+ hasDecoration,
|
|
|
|
|
+ binding,
|
|
|
|
|
+ handle,
|
|
|
|
|
+ point: anchorPoint,
|
|
|
|
|
+ target,
|
|
|
|
|
+ bounds,
|
|
|
|
|
+ expandedBounds,
|
|
|
|
|
+ intersectBounds,
|
|
|
|
|
+ center: target.getCenter(),
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (lineShape.props.handles.end.bindingId) {
|
|
|
|
|
+ const hasDecoration = lineShape.props.decorations?.end !== undefined
|
|
|
|
|
+ const handle = lineShape.props.handles.end
|
|
|
|
|
+ const binding = this.bindings[lineShape.props.handles.end.bindingId]
|
|
|
|
|
+ if (!binding) throw Error("Could not find a binding to match the end handle's bindingId")
|
|
|
|
|
+ const target = this.getShapeById(binding.toId)
|
|
|
|
|
+ const bounds = target.getBounds()
|
|
|
|
|
+ const expandedBounds = target.getExpandedBounds()
|
|
|
|
|
+ const intersectBounds = hasDecoration
|
|
|
|
|
+ ? BoundsUtils.expandBounds(bounds, binding.distance)
|
|
|
|
|
+ : bounds
|
|
|
|
|
+ const { minX, minY, width, height } = expandedBounds
|
|
|
|
|
+ const anchorPoint = Vec.add(
|
|
|
|
|
+ [minX, minY],
|
|
|
|
|
+ Vec.mulV(
|
|
|
|
|
+ [width, height],
|
|
|
|
|
+ Vec.rotWith(binding.point, [0.5, 0.5], target.props.rotation || 0)
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ end = {
|
|
|
|
|
+ isBound: true,
|
|
|
|
|
+ hasDecoration,
|
|
|
|
|
+ binding,
|
|
|
|
|
+ handle,
|
|
|
|
|
+ point: anchorPoint,
|
|
|
|
|
+ target,
|
|
|
|
|
+ bounds,
|
|
|
|
|
+ expandedBounds,
|
|
|
|
|
+ intersectBounds,
|
|
|
|
|
+ center: target.getCenter(),
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ for (const ID of ['end', 'start'] as const) {
|
|
|
|
|
+ const A = ID === 'start' ? start : end
|
|
|
|
|
+ const B = ID === 'start' ? end : start
|
|
|
|
|
+ if (A.isBound) {
|
|
|
|
|
+ if (!A.binding.distance) {
|
|
|
|
|
+ // If the binding distance is zero, then the arrow is bound to a specific point
|
|
|
|
|
+ // in the target shape. The resulting handle should be exactly at that point.
|
|
|
|
|
+ result[ID].point = Vec.sub(A.point, lineShape.props.point)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // We'll need to figure out the handle's true point based on some intersections
|
|
|
|
|
+ // between the opposite handle point and this handle point. This is different
|
|
|
|
|
+ // for each type of shape.
|
|
|
|
|
+ const direction = Vec.uni(Vec.sub(A.point, B.point))
|
|
|
|
|
+ switch (A.target.type) {
|
|
|
|
|
+ // TODO: do we need to support othershapes?
|
|
|
|
|
+ default: {
|
|
|
|
|
+ const hits = intersectRayBounds(
|
|
|
|
|
+ B.point,
|
|
|
|
|
+ direction,
|
|
|
|
|
+ A.intersectBounds,
|
|
|
|
|
+ A.target.props.rotation
|
|
|
|
|
+ )
|
|
|
|
|
+ .filter(int => int.didIntersect)
|
|
|
|
|
+ .map(int => int.points[0])
|
|
|
|
|
+ .sort((a, b) => Vec.dist(a, B.point) - Vec.dist(b, B.point))
|
|
|
|
|
+ if (!hits[0]) continue
|
|
|
|
|
+ let bHit: number[] | undefined = undefined
|
|
|
|
|
+ if (B.isBound) {
|
|
|
|
|
+ const bHits = intersectRayBounds(
|
|
|
|
|
+ B.point,
|
|
|
|
|
+ direction,
|
|
|
|
|
+ B.intersectBounds,
|
|
|
|
|
+ B.target.props.rotation
|
|
|
|
|
+ )
|
|
|
|
|
+ .filter(int => int.didIntersect)
|
|
|
|
|
+ .map(int => int.points[0])
|
|
|
|
|
+ .sort((a, b) => Vec.dist(a, B.point) - Vec.dist(b, B.point))
|
|
|
|
|
+ bHit = bHits[0]
|
|
|
|
|
+ }
|
|
|
|
|
+ if (
|
|
|
|
|
+ B.isBound &&
|
|
|
|
|
+ (hits.length < 2 ||
|
|
|
|
|
+ (bHit &&
|
|
|
|
|
+ hits[0] &&
|
|
|
|
|
+ Math.ceil(Vec.dist(hits[0], bHit)) < BINDING_DISTANCE * 2.5) ||
|
|
|
|
|
+ BoundsUtils.boundsContain(A.expandedBounds, B.expandedBounds) ||
|
|
|
|
|
+ BoundsUtils.boundsCollide(A.expandedBounds, B.expandedBounds))
|
|
|
|
|
+ ) {
|
|
|
|
|
+ // If the other handle is bound, and if we need to fallback to the short arrow method...
|
|
|
|
|
+ const shortArrowDirection = Vec.uni(Vec.sub(B.point, A.point))
|
|
|
|
|
+ const shortArrowHits = intersectRayBounds(
|
|
|
|
|
+ A.point,
|
|
|
|
|
+ shortArrowDirection,
|
|
|
|
|
+ A.bounds,
|
|
|
|
|
+ A.target.props.rotation
|
|
|
|
|
+ )
|
|
|
|
|
+ .filter(int => int.didIntersect)
|
|
|
|
|
+ .map(int => int.points[0])
|
|
|
|
|
+ if (!shortArrowHits[0]) continue
|
|
|
|
|
+ result[ID].point = Vec.toFixed(Vec.sub(shortArrowHits[0], lineShape.props.point))
|
|
|
|
|
+ result[ID === 'start' ? 'end' : 'start'].point = Vec.toFixed(
|
|
|
|
|
+ Vec.add(
|
|
|
|
|
+ Vec.sub(shortArrowHits[0], lineShape.props.point),
|
|
|
|
|
+ Vec.mul(
|
|
|
|
|
+ shortArrowDirection,
|
|
|
|
|
+ Math.min(
|
|
|
|
|
+ Vec.dist(shortArrowHits[0], B.point),
|
|
|
|
|
+ BINDING_DISTANCE *
|
|
|
|
|
+ 2.5 *
|
|
|
|
|
+ (BoundsUtils.boundsContain(B.bounds, A.intersectBounds) ? -1 : 1)
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ } else if (
|
|
|
|
|
+ !B.isBound &&
|
|
|
|
|
+ ((hits[0] && Vec.dist(hits[0], B.point) < BINDING_DISTANCE * 2.5) ||
|
|
|
|
|
+ PointUtils.pointInBounds(B.point, A.intersectBounds))
|
|
|
|
|
+ ) {
|
|
|
|
|
+ // Short arrow time!
|
|
|
|
|
+ const shortArrowDirection = Vec.uni(Vec.sub(A.center, B.point))
|
|
|
|
|
+ return lineShape.getHandlesChange?.(lineShape.props, {
|
|
|
|
|
+ [ID]: {
|
|
|
|
|
+ ...lineShape.props.handles[ID],
|
|
|
|
|
+ point: Vec.toFixed(
|
|
|
|
|
+ Vec.add(
|
|
|
|
|
+ Vec.sub(B.point, lineShape.props.point),
|
|
|
|
|
+ Vec.mul(shortArrowDirection, BINDING_DISTANCE * 2.5)
|
|
|
|
|
+ )
|
|
|
|
|
+ ),
|
|
|
|
|
+ },
|
|
|
|
|
+ })
|
|
|
|
|
+ } else if (hits[0]) {
|
|
|
|
|
+ result[ID].point = Vec.toFixed(Vec.sub(hits[0], lineShape.props.point))
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return lineShape.getHandlesChange(lineShape.props, result)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function getRelatedBindings(page: TLPageModel, ids: string[]): TLBinding[] {
|
|
|
|
|
+ const changedShapeIds = new Set(ids)
|
|
|
|
|
+ const bindingsArr = Object.values(page.bindings)
|
|
|
|
|
+
|
|
|
|
|
+ // Start with bindings that are directly bound to our changed shapes
|
|
|
|
|
+ const bindingsToUpdate = new Set(
|
|
|
|
|
+ bindingsArr.filter(
|
|
|
|
|
+ binding => changedShapeIds.has(binding.toId) || changedShapeIds.has(binding.fromId)
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ // Next, look for other bindings that effect the same shapes
|
|
|
|
|
+ let prevSize = bindingsToUpdate.size
|
|
|
|
|
+ let delta = -1
|
|
|
|
|
+
|
|
|
|
|
+ while (delta !== 0) {
|
|
|
|
|
+ bindingsToUpdate.forEach(binding => {
|
|
|
|
|
+ const fromId = binding.fromId
|
|
|
|
|
+
|
|
|
|
|
+ for (const otherBinding of bindingsArr) {
|
|
|
|
|
+ if (otherBinding.fromId === fromId) {
|
|
|
|
|
+ bindingsToUpdate.add(otherBinding)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (otherBinding.toId === fromId) {
|
|
|
|
|
+ bindingsToUpdate.add(otherBinding)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // Continue until we stop finding new bindings to update
|
|
|
|
|
+ delta = bindingsToUpdate.size - prevSize
|
|
|
|
|
+
|
|
|
|
|
+ prevSize = bindingsToUpdate.size
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return Array.from(bindingsToUpdate.values())
|
|
|
}
|
|
}
|