/** * Layers and groups of vector art (#190): the tree as it is read, every way it changes, the commands * that change it with their undo, what a project file gives back and what is in the picture. */ import { describe, expect, it } from 'vitest'; import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; import { AddVectorLayerCommand, AddVectorShapeCommand, CombineVectorShapesCommand, DuplicateVectorShapeCommand, GroupVectorShapesCommand, MoveVectorLayerCommand, MoveVectorShapesCommand, OrderVectorShapeCommand, RemoveVectorLayerCommand, RemoveVectorShapeCommand, RemoveVectorShapesCommand, RenameVectorLayerCommand, RenameVectorShapeCommand, SetVectorLayerHiddenCommand, SetVectorShapeRingsCommand, UngroupVectorShapesCommand, } from '../../src/core/commands/vectorArtCommands'; import type { VectorArtAsset, VectorItem, VectorLayer, VectorShape } from '../../src/core/model/Asset'; import { findAsset, type Project } from '../../src/core/model/Project'; import { vectorArtRevision } from '../../src/core/model/vectorart'; import { vectorArtDrawings } from '../../src/core/model/vectorPath'; import { allShapes, drawnShapes, outermostGroup, placeOf, selectedItems, treeDrop, treeDropDepth, type TreeRow, treeRows, VectorTreeError, withGroupDissolved, withItemsGrouped, withItemsMoved, withItemsRemoved, withLayerRemoved, } from '../../src/core/model/vectorTree'; import { makeProject, roundTrip } from '../helpers'; function shape(id: string, x = 0): VectorShape { return { id, name: id.toUpperCase(), fill: 0x112233, opacity: 1, rings: [ { closed: true, points: [ { x, y: 0 }, { x: x + 10, y: 0 }, { x, y: 10 }, ], }, ], }; } const group = (id: string, ...items: VectorItem[]): VectorItem => ({ id, name: id.toUpperCase(), items }); const layer = (id: string, ...items: VectorItem[]): VectorLayer => ({ id, name: id, items }); /** How a tree reads, groups in brackets: `a g[b h[c]] d`. */ function text(items: readonly VectorItem[]): string { return items.map((item) => ('items' in item ? `${item.id}[${text(item.items)}]` : item.id)).join(' '); } const layersText = (layers: readonly VectorLayer[]) => layers.map((l) => `${l.id}: ${text(l.items)}`); /** a, then group g of b and the group h of c, then d – on one layer. */ const nested = () => [layer('l1', shape('a'), group('g', shape('b'), group('h', shape('c'))), shape('d'))]; const row = (id: string, depth: number, isGroup = false): TreeRow => ({ id, name: id.toUpperCase(), depth, group: isGroup, }); describe('reading the tree', () => { it('hands the shapes out in drawing order, groups walked in place', () => { const art = { layers: [...nested(), layer('l2', shape('e'))] } as VectorArtAsset; expect(allShapes(art).map((s) => s.id)).toEqual(['a', 'b', 'c', 'd', 'e']); }); it('leaves the shapes of a hidden layer out of what is drawn, and only there', () => { const art = { id: 'art', type: 'vectorart', name: 'Art', props: {}, layers: [{ ...layer('l1', shape('a')), hidden: true }, layer('l2', shape('b'))], } as VectorArtAsset; expect(drawnShapes(art).map((s) => s.id)).toEqual(['b']); expect(allShapes(art).map((s) => s.id)).toEqual(['a', 'b']); expect(vectorArtDrawings(art).map((d) => d.id)).toEqual(['b']); }); it('says where an item stands, and which outermost group it is in', () => { const layers = nested(); expect(placeOf(layers, 'c')).toMatchObject({ index: 0, depth: 2, parent: { id: 'h' }, layer: { id: 'l1' }, }); expect(placeOf(layers, 'a')).toMatchObject({ index: 0, depth: 0, parent: null }); expect(placeOf(layers, 'nobody')).toBeNull(); expect(outermostGroup(layers, 'c')?.id).toBe('g'); expect(outermostGroup(layers, 'g')).toBeNull(); expect(outermostGroup(layers, 'a')).toBeNull(); }); it('reads a selection of shapes as the groups it covers completely', () => { const layers = nested(); expect(selectedItems(layers, ['b', 'c'])).toEqual(['g']); expect(selectedItems(layers, ['c'])).toEqual(['h']); expect(selectedItems(layers, ['d', 'b', 'a'])).toEqual(['a', 'b', 'd']); }); it('lists a layer top row first, a group over what is in it', () => { expect(treeRows(nested()[0]!.items).map((r) => `${r.id}${r.depth}${r.group ? 'g' : ''}`)).toEqual([ 'd0', 'g0g', 'h1g', 'c2', 'b1', 'a0', ]); }); }); describe('dropping a row', () => { // d / g / h / c / b / a, as `treeRows` lists `nested()`. const rows = [row('d', 0), row('g', 0, true), row('h', 1, true), row('c', 2), row('b', 1), row('a', 0)]; it('puts a row between two of the same list', () => { expect(treeDrop(rows, 'a', 0, 0)).toEqual({ parentId: null, belowId: 'd' }); expect(treeDrop(rows, 'd', 6, 0)).toEqual({ parentId: null, belowId: null }); }); it('drops into a group right under its row, and deeper into the one inside', () => { expect(treeDrop(rows, 'a', 2, 1)).toEqual({ parentId: 'g', belowId: 'h' }); expect(treeDrop(rows, 'a', 3, 2)).toEqual({ parentId: 'h', belowId: 'c' }); expect(treeDropDepth(rows, 'a', 3, 2)).toBe(2); }); it('lets the pointer say which list the end of a group belongs to', () => { // Between c (depth 2) and b (depth 1): the end of h, or g after h. expect(treeDrop(rows, 'a', 4, 2)).toEqual({ parentId: 'h', belowId: null }); expect(treeDrop(rows, 'a', 4, 1)).toEqual({ parentId: 'g', belowId: 'b' }); // No shallower than the row below: g goes on under this gap. expect(treeDrop(rows, 'a', 4, 0)).toEqual({ parentId: 'g', belowId: 'b' }); // Between b (depth 1) and a (depth 0), d dragged: the end of g, or the layer after g. expect(treeDrop(rows, 'd', 5, 1)).toEqual({ parentId: 'g', belowId: null }); expect(treeDrop(rows, 'd', 5, 0)).toEqual({ parentId: null, belowId: 'a' }); expect(treeDropDepth(rows, 'd', 5, 7)).toBe(1); }); it('takes a row out of its group when it is dropped where it stands, further left', () => { // b is the last row of g; the gap under it, asked for at depth 0, is the layer. expect(treeDrop(rows, 'b', 5, 0)).toEqual({ parentId: null, belowId: 'a' }); expect(treeDrop(rows, 'b', 5, 1)).toBeNull(); }); it('has no place where the row already is, inside itself, or for a row it does not know', () => { expect(treeDrop(rows, 'd', 0, 0)).toBeNull(); expect(treeDrop(rows, 'd', 1, 0)).toBeNull(); expect(treeDrop(rows, 'g', 3, 2)).toBeNull(); expect(treeDrop(rows, 'g', 4, 1)).toBeNull(); expect(treeDropDepth(rows, 'g', 3, 2)).toBeNull(); expect(treeDrop(rows, 'nobody', 0, 0)).toBeNull(); }); }); describe('editing the tree', () => { it('never touches the layers it was given', () => { const layers = nested(); const before = JSON.stringify(layers); withItemsRemoved(layers, ['c']); withItemsGrouped(layers, ['a', 'd'], { id: 'n', name: 'N' }); withItemsMoved(layers, ['a'], 'l1', { parentId: 'h', belowId: null }); withGroupDissolved(layers, 'g'); expect(JSON.stringify(layers)).toBe(before); }); it('takes a group away with its last item', () => { expect(layersText(withItemsRemoved(nested(), ['c']))).toEqual(['l1: a g[b] d']); expect(layersText(withItemsRemoved(nested(), ['c', 'b']))).toEqual(['l1: a d']); expect(layersText(withItemsRemoved(nested(), ['g']))).toEqual(['l1: a d']); expect(() => withItemsRemoved(nested(), ['nobody'])).toThrow(VectorTreeError); }); it('groups items where the topmost of them stood, in the order they are drawn', () => { const made = { id: 'n', name: 'N' }; expect(layersText(withItemsGrouped(nested(), ['d', 'a'], made))).toEqual(['l1: g[b h[c]] n[a d]']); expect(layersText(withItemsGrouped(nested(), ['a', 'b'], made))).toEqual(['l1: g[n[a b] h[c]] d']); expect(layersText(withItemsGrouped(nested(), ['g', 'a'], made))).toEqual(['l1: n[a g[b h[c]]] d']); }); it('refuses a group across two layers', () => { const layers = [...nested(), layer('l2', shape('e'))]; expect(() => withItemsGrouped(layers, ['a', 'e'], { id: 'n', name: 'N' })).toThrow( 'Shapes of different layers cannot be grouped', ); }); it('dissolves a group into the place it stood in', () => { expect(layersText(withGroupDissolved(nested(), 'g'))).toEqual(['l1: a b h[c] d']); expect(layersText(withGroupDissolved(nested(), 'h'))).toEqual(['l1: a g[b c] d']); expect(() => withGroupDissolved(nested(), 'a')).toThrow(VectorTreeError); }); it('moves items into a group, out of one, and onto another layer', () => { const layers = [...nested(), layer('l2', shape('e'))]; expect(layersText(withItemsMoved(layers, ['a'], 'l1', { parentId: 'g', belowId: 'b' }))).toEqual([ 'l1: g[b a h[c]] d', 'l2: e', ]); expect(layersText(withItemsMoved(layers, ['c'], 'l1', { parentId: null, belowId: null }))).toEqual([ 'l1: c a g[b] d', 'l2: e', ]); // Without a place it lands on top of the layer; a group goes as a whole. expect(layersText(withItemsMoved(layers, ['g', 'a'], 'l2'))).toEqual(['l1: d', 'l2: e a g[b h[c]]']); }); it('keeps a group whose only item is put back into it', () => { expect(layersText(withItemsMoved(nested(), ['c'], 'l1', { parentId: 'h', belowId: null }))).toEqual([ 'l1: a g[b h[c]] d', ]); }); it('refuses to move a group into itself or into a group that is not there', () => { expect(() => withItemsMoved(nested(), ['g'], 'l1', { parentId: 'h', belowId: null })).toThrow( 'A group cannot be moved into itself', ); expect(() => withItemsMoved(nested(), ['a'], 'l1', { parentId: 'nobody', belowId: null })).toThrow( VectorTreeError, ); expect(() => withItemsMoved(nested(), ['a'], 'l1', { parentId: 'd', belowId: null })).toThrow( VectorTreeError, ); }); it('keeps the last layer', () => { expect(() => withLayerRemoved(nested(), 'l1')).toThrow('A drawing needs one layer'); }); }); function drawing() { const fixture = makeProject(); const create = new CreateAssetCommand('vectorart', 'Logo'); fixture.stack.execute(create); const assetId = create.asset.id; const art = (project: Project = fixture.project): VectorArtAsset => findAsset(project, assetId) as VectorArtAsset; const add = (layerId = ''): string => { const command = new AddVectorShapeCommand(assetId, shape('x').rings, undefined, layerId); fixture.stack.execute(command); return command.shapeId; }; const tree = () => layersText(art().layers).map((line) => line.replace(/^[^:]+: /, '')); return { ...fixture, assetId, art, add, tree }; } describe('the commands', () => { it('starts a drawing with one layer and draws onto the topmost one unless told', () => { const f = drawing(); expect(f.art().layers.map((l) => l.name)).toEqual(['Layer 1']); const first = f.add(); const addLayer = new AddVectorLayerCommand(f.assetId); f.stack.execute(addLayer); expect(f.art().layers.map((l) => l.name)).toEqual(['Layer 1', 'Layer 2']); const second = f.add(); const third = f.add(f.art().layers[0]!.id); expect(f.tree()).toEqual([`${first} ${third}`, second]); // Numbered across the layers: no two shapes of a drawing share a name. expect(allShapes(f.art()).map((s) => s.name)).toEqual(['Shape 1', 'Shape 3', 'Shape 2']); }); it('groups and ungroups in one undo step each, under the same id on redo', () => { const f = drawing(); const [a, b, c] = [f.add(), f.add(), f.add()]; const made = new GroupVectorShapesCommand(f.assetId, [a, c]); f.stack.execute(made); expect(f.tree()).toEqual([`${b} ${made.groupId}[${a} ${c}]`]); expect(placeOf(f.art().layers, made.groupId)?.item.name).toBe('Group 1'); f.stack.undo(); expect(f.tree()).toEqual([`${a} ${b} ${c}`]); f.stack.redo(); expect(f.tree()).toEqual([`${b} ${made.groupId}[${a} ${c}]`]); f.stack.execute(new UngroupVectorShapesCommand(f.assetId, [made.groupId])); expect(f.tree()).toEqual([`${b} ${a} ${c}`]); f.stack.undo(); expect(f.tree()).toEqual([`${b} ${made.groupId}[${a} ${c}]`]); }); it('brings a removed group back with a shape edited before it went', () => { const f = drawing(); const [a, b] = [f.add(), f.add()]; const made = new GroupVectorShapesCommand(f.assetId, [a, b]); f.stack.execute(made); f.stack.execute(new SetVectorShapeRingsCommand(f.assetId, a, shape('y', 50).rings)); f.stack.execute(new RemoveVectorShapeCommand(f.assetId, made.groupId)); expect(allShapes(f.art())).toEqual([]); f.stack.undo(); expect(f.tree()).toEqual([`${made.groupId}[${a} ${b}]`]); expect(allShapes(f.art())[0]!.rings[0]!.points[0]).toEqual({ x: 50, y: 0 }); f.stack.undo(); expect(allShapes(f.art())[0]!.rings[0]!.points[0]).toEqual({ x: 0, y: 0 }); }); it('removes shapes out of a group and takes the emptied group with them', () => { const f = drawing(); const [a, b, c] = [f.add(), f.add(), f.add()]; const made = new GroupVectorShapesCommand(f.assetId, [a, b]); f.stack.execute(made); f.stack.execute(new RemoveVectorShapesCommand(f.assetId, [a, b])); expect(f.tree()).toEqual([c]); f.stack.undo(); expect(f.tree()).toEqual([`${made.groupId}[${a} ${b}] ${c}`]); }); it('orders an item among those of its own list only', () => { const f = drawing(); const [a, b, c] = [f.add(), f.add(), f.add()]; const made = new GroupVectorShapesCommand(f.assetId, [a, b]); f.stack.execute(made); f.stack.execute(new OrderVectorShapeCommand(f.assetId, a, 'front')); expect(f.tree()).toEqual([`${made.groupId}[${b} ${a}] ${c}`]); // Already on top of its group: the shape outside is no step further. expect(() => f.stack.execute(new OrderVectorShapeCommand(f.assetId, a, 'forward'))).toThrow(); f.stack.execute(new OrderVectorShapeCommand(f.assetId, made.groupId, 'front')); expect(f.tree()).toEqual([`${c} ${made.groupId}[${b} ${a}]`]); }); it('duplicates a group with all that is in it, right over it, under new ids', () => { const f = drawing(); const [a, b] = [f.add(), f.add()]; const made = new GroupVectorShapesCommand(f.assetId, [a, b]); f.stack.execute(made); const copy = new DuplicateVectorShapeCommand(f.assetId, made.groupId); f.stack.execute(copy); const place = placeOf(f.art().layers, copy.shapeId)!; expect(place.index).toBe(1); expect(place.item.name).toBe('Group 1 copy'); const ids = allShapes(f.art()).map((s) => s.id); expect(ids).toHaveLength(4); expect(new Set(ids).size).toBe(4); const copied = ids[2]!; f.stack.undo(); f.stack.redo(); expect(allShapes(f.art())[2]!.id).toBe(copied); }); it('duplicates a shape inside its group', () => { const f = drawing(); const [a, b] = [f.add(), f.add()]; const made = new GroupVectorShapesCommand(f.assetId, [a, b]); f.stack.execute(made); const copy = new DuplicateVectorShapeCommand(f.assetId, a); f.stack.execute(copy); expect(f.tree()).toEqual([`${made.groupId}[${a} ${copy.shapeId} ${b}]`]); }); it('combines shapes across groups into the lowest and undoes it with the groups', () => { const f = drawing(); const [a, b] = [f.add(), f.add()]; const made = new GroupVectorShapesCommand(f.assetId, [b]); f.stack.execute(made); const combine = new CombineVectorShapesCommand(f.assetId, [a, b], 'union'); f.stack.execute(combine); expect(combine.shapeId).toBe(a); expect(f.tree()).toEqual([a]); f.stack.undo(); expect(f.tree()).toEqual([`${a} ${made.groupId}[${b}]`]); }); it('renames a group and refuses a blank name', () => { const f = drawing(); const made = new GroupVectorShapesCommand(f.assetId, [f.add()]); f.stack.execute(made); f.stack.execute(new RenameVectorShapeCommand(f.assetId, made.groupId, ' Wheels ')); expect(placeOf(f.art().layers, made.groupId)?.item.name).toBe('Wheels'); expect(() => f.stack.execute(new RenameVectorShapeCommand(f.assetId, made.groupId, ' '))).toThrow( 'A group needs a name', ); f.stack.undo(); expect(placeOf(f.art().layers, made.groupId)?.item.name).toBe('Group 1'); }); it('moves shapes to another layer and back with an undo', () => { const f = drawing(); const [a, b] = [f.add(), f.add()]; const upper = new AddVectorLayerCommand(f.assetId); f.stack.execute(upper); f.stack.execute(new MoveVectorShapesCommand(f.assetId, [a], upper.layerId)); expect(f.tree()).toEqual([b, a]); f.stack.undo(); expect(f.tree()).toEqual([`${a} ${b}`, '']); }); it('adds a layer over the one named, reorders, renames and removes with all on it', () => { const f = drawing(); const base = f.art().layers[0]!.id; const top = new AddVectorLayerCommand(f.assetId); f.stack.execute(top); const between = new AddVectorLayerCommand(f.assetId, base); f.stack.execute(between); expect(f.art().layers.map((l) => l.id)).toEqual([base, between.layerId, top.layerId]); f.stack.execute(new MoveVectorLayerCommand(f.assetId, 0, 2)); expect(f.art().layers.map((l) => l.id)).toEqual([between.layerId, top.layerId, base]); f.stack.execute(new RenameVectorLayerCommand(f.assetId, base, 'Ink')); expect(f.art().layers[2]!.name).toBe('Ink'); expect(() => f.stack.execute(new RenameVectorLayerCommand(f.assetId, base, ' '))).toThrow( 'A layer needs a name', ); const a = f.add(base); f.stack.execute(new RemoveVectorLayerCommand(f.assetId, base)); expect(allShapes(f.art())).toEqual([]); f.stack.undo(); expect(allShapes(f.art()).map((s) => s.id)).toEqual([a]); }); it('makes a new picture of a layer hidden, and the old one of it shown again', () => { const f = drawing(); f.add(); const layerId = f.art().layers[0]!.id; const shown = vectorArtRevision(f.art()); f.stack.execute(new SetVectorLayerHiddenCommand(f.assetId, layerId, true)); expect(f.art().layers[0]!.hidden).toBe(true); expect(vectorArtRevision(f.art())).not.toBe(shown); expect(vectorArtDrawings(f.art())).toEqual([]); f.stack.execute(new SetVectorLayerHiddenCommand(f.assetId, layerId, false)); expect('hidden' in f.art().layers[0]!).toBe(false); expect(vectorArtRevision(f.art())).toBe(shown); }); it('draws the same picture whatever groups the shapes stand in', () => { const f = drawing(); const [a, b] = [f.add(), f.add()]; const flat = vectorArtRevision(f.art()); f.stack.execute(new GroupVectorShapesCommand(f.assetId, [a, b])); expect(vectorArtRevision(f.art())).toBe(flat); }); }); describe('the project file', () => { it('gives layers, groups and the hidden mark back as they were written', () => { const f = drawing(); const [a, b] = [f.add(), f.add()]; f.stack.execute(new GroupVectorShapesCommand(f.assetId, [a, b])); const upper = new AddVectorLayerCommand(f.assetId); f.stack.execute(upper); f.add(upper.layerId); f.stack.execute(new SetVectorLayerHiddenCommand(f.assetId, upper.layerId, true)); expect(f.art(roundTrip(f.project)).layers).toEqual(f.art().layers); }); it('reads the flat shapes of an older drawing as its one layer', () => { const f = drawing(); const art = f.art() as unknown as Record; delete art.layers; art.shapes = [shape('a'), shape('b')]; const loaded = f.art(roundTrip(f.project)); expect(loaded.layers).toHaveLength(1); expect(loaded.layers[0]).toMatchObject({ name: 'Layer 1' }); expect(text(loaded.layers[0]!.items)).toBe('a b'); expect('shapes' in loaded).toBe(false); }); it('drops an empty group, a doubled id anywhere in the drawing and a layer without an id', () => { const f = drawing(); const art = f.art() as unknown as Record; art.layers = [ { id: 'l1', name: 'One', items: [shape('a'), { id: 'g', name: 'G', items: [] }, group('h', shape('a'))], }, { id: 'l1', name: 'Twin', items: [shape('b')] }, { name: 'Nameless', items: [shape('c')] }, { id: 'l2', hidden: 'yes', items: [shape('a'), shape('d')] }, ]; const loaded = f.art(roundTrip(f.project)); expect(layersText(loaded.layers)).toEqual(['l1: a', 'l2: d']); expect(loaded.layers[1]).toMatchObject({ name: 'Layer' }); expect('hidden' in loaded.layers[1]!).toBe(false); }); it('gives a drawing left without a layer one to draw on', () => { const f = drawing(); (f.art() as unknown as Record).layers = []; const loaded = f.art(roundTrip(f.project)); expect(loaded.layers).toHaveLength(1); expect(loaded.layers[0]!.items).toEqual([]); }); });