[Snapping 4/5] Add handle-point snapping (#2841)

Currently, when dragging line handles they'll snap to the outlines of
other shapes, but not to their vertices. This can make it hard to snap
precisely to certain key places, like the handles of other lines, or the
corners of `geo` shapes.

This diff adds a new snap type for handles - snapping to points:

![Kapture 2024-02-14 at 16 30
41](https://github.com/tldraw/tldraw/assets/1489520/046109d3-2961-463f-bf71-9350ea1204bc)

This adds to the new snapping API so the snapping points can very easily
be customised on a shape-by-shape basis. Closes TLD-2198

This PR is part of a series - please don't merge it until the things
before it have landed!
1. #2827 
2. #2831
3. #2793 
4. #2841 (you are here)
5. #2845

### Change Type

- [x] `minor` — New feature

### Test Plan

1. create a line shape
2. drag its handles whilst holding command
3. it should snap to the outlines of other shapes, vertices of other
line shapes, and the bounding box corners/center of most 'boxy' shapes
(geo, embed, etc)

- [x] Unit Tests

### Release Notes

- Line handles
This commit is contained in:
alex 2024-02-15 15:22:48 +00:00 committed by GitHub
parent 77865d9f5e
commit 89881397b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 359 additions and 46 deletions

View file

@ -156,6 +156,8 @@ export abstract class BaseBoxShapeUtil<Shape extends TLBaseBoxShape> extends Sha
// (undocumented)
getGeometry(shape: Shape): Geometry2d;
// (undocumented)
getHandleSnapGeometry(shape: Shape): HandleSnapGeometry;
// (undocumented)
onResize: TLOnResizeHandler<any>;
}
@ -1141,6 +1143,7 @@ export const HALF_PI: number;
// @public
export interface HandleSnapGeometry {
outline?: Geometry2d | null;
points?: VecModel[];
}
// @public

View file

@ -1275,6 +1275,55 @@
"isAbstract": false,
"name": "getGeometry"
},
{
"kind": "Method",
"canonicalReference": "@tldraw/editor!BaseBoxShapeUtil#getHandleSnapGeometry:member(1)",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "getHandleSnapGeometry(shape: "
},
{
"kind": "Content",
"text": "Shape"
},
{
"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": "Property",
"canonicalReference": "@tldraw/editor!BaseBoxShapeUtil#onResize:member",
@ -22959,7 +23008,7 @@
{
"kind": "Interface",
"canonicalReference": "@tldraw/editor!HandleSnapGeometry:interface",
"docComment": "/**\n * When dragging a handle, users can snap the handle to key geometry on other nearby shapes. Customize how handles snap to a shape by returning this from {@link ShapeUtil.getHandleSnapGeometry}.\n *\n * @public\n */\n",
"docComment": "/**\n * When dragging a handle, users can snap the handle to key geometry on other nearby shapes. Customize how handles snap to a shape by returning this from {@link ShapeUtil.getHandleSnapGeometry}.\n *\n * Any co-ordinates here should be in the shape's local space.\n *\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
@ -23002,6 +23051,38 @@
"startIndex": 1,
"endIndex": 3
}
},
{
"kind": "PropertySignature",
"canonicalReference": "@tldraw/editor!HandleSnapGeometry#points:member",
"docComment": "/**\n * Key points on the shape that the handle will snap to. For example, the corners of a rectangle, or the centroid of a triangle. By default, no points are used.\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "points?: "
},
{
"kind": "Reference",
"text": "VecModel",
"canonicalReference": "@tldraw/tlschema!VecModel:interface"
},
{
"kind": "Content",
"text": "[]"
},
{
"kind": "Content",
"text": ";"
}
],
"isReadonly": false,
"isOptional": true,
"releaseTag": "Public",
"name": "points",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 3
}
}
],
"extendsTokenRanges": []

View file

