Pan with the middle button, select several nodes, and fit properly
Three things the graph view did differently from the scene view, or not at all: Panning moves to the middle button, which works from anywhere – a node under the pointer included, since NodeView now lets that one button bubble up. Shift-drag on empty canvas pans too. The left button pulls a rubber band instead and no longer pans. Nodes can be selected several at a time: by band, by ctrl/shift-click, moved together, deleted together, each as one undo step. The selection model gains `nodeIds` alongside the objects' `objectIds`. Fit really fits. It used to pin the zoom at 1 and merely centre, so a graph larger than the view always left nodes hanging out, and it sized every node with one guessed height although real ones vary with their port count. Both sums now live in editor/graph/framing.ts as pure functions, which is what made the defect testable at all. The band selects any node it overlaps rather than one whose position it contains, unlike the scene view: nodes are large rectangles, and a band drawn visibly across one has to catch it.
This commit is contained in:
1 parent
ed1fe31c77
commit
e0d43e12aa
12 files changed
+656
-61
No files matched your search
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { type Box, boxesInBand, frameBoxes } from '../../src/editor/graph/framing';
|
||||
|
||||
const LIMITS = { min: 0.25, max: 2.5 };
|
||||
|
||||
/** Two nodes side by side, the right one twice as tall. */
|
||||
const boxes: Box[] = [
|
||||
{ id: 'a', x: 0, y: 0, w: 100, h: 60 },
|
||||
{ id: 'b', x: 200, y: 0, w: 100, h: 120 },
|
||||
];
|
||||
|
||||
describe('rubber band', () => {
|
||||
it('catches a node the band merely touches, not only one it encloses', () => {
|
||||
// The band clips a's right edge and never reaches b.
|
||||
expect(boxesInBand(boxes, { x1: 90, y1: 10, x2: 150, y2: 20 })).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('catches both when the band spans them', () => {
|
||||
expect(boxesInBand(boxes, { x1: -10, y1: -10, x2: 310, y2: 130 })).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('reads a band dragged up and to the left the same way', () => {
|
||||
expect(boxesInBand(boxes, { x1: 310, y1: 130, x2: -10, y2: -10 })).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('catches nothing in empty space', () => {
|
||||
expect(boxesInBand(boxes, { x1: 120, y1: 200, x2: 180, y2: 260 })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fit', () => {
|
||||
/** Where a graph point lands on screen under a framing. */
|
||||
const toScreen = (f: { zoom: number; panX: number; panY: number }, x: number, y: number) => ({
|
||||
x: x * f.zoom + f.panX,
|
||||
y: y * f.zoom + f.panY,
|
||||
});
|
||||
|
||||
it('leaves no node outside the view – the reported bug', () => {
|
||||
// Wider than the view at zoom 1, so framing has to zoom out rather than just centre.
|
||||
const view = { w: 200, h: 200 };
|
||||
const f = frameBoxes(boxes, view, 24, LIMITS);
|
||||
expect(f.zoom).toBeLessThan(1);
|
||||
for (const b of boxes) {
|
||||
const topLeft = toScreen(f, b.x, b.y);
|
||||
const bottomRight = toScreen(f, b.x + b.w, b.y + b.h);
|
||||
expect(topLeft.x).toBeGreaterThanOrEqual(0);
|
||||
expect(topLeft.y).toBeGreaterThanOrEqual(0);
|
||||
expect(bottomRight.x).toBeLessThanOrEqual(view.w);
|
||||
expect(bottomRight.y).toBeLessThanOrEqual(view.h);
|
||||
}
|
||||
});
|
||||
|
||||
it('measures each node by its own height', () => {
|
||||
// b is twice as tall as a: pretending both were 60 high would frame too little.
|
||||
const tall = frameBoxes([{ id: 'b', x: 0, y: 0, w: 100, h: 400 }], { w: 200, h: 200 }, 24, LIMITS);
|
||||
const short = frameBoxes([{ id: 'a', x: 0, y: 0, w: 100, h: 60 }], { w: 200, h: 200 }, 24, LIMITS);
|
||||
expect(tall.zoom).toBeLessThan(short.zoom);
|
||||
});
|
||||
|
||||
it('centres what it frames', () => {
|
||||
const view = { w: 200, h: 200 };
|
||||
const f = frameBoxes(boxes, view, 24, LIMITS);
|
||||
const centre = toScreen(f, 150, 60);
|
||||
expect(centre.x).toBeCloseTo(100);
|
||||
expect(centre.y).toBeCloseTo(100);
|
||||
});
|
||||
|
||||
it('does not blow a small graph up past its natural size', () => {
|
||||
const f = frameBoxes([{ id: 'a', x: 0, y: 0, w: 10, h: 10 }], { w: 800, h: 600 }, 24, LIMITS);
|
||||
expect(f.zoom).toBe(1);
|
||||
});
|
||||
|
||||
it('never zooms out past the limit, however far the nodes are apart', () => {
|
||||
const far: Box[] = [
|
||||
{ id: 'a', x: 0, y: 0, w: 100, h: 60 },
|
||||
{ id: 'b', x: 100000, y: 0, w: 100, h: 60 },
|
||||
];
|
||||
expect(frameBoxes(far, { w: 200, h: 200 }, 24, LIMITS).zoom).toBe(LIMITS.min);
|
||||
});
|
||||
|
||||
it('centres the view on an empty graph instead of returning nonsense', () => {
|
||||
const f = frameBoxes([], { w: 200, h: 100 }, 24, LIMITS);
|
||||
expect(f).toEqual({ zoom: 1, panX: 100, panY: 50 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
|
||||
import { AddNodeCommand } from '../../src/core/commands/graphCommands';
|
||||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||||
import { createEmptyProject } from '../../src/core/model/Project';
|
||||
import { removeNodes } from '../../src/editor/actions';
|
||||
import { EditorState, nodeSelection } from '../../src/editor/state';
|
||||
|
||||
function setup(): { state: EditorState; behavior: BehaviorAsset; ids: string[] } {
|
||||
const state = new EditorState();
|
||||
state.loadProject(createEmptyProject('p', 'P'), 1);
|
||||
const create = new CreateAssetCommand('behavior', 'B');
|
||||
// A rejected command only returns false, so an unasserted setup would leave an empty graph
|
||||
// and let every assertion below pass for the wrong reason.
|
||||
expect(state.execute(create)).toBe(true);
|
||||
const behavior = create.asset as BehaviorAsset;
|
||||
const ids = ['OnUpdate', 'Number', 'Number'].map((type) => {
|
||||
const add = new AddNodeCommand(behavior.id, type, 0, 0);
|
||||
expect(state.execute(add)).toBe(true);
|
||||
return add.node.id;
|
||||
});
|
||||
return { state, behavior, ids };
|
||||
}
|
||||
|
||||
describe('selecting several graph nodes', () => {
|
||||
let f: ReturnType<typeof setup>;
|
||||
beforeEach(() => {
|
||||
f = setup();
|
||||
});
|
||||
|
||||
it('keeps the last picked one as the primary, which is what the inspector shows', () => {
|
||||
f.state.select(nodeSelection(f.behavior.id, f.ids));
|
||||
expect(f.state.selectedNodeIds).toEqual(f.ids);
|
||||
expect(f.state.selection).toMatchObject({ kind: 'node', nodeId: f.ids[2] });
|
||||
});
|
||||
|
||||
it('adds and removes a node on ctrl-click', () => {
|
||||
f.state.select(nodeSelection(f.behavior.id, [f.ids[0]!]));
|
||||
f.state.toggleNode(f.behavior.id, f.ids[1]!);
|
||||
expect(f.state.selectedNodeIds).toEqual([f.ids[0], f.ids[1]]);
|
||||
f.state.toggleNode(f.behavior.id, f.ids[0]!);
|
||||
expect(f.state.selectedNodeIds).toEqual([f.ids[1]]);
|
||||
});
|
||||
|
||||
it('falls back to the behavior when the last node is toggled off, so the graph stays open', () => {
|
||||
f.state.select(nodeSelection(f.behavior.id, [f.ids[0]!]));
|
||||
f.state.toggleNode(f.behavior.id, f.ids[0]!);
|
||||
expect(f.state.selection).toEqual({ kind: 'asset', assetId: f.behavior.id });
|
||||
});
|
||||
|
||||
it('accepts the short form a test hook passes, filling in the list', () => {
|
||||
f.state.select({ kind: 'node', assetId: f.behavior.id, nodeId: f.ids[0]! } as never);
|
||||
expect(f.state.selectedNodeIds).toEqual([f.ids[0]]);
|
||||
});
|
||||
|
||||
it('deletes the whole selection in one undo step', () => {
|
||||
f.state.select(nodeSelection(f.behavior.id, [f.ids[0]!, f.ids[1]!]));
|
||||
removeNodes(f.state, f.behavior.id, f.state.selectedNodeIds);
|
||||
const graph = () => (f.state.project.assets.find((a) => a.id === f.behavior.id) as BehaviorAsset).graph;
|
||||
expect(graph().nodes.map((n) => n.id)).toEqual([f.ids[2]]);
|
||||
f.state.undo();
|
||||
expect(graph().nodes).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('drops deleted nodes from the selection but keeps the survivors', () => {
|
||||
f.state.select(nodeSelection(f.behavior.id, f.ids));
|
||||
removeNodes(f.state, f.behavior.id, [f.ids[0]!]);
|
||||
expect(f.state.selectedNodeIds).toEqual([f.ids[1], f.ids[2]]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user