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

View file

@ -12913,81 +12913,6 @@
"isAbstract": false,
"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",
"canonicalReference": "@tldraw/editor!Editor#getShapePageBounds:member(1)",
@ -23309,6 +23234,108 @@
"name": "HandleSnapGeometry",
"preserveMemberOrder": false,
"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",
"canonicalReference": "@tldraw/editor!HandleSnapGeometry#outline:member",
@ -30778,59 +30805,6 @@
"isAbstract": false,
"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",
"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
}
/** @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 */
@computed private _getShapeHandlesCache(): ComputedCache<TLHandle[] | undefined, TLShape> {
return this.store.createComputedCache('handles', (shape) => {

View file

@ -1,5 +1,5 @@
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 { Vec } from '../../../primitives/Vec'
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.
*/
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 {
readonly editor: Editor
constructor(readonly manager: SnapManager) {
@ -47,17 +64,62 @@ export class HandleSnaps {
? editor.getShapeGeometry(shape)
: 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({
handlePoint,
additionalSegments,
currentShapeId,
handle,
handleInPageSpace,
}: {
handlePoint: Vec
additionalSegments: Vec[][]
currentShapeId: TLShapeId
handle: TLHandle
handleInPageSpace: Vec
}): Vec | null {
const snapThreshold = this.manager.getSnapThreshold()
@ -70,20 +132,12 @@ export class HandleSnaps {
// Start with the points:
let minDistanceForSnapPoint = snapThreshold
let nearestSnapPoint: Vec | null = null
for (const shapeId of this.manager.getSnappableShapes()) {
const snapPoints = this.getSnapGeometryCache().get(shapeId)?.points
if (!snapPoints) continue
for (const snapPoint of this.iterateSnapPointsInPageSpace(currentShapeId, handle)) {
const distance = Vec.Dist(handleInPageSpace, snapPoint)
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) {
minDistanceForSnapPoint = distance
nearestSnapPoint = snapPointInPageSpace
}
if (distance < minDistanceForSnapPoint) {
minDistanceForSnapPoint = distance
nearestSnapPoint = snapPoint
}
}
@ -94,16 +148,13 @@ export class HandleSnaps {
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
for (const { shapeId, outline } of this.iterateSnapOutlines(currentShapeId, handle)) {
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 distance = Vec.Dist(handlePoint, nearestInPageSpace)
const distance = Vec.Dist(handleInPageSpace, nearestInPageSpace)
if (distance < minDistanceForOutline) {
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 (nearestPointOnOutline) return nearestPointOnOutline
@ -130,8 +169,16 @@ export class HandleSnaps {
return null
}
snapHandle(opts: { handlePoint: Vec; additionalSegments: Vec[][] }): SnapData | null {
const snapPosition = this.getHandleSnapPosition(opts)
snapHandle({
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 (snapPosition) {
@ -143,7 +190,7 @@ export class HandleSnaps {
},
])
return { nudge: Vec.Sub(snapPosition, opts.handlePoint) }
return { nudge: Vec.Sub(snapPosition, handleInPageSpace) }
}
return null

View file

@ -201,25 +201,6 @@ export abstract class ShapeUtil<Shape extends TLUnknownShape = TLUnknownShape> {
*/
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.
*

View file

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