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

+104 -17
View File
@@ -27,12 +27,16 @@ export type Selection =
* holds the whole selection, primary included.
*/
| { kind: 'object'; holderId: string; objectId: string; objectIds: string[] }
/** One or more nodes of a behavior's graph, `nodeId` being the primary one (as with objects). */
| { kind: 'node'; assetId: string; nodeId: string; nodeIds: string[] }
/**
* One or more nodes of a behavior's graph, `nodeId` being the primary one (as with objects).
* `annotationIds` holds the groups and comments caught alongside them: a rubber band spans
* whatever lies under it, and the two sorts move and die together from then on.
*/
| { kind: 'node'; assetId: string; nodeId: string; nodeIds: string[]; annotationIds: string[] }
/** A frame rectangle inside a texture asset (the Texture tab). */
| { kind: 'frame'; assetId: string; frameId: string }
/** One group or comment of a graph – never several, since neither carries the other. */
| { kind: 'annotation'; assetId: string; annotationId: string }
/** One or more groups or comments of a graph, `annotationId` being the primary one. */
| { kind: 'annotation'; assetId: string; annotationId: string; annotationIds: string[] }
| null;
/**
@@ -67,9 +71,46 @@ export function objectSelection(holderId: string, objectIds: readonly string[],
}
/** Builds a node selection; the last id is the primary one unless given explicitly. */
export function nodeSelection(assetId: string, nodeIds: readonly string[], primary?: string): Selection {
export function nodeSelection(
assetId: string,
nodeIds: readonly string[],
primary?: string,
annotationIds: readonly string[] = [],
): Selection {
const picked = pickIds(nodeIds, primary);
return picked && { kind: 'node', assetId, nodeId: picked.primary, nodeIds: picked.ids };
return (
picked && {
kind: 'node',
assetId,
nodeId: picked.primary,
nodeIds: picked.ids,
annotationIds: [...new Set(annotationIds)],
}
);
}
/** Builds an annotation selection; the last id is the primary one unless given explicitly. */
export function annotationSelection(
assetId: string,
annotationIds: readonly string[],
primary?: string,
): Selection {
const picked = pickIds(annotationIds, primary);
return picked && { kind: 'annotation', assetId, annotationId: picked.primary, annotationIds: picked.ids };
}
/**
* What a rubber band leaves selected: nodes win the kind when it caught any, so the inspector keeps
* showing a node where it did before, and a band over boxes alone still selects them.
*/
export function graphSelection(
assetId: string,
nodeIds: readonly string[],
annotationIds: readonly string[],
): Selection {
return (
nodeSelection(assetId, nodeIds, undefined, annotationIds) ?? annotationSelection(assetId, annotationIds)
);
}
function pickIds(input: readonly string[], primary?: string): { ids: string[]; primary: string } | null {
@@ -317,9 +358,25 @@ export class EditorState {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (selection?.kind === 'object' && !selection.objectIds)
selection = objectSelection(selection.holderId, [selection.objectId]);
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (selection?.kind === 'node' && !selection.nodeIds)
selection = nodeSelection(selection.assetId, [selection.nodeId]);
// A graph selection carries two lists, and a short form may leave out either – `{ nodeId }`
// alone, or `{ nodeIds }` without the boxes. Filling both here keeps every reader of them
// from having to ask whether they are there.
if (selection?.kind === 'node')
selection = nodeSelection(
selection.assetId,
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
selection.nodeIds ?? [selection.nodeId],
selection.nodeId,
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
selection.annotationIds ?? [],
);
if (selection?.kind === 'annotation')
selection = annotationSelection(
selection.assetId,
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
selection.annotationIds ?? [selection.annotationId],
selection.annotationId,
);
if (sameSelection(this.selection, selection)) return;
this.selection = selection;
this.emit('selection');
@@ -339,8 +396,13 @@ export class EditorState {
const current = s?.kind === 'node' && s.assetId === assetId ? s.nodeIds : [];
const held = current.includes(nodeId);
const next = held ? current.filter((id) => id !== nodeId) : [...current, nodeId];
// Boxes caught alongside stay held: the toggle speaks about this node, not about them.
const boxes = s?.kind === 'node' && s.assetId === assetId ? s.annotationIds : [];
// Emptied by the toggle, the behavior itself stays selected – the graph must not close.
this.select(nodeSelection(assetId, next, held ? undefined : nodeId) ?? { kind: 'asset', assetId });
this.select(
nodeSelection(assetId, next, held ? undefined : nodeId, boxes) ??
annotationSelection(assetId, boxes) ?? { kind: 'asset', assetId },
);
}
get selectedObjectIds(): string[] {
@@ -351,6 +413,13 @@ export class EditorState {
return this.selection?.kind === 'node' ? this.selection.nodeIds : [];
}
/** The groups and comments selected, whether alongside nodes or on their own. */
get selectedAnnotationIds(): string[] {
const s = this.selection;
if (s?.kind === 'node') return s.annotationIds;
return s?.kind === 'annotation' ? s.annotationIds : [];
}
/** Drops selected targets that no longer exist (after delete/undo). */
private validateSelection(): void {
const s = this.selection;
@@ -368,16 +437,25 @@ export class EditorState {
if (!asset) this.select(null);
else {
const alive = s.nodeIds.filter((id) => asset.graph.nodes.some((n) => n.id === id));
if (alive.length !== s.nodeIds.length)
this.select(nodeSelection(s.assetId, alive, s.nodeId) ?? { kind: 'asset', assetId: s.assetId });
const boxes = s.annotationIds.filter((id) => findAnnotation(asset.graph, id));
if (alive.length !== s.nodeIds.length || boxes.length !== s.annotationIds.length)
this.select(
nodeSelection(s.assetId, alive, s.nodeId, boxes) ??
annotationSelection(s.assetId, boxes) ?? { kind: 'asset', assetId: s.assetId },
);
}
}
if (s.kind === 'annotation') {
const asset = findGraphAsset(this.project, s.assetId);
// Same fallback as a node: the graph stays open when the box it held vanished (undo).
if (!asset) this.select(null);
else if (!findAnnotation(asset.graph, s.annotationId))
this.select({ kind: 'asset', assetId: s.assetId });
else {
const alive = s.annotationIds.filter((id) => findAnnotation(asset.graph, id));
if (alive.length !== s.annotationIds.length)
this.select(
annotationSelection(s.assetId, alive, s.annotationId) ?? { kind: 'asset', assetId: s.assetId },
);
}
}
if (s.kind === 'frame') {
const asset = findAssetOf(this.project, s.assetId, 'texture');
@@ -488,12 +566,21 @@ export function sameSelection(a: Selection, b: Selection): boolean {
return (
a.assetId === b.assetId &&
a.nodeId === b.nodeId &&
a.nodeIds.length === b.nodeIds.length &&
a.nodeIds.every((id, i) => id === b.nodeIds[i])
sameIds(a.nodeIds, b.nodeIds) &&
sameIds(a.annotationIds, b.annotationIds)
);
}
if (a.kind === 'frame' && b.kind === 'frame') return a.assetId === b.assetId && a.frameId === b.frameId;
if (a.kind === 'annotation' && b.kind === 'annotation')
return a.assetId === b.assetId && a.annotationId === b.annotationId;
return (
a.assetId === b.assetId &&
a.annotationId === b.annotationId &&
sameIds(a.annotationIds, b.annotationIds)
);
return false;
}
/** Same ids in the same order – the order carries which one was picked last. */
function sameIds(a: readonly string[], b: readonly string[]): boolean {
return a.length === b.length && a.every((id, i) => id === b[i]);
}