tldraw
Version:
A tiny little drawing editor.
1,553 lines (1,397 loc) • 149 kB
text/typescript
import {
GapsSnapIndicator,
PI,
PI2,
PointsSnapIndicator,
RotateCorner,
TLGeoShape,
TLSelectionHandle,
TLShapeId,
TLShapePartial,
TLTextShape,
Vec,
canonicalizeRotation,
createShapeId,
rotateSelectionHandle,
toRichText,
} from '@tldraw/editor'
import { NoteShapeUtil } from '../lib/shapes/note/NoteShapeUtil'
import { TestEditor } from './TestEditor'
import { getSnapLines } from './getSnapLines'
import { roundedBox } from './roundedBox'
jest.useFakeTimers()
const ORDERED_ROTATE_CORNERS: TLSelectionHandle[] = [
'top_left_rotate',
'top_right_rotate',
'bottom_right_rotate',
'bottom_left_rotate',
]
export function rotateRotateCorner(corner: RotateCorner, rotation: number): TLSelectionHandle {
// first find out how many 90deg we need to rotate by
rotation = rotation % PI2
const numSteps = Math.round(rotation / (PI / 2))
const currentIndex = ORDERED_ROTATE_CORNERS.indexOf(corner)
return ORDERED_ROTATE_CORNERS[(currentIndex + numSteps) % ORDERED_ROTATE_CORNERS.length]
}
const box = (id: TLShapeId, x: number, y: number, w = 10, h = 10): TLShapePartial => ({
type: 'geo',
id,
x,
y,
props: {
w,
h,
},
})
const roundedPageBounds = (shapeId: TLShapeId, accuracy = 0.01) => {
return roundedBox(editor.getShapePageBounds(shapeId)!, accuracy)
}
// 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 }
// }
let editor: TestEditor
afterEach(() => {
editor?.dispose()
})
const ids = {
boxA: createShapeId('boxA'),
boxB: createShapeId('boxB'),
boxC: createShapeId('boxC'),
boxD: createShapeId('boxD'),
boxX: createShapeId('boxX'),
lineA: createShapeId('lineA'),
iconA: createShapeId('iconA'),
}
beforeEach(() => {
editor = new TestEditor()
editor.createShapes([
{
id: ids.boxA,
type: 'geo',
x: 10,
y: 10,
props: {
w: 100,
h: 100,
},
},
{
id: ids.boxB,
type: 'geo',
parentId: ids.boxA,
x: 100,
y: 100,
props: {
w: 100,
h: 100,
},
},
{
id: ids.boxC,
type: 'geo',
parentId: ids.boxA,
x: 200,
y: 200,
props: {
w: 100,
h: 100,
},
},
])
})
describe('When pointing a resizer handle...', () => {
it('enters and exits the pointing_resize_handle state', () => {
editor
.select(ids.boxA)
.pointerDown(60, 60, {
target: 'selection',
handle: 'bottom_right',
})
.expectToBeIn('select.pointing_resize_handle')
.pointerUp()
.expectToBeIn('select.idle')
})
it('exits the pointing_resize_handle state when cancelled', () => {
editor
.select(ids.boxA)
.pointerDown(60, 60, {
target: 'selection',
handle: 'bottom_right',
})
.expectToBeIn('select.pointing_resize_handle')
.cancel()
.expectToBeIn('select.idle')
})
})
describe('When dragging a resize handle...', () => {
it('enters and exits the resizing state', () => {
editor
.select(ids.boxA)
.pointerDown(60, 60, {
target: 'selection',
handle: 'bottom_right',
})
.pointerMove(10, 10)
.expectToBeIn('select.resizing')
})
it('exits the resizing state on pointer up', () => {
editor
.select(ids.boxA)
.pointerDown(60, 60, {
target: 'selection',
handle: 'bottom_right',
})
.pointerMove(10, 10)
.pointerUp()
.expectToBeIn('select.idle')
})
it('exits the resizing state when cancelled', () => {
editor
.select(ids.boxA)
.pointerDown(60, 60, {
target: 'selection',
handle: 'bottom_right',
})
.pointerMove(10, 10)
.cancel()
.expectToBeIn('select.idle')
})
})
describe('When resizing...', () => {
it('Resizes a single shape from the top left', () => {
editor
.select(ids.boxA)
.pointerDown(10, 10, {
type: 'pointer',
target: 'selection',
handle: 'top_left',
})
.expectShapeToMatch({ id: ids.boxA, x: 10, y: 10, props: { w: 100, h: 100 } })
.pointerMove(0, 0)
.expectShapeToMatch({ id: ids.boxA, x: 0, y: 0, props: { w: 110, h: 110 } })
})
it('Resizes a single shape from the top right', () => {
editor
.select(ids.boxA)
.pointerDown(60, 10, {
target: 'selection',
handle: 'top_right',
})
.expectShapeToMatch({ id: ids.boxA, x: 10, y: 10, props: { w: 100, h: 100 } })
.pointerMove(70, 0)
.expectShapeToMatch({ id: ids.boxA, x: 10, y: 0, props: { w: 110, h: 110 } })
})
it('Resizes a single shape from the bottom right', () => {
editor
.select(ids.boxA)
.pointerDown(60, 60, {
target: 'selection',
handle: 'bottom_right',
})
.expectShapeToMatch({ id: ids.boxA, x: 10, y: 10, props: { w: 100, h: 100 } })
.pointerMove(70, 70)
.expectShapeToMatch({ id: ids.boxA, x: 10, y: 10, props: { w: 110, h: 110 } })
})
it('Resizes a single shape from the bottom left', () => {
editor
.select(ids.boxA)
.pointerDown(10, 60, {
target: 'selection',
handle: 'bottom_left',
})
.expectShapeToMatch({ id: ids.boxA, x: 10, y: 10, props: { w: 100, h: 100 } })
.pointerMove(0, 70)
.expectShapeToMatch({ id: ids.boxA, x: 0, y: 10, props: { w: 110, h: 110 } })
})
})
describe('When resizing a rotated shape...', () => {
it.each([
0,
Math.PI / 2,
// Math.PI / 4, Math.PI
])('Resizes a shape rotated %i from the top left', (rotation) => {
const offset = new Vec(10, 10)
// Rotate the shape by $rotation from its top left corner
editor.select(ids.boxA)
const initialPagePoint = editor.getShapePageTransform(ids.boxA)!.point()
const pt0 = Vec.From(initialPagePoint)
const pt1 = Vec.RotWith(initialPagePoint, editor.getSelectionPageBounds()!.center, rotation)
const pt2 = Vec.Sub(initialPagePoint, offset).rotWith(
editor.getSelectionPageBounds()!.center!,
rotation
)
editor
.pointerDown(pt0.x, pt0.y, {
target: 'selection',
handle: 'top_left_rotate',
})
.pointerMove(pt1.x, pt1.y)
.pointerUp()
// The shape's point should now be at pt1 (it rotates from the top left corner)
expect(editor.getShapePageTransform(ids.boxA)!.rotation()).toBeCloseTo(rotation)
expect(editor.getShapePageTransform(ids.boxA)!.point()).toCloselyMatchObject(pt1)
// Resize by moving the top left resize handle to pt2. Should be a delta of [10, 10].
expect(Vec.Dist(editor.getShapePageTransform(ids.boxA)!.point(), pt2)).toBeCloseTo(offset.len())
editor
.pointerDown(pt1.x, pt1.y, {
target: 'selection',
handle: 'top_left',
})
.pointerMove(pt2.x, pt2.y)
.pointerUp()
// The shape should have moved its point to pt2 and be delta bigger.
expect(editor.getShapePageTransform(ids.boxA)!.point()).toCloselyMatchObject(pt2)
editor.expectShapeToMatch({ id: ids.boxA, props: { w: 110, h: 110 } })
})
})
describe('When resizing mulitple shapes...', () => {
it.each([
[0, 0, 0, 0],
[10, 10, 0, 0],
[0, 0, Math.PI, 0],
[10, 10, 0, Math.PI / 4],
])(
'Resizes B and C when: \n\tA = { x: %s, y: %s, rotation: %s }\n\tB = { rotation: %s }',
(x, y, rotation, rotationB) => {
const shapeA = editor.getShape(ids.boxA)!
const shapeB = editor.getShape(ids.boxB)!
const shapeC = editor.getShape(ids.boxC)!
editor.updateShapes([
{
id: ids.boxA,
type: 'geo',
x,
y,
rotation,
},
{
id: ids.boxB,
parentId: ids.boxA,
type: 'geo',
x: 100,
y: 100,
rotation: rotationB,
},
{
id: ids.boxC,
parentId: ids.boxA,
type: 'geo',
x: 200,
y: 200,
rotation: rotationB,
},
])
// Rotate the shape by $rotation from its top left corner
const rotateStart = editor.getShapePageTransform(ids.boxA)!.point()
const rotateCenter = editor.getPageCenter(shapeA)!
const rotateEnd = Vec.RotWith(rotateStart, rotateCenter, rotation)
editor
.select(ids.boxA)
.pointerDown(rotateStart.x, rotateStart.y, {
target: 'selection',
handle: rotateRotateCorner('top_left_rotate', -editor.getSelectionRotation()),
})
.pointerMove(rotateEnd.x, rotateEnd.y)
.pointerUp()
expect(canonicalizeRotation(shapeA.rotation) % Math.PI).toBeCloseTo(
canonicalizeRotation(rotation) % Math.PI
)
expect(editor.getPageRotation(shapeB)).toBeCloseTo(rotation + rotationB)
expect(editor.getPageRotation(shapeC)).toBeCloseTo(rotation + rotationB)
editor.select(ids.boxB, ids.boxC)
// Now drag to resize the selection bounds
const initialBounds = editor.getSelectionPageBounds()!
// oddly rotated shapes maintain aspect ratio when being resized (for now)
const aspectRatio = initialBounds.width / initialBounds.height
const offsetX = initialBounds.width + 200
const offset = new Vec(offsetX, offsetX / aspectRatio)
const resizeStart = initialBounds.point
const resizeEnd = Vec.Sub(resizeStart, offset)
expect(Vec.Dist(resizeStart, resizeEnd)).toBeCloseTo(offset.len())
expect(
Vec.Min(editor.getShapePageBounds(shapeB)!.point, editor.getShapePageBounds(shapeC)!.point)
).toCloselyMatchObject(resizeStart)
editor
.pointerDown(resizeStart.x, resizeStart.y, {
target: 'selection',
handle: rotateSelectionHandle('top_left', -editor.getSelectionRotation()),
})
.pointerMove(resizeStart.x - 10, resizeStart.y - 10)
.pointerMove(resizeEnd.x, resizeEnd.y)
.pointerUp()
expect(editor.getSelectionPageBounds()!.point).toCloselyMatchObject(resizeEnd)
expect(new Vec(initialBounds.maxX, initialBounds.maxY)).toCloselyMatchObject(
new Vec(editor.getSelectionPageBounds()!.maxX, editor.getSelectionPageBounds()!.maxY)
)
}
)
})
describe('Reisizing a selection of multiple shapes', () => {
beforeEach(() => {
// 0 10 20 30
//
// ┌──────────┐
// │ │
// │ │
// │ A │
// │ │
// │ │
// 10 └──────────┘
//
//
//
//
// 20 ┌──────────┐
// │ │
// │ │
// │ B │
// │ │
// │ │
// 30 └──────────┘
editor.createShapes([box(ids.boxA, 0, 0), box(ids.boxB, 20, 20)])
})
it('works correctly when the shapes are not rotated', () => {
editor.select(ids.boxA, ids.boxB)
// shrink
// 0 15
// ┌──────────────────┐
// │ ┌───┐ │
// │ │ A │ │
// │ └───┘ │
// │ │
// │ ┌───┐ │
// │ │ B │ │
// │ └───┘ │
// └──────────────────O
editor.pointerDown(30, 30, { target: 'selection', handle: 'bottom_right' })
editor.pointerMove(15, 15)
expect(roundedBox(editor.getSelectionPageBounds()!)).toMatchObject({ w: 15, h: 15 })
expect(roundedPageBounds(ids.boxA)).toMatchObject({ x: 0, y: 0, w: 5, h: 5 })
expect(roundedPageBounds(ids.boxB)).toMatchObject({ x: 10, y: 10, w: 5, h: 5 })
// strech horizontally
// 0 20 40 60
//
// ┌──────────────────────────────────────────────────────────────────┐
// │ ┌───────────────────────┐ │
// │ │ │ │
// │ │ A │ │
// │ │ │ │
// │ └───────────────────────┘ │
// │ │
// │ │
// │ ┌───────────────────────┐ │
// │ │ │ │
// │ │ B │ │
// │ │ │ │
// │ └───────────────────────┘ │
// └──────────────────────────────────────────────────────────────────O
editor.pointerMove(60, 30)
expect(roundedBox(editor.getSelectionPageBounds()!)).toMatchObject({ w: 60, h: 30 })
expect(roundedPageBounds(ids.boxA)).toMatchObject({ x: 0, y: 0, w: 20, h: 10 })
expect(roundedPageBounds(ids.boxB)).toMatchObject({ x: 40, y: 20, w: 20, h: 10 })
// stretch vertically
// 0 10 20 30
// ┌─────────────────────────────────┐
// │ ┌──────────┐ │
// │ │ │ │
// │ │ │ │
// │ │ │ │
// │ │ │ │
// │ │ A │ │
// │ │ │ │
// │ │ │ │
// │ │ │ │
// 20 │ └──────────┘ │
// │ │
// │ │
// │ │
// │ │
// │ │
// │ │
// │ │
// 40 │ ┌──────────┐ │
// │ │ │ │
// │ │ │ │
// │ │ │ │
// │ │ B │ │
// │ │ │ │
// │ │ │ │
// │ │ │ │
// │ │ │ │
// 60 │ └──────────┘ │
// └─────────────────────────────────O
editor.pointerMove(30, 60)
expect(roundedBox(editor.getSelectionPageBounds()!)).toMatchObject({ w: 30, h: 60 })
expect(roundedPageBounds(ids.boxA)).toMatchObject({ x: 0, y: 0, w: 10, h: 20 })
expect(roundedPageBounds(ids.boxB)).toMatchObject({ x: 20, y: 40, w: 10, h: 20 })
// invert + shrink
// -15 0
// O───────────────┐
// │ ┌───┐ │
// │ │ B │ │
// │ └───┘ │
// │ │
// │ ┌───┐ │
// │ │ A │ │
// │ └───┘ │
// └───────────────┘
editor.pointerMove(-15, -15)
expect(roundedBox(editor.getSelectionPageBounds()!)).toMatchObject({ w: 15, h: 15 })
expect(roundedPageBounds(ids.boxA)).toMatchObject({ x: -5, y: -5, w: 5, h: 5 })
expect(roundedPageBounds(ids.boxB)).toMatchObject({ x: -15, y: -15, w: 5, h: 5 })
// resize from center
// -15 5 15 25 45
// ┌───────────────────────────────────┐
// │ ┌──────────┐ │
// │ │ │ │
// │ │ A │ │
// │ │ │ │
// │ └──────────┘ │
// │ │
// │ x │
// │ │
// │ ┌──────────┐ │
// │ │ │ │
// │ │ B │ │
// │ │ │ │
// │ └──────────┘ │
// └───────────────────────────────────O
editor.pointerMove(45, 45, { altKey: true })
expect(roundedBox(editor.getSelectionPageBounds()!)).toMatchObject({
w: 60,
h: 60,
x: -15,
y: -15,
})
expect(roundedPageBounds(ids.boxA)).toMatchObject({ x: -15, y: -15, w: 20, h: 20 })
expect(roundedPageBounds(ids.boxB)).toMatchObject({ x: 25, y: 25, w: 20, h: 20 })
// resize with aspect ratio locked
// 0 15
// ┌──────────────────┐
// │ ┌───┐ │
// │ │ A │ │
// │ └───┘ │
// │ │ <- mouse is here
// │ ┌───┐ │
// │ │ B │ │
// │ └───┘ │
// └──────────────────O
editor.pointerMove(15, 8, { altKey: false, shiftKey: true })
jest.advanceTimersByTime(200)
expect(roundedBox(editor.getSelectionPageBounds()!)).toMatchObject({ w: 15, h: 15 })
expect(roundedPageBounds(ids.boxA)).toMatchObject({ x: 0, y: 0, w: 5, h: 5 })
expect(roundedPageBounds(ids.boxB)).toMatchObject({ x: 10, y: 10, w: 5, h: 5 })
// resize from center with aspect ratio locked
// -15 5 15 25 45
// ┌───────────────────────────────────┐
// │ ┌──────────┐ │
// │ │ │ │
// │ │ A │ │
// │ │ │ │
// │ └──────────┘ │
// │ │
// │ x │ <- mouse is here
// │ │
// │ ┌──────────┐ │
// │ │ │ │
// │ │ B │ │
// │ │ │ │
// │ └──────────┘ │
// └───────────────────────────────────O
editor.pointerMove(45, 16, { altKey: true, shiftKey: true })
expect(roundedBox(editor.getSelectionPageBounds()!)).toMatchObject({
w: 60,
h: 60,
x: -15,
y: -15,
})
expect(roundedPageBounds(ids.boxA)).toMatchObject({ x: -15, y: -15, w: 20, h: 20 })
expect(roundedPageBounds(ids.boxB)).toMatchObject({ x: 25, y: 25, w: 20, h: 20 })
})
it('works the same when shapes are rotated by a multiple of 90 degrees', () => {
// rotate A by 90 degrees
editor.select(ids.boxA)
editor.pointerDown(0, 0, { target: 'selection', handle: 'top_left_rotate' })
editor.pointerMove(10, 0, { shiftKey: true })
editor.pointerUp(10, 0, { shiftKey: false })
expect(editor.getShape(ids.boxA)!.rotation).toBeCloseTo(PI / 2)
// rotate B by -90 degrees
editor.select(ids.boxB)
editor.pointerDown(30, 20, { target: 'selection', handle: 'top_left_rotate' })
editor.pointerMove(20, 20, { shiftKey: true })
editor.pointerUp(20, 20, { shiftKey: false })
jest.advanceTimersByTime(200)
expect(editor.getShape(ids.boxB)!.rotation).toBeCloseTo(canonicalizeRotation(-PI / 2))
editor.select(ids.boxA, ids.boxB)
// shrink
// 0 15
// ┌──────────────────┐
// │ ┌───┐ │
// │ │ A │ │
// │ └───┘ │
// │ │
// │ ┌───┐ │
// │ │ B │ │
// │ └───┘ │
// └──────────────────O
editor.pointerDown(30, 30, {
target: 'selection',
handle: rotateSelectionHandle('bottom_right', -editor.getSelectionRotation()),
})
editor.pointerMove(15, 15)
expect(roundedPageBounds(ids.boxA)).toMatchObject({ x: 0, y: 0, w: 5, h: 5 })
expect(roundedPageBounds(ids.boxB)).toMatchObject({ x: 10, y: 10, w: 5, h: 5 })
expect(roundedBox(editor.getSelectionPageBounds()!)).toMatchObject({ w: 15, h: 15 })
// strech horizontally
// 0 20 40 60
//
// ┌──────────────────────────────────────────────────────────────────┐
// │ ┌───────────────────────┐ │
// │ │ │ │
// │ │ A │ │
// │ │ │ │
// │ └───────────────────────┘ │
// │ │
// │ │
// │ ┌───────────────────────┐ │
// │ │ │ │
// │ │ B │ │
// │ │ │ │
// │ └───────────────────────┘ │
// └──────────────────────────────────────────────────────────────────O
editor.pointerMove(60, 30)
expect(roundedBox(editor.getSelectionPageBounds()!)).toMatchObject({ w: 60, h: 30 })
expect(roundedPageBounds(ids.boxA)).toMatchObject({ x: 0, y: 0, w: 20, h: 10 })
expect(roundedPageBounds(ids.boxB)).toMatchObject({ x: 40, y: 20, w: 20, h: 10 })
// stretch vertically
// 0 10 20 30
// ┌─────────────────────────────────┐
// │ ┌──────────┐ │
// │ │ │ │
// │ │ │ │
// │ │ │ │
// │ │ │ │
// │ │ A │ │
// │ │ │ │
// │ │ │ │
// │ │ │ │
// 20 │ └──────────┘ │
// │ │
// │ │
// │ │
// │ │
// │ │
// │ │
// │ │
// 40 │ ┌──────────┐ │
// │ │ │ │
// │ │ │ │
// │ │ │ │
// │ │ B │ │
// │ │ │ │
// │ │ │ │
// │ │ │ │
// │ │ │ │
// 60 │ └──────────┘ │
// └─────────────────────────────────O
editor.pointerMove(30, 60)
expect(roundedBox(editor.getSelectionPageBounds()!)).toMatchObject({ w: 30, h: 60 })
expect(roundedPageBounds(ids.boxA)).toMatchObject({ x: 0, y: 0, w: 10, h: 20 })
expect(roundedPageBounds(ids.boxB)).toMatchObject({ x: 20, y: 40, w: 10, h: 20 })
// invert + shrink
// -15 0
// O───────────────┐
// │ ┌───┐ │
// │ │ B │ │
// │ └───┘ │
// │ │
// │ ┌───┐ │
// │ │ A │ │
// │ └───┘ │
// └───────────────┘
editor.pointerMove(-15, -15)
expect(roundedBox(editor.getSelectionPageBounds()!)).toMatchObject({ w: 15, h: 15 })
expect(roundedPageBounds(ids.boxA)).toMatchObject({ x: -5, y: -5, w: 5, h: 5 })
expect(roundedPageBounds(ids.boxB)).toMatchObject({ x: -15, y: -15, w: 5, h: 5 })
// resize from center
// -15 5 15 25 45
// ┌───────────────────────────────────┐
// │ ┌──────────┐ │
// │ │ │ │
// │ │ A │ │
// │ │ │ │
// │ └──────────┘ │
// │ │
// │ x │
// │ │
// │ ┌──────────┐ │
// │ │ │ │
// │ │ B │ │
// │ │ │ │
// │ └──────────┘ │
// └───────────────────────────────────O
editor.pointerMove(45, 45, { altKey: true })
expect(roundedBox(editor.getSelectionPageBounds()!)).toMatchObject({
w: 60,
h: 60,
x: -15,
y: -15,
})
expect(roundedPageBounds(ids.boxA)).toMatchObject({ x: -15, y: -15, w: 20, h: 20 })
expect(roundedPageBounds(ids.boxB)).toMatchObject({ x: 25, y: 25, w: 20, h: 20 })
// resize with aspect ratio locked
// 0 15
// ┌──────────────────┐
// │ ┌───┐ │
// │ │ A │ │
// │ └───┘ │
// │ │ <- mouse is here
// │ ┌───┐ │
// │ │ B │ │
// │ └───┘ │
// └──────────────────O
editor.pointerMove(15, 8, { altKey: false, shiftKey: true })
jest.advanceTimersByTime(200)
expect(roundedBox(editor.getSelectionPageBounds()!)).toMatchObject({ w: 15, h: 15 })
expect(roundedPageBounds(ids.boxA)).toMatchObject({ x: 0, y: 0, w: 5, h: 5 })
expect(roundedPageBounds(ids.boxB)).toMatchObject({ x: 10, y: 10, w: 5, h: 5 })
// resize from center with aspect ratio locked
// -15 5 15 25 45
// ┌───────────────────────────────────┐
// │ ┌──────────┐ │
// │ │ │ │
// │ │ A │ │
// │ │ │ │
// │ └──────────┘ │
// │ │
// │ x │ <- mouse is here
// │ │
// │ ┌──────────┐ │
// │ │ │ │
// │ │ B │ │
// │ │ │ │
// │ └──────────┘ │
// └───────────────────────────────────O
editor.pointerMove(45, 16, { altKey: true, shiftKey: true })
expect(roundedBox(editor.getSelectionPageBounds()!)).toMatchObject({
w: 60,
h: 60,
x: -15,
y: -15,
})
expect(roundedPageBounds(ids.boxA)).toMatchObject({ x: -15, y: -15, w: 20, h: 20 })
expect(roundedPageBounds(ids.boxB)).toMatchObject({ x: 25, y: 25, w: 20, h: 20 })
})
it('will not change the apsect ratio on shapes that have been rotated by some number that is not a multiple of 90 degrees', () => {
// rotate B a tiny bit
editor.select(ids.boxB)
editor.pointerDown(30, 20, { target: 'selection', handle: 'top_left_rotate' })
editor.pointerMove(30, 21)
editor.pointerUp(30, 21)
// strech horizontally
// 0 20 40 60
// ┌──────────────────────────────────────────────────────────────────┐
// │ ┌───────────────────────┐ │
// │ │ │ │
// │ │ A │ │
// │ │ │ │
// │ └───────────────────────┘ │
// │ │
// │ │
// │ ┌────────────┐ │
// │ │ │ │
// │ │ B │ │
// │ │ │ │
// │ └────────────┘ │
// └──────────────────────────────────────────────────────────────────O
editor.select(ids.boxA, ids.boxB)
editor.pointerDown(30, 30, { target: 'selection', handle: 'bottom_right' })
editor.pointerMove(60, 30)
expect(roundedBox(editor.getSelectionPageBounds()!)).toMatchObject({ w: 60, h: 30 })
// A should stretch
expect(roundedPageBounds(ids.boxA)).toMatchObject({ x: 0, y: 0, w: 20, h: 10 })
// B should not
expect(roundedPageBounds(ids.boxB)).toMatchObject({ w: 20, h: 10 })
})
})
describe('When resizing a shape with children', () => {
it("Offsets children when the shape's top left corner changes", () => {
editor
.updateShapes([
{
id: ids.boxC,
type: 'geo',
parentId: ids.boxB,
},
])
.select(ids.boxA)
.pointerDown(10, 10, {
target: 'selection',
handle: 'top_left',
})
.pointerMove(0, 0)
// A's model should have changed by the offset
.expectShapeToMatch({
id: ids.boxA,
x: 0,
y: 0,
})
// B's model should have changed by the offset
.expectShapeToMatch({
id: ids.boxB,
x: 110,
y: 110,
})
// C's model should also have changed
.expectShapeToMatch({
id: ids.boxC,
x: 220,
y: 220,
})
})
it('Offsets children when the shape is rotated', () => {
editor
.updateShapes([
{
id: ids.boxA,
type: 'geo',
rotation: Math.PI,
},
])
.select(ids.boxA)
.pointerDown(10, 10, {
target: 'selection',
handle: 'top_left',
})
.pointerMove(0, 0)
.expectToBeIn('select.resizing')
// A's model should have changed by the offset
.expectShapeToMatch({
id: ids.boxA,
x: 0,
y: 0,
})
// B's model should have changed by the offset
.expectShapeToMatch({
id: ids.boxB,
x: 90,
y: 90,
})
})
it('Resizes a rotated draw shape', () => {
editor
.updateShapes([
{
id: ids.boxA,
type: 'geo',
rotation: 0,
x: 10,
y: 10,
},
{
id: ids.boxB,
type: 'geo',
parentId: ids.boxA,
rotation: 0,
x: 0,
y: 0,
},
])
.createShapes([
{
id: ids.lineA,
parentId: ids.boxA,
rotation: Math.PI,
type: 'draw',
x: 100,
y: 100,
props: {
segments: [
{
type: 'free',
points: [
{ x: 0, y: 0, z: 0.5 },
{ x: 100, y: 100, z: 0.5 },
],
},
],
},
},
])
.select(ids.boxB, ids.lineA)
editor
.pointerDown(10, 10, {
target: 'selection',
handle: 'top_left',
})
.pointerMove(0, 0)
// .pointerMove(10, 10)
.expectToBeIn('select.resizing')
// A's model should have changed by the offset
.expectShapeToMatch({
id: ids.boxB,
x: -10,
y: -10,
})
// B's model should have changed by the offset
expect(editor.getShape(ids.lineA)).toMatchSnapshot('draw shape after rotating')
})
})
function getGapAndPointLines() {
const gapLines = editor.snaps
.getIndicators()
.filter((snap) => snap.type === 'gaps') as GapsSnapIndicator[]
const pointLines = editor.snaps
.getIndicators()
.filter((snap) => snap.type === 'points') as PointsSnapIndicator[]
return { gapLines, pointLines }
}
describe('snapping while resizing', () => {
beforeEach(() => {
// 0 40 60 160 180
//
// 0 ┌────────────┐
// │ A │
// 40 └────────────┘
//
// 60 ┌──┐ 80 140 ┌──┐
// │D │ 80 ┌──────┐ │B │
// │ │ │ │ │ │
// │ │ │ X │ │ │
// │ │ │ │ │ │
// │ │ 140 └──────┘ │ │
// 160 └──┘ └──┘
//
// 180 ┌────────────┐
// │ C │
// └────────────┘
editor.createShapes([
box(ids.boxA, 60, 0, 100, 40),
box(ids.boxB, 180, 60, 40, 100),
box(ids.boxC, 60, 180, 100, 40),
box(ids.boxD, 0, 60, 40, 100),
box(ids.boxX, 80, 80, 60, 60),
])
})
it('works for dragging the top edge', () => {
// snap to top edges of D and B
editor
.select(ids.boxX)
.pointerDown(115, 80, {
target: 'selection',
handle: 'top',
})
.pointerMove(115, 59, { ctrlKey: true })
expect(editor.getShape(ids.boxX)).toMatchObject({ x: 80, y: 60, props: { w: 60, h: 80 } })
expect(editor.snaps.getIndicators().length).toBe(1)
// moving the mouse horizontally should not change things
editor.pointerMove(15, 65, { ctrlKey: true })
expect(editor.getShape(ids.boxX)).toMatchObject({ x: 80, y: 60, props: { w: 60, h: 80 } })
expect(editor.snaps.getIndicators().length).toBe(1)
expect(getGapAndPointLines().pointLines[0].points).toHaveLength(6)
// snap to bottom edge of A
editor.pointerMove(15, 43, { ctrlKey: true })
expect(editor.getShape(ids.boxX)).toMatchObject({ x: 80, y: 40, props: { w: 60, h: 100 } })
expect(editor.snaps.getIndicators().length).toBe(1)
expect(getGapAndPointLines().pointLines[0].points).toHaveLength(4)
})
it('works for dragging the right edge', () => {
// Snap to right edges of A and C
editor
.select(ids.boxX)
.pointerDown(140, 115, {
target: 'selection',
handle: 'right',
})
.pointerMove(156, 115, { ctrlKey: true })
expect(editor.getShape(ids.boxX)).toMatchObject({ x: 80, y: 80, props: { w: 80, h: 60 } })
expect(editor.snaps.getIndicators().length).toBe(1)
expect(getGapAndPointLines().pointLines[0].points).toHaveLength(6)
// moving the mouse vertically should not change things
editor.pointerMove(156, 180, { ctrlKey: true })
expect(editor.getShape(ids.boxX)).toMatchObject({ x: 80, y: 80, props: { w: 80, h: 60 } })
// snap to left edge of B
editor.pointerMove(173, 280, { ctrlKey: true })
expect(editor.getShape(ids.boxX)).toMatchObject({ x: 80, y: 80, props: { w: 100, h: 60 } })
expect(editor.snaps.getIndicators().length).toBe(1)
expect(getGapAndPointLines().pointLines[0].points).toHaveLength(4)
})
it('works for dragging the bottom edge', () => {
// snap to bottom edges of B and D
editor
.select(ids.boxX)
.pointerDown(115, 140, {
target: 'selection',
handle: 'bottom',
})
.pointerMove(115, 159, { ctrlKey: true })
expect(editor.getShape(ids.boxX)).toMatchObject({ x: 80, y: 80, props: { w: 60, h: 80 } })
expect(editor.snaps.getIndicators().length).toBe(1)
expect(getGapAndPointLines().pointLines[0].points).toHaveLength(6)
// changing horzontal mouse position should not change things
editor.pointerMove(315, 163, { ctrlKey: true })
expect(editor.getShape(ids.boxX)).toMatchObject({ x: 80, y: 80, props: { w: 60, h: 80 } })
expect(editor.snaps.getIndicators().length).toBe(1)
// snap to top edge of C
editor.pointerMove(115, 183, { ctrlKey: true })
expect(editor.getShape(ids.boxX)).toMatchObject({ x: 80, y: 80, props: { w: 60, h: 100 } })
expect(editor.snaps.getIndicators().length).toBe(1)
expect(getGapAndPointLines().pointLines[0].points).toHaveLength(4)
})
it('works for dragging the left edge', () => {
// snap to left edges of A and C
editor
.select(ids.boxX)
.pointerDown(80, 115, {
target: 'selection',
handle: 'left',
})
.pointerMove(59, 115, { ctrlKey: true })
expect(editor.getShape(ids.boxX)).toMatchObject({ x: 60, y: 80, props: { w: 80, h: 60 } })
expect(editor.snaps.getIndicators().length).toBe(1)
expect(getGapAndPointLines().pointLines[0].points).toHaveLength(6)
// moving the mouse vertically should not change things
editor.pointerMove(63, 180, { ctrlKey: true })
expect(editor.getShape(ids.boxX)).toMatchObject({ x: 60, y: 80, props: { w: 80, h: 60 } })
// snap to right edge of D
editor.pointerMove(39, 280, { ctrlKey: true })
expect(editor.getShape(ids.boxX)).toMatchObject({ x: 40, y: 80, props: { w: 100, h: 60 } })
expect(editor.snaps.getIndicators().length).toBe(1)
expect(getGapAndPointLines().pointLines[0].points).toHaveLength(4)
})
it('works for dragging the top left corner', () => {
// snap to left edges of A and C
// x ┌───────────────────────────┐
// │ │ A │
// │ │ │
// x └───────────────────────────┘
// │
// │
// ┌─────┐ │
// │ │ │
// │ │ x O────────────────┐
// │ D │ │ │ │
// │ │ │ │ │
// │ │ │ │ X │
// │ │ │ │ │
// │ │ │ │ │
// │ │ x └────────────────┘
// │ │ │
// └─────┘ │
// │
// │
// x ┌───────────────────────────┐
// │ │ c │
// │ │ │
// x └───────────────────────────┘
editor.select(ids.boxX).pointerDown(80, 80, {
target: 'selection',
handle: 'top_left',
})
editor.pointerMove(62, 81, { ctrlKey: true })
expect(editor.getShape(ids.boxX)).toMatchObject({ x: 60, y: 81, props: { w: 80, h: 59 } })
expect(getSnapLines(editor)).toMatchInlineSnapshot(`
[
"60,0 60,40 60,81 60,140 60,180 60,220",
]
`)
// snap to top edges of B and D
//
// ┌────────────────────┐
// │ │
// │ A │
// │ │
// └────────────────────┘
//
// x─────x────────x─────────────x─────────x─────x
// ┌─────┐ O─────────────┐ ┌─────┐
// │ │ │ │ │ │
// │ │ │ │ │ │
// │ D │ │ │ │ B │
// │ │ │ X │ │ │
// │ │ │ │ │ │
// │ │ │ │ │ │
// │ │ │ │ │ │
// │ │ └─────────────┘ │ │
// │ │ │ │
// │ │ │ │
// └─────┘ └─────┘
//
// ┌────────────────────┐
// │ │
// │ C │
// │ │
// └────────────────────┘
editor.pointerMove(81, 58, { ctrlKey: true })
expect(editor.getShape(ids.boxX)).toMatchObject({ x: 81, y: 60, props: { w: 59, h: 80 } })
expect(getSnapLines(editor)).toMatchInlineSnapshot(`
[
"0,60 40,60 81,60 140,60 180,60 220,60",
]
`)
// sanp to both at the same time
// x ┌────────────────────┐
// │ │ │
// │ │ A │
// │ │ │
// x └────────────────────┘
// │
// x─────x───x──────────────────x─────────x─────x
// ┌─────┐ │ O────────────────┐ ┌─────┐
// │ │ │ │ │ │ │
// │ │ │ │ │ │ │
// │ D │ │ │ │ │ B │
// │ │ │ │ X │ │ │
// │ │ │ │ │ │ │
// │ │ │ │ │ │ │
// │ │ │ │ │ │ │
// │ │ x └────────────────┘ │ │
// │ │ │ │ │
// │ │ │ │ │
// └─────┘ │ └─────┘
// │
// x ┌────────────────────┐
// │ │ │
// │ │ C │
// │ │ │
// x └────────────────────┘
editor.pointerMove(59, 62, { ctrlKey: true })
expect(editor.getShape(ids.boxX)).toMatchObject({ x: 60, y: 60, props: { w: 80, h: 80 } })
expect(getSnapLines(editor)).toMatchInlineSnapshot(`
[
"0,60 40,60 60,60 140,60 180,60 220,60",
"60,0 60,40 60,60 60,140 60,180 60,220",
]
`)
})
it('works for dragging the top right corner', () => {
// ┌────────────────────┐ x
// │ │ │
// │ A │ │
// │ │ │
// └────────────────────┘ x
// │
// x─────x──────────x─────────────────x───x─────x
// ┌─────┐ ┌───────────────O │ ┌─────┐
// │ │ │ │ │ │ │
// │ │ │ │ │ │ │
// │ D │ │ │ │ │ B │
// │ │ │ X │ │ │ │
// │ │ │ │ │ │ │
// │ │ │ │ │ │ │
// │ │ │ │ │ │ │
// │ │ └───────────────┘ x │ │
// │ │ │ │ │
// │ │ │ │ │
// └─────┘ │ └─────┘
// │
// ┌────────────────────┐ x
// │ │ │
// │ C │ │
// │ │ │
// └────────────────────┘ x
editor
.select(ids.boxX)
.pointerDown(140, 80, {
target: 'selection',
handle: 'top_right',
})
.pointerMove(161, 59, { ctrlKey: true })
expect(editor.getShape(ids.boxX)).toMatchObject({ x: 80, y: 60, props: { w: 80, h: 80 } })
})
it('works for dragging the bottom right corner', () => {
// ┌────────────────────┐ x
// │ │ │
// │ A │ │
// │ │ │
// └────────────────────┘ x
// │
// │
// │
// ┌─────┐ │ ┌─────┐
// │ │ │ │ │
// │ │ ┌───────────────┐ x │ │
// │ D │ │ │ │ │ B │
// │ │ │ X │ │ │ │
// │ │ │ │ │ │ │
// │ │ │ │ │ │ │
// │ │ │ │ │ │ │
// │ │ │ │ │ │ │
// │ │ │ │ │ │ │
// │ │ │ │ │ │ │
// └─────┘ └───────────────O │ └─────┘
// x─────x──────────x─────────────────x───x─────x
// ┌────────────────────┐ x
// │ │ │
// │ C │ │
// │ │ │
// └────────────────────┘ x
editor
.select(ids.boxX)
.pointerDown(140, 140, {
target: 'selection',
handle: 'bottom_right',
})
.pointerMove(161, 159, { ctrlKey: true })
expect(editor.getShape(ids.boxX)).toMatchObject({ x: 80, y: 80, props: { w: 80, h: 80 } })
})
it('works for dragging the bottom left corner', () => {
// x ┌────────────────────┐
// │ │ │
// │ │ A │
// │ │ │
// x └────────────────────┘
// │
// │
// │
// ┌─────┐ │ ┌─────┐
// │ │ │ │ │
// │ │ x ┌────────────────┐ │ │
// │ D │ │ │ │ │ B │
// │ │ │ │ X │ │ │
// │ │ │ │ │ │ │
// │ │ │ │ │ │ │
// │ │ │ │ │ │ │
// │ │ │ │ │ │ │
// │ │ │ │ │ │ │
// │ │ │ │ │ │ │
// └─────┘ │ O────────────────┘ └─────┘
// x─────x───x──────────────────x─────────x─────x
// │
// x ┌────────────────────┐
// │ │ │
// │ │ C │
// │ │ │
// x └────────────────────┘
editor
.select(ids.boxX)
.pointerDown(80, 140, {
target: 'selection',
handle: 'bottom_left',
})
.pointerMove(59, 159, { ctrlKey: true })
expect(editor.getShape(ids.boxX)).toMatchObject({ x: 60, y: 80, props: { w: 80, h: 80 } })
})
})
describe('snapping while resizing from center', () => {
beforeEach(() => {
// 0 20 40 60 80 100 120 140
// 0 ┌───┐
// │ A │
// 20 └───┘
//
// 40 ┌─────────────┐
// │ │
// 60 ┌───┐ │ │ ┌───┐
// │ D │ │ X │ │ B │
// 80 └───┘ │ │ └───┘
// │ │
// 100 └─────────────┘
//
// 120 ┌───┐
// │ C │
// 140 └───┘
editor.createShapes([
box(ids.boxA, 60, 0, 20, 20),
box(ids.boxB, 120, 60, 20, 20),
box(ids.boxC, 60, 120, 20, 20),
box(ids.boxD, 0, 60, 20, 20),
box(ids.boxX, 40, 40, 60, 60),
])
})
it('should work from the top', () => {
editor
.select(ids.boxX)
.pointerDown(70, 40, {
target: 'selection',
handle: 'top',
})
.pointerMove(70, 21, { ctrlKey: true, altKey: true })
expect(editor.getShape(ids.boxX)).toMatchObject({
x: 40,
y: 20,
props: { w: 60, h: 100 },
})
expect(getSnapLines(editor)).toMatchInlineSnapshot(`
[
"40,120 60,120 80,120 100,120",
"40,20 60,20 80,20 100,20",
]
`)
})
it('should work from the right', () => {
editor
.select(ids.boxX)
.pointerDown(100, 70, {
target: 'selection',
handle: 'right',
})
.pointerMove(121, 70, { ctrlKey: true, altKey: true })
expect(editor.getShape(ids.boxX)).toMatchObject({
x: 20,
y: 40,
props: { w: 100, h: 60 },
})
})
it('should work from the bottom', () => {
editor
.select(ids.boxX)
.pointerDown(70, 100, {
target: 'selection',
handle: 'bottom',
})
.pointerMove(70, 121, { ctrlKey: true, altKey: true })
expect(editor.getShape(ids.boxX)).toMatchObject({
x: 40,
y: 20,
props: { w: 60, h: 100 },
})
})
it('should work from the left', () => {
editor
.select(ids.boxX)
.pointerDown(40, 70, {
target: 'selection',
handle: 'left',
})
.pointerMove(21, 70, { ctrlKey: true, altKey: true })
expect(editor.getShape(ids.boxX)).toMatchObject({
x: 20,
y: 40,
props: { w: 100, h: 60 },
})
})
it('should work from the top right', () => {
// 0 20 40 60 80 100 120 140
// 0 ┌───┐
// │ A │
// 20 └───┘
//
// 40 x───────────────────────O
// │ │
// 60 ┌───x x───┐
// │ D │ X │ B │
// 80 └───x x───┘
// │ │
// 100 x───────────────────────x
//
// 120 ┌───┐
// │ C │
// 140 └───┘
editor
.select(ids.boxX)
.pointerDown(100, 40, {
target: 'selection',
handle: 'top_right',
})
.pointerMove(123, 40, { ctrlKey: true, altKey: true })
expect(editor.getShape(ids.boxX)).toMatchObject({
x: 20,
y: 40,
props: { w: 100, h: 60 },
})
expect(getSnapLines(editor)).toMatchInlineSnapshot(`
[
"120,40 120,60 120,80 120,100",
"20,40 20,60 20,80 20,100",
]
`)
// 0 20 40 60 80 100 120 140
// 0 ┌───┐
// │ A │
// 20 x─────────x───x─────────O
// │ │
// 40 │ │
// │ │
// 60 ┌───x x───┐
// │ D │ X │ B │
// 80 └───x x───┘
// │ │
// 100 │ │
// │ │
// 120 x─────────x───x─────────x
// │ C │
// 140 └───┘
editor.pointerMove(123, 18, { ctrlKey: true, altKey: true })
expect(editor.getShape(ids.boxX)).toMatchObject({
x: 20,
y: 20,
props: { w: 100, h: 100 },
})
expect(getSnapLines(editor)).toMatchInlineSnapshot(`
[
"120,20 120,60 120,80 120,120",
"20,120 60,120 80,120 120,120",
"20,20 20,60 20,80 20,120",
"20,20 60,20 80,20 120,20",
]
`)
})
it('should work from the bottom right', () => {
// 0 20 40 60 80 100 120 140
// 0 ┌───┐
// │ A │
// 20 └───┘
//
// 40 x───────────────────────x
// │ │
// 60 ┌───x x───┐
// │ D │ X │ B │
// 80 └───x x───┘
// │ │
// 100 x───────────────────────O
//
// 120 ┌───┐
// │ C │
// 140 └───┘
editor
.select(ids.boxX)
.pointerDown(100, 100, {
target: 'selection',
handle: 'bottom_right',
})
.pointerMove(123, 100, { ctrlKey: true, altKey: true })
expect(editor.getShape(ids.boxX)).toMatchObject({
x: 20,
y: 40,
props: { w: 100, h: 60 },
})
expect(getSnapLines(editor)).toMatchInlineSnapshot(`
[
"120,40 120,60 120,80 120,100",
"20,40 20,60 20,80 20,100",
]
`)
// 0 20 40 60 80 100 120 140
// 0 ┌───┐
// │ A │
// 20 x─────────x───x─────────x
// │ │
// 40 │ │
// │ │
// 60 ┌───x x───┐
// │ D │ X │ B │
// 80 └───x x───┘
// │ │
// 100 │ │
// │ │
// 120 x─────────x───x─────────O
// │ C │
// 140 └───┘
editor.pointerMove(123, 118, { ctrlKey: true, altKey: true })
expect(editor.getShape(ids.boxX)).toMatchObject({
x: 20,
y: 20,
props: { w: 100, h: 100 },
})
expect(getSnapLines(editor)).toMatchInlineSnapshot(`
[
"120,20 120,60 120,80 120,120",
"20,120 60,120 80,120 120,120",
"20,20 20,60 20,80 20,120",
"20,20 60,20 80,20 120,20",
]
`)
}