[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:
parent
89881397b5
commit
31a2b2115f
4 changed files with 157 additions and 4 deletions
|
@ -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)
|
||||
|
|
|
@ -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)",
|
||||
|
|
65
packages/tldraw/src/lib/shapes/geo/GeoShapeUtil.test.tsx
Normal file
65
packages/tldraw/src/lib/shapes/geo/GeoShapeUtil.test.tsx
Normal 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 })
|
||||
})
|
||||
})
|
|
@ -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,
|
||||
|
|
Loading…
Reference in a new issue