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>
125 lines
4.5 KiB
TypeScript
125 lines
4.5 KiB
TypeScript
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;
|
|
}
|