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:
1 parent
10d030376a
commit
4b47741fac
9 files changed
+187
-14
No files matched your search
@@ -252,6 +252,11 @@ shown on the node and editable in the inspector.
|
||||
**Add node…** (the palette, dropping the node in the middle of the view – the right-click nobody
|
||||
finds), **Fit** (which zooms out far enough to hold every node, measuring each by its own drawn
|
||||
height – `editor/framing.ts`), and the size of the graph as "N nodes · M links".
|
||||
- **Snap** (header checkbox, off by default): a drag drops each node it carries on the nearest
|
||||
24 px grid line – the same grid the background draws. It takes effect on the next drag only, so
|
||||
switching it on never shuffles a graph that is already laid out, and a node nobody drags never
|
||||
moves. Every node of a selection snaps **for itself**, so dragging a group is how you line one
|
||||
up; the offset is a decision about rectangles and lives with the others in `editor/framing.ts`.
|
||||
- **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
|
||||
@@ -599,7 +604,7 @@ npm run check # typecheck + lint + format:check + tests, what CI would ru
|
||||
# code is lost (tail's is reported), so a red run reads as green
|
||||
npm run build # writes dist/ and regenerates index.html
|
||||
export GM_REMOTE=user@host # optional: run the browser elsewhere; see below
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # 483 editor checks, screenshots in /tmp/gm-e2e-*.png
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # 491 editor checks, screenshots in /tmp/gm-e2e-*.png
|
||||
GM_STEPS=20 node tools/e2e.mjs # or 1-12, or 6,20 – a subset while working on one step
|
||||
./e2e.sh start [GM_STEPS] # the same run detached, see below
|
||||
./e2e.sh status # live progress, or the last run's result
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { boundsOf, type Box, boxesInBand, frameBoxes } from '../../src/editor/framing';
|
||||
import { boundsOf, type Box, boxesInBand, dragOffset, frameBoxes } from '../../src/editor/framing';
|
||||
|
||||
const LIMITS = { min: 0.25, max: 2.5 };
|
||||
|
||||
@@ -28,6 +28,39 @@ describe('rubber band', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('drag offset', () => {
|
||||
const STEP = 24;
|
||||
|
||||
it('rounds to whole pixels without snapping', () => {
|
||||
expect(dragOffset({ x: 10, y: 10 }, 12.4, -7.6, 0)).toEqual({ x: 12, y: -8 });
|
||||
});
|
||||
|
||||
it('puts a dragged node on the grid, wherever it started', () => {
|
||||
// Started off-grid at 10: the drop is a multiple of 24, not 10 plus a multiple.
|
||||
const off = dragOffset({ x: 10, y: 10 }, 50, 50, STEP);
|
||||
expect({ x: 10 + off.x, y: 10 + off.y }).toEqual({ x: 72, y: 72 });
|
||||
});
|
||||
|
||||
it('gives two differently placed nodes different offsets, so both land on the grid', () => {
|
||||
// The same gesture over a selection: 10 and 17 are 7 apart and both end up on a grid line.
|
||||
const a = dragOffset({ x: 10, y: 0 }, 50, 0, STEP);
|
||||
const b = dragOffset({ x: 17, y: 0 }, 50, 0, STEP);
|
||||
expect(a.x).not.toBe(b.x);
|
||||
expect(10 + a.x).toBe(72);
|
||||
expect(17 + b.x).toBe(72);
|
||||
});
|
||||
|
||||
it('takes the nearest grid line, in both directions', () => {
|
||||
expect(dragOffset({ x: 0, y: 0 }, 11, 13, STEP)).toEqual({ x: 0, y: 24 });
|
||||
expect(dragOffset({ x: 0, y: 0 }, -11, -13, STEP)).toEqual({ x: 0, y: -24 });
|
||||
});
|
||||
|
||||
it('still moves a node that starts on the grid', () => {
|
||||
// A snapped drag shorter than half a step is the one case that could deadlock into "no move".
|
||||
expect(dragOffset({ x: 48, y: 48 }, 20, 4, STEP)).toEqual({ x: 24, y: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
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) => ({
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
beim graph bräuchte ich einen toggle der grid snapping aktiviert.
|
||||
alle vorhandenen nodes soll da bleiben wo sie sind, erst wenn man ein node bewegt soll es greifen
|
||||
die grid-größe kannst du selbst entscheiden, am besten ein sinnvoller wert
|
||||
|
||||
---
|
||||
|
||||
Gebaut. Zwei Dinge, die weder oben stehen noch aus dem Commit hervorgehen:
|
||||
|
||||
**Die Rastergröße ist 24 px – dieselbe Zahl, die den Hintergrund zeichnet** (`GRID_STEP`). Es gibt
|
||||
also keinen zweiten, unsichtbaren Raster: was einrastet, rastet auf einer Linie ein, die man sieht.
|
||||
Bei starkem Herauszoomen blendet das Raster aus (`MIN_GRID_STEP`), der Snap-Schritt bleibt aber
|
||||
derselbe – er hängt an Weltkoordinaten, nicht am Zoom.
|
||||
|
||||
**Bei Mehrfachauswahl richtet sich jeder Node für sich aus** (Nachtrag des Users während der
|
||||
Arbeit). Die erste Fassung verschob die Auswahl um *einen* gemeinsamen Versatz, was ihre alten,
|
||||
krummen Abstände mitschleppte: nur der Ankerknoten landete auf einer Linie. Jetzt ist `dragOffset`
|
||||
pro Node definiert – jeder bekommt seinen eigenen Versatz, die Auswahl fluchtet danach wirklich.
|
||||
Der Preis ist bewusst: die relativen Abstände innerhalb der Auswahl können sich um bis zu einen
|
||||
halben Schritt ändern.
|
||||
@@ -1,3 +0,0 @@
|
||||
beim graph bräuchte ich einen toggle der grid snapping aktiviert.
|
||||
alle vorhandenen nodes soll da bleiben wo sie sind, erst wenn man ein node bewegt soll es greifen
|
||||
die grid-größe kannst du selbst entscheiden, am besten ein sinnvoller wert
|
||||
@@ -389,6 +389,86 @@ export async function graph(t) {
|
||||
await undo();
|
||||
check('one undo takes the group move back', (await positions()) === before13i, await positions());
|
||||
|
||||
// 13k2. Grid snapping. The graph keeps a zoom of 1 here (13k proved it with an exact +40/+30),
|
||||
// so a screen delta is a graph delta.
|
||||
const GRID = 24;
|
||||
const onGrid = (n) => n.x % GRID === 0 && n.y % GRID === 0;
|
||||
check('snapping is off until it is asked for', (await json('__gm.graph.snapping()')) === false);
|
||||
const snapLabel = await locate('Snap');
|
||||
// The checkbox sits right of the "Snap" label; locate() gives the label's text centre.
|
||||
await click(snapLabel.x + 30, snapLabel.y);
|
||||
await sleep(150);
|
||||
check(
|
||||
'the toggle switches it on without moving a single node',
|
||||
(await json('__gm.graph.snapping()')) === true && (await positions()) === before13i,
|
||||
await positions(),
|
||||
);
|
||||
|
||||
// A single node: dropped between two grid lines, it lands on the nearer one.
|
||||
await click(corner.x, corner.y);
|
||||
await sleep(150);
|
||||
const snapPre = await nodeOf('n-axis');
|
||||
const snapFrom = await json(`__gm.graph.nodeAt('n-axis')`);
|
||||
await drag(snapFrom.x, snapFrom.y, snapFrom.x + 50, snapFrom.y + 50);
|
||||
const snapped = await nodeOf('n-axis');
|
||||
check(
|
||||
'a dragged node lands on the grid, not where the pointer let go',
|
||||
onGrid(snapped) &&
|
||||
Math.abs(snapped.x - (snapPre.x + 50)) <= GRID / 2 &&
|
||||
Math.abs(snapped.y - (snapPre.y + 50)) <= GRID / 2,
|
||||
`${snapPre.x},${snapPre.y} → ${snapped.x},${snapped.y}`,
|
||||
);
|
||||
const others = (await asset('mover')).graph.nodes.filter((n) => n.id !== 'n-axis');
|
||||
check(
|
||||
'the nodes it was dragged past stay off the grid',
|
||||
others.some((n) => !onGrid(n)),
|
||||
JSON.stringify(others.map((n) => `${n.id}:${n.x},${n.y}`)),
|
||||
);
|
||||
|
||||
// A group: every selected node snaps for itself, so the whole selection ends up on grid lines
|
||||
// – off-grid gaps inside it close, rather than travelling along.
|
||||
await drag(corner.x, corner.y, opposite.x, opposite.y, { steps: 16 });
|
||||
const groupPre = (await asset('mover')).graph.nodes.map((n) => ({ id: n.id, x: n.x, y: n.y }));
|
||||
check(
|
||||
'the selection is not on the grid yet',
|
||||
groupPre.some((n) => !onGrid(n)),
|
||||
JSON.stringify(groupPre.map((n) => `${n.id}:${n.x},${n.y}`)),
|
||||
);
|
||||
const grabFrom = await json(`__gm.graph.nodeAt('n-axis')`);
|
||||
await drag(grabFrom.x, grabFrom.y, grabFrom.x + 30, grabFrom.y + 30);
|
||||
const groupPost = (await asset('mover')).graph.nodes;
|
||||
check(
|
||||
'every selected node lines up on the grid for itself, none by more than half a step',
|
||||
groupPost.every(onGrid) &&
|
||||
groupPre.every((n) => {
|
||||
const now = groupPost.find((m) => m.id === n.id);
|
||||
return Math.abs(now.x - (n.x + 30)) <= GRID / 2 && Math.abs(now.y - (n.y + 30)) <= GRID / 2;
|
||||
}),
|
||||
await positions(),
|
||||
);
|
||||
await undo();
|
||||
await undo();
|
||||
check('undo takes both snapped moves back', (await positions()) === before13i, await positions());
|
||||
|
||||
// Switching it off has to give the pointer its exact drop position back.
|
||||
await click(snapLabel.x + 30, snapLabel.y);
|
||||
await sleep(150);
|
||||
await click(corner.x, corner.y);
|
||||
await sleep(150);
|
||||
const offPre = await nodeOf('n-axis');
|
||||
const offFrom = await json(`__gm.graph.nodeAt('n-axis')`);
|
||||
await drag(offFrom.x, offFrom.y, offFrom.x + 50, offFrom.y + 50);
|
||||
const offPost = await nodeOf('n-axis');
|
||||
check(
|
||||
'switched off, a node lands exactly where it was dropped again',
|
||||
(await json('__gm.graph.snapping()')) === false &&
|
||||
offPost.x === offPre.x + 50 &&
|
||||
offPost.y === offPre.y + 50,
|
||||
`${offPre.x},${offPre.y} → ${offPost.x},${offPost.y}`,
|
||||
);
|
||||
await undo();
|
||||
await sleep(150);
|
||||
|
||||
// 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();
|
||||
|
||||
Reference in new issue
Block a user