[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:
alex 2024-02-15 15:10:04 +00:00 committed by GitHub
parent ac0259a6af
commit 77865d9f5e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 726 additions and 886 deletions

View file

@ -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>;

View file

@ -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)",

View file

@ -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,

View file

@ -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
}

View file

@ -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

View file

@ -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'

View file

@ -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
/**

View file

@ -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),

View file

@ -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() {

View file

@ -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')

View file

@ -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,

View 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)
})
})
})

View file

@ -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>

View file

@ -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])
})
})