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 {
|
export interface Band {
|
||||||
x1: number;
|
x1: number;
|
||||||
y1: number;
|
y1: number;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
edgesOutOf,
|
edgesOutOf,
|
||||||
FUNCTION_PARAM,
|
FUNCTION_PARAM,
|
||||||
ghostProblem,
|
ghostProblem,
|
||||||
|
MIN_ANNOTATION_SIZE,
|
||||||
type Graph,
|
type Graph,
|
||||||
type GraphNode,
|
type GraphNode,
|
||||||
type PortDef,
|
type PortDef,
|
||||||
@@ -67,7 +68,16 @@ import {
|
|||||||
nodeSelection,
|
nodeSelection,
|
||||||
} from '../state';
|
} from '../state';
|
||||||
import { canPasteGraph, copySelection, pasteGraph } from '../clipboard';
|
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 {
|
import {
|
||||||
ANNOTATION_TITLE_HEIGHT,
|
ANNOTATION_TITLE_HEIGHT,
|
||||||
annotationBoxes,
|
annotationBoxes,
|
||||||
@@ -87,7 +97,7 @@ import { splitEntries } from './splitMenu';
|
|||||||
import { type PaletteEntry, paletteEntries } from './paletteEntries';
|
import { type PaletteEntry, paletteEntries } from './paletteEntries';
|
||||||
import { NODE_WIDTH, NodeView, TITLE_HEIGHT } from './NodeView';
|
import { NODE_WIDTH, NodeView, TITLE_HEIGHT } from './NodeView';
|
||||||
import type { NodeFace } from './nodeFace';
|
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 { GRAPH_SNAPS_AT_FIRST } from './SnapToggle';
|
||||||
import { arrangeCommand } from './graphLayout';
|
import { arrangeCommand } from './graphLayout';
|
||||||
import { measuredNodes } from './measureNodes';
|
import { measuredNodes } from './measureNodes';
|
||||||
@@ -466,11 +476,25 @@ export class GraphCanvas extends Widget {
|
|||||||
if (command) this.state.execute(command);
|
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 {
|
groupSelectedNodes(): void {
|
||||||
const ids = new Set(this.state.selectedNodeIds);
|
const ids = new Set(this.state.selectedNodeIds);
|
||||||
const box = groupBoxAround(this.nodeBoxes().filter((b) => ids.has(b.id)));
|
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',
|
label: 'Add group',
|
||||||
hint: 'box',
|
hint: 'box',
|
||||||
match: 'group box frame',
|
match: 'group box frame',
|
||||||
onSelect: () => this.addAnnotation('group', { ...at, ...NEW_GROUP_SIZE }, 'Group'),
|
onSelect: () => this.addNewAnnotation('group', at, 'Group'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'add-comment',
|
id: 'add-comment',
|
||||||
label: 'Add comment',
|
label: 'Add comment',
|
||||||
hint: 'text',
|
hint: 'text',
|
||||||
match: 'comment note 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) : [];
|
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 {
|
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 {
|
private addNode(entry: PaletteEntry, at: { x: number; y: number }): void {
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import {
|
|||||||
frameBoxes,
|
frameBoxes,
|
||||||
resizeSize,
|
resizeSize,
|
||||||
revealBoxes,
|
revealBoxes,
|
||||||
|
snapBoxOutward,
|
||||||
|
snapNewBox,
|
||||||
|
snapPoint,
|
||||||
} from '../../src/editor/framing';
|
} from '../../src/editor/framing';
|
||||||
|
|
||||||
const LIMITS = { min: 0.25, max: 2.5 };
|
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', () => {
|
describe('fit', () => {
|
||||||
/** Where a graph point lands on screen under a framing. */
|
/** Where a graph point lands on screen under a framing. */
|
||||||
const toScreen = (f: { zoom: number; panX: number; panY: number }, x: number, y: number) => ({
|
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,
|
snapped.w > held.w,
|
||||||
JSON.stringify({ x: snapped.x, y: snapped.y, w: snapped.w, h: snapped.h }),
|
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);
|
await click(magnet.x, magnet.y);
|
||||||
|
|
||||||
// 36g. A freely picked colour, typed as hex into the inspector. The point is the second colour:
|
// 36g. A freely picked colour, typed as hex into the inspector. The point is the second colour:
|
||||||
|
|||||||
Reference in new issue
Block a user