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

+1
View File
@@ -204,6 +204,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
// drawing area.
center: () => centreOf(graphEditor.canvas),
pickerWidth: () => graphEditor.pickerWidth,
snapping: () => graphEditor.canvas.snapEnabled,
},
/** The open image picker: what it offers, what is picked, and where a tile sits. */
imagePicker: () => currentImagePicker()?.debugInfo() ?? null,
+24 -3
View File
@@ -1,9 +1,30 @@
/**
* 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.
* Geometry of the graph view: which nodes a rubber band caught, what view frames them all, and
* where a drag puts them. All three 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.
*/
import { snapToStep } from '../ui/math';
export interface Point {
x: number;
y: number;
}
/**
* The offset a drag adds to one node that started at `start`. A positive `step` lands that node on
* the grid – applied per node, so a whole selection lines up instead of carrying its old spacing
* along. `step <= 0` is "no snapping". A node nobody drags never moves either way.
*/
export function dragOffset(start: Point, dx: number, dy: number, step: number): Point {
if (!(step > 0)) return { x: Math.round(dx), y: Math.round(dy) };
return {
x: snapToStep(start.x + dx, step) - start.x,
y: snapToStep(start.y + dy, step) - start.y,
};
}
export interface Box {
id: string;
x: number;
+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);
}
}