A graph that grew past one screen needs somewhere to say what a part of it is for (#38)
game-maker / suite (push) Successful in 6m13s

The graph could hold any number of nodes and said nothing about which of them
belonged together. Groups and comments are two separate things, as decided in
the issue: a group is a titled box that carries the nodes it encloses, a
comment is free text that carries nothing.

Both live as optional arrays on Graph, not as registry nodes. A comment that
were a real node would run through the interpreter, show up in the drop
palettes and be compiled into a shader — the wrong place for a piece of text.
As arrays on Graph they exist in all four graph kinds at once, and a document
without them means exactly "no groups", so no format bump.

Membership is computed when the box is grabbed, never stored: the center of a
node inside the box travels with it, and a node dragged out is simply out. A
box inside a box travels with the outer one, its own nodes included. A group
is grabbed by its title bar only — its body stays transparent to the pointer,
or a rubber band could never be drawn across the nodes it holds. Deleting a
box deletes the box; the nodes stay where they are, because a box that
silently took ten nodes with it would be the most expensive surprise in the
feature.

The color is a free picker, and the title color follows from it: relative
luminance per WCAG, threshold at 0.179 where the two contrast ratios meet —
not 0.5, which would pick white on mid grey where black reads better.

Resizing snaps the far edge to the grid, not the width, so the corner that is
not being dragged stays put. The minimum size rises in whole steps for that
reason: 80 is no multiple of 24, so a box squeezed to the stop would have sat
off-grid in exactly the mode meant to keep it on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-16 11:24:34 +02:00
1 parent 1135205d69
commit b9f1a9d319
21 files changed
+1473 -22

No files matched your search

+13 -1
View File
@@ -2,6 +2,7 @@
* Editor actions that several places trigger the same way (inspector, hierarchy context
* menu, keyboard): they run the command and put the selection where the user expects it.
*/
import { RemoveAnnotationCommand } from '../core/commands/annotationCommands';
import { DeleteAssetCommand } from '../core/commands/assetCommands';
import { AddColliderCommand } from '../core/commands/colliderCommands';
import { oneStep } from '../core/commands/CompoundCommand';
@@ -10,7 +11,8 @@ import { RemoveNodeCommand } from '../core/commands/graphCommands';
import { RemoveSceneObjectsCommand } from '../core/commands/sceneObjectCommands';
import type { AssetType } from '../core/model/AssetTypes';
import { isLiveInstance, isTemplateContent } from '../core/model/instance';
import { findAsset, type ObjectHolder, type Project } from '../core/model/Project';
import { findAnnotation } from '../core/graph';
import { findAsset, findGraphAsset, type ObjectHolder, type Project } from '../core/model/Project';
import type { SceneObject } from '../core/model/SceneObject';
import { textureRefIds } from '../core/model/texture';
import { animationsUsing, findPropertyUsages, findUsages } from '../core/model/usages';
@@ -55,6 +57,16 @@ export function removeNodes(state: EditorState, assetId: string, nodeIds: readon
return commands.length > 0 && state.execute(oneStep(`Delete ${commands.length} nodes`, commands));
}
/**
* Deletes a group or comment and leaves every node alone – which is the point, so the caller does
* not have to know which of the two lists holds it either.
*/
export function removeAnnotation(state: EditorState, assetId: string, annotationId: string): boolean {
const graph = findGraphAsset(state.project, assetId)?.graph;
const found = graph && findAnnotation(graph, annotationId);
return Boolean(found) && state.execute(new RemoveAnnotationCommand(assetId, found!.kind, annotationId));
}
/** Asks before deleting an asset, spelling out what goes with it. */
export function confirmDeleteAsset(state: EditorState, assetId: string): void {
const asset = findAsset(state.project, assetId);
+4
View File
@@ -323,6 +323,10 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
*/
paletteRows: () => currentNodePalette()?.rowsDrawn() ?? null,
selected: () => state.selectedNodeIds,
/** Groups and comments as drawn – text and both colours included, since no pixel says them. */
annotations: () => graphEditor.canvas.annotationsDrawn(),
/** Where one is grabbed: its title bar, or with `corner` the handle that resizes it. */
annotationAt: (id: string, corner = false) => graphEditor.canvas.annotationGlobal(id, corner),
dropTarget: () => graphEditor.canvas.pendingDropTarget(),
danglingEdge: () => graphEditor.canvas.danglingEdgeVisible(),
edgeDrawn: (edgeId: string) => graphEditor.canvas.edgeDrawn(edgeId),
+43
View File
@@ -28,6 +28,33 @@ export interface Box {
h: number;
}
/** Grows a snapped size by whole steps until it clears `min`, so the far edge stays on the grid. */
function growToMin(size: number, min: number, step: number): number {
return size >= min ? size : size + Math.ceil((min - size) / step) * step;
}
/**
* The size a resize drag gives a box that started as `start`. With a positive `step` the corner
* being dragged – the far one, since the near one stays put – lands on the grid, the same way
* `dragOffset` puts a dragged node's own corner there. `step <= 0` is "no snapping". `min` is
* honoured in whole steps, so a box that hits its minimum is still grid-aligned.
*/
export function resizeSize(
start: Omit<Box, 'id'>,
dx: number,
dy: number,
step: number,
min: { w: number; h: number },
): { w: number; h: number } {
const w = start.w + dx;
const h = start.h + dy;
if (!(step > 0)) return { w, h };
return {
w: growToMin(snapToStep(start.x + w, step) - start.x, min.w, step),
h: growToMin(snapToStep(start.y + h, step) - start.y, min.h, step),
};
}
export interface Band {
x1: number;
y1: number;
@@ -56,6 +83,22 @@ export function boxesInBand(boxes: readonly Box[], band: Band): string[] {
.map((b) => b.id);
}
/**
* Ids of the boxes a group carries: those whose centre lies inside it. The centre, not an overlap,
* because a group and the node next to it always touch a little – and because the answer must be
* the one the eye gives. Computed fresh whenever the group is grabbed, so nothing is remembered
* about a node that has been dragged out.
*/
export function boxesCentredIn(boxes: readonly Box[], rect: Box | Omit<Box, 'id'>): string[] {
return boxes
.filter((b) => {
const cx = b.x + b.w / 2;
const cy = b.y + b.h / 2;
return cx >= rect.x && cx <= rect.x + rect.w && cy >= rect.y && cy <= rect.y + rect.h;
})
.map((b) => b.id);
}
export interface Bounds {
minX: number;
minY: number;
+120
View File
@@ -0,0 +1,120 @@
import { Graphics, Rectangle, Text, TextStyle } from 'pixi.js';
import type { AnnotationKind, GraphAnnotation } from '../../core/graph';
import { readableTextColor, Theme, truncate, Widget } from '../../ui';
/** Height of a group's title bar – the same as a node's, so the two line up in the eye. */
export const ANNOTATION_TITLE_HEIGHT = 24;
/** Side of the square at the bottom right corner that resizes the box. */
export const RESIZE_HANDLE = 14;
const PAD = 8;
/**
* One group or comment, drawn behind the nodes in graph coordinates.
*
* A group is hit on its title bar and its resize handle only: its body has to stay transparent to
* the pointer, or a rubber band could never be drawn across the nodes it holds. A comment frames
* nothing, so it is grabbed anywhere.
*/
export class AnnotationView extends Widget {
private readonly box = new Graphics();
private readonly caption: Text;
selected = false;
constructor(
public annotation: GraphAnnotation,
readonly kind: AnnotationKind,
) {
super();
this.addChild(this.box);
this.caption = new Text({ text: '', style: this.captionStyle(0xffffff) });
this.caption.position.set(PAD, kind === 'group' ? 0 : PAD);
this.addChild(this.caption);
this.cursor = Theme.hoverCursor;
this.sync(annotation);
}
sync(annotation: GraphAnnotation): void {
this.annotation = annotation;
this.position.set(annotation.x, annotation.y);
this.resize(annotation.w, annotation.h);
// `resize` puts the full rectangle back every time, changed size or not, so this comes after it.
this.applyHitArea();
this.redraw();
}
get color(): number {
return this.annotation.color ?? Theme.colors.annotation;
}
/** Black or white, picked from the fill it sits on – the price of letting the picker run free. */
get textColor(): number {
return readableTextColor(this.color);
}
private captionStyle(color: number): TextStyle {
return new TextStyle({
fontFamily: Theme.font,
fontSize: Theme.fontSize,
fill: color,
fontWeight: this.kind === 'group' ? '600' : '400',
wordWrap: this.kind === 'note',
wordWrapWidth: Math.max(this.w - PAD * 2, 1),
lineHeight: Theme.fontSize + 4,
});
}
/** Where the box answers the pointer: the title bar of a group, the whole card of a comment. */
private applyHitArea(): void {
if (this.kind === 'note') {
this.hitArea = new Rectangle(0, 0, this.w, this.h);
return;
}
this.hitArea = {
contains: (x: number, y: number) =>
(x >= 0 && x <= this.w && y >= 0 && y <= ANNOTATION_TITLE_HEIGHT) || this.handleAt(x, y),
};
}
/** Whether this local point is on the corner that resizes – outside the hit area for a group,
* so the canvas asks for it before it asks who is under the pointer. */
handleAt(x: number, y: number): boolean {
return x >= this.w - RESIZE_HANDLE && x <= this.w && y >= this.h - RESIZE_HANDLE && y <= this.h;
}
override redraw(): void {
const c = Theme.colors;
const color = this.color;
const text = this.textColor;
this.box.clear();
if (this.kind === 'group') {
// Translucent body, solid title: the nodes inside must stay readable, the title must not.
this.box.roundRect(0, 0, this.w, this.h, Theme.radius).fill({ color, alpha: 0.12 });
this.box.roundRect(0, 0, this.w, ANNOTATION_TITLE_HEIGHT, Theme.radius).fill(color);
this.box.rect(0, ANNOTATION_TITLE_HEIGHT - Theme.radius, this.w, Theme.radius).fill(color);
} else {
this.box.roundRect(0, 0, this.w, this.h, Theme.radius).fill(color);
}
this.box
.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius)
.stroke({ width: this.selected ? 2 : 1, color: this.selected ? c.nodeSelected : color, alpha: 0.9 });
// A corner that can be grabbed says so, in the text colour of the box it belongs to.
this.box
.moveTo(this.w - RESIZE_HANDLE, this.h - 2)
.lineTo(this.w - 2, this.h - RESIZE_HANDLE)
.moveTo(this.w - RESIZE_HANDLE / 2, this.h - 2)
.lineTo(this.w - 2, this.h - RESIZE_HANDLE / 2)
.stroke({ width: 1, color: text, alpha: 0.5 });
this.caption.style = this.captionStyle(text);
this.caption.text =
this.kind === 'group'
? truncate(this.annotation.text, this.caption.style, Math.max(this.w - PAD * 2, 1))
: this.annotation.text;
if (this.kind === 'group') this.caption.y = (ANNOTATION_TITLE_HEIGHT - this.caption.height) / 2;
}
/** What the last redraw put on screen, for the checks. */
drawnText(): string {
return this.caption.text;
}
}
+331 -9
View File
@@ -10,6 +10,16 @@ import {
} from '../../core/commands/graphCommands';
import { SetMachineStartCommand } from '../../core/commands/assetCommands';
import {
AddAnnotationCommand,
type AnnotationBox,
annotationNoun,
normalizeBox,
SetAnnotationBoxCommand,
} from '../../core/commands/annotationCommands';
import {
type AnnotationKind,
ANNOTATION_KINDS,
annotationsOf,
BEHAVIOR_PARAM,
callProblem,
canConnect,
@@ -18,11 +28,14 @@ import {
dropTargetPort,
edgesInto,
edgesOutOf,
findAnnotation,
firstPortFor,
ghostProblem,
type Graph,
type GraphAnnotation,
type GraphNode,
type GraphLookup,
MIN_ANNOTATION_SIZE,
type PortSide,
type PortType,
portTypeOf,
@@ -52,17 +65,27 @@ import {
type Size,
Theme,
textStyle,
toHex,
truncate,
ui,
Widget,
zoomFactor,
} from '../../ui';
import { type EditorState, nodeSelection } from '../state';
import { type Box, boxesInBand, dragOffset, frameBoxes } from '../framing';
import {
type Box,
boundsOf,
boxesCentredIn,
boxesInBand,
dragOffset,
frameBoxes,
resizeSize,
} from '../framing';
import { ANNOTATION_TITLE_HEIGHT, AnnotationView, RESIZE_HANDLE } from './AnnotationView';
import { arrowHead, bezier, EdgeLayer } from './EdgeLayer';
import { arrowShaft, type Point, transitionPreview } from './transitions';
import { openLinkMenu, openNodeMenu } from './graphMenus';
import { NodePalette } from './NodePalette';
import { openAnnotationMenu, openLinkMenu, openNodeMenu } from './graphMenus';
import { NodePalette, type PaletteAction } from './NodePalette';
import { type PaletteEntry, paletteEntries } from './paletteEntries';
import { NODE_WIDTH, NodeView, TITLE_HEIGHT } from './NodeView';
@@ -88,6 +111,11 @@ const FIT_MARGIN = 24;
const DRAG_THRESHOLD = 3;
/** Live values change every frame; ten updates a second are still readable and cost far less text layout. */
const LIVE_VALUE_INTERVAL_MS = 100;
/** A group or comment created out of nothing – large enough to drop a few nodes into. */
const NEW_GROUP_SIZE = { w: 320, h: 200 };
const NEW_NOTE_SIZE = { w: 220, h: 96 };
/** Air a group keeps around the nodes it was drawn around; the top also holds the title bar. */
const GROUP_MARGIN = 20;
/**
* The node graph of one behavior or state machine asset. Which one that is comes from the outside
@@ -98,6 +126,8 @@ export class GraphCanvas extends Widget {
private readonly grid = new Graphics();
private readonly clip = new Graphics();
private readonly canvas = new Container();
/** Behind the edges: a group is a backdrop, and a comment must never cover a node. */
private readonly annotationLayer = new Container();
private readonly edgeLayer = new EdgeLayer();
private readonly nodeLayer = new Container();
private readonly tempEdge = new Graphics();
@@ -105,6 +135,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 readonly annotations = new Map<string, AnnotationView>();
private assetId = '';
private pan = { x: 0, y: 0 };
private zoom = 1;
@@ -114,6 +145,21 @@ export class GraphCanvas extends Widget {
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.
*/
private annotationDrag: {
id: string;
kind: AnnotationKind;
mode: 'move' | 'resize';
start: AnnotationBox;
members: { nodeId: string; start: { x: number; y: number } }[];
nested: { id: string; kind: AnnotationKind; start: AnnotationBox }[];
pointer: { x: number; y: number };
moved: boolean;
} | null = null;
private snapping = false;
/** A rubber-band selection in progress, in graph coordinates. */
private marquee: {
@@ -156,7 +202,7 @@ export class GraphCanvas extends Widget {
private readonly noun = 'behavior',
) {
super();
this.canvas.addChild(this.edgeLayer, this.nodeLayer, this.tempEdge);
this.canvas.addChild(this.annotationLayer, this.edgeLayer, this.nodeLayer, this.tempEdge);
this.addChild(this.bg, this.grid, this.canvas, this.band, this.hint, this.clip);
this.canvas.mask = this.clip;
this.cursor = 'default';
@@ -242,9 +288,11 @@ export class GraphCanvas extends Widget {
this.cancelTransition();
for (const view of this.views.values()) view.destroy({ children: true });
this.views.clear();
this.clearAnnotations();
this.redraw();
return;
}
this.syncAnnotations(asset.graph);
const seen = new Set<string>();
const lookup = graphLookup(this.state.project);
// In a compiled graph a node's problem is the compiler's, not a call's.
@@ -281,6 +329,197 @@ export class GraphCanvas extends Widget {
this.redraw();
}
private clearAnnotations(): void {
for (const view of this.annotations.values()) view.destroy({ children: true });
this.annotations.clear();
}
/** Same shape as `sync`: views are reused by id, and a box that is gone takes its view with it. */
private syncAnnotations(graph: Graph): void {
const seen = new Set<string>();
for (const kind of ANNOTATION_KINDS) {
for (const annotation of annotationsOf(graph, kind)) {
seen.add(annotation.id);
let view = this.annotations.get(annotation.id);
if (!view || view.kind !== kind) {
view?.destroy({ children: true });
view = new AnnotationView(annotation, kind);
view.on('pointerdown', (e: FederatedPointerEvent) => this.onAnnotationPointerDown(view!, e));
this.annotations.set(annotation.id, view);
this.annotationLayer.addChild(view);
}
view.sync(annotation);
}
}
for (const [id, view] of this.annotations) {
if (!seen.has(id)) {
view.destroy({ children: true });
this.annotations.delete(id);
}
}
}
private onAnnotationPointerDown(view: AnnotationView, e: FederatedPointerEvent): void {
if (e.button === 1 || !this.assetId) return;
e.stopPropagation();
// An armed transition eats the click, exactly as it does over a node.
if (this.armed && (e.button === 0 || e.button === 2)) {
this.finishTransition(e.global.x, e.global.y, false);
return;
}
const id = view.annotation.id;
this.state.select({ kind: 'annotation', assetId: this.assetId, annotationId: id });
if (e.button === 2) {
openAnnotationMenu(this.state, this.assetId, view.kind, id, e.global.x, e.global.y);
return;
}
if (e.button !== 0) 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 resizing = view.handleAt(local.x, local.y);
this.annotationDrag = {
id,
kind: view.kind,
mode: resizing ? 'resize' : 'move',
start,
// A comment carries nothing, and resizing carries nothing either: membership is re-derived
// from where the box ends up, so a node the corner sweeps over simply belongs to it after.
members: view.kind === 'group' && !resizing ? this.nodeMembers(start) : [],
nested: view.kind === 'group' && !resizing ? this.nestedMembers(id, start) : [],
pointer,
moved: false,
};
}
/** 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;
const graph = this.asset()?.graph;
if (!drag || !graph) return;
const dx = g.x - drag.pointer.x;
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;
}
this.state.preview();
}
/** Puts the previewed boxes back and records the whole gesture as one undo step. */
private commitAnnotationDrag(): void {
const drag = this.annotationDrag;
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));
}
/** Adds a group or comment and selects it, so its title can be typed straight away. */
private addAnnotation(kind: AnnotationKind, box: AnnotationBox, text: string): void {
if (!this.assetId) return;
const command = new AddAnnotationCommand(this.assetId, kind, box, text);
if (this.state.execute(command))
this.state.select({
kind: 'annotation',
assetId: this.assetId,
annotationId: command.annotation.id,
});
}
/** A group drawn around the selected nodes, with room for the title bar above them. */
groupSelectedNodes(): void {
const ids = new Set(this.state.selectedNodeIds);
const bounds = boundsOf(this.nodeBoxes().filter((b) => ids.has(b.id)));
if (!bounds) return;
this.addAnnotation(
'group',
{
x: bounds.minX - GROUP_MARGIN,
y: bounds.minY - GROUP_MARGIN - ANNOTATION_TITLE_HEIGHT,
w: bounds.maxX - bounds.minX + GROUP_MARGIN * 2,
h: bounds.maxY - bounds.minY + GROUP_MARGIN * 2 + ANNOTATION_TITLE_HEIGHT,
},
'Group',
);
}
/**
* The one entry that marks a state as the machine's start, or takes the mark off it again – the
* same place, so it reads as the switch it is. Only a state machine has one to give.
@@ -438,13 +677,19 @@ export class GraphCanvas extends Widget {
if (!this.state.selectedNodeIds.includes(id)) this.state.select(nodeSelection(this.assetId, [id]));
const at = this.toLocal(e.global);
const graphPoint = this.screenToGraph(at.x, at.y);
const extras: MenuItem[] =
view.def.transitionEdges === true
const picked = this.state.selectedNodeIds.length;
const extras: MenuItem[] = [
...(view.def.transitionEdges === true
? [
{ label: 'Make transition', onSelect: () => this.armTransition(id, graphPoint) },
...this.startItems(id),
]
: [];
: []),
{
label: picked > 1 ? `Group these ${picked} nodes` : 'Group this node',
onSelect: () => this.groupSelectedNodes(),
},
];
openNodeMenu(this.state, this.assetId, this.selectedNodes(), e.global.x, e.global.y, extras);
}
return;
@@ -515,7 +760,9 @@ export class GraphCanvas extends Widget {
}
const local = this.toLocal(e.global);
const g = this.screenToGraph(local.x, local.y);
if (this.dragging) {
if (this.annotationDrag) {
this.previewAnnotationDrag(g);
} else if (this.dragging) {
const dx = g.x - this.dragging.pointer.x;
const dy = g.y - this.dragging.pointer.y;
if (!this.dragging.moved && Math.hypot(dx, dy) * this.zoom < DRAG_THRESHOLD) return;
@@ -593,6 +840,11 @@ export class GraphCanvas extends Widget {
return;
}
this.panning = null;
if (this.annotationDrag) {
this.commitAnnotationDrag();
this.redraw();
return;
}
const marquee = this.marquee;
this.marquee = null;
if (marquee && this.assetId) {
@@ -729,7 +981,32 @@ export class GraphCanvas extends Widget {
}
private openPalette(globalX: number, globalY: number, at: { x: number; y: number }): void {
new NodePalette(this.entries(), (entry) => this.addNode(entry, at)).open(globalX, globalY);
new NodePalette(
this.entries(),
(entry) => this.addNode(entry, at),
undefined,
this.annotationActions(at),
).open(globalX, globalY);
}
/** The two things the palette offers that are not nodes. Both land where it was opened. */
private annotationActions(at: { x: number; y: number }): PaletteAction[] {
return [
{
id: 'add-group',
label: 'Add group',
hint: 'box',
match: 'group box frame',
onSelect: () => this.addAnnotation('group', { ...at, ...NEW_GROUP_SIZE }, 'Group'),
},
{
id: 'add-comment',
label: 'Add comment',
hint: 'text',
match: 'comment note text',
onSelect: () => this.addAnnotation('note', { ...at, ...NEW_NOTE_SIZE }, 'Comment'),
},
];
}
private entries(): PaletteEntry[] {
@@ -864,6 +1141,12 @@ export class GraphCanvas extends Widget {
return t !== undefined && now - t <= TRACE_FADE;
};
const selectedNodes = new Set(this.state.selectedNodeIds);
const selectedAnnotation =
this.state.selection?.kind === 'annotation' ? this.state.selection.annotationId : '';
for (const view of this.annotations.values()) {
view.selected = view.annotation.id === selectedAnnotation;
view.redraw();
}
const target = this.dropTarget;
const wall = performance.now();
const values = trace && wall - this.valuesShownAt >= LIVE_VALUE_INTERVAL_MS ? trace.values : null;
@@ -1089,6 +1372,41 @@ export class GraphCanvas extends Widget {
return { x: g.x, y: g.y };
}
/** Every group and comment as it is drawn: the box, the text on it, and both its colours. */
annotationsDrawn(): {
id: string;
kind: AnnotationKind;
text: string;
color: string;
textColor: string;
selected: boolean;
x: number;
y: number;
w: number;
h: number;
}[] {
return [...this.annotations.values()].map((view) => ({
id: view.annotation.id,
kind: view.kind,
text: view.drawnText(),
color: toHex(view.color),
textColor: toHex(view.textColor),
selected: view.selected,
...boxOf(view.annotation),
}));
}
/** Where a group or comment is grabbed: its title bar, or – with `corner` – its resize handle. */
annotationGlobal(id: string, corner = false): { x: number; y: number } | null {
const view = this.annotations.get(id);
if (!view) return null;
const local = corner
? { x: view.w - RESIZE_HANDLE / 2, y: view.h - RESIZE_HANDLE / 2 }
: { x: view.w / 2, y: ANNOTATION_TITLE_HEIGHT / 2 };
const g = view.toGlobal(local);
return { x: g.x, y: g.y };
}
chevronGlobal(nodeId: string): { x: number; y: number } | null {
const g = this.views.get(nodeId)?.chevronGlobal();
return g ? { x: g.x, y: g.y } : null;
@@ -1103,6 +1421,10 @@ 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];
+51 -4
View File
@@ -16,6 +16,24 @@ export function currentNodePalette(): NodePalette | null {
/** Below this the list would jump in height with every keystroke while typing a query. */
const MIN_LIST_HEIGHT = 260;
/**
* Something the palette offers that is not a node: adding a group or a comment. They belong here
* because "right-click where it should go" is the one gesture the graph has for putting something
* on the canvas – a second gesture for two entries would be a second thing to know.
*/
export interface PaletteAction {
id: string;
label: string;
hint?: string;
/**
* What the search matches instead of the label. The label starts with "Add", and a query of
* "add" is one somebody types to find the `Add` node – two entries jumping the list for it
* would cost more than they help.
*/
match: string;
onSelect: () => void;
}
/** Searchable list of node types; right-click menu of the graph editor. */
export class NodePalette extends Popup {
private readonly search: TextField;
@@ -33,6 +51,8 @@ export class NodePalette extends Popup {
private readonly entries: PaletteEntry[],
private readonly onPick: (entry: PaletteEntry) => void,
private readonly accepts: (def: NodeDef) => boolean = () => true,
/** Listed above the nodes. An edge dropped into empty space passes none: it wants a node. */
private readonly actions: readonly PaletteAction[] = [],
) {
super({ padding: 4, gap: 4 });
this.minWidth = 240;
@@ -42,6 +62,11 @@ export class NodePalette extends Popup {
// Enter only. NOT onChange: that also fires when the search field loses focus because
// the user clicked a list row, which would add the first match *and* the clicked one.
onSubmit: () => {
const action = this.matchingActions()[0];
if (action) {
this.run(() => action.onSelect());
return;
}
const first = this.filtered[0];
if (first) this.pick(first);
},
@@ -53,6 +78,11 @@ export class NodePalette extends Popup {
this.list.rowHeight = Theme.rowHeight - 2;
this.list.emptyText = 'No matching node';
this.list.onSelect = (id) => {
const action = this.matchingActions().find((a) => a.id === id);
if (action) {
this.run(() => action.onSelect());
return;
}
const entry = this.filtered.find((e) => e.id === id);
if (entry) this.pick(entry);
};
@@ -71,9 +101,18 @@ export class NodePalette extends Popup {
super.close();
}
/** The offered nodes in the order they are listed, best answer first (test hook). */
/** What the palette offers, in the order it is listed – actions first, then the best node (test hook). */
rowsDrawn(): { label: string; type: string }[] {
return this.filtered.map((entry) => ({ label: entry.def.label, type: entry.def.type }));
return [
...this.matchingActions().map((a) => ({ label: a.label, type: a.id })),
...this.filtered.map((entry) => ({ label: entry.def.label, type: entry.def.type })),
];
}
/** Two entries need no ranking, only a yes or no. */
private matchingActions(): readonly PaletteAction[] {
const query = this.search.value.trim().toLowerCase();
return this.actions.filter((a) => a.match.toLowerCase().includes(query));
}
private refresh(): void {
@@ -81,7 +120,10 @@ export class NodePalette extends Popup {
this.entries.filter((e) => this.accepts(e.def)),
this.search.value,
);
this.list.setItems(this.filtered.map((e) => ({ id: e.id, label: e.def.label, hint: e.hint })));
this.list.setItems([
...this.matchingActions().map((a) => ({ id: a.id, label: a.label, hint: a.hint })),
...this.filtered.map((e) => ({ id: e.id, label: e.def.label, hint: e.hint })),
]);
this.scroll.minHeight = Math.min(
Math.max(this.list.measure().h, MIN_LIST_HEIGHT),
this.roomFor(this.scroll),
@@ -91,9 +133,14 @@ export class NodePalette extends Popup {
}
private pick(entry: PaletteEntry): void {
this.run(() => this.onPick(entry));
}
/** One pick per opening, whatever was picked: Enter and a row click can race. */
private run(chosen: () => void): void {
if (this.picked) return;
this.picked = true;
this.close();
this.onPick(entry);
chosen();
}
}
+24 -2
View File
@@ -1,9 +1,10 @@
import type { Command } from '../../core/commands/Command';
import { oneStep } from '../../core/commands/CompoundCommand';
import { CollapseNodesCommand, DisconnectCommand, FoldEdgeCommand } from '../../core/commands/graphCommands';
import type { GraphEdge, GraphNode } from '../../core/graph';
import { annotationNoun } from '../../core/commands/annotationCommands';
import type { AnnotationKind, GraphEdge, GraphNode } from '../../core/graph';
import { Menu, type MenuItem } from '../../ui';
import { removeNodes } from '../actions';
import { removeAnnotation, removeNodes } from '../actions';
import type { EditorState } from '../state';
/** For the selected nodes; a selection mixing both states offers both directions. */
@@ -36,6 +37,27 @@ export function openNodeMenu(
]).open(x, y);
}
/**
* For one group or comment. Only deleting lives here: the title, the text and the colour are
* edited in the inspector, where every other selected thing is edited too.
*/
export function openAnnotationMenu(
state: EditorState,
assetId: string,
kind: AnnotationKind,
id: string,
x: number,
y: number,
): void {
new Menu([
{
label: `Delete ${annotationNoun(kind)}`,
danger: true,
onSelect: () => removeAnnotation(state, assetId, id),
},
]).open(x, y);
}
/**
* The links at one port, or behind one fold mark – the mark is the only handle a hidden link has,
* so it carries the way back. Every entry is one undo step, however many links it covers.
+6 -1
View File
@@ -2,6 +2,7 @@ import type { PropertyBag } from '../../core/model/Properties';
import { Label, Panel, ScrollContainer, Theme, ui, VStack } from '../../ui';
import { type EditorState, sameSelection, type Selection } from '../state';
import { assetSignature, buildAssetSection, refreshAssetSection } from './assetSection';
import { annotationSignature, buildAnnotationSection, refreshAnnotationSection } from './annotationSection';
import { buildFrameSection, frameSignature, refreshFrameSection } from './frameSection';
import type { InspectorHost } from './host';
import { buildNodeSection, nodeSignature, refreshNodeSection } from './nodeSection';
@@ -88,6 +89,7 @@ export class Inspector extends Panel {
else if (s.kind === 'asset') buildAssetSection(this.host, s.assetId);
else if (s.kind === 'node') buildNodeSection(this.host, s.assetId, s.nodeId);
else if (s.kind === 'frame') buildFrameSection(this.host, s.assetId, s.frameId);
else if (s.kind === 'annotation') buildAnnotationSection(this.host, s.assetId, s.annotationId);
else if (s.objectIds.length > 1) buildObjectsSection(this.host, s.holderId, s.objectIds);
else buildObjectSection(this.host, s.holderId, s.objectId);
this.scroll.setContent(this.host.body);
@@ -99,6 +101,7 @@ export class Inspector extends Panel {
if (s.kind === 'asset') return assetSignature(this.state.project, s.assetId);
if (s.kind === 'node') return nodeSignature(this.state.project, s.assetId, s.nodeId);
if (s.kind === 'frame') return frameSignature(this.state.project, s.assetId, s.frameId);
if (s.kind === 'annotation') return annotationSignature(this.state.project, s.assetId, s.annotationId);
return objectSignature(this.state.project, s.holderId, s.objectIds, s.objectId);
}
@@ -124,7 +127,9 @@ export class Inspector extends Panel {
? refreshNodeSection(this.host, s.assetId, s.nodeId)
: s.kind === 'frame'
? refreshFrameSection(this.host, s.assetId, s.frameId)
: s.objectIds.length > 1 || refreshObjectSection(this.host, s.holderId, s.objectId);
: s.kind === 'annotation'
? refreshAnnotationSection(this.host, s.assetId, s.annotationId)
: s.objectIds.length > 1 || refreshObjectSection(this.host, s.holderId, s.objectId);
if (!ok) this.rebuild();
}
}
+121
View File
@@ -0,0 +1,121 @@
import {
annotationNoun,
RemoveAnnotationCommand,
SetAnnotationColorCommand,
SetAnnotationTextCommand,
} from '../../core/commands/annotationCommands';
import type { Command } from '../../core/commands/Command';
import { type AnnotationKind, findAnnotation, type GraphAnnotation } from '../../core/graph';
import { findGraphAsset, type Project } from '../../core/model/Project';
import { type PropertyValue, textOf } from '../../core/model/Properties';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Button, Theme } from '../../ui';
import { createFieldRow } from './fieldFactory';
import { addGap, addNote, addTitle, type InspectorHost } from './host';
/** The key the colour field is registered under, so a live refresh finds it again. */
const COLOR_KEY = 'annotation-color';
const TEXT_KEY = 'annotation-text';
/**
* Structure of a group/comment selection: its kind decides the wording, and whether it carries a
* colour of its own decides whether the reset button is there.
*/
export function annotationSignature(project: Project, assetId: string, annotationId: string): string {
const found = current(project, assetId, annotationId);
if (!found) return 'missing';
return `${found.kind}:${found.annotation.color === undefined ? 'default' : 'own'}`;
}
/** Title (or text), colour, and the way to delete the box without touching what it frames. */
export function buildAnnotationSection(host: InspectorHost, assetId: string, annotationId: string): void {
const found = current(host.state.project, assetId, annotationId);
if (!found) return;
const { kind, annotation } = found;
const noun = annotationNoun(kind);
addTitle(host, noun.toUpperCase());
addRow(
host,
{ key: TEXT_KEY, label: kind === 'group' ? 'Title' : 'Text', kind: 'text', default: '' },
annotation.text,
(value) => new SetAnnotationTextCommand(assetId, kind, annotationId, textOf(value)),
);
addRow(
host,
{ key: COLOR_KEY, label: 'Color', kind: 'color', default: Theme.colors.annotation },
annotation.color ?? Theme.colors.annotation,
(value) => new SetAnnotationColorCommand(assetId, kind, annotationId, Number(value)),
);
addNote(
host,
'The text is drawn black or white, whichever reads better on this colour – so any colour stays legible.',
'small',
);
if (annotation.color !== undefined)
host.body.add(
new Button('Use the default color', {
onClick: () => host.state.execute(new SetAnnotationColorCommand(assetId, kind, annotationId, null)),
}),
);
addGap(host);
addNote(host, describe(kind));
addGap(host);
host.body.add(
new Button(`Delete ${noun}`, {
variant: 'danger',
onClick: () => host.state.execute(new RemoveAnnotationCommand(assetId, kind, annotationId)),
}),
);
}
/** Pushes the current text and colour into the rows. False when the box is gone (rebuild). */
export function refreshAnnotationSection(
host: InspectorHost,
assetId: string,
annotationId: string,
): boolean {
const found = current(host.state.project, assetId, annotationId);
if (!found) return false;
host.fields.get(TEXT_KEY)?.setValue(found.annotation.text);
host.fields.get(COLOR_KEY)?.setValue(found.annotation.color ?? Theme.colors.annotation);
return true;
}
/**
* One row, committed on change only: a colour dragged in the picker fires continuously, and an
* undo entry per pixel of the hue bar would bury everything that came before it.
*/
function addRow(
host: InspectorHost,
def: PropertyDef,
value: PropertyValue,
commit: (v: PropertyValue) => Command,
): void {
const row = createFieldRow(host, def, value, {
onInput: () => {},
onChange: (next) => void host.state.execute(commit(next)),
});
host.fields.set(def.key, row);
host.body.add(row.row);
}
function describe(kind: AnnotationKind): string {
return kind === 'group'
? 'Nodes whose centre lies in the box move with it. Deleting the group leaves them where they are.'
: 'A comment is read, not run: it frames nothing and moves alone.';
}
/**
* Looked up inside the selected graph rather than by id alone, so a selection left over from a
* deleted asset cannot edit whichever box later inherits the id.
*/
function current(
project: Project,
assetId: string,
annotationId: string,
): { kind: AnnotationKind; annotation: GraphAnnotation } | null {
const graph = findGraphAsset(project, assetId)?.graph;
return graph ? findAnnotation(graph, annotationId) : null;
}
+2 -1
View File
@@ -1,6 +1,6 @@
import { RemoveFrameCommand } from '../core/commands/frameCommands';
import { ui } from '../ui';
import { confirmDeleteAsset, createContainer, removeNodes, removeObjects } from './actions';
import { confirmDeleteAsset, createContainer, removeAnnotation, removeNodes, removeObjects } from './actions';
import type { EditorState } from './state';
export interface ShortcutActions {
@@ -40,6 +40,7 @@ export function installShortcuts(state: EditorState, actions: ShortcutActions):
if (s?.kind === 'object') removeObjects(state, s.holderId, s.objectIds);
else if (s?.kind === 'node') removeNodes(state, s.assetId, s.nodeIds);
else if (s?.kind === 'frame') state.execute(new RemoveFrameCommand(s.assetId, s.frameId));
else if (s?.kind === 'annotation') removeAnnotation(state, s.assetId, s.annotationId);
else if (s?.kind === 'asset') confirmDeleteAsset(state, s.assetId);
return s !== null;
}
+16 -2
View File
@@ -1,6 +1,7 @@
import { type Command, CommandError } from '../core/commands/Command';
import { CommandStack } from '../core/commands/CommandStack';
import type { Asset, SceneAsset } from '../core/model/Asset';
import { findAnnotation } from '../core/graph';
import { findObject } from '../core/model/hierarchy';
import { resolvedHolder } from '../core/model/instance';
import {
@@ -30,6 +31,8 @@ export type Selection =
| { kind: 'node'; assetId: string; nodeId: string; nodeIds: string[] }
/** A frame rectangle inside a texture asset (the Texture tab). */
| { kind: 'frame'; assetId: string; frameId: string }
/** One group or comment of a graph – never several, since neither carries the other. */
| { kind: 'annotation'; assetId: string; annotationId: string }
| null;
/**
@@ -369,6 +372,13 @@ export class EditorState {
this.select(nodeSelection(s.assetId, alive, s.nodeId) ?? { kind: 'asset', assetId: s.assetId });
}
}
if (s.kind === 'annotation') {
const asset = findGraphAsset(this.project, s.assetId);
// Same fallback as a node: the graph stays open when the box it held vanished (undo).
if (!asset) this.select(null);
else if (!findAnnotation(asset.graph, s.annotationId))
this.select({ kind: 'asset', assetId: s.assetId });
}
if (s.kind === 'frame') {
const asset = findAssetOf(this.project, s.assetId, 'texture');
// Same fallback as a graph node: the texture stays open when its frame vanished.
@@ -415,8 +425,10 @@ export class EditorState {
const s = this.selection;
if (!s) return null;
if (s.kind === 'frame') return null;
// A node names its asset; which tab that is depends on what the asset is.
const asset = s.kind === 'node' ? findAsset(this.project, s.assetId) : this.assetBehind(s);
// A node names its asset; which tab that is depends on what the asset is. A group or comment
// names it the same way, so selecting one keeps its graph on screen.
const asset =
s.kind === 'node' || s.kind === 'annotation' ? findAsset(this.project, s.assetId) : this.assetBehind(s);
return asset && asset.type === type ? asset.id : null;
}
@@ -481,5 +493,7 @@ export function sameSelection(a: Selection, b: Selection): boolean {
);
}
if (a.kind === 'frame' && b.kind === 'frame') return a.assetId === b.assetId && a.frameId === b.frameId;
if (a.kind === 'annotation' && b.kind === 'annotation')
return a.assetId === b.assetId && a.annotationId === b.annotationId;
return false;
}