Put new graph nodes, comments and groups on the grid (#439)
With the magnet on, only boxes dragged by hand landed on the grid. Anything new stayed where it was dropped and had to be nudged. - A node from the palette, from a dropped link or from a dragged behavior snaps its corner to the nearest grid point. All three ways go through newNode. - A comment or group from the palette snaps corner and size to whole grid steps and keeps its minimum size. A new group is now 312 x 192, a new comment 216 x 96. - "Group these N nodes" moves every edge outward to the next grid line, so the margin around the nodes only grows. - The two helpers snapNewBox and snapBoxOutward live in framing.ts. - Without the magnet nothing changes. Tests: - graph-framing 36/36, 7 new. Two broken variants (rounding instead of outward, missing minimum size) both red. - e2e 36 ALL PASSED, 21 checks, new 36f3. Against the old build exactly the two new checks were red. - Gate on atx: 339 files, 5261 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
beb95c17a7
commit
dc6ab17df9
4 files changed
+185
-7
No files matched your search
@@ -55,6 +55,47 @@ export function resizeSize(
|
||||
};
|
||||
}
|
||||
|
||||
/** A point put on the nearest grid point – where a node made with snapping on lands. */
|
||||
export function snapPoint(p: Point, step: number): Point {
|
||||
if (!(step > 0)) return { x: Math.round(p.x), y: Math.round(p.y) };
|
||||
return { x: snapToStep(p.x, step), y: snapToStep(p.y, step) };
|
||||
}
|
||||
|
||||
/**
|
||||
* A group or comment just made, with snapping on: its corner on the nearest grid point, its size
|
||||
* rounded to whole steps but never under `min`, so the far corner lands on the grid as well.
|
||||
*/
|
||||
export function snapNewBox(
|
||||
box: Omit<Box, 'id'>,
|
||||
step: number,
|
||||
min: { w: number; h: number },
|
||||
): Omit<Box, 'id'> {
|
||||
if (!(step > 0)) return box;
|
||||
const { x, y } = snapPoint(box, step);
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
w: growToMin(Math.max(step, snapToStep(box.w, step)), min.w, step),
|
||||
h: growToMin(Math.max(step, snapToStep(box.h, step)), min.h, step),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A box drawn around things, put on the grid by growing it: each edge moves out to the next grid
|
||||
* line, so it never comes closer to what it holds than it did.
|
||||
*/
|
||||
export function snapBoxOutward(box: Omit<Box, 'id'>, step: number): Omit<Box, 'id'> {
|
||||
if (!(step > 0)) return box;
|
||||
const x = Math.floor(box.x / step) * step;
|
||||
const y = Math.floor(box.y / step) * step;
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
w: Math.ceil((box.x + box.w) / step) * step - x,
|
||||
h: Math.ceil((box.y + box.h) / step) * step - y,
|
||||
};
|
||||
}
|
||||
|
||||
export interface Band {
|
||||
x1: number;
|
||||
y1: number;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
edgesOutOf,
|
||||
FUNCTION_PARAM,
|
||||
ghostProblem,
|
||||
MIN_ANNOTATION_SIZE,
|
||||
type Graph,
|
||||
type GraphNode,
|
||||
type PortDef,
|
||||
@@ -67,7 +68,16 @@ import {
|
||||
nodeSelection,
|
||||
} from '../state';
|
||||
import { canPasteGraph, copySelection, pasteGraph } from '../clipboard';
|
||||
import { type Box, boxesInBand, boxesWithinBand, frameBoxes, revealBoxes } from '../framing';
|
||||
import {
|
||||
type Box,
|
||||
boxesInBand,
|
||||
boxesWithinBand,
|
||||
frameBoxes,
|
||||
revealBoxes,
|
||||
snapBoxOutward,
|
||||
snapNewBox,
|
||||
snapPoint,
|
||||
} from '../framing';
|
||||
import {
|
||||
ANNOTATION_TITLE_HEIGHT,
|
||||
annotationBoxes,
|
||||
@@ -87,7 +97,7 @@ import { splitEntries } from './splitMenu';
|
||||
import { type PaletteEntry, paletteEntries } from './paletteEntries';
|
||||
import { NODE_WIDTH, NodeView, TITLE_HEIGHT } from './NodeView';
|
||||
import type { NodeFace } from './nodeFace';
|
||||
import { drawGraphGrid, FIT_MARGIN, inView, ZOOM_LIMITS, zoomedAbout } from './graphView';
|
||||
import { drawGraphGrid, FIT_MARGIN, GRID_STEP, inView, ZOOM_LIMITS, zoomedAbout } from './graphView';
|
||||
import { GRAPH_SNAPS_AT_FIRST } from './SnapToggle';
|
||||
import { arrangeCommand } from './graphLayout';
|
||||
import { measuredNodes } from './measureNodes';
|
||||
@@ -466,11 +476,25 @@ export class GraphCanvas extends Widget {
|
||||
if (command) this.state.execute(command);
|
||||
}
|
||||
|
||||
/** A group drawn around the selected nodes, with room for the title bar above them. */
|
||||
/**
|
||||
* A group drawn around the selected nodes, with room for the title bar above them. With snapping
|
||||
* on its edges move out to the grid, so the room around the nodes only grows.
|
||||
*/
|
||||
groupSelectedNodes(): void {
|
||||
const ids = new Set(this.state.selectedNodeIds);
|
||||
const box = groupBoxAround(this.nodeBoxes().filter((b) => ids.has(b.id)));
|
||||
if (box) this.addAnnotation('group', box, 'Group');
|
||||
if (box) this.addAnnotation('group', snapBoxOutward(box, this.gridStep()), 'Group');
|
||||
}
|
||||
|
||||
/** The grid a new node or box lands on: none with snapping off. */
|
||||
private gridStep(): number {
|
||||
return this.snapping ? GRID_STEP : 0;
|
||||
}
|
||||
|
||||
/** A group or comment from the palette: where it was opened, on the grid with snapping on. */
|
||||
private addNewAnnotation(kind: AnnotationKind, at: { x: number; y: number }, text: string): void {
|
||||
const size = kind === 'group' ? NEW_GROUP_SIZE : NEW_NOTE_SIZE;
|
||||
this.addAnnotation(kind, snapNewBox({ ...at, ...size }, this.gridStep(), MIN_ANNOTATION_SIZE), text);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -992,14 +1016,14 @@ export class GraphCanvas extends Widget {
|
||||
label: 'Add group',
|
||||
hint: 'box',
|
||||
match: 'group box frame',
|
||||
onSelect: () => this.addAnnotation('group', { ...at, ...NEW_GROUP_SIZE }, 'Group'),
|
||||
onSelect: () => this.addNewAnnotation('group', at, 'Group'),
|
||||
},
|
||||
{
|
||||
id: 'add-comment',
|
||||
label: 'Add comment',
|
||||
hint: 'text',
|
||||
match: 'comment note text',
|
||||
onSelect: () => this.addAnnotation('note', { ...at, ...NEW_NOTE_SIZE }, 'Comment'),
|
||||
onSelect: () => this.addNewAnnotation('note', at, 'Comment'),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1009,8 +1033,10 @@ export class GraphCanvas extends Widget {
|
||||
return asset ? paletteEntries(this.state.project, asset) : [];
|
||||
}
|
||||
|
||||
/** A node at `at`, its corner on the nearest grid point with snapping on – as a dragged one lands. */
|
||||
private newNode(entry: PaletteEntry, at: { x: number; y: number }): AddNodeCommand {
|
||||
return new AddNodeCommand(this.assetId, entry.def.type, Math.round(at.x), Math.round(at.y), entry.params);
|
||||
const { x, y } = snapPoint(at, this.gridStep());
|
||||
return new AddNodeCommand(this.assetId, entry.def.type, x, y, entry.params);
|
||||
}
|
||||
|
||||
private addNode(entry: PaletteEntry, at: { x: number; y: number }): void {
|
||||
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
frameBoxes,
|
||||
resizeSize,
|
||||
revealBoxes,
|
||||
snapBoxOutward,
|
||||
snapNewBox,
|
||||
snapPoint,
|
||||
} from '../../src/editor/framing';
|
||||
|
||||
const LIMITS = { min: 0.25, max: 2.5 };
|
||||
@@ -100,6 +103,54 @@ describe('resize size', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('made on the grid', () => {
|
||||
const STEP = 24;
|
||||
const MIN = { w: 80, h: 48 };
|
||||
|
||||
it('puts a new node on the nearest grid point, and only rounds it without snapping', () => {
|
||||
expect(snapPoint({ x: 37.4, y: 11.6 }, STEP)).toEqual({ x: 48, y: 0 });
|
||||
expect(snapPoint({ x: 37.4, y: 11.6 }, 0)).toEqual({ x: 37, y: 12 });
|
||||
});
|
||||
|
||||
it('puts a new group on the grid, corner and size alike', () => {
|
||||
// The palette's group is 320 × 200: neither is a whole number of steps.
|
||||
expect(snapNewBox({ x: 37, y: 13, w: 320, h: 200 }, STEP, MIN)).toEqual({ x: 48, y: 24, w: 312, h: 192 });
|
||||
});
|
||||
|
||||
it('puts a new comment on the grid', () => {
|
||||
expect(snapNewBox({ x: -13, y: 100, w: 220, h: 96 }, STEP, MIN)).toEqual({
|
||||
x: -24,
|
||||
y: 96,
|
||||
w: 216,
|
||||
h: 96,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a new box at its minimum, in whole steps', () => {
|
||||
expect(snapNewBox({ x: 0, y: 0, w: 70, h: 30 }, STEP, MIN)).toEqual({ x: 0, y: 0, w: 96, h: 48 });
|
||||
});
|
||||
|
||||
it('leaves a new box as it is without snapping', () => {
|
||||
const box = { x: 37, y: 13, w: 320, h: 200 };
|
||||
expect(snapNewBox(box, 0, MIN)).toEqual(box);
|
||||
});
|
||||
|
||||
it('grows a group around nodes out to the grid, so no edge comes closer to them', () => {
|
||||
const around = { x: 28, y: 52, w: 240, h: 144 };
|
||||
const snapped = snapBoxOutward(around, STEP);
|
||||
expect(snapped).toEqual({ x: 24, y: 48, w: 264, h: 168 });
|
||||
expect(snapped.x + snapped.w).toBeGreaterThanOrEqual(around.x + around.w);
|
||||
expect(snapped.y + snapped.h).toBeGreaterThanOrEqual(around.y + around.h);
|
||||
});
|
||||
|
||||
it('leaves a group around nodes alone when it is on the grid already, or snapping is off', () => {
|
||||
const onGrid = { x: -24, y: 48, w: 240, h: 96 };
|
||||
expect(snapBoxOutward(onGrid, STEP)).toEqual(onGrid);
|
||||
const off = { x: 28, y: 52, w: 240, h: 144 };
|
||||
expect(snapBoxOutward(off, 0)).toEqual(off);
|
||||
});
|
||||
});
|
||||
|
||||
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) => ({
|
||||
|
||||
@@ -180,6 +180,66 @@ export async function graphGroups(t) {
|
||||
snapped.w > held.w,
|
||||
JSON.stringify({ x: snapped.x, y: snapped.y, w: snapped.w, h: snapped.h }),
|
||||
);
|
||||
|
||||
// 36f3. With snapping on, what is made lands on the grid straight away: a comment, a group and a
|
||||
// node from the palette, and a group around a selection – size and all. The comment from
|
||||
// 36b moves aside first (36j moves it anyway), so the free spot is free of it too.
|
||||
await mutate(`
|
||||
const g = __gm.state.project.assets.find((a) => a.id === 'gg-logic').graph;
|
||||
Object.assign(g.notes[0], { x: -700, y: -500 });`);
|
||||
const onGrid = (b) => b !== null && [b.x, b.y, b.w ?? 0, b.h ?? 0].every((v) => v % 24 === 0);
|
||||
const pickedBox = async () => {
|
||||
const id = (await json('__gm.state.selectedAnnotationIds ?? []'))[0];
|
||||
return (await boxes()).find((a) => a.id === id) ?? null;
|
||||
};
|
||||
const madeAt = await json('__gm.graph.emptySpot()');
|
||||
await click(madeAt.x, madeAt.y, 'right');
|
||||
await clickText('Add comment');
|
||||
const snappedNote = await pickedBox();
|
||||
await t.undo();
|
||||
await click(madeAt.x, madeAt.y, 'right');
|
||||
await clickText('Add group');
|
||||
const snappedGroup = await pickedBox();
|
||||
await t.undo();
|
||||
await click(madeAt.x, madeAt.y, 'right');
|
||||
await typeText('self');
|
||||
await press('Enter');
|
||||
const madeNode = await nodeAt((await json('__gm.state.selectedNodeIds ?? []'))[0] ?? '');
|
||||
await t.undo();
|
||||
check(
|
||||
'with snapping on a comment, a group and a node from the palette land on the grid, sizes too',
|
||||
onGrid(snappedNote) &&
|
||||
snappedNote.kind === 'note' &&
|
||||
snappedNote.w === 216 &&
|
||||
snappedNote.h === 96 &&
|
||||
onGrid(snappedGroup) &&
|
||||
snappedGroup.kind === 'group' &&
|
||||
snappedGroup.w === 312 &&
|
||||
snappedGroup.h === 192 &&
|
||||
onGrid(madeNode),
|
||||
JSON.stringify({ note: snappedNote, group: snappedGroup, node: madeNode }),
|
||||
);
|
||||
await evaluate(
|
||||
"__gm.state.select({ kind: 'node', assetId: 'gg-logic', nodeIds: ['gg-start', 'gg-self'] })",
|
||||
);
|
||||
const startAgain = await json('__gm.graph.nodeAt("gg-start")');
|
||||
await click(startAgain.x, startAgain.y, 'right');
|
||||
await clickText('Group these 2 nodes');
|
||||
const around = await pickedBox();
|
||||
await t.undo();
|
||||
// `group` is the box 36c drew around the same two nodes with snapping off: the snapped one holds it.
|
||||
check(
|
||||
'with snapping on a group around a selection lands on the grid and only grows its room',
|
||||
onGrid(around) &&
|
||||
around.kind === 'group' &&
|
||||
around.x <= group.x &&
|
||||
around.y <= group.y &&
|
||||
around.x + around.w >= group.x + group.w &&
|
||||
around.y + around.h >= group.y + group.h &&
|
||||
around.x > group.x - 24 &&
|
||||
around.y > group.y - 24,
|
||||
JSON.stringify({ around, unsnapped: { x: group.x, y: group.y, w: group.w, h: group.h } }),
|
||||
);
|
||||
await click(magnet.x, magnet.y);
|
||||
|
||||
// 36g. A freely picked colour, typed as hex into the inspector. The point is the second colour:
|
||||
|
||||
Reference in new issue
Block a user