@helping-ai-workflow/md2doc 3.4.2 → 3.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.
@@ -602,6 +602,10 @@ function resolveEdit(ctx, edit) {
602
602
  const op = edit.op === undefined ? 'set' : edit.op;
603
603
  if (op === 'set') return resolveSet(ctx, edit);
604
604
  if (op === 'insert') return resolveInsert(ctx, edit);
605
+ // v3.6.0 Task 13 fix round 1: several new members on one object, in a
606
+ // single local edit — see `wave-store.js`'s `diffMembers` for why one
607
+ // `insert` per key is not safe once a session can add more than one.
608
+ if (op === 'insert-members') return resolveInsertMembers(ctx, edit);
605
609
  if (op === 'remove') return resolveRemove(ctx, edit);
606
610
  if (op === 'move') return resolveMove(ctx, edit);
607
611
  if (op === 'replace') return resolveReplace(ctx, edit);
@@ -900,6 +904,81 @@ function resolveInsertMember(ctx, edit) {
900
904
  return appendAfterMember(ctx, path, last, body);
901
905
  }
902
906
 
907
+ /**
908
+ * v3.6.0 Task 13 fix round 1 (Critical 1): `resolveInsertMember` above,
909
+ * generalised to SEVERAL new keys on the same object in one edit —
910
+ * `edit = {op: 'insert-members', path: <parent path>, entries: [[key,
911
+ * value], …]}`, produced by `wave-store.js`'s `diffMembers` whenever a
912
+ * session added more than one new field to the same object. See that
913
+ * function's own comment for why one `insert` per key is not safe once
914
+ * there can be more than one: every one of them would anchor at the SAME
915
+ * "object's last existing member" and `patchSource`'s "two edits land at
916
+ * the same place" guard would refuse the batch.
917
+ *
918
+ * This resolves to exactly ONE edit at exactly ONE anchor — the object's
919
+ * last existing member, same as `resolveInsertMember` — with every new
920
+ * key's `key: value` joined into one body. Each entry gets its own
921
+ * duplicate-key and serialization checks, same as `resolveInsertMember`
922
+ * gives its one key, so a batch that only has ONE new key refuses (or
923
+ * succeeds) identically either way; `diffMembers` never actually calls this
924
+ * with fewer than two entries, but nothing here depends on that.
925
+ */
926
+ function resolveInsertMembers(ctx, edit) {
927
+ const path = edit.path;
928
+ const parentSpan = ctx.parsed.spans.get(JSON.stringify(path));
929
+ const parent = lookup(ctx.parsed.doc, path);
930
+ if (!parentSpan || !parent.found || parent.value === null ||
931
+ typeof parent.value !== 'object' || Array.isArray(parent.value)) {
932
+ return refuse('insert-members needs an object at ' + JSON.stringify(path),
933
+ nearestRange(ctx, path));
934
+ }
935
+ if (touchesDuplicateKey(ctx, path)) {
936
+ return refuse(duplicateWhy(path), rangeOf(path, path, parentSpan));
937
+ }
938
+ if (!Array.isArray(edit.entries) || edit.entries.length === 0) {
939
+ return refuse('insert-members needs at least one [key, value] entry',
940
+ rangeOf(path, path, parentSpan));
941
+ }
942
+ const parts = [];
943
+ for (const entry of edit.entries) {
944
+ const key = entry[0];
945
+ const value = entry[1];
946
+ const memberPath = path.concat([key]);
947
+ if (Object.prototype.hasOwnProperty.call(parent.value, key)) {
948
+ return refuse(JSON.stringify(memberPath) + ' already has a member there, so this is a ' +
949
+ 'set rather than an insert', rangeOf(memberPath, path, parentSpan));
950
+ }
951
+ const ser = serialize(value, dominantQuote(ctx), MAX_DEPTH - path.length - 1);
952
+ if (ser.ok !== true) return refuse(ser.why, rangeOf(memberPath, path, parentSpan));
953
+ parts.push(renderKey(key, '"') + ': ' + ser.text);
954
+ }
955
+ const keys = Object.keys(parent.value);
956
+ if (keys.length === 0) {
957
+ const at = parentSpan[0] + 1;
958
+ const close = lineIndentBefore(ctx.text, parentSpan[1] - 1);
959
+ if (close !== null) {
960
+ const eol = lineBreakBefore(ctx.text, parentSpan[1] - 1);
961
+ return { ok: true, path: path, start: at, end: at, text: eol + close + ' ' + parts.join(', ') };
962
+ }
963
+ return { ok: true, path: path, start: at, end: at, text: parts.join(', ') };
964
+ }
965
+ const lastPath = path.concat([keys[keys.length - 1]]);
966
+ const last = ctx.parsed.members.get(JSON.stringify(lastPath));
967
+ if (!last) {
968
+ return refuse('no member span for ' + JSON.stringify(lastPath),
969
+ rangeOf(path, path, parentSpan));
970
+ }
971
+ // The same comma-plus-gap `appendAfterMember` puts between what was
972
+ // already there and this edit's own body is used AGAIN here, between the
973
+ // new members joined into that one body — the indent/line-break come from
974
+ // the same anchor (`last[0]`) either way, so a multi-key insert reads on
975
+ // the page exactly like the same keys inserted one at a time would have,
976
+ // had that been safe to do.
977
+ const indent = lineIndentBefore(ctx.text, last[0]);
978
+ const gap = indent === null ? ' ' : lineBreakBefore(ctx.text, last[0]) + indent;
979
+ return appendAfterMember(ctx, path, last, parts.join(',' + gap));
980
+ }
981
+
903
982
  /**
904
983
  * Move one array element, bytes and all.
905
984
  *
@@ -1786,6 +1865,83 @@ function copyObject(obj) {
1786
1865
  return out;
1787
1866
  }
1788
1867
 
1868
+ /**
1869
+ * Which entry of a lane's `data` list a display cycle draws, or `null` for a
1870
+ * cycle that draws none of its own.
1871
+ *
1872
+ * Only `=` and `2`-`9` written out explicitly consume a slot; `.` (a held
1873
+ * continuation) and `|` (a gap) do not — measured against the pinned wavedrom
1874
+ * 3.5.0, the same rule `readLane`'s slot counter already applies while it
1875
+ * folds labels into cells. This is that same count exposed as a standalone
1876
+ * lookup, so a caller who only has a cycle index — a click, a keyboard
1877
+ * cursor — can ask "which array index is this" before it needs anything else
1878
+ * about the lane. It reads the wave string directly through `expandWave`
1879
+ * rather than through `readLane`, because the question here never touches a
1880
+ * label value, only the shape of the wave.
1881
+ */
1882
+ function dataSlotOf(lane, cell) {
1883
+ if (lane === null || typeof lane !== 'object') return null;
1884
+ const cells = expandWave(typeof lane.wave === 'string' ? lane.wave : '');
1885
+ if (!isIndex(cell, cells.length)) return null;
1886
+ let slot = 0;
1887
+ for (let i = 0; i < cells.length; i++) {
1888
+ const c = cells[i];
1889
+ const consumes = !c.held && isValueChar(c.ch);
1890
+ if (i === cell) return consumes ? slot : null;
1891
+ if (consumes) slot++;
1892
+ }
1893
+ return null;
1894
+ }
1895
+
1896
+ /**
1897
+ * Write one entry of a lane's `data` list by array index, on the lane at
1898
+ * display position `at`. Never addressed by cycle — a caller who has a cycle
1899
+ * gets the index from `dataSlotOf` first, so the counting `data` is an ARRAY
1900
+ * and only some cycles consume a slot from it never has to happen twice.
1901
+ *
1902
+ * A slot that lands on one of the wave's own value cycles goes in through the
1903
+ * same `readLane` / `writeLane` round trip every other label edit already
1904
+ * uses, so text-vs-array spelling and the whitespace `readLane` preserved
1905
+ * come through untouched. A slot past the last consuming cycle — one the wave
1906
+ * itself never reaches, which wavedrom ignores and the picture never shows —
1907
+ * extends `spare` instead, padded with `''` so nothing in between is left a
1908
+ * hole.
1909
+ *
1910
+ * `slot` must be a non-negative integer and `text` a string; anything else,
1911
+ * or an `at` outside the document, answers the same doc unchanged, and so
1912
+ * does a `text` equal to what is already there — `mapLane` catches that on
1913
+ * its own by comparing the lane it gets back.
1914
+ */
1915
+ function setDataAt(doc, at, slot, text) {
1916
+ if (!Number.isInteger(slot) || slot < 0) return doc;
1917
+ if (typeof text !== 'string') return doc;
1918
+ return mapLane(doc, at, function (lane) {
1919
+ const model = readLane(lane);
1920
+ const cells = model.cells;
1921
+ let total = 0;
1922
+ let hit = -1;
1923
+ for (let i = 0; i < cells.length; i++) {
1924
+ const cell = cells[i];
1925
+ if (cell.held || !isValueChar(cell.ch)) continue;
1926
+ if (total === slot) hit = i;
1927
+ total++;
1928
+ }
1929
+ if (hit !== -1) {
1930
+ const next = cells.slice();
1931
+ next[hit] = { ch: cells[hit].ch, held: cells[hit].held, label: text };
1932
+ model.cells = next;
1933
+ } else {
1934
+ const spare = model.spare.slice();
1935
+ const pos = slot - total;
1936
+ while (spare.length <= pos) spare.push('');
1937
+ spare[pos] = text;
1938
+ model.spare = spare;
1939
+ }
1940
+ model.hasLabels = true;
1941
+ return writeLane(lane, model);
1942
+ });
1943
+ }
1944
+
1789
1945
  /**
1790
1946
  * A copy all the way down, for a value arriving from a caller who keeps their
1791
1947
  * own reference to it. A shallow copy leaves the arrays shared, so a caller
@@ -2349,6 +2505,51 @@ function moveLane(doc, from, to) {
2349
2505
  return insertAtPath(reduced, path, lane);
2350
2506
  }
2351
2507
 
2508
+ /**
2509
+ * Copy the lane at display position `at` and put the copy immediately after
2510
+ * it, at display position `at + 1` — a full deep copy, `data` / any other key
2511
+ * included, not a name-and-wave reconstruction that would silently drop the
2512
+ * rest. Built on `addLane`, which already does that deep copy
2513
+ * (`copyValue(lane, MAX_DEPTH)`) for a caller-owned object.
2514
+ *
2515
+ * `node` IS THE ONE EXCEPTION and is stripped before the copy is built —
2516
+ * final review finding 2. `node` is a map of anchor LETTERS to cell indices
2517
+ * (§1's invariant: a letter resolves to exactly one `{lane, cell}`), and it
2518
+ * names the ORIGINAL lane, not the wave shape being copied. Carrying it over
2519
+ * verbatim gives two lanes the same letters; `nodesOf` resolves each letter to
2520
+ * whichever lane it saw last, so every edge anchored on the original lane
2521
+ * silently relocates onto the fresh duplicate, and `pruneNodes` cannot repair
2522
+ * it — it only asks whether a letter is referenced *somewhere*, and after the
2523
+ * duplication it still is. A brand-new lane starts with no edge pointing at
2524
+ * it, which is what "no `node`" means; the user can draw new anchors once
2525
+ * they decide what this copy is for. This was PINNED the other way — a
2526
+ * `test/wave-codec.test.js` assertion whose comment said every field
2527
+ * including `node` was copied, and the duplicate button's tooltip in
2528
+ * `wave-ui.js` promised the same — both were reversed alongside this fix.
2529
+ *
2530
+ * `at + 1` goes through `laneInsertPath` exactly as every other insert does,
2531
+ * so it inherits the one asymmetry that function documents: duplicating a
2532
+ * group's LAST lane lands the copy just outside the group (append to `signal`
2533
+ * itself), while duplicating any other lane in the group — including its
2534
+ * first — keeps the copy inside. That is not a special case written for
2535
+ * duplication; it is the same rule the rail's own insert button already
2536
+ * surfaces to the user, reached through the same door.
2537
+ */
2538
+ function duplicateLane(doc, at) {
2539
+ const flat = flattenLanes(doc);
2540
+ if (!isIndex(at, flat.length)) return doc;
2541
+ const lane = flat[at].lane;
2542
+ let toCopy = lane;
2543
+ if (lane !== null && typeof lane === 'object' && !Array.isArray(lane) &&
2544
+ Object.prototype.hasOwnProperty.call(lane, 'node')) {
2545
+ toCopy = {};
2546
+ for (const key of Object.keys(lane)) {
2547
+ if (key !== 'node') toCopy[key] = lane[key];
2548
+ }
2549
+ }
2550
+ return addLane(doc, at + 1, toCopy);
2551
+ }
2552
+
2352
2553
  /**
2353
2554
  * Where the lane at display position `index` lives, as a path the patching half
2354
2555
  * of this file addresses members by — `['signal', 1, 2]` for the second lane of
@@ -2611,6 +2812,18 @@ function replaceSegment(node, seg, child) {
2611
2812
  return out;
2612
2813
  }
2613
2814
 
2815
+ /**
2816
+ * A new document with the whole member at `path` swapped for `value`, sharing
2817
+ * everything else — the third of this trio alongside `withoutPath` and
2818
+ * `insertAtPath`, for the one shape neither of those two says: "this exact
2819
+ * container becomes a different array", which is what dissolving a group is.
2820
+ * `path` must not be empty; there is no member to replace at the root.
2821
+ */
2822
+ function setAtPath(node, path, value) {
2823
+ if (path.length === 1) return replaceSegment(node, path[0], value);
2824
+ return replaceSegment(node, path[0], setAtPath(node[path[0]], path.slice(1), value));
2825
+ }
2826
+
2614
2827
  /** Rename the lane at display position `at`. */
2615
2828
  function renameLane(doc, at, name) {
2616
2829
  if (typeof name !== 'string') return doc;
@@ -2621,11 +2834,572 @@ function renameLane(doc, at, name) {
2621
2834
  });
2622
2835
  }
