[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;
|
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>;
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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))
|
if (distance < minDistanceForSnapPoint) {
|
||||||
|
minDistanceForSnapPoint = distance
|
||||||
for (const snapPointInShapeSpace of snapPoints) {
|
nearestSnapPoint = snapPoint
|
||||||
const snapPointInPageSpace = shapePageTransform.applyToPoint(snapPointInShapeSpace)
|
|
||||||
const distance = Vec.Dist(handlePoint, snapPointInPageSpace)
|
|
||||||
|
|
||||||
if (distance < minDistanceForSnapPoint) {
|
|
||||||
minDistanceForSnapPoint = distance
|
|
||||||
nearestSnapPoint = snapPointInPageSpace
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
|
|
@ -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.
|
||||||
*
|
*
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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 })
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
})
|
})
|
||||||
|
|
|
@ -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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
Loading…
Reference in a new issue