import { Box, createShapeId, PI, TLShapeId } from '@tldraw/editor' import { vi } from 'vitest' import { TestEditor } from '../TestEditor' let editor: TestEditor let ids: Record vi.useFakeTimers() beforeEach(() => { editor = new TestEditor() ids = { boxA: createShapeId('boxA'), boxB: createShapeId('boxB'), boxC: createShapeId('boxC'), } editor.createShapes([ { id: ids.boxA, type: 'geo', x: 0, y: 0, props: { w: 100, h: 100 } }, { id: ids.boxB, type: 'geo', x: 100, y: 100, props: { w: 50, h: 50 } }, { id: ids.boxC, type: 'geo', x: 400, y: 400, props: { w: 100, h: 100 } }, ]) editor.selectAll() }) describe('when less than two shapes are selected', () => { it('does nothing', () => { editor.setSelectedShapes([ids.boxB]) const fn = vi.fn() editor.store.listen(fn) editor.alignShapes(editor.getSelectedShapeIds(), 'top') vi.advanceTimersByTime(1000) expect(fn).not.toHaveBeenCalled() }) }) describe('when multiple shapes are selected', () => { it('does, undoes and redoes command', () => { editor.markHistoryStoppingPoint('align') editor.alignShapes(editor.getSelectedShapeIds(), 'top') vi.advanceTimersByTime(1000) editor.expectShapeToMatch({ id: ids.boxB, y: 0 }) editor.undo() editor.expectShapeToMatch({ id: ids.boxB, y: 100 }) editor.redo() editor.expectShapeToMatch({ id: ids.boxB, y: 0 }) }) it('aligns top', () => { editor.alignShapes(editor.getSelectedShapeIds(), 'top') vi.advanceTimersByTime(1000) editor.expectShapeToMatch( { id: ids.boxA, y: 0 }, { id: ids.boxB, y: 0 }, { id: ids.boxC, y: 0 } ) }) it('aligns right', () => { editor.alignShapes(editor.getSelectedShapeIds(), 'right') vi.advanceTimersByTime(1000) editor.expectShapeToMatch( { id: ids.boxA, x: 400 }, { id: ids.boxB, x: 450 }, { id: ids.boxC, x: 400 } ) }) it('aligns bottom', () => { editor.alignShapes(editor.getSelectedShapeIds(), 'bottom') vi.advanceTimersByTime(1000) editor.expectShapeToMatch( { id: ids.boxA, y: 400 }, { id: ids.boxB, y: 450 }, { id: ids.boxC, y: 400 } ) }) it('aligns left', () => { editor.alignShapes(editor.getSelectedShapeIds(), 'left') vi.advanceTimersByTime(1000) editor.expectShapeToMatch( { id: ids.boxA, x: 0 }, { id: ids.boxB, x: 0 }, { id: ids.boxC, x: 0 } ) }) it('aligns center horizontal', () => { editor.alignShapes(editor.getSelectedShapeIds(), 'center-horizontal') vi.advanceTimersByTime(1000) editor.expectShapeToMatch( { id: ids.boxA, x: 200 }, { id: ids.boxB, x: 225 }, { id: ids.boxC, x: 200 } ) }) it('aligns center vertical', () => { editor.alignShapes(editor.getSelectedShapeIds(), 'center-vertical') vi.advanceTimersByTime(1000) editor.expectShapeToMatch( { id: ids.boxA, y: 200 }, { id: ids.boxB, y: 225 }, { id: ids.boxC, y: 200 } ) }) it('aligns center, when shapes are rotated', () => { editor.updateShapes([ { id: ids.boxA, type: 'geo', rotation: PI, }, { id: ids.boxB, type: 'geo', rotation: PI, }, { id: ids.boxC, type: 'geo', rotation: PI, }, ]) editor.alignShapes(editor.getSelectedShapeIds(), 'center-vertical') vi.advanceTimersByTime(1000) editor.alignShapes(editor.getSelectedShapeIds(), 'center-horizontal') vi.advanceTimersByTime(1000) const commonBounds = Box.Common([ editor.getShapePageBounds(ids.boxA)!, editor.getShapePageBounds(ids.boxB)!, editor.getShapePageBounds(ids.boxC)!, ]) expect(commonBounds.midX).toBeCloseTo(editor.getShapePageBounds(ids.boxA)!.midX, 5) expect(commonBounds.midX).toBeCloseTo(editor.getShapePageBounds(ids.boxB)!.midX, 5) expect(commonBounds.midX).toBeCloseTo(editor.getShapePageBounds(ids.boxC)!.midX, 5) expect(commonBounds.midY).toBeCloseTo(editor.getShapePageBounds(ids.boxA)!.midY, 5) expect(commonBounds.midY).toBeCloseTo(editor.getShapePageBounds(ids.boxB)!.midY, 5) expect(commonBounds.midY).toBeCloseTo(editor.getShapePageBounds(ids.boxC)!.midY, 5) }) it('aligns top-left, when shapes are rotated', () => { editor.updateShapes([ { id: ids.boxA, type: 'geo', rotation: 0.2, }, { id: ids.boxB, type: 'geo', rotation: 0.4, }, { id: ids.boxC, type: 'geo', rotation: 0.6, }, ]) editor.alignShapes(editor.getSelectedShapeIds(), 'top') vi.advanceTimersByTime(1000) editor.alignShapes(editor.getSelectedShapeIds(), 'left') vi.advanceTimersByTime(1000) const commonBounds = Box.Common([ editor.getShapePageBounds(ids.boxA)!, editor.getShapePageBounds(ids.boxB)!, editor.getShapePageBounds(ids.boxC)!, ]) expect(commonBounds.minX).toBeCloseTo(editor.getShapePageBounds(ids.boxA)!.minX, 5) expect(commonBounds.minX).toBeCloseTo(editor.getShapePageBounds(ids.boxB)!.minX, 5) expect(commonBounds.minX).toBeCloseTo(editor.getShapePageBounds(ids.boxC)!.minX, 5) expect(commonBounds.minY).toBeCloseTo(editor.getShapePageBounds(ids.boxA)!.minY, 5) expect(commonBounds.minY).toBeCloseTo(editor.getShapePageBounds(ids.boxB)!.minY, 5) expect(commonBounds.minY).toBeCloseTo(editor.getShapePageBounds(ids.boxC)!.minY, 5) }) it('aligns bottom-right, when shapes are rotated', () => { editor.updateShapes([ { id: ids.boxA, type: 'geo', rotation: 0.2, }, { id: ids.boxB, type: 'geo', rotation: 0.4, }, { id: ids.boxC, type: 'geo', rotation: 0.6, }, ]) editor.setSelectedShapes([ids.boxA, ids.boxB, ids.boxC]) editor.alignShapes(editor.getSelectedShapeIds(), 'bottom') vi.advanceTimersByTime(1000) editor.alignShapes(editor.getSelectedShapeIds(), 'right') vi.advanceTimersByTime(1000) const commonBounds = Box.Common([ editor.getShapePageBounds(ids.boxA)!, editor.getShapePageBounds(ids.boxC)!, ]) expect(commonBounds.maxX).toBeCloseTo(editor.getShapePageBounds(ids.boxA)!.maxX, 5) expect(commonBounds.maxX).toBeCloseTo(editor.getShapePageBounds(ids.boxB)!.maxX, 5) expect(commonBounds.maxX).toBeCloseTo(editor.getShapePageBounds(ids.boxC)!.maxX, 5) expect(commonBounds.maxX).toBeCloseTo(editor.getShapePageBounds(ids.boxA)!.maxX, 5) expect(commonBounds.maxY).toBeCloseTo(editor.getShapePageBounds(ids.boxB)!.maxY, 5) expect(commonBounds.maxY).toBeCloseTo(editor.getShapePageBounds(ids.boxC)!.maxY, 5) }) }) describe('When shapes are parented to other shapes...', () => { beforeEach(() => { editor = new TestEditor() editor.selectAll() editor.deleteShapes(editor.getSelectedShapeIds()) ids = { boxA: createShapeId('boxA'), boxB: createShapeId('boxB'), boxC: createShapeId('boxC'), } editor.createShapes([ { id: ids.boxA, type: 'geo', x: 0, y: 0, props: { w: 100, h: 100 } }, { id: ids.boxB, type: 'geo', x: 100, y: 100, parentId: ids.boxA, props: { w: 50, h: 50 } }, { id: ids.boxC, type: 'geo', x: 400, y: 400, props: { w: 100, h: 100 } }, ]) editor.selectAll() }) it('Aligns to the top left.', () => { editor.setSelectedShapes([ids.boxC, ids.boxB]) const commonBoundsBefore = Box.Common([ editor.getShapePageBounds(ids.boxC)!, editor.getShapePageBounds(ids.boxB)!, ]) editor.alignShapes(editor.getSelectedShapeIds(), 'top') vi.advanceTimersByTime(1000) editor.alignShapes(editor.getSelectedShapeIds(), 'left') vi.advanceTimersByTime(1000) const commonBoundsAfter = Box.Common([ editor.getShapePageBounds(ids.boxC)!, editor.getShapePageBounds(ids.boxB)!, ]) expect(commonBoundsBefore.minX).toBeCloseTo(commonBoundsAfter.minX) expect(commonBoundsBefore.minY).toBeCloseTo(commonBoundsAfter.minY) }) it('Aligns to the bottom right.', () => { editor.setSelectedShapes([ids.boxC, ids.boxB]) const commonBoundsBefore = Box.Common([ editor.getShapePageBounds(ids.boxC)!, editor.getShapePageBounds(ids.boxB)!, ]) editor.alignShapes(editor.getSelectedShapeIds(), 'bottom') vi.advanceTimersByTime(1000) editor.alignShapes(editor.getSelectedShapeIds(), 'right') vi.advanceTimersByTime(1000) const commonBoundsAfter = Box.Common([ editor.getShapePageBounds(ids.boxC)!, editor.getShapePageBounds(ids.boxB)!, ]) expect(commonBoundsBefore.maxX).toBeCloseTo(commonBoundsAfter.maxX) expect(commonBoundsBefore.maxY).toBeCloseTo(commonBoundsAfter.maxY) }) }) describe('When shapes are parented to a rotated shape...', () => { beforeEach(() => { editor = new TestEditor() editor.selectAll() editor.deleteShapes(editor.getSelectedShapeIds()) editor.createShapes([ { id: ids.boxA, type: 'geo', x: 100, y: 100, props: { w: 100, h: 100, }, rotation: PI / 2, }, { id: ids.boxB, type: 'geo', parentId: ids.boxA, x: 100, y: 100, props: { geo: 'ellipse', w: 50, h: 50, }, }, { id: ids.boxC, type: 'geo', x: 400, y: 400, props: { w: 100, h: 100, }, }, ]) editor.selectAll() }) it('Aligns to the top left.', () => { editor.setSelectedShapes([ids.boxC, ids.boxB]) const commonBoundsBefore = Box.Common([ editor.getShapePageBounds(ids.boxC)!, editor.getShapePageBounds(ids.boxB)!, ]) editor.alignShapes(editor.getSelectedShapeIds(), 'top') vi.advanceTimersByTime(1000) editor.alignShapes(editor.getSelectedShapeIds(), 'left') vi.advanceTimersByTime(1000) const commonBoundsAfter = Box.Common([ editor.getShapePageBounds(ids.boxC)!, editor.getShapePageBounds(ids.boxB)!, ]) expect(commonBoundsBefore.minX).toBeCloseTo(commonBoundsAfter.minX) expect(commonBoundsBefore.minY).toBeCloseTo(commonBoundsAfter.minY) expect(commonBoundsAfter.minX).toBeCloseTo(editor.getShapePageBounds(ids.boxB)!.minX, 5) expect(commonBoundsAfter.minX).toBeCloseTo(editor.getShapePageBounds(ids.boxC)!.minX, 5) expect(commonBoundsAfter.minY).toBeCloseTo(editor.getShapePageBounds(ids.boxB)!.minY, 5) expect(commonBoundsAfter.minY).toBeCloseTo(editor.getShapePageBounds(ids.boxC)!.minY, 5) }) it('Aligns to the bottom right.', () => { editor.setSelectedShapes([ids.boxC, ids.boxB]) const commonBoundsBefore = Box.Common([ editor.getShapePageBounds(ids.boxC)!, editor.getShapePageBounds(ids.boxB)!, ]) editor.alignShapes(editor.getSelectedShapeIds(), 'bottom') vi.advanceTimersByTime(1000) editor.alignShapes(editor.getSelectedShapeIds(), 'right') vi.advanceTimersByTime(1000) const commonBoundsAfter = Box.Common([ editor.getShapePageBounds(ids.boxC)!, editor.getShapePageBounds(ids.boxB)!, ]) expect(commonBoundsBefore.maxX).toBeCloseTo(commonBoundsAfter.maxX) expect(commonBoundsBefore.maxY).toBeCloseTo(commonBoundsAfter.maxY) expect(commonBoundsAfter.maxX).toBeCloseTo(editor.getShapePageBounds(ids.boxB)!.maxX, 5) expect(commonBoundsAfter.maxX).toBeCloseTo(editor.getShapePageBounds(ids.boxC)!.maxX, 5) expect(commonBoundsAfter.maxY).toBeCloseTo(editor.getShapePageBounds(ids.boxB)!.maxY, 5) expect(commonBoundsAfter.maxY).toBeCloseTo(editor.getShapePageBounds(ids.boxC)!.maxY, 5) }) })