flexdesk 0.4.7 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +1036 -301
  2. package/css/base.css +2958 -2
  3. package/css/flexdesk.css +2962 -4
  4. package/css/overrides.css +4 -2
  5. package/css/tokens.css +86 -0
  6. package/dist/canvas.js +390 -0
  7. package/dist/canvas.js.map +7 -0
  8. package/dist/charts.js +782 -782
  9. package/dist/{chunk-LH5TSOZW.js → chunk-6JSOVNID.js} +42 -2
  10. package/dist/{chunk-LH5TSOZW.js.map → chunk-6JSOVNID.js.map} +3 -3
  11. package/dist/{chunk-ELXVW542.js → chunk-6NG7YVBG.js} +3 -471
  12. package/dist/chunk-6NG7YVBG.js.map +7 -0
  13. package/dist/{chunk-F5FYDCOA.js → chunk-CNXP3SIN.js} +2373 -2373
  14. package/dist/{chunk-F5FYDCOA.js.map → chunk-CNXP3SIN.js.map} +7 -7
  15. package/dist/chunk-H4LT43RS.js +475 -0
  16. package/dist/chunk-H4LT43RS.js.map +7 -0
  17. package/dist/{chunk-WMGOJ3I6.js → chunk-KH2GJTKR.js} +3702 -2628
  18. package/dist/chunk-KH2GJTKR.js.map +7 -0
  19. package/dist/chunk-SN4LSOAR.js +532 -0
  20. package/dist/chunk-SN4LSOAR.js.map +7 -0
  21. package/dist/flexdesk.css +2962 -4
  22. package/dist/flow.js +10030 -0
  23. package/dist/flow.js.map +7 -0
  24. package/dist/tiles.js +2195 -2195
  25. package/dist/tokens.css +86 -0
  26. package/dist/widgets.js +5824 -5796
  27. package/dist/widgets.js.map +1 -1
  28. package/dist/wm.js +8077 -8074
  29. package/dist/wm.js.map +7 -7
  30. package/package.json +89 -81
  31. package/src/canvas/canvas_adapter.js +523 -0
  32. package/src/canvas/edge_router.js +392 -0
  33. package/src/canvas/graph/connector_router.js +189 -0
  34. package/src/canvas/graph/history_service.js +170 -0
  35. package/src/canvas/graph/node_platform.js +116 -0
  36. package/src/canvas/index.js +16 -0
  37. package/src/flow/canvas_editor/editor.js +1081 -0
  38. package/src/flow/canvas_editor/index.js +16 -0
  39. package/src/flow/canvas_editor/layout.js +118 -0
  40. package/src/flow/canvas_editor/strings.js +37 -0
  41. package/src/flow/kit/always_before.js +325 -0
  42. package/src/flow/kit/catalogue.js +170 -0
  43. package/src/flow/kit/chip_input.js +461 -0
  44. package/src/flow/kit/dom.js +205 -0
  45. package/src/flow/kit/findings.js +129 -0
  46. package/src/flow/kit/history.js +173 -0
  47. package/src/flow/kit/index.js +38 -0
  48. package/src/flow/kit/keys.js +127 -0
  49. package/src/flow/kit/logic_graph.js +210 -0
  50. package/src/flow/kit/references.js +199 -0
  51. package/src/flow/kit/settings_panel.js +357 -0
  52. package/src/flow/kit/settings_schema.js +175 -0
  53. package/src/flow/kit/step_picker.js +209 -0
  54. package/src/flow/kit/strings.js +102 -0
  55. package/src/flow/kit/value_picker.js +238 -0
  56. package/src/flow/kit/widgets.js +763 -0
  57. package/src/flow/lanes/editor.js +1469 -0
  58. package/src/flow/lanes/index.js +26 -0
  59. package/src/flow/lanes/layout.js +525 -0
  60. package/src/flow/lanes/pipeline.js +327 -0
  61. package/src/flow/lanes/preview.js +109 -0
  62. package/src/flow/lanes/strings.js +98 -0
  63. package/src/flow/outline/editor.js +1511 -0
  64. package/src/flow/outline/index.js +20 -0
  65. package/src/flow/outline/mapping.js +286 -0
  66. package/src/flow/outline/menu.js +154 -0
  67. package/src/flow/outline/operations.js +738 -0
  68. package/src/flow/outline/recognise.js +755 -0
  69. package/src/flow/outline/strings.js +135 -0
  70. package/src/tiling/landing_table.js +13 -5
  71. package/src/tiling/wm.js +3916 -3916
  72. package/src/ui/components/data_table.js +4205 -3294
  73. package/src/ui/components/row_activation.js +70 -0
  74. package/src/ui/components/table_values.js +423 -0
  75. package/dist/chunk-ELXVW542.js.map +0 -7
  76. package/dist/chunk-WMGOJ3I6.js.map +0 -7
