Laying out a graph by hand mostly means lining nodes up, so the magnet was switched on in nearly every session. Every graph tab now starts with it lit: Behavior, State machine, Shader, Particles, Sound FX, Instrument, the song's Routing and the Routing tab. - SnapToggle takes the state it starts in and lights its button to match. GRAPH_SNAPS_AT_FIRST sets it for the toggles and for GraphCanvas alike, so button and canvas cannot disagree. - Curve and Vector Art are no graphs and still start without it. - New hook __gm.graphSnapping(on?) reads every graph tab's magnet, or "mixed" where button and canvas differ, and switches them all. - The e2e harness reads the magnets after each navigate() and then switches them off, as it already sets the zoom transition to 0. Most checks drag a node to an exact spot. e2e 45 does the same itself, because the editor comes up there through the sign-in. - e2e 06 checks that all eight graph tabs started with the magnet on. The checks in 06, 26 and 65 that read it off now say they read what the harness left. Tests: - Unit snap-toggle: 4 tests, 1 new (lit at first, nothing asked of the target before a click). - e2e 06 (melektaus-atx): 150 checks, 1 new. e2e 34: 134 checks. - Gate (melektaus-atx, together with #390): 287 files, 4397 tests. - Pictures looked at: none, the state comes from the hook. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
88 lines
3.3 KiB
TypeScript
88 lines
3.3 KiB
TypeScript
import type { Asset, BehaviorAsset } from '../../core/model/Asset';
|
|
import { findAsset, findAssetOf } from '../../core/model/Project';
|
|
import { AssetTabEditor } from '../AssetTabEditor';
|
|
import type { EditorState } from '../state';
|
|
import { Breadcrumb } from './Breadcrumb';
|
|
import { GraphCanvas } from './GraphCanvas';
|
|
import { GRAPH_SNAPS_AT_FIRST, SnapToggle } from './SnapToggle';
|
|
import { backTo, descend, follow, type Trail } from './trail';
|
|
|
|
/** Room kept for the "N nodes · M links" readout, wide enough for a three-digit graph. */
|
|
const INFO_WIDTH = 150;
|
|
|
|
export class GraphEditor extends AssetTabEditor<BehaviorAsset, GraphCanvas> {
|
|
readonly snap = new SnapToggle(() => this.canvas, GRAPH_SNAPS_AT_FIRST);
|
|
readonly breadcrumb = new Breadcrumb();
|
|
private trail: Trail = [];
|
|
/** A call followed into another behavior: the tab goes on to that document (#171). */
|
|
onRetarget: (from: string, to: string) => void = () => undefined;
|
|
|
|
constructor(state: EditorState) {
|
|
super(state, { infoWidth: INFO_WIDTH });
|
|
this.breadcrumb.onPick = (index) => this.open(this.trail[index]!, backTo(this.trail, index));
|
|
this.mount(new GraphCanvas(state), this.snap.widgets, this.breadcrumb);
|
|
this.canvas.onOpenBehavior = (id) => this.open(id, descend(this.trail, this.currentAssetId, id));
|
|
// Play swaps in the runtime copy of the project, and back.
|
|
state.on('mode', () => this.refresh());
|
|
}
|
|
|
|
/**
|
|
* The bar goes up before the graph switches: switching fits the view to the canvas, and a bar
|
|
* appearing afterwards would cut the bottom of the fitted graph off.
|
|
*/
|
|
private open(assetId: string, trail: Trail): void {
|
|
const from = this.currentAssetId;
|
|
this.trail = trail;
|
|
this.showTrail();
|
|
// A fresh look: fitted below this trail, not the view (and trail) kept from an earlier visit.
|
|
this.forgetView(assetId);
|
|
this.showAsset(assetId);
|
|
this.onRetarget(from, assetId);
|
|
this.state.select({ kind: 'asset', assetId });
|
|
}
|
|
|
|
/** The trail belongs to the tab's document, so it comes back with the view. */
|
|
protected override saveView(): unknown {
|
|
return { canvas: super.saveView(), trail: this.trail };
|
|
}
|
|
|
|
protected override restoreView(view: unknown): void {
|
|
const saved = view as { canvas: unknown; trail: Trail };
|
|
this.trail = saved.trail;
|
|
this.showTrail();
|
|
super.restoreView(saved.canvas);
|
|
}
|
|
|
|
protected override refresh(): void {
|
|
super.refresh();
|
|
const project = this.state.project;
|
|
this.trail = follow(this.trail, this.currentAssetId, (id) =>
|
|
Boolean(findAssetOf(project, id, 'behavior')),
|
|
);
|
|
this.showTrail();
|
|
}
|
|
|
|
private showTrail(): void {
|
|
const names = this.trail.map((id) => findAsset(this.state.project, id)?.name ?? '');
|
|
if (this.breadcrumb.show(names)) this.relayout();
|
|
}
|
|
|
|
override selectedId(): string {
|
|
return this.state.selectedBehaviorId ?? '';
|
|
}
|
|
|
|
override isMine(asset: Asset): asset is BehaviorAsset {
|
|
return asset.type === 'behavior';
|
|
}
|
|
|
|
protected override infoText(asset: BehaviorAsset): string {
|
|
const nodes = asset.graph.nodes.length;
|
|
const links = asset.graph.edges.length;
|
|
return `${nodes} ${nodes === 1 ? 'node' : 'nodes'} · ${links} ${links === 1 ? 'link' : 'links'}`;
|
|
}
|
|
|
|
protected override syncExtras(asset: BehaviorAsset | null): void {
|
|
this.snap.setEnabled(Boolean(asset));
|
|
}
|
|
}
|