[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.
pull/2843/head^2
alex 2024-02-15 15:53:28 +00:00 zatwierdzone przez GitHub
rodzic 89881397b5
commit 31a2b2115f
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
4 zmienionych plików z 157 dodań i 4 usunięć

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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 })
})
})

Wyświetl plik

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