@helping-ai-workflow/md2doc 3.12.1 → 3.13.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.
@@ -452,7 +452,7 @@ function parseWord(st) {
452
452
  * characters, so a write-back that re-serialised or "normalised" a wave string
453
453
  * would change the picture the user is looking at.
454
454
  *
455
- * Three kinds of edit:
455
+ * The kinds of edit:
456
456
  *
457
457
  * {path, value} or {op:'set', path, value}
458
458
  * Replace the value at `path`. Refused when nothing is parsed at that
@@ -482,7 +482,33 @@ function parseWord(st) {
482
482
  * so the comment the author wrote beside it travels with it, which a
483
483
  * remove plus an insert cannot do because an insert renders a value and a
484
484
  * value carries no comment. An array the element leaves with no lane in it
485
- * goes too, the same rule `removeLane` follows.
485
+ * goes too, the same rule `removeLane` follows. The element may be a
486
+ * whole group: its title and members are one member's bytes and travel
487
+ * together, which is how a group's 上移 / 下移 is written.
488
+ *
489
+ * {op:'wrap', path, count, title}
490
+ * Make the `count` elements of one array starting at `path` the members
491
+ * of a new group in their place — what `groupLanes` does to a run of
492
+ * sibling lanes. Only the group's own syntax is written: `[`, the title
493
+ * (a string, or absent for an untitled group) and `]`. Every member keeps
494
+ * its bytes, its indentation and the comments beside and above it. The
495
+ * title is spelled the way an inserted string is (`dominantQuote`) and
496
+ * laid out like its neighbours: when the first member starts its own
497
+ * line, the `[` and title get a line of their own at that member's
498
+ * indentation, above the comments that belong to it; on a shared line
499
+ * they go inline in front of it, spaced like the array's other elements
500
+ * (`inlineGap`). The `]` goes straight after the last
501
+ * member's value, so the comma that separated that member from what
502
+ * follows now separates the group. Only lanes and groups can be wrapped.
503
+ *
504
+ * {op:'unwrap', path}
505
+ * The inverse: the group at `path` goes and its members take its place —
506
+ * `ungroupLanes`. Removed are the `[`, the title member and the `]`
507
+ * (plus a separating comma the members no longer need, or one added after
508
+ * the last member when the group had one and it did not), and a line
509
+ * those leave holding nothing at all. Members, and every comment
510
+ * anywhere — including one written beside the title — stay byte for
511
+ * byte. `unwrap` of a `wrap` reads back as the original text.
486
512
  *
487
513
  * {op:'replace', path, value}
488
514
  * Replace the value at `path` with a fresh serialization, keeping the key,
@@ -609,6 +635,8 @@ function resolveEdit(ctx, edit) {
609
635
  if (op === 'remove') return resolveRemove(ctx, edit);
610
636
  if (op === 'move') return resolveMove(ctx, edit);
611
637
  if (op === 'replace') return resolveReplace(ctx, edit);
638
+ if (op === 'wrap') return resolveWrap(ctx, edit);
639
+ if (op === 'unwrap') return resolveUnwrap(ctx, edit);
612
640
  return refuse('unknown edit op ' + JSON.stringify(edit.op), nearestRange(ctx, edit.path));
613
641
  }
614
642
 
@@ -1125,6 +1153,286 @@ function resolveRemove(ctx, edit) {
1125
1153
  return { ok: true, path: path, start: range[0], end: range[1], text: '' };
1126
1154
  }
1127
1155
 
1156
+ /**
1157
+ * Wrap a run of array elements into a new group without touching their bytes.
1158
+ *
1159
+ * Writing the new group as a value would re-serialise every lane in it and drop
1160
+ * the comments the author put beside them; a group is only an array around its
1161
+ * members, so the edit is two insertions — the `[` and title in front of the
1162
+ * run, the `]` behind it — and nothing between them moves. The layout rule for
1163
+ * the title is stated on `patchSource`.
1164
+ */
1165
+ function resolveWrap(ctx, edit) {
1166
+ const path = edit.path;
1167
+ if (path.length === 0 || typeof path[path.length - 1] !== 'number') {
1168
+ return refuse('wrap addresses the first of a run of array elements, so its path must end ' +
1169
+ 'in a numeric index', nearestRange(ctx, path));
1170
+ }
1171
+ const parentPath = path.slice(0, -1);
1172
+ const parentSpan = ctx.parsed.spans.get(JSON.stringify(parentPath));
1173
+ const parent = lookup(ctx.parsed.doc, parentPath);
1174
+ if (!parentSpan || !parent.found || !Array.isArray(parent.value)) {
1175
+ return refuse('wrap needs an array at ' + JSON.stringify(parentPath), nearestRange(ctx, parentPath));
1176
+ }
1177
+ const first = path[path.length - 1];
1178
+ const count = edit.count;
1179
+ if (!Number.isInteger(first) || !Number.isInteger(count) || first < 0 || count < 1 ||
1180
+ first + count > parent.value.length) {
1181
+ return refuse('wrap needs a run of one or more elements inside the array at ' +
1182
+ JSON.stringify(parentPath) + ', which holds ' + parent.value.length,
1183
+ rangeOf(path, parentPath, parentSpan));
1184
+ }
1185
+ if (edit.title !== undefined && typeof edit.title !== 'string') {
1186
+ return refuse('a group title is a string', rangeOf(path, parentPath, parentSpan));
1187
+ }
1188
+ for (let i = first; i < first + count; i++) {
1189
+ const item = parent.value[i];
1190
+ if (!isLane(item) && !Array.isArray(item)) {
1191
+ return refuse('only lanes and groups can be wrapped into a group, and ' +
1192
+ JSON.stringify(parentPath.concat([i])) + ' is neither (a group title is a string)',
1193
+ rangeOf(path, parentPath, parentSpan));
1194
+ }
1195
+ }
1196
+ if (touchesDuplicateKey(ctx, parentPath)) {
1197
+ return refuse(duplicateWhy(path), rangeOf(path, parentPath, parentSpan));
1198
+ }
1199
+ if (deepestContainerUnder(ctx, parentPath, first, first + count) >= MAX_DEPTH - 1) {
1200
+ return refuse('wrapping these elements would nest something under them past the reader\'s ' +
1201
+ 'limit of ' + MAX_DEPTH + ' levels', rangeOf(path, parentPath, parentSpan));
1202
+ }
1203
+ const firstKey = JSON.stringify(path);
1204
+ const lastPath = parentPath.concat([first + count - 1]);
1205
+ const firstMember = ctx.parsed.members.get(firstKey);
1206
+ const lastValue = ctx.parsed.spans.get(JSON.stringify(lastPath));
1207
+ if (!firstMember || !lastValue) {
1208
+ return refuse('no member span for the run starting at ' + firstKey,
1209
+ rangeOf(path, parentPath, parentSpan));
1210
+ }
1211
+ const lead = ctx.parsed.leads.get(firstKey);
1212
+ // Above the comments that belong to the first member, so they stay its own.
1213
+ const at = commentRunStart(ctx.text, lead === undefined ? firstMember[0] : lead, firstMember[0]);
1214
+ let open = '[';
1215
+ if (typeof edit.title === 'string') {
1216
+ const ser = serialize(edit.title, dominantQuote(ctx), MAX_DEPTH - path.length);
1217
+ if (ser.ok !== true) return refuse(ser.why, rangeOf(path, parentPath, parentSpan));
1218
+ const indent = lineIndentBefore(ctx.text, at);
1219
+ open += ser.text + ',' +
1220
+ (indent === null ? inlineGap(ctx, parentPath, first) : lineBreakBefore(ctx.text, at) + indent);
1221
+ }
1222
+ return {
1223
+ ok: true,
1224
+ path: path,
1225
+ parts: [
1226
+ { ok: true, path: path, start: at, end: at, text: open },
1227
+ { ok: true, path: lastPath, start: lastValue[1], end: lastValue[1], text: ']' },
1228
+ ],
1229
+ };
1230
+ }
1231
+
1232
+ /**
1233
+ * The spaces this array writes after a comma when its elements share a line —
1234
+ * read off the element at `index`, or the second one when `index` is the first
1235
+ * — so a title put in front of an element on a shared line is spaced like its
1236
+ * neighbours: `["", {…}` in a source written `{…}, {…}`, `["",{…}` in compact
1237
+ * JSON. One space when the array has no such gap to read.
1238
+ */
1239
+ function inlineGap(ctx, parentPath, index) {
1240
+ const k = JSON.stringify(parentPath.concat([index > 0 ? index : 1]));
1241
+ const lead = ctx.parsed.leads.get(k);
1242
+ const member = ctx.parsed.members.get(k);
1243
+ if (lead === undefined || !member) return ' ';
1244
+ const gap = ctx.text.slice(lead, member[0]);
1245
+ return /^[ \t]*$/.test(gap) ? gap : ' ';
1246
+ }
1247
+
1248
+ /**
1249
+ * The longest path of any container at or under elements `[from, to)` of the
1250
+ * array at `parentPath` — how deep a wrap around them would push the deepest
1251
+ * thing the reader has to open, measured on the parse rather than recomputed.
1252
+ */
1253
+ function deepestContainerUnder(ctx, parentPath, from, to) {
1254
+ let deepest = 0;
1255
+ const heads = [];
1256
+ for (let i = from; i < to; i++) heads.push(JSON.stringify(parentPath.concat([i])));
1257
+ for (const [key, span] of ctx.parsed.spans) {
1258
+ const c = ctx.text[span[0]];
1259
+ if (c !== '{' && c !== '[') continue;
1260
+ for (const head of heads) {
1261
+ if (key === head || key.startsWith(head.slice(0, -1) + ',')) {
1262
+ const depth = JSON.parse(key).length;
1263
+ if (depth > deepest) deepest = depth;
1264
+ break;
1265
+ }
1266
+ }
1267
+ }
1268
+ return deepest;
1269
+ }
1270
+
1271
+ /**
1272
+ * Dissolve the group at `path` into its parent, members' bytes untouched.
1273
+ *
1274
+ * Two replacements: the opening loses `[`, the title's value and its comma, and
1275
+ * the closing stretch from the last member's value to the end of the group's
1276
+ * own member is rewritten without its `]` — with the separating comma moved to
1277
+ * where the members now need it. A line either of them leaves holding only
1278
+ * whitespace goes with it, so a group written one member per line comes apart
1279
+ * into those same lines. Comments are never deleted here: one that sat beside
1280
+ * the title stays where it was, on its own line.
1281
+ */
1282
+ function resolveUnwrap(ctx, edit) {
1283
+ const path = edit.path;
1284
+ if (path.length === 0 || typeof path[path.length - 1] !== 'number') {
1285
+ return refuse('unwrap addresses a group inside an array, so its path must end in a numeric index',
1286
+ nearestRange(ctx, path));
1287
+ }
1288
+ const key = JSON.stringify(path);
1289
+ const member = ctx.parsed.members.get(key);
1290
+ const span = ctx.parsed.spans.get(key);
1291
+ const found = lookup(ctx.parsed.doc, path);
1292
+ if (!member || !span || !found.found) {
1293
+ return refuse('nothing is parsed at ' + key + ', so there is no group to unwrap',
1294
+ nearestRange(ctx, path));
1295
+ }
1296
+ if (!Array.isArray(found.value)) {
1297
+ return refuse('the value at ' + key + ' is not a group, so there is nothing to unwrap',
1298
+ rangeOf(path, path, span));
1299
+ }
1300
+ if (touchesDuplicateKey(ctx, path)) return refuse(duplicateWhy(path), rangeOf(path, path, span));
1301
+ const group = found.value;
1302
+ const titled = group.length > 0 && typeof group[0] === 'string';
1303
+ if (group.length <= (titled ? 1 : 0)) {
1304
+ return refuse('the group at ' + key + ' has no members, so unwrapping it would only delete ' +
1305
+ 'it; remove it instead', rangeOf(path, path, span));
1306
+ }
1307
+ const text = ctx.text;
1308
+
1309
+ // The opening: exactly three tokens go — `[`, the title's value and the comma
1310
+ // after it. Whatever sits between them (a comment after `[`, beside the
1311
+ // title, before its comma) stays; `unwrapOpening` decides the whitespace.
1312
+ const deleted = [[span[0], span[0] + 1]];
1313
+ let titleEnd = span[0] + 1;
1314
+ if (titled) {
1315
+ const titlePath = JSON.stringify(path.concat([0]));
1316
+ const title = ctx.parsed.members.get(titlePath);
1317
+ const titleValue = ctx.parsed.spans.get(titlePath);
1318
+ if (!title || !titleValue || text[title[1] - 1] !== ',') {
1319
+ return refuse('no member span for the title of ' + key, rangeOf(path, path, span));
1320
+ }
1321
+ deleted.push(titleValue, [title[1] - 1, title[1]]);
1322
+ titleEnd = title[1];
1323
+ }
1324
+ const opening = unwrapOpening(text, span[0], titleEnd, deleted);
1325
+
1326
+ // The closing: from the last member's value to the end of the group's member.
1327
+ const lastPath = path.concat([group.length - 1]);
1328
+ const lastValue = ctx.parsed.spans.get(JSON.stringify(lastPath));
1329
+ const lastMember = ctx.parsed.members.get(JSON.stringify(lastPath));
1330
+ if (!lastValue || !lastMember) {
1331
+ return refuse('no member span for the last member of ' + key, rangeOf(path, path, span));
1332
+ }
1333
+ const close = span[1] - 1; // the group's own `]`
1334
+ const groupHasComma = member[1] > span[1]; // its member runs on past `]` to a comma
1335
+ const lastHasComma = lastMember[1] > lastValue[1]; // likewise for the last member
1336
+ // What sits between the last member's value and `]` (its comma, its comment,
1337
+ // the break before the bracket), and between `]` and the group's comma.
1338
+ let before = text.slice(lastValue[1], close);
1339
+ let after = text.slice(span[1], groupHasComma ? member[1] - 1 : member[1]);
1340
+ let closeEnd = member[1];
1341
+ // The group's comma separated it from what follows; that is now the last
1342
+ // member's job, so it moves to the member when the member had none.
1343
+ if (groupHasComma && !lastHasComma) before = ',' + before;
1344
+ let restEnd = closeEnd;
1345
+ while (text[restEnd] === ' ' || text[restEnd] === '\t') restEnd++;
1346
+ const lineBreak = /(?:\r\n|\r|\n)[ \t]*$/.exec(before);
1347
+ if (lineBreak !== null && /^[ \t]*$/.test(after) &&
1348
+ (restEnd >= text.length || text[restEnd] === '\n' || text[restEnd] === '\r')) {
1349
+ // The `]` line held nothing else: it goes, and the members' last line ends
1350
+ // where the group's line used to.
1351
+ before = before.slice(0, lineBreak.index);
1352
+ after = '';
1353
+ closeEnd = restEnd;
1354
+ } else if (/^,?[ \t]*$/.test(before)) {
1355
+ // `{c}]` or `{c} ], {d}` on one line: of what stood between the value and
1356
+ // the bracket only the comma is worth keeping.
1357
+ before = before[0] === ',' ? ',' : '';
1358
+ }
1359
+ return {
1360
+ ok: true,
1361
+ path: path,
1362
+ parts: [
1363
+ { ok: true, path: path, start: opening.start, end: opening.end, text: opening.text },
1364
+ { ok: true, path: lastPath, start: lastValue[1], end: closeEnd, text: before + after },
1365
+ ],
1366
+ };
1367
+ }
1368
+
1369
+ /**
1370
+ * The opening half of an unwrap: the text from the start of the `[`'s line (or
1371
+ * the `[` itself, when something precedes it on that line) to just past the
1372
+ * title's comma, its trailing spaces and — when nothing else follows on that
1373
+ * line — its line break, rewritten without the `deleted` ranges.
1374
+ *
1375
+ * Whitespace on either side of a deleted token collapses to the side that was
1376
+ * there first, so `[ /*c*\/ "g", {b}` keeps one space on each side of its
1377
+ * comment. A line the deletions leave holding only whitespace goes, break and
1378
+ * all; on the `[`'s own line when the `[` was not first on it, the break stays
1379
+ * and the spaces before the `[` go instead, so the line before simply ends.
1380
+ * Comments are text like any other here, so none is ever lost.
1381
+ */
1382
+ function unwrapOpening(text, bracket, titleEnd, deleted) {
1383
+ const indent = lineIndentBefore(text, bracket);
1384
+ const ownsLine = indent !== null;
1385
+ let start = ownsLine ? bracket - indent.length : bracket;
1386
+ let end = titleEnd;
1387
+ while (text[end] === ' ' || text[end] === '\t') end++;
1388
+ if (text[end] === '\r' && text[end + 1] === '\n') end += 2;
1389
+ else if (text[end] === '\n' || text[end] === '\r') end++;
1390
+ let joined = '';
1391
+ let at = start;
1392
+ for (const cut of deleted) {
1393
+ joined = joinKeep(joined, text.slice(at, cut[0]));
1394
+ at = cut[1];
1395
+ }
1396
+ joined = joinKeep(joined, text.slice(at, end));
1397
+ // Line by line, keeping each line's own break.
1398
+ const lines = [];
1399
+ let from = 0;
1400
+ for (let j = 0; j < joined.length; j++) {
1401
+ if (joined[j] === '\n' || (joined[j] === '\r' && joined[j + 1] !== '\n')) {
1402
+ lines.push(joined.slice(from, j + 1));
1403
+ from = j + 1;
1404
+ }
1405
+ }
1406
+ lines.push(joined.slice(from));
1407
+ let out = '';
1408
+ for (let k = 0; k < lines.length; k++) {
1409
+ const line = lines[k];
1410
+ const body = line.replace(/\r?\n$|\r$/, '');
1411
+ const broken = body.length !== line.length;
1412
+ if (!/^[ \t]*$/.test(body)) {
1413
+ out += line;
1414
+ } else if (k === 0 && !ownsLine) {
1415
+ // The `[`'s own line, with something in front of it. Broken: that line
1416
+ // now ends where its last real character does. Not broken (all on one
1417
+ // line): the spaces before the `[` already separate it from what follows.
1418
+ if (broken) {
1419
+ while (start > 0 && (text[start - 1] === ' ' || text[start - 1] === '\t')) start--;
1420
+ out += line.slice(body.length);
1421
+ }
1422
+ } else if (!broken) {
1423
+ out += line; // the indentation of whatever follows on that line
1424
+ }
1425
+ // else: a line holding nothing any more, which goes with its break
1426
+ }
1427
+ return { start: start, end: end, text: out };
1428
+ }
1429
+
1430
+ /** Join two pieces of text that a deleted token stood between. */
1431
+ function joinKeep(left, right) {
1432
+ const leftSpace = /[ \t]$/.test(left);
1433
+ return leftSpace ? left + right.replace(/^[ \t]+/, '') : left + right;
1434
+ }
1435
+
1128
1436
  /**
1129
1437
  * A key written twice in the same object is legal WaveJSON and the later one
1130
1438
  * wins, which leaves the earlier one's subtree still holding spans that the doc
@@ -2532,8 +2840,9 @@ function moveLane(doc, from, to) {
2532
2840
  * group's LAST lane lands the copy just outside the group (append to `signal`
2533
2841
  * itself), while duplicating any other lane in the group — including its
2534
2842
  * 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.
2843
+ * duplication; it is the same rule every lane insert surfaces to the user
2844
+ * (the ⠿ menu's 在下方新增訊號 today, the retired rail's insert button
2845
+ * before it), reached through the same door.
2537
2846
  */
2538
2847
  function duplicateLane(doc, at) {
2539
2848
  const flat = flattenLanes(doc);
@@ -2567,7 +2876,7 @@ function duplicateLane(doc, at) {
2567
2876
  * that path names a real lane too.
2568
2877
  *
2569
2878
  * So every structural edit this module can produce has exactly one supported way
2570
- * to be written back. All five are pinned as tests against a grouped fixture in
2879
+ * to be written back. All eight are pinned as tests against a grouped fixture in
2571
2880
  * `test/wave-codec.test.js`; this list is a map to them, not the authority:
2572
2881
  *
2573
2882
  * renameLane(doc, k, n), lane already has a name
@@ -2581,6 +2890,18 @@ function duplicateLane(doc, at) {
2581
2890
  * moveLane(doc, f, t)
2582
2891
  * {op: 'move', path: lanePath(doc, f),
2583
2892
  * to: laneInsertPath(doc, t < f ? t : t + 1)}
2893
+ * groupLanes(doc, ks, t), the lanes in ks siblings in one array
2894
+ * {op: 'wrap', path: lanePath(doc, min of ks), count: ks.length, title: t}
2895
+ * ungroupLanes(doc, k)
2896
+ * {op: 'unwrap', path: lanePath(doc, k).slice(0, -1)}
2897
+ * a whole group moved inside the array that holds it (the name column's
2898
+ * group 上移 / 下移, `wave-panels.js` `commitGroupMove`)
2899
+ * {op: 'move', path: <the group's path>, to: <its array's path>.concat([d])}
2900
+ * where d counts the array as it stands, like every move destination
2901
+ *
2902
+ * A `groupLanes` selection that swallows a whole sub-group flattens that group
2903
+ * into the new one (its title goes); that is a wrap and an unwrap at once and
2904
+ * has no single recipe here, so a store asked to write it refuses.
2584
2905
  *
2585
2906
  * The rename is the one with a branch, and picking the wrong side of it cannot
2586
2907
  * go unnoticed: `set` on a lane with no `name` refuses because nothing is parsed
@@ -2917,10 +3238,10 @@ function groupLanes(doc, ats, label) {
2917
3238
  * ungrouping always names one group unambiguously. It also means a group made
2918
3239
  * of nothing but sub-groups — no lane of its own anywhere in it — cannot be
2919
3240
  * 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.
3241
+ * UI asks for a direct member for the same reason: the name column's group
3242
+ * ⠿ → 解散群組 (`ungroupSpan` in `wave-ui.js`) looks for one in the group's
3243
+ * span, and for a group made only of sub-groups says to dissolve the inner
3244
+ * group first.
2924
3245
  *
2925
3246
  * `at` outside the document, or a lane that is not inside any group (`signal`
2926
3247
  * is not one), answers the document unchanged.
@@ -3119,6 +3440,30 @@ function parseEdge(entry) {
3119
3440
  return { from: from, to: to, shape: shape, label: rest === '' ? '' : rest.slice(1) };
3120
3441
  }
3121
3442
 
3443
+ /**
3444
+ * 單點標註:`<字母> <文字>`。引擎把一字元的 head 讀成 from === to、shape '',
3445
+ * 於是在那個 node 畫零長度的線加標籤。parseEdge 仍拒絕這種 entry(它不是關聯線),
3446
+ * 標註走自己的這組函數。
3447
+ */
3448
+ function parseNote(entry) {
3449
+ if (typeof entry !== 'string') return null;
3450
+ const text = entry.trim();
3451
+ if (text === '') return null;
3452
+ const head = text.split(/\s+/)[0];
3453
+ if (head.length !== 1 || !isNodeLetter(head)) return null;
3454
+ const rest = text.slice(1).trim();
3455
+ return { letter: head, text: rest };
3456
+ }
3457
+
3458
+ /** `{letter, text}` → 一條標註 entry;字母不合法或文字為空回 null。 */
3459
+ function formatNote(note) {
3460
+ if (note === null || typeof note !== 'object') return null;
3461
+ if (!isNodeLetter(note.letter)) return null;
3462
+ const text = typeof note.text === 'string' ? note.text.trim() : '';
3463
+ if (text === '') return null;
3464
+ return note.letter + ' ' + text;
3465
+ }
3466
+
3122
3467
  /** `{from, to, shape, label}` → 一條 entry,不合法回 null。 */
3123
3468
  function formatEdge(edge) {
3124
3469
  if (edge === null || typeof edge !== 'object') return null;
@@ -3164,8 +3509,10 @@ function nodesOf(doc) {
3164
3509
  return out;
3165
3510
  }
3166
3511
 
3512
+ // 大寫優先:作者手寫的 anchor 慣例是小寫(a、b…),編輯器自動配的字母從大寫
3513
+ // 開始,兩邊不會互相踩到;池子大小與總數不變(52)。
3167
3514
  const NODE_LETTER_POOL =
3168
- 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
3515
+ 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
3169
3516
 
3170
3517
  /**
3171
3518
  * 保證 `(at, cell)` 這一格有一個 anchor 字母。
@@ -3219,7 +3566,10 @@ function pruneNodes(doc) {
3219
3566
  ? doc.edge : [];
3220
3567
  for (let i = 0; i < list.length; i += 1) {
3221
3568
  const e = parseEdge(list[i]);
3222
- if (e !== null) { referenced[e.from] = true; referenced[e.to] = true; }
3569
+ if (e !== null) { referenced[e.from] = true; referenced[e.to] = true; continue; }
3570
+ // 標註也引用它的字母,否則任何一次 prune 都會把標註的錨點清掉。
3571
+ const n = parseNote(list[i]);
3572
+ if (n !== null) referenced[n.letter] = true;
3223
3573
  }
3224
3574
 
3225
3575
  const flat = flattenLanes(doc);
@@ -3390,7 +3740,63 @@ function moveEdgeEnd(doc, index, end, at, cell) {
3390
3740
  return pruneNodes(updateEdge(ensured.doc, index, nextEdge));
3391
3741
  }
3392
3742
 
3743
+ /** doc.edge 裡所有標註;`index` 是在 `doc.edge` 的原始索引。 */
3744
+ function notesOf(doc) {
3745
+ const out = [];
3746
+ const list = (doc !== null && typeof doc === 'object' && Array.isArray(doc.edge))
3747
+ ? doc.edge : [];
3748
+ for (let i = 0; i < list.length; i += 1) {
3749
+ const n = parseNote(list[i]);
3750
+ if (n !== null) out.push({ index: i, letter: n.letter, text: n.text });
3751
+ }
3752
+ return out;
3753
+ }
3754
+
3755
+ /** 在 `(at, cell)` 加一條標註(必要時配字母);放不下/文字為空回原 doc。 */
3756
+ function addNote(doc, at, cell, text) {
3757
+ if (formatNote({ letter: 'A', text: text }) === null) return doc;
3758
+ const ensured = ensureNode(doc, at, cell);
3759
+ if (ensured.letter === null) return doc;
3760
+ const entry = formatNote({ letter: ensured.letter, text: text });
3761
+ const base = ensured.doc;
3762
+ const list = Array.isArray(base.edge) ? base.edge.slice() : [];
3763
+ list.push(entry);
3764
+ const copy = copyObject(base);
3765
+ copy.edge = list;
3766
+ return copy;
3767
+ }
3768
+
3769
+ /** 改第 `index` 條 edge(必須是標註)的文字;不是標註或文字為空回原 doc。 */
3770
+ function updateNote(doc, index, text) {
3771
+ const list = (doc !== null && typeof doc === 'object' && Array.isArray(doc.edge))
3772
+ ? doc.edge : [];
3773
+ if (!isIndex(index, list.length)) return doc;
3774
+ const n = parseNote(list[index]);
3775
+ if (n === null) return doc;
3776
+ const entry = formatNote({ letter: n.letter, text: text });
3777
+ if (entry === null) return doc;
3778
+ const next = list.slice();
3779
+ next[index] = entry;
3780
+ const copy = copyObject(doc);
3781
+ copy.edge = next;
3782
+ return copy;
3783
+ }
3784
+
3785
+ /** 移除第 `index` 條標註並清掉不再被引用的字母。 */
3786
+ function removeNote(doc, index) {
3787
+ const list = (doc !== null && typeof doc === 'object' && Array.isArray(doc.edge))
3788
+ ? doc.edge : [];
3789
+ if (!isIndex(index, list.length)) return doc;
3790
+ if (parseNote(list[index]) === null) return doc;
3791
+ const next = list.slice();
3792
+ next.splice(index, 1);
3793
+ const copy = copyObject(doc);
3794
+ copy.edge = next;
3795
+ return pruneNodes(copy);
3796
+ }
3797
+
3393
3798
  module.exports = {
3799
+ parseNote, formatNote, notesOf, addNote, updateNote, removeNote, NODE_LETTER_POOL,
3394
3800
  parseSource, patchSource,
3395
3801
  expandWave, collapseWave, levelsOf,
3396
3802
  setCell, setCellRange, insertCycles, deleteCycles, copyCycles, pasteCycles,