Give the three asset tabs one base, and move the viewports into the editor

src/viewport/ held three classes only src/editor/ ever used, and framing.ts sat
under graph/ although the scene view wants the same bounds sum. The graph, the
texture and the container tab repeated the same header, subscription and
disposal code with two silent divergences: the graph tab said "nothing shown"
with null instead of '', and the texture tab refell back to the first asset on
a selection change but not on a project event.

These arrive together because GraphCanvas ties them: it reads framing from its
new home, hands its fold marks to folding.ts, and its '' change is what
GraphEditor must agree with.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-10 07:14:38 +02:00
1 parent a2f2f8465c
commit 11981b40d0
15 files changed
+408 -425

No files matched your search

+32 -59
View File
@@ -40,6 +40,7 @@ import {
Menu,
type MenuItem,
onDragChange,
pressCount,
type Size,
Theme,
textStyle,
@@ -47,11 +48,18 @@ import {
Widget,
zoomFactor,
} from '../../ui';
import { pressCount } from '../../ui/clicks';
import { removeNodes } from '../actions';
import { type EditorState, nodeSelection } from '../state';
import { foldedEdgeShown, foldMarkKey, foldMarkLabel } from './folding';
import { type Box, boxesInBand, frameBoxes } from './framing';
import {
buildFoldMarks,
foldedEdgeShown,
type FoldMarkEnd,
foldMarkAt,
foldMarkKey,
foldMarkLabel,
foldMarkRadius,
} from './folding';
import { type Box, boxesInBand, frameBoxes } from '../framing';
import { NodePalette } from './NodePalette';
import { type PaletteEntry, paletteEntries } from './paletteEntries';
import { NODE_WIDTH, NodeView, portColor, TITLE_HEIGHT } from './NodeView';
@@ -83,14 +91,6 @@ interface FoldMark {
text: string;
}
/** A mark while the edges are still adding themselves to it. */
interface PendingMark extends Omit<FoldMark, 'text'> {
names: string[];
portX: number;
color: number;
hot: boolean;
}
/**
* The node graph of one behavior asset. Which behavior that is comes from the outside (`show`),
* so the canvas never has an opinion about the selection – the GraphEditor around it does.
@@ -107,7 +107,7 @@ export class GraphCanvas extends Widget {
private readonly markLabels: Text[] = [];
private markLabelCount = 0;
private foldMarks: FoldMark[] = [];
private readonly foldPending = new Map<string, PendingMark>();
private foldEnds: FoldMarkEnd[] = [];
/** Edges the last redraw really stroked as a curve – a hidden one is missing from it. */
private readonly strokedEdges = new Set<string>();
private readonly nodeLayer = new Container();
@@ -116,7 +116,7 @@ export class GraphCanvas extends Widget {
private readonly band = new Graphics();
private readonly hint = new Text({ text: '', style: textStyle('muted') });
private readonly views = new Map<string, NodeView>();
private assetId: string | null = null;
private assetId = '';
private pan = { x: 0, y: 0 };
private zoom = 1;
private panning: { startX: number; startY: number; panX: number; panY: number } | null = null;
@@ -180,12 +180,12 @@ export class GraphCanvas extends Widget {
state.on('mode', () => this.redraw());
}
get currentAssetId(): string | null {
get currentAssetId(): string {
return this.assetId;
}
/** Switching refits the view, since the next graph is a new subject. */
show(assetId: string | null): void {
show(assetId: string): void {
if (this.assetId === assetId) return;
this.assetId = assetId;
this.sync();
@@ -211,8 +211,6 @@ export class GraphCanvas extends Widget {
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
}
// --- model binding ----------------------------------------------------------------------
private behaviors(): BehaviorLookup {
return behaviorLookup(this.state.project);
}
@@ -227,7 +225,7 @@ export class GraphCanvas extends Widget {
const asset = this.asset();
if (!asset) {
// A behavior that was deleted (or undone away) must not linger as a graph.
this.assetId = null;
this.assetId = '';
for (const view of this.views.values()) view.destroy({ children: true });
this.views.clear();
this.redraw();
@@ -306,8 +304,6 @@ export class GraphCanvas extends Widget {
return { x: gx * this.zoom + this.pan.x, y: gy * this.zoom + this.pan.y };
}
// --- pointer -----------------------------------------------------------------------------
private onCanvasPointerDown(e: FederatedPointerEvent): void {
// The middle button pans from anywhere, a node under the pointer included – it is the one
// gesture that never means "grab this". Nodes let it through for exactly that reason.
@@ -613,8 +609,6 @@ export class GraphCanvas extends Widget {
}
}
// --- menus -------------------------------------------------------------------------------
private openPalette(globalX: number, globalY: number, at: { x: number; y: number }): void {
new NodePalette(this.entries(), (entry) => this.addNode(entry, at)).open(globalX, globalY);
}
@@ -750,8 +744,6 @@ export class GraphCanvas extends Widget {
]).open(globalX, globalY);
}
// --- drawing -----------------------------------------------------------------------------
override layout(): void {
this.hint.position.set(Theme.padding, this.h - Theme.padding - 16);
if (this.needsFit) this.fitView();
@@ -792,7 +784,7 @@ export class GraphCanvas extends Widget {
this.foldMarks = [];
this.markLabelCount = 0;
this.strokedEdges.clear();
this.foldPending.clear();
this.foldEnds = [];
for (const edge of asset.graph.edges) this.drawEdge(edge, isHot(trace?.edges, edge.id), selectedNodes);
// After the curves, so no line drawn later crosses a mark that stands for a hidden one.
this.drawFoldMarks();
@@ -871,7 +863,7 @@ export class GraphCanvas extends Widget {
this.strokedEdges.add(edge.id);
}
/** Adds one end of a hidden link to its port's mark, creating that mark on first sight. */
/** Records one end of a hidden link; `drawFoldMarks` groups them per port afterwards. */
private addFoldMark(
edge: GraphEdge,
port: { x: number; y: number },
@@ -881,38 +873,25 @@ export class GraphCanvas extends Widget {
hot: boolean,
): void {
const [side, other] = isOutput ? [edge.from, edge.to.node] : [edge.to, edge.from.node];
const key = foldMarkKey(side, isOutput);
let mark = this.foldPending.get(key);
if (!mark) {
mark = {
edgeIds: [],
others: [],
names: [],
isOutput,
x: port.x + (isOutput ? FOLD_STUB : -FOLD_STUB),
y: port.y,
portX: port.x,
color,
hot,
};
this.foldPending.set(key, mark);
}
mark.edgeIds.push(edge.id);
mark.names.push(otherLabel);
if (!mark.others.includes(other)) mark.others.push(other);
// A pulse anywhere in the bundle colours the whole mark: it is the one thing worth spotting.
if (hot && !mark.hot) {
mark.hot = true;
mark.color = color;
}
this.foldEnds.push({
edgeId: edge.id,
key: foldMarkKey(side, isOutput),
other,
otherLabel,
isOutput,
portX: port.x,
portY: port.y,
color,
hot,
});
}
/** A stub out of the port, a dot, and what waits at the other end – one per port, not per link. */
private drawFoldMarks(): void {
for (const mark of this.foldPending.values()) {
for (const mark of buildFoldMarks(this.foldEnds, FOLD_STUB)) {
const { x, y, color, isOutput } = mark;
this.edges.moveTo(mark.portX, y).lineTo(x, y).stroke({ width: 2, color, alpha: 0.9 });
const radius = mark.edgeIds.length > 1 ? FOLD_MARK_RADIUS + 2 : FOLD_MARK_RADIUS;
const radius = foldMarkRadius(mark.edgeIds.length, FOLD_MARK_RADIUS);
this.edges.circle(x, y, radius).fill(color);
this.edges.circle(x, y, radius).stroke({ width: 1, color: Theme.colors.node });
const label = this.markLabel();
@@ -941,11 +920,7 @@ export class GraphCanvas extends Widget {
}
private markAt(g: { x: number; y: number }): FoldMark | null {
for (const m of this.foldMarks) {
const radius = m.edgeIds.length > 1 ? FOLD_MARK_RADIUS + 2 : FOLD_MARK_RADIUS;
if (Math.hypot(g.x - m.x, g.y - m.y) <= radius + 4) return m;
}
return null;
return foldMarkAt(this.foldMarks, g, FOLD_MARK_RADIUS);
}
private syncGrid(): void {
@@ -963,8 +938,6 @@ export class GraphCanvas extends Widget {
});
}
// --- test hooks -----------------------------------------------------------------------------
/** Global screen position of a port (tests drive connections with it). */
portGlobal(nodeId: string, port: string, isOutput: boolean): { x: number; y: number } | null {
const view = this.views.get(nodeId);