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
@@ -88,8 +88,14 @@ shown on the node and editable in the inspector.
|
||||
`On key down (Space)` sets `vy = −jumpSpeed` when `onGround`.
|
||||
- **Editing**: right-click the canvas for the searchable palette, drag port → port to connect
|
||||
(types are checked; a data input holds one link and is replaced), drag nodes, right-click a
|
||||
node or port to delete/disconnect, `Delete` removes the selected node, wheel zooms, dragging
|
||||
the empty canvas pans. Everything is undoable.
|
||||
node or port to delete/disconnect, `Delete` removes the selection, wheel zooms. Everything is
|
||||
undoable.
|
||||
- **Mouse and selection work as in the scene view.** The **middle button pans** from anywhere,
|
||||
a node under the pointer included (shift-drag on empty canvas does too); the left button never
|
||||
pans. Dragging on empty canvas pulls a **rubber band** instead, which selects every node it
|
||||
touches – nodes are large rectangles, so touching counts, unlike the scene view's test of an
|
||||
object's position. Ctrl/Shift-click adds or removes single nodes, dragging any node of a
|
||||
selection moves all of them, and `Delete` removes all of them – one undo step either way.
|
||||
- **Dragging an edge tells you where it would land** before you let go: the port it would connect
|
||||
to wears a white ring and the line snaps to it. Dropping on a node's *body* rather than a port
|
||||
takes that node's first free compatible port – or, when none is free, the last compatible one,
|
||||
@@ -100,7 +106,8 @@ shown on the node and editable in the inspector.
|
||||
still visible while its node is chosen.
|
||||
- **Header bar** (the same one the Texture and Container tabs have): the behavior picker,
|
||||
**Add node…** (the palette, dropping the node in the middle of the view – the right-click nobody
|
||||
finds), **Fit**, and the size of the graph as "N nodes · M links".
|
||||
finds), **Fit** (which zooms out far enough to hold every node, measuring each by its own drawn
|
||||
height – `editor/graph/framing.ts`), and the size of the graph as "N nodes · M links".
|
||||
- **Selection-following, not selection-bound**: selecting a behavior asset, a placed behavior
|
||||
or one of its nodes shows that graph; a selection about anything else (a sprite, nothing at
|
||||
all) leaves the tab on the behavior you were editing, because checking a sprite's position
|
||||
@@ -433,7 +440,7 @@ npm run format # Prettier over src, tests and tools
|
||||
npm run check # typecheck + lint + format:check + tests, what CI would run
|
||||
npm run build # writes dist/ and regenerates index.html
|
||||
export GM_REMOTE=user@host # where the browser runs; see below
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # 299 editor checks, screenshots in /tmp/gm-e2e-*.png
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # 312 editor checks, screenshots in /tmp/gm-e2e-*.png
|
||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs # 24 checks: the example game, played end to end
|
||||
```
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
*/
|
||||
import { DeleteAssetCommand } from '../core/commands/assetCommands';
|
||||
import { AddColliderCommand } from '../core/commands/colliderCommands';
|
||||
import { CompoundCommand } from '../core/commands/CompoundCommand';
|
||||
import { CreateContainerCommand } from '../core/commands/containerCommands';
|
||||
import { RemoveNodeCommand } from '../core/commands/graphCommands';
|
||||
import { RemoveSceneObjectsCommand } from '../core/commands/sceneObjectCommands';
|
||||
import { findAsset, findPropertyUsages, findUsages } from '../core/model/Project';
|
||||
import { textureRefIds } from '../core/model/texture';
|
||||
@@ -32,6 +34,14 @@ export function removeObjects(state: EditorState, holderId: string, objectIds: r
|
||||
return state.execute(new RemoveSceneObjectsCommand(holderId, objectIds));
|
||||
}
|
||||
|
||||
/** Deletes graph nodes as one undo step, however many were selected. */
|
||||
export function removeNodes(state: EditorState, assetId: string, nodeIds: readonly string[]): boolean {
|
||||
const commands = nodeIds.map((id) => new RemoveNodeCommand(assetId, id));
|
||||
if (commands.length === 0) return false;
|
||||
if (commands.length === 1) return state.execute(commands[0]!);
|
||||
return state.execute(new CompoundCommand(`Delete ${commands.length} nodes`, commands));
|
||||
}
|
||||
|
||||
/** Asks before deleting an asset, spelling out what goes with it. */
|
||||
export function confirmDeleteAsset(state: EditorState, assetId: string): void {
|
||||
const asset = findAsset(state.project, assetId);
|
||||
|
||||
@@ -155,6 +155,10 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
portAt: (nodeId: string, port: string, isOutput: boolean) =>
|
||||
graphEditor.canvas.portGlobal(nodeId, port, isOutput),
|
||||
nodeAt: (nodeId: string) => graphEditor.canvas.nodeGlobal(nodeId),
|
||||
/** Drawn rectangles on screen: what "fit" has to keep inside `canvasRect`. */
|
||||
nodeRect: (nodeId: string) => graphEditor.canvas.nodeRect(nodeId),
|
||||
canvasRect: () => graphEditor.canvas.canvasRect(),
|
||||
selected: () => state.selectedNodeIds,
|
||||
dropTarget: () => graphEditor.canvas.pendingDropTarget(),
|
||||
danglingEdge: () => graphEditor.canvas.danglingEdgeVisible(),
|
||||
// The canvas, not the whole tab: a centre measured through the header sits above the
|
||||
|
||||
+172
-40
@@ -6,7 +6,6 @@ import {
|
||||
ConnectCommand,
|
||||
DisconnectCommand,
|
||||
MoveNodeCommand,
|
||||
RemoveNodeCommand,
|
||||
} from '../../core/commands/graphCommands';
|
||||
import {
|
||||
canConnect,
|
||||
@@ -16,6 +15,7 @@ import {
|
||||
edgesOutOf,
|
||||
firstPortFor,
|
||||
type GraphEdge,
|
||||
type GraphNode,
|
||||
type NodeDef,
|
||||
nodeDef,
|
||||
type PortSide,
|
||||
@@ -27,7 +27,9 @@ import type { BehaviorAsset } from '../../core/model/Asset';
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem';
|
||||
import { clamp, drawGrid, Menu, type Size, Theme, textStyle, Widget, zoomFactor } from '../../ui';
|
||||
import type { EditorState } from '../state';
|
||||
import { removeNodes } from '../actions';
|
||||
import { type EditorState, nodeSelection } from '../state';
|
||||
import { type Box, boxesInBand, frameBoxes } from './framing';
|
||||
import { NodePalette } from './NodePalette';
|
||||
import { NODE_WIDTH, NodeView, portColor, TITLE_HEIGHT } from './NodeView';
|
||||
|
||||
@@ -36,8 +38,12 @@ const GRID_STEP = 24;
|
||||
const MIN_GRID_STEP = 8;
|
||||
const MIN_ZOOM = 0.25;
|
||||
const MAX_ZOOM = 2.5;
|
||||
/** Stand-in node height for centring the view – real nodes vary with their port count. */
|
||||
/** Stand-in node height for a node with no view yet – real nodes vary with their port count. */
|
||||
const NODE_HEIGHT_ESTIMATE = 80;
|
||||
/** Breathing room around the framed graph, so nodes do not touch the edge. */
|
||||
const FIT_MARGIN = 24;
|
||||
/** Pointer travel (screen pixels) before a press turns into a drag rather than a click. */
|
||||
const DRAG_THRESHOLD = 3;
|
||||
|
||||
/**
|
||||
* The node graph of one behavior asset. Which behavior that is comes from the outside (`show`),
|
||||
@@ -51,6 +57,8 @@ export class GraphCanvas extends Widget {
|
||||
private readonly edges = new Graphics();
|
||||
private readonly nodeLayer = new Container();
|
||||
private readonly tempEdge = new Graphics();
|
||||
/** Drawn in screen space, so the band keeps a hairline border at any zoom. */
|
||||
private readonly band = new Graphics();
|
||||
private readonly hint = new Text({ text: '', style: textStyle('muted') });
|
||||
private readonly views = new Map<string, NodeView>();
|
||||
private assetId: string | null = null;
|
||||
@@ -58,11 +66,16 @@ export class GraphCanvas extends Widget {
|
||||
private zoom = 1;
|
||||
private panning: { startX: number; startY: number; panX: number; panY: number } | null = null;
|
||||
private dragging: {
|
||||
nodeId: string;
|
||||
start: { x: number; y: number };
|
||||
items: { nodeId: string; start: { x: number; y: number } }[];
|
||||
pointer: { x: number; y: number };
|
||||
moved: boolean;
|
||||
} | null = null;
|
||||
/** A rubber-band selection in progress, in graph coordinates. */
|
||||
private marquee: {
|
||||
start: { x: number; y: number };
|
||||
current: { x: number; y: number };
|
||||
active: boolean;
|
||||
} | null = null;
|
||||
private connecting: { from: PortSide; pointer: { x: number; y: number } } | null = null;
|
||||
/** A finished drag whose palette is still open – drawn like the running one, so the connection
|
||||
* being made stays visible while the node type is chosen. */
|
||||
@@ -80,7 +93,7 @@ export class GraphCanvas extends Widget {
|
||||
constructor(private readonly state: EditorState) {
|
||||
super();
|
||||
this.canvas.addChild(this.edges, this.nodeLayer, this.tempEdge);
|
||||
this.addChild(this.bg, this.grid, this.canvas, this.hint, this.clip);
|
||||
this.addChild(this.bg, this.grid, this.canvas, this.band, this.hint, this.clip);
|
||||
this.canvas.mask = this.clip;
|
||||
this.cursor = 'default';
|
||||
|
||||
@@ -178,7 +191,7 @@ export class GraphCanvas extends Widget {
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
/** Centres the graph's nodes (or the origin) in the view. Waits for a real size. */
|
||||
/** Frames the graph's nodes (or the origin) in the view. Waits for a real size. */
|
||||
private fitView(): void {
|
||||
if (this.w === 0 || this.h === 0) return;
|
||||
this.needsFit = false;
|
||||
@@ -186,17 +199,32 @@ export class GraphCanvas extends Widget {
|
||||
if (!asset || asset.graph.nodes.length === 0) {
|
||||
this.pan = { x: this.w / 2 - NODE_WIDTH / 2, y: this.h / 3 };
|
||||
this.zoom = 1;
|
||||
} else {
|
||||
const xs = asset.graph.nodes.map((n) => n.x);
|
||||
const ys = asset.graph.nodes.map((n) => n.y);
|
||||
const cx = (Math.min(...xs) + Math.max(...xs) + NODE_WIDTH) / 2;
|
||||
const cy = (Math.min(...ys) + Math.max(...ys) + NODE_HEIGHT_ESTIMATE) / 2;
|
||||
this.zoom = 1;
|
||||
this.pan = { x: this.w / 2 - cx, y: this.h / 2 - cy };
|
||||
this.applyView();
|
||||
return;
|
||||
}
|
||||
const framing = frameBoxes(this.nodeBoxes(), { w: this.w, h: this.h }, FIT_MARGIN, {
|
||||
min: MIN_ZOOM,
|
||||
max: MAX_ZOOM,
|
||||
});
|
||||
this.zoom = framing.zoom;
|
||||
this.pan = { x: framing.panX, y: framing.panY };
|
||||
this.applyView();
|
||||
}
|
||||
|
||||
/** Each node as the rectangle it is actually drawn as (a view knows its own height). */
|
||||
private nodeBoxes(): Box[] {
|
||||
return (this.asset()?.graph.nodes ?? []).map((node) => {
|
||||
const view = this.views.get(node.id);
|
||||
return {
|
||||
id: node.id,
|
||||
x: node.x,
|
||||
y: node.y,
|
||||
w: view?.w ?? NODE_WIDTH,
|
||||
h: view?.h ?? NODE_HEIGHT_ESTIMATE,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private applyView(): void {
|
||||
this.canvas.position.set(Math.round(this.pan.x), Math.round(this.pan.y));
|
||||
this.canvas.scale.set(this.zoom);
|
||||
@@ -207,24 +235,42 @@ export class GraphCanvas extends Widget {
|
||||
return { x: (sx - this.pan.x) / this.zoom, y: (sy - this.pan.y) / this.zoom };
|
||||
}
|
||||
|
||||
private graphToScreen(gx: number, gy: number): { x: number; y: number } {
|
||||
return { x: gx * this.zoom + this.pan.x, y: gy * this.zoom + this.pan.y };
|
||||
}
|
||||
|
||||
// --- pointer -----------------------------------------------------------------------------
|
||||
|
||||
private onCanvasPointerDown(e: FederatedPointerEvent): void {
|
||||
// The middle button pans from anywhere, a node under the pointer included – it is the one
|
||||
// gesture that never means "grab this". Nodes let it through for exactly that reason.
|
||||
if (e.button === 1) {
|
||||
this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y };
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (e.target !== this) return;
|
||||
// Shift-drag on empty space pans as well, as it does in the scene view.
|
||||
if (e.button === 0 && e.shiftKey) {
|
||||
this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y };
|
||||
return;
|
||||
}
|
||||
const local = this.toLocal(e.global);
|
||||
if (e.button === 2) {
|
||||
if (this.assetId && this.state.mode === 'edit')
|
||||
this.openPalette(e.global.x, e.global.y, this.screenToGraph(local.x, local.y));
|
||||
return;
|
||||
}
|
||||
if (e.button === 0 || e.button === 1) {
|
||||
if (e.button === 0 && this.assetId && this.state.mode === 'edit')
|
||||
this.state.select({ kind: 'asset', assetId: this.assetId });
|
||||
this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y };
|
||||
if (e.button === 0) {
|
||||
// Empty space: a click selects the behavior, a drag becomes a rubber band – decided on release.
|
||||
const g = this.screenToGraph(local.x, local.y);
|
||||
this.marquee = { start: g, current: g, active: false };
|
||||
}
|
||||
}
|
||||
|
||||
private onNodePointerDown(view: NodeView, e: FederatedPointerEvent): void {
|
||||
// Panning takes precedence over the node: let the middle button bubble up to the canvas.
|
||||
if (e.button === 1) return;
|
||||
if (!this.assetId || this.state.mode !== 'edit') return;
|
||||
e.stopPropagation();
|
||||
const local = view.toLocal(e.global);
|
||||
@@ -245,17 +291,29 @@ export class GraphCanvas extends Widget {
|
||||
this.redraw();
|
||||
return;
|
||||
}
|
||||
this.state.select({ kind: 'node', assetId: this.assetId, nodeId: view.node.id });
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey) {
|
||||
this.state.toggleNode(this.assetId, view.node.id);
|
||||
return;
|
||||
}
|
||||
// Clicking a selected node keeps the selection so the whole group can be dragged.
|
||||
if (!this.state.selectedNodeIds.includes(view.node.id))
|
||||
this.state.select(nodeSelection(this.assetId, [view.node.id]));
|
||||
const p = this.toLocal(e.global);
|
||||
this.dragging = {
|
||||
nodeId: view.node.id,
|
||||
start: { x: view.node.x, y: view.node.y },
|
||||
items: this.selectedNodes().map((n) => ({ nodeId: n.id, start: { x: n.x, y: n.y } })),
|
||||
pointer: this.screenToGraph(p.x, p.y),
|
||||
moved: false,
|
||||
};
|
||||
this.nodeLayer.addChild(view); // raise
|
||||
}
|
||||
|
||||
private selectedNodes(): GraphNode[] {
|
||||
const graph = this.asset()?.graph;
|
||||
if (!graph) return [];
|
||||
const ids = new Set(this.state.selectedNodeIds);
|
||||
return graph.nodes.filter((n) => ids.has(n.id));
|
||||
}
|
||||
|
||||
private onPointerMove(e: FederatedPointerEvent): void {
|
||||
if (this.panning) {
|
||||
this.pan.x = this.panning.panX + (e.global.x - this.panning.startX);
|
||||
@@ -268,33 +326,54 @@ export class GraphCanvas extends Widget {
|
||||
if (this.dragging) {
|
||||
const dx = g.x - this.dragging.pointer.x;
|
||||
const dy = g.y - this.dragging.pointer.y;
|
||||
if (!this.dragging.moved && Math.hypot(dx, dy) * this.zoom < 3) return;
|
||||
if (!this.dragging.moved && Math.hypot(dx, dy) * this.zoom < DRAG_THRESHOLD) return;
|
||||
this.dragging.moved = true;
|
||||
const node = this.asset()?.graph.nodes.find((n) => n.id === this.dragging?.nodeId);
|
||||
if (!node) return;
|
||||
node.x = Math.round(this.dragging.start.x + dx);
|
||||
node.y = Math.round(this.dragging.start.y + dy);
|
||||
const graph = this.asset()?.graph;
|
||||
if (!graph) return;
|
||||
for (const item of this.dragging.items) {
|
||||
const node = graph.nodes.find((n) => n.id === item.nodeId);
|
||||
if (!node) continue;
|
||||
node.x = Math.round(item.start.x + dx);
|
||||
node.y = Math.round(item.start.y + dy);
|
||||
}
|
||||
this.state.preview();
|
||||
} else if (this.connecting) {
|
||||
this.connecting.pointer = g;
|
||||
this.dropTarget = this.resolveDrop(e.global.x, e.global.y);
|
||||
this.redraw();
|
||||
} else if (this.marquee) {
|
||||
this.marquee.current = g;
|
||||
const d = Math.hypot(g.x - this.marquee.start.x, g.y - this.marquee.start.y) * this.zoom;
|
||||
if (!this.marquee.active && d < DRAG_THRESHOLD) return;
|
||||
this.marquee.active = true;
|
||||
this.redraw();
|
||||
}
|
||||
}
|
||||
|
||||
private onPointerUp(e: FederatedPointerEvent): void {
|
||||
this.panning = null;
|
||||
const marquee = this.marquee;
|
||||
this.marquee = null;
|
||||
if (marquee && this.assetId && this.state.mode === 'edit') {
|
||||
if (marquee.active) this.selectInMarquee(marquee);
|
||||
else this.state.select({ kind: 'asset', assetId: this.assetId });
|
||||
}
|
||||
const drag = this.dragging;
|
||||
this.dragging = null;
|
||||
if (drag && this.assetId) {
|
||||
const node = this.asset()?.graph.nodes.find((n) => n.id === drag.nodeId);
|
||||
if (node && drag.moved) {
|
||||
if (drag?.moved && this.assetId) {
|
||||
const graph = this.asset()?.graph;
|
||||
const moves: MoveNodeCommand[] = [];
|
||||
for (const item of drag.items) {
|
||||
const node = graph?.nodes.find((n) => n.id === item.nodeId);
|
||||
if (!node) continue;
|
||||
// The preview moved the node already; put it back so the command records the start.
|
||||
const next = { x: node.x, y: node.y };
|
||||
node.x = drag.start.x;
|
||||
node.y = drag.start.y;
|
||||
this.state.execute(new MoveNodeCommand(this.assetId, drag.nodeId, next.x, next.y));
|
||||
node.x = item.start.x;
|
||||
node.y = item.start.y;
|
||||
moves.push(new MoveNodeCommand(this.assetId, item.nodeId, next.x, next.y));
|
||||
}
|
||||
if (moves.length === 1) this.state.execute(moves[0]!);
|
||||
else if (moves.length > 1) this.state.execute(new CompoundCommand('Move nodes', moves));
|
||||
}
|
||||
const connecting = this.connecting;
|
||||
const target = connecting ? (this.dropTarget ?? this.resolveDrop(e.global.x, e.global.y)) : null;
|
||||
@@ -308,6 +387,21 @@ export class GraphCanvas extends Widget {
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
private selectInMarquee(marquee: {
|
||||
start: { x: number; y: number };
|
||||
current: { x: number; y: number };
|
||||
}): void {
|
||||
if (!this.assetId) return;
|
||||
const ids = boxesInBand(this.nodeBoxes(), {
|
||||
x1: marquee.start.x,
|
||||
y1: marquee.start.y,
|
||||
x2: marquee.current.x,
|
||||
y2: marquee.current.y,
|
||||
});
|
||||
// An empty band clears the nodes but keeps the behavior selected, as a plain click does.
|
||||
this.state.select(nodeSelection(this.assetId, ids) ?? { kind: 'asset', assetId: this.assetId });
|
||||
}
|
||||
|
||||
private connect(from: PortSide, to: PortSide): void {
|
||||
if (!this.assetId) return;
|
||||
const [out, inp] = from.isOutput ? [from, to] : [to, from];
|
||||
@@ -395,8 +489,7 @@ export class GraphCanvas extends Widget {
|
||||
private addNode(def: NodeDef, at: { x: number; y: number }): void {
|
||||
if (!this.assetId) return;
|
||||
const command = new AddNodeCommand(this.assetId, def.type, Math.round(at.x), Math.round(at.y));
|
||||
if (this.state.execute(command))
|
||||
this.state.select({ kind: 'node', assetId: this.assetId, nodeId: command.node.id });
|
||||
if (this.state.execute(command)) this.state.select(nodeSelection(this.assetId, [command.node.id]));
|
||||
}
|
||||
|
||||
/** Adding and wiring go in as one command, so a single undo takes the whole gesture back. */
|
||||
@@ -421,18 +514,21 @@ export class GraphCanvas extends Widget {
|
||||
),
|
||||
);
|
||||
if (this.state.execute(new CompoundCommand(add.label, commands)))
|
||||
this.state.select({ kind: 'node', assetId: this.assetId, nodeId: add.node.id });
|
||||
this.state.select(nodeSelection(this.assetId, [add.node.id]));
|
||||
}
|
||||
|
||||
private openNodeMenu(view: NodeView, globalX: number, globalY: number): void {
|
||||
const assetId = this.assetId;
|
||||
if (!assetId) return;
|
||||
this.state.select({ kind: 'node', assetId, nodeId: view.node.id });
|
||||
// Right-clicking inside a multi-selection acts on all of it; outside it, on that node alone.
|
||||
if (!this.state.selectedNodeIds.includes(view.node.id))
|
||||
this.state.select(nodeSelection(assetId, [view.node.id]));
|
||||
const ids = this.state.selectedNodeIds;
|
||||
new Menu([
|
||||
{
|
||||
label: 'Delete node',
|
||||
label: ids.length > 1 ? `Delete ${ids.length} nodes` : 'Delete node',
|
||||
danger: true,
|
||||
onSelect: () => this.state.execute(new RemoveNodeCommand(assetId, view.node.id)),
|
||||
onSelect: () => removeNodes(this.state, assetId, ids),
|
||||
},
|
||||
]).open(globalX, globalY);
|
||||
}
|
||||
@@ -488,10 +584,10 @@ export class GraphCanvas extends Widget {
|
||||
const t = map?.get(id);
|
||||
return t !== undefined && now - t <= TRACE_FADE;
|
||||
};
|
||||
const selectedNode = this.state.selection?.kind === 'node' ? this.state.selection.nodeId : null;
|
||||
const selectedNodes = new Set(this.state.selectedNodeIds);
|
||||
const target = this.dropTarget;
|
||||
for (const view of this.views.values()) {
|
||||
view.selected = view.node.id === selectedNode;
|
||||
view.selected = selectedNodes.has(view.node.id);
|
||||
view.hot = isHot(trace?.nodes, view.node.id);
|
||||
view.targetPort =
|
||||
target && target.nodeId === view.node.id ? { port: target.port, isOutput: target.isOutput } : null;
|
||||
@@ -517,6 +613,17 @@ export class GraphCanvas extends Widget {
|
||||
this.tempEdgeShown = true;
|
||||
}
|
||||
}
|
||||
|
||||
this.band.clear();
|
||||
const m = this.marquee;
|
||||
if (m?.active) {
|
||||
const a = this.graphToScreen(m.start.x, m.start.y);
|
||||
const b = this.graphToScreen(m.current.x, m.current.y);
|
||||
this.band
|
||||
.rect(Math.min(a.x, b.x), Math.min(a.y, b.y), Math.abs(b.x - a.x), Math.abs(b.y - a.y))
|
||||
.fill({ color: c.marquee, alpha: 0.12 })
|
||||
.stroke({ width: 1, color: c.marquee });
|
||||
}
|
||||
}
|
||||
|
||||
/** A port's centre in graph coordinates. */
|
||||
@@ -580,6 +687,31 @@ export class GraphCanvas extends Widget {
|
||||
return this.tempEdgeShown;
|
||||
}
|
||||
|
||||
/** A node's drawn rectangle on screen, and the canvas' own – "fit" is a claim about both. */
|
||||
nodeRect(nodeId: string): { x: number; y: number; w: number; h: number } | null {
|
||||
const view = this.views.get(nodeId);
|
||||
if (!view) return null;
|
||||
const topLeft = view.toGlobal({ x: 0, y: 0 });
|
||||
const bottomRight = view.toGlobal({ x: view.w, y: view.h });
|
||||
return {
|
||||
x: topLeft.x,
|
||||
y: topLeft.y,
|
||||
w: bottomRight.x - topLeft.x,
|
||||
h: bottomRight.y - topLeft.y,
|
||||
};
|
||||
}
|
||||
|
||||
canvasRect(): { x: number; y: number; w: number; h: number } {
|
||||
const topLeft = this.toGlobal({ x: 0, y: 0 });
|
||||
const bottomRight = this.toGlobal({ x: this.w, y: this.h });
|
||||
return {
|
||||
x: topLeft.x,
|
||||
y: topLeft.y,
|
||||
w: bottomRight.x - topLeft.x,
|
||||
h: bottomRight.y - topLeft.y,
|
||||
};
|
||||
}
|
||||
|
||||
/** Global screen position of a node's title bar centre. */
|
||||
nodeGlobal(nodeId: string): { x: number; y: number } | null {
|
||||
const view = this.views.get(nodeId);
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Geometry of the graph view: which nodes a rubber band caught, and what view frames them all.
|
||||
* Both are decisions about rectangles, so they live here rather than in the canvas – the "fit"
|
||||
* that left nodes hanging out of the view was a sum that no test could reach.
|
||||
*/
|
||||
|
||||
export interface Box {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface Band {
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
}
|
||||
|
||||
export interface Framing {
|
||||
zoom: number;
|
||||
panX: number;
|
||||
panY: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ids of every box the band overlaps. Nodes are large rectangles, so – unlike the scene view,
|
||||
* which tests an object's position – touching counts: a band drawn across a node that visibly
|
||||
* caught it would otherwise leave it out.
|
||||
*/
|
||||
export function boxesInBand(boxes: readonly Box[], band: Band): string[] {
|
||||
const minX = Math.min(band.x1, band.x2);
|
||||
const maxX = Math.max(band.x1, band.x2);
|
||||
const minY = Math.min(band.y1, band.y2);
|
||||
const maxY = Math.max(band.y1, band.y2);
|
||||
return boxes
|
||||
.filter((b) => b.x <= maxX && b.x + b.w >= minX && b.y <= maxY && b.y + b.h >= minY)
|
||||
.map((b) => b.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* The view that shows every box at once, centred, with `margin` screen pixels to spare.
|
||||
* Never zooms past 1: a two-node graph blown up to fill the screen helps nobody.
|
||||
*/
|
||||
export function frameBoxes(
|
||||
boxes: readonly Box[],
|
||||
view: { w: number; h: number },
|
||||
margin: number,
|
||||
limits: { min: number; max: number },
|
||||
): Framing {
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
for (const b of boxes) {
|
||||
minX = Math.min(minX, b.x);
|
||||
minY = Math.min(minY, b.y);
|
||||
maxX = Math.max(maxX, b.x + b.w);
|
||||
maxY = Math.max(maxY, b.y + b.h);
|
||||
}
|
||||
if (!Number.isFinite(minX)) return { zoom: 1, panX: view.w / 2, panY: view.h / 2 };
|
||||
const width = Math.max(maxX - minX, 1);
|
||||
const height = Math.max(maxY - minY, 1);
|
||||
const fitted = Math.min((view.w - margin * 2) / width, (view.h - margin * 2) / height, 1);
|
||||
const zoom = Math.min(Math.max(fitted, limits.min), limits.max);
|
||||
return {
|
||||
zoom,
|
||||
panX: view.w / 2 - ((minX + maxX) / 2) * zoom,
|
||||
panY: view.h / 2 - ((minY + maxY) / 2) * zoom,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { RemoveFrameCommand } from '../core/commands/frameCommands';
|
||||
import { RemoveNodeCommand } from '../core/commands/graphCommands';
|
||||
import { ui } from '../ui';
|
||||
import { confirmDeleteAsset, createContainer, removeObjects } from './actions';
|
||||
import { confirmDeleteAsset, createContainer, removeNodes, removeObjects } from './actions';
|
||||
import type { EditorState } from './state';
|
||||
|
||||
export interface ShortcutActions {
|
||||
@@ -38,7 +37,7 @@ export function installShortcuts(state: EditorState, actions: ShortcutActions):
|
||||
if (event.key === 'Delete' || event.key === 'Backspace') {
|
||||
const s = state.selection;
|
||||
if (s?.kind === 'object') removeObjects(state, s.holderId, s.objectIds);
|
||||
else if (s?.kind === 'node') state.execute(new RemoveNodeCommand(s.assetId, s.nodeId));
|
||||
else if (s?.kind === 'node') removeNodes(state, s.assetId, s.nodeIds);
|
||||
else if (s?.kind === 'frame') state.execute(new RemoveFrameCommand(s.assetId, s.frameId));
|
||||
else if (s?.kind === 'asset') confirmDeleteAsset(state, s.assetId);
|
||||
return s !== null;
|
||||
|
||||
+46
-8
@@ -20,18 +20,28 @@ export type Selection =
|
||||
* holds the whole selection, primary included.
|
||||
*/
|
||||
| { kind: 'object'; holderId: string; objectId: string; objectIds: string[] }
|
||||
/** A node inside a behavior's graph. */
|
||||
| { kind: 'node'; assetId: string; nodeId: string }
|
||||
/** One or more nodes of a behavior's graph, `nodeId` being the primary one (as with objects). */
|
||||
| { kind: 'node'; assetId: string; nodeId: string; nodeIds: string[] }
|
||||
/** A frame rectangle inside a texture asset (the Texture tab). */
|
||||
| { kind: 'frame'; assetId: string; frameId: string }
|
||||
| null;
|
||||
|
||||
/** Builds an object selection; the last id is the primary one unless given explicitly. */
|
||||
export function objectSelection(holderId: string, objectIds: readonly string[], primary?: string): Selection {
|
||||
const ids = [...new Set(objectIds)];
|
||||
const picked = pickIds(objectIds, primary);
|
||||
return picked && { kind: 'object', holderId, objectId: picked.primary, objectIds: picked.ids };
|
||||
}
|
||||
|
||||
/** Builds a node selection; the last id is the primary one unless given explicitly. */
|
||||
export function nodeSelection(assetId: string, nodeIds: readonly string[], primary?: string): Selection {
|
||||
const picked = pickIds(nodeIds, primary);
|
||||
return picked && { kind: 'node', assetId, nodeId: picked.primary, nodeIds: picked.ids };
|
||||
}
|
||||
|
||||
function pickIds(input: readonly string[], primary?: string): { ids: string[]; primary: string } | null {
|
||||
const ids = [...new Set(input)];
|
||||
if (ids.length === 0) return null;
|
||||
const objectId = primary && ids.includes(primary) ? primary : ids[ids.length - 1]!;
|
||||
return { kind: 'object', holderId, objectId, objectIds: ids };
|
||||
return { ids, primary: primary && ids.includes(primary) ? primary : ids[ids.length - 1]! };
|
||||
}
|
||||
|
||||
export type EditorMode = 'edit' | 'play';
|
||||
@@ -206,6 +216,9 @@ export class EditorState {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (selection?.kind === 'object' && !selection.objectIds)
|
||||
selection = objectSelection(selection.holderId, [selection.objectId]);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (selection?.kind === 'node' && !selection.nodeIds)
|
||||
selection = nodeSelection(selection.assetId, [selection.nodeId]);
|
||||
if (sameSelection(this.selection, selection)) return;
|
||||
this.selection = selection;
|
||||
this.emit('selection');
|
||||
@@ -221,11 +234,26 @@ export class EditorState {
|
||||
this.select(objectSelection(holderId, next, current.includes(objectId) ? undefined : objectId));
|
||||
}
|
||||
|
||||
/** Ctrl/Shift-click: adds the node to the selection or removes it again. */
|
||||
toggleNode(assetId: string, nodeId: string): void {
|
||||
const s = this.selection;
|
||||
const current = s?.kind === 'node' && s.assetId === assetId ? s.nodeIds : [];
|
||||
const held = current.includes(nodeId);
|
||||
const next = held ? current.filter((id) => id !== nodeId) : [...current, nodeId];
|
||||
// Emptied by the toggle, the behavior itself stays selected – the graph must not close.
|
||||
this.select(nodeSelection(assetId, next, held ? undefined : nodeId) ?? { kind: 'asset', assetId });
|
||||
}
|
||||
|
||||
/** Ids of the selected scene objects (empty unless objects are selected). */
|
||||
get selectedObjectIds(): string[] {
|
||||
return this.selection?.kind === 'object' ? this.selection.objectIds : [];
|
||||
}
|
||||
|
||||
/** Ids of the selected graph nodes (empty unless nodes are selected). */
|
||||
get selectedNodeIds(): string[] {
|
||||
return this.selection?.kind === 'node' ? this.selection.nodeIds : [];
|
||||
}
|
||||
|
||||
/** Drops selected targets that no longer exist (after delete/undo). */
|
||||
private validateSelection(): void {
|
||||
const s = this.selection;
|
||||
@@ -241,8 +269,11 @@ export class EditorState {
|
||||
const asset = findAsset(this.project, s.assetId);
|
||||
// Fall back to the behavior asset so the graph stays open when its node vanished (undo).
|
||||
if (!asset || asset.type !== 'behavior') this.select(null);
|
||||
else if (!asset.graph.nodes.some((n) => n.id === s.nodeId))
|
||||
this.select({ kind: 'asset', assetId: s.assetId });
|
||||
else {
|
||||
const alive = s.nodeIds.filter((id) => asset.graph.nodes.some((n) => n.id === id));
|
||||
if (alive.length !== s.nodeIds.length)
|
||||
this.select(nodeSelection(s.assetId, alive, s.nodeId) ?? { kind: 'asset', assetId: s.assetId });
|
||||
}
|
||||
}
|
||||
if (s.kind === 'frame') {
|
||||
const asset = findAsset(this.project, s.assetId);
|
||||
@@ -321,7 +352,14 @@ export function sameSelection(a: Selection, b: Selection): boolean {
|
||||
a.objectIds.every((id, i) => id === b.objectIds[i])
|
||||
);
|
||||
}
|
||||
if (a.kind === 'node' && b.kind === 'node') return a.assetId === b.assetId && a.nodeId === b.nodeId;
|
||||
if (a.kind === 'node' && b.kind === 'node') {
|
||||
return (
|
||||
a.assetId === b.assetId &&
|
||||
a.nodeId === b.nodeId &&
|
||||
a.nodeIds.length === b.nodeIds.length &&
|
||||
a.nodeIds.every((id, i) => id === b.nodeIds[i])
|
||||
);
|
||||
}
|
||||
if (a.kind === 'frame' && b.kind === 'frame') return a.assetId === b.assetId && a.frameId === b.frameId;
|
||||
return false;
|
||||
}
|
||||
@@ -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]]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
1. panning per linksklick drag soll raus, dafür beim mittelklick (aber egal ob man ins leere klick oder sich über einem node befindet) = wie bei scene view
|
||||
2. mehrere nodes gleichzeitig auswählbar um dadurch mehrere zu bewegen oder zu löschen. auch mit rect-auswahl selektierbar = auch wie bei scene view
|
||||
3. was anderes: wenn ich in der graph view auf "fit" klicke, dann fittet es nicht wirklich (es befinden sich nodes halb außerhalb)
|
||||
|
||||
---
|
||||
|
||||
**Umgesetzt (2026-09-09), alle drei Punkte.**
|
||||
|
||||
1. **Panning nur noch mit der mittleren Taste** — und zwar von ueberall, auch wenn der Zeiger
|
||||
ueber einem Node steht: der NodeView laesst den Mittelklick bewusst durchblubbern, statt ihn
|
||||
wie jeden anderen Klick zu schlucken. Shift+Links auf leerer Flaeche pannt zusaetzlich, genau
|
||||
wie im Scene-View. Die linke Taste pannt gar nicht mehr.
|
||||
2. **Mehrfachauswahl**: Ziehen auf leerer Flaeche spannt ein Gummiband auf, das jeden **beruehrten**
|
||||
Node auswaehlt; Ctrl/Shift-Klick nimmt einzelne dazu oder raus; das Ziehen eines Nodes der
|
||||
Auswahl bewegt alle; `Delete` (und das Kontextmenue) loescht alle — jeweils als **ein**
|
||||
Undo-Schritt (`CompoundCommand`).
|
||||
Bewusster Unterschied zum Scene-View: dort entscheidet die *Position* eines Objekts, hier die
|
||||
*Ueberschneidung* mit dem Rechteck. Nodes sind grosse Kaesten — ein Band, das sichtbar quer
|
||||
durch einen Node geht, muss ihn fangen.
|
||||
3. **Fit fittet wirklich.** Die alte Fassung setzte den Zoom hart auf `1` und *zentrierte* nur —
|
||||
war der Graph groesser als die Ansicht, hingen Nodes danach zwangslaeufig heraus. Ausserdem
|
||||
rechnete sie mit einer *geschaetzten* Node-Hoehe (80) fuer alle, obwohl echte Nodes je nach
|
||||
Portzahl deutlich hoeher sind. Jetzt wird jeder Node mit seiner **gezeichneten** Groesse
|
||||
gemessen und der Zoom so gewaehlt, dass alles hineinpasst (nie ueber 1 hinaus vergroessert).
|
||||
|
||||
Die Geometrie dahinter liegt als reine Funktion in `src/editor/graph/framing.ts`
|
||||
(`boxesInBand`, `frameBoxes`), nicht im Panel — genau diese Rechnung war der Bug aus Punkt 3 und
|
||||
war vorher von keinem Test erreichbar. Die Mehrfachauswahl selbst sitzt im Selektionsmodell
|
||||
(`nodeSelection`/`toggleNode`, symmetrisch zu den Szenenobjekten), nicht im Canvas.
|
||||
@@ -326,5 +326,142 @@ export async function graph(t) {
|
||||
'graph editor rebinds when a behavior is selected again',
|
||||
(await evaluate(`__gm.graph.current()`)) === 'mover',
|
||||
);
|
||||
|
||||
// 13i. Mouse behaviour, as in the scene view: the middle button pans from anywhere – a node
|
||||
// under it included – and the left button no longer pans at all.
|
||||
await clickText('Fit');
|
||||
await sleep(250);
|
||||
const positions = async () =>
|
||||
(await asset('mover')).graph.nodes.map((n) => `${n.id}:${n.x},${n.y}`).join(' ');
|
||||
const area = await json('__gm.graph.canvasRect()');
|
||||
const before13i = await positions();
|
||||
const axisScreenBefore = await json(`__gm.graph.nodeAt('n-axis')`);
|
||||
await drag(axisScreenBefore.x, axisScreenBefore.y, axisScreenBefore.x + 120, axisScreenBefore.y + 60, {
|
||||
button: 'middle',
|
||||
});
|
||||
const axisScreenPanned = await json(`__gm.graph.nodeAt('n-axis')`);
|
||||
check(
|
||||
'the middle button pans even when it starts on a node',
|
||||
Math.abs(axisScreenPanned.x - axisScreenBefore.x - 120) < 3 &&
|
||||
Math.abs(axisScreenPanned.y - axisScreenBefore.y - 60) < 3,
|
||||
JSON.stringify({ axisScreenBefore, axisScreenPanned }),
|
||||
);
|
||||
check(
|
||||
'panning over a node leaves the node where it was',
|
||||
(await positions()) === before13i,
|
||||
await positions(),
|
||||
);
|
||||
|
||||
// An empty spot: after a fit every node sits at least a margin away from the corner.
|
||||
const corner = { x: area.x + 6, y: area.y + 6 };
|
||||
const opposite = { x: area.x + area.w - 6, y: area.y + area.h - 6 };
|
||||
const axisBeforeBand = await json(`__gm.graph.nodeAt('n-axis')`);
|
||||
await drag(corner.x, corner.y, corner.x + 60, corner.y + 40);
|
||||
const axisAfterBand = await json(`__gm.graph.nodeAt('n-axis')`);
|
||||
check(
|
||||
'dragging with the left button no longer pans',
|
||||
Math.abs(axisAfterBand.x - axisBeforeBand.x) < 3 && Math.abs(axisAfterBand.y - axisBeforeBand.y) < 3,
|
||||
JSON.stringify({ axisBeforeBand, axisAfterBand }),
|
||||
);
|
||||
|
||||
// 13j. A rubber band across the whole canvas takes every node – the count comes from the model,
|
||||
// so this cannot agree with the canvas by sharing its rule.
|
||||
const nodeCount = (await asset('mover')).graph.nodes.length;
|
||||
await drag(corner.x, corner.y, opposite.x, opposite.y, { steps: 16 });
|
||||
const banded = await json('__gm.graph.selected()');
|
||||
check(
|
||||
'a rubber band selects every node it spans',
|
||||
banded.length === nodeCount,
|
||||
`${banded.length} of ${nodeCount}`,
|
||||
);
|
||||
await shot('21-graph-multiselect');
|
||||
|
||||
// 13k. The whole selection moves as one, and one undo takes all of it back.
|
||||
const beforeMove = (await asset('mover')).graph.nodes.map((n) => ({ id: n.id, x: n.x, y: n.y }));
|
||||
const grabAt = await json(`__gm.graph.nodeAt('n-axis')`);
|
||||
await drag(grabAt.x, grabAt.y, grabAt.x + 40, grabAt.y + 30);
|
||||
const afterMove = await asset('mover');
|
||||
const movedTogether = beforeMove.every((n) => {
|
||||
const now = afterMove.graph.nodes.find((m) => m.id === n.id);
|
||||
return now.x === n.x + 40 && now.y === n.y + 30;
|
||||
});
|
||||
check('dragging one of them moves the whole selection', movedTogether, await positions());
|
||||
await undo();
|
||||
check('one undo takes the group move back', (await positions()) === before13i, await positions());
|
||||
|
||||
// 13l. Delete acts on the selection, not just the primary node.
|
||||
await drag(corner.x, corner.y, opposite.x, opposite.y, { steps: 16 });
|
||||
const beforeDelete = await graphCounts();
|
||||
await press('Delete');
|
||||
await sleep(200);
|
||||
const afterDelete = await graphCounts();
|
||||
check(
|
||||
'Delete removes every selected node, edges with them',
|
||||
afterDelete.nodes === 0 && afterDelete.edges === 0,
|
||||
JSON.stringify(afterDelete),
|
||||
);
|
||||
await undo();
|
||||
const restoredAll = await graphCounts();
|
||||
check(
|
||||
'one undo brings the whole graph back',
|
||||
restoredAll.nodes === beforeDelete.nodes && restoredAll.edges === beforeDelete.edges,
|
||||
JSON.stringify(restoredAll),
|
||||
);
|
||||
|
||||
// 13m. Ctrl-click adds and removes single nodes. Fit first, so every node is really on screen
|
||||
// and a click cannot miss one that the earlier pan pushed over the edge.
|
||||
await select({ kind: 'asset', assetId: 'mover' });
|
||||
await clickText('Fit');
|
||||
await sleep(250);
|
||||
const axisRow = await json(`__gm.graph.nodeAt('n-axis')`);
|
||||
await click(axisRow.x, axisRow.y);
|
||||
const second = (await asset('mover')).graph.nodes.find((n) => n.id !== 'n-axis');
|
||||
const secondAt = await json(`__gm.graph.nodeAt(${JSON.stringify(second.id)})`);
|
||||
await click(secondAt.x, secondAt.y, 'left', { ctrl: true });
|
||||
check(
|
||||
'ctrl-click adds a second node to the selection',
|
||||
JSON.stringify(await json('__gm.graph.selected()')) === JSON.stringify(['n-axis', second.id]),
|
||||
JSON.stringify(await json('__gm.graph.selected()')),
|
||||
);
|
||||
await click(secondAt.x, secondAt.y, 'left', { ctrl: true });
|
||||
check(
|
||||
'ctrl-clicking it again removes it',
|
||||
JSON.stringify(await json('__gm.graph.selected()')) === JSON.stringify(['n-axis']),
|
||||
JSON.stringify(await json('__gm.graph.selected()')),
|
||||
);
|
||||
|
||||
// 13n. Fit really fits: proven against a node parked far outside, and against the fact that the
|
||||
// graph is then wider than the canvas – otherwise centring alone would pass this check.
|
||||
await evaluate(
|
||||
`(() => { const n = __gm.state.project.assets.find(a => a.id === 'mover').graph.nodes.find(n => n.id === 'n-axis'); n.x += 2500; __gm.state.preview(); })()`,
|
||||
);
|
||||
await sleep(250);
|
||||
await clickText('Fit');
|
||||
await sleep(300);
|
||||
const rects = [];
|
||||
for (const node of (await asset('mover')).graph.nodes)
|
||||
rects.push(await json(`__gm.graph.nodeRect(${JSON.stringify(node.id)})`));
|
||||
const span = Math.max(...rects.map((r) => r.x + r.w)) - Math.min(...rects.map((r) => r.x));
|
||||
check(
|
||||
'the parked node makes the graph wider than the canvas, so fitting has to zoom out',
|
||||
span > area.w * 0.5,
|
||||
`span=${Math.round(span)} canvas=${Math.round(area.w)}`,
|
||||
);
|
||||
const outside = rects.filter(
|
||||
(r) => r.x < area.x || r.y < area.y || r.x + r.w > area.x + area.w || r.y + r.h > area.y + area.h,
|
||||
);
|
||||
check(
|
||||
'Fit leaves no node hanging out of the view',
|
||||
outside.length === 0,
|
||||
`${outside.length} outside: ${JSON.stringify(outside.slice(0, 2))}`,
|
||||
);
|
||||
await shot('22-graph-fitted');
|
||||
await evaluate(
|
||||
`(() => { const n = __gm.state.project.assets.find(a => a.id === 'mover').graph.nodes.find(n => n.id === 'n-axis'); n.x -= 2500; __gm.state.preview(); })()`,
|
||||
);
|
||||
await clickText('Fit');
|
||||
await sleep(250);
|
||||
check('the parked node is back where it started', (await positions()) === before13i, await positions());
|
||||
|
||||
await evaluate(`__gm.showTab('scene')`);
|
||||
}
|
||||
+17
-6
@@ -174,16 +174,27 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) {
|
||||
const p = await locate(text);
|
||||
await click(p.x, p.y, button);
|
||||
};
|
||||
const drag = async (x1, y1, x2, y2, steps = 12) => {
|
||||
/** CDP reports held buttons as a bitmask, which is not the button name it takes elsewhere. */
|
||||
const BUTTON_MASK = { left: 1, right: 2, middle: 4 };
|
||||
/**
|
||||
* Drags with the left button unless told otherwise. The fifth argument is either a step count
|
||||
* (its long-standing meaning) or `{ steps, button, modifiers }`.
|
||||
*/
|
||||
const drag = async (x1, y1, x2, y2, options = {}) => {
|
||||
const {
|
||||
steps = 12,
|
||||
button = 'left',
|
||||
modifiers = {},
|
||||
} = typeof options === 'number' ? { steps: options } : options;
|
||||
const extra = { modifiers: modifierBits(modifiers) };
|
||||
const held = { ...extra, buttons: BUTTON_MASK[button] };
|
||||
await mouse('mouseMoved', x1, y1);
|
||||
await mouse('mousePressed', x1, y1);
|
||||
await mouse('mousePressed', x1, y1, button, extra);
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
await mouse('mouseMoved', x1 + ((x2 - x1) * i) / steps, y1 + ((y2 - y1) * i) / steps, 'left', {
|
||||
buttons: 1,
|
||||
});
|
||||
await mouse('mouseMoved', x1 + ((x2 - x1) * i) / steps, y1 + ((y2 - y1) * i) / steps, button, held);
|
||||
await sleep(16);
|
||||
}
|
||||
await mouse('mouseReleased', x2, y2);
|
||||
await mouse('mouseReleased', x2, y2, button, extra);
|
||||
await sleep(150);
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user