Dragging previews once per frame instead of on every move (#213)
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>
This commit is contained in:
1 parent
f9d5e8bca5
commit
30fb3a2316
7 files changed
+182
-10
No files matched your search
@@ -0,0 +1,29 @@
|
|||||||
|
import { cancelFrameWork, onFrame } from '../ui/render';
|
||||||
|
import type { EditorState } from './state';
|
||||||
|
|
||||||
|
let instances = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A drag's `state.preview()`, at most once per frame (#213). The gesture still writes the model on
|
||||||
|
* every pointer move; only the event that rebuilds panels, the run in the editor and generated
|
||||||
|
* pictures waits for the frame, which a mouse reporting faster than the screen would otherwise
|
||||||
|
* outrun (#129). `cancel` on release: the command writes the end state and emits for itself.
|
||||||
|
*/
|
||||||
|
export class FramePreview {
|
||||||
|
private readonly key: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly state: Pick<EditorState, 'preview'>,
|
||||||
|
name: string,
|
||||||
|
) {
|
||||||
|
this.key = `${name}:${(instances += 1)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
request(): void {
|
||||||
|
onFrame(this.key, () => this.state.preview());
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel(): void {
|
||||||
|
cancelFrameWork(this.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
travellers,
|
travellers,
|
||||||
type Travellers,
|
type Travellers,
|
||||||
} from './annotationDrag';
|
} from './annotationDrag';
|
||||||
|
import { FramePreview } from '../framePreview';
|
||||||
import type { AnnotationView } from './AnnotationView';
|
import type { AnnotationView } from './AnnotationView';
|
||||||
import type { GraphCanvasHost } from './graphHost';
|
import type { GraphCanvasHost } from './graphHost';
|
||||||
import { GRID_STEP } from './graphView';
|
import { GRID_STEP } from './graphView';
|
||||||
@@ -36,7 +37,11 @@ export class GraphDrags {
|
|||||||
moved: boolean;
|
moved: boolean;
|
||||||
} | null = null;
|
} | null = null;
|
||||||
|
|
||||||
constructor(private readonly host: GraphCanvasHost) {}
|
private readonly perFrame: FramePreview;
|
||||||
|
|
||||||
|
constructor(private readonly host: GraphCanvasHost) {
|
||||||
|
this.perFrame = new FramePreview(host.state, 'graph-drag');
|
||||||
|
}
|
||||||
|
|
||||||
/** A press on a node: the selection travels with it. False when there is no graph to move in. */
|
/** A press on a node: the selection travels with it. False when there is no graph to move in. */
|
||||||
grabNodes(global: Point): boolean {
|
grabNodes(global: Point): boolean {
|
||||||
@@ -95,7 +100,7 @@ export class GraphDrags {
|
|||||||
const step = this.host.snapping() ? GRID_STEP : 0;
|
const step = this.host.snapping() ? GRID_STEP : 0;
|
||||||
// Each thing snaps for itself, node or box, so what lands on the grid is what was grabbed.
|
// Each thing snaps for itself, node or box, so what lands on the grid is what was grabbed.
|
||||||
placeTravellers(graph, drag.along, (start) => dragOffset(start, dx, dy, step));
|
placeTravellers(graph, drag.along, (start) => dragOffset(start, dx, dy, step));
|
||||||
this.host.state.preview();
|
this.perFrame.request();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Moves or resizes the dragged box in the model, for the preview; committed on release. */
|
/** Moves or resizes the dragged box in the model, for the preview; committed on release. */
|
||||||
@@ -118,7 +123,7 @@ export class GraphDrags {
|
|||||||
const offset = dragOffset(drag.grabbed.start, dx, dy, step);
|
const offset = dragOffset(drag.grabbed.start, dx, dy, step);
|
||||||
placeTravellers(graph, drag.along, () => offset);
|
placeTravellers(graph, drag.along, () => offset);
|
||||||
}
|
}
|
||||||
this.host.state.preview();
|
this.perFrame.request();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Puts the previewed boxes back and records the whole gesture as one undo step. False when no box was dragged. */
|
/** Puts the previewed boxes back and records the whole gesture as one undo step. False when no box was dragged. */
|
||||||
@@ -126,6 +131,7 @@ export class GraphDrags {
|
|||||||
const drag = this.box;
|
const drag = this.box;
|
||||||
if (!drag) return false;
|
if (!drag) return false;
|
||||||
this.box = null;
|
this.box = null;
|
||||||
|
this.perFrame.cancel();
|
||||||
const graph = this.host.asset()?.graph;
|
const graph = this.host.asset()?.graph;
|
||||||
const assetId = this.host.assetId();
|
const assetId = this.host.assetId();
|
||||||
if (!drag.moved || !graph || !assetId) return true;
|
if (!drag.moved || !graph || !assetId) return true;
|
||||||
@@ -140,6 +146,7 @@ export class GraphDrags {
|
|||||||
releaseNodes(): void {
|
releaseNodes(): void {
|
||||||
const drag = this.nodes;
|
const drag = this.nodes;
|
||||||
this.nodes = null;
|
this.nodes = null;
|
||||||
|
this.perFrame.cancel();
|
||||||
const graph = this.host.asset()?.graph;
|
const graph = this.host.asset()?.graph;
|
||||||
const assetId = this.host.assetId();
|
const assetId = this.host.assetId();
|
||||||
if (!drag?.moved || !graph || !assetId) return;
|
if (!drag?.moved || !graph || !assetId) return;
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
} from '../polygonEdit';
|
} from '../polygonEdit';
|
||||||
import { dashedLine } from '../../viewport/symbols';
|
import { dashedLine } from '../../viewport/symbols';
|
||||||
import { textureLibrary } from '../../viewport/TextureLibrary';
|
import { textureLibrary } from '../../viewport/TextureLibrary';
|
||||||
|
import { FramePreview } from '../framePreview';
|
||||||
import type { EditorState } from '../state';
|
import type { EditorState } from '../state';
|
||||||
import { drawChecker } from './checker';
|
import { drawChecker } from './checker';
|
||||||
import { SheetCanvas } from './SheetCanvas';
|
import { SheetCanvas } from './SheetCanvas';
|
||||||
@@ -118,9 +119,11 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
|
|||||||
/** The outline a click adds corners to; for another frame, or one gone, the frame's last one. */
|
/** The outline a click adds corners to; for another frame, or one gone, the frame's last one. */
|
||||||
private active = { frameId: '', index: -1 };
|
private active = { frameId: '', index: -1 };
|
||||||
private readonly stopPictures: () => void;
|
private readonly stopPictures: () => void;
|
||||||
|
private readonly perFrame: FramePreview;
|
||||||
|
|
||||||
constructor(private readonly state: EditorState) {
|
constructor(private readonly state: EditorState) {
|
||||||
super();
|
super();
|
||||||
|
this.perFrame = new FramePreview(state, 'texture-drag');
|
||||||
this.content.addChild(this.image, this.overlay, this.labelLayer);
|
this.content.addChild(this.image, this.overlay, this.labelLayer);
|
||||||
|
|
||||||
state.on('project', () => this.redraw());
|
state.on('project', () => this.redraw());
|
||||||
@@ -344,7 +347,7 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
|
|||||||
const frame = corner && this.frame(corner.frameId);
|
const frame = corner && this.frame(corner.frameId);
|
||||||
if (!corner || !frame) return;
|
if (!corner || !frame) return;
|
||||||
setFrameCollision(frame, corner.outlines);
|
setFrameCollision(frame, corner.outlines);
|
||||||
this.state.preview();
|
this.perFrame.request();
|
||||||
}
|
}
|
||||||
|
|
||||||
private startDrag(
|
private startDrag(
|
||||||
@@ -424,10 +427,11 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
|
|||||||
frame,
|
frame,
|
||||||
normalizeFrameRect(dragFrame(drag.start, drag.handle, dx, dy), asset.width, asset.height),
|
normalizeFrameRect(dragFrame(drag.start, drag.handle, dx, dy), asset.width, asset.height),
|
||||||
);
|
);
|
||||||
this.state.preview();
|
this.perFrame.request();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected release(): void {
|
protected release(): void {
|
||||||
|
this.perFrame.cancel();
|
||||||
const corner = this.corner;
|
const corner = this.corner;
|
||||||
this.corner = null;
|
this.corner = null;
|
||||||
const frame = corner && this.frame(corner.frameId);
|
const frame = corner && this.frame(corner.frameId);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { findAsset, type ObjectHolder } from '../../core/model/Project';
|
|||||||
import { cellAtPoint } from '../../core/model/tilemap';
|
import { cellAtPoint } from '../../core/model/tilemap';
|
||||||
import { footprintCorners } from '../../core/properties/bounds';
|
import { footprintCorners } from '../../core/properties/bounds';
|
||||||
import { localMatrixOf, parentTransform, transformOf } from '../../core/properties/transform';
|
import { localMatrixOf, parentTransform, transformOf } from '../../core/properties/transform';
|
||||||
|
import { FramePreview } from '../framePreview';
|
||||||
import { type EditorState, objectSelection } from '../state';
|
import { type EditorState, objectSelection } from '../state';
|
||||||
import { paintedLayerId, paintedTilemap } from '../panels/TilemapPanel';
|
import { paintedLayerId, paintedTilemap } from '../panels/TilemapPanel';
|
||||||
import { activeDrag, drawBand, drawGrid, endDrag, onDragChange, Theme, textStyle } from '../../ui';
|
import { activeDrag, drawBand, drawGrid, endDrag, onDragChange, Theme, textStyle } from '../../ui';
|
||||||
@@ -109,6 +110,7 @@ export class SceneViewport extends FreeViewport {
|
|||||||
private editorWorld: World | null = null;
|
private editorWorld: World | null = null;
|
||||||
private lastRays: CastRay[] = [];
|
private lastRays: CastRay[] = [];
|
||||||
private dragging: ObjectDrag | null = null;
|
private dragging: ObjectDrag | null = null;
|
||||||
|
private readonly perFrame = new FramePreview(this.state, 'scene-drag');
|
||||||
private marquee: Marquee | null = null;
|
private marquee: Marquee | null = null;
|
||||||
private dropPreview: DropPreview | null = null;
|
private dropPreview: DropPreview | null = null;
|
||||||
private lastGizmos: GizmoInfo[] = [];
|
private lastGizmos: GizmoInfo[] = [];
|
||||||
@@ -232,7 +234,7 @@ export class SceneViewport extends FreeViewport {
|
|||||||
z: item.start.z,
|
z: item.start.z,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
this.state.preview();
|
this.perFrame.request();
|
||||||
} else if (this.marquee) {
|
} else if (this.marquee) {
|
||||||
const local = this.toLocal(e.global);
|
const local = this.toLocal(e.global);
|
||||||
this.marquee.current = { x: local.x, y: local.y };
|
this.marquee.current = { x: local.x, y: local.y };
|
||||||
@@ -303,6 +305,7 @@ export class SceneViewport extends FreeViewport {
|
|||||||
}
|
}
|
||||||
const drag = this.dragging;
|
const drag = this.dragging;
|
||||||
this.dragging = null;
|
this.dragging = null;
|
||||||
|
this.perFrame.cancel();
|
||||||
if (drag && !drag.moved && drag.onClick)
|
if (drag && !drag.moved && drag.onClick)
|
||||||
this.state.select(objectSelection(drag.holderId, [drag.onClick]));
|
this.state.select(objectSelection(drag.holderId, [drag.onClick]));
|
||||||
if (!drag || !drag.moved) return;
|
if (!drag || !drag.moved) return;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { pressCount, Theme } from '../../ui';
|
|||||||
import type { DrawItem } from '../../viewport/items';
|
import type { DrawItem } from '../../viewport/items';
|
||||||
import { type View, worldToScreen } from '../../viewport/view';
|
import { type View, worldToScreen } from '../../viewport/view';
|
||||||
import { gripAt, withCornerAfter, withCornerMoved, withoutCorner } from '../polygonEdit';
|
import { gripAt, withCornerAfter, withCornerMoved, withoutCorner } from '../polygonEdit';
|
||||||
|
import { FramePreview } from '../framePreview';
|
||||||
import type { SceneViewHost } from './sceneViewHost';
|
import type { SceneViewHost } from './sceneViewHost';
|
||||||
|
|
||||||
/** Screen pixels around a polygon's corner or edge that grab it. */
|
/** Screen pixels around a polygon's corner or edge that grab it. */
|
||||||
@@ -41,8 +42,11 @@ export class PolygonCornerEditor {
|
|||||||
private corner: CornerDrag | null = null;
|
private corner: CornerDrag | null = null;
|
||||||
/** The corners drawn for the edited polygon, in global pixels (test hook). */
|
/** The corners drawn for the edited polygon, in global pixels (test hook). */
|
||||||
private lastCorners: { objectId: string; corners: Vec2[] } | null = null;
|
private lastCorners: { objectId: string; corners: Vec2[] } | null = null;
|
||||||
|
private readonly perFrame: FramePreview;
|
||||||
|
|
||||||
constructor(private readonly host: SceneViewHost) {}
|
constructor(private readonly host: SceneViewHost) {
|
||||||
|
this.perFrame = new FramePreview(host.state, 'polygon-corner');
|
||||||
|
}
|
||||||
|
|
||||||
/** A corner is being dragged. */
|
/** A corner is being dragged. */
|
||||||
get active(): boolean {
|
get active(): boolean {
|
||||||
@@ -123,7 +127,7 @@ export class PolygonCornerEditor {
|
|||||||
const ref = corner && holder && objectRef(this.host.state.project, holder, corner.objectId);
|
const ref = corner && holder && objectRef(this.host.state.project, holder, corner.objectId);
|
||||||
if (!corner || !ref) return;
|
if (!corner || !ref) return;
|
||||||
ref.setOverride(COLLIDER_POINTS_KEY, listOf(corner.points));
|
ref.setOverride(COLLIDER_POINTS_KEY, listOf(corner.points));
|
||||||
this.host.state.preview();
|
this.perFrame.request();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -134,6 +138,7 @@ export class PolygonCornerEditor {
|
|||||||
const corner = this.corner;
|
const corner = this.corner;
|
||||||
if (!corner) return false;
|
if (!corner) return false;
|
||||||
this.corner = null;
|
this.corner = null;
|
||||||
|
this.perFrame.cancel();
|
||||||
const state = this.host.state;
|
const state = this.host.state;
|
||||||
const holder = this.host.holder();
|
const holder = this.host.holder();
|
||||||
const ref = holder && objectRef(state.project, holder, corner.objectId);
|
const ref = holder && objectRef(state.project, holder, corner.objectId);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
import { Theme } from '../../ui';
|
import { Theme } from '../../ui';
|
||||||
import type { DrawItem } from '../../viewport/items';
|
import type { DrawItem } from '../../viewport/items';
|
||||||
import { type View, worldToScreen } from '../../viewport/view';
|
import { type View, worldToScreen } from '../../viewport/view';
|
||||||
|
import { FramePreview } from '../framePreview';
|
||||||
import { paintedLayerId, paintedTilemap, pickTile } from '../panels/TilemapPanel';
|
import { paintedLayerId, paintedTilemap, pickTile } from '../panels/TilemapPanel';
|
||||||
import type { TilemapTool } from '../state';
|
import type { TilemapTool } from '../state';
|
||||||
import { MIN_GRID_STEP, type SceneViewHost } from './sceneViewHost';
|
import { MIN_GRID_STEP, type SceneViewHost } from './sceneViewHost';
|
||||||
@@ -54,8 +55,11 @@ interface PaintStroke {
|
|||||||
/** The tilemap tools of the scene view: brush, eraser, rectangle, fill and pick. */
|
/** The tilemap tools of the scene view: brush, eraser, rectangle, fill and pick. */
|
||||||
export class TilemapPainter {
|
export class TilemapPainter {
|
||||||
private stroke: PaintStroke | null = null;
|
private stroke: PaintStroke | null = null;
|
||||||
|
private readonly perFrame: FramePreview;
|
||||||
|
|
||||||
constructor(private readonly host: SceneViewHost) {}
|
constructor(private readonly host: SceneViewHost) {
|
||||||
|
this.perFrame = new FramePreview(host.state, 'tile-stroke');
|
||||||
|
}
|
||||||
|
|
||||||
/** A stroke is under way. */
|
/** A stroke is under way. */
|
||||||
get active(): boolean {
|
get active(): boolean {
|
||||||
@@ -198,13 +202,14 @@ export class TilemapPainter {
|
|||||||
else delete layer.cells[key];
|
else delete layer.cells[key];
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
if (changed) this.host.state.preview();
|
if (changed) this.perFrame.request();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Puts the preview back and lets the command do the edit, so one stroke is one undo step. */
|
/** Puts the preview back and lets the command do the edit, so one stroke is one undo step. */
|
||||||
private endStroke(): void {
|
private endStroke(): void {
|
||||||
const stroke = this.stroke;
|
const stroke = this.stroke;
|
||||||
this.stroke = null;
|
this.stroke = null;
|
||||||
|
this.perFrame.cancel();
|
||||||
if (!stroke) return;
|
if (!stroke) return;
|
||||||
const layer = this.strokeLayer(stroke);
|
const layer = this.strokeLayer(stroke);
|
||||||
if (layer)
|
if (layer)
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user