[Snapping 6/6] Self-snapping API (#2869)

This diff adds a self-snapping API for handles. Self-snapping is used
when a shape's handles want to snap to the shape itself. By default,
this isn't allowed because moving the handle might move the snap point,
which creates a janky user experience.

Now, shapes can return customised versions of their normal handle
snapping geometry in these cases. As a bonus, line shapes now snap to
other handles on their own line!

### Change Type

- [x] `minor` — New feature

### Test Plan

1. Line handles should snap to other handles on the same line when
holding command

- [x] Unit Tests

### Release Notes

- Line handles now snap to other handles on the same line when holding
command

---------

Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
This commit is contained in:
alex 2024-02-19 17:27:29 +00:00 committed by GitHub
parent 31ce1c1a89
commit 50f77fe75c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 372 additions and 322 deletions

View file

@ -741,7 +741,6 @@ export class Editor extends EventEmitter<TLEventMap> {
getShapeLocalTransform(shape: TLShape | TLShapeId): Mat; getShapeLocalTransform(shape: TLShape | TLShapeId): Mat;
getShapeMask(shape: TLShape | TLShapeId): undefined | VecLike[]; getShapeMask(shape: TLShape | TLShapeId): undefined | VecLike[];
getShapeMaskedPageBounds(shape: TLShape | TLShapeId): Box | undefined; getShapeMaskedPageBounds(shape: TLShape | TLShapeId): Box | undefined;
getShapeOutlineSegments<T extends TLShape>(shape: T | T['id']): Vec[][];
getShapePageBounds(shape: TLShape | TLShapeId): Box | undefined; getShapePageBounds(shape: TLShape | TLShapeId): Box | undefined;
getShapePageTransform(shape: TLShape | TLShapeId): Mat; getShapePageTransform(shape: TLShape | TLShapeId): Mat;
getShapeParent(shape?: TLShape | TLShapeId): TLShape | undefined; getShapeParent(shape?: TLShape | TLShapeId): TLShape | undefined;
@ -1148,6 +1147,8 @@ export const HALF_PI: number;
// @public // @public
export interface HandleSnapGeometry { export interface HandleSnapGeometry {
getSelfSnapOutline?(handle: TLHandle): Geometry2d | null;
getSelfSnapPoints?(handle: TLHandle): VecModel[];
outline?: Geometry2d | null; outline?: Geometry2d | null;
points?: VecModel[]; points?: VecModel[];
} }
@ -1607,7 +1608,6 @@ export abstract class ShapeUtil<Shape extends TLUnknownShape = TLUnknownShape> {
abstract getGeometry(shape: Shape): Geometry2d; abstract getGeometry(shape: Shape): Geometry2d;
getHandles?(shape: Shape): TLHandle[]; getHandles?(shape: Shape): TLHandle[];
getHandleSnapGeometry(shape: Shape): HandleSnapGeometry; getHandleSnapGeometry(shape: Shape): HandleSnapGeometry;
getOutlineSegments(shape: Shape): Vec[][];
hideResizeHandles: TLShapeUtilFlag<Shape>; hideResizeHandles: TLShapeUtilFlag<Shape>;
hideRotateHandle: TLShapeUtilFlag<Shape>; hideRotateHandle: TLShapeUtilFlag<Shape>;
hideSelectionBoundsBg: TLShapeUtilFlag<Shape>; hideSelectionBoundsBg: TLShapeUtilFlag<Shape>;

View file

@ -12913,81 +12913,6 @@
"isAbstract": false, "isAbstract": false,
"name": "getShapeMaskedPageBounds" "name": "getShapeMaskedPageBounds"
}, },
{
"kind": "Method",
"canonicalReference": "@tldraw/editor!Editor#getShapeOutlineSegments:member(1)",
"docComment": "/**\n * Get the local outline segments of a shape.\n *\n * @param shape - The shape (or shape id) to get the outline segments for.\n *\n * @example\n * ```ts\n * editor.getShapeOutlineSegments(myShape)\n * editor.getShapeOutlineSegments(myShapeId)\n * ```\n *\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "getShapeOutlineSegments<T extends "
},
{
"kind": "Reference",
"text": "TLShape",
"canonicalReference": "@tldraw/tlschema!TLShape:type"
},
{
"kind": "Content",
"text": ">(shape: "
},
{
"kind": "Content",
"text": "T | T['id']"
},
{
"kind": "Content",
"text": "): "
},
{
"kind": "Reference",
"text": "Vec",
"canonicalReference": "@tldraw/editor!Vec:class"
},
{
"kind": "Content",
"text": "[][]"
},
{
"kind": "Content",
"text": ";"
}
],
"typeParameters": [
{
"typeParameterName": "T",
"constraintTokenRange": {
"startIndex": 1,
"endIndex": 2
},
"defaultTypeTokenRange": {
"startIndex": 0,
"endIndex": 0
}
}
],
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 5,
"endIndex": 7
},
"releaseTag": "Public",
"isProtected": false,
"overloadIndex": 1,
"parameters": [
{
"parameterName": "shape",
"parameterTypeTokenRange": {
"startIndex": 3,
"endIndex": 4
},
"isOptional": false
}
],
"isOptional": false,
"isAbstract": false,
"name": "getShapeOutlineSegments"
},
{ {
"kind": "Method", "kind": "Method",
"canonicalReference": "@tldraw/editor!Editor#getShapePageBounds:member(1)", "canonicalReference": "@tldraw/editor!Editor#getShapePageBounds:member(1)",
@ -23309,6 +23234,108 @@
"name": "HandleSnapGeometry", "name": "HandleSnapGeometry",
"preserveMemberOrder": false, "preserveMemberOrder": false,
"members": [ "members": [
{
"kind": "MethodSignature",
"canonicalReference": "@tldraw/editor!HandleSnapGeometry#getSelfSnapOutline:member(1)",
"docComment": "/**\n * By default, handles can't snap to their own shape because moving the handle might change the snapping location which can cause feedback loops. You can override this by returning a version of `outline` that won't be affected by the current handle's position to use for self-snapping.\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "getSelfSnapOutline?(handle: "
},
{
"kind": "Reference",
"text": "TLHandle",
"canonicalReference": "@tldraw/tlschema!TLHandle:interface"
},
{
"kind": "Content",
"text": "): "
},
{
"kind": "Reference",
"text": "Geometry2d",
"canonicalReference": "@tldraw/editor!Geometry2d:class"
},
{
"kind": "Content",
"text": " | null"
},
{
"kind": "Content",
"text": ";"
}
],
"isOptional": true,
"returnTypeTokenRange": {
"startIndex": 3,
"endIndex": 5
},
"releaseTag": "Public",
"overloadIndex": 1,
"parameters": [
{
"parameterName": "handle",
"parameterTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
},
"isOptional": false
}
],
"name": "getSelfSnapOutline"
},
{
"kind": "MethodSignature",
"canonicalReference": "@tldraw/editor!HandleSnapGeometry#getSelfSnapPoints:member(1)",
"docComment": "/**\n * By default, handles can't snap to their own shape because moving the handle might change the snapping location which can cause feedback loops. You can override this by returning a version of `points` that won't be affected by the current handle's position to use for self-snapping.\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "getSelfSnapPoints?(handle: "
},
{
"kind": "Reference",
"text": "TLHandle",
"canonicalReference": "@tldraw/tlschema!TLHandle:interface"
},
{
"kind": "Content",
"text": "): "
},
{
"kind": "Reference",
"text": "VecModel",
"canonicalReference": "@tldraw/tlschema!VecModel:interface"
},
{
"kind": "Content",
"text": "[]"
},
{
"kind": "Content",
"text": ";"
}
],
"isOptional": true,
"returnTypeTokenRange": {
"startIndex": 3,
"endIndex": 5
},
"releaseTag": "Public",
"overloadIndex": 1,
"parameters": [
{
"parameterName": "handle",
"parameterTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
},
"isOptional": false
}
],
"name": "getSelfSnapPoints"
},
{ {
"kind": "PropertySignature", "kind": "PropertySignature",
"canonicalReference": "@tldraw/editor!HandleSnapGeometry#outline:member", "canonicalReference": "@tldraw/editor!HandleSnapGeometry#outline:member",
@ -30778,59 +30805,6 @@
"isAbstract": false, "isAbstract": false,
"name": "getHandleSnapGeometry" "name": "getHandleSnapGeometry"
}, },
{
"kind": "Method",
"canonicalReference": "@tldraw/editor!ShapeUtil#getOutlineSegments:member(1)",
"docComment": "/**\n * Get an array of outline segments for the shape. For most shapes, this will be a single segment that includes the entire outline. For shapes with handles, this might be segments of the outline between each handle.\n *\n * @param shape - The shape.\n *\n * @example\n * ```ts\n * util.getOutlineSegments(myShape)\n * ```\n *\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "getOutlineSegments(shape: "
},
{
"kind": "Content",
"text": "Shape"
},
{
"kind": "Content",
"text": "): "
},
{
"kind": "Reference",
"text": "Vec",
"canonicalReference": "@tldraw/editor!Vec:class"
},
{
"kind": "Content",
"text": "[][]"
},
{
"kind": "Content",
"text": ";"
}
],
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 3,
"endIndex": 5
},
"releaseTag": "Public",
"isProtected": false,
"overloadIndex": 1,
"parameters": [
{
"parameterName": "shape",
"parameterTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
},
"isOptional": false
}
],
"isOptional": false,
"isAbstract": false,
"name": "getOutlineSegments"
},
{ {
"kind": "Property", "kind": "Property",
"canonicalReference": "@tldraw/editor!ShapeUtil#hideResizeHandles:member", "canonicalReference": "@tldraw/editor!ShapeUtil#hideResizeHandles:member",

View file

@ -3815,33 +3815,6 @@ export class Editor extends EventEmitter<TLEventMap> {
return this._getShapeGeometryCache().get(typeof shape === 'string' ? shape : shape.id)! as T return this._getShapeGeometryCache().get(typeof shape === 'string' ? shape : shape.id)! as T
} }
/** @internal */
@computed private _getShapeOutlineSegmentsCache(): ComputedCache<Vec[][], TLShape> {
return this.store.createComputedCache('outline-segments', (shape) => {
return this.getShapeUtil(shape).getOutlineSegments(shape)
})
}
/**
* Get the local outline segments of a shape.
*
* @example
* ```ts
* editor.getShapeOutlineSegments(myShape)
* editor.getShapeOutlineSegments(myShapeId)
* ```
*
* @param shape - The shape (or shape id) to get the outline segments for.
*
* @public
*/
getShapeOutlineSegments<T extends TLShape>(shape: T | T['id']): Vec[][] {
return (
this._getShapeOutlineSegmentsCache().get(typeof shape === 'string' ? shape : shape.id) ??
EMPTY_ARRAY
)
}
/** @internal */ /** @internal */
@computed private _getShapeHandlesCache(): ComputedCache<TLHandle[] | undefined, TLShape> { @computed private _getShapeHandlesCache(): ComputedCache<TLHandle[] | undefined, TLShape> {
return this.store.createComputedCache('handles', (shape) => { return this.store.createComputedCache('handles', (shape) => {

View file

@ -1,5 +1,5 @@
import { computed } from '@tldraw/state' import { computed } from '@tldraw/state'
import { TLShape, VecModel } from '@tldraw/tlschema' import { TLHandle, TLShape, TLShapeId, VecModel } from '@tldraw/tlschema'
import { assertExists } from '@tldraw/utils' import { assertExists } from '@tldraw/utils'
import { Vec } from '../../../primitives/Vec' import { Vec } from '../../../primitives/Vec'
import { Geometry2d } from '../../../primitives/geometry/Geometry2d' import { Geometry2d } from '../../../primitives/geometry/Geometry2d'
@ -28,8 +28,25 @@ export interface HandleSnapGeometry {
* rectangle, or the centroid of a triangle. By default, no points are used. * rectangle, or the centroid of a triangle. By default, no points are used.
*/ */
points?: VecModel[] points?: VecModel[]
/**
* By default, handles can't snap to their own shape because moving the handle might change the
* snapping location which can cause feedback loops. You can override this by returning a
* version of `outline` that won't be affected by the current handle's position to use for
* self-snapping.
*/
getSelfSnapOutline?(handle: TLHandle): Geometry2d | null
/**
* By default, handles can't snap to their own shape because moving the handle might change the
* snapping location which can cause feedback loops. You can override this by returning a
* version of `points` that won't be affected by the current handle's position to use for
* self-snapping.
*/
getSelfSnapPoints?(handle: TLHandle): VecModel[]
} }
const defaultGetSelfSnapOutline = () => null
const defaultGetSelfSnapPoints = () => []
export class HandleSnaps { export class HandleSnaps {
readonly editor: Editor readonly editor: Editor
constructor(readonly manager: SnapManager) { constructor(readonly manager: SnapManager) {
@ -47,17 +64,62 @@ export class HandleSnaps {
? editor.getShapeGeometry(shape) ? editor.getShapeGeometry(shape)
: snapGeometry.outline, : snapGeometry.outline,
points: snapGeometry.points, points: snapGeometry.points ?? [],
getSelfSnapOutline: snapGeometry.getSelfSnapOutline ?? defaultGetSelfSnapOutline,
getSelfSnapPoints: snapGeometry.getSelfSnapPoints ?? defaultGetSelfSnapPoints,
} }
}) })
} }
private *iterateSnapPointsInPageSpace(currentShapeId: TLShapeId, currentHandle: TLHandle) {
const selfSnapPoints = this.getSnapGeometryCache()
.get(currentShapeId)
?.getSelfSnapPoints(currentHandle)
if (selfSnapPoints && selfSnapPoints.length) {
const shapePageTransform = assertExists(this.editor.getShapePageTransform(currentShapeId))
for (const point of selfSnapPoints) {
yield shapePageTransform.applyToPoint(point)
}
}
for (const shapeId of this.manager.getSnappableShapes()) {
if (shapeId === currentShapeId) continue
const snapPoints = this.getSnapGeometryCache().get(shapeId)?.points
if (!snapPoints || !snapPoints.length) continue
const shapePageTransform = assertExists(this.editor.getShapePageTransform(shapeId))
for (const point of snapPoints) {
yield shapePageTransform.applyToPoint(point)
}
}
}
private *iterateSnapOutlines(currentShapeId: TLShapeId, currentHandle: TLHandle) {
const selfSnapOutline = this.getSnapGeometryCache()
.get(currentShapeId)
?.getSelfSnapOutline(currentHandle)
if (selfSnapOutline) {
yield { shapeId: currentShapeId, outline: selfSnapOutline }
}
for (const shapeId of this.manager.getSnappableShapes()) {
if (shapeId === currentShapeId) continue
const snapOutline = this.getSnapGeometryCache().get(shapeId)?.outline
if (!snapOutline) continue
yield { shapeId, outline: snapOutline }
}
}
private getHandleSnapPosition({ private getHandleSnapPosition({
handlePoint, currentShapeId,
additionalSegments, handle,
handleInPageSpace,
}: { }: {
handlePoint: Vec currentShapeId: TLShapeId
additionalSegments: Vec[][] handle: TLHandle
handleInPageSpace: Vec
}): Vec | null { }): Vec | null {
const snapThreshold = this.manager.getSnapThreshold() const snapThreshold = this.manager.getSnapThreshold()
@ -70,20 +132,12 @@ export class HandleSnaps {
// Start with the points: // Start with the points:
let minDistanceForSnapPoint = snapThreshold let minDistanceForSnapPoint = snapThreshold
let nearestSnapPoint: Vec | null = null let nearestSnapPoint: Vec | null = null
for (const shapeId of this.manager.getSnappableShapes()) { for (const snapPoint of this.iterateSnapPointsInPageSpace(currentShapeId, handle)) {
const snapPoints = this.getSnapGeometryCache().get(shapeId)?.points const distance = Vec.Dist(handleInPageSpace, snapPoint)
if (!snapPoints) continue
const shapePageTransform = assertExists(this.editor.getShapePageTransform(shapeId))
for (const snapPointInShapeSpace of snapPoints) {
const snapPointInPageSpace = shapePageTransform.applyToPoint(snapPointInShapeSpace)
const distance = Vec.Dist(handlePoint, snapPointInPageSpace)
if (distance < minDistanceForSnapPoint) { if (distance < minDistanceForSnapPoint) {
minDistanceForSnapPoint = distance minDistanceForSnapPoint = distance
nearestSnapPoint = snapPointInPageSpace nearestSnapPoint = snapPoint
}
} }
} }
@ -94,16 +148,13 @@ export class HandleSnaps {
let minDistanceForOutline = snapThreshold let minDistanceForOutline = snapThreshold
let nearestPointOnOutline: Vec | null = null let nearestPointOnOutline: Vec | null = null
for (const shapeId of this.manager.getSnappableShapes()) { for (const { shapeId, outline } of this.iterateSnapOutlines(currentShapeId, handle)) {
const snapOutline = this.getSnapGeometryCache().get(shapeId)?.outline
if (!snapOutline) continue
const shapePageTransform = assertExists(this.editor.getShapePageTransform(shapeId)) const shapePageTransform = assertExists(this.editor.getShapePageTransform(shapeId))
const pointInShapeSpace = this.editor.getPointInShapeSpace(shapeId, handlePoint) const pointInShapeSpace = this.editor.getPointInShapeSpace(shapeId, handleInPageSpace)
const nearestShapePointInShapeSpace = snapOutline.nearestPoint(pointInShapeSpace) const nearestShapePointInShapeSpace = outline.nearestPoint(pointInShapeSpace)
const nearestInPageSpace = shapePageTransform.applyToPoint(nearestShapePointInShapeSpace) const nearestInPageSpace = shapePageTransform.applyToPoint(nearestShapePointInShapeSpace)
const distance = Vec.Dist(handlePoint, nearestInPageSpace) const distance = Vec.Dist(handleInPageSpace, nearestInPageSpace)
if (distance < minDistanceForOutline) { if (distance < minDistanceForOutline) {
minDistanceForOutline = distance minDistanceForOutline = distance
@ -111,18 +162,6 @@ export class HandleSnaps {
} }
} }
// 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 < minDistanceForOutline) {
minDistanceForOutline = distance
nearestPointOnOutline = nearestOnSegment
}
}
// if we found a point on the outline, return it // if we found a point on the outline, return it
if (nearestPointOnOutline) return nearestPointOnOutline if (nearestPointOnOutline) return nearestPointOnOutline
@ -130,8 +169,16 @@ export class HandleSnaps {
return null return null
} }
snapHandle(opts: { handlePoint: Vec; additionalSegments: Vec[][] }): SnapData | null { snapHandle({
const snapPosition = this.getHandleSnapPosition(opts) currentShapeId,
handle,
}: {
currentShapeId: TLShapeId
handle: TLHandle
}): SnapData | null {
const currentShapeTransform = assertExists(this.editor.getShapePageTransform(currentShapeId))
const handleInPageSpace = currentShapeTransform.applyToPoint(handle)
const snapPosition = this.getHandleSnapPosition({ currentShapeId, handle, handleInPageSpace })
// If we found a point, display snap lines, and return the nudge // If we found a point, display snap lines, and return the nudge
if (snapPosition) { if (snapPosition) {
@ -143,7 +190,7 @@ export class HandleSnaps {
}, },
]) ])
return { nudge: Vec.Sub(snapPosition, opts.handlePoint) } return { nudge: Vec.Sub(snapPosition, handleInPageSpace) }
} }
return null return null

View file

@ -201,25 +201,6 @@ export abstract class ShapeUtil<Shape extends TLUnknownShape = TLUnknownShape> {
*/ */
getHandles?(shape: Shape): TLHandle[] getHandles?(shape: Shape): TLHandle[]
/**
* Get an array of outline segments for the shape. For most shapes,
* this will be a single segment that includes the entire outline.
* For shapes with handles, this might be segments of the outline
* between each handle.
*
* @example
*
* ```ts
* util.getOutlineSegments(myShape)
* ```
*
* @param shape - The shape.
* @public
*/
getOutlineSegments(shape: Shape): Vec[][] {
return [this.editor.getShapeGeometry(shape).vertices]
}
/** /**
* Get whether the shape can receive children of a given type. * Get whether the shape can receive children of a given type.
* *

View file

@ -19,6 +19,7 @@ export class CubicBezier2d extends Polyline2d {
) { ) {
const { start: a, cp1: b, cp2: c, end: d } = config const { start: a, cp1: b, cp2: c, end: d } = config
super({ ...config, points: [a, d] }) super({ ...config, points: [a, d] })
this.a = a this.a = a
this.b = b this.b = b
this.c = c this.c = c

View file

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

View file

@ -10257,18 +10257,10 @@
"kind": "Content", "kind": "Content",
"text": "): " "text": "): "
}, },
{
"kind": "Content",
"text": "{\n points: import(\"@tldraw/editor\")."
},
{ {
"kind": "Reference", "kind": "Reference",
"text": "VecModel", "text": "HandleSnapGeometry",
"canonicalReference": "@tldraw/tlschema!VecModel:interface" "canonicalReference": "@tldraw/editor!HandleSnapGeometry:interface"
},
{
"kind": "Content",
"text": "[];\n }"
}, },
{ {
"kind": "Content", "kind": "Content",
@ -10278,7 +10270,7 @@
"isStatic": false, "isStatic": false,
"returnTypeTokenRange": { "returnTypeTokenRange": {
"startIndex": 3, "startIndex": 3,
"endIndex": 6 "endIndex": 4
}, },
"releaseTag": "Public", "releaseTag": "Public",
"isProtected": false, "isProtected": false,
@ -10297,60 +10289,6 @@
"isAbstract": false, "isAbstract": false,
"name": "getHandleSnapGeometry" "name": "getHandleSnapGeometry"
}, },
{
"kind": "Method",
"canonicalReference": "@tldraw/tldraw!LineShapeUtil#getOutlineSegments:member(1)",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "getOutlineSegments(shape: "
},
{
"kind": "Reference",
"text": "TLLineShape",
"canonicalReference": "@tldraw/tlschema!TLLineShape:type"
},
{
"kind": "Content",
"text": "): "
},
{
"kind": "Reference",
"text": "Vec",
"canonicalReference": "@tldraw/editor!Vec:class"
},
{
"kind": "Content",
"text": "[][]"
},
{
"kind": "Content",
"text": ";"
}
],
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 3,
"endIndex": 5
},
"releaseTag": "Public",
"isProtected": false,
"overloadIndex": 1,
"parameters": [
{
"parameterName": "shape",
"parameterTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
},
"isOptional": false
}
],
"isOptional": false,
"isAbstract": false,
"name": "getOutlineSegments"
},
{ {
"kind": "Property", "kind": "Property",
"canonicalReference": "@tldraw/tldraw!LineShapeUtil#hideResizeHandles:member", "canonicalReference": "@tldraw/tldraw!LineShapeUtil#hideResizeHandles:member",

View file

@ -189,6 +189,27 @@ describe('Snapping', () => {
}) })
}) })
it('snaps endpoints to its vertices', () => {
editor.select(id)
editor
.pointerDown(0, 0, { target: 'handle', shape: getShape(), handle: getHandles()[0] })
.pointerMove(3, 95, undefined, { ctrlKey: true })
expect(editor.snaps.getIndicators()).toHaveLength(1)
editor.expectShapeToMatch({
id: id,
props: {
points: [
{ x: 0, y: 100 },
{ x: 100, y: 0 },
{ x: 100, y: 100 },
{ x: 0, y: 100 },
],
},
})
})
it("doesn't snap to the segment of the current handle", () => { it("doesn't snap to the segment of the current handle", () => {
editor.select(id) editor.select(id)

View file

@ -1,6 +1,8 @@
/* eslint-disable react-hooks/rules-of-hooks */ /* eslint-disable react-hooks/rules-of-hooks */
import { import {
CubicSpline2d, CubicSpline2d,
Group2d,
HandleSnapGeometry,
Polyline2d, Polyline2d,
SVGContainer, SVGContainer,
ShapeUtil, ShapeUtil,
@ -110,11 +112,6 @@ export class LineShapeUtil extends ShapeUtil<TLLineShape> {
}) })
} }
override getOutlineSegments(shape: TLLineShape) {
const spline = this.editor.getShapeGeometry(shape) as Polyline2d | CubicSpline2d
return spline.segments.map((s) => s.vertices)
}
// Events // Events
override onResize: TLOnResizeHandler<TLLineShape> = (shape, info) => { override onResize: TLOnResizeHandler<TLLineShape> = (shape, info) => {
@ -389,9 +386,34 @@ export class LineShapeUtil extends ShapeUtil<TLLineShape> {
} }
} }
override getHandleSnapGeometry(shape: TLLineShape) { override getHandleSnapGeometry(shape: TLLineShape): HandleSnapGeometry {
const { points } = shape.props
return { return {
points: shape.props.points, points,
getSelfSnapPoints: (handle) => {
const index = this.getHandles(shape)
.filter((h) => h.type === 'vertex')
.findIndex((h) => h.id === handle.id)!
// We want to skip the current and adjacent handles
return points.filter((_, i) => Math.abs(i - index) > 1).map(Vec.From)
},
getSelfSnapOutline: (handle) => {
// We want to skip the segments that include the handle, so
// find the index of the handle that shares the same index property
// as the initial dragging handle; this catches a quirk of create handles
const index = this.getHandles(shape)
.filter((h) => h.type === 'vertex')
.findIndex((h) => h.id === handle.id)!
// Get all the outline segments from the shape that don't include the handle
const segments = getGeometryForLineShape(shape).segments.filter(
(_, i) => i !== index - 1 && i !== index
)
if (!segments.length) return null
return new Group2d({ children: segments })
},
} }
} }
} }

