[Snapping 3/5] Custom snapping API (#2793)
This diff adds an API for customising our existing snap types. These are: 1. Bound snapping. When translating or resizing a shape, it'll snap to certain key points on the bounds of particular shapes. Previously, these were hard-coded to the corners and center of the bounding box of the shape. Now, a shape can bring its own (e.g. a triangle may add snapping for its 3 corners, and it's centroid rather than bounding box center. 2. Handle outline snapping. When dragging a handle, it'll snap to the outline of other shapes geometry. Now, shapes can return different geometry for this sort of snapping if they like. Each of these is customised through a method on `ShapeUtil`: `getBoundsSnapGeometry` and `getHandleSnapGeometry`. These return interfaces describing the different geometry that can be snapped to in both these cases. Currently, each returns an object with a single property, but there are more types of snapping coming in follow-up PRs. When reviewing this PR, start with the definitions of `BoundsSnapGeometry` in `BoundsSnaps.ts` and `HandleSnapGeometry` in `HandleSnaps.ts` This doesn't add point snapping - i'll add that in a follow-up! It'll be customisable with the `getHandleSnapGeometry` API. Fixes TLD-2197 This PR is part of a series - please don't merge it until the things before it have landed! 1. #2827 4. #2831 5. #2793 (you are here) 6. #2841 7. #2845 ### Change Type - [x] `minor` — New feature [^1]: publishes a `patch` release, for devDependencies use `internal` [^2]: will not publish a new version ### Test Plan - [x] Unit Tests ### Release Notes - Add `ShapeUtil.getSnapInfo` for customising shape snaps.
This commit is contained in:
parent
ac0259a6af
commit
77865d9f5e
15 changed files with 726 additions and 886 deletions
|
@ -159,6 +159,11 @@ export abstract class BaseBoxShapeUtil<Shape extends TLBaseBoxShape> extends Sha
|
|||
onResize: TLOnResizeHandler<any>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface BoundsSnapGeometry {
|
||||
points?: VecModel[];
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export interface BoundsSnapPoint {
|
||||
// (undocumented)
|
||||
|
@ -198,6 +203,8 @@ export class Box {
|
|||
// (undocumented)
|
||||
get corners(): Vec[];
|
||||
// (undocumented)
|
||||
get cornersAndCenter(): Vec[];
|
||||
// (undocumented)
|
||||
static Equals(a: Box | BoxModel, b: Box | BoxModel): boolean;
|
||||
// (undocumented)
|
||||
equals(other: Box | BoxModel): boolean;
|
||||
|
@ -264,8 +271,6 @@ export class Box {
|
|||
// (undocumented)
|
||||
get size(): Vec;
|
||||
// (undocumented)
|
||||
get snapPoints(): Vec[];
|
||||
// (undocumented)
|
||||
snapToGrid(size: number): void;
|
||||
// (undocumented)
|
||||
toFixed(): this;
|
||||
|
@ -982,12 +987,8 @@ export abstract class Geometry2d {
|
|||
// (undocumented)
|
||||
get area(): number;
|
||||
// (undocumented)
|
||||
_area: number | undefined;
|
||||
// (undocumented)
|
||||
get bounds(): Box;
|
||||
// (undocumented)
|
||||
_bounds: Box | undefined;
|
||||
// (undocumented)
|
||||
get center(): Vec;
|
||||
// (undocumented)
|
||||
debugColor?: string;
|
||||
|
@ -1020,15 +1021,9 @@ export abstract class Geometry2d {
|
|||
// (undocumented)
|
||||
nearestPointOnLineSegment(A: Vec, B: Vec): Vec;
|
||||
// (undocumented)
|
||||
get snapPoints(): Vec[];
|
||||
// (undocumented)
|
||||
_snapPoints: undefined | Vec[];
|
||||
// (undocumented)
|
||||
toSimpleSvgPath(): string;
|
||||
// (undocumented)
|
||||
get vertices(): Vec[];
|
||||
// (undocumented)
|
||||
_vertices: undefined | Vec[];
|
||||
}
|
||||
|
||||
// @public
|
||||
|
@ -1143,6 +1138,11 @@ export class GroupShapeUtil extends ShapeUtil<TLGroupShape> {
|
|||
// @public (undocumented)
|
||||
export const HALF_PI: number;
|
||||
|
||||
// @public
|
||||
export interface HandleSnapGeometry {
|
||||
outline?: Geometry2d | null;
|
||||
}
|
||||
|
||||
// @public
|
||||
export function hardReset({ shouldReload }?: {
|
||||
shouldReload?: boolean | undefined;
|
||||
|
@ -1600,10 +1600,12 @@ export abstract class ShapeUtil<Shape extends TLUnknownShape = TLUnknownShape> {
|
|||
editor: Editor;
|
||||
// @internal (undocumented)
|
||||
expandSelectionOutlinePx(shape: Shape): number;
|
||||
getBoundsSnapGeometry(shape: Shape): BoundsSnapGeometry;
|
||||
getCanvasSvgDefs(): TLShapeUtilCanvasSvgDef[];
|
||||
abstract getDefaultProps(): Shape['props'];
|
||||
abstract getGeometry(shape: Shape): Geometry2d;
|
||||
getHandles?(shape: Shape): TLHandle[];
|
||||
getHandleSnapGeometry(shape: Shape): HandleSnapGeometry;
|
||||
getOutlineSegments(shape: Shape): Vec[][];
|
||||
hideResizeHandles: TLShapeUtilFlag<Shape>;
|
||||
hideRotateHandle: TLShapeUtilFlag<Shape>;
|
||||
|
|
|
@ -1317,6 +1317,56 @@
|
|||
},
|
||||
"implementsTokenRanges": []
|
||||
},
|
||||
{
|
||||
"kind": "Interface",
|
||||
"canonicalReference": "@tldraw/editor!BoundsSnapGeometry:interface",
|
||||
"docComment": "/**\n * When moving or resizing shapes, the bounds of the shape can snap to key geometry on other nearby shapes. Customize how a shape snaps to others with {@link ShapeUtil.getBoundsSnapGeometry}.\n *\n * @public\n */\n",
|
||||
"excerptTokens": [
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "export interface BoundsSnapGeometry "
|
||||
}
|
||||
],
|
||||
"fileUrlPath": "packages/editor/src/lib/editor/managers/SnapManager/BoundsSnaps.ts",
|
||||
"releaseTag": "Public",
|
||||
"name": "BoundsSnapGeometry",
|
||||
"preserveMemberOrder": false,
|
||||
"members": [
|
||||
{
|
||||
"kind": "PropertySignature",
|
||||
"canonicalReference": "@tldraw/editor!BoundsSnapGeometry#points:member",
|
||||
"docComment": "/**\n * Points that this shape will snap to. By default, this will be the corners and center of the shapes bounding box. To disable snapping to a specific point, use an empty array.\n */\n",
|
||||
"excerptTokens": [
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "points?: "
|
||||
},
|
||||
{
|
||||
"kind": "Reference",
|
||||
"text": "VecModel",
|
||||
"canonicalReference": "@tldraw/tlschema!VecModel:interface"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "[]"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": ";"
|
||||
}
|
||||
],
|
||||
"isReadonly": false,
|
||||
"isOptional": true,
|
||||
"releaseTag": "Public",
|
||||
"name": "points",
|
||||
"propertyTypeTokenRange": {
|
||||
"startIndex": 1,
|
||||
"endIndex": 3
|
||||
}
|
||||
}
|
||||
],
|
||||
"extendsTokenRanges": []
|
||||
},
|
||||
{
|
||||
"kind": "Interface",
|
||||
"canonicalReference": "@tldraw/editor!BoundsSnapPoint:interface",
|
||||
|
@ -2028,6 +2078,41 @@
|
|||
"isProtected": false,
|
||||
"isAbstract": false
|
||||
},
|
||||
{
|
||||
"kind": "Property",
|
||||
"canonicalReference": "@tldraw/editor!Box#cornersAndCenter:member",
|
||||
"docComment": "",
|
||||
"excerptTokens": [
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "get cornersAndCenter(): "
|
||||
},
|
||||
{
|
||||
"kind": "Reference",
|
||||
"text": "Vec",
|
||||
"canonicalReference": "@tldraw/editor!Vec:class"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "[]"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": ";"
|
||||
}
|
||||
],
|
||||
"isReadonly": true,
|
||||
"isOptional": false,
|
||||
"releaseTag": "Public",
|
||||
"name": "cornersAndCenter",
|
||||
"propertyTypeTokenRange": {
|
||||
"startIndex": 1,
|
||||
"endIndex": 3
|
||||
},
|
||||
"isStatic": false,
|
||||
"isProtected": false,
|
||||
"isAbstract": false
|
||||
},
|
||||
{
|
||||
"kind": "Method",
|
||||
"canonicalReference": "@tldraw/editor!Box#equals:member(1)",
|
||||
|
@ -3562,41 +3647,6 @@
|
|||
"isProtected": false,
|
||||
"isAbstract": false
|
||||
},
|
||||
{
|
||||
"kind": "Property",
|
||||
"canonicalReference": "@tldraw/editor!Box#snapPoints:member",
|
||||
"docComment": "",
|
||||
"excerptTokens": [
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "get snapPoints(): "
|
||||
},
|
||||
{
|
||||
"kind": "Reference",
|
||||
"text": "Vec",
|
||||
"canonicalReference": "@tldraw/editor!Vec:class"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "[]"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": ";"
|
||||
}
|
||||
],
|
||||
"isReadonly": true,
|
||||
"isOptional": false,
|
||||
"releaseTag": "Public",
|
||||
"name": "snapPoints",
|
||||
"propertyTypeTokenRange": {
|
||||
"startIndex": 1,
|
||||
"endIndex": 3
|
||||
},
|
||||
"isStatic": false,
|
||||
"isProtected": false,
|
||||
"isAbstract": false
|
||||
},
|
||||
{
|
||||
"kind": "Method",
|
||||
"canonicalReference": "@tldraw/editor!Box#snapToGrid:member(1)",
|
||||
|
@ -20202,149 +20252,6 @@
|
|||
"name": "Geometry2d",
|
||||
"preserveMemberOrder": false,
|
||||
"members": [
|
||||
{
|
||||
"kind": "Property",
|
||||
"canonicalReference": "@tldraw/editor!Geometry2d#_area:member",
|
||||
"docComment": "",
|
||||
"excerptTokens": [
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "_area: "
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "number | undefined"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": ";"
|
||||
}
|
||||
],
|
||||
"isReadonly": false,
|
||||
"isOptional": false,
|
||||
"releaseTag": "Public",
|
||||
"name": "_area",
|
||||
"propertyTypeTokenRange": {
|
||||
"startIndex": 1,
|
||||
"endIndex": 2
|
||||
},
|
||||
"isStatic": false,
|
||||
"isProtected": false,
|
||||
"isAbstract": false
|
||||
},
|
||||
{
|
||||
"kind": "Property",
|
||||
"canonicalReference": "@tldraw/editor!Geometry2d#_bounds:member",
|
||||
"docComment": "",
|
||||
"excerptTokens": [
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "_bounds: "
|
||||
},
|
||||
{
|
||||
"kind": "Reference",
|
||||
"text": "Box",
|
||||
"canonicalReference": "@tldraw/editor!Box:class"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": " | undefined"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": ";"
|
||||
}
|
||||
],
|
||||
"isReadonly": false,
|
||||
"isOptional": false,
|
||||
"releaseTag": "Public",
|
||||
"name": "_bounds",
|
||||
"propertyTypeTokenRange": {
|
||||
"startIndex": 1,
|
||||
"endIndex": 3
|
||||
},
|
||||
"isStatic": false,
|
||||
"isProtected": false,
|
||||
"isAbstract": false
|
||||
},
|
||||
{
|
||||
"kind": "Property",
|
||||
"canonicalReference": "@tldraw/editor!Geometry2d#_snapPoints:member",
|
||||
"docComment": "",
|
||||
"excerptTokens": [
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "_snapPoints: "
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "undefined | "
|
||||
},
|
||||
{
|
||||
"kind": "Reference",
|
||||
"text": "Vec",
|
||||
"canonicalReference": "@tldraw/editor!Vec:class"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "[]"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": ";"
|
||||
}
|
||||
],
|
||||
"isReadonly": false,
|
||||
"isOptional": false,
|
||||
"releaseTag": "Public",
|
||||
"name": "_snapPoints",
|
||||
"propertyTypeTokenRange": {
|
||||
"startIndex": 1,
|
||||
"endIndex": 4
|
||||
},
|
||||
"isStatic": false,
|
||||
"isProtected": false,
|
||||
"isAbstract": false
|
||||
},
|
||||
{
|
||||
"kind": "Property",
|
||||
"canonicalReference": "@tldraw/editor!Geometry2d#_vertices:member",
|
||||
"docComment": "",
|
||||
"excerptTokens": [
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "_vertices: "
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "undefined | "
|
||||
},
|
||||
{
|
||||
"kind": "Reference",
|
||||
"text": "Vec",
|
||||
"canonicalReference": "@tldraw/editor!Vec:class"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "[]"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": ";"
|
||||
}
|
||||
],
|
||||
"isReadonly": false,
|
||||
"isOptional": false,
|
||||
"releaseTag": "Public",
|
||||
"name": "_vertices",
|
||||
"propertyTypeTokenRange": {
|
||||
"startIndex": 1,
|
||||
"endIndex": 4
|
||||
},
|
||||
"isStatic": false,
|
||||
"isProtected": false,
|
||||
"isAbstract": false
|
||||
},
|
||||
{
|
||||
"kind": "Constructor",
|
||||
"canonicalReference": "@tldraw/editor!Geometry2d:constructor(1)",
|
||||
|
@ -21195,41 +21102,6 @@
|
|||
"isAbstract": false,
|
||||
"name": "nearestPointOnLineSegment"
|
||||
},
|
||||
{
|
||||
"kind": "Property",
|
||||
"canonicalReference": "@tldraw/editor!Geometry2d#snapPoints:member",
|
||||
"docComment": "",
|
||||
"excerptTokens": [
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "get snapPoints(): "
|
||||
},
|
||||
{
|
||||
"kind": "Reference",
|
||||
"text": "Vec",
|
||||
"canonicalReference": "@tldraw/editor!Vec:class"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "[]"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": ";"
|
||||
}
|
||||
],
|
||||
"isReadonly": true,
|
||||
"isOptional": false,
|
||||
"releaseTag": "Public",
|
||||
"name": "snapPoints",
|
||||
"propertyTypeTokenRange": {
|
||||
"startIndex": 1,
|
||||
"endIndex": 3
|
||||
},
|
||||
"isStatic": false,
|
||||
"isProtected": false,
|
||||
"isAbstract": false
|
||||
},
|
||||
{
|
||||
"kind": "Method",
|
||||
"canonicalReference": "@tldraw/editor!Geometry2d#toSimpleSvgPath:member(1)",
|
||||
|
@ -23084,6 +22956,56 @@
|
|||
"endIndex": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "Interface",
|
||||
"canonicalReference": "@tldraw/editor!HandleSnapGeometry:interface",
|
||||
"docComment": "/**\n * When dragging a handle, users can snap the handle to key geometry on other nearby shapes. Customize how handles snap to a shape by returning this from {@link ShapeUtil.getHandleSnapGeometry}.\n *\n * @public\n */\n",
|
||||
"excerptTokens": [
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "export interface HandleSnapGeometry "
|
||||
}
|
||||
],
|
||||
"fileUrlPath": "packages/editor/src/lib/editor/managers/SnapManager/HandleSnaps.ts",
|
||||
"releaseTag": "Public",
|
||||
"name": "HandleSnapGeometry",
|
||||
"preserveMemberOrder": false,
|
||||
"members": [
|
||||
{
|
||||
"kind": "PropertySignature",
|
||||
"canonicalReference": "@tldraw/editor!HandleSnapGeometry#outline:member",
|
||||
"docComment": "/**\n * A `Geometry2d` that describe the outline of the shape that the handle will snap to - fills are ignored. By default, this is the same geometry returned by {@link ShapeUtil.getGeometry}. Set this to `null` to disable handle snapping to this shape's outline.\n */\n",
|
||||
"excerptTokens": [
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "outline?: "
|
||||
},
|
||||
{
|
||||
"kind": "Reference",
|
||||
"text": "Geometry2d",
|
||||
"canonicalReference": "@tldraw/editor!Geometry2d:class"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": " | null"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": ";"
|
||||
}
|
||||
],
|
||||
"isReadonly": false,
|
||||
"isOptional": true,
|
||||
"releaseTag": "Public",
|
||||
"name": "outline",
|
||||
"propertyTypeTokenRange": {
|
||||
"startIndex": 1,
|
||||
"endIndex": 3
|
||||
}
|
||||
}
|
||||
],
|
||||
"extendsTokenRanges": []
|
||||
},
|
||||
{
|
||||
"kind": "Function",
|
||||
"canonicalReference": "@tldraw/editor!hardReset:function(1)",
|
||||
|
@ -30256,6 +30178,55 @@
|
|||
"isProtected": false,
|
||||
"isAbstract": false
|
||||
},
|
||||
{
|
||||
"kind": "Method",
|
||||
"canonicalReference": "@tldraw/editor!ShapeUtil#getBoundsSnapGeometry:member(1)",
|
||||
"docComment": "/**\n * Get the geometry to use when snapping to this this shape in translate/resize operations. See {@link BoundsSnapGeometry} for details.\n */\n",
|
||||
"excerptTokens": [
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "getBoundsSnapGeometry(shape: "
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "Shape"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "): "
|
||||
},
|
||||
{
|
||||
"kind": "Reference",
|
||||
"text": "BoundsSnapGeometry",
|
||||
"canonicalReference": "@tldraw/editor!BoundsSnapGeometry:interface"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": ";"
|
||||
}
|
||||
],
|
||||
"isStatic": false,
|
||||
"returnTypeTokenRange": {
|
||||
"startIndex": 3,
|
||||
"endIndex": 4
|
||||
},
|
||||
"releaseTag": "Public",
|
||||
"isProtected": false,
|
||||
"overloadIndex": 1,
|
||||
"parameters": [
|
||||
{
|
||||
"parameterName": "shape",
|
||||
"parameterTypeTokenRange": {
|
||||
"startIndex": 1,
|
||||
"endIndex": 2
|
||||
},
|
||||
"isOptional": false
|
||||
}
|
||||
],
|
||||
"isOptional": false,
|
||||
"isAbstract": false,
|
||||
"name": "getBoundsSnapGeometry"
|
||||
},
|
||||
{
|
||||
"kind": "Method",
|
||||
"canonicalReference": "@tldraw/editor!ShapeUtil#getCanvasSvgDefs:member(1)",
|
||||
|
@ -30425,6 +30396,55 @@
|
|||
"isAbstract": false,
|
||||
"name": "getHandles"
|
||||
},
|
||||
{
|
||||
"kind": "Method",
|
||||
"canonicalReference": "@tldraw/editor!ShapeUtil#getHandleSnapGeometry:member(1)",
|
||||
"docComment": "/**\n * Get the geometry to use when snapping handles to this shape. See {@link HandleSnapGeometry} for details.\n */\n",
|
||||
"excerptTokens": [
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "getHandleSnapGeometry(shape: "
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "Shape"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": "): "
|
||||
},
|
||||
{
|
||||
"kind": "Reference",
|
||||
"text": "HandleSnapGeometry",
|
||||
"canonicalReference": "@tldraw/editor!HandleSnapGeometry:interface"
|
||||
},
|
||||
{
|
||||
"kind": "Content",
|
||||
"text": ";"
|
||||
}
|
||||
],
|
||||
"isStatic": false,
|
||||
"returnTypeTokenRange": {
|
||||
"startIndex": 3,
|
||||
"endIndex": 4
|
||||
},
|
||||
"releaseTag": "Public",
|
||||
"isProtected": false,
|
||||
"overloadIndex": 1,
|
||||
"parameters": [
|
||||
{
|
||||
"parameterName": "shape",
|
||||
"parameterTypeTokenRange": {
|
||||
"startIndex": 1,
|
||||
"endIndex": 2
|
||||
},
|
||||
"isOptional": false
|
||||
}
|
||||
],
|
||||
"isOptional": false,
|
||||
"isAbstract": false,
|
||||
"name": "getHandleSnapGeometry"
|
||||
},
|
||||
{
|
||||
"kind": "Method",
|
||||
"canonicalReference": "@tldraw/editor!ShapeUtil#getOutlineSegments:member(1)",
|
||||
|
|
|
@ -154,7 +154,11 @@ export type {
|
|||
TLBeforeCreateHandler,
|
||||
TLBeforeDeleteHandler,
|
||||
} from './lib/editor/managers/SideEffectManager'
|
||||
export { type BoundsSnapPoint } from './lib/editor/managers/SnapManager/BoundsSnaps'
|
||||
export {
|
||||
type BoundsSnapGeometry,
|
||||
type BoundsSnapPoint,
|
||||
} from './lib/editor/managers/SnapManager/BoundsSnaps'
|
||||
export { type HandleSnapGeometry } from './lib/editor/managers/SnapManager/HandleSnaps'
|
||||
export {
|
||||
SnapManager,
|
||||
type GapsSnapIndicator,
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { computed } from '@tldraw/state'
|
||||
import { TLShape, TLShapeId } from '@tldraw/tlschema'
|
||||
import { TLShape, TLShapeId, VecModel } from '@tldraw/tlschema'
|
||||
import { assertExists, dedupe } from '@tldraw/utils'
|
||||
import {
|
||||
Box,
|
||||
|
@ -22,6 +22,20 @@ import {
|
|||
SnapManager,
|
||||
} from './SnapManager'
|
||||
|
||||
/**
|
||||
* When moving or resizing shapes, the bounds of the shape can snap to key geometry on other nearby
|
||||
* shapes. Customize how a shape snaps to others with {@link ShapeUtil.getBoundsSnapGeometry}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface BoundsSnapGeometry {
|
||||
/**
|
||||
* Points that this shape will snap to. By default, this will be the corners and center of the
|
||||
* shapes bounding box. To disable snapping to a specific point, use an empty array.
|
||||
*/
|
||||
points?: VecModel[]
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface BoundsSnapPoint {
|
||||
id: string
|
||||
|
@ -188,11 +202,15 @@ export class BoundsSnaps {
|
|||
@computed private getSnapPointsCache() {
|
||||
const { editor } = this
|
||||
return editor.store.createComputedCache<BoundsSnapPoint[], TLShape>('snapPoints', (shape) => {
|
||||
const pageTransfrorm = editor.getShapePageTransform(shape.id)
|
||||
if (!pageTransfrorm) return undefined
|
||||
const snapPoints = this.editor.getShapeGeometry(shape).snapPoints
|
||||
const pageTransform = editor.getShapePageTransform(shape.id)
|
||||
if (!pageTransform) return undefined
|
||||
const boundsSnapGeometry = editor.getShapeUtil(shape).getBoundsSnapGeometry(shape)
|
||||
const snapPoints =
|
||||
boundsSnapGeometry.points ?? editor.getShapeGeometry(shape).bounds.cornersAndCenter
|
||||
|
||||
if (!pageTransform || !snapPoints) return undefined
|
||||
return snapPoints.map((point, i) => {
|
||||
const { x, y } = Mat.applyToPoint(pageTransfrorm, point)
|
||||
const { x, y } = Mat.applyToPoint(pageTransform, point)
|
||||
return { x, y, id: `${shape.id}:${i}` }
|
||||
})
|
||||
})
|
||||
|
@ -208,12 +226,12 @@ export class BoundsSnaps {
|
|||
const snappableShapes = this.manager.getSnappableShapes()
|
||||
const result: BoundsSnapPoint[] = []
|
||||
|
||||
snappableShapes.forEach((shapeId) => {
|
||||
for (const shapeId of snappableShapes) {
|
||||
const snapPoints = snapPointsCache.get(shapeId)
|
||||
if (snapPoints) {
|
||||
result.push(...snapPoints)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
|
|
@ -1,26 +1,45 @@
|
|||
import { computed } from '@tldraw/state'
|
||||
import { VecModel } from '@tldraw/tlschema'
|
||||
import { deepCopy } from '@tldraw/utils'
|
||||
import { Mat } from '../../../primitives/Mat'
|
||||
import { TLShape } from '@tldraw/tlschema'
|
||||
import { assertExists } from '@tldraw/utils'
|
||||
import { Vec } from '../../../primitives/Vec'
|
||||
import { Geometry2d } from '../../../primitives/geometry/Geometry2d'
|
||||
import { uniqueId } from '../../../utils/uniqueId'
|
||||
import { Editor } from '../../Editor'
|
||||
import { SnapData, SnapManager } from './SnapManager'
|
||||
|
||||
/**
|
||||
* When dragging a handle, users can snap the handle to key geometry on other nearby shapes.
|
||||
* Customize how handles snap to a shape by returning this from
|
||||
* {@link ShapeUtil.getHandleSnapGeometry}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface HandleSnapGeometry {
|
||||
/**
|
||||
* A `Geometry2d` that describe the outline of the shape that the handle will snap to - fills
|
||||
* are ignored. By default, this is the same geometry returned by {@link ShapeUtil.getGeometry}.
|
||||
* Set this to `null` to disable handle snapping to this shape's outline.
|
||||
*/
|
||||
outline?: Geometry2d | null
|
||||
}
|
||||
|
||||
export class HandleSnaps {
|
||||
readonly editor: Editor
|
||||
constructor(readonly manager: SnapManager) {
|
||||
this.editor = manager.editor
|
||||
}
|
||||
|
||||
@computed private getOutlinesInPageSpace() {
|
||||
return Array.from(this.manager.getSnappableShapes(), (id) => {
|
||||
const geometry = this.editor.getShapeGeometry(id)
|
||||
const outline = deepCopy(geometry.vertices)
|
||||
if (geometry.isClosed) outline.push(outline[0])
|
||||
const pageTransform = this.editor.getShapePageTransform(id)
|
||||
if (!pageTransform) throw Error('No page transform')
|
||||
return Mat.applyToPoints(pageTransform, outline)
|
||||
@computed private getSnapGeometryCache() {
|
||||
const { editor } = this
|
||||
return editor.store.createComputedCache('handle snap geometry', (shape: TLShape) => {
|
||||
const snapGeometry = editor.getShapeUtil(shape).getHandleSnapGeometry(shape)
|
||||
|
||||
return {
|
||||
outline:
|
||||
snapGeometry.outline === undefined
|
||||
? editor.getShapeGeometry(shape)
|
||||
: snapGeometry.outline,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -32,27 +51,36 @@ export class HandleSnaps {
|
|||
additionalSegments: Vec[][]
|
||||
}): SnapData | null {
|
||||
const snapThreshold = this.manager.getSnapThreshold()
|
||||
const outlinesInPageSpace = this.getOutlinesInPageSpace()
|
||||
|
||||
// Find the nearest point that is within the snap threshold
|
||||
let minDistance = snapThreshold
|
||||
let nearestPoint: Vec | null = null
|
||||
let C: VecModel, D: VecModel, nearest: Vec, distance: number
|
||||
const allSegments = [...outlinesInPageSpace, ...additionalSegments]
|
||||
for (const outline of allSegments) {
|
||||
for (let i = 0; i < outline.length - 1; i++) {
|
||||
C = outline[i]
|
||||
D = outline[i + 1]
|
||||
|
||||
nearest = Vec.NearestPointOnLineSegment(C, D, handlePoint)
|
||||
distance = Vec.Dist(handlePoint, nearest)
|
||||
for (const shapeId of this.manager.getSnappableShapes()) {
|
||||
const handleSnapOutline = this.getSnapGeometryCache().get(shapeId)?.outline
|
||||
if (!handleSnapOutline) continue
|
||||
|
||||
const shapePageTransform = assertExists(this.editor.getShapePageTransform(shapeId))
|
||||
const pointInShapeSpace = this.editor.getPointInShapeSpace(shapeId, handlePoint)
|
||||
const nearestShapePointInShapeSpace = handleSnapOutline.nearestPoint(pointInShapeSpace)
|
||||
const nearestInPageSpace = shapePageTransform.applyToPoint(nearestShapePointInShapeSpace)
|
||||
const distance = Vec.Dist(handlePoint, nearestInPageSpace)
|
||||
|
||||
if (isNaN(distance)) continue
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance
|
||||
nearestPoint = nearest
|
||||
nearestPoint = nearestInPageSpace
|
||||
}
|
||||
}
|
||||
|
||||
// handle additional segments:
|
||||
for (const segment of additionalSegments) {
|
||||
const nearestOnSegment = Vec.NearestPointOnLineSegment(segment[0], segment[1], handlePoint)
|
||||
const distance = Vec.Dist(handlePoint, nearestOnSegment)
|
||||
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance
|
||||
nearestPoint = nearestOnSegment
|
||||
}
|
||||
}
|
||||
|
||||
// If we found a point, display snap lines, and return the nudge
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { atom, computed, EMPTY_ARRAY } from '@tldraw/state'
|
||||
import { isShapeId, TLFrameShape, TLGroupShape, TLParentId, TLShapeId } from '@tldraw/tlschema'
|
||||
import { EMPTY_ARRAY, atom, computed } from '@tldraw/state'
|
||||
import { TLFrameShape, TLGroupShape, TLParentId, TLShapeId, isShapeId } from '@tldraw/tlschema'
|
||||
import { Vec, VecLike } from '../../../primitives/Vec'
|
||||
import type { Editor } from '../../Editor'
|
||||
import { BoundsSnaps } from './BoundsSnaps'
|
||||
|
|
|
@ -5,6 +5,8 @@ import { Box } from '../../primitives/Box'
|
|||
import { Vec } from '../../primitives/Vec'
|
||||
import { Geometry2d } from '../../primitives/geometry/Geometry2d'
|
||||
import type { Editor } from '../Editor'
|
||||
import { BoundsSnapGeometry } from '../managers/SnapManager/BoundsSnaps'
|
||||
import { HandleSnapGeometry } from '../managers/SnapManager/HandleSnaps'
|
||||
import { SvgExportContext } from '../types/SvgExportContext'
|
||||
import { TLResizeHandle } from '../types/selection-types'
|
||||
|
||||
|
@ -276,6 +278,22 @@ export abstract class ShapeUtil<Shape extends TLUnknownShape = TLUnknownShape> {
|
|||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the geometry to use when snapping to this this shape in translate/resize operations. See
|
||||
* {@link BoundsSnapGeometry} for details.
|
||||
*/
|
||||
getBoundsSnapGeometry(shape: Shape): BoundsSnapGeometry {
|
||||
return {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the geometry to use when snapping handles to this shape. See {@link HandleSnapGeometry}
|
||||
* for details.
|
||||
*/
|
||||
getHandleSnapGeometry(shape: Shape): HandleSnapGeometry {
|
||||
return {}
|
||||
}
|
||||
|
||||
// Events
|
||||
|
||||
/**
|
||||
|
|
|
@ -134,7 +134,7 @@ export class Box {
|
|||
}
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
get snapPoints() {
|
||||
get cornersAndCenter() {
|
||||
return [
|
||||
new Vec(this.minX, this.minY),
|
||||
new Vec(this.maxX, this.minY),
|
||||
|
|
|
@ -79,7 +79,7 @@ export abstract class Geometry2d {
|
|||
)
|
||||
}
|
||||
|
||||
_vertices: Vec[] | undefined
|
||||
private _vertices: Vec[] | undefined
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
get vertices(): Vec[] {
|
||||
|
@ -94,7 +94,7 @@ export abstract class Geometry2d {
|
|||
return Box.FromPoints(this.vertices)
|
||||
}
|
||||
|
||||
_bounds: Box | undefined
|
||||
private _bounds: Box | undefined
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
get bounds(): Box {
|
||||
|
@ -104,22 +104,12 @@ export abstract class Geometry2d {
|
|||
return this._bounds
|
||||
}
|
||||
|
||||
_snapPoints: Vec[] | undefined
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
get snapPoints() {
|
||||
if (!this._snapPoints) {
|
||||
this._snapPoints = this.bounds.snapPoints
|
||||
}
|
||||
return this._snapPoints
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
get center() {
|
||||
return this.bounds.center
|
||||
}
|
||||
|
||||
_area: number | undefined
|
||||
private _area: number | undefined
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
get area() {
|
||||
|
|
|
@ -40,10 +40,13 @@ beforeEach(() => {
|
|||
])
|
||||
})
|
||||
|
||||
const getShape = () => editor.getShape<TLLineShape>(id)!
|
||||
const getHandles = () => (editor.getShapeUtil('line') as LineShapeUtil).getHandles(getShape())
|
||||
|
||||
describe('Translating', () => {
|
||||
it('updates the line', () => {
|
||||
editor.select(id)
|
||||
editor.pointerDown(25, 25, { target: 'shape', shape: editor.getShape<TLLineShape>(id) })
|
||||
editor.pointerDown(25, 25, { target: 'shape', shape: getShape() })
|
||||
editor.pointerMove(50, 50) // Move shape by 25, 25
|
||||
editor.expectShapeToMatch({
|
||||
id: id,
|
||||
|
@ -55,7 +58,7 @@ describe('Translating', () => {
|
|||
it('updates the line when rotated', () => {
|
||||
editor.select(id)
|
||||
|
||||
const shape = editor.getShape<TLLineShape>(id)!
|
||||
const shape = getShape()
|
||||
editor.updateShape({ ...shape, rotation: Math.PI / 2 })
|
||||
|
||||
editor.pointerDown(250, 250, { target: 'shape', shape: shape })
|
||||
|
@ -73,10 +76,9 @@ describe('Mid-point handles', () => {
|
|||
it('create new handle', () => {
|
||||
editor.select(id)
|
||||
|
||||
const shape = editor.getShape<TLLineShape>(id)!
|
||||
editor.pointerDown(200, 200, {
|
||||
target: 'handle',
|
||||
shape,
|
||||
shape: getShape(),
|
||||
handle: {
|
||||
id: 'mid-0',
|
||||
type: 'create',
|
||||
|
@ -92,7 +94,6 @@ describe('Mid-point handles', () => {
|
|||
id: id,
|
||||
props: {
|
||||
handles: {
|
||||
...shape.props.handles,
|
||||
a1V: { x: 200, y: 200 },
|
||||
},
|
||||
},
|
||||
|
@ -104,13 +105,11 @@ describe('Mid-point handles', () => {
|
|||
|
||||
editor.select(id)
|
||||
|
||||
const shape = editor.getShape<TLLineShape>(id)!
|
||||
const util = editor.getShapeUtil('line') as LineShapeUtil
|
||||
editor
|
||||
.pointerDown(200, 200, {
|
||||
target: 'handle',
|
||||
shape,
|
||||
handle: util.getHandles(shape).find((h) => h.id === 'mid-0')!,
|
||||
shape: getShape(),
|
||||
handle: getHandles().find((h) => h.id === 'mid-0')!,
|
||||
})
|
||||
.pointerMove(198, 230, undefined, { ctrlKey: true })
|
||||
|
||||
|
@ -119,7 +118,6 @@ describe('Mid-point handles', () => {
|
|||
id: id,
|
||||
props: {
|
||||
handles: {
|
||||
...shape.props.handles,
|
||||
a1V: { x: 50, y: 80 },
|
||||
},
|
||||
},
|
||||
|
@ -130,29 +128,26 @@ describe('Mid-point handles', () => {
|
|||
editor.createShapesFromJsx([<TL.geo x={200} y={200} w={100} h={100} />])
|
||||
editor.select(id)
|
||||
|
||||
const getShape = () => editor.getShape<TLLineShape>(id)!
|
||||
const util = editor.getShapeUtil('line') as LineShapeUtil
|
||||
|
||||
// use a mid-point handle to create a new handle
|
||||
editor
|
||||
.pointerDown(200, 200, {
|
||||
target: 'handle',
|
||||
shape: getShape(),
|
||||
handle: util.getHandles(getShape()).find((h) => h.id === 'mid-0')!,
|
||||
handle: getHandles().find((h) => h.id === 'mid-0')!,
|
||||
})
|
||||
.pointerMove(230, 200)
|
||||
.pointerMove(200, 200)
|
||||
.pointerUp()
|
||||
|
||||
// 3 actual points, plus 2 mid-points:
|
||||
expect(util.getHandles(getShape())).toHaveLength(5)
|
||||
expect(getHandles()).toHaveLength(5)
|
||||
|
||||
// now, try dragging the newly created handle. it should still snap:
|
||||
editor
|
||||
.pointerDown(200, 200, {
|
||||
target: 'handle',
|
||||
shape: getShape(),
|
||||
handle: util.getHandles(getShape()).find((h) => h.id === 'a1V')!,
|
||||
handle: getHandles().find((h) => h.id === 'a1V')!,
|
||||
})
|
||||
.pointerMove(198, 230, undefined, { ctrlKey: true })
|
||||
|
||||
|
@ -161,7 +156,6 @@ describe('Mid-point handles', () => {
|
|||
id: id,
|
||||
props: {
|
||||
handles: {
|
||||
...getShape().props.handles,
|
||||
a1V: { x: 50, y: 80 },
|
||||
},
|
||||
},
|
||||
|
@ -169,10 +163,63 @@ describe('Mid-point handles', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('Snapping', () => {
|
||||
beforeEach(() => {
|
||||
editor.updateShape({
|
||||
id: id,
|
||||
type: 'line',
|
||||
props: {
|
||||
handles: {
|
||||
a1: { x: 0, y: 0 },
|
||||
a2: { x: 100, y: 0 },
|
||||
a3: { x: 100, y: 100 },
|
||||
a4: { x: 0, y: 100 },
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('snaps endpoints to itself', () => {
|
||||
editor.select(id)
|
||||
|
||||
editor
|
||||
.pointerDown(0, 0, { target: 'handle', shape: getShape(), handle: getHandles()[0] })
|
||||
.pointerMove(50, 95, undefined, { ctrlKey: true })
|
||||
|
||||
expect(editor.snaps.getIndicators()).toHaveLength(1)
|
||||
editor.expectShapeToMatch({
|
||||
id: id,
|
||||
props: {
|
||||
handles: {
|
||||
a1: { x: 50, y: 100 },
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("doesn't snap to the segment of the current handle", () => {
|
||||
editor.select(id)
|
||||
|
||||
editor
|
||||
.pointerDown(0, 0, { target: 'handle', shape: getShape(), handle: getHandles()[0] })
|
||||
.pointerMove(5, 2, undefined, { ctrlKey: true })
|
||||
|
||||
expect(editor.snaps.getIndicators()).toHaveLength(0)
|
||||
editor.expectShapeToMatch({
|
||||
id: id,
|
||||
props: {
|
||||
handles: {
|
||||
a1: { x: 5, y: 2 },
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Misc', () => {
|
||||
it('preserves handle positions on spline type change', () => {
|
||||
editor.select(id)
|
||||
const shape = editor.getShape<TLLineShape>(id)!
|
||||
const shape = getShape()
|
||||
const prevHandles = deepCopy(shape.props.handles)
|
||||
|
||||
editor.updateShapes([
|
||||
|
@ -195,7 +242,6 @@ describe('Misc', () => {
|
|||
|
||||
it('resizes', () => {
|
||||
editor.select(id)
|
||||
editor.getShape<TLLineShape>(id)!
|
||||
|
||||
editor
|
||||
.pointerDown(150, 0, { target: 'selection', handle: 'bottom' })
|
||||
|
@ -229,7 +275,7 @@ describe('Misc', () => {
|
|||
editor.createShapes([{ id: boxID, type: 'geo', x: 500, y: 150, props: { w: 100, h: 50 } }])
|
||||
|
||||
const box = editor.getShape<TLGeoShape>(boxID)!
|
||||
const line = editor.getShape<TLLineShape>(id)!
|
||||
const line = getShape()
|
||||
|
||||
editor.select(boxID, id)
|
||||
|
||||
|
@ -247,9 +293,7 @@ describe('Misc', () => {
|
|||
it('duplicates', () => {
|
||||
editor.select(id)
|
||||
|
||||
editor
|
||||
.keyDown('Alt')
|
||||
.pointerDown(25, 25, { target: 'shape', shape: editor.getShape<TLLineShape>(id) })
|
||||
editor.keyDown('Alt').pointerDown(25, 25, { target: 'shape', shape: getShape() })
|
||||
editor.pointerMove(50, 50) // Move shape by 25, 25
|
||||
editor.pointerUp().keyUp('Alt')
|
||||
|
||||
|
@ -259,9 +303,7 @@ describe('Misc', () => {
|
|||
it('deletes', () => {
|
||||
editor.select(id)
|
||||
|
||||
editor
|
||||
.keyDown('Alt')
|
||||
.pointerDown(25, 25, { target: 'shape', shape: editor.getShape<TLLineShape>(id) })
|
||||
editor.keyDown('Alt').pointerDown(25, 25, { target: 'shape', shape: getShape() })
|
||||
editor.pointerMove(50, 50) // Move shape by 25, 25
|
||||
editor.pointerUp().keyUp('Alt')
|
||||
|
||||
|
|
|
@ -329,7 +329,7 @@ function getTranslatingSnapshot(editor: Editor) {
|
|||
} else {
|
||||
const selectionPageBounds = editor.getSelectionPageBounds()
|
||||
if (selectionPageBounds) {
|
||||
initialSnapPoints = selectionPageBounds.snapPoints.map((p, i) => ({
|
||||
initialSnapPoints = selectionPageBounds.cornersAndCenter.map((p, i) => ({
|
||||
id: 'selection:' + i,
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
|
|
299
packages/tldraw/src/test/customSnapping.test.tsx
Normal file
299
packages/tldraw/src/test/customSnapping.test.tsx
Normal file
|
@ -0,0 +1,299 @@
|
|||
import {
|
||||
BaseBoxShapeUtil,
|
||||
IndexKey,
|
||||
Polyline2d,
|
||||
TLAnyShapeUtilConstructor,
|
||||
TLBaseShape,
|
||||
TLLineShape,
|
||||
TLShapeId,
|
||||
Vec,
|
||||
VecModel,
|
||||
} from '@tldraw/editor'
|
||||
import { TestEditor } from './TestEditor'
|
||||
import { TL } from './test-jsx'
|
||||
|
||||
describe('custom shape bounds snapping - translate', () => {
|
||||
type TestShape = TLBaseShape<
|
||||
'test',
|
||||
{ w: number; h: number; boundsSnapPoints: VecModel[] | null }
|
||||
>
|
||||
class TestShapeUtil extends BaseBoxShapeUtil<TestShape> {
|
||||
static override type = 'test'
|
||||
override getDefaultProps() {
|
||||
return { w: 100, h: 100, boundsSnapPoints: null }
|
||||
}
|
||||
override component() {
|
||||
throw new Error('Method not implemented.')
|
||||
}
|
||||
override indicator() {
|
||||
throw new Error('Method not implemented.')
|
||||
}
|
||||
override getBoundsSnapGeometry(shape: TestShape) {
|
||||
return {
|
||||
points: shape.props.boundsSnapPoints ?? undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
const shapeUtils = [TestShapeUtil] as TLAnyShapeUtilConstructor[]
|
||||
|
||||
let editor: TestEditor
|
||||
let ids: Record<string, TLShapeId>
|
||||
beforeEach(() => {
|
||||
editor = new TestEditor({ shapeUtils })
|
||||
ids = editor.createShapesFromJsx([
|
||||
<TL.geo ref="box" x={0} y={0} w={100} h={100} />,
|
||||
<TL.test ref="test" x={200} y={200} w={100} h={100} boundsSnapPoints={null} />,
|
||||
])
|
||||
})
|
||||
|
||||
describe('with default boundSnapPoints', () => {
|
||||
test('normal snapping works with default boundSnapPoints when moving test shape', () => {
|
||||
// start translating the test shape
|
||||
editor.setSelectedShapes([ids.test]).pointerDown(250, 250)
|
||||
|
||||
// move the left edge of the test shape to the right edge of the box shape - it should snap
|
||||
editor.pointerMove(155, 250, undefined, { ctrlKey: true })
|
||||
expect(editor.snaps.getIndicators()).toHaveLength(1)
|
||||
expect(editor.getOnlySelectedShape()?.x).toBe(100)
|
||||
|
||||
// move the left edge of the test shape to the center of the box shape - it should snap
|
||||
editor.pointerMove(105, 250, undefined, { ctrlKey: true })
|
||||
expect(editor.snaps.getIndicators()).toHaveLength(2)
|
||||
expect(editor.getOnlySelectedShape()?.x).toBe(50)
|
||||
})
|
||||
|
||||
test('normal snapping works with default boundSnapPoints when snapping to test shape', () => {
|
||||
// start translating the box shape
|
||||
editor.setSelectedShapes([ids.box]).pointerDown(50, 50)
|
||||
|
||||
// move the left edge of the box shape to the right edge of the test shape - it should snap
|
||||
editor.pointerMove(155, 50, undefined, { ctrlKey: true })
|
||||
expect(editor.snaps.getIndicators()).toHaveLength(1)
|
||||
expect(editor.getOnlySelectedShape()?.x).toBe(100)
|
||||
|
||||
// move the left edge of the box shape to the center of the test shape - it should snap
|
||||
editor.pointerMove(205, 50, undefined, { ctrlKey: true })
|
||||
expect(editor.snaps.getIndicators()).toHaveLength(2)
|
||||
expect(editor.getOnlySelectedShape()?.x).toBe(150)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with only the center in boundSnapPoints', () => {
|
||||
beforeEach(() => {
|
||||
editor.updateShape<TestShape>({
|
||||
id: ids.test,
|
||||
type: 'test',
|
||||
props: { boundsSnapPoints: [{ x: 50, y: 50 }] },
|
||||
})
|
||||
})
|
||||
|
||||
describe('when moving the test shape', () => {
|
||||
beforeEach(() => editor.select(ids.test).pointerDown(250, 250))
|
||||
|
||||
test('does not snap its edges to the box edges', () => {
|
||||
editor.pointerMove(155, 250, undefined, { ctrlKey: true })
|
||||
expect(editor.snaps.getIndicators()).toHaveLength(0)
|
||||
expect(editor.getOnlySelectedShape()?.x).toBe(105)
|
||||
})
|
||||
|
||||
test('snaps its center to the box right edge', () => {
|
||||
editor.pointerMove(105, 250, undefined, { ctrlKey: true })
|
||||
expect(editor.snaps.getIndicators()).toHaveLength(1)
|
||||
expect(editor.getOnlySelectedShape()?.x).toBe(50)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when moving the box shape', () => {
|
||||
beforeEach(() => editor.select(ids.box).pointerDown(50, 50))
|
||||
|
||||
test('does not snap to the left edge of the test shape', () => {
|
||||
editor.pointerMove(155, 50, undefined, { ctrlKey: true })
|
||||
expect(editor.snaps.getIndicators()).toHaveLength(0)
|
||||
expect(editor.getOnlySelectedShape()?.x).toBe(105)
|
||||
})
|
||||
|
||||
test('snaps its right edge to the center of the test shape', () => {
|
||||
editor.pointerMove(205, 50, undefined, { ctrlKey: true })
|
||||
expect(editor.snaps.getIndicators()).toHaveLength(1)
|
||||
expect(editor.getOnlySelectedShape()?.x).toBe(150)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with empty boundSnapPoints', () => {
|
||||
beforeEach(() => {
|
||||
editor.updateShape<TestShape>({
|
||||
id: ids.test,
|
||||
type: 'test',
|
||||
props: { boundsSnapPoints: [] },
|
||||
})
|
||||
})
|
||||
|
||||
test('test shape does not snap to anything', () => {
|
||||
editor.select(ids.test).pointerDown(250, 250)
|
||||
|
||||
// try to snap our left edge to the right edge of the box shape - it should not snap
|
||||
editor.pointerMove(155, 250, undefined, { ctrlKey: true })
|
||||
expect(editor.snaps.getIndicators()).toHaveLength(0)
|
||||
expect(editor.getOnlySelectedShape()?.x).toBe(105)
|
||||
|
||||
// try to snap our left edge to the center of the box shape - it should not snap
|
||||
editor.pointerMove(105, 250, undefined, { ctrlKey: true })
|
||||
expect(editor.snaps.getIndicators()).toHaveLength(0)
|
||||
expect(editor.getOnlySelectedShape()?.x).toBe(55)
|
||||
})
|
||||
|
||||
test('box shape does not snap to test shape', () => {
|
||||
editor.select(ids.box).pointerDown(50, 50)
|
||||
|
||||
// try to snap our left edge to the right edge of the test shape - it should not snap
|
||||
editor.pointerMove(155, 50, undefined, { ctrlKey: true })
|
||||
expect(editor.snaps.getIndicators()).toHaveLength(0)
|
||||
expect(editor.getOnlySelectedShape()?.x).toBe(105)
|
||||
|
||||
// try to snap our right edge to the center of the test shape - it should not snap
|
||||
editor.pointerMove(205, 50, undefined, { ctrlKey: true })
|
||||
expect(editor.snaps.getIndicators()).toHaveLength(0)
|
||||
expect(editor.getOnlySelectedShape()?.x).toBe(155)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('custom handle snapping', () => {
|
||||
type TestShape = TLBaseShape<
|
||||
'test',
|
||||
{ w: number; h: number; handleGeomVertices: VecModel[] | 'default' | null }
|
||||
>
|
||||
class TestShapeUtil extends BaseBoxShapeUtil<TestShape> {
|
||||
static override type = 'test'
|
||||
override getDefaultProps(): TestShape['props'] {
|
||||
return { w: 100, h: 100, handleGeomVertices: 'default' }
|
||||
}
|
||||
override component() {
|
||||
throw new Error('Method not implemented.')
|
||||
}
|
||||
override indicator() {
|
||||
throw new Error('Method not implemented.')
|
||||
}
|
||||
override getHandleSnapGeometry(shape: TestShape) {
|
||||
const vertices = shape.props.handleGeomVertices
|
||||
return {
|
||||
outline:
|
||||
vertices === 'default'
|
||||
? undefined
|
||||
: vertices === null
|
||||
? null
|
||||
: new Polyline2d({ points: vertices.map(Vec.From) }),
|
||||
}
|
||||
}
|
||||
}
|
||||
const shapeUtils = [TestShapeUtil] as TLAnyShapeUtilConstructor[]
|
||||
|
||||
let editor: TestEditor
|
||||
let ids: Record<string, TLShapeId>
|
||||
beforeEach(() => {
|
||||
editor = new TestEditor({ shapeUtils })
|
||||
ids = editor.createShapesFromJsx([
|
||||
<TL.line
|
||||
ref="line"
|
||||
x={0}
|
||||
y={0}
|
||||
handles={{
|
||||
['a1' as IndexKey]: { x: 0, y: 0 },
|
||||
['a2' as IndexKey]: { x: 100, y: 100 },
|
||||
}}
|
||||
/>,
|
||||
<TL.test ref="test" x={200} y={200} w={100} h={100} boundsSnapPoints={null} />,
|
||||
])
|
||||
})
|
||||
|
||||
function startDraggingHandle() {
|
||||
const shape = editor.select(ids.line).getOnlySelectedShape()! as TLLineShape
|
||||
const handles = editor.getShapeHandles(shape)!
|
||||
editor.pointerDown(100, 100, { target: 'handle', shape, handle: handles[handles.length - 1] })
|
||||
}
|
||||
|
||||
function handlePosition() {
|
||||
const shape = editor.select(ids.line).getOnlySelectedShape()! as TLLineShape
|
||||
const handles = editor.getShapeHandles(shape)!
|
||||
const handle = handles[handles.length - 1]
|
||||
return { x: handle.x, y: handle.y }
|
||||
}
|
||||
|
||||
describe('with default handleGeomVertices', () => {
|
||||
test('snaps handles to the box of the shape', () => {
|
||||
startDraggingHandle()
|
||||
editor.pointerMove(215, 205, undefined, { ctrlKey: true })
|
||||
expect(editor.snaps.getIndicators()).toHaveLength(1)
|
||||
expect(handlePosition().x).toBe(215)
|
||||
expect(handlePosition().y).toBe(200)
|
||||
})
|
||||
|
||||
test("doesn't particularly snap to vertices", () => {
|
||||
startDraggingHandle()
|
||||
editor.pointerMove(204, 205, undefined, { ctrlKey: true })
|
||||
// only snapped to the nearest edge, not the vertex
|
||||
expect(editor.snaps.getIndicators()).toHaveLength(1)
|
||||
expect(handlePosition().x).toBe(200)
|
||||
expect(handlePosition().y).toBe(205)
|
||||
})
|
||||
|
||||
test("doesn't snap to the center", () => {
|
||||
startDraggingHandle()
|
||||
editor.pointerMove(251, 251, undefined, { ctrlKey: true })
|
||||
expect(editor.snaps.getIndicators()).toHaveLength(0)
|
||||
expect(handlePosition().x).toBe(251)
|
||||
expect(handlePosition().y).toBe(251)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with empty handleGeomVertices', () => {
|
||||
beforeEach(() => {
|
||||
editor.updateShape<TestShape>({
|
||||
id: ids.test,
|
||||
type: 'test',
|
||||
props: { handleGeomVertices: null },
|
||||
})
|
||||
})
|
||||
|
||||
test("doesn't snap to the shape at all", () => {
|
||||
startDraggingHandle()
|
||||
editor.pointerMove(215, 205, undefined, { ctrlKey: true })
|
||||
expect(editor.snaps.getIndicators()).toHaveLength(0)
|
||||
expect(handlePosition().x).toBe(215)
|
||||
expect(handlePosition().y).toBe(205)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with custom handleGeomVertices', () => {
|
||||
beforeEach(() => {
|
||||
editor.updateShape<TestShape>({
|
||||
id: ids.test,
|
||||
type: 'test',
|
||||
props: {
|
||||
// a diagonal line from the top left to the bottom right
|
||||
handleGeomVertices: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 100, y: 100 },
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('does not snap to the normal edges of the shape', () => {
|
||||
startDraggingHandle()
|
||||
editor.pointerMove(235, 205, undefined, { ctrlKey: true })
|
||||
expect(editor.snaps.getIndicators()).toHaveLength(0)
|
||||
expect(handlePosition().x).toBe(235)
|
||||
expect(handlePosition().y).toBe(205)
|
||||
})
|
||||
|
||||
test('snaps to the custom geometry', () => {
|
||||
startDraggingHandle()
|
||||
editor.pointerMove(210, 214, undefined, { ctrlKey: true })
|
||||
expect(editor.snaps.getIndicators()).toHaveLength(1)
|
||||
expect(handlePosition().x).toBe(212)
|
||||
expect(handlePosition().y).toBe(212)
|
||||
})
|
||||
})
|
||||
})
|
|
@ -46,7 +46,10 @@ export const TL = new Proxy(
|
|||
return createElement(key as string)
|
||||
},
|
||||
}
|
||||
) as { [K in TLDefaultShape['type']]: (props: PropsForShape<K>) => null }
|
||||
) as { [K in TLDefaultShape['type']]: (props: PropsForShape<K>) => null } & Record<
|
||||
string,
|
||||
(props: PropsForShape<string>) => null
|
||||
>
|
||||
|
||||
export function shapesFromJsx(shapes: React.JSX.Element | Array<React.JSX.Element>) {
|
||||
const ids = {} as Record<string, TLShapeId>
|
||||
|
|
|
@ -1,584 +0,0 @@
|
|||
import { Geometry2d, Rectangle2d, ShapeUtil, SnapIndicator, createShapeId } from '@tldraw/editor'
|
||||
import { TestEditor } from './TestEditor'
|
||||
|
||||
let editor: TestEditor
|
||||
|
||||
afterEach(() => {
|
||||
editor?.dispose()
|
||||
})
|
||||
|
||||
const ids = {
|
||||
frame1: createShapeId('frame1'),
|
||||
frame2: createShapeId('frame2'),
|
||||
box1: createShapeId('box1'),
|
||||
box2: createShapeId('box2'),
|
||||
line1: createShapeId('line1'),
|
||||
boxD: createShapeId('boxD'),
|
||||
boxE: createShapeId('boxE'),
|
||||
boxF: createShapeId('boxF'),
|
||||
boxG: createShapeId('boxG'),
|
||||
boxH: createShapeId('boxH'),
|
||||
boxX: createShapeId('boxX'),
|
||||
|
||||
boxT: createShapeId('boxT'),
|
||||
|
||||
lineA: createShapeId('lineA'),
|
||||
}
|
||||
|
||||
const getNumSnapPoints = (snap: SnapIndicator): number => {
|
||||
return snap.type === 'points' ? snap.points.length : (null as any as number)
|
||||
}
|
||||
|
||||
const getSnapPoints = (snap: SnapIndicator) => {
|
||||
return snap.type === 'points' ? snap.points : null
|
||||
}
|
||||
|
||||
type __TopLeftSnapOnlyShape = any
|
||||
|
||||
class __TopLeftSnapOnlyShapeUtil extends ShapeUtil<__TopLeftSnapOnlyShape> {
|
||||
static override type = '__test_top_left_snap_only' as const
|
||||
|
||||
getDefaultProps(): __TopLeftSnapOnlyShape['props'] {
|
||||
return { width: 10, height: 10 }
|
||||
}
|
||||
component() {
|
||||
throw new Error('Method not implemented.')
|
||||
}
|
||||
indicator() {
|
||||
throw new Error('Method not implemented.')
|
||||
}
|
||||
getGeometry(shape: __TopLeftSnapOnlyShape): Geometry2d {
|
||||
return new Rectangle2d({
|
||||
width: shape.props.width,
|
||||
height: shape.props.height,
|
||||
isFilled: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const __TopLeftSnapOnlyShape = __TopLeftSnapOnlyShapeUtil
|
||||
|
||||
describe.skip('custom snapping points', () => {
|
||||
const shapeUtils = Object.freeze([__TopLeftSnapOnlyShape])
|
||||
beforeEach(() => {
|
||||
editor?.dispose()
|
||||
editor = new TestEditor({
|
||||
shapeUtils,
|
||||
// x───────┐
|
||||
// │ T │
|
||||
// │ │
|
||||
// │ │
|
||||
// └───────┘
|
||||
//
|
||||
// x───────x
|
||||
// │ A │
|
||||
// │ x │
|
||||
// │ │
|
||||
// x───────x
|
||||
//
|
||||
// x───────x
|
||||
// │ B │
|
||||
// │ x │
|
||||
// │ │
|
||||
// x───────x
|
||||
})
|
||||
editor.createShapes([
|
||||
{
|
||||
type: '__test_top_left_snap_only',
|
||||
id: ids.boxT,
|
||||
x: 0,
|
||||
y: 0,
|
||||
props: { width: 100, height: 100 },
|
||||
},
|
||||
{
|
||||
type: 'geo',
|
||||
id: ids.box1,
|
||||
x: 200,
|
||||
y: 200,
|
||||
props: { w: 100, h: 100 },
|
||||
},
|
||||
{
|
||||
type: 'geo',
|
||||
id: ids.box2,
|
||||
x: 400,
|
||||
y: 400,
|
||||
props: { w: 100, h: 100 },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('allows other shapes to snap to custom snap points', () => {
|
||||
// should snap to 0 on y axis
|
||||
// x────────────x───────x
|
||||
// x───────┐ x───────x
|
||||
// │ T │ │ A │
|
||||
// │ │ │ x │
|
||||
// │ │ │ drag │
|
||||
// └───────┘ x───────x
|
||||
editor.pointerDown(250, 250, ids.box1).pointerMove(250, 51, { ctrlKey: true })
|
||||
expect(editor.getShape(ids.box1)).toMatchObject({ x: 200, y: 0 })
|
||||
expect(editor.snaps.getIndicators()?.length).toBe(1)
|
||||
expect(getNumSnapPoints(editor.snaps.getIndicators()![0])).toBe(3)
|
||||
|
||||
// should not snap to 100 on y axis
|
||||
// x───────┐
|
||||
// │ T │
|
||||
// │ │
|
||||
// │ │
|
||||
// └───────┘ x───────x
|
||||
// │ A │
|
||||
// │ x │
|
||||
// │ drag │
|
||||
// x───────x
|
||||
editor.pointerMove(250, 151, { ctrlKey: true })
|
||||
expect(editor.getShape(ids.box1)).toMatchObject({ x: 200, y: 101 })
|
||||
expect(editor.snaps.getIndicators()?.length).toBe(0)
|
||||
|
||||
// should not snap to 50 on y axis
|
||||
// x───────┐
|
||||
// │ T │
|
||||
// │ │ x───────x
|
||||
// │ │ │ A │
|
||||
// └───────┘ │ x │
|
||||
// │ drag │
|
||||
// x───────x
|
||||
editor.pointerMove(250, 101, { ctrlKey: true })
|
||||
expect(editor.getShape(ids.box1)).toMatchObject({ x: 200, y: 51 })
|
||||
expect(editor.snaps.getIndicators()?.length).toBe(0)
|
||||
|
||||
// should snap to 0 on x axis
|
||||
// x x───────┐
|
||||
// │ │ T │
|
||||
// │ │ │
|
||||
// │ │ │
|
||||
// │ └───────┘
|
||||
// │
|
||||
// x x───────x
|
||||
// │ │ A │
|
||||
// │ │ x │
|
||||
// │ │ drag │
|
||||
// x x───────x
|
||||
editor.pointerMove(51, 250, { ctrlKey: true })
|
||||
expect(editor.getShape(ids.box1)).toMatchObject({ x: 0, y: 200 })
|
||||
expect(editor.snaps.getIndicators()?.length).toBe(1)
|
||||
expect(getNumSnapPoints(editor.snaps.getIndicators()![0])).toBe(3)
|
||||
|
||||
// should not snap to 100 on x axis
|
||||
// x───────┐
|
||||
// │ T │
|
||||
// │ │
|
||||
// │ │
|
||||
// └───────┘
|
||||
//
|
||||
// x───────x
|
||||
// │ A │
|
||||
// │ x │
|
||||
// │ drag │
|
||||
// x───────x
|
||||
editor.pointerMove(151, 250, { ctrlKey: true })
|
||||
expect(editor.getShape(ids.box1)).toMatchObject({ x: 101, y: 200 })
|
||||
expect(editor.snaps.getIndicators()?.length).toBe(0)
|
||||
|
||||
// should not snap to 50 on x axis
|
||||
// x───────┐
|
||||
// │ T │
|
||||
// │ │
|
||||
// │ │
|
||||
// └───────┘
|
||||
//
|
||||
// x───────x
|
||||
// │ A │
|
||||
// │ x │
|
||||
// │ drag │
|
||||
// x───────x
|
||||
editor.pointerMove(101, 250, { ctrlKey: true })
|
||||
expect(editor.getShape(ids.box1)).toMatchObject({ x: 51, y: 200 })
|
||||
expect(editor.snaps.getIndicators()?.length).toBe(0)
|
||||
})
|
||||
|
||||
it('allows shapes with custom points to snap to other shapes', () => {
|
||||
// should snap to 200 on y axis
|
||||
// x────────────x───────x
|
||||
// x───────┐ x───────x
|
||||
// │ T │ │ A │
|
||||
// │ │ │ x │
|
||||
// │ drag │ │ │
|
||||
// └───────┘ x───────x
|
||||
editor.pointerDown(50, 50, ids.boxT).pointerMove(50, 251, { ctrlKey: true })
|
||||
expect(editor.getShape(ids.boxT)).toMatchObject({ x: 0, y: 200 })
|
||||
expect(editor.snaps.getIndicators()?.length).toBe(1)
|
||||
expect(getNumSnapPoints(editor.snaps.getIndicators()![0])).toBe(3)
|
||||
|
||||
// should snap to 250 on y axis
|
||||
// x─────────────────x
|
||||
// x───────x
|
||||
// │ A │
|
||||
// x───────┐ │ x │
|
||||
// │ T │ │ │
|
||||
// │ │ x───────x
|
||||
// │ drag │
|
||||
// └───────┘
|
||||
editor.pointerMove(50, 301, { ctrlKey: true })
|
||||
expect(editor.getShape(ids.boxT)).toMatchObject({ x: 0, y: 250 })
|
||||
expect(editor.snaps.getIndicators()?.length).toBe(1)
|
||||
expect(getNumSnapPoints(editor.snaps.getIndicators()![0])).toBe(2)
|
||||
|
||||
// should snap to 300 on y axis
|
||||
// x─────────────x───────x
|
||||
// x───────x
|
||||
// │ A │
|
||||
// │ x │
|
||||
// │ │
|
||||
// x───────┐ x───────x
|
||||
// │ T │
|
||||
// │ │
|
||||
// │ drag │
|
||||
// └───────┘
|
||||
editor.pointerMove(50, 351, { ctrlKey: true })
|
||||
expect(editor.getShape(ids.boxT)).toMatchObject({ x: 0, y: 300 })
|
||||
expect(editor.snaps.getIndicators()?.length).toBe(1)
|
||||
expect(getNumSnapPoints(editor.snaps.getIndicators()![0])).toBe(3)
|
||||
|
||||
// should snap to 200 on x axis
|
||||
// x x───────┐
|
||||
// │ │ T │
|
||||
// │ │ │
|
||||
// │ │ drag │
|
||||
// │ └───────┘
|
||||
// │
|
||||
// x x───────x
|
||||
// │ │ A │
|
||||
// │ │ x │
|
||||
// │ │ │
|
||||
// x x───────x
|
||||
editor.pointerMove(251, 50, { ctrlKey: true })
|
||||
expect(editor.getShape(ids.boxT)).toMatchObject({ x: 200, y: 0 })
|
||||
expect(editor.snaps.getIndicators()?.length).toBe(1)
|
||||
expect(getNumSnapPoints(editor.snaps.getIndicators()![0])).toBe(3)
|
||||
|
||||
// should snap to 250 on x axis
|
||||
// x x───────┐
|
||||
// │ │ T │
|
||||
// │ │ │
|
||||
// │ │ drag │
|
||||
// │ └───────┘
|
||||
// │
|
||||
// │ x───────x
|
||||
// │ │ A │
|
||||
// x │ x │
|
||||
// │ │
|
||||
// x───────x
|
||||
editor.pointerMove(301, 50, { ctrlKey: true })
|
||||
expect(editor.getShape(ids.boxT)).toMatchObject({ x: 250, y: 0 })
|
||||
expect(editor.snaps.getIndicators()?.length).toBe(1)
|
||||
expect(getNumSnapPoints(editor.snaps.getIndicators()![0])).toBe(2)
|
||||
|
||||
// should snap to 300 on x axis
|
||||
// x x───────┐
|
||||
// │ │ T │
|
||||
// │ │ │
|
||||
// │ │ drag │
|
||||
// │ └───────┘
|
||||
// │
|
||||
// x x───────x
|
||||
// │ │ A │
|
||||
// │ │ x │
|
||||
// │ │ │
|
||||
// x x───────x
|
||||
editor.pointerMove(351, 50, { ctrlKey: true })
|
||||
expect(editor.getShape(ids.boxT)).toMatchObject({ x: 300, y: 0 })
|
||||
expect(editor.snaps.getIndicators()?.length).toBe(1)
|
||||
expect(getNumSnapPoints(editor.snaps.getIndicators()![0])).toBe(3)
|
||||
})
|
||||
|
||||
it('becomes part of the selection bounding box if there is more than one shape in the selection', () => {
|
||||
// ┌────────────────────────┐
|
||||
// │ │
|
||||
// │ x───────┐ │
|
||||
// │ │ T │ │
|
||||
// │ │ │ │
|
||||
// │ │ │ │
|
||||
// │ └───────┘ x │
|
||||
// │ │ x───────x │
|
||||
// │ │ │ A │ │
|
||||
// │ │ │ x │ │
|
||||
// │ │ │ │ │
|
||||
// │ │ x───────x │
|
||||
// │ │ │
|
||||
// └───────────┼────────────┘
|
||||
// │
|
||||
// │ 450
|
||||
// x───┼───x
|
||||
// │ B │ │
|
||||
// │ x │
|
||||
// │ │
|
||||
// x───────x
|
||||
editor.select(ids.boxT, ids.box1)
|
||||
editor.pointerDown(50, 50, ids.boxT).pointerMove(351, 50, { ctrlKey: true })
|
||||
expect(editor.snaps.getIndicators()?.length).toBe(1)
|
||||
expect(getNumSnapPoints(editor.snaps.getIndicators()![0])).toBe(2)
|
||||
expect(getSnapPoints(editor.snaps.getIndicators()![0])?.map(({ x }) => x)).toEqual([450, 450])
|
||||
})
|
||||
})
|
||||
|
||||
describe.skip('custom snapping points', () => {
|
||||
beforeEach(() => {
|
||||
editor?.dispose()
|
||||
editor = new TestEditor({
|
||||
shapeUtils: [__TopLeftSnapOnlyShape],
|
||||
// x───────┐
|
||||
// │ T │
|
||||
// │ │
|
||||
// │ │
|
||||
// └───────┘
|
||||
//
|
||||
// x───────x
|
||||
// │ A │
|
||||
// │ x │
|
||||
// │ │
|
||||
// x───────x
|
||||
//
|
||||
// x───────x
|
||||
// │ B │
|
||||
// │ x │
|
||||
// │ │
|
||||
// x───────x
|
||||
})
|
||||
editor.createShapes([
|
||||
{
|
||||
type: '__test_top_left_snap_only',
|
||||
id: ids.boxT,
|
||||
x: 0,
|
||||
y: 0,
|
||||
props: { width: 100, height: 100 },
|
||||
},
|
||||
{
|
||||
type: 'geo',
|
||||
id: ids.box1,
|
||||
x: 200,
|
||||
y: 200,
|
||||
props: { w: 100, h: 100 },
|
||||
},
|
||||
{
|
||||
type: 'geo',
|
||||
id: ids.box2,
|
||||
x: 400,
|
||||
y: 400,
|
||||
props: { w: 100, h: 100 },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('allows other shapes to snap to custom snap points', () => {
|
||||
// should snap to 0 on y axis
|
||||
// x────────────x───────x
|
||||
// x───────┐ x───────x
|
||||
// │ T │ │ A │
|
||||
// │ │ │ x │
|
||||
// │ │ │ drag │
|
||||
// └───────┘ x───────x
|
||||
editor.pointerDown(250, 250, ids.box1).pointerMove(250, 51, { ctrlKey: true })
|
||||
expect(editor.getShape(ids.box1)).toMatchObject({ x: 200, y: 0 })
|
||||
expect(editor.snaps.getIndicators()?.length).toBe(1)
|
||||
expect(getNumSnapPoints(editor.snaps.getIndicators()![0])).toBe(3)
|
||||
|
||||
// should not snap to 100 on y axis
|
||||
// x───────┐
|
||||
// │ T │
|
||||
// │ │
|
||||
// │ │
|
||||
// └───────┘ x───────x
|
||||
// │ A │
|
||||
// │ x │
|
||||
// │ drag │
|
||||
// x───────x
|
||||
editor.pointerMove(250, 151, { ctrlKey: true })
|
||||
expect(editor.getShape(ids.box1)).toMatchObject({ x: 200, y: 101 })
|
||||
expect(editor.snaps.getIndicators()?.length).toBe(0)
|
||||
|
||||
// should not snap to 50 on y axis
|
||||
// x───────┐
|
||||
// │ T │
|
||||
// │ │ x───────x
|
||||
// │ │ │ A │
|
||||
// └───────┘ │ x │
|
||||
// │ drag │
|
||||
// x───────x
|
||||
editor.pointerMove(250, 101, { ctrlKey: true })
|
||||
expect(editor.getShape(ids.box1)).toMatchObject({ x: 200, y: 51 })
|
||||
expect(editor.snaps.getIndicators()?.length).toBe(0)
|
||||
|
||||
// should snap to 0 on x axis
|
||||
// x x───────┐
|
||||
// │ │ T │
|
||||
// │ │ │
|
||||
// │ │ │
|
||||
// │ └───────┘
|
||||
// │
|
||||
// x x───────x
|
||||
// │ │ A │
|
||||
// │ │ x │
|
||||
// │ │ drag │
|
||||
// x x───────x
|
||||
editor.pointerMove(51, 250, { ctrlKey: true })
|
||||
expect(editor.getShape(ids.box1)).toMatchObject({ x: 0, y: 200 })
|
||||
expect(editor.snaps.getIndicators()?.length).toBe(1)
|
||||
expect(getNumSnapPoints(editor.snaps.getIndicators()![0])).toBe(3)
|
||||
|
||||
// should not snap to 100 on x axis
|
||||
// x───────┐
|
||||
// │ T │
|
||||
// │ │
|
||||
// │ │
|
||||
// └───────┘
|
||||
//
|
||||
// x───────x
|
||||
// │ A │
|
||||
// │ x │
|
||||
// │ drag │
|
||||
// x───────x
|
||||
editor.pointerMove(151, 250, { ctrlKey: true })
|
||||
expect(editor.getShape(ids.box1)).toMatchObject({ x: 101, y: 200 })
|
||||
expect(editor.snaps.getIndicators()?.length).toBe(0)
|
||||
|
||||
// should not snap to 50 on x axis
|
||||
// x───────┐
|
||||
// │ T │
|
||||
// │ │
|
||||
// │ │
|
||||
// └───────┘
|
||||
//
|
||||
// x───────x
|
||||
// │ A │
|
||||
// │ x │
|
||||
// │ drag │
|
||||
// x───────x
|
||||
editor.pointerMove(101, 250, { ctrlKey: true })
|
||||
expect(editor.getShape(ids.box1)).toMatchObject({ x: 51, y: 200 })
|
||||
expect(editor.snaps.getIndicators()?.length).toBe(0)
|
||||
})
|
||||
|
||||
it('allows shapes with custom points to snap to other shapes', () => {
|
||||
// should snap to 200 on y axis
|
||||
// x────────────x───────x
|
||||
// x───────┐ x───────x
|
||||
// │ T │ │ A │
|
||||
// │ │ │ x │
|
||||
// │ drag │ │ │
|
||||
// └───────┘ x───────x
|
||||
editor.pointerDown(50, 50, ids.boxT).pointerMove(50, 251, { ctrlKey: true })
|
||||
expect(editor.getShape(ids.boxT)).toMatchObject({ x: 0, y: 200 })
|
||||
expect(editor.snaps.getIndicators()?.length).toBe(1)
|
||||
expect(getNumSnapPoints(editor.snaps.getIndicators()![0])).toBe(3)
|
||||
|
||||
// should snap to 250 on y axis
|
||||
// x─────────────────x
|
||||
// x───────x
|
||||
// │ A │
|
||||
// x───────┐ │ x │
|
||||
// │ T │ │ │
|
||||
// │ │ x───────x
|
||||
// │ drag │
|
||||
// └───────┘
|
||||
editor.pointerMove(50, 301, { ctrlKey: true })
|
||||
expect(editor.getShape(ids.boxT)).toMatchObject({ x: 0, y: 250 })
|
||||
expect(editor.snaps.getIndicators()?.length).toBe(1)
|
||||
expect(getNumSnapPoints(editor.snaps.getIndicators()![0])).toBe(2)
|
||||
|
||||
// should snap to 300 on y axis
|
||||
// x─────────────x───────x
|
||||
// x───────x
|
||||
// │ A │
|
||||
// │ x │
|
||||
// │ │
|
||||
// x───────┐ x───────x
|
||||
// │ T │
|
||||
// │ │
|
||||
// │ drag │
|
||||
// └───────┘
|
||||
editor.pointerMove(50, 351, { ctrlKey: true })
|
||||
expect(editor.getShape(ids.boxT)).toMatchObject({ x: 0, y: 300 })
|
||||
expect(editor.snaps.getIndicators()?.length).toBe(1)
|
||||
expect(getNumSnapPoints(editor.snaps.getIndicators()![0])).toBe(3)
|
||||
|
||||
// should snap to 200 on x axis
|
||||
// x x───────┐
|
||||
// │ │ T │
|
||||
// │ │ │
|
||||
// │ │ drag │
|
||||
// │ └───────┘
|
||||
// │
|
||||
// x x───────x
|
||||
// │ │ A │
|
||||
// │ │ x │
|
||||
// │ │ │
|
||||
// x x───────x
|
||||
editor.pointerMove(251, 50, { ctrlKey: true })
|
||||
expect(editor.getShape(ids.boxT)).toMatchObject({ x: 200, y: 0 })
|
||||
expect(editor.snaps.getIndicators()?.length).toBe(1)
|
||||
expect(getNumSnapPoints(editor.snaps.getIndicators()![0])).toBe(3)
|
||||
|
||||
// should snap to 250 on x axis
|
||||
// x x───────┐
|
||||
// │ │ T │
|
||||
// │ │ │
|
||||
// │ │ drag │
|
||||
// │ └───────┘
|
||||
// │
|
||||
// │ x───────x
|
||||
// │ │ A │
|
||||
// x │ x │
|
||||
// │ │
|
||||
// x───────x
|
||||
editor.pointerMove(301, 50, { ctrlKey: true })
|
||||
expect(editor.getShape(ids.boxT)).toMatchObject({ x: 250, y: 0 })
|
||||
expect(editor.snaps.getIndicators()?.length).toBe(1)
|
||||
expect(getNumSnapPoints(editor.snaps.getIndicators()![0])).toBe(2)
|
||||
|
||||
// should snap to 300 on x axis
|
||||
// x x───────┐
|
||||
// │ │ T │
|
||||
// │ │ │
|
||||
// │ │ drag │
|
||||
// │ └───────┘
|
||||
// │
|
||||
// x x───────x
|
||||
// │ │ A │
|
||||
// │ │ x │
|
||||
// │ │ │
|
||||
// x x───────x
|
||||
editor.pointerMove(351, 50, { ctrlKey: true })
|
||||
expect(editor.getShape(ids.boxT)).toMatchObject({ x: 300, y: 0 })
|
||||
expect(editor.snaps.getIndicators()?.length).toBe(1)
|
||||
expect(getNumSnapPoints(editor.snaps.getIndicators()![0])).toBe(3)
|
||||
})
|
||||
|
||||
it('becomes part of the selection bounding box if there is more than one shape in the selection', () => {
|
||||
// ┌────────────────────────┐
|
||||
// │ │
|
||||
// │ x───────┐ │
|
||||
// │ │ T │ │
|
||||
// │ │ │ │
|
||||
// │ │ │ │
|
||||
// │ └───────┘ x │
|
||||
// │ │ x───────x │
|
||||
// │ │ │ A │ │
|
||||
// │ │ │ x │ │
|
||||
// │ │ │ │ │
|
||||
// │ │ x───────x │
|
||||
// │ │ │
|
||||
// └───────────┼────────────┘
|
||||
// │
|
||||
// │ 450
|
||||
// x───┼───x
|
||||
// │ B │ │
|
||||
// │ x │
|
||||
// │ │
|
||||
// x───────x
|
||||
editor.select(ids.boxT, ids.box1)
|
||||
editor.pointerDown(50, 50, ids.boxT).pointerMove(351, 50, { ctrlKey: true })
|
||||
expect(editor.snaps.getIndicators()?.length).toBe(1)
|
||||
expect(getNumSnapPoints(editor.snaps.getIndicators()![0])).toBe(2)
|
||||
expect(getSnapPoints(editor.snaps.getIndicators()![0])?.map(({ x }) => x)).toEqual([450, 450])
|
||||
})
|
||||
})
|
Loading…
Add table
Add a link
Reference in a new issue