Pārlūkot izejas kodu

binding two shapes

Peng Xiao 3 gadi atpakaļ
vecāks
revīzija
eb585ecf2b

+ 9 - 7
tldraw/packages/core/src/lib/TLApp/TLApp.ts

@@ -463,20 +463,22 @@ export class TLApp<
 
   /* ------------------ Binding Shape ----------------- */
 
-  @observable bindingId?: string
+  @observable bindingIds?: string[]
 
-  @computed get bindingShape(): S | undefined {
-    const { bindingId, currentPage } = this
-    return bindingId ? currentPage.shapes.find(shape => shape.id === bindingId) : undefined
+  @computed get bindingShapes(): S[] | undefined {
+    const { bindingIds, currentPage } = this
+    return bindingIds
+      ? currentPage.shapes.filter(shape => bindingIds?.includes(shape.id))
+      : undefined
   }
 
-  @action readonly setBindingShape = (shape?: string | S): this => {
-    this.bindingId = typeof shape === 'string' ? shape : shape?.id
+  @action readonly setBindingShapes = (ids?: string[]): this => {
+    this.bindingIds = ids
     return this
   }
 
   readonly clearBindingShape = (): this => {
-    return this.setBindingShape()
+    return this.setBindingShapes()
   }
 
   /* ---------------------- Brush --------------------- */

+ 257 - 0
tldraw/packages/core/src/lib/TLBaseLineBindingState.ts

@@ -0,0 +1,257 @@
+import Vec from '@tldraw/vec'
+import { TLApp, TLLineShape, TLLineShapeProps, TLShape, TLTool, TLToolState } from '~lib'
+import type { TLBinding, TLEventMap, TLHandle, TLStateEvents } from '~types'
+import { deepMerge, GeomUtils } from '~utils'
+
+export class TLBaseLineBindingState<
+  S extends TLShape,
+  T extends S & TLLineShape,
+  K extends TLEventMap,
+  R extends TLApp<S, K>,
+  P extends TLTool<S, K, R>
+> extends TLToolState<S, K, R, P> {
+  static id = 'creating'
+
+  handle: TLHandle = {} as TLHandle
+  handleId: 'start' | 'end' = 'end'
+  currentShape = {} as T
+  initialShape = {} as T['props']
+  bindableShapeIds: string[] = []
+  startBindingShapeId?: string
+  newStartBindingId = ''
+  draggedBindingId = ''
+
+  onPointerMove: TLStateEvents<S, K>['onPointerMove'] = () => {
+    const {
+      inputs: { shiftKey, previousPoint, originPoint, currentPoint, modKey, altKey },
+      settings: { showGrid },
+      currentGrid,
+    } = this.app
+    // @ts-expect-error just ignore
+    const shape = this.app.getShapeById<TLLineShape>(this.initialShape.id)
+
+    const { handles } = this.initialShape
+    const handleId = this.handleId
+    const otherHandleId = this.handleId === 'start' ? 'end' : 'start'
+    if (Vec.isEqual(previousPoint, currentPoint)) return
+    let delta = Vec.sub(currentPoint, originPoint)
+
+    if (shiftKey) {
+      const A = handles[otherHandleId].point
+      const B = handles[handleId].point
+      const C = Vec.add(B, delta)
+      const angle = Vec.angle(A, C)
+      const adjusted = Vec.rotWith(C, A, GeomUtils.snapAngleToSegments(angle, 24) - angle)
+      delta = Vec.add(delta, Vec.sub(adjusted, C))
+    }
+
+    const nextPoint = Vec.add(handles[handleId].point, delta)
+
+    const handleChanges = {
+      [handleId]: {
+        ...handles[handleId],
+        // FIXME Snap not working properly
+        point: showGrid ? Vec.snap(nextPoint, currentGrid) : Vec.toFixed(nextPoint),
+        bindingId: undefined,
+      },
+    }
+
+    let updated = this.currentShape.getHandlesChange(this.initialShape, handleChanges)
+
+    // If the handle changed produced no change, bail here
+    if (!updated) return
+
+    // If nothing changes, we want these to be the same object reference as
+    // 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, TLBinding> } = {
+      shape: deepMerge(shape.props, updated),
+      bindings: {},
+    }
+
+    let draggedBinding: TLBinding | undefined
+
+    const draggingHandle = next.shape.handles[handleId]
+    const oppositeHandle = next.shape.handles[otherHandleId]
+
+    // START BINDING
+    // If we have a start binding shape id, the recompute the binding
+    // point based on the current end handle position
+
+    if (this.startBindingShapeId) {
+      let nextStartBinding: TLBinding | undefined
+
+      const startTarget = this.app.getShapeById(this.startBindingShapeId)
+      const center = startTarget.getCenter()
+
+      const startHandle = next.shape.handles.start
+      const endHandle = next.shape.handles.end
+
+      const rayPoint = Vec.add(startHandle.point, next.shape.point)
+
+      if (Vec.isEqual(rayPoint, center)) rayPoint[1]++ // Fix bug where ray and center are identical
+
+      const rayOrigin = center
+
+      const isInsideShape = startTarget.hitTestPoint(currentPoint)
+
+      const rayDirection = Vec.uni(Vec.sub(rayPoint, rayOrigin))
+
+      const hasStartBinding = this.app.currentPage.bindings[this.newStartBindingId] !== undefined
+
+      // Don't bind the start handle if both handles are inside of the target shape.
+      if (!modKey && !startTarget.hitTestPoint(Vec.add(next.shape.point, endHandle.point))) {
+        nextStartBinding = this.findBindingPoint(
+          shape.props,
+          startTarget,
+          'start',
+          this.newStartBindingId,
+          center,
+          rayOrigin,
+          rayDirection,
+          isInsideShape
+        )
+      }
+
+      if (nextStartBinding && !hasStartBinding) {
+        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
+      }
+    }
+
+    if (!modKey) {
+      const rayOrigin = Vec.add(oppositeHandle.point, next.shape.point)
+
+      const rayPoint = Vec.add(draggingHandle.point, next.shape.point)
+
+      const rayDirection = Vec.uni(Vec.sub(rayPoint, rayOrigin))
+
+      const startPoint = Vec.add(next.shape.point, next.shape.handles.start.point)
+
+      const endPoint = Vec.add(next.shape.point, next.shape.handles.end.point)
+
+      const targets = this.bindableShapeIds
+        .map(id => this.app.getShapeById(id))
+        .sort((a, b) => b.nonce - a.nonce)
+        .filter(shape => {
+          return ![startPoint, endPoint].every(point => shape.hitTestPoint(point))
+        })
+
+      for (const target of targets) {
+        draggedBinding = this.findBindingPoint(
+          shape.props,
+          target,
+          this.handleId,
+          this.draggedBindingId,
+          rayPoint,
+          rayOrigin,
+          rayDirection,
+          altKey
+        )
+
+        if (draggedBinding) break
+      }
+    }
+
+    if (draggedBinding) {
+      // Create the dragged point binding
+      next.bindings[this.draggedBindingId] = draggedBinding
+
+      next.shape = deepMerge(next.shape, {
+        handles: {
+          [this.handleId]: {
+            bindingId: this.draggedBindingId,
+          },
+        },
+      })
+    } else {
+      // Remove the dragging point binding
+      const currentBindingId = shape.props.handles[this.handleId].bindingId
+
+      if (currentBindingId !== undefined) {
+        delete next.bindings[currentBindingId]
+
+        next.shape = deepMerge(next.shape, {
+          handles: {
+            [this.handleId]: {
+              bindingId: undefined,
+            },
+          },
+        })
+      }
+    }
+
+    updated = this.currentShape.getHandlesChange(next.shape, next.shape.handles)
+
+    if (updated) {
+      this.currentShape.update(updated)
+      this.app.currentPage.updateBindings(next.bindings)
+    }
+  }
+
+  onPointerUp: TLStateEvents<S, K>['onPointerUp'] = () => {
+    this.tool.transition('idle')
+    if (this.currentShape) {
+      this.app.setSelectedShapes([this.currentShape])
+    }
+    if (!this.app.settings.isToolLocked) {
+      this.app.transition('select')
+    }
+    this.app.persist()
+  }
+
+  onWheel: TLStateEvents<S, K>['onWheel'] = (info, e) => {
+    this.onPointerMove(info, e)
+  }
+
+  onExit: TLStateEvents<S, K>['onExit'] = () => {
+    this.app.clearBindingShape()
+    this.app.history.resume()
+  }
+
+  onKeyDown: TLStateEvents<S>['onKeyDown'] = (info, e) => {
+    switch (e.key) {
+      case 'Escape': {
+        this.app.deleteShapes([this.currentShape])
+        this.tool.transition('idle')
+        break
+      }
+    }
+  }
+
+  private findBindingPoint = (
+    shape: TLLineShapeProps,
+    target: TLShape,
+    handleId: 'start' | 'end',
+    bindingId: string,
+    point: number[],
+    origin: number[],
+    direction: number[],
+    bindAnywhere: boolean
+  ) => {
+    const bindingPoint = target.getBindingPoint(
+      point, // fix dead center bug
+      origin,
+      direction,
+      bindAnywhere
+    )
+
+    // Not all shapes will produce a binding point
+    if (!bindingPoint) return
+
+    return {
+      id: bindingId,
+      type: 'line',
+      fromId: shape.id,
+      toId: target.id,
+      handleId: handleId,
+      point: Vec.toFixed(bindingPoint.point),
+      distance: bindingPoint.distance,
+    }
+  }
+}

+ 14 - 191
tldraw/packages/core/src/lib/tools/TLLineTool/states/CreatingState.tsx

@@ -1,8 +1,8 @@
-import Vec from '@tldraw/vec'
 import { toJS } from 'mobx'
-import { TLApp, TLLineShape, TLLineShapeProps, TLShape, TLToolState } from '~lib'
-import type { TLEventMap, TLBinding, TLStateEvents } from '~types'
-import { deepMerge, GeomUtils, PointUtils, uniqueId } from '~utils'
+import type { TLApp, TLLineShape, TLShape } from '~lib'
+import { TLBaseLineBindingState } from '~lib/TLBaseLineBindingState'
+import type { TLEventMap } from '~types'
+import { PointUtils, uniqueId } from '~utils'
 import type { TLLineTool } from '../TLLineTool'
 
 export class CreatingState<
@@ -11,17 +11,17 @@ export class CreatingState<
   K extends TLEventMap,
   R extends TLApp<S, K>,
   P extends TLLineTool<T, S, K, R>
-> extends TLToolState<S, K, R, P> {
+> extends TLBaseLineBindingState<S, T, K, R, P> {
   static id = 'creating'
 
-  creatingShape = {} as T
-  initialShape = {} as T['props']
-  bindableShapeIds: string[] = []
-  startBindingShapeId?: string
-  newStartBindingId = uniqueId()
-  draggedBindingId = uniqueId()
-
   onEnter = () => {
+    this.app.history.pause()
+    this.newStartBindingId = uniqueId()
+    this.draggedBindingId = uniqueId()
+
+    const page = this.app.currentPage
+    this.bindableShapeIds = page.getBindableShapes()
+
     const { Shape } = this.tool
     const { originPoint } = this.app.inputs
 
@@ -33,15 +33,9 @@ export class CreatingState<
       point: originPoint,
     })
     this.initialShape = toJS(shape.props)
-    this.creatingShape = shape
+    this.currentShape = shape
     this.app.currentPage.addShapes(shape)
     this.app.setSelectedShapes([shape])
-    this.newStartBindingId = uniqueId()
-    this.draggedBindingId = uniqueId()
-
-    const page = this.app.currentPage
-
-    this.bindableShapeIds = page.getBindableShapes()
 
     this.startBindingShapeId = this.bindableShapeIds
       .map(id => this.app.getShapeById(id))
@@ -49,178 +43,7 @@ export class CreatingState<
 
     if (this.startBindingShapeId) {
       this.bindableShapeIds.splice(this.bindableShapeIds.indexOf(this.startBindingShapeId), 1)
-      this.app.setBindingShape(this.startBindingShapeId)
-    }
-  }
-
-  onPointerMove: TLStateEvents<S, K>['onPointerMove'] = () => {
-    const {
-      inputs: { shiftKey, previousPoint, originPoint, currentPoint, modKey },
-      settings: { showGrid },
-      currentGrid,
-    } = this.app
-    // @ts-expect-error just ignore
-    const shape = this.app.getShapeById<TLLineShape>(this.initialShape.id)
-
-    const { handles } = this.initialShape
-    const handleId = 'end'
-    const otherHandleId = 'start'
-    if (Vec.isEqual(previousPoint, currentPoint)) return
-    let delta = Vec.sub(currentPoint, originPoint)
-
-    if (shiftKey) {
-      const A = handles[otherHandleId].point
-      const B = handles[handleId].point
-      const C = Vec.add(B, delta)
-      const angle = Vec.angle(A, C)
-      const adjusted = Vec.rotWith(C, A, GeomUtils.snapAngleToSegments(angle, 24) - angle)
-      delta = Vec.add(delta, Vec.sub(adjusted, C))
-    }
-
-    const nextPoint = Vec.add(handles[handleId].point, delta)
-
-    const handleChanges = {
-      [handleId]: {
-        ...handles[handleId],
-        // FIXME Snap not working properly
-        point: showGrid ? Vec.snap(nextPoint, currentGrid) : Vec.toFixed(nextPoint),
-        bindingId: undefined,
-      },
-    }
-
-    let updated = this.creatingShape.getHandlesChange(this.initialShape, handleChanges)
-
-    // If the handle changed produced no change, bail here
-    if (!updated) return
-
-    // If nothing changes, we want these to be the same object reference as
-    // 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, TLBinding> } = {
-      shape: deepMerge(shape.props, updated),
-      bindings: {},
-    }
-
-    let draggedBinding: TLBinding | undefined
-
-    const draggingHandle = next.shape.handles[handleId]
-    const oppositeHandle = next.shape.handles[otherHandleId]
-
-    // START BINDING
-    // If we have a start binding shape id, the recompute the binding
-    // point based on the current end handle position
-
-    if (this.startBindingShapeId) {
-      let nextStartBinding: TLBinding | undefined
-
-      const startTarget = this.app.getShapeById(this.startBindingShapeId)
-      const center = startTarget.getCenter()
-
-      const startHandle = next.shape.handles.start
-      const endHandle = next.shape.handles.end
-
-      const rayPoint = Vec.add(startHandle.point, next.shape.point)
-
-      if (Vec.isEqual(rayPoint, center)) rayPoint[1]++ // Fix bug where ray and center are identical
-
-      const rayOrigin = center
-
-      const isInsideShape = startTarget.hitTestPoint(currentPoint)
-
-      const rayDirection = Vec.uni(Vec.sub(rayPoint, rayOrigin))
-
-      const hasStartBinding = this.app.currentPage.bindings[this.newStartBindingId] !== undefined
-
-      // Don't bind the start handle if both handles are inside of the target shape.
-      if (!modKey && !startTarget.hitTestPoint(Vec.add(next.shape.point, endHandle.point))) {
-        nextStartBinding = this.findBindingPoint(
-          shape.props,
-          startTarget,
-          'start',
-          this.newStartBindingId,
-          center,
-          rayOrigin,
-          rayDirection,
-          isInsideShape
-        )
-      }
-
-      if (nextStartBinding && !hasStartBinding) {
-        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
-      }
-    }
-
-    updated = this.creatingShape.getHandlesChange(next.shape, next.shape.handles)
-
-    if (updated) {
-      this.creatingShape.update(updated)
-      this.app.currentPage.updateBindings(next.bindings)
-    }
-  }
-
-  onPointerUp: TLStateEvents<S, K>['onPointerUp'] = () => {
-    this.tool.transition('idle')
-    if (this.creatingShape) {
-      this.app.setSelectedShapes([this.creatingShape])
-    }
-    if (!this.app.settings.isToolLocked) {
-      this.app.transition('select')
-    }
-    this.app.persist()
-  }
-
-  onWheel: TLStateEvents<S, K>['onWheel'] = (info, e) => {
-    this.onPointerMove(info, e)
-  }
-
-  onExit: TLStateEvents<S, K>['onExit'] = () => {
-    this.app.clearBindingShape()
-  }
-
-  onKeyDown: TLStateEvents<S>['onKeyDown'] = (info, e) => {
-    switch (e.key) {
-      case 'Escape': {
-        this.app.deleteShapes([this.creatingShape])
-        this.tool.transition('idle')
-        break
-      }
-    }
-  }
-
-  private findBindingPoint = (
-    shape: TLLineShapeProps,
-    target: TLShape,
-    handleId: 'start' | 'end',
-    bindingId: string,
-    point: number[],
-    origin: number[],
-    direction: number[],
-    bindAnywhere: boolean
-  ) => {
-    const bindingPoint = target.getBindingPoint(
-      point, // fix dead center bug
-      origin,
-      direction,
-      bindAnywhere
-    )
-
-    // Not all shapes will produce a binding point
-    if (!bindingPoint) return
-
-    return {
-      id: bindingId,
-      type: 'line',
-      fromId: shape.id,
-      toId: target.id,
-      handleId: handleId,
-      point: Vec.toFixed(bindingPoint.point),
-      distance: bindingPoint.distance,
+      this.app.setBindingShapes([this.startBindingShapeId])
     }
   }
 }

