Files
feuerwerk/src/editor/graph/annotationDrag.ts
T
adminandClaude Fable 5.1 cc6db5f350
game-maker / gate (push) Successful in 1m33s
game-maker / suite (push) Successful in 5m46s
Three stores, three importers and three pickers that differ only in their type are one of each (#85)
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>
2026-09-16 17:38:54 +02:00

162 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
}