A Filter sounds like one band of the Parametric EQ, but it keeps what the EQ cannot: linked numbers, the all-pass and +-40 dB. So it stays a node of its own and gets the EQ's editor with itself as the one band. The editors also stop running out of their room: what does not fit is left out instead of being cut. - The curve of the EQ is now BandCurve with a host the EQ and the Filter both fill. CurvePanel holds what their panels share. - FilterPanel shows the Filter as one band. A drag of its handle sets frequency and gain as one undo step. A number fed by a link is greyed out and the drag leaves it alone. - A double-click on the Filter's title opens the same in a tab of its own. Delete there does nothing, the node stays. - The Filter is tapped like the EQ, so its node and its tab draw the spectrum of what passes it. A tap is two gain nodes per voice, the analysers are made only while someone listens. - EqFace and FilterFace are one CurveFace now, given the panel. - EQ and Filter nodes have a corner to resize them, drawn like the floating window's. The size is saved on the node as `size`, each drag is one undo step, the least is 240 x 120. - In the node the help line is gone. The row of numbers comes in only once the curve keeps 120 px of height. A group of the row that does not fit is not drawn, the one on the right first. - A frequency or dB label that would run into the one before it is left out. - The all-pass is drawn flat. The curve's maths takes every shape a Filter has. Found on the way: - The instrument presets set nodes 240 apart. A Filter with its editor is about 700 wide and covered the Bitcrusher, so a Filter now takes four columns. Tests: - Unit: 8 new (filter band, resize command and save, fitting rules), stage lists with a Filter now hold its taps - e2e 34 (atx): ALL PASSED, 130 checks (4 new) - Gate (atx): CHECK EXIT 0, 284 files, 4367 tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
415 lines
15 KiB
TypeScript
415 lines
15 KiB
TypeScript
import { edgesInto, edgesOfNode, findNode, type Graph, type GraphEdge, type GraphNode } from '../graph/Graph';
|
||
import { closesLoop } from '../graph/dropTarget';
|
||
import type { GraphLookup } from '../graph/lookup';
|
||
import { isTerminalType, resolveDef, terminalNode } from '../graph/nested';
|
||
import { nextTransitionName, parallelTransitions } from '../graph/stateMachine';
|
||
import { nodeDef, portsMeet, takesManyEdges } from '../graph/NodeRegistry';
|
||
import { graphLookup } from '../model/graphLookup';
|
||
import { newId } from '../ids';
|
||
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
||
import type { Project } from '../model/Project';
|
||
import { coerceValue, defaultsOf } from '../properties/PropertySchema';
|
||
import { type Removed, removeById, removeFirst, removeWhere, restoreAt } from '../util/list';
|
||
import { type Command, CommandError } from './Command';
|
||
import { requireEdge, requireGraph, requireNode, requireNodeDef } from './require';
|
||
import { SetBagValueCommand } from './SetBagValueCommand';
|
||
|
||
export class AddNodeCommand implements Command {
|
||
readonly label: string;
|
||
readonly node: GraphNode;
|
||
/** The end of the block the node opens (`Find` → `Match`), added beside it in the same step. */
|
||
readonly closing: GraphNode | null;
|
||
|
||
constructor(
|
||
private readonly assetId: string,
|
||
type: string,
|
||
x: number,
|
||
y: number,
|
||
params: PropertyBag = {},
|
||
) {
|
||
// An unknown type is rejected in `execute`: constructors run outside the editor's error
|
||
// handling, so throwing here would crash instead of showing a notice.
|
||
const def = nodeDef(type);
|
||
this.node = {
|
||
id: newId(),
|
||
type,
|
||
x: Math.round(x),
|
||
y: Math.round(y),
|
||
params: { ...defaultsOf(def?.params ?? []), ...params },
|
||
};
|
||
const opens = def?.opens;
|
||
this.closing = opens
|
||
? {
|
||
id: newId(),
|
||
type: opens.type,
|
||
x: this.node.x + opens.dx,
|
||
y: this.node.y,
|
||
params: defaultsOf(nodeDef(opens.type)?.params ?? []),
|
||
}
|
||
: null;
|
||
this.label = def ? `Add ${def.label}` : `Add ${type}`;
|
||
}
|
||
|
||
execute(project: Project): void {
|
||
const def = requireNodeDef(this.node.type);
|
||
if (def.fixed) throw new CommandError(`A ${def.label} node comes and goes on its own`);
|
||
const graph = requireGraph(project, this.assetId);
|
||
// The two terminals give the behavior its signature; a second one of either would make
|
||
// "which ports does this behavior have" ambiguous.
|
||
if (isTerminalType(this.node.type) && terminalNode(graph, this.node.type))
|
||
throw new CommandError(`This graph already has a ${nodeDef(this.node.type)?.label} node`);
|
||
graph.nodes.push(this.node);
|
||
if (this.closing) graph.nodes.push(this.closing);
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
const graph = requireGraph(project, this.assetId);
|
||
graph.nodes = removeById(graph.nodes, this.node.id);
|
||
if (this.closing) graph.nodes = removeById(graph.nodes, this.closing.id);
|
||
}
|
||
}
|
||
|
||
/** Removes a node and every edge touching it. */
|
||
export class RemoveNodeCommand implements Command {
|
||
readonly label = 'Remove node';
|
||
private removed?: { node: Removed<GraphNode>; edges: Removed<GraphEdge>[] };
|
||
|
||
constructor(
|
||
private readonly assetId: string,
|
||
private readonly nodeId: string,
|
||
) {}
|
||
|
||
execute(project: Project): void {
|
||
const graph = requireGraph(project, this.assetId);
|
||
const def = nodeDef(findNode(graph, this.nodeId)?.type ?? '');
|
||
if (def?.fixed) throw new CommandError(`A ${def.label} node comes and goes on its own`);
|
||
const node = removeFirst(graph.nodes, (n) => n.id === this.nodeId);
|
||
if (!node) throw new CommandError(`Node ${this.nodeId} not found`);
|
||
// `edgesOfNode` reads the edge list only, so it still finds them after the node is gone.
|
||
const touching = edgesOfNode(graph, this.nodeId);
|
||
this.removed = { node, edges: removeWhere(graph.edges, (e) => touching.includes(e)) };
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
if (!this.removed) return;
|
||
const graph = requireGraph(project, this.assetId);
|
||
restoreAt(graph.nodes, [this.removed.node]);
|
||
restoreAt(graph.edges, this.removed.edges);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The previous position is read at execution time, so a caller that previewed the drag must restore
|
||
* the node before executing.
|
||
*/
|
||
export class MoveNodeCommand implements Command {
|
||
readonly label = 'Move node';
|
||
private previous = { x: 0, y: 0 };
|
||
|
||
constructor(
|
||
private readonly assetId: string,
|
||
private readonly nodeId: string,
|
||
private readonly x: number,
|
||
private readonly y: number,
|
||
) {}
|
||
|
||
execute(project: Project): void {
|
||
const node = requireNode(project, this.assetId, this.nodeId);
|
||
this.previous = { x: node.x, y: node.y };
|
||
node.x = Math.round(this.x);
|
||
node.y = Math.round(this.y);
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
const node = requireNode(project, this.assetId, this.nodeId);
|
||
node.x = this.previous.x;
|
||
node.y = this.previous.y;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Sets a node param or the inline value of an unconnected data input. A param can decide how many
|
||
* ports the node has, so this takes the edges of ports the new value does away with: an edge on a
|
||
* port that is gone draws as nothing but survives every save, and comes back to life if the port
|
||
* ever returns.
|
||
*/
|
||
export class SetNodeParamCommand extends SetBagValueCommand<GraphNode> {
|
||
private orphans: Removed<GraphEdge>[] = [];
|
||
|
||
constructor(
|
||
private readonly assetId: string,
|
||
private readonly nodeId: string,
|
||
key: string,
|
||
private readonly value: PropertyValue,
|
||
) {
|
||
super(key, `Set ${key}`);
|
||
}
|
||
|
||
override execute(project: Project): void {
|
||
const graph = requireGraph(project, this.assetId);
|
||
const lookup = graphLookup(project);
|
||
// A param can reshape another node too – a Find's mode decides its Match's outputs (#259).
|
||
const held = new Set(graph.edges.filter((e) => edgeHolds(graph, e, lookup)));
|
||
super.execute(project);
|
||
this.orphans = removeWhere(
|
||
graph.edges,
|
||
(e) =>
|
||
(held.has(e) || e.from.node === this.nodeId || e.to.node === this.nodeId) &&
|
||
!edgeHolds(graph, e, lookup),
|
||
);
|
||
}
|
||
|
||
override undo(project: Project): void {
|
||
super.undo(project);
|
||
restoreAt(requireGraph(project, this.assetId).edges, this.orphans);
|
||
}
|
||
|
||
protected override resolveTarget(project: Project): GraphNode {
|
||
return requireNode(project, this.assetId, this.nodeId);
|
||
}
|
||
|
||
protected override bagOf(node: GraphNode): PropertyBag {
|
||
return node.params;
|
||
}
|
||
|
||
protected override computeNext(node: GraphNode): PropertyValue {
|
||
const param = requireNodeDef(node.type).params.find((p) => p.key === this.key);
|
||
// Inline input values are stored as-is; declared params are coerced against their definition.
|
||
return param && param.kind !== 'enum' ? coerceValue(param, this.value) : this.value;
|
||
}
|
||
}
|
||
|
||
/** Whether both ends of an edge name a port their node has; a node of unknown type is not judged. */
|
||
function edgeHolds(graph: Graph, edge: GraphEdge, lookup: GraphLookup): boolean {
|
||
const has = (end: GraphEdge['from'], side: 'inputs' | 'outputs') => {
|
||
const node = findNode(graph, end.node);
|
||
const def = node && resolveDef(node, graph, lookup);
|
||
return !def || def[side].some((p) => p.key === end.port);
|
||
};
|
||
return has(edge.from, 'outputs') && has(edge.to, 'inputs');
|
||
}
|
||
|
||
/** A data input holds at most one edge, so an existing one is replaced (and restored on undo). */
|
||
export class ConnectCommand implements Command {
|
||
readonly label = 'Connect';
|
||
readonly edge: GraphEdge;
|
||
private replaced: Removed<GraphEdge>[] = [];
|
||
|
||
constructor(
|
||
private readonly assetId: string,
|
||
from: { node: string; port: string },
|
||
to: { node: string; port: string },
|
||
) {
|
||
this.edge = { id: newId(), from: { ...from }, to: { ...to } };
|
||
}
|
||
|
||
execute(project: Project): void {
|
||
const graph = requireGraph(project, this.assetId);
|
||
const fromNode = findNode(graph, this.edge.from.node);
|
||
const toNode = findNode(graph, this.edge.to.node);
|
||
if (!fromNode || !toNode) throw new CommandError('Node not found');
|
||
const lookup = graphLookup(project);
|
||
const fromDef = resolveDef(fromNode, graph, lookup);
|
||
// A transition back into its own state re-enters it; any other edge onto its own node is a loop.
|
||
if (fromNode === toNode && fromDef?.transitionEdges !== true)
|
||
throw new CommandError('A node cannot connect to itself');
|
||
const out = fromDef?.outputs.find((p) => p.key === this.edge.from.port);
|
||
const toDef = resolveDef(toNode, graph, lookup);
|
||
const inp = toDef?.inputs.find((p) => p.key === this.edge.to.port);
|
||
if (!out || !inp) throw new CommandError('Port not found');
|
||
if (!portsMeet(out, inp)) throw new CommandError(`Cannot connect ${out.type} to ${inp.type}`);
|
||
if (closesLoop(graph, this.edge.from.node, this.edge.to.node, lookup))
|
||
throw new CommandError('That link would lead round in a loop');
|
||
if (graph.edges.some((e) => sameEnds(e, this.edge))) {
|
||
// Transitions are told apart by name, so a further one between the same states gets its own.
|
||
if (fromDef?.transitionEdges !== true || toDef?.transitionEdges !== true)
|
||
throw new CommandError('Already connected');
|
||
this.edge.label = nextTransitionName(graph, this.edge);
|
||
}
|
||
const occupied = takesManyEdges(inp) ? [] : edgesInto(graph, this.edge.to.node, this.edge.to.port);
|
||
this.replaced = removeWhere(graph.edges, (e) => occupied.includes(e));
|
||
graph.edges.push(this.edge);
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
const graph = requireGraph(project, this.assetId);
|
||
graph.edges = removeById(graph.edges, this.edge.id);
|
||
restoreAt(graph.edges, this.replaced);
|
||
}
|
||
}
|
||
|
||
/** 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 = requireEdge(project, this.assetId, this.edgeId);
|
||
this.previous = edge.folded === true;
|
||
this.apply(edge, this.folded);
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
this.apply(requireEdge(project, this.assetId, this.edgeId), this.previous);
|
||
}
|
||
|
||
private apply(edge: GraphEdge, folded: boolean): void {
|
||
if (folded) edge.folded = true;
|
||
else delete edge.folded;
|
||
}
|
||
}
|
||
|
||
/** The name a transition answers to. Blank clears it, since an unnamed transition is the normal one. */
|
||
export class NameTransitionCommand implements Command {
|
||
readonly label = 'Name transition';
|
||
private previous = '';
|
||
|
||
constructor(
|
||
private readonly assetId: string,
|
||
private readonly edgeId: string,
|
||
private readonly name: string,
|
||
) {}
|
||
|
||
execute(project: Project): void {
|
||
const edge = requireEdge(project, this.assetId, this.edgeId);
|
||
const name = this.name.trim();
|
||
// Between the same two states the name is all that tells transitions apart.
|
||
if (parallelTransitions(requireGraph(project, this.assetId), edge).some((e) => (e.label ?? '') === name))
|
||
throw new CommandError(
|
||
name
|
||
? `Another transition between these states is already called "${name}"`
|
||
: 'Another transition between these states is already unnamed',
|
||
);
|
||
this.previous = edge.label ?? '';
|
||
this.apply(edge, name);
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
this.apply(requireEdge(project, this.assetId, this.edgeId), this.previous);
|
||
}
|
||
|
||
private apply(edge: GraphEdge, name: string): void {
|
||
if (name) edge.label = name;
|
||
else delete edge.label;
|
||
}
|
||
}
|
||
|
||
/** 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;
|
||
}
|
||
|
||
/**
|
||
* The size a node's face was dragged to (#385). The previous size is read at execution time, so a
|
||
* caller that previewed the drag must restore the node before executing.
|
||
*/
|
||
export class ResizeNodeCommand implements Command {
|
||
readonly label = 'Resize node';
|
||
private previous: GraphNode['size'];
|
||
|
||
constructor(
|
||
private readonly assetId: string,
|
||
private readonly nodeId: string,
|
||
private readonly size: { w: number; h: number },
|
||
) {}
|
||
|
||
execute(project: Project): void {
|
||
const node = requireNode(project, this.assetId, this.nodeId);
|
||
this.previous = node.size && { ...node.size };
|
||
node.size = { w: Math.round(this.size.w), h: Math.round(this.size.h) };
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
const node = requireNode(project, this.assetId, this.nodeId);
|
||
if (this.previous) node.size = { ...this.previous };
|
||
else delete node.size;
|
||
}
|
||
}
|
||
|
||
/** Other nodes and edges in place of a graph's, as one step: an instrument's Preset (#329). Groups and notes stay. */
|
||
export class ReplaceGraphCommand implements Command {
|
||
private previous?: Pick<Graph, 'nodes' | 'edges'>;
|
||
|
||
constructor(
|
||
private readonly assetId: string,
|
||
private readonly graph: Pick<Graph, 'nodes' | 'edges'>,
|
||
readonly label: string,
|
||
) {}
|
||
|
||
execute(project: Project): void {
|
||
const graph = requireGraph(project, this.assetId);
|
||
this.previous = { nodes: graph.nodes, edges: graph.edges };
|
||
graph.nodes = structuredClone(this.graph.nodes);
|
||
graph.edges = structuredClone(this.graph.edges);
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
if (!this.previous) return;
|
||
const graph = requireGraph(project, this.assetId);
|
||
graph.nodes = this.previous.nodes;
|
||
graph.edges = this.previous.edges;
|
||
}
|
||
}
|
||
|
||
export class DisconnectCommand implements Command {
|
||
readonly label = 'Disconnect';
|
||
private removed: Removed<GraphEdge>[] = [];
|
||
|
||
constructor(
|
||
private readonly assetId: string,
|
||
private readonly edgeId: string,
|
||
) {}
|
||
|
||
execute(project: Project): void {
|
||
const graph = requireGraph(project, this.assetId);
|
||
this.removed = removeWhere(graph.edges, (e) => e.id === this.edgeId);
|
||
if (this.removed.length === 0) throw new CommandError(`Edge ${this.edgeId} not found`);
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
restoreAt(requireGraph(project, this.assetId).edges, this.removed);
|
||
}
|
||
}
|
||
|
||
function sameEnds(a: GraphEdge, b: GraphEdge): boolean {
|
||
return (
|
||
a.from.node === b.from.node &&
|
||
a.from.port === b.from.port &&
|
||
a.to.node === b.to.node &&
|
||
a.to.port === b.to.port
|
||
);
|
||
}
|