UNPKG

tldraw

Version:

A tiny little drawing editor.

673 lines (614 loc) • 15.5 kB
import { Mat, PI, TLArrowShape, TLArrowShapeProps, TLBindingCreate, TLImageShape, TLShapeId, TLShapePartial, createBindingId, createShapeId, } from '@tldraw/editor' import { vi } from 'vitest' import { getArrowBindings } from '../lib/shapes/arrow/shared' import { TestEditor } from './TestEditor' let editor: TestEditor vi.useFakeTimers() const ids = { boxA: createShapeId('boxA'), boxB: createShapeId('boxB'), boxC: createShapeId('boxC'), boxD: createShapeId('boxD'), } beforeEach(() => { editor = new TestEditor() editor.selectAll() editor.deleteShapes(editor.getSelectedShapeIds()) editor.createShapes([ { id: ids.boxA, type: 'geo', x: 0, y: 0, props: { w: 100, h: 100, }, }, { id: ids.boxB, type: 'geo', x: 150, y: 150, props: { w: 50, h: 50, }, }, { id: ids.boxC, type: 'geo', x: 300, y: 300, props: { w: 100, h: 100, }, }, ]) }) describe('When flipping horizontally', () => { it('Flips the selected shapes', () => { editor.select(ids.boxA, ids.boxB, ids.boxC) editor.markHistoryStoppingPoint('flipped') editor.flipShapes(editor.getSelectedShapeIds(), 'horizontal') editor.expectShapeToMatch( { id: ids.boxA, type: 'geo', x: 300, }, { id: ids.boxB, type: 'geo', x: 200, }, { id: ids.boxC, type: 'geo', x: 0, } ) }) it('Flips the provided shapes', () => { editor.markHistoryStoppingPoint('flipped') editor.flipShapes([ids.boxA, ids.boxB], 'horizontal') editor.expectShapeToMatch( { id: ids.boxA, type: 'geo', x: 100, }, { id: ids.boxB, type: 'geo', x: 0, } ) }) it('Flips rotated shapes', () => { editor.updateShapes([{ id: ids.boxA, type: 'geo', rotation: PI }]) editor.select(ids.boxA, ids.boxB) const a = editor.getSelectionPageBounds() editor.markHistoryStoppingPoint('flipped') editor.flipShapes(editor.getSelectedShapeIds(), 'horizontal') const b = editor.getSelectionPageBounds() expect(a!).toCloselyMatchObject(b!) editor.expectShapeToMatch( { id: ids.boxA, type: 'geo', x: 200, }, { id: ids.boxB, type: 'geo', x: -100, } ) }) it('Flips the children of rotated shapes', () => { editor.reparentShapes([ids.boxB], ids.boxA) editor.updateShapes([{ id: ids.boxA, type: 'geo', rotation: PI }]) editor.select(ids.boxB, ids.boxC) const a = editor.getSelectionPageBounds() editor.markHistoryStoppingPoint('flipped') editor.flipShapes(editor.getSelectedShapeIds(), 'horizontal') const b = editor.getSelectionPageBounds() expect(a).toCloselyMatchObject(b!) }) }) describe('When flipping vertically', () => { it('Flips the selected shapes', () => { editor.select(ids.boxA, ids.boxB, ids.boxC) editor.markHistoryStoppingPoint('flipped') editor.flipShapes(editor.getSelectedShapeIds(), 'vertical') editor.expectShapeToMatch( { id: ids.boxA, type: 'geo', y: 300, }, { id: ids.boxB, type: 'geo', y: 200, }, { id: ids.boxC, type: 'geo', y: 0, } ) }) it('Flips the provided shapes', () => { editor.markHistoryStoppingPoint('flipped') editor.flipShapes([ids.boxA, ids.boxB], 'vertical') editor.expectShapeToMatch( { id: ids.boxA, type: 'geo', y: 100, }, { id: ids.boxB, type: 'geo', y: 0, } ) }) it('Flips rotated shapes', () => { editor.updateShapes([{ id: ids.boxA, type: 'geo', rotation: PI }]) editor.select(ids.boxA, ids.boxB) const a = editor.getSelectionPageBounds() editor.markHistoryStoppingPoint('flipped') editor.flipShapes(editor.getSelectedShapeIds(), 'vertical') const b = editor.getSelectionPageBounds() expect(a).toCloselyMatchObject(b!) editor.expectShapeToMatch( { id: ids.boxA, type: 'geo', y: 200, }, { id: ids.boxB, type: 'geo', y: -100, } ) }) it('Flips the children of rotated shapes', () => { editor.reparentShapes([ids.boxB], ids.boxA) editor.updateShapes([{ id: ids.boxA, type: 'geo', rotation: PI }]) editor.select(ids.boxB, ids.boxC) const a = editor.getSelectionPageBounds() editor.markHistoryStoppingPoint('flipped') editor.flipShapes(editor.getSelectedShapeIds(), 'vertical') const b = editor.getSelectionPageBounds() expect(a).toCloselyMatchObject(b!) }) }) it('Preserves the selection bounds.', () => { editor.selectAll() const a = editor.getSelectionPageBounds() editor.markHistoryStoppingPoint('flipped') editor.flipShapes(editor.getSelectedShapeIds(), 'horizontal') const b = editor.getSelectionPageBounds() expect(a).toMatchObject(b!) editor.markHistoryStoppingPoint('flipped') editor.flipShapes(editor.getSelectedShapeIds(), 'vertical') const c = editor.getSelectionPageBounds() expect(a).toMatchObject(c!) }) it('Does, undoes and redoes', () => { editor.markHistoryStoppingPoint('flip vertical') editor.flipShapes([ids.boxA, ids.boxB], 'vertical') editor.expectShapeToMatch( { id: ids.boxA, type: 'geo', y: 100, }, { id: ids.boxB, type: 'geo', y: 0, } ) editor.undo() editor.expectShapeToMatch( { id: ids.boxA, type: 'geo', y: 0, }, { id: ids.boxB, type: 'geo', y: 150, } ) editor.redo() editor.expectShapeToMatch( { id: ids.boxA, type: 'geo', y: 100, }, { id: ids.boxB, type: 'geo', y: 0, } ) }) describe('When multiple shapes are selected', () => { it.todo('Flips the shape positions according to the selection rotation') it.todo('Flips using the selection rotation when the shapes have a common selection rotation') it.todo('Flips using the main axis when shapes do not have a common selection rotation') it.todo('Flips when shapes have different parents') }) describe('When one shape is selected', () => { it('Does nothing if the shape is not a group', () => { const before = editor.getShape(ids.boxA)! editor.select(ids.boxA) editor.flipShapes(editor.getSelectedShapeIds(), 'horizontal') expect(editor.getShape(ids.boxA)).toMatchObject(before) }) it('Flips the direct child shape positions if the shape is a group', async () => { const fn = vi.fn() editor.selectAll() editor.groupShapes(editor.getSelectedShapeIds()) // this will also select the new group const groupBefore = editor.getSelectedShapes()[0] editor.on('change', fn) editor.flipShapes(editor.getSelectedShapeIds(), 'horizontal') // The change event should have been called vi.runOnlyPendingTimers() expect(fn).toHaveBeenCalled() editor.expectShapeToMatch( { ...groupBefore, // group should not have changed }, { id: ids.boxA, // group's children shapes should have been flipped type: 'geo', parentId: groupBefore.id, x: 300, y: 0, }, { id: ids.boxB, type: 'geo', parentId: groupBefore.id, x: 200, y: 150, }, { id: ids.boxC, type: 'geo', parentId: groupBefore.id, x: 0, y: 300, } ) }) it.todo('Flips line and arrow shapes when their parent group is flipped') }) describe('flipping rotated shapes', () => { const arrowLength = 100 const diamondRadius = Math.cos(Math.PI / 4) * arrowLength const topPoint = { x: 0, y: 0 } const rightPoint = { x: diamondRadius, y: diamondRadius } const bottomPoint = { x: 0, y: 2 * diamondRadius } const leftPoint = { x: -diamondRadius, y: diamondRadius } const ids = { arrowA: createShapeId('arrowA'), arrowB: createShapeId('arrowB'), arrowC: createShapeId('arrowC'), arrowD: createShapeId('arrowD'), } beforeEach(() => { editor.selectAll().deleteShapes(editor.getSelectedShapeIds()) const props: Partial<TLArrowShapeProps> = { start: { x: 0, y: 0, }, end: { x: 100, y: 0, }, } // create a diamond of rotated arrows, pointing clockwise, with the top point at 0,0 editor.createShapes([ { // top to right type: 'arrow', id: ids.arrowA, ...topPoint, rotation: Math.PI / 4, props, }, { // right to bottom type: 'arrow', id: ids.arrowB, ...rightPoint, rotation: (Math.PI * 3) / 4, props, }, { // bottom to left type: 'arrow', id: ids.arrowC, ...bottomPoint, rotation: (Math.PI * 5) / 4, props, }, { // left to top type: 'arrow', id: ids.arrowD, ...leftPoint, rotation: (Math.PI * 7) / 4, props, }, ]) editor.select(ids.arrowA, ids.arrowB, ids.arrowC, ids.arrowD) }) const getStartAndEndPoints = (id: TLShapeId) => { const transform = editor.getShapePageTransform(id) if (!transform) throw new Error('no transform') const arrow = editor.getShape<TLArrowShape>(id)! const bindings = getArrowBindings(editor, arrow) if (bindings.start || bindings.end) throw new Error('not a point') const start = Mat.applyToPoint(transform, arrow.props.start) const end = Mat.applyToPoint(transform, arrow.props.end) return { start, end } } test('flipping horizontally', () => { editor.flipShapes(editor.getSelectedShapeIds(), 'horizontal') // now arrow A should be pointing from top to left let { start, end } = getStartAndEndPoints(ids.arrowA) expect(start).toCloselyMatchObject(topPoint) expect(end).toCloselyMatchObject(leftPoint) // now arrow B should be pointing from left to bottom ;({ start, end } = getStartAndEndPoints(ids.arrowB)) expect(start).toCloselyMatchObject(leftPoint) expect(end).toCloselyMatchObject(bottomPoint) // now arrow C should be pointing from bottom to right ;({ start, end } = getStartAndEndPoints(ids.arrowC)) expect(start).toCloselyMatchObject(bottomPoint) expect(end).toCloselyMatchObject(rightPoint) // now arrow D should be pointing from right to top ;({ start, end } = getStartAndEndPoints(ids.arrowD)) expect(start).toCloselyMatchObject(rightPoint) expect(end).toCloselyMatchObject(topPoint) }) test('flipping vertically', () => { editor.flipShapes(editor.getSelectedShapeIds(), 'vertical') // arrows that have height 0 get nudged by a pixel when flipped vertically // so we need to use a fairly loose tolerance // now arrow A should be pointing from bottom to right let { start, end } = getStartAndEndPoints(ids.arrowA) expect(start).toCloselyMatchObject(bottomPoint, 5) expect(end).toCloselyMatchObject(rightPoint, 5) // now arrow B should be pointing from right to top ;({ start, end } = getStartAndEndPoints(ids.arrowB)) expect(start).toCloselyMatchObject(rightPoint, 5) expect(end).toCloselyMatchObject(topPoint, 5) // now arrow C should be pointing from top to left ;({ start, end } = getStartAndEndPoints(ids.arrowC)) expect(start).toCloselyMatchObject(topPoint, 5) expect(end).toCloselyMatchObject(leftPoint, 5) // now arrow D should be pointing from left to bottom ;({ start, end } = getStartAndEndPoints(ids.arrowD)) expect(start).toCloselyMatchObject(leftPoint, 5) expect(end).toCloselyMatchObject(bottomPoint, 5) }) }) describe('When flipping shapes that include arrows', () => { let shapes: TLShapePartial[] let bindings: TLBindingCreate[] beforeEach(() => { const box1 = createShapeId() const box2 = createShapeId() const box3 = createShapeId() const arrow1 = createShapeId() const arrow2 = createShapeId() const arrow3 = createShapeId() shapes = [ { id: box1, type: 'geo', x: 0, y: 0, }, { id: box2, type: 'geo', x: 300, y: 300, }, { id: box3, type: 'geo', x: 300, y: 0, }, { id: arrow1, type: 'arrow', x: 50, y: 50, props: { bend: 200, }, }, { id: arrow2, type: 'arrow', x: 50, y: 50, props: { bend: -200, }, }, { id: arrow3, type: 'arrow', x: 50, y: 50, props: { bend: -200, }, }, ] bindings = [ { id: createBindingId(), type: 'arrow', fromId: arrow1, toId: box1, props: { terminal: 'start', normalizedAnchor: { x: 0.75, y: 0.75 }, isExact: false, isPrecise: true, }, }, { id: createBindingId(), type: 'arrow', fromId: arrow1, toId: box1, props: { terminal: 'end', normalizedAnchor: { x: 0.25, y: 0.25 }, isExact: false, isPrecise: true, }, }, { id: createBindingId(), type: 'arrow', fromId: arrow2, toId: box1, props: { terminal: 'start', normalizedAnchor: { x: 0.75, y: 0.75 }, isExact: false, isPrecise: true, }, }, { id: createBindingId(), type: 'arrow', fromId: arrow2, toId: box1, props: { terminal: 'end', normalizedAnchor: { x: 0.25, y: 0.25 }, isExact: false, isPrecise: true, }, }, { id: createBindingId(), type: 'arrow', fromId: arrow3, toId: box1, props: { terminal: 'start', normalizedAnchor: { x: 0.75, y: 0.75 }, isExact: false, isPrecise: true, }, }, { id: createBindingId(), type: 'arrow', fromId: arrow3, toId: box3, props: { terminal: 'end', normalizedAnchor: { x: 0.25, y: 0.25 }, isExact: false, isPrecise: true, }, }, ] }) it('Flips horizontally', () => { editor .selectAll() .deleteShapes(editor.getSelectedShapeIds()) .createShapes(shapes) .createBindings(bindings) const boundsBefore = editor.getSelectionRotatedPageBounds()! editor.flipShapes(editor.getSelectedShapeIds(), 'horizontal') expect(editor.getSelectionRotatedPageBounds()).toCloselyMatchObject(boundsBefore) }) it('Flips vertically', () => { editor .selectAll() .deleteShapes(editor.getSelectedShapeIds()) .createShapes(shapes) .createBindings(bindings) const boundsBefore = editor.getSelectionRotatedPageBounds()! editor.flipShapes(editor.getSelectedShapeIds(), 'vertical') expect(editor.getSelectionRotatedPageBounds()).toCloselyMatchObject(boundsBefore) }) }) it('Updates the image shape flip properties when flipped', () => { editor.createShape({ type: 'image', }) editor.select(editor.getLastCreatedShape()) editor.flipShapes(editor.getSelectedShapeIds(), 'horizontal') expect(editor.getLastCreatedShape<TLImageShape>().props.flipX).toBe(true) editor.flipShapes(editor.getSelectedShapeIds(), 'vertical') expect(editor.getLastCreatedShape<TLImageShape>().props.flipY).toBe(true) }) it('Restores flipped shape positions when shape is rotated', () => { editor.selectAll().rotateSelection(PI / 2.5) const before = editor.getSelectedShapes() editor.flipShapes(editor.getSelectedShapeIds(), 'horizontal') editor.flipShapes(editor.getSelectedShapeIds(), 'horizontal') const after = editor.getSelectedShapes() expect(after.length).toBe(before.length) for (let i = 0; i < before.length; i++) { expect(after[i]).toCloselyMatchObject(before[i]) } }) it('Restores flipped shape positions with draw shapes when shape is rotated', () => { editor .cancel() .setCurrentTool('draw') .pointerDown(0, 0) .pointerMove(-100, -100) .pointerMove(0, -100) .pointerMove(100, 100) .pointerUp() editor.selectAll().rotateSelection(PI / 2.5) const before = editor.getSelectedShapes() editor.flipShapes(editor.getSelectedShapeIds(), 'horizontal') editor.flipShapes(editor.getSelectedShapeIds(), 'horizontal') const after = editor.getSelectedShapes() expect(after.length).toBe(before.length) for (let i = 0; i < before.length; i++) { expect(after[i]).toCloselyMatchObject(before[i]) } })