A box drawn around a part of the graph is part of it, so a fit frames it and a band catches it (#95)
game-maker / suite (push) Successful in 6m22s

Fit measured the nodes alone: a group drawn around them was cut off, and a graph holding nothing
but a comment framed the empty origin. It now frames both sorts together.

The rubber band ignored boxes entirely. It catches them now, into the *same* selection as the
nodes — `nodeIds` and `annotationIds` side by side — so one drag moves all of it, one Delete
removes all of it, and one undo takes it back. A box counts only when the band holds all of it,
where a node counts on touching: a group wraps the nodes it holds and therefore overlaps every
band drawn over any of them, and since it drags those nodes along, catching it by accident would
be felt at once.

A node inside a selected group is moved once, not twice — `travellers()` collects everything one
gesture carries and lets each thing appear in it a single time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-16 14:23:56 +02:00
1 parent be8f2331de
commit 36ccc7626f
9 files changed
+554 -46

No files matched your search

+113 -22
View File
@@ -71,12 +71,13 @@ import {
Widget,
zoomFactor,
} from '../../ui';
import { type EditorState, nodeSelection } from '../state';
import { annotationSelection, type EditorState, graphSelection, nodeSelection } from '../state';
import {
type Box,
boundsOf,
boxesCentredIn,
boxesInBand,
boxesWithinBand,
dragOffset,
frameBoxes,
resizeSize,
@@ -142,6 +143,8 @@ export class GraphCanvas extends Widget {
private panning: { startX: number; startY: number; panX: number; panY: number } | null = null;
private dragging: {
items: { nodeId: string; start: { x: number; y: number } }[];
/** Boxes selected alongside the nodes: a band caught them together, so they travel together. */
boxes: { id: string; kind: AnnotationKind; start: AnnotationBox }[];
pointer: { x: number; y: number };
moved: boolean;
} | null = null;
@@ -368,7 +371,10 @@ export class GraphCanvas extends Widget {
return;
}
const id = view.annotation.id;
this.state.select({ kind: 'annotation', assetId: this.assetId, annotationId: id });
// Grabbing a box that is already part of a wider selection keeps that selection, the way a node
// does: otherwise dragging one of several would silently drop the rest first.
if (!this.state.selectedAnnotationIds.includes(id))
this.state.select(annotationSelection(this.assetId, [id]));
if (e.button === 2) {
openAnnotationMenu(this.state, this.assetId, view.kind, id, e.global.x, e.global.y);
return;
@@ -379,20 +385,71 @@ export class GraphCanvas extends Widget {
const pointer = this.screenToGraph(p.x, p.y);
const start = boxOf(view.annotation);
const resizing = view.handleAt(local.x, local.y);
// A resize carries nothing: membership is re-derived from where the box ends up, so a node the
// corner sweeps over simply belongs to it after.
const along = resizing
? { boxes: [], nodes: [] }
: this.travellers(
[{ id, kind: view.kind, start }, ...this.selectedBoxes(id)],
this.state.selectedNodeIds,
);
this.annotationDrag = {
id,
kind: view.kind,
mode: resizing ? 'resize' : 'move',
start,
// A comment carries nothing, and resizing carries nothing either: membership is re-derived
// from where the box ends up, so a node the corner sweeps over simply belongs to it after.
members: view.kind === 'group' && !resizing ? this.nodeMembers(start) : [],
nested: view.kind === 'group' && !resizing ? this.nestedMembers(id, start) : [],
members: along.nodes,
nested: along.boxes.filter((b) => b.id !== id),
pointer,
moved: false,
};
}
/** The selected groups and comments as drag seeds, `exceptId` left out (it is the grabbed one). */
private selectedBoxes(exceptId?: string): { id: string; kind: AnnotationKind; start: AnnotationBox }[] {
const graph = this.asset()?.graph;
if (!graph) return [];
const seeds: { id: string; kind: AnnotationKind; start: AnnotationBox }[] = [];
for (const selected of this.state.selectedAnnotationIds) {
if (selected === exceptId) continue;
const found = findAnnotation(graph, selected);
if (found) seeds.push({ id: selected, kind: found.kind, start: boxOf(found.annotation) });
}
return seeds;
}
/**
* Everything one move gesture carries: the boxes it starts from, the boxes those hold, and every
* node beneath any of them – each node exactly once, however many groups claim it. Without that
* last part a node inside a group that is itself selected would be offset twice.
*/
private travellers(
seedBoxes: readonly { id: string; kind: AnnotationKind; start: AnnotationBox }[],
seedNodeIds: readonly string[],
): {
boxes: { id: string; kind: AnnotationKind; start: AnnotationBox }[];
nodes: { nodeId: string; start: { x: number; y: number } }[];
} {
const boxes = new Map(seedBoxes.map((b) => [b.id, b]));
for (const seed of seedBoxes) {
if (seed.kind !== 'group') continue;
for (const nested of this.nestedMembers(seed.id, seed.start))
if (!boxes.has(nested.id)) boxes.set(nested.id, nested);
}
const nodes = new Map<string, { nodeId: string; start: { x: number; y: number } }>();
const graph = this.asset()?.graph;
for (const id of seedNodeIds) {
const node = graph?.nodes.find((n) => n.id === id);
if (node) nodes.set(id, { nodeId: id, start: { x: node.x, y: node.y } });
}
for (const box of boxes.values()) {
if (box.kind !== 'group') continue;
for (const member of this.nodeMembers(box.start))
if (!nodes.has(member.nodeId)) nodes.set(member.nodeId, member);
}
return { boxes: [...boxes.values()], nodes: [...nodes.values()] };
}
/** The nodes a group at this box carries – the centre rule, against what is drawn right now. */
private nodeMembers(box: AnnotationBox): { nodeId: string; start: { x: number; y: number } }[] {
const graph = this.asset()?.graph;
@@ -496,11 +553,7 @@ export class GraphCanvas extends Widget {
if (!this.assetId) return;
const command = new AddAnnotationCommand(this.assetId, kind, box, text);
if (this.state.execute(command))
this.state.select({
kind: 'annotation',
assetId: this.assetId,
annotationId: command.annotation.id,
});
this.state.select(annotationSelection(this.assetId, [command.annotation.id]));
}
/** A group drawn around the selected nodes, with room for the title bar above them. */
@@ -570,13 +623,16 @@ export class GraphCanvas extends Widget {
if (this.w === 0 || this.h === 0) return;
this.needsFit = false;
const asset = this.asset();
if (!asset || asset.graph.nodes.length === 0) {
// Groups and comments are part of the picture: a fit that framed only the nodes would cut the
// box drawn around them, and a graph holding nothing but a comment would frame the origin.
const boxes = asset ? [...this.nodeBoxes(), ...this.annotationBoxes()] : [];
if (boxes.length === 0) {
this.pan = { x: this.w / 2 - NODE_WIDTH / 2, y: this.h / 3 };
this.zoom = 1;
this.applyView();
return;
}
const framing = frameBoxes(this.nodeBoxes(), { w: this.w, h: this.h }, FIT_MARGIN, {
const framing = frameBoxes(boxes, { w: this.w, h: this.h }, FIT_MARGIN, {
min: MIN_ZOOM,
max: MAX_ZOOM,
});
@@ -585,6 +641,15 @@ export class GraphCanvas extends Widget {
this.applyView();
}
/** Every group and comment as the rectangle it is drawn as – a box states its own size. */
private annotationBoxes(): Box[] {
const graph = this.asset()?.graph;
if (!graph) return [];
return ANNOTATION_KINDS.flatMap((kind) =>
annotationsOf(graph, kind).map((a) => ({ id: a.id, ...boxOf(a) })),
);
}
/** Each node as the rectangle it is actually drawn as (a view knows its own height). */
private nodeBoxes(): Box[] {
return (this.asset()?.graph.nodes ?? []).map((node) => {
@@ -722,8 +787,10 @@ export class GraphCanvas extends Widget {
if (!this.state.selectedNodeIds.includes(view.node.id))
this.state.select(nodeSelection(this.assetId, [view.node.id]));
const p = this.toLocal(e.global);
const along = this.travellers(this.selectedBoxes(), this.state.selectedNodeIds);
this.dragging = {
items: this.selectedNodes().map((n) => ({ nodeId: n.id, start: { x: n.x, y: n.y } })),
items: along.nodes,
boxes: along.boxes,
pointer: this.screenToGraph(p.x, p.y),
moved: false,
};
@@ -777,6 +844,14 @@ export class GraphCanvas extends Widget {
node.x = item.start.x + offset.x;
node.y = item.start.y + offset.y;
}
// Boxes caught by the same band travel with the nodes, each snapping for itself as a node does.
for (const box of this.dragging.boxes) {
const found = findAnnotation(graph, box.id)?.annotation;
if (!found) continue;
const offset = dragOffset(box.start, dx, dy, step);
found.x = box.start.x + offset.x;
found.y = box.start.y + offset.y;
}
this.state.preview();
} else if (this.connecting) {
this.connecting.pointer = g;
@@ -855,7 +930,7 @@ export class GraphCanvas extends Widget {
this.dragging = null;
if (drag?.moved && this.assetId) {
const graph = this.asset()?.graph;
const moves: MoveNodeCommand[] = [];
const moves: Command[] = [];
for (const item of drag.items) {
const node = graph?.nodes.find((n) => n.id === item.nodeId);
if (!node) continue;
@@ -865,6 +940,14 @@ export class GraphCanvas extends Widget {
node.y = item.start.y;
moves.push(new MoveNodeCommand(this.assetId, item.nodeId, next.x, next.y));
}
// The boxes that came along belong in the same step: one gesture, one undo.
for (const box of drag.boxes) {
const found = graph && findAnnotation(graph, box.id)?.annotation;
if (!found) continue;
const next = boxOf(found);
Object.assign(found, box.start);
moves.push(new SetAnnotationBoxCommand(this.assetId, box.kind, box.id, next));
}
if (moves.length > 0) this.state.execute(oneStep('Move nodes', moves));
}
const connecting = this.connecting;
@@ -884,14 +967,17 @@ export class GraphCanvas extends Widget {
current: { x: number; y: number };
}): void {
if (!this.assetId) return;
const ids = boxesInBand(this.nodeBoxes(), {
const band = {
x1: marquee.start.x,
y1: marquee.start.y,
x2: marquee.current.x,
y2: marquee.current.y,
});
// An empty band clears the nodes but keeps the behavior selected, as a plain click does.
this.state.select(nodeSelection(this.assetId, ids) ?? { kind: 'asset', assetId: this.assetId });
};
const ids = boxesInBand(this.nodeBoxes(), band);
// A box counts only when the band holds all of it – see `boxesWithinBand`.
const boxIds = boxesWithinBand(this.annotationBoxes(), band);
// An empty band clears the selection but keeps the behavior, as a plain click does.
this.state.select(graphSelection(this.assetId, ids, boxIds) ?? { kind: 'asset', assetId: this.assetId });
}
private connect(from: PortSide, to: PortSide): void {
@@ -1141,10 +1227,9 @@ export class GraphCanvas extends Widget {
return t !== undefined && now - t <= TRACE_FADE;
};
const selectedNodes = new Set(this.state.selectedNodeIds);
const selectedAnnotation =
this.state.selection?.kind === 'annotation' ? this.state.selection.annotationId : '';
const selectedAnnotations = new Set(this.state.selectedAnnotationIds);
for (const view of this.annotations.values()) {
view.selected = view.annotation.id === selectedAnnotation;
view.selected = selectedAnnotations.has(view.annotation.id);
view.redraw();
}
const target = this.dropTarget;
@@ -1407,6 +1492,12 @@ export class GraphCanvas extends Widget {
return { x: g.x, y: g.y };
}
/** The rectangle a box is drawn as, on screen – what a fit has to bring into view. */
annotationRect(id: string): Rect | null {
const view = this.annotations.get(id);
return view ? globalRect(view, view.w, view.h) : null;
}
chevronGlobal(nodeId: string): { x: number; y: number } | null {
const g = this.views.get(nodeId)?.chevronGlobal();
return g ? { x: g.x, y: g.y } : null;