[Snapping 5/5] Better handle snapping for geo shapes (#2845)

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.
This commit is contained in:
alex 2024-02-15 15:53:28 +00:00 committed by GitHub
parent 89881397b5
commit 31a2b2115f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 157 additions and 4 deletions

View file

@ -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<TLGeoShape> {
// (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)

View file

@ -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)",

View file

@ -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<string, TLShapeId>
beforeEach(() => {
editor = new TestEditor()
})
describe('Handle snapping', () => {
beforeEach(() => {
ids = editor.createShapesFromJsx([
<TL.geo ref="geo" x={0} y={0} geo="rectangle" w={100} h={100} />,
<TL.line
ref="line"
x={0}
y={0}
handles={{ ['a1' as IndexKey]: { x: 200, y: 0 }, ['a2' as IndexKey]: { x: 200, y: 100 } }}
/>,
])
})
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 })
})
})

View file

@ -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<TLGeoShape> {
}
}
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<TLGeoShape> {
})
}
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<TLGeoShape> = (shape) => {
const {
id,