@helping-ai-workflow/md2doc 3.4.0 → 3.5.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.
@@ -1786,6 +1786,83 @@ function copyObject(obj) {
1786
1786
  return out;
1787
1787
  }
1788
1788
 
1789
+ /**
1790
+ * Which entry of a lane's `data` list a display cycle draws, or `null` for a
1791
+ * cycle that draws none of its own.
1792
+ *
1793
+ * Only `=` and `2`-`9` written out explicitly consume a slot; `.` (a held
1794
+ * continuation) and `|` (a gap) do not — measured against the pinned wavedrom
1795
+ * 3.5.0, the same rule `readLane`'s slot counter already applies while it
1796
+ * folds labels into cells. This is that same count exposed as a standalone
1797
+ * lookup, so a caller who only has a cycle index — a click, a keyboard
1798
+ * cursor — can ask "which array index is this" before it needs anything else
1799
+ * about the lane. It reads the wave string directly through `expandWave`
1800
+ * rather than through `readLane`, because the question here never touches a
1801
+ * label value, only the shape of the wave.
1802
+ */
1803
+ function dataSlotOf(lane, cell) {
1804
+ if (lane === null || typeof lane !== 'object') return null;
1805
+ const cells = expandWave(typeof lane.wave === 'string' ? lane.wave : '');
1806
+ if (!isIndex(cell, cells.length)) return null;
1807
+ let slot = 0;
1808
+ for (let i = 0; i < cells.length; i++) {
1809
+ const c = cells[i];
1810
+ const consumes = !c.held && isValueChar(c.ch);
1811
+ if (i === cell) return consumes ? slot : null;
1812
+ if (consumes) slot++;
1813
+ }
1814
+ return null;
1815
+ }
1816
+
1817
+ /**
1818
+ * Write one entry of a lane's `data` list by array index, on the lane at
1819
+ * display position `at`. Never addressed by cycle — a caller who has a cycle
1820
+ * gets the index from `dataSlotOf` first, so the counting `data` is an ARRAY
1821
+ * and only some cycles consume a slot from it never has to happen twice.
1822
+ *
1823
+ * A slot that lands on one of the wave's own value cycles goes in through the
1824
+ * same `readLane` / `writeLane` round trip every other label edit already
1825
+ * uses, so text-vs-array spelling and the whitespace `readLane` preserved
1826
+ * come through untouched. A slot past the last consuming cycle — one the wave
1827
+ * itself never reaches, which wavedrom ignores and the picture never shows —
1828
+ * extends `spare` instead, padded with `''` so nothing in between is left a
1829
+ * hole.
1830
+ *
1831
+ * `slot` must be a non-negative integer and `text` a string; anything else,
1832
+ * or an `at` outside the document, answers the same doc unchanged, and so
1833
+ * does a `text` equal to what is already there — `mapLane` catches that on
1834
+ * its own by comparing the lane it gets back.
1835
+ */
1836
+ function setDataAt(doc, at, slot, text) {
1837
+ if (!Number.isInteger(slot) || slot < 0) return doc;
1838
+ if (typeof text !== 'string') return doc;
1839
+ return mapLane(doc, at, function (lane) {
1840
+ const model = readLane(lane);
1841
+ const cells = model.cells;
1842
+ let total = 0;
1843
+ let hit = -1;
1844
+ for (let i = 0; i < cells.length; i++) {
1845
+ const cell = cells[i];
1846
+ if (cell.held || !isValueChar(cell.ch)) continue;
1847
+ if (total === slot) hit = i;
1848
+ total++;
1849
+ }
1850
+ if (hit !== -1) {
1851
+ const next = cells.slice();
1852
+ next[hit] = { ch: cells[hit].ch, held: cells[hit].held, label: text };
1853
+ model.cells = next;
1854
+ } else {
1855
+ const spare = model.spare.slice();
1856
+ const pos = slot - total;
1857
+ while (spare.length <= pos) spare.push('');
1858
+ spare[pos] = text;
1859
+ model.spare = spare;
1860
+ }
1861
+ model.hasLabels = true;
1862
+ return writeLane(lane, model);
1863
+ });
1864
+ }
1865
+
1789
1866
  /**
1790
1867
  * A copy all the way down, for a value arriving from a caller who keeps their
1791
1868
  * own reference to it. A shallow copy leaves the arrays shared, so a caller
@@ -2349,6 +2426,51 @@ function moveLane(doc, from, to) {
2349
2426
  return insertAtPath(reduced, path, lane);
2350
2427
  }
2351
2428
 
2429
+ /**
2430
+ * Copy the lane at display position `at` and put the copy immediately after
2431
+ * it, at display position `at + 1` — a full deep copy, `data` / any other key
2432
+ * included, not a name-and-wave reconstruction that would silently drop the
2433
+ * rest. Built on `addLane`, which already does that deep copy
2434
+ * (`copyValue(lane, MAX_DEPTH)`) for a caller-owned object.
2435
+ *
2436
+ * `node` IS THE ONE EXCEPTION and is stripped before the copy is built —
2437
+ * final review finding 2. `node` is a map of anchor LETTERS to cell indices
2438
+ * (§1's invariant: a letter resolves to exactly one `{lane, cell}`), and it
2439
+ * names the ORIGINAL lane, not the wave shape being copied. Carrying it over
2440
+ * verbatim gives two lanes the same letters; `nodesOf` resolves each letter to
2441
+ * whichever lane it saw last, so every edge anchored on the original lane
2442
+ * silently relocates onto the fresh duplicate, and `pruneNodes` cannot repair
2443
+ * it — it only asks whether a letter is referenced *somewhere*, and after the
2444
+ * duplication it still is. A brand-new lane starts with no edge pointing at
2445
+ * it, which is what "no `node`" means; the user can draw new anchors once
2446
+ * they decide what this copy is for. This was PINNED the other way — a
2447
+ * `test/wave-codec.test.js` assertion whose comment said every field
2448
+ * including `node` was copied, and the duplicate button's tooltip in
2449
+ * `wave-ui.js` promised the same — both were reversed alongside this fix.
2450
+ *
2451
+ * `at + 1` goes through `laneInsertPath` exactly as every other insert does,
2452
+ * so it inherits the one asymmetry that function documents: duplicating a
2453
+ * group's LAST lane lands the copy just outside the group (append to `signal`
2454
+ * itself), while duplicating any other lane in the group — including its
2455
+ * first — keeps the copy inside. That is not a special case written for
2456
+ * duplication; it is the same rule the rail's own insert button already
2457
+ * surfaces to the user, reached through the same door.
2458
+ */
2459
+ function duplicateLane(doc, at) {
2460
+ const flat = flattenLanes(doc);
2461
+ if (!isIndex(at, flat.length)) return doc;
2462
+ const lane = flat[at].lane;
2463
+ let toCopy = lane;
2464
+ if (lane !== null && typeof lane === 'object' && !Array.isArray(lane) &&
2465
+ Object.prototype.hasOwnProperty.call(lane, 'node')) {
2466
+ toCopy = {};
2467
+ for (const key of Object.keys(lane)) {
2468
+ if (key !== 'node') toCopy[key] = lane[key];
2469
+ }
2470
+ }
2471
+ return addLane(doc, at + 1, toCopy);
2472
+ }
2473
+
2352
2474
  /**
2353
2475
  * Where the lane at display position `index` lives, as a path the patching half
2354
2476
  * of this file addresses members by — `['signal', 1, 2]` for the second lane of
@@ -2611,6 +2733,18 @@ function replaceSegment(node, seg, child) {
2611
2733
  return out;
2612
2734
  }
2613
2735
 
2736
+ /**
2737
+ * A new document with the whole member at `path` swapped for `value`, sharing
2738
+ * everything else — the third of this trio alongside `withoutPath` and
2739
+ * `insertAtPath`, for the one shape neither of those two says: "this exact
2740
+ * container becomes a different array", which is what dissolving a group is.
2741
+ * `path` must not be empty; there is no member to replace at the root.
2742
+ */
2743
+ function setAtPath(node, path, value) {
2744
+ if (path.length === 1) return replaceSegment(node, path[0], value);
2745
+ return replaceSegment(node, path[0], setAtPath(node[path[0]], path.slice(1), value));
2746
+ }
2747
+
2614
2748
  /** Rename the lane at display position `at`. */