@ -1,5 +1,5 @@
import { computed } from '@tldraw/state'
import { TLShape } from '@tldraw/tlschema'
import { TLShape, VecModel } from '@tldraw/tlschema'
import { assertExists } from '@tldraw/utils'
import { Vec } from '../../../primitives/Vec'
import { Geometry2d } from '../../../primitives/geometry/Geometry2d'
@ -12,6 +12,8 @@ import { SnapData, SnapManager } from './SnapManager'
* Customize how handles snap to a shape by returning this from
* {@link ShapeUtil.getHandleSnapGeometry}.
*
* Any co-ordinates here should be in the shape's local space.
*
* @public
*/
export interface HandleSnapGeometry {
@ -21,6 +23,11 @@ export interface HandleSnapGeometry {
* Set this to `null` to disable handle snapping to this shape's outline.
*/
outline?: Geometry2d | null
/**
* Key points on the shape that the handle will snap to. For example, the corners of a
* rectangle, or the centroid of a triangle. By default, no points are used.
*/
points?: VecModel[]
}
export class HandleSnaps {
@ -39,61 +46,104 @@ export class HandleSnaps {
snapGeometry.outline === undefined
? editor.getShapeGeometry(shape)
: snapGeometry.outline,
points: snapGeometry.points,
}
})
}
snapHandle({
private getHandleSnapPosition({
handlePoint,
additionalSegments,
}: {
handlePoint: Vec
additionalSegments: Vec[][]
}): SnapData | null {
}): Vec | null {
const snapThreshold = this.manager.getSnapThreshold()
// Find the nearest point that is within the snap threshold
let minDistance = snapThreshold
let nearestPoint: Vec | null = null
// We snap to two different parts of the shape's handle snap geometry:
// 1. The `points`. These are handles or other key points that we want to snap to with a
// higher priority than the normal outline snapping.
// 2. The `outline`. This describes the outline of the shape, and we just snap to the
// nearest point on that outline.
// Start with the points:
let minDistanceForSnapPoint = snapThreshold
let nearestSnapPoint: Vec | null = null
for (const shapeId of this.manager.getSnappableShapes()) {
const handleSnapOutline = this.getSnapGeometryCache().get(shapeId)?.outline
if (!handleSnapOutline) continue
const snapPoints = this.getSnapGeometryCache().get(shapeId)?.points
if (!snapPoints) continue
const shapePageTransform = assertExists(this.editor.getShapePageTransform(shapeId))
const pointInShapeSpace = this.editor.getPointInShapeSpace(shapeId, handlePoint)
const nearestShapePointInShapeSpace = handleSnapOutline.nearestPoint(pointInShapeSpace)
const nearestInPageSpace = shapePageTransform.applyToPoint(nearestShapePointInShapeSpace)
const distance = Vec.Dist(handlePoint, nearestInPageSpace)
if (distance < minDistance) {
minDistance = distance
nearestPoint = nearestInPageSpace
for (const snapPointInShapeSpace of snapPoints) {
const snapPointInPageSpace = shapePageTransform.applyToPoint(snapPointInShapeSpace)
const distance = Vec.Dist(handlePoint, snapPointInPageSpace)
if (distance < minDistanceForSnapPoint) {
minDistanceForSnapPoint = distance
nearestSnapPoint = snapPointInPageSpace
}
}
}
// handle additional segments:
// if we found a snap point, return it - we don't need to check the outlines because points
// have a higher priority
if (nearestSnapPoint) return nearestSnapPoint
let minDistanceForOutline = snapThreshold
let nearestPointOnOutline: Vec | null = null
for (const shapeId of this.manager.getSnappableShapes()) {
const snapOutline = this.getSnapGeometryCache().get(shapeId)?.outline
if (!snapOutline) continue
const shapePageTransform = assertExists(this.editor.getShapePageTransform(shapeId))
const pointInShapeSpace = this.editor.getPointInShapeSpace(shapeId, handlePoint)
const nearestShapePointInShapeSpace = snapOutline.nearestPoint(pointInShapeSpace)
const nearestInPageSpace = shapePageTransform.applyToPoint(nearestShapePointInShapeSpace)
const distance = Vec.Dist(handlePoint, nearestInPageSpace)
if (distance < minDistanceForOutline) {
minDistanceForOutline = distance
nearestPointOnOutline = nearestInPageSpace
}
}
// We also allow passing "additionSegments" for self-snapping.
// TODO(alex): replace this with a proper self-snapping solution
for (const segment of additionalSegments) {
const nearestOnSegment = Vec.NearestPointOnLineSegment(segment[0], segment[1], handlePoint)
const distance = Vec.Dist(handlePoint, nearestOnSegment)
if (distance < minDistance) {
minDistance = distance
nearestPoint = nearestOnSegment
if (distance < minDistanceForOutline) {
minDistanceForOutline = distance
nearestPointOnOutline = nearestOnSegment
}
}
// if we found a point on the outline, return it
if (nearestPointOnOutline) return nearestPointOnOutline
// if not, there's no nearby snap point
return null
}
snapHandle(opts: { handlePoint: Vec; additionalSegments: Vec[][] }): SnapData | null {
const snapPosition = this.getHandleSnapPosition(opts)
// If we found a point, display snap lines, and return the nudge
if (nearestPoint) {
if (snapPosition) {
this.manager.setIndicators([
{
id: uniqueId(),
type: 'points',
points: [nearestPoint],
points: [snapPosition],
},
])
return { nudge: Vec.Sub(nearestPoint, handlePoint) }
return { nudge: Vec.Sub(snapPosition, opts.handlePoint) }
}
return null

View file

@ -1,6 +1,7 @@
import { TLBaseShape } from '@tldraw/tlschema'
import { Geometry2d } from '../../primitives/geometry/Geometry2d'
import { Rectangle2d } from '../../primitives/geometry/Rectangle2d'
import { HandleSnapGeometry } from '../managers/SnapManager/HandleSnaps'
import { ShapeUtil, TLOnResizeHandler } from './ShapeUtil'
import { resizeBox } from './shared/resizeBox'
@ -20,4 +21,10 @@ export abstract class BaseBoxShapeUtil<Shape extends TLBaseBoxShape> extends Sha
override onResize: TLOnResizeHandler<any> = (shape, info) => {
return resizeBox(shape, info)
}
override getHandleSnapGeometry(shape: Shape): HandleSnapGeometry {
return {
points: this.getGeometry(shape).bounds.cornersAndCenter,
}
}
}

View file

@ -910,6 +910,10 @@ export class LineShapeUtil extends ShapeUtil<TLLineShape> {
// (undocumented)
getHandles(shape: TLLineShape): TLHandle[];
// (undocumented)
getHandleSnapGeometry(shape: TLLineShape): {
points: VecModel[];
};
// (undocumented)
getOutlineSegments(shape: TLLineShape): Vec[][];
// (undocumented)
hideResizeHandles: () => boolean;

View file

@ -10964,6 +10964,64 @@
"isAbstract": false,
"name": "getHandles"
},
{
"kind": "Method",
"canonicalReference": "@tldraw/tldraw!LineShapeUtil#getHandleSnapGeometry:member(1)",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "getHandleSnapGeometry(shape: "
},
{
"kind": "Reference",
"text": "TLLineShape",
"canonicalReference": "@tldraw/tlschema!TLLineShape:type"
},
{
"kind": "Content",
"text": "): "
},
{
"kind": "Content",
"text": "{\n points: import(\"@tldraw/editor\")."
},
{
"kind": "Reference",
"text": "VecModel",
"canonicalReference": "@tldraw/tlschema!VecModel:interface"
},
{
"kind": "Content",
"text": "[];\n }"
},
{
"kind": "Content",
"text": ";"
}
],
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 3,
"endIndex": 6
},
"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!LineShapeUtil#getOutlineSegments:member(1)",

View file

@ -214,6 +214,28 @@ describe('Snapping', () => {
},
})
})
it('snaps to vertices on other line shapes', () => {
editor.createShapesFromJsx([
<TL.line
x={150}
y={150}
handles={{ ['a1' as IndexKey]: { x: 200, y: 0 }, ['a2' as IndexKey]: { x: 300, y: 0 } }}
/>,
])
editor.select(id)
editor
.pointerDown(0, 0, { target: 'handle', shape: getShape(), handle: getHandles()[0] })
.pointerMove(205, 1, undefined, { ctrlKey: true })
expect(editor.snaps.getIndicators()).toHaveLength(1)
editor.expectShapeToMatch({
id: id,
props: { handles: { a1: { x: 200, y: 0 } } },
})
})
})
describe('Misc', () => {

View file

@ -380,6 +380,12 @@ export class LineShapeUtil extends ShapeUtil<TLLineShape> {
}
}
}
override getHandleSnapGeometry(shape: TLLineShape) {
return {
points: Object.values(shape.props.handles),
}
}
}
/** @public */

