/** * The frame round a vector art selection (#291): where it stands and which handle a press finds, * what pulling a handle does in the tab, and the TRANSFORM rows of the inspector. The last block * covers what #217 left without a test of its own – shapes dragged together. */ import { CanvasTextMetrics, type FederatedPointerEvent } from 'pixi.js'; import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { buildAsset } from '../../src/core/commands/assetCommands'; import { GroupVectorShapesCommand } from '../../src/core/commands/vectorArtCommands'; import type { VectorArtAsset, VectorGradient, VectorShape } from '../../src/core/model/Asset'; import { IDENTITY } from '../../src/core/model/matrix'; import { createEmptyProject } from '../../src/core/model/Project'; import type { Vec2 } from '../../src/core/model/Properties'; import { rotationAbout } from '../../src/core/model/vectorTransform'; import { allShapes } from '../../src/core/model/vectorTree'; import type { InspectorHost } from '../../src/editor/inspector/host'; import { buildShapeSection, refreshShapeSection, shapeSignature, } from '../../src/editor/inspector/shapeSection'; import { flipSelection } from '../../src/editor/inspector/shapeTransformRows'; import { EditorState, shapeSelection } from '../../src/editor/state'; import { FRAME_PAD, FRAME_STEM, frameBox, frameCursor, frameGripAt, frameSizing, frameTurning, sidePoint, } from '../../src/editor/vectorart/shapeFrame'; import { VectorArtCanvas } from '../../src/editor/vectorart/VectorArtCanvas'; import { Button, NumberField, VStack, type Widget } from '../../src/ui'; import { resetRenderLoop } from '../../src/ui/render'; import { PaintCache } from '../../src/viewport/vectorArtPicture'; beforeAll(() => { CanvasTextMetrics.measureText = (() => ({ width: 10, height: 10, lines: [], lineWidths: [] })) as never; PaintCache.prototype.gradient = (() => null) as never; }); beforeEach(() => resetRenderLoop()); const BOX = { x0: 10, y0: 20, x1: 50, y1: 40 }; /** A view of two screen pixels per canvas pixel, moved by 100 and 50. */ const toLocal = (x: number, y: number): Vec2 => ({ x: 100 + x * 2, y: 50 + y * 2 }); describe('where the frame stands', () => { it('stands a fixed number of screen pixels off the extent, at any zoom', () => { const box = frameBox(BOX, IDENTITY, toLocal); expect(box.corners).toEqual([ { x: 120 - FRAME_PAD, y: 90 - FRAME_PAD }, { x: 200 + FRAME_PAD, y: 90 - FRAME_PAD }, { x: 200 + FRAME_PAD, y: 130 + FRAME_PAD }, { x: 120 - FRAME_PAD, y: 130 + FRAME_PAD }, ]); expect(box.edges[0]).toEqual({ x: 160, y: 90 - FRAME_PAD }); expect(box.edges[1]).toEqual({ x: 200 + FRAME_PAD, y: 110 }); expect(box.knob).toEqual({ x: 160, y: 90 - FRAME_PAD - FRAME_STEM }); }); it('turns with the map of a drag, the knob going round with it', () => { const box = frameBox(BOX, rotationAbout({ x: 30, y: 30 }, 90), toLocal); // The top edge is now the right one, so the knob stands out to the right of the middle. expect(box.knob.x).toBeCloseTo(160 + 20 + FRAME_PAD + FRAME_STEM, 6); expect(box.knob.y).toBeCloseTo(110, 6); expect(box.corners[0]!.x).toBeCloseTo(180 + FRAME_PAD, 6); expect(box.corners[0]!.y).toBeCloseTo(70 - FRAME_PAD, 6); }); it('finds the knob, a corner and an edge, and nothing in the middle', () => { const box = frameBox(BOX, IDENTITY, toLocal); expect(frameGripAt(box, box.knob)).toEqual({ kind: 'knob' }); expect(frameGripAt(box, { x: box.corners[2]!.x + 3, y: box.corners[2]!.y + 3 })).toEqual({ kind: 'size', side: { sx: 1, sy: 1 }, }); expect(frameGripAt(box, { x: 140, y: box.edges[2]!.y })).toEqual({ kind: 'size', side: { sx: 0, sy: 1 }, }); expect(frameGripAt(box, { x: 160, y: 110 })).toBeNull(); // A point of the framed shape lies on the extent: out of every handle's reach. expect(frameGripAt(box, { x: 200, y: 130 })).toBeNull(); expect(frameGripAt(box, { x: 200, y: 110 })).toBeNull(); }); it('shows the arrow along the pull of each handle', () => { expect(frameCursor({ kind: 'knob' })).toBe('grab'); expect(frameCursor({ kind: 'size', side: { sx: 0, sy: -1 } })).toBe('ns-resize'); expect(frameCursor({ kind: 'size', side: { sx: 1, sy: 0 } })).toBe('ew-resize'); expect(frameCursor({ kind: 'size', side: { sx: -1, sy: -1 } })).toBe('nwse-resize'); expect(frameCursor({ kind: 'size', side: { sx: 1, sy: -1 } })).toBe('nesw-resize'); }); it('moves a handle as far as the pointer went, wherever on the handle the press was', () => { const side = { sx: 1, sy: 1 } as const; expect(sidePoint(BOX, side)).toEqual({ x: 50, y: 40 }); expect(sidePoint(BOX, { sx: -1, sy: 0 })).toEqual({ x: 10, y: 30 }); const m = frameSizing( BOX, side, { x: 57, y: 47 }, { x: 97, y: 57 }, { proportional: false, centred: false }, ); expect(m).toMatchObject({ a: 2, d: 1.5 }); const snapped = frameSizing( BOX, side, { x: 57, y: 47 }, { x: 95, y: 47 }, { proportional: false, centred: false, snap: (p) => ({ x: Math.round(p.x / 8) * 8, y: Math.round(p.y / 8) * 8 }), }, ); // 50 + 38 = 88 is on the grid already; the height stays, 40 being on it too. expect(snapped).toMatchObject({ a: 78 / 40, d: 1 }); }); it('turns to a tenth of a degree, and in steps of fifteen with Shift', () => { expect(frameTurning(BOX, 37.26, false)).toEqual(rotationAbout({ x: 30, y: 30 }, 37.3)); expect(frameTurning(BOX, 37.26, true)).toEqual(rotationAbout({ x: 30, y: 30 }, 30)); expect(frameTurning(BOX, 38, true)).toEqual(rotationAbout({ x: 30, y: 30 }, 45)); }); }); /** A square of 20 pixels with its corner at `x`, `y`. */ function square(id: string, x: number, y = 0): VectorShape { return { id, name: id.toUpperCase(), fill: 0x336699, opacity: 1, rings: [ { closed: true, points: [ { x, y }, { x: x + 20, y }, { x: x + 20, y: y + 20 }, { x, y: y + 20 }, ], }, ], }; } const GRADIENT: VectorGradient = { type: 'linear', from: { x: 80, y: 50 }, to: { x: 100, y: 50 }, stops: [ { offset: 0, color: 0xff0000, alpha: 1 }, { offset: 1, color: 0x0000ff, alpha: 1 }, ], }; /** A drawing with the squares a, b and c side by side at y 40, and a host to build rows into. */ function rig() { const state = new EditorState(); state.loadProject(createEmptyProject('p', 'P'), 1); const asset = buildAsset('vectorart', 'Drawing') as VectorArtAsset; asset.layers[0]!.items.push(square('a', 40, 40), square('b', 80, 40), square('c', 120, 40)); state.project.assets.push(asset); const host: InspectorHost = { state, body: new VStack(), fields: new Map(), nameField: null, project: () => state.project, liveVariables: () => null, }; const build = (): void => { host.body.removeChildren(); host.fields.clear(); const s = state.selection; if (s?.kind === 'shape') buildShapeSection(host, asset.id, s.shapeId, s.shapeIds); }; const widgets = (type: new (...args: never[]) => T): T[] => { const found: T[] = []; const walk = (widget: Widget): void => { if (widget instanceof type) found.push(widget); for (const child of widget.children) walk(child as Widget); }; walk(host.body); return found; }; const shape = (id: string) => allShapes(asset).find((s) => s.id === id)!; const points = (id: string) => shape(id).rings[0]!.points.map((p) => [p.x, p.y]); return { state, asset, host, build, widgets, shape, points }; } let clock = 0; function pressAt(global: Vec2, held: 'shift' | 'alt' | null = null) { clock += 1000; return { timeStamp: clock, button: 0, global, altKey: held === 'alt', ctrlKey: false, metaKey: false, shiftKey: held === 'shift', stopPropagation: () => undefined, } as unknown as FederatedPointerEvent; } interface Pressable { shiftPresses(asset: VectorArtAsset, local: Vec2): boolean; press(asset: VectorArtAsset, e: FederatedPointerEvent, local: Vec2): void; move(local: Vec2, keys?: { shiftKey: boolean; altKey: boolean }): void; release(): void; } function tab() { const r = rig(); const canvas = new VectorArtCanvas(r.state); canvas.show(r.asset.id); canvas.resize(512, 512); canvas.redraw(); const target = canvas as unknown as Pressable; const at = (x: number, y: number) => canvas.imageToScreen(x, y); /** Screen pixels per canvas pixel. */ const zoom = at(1, 0).x - at(0, 0).x; const select = (...ids: string[]) => r.state.select(shapeSelection(r.asset.id, ids)); const frame = () => canvas.frameInfo!; /** Presses at `from` and lets go `dx`, `dy` canvas pixels further, with a key held all the way. */ const pull = (from: Vec2, dx: number, dy: number, held: 'shift' | 'alt' | null = null) => { const to = { x: from.x + dx * zoom, y: from.y + dy * zoom }; target.press(r.asset, pressAt(from, held), from); target.move(to, { shiftKey: held === 'shift', altKey: held === 'alt' }); target.release(); }; return { ...r, canvas, target, at, zoom, select, frame, pull }; } describe('the frame in the tab', () => { it('frames the selection off its bounds, and nothing with another tool or no selection', () => { const t = tab(); expect(t.canvas.frameInfo).toBeNull(); t.select('a'); const corner = t.at(40, 40); expect(t.frame().corners[0]).toEqual({ x: corner.x - FRAME_PAD, y: corner.y - FRAME_PAD }); t.select('a', 'c'); const far = t.at(140, 60); expect(t.frame().corners[2]).toEqual({ x: far.x + FRAME_PAD, y: far.y + FRAME_PAD }); t.canvas.setTool('pen'); expect(t.canvas.frameInfo).toBeNull(); t.canvas.setTool('paint'); expect(t.canvas.frameInfo).toBeNull(); }); it('scales a shape from the opposite corner when a corner is pulled, as one undo step', () => { const t = tab(); t.select('a'); t.pull(t.frame().corners[2]!, 20, 10); expect(t.points('a')).toEqual([ [40, 40], [80, 40], [80, 70], [40, 70], ]); t.state.undo(); expect(t.points('a')[2]).toEqual([60, 60]); // The step before was none of the gesture's: the drawing has no further one to take back. t.state.undo(); expect(t.points('a')[2]).toEqual([60, 60]); }); it('shows the pull while it runs, and its undo step leads back to the very rings that were there', () => { const t = tab(); t.select('a', 'c'); const before = t.shape('c').rings; const handle = t.frame().corners[2]!; t.target.press(t.asset, pressAt(handle), handle); t.target.move({ x: handle.x + 100 * t.zoom, y: handle.y }); // The preview waits for the frame; a test has none, so it is run by hand. (t.canvas as unknown as { writePreview(): void }).writePreview(); expect(t.points('c')[1]).toEqual([240, 40]); expect(t.points('a')[1]).toEqual([80, 40]); t.target.release(); expect(t.points('c')[1]).toEqual([240, 40]); t.state.undo(); expect(t.shape('c').rings).toBe(before); }); it('scales in one direction from an edge', () => { const t = tab(); t.select('a'); t.pull(t.frame().edges[3]!, -20, 33); expect(t.points('a')).toEqual([ [20, 40], [60, 40], [60, 60], [20, 60], ]); }); it('scales every selected shape as one, grouped or not, and one undo puts all back', () => { const t = tab(); t.select('a', 'c'); // The frame is 100 wide; pulled to 200 everything doubles from the left top corner. t.pull(t.frame().corners[2]!, 100, 20); expect(t.points('a')[2]).toEqual([80, 80]); expect(t.points('c')[0]).toEqual([200, 40]); expect(t.points('c')[2]).toEqual([240, 80]); expect(t.points('b')[0]).toEqual([80, 40]); t.state.undo(); expect(t.points('a')[2]).toEqual([60, 60]); expect(t.points('c')[0]).toEqual([120, 40]); }); it('frames a whole group after a press on one of its shapes and scales it together', () => { const t = tab(); t.state.execute(new GroupVectorShapesCommand(t.asset.id, ['a', 'b'])); const on = t.at(50, 50); t.target.press(t.asset, pressAt(on), on); t.target.release(); expect(t.state.selectedShapeIds).toEqual(['a', 'b']); t.pull(t.frame().edges[1]!, 60, 0); expect(t.points('a')[1]).toEqual([80, 40]); expect(t.points('b')[0]).toEqual([120, 40]); expect(t.points('b')[1]).toEqual([160, 40]); }); it('keeps the proportions with Shift and scales about the middle with Alt', () => { const t = tab(); t.select('a'); t.pull(t.frame().corners[2]!, 20, 4, 'shift'); expect(t.points('a')[2]).toEqual([80, 80]); t.state.undo(); t.pull(t.frame().corners[2]!, 10, 10, 'alt'); expect(t.points('a')).toEqual([ [30, 30], [70, 30], [70, 70], [30, 70], ]); }); it('takes a Shift press on a handle itself instead of leaving it to the pan or the selection', () => { const t = tab(); t.select('a', 'c'); const handle = t.frame().corners[2]!; expect(t.target.shiftPresses(t.asset, handle)).toBe(true); // The frame is 100 wide: pulled to 200 with Shift the height doubles along. t.pull(handle, 100, 0, 'shift'); expect(t.state.selectedShapeIds).toEqual(['a', 'c']); expect(t.points('c')[2]).toEqual([240, 80]); // Beside the frame and the shapes Shift is still the pan's. expect(t.target.shiftPresses(t.asset, t.at(200, 200))).toBe(false); }); it('turns the selection round the middle of its frame with the knob', () => { const t = tab(); t.select('a'); const knob = t.frame().knob; const centre = t.at(50, 50); const reach = centre.y - knob.y; // From straight above the middle to straight right of it: a quarter turn clockwise. t.target.press(t.asset, pressAt(knob), knob); t.target.move({ x: centre.x + reach, y: centre.y }); expect(t.frame().knob.x).toBeCloseTo(centre.x + reach, 6); expect(t.frame().knob.y).toBeCloseTo(centre.y, 6); t.target.release(); expect(t.points('a')).toEqual([ [60, 40], [60, 60], [40, 60], [40, 40], ]); // Baked in: the frame is upright again and the knob back on top. expect(t.frame().knob.x).toBeCloseTo(knob.x, 6); expect(t.frame().knob.y).toBeCloseTo(knob.y, 6); t.state.undo(); expect(t.points('a')[0]).toEqual([40, 40]); }); it('snaps a turn to steps of fifteen degrees with Shift', () => { const t = tab(); t.select('a'); const knob = t.frame().knob; const centre = t.at(50, 50); const reach = centre.y - knob.y; const toward = (degrees: number): Vec2 => { const r = ((degrees - 90) * Math.PI) / 180; return { x: centre.x + reach * Math.cos(r), y: centre.y + reach * Math.sin(r) }; }; t.target.press(t.asset, pressAt(knob), knob); t.target.move(toward(41), { shiftKey: true, altKey: false }); t.target.release(); const turned = rotationAbout({ x: 50, y: 50 }, 45); expect(t.points('a')[0]![0]).toBeCloseTo(turned.a * 40 + turned.c * 40 + turned.tx, 3); expect(t.points('a')[0]![1]).toBeCloseTo(turned.b * 40 + turned.d * 40 + turned.ty, 3); }); it('leaves a point of the shape to itself: the frame never lies under it', () => { const t = tab(); t.select('a'); const corner = t.at(60, 60); t.target.press(t.asset, pressAt(corner), corner); t.target.move({ x: corner.x + 10 * t.zoom, y: corner.y }); t.target.release(); expect(t.points('a')).toEqual([ [40, 40], [60, 40], [70, 60], [40, 60], ]); expect(t.canvas.pickedAt).toEqual({ ring: 0, index: 2 }); }); it('puts a pulled handle on the grid while snapping is on', () => { const t = tab(); t.select('a'); t.canvas.setSnapping(true); t.pull(t.frame().corners[2]!, 13, 2); expect(t.points('a')[2]).toEqual([72, 64]); }); it('makes no undo step of a press on a handle that pulls nothing', () => { const t = tab(); t.select('a'); t.pull(t.frame().corners[2]!, 20, 20); const handle = t.frame().corners[0]!; t.target.press(t.asset, pressAt(handle), handle); t.target.release(); t.state.undo(); expect(t.points('a')[2]).toEqual([60, 60]); }); it('takes the fill paint along and leaves the line as wide as it was', () => { const t = tab(); const b = t.shape('b'); b.paint = { ...GRADIENT }; b.stroke = { color: 0, width: 3 }; t.select('b'); t.pull(t.frame().corners[2]!, 20, 20); expect(b.paint).toMatchObject({ from: { x: 80, y: 60 }, to: { x: 120, y: 60 } }); expect(b.stroke.width).toBe(3); t.state.undo(); expect(b.paint).toMatchObject({ from: { x: 80, y: 50 }, to: { x: 100, y: 50 } }); }); it('shows the arrow of the handle under the pointer', () => { const t = tab(); t.select('a'); t.target.move(t.frame().corners[2]!); expect(t.canvas.cursor).toBe('nwse-resize'); t.target.move(t.frame().knob); expect(t.canvas.cursor).toBe('grab'); t.target.move(t.at(50, 50)); expect(t.canvas.cursor).toBe('move'); }); }); describe('the TRANSFORM rows', () => { const KEYS = ['frameX', 'frameY', 'frameWidth', 'frameHeight', 'frameTurn']; const built = (...ids: string[]) => { const r = rig(); r.state.select(shapeSelection(r.asset.id, ids)); r.build(); const row = (key: string) => r.widgets(NumberField)[KEYS.indexOf(key)]!; const button = (label: string) => r.widgets(Button).find((b) => b.text === label)!; const refresh = () => refreshShapeSection(r.host, r.asset.id, ids[0]!); return { ...r, row, button, refresh }; }; it('show where the frame of the selection stands and how big it is', () => { const r = built('a', 'c'); expect(KEYS.map((key) => Number(r.row(key).value))).toEqual([40, 40, 100, 20, 0]); expect([...r.host.fields.keys()].slice(0, 5)).toEqual(KEYS); }); it('move the whole selection to a typed place', () => { const r = built('a', 'c'); r.row('frameX').onChange!('10'); r.row('frameY').onChange!('0'); expect(r.points('a')[0]).toEqual([10, 0]); expect(r.points('c')[0]).toEqual([90, 0]); expect(r.points('b')[0]).toEqual([80, 40]); }); it('stretch the selection to a typed size from its left top corner', () => { const r = built('a', 'c'); r.row('frameWidth').onChange!('200'); expect(r.points('c')[1]).toEqual([240, 40]); r.row('frameHeight').onChange!('10'); expect(r.points('a')[2]).toEqual([80, 50]); r.refresh(); expect(Number(r.row('frameWidth').value)).toBe(200); expect(Number(r.row('frameHeight').value)).toBe(10); }); it('turn by the typed degrees, and read 0 again once they are baked in', () => { const r = built('a'); r.row('frameTurn').onChange!('90'); expect(r.points('a')[0]).toEqual([60, 40]); r.refresh(); expect(Number(r.row('frameTurn').value)).toBe(0); r.state.undo(); expect(r.points('a')[0]).toEqual([40, 40]); }); it('preview a dragged number from the shapes as they were, and commit one undo step', () => { const r = built('a'); const before = r.shape('a').rings; const turn = r.row('frameTurn'); turn.onInput!('30'); turn.onInput!('60'); turn.onInput!('90'); // Each preview starts from the square as it was: three steps of thirty would have added rounding. expect(r.points('a')[0]).toEqual([60, 40]); r.refresh(); expect(Number(turn.value)).toBe(90); turn.onChange!('180'); expect(r.points('a')[0]).toEqual([60, 60]); r.refresh(); expect(Number(turn.value)).toBe(0); r.state.undo(); expect(r.shape('a').rings).toBe(before); }); it('mirror the selection about the middle of its frame', () => { const r = built('a', 'c'); r.button('Flip horizontal').onClick!(); expect(r.points('a')[0]).toEqual([140, 40]); expect(r.points('c')[0]).toEqual([60, 40]); r.button('Flip vertical').onClick!(); expect(r.points('a')[0]).toEqual([140, 60]); r.state.undo(); r.state.undo(); expect(r.points('a')[0]).toEqual([40, 40]); expect(flipSelection(r.state, r.asset.id, ['nobody'], 'x')).toBe(false); }); it('are not there for a shape that has no size yet, and come with its second point', () => { const r = rig(); r.shape('a').rings = [{ closed: false, points: [{ x: 5, y: 5 }] }]; r.state.select(shapeSelection(r.asset.id, ['a'])); r.build(); expect(r.host.fields.has('frameX')).toBe(false); const bare = shapeSignature(r.state.project, r.asset.id, 'a', ['a']); r.shape('a').rings = [ { closed: false, points: [ { x: 5, y: 5 }, { x: 9, y: 5 }, ], }, ]; expect(shapeSignature(r.state.project, r.asset.id, 'a', ['a'])).not.toBe(bare); }); }); describe('shapes dragged together (#217)', () => { it('snaps the offset all of them share, so each keeps its place beside the others', () => { const t = tab(); t.shape('a').rings = square('a', 43, 41).rings; t.select('a', 'c'); t.canvas.setSnapping(true); const on = t.at(130, 50); t.target.press(t.asset, pressAt(on), on); t.target.move({ x: on.x + 13 * t.zoom, y: on.y + 5 * t.zoom }); t.target.release(); // 13 and 5 go to the grid's 16 and 8 once, for both: a stays three pixels off the grid. expect(t.points('c')[0]).toEqual([136, 48]); expect(t.points('a')[0]).toEqual([59, 49]); }); it('takes the paints of every dragged shape along, and one undo puts them back', () => { const t = tab(); t.shape('b').paint = { ...GRADIENT }; const c = t.shape('c'); c.stroke = { color: 0, width: 2, paint: { ...GRADIENT, from: { x: 120, y: 50 }, to: { x: 140, y: 50 } } }; t.select('b', 'c'); // On the grid, so the offset is a whole 8 and 16 and not what a screen pixel divides to. t.canvas.setSnapping(true); const on = t.at(130, 50); t.target.press(t.asset, pressAt(on), on); t.target.move({ x: on.x + 8 * t.zoom, y: on.y + 16 * t.zoom }); t.target.release(); expect(t.shape('b').paint).toMatchObject({ from: { x: 88, y: 66 }, to: { x: 108, y: 66 } }); expect(c.stroke.paint).toMatchObject({ from: { x: 128, y: 66 }, to: { x: 148, y: 66 } }); t.state.undo(); expect(t.shape('b').paint).toMatchObject({ from: { x: 80, y: 50 }, to: { x: 100, y: 50 } }); expect(c.stroke.paint).toMatchObject({ from: { x: 120, y: 50 }, to: { x: 140, y: 50 } }); }); });