Graph: optionales Grid-Snapping beim Ziehen von Nodes

Ein Toggle in der Graph-Kopfzeile lässt gezogene Nodes auf dem 24er-Raster
einrasten – derselben Größe, die den Hintergrund zeichnet. Einschalten bewegt
nichts; die Regel greift erst beim nächsten Zug. Bei Mehrfachauswahl richtet
sich jeder Node für sich aus, statt einem gemeinsamen Versatz zu folgen, der
die alten krummen Abstände mitschleppen würde.

Die Entscheidung liegt als reine Funktion in framing.ts, wo die übrige
Graph-Geometrie schon wohnt, und ist dort wie im Browser gepinnt.
This commit is contained in:
root committed 2026-09-10 17:14:37 +02:00
1 parent 10d030376a
commit 4b47741fac
9 files changed
+187 -14

No files matched your search

+16 -3
View File
@@ -60,11 +60,12 @@ import {
foldMarkLabel,
foldMarkRadius,
} from './folding';
import { type Box, boxesInBand, frameBoxes } from '../framing';
import { type Box, boxesInBand, dragOffset, frameBoxes } from '../framing';
import { NodePalette } from './NodePalette';
import { type PaletteEntry, paletteEntries } from './paletteEntries';
import { NODE_WIDTH, NodeView, portColor, TITLE_HEIGHT } from './NodeView';
/** Spacing of the background grid – and, with snapping on, of the positions a drag can leave. */
const GRID_STEP = 24;
/** Below this on-screen spacing the grid is noise rather than orientation, so it is dropped. */
const MIN_GRID_STEP = 8;
@@ -126,6 +127,7 @@ export class GraphCanvas extends Widget {
pointer: { x: number; y: number };
moved: boolean;
} | null = null;
private snapping = false;
/** A rubber-band selection in progress, in graph coordinates. */
private marquee: {
start: { x: number; y: number };
@@ -199,6 +201,15 @@ export class GraphCanvas extends Widget {
this.fitView();
}
/** Takes effect on the next drag only – turning it on must not shuffle the graph. */
setSnapping(on: boolean): void {
this.snapping = on;
}
get snapEnabled(): boolean {
return this.snapping;
}
/** Drops the node in the middle of the view, since a button press names no position. */
addNodeFrom(anchor: Widget): void {
if (!this.assetId || this.state.mode !== 'edit') return;
@@ -416,11 +427,13 @@ export class GraphCanvas extends Widget {
this.dragging.moved = true;
const graph = this.asset()?.graph;
if (!graph) return;
const step = this.snapping ? GRID_STEP : 0;
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);
const offset = dragOffset(item.start, dx, dy, step);
node.x = item.start.x + offset.x;
node.y = item.start.y + offset.y;
}
this.state.preview();
} else if (this.connecting) {
+7 -3
View File
@@ -1,5 +1,5 @@
import type { Asset, BehaviorAsset } from '../../core/model/Asset';
import { Button } from '../../ui';
import { Button, Checkbox, Label } from '../../ui';
import { AssetTabEditor } from '../AssetTabEditor';
import type { EditorState } from '../state';
import { GraphCanvas } from './GraphCanvas';
@@ -9,11 +9,13 @@ const INFO_WIDTH = 150;
export class GraphEditor extends AssetTabEditor<BehaviorAsset, GraphCanvas> {
private readonly addButton: Button;
private readonly snapBox = new Checkbox();
constructor(state: EditorState) {
super(state, { noun: 'Behavior', infoWidth: INFO_WIDTH });
this.addButton = new Button('Add node…', { onClick: () => this.canvas.addNodeFrom(this.addButton) });
this.mount(new GraphCanvas(state), [this.addButton]);
this.snapBox.onChange = (on) => this.canvas.setSnapping(on);
this.mount(new GraphCanvas(state), [this.addButton, new Label('Snap', { style: 'muted' }), this.snapBox]);
// Adding nodes is an edit, and edits made while playing would be thrown away with the run.
state.on('mode', () => this.refresh());
}
@@ -33,6 +35,8 @@ export class GraphEditor extends AssetTabEditor<BehaviorAsset, GraphCanvas> {
}
protected override syncExtras(asset: BehaviorAsset | null): void {
this.addButton.setEnabled(Boolean(asset) && this.state.mode === 'edit');
const editable = Boolean(asset) && this.state.mode === 'edit';
this.addButton.setEnabled(editable);
this.snapBox.setEnabled(editable);
}
}