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>
104 lines
3.8 KiB
TypeScript
104 lines
3.8 KiB
TypeScript
/** 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);
|
||
}
|