diff --git a/src/core/commands/annotationCommands.ts b/src/core/commands/annotationCommands.ts new file mode 100644 index 00000000..52ffb151 --- /dev/null +++ b/src/core/commands/annotationCommands.ts @@ -0,0 +1,184 @@ +/** + * Groups and comments: boxes drawn behind a graph. Both kinds take the same commands, because they + * hold the same thing – which list they live in is the whole difference, and it is a parameter. + */ +import { + type AnnotationKind, + annotationList, + annotationsOf, + type GraphAnnotation, + MIN_ANNOTATION_SIZE, +} from '../graph/Graph'; +import { newId } from '../ids'; +import type { Project } from '../model/Project'; +import { type Removed, removeWhere, restoreAt } from '../util/list'; +import { type Command, CommandError } from './Command'; +import { requireGraph } from './require'; + +/** What each kind is called where a user reads it – a "note" is a word only the model uses. */ +export function annotationNoun(kind: AnnotationKind): string { + return kind === 'group' ? 'group' : 'comment'; +} + +export interface AnnotationBox { + x: number; + y: number; + w: number; + h: number; +} + +/** Rounded to whole units and never smaller than the minimum, wherever a box comes from. */ +export function normalizeBox(box: AnnotationBox): AnnotationBox { + return { + x: Math.round(box.x), + y: Math.round(box.y), + w: Math.max(Math.round(box.w), MIN_ANNOTATION_SIZE.w), + h: Math.max(Math.round(box.h), MIN_ANNOTATION_SIZE.h), + }; +} + +function requireAnnotation( + project: Project, + assetId: string, + kind: AnnotationKind, + id: string, +): GraphAnnotation { + const found = annotationsOf(requireGraph(project, assetId), kind).find((a) => a.id === id); + if (!found) throw new CommandError(`${annotationNoun(kind)} ${id} not found`); + return found; +} + +export class AddAnnotationCommand implements Command { + readonly label: string; + readonly annotation: GraphAnnotation; + + constructor( + private readonly assetId: string, + private readonly kind: AnnotationKind, + box: AnnotationBox, + text: string, + ) { + this.annotation = { id: newId(), ...normalizeBox(box), text }; + this.label = `Add ${annotationNoun(kind)}`; + } + + execute(project: Project): void { + annotationList(requireGraph(project, this.assetId), this.kind).push(this.annotation); + } + + undo(project: Project): void { + const list = annotationList(requireGraph(project, this.assetId), this.kind); + removeWhere(list, (a) => a.id === this.annotation.id); + } +} + +/** + * Takes the box away and nothing else: the nodes it framed stay where they are. A box that + * silently deleted ten nodes with it would be the most expensive surprise in the editor – whoever + * wants the nodes gone selects them and presses Delete, which the graph could already do. + */ +export class RemoveAnnotationCommand implements Command { + readonly label: string; + private removed: Removed[] = []; + + constructor( + private readonly assetId: string, + private readonly kind: AnnotationKind, + private readonly id: string, + ) { + this.label = `Delete ${annotationNoun(kind)}`; + } + + execute(project: Project): void { + const list = annotationList(requireGraph(project, this.assetId), this.kind); + this.removed = removeWhere(list, (a) => a.id === this.id); + if (this.removed.length === 0) + throw new CommandError(`${annotationNoun(this.kind)} ${this.id} not found`); + } + + undo(project: Project): void { + restoreAt(annotationList(requireGraph(project, this.assetId), this.kind), this.removed); + } +} + +/** + * Move and resize in one command, since both are the same edit to the same four numbers. As with + * `MoveNodeCommand` the previous box is read at execution time, so a caller that previewed the + * drag must put the box back before executing. + */ +export class SetAnnotationBoxCommand implements Command { + readonly label: string; + private previous: AnnotationBox = { x: 0, y: 0, w: 0, h: 0 }; + + constructor( + private readonly assetId: string, + private readonly kind: AnnotationKind, + private readonly id: string, + private readonly box: AnnotationBox, + label?: string, + ) { + this.label = label ?? `Move ${annotationNoun(kind)}`; + } + + execute(project: Project): void { + const a = requireAnnotation(project, this.assetId, this.kind, this.id); + this.previous = { x: a.x, y: a.y, w: a.w, h: a.h }; + Object.assign(a, normalizeBox(this.box)); + } + + undo(project: Project): void { + Object.assign(requireAnnotation(project, this.assetId, this.kind, this.id), this.previous); + } +} + +export class SetAnnotationTextCommand implements Command { + readonly label: string; + private previous = ''; + + constructor( + private readonly assetId: string, + private readonly kind: AnnotationKind, + private readonly id: string, + private readonly text: string, + ) { + this.label = kind === 'group' ? 'Set title' : 'Set comment'; + } + + execute(project: Project): void { + const a = requireAnnotation(project, this.assetId, this.kind, this.id); + this.previous = a.text; + a.text = this.text; + } + + undo(project: Project): void { + requireAnnotation(project, this.assetId, this.kind, this.id).text = this.previous; + } +} + +/** `null` clears the colour, which is how a box goes back to the theme's default. */ +export class SetAnnotationColorCommand implements Command { + readonly label = 'Set color'; + private previous: number | null = null; + + constructor( + private readonly assetId: string, + private readonly kind: AnnotationKind, + private readonly id: string, + private readonly color: number | null, + ) {} + + execute(project: Project): void { + const a = requireAnnotation(project, this.assetId, this.kind, this.id); + this.previous = a.color ?? null; + apply(a, this.color); + } + + undo(project: Project): void { + apply(requireAnnotation(project, this.assetId, this.kind, this.id), this.previous); + } +} + +function apply(annotation: GraphAnnotation, color: number | null): void { + if (color === null) delete annotation.color; + else annotation.color = color; +} diff --git a/src/core/graph/Graph.ts b/src/core/graph/Graph.ts index 12149ada..8fb2fd0c 100644 --- a/src/core/graph/Graph.ts +++ b/src/core/graph/Graph.ts @@ -57,15 +57,71 @@ export interface GraphEdge { label?: string; } +/** + * A box drawn behind the nodes, carrying a line of text and a colour. Two kinds share this shape + * because they differ in what they *mean*, not in what they hold: a group frames the nodes whose + * centre lies in it and takes them along when moved, a comment frames nothing and is read. + * Purely visual either way – no interpreter ever looks at one. + */ +export interface GraphAnnotation { + id: string; + x: number; + y: number; + w: number; + h: number; + /** A group's title line; a comment's body, which wraps. */ + text: string; + /** 0xRRGGBB. Absent means the theme's default, so a project never stores a colour nobody chose. */ + color?: number; +} + +/** Which of the two lists an annotation lives in – the only thing that tells them apart. */ +export type AnnotationKind = 'group' | 'note'; + +/** Smallest box either kind may be dragged down to; below this the text has nowhere to go. */ +export const MIN_ANNOTATION_SIZE = { w: 80, h: 48 }; + export interface Graph { nodes: GraphNode[]; edges: GraphEdge[]; + /** Titled boxes that carry what they enclose. Absent means none – an old document says exactly that. */ + groups?: GraphAnnotation[]; + notes?: GraphAnnotation[]; } export function createEmptyGraph(): Graph { return { nodes: [], edges: [] }; } +/** The list of one kind, created on demand: a graph without annotations stores neither array. */ +export function annotationList(graph: Graph, kind: AnnotationKind): GraphAnnotation[] { + const list = kind === 'group' ? graph.groups : graph.notes; + if (list) return list; + const fresh: GraphAnnotation[] = []; + if (kind === 'group') graph.groups = fresh; + else graph.notes = fresh; + return fresh; +} + +/** Reads without creating, so drawing and lookups leave an untouched graph untouched. */ +export function annotationsOf(graph: Graph, kind: AnnotationKind): readonly GraphAnnotation[] { + return (kind === 'group' ? graph.groups : graph.notes) ?? []; +} + +/** An annotation by id, whichever list holds it. Ids are unique across both. */ +export function findAnnotation( + graph: Graph, + id: string, +): { kind: AnnotationKind; annotation: GraphAnnotation } | null { + for (const kind of ANNOTATION_KINDS) { + const annotation = annotationsOf(graph, kind).find((a) => a.id === id); + if (annotation) return { kind, annotation }; + } + return null; +} + +export const ANNOTATION_KINDS: readonly AnnotationKind[] = ['group', 'note']; + export function findNode(graph: Graph, nodeId: string): GraphNode | undefined { return graph.nodes.find((n) => n.id === nodeId); } diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index 45ee37db..0c96a8bf 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -1,7 +1,11 @@ // Importing the graph package index is what registers the built-in nodes. import { + ANNOTATION_KINDS, + annotationList, createEmptyGraph, type DeclaredPort, + type GraphAnnotation, + MIN_ANNOTATION_SIZE, type DeclaredPortKind, type Graph, type GraphEdge, @@ -369,9 +373,40 @@ function normalizeGraph(raw: unknown, keep?: (def: NodeDef) => boolean): Graph { if (label) edge.label = label; graph.edges.push(edge); } + for (const kind of ANNOTATION_KINDS) { + const list = normalizeAnnotations(g[kind === 'group' ? 'groups' : 'notes'], nodeIds); + if (list.length > 0) annotationList(graph, kind).push(...list); + } return graph; } +/** + * Annotations share the id space with nodes and with each other: a duplicate would make one + * selection mean two boxes. A size below the minimum is raised rather than dropped – a box nobody + * can see is worse than one that is a little larger than it was written. + */ +function normalizeAnnotations(raw: unknown, takenIds: Set): GraphAnnotation[] { + const list: GraphAnnotation[] = []; + for (const item of Array.isArray(raw) ? raw : []) { + const a = asRecord(item); + const id = typeof a.id === 'string' ? a.id : ''; + if (!id || takenIds.has(id)) continue; + takenIds.add(id); + const annotation: GraphAnnotation = { + id, + x: toNumber(a.x), + y: toNumber(a.y), + w: Math.max(toNumber(a.w), MIN_ANNOTATION_SIZE.w), + h: Math.max(toNumber(a.h), MIN_ANNOTATION_SIZE.h), + text: typeof a.text === 'string' ? a.text : '', + }; + if (typeof a.color === 'number' && Number.isFinite(a.color)) + annotation.color = Math.max(0, Math.min(0xffffff, Math.floor(a.color))); + list.push(annotation); + } + return list; +} + /** * A file name that is not the server's content-addressed form is dropped: a request built from it * would 404, and the texture then loads as "no picture". diff --git a/src/editor/actions.ts b/src/editor/actions.ts index f31d4094..96fd82e9 100644 --- a/src/editor/actions.ts +++ b/src/editor/actions.ts @@ -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); diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index f6249ab5..103ad59c 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -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), diff --git a/src/editor/framing.ts b/src/editor/framing.ts index 9f63cc7d..f0951e1d 100644 --- a/src/editor/framing.ts +++ b/src/editor/framing.ts @@ -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, + 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): 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; diff --git a/src/editor/graph/AnnotationView.ts b/src/editor/graph/AnnotationView.ts new file mode 100644 index 00000000..513e89ec --- /dev/null +++ b/src/editor/graph/AnnotationView.ts @@ -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; + } +} diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index d5abc0c5..9c86eb79 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -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(); + private readonly annotations = new Map(); 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(); 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(); + 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]; diff --git a/src/editor/graph/NodePalette.ts b/src/editor/graph/NodePalette.ts index 8b0d6997..0d81335c 100644 --- a/src/editor/graph/NodePalette.ts +++ b/src/editor/graph/NodePalette.ts @@ -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(); } } diff --git a/src/editor/graph/graphMenus.ts b/src/editor/graph/graphMenus.ts index eab09cf2..756d54ef 100644 --- a/src/editor/graph/graphMenus.ts +++ b/src/editor/graph/graphMenus.ts @@ -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. diff --git a/src/editor/inspector/Inspector.ts b/src/editor/inspector/Inspector.ts index 06be63ab..e263bc57 100644 --- a/src/editor/inspector/Inspector.ts +++ b/src/editor/inspector/Inspector.ts @@ -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(); } } diff --git a/src/editor/inspector/annotationSection.ts b/src/editor/inspector/annotationSection.ts new file mode 100644 index 00000000..6013bb7e --- /dev/null +++ b/src/editor/inspector/annotationSection.ts @@ -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; +} diff --git a/src/editor/shortcuts.ts b/src/editor/shortcuts.ts index 466dcab0..2e203f0c 100644 --- a/src/editor/shortcuts.ts +++ b/src/editor/shortcuts.ts @@ -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; } diff --git a/src/editor/state.ts b/src/editor/state.ts index 5e2b3651..64897f3b 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -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; } diff --git a/src/ui/Theme.ts b/src/ui/Theme.ts index bba1d2c0..8e142e66 100644 --- a/src/ui/Theme.ts +++ b/src/ui/Theme.ts @@ -70,6 +70,8 @@ export const Theme = { portAny: 0xbdbdbd, /** Ring around the port a running edge drag would connect to. */ portTarget: 0xffffff, + /** Groups and comments that were never given a colour of their own. */ + annotation: 0x3c4352, hot: 0xffc857, }, } as const; diff --git a/src/ui/color.ts b/src/ui/color.ts index 5aaa7007..ff4d8272 100644 --- a/src/ui/color.ts +++ b/src/ui/color.ts @@ -22,6 +22,28 @@ export function parseHex(text: string): number | null { return null; } +/** + * Relative luminance (WCAG): the perceived brightness of a colour, 0 for black and 1 for white. + * Green weighs most because the eye sees it best – an average of the channels would call a + * saturated blue as bright as a yellow. + */ +export function luminance(color: number): number { + const channel = (shift: number) => { + const v = ((color >> shift) & 0xff) / 255; + return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * channel(16) + 0.7152 * channel(8) + 0.0722 * channel(0); +} + +/** + * Black or white, whichever is easier to read on `background`. The threshold is where the two + * contrast ratios meet, not 0.5: white on mid grey is worse than black on it. This is what lets a + * free colour picker stay safe – whatever colour is chosen, its text is still legible. + */ +export function readableTextColor(background: number): number { + return luminance(background) > 0.179 ? 0x000000 : 0xffffff; +} + /** Greys report hue 0 and saturation 0, so a round trip through `hsvToRgb` is exact. */ export function rgbToHsv(color: number): HSV { const r = ((color >> 16) & 0xff) / 255; diff --git a/src/ui/index.ts b/src/ui/index.ts index a425121e..caa55a7d 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -38,5 +38,15 @@ export { TextField, type TextFieldOptions } from './widgets/TextField'; export { NumberField } from './widgets/NumberField'; export { ColorField } from './widgets/ColorField'; export { ColorPicker } from './widgets/ColorPicker'; -export { type HSV, hsvToRgb, paintHueBar, paintSvSquare, parseHex, rgbToHsv, toHex } from './color'; +export { + type HSV, + hsvToRgb, + luminance, + paintHueBar, + paintSvSquare, + parseHex, + readableTextColor, + rgbToHsv, + toHex, +} from './color'; export { pressCount } from './clicks'; diff --git a/tests/core/graph-annotations.test.ts b/tests/core/graph-annotations.test.ts new file mode 100644 index 00000000..ec1916ea --- /dev/null +++ b/tests/core/graph-annotations.test.ts @@ -0,0 +1,160 @@ +/** + * A graph that has grown needs somewhere to say what a corner of it is for. Two things say it: a + * group, a titled box that takes what it encloses along when it is moved, and a comment, a card of + * free text that frames nothing. Both are drawing only – no interpreter ever reads one – and both + * survive a save, which is the whole point of writing them down. + */ +import { describe, expect, it } from 'vitest'; +import { + AddAnnotationCommand, + RemoveAnnotationCommand, + SetAnnotationBoxCommand, + SetAnnotationColorCommand, + SetAnnotationTextCommand, +} from '../../src/core/commands/annotationCommands'; +import { AddNodeCommand } from '../../src/core/commands/graphCommands'; +import { annotationsOf, findAnnotation, type Graph } from '../../src/core/graph'; +import { findGraphAsset } from '../../src/core/model/Project'; +import { boxesCentredIn } from '../../src/editor/framing'; +import { luminance, readableTextColor } from '../../src/ui/color'; +import { makeBehavior, makeProject, roundTrip } from '../helpers'; + +function fixture() { + const f = makeProject(); + const behavior = makeBehavior(f, 'B'); + const graphOf = (): Graph => findGraphAsset(f.project, behavior.id)!.graph; + return { ...f, behaviorId: behavior.id, graphOf }; +} + +describe('a group and a comment', () => { + it('are two lists of the same shape, and an untouched graph holds neither', () => { + const f = fixture(); + expect(f.graphOf().groups).toBeUndefined(); + expect(f.graphOf().notes).toBeUndefined(); + + const group = new AddAnnotationCommand(f.behaviorId, 'group', { x: 10, y: 20, w: 300, h: 200 }, 'Setup'); + const note = new AddAnnotationCommand(f.behaviorId, 'note', { x: 0, y: 0, w: 200, h: 90 }, 'Careful'); + f.stack.execute(group); + f.stack.execute(note); + + expect(annotationsOf(f.graphOf(), 'group').map((a) => a.text)).toEqual(['Setup']); + expect(annotationsOf(f.graphOf(), 'note').map((a) => a.text)).toEqual(['Careful']); + // Found by id alone: the selection knows an id, not which of the two lists holds it. + expect(findAnnotation(f.graphOf(), note.annotation.id)?.kind).toBe('note'); + expect(group.label).toBe('Add group'); + expect(note.label).toBe('Add comment'); + }); + + it('are refused a box smaller than the minimum, wherever the numbers come from', () => { + const f = fixture(); + const add = new AddAnnotationCommand(f.behaviorId, 'group', { x: 0, y: 0, w: 4, h: 4 }, 'Tiny'); + f.stack.execute(add); + const box = findAnnotation(f.graphOf(), add.annotation.id)!.annotation; + expect(box.w).toBeGreaterThanOrEqual(80); + expect(box.h).toBeGreaterThanOrEqual(48); + + f.stack.execute( + new SetAnnotationBoxCommand(f.behaviorId, 'group', add.annotation.id, { + x: 5, + y: 5, + w: 1, + h: 1, + }), + ); + expect(findAnnotation(f.graphOf(), add.annotation.id)!.annotation.w).toBeGreaterThanOrEqual(80); + }); + + it('take their text and colour back on undo, and a cleared colour means the default again', () => { + const f = fixture(); + const add = new AddAnnotationCommand(f.behaviorId, 'group', { x: 0, y: 0, w: 200, h: 120 }, 'Setup'); + f.stack.execute(add); + const id = add.annotation.id; + const current = () => findAnnotation(f.graphOf(), id)!.annotation; + + f.stack.execute(new SetAnnotationTextCommand(f.behaviorId, 'group', id, 'Movement')); + expect(current().text).toBe('Movement'); + f.stack.undo(); + expect(current().text).toBe('Setup'); + + f.stack.execute(new SetAnnotationColorCommand(f.behaviorId, 'group', id, 0x336699)); + expect(current().color).toBe(0x336699); + // Absent, not zero: black is a colour somebody could choose, "none" is not. + f.stack.execute(new SetAnnotationColorCommand(f.behaviorId, 'group', id, null)); + expect(current().color).toBeUndefined(); + f.stack.undo(); + expect(current().color).toBe(0x336699); + }); + + it('give the nodes back when deleted – the box goes, what it framed stays', () => { + const f = fixture(); + const node = new AddNodeCommand(f.behaviorId, 'OnStart', 40, 40); + f.stack.execute(node); + const add = new AddAnnotationCommand(f.behaviorId, 'group', { x: 0, y: 0, w: 300, h: 200 }, 'Setup'); + f.stack.execute(add); + + f.stack.execute(new RemoveAnnotationCommand(f.behaviorId, 'group', add.annotation.id)); + expect(annotationsOf(f.graphOf(), 'group')).toHaveLength(0); + expect(f.graphOf().nodes.map((n) => n.id)).toEqual([node.node.id]); + + f.stack.undo(); + expect(annotationsOf(f.graphOf(), 'group').map((a) => a.text)).toEqual(['Setup']); + }); + + it('survive a save, and a file that invents fields or repeats an id does not', () => { + const f = fixture(); + const add = new AddAnnotationCommand(f.behaviorId, 'group', { x: 10, y: 20, w: 300, h: 200 }, 'Setup'); + f.stack.execute(add); + f.stack.execute(new SetAnnotationColorCommand(f.behaviorId, 'group', add.annotation.id, 0x336699)); + + const back = roundTrip(f.project); + const group = annotationsOf(findGraphAsset(back, f.behaviorId)!.graph, 'group')[0]!; + expect(group).toEqual({ + id: add.annotation.id, + x: 10, + y: 20, + w: 300, + h: 200, + text: 'Setup', + color: 0x336699, + }); + + // Straight from an untrusted file: the second box repeats the id, the third invents a key. + const graph = findGraphAsset(f.project, f.behaviorId)!.graph; + graph.groups = [ + { id: 'g1', x: 0, y: 0, w: 200, h: 120, text: 'One' }, + { id: 'g1', x: 0, y: 0, w: 200, h: 120, text: 'Again' }, + { id: 'g2', x: 0, y: 0, w: 200, h: 120, text: 'Two', danger: 'yes' }, + ] as never; + const cleaned = annotationsOf(findGraphAsset(roundTrip(f.project), f.behaviorId)!.graph, 'group'); + expect(cleaned.map((a) => a.text)).toEqual(['One', 'Two']); + expect(cleaned[1]).not.toHaveProperty('danger'); + }); +}); + +describe('what a group carries', () => { + const box = { x: 0, y: 0, w: 100, h: 100 }; + + it('is what the eye says: the node whose centre lies inside, overlap or not', () => { + const inside = { id: 'in', x: 10, y: 10, w: 40, h: 20 }; + // Its left edge is in the box, its centre is not – the eye calls it "next to the group". + const touching = { id: 'touch', x: 90, y: 10, w: 60, h: 20 }; + const outside = { id: 'out', x: 400, y: 400, w: 40, h: 20 }; + expect(boxesCentredIn([inside, touching, outside], box)).toEqual(['in']); + }); + + it('includes a box sitting exactly on the border, so nothing falls between the two answers', () => { + expect(boxesCentredIn([{ id: 'edge', x: 80, y: 80, w: 40, h: 40 }], box)).toEqual(['edge']); + }); +}); + +describe('the text colour of a box', () => { + it('is the one that reads on it, which is what lets the picker run free', () => { + expect(readableTextColor(0x000000)).toBe(0xffffff); + expect(readableTextColor(0xffffff)).toBe(0x000000); + // The two colours a free picker makes dangerous: a saturated yellow is bright, a blue is not. + expect(readableTextColor(0xffff00)).toBe(0x000000); + expect(readableTextColor(0x0000ff)).toBe(0xffffff); + expect(luminance(0x000000)).toBe(0); + expect(luminance(0xffffff)).toBeCloseTo(1, 5); + }); +}); diff --git a/tests/editor/graph-framing.test.ts b/tests/editor/graph-framing.test.ts index 51efd867..eec73c25 100644 --- a/tests/editor/graph-framing.test.ts +++ b/tests/editor/graph-framing.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from 'vitest'; -import { boundsOf, type Box, boxesInBand, dragOffset, frameBoxes } from '../../src/editor/framing'; +import { + boundsOf, + type Box, + boxesInBand, + dragOffset, + frameBoxes, + resizeSize, +} from '../../src/editor/framing'; const LIMITS = { min: 0.25, max: 2.5 }; @@ -61,6 +68,37 @@ describe('drag offset', () => { }); }); +describe('resize size', () => { + const STEP = 24; + const MIN = { w: 80, h: 48 }; + + it('follows the pointer exactly without snapping', () => { + expect(resizeSize({ x: 10, y: 10, w: 100, h: 60 }, 12.4, -7.6, 0, MIN)).toEqual({ + w: 112.4, + h: 52.4, + }); + }); + + it('puts the dragged corner on the grid, wherever the box starts', () => { + // Off-grid at 10: the far edge is a multiple of 24, so the width is not. + const size = resizeSize({ x: 10, y: 10, w: 100, h: 60 }, 5, 5, STEP, MIN); + expect({ right: 10 + size.w, bottom: 10 + size.h }).toEqual({ right: 120, bottom: 72 }); + }); + + it('leaves the near corner alone – only the size changes', () => { + const start = { x: 24, y: 24, w: 120, h: 72 }; + const size = resizeSize(start, -30, -30, STEP, MIN); + expect(size).toEqual({ w: 96, h: 48 }); + }); + + it('honours the minimum in whole steps, so a shrunk box stays on the grid', () => { + // Dragged far past the minimum: 80 wide would leave the right edge between two grid lines. + const size = resizeSize({ x: 0, y: 0, w: 320, h: 200 }, -400, -400, STEP, MIN); + expect(size).toEqual({ w: 96, h: 48 }); + expect(size.w).toBeGreaterThanOrEqual(MIN.w); + }); +}); + describe('fit', () => { /** Where a graph point lands on screen under a framing. */ const toScreen = (f: { zoom: number; panX: number; panY: number }, x: number, y: number) => ({ diff --git a/tools/e2e/36-graph-groups.mjs b/tools/e2e/36-graph-groups.mjs new file mode 100644 index 00000000..0dccf8b3 --- /dev/null +++ b/tools/e2e/36-graph-groups.mjs @@ -0,0 +1,231 @@ +import { node } from '../lib/builders.mjs'; + +/** + * Groups and comments in a graph (#38). Two separate things on purpose: a group is a titled box + * that carries what it encloses, a comment is a card of text that frames nothing. + * + * What only a running editor can answer is in here: that a group takes the nodes whose *centre* + * lies in it and leaves the one beside it, that deleting the box leaves every node where it was, + * and that the text on a freely picked colour flips between black and white so it stays readable – + * none of which a unit test can see, because all three are about what is drawn and dragged. + */ + +const GRAPH = { + nodes: [ + node('gg-start', 'OnStart', 0, 0), + node('gg-self', 'Self', 0, 200), + node('gg-far', 'Self', 900, 600), + ], + // No links: this step is about the boxes behind the nodes, not about what runs. + edges: [], +}; + +export async function graphGroups(t) { + const { + check, + json, + evaluate, + mutate, + click, + clickText, + drag, + locate, + press, + typeText, + shot, + showTab, + settle, + } = t; + + const boxes = () => json('__gm.graph.annotations()'); + const one = async (kind) => (await boxes()).find((a) => a.kind === kind) ?? null; + const nodeAt = (id) => + json( + `(() => { const g = __gm.state.project.assets.find((a) => a.id === 'gg-logic').graph;` + + ` const n = g.nodes.find((n) => n.id === ${JSON.stringify(id)});` + + ` return n ? { x: n.x, y: n.y } : null; })()`, + ); + + await press('Escape'); + await evaluate('__gm.state.select(null)'); + + // 36a. A graph of its own: two nodes close together and a third far away, which is what makes + // "the group took the right ones" a question with a wrong answer available. + await mutate(` + const p = __gm.state.project; + const logic = structuredClone(p.assets.find((a) => a.id === 'mover')); + Object.assign(logic, { + id: 'gg-logic', + name: 'Grouped', + props: { enabled: true, runInEditor: false }, + variables: [], + graph: ${JSON.stringify(GRAPH)}, + }); + p.assets.push(logic);`); + await evaluate("__gm.state.select({ kind: 'asset', assetId: 'gg-logic' })"); + await showTab('graph'); + await evaluate('__gm.graph.current()'); + + // 36b. The palette is the one gesture the canvas has for putting something on itself, so both + // boxes are offered there – above the nodes, and reachable by the same search field. + const spot = await json('__gm.graph.emptySpot()'); + await click(spot.x, spot.y, 'right'); + const offered = await json('__gm.graph.paletteRows()'); + check( + 'the drop palette offers a group and a comment before the nodes', + offered[0]?.type === 'add-group' && offered[1]?.type === 'add-comment', + JSON.stringify(offered.slice(0, 4)), + ); + // The search reaches them by what they are, not by the "Add" they both start with: that word is + // what somebody types to find the `Add` node, and two entries jumping the list for it would cost + // more than they help. + await typeText('add'); + const searchedAdd = await json('__gm.graph.paletteRows()'); + await press('a', { ctrl: true }); + await typeText('comment'); + const searchedComment = await json('__gm.graph.paletteRows()'); + check( + 'the search finds them by what they are, and leaves the query "add" to the nodes', + searchedAdd.every((row) => !row.type.startsWith('add-')) && searchedComment[0]?.type === 'add-comment', + JSON.stringify({ add: searchedAdd.slice(0, 2), comment: searchedComment.slice(0, 2) }), + ); + await clickText('Add comment'); + const comment = await one('note'); + check( + 'a comment is drawn where the palette was opened, in white on the default colour', + comment !== null && comment.text === 'Comment' && comment.textColor === '#ffffff', + JSON.stringify(comment), + ); + + // 36c. A group out of a selection: the two near nodes, right-clicked as a selection. + await evaluate( + "__gm.state.select({ kind: 'node', assetId: 'gg-logic', nodeIds: ['gg-start', 'gg-self'] })", + ); + const start = await json('__gm.graph.nodeAt("gg-start")'); + await click(start.x, start.y, 'right'); + const menu = await json('__gm.menuRows()'); + await clickText('Group these 2 nodes'); + const group = await one('group'); + const farBefore = await nodeAt('gg-far'); + check( + 'the node menu groups the whole selection, and the box is drawn around it', + menu.some((row) => (row ?? '') === 'Group these 2 nodes') && + group !== null && + group.text === 'Group' && + (await json('__gm.state.selection')).kind === 'annotation', + JSON.stringify({ menu: menu.filter(Boolean).slice(0, 3), group }), + ); + await shot('36-group'); + + // 36d. Dragging the title bar. The far node is the check that matters: it never moves, because + // its centre is nowhere near the box – that is the whole membership rule. + const startBefore = await nodeAt('gg-start'); + const commentBefore = (await one('note')).x; + const title = await json(`__gm.graph.annotationAt(${JSON.stringify(group.id)})`); + await drag(title.x, title.y, title.x + 80, title.y + 60); + await settle(); + const startAfter = await nodeAt('gg-start'); + const farAfter = await nodeAt('gg-far'); + const movedBox = await one('group'); + check( + 'moving a group takes the nodes inside it and leaves the one beside it', + startAfter.x > startBefore.x && + startAfter.y > startBefore.y && + movedBox.x > group.x && + farAfter.x === farBefore.x && + farAfter.y === farBefore.y && + (await one('note')).x === commentBefore, + JSON.stringify({ startBefore, startAfter, farBefore, farAfter }), + ); + + // 36e. One undo for the whole gesture: the box and every node it carried, in one step. + await t.undo(); + check( + 'one undo takes the move back whole', + JSON.stringify(await nodeAt('gg-start')) === JSON.stringify(startBefore) && + (await one('group')).x === group.x, + JSON.stringify({ node: await nodeAt('gg-start'), box: (await one('group')).x }), + ); + + // 36f. The corner resizes and nothing else: membership is derived from where the box ends up, + // so a node the corner sweeps over is simply inside it afterwards – it is not dragged. + const corner = await json(`__gm.graph.annotationAt(${JSON.stringify(group.id)}, true)`); + await drag(corner.x, corner.y, corner.x + 60, corner.y + 40); + await settle(); + const resized = await one('group'); + check( + 'the corner resizes the box while every node stays put', + resized.w > group.w && + resized.h > group.h && + JSON.stringify(await nodeAt('gg-start')) === JSON.stringify(startBefore), + JSON.stringify({ before: { w: group.w, h: group.h }, after: { w: resized.w, h: resized.h } }), + ); + + // 36f2. With snapping on, the corner obeys the grid like a dragged node does – and it is the far + // edge that lands on it, because the near one is exactly what a resize must not move. + const snapLabel = await locate('Snap'); + // The checkbox sits right of the "Snap" label; locate() gives the label's text centre. + await click(snapLabel.x + 30, snapLabel.y); + const held = await one('group'); + const snapCorner = await json(`__gm.graph.annotationAt(${JSON.stringify(group.id)}, true)`); + // 53 and 37 are deliberately ragged: a multiple of 24 afterwards cannot come from the gesture. + await drag(snapCorner.x, snapCorner.y, snapCorner.x + 53, snapCorner.y + 37); + await settle(); + const snapped = await one('group'); + check( + 'with snapping on the resized box ends on the grid, without moving its near corner', + (snapped.x + snapped.w) % 24 === 0 && + (snapped.y + snapped.h) % 24 === 0 && + snapped.x === held.x && + snapped.y === held.y && + snapped.w > held.w, + JSON.stringify({ x: snapped.x, y: snapped.y, w: snapped.w, h: snapped.h }), + ); + await click(snapLabel.x + 30, snapLabel.y); + + // 36g. A freely picked colour, typed as hex into the inspector. The point is the second colour: + // the text flips to black on a light fill, which is what makes a free picker safe. + await evaluate( + `__gm.state.select({ kind: 'annotation', assetId: 'gg-logic', annotationId: ${JSON.stringify(group.id)} })`, + ); + const field = await json("__gm.inspectorField('Color')"); + await click(field.x, field.y); + // The field already holds the current hex; typing without this appends to it and parses as nothing. + await press('a', { ctrl: true }); + await typeText('#ffe08a'); + await press('Enter'); + const colored = await one('group'); + check( + 'a group takes any colour, and its title turns black to stay readable on a light one', + colored.color === '#ffe08a' && colored.textColor === '#000000', + JSON.stringify({ color: colored.color, textColor: colored.textColor }), + ); + await shot('36-group-colored'); + + // 36h. Delete takes the box and nothing else. A group that silently deleted what it framed + // would be the most expensive surprise in the editor. + const nodesBefore = await json( + "__gm.state.project.assets.find((a) => a.id === 'gg-logic').graph.nodes.length", + ); + await press('Delete'); + check( + 'deleting a group leaves every node it framed exactly where it was', + (await one('group')) === null && + (await json("__gm.state.project.assets.find((a) => a.id === 'gg-logic').graph.nodes.length")) === + nodesBefore && + JSON.stringify(await nodeAt('gg-start')) === JSON.stringify(startBefore), + JSON.stringify({ nodes: nodesBefore, boxes: (await boxes()).map((a) => a.kind) }), + ); + + // 36i. And it all survives the round trip through the store, which is what writing a note is for. + await t.saved(); + const stored = await t.api(`projects/${t.projectId}`); + const saved = stored.project.assets.find((a) => a.id === 'gg-logic'); + check( + 'the comment is part of the saved document, the deleted group is not', + (saved.graph.notes ?? []).length === 1 && + (saved.graph.notes ?? [])[0].text === 'Comment' && + (saved.graph.groups ?? []).length === 0, + JSON.stringify({ notes: saved.graph.notes, groups: saved.graph.groups }), + ); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index bce9a61a..ffee8f90 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -38,6 +38,7 @@ import { scope } from './32-scope.mjs'; import { renderOnChange } from './33-render-on-change.mjs'; import { sound } from './34-sound.mjs'; import { loadScene } from './35-load-scene.mjs'; +import { graphGroups } from './36-graph-groups.mjs'; export const STEPS = [ hierarchy, @@ -75,4 +76,5 @@ export const STEPS = [ renderOnChange, sound, loadScene, + graphGroups, ];