Tldraw/packages/tldraw/src/test/customSnapping.test.tsx

499 wiersze
16 KiB
TypeScript

import {
BaseBoxShapeUtil,
IndexKey,
Polyline2d,
TLAnyShapeUtilConstructor,
TLBaseShape,
TLHandle,
TLLineShape,
TLOnHandleDragHandler,
TLShapeId,
Vec,
VecModel,
ZERO_INDEX_KEY,
} 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
ownHandle: VecModel
handleOutline: VecModel[] | 'default' | null
handlePoints: VecModel[] | 'default'
selfSnapOutline: VecModel[] | 'default'
selfSnapPoints: VecModel[] | 'default'
}
>
class TestShapeUtil extends BaseBoxShapeUtil<TestShape> {
static override type = 'test'
override getDefaultProps(): TestShape['props'] {
return {
w: 100,
h: 100,
ownHandle: { x: 0, y: 0 },
handleOutline: 'default',
handlePoints: 'default',
selfSnapOutline: 'default',
selfSnapPoints: 'default',
}
}
override component() {
throw new Error('Method not implemented.')
}
override indicator() {
throw new Error('Method not implemented.')
}
override getHandleSnapGeometry(shape: TestShape) {
const { handleOutline, handlePoints, selfSnapOutline, selfSnapPoints } = shape.props
return {
outline:
handleOutline === 'default'
? undefined
: handleOutline === null
? null
: new Polyline2d({ points: handleOutline.map(Vec.From) }),
points: handlePoints === 'default' ? undefined : handlePoints,
getSelfSnapOutline:
selfSnapOutline === 'default'
? undefined
: () => new Polyline2d({ points: selfSnapOutline.map(Vec.From) }),
getSelfSnapPoints: selfSnapPoints === 'default' ? undefined : () => selfSnapPoints,
}
}
override getHandles(shape: TestShape): TLHandle[] {
return [
{
id: 'handle',
type: 'vertex',
x: shape.props.ownHandle.x,
y: shape.props.ownHandle.y,
index: ZERO_INDEX_KEY,
canSnap: true,
},
]
}
override onHandleDrag: TLOnHandleDragHandler<TestShape> = (shape, { handle }) => {
return { ...shape, props: { ...shape.props, ownHandle: { x: handle.x, y: handle.y } } }
}
}
const shapeUtils = [TestShapeUtil] as TLAnyShapeUtilConstructor[]
let editor: TestEditor
let ids: Record<string, TLShapeId>
beforeEach(() => {
editor = new TestEditor({ shapeUtils })
ids = editor.createShapesFromJsx([
<TL.line
ref="line"
x={0}
y={0}
points={{
a1: { id: 'a1', index: 'a1' as IndexKey, x: 0, y: 0 },
a2: { id: 'a2', index: '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 handleSnapGeometry.outline', () => {
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()).toMatchObject({ x: 215, y: 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()).toMatchObject({ x: 200, y: 205 })
})
test("doesn't snap to the center", () => {
startDraggingHandle()
editor.pointerMove(251, 251, undefined, { ctrlKey: true })
expect(editor.snaps.getIndicators()).toHaveLength(0)
expect(handlePosition()).toMatchObject({ x: 251, y: 251 })
})
})
describe('with empty handleSnapGeometry.outline', () => {
beforeEach(() => {
editor.updateShape<TestShape>({
id: ids.test,
type: 'test',
props: { handleOutline: 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()).toMatchObject({ x: 215, y: 205 })
})
})
describe('with custom handleSnapGeometry.outline', () => {
beforeEach(() => {
editor.updateShape<TestShape>({
id: ids.test,
type: 'test',
props: {
// a diagonal line from the top left to the bottom right
handleOutline: [
{ 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()).toMatchObject({ x: 235, y: 205 })
})
test('snaps to the custom geometry', () => {
startDraggingHandle()
editor.pointerMove(210, 214, undefined, { ctrlKey: true })
expect(editor.snaps.getIndicators()).toHaveLength(1)
expect(handlePosition()).toMatchObject({ x: 212, y: 212 })
})
})
describe('with default handleSnapGeometry.points', () => {
test('doesnt snap to the center', () => {
startDraggingHandle()
editor.pointerMove(251, 251, undefined, { ctrlKey: true })
expect(editor.snaps.getIndicators()).toHaveLength(0)
expect(handlePosition()).toMatchObject({ x: 251, y: 251 })
})
test('doesnt snap to corners', () => {
startDraggingHandle()
editor.pointerMove(203, 202, undefined, { ctrlKey: true })
// snaps to edge, not corner:
expect(editor.snaps.getIndicators()).toHaveLength(1)
expect(handlePosition()).toMatchObject({ x: 203, y: 200 })
})
})
describe('with custom handleSnapGeometry.points', () => {
beforeEach(() => {
editor.updateShape<TestShape>({
id: ids.test,
type: 'test',
props: {
handlePoints: [
{ x: 30, y: 30 },
{ x: 70, y: 50 },
],
},
})
})
test('snaps to the custom points', () => {
startDraggingHandle()
editor.pointerMove(235, 235, undefined, { ctrlKey: true })
expect(editor.snaps.getIndicators()).toHaveLength(1)
expect(handlePosition()).toMatchObject({ x: 230, y: 230 })
editor.snaps.clearIndicators()
editor.pointerMove(265, 255, undefined, { ctrlKey: true })
expect(editor.snaps.getIndicators()).toHaveLength(1)
expect(handlePosition()).toMatchObject({ x: 270, y: 250 })
})
})
describe('with custom handleSnapGeometry.points along the outline', () => {
beforeEach(() => {
editor.updateShape<TestShape>({
id: ids.test,
type: 'test',
props: {
handlePoints: editor
.getShapeGeometry(ids.test)
.bounds.cornersAndCenter.map(({ x, y }) => ({ x, y })),
},
})
})
test('snaps to points over outline', () => {
startDraggingHandle()
editor.pointerMove(203, 202, undefined, { ctrlKey: true })
// snaps to corner, not edge:
expect(editor.snaps.getIndicators()).toHaveLength(1)
expect(handlePosition()).toMatchObject({ x: 200, y: 200 })
})
test('can still snap to non-outline points', () => {
startDraggingHandle()
editor.pointerMove(255, 255, undefined, { ctrlKey: true })
// snaps to the center:
expect(editor.snaps.getIndicators()).toHaveLength(1)
expect(handlePosition()).toMatchObject({ x: 250, y: 250 })
})
test('can still snap to non-point outlines', () => {
startDraggingHandle()
editor.pointerMove(235, 205, undefined, { ctrlKey: true })
// snaps to the edge:
expect(editor.snaps.getIndicators()).toHaveLength(1)
expect(handlePosition()).toMatchObject({ x: 235, y: 200 })
})
})
describe('self snapping', () => {
beforeEach(() => {
editor.deleteShape(ids.line)
editor.updateShape<TestShape>({
id: ids.test,
type: 'test',
x: 0,
y: 0,
props: {
handlePoints: [{ x: 0, y: 0 }],
},
})
})
function startDraggingOwnHandle() {
const shape = editor.select(ids.test).getOnlySelectedShape()!
const handles = editor.getShapeHandles(shape)!
editor.pointerDown(0, 0, { target: 'handle', shape, handle: handles[0] })
}
function ownHandlePosition() {
const shape = editor.select(ids.test).getOnlySelectedShape()!
const handle = editor.getShapeHandles(shape)![0]
return { x: handle.x, y: handle.y }
}
describe('by default', () => {
test('does not snap to standard outline', () => {
startDraggingOwnHandle()
editor.pointerMove(3, 50, undefined, { ctrlKey: true })
expect(editor.snaps.getIndicators()).toHaveLength(0)
expect(ownHandlePosition()).toMatchObject({ x: 3, y: 50 })
})
test('does not snap to standard points', () => {
startDraggingOwnHandle()
editor.pointerMove(3, 3, undefined, { ctrlKey: true })
expect(editor.snaps.getIndicators()).toHaveLength(0)
expect(ownHandlePosition()).toMatchObject({ x: 3, y: 3 })
})
})
describe('with custom self snap outline & points', () => {
beforeEach(() => {
editor.updateShape<TestShape>({
id: ids.test,
type: 'test',
props: {
selfSnapOutline: [
{ x: 20, y: 50 },
{ x: 80, y: 50 },
],
selfSnapPoints: [
{ x: 20, y: 50 },
{ x: 80, y: 50 },
],
},
})
})
test('does not snap to standard outline', () => {
startDraggingOwnHandle()
editor.pointerMove(3, 50, undefined, { ctrlKey: true })
expect(editor.snaps.getIndicators()).toHaveLength(0)
expect(ownHandlePosition()).toMatchObject({ x: 3, y: 50 })
})
test('does not snap to standard points', () => {
startDraggingOwnHandle()
editor.pointerMove(3, 3, undefined, { ctrlKey: true })
expect(editor.snaps.getIndicators()).toHaveLength(0)
expect(ownHandlePosition()).toMatchObject({ x: 3, y: 3 })
})
test('snaps to the self-snap outline', () => {
startDraggingOwnHandle()
editor.pointerMove(50, 55, undefined, { ctrlKey: true })
expect(editor.snaps.getIndicators()).toHaveLength(1)
expect(ownHandlePosition()).toMatchObject({ x: 50, y: 50 })
})
test('snaps to the self-snap points', () => {
startDraggingOwnHandle()
editor.pointerMove(23, 55, undefined, { ctrlKey: true })
expect(editor.snaps.getIndicators()).toHaveLength(1)
expect(ownHandlePosition()).toMatchObject({ x: 20, y: 50 })
})
})
})
})