2623
2836
 
2837
+ /**
2838
+ * Wrap the CONTIGUOUS run of lanes at display positions `ats` into one new
2839
+ * named group — WaveJSON's only shape for one, an array whose first element
2840
+ * is `label` and whose rest are the lanes it names, nested inside `signal`.
2841
+ * That shape is why the run has to be contiguous: a group is a slice of ONE
2842
+ * array, and lanes that are not next to each other in display order cannot
2843
+ * become elements of the same slice without an author reading the result as
2844
+ * lanes that silently changed place. So a selection that is not contiguous —
2845
+ * `ats` sorted does not read `k, k+1, k+2, …` — is refused, the same doc back,
2846
+ * rather than grouping the SPAN between the gaps and silently carrying along
2847
+ * lanes the caller never selected.
2848
+ *
2849
+ * `ats` may name lanes that live under different existing groups, or none —
2850
+ * the members of the new group are found by `laneRemovePaths`, which already
2851
+ * answers the one question this needs: what has to come OUT of the document
2852
+ * for this set of lanes to leave it, taking an existing group with them when
2853
+ * every one of ITS lanes is in the set (the same rule `removeLane` applies to
2854
+ * a single lane, batched the way multi-select needs). Two things follow from
2855
+ * reusing that answer rather than re-deriving it: the topmost members it
2856
+ * names are always siblings of ONE array — display-contiguous lanes cannot
2857
+ * collapse into removals that live in two different containers without a
2858
+ * non-selected lane or an un-consumed sub-group sitting between them, and
2859
+ * either one would have broken the display-contiguity check above first —
2860
+ * and the new group lands exactly where that array's first survivor used to
2861
+ * be, which is what makes the edit read as "these lanes became a group" and
2862
+ * not as "these lanes moved somewhere else and a group appeared".
2863
+ *
2864
+ * The lane objects themselves are carried over as the SAME objects, the same
2865
+ * promise `moveLane` makes and for the identical reason: `wave-store`'s
2866
+ * `remapOrigins` matches lanes across an edit by identity, and a copy here
2867
+ * would make an ordinary re-grouping look like every one of those lanes was
2868
+ * deleted and a new one added in its place.
2869
+ *
2870
+ * `label` must be a string (an empty one is fine — the caller can rename the
2871
+ * group afterwards, same as a newly added lane starts with an empty name).
2872
+ * Anything else, an `ats` that is not an array, an empty one, an index out of
2873
+ * range, or a defensive shape the removal set does not turn out to have —
2874
+ * more than one parent among the topmost removals, or gaps between their
2875
+ * positions in it — answers the document unchanged rather than guessing.
2876
+ */
2877
+ function groupLanes(doc, ats, label) {
2878
+ if (typeof label !== 'string') return doc;
2879
+ if (!Array.isArray(ats) || ats.length === 0) return doc;
2880
+ const flat = flattenLanes(doc);
2881
+ const sorted = ats.slice().sort(function (a, b) { return a - b; });
2882
+ for (let i = 0; i < sorted.length; i++) {
2883
+ if (!isIndex(sorted[i], flat.length)) return doc;
2884
+ if (i > 0 && sorted[i] !== sorted[i - 1] + 1) return doc; // non-contiguous, or a duplicate
2885
+ }
2886
+ const lanes = sorted.map(function (i) { return flat[i].lane; });
2887
+
2888
+ const removals = laneRemovePaths(doc, sorted);
2889
+ if (removals === null || removals.length === 0) return doc;
2890
+ const parentKey = JSON.stringify(removals[0].slice(0, -1));
2891
+ const positions = [];
2892
+ for (const path of removals) {
2893
+ if (JSON.stringify(path.slice(0, -1)) !== parentKey) return doc; // not one array — refuse
2894
+ positions.push(path[path.length - 1]);
2895
+ }
2896
+ positions.sort(function (a, b) { return a - b; });
2897
+ for (let i = 1; i < positions.length; i++) {
2898
+ if (positions[i] !== positions[i - 1] + 1) return doc; // a gap in that array — refuse
2899
+ }
2900
+ const parent = removals[0].slice(0, -1);
2901
+ const insertIndex = positions[0];
2902
+
2903
+ let next = doc;
2904
+ for (let i = positions.length - 1; i >= 0; i--) {
2905
+ next = withoutPath(next, parent.concat([positions[i]]));
2906
+ }
2907
+ return insertAtPath(next, parent.concat([insertIndex]), [label].concat(lanes));
2908
+ }
2909
+
2910
+ /**
2911
+ * Dissolve the group that lane `at` is a DIRECT member of, keeping its lanes
2912
+ * (and any sub-groups it holds) exactly where that group was and dropping
2913
+ * only the wrapping array and its title.
2914
+ *
2915
+ * "Direct" is load-bearing for a caller addressing a nested group: `at` is a
2916
+ * lane's display position, and a lane has exactly one immediate parent, so
2917
+ * ungrouping always names one group unambiguously. It also means a group made
2918
+ * of nothing but sub-groups — no lane of its own anywhere in it — cannot be
2919
+ * named this way; there is no lane whose immediate parent IS that group. The
2920
+ * UI does not offer the control there for the same reason: v3.6.0 Task 14's
2921
+ * 解散群組 button is enabled only when the SELECTED lane is a direct member
2922
+ * of some group (`laneIsInGroup` in `wave-ui.js`), and a group made only of
2923
+ * sub-groups has no such lane to select.
2924
+ *
2925
+ * `at` outside the document, or a lane that is not inside any group (`signal`
2926
+ * is not one), answers the document unchanged.
2927
+ */
2928
+ function ungroupLanes(doc, at) {
2929
+ const path = lanePath(doc, at);
2930
+ if (path === null || path.length < 3) return doc;
2931
+ const groupPath = path.slice(0, -1);
2932
+ const found = lookup(doc, groupPath);
2933
+ if (!found.found || !Array.isArray(found.value)) return doc;
2934
+ const group = found.value;
2935
+ const members = group.length > 0 && typeof group[0] === 'string' ? group.slice(1) : group.slice();
2936
+
2937
+ const parentPath = groupPath.slice(0, -1);
2938
+ const parentFound = lookup(doc, parentPath);
2939
+ if (!parentFound.found || !Array.isArray(parentFound.value)) return doc;
2940
+ const idx = groupPath[groupPath.length - 1];
2941
+ const parentArr = parentFound.value;
2942
+ const nextParentArr = parentArr.slice(0, idx).concat(members, parentArr.slice(idx + 1));
2943
+ return setAtPath(doc, parentPath, nextParentArr);
2944
+ }
2945
+
2946
+ // ── v3.5.0 Task 13 fix round 2: doc.head / doc.foot banner fields ─────────
2947
+
2948
+ /**
2949
+ * Write, or clear, one field of `doc.head` / `doc.foot` — the six ruler
2950
+ * fields (`text`/`tick`/`tock`/`every` under each of `head`/`foot`) the wave
2951
+ * editor's meta bar exposes through `commitBanner`.
2952
+ *
2953
+ * Setting a non-empty `value` writes it under `doc[which][key]`, creating
2954
+ * `doc[which]` if it did not exist yet. Clearing — `value === ''` — DELETES
2955
+ * the key rather than leaving an empty string in its place: for
2956
+ * `tick`/`tock`/`every` an empty string is NOT the same thing to the pinned
2957
+ * engine as an absent key. `ticktock` in
2958
+ * `node_modules/wavedrom/lib/render-marks.js` only skips a field that is
2959
+ * `=== undefined`; an empty string instead reaches its array branch,
2960
+ * `Number('')` is `0`, `isNaN(0)` is `false`, and the engine draws a full
2961
+ * default 0-based ruler nobody asked for — measured, not assumed. (`.text`
2962
+ * happens to be safe either way, since `captext`'s own guard is a plain
2963
+ * truthy check — but this function does not special-case it, so the same
2964
+ * correct clear applies uniformly across all six fields rather than one
2965
+ * being correct by accident.)
2966
+ *
2967
+ * If deleting the key leaves `doc[which]` with no keys of its own, `which`
2968
+ * itself is deleted too — a document that has had every one of its fields
2969
+ * cleared is byte-identical to one that never carried `which` at all, not
2970
+ * left holding a stray `{}` (the same "no leftover empty container" rule
2971
+ * `withEdgeList` already keeps for `doc.edge`).
2972
+ *
2973
+ * Returns the SAME `doc` (`===`), like every other op in this file, when
2974
+ * nothing changes: clearing a key that was never set, or writing the value
2975
+ * already there.
2976
+ */
2977
+ function setBannerField(doc, which, key, value) {
2978
+ if (doc === null || typeof doc !== 'object') return doc;
2979
+ if (which !== 'head' && which !== 'foot') return doc;
2980
+ if (typeof key !== 'string' || key === '') return doc;
2981
+
2982
+ const had = doc[which];
2983
+ const hadObj = (had !== null && typeof had === 'object') ? had : null;
2984
+
2985
+ if (value === '') {
2986
+ if (hadObj === null || hadObj[key] === undefined || hadObj[key] === null) return doc;
2987
+ const nextObj = copyObject(hadObj);
2988
+ delete nextObj[key];
2989
+ const next = copyObject(doc);
2990
+ if (Object.keys(nextObj).length === 0) delete next[which];
2991
+ else next[which] = nextObj;
2992
+ return next;
2993
+ }
2994
+
2995
+ if (hadObj !== null && hadObj[key] === value) return doc;
2996
+ const nextObj = copyObject(hadObj || {});
2997
+ nextObj[key] = value;
2998
+ const next = copyObject(doc);
2999
+ next[which] = nextObj;
3000
+ return next;
3001
+ }
3002
+
3003
+ // ── v3.6.0 Task 13: doc.config.<key> / a lane's own <key> ─────────────────
3004
+
3005
+ /**
3006
+ * Write, or clear, one field of `doc.config` — today only `hscale`, the
3007
+ * document-wide field the right-hand 文件 panel's ninth input exposes.
3008
+ *
3009
+ * Same clear-deletes-key discipline as `setBannerField` just above, and for
3010
+ * the identical reason: `value === undefined` deletes the key (and `config`
3011
+ * itself, if that empties it) instead of leaving `''` behind. `undefined`
3012
+ * rather than `''` is the delete sentinel here — unlike the six banner
3013
+ * fields, this field is meant to hold a NUMBER
3014
+ * (`wave-geometry.hscaleOf` gates on `typeof cfg.hscale === 'number'`), and
3015
+ * the caller (`wave-panels.js`'s `commitConfigField`) already turns a
3016
+ * well-formed numeral into a real `number` before this ever runs — an empty
3017
+ * string would be a legitimate thing to want to write if `''` meant
3018
+ * "clear", so the sentinel is moved to a value JSON can never actually hold.
3019
+ *
3020
+ * Returns the SAME `doc` (`===`) when nothing changes, like every other op
3021
+ * in this file.
3022
+ */
3023
+ function setConfigField(doc, key, value) {
3024
+ if (doc === null || typeof doc !== 'object') return doc;
3025
+ if (typeof key !== 'string' || key === '') return doc;
3026
+
3027
+ const had = doc.config;
3028
+ const hadObj = (had !== null && typeof had === 'object') ? had : null;
3029
+
3030
+ if (value === undefined) {
3031
+ if (hadObj === null || hadObj[key] === undefined) return doc;
3032
+ const nextObj = copyObject(hadObj);
3033
+ delete nextObj[key];
3034
+ const next = copyObject(doc);
3035
+ if (Object.keys(nextObj).length === 0) delete next.config;
3036
+ else next.config = nextObj;
3037
+ return next;
3038
+ }
3039
+
3040
+ if (hadObj !== null && hadObj[key] === value) return doc;
3041
+ const nextObj = copyObject(hadObj || {});
3042
+ nextObj[key] = value;
3043
+ const next = copyObject(doc);
3044
+ next.config = nextObj;
3045
+ return next;
3046
+ }
3047
+
3048
+ /**
3049
+ * Write, or clear, one field of the lane at display position `at` — today
3050
+ * `period`/`phase`, the two fields the right-hand Lane panel exposes for
3051
+ * whichever lane the cycle selection currently names.
3052
+ *
3053
+ * Built on `mapLane` (the same bridge `renameLane` above uses), so this
3054
+ * inherits its refusal for good measure: an out-of-range `at` hands the same
3055
+ * `doc` back rather than throwing. `value === undefined` deletes the key,
3056
+ * same sentinel and same reason as `setConfigField` just above —
3057
+ * `wave-geometry.layoutOf` also gates `period`/`phase` on `typeof … ===
3058
+ * 'number'`, so the caller commits a real `number` for a well-formed
3059
+ * numeral, and `''` stays available as an ordinary (if odd) string value
3060
+ * rather than being overloaded to mean "clear".
3061
+ */
3062
+ function setLaneField(doc, at, key, value) {
3063
+ if (key !== 'period' && key !== 'phase') return doc;
3064
+ return mapLane(doc, at, function (lane) {
3065
+ const laneObj = isLane(lane) ? lane : {};
3066
+ if (value === undefined) {
3067
+ if (laneObj[key] === undefined) return lane;
3068
+ const next = copyObject(laneObj);
3069
+ delete next[key];
3070
+ return next;
3071
+ }
3072
+ if (laneObj[key] === value) return lane;
3073
+ const next = copyObject(laneObj);
3074
+ next[key] = value;
3075
+ return next;
3076
+ });
3077
+ }
3078
+
3079
+ // ── v3.5.0 Task 1: edge 字串 ↔ 物件 ────────────────────────────────────────
3080
+ // 形狀清單與切字串的方式都是從 pin 住的 wavedrom 3.5.0 原始碼量到的
3081
+ // (node_modules/wavedrom/lib/arc-shape.js 的 case 清單、
3082
+ // node_modules/wavedrom/lib/render-arcs.js 的 words[0] 切法),不是憑記憶。
3083
+ /**
3084
+ * 引擎自己認得的 20 種形狀。抄自 pin 住的
3085
+ * node_modules/wavedrom/lib/arc-shape.js 的 case 清單,順序無意義(是集合)。
3086
+ * 不在這份清單裡的形狀我們拒收:畫得出來但引擎畫不出來的線,兩張圖就會不一致。
3087
+ */
3088
+ const EDGE_SHAPES = Object.freeze([
3089
+ '-', '~', '-~', '~-', '-|', '|-', '-|-',
3090
+ '->', '~>', '-~>', '~->', '-|>', '|->', '-|->',
3091
+ '<->', '<~>', '<-~>', '<-|>', '<-|->', '+',
3092
+ ]);
3093
+
3094
+ function isNodeLetter(ch) {
3095
+ return typeof ch === 'string' && ch.length === 1 && /[A-Za-z]/.test(ch);
3096
+ }
3097
+
3098
+ /**
3099
+ * 一條 `edge` entry → `{from, to, shape, label}`,認不得就回 null。
3100
+ *
3101
+ * 切法跟引擎一致(render-arcs.js):第一個以空白分隔的 token 就是
3102
+ * `<from><shape><to>`,其餘是標籤。差別只有一個,而且是刻意的:引擎用**未
3103
+ * trim** 的原字串配 trim 後算出的長度取標籤位移,所以 ' a~>b hello' 在它那邊
3104
+ * 會得到 'b hello'。我們對 trim 過的字串取,於是前導空白的 entry 也讀得對;
3105
+ * 而 formatEdge 從不產生前導空白,所以我們寫出去的東西引擎一定讀得對。
3106
+ */
3107
+ function parseEdge(entry) {
3108
+ if (typeof entry !== 'string') return null;
3109
+ const text = entry.trim();
3110
+ if (text === '') return null;
3111
+ const head = text.split(/\s+/)[0];
3112
+ if (head.length < 3) return null;
3113
+ const from = head.charAt(0);
3114
+ const to = head.charAt(head.length - 1);
3115
+ const shape = head.slice(1, -1);
3116
+ if (!isNodeLetter(from) || !isNodeLetter(to)) return null;
3117
+ if (EDGE_SHAPES.indexOf(shape) === -1) return null;
3118
+ const rest = text.slice(head.length);
3119
+ return { from: from, to: to, shape: shape, label: rest === '' ? '' : rest.slice(1) };
3120
+ }
3121
+
3122
+ /** `{from, to, shape, label}` → 一條 entry,不合法回 null。 */
3123
+ function formatEdge(edge) {
3124
+ if (edge === null || typeof edge !== 'object') return null;
3125
+ if (!isNodeLetter(edge.from) || !isNodeLetter(edge.to)) return null;
3126
+ if (EDGE_SHAPES.indexOf(edge.shape) === -1) return null;
3127
+ const label = typeof edge.label === 'string' ? edge.label.trim() : '';
3128
+ return edge.from + edge.shape + edge.to + (label === '' ? '' : ' ' + label);
3129
+ }
3130
+
3131
+ // ── v3.5.0 Task 2: node 字母 ────────────────────────────────────────────
3132
+ // 釘死的不變量:node 字串的索引是 CELL、不是 cycle——跟 data 陣列的消費規則
3133
+ // 同一套座標系,`.` 與 `|` 都各佔一格。
3134
+
3135
+ /** 一條 lane 的 `node` 字串 → `{letter: cell}`。 */
3136
+ function nodeMapOfLane(lane) {
3137
+ const out = {};
3138
+ const s = (lane !== null && typeof lane === 'object' && typeof lane.node === 'string')
3139
+ ? lane.node : '';
3140
+ for (let i = 0; i < s.length; i += 1) {
3141
+ const ch = s.charAt(i);
3142
+ if (isNodeLetter(ch)) out[ch] = i;
3143
+ }
3144
+ return out;
3145
+ }
3146
+
3147
+ /**
3148
+ * 全文件的字母表:`letter -> {at, cell}`,`at` 是 display position。
3149
+ *
3150
+ * 走的是 `flattenLanes(doc)`——這個檔案裡唯一一份「哪些是 lane、順序為何」的
3151
+ * 真相(`lanePaths` 本身也是靠它算出來的),第二份會跟它漂移。
3152
+ */
3153
+ function nodesOf(doc) {
3154
+ const out = {};
3155
+ const flat = flattenLanes(doc);
3156
+ for (let at = 0; at < flat.length; at += 1) {
3157
+ const map = nodeMapOfLane(flat[at].lane);
3158
+ for (const letter in map) {
3159
+ if (Object.prototype.hasOwnProperty.call(map, letter)) {
3160
+ out[letter] = { at: at, cell: map[letter] };
3161
+ }
3162
+ }
3163
+ }
3164
+ return out;
3165
+ }
3166
+
3167
+ const NODE_LETTER_POOL =
3168
+ 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
3169
+
3170
+ /**
3171
+ * 保證 `(at, cell)` 這一格有一個 anchor 字母。
3172
+ *
3173
+ * 已經有就回既有的那個字母與**原本那個 doc**(`===` 相同),呼叫端才分得出
3174
+ * 「什麼都沒發生」。字母池用完(52 個都被佔)回 `{doc, letter: null}`,跟這個
3175
+ * 檔案其餘的 op 一樣,不丟例外。
3176
+ *
3177
+ * `cell` 的上界是這條 lane 實際的 wave 長度(`readLane(lane).cells.length`)—
3178
+ * 跟 `paint`/`setCell`/`setCellRange` 判斷 `lo`/`hi` 合法性用的是同一個
3179
+ * `isIndex(_, n)`,不算第二個「這條 lane 有幾格」的答案。超界跟其他 op 一樣
3180
+ * 回原 doc:畫得出字母的格子但引擎的 timeline 沒畫到那裡,anchor 會落在畫面
3181
+ * 外面(`node_modules/wavedrom/lib/render-arcs.js` 的 labeler 直接拿字串
3182
+ * index 算 x,不會自己夾住範圍)。
3183
+ */
3184
+ function ensureNode(doc, at, cell) {
3185
+ const flat = flattenLanes(doc);
3186
+ if (!isIndex(at, flat.length)) return { doc: doc, letter: null };
3187
+ const lane = flat[at].lane;
3188
+ const model = readLane(lane);
3189
+ if (!isIndex(cell, model.cells.length)) return { doc: doc, letter: null };
3190
+ const existing = (typeof lane.node === 'string' && cell < lane.node.length)
3191
+ ? lane.node.charAt(cell) : '';
3192
+ if (isNodeLetter(existing)) return { doc: doc, letter: existing };
3193
+
3194
+ const used = nodesOf(doc);
3195
+ let letter = null;
3196
+ for (let i = 0; i < NODE_LETTER_POOL.length; i += 1) {
3197
+ const c = NODE_LETTER_POOL.charAt(i);
3198
+ if (!Object.prototype.hasOwnProperty.call(used, c)) { letter = c; break; }
3199
+ }
3200
+ if (letter === null) return { doc: doc, letter: null };
3201
+
3202
+ const old = typeof lane.node === 'string' ? lane.node : '';
3203
+ const padded = old.length >= cell ? old : old + new Array(cell - old.length + 1).join('.');
3204
+ const next = padded.slice(0, cell) + letter + padded.slice(cell + 1);
3205
+ return {
3206
+ doc: mapLane(doc, at, function (l) {
3207
+ const copy = copyObject(l);
3208
+ copy.node = next;
3209
+ return copy;
3210
+ }),
3211
+ letter: letter,
3212
+ };
3213
+ }
3214
+
3215
+ /** 清掉沒有任何 edge 引用的字母;沒有可清的就回原 doc(`===` 相同)。 */
3216
+ function pruneNodes(doc) {
3217
+ const referenced = {};
3218
+ const list = (doc !== null && typeof doc === 'object' && Array.isArray(doc.edge))
3219
+ ? doc.edge : [];
3220
+ for (let i = 0; i < list.length; i += 1) {
3221
+ const e = parseEdge(list[i]);
3222
+ if (e !== null) { referenced[e.from] = true; referenced[e.to] = true; }
3223
+ }
3224
+
3225
+ const flat = flattenLanes(doc);
3226
+ let next = doc;
3227
+ for (let at = 0; at < flat.length; at += 1) {
3228
+ const lane = flat[at].lane;
3229
+ if (lane === null || typeof lane !== 'object' || typeof lane.node !== 'string') continue;
3230
+ let changed = false;
3231
+ let s = '';
3232
+ for (let i = 0; i < lane.node.length; i += 1) {
3233
+ const ch = lane.node.charAt(i);
3234
+ if (isNodeLetter(ch) && !referenced[ch]) { s += '.'; changed = true; } else { s += ch; }
3235
+ }
3236
+ if (!changed) continue;
3237
+ const trimmed = s.replace(/\.+$/, '');
3238
+ next = mapLane(next, at, function (l) {
3239
+ const copy = copyObject(l);
3240
+ if (trimmed === '') { delete copy.node; } else { copy.node = trimmed; }
3241
+ return copy;
3242
+ });
3243
+ }
3244
+ return next;
3245
+ }
3246
+
3247
+ // ── v3.5.0 Task 3: edge 陣列的三個 op ────────────────────────────────────
3248
+
3249
+ function edgeListOf(doc) {
3250
+ return (doc !== null && typeof doc === 'object' && Array.isArray(doc.edge)) ? doc.edge : [];
3251
+ }
3252
+
3253
+ function withEdgeList(doc, list) {
3254
+ const next = copyObject(doc);
3255
+ if (list.length === 0) { delete next.edge; } else { next.edge = list; }
3256
+ return next;
3257
+ }
3258
+
3259
+ /** 附一條 edge 在陣列尾端;形狀不合法就回原 doc。 */
3260
+ function addEdge(doc, edge) {
3261
+ const entry = formatEdge(edge);
3262
+ if (entry === null) return doc;
3263
+ return withEdgeList(doc, edgeListOf(doc).concat([entry]));
3264
+ }
3265
+
3266
+ /** 換掉第 `index` 條;越界或不合法回原 doc。 */
3267
+ function updateEdge(doc, index, edge) {
3268
+ const list = edgeListOf(doc);
3269
+ if (!Number.isInteger(index) || index < 0 || index >= list.length) return doc;
3270
+ const entry = formatEdge(edge);
3271
+ if (entry === null) return doc;
3272
+ const next = list.slice();
3273
+ next[index] = entry;
3274
+ return withEdgeList(doc, next);
3275
+ }
3276
+
3277
+ /** 刪掉第 `index` 條;越界回原 doc。刪到空陣列時把 `edge` 鍵一起拿掉。 */
3278
+ function removeEdge(doc, index) {
3279
+ const list = edgeListOf(doc);
3280
+ if (!Number.isInteger(index) || index < 0 || index >= list.length) return doc;
3281
+ const next = list.slice();
3282
+ next.splice(index, 1);
3283
+ return withEdgeList(doc, next);
3284
+ }
3285
+
3286
+ // ── v3.5.0 Task 4: moveEdgeEnd ───────────────────────────────────────────
3287
+
3288
+ /**
3289
+ * 把第 `index` 條 edge 的某一端重新指到 `(at, cell)`。
3290
+ *
3291
+ * 兩條路,差別在那個字母**還有沒有別人在用**:
3292
+ *
3293
+ * - 有人共用 → **分岔**:在目標格配一個新字母,只有這一條 entry 改綁。原字母
3294
+ * 留在原處,別條 edge 的 entry 位元組不變。直接搬會把別人的線一起拖走。
3295
+ * - 只有自己用 → **原地搬移**:字母不換,只是換一格。使用者可能已經把那個名字
3296
+ * 寫進標籤或註解裡,換名字是沒必要的破壞。
3297
+ *
3298
+ * 「有人共用」數的是**端點**(`from` 跟 `to` 各算一次),不是「數到幾條別的
3299
+ * edge」——一條 self-loop(`b~>b`)自己的 from 跟 to 就佔掉兩個端點,所以永遠
3300
+ * 落在分岔路,即使沒有任何別條 edge 引用同一個字母。這是故意的:原地搬移會把
3301
+ * self-loop 的兩端一起拖走,那正是這個函式存在的目的要擋下的事。如果哪天有人
3302
+ * 把判斷「簡化」成數「有幾條別的 edge 引用這個字母」,self-loop 會被悄悄改判成
3303
+ * 原地搬移,重新引進同一個 bug——所以數端點,不要數 edge。
3304
+ *
3305
+ * 目標格如果已經被**另一個**字母佔走(不是 movingLetter 自己現在的格子,也不是
3306
+ * 另一端的格子——那兩種是下面兩個 no-op),原地搬移絕不能覆寫它:那會把佔著那
3307
+ * 格的字母原本掛著的別條 edge 無聲拔線。這種情況併進分岔路,重用已經在那裡的
3308
+ * 字母(`ensureNode` 本來就會這麼做),而不是發明第三種行為。
3309
+ *
3310
+ * 三種 no-op 回**原本那個 doc**(`===` 相同),呼叫端據此決定不推 undo:拖回原格、
3311
+ * 拖到自己另一端(會變成 `a~>a`)、目標不存在(lane 越界,或 cell 超過那條 lane
3312
+ * 實際的長度——跟 `ensureNode` 同一顆 `isIndex(_, readLane(lane).cells.length)`
3313
+ * 上界,不是第二個「這條 lane 有幾格」的答案)。
3314
+ */
3315
+ function moveEdgeEnd(doc, index, end, at, cell) {
3316
+ if (end !== 'from' && end !== 'to') return doc;
3317
+ const list = edgeListOf(doc);
3318
+ if (!Number.isInteger(index) || index < 0 || index >= list.length) return doc;
3319
+ const edge = parseEdge(list[index]);
3320
+ if (edge === null) return doc;
3321
+
3322
+ const flat = flattenLanes(doc);
3323
+ if (!isIndex(at, flat.length)) return doc;
3324
+ if (!Number.isInteger(cell) || cell < 0) return doc;
3325
+ // 跟 ensureNode 用同一顆上界(readLane(...).cells.length),不是第二個「這條
3326
+ // lane 有幾格」的答案:畫得出字母的格子但引擎的 timeline 沒畫到那裡,anchor
3327
+ // 會落在畫面外面。原地搬移這條路先前漏了這一段,讓 cell 可以超出 lane 長度。
3328
+ const targetModel = readLane(flat[at].lane);
3329
+ if (!isIndex(cell, targetModel.cells.length)) return doc;
3330
+
3331
+ const nodes = nodesOf(doc);
3332
+ const movingLetter = edge[end];
3333
+ const otherLetter = edge[end === 'from' ? 'to' : 'from'];
3334
+
3335
+ const here = nodes[movingLetter];
3336
+ if (here !== undefined && here.at === at && here.cell === cell) return doc; // 拖回原格
3337
+ const there = nodes[otherLetter];
3338
+ if (there !== undefined && there.at === at && there.cell === cell) return doc; // a~>a
3339
+
3340
+ let sharers = 0;
3341
+ for (let i = 0; i < list.length; i += 1) {
3342
+ const e = parseEdge(list[i]);
3343
+ if (e === null) continue;
3344
+ if (e.from === movingLetter) sharers += 1;
3345
+ if (e.to === movingLetter) sharers += 1;
3346
+ }
3347
+
3348
+ // 目標格是不是已經有別的字母佔著(跟 movingLetter/otherLetter 都不同——那兩種
3349
+ // 情況上面兩個 no-op 已經攔下來了)。原地搬移絕不能覆寫別人的 anchor,佔著就
3350
+ // 併進下面的分岔路,讓 ensureNode 重用那個已經在那裡的字母。
3351
+ let occupant = null;
3352
+ for (const letter in nodes) {
3353
+ if (Object.prototype.hasOwnProperty.call(nodes, letter)) {
3354
+ const pos = nodes[letter];
3355
+ if (pos.at === at && pos.cell === cell) { occupant = letter; break; }
3356
+ }
3357
+ }
3358
+
3359
+ const exclusive = sharers <= 1 && occupant === null;
3360
+
3361
+ if (exclusive && here !== undefined) {
3362
+ // 原地搬移:把字母從舊格拿掉,放到新格。entry 不動。
3363
+ // 這兩次 mapLane 必須依序執行、不能合併成一次:第二次 map 讀到的 `copy.node`
3364
+ // 是「第一次已經把舊格清成 '.' 之後」的 lane 狀態。同一條 lane 內搬移(`here.at
3365
+ // === at`)靠的正是這個先後關係——先清舊格、再往(可能同一個)新格寫——才不會
3366
+ // 用到清格前的 stale node 字串、或把新舊兩個位置同時留著字母。
3367
+ let next = mapLane(doc, here.at, function (l) {
3368
+ const copy = copyObject(l);
3369
+ const s = typeof copy.node === 'string' ? copy.node : '';
3370
+ const cleared = s.slice(0, here.cell) + '.' + s.slice(here.cell + 1);
3371
+ const trimmed = cleared.replace(/\.+$/, '');
3372
+ if (trimmed === '') { delete copy.node; } else { copy.node = trimmed; }
3373
+ return copy;
3374
+ });
3375
+ next = mapLane(next, at, function (l) {
3376
+ const copy = copyObject(l);
3377
+ const s = typeof copy.node === 'string' ? copy.node : '';
3378
+ const padded = s.length >= cell ? s : s + new Array(cell - s.length + 1).join('.');
3379
+ copy.node = padded.slice(0, cell) + movingLetter + padded.slice(cell + 1);
3380
+ return copy;
3381
+ });
3382
+ return next;
3383
+ }
3384
+
3385
+ // 分岔:目標格配一個字母(那一格已經有字母就用它),只有這一條改綁。
3386
+ const ensured = ensureNode(doc, at, cell);
3387
+ if (ensured.letter === null) return doc;
3388
+ const nextEdge = copyObject(edge);
3389
+ nextEdge[end] = ensured.letter;
3390
+ return pruneNodes(updateEdge(ensured.doc, index, nextEdge));
3391
+ }
3392
+
2624
3393
  module.exports = {
2625
3394
  parseSource, patchSource,
2626
3395
  expandWave, collapseWave, levelsOf,
2627
3396
  setCell, setCellRange, insertCycles, deleteCycles, copyCycles, pasteCycles,
2628
- addLane, removeLane, moveLane, renameLane,
3397
+ dataSlotOf, setDataAt,
3398
+ addLane, removeLane, moveLane, duplicateLane, renameLane, groupLanes, ungroupLanes,
2629
3399
  lanePath, lanePaths, laneInsertPath, laneRemovePath, laneRemovePaths,
3400
+ setBannerField, setConfigField, setLaneField,
3401
+ parseEdge, formatEdge, EDGE_SHAPES,
3402
+ nodesOf, ensureNode, pruneNodes,
3403
+ addEdge, updateEdge, removeEdge, moveEdgeEnd,
2630
3404
  MAX_DEPTH,
2631
3405
  };