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

+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);
}