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:
1 parent
a2f2f8465c
commit
11981b40d0
15 files changed
+408
-425
No files matched your search
@@ -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);
|
||||
|
||||
@@ -1,115 +1,38 @@
|
||||
import { Graphics } from 'pixi.js';
|
||||
import type { BehaviorAsset } from '../../core/model/Asset';
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
import { Button, Dropdown, HStack, Label, Menu, type MenuItem, Spacer, Theme, VStack } from '../../ui';
|
||||
import type { Asset, BehaviorAsset } from '../../core/model/Asset';
|
||||
import { Button } from '../../ui';
|
||||
import { AssetTabEditor } from '../AssetTabEditor';
|
||||
import type { EditorState } from '../state';
|
||||
import { GraphCanvas } from './GraphCanvas';
|
||||
|
||||
/** Room kept for the "N nodes · M links" readout, wide enough for a three-digit graph. */
|
||||
const INFO_WIDTH = 150;
|
||||
|
||||
/**
|
||||
* The Graph tab. It follows the selection, but keeps showing the last behavior when the
|
||||
* selection moves elsewhere – clicking a sprite must not empty the graph being edited.
|
||||
*/
|
||||
export class GraphEditor extends VStack {
|
||||
/** The canvas itself: the live redraw while playing and the check scripts reach for it. */
|
||||
readonly canvas: GraphCanvas;
|
||||
private readonly picker: Dropdown;
|
||||
private readonly info: Label;
|
||||
export class GraphEditor extends AssetTabEditor<BehaviorAsset, GraphCanvas> {
|
||||
private readonly addButton: Button;
|
||||
private readonly headerBg = new Graphics();
|
||||
|
||||
constructor(private readonly state: EditorState) {
|
||||
super();
|
||||
const header = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' });
|
||||
header.minHeight = Theme.controlHeight + Theme.padding * 2;
|
||||
header.addChildAt(this.headerBg, 0);
|
||||
|
||||
header.add(new Label('Behavior', { style: 'muted' }));
|
||||
this.picker = new Dropdown('—', { width: 200, onClick: () => this.openPicker() });
|
||||
header.add(this.picker);
|
||||
constructor(state: EditorState) {
|
||||
super(state, { noun: 'Behavior', infoWidth: INFO_WIDTH });
|
||||
this.addButton = new Button('Add node…', { onClick: () => this.canvas.addNodeFrom(this.addButton) });
|
||||
header.add(this.addButton);
|
||||
header.add(new Button('Fit', { onClick: () => this.canvas.fit() }));
|
||||
header.add(new Spacer(), 1);
|
||||
this.info = new Label('', { style: 'small', align: 'right' });
|
||||
// Reserved, not measured: a stack sizes the label once, from the empty text it was built with.
|
||||
this.info.minWidth = INFO_WIDTH;
|
||||
header.add(this.info);
|
||||
|
||||
this.canvas = new GraphCanvas(state);
|
||||
this.add(header);
|
||||
this.add(this.canvas, 1);
|
||||
|
||||
state.on('selection', () => this.followSelection());
|
||||
state.on('project', () => this.refresh());
|
||||
this.mount(new GraphCanvas(state), [this.addButton]);
|
||||
// Adding nodes is an edit, and edits made while playing would be thrown away with the run.
|
||||
state.on('mode', () => this.refresh());
|
||||
state.on('load', () => {
|
||||
this.canvas.show(null);
|
||||
this.followSelection();
|
||||
});
|
||||
this.followSelection();
|
||||
}
|
||||
|
||||
/** The behavior currently shown – the check scripts read this. */
|
||||
get currentAssetId(): string | null {
|
||||
return this.canvas.currentAssetId;
|
||||
protected override selectedId(): string {
|
||||
return this.state.selectedBehaviorId ?? '';
|
||||
}
|
||||
|
||||
/** For the check scripts: the picker's width must not follow the name it happens to show. */
|
||||
get pickerWidth(): number {
|
||||
return this.picker.w;
|
||||
protected override isMine(asset: Asset): asset is BehaviorAsset {
|
||||
return asset.type === 'behavior';
|
||||
}
|
||||
|
||||
private followSelection(): void {
|
||||
const id = this.state.selectedBehaviorId;
|
||||
// Null merely means "the selection is about something else"; the tab keeps its subject.
|
||||
if (id) this.canvas.show(id);
|
||||
this.refresh();
|
||||
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'}`;
|
||||
}
|
||||
|
||||
private asset(): BehaviorAsset | null {
|
||||
const id = this.canvas.currentAssetId;
|
||||
const asset = id ? findAsset(this.state.project, id) : undefined;
|
||||
return asset && asset.type === 'behavior' ? asset : null;
|
||||
}
|
||||
|
||||
private behaviors(): BehaviorAsset[] {
|
||||
return this.state.project.assets
|
||||
.filter((a): a is BehaviorAsset => a.type === 'behavior')
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
private openPicker(): void {
|
||||
const current = this.canvas.currentAssetId;
|
||||
const items: MenuItem[] = this.behaviors().map((asset) => ({
|
||||
label: asset.id === current ? `● ${asset.name}` : asset.name,
|
||||
onSelect: () => this.state.select({ kind: 'asset', assetId: asset.id }),
|
||||
}));
|
||||
if (items.length === 0) items.push({ label: 'No behaviors yet', onSelect: () => undefined });
|
||||
new Menu(items, { searchable: true }).openBelow(this.picker);
|
||||
}
|
||||
|
||||
private refresh(): void {
|
||||
// A deleted behavior leaves the canvas empty; fall back to whatever behavior is left.
|
||||
if (!this.asset()) this.canvas.show(this.behaviors()[0]?.id ?? null);
|
||||
const asset = this.asset();
|
||||
this.picker.setText(asset ? asset.name : 'No behavior');
|
||||
const nodes = asset?.graph.nodes.length ?? 0;
|
||||
const links = asset?.graph.edges.length ?? 0;
|
||||
this.info.setText(
|
||||
asset ? `${nodes} ${nodes === 1 ? 'node' : 'nodes'} · ${links} ${links === 1 ? 'link' : 'links'}` : '',
|
||||
);
|
||||
protected override syncExtras(asset: BehaviorAsset | null): void {
|
||||
this.addButton.setEnabled(Boolean(asset) && this.state.mode === 'edit');
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
super.redraw();
|
||||
this.headerBg.clear();
|
||||
const h = Theme.controlHeight + Theme.padding * 2;
|
||||
this.headerBg.rect(0, 0, this.w, h).fill(Theme.colors.panelHeader);
|
||||
this.headerBg.rect(0, h - 1, this.w, 1).fill(Theme.colors.border);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -1,73 +0,0 @@
|
||||
/**
|
||||
* Geometry of the graph view: which nodes a rubber band caught, and what view frames them all.
|
||||
* Both are decisions about rectangles, so they live here rather than in the canvas – the "fit"
|
||||
* that left nodes hanging out of the view was a sum that no test could reach.
|
||||
*/
|
||||
|
||||
export interface Box {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface Band {
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
}
|
||||
|
||||
export interface Framing {
|
||||
zoom: number;
|
||||
panX: number;
|
||||
panY: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ids of every box the band overlaps. Nodes are large rectangles, so – unlike the scene view,
|
||||
* which tests an object's position – touching counts: a band drawn across a node that visibly
|
||||
* caught it would otherwise leave it out.
|
||||
*/
|
||||
export function boxesInBand(boxes: readonly Box[], band: Band): string[] {
|
||||
const minX = Math.min(band.x1, band.x2);
|
||||
const maxX = Math.max(band.x1, band.x2);
|
||||
const minY = Math.min(band.y1, band.y2);
|
||||
const maxY = Math.max(band.y1, band.y2);
|
||||
return boxes
|
||||
.filter((b) => b.x <= maxX && b.x + b.w >= minX && b.y <= maxY && b.y + b.h >= minY)
|
||||
.map((b) => b.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* The view that shows every box at once, centred, with `margin` screen pixels to spare.
|
||||
* Never zooms past 1: a two-node graph blown up to fill the screen helps nobody.
|
||||
*/
|
||||
export function frameBoxes(
|
||||
boxes: readonly Box[],
|
||||
view: { w: number; h: number },
|
||||
margin: number,
|
||||
limits: { min: number; max: number },
|
||||
): Framing {
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
for (const b of boxes) {
|
||||
minX = Math.min(minX, b.x);
|
||||
minY = Math.min(minY, b.y);
|
||||
maxX = Math.max(maxX, b.x + b.w);
|
||||
maxY = Math.max(maxY, b.y + b.h);
|
||||
}
|
||||
if (!Number.isFinite(minX)) return { zoom: 1, panX: view.w / 2, panY: view.h / 2 };
|
||||
const width = Math.max(maxX - minX, 1);
|
||||
const height = Math.max(maxY - minY, 1);
|
||||
const fitted = Math.min((view.w - margin * 2) / width, (view.h - margin * 2) / height, 1);
|
||||
const zoom = Math.min(Math.max(fitted, limits.min), limits.max);
|
||||
return {
|
||||
zoom,
|
||||
panX: view.w / 2 - ((minX + maxX) / 2) * zoom,
|
||||
panY: view.h / 2 - ((minY + maxY) / 2) * zoom,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user