Pick a link on its line and delete it from there (#289)
game-maker / gate (push) Successful in 2m37s
game-maker / suite (push) Successful in 13m10s

A link could only be removed through the menu of its port, and the
line itself took no input. A transition of a state machine could only
be deleted in the list of the inspector. Now the line is the handle,
in a behavior's graph and between states alike.

- A click on a line selects that link and draws a halo behind it. A
  press counts within 6 screen pixels of the line, and where two cross
  the one drawn last wins.
- Ctrl-click or Shift-click adds a link or takes it away. The rubber
  band takes every line it crosses, into the same selection as the
  nodes and boxes it caught.
- Delete or Backspace removes what is picked as one undo step. A
  right-click on a line offers Hide link and Delete link.
- The inspector shows a picked link: the node and port at each end and
  the type it carries, with Hide link and Delete link below. For
  several it says how many.
- The arrow of a transition is picked the same way, on its name too.
  Delete or a right-click with Delete transition removes it.
- The menu of a port is unchanged, Disconnect included, as answered in
  the issue.
- editor/graph/edgeHit.ts holds the hit test as pure functions, and
  editor/inspector/edgeSection.ts builds the section. The selection
  has a new kind edge, and node and annotation carry edgeIds.

Decided here:

- A hidden link has no line and is never picked this way, also while
  it is shown on loan. Its mark at the port stays its handle.
- The menu of a line speaks about the whole selection, like the menu
  of a box. With nodes or boxes held alongside it reads Delete N
  items.
- Hiding picked links lets go of them, since no line is left.
- A link wins over the box it crosses, and a mark wins over a link.
- A transition has no Hide entry.
- With nodes or boxes in the selection the inspector keeps showing
  the node or box, not the link.

Tests:
- Gate: 201 files, 2982 tests (2967 + 15), exit 0
- e2e, full run: 1635 checks passed (1629 + 6), exit 0
- Smokes: 24 / 19 / 21, exit 0
- The full run was done for exactly this state.
- 15 unit tests in tests/editor/edge-selection.test.ts
- Six e2e checks with a real mouse, block 6z-xi in module 06 and 26e7
  in module 26
- 12 unit counter-probes all fail tests, run on stein2
- Three browser counter-probes fail, the build went through each
  time: no hit on the line, no halo, a band that skips lines
- Screenshots of a picked link and a picked transition looked at
  before the last build, none after it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-01 20:51:15 +02:00
1 parent 0f85476b93
commit c5ba21533d
15 files changed
+904 -41

No files matched your search

+1 -1
View File
@@ -45,7 +45,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU:
```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1629 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1635 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+39 -8
View File
@@ -14,7 +14,7 @@ import {
import { oneStep } from '../core/commands/CompoundCommand';
import { CreateContainerCommand } from '../core/commands/containerCommands';
import { RemoveFrameCommand } from '../core/commands/frameCommands';
import { RemoveNodeCommand } from '../core/commands/graphCommands';
import { DisconnectCommand, FoldEdgeCommand, RemoveNodeCommand } from '../core/commands/graphCommands';
import { RemoveSceneObjectsCommand } from '../core/commands/sceneObjectCommands';
import { RemoveVectorShapeCommand, RemoveVectorShapesCommand } from '../core/commands/vectorArtCommands';
import type { AssetType } from '../core/model/AssetTypes';
@@ -36,7 +36,7 @@ import {
findUsages,
} from '../core/model/usages';
import { Dialog } from '../ui';
import { type EditorState, objectSelection } from './state';
import { type EditorState, graphSelection, objectSelection } from './state';
/**
* What a double-click on an asset does, wherever it happens (asset panel, "Open …" entries, a link
@@ -107,19 +107,47 @@ export function removeNodes(state: EditorState, assetId: string, nodeIds: readon
return commands.length > 0 && state.execute(oneStep(`Delete ${commands.length} nodes`, commands));
}
/** What a link is called in this graph: between states it is a transition. */
export function linkNoun(state: EditorState, assetId: string): string {
return findGraphAsset(state.project, assetId)?.type === 'statemachine' ? 'transition' : 'link';
}
/**
* Deletes everything a graph selection holds – nodes and boxes alike – as one undo step, since one
* band caught them and one keypress asked for them to go. Boxes still take none of their nodes.
* Hides the links as one undo step and lets go of them: a hidden link has no line left to be
* picked on, its mark at the port is the handle from then on.
*/
export function hideLinks(state: EditorState, assetId: string, edgeIds: readonly string[]): boolean {
const commands = edgeIds.map((id) => new FoldEdgeCommand(assetId, id, true));
if (
commands.length === 0 ||
!state.execute(oneStep(commands.length === 1 ? 'Hide link' : 'Hide links', commands))
)
return false;
state.select(
graphSelection(assetId, state.selectedNodeIds, state.selectedAnnotationIds) ?? { kind: 'asset', assetId },
);
return true;
}
/**
* Deletes everything a graph selection holds – nodes, boxes and links alike – as one undo step,
* since one band caught them and one keypress asked for them to go. Boxes still take none of
* their nodes. Links go first, so one at a node that goes too is not asked for twice.
*/
export function removeGraphSelection(
state: EditorState,
assetId: string,
nodeIds: readonly string[],
annotationIds: readonly string[],
edgeIds: readonly string[] = [],
): boolean {
const graph = findGraphAsset(state.project, assetId)?.graph;
const commands: Command[] = nodeIds.map((id) => new RemoveNodeCommand(assetId, id));
let noun = '';
const links = (graph?.edges ?? []).filter((e) => edgeIds.includes(e.id));
const commands: Command[] = [
...links.map((e) => new DisconnectCommand(assetId, e.id)),
...nodeIds.map((id) => new RemoveNodeCommand(assetId, id)),
];
let noun = links.length === 1 ? linkNoun(state, assetId) : '';
for (const id of annotationIds) {
const found = graph && findAnnotation(graph, id);
if (!found) continue;
@@ -151,10 +179,13 @@ export function removeSelection(state: EditorState): boolean {
removeObjects(state, s.holderId, s.objectIds);
return true;
case 'node':
removeGraphSelection(state, s.assetId, s.nodeIds, s.annotationIds);
removeGraphSelection(state, s.assetId, s.nodeIds, s.annotationIds, s.edgeIds);
return true;
case 'annotation':
removeGraphSelection(state, s.assetId, [], s.annotationIds);
removeGraphSelection(state, s.assetId, [], s.annotationIds, s.edgeIds);
return true;
case 'edge':
removeGraphSelection(state, s.assetId, [], [], s.edgeIds);
return true;
case 'frame':
state.execute(new RemoveFrameCommand(s.assetId, s.frameId));
+5
View File
@@ -44,6 +44,9 @@ export function graphHooks({ state, editor }: HookContext) {
edgeDrawn: (edgeId: string) => graphEditor.canvas.edgeDrawn(edgeId),
/** The colour it was stroked in: `danger` is an edge whose two ends stopped fitting (#207). */
edgeColor: (edgeId: string) => graphEditor.canvas.edgeColor(edgeId),
/** Halfway along a link on screen, and the links drawn as picked (#289). */
edgeAt: (edgeId: string) => graphEditor.canvas.edgeGlobal(edgeId),
pickedEdges: () => graphEditor.canvas.pickedEdgesDrawn(),
/** The types the node's ports are drawn with, after a shared group had its say (#207). */
portTypes: (nodeId: string) => graphEditor.canvas.portTypes(nodeId),
blockFrames: () => graphEditor.canvas.blockFrames(),
@@ -72,6 +75,8 @@ export function graphHooks({ state, editor }: HookContext) {
portGrabbable: (nodeId: string, port: string, isOutput: boolean) =>
editor.stateMachineEditor.canvas.portGrabbable(nodeId, port, isOutput),
edgeDrawn: (edgeId: string) => editor.stateMachineEditor.canvas.edgeDrawn(edgeId),
edgeAt: (edgeId: string) => editor.stateMachineEditor.canvas.edgeGlobal(edgeId),
pickedEdges: () => editor.stateMachineEditor.canvas.pickedEdgesDrawn(),
transitionLabel: (edgeId: string) => editor.stateMachineEditor.canvas.transitionLabelDrawn(edgeId),
transitionLabelAt: (edgeId: string) => editor.stateMachineEditor.canvas.transitionLabelAt(edgeId),
hint: () => editor.stateMachineEditor.canvas.shownHint(),
+47 -1
View File
@@ -26,6 +26,8 @@ import {
foldMarkRadius,
} from './folding';
import { curvePoint, curveReach } from './beads';
import { edgeAt, type EdgePath, edgesInBand, pathMiddle } from './edgeHit';
import type { Band } from '../framing';
import { type NodeView, portColor } from './NodeView';
/** How far a folded edge's stub reaches out of its port. */
@@ -38,6 +40,8 @@ const BEAD_RADIUS = 4.5;
const GHOST_DASH = 7;
const GHOST_GAP = 5;
const DASH_STEPS = 80;
/** How much wider than the link itself the halo behind a picked one is. */
const PICKED_HALO = 5;
/** One port's hidden links as drawn: what they stand in for, and where they can be clicked. */
export interface FoldMark {
@@ -75,6 +79,9 @@ export class EdgeLayer extends Container {
/** Which ring each loop back into its own state takes, so several nest instead of overlapping. */
private rings = new Map<string, number>();
private transitionLabels: TransitionLabel[] = [];
/** Every link the last draw stroked, bottom first – what a click on a line is tested against. */
private paths: EdgePath[] = [];
private picked: ReadonlySet<string> = new Set();
constructor() {
super();
@@ -87,7 +94,10 @@ export class EdgeLayer extends Container {
isHot: (edgeId: string) => boolean,
selected: Set<string>,
beads: (edgeId: string) => readonly number[] = () => [],
picked: ReadonlySet<string> = new Set(),
): void {
this.paths = [];
this.picked = picked;
this.curves.clear();
this.beads.clear();
this.beadsDrawn = [];
@@ -112,6 +122,26 @@ export class EdgeLayer extends Container {
return foldMarkAt(this.marks, g, FOLD_MARK_RADIUS);
}
/** The link under a point; a hidden one has no line and is never it. */
edgeAt(g: Point, tolerance: number): string | null {
return edgeAt(this.paths, g, tolerance, this.transitionLabels);
}
edgesIn(band: Band): string[] {
return edgesInBand(this.paths, band);
}
/** Halfway along a link as drawn, or null when it has no line. */
edgeMiddle(edgeId: string): Point | null {
const path = this.paths.find((p) => p.edgeId === edgeId);
return path ? pathMiddle(path) : null;
}
/** The links the last draw put a halo behind. */
pickedDrawn(): string[] {
return this.paths.filter((p) => this.picked.has(p.edgeId)).map((p) => p.edgeId);
}
drawnMarks(): readonly FoldMark[] {
return this.marks;
}
@@ -170,6 +200,15 @@ export class EdgeLayer extends Container {
if (!foldedEdgeShown(edge, selected, hot)) return;
}
const shaft = transition ? arrowShaft(approach, end) : end;
const width = hot ? 4 : type === 'exec' ? 2.5 : 2;
const picked = !edge.folded && this.picked.has(edge.id);
if (picked) {
// Solid even behind a dashed link: the halo says "picked", the dashes on top still say "broken".
if (loop) loopPath(this.curves, loop);
else if (transition) this.curves.moveTo(start.x, start.y).lineTo(shaft.x, shaft.y);
else bezier(this.curves, start.x, start.y, end.x, end.y);
this.curves.stroke({ width: width + PICKED_HALO, color: Theme.colors.nodeSelected, alpha: 0.9 });
}
if (loop) loopPath(this.curves, loop);
else if (ghost && transition)
dashedLine(this.curves, start.x, start.y, shaft.x, shaft.y, GHOST_DASH, GHOST_GAP);
@@ -177,7 +216,7 @@ export class EdgeLayer extends Container {
else if (transition) this.curves.moveTo(start.x, start.y).lineTo(shaft.x, shaft.y);
else bezier(this.curves, start.x, start.y, end.x, end.y);
this.curves.stroke({
width: hot ? 4 : type === 'exec' ? 2.5 : 2,
width,
color,
// Dimmed while folded, so a curve that is only on loan is not mistaken for a plain one.
alpha: (type === 'exec' || hot ? 1 : 0.85) * (edge.folded ? 0.6 : 1),
@@ -190,6 +229,13 @@ export class EdgeLayer extends Container {
loop ? { x: 1, y: 0 } : (direction(start, end) ?? { x: 1, y: 0 }),
);
this.stroked.set(edge.id, color);
// A hidden link shown on loan has its mark as the handle; its line stays out of reach.
if (!edge.folded)
this.paths.push(
loop
? { edgeId: edge.id, kind: 'loop', loop }
: { edgeId: edge.id, kind: transition ? 'line' : 'curve', start, end },
);
for (const progress of beads) {
const { x, y } = loop
? loopPoint(loop, progress)
+53 -6
View File
@@ -43,7 +43,13 @@ import {
Widget,
zoomFactor,
} from '../../ui';
import { annotationSelection, type EditorState, graphSelection, nodeSelection } from '../state';
import {
annotationSelection,
edgeSelection,
type EditorState,
graphSelection,
nodeSelection,
} from '../state';
import { canPasteGraph, copySelection, pasteGraph } from '../clipboard';
import { type Box, boundsOf, boxesInBand, boxesWithinBand, frameBoxes } from '../framing';
import { annotationBoxes, boxOf } from './annotationDrag';
@@ -53,7 +59,7 @@ import { type BlockFrame, blockFrames, drawBlockFrames } from './blockFrames';
import { EdgeLayer } from './EdgeLayer';
import { DRAG_THRESHOLD, GraphDrags } from './graphDrags';
import type { GraphCanvasHost } from './graphHost';
import { openAnnotationMenu, openLinkMenu, openNodeMenu } from './graphMenus';
import { openAnnotationMenu, openEdgeMenu, openLinkMenu, openNodeMenu } from './graphMenus';
import { NodePalette, type PaletteAction } from './NodePalette';
import { splitEntries } from './splitMenu';
import { type PaletteEntry, paletteEntries } from './paletteEntries';
@@ -78,6 +84,8 @@ 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;
/** How far from a link, in screen pixels, a press still means that link. */
const EDGE_TOLERANCE = 6;
/**
* The node graph of one behavior or state machine asset. Which one that is comes from the outside
@@ -303,6 +311,8 @@ export class GraphCanvas extends Widget {
this.wiring.finish(e.global, false);
return;
}
// A link runs over the box it crosses, so it is the nearer thing under the pointer.
if (this.pressEdge(e)) return;
const id = view.annotation.id;
// Grabbing a box that is already part of a wider selection keeps that selection, the way a node
// does: otherwise dragging one of several would silently drop the rest first.
@@ -450,14 +460,15 @@ export class GraphCanvas extends Widget {
this.wiring.finish(e.global, false);
return;
}
const local = this.toLocal(e.global);
const g = this.screenToGraph(local.x, local.y);
const mark = this.edgeLayer.markAt(g);
if (!mark && this.pressEdge(e)) return;
// Shift-drag on empty space pans as well, as it does in the scene view.
if (e.button === 0 && e.shiftKey) {
startPan();
return;
}
const local = this.toLocal(e.global);
const g = this.screenToGraph(local.x, local.y);
const mark = this.edgeLayer.markAt(g);
if (e.button === 2) {
if (mark) this.openLinks((g) => g.edges.filter((it) => mark.edgeIds.includes(it.id)), e.global);
else if (this.assetId) this.openPalette(e.global.x, e.global.y, g);
@@ -474,6 +485,25 @@ export class GraphCanvas extends Widget {
}
}
/**
* A press on a link (#289): it selects the link, Ctrl or Shift adds or takes it, and the right
* button opens its menu. False when no link is under the pointer.
*/
private pressEdge(e: FederatedPointerEvent): boolean {
if (!this.assetId || (e.button !== 0 && e.button !== 2)) return false;
const local = this.toLocal(e.global);
const id = this.edgeLayer.edgeAt(this.screenToGraph(local.x, local.y), EDGE_TOLERANCE / this.zoom);
if (!id) return false;
if (e.button === 0 && (e.ctrlKey || e.metaKey || e.shiftKey)) {
this.state.toggleEdge(this.assetId, id);
return true;
}
// Pressing a link of a wider selection keeps that selection, so its menu speaks about all of it.
if (!this.state.selectedEdgeIds.includes(id)) this.state.select(edgeSelection(this.assetId, [id]));
if (e.button === 2) openEdgeMenu(this.state, this.assetId, e.global.x, e.global.y);
return true;
}
private onNodePointerDown(view: NodeView, e: FederatedPointerEvent): void {
// Panning takes precedence over the node: let the middle button bubble up to the canvas.
if (e.button === 1) return;
@@ -675,7 +705,12 @@ export class GraphCanvas extends Widget {
const graph = this.asset()?.graph;
const boxIds = graph ? boxesWithinBand(annotationBoxes(graph), band) : [];
// An empty band clears the selection but keeps the behavior, as a plain click does.
this.state.select(graphSelection(this.assetId, ids, boxIds) ?? { kind: 'asset', assetId: this.assetId });
this.state.select(
graphSelection(this.assetId, ids, boxIds, this.edgeLayer.edgesIn(band)) ?? {
kind: 'asset',
assetId: this.assetId,
},
);
}
private nodeUnder(globalX: number, globalY: number): string | null {
@@ -829,6 +864,7 @@ export class GraphCanvas extends Widget {
(id) => isHot(trace?.edges, id),
selectedNodes,
(id) => this.beads.inFlight(id, now),
new Set(this.state.selectedEdgeIds),
);
this.wiring.draw(this.tempEdge, asset.graph);
@@ -910,6 +946,17 @@ export class GraphCanvas extends Widget {
};
}
/** Halfway along a link as drawn, on screen – where a check clicks it. Null for one without a line. */
edgeGlobal(edgeId: string): { x: number; y: number } | null {
const middle = this.edgeLayer.edgeMiddle(edgeId);
return middle ? globalPoint(this.edgeLayer, middle) : null;
}
/** The links the last redraw marked as picked. */
pickedEdgesDrawn(): string[] {
return this.edgeLayer.pickedDrawn();
}
/** The name drawn on a transition, or null when nothing is drawn on it. */
transitionLabelDrawn(edgeId: string): string | null {
return this.edgeLayer.drawnTransitionLabels().find((l) => l.edgeId === edgeId)?.text ?? null;
+103
View File
@@ -0,0 +1,103 @@
/** Which link a click or a rubber band means, kept out of the canvas so tests can reach it (#289). */
import type { Band } from '../framing';
import { curvePoint } from './beads';
import { type Loop, loopPoint, type Point, type Rect } from './transitions';
/** One link as the last draw laid it out, in graph units. */
export type EdgePath = { edgeId: string } & (
| { kind: 'curve'; start: Point; end: Point }
| { kind: 'line'; start: Point; end: Point }
| { kind: 'loop'; loop: Loop }
);
/** A transition's name plate: clicking the name means its arrow. */
export type EdgePlate = Rect & { edgeId: string };
/** Straight pieces a curve is tested as; at 24 the error stays far below a pixel of tolerance. */
const CURVE_STEPS = 24;
export function pathPoints(path: EdgePath): Point[] {
if (path.kind === 'line') return [path.start, path.end];
const at = (t: number) =>
path.kind === 'loop'
? loopPoint(path.loop, t)
: curvePoint(path.start.x, path.start.y, path.end.x, path.end.y, t);
return Array.from({ length: CURVE_STEPS + 1 }, (_, i) => at(i / CURVE_STEPS));
}
/** The point halfway along a link – where a check clicks it. */
export function pathMiddle(path: EdgePath): Point {
if (path.kind === 'line') return { x: (path.start.x + path.end.x) / 2, y: (path.start.y + path.end.y) / 2 };
return path.kind === 'loop'
? loopPoint(path.loop, 0.5)
: curvePoint(path.start.x, path.start.y, path.end.x, path.end.y, 0.5);
}
function distanceToSegment(p: Point, a: Point, b: Point): number {
const [dx, dy] = [b.x - a.x, b.y - a.y];
const length = dx * dx + dy * dy;
const t = length === 0 ? 0 : Math.min(1, Math.max(0, ((p.x - a.x) * dx + (p.y - a.y) * dy) / length));
return Math.hypot(p.x - (a.x + dx * t), p.y - (a.y + dy * t));
}
export function distanceToPath(path: EdgePath, point: Point): number {
const points = pathPoints(path);
let nearest = Infinity;
for (let i = 1; i < points.length; i++)
nearest = Math.min(nearest, distanceToSegment(point, points[i - 1]!, points[i]!));
return nearest;
}
/**
* The link under a point, or null. A name plate wins over any line, and of several lines within
* `tolerance` the one drawn last does – it is the one on top.
*/
export function edgeAt(
paths: readonly EdgePath[],
point: Point,
tolerance: number,
plates: readonly EdgePlate[] = [],
): string | null {
for (let i = plates.length - 1; i >= 0; i--) {
const p = plates[i]!;
if (point.x >= p.x && point.x <= p.x + p.w && point.y >= p.y && point.y <= p.y + p.h) return p.edgeId;
}
for (let i = paths.length - 1; i >= 0; i--)
if (distanceToPath(paths[i]!, point) <= tolerance) return paths[i]!.edgeId;
return null;
}
/** Whether the segment a-b has any point inside the rectangle (Liang-Barsky). */
function segmentInRect(a: Point, b: Point, minX: number, minY: number, maxX: number, maxY: number): boolean {
let [t0, t1] = [0, 1];
const [dx, dy] = [b.x - a.x, b.y - a.y];
for (const [p, q] of [
[-dx, a.x - minX],
[dx, maxX - a.x],
[-dy, a.y - minY],
[dy, maxY - a.y],
] as const) {
if (p === 0) {
if (q < 0) return false;
continue;
}
const r = q / p;
if (p < 0) t0 = Math.max(t0, r);
else t1 = Math.min(t1, r);
if (t0 > t1) return false;
}
return true;
}
/** Ids of every link the band touches, in drawing order: a line is thin, so crossing it counts. */
export function edgesInBand(paths: readonly EdgePath[], band: Band): string[] {
const [minX, maxX] = [Math.min(band.x1, band.x2), Math.max(band.x1, band.x2)];
const [minY, maxY] = [Math.min(band.y1, band.y2), Math.max(band.y1, band.y2)];
return paths
.filter((path) => {
const points = pathPoints(path);
return points.some((p, i) => i > 0 && segmentInRect(points[i - 1]!, p, minX, minY, maxX, maxY));
})
.map((path) => path.edgeId);
}
+34 -1
View File
@@ -4,7 +4,7 @@ import { CollapseNodesCommand, DisconnectCommand, FoldEdgeCommand } from '../../
import { annotationNoun } from '../../core/commands/annotationCommands';
import type { AnnotationKind, GraphEdge, GraphNode } from '../../core/graph';
import { Menu, type MenuItem } from '../../ui';
import { removeGraphSelection, removeNodes } from '../actions';
import { hideLinks, linkNoun, removeGraphSelection, removeNodes } from '../actions';
import { copySelection } from '../clipboard';
import type { EditorState } from '../state';
@@ -100,3 +100,36 @@ export function openLinkMenu(
...item('Disconnect', 'Disconnect', edges, (id) => new DisconnectCommand(assetId, id), true),
]).open(x, y);
}
/**
* For the selected links (#289), opened on one of them. Like a box's menu it speaks about the whole
* selection, so deleting from it takes the nodes and boxes held alongside too.
*/
export function openEdgeMenu(state: EditorState, assetId: string, x: number, y: number): void {
const edges = state.selectedEdgeIds;
const others = state.selectedNodeIds.length + state.selectedAnnotationIds.length;
const noun = linkNoun(state, assetId);
const hide: MenuItem[] =
noun === 'link'
? [
{
label: edges.length === 1 ? 'Hide link' : `Hide ${edges.length} links`,
onSelect: () => hideLinks(state, assetId, edges),
},
]
: [];
new Menu([
...hide,
{
label:
others > 0
? `Delete ${edges.length + others} items`
: edges.length === 1
? `Delete ${noun}`
: `Delete ${edges.length} ${noun}s`,
danger: true,
onSelect: () =>
removeGraphSelection(state, assetId, state.selectedNodeIds, state.selectedAnnotationIds, edges),
},
]).open(x, y);
}
+6 -1
View File
@@ -5,6 +5,7 @@ import { activateAsset } from '../actions';
import { type EditorState, sameSelection, type Selection } from '../state';
import { assetSignature, buildAssetSection, refreshAssetSection } from './assetSection';
import { annotationSignature, buildAnnotationSection, refreshAnnotationSection } from './annotationSection';
import { buildEdgeSection, edgeSignature, refreshEdgeSection } from './edgeSection';
import { buildFrameSection, frameSignature, refreshFrameSection } from './frameSection';
import type { InspectorHost } from './host';
import { buildNodeSection, nodeSignature, refreshNodeSection } from './nodeSection';
@@ -123,6 +124,7 @@ export class Inspector extends Panel {
else if (s.kind === 'shape') buildShapeSection(this.host, s.assetId, s.shapeId, s.shapeIds);
else if (s.kind === 'annotation') buildAnnotationSection(this.host, s.assetId, s.annotationId);
else if (s.kind === 'track') buildTrackSection(this.host, s.assetId, s.trackId, s.keys);
else if (s.kind === 'edge') buildEdgeSection(this.host, s.assetId, s.edgeId, s.edgeIds);
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);
@@ -137,6 +139,7 @@ export class Inspector extends Panel {
if (s.kind === 'shape') return shapeSignature(this.state.project, s.assetId, s.shapeId, s.shapeIds);
if (s.kind === 'annotation') return annotationSignature(this.state.project, s.assetId, s.annotationId);
if (s.kind === 'track') return trackSignature(this.state.project, s.assetId, s.trackId, s.keys);
if (s.kind === 'edge') return edgeSignature(this.state.project, s.assetId, s.edgeId, s.edgeIds);
return objectSignature(this.state, s.holderId, s.objectIds, s.objectId);
}
@@ -168,7 +171,9 @@ export class Inspector extends Panel {
? refreshAnnotationSection(this.host, s.assetId, s.annotationId)
: s.kind === 'track'
? refreshTrackSection(this.host, s.assetId, s.trackId, s.keys)
: s.objectIds.length > 1 || refreshObjectSection(this.host, s.holderId, s.objectId);
: s.kind === 'edge'
? refreshEdgeSection(this.host, s.assetId, s.edgeId)
: s.objectIds.length > 1 || refreshObjectSection(this.host, s.holderId, s.objectId);
if (!ok) this.rebuild();
}
}
+124
View File
@@ -0,0 +1,124 @@
import { NameTransitionCommand } from '../../core/commands/graphCommands';
import { findNode, type GraphEdge, isStateNode, resolveDef, stateName } from '../../core/graph';
import { graphLookup } from '../../core/model/graphLookup';
import { findGraphAsset, type Project } from '../../core/model/Project';
import { textOf } from '../../core/model/Properties';
import { Button, Label } from '../../ui';
import { hideLinks, linkNoun, removeGraphSelection } from '../actions';
import { createFieldRow, labelledRow } from './fieldFactory';
import { addGap, addNote, addTitle, type InspectorHost } from './host';
const NAME_KEY = 'transition-name';
/** What the panel says about one link: the two ends by the names drawn on them. */
export interface EdgeDescription {
transition: boolean;
from: string;
output: string;
to: string;
input: string;
/** What flows through it, as the output names it. */
type: string;
name: string;
}
/** Null when the link is gone. An end whose node or port is gone reads `?`. */
export function describeEdge(project: Project, assetId: string, edgeId: string): EdgeDescription | null {
const graph = findGraphAsset(project, assetId)?.graph;
const edge = graph?.edges.find((e) => e.id === edgeId);
if (!graph || !edge) return null;
const lookup = graphLookup(project);
const end = (side: GraphEdge['from'], ports: 'inputs' | 'outputs') => {
const node = findNode(graph, side.node);
const def = node && resolveDef(node, graph, lookup);
const port = def?.[ports].find((p) => p.key === side.port);
return {
state: node !== undefined && isStateNode(node),
node: node ? (isStateNode(node) ? stateName(node) : (def?.label ?? node.type)) : '?',
port: port ? port.label || port.key : '?',
type: port?.type ?? '?',
};
};
const from = end(edge.from, 'outputs');
const to = end(edge.to, 'inputs');
return {
transition: from.state && to.state,
from: from.node,
output: from.port,
to: to.node,
input: to.port,
type: from.type,
name: edge.label ?? '',
};
}
/** Structure of a link selection: how many, and for one of them what it joins. */
export function edgeSignature(
project: Project,
assetId: string,
edgeId: string,
edgeIds: readonly string[],
): string {
if (edgeIds.length > 1) return `many:${edgeIds.length}`;
const d = describeEdge(project, assetId, edgeId);
return d ? [d.transition, d.from, d.output, d.to, d.input, d.type].join('|') : 'missing';
}
/** One link: where it comes from and goes to. Several: how many, and the way to delete them. */
export function buildEdgeSection(
host: InspectorHost,
assetId: string,
edgeId: string,
edgeIds: readonly string[],
): void {
const noun = linkNoun(host.state, assetId);
const remove = (label: string) =>
host.body.add(
new Button(label, {
variant: 'danger',
onClick: () => removeGraphSelection(host.state, assetId, [], [], edgeIds),
}),
);
if (edgeIds.length > 1) {
addTitle(host, `${edgeIds.length} ${noun.toUpperCase()}S`);
addNote(host, 'Delete removes all of them in one step.');
addGap(host);
remove(`Delete ${edgeIds.length} ${noun}s`);
return;
}
const d = describeEdge(host.state.project, assetId, edgeId);
if (!d) return;
addTitle(host, noun.toUpperCase());
const fact = (label: string, value: string) => {
const { row } = labelledRow(label);
row.add(new Label(value), 1);
host.body.add(row);
};
fact('From', d.from);
if (!d.transition) fact('Output', d.output);
fact('To', d.to);
if (d.transition) {
const name = createFieldRow(host, { key: NAME_KEY, label: 'Name', kind: 'text', default: '' }, d.name, {
onInput: () => {},
onChange: (next) => void host.state.execute(new NameTransitionCommand(assetId, edgeId, textOf(next))),
});
host.fields.set(NAME_KEY, name);
host.body.add(name.row);
addNote(host, 'A State transition node takes it by this name.', 'small');
} else {
fact('Input', d.input);
fact('Type', d.type);
}
addGap(host);
if (!d.transition)
host.body.add(new Button('Hide link', { onClick: () => hideLinks(host.state, assetId, [edgeId]) }));
remove(`Delete ${noun}`);
}
/** Pushes the current name into its row. False when the link is gone (rebuild). */
export function refreshEdgeSection(host: InspectorHost, assetId: string, edgeId: string): boolean {
const d = describeEdge(host.state.project, assetId, edgeId);
if (!d) return false;
host.fields.get(NAME_KEY)?.setValue(d.name);
return true;
}
+2 -2
View File
@@ -58,8 +58,8 @@ export function buildTransitionPicker(
/** How the transition nobody named reads in a picker, where an empty row would look like a bug. */
const UNNAMED = '(unnamed)';
/** The transitions at a state, both ways round – the only handle they have, since a transition
* hangs on ports the canvas never draws and so cannot be clicked out there. */
/** The transitions at a state, both ways round – all of them at a glance, where the canvas shows
* one picked arrow at a time (#289). */
export function buildTransitionSection(
host: InspectorHost,
assetId: string,
+129 -21
View File
@@ -5,7 +5,7 @@ import { findKey, findTrack, hasKey, type KeyRef, sameKey } from '../core/model/
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 { findAnnotation, type Graph } from '../core/graph';
import { findObject } from '../core/model/hierarchy';
import { resolvedHolder } from '../core/model/instance';
import {
@@ -37,9 +37,17 @@ export type Selection =
/**
* One or more nodes of a behavior's graph, `nodeId` being the primary one (as with objects).
* `annotationIds` holds the groups and comments caught alongside them: a rubber band spans
* whatever lies under it, and the two sorts move and die together from then on.
* whatever lies under it, and the two sorts move and die together from then on. `edgeIds` holds
* the links picked alongside (#289); they do not move, but Delete takes them too.
*/
| { kind: 'node'; assetId: string; nodeId: string; nodeIds: string[]; annotationIds: string[] }
| {
kind: 'node';
assetId: string;
nodeId: string;
nodeIds: string[];
annotationIds: string[];
edgeIds: string[];
}
/** A frame rectangle inside a texture asset (the Texture tab). */
| { kind: 'frame'; assetId: string; frameId: string }
/**
@@ -48,7 +56,9 @@ export type Selection =
*/
| { kind: 'shape'; assetId: string; shapeId: string; shapeIds: string[] }
/** One or more groups or comments of a graph, `annotationId` being the primary one. */
| { kind: 'annotation'; assetId: string; annotationId: string; annotationIds: string[] }
| { kind: 'annotation'; assetId: string; annotationId: string; annotationIds: string[]; edgeIds: string[] }
/** One or more links of a graph and nothing else, `edgeId` being the primary one (#289). */
| { kind: 'edge'; assetId: string; edgeId: string; edgeIds: string[] }
/**
* A row of an animation's timeline (the Animation tab), and the keys picked on it or on other
* rows: `keys` is empty when the row itself was clicked. `trackId` is the row the inspector
@@ -88,6 +98,7 @@ export function nodeSelection(
nodeIds: readonly string[],
primary?: string,
annotationIds: readonly string[] = [],
edgeIds: readonly string[] = [],
): Selection {
const picked = pickIds(nodeIds, primary);
return (
@@ -97,6 +108,7 @@ export function nodeSelection(
nodeId: picked.primary,
nodeIds: picked.ids,
annotationIds: [...new Set(annotationIds)],
edgeIds: [...new Set(edgeIds)],
}
);
}
@@ -112,22 +124,42 @@ export function annotationSelection(
assetId: string,
annotationIds: readonly string[],
primary?: string,
edgeIds: readonly string[] = [],
): Selection {
const picked = pickIds(annotationIds, primary);
return picked && { kind: 'annotation', assetId, annotationId: picked.primary, annotationIds: picked.ids };
return (
picked && {
kind: 'annotation',
assetId,
annotationId: picked.primary,
annotationIds: picked.ids,
edgeIds: [...new Set(edgeIds)],
}
);
}
/** Builds a selection of links alone; the last id is the primary one unless given explicitly. */
export function edgeSelection(assetId: string, edgeIds: readonly string[], primary?: string): Selection {
const picked = pickIds(edgeIds, primary);
return picked && { kind: 'edge', assetId, edgeId: picked.primary, edgeIds: picked.ids };
}
/**
* What a rubber band leaves selected: nodes win the kind when it caught any, so the inspector keeps
* showing a node where it did before, and a band over boxes alone still selects them.
* showing a node where it did before, then boxes, and a band over links alone still selects them.
* `primary` names the one picked last, in whichever of the three lists it is.
*/
export function graphSelection(
assetId: string,
nodeIds: readonly string[],
annotationIds: readonly string[],
edgeIds: readonly string[] = [],
primary?: string,
): Selection {
return (
nodeSelection(assetId, nodeIds, undefined, annotationIds) ?? annotationSelection(assetId, annotationIds)
nodeSelection(assetId, nodeIds, primary, annotationIds, edgeIds) ??
annotationSelection(assetId, annotationIds, primary, edgeIds) ??
edgeSelection(assetId, edgeIds, primary)
);
}
@@ -471,6 +503,8 @@ export class EditorState {
selection.nodeId,
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
selection.annotationIds ?? [],
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
selection.edgeIds ?? [],
);
if (selection?.kind === 'annotation')
selection = annotationSelection(
@@ -478,7 +512,12 @@ export class EditorState {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
selection.annotationIds ?? [selection.annotationId],
selection.annotationId,
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
selection.edgeIds ?? [],
);
if (selection?.kind === 'edge')
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
selection = edgeSelection(selection.assetId, selection.edgeIds ?? [selection.edgeId], selection.edgeId);
// The short form `{ trackId }` names a row without keys.
if (selection?.kind === 'track')
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
@@ -549,15 +588,42 @@ export class EditorState {
const current = s?.kind === 'node' && s.assetId === assetId ? s.nodeIds : [];
const held = current.includes(nodeId);
const next = held ? current.filter((id) => id !== nodeId) : [...current, nodeId];
// Boxes caught alongside stay held: the toggle speaks about this node, not about them.
const boxes = s?.kind === 'node' && s.assetId === assetId ? s.annotationIds : [];
// Boxes and links caught alongside stay held: the toggle speaks about this node, not about them.
const mine = s?.kind === 'node' && s.assetId === assetId;
const boxes = mine ? s.annotationIds : [];
const edges = this.graphHolds(assetId) ? this.selectedEdgeIds : [];
// Emptied by the toggle, the behavior itself stays selected – the graph must not close.
this.select(
nodeSelection(assetId, next, held ? undefined : nodeId, boxes) ??
annotationSelection(assetId, boxes) ?? { kind: 'asset', assetId },
graphSelection(assetId, next, boxes, edges, held ? undefined : nodeId) ?? { kind: 'asset', assetId },
);
}
/** Ctrl+click on a link (#289): in or out of the selection, whatever else of the graph is held. */
toggleEdge(assetId: string, edgeId: string): void {
const mine = this.graphHolds(assetId);
const current = mine ? this.selectedEdgeIds : [];
const held = current.includes(edgeId);
const next = held ? current.filter((id) => id !== edgeId) : [...current, edgeId];
const s = this.selection;
// A node or box already shown in the inspector stays the primary one.
const primary = s?.kind === 'node' ? s.nodeId : s?.kind === 'annotation' ? s.annotationId : undefined;
this.select(
graphSelection(
assetId,
mine ? this.selectedNodeIds : [],
mine ? this.selectedAnnotationIds : [],
next,
mine ? (primary ?? (held ? undefined : edgeId)) : edgeId,
) ?? { kind: 'asset', assetId },
);
}
/** Whether the selection is a node, box or link of this graph. */
private graphHolds(assetId: string): boolean {
const s = this.selection;
return (s?.kind === 'node' || s?.kind === 'annotation' || s?.kind === 'edge') && s.assetId === assetId;
}
get selectedObjectIds(): string[] {
return this.selection?.kind === 'object' ? this.selection.objectIds : [];
}
@@ -573,6 +639,12 @@ export class EditorState {
return s?.kind === 'annotation' ? s.annotationIds : [];
}
/** The links selected, whether alongside nodes and boxes or on their own. */
get selectedEdgeIds(): string[] {
const s = this.selection;
return s?.kind === 'node' || s?.kind === 'annotation' || s?.kind === 'edge' ? s.edgeIds : [];
}
/** Drops selected targets that no longer exist (after delete/undo). */
private validateSelection(): void {
const s = this.selection;
@@ -591,22 +663,47 @@ export class EditorState {
else {
const alive = s.nodeIds.filter((id) => asset.graph.nodes.some((n) => n.id === id));
const boxes = s.annotationIds.filter((id) => findAnnotation(asset.graph, id));
if (alive.length !== s.nodeIds.length || boxes.length !== s.annotationIds.length)
const edges = liveEdges(asset.graph, s.edgeIds);
if (
alive.length !== s.nodeIds.length ||
boxes.length !== s.annotationIds.length ||
edges.length !== s.edgeIds.length
)
this.select(
nodeSelection(s.assetId, alive, s.nodeId, boxes) ??
annotationSelection(s.assetId, boxes) ?? { kind: 'asset', assetId: s.assetId },
graphSelection(
s.assetId,
alive,
boxes,
edges,
alive.includes(s.nodeId) ? s.nodeId : undefined,
) ?? {
kind: 'asset',
assetId: s.assetId,
},
);
}
}
if (s.kind === 'edge') {
const asset = findGraphAsset(this.project, s.assetId);
// Same fallback as a node: the graph stays open when the link it held vanished (undo).
if (!asset) this.select(null);
else {
const alive = liveEdges(asset.graph, s.edgeIds);
if (alive.length !== s.edgeIds.length)
this.select(edgeSelection(s.assetId, alive, s.edgeId) ?? { 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 {
const alive = s.annotationIds.filter((id) => findAnnotation(asset.graph, id));
if (alive.length !== s.annotationIds.length)
const edges = liveEdges(asset.graph, s.edgeIds);
if (alive.length !== s.annotationIds.length || edges.length !== s.edgeIds.length)
this.select(
annotationSelection(s.assetId, alive, s.annotationId) ?? { kind: 'asset', assetId: s.assetId },
annotationSelection(s.assetId, alive, s.annotationId, edges) ??
edgeSelection(s.assetId, edges) ?? { kind: 'asset', assetId: s.assetId },
);
}
}
@@ -715,10 +812,12 @@ 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. A group or comment
// names it the same way, so selecting one keeps its graph on screen.
// A node names its asset; which tab that is depends on what the asset is. A group, a comment
// and a link name 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);
s.kind === 'node' || s.kind === 'annotation' || s.kind === 'edge'
? findAsset(this.project, s.assetId)
: this.assetBehind(s);
return asset && asset.type === type ? asset.id : null;
}
@@ -785,7 +884,8 @@ export function sameSelection(a: Selection, b: Selection): boolean {
a.assetId === b.assetId &&
a.nodeId === b.nodeId &&
sameIds(a.nodeIds, b.nodeIds) &&
sameIds(a.annotationIds, b.annotationIds)
sameIds(a.annotationIds, b.annotationIds) &&
sameIds(a.edgeIds, b.edgeIds)
);
}
if (a.kind === 'frame' && b.kind === 'frame') return a.assetId === b.assetId && a.frameId === b.frameId;
@@ -802,8 +902,11 @@ export function sameSelection(a: Selection, b: Selection): boolean {
return (
a.assetId === b.assetId &&
a.annotationId === b.annotationId &&
sameIds(a.annotationIds, b.annotationIds)
sameIds(a.annotationIds, b.annotationIds) &&
sameIds(a.edgeIds, b.edgeIds)
);
if (a.kind === 'edge' && b.kind === 'edge')
return a.assetId === b.assetId && a.edgeId === b.edgeId && sameIds(a.edgeIds, b.edgeIds);
return false;
}
@@ -811,3 +914,8 @@ export function sameSelection(a: Selection, b: Selection): boolean {
function sameIds(a: readonly string[], b: readonly string[]): boolean {
return a.length === b.length && a.every((id, i) => id === b[i]);
}
/** The ids that still name a link of the graph. */
function liveEdges(graph: Graph, ids: readonly string[]): string[] {
return ids.filter((id) => graph.edges.some((e) => e.id === id));
}
+235
View File
@@ -0,0 +1,235 @@
/**
* A link is picked on its line (#289): a click near the curve means that link, Ctrl adds another,
* a rubber band takes every line it crosses, and Delete removes what is picked in one undo step.
* A hidden link has no line, so it is never among the paths these rules are asked about.
*/
import { beforeEach, describe, expect, it } from 'vitest';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { AddNodeCommand, ConnectCommand, NameTransitionCommand } from '../../src/core/commands/graphCommands';
import { type Graph, STATE, STATE_IN, STATE_NAME_PARAM, STATE_OUT } from '../../src/core/graph';
import { createEmptyProject, findGraphAsset } from '../../src/core/model/Project';
import { hideLinks, removeGraphSelection, removeSelection } from '../../src/editor/actions';
import {
distanceToPath,
edgeAt,
type EdgePath,
edgesInBand,
pathMiddle,
} from '../../src/editor/graph/edgeHit';
import { selfLoop } from '../../src/editor/graph/transitions';
import { describeEdge, edgeSignature } from '../../src/editor/inspector/edgeSection';
import { edgeSelection, EditorState, graphSelection } from '../../src/editor/state';
const curve = (edgeId: string, y1: number, y2: number): EdgePath => ({
edgeId,
kind: 'curve',
start: { x: 0, y: y1 },
end: { x: 200, y: y2 },
});
describe('which link a point means', () => {
it('takes the link whose curve runs within the tolerance, and none further off', () => {
const paths = [curve('a', 0, 100)];
const on = pathMiddle(paths[0]!);
expect(on).toEqual({ x: 100, y: 50 });
expect(edgeAt(paths, { x: on.x + 4, y: on.y }, 6)).toBe('a');
expect(edgeAt(paths, { x: on.x + 40, y: on.y }, 6)).toBeNull();
// The straight line between the ports is not the curve: its quarter point is well off it.
expect(distanceToPath(paths[0]!, { x: 50, y: 25 })).toBeGreaterThan(6);
});
it('takes the one drawn last where two cross', () => {
const paths = [curve('under', 0, 100), curve('over', 100, 0)];
expect(edgeAt(paths, { x: 100, y: 50 }, 6)).toBe('over');
expect(edgeAt([...paths].reverse(), { x: 100, y: 50 }, 6)).toBe('under');
});
it('follows a straight transition and a loop back into its own state', () => {
const line: EdgePath = { edgeId: 'line', kind: 'line', start: { x: 0, y: 0 }, end: { x: 100, y: 100 } };
const loop: EdgePath = {
edgeId: 'loop',
kind: 'loop',
loop: selfLoop({ x: 300, y: 300, w: 100, h: 40 }),
};
expect(edgeAt([line, loop], { x: 52, y: 48 }, 6)).toBe('line');
expect(edgeAt([line, loop], { x: 80, y: 20 }, 6)).toBeNull();
expect(edgeAt([line, loop], pathMiddle(loop), 6)).toBe('loop');
// Inside the loop is not on it.
expect(edgeAt([line, loop], { x: 350, y: 285 }, 6)).toBeNull();
});
it('means the transition when its name plate is pressed, before any line under it', () => {
const paths = [curve('line', 0, 0)];
const plates = [{ edgeId: 'named', x: 80, y: -8, w: 40, h: 16 }];
expect(edgeAt(paths, { x: 100, y: 0 }, 6, plates)).toBe('named');
expect(edgeAt(paths, { x: 20, y: 0 }, 6, plates)).toBe('line');
});
it('gives a band every link it crosses, not only those with an end inside', () => {
const paths = [curve('a', 0, 100), curve('far', 400, 400)];
expect(edgesInBand(paths, { x1: 90, y1: 20, x2: 110, y2: 80 })).toEqual(['a']);
// Drawn the other way round it is the same rectangle.
expect(edgesInBand(paths, { x1: 110, y1: 80, x2: 90, y2: 20 })).toEqual(['a']);
expect(edgesInBand(paths, { x1: 90, y1: 150, x2: 110, y2: 200 })).toEqual([]);
expect(edgesInBand(paths, { x1: -10, y1: -10, x2: 300, y2: 500 })).toEqual(['a', 'far']);
});
});
function fixture() {
const state = new EditorState();
state.loadProject(createEmptyProject('p', 'P'), 1);
const create = new CreateAssetCommand('behavior', 'B');
expect(state.execute(create)).toBe(true);
const behaviorId = create.asset.id;
const graphOf = (): Graph => findGraphAsset(state.project, behaviorId)!.graph;
const addNode = (type: string): string => {
const command = new AddNodeCommand(behaviorId, type, 0, 0);
expect(state.execute(command)).toBe(true);
return command.node.id;
};
const connect = (from: string, to: string, port: string): string => {
const command = new ConnectCommand(behaviorId, { node: from, port: 'value' }, { node: to, port });
expect(state.execute(command)).toBe(true);
return command.edge.id;
};
const [one, two, add] = [addNode('Number'), addNode('Number'), addNode('Add')];
const [first, second] = [connect(one, add, 'a'), connect(two, add, 'b')];
const edgeIds = () => graphOf().edges.map((e) => e.id);
return { state, behaviorId, graphOf, one, two, add, first, second, edgeIds };
}
describe('a selection of links', () => {
let f: ReturnType<typeof fixture>;
beforeEach(() => {
f = fixture();
});
it('is its own kind, and keeps the graph of its asset on screen', () => {
f.state.select(edgeSelection(f.behaviorId, [f.first]));
expect(f.state.selection).toEqual({
kind: 'edge',
assetId: f.behaviorId,
edgeId: f.first,
edgeIds: [f.first],
});
expect(f.state.selectedEdgeIds).toEqual([f.first]);
expect(f.state.selectedBehaviorId).toBe(f.behaviorId);
});
it('adds and takes a link with the toggle, and falls back to the asset when emptied', () => {
f.state.select(edgeSelection(f.behaviorId, [f.first]));
f.state.toggleEdge(f.behaviorId, f.second);
expect(f.state.selection).toMatchObject({ kind: 'edge', edgeId: f.second, edgeIds: [f.first, f.second] });
f.state.toggleEdge(f.behaviorId, f.second);
f.state.toggleEdge(f.behaviorId, f.first);
expect(f.state.selection).toEqual({ kind: 'asset', assetId: f.behaviorId });
});
it('rides along with nodes: the node stays what the inspector shows, either toggle keeps the rest', () => {
f.state.select(graphSelection(f.behaviorId, [f.one], [], [f.first]));
expect(f.state.selection).toMatchObject({ kind: 'node', nodeId: f.one, edgeIds: [f.first] });
f.state.toggleEdge(f.behaviorId, f.second);
expect(f.state.selection).toMatchObject({ kind: 'node', nodeId: f.one, edgeIds: [f.first, f.second] });
f.state.toggleNode(f.behaviorId, f.two);
expect(f.state.selectedNodeIds).toEqual([f.one, f.two]);
expect(f.state.selectedEdgeIds).toEqual([f.first, f.second]);
// The last node toggled away leaves the links, as a selection of their own.
f.state.toggleNode(f.behaviorId, f.one);
f.state.toggleNode(f.behaviorId, f.two);
expect(f.state.selection).toMatchObject({ kind: 'edge', edgeIds: [f.first, f.second] });
});
it('deletes exactly the picked link on Delete, and one undo brings it back', () => {
f.state.select(edgeSelection(f.behaviorId, [f.first]));
expect(removeSelection(f.state)).toBe(true);
expect(f.edgeIds()).toEqual([f.second]);
// The link is gone, so the selection falls back to the graph it was in.
expect(f.state.selection).toEqual({ kind: 'asset', assetId: f.behaviorId });
f.state.undo();
expect(f.edgeIds()).toEqual([f.first, f.second]);
});
it('deletes several links, and a node with them, as one undo step', () => {
f.state.select(graphSelection(f.behaviorId, [f.one], [], [f.first, f.second]));
expect(removeSelection(f.state)).toBe(true);
expect(f.edgeIds()).toEqual([]);
expect(f.graphOf().nodes.map((n) => n.id)).toEqual([f.two, f.add]);
f.state.undo();
expect(f.edgeIds()).toEqual([f.first, f.second]);
expect(f.graphOf().nodes.map((n) => n.id)).toEqual([f.one, f.two, f.add]);
f.state.redo();
expect(f.edgeIds()).toEqual([]);
});
it('has nothing to delete when the link is already gone', () => {
expect(removeGraphSelection(f.state, f.behaviorId, [], [], ['gone'])).toBe(false);
expect(f.edgeIds()).toEqual([f.first, f.second]);
});
it('lets go of a link that is hidden, since it has no line left to be picked on', () => {
f.state.select(graphSelection(f.behaviorId, [f.one], [], [f.first]));
expect(hideLinks(f.state, f.behaviorId, [f.first])).toBe(true);
expect(f.graphOf().edges[0]!.folded).toBe(true);
expect(f.state.selection).toMatchObject({ kind: 'node', nodeId: f.one, edgeIds: [] });
});
it('drops a link from the selection when an undo takes it away', () => {
f.state.select(edgeSelection(f.behaviorId, [f.first, f.second]));
f.state.undo();
expect(f.state.selection).toMatchObject({ kind: 'edge', edgeIds: [f.first] });
});
it('describes a link by the names drawn at its two ends', () => {
expect(describeEdge(f.state.project, f.behaviorId, f.second)).toEqual({
transition: false,
from: 'Number',
output: 'value',
to: 'Add',
input: 'b',
type: 'number',
name: '',
});
expect(describeEdge(f.state.project, f.behaviorId, 'gone')).toBeNull();
expect(edgeSignature(f.state.project, f.behaviorId, f.first, [f.first, f.second])).toBe('many:2');
expect(edgeSignature(f.state.project, f.behaviorId, f.first, [f.first])).not.toBe(
edgeSignature(f.state.project, f.behaviorId, f.second, [f.second]),
);
});
});
describe('a picked transition', () => {
function machine() {
const state = new EditorState();
state.loadProject(createEmptyProject('p', 'P'), 1);
const create = new CreateAssetCommand('statemachine', 'Guard');
expect(state.execute(create)).toBe(true);
const id = create.asset.id;
const [patrol, chase] = ['Patrol', 'Chase'].map((name) => {
const add = new AddNodeCommand(id, STATE, 0, 0, { [STATE_NAME_PARAM]: name });
expect(state.execute(add)).toBe(true);
return add.node.id;
}) as [string, string];
const connect = new ConnectCommand(
id,
{ node: patrol, port: STATE_OUT },
{ node: chase, port: STATE_IN },
);
expect(state.execute(connect)).toBe(true);
return { state, id, edge: connect.edge.id };
}
it('is described by its two states and its name, and goes as a transition', () => {
const m = machine();
expect(m.state.execute(new NameTransitionCommand(m.id, m.edge, 'alarm'))).toBe(true);
expect(describeEdge(m.state.project, m.id, m.edge)).toMatchObject({
transition: true,
from: 'Patrol',
to: 'Chase',
name: 'alarm',
});
m.state.select(edgeSelection(m.id, [m.edge]));
expect(m.state.selectedStateMachineId).toBe(m.id);
expect(removeSelection(m.state)).toBe(true);
expect(findGraphAsset(m.state.project, m.id)!.graph.edges).toEqual([]);
});
});
+1
View File
@@ -71,6 +71,7 @@ describe('the picture the Shader tab previews (#168)', () => {
nodeId: 'n',
nodeIds: ['n'],
annotationIds: [],
edgeIds: [],
};
expect(pictureSubjectOf(f.project, node)).toBeNull();
});
+96
View File
@@ -1108,6 +1108,7 @@ export async function graph(t) {
await eventOrder();
await animationPlaying();
await layerOf();
await pickedLinks();
await mutate(`__gm.state.select({ kind: 'asset', assetId: 'mover' });`);
@@ -1929,4 +1930,99 @@ export async function graph(t) {
scene.objects = scene.objects.filter((o) => !o.id.startsWith('lo-'));
`);
}
// 6z-xi. Links are picked on their line (#289): a click picks one and the inspector shows it,
// Ctrl+click adds another, a rubber band takes every line it crosses, and Delete or the
// line's own menu removes what is picked. The port keeps its Disconnect.
async function pickedLinks() {
await mutate(`
const node = (id, type, x, y, params = {}) => ({ id, type, x, y, params });
const edge = (id, from, to, toPort) =>
({ id, from: { node: from, port: 'value' }, to: { node: to, port: toPort } });
__gm.state.project.assets.push({
id: 'lky',
type: 'behavior',
name: 'Link picker',
props: { enabled: true, runInEditor: false },
variables: [],
graph: {
nodes: [
node('lk-one', 'Number', -300, -160, { value: 1 }),
node('lk-two', 'Number', -300, 160, { value: 2 }),
node('lk-add', 'Add', 200, 0),
],
edges: [edge('lk-e1', 'lk-one', 'lk-add', 'a'), edge('lk-e2', 'lk-two', 'lk-add', 'b')],
},
});
__gm.state.select({ kind: 'asset', assetId: 'lky' });
`);
await showTab('graph');
await clickText('Fit');
const edges = async () => (await asset('lky')).graph.edges.map((e) => e.id);
const drawn = () => json('__gm.graph.pickedEdges()');
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const at = async (id) => json(`__gm.graph.edgeAt(${JSON.stringify(id)})`);
const [one, two] = [await at('lk-e1'), await at('lk-e2')];
await click(one.x, one.y);
let s = await state();
await shot('30-link-picked');
check(
'clicking a link picks it: it is drawn picked and the inspector shows where it runs',
s.selection?.kind === 'edge' &&
s.selection.edgeId === 'lk-e1' &&
same(await drawn(), ['lk-e1']) &&
Boolean(await find('LINK')) &&
Boolean(await find('Delete link')),
JSON.stringify({ selection: s.selection, drawn: await drawn() }),
);
await press('Delete');
const afterDelete = await edges();
await undo();
check(
'Delete removes exactly the picked link, and undo brings it back',
same(afterDelete, ['lk-e2']) && same(await edges(), ['lk-e1', 'lk-e2']),
JSON.stringify({ afterDelete, undone: await edges() }),
);
await click(one.x, one.y);
await click(two.x, two.y, 'left', { ctrl: true });
const both = await drawn();
// Off both lines and every node: a click on bare canvas lets go of them again.
await click(one.x, two.y + 60);
const none = await drawn();
await drag(one.x - 12, one.y - 30, two.x + 12, two.y + 30);
s = await state();
check(
'Ctrl+click adds a second link, and a rubber band across the lines takes both without a node',
same(both, ['lk-e1', 'lk-e2']) &&
same(none, []) &&
s.selection?.kind === 'edge' &&
same(s.selection.edgeIds, ['lk-e1', 'lk-e2']) &&
Boolean(await find('2 LINKS')),
JSON.stringify({ both, none, selection: s.selection }),
);
await click(one.x, two.y + 60);
await click(two.x, two.y, 'right');
const offered = Boolean(await find('Hide link'));
// The inspector shows a button of the same name; the menu's entry is the one drawn last.
const entry = await locate('Delete link', true);
await click(entry.x, entry.y);
const afterMenu = await edges();
await undo();
const port = await json(`__gm.graph.portAt('lk-add', 'a', false)`);
await click(port.x, port.y, 'right');
const atPort = Boolean(await find('Disconnect'));
await press('Escape');
check(
'right-clicking a link offers to hide or delete it, and the port keeps its Disconnect',
offered && same(afterMenu, ['lk-e1']) && same(await edges(), ['lk-e1', 'lk-e2']) && atPort,
JSON.stringify({ offered, afterMenu, atPort }),
);
await mutate(`
const p = __gm.state.project;
p.assets = p.assets.filter((a) => a.id !== 'lky');
`);
}
}
+29
View File
@@ -12,6 +12,7 @@ export async function stateMachine(t) {
check,
sleep,
json,
state,
evaluate,
shot,
mutate,
@@ -364,6 +365,34 @@ export async function stateMachine(t) {
(await transitions()).filter((e) => e.from.node === first && e.to.node === second).length === 1,
);
// 26e7. (#289) The arrow itself is a handle: a click picks the transition and the inspector shows
// it, Delete takes exactly that one, and an undo brings it back.
const alarm = (await transitions()).find((e) => e.from.node === first && e.to.node === second);
const arrow = await json(`__gm.stateMachine.edgeAt(${JSON.stringify(alarm?.id)})`);
const beforePick = (await transitions()).length;
await click(arrow.x, arrow.y);
const arrowSelection = (await state()).selection;
await shot('05f-picked');
check(
'clicking a transition picks it: the inspector shows it with its name and the way to delete it',
arrowSelection?.kind === 'edge' &&
arrowSelection.edgeId === alarm?.id &&
JSON.stringify(await json('__gm.stateMachine.pickedEdges()')) === JSON.stringify([alarm?.id]) &&
(await has('TRANSITION')) &&
(await has('Delete transition')),
JSON.stringify({ arrowSelection, drawn: await json('__gm.stateMachine.pickedEdges()') }),
);
await press('Delete');
const afterDelete = await transitions();
await undo();
check(
'Delete takes the picked transition alone, and undo brings it back',
afterDelete.length === beforePick - 1 &&
!afterDelete.some((e) => e.id === alarm?.id) &&
(await transitions()).some((e) => e.id === alarm?.id && e.label === 'alarm'),
JSON.stringify({ beforePick, afterDelete }),
);
// 26f. A behavior reads the machine: Set state on start, State branch every frame.
const rig = await scaffoldBehavior(t, {
assetId: 'sm-logic',