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
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user