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,