Scene view, texture tab and graph called state.preview() on every pointer move. Each call rebuilds panels, the editor run and generated images, so a drag in a busy project cost up to 34 ms per move. The model is still written on every move. Only the preview now waits for the next frame. - New FramePreview in src/editor/framePreview.ts. request() goes into the move handler, cancel() into the release. The rule lives in one place instead of six copies. - Scene view: object drag, polygon corners and tilemap strokes use it. - Texture tab: frames, slice edges and collision corners use it. - Graph: nodes, groups and comments use it through GraphDrags. That covers the state machine, shader and particle tabs as well. - Vector art is unchanged. Its frame already writes the model since #129. Findings: - The texture tab paid twice per move (232 project events for 120 moves). Pixi sends a second pointer event 4 to 8 ms after each real move as a hover update. It now lands in the same frame for free. - Inspector scrubbing and gradient stop dragging still preview on every input. Both lie outside the three canvases of the issue. Measured with a throwaway probe, 120 moves at 8 ms, 120 extra objects: scene 26.2 -> 18.5 ms/move, texture 34.2 -> 17.9, graph 28.8 -> 17.0. Project events dropped from 120/232/119 to 48/48/41. Tests: - ./check.sh: 147 files, 2204 tests, exit 0 (before 146 / 2199). - 5 new unit tests in tests/editor/frame-preview.test.ts, including the real GraphDrags. Counter-checks (direct preview in the graph, no cancel on release, one key per name) each fail the expected test. - e2e 1-16,29,31,36,37,39-43: 708 checks passed. - e2e 1-16,26,28: 634 checks passed. - No new e2e check, the check sum stays at 1314. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
120 lines
3.8 KiB
TypeScript
120 lines
3.8 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 } from 'pixi.js';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import type { GraphAsset } from '../../src/core/model/Asset';
|
|
import { FramePreview } from '../../src/editor/framePreview';
|
|
import { GraphDrags } from '../../src/editor/graph/graphDrags';
|
|
import type { GraphCanvasHost } from '../../src/editor/graph/graphHost';
|
|
import type { EditorState } from '../../src/editor/state';
|
|
import { initRenderLoop, resetRenderLoop } from '../../src/ui/render';
|
|
|
|
/** 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();
|
|
});
|
|
});
|