Three stores, three importers and three pickers that differ only in their type are one of each (#85)
game-maker / gate (push) Successful in 1m33s
game-maker / suite (push) Successful in 5m46s

Refactoring round one: HttpFontStore/HttpSoundStore/HttpTextureStore collapse into a generic
HttpFileStore over a FileStore contract; fontImport/soundImport/textureImport share assetImport
with an ImportKind per sort; ImagePicker/FontPicker/SoundPicker sit on FilePicker and describe
only their row. The group/comment drag logic leaves GraphCanvas for annotationDrag.ts (with its
own unit test), declaredRows.ts replaces the duplicated row build in particles.ts and shaders.ts,
and dead code in font/sound/compile/emission/transform/actions is gone. README: asset list,
check count 958, the two-job CI, the Scenes wiki page.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Fable 5.1 committed 2026-09-16 17:38:54 +02:00
1 parent 15bdfdf07a
commit cc6db5f350
42 files changed
+1375 -1650

No files matched your search

+51 -209
View File
@@ -2,19 +2,13 @@ import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphi
import { textOf } from '../../core/model/Properties';
import type { Command } from '../../core/commands/Command';
import { oneStep } from '../../core/commands/CompoundCommand';
import {
AddNodeCommand,
CollapseNodesCommand,
ConnectCommand,
MoveNodeCommand,
} from '../../core/commands/graphCommands';
import { AddNodeCommand, CollapseNodesCommand, ConnectCommand } from '../../core/commands/graphCommands';
import { SetMachineStartCommand } from '../../core/commands/assetCommands';
import {
AddAnnotationCommand,
type AnnotationBox,
annotationNoun,
normalizeBox,
SetAnnotationBoxCommand,
} from '../../core/commands/annotationCommands';
import {
type AnnotationKind,
@@ -32,7 +26,6 @@ import {
firstPortFor,
ghostProblem,
type Graph,
type GraphAnnotation,
type GraphNode,
type GraphLookup,
MIN_ANNOTATION_SIZE,
@@ -75,13 +68,22 @@ import { annotationSelection, type EditorState, graphSelection, nodeSelection }
import {
type Box,
boundsOf,
boxesCentredIn,
boxesInBand,
boxesWithinBand,
dragOffset,
frameBoxes,
resizeSize,
} from '../framing';
import {
annotationBoxes,
boxOf,
type BoxSeed,
boxSeeds,
placeTravellers,
recordTravellers,
travellers,
type Travellers,
} from './annotationDrag';
import { ANNOTATION_TITLE_HEIGHT, AnnotationView, RESIZE_HANDLE } from './AnnotationView';
import { arrowHead, bezier, EdgeLayer } from './EdgeLayer';
import { arrowShaft, type Point, transitionPreview } from './transitions';
@@ -141,25 +143,17 @@ export class GraphCanvas extends Widget {
private pan = { x: 0, y: 0 };
private zoom = 1;
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;
/** Nodes being dragged, with the boxes a band caught alongside them – they travel together. */
private dragging: { along: Travellers; pointer: { x: number; y: number }; moved: boolean } | null = null;
/**
* A group or comment being moved or resized. `members` is fixed when the drag begins: which
* nodes a group carries is a question about where they were when it was grabbed, not a running
* one – otherwise a node would join the group halfway through the gesture that passes over it.
* A group or comment being moved or resized. `along` is fixed when the drag begins: which nodes a
* group carries is a question about where they were when it was grabbed, not a running one –
* otherwise a node would join the group halfway through the gesture that passes over it.
*/
private annotationDrag: {
id: string;
kind: AnnotationKind;
grabbed: BoxSeed;
mode: 'move' | 'resize';
start: AnnotationBox;
members: { nodeId: string; start: { x: number; y: number } }[];
nested: { id: string; kind: AnnotationKind; start: AnnotationBox }[];
along: Travellers;
pointer: { x: number; y: number };
moved: boolean;
} | null = null;
@@ -380,106 +374,27 @@ export class GraphCanvas extends Widget {
return;
}
if (e.button !== 0) return;
const graph = this.asset()?.graph;
if (!graph) return;
const local = view.toLocal(e.global);
const p = this.toLocal(e.global);
const pointer = this.screenToGraph(p.x, p.y);
const start = boxOf(view.annotation);
const grabbed: BoxSeed = { id, kind: view.kind, 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 seeds = [grabbed, ...boxSeeds(graph, this.state.selectedAnnotationIds, id)];
const along = resizing
? { boxes: [], nodes: [] }
: this.travellers(
[{ id, kind: view.kind, start }, ...this.selectedBoxes(id)],
this.state.selectedNodeIds,
);
? { boxes: [grabbed], nodes: [] }
: travellers(graph, this.nodeBoxes(), seeds, this.state.selectedNodeIds);
this.annotationDrag = {
id,
kind: view.kind,
grabbed,
mode: resizing ? 'resize' : 'move',
start,
members: along.nodes,
nested: along.boxes.filter((b) => b.id !== id),
pointer,
along,
pointer: this.screenToGraph(p.x, p.y),
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;
const ids = new Set(boxesCentredIn(this.nodeBoxes(), box));
return (graph?.nodes ?? [])
.filter((n) => ids.has(n.id))
.map((n) => ({ nodeId: n.id, start: { x: n.x, y: n.y } }));
}
/** Groups and comments inside a group: the outer one takes them, and with them their own nodes. */
private nestedMembers(
selfId: string,
box: AnnotationBox,
): { id: string; kind: AnnotationKind; start: AnnotationBox }[] {
const graph = this.asset()?.graph;
if (!graph) return [];
const inside: { id: string; kind: AnnotationKind; start: AnnotationBox }[] = [];
for (const kind of ANNOTATION_KINDS) {
const list = annotationsOf(graph, kind).filter((a) => a.id !== selfId);
const ids = new Set(
boxesCentredIn(
list.map((a) => ({ id: a.id, ...boxOf(a) })),
box,
),
);
for (const a of list) if (ids.has(a.id)) inside.push({ id: a.id, kind, start: boxOf(a) });
}
return inside;
}
/** Moves or resizes the dragged box in the model, for the preview; committed on release. */
private previewAnnotationDrag(g: { x: number; y: number }): void {
const drag = this.annotationDrag;
@@ -489,29 +404,16 @@ export class GraphCanvas extends Widget {
const dy = g.y - drag.pointer.y;
if (!drag.moved && Math.hypot(dx, dy) * this.zoom < DRAG_THRESHOLD) return;
drag.moved = true;
const self = findAnnotation(graph, drag.id)?.annotation;
if (!self) return;
const step = this.snapping ? GRID_STEP : 0;
if (drag.mode === 'resize') {
const size = resizeSize(drag.start, dx, dy, step, MIN_ANNOTATION_SIZE);
Object.assign(self, normalizeBox({ ...drag.start, ...size }));
this.state.preview();
return;
}
const offset = dragOffset(drag.start, dx, dy, step);
self.x = drag.start.x + offset.x;
self.y = drag.start.y + offset.y;
for (const member of drag.members) {
const node = graph.nodes.find((n) => n.id === member.nodeId);
if (!node) continue;
node.x = member.start.x + offset.x;
node.y = member.start.y + offset.y;
}
for (const member of drag.nested) {
const nested = findAnnotation(graph, member.id)?.annotation;
if (!nested) continue;
nested.x = member.start.x + offset.x;
nested.y = member.start.y + offset.y;
const self = findAnnotation(graph, drag.grabbed.id)?.annotation;
if (!self) return;
const size = resizeSize(drag.grabbed.start, dx, dy, step, MIN_ANNOTATION_SIZE);
Object.assign(self, normalizeBox({ ...drag.grabbed.start, ...size }));
} else {
// One offset for all of it, the grabbed box's: what travels with a group keeps its distance.
const offset = dragOffset(drag.grabbed.start, dx, dy, step);
placeTravellers(graph, drag.along, () => offset);
}
this.state.preview();
}
@@ -522,30 +424,10 @@ export class GraphCanvas extends Widget {
this.annotationDrag = null;
const graph = this.asset()?.graph;
if (!drag?.moved || !graph || !this.assetId) return;
const self = findAnnotation(graph, drag.id)?.annotation;
if (!self) return;
const commands: Command[] = [];
const take = (a: GraphAnnotation, kind: AnnotationKind, id: string, start: AnnotationBox) => {
const next = boxOf(a);
Object.assign(a, start);
commands.push(new SetAnnotationBoxCommand(this.assetId, kind, id, next, label));
};
const label = drag.mode === 'resize' ? `Resize ${annotationNoun(drag.kind)}` : undefined;
take(self, drag.kind, drag.id, drag.start);
for (const member of drag.nested) {
const nested = findAnnotation(graph, member.id)?.annotation;
if (nested) take(nested, member.kind, member.id, member.start);
}
for (const member of drag.members) {
const node = graph.nodes.find((n) => n.id === member.nodeId);
if (!node) continue;
const next = { x: node.x, y: node.y };
node.x = member.start.x;
node.y = member.start.y;
commands.push(new MoveNodeCommand(this.assetId, member.nodeId, next.x, next.y));
}
const verb = drag.mode === 'resize' ? 'Resize' : 'Move';
this.state.execute(oneStep(`${verb} ${annotationNoun(drag.kind)}`, commands));
const noun = annotationNoun(drag.grabbed.kind);
const label = drag.mode === 'resize' ? `Resize ${noun}` : undefined;
const commands = recordTravellers(graph, this.assetId, drag.along, label);
this.state.execute(oneStep(`${drag.mode === 'resize' ? 'Resize' : 'Move'} ${noun}`, commands));
}
/** Adds a group or comment and selects it, so its title can be typed straight away. */
@@ -625,7 +507,7 @@ export class GraphCanvas extends Widget {
const asset = this.asset();
// 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()] : [];
const boxes = asset ? [...this.nodeBoxes(), ...annotationBoxes(asset.graph)] : [];
if (boxes.length === 0) {
this.pan = { x: this.w / 2 - NODE_WIDTH / 2, y: this.h / 3 };
this.zoom = 1;
@@ -641,15 +523,6 @@ 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) => {
@@ -786,11 +659,12 @@ export class GraphCanvas extends Widget {
// Clicking a selected node keeps the selection so the whole group can be dragged.
if (!this.state.selectedNodeIds.includes(view.node.id))
this.state.select(nodeSelection(this.assetId, [view.node.id]));
const graph = this.asset()?.graph;
if (!graph) return;
const p = this.toLocal(e.global);
const along = this.travellers(this.selectedBoxes(), this.state.selectedNodeIds);
const seeds = boxSeeds(graph, this.state.selectedAnnotationIds);
this.dragging = {
items: along.nodes,
boxes: along.boxes,
along: travellers(graph, this.nodeBoxes(), seeds, this.state.selectedNodeIds),
pointer: this.screenToGraph(p.x, p.y),
moved: false,
};
@@ -837,21 +711,8 @@ export class GraphCanvas extends Widget {
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;
const offset = dragOffset(item.start, dx, dy, step);
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;
}
// Each thing snaps for itself, node or box, so what lands on the grid is what was grabbed.
placeTravellers(graph, this.dragging.along, (start) => dragOffset(start, dx, dy, step));
this.state.preview();
} else if (this.connecting) {
this.connecting.pointer = g;
@@ -928,26 +789,10 @@ export class GraphCanvas extends Widget {
}
const drag = this.dragging;
this.dragging = null;
if (drag?.moved && this.assetId) {
const graph = this.asset()?.graph;
const moves: Command[] = [];
for (const item of drag.items) {
const node = graph?.nodes.find((n) => n.id === item.nodeId);
if (!node) continue;
// The preview moved the node already; put it back so the command records the start.
const next = { x: node.x, y: node.y };
node.x = item.start.x;
node.y = item.start.y;
moves.push(new MoveNodeCommand(this.assetId, item.nodeId, next.x, next.y));
}
const graph = this.asset()?.graph;
if (drag?.moved && graph && this.assetId) {
// 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));
}
const moves = recordTravellers(graph, this.assetId, drag.along);
if (moves.length > 0) this.state.execute(oneStep('Move nodes', moves));
}
const connecting = this.connecting;
@@ -975,7 +820,8 @@ export class GraphCanvas extends Widget {
};
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);
const graph = this.asset()?.graph;
const boxIds = graph ? boxesWithinBand(annotationBoxes(graph), 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 });
}
@@ -1512,10 +1358,6 @@ function globalRect(target: Container, w: number, h: number): Rect {
return { x: topLeft.x, y: topLeft.y, w: bottomRight.x - topLeft.x, h: bottomRight.y - topLeft.y };
}
function boxOf(annotation: GraphAnnotation): AnnotationBox {
return { x: annotation.x, y: annotation.y, w: annotation.w, h: annotation.h };
}
/** A drag may start at either end; the command wants the output first. */
function connectSides(assetId: string, a: PortSide, b: PortSide): ConnectCommand {
const [out, inp] = a.isOutput ? [a, b] : [b, a];
+161
View File
@@ -0,0 +1,161 @@
/**
* What a drag in the graph carries and how it is written back. A group takes the nodes whose centre
* lies in it and the boxes inside it, a band may have caught nodes and boxes together, and either
* way a thing must move exactly once – a node inside a selected group is claimed twice and offset
* once. Pure over the graph and the drawn node boxes, so the rule is testable without a canvas.
*/
import { type AnnotationBox, SetAnnotationBoxCommand } from '../../core/commands/annotationCommands';
import type { Command } from '../../core/commands/Command';
import { MoveNodeCommand } from '../../core/commands/graphCommands';
import {
type AnnotationKind,
ANNOTATION_KINDS,
annotationsOf,
findAnnotation,
type Graph,
type GraphAnnotation,
} from '../../core/graph';
import { type Box, boxesCentredIn, type Point } from '../framing';
/** A box as the drag found it. */
export interface BoxSeed {
id: string;
kind: AnnotationKind;
start: AnnotationBox;
}
/** A node as the drag found it. */
export interface NodeSeed {
nodeId: string;
start: Point;
}
export interface Travellers {
boxes: BoxSeed[];
nodes: NodeSeed[];
}
export function boxOf(annotation: GraphAnnotation): AnnotationBox {
return { x: annotation.x, y: annotation.y, w: annotation.w, h: annotation.h };
}
/** Every group and comment as the rectangle it is drawn as – a box states its own size. */
export function annotationBoxes(graph: Graph): Box[] {
return ANNOTATION_KINDS.flatMap((kind) =>
annotationsOf(graph, kind).map((a) => ({ id: a.id, ...boxOf(a) })),
);
}
/** The boxes with these ids as drag seeds, `exceptId` left out (it is the grabbed one). */
export function boxSeeds(graph: Graph, ids: readonly string[], exceptId?: string): BoxSeed[] {
const seeds: BoxSeed[] = [];
for (const id of ids) {
if (id === exceptId) continue;
const found = findAnnotation(graph, id);
if (found) seeds.push({ id, 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. `nodeBoxes`
* are the nodes as drawn, since a node's height is the view's to know.
*/
export function travellers(
graph: Graph,
nodeBoxes: readonly Box[],
seedBoxes: readonly BoxSeed[],
seedNodeIds: readonly string[],
): Travellers {
const boxes = new Map(seedBoxes.map((b) => [b.id, b]));
for (const seed of seedBoxes) {
if (seed.kind !== 'group') continue;
for (const nested of nestedMembers(graph, seed.id, seed.start))
if (!boxes.has(nested.id)) boxes.set(nested.id, nested);
}
const nodes = new Map<string, NodeSeed>();
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 nodeMembers(graph, nodeBoxes, 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. */
function nodeMembers(graph: Graph, nodeBoxes: readonly Box[], box: AnnotationBox): NodeSeed[] {
const ids = new Set(boxesCentredIn(nodeBoxes, box));
return graph.nodes.filter((n) => ids.has(n.id)).map((n) => ({ nodeId: n.id, start: { x: n.x, y: n.y } }));
}
/** Groups and comments inside a group: the outer one takes them, and with them their own nodes. */
function nestedMembers(graph: Graph, selfId: string, box: AnnotationBox): BoxSeed[] {
const inside: BoxSeed[] = [];
for (const kind of ANNOTATION_KINDS) {
const list = annotationsOf(graph, kind).filter((a) => a.id !== selfId);
const ids = new Set(
boxesCentredIn(
list.map((a) => ({ id: a.id, ...boxOf(a) })),
box,
),
);
for (const a of list) if (ids.has(a.id)) inside.push({ id: a.id, kind, start: boxOf(a) });
}
return inside;
}
/**
* Puts every traveller where the gesture has it now, for the preview. `offsetFor` answers per start
* position, because with snapping on each thing lands on the grid for itself, as a node does.
*/
export function placeTravellers(graph: Graph, along: Travellers, offsetFor: (start: Point) => Point): void {
for (const member of along.nodes) {
const node = graph.nodes.find((n) => n.id === member.nodeId);
if (!node) continue;
const offset = offsetFor(member.start);
node.x = member.start.x + offset.x;
node.y = member.start.y + offset.y;
}
for (const member of along.boxes) {
const box = findAnnotation(graph, member.id)?.annotation;
if (!box) continue;
const offset = offsetFor(member.start);
box.x = member.start.x + offset.x;
box.y = member.start.y + offset.y;
}
}
/**
* The commands that take everything from where the drag found it to where the preview left it.
* The preview has already moved the model; each thing is put back first so the command records the
* start, and the caller runs them as one step. `label` names a box command that is not a plain move.
*/
export function recordTravellers(
graph: Graph,
assetId: string,
along: Travellers,
label?: string,
): Command[] {
const commands: Command[] = [];
for (const member of along.boxes) {
const box = findAnnotation(graph, member.id)?.annotation;
if (!box) continue;
const next = boxOf(box);
Object.assign(box, member.start);
commands.push(new SetAnnotationBoxCommand(assetId, member.kind, member.id, next, label));
}
for (const member of along.nodes) {
const node = graph.nodes.find((n) => n.id === member.nodeId);
if (!node) continue;
const next = { x: node.x, y: node.y };
node.x = member.start.x;
node.y = member.start.y;
commands.push(new MoveNodeCommand(assetId, member.nodeId, next.x, next.y));
}
return commands;
}