@helping-ai-workflow/md2doc 3.2.1 → 3.4.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.
- package/README.md +33 -8
- package/THIRD-PARTY-NOTICES.md +44 -0
- package/lib/drawio.js +178 -0
- package/lib/editor/blockmap.js +225 -17
- package/lib/editor/cli.js +50 -1
- package/lib/editor/client.js +5376 -173
- package/lib/editor/inline-md.js +122 -12
- package/lib/editor/lineops.js +190 -3
- package/lib/editor/server.js +398 -6
- package/lib/editor/toolbar-model.js +118 -20
- package/lib/editor/wave-codec.js +2631 -0
- package/lib/editor/wave-geometry.js +320 -0
- package/lib/editor/wave-store.js +950 -0
- package/lib/editor/wave-ui.js +2124 -0
- package/lib/md2doc.js +874 -32
- package/package.json +14 -6
- package/vendor/drawio/LICENSE +201 -0
- package/vendor/drawio/NOTICE +109 -0
- package/vendor/drawio/README.md +25 -0
- package/vendor/drawio/viewer-static.min.js +8596 -0
package/lib/editor/inline-md.js
CHANGED
|
@@ -115,29 +115,128 @@
|
|
|
115
115
|
unsupported.push('A');
|
|
116
116
|
return '';
|
|
117
117
|
}
|
|
118
|
-
const label = walkChildren(node.childNodes, unsupported);
|
|
118
|
+
const label = walkChildren(node.childNodes, unsupported).md;
|
|
119
119
|
return '[' + label + '](' + (href || '') + ')';
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
// v3.4.0 / backlog 3 (Ruling T6-7, then a fix-round regression): STRONG
|
|
123
|
+
// and EM each emit a fixed, context-blind '*'-based delimiter for their
|
|
124
|
+
// own tag — '**'/'**' or '*'/'*' — with no awareness of what the
|
|
125
|
+
// accumulated sibling output already ends with. CommonMark scans the
|
|
126
|
+
// *raw character stream*, not the DOM/AST, and a "delimiter run" is a
|
|
127
|
+
// maximal span of identical adjacent characters: when one node's closing
|
|
128
|
+
// '*' is directly followed (no separating character) by the next
|
|
129
|
+
// sibling's opening '*', the two independent single-char delimiter
|
|
130
|
+
// EVENTS silently fuse into one length-2+ RUN in the emitted text, which
|
|
131
|
+
// marked's own flanking/pairing algorithm then matches on its own terms
|
|
132
|
+
// — not necessarily the pairing the DOM implied. Measured (task-8
|
|
133
|
+
// report): DOM `<em><code>code</code></em><em>text</em>` serialized to
|
|
134
|
+
// `*`code`**text*` — the merged `**` is grabbed by marked as its own
|
|
135
|
+
// delimiter run, and the FIRST <em> is dropped entirely, reappearing as
|
|
136
|
+
// two literal '*' characters around the code span on reload.
|
|
137
|
+
//
|
|
138
|
+
// The first version of this fix guarded on ANY trailing '*', which
|
|
139
|
+
// regressed a real journey scenario: `<em>it</em>` immediately followed
|
|
140
|
+
// by `<strong><em>al</em> bold</strong>` — EM's closing 1 star touching
|
|
141
|
+
// STRONG's opening 2+1 stars — serializes WITHOUT any guard to
|
|
142
|
+
// `*it****al* bold**`, which marked already parses correctly as-is
|
|
143
|
+
// (`<em>it</em><strong><em>al</em> bold</strong>`); inserting a comment
|
|
144
|
+
// there changed already-correct bytes for no reason, and
|
|
145
|
+
// editor-journey.test.js caught it. A 100-context sweep (reviewer-built:
|
|
146
|
+
// 5 front contexts × 5 back contexts × the 4 direct EM/STRONG pairings)
|
|
147
|
+
// measured the actual split: SAME-tag adjacency (EM-then-EM,
|
|
148
|
+
// STRONG-then-STRONG) breaks 50/50; CROSS-tag adjacency (EM-then-STRONG,
|
|
149
|
+
// STRONG-then-EM) is correct 50/50 — `*a*` immediately followed by
|
|
150
|
+
// `**b**` is exactly 3 touching stars, which CommonMark's own delimiter
|
|
151
|
+
// matching splits correctly without any help.
|
|
152
|
+
//
|
|
153
|
+
// Fix (task-8 report has the re-measured matrix): guard on the TAG of
|
|
154
|
+
// whichever STRONG/EM most recently closed at THIS sibling level, not on
|
|
155
|
+
// the trailing character. `lastMarkTag` (below) tracks it; a STRONG/EM
|
|
156
|
+
// node inserts `<!-- -->` only when `lastMarkTag` equals its OWN tag.
|
|
157
|
+
// `<!-- -->` contains no '*', so when it does fire it always ends the
|
|
158
|
+
// preceding run and starts a fresh one, regardless of what characters
|
|
159
|
+
// sit on either side — unlike switching to `_`/`__`, which was tried and
|
|
160
|
+
// rejected: `_`-based emphasis carries CommonMark's intraword
|
|
161
|
+
// restriction, and a switched closing `_` glued directly to a following
|
|
162
|
+
// word character (no space) fails to close at all (verified:
|
|
163
|
+
// `*`code`*_text_word` leaves `_text_word` unparsed, while the
|
|
164
|
+
// HTML-comment form parses `text` as EM in the same shape). The comment
|
|
165
|
+
// is inert on read-back too: `walkChildren` only handles nodeType 3
|
|
166
|
+
// (text) and 1 (element) and silently skips anything else (comment nodes
|
|
167
|
+
// are nodeType 8), so it never leaks into canWysiwyg's `unsupported`
|
|
168
|
+
// list or into a later re-serialize — a reparsed document with the
|
|
169
|
+
// comment still in the DOM just re-triggers this same guard on the next
|
|
170
|
+
// save, it does not accumulate.
|
|
171
|
+
//
|
|
172
|
+
// `lastMarkTag` also fixes review Minor 1 (a false trigger on a literal,
|
|
173
|
+
// already-escaped asterisk) as a side effect, with no regex needed: a
|
|
174
|
+
// TEXT node resets `lastMarkTag` to null whenever it contributes any
|
|
175
|
+
// characters at all, because escapeText() unconditionally escapes every
|
|
176
|
+
// literal '*' a user types — a text node's own trailing character is
|
|
177
|
+
// therefore NEVER a live, still-open '*' that a following STRONG/EM
|
|
178
|
+
// could collide with, so there is nothing to compare tags against. The
|
|
179
|
+
// text "a*" (typed literally, no italics intent) followed by an EM was a
|
|
180
|
+
// MORE common false trigger than the real bug under the old last-char
|
|
181
|
+
// check: it inserted a needless `<!-- -->` even though "a\*<em>x</em>"
|
|
182
|
+
// already round-trips correctly on its own.
|
|
183
|
+
//
|
|
184
|
+
// Every OTHER node type that contributes real, non-empty output (CODE,
|
|
185
|
+
// A, BR, DEL/S, U) also resets `lastMarkTag` to null: none of them ever
|
|
186
|
+
// end their own output in a live '*' (backtick, ')'/']]', '>', '~~'
|
|
187
|
+
// respectively), so nothing after them can be touching a star run.
|
|
188
|
+
// Nodes that contribute NOTHING to `out` (an empty text node, a
|
|
189
|
+
// `<span>` carrying attributes, an unrecognized tag pushed to
|
|
190
|
+
// `unsupported`) leave `lastMarkTag` untouched — correctly preserving
|
|
191
|
+
// whatever WAS touching before them, since they added no separating
|
|
192
|
+
// character.
|
|
193
|
+
//
|
|
194
|
+
// DIV and transparent (attribute-less) SPAN are genuine passthroughs —
|
|
195
|
+
// browsers wrap plain runs in bare <span>s, and a top-level <div> line
|
|
196
|
+
// splices its children inline rather than nesting — so `EM` immediately
|
|
197
|
+
// followed by `<span><em>x</em></span>` (or wrapped in a <div>) must
|
|
198
|
+
// still be treated as touching, same-tag EM adjacency: this is exactly
|
|
199
|
+
// the "透明節點覆蓋一點都沒少" requirement the coordinator called out.
|
|
200
|
+
// Both branches therefore propagate the RECURSIVE call's own
|
|
201
|
+
// `lastMarkTag` outward whenever that recursive call actually
|
|
202
|
+
// contributed characters (`inner.md.length`); if it contributed nothing,
|
|
203
|
+
// the DIV/SPAN itself contributed nothing either (SPAN) or only its own
|
|
204
|
+
// leading `<br>` (DIV, non-first segment) — a `<br>` is real, `*`-free
|
|
205
|
+
// output, so it resets to null; truly nothing added at all leaves the
|
|
206
|
+
// incoming `lastMarkTag` untouched, same as any other zero-output node.
|
|
207
|
+
|
|
122
208
|
// Walk a sibling list. A top-level <div> is a contenteditable line-break
|
|
123
209
|
// artifact (browsers split lines with <div> rather than <br>), so each
|
|
124
210
|
// <div> boundary after the first emits a <br> and its children are
|
|
125
|
-
// spliced inline, never nested further.
|
|
211
|
+
// spliced inline, never nested further. Returns `{ md, lastMarkTag }` —
|
|
212
|
+
// `lastMarkTag` is this call's OWN trailing-star-boundary state (see
|
|
213
|
+
// above), needed by DIV/SPAN passthrough and by nothing else: a caller
|
|
214
|
+
// that wraps its own delimiter around a recursive call's content (EM,
|
|
215
|
+
// STRONG, DEL/S, U, the citation label) only ever needs `.md`, because
|
|
216
|
+
// its own outer delimiter is a fixed, known tag regardless of what's
|
|
217
|
+
// nested inside.
|
|
126
218
|
function walkChildren(nodes, unsupported) {
|
|
127
219
|
let out = '';
|
|
128
220
|
let firstSegment = true;
|
|
221
|
+
let lastMarkTag = null;
|
|
129
222
|
for (let i = 0; i < nodes.length; i++) {
|
|
130
223
|
const node = nodes[i];
|
|
131
224
|
if (node.nodeType === 3) {
|
|
132
|
-
|
|
225
|
+
const text = escapeText(node.textContent);
|
|
226
|
+
out += text;
|
|
227
|
+
if (text.length) lastMarkTag = null;
|
|
133
228
|
firstSegment = false;
|
|
134
229
|
continue;
|
|
135
230
|
}
|
|
136
231
|
if (node.nodeType !== 1) continue;
|
|
137
232
|
const name = node.nodeName;
|
|
138
233
|
if (name === 'DIV') {
|
|
139
|
-
|
|
140
|
-
|
|
234
|
+
let added = '';
|
|
235
|
+
if (!firstSegment) added += '<br>';
|
|
236
|
+
const inner = walkChildren(node.childNodes, unsupported);
|
|
237
|
+
added += inner.md;
|
|
238
|
+
out += added;
|
|
239
|
+
if (added.length) lastMarkTag = inner.md.length ? inner.lastMarkTag : null;
|
|
141
240
|
firstSegment = false;
|
|
142
241
|
continue;
|
|
143
242
|
}
|
|
@@ -145,18 +244,24 @@
|
|
|
145
244
|
if (spanHasAttributes(node)) {
|
|
146
245
|
unsupported.push(name);
|
|
147
246
|
} else {
|
|
148
|
-
|
|
247
|
+
const inner = walkChildren(node.childNodes, unsupported);
|
|
248
|
+
out += inner.md;
|
|
249
|
+
if (inner.md.length) lastMarkTag = inner.lastMarkTag;
|
|
149
250
|
}
|
|
150
251
|
firstSegment = false;
|
|
151
252
|
continue;
|
|
152
253
|
}
|
|
153
254
|
if (name === 'STRONG' || name === 'B') {
|
|
154
|
-
out += '
|
|
255
|
+
out += (lastMarkTag === 'STRONG' ? '<!-- -->' : '') +
|
|
256
|
+
'**' + walkChildren(node.childNodes, unsupported).md + '**';
|
|
257
|
+
lastMarkTag = 'STRONG';
|
|
155
258
|
firstSegment = false;
|
|
156
259
|
continue;
|
|
157
260
|
}
|
|
158
261
|
if (name === 'EM' || name === 'I') {
|
|
159
|
-
out += '
|
|
262
|
+
out += (lastMarkTag === 'EM' ? '<!-- -->' : '') +
|
|
263
|
+
'*' + walkChildren(node.childNodes, unsupported).md + '*';
|
|
264
|
+
lastMarkTag = 'EM';
|
|
160
265
|
firstSegment = false;
|
|
161
266
|
continue;
|
|
162
267
|
}
|
|
@@ -166,7 +271,8 @@
|
|
|
166
271
|
// accepted on input (some contenteditable/paste paths produce it)
|
|
167
272
|
// but the toolbar/serializer always speak DEL, matching what marked
|
|
168
273
|
// itself emits.
|
|
169
|
-
out += '~~' + walkChildren(node.childNodes, unsupported) + '~~';
|
|
274
|
+
out += '~~' + walkChildren(node.childNodes, unsupported).md + '~~';
|
|
275
|
+
lastMarkTag = null;
|
|
170
276
|
firstSegment = false;
|
|
171
277
|
continue;
|
|
172
278
|
}
|
|
@@ -181,17 +287,20 @@
|
|
|
181
287
|
// backtick, literal `<u>` typed as plain text is already escaped by
|
|
182
288
|
// the `<` -> `<` rule above, so it can never collide with a real
|
|
183
289
|
// toolbar-made underline.
|
|
184
|
-
out += '<u>' + walkChildren(node.childNodes, unsupported) + '</u>';
|
|
290
|
+
out += '<u>' + walkChildren(node.childNodes, unsupported).md + '</u>';
|
|
291
|
+
lastMarkTag = null;
|
|
185
292
|
firstSegment = false;
|
|
186
293
|
continue;
|
|
187
294
|
}
|
|
188
295
|
if (name === 'CODE') {
|
|
189
296
|
out += serializeCode(node);
|
|
297
|
+
lastMarkTag = null;
|
|
190
298
|
firstSegment = false;
|
|
191
299
|
continue;
|
|
192
300
|
}
|
|
193
301
|
if (name === 'A') {
|
|
194
302
|
out += serializeAnchor(node, unsupported);
|
|
303
|
+
lastMarkTag = null;
|
|
195
304
|
firstSegment = false;
|
|
196
305
|
continue;
|
|
197
306
|
}
|
|
@@ -221,18 +330,19 @@
|
|
|
221
330
|
const hardBreak = typeof node.getAttribute === 'function' &&
|
|
222
331
|
node.getAttribute('data-hard-break') === '1';
|
|
223
332
|
out += hardBreak ? '\\\n' : '<br>';
|
|
333
|
+
lastMarkTag = null;
|
|
224
334
|
firstSegment = false;
|
|
225
335
|
continue;
|
|
226
336
|
}
|
|
227
337
|
unsupported.push(name);
|
|
228
338
|
firstSegment = false;
|
|
229
339
|
}
|
|
230
|
-
return out;
|
|
340
|
+
return { md: out, lastMarkTag };
|
|
231
341
|
}
|
|
232
342
|
|
|
233
343
|
function serializeInline(rootEl) {
|
|
234
344
|
const unsupported = [];
|
|
235
|
-
const md = walkChildren(rootEl.childNodes, unsupported);
|
|
345
|
+
const md = walkChildren(rootEl.childNodes, unsupported).md;
|
|
236
346
|
return { md, unsupported };
|
|
237
347
|
}
|
|
238
348
|
|
package/lib/editor/lineops.js
CHANGED
|
@@ -23,12 +23,48 @@
|
|
|
23
23
|
);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// ── v3.4.0 batch3 Task 7 fix 2: what "saved" is allowed to mean ──────────
|
|
27
|
+
//
|
|
28
|
+
// `_savedDepth` marks WHERE ON `_done` the bytes that are on disk were
|
|
29
|
+
// reached. It was an unconditional index, and an index into a stack that can
|
|
30
|
+
// be rewound is not a fact about the document — it is a second record of one,
|
|
31
|
+
// and it drifts.
|
|
32
|
+
//
|
|
33
|
+
// MEASURED on the shipped v3.3.0 behaviour, with no wave editor anywhere near
|
|
34
|
+
// it: type into a paragraph, commit, Ctrl+S, Ctrl+Z, then make one more
|
|
35
|
+
// ordinary edit. `_done.length` is 1 again and `_savedDepth` is still 1, so
|
|
36
|
+
// `dirtyDepth` is 0 — `documentIsDirty()` answers false, the title dot goes
|
|
37
|
+
// out, the save button greys, `beforeunload` does not fire, and the conflict
|
|
38
|
+
// banner's Reload discards BOTH the undone save and the new edit without
|
|
39
|
+
// asking. The depth arithmetic said "you are standing where you saved"; the
|
|
40
|
+
// user was standing somewhere else entirely, because the branch that led to
|
|
41
|
+
// the save point had been replaced.
|
|
42
|
+
//
|
|
43
|
+
// The invariant this restores is the only one that is worth anything here:
|
|
44
|
+
// **"not dirty" means what is in memory equals what is on disk.** A counter
|
|
45
|
+
// that can pass through zero from below cannot express it, so the marker is
|
|
46
|
+
// INVALIDATED — set to null — the moment history diverges past it, which is
|
|
47
|
+
// exactly the moment a `push()` happens below it. From then on the saved
|
|
48
|
+
// state is not reachable anywhere on this stack and the honest answer to "are
|
|
49
|
+
// we standing on it" is no, until `markSaved()` establishes a new one.
|
|
50
|
+
//
|
|
51
|
+
// Undo alone does NOT invalidate: undo then redo puts the same ops back and
|
|
52
|
+
// the marker still names the same state, which is why the rewind itself is
|
|
53
|
+
// not the divergence — the push that replaces the rewound branch is.
|
|
26
54
|
function UndoStack() {
|
|
27
55
|
this._done = [];
|
|
28
56
|
this._undone = [];
|
|
57
|
+
// null = "the saved state is not on this stack any more". Callers must not
|
|
58
|
+
// read it as a number; ask `isDirty()`.
|
|
29
59
|
this._savedDepth = 0;
|
|
30
60
|
}
|
|
31
61
|
UndoStack.prototype.push = function (op) {
|
|
62
|
+
if (this._savedDepth !== null && this._done.length < this._savedDepth) {
|
|
63
|
+
// History has been rewound past the save point and is now growing a
|
|
64
|
+
// different branch: the ops that produced the saved bytes are gone from
|
|
65
|
+
// this stack and no sequence of undo/redo can get back to them.
|
|
66
|
+
this._savedDepth = null;
|
|
67
|
+
}
|
|
32
68
|
this._done.push(op);
|
|
33
69
|
this._undone.length = 0;
|
|
34
70
|
};
|
|
@@ -60,11 +96,162 @@
|
|
|
60
96
|
const span = { startLine: op.startLine, endLine: op.startLine + op.after.length - 1 };
|
|
61
97
|
return { lines: replaceLines(lines, span.startLine, span.endLine, op.before).lines, op };
|
|
62
98
|
};
|
|
99
|
+
// v3.4.0 batch3 Task 7 fix 1 (F3) — read and write the REDO tail.
|
|
100
|
+
//
|
|
101
|
+
// `push()` clears `_undone`, and that is correct for an ordinary edit: once
|
|
102
|
+
// the document has moved forward, the branch that was undone is gone. It is
|
|
103
|
+
// NOT correct for an edit that is later withdrawn as if it had never
|
|
104
|
+
// happened — `discardTop()` can put the bytes and the depth back, but it has
|
|
105
|
+
// no way to put the branch back, so a caller that discards its own ops
|
|
106
|
+
// silently consumes a redo the user had before it started. MEASURED on the
|
|
107
|
+
// wave editor: type into a paragraph, commit, Ctrl+Z (the edit is redoable),
|
|
108
|
+
// open the wave editor, paint, Escape — and Ctrl+Y then brings nothing back,
|
|
109
|
+
// over a document that is byte-identical to the one that had the redo.
|
|
110
|
+
//
|
|
111
|
+
// So the tail is a value a caller can take before it starts and hand back
|
|
112
|
+
// when it withdraws. Deliberately two plain accessors rather than a
|
|
113
|
+
// "transaction" API: the only caller is `finishWaveSession()`, the copies
|
|
114
|
+
// keep `_undone` unaliased, and an ordinary push still clears it exactly as
|
|
115
|
+
// before.
|
|
116
|
+
UndoStack.prototype.redoTail = function () {
|
|
117
|
+
return this._undone.slice();
|
|
118
|
+
};
|
|
119
|
+
UndoStack.prototype.setRedoTail = function (tail) {
|
|
120
|
+
this._undone = Array.isArray(tail) ? tail.slice() : [];
|
|
121
|
+
};
|
|
122
|
+
// Does memory differ from disk? The one question the product actually asks,
|
|
123
|
+
// and a boolean rather than a distance precisely because a distance is what
|
|
124
|
+
// walked through zero. `documentIsDirty()` in client.js reads this.
|
|
125
|
+
UndoStack.prototype.isDirty = function () {
|
|
126
|
+
return this._savedDepth === null || this._done.length !== this._savedDepth;
|
|
127
|
+
};
|
|
128
|
+
// How far the stack has moved since the save point, for diagnostics and for
|
|
129
|
+
// tests that want to see the shape of a sequence. When the marker has been
|
|
130
|
+
// invalidated there IS no distance — the state it named is not on the stack —
|
|
131
|
+
// so this answers with a value that is deliberately never 0, and callers that
|
|
132
|
+
// only want the yes/no must ask `isDirty()` instead of comparing this.
|
|
63
133
|
Object.defineProperty(UndoStack.prototype, 'dirtyDepth', {
|
|
64
|
-
get() {
|
|
134
|
+
get() {
|
|
135
|
+
if (this._savedDepth === null) return this._done.length + 1;
|
|
136
|
+
return this._done.length - this._savedDepth;
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
// How many ops are on the done-stack. Exposed so a caller that pushed N ops
|
|
140
|
+
// can check that the top N are still its own — a question about the STACK,
|
|
141
|
+
// with no premise about the save marker, which is what makes it composable
|
|
142
|
+
// with `savedDepth` below rather than entangled with it.
|
|
143
|
+
Object.defineProperty(UndoStack.prototype, 'depth', {
|
|
144
|
+
get() { return this._done.length; },
|
|
145
|
+
});
|
|
146
|
+
// WHERE the marker is, or null when the saved state is not on this stack any
|
|
147
|
+
// more. A caller that needs to reason about a range of the stack — "would
|
|
148
|
+
// removing my own N ops destroy the state disk was written from?" — needs the
|
|
149
|
+
// position, not the distance. It is deliberately nullable rather than a
|
|
150
|
+
// sentinel number: arithmetic on it is exactly the mistake this whole rework
|
|
151
|
+
// is about, and `null` makes that arithmetic loud instead of plausible.
|
|
152
|
+
Object.defineProperty(UndoStack.prototype, 'savedDepth', {
|
|
153
|
+
get() { return this._savedDepth; },
|
|
65
154
|
});
|
|
66
|
-
|
|
67
|
-
|
|
155
|
+
/**
|
|
156
|
+
* A receipt for "these are the bytes I am about to write to disk".
|
|
157
|
+
*
|
|
158
|
+
* Taken BEFORE the save request leaves, and handed back to `markSaved()` when
|
|
159
|
+
* the response lands. See markSaved() for the measured race this closes.
|
|
160
|
+
*
|
|
161
|
+
* The receipt is a depth plus the IDENTITY of the op at that depth's
|
|
162
|
+
* boundary. The depth alone is not enough: the user can undo and re-edit
|
|
163
|
+
* during a round trip, and the stack is then back at the same depth holding a
|
|
164
|
+
* different op — measured, that combination marked bytes as saved that disk
|
|
165
|
+
* had never held. Op objects are created fresh by each commit, so identity is
|
|
166
|
+
* an exact fingerprint and costs a reference.
|
|
167
|
+
*
|
|
168
|
+
* ── Why ONE boundary op is enough (fix 4 / R5) ─────────────────────────
|
|
169
|
+
*
|
|
170
|
+
* It looks like an under-check: the receipt fingerprints a single op, yet it
|
|
171
|
+
* is trusted to mean "the whole prefix `_done[0 .. depth-1]` is unchanged".
|
|
172
|
+
* It holds, and the argument is worth having in one place rather than
|
|
173
|
+
* reconstructed from four:
|
|
174
|
+
*
|
|
175
|
+
* 1. The timeline is only ever edited at its TOP. `push()` appends,
|
|
176
|
+
* `undo()` moves the top entry onto `_undone`, `redo()` moves it back,
|
|
177
|
+
* and `discardTop()` drops it. Nothing in this file writes at an
|
|
178
|
+
* interior index — there is no splice, no sort, no assignment to
|
|
179
|
+
* `_done[i]`. So a prefix can only change by first being exposed, i.e.
|
|
180
|
+
* by everything above it coming off.
|
|
181
|
+
* 2. Therefore, if the op at index `depth-1` is still that same object,
|
|
182
|
+
* nothing ever popped BELOW `depth-1` in between — had it, that op would
|
|
183
|
+
* have come off too, and the only way an identical object returns is
|
|
184
|
+
* `redo()`, which also restores every entry below it unchanged (it pops
|
|
185
|
+
* `_undone` in the exact reverse order `undo()` pushed it).
|
|
186
|
+
* 3. Identity, not equality: a commit builds a fresh `{startLine, endLine,
|
|
187
|
+
* before, after}` literal every time, so two different edits can never
|
|
188
|
+
* share an object, and a re-edit that happens to produce the same bytes
|
|
189
|
+
* is still a different object — which is the conservative direction.
|
|
190
|
+
* 4. `setRedoTail()` is the one place `_undone` is replaced wholesale, and
|
|
191
|
+
* it is handed back exactly the array `redoTail()` copied out, so the
|
|
192
|
+
* objects in it are the same objects. A wave session's withdrawal
|
|
193
|
+
* therefore restores the timeline, it does not fabricate one.
|
|
194
|
+
*
|
|
195
|
+
* The check is conservative where it is imperfect: anything it cannot prove
|
|
196
|
+
* unchanged reads as changed, and a refused receipt leaves the document
|
|
197
|
+
* DIRTY. Its failure direction is over-warning.
|
|
198
|
+
*/
|
|
199
|
+
UndoStack.prototype.saveToken = function () {
|
|
200
|
+
return {
|
|
201
|
+
depth: this._done.length,
|
|
202
|
+
op: this._done.length === 0 ? null : this._done[this._done.length - 1],
|
|
203
|
+
};
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Where on the timeline `token.depth - 1` now lives, whether it has since been
|
|
208
|
+
* undone or not — `undefined` when that index is off the end of both stacks.
|
|
209
|
+
*
|
|
210
|
+
* An op that was undone during a save round trip is still the same op and can
|
|
211
|
+
* still be redone back into place, so it must NOT read as a divergence; it
|
|
212
|
+
* has merely moved from `_done` onto `_undone`, which is a LIFO, hence the
|
|
213
|
+
* mirrored index.
|
|
214
|
+
*/
|
|
215
|
+
function timelineOpAt(stack, index) {
|
|
216
|
+
if (index < 0) return null;
|
|
217
|
+
if (index < stack._done.length) return stack._done[index];
|
|
218
|
+
const j = stack._done.length + stack._undone.length - 1 - index;
|
|
219
|
+
if (j < 0 || j >= stack._undone.length) return undefined;
|
|
220
|
+
return stack._undone[j];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Record that disk now holds the bytes the given receipt describes.
|
|
225
|
+
*
|
|
226
|
+
* MEASURED before the receipt existed, with no wave editor involved: type,
|
|
227
|
+
* commit, Ctrl+S, then press Ctrl+Z while the request is still in flight. The
|
|
228
|
+
* 200 arrived and marked whatever depth happened to exist AT THAT MOMENT —
|
|
229
|
+
* the post-undo one — as the saved point, so `isDirty()` answered false over a
|
|
230
|
+
* document that differs from disk. All three nets failed together there: the
|
|
231
|
+
* title dot, the unload guard, AND the conflict check (the response had just
|
|
232
|
+
* advanced `mtimeMs`, so the next save would not 409 either). Closing the tab
|
|
233
|
+
* left on disk an edit the user had explicitly undone.
|
|
234
|
+
*
|
|
235
|
+
* With a receipt the marker is established against the depth the bytes were
|
|
236
|
+
* taken from, not the depth the reply happened to find — and only if the
|
|
237
|
+
* timeline at that boundary is still the same one. Where it is not, the saved
|
|
238
|
+
* state is not reachable on this stack at all and the marker is invalidated,
|
|
239
|
+
* which is the same answer `push()` gives for the same reason.
|
|
240
|
+
*
|
|
241
|
+
* No argument still means "right here, right now", which is what every
|
|
242
|
+
* synchronous caller and every test wants.
|
|
243
|
+
*/
|
|
244
|
+
UndoStack.prototype.markSaved = function (token) {
|
|
245
|
+
if (token === undefined || token === null) {
|
|
246
|
+
this._savedDepth = this._done.length;
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
if (timelineOpAt(this, token.depth - 1) !== token.op) {
|
|
250
|
+
this._savedDepth = null;
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
this._savedDepth = token.depth;
|
|
254
|
+
return true;
|
|
68
255
|
};
|
|
69
256
|
|
|
70
257
|
return { replaceLines, insertLines, shiftBlocks, UndoStack };
|