2615
2749
  function renameLane(doc, at, name) {
2616
2750
  if (typeof name !== 'string') return doc;
@@ -2621,11 +2755,494 @@ function renameLane(doc, at, name) {
2621
2755
  });
2622
2756
  }
2623
2757
 
2758
+ /**
2759
+ * Wrap the CONTIGUOUS run of lanes at display positions `ats` into one new
2760
+ * named group — WaveJSON's only shape for one, an array whose first element
2761
+ * is `label` and whose rest are the lanes it names, nested inside `signal`.
2762
+ * That shape is why the run has to be contiguous: a group is a slice of ONE
2763
+ * array, and lanes that are not next to each other in display order cannot
2764
+ * become elements of the same slice without an author reading the result as
2765
+ * lanes that silently changed place. So a selection that is not contiguous —
2766
+ * `ats` sorted does not read `k, k+1, k+2, …` — is refused, the same doc back,
2767
+ * rather than grouping the SPAN between the gaps and silently carrying along
2768
+ * lanes the caller never selected.
2769
+ *
2770
+ * `ats` may name lanes that live under different existing groups, or none —
2771
+ * the members of the new group are found by `laneRemovePaths`, which already
2772
+ * answers the one question this needs: what has to come OUT of the document
2773
+ * for this set of lanes to leave it, taking an existing group with them when
2774
+ * every one of ITS lanes is in the set (the same rule `removeLane` applies to
2775
+ * a single lane, batched the way multi-select needs). Two things follow from
2776
+ * reusing that answer rather than re-deriving it: the topmost members it
2777
+ * names are always siblings of ONE array — display-contiguous lanes cannot
2778
+ * collapse into removals that live in two different containers without a
2779
+ * non-selected lane or an un-consumed sub-group sitting between them, and
2780
+ * either one would have broken the display-contiguity check above first —
2781
+ * and the new group lands exactly where that array's first survivor used to
2782
+ * be, which is what makes the edit read as "these lanes became a group" and
2783
+ * not as "these lanes moved somewhere else and a group appeared".
2784
+ *
2785
+ * The lane objects themselves are carried over as the SAME objects, the same
2786
+ * promise `moveLane` makes and for the identical reason: `wave-store`'s
2787
+ * `remapOrigins` matches lanes across an edit by identity, and a copy here
2788
+ * would make an ordinary re-grouping look like every one of those lanes was
2789
+ * deleted and a new one added in its place.
2790
+ *
2791
+ * `label` must be a string (an empty one is fine — the caller can rename the
2792
+ * group afterwards, same as a newly added lane starts with an empty name).
2793
+ * Anything else, an `ats` that is not an array, an empty one, an index out of
2794
+ * range, or a defensive shape the removal set does not turn out to have —
2795
+ * more than one parent among the topmost removals, or gaps between their
2796
+ * positions in it — answers the document unchanged rather than guessing.
2797
+ */
2798
+ function groupLanes(doc, ats, label) {
2799
+ if (typeof label !== 'string') return doc;
2800
+ if (!Array.isArray(ats) || ats.length === 0) return doc;
2801
+ const flat = flattenLanes(doc);
2802
+ const sorted = ats.slice().sort(function (a, b) { return a - b; });
2803
+ for (let i = 0; i < sorted.length; i++) {
2804
+ if (!isIndex(sorted[i], flat.length)) return doc;
2805
+ if (i > 0 && sorted[i] !== sorted[i - 1] + 1) return doc; // non-contiguous, or a duplicate
2806
+ }
2807
+ const lanes = sorted.map(function (i) { return flat[i].lane; });
2808
+
2809
+ const removals = laneRemovePaths(doc, sorted);
2810
+ if (removals === null || removals.length === 0) return doc;
2811
+ const parentKey = JSON.stringify(removals[0].slice(0, -1));
2812
+ const positions = [];
2813
+ for (const path of removals) {
2814
+ if (JSON.stringify(path.slice(0, -1)) !== parentKey) return doc; // not one array — refuse
2815
+ positions.push(path[path.length - 1]);
2816
+ }
2817
+ positions.sort(function (a, b) { return a - b; });
2818
+ for (let i = 1; i < positions.length; i++) {
2819
+ if (positions[i] !== positions[i - 1] + 1) return doc; // a gap in that array — refuse
2820
+ }
2821
+ const parent = removals[0].slice(0, -1);
2822
+ const insertIndex = positions[0];
2823
+
2824
+ let next = doc;
2825
+ for (let i = positions.length - 1; i >= 0; i--) {
2826
+ next = withoutPath(next, parent.concat([positions[i]]));
2827
+ }
2828
+ return insertAtPath(next, parent.concat([insertIndex]), [label].concat(lanes));
2829
+ }
2830
+
2831
+ /**
2832
+ * Dissolve the group that lane `at` is a DIRECT member of, keeping its lanes
2833
+ * (and any sub-groups it holds) exactly where that group was and dropping
2834
+ * only the wrapping array and its title.
2835
+ *
2836
+ * "Direct" is load-bearing for a caller addressing a nested group: `at` is a
2837
+ * lane's display position, and a lane has exactly one immediate parent, so
2838
+ * ungrouping always names one group unambiguously. It also means a group made
2839
+ * of nothing but sub-groups — no lane of its own anywhere in it — cannot be
2840
+ * named this way; there is no lane whose immediate parent IS that group. The
2841
+ * UI does not offer the control there for the same reason (see wave-ui.js's
2842
+ * own comment on `directFirstLaneOf`).
2843
+ *
2844
+ * `at` outside the document, or a lane that is not inside any group (`signal`
2845
+ * is not one), answers the document unchanged.
2846
+ */
2847
+ function ungroupLanes(doc, at) {
2848
+ const path = lanePath(doc, at);
2849
+ if (path === null || path.length < 3) return doc;
2850
+ const groupPath = path.slice(0, -1);
2851
+ const found = lookup(doc, groupPath);
2852
+ if (!found.found || !Array.isArray(found.value)) return doc;
2853
+ const group = found.value;
2854
+ const members = group.length > 0 && typeof group[0] === 'string' ? group.slice(1) : group.slice();
2855
+
2856
+ const parentPath = groupPath.slice(0, -1);
2857
+ const parentFound = lookup(doc, parentPath);
2858
+ if (!parentFound.found || !Array.isArray(parentFound.value)) return doc;
2859
+ const idx = groupPath[groupPath.length - 1];
2860
+ const parentArr = parentFound.value;
2861
+ const nextParentArr = parentArr.slice(0, idx).concat(members, parentArr.slice(idx + 1));
2862
+ return setAtPath(doc, parentPath, nextParentArr);
2863
+ }
2864
+
2865
+ // ── v3.5.0 Task 13 fix round 2: doc.head / doc.foot banner fields ─────────
2866
+
2867
+ /**
2868
+ * Write, or clear, one field of `doc.head` / `doc.foot` — the six ruler
2869
+ * fields (`text`/`tick`/`tock`/`every` under each of `head`/`foot`) the wave
2870
+ * editor's meta bar exposes through `commitBanner`.
2871
+ *
2872
+ * Setting a non-empty `value` writes it under `doc[which][key]`, creating
2873
+ * `doc[which]` if it did not exist yet. Clearing — `value === ''` — DELETES
2874
+ * the key rather than leaving an empty string in its place: for
2875
+ * `tick`/`tock`/`every` an empty string is NOT the same thing to the pinned
2876
+ * engine as an absent key. `ticktock` in
2877
+ * `node_modules/wavedrom/lib/render-marks.js` only skips a field that is
2878
+ * `=== undefined`; an empty string instead reaches its array branch,
2879
+ * `Number('')` is `0`, `isNaN(0)` is `false`, and the engine draws a full
2880
+ * default 0-based ruler nobody asked for — measured, not assumed. (`.text`
2881
+ * happens to be safe either way, since `captext`'s own guard is a plain
2882
+ * truthy check — but this function does not special-case it, so the same
2883
+ * correct clear applies uniformly across all six fields rather than one
2884
+ * being correct by accident.)
2885
+ *
2886
+ * If deleting the key leaves `doc[which]` with no keys of its own, `which`
2887
+ * itself is deleted too — a document that has had every one of its fields
2888
+ * cleared is byte-identical to one that never carried `which` at all, not
2889
+ * left holding a stray `{}` (the same "no leftover empty container" rule
2890
+ * `withEdgeList` already keeps for `doc.edge`).
2891
+ *
2892
+ * Returns the SAME `doc` (`===`), like every other op in this file, when
2893
+ * nothing changes: clearing a key that was never set, or writing the value
2894
+ * already there.
2895
+ */
2896
+ function setBannerField(doc, which, key, value) {
2897
+ if (doc === null || typeof doc !== 'object') return doc;
2898
+ if (which !== 'head' && which !== 'foot') return doc;
2899
+ if (typeof key !== 'string' || key === '') return doc;
2900
+
2901
+ const had = doc[which];
2902
+ const hadObj = (had !== null && typeof had === 'object') ? had : null;
2903
+
2904
+ if (value === '') {
2905
+ if (hadObj === null || hadObj[key] === undefined || hadObj[key] === null) return doc;
2906
+ const nextObj = copyObject(hadObj);
2907
+ delete nextObj[key];
2908
+ const next = copyObject(doc);
2909
+ if (Object.keys(nextObj).length === 0) delete next[which];
2910
+ else next[which] = nextObj;
2911
+ return next;
2912
+ }
2913
+
2914
+ if (hadObj !== null && hadObj[key] === value) return doc;
2915
+ const nextObj = copyObject(hadObj || {});
2916
+ nextObj[key] = value;
2917
+ const next = copyObject(doc);
2918
+ next[which] = nextObj;
2919
+ return next;
2920
+ }
2921
+
2922
+ // ── v3.5.0 Task 1: edge 字串 ↔ 物件 ────────────────────────────────────────
2923
+ // 形狀清單與切字串的方式都是從 pin 住的 wavedrom 3.5.0 原始碼量到的
2924
+ // (node_modules/wavedrom/lib/arc-shape.js 的 case 清單、
2925
+ // node_modules/wavedrom/lib/render-arcs.js 的 words[0] 切法),不是憑記憶。
2926
+ /**
2927
+ * 引擎自己認得的 20 種形狀。抄自 pin 住的
2928
+ * node_modules/wavedrom/lib/arc-shape.js 的 case 清單,順序無意義(是集合)。
2929
+ * 不在這份清單裡的形狀我們拒收:畫得出來但引擎畫不出來的線,兩張圖就會不一致。
2930
+ */
2931
+ const EDGE_SHAPES = Object.freeze([
2932
+ '-', '~', '-~', '~-', '-|', '|-', '-|-',
2933
+ '->', '~>', '-~>', '~->', '-|>', '|->', '-|->',
2934
+ '<->', '<~>', '<-~>', '<-|>', '<-|->', '+',
2935
+ ]);
2936
+
2937
+ function isNodeLetter(ch) {
2938
+ return typeof ch === 'string' && ch.length === 1 && /[A-Za-z]/.test(ch);
2939
+ }
2940
+
2941
+ /**
2942
+ * 一條 `edge` entry → `{from, to, shape, label}`,認不得就回 null。
2943
+ *
2944
+ * 切法跟引擎一致(render-arcs.js):第一個以空白分隔的 token 就是
2945
+ * `<from><shape><to>`,其餘是標籤。差別只有一個,而且是刻意的:引擎用**未
2946
+ * trim** 的原字串配 trim 後算出的長度取標籤位移,所以 ' a~>b hello' 在它那邊
2947
+ * 會得到 'b hello'。我們對 trim 過的字串取,於是前導空白的 entry 也讀得對;
2948
+ * 而 formatEdge 從不產生前導空白,所以我們寫出去的東西引擎一定讀得對。
2949
+ */
2950
+ function parseEdge(entry) {
2951
+ if (typeof entry !== 'string') return null;
2952
+ const text = entry.trim();
2953
+ if (text === '') return null;
2954
+ const head = text.split(/\s+/)[0];
2955
+ if (head.length < 3) return null;
2956
+ const from = head.charAt(0);
2957
+ const to = head.charAt(head.length - 1);
2958
+ const shape = head.slice(1, -1);
2959
+ if (!isNodeLetter(from) || !isNodeLetter(to)) return null;
2960
+ if (EDGE_SHAPES.indexOf(shape) === -1) return null;
2961
+ const rest = text.slice(head.length);
2962
+ return { from: from, to: to, shape: shape, label: rest === '' ? '' : rest.slice(1) };
2963
+ }
2964
+
2965
+ /** `{from, to, shape, label}` → 一條 entry,不合法回 null。 */
2966
+ function formatEdge(edge) {
2967
+ if (edge === null || typeof edge !== 'object') return null;
2968
+ if (!isNodeLetter(edge.from) || !isNodeLetter(edge.to)) return null;
2969
+ if (EDGE_SHAPES.indexOf(edge.shape) === -1) return null;
2970
+ const label = typeof edge.label === 'string' ? edge.label.trim() : '';
2971
+ return edge.from + edge.shape + edge.to + (label === '' ? '' : ' ' + label);
2972
+ }
2973
+
2974
+ // ── v3.5.0 Task 2: node 字母 ────────────────────────────────────────────
2975
+ // 釘死的不變量:node 字串的索引是 CELL、不是 cycle——跟 data 陣列的消費規則
2976
+ // 同一套座標系,`.` 與 `|` 都各佔一格。
2977
+
2978
+ /** 一條 lane 的 `node` 字串 → `{letter: cell}`。 */
2979
+ function nodeMapOfLane(lane) {
2980
+ const out = {};
2981
+ const s = (lane !== null && typeof lane === 'object' && typeof lane.node === 'string')
2982
+ ? lane.node : '';
2983
+ for (let i = 0; i < s.length; i += 1) {
2984
+ const ch = s.charAt(i);
2985
+ if (isNodeLetter(ch)) out[ch] = i;
2986
+ }
2987
+ return out;
2988
+ }
2989
+
2990
+ /**
2991
+ * 全文件的字母表:`letter -> {at, cell}`,`at` 是 display position。
2992
+ *
2993
+ * 走的是 `flattenLanes(doc)`——這個檔案裡唯一一份「哪些是 lane、順序為何」的
2994
+ * 真相(`lanePaths` 本身也是靠它算出來的),第二份會跟它漂移。
2995
+ */
2996
+ function nodesOf(doc) {
2997
+ const out = {};
2998
+ const flat = flattenLanes(doc);
2999
+ for (let at = 0; at < flat.length; at += 1) {
3000
+ const map = nodeMapOfLane(flat[at].lane);
3001
+ for (const letter in map) {
3002
+ if (Object.prototype.hasOwnProperty.call(map, letter)) {
3003
+ out[letter] = { at: at, cell: map[letter] };
3004
+ }
3005
+ }
3006
+ }
3007
+ return out;
3008
+ }
3009
+
3010
+ const NODE_LETTER_POOL =
3011
+ 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
3012
+
3013
+ /**
3014
+ * 保證 `(at, cell)` 這一格有一個 anchor 字母。
3015
+ *
3016
+ * 已經有就回既有的那個字母與**原本那個 doc**(`===` 相同),呼叫端才分得出
3017
+ * 「什麼都沒發生」。字母池用完(52 個都被佔)回 `{doc, letter: null}`,跟這個
3018
+ * 檔案其餘的 op 一樣,不丟例外。
3019
+ *
3020
+ * `cell` 的上界是這條 lane 實際的 wave 長度(`readLane(lane).cells.length`)—
3021
+ * 跟 `paint`/`setCell`/`setCellRange` 判斷 `lo`/`hi` 合法性用的是同一個
3022
+ * `isIndex(_, n)`,不算第二個「這條 lane 有幾格」的答案。超界跟其他 op 一樣
3023
+ * 回原 doc:畫得出字母的格子但引擎的 timeline 沒畫到那裡,anchor 會落在畫面
3024
+ * 外面(`node_modules/wavedrom/lib/render-arcs.js` 的 labeler 直接拿字串
3025
+ * index 算 x,不會自己夾住範圍)。
3026
+ */
3027
+ function ensureNode(doc, at, cell) {
3028
+ const flat = flattenLanes(doc);
3029
+ if (!isIndex(at, flat.length)) return { doc: doc, letter: null };
3030
+ const lane = flat[at].lane;
3031
+ const model = readLane(lane);
3032
+ if (!isIndex(cell, model.cells.length)) return { doc: doc, letter: null };
3033
+ const existing = (typeof lane.node === 'string' && cell < lane.node.length)
3034
+ ? lane.node.charAt(cell) : '';
3035
+ if (isNodeLetter(existing)) return { doc: doc, letter: existing };
3036
+
3037
+ const used = nodesOf(doc);
3038
+ let letter = null;
3039
+ for (let i = 0; i < NODE_LETTER_POOL.length; i += 1) {
3040
+ const c = NODE_LETTER_POOL.charAt(i);
3041
+ if (!Object.prototype.hasOwnProperty.call(used, c)) { letter = c; break; }
3042
+ }
3043
+ if (letter === null) return { doc: doc, letter: null };
3044
+
3045
+ const old = typeof lane.node === 'string' ? lane.node : '';
3046
+ const padded = old.length >= cell ? old : old + new Array(cell - old.length + 1).join('.');
3047
+ const next = padded.slice(0, cell) + letter + padded.slice(cell + 1);
3048
+ return {
3049
+ doc: mapLane(doc, at, function (l) {
3050
+ const copy = copyObject(l);
3051
+ copy.node = next;
3052
+ return copy;
3053
+ }),
3054
+ letter: letter,
3055
+ };
3056
+ }
3057
+
3058
+ /** 清掉沒有任何 edge 引用的字母;沒有可清的就回原 doc(`===` 相同)。 */
3059
+ function pruneNodes(doc) {
3060
+ const referenced = {};
3061
+ const list = (doc !== null && typeof doc === 'object' && Array.isArray(doc.edge))
3062
+ ? doc.edge : [];
3063
+ for (let i = 0; i < list.length; i += 1) {
3064
+ const e = parseEdge(list[i]);
3065
+ if (e !== null) { referenced[e.from] = true; referenced[e.to] = true; }
3066
+ }
3067
+
3068
+ const flat = flattenLanes(doc);
3069
+ let next = doc;
3070
+ for (let at = 0; at < flat.length; at += 1) {
3071
+ const lane = flat[at].lane;
3072
+ if (lane === null || typeof lane !== 'object' || typeof lane.node !== 'string') continue;
3073
+ let changed = false;
3074
+ let s = '';
3075
+ for (let i = 0; i < lane.node.length; i += 1) {
3076
+ const ch = lane.node.charAt(i);
3077
+ if (isNodeLetter(ch) && !referenced[ch]) { s += '.'; changed = true; } else { s += ch; }
3078
+ }
3079
+ if (!changed) continue;
3080
+ const trimmed = s.replace(/\.+$/, '');
3081
+ next = mapLane(next, at, function (l) {
3082
+ const copy = copyObject(l);
3083
+ if (trimmed === '') { delete copy.node; } else { copy.node = trimmed; }
3084
+ return copy;
3085
+ });
3086
+ }
3087
+ return next;
3088
+ }
3089
+
3090
+ // ── v3.5.0 Task 3: edge 陣列的三個 op ────────────────────────────────────
3091
+
3092
+ function edgeListOf(doc) {
3093
+ return (doc !== null && typeof doc === 'object' && Array.isArray(doc.edge)) ? doc.edge : [];
3094
+ }
3095
+
3096
+ function withEdgeList(doc, list) {
3097
+ const next = copyObject(doc);
3098
+ if (list.length === 0) { delete next.edge; } else { next.edge = list; }
3099
+ return next;
3100
+ }
3101
+
3102
+ /** 附一條 edge 在陣列尾端;形狀不合法就回原 doc。 */
3103
+ function addEdge(doc, edge) {
3104
+ const entry = formatEdge(edge);
3105
+ if (entry === null) return doc;
3106
+ return withEdgeList(doc, edgeListOf(doc).concat([entry]));
3107
+ }
3108
+
3109
+ /** 換掉第 `index` 條;越界或不合法回原 doc。 */
3110
+ function updateEdge(doc, index, edge) {
3111
+ const list = edgeListOf(doc);
3112
+ if (!Number.isInteger(index) || index < 0 || index >= list.length) return doc;
3113
+ const entry = formatEdge(edge);
3114
+ if (entry === null) return doc;
3115
+ const next = list.slice();
3116
+ next[index] = entry;
3117
+ return withEdgeList(doc, next);
3118
+ }
3119
+
3120
+ /** 刪掉第 `index` 條;越界回原 doc。刪到空陣列時把 `edge` 鍵一起拿掉。 */
3121
+ function removeEdge(doc, index) {
3122
+ const list = edgeListOf(doc);
3123
+ if (!Number.isInteger(index) || index < 0 || index >= list.length) return doc;
3124
+ const next = list.slice();
3125
+ next.splice(index, 1);
3126
+ return withEdgeList(doc, next);
3127
+ }
3128
+
3129
+ // ── v3.5.0 Task 4: moveEdgeEnd ───────────────────────────────────────────
3130
+
3131
+ /**
3132
+ * 把第 `index` 條 edge 的某一端重新指到 `(at, cell)`。
3133
+ *
3134
+ * 兩條路,差別在那個字母**還有沒有別人在用**:
3135
+ *
3136
+ * - 有人共用 → **分岔**:在目標格配一個新字母,只有這一條 entry 改綁。原字母
3137
+ * 留在原處,別條 edge 的 entry 位元組不變。直接搬會把別人的線一起拖走。
3138
+ * - 只有自己用 → **原地搬移**:字母不換,只是換一格。使用者可能已經把那個名字
3139
+ * 寫進標籤或註解裡,換名字是沒必要的破壞。
3140
+ *
3141
+ * 「有人共用」數的是**端點**(`from` 跟 `to` 各算一次),不是「數到幾條別的
3142
+ * edge」——一條 self-loop(`b~>b`)自己的 from 跟 to 就佔掉兩個端點,所以永遠
3143
+ * 落在分岔路,即使沒有任何別條 edge 引用同一個字母。這是故意的:原地搬移會把
3144
+ * self-loop 的兩端一起拖走,那正是這個函式存在的目的要擋下的事。如果哪天有人
3145
+ * 把判斷「簡化」成數「有幾條別的 edge 引用這個字母」,self-loop 會被悄悄改判成
3146
+ * 原地搬移,重新引進同一個 bug——所以數端點,不要數 edge。
3147
+ *
3148
+ * 目標格如果已經被**另一個**字母佔走(不是 movingLetter 自己現在的格子,也不是
3149
+ * 另一端的格子——那兩種是下面兩個 no-op),原地搬移絕不能覆寫它:那會把佔著那
3150
+ * 格的字母原本掛著的別條 edge 無聲拔線。這種情況併進分岔路,重用已經在那裡的
3151
+ * 字母(`ensureNode` 本來就會這麼做),而不是發明第三種行為。
3152
+ *
3153
+ * 三種 no-op 回**原本那個 doc**(`===` 相同),呼叫端據此決定不推 undo:拖回原格、
3154
+ * 拖到自己另一端(會變成 `a~>a`)、目標不存在(lane 越界,或 cell 超過那條 lane
3155
+ * 實際的長度——跟 `ensureNode` 同一顆 `isIndex(_, readLane(lane).cells.length)`
3156
+ * 上界,不是第二個「這條 lane 有幾格」的答案)。
3157
+ */
3158
+ function moveEdgeEnd(doc, index, end, at, cell) {
3159
+ if (end !== 'from' && end !== 'to') return doc;
3160
+ const list = edgeListOf(doc);
3161
+ if (!Number.isInteger(index) || index < 0 || index >= list.length) return doc;
3162
+ const edge = parseEdge(list[index]);
3163
+ if (edge === null) return doc;
3164
+
3165
+ const flat = flattenLanes(doc);
3166
+ if (!isIndex(at, flat.length)) return doc;
3167
+ if (!Number.isInteger(cell) || cell < 0) return doc;
3168
+ // 跟 ensureNode 用同一顆上界(readLane(...).cells.length),不是第二個「這條
3169
+ // lane 有幾格」的答案:畫得出字母的格子但引擎的 timeline 沒畫到那裡,anchor
3170
+ // 會落在畫面外面。原地搬移這條路先前漏了這一段,讓 cell 可以超出 lane 長度。
3171
+ const targetModel = readLane(flat[at].lane);
3172
+ if (!isIndex(cell, targetModel.cells.length)) return doc;
3173
+
3174
+ const nodes = nodesOf(doc);
3175
+ const movingLetter = edge[end];
3176
+ const otherLetter = edge[end === 'from' ? 'to' : 'from'];
3177
+
3178
+ const here = nodes[movingLetter];
3179
+ if (here !== undefined && here.at === at && here.cell === cell) return doc; // 拖回原格
3180
+ const there = nodes[otherLetter];
3181
+ if (there !== undefined && there.at === at && there.cell === cell) return doc; // a~>a
3182
+
3183
+ let sharers = 0;
3184
+ for (let i = 0; i < list.length; i += 1) {
3185
+ const e = parseEdge(list[i]);
3186
+ if (e === null) continue;
3187
+ if (e.from === movingLetter) sharers += 1;
3188
+ if (e.to === movingLetter) sharers += 1;
3189
+ }
3190
+
3191
+ // 目標格是不是已經有別的字母佔著(跟 movingLetter/otherLetter 都不同——那兩種
3192
+ // 情況上面兩個 no-op 已經攔下來了)。原地搬移絕不能覆寫別人的 anchor,佔著就
3193
+ // 併進下面的分岔路,讓 ensureNode 重用那個已經在那裡的字母。
3194
+ let occupant = null;
3195
+ for (const letter in nodes) {
3196
+ if (Object.prototype.hasOwnProperty.call(nodes, letter)) {
3197
+ const pos = nodes[letter];
3198
+ if (pos.at === at && pos.cell === cell) { occupant = letter; break; }
3199
+ }
3200
+ }
3201
+
3202
+ const exclusive = sharers <= 1 && occupant === null;
3203
+
3204
+ if (exclusive && here !== undefined) {
3205
+ // 原地搬移:把字母從舊格拿掉,放到新格。entry 不動。
3206
+ // 這兩次 mapLane 必須依序執行、不能合併成一次:第二次 map 讀到的 `copy.node`
3207
+ // 是「第一次已經把舊格清成 '.' 之後」的 lane 狀態。同一條 lane 內搬移(`here.at
3208
+ // === at`)靠的正是這個先後關係——先清舊格、再往(可能同一個)新格寫——才不會
3209
+ // 用到清格前的 stale node 字串、或把新舊兩個位置同時留著字母。
3210
+ let next = mapLane(doc, here.at, function (l) {
3211
+ const copy = copyObject(l);
3212
+ const s = typeof copy.node === 'string' ? copy.node : '';
3213
+ const cleared = s.slice(0, here.cell) + '.' + s.slice(here.cell + 1);
3214
+ const trimmed = cleared.replace(/\.+$/, '');
3215
+ if (trimmed === '') { delete copy.node; } else { copy.node = trimmed; }
3216
+ return copy;
3217
+ });
3218
+ next = mapLane(next, at, function (l) {
3219
+ const copy = copyObject(l);
3220
+ const s = typeof copy.node === 'string' ? copy.node : '';
3221
+ const padded = s.length >= cell ? s : s + new Array(cell - s.length + 1).join('.');
3222
+ copy.node = padded.slice(0, cell) + movingLetter + padded.slice(cell + 1);
3223
+ return copy;
3224
+ });
3225
+ return next;
3226
+ }
3227
+
3228
+ // 分岔:目標格配一個字母(那一格已經有字母就用它),只有這一條改綁。
3229
+ const ensured = ensureNode(doc, at, cell);
3230
+ if (ensured.letter === null) return doc;
3231
+ const nextEdge = copyObject(edge);
3232
+ nextEdge[end] = ensured.letter;
3233
+ return pruneNodes(updateEdge(ensured.doc, index, nextEdge));
3234
+ }
3235
+
2624
3236
  module.exports = {
2625
3237
  parseSource, patchSource,
2626
3238
  expandWave, collapseWave, levelsOf,
2627
3239
  setCell, setCellRange, insertCycles, deleteCycles, copyCycles, pasteCycles,
2628
- addLane, removeLane, moveLane, renameLane,
3240
+ dataSlotOf, setDataAt,
3241
+ addLane, removeLane, moveLane, duplicateLane, renameLane, groupLanes, ungroupLanes,
2629
3242
  lanePath, lanePaths, laneInsertPath, laneRemovePath, laneRemovePaths,
3243
+ setBannerField,
3244
+ parseEdge, formatEdge, EDGE_SHAPES,
3245
+ nodesOf, ensureNode, pruneNodes,
3246
+ addEdge, updateEdge, removeEdge, moveEdgeEnd,
2630
3247
  MAX_DEPTH,
2631
3248
  };