Pick a link on its line and delete it from there (#289)
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:
1 parent
0f85476b93
commit
c5ba21533d
15 files changed
+904
-41
No files matched your search
@@ -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
@@ -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));
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -71,6 +71,7 @@ describe('the picture the Shader tab previews (#168)', () => {
|
||||
nodeId: 'n',
|
||||
nodeIds: ['n'],
|
||||
annotationIds: [],
|
||||
edgeIds: [],
|
||||
};
|
||||
expect(pictureSubjectOf(f.project, node)).toBeNull();
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user