Browse Source

line binding wip

Peng Xiao 3 years ago
parent
commit
339c462b98

+ 2 - 2
tldraw/apps/tldraw-logseq/src/lib/shapes/BoxShape.tsx

@@ -26,7 +26,7 @@ export class BoxShape extends TLBoxShape<BoxShapeProps> {
     opacity: 1,
   }
 
-  ReactComponent = observer(({ events, isErasing, isSelected }: TLComponentProps) => {
+  ReactComponent = observer(({ events, isErasing, isBinding, isSelected }: TLComponentProps) => {
     const {
       props: {
         size: [w, h],
@@ -58,7 +58,7 @@ export class BoxShape extends TLBoxShape<BoxShapeProps> {
           height={Math.max(0.01, h - strokeWidth)}
           strokeWidth={strokeWidth}
           stroke={stroke}
-          fill={fill}
+          fill={!isBinding ? fill : 'red'}
         />
       </SVGContainer>
     )

+ 2 - 3
tldraw/apps/tldraw-logseq/src/lib/shapes/LineShape.tsx

@@ -1,13 +1,12 @@
 /* eslint-disable @typescript-eslint/no-explicit-any */
-import * as React from 'react'
-import { TLHandle, TLLineShapeProps, TLLineShape } from '@tldraw/core'
+import { TLLineShape, TLLineShapeProps } from '@tldraw/core'
 import { SVGContainer, TLComponentProps } from '@tldraw/react'
 import { observer } from 'mobx-react-lite'
+import * as React from 'react'
 import { CustomStyleProps, withClampedStyles } from './style-props'
 
 interface LineShapeProps extends CustomStyleProps, TLLineShapeProps {
   type: 'line'
-  handles: TLHandle[]
 }
 
 export class LineShape extends TLLineShape<LineShapeProps> {

+ 2 - 0
tldraw/packages/core/src/constants.ts

@@ -15,6 +15,8 @@ export const FIT_TO_SCREEN_PADDING = 100
 
 export const BINDING_DISTANCE = 16
 
+export const GRID_SIZE = 8
+
 export const EMPTY_OBJECT: any = {}
 
 export const EMPTY_ARRAY: any[] = []

+ 30 - 0
tldraw/packages/core/src/lib/TLApp/TLApp.ts

@@ -32,6 +32,7 @@ import { TLSettings } from '../TLSettings'
 import { TLRootState } from '../TLState'
 import { TLApi } from '~lib/TLApi'
 import { TLCursors } from '~lib/TLCursors'
+import { GRID_SIZE } from '~constants'
 
 export interface TLDocumentModel<S extends TLShape = TLShape, A extends TLAsset = TLAsset> {
   currentPageId: string
@@ -460,6 +461,24 @@ export class TLApp<
     return this
   }
 
+  /* ------------------ Binding Shape ----------------- */
+
+  @observable bindingId?: string
+
+  @computed get bindingShape(): S | undefined {
+    const { bindingId, currentPage } = this
+    return bindingId ? currentPage.shapes.find(shape => shape.id === bindingId) : undefined
+  }
+
+  @action readonly setBindingShape = (shape?: string | S): this => {
+    this.bindingId = typeof shape === 'string' ? shape : shape?.id
+    return this
+  }
+
+  readonly clearBindingShape = (): this => {
+    return this.setBindingShape()
+  }
+
   /* ---------------------- Brush --------------------- */
 
   @observable brush?: TLBounds
@@ -486,6 +505,17 @@ export class TLApp<
     return Vec.mul(Vec.add(point, camera.point), camera.zoom)
   }
 
+  get currentGrid() {
+    const { zoom } = this.viewport.camera
+    if (zoom < 0.15) {
+      return GRID_SIZE * 16
+    } else if (zoom < 1) {
+      return GRID_SIZE * 4
+    } else {
+      return GRID_SIZE * 1
+    }
+  }
+
   /* --------------------- Display -------------------- */
 
   @computed get shapes(): S[] {

+ 4 - 1
tldraw/packages/core/src/lib/TLInputs.ts

@@ -1,5 +1,6 @@
 import { action, makeObservable, observable } from 'mobx'
 import type { TLEventMap } from '~types'
+import { modKey } from '~utils'
 
 export class TLInputs<K extends TLEventMap> {
   constructor() {
@@ -10,6 +11,7 @@ export class TLInputs<K extends TLEventMap> {
   // any of these properties observable
   @observable shiftKey = false
   @observable ctrlKey = false
+  @observable modKey = false
   @observable altKey = false
   @observable spaceKey = false
   @observable isPinching = false
@@ -32,8 +34,9 @@ export class TLInputs<K extends TLEventMap> {
     }
     if ('shiftKey' in event) {
       this.shiftKey = event.shiftKey
-      this.ctrlKey = event.metaKey || event.ctrlKey
+      this.ctrlKey = event.ctrlKey
       this.altKey = event.altKey
+      this.modKey = modKey(event)
     }
   }
 

+ 7 - 0
tldraw/packages/core/src/lib/TLPage/TLPage.ts

@@ -163,4 +163,11 @@ export class TLPage<S extends TLShape = TLShape, E extends TLEventMap = TLEventM
     })
     return this
   }
+
+  getBindableShapes() {
+    return this.shapes
+      .filter(shape => shape.canBind)
+      .sort((a, b) => b.nonce - a.nonce)
+      .map(s => s.id)
+  }
 }

+ 3 - 1
tldraw/packages/core/src/lib/shapes/TLBoxShape/TLBoxShape.tsx

@@ -1,7 +1,7 @@
 import type { TLBounds } from '@tldraw/intersect'
 import { makeObservable } from 'mobx'
-import { TLResizeInfo, TLShape, TLShapeProps } from '../TLShape'
 import { BoundsUtils } from '~utils'
+import { TLResizeInfo, TLShape, TLShapeProps } from '../TLShape'
 
 export interface TLBoxShapeProps extends TLShapeProps {
   size: number[]
@@ -17,6 +17,8 @@ export class TLBoxShape<P extends TLBoxShapeProps = TLBoxShapeProps, M = any> ex
   }
 
   static id = 'box'
+  
+  canBind = true
 
   static defaultProps: TLBoxShapeProps = {
     id: 'box',

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

@@ -1,10 +1,15 @@
+import Vec from '@tldraw/vec'
 import { makeObservable } from 'mobx'
 import type { TLHandle } from '~types'
-import type { TLShapeProps } from '~lib'
+import { BoundsUtils, deepMerge } from '~utils'
 import { TLPolylineShape, TLPolylineShapeProps } from '../TLPolylineShape'
 
+interface TLLineHandle extends TLHandle {
+  id: 'start' | 'end'
+}
+
 export interface TLLineShapeProps extends TLPolylineShapeProps {
-  handles: TLHandle[]
+  handles: TLLineHandle[]
 }
 
 export class TLLineShape<
@@ -24,14 +29,48 @@ export class TLLineShape<
     parentId: 'page',
     point: [0, 0],
     handles: [
-      { id: 'start', point: [0, 0] },
-      { id: 'end', point: [1, 1] },
+      { id: 'start', canBind: true, point: [0, 0] },
+      { id: 'end', canBind: true, point: [1, 1] },
     ],
   }
 
   validateProps = (props: Partial<P>) => {
     if (props.point) props.point = [0, 0]
-    if (props.handles !== undefined && props.handles.length < 1) props.handles = [{ point: [0, 0] }]
+    if (props.handles !== undefined && props.handles.length < 1)
+      props.handles = [{ point: [0, 0], id: 'start' }]
     return props
   }
+
+  getHandlesChange = (initialShape: P, handles: Partial<TLLineHandle>[]): P | void => {
+    let nextHandles = handles.map((h, i) => deepMerge(initialShape.handles[i] ?? {}, h))
+    nextHandles = nextHandles.map(h => ({ ...h, point: Vec.toFixed(h.point) }))
+
+    if (nextHandles.length !== 2 || Vec.isEqual(nextHandles[0].point, nextHandles[1].point)) {
+      return
+    }
+
+    const nextShape = {
+      ...initialShape,
+      handles: nextHandles,
+    }
+
+    // Zero out the handles to prevent handles with negative points. If a handle's x or y
+    // is below zero, we need to move the shape left or up to make it zero.
+    const topLeft = initialShape.point
+
+    const nextBounds = BoundsUtils.translateBounds(
+      BoundsUtils.getBoundsFromPoints(nextHandles.map(h => h.point)),
+      nextShape.point
+    )
+
+    const offset = Vec.sub([nextBounds.minX, nextBounds.minY], topLeft)
+
+    if (!Vec.isEqual(offset, [0, 0])) {
+      Object.values(nextShape.handles).forEach(handle => {
+        handle.point = Vec.toFixed(Vec.sub(handle.point, offset))
+      })
+      nextShape.point = Vec.toFixed(Vec.add(nextShape.point, offset))
+    }
+    return nextShape
+  }
 }

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

@@ -8,8 +8,8 @@ import {
 import Vec from '@tldraw/vec'
 import { action, computed, makeObservable, observable, toJS } from 'mobx'
 import { BINDING_DISTANCE } from '~constants'
-import type { TLHandle, TLBounds, TLResizeEdge, TLResizeCorner, TLAsset } from '~types'
-import { deepCopy, BoundsUtils, PointUtils } from '~utils'
+import type { TLAsset, TLBounds, TLHandle, TLResizeCorner, TLResizeEdge } from '~types'
+import { BoundsUtils, PointUtils } from '~utils'
 
 export type TLShapeModel<P extends TLShapeProps = TLShapeProps> = {
   nonce?: number
@@ -221,7 +221,7 @@ export abstract class TLShape<P extends TLShapeProps = TLShapeProps, M = any> {
         distance = Math.max(
           this.bindingDistance,
           BoundsUtils.getBoundsSides(bounds)
-            .map((side) => Vec.distanceToLineSegment(side[1][0], side[1][1], point))
+            .map(side => Vec.distanceToLineSegment(side[1][0], side[1][1], point))
             .sort((a, b) => a - b)[0]
         )
       }
@@ -313,7 +313,7 @@ export abstract class TLShape<P extends TLShapeProps = TLShapeProps, M = any> {
     return this
   }
 
-  onHandleChange = (initialShape: any, { index, delta }: TLHandleChangeInfo) => {
+  onHandleChange = (initialShape: P, { index, delta }: TLHandleChangeInfo) => {
     if (initialShape.handles === undefined) return
     const nextHandles = [...initialShape.handles]
     nextHandles[index] = {

+ 167 - 13
tldraw/packages/core/src/lib/tools/TLLineTool/states/CreatingState.tsx

@@ -1,8 +1,8 @@
 import type { TLLineTool } from '../TLLineTool'
-import { TLShape, TLApp, TLToolState, TLLineShape } from '~lib'
-import type { TLEventMap, TLStateEvents } from '~types'
+import { TLShape, TLApp, TLToolState, TLLineShape, TLLineShapeProps } from '~lib'
+import type { TLEventMap, TLLineBinding, TLStateEvents } from '~types'
 import Vec from '@tldraw/vec'
-import { uniqueId } from '~utils'
+import { deepCopy, deepMerge, GeomUtils, PointUtils, uniqueId } from '~utils'
 import { toJS } from 'mobx'
 
 export class CreatingState<
@@ -16,37 +16,156 @@ export class CreatingState<
 
   creatingShape = {} as T
   initialShape = {} as T['props']
+  bindableShapeIds: string[] = []
+  startBindingShapeId?: string
+  newStartBindingId = uniqueId()
+  draggedBindingId = uniqueId()
 
   onEnter = () => {
     const { Shape } = this.tool
+    const { originPoint } = this.app.inputs
     const shape = new Shape({
       id: uniqueId(),
       type: Shape.id,
       parentId: this.app.currentPage.id,
-      point: this.app.inputs.originPoint,
-      handles: [{ point: [0, 0] }, { point: [1, 1] }],
+      point: originPoint,
+      handles: [
+        { id: 'start', canBind: true, point: [0, 0] },
+        { id: 'end', canBind: true, point: [1, 1] },
+      ],
     })
     this.initialShape = toJS(shape.props)
     this.creatingShape = shape
     this.app.currentPage.addShapes(shape)
     this.app.setSelectedShapes([shape])
+
+    const page = this.app.currentPage
+
+    this.bindableShapeIds = page.getBindableShapes()
+
+    this.startBindingShapeId = this.bindableShapeIds
+      .map(id => this.app.getShapeById(id))
+      .filter(s => PointUtils.pointInBounds(originPoint, s.bounds))[0]?.id
+
+    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 },
+      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 curIndex = 1
+    const oppIndex = 0
     if (Vec.isEqual(previousPoint, currentPoint)) return
-    const delta = Vec.sub(currentPoint, originPoint)
+    let delta = Vec.sub(currentPoint, originPoint)
+
     if (shiftKey) {
-      if (Math.abs(delta[0]) < Math.abs(delta[1])) {
-        delta[0] = 0
-      } else {
-        delta[1] = 0
+      const A = handles[oppIndex].point
+      const B = handles[curIndex].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[curIndex].point, delta)
+
+    const handleChanges = deepCopy(handles)
+    handleChanges[curIndex].point = showGrid
+      ? Vec.snap(nextPoint, currentGrid)
+      : Vec.toFixed(nextPoint)
+
+    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: { props: TLLineShapeProps; bindings: Record<string, TLLineBinding> } = {
+      props: {
+        ...deepCopy(shape.props),
+        ...updated,
+        handles: updated.handles.map((h, idx) => deepMerge(shape.props.handles[idx], h)),
+      },
+      bindings: this.app.currentPage.bindings.reduce(
+        (acc, binding) => ({ ...acc, [binding.id]: binding }),
+        {}
+      ),
+    }
+
+    let draggedBinding: TLLineBinding | undefined
+
+    const draggingHandle = next.props.handles[curIndex]
+    const oppositeHandle = next.props.handles[oppIndex]
+
+    // 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: TLLineBinding | undefined
+
+      const startTarget = this.app.getShapeById(this.startBindingShapeId)
+      const center = startTarget.getCenter()
+
+      const startHandle = next.props.handles[0]
+      const endHandle = next.props.handles[1]
+
+      const rayPoint = Vec.add(startHandle.point, next.props.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.some(
+        b => b.id === this.newStartBindingId
+      )
+
+      // Don't bind the start handle if both handles are inside of the target shape.
+      if (!modKey && !startTarget.hitTestPoint(Vec.add(next.props.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.props.handles[0].bindingId = nextStartBinding.id
+      } else if (!nextStartBinding && hasStartBinding) {
+        delete next.bindings[this.newStartBindingId]
+        next.props.handles[0].bindingId = undefined
       }
     }
-    const { initialShape } = this
-    this.creatingShape.onHandleChange(initialShape, { index: 1, delta })
+
+    updated = this.creatingShape.getHandlesChange(next.props, next.props.handles)
+
+    if (updated) {
+      this.creatingShape.update(updated)
+      this.app.currentPage.bindings = Object.values(next.bindings)
+    }
   }
 
   onPointerUp: TLStateEvents<S, K>['onPointerUp'] = () => {
@@ -64,6 +183,10 @@ export class CreatingState<
     this.onPointerMove(info, e)
   }
 
+  onExit: TLStateEvents<S, K>['onExit'] = () => {
+    this.app.clearBindingShape()
+  }
+
   onKeyDown: TLStateEvents<S>['onKeyDown'] = (info, e) => {
     switch (e.key) {
       case 'Escape': {
@@ -73,4 +196,35 @@ export class CreatingState<
       }
     }
   }
+
+  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,
+    }
+  }
 }

+ 23 - 6
tldraw/packages/core/src/lib/tools/TLSelectTool/states/TranslatingHandleState.ts

@@ -3,10 +3,9 @@ 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 type { TLLineShape } from '~lib/shapes'
 
 export class TranslatingHandleState<
-  S extends TLLineShape,
+  S extends TLShape,
   K extends TLEventMap,
   R extends TLApp<S, K>,
   P extends TLSelectTool<S, K, R>
@@ -18,9 +17,10 @@ export class TranslatingHandleState<
   private initialTopLeft = [0, 0]
   private index = 0
   private shape: S = {} as S
+  private handleId: 'start' | 'end' = 'start'
   private initialShape: S['props'] = {} as S['props']
-  private handles: TLHandle[] = []
-  private initialHandles: TLHandle[] = []
+  private handle: TLHandle = {} as TLHandle
+  private bindableShapeIds: string[] = []
 
   onEnter = (
     info: {
@@ -31,10 +31,27 @@ export class TranslatingHandleState<
     this.offset = [0, 0]
     this.index = info.index
     this.shape = info.shape
+    this.handle = info.handle
     this.initialShape = deepCopy({ ...this.shape.props })
-    this.handles = deepCopy(info.shape.props.handles!)
-    this.initialHandles = deepCopy(info.shape.props.handles!)
     this.initialTopLeft = [...info.shape.props.point]
+
+    const page = this.app.currentPage
+
+    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 = () => {

+ 6 - 0
tldraw/packages/core/src/types/types.ts

@@ -90,6 +90,12 @@ export interface TLBinding {
   fromId: string
 }
 
+export interface TLLineBinding extends TLBinding {
+  handleId: 'start' | 'end'
+  distance: number
+  point: number[]
+}
+
 export interface TLOffset {
   top: number
   right: number

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

@@ -1,5 +1,6 @@
 import { isPlainObject } from 'is-plain-object'
 import copy from 'fast-copy'
+import deepmerge from 'deepmerge'
 
 /* eslint-disable @typescript-eslint/no-explicit-any */
 /**
@@ -10,7 +11,12 @@ import copy from 'fast-copy'
  * @see Source project, ts-deeply https://github.com/ykdr2017/ts-deepcopy
  * @see Code pen https://codepen.io/erikvullings/pen/ejyBYg
  */
-export const deepCopy = copy;
+export const deepCopy = copy
+export function deepMerge<T>(a: Partial<T>, b: Partial<T>): T {
+  return deepmerge(a, b, {
+    arrayMerge: (destinationArray, sourceArray, options) => sourceArray,
+  })
+}
 
 /**
  * Modulate a value between two ranges.

+ 14 - 0
tldraw/packages/core/src/utils/index.ts

@@ -39,3 +39,17 @@ export function throttle<T extends (...args: any) => any>(
 export function lerp(a: number, b: number, t: number) {
   return a + (b - a) * t
 }
+
+/** Find whether the current device is a Mac / iOS / iPadOS. */
+export function isDarwin(): boolean {
+  return /Mac|iPod|iPhone|iPad/.test(window.navigator.platform)
+}
+
+/**
+ * Get whether an event is command (mac) or control (pc).
+ *
+ * @param e
+ */
+export function modKey(e: any): boolean {
+  return isDarwin() ? e.metaKey : e.ctrlKey
+}

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

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