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>
162 lines
5.9 KiB
TypeScript
162 lines
5.9 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|