From 50f77fe75c5962e61a628e58faa52ef218e68d14 Mon Sep 17 00:00:00 2001 From: alex Date: Mon, 19 Feb 2024 17:27:29 +0000 Subject: [PATCH] [Snapping 6/6] Self-snapping API (#2869) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This diff adds a self-snapping API for handles. Self-snapping is used when a shape's handles want to snap to the shape itself. By default, this isn't allowed because moving the handle might move the snap point, which creates a janky user experience. Now, shapes can return customised versions of their normal handle snapping geometry in these cases. As a bonus, line shapes now snap to other handles on their own line! ### Change Type - [x] `minor` — New feature ### Test Plan 1. Line handles should snap to other handles on the same line when holding command - [x] Unit Tests ### Release Notes - Line handles now snap to other handles on the same line when holding command --------- Co-authored-by: Steve Ruiz --- packages/editor/api-report.md | 4 +- packages/editor/api/api.json | 230 ++++++++---------- packages/editor/src/lib/editor/Editor.ts | 27 -- .../managers/SnapManager/HandleSnaps.ts | 129 ++++++---- .../editor/src/lib/editor/shapes/ShapeUtil.ts | 19 -- .../lib/primitives/geometry/CubicBezier2d.ts | 1 + packages/tldraw/api-report.md | 6 +- packages/tldraw/api/api.json | 68 +----- .../lib/shapes/line/LineShapeUtil.test.tsx | 21 ++ .../src/lib/shapes/line/LineShapeUtil.tsx | 36 ++- .../SelectTool/childStates/DraggingHandle.tsx | 30 +-- .../tldraw/src/test/customSnapping.test.tsx | 123 +++++++++- 12 files changed, 372 insertions(+), 322 deletions(-) diff --git a/packages/editor/api-report.md b/packages/editor/api-report.md index 158853bf6..807b9a68f 100644 --- a/packages/editor/api-report.md +++ b/packages/editor/api-report.md @@ -741,7 +741,6 @@ export class Editor extends EventEmitter { getShapeLocalTransform(shape: TLShape | TLShapeId): Mat; getShapeMask(shape: TLShape | TLShapeId): undefined | VecLike[]; getShapeMaskedPageBounds(shape: TLShape | TLShapeId): Box | undefined; - getShapeOutlineSegments(shape: T | T['id']): Vec[][]; getShapePageBounds(shape: TLShape | TLShapeId): Box | undefined; getShapePageTransform(shape: TLShape | TLShapeId): Mat; getShapeParent(shape?: TLShape | TLShapeId): TLShape | undefined; @@ -1148,6 +1147,8 @@ export const HALF_PI: number; // @public export interface HandleSnapGeometry { + getSelfSnapOutline?(handle: TLHandle): Geometry2d | null; + getSelfSnapPoints?(handle: TLHandle): VecModel[]; outline?: Geometry2d | null; points?: VecModel[]; } @@ -1607,7 +1608,6 @@ export abstract class ShapeUtil { abstract getGeometry(shape: Shape): Geometry2d; getHandles?(shape: Shape): TLHandle[]; getHandleSnapGeometry(shape: Shape): HandleSnapGeometry; - getOutlineSegments(shape: Shape): Vec[][]; hideResizeHandles: TLShapeUtilFlag; hideRotateHandle: TLShapeUtilFlag; hideSelectionBoundsBg: TLShapeUtilFlag; diff --git a/packages/editor/api/api.json b/packages/editor/api/api.json index 7843a8c12..18f510b1e 100644 --- a/packages/editor/api/api.json +++ b/packages/editor/api/api.json @@ -12913,81 +12913,6 @@ "isAbstract": false, "name": "getShapeMaskedPageBounds" }, - { - "kind": "Method", - "canonicalReference": "@tldraw/editor!Editor#getShapeOutlineSegments:member(1)", - "docComment": "/**\n * Get the local outline segments of a shape.\n *\n * @param shape - The shape (or shape id) to get the outline segments for.\n *\n * @example\n * ```ts\n * editor.getShapeOutlineSegments(myShape)\n * editor.getShapeOutlineSegments(myShapeId)\n * ```\n *\n * @public\n */\n", - "excerptTokens": [ - { - "kind": "Content", - "text": "getShapeOutlineSegments(shape: " - }, - { - "kind": "Content", - "text": "T | T['id']" - }, - { - "kind": "Content", - "text": "): " - }, - { - "kind": "Reference", - "text": "Vec", - "canonicalReference": "@tldraw/editor!Vec:class" - }, - { - "kind": "Content", - "text": "[][]" - }, - { - "kind": "Content", - "text": ";" - } - ], - "typeParameters": [ - { - "typeParameterName": "T", - "constraintTokenRange": { - "startIndex": 1, - "endIndex": 2 - }, - "defaultTypeTokenRange": { - "startIndex": 0, - "endIndex": 0 - } - } - ], - "isStatic": false, - "returnTypeTokenRange": { - "startIndex": 5, - "endIndex": 7 - }, - "releaseTag": "Public", - "isProtected": false, - "overloadIndex": 1, - "parameters": [ - { - "parameterName": "shape", - "parameterTypeTokenRange": { - "startIndex": 3, - "endIndex": 4 - }, - "isOptional": false - } - ], - "isOptional": false, - "isAbstract": false, - "name": "getShapeOutlineSegments" - }, { "kind": "Method", "canonicalReference": "@tldraw/editor!Editor#getShapePageBounds:member(1)", @@ -23309,6 +23234,108 @@ "name": "HandleSnapGeometry", "preserveMemberOrder": false, "members": [ + { + "kind": "MethodSignature", + "canonicalReference": "@tldraw/editor!HandleSnapGeometry#getSelfSnapOutline:member(1)", + "docComment": "/**\n * By default, handles can't snap to their own shape because moving the handle might change the snapping location which can cause feedback loops. You can override this by returning a version of `outline` that won't be affected by the current handle's position to use for self-snapping.\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "getSelfSnapOutline?(handle: " + }, + { + "kind": "Reference", + "text": "TLHandle", + "canonicalReference": "@tldraw/tlschema!TLHandle:interface" + }, + { + "kind": "Content", + "text": "): " + }, + { + "kind": "Reference", + "text": "Geometry2d", + "canonicalReference": "@tldraw/editor!Geometry2d:class" + }, + { + "kind": "Content", + "text": " | null" + }, + { + "kind": "Content", + "text": ";" + } + ], + "isOptional": true, + "returnTypeTokenRange": { + "startIndex": 3, + "endIndex": 5 + }, + "releaseTag": "Public", + "overloadIndex": 1, + "parameters": [ + { + "parameterName": "handle", + "parameterTypeTokenRange": { + "startIndex": 1, + "endIndex": 2 + }, + "isOptional": false + } + ], + "name": "getSelfSnapOutline" + }, + { + "kind": "MethodSignature", + "canonicalReference": "@tldraw/editor!HandleSnapGeometry#getSelfSnapPoints:member(1)", + "docComment": "/**\n * By default, handles can't snap to their own shape because moving the handle might change the snapping location which can cause feedback loops. You can override this by returning a version of `points` that won't be affected by the current handle's position to use for self-snapping.\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "getSelfSnapPoints?(handle: " + }, + { + "kind": "Reference", + "text": "TLHandle", + "canonicalReference": "@tldraw/tlschema!TLHandle:interface" + }, + { + "kind": "Content", + "text": "): " + }, + { + "kind": "Reference", + "text": "VecModel", + "canonicalReference": "@tldraw/tlschema!VecModel:interface" + }, + { + "kind": "Content", + "text": "[]" + }, + { + "kind": "Content", + "text": ";" + } + ], + "isOptional": true, + "returnTypeTokenRange": { + "startIndex": 3, + "endIndex": 5 + }, + "releaseTag": "Public", + "overloadIndex": 1, + "parameters": [ + { + "parameterName": "handle", + "parameterTypeTokenRange": { + "startIndex": 1, + "endIndex": 2 + }, + "isOptional": false + } + ], + "name": "getSelfSnapPoints" + }, { "kind": "PropertySignature", "canonicalReference": "@tldraw/editor!HandleSnapGeometry#outline:member", @@ -30778,59 +30805,6 @@ "isAbstract": false, "name": "getHandleSnapGeometry" }, - { - "kind": "Method", - "canonicalReference": "@tldraw/editor!ShapeUtil#getOutlineSegments:member(1)", - "docComment": "/**\n * Get an array of outline segments for the shape. For most shapes, this will be a single segment that includes the entire outline. For shapes with handles, this might be segments of the outline between each handle.\n *\n * @param shape - The shape.\n *\n * @example\n * ```ts\n * util.getOutlineSegments(myShape)\n * ```\n *\n * @public\n */\n", - "excerptTokens": [ - { - "kind": "Content", - "text": "getOutlineSegments(shape: " - }, - { - "kind": "Content", - "text": "Shape" - }, - { - "kind": "Content", - "text": "): " - }, - { - "kind": "Reference", - "text": "Vec", - "canonicalReference": "@tldraw/editor!Vec:class" - }, - { - "kind": "Content", - "text": "[][]" - }, - { - "kind": "Content", - "text": ";" - } - ], - "isStatic": false, - "returnTypeTokenRange": { - "startIndex": 3, - "endIndex": 5 - }, - "releaseTag": "Public", - "isProtected": false, - "overloadIndex": 1, - "parameters": [ - { - "parameterName": "shape", - "parameterTypeTokenRange": { - "startIndex": 1, - "endIndex": 2 - }, - "isOptional": false - } - ], - "isOptional": false, - "isAbstract": false, - "name": "getOutlineSegments" - }, { "kind": "Property", "canonicalReference": "@tldraw/editor!ShapeUtil#hideResizeHandles:member", diff --git a/packages/editor/src/lib/editor/Editor.ts b/packages/editor/src/lib/editor/Editor.ts index 0681fd59c..9698f1047 100644 --- a/packages/editor/src/lib/editor/Editor.ts +++ b/packages/editor/src/lib/editor/Editor.ts @@ -3815,33 +3815,6 @@ export class Editor extends EventEmitter { return this._getShapeGeometryCache().get(typeof shape === 'string' ? shape : shape.id)! as T } - /** @internal */ - @computed private _getShapeOutlineSegmentsCache(): ComputedCache { - return this.store.createComputedCache('outline-segments', (shape) => { - return this.getShapeUtil(shape).getOutlineSegments(shape) - }) - } - - /** - * Get the local outline segments of a shape. - * - * @example - * ```ts - * editor.getShapeOutlineSegments(myShape) - * editor.getShapeOutlineSegments(myShapeId) - * ``` - * - * @param shape - The shape (or shape id) to get the outline segments for. - * - * @public - */ - getShapeOutlineSegments(shape: T | T['id']): Vec[][] { - return ( - this._getShapeOutlineSegmentsCache().get(typeof shape === 'string' ? shape : shape.id) ?? - EMPTY_ARRAY - ) - } - /** @internal */ @computed private _getShapeHandlesCache(): ComputedCache { return this.store.createComputedCache('handles', (shape) => { diff --git a/packages/editor/src/lib/editor/managers/SnapManager/HandleSnaps.ts b/packages/editor/src/lib/editor/managers/SnapManager/HandleSnaps.ts index 90130d303..d329a96fb 100644 --- a/packages/editor/src/lib/editor/managers/SnapManager/HandleSnaps.ts +++ b/packages/editor/src/lib/editor/managers/SnapManager/HandleSnaps.ts @@ -1,5 +1,5 @@ import { computed } from '@tldraw/state' -import { TLShape, VecModel } from '@tldraw/tlschema' +import { TLHandle, TLShape, TLShapeId, VecModel } from '@tldraw/tlschema' import { assertExists } from '@tldraw/utils' import { Vec } from '../../../primitives/Vec' import { Geometry2d } from '../../../primitives/geometry/Geometry2d' @@ -28,8 +28,25 @@ export interface HandleSnapGeometry { * rectangle, or the centroid of a triangle. By default, no points are used. */ points?: VecModel[] + /** + * By default, handles can't snap to their own shape because moving the handle might change the + * snapping location which can cause feedback loops. You can override this by returning a + * version of `outline` that won't be affected by the current handle's position to use for + * self-snapping. + */ + getSelfSnapOutline?(handle: TLHandle): Geometry2d | null + /** + * By default, handles can't snap to their own shape because moving the handle might change the + * snapping location which can cause feedback loops. You can override this by returning a + * version of `points` that won't be affected by the current handle's position to use for + * self-snapping. + */ + getSelfSnapPoints?(handle: TLHandle): VecModel[] } +const defaultGetSelfSnapOutline = () => null +const defaultGetSelfSnapPoints = () => [] + export class HandleSnaps { readonly editor: Editor constructor(readonly manager: SnapManager) { @@ -47,17 +64,62 @@ export class HandleSnaps { ? editor.getShapeGeometry(shape) : snapGeometry.outline, - points: snapGeometry.points, + points: snapGeometry.points ?? [], + getSelfSnapOutline: snapGeometry.getSelfSnapOutline ?? defaultGetSelfSnapOutline, + getSelfSnapPoints: snapGeometry.getSelfSnapPoints ?? defaultGetSelfSnapPoints, } }) } + private *iterateSnapPointsInPageSpace(currentShapeId: TLShapeId, currentHandle: TLHandle) { + const selfSnapPoints = this.getSnapGeometryCache() + .get(currentShapeId) + ?.getSelfSnapPoints(currentHandle) + if (selfSnapPoints && selfSnapPoints.length) { + const shapePageTransform = assertExists(this.editor.getShapePageTransform(currentShapeId)) + for (const point of selfSnapPoints) { + yield shapePageTransform.applyToPoint(point) + } + } + + for (const shapeId of this.manager.getSnappableShapes()) { + if (shapeId === currentShapeId) continue + const snapPoints = this.getSnapGeometryCache().get(shapeId)?.points + if (!snapPoints || !snapPoints.length) continue + + const shapePageTransform = assertExists(this.editor.getShapePageTransform(shapeId)) + for (const point of snapPoints) { + yield shapePageTransform.applyToPoint(point) + } + } + } + + private *iterateSnapOutlines(currentShapeId: TLShapeId, currentHandle: TLHandle) { + const selfSnapOutline = this.getSnapGeometryCache() + .get(currentShapeId) + ?.getSelfSnapOutline(currentHandle) + if (selfSnapOutline) { + yield { shapeId: currentShapeId, outline: selfSnapOutline } + } + + for (const shapeId of this.manager.getSnappableShapes()) { + if (shapeId === currentShapeId) continue + + const snapOutline = this.getSnapGeometryCache().get(shapeId)?.outline + if (!snapOutline) continue + + yield { shapeId, outline: snapOutline } + } + } + private getHandleSnapPosition({ - handlePoint, - additionalSegments, + currentShapeId, + handle, + handleInPageSpace, }: { - handlePoint: Vec - additionalSegments: Vec[][] + currentShapeId: TLShapeId + handle: TLHandle + handleInPageSpace: Vec }): Vec | null { const snapThreshold = this.manager.getSnapThreshold() @@ -70,20 +132,12 @@ export class HandleSnaps { // Start with the points: let minDistanceForSnapPoint = snapThreshold let nearestSnapPoint: Vec | null = null - for (const shapeId of this.manager.getSnappableShapes()) { - const snapPoints = this.getSnapGeometryCache().get(shapeId)?.points - if (!snapPoints) continue + for (const snapPoint of this.iterateSnapPointsInPageSpace(currentShapeId, handle)) { + const distance = Vec.Dist(handleInPageSpace, snapPoint) - const shapePageTransform = assertExists(this.editor.getShapePageTransform(shapeId)) - - for (const snapPointInShapeSpace of snapPoints) { - const snapPointInPageSpace = shapePageTransform.applyToPoint(snapPointInShapeSpace) - const distance = Vec.Dist(handlePoint, snapPointInPageSpace) - - if (distance < minDistanceForSnapPoint) { - minDistanceForSnapPoint = distance - nearestSnapPoint = snapPointInPageSpace - } + if (distance < minDistanceForSnapPoint) { + minDistanceForSnapPoint = distance + nearestSnapPoint = snapPoint } } @@ -94,16 +148,13 @@ export class HandleSnaps { let minDistanceForOutline = snapThreshold let nearestPointOnOutline: Vec | null = null - for (const shapeId of this.manager.getSnappableShapes()) { - const snapOutline = this.getSnapGeometryCache().get(shapeId)?.outline - if (!snapOutline) continue - + for (const { shapeId, outline } of this.iterateSnapOutlines(currentShapeId, handle)) { const shapePageTransform = assertExists(this.editor.getShapePageTransform(shapeId)) - const pointInShapeSpace = this.editor.getPointInShapeSpace(shapeId, handlePoint) + const pointInShapeSpace = this.editor.getPointInShapeSpace(shapeId, handleInPageSpace) - const nearestShapePointInShapeSpace = snapOutline.nearestPoint(pointInShapeSpace) + const nearestShapePointInShapeSpace = outline.nearestPoint(pointInShapeSpace) const nearestInPageSpace = shapePageTransform.applyToPoint(nearestShapePointInShapeSpace) - const distance = Vec.Dist(handlePoint, nearestInPageSpace) + const distance = Vec.Dist(handleInPageSpace, nearestInPageSpace) if (distance < minDistanceForOutline) { minDistanceForOutline = distance @@ -111,18 +162,6 @@ export class HandleSnaps { } } - // We also allow passing "additionSegments" for self-snapping. - // TODO(alex): replace this with a proper self-snapping solution - for (const segment of additionalSegments) { - const nearestOnSegment = Vec.NearestPointOnLineSegment(segment[0], segment[1], handlePoint) - const distance = Vec.Dist(handlePoint, nearestOnSegment) - - if (distance < minDistanceForOutline) { - minDistanceForOutline = distance - nearestPointOnOutline = nearestOnSegment - } - } - // if we found a point on the outline, return it if (nearestPointOnOutline) return nearestPointOnOutline @@ -130,8 +169,16 @@ export class HandleSnaps { return null } - snapHandle(opts: { handlePoint: Vec; additionalSegments: Vec[][] }): SnapData | null { - const snapPosition = this.getHandleSnapPosition(opts) + snapHandle({ + currentShapeId, + handle, + }: { + currentShapeId: TLShapeId + handle: TLHandle + }): SnapData | null { + const currentShapeTransform = assertExists(this.editor.getShapePageTransform(currentShapeId)) + const handleInPageSpace = currentShapeTransform.applyToPoint(handle) + const snapPosition = this.getHandleSnapPosition({ currentShapeId, handle, handleInPageSpace }) // If we found a point, display snap lines, and return the nudge if (snapPosition) { @@ -143,7 +190,7 @@ export class HandleSnaps { }, ]) - return { nudge: Vec.Sub(snapPosition, opts.handlePoint) } + return { nudge: Vec.Sub(snapPosition, handleInPageSpace) } } return null diff --git a/packages/editor/src/lib/editor/shapes/ShapeUtil.ts b/packages/editor/src/lib/editor/shapes/ShapeUtil.ts index e7bb6ac21..1e4d13f4f 100644 --- a/packages/editor/src/lib/editor/shapes/ShapeUtil.ts +++ b/packages/editor/src/lib/editor/shapes/ShapeUtil.ts @@ -201,25 +201,6 @@ export abstract class ShapeUtil { */ getHandles?(shape: Shape): TLHandle[] - /** - * Get an array of outline segments for the shape. For most shapes, - * this will be a single segment that includes the entire outline. - * For shapes with handles, this might be segments of the outline - * between each handle. - * - * @example - * - * ```ts - * util.getOutlineSegments(myShape) - * ``` - * - * @param shape - The shape. - * @public - */ - getOutlineSegments(shape: Shape): Vec[][] { - return [this.editor.getShapeGeometry(shape).vertices] - } - /** * Get whether the shape can receive children of a given type. * diff --git a/packages/editor/src/lib/primitives/geometry/CubicBezier2d.ts b/packages/editor/src/lib/primitives/geometry/CubicBezier2d.ts index 06afd0813..9923891b7 100644 --- a/packages/editor/src/lib/primitives/geometry/CubicBezier2d.ts +++ b/packages/editor/src/lib/primitives/geometry/CubicBezier2d.ts @@ -19,6 +19,7 @@ export class CubicBezier2d extends Polyline2d { ) { const { start: a, cp1: b, cp2: c, end: d } = config super({ ...config, points: [a, d] }) + this.a = a this.b = b this.c = c diff --git a/packages/tldraw/api-report.md b/packages/tldraw/api-report.md index 59585c644..e1caffc92 100644 --- a/packages/tldraw/api-report.md +++ b/packages/tldraw/api-report.md @@ -865,11 +865,7 @@ export class LineShapeUtil extends ShapeUtil { // (undocumented) getHandles(shape: TLLineShape): TLHandle[]; // (undocumented) - getHandleSnapGeometry(shape: TLLineShape): { - points: VecModel[]; - }; - // (undocumented) - getOutlineSegments(shape: TLLineShape): Vec[][]; + getHandleSnapGeometry(shape: TLLineShape): HandleSnapGeometry; // (undocumented) hideResizeHandles: () => boolean; // (undocumented) diff --git a/packages/tldraw/api/api.json b/packages/tldraw/api/api.json index be8f1b537..9245c5bea 100644 --- a/packages/tldraw/api/api.json +++ b/packages/tldraw/api/api.json @@ -10257,18 +10257,10 @@ "kind": "Content", "text": "): " }, - { - "kind": "Content", - "text": "{\n points: import(\"@tldraw/editor\")." - }, { "kind": "Reference", - "text": "VecModel", - "canonicalReference": "@tldraw/tlschema!VecModel:interface" - }, - { - "kind": "Content", - "text": "[];\n }" + "text": "HandleSnapGeometry", + "canonicalReference": "@tldraw/editor!HandleSnapGeometry:interface" }, { "kind": "Content", @@ -10278,7 +10270,7 @@ "isStatic": false, "returnTypeTokenRange": { "startIndex": 3, - "endIndex": 6 + "endIndex": 4 }, "releaseTag": "Public", "isProtected": false, @@ -10297,60 +10289,6 @@ "isAbstract": false, "name": "getHandleSnapGeometry" }, - { - "kind": "Method", - "canonicalReference": "@tldraw/tldraw!LineShapeUtil#getOutlineSegments:member(1)", - "docComment": "", - "excerptTokens": [ - { - "kind": "Content", - "text": "getOutlineSegments(shape: " - }, - { - "kind": "Reference", - "text": "TLLineShape", - "canonicalReference": "@tldraw/tlschema!TLLineShape:type" - }, - { - "kind": "Content", - "text": "): " - }, - { - "kind": "Reference", - "text": "Vec", - "canonicalReference": "@tldraw/editor!Vec:class" - }, - { - "kind": "Content", - "text": "[][]" - }, - { - "kind": "Content", - "text": ";" - } - ], - "isStatic": false, - "returnTypeTokenRange": { - "startIndex": 3, - "endIndex": 5 - }, - "releaseTag": "Public", - "isProtected": false, - "overloadIndex": 1, - "parameters": [ - { - "parameterName": "shape", - "parameterTypeTokenRange": { - "startIndex": 1, - "endIndex": 2 - }, - "isOptional": false - } - ], - "isOptional": false, - "isAbstract": false, - "name": "getOutlineSegments" - }, { "kind": "Property", "canonicalReference": "@tldraw/tldraw!LineShapeUtil#hideResizeHandles:member", diff --git a/packages/tldraw/src/lib/shapes/line/LineShapeUtil.test.tsx b/packages/tldraw/src/lib/shapes/line/LineShapeUtil.test.tsx index 88efee2e7..92834b851 100644 --- a/packages/tldraw/src/lib/shapes/line/LineShapeUtil.test.tsx +++ b/packages/tldraw/src/lib/shapes/line/LineShapeUtil.test.tsx @@ -189,6 +189,27 @@ describe('Snapping', () => { }) }) + it('snaps endpoints to its vertices', () => { + editor.select(id) + + editor + .pointerDown(0, 0, { target: 'handle', shape: getShape(), handle: getHandles()[0] }) + .pointerMove(3, 95, undefined, { ctrlKey: true }) + + expect(editor.snaps.getIndicators()).toHaveLength(1) + editor.expectShapeToMatch({ + id: id, + props: { + points: [ + { x: 0, y: 100 }, + { x: 100, y: 0 }, + { x: 100, y: 100 }, + { x: 0, y: 100 }, + ], + }, + }) + }) + it("doesn't snap to the segment of the current handle", () => { editor.select(id) diff --git a/packages/tldraw/src/lib/shapes/line/LineShapeUtil.tsx b/packages/tldraw/src/lib/shapes/line/LineShapeUtil.tsx index 4e9ba62e7..9c580cc41 100644 --- a/packages/tldraw/src/lib/shapes/line/LineShapeUtil.tsx +++ b/packages/tldraw/src/lib/shapes/line/LineShapeUtil.tsx @@ -1,6 +1,8 @@ /* eslint-disable react-hooks/rules-of-hooks */ import { CubicSpline2d, + Group2d, + HandleSnapGeometry, Polyline2d, SVGContainer, ShapeUtil, @@ -110,11 +112,6 @@ export class LineShapeUtil extends ShapeUtil { }) } - override getOutlineSegments(shape: TLLineShape) { - const spline = this.editor.getShapeGeometry(shape) as Polyline2d | CubicSpline2d - return spline.segments.map((s) => s.vertices) - } - // Events override onResize: TLOnResizeHandler = (shape, info) => { @@ -389,9 +386,34 @@ export class LineShapeUtil extends ShapeUtil { } } - override getHandleSnapGeometry(shape: TLLineShape) { + override getHandleSnapGeometry(shape: TLLineShape): HandleSnapGeometry { + const { points } = shape.props return { - points: shape.props.points, + points, + getSelfSnapPoints: (handle) => { + const index = this.getHandles(shape) + .filter((h) => h.type === 'vertex') + .findIndex((h) => h.id === handle.id)! + + // We want to skip the current and adjacent handles + return points.filter((_, i) => Math.abs(i - index) > 1).map(Vec.From) + }, + getSelfSnapOutline: (handle) => { + // We want to skip the segments that include the handle, so + // find the index of the handle that shares the same index property + // as the initial dragging handle; this catches a quirk of create handles + const index = this.getHandles(shape) + .filter((h) => h.type === 'vertex') + .findIndex((h) => h.id === handle.id)! + + // Get all the outline segments from the shape that don't include the handle + const segments = getGeometryForLineShape(shape).segments.filter( + (_, i) => i !== index - 1 && i !== index + ) + + if (!segments.length) return null + return new Group2d({ children: segments }) + }, } } } diff --git a/packages/tldraw/src/lib/tools/SelectTool/childStates/DraggingHandle.tsx b/packages/tldraw/src/lib/tools/SelectTool/childStates/DraggingHandle.tsx index c0f2fcdc8..f8e5cc3b3 100644 --- a/packages/tldraw/src/lib/tools/SelectTool/childStates/DraggingHandle.tsx +++ b/packages/tldraw/src/lib/tools/SelectTool/childStates/DraggingHandle.tsx @@ -1,5 +1,4 @@ import { - Mat, StateNode, TLArrowShape, TLArrowShapeTerminal, @@ -263,43 +262,24 @@ export class DraggingHandle extends StateNode { // Clear any existing snaps editor.snaps.clearIndicators() + let nextHandle = { ...initialHandle, x: point.x, y: point.y } + if (initialHandle.canSnap && (isSnapMode ? !ctrlKey : ctrlKey)) { // We're snapping const pageTransform = editor.getShapePageTransform(shape.id) if (!pageTransform) throw Error('Expected a page transform') - // We want to skip the segments that include the handle, so - // find the index of the handle that shares the same index property - // as the initial dragging handle; this catches a quirk of create handles - const handleIndex = editor - .getShapeHandles(shape)! - .filter(({ type }) => type === 'vertex') - .sort(sortByIndex) - .findIndex(({ index }) => initialHandle.index === index) - - // Get all the outline segments from the shape - const additionalSegments = util - .getOutlineSegments(shape) - .map((segment) => Mat.applyToPoints(pageTransform, segment)) - .filter((_segment, i) => i !== handleIndex - 1 && i !== handleIndex) - - const snap = snaps.handles.snapHandle({ - additionalSegments, - handlePoint: Mat.applyToPoint(pageTransform, point), - }) + const snap = snaps.handles.snapHandle({ currentShapeId: shapeId, handle: nextHandle }) if (snap) { snap.nudge.rot(-editor.getShapeParentTransform(shape)!.rotation()) point.add(snap.nudge) + nextHandle = { ...initialHandle, x: point.x, y: point.y } } } const changes = util.onHandleDrag?.(shape, { - handle: { - ...initialHandle, - x: point.x, - y: point.y, - }, + handle: nextHandle, isPrecise: this.isPrecise || altKey, initial: initial, }) diff --git a/packages/tldraw/src/test/customSnapping.test.tsx b/packages/tldraw/src/test/customSnapping.test.tsx index a4c8253e7..52881892a 100644 --- a/packages/tldraw/src/test/customSnapping.test.tsx +++ b/packages/tldraw/src/test/customSnapping.test.tsx @@ -3,10 +3,13 @@ import { Polyline2d, TLAnyShapeUtilConstructor, TLBaseShape, + TLHandle, TLLineShape, + TLOnHandleDragHandler, TLShapeId, Vec, VecModel, + ZERO_INDEX_KEY, } from '@tldraw/editor' import { TestEditor } from './TestEditor' import { TL } from './test-jsx' @@ -164,14 +167,25 @@ describe('custom handle snapping', () => { { w: number h: number + ownHandle: VecModel handleOutline: VecModel[] | 'default' | null - handlePoints: 'default' | VecModel[] + handlePoints: VecModel[] | 'default' + selfSnapOutline: VecModel[] | 'default' + selfSnapPoints: VecModel[] | 'default' } > class TestShapeUtil extends BaseBoxShapeUtil { static override type = 'test' override getDefaultProps(): TestShape['props'] { - return { w: 100, h: 100, handleOutline: 'default', handlePoints: 'default' } + return { + w: 100, + h: 100, + ownHandle: { x: 0, y: 0 }, + handleOutline: 'default', + handlePoints: 'default', + selfSnapOutline: 'default', + selfSnapPoints: 'default', + } } override component() { throw new Error('Method not implemented.') @@ -180,7 +194,7 @@ describe('custom handle snapping', () => { throw new Error('Method not implemented.') } override getHandleSnapGeometry(shape: TestShape) { - const { handleOutline, handlePoints } = shape.props + const { handleOutline, handlePoints, selfSnapOutline, selfSnapPoints } = shape.props return { outline: handleOutline === 'default' @@ -189,8 +203,29 @@ describe('custom handle snapping', () => { ? null : new Polyline2d({ points: handleOutline.map(Vec.From) }), points: handlePoints === 'default' ? undefined : handlePoints, + + getSelfSnapOutline: + selfSnapOutline === 'default' + ? undefined + : () => new Polyline2d({ points: selfSnapOutline.map(Vec.From) }), + getSelfSnapPoints: selfSnapPoints === 'default' ? undefined : () => selfSnapPoints, } } + override getHandles(shape: TestShape): TLHandle[] { + return [ + { + id: 'handle', + type: 'vertex', + x: shape.props.ownHandle.x, + y: shape.props.ownHandle.y, + index: ZERO_INDEX_KEY, + canSnap: true, + }, + ] + } + override onHandleDrag: TLOnHandleDragHandler = (shape, { handle }) => { + return { ...shape, props: { ...shape.props, ownHandle: { x: handle.x, y: handle.y } } } + } } const shapeUtils = [TestShapeUtil] as TLAnyShapeUtilConstructor[] @@ -377,4 +412,86 @@ describe('custom handle snapping', () => { expect(handlePosition()).toMatchObject({ x: 235, y: 200 }) }) }) + + describe('self snapping', () => { + beforeEach(() => { + editor.deleteShape(ids.line) + editor.updateShape({ + id: ids.test, + type: 'test', + x: 0, + y: 0, + props: { + handlePoints: [{ x: 0, y: 0 }], + }, + }) + }) + function startDraggingOwnHandle() { + const shape = editor.select(ids.test).getOnlySelectedShape()! + const handles = editor.getShapeHandles(shape)! + editor.pointerDown(0, 0, { target: 'handle', shape, handle: handles[0] }) + } + function ownHandlePosition() { + const shape = editor.select(ids.test).getOnlySelectedShape()! + const handle = editor.getShapeHandles(shape)![0] + return { x: handle.x, y: handle.y } + } + describe('by default', () => { + test('does not snap to standard outline', () => { + startDraggingOwnHandle() + editor.pointerMove(3, 50, undefined, { ctrlKey: true }) + expect(editor.snaps.getIndicators()).toHaveLength(0) + expect(ownHandlePosition()).toMatchObject({ x: 3, y: 50 }) + }) + test('does not snap to standard points', () => { + startDraggingOwnHandle() + editor.pointerMove(3, 3, undefined, { ctrlKey: true }) + expect(editor.snaps.getIndicators()).toHaveLength(0) + expect(ownHandlePosition()).toMatchObject({ x: 3, y: 3 }) + }) + }) + describe('with custom self snap outline & points', () => { + beforeEach(() => { + editor.updateShape({ + id: ids.test, + type: 'test', + props: { + selfSnapOutline: [ + { x: 20, y: 50 }, + { x: 80, y: 50 }, + ], + selfSnapPoints: [ + { x: 20, y: 50 }, + { x: 80, y: 50 }, + ], + }, + }) + }) + + test('does not snap to standard outline', () => { + startDraggingOwnHandle() + editor.pointerMove(3, 50, undefined, { ctrlKey: true }) + expect(editor.snaps.getIndicators()).toHaveLength(0) + expect(ownHandlePosition()).toMatchObject({ x: 3, y: 50 }) + }) + test('does not snap to standard points', () => { + startDraggingOwnHandle() + editor.pointerMove(3, 3, undefined, { ctrlKey: true }) + expect(editor.snaps.getIndicators()).toHaveLength(0) + expect(ownHandlePosition()).toMatchObject({ x: 3, y: 3 }) + }) + test('snaps to the self-snap outline', () => { + startDraggingOwnHandle() + editor.pointerMove(50, 55, undefined, { ctrlKey: true }) + expect(editor.snaps.getIndicators()).toHaveLength(1) + expect(ownHandlePosition()).toMatchObject({ x: 50, y: 50 }) + }) + test('snaps to the self-snap points', () => { + startDraggingOwnHandle() + editor.pointerMove(23, 55, undefined, { ctrlKey: true }) + expect(editor.snaps.getIndicators()).toHaveLength(1) + expect(ownHandlePosition()).toMatchObject({ x: 20, y: 50 }) + }) + }) + }) })