Put the shapes of a vector drawing on layers and in groups (#190)
A vector drawing was one flat list of shapes. With many shapes the list got hard to read, and several shapes could not be kept or moved together. Now the shapes stand on layers, as in pixel art, and can be put into groups that nest. - A drawing has layers, each with a name and an eye. A hidden layer is neither in the tab nor in the picture a sprite wears, and its shapes cannot be pressed. One layer is the one worked on: the pen and Add shape draw onto it, and SHAPES lists what is on it. - The inspector has a list LAYERS over the shapes. Its plus adds a layer, the cross on a row removes it with all on it, the last one stays. A double-click renames, a drag sorts. - Group (Ctrl+G) puts the selected shapes into a group, Ungroup (Ctrl+Shift+G) takes it away. A group stands over its rows in the list, the rows one step in, and a row is dragged into a group or out of it. - In the tab a press on a shape takes its outermost group, and a drag moves the group as one undo step. - The model has layers with a tree of shapes and groups instead of a flat shapes array. The rules are pure functions in core/model/vectorTree.ts. A drawing saved before opens with all its shapes on one layer. - SortableList takes rows with a depth and drops into a tree. The flat lists of tilemap and pixel art run as before. Decided here: - A group has no transformation of its own and cannot be hidden or locked. Rotating and scaling is the follow-up #291. - A group is never empty: the one that loses its last shape goes with it. - Several selected shapes are dragged together, grouped or not, and a click on a shape of the selection keeps the selection. This is what #217 asked for. - Shift+click selects like Ctrl+click. With Select, Shift pans only beside the shapes, as in the scene view. The middle button pans anywhere, and with the pen and Paint Shift pans as before. - A row of the list picks one shape of a group alone. - Shapes change their layer through a row Layer in the inspector, which is there from two layers on. Rows are not dragged between layers. - A group cannot span two layers, the button is off then. - The pen does not draw onto a hidden layer, the hint says so. - Groups are always shown unfolded. - The four order buttons work inside the own group or layer. Tests: - Gate: 204 files, 3067 tests (3008 + 59), exit 0 - e2e 47,48: 108 checks. e2e 39,40,47,48 before the Shift change: 209 checks. No full run, the sum of 1645 (1639 + 6) is reckoned and CI measures it. - 59 unit tests: 36 in tests/core/vector-tree.test.ts, 23 in tests/editor/vector-layers.test.ts - Six e2e checks with a real mouse in block 47j - 47 unit counter-probes for layers and groups and three for the Shift click all fail tests, run on stein2 - Three browser counter-probes fail their checks - Two screenshots looked at (group in the list, hidden layer) - Only unit tested: the row Layer, sorting and removing layers, copying a group, the Shift pan beside the shapes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
393caadc9d
commit
2ff3a78607
30 files changed
+2625
-408
No files matched your search
@@ -22,6 +22,7 @@ import {
|
||||
flattenRing,
|
||||
type ShapeBoolean,
|
||||
} from '../../src/core/model/vectorBoolean';
|
||||
import { allShapes } from '../../src/core/model/vectorTree';
|
||||
import { makeProject } from '../helpers';
|
||||
|
||||
function square(x: number, y: number, side: number): VectorRing {
|
||||
@@ -280,38 +281,38 @@ describe('the combine command', () => {
|
||||
const combine = new CombineVectorShapesCommand(f.assetId, [high, low], 'subtract');
|
||||
f.stack.execute(combine);
|
||||
expect(combine.shapeId).toBe(low);
|
||||
expect(f.art().shapes.map((s) => s.id)).toEqual([first, low]);
|
||||
const result = f.art().shapes[1]!;
|
||||
expect(allShapes(f.art()).map((s) => s.id)).toEqual([first, low]);
|
||||
const result = allShapes(f.art())[1]!;
|
||||
expect(result).toMatchObject({ name: 'Shape 2', fill: 0xff0000, stroke: { color: 0, width: 2 } });
|
||||
expect(paintedArea(result.rings)).toBe(300);
|
||||
});
|
||||
|
||||
it('is one undo step that puts every shape back as it was', () => {
|
||||
const f = drawingWith(square(0, 0, 20), square(10, 10, 20), square(40, 40, 10));
|
||||
const before = JSON.stringify(f.art().shapes);
|
||||
const before = JSON.stringify(allShapes(f.art()));
|
||||
f.stack.execute(new CombineVectorShapesCommand(f.assetId, f.ids, 'union'));
|
||||
expect(f.art().shapes).toHaveLength(1);
|
||||
expect(allShapes(f.art())).toHaveLength(1);
|
||||
f.stack.undo();
|
||||
expect(JSON.stringify(f.art().shapes)).toBe(before);
|
||||
expect(JSON.stringify(allShapes(f.art()))).toBe(before);
|
||||
f.stack.redo();
|
||||
expect(paintedArea(f.art().shapes[0]!.rings)).toBe(800);
|
||||
expect(paintedArea(allShapes(f.art())[0]!.rings)).toBe(800);
|
||||
});
|
||||
|
||||
it('refuses a result of nothing and leaves the drawing alone', () => {
|
||||
const f = drawingWith(square(0, 0, 10), square(30, 30, 10));
|
||||
const before = JSON.stringify(f.art().shapes);
|
||||
const before = JSON.stringify(allShapes(f.art()));
|
||||
expect(() => f.stack.execute(new CombineVectorShapesCommand(f.assetId, f.ids, 'intersect'))).toThrow(
|
||||
/nothing in common/,
|
||||
);
|
||||
expect(JSON.stringify(f.art().shapes)).toBe(before);
|
||||
expect(JSON.stringify(allShapes(f.art()))).toBe(before);
|
||||
});
|
||||
|
||||
it('removes several shapes in one step and brings them back in their places', () => {
|
||||
const f = drawingWith(square(0, 0, 5), square(10, 0, 5), square(20, 0, 5));
|
||||
const before = f.art().shapes.map((s) => s.id);
|
||||
const before = allShapes(f.art()).map((s) => s.id);
|
||||
f.stack.execute(new RemoveVectorShapesCommand(f.assetId, [f.ids[0]!, f.ids[2]!]));
|
||||
expect(f.art().shapes.map((s) => s.id)).toEqual([f.ids[1]]);
|
||||
expect(allShapes(f.art()).map((s) => s.id)).toEqual([f.ids[1]]);
|
||||
f.stack.undo();
|
||||
expect(f.art().shapes.map((s) => s.id)).toEqual(before);
|
||||
expect(allShapes(f.art()).map((s) => s.id)).toEqual(before);
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from '../../src/core/model/vectorPaint';
|
||||
import { shapeDrawing } from '../../src/core/model/vectorPath';
|
||||
import { makeProject, roundTrip } from '../helpers';
|
||||
import { allShapes } from '../../src/core/model/vectorTree';
|
||||
|
||||
const FILE = `${'c'.repeat(64)}.png`;
|
||||
|
||||
@@ -214,7 +215,7 @@ describe('drawing a paint', () => {
|
||||
describe('a paint in the picture key and the project file', () => {
|
||||
it('is a new drawing when a stop, an end or the picture behind a texture changes', () => {
|
||||
const f = withTexture();
|
||||
const shapeOf = () => f.drawing().shapes[0]!;
|
||||
const shapeOf = () => allShapes(f.drawing())[0]!;
|
||||
const plain = vectorArtRevision(f.drawing(), f.drawing().props, f.project);
|
||||
shapeOf().paint = gradient();
|
||||
const linear = vectorArtRevision(f.drawing(), f.drawing().props, f.project);
|
||||
@@ -231,10 +232,10 @@ describe('a paint in the picture key and the project file', () => {
|
||||
|
||||
it('comes back from a project file as it was written', () => {
|
||||
const f = withTexture();
|
||||
const shapeNow = f.drawing().shapes[0]!;
|
||||
const shapeNow = allShapes(f.drawing())[0]!;
|
||||
shapeNow.paint = gradient({ type: 'radial' });
|
||||
shapeNow.stroke = { color: 0xff0000, width: 2, paint: gradient() };
|
||||
const back = f.drawing(roundTrip(f.project)).shapes[0]!;
|
||||
const back = allShapes(f.drawing(roundTrip(f.project)))[0]!;
|
||||
expect(back.paint).toEqual(shapeNow.paint);
|
||||
expect(back.stroke).toEqual(shapeNow.stroke);
|
||||
expect(vectorArtRevision(f.drawing(roundTrip(f.project)))).toBe(vectorArtRevision(f.drawing()));
|
||||
@@ -242,9 +243,11 @@ describe('a paint in the picture key and the project file', () => {
|
||||
|
||||
it('drops a paint it cannot draw and keeps the colour the shape had', () => {
|
||||
const f = withTexture();
|
||||
const raw = JSON.parse(JSON.stringify(f.project)) as { assets: { shapes?: unknown[] }[] };
|
||||
const drawing = raw.assets.find((a) => Array.isArray(a.shapes))!;
|
||||
const written = drawing.shapes![0] as Record<string, unknown>;
|
||||
const raw = JSON.parse(JSON.stringify(f.project)) as {
|
||||
assets: { type: string; layers?: { items: unknown[] }[] }[];
|
||||
};
|
||||
const drawing = raw.assets.find((a) => a.type === 'vectorart')!;
|
||||
const written = drawing.layers![0]!.items[0] as Record<string, unknown>;
|
||||
written.paint = {
|
||||
type: 'linear',
|
||||
from: { x: 0, y: 0 },
|
||||
@@ -256,7 +259,7 @@ describe('a paint in the picture key and the project file', () => {
|
||||
width: 1,
|
||||
paint: { type: 'texture', from: { x: 0, y: 0 }, to: { x: 1, y: 1 }, texture: 'x' },
|
||||
};
|
||||
const back = f.drawing(roundTrip(raw as unknown as Project)).shapes[0]!;
|
||||
const back = allShapes(f.drawing(roundTrip(raw as unknown as Project)))[0]!;
|
||||
expect(back.paint).toBeUndefined();
|
||||
expect(back.stroke).toEqual({ color: 5, width: 1 });
|
||||
});
|
||||
@@ -266,13 +269,13 @@ describe('writing a paint', () => {
|
||||
it('sets and removes a fill paint as one undo step each', () => {
|
||||
const f = withTexture();
|
||||
f.stack.execute(new SetVectorShapeStyleCommand(f.assetId, f.shapeId, { paint: gradient() }));
|
||||
expect(f.drawing().shapes[0]!.paint).toEqual(gradient());
|
||||
expect(allShapes(f.drawing())[0]!.paint).toEqual(gradient());
|
||||
f.stack.execute(new SetVectorShapeStyleCommand(f.assetId, f.shapeId, { paint: null }));
|
||||
expect(f.drawing().shapes[0]!.paint).toBeUndefined();
|
||||
expect(allShapes(f.drawing())[0]!.paint).toBeUndefined();
|
||||
f.stack.undo();
|
||||
expect(f.drawing().shapes[0]!.paint).toEqual(gradient());
|
||||
expect(allShapes(f.drawing())[0]!.paint).toEqual(gradient());
|
||||
f.stack.undo();
|
||||
expect(f.drawing().shapes[0]!.paint).toBeUndefined();
|
||||
expect(allShapes(f.drawing())[0]!.paint).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses a gradient of one colour, on the fill and on the outline', () => {
|
||||
@@ -289,7 +292,7 @@ describe('writing a paint', () => {
|
||||
|
||||
it('moves the paints along when a whole shape is dragged, and undo takes both back', () => {
|
||||
const f = withTexture();
|
||||
const shapeNow = f.drawing().shapes[0]!;
|
||||
const shapeNow = allShapes(f.drawing())[0]!;
|
||||
shapeNow.paint = gradient();
|
||||
shapeNow.stroke = { color: 0, width: 1, paint: gradient({ type: 'radial' }) };
|
||||
const rings = [{ ...SQUARE, points: SQUARE.points.map((p) => ({ x: p.x + 10, y: p.y })) }];
|
||||
@@ -298,10 +301,10 @@ describe('writing a paint', () => {
|
||||
strokePaint: paintMoved(gradient({ type: 'radial' }), 10, 0),
|
||||
};
|
||||
f.stack.execute(new SetVectorShapeRingsCommand(f.assetId, f.shapeId, rings, 'Move shape', paints));
|
||||
expect(f.drawing().shapes[0]!.paint?.from).toEqual({ x: 10, y: 0 });
|
||||
expect(f.drawing().shapes[0]!.stroke?.paint?.to).toEqual({ x: 110, y: 0 });
|
||||
expect(allShapes(f.drawing())[0]!.paint?.from).toEqual({ x: 10, y: 0 });
|
||||
expect(allShapes(f.drawing())[0]!.stroke?.paint?.to).toEqual({ x: 110, y: 0 });
|
||||
f.stack.undo();
|
||||
expect(f.drawing().shapes[0]!.paint?.from).toEqual({ x: 0, y: 0 });
|
||||
expect(f.drawing().shapes[0]!.stroke?.paint?.from).toEqual({ x: 0, y: 0 });
|
||||
expect(allShapes(f.drawing())[0]!.paint?.from).toEqual({ x: 0, y: 0 });
|
||||
expect(allShapes(f.drawing())[0]!.stroke?.paint?.from).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,13 @@ function shape(over: Partial<VectorShape> = {}): VectorShape {
|
||||
}
|
||||
|
||||
function art(shapes: VectorShape[], props: PropertyBag = {}): VectorArtAsset {
|
||||
return { id: 'a1', type: 'vectorart', name: 'Logo', props, shapes };
|
||||
return {
|
||||
id: 'a1',
|
||||
type: 'vectorart',
|
||||
name: 'Logo',
|
||||
props,
|
||||
layers: [{ id: 'l1', name: 'Layer 1', items: shapes }],
|
||||
};
|
||||
}
|
||||
|
||||
describe('the path of a ring', () => {
|
||||
|
||||
@@ -0,0 +1,505 @@
|
||||
/**
|
||||
* 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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>).layers = [];
|
||||
const loaded = f.art(roundTrip(f.project));
|
||||
expect(loaded.layers).toHaveLength(1);
|
||||
expect(loaded.layers[0]!.items).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/comm
|
||||
import {
|
||||
AddVectorShapeCommand,
|
||||
DuplicateVectorShapeCommand,
|
||||
MoveVectorShapeCommand,
|
||||
OrderVectorShapeCommand,
|
||||
RemoveVectorShapeCommand,
|
||||
RenameVectorShapeCommand,
|
||||
SetVectorShapeRingsCommand,
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
isDrawableRing,
|
||||
SHAPE_FILL_DEFAULT,
|
||||
shapeExtent,
|
||||
shapeOrderTarget,
|
||||
VECTOR_CANVAS_DEFAULT,
|
||||
VECTOR_CANVAS_MAX,
|
||||
VECTOR_HEIGHT_KEY,
|
||||
@@ -28,6 +27,7 @@ import {
|
||||
vectorArtRevision,
|
||||
vectorCanvasSize,
|
||||
} from '../../src/core/model/vectorart';
|
||||
import { allShapes, shapeOrderTarget } from '../../src/core/model/vectorTree';
|
||||
import { makeProject, roundTrip } from '../helpers';
|
||||
|
||||
/** A square, and the same square with one point bent into a curve. */
|
||||
@@ -60,7 +60,7 @@ describe('the vector art asset', () => {
|
||||
|
||||
it('starts empty on a canvas of its own', () => {
|
||||
const f = drawing();
|
||||
expect(f.art().shapes).toEqual([]);
|
||||
expect(allShapes(f.art())).toEqual([]);
|
||||
expect(vectorCanvasSize(f.art().props)).toEqual({
|
||||
width: VECTOR_CANVAS_DEFAULT,
|
||||
height: VECTOR_CANVAS_DEFAULT,
|
||||
@@ -150,7 +150,9 @@ describe('the revision', () => {
|
||||
type: 'vectorart',
|
||||
name: 'A',
|
||||
props: {},
|
||||
shapes: [{ id: 's', name: 'S', rings: [ring], fill: 1, opacity: 1 }],
|
||||
layers: [
|
||||
{ id: 'l', name: 'Layer 1', items: [{ id: 's', name: 'S', rings: [ring], fill: 1, opacity: 1 }] },
|
||||
],
|
||||
});
|
||||
// The same ring with its two keys set the other way round – what a project file gives back.
|
||||
const built = { closed: true, points: square().points };
|
||||
@@ -203,14 +205,14 @@ describe('editing the shapes', () => {
|
||||
const second = new AddVectorShapeCommand(f.assetId, [square()], 0x112233);
|
||||
f.stack.execute(first);
|
||||
f.stack.execute(second);
|
||||
expect(f.art().shapes.map((s) => s.name)).toEqual(['Shape 1', 'Shape 2']);
|
||||
expect(f.art().shapes[1]).toMatchObject({ fill: 0x112233, opacity: 1 });
|
||||
expect(f.art().shapes[0]!.fill).toBe(SHAPE_FILL_DEFAULT);
|
||||
expect(allShapes(f.art()).map((s) => s.name)).toEqual(['Shape 1', 'Shape 2']);
|
||||
expect(allShapes(f.art())[1]).toMatchObject({ fill: 0x112233, opacity: 1 });
|
||||
expect(allShapes(f.art())[0]!.fill).toBe(SHAPE_FILL_DEFAULT);
|
||||
|
||||
f.stack.undo();
|
||||
expect(f.art().shapes.map((s) => s.name)).toEqual(['Shape 1']);
|
||||
expect(allShapes(f.art()).map((s) => s.name)).toEqual(['Shape 1']);
|
||||
f.stack.redo();
|
||||
expect(f.art().shapes.map((s) => s.name)).toEqual(['Shape 1', 'Shape 2']);
|
||||
expect(allShapes(f.art()).map((s) => s.name)).toEqual(['Shape 1', 'Shape 2']);
|
||||
});
|
||||
|
||||
it('puts a removed shape back where it stood, unlike a tilemap keeping its last layer', () => {
|
||||
@@ -221,13 +223,13 @@ describe('editing the shapes', () => {
|
||||
return add.shapeId;
|
||||
});
|
||||
f.stack.execute(new RemoveVectorShapeCommand(f.assetId, ids[1]!));
|
||||
expect(f.art().shapes.map((s) => s.id)).toEqual([ids[0], ids[2]]);
|
||||
expect(allShapes(f.art()).map((s) => s.id)).toEqual([ids[0], ids[2]]);
|
||||
f.stack.undo();
|
||||
expect(f.art().shapes.map((s) => s.id)).toEqual(ids);
|
||||
expect(allShapes(f.art()).map((s) => s.id)).toEqual(ids);
|
||||
|
||||
// The last one may go: an empty drawing is a drawing.
|
||||
for (const id of ids) f.stack.execute(new RemoveVectorShapeCommand(f.assetId, id));
|
||||
expect(f.art().shapes).toEqual([]);
|
||||
expect(allShapes(f.art())).toEqual([]);
|
||||
});
|
||||
|
||||
it('duplicates a shape right above it, named as a copy, with outlines of its own', () => {
|
||||
@@ -240,7 +242,7 @@ describe('editing the shapes', () => {
|
||||
const copy = new DuplicateVectorShapeCommand(f.assetId, add.shapeId);
|
||||
f.stack.execute(copy);
|
||||
f.stack.execute(new DuplicateVectorShapeCommand(f.assetId, add.shapeId));
|
||||
const shapes = f.art().shapes;
|
||||
const shapes = allShapes(f.art());
|
||||
expect(shapes.map((s) => s.name)).toEqual(['Shape 1', 'Shape 1 copy 2', 'Shape 1 copy', 'Shape 2']);
|
||||
expect(shapes[2]).toMatchObject({ id: copy.shapeId, fill: 0x112233, opacity: 0.5 });
|
||||
expect(shapes[2]!.rings).toEqual(shapes[0]!.rings);
|
||||
@@ -249,7 +251,7 @@ describe('editing the shapes', () => {
|
||||
|
||||
f.stack.undo();
|
||||
f.stack.undo();
|
||||
expect(f.art().shapes.map((s) => s.name)).toEqual(['Shape 1', 'Shape 2']);
|
||||
expect(allShapes(f.art()).map((s) => s.name)).toEqual(['Shape 1', 'Shape 2']);
|
||||
expect(() => f.stack.execute(new DuplicateVectorShapeCommand(f.assetId, 'nobody'))).toThrow(/not found/);
|
||||
});
|
||||
|
||||
@@ -261,19 +263,19 @@ describe('editing the shapes', () => {
|
||||
return add.shapeId;
|
||||
});
|
||||
// Last is on top, so "forward" is one step towards the end and "front" is the end.
|
||||
expect(shapeOrderTarget(f.art().shapes, ids[0]!, 'forward')).toBe(1);
|
||||
expect(shapeOrderTarget(f.art().shapes, ids[0]!, 'front')).toBe(2);
|
||||
expect(shapeOrderTarget(f.art().shapes, ids[2]!, 'backward')).toBe(1);
|
||||
expect(shapeOrderTarget(f.art().shapes, ids[1]!, 'back')).toBe(0);
|
||||
expect(shapeOrderTarget(allShapes(f.art()), ids[0]!, 'forward')).toBe(1);
|
||||
expect(shapeOrderTarget(allShapes(f.art()), ids[0]!, 'front')).toBe(2);
|
||||
expect(shapeOrderTarget(allShapes(f.art()), ids[2]!, 'backward')).toBe(1);
|
||||
expect(shapeOrderTarget(allShapes(f.art()), ids[1]!, 'back')).toBe(0);
|
||||
// Already there, and not there at all.
|
||||
expect(shapeOrderTarget(f.art().shapes, ids[2]!, 'front')).toBe(-1);
|
||||
expect(shapeOrderTarget(f.art().shapes, ids[0]!, 'back')).toBe(-1);
|
||||
expect(shapeOrderTarget(f.art().shapes, 'nobody', 'front')).toBe(-1);
|
||||
expect(shapeOrderTarget(allShapes(f.art()), ids[2]!, 'front')).toBe(-1);
|
||||
expect(shapeOrderTarget(allShapes(f.art()), ids[0]!, 'back')).toBe(-1);
|
||||
expect(shapeOrderTarget(allShapes(f.art()), 'nobody', 'front')).toBe(-1);
|
||||
|
||||
f.stack.execute(new MoveVectorShapeCommand(f.assetId, 0, 2));
|
||||
expect(f.art().shapes.map((s) => s.id)).toEqual([ids[1], ids[2], ids[0]]);
|
||||
f.stack.execute(new OrderVectorShapeCommand(f.assetId, ids[0]!, 'front'));
|
||||
expect(allShapes(f.art()).map((s) => s.id)).toEqual([ids[1], ids[2], ids[0]]);
|
||||
f.stack.undo();
|
||||
expect(f.art().shapes.map((s) => s.id)).toEqual(ids);
|
||||
expect(allShapes(f.art()).map((s) => s.id)).toEqual(ids);
|
||||
});
|
||||
|
||||
it('copies the outline it is handed, so a later drag does not reach into the undo step', () => {
|
||||
@@ -283,15 +285,15 @@ describe('editing the shapes', () => {
|
||||
const rings = [square()];
|
||||
rings[0]!.points[0] = { x: 0, y: 0, out: { x: 5, y: 5 } };
|
||||
const edit = new SetVectorShapeRingsCommand(f.assetId, add.shapeId, rings);
|
||||
expect(SetVectorShapeRingsCommand.changes(f.art().shapes[0]!, rings)).toBe(true);
|
||||
expect(SetVectorShapeRingsCommand.changes(allShapes(f.art())[0]!, rings)).toBe(true);
|
||||
f.stack.execute(edit);
|
||||
rings[0]!.points[1] = { x: -999, y: -999 };
|
||||
expect(f.art().shapes[0]!.rings[0]!.points[0]).toEqual({ x: 0, y: 0, out: { x: 5, y: 5 } });
|
||||
expect(f.art().shapes[0]!.rings[0]!.points[1]).toEqual({ x: 90, y: 10 });
|
||||
expect(allShapes(f.art())[0]!.rings[0]!.points[0]).toEqual({ x: 0, y: 0, out: { x: 5, y: 5 } });
|
||||
expect(allShapes(f.art())[0]!.rings[0]!.points[1]).toEqual({ x: 90, y: 10 });
|
||||
|
||||
f.stack.undo();
|
||||
expect(f.art().shapes[0]!.rings[0]!.points[0]).toEqual({ x: 10, y: 10 });
|
||||
expect(SetVectorShapeRingsCommand.changes(f.art().shapes[0]!, [square()])).toBe(false);
|
||||
expect(allShapes(f.art())[0]!.rings[0]!.points[0]).toEqual({ x: 10, y: 10 });
|
||||
expect(SetVectorShapeRingsCommand.changes(allShapes(f.art())[0]!, [square()])).toBe(false);
|
||||
});
|
||||
|
||||
it('writes fill, opacity and outline one field at a time and takes each back', () => {
|
||||
@@ -306,16 +308,16 @@ describe('editing the shapes', () => {
|
||||
}),
|
||||
);
|
||||
// An opacity outside 0…1 is clamped, not refused: a slider may overshoot.
|
||||
expect(f.art().shapes[0]).toMatchObject({ fill: 0x00ff00, opacity: 1 });
|
||||
expect(f.art().shapes[0]!.stroke).toEqual({ color: 0, width: 2 });
|
||||
expect(allShapes(f.art())[0]).toMatchObject({ fill: 0x00ff00, opacity: 1 });
|
||||
expect(allShapes(f.art())[0]!.stroke).toEqual({ color: 0, width: 2 });
|
||||
|
||||
f.stack.execute(new SetVectorShapeStyleCommand(f.assetId, add.shapeId, { stroke: null }));
|
||||
expect(f.art().shapes[0]!.stroke).toBeUndefined();
|
||||
expect(allShapes(f.art())[0]!.stroke).toBeUndefined();
|
||||
f.stack.undo();
|
||||
expect(f.art().shapes[0]!.stroke).toEqual({ color: 0, width: 2 });
|
||||
expect(allShapes(f.art())[0]!.stroke).toEqual({ color: 0, width: 2 });
|
||||
f.stack.undo();
|
||||
expect(f.art().shapes[0]).toMatchObject({ fill: SHAPE_FILL_DEFAULT, opacity: 1 });
|
||||
expect(f.art().shapes[0]!.stroke).toBeUndefined();
|
||||
expect(allShapes(f.art())[0]).toMatchObject({ fill: SHAPE_FILL_DEFAULT, opacity: 1 });
|
||||
expect(allShapes(f.art())[0]!.stroke).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses an outline of no width and a shape of no name', () => {
|
||||
@@ -364,12 +366,13 @@ describe('what a project file may say', () => {
|
||||
stroke: { color: 0xffffff, width: 3 },
|
||||
}),
|
||||
);
|
||||
expect(f.art(roundTrip(f.project)).shapes).toEqual(f.art().shapes);
|
||||
expect(allShapes(f.art(roundTrip(f.project)))).toEqual(allShapes(f.art()));
|
||||
});
|
||||
|
||||
it('drops what cannot be drawn from and keeps what someone is still drawing', () => {
|
||||
const f = drawing();
|
||||
const art = f.art() as unknown as Record<string, unknown>;
|
||||
delete art.layers;
|
||||
art.shapes = [
|
||||
{
|
||||
id: 'one',
|
||||
@@ -396,8 +399,8 @@ describe('what a project file may say', () => {
|
||||
'not a shape',
|
||||
];
|
||||
const loaded = f.art(roundTrip(f.project));
|
||||
expect(loaded.shapes).toHaveLength(1);
|
||||
const shape = loaded.shapes[0]!;
|
||||
expect(allShapes(loaded)).toHaveLength(1);
|
||||
const shape = allShapes(loaded)[0]!;
|
||||
// The broken handle falls away and leaves a corner, and so does a point type nobody knows (#195);
|
||||
// the broken points fall away entirely.
|
||||
expect(shape.rings[0]!.points).toEqual([
|
||||
@@ -414,8 +417,9 @@ describe('what a project file may say', () => {
|
||||
it('falls back to a fill and a name where the file says nothing usable', () => {
|
||||
const f = drawing();
|
||||
const art = f.art() as unknown as Record<string, unknown>;
|
||||
delete art.layers;
|
||||
art.shapes = [{ id: 'one', rings: [{ closed: false, points: [] }], fill: 'blue' }];
|
||||
const shape = f.art(roundTrip(f.project)).shapes[0]!;
|
||||
const shape = allShapes(f.art(roundTrip(f.project)))[0]!;
|
||||
expect(shape).toMatchObject({ name: 'Shape', fill: SHAPE_FILL_DEFAULT, opacity: 1 });
|
||||
expect(shape.rings).toEqual([{ closed: false, points: [] }]);
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ import { EditorState } from '../../src/editor/state';
|
||||
import { VStack } from '../../src/ui';
|
||||
import { initRenderLoop, resetRenderLoop } from '../../src/ui/render';
|
||||
import { PaintCache } from '../../src/viewport/vectorArtPicture';
|
||||
import { allShapes } from '../../src/core/model/vectorTree';
|
||||
|
||||
/** One frame of the render loop, the way the ticker runs it. */
|
||||
function frames(): () => void {
|
||||
@@ -188,7 +189,7 @@ describe('dragging a colour stop of a gradient (#274)', () => {
|
||||
const state = new EditorState();
|
||||
state.loadProject(createEmptyProject('p', 'P'), 1);
|
||||
const asset = buildAsset('vectorart', 'Drawing') as VectorArtAsset;
|
||||
asset.shapes.push({
|
||||
asset.layers[0]!.items.push({
|
||||
id: 's',
|
||||
name: 'Sky',
|
||||
fill: 0x0000ff,
|
||||
@@ -219,7 +220,7 @@ describe('dragging a colour stop of a gradient (#274)', () => {
|
||||
bar.resize(112, 32);
|
||||
const pointer = (type: string, x: number) =>
|
||||
bar.emit(type, { button: 0, global: { x, y: 25 }, stopPropagation: () => undefined });
|
||||
const offsets = () => (asset.shapes[0]!.paint as VectorGradient).stops.map((stop) => stop.offset);
|
||||
const offsets = () => (allShapes(asset)[0]!.paint as VectorGradient).stops.map((stop) => stop.offset);
|
||||
return { state, preview: vi.spyOn(state, 'preview'), pointer, offsets };
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import type { SceneViewHost } from '../../src/editor/viewport/sceneViewHost';
|
||||
import { resetRenderLoop } from '../../src/ui/render';
|
||||
import type { DrawItem } from '../../src/viewport/items';
|
||||
import { PaintCache } from '../../src/viewport/vectorArtPicture';
|
||||
import { allShapes } from '../../src/core/model/vectorTree';
|
||||
|
||||
/** Presses run on one clock, far enough apart that no two join unless a test asks for a double-click. */
|
||||
let clock = 0;
|
||||
@@ -207,7 +208,7 @@ describe('the vector art tab', () => {
|
||||
{ x: 50, y: 50 },
|
||||
{ x: 10, y: 50 },
|
||||
];
|
||||
asset.shapes.push({
|
||||
asset.layers[0]!.items.push({
|
||||
id: 's',
|
||||
name: 'Square',
|
||||
fill: 0x0000ff,
|
||||
@@ -221,7 +222,7 @@ describe('the vector art tab', () => {
|
||||
canvas.redraw();
|
||||
state.select(shapeSelection(asset.id, ['s']));
|
||||
const at = (x: number, y: number) => canvas.imageToScreen(x, y);
|
||||
const points = () => asset.shapes[0]?.rings[0]?.points ?? [];
|
||||
const points = () => allShapes(asset)[0]?.rings[0]?.points ?? [];
|
||||
return { state, asset, canvas, at, points, ...gestures(canvas, asset) };
|
||||
}
|
||||
|
||||
@@ -257,13 +258,13 @@ describe('the vector art tab', () => {
|
||||
expect(canvas.pickedKind).toBe('straight');
|
||||
expect(canvas.onKeyDown(key('Delete'))).toBe(true);
|
||||
expect(points()).toHaveLength(3);
|
||||
expect(asset.shapes).toHaveLength(1);
|
||||
expect(allShapes(asset)).toHaveLength(1);
|
||||
state.undo();
|
||||
expect(points()).toHaveLength(4);
|
||||
click(at(30, 30));
|
||||
expect(canvas.pickedKind).toBe(null);
|
||||
expect(canvas.onKeyDown(key('Delete'))).toBe(true);
|
||||
expect(asset.shapes).toHaveLength(0);
|
||||
expect(allShapes(asset)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
} from '../../src/editor/inspector/shapeSection';
|
||||
import { EditorState, shapeSelection } from '../../src/editor/state';
|
||||
import { PaintCache } from '../../src/viewport/vectorArtPicture';
|
||||
import { allShapes } from '../../src/core/model/vectorTree';
|
||||
|
||||
/**
|
||||
* These rows really are built, and a widget repaints itself one microtask after a value goes into
|
||||
@@ -85,7 +86,7 @@ function withShapes(count: number): { host: InspectorHost; assetId: string; ids:
|
||||
}
|
||||
|
||||
const shapeOf = (host: InspectorHost, assetId: string, index = 0) =>
|
||||
findAssetOf(host.state.project, assetId, 'vectorart')!.shapes[index]!;
|
||||
allShapes(findAssetOf(host.state.project, assetId, 'vectorart')!)[index]!;
|
||||
|
||||
function widgets<T extends Widget>(host: InspectorHost, type: new (...args: never[]) => T): T[] {
|
||||
const found: T[] = [];
|
||||
@@ -239,7 +240,7 @@ describe('combining from the inspector (#187)', () => {
|
||||
buildShapeSection(host, assetId, ids[1]!, [ids[2]!, ids[1]!]);
|
||||
expect(texts(host)).toContain('The result keeps "Shape 2", the lowest.');
|
||||
button(host, 'Union').onClick!();
|
||||
expect(findAssetOf(host.state.project, assetId, 'vectorart')!.shapes.map((s) => s.id)).toEqual([
|
||||
expect(allShapes(findAssetOf(host.state.project, assetId, 'vectorart')!).map((s) => s.id)).toEqual([
|
||||
ids[0],
|
||||
ids[1],
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
/**
|
||||
* 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 = <T extends Widget>(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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user