View file

@ -1,5 +1,4 @@
import { import {
Mat,
StateNode, StateNode,
TLArrowShape, TLArrowShape,
TLArrowShapeTerminal, TLArrowShapeTerminal,
@ -263,43 +262,24 @@ export class DraggingHandle extends StateNode {
// Clear any existing snaps // Clear any existing snaps
editor.snaps.clearIndicators() editor.snaps.clearIndicators()
let nextHandle = { ...initialHandle, x: point.x, y: point.y }
if (initialHandle.canSnap && (isSnapMode ? !ctrlKey : ctrlKey)) { if (initialHandle.canSnap && (isSnapMode ? !ctrlKey : ctrlKey)) {
// We're snapping // We're snapping
const pageTransform = editor.getShapePageTransform(shape.id) const pageTransform = editor.getShapePageTransform(shape.id)
if (!pageTransform) throw Error('Expected a page transform') if (!pageTransform) throw Error('Expected a page transform')
// We want to skip the segments that include the handle, so const snap = snaps.handles.snapHandle({ currentShapeId: shapeId, handle: nextHandle })
// find the index of the handle that shares the same index property
// as the initial dragging handle; this catches a quirk of create handles
const handleIndex = editor
.getShapeHandles(shape)!
.filter(({ type }) => type === 'vertex')
.sort(sortByIndex)
.findIndex(({ index }) => initialHandle.index === index)
// Get all the outline segments from the shape
const additionalSegments = util
.getOutlineSegments(shape)
.map((segment) => Mat.applyToPoints(pageTransform, segment))
.filter((_segment, i) => i !== handleIndex - 1 && i !== handleIndex)
const snap = snaps.handles.snapHandle({
additionalSegments,
handlePoint: Mat.applyToPoint(pageTransform, point),
})
if (snap) { if (snap) {
snap.nudge.rot(-editor.getShapeParentTransform(shape)!.rotation()) snap.nudge.rot(-editor.getShapeParentTransform(shape)!.rotation())
point.add(snap.nudge) point.add(snap.nudge)
nextHandle = { ...initialHandle, x: point.x, y: point.y }
} }
} }
const changes = util.onHandleDrag?.(shape, { const changes = util.onHandleDrag?.(shape, {
handle: { handle: nextHandle,
...initialHandle,
x: point.x,
y: point.y,
},
isPrecise: this.isPrecise || altKey, isPrecise: this.isPrecise || altKey,
initial: initial, initial: initial,
}) })

View file

@ -3,10 +3,13 @@ import {
Polyline2d, Polyline2d,
TLAnyShapeUtilConstructor, TLAnyShapeUtilConstructor,
TLBaseShape, TLBaseShape,
TLHandle,
TLLineShape, TLLineShape,
TLOnHandleDragHandler,
TLShapeId, TLShapeId,
Vec, Vec,
VecModel, VecModel,
ZERO_INDEX_KEY,
} from '@tldraw/editor' } from '@tldraw/editor'
import { TestEditor } from './TestEditor' import { TestEditor } from './TestEditor'
import { TL } from './test-jsx' import { TL } from './test-jsx'
@ -164,14 +167,25 @@ describe('custom handle snapping', () => {
{ {
w: number w: number
h: number h: number
ownHandle: VecModel
handleOutline: VecModel[] | 'default' | null handleOutline: VecModel[] | 'default' | null
handlePoints: 'default' | VecModel[] handlePoints: VecModel[] | 'default'
selfSnapOutline: VecModel[] | 'default'
selfSnapPoints: VecModel[] | 'default'
} }
> >
class TestShapeUtil extends BaseBoxShapeUtil<TestShape> { class TestShapeUtil extends BaseBoxShapeUtil<TestShape> {
static override type = 'test' static override type = 'test'
override getDefaultProps(): TestShape['props'] { override getDefaultProps(): TestShape['props'] {
return { w: 100, h: 100, handleOutline: 'default', handlePoints: 'default' } return {
w: 100,
h: 100,
ownHandle: { x: 0, y: 0 },
handleOutline: 'default',
handlePoints: 'default',
selfSnapOutline: 'default',
selfSnapPoints: 'default',
}
} }
override component() { override component() {
throw new Error('Method not implemented.') throw new Error('Method not implemented.')
@ -180,7 +194,7 @@ describe('custom handle snapping', () => {
throw new Error('Method not implemented.') throw new Error('Method not implemented.')
} }
override getHandleSnapGeometry(shape: TestShape) { override getHandleSnapGeometry(shape: TestShape) {
const { handleOutline, handlePoints } = shape.props const { handleOutline, handlePoints, selfSnapOutline, selfSnapPoints } = shape.props
return { return {
outline: outline:
handleOutline === 'default' handleOutline === 'default'
@ -189,8 +203,29 @@ describe('custom handle snapping', () => {
? null ? null
: new Polyline2d({ points: handleOutline.map(Vec.From) }), : new Polyline2d({ points: handleOutline.map(Vec.From) }),
points: handlePoints === 'default' ? undefined : handlePoints, points: handlePoints === 'default' ? undefined : handlePoints,
getSelfSnapOutline:
selfSnapOutline === 'default'
? undefined
: () => new Polyline2d({ points: selfSnapOutline.map(Vec.From) }),
getSelfSnapPoints: selfSnapPoints === 'default' ? undefined : () => selfSnapPoints,
} }
} }
override getHandles(shape: TestShape): TLHandle[] {
return [
{
id: 'handle',
type: 'vertex',
x: shape.props.ownHandle.x,
y: shape.props.ownHandle.y,
index: ZERO_INDEX_KEY,
canSnap: true,
},
]
}
override onHandleDrag: TLOnHandleDragHandler<TestShape> = (shape, { handle }) => {
return { ...shape, props: { ...shape.props, ownHandle: { x: handle.x, y: handle.y } } }
}
} }
const shapeUtils = [TestShapeUtil] as TLAnyShapeUtilConstructor[] const shapeUtils = [TestShapeUtil] as TLAnyShapeUtilConstructor[]
@ -377,4 +412,86 @@ describe('custom handle snapping', () => {
expect(handlePosition()).toMatchObject({ x: 235, y: 200 }) expect(handlePosition()).toMatchObject({ x: 235, y: 200 })
}) })
}) })
describe('self snapping', () => {
beforeEach(() => {
editor.deleteShape(ids.line)
editor.updateShape<TestShape>({
id: ids.test,
type: 'test',
x: 0,
y: 0,
props: {
handlePoints: [{ x: 0, y: 0 }],
},
})
})
function startDraggingOwnHandle() {
const shape = editor.select(ids.test).getOnlySelectedShape()!
const handles = editor.getShapeHandles(shape)!
editor.pointerDown(0, 0, { target: 'handle', shape, handle: handles[0] })
}
function ownHandlePosition() {
const shape = editor.select(ids.test).getOnlySelectedShape()!
const handle = editor.getShapeHandles(shape)![0]
return { x: handle.x, y: handle.y }
}
describe('by default', () => {
test('does not snap to standard outline', () => {
startDraggingOwnHandle()
editor.pointerMove(3, 50, undefined, { ctrlKey: true })
expect(editor.snaps.getIndicators()).toHaveLength(0)
expect(ownHandlePosition()).toMatchObject({ x: 3, y: 50 })
})
test('does not snap to standard points', () => {
startDraggingOwnHandle()
editor.pointerMove(3, 3, undefined, { ctrlKey: true })
expect(editor.snaps.getIndicators()).toHaveLength(0)
expect(ownHandlePosition()).toMatchObject({ x: 3, y: 3 })
})
})
describe('with custom self snap outline & points', () => {
beforeEach(() => {
editor.updateShape<TestShape>({
id: ids.test,
type: 'test',
props: {
selfSnapOutline: [
{ x: 20, y: 50 },
{ x: 80, y: 50 },
],
selfSnapPoints: [
{ x: 20, y: 50 },
{ x: 80, y: 50 },
],
},
})
})
test('does not snap to standard outline', () => {
startDraggingOwnHandle()
editor.pointerMove(3, 50, undefined, { ctrlKey: true })
expect(editor.snaps.getIndicators()).toHaveLength(0)
expect(ownHandlePosition()).toMatchObject({ x: 3, y: 50 })
})
test('does not snap to standard points', () => {
startDraggingOwnHandle()
editor.pointerMove(3, 3, undefined, { ctrlKey: true })
expect(editor.snaps.getIndicators()).toHaveLength(0)
expect(ownHandlePosition()).toMatchObject({ x: 3, y: 3 })
})
test('snaps to the self-snap outline', () => {
startDraggingOwnHandle()
editor.pointerMove(50, 55, undefined, { ctrlKey: true })
expect(editor.snaps.getIndicators()).toHaveLength(1)
expect(ownHandlePosition()).toMatchObject({ x: 50, y: 50 })
})
test('snaps to the self-snap points', () => {
startDraggingOwnHandle()
editor.pointerMove(23, 55, undefined, { ctrlKey: true })
expect(editor.snaps.getIndicators()).toHaveLength(1)
expect(ownHandlePosition()).toMatchObject({ x: 20, y: 50 })
})
})
})
}) })