View file

@ -162,12 +162,17 @@ describe('custom shape bounds snapping - translate', () => {
describe('custom handle snapping', () => {
type TestShape = TLBaseShape<
'test',
{ w: number; h: number; handleGeomVertices: VecModel[] | 'default' | null }
{
w: number
h: number
handleOutline: VecModel[] | 'default' | null
handlePoints: 'default' | VecModel[]
}
>
class TestShapeUtil extends BaseBoxShapeUtil<TestShape> {
static override type = 'test'
override getDefaultProps(): TestShape['props'] {
return { w: 100, h: 100, handleGeomVertices: 'default' }
return { w: 100, h: 100, handleOutline: 'default', handlePoints: 'default' }
}
override component() {
throw new Error('Method not implemented.')
@ -176,14 +181,15 @@ describe('custom handle snapping', () => {
throw new Error('Method not implemented.')
}
override getHandleSnapGeometry(shape: TestShape) {
const vertices = shape.props.handleGeomVertices
const { handleOutline, handlePoints } = shape.props
return {
outline:
vertices === 'default'
handleOutline === 'default'
? undefined
: vertices === null
: handleOutline === null
? null
: new Polyline2d({ points: vertices.map(Vec.From) }),
: new Polyline2d({ points: handleOutline.map(Vec.From) }),
points: handlePoints === 'default' ? undefined : handlePoints,
}
}
}
@ -220,13 +226,12 @@ describe('custom handle snapping', () => {
return { x: handle.x, y: handle.y }
}
describe('with default handleGeomVertices', () => {
describe('with default handleSnapGeometry.outline', () => {
test('snaps handles to the box of the shape', () => {
startDraggingHandle()
editor.pointerMove(215, 205, undefined, { ctrlKey: true })
expect(editor.snaps.getIndicators()).toHaveLength(1)
expect(handlePosition().x).toBe(215)
expect(handlePosition().y).toBe(200)
expect(handlePosition()).toMatchObject({ x: 215, y: 200 })
})
test("doesn't particularly snap to vertices", () => {
@ -234,25 +239,23 @@ describe('custom handle snapping', () => {
editor.pointerMove(204, 205, undefined, { ctrlKey: true })
// only snapped to the nearest edge, not the vertex
expect(editor.snaps.getIndicators()).toHaveLength(1)
expect(handlePosition().x).toBe(200)
expect(handlePosition().y).toBe(205)
expect(handlePosition()).toMatchObject({ x: 200, y: 205 })
})
test("doesn't snap to the center", () => {
startDraggingHandle()
editor.pointerMove(251, 251, undefined, { ctrlKey: true })
expect(editor.snaps.getIndicators()).toHaveLength(0)
expect(handlePosition().x).toBe(251)
expect(handlePosition().y).toBe(251)
expect(handlePosition()).toMatchObject({ x: 251, y: 251 })
})
})
describe('with empty handleGeomVertices', () => {
describe('with empty handleSnapGeometry.outline', () => {
beforeEach(() => {
editor.updateShape<TestShape>({
id: ids.test,
type: 'test',
props: { handleGeomVertices: null },
props: { handleOutline: null },
})
})
@ -260,19 +263,18 @@ describe('custom handle snapping', () => {
startDraggingHandle()
editor.pointerMove(215, 205, undefined, { ctrlKey: true })
expect(editor.snaps.getIndicators()).toHaveLength(0)
expect(handlePosition().x).toBe(215)
expect(handlePosition().y).toBe(205)
expect(handlePosition()).toMatchObject({ x: 215, y: 205 })
})
})
describe('with custom handleGeomVertices', () => {
describe('with custom handleSnapGeometry.outline', () => {
beforeEach(() => {
editor.updateShape<TestShape>({
id: ids.test,
type: 'test',
props: {
// a diagonal line from the top left to the bottom right
handleGeomVertices: [
handleOutline: [
{ x: 0, y: 0 },
{ x: 100, y: 100 },
],
@ -284,16 +286,96 @@ describe('custom handle snapping', () => {
startDraggingHandle()
editor.pointerMove(235, 205, undefined, { ctrlKey: true })
expect(editor.snaps.getIndicators()).toHaveLength(0)
expect(handlePosition().x).toBe(235)
expect(handlePosition().y).toBe(205)
expect(handlePosition()).toMatchObject({ x: 235, y: 205 })
})
test('snaps to the custom geometry', () => {
startDraggingHandle()
editor.pointerMove(210, 214, undefined, { ctrlKey: true })
expect(editor.snaps.getIndicators()).toHaveLength(1)
expect(handlePosition().x).toBe(212)
expect(handlePosition().y).toBe(212)
expect(handlePosition()).toMatchObject({ x: 212, y: 212 })
})
})
describe('with default handleSnapGeometry.points', () => {
test('doesnt snap to the center', () => {
startDraggingHandle()
editor.pointerMove(251, 251, undefined, { ctrlKey: true })
expect(editor.snaps.getIndicators()).toHaveLength(0)
expect(handlePosition()).toMatchObject({ x: 251, y: 251 })
})
test('doesnt snap to corners', () => {
startDraggingHandle()
editor.pointerMove(203, 202, undefined, { ctrlKey: true })
// snaps to edge, not corner:
expect(editor.snaps.getIndicators()).toHaveLength(1)
expect(handlePosition()).toMatchObject({ x: 203, y: 200 })
})
})
describe('with custom handleSnapGeometry.points', () => {
beforeEach(() => {
editor.updateShape<TestShape>({
id: ids.test,
type: 'test',
props: {
handlePoints: [
{ x: 30, y: 30 },
{ x: 70, y: 50 },
],
},
})
})
test('snaps to the custom points', () => {
startDraggingHandle()
editor.pointerMove(235, 235, undefined, { ctrlKey: true })
expect(editor.snaps.getIndicators()).toHaveLength(1)
expect(handlePosition()).toMatchObject({ x: 230, y: 230 })
editor.snaps.clearIndicators()
editor.pointerMove(265, 255, undefined, { ctrlKey: true })
expect(editor.snaps.getIndicators()).toHaveLength(1)
expect(handlePosition()).toMatchObject({ x: 270, y: 250 })
})
})
describe('with custom handleSnapGeometry.points along the outline', () => {
beforeEach(() => {
editor.updateShape<TestShape>({
id: ids.test,
type: 'test',
props: {
handlePoints: editor
.getShapeGeometry(ids.test)
.bounds.cornersAndCenter.map(({ x, y }) => ({ x, y })),
},
})
})
test('snaps to points over outline', () => {
startDraggingHandle()
editor.pointerMove(203, 202, undefined, { ctrlKey: true })
// snaps to corner, not edge:
expect(editor.snaps.getIndicators()).toHaveLength(1)
expect(handlePosition()).toMatchObject({ x: 200, y: 200 })
})
test('can still snap to non-outline points', () => {
startDraggingHandle()
editor.pointerMove(255, 255, undefined, { ctrlKey: true })
// snaps to the center:
expect(editor.snaps.getIndicators()).toHaveLength(1)
expect(handlePosition()).toMatchObject({ x: 250, y: 250 })
})
test('can still snap to non-point outlines', () => {
startDraggingHandle()
editor.pointerMove(235, 205, undefined, { ctrlKey: true })
// snaps to the edge:
expect(editor.snaps.getIndicators()).toHaveLength(1)
expect(handlePosition()).toMatchObject({ x: 235, y: 200 })
})
})
})