@helping-ai-workflow/md2doc 3.12.1 → 3.13.1
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.
- package/bin/md2doc.js +6 -0
- package/lib/editor/client.js +1015 -194
- package/lib/editor/inline-md.js +45 -1
- package/lib/editor/list-md.js +8 -1
- package/lib/editor/server.js +5 -0
- package/lib/editor/wave-canvas.js +301 -0
- package/lib/editor/wave-codec.js +417 -11
- package/lib/editor/wave-draw.js +209 -1202
- package/lib/editor/wave-geometry.js +145 -10
- package/lib/editor/wave-panels.js +907 -918
- package/lib/editor/wave-store.js +486 -91
- package/lib/editor/wave-ui.js +3588 -2232
- package/lib/md2doc.js +765 -534
- package/lib/theme/runtime.js +296 -29
- package/lib/theme/tokens.js +3 -0
- package/lib/wave-overflow.js +107 -0
- package/package.json +3 -3
- package/scripts/release-check.js +60 -0
package/lib/editor/wave-codec.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
2536
|
-
*
|
|
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
|
|
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
|
|
2921
|
-
*
|
|
2922
|
-
*
|
|
2923
|
-
*
|
|
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
|
-
'
|
|
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,
|