Ver código fonte

wip: binding starting position of arrow

Peng Xiao 3 anos atrás
pai
commit
302d06428c

+ 1 - 0
tldraw/packages/core/package.json

@@ -41,6 +41,7 @@
     "@tldraw/vec": "2.0.0-alpha.1",
     "@use-gesture/react": "^10.1.3",
     "fast-copy": "^2.1.3",
+    "fast-deep-equal": "^3.1.3",
     "hotkeys-js": "^3.8.7",
     "is-plain-object": "^5.0.0",
     "mobx": "^6.3.8",

+ 351 - 6
tldraw/packages/core/src/lib/TLPage/TLPage.ts

@@ -1,8 +1,22 @@
 /* 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 { 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> {
   id: string
@@ -30,10 +44,15 @@ export class TLPage<S extends TLShape = TLShape, E extends TLEventMap = TLEventM
     makeObservable(this)
 
     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) => {
-        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
   }
 
+  @action updateBindings(bindings: Record<string, TLBinding>) {
+    Object.assign(this.bindings, bindings)
+    return this
+  }
+
   @action addShapes(...shapes: S[] | TLShapeModel[]) {
     if (shapes.length === 0) return
     const shapeInstances =
@@ -178,4 +202,325 @@ export class TLPage<S extends TLShape = TLShape, E extends TLEventMap = TLEventM
       .sort((a, b) => b.nonce - a.nonce)
       .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())
 }

+ 5 - 1
tldraw/packages/core/src/lib/shapes/TLLineShape/TLLineShape.tsx

@@ -1,11 +1,15 @@
 import Vec from '@tldraw/vec'
 import { makeObservable } from 'mobx'
-import type { TLHandle } from '~types'
+import type { Decoration, TLHandle } from '~types'
 import { BoundsUtils, deepCopy, deepMerge } from '~utils'
 import { TLPolylineShape, TLPolylineShapeProps } from '../TLPolylineShape'
 
 export interface TLLineShapeProps extends TLPolylineShapeProps {
   handles: Record<'start' | 'end' | string, TLHandle>
+  decorations?: {
+    start?: Decoration
+    end?: Decoration
+  }
 }
 
 export class TLLineShape<

+ 3 - 3
tldraw/packages/core/src/lib/shapes/TLShape/TLShape.ts

@@ -6,7 +6,7 @@ import {
   intersectRayBounds,
 } from '@tldraw/intersect'
 import Vec from '@tldraw/vec'
-import { action, computed, makeObservable, observable, toJS } from 'mobx'
+import { action, computed, makeObservable, observable, reaction, toJS } from 'mobx'
 import { BINDING_DISTANCE } from '~constants'
 import type { TLAsset, TLBounds, TLHandle, TLResizeCorner, TLResizeEdge } from '~types'
 import { BoundsUtils, deepCopy, PointUtils } from '~utils'
@@ -106,7 +106,7 @@ export abstract class TLShape<P extends TLShapeProps = TLShapeProps, M = any> {
   bindingDistance = BINDING_DISTANCE
 
   private isDirty = false
-  private lastSerialized = {} as TLShapeModel<P>
+  private lastSerialized: TLShapeModel<P> | undefined
 
   abstract getBounds: () => TLBounds
 
@@ -259,7 +259,7 @@ export abstract class TLShape<P extends TLShapeProps = TLShapeProps, M = any> {
   }
 
   protected getCachedSerialized = (): TLShapeModel<P> => {
-    if (this.isDirty || Object.keys(this.lastSerialized).length === 0) {
+    if (this.isDirty || !this.lastSerialized) {
       this.nonce++
       this.isDirty = false
       this.lastSerialized = this.getSerialized()

+ 9 - 6
tldraw/packages/core/src/lib/tools/TLLineTool/states/CreatingState.tsx

@@ -1,7 +1,7 @@
 import Vec from '@tldraw/vec'
 import { toJS } from 'mobx'
 import { TLApp, TLLineShape, TLLineShapeProps, TLShape, TLToolState } from '~lib'
-import type { TLEventMap, TLLineBinding, TLStateEvents } from '~types'
+import type { TLEventMap, TLBinding, TLStateEvents } from '~types'
 import { deepMerge, GeomUtils, PointUtils, uniqueId } from '~utils'
 import type { TLLineTool } from '../TLLineTool'
 
@@ -36,6 +36,8 @@ export class CreatingState<
     this.creatingShape = shape
     this.app.currentPage.addShapes(shape)
     this.app.setSelectedShapes([shape])
+    this.newStartBindingId = uniqueId()
+    this.draggedBindingId = uniqueId()
 
     const page = this.app.currentPage
 
@@ -80,7 +82,7 @@ export class CreatingState<
     const handleChanges = {
       [handleId]: {
         ...handles[handleId],
-        // FIXMEL Snap not working properly
+        // FIXME Snap not working properly
         point: showGrid ? Vec.snap(nextPoint, currentGrid) : Vec.toFixed(nextPoint),
         bindingId: undefined,
       },
@@ -95,12 +97,12 @@ export class CreatingState<
     // before. If it does change, we'll redefine this later on. And if we've
     // made it this far, the shape should be a new object reference that
     // incorporates the changes we've made due to the handle movement.
-    const next: { shape: TLLineShapeProps; bindings: Record<string, TLLineBinding> } = {
+    const next: { shape: TLLineShapeProps; bindings: Record<string, TLBinding> } = {
       shape: deepMerge(shape.props, updated),
       bindings: {},
     }
 
-    let draggedBinding: TLLineBinding | undefined
+    let draggedBinding: TLBinding | undefined
 
     const draggingHandle = next.shape.handles[handleId]
     const oppositeHandle = next.shape.handles[otherHandleId]
@@ -110,7 +112,7 @@ export class CreatingState<
     // point based on the current end handle position
 
     if (this.startBindingShapeId) {
-      let nextStartBinding: TLLineBinding | undefined
+      let nextStartBinding: TLBinding | undefined
 
       const startTarget = this.app.getShapeById(this.startBindingShapeId)
       const center = startTarget.getCenter()
@@ -148,6 +150,7 @@ export class CreatingState<
         next.bindings[this.newStartBindingId] = nextStartBinding
         next.shape.handles.start.bindingId = nextStartBinding.id
       } else if (!nextStartBinding && hasStartBinding) {
+        console.log('removing start binding')
         delete next.bindings[this.newStartBindingId]
         next.shape.handles.start.bindingId = undefined
       }
@@ -157,7 +160,7 @@ export class CreatingState<
 
     if (updated) {
       this.creatingShape.update(updated)
-      Object.assign(this.app.currentPage.bindings, next.bindings)
+      this.app.currentPage.updateBindings(next.bindings)
     }
   }
 

+ 4 - 3
tldraw/packages/core/src/types/types.ts

@@ -88,9 +88,6 @@ export interface TLBinding {
   id: string
   toId: string
   fromId: string
-}
-
-export interface TLLineBinding extends TLBinding {
   handleId: 'start' | 'end'
   distance: number
   point: number[]
@@ -260,3 +257,7 @@ export function isStringArray(arr: string[] | any[]): asserts arr is string[] {
 /* ---------------------- Misc ---------------------- */
 
 export type AnyObject = { [key: string]: any }
+
+export enum Decoration {
+  Arrow = 'arrow',
+}

+ 1 - 0
tldraw/packages/core/src/utils/DataUtils.ts

@@ -1,5 +1,6 @@
 import { isPlainObject } from 'is-plain-object'
 import copy from 'fast-copy'
+export { default as deepEqual } from 'fast-deep-equal'
 import deepmerge from 'deepmerge'
 
 /* eslint-disable @typescript-eslint/no-explicit-any */