[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:
parent
31ce1c1a89
commit
50f77fe75c
12 changed files with 372 additions and 322 deletions
|
@ -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>;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -865,11 +865,7 @@ export class LineShapeUtil extends ShapeUtil<TLLineShape> {
|
|||
// (undocumented)
|
||||
getHandles(shape: TLLineShape): TLHandle[];
|
||||
// (undocumented)
|
||||
getHandleSnapGeometry(shape: TLLineShape): {
|
||||
points: VecModel[];
|
||||
};
|
||||
// (undocumented)
|
||||
getOutlineSegments(shape: TLLineShape): Vec[][];
|
||||
getHandleSnapGeometry(shape: TLLineShape): HandleSnapGeometry;
|
||||
// (undocumented)
|
||||
hideResizeHandles: () => boolean;
|
||||
// (undocumented)
|
||||
|
|
|
@ -10257,18 +10257,10 @@
|
|||
"kind": "Content",
|
||||
"text": "): "
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "{\n points: import(\"@tldraw/editor\")."
|
||||
},
|
||||
{
|
||||
"kind": "Reference",
|
||||
"text": "VecModel",
|
||||
"canonicalReference": "@tldraw/tlschema!VecModel:interface"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "[];\n }"
|
||||
"text": "HandleSnapGeometry",
|
||||
"canonicalReference": "@tldraw/editor!HandleSnapGeometry:interface"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
|
@ -10278,7 +10270,7 @@
|
|||
"isStatic": false,
|
||||
"returnTypeTokenRange": {
|
||||
"startIndex": 3,
|
||||
"endIndex": 6
|
||||
"endIndex": 4
|
||||
},
|
||||
"releaseTag": "Public",
|
||||
"isProtected": false,
|
||||
|
@ -10297,60 +10289,6 @@
|
|||
"isAbstract": false,
|
||||
"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",
|
||||
"canonicalReference": "@tldraw/tldraw!LineShapeUtil#hideResizeHandles:member",
|
||||
|
|
|
@ -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", () => {
|
||||
editor.select(id)
|
||||
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
import {
|
||||
CubicSpline2d,
|
||||
Group2d,
|
||||
HandleSnapGeometry,
|
||||
Polyline2d,
|
||||
SVGContainer,
|
||||
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
|
||||
|
||||
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 {
|
||||
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 })
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import {
|
||||
Mat,
|
||||
StateNode,
|
||||
TLArrowShape,
|
||||
TLArrowShapeTerminal,
|
||||
|
@ -263,43 +262,24 @@ export class DraggingHandle extends StateNode {
|
|||
// Clear any existing snaps
|
||||
editor.snaps.clearIndicators()
|
||||
|
||||
let nextHandle = { ...initialHandle, x: point.x, y: point.y }
|
||||
|
||||
if (initialHandle.canSnap && (isSnapMode ? !ctrlKey : ctrlKey)) {
|
||||
// We're snapping
|
||||
const pageTransform = editor.getShapePageTransform(shape.id)
|
||||
if (!pageTransform) throw Error('Expected a page transform')
|
||||
|
||||
// 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 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),
|
||||
})
|
||||
const snap = snaps.handles.snapHandle({ currentShapeId: shapeId, handle: nextHandle })
|
||||
|
||||
if (snap) {
|
||||
snap.nudge.rot(-editor.getShapeParentTransform(shape)!.rotation())
|
||||
point.add(snap.nudge)
|
||||
nextHandle = { ...initialHandle, x: point.x, y: point.y }
|
||||
}
|
||||
}
|
||||
|
||||
const changes = util.onHandleDrag?.(shape, {
|
||||
handle: {
|
||||
...initialHandle,
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
},
|
||||
handle: nextHandle,
|
||||
isPrecise: this.isPrecise || altKey,
|
||||
initial: initial,
|
||||
})
|
||||
|
|
|
@ -3,10 +3,13 @@ import {
|
|||
Polyline2d,
|
||||
TLAnyShapeUtilConstructor,
|
||||
TLBaseShape,
|
||||
TLHandle,
|
||||
TLLineShape,
|
||||
TLOnHandleDragHandler,
|
||||
TLShapeId,
|
||||
Vec,
|
||||
VecModel,
|
||||
ZERO_INDEX_KEY,
|
||||
} from '@tldraw/editor'
|
||||
import { TestEditor } from './TestEditor'
|
||||
import { TL } from './test-jsx'
|
||||
|
@ -164,14 +167,25 @@ describe('custom handle snapping', () => {
|
|||
{
|
||||
w: number
|
||||
h: number
|
||||
ownHandle: VecModel
|
||||
handleOutline: VecModel[] | 'default' | null
|
||||
handlePoints: 'default' | VecModel[]
|
||||
handlePoints: VecModel[] | 'default'
|
||||
selfSnapOutline: VecModel[] | 'default'
|
||||
selfSnapPoints: VecModel[] | 'default'
|
||||
}
|
||||
>
|
||||
class TestShapeUtil extends BaseBoxShapeUtil<TestShape> {
|
||||
static override type = 'test'
|
||||
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() {
|
||||
throw new Error('Method not implemented.')
|
||||
|
@ -180,7 +194,7 @@ describe('custom handle snapping', () => {
|
|||
throw new Error('Method not implemented.')
|
||||
}
|
||||
override getHandleSnapGeometry(shape: TestShape) {
|
||||
const { handleOutline, handlePoints } = shape.props
|
||||
const { handleOutline, handlePoints, selfSnapOutline, selfSnapPoints } = shape.props
|
||||
return {
|
||||
outline:
|
||||
handleOutline === 'default'
|
||||
|
@ -189,8 +203,29 @@ describe('custom handle snapping', () => {
|
|||
? null
|
||||
: new Polyline2d({ points: handleOutline.map(Vec.From) }),
|
||||
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[]
|
||||
|
||||
|
@ -377,4 +412,86 @@ describe('custom handle snapping', () => {
|
|||
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 })
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
Loading…
Reference in a new issue