@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-store.js
CHANGED
|
@@ -14,11 +14,14 @@
|
|
|
14
14
|
* be derived wrongly.
|
|
15
15
|
*
|
|
16
16
|
* `toPatch` does NOT replay the stack. It compares the current entry against
|
|
17
|
-
* the parse of the
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
17
|
+
* the parse of the BASE text and writes the difference; nothing about how the
|
|
18
|
+
* user got there reaches the file. The base starts as the text the store was
|
|
19
|
+
* opened on, and the editor moves it with `rebase` after every write-back that
|
|
20
|
+
* landed (Ruling R16), so a patch is planned for one gesture rather than for a
|
|
21
|
+
* whole session of them. That is what makes an edit and its inverse cost zero
|
|
22
|
+
* bytes instead of two edits that cancel, and it is why `isDirty` — which keeps
|
|
23
|
+
* measuring against the OPENING text, never the base — can be a question about
|
|
24
|
+
* two documents rather than about the stack; see the note on it below.
|
|
22
25
|
*
|
|
23
26
|
* Four rules this file does not get to bend:
|
|
24
27
|
*
|
|
@@ -80,44 +83,30 @@ const HISTORY_LIMIT = 200;
|
|
|
80
83
|
* than as a throw or a null: the GUI layer above has a block on screen either
|
|
81
84
|
* way, and `ok` plus a refusing `toPatch` is something it can show.
|
|
82
85
|
*/
|
|
83
|
-
function createStore(
|
|
84
|
-
const
|
|
85
|
-
if (
|
|
86
|
+
function createStore(openingText) {
|
|
87
|
+
const opening = C.parseSource(openingText);
|
|
88
|
+
if (opening.ok !== true) return refusingStore(openingText, opening);
|
|
86
89
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
+
// The BASE is what the next patch is written against: the opening text until
|
|
91
|
+
// the first write-back, then whatever `rebase` was last handed. The OPENING
|
|
92
|
+
// text and its parse stay for `isDirty`, which asks about the whole session,
|
|
93
|
+
// and as the second reading `toPatch` falls back on (Ruling R17).
|
|
94
|
+
let sourceText = openingText;
|
|
95
|
+
let parsed = opening;
|
|
96
|
+
const openingDoc = deepFreeze(opening.doc, 0);
|
|
97
|
+
const openingCtx = contextFor(opening, openingDoc, C.lanePaths(openingDoc));
|
|
98
|
+
let ctx = openingCtx;
|
|
99
|
+
// Each entry carries two answers to "which lane is which": `origins`, against
|
|
100
|
+
// the current base (re-traced by every `rebase`), and `openOrigins`, against
|
|
101
|
+
// the opening text, which nothing but `apply` ever writes.
|
|
102
|
+
const history = [{
|
|
103
|
+
opName: null,
|
|
104
|
+
doc: openingDoc,
|
|
105
|
+
origins: countUp(openingCtx.basePaths.length),
|
|
106
|
+
openOrigins: countUp(openingCtx.basePaths.length),
|
|
107
|
+
}];
|
|
90
108
|
let at = 0;
|
|
91
109
|
|
|
92
|
-
/** The span of one path as a range a caller can frame, or null. */
|
|
93
|
-
function range(path) {
|
|
94
|
-
const span = parsed.spans.get(JSON.stringify(path));
|
|
95
|
-
return span === undefined
|
|
96
|
-
? null
|
|
97
|
-
: { path: path, spanPath: path, start: span[0], end: span[1] };
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/** One range covering several paths — for a refusal about more than one lane. */
|
|
101
|
-
function spanning(paths) {
|
|
102
|
-
let out = null;
|
|
103
|
-
for (const path of paths) {
|
|
104
|
-
const one = range(path);
|
|
105
|
-
if (one === null) continue;
|
|
106
|
-
if (out === null) out = one;
|
|
107
|
-
else {
|
|
108
|
-
out = {
|
|
109
|
-
path: out.path,
|
|
110
|
-
spanPath: out.spanPath,
|
|
111
|
-
start: Math.min(out.start, one.start),
|
|
112
|
-
end: Math.max(out.end, one.end),
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
return out === null ? range([]) : out;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const ctx = { baseDoc: baseDoc, basePaths: basePaths, range: range, spanning: spanning };
|
|
120
|
-
|
|
121
110
|
/**
|
|
122
111
|
* Run one operation and, when it changed anything, push the result.
|
|
123
112
|
*
|
|
@@ -144,11 +133,13 @@ function createStore(sourceText) {
|
|
|
144
133
|
if (next === here.doc || !isDocShape(here.doc, next)) return false;
|
|
145
134
|
const adopted = adoptDocument(next, 0, new Map());
|
|
146
135
|
const origins = remapOrigins(here.doc, adopted, here.origins);
|
|
136
|
+
const openOrigins = remapOrigins(here.doc, adopted, here.openOrigins);
|
|
147
137
|
history.length = at + 1;
|
|
148
138
|
history.push({
|
|
149
139
|
opName: opName === undefined ? null : opName,
|
|
150
140
|
doc: adopted,
|
|
151
141
|
origins: origins,
|
|
142
|
+
openOrigins: openOrigins,
|
|
152
143
|
});
|
|
153
144
|
at++;
|
|
154
145
|
// Cap the stack, not just its growth: apply always leaves `at` on the
|
|
@@ -209,7 +200,48 @@ function createStore(sourceText) {
|
|
|
209
200
|
* that the patch would not write.
|
|
210
201
|
*/
|
|
211
202
|
function isDirty() {
|
|
212
|
-
return !sameValue(
|
|
203
|
+
return !sameValue(openingDoc, history[at].doc, 0);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Move the base to `text`, the bytes the caller has just written back for the
|
|
208
|
+
* CURRENT document, so the next `toPatch` is planned against them and sees
|
|
209
|
+
* one gesture rather than the whole session (Ruling R16).
|
|
210
|
+
*
|
|
211
|
+
* Planning stays a comparison of two documents — the current one against the
|
|
212
|
+
* parse of the base text — and nothing is replayed. What a rebase has to
|
|
213
|
+
* carry over is lane identity: every stack entry's `origins` named lanes of
|
|
214
|
+
* the OLD base, and is re-traced to the new one, outward from the current
|
|
215
|
+
* entry, one operation at a time with the same `remapOrigins` that `apply`
|
|
216
|
+
* runs. The current entry is the new base itself, so its origins count up;
|
|
217
|
+
* an undo entry then reads as the reverse of the gesture that left it, and a
|
|
218
|
+
* redo entry as that gesture again.
|
|
219
|
+
*
|
|
220
|
+
* Refused — answering false and changing nothing — for text that does not
|
|
221
|
+
* read back as the current document: a rebase onto bytes that say something
|
|
222
|
+
* else would plan the next patch against a file that is not there. `isDirty`
|
|
223
|
+
* does not move: it measures the session against the text the editor opened.
|
|
224
|
+
*
|
|
225
|
+
* The entry also remembers `text` as what the file held for it, so an undo or
|
|
226
|
+
* redo that lands on it again writes exactly those bytes (see `toPatch`).
|
|
227
|
+
* Its `openOrigins` are left alone: they are the second reading's (R17).
|
|
228
|
+
*/
|
|
229
|
+
function rebase(text) {
|
|
230
|
+
const next = C.parseSource(text);
|
|
231
|
+
if (next.ok !== true || !sameValue(next.doc, history[at].doc, 0)) return false;
|
|
232
|
+
sourceText = text;
|
|
233
|
+
parsed = next;
|
|
234
|
+
const baseDoc = deepFreeze(next.doc, 0);
|
|
235
|
+
ctx = contextFor(next, baseDoc, C.lanePaths(baseDoc));
|
|
236
|
+
history[at].origins = countUp(ctx.basePaths.length);
|
|
237
|
+
history[at].written = text;
|
|
238
|
+
for (let k = at - 1; k >= 0; k--) {
|
|
239
|
+
history[k].origins = remapOrigins(history[k + 1].doc, history[k].doc, history[k + 1].origins);
|
|
240
|
+
}
|
|
241
|
+
for (let k = at + 1; k < history.length; k++) {
|
|
242
|
+
history[k].origins = remapOrigins(history[k - 1].doc, history[k].doc, history[k - 1].origins);
|
|
243
|
+
}
|
|
244
|
+
return true;
|
|
213
245
|
}
|
|
214
246
|
|
|
215
247
|
/**
|
|
@@ -228,47 +260,47 @@ function createStore(sourceText) {
|
|
|
228
260
|
*/
|
|
229
261
|
function toPatch() {
|
|
230
262
|
const here = history[at];
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
//
|
|
251
|
-
// When there was more than one reading, the one being described is the first
|
|
252
|
-
// by position and not necessarily the drag the user performed, so the message
|
|
253
|
-
// says that rather than asserting a gesture nobody made. Naming the "more
|
|
254
|
-
// plausible" reading instead would be a tie-break by argument, which is the
|
|
255
|
-
// thing that shipped the wrong patch in the first place.
|
|
256
|
-
if (plan.refusals.length > 0) {
|
|
257
|
-
const first = plan.refusals[0];
|
|
258
|
-
if (plan.readings < 2) return first;
|
|
259
|
-
return refuse('the lanes were reordered in a way that has more than one reading — ' +
|
|
260
|
-
'which lane was dragged cannot be told apart from the result — and none of them ' +
|
|
261
|
-
'could be written as a local patch. One reading was refused because: ' + first.reason,
|
|
262
|
-
first.rewroteRange);
|
|
263
|
+
// Back at the document the editor opened on: the opening bytes ARE its
|
|
264
|
+
// patch. Planned against a moved base, the reverse of the last gesture can
|
|
265
|
+
// read back right and still differ in bytes (a move leaves its trailing
|
|
266
|
+
// comma), and a session that has come back to where it started must hand
|
|
267
|
+
// back the author's own text — `client.js` takes the session's ops off the
|
|
268
|
+
// document stack only when the block is byte-identical to its opening.
|
|
269
|
+
if (sourceText !== openingText && !isDirty()) return { ok: true, text: openingText };
|
|
270
|
+
// An entry the file has already held — the one an undo or a redo lands on —
|
|
271
|
+
// is written back as exactly those bytes: a reverse gesture planned against
|
|
272
|
+
// the base would say the same thing with less of the author's text in it
|
|
273
|
+
// (an undone delete comes back as a fresh value, without the comment that
|
|
274
|
+
// sat beside it), or not at all. They go through the read-back check again
|
|
275
|
+
// first (Ruling R18): they read back as this document when they were
|
|
276
|
+
// written, but a value a caller froze only at its top keeps whatever is
|
|
277
|
+
// inside it mutable, so "this document" may have moved since. On a
|
|
278
|
+
// mismatch the two planners below decide, as for any other entry.
|
|
279
|
+
if (here.written !== undefined) {
|
|
280
|
+
const back = C.parseSource(here.written);
|
|
281
|
+
if (back.ok === true && sameValue(back.doc, here.doc, 0)) return { ok: true, text: here.written };
|
|
263
282
|
}
|
|
264
|
-
|
|
265
|
-
|
|
283
|
+
const first = writeAgainst(ctx, sourceText, parsed, here.doc, here.origins);
|
|
284
|
+
if (first.ok === true || sourceText === openingText) return first;
|
|
285
|
+
// Ruling R17: the plan against the rebased base refused, so the same
|
|
286
|
+
// document is planned once more against the OPENING text with the
|
|
287
|
+
// opening-relative origins — the whole-session plan that preceded R16.
|
|
288
|
+
// Some changes only that reading can say: an undo that puts a deleted lane
|
|
289
|
+
// back in front of a group at the group's own level, or behind a group's
|
|
290
|
+
// last member, or brings back a group its deletions had emptied. It is
|
|
291
|
+
// still two documents and no replay, the read-back decides, and either
|
|
292
|
+
// way the patch is the whole block text. The base reading goes first, so
|
|
293
|
+
// whatever it could write is written exactly as before; when both refuse,
|
|
294
|
+
// the base reading's refusal is the one reported.
|
|
295
|
+
const second = writeAgainst(openingCtx, openingText, opening, here.doc, here.openOrigins);
|
|
296
|
+
return second.ok === true ? second : first;
|
|
266
297
|
}
|
|
267
298
|
|
|
268
299
|
const store = {
|
|
269
300
|
ok: true,
|
|
270
301
|
error: null,
|
|
271
|
-
|
|
302
|
+
// The text the editor OPENED with — not the moving base `rebase` keeps.
|
|
303
|
+
source: openingText,
|
|
272
304
|
apply: apply,
|
|
273
305
|
undo: undo,
|
|
274
306
|
redo: redo,
|
|
@@ -278,6 +310,7 @@ function createStore(sourceText) {
|
|
|
278
310
|
redoName: function () { return at < history.length - 1 ? history[at + 1].opName : null; },
|
|
279
311
|
isDirty: isDirty,
|
|
280
312
|
toPatch: toPatch,
|
|
313
|
+
rebase: rebase,
|
|
281
314
|
};
|
|
282
315
|
Object.defineProperty(store, 'doc', {
|
|
283
316
|
enumerable: true,
|
|
@@ -286,6 +319,80 @@ function createStore(sourceText) {
|
|
|
286
319
|
return store;
|
|
287
320
|
}
|
|
288
321
|
|
|
322
|
+
/**
|
|
323
|
+
* Everything the planner reads about the text a patch is written against: its
|
|
324
|
+
* document, its lane paths, and ranges into its spans for refusals to frame.
|
|
325
|
+
*/
|
|
326
|
+
function contextFor(parsed, baseDoc, basePaths) {
|
|
327
|
+
function range(path) {
|
|
328
|
+
const span = parsed.spans.get(JSON.stringify(path));
|
|
329
|
+
return span === undefined
|
|
330
|
+
? null
|
|
331
|
+
: { path: path, spanPath: path, start: span[0], end: span[1] };
|
|
332
|
+
}
|
|
333
|
+
/** One range covering several paths — for a refusal about more than one lane. */
|
|
334
|
+
function spanning(paths) {
|
|
335
|
+
let out = null;
|
|
336
|
+
for (const path of paths) {
|
|
337
|
+
const one = range(path);
|
|
338
|
+
if (one === null) continue;
|
|
339
|
+
if (out === null) out = one;
|
|
340
|
+
else {
|
|
341
|
+
out = {
|
|
342
|
+
path: out.path,
|
|
343
|
+
spanPath: out.spanPath,
|
|
344
|
+
start: Math.min(out.start, one.start),
|
|
345
|
+
end: Math.max(out.end, one.end),
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return out === null ? range([]) : out;
|
|
350
|
+
}
|
|
351
|
+
return { baseDoc: baseDoc, basePaths: basePaths, range: range, spanning: spanning };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Plan `doc` against one text, write every reading, and hand back the first
|
|
356
|
+
* that reads back as `doc` — or the refusal that best says why none did.
|
|
357
|
+
*/
|
|
358
|
+
function writeAgainst(ctx, text, parsed, doc, origins) {
|
|
359
|
+
const plan = planEdits(ctx, doc, origins);
|
|
360
|
+
if (plan.ok !== true) return plan;
|
|
361
|
+
let refused = null;
|
|
362
|
+
let mismatched = null;
|
|
363
|
+
for (const edits of plan.alternatives) {
|
|
364
|
+
const written = C.patchSource(text, parsed, edits);
|
|
365
|
+
if (written.ok !== true) {
|
|
366
|
+
if (refused === null) refused = written;
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
const back = C.parseSource(written.text);
|
|
370
|
+
if (back.ok === true && sameValue(back.doc, doc, 0)) return written;
|
|
371
|
+
if (mismatched === null) mismatched = refuse(NET_REASON, ctx.range([]));
|
|
372
|
+
}
|
|
373
|
+
// Nothing reproduced the document, so this is a refusal — and WHICH refusal
|
|
374
|
+
// matters, because the candidate that refused during planning is usually the
|
|
375
|
+
// reading that was actually right. A candidate whose plan named a real
|
|
376
|
+
// structural conflict says more than "the bytes did not come back the same",
|
|
377
|
+
// so it is preferred, then the codec's own words, and only then the net.
|
|
378
|
+
//
|
|
379
|
+
// When there was more than one reading, the one being described is the first
|
|
380
|
+
// by position and not necessarily the drag the user performed, so the message
|
|
381
|
+
// says that rather than asserting a gesture nobody made. Naming the "more
|
|
382
|
+
// plausible" reading instead would be a tie-break by argument, which is the
|
|
383
|
+
// thing that shipped the wrong patch in the first place.
|
|
384
|
+
if (plan.refusals.length > 0) {
|
|
385
|
+
const first = plan.refusals[0];
|
|
386
|
+
if (plan.readings < 2) return first;
|
|
387
|
+
return refuse('the lanes were reordered in a way that has more than one reading — ' +
|
|
388
|
+
'which lane was dragged cannot be told apart from the result — and none of them ' +
|
|
389
|
+
'could be written as a local patch. One reading was refused because: ' + first.reason,
|
|
390
|
+
first.rewroteRange);
|
|
391
|
+
}
|
|
392
|
+
if (refused !== null) return refused;
|
|
393
|
+
return mismatched === null ? refuse(NET_REASON, ctx.range([])) : mismatched;
|
|
394
|
+
}
|
|
395
|
+
|
|
289
396
|
/** A store over a source that could not be read back: it refuses, it never throws. */
|
|
290
397
|
function refusingStore(sourceText, parsed) {
|
|
291
398
|
const why = 'the source could not be read back as WaveJSON (' + parsed.message +
|
|
@@ -302,6 +409,7 @@ function refusingStore(sourceText, parsed) {
|
|
|
302
409
|
undoName: function () { return null; },
|
|
303
410
|
redoName: function () { return null; },
|
|
304
411
|
isDirty: function () { return false; },
|
|
412
|
+
rebase: function () { return false; },
|
|
305
413
|
// No span table and no edit, so there is no range to frame: `rewroteRange`
|
|
306
414
|
// is null on purpose here, and `error.offset` is where the trouble is.
|
|
307
415
|
toPatch: function () { return refuse(why, null); },
|
|
@@ -531,6 +639,11 @@ function countUp(n) {
|
|
|
531
639
|
* address lanes through the bridge, never by a path built here — and the
|
|
532
640
|
* content half is then done pairwise between lanes that survived. That half can
|
|
533
641
|
* offer more than one candidate; `toPatch` decides between them by reproduction.
|
|
642
|
+
*
|
|
643
|
+
* A third route is for the changes to the GROUPS themselves, which neither of
|
|
644
|
+
* those can say: a run of lanes wrapped into a new group, a group dissolved, a
|
|
645
|
+
* whole group moved among its siblings. See `planTree`; its readings are added
|
|
646
|
+
* to whichever of the two routes above ran, after that route's own.
|
|
534
647
|
*/
|
|
535
648
|
function planEdits(ctx, doc, origins) {
|
|
536
649
|
const common = [];
|
|
@@ -539,18 +652,28 @@ function planEdits(ctx, doc, origins) {
|
|
|
539
652
|
const top = diffMembers(ctx, [], ctx.baseDoc, doc, skip, common);
|
|
540
653
|
if (top.ok !== true) return top;
|
|
541
654
|
if (!laneAware) return { ok: true, alternatives: [common], refusals: [], readings: 1 };
|
|
655
|
+
// The group-level readings (a group wrapped, unwrapped or moved as a whole)
|
|
656
|
+
// come AFTER the lane-level ones in every list below: where a lane-level plan
|
|
657
|
+
// already reproduced the document before, it still wins and writes exactly
|
|
658
|
+
// the bytes it always wrote. They only ever turn a refusal into a write.
|
|
659
|
+
const tree = planTree(ctx, doc, origins, common);
|
|
542
660
|
if (origins.length === ctx.basePaths.length && isCountUp(origins)) {
|
|
543
|
-
const
|
|
661
|
+
const same = common.slice();
|
|
662
|
+
const lanes = diffValue(ctx, ['signal'], signalOf(ctx.baseDoc), signalOf(doc), same);
|
|
544
663
|
if (lanes.ok !== true) return lanes;
|
|
545
|
-
return { ok: true, alternatives: [
|
|
664
|
+
return { ok: true, alternatives: [same].concat(tree), refusals: [], readings: 1 + tree.length };
|
|
546
665
|
}
|
|
547
666
|
const structural = planStructural(ctx, doc, origins);
|
|
548
|
-
if (structural.ok !== true)
|
|
667
|
+
if (structural.ok !== true) {
|
|
668
|
+
if (tree.length === 0) return structural;
|
|
669
|
+
return { ok: true, alternatives: tree, refusals: [structural], readings: 1 + tree.length };
|
|
670
|
+
}
|
|
549
671
|
return {
|
|
550
672
|
ok: true,
|
|
551
|
-
alternatives: structural.alternatives.map(function (extra) { return common.concat(extra); })
|
|
673
|
+
alternatives: structural.alternatives.map(function (extra) { return common.concat(extra); })
|
|
674
|
+
.concat(tree),
|
|
552
675
|
refusals: structural.refusals,
|
|
553
|
-
readings: structural.readings,
|
|
676
|
+
readings: structural.readings + tree.length,
|
|
554
677
|
};
|
|
555
678
|
}
|
|
556
679
|
|
|
@@ -579,20 +702,25 @@ function planStructural(ctx, doc, origins) {
|
|
|
579
702
|
}
|
|
580
703
|
|
|
581
704
|
// A group title is not a lane, so nothing above tracks one, and a structural
|
|
582
|
-
// walk would carry a renamed title silently past the patch.
|
|
583
|
-
//
|
|
584
|
-
//
|
|
585
|
-
//
|
|
586
|
-
//
|
|
587
|
-
//
|
|
588
|
-
//
|
|
705
|
+
// walk would carry a renamed or new title silently past the patch. Titles do
|
|
706
|
+
// change — `wave-panels`' commitGroupTitle renames one, `groupLanes` writes a
|
|
707
|
+
// new one — but each is its own gesture, and after the editor rebases on a
|
|
708
|
+
// write-back this lane-level route only ever sees a title change that rode in
|
|
709
|
+
// with a lane change since the last write-back that landed (or a hand-written
|
|
710
|
+
// operation). Rather than guess which title became which, anything the
|
|
711
|
+
// current doc says that the base doc did not is refused here; the group-level
|
|
712
|
+
// route (`planTree`) still gets its chance, and covers a new group's title.
|
|
713
|
+
// A title that vanished is ordinary: it went with the group its last lane
|
|
714
|
+
// emptied. (A group DISSOLVED with its lanes kept says nothing new here —
|
|
715
|
+
// `planTree` plans that one, and the read-back check catches any other.)
|
|
589
716
|
const titles = titleCensus(ctx.baseDoc);
|
|
590
717
|
for (const title of titlesOf(doc)) {
|
|
591
718
|
const seen = titles.get(title);
|
|
592
719
|
if (seen === undefined || seen === 0) {
|
|
593
|
-
return refuse('a group title changed
|
|
594
|
-
'title is not a lane, so which one it used to be is not
|
|
595
|
-
'
|
|
720
|
+
return refuse('a group title changed together with the lane order since the last ' +
|
|
721
|
+
'write-back, and a title is not a lane, so which one it used to be is not ' +
|
|
722
|
+
'recoverable; undo back to the last change that was written, then make the title ' +
|
|
723
|
+
'change and the lane change one at a time', ctx.range(['signal']));
|
|
596
724
|
}
|
|
597
725
|
titles.set(title, seen - 1);
|
|
598
726
|
}
|
|
@@ -835,6 +963,273 @@ function isLane(item) {
|
|
|
835
963
|
return item !== null && typeof item === 'object' && !Array.isArray(item);
|
|
836
964
|
}
|
|
837
965
|
|
|
966
|
+
// ---------------------------------------------------------------------------
|
|
967
|
+
// Group-level structure: wrap, unwrap, move a whole element
|
|
968
|
+
// ---------------------------------------------------------------------------
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* The candidate edit lists for a change to the lane tree's GROUPS — one list per
|
|
972
|
+
* reading, each beginning with `common` — or `[]` when there is none to offer.
|
|
973
|
+
*
|
|
974
|
+
* The lane-level planner above sees a change as lanes added, removed and moved,
|
|
975
|
+
* and every one of its destinations is a lane position. That cannot say three
|
|
976
|
+
* things the name column does: `groupLanes` and `ungroupLanes` leave every lane
|
|
977
|
+
* in its display position and change only the arrays around them, and a group
|
|
978
|
+
* moved past its neighbour looks like several lanes moving — or like one lane
|
|
979
|
+
* moving to "just before the group, at the group's own level", which no lane
|
|
980
|
+
* position resolves to (`laneInsertPath` joins a group at its head). So this
|
|
981
|
+
* plans on the tree instead, with the codec's element-level edits: `wrap`,
|
|
982
|
+
* `unwrap`, and `move` of a whole element.
|
|
983
|
+
*
|
|
984
|
+
* It plans from the two documents only, through a skeleton of each (`skeletonOf`):
|
|
985
|
+
* the arrays, the titles and other scalars with their values, and every lane as
|
|
986
|
+
* the base lane it came from — `origins`, the same identity the lane planner
|
|
987
|
+
* uses. A reading is ONE structural edit that turns the base skeleton into the
|
|
988
|
+
* current one exactly:
|
|
989
|
+
*
|
|
990
|
+
* - unwrap: a base group whose dissolving gives the current skeleton;
|
|
991
|
+
* - wrap: a current group whose dissolving gives the BASE skeleton — the same
|
|
992
|
+
* test run backwards, which also hands over the new title as written;
|
|
993
|
+
* - move: a base element (lane or group) that, lifted out, leaves the same
|
|
994
|
+
* skeleton the current one leaves without that element.
|
|
995
|
+
*
|
|
996
|
+
* Every reading that fits is offered, not one chosen by argument — a swap of two
|
|
997
|
+
* neighbours fits "the upper one moved down" and "the lower one moved up", and
|
|
998
|
+
* those can be different patches. `toPatch` writes each and keeps the first
|
|
999
|
+
* that reads back as the document, as it does for the lane planner's readings.
|
|
1000
|
+
*
|
|
1001
|
+
* Only the shape is decided here; what changed INSIDE a lane is added to every
|
|
1002
|
+
* reading as the ordinary per-lane diff, at the lane's base path. That is safe
|
|
1003
|
+
* beside a wrap or unwrap, whose edits sit outside every member, and refused by
|
|
1004
|
+
* the codec as overlapping beside a move of that same lane — which is the
|
|
1005
|
+
* answer the lane planner gives for a moved lane that was also edited.
|
|
1006
|
+
*
|
|
1007
|
+
* Only offered when the roster is intact — every base lane present once, none
|
|
1008
|
+
* new — and the skeletons differ. Lanes added or removed in the same session
|
|
1009
|
+
* as a group change would need two structural edits measured against the same
|
|
1010
|
+
* text, and the lane planner's own refusal is the honest answer there.
|
|
1011
|
+
*/
|
|
1012
|
+
function planTree(ctx, doc, origins, common) {
|
|
1013
|
+
if (!isPermutation(origins, ctx.basePaths.length)) return [];
|
|
1014
|
+
if (ctx.baseTree === undefined) ctx.baseTree = skeletonOf(ctx.baseDoc, countUp(ctx.basePaths.length));
|
|
1015
|
+
const base = ctx.baseTree;
|
|
1016
|
+
const now = skeletonOf(doc, origins);
|
|
1017
|
+
if (base === null || now === null) return [];
|
|
1018
|
+
const baseSig = sigOf(base, null);
|
|
1019
|
+
const nowSig = sigOf(now, null);
|
|
1020
|
+
if (baseSig === nowSig) return [];
|
|
1021
|
+
|
|
1022
|
+
const structural = [];
|
|
1023
|
+
for (const group of groupsOf(base)) {
|
|
1024
|
+
if (unwrappedSig(base, group) === nowSig) structural.push({ op: 'unwrap', path: group.path });
|
|
1025
|
+
}
|
|
1026
|
+
for (const group of groupsOf(now)) {
|
|
1027
|
+
if (unwrappedSig(now, group) !== baseSig) continue;
|
|
1028
|
+
const titled = group.items.length > 0 && group.items[0].kind === 'other' &&
|
|
1029
|
+
typeof group.items[0].value === 'string';
|
|
1030
|
+
const members = group.items.slice(titled ? 1 : 0);
|
|
1031
|
+
if (members.length === 0 || members.some(function (m) { return m.kind === 'other'; })) continue;
|
|
1032
|
+
// Dissolving it in the current tree gives the base tree, so everything in
|
|
1033
|
+
// front of it — its parent's path and its own index — is the same in both.
|
|
1034
|
+
const edit = { op: 'wrap', path: group.path, count: members.length };
|
|
1035
|
+
if (titled) edit.title = group.items[0].value;
|
|
1036
|
+
structural.push(edit);
|
|
1037
|
+
}
|
|
1038
|
+
const bySig = new Map();
|
|
1039
|
+
for (const node of elementsOf(now)) {
|
|
1040
|
+
const sig = sigOf(node, null);
|
|
1041
|
+
const list = bySig.get(sig);
|
|
1042
|
+
if (list === undefined) bySig.set(sig, [node]);
|
|
1043
|
+
else list.push(node);
|
|
1044
|
+
}
|
|
1045
|
+
for (const node of elementsOf(base)) {
|
|
1046
|
+
const cut = liftedOut(node);
|
|
1047
|
+
const without = sigOf(base, cut);
|
|
1048
|
+
for (const there of bySig.get(sigOf(node, null)) || []) {
|
|
1049
|
+
if (sigOf(now, there) !== without) continue;
|
|
1050
|
+
structural.push({ op: 'move', path: node.path, to: destinationIn(cut, there) });
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
const alternatives = [];
|
|
1055
|
+
for (const edit of structural) {
|
|
1056
|
+
const edits = common.concat([edit]);
|
|
1057
|
+
const r = laneContent(ctx, doc, now, edits);
|
|
1058
|
+
if (r.ok === true) alternatives.push(edits);
|
|
1059
|
+
}
|
|
1060
|
+
return alternatives;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/** Whether `origins` names every base lane exactly once and nothing new. */
|
|
1064
|
+
function isPermutation(origins, n) {
|
|
1065
|
+
if (origins.length !== n) return false;
|
|
1066
|
+
const seen = new Array(n).fill(false);
|
|
1067
|
+
for (const o of origins) {
|
|
1068
|
+
if (o === null || !(o >= 0 && o < n) || seen[o]) return false;
|
|
1069
|
+
seen[o] = true;
|
|
1070
|
+
}
|
|
1071
|
+
return true;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/**
|
|
1075
|
+
* The lane tree of `doc` as plain nodes: `{kind:'group', path, items}` for every
|
|
1076
|
+
* array (the root is `signal` itself), `{kind:'lane', path, id}` for a lane —
|
|
1077
|
+
* `id` being `origins` at its display position, so the two trees name a lane
|
|
1078
|
+
* the same way — and `{kind:'other', path, value}` for a title or any other
|
|
1079
|
+
* scalar. Each node knows its `parent` and its `index` there.
|
|
1080
|
+
*
|
|
1081
|
+
* Display positions come from the codec's own walk (`lanePaths`), not from a
|
|
1082
|
+
* count kept here: a lane's number and its path are matched through the bridge,
|
|
1083
|
+
* the one rule at the top of this file. `null` when the tree is deeper than the
|
|
1084
|
+
* reader would go, or when the two walks disagree about where a lane is.
|
|
1085
|
+
*/
|
|
1086
|
+
function skeletonOf(doc, origins) {
|
|
1087
|
+
const lanes = signalOf(doc);
|
|
1088
|
+
if (!Array.isArray(lanes)) return null;
|
|
1089
|
+
const display = new Map();
|
|
1090
|
+
const paths = C.lanePaths(doc);
|
|
1091
|
+
for (let j = 0; j < paths.length; j++) display.set(JSON.stringify(paths[j]), j);
|
|
1092
|
+
let sound = true;
|
|
1093
|
+
let counted = 0;
|
|
1094
|
+
const walk = function (arr, path, depth, parent, index) {
|
|
1095
|
+
const node = { kind: 'group', path: path, items: [], parent: parent, index: index };
|
|
1096
|
+
if (depth > MAX_DEPTH) {
|
|
1097
|
+
sound = false;
|
|
1098
|
+
return node;
|
|
1099
|
+
}
|
|
1100
|
+
for (let i = 0; i < arr.length; i++) {
|
|
1101
|
+
const item = arr[i];
|
|
1102
|
+
const here = path.concat([i]);
|
|
1103
|
+
if (Array.isArray(item)) {
|
|
1104
|
+
node.items.push(walk(item, here, depth + 1, node, i));
|
|
1105
|
+
} else if (isLane(item)) {
|
|
1106
|
+
const j = display.get(JSON.stringify(here));
|
|
1107
|
+
if (j === undefined) sound = false;
|
|
1108
|
+
counted++;
|
|
1109
|
+
node.items.push({ kind: 'lane', path: here, id: origins[j], parent: node, index: i });
|
|
1110
|
+
} else {
|
|
1111
|
+
node.items.push({ kind: 'other', path: here, value: item, parent: node, index: i });
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
return node;
|
|
1115
|
+
};
|
|
1116
|
+
const root = walk(lanes, ['signal'], 0, null, null);
|
|
1117
|
+
return sound && counted === paths.length ? root : null;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/**
|
|
1121
|
+
* A string that two skeletons share exactly when they are the same tree: nesting,
|
|
1122
|
+
* every scalar with its value, every lane by the base lane it came from. `cut`,
|
|
1123
|
+
* when given, is left out as though it were not there.
|
|
1124
|
+
*/
|
|
1125
|
+
function sigOf(node, cut) {
|
|
1126
|
+
if (node === cut) return '';
|
|
1127
|
+
if (node.kind === 'lane') return 'L' + node.id + ';';
|
|
1128
|
+
if (node.kind === 'other') return 'S' + scalarKey(node.value) + ';';
|
|
1129
|
+
let out = '[';
|
|
1130
|
+
for (const item of node.items) out += sigOf(item, cut);
|
|
1131
|
+
return out + ']';
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
/** `sigOf(root)` with `group` dissolved the way `ungroupLanes` dissolves one. */
|
|
1135
|
+
function unwrappedSig(root, group) {
|
|
1136
|
+
const walk = function (node) {
|
|
1137
|
+
if (node.kind !== 'group') return sigOf(node, null);
|
|
1138
|
+
const items = node === group ? membersOf(node) : node.items;
|
|
1139
|
+
let inner = '';
|
|
1140
|
+
for (const item of items) inner += walk(item);
|
|
1141
|
+
return node === group ? inner : '[' + inner + ']';
|
|
1142
|
+
};
|
|
1143
|
+
return walk(root);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/** A group's members: everything but a leading title string. */
|
|
1147
|
+
function membersOf(group) {
|
|
1148
|
+
const titled = group.items.length > 0 && group.items[0].kind === 'other' &&
|
|
1149
|
+
typeof group.items[0].value === 'string';
|
|
1150
|
+
return group.items.slice(titled ? 1 : 0);
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
function scalarKey(value) {
|
|
1154
|
+
return typeof value + ':' + (typeof value === 'string' ? JSON.stringify(value) : String(value));
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/** Every group below the root, in document order. */
|
|
1158
|
+
function groupsOf(root) {
|
|
1159
|
+
return elementsOf(root).filter(function (n) { return n.kind === 'group'; });
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/** Every lane and group below the root, in document order (titles left out). */
|
|
1163
|
+
function elementsOf(root) {
|
|
1164
|
+
const out = [];
|
|
1165
|
+
const walk = function (node) {
|
|
1166
|
+
for (const item of node.items) {
|
|
1167
|
+
if (item.kind === 'other') continue;
|
|
1168
|
+
out.push(item);
|
|
1169
|
+
if (item.kind === 'group') walk(item);
|
|
1170
|
+
}
|
|
1171
|
+
};
|
|
1172
|
+
walk(root);
|
|
1173
|
+
return out;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/**
|
|
1177
|
+
* What actually leaves the base tree when `node` is moved: the node, or the
|
|
1178
|
+
* group it empties by leaving — the codec's `move` takes that too, by the same
|
|
1179
|
+
* rule `removeLane` follows (a group left holding no lane and no group goes).
|
|
1180
|
+
*/
|
|
1181
|
+
function liftedOut(node) {
|
|
1182
|
+
let cut = node;
|
|
1183
|
+
while (cut.parent !== null && cut.parent.parent !== null) {
|
|
1184
|
+
const stays = cut.parent.items.some(function (item) {
|
|
1185
|
+
return item !== cut && item.kind !== 'other';
|
|
1186
|
+
});
|
|
1187
|
+
if (stays) break;
|
|
1188
|
+
cut = cut.parent;
|
|
1189
|
+
}
|
|
1190
|
+
return cut;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
/**
|
|
1194
|
+
* Where a moved element lands, as a `move` destination in the BASE document,
|
|
1195
|
+
* given the node it became in the current tree.
|
|
1196
|
+
*
|
|
1197
|
+
* With the element lifted out of both, the two trees are the same, so the
|
|
1198
|
+
* current parent's path and index are already right for the base tree minus
|
|
1199
|
+
* `cut`. Putting `cut` back shifts every position at or after it in its own
|
|
1200
|
+
* array up by one — which is what turns that answer into one in the base text.
|
|
1201
|
+
*/
|
|
1202
|
+
function destinationIn(cut, there) {
|
|
1203
|
+
const cutParent = cut.parent.path;
|
|
1204
|
+
const at = cut.index;
|
|
1205
|
+
const parent = there.parent.path.slice();
|
|
1206
|
+
const depth = cutParent.length;
|
|
1207
|
+
if (parent.length > depth && isPrefixPath(cutParent, parent) && parent[depth] >= at) {
|
|
1208
|
+
parent[depth] += 1;
|
|
1209
|
+
}
|
|
1210
|
+
let index = there.index;
|
|
1211
|
+
if (parent.length === depth && isPrefixPath(cutParent, parent) && index >= at) index += 1;
|
|
1212
|
+
return parent.concat([index]);
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
/**
|
|
1216
|
+
* Append to `edits` what changed inside each lane, at its base path. The lanes
|
|
1217
|
+
* are matched by origin through the skeleton, so a lane that moved is still
|
|
1218
|
+
* compared with itself.
|
|
1219
|
+
*/
|
|
1220
|
+
function laneContent(ctx, doc, now, edits) {
|
|
1221
|
+
for (const node of elementsOf(now)) {
|
|
1222
|
+
if (node.kind !== 'lane') continue;
|
|
1223
|
+
const basePath = ctx.basePaths[node.id];
|
|
1224
|
+
const was = valueAt(ctx.baseDoc, basePath);
|
|
1225
|
+
const is = valueAt(doc, node.path);
|
|
1226
|
+
if (was === is) continue;
|
|
1227
|
+
const r = diffValue(ctx, basePath, was, is, edits);
|
|
1228
|
+
if (r.ok !== true) return r;
|
|
1229
|
+
}
|
|
1230
|
+
return OK;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
838
1233
|
// ---------------------------------------------------------------------------
|
|
839
1234
|
// Value-level difference
|
|
840
1235
|
// ---------------------------------------------------------------------------
|