Let a graph node collapse to its title bar
A chevron in front of the title toggles it without touching the selection, and right-click offers Collapse node / Expand node for the whole selection as one undo step. A collapsed node draws its title bar alone; every link of one side meets at a single point on it, and hidden links there share one mark per side, since per-port marks would stack on the same pixels. It has no ports to grab, so a link dropped on it connects nothing. Like hiding a link this is drawing only: collapsed is saved on the node and an expanded node leaves no trace in the project file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
631cf54c72
commit
1e2285c545
12 files changed
+366
-14
No files matched your search
@@ -257,6 +257,12 @@ shown on the node and editable in the inspector.
|
||||
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.
|
||||
- **A node can be collapsed** to its title bar: the chevron in front of the title (it leaves the
|
||||
selection alone, like the hierarchy's), or right-click → *Collapse node* / *Expand node*, which
|
||||
acts on the whole selection as one undo step. Every link of one side then meets at a single
|
||||
point on the title bar, and its hidden links share one mark per side. A collapsed node has no
|
||||
ports to grab, so a link dropped on it goes nowhere – expand it to wire it. Like hiding a link,
|
||||
this is drawing only: `collapsed` on the node is saved with the project.
|
||||
- **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
|
||||
@@ -578,7 +584,7 @@ src/editor/viewport/ WorldViewport base + SceneViewport/GameViewport – editi
|
||||
src/editor/inspector/ Inspector panel, field factory, previewHandlers (scrub → preview → command),
|
||||
one section per selection kind (asset/object/node), objectQueries/vectorRows/parentRow
|
||||
src/editor/panels/ AssetPanel, HierarchyPanel, Toolbar
|
||||
src/editor/graph/ GraphEditor (header + picker), GraphCanvas, NodeView, NodePalette, folding (hidden links)
|
||||
src/editor/graph/ GraphEditor (header + picker), GraphCanvas, NodeView, NodePalette, folding (hidden links, collapsed nodes)
|
||||
src/editor/container/ ContainerEditor (header + picker), ContainerCanvas (a SceneViewport on a template)
|
||||
src/editor/texture/ TextureEditor (header + picker), TextureCanvas (picture + frames), SliceDialog, sheetView (the grid maths)
|
||||
src/editor/list/ ListEditor (a row per item), slot (which list is being edited, and how it is written back)
|
||||
@@ -613,7 +619,7 @@ npm run check # typecheck + lint + format:check + tests, what CI would ru
|
||||
# code is lost (tail's is reported), so a red run reads as green
|
||||
npm run build # writes dist/ and regenerates index.html
|
||||
export GM_REMOTE=user@host # optional: run the browser elsewhere; see below
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # 509 editor checks, screenshots in /tmp/gm-e2e-*.png
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # 527 editor checks, screenshots in /tmp/gm-e2e-*.png
|
||||
GM_STEPS=20 node tools/e2e.mjs # or 1-12, or 6,20 – a subset while working on one step
|
||||
./e2e.sh start [GM_STEPS] # the same run detached, see below
|
||||
./e2e.sh status # live progress, or the last run's result
|
||||
|
||||
@@ -242,6 +242,36 @@ export class FoldEdgeCommand implements Command {
|
||||
}
|
||||
}
|
||||
|
||||
/** One undo step for the whole selection; every node is found before any of them changes. */
|
||||
export class CollapseNodesCommand implements Command {
|
||||
readonly label: string;
|
||||
private previous: boolean[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly nodeIds: readonly string[],
|
||||
private readonly collapsed: boolean,
|
||||
) {
|
||||
const noun = nodeIds.length === 1 ? 'node' : 'nodes';
|
||||
this.label = collapsed ? `Collapse ${noun}` : `Expand ${noun}`;
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const nodes = this.nodeIds.map((id) => requireNode(project, this.assetId, id));
|
||||
this.previous = nodes.map((n) => n.collapsed === true);
|
||||
for (const node of nodes) setCollapsed(node, this.collapsed);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
this.nodeIds.forEach((id, i) => setCollapsed(requireNode(project, this.assetId, id), this.previous[i]!));
|
||||
}
|
||||
}
|
||||
|
||||
function setCollapsed(node: GraphNode, collapsed: boolean): void {
|
||||
if (collapsed) node.collapsed = true;
|
||||
else delete node.collapsed;
|
||||
}
|
||||
|
||||
export class DisconnectCommand implements Command {
|
||||
readonly label = 'Disconnect';
|
||||
private removed: Removed<GraphEdge>[] = [];
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface GraphNode {
|
||||
* behavior its signature; an Expand/Pack node is what gives a struct its fields.
|
||||
*/
|
||||
ports?: DeclaredPort[];
|
||||
/** Drawn as its title bar alone, every link meeting at one point per side. Purely visual. */
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
/** A port a start/end node declares. `kind` maps to the PortType of the same name (`text` → string). */
|
||||
|
||||
@@ -244,6 +244,7 @@ function normalizeGraph(raw: unknown): Graph {
|
||||
const node: GraphNode = { id, type, x: toNumber(n.x), y: toNumber(n.y), params };
|
||||
const ports = normalizePorts(n.ports);
|
||||
if (ports.length > 0) node.ports = ports;
|
||||
if (n.collapsed === true) node.collapsed = true;
|
||||
graph.nodes.push(node);
|
||||
}
|
||||
const edgeIds = new Set<string>();
|
||||
|
||||
@@ -191,6 +191,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
portAt: (nodeId: string, port: string, isOutput: boolean) =>
|
||||
graphEditor.canvas.portGlobal(nodeId, port, isOutput),
|
||||
nodeAt: (nodeId: string) => graphEditor.canvas.nodeGlobal(nodeId),
|
||||
chevronAt: (nodeId: string) => graphEditor.canvas.chevronGlobal(nodeId),
|
||||
nodeLabel: (nodeId: string) => graphEditor.canvas.nodeLabel(nodeId),
|
||||
nodeProblem: (nodeId: string) => graphEditor.canvas.nodeProblem(nodeId),
|
||||
nodeSettings: (nodeId: string) => graphEditor.canvas.nodeSettings(nodeId),
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Command } from '../../core/commands/Command';
|
||||
import { CompoundCommand } from '../../core/commands/CompoundCommand';
|
||||
import {
|
||||
AddNodeCommand,
|
||||
CollapseNodesCommand,
|
||||
ConnectCommand,
|
||||
DisconnectCommand,
|
||||
FoldEdgeCommand,
|
||||
@@ -372,6 +373,11 @@ export class GraphCanvas extends Widget {
|
||||
this.redraw();
|
||||
return;
|
||||
}
|
||||
// Like the hierarchy's chevron: it folds, and leaves the selection alone.
|
||||
if (view.chevronAt(local.x, local.y)) {
|
||||
this.state.execute(new CollapseNodesCommand(this.assetId, [view.node.id], !view.collapsed));
|
||||
return;
|
||||
}
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey) {
|
||||
this.state.toggleNode(this.assetId, view.node.id);
|
||||
return;
|
||||
@@ -595,7 +601,8 @@ export class GraphCanvas extends Widget {
|
||||
const hit = this.portUnder(globalX, globalY);
|
||||
if (hit) return canConnect(graph, from, hit, lookup) ? hit : null;
|
||||
const node = this.nodeUnder(globalX, globalY);
|
||||
return node ? dropTargetPort(graph, from, node, lookup) : null;
|
||||
if (!node || this.views.get(node)?.collapsed) return null;
|
||||
return dropTargetPort(graph, from, node, lookup);
|
||||
}
|
||||
|
||||
private portUnder(globalX: number, globalY: number): PortSide | null {
|
||||
@@ -682,7 +689,21 @@ export class GraphCanvas extends Widget {
|
||||
if (!this.state.selectedNodeIds.includes(view.node.id))
|
||||
this.state.select(nodeSelection(assetId, [view.node.id]));
|
||||
const ids = this.state.selectedNodeIds;
|
||||
const nodes = this.selectedNodes();
|
||||
const expanded = nodes.filter((n) => !n.collapsed).map((n) => n.id);
|
||||
const collapsed = nodes.filter((n) => n.collapsed).map((n) => n.id);
|
||||
const fold = (list: string[], verb: string, to: boolean): MenuItem[] =>
|
||||
list.length === 0
|
||||
? []
|
||||
: [
|
||||
{
|
||||
label: list.length > 1 ? `${verb} ${list.length} nodes` : `${verb} node`,
|
||||
onSelect: () => this.state.execute(new CollapseNodesCommand(assetId, list, to)),
|
||||
},
|
||||
];
|
||||
new Menu([
|
||||
...fold(expanded, 'Collapse', true),
|
||||
...fold(collapsed, 'Expand', false),
|
||||
{
|
||||
label: ids.length > 1 ? `Delete ${ids.length} nodes` : 'Delete node',
|
||||
danger: true,
|
||||
@@ -889,7 +910,7 @@ export class GraphCanvas extends Widget {
|
||||
const [side, other] = isOutput ? [edge.from, edge.to.node] : [edge.to, edge.from.node];
|
||||
this.foldEnds.push({
|
||||
edgeId: edge.id,
|
||||
key: foldMarkKey(side, isOutput),
|
||||
key: foldMarkKey(side, isOutput, this.views.get(side.node)?.collapsed),
|
||||
other,
|
||||
otherLabel,
|
||||
isOutput,
|
||||
@@ -1041,6 +1062,11 @@ export class GraphCanvas extends Widget {
|
||||
const g = view.toGlobal({ x: view.w / 2, y: TITLE_HEIGHT / 2 });
|
||||
return { x: g.x, y: g.y };
|
||||
}
|
||||
|
||||
chevronGlobal(nodeId: string): { x: number; y: number } | null {
|
||||
const g = this.views.get(nodeId)?.chevronGlobal();
|
||||
return g ? { x: g.x, y: g.y } : null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Horizontal S-curve between two points. */
|
||||
|
||||
@@ -28,6 +28,8 @@ export const PORT_RADIUS = 5;
|
||||
/** Distance (graph units) within which a press counts as hitting a port. */
|
||||
export const PORT_HIT_RADIUS = 9;
|
||||
const PAD = 6;
|
||||
/** Room in front of the title for the collapse chevron, as wide as the hierarchy's. */
|
||||
const CHEVRON_SLOT = 14;
|
||||
|
||||
export function categoryColor(category: NodeCategory): number {
|
||||
const c = Theme.colors;
|
||||
@@ -86,6 +88,8 @@ export class NodeView extends Widget {
|
||||
targetPort: { port: string; isOutput: boolean } | null = null;
|
||||
/** Data inputs that currently have a connection (their inline value is hidden). */
|
||||
private connected = new Set<string>();
|
||||
/** Which sides carry a link – a collapsed node marks those with one dot each. */
|
||||
private linked = { inputs: false, outputs: false };
|
||||
|
||||
/**
|
||||
* Not fixed at construction: a nested-behavior node's ports come from another graph, so the
|
||||
@@ -137,12 +141,28 @@ export class NodeView extends Widget {
|
||||
.filter((p) => edgesInto(graph, node.id, p.key).length > 0)
|
||||
.map((p) => p.key),
|
||||
);
|
||||
const height = this.headerHeight() + this.rowCount() * ROW_HEIGHT + PAD;
|
||||
this.linked = {
|
||||
inputs: graph.edges.some((e) => e.to.node === node.id),
|
||||
outputs: graph.edges.some((e) => e.from.node === node.id),
|
||||
};
|
||||
const height = this.collapsed ? TITLE_HEIGHT : this.headerHeight() + this.rowCount() * ROW_HEIGHT + PAD;
|
||||
this.rebuildTexts();
|
||||
this.resize(NODE_WIDTH, height);
|
||||
this.position.set(node.x, node.y);
|
||||
}
|
||||
|
||||
get collapsed(): boolean {
|
||||
return this.node.collapsed === true;
|
||||
}
|
||||
|
||||
chevronAt(localX: number, localY: number): boolean {
|
||||
return localY >= 0 && localY < TITLE_HEIGHT && localX >= PAD - 2 && localX < PAD + CHEVRON_SLOT;
|
||||
}
|
||||
|
||||
chevronGlobal(): { x: number; y: number } {
|
||||
return this.toGlobal({ x: PAD + CHEVRON_SLOT / 2, y: TITLE_HEIGHT / 2 });
|
||||
}
|
||||
|
||||
private rowCount(): number {
|
||||
return Math.max(this.def.inputs.length, this.def.outputs.length);
|
||||
}
|
||||
@@ -168,15 +188,16 @@ export class NodeView extends Widget {
|
||||
const list = isOutput ? this.def.outputs : this.def.inputs;
|
||||
const index = list.findIndex((p) => p.key === key);
|
||||
if (index < 0) return null;
|
||||
return { x: isOutput ? this.w : 0, y: this.rowY(index) };
|
||||
return { x: isOutput ? this.w : 0, y: this.collapsed ? TITLE_HEIGHT / 2 : this.rowY(index) };
|
||||
}
|
||||
|
||||
private rowY(index: number): number {
|
||||
return this.headerHeight() + index * ROW_HEIGHT + ROW_HEIGHT / 2;
|
||||
}
|
||||
|
||||
/** Every port with its local centre – inputs on the left edge, outputs on the right. */
|
||||
/** Every port with its local centre – none while collapsed, so none can be grabbed. */
|
||||
private *portCentres(): Iterable<{ port: PortDef; isOutput: boolean; x: number; y: number }> {
|
||||
if (this.collapsed) return;
|
||||
for (const [list, isOutput] of [
|
||||
[this.def.inputs, false],
|
||||
[this.def.outputs, true],
|
||||
@@ -209,7 +230,15 @@ export class NodeView extends Widget {
|
||||
this.addChild(node);
|
||||
this.texts.push(node);
|
||||
};
|
||||
add(this.def.label, 'accent', PAD + 2, (TITLE_HEIGHT - 16) / 2);
|
||||
add(
|
||||
this.def.label,
|
||||
'accent',
|
||||
PAD + CHEVRON_SLOT,
|
||||
(TITLE_HEIGHT - 16) / 2,
|
||||
'left',
|
||||
NODE_WIDTH - PAD * 2 - CHEVRON_SLOT,
|
||||
);
|
||||
if (this.collapsed) return;
|
||||
let y = TITLE_HEIGHT + PAD;
|
||||
for (const line of this.paramLines) {
|
||||
add(line, 'small', PAD + 2, y + 2);
|
||||
@@ -256,9 +285,11 @@ export class NodeView extends Widget {
|
||||
this.box.roundRect(2, 3, this.w, this.h, Theme.radius).fill({ color: 0x000000, alpha: 0.35 });
|
||||
this.box.roundRect(0, 0, this.w, this.h, Theme.radius).fill(c.node);
|
||||
this.box.roundRect(0, 0, this.w, TITLE_HEIGHT, Theme.radius).fill(categoryColor(this.def.category));
|
||||
this.box
|
||||
.rect(0, TITLE_HEIGHT - Theme.radius, this.w, Theme.radius)
|
||||
.fill(categoryColor(this.def.category));
|
||||
if (!this.collapsed)
|
||||
this.box
|
||||
.rect(0, TITLE_HEIGHT - Theme.radius, this.w, Theme.radius)
|
||||
.fill(categoryColor(this.def.category));
|
||||
this.drawChevron(PAD + 4, TITLE_HEIGHT / 2);
|
||||
const border = this.hot ? c.hot : this.selected ? c.nodeSelected : this.problem ? c.danger : c.nodeBorder;
|
||||
this.box
|
||||
.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius)
|
||||
@@ -266,6 +297,16 @@ export class NodeView extends Widget {
|
||||
|
||||
this.ports.clear();
|
||||
const r = PORT_RADIUS;
|
||||
if (this.collapsed) {
|
||||
for (const [linked, x] of [
|
||||
[this.linked.inputs, 0],
|
||||
[this.linked.outputs, this.w],
|
||||
] as const) {
|
||||
if (!linked) continue;
|
||||
this.ports.circle(x, TITLE_HEIGHT / 2, r - 1).fill(c.textMuted);
|
||||
this.ports.circle(x, TITLE_HEIGHT / 2, r - 1).stroke({ width: 1, color: c.node });
|
||||
}
|
||||
}
|
||||
for (const { port, isOutput, x, y } of this.portCentres()) {
|
||||
if (this.targetPort?.port === port.key && this.targetPort.isOutput === isOutput) {
|
||||
// A gap in the node colour first, so the ring stays visible on the white exec pin too.
|
||||
@@ -288,6 +329,23 @@ export class NodeView extends Widget {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The hierarchy's shape, so both folds read the same. */
|
||||
private drawChevron(x: number, y: number): void {
|
||||
const color = Theme.colors.text;
|
||||
if (this.collapsed)
|
||||
this.box
|
||||
.moveTo(x - 1, y - 4)
|
||||
.lineTo(x + 4, y)
|
||||
.lineTo(x - 1, y + 4)
|
||||
.fill(color);
|
||||
else
|
||||
this.box
|
||||
.moveTo(x - 3, y - 2)
|
||||
.lineTo(x + 5, y - 2)
|
||||
.lineTo(x + 1, y + 3)
|
||||
.fill(color);
|
||||
}
|
||||
}
|
||||
|
||||
/** The value of a setting as shown on the node; references that no longer resolve get a "?". */
|
||||
|
||||
@@ -13,9 +13,14 @@ export function foldedEdgeShown(edge: GraphEdge, selected: ReadonlySet<string>,
|
||||
* Which mark a folded edge end belongs to. One port, one mark: drawn per edge instead, several
|
||||
* hidden links out of the same port stack their stub, dot and name on the very same pixels.
|
||||
* The separator is NUL: a printable one merges node "a b" + port "c" with node "a" + port "b c".
|
||||
* A collapsed node draws all its ports on one point per side, so there it is one mark per side.
|
||||
*/
|
||||
export function foldMarkKey(side: { node: string; port: string }, isOutput: boolean): string {
|
||||
return `${side.node}\0${side.port}\0${isOutput ? 'out' : 'in'}`;
|
||||
export function foldMarkKey(
|
||||
side: { node: string; port: string },
|
||||
isOutput: boolean,
|
||||
collapsed = false,
|
||||
): string {
|
||||
return `${side.node}\0${collapsed ? '' : side.port}\0${isOutput ? 'out' : 'in'}`;
|
||||
}
|
||||
|
||||
/** One name plus a count: three names do not fit, and the count is what says "there is more here". */
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CommandError } from '../../src/core/commands/Command';
|
||||
import { CollapseNodesCommand } from '../../src/core/commands/graphCommands';
|
||||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||||
import { findAsset } from '../../src/core/model/Project';
|
||||
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
|
||||
import { foldMarkKey } from '../../src/editor/graph/folding';
|
||||
import { graphEditor, makeBehavior, makeProject, roundTrip } from '../helpers';
|
||||
|
||||
function threeNodes() {
|
||||
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 });
|
||||
return { ...fixture, behavior, nodes: { start, self, move } };
|
||||
}
|
||||
|
||||
describe('collapsing nodes', () => {
|
||||
it('takes a whole selection back in one undo, each node to the state it really had', () => {
|
||||
const { stack, behavior, nodes } = threeNodes();
|
||||
stack.execute(new CollapseNodesCommand(behavior.id, [nodes.self.id], true));
|
||||
stack.execute(new CollapseNodesCommand(behavior.id, [nodes.self.id, nodes.move.id], true));
|
||||
expect(nodes.move.collapsed).toBe(true);
|
||||
|
||||
stack.undo();
|
||||
expect(nodes.self.collapsed).toBe(true);
|
||||
expect(nodes.move.collapsed).toBeUndefined();
|
||||
});
|
||||
|
||||
it('changes no node when one of them is missing', () => {
|
||||
const { project, behavior, nodes } = threeNodes();
|
||||
const command = new CollapseNodesCommand(behavior.id, [nodes.self.id, 'no-such-node'], true);
|
||||
expect(() => command.execute(project)).toThrow(CommandError);
|
||||
expect(nodes.self.collapsed).toBeUndefined();
|
||||
});
|
||||
|
||||
it('survives the save, and expanding leaves no trace in the file', () => {
|
||||
const { project, stack, behavior, nodes } = threeNodes();
|
||||
stack.execute(new CollapseNodesCommand(behavior.id, [nodes.move.id], true));
|
||||
const loaded = findAsset(roundTrip(project), behavior.id) as BehaviorAsset;
|
||||
expect(loaded.graph.nodes.find((n) => n.id === nodes.move.id)!.collapsed).toBe(true);
|
||||
expect('collapsed' in loaded.graph.nodes.find((n) => n.id === nodes.self.id)!).toBe(false);
|
||||
|
||||
stack.execute(new CollapseNodesCommand(behavior.id, [nodes.move.id], false));
|
||||
const again = findAsset(roundTrip(project), behavior.id) as BehaviorAsset;
|
||||
expect(again.graph.nodes.every((n) => !('collapsed' in n))).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores a collapsed flag in the file that is not literally true', () => {
|
||||
const { project, behavior } = threeNodes();
|
||||
const raw = serializeProject(project) as {
|
||||
assets: { id: string; graph?: { nodes: Record<string, unknown>[] } }[];
|
||||
};
|
||||
const stored = raw.assets.find((a) => a.id === behavior.id)!.graph!.nodes;
|
||||
stored[0]!.collapsed = 'yes';
|
||||
stored[1]!.collapsed = 1;
|
||||
const loaded = findAsset(deserializeProject(raw), behavior.id) as BehaviorAsset;
|
||||
expect(loaded.graph.nodes.every((n) => !('collapsed' in n))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hidden links at a collapsed node', () => {
|
||||
it('share one mark per side, because every port sits on the same point', () => {
|
||||
const a = foldMarkKey({ node: 'n', port: 'x' }, false, true);
|
||||
expect(foldMarkKey({ node: 'n', port: 'y' }, false, true)).toBe(a);
|
||||
expect(foldMarkKey({ node: 'n', port: 'x' }, true, true)).not.toBe(a);
|
||||
expect(foldMarkKey({ node: 'm', port: 'x' }, false, true)).not.toBe(a);
|
||||
expect(foldMarkKey({ node: 'n', port: 'x' }, false)).not.toBe(
|
||||
foldMarkKey({ node: 'n', port: 'y' }, false),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
Möglichkeit Nodes zuklappen können, wichtig: soll auch gespeichert werden.
|
||||
|
||||
---
|
||||
|
||||
Gebaut. Zwei Dinge, die weder oben stehen noch aus dem Commit hervorgehen:
|
||||
|
||||
**Zugeklappt ist reine Darstellung, wie ein versteckter Link.** Der Graph bleibt derselbe,
|
||||
`collapsed` am Node wird nur gespeichert, und ein aufgeklappter Node hinterlässt keine Spur in der
|
||||
Datei. Weil ein zugeklappter Node keine Ports zum Greifen hat, verbindet ein darauf fallengelassener
|
||||
Link nichts. Neue Kanten gibt es erst nach dem Aufklappen, statt einen Port zu raten.
|
||||
|
||||
**Versteckte Links teilen sich am zugeklappten Node eine Marke pro Seite statt pro Port.** Alle
|
||||
Ports einer Seite liegen dort auf einem Punkt. Mit einer Marke pro Port stapelten sich Stummel,
|
||||
Punkt und Name auf denselben Pixeln (gemessen: zwei Marken mit je `links: 1`, identische
|
||||
Koordinaten).
|
||||
@@ -1 +0,0 @@
|
||||
Möglichkeit Nodes zuklappen können, wichtig: soll auch gespeichert werden.
|
||||
@@ -22,6 +22,9 @@ export async function graph(t) {
|
||||
select,
|
||||
stop,
|
||||
asset,
|
||||
api,
|
||||
navigate,
|
||||
projectId,
|
||||
} = t;
|
||||
const nodeOf = async (id) => (await asset('mover')).graph.nodes.find((n) => n.id === id);
|
||||
const graphCounts = async () => {
|
||||
@@ -739,5 +742,137 @@ export async function graph(t) {
|
||||
await undo();
|
||||
await sleep(200);
|
||||
|
||||
// 13q. Collapsing a node: the title bar alone, every link meeting at one point per side. No new
|
||||
// link lands on it until it is expanded, and the state is saved with the project.
|
||||
await select({ kind: 'asset', assetId: 'mover' });
|
||||
await clickText('Fit');
|
||||
await sleep(250);
|
||||
const edgesBefore13q = (await graphCounts()).edges;
|
||||
const openRect = await json(`__gm.graph.nodeRect('n-move')`);
|
||||
const chevron = await json(`__gm.graph.chevronAt('n-move')`);
|
||||
await click(chevron.x, chevron.y);
|
||||
await sleep(200);
|
||||
const shut = await json(`__gm.graph.nodeRect('n-move')`);
|
||||
check(
|
||||
'the chevron collapses the node to its title bar, and leaves the selection alone',
|
||||
(await nodeOf('n-move')).collapsed === true &&
|
||||
Math.abs(shut.h / shut.w - 24 / 176) < 0.01 &&
|
||||
(await json('__gm.graph.selected()')).length === 0,
|
||||
JSON.stringify({ openRect, shut, collapsed: (await nodeOf('n-move')).collapsed }),
|
||||
);
|
||||
const title = await json(`__gm.graph.nodeAt('n-move')`);
|
||||
const anchorOf = (port, out) => json(`__gm.graph.portAt('n-move', '${port}', ${out})`);
|
||||
const inputs = [
|
||||
await anchorOf('exec', false),
|
||||
await anchorOf('object', false),
|
||||
await anchorOf('dy', false),
|
||||
];
|
||||
const output = await anchorOf('exec', true);
|
||||
check(
|
||||
'every link meets at one point per side, level with the title',
|
||||
inputs.every((p) => Math.abs(p.x - shut.x) < 1 && Math.abs(p.y - title.y) < 1) &&
|
||||
Math.abs(output.x - (shut.x + shut.w)) < 1 &&
|
||||
Math.abs(output.y - title.y) < 1,
|
||||
JSON.stringify({ inputs, output, title, shut }),
|
||||
);
|
||||
check(
|
||||
'the links are still there and still drawn',
|
||||
(await graphCounts()).edges === edgesBefore13q && (await json(`__gm.graph.edgeDrawn('e3')`)) === true,
|
||||
);
|
||||
await shot('24b-node-collapsed');
|
||||
|
||||
const speedOut = await json(`__gm.graph.portAt('n-speed', 'value', true)`);
|
||||
await mouse('mouseMoved', speedOut.x, speedOut.y);
|
||||
await mouse('mousePressed', speedOut.x, speedOut.y);
|
||||
await mouse('mouseMoved', title.x, title.y, 'left', { buttons: 1 });
|
||||
await sleep(50);
|
||||
const aimed = await json('__gm.graph.dropTarget()');
|
||||
await mouse('mouseReleased', title.x, title.y);
|
||||
await sleep(150);
|
||||
await press('Escape');
|
||||
check(
|
||||
'a link dropped on a collapsed node goes nowhere',
|
||||
aimed === null && (await graphCounts()).edges === edgesBefore13q,
|
||||
JSON.stringify(aimed),
|
||||
);
|
||||
|
||||
for (const port of ['x', 'y']) {
|
||||
const at = await json(`__gm.graph.portAt('n-axis', '${port}', true)`);
|
||||
await click(at.x, at.y, 'right');
|
||||
await sleep(150);
|
||||
await clickText('Hide link');
|
||||
await sleep(200);
|
||||
}
|
||||
await select({ kind: 'asset', assetId: 'mover' });
|
||||
await sleep(200);
|
||||
const markDx = await json(`__gm.graph.foldMark('e3', false)`);
|
||||
const markDy = await json(`__gm.graph.foldMark('e4', false)`);
|
||||
check(
|
||||
'hidden links into a collapsed node share one mark instead of stacking',
|
||||
markDx !== null && markDx.x === markDy.x && markDx.y === markDy.y && markDx.links === 2,
|
||||
JSON.stringify({ markDx, markDy }),
|
||||
);
|
||||
await undo();
|
||||
await undo();
|
||||
|
||||
await select({ kind: 'node', assetId: 'mover', nodeId: 'n-move', nodeIds: ['n-speed', 'n-move'] });
|
||||
await sleep(150);
|
||||
await click(title.x, title.y, 'right');
|
||||
await sleep(150);
|
||||
check(
|
||||
'a mixed selection is offered both ways',
|
||||
Boolean(await evaluate(`__gm.locate('Collapse node', false)`)) &&
|
||||
Boolean(await evaluate(`__gm.locate('Expand node', false)`)),
|
||||
);
|
||||
await press('Escape');
|
||||
await undo();
|
||||
await sleep(150);
|
||||
check('undo expands it again', (await nodeOf('n-move')).collapsed === undefined);
|
||||
|
||||
await select({ kind: 'node', assetId: 'mover', nodeId: 'n-move', nodeIds: ['n-speed', 'n-move'] });
|
||||
await sleep(150);
|
||||
const openTitle = await json(`__gm.graph.nodeAt('n-move')`);
|
||||
await click(openTitle.x, openTitle.y, 'right');
|
||||
await sleep(150);
|
||||
const collapseBoth = await json(`__gm.locate('Collapse 2 nodes', false)`);
|
||||
check('two expanded nodes are offered as one', collapseBoth !== null);
|
||||
if (collapseBoth) await click(collapseBoth.x, collapseBoth.y);
|
||||
else await press('Escape');
|
||||
await sleep(200);
|
||||
const both = async () => [(await nodeOf('n-speed')).collapsed, (await nodeOf('n-move')).collapsed];
|
||||
check('the menu collapses the whole selection', JSON.stringify(await both()) === '[true,true]');
|
||||
await undo();
|
||||
await sleep(150);
|
||||
check(
|
||||
'and one undo takes all of it back',
|
||||
JSON.stringify(await both()) === '[null,null]',
|
||||
JSON.stringify(await both()),
|
||||
);
|
||||
|
||||
// Saved and loaded: a reload, not just the file, because the drawing is what has to come back.
|
||||
await select({ kind: 'asset', assetId: 'mover' });
|
||||
const again = await json(`__gm.graph.chevronAt('n-move')`);
|
||||
await click(again.x, again.y);
|
||||
const savedMove = async () => {
|
||||
for (let i = 0; i < 30 && (await state()).save !== 'saved'; i++) await sleep(300);
|
||||
const saved = await api(`projects/${projectId}`);
|
||||
return saved.project.assets.find((a) => a.id === 'mover').graph.nodes.find((n) => n.id === 'n-move');
|
||||
};
|
||||
check('the collapse reaches the saved file', (await savedMove()).collapsed === true);
|
||||
await navigate();
|
||||
await sleep(500);
|
||||
await select({ kind: 'asset', assetId: 'mover' });
|
||||
await evaluate(`__gm.showTab('graph')`);
|
||||
await sleep(250);
|
||||
const reloaded = await json(`__gm.graph.nodeRect('n-move')`);
|
||||
check(
|
||||
'and a reloaded project draws it collapsed again',
|
||||
(await nodeOf('n-move')).collapsed === true && Math.abs(reloaded.h / reloaded.w - 24 / 176) < 0.01,
|
||||
JSON.stringify(reloaded),
|
||||
);
|
||||
const reopen = await json(`__gm.graph.chevronAt('n-move')`);
|
||||
await click(reopen.x, reopen.y);
|
||||
check('expanding leaves no trace in the file', !('collapsed' in (await savedMove())));
|
||||
|
||||
await evaluate(`__gm.showTab('scene')`);
|
||||
}
|
||||
Reference in new issue
Block a user