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>
371 lines
13 KiB
TypeScript
371 lines
13 KiB
TypeScript
/**
|
|
* A drag writes the model on every pointer move but tells the editor once per frame (#213): the
|
|
* model event rebuilds panels and pictures, and a mouse reports faster than the screen draws.
|
|
*/
|
|
import { type Application, CanvasTextMetrics } from 'pixi.js';
|
|
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { buildAsset } from '../../src/core/commands/assetCommands';
|
|
import type { Command } from '../../src/core/commands/Command';
|
|
import type {
|
|
CurveAsset,
|
|
GraphAsset,
|
|
PaletteAsset,
|
|
PixelArtAsset,
|
|
VectorArtAsset,
|
|
VectorGradient,
|
|
} from '../../src/core/model/Asset';
|
|
import { PALETTE_KEY } from '../../src/core/model/pixelart';
|
|
import { createEmptyProject } from '../../src/core/model/Project';
|
|
import { CurveCanvas } from '../../src/editor/curve/CurveCanvas';
|
|
import { FramePreview } from '../../src/editor/framePreview';
|
|
import { GraphDrags } from '../../src/editor/graph/graphDrags';
|
|
import type { GraphCanvasHost } from '../../src/editor/graph/graphHost';
|
|
import type { InspectorHost } from '../../src/editor/inspector/host';
|
|
import { previewingHandlers } from '../../src/editor/inspector/previewHandlers';
|
|
import { buildShapeSection, currentGradientBar } from '../../src/editor/inspector/shapeSection';
|
|
import { PixelArtCanvas } from '../../src/editor/pixelart/PixelArtCanvas';
|
|
import { PixelArtSide } from '../../src/editor/pixelart/PixelArtSide';
|
|
import { PixelThumbs } from '../../src/editor/pixelart/PixelThumbs';
|
|
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 {
|
|
const added: Array<() => void> = [];
|
|
const app = {
|
|
render(): void {},
|
|
ticker: {
|
|
add: (fn: () => void) => added.push(fn),
|
|
remove: () => undefined,
|
|
},
|
|
};
|
|
initRenderLoop(app as unknown as Application);
|
|
return () => added.forEach((fn) => fn());
|
|
}
|
|
|
|
function fakeState() {
|
|
return {
|
|
selectedAnnotationIds: [] as string[],
|
|
selectedNodeIds: ['n1'],
|
|
preview: vi.fn(),
|
|
execute: vi.fn(() => true),
|
|
};
|
|
}
|
|
|
|
describe('FramePreview', () => {
|
|
beforeEach(() => resetRenderLoop());
|
|
|
|
it('turns any number of requests in one frame into a single preview', () => {
|
|
const tick = frames();
|
|
const state = fakeState();
|
|
const preview = new FramePreview(state, 'drag');
|
|
for (let i = 0; i < 5; i++) preview.request();
|
|
expect(state.preview).not.toHaveBeenCalled();
|
|
tick();
|
|
expect(state.preview).toHaveBeenCalledTimes(1);
|
|
tick();
|
|
expect(state.preview).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('drops the waiting preview on cancel', () => {
|
|
const tick = frames();
|
|
const state = fakeState();
|
|
const preview = new FramePreview(state, 'drag');
|
|
preview.request();
|
|
preview.cancel();
|
|
tick();
|
|
expect(state.preview).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps two gestures of the same name apart', () => {
|
|
const tick = frames();
|
|
const a = fakeState();
|
|
const b = fakeState();
|
|
new FramePreview(a, 'drag').request();
|
|
new FramePreview(b, 'drag').request();
|
|
tick();
|
|
expect(a.preview).toHaveBeenCalledTimes(1);
|
|
expect(b.preview).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe('dragging a node in the graph', () => {
|
|
beforeEach(() => resetRenderLoop());
|
|
|
|
function rig() {
|
|
const state = fakeState();
|
|
const asset = {
|
|
id: 'b',
|
|
graph: { nodes: [{ id: 'n1', type: 'Add', x: 0, y: 0, params: {} }], edges: [] },
|
|
} as unknown as GraphAsset;
|
|
const host = {
|
|
state: state as unknown as EditorState,
|
|
assetId: () => 'b',
|
|
asset: () => asset,
|
|
nodeBoxes: () => [{ id: 'n1', x: 0, y: 0, w: 100, h: 40 }],
|
|
toGraph: (p: { x: number; y: number }) => ({ x: p.x, y: p.y }),
|
|
zoom: () => 1,
|
|
snapping: () => false,
|
|
} as unknown as GraphCanvasHost;
|
|
return { state, node: asset.graph.nodes[0]!, drags: new GraphDrags(host) };
|
|
}
|
|
|
|
it('moves the node with every pointer move and previews once a frame', () => {
|
|
const tick = frames();
|
|
const { state, node, drags } = rig();
|
|
drags.grabNodes({ x: 10, y: 10 });
|
|
for (let i = 1; i <= 5; i++) {
|
|
drags.move({ x: 10 + i * 10, y: 10 });
|
|
expect(node.x).toBe(i * 10);
|
|
}
|
|
expect(state.preview).not.toHaveBeenCalled();
|
|
tick();
|
|
expect(state.preview).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('leaves nothing for the frame after the release: the command has the last word', () => {
|
|
const tick = frames();
|
|
const { state, node, drags } = rig();
|
|
drags.grabNodes({ x: 10, y: 10 });
|
|
drags.move({ x: 50, y: 30 });
|
|
drags.releaseNodes();
|
|
expect(state.execute).toHaveBeenCalledTimes(1);
|
|
expect(node.x).toBe(0);
|
|
tick();
|
|
expect(state.preview).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('editing an inspector row (#274)', () => {
|
|
beforeEach(() => resetRenderLoop());
|
|
|
|
function rig() {
|
|
const state = fakeState();
|
|
const target = { x: 1 };
|
|
const commit = vi.fn((value: number) => ({ value }) as unknown as Command);
|
|
const handlers = previewingHandlers<typeof target, number>(
|
|
{ state: state as unknown as EditorState },
|
|
{ target: () => target, read: (t) => t.x, write: (t, value) => (t.x = value), commit },
|
|
);
|
|
return { state, target, commit, handlers };
|
|
}
|
|
|
|
it('writes every input into the model and previews once a frame', () => {
|
|
const tick = frames();
|
|
const { state, target, handlers } = rig();
|
|
for (let i = 2; i <= 6; i++) {
|
|
handlers.onInput(i);
|
|
expect(target.x).toBe(i);
|
|
}
|
|
expect(state.preview).not.toHaveBeenCalled();
|
|
tick();
|
|
expect(state.preview).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('commits from the value before the scrub and leaves nothing for the next frame', () => {
|
|
const tick = frames();
|
|
const { state, target, commit, handlers } = rig();
|
|
handlers.onInput(5);
|
|
handlers.onChange(7);
|
|
expect(target.x).toBe(1);
|
|
expect(commit).toHaveBeenCalledWith(7);
|
|
expect(state.execute).toHaveBeenCalledTimes(1);
|
|
tick();
|
|
expect(state.preview).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('dragging a colour stop of a gradient (#274)', () => {
|
|
beforeAll(() => {
|
|
CanvasTextMetrics.measureText = (() => ({ width: 10, height: 10, lines: [], lineWidths: [] })) as never;
|
|
PaintCache.prototype.gradient = (() => null) as never;
|
|
});
|
|
beforeEach(() => resetRenderLoop());
|
|
|
|
function rig() {
|
|
const state = new EditorState();
|
|
state.loadProject(createEmptyProject('p', 'P'), 1);
|
|
const asset = buildAsset('vectorart', 'Drawing') as VectorArtAsset;
|
|
asset.layers[0]!.items.push({
|
|
id: 's',
|
|
name: 'Sky',
|
|
fill: 0x0000ff,
|
|
opacity: 1,
|
|
rings: [],
|
|
paint: {
|
|
type: 'linear',
|
|
from: { x: 0, y: 0 },
|
|
to: { x: 100, y: 0 },
|
|
stops: [
|
|
{ offset: 0, color: 0x0000ff, alpha: 1 },
|
|
{ offset: 1, color: 0xffffff, alpha: 1 },
|
|
],
|
|
},
|
|
});
|
|
state.project.assets.push(asset);
|
|
const host: InspectorHost = {
|
|
state,
|
|
body: new VStack(),
|
|
fields: new Map(),
|
|
nameField: null,
|
|
project: () => state.project,
|
|
liveVariables: () => null,
|
|
};
|
|
buildShapeSection(host, asset.id, 's', ['s']);
|
|
const bar = currentGradientBar('fill')!;
|
|
// 100 px between the outermost markers, which stand 6 px in from either end.
|
|
bar.resize(112, 32);
|
|
const pointer = (type: string, x: number) =>
|
|
bar.emit(type, { button: 0, global: { x, y: 25 }, stopPropagation: () => undefined });
|
|
const offsets = () => (allShapes(asset)[0]!.paint as VectorGradient).stops.map((stop) => stop.offset);
|
|
return { state, preview: vi.spyOn(state, 'preview'), pointer, offsets };
|
|
}
|
|
|
|
it('moves the stop with every pointer move and previews once a frame', () => {
|
|
const tick = frames();
|
|
const { preview, pointer, offsets } = rig();
|
|
pointer('pointerdown', 6);
|
|
for (let i = 1; i <= 5; i++) {
|
|
pointer('globalpointermove', 6 + i * 10);
|
|
expect(offsets()[0]).toBeCloseTo(i / 10);
|
|
}
|
|
expect(preview).not.toHaveBeenCalled();
|
|
tick();
|
|
expect(preview).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('leaves nothing for the frame after the release, and the drag is one undo step', () => {
|
|
const tick = frames();
|
|
const { state, preview, pointer, offsets } = rig();
|
|
pointer('pointerdown', 6);
|
|
pointer('globalpointermove', 56);
|
|
pointer('pointerup', 56);
|
|
expect(offsets()[0]).toBeCloseTo(0.5);
|
|
tick();
|
|
expect(preview).not.toHaveBeenCalled();
|
|
state.undo();
|
|
expect(offsets()[0]).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('the side panel of the pixel art tab (#274)', () => {
|
|
beforeAll(() => {
|
|
CanvasTextMetrics.measureText = (() => ({ width: 10, height: 10, lines: [], lineWidths: [] })) as never;
|
|
// Canvas and thumbnails paint onto browser canvases, which Node has none of.
|
|
PixelArtCanvas.prototype.redraw = () => undefined;
|
|
PixelThumbs.prototype.paint = () => undefined;
|
|
});
|
|
beforeEach(() => resetRenderLoop());
|
|
|
|
function rig() {
|
|
const state = new EditorState();
|
|
state.loadProject(createEmptyProject('p', 'P'), 1);
|
|
const palette = buildAsset('palette', 'Palette') as PaletteAsset;
|
|
const art = buildAsset('pixelart', 'Art') as PixelArtAsset;
|
|
art.props[PALETTE_KEY] = palette.id;
|
|
state.project.assets.push(palette, art);
|
|
const canvas = new PixelArtCanvas(state);
|
|
const side = new PixelArtSide(state, canvas);
|
|
canvas.show(art.id);
|
|
canvas.setColor(1);
|
|
const layer = art.layers[0]!;
|
|
// The row's number field calls these two; the list keeps them to itself.
|
|
const opacity = side as unknown as {
|
|
previewOpacity(layerId: string, opacity: number): void;
|
|
commitOpacity(layerId: string, opacity: number): void;
|
|
};
|
|
return { state, palette, art, layer, side, opacity, preview: vi.spyOn(state, 'preview') };
|
|
}
|
|
|
|
it('writes every colour under the picker into the palette and previews once a frame', () => {
|
|
const tick = frames();
|
|
const { state, palette, side, preview } = rig();
|
|
const first = palette.colors[0]!;
|
|
for (const color of [0x111111, 0x222222, 0x333333]) {
|
|
side.colorField.onInput?.(color);
|
|
expect(palette.colors[0]).toBe(color);
|
|
}
|
|
expect(preview).not.toHaveBeenCalled();
|
|
tick();
|
|
expect(preview).toHaveBeenCalledTimes(1);
|
|
side.colorField.onInput?.(0x444444);
|
|
side.colorField.onChange?.(0x555555);
|
|
tick();
|
|
expect(preview).toHaveBeenCalledTimes(1);
|
|
expect(palette.colors[0]).toBe(0x555555);
|
|
state.undo();
|
|
expect(palette.colors[0]).toBe(first);
|
|
});
|
|
|
|
it('says so when the picker ends on the colour it started from', () => {
|
|
const tick = frames();
|
|
const { palette, side, preview } = rig();
|
|
const first = palette.colors[0]!;
|
|
side.colorField.onInput?.(0x111111);
|
|
tick();
|
|
side.colorField.onChange?.(first);
|
|
expect(palette.colors[0]).toBe(first);
|
|
expect(preview).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('writes every step of an opacity scrub into the layer and previews once a frame', () => {
|
|
const tick = frames();
|
|
const { state, art, layer, opacity, preview } = rig();
|
|
for (const value of [0.9, 0.8, 0.7]) {
|
|
opacity.previewOpacity(layer.id, value);
|
|
expect(layer.opacity).toBe(value);
|
|
}
|
|
expect(preview).not.toHaveBeenCalled();
|
|
tick();
|
|
expect(preview).toHaveBeenCalledTimes(1);
|
|
opacity.previewOpacity(layer.id, 0.6);
|
|
opacity.commitOpacity(layer.id, 0.5);
|
|
tick();
|
|
expect(preview).toHaveBeenCalledTimes(1);
|
|
expect(art.layers[0]!.opacity).toBe(0.5);
|
|
state.undo();
|
|
expect(art.layers[0]!.opacity).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('the place fields of the curve tab (#274)', () => {
|
|
beforeAll(() => {
|
|
CanvasTextMetrics.measureText = (() => ({ width: 10, height: 10, lines: [], lineWidths: [] })) as never;
|
|
});
|
|
beforeEach(() => resetRenderLoop());
|
|
|
|
function rig() {
|
|
const state = new EditorState();
|
|
state.loadProject(createEmptyProject('p', 'P'), 1);
|
|
const curve = buildAsset('curve', 'Ease') as CurveAsset;
|
|
state.project.assets.push(curve);
|
|
const canvas = new CurveCanvas(state);
|
|
canvas.show(curve.id);
|
|
(canvas as unknown as { picked: number }).picked = 1;
|
|
return { state, curve, canvas, preview: vi.spyOn(state, 'preview') };
|
|
}
|
|
|
|
it('moves the picked point with every input and previews once a frame', () => {
|
|
const tick = frames();
|
|
const { state, curve, canvas, preview } = rig();
|
|
const before = curve.points[1]!.y;
|
|
for (const v of [0.9, 0.8, 0.7]) {
|
|
canvas.movePicked({ x: 1, y: v }, false);
|
|
expect(canvas.pickedPlace?.y).toBeCloseTo(v);
|
|
}
|
|
expect(preview).not.toHaveBeenCalled();
|
|
tick();
|
|
expect(preview).toHaveBeenCalledTimes(1);
|
|
canvas.movePicked({ x: 1, y: 0.6 }, false);
|
|
canvas.movePicked({ x: 1, y: 0.5 }, true);
|
|
tick();
|
|
expect(preview).toHaveBeenCalledTimes(1);
|
|
expect(curve.points[1]!.y).toBeCloseTo(0.5);
|
|
state.undo();
|
|
expect(curve.points[1]!.y).toBe(before);
|
|
});
|
|
});
|