+ 14 - 75
tldraw/packages/core/src/lib/tools/TLSelectTool/states/TranslatingHandleState.ts

@@ -1,96 +1,35 @@
 /* eslint-disable @typescript-eslint/no-non-null-assertion */
-import { Vec } from '@tldraw/vec'
-import { TLApp, TLSelectTool, TLShape, TLToolState } from '~lib'
-import { TLCursor, TLEventHandleInfo, TLEventMap, TLEvents, TLHandle } from '~types'
-import { deepCopy } from '~utils'
+import { toJS } from 'mobx'
+import type { TLApp, TLLineShape, TLSelectTool, TLShape } from '~lib'
+import { TLBaseLineBindingState } from '~lib/TLBaseLineBindingState'
+import { TLCursor, TLEventHandleInfo, TLEventMap } from '~types'
+import { uniqueId } from '~utils'
 
 export class TranslatingHandleState<
   S extends TLShape,
+  T extends S & TLLineShape,
   K extends TLEventMap,
   R extends TLApp<S, K>,
   P extends TLSelectTool<S, K, R>
-> extends TLToolState<S, K, R, P> {
+> extends TLBaseLineBindingState<S, T, K, R, P> {
   static id = 'translatingHandle'
   cursor = TLCursor.Grabbing
 
-  private offset = [0, 0]
-  private initialTopLeft = [0, 0]
-  private handleId = 'start'
-  private shape: S = {} as S
-  private initialShape: S['props'] = {} as S['props']
-  private handle: TLHandle = {} as TLHandle
-  private bindableShapeIds: string[] = []
-
   onEnter = (
     info: {
       fromId: string
     } & TLEventHandleInfo<S>
   ) => {
     this.app.history.pause()
-    this.offset = [0, 0]
-    this.handleId = info.id
-    this.shape = info.shape
-    this.handle = info.handle
-    this.initialShape = deepCopy({ ...this.shape.props })
-    this.initialTopLeft = [...info.shape.props.point]
+    this.newStartBindingId = uniqueId()
+    this.draggedBindingId = uniqueId()
 
     const page = this.app.currentPage
+    this.bindableShapeIds = page.getBindableShapes()
 
-    this.bindableShapeIds = page.shapes
-      .filter(shape => shape.canBind)
-      .sort((a, b) => b.nonce - a.nonce)
-      .map(s => s.id)
-
-    // // TODO: find out why this the oppositeHandleBindingId is sometimes missing
-    // const oppositeHandleBindingId =
-    //   this.initialShape.handles[handleId === 'start' ? 'end' : 'start']?.bindingId
-
-    // if (oppositeHandleBindingId) {
-    //   const oppositeToId = page.bindings[oppositeHandleBindingId]?.toId
-    //   if (oppositeToId) {
-    //     this.bindableShapeIds = this.bindableShapeIds.filter(id => id !== oppositeToId)
-    //   }
-    // }
-  }
-
-  onExit = () => {
-    this.app.history.resume()
-  }
-
-  onWheel: TLEvents<S>['wheel'] = (info, e) => {
-    this.onPointerMove(info, e)
-  }
-
-  onPointerMove: TLEvents<S>['pointer'] = () => {
-    const {
-      inputs: { shiftKey, previousPoint, originPoint, currentPoint },
-    } = this.app
-    if (Vec.isEqual(previousPoint, currentPoint)) return
-    const delta = Vec.sub(currentPoint, originPoint)
-    if (shiftKey) {
-      if (Math.abs(delta[0]) < Math.abs(delta[1])) {
-        delta[0] = 0
-      } else {
-        delta[1] = 0
-      }
-    }
-    const { shape, initialShape, handleId: id } = this
-    shape.onHandleChange(initialShape, { id, delta })
-  }
-
-  onPointerUp: TLEvents<S>['pointer'] = () => {
-    this.app.history.resume()
-    this.app.persist()
-    this.tool.transition('idle')
-  }
-
-  onKeyDown: TLEvents<S>['keyboard'] = (info, e) => {
-    switch (e.key) {
-      case 'Escape': {
-        this.shape.update(this.initialShape)
-        this.tool.transition('idle')
-        break
-      }
-    }
+    this.handleId = info.id as 'start' | 'end'
+    this.currentShape = info.shape as T
+    this.initialShape = toJS(this.currentShape.props)
+    this.app.setSelectedShapes([this.currentShape])
   }
 }

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

@@ -13,7 +13,12 @@ import deepmerge from 'deepmerge'
  * @see Code pen https://codepen.io/erikvullings/pen/ejyBYg
  */
 export const deepCopy = copy
-export function deepMerge<T>(a: Partial<T>, b: Partial<T>): T {
+
+type Patch<T> = Partial<{ [P in keyof T]: T | Partial<T> | Patch<T[P]> }>
+
+
+export function deepMerge<T>(a: T, b: Patch<T>): T {
+  // @ts-expect-error ???
   return deepmerge(a, b, {
     arrayMerge: (destinationArray, sourceArray, options) => sourceArray,
   })

+ 1 - 1
tldraw/packages/react/src/components/AppCanvas.tsx

@@ -19,7 +19,7 @@ export const AppCanvas = observer(function InnerApp<S extends TLReactShape>(
       brush={app.brush}
       editingShape={app.editingShape}
       hoveredShape={app.hoveredShape}
-      bindingShape={app.bindingShape}
+      bindingShapes={app.bindingShapes}
       selectionDirectionHint={app.selectionDirectionHint}
       selectionBounds={app.selectionBounds}
       selectedShapes={app.selectedShapesArray}

+ 3 - 3
tldraw/packages/react/src/components/Canvas/Canvas.tsx

@@ -37,7 +37,7 @@ export interface TLCanvasProps<S extends TLReactShape> {
   theme: TLTheme
   hoveredShape: S
   editingShape: S
-  bindingShape: S
+  bindingShapes: S[]
   selectionDirectionHint: number[]
   selectionBounds: TLBounds
   selectedShapes: S[]
@@ -64,7 +64,7 @@ export const Canvas = observer(function Renderer<S extends TLReactShape>({
   brush,
   shapes,
   assets,
-  bindingShape,
+  bindingShapes,
   editingShape,
   hoveredShape,
   selectionBounds,
@@ -127,7 +127,7 @@ export const Canvas = observer(function Renderer<S extends TLReactShape>({
                 asset={assets && shape.props.assetId ? assets[shape.props.assetId] : undefined}
                 isEditing={shape === editingShape}
                 isHovered={shape === hoveredShape}
-                isBinding={shape === bindingShape}
+                isBinding={bindingShapes?.includes(shape)}
                 isSelected={selectedShapesSet.has(shape)}
                 isErasing={erasingShapesSet.has(shape)}
                 meta={meta}