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>
This commit is contained in:
1 parent
15bdfdf07a
commit
cc6db5f350
42 files changed
+1375
-1650
No files matched your search
+51
-209
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user