@helping-ai-workflow/md2doc 3.17.7 → 3.17.8
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/lib/editor/client.js +40 -3
- package/lib/editor/inline-md.js +8 -0
- package/lib/editor/paste-md.js +14 -0
- package/lib/editor/table-md.js +358 -43
- package/package.json +1 -1
package/lib/editor/client.js
CHANGED
|
@@ -17387,7 +17387,16 @@
|
|
|
17387
17387
|
// after a space (`**a **`) is not a closing delimiter in markdown.
|
|
17388
17388
|
const r = document.createRange();
|
|
17389
17389
|
r.selectNodeContents(cell);
|
|
17390
|
-
if (trimRangeToText(r))
|
|
17390
|
+
if (trimRangeToText(r)) {
|
|
17391
|
+
// v3.17.8 D2 (final review I2): a cell whose text is one code span
|
|
17392
|
+
// (a signal-name column) trims to the code's own text node, and the
|
|
17393
|
+
// wrap landed inside the <code>, which the save drops. The mark goes
|
|
17394
|
+
// around the code instead (`**`b`**`), as it goes around every other
|
|
17395
|
+
// cell's text.
|
|
17396
|
+
const code = tag !== 'CODE' ? wholeSelectionMark(r, 'CODE', cell) : null;
|
|
17397
|
+
if (code) r.selectNode(code);
|
|
17398
|
+
wrapRangeIn(r, tag, cell);
|
|
17399
|
+
}
|
|
17391
17400
|
}
|
|
17392
17401
|
});
|
|
17393
17402
|
snapBurstIfActive(burst.editEl, 'range-mark');
|
|
@@ -21616,6 +21625,15 @@
|
|
|
21616
21625
|
|
|
21617
21626
|
// Applies the toggle policy for a plain mark type (STRONG/EM/CODE/DEL/U —
|
|
21618
21627
|
// link has its own entry point below because it also needs a URL).
|
|
21628
|
+
// v3.17.8 D2: Markdown cannot hold a mark inside a code span (serializeCode()
|
|
21629
|
+
// writes the code's textContent), so a WRAP of anything but the code itself is
|
|
21630
|
+
// refused when the selection lies wholly inside one <code>. One helper, read
|
|
21631
|
+
// by selectionMarkStates() (the buttons' 'inert') and by the actions (the
|
|
21632
|
+
// keyboard reaches them without a button), so the two cannot drift. Unwrapping
|
|
21633
|
+
// a mark that wraps the code from outside stays allowed: that is spellable.
|
|
21634
|
+
function wrapRefusedInCode(range, tag, root) {
|
|
21635
|
+
return tag !== 'CODE' && !!wholeSelectionMark(range, 'CODE', root);
|
|
21636
|
+
}
|
|
21619
21637
|
function applyMarkToggle(tag) {
|
|
21620
21638
|
if (cellRangeBurst()) { applyMarkToCellRange(tag); return; }
|
|
21621
21639
|
const root = selToolbarEditEl;
|
|
@@ -21646,6 +21664,11 @@
|
|
|
21646
21664
|
return;
|
|
21647
21665
|
}
|
|
21648
21666
|
if (!trimRangeToText(range)) return; // 全空白/collapsed 選取:no-op
|
|
21667
|
+
// v3.17.8 D2: judged on the TRIMMED range, as the wrap sees it. A double
|
|
21668
|
+
// click on a code span's last word also selects the space after it; the
|
|
21669
|
+
// untrimmed range then ends outside the <code>, the trim pulls it back in,
|
|
21670
|
+
// and the wrap landed inside the code (final review I1).
|
|
21671
|
+
if (wrapRefusedInCode(range, tag, root)) return;
|
|
21649
21672
|
snapBurstIfActive(root, 'mark-pre');
|
|
21650
21673
|
reselectAndReposition(wrapRangeIn(range, tag, root));
|
|
21651
21674
|
snapBurstIfActive(root, 'mark');
|
|
@@ -21990,6 +22013,7 @@
|
|
|
21990
22013
|
// An all-whitespace range trims to nothing: bail before the field opens,
|
|
21991
22014
|
// or accepting it would wrap a bare space in an <a>.
|
|
21992
22015
|
if (!trimRangeToText(range)) return;
|
|
22016
|
+
if (wrapRefusedInCode(range, 'A', root)) return; // v3.17.8 D2
|
|
21993
22017
|
openLinkField({ root, range, existing: null, prefill: '' });
|
|
21994
22018
|
}
|
|
21995
22019
|
|
|
@@ -23382,7 +23406,9 @@
|
|
|
23382
23406
|
// <strong> was gone, the paragraph's text was unchanged, and the file
|
|
23383
23407
|
// saved back with no `**` in it. So drawing every button inert there
|
|
23384
23408
|
// would grey out a button that acts.
|
|
23385
|
-
// *
|
|
23409
|
+
// * (v3.17.8 D2) A selection wholly inside one <code> makes every other
|
|
23410
|
+
// tag's would-wrap state 'inert' too, 'A' included: wrapRefusedInCode().
|
|
23411
|
+
// * Otherwise 'A' never reaches it. The link button does not go through
|
|
23386
23412
|
// applyMarkToggle() at all — applyLinkToggle() has the same first two
|
|
23387
23413
|
// branches, and since v3.4.0 its third refuses an all-whitespace
|
|
23388
23414
|
// selection through trimRangeToText() before the URL field opens. The
|
|
@@ -23405,11 +23431,13 @@
|
|
|
23405
23431
|
if (!hasFormattableSelection(sel)) return null;
|
|
23406
23432
|
const root = selToolbarEditEl;
|
|
23407
23433
|
const range = sel.getRangeAt(0);
|
|
23408
|
-
const
|
|
23434
|
+
const trimmed = range.cloneRange();
|
|
23435
|
+
const wrappable = trimRangeToText(trimmed);
|
|
23409
23436
|
const out = {};
|
|
23410
23437
|
for (const tag of MARK_TAGS) {
|
|
23411
23438
|
if (wholeSelectionMark(range, tag, root)) out[tag] = 'whole';
|
|
23412
23439
|
else if (overlappingMarks(range, tag, root).length > 0) out[tag] = 'partial';
|
|
23440
|
+
else if (wrapRefusedInCode(wrappable ? trimmed : range, tag, root)) out[tag] = 'inert'; // v3.17.8 D2, trimmed as the wrap sees it
|
|
23413
23441
|
else if (wrappable || tag === 'A') out[tag] = 'none';
|
|
23414
23442
|
else out[tag] = 'inert';
|
|
23415
23443
|
}
|
|
@@ -26109,6 +26137,15 @@
|
|
|
26109
26137
|
// file kept the plain word. This listener is registered ahead of the
|
|
26110
26138
|
// `native-format-pre` one below (both capture phase on document, so they
|
|
26111
26139
|
// run in that order), so the burst exists when that one snaps it.
|
|
26140
|
+
// v3.17.8 D2: the browser's own Ctrl+B / I / U must not wrap inside a code
|
|
26141
|
+
// span either (it would draw <b> in a <code> that the save then drops).
|
|
26142
|
+
document.addEventListener('beforeinput', (e) => {
|
|
26143
|
+
if (!NATIVE_FORMAT_INPUT_TYPES.has(e.inputType) || e.inputType === 'formatRemove') return;
|
|
26144
|
+
const surface = e.target && e.target.closest ? e.target.closest('.ed-wys-armed, .ed-wys-cell') : null;
|
|
26145
|
+
const sel = window.getSelection();
|
|
26146
|
+
if (!surface || !sel || !sel.rangeCount || sel.isCollapsed) return;
|
|
26147
|
+
if (wholeSelectionMark(sel.getRangeAt(0), 'CODE', surface)) e.preventDefault();
|
|
26148
|
+
}, true);
|
|
26112
26149
|
document.addEventListener('beforeinput', (e) => {
|
|
26113
26150
|
if (e.defaultPrevented) return;
|
|
26114
26151
|
const t = e.inputType;
|
package/lib/editor/inline-md.js
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
return ch !== undefined && /\w/.test(ch);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
const REFERENCE_SHAPE = /^&#?\w+;/;
|
|
28
29
|
function escapeText(s) {
|
|
29
30
|
let out = '';
|
|
30
31
|
for (let i = 0; i < s.length; i++) {
|
|
@@ -36,6 +37,13 @@
|
|
|
36
37
|
if (c === ']') { out += '\\]'; continue; }
|
|
37
38
|
if (c === '<') { out += '<'; continue; }
|
|
38
39
|
if (c === '~') { out += '\\~'; continue; }
|
|
40
|
+
// v3.17.8 D3: an `&` that starts something shaped like a character
|
|
41
|
+
// reference (`&`, `&`, `&x_1;`) is written `\&`, or the reader
|
|
42
|
+
// decodes what the person typed (`a & b` showed `a & b`). The shape
|
|
43
|
+
// is marked's own, `&#?\w+;` (its escape() leaves exactly that alone),
|
|
44
|
+
// so it covers every reference a browser could decode. Any other `&`
|
|
45
|
+
// (`AT&T`, `©` with no `;`) is shown as typed and stays bare.
|
|
46
|
+
if (c === '&' && REFERENCE_SHAPE.test(s.slice(i, i + 40))) { out += '\\&'; continue; }
|
|
39
47
|
if (c === '_') {
|
|
40
48
|
const prev = s[i - 1];
|
|
41
49
|
const next = s[i + 1];
|
package/lib/editor/paste-md.js
CHANGED
|
@@ -381,6 +381,20 @@ function htmlToMarkdown(html) {
|
|
|
381
381
|
codeBlockStyle: 'fenced',
|
|
382
382
|
emDelimiter: '*'
|
|
383
383
|
});
|
|
384
|
+
// v3.17.8 D3: turndown's escape() covers Markdown punctuation only, so
|
|
385
|
+
// pasted TEXT that spells HTML came back as markup: `<b>x</b>` turned bold,
|
|
386
|
+
// `<!-- c -->` vanished, `&` showed as `&`. A `<` that could open a tag,
|
|
387
|
+
// a comment or an autolink is written `<`, and an `&` shaped like a
|
|
388
|
+
// character reference is written `\&` -- the spellings inline-md.js's
|
|
389
|
+
// escapeText() gives typed text. A bare `<` (`1 < 2`) or `&` (`AT&T`) is
|
|
390
|
+
// already shown as typed and stays bare. Code is not escaped by turndown
|
|
391
|
+
// at all, and needs none: a code span is literal.
|
|
392
|
+
var baseEscape = service.escape;
|
|
393
|
+
service.escape = function (s) {
|
|
394
|
+
return baseEscape.call(this, s)
|
|
395
|
+
.replace(/&(?=#?\w+;)/g, '\\&')
|
|
396
|
+
.replace(/<(?=[A-Za-z\/!?])/g, '<');
|
|
397
|
+
};
|
|
384
398
|
addStrikethroughRule(service);
|
|
385
399
|
addFlankingEmphasisRules(service);
|
|
386
400
|
addTableRules(service);
|
package/lib/editor/table-md.js
CHANGED
|
@@ -29,14 +29,14 @@
|
|
|
29
29
|
// nothing to do with text alignment. Reading them as alignment would be a
|
|
30
30
|
// bug; they are deliberately never consulted below.
|
|
31
31
|
//
|
|
32
|
-
// ── Emission form (spec §4 / task-5 brief, amended by v3.0.1)
|
|
33
|
-
// There are TWO emission forms
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
// form A.
|
|
32
|
+
// ── Emission form (spec §4 / task-5 brief, amended by v3.0.1, v3.17.8) ─
|
|
33
|
+
// There are TWO emission forms for the padding. Form B applies when
|
|
34
|
+
// serializeTable() was handed an `originalMd` it could turn into a width
|
|
35
|
+
// source: columnWidthsOf() parsed it (not ragged) and the forward header
|
|
36
|
+
// scan in serializeTable() found no MOVED column. Since v3.0.2 a renamed
|
|
37
|
+
// or dropped column no longer voids it; only that column loses its width.
|
|
38
|
+
// No second argument, an unparseable or ragged original, a moved column:
|
|
39
|
+
// all of them land on form A.
|
|
40
40
|
//
|
|
41
41
|
// A. MINIMAL form (pre-v3.0.1, unchanged, and still what
|
|
42
42
|
// `serializeTable(tableEl)` with no second argument emits — byte for
|
|
@@ -60,18 +60,30 @@
|
|
|
60
60
|
// separators Prettier and VS Code emit ('| ----- | --- |') survive a
|
|
61
61
|
// no-op instead of being rebuilt flush ('|-------|-----|').
|
|
62
62
|
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
63
|
+
// Cell bytes (v3.17.8 F1, both forms): a cell whose original field reads
|
|
64
|
+
// the same as its current content (see readsSame()) is written with the
|
|
65
|
+
// original field's bytes, trimmed; every other cell gets the canonical
|
|
66
|
+
// spelling below. A header row or body row whose cells were ALL reused, in
|
|
67
|
+
// the original columns, with no column widened past that line's own
|
|
68
|
+
// fields, is the original line itself (trimmed), odd padding like '|a|b|'
|
|
69
|
+
// included. Body rows are paired with original rows by pairBodyRows(), so a
|
|
70
|
+
// row inserted, deleted or moved leaves the others as they were. With no
|
|
71
|
+
// parser nothing is reused, and the output is what it was before v3.17.8.
|
|
72
|
+
//
|
|
73
|
+
// Canonical spelling: a literal '|' inside a cell's text -> '|'; a
|
|
74
|
+
// cell newline is the literal '<br>' inline-md.js's
|
|
65
75
|
// serializeInline() already emits for both a real <br> node and a
|
|
66
76
|
// contenteditable <div> line-split boundary — AND (final-review Finding 1
|
|
67
77
|
// defense-in-depth) any raw '\n'/'\r' that still reaches a text node is
|
|
68
78
|
// collapsed to '<br>' here too, via escapeNewlines() below, so a cell can
|
|
69
79
|
// never split serializeTable()'s output into more physical lines than
|
|
70
|
-
// it has rows.
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
80
|
+
// it has rows. A '|' inside a <code> span is written as '\|' instead
|
|
81
|
+
// (v3.17.6) — see codePipesOf() below; the one code text that has no
|
|
82
|
+
// table spelling reports 'CODE' unsupported. A reused field never holds a
|
|
83
|
+
// newline or an unescaped '|' (it is one field of one source line). No
|
|
84
|
+
// trailing whitespace on any emitted line: a built row or separator is
|
|
85
|
+
// '|'-delimited fields, and an original line is trimmed and, to be read
|
|
86
|
+
// at all, ends in an unescaped '|' (splitFields()).
|
|
75
87
|
//
|
|
76
88
|
// Constrained (same as inline-md.js, and for the same reason: the node
|
|
77
89
|
// test drives this with the hand-rolled element stub from
|
|
@@ -190,22 +202,35 @@
|
|
|
190
202
|
// width of 3 is indistinguishable from this serializer's own minimal
|
|
191
203
|
// form, which is exactly 3 on every row by construction).
|
|
192
204
|
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
// the split must not break on an escaped delimiter.
|
|
205
|
+
// A hand-written table may use '\|' anywhere, and since v3.17.8 so may
|
|
206
|
+
// this serializer's output (a reused field keeps the author's '\|'), so
|
|
207
|
+
// the split must not break on an escaped delimiter. Escaped means what
|
|
208
|
+
// marked's splitCells() means (v3.17.8 F3): an ODD run of backslashes
|
|
209
|
+
// before the '|'. A backslash and the character after it are taken as one
|
|
210
|
+
// pair, so '\\|' is an escaped backslash and then a delimiter; before this
|
|
211
|
+
// the split read that '|' as escaped, the row came out one field short and
|
|
212
|
+
// the whole table lost its width memory. A line whose last '|' is escaped
|
|
213
|
+
// has no closing delimiter and is not a row this can read (see the end).
|
|
196
214
|
function splitFields(line) {
|
|
197
215
|
const t = line.trim();
|
|
198
|
-
if (t.charAt(0) !== '|' || t.
|
|
199
|
-
const inner = t.slice(1, -1);
|
|
216
|
+
if (t.charAt(0) !== '|' || t.length < 2) return null;
|
|
200
217
|
const out = [];
|
|
201
218
|
let cur = '';
|
|
202
|
-
for (let i =
|
|
203
|
-
const c =
|
|
204
|
-
if (c === '\\' &&
|
|
219
|
+
for (let i = 1; i < t.length; i++) {
|
|
220
|
+
const c = t.charAt(i);
|
|
221
|
+
if (c === '\\' && i + 1 < t.length) { cur += c + t.charAt(i + 1); i += 1; continue; }
|
|
205
222
|
if (c === '|') { out.push(cur); cur = ''; continue; }
|
|
206
223
|
cur += c;
|
|
207
224
|
}
|
|
208
|
-
|
|
225
|
+
// A line whose last '|' is escaped (`| p | q \|`) has no closing
|
|
226
|
+
// delimiter: not a row this can read, so it is never reused and the table
|
|
227
|
+
// has no width memory. v3.17.8's final-review round 1 split it the old way
|
|
228
|
+
// instead (last '|' closes the row) to keep the width memory, and the
|
|
229
|
+
// re-review found that this loses an edit: the field ` q \` read alone is
|
|
230
|
+
// `q \`, but in its line it reads `q |`, so an edit from `q |` to `q \`
|
|
231
|
+
// read "the same" and the original line came back (N1). Width memory
|
|
232
|
+
// for such a rare table is not worth that.
|
|
233
|
+
if (cur !== '') return null;
|
|
209
234
|
return out;
|
|
210
235
|
}
|
|
211
236
|
|
|
@@ -268,6 +293,231 @@
|
|
|
268
293
|
return { widths, headerFields: first.map((f) => f.trim()), sepFields };
|
|
269
294
|
}
|
|
270
295
|
|
|
296
|
+
// ── v3.17.8 F1: an untouched cell keeps its bytes ─────────────────────
|
|
297
|
+
// Every cell is serialized from the DOM, so a cell nobody edited comes
|
|
298
|
+
// back in this file's canonical spelling: a plain '[' as '\[', a
|
|
299
|
+
// plain-text '\|' as '|'. The meaning is the same; the git diff is
|
|
300
|
+
// not. So a current cell reuses its ORIGINAL field (trimmed) when the two
|
|
301
|
+
// spellings READ the same, and that is decided by parsing, not by rules:
|
|
302
|
+
// a private `new Marked({ gfm: true, breaks: false })` (the same family as
|
|
303
|
+
// inline-md.js's getInlineParser(); server.js injects marked before the
|
|
304
|
+
// editor modules) renders each spelling as the one cell of a one-column
|
|
305
|
+
// table, and the two cell HTMLs are compared after character references
|
|
306
|
+
// in their text are decoded (marked passes '|' through, the browser
|
|
307
|
+
// shows '|'). A cell is parsed once per string (readCache).
|
|
308
|
+
//
|
|
309
|
+
// The plain parser has none of md2doc's sub / sup / KaTeX extensions, so
|
|
310
|
+
// an original holding an unescaped '~', '^' or '$' is reused only when it
|
|
311
|
+
// is byte-equal to the canonical spelling (R5). No parser (a page without
|
|
312
|
+
// marked, or setCellParser(null)): nothing is reused and every byte is
|
|
313
|
+
// what the serializer wrote before v3.17.8.
|
|
314
|
+
let cellParser; // undefined: not looked up yet; null: none available
|
|
315
|
+
const readCache = new Map();
|
|
316
|
+
function getCellParser() {
|
|
317
|
+
if (cellParser !== undefined) return cellParser;
|
|
318
|
+
cellParser = null;
|
|
319
|
+
try {
|
|
320
|
+
let lib = null;
|
|
321
|
+
if (typeof module === 'object' && module.exports && typeof require === 'function') lib = require('marked');
|
|
322
|
+
else if (typeof self !== 'undefined' && self.marked) lib = self.marked;
|
|
323
|
+
if (lib && typeof lib.Marked === 'function') {
|
|
324
|
+
const m = new lib.Marked({ gfm: true, breaks: false });
|
|
325
|
+
cellParser = (s) => m.parse(s);
|
|
326
|
+
}
|
|
327
|
+
} catch (e) {
|
|
328
|
+
cellParser = null;
|
|
329
|
+
}
|
|
330
|
+
return cellParser;
|
|
331
|
+
}
|
|
332
|
+
// Tests swap the parser: a function (markdown -> HTML), null to switch
|
|
333
|
+
// reuse off, or nothing to go back to the lookup above.
|
|
334
|
+
function setCellParser(fn) {
|
|
335
|
+
cellParser = typeof fn === 'function' ? fn : (fn === null ? null : undefined);
|
|
336
|
+
readCache.clear();
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const CELL_HEAD = '<table>\n<thead>\n<tr>\n<th>h</th>\n</tr>\n</thead>\n<tbody><tr>\n<td>';
|
|
340
|
+
const CELL_TAIL = '</td>\n</tr>\n</tbody></table>\n';
|
|
341
|
+
const NAMED_REF = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
|
|
342
|
+
// A named reference outside NAMED_REF cannot be decoded here, so a cell
|
|
343
|
+
// holding one has no reading at all (final review I3): left as it was, the
|
|
344
|
+
// `&` was re-escaped and `©` (shows ©) read the same as the typed
|
|
345
|
+
// text `\©` (shows ©), so an edit to that text was overwritten
|
|
346
|
+
// by the original bytes. Such a cell is now reused only when byte-equal.
|
|
347
|
+
const UNKNOWN_REF = {};
|
|
348
|
+
function decodeRef(m, dec, hex, name) {
|
|
349
|
+
if (name !== undefined) {
|
|
350
|
+
if (NAMED_REF[name] !== undefined) return NAMED_REF[name];
|
|
351
|
+
throw UNKNOWN_REF;
|
|
352
|
+
}
|
|
353
|
+
const n = dec !== undefined ? parseInt(dec, 10) : parseInt(hex, 16);
|
|
354
|
+
return n === 0 || n > 0x10ffff || (n >= 0xd800 && n <= 0xdfff) ? '�' : String.fromCodePoint(n);
|
|
355
|
+
}
|
|
356
|
+
// Text between tags: references decoded, then & < > " re-escaped, so two
|
|
357
|
+
// spellings of one character give one string. Tags are left as they are.
|
|
358
|
+
// HTML comments are dropped first: nothing shows them, the editor's DOM
|
|
359
|
+
// walk skips them (so the canonical spelling never has one), and
|
|
360
|
+
// inline-md.js's own read-back (htmlMarks()) skips them too. That keeps
|
|
361
|
+
// an untouched cell's comments, e.g. the `<!-- [[source, Cl.x]] -->`
|
|
362
|
+
// citation anchors in paperwork's hwe1 tables, which the canonical
|
|
363
|
+
// spelling would drop.
|
|
364
|
+
function normalizeCellHtml(html) {
|
|
365
|
+
return html.replace(/<!--[\s\S]*?-->/g, '').split(/(<[^>]*>)/).map((part, i) => (i % 2 ? part : part
|
|
366
|
+
.replace(/&(?:#([0-9]+)|#[xX]([0-9a-fA-F]+)|([A-Za-z][A-Za-z0-9]*));/g, decodeRef)
|
|
367
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'))).join('');
|
|
368
|
+
}
|
|
369
|
+
// The normalized HTML of `field` as the one cell of a one-column table, or
|
|
370
|
+
// null when it is not read as exactly that (then it never reads the same
|
|
371
|
+
// as anything).
|
|
372
|
+
function cellReading(parse, field) {
|
|
373
|
+
if (readCache.has(field)) return readCache.get(field);
|
|
374
|
+
let v = null;
|
|
375
|
+
try {
|
|
376
|
+
const html = parse('| h |\n| - |\n| ' + field + ' |');
|
|
377
|
+
if (typeof html === 'string' && html.startsWith(CELL_HEAD) && html.endsWith(CELL_TAIL)) {
|
|
378
|
+
const inner = html.slice(CELL_HEAD.length, html.length - CELL_TAIL.length);
|
|
379
|
+
if (inner.indexOf('</td>') === -1) v = normalizeCellHtml(inner);
|
|
380
|
+
}
|
|
381
|
+
} catch (e) {
|
|
382
|
+
v = null;
|
|
383
|
+
}
|
|
384
|
+
// FIFO, not a clear: a table larger than the cap read itself cold on every
|
|
385
|
+
// commit when the whole cache was dropped (final review M1).
|
|
386
|
+
if (readCache.size >= READ_CACHE_MAX) readCache.delete(readCache.keys().next().value);
|
|
387
|
+
readCache.set(field, v);
|
|
388
|
+
return v;
|
|
389
|
+
}
|
|
390
|
+
const READ_CACHE_MAX = 100000;
|
|
391
|
+
// One string per cell such that readsSame(a, b) === (readingKey(a) ===
|
|
392
|
+
// readingKey(b)): a cell with a reading is keyed by it, one without by its
|
|
393
|
+
// bytes, so it only ever pairs with itself. pairBodyRows() hashes rows on it.
|
|
394
|
+
function readingKey(parse, x) {
|
|
395
|
+
if (!parse) return 'B' + x;
|
|
396
|
+
const r = cellReading(parse, x);
|
|
397
|
+
return r === null ? 'B' + x : 'R' + r;
|
|
398
|
+
}
|
|
399
|
+
function readsSame(parse, a, b) {
|
|
400
|
+
if (a === b) return true;
|
|
401
|
+
const ra = cellReading(parse, a);
|
|
402
|
+
return ra !== null && ra === cellReading(parse, b);
|
|
403
|
+
}
|
|
404
|
+
// '~' (sub), '^' (sup) or '$' (KaTeX) after an even backslash run (R5).
|
|
405
|
+
const EXTENSION_CHAR = /(?:^|[^\\])(?:\\\\)*[~^$]/;
|
|
406
|
+
|
|
407
|
+
// The original table's rows for reuse: { header, body } where `header` is
|
|
408
|
+
// { fields, line } and `body` holds one { fields, line } per body row, or
|
|
409
|
+
// null for a line splitFields() cannot read (it is then never paired).
|
|
410
|
+
// `line` is the source line trimmed. Unlike columnWidthsOf(), a ragged row
|
|
411
|
+
// does not void the table: reuse is decided per cell. null when there is
|
|
412
|
+
// no original or its header line is not a row.
|
|
413
|
+
function originalRowsOf(originalMd) {
|
|
414
|
+
if (typeof originalMd !== 'string' || originalMd === '') return null;
|
|
415
|
+
const rows = originalMd.split('\n').filter((l) => l.trim() !== '');
|
|
416
|
+
if (rows.length < 2) return null;
|
|
417
|
+
const row = (l) => { const f = splitFields(l); return f ? { fields: f, line: l.trim() } : null; };
|
|
418
|
+
const header = row(rows[0]);
|
|
419
|
+
if (!header) return null;
|
|
420
|
+
return { header, body: rows.slice(2).map(row) };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Pairs each current body row with an original body row (an index into
|
|
424
|
+
// `origBody`, or -1). A row "matches" when every mapped cell reads the
|
|
425
|
+
// same. (1) a forward greedy scan pairs matching rows in order — the
|
|
426
|
+
// anchors; (2) a row left over that matches some unused original anywhere
|
|
427
|
+
// is paired with it (a moved row); (3) the rows still left between two
|
|
428
|
+
// anchors are paired in order with the unused originals between the same
|
|
429
|
+
// anchors — that is an edited row, whose other cells are then reused one
|
|
430
|
+
// by one. A wrong pair only costs preserved bytes: every reuse is gated by
|
|
431
|
+
// readsSame() again, cell by cell.
|
|
432
|
+
// Linear in rows (final review M1: the scans were quadratic, 3000 rows
|
|
433
|
+
// that all differ took 3 s): each row is hashed on its mapped cells'
|
|
434
|
+
// readingKey(), so "matches" is one Map lookup.
|
|
435
|
+
function pairBodyRows(curRows, origBody, colMap, keyOf) {
|
|
436
|
+
const mapped = [];
|
|
437
|
+
colMap.forEach((k, i) => { if (k !== undefined) mapped.push(i); });
|
|
438
|
+
const pair = new Array(curRows.length).fill(-1);
|
|
439
|
+
if (mapped.length === 0) return pair;
|
|
440
|
+
const SEP = '\u0000';
|
|
441
|
+
const origKey = origBody.map((o) => {
|
|
442
|
+
if (!o) return null;
|
|
443
|
+
const parts = [];
|
|
444
|
+
for (let x = 0; x < mapped.length; x++) {
|
|
445
|
+
const f = o.fields[colMap[mapped[x]]];
|
|
446
|
+
if (f === undefined) return null;
|
|
447
|
+
parts.push(keyOf(f.trim()));
|
|
448
|
+
}
|
|
449
|
+
return parts.join(SEP);
|
|
450
|
+
});
|
|
451
|
+
const curKey = curRows.map((row) => mapped.map((i) => keyOf(row[i])).join(SEP));
|
|
452
|
+
// key -> { list: ascending original indices, p1: step-1 cursor, p2: step-2 cursor }
|
|
453
|
+
const byKey = new Map();
|
|
454
|
+
origKey.forEach((k, q) => {
|
|
455
|
+
if (k === null) return;
|
|
456
|
+
let e = byKey.get(k);
|
|
457
|
+
if (!e) { e = { list: [], p1: 0, p2: 0 }; byKey.set(k, e); }
|
|
458
|
+
e.list.push(q);
|
|
459
|
+
});
|
|
460
|
+
const used = new Array(origBody.length).fill(false);
|
|
461
|
+
const anchor = new Array(curRows.length).fill(false);
|
|
462
|
+
// (1) forward greedy: the first unused match at or after j. Every original
|
|
463
|
+
// used in this step lies before j, so a cursor that skips indices < j
|
|
464
|
+
// finds it, and it only moves forward.
|
|
465
|
+
let j = 0;
|
|
466
|
+
for (let c = 0; c < curRows.length; c++) {
|
|
467
|
+
const e = byKey.get(curKey[c]);
|
|
468
|
+
if (!e) continue;
|
|
469
|
+
while (e.p1 < e.list.length && e.list[e.p1] < j) e.p1++;
|
|
470
|
+
if (e.p1 < e.list.length) {
|
|
471
|
+
const q = e.list[e.p1++];
|
|
472
|
+
pair[c] = q; used[q] = true; anchor[c] = true; j = q + 1;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
// (2) a leftover row that matches an unused original anywhere (moved).
|
|
476
|
+
for (let c = 0; c < curRows.length; c++) {
|
|
477
|
+
if (pair[c] !== -1) continue;
|
|
478
|
+
const e = byKey.get(curKey[c]);
|
|
479
|
+
if (!e) continue;
|
|
480
|
+
while (e.p2 < e.list.length && used[e.list[e.p2]]) e.p2++;
|
|
481
|
+
if (e.p2 < e.list.length) { const q = e.list[e.p2++]; pair[c] = q; used[q] = true; }
|
|
482
|
+
}
|
|
483
|
+
// (3) the rows still left between two anchors take the unused originals
|
|
484
|
+
// between the same anchors, in order.
|
|
485
|
+
const nextAnchorQ = new Array(curRows.length + 1);
|
|
486
|
+
nextAnchorQ[curRows.length] = origBody.length;
|
|
487
|
+
for (let c = curRows.length - 1; c >= 0; c--) nextAnchorQ[c] = anchor[c] ? pair[c] : nextAnchorQ[c + 1];
|
|
488
|
+
let q = 0;
|
|
489
|
+
for (let c = 0; c < curRows.length; c++) {
|
|
490
|
+
if (anchor[c]) { q = pair[c] + 1; continue; }
|
|
491
|
+
if (pair[c] !== -1) continue;
|
|
492
|
+
const nextQ = nextAnchorQ[c + 1];
|
|
493
|
+
while (q < nextQ && (used[q] || !origBody[q])) q++;
|
|
494
|
+
if (q < nextQ) { pair[c] = q; used[q] = true; q++; }
|
|
495
|
+
}
|
|
496
|
+
return pair;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// The contents a row is emitted with: each cell's original field where it
|
|
500
|
+
// may be reused, else its canonical content. `whole` is true when every
|
|
501
|
+
// cell was reused and the columns are the original's, in order — the row
|
|
502
|
+
// may then be the original line itself (decided at emission, once widths
|
|
503
|
+
// are known).
|
|
504
|
+
function reuseRow(parse, canon, cellBad, orig, colMap, nOrigCols) {
|
|
505
|
+
if (!orig) return { contents: canon, whole: false };
|
|
506
|
+
let whole = canon.length === orig.fields.length && canon.length === nOrigCols;
|
|
507
|
+
const contents = canon.map((c, i) => {
|
|
508
|
+
const k = colMap[i];
|
|
509
|
+
if (k !== i) whole = false;
|
|
510
|
+
const field = k === undefined ? undefined : orig.fields[k];
|
|
511
|
+
if (field === undefined || cellBad[i]) { whole = false; return c; }
|
|
512
|
+
const f = field.trim();
|
|
513
|
+
if (f === c) return c;
|
|
514
|
+
if (!EXTENSION_CHAR.test(f) && readsSame(parse, f, c)) return f;
|
|
515
|
+
whole = false;
|
|
516
|
+
return c;
|
|
517
|
+
});
|
|
518
|
+
return { contents, whole };
|
|
519
|
+
}
|
|
520
|
+
|
|
271
521
|
// ' content ' right-padded out to `width`. NEVER truncates: a cell whose
|
|
272
522
|
// content grew past the original column simply widens that column, and a
|
|
273
523
|
// deliberately blank hand-aligned cell (e.g. `| |` kept wide on
|
|
@@ -380,13 +630,22 @@
|
|
|
380
630
|
// Render every cell's content up front (same order the pre-v3.0.1 code
|
|
381
631
|
// walked: header first, then each body row left-to-right), so the width
|
|
382
632
|
// pass below and the emission pass after it share one rendering.
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
633
|
+
// `bad` marks a cell that reported unsupported: it is never reused, so a
|
|
634
|
+
// reuse can never stand in for (or hide) such a cell.
|
|
635
|
+
const cellOf = (c) => {
|
|
636
|
+
const before = unsupported.length;
|
|
637
|
+
const md = cellContentOf(c, unsupported);
|
|
638
|
+
return { md, bad: unsupported.length !== before };
|
|
639
|
+
};
|
|
640
|
+
const headerCellMd = headerCells.map(cellOf);
|
|
641
|
+
const bodyCellMd = bodyRows.map((tr) => elementChildren(tr).filter(isCell).map(cellOf));
|
|
642
|
+
// The canonical contents; replaced below by the reused ones (F1).
|
|
643
|
+
let headerContents = headerCellMd.map((x) => x.md);
|
|
644
|
+
let bodyContentRows = bodyCellMd.map((row) => row.map((x) => x.md));
|
|
386
645
|
|
|
387
646
|
// Read the ORIGINAL widths first, then line each CURRENT header column
|
|
388
|
-
// up with the original column it corresponds to BY TEXT
|
|
389
|
-
// index. A column inserted in the MIDDLE (insertColumn() in client.js)
|
|
647
|
+
// up with the original column it corresponds to BY TEXT (since v3.17.8,
|
|
648
|
+
// by how the text reads: `same` below), not by raw index. A column inserted in the MIDDLE (insertColumn() in client.js)
|
|
390
649
|
// shifts every later column's index by one without changing what that
|
|
391
650
|
// column actually is — matching by index alone would hand the shifted
|
|
392
651
|
// column a stale neighbour's width, and the freshly-inserted empty
|
|
@@ -411,6 +670,17 @@
|
|
|
411
670
|
// patch release carries. See test/table-width.test.js cases 14, 15, 20,
|
|
412
671
|
// 21, 23 and 24, which pin every branch of the above.
|
|
413
672
|
const original = columnWidthsOf(originalMd);
|
|
673
|
+
// F1: with a parser, names are compared by how they READ (a header
|
|
674
|
+
// spelled 'a\_b' in the file is the DOM's 'a_b'); without one, by bytes,
|
|
675
|
+
// as before. `src` is the original's rows for reuse (null: no reuse).
|
|
676
|
+
const parse = getCellParser();
|
|
677
|
+
const same = parse ? (a, b) => readsSame(parse, a, b) : (a, b) => a === b;
|
|
678
|
+
const src = parse ? originalRowsOf(originalMd) : null;
|
|
679
|
+
const origNames = original ? original.headerFields
|
|
680
|
+
: (src ? src.header.fields.map((f) => f.trim()) : null);
|
|
681
|
+
// colIdx[i]: the original column current column i was matched to.
|
|
682
|
+
const colIdx = new Array(headerContents.length);
|
|
683
|
+
let moved = false;
|
|
414
684
|
let widths = null;
|
|
415
685
|
// Set for a column that falls off the END of the original header list
|
|
416
686
|
// (every original column has already been matched by the time this one
|
|
@@ -445,8 +715,7 @@
|
|
|
445
715
|
// separator did not actually change (F1, final review) — see the
|
|
446
716
|
// verbatim gate at the emission site.
|
|
447
717
|
const matchedSepFields = new Array(headerContents.length);
|
|
448
|
-
if (
|
|
449
|
-
const matched = new Array(headerContents.length);
|
|
718
|
+
if (origNames) {
|
|
450
719
|
// v3.0.2: a FORWARD SCAN, not the pre-v3.0.2 lockstep compare. The
|
|
451
720
|
// lockstep version left `j` parked on the first original column that
|
|
452
721
|
// did not match, so it could never recover from one mismatch — and
|
|
@@ -460,15 +729,14 @@
|
|
|
460
729
|
for (let i = 0; i < headerContents.length; i++) {
|
|
461
730
|
const name = headerContents[i].trim();
|
|
462
731
|
let k = -1;
|
|
463
|
-
for (let q = j; q <
|
|
464
|
-
if (
|
|
732
|
+
for (let q = j; q < origNames.length; q++) {
|
|
733
|
+
if (same(origNames[q].trim(), name)) { k = q; break; }
|
|
465
734
|
}
|
|
466
735
|
if (k >= 0) {
|
|
467
|
-
|
|
468
|
-
matchedSepFields[i] = original.sepFields[k];
|
|
736
|
+
colIdx[i] = k;
|
|
737
|
+
if (original) matchedSepFields[i] = original.sepFields[k];
|
|
469
738
|
j = k + 1;
|
|
470
739
|
} else {
|
|
471
|
-
matched[i] = undefined;
|
|
472
740
|
// The EMPTY name is exempt from the reorder test below, and the
|
|
473
741
|
// exemption is load-bearing, not defensive: insertColumn()
|
|
474
742
|
// (lib/editor/client.js:6448-6465) creates a th (or td) with no
|
|
@@ -479,7 +747,7 @@
|
|
|
479
747
|
// whole-table refusal — a straight regression against v3.0.1 on
|
|
480
748
|
// the ordinary insert-a-column-twice flow.
|
|
481
749
|
if (name !== '') unmatchedNames.push(name);
|
|
482
|
-
if (j >=
|
|
750
|
+
if (original && j >= origNames.length) sepFallbackNatural[i] = true;
|
|
483
751
|
}
|
|
484
752
|
}
|
|
485
753
|
// A column that could not find itself in what remains of the original,
|
|
@@ -489,10 +757,50 @@
|
|
|
489
757
|
// so it still discards the width source for the WHOLE table. A name
|
|
490
758
|
// that is absent from the original entirely is an insert or a rename:
|
|
491
759
|
// nothing stale to carry, so its neighbours keep their widths.
|
|
492
|
-
|
|
493
|
-
|
|
760
|
+
moved = unmatchedNames.some((n) =>
|
|
761
|
+
origNames.some((f) => same(f.trim(), n)));
|
|
762
|
+
// F1: the cells of a column the scan did not match (a moved one) are
|
|
763
|
+
// still reused when exactly one unmatched original column has its name.
|
|
764
|
+
// This feeds reuse only; the width refusal above stands.
|
|
765
|
+
if (src) {
|
|
766
|
+
const taken = new Set(colIdx.filter((k) => k !== undefined));
|
|
767
|
+
for (let i = 0; i < headerContents.length; i++) {
|
|
768
|
+
const name = headerContents[i].trim();
|
|
769
|
+
if (colIdx[i] !== undefined || name === '') continue;
|
|
770
|
+
const hits = [];
|
|
771
|
+
for (let q = 0; q < origNames.length; q++) {
|
|
772
|
+
if (!taken.has(q) && same(origNames[q].trim(), name)) hits.push(q);
|
|
773
|
+
}
|
|
774
|
+
if (hits.length === 1) { colIdx[i] = hits[0]; taken.add(hits[0]); }
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// F1: reuse each untouched cell's original bytes (see reuseRow()), before
|
|
780
|
+
// the widths are settled, so the reused contents are what they measure.
|
|
781
|
+
let headerWhole = false;
|
|
782
|
+
const bodyWhole = bodyContentRows.map(() => false);
|
|
783
|
+
const bodyOrig = bodyContentRows.map(() => null);
|
|
784
|
+
if (src) {
|
|
785
|
+
const nOrigCols = src.header.fields.length;
|
|
786
|
+
const h = reuseRow(parse, headerContents, headerCellMd.map((x) => x.bad), src.header, colIdx, nOrigCols);
|
|
787
|
+
headerContents = h.contents;
|
|
788
|
+
headerWhole = h.whole;
|
|
789
|
+
const pair = pairBodyRows(bodyContentRows, src.body, colIdx, (x) => readingKey(parse, x));
|
|
790
|
+
bodyContentRows = bodyContentRows.map((canon, r) => {
|
|
791
|
+
const orig = pair[r] === -1 ? null : src.body[pair[r]];
|
|
792
|
+
const b = reuseRow(parse, canon, bodyCellMd[r].map((x) => x.bad), orig, colIdx, nOrigCols);
|
|
793
|
+
bodyWhole[r] = b.whole;
|
|
794
|
+
bodyOrig[r] = orig;
|
|
795
|
+
return b.contents;
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
if (original) {
|
|
494
800
|
if (!moved) {
|
|
495
|
-
|
|
801
|
+
// Not moved means the name rescue above found nothing (an unmatched
|
|
802
|
+
// name is absent from the original), so colIdx is the scan's alone.
|
|
803
|
+
widths = colIdx.map((k) => (k === undefined ? undefined : original.widths[k]));
|
|
496
804
|
// A matched column is then widened, for every row of that column
|
|
497
805
|
// at once (header + separator + every body row), to whatever the
|
|
498
806
|
// grown content now needs — the padding floor rises but never
|
|
@@ -515,8 +823,14 @@
|
|
|
515
823
|
}
|
|
516
824
|
}
|
|
517
825
|
|
|
826
|
+
// F1: a row whose cells were all reused, in the original columns, is the
|
|
827
|
+
// original line itself (trimmed), padding and all, unless a column it
|
|
828
|
+
// holds was widened past that line's own field.
|
|
829
|
+
const asWritten = (whole, orig) => whole && orig.fields.every((f, i) =>
|
|
830
|
+
!widths || widths[i] === undefined || widths[i] <= f.length);
|
|
518
831
|
const lines = [];
|
|
519
|
-
lines.push(
|
|
832
|
+
lines.push(asWritten(headerWhole, src && src.header) ? src.header.line
|
|
833
|
+
: serializeRow(headerContents, widths));
|
|
520
834
|
lines.push('|' + headerCells.map((c, i) => {
|
|
521
835
|
// Preference order for a column's separator width:
|
|
522
836
|
// 1. widths[i] — this column is a full hand-aligned width source
|
|
@@ -592,10 +906,11 @@
|
|
|
592
906
|
return sepCellFor(align, w);
|
|
593
907
|
}).join('|') + '|');
|
|
594
908
|
|
|
595
|
-
bodyContentRows.forEach((contents) => lines.push(
|
|
909
|
+
bodyContentRows.forEach((contents, r) => lines.push(asWritten(bodyWhole[r], bodyOrig[r])
|
|
910
|
+
? bodyOrig[r].line : serializeRow(contents, widths)));
|
|
596
911
|
|
|
597
912
|
return { md: lines.join('\n'), unsupported };
|
|
598
913
|
}
|
|
599
914
|
|
|
600
|
-
return { serializeTable };
|
|
915
|
+
return { serializeTable, setCellParser };
|
|
601
916
|
});
|