@@ -0,0 +1,738 @@
1
+ /**
2
+ * EVERY EDIT IS AN OPERATION ON THE GRAPH (36 §6.6). PURE: no DOM.
3
+ *
4
+ * The outline holds no second model. An operation takes the graph, changes
5
+ * its nodes and lines — and nothing else — and the outline is read from the
6
+ * result again. So an operation is checked the only way that cannot drift
7
+ * from the drawing: the graph it leaves must be block-shaped, or the
8
+ * operation is REFUSED with a sentence and the graph is left as it was.
9
+ *
10
+ * const ops = createOutlineOperations({ catalogue, blocks, strings, references });
11
+ * const r = ops.insert(graph, { before: 'check' }, 'http-request');
12
+ * // → { ok: true, graph, select } | { ok: false, reason }
13
+ *
14
+ * Every operation works on a COPY and returns it; the graph passed in is
15
+ * never changed. A new step's position is (0, 0), the position a graph
16
+ * without one is read with (I2).
17
+ *
18
+ * ══ WHERE ════════════════════════════════════════════════════════════════
19
+ *
20
+ * A place in the outline is an ANCHOR, named by the steps around it so that
21
+ * it survives the edit that makes room for it:
22
+ *
23
+ * { before: id } in the Seq that holds `id`, just before it
24
+ * { after: id } … just after it
25
+ * { into: ref } at the start of a Seq — the only place in an empty one —
26
+ * where `ref` is 'top', {arm: owner, port}, {body: loop}
27
+ * or {fanout: parallel, first: id | null}
28
+ *
29
+ * A GAP is an anchor read against an outline: the lines arriving there (each
30
+ * a `{source, port}`, with or without a line yet) and the place they go on
31
+ * to (`succ`, or null where nothing follows).
32
+ *
33
+ * ══ A BLOCK IN, A BLOCK OUT ══════════════════════════════════════════════
34
+ *
35
+ * Inserting splices: every line arriving at the gap goes to the new step's
36
+ * input instead, and each of the new step's exits goes on to where those
37
+ * lines went. A block arrives with its arms empty, each connected straight to
38
+ * what follows; a loop with its body and Next unconnected, which the server
39
+ * reports until a step is added; a parallel with its one empty branch into
40
+ * its join. Removing is the reverse: what arrived at a step goes on to its
41
+ * continuation, and the step goes with everything inside it.
42
+ */
43
+
44
+ import { addNode } from '../kit/logic_graph.js';
45
+ import { createStrings, say } from '../kit/strings.js';
46
+ import { OUTLINE_STRINGS } from './strings.js';
47
+ import { createBlockMapping, displayName } from './mapping.js';
48
+ import { outlineFromGraph, indexTree, subtreeIds } from './recognise.js';
49
+
50
+ const portOf = (c, side) => String((side === 'source' ? c.sourcePort : c.targetPort)
51
+ || (side === 'source' ? 'out' : 'in'));
52
+ const sameRef = (a, b) => JSON.stringify(a) === JSON.stringify(b);
53
+ const clone = (v) => structuredClone(v);
54
+
55
+ class OpRefusal extends Error {}
56
+
57
+ /**
58
+ * @param {object} o
59
+ * @param {object} o.catalogue `createStepCatalogue`'s
60
+ * @param {object} [o.blocks] the block mapping (36 §6.2)
61
+ * @param {object} [o.strings]
62
+ * @param {object} [o.references] syntax name → syntax, for renaming references in a paste
63
+ */
64
+ export function createOutlineOperations({ catalogue, blocks = {}, strings = null, references = {} } = {}) {
65
+ const map = createBlockMapping(blocks, catalogue);
66
+ const S = createStrings(OUTLINE_STRINGS, strings);
67
+ const syntaxes = Object.values(references || {}).filter((x) => x && typeof x.rename === 'function');
68
+
69
+ const refuse = (reason) => { throw new OpRefusal(reason); };
70
+ const nameOf = (graph, id) => displayName(graph.nodes.find((n) => n.id === id), catalogue) || String(id);
71
+
72
+ /** The outline of `graph`, with its index; `null` when it is not block-shaped. */
73
+ function read(graph) {
74
+ const r = outlineFromGraph(graph, catalogue, map, { strings });
75
+ if (!r.ok) return { ok: false, refusal: r };
76
+ return { ok: true, tree: r.tree, index: indexTree(r.tree), graph };
77
+ }
78
+
79
+ // ── reading places ───────────────────────────────────────────────────
80
+ function seqOf(view, ref) {
81
+ const { tree, index } = view;
82
+ if (ref === 'top') return tree.top;
83
+ if (ref?.arm !== undefined) {
84
+ const place = index.at.get(ref.arm);
85
+ const arms = place?.item?.arms || [];
86
+ return arms.find((a) => a.port === ref.port)?.seq ?? null;
87
+ }
88
+ if (ref?.body !== undefined) {
89
+ const item = index.at.get(ref.body)?.item;
90
+ return item?.kind === 'loop' ? item.body.seq : null;
91
+ }
92
+ if (ref?.fanout !== undefined) {
93
+ const item = index.at.get(ref.fanout)?.item;
94
+ if (item?.kind !== 'fanout') return null;
95
+ return item.arms.find((a) => (a.seq.items[0]?.id ?? null) === (ref.first ?? null))?.seq ?? null;
96
+ }
97
+ return null;
98
+ }
99
+
100
+ function placeOf(view, id) {
101
+ const p = view.index.at.get(id);
102
+ if (!p || p.seq === null) return null;
103
+ return p;
104
+ }
105
+
106
+ /** An anchor as a Seq and an index in it, or null. */
107
+ function locate(view, anchor) {
108
+ if (!anchor) return null;
109
+ if (anchor.before !== undefined || anchor.after !== undefined) {
110
+ const p = placeOf(view, anchor.before ?? anchor.after);
111
+ if (!p || p.join) return p && p.join && anchor.after !== undefined
112
+ ? { seq: p.seq, index: p.index + 1 } : null;
113
+ return { seq: p.seq, index: p.index + (anchor.after !== undefined ? 1 : 0) };
114
+ }
115
+ if (anchor.into !== undefined) {
116
+ const seq = seqOf(view, anchor.into);
117
+ return seq ? { seq, index: 0 } : null;
118
+ }
119
+ return null;
120
+ }
121
+
122
+ function inputOf(id, graph) {
123
+ return map.input(graph.nodes.find((n) => n.id === id)?.type);
124
+ }
125
+
126
+ /** Where a Seq's last continuation goes. */
127
+ function exitTarget(seq, graph) {
128
+ if (seq.exit === 'next') return seq.to;
129
+ if (seq.exit !== 'rejoin' || !seq.T) return null;
130
+ if (seq.to && seq.to.id === seq.T.id) return seq.to;
131
+ return seq.T.next ? { id: seq.T.id, port: map.loopPorts.next } : { id: seq.T.id, port: inputOf(seq.T.id, graph) };
132
+ }
133
+
134
+ /** The ports an item carries on by: its continuation, and its arms that rejoin it. */
135
+ function outsOf(item) {
136
+ const rejoins = (arms) => arms.flatMap((a) => (a.seq.exit !== 'rejoin' ? []
137
+ : a.seq.items.length ? outsOf(a.seq.items[a.seq.items.length - 1]) : [a.seq.entry]));
138
+ switch (item.kind) {
139
+ case 'start':
140
+ case 'step': return [...(item.cont ? [{ source: item.id, port: item.cont }] : []), ...rejoins(item.arms)];
141
+ case 'loop': return [{ source: item.id, port: item.done }, ...rejoins(item.arms)];
142
+ case 'fanout': return item.join?.cont ? [{ source: item.join.id, port: item.join.cont }] : [];
143
+ case 'branch': return rejoins(item.arms);
144
+ default: return [];
145
+ }
146
+ }
147
+
148
+ /** The innermost loop around a Seq, or null. */
149
+ function loopAround(view, seq) {
150
+ const s = view.index.seqs.find((x) => x.seq === seq);
151
+ if (!s) return null;
152
+ for (let i = s.path.length - 1; i >= 0; i -= 1) if (s.path[i].arm.role === 'body') return s.path[i].owner.id;
153
+ return null;
154
+ }
155
+
156
+ /** A gap's facts: what arrives, where it goes on to, the loop around it. */
157
+ function gapAt(view, seq, index) {
158
+ const items = seq.items;
159
+ const last = items[items.length - 1];
160
+ let succ = index < items.length ? { id: items[index].id, port: items[index].input } : exitTarget(seq, view.graph);
161
+ // An empty body: the first step put in it closes it onto the loop's Next.
162
+ if (!succ && !items.length && seq.ref?.body !== undefined) succ = { id: seq.ref.body, port: map.loopPorts.next };
163
+ const arriving = index === 0 ? [seq.entry] : outsOf(items[index - 1]);
164
+ return {
165
+ seq, index, succ, arriving,
166
+ loop: loopAround(view, seq),
167
+ afterEnd: index === items.length && last?.kind === 'end',
168
+ atEnd: index === items.length,
169
+ };
170
+ }
171
+
172
+ // ── changing lines ───────────────────────────────────────────────────
173
+ function addLine(graph, source, sourcePort, target, targetPort) {
174
+ if (source === target) return;
175
+ if (graph.connections.some((c) => c.source === source && portOf(c, 'source') === sourcePort
176
+ && c.target === target && portOf(c, 'target') === targetPort)) return;
177
+ graph.connections.push({ source, target, sourcePort, targetPort });
178
+ }
179
+
180
+ function dropLine(graph, source, sourcePort, to) {
181
+ graph.connections = graph.connections.filter((c) => !(c.source === source && portOf(c, 'source') === sourcePort
182
+ && (!to || (c.target === to.id && portOf(c, 'target') === to.port))));
183
+ }
184
+
185
+ /** Put `frag` (its nodes already in the graph) into a gap. */
186
+ function splice(graph, gap, frag) {
187
+ if (gap.afterEnd) refuse(say(S, 'nothingAfterEnd', nameOf(graph, gap.seq.items[gap.seq.items.length - 1].id)));
188
+ if (!gap.arriving.length) {
189
+ const prev = gap.seq.items[gap.index - 1];
190
+ refuse(say(S, 'unreachableHere', prev ? nameOf(graph, prev.id) : ''));
191
+ }
192
+ // A fragment that never goes on (an End; a branch every arm of which
193
+ // ends) would leave what follows it unreachable: it can only be last.
194
+ const goesOn = frag.exits.some((x) => x.kind === 'cont');
195
+ if (!goesOn && !gap.atEnd) {
196
+ refuse(say(S, frag.endsHere ? 'endNotLast' : 'endsNotLast', nameOf(graph, frag.head.id)));
197
+ }
198
+ // … and at the end of an arm it may not take the LAST line into what
199
+ // follows the arm's block: nothing would reach it any more.
200
+ if (!goesOn && gap.succ && !gap.addOnly) {
201
+ const from = new Set(gap.arriving.map((a) => `${a.source}\u0000${a.port}`));
202
+ const others = graph.connections.filter((c) => c.target === gap.succ.id && portOf(c, 'target') === gap.succ.port
203
+ && !from.has(`${c.source}\u0000${portOf(c, 'source')}`));
204
+ if (!others.length) refuse(say(S, 'wouldOrphan', nameOf(graph, gap.succ.id)));
205
+ }
206
+ if (frag.exits.some((x) => x.kind === 'next') && !gap.loop) refuse(say(S, 'nextOutsideLoop', nameOf(graph, frag.head.id)));
207
+ if (gap.loop) {
208
+ const end = frag.ids.find((id) => map.kindOf(graph.nodes.find((n) => n.id === id)?.type) === 'end');
209
+ if (end) refuse(say(S, 'endInLoop', nameOf(graph, end), nameOf(graph, gap.loop)));
210
+ }
211
+ for (const a of gap.arriving) {
212
+ if (gap.succ && !gap.addOnly) dropLine(graph, a.source, a.port, gap.succ);
213
+ addLine(graph, a.source, a.port, frag.head.id, frag.head.input);
214
+ }
215
+ for (const x of frag.exits) {
216
+ const to = x.kind === 'next' ? { id: gap.loop, port: map.loopPorts.next } : gap.succ;
217
+ if (to) addLine(graph, x.source, x.port, to.id, to.port);
218
+ }
219
+ for (const l of frag.internal) addLine(graph, l.source, portOf(l, 'source'), l.target, portOf(l, 'target'));
220
+ }
221
+
222
+ /** A new step (or a block's steps) of `typeId`, added to the graph; its fragment. */
223
+ function freshFragment(graph, typeId) {
224
+ const type = catalogue.get(typeId);
225
+ if (!type) refuse(say(S, 'outline_unknown_type', typeId));
226
+ if (type.unavailable) refuse(String(type.unavailable));
227
+ const kind = map.kindOf(typeId);
228
+ if (kind === 'start' || kind === 'join') refuse(say(S, 'outline_unknown_type', typeId));
229
+ const node = addNode(graph, typeId, null, { catalogue });
230
+ const head = { id: node.id, input: map.input(typeId) };
231
+ const exit = (port) => ({ source: node.id, port, kind: 'cont' });
232
+ switch (kind) {
233
+ case 'end': return { head, exits: [], internal: [], ids: [node.id], endsHere: true };
234
+ case 'branch': return { head, exits: map.armPorts(typeId).map(exit), internal: [], ids: [node.id] };
235
+ case 'loop': return { head, exits: [exit(map.loopPorts.done)], internal: [], ids: [node.id] };
236
+ case 'fanout': {
237
+ const joinType = map.joinType();
238
+ if (!joinType) refuse(say(S, 'noJoinType'));
239
+ const join = addNode(graph, joinType, null, { catalogue });
240
+ const out = catalogue.outputs(typeId, { flow: true })[0]?.name ?? 'out';
241
+ return {
242
+ head, ids: [node.id, join.id],
243
+ internal: [{ source: node.id, sourcePort: out, target: join.id, targetPort: map.input(joinType) }],
244
+ exits: [{ source: join.id, port: map.cont(joinType), kind: 'cont' }],
245
+ };
246
+ }
247
+ default: return { head, exits: map.cont(typeId) ? [exit(map.cont(typeId))] : [], internal: [], ids: [node.id] };
248
+ }
249
+ }
250
+
251
+ /**
252
+ * The fragment an item is: its nodes, the lines among them, and its exits
253
+ * — the ports that carry on (`cont`) and those that go on with the next
254
+ * item of a loop outside it (`next`).
255
+ */
256
+ function fragmentOf(view, id) {
257
+ const place = placeOf(view, id);
258
+ if (!place) refuse(say(S, 'outline_unreachable', nameOf(view.graph, id)));
259
+ const item = place.item;
260
+ const ids = subtreeIds(item);
261
+ const inside = new Set(ids);
262
+ const internal = view.graph.connections.filter((c) => inside.has(c.source) && inside.has(c.target));
263
+ const exits = outsOf(item).map((o) => ({ ...o, kind: 'cont' }));
264
+ const walk = (seq) => {
265
+ if (seq.exit === 'next' && !inside.has(seq.loop)) {
266
+ const outs = seq.items.length ? outsOf(seq.items[seq.items.length - 1]) : [seq.entry];
267
+ for (const o of outs) exits.push({ ...o, kind: 'next' });
268
+ }
269
+ for (const it of seq.items) {
270
+ if (it.kind === 'loop') walk(it.body.seq);
271
+ for (const a of it.arms || []) walk(a.seq);
272
+ }
273
+ };
274
+ if (item.kind === 'loop') walk(item.body.seq);
275
+ for (const a of item.arms || []) walk(a.seq);
276
+ return {
277
+ head: { id: item.id, input: item.input }, ids, internal: internal.map(clone), exits,
278
+ endsHere: item.kind === 'end', kind: item.kind, item, place,
279
+ nodes: view.graph.nodes.filter((n) => inside.has(n.id)).map(clone),
280
+ };
281
+ }
282
+
283
+ /**
284
+ * Take an item out of its place: what arrived there goes on to its
285
+ * continuation. Its nodes and the lines among them stay in the graph
286
+ * (unless `drop`), its lines to the outside go.
287
+ */
288
+ function detach(graph, view, id, { drop = false } = {}) {
289
+ const frag = fragmentOf(view, id);
290
+ const { seq, index } = frag.place;
291
+ const before = gapAt(view, seq, index);
292
+ const after = gapAt(view, seq, index + 1);
293
+ const inside = new Set(frag.ids);
294
+ graph.connections = graph.connections.filter((c) => {
295
+ const a = inside.has(c.source);
296
+ const b = inside.has(c.target);
297
+ return drop ? !a && !b : a === b;
298
+ });
299
+ if (drop) graph.nodes = graph.nodes.filter((n) => !inside.has(n.id));
300
+ for (const a of before.arriving) {
301
+ if (after.succ && !(after.succ.id === a.source)) addLine(graph, a.source, a.port, after.succ.id, after.succ.port);
302
+ }
303
+ return frag;
304
+ }
305
+
306
+ /** The graph without a fragment's nodes and inner lines — what to read places from while it is lifted out. */
307
+ function without(graph, ids) {
308
+ const out = new Set(ids);
309
+ return { nodes: graph.nodes.filter((n) => !out.has(n.id)),
310
+ connections: graph.connections.filter((c) => !out.has(c.source) && !out.has(c.target)) };
311
+ }
312
+
313
+ /** A fragment copied under new ids, its references to its own steps renamed; nodes added to `graph`. */
314
+ function freshCopy(graph, frag) {
315
+ const taken = new Set(graph.nodes.map((n) => n.id));
316
+ const ids = new Map();
317
+ for (const n of frag.nodes) {
318
+ const base = String(n.id).replace(/-\d+$/, '') || 'step';
319
+ let id = base;
320
+ for (let i = 2; taken.has(id); i += 1) id = `${base}-${i}`;
321
+ taken.add(id);
322
+ ids.set(n.id, id);
323
+ }
324
+ const rename = (v) => {
325
+ if (typeof v === 'string') return syntaxes.reduce((text, s) => s.rename(text, ids), v);
326
+ if (Array.isArray(v)) return v.map(rename);
327
+ if (v && typeof v === 'object') return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, rename(x)]));
328
+ return v;
329
+ };
330
+ for (const n of frag.nodes) {
331
+ graph.nodes.push({ ...clone(n), id: ids.get(n.id), config: rename(clone(n.config ?? {})),
332
+ position: { x: 0, y: 0 } });
333
+ }
334
+ const m = (id) => ids.get(id) ?? id;
335
+ return {
336
+ head: { id: m(frag.head.id), input: frag.head.input },
337
+ ids: frag.ids.map(m),
338
+ internal: frag.internal.map((c) => ({ ...c, source: m(c.source), target: m(c.target) })),
339
+ exits: frag.exits.map((x) => ({ ...x, source: m(x.source) })),
340
+ endsHere: frag.endsHere,
341
+ };
342
+ }
343
+
344
+ // ── running an operation ─────────────────────────────────────────────
345
+ /** Run `fn` on a copy; keep the copy only when it is block-shaped. */
346
+ function attempt(graph, fn) {
347
+ const g = clone(graph);
348
+ g.nodes = Array.isArray(g.nodes) ? g.nodes : [];
349
+ g.connections = Array.isArray(g.connections) ? g.connections : [];
350
+ const view = read(g);
351
+ if (!view.ok) return { ok: false, reason: view.refusal.message };
352
+ try {
353
+ const select = fn(g, view);
354
+ const check = read(g);
355
+ if (!check.ok) return { ok: false, reason: say(S, 'notBlockShaped', check.refusal.message) };
356
+ return { ok: true, graph: g, select: select ?? null, view: check };
357
+ } catch (err) {
358
+ if (err instanceof OpRefusal) return { ok: false, reason: err.message };
359
+ throw err;
360
+ }
361
+ }
362
+
363
+ /**
364
+ * The place a gap really is. After a branch whose arms meet nowhere, the
365
+ * only lines that go on come from the arms that rejoin; when ONE arm does,
366
+ * "after the block" and "at the end of that arm" are the same lines, and
367
+ * the outline reads them as the arm — so that is where the gap is.
368
+ */
369
+ function canonical(view, seq, index) {
370
+ for (;;) {
371
+ const prev = seq.items[index - 1];
372
+ if (!prev || prev.kind !== 'branch' || index !== seq.items.length) return { seq, index };
373
+ // The arms that hand a line on to what follows — an arm can rejoin and
374
+ // still hand none on, when its last block ends on its own.
375
+ const live = prev.arms.filter((a) => (a.seq.exit !== 'rejoin' ? false
376
+ : a.seq.items.length ? outsOf(a.seq.items[a.seq.items.length - 1]).length > 0 : true));
377
+ if (live.length !== 1) return { seq, index };
378
+ seq = live[0].seq;
379
+ index = seq.items.length;
380
+ }
381
+ }
382
+
383
+ function gapFor(view, anchor) {
384
+ const at = locate(view, anchor);
385
+ if (!at) refuse(say(S, 'dropNowhere'));
386
+ const c = canonical(view, at.seq, at.index);
387
+ return gapAt(view, c.seq, c.index);
388
+ }
389
+
390
+ /** Is `anchor` inside the item `id` (or right where it is already)? */
391
+ function insideOf(view, id, anchor) {
392
+ const ids = new Set(subtreeIds(placeOf(view, id).item));
393
+ const refIds = [anchor.before, anchor.after, anchor.into?.arm, anchor.into?.body, anchor.into?.fanout]
394
+ .filter((x) => x !== undefined);
395
+ return refIds.some((x) => ids.has(x) && !(x === id && (anchor.before !== undefined || anchor.after !== undefined)));
396
+ }
397
+
398
+ // ── the operations ───────────────────────────────────────────────────
399
+ const ops = {
400
+ map, strings: S, read, locate, gapAt, outsOf, fragmentOf,
401
+
402
+ /** Insert a new step of `typeId` (a block's steps, for a block) at `anchor`. */
403
+ insert(graph, anchor, typeId) {
404
+ return attempt(graph, (g, view) => {
405
+ const gap = gapFor(view, anchor);
406
+ const frag = freshFragment(g, typeId);
407
+ splice(g, gap, frag);
408
+ return frag.head.id;
409
+ });
410
+ },
411
+
412
+ /** Remove a step and everything inside it. */
413
+ remove(graph, id) {
414
+ return attempt(graph, (g, view) => {
415
+ const place = placeOf(view, id);
416
+ if (!place) refuse(say(S, 'outline_unreachable', nameOf(g, id)));
417
+ const { seq, index } = place;
418
+ const holder = view.index.seqs.find((x) => x.seq === seq);
419
+ if (holder?.arm?.role === 'fanout' && seq.items.length === 1 && !holder.owner.join
420
+ && holder.owner.arms.length === 1) refuse(say(S, 'lastBranch', nameOf(g, holder.owner.id)));
421
+ const nextId = seq.items[index + 1]?.id ?? seq.items[index - 1]?.id ?? view.index.seqs
422
+ .find((x) => x.seq === seq)?.owner?.id ?? null;
423
+ detach(g, view, place.item.id, { drop: true });
424
+ return nextId;
425
+ });
426
+ },
427
+
428
+ /** Move a step (with what is inside it) to `anchor`, keeping its id. */
429
+ move(graph, id, anchor) {
430
+ return attempt(graph, (g, view) => {
431
+ const place = placeOf(view, id);
432
+ if (!place) refuse(say(S, 'outline_unreachable', nameOf(g, id)));
433
+ const itemId = place.item.id;
434
+ if (insideOf(view, itemId, anchor)) refuse(say(S, 'intoItself', nameOf(g, itemId)));
435
+ const frag = detach(g, view, itemId);
436
+ const lifted = read(without(g, frag.ids));
437
+ if (!lifted.ok) refuse(lifted.refusal.message);
438
+ splice(g, gapFor({ ...lifted, graph: g }, anchor), frag);
439
+ return itemId;
440
+ });
441
+ },
442
+
443
+ moveUp(graph, id) {
444
+ const view = read(graph);
445
+ if (!view.ok) return { ok: false, reason: view.refusal.message };
446
+ const p = placeOf(view, id);
447
+ if (!p) return { ok: false, reason: say(S, 'readOnlyRefusal') };
448
+ if (p.index === 0) return { ok: false, reason: say(S, 'alreadyFirst', seqName(view, p.seq)) };
449
+ return ops.move(graph, id, { before: p.seq.items[p.index - 1].id });
450
+ },
451
+
452
+ moveDown(graph, id) {
453
+ const view = read(graph);
454
+ if (!view.ok) return { ok: false, reason: view.refusal.message };
455
+ const p = placeOf(view, id);
456
+ if (!p) return { ok: false, reason: say(S, 'readOnlyRefusal') };
457
+ const items = p.seq.items;
458
+ if (p.index >= items.length - 1) return { ok: false, reason: say(S, 'alreadyLast', seqName(view, p.seq)) };
459
+ const anchor = p.index + 2 < items.length ? { before: items[p.index + 2].id } : { after: items[p.index + 1].id };
460
+ return ops.move(graph, id, anchor);
461
+ },
462
+
463
+ moveOut(graph, id) {
464
+ const view = read(graph);
465
+ if (!view.ok) return { ok: false, reason: view.refusal.message };
466
+ const p = placeOf(view, id);
467
+ if (!p) return { ok: false, reason: say(S, 'readOnlyRefusal') };
468
+ if (!p.owner || p.owner.kind === 'start') return { ok: false, reason: say(S, 'alreadyTop') };
469
+ return ops.move(graph, id, { after: p.owner.id });
470
+ },
471
+
472
+ /** Wrap a step in a new block of `typeId`: it becomes the block's first arm (or its body). */
473
+ wrap(graph, id, typeId) {
474
+ return attempt(graph, (g, view) => {
475
+ const place = placeOf(view, id);
476
+ if (!place) refuse(say(S, 'outline_unreachable', nameOf(g, id)));
477
+ const itemId = place.item.id;
478
+ const { seq, index } = place;
479
+ // The only step of a parallel's branch leaves that branch empty, and
480
+ // an empty branch is named by having no first step.
481
+ const emptyRef = seq.ref?.fanout !== undefined ? { fanout: seq.ref.fanout, first: null } : seq.ref;
482
+ const anchor = index > 0 ? { after: seq.items[index - 1].id }
483
+ : seq.items[index + 1] ? { before: seq.items[index + 1].id } : { into: emptyRef };
484
+ const frag = detach(g, view, itemId);
485
+ const lifted = read(without(g, frag.ids));
486
+ if (!lifted.ok) refuse(lifted.refusal.message);
487
+ const kind = map.kindOf(typeId);
488
+ if (!['branch', 'loop', 'fanout'].includes(kind)) refuse(say(S, 'outline_unknown_type', typeId));
489
+ const goesOn = frag.exits.some((x) => x.kind === 'cont');
490
+ if (kind === 'loop' && !goesOn) refuse(say(S, 'wrapLoopNeverReturns', nameOf(g, itemId)));
491
+ const block = freshFragment(g, typeId);
492
+ if (kind === 'fanout' && !goesOn) {
493
+ // Its one branch ends on its own: nothing to wait for, no Merge.
494
+ const join = block.ids[1];
495
+ g.nodes = g.nodes.filter((n) => n.id !== join);
496
+ block.ids = [block.ids[0]];
497
+ block.internal = [];
498
+ block.exits = [];
499
+ }
500
+ splice(g, gapFor({ ...lifted, graph: g }, anchor), block);
501
+ const withBlock = read(without(g, frag.ids));
502
+ if (!withBlock.ok) refuse(withBlock.refusal.message);
503
+ const inner = kind === 'branch' ? { arm: block.head.id, port: map.armPorts(typeId)[0] }
504
+ : kind === 'loop' ? { body: block.head.id } : { fanout: block.head.id, first: null };
505
+ if (kind === 'fanout' && !block.exits.length) {
506
+ const port = catalogue.outputs(typeId, { flow: true })[0]?.name ?? 'out';
507
+ splice(g, { seq: { items: [] }, index: 0, arriving: [{ source: block.head.id, port }], succ: null,
508
+ loop: loopAround(withBlock, withBlock.index.at.get(block.head.id).seq),
509
+ atEnd: true, afterEnd: false }, frag);
510
+ return block.head.id;
511
+ }
512
+ splice(g, gapFor({ ...withBlock, graph: g }, { into: inner }), frag);
513
+ return block.head.id;
514
+ });
515
+ },
516
+
517
+ /** A copy of a step (and what is inside it) right after it. */
518
+ duplicate(graph, id) {
519
+ return attempt(graph, (g, view) => {
520
+ const frag = fragmentOf(view, id);
521
+ if (frag.endsHere) refuse(say(S, 'nothingAfterEnd', nameOf(g, frag.head.id)));
522
+ const copy = freshCopy(g, frag);
523
+ splice(g, gapFor(view, { after: frag.head.id }), copy);
524
+ return copy.head.id;
525
+ });
526
+ },
527
+
528
+ /** What Copy keeps: a step's fragment, as plain data. */
529
+ copy(graph, id) {
530
+ const view = read(graph);
531
+ if (!view.ok) return null;
532
+ try {
533
+ const f = fragmentOf(view, id);
534
+ return { head: f.head, ids: f.ids, internal: f.internal, exits: f.exits, endsHere: f.endsHere,
535
+ nodes: f.nodes, label: displayName(f.nodes[0], catalogue), kind: f.kind };
536
+ } catch (err) {
537
+ if (err instanceof OpRefusal) return null;
538
+ throw err;
539
+ }
540
+ },
541
+
542
+ /** Paste a copied fragment at `anchor`, under new ids. */
543
+ paste(graph, anchor, copied) {
544
+ if (!copied) return { ok: false, reason: say(S, 'nothingCopied') };
545
+ return attempt(graph, (g, view) => {
546
+ const gap = gapFor(view, anchor);
547
+ const copy = freshCopy(g, copied);
548
+ splice(g, gap, copy);
549
+ return copy.head.id;
550
+ });
551
+ },
552
+
553
+ /** An ordinary step's arm port: connected straight to the step's continuation, or not at all. */
554
+ setArm(graph, id, port, connected) {
555
+ return attempt(graph, (g, view) => {
556
+ const place = placeOf(view, id);
557
+ const arm = place?.item?.arms?.find((a) => a.port === port);
558
+ if (!arm) refuse(say(S, 'outline_unknown_port', nameOf(g, id), port));
559
+ if (connected) {
560
+ if (arm.seq.exit !== 'open' || arm.seq.items.length) return id;
561
+ const after = gapAt(view, place.seq, place.index + 1).succ;
562
+ if (!after) refuse(say(S, 'armNoContinuation', nameOf(g, id)));
563
+ addLine(g, id, port, after.id, after.port);
564
+ } else {
565
+ const inside = new Set(arm.seq.items.flatMap(subtreeIds));
566
+ g.nodes = g.nodes.filter((n) => !inside.has(n.id));
567
+ g.connections = g.connections.filter((c) => !inside.has(c.source) && !inside.has(c.target)
568
+ && !(c.source === id && portOf(c, 'source') === port));
569
+ }
570
+ return id;
571
+ });
572
+ },
573
+
574
+ /** Where an arm inside a loop goes after its steps: on with the step after, or the loop's next item. */
575
+ setArmExit(graph, id, port, exit) {
576
+ return attempt(graph, (g, view) => {
577
+ const place = placeOf(view, id);
578
+ const arm = place?.item?.arms?.find((a) => a.port === port);
579
+ if (!arm || !['rejoin', 'next'].includes(arm.seq.exit)) refuse(say(S, 'readOnlyRefusal'));
580
+ if (arm.seq.exit === exit) return id;
581
+ const loop = loopAround(view, place.seq);
582
+ const cont = gapAt(view, place.seq, place.index + 1).succ;
583
+ if (!loop || !cont) refuse(say(S, 'nextOutsideLoop', nameOf(g, id)));
584
+ const from = exit === 'next' ? cont : { id: loop, port: map.loopPorts.next };
585
+ const to = exit === 'next' ? { id: loop, port: map.loopPorts.next } : cont;
586
+ const outs = arm.seq.items.length ? outsOf(arm.seq.items[arm.seq.items.length - 1]) : [arm.seq.entry];
587
+ for (const o of outs) {
588
+ dropLine(g, o.source, o.port, from);
589
+ addLine(g, o.source, o.port, to.id, to.port);
590
+ }
591
+ return id;
592
+ });
593
+ },
594
+
595
+ /** A new branch of a parallel step, holding one new step of `typeId`. */
596
+ addBranch(graph, fanoutId, typeId) {
597
+ return attempt(graph, (g, view) => {
598
+ const item = placeOf(view, fanoutId)?.item;
599
+ if (item?.kind !== 'fanout') refuse(say(S, 'readOnlyRefusal'));
600
+ const port = catalogue.outputs(g.nodes.find((n) => n.id === fanoutId).type, { flow: true })[0]?.name ?? 'out';
601
+ const frag = freshFragment(g, typeId);
602
+ const gap = { seq: { items: [] }, index: 0, arriving: [{ source: fanoutId, port }], loop: loopAround(view, placeOf(view, fanoutId).seq),
603
+ succ: item.join ? { id: item.join.id, port: item.join.input } : null, atEnd: true, afterEnd: false,
604
+ // the parallel's other lines out stay: a new branch is one more of them
605
+ addOnly: true };
606
+ splice(g, gap, frag);
607
+ return frag.head.id;
608
+ });
609
+ },
610
+
611
+ /** Remove one branch of a parallel step and everything in it. */
612
+ removeBranch(graph, fanoutId, armIndex) {
613
+ return attempt(graph, (g, view) => {
614
+ const item = placeOf(view, fanoutId)?.item;
615
+ if (item?.kind !== 'fanout') refuse(say(S, 'readOnlyRefusal'));
616
+ if (item.arms.length <= 1) refuse(say(S, 'lastBranch', nameOf(g, fanoutId)));
617
+ const arm = item.arms[armIndex];
618
+ if (!arm) refuse(say(S, 'readOnlyRefusal'));
619
+ // The join must still be reached: by a branch that goes on to it.
620
+ if (item.join && !item.arms.some((a, i) => i !== armIndex && a.seq.exit === 'rejoin')) {
621
+ refuse(say(S, 'branchToJoinNeeded', nameOf(g, fanoutId), nameOf(g, item.join.id)));
622
+ }
623
+ const inside = new Set(arm.seq.items.flatMap(subtreeIds));
624
+ const first = arm.seq.items[0];
625
+ g.nodes = g.nodes.filter((n) => !inside.has(n.id));
626
+ g.connections = g.connections.filter((c) => !inside.has(c.source) && !inside.has(c.target)
627
+ && !(c.source === fanoutId && portOf(c, 'source') === arm.port
628
+ && (first ? c.target === first.id : c.target === item.join?.id)));
629
+ return fanoutId;
630
+ });
631
+ },
632
+
633
+ /** The parallel's join setting (`all` / `any`), written to its join step's config. */
634
+ setJoin(graph, fanoutId, value) {
635
+ return attempt(graph, (g, view) => {
636
+ const item = placeOf(view, fanoutId)?.item;
637
+ if (item?.kind !== 'fanout' || !item.join) refuse(say(S, 'readOnlyRefusal'));
638
+ const node = g.nodes.find((n) => n.id === item.join.id);
639
+ node.config = { ...(node.config || {}), [map.joinField]: value };
640
+ return fanoutId;
641
+ });
642
+ },
643
+
644
+ /** Is the anchor somewhere `id` could be moved to? (A drag's drop targets ask.) */
645
+ canMoveTo(view, id, anchor) {
646
+ return !insideOf(view, placeOf(view, id)?.item?.id ?? id, anchor);
647
+ },
648
+ };
649
+
650
+ // ── words for places ─────────────────────────────────────────────────
651
+ function armWords(graph, owner, arm, i) {
652
+ const node = graph.nodes.find((n) => n.id === owner.id);
653
+ if (arm.role === 'body') return displayName(node, catalogue);
654
+ if (arm.role === 'fanout') return map.fanoutArmLabel(i + 1);
655
+ if (owner.kind === 'branch') return map.branchArmLabel(node, arm.port);
656
+ return map.armLabel(node, arm.port);
657
+ }
658
+
659
+ /** The steps and arms around a Seq, as words: ["Anything new?", "Then", "For each …"]. */
660
+ function pathWords(view, seq) {
661
+ const s = view.index.seqs.find((x) => x.seq === seq);
662
+ if (!s) return [];
663
+ const out = [];
664
+ for (const step of s.path) {
665
+ const i = step.owner.arms ? step.owner.arms.indexOf(step.arm) : -1;
666
+ if (step.arm.role === 'body') out.push(displayName(view.graph.nodes.find((n) => n.id === step.owner.id), catalogue));
667
+ else {
668
+ out.push(displayName(view.graph.nodes.find((n) => n.id === step.owner.id), catalogue));
669
+ out.push(armWords(view.graph, step.owner, step.arm, i));
670
+ }
671
+ }
672
+ return out;
673
+ }
674
+
675
+ function seqName(view, seq) {
676
+ if (seq === view.tree.top) return say(S, 'theTopLevel');
677
+ const s = view.index.seqs.find((x) => x.seq === seq);
678
+ if (!s) return '';
679
+ const last = s.path[s.path.length - 1];
680
+ const i = last.owner.arms ? last.owner.arms.indexOf(last.arm) : -1;
681
+ return armWords(view.graph, last.owner, last.arm, i);
682
+ }
683
+
684
+ /** Where a step is: "Top level", or "Inside Anything new? › Then › For each new application". */
685
+ ops.whereText = (view, id) => {
686
+ const p = view.index.at.get(id);
687
+ if (!p || !p.seq) return '';
688
+ if (p.seq === view.tree.top) {
689
+ const items = p.seq.items;
690
+ if (items.length > 1 && p.index === 0) return say(S, 'topLevelFirst');
691
+ if (items.length > 1 && p.index === items.length - 1) return say(S, 'topLevelLast');
692
+ return say(S, 'topLevel');
693
+ }
694
+ return say(S, 'insidePath', pathWords(view, p.seq).join(say(S, 'pathJoin')));
695
+ };
696
+
697
+ /** Where a step put in a gap lands, for the step picker: "inside For each …, after Check eligibility". */
698
+ ops.gapWords = (view, seq, index) => {
699
+ const where = seq === view.tree.top ? say(S, 'gapTop')
700
+ : say(S, 'gapInside', pathWords(view, seq).join(say(S, 'pathJoin')));
701
+ const prev = seq.items[index - 1];
702
+ const next = seq.items[index];
703
+ if (prev) return say(S, 'gapAfter', where, nameOf(view.graph, prev.id));
704
+ if (next) return say(S, 'gapBefore', where, nameOf(view.graph, next.id));
705
+ return say(S, 'gapFirst', where);
706
+ };
707
+
708
+ /** A drop target's label: "Into Otherwise · before Stop: nothing new". */
709
+ ops.dropWords = (view, seq, index) => {
710
+ const where = seq === view.tree.top ? say(S, 'dropTop') : say(S, 'dropInto', seqName(view, seq));
711
+ const next = seq.items[index];
712
+ const prev = seq.items[index - 1];
713
+ const at = next ? say(S, 'dropBefore', nameOf(view.graph, next.id))
714
+ : prev ? say(S, 'dropAfter', nameOf(view.graph, prev.id)) : say(S, 'dropFirst');
715
+ return say(S, 'dropLabel', where, at);
716
+ };
717
+
718
+ /** An anchor as the Seq and index an operation will use (`canonical`'s), or null. */
719
+ ops.resolve = (view, anchor) => {
720
+ const at = locate(view, anchor);
721
+ return at ? canonical(view, at.seq, at.index) : null;
722
+ };
723
+ /** Is this gap its own canonical place (rather than another name for one)? */
724
+ ops.isCanonical = (view, seq, index) => {
725
+ const c = canonical(view, seq, index);
726
+ return c.seq === seq && c.index === index;
727
+ };
728
+ ops.seqName = seqName;
729
+ ops.armWords = armWords;
730
+ ops.anchorOf = (seq, index) => {
731
+ if (index < seq.items.length) return { before: seq.items[index].id };
732
+ if (index > 0) return { after: seq.items[index - 1].id };
733
+ return { into: seq.ref };
734
+ };
735
+ ops.sameRef = sameRef;
736
+ ops.loopAround = loopAround;
737
+ return ops;
738
+ }