Name, opacity and Z each sat in a separate field below or beside
the list. You had to select a row first and then look elsewhere to
change it. Duplicating was a separate button or not possible at all.
Now the row itself carries everything.
- SortableList gets three optional callbacks. onDuplicate shows a
copy button between the eye and the delete button. onRename opens
a text field on double click, Enter or a click outside commits,
Esc cancels, an empty name is ignored. value puts a NumberField
into the row.
- Dragging on the number changes the value and never moves the row.
A whole drag is one undo step, the canvas follows live.
- Copy and delete only show on the hovered and the selected row.
Their space stays reserved, so nothing jumps on hover.
- Pixel layers: copy right above with the same pixels on all
frames, rename, opacity in percent. The Name and Opacity fields
below the list are gone.
- Pixel frames: copy right after, replaces the Duplicate button.
- Tilemap layers: copy with cells, Z and visibility, rename, Z in
the row. The Name and Z fields in the panel are gone.
- Vector shapes: copy at the same place and select it, rename.
- New in core: DuplicateVectorShapeCommand,
DuplicateTilemapLayerCommand, withLayerDuplicated and copyName
("X copy", "X copy 2"). copyName lives in src/core/util/names.ts
because the model never imports from commands/.
Findings:
- Double clicking a shape that is not selected yet only selects it.
The first click moves the list up, so the second click misses the
name. Module 47 selects the row first.
Tests:
- ./check.sh: 138 files, 2055 tests, CHECK EXIT 0 (before 2052).
- GM_STEPS=47,48 (fresh, twice): ALL PASSED, 91 checks (before 86).
- GM_STEPS=39,40,47,48: module 40 green, 47 died on the rename
check, which was a test fault and is fixed.
- Counter probes: a constant copyName fails the shape and pixel
layer tests. A dead copy hit fails the copy check in 47.
- Check total 1272 in README and wiki, computed (1266 + 6).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
425 lines
16 KiB
TypeScript
425 lines
16 KiB
TypeScript
import { afterEach, describe, expect, it } from 'vitest';
|
||
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||
import {
|
||
AddVectorShapeCommand,
|
||
DuplicateVectorShapeCommand,
|
||
MoveVectorShapeCommand,
|
||
RemoveVectorShapeCommand,
|
||
RenameVectorShapeCommand,
|
||
SetVectorShapeRingsCommand,
|
||
SetVectorShapeStyleCommand,
|
||
} from '../../src/core/commands/vectorArtCommands';
|
||
import type { VectorArtAsset, VectorRing } from '../../src/core/model/Asset';
|
||
import { isPlaceableType } from '../../src/core/model/AssetTypes';
|
||
import { generatedPictureOf, registerPictureSource } from '../../src/core/model/generated';
|
||
import { findAsset, type Project } from '../../src/core/model/Project';
|
||
import { resolvePicture } from '../../src/core/model/texture';
|
||
import {
|
||
handleAt,
|
||
isDrawableRing,
|
||
isDrawableShape,
|
||
SHAPE_FILL_DEFAULT,
|
||
shapeExtent,
|
||
shapeOrderTarget,
|
||
VECTOR_CANVAS_DEFAULT,
|
||
VECTOR_CANVAS_MAX,
|
||
VECTOR_HEIGHT_KEY,
|
||
VECTOR_WIDTH_KEY,
|
||
vectorArtPicture,
|
||
vectorArtRevision,
|
||
vectorCanvasSize,
|
||
} from '../../src/core/model/vectorart';
|
||
import { makeProject, roundTrip } from '../helpers';
|
||
|
||
/** A square, and the same square with one point bent into a curve. */
|
||
function square(): VectorRing {
|
||
return {
|
||
closed: true,
|
||
points: [
|
||
{ x: 10, y: 10 },
|
||
{ x: 90, y: 10 },
|
||
{ x: 90, y: 90 },
|
||
{ x: 10, y: 90 },
|
||
],
|
||
};
|
||
}
|
||
|
||
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;
|
||
return { ...fixture, assetId, art };
|
||
}
|
||
|
||
describe('the vector art asset', () => {
|
||
it('is a picture, not something placed in a scene', () => {
|
||
expect(isPlaceableType('vectorart')).toBe(false);
|
||
});
|
||
|
||
it('starts empty on a canvas of its own', () => {
|
||
const f = drawing();
|
||
expect(f.art().shapes).toEqual([]);
|
||
expect(vectorCanvasSize(f.art().props)).toEqual({
|
||
width: VECTOR_CANVAS_DEFAULT,
|
||
height: VECTOR_CANVAS_DEFAULT,
|
||
});
|
||
});
|
||
|
||
it('reads a canvas that is missing, broken or absurd as one it can draw on', () => {
|
||
expect(vectorCanvasSize({})).toEqual({ width: 256, height: 256 });
|
||
expect(vectorCanvasSize({ [VECTOR_WIDTH_KEY]: 0, [VECTOR_HEIGHT_KEY]: -40 })).toEqual({
|
||
width: 256,
|
||
height: 256,
|
||
});
|
||
expect(vectorCanvasSize({ [VECTOR_WIDTH_KEY]: 99999, [VECTOR_HEIGHT_KEY]: 64.7 })).toEqual({
|
||
width: VECTOR_CANVAS_MAX,
|
||
height: 64,
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('what is worth drawing', () => {
|
||
it('needs three points closed and two open', () => {
|
||
expect(isDrawableRing({ closed: true, points: square().points.slice(0, 2) })).toBe(false);
|
||
expect(isDrawableRing({ closed: true, points: square().points.slice(0, 3) })).toBe(true);
|
||
expect(isDrawableRing({ closed: false, points: square().points.slice(0, 2) })).toBe(true);
|
||
expect(isDrawableShape({ id: 's', name: 'S', rings: [], fill: 0, opacity: 1 })).toBe(false);
|
||
});
|
||
|
||
it('measures a shape including the handles pulled off its points', () => {
|
||
const shape = {
|
||
id: 's',
|
||
name: 'S',
|
||
rings: [
|
||
{
|
||
closed: true,
|
||
points: [
|
||
{ x: 10, y: 10, out: { x: -30, y: 5 } },
|
||
{ x: 90, y: 90 },
|
||
],
|
||
},
|
||
],
|
||
fill: 0,
|
||
opacity: 1,
|
||
};
|
||
expect(handleAt(shape.rings[0]!.points[0]!, 'out')).toEqual({ x: -20, y: 15 });
|
||
expect(handleAt(shape.rings[0]!.points[1]!, 'in')).toBeNull();
|
||
expect(shapeExtent(shape)).toEqual({ x0: -20, y0: 10, x1: 90, y1: 90 });
|
||
expect(shapeExtent({ ...shape, rings: [] })).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('the revision', () => {
|
||
it('changes with every pixel and with nothing else', () => {
|
||
const f = drawing();
|
||
const add = new AddVectorShapeCommand(f.assetId, [square()]);
|
||
f.stack.execute(add);
|
||
const first = vectorArtRevision(f.art());
|
||
|
||
f.stack.execute(new RenameVectorShapeCommand(f.assetId, add.shapeId, 'Body'));
|
||
expect(vectorArtRevision(f.art())).toBe(first);
|
||
|
||
// A point's type only says what the next drag does to its handles (#195).
|
||
const typed = square();
|
||
typed.points[0] = { ...typed.points[0]!, mode: 'smooth' };
|
||
f.stack.execute(new SetVectorShapeRingsCommand(f.assetId, add.shapeId, [typed]));
|
||
expect(vectorArtRevision(f.art())).toBe(first);
|
||
|
||
f.stack.execute(new SetVectorShapeStyleCommand(f.assetId, add.shapeId, { fill: 0xff0000 }));
|
||
const coloured = vectorArtRevision(f.art());
|
||
expect(coloured).not.toBe(first);
|
||
|
||
const moved = square();
|
||
moved.points[0] = { x: 11, y: 10 };
|
||
f.stack.execute(new SetVectorShapeRingsCommand(f.assetId, add.shapeId, [moved]));
|
||
expect(vectorArtRevision(f.art())).not.toBe(coloured);
|
||
});
|
||
|
||
it('changes when the canvas is resized, because the picture is a different one', () => {
|
||
const f = drawing();
|
||
f.stack.execute(new AddVectorShapeCommand(f.assetId, [square()]));
|
||
const before = vectorArtRevision(f.art());
|
||
f.stack.execute(new SetAssetPropertyCommand(f.assetId, VECTOR_WIDTH_KEY, 512));
|
||
expect(vectorArtRevision(f.art())).not.toBe(before);
|
||
});
|
||
|
||
it('is the same for the same drawing, however the object was built', () => {
|
||
const asset = (ring: VectorRing): VectorArtAsset => ({
|
||
id: 'a',
|
||
type: 'vectorart',
|
||
name: 'A',
|
||
props: {},
|
||
shapes: [{ 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 };
|
||
const loaded = { points: square().points, closed: true } as VectorRing;
|
||
expect(vectorArtRevision(asset(loaded))).toBe(vectorArtRevision(asset(built)));
|
||
});
|
||
|
||
it('survives a save and load: the same drawing is the same picture', () => {
|
||
const f = drawing();
|
||
f.stack.execute(new AddVectorShapeCommand(f.assetId, [square()]));
|
||
expect(vectorArtRevision(f.art(roundTrip(f.project)))).toBe(vectorArtRevision(f.art()));
|
||
});
|
||
});
|
||
|
||
describe('the way into the picture path', () => {
|
||
let unregister: (() => void) | null = null;
|
||
afterEach(() => {
|
||
unregister?.();
|
||
unregister = null;
|
||
});
|
||
|
||
it('hands over the canvas and the revision, and only for vector art', () => {
|
||
const f = drawing();
|
||
expect(vectorArtPicture(f.art())).toEqual({
|
||
width: VECTOR_CANVAS_DEFAULT,
|
||
height: VECTOR_CANVAS_DEFAULT,
|
||
revision: vectorArtRevision(f.art()),
|
||
});
|
||
const other = new CreateAssetCommand('sprite', 'Box');
|
||
f.stack.execute(other);
|
||
expect(vectorArtPicture(other.asset)).toBeNull();
|
||
});
|
||
|
||
it('resolves to a generated picture of the canvas size once it is registered (#185)', () => {
|
||
const f = drawing();
|
||
f.stack.execute(new SetAssetPropertyCommand(f.assetId, VECTOR_HEIGHT_KEY, 128));
|
||
expect(generatedPictureOf(f.art())).toBeNull();
|
||
unregister = registerPictureSource('vectorart', vectorArtPicture);
|
||
const ref = resolvePicture(f.project, f.assetId);
|
||
expect(ref?.file).toBe(`gen:${f.assetId}@${vectorArtRevision(f.art())}`);
|
||
expect(ref?.sourceWidth).toBe(VECTOR_CANVAS_DEFAULT);
|
||
expect(ref?.sourceHeight).toBe(128);
|
||
});
|
||
});
|
||
|
||
describe('editing the shapes', () => {
|
||
it('numbers new shapes and takes them back out again', () => {
|
||
const f = drawing();
|
||
const first = new AddVectorShapeCommand(f.assetId);
|
||
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);
|
||
|
||
f.stack.undo();
|
||
expect(f.art().shapes.map((s) => s.name)).toEqual(['Shape 1']);
|
||
f.stack.redo();
|
||
expect(f.art().shapes.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', () => {
|
||
const f = drawing();
|
||
const ids = [0, 1, 2].map(() => {
|
||
const add = new AddVectorShapeCommand(f.assetId, [square()]);
|
||
f.stack.execute(add);
|
||
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]]);
|
||
f.stack.undo();
|
||
expect(f.art().shapes.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([]);
|
||
});
|
||
|
||
it('duplicates a shape right above it, named as a copy, with outlines of its own', () => {
|
||
const f = drawing();
|
||
const add = new AddVectorShapeCommand(f.assetId, [square()], 0x112233);
|
||
const top = new AddVectorShapeCommand(f.assetId, [square()]);
|
||
f.stack.execute(add);
|
||
f.stack.execute(top);
|
||
f.stack.execute(new SetVectorShapeStyleCommand(f.assetId, add.shapeId, { opacity: 0.5 }));
|
||
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;
|
||
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);
|
||
// Its own outline: a point dragged on the copy leaves the original where it is.
|
||
expect(shapes[2]!.rings[0]).not.toBe(shapes[0]!.rings[0]);
|
||
|
||
f.stack.undo();
|
||
f.stack.undo();
|
||
expect(f.art().shapes.map((s) => s.name)).toEqual(['Shape 1', 'Shape 2']);
|
||
expect(() => f.stack.execute(new DuplicateVectorShapeCommand(f.assetId, 'nobody'))).toThrow(/not found/);
|
||
});
|
||
|
||
it('keeps the drawing order apart from the list order in no way at all', () => {
|
||
const f = drawing();
|
||
const ids = ['a', 'b', 'c'].map(() => {
|
||
const add = new AddVectorShapeCommand(f.assetId, [square()]);
|
||
f.stack.execute(add);
|
||
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);
|
||
// 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);
|
||
|
||
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.undo();
|
||
expect(f.art().shapes.map((s) => s.id)).toEqual(ids);
|
||
});
|
||
|
||
it('copies the outline it is handed, so a later drag does not reach into the undo step', () => {
|
||
const f = drawing();
|
||
const add = new AddVectorShapeCommand(f.assetId, [square()]);
|
||
f.stack.execute(add);
|
||
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);
|
||
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 });
|
||
|
||
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);
|
||
});
|
||
|
||
it('writes fill, opacity and outline one field at a time and takes each back', () => {
|
||
const f = drawing();
|
||
const add = new AddVectorShapeCommand(f.assetId, [square()]);
|
||
f.stack.execute(add);
|
||
f.stack.execute(
|
||
new SetVectorShapeStyleCommand(f.assetId, add.shapeId, {
|
||
fill: 0x00ff00,
|
||
opacity: 4,
|
||
stroke: { color: 0, width: 2 },
|
||
}),
|
||
);
|
||
// 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 });
|
||
|
||
f.stack.execute(new SetVectorShapeStyleCommand(f.assetId, add.shapeId, { stroke: null }));
|
||
expect(f.art().shapes[0]!.stroke).toBeUndefined();
|
||
f.stack.undo();
|
||
expect(f.art().shapes[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();
|
||
});
|
||
|
||
it('refuses an outline of no width and a shape of no name', () => {
|
||
const f = drawing();
|
||
const add = new AddVectorShapeCommand(f.assetId, [square()]);
|
||
f.stack.execute(add);
|
||
expect(() =>
|
||
f.stack.execute(
|
||
new SetVectorShapeStyleCommand(f.assetId, add.shapeId, { stroke: { color: 0, width: 0 } }),
|
||
),
|
||
).toThrow(/wider than nothing/);
|
||
expect(() => f.stack.execute(new RenameVectorShapeCommand(f.assetId, add.shapeId, ' '))).toThrow(
|
||
/needs a name/,
|
||
);
|
||
expect(() => f.stack.execute(new RenameVectorShapeCommand(f.assetId, 'nobody', 'X'))).toThrow(
|
||
/not found/,
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('what a project file may say', () => {
|
||
it('keeps a whole drawing across save and load', () => {
|
||
const f = drawing();
|
||
const add = new AddVectorShapeCommand(f.assetId, [
|
||
{
|
||
closed: true,
|
||
points: [
|
||
{ x: 10, y: 10, out: { x: 4, y: 0 } },
|
||
{ x: 90, y: 10, in: { x: -3, y: 0 }, out: { x: 6, y: 0 }, mode: 'smooth' },
|
||
{ x: 50, y: 80, in: { x: -2, y: 1 }, out: { x: 2, y: -1 }, mode: 'symmetric' },
|
||
],
|
||
},
|
||
{
|
||
closed: false,
|
||
points: [
|
||
{ x: 20, y: 20 },
|
||
{ x: 30, y: 30 },
|
||
],
|
||
},
|
||
]);
|
||
f.stack.execute(add);
|
||
f.stack.execute(
|
||
new SetVectorShapeStyleCommand(f.assetId, add.shapeId, {
|
||
fill: 0x334455,
|
||
opacity: 0.5,
|
||
stroke: { color: 0xffffff, width: 3 },
|
||
}),
|
||
);
|
||
expect(f.art(roundTrip(f.project)).shapes).toEqual(f.art().shapes);
|
||
});
|
||
|
||
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>;
|
||
art.shapes = [
|
||
{
|
||
id: 'one',
|
||
name: 'One',
|
||
rings: [
|
||
{
|
||
closed: true,
|
||
points: [
|
||
{ x: 10, y: 10, in: { x: 'left', y: 2 }, out: { x: 1, y: 2 }, mode: 'wobbly' },
|
||
{ x: 'far', y: 10 },
|
||
{ x: 30, y: 30 },
|
||
{ x: 50 },
|
||
],
|
||
},
|
||
{ points: [{ x: 0, y: 0 }] },
|
||
],
|
||
fill: 0x123456,
|
||
opacity: 7,
|
||
stroke: { color: 0xffffff, width: 0 },
|
||
},
|
||
// A doubled id would make one reference mean two shapes, as a doubled layer id does.
|
||
{ id: 'one', name: 'Twin', rings: [] },
|
||
{ name: 'Nameless', rings: [] },
|
||
'not a shape',
|
||
];
|
||
const loaded = f.art(roundTrip(f.project));
|
||
expect(loaded.shapes).toHaveLength(1);
|
||
const shape = loaded.shapes[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([
|
||
{ x: 10, y: 10, out: { x: 1, y: 2 } },
|
||
{ x: 30, y: 30 },
|
||
]);
|
||
// A ring of one point is kept – it is an outline someone is still drawing – and closed by default.
|
||
expect(shape.rings[1]).toEqual({ closed: true, points: [{ x: 0, y: 0 }] });
|
||
expect(shape).toMatchObject({ fill: 0x123456, opacity: 1 });
|
||
// An outline of no width is no outline.
|
||
expect(shape.stroke).toBeUndefined();
|
||
});
|
||
|
||
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>;
|
||
art.shapes = [{ id: 'one', rings: [{ closed: false, points: [] }], fill: 'blue' }];
|
||
const shape = f.art(roundTrip(f.project)).shapes[0]!;
|
||
expect(shape).toMatchObject({ name: 'Shape', fill: SHAPE_FILL_DEFAULT, opacity: 1 });
|
||
expect(shape.rings).toEqual([{ closed: false, points: [] }]);
|
||
});
|
||
});
|