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>
134 lines
4.8 KiB
TypeScript
134 lines
4.8 KiB
TypeScript
import {
|
||
DisconnectCommand,
|
||
NameTransitionCommand,
|
||
SetNodeParamCommand,
|
||
} from '../../core/commands/graphCommands';
|
||
import {
|
||
findNode,
|
||
type Graph,
|
||
type GraphEdge,
|
||
type GraphNode,
|
||
isStateNode,
|
||
machineOf,
|
||
stateName,
|
||
TRANSITION_PARAM,
|
||
transitionNames,
|
||
} from '../../core/graph';
|
||
import { graphLookup } from '../../core/model/graphLookup';
|
||
import { textOf } from '../../core/model/Properties';
|
||
import { Dropdown, HStack, Icon, Label, Menu, Spacer, TextField, Theme } from '../../ui';
|
||
import { ARROW_ICON_SVG } from '../panels/markIcons';
|
||
import { NAME_WIDTH, removeButton } from './fieldFactory';
|
||
import { addNote, addTitle, type InspectorHost } from './host';
|
||
|
||
/** The name `State transition` asks for, picked from the machine on its `machine` input – the
|
||
* options depend on another field, which no field kind can express. */
|
||
export function buildTransitionPicker(
|
||
host: InspectorHost,
|
||
assetId: string,
|
||
node: GraphNode,
|
||
graph: Graph,
|
||
): void {
|
||
const lookup = graphLookup(host.state.project);
|
||
const machine = machineOf({ node, graph, lookup });
|
||
const names = transitionNames(machine);
|
||
const chosen = textOf(node.params[TRANSITION_PARAM] ?? '');
|
||
const known = chosen === '' || names.includes(chosen);
|
||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||
row.minHeight = Theme.rowHeight;
|
||
row.add(new Label('Transition', { style: 'muted' }));
|
||
row.add(new Spacer());
|
||
const choose = new Dropdown(chosen || UNNAMED, { variant: known ? 'default' : 'danger' });
|
||
choose.onClick = () => {
|
||
const items = [UNNAMED, ...names].map((name) => ({
|
||
label: name,
|
||
onSelect: () =>
|
||
host.state.execute(
|
||
new SetNodeParamCommand(assetId, node.id, TRANSITION_PARAM, name === UNNAMED ? '' : name),
|
||
),
|
||
}));
|
||
new Menu(items, { searchable: names.length > 8 }).openBelow(choose);
|
||
};
|
||
row.add(choose);
|
||
host.body.add(row);
|
||
if (!machine) addNote(host, 'Pick a machine first.');
|
||
else if (!known) addNote(host, `"${chosen}" is not a transition of ${machine.name}.`, 'warning');
|
||
}
|
||
|
||
/** 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 – all of them at a glance, where the canvas shows
|
||
* one picked arrow at a time (#289). */
|
||
export function buildTransitionSection(
|
||
host: InspectorHost,
|
||
assetId: string,
|
||
node: GraphNode,
|
||
graph: Graph,
|
||
): void {
|
||
const out = transitionsAt(graph, node.id, true);
|
||
const into = transitionsAt(graph, node.id, false);
|
||
group(host, assetId, graph, 'TRANSITIONS OUT', out, true);
|
||
group(host, assetId, graph, 'TRANSITIONS IN', into, false);
|
||
const duplicate = firstDuplicate(out.map((e) => e.label ?? ''));
|
||
if (duplicate !== null)
|
||
addNote(
|
||
host,
|
||
duplicate
|
||
? `Two transitions out of here are called "${duplicate}" – neither will be taken.`
|
||
: 'Two transitions out of here are unnamed – neither will be taken.',
|
||
'warning',
|
||
);
|
||
}
|
||
|
||
/** Edges joining this state to a state, on the given side. One back into itself is listed as going
|
||
* out only – it is one transition, and twice in the panel it would read as two. */
|
||
function transitionsAt(graph: Graph, nodeId: string, outgoing: boolean): GraphEdge[] {
|
||
return graph.edges.filter((e) => {
|
||
const [mine, other] = outgoing ? [e.from.node, e.to.node] : [e.to.node, e.from.node];
|
||
const far = findNode(graph, other);
|
||
return mine === nodeId && far !== undefined && isStateNode(far) && (outgoing || other !== nodeId);
|
||
});
|
||
}
|
||
|
||
/** The first name that appears twice, or null. An empty name counts: it is the unnamed transition. */
|
||
function firstDuplicate(names: readonly string[]): string | null {
|
||
const seen = new Set<string>();
|
||
for (const name of names) {
|
||
if (seen.has(name)) return name;
|
||
seen.add(name);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function group(
|
||
host: InspectorHost,
|
||
assetId: string,
|
||
graph: Graph,
|
||
title: string,
|
||
edges: readonly GraphEdge[],
|
||
outgoing: boolean,
|
||
): void {
|
||
addTitle(host, title);
|
||
if (edges.length === 0) {
|
||
addNote(host, outgoing ? 'Nothing leads out of this state yet.' : 'Nothing leads here yet.');
|
||
return;
|
||
}
|
||
for (const edge of edges) {
|
||
const far = findNode(graph, outgoing ? edge.to.node : edge.from.node);
|
||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||
row.minHeight = Theme.rowHeight;
|
||
row.add(new Icon(outgoing ? ARROW_ICON_SVG.out : ARROW_ICON_SVG.in));
|
||
row.add(new Label(far ? stateName(far) : '?', { style: 'muted' }));
|
||
row.add(new Spacer());
|
||
row.add(
|
||
new TextField(edge.label ?? '', {
|
||
width: NAME_WIDTH,
|
||
onChange: (next) => host.state.execute(new NameTransitionCommand(assetId, edge.id, next)),
|
||
}),
|
||
);
|
||
row.add(removeButton(() => host.state.execute(new DisconnectCommand(assetId, edge.id))));
|
||
host.body.add(row);
|
||
}
|
||
}
|