Let a link be hidden, and shown again by selecting either end
A link can be folded away from the canvas: instead of a curve running clear across the graph, each end grows a short stub with a mark naming the node at the far side. The curve comes back while either of its two nodes is selected, so following a connection is a click rather than a search, and a link carrying a live pulse is always drawn so the trace keeps its thread. Marks are collected per port, not per link, because several hidden links on one port used to stack stubs, dots and labels on identical pixels. A bundle reads "Move +1" and its dot selects every node the port feeds, as one undo step. The whole thing is one optional flag on the edge; the interpreter never sees it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
558365d33f
commit
4b0919a224
9 files changed
+611
-13
No files matched your search
@@ -177,6 +177,19 @@ shown on the node and editable in the inspector.
|
||||
in as **one** undo step (`core/graph/dropTarget.ts` decides all of this, the canvas only draws
|
||||
it). The dangling edge stays on screen while that palette is up, so the connection being made is
|
||||
still visible while its node is chosen.
|
||||
- **A link can be hidden**, so a connection across the whole graph does not have to be drawn as a
|
||||
line across the whole graph. Right-click a port → *Hide link*; the curve is replaced by a short
|
||||
stub at each end, ending in a dot captioned with the node at the *other* end. The connection is
|
||||
untouched – hiding is drawing only, and `folded` on the edge is the entire change. The curve
|
||||
comes back, dimmed, **while either of its two nodes is selected**, and always while a pulse is
|
||||
travelling it, so the live trace never loses its thread. Clicking a dot selects the node at the
|
||||
far end, which both answers "where does this go?" and lends the curve back;
|
||||
right-clicking it offers *Show link* and *Disconnect* (`editor/graph/folding.ts` holds the
|
||||
visibility rule, the canvas only draws it). **A port's hidden links share one mark** – drawn per
|
||||
link, several of them would stack stub, dot and caption on the very same pixels. The mark then
|
||||
grows a little, is captioned `Add +2`, and one click selects *every* far end at once, which is
|
||||
also what lends all of its curves back; the menu counts what it acts on (*Show 3 links*), and
|
||||
does it as one undo step.
|
||||
- **Header bar** (the same one the Texture and Container tabs have): the behavior picker,
|
||||
**Add node…** (the palette, dropping the node in the middle of the view – the right-click nobody
|
||||
finds), **Fit** (which zooms out far enough to hold every node, measuring each by its own drawn
|
||||
|
||||
@@ -179,6 +179,41 @@ export class ConnectCommand implements Command {
|
||||
}
|
||||
}
|
||||
|
||||
/** Folding is drawing only: the edge keeps carrying its value either way. */
|
||||
export class FoldEdgeCommand implements Command {
|
||||
readonly label: string;
|
||||
private previous = false;
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly edgeId: string,
|
||||
private readonly folded: boolean,
|
||||
) {
|
||||
this.label = folded ? 'Hide link' : 'Show link';
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const edge = this.edge(project);
|
||||
this.previous = edge.folded === true;
|
||||
this.apply(edge, this.folded);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
this.apply(this.edge(project), this.previous);
|
||||
}
|
||||
|
||||
private edge(project: Project): GraphEdge {
|
||||
const edge = requireBehavior(project, this.assetId).graph.edges.find((e) => e.id === this.edgeId);
|
||||
if (!edge) throw new CommandError(`Edge ${this.edgeId} not found`);
|
||||
return edge;
|
||||
}
|
||||
|
||||
private apply(edge: GraphEdge, folded: boolean): void {
|
||||
if (folded) edge.folded = true;
|
||||
else delete edge.folded;
|
||||
}
|
||||
}
|
||||
|
||||
export class DisconnectCommand implements Command {
|
||||
readonly label = 'Disconnect';
|
||||
private removed: Removed<GraphEdge>[] = [];
|
||||
|
||||
@@ -42,6 +42,8 @@ export interface GraphEdge {
|
||||
id: string;
|
||||
from: PortRef;
|
||||
to: PortRef;
|
||||
/** Drawn as a stub at each end instead of a curve across the graph. Purely visual. */
|
||||
folded?: boolean;
|
||||
}
|
||||
|
||||
export interface Graph {
|
||||
|
||||
@@ -240,6 +240,7 @@ function normalizeGraph(raw: unknown): Graph {
|
||||
from: { node: from.node, port: from.port },
|
||||
to: { node: to.node, port: to.port },
|
||||
};
|
||||
if (e.folded === true) edge.folded = true;
|
||||
graph.edges.push(edge);
|
||||
}
|
||||
return graph;
|
||||
|
||||
@@ -194,6 +194,9 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
selected: () => state.selectedNodeIds,
|
||||
dropTarget: () => graphEditor.canvas.pendingDropTarget(),
|
||||
danglingEdge: () => graphEditor.canvas.danglingEdgeVisible(),
|
||||
edgeDrawn: (edgeId: string) => graphEditor.canvas.edgeDrawn(edgeId),
|
||||
foldMark: (edgeId: string, isOutput: boolean) => graphEditor.canvas.foldMarkGlobal(edgeId, isOutput),
|
||||
foldMarks: () => graphEditor.canvas.foldMarksDrawn(),
|
||||
// The canvas, not the whole tab: a centre measured through the header sits above the
|
||||
// drawing area.
|
||||
center: () => centreOf(graphEditor.canvas),
|
||||
|
||||
+214
-13
@@ -5,6 +5,7 @@ import {
|
||||
AddNodeCommand,
|
||||
ConnectCommand,
|
||||
DisconnectCommand,
|
||||
FoldEdgeCommand,
|
||||
MoveNodeCommand,
|
||||
} from '../../core/commands/graphCommands';
|
||||
import {
|
||||
@@ -37,16 +38,19 @@ import {
|
||||
drawGrid,
|
||||
endDrag,
|
||||
Menu,
|
||||
type MenuItem,
|
||||
onDragChange,
|
||||
type Size,
|
||||
Theme,
|
||||
textStyle,
|
||||
truncate,
|
||||
Widget,
|
||||
zoomFactor,
|
||||
} from '../../ui';
|
||||
import { pressCount } from '../../ui/clicks';
|
||||
import { removeNodes } from '../actions';
|
||||
import { type EditorState, nodeSelection } from '../state';
|
||||
import { foldedEdgeShown, foldMarkKey, foldMarkLabel } from './folding';
|
||||
import { type Box, boxesInBand, frameBoxes } from './framing';
|
||||
import { NodePalette } from './NodePalette';
|
||||
import { type PaletteEntry, paletteEntries } from './paletteEntries';
|
||||
@@ -63,6 +67,29 @@ const NODE_HEIGHT_ESTIMATE = 80;
|
||||
const FIT_MARGIN = 24;
|
||||
/** Pointer travel (screen pixels) before a press turns into a drag rather than a click. */
|
||||
const DRAG_THRESHOLD = 3;
|
||||
/** How far a folded edge's stub reaches out of its port. */
|
||||
const FOLD_STUB = 16;
|
||||
const FOLD_MARK_RADIUS = 5;
|
||||
const FOLD_LABEL_WIDTH = 96;
|
||||
|
||||
/** One port's hidden links as drawn: what they stand in for, and where they can be clicked. */
|
||||
interface FoldMark {
|
||||
edgeIds: string[];
|
||||
/** The nodes at the other ends, deduped – clicking selects them all. */
|
||||
others: string[];
|
||||
isOutput: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** A mark while the edges are still adding themselves to it. */
|
||||
interface PendingMark extends Omit<FoldMark, 'text'> {
|
||||
names: string[];
|
||||
portX: number;
|
||||
color: number;
|
||||
hot: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The node graph of one behavior asset. Which behavior that is comes from the outside (`show`),
|
||||
@@ -74,6 +101,15 @@ export class GraphCanvas extends Widget {
|
||||
private readonly clip = new Graphics();
|
||||
private readonly canvas = new Container();
|
||||
private readonly edges = new Graphics();
|
||||
private readonly markChips = new Graphics();
|
||||
/** Texts naming the far node of each folded edge; pooled, since a redraw rebuilds them all. */
|
||||
private readonly markLayer = new Container();
|
||||
private readonly markLabels: Text[] = [];
|
||||
private markLabelCount = 0;
|
||||
private foldMarks: FoldMark[] = [];
|
||||
private readonly foldPending = new Map<string, PendingMark>();
|
||||
/** Edges the last redraw really stroked as a curve – a hidden one is missing from it. */
|
||||
private readonly strokedEdges = new Set<string>();
|
||||
private readonly nodeLayer = new Container();
|
||||
private readonly tempEdge = new Graphics();
|
||||
/** Drawn in screen space, so the band keeps a hairline border at any zoom. */
|
||||
@@ -113,7 +149,7 @@ export class GraphCanvas extends Widget {
|
||||
|
||||
constructor(private readonly state: EditorState) {
|
||||
super();
|
||||
this.canvas.addChild(this.edges, this.nodeLayer, this.tempEdge);
|
||||
this.canvas.addChild(this.edges, this.markChips, this.markLayer, this.nodeLayer, this.tempEdge);
|
||||
this.addChild(this.bg, this.grid, this.canvas, this.band, this.hint, this.clip);
|
||||
this.canvas.mask = this.clip;
|
||||
this.cursor = 'default';
|
||||
@@ -287,14 +323,20 @@ export class GraphCanvas extends Widget {
|
||||
return;
|
||||
}
|
||||
const local = this.toLocal(e.global);
|
||||
const g = this.screenToGraph(local.x, local.y);
|
||||
const mark = this.state.mode === 'edit' ? this.markAt(g) : null;
|
||||
if (e.button === 2) {
|
||||
if (this.assetId && this.state.mode === 'edit')
|
||||
this.openPalette(e.global.x, e.global.y, this.screenToGraph(local.x, local.y));
|
||||
if (mark) this.openMarkMenu(mark, e.global.x, e.global.y);
|
||||
else if (this.assetId && this.state.mode === 'edit') this.openPalette(e.global.x, e.global.y, g);
|
||||
return;
|
||||
}
|
||||
if (e.button === 0) {
|
||||
// Selecting the far nodes is also what puts the hidden curves back on screen.
|
||||
if (mark && this.assetId) {
|
||||
this.state.select(nodeSelection(this.assetId, mark.others));
|
||||
return;
|
||||
}
|
||||
// Empty space: a click selects the behavior, a drag becomes a rubber band – decided on release.
|
||||
const g = this.screenToGraph(local.x, local.y);
|
||||
this.marquee = { start: g, current: g, active: false };
|
||||
}
|
||||
}
|
||||
@@ -654,13 +696,56 @@ export class GraphCanvas extends Widget {
|
||||
? edgesOutOf(asset.graph, view.node.id, port)
|
||||
: edgesInto(asset.graph, view.node.id, port);
|
||||
if (edges.length === 0) return;
|
||||
const shown = edges.filter((e) => !e.folded);
|
||||
const hidden = edges.filter((e) => e.folded);
|
||||
const fold = (list: GraphEdge[], folded: boolean) => () => {
|
||||
const commands = list.map((e) => new FoldEdgeCommand(asset.id, e.id, folded));
|
||||
this.state.execute(
|
||||
commands.length === 1
|
||||
? commands[0]!
|
||||
: new CompoundCommand(folded ? 'Hide links' : 'Show links', commands),
|
||||
);
|
||||
};
|
||||
const items: MenuItem[] = [];
|
||||
if (shown.length > 0)
|
||||
items.push({
|
||||
label: shown.length === 1 ? 'Hide link' : `Hide ${shown.length} links`,
|
||||
onSelect: fold(shown, true),
|
||||
});
|
||||
if (hidden.length > 0)
|
||||
items.push({
|
||||
label: hidden.length === 1 ? 'Show link' : `Show ${hidden.length} links`,
|
||||
onSelect: fold(hidden, false),
|
||||
});
|
||||
items.push({
|
||||
label: edges.length === 1 ? 'Disconnect' : `Disconnect ${edges.length} links`,
|
||||
danger: true,
|
||||
onSelect: () => {
|
||||
for (const edge of edges) this.state.execute(new DisconnectCommand(asset.id, edge.id));
|
||||
},
|
||||
});
|
||||
new Menu(items).open(globalX, globalY);
|
||||
}
|
||||
|
||||
/** The mark is the only handle a hidden edge has, so it carries the way back. */
|
||||
private openMarkMenu(mark: FoldMark, globalX: number, globalY: number): void {
|
||||
const assetId = this.assetId;
|
||||
if (!assetId) return;
|
||||
const ids = mark.edgeIds;
|
||||
const n = ids.length;
|
||||
const all = (title: string, make: (edgeId: string) => Command) => () => {
|
||||
const commands = ids.map(make);
|
||||
this.state.execute(commands.length === 1 ? commands[0]! : new CompoundCommand(title, commands));
|
||||
};
|
||||
new Menu([
|
||||
{
|
||||
label: edges.length === 1 ? 'Disconnect' : `Disconnect ${edges.length} links`,
|
||||
label: n === 1 ? 'Show link' : `Show ${n} links`,
|
||||
onSelect: all('Show links', (id) => new FoldEdgeCommand(assetId, id, false)),
|
||||
},
|
||||
{
|
||||
label: n === 1 ? 'Disconnect' : `Disconnect ${n} links`,
|
||||
danger: true,
|
||||
onSelect: () => {
|
||||
for (const edge of edges) this.state.execute(new DisconnectCommand(asset.id, edge.id));
|
||||
},
|
||||
onSelect: all('Disconnect links', (id) => new DisconnectCommand(assetId, id)),
|
||||
},
|
||||
]).open(globalX, globalY);
|
||||
}
|
||||
@@ -703,7 +788,15 @@ export class GraphCanvas extends Widget {
|
||||
}
|
||||
|
||||
this.edges.clear();
|
||||
for (const edge of asset.graph.edges) this.drawEdge(edge, isHot(trace?.edges, edge.id));
|
||||
this.markChips.clear();
|
||||
this.foldMarks = [];
|
||||
this.markLabelCount = 0;
|
||||
this.strokedEdges.clear();
|
||||
this.foldPending.clear();
|
||||
for (const edge of asset.graph.edges) this.drawEdge(edge, isHot(trace?.edges, edge.id), selectedNodes);
|
||||
// After the curves, so no line drawn later crosses a mark that stands for a hidden one.
|
||||
this.drawFoldMarks();
|
||||
for (let i = this.markLabelCount; i < this.markLabels.length; i++) this.markLabels[i]!.visible = false;
|
||||
|
||||
this.tempEdge.clear();
|
||||
this.tempEdgeShown = false;
|
||||
@@ -751,7 +844,7 @@ export class GraphCanvas extends Widget {
|
||||
return view && p ? { x: view.x + p.x, y: view.y + p.y } : null;
|
||||
}
|
||||
|
||||
private drawEdge(edge: GraphEdge, hot: boolean): void {
|
||||
private drawEdge(edge: GraphEdge, hot: boolean, selected: Set<string>): void {
|
||||
const from = this.views.get(edge.from.node);
|
||||
const to = this.views.get(edge.to.node);
|
||||
const a = from?.portPosition(edge.from.port, true);
|
||||
@@ -760,12 +853,99 @@ export class GraphCanvas extends Widget {
|
||||
const type = from.def.outputs.find((p) => p.key === edge.from.port)?.type ?? 'any';
|
||||
const inType = to.def.inputs.find((p) => p.key === edge.to.port)?.type ?? 'any';
|
||||
const valid = portsCompatible(type, inType);
|
||||
bezier(this.edges, from.x + a.x, from.y + a.y, to.x + b.x, to.y + b.y);
|
||||
const color = hot ? Theme.colors.hot : valid ? portColor(type) : Theme.colors.danger;
|
||||
const start = { x: from.x + a.x, y: from.y + a.y };
|
||||
const end = { x: to.x + b.x, y: to.y + b.y };
|
||||
if (edge.folded) {
|
||||
this.addFoldMark(edge, start, true, to.def.label, color, hot);
|
||||
this.addFoldMark(edge, end, false, from.def.label, color, hot);
|
||||
if (!foldedEdgeShown(edge, selected, hot)) return;
|
||||
}
|
||||
bezier(this.edges, start.x, start.y, end.x, end.y);
|
||||
this.edges.stroke({
|
||||
width: hot ? 4 : type === 'exec' ? 2.5 : 2,
|
||||
color: hot ? Theme.colors.hot : valid ? portColor(type) : Theme.colors.danger,
|
||||
alpha: type === 'exec' || hot ? 1 : 0.85,
|
||||
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),
|
||||
});
|
||||
this.strokedEdges.add(edge.id);
|
||||
}
|
||||
|
||||
/** Adds one end of a hidden link to its port's mark, creating that mark on first sight. */
|
||||
private addFoldMark(
|
||||
edge: GraphEdge,
|
||||
port: { x: number; y: number },
|
||||
isOutput: boolean,
|
||||
otherLabel: string,
|
||||
color: number,
|
||||
hot: boolean,
|
||||
): void {
|
||||
const [side, other] = isOutput ? [edge.from, edge.to.node] : [edge.to, edge.from.node];
|
||||
const key = foldMarkKey(side, isOutput);
|
||||
let mark = this.foldPending.get(key);
|
||||
if (!mark) {
|
||||
mark = {
|
||||
edgeIds: [],
|
||||
others: [],
|
||||
names: [],
|
||||
isOutput,
|
||||
x: port.x + (isOutput ? FOLD_STUB : -FOLD_STUB),
|
||||
y: port.y,
|
||||
portX: port.x,
|
||||
color,
|
||||
hot,
|
||||
};
|
||||
this.foldPending.set(key, mark);
|
||||
}
|
||||
mark.edgeIds.push(edge.id);
|
||||
mark.names.push(otherLabel);
|
||||
if (!mark.others.includes(other)) mark.others.push(other);
|
||||
// A pulse anywhere in the bundle colours the whole mark: it is the one thing worth spotting.
|
||||
if (hot && !mark.hot) {
|
||||
mark.hot = true;
|
||||
mark.color = color;
|
||||
}
|
||||
}
|
||||
|
||||
/** A stub out of the port, a dot, and what waits at the other end – one per port, not per link. */
|
||||
private drawFoldMarks(): void {
|
||||
for (const mark of this.foldPending.values()) {
|
||||
const { x, y, color, isOutput } = mark;
|
||||
this.edges.moveTo(mark.portX, y).lineTo(x, y).stroke({ width: 2, color, alpha: 0.9 });
|
||||
const radius = mark.edgeIds.length > 1 ? FOLD_MARK_RADIUS + 2 : FOLD_MARK_RADIUS;
|
||||
this.edges.circle(x, y, radius).fill(color);
|
||||
this.edges.circle(x, y, radius).stroke({ width: 1, color: Theme.colors.node });
|
||||
const label = this.markLabel();
|
||||
label.text = truncate(foldMarkLabel(mark.names), textStyle('small'), FOLD_LABEL_WIDTH);
|
||||
const gap = radius + 4;
|
||||
const lx = isOutput ? x + gap : x - gap - label.width;
|
||||
label.position.set(lx, y - label.height / 2);
|
||||
// Without a plate behind it the name blurs into whatever curves pass behind the mark.
|
||||
this.markChips
|
||||
.roundRect(lx - 3, y - label.height / 2 - 1, label.width + 6, label.height + 2, 3)
|
||||
.fill({ color: Theme.colors.node, alpha: 0.85 });
|
||||
this.foldMarks.push({ edgeIds: mark.edgeIds, others: mark.others, isOutput, x, y, text: label.text });
|
||||
}
|
||||
}
|
||||
|
||||
private markLabel(): Text {
|
||||
let label = this.markLabels[this.markLabelCount];
|
||||
if (!label) {
|
||||
label = new Text({ text: '', style: textStyle('small') });
|
||||
this.markLabels.push(label);
|
||||
this.markLayer.addChild(label);
|
||||
}
|
||||
label.visible = true;
|
||||
this.markLabelCount++;
|
||||
return label;
|
||||
}
|
||||
|
||||
private markAt(g: { x: number; y: number }): FoldMark | null {
|
||||
for (const m of this.foldMarks) {
|
||||
const radius = m.edgeIds.length > 1 ? FOLD_MARK_RADIUS + 2 : FOLD_MARK_RADIUS;
|
||||
if (Math.hypot(g.x - m.x, g.y - m.y) <= radius + 4) return m;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private syncGrid(): void {
|
||||
@@ -804,6 +984,27 @@ export class GraphCanvas extends Widget {
|
||||
return this.tempEdgeShown;
|
||||
}
|
||||
|
||||
/** Whether the last redraw drew this edge as a curve – the claim "hidden" is about exactly this. */
|
||||
edgeDrawn(edgeId: string): boolean {
|
||||
return this.strokedEdges.has(edgeId);
|
||||
}
|
||||
|
||||
/** Every mark the last redraw drew – its length is the claim "one per port, not one per link". */
|
||||
foldMarksDrawn(): { links: number; label: string; isOutput: boolean }[] {
|
||||
return this.foldMarks.map((m) => ({ links: m.edgeIds.length, label: m.text, isOutput: m.isOutput }));
|
||||
}
|
||||
|
||||
/** Global screen position of a folded edge's mark at one end, and what that mark stands for. */
|
||||
foldMarkGlobal(
|
||||
edgeId: string,
|
||||
isOutput: boolean,
|
||||
): { x: number; y: number; links: number; label: string } | null {
|
||||
const mark = this.foldMarks.find((m) => m.edgeIds.includes(edgeId) && m.isOutput === isOutput);
|
||||
if (!mark) return null;
|
||||
const g = this.canvas.toGlobal({ x: mark.x, y: mark.y });
|
||||
return { x: g.x, y: g.y, links: mark.edgeIds.length, label: mark.text };
|
||||
}
|
||||
|
||||
/** A node's drawn rectangle on screen, and the canvas' own – "fit" is a claim about both. */
|
||||
nodeRect(nodeId: string): { x: number; y: number; w: number; h: number } | null {
|
||||
const view = this.views.get(nodeId);
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CommandError } from '../../src/core/commands/Command';
|
||||
import { ConnectCommand, FoldEdgeCommand } from '../../src/core/commands/graphCommands';
|
||||
import { GraphInterpreter } from '../../src/core/graph';
|
||||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||||
import { findAsset } from '../../src/core/model/Project';
|
||||
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
|
||||
import { foldedEdgeShown, foldMarkKey, foldMarkLabel } from '../../src/editor/graph/folding';
|
||||
import { fakeContext, graphEditor, makeBehavior, makeProject, roundTrip } from '../helpers';
|
||||
|
||||
/** "On start → Move(self, dx = 7)": two edges, so a fold can hide one and leave the other. */
|
||||
function mover() {
|
||||
const fixture = makeProject();
|
||||
const behavior = makeBehavior(fixture, 'Mover');
|
||||
const edit = graphEditor(fixture, behavior);
|
||||
const start = edit.addNode('OnStart');
|
||||
const self = edit.addNode('Self');
|
||||
const move = edit.addNode('Move', { dx: 7 });
|
||||
edit.wire([
|
||||
[start, 'exec', move, 'exec'],
|
||||
[self, 'object', move, 'object'],
|
||||
]);
|
||||
return { ...fixture, behavior, edit, nodes: { start, self, move } };
|
||||
}
|
||||
|
||||
describe('folding an edge', () => {
|
||||
it('changes nothing about what the graph does – the value still arrives', () => {
|
||||
const { project, stack, behavior } = mover();
|
||||
for (const edge of behavior.graph.edges) stack.execute(new FoldEdgeCommand(behavior.id, edge.id, true));
|
||||
expect(behavior.graph.edges.every((e) => e.folded === true)).toBe(true);
|
||||
|
||||
const ctx = fakeContext();
|
||||
new GraphInterpreter(behavior.graph).fire('OnStart', ctx);
|
||||
expect(ctx.self.x).toBe(7);
|
||||
// And the edges themselves are untouched apart from the flag.
|
||||
expect(findAsset(project, behavior.id)).toBe(behavior);
|
||||
expect(behavior.graph.edges).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('survives the save, and unfolding leaves no trace in the file', () => {
|
||||
const { project, stack, behavior } = mover();
|
||||
const [first, second] = behavior.graph.edges;
|
||||
stack.execute(new FoldEdgeCommand(behavior.id, first!.id, true));
|
||||
|
||||
const loaded = findAsset(roundTrip(project), behavior.id) as BehaviorAsset;
|
||||
expect(loaded.graph.edges.find((e) => e.id === first!.id)!.folded).toBe(true);
|
||||
expect('folded' in loaded.graph.edges.find((e) => e.id === second!.id)!).toBe(false);
|
||||
|
||||
stack.execute(new FoldEdgeCommand(behavior.id, first!.id, false));
|
||||
const again = findAsset(roundTrip(project), behavior.id) as BehaviorAsset;
|
||||
expect(again.graph.edges.every((e) => !('folded' in e))).toBe(true);
|
||||
});
|
||||
|
||||
it('is undoable, and undo restores the state the edge really had', () => {
|
||||
const { stack, behavior } = mover();
|
||||
const edge = behavior.graph.edges[0]!;
|
||||
stack.execute(new FoldEdgeCommand(behavior.id, edge.id, true));
|
||||
// Folding an already folded edge must not make undo unfold it.
|
||||
stack.execute(new FoldEdgeCommand(behavior.id, edge.id, true));
|
||||
stack.undo();
|
||||
expect(edge.folded).toBe(true);
|
||||
stack.undo();
|
||||
expect(edge.folded).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reports a missing edge as a CommandError instead of throwing from the constructor', () => {
|
||||
const { project, behavior } = mover();
|
||||
const command = new FoldEdgeCommand(behavior.id, 'no-such-edge', true);
|
||||
expect(command.label).toBe('Hide link');
|
||||
expect(() => command.execute(project)).toThrow(CommandError);
|
||||
});
|
||||
|
||||
it('belongs to the edge, not to the port: a replacement starts out shown', () => {
|
||||
const { stack, behavior, edit, nodes } = mover();
|
||||
const edge = behavior.graph.edges.find((e) => e.to.port === 'object')!;
|
||||
stack.execute(new FoldEdgeCommand(behavior.id, edge.id, true));
|
||||
|
||||
const owner = edit.addNode('Owner');
|
||||
stack.execute(
|
||||
new ConnectCommand(
|
||||
behavior.id,
|
||||
{ node: owner.id, port: 'object' },
|
||||
{ node: nodes.move.id, port: 'object' },
|
||||
),
|
||||
);
|
||||
const now = behavior.graph.edges.find((e) => e.to.port === 'object')!;
|
||||
expect(now.id).not.toBe(edge.id);
|
||||
expect(now.folded).toBeUndefined();
|
||||
// Undo puts the old edge back exactly as it was – folded.
|
||||
stack.undo();
|
||||
expect(behavior.graph.edges.find((e) => e.to.port === 'object')!.folded).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores a folded flag in the file that is not literally true', () => {
|
||||
const { project, behavior } = mover();
|
||||
const raw = serializeProject(project) as {
|
||||
assets: { id: string; graph?: { edges: Record<string, unknown>[] } }[];
|
||||
};
|
||||
const edges = raw.assets.find((a) => a.id === behavior.id)!.graph!.edges;
|
||||
edges[0]!.folded = 'yes';
|
||||
edges[1]!.folded = false;
|
||||
const loaded = findAsset(deserializeProject(raw), behavior.id) as BehaviorAsset;
|
||||
expect(loaded.graph.edges.every((e) => !('folded' in e))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when a folded edge shows its curve anyway', () => {
|
||||
const edge = { id: 'e', from: { node: 'a', port: 'out' }, to: { node: 'b', port: 'in' } };
|
||||
|
||||
it('stays hidden while neither end is selected', () => {
|
||||
expect(foldedEdgeShown(edge, new Set(['c']), false)).toBe(false);
|
||||
expect(foldedEdgeShown(edge, new Set(), false)).toBe(false);
|
||||
});
|
||||
|
||||
it('comes back for either end, not just the source', () => {
|
||||
expect(foldedEdgeShown(edge, new Set(['a']), false)).toBe(true);
|
||||
expect(foldedEdgeShown(edge, new Set(['b']), false)).toBe(true);
|
||||
});
|
||||
|
||||
it('always shows while a pulse is on it, so the live trace keeps its thread', () => {
|
||||
expect(foldedEdgeShown(edge, new Set(), true)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('several hidden links at one port', () => {
|
||||
it('share a mark, while a different port or side gets its own', () => {
|
||||
const out = foldMarkKey({ node: 'a', port: 'value' }, true);
|
||||
expect(foldMarkKey({ node: 'a', port: 'value' }, true)).toBe(out);
|
||||
expect(foldMarkKey({ node: 'a', port: 'other' }, true)).not.toBe(out);
|
||||
expect(foldMarkKey({ node: 'b', port: 'value' }, true)).not.toBe(out);
|
||||
// The same port on the other side of the node is a mark of its own.
|
||||
expect(foldMarkKey({ node: 'a', port: 'value' }, false)).not.toBe(out);
|
||||
});
|
||||
|
||||
it('cannot be confused by a node or port name that contains the separator', () => {
|
||||
expect(foldMarkKey({ node: 'a b', port: 'c' }, true)).not.toBe(
|
||||
foldMarkKey({ node: 'a', port: 'b c' }, true),
|
||||
);
|
||||
});
|
||||
|
||||
it('name one far node and count the rest, so nothing is written on top of anything', () => {
|
||||
expect(foldMarkLabel(['Add'])).toBe('Add');
|
||||
expect(foldMarkLabel(['Add', 'Compare'])).toBe('Add +1');
|
||||
expect(foldMarkLabel(['Add', 'Compare', 'Multiply'])).toBe('Add +2');
|
||||
expect(foldMarkLabel([])).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -463,5 +463,201 @@ export async function graph(t) {
|
||||
await sleep(250);
|
||||
check('the parked node is back where it started', (await positions()) === before13i, await positions());
|
||||
|
||||
// 13o. Hiding a link: it stops being drawn across the graph but keeps carrying its value, and
|
||||
// comes back while either of its two nodes is selected.
|
||||
await select({ kind: 'asset', assetId: 'mover' });
|
||||
await clickText('Fit');
|
||||
await sleep(250);
|
||||
const allEdges = (await asset('mover')).graph.edges;
|
||||
const edge = allEdges.find((e) => e.from.port !== 'exec') ?? allEdges[0];
|
||||
const id = JSON.stringify(edge.id);
|
||||
const drawn = async () => await json(`__gm.graph.edgeDrawn(${id})`);
|
||||
const edgeNow = async () => (await asset('mover')).graph.edges.find((e) => e.id === edge.id);
|
||||
check('the link is drawn as a curve to start with', (await drawn()) === true);
|
||||
|
||||
const outPort = await json(
|
||||
`__gm.graph.portAt(${JSON.stringify(edge.from.node)}, ${JSON.stringify(edge.from.port)}, true)`,
|
||||
);
|
||||
await click(outPort.x, outPort.y, 'right');
|
||||
await sleep(150);
|
||||
check('right-clicking a port offers hiding its link', Boolean(await evaluate(`__gm.locate('Hide link')`)));
|
||||
await clickText('Hide link');
|
||||
await sleep(250);
|
||||
await select({ kind: 'asset', assetId: 'mover' });
|
||||
await sleep(150);
|
||||
const hidden = await edgeNow();
|
||||
check(
|
||||
'hiding changes nothing but a flag – the connection is still there',
|
||||
Boolean(hidden) &&
|
||||
hidden.folded === true &&
|
||||
hidden.from.node === edge.from.node &&
|
||||
hidden.to.node === edge.to.node,
|
||||
JSON.stringify(hidden),
|
||||
);
|
||||
check('and the curve is gone from the canvas', (await drawn()) === false);
|
||||
const mark = await json(`__gm.graph.foldMark(${id}, true)`);
|
||||
check(
|
||||
'a stub with a mark takes its place at the port',
|
||||
mark !== null && Math.abs(mark.x - outPort.x) < 40 && Math.abs(mark.y - outPort.y) < 8,
|
||||
JSON.stringify({ mark, outPort }),
|
||||
);
|
||||
await shot('23-link-hidden');
|
||||
|
||||
// Selecting either end lends the curve back – the source first, then the target.
|
||||
const sourceAt = await json(`__gm.graph.nodeAt(${JSON.stringify(edge.from.node)})`);
|
||||
await click(sourceAt.x, sourceAt.y);
|
||||
await sleep(200);
|
||||
check('selecting the node at one end brings the curve back', (await drawn()) === true);
|
||||
const targetAt = await json(`__gm.graph.nodeAt(${JSON.stringify(edge.to.node)})`);
|
||||
await click(targetAt.x, targetAt.y);
|
||||
await sleep(200);
|
||||
check('selecting the node at the other end does too', (await drawn()) === true);
|
||||
await select({ kind: 'asset', assetId: 'mover' });
|
||||
await sleep(200);
|
||||
check('deselecting hides it again', (await drawn()) === false);
|
||||
|
||||
// The mark is the handle: clicking it selects the node at the far end, which is also what
|
||||
// puts the curve back – so it answers "where does this go?" in one click.
|
||||
const mark2 = await json(`__gm.graph.foldMark(${id}, true)`);
|
||||
await click(mark2.x, mark2.y);
|
||||
await sleep(200);
|
||||
check(
|
||||
'clicking the mark jumps to the node at the far end',
|
||||
JSON.stringify(await json('__gm.graph.selected()')) === JSON.stringify([edge.to.node]),
|
||||
JSON.stringify(await json('__gm.graph.selected()')),
|
||||
);
|
||||
check('which shows the curve again', (await drawn()) === true);
|
||||
|
||||
await undo();
|
||||
await select({ kind: 'asset', assetId: 'mover' });
|
||||
await sleep(250);
|
||||
const shown = await edgeNow();
|
||||
check(
|
||||
'undo unhides the link, with nothing left behind',
|
||||
shown.folded === undefined && (await drawn()) === true,
|
||||
JSON.stringify(shown),
|
||||
);
|
||||
|
||||
// 13p. Two hidden links out of one port. Drawn per link they would stack stub, dot and name on
|
||||
// the very same pixels – the names smearing over each other. One port, one mark.
|
||||
const before13p = await graphCounts();
|
||||
const forkPort = await json(
|
||||
`__gm.graph.portAt(${JSON.stringify(edge.from.node)}, ${JSON.stringify(edge.from.port)}, true)`,
|
||||
);
|
||||
// A spot no node covers, measured rather than guessed: after the Fit above, where the nodes
|
||||
// sit on screen is not something this step gets to assume.
|
||||
const canvasArea = await json('__gm.graph.canvasRect()');
|
||||
const taken = [];
|
||||
for (const node of (await asset('mover')).graph.nodes)
|
||||
taken.push(await json(`__gm.graph.nodeRect(${JSON.stringify(node.id)})`));
|
||||
const freeSpots = [
|
||||
{ x: canvasArea.x + 60, y: canvasArea.y + canvasArea.h - 60 },
|
||||
{ x: canvasArea.x + canvasArea.w - 60, y: canvasArea.y + canvasArea.h - 60 },
|
||||
{ x: canvasArea.x + 60, y: canvasArea.y + 60 },
|
||||
];
|
||||
const free =
|
||||
freeSpots.find((p) =>
|
||||
taken.every(
|
||||
(r) => !r || p.x < r.x - 10 || p.y < r.y - 10 || p.x > r.x + r.w + 10 || p.y > r.y + r.h + 10,
|
||||
),
|
||||
) ?? freeSpots[0];
|
||||
// The same gesture as 13d3: dropped into empty space the palette offers only what this port can
|
||||
// reach, and the picked node arrives already wired – so no port name has to be guessed here.
|
||||
await mouse('mouseMoved', forkPort.x, forkPort.y);
|
||||
await mouse('mousePressed', forkPort.x, forkPort.y);
|
||||
await mouse('mouseMoved', free.x, free.y, 'left', { buttons: 1 });
|
||||
await sleep(50);
|
||||
await mouse('mouseReleased', free.x, free.y);
|
||||
await sleep(200);
|
||||
const forkRow = await locate('Clamp');
|
||||
if (forkRow) await click(forkRow.x, forkRow.y);
|
||||
await sleep(250);
|
||||
const twin = (await state()).selection?.nodeId;
|
||||
const forked = (await asset('mover')).graph.edges.filter(
|
||||
(e) => e.from.node === edge.from.node && e.from.port === edge.from.port,
|
||||
);
|
||||
check(
|
||||
'a second link now leaves the same port',
|
||||
forked.length === 2 && (await graphCounts()).nodes === before13p.nodes + 1,
|
||||
JSON.stringify({ forked: forked.length, twin, free, row: Boolean(forkRow) }),
|
||||
);
|
||||
|
||||
await click(forkPort.x, forkPort.y, 'right');
|
||||
await sleep(150);
|
||||
check('the port menu counts what it would hide', Boolean(await evaluate(`__gm.locate('Hide 2 links')`)));
|
||||
await clickText('Hide 2 links');
|
||||
await sleep(250);
|
||||
await select({ kind: 'asset', assetId: 'mover' });
|
||||
await sleep(200);
|
||||
const ids = forked.map((e) => JSON.stringify(e.id));
|
||||
const markA = await json(`__gm.graph.foldMark(${ids[0]}, true)`);
|
||||
const markB = await json(`__gm.graph.foldMark(${ids[1]}, true)`);
|
||||
check(
|
||||
'both hidden links share one mark at the port instead of stacking two',
|
||||
markA !== null && markA.x === markB.x && markA.y === markB.y && markA.links === 2,
|
||||
JSON.stringify({ markA, markB }),
|
||||
);
|
||||
const marks = await json(`__gm.graph.foldMarks()`);
|
||||
check(
|
||||
'and only three marks are drawn for the two links: one shared, one at each far end',
|
||||
marks.length === 3 && marks.filter((m) => m.links === 2).length === 1,
|
||||
JSON.stringify(marks),
|
||||
);
|
||||
check(
|
||||
'the shared mark names one far node and counts the rest',
|
||||
markA.label.endsWith('+1') && markA.label.length > 2,
|
||||
JSON.stringify(markA.label),
|
||||
);
|
||||
await shot('24-links-bundled');
|
||||
|
||||
// The bundled mark is still a handle: one click selects every far end, which is also what
|
||||
// lends all of its curves back at once.
|
||||
await click(markA.x, markA.y);
|
||||
await sleep(250);
|
||||
const picked = await json('__gm.graph.selected()');
|
||||
check(
|
||||
'clicking a bundled mark selects every node the port feeds',
|
||||
picked.length === 2 && picked.includes(edge.to.node) && picked.includes(twin),
|
||||
JSON.stringify(picked),
|
||||
);
|
||||
check(
|
||||
'and both curves come back at once',
|
||||
(await json(`__gm.graph.edgeDrawn(${ids[0]})`)) === true &&
|
||||
(await json(`__gm.graph.edgeDrawn(${ids[1]})`)) === true,
|
||||
);
|
||||
|
||||
await click(markA.x, markA.y, 'right');
|
||||
await sleep(150);
|
||||
check(
|
||||
'the mark menu shows both links back in one go',
|
||||
Boolean(await evaluate(`__gm.locate('Show 2 links')`)),
|
||||
);
|
||||
await clickText('Show 2 links');
|
||||
await sleep(250);
|
||||
await select({ kind: 'asset', assetId: 'mover' });
|
||||
await sleep(200);
|
||||
const unfolded = (await asset('mover')).graph.edges.filter(
|
||||
(e) => e.from.node === edge.from.node && e.from.port === edge.from.port,
|
||||
);
|
||||
check(
|
||||
'showing them again leaves no fold behind',
|
||||
unfolded.every((e) => e.folded === undefined) && (await json(`__gm.graph.foldMarks()`)).length === 0,
|
||||
JSON.stringify(unfolded),
|
||||
);
|
||||
// One step of undo takes both back – hiding two links was one command, so it is one undo.
|
||||
await undo();
|
||||
await select({ kind: 'asset', assetId: 'mover' });
|
||||
await sleep(250);
|
||||
const refolded = (await asset('mover')).graph.edges.filter(
|
||||
(e) => e.from.node === edge.from.node && e.from.port === edge.from.port,
|
||||
);
|
||||
check(
|
||||
'and undo re-hides both as one step',
|
||||
refolded.length === 2 && refolded.every((e) => e.folded === true),
|
||||
JSON.stringify(refolded.map((e) => e.folded)),
|
||||
);
|
||||
await undo();
|
||||
await sleep(200);
|
||||
|
||||
await evaluate(`__gm.showTab('scene')`);
|
||||
}
|
||||
Reference in new issue
Block a user