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
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:
1 parent
1135205d69
commit
b9f1a9d319
21 files changed
+1473
-22
No files matched your search
@@ -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<GraphAnnotation>[] = [];
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<string>): 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".
|
||||
|
||||
+13
-1
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+11
-1
@@ -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';
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
Reference in new issue
Block a user