Tldraw/packages/tldraw/src/test/translating.test.ts

1872 wiersze
65 KiB
TypeScript

import {
GapsSnapLine,
PointsSnapLine,
SnapLine,
TLArrowShape,
TLGeoShape,
TLShapeId,
TLShapePartial,
Vec,
createShapeId,
} from '@tldraw/editor'
import { TestEditor } from './TestEditor'
import { getSnapLines } from './getSnapLines'
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'),
}
beforeEach(() => {
console.error = jest.fn()
editor = new TestEditor()
})
const getNumSnapPoints = (snap: SnapLine): number => {
return snap.type === 'points' ? snap.points.length : (null as any as number)
}
function assertGaps(snap: SnapLine): asserts snap is GapsSnapLine {
expect(snap.type).toBe('gaps')
}
function getGapAndPointLines(snaps: SnapLine[]) {
const gapLines = snaps.filter((snap) => snap.type === 'gaps') as GapsSnapLine[]
const pointLines = snaps.filter((snap) => snap.type === 'points') as PointsSnapLine[]
return { gapLines, pointLines }
}
const box = (id: TLShapeId, x: number, y: number, w = 10, h = 10): TLShapePartial => ({
type: 'geo',
id,
x,
y,
props: {
w,
h,
},
})
describe('When translating...', () => {
beforeEach(() => {
editor.createShapes([
{
id: ids.box1,
type: 'geo',
x: 10,
y: 10,
props: {
w: 100,
h: 100,
},
},
{
id: ids.box2,
type: 'geo',
x: 200,
y: 200,
props: {
w: 100,
h: 100,
},
},
{
id: ids.line1,
type: 'line',
x: 100,
y: 100,
},
])
})
it('enters and exits the translating state', () => {
editor
.pointerDown(50, 50, ids.box1)
.expectToBeIn('select.pointing_shape')
.pointerMove(50, 40)
.expectToBeIn('select.translating')
.pointerUp()
.expectToBeIn('select.idle')
})
it('exits the translating state when canceled', () => {
editor
.pointerDown(50, 50, ids.box1)
.pointerMove(50, 40) // [0, -10]
.expectToBeIn('select.translating')
.cancel()
.expectToBeIn('select.idle')
})
it('translates a single shape', () => {
editor
.pointerDown(50, 50, ids.box1)
.pointerMove(50, 40) // [0, -10]
.expectShapeToMatch({ id: ids.box1, x: 10, y: 0 })
.pointerMove(100, 100) // [50, 50]
.expectShapeToMatch({ id: ids.box1, x: 60, y: 60 })
.pointerUp()
.expectShapeToMatch({ id: ids.box1, x: 60, y: 60 })
})
it('translates a single shape near the top left edge', () => {
editor.user.updateUserPreferences({ edgeScrollSpeed: 1 })
editor.pointerDown(50, 50, ids.box1).pointerMove(0, 50) // [-50, 0]
const before = editor.getShape<TLGeoShape>(ids.box1)!
jest.advanceTimersByTime(100)
editor
// The change is bigger than expected because the camera moves
.expectShapeToMatch({ id: ids.box1, x: -160, y: 10 })
// We'll continue moving in the x postion, but now we'll also move in the y position.
// The speed in the y position is smaller since we are further away from the edge.
.pointerMove(0, 25)
jest.advanceTimersByTime(100)
editor.pointerUp()
const after = editor.getShape<TLGeoShape>(ids.box1)!
expect(after.x).toBeLessThan(before.x)
expect(after.y).toBeLessThan(before.y)
expect(after.props.w).toEqual(before.props.w)
expect(after.props.h).toEqual(before.props.h)
})
it('translates a single shape near the bottom right edge', () => {
editor.user.updateUserPreferences({ edgeScrollSpeed: 1 })
editor.pointerDown(50, 50, ids.box1).pointerMove(1080, 50)
jest.advanceTimersByTime(100)
editor
// The change is bigger than expected because the camera moves
.expectShapeToMatch({ id: ids.box1, x: 1140, y: 10 })
.pointerMove(1080, 800)
jest.advanceTimersByTime(100)
editor
.expectShapeToMatch({ id: ids.box1, x: 1280, y: 845.68 })
.pointerUp()
.expectShapeToMatch({ id: ids.box1, x: 1280, y: 845.68 })
})
it('translates multiple shapes', () => {
editor
.select(ids.box1, ids.box2)
.pointerDown(50, 50, ids.box1)
.pointerMove(50, 40) // [0, -10]
.expectShapeToMatch({ id: ids.box1, x: 10, y: 0 }, { id: ids.box2, x: 200, y: 190 })
.pointerMove(100, 100) // [50, 50]
.expectShapeToMatch({ id: ids.box1, x: 60, y: 60 }, { id: ids.box2, x: 250, y: 250 })
.pointerUp()
.expectShapeToMatch({ id: ids.box1, x: 60, y: 60 }, { id: ids.box2, x: 250, y: 250 })
})
})
describe('When cloning...', () => {
beforeEach(() => {
editor.createShapes([
{
id: ids.box1,
type: 'geo',
x: 10,
y: 10,
props: {
w: 100,
h: 100,
},
},
{
id: ids.box2,
type: 'geo',
x: 200,
y: 200,
props: {
w: 100,
h: 100,
},
},
{
id: ids.line1,
type: 'line',
x: 100,
y: 100,
},
])
})
it('clones a single shape and restores when stopping cloning', () => {
// Move the camera so that we are not at the edges, which causes the camera to move when we translate
expect(editor.getCurrentPageShapeIds().size).toBe(3)
expect(editor.getCurrentPageShapeIds().size).toBe(3)
editor.select(ids.box1).pointerDown(50, 50, ids.box1).pointerMove(50, 40) // [0, -10]
expect(editor.getCurrentPageShapeIds().size).toBe(3)
editor.expectShapeToMatch({ id: ids.box1, x: 10, y: 0 }) // Translated A...
// Start cloning!
editor.keyDown('Alt')
expect(editor.getCurrentPageShapeIds().size).toBe(4)
const newShape = editor.getSelectedShapes()[0]
expect(newShape.id).not.toBe(ids.box1)
editor
.expectShapeToMatch({ id: ids.box1, x: 10, y: 10 }) // A should be back to original position...
.expectShapeToMatch({ id: newShape.id, x: 10, y: 0 }) // New node should be at A's previous position
.pointerMove(60, 40) // [10, -10]
.expectShapeToMatch({ id: ids.box1, x: 10, y: 10 }) // No movement on A
.expectShapeToMatch({ id: newShape.id, x: 20, y: 0 }) // Clone should be moving
// Stop cloning!
editor.keyUp('Alt')
jest.advanceTimersByTime(500)
editor.expectShapeToMatch({ id: ids.box1, x: 20, y: 0 }) // A should be at the translated position...
expect(editor.getShape(newShape.id)).toBeUndefined() // And the new node should be gone!
})
it('clones multiple single shape and restores when stopping cloning', () => {
editor.select(ids.box1, ids.box2).pointerDown(50, 50, ids.box1).pointerMove(50, 40) // [0, -10]
expect(editor.getCurrentPageShapeIds().size).toBe(3)
editor.expectShapeToMatch({ id: ids.box1, x: 10, y: 0 }) // Translated A...
editor.expectShapeToMatch({ id: ids.box2, x: 200, y: 190 }) // Translated B...
// Start cloning!
editor.keyDown('Alt')
expect(editor.getCurrentPageShapeIds().size).toBe(5) // Two new shapes!
const newShapeA = editor.getShape(editor.getSelectedShapeIds()[0])!
const newShapeB = editor.getShape(editor.getSelectedShapeIds()[1])!
expect(newShapeA).toBeDefined()
expect(newShapeB).toBeDefined()
editor
.expectShapeToMatch({ id: ids.box1, x: 10, y: 10 }) // A should be back to original position...
.expectShapeToMatch({ id: ids.box2, x: 200, y: 200 }) // B should be back to original position...
.expectShapeToMatch({ id: newShapeA.id, x: 10, y: 0 }) // New node should be at A's previous position
.expectShapeToMatch({ id: newShapeB.id, x: 200, y: 190 }) // New node should be at B's previous position
.pointerMove(60, 40) // [10, -10]
.expectShapeToMatch({ id: ids.box1, x: 10, y: 10 }) // No movement on A
.expectShapeToMatch({ id: ids.box2, x: 200, y: 200 }) // No movement on B
.expectShapeToMatch({ id: newShapeA.id, x: 20, y: 0 }) // Clone A should be moving
.expectShapeToMatch({ id: newShapeB.id, x: 210, y: 190 }) // Clone B should be moving
// Stop cloning!
editor.keyUp('Alt')
// wait 500ms
jest.advanceTimersByTime(500)
editor
.expectShapeToMatch({ id: ids.box1, x: 20, y: 0 }) // A should be at the translated position...
.expectShapeToMatch({ id: ids.box2, x: 210, y: 190 }) // B should be at the translated position...
expect(editor.getShape(newShapeA.id)).toBeUndefined() // And the new node A should be gone!
expect(editor.getShape(newShapeB.id)).toBeUndefined() // And the new node B should be gone!
})
it('clones a parent and its descendants and removes descendants when stopping cloning', () => {
editor.updateShapes([{ id: ids.line1, type: 'geo', parentId: ids.box2 }])
expect(editor.getShape(ids.line1)!.parentId).toBe(ids.box2)
editor.select(ids.box2).pointerDown(250, 250, ids.box2).pointerMove(250, 240) // [0, -10]
expect(editor.getCurrentPageShapeIds().size).toBe(3)
editor.keyDown('Alt', { altKey: true })
expect(editor.getCurrentPageShapeIds().size).toBe(5) // Creates a clone of B and C (its descendant)
const newShapeA = editor.getShape(editor.getSelectedShapeIds()[0])!
const newShapeB = editor.getShape(editor.getSortedChildIdsForParent(newShapeA.id)[0])!
expect(newShapeA).toBeDefined()
expect(newShapeB).toBeDefined()
const cloneB = newShapeA.x === editor.getShape(ids.box2)!.x ? newShapeA : newShapeB
const cloneC = newShapeA.x === editor.getShape(ids.box2)!.x ? newShapeB : newShapeA
editor
.expectShapeToMatch({ id: ids.box2, x: 200, y: 200 }) // B should be back to original position...
.expectShapeToMatch({ id: cloneB.id, x: 200, y: 190 }) // New node should be at A's previous position
.expectShapeToMatch({ id: cloneC.id, x: 100, y: 100 }) // New node should be at B's previous position
.pointerMove(260, 240) // [10, -10]
.expectShapeToMatch({ id: ids.box2, x: 200, y: 200 }) // No movement on B
.expectShapeToMatch({ id: cloneB.id, x: 210, y: 190 }) // Clone A should be moving
.expectShapeToMatch({ id: cloneC.id, x: 100, y: 100 }) // New node should be at B's previous position
// Stop cloning!
editor.keyUp('Alt')
// wait 500ms
jest.advanceTimersByTime(500)
editor.expectShapeToMatch({ id: ids.box2, x: 210, y: 190 }) // B should be at the translated position...
expect(editor.getShape(cloneB.id)).toBeUndefined() // And the new node A should be gone!
expect(editor.getShape(cloneC.id)).toBeUndefined() // And the new node B should be gone!
})
it('Clones twice', () => {
const groupId = createShapeId('g')
editor.groupShapes([ids.box1, ids.box2], groupId)
const count1 = editor.getCurrentPageShapes().length
editor.pointerDown(50, 50, { shape: editor.getShape(groupId)!, target: 'shape' })
editor.expectToBeIn('select.pointing_shape')
editor.pointerMove(199, 199)
editor.expectToBeIn('select.translating')
expect(editor.getCurrentPageShapes().length).toBe(count1) // 2 new box and group
editor.keyDown('Alt')
editor.expectToBeIn('select.translating')
expect(editor.getCurrentPageShapes().length).toBe(count1 + 3) // 2 new box and group
editor.keyUp('Alt')
jest.advanceTimersByTime(500)
expect(editor.getCurrentPageShapes().length).toBe(count1) // 2 new box and group
editor.keyDown('Alt')
expect(editor.getCurrentPageShapes().length).toBe(count1 + 3) // 2 new box and group
})
})
describe('When translating shapes that are descendants of a rotated shape...', () => {
beforeEach(() => {
editor.createShapes([
{
id: ids.box1,
type: 'geo',
x: 10,
y: 10,
props: {
w: 100,
h: 100,
},
},
{
id: ids.box2,
type: 'geo',
x: 200,
y: 200,
props: {
w: 100,
h: 100,
},
},
{
id: ids.line1,
type: 'line',
x: 100,
y: 100,
},
])
})
it('Translates correctly', () => {
editor.createShapes([
{
id: ids.boxD,
parentId: ids.box1,
type: 'geo',
x: 20,
y: 20,
props: {
w: 10,
h: 10,
},
},
])
const shapeA = editor.getShape(ids.box1)!
const shapeD = editor.getShape(ids.boxD)!
expect(editor.getPageCenter(shapeA)).toMatchObject(new Vec(60, 60))
expect(editor.getShapeGeometry(shapeD).center).toMatchObject(new Vec(5, 5))
expect(editor.getPageCenter(shapeD)).toMatchObject(new Vec(35, 35))
const rads = 0
expect(editor.getPageCenter(shapeA)).toMatchObject(new Vec(60, 60))
// Expect the node's page position to be rotated around its parent's page center
expect(editor.getPageCenter(shapeD)).toMatchObject(
new Vec(35, 35).rotWith(editor.getPageCenter(shapeA)!, rads)
)
const centerD = editor.getPageCenter(shapeD)!.clone().toFixed()
editor
.select(ids.boxD)
.pointerDown(centerD.x, centerD.y, ids.boxD)
.pointerMove(centerD.x, centerD.y - 10)
.pointerMove(centerD.x, centerD.y - 10)
.pointerUp()
expect(editor.getPageCenter(shapeD)).toMatchObject(new Vec(centerD.x, centerD.y - 10))
const centerA = editor.getPageCenter(shapeA)!.clone().toFixed()
editor
.select(ids.box1)
.pointerDown(centerA.x, centerA.y, ids.box1)
.pointerMove(centerA.x, centerA.y - 100)
.pointerUp()
const centerB = editor.getPageCenter(shapeA)!.clone().toFixed()
expect(centerB).toMatchObject({ x: centerA.x, y: centerA.y - 100 })
})
})
describe('snapping with single shapes', () => {
beforeEach(() => {
// 0 10 20 30
// ┌──────┐ ┌──────┐
// │ A │ │ B │
// └──────┘ └──────┘
editor.createShapes([
{
id: ids.box1,
type: 'geo',
x: 0,
y: 0,
props: { w: 10, h: 10 },
},
{
id: ids.box2,
type: 'geo',
x: 20,
y: 0,
props: { w: 10, h: 10 },
},
])
})
it('happens when the ctrl key is pressed', () => {
// 0 10 11 21
// ┌──────┐ ┌──────┐
// │ │ │ │ <- dragging left
// └──────┘ └──────┘
//
// │
// │ press ctrl
// ▼
//
// 0 10 20
// ┌──────┬──────┐
// │ │ │ *snap*
// └──────┴──────┘
editor.pointerDown(25, 5, ids.box2).pointerMove(16, 5)
// expect box B to be at 11, 0
expect(editor.getShape(ids.box2)!).toMatchObject({ x: 11, y: 0 })
// press ctrl key and it snaps to 10, 0
editor.keyDown('Control')
expect(editor.getShape(ids.box2)!).toMatchObject({ x: 10, y: 0 })
// release ctrl key and it unsnaps
editor.keyUp('Control')
jest.advanceTimersByTime(200)
expect(editor.getShape(ids.box2)!).toMatchObject({ x: 11, y: 0 })
// press ctrl and release the pointer and it should stay snapped
editor.keyDown('Control')
expect(editor.getShape(ids.box2)!).toMatchObject({ x: 10, y: 0 })
editor.pointerUp(16, 5, { ctrlKey: true })
expect(editor.getShape(ids.box2)!).toMatchObject({ x: 10, y: 0 })
})
it('snaps to the center point as well as all four corners of a bounding box', () => {
// ┌──────┐
// │ B │
// └──────┘
// ┌──────┐
// │ A │
// └──────┘
editor.pointerDown(25, 5, ids.box2).pointerMove(-6, -6, { ctrlKey: true })
expect(editor.getShape(ids.box2)!).toMatchObject({ x: -10, y: -10 })
// ┌──────┐
// │ B │
// └──────┘
// ┌──────┐
// │ A │
// └──────┘
editor.pointerMove(16, -6, { ctrlKey: true })
expect(editor.getShape(ids.box2)!).toMatchObject({ x: 10, y: -10 })
// ┌──────┐
// │ A │
// └──────┘
// ┌──────┐
// │ B │
// └──────┘
editor.pointerMove(16, 16, { ctrlKey: true })
expect(editor.getShape(ids.box2)!).toMatchObject({ x: 10, y: 10 })
// ┌──────┐
// │ A │
// └──────┘
// ┌──────┐
// │ B │
// └──────┘
editor.pointerMove(-6, 16, { ctrlKey: true })
expect(editor.getShape(ids.box2)!).toMatchObject({ x: -10, y: 10 })
// ┌──────┐
// │ AB │
// └──────┘
editor.pointerMove(6, 6, { ctrlKey: true })
expect(editor.getShape(ids.box2)!).toMatchObject({ x: 0, y: 0 })
})
it('creates snap lines + points to render in the UI', () => {
// 0 10
// ┌──────┐ ┼
// │ │
// └──────┘ ┼ one line, four points
// │
// │
// │
// │11 21
// ┼ ┌──────┐
// │ │
// ┼ └──────┘
editor.pointerDown(25, 5, ids.box2).pointerMove(16, 35, { ctrlKey: true })
expect(editor.snaps.getLines()?.length).toBe(1)
expect(getNumSnapPoints(editor.snaps.getLines()![0])).toBe(4)
})
it('shows all the horizonal lines + points where the bounding boxes align', () => {
// x─────x────────────────────x─────x
// ┌─────┐ ┌─────┐
// │ x──┼────────────────────┼──x │
// └─────┘ └─────┘
// x─────x────────────────────x─────x
editor.pointerDown(25, 5, ids.box2).pointerMove(36, 5, { ctrlKey: true })
const snaps = editor.snaps.getLines()!.sort((a, b) => getNumSnapPoints(a) - getNumSnapPoints(b))
expect(snaps.length).toBe(3)
// center snap line
expect(getNumSnapPoints(snaps[0])).toBe(2)
// top and bottom lines
expect(getNumSnapPoints(snaps[1])).toBe(4)
expect(getNumSnapPoints(snaps[2])).toBe(4)
})
it('shows all the vertical lines + points where the bounding boxes align', () => {
// x ┌─────┐ x
// │ │ x │ │
// x └──┼──┘ x
// │ │ │
// x ┌──┼──┐ x
// │ │ x │ │
// x └─────┘ x
editor.pointerDown(25, 5, ids.box2).pointerMove(5, 45, { ctrlKey: true })
const snaps = editor.snaps.getLines()!.sort((a, b) => getNumSnapPoints(a) - getNumSnapPoints(b))
expect(snaps.length).toBe(3)
// center snap line
expect(getNumSnapPoints(snaps[0])).toBe(2)
// left and right lines
expect(getNumSnapPoints(snaps[1])).toBe(4)
expect(getNumSnapPoints(snaps[2])).toBe(4)
})
it('does not snap to shapes that are not visible in the viewport', () => {
// move A off screen
editor.updateShapes([{ id: ids.box1, type: 'geo', x: -20 }])
editor.pointerDown(25, 5, ids.box2).pointerMove(36, 5, { ctrlKey: true })
expect(editor.snaps.getLines()!.length).toBe(0)
editor.updateShapes([{ id: ids.box1, type: 'geo', x: editor.getViewportScreenBounds().w + 10 }])
editor.pointerMove(33, 5, { ctrlKey: true })
expect(editor.snaps.getLines()!.length).toBe(0)
editor.updateShapes([{ id: ids.box1, type: 'geo', y: -20 }])
editor.pointerMove(5, 5, { ctrlKey: true })
expect(editor.snaps.getLines()!.length).toBe(0)
editor.updateShapes([
{ id: ids.box1, type: 'geo', x: 0, y: editor.getViewportScreenBounds().h + 10 },
])
editor.pointerMove(5, 5, { ctrlKey: true })
expect(editor.snaps.getLines()!.length).toBe(0)
})
it('does not snap on the Y axis if the shift key is pressed', () => {
// ┌──────┐ ──────►
// ┌──────┐ │ B │ drag with shift
// │ A │ └──────┘
// └──────┘
// move B up one pixel
editor.updateShapes([{ id: ids.box2, type: 'geo', y: editor.getShape(ids.box2)!.y - 1 }])
editor.pointerDown(25, 5, ids.box2).pointerMove(36, 5, { ctrlKey: true })
// should snap without shift key
expect(editor.getShape(ids.box2)).toMatchObject({ x: 31, y: 0 })
editor.keyDown('Shift')
// should unsnap with shift key
expect(editor.getShape(ids.box2)).toMatchObject({ x: 31, y: -1 })
// and continue not snapping while moving
editor.pointerMove(45, 5, { ctrlKey: true, shiftKey: true })
expect(editor.getShape(ids.box2)).toMatchObject({ x: 40, y: -1 })
// should still snap to things on the X axis
editor.createShapes([{ type: 'geo', id: ids.line1, x: 100, y: 0, props: { w: 10, h: 10 } }])
editor.pointerMove(106, 5, { ctrlKey: true, shiftKey: true })
expect(editor.getShape(ids.box2)).toMatchObject({ x: 100, y: -1 })
})
it('does not snap on the X axis if the shift key is pressed', () => {
// ┌──────┐
// │ A │
// └──────┘
//
// ┌──────┐ │
// │ B │ drag with shift │
// └──────┘ ▼
// move B into place
editor.updateShapes([{ id: ids.box2, type: 'geo', x: 1, y: 20 }])
editor.pointerDown(6, 25, ids.box2).pointerMove(6, 35, { ctrlKey: true })
// should snap without shift key
expect(editor.getShape(ids.box2)).toMatchObject({ x: 0, y: 30 })
editor.keyDown('Shift')
// should unsnap with shift key
expect(editor.getShape(ids.box2)).toMatchObject({ x: 1, y: 30 })
// and continue not snapping while moving
editor.pointerMove(6, 50, { ctrlKey: true, shiftKey: true })
expect(editor.getShape(ids.box2)).toMatchObject({ x: 1, y: 45 })
// should still snap to things on the Y axis
editor.createShapes([{ type: 'geo', id: ids.line1, x: 20, y: 100, props: { w: 10, h: 10 } }])
editor.pointerMove(6, 106, { ctrlKey: true, shiftKey: true })
expect(editor.getShape(ids.box2)).toMatchObject({ x: 1, y: 100 })
})
})
describe('snapping with multiple shapes', () => {
beforeEach(() => {
// 0 100 200 300
// ┌──────┐ ┌──────┐
// │ A │ │ B │
// └──────┘ └──────┘
//
// ┌────────────────────┐
// │ │
// │ │
// │ │
// │ C │
// │ │
// │ │
// └────────────────────┘
editor.createShapes([
{
id: ids.box1,
type: 'geo',
x: 0,
y: 0,
props: { w: 100, h: 100 },
},
{
id: ids.box2,
type: 'geo',
x: 200,
y: 0,
props: { w: 100, h: 100 },
},
{
id: ids.line1,
type: 'geo',
x: 0,
y: 200,
props: { w: 300, h: 300 },
},
])
})
it("will not snap to inidivual shape's edges", () => {
// 0 100 200 300
// ┌──────┐ ┌──────┐
// │ A │ │ B │
// └──────┘ └──────┘
//
// ┌────────────────────┐
// │ │
// │ │
// │ │
// │ C │
// │ │
// │ │
// └────────────────────┘
editor.select(ids.box1, ids.box2)
editor.pointerDown(50, 50, ids.box1).pointerMove(249, 50, { ctrlKey: true })
expect(editor.getShape(ids.box1)!).toMatchObject({ x: 199, y: 0 })
})
it("will snap to the selection's bounding box", () => {
// 0 100 200 300
// ┌──────┐ ┌──────┐
// │ A │ │ B │
// └──────┘ └──────┘
// ┌────────────────────┐
// │ │
// │ │
// │ │
// │ C │
// │ │
// │ │
// └────────────────────┘
editor.select(ids.box1, ids.box2)
editor.pointerDown(50, 50, ids.box1).pointerMove(349, 50, { ctrlKey: true })
expect(editor.getShape(ids.box1)!).toMatchObject({ x: 300, y: 0 })
})
})
describe('Snap-between behavior', () => {
beforeEach(() => {
editor?.dispose()
})
it('snaps a shape horizontally between two others', () => {
// ┌─────┐ ┌─────┐
// │ │ │ │
// │ │ │ │
// │ │ │ │
// │ A │ │ B │
// │ │ ┌───┐ │ │
// │ ├──┼──┤ C ├──┼──┤ │
// │ │ └───┘ │ │
// └─────┘ └─────┘
editor.createShapes([
{ type: 'geo', id: ids.box1, x: 0, y: 0, props: { w: 50, h: 100 } },
{ type: 'geo', id: ids.box2, x: 200, y: 0, props: { w: 50, h: 100 } },
{ type: 'geo', id: ids.line1, x: 50, y: 0, props: { w: 10, h: 10 } },
])
// the midpoint is 125 and c is 10 wide so it should snap to 120 if we put it at 121
editor.pointerDown(55, 5, ids.line1).pointerMove(126, 67, { ctrlKey: true })
expect(editor.getShape(ids.line1)).toMatchObject({ x: 120, y: 62 })
expect(editor.snaps.getLines()?.length).toBe(1)
const line = editor.snaps.getLines()![0]
assertGaps(line)
expect(line.gaps.length).toBe(2)
})
it('shows horizontal point snaps at the same time as horizontal gap snaps', () => {
// ┌─────┐ ┌─────┐
// │ │ │ │
// │ │ │ │
// │ │ │ │
// │ A │ │ B │
// │ │ │ │
// │ │ ┌───┐ │ │
// │ ├──┼──┤ C ├──┼──┤ │
// └─────┘ └───┘ └─────┘
// x─────x─────x───x─────x─────x
editor.createShapes([
{ type: 'geo', id: ids.box1, x: 0, y: 0, props: { w: 50, h: 100 } },
{ type: 'geo', id: ids.box2, x: 200, y: 0, props: { w: 50, h: 100 } },
{ type: 'geo', id: ids.line1, x: 50, y: 0, props: { w: 10, h: 10 } },
])
editor.pointerDown(55, 5, ids.line1).pointerMove(126, 94, { ctrlKey: true })
expect(editor.getShape(ids.line1)).toMatchObject({ x: 120, y: 90 })
const { gapLines, pointLines } = getGapAndPointLines(editor.snaps.getLines()!)
expect(gapLines).toHaveLength(1)
expect(pointLines).toHaveLength(1)
expect(gapLines[0].gaps.length).toBe(2)
expect(pointLines[0].points.length).toBe(6)
})
it('shows vertical point snaps at the same time as horizontal gap snaps', () => {
// ┌─────┐ ┌─────┐
// │ │ │ │
// │ │ │ │
// │ │ │ │
// │ A │ │ B │
// │ │ ┌───┐ │ │
// │ ├──┼──┤ C ├──┼──┤ │ x
// │ │ └───┘ │ │ │
// └─────┘ └─────┘ │
// │
// ┌───────┐ │
// │ D │ x
// └───────┘
editor.createShapes([
{ type: 'geo', id: ids.box1, x: 0, y: 0, props: { w: 50, h: 100 } },
{ type: 'geo', id: ids.box2, x: 200, y: 0, props: { w: 50, h: 100 } },
{ type: 'geo', id: ids.line1, x: 50, y: 0, props: { w: 10, h: 10 } },
{ type: 'geo', id: ids.boxD, x: 75, y: 150, props: { w: 100, h: 10 } },
])
// the midpoint is 125 and c is 10 wide so it should snap to 120 if we put it at 121
editor.pointerDown(55, 5, ids.line1).pointerMove(126, 67, { ctrlKey: true })
expect(editor.getShape(ids.line1)).toMatchObject({ x: 120, y: 62 })
const { gapLines, pointLines } = getGapAndPointLines(editor.snaps.getLines()!)
expect(gapLines).toHaveLength(1)
expect(pointLines).toHaveLength(1)
expect(gapLines[0].gaps.length).toBe(2)
expect(pointLines[0].points.length).toBe(2)
})
it('snaps a shape vertically between two others', () => {
// ┌──────────────────────────┐
// │ │
// │ A │
// │ │
// └─────┬────────────────────┘
// │
// ─┼─
// │
// ┌─┴─┐
// │ C │
// └─┬─┘
// │
// ─┼─
// │
// ┌─────┴────────────────────┐
// │ │
// │ B │
// │ │
// └──────────────────────────┘
editor.createShapes([
{ type: 'geo', id: ids.box1, x: 0, y: 0, props: { w: 100, h: 50 } },
{ type: 'geo', id: ids.box2, x: 0, y: 200, props: { w: 100, h: 50 } },
{ type: 'geo', id: ids.line1, x: 50, y: 150, props: { w: 10, h: 10 } },
])
// the midpoint is 125 and c is 10 wide so it should snap to 120 if we put it at 121
editor.pointerDown(55, 155, ids.line1).pointerMove(27, 126, { ctrlKey: true })
expect(editor.getShape(ids.line1)).toMatchObject({ x: 22, y: 120 })
expect(editor.snaps.getLines()?.length).toBe(1)
assertGaps(editor.snaps.getLines()![0])
const { gapLines } = getGapAndPointLines(editor.snaps.getLines()!)
expect(gapLines[0].gaps.length).toBe(2)
})
it('shows vertical snap points at the same time as vertical gaps', () => {
// x ┌──────────────────────────┐
// │ │ │
// │ │ A │
// │ │ │
// x └─┬────────────────────────┘
// │ │
// │ ─┼─
// │ │
// x ┌─┴─┐
// │ │ C │
// x └─┬─┘
// │ │
// │ ─┼─
// │ │
// x ┌─┴────────────────────────┐
// │ │ │
// │ │ B │
// │ │ │
// x └──────────────────────────┘
editor.createShapes([
{ type: 'geo', id: ids.box1, x: 0, y: 0, props: { w: 100, h: 50 } },
{ type: 'geo', id: ids.box2, x: 0, y: 200, props: { w: 100, h: 50 } },
{ type: 'geo', id: ids.line1, x: 50, y: 150, props: { w: 10, h: 10 } },
])
// the midpoint is 125 and c is 10 wide so it should snap to 120 if we put it at 121
editor.pointerDown(55, 155, ids.line1).pointerMove(6, 126, { ctrlKey: true })
expect(editor.getShape(ids.line1)).toMatchObject({ x: 0, y: 120 })
const { gapLines, pointLines } = getGapAndPointLines(editor.snaps.getLines()!)
expect(gapLines).toHaveLength(1)
expect(pointLines).toHaveLength(1)
expect(gapLines[0].gaps.length).toBe(2)
expect(pointLines[0].points.length).toBe(6)
})
it('shows horizontal snap points at the same time as vertical gaps', () => {
// ┌──────────────────────────┐
// │ │
// │ A │
// │ │
// └────┬─────────────────────┘
// │
// ─┼─ D┌───────────┐
// │ │ │
// C┌─┴─┐ │ │
// │ x─┼───────┼─────x │
// └─┬─┘ │ │
// │ │ │
// ─┼─ └───────────┘
// │
// ┌────┴─────────────────────┐
// │ │
// │ B │
// │ │
// └──────────────────────────┘
editor.createShapes([
{ type: 'geo', id: ids.box1, x: 0, y: 0, props: { w: 100, h: 50 } },
{ type: 'geo', id: ids.box2, x: 0, y: 200, props: { w: 100, h: 50 } },
{ type: 'geo', id: ids.line1, x: 50, y: 150, props: { w: 10, h: 10 } },
{ type: 'geo', id: ids.boxD, x: 50, y: 75, props: { w: 10, h: 100 } },
])
// the midpoint is 125 and c is 10 wide so it should snap to 120 if we put it at 121
editor.pointerDown(55, 155, ids.line1).pointerMove(27, 126, { ctrlKey: true })
expect(editor.getShape(ids.line1)).toMatchObject({ x: 22, y: 120 })
const { gapLines, pointLines } = getGapAndPointLines(editor.snaps.getLines()!)
expect(gapLines).toHaveLength(1)
expect(pointLines).toHaveLength(1)
expect(gapLines[0].gaps).toHaveLength(2)
expect(pointLines[0].points).toHaveLength(2)
})
it('can happen on multiple axes at the same time', () => {
// ┌──────────────────────────┐
// │ │
// │ A │
// ┌─────┐ │ ┌─────┐ │
// │ │ └─────┬─────────┼─────┼────┘
// │ │ │ │ │
// │ │ ─┼─ │ │
// │ D │ │ │ B │
// │ │ ┌─┴─┐ │ │
// │ ├───┼───┤ E ├───┼───┤ │
// │ │ └─┬─┘ │ │
// └─────┘ │ └─────┘
// ─┼─
// │
// ┌─────┴────────────────────┐
// │ │
// │ C │
// │ │
// └──────────────────────────┘
editor.createShapes([
{ type: 'geo', id: ids.box1, x: 50, y: 0, props: { w: 200, h: 50 } },
{ type: 'geo', id: ids.box2, x: 150, y: 50, props: { w: 50, h: 100 } },
{ type: 'geo', id: ids.line1, x: 50, y: 200, props: { w: 200, h: 50 } },
{ type: 'geo', id: ids.boxD, x: 0, y: 50, props: { w: 50, h: 100 } },
{ type: 'geo', id: ids.boxE, x: 0, y: 0, props: { w: 10, h: 10 } },
])
editor.pointerDown(5, 5, ids.boxE).pointerMove(101, 126, { ctrlKey: true })
expect(editor.getShape(ids.boxE)).toMatchObject({ x: 95, y: 120 })
expect(editor.snaps.getLines()?.length).toBe(2)
assertGaps(editor.snaps.getLines()![0])
assertGaps(editor.snaps.getLines()![1])
const { gapLines } = getGapAndPointLines(editor.snaps.getLines()!)
expect(gapLines[0].gaps.length).toBe(2)
expect(gapLines[1].gaps.length).toBe(2)
})
it('will expand a horizontal and vertical selections outwards if possible', () => {
// ┌───┐
// │ E │
// └─┬─┘
// ┼
// ┌─┴─┐
// │ F │
// └─┬─┘
// ┼
// ┌───┐ ┌───┐ ┌─┴─┐ ┌───┐ ┌───┐
// │ A ├─┼─┤ B ├─┼─┤ X ├─┼─┤ C ├─┼─┤ D │
// └───┘ └───┘ └─┬─┘ └───┘ └───┘
// ┼
// ┌─┴─┐
// │ G │
// └─┬─┘
// ┼
// ┌─┴─┐
// │ H │
// └───┘
// dragging X
editor.createShapes([
box(ids.box1, 0, 40),
box(ids.box2, 20, 40),
box(ids.line1, 60, 40),
box(ids.boxD, 80, 40),
box(ids.boxE, 40, 0),
box(ids.boxF, 40, 20),
box(ids.boxG, 40, 60),
box(ids.boxH, 40, 80),
box(ids.boxX, 0, 0),
])
editor.pointerDown(5, 5, ids.boxX).pointerMove(46, 46, { ctrlKey: true })
expect(editor.getShape(ids.boxX)).toMatchObject({ x: 40, y: 40 })
const { gapLines, pointLines } = getGapAndPointLines(editor.snaps.getLines()!)
expect(gapLines).toHaveLength(2)
expect(gapLines[0].gaps).toHaveLength(4)
expect(gapLines[1].gaps).toHaveLength(4)
// it should also have snap lines for all the edge/center alignments
expect(pointLines).toHaveLength(6)
})
it('will show multiple non-overlapping snap-betweens on the same axis', () => {
// ┌─────┐ ┌─────┐
// │ A │ │ B │
// └──┬──┘ └──┬──┘
// ┼ ┼
// ┌──┴─────────┴──┐
// │ X drag │
// └──┬─────────┬──┘
// ┼ ┼
// ┌──┴──┐ ┌──┴──┐
// │ C │ │ D │
// └─────┘ └─────┘
editor.createShapes([
box(ids.box1, 0, 0),
box(ids.box2, 20, 0),
box(ids.line1, 0, 40),
box(ids.boxD, 20, 40),
box(ids.boxX, 50, 20, 30),
])
editor.pointerDown(65, 25, ids.boxX).pointerMove(16, 25, { ctrlKey: true })
expect(editor.getShape(ids.boxX)).toMatchObject({ x: 0, y: 20 })
const { gapLines, pointLines } = getGapAndPointLines(editor.snaps.getLines()!)
expect(gapLines).toHaveLength(2)
expect(gapLines[0].gaps).toHaveLength(2)
expect(gapLines[1].gaps).toHaveLength(2)
// check outer edge snaps too
expect(pointLines).toHaveLength(2)
expect(pointLines[0].points).toHaveLength(6)
expect(pointLines[1].points).toHaveLength(6)
})
it('should not snap horizontally if the shape is larger than the gap', () => {
// ┌─────┐ ┌─────┐
// │ │ │ │
// │ A │ │ B │
// │ │ │ │
// │ │ │ │
// ┌──────┼─────┼─────────────┼─────┼──────┐
// │ │ │ │ │ │
// │ │ │ X │ │ │ ◄─── drag
// │ │ │ │ │ │
// └──────┼─────┼─────────────┼─────┼──────┘
// │ │ │ │
// │ │ │ │
// │ │ │ │
// └─────┘ └─────┘
//
// no snap to center gap between A + B
editor.createShapes([
box(ids.box1, 20, 0, 10, 100),
box(ids.box2, 70, 0, 10, 100),
box(ids.boxX, 0, 50, 100, 10),
])
editor.pointerDown(50, 55, ids.boxX).pointerMove(51, 66, { ctrlKey: true })
expect(editor.getShape(ids.boxX)).toMatchObject({ x: 1, y: 61 })
expect(editor.snaps.getLines()?.length).toBe(0)
})
it('should work if the thing being dragged is a selection', () => {
// selection
// ┌─────────────────────────┐
// │ │ ┌────────┐
// ┌────────┐ │ ┌────────────┐ │ │ │
// │ │ │ │ │ │ │ │
// │ │ │ │ C │ │ │ │
// │ A ├───┼───┤ ┌────┐ └────────────┘ ├───┼───┤ B │
// │ │ │ │ │ │ │ │
// │ │ │ │ D │ │ │ │
// └────────┘ │ └────┘ │ └────────┘
// └─────────────────────────┘
editor.createShapes([
box(ids.box1, 0, 50, 50, 100),
box(ids.box2, 350, 0, 50, 100),
box(ids.line1, 200, 10, 100, 10),
box(ids.boxD, 100, 80, 10, 50),
])
editor.select(ids.line1, ids.boxD)
editor.pointerDown(200, 50, ids.line1).pointerMove(201, 61, { ctrlKey: true })
expect(editor.getShape(ids.line1)).toMatchObject({ x: 200, y: 21 })
const { gapLines, pointLines } = getGapAndPointLines(editor.snaps.getLines()!)
expect(gapLines).toHaveLength(1)
expect(pointLines).toHaveLength(0)
expect(gapLines[0].gaps).toHaveLength(2)
const sortedGaps = gapLines[0].gaps.sort((a, b) => a.startEdge[0].x - b.startEdge[0].x)
expect(sortedGaps[0].startEdge[0].x).toBeCloseTo(50)
expect(sortedGaps[0].endEdge[0].x).toBeCloseTo(100)
expect(sortedGaps[1].startEdge[0].x).toBeCloseTo(300)
expect(sortedGaps[1].endEdge[0].x).toBeCloseTo(350)
})
})
describe('Snap-next-to behavior', () => {
beforeEach(() => {
editor?.dispose()
})
it('snaps a shape to the left of two others, matching the gap size', () => {
// ┌───┐
// │ X │
// └───┘ ┌───┐ ┌───┐
// │ A │ │ B │
// └───┘ └───┘
// │
// │ drag x down
// ▼
//
// ┌───┐ ┌───┐ ┌───┐
// │ X ├────┼────┤ A ├────┼────┤ B │ *snap*
// └───┘ └───┘ └───┘
editor.createShapes([box(ids.boxX, 0, 0), box(ids.box1, 50, 10), box(ids.box2, 100, 10)])
editor.pointerDown(5, 5, ids.boxX).pointerMove(6, 16, { ctrlKey: true })
expect(editor.getShape(ids.boxX)).toMatchObject({ x: 0, y: 10 })
const { gapLines, pointLines } = getGapAndPointLines(editor.snaps.getLines()!)
expect(gapLines).toHaveLength(1)
expect(gapLines[0].gaps).toHaveLength(2)
// also check the outer edge snaps
expect(pointLines).toHaveLength(3)
})
it('expands the selection to the right for left snap-besides ', () => {
// ┌───┐
// │ X │
// └───┘ ┌───┐ ┌───┐ ┌───┐ ┌───┐
// │ A │ │ B │ │ C │ │ D │
// └───┘ └───┘ └───┘ └───┘
// │
// │ drag x down
// ▼
//
// ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐
// │ X ├────┼────┤ A ├────┼────┤ B ├────┼────┤ C ├────┼────┤ D │
// └───┘ └───┘ └───┘ └───┘ └───┘
//
// *snap*
//
editor.createShapes([
box(ids.boxX, 0, 0),
box(ids.box1, 50, 10),
box(ids.box2, 100, 10),
box(ids.line1, 150, 10),
box(ids.boxD, 200, 10),
])
editor.pointerDown(5, 5, ids.boxX).pointerMove(6, 16, { ctrlKey: true })
expect(editor.getShape(ids.boxX)).toMatchObject({ x: 0, y: 10 })
const { gapLines, pointLines } = getGapAndPointLines(editor.snaps.getLines()!)
expect(gapLines).toHaveLength(1)
expect(gapLines[0].gaps).toHaveLength(4)
// also check the outer edge snaps
expect(pointLines).toHaveLength(3)
})
it('snaps a shape to the right of two others, matching the gap size', () => {
// ┌───┐
// │ X │
// ┌───┐ ┌───┐ └───┘
// │ A │ │ B │
// └───┘ └───┘
// │
// │ drag X down
// ▼
//
// ┌───┐ ┌───┐ ┌───┐
// │ A ├────┼────┤ B ├────┼────┤ X │ *snap*
// └───┘ └───┘ └───┘
editor.createShapes([box(ids.box1, 0, 10), box(ids.box2, 50, 10), box(ids.boxX, 100, 0)])
editor.pointerDown(105, 5, ids.boxX).pointerMove(106, 16, { ctrlKey: true })
expect(editor.getShape(ids.boxX)).toMatchObject({ x: 100, y: 10 })
const { gapLines, pointLines } = getGapAndPointLines(editor.snaps.getLines()!)
expect(gapLines).toHaveLength(1)
expect(gapLines[0].gaps).toHaveLength(2)
// also check the outer edge snaps
expect(pointLines).toHaveLength(3)
})
it('expands the selection to the left for right snap-besides ', () => {
// ┌───┐
// │ X │
// ┌───┐ ┌───┐ ┌───┐ ┌───┐ └───┘
// │ A │ │ B │ │ C │ │ D │
// └───┘ └───┘ └───┘ └───┘
// │
// drag x down │
// ▼
//
// ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐
// │ A ├────┼────┤ B ├────┼────┤ C ├────┼────┤ D ├────┼────┤ x │
// └───┘ └───┘ └───┘ └───┘ └───┘
//
// *snap*
editor.createShapes([
box(ids.box1, 0, 10),
box(ids.box2, 50, 10),
box(ids.line1, 100, 10),
box(ids.boxD, 150, 10),
box(ids.boxX, 200, 0),
])
editor.pointerDown(205, 5, ids.boxX).pointerMove(206, 16, { ctrlKey: true })
expect(editor.getShape(ids.boxX)).toMatchObject({ x: 200, y: 10 })
const { gapLines, pointLines } = getGapAndPointLines(editor.snaps.getLines()!)
expect(gapLines).toHaveLength(1)
expect(gapLines[0].gaps).toHaveLength(4)
// also check the outer edge snaps
expect(pointLines).toHaveLength(3)
})
it('snaps a shape above two others, matching the gap size', () => {
// ┌───┐ ┌───┐
// │ X │ │ X │
// └───┘ └─┬─┘
// drag X ┼
// ┌───┐ ┌─┴─┐
// │ A │ ────► │ A │ *snap*
// └───┘ └─┬─┘
// ┼
// ┌───┐ ┌─┴─┐
// │ B │ │ B │
// └───┘ └───┘
editor.createShapes([box(ids.boxX, 0, 0), box(ids.box1, 10, 20), box(ids.box2, 10, 40)])
editor.pointerDown(5, 5, ids.boxX).pointerMove(16, 6, { ctrlKey: true })
expect(editor.getShape(ids.boxX)).toMatchObject({ x: 10, y: 0 })
const { gapLines, pointLines } = getGapAndPointLines(editor.snaps.getLines()!)
expect(gapLines).toHaveLength(1)
expect(gapLines[0].gaps).toHaveLength(2)
// also check the outer edge snaps
expect(pointLines).toHaveLength(3)
})
it('expands the selection downwards for top snap-besides ', () => {
// ┌───┐ ┌───┐
// │ X │ │ X │
// └───┘ └─┬─┘
// drag X ┼
// ┌───┐ ┌─┴─┐
// │ A │ ────► │ A │ *snap*
// └───┘ └─┬─┘
// ┼
// ┌───┐ ┌─┴─┐
// │ B │ │ B │
// └───┘ └─┬─┘
// ┼
// ┌───┐ ┌─┴─┐
// │ C │ │ C │
// └───┘ └─┬─┘
// ┼
// ┌───┐ ┌─┴─┐
// │ D │ │ D │
// └───┘ └───┘
editor.createShapes([
box(ids.boxX, 0, 0),
box(ids.box1, 10, 20),
box(ids.box2, 10, 40),
box(ids.line1, 10, 60),
box(ids.boxD, 10, 80),
])
editor.pointerDown(5, 5, ids.boxX).pointerMove(16, 6, { ctrlKey: true })
expect(editor.getShape(ids.boxX)).toMatchObject({ x: 10, y: 0 })
const { gapLines, pointLines } = getGapAndPointLines(editor.snaps.getLines()!)
expect(gapLines).toHaveLength(1)
expect(gapLines[0].gaps).toHaveLength(4)
// also check the outer edge snaps
expect(pointLines).toHaveLength(3)
})
it('snaps a shape below two others, matching the gap size', () => {
// ┌───┐ ┌───┐
// │ A │ │ A │
// └───┘ └─┬─┘
// ┼
// ┌───┐ ┌─┴─┐
// │ B │ │ B │
// └───┘ └─┬─┘
// ┼
// ┌───┐ drag X ┌─┴─┐ *snap*
// │ X │ │ X │
// └───┘ ────► └───┘
editor.createShapes([box(ids.box1, 10, 0), box(ids.box2, 10, 20), box(ids.boxX, 0, 40)])
editor.pointerDown(5, 45, ids.boxX).pointerMove(16, 46, { ctrlKey: true })
expect(editor.getShape(ids.boxX)).toMatchObject({ x: 10, y: 40 })
const { gapLines, pointLines } = getGapAndPointLines(editor.snaps.getLines()!)
expect(gapLines).toHaveLength(1)
expect(gapLines[0].gaps).toHaveLength(2)
// also check the outer edge snaps
expect(pointLines).toHaveLength(3)
})
it('expands the selection upwards for bottom snap-besides ', () => {
// ┌───┐ ┌───┐
// │ A │ │ A │
// └───┘ └─┬─┘
// ┼
// ┌───┐ ┌─┴─┐
// │ B │ │ B │
// └───┘ └─┬─┘
// ┼
// ┌───┐ ┌─┴─┐
// │ C │ │ C │
// └───┘ └─┬─┘
// ┼
// ┌───┐ ┌─┴─┐
// │ D │ │ D │
// └───┘ └─┬─┘
// ┼
// ┌───┐ drag X ┌─┴─┐ *snap*
// │ X │ │ X │
// └───┘ ────► └───┘
editor.createShapes([
box(ids.box1, 10, 0),
box(ids.box2, 10, 20),
box(ids.line1, 10, 40),
box(ids.boxD, 10, 60),
box(ids.boxX, 0, 80),
])
editor.pointerDown(5, 85, ids.boxX).pointerMove(16, 86, { ctrlKey: true })
expect(editor.getShape(ids.boxX)).toMatchObject({ x: 10, y: 80 })
const { gapLines, pointLines } = getGapAndPointLines(editor.snaps.getLines()!)
expect(gapLines).toHaveLength(1)
expect(gapLines[0].gaps).toHaveLength(4)
// also check the outer edge snaps
expect(pointLines).toHaveLength(3)
})
it('should work if the thing being dragged is a selection', () => {
// selection
// ┌─────────────────────────┐
// │ │
// ┌────────┐ ┌────────┐ │ ┌────────────┐ │
// │ │ │ │ │ │ C │ │
// │ │ │ │ │ │ │ │
// │ A ├───┼───┤ B ├───┼───┤ ┌────┐ └────────────┘ │
// │ │ │ │ │ │ D │ │
// │ │ │ │ │ │ │ │
// └────────┘ └────────┘ │ └────┘ │
// └─────────────────────────┘
editor.createShapes([
box(ids.box1, 0, 50, 50, 100),
box(ids.box2, 100, 50, 50, 100),
box(ids.line1, 300, 10, 100, 10),
box(ids.boxD, 200, 80, 10, 50),
])
editor.select(ids.line1, ids.boxD)
editor.pointerDown(300, 50, ids.line1).pointerMove(301, 101, { ctrlKey: true })
expect(editor.getShape(ids.boxD)).toMatchObject({ x: 200, y: 131 })
const { gapLines, pointLines } = getGapAndPointLines(editor.snaps.getLines()!)
expect(gapLines).toHaveLength(1)
expect(pointLines).toHaveLength(0)
expect(gapLines[0].gaps).toHaveLength(2)
const sortedGaps = gapLines[0].gaps.sort((a, b) => a.startEdge[0].x - b.startEdge[0].x)
expect(sortedGaps[0].startEdge[0].x).toBeCloseTo(50)
expect(sortedGaps[0].endEdge[0].x).toBeCloseTo(100)
expect(sortedGaps[1].startEdge[0].x).toBeCloseTo(150)
expect(sortedGaps[1].endEdge[0].x).toBeCloseTo(200)
})
})
describe('translating while the grid is enabled', () => {
it('does not snap to the grid', () => {
// 0 20 50 70
// ┌───┐ ┌───┐
// │ A │ │ B │
// └───┘ └───┘
editor.createShapes([box(ids.box1, 0, 0, 20, 20), box(ids.box2, 50, 0, 20, 20)])
editor.updateInstanceState({ isGridMode: true })
// try to snap A to B
// doesn't work because of the grid
// 0 20 50 70
// ┌───┬┬───┐
// │ A ││ B │
// └───┴┴───┘
editor.select(ids.box1).pointerDown(10, 10, ids.box1).pointerMove(39, 10)
// rounds to nearest 10
expect(editor.getShapePageBounds(ids.box1)!.x).toEqual(30)
// engage snap mode and it should indeed snap to B
// 0 20 50 70
// ┌───┬───┐
// │ A │ B │
// └───┴───┘
editor.keyDown('Control')
expect(editor.getShapePageBounds(ids.box1)!.x).toEqual(30)
// and we can move the box anywhere if there are no snaps nearby
editor.pointerMove(-19, -32, { ctrlKey: true })
expect(editor.getShapePageBounds(ids.box1)!).toMatchObject({ x: -29, y: -42 })
})
})
describe('snap lines', () => {
it('should show up for all matching snaps, even if the axis is locked', () => {
// 0 60 200
//
// ┌─────────────┐ ┌─────────────┐
// │ A │ │ B │
// │ │ │ │
// ◄──────── │ │ │ │
// │ │ │ │
// │ │ │ │
// 100 └─────────────┘ └─────────────┘
//
// hold shift and
// drag A left to C
//
// 200 ┌─────────────┐
// │ C │
// │ │
// │ │
// │ │
// │ │
// └─────────────┘
//
//
// ────────────────────────────────────────────────────────
//
//
// 0 *snap* 100 200
//
// x─────────────x──────────────────x─────────────x
// │ A │ │ B │
// │ │ │ │
// │ x──────┼──────────────────┼──────x │
// │ │ │ │ │
// │ │ │ │ │
// 100 x──────┼──────x──────────────────x─────────────x
// │ │ │
// │ │ │
// │ │ │
// │ │ │
// │ │ │
// 200 x──────┼──────x
// │ C │ │
// │ │ │
// │ x │
// │ │
// │ │
// x─────────────x
editor.createShapes([
box(ids.box1, 60, 0, 100, 100),
box(ids.box2, 200, 0, 100, 100),
box(ids.line1, 0, 200, 100, 100),
])
editor
.select(ids.box1)
.pointerDown(110, 50, ids.box1)
.pointerMove(49, 52, { shiftKey: true, ctrlKey: true })
expect(editor.getShape(ids.box1)).toMatchObject({
x: 0,
y: 0,
props: { w: 100, h: 100 },
})
expect(getSnapLines(editor)).toMatchInlineSnapshot(`
[
"0,0 0,100 0,200 0,300",
"0,0 100,0 200,0 300,0",
"0,100 100,100 200,100 300,100",
"100,0 100,100 100,200 100,300",
"50,50 250,50",
"50,50 50,250",
]
`)
})
})
describe('translating a shape with a child', () => {
it('should not snap to the child', () => {
// 0 1 11 50
// ┌───────────────────┐
// │ ┌───┐ │
// │ │ B │ │
// │ └───┘ │
// │ │
// │ A │
// │ │
// │ │
// │ │
// └───────────────────┘
editor.createShapes([box(ids.box1, 0, 0, 50, 50), box(ids.box2, 1, 1)])
editor.updateShapes([{ id: ids.box2, type: 'geo', parentId: ids.box1 }])
editor.pointerDown(25, 25, ids.box1).pointerMove(50, 25, { ctrlKey: true })
expect(editor.snaps.getLines()?.length).toBe(0)
expect(editor.getShape(ids.box1)).toMatchObject({
x: 25,
y: 0,
props: { w: 50, h: 50 },
})
expect(editor.getShape(ids.box2)).toMatchObject({ x: 1, y: 1, props: { w: 10, h: 10 } })
expect(editor.getShapePageBounds(ids.box2)).toMatchObject({
x: 26,
y: 1,
w: 10,
h: 10,
})
})
})
describe('translating a shape with a bound shape', () => {
it('should not snap to arrows', () => {
// 100 200
// ┌───────────────────┐
// │ ┌───┐ ┌───┐ │
// │ │ A │ ---> │ B │ │
// │ └───┘ └───┘ │
// └───────────────────┘
editor.createShapes([box(ids.box1, 0, 0, 100, 100), box(ids.box2, 200, 0, 100, 100)])
// Create an arrow starting within the first box and ending within the second box
editor.setCurrentTool('arrow').pointerDown(50, 50).pointerMove(250, 50).pointerUp()
// 100 200
// ┌───────────────────┐
// │ ┌───┐ │
// │ , │ B │ │
// │ ┌───┐ └───┘ │
// | │ A │ |
// | └───┘ |
// └───────────────────┘
expect(editor.getShape(editor.getSelectedShapeIds()[0])?.type).toBe('arrow')
editor.pointerDown(50, 50, ids.box1).pointerMove(84, 110, { ctrlKey: true })
expect(editor.snaps.getLines().length).toBe(0)
})
it('should preserve arrow bindings', () => {
const arrow1 = createShapeId('arrow1')
editor.createShapes([
{ id: ids.box1, type: 'geo', x: 100, y: 100, props: { w: 100, h: 100 } },
{ id: ids.box2, type: 'geo', x: 300, y: 300, props: { w: 100, h: 100 } },
{
id: arrow1,
type: 'arrow',
x: 150,
y: 150,
props: {
start: {
type: 'binding',
isExact: false,
boundShapeId: ids.box1,
normalizedAnchor: { x: 0.5, y: 0.5 },
isPrecise: false,
},
end: {
type: 'binding',
isExact: false,
boundShapeId: ids.box2,
normalizedAnchor: { x: 0.5, y: 0.5 },
isPrecise: false,
},
},
},
])
editor.select(ids.box1, arrow1)
editor.pointerDown(150, 150, ids.box1).pointerMove(0, 0)
expect(editor.getShape(ids.box1)).toMatchObject({ x: -50, y: -50 })
expect(editor.getShape(arrow1)).toMatchObject({
props: { start: { type: 'binding' }, end: { type: 'binding' } },
})
})
it('breaks arrow bindings when cloning', () => {
const arrow1 = createShapeId('arrow1')
editor.createShapes([
{ id: ids.box1, type: 'geo', x: 100, y: 100, props: { w: 100, h: 100 } },
{ id: ids.box2, type: 'geo', x: 300, y: 300, props: { w: 100, h: 100 } },
{
id: arrow1,
type: 'arrow',
x: 150,
y: 150,
props: {
start: {
type: 'binding',
isExact: false,
boundShapeId: ids.box1,
normalizedAnchor: { x: 0.5, y: 0.5 },
isPrecise: false,
},
end: {
type: 'binding',
isExact: false,
boundShapeId: ids.box2,
normalizedAnchor: { x: 0.5, y: 0.5 },
isPrecise: false,
},
},
},
])
editor.select(ids.box1, arrow1)
editor.pointerDown(150, 150, ids.box1).pointerMove(0, 0, { altKey: true })
expect(editor.getShape(ids.box1)).toMatchObject({ x: 100, y: 100 })
expect(editor.getShape(arrow1)).toMatchObject({
props: { start: { type: 'binding' }, end: { type: 'binding' } },
})
const newArrow = editor
.getCurrentPageShapes()
.find((s) => editor.isShapeOfType<TLArrowShape>(s, 'arrow') && s.id !== arrow1)
expect(newArrow).toMatchObject({
props: { start: { type: 'binding' }, end: { type: 'point' } },
})
})
})
describe('When dragging a shape onto a parent', () => {
it('reparents the shape', () => {
editor.createShapes([
{
id: ids.frame1,
type: 'frame',
x: 0,
y: 0,
props: {
w: 200,
h: 200,
},
},
{
id: ids.box1,
type: 'geo',
x: 500,
y: 500,
props: {
w: 100,
h: 100,
},
},
])
editor.pointerDown(550, 550, ids.box1).pointerMove(100, 100).pointerUp()
expect(editor.getShape(ids.box1)?.parentId).toBe(ids.frame1)
})
it('does not reparent the shape when the parent is clipped', () => {
editor.createShapes([
{
id: ids.frame1,
type: 'frame',
x: 0,
y: 0,
props: {
w: 200,
h: 200,
},
},
{
id: ids.frame2,
type: 'frame',
x: 200,
y: 200,
props: {
w: 500,
h: 500,
},
},
{
id: ids.box1,
type: 'geo',
x: 500,
y: 500,
props: {
w: 100,
h: 100,
},
},
])
// drop the frame2 onto frame 1
editor.reparentShapes([ids.frame2], ids.frame1)
expect(editor.getShape(ids.frame2)?.parentId).toBe(ids.frame1)
// drop box1 onto the CLIPPED part of frame2
editor.pointerDown(550, 550, ids.box1).pointerMove(350, 350).pointerUp()
// It should not become the child of frame2 because it is clipped
expect(editor.getShape(ids.box1)?.parentId).toBe(editor.getCurrentPageId())
})
})
describe('When dragging shapes', () => {
it('should drag and undo and redo', () => {
editor.deleteShapes(editor.getCurrentPageShapes())
editor.setCurrentTool('arrow').pointerMove(0, 0).pointerDown().pointerMove(100, 100).pointerUp()
editor.expectShapeToMatch({
id: editor.getCurrentPageShapes()[0]!.id,
x: 0,
y: 0,
})
editor.setCurrentTool('geo').pointerMove(-10, 100).pointerDown().pointerUp()
editor.expectShapeToMatch({
id: editor.getCurrentPageShapes()[1]!.id,
x: -110,
y: 0,
})
editor
.selectAll()
.pointerMove(50, 50)
.pointerDown()
.pointerMove(100, 50)
.pointerUp()
.expectShapeToMatch({
id: editor.getCurrentPageShapes()[0]!.id,
x: 50, // 50 to the right
y: 0,
})
.expectShapeToMatch({
id: editor.getCurrentPageShapes()[1]!.id,
x: -60, // 50 to the right
y: 0,
})
editor
.undo()
.expectShapeToMatch({
id: editor.getCurrentPageShapes()[0]!.id,
x: 0, // 50 to the right
y: 0,
})
.expectShapeToMatch({
id: editor.getCurrentPageShapes()[1]!.id,
x: -110, // 50 to the right
y: 0,
})
})
})
it('clones a single shape simply', () => {
editor
// create a note shape
.setCurrentTool('note')
.pointerMove(50, 50)
.click()
expect(editor.getOnlySelectedShape()).toBe(editor.getCurrentPageShapes()[0])
expect(editor.getHoveredShape()).toBe(editor.getCurrentPageShapes()[0])
// click on the canvas to deselect
editor.pointerMove(200, 50).click()
expect(editor.getOnlySelectedShape()).toBe(null)
expect(editor.getHoveredShape()).toBe(undefined)
// move back over the the shape
editor.pointerMove(50, 50)
expect(editor.getOnlySelectedShape()).toBe(null)
expect(editor.getHoveredShape()).toBe(editor.getCurrentPageShapes()[0])
// start dragging the shape
editor
.pointerDown()
.pointerMove(50, 500)
// start cloning
.keyDown('Alt')
// stop dragging
.pointerUp()
expect(editor.getCurrentPageShapes()).toHaveLength(2)
const [, sticky2] = editor.getCurrentPageShapes()
expect(editor.getOnlySelectedShape()).toBe(sticky2)
expect(editor.getEditingShape()).toBe(undefined)
expect(editor.getHoveredShape()).toBe(sticky2)
})