From 31a2b2115f7f79215a2e4ba1d5842f3336862645 Mon Sep 17 00:00:00 2001 From: alex Date: Thu, 15 Feb 2024 15:53:28 +0000 Subject: [PATCH] [Snapping 5/5] Better handle snapping for geo shapes (#2845) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently, geo shapes have slightly janky handle-snapping: they snap to label geometry (even though its invisible) and because they extend from `BaseBoxShapeUtil` they snap to the corners of their bounding box (even if that's not where the actual shape is). With this PR, we no longer snap to labels, and we snap to the actual vertices of the geo shape rather than its bounding points. 1. #2827 2. #2831 3. #2793 4. #2841 5. #2845 (you are here) ### Change Type - [x] `minor` — New feature ### Test Plan - [x] Unit Tests ### Release Notes - You can now snap the handles of lines to the corners of rectangles, stars, triangles, etc. --- packages/tldraw/api-report.md | 5 +- packages/tldraw/api/api.json | 54 ++++++++++++++- .../src/lib/shapes/geo/GeoShapeUtil.test.tsx | 65 +++++++++++++++++++ .../src/lib/shapes/geo/GeoShapeUtil.tsx | 37 ++++++++++- 4 files changed, 157 insertions(+), 4 deletions(-) create mode 100644 packages/tldraw/src/lib/shapes/geo/GeoShapeUtil.test.tsx diff --git a/packages/tldraw/api-report.md b/packages/tldraw/api-report.md index 51443a834..4d3949fa2 100644 --- a/packages/tldraw/api-report.md +++ b/packages/tldraw/api-report.md @@ -21,6 +21,7 @@ import { EmbedDefinition } from '@tldraw/editor'; import { EnumStyleProp } from '@tldraw/editor'; import { Geometry2d } from '@tldraw/editor'; import { Group2d } from '@tldraw/editor'; +import { HandleSnapGeometry } from '@tldraw/editor'; import { IndexKey } from '@tldraw/editor'; import { JsonObject } from '@tldraw/editor'; import { JSX as JSX_2 } from 'react/jsx-runtime'; @@ -607,7 +608,9 @@ export class GeoShapeUtil extends BaseBoxShapeUtil { // (undocumented) getDefaultProps(): TLGeoShape['props']; // (undocumented) - getGeometry(shape: TLGeoShape): Geometry2d; + getGeometry(shape: TLGeoShape): Group2d; + // (undocumented) + getHandleSnapGeometry(shape: TLGeoShape): HandleSnapGeometry; // (undocumented) indicator(shape: TLGeoShape): JSX_2.Element; // (undocumented) diff --git a/packages/tldraw/api/api.json b/packages/tldraw/api/api.json index dd9bdde0b..640e4f115 100644 --- a/packages/tldraw/api/api.json +++ b/packages/tldraw/api/api.json @@ -7580,8 +7580,8 @@ }, { "kind": "Reference", - "text": "Geometry2d", - "canonicalReference": "@tldraw/editor!Geometry2d:class" + "text": "Group2d", + "canonicalReference": "@tldraw/editor!Group2d:class" }, { "kind": "Content", @@ -7610,6 +7610,56 @@ "isAbstract": false, "name": "getGeometry" }, + { + "kind": "Method", + "canonicalReference": "@tldraw/tldraw!GeoShapeUtil#getHandleSnapGeometry:member(1)", + "docComment": "", + "excerptTokens": [ + { + "kind": "Content", + "text": "getHandleSnapGeometry(shape: " + }, + { + "kind": "Reference", + "text": "TLGeoShape", + "canonicalReference": "@tldraw/tlschema!TLGeoShape:type" + }, + { + "kind": "Content", + "text": "): " + }, + { + "kind": "Reference", + "text": "HandleSnapGeometry", + "canonicalReference": "@tldraw/editor!HandleSnapGeometry:interface" + }, + { + "kind": "Content", + "text": ";" + } + ], + "isStatic": false, + "returnTypeTokenRange": { + "startIndex": 3, + "endIndex": 4 + }, + "releaseTag": "Public", + "isProtected": false, + "overloadIndex": 1, + "parameters": [ + { + "parameterName": "shape", + "parameterTypeTokenRange": { + "startIndex": 1, + "endIndex": 2 + }, + "isOptional": false + } + ], + "isOptional": false, + "isAbstract": false, + "name": "getHandleSnapGeometry" + }, { "kind": "Method", "canonicalReference": "@tldraw/tldraw!GeoShapeUtil#indicator:member(1)", diff --git a/packages/tldraw/src/lib/shapes/geo/GeoShapeUtil.test.tsx b/packages/tldraw/src/lib/shapes/geo/GeoShapeUtil.test.tsx new file mode 100644 index 000000000..4f841d962 --- /dev/null +++ b/packages/tldraw/src/lib/shapes/geo/GeoShapeUtil.test.tsx @@ -0,0 +1,65 @@ +import { Group2d, IndexKey, TLShapeId } from '@tldraw/editor' +import { TestEditor } from '../../../test/TestEditor' +import { TL } from '../../../test/test-jsx' + +let editor: TestEditor +let ids: Record + +beforeEach(() => { + editor = new TestEditor() +}) + +describe('Handle snapping', () => { + beforeEach(() => { + ids = editor.createShapesFromJsx([ + , + , + ]) + }) + + const geoShape = () => editor.getShape(ids.geo)! + const lineShape = () => editor.getShape(ids.line)! + const lineHandles = () => editor.getShapeUtil('line').getHandles!(lineShape())! + + function startDraggingHandle() { + editor + .select(ids.line) + .pointerDown(200, 0, { target: 'handle', shape: lineShape(), handle: lineHandles()[0] }) + } + + test('handles snap to the edges of the shape', () => { + startDraggingHandle() + editor.pointerMove(50, 5, undefined, { ctrlKey: true }) + expect(editor.snaps.getIndicators()).toHaveLength(1) + expect(lineHandles()[0]).toMatchObject({ x: 50, y: 0 }) + }) + + test('handles snap to the corner of the shape', () => { + startDraggingHandle() + editor.pointerMove(0, 5, undefined, { ctrlKey: true }) + expect(editor.snaps.getIndicators()).toHaveLength(1) + expect(lineHandles()[0]).toMatchObject({ x: 0, y: 0 }) + }) + + test('handles snap to the center of the shape', () => { + startDraggingHandle() + editor.pointerMove(51, 45, undefined, { ctrlKey: true }) + expect(editor.snaps.getIndicators()).toHaveLength(1) + expect(lineHandles()[0]).toMatchObject({ x: 50, y: 50 }) + }) + + test('does not snap to the label of the shape', () => { + startDraggingHandle() + const geometry = editor.getShapeUtil('geo').getGeometry(geoShape()) as Group2d + const label = geometry.children.find((c) => c.isLabel)! + const labelVertex = label.vertices[0] + editor.pointerMove(labelVertex.x + 2, labelVertex.y + 2, undefined, { ctrlKey: true }) + expect(editor.snaps.getIndicators()).toHaveLength(0) + expect(lineHandles()[0]).toMatchObject({ x: labelVertex.x + 2, y: labelVertex.y + 2 }) + }) +}) diff --git a/packages/tldraw/src/lib/shapes/geo/GeoShapeUtil.tsx b/packages/tldraw/src/lib/shapes/geo/GeoShapeUtil.tsx index 3d02e939b..c9d215222 100644 --- a/packages/tldraw/src/lib/shapes/geo/GeoShapeUtil.tsx +++ b/packages/tldraw/src/lib/shapes/geo/GeoShapeUtil.tsx @@ -7,6 +7,7 @@ import { Group2d, HALF_PI, HTMLContainer, + HandleSnapGeometry, PI2, Polygon2d, Polyline2d, @@ -21,6 +22,7 @@ import { TLShapeUtilCanvasSvgDef, Vec, VecLike, + exhaustiveSwitchError, geoShapeMigrations, geoShapeProps, getDefaultColorTheme, @@ -89,7 +91,7 @@ export class GeoShapeUtil extends BaseBoxShapeUtil { } } - override getGeometry(shape: TLGeoShape): Geometry2d { + override getGeometry(shape: TLGeoShape) { const w = Math.max(1, shape.props.w) const h = Math.max(1, shape.props.h + shape.props.growY) const cx = w / 2 @@ -339,6 +341,39 @@ export class GeoShapeUtil extends BaseBoxShapeUtil { }) } + override getHandleSnapGeometry(shape: TLGeoShape): HandleSnapGeometry { + const geometry = this.getGeometry(shape) + // we only want to snap handles to the outline of the shape - not to its label etc. + const outline = geometry.children[0] + switch (shape.props.geo) { + case 'arrow-down': + case 'arrow-left': + case 'arrow-right': + case 'arrow-up': + case 'check-box': + case 'diamond': + case 'hexagon': + case 'octagon': + case 'pentagon': + case 'rectangle': + case 'rhombus': + case 'rhombus-2': + case 'star': + case 'trapezoid': + case 'triangle': + case 'x-box': + // poly-line type shapes hand snap points for each vertex & the center + return { outline: outline, points: [...outline.getVertices(), geometry.bounds.center] } + case 'cloud': + case 'ellipse': + case 'oval': + // blobby shapes only have a snap point in their center + return { outline: outline, points: [geometry.bounds.center] } + default: + exhaustiveSwitchError(shape.props.geo) + } + } + override onEditEnd: TLOnEditEndHandler = (shape) => { const { id,