/** * Layers and groups of vector art in the editor (#190): the two lists of the inspector, the buttons * that group, and what a press in the tab does to a shape that stands in a group or on a hidden layer. */ import { CanvasTextMetrics, type FederatedPointerEvent } from 'pixi.js'; import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { buildAsset } from '../../src/core/commands/assetCommands'; import { AddVectorLayerCommand, GroupVectorShapesCommand, SetVectorLayerHiddenCommand, } from '../../src/core/commands/vectorArtCommands'; import type { VectorArtAsset, VectorItem, VectorShape } from '../../src/core/model/Asset'; import { createEmptyProject } from '../../src/core/model/Project'; import type { Vec2 } from '../../src/core/model/Properties'; import { allShapes } from '../../src/core/model/vectorTree'; import type { InspectorHost } from '../../src/editor/inspector/host'; import { buildShapeListSection, buildShapeSection, currentLayerList, currentShapeList, moveShapesToLayer, pickShapeRow, shapeListSignature, shapeSignature, } from '../../src/editor/inspector/shapeSection'; import { EditorState, shapeSelection } from '../../src/editor/state'; import { activeLayerOf, pickVectorLayer } from '../../src/editor/vectorart/activeLayer'; import { groupShapes, ungroupShapes } from '../../src/editor/vectorart/grouping'; import { VectorArtCanvas } from '../../src/editor/vectorart/VectorArtCanvas'; import { Button, Label, 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()); /** 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 }, ], }, ], }; } function text(items: readonly VectorItem[]): string { return items.map((item) => ('items' in item ? `${item.id}[${text(item.items)}]` : item.id)).join(' '); } /** A drawing with the squares a, b and c side by side on its one layer. */ 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', 0), square('b', 40), square('c', 80)); state.project.assets.push(asset); const host: InspectorHost = { state, body: new VStack(), fields: new Map(), nameField: null, project: () => state.project, liveVariables: () => null, }; /** The rows of the current selection, built anew as the inspector does after a change. */ 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); else buildShapeListSection(host, asset); }; 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 button = (label: string) => widgets(Button).find((b) => b.text === label)!; const texts = () => widgets(Label).map((label) => label.text); const group = (...ids: string[]): string => { const command = new GroupVectorShapesCommand(asset.id, ids); state.execute(command); return command.groupId; }; const addLayer = (): string => { const command = new AddVectorLayerCommand(asset.id); state.execute(command); return command.layerId; }; const tree = () => asset.layers.map((layer) => text(layer.items)); return { state, asset, host, build, button, texts, group, addLayer, tree }; } describe('the lists of the inspector', () => { it('lists the layers top first and marks the one worked on', () => { const r = rig(); const base = r.asset.layers[0]!.id; const upper = r.addLayer(); r.build(); expect(currentLayerList()!.shownIds).toEqual([upper, base]); // Nothing was picked yet, so the topmost layer is the one worked on – and it is empty. expect(activeLayerOf(r.state, r.asset)?.id).toBe(upper); expect(currentShapeList()).toBeNull(); expect(r.texts()).toContain('Nothing on this layer yet – the Pen draws onto it.'); pickVectorLayer(r.state, r.asset.id, base); r.build(); expect(currentShapeList()!.shownIds).toEqual(['c', 'b', 'a']); }); it('follows the selected shape onto its layer, and remembers it once the shape is let go', () => { const r = rig(); const base = r.asset.layers[0]!.id; r.addLayer(); r.state.select(shapeSelection(r.asset.id, ['b'])); expect(activeLayerOf(r.state, r.asset)?.id).toBe(base); r.state.select({ kind: 'asset', assetId: r.asset.id }); expect(activeLayerOf(r.state, r.asset)?.id).toBe(base); }); it('lets go of the shapes of another layer when a layer is picked', () => { const r = rig(); const upper = r.addLayer(); r.state.select(shapeSelection(r.asset.id, ['b'])); pickVectorLayer(r.state, r.asset.id, upper); expect(r.state.selection).toEqual({ kind: 'asset', assetId: r.asset.id }); expect(activeLayerOf(r.state, r.asset)?.id).toBe(upper); }); it('rebuilds the rows for another layer, a group and a hidden layer', () => { const r = rig(); const base = r.asset.layers[0]!.id; pickVectorLayer(r.state, r.asset.id, base); const flat = shapeListSignature(r.asset); r.group('a', 'b'); const grouped = shapeListSignature(r.asset); expect(grouped).not.toBe(flat); r.state.execute(new SetVectorLayerHiddenCommand(r.asset.id, base, true)); const hidden = shapeListSignature(r.asset); expect(hidden).not.toBe(grouped); const upper = r.addLayer(); pickVectorLayer(r.state, r.asset.id, base); const onBase = shapeListSignature(r.asset); pickVectorLayer(r.state, r.asset.id, upper); expect(shapeListSignature(r.asset)).not.toBe(onBase); expect(shapeSignature(r.state.project, r.asset.id, 'a', ['a'])).toContain(`@${base}[`); }); it('shows a group over its shapes and selects all of them from its row', () => { const r = rig(); const g = r.group('a', 'b'); r.state.select(shapeSelection(r.asset.id, ['a'])); r.build(); expect(currentShapeList()!.shownIds).toEqual(['c', g, 'b', 'a']); pickShapeRow(r.state, r.asset.id, g, false); expect(r.state.selectedShapeIds).toEqual(['a', 'b']); }); it('adds a group to the selection with Ctrl and takes it out again as a whole', () => { const r = rig(); const g = r.group('a', 'b'); r.state.select(shapeSelection(r.asset.id, ['c'])); pickShapeRow(r.state, r.asset.id, g, true); expect(r.state.selectedShapeIds).toEqual(['c', 'a', 'b']); pickShapeRow(r.state, r.asset.id, g, true); expect(r.state.selectedShapeIds).toEqual(['c']); pickShapeRow(r.state, r.asset.id, 'c', true); expect(r.state.selection).toEqual({ kind: 'asset', assetId: r.asset.id }); }); it('puts a shape added from the inspector onto the layer worked on', () => { const r = rig(); const base = r.asset.layers[0]!.id; r.addLayer(); pickVectorLayer(r.state, r.asset.id, base); r.build(); r.button('Add shape').onClick!(); expect(r.asset.layers[0]!.items).toHaveLength(4); expect(r.asset.layers[1]!.items).toHaveLength(0); }); }); describe('grouping from the inspector', () => { it('groups the selection, which then reads as the group, and ungroups it again', () => { const r = rig(); r.state.select(shapeSelection(r.asset.id, ['a', 'c'])); r.build(); expect(r.button('Group').enabled).toBe(true); expect(r.button('Ungroup').enabled).toBe(false); r.button('Group').onClick!(); const made = r.asset.layers[0]!.items[1]!; expect(r.tree()).toEqual([`b ${made.id}[a c]`]); expect(r.state.selectedShapeIds).toEqual(['a', 'c']); r.build(); expect(r.button('Ungroup').enabled).toBe(true); r.button('Ungroup').onClick!(); expect(r.tree()).toEqual(['b a c']); r.state.undo(); r.state.undo(); expect(r.tree()).toEqual(['a b c']); }); it('ungroups the group the primary shape stands in when no group is picked as a whole', () => { const r = rig(); r.group('a', 'b'); r.state.select(shapeSelection(r.asset.id, ['a'])); r.build(); expect(r.button('Ungroup').enabled).toBe(true); r.button('Ungroup').onClick!(); expect(r.tree()).toEqual(['a b c']); }); it('moves a whole picked group with the order buttons, and a lone shape inside its group', () => { const r = rig(); const g = r.group('a', 'b'); r.state.select(shapeSelection(r.asset.id, ['a', 'b'])); r.build(); r.button('To front').onClick!(); expect(r.tree()).toEqual([`c ${g}[a b]`]); r.state.select(shapeSelection(r.asset.id, ['a'])); r.build(); expect(r.button('Backward').enabled).toBe(false); r.button('Forward').onClick!(); expect(r.tree()).toEqual([`c ${g}[b a]`]); }); it('switches Group off across two layers', () => { const r = rig(); const upper = r.addLayer(); moveShapesToLayer(r.state, r.asset.id, ['c'], upper); r.state.select(shapeSelection(r.asset.id, ['a', 'c'])); r.build(); expect(r.button('Group').enabled).toBe(false); }); it('offers the Layer row from two layers on and moves a picked group as a whole', () => { const r = rig(); const g = r.group('a', 'b'); r.state.select(shapeSelection(r.asset.id, ['a', 'b'])); r.build(); expect(r.host.fields.has('shapeLayer')).toBe(false); const upper = r.addLayer(); r.build(); expect(r.host.fields.has('shapeLayer')).toBe(true); moveShapesToLayer(r.state, r.asset.id, ['a', 'b'], upper); expect(r.tree()).toEqual(['c', `${g}[a b]`]); // The selection went along, so the layer worked on is now the upper one. expect(activeLayerOf(r.state, r.asset)?.id).toBe(upper); // Already there: no second step, so one undo takes the move back. moveShapesToLayer(r.state, r.asset.id, ['a', 'b'], upper); r.state.undo(); expect(r.tree()).toEqual([`${g}[a b] c`, '']); }); }); /** Presses run on one clock, far enough apart that no two join into a double-click. */ let clock = 0; function pressAt(global: Vec2, held: 'ctrl' | 'shift' | null = null) { clock += 1000; return { timeStamp: clock, button: 0, global, altKey: false, ctrlKey: held === 'ctrl', 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): void; release(): void; } function tab() { const r = rig(); const canvas = new VectorArtCanvas(r.state); canvas.show(r.asset.id); canvas.resize(400, 400); canvas.redraw(); const target = canvas as unknown as Pressable; const at = (x: number, y: number) => canvas.imageToScreen(x, y); // A Shift press only reaches the tab where it is not a pan, which the tab says itself. const click = (point: Vec2, held: 'ctrl' | 'shift' | null = null) => { if (held === 'shift' && !target.shiftPresses(r.asset, point)) return; target.press(r.asset, pressAt(point, held), point); target.release(); }; const drag = (from: Vec2, to: Vec2) => { target.press(r.asset, pressAt(from), from); target.move(to); target.release(); }; const corner = (id: string) => allShapes(r.asset).find((s) => s.id === id)!.rings[0]!.points[0]; return { ...r, canvas, at, click, drag, corner }; } describe('the tab', () => { it('selects the whole group on a press on one of its shapes, the pressed one first', () => { const t = tab(); t.group('a', 'b'); t.click(t.at(50, 10)); expect(t.state.selection).toMatchObject({ shapeId: 'b', shapeIds: ['a', 'b'] }); t.click(t.at(90, 10)); expect(t.state.selection).toMatchObject({ shapeId: 'c', shapeIds: ['c'] }); }); it('selects the outermost group of a shape that stands two groups deep', () => { const t = tab(); const inner = t.group('a'); t.group(inner, 'b'); t.click(t.at(10, 10)); expect(t.state.selection).toMatchObject({ shapeId: 'a', shapeIds: ['a', 'b'] }); }); it('moves a group by dragging one of its shapes, as one undo step', () => { const t = tab(); t.group('a', 'b'); t.drag(t.at(10, 10), t.at(15, 40)); expect(t.corner('a')).toMatchObject({ x: 5, y: 30 }); expect(t.corner('b')).toMatchObject({ x: 45, y: 30 }); expect(t.corner('c')).toMatchObject({ x: 80, y: 0 }); t.state.undo(); expect(t.corner('a')).toMatchObject({ x: 0, y: 0 }); expect(t.corner('b')).toMatchObject({ x: 40, y: 0 }); }); it('carries every selected shape when one of them is dragged, grouped or not', () => { const t = tab(); t.state.select(shapeSelection(t.asset.id, ['a', 'c'])); t.drag(t.at(90, 10), t.at(90, 30)); expect(t.state.selection).toMatchObject({ shapeId: 'c', shapeIds: ['a', 'c'] }); expect(t.corner('a')).toMatchObject({ x: 0, y: 20 }); expect(t.corner('c')).toMatchObject({ x: 80, y: 20 }); expect(t.corner('b')).toMatchObject({ x: 40, y: 0 }); }); it.each(['ctrl', 'shift'] as const)( 'adds a shape to the selection with %s held and takes it out again', (held) => { const t = tab(); t.click(t.at(10, 10)); t.click(t.at(90, 10), held); expect(t.state.selectedShapeIds).toEqual(['a', 'c']); t.click(t.at(50, 10), held); expect(t.state.selectedShapeIds).toEqual(['a', 'c', 'b']); t.click(t.at(90, 10), held); expect(t.state.selectedShapeIds).toEqual(['a', 'b']); // Nothing was moved or changed on the way. expect(t.corner('c')).toMatchObject({ x: 80, y: 0 }); }, ); it('leaves a Shift press to the pan beside the shapes and with another tool', () => { const t = tab(); const target = t.canvas as unknown as Pressable; expect(target.shiftPresses(t.asset, t.at(10, 10))).toBe(true); expect(target.shiftPresses(t.asset, t.at(10, 60))).toBe(false); t.canvas.setTool('pen'); expect(target.shiftPresses(t.asset, t.at(10, 10))).toBe(false); }); it('leaves the selection as it is on a click that moves nothing', () => { const t = tab(); t.group('a', 'b'); t.click(t.at(10, 10)); t.click(t.at(50, 10)); expect(t.state.selection).toMatchObject({ shapeId: 'b', shapeIds: ['a', 'b'] }); expect(t.corner('a')).toMatchObject({ x: 0, y: 0 }); // No step was made: the last one is still the grouping. t.state.undo(); expect(t.tree()).toEqual(['a b c']); }); it('groups and ungroups the selection, and does nothing while only the drawing is selected', () => { const t = tab(); t.state.select(shapeSelection(t.asset.id, ['a', 'b'])); expect(groupShapes(t.state, t.asset.id)).toBe(true); expect(t.tree()[0]).toMatch(/^\S+\[a b\] c$/); expect(ungroupShapes(t.state, t.asset.id)).toBe(true); expect(t.tree()).toEqual(['a b c']); expect(ungroupShapes(t.state, t.asset.id)).toBe(false); t.state.select({ kind: 'asset', assetId: t.asset.id }); expect(groupShapes(t.state, t.asset.id)).toBe(false); }); it('does not let a shape on a hidden layer be pressed', () => { const t = tab(); const base = t.asset.layers[0]!.id; t.state.execute(new SetVectorLayerHiddenCommand(t.asset.id, base, true)); t.click(t.at(10, 10)); expect(t.state.selection).toEqual({ kind: 'asset', assetId: t.asset.id }); }); it('draws with the pen onto the layer worked on, and nothing onto a hidden one', () => { const t = tab(); const base = t.asset.layers[0]!.id; const upper = t.addLayer(); pickVectorLayer(t.state, t.asset.id, base); t.canvas.setTool('pen'); t.click(t.at(150, 150)); expect(t.asset.layers[0]!.items).toHaveLength(4); expect(t.asset.layers[1]!.items).toHaveLength(0); t.canvas.onKeyDown({ key: 'Escape' } as KeyboardEvent); t.state.execute(new SetVectorLayerHiddenCommand(t.asset.id, upper, true)); pickVectorLayer(t.state, t.asset.id, upper); t.click(t.at(170, 170)); expect(allShapes(t.asset)).toHaveLength(4); }); });