UNPKG

tldraw

Version:

A tiny little drawing editor.

1,553 lines (1,397 loc) 149 kB
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", ] `) }