The previous commit left two findings open: in the pixel art tab the palette colour and the opacity of a layer still told the editor about every input, and so did the place fields of the curve tab. They now follow the same rule as the inspector rows. - PixelArtSide writes colour and opacity into the model on every input and requests the preview through a FramePreview. Both commits cancel a waiting preview first. - CurveCanvas.movePicked does the same for the t and v fields of the picked point. Showing another curve cancels a waiting preview. - Also fixed: when the colour picker ended on the colour it started from, the model was put back but nobody was told, so the previewed colour stayed on screen. commitColor now says so, as the opacity already did. The gain was not measured for either tab, only the behaviour is tested. Tests: - Gate: 198 files, 2911 tests (2907 + 4), exit 0 - e2e 47,48,53: 117 checks passed - Four new unit tests in tests/editor/frame-preview.test.ts: colour, opacity, curve fields and the return to the starting colour - Seven counter-probes fail, one test each. They ran on stein2, not in the container. - No new e2e check, the sum stays at 1616. Neither the full suite nor the smokes ran for this state, and there was no counter-probe in the browser. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
370 lines
13 KiB
TypeScript
370 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';
|
|
|
|
/** 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.shapes.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 = () => (asset.shapes[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);
|
|
});
|
|
});
|