@helping-ai-workflow/md2doc 3.17.2 → 3.17.4
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/blockmap.js +65 -0
- package/lib/editor/client.js +1861 -154
- package/lib/editor/code-edit.js +6 -5
- package/lib/editor/convert-md.js +9 -2
- package/lib/editor/dot-preview.js +65 -0
- package/lib/editor/dot-worker.js +14 -0
- package/lib/editor/inline-md.js +40 -5
- package/lib/editor/list-md.js +143 -47
- package/lib/editor/server.js +52 -3
- package/lib/editor/wave-ui.js +9 -0
- package/lib/md2doc.js +46 -6
- package/lib/theme/tokens.js +5 -1
- package/package.json +1 -1
package/lib/editor/client.js
CHANGED
|
@@ -50,18 +50,130 @@
|
|
|
50
50
|
return { lines: state.lines, blocks: state.blocks, op: null, refused: 'inverted-range' };
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
// v3.17.3 N2b (R54): lines between two blocks that no block owns, a link
|
|
54
|
+
// reference definition today (blockmap.js hands each block the definition
|
|
55
|
+
// lines above it as `defLinesAbove`, distances above its startLine). A merge,
|
|
56
|
+
// a span delete or a span convert rewrites the range from its first block to
|
|
57
|
+
// its last, and every one of them used to drop such a line unseen: Backspace
|
|
58
|
+
// at the start of P. in '# D\n\nA.\n\n[r]: http://u\n\nP.\n' wrote
|
|
59
|
+
// '# D\n\nA.P.\n'. Both helpers below now keep them, so every path that
|
|
60
|
+
// commits a range is covered in one place. Groups of adjacent lines, in
|
|
61
|
+
// document order.
|
|
62
|
+
function definitionLinesIn(state, startLine, endLine) {
|
|
63
|
+
const at = [];
|
|
64
|
+
for (const b of state.blocks) {
|
|
65
|
+
const ds = b.defLinesAbove;
|
|
66
|
+
if (!Array.isArray(ds)) continue;
|
|
67
|
+
for (const d of ds) {
|
|
68
|
+
const n = b.startLine - d;
|
|
69
|
+
const text = state.lines[n - 1];
|
|
70
|
+
if (n >= startLine && n <= endLine && text !== undefined && text.trim() !== '') at.push(n);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
at.sort((x, y) => x - y);
|
|
74
|
+
const groups = [];
|
|
75
|
+
let last = -1;
|
|
76
|
+
for (const n of at) {
|
|
77
|
+
if (n === last) continue;
|
|
78
|
+
if (groups.length && n === last + 1) {
|
|
79
|
+
const g = groups[groups.length - 1];
|
|
80
|
+
g.at.push(n); g.lines.push(state.lines[n - 1]);
|
|
81
|
+
} else groups.push({ at: [n], lines: [state.lines[n - 1]] });
|
|
82
|
+
last = n;
|
|
83
|
+
}
|
|
84
|
+
return groups;
|
|
85
|
+
}
|
|
86
|
+
// Fix round 1 (M3): a kept definition is written with its first line at
|
|
87
|
+
// column 0. Indented by one to three spaces it is still a definition where
|
|
88
|
+
// it stood, but written after a list it would become the last item's
|
|
89
|
+
// continuation ('- P.' then ' [r]: …' is one item, and deleting that item
|
|
90
|
+
// deleted the definition).
|
|
91
|
+
function definitionLinesToWrite(g) {
|
|
92
|
+
return [g.lines[0].replace(/^ {1,3}/, '')].concat(g.lines.slice(1));
|
|
93
|
+
}
|
|
94
|
+
// The range's lines without its definition lines, blank runs collapsed:
|
|
95
|
+
// what a structural rewrite that changed nothing hands back (M4).
|
|
96
|
+
function withoutDefinitionLines(state, startLine, endLine, groups) {
|
|
97
|
+
const skip = new Set();
|
|
98
|
+
groups.forEach((g) => g.at.forEach((n) => skip.add(n)));
|
|
99
|
+
const out = [];
|
|
100
|
+
for (let n = startLine; n <= endLine; n++) {
|
|
101
|
+
if (skip.has(n)) continue;
|
|
102
|
+
const l = state.lines[n - 1];
|
|
103
|
+
if (l.trim() === '' && out.length && out[out.length - 1].trim() === '') continue;
|
|
104
|
+
out.push(l);
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
function collapseBlankRuns(rows) {
|
|
109
|
+
return rows.filter((l, i) => !(l.trim() === '' && i > 0 && rows[i - 1].trim() === ''));
|
|
110
|
+
}
|
|
111
|
+
// `after` with the definition lines of startLine..endLine written AFTER
|
|
112
|
+
// its content, one blank line apart, before its own trailing blank lines.
|
|
113
|
+
// A blank line separates them from text on either side, so a paragraph
|
|
114
|
+
// cannot absorb them. `null`: the rewrite changed nothing but the
|
|
115
|
+
// definitions' absence (M4), so nothing is written.
|
|
116
|
+
//
|
|
117
|
+
// Fix round 3 (R56): no scan of `after` decides whether a definition is
|
|
118
|
+
// already in it — reading Markdown on the client is a second copy of
|
|
119
|
+
// marked's grammar, and it read '<!--' / '$' blocks wrongly. The caller
|
|
120
|
+
// says so instead: a gesture whose new text replays the lines between its
|
|
121
|
+
// blocks verbatim passes the carriesGap option (see the census row in
|
|
122
|
+
// test/editor-client.test.js for the callers allowed to); every other
|
|
123
|
+
// caller has the definitions appended. A missing flag costs a second copy,
|
|
124
|
+
// a wrong one the definition, so the flag is only for text that is built
|
|
125
|
+
// by slicing `lines` across every gap of the range.
|
|
126
|
+
//
|
|
127
|
+
// v3.17.4 D5 (R8): a gesture that replays SOME of its gaps verbatim (a span
|
|
128
|
+
// move keeps the lines between two blocks that stay neighbours, and drops
|
|
129
|
+
// the others) cannot say carriesGap for the whole range. It names the source
|
|
130
|
+
// line numbers it copied instead, `replayedLines` (a Set), and a group whose
|
|
131
|
+
// every line is in it is skipped; every other group is appended as above.
|
|
132
|
+
// Still the gesture's own fact, per group instead of all-or-nothing: the
|
|
133
|
+
// text is never read, so a copy inside a fence is still code. Same costs —
|
|
134
|
+
// a line named that was not copied loses its definition — so a line goes in
|
|
135
|
+
// the set only where the caller slices it from `lines` (census row in
|
|
136
|
+
// test/editor-client.test.js).
|
|
137
|
+
function keepDefinitionLines(state, startLine, endLine, after, replayedLines) {
|
|
138
|
+
const groups = definitionLinesIn(state, startLine, endLine)
|
|
139
|
+
.filter((g) => !(replayedLines && g.at.every((n) => replayedLines.has(n))));
|
|
140
|
+
if (!groups.length) return after;
|
|
141
|
+
if (collapseBlankRuns(after).join('\n') === withoutDefinitionLines(state, startLine, endLine, groups).join('\n')) return null;
|
|
142
|
+
let tail = after.length;
|
|
143
|
+
while (tail > 0 && after[tail - 1] === '') tail--;
|
|
144
|
+
const out = after.slice(0, tail);
|
|
145
|
+
const prev = startLine >= 2 ? state.lines[startLine - 2] : undefined;
|
|
146
|
+
if (!out.length && prev !== undefined && prev.trim() !== '') out.push('');
|
|
147
|
+
for (const g of groups) {
|
|
148
|
+
if (out.length && out[out.length - 1] !== '') out.push('');
|
|
149
|
+
out.push(...definitionLinesToWrite(g));
|
|
150
|
+
}
|
|
151
|
+
const next = state.lines[endLine];
|
|
152
|
+
if (tail === after.length && next !== undefined && next.trim() !== '') out.push('');
|
|
153
|
+
return out.concat(after.slice(tail));
|
|
154
|
+
}
|
|
155
|
+
|
|
53
156
|
// Pure: apply a raw-edit commit to an EXPLICIT line range (startLine..endLine,
|
|
54
157
|
// 1-indexed inclusive); push onto stack.
|
|
55
158
|
// Returns {lines, blocks, op}; op === null when text is unchanged.
|
|
56
159
|
// The shift anchor is the LAST block whose endLine <= the range's endLine,
|
|
57
160
|
// so that only blocks after the committed range are shifted — not blocks
|
|
58
161
|
// inside it. Guards the no-anchor case (range before every block).
|
|
59
|
-
|
|
162
|
+
// Fix round 1 (I1, R55): `opts.verbatim` is text the user wrote (the raw
|
|
163
|
+
// box, an in-place code edit): it replaces what it covers exactly, so a
|
|
164
|
+
// definition the user deleted or changed there is not kept as well.
|
|
165
|
+
// Fix round 3 (R56): `opts.carriesGap` is structural text that already
|
|
166
|
+
// holds every line between the range's blocks verbatim (keepDefinitionLines()).
|
|
167
|
+
// v3.17.4 D5 (R8): `opts.replayedLines` names the source lines it holds
|
|
168
|
+
// verbatim when only some gaps are replayed (keepDefinitionLines()).
|
|
169
|
+
function commitRangeEdit(state, startLine, endLine, newText, opts) {
|
|
60
170
|
const refusal = refuseInvertedRange(state, 'commitRangeEdit', startLine, endLine);
|
|
61
171
|
if (refusal) return refusal;
|
|
62
172
|
const before = state.lines.slice(startLine - 1, endLine);
|
|
63
|
-
const
|
|
64
|
-
|
|
173
|
+
const typed = newText.split('\n');
|
|
174
|
+
const after = opts && (opts.verbatim || opts.carriesGap) ? typed
|
|
175
|
+
: keepDefinitionLines(state, startLine, endLine, typed, opts && opts.replayedLines);
|
|
176
|
+
if (after === null || before.join('\n') === after.join('\n')) {
|
|
65
177
|
return { lines: state.lines, blocks: state.blocks, op: null };
|
|
66
178
|
}
|
|
67
179
|
const op = { startLine, endLine, before, after };
|
|
@@ -75,9 +187,9 @@
|
|
|
75
187
|
// Pure: apply a raw-edit commit to (lines, blocks); push onto stack.
|
|
76
188
|
// Returns {lines, blocks, op}; op === null when text is unchanged.
|
|
77
189
|
// Wrapper over commitRangeEdit: looks up the block by id and delegates.
|
|
78
|
-
function commitEdit(state, blockId, newText) {
|
|
190
|
+
function commitEdit(state, blockId, newText, opts) {
|
|
79
191
|
const block = state.blocks.find((b) => b.id === blockId);
|
|
80
|
-
return commitRangeEdit(state, block.startLine, block.endLine, newText);
|
|
192
|
+
return commitRangeEdit(state, block.startLine, block.endLine, newText, opts);
|
|
81
193
|
}
|
|
82
194
|
|
|
83
195
|
// Generalized range removal: deletes startLine..endLine (zero lines) and
|
|
@@ -92,6 +204,14 @@
|
|
|
92
204
|
function commitRangeRemoval(state, startLine, endLine) {
|
|
93
205
|
const refusal = refuseInvertedRange(state, 'commitRangeRemoval', startLine, endLine);
|
|
94
206
|
if (refusal) return refusal;
|
|
207
|
+
// v3.17.3 N2b: a span delete across a definition removes only the blocks'
|
|
208
|
+
// own lines; the definition stays where the span was, the blank lines
|
|
209
|
+
// around the span untouched. Same anchor, same single op.
|
|
210
|
+
const kept = definitionLinesIn(state, startLine, endLine);
|
|
211
|
+
if (kept.length) {
|
|
212
|
+
return commitRangeEdit(state, startLine, endLine,
|
|
213
|
+
kept.map((g) => definitionLinesToWrite(g).join('\n')).join('\n\n'), { carriesGap: true });
|
|
214
|
+
}
|
|
95
215
|
const anchor = state.blocks.filter((b) => b.endLine <= endLine).pop();
|
|
96
216
|
let sl = startLine, el = endLine;
|
|
97
217
|
// state.lines[el] (0-indexed) is the line immediately AFTER the range.
|
|
@@ -128,7 +248,7 @@
|
|
|
128
248
|
const refusal = refuseInvertedRange(state, 'commitTightRemoval', startLine, endLine);
|
|
129
249
|
if (refusal) return refusal;
|
|
130
250
|
const anchor = state.blocks.filter((b) => b.endLine <= endLine).pop();
|
|
131
|
-
const span = growOverBlanks(state.lines, startLine, endLine, 'both');
|
|
251
|
+
const span = growOverBlanks(state.lines, startLine, endLine, 'both', state.blocks);
|
|
132
252
|
const before = state.lines.slice(span[0] - 1, span[1]);
|
|
133
253
|
const op = { startLine: span[0], endLine: span[1], before, after: [] };
|
|
134
254
|
const r = ops.replaceLines(state.lines, span[0], span[1], []);
|
|
@@ -278,6 +398,9 @@
|
|
|
278
398
|
// * no blocks at all — an empty or whitespace-only file. Its whole line
|
|
279
399
|
// range is replaced by [...newLines, ''], so the file ends with exactly
|
|
280
400
|
// one newline whatever blank lines it held.
|
|
401
|
+
// v3.17.3 N2b (R54): or a file holding only link reference
|
|
402
|
+
// definitions, which make no block; their lines are kept after the new
|
|
403
|
+
// ones, one blank line apart.
|
|
281
404
|
// Returns { lines, blocks, op, newStartLine }; op is null only for a
|
|
282
405
|
// refusal (commitBlockInsertion()'s inverted-anchor case).
|
|
283
406
|
function commitInsertionAt(state, anchorBlockId, newLines) {
|
|
@@ -292,7 +415,9 @@
|
|
|
292
415
|
state.stack.push(op);
|
|
293
416
|
return { lines: r.lines, blocks: state.blocks, op, newStartLine: 1 };
|
|
294
417
|
}
|
|
295
|
-
const
|
|
418
|
+
const kept = state.lines.filter((l) => l.trim() !== '');
|
|
419
|
+
const z = commitRangeEdit(state, 1, state.lines.length,
|
|
420
|
+
newLines.concat(kept.length ? [''].concat(kept, ['']) : ['']).join('\n'));
|
|
296
421
|
return { lines: z.lines, blocks: z.blocks, op: z.op, newStartLine: 1 };
|
|
297
422
|
}
|
|
298
423
|
const first = state.blocks[0];
|
|
@@ -329,14 +454,28 @@
|
|
|
329
454
|
// 'below', R58) grows op 2 over the blank lines to that side.
|
|
330
455
|
// v3.15.0 B2 fix round 1 (R58): `start..end` grown over the blank lines
|
|
331
456
|
// right above it (`side` 'above'), right below it ('below') or both ('both',
|
|
332
|
-
// R62)
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
|
|
457
|
+
// R62): the separator to the neighbouring block, the one a list item eats to
|
|
458
|
+
// join that list tight. Final review I1 (R54): the lines between two blocks
|
|
459
|
+
// are NOT all blank — a link reference definition is on lines no block owns
|
|
460
|
+
// (blockmap.js, `defLinesAbove`). So a side is grown only when its blank run
|
|
461
|
+
// reaches a block of `blocks` exactly (the block above ends on the line
|
|
462
|
+
// before the run, the block below starts on the line after it) or the end
|
|
463
|
+
// of the file. A run that stops at an unowned line is left as it is: eating
|
|
464
|
+
// it made `- Intro` then `[r]: http://u` one item, the definition its text.
|
|
465
|
+
function growOverBlanks(lines, start, end, side, blocks) {
|
|
466
|
+
const bs = Array.isArray(blocks) ? blocks : [];
|
|
336
467
|
let s = start;
|
|
337
468
|
let e = end;
|
|
338
|
-
if (side === 'above' || side === 'both')
|
|
339
|
-
|
|
469
|
+
if (side === 'above' || side === 'both') {
|
|
470
|
+
let k = s;
|
|
471
|
+
while (k >= 2 && String(lines[k - 2]).trim() === '') k--;
|
|
472
|
+
if (k === 1 || bs.some((b) => b.endLine === k - 1)) s = k;
|
|
473
|
+
}
|
|
474
|
+
if (side === 'below' || side === 'both') {
|
|
475
|
+
let k = e;
|
|
476
|
+
while (k < lines.length && String(lines[k]).trim() === '') k++;
|
|
477
|
+
if (k === lines.length || bs.some((b) => b.startLine === k + 1)) e = k;
|
|
478
|
+
}
|
|
340
479
|
return [s, e];
|
|
341
480
|
}
|
|
342
481
|
|
|
@@ -350,7 +489,11 @@
|
|
|
350
489
|
return { lines: r.lines, blocks: r.blocks, op: r.op, newStartLine: before ? at : at + 1 };
|
|
351
490
|
}
|
|
352
491
|
|
|
353
|
-
|
|
492
|
+
// v3.17.3 N4 (R41): `tail` ({ range, md }, renumberedRunFrom()) is the
|
|
493
|
+
// ordered list right below, renumbered: op 2 also replaces its lines when
|
|
494
|
+
// they still follow op 2's range unchanged, so the join and the new numbers
|
|
495
|
+
// are one step. `tailEnd` (its last line before the ops) is reported then.
|
|
496
|
+
function commitShortcut(state, at, literalLines, convertedLines, join, tail) {
|
|
354
497
|
let first;
|
|
355
498
|
let start;
|
|
356
499
|
if (at && at.blockId !== undefined && at.blockId !== null) {
|
|
@@ -367,8 +510,21 @@
|
|
|
367
510
|
const mid = { lines: first.lines, blocks: first.blocks, stack: state.stack };
|
|
368
511
|
// R58: a list item joining the list above or below takes the separator
|
|
369
512
|
// between the two into op 2's range, so the list stays tight.
|
|
370
|
-
const span = growOverBlanks(mid.lines, start, start + literalLines.length - 1, join || null);
|
|
371
|
-
|
|
513
|
+
const span = growOverBlanks(mid.lines, start, start + literalLines.length - 1, join || null, mid.blocks);
|
|
514
|
+
let spanEnd = span[1];
|
|
515
|
+
let text = convertedLines.join('\n');
|
|
516
|
+
let tailEnd = null;
|
|
517
|
+
if (tail && tail.range && typeof tail.md === 'string') {
|
|
518
|
+
const n = tail.range.endLine - tail.range.startLine + 1;
|
|
519
|
+
const before = state.lines.slice(tail.range.startLine - 1, tail.range.endLine);
|
|
520
|
+
const here = mid.lines.slice(span[1], span[1] + n);
|
|
521
|
+
if (n > 0 && here.length === n && here.every((l, k) => l === before[k])) {
|
|
522
|
+
spanEnd = span[1] + n;
|
|
523
|
+
text += '\n' + tail.md;
|
|
524
|
+
tailEnd = tail.range.endLine;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
const second = commitRangeEdit(mid, span[0], spanEnd, text);
|
|
372
528
|
if (second.op === null) {
|
|
373
529
|
// Nothing to convert (cannot happen for a real marker: the literal is
|
|
374
530
|
// escaped and the conversion is not). Op 1 alone is not a shortcut, so
|
|
@@ -377,7 +533,7 @@
|
|
|
377
533
|
return { lines: state.lines, blocks: state.blocks, ops: [null, null], newStartLine: null };
|
|
378
534
|
}
|
|
379
535
|
return { lines: second.lines, blocks: second.blocks, ops: [first.op, second.op],
|
|
380
|
-
newStartLine: span[0], literalStartLine: start };
|
|
536
|
+
newStartLine: span[0], literalStartLine: start, tailEnd: tailEnd };
|
|
381
537
|
}
|
|
382
538
|
|
|
383
539
|
// ── S4 Task 3: relocating ONE block's lines (spec §4.5) ────────────────
|
|
@@ -483,7 +639,10 @@
|
|
|
483
639
|
if (!plan) {
|
|
484
640
|
return { lines: state.lines, blocks: state.blocks, op: null, noop: 'same-position' };
|
|
485
641
|
}
|
|
486
|
-
|
|
642
|
+
// R56: planBlockMove() builds `after` by slicing `lines` — the source's
|
|
643
|
+
// own span, everything between source and destination — so every
|
|
644
|
+
// definition line of the range is in it, where it stood.
|
|
645
|
+
const r = commitRangeEdit(state, plan.startLine, plan.endLine, plan.after.join('\n'), { carriesGap: true });
|
|
487
646
|
r.newRange = plan.newRange;
|
|
488
647
|
return r;
|
|
489
648
|
}
|
|
@@ -910,6 +1069,21 @@
|
|
|
910
1069
|
// ── DOM wiring (browser only) ─────────────────────────────────────────
|
|
911
1070
|
const ED = window.__ED__;
|
|
912
1071
|
const inlineMd = window.md2docInlineMd;
|
|
1072
|
+
// The alt field decodes every named character reference the way the reader's
|
|
1073
|
+
// browser does. The reader's alt is an ATTRIBUTE, where a legacy name followed
|
|
1074
|
+
// by more letters (¬it;, ©x;) stays literal; a textarea (text context)
|
|
1075
|
+
// would give the prefix's character (¬it;), so the probe parses an img alt
|
|
1076
|
+
// inside an inert template. Not a div: Chromium's innerHTML fast path decodes
|
|
1077
|
+
// an attribute there as text (measured, final review I5).
|
|
1078
|
+
{
|
|
1079
|
+
const refProbe = document.createElement('template');
|
|
1080
|
+
inlineMd.setNamedReferenceDecoder((name) => {
|
|
1081
|
+
refProbe.innerHTML = '<img alt="&' + name + ';">';
|
|
1082
|
+
const img = refProbe.content.firstChild;
|
|
1083
|
+
const v = img && img.getAttribute('alt');
|
|
1084
|
+
return typeof v !== 'string' || v === '&' + name + ';' ? undefined : v;
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
913
1087
|
const tableMd = window.md2docTableMd;
|
|
914
1088
|
const listMd = window.md2docListMd;
|
|
915
1089
|
const indentClamp = window.md2docIndentClamp;
|
|
@@ -1421,8 +1595,22 @@
|
|
|
1421
1595
|
// v3.17.2 B1 (v3.17.0 final review T2 L74): and so is the text typed into
|
|
1422
1596
|
// an open raw box. Measured before: type in the box, and the status
|
|
1423
1597
|
// read clean and a tab close raised no dialog — the typing was lost.
|
|
1598
|
+
// v3.17.3 C8: and so is alt text typed into the image toolbar's field and
|
|
1599
|
+
// not yet applied (Enter). Measured before: the status read clean and a
|
|
1600
|
+
// tab close raised no dialog.
|
|
1424
1601
|
return stack.isDirty() || burstHasUncommittedEdit() || waveUnwrittenEdit || pendingStructure !== null ||
|
|
1425
|
-
codeEditHasUncommittedText() || diagramPanelHasUncommittedText() || rawEditHasUncommittedText()
|
|
1602
|
+
codeEditHasUncommittedText() || diagramPanelHasUncommittedText() || rawEditHasUncommittedText() ||
|
|
1603
|
+
imgTbHasPendingAlt();
|
|
1604
|
+
}
|
|
1605
|
+
// Guarded the way its neighbours are: documentIsDirty() also runs in the
|
|
1606
|
+
// beforeunload handler, and the toolbar's state is declared far below.
|
|
1607
|
+
function imgTbHasPendingAlt() {
|
|
1608
|
+
try {
|
|
1609
|
+
if (imgTbAlt) return !imgTbInput.hidden && imgTbInput.value !== imgTbAlt.original;
|
|
1610
|
+
// R12: a kept draft is unsaved work while its image is still in the
|
|
1611
|
+
// document (a draft whose source is gone stops counting).
|
|
1612
|
+
return !!imgTbAltDraft && blocks.some((b) => blockSourceOf(b) === imgTbAltDraft.source);
|
|
1613
|
+
} catch (e) { return false; }
|
|
1426
1614
|
}
|
|
1427
1615
|
// Guarded the same way, for the same reason (the beforeunload handler).
|
|
1428
1616
|
function rawEditHasUncommittedText() {
|
|
@@ -2244,14 +2432,19 @@
|
|
|
2244
2432
|
absorbedOutsideContent(lastRenderLines, blocks,
|
|
2245
2433
|
lines, j.blocks,
|
|
2246
2434
|
j.blocks[j.blocks.length - 1]);
|
|
2247
|
-
|
|
2435
|
+
// v3.17.3 B10: read against the blocks the transient was placed among,
|
|
2436
|
+
// before applyFullRender() replaces them. A transient on screen is a child
|
|
2437
|
+
// the last render did not draw, so domMatchesLastRender() is false and
|
|
2438
|
+
// this render is always the full one.
|
|
2439
|
+
const keptTransient = transientKeptThroughRender(editRange, isReconcile);
|
|
2440
|
+
const plan = (!keptTransient && editRange && window.md2docPatchmap && !lastPartsIsBootstrapSeed && domMatchesLastRender())
|
|
2248
2441
|
? window.md2docPatchmap.patchmap({
|
|
2249
2442
|
oldBlocks: blocks, newBlocks: j.blocks,
|
|
2250
2443
|
oldParts: lastParts, newParts: j.parts, editRange: editRange,
|
|
2251
2444
|
})
|
|
2252
2445
|
: null;
|
|
2253
2446
|
const ok = plan ? await applyPatch(j, plan, declaredRange)
|
|
2254
|
-
: await applyFullRender(j, scrollY, declaredRange);
|
|
2447
|
+
: await applyFullRender(j, scrollY, declaredRange, keptTransient);
|
|
2255
2448
|
// applyPatch() 與 applyFullRender() 今天都只回 true:applyFullRender() 全函
|
|
2256
2449
|
// 式唯一的 return 就是尾端
|
|
2257
2450
|
// 那個 `return true`,而 applyPatch() 要嘛回 true、要嘛在自己的 catch 裡
|
|
@@ -2893,7 +3086,7 @@
|
|
|
2893
3086
|
return true;
|
|
2894
3087
|
}
|
|
2895
3088
|
|
|
2896
|
-
async function applyFullRender(j, scrollY, declaredRange) {
|
|
3089
|
+
async function applyFullRender(j, scrollY, declaredRange, keptTransient) {
|
|
2897
3090
|
blocks = j.blocks;
|
|
2898
3091
|
lastParts = j.parts; // v3.2.0: 供下一次 patch 當 oldParts
|
|
2899
3092
|
lastPartsIsBootstrapSeed = false;
|
|
@@ -2928,8 +3121,10 @@
|
|
|
2928
3121
|
// fresh assignment.
|
|
2929
3122
|
pristineInsert = null;
|
|
2930
3123
|
// v3.14.0 T1: the transient lived in the `.content` children the
|
|
2931
|
-
// innerHTML write above just replaced — every render drops it (D3)
|
|
3124
|
+
// innerHTML write above just replaced — every render drops it (D3),
|
|
3125
|
+
// except the one transientKeptThroughRender() kept (v3.17.3 B10).
|
|
2932
3126
|
transient = null;
|
|
3127
|
+
if (keptTransient) restoreKeptTransient(keptTransient);
|
|
2933
3128
|
// Task 2 (Phase 3): same belt-and-braces reasoning for the always-on
|
|
2934
3129
|
// WYSIWYG burst — whatever block had one open a moment ago was just
|
|
2935
3130
|
// detached by the innerHTML replacement above, so its own focusout
|
|
@@ -3061,10 +3256,79 @@
|
|
|
3061
3256
|
}
|
|
3062
3257
|
}
|
|
3063
3258
|
reapDiagramPanel(); // T10: a panel this render took away is disposed here
|
|
3259
|
+
// v3.17.3 B10: last, after every teardown above (the burst one would
|
|
3260
|
+
// dispose the burst this focus opens).
|
|
3261
|
+
if (keptTransient && keptTransient.focused && transient === keptTransient.t) focusTransientStart(transient.el);
|
|
3064
3262
|
setDirty();
|
|
3065
3263
|
return true;
|
|
3066
3264
|
}
|
|
3067
3265
|
|
|
3266
|
+
// ── v3.17.3 B10: which transient a render keeps ───────────────────────
|
|
3267
|
+
//
|
|
3268
|
+
// A render replaces every `.content` child, so it used to drop the
|
|
3269
|
+
// transient with them (D3). That took away the empty paragraph an Enter at
|
|
3270
|
+
// a block's start leaves ABOVE the caret as soon as the block below it was
|
|
3271
|
+
// committed: measured, Enter at `Omega.`'s start, type X, then ArrowUp —
|
|
3272
|
+
// the paragraph was gone and the caret landed in `Alpha.` at offset 1; a
|
|
3273
|
+
// click on it instead left focus on the body. This is the one rule for that
|
|
3274
|
+
// (Task 7's nested-list commit goes through it too): an EMPTY transient
|
|
3275
|
+
// whose whole neighbourhood above sits before the render's edit — the
|
|
3276
|
+
// edit starts at or below the first real block after it — is the same
|
|
3277
|
+
// element after the render, back after the block it followed (the lines
|
|
3278
|
+
// above the edit do not move, so that block keeps its start line), still
|
|
3279
|
+
// `transient`, and focused again if it had focus. Anything else is dropped
|
|
3280
|
+
// as before: a transient with text is committed by its own path, never
|
|
3281
|
+
// rendered over; an edit at or above it may have moved or removed the block
|
|
3282
|
+
// it follows; a reconciliation redraw restores no transient.
|
|
3283
|
+
// R29 (fix round 1): and only while the user is still at it — focus is in
|
|
3284
|
+
// the transient, or in the block right below it that this render commits
|
|
3285
|
+
// (the caret an Enter at that block's start left there; an ArrowUp held
|
|
3286
|
+
// behind the commit still has it there when this runs). A transient the
|
|
3287
|
+
// user has left (a press into another block) is not kept: it was never
|
|
3288
|
+
// written, so it goes as an empty transient that was left goes (D3), and
|
|
3289
|
+
// while one stands on screen every render is a full one
|
|
3290
|
+
// (domMatchesLastRender()).
|
|
3291
|
+
// → null, or { t, anchorLine, focused }; anchorLine null = top of
|
|
3292
|
+
// `.content`.
|
|
3293
|
+
function transientKeptThroughRender(editRange, isReconcile) {
|
|
3294
|
+
const t = transient;
|
|
3295
|
+
if (!t || isReconcile || !editRange || !t.el.isConnected || t.el.parentNode !== contentEl) return null;
|
|
3296
|
+
if (!transientSurfaceEmpty(t.el) || transientIsOnlyBlock(t.el)) return null;
|
|
3297
|
+
const realSibling = (step) => {
|
|
3298
|
+
let n = step(t.el);
|
|
3299
|
+
while (n && !(n.classList.contains('ed-block') && n.getAttribute('data-block-id') !== null)) n = step(n);
|
|
3300
|
+
return n;
|
|
3301
|
+
};
|
|
3302
|
+
const belowEl = realSibling((n) => n.nextElementSibling);
|
|
3303
|
+
const below = blockRecOf(belowEl);
|
|
3304
|
+
if (!below || editRange.startLine < below.startLine) return null;
|
|
3305
|
+
const ae = document.activeElement;
|
|
3306
|
+
if (!ae || !(t.el.contains(ae) || belowEl.contains(ae))) return null;
|
|
3307
|
+
const aboveEl = realSibling((n) => n.previousElementSibling);
|
|
3308
|
+
const above = aboveEl ? blockRecOf(aboveEl) : null;
|
|
3309
|
+
if (aboveEl && !above) return null;
|
|
3310
|
+
return { t, anchorLine: above ? above.startLine : null, focused: t.el.contains(document.activeElement) };
|
|
3311
|
+
}
|
|
3312
|
+
// applyFullRender()'s half, against the new `blocks`: the element goes back
|
|
3313
|
+
// right after the block now starting at `anchorLine`. A block no longer
|
|
3314
|
+
// there leaves it dropped.
|
|
3315
|
+
function restoreKeptTransient(kept) {
|
|
3316
|
+
const el = kept.t.el;
|
|
3317
|
+
const after = kept.anchorLine === null ? null : blockElAtLine(kept.anchorLine);
|
|
3318
|
+
if (kept.anchorLine !== null && !after) return;
|
|
3319
|
+
if (after) after.parentNode.insertBefore(el, after.nextSibling);
|
|
3320
|
+
else contentEl.insertBefore(el, contentEl.firstChild);
|
|
3321
|
+
// M1 (fix round 1): `stayEl` is nulled, not re-pointed at the block now
|
|
3322
|
+
// below. burstUndo() reads it as "Ctrl+Z in that block takes the
|
|
3323
|
+
// transient away as the Enter's undo step", which holds only while
|
|
3324
|
+
// nothing has been committed since the Enter. This render committed
|
|
3325
|
+
// something (it has an editRange), so the next Ctrl+Z belongs to that
|
|
3326
|
+
// newer op; taking the older Enter's transient first would undo out of
|
|
3327
|
+
// order. The transient goes when it is left (D3).
|
|
3328
|
+
kept.t.stayEl = null;
|
|
3329
|
+
transient = kept.t;
|
|
3330
|
+
}
|
|
3331
|
+
|
|
3068
3332
|
// ── v3.15.0 T4 (D12, R9): diagram engines on demand ────────────────────
|
|
3069
3333
|
//
|
|
3070
3334
|
// The edit page inlines Mermaid / WaveDrom / the KaTeX stylesheet only when
|
|
@@ -4118,6 +4382,10 @@
|
|
|
4118
4382
|
// answer — the "/" menu's 引用 opens the box seeded `> ` with the caret at
|
|
4119
4383
|
// its end. hasChanges() compares against it, so a box left untouched stays
|
|
4120
4384
|
// pristine.
|
|
4385
|
+
// v3.17.3 fix round 1 (R47): the raw box textarea whose commit is
|
|
4386
|
+
// rendering (its text is already in `lines`), or null. The keydown handler
|
|
4387
|
+
// sends its Ctrl+Z / Ctrl+Y to the document instead of the box.
|
|
4388
|
+
let rawBoxCommitting = null;
|
|
4121
4389
|
async function openRawEditor(blockEl, range, seedOverride) {
|
|
4122
4390
|
if (blockEl.querySelector('.ed-raw')) return; // already editing this block
|
|
4123
4391
|
// Task G fix round 1 (npm test RED on f63c88a): the guard below used to
|
|
@@ -4420,8 +4688,8 @@
|
|
|
4420
4688
|
// delegates), so the two branches are the same pipeline — replaceLines()
|
|
4421
4689
|
// + one undo op — differing only in which lines they name.
|
|
4422
4690
|
const result = rangeMode
|
|
4423
|
-
? commitRangeEdit({ lines, blocks, stack }, startLine, endLine, ta.value)
|
|
4424
|
-
: commitEdit({ lines, blocks, stack }, blockId, ta.value);
|
|
4691
|
+
? commitRangeEdit({ lines, blocks, stack }, startLine, endLine, ta.value, { verbatim: true })
|
|
4692
|
+
: commitEdit({ lines, blocks, stack }, blockId, ta.value, { verbatim: true });
|
|
4425
4693
|
if (result.op === null) {
|
|
4426
4694
|
// §10-gap fix (review): an explicit commit (Ctrl+Enter/✓) whose
|
|
4427
4695
|
// text happens to be byte-identical to `source` is still an
|
|
@@ -4436,7 +4704,14 @@
|
|
|
4436
4704
|
endLine: Math.max(result.op.startLine - 1, result.op.endLine),
|
|
4437
4705
|
delta: result.op.after.length - result.op.before.length }
|
|
4438
4706
|
: undefined;
|
|
4439
|
-
|
|
4707
|
+
// R47: see `rawBoxCommitting`.
|
|
4708
|
+
rawBoxCommitting = ta;
|
|
4709
|
+
let ok;
|
|
4710
|
+
try {
|
|
4711
|
+
ok = await safeRerenderAll(editRange);
|
|
4712
|
+
} finally {
|
|
4713
|
+
if (rawBoxCommitting === ta) rawBoxCommitting = null;
|
|
4714
|
+
}
|
|
4440
4715
|
if (!ok) {
|
|
4441
4716
|
// Roll back the optimistic edit: pop the op safeRerenderAll's
|
|
4442
4717
|
// failure means was never actually rendered, so `lines` stays
|
|
@@ -4804,6 +5079,22 @@
|
|
|
4804
5079
|
}, () => {});
|
|
4805
5080
|
}
|
|
4806
5081
|
|
|
5082
|
+
// v3.17.3 C2: a click lands in the RENDERED text, where the fence's indent
|
|
5083
|
+
// is already stripped from every line; the edit's text is the body with the
|
|
5084
|
+
// indent still on each line. Add the stripped width of every line up to the
|
|
5085
|
+
// click's line back. Lines that do not pair up (never today) leave the
|
|
5086
|
+
// offset as measured.
|
|
5087
|
+
function codeEditBodyOffset(rendered, bodyLines, off) {
|
|
5088
|
+
const rl = String(rendered).split('\n');
|
|
5089
|
+
if (rl.length !== bodyLines.length) return off;
|
|
5090
|
+
let line = 0;
|
|
5091
|
+
let left = off;
|
|
5092
|
+
while (line < rl.length - 1 && left > rl[line].length) { left -= rl[line].length + 1; line++; }
|
|
5093
|
+
let add = 0;
|
|
5094
|
+
for (let i = 0; i <= line; i++) add += Math.max(0, bodyLines[i].length - rl[i].length);
|
|
5095
|
+
return off + add;
|
|
5096
|
+
}
|
|
5097
|
+
|
|
4807
5098
|
// Opens the edit on `blockEl` with the caret at `opts.offset` (default 0)
|
|
4808
5099
|
// or under the click point `opts.clientX/clientY`. false when the block is
|
|
4809
5100
|
// not a fenced code block (the caller opens the raw box instead); true
|
|
@@ -4847,6 +5138,7 @@
|
|
|
4847
5138
|
const host = blockEl.firstElementChild;
|
|
4848
5139
|
const body = fence.body.join('\n');
|
|
4849
5140
|
let caret = o.clientX !== undefined ? codeCaretFromPoint(host, o.clientX, o.clientY) : null;
|
|
5141
|
+
if (caret !== null) caret = codeEditBodyOffset(host.textContent, fence.body, caret);
|
|
4850
5142
|
if (caret === null) caret = o.offset || 0;
|
|
4851
5143
|
caret = Math.max(0, Math.min(body.length, caret));
|
|
4852
5144
|
const s = {
|
|
@@ -5018,7 +5310,7 @@
|
|
|
5018
5310
|
// text. RENDER_SUPERSEDED: not success yet, the caller touches no DOM. true:
|
|
5019
5311
|
// rendered.
|
|
5020
5312
|
async function commitFencedRangeAndRender(startLine, endLine, text) {
|
|
5021
|
-
const result = commitRangeEdit({ lines, blocks, stack }, startLine, endLine, text);
|
|
5313
|
+
const result = commitRangeEdit({ lines, blocks, stack }, startLine, endLine, text, { verbatim: true });
|
|
5022
5314
|
if (result.op === null) return null;
|
|
5023
5315
|
const prevLines = lines;
|
|
5024
5316
|
lines = result.lines;
|
|
@@ -5916,7 +6208,30 @@
|
|
|
5916
6208
|
// Enter made the item) takes the spacing of the list it joins, which the
|
|
5917
6209
|
// renderer stamped from marked's own looseness as data-list-loose.
|
|
5918
6210
|
function serializeListEls(els, opts) {
|
|
5919
|
-
return listMd.serializeBlocks(els, Object.assign({ blankBefore: looseBlankBefore
|
|
6211
|
+
return listMd.serializeBlocks(els, Object.assign({ blankBefore: looseBlankBefore,
|
|
6212
|
+
sourceMarker: sourceMarkerOf }, opts || {}));
|
|
6213
|
+
}
|
|
6214
|
+
// v3.17.3 A9 (R5): the bullet character / ordinal delimiter this block's
|
|
6215
|
+
// own marker has in the file ('-', '*', '+', '.', ')'), or null for a block
|
|
6216
|
+
// with no record (an item just made). list-md.js asks it for every item it
|
|
6217
|
+
// re-serialises — a carried one answers from its carried line — and writes
|
|
6218
|
+
// each run in the style most of its items have, so an edit to `* b` no
|
|
6219
|
+
// longer rewrites the list as `- `. A zero-line block (the outer item of a
|
|
6220
|
+
// same-line nest, `- * b`) has its marker on the line of the item that owns
|
|
6221
|
+
// it, at its own depth's place in that line's marker chain.
|
|
6222
|
+
function sourceMarkerOf(el) {
|
|
6223
|
+
const rec = blockRecOf(el);
|
|
6224
|
+
if (!rec) return null;
|
|
6225
|
+
const chain = sourceMarkerChain(lines[rec.startLine - 1]);
|
|
6226
|
+
if (!chain.length) return null;
|
|
6227
|
+
let at = chain.length - 1;
|
|
6228
|
+
if (rec.endLine < rec.startLine) {
|
|
6229
|
+
const owner = blocks.find((b) => b.startLine === rec.startLine && b.endLine >= b.startLine);
|
|
6230
|
+
if (!owner) return null;
|
|
6231
|
+
at = chain.length - 1 - (owner.indent - rec.indent);
|
|
6232
|
+
if (at < 0) return null;
|
|
6233
|
+
}
|
|
6234
|
+
return chain[at].bullet || chain[at].delim || null;
|
|
5920
6235
|
}
|
|
5921
6236
|
function looseBlankBefore(el, prevEl) {
|
|
5922
6237
|
if (!prevEl) return false;
|
|
@@ -8689,8 +9004,13 @@
|
|
|
8689
9004
|
const original = currentBurst && currentBurst.blockEl === blockEl ? currentBurst.original : null;
|
|
8690
9005
|
if (currentBurst && currentBurst.blockEl === blockEl) endBurstWithoutResolve();
|
|
8691
9006
|
// R58 (`joinSide`): the blank lines to the list being joined go too.
|
|
8692
|
-
|
|
8693
|
-
|
|
9007
|
+
// v3.17.3 A6: no lines at all is a removal (the dismissed 圖片 picker),
|
|
9008
|
+
// the paragraph and one blank line with it, like removeEmptiedParagraph().
|
|
9009
|
+
const range = newLines.length === 0 ? [block.startLine, block.endLine]
|
|
9010
|
+
: growOverBlanks(lines, block.startLine, block.endLine, joinSide || null, blocks);
|
|
9011
|
+
const result = newLines.length === 0
|
|
9012
|
+
? commitRangeRemoval({ lines, blocks, stack }, range[0], range[1])
|
|
9013
|
+
: commitRangeEdit({ lines, blocks, stack }, range[0], range[1], newLines.join('\n'));
|
|
8694
9014
|
if (result.op === null) return null;
|
|
8695
9015
|
const prevLines = lines;
|
|
8696
9016
|
lines = result.lines;
|
|
@@ -8742,6 +9062,17 @@
|
|
|
8742
9062
|
});
|
|
8743
9063
|
const startLine = await replaceParagraphWithLines(live, liveSurface, newLines);
|
|
8744
9064
|
if (startLine !== null) focusBlockAtLine(startLine, true);
|
|
9065
|
+
}, {
|
|
9066
|
+
// v3.17.3 A6 (R8): a dismissed dialog consumes the "/圖片" text, by the
|
|
9067
|
+
// commit the picked file would have made, minus the image. A paragraph
|
|
9068
|
+
// that was edited meanwhile, or that cannot be found again, is left alone.
|
|
9069
|
+
onCancel: async () => {
|
|
9070
|
+
const live = refindBlock(blockEl, identity, selfSession);
|
|
9071
|
+
const liveSurface = live ? blockContentEl(live) : null;
|
|
9072
|
+
if (!liveSurface || slashSurfaceText(liveSurface) !== typed) return;
|
|
9073
|
+
const startLine = await replaceParagraphWithLines(live, liveSurface, []);
|
|
9074
|
+
if (startLine !== null) focusBlockAtLine(startLine, true);
|
|
9075
|
+
},
|
|
8745
9076
|
});
|
|
8746
9077
|
}
|
|
8747
9078
|
|
|
@@ -8860,8 +9191,13 @@
|
|
|
8860
9191
|
}
|
|
8861
9192
|
const join = isListKind(match.kind) ? listJoinOf(blockEl, match.kind) : null;
|
|
8862
9193
|
const lines2 = join ? listJoinLines(join, match.kind, rest === '' ? [] : rest.split('\n')) : converted;
|
|
9194
|
+
// v3.17.3 N4 (R41): an ordered item joining the ordered list below
|
|
9195
|
+
// (same delimiter) renumbers it in op 2 — renumberedRunFrom(), A7's.
|
|
9196
|
+
const belowJoin = !join ? null : join.side === 'below' ? join : join.side === 'both' ? join.below : null;
|
|
9197
|
+
const tail = match.kind === 'ol' && belowJoin && belowJoin.delim === join.delim
|
|
9198
|
+
? renumberedRunFrom(belowJoin.run, join.ordinal + 1) : null;
|
|
8863
9199
|
await commitShortcutAt(blockEl, surface, isT, match, literal.split('\n'), lines2, markerLen,
|
|
8864
|
-
join ? join.side : null);
|
|
9200
|
+
join ? join.side : null, tail);
|
|
8865
9201
|
} finally {
|
|
8866
9202
|
if (rawBoxKind) shortcutRawBoxFlight = false;
|
|
8867
9203
|
endCommitFlight();
|
|
@@ -8901,6 +9237,20 @@
|
|
|
8901
9237
|
const all = allBlockEls().filter((el) => el === blockEl || !el.hasAttribute('data-ed-transient'));
|
|
8902
9238
|
const i = all.indexOf(blockEl);
|
|
8903
9239
|
if (i < 0) return null;
|
|
9240
|
+
// Final review I1 (R54): the lines this item is written over (a real
|
|
9241
|
+
// block's own) or after (a transient's: right after its anchor, or before
|
|
9242
|
+
// the first block, as commitInsertionAt() writes it). A neighbour list with
|
|
9243
|
+
// a line that is not blank in between (a link reference definition, which
|
|
9244
|
+
// no block owns) is a separate list in the file: joining it would write
|
|
9245
|
+
// over that line or past it, so that side does not join.
|
|
9246
|
+
const ownRec = blockRecOf(blockEl);
|
|
9247
|
+
const anchorRec = ownRec ? null : blockRecOf(transientAnchorEl(blockEl));
|
|
9248
|
+
const ownBottom = ownRec ? ownRec.endLine : anchorRec ? anchorRec.endLine : (blocks.length ? blocks[0].startLine - 1 : 0);
|
|
9249
|
+
const ownTop = ownRec ? ownRec.startLine : ownBottom + 1;
|
|
9250
|
+
const gapIsBlank = (from, to) => {
|
|
9251
|
+
for (let n = from; n <= to; n++) if (String(lines[n - 1] === undefined ? '' : lines[n - 1]).trim() !== '') return false;
|
|
9252
|
+
return true;
|
|
9253
|
+
};
|
|
8904
9254
|
const sideOf = (el, above) => {
|
|
8905
9255
|
if (!el || el.getAttribute('data-block-type') !== 'li' || el.getAttribute('data-block-id') === null) return null;
|
|
8906
9256
|
if (!above && (Number(el.getAttribute('data-indent')) || 0) !== 0) return null;
|
|
@@ -8915,11 +9265,12 @@
|
|
|
8915
9265
|
const refRec = blockRecOf(ref);
|
|
8916
9266
|
const edgeRec = blockRecOf(el);
|
|
8917
9267
|
if (!refRec || !edgeRec || refRec.endLine < refRec.startLine || edgeRec.endLine < edgeRec.startLine) return null;
|
|
9268
|
+
if (above ? !gapIsBlank(edgeRec.endLine + 1, ownTop - 1) : !gapIsBlank(ownBottom + 1, edgeRec.startLine - 1)) return null;
|
|
8918
9269
|
const m = /^(?:([-*+])|(\d{1,9})([.)]))(?:[ \t]|$)/.exec(lines[refRec.startLine - 1] || '');
|
|
8919
9270
|
if (!m) return null;
|
|
8920
9271
|
return { side: above ? 'above' : 'below', bullet: m[1] || '-',
|
|
8921
9272
|
ordinal: m[2] ? Number(m[2]) + (above ? 1 : 0) : 1, delim: m[3] || '.',
|
|
8922
|
-
at: above ? edgeRec.endLine : edgeRec.startLine };
|
|
9273
|
+
at: above ? edgeRec.endLine : edgeRec.startLine, run: run };
|
|
8923
9274
|
};
|
|
8924
9275
|
const above = sideOf(all[i - 1], true);
|
|
8925
9276
|
const below = sideOf(all[i + 1], false);
|
|
@@ -8939,11 +9290,12 @@
|
|
|
8939
9290
|
// writes it at the top level, and in an ordered list the empty path
|
|
8940
9291
|
// renumbers while this one keeps the number typed.
|
|
8941
9292
|
const oneList = isTransient(blockEl) && all[i + 1].getAttribute('data-list-start') !== '1';
|
|
8942
|
-
|
|
9293
|
+
// v3.17.3 N4: `below` names the list below for the shortcut's renumbering.
|
|
9294
|
+
if (!oneList) return Object.assign(above, { side: 'both', below: below });
|
|
8943
9295
|
const aRec = blockRecOf(all[i - 1]);
|
|
8944
9296
|
const bRec = blockRecOf(all[i + 1]);
|
|
8945
9297
|
const tight = !!(aRec && bRec && bRec.startLine === aRec.endLine + 1);
|
|
8946
|
-
return tight ? Object.assign(above, { side: 'both' }) : null;
|
|
9298
|
+
return tight ? Object.assign(above, { side: 'both', below: below }) : null;
|
|
8947
9299
|
}
|
|
8948
9300
|
return above || below;
|
|
8949
9301
|
}
|
|
@@ -8992,7 +9344,7 @@
|
|
|
8992
9344
|
await landSlashItem(result.newStartLine, item, true);
|
|
8993
9345
|
}
|
|
8994
9346
|
|
|
8995
|
-
async function commitShortcutAt(blockEl, surface, isT, match, literalLines, converted, markerLen, join) {
|
|
9347
|
+
async function commitShortcutAt(blockEl, surface, isT, match, literalLines, converted, markerLen, join, tail) {
|
|
8996
9348
|
let at;
|
|
8997
9349
|
if (isT) {
|
|
8998
9350
|
const anchorEl = transientAnchorEl(blockEl);
|
|
@@ -9006,7 +9358,7 @@
|
|
|
9006
9358
|
}
|
|
9007
9359
|
const rec = isT ? null : blocks.find((b) => b.id === at.blockId);
|
|
9008
9360
|
if (!isT && !rec) return;
|
|
9009
|
-
const r = commitShortcut({ lines, blocks, stack }, at, literalLines, converted, join);
|
|
9361
|
+
const r = commitShortcut({ lines, blocks, stack }, at, literalLines, converted, join, tail || null);
|
|
9010
9362
|
const op1 = r.ops[0];
|
|
9011
9363
|
const op2 = r.ops[1];
|
|
9012
9364
|
if (!op2) return;
|
|
@@ -9017,13 +9369,13 @@
|
|
|
9017
9369
|
if (isT) {
|
|
9018
9370
|
const first = op1 || op2;
|
|
9019
9371
|
editRange = { startLine: Math.min(first.startLine, first.endLine + 1),
|
|
9020
|
-
endLine: Math.max(first.startLine - 1, first.endLine), delta };
|
|
9372
|
+
endLine: Math.max(first.startLine - 1, first.endLine, r.tailEnd || 0), delta };
|
|
9021
9373
|
const shifted = {};
|
|
9022
9374
|
blocks.forEach((b) => { if (b.startLine >= first.startLine) shifted[b.id] = b.startLine + delta; });
|
|
9023
9375
|
pendingFlightLineMap = { ids: shifted, provisional: r.newStartLine };
|
|
9024
9376
|
} else {
|
|
9025
|
-
const grown = growOverBlanks(prevLines, rec.startLine, rec.endLine, join || null);
|
|
9026
|
-
editRange = { startLine: grown[0], endLine: grown[1], delta };
|
|
9377
|
+
const grown = growOverBlanks(prevLines, rec.startLine, rec.endLine, join || null, blocks);
|
|
9378
|
+
editRange = { startLine: grown[0], endLine: Math.max(grown[1], r.tailEnd || 0), delta };
|
|
9027
9379
|
pendingFlightLineMap = { ids: { [rec.id]: r.newStartLine }, provisional: r.newStartLine };
|
|
9028
9380
|
}
|
|
9029
9381
|
const original = !isT && currentBurst && currentBurst.blockEl === blockEl ? currentBurst.original : null;
|
|
@@ -9293,8 +9645,10 @@
|
|
|
9293
9645
|
// R4: the text a paragraph / heading surface commits as — the heading's
|
|
9294
9646
|
// own `#` run in front, a paragraph's line-start markers escaped (F-1).
|
|
9295
9647
|
// resolveBurst() uses the same rule; split and merge serialize through it.
|
|
9648
|
+
// The closing-sequence escape lives in convert-md.js (the block menu uses it too).
|
|
9649
|
+
function escapeClosingHashes(md) { return convertMd.escapeClosingHashes(md); }
|
|
9296
9650
|
function commitTextOf(md, blockType, depth) {
|
|
9297
|
-
if (blockType === 'heading') return md === '' ? '#'.repeat(depth) : '#'.repeat(depth) + ' ' + md;
|
|
9651
|
+
if (blockType === 'heading') return md === '' ? '#'.repeat(depth) : '#'.repeat(depth) + ' ' + escapeClosingHashes(md);
|
|
9298
9652
|
return blockType === 'paragraph' ? inlineMd.escapeBlockStarts(md) : md;
|
|
9299
9653
|
}
|
|
9300
9654
|
function surfaceCommitText(surface, blockType, depth) {
|
|
@@ -9443,15 +9797,23 @@
|
|
|
9443
9797
|
}
|
|
9444
9798
|
if (place === 'middle') { await splitBlockAtCaret(burst, editEl, blockEl); return; }
|
|
9445
9799
|
const live = await settleEnterSource(burst, blockEl);
|
|
9446
|
-
if (!live) return;
|
|
9800
|
+
if (!live) { keepFailedEdgeEnter(burst, editEl, place); return; }
|
|
9801
|
+
openEdgeParagraph(live, place, true);
|
|
9802
|
+
}
|
|
9803
|
+
|
|
9804
|
+
// The paragraph an Enter at `live`'s edge opens: above it for 'start' (the
|
|
9805
|
+
// caret stays in `live`, at its start when `placeCaret`), below its subtree
|
|
9806
|
+
// for 'end' (focused).
|
|
9807
|
+
function openEdgeParagraph(live, place, placeCaret) {
|
|
9447
9808
|
const surface = blockContentEl(live);
|
|
9448
9809
|
if (place === 'start') {
|
|
9449
9810
|
createTransient(previousBlockEl(live), { focus: false, byEnter: true, stayEl: live });
|
|
9450
|
-
if (!surface || !surface.classList.contains('ed-wys-armed')) return;
|
|
9811
|
+
if (!placeCaret || !surface || !surface.classList.contains('ed-wys-armed')) return;
|
|
9451
9812
|
if (document.activeElement !== surface) surface.focus();
|
|
9452
9813
|
const r = document.createRange();
|
|
9453
9814
|
r.selectNodeContents(surface);
|
|
9454
9815
|
r.collapse(true);
|
|
9816
|
+
const sel = window.getSelection();
|
|
9455
9817
|
sel.removeAllRanges();
|
|
9456
9818
|
sel.addRange(r);
|
|
9457
9819
|
// R3: a surface that kept focus through the commit gets no focusin, and
|
|
@@ -9463,6 +9825,36 @@
|
|
|
9463
9825
|
anchorCaret: surface ? { surface, offset: surface.textContent.length } : null });
|
|
9464
9826
|
}
|
|
9465
9827
|
|
|
9828
|
+
// v3.17.3 B3: the edge Enter commits the typing before it first
|
|
9829
|
+
// (settleEnterSource()). When that commit's render failed, the Enter was
|
|
9830
|
+
// dropped: the card came up, no paragraph opened, and only a second Enter
|
|
9831
|
+
// opened one. Now the typing is kept as the pending structure (D7) — so the
|
|
9832
|
+
// one retry every other failed structure gets (a resolution such as Ctrl+S,
|
|
9833
|
+
// a focus into a surface, a structural key, runToolbarAction()) writes it —
|
|
9834
|
+
// and the Enter replays once that retry has landed (`after`), if the caret
|
|
9835
|
+
// is still in this block then. A caret that went elsewhere drops it: an
|
|
9836
|
+
// empty paragraph nobody is in would be removed again at once (D3). A
|
|
9837
|
+
// second Enter pressed meanwhile is held behind the retry and replays into
|
|
9838
|
+
// the paragraph this one opened, so it opens no second one.
|
|
9839
|
+
function keepFailedEdgeEnter(burst, editEl, place) {
|
|
9840
|
+
if (burst.transient || currentBurst !== burst || pendingStructure || !editEl.isConnected) return;
|
|
9841
|
+
if (!burstHasUncommittedEdit() || reconcileBlocksCommits()) return;
|
|
9842
|
+
const rec = blocks.find((b) => b.id === burst.blockId);
|
|
9843
|
+
if (!rec) return;
|
|
9844
|
+
const blockId = burst.blockId;
|
|
9845
|
+
const startLine = rec.startLine;
|
|
9846
|
+
setPendingStructure(rec.startLine, rec.endLine, [editEl], () => ({
|
|
9847
|
+
text: surfaceCommitText(editEl, burst.blockType, burst.depth),
|
|
9848
|
+
lineMap: { ids: { [blockId]: startLine }, provisional: null },
|
|
9849
|
+
}), null, null);
|
|
9850
|
+
pendingStructure.after = () => {
|
|
9851
|
+
const live = blockElAtLine(startLine);
|
|
9852
|
+
const surface = live ? blockContentEl(live) : null;
|
|
9853
|
+
if (!surface || !surface.contains(document.activeElement)) return;
|
|
9854
|
+
openEdgeParagraph(live, place, false);
|
|
9855
|
+
};
|
|
9856
|
+
}
|
|
9857
|
+
|
|
9466
9858
|
// The edge cases' first step: commit what is pending in the block Enter was
|
|
9467
9859
|
// pressed in (R7) and hand back the live element it is now — re-resolved by
|
|
9468
9860
|
// its start line when the commit re-rendered it, or the paragraph a
|
|
@@ -9564,10 +9956,13 @@
|
|
|
9564
9956
|
// wrote as its own op before the structure ({ blockId, text }, or null).
|
|
9565
9957
|
// The retry writes it as its own op again, so one Ctrl+Z still takes the
|
|
9566
9958
|
// structure back alone.
|
|
9959
|
+
// `after` (v3.17.3 B3, optional, set by the caller): run once a retry has
|
|
9960
|
+
// landed and put the caret back — the gesture that was waiting on the
|
|
9961
|
+
// structure (keepFailedEdgeEnter()).
|
|
9567
9962
|
function setPendingStructure(startLine, endLine, surfaces, serialize, covers, typed) {
|
|
9568
9963
|
pendingStructure = { range: { startLine, endLine },
|
|
9569
9964
|
before: lines.slice(startLine - 1, endLine).join('\n'), surfaces, serialize, covers: covers || null,
|
|
9570
|
-
typedIds: new Set(), typed: typed || null };
|
|
9965
|
+
typedIds: new Set(), typed: typed || null, after: null };
|
|
9571
9966
|
setDirty(); // R34: it is unsaved work (documentIsDirty())
|
|
9572
9967
|
}
|
|
9573
9968
|
|
|
@@ -9627,7 +10022,7 @@
|
|
|
9627
10022
|
// `text: null` is a removal (an emptied block whose removal did not land).
|
|
9628
10023
|
const result = s.text === null
|
|
9629
10024
|
? commitRangeRemoval(state, ps.range.startLine, endLine)
|
|
9630
|
-
: commitRangeEdit(state, ps.range.startLine, endLine, s.text);
|
|
10025
|
+
: commitRangeEdit(state, ps.range.startLine, endLine, s.text, { carriesGap: s.carriesGap === true });
|
|
9631
10026
|
if (result.op !== null) lines = result.lines;
|
|
9632
10027
|
else if (typedOp) lines = typedOp.lines;
|
|
9633
10028
|
const editRange = { startLine: ps.range.startLine, endLine: ps.range.endLine,
|
|
@@ -9679,6 +10074,14 @@
|
|
|
9679
10074
|
restoreSelection(surface, caretTag.anchor, caretTag.focus);
|
|
9680
10075
|
}
|
|
9681
10076
|
}
|
|
10077
|
+
// v3.17.3 B3: only when the caret was in the structure when the retry
|
|
10078
|
+
// began (a press elsewhere has moved on from the waiting gesture).
|
|
10079
|
+
// R30 (fix round 1): that includes a retry started by a toolbar action
|
|
10080
|
+
// or an undo (both resolve through resolveOpenSession() with the caret
|
|
10081
|
+
// still here). The failed Enter replays first and the action then
|
|
10082
|
+
// applies to the paragraph it opened — the order the user pressed them
|
|
10083
|
+
// in. Intended.
|
|
10084
|
+
if (ps.after && caretTag) ps.after();
|
|
9682
10085
|
return true;
|
|
9683
10086
|
}
|
|
9684
10087
|
|
|
@@ -9744,8 +10147,10 @@
|
|
|
9744
10147
|
// Spec §3.2. Backspace with the caret at the start of a paragraph joins it
|
|
9745
10148
|
// to the block above (a paragraph, a heading, or a list item's text); at
|
|
9746
10149
|
// the start of a heading it makes the heading a paragraph. Delete at the
|
|
9747
|
-
// end of a paragraph or heading pulls the next one up.
|
|
9748
|
-
// no
|
|
10150
|
+
// end of a paragraph or heading pulls the next one up (v3.17.3 B9: a list
|
|
10151
|
+
// item with no children too, see pullListItemUp(); Delete at a list item's
|
|
10152
|
+
// end is liDeleteAtEnd()). A neighbour that has no text to join (a table, a
|
|
10153
|
+
// code block, a list item that owns children, for Delete) is
|
|
9749
10154
|
// selected instead, and an EMPTY block below such a neighbour goes away
|
|
9750
10155
|
// with the caret landing at the neighbour's end (R12). Every case is one
|
|
9751
10156
|
// undo step; typing the key commits first is its own earlier step (D4).
|
|
@@ -9910,7 +10315,7 @@
|
|
|
9910
10315
|
const range = o.range(state.blocks);
|
|
9911
10316
|
second = o.text === null
|
|
9912
10317
|
? commitRangeRemoval(state, range[0], range[1])
|
|
9913
|
-
: commitRangeEdit(state, range[0], range[1], o.text);
|
|
10318
|
+
: commitRangeEdit(state, range[0], range[1], o.text, { carriesGap: o.carriesGap === true });
|
|
9914
10319
|
}
|
|
9915
10320
|
lines = second.lines;
|
|
9916
10321
|
const delta = (typedOp ? typedOp.op.after.length - typedOp.op.before.length : 0) +
|
|
@@ -10001,7 +10406,10 @@
|
|
|
10001
10406
|
const typed = empty ? null : typedInBurst(burst);
|
|
10002
10407
|
let seam = 0;
|
|
10003
10408
|
withFocusoutSuppressed(() => {
|
|
10004
|
-
|
|
10409
|
+
// v3.17.3 N1 (R33): into a loose item's <p>, not beside it (joinHostOf()).
|
|
10410
|
+
// Measured before: `- a`, blank, `- b`, blank, `P.`, Backspace at the
|
|
10411
|
+
// start of `P.` wrote `- a\n\n- P.\n` (`b` lost).
|
|
10412
|
+
seam = joinSeam(joinHostOf(prevSurface), Array.prototype.slice.call(editEl.childNodes), prevType === 'heading');
|
|
10005
10413
|
structureMutated = true;
|
|
10006
10414
|
prevSurface.focus();
|
|
10007
10415
|
restoreSelection(prevSurface, seam, seam);
|
|
@@ -10141,7 +10549,19 @@
|
|
|
10141
10549
|
else selectBlock(next);
|
|
10142
10550
|
return;
|
|
10143
10551
|
}
|
|
10144
|
-
//
|
|
10552
|
+
// v3.17.3 B9 (R11, supersedes D14's select for this case): a list item
|
|
10553
|
+
// with no children of its own is pulled up into a paragraph or heading,
|
|
10554
|
+
// as in Notion. One that owns children is still selected (pulling it up
|
|
10555
|
+
// would leave them with no parent), and so is one below a transient.
|
|
10556
|
+
// R34: an item over several source lines (a hard wrap, a continuation
|
|
10557
|
+
// paragraph, a fenced child) counts as one with children, and so does
|
|
10558
|
+
// one whose list the structural serializer refuses: both keep the
|
|
10559
|
+
// select, never a refusal notice.
|
|
10560
|
+
if (nextSurface && nextType === 'li' && !curIsTransient && liJoinable(next) &&
|
|
10561
|
+
listRunSupportsStructuralEdit(listRunOf(next), next)) {
|
|
10562
|
+
await pullListItemUp(burst, editEl, next, nextSurface, empty);
|
|
10563
|
+
return;
|
|
10564
|
+
}
|
|
10145
10565
|
if (!nextSurface || nextType === 'li') { selectBlock(next); return; }
|
|
10146
10566
|
const nextRec = blockRecOf(next);
|
|
10147
10567
|
const curRec = curIsTransient ? null : blocks.find((b) => b.id === burst.blockId);
|
|
@@ -10176,6 +10596,54 @@
|
|
|
10176
10596
|
if (ok) focusBlockAtLineOffset(headLine, seam);
|
|
10177
10597
|
}
|
|
10178
10598
|
|
|
10599
|
+
// v3.17.3 B9 (R11): Delete at the end of a paragraph or heading whose next
|
|
10600
|
+
// block is a list item with no children. The item's text joins the block
|
|
10601
|
+
// and the item leaves its list; the commit is ONE op over the block's
|
|
10602
|
+
// first line through the end of the list, written as the block, the lines
|
|
10603
|
+
// that separated it from the list (kept byte for byte), then the rest of
|
|
10604
|
+
// the list (its other items replayed from the file). An emptied list is
|
|
10605
|
+
// written as nothing. The block's own typing is its own earlier op (D4).
|
|
10606
|
+
async function pullListItemUp(burst, editEl, li, liSurface, empty) {
|
|
10607
|
+
const curRec = blocks.find((b) => b.id === burst.blockId);
|
|
10608
|
+
const run = listRunOf(li);
|
|
10609
|
+
const runRange = run.length ? runRangeOfBlocks({ lines, blocks, stack }, run) : null;
|
|
10610
|
+
// deleteAtEnd() checked the run and the item (R34) synchronously first.
|
|
10611
|
+
if (!curRec || !runRange || runRange.startLine <= curRec.endLine) return;
|
|
10612
|
+
const typed = empty ? null : typedInBurst(burst);
|
|
10613
|
+
const gap = lines.slice(curRec.endLine, runRange.startLine - 1);
|
|
10614
|
+
const survivors = run.filter((el) => el !== li);
|
|
10615
|
+
let seam = 0;
|
|
10616
|
+
withFocusoutSuppressed(() => {
|
|
10617
|
+
seam = joinSeam(editEl, Array.prototype.slice.call(joinHostOf(liSurface).childNodes), burst.blockType === 'heading');
|
|
10618
|
+
structureMutated = true;
|
|
10619
|
+
restoreSelection(editEl, seam, seam);
|
|
10620
|
+
mutateListRun(() => removeListItem(li));
|
|
10621
|
+
});
|
|
10622
|
+
endBurstWithoutResolve();
|
|
10623
|
+
const serialize = () => {
|
|
10624
|
+
const head = surfaceCommitText(editEl, burst.blockType, burst.depth);
|
|
10625
|
+
const ids = { [curRec.id]: curRec.startLine };
|
|
10626
|
+
if (!survivors.length) return { text: head, lineMap: { ids, provisional: null } };
|
|
10627
|
+
const out = serializeListEls(survivors, { carryOver: bystanderCarryOver(survivors) });
|
|
10628
|
+
const listStart = curRec.startLine + head.split('\n').length + gap.length;
|
|
10629
|
+
Object.assign(ids, flightLineMapOf(out.lineMeta, listStart).ids);
|
|
10630
|
+
// R56: `gap` is the lines between the block and the list, sliced from
|
|
10631
|
+
// `lines` verbatim, and a list run never spans a definition (every list
|
|
10632
|
+
// token opens a new run), so the text holds every definition line of
|
|
10633
|
+
// the range.
|
|
10634
|
+
return { text: [head].concat(gap, [out.md]).join('\n'), lineMap: { ids, provisional: null }, carriesGap: true };
|
|
10635
|
+
};
|
|
10636
|
+
const s = serialize();
|
|
10637
|
+
const ok = await commitTypingThenStructure({ typed, text: s.text, carriesGap: s.carriesGap === true,
|
|
10638
|
+
range: (bs) => {
|
|
10639
|
+
const c = bs.find((b) => b.id === curRec.id);
|
|
10640
|
+
return [c.startLine, runRangeOfBlocks({ blocks: bs }, run).endLine];
|
|
10641
|
+
},
|
|
10642
|
+
oldRange: [curRec.startLine, runRange.endLine], lineMap: s.lineMap,
|
|
10643
|
+
pending: { surfaces: [editEl], serialize } });
|
|
10644
|
+
if (ok) focusBlockAtLineOffset(curRec.startLine, seam);
|
|
10645
|
+
}
|
|
10646
|
+
|
|
10179
10647
|
// S2 Task 7 — the li half of +. The new item is spliced into the run's own
|
|
10180
10648
|
// span and the WHOLE span is re-serialized over the run's line range: one
|
|
10181
10649
|
// commitRangeEdit, therefore one undo op, no leading blank line (so the run
|
|
@@ -10746,6 +11214,31 @@
|
|
|
10746
11214
|
}
|
|
10747
11215
|
}
|
|
10748
11216
|
|
|
11217
|
+
// v3.17.4 D3 (R7): a block converted to text, a list kind or a quote is
|
|
11218
|
+
// written over its own lines only, so a non-blank line directly above or
|
|
11219
|
+
// below it (a link reference definition, a paragraph) would be read as a lazy
|
|
11220
|
+
// line of it. These two answer, for the converted lines firstLine..lastLine
|
|
11221
|
+
// (1-indexed inclusive), whether a blank line has to be written before and
|
|
11222
|
+
// after them. Heading and code targets are fenced off by their own syntax and
|
|
11223
|
+
// are left alone. `skip` names a side the caller handles itself (a list item
|
|
11224
|
+
// that joins the list there must NOT be separated from it).
|
|
11225
|
+
function convertNeedsBlank(target) {
|
|
11226
|
+
return !(/^h[1-6]$/.test(target) || target === 'code');
|
|
11227
|
+
}
|
|
11228
|
+
function convertLeadBlank(firstLine, target, skip) {
|
|
11229
|
+
if (!convertNeedsBlank(target) || (skip && skip.lead)) return false;
|
|
11230
|
+
const prev = lines[firstLine - 2];
|
|
11231
|
+
return prev !== undefined && String(prev).trim() !== '';
|
|
11232
|
+
}
|
|
11233
|
+
function separatedConvertText(md, firstLine, lastLine, target, skip) {
|
|
11234
|
+
if (!convertNeedsBlank(target)) return md;
|
|
11235
|
+
let out = md;
|
|
11236
|
+
if (convertLeadBlank(firstLine, target, skip)) out = '\n' + out;
|
|
11237
|
+
const next = lines[lastLine];
|
|
11238
|
+
if (!(skip && skip.trail) && next !== undefined && String(next).trim() !== '') out = out + '\n';
|
|
11239
|
+
return out;
|
|
11240
|
+
}
|
|
11241
|
+
|
|
10749
11242
|
async function convertBlockViaMenuBody(blockEl, target, anchor) {
|
|
10750
11243
|
if (!blockEl || !target) return;
|
|
10751
11244
|
// §3.3's membership rules and the whole shared preamble — see
|
|
@@ -10835,13 +11328,17 @@
|
|
|
10835
11328
|
pieces.push(convertMd.emitAs(stripped.content, target, {}).join('\n'));
|
|
10836
11329
|
}
|
|
10837
11330
|
const md = pieces.join('\n\n');
|
|
11331
|
+
// Nothing changed (converting an H2 to 標題 2, a paragraph to 文字, a quote
|
|
11332
|
+
// to 引用): nothing is written and nothing pushed onto the undo stack, so
|
|
11333
|
+
// there is nothing to render or roll back and no selection range to
|
|
11334
|
+
// declare. Asked before the D3 separation, which would otherwise turn an
|
|
11335
|
+
// own-type convert next to a tight neighbour into a blank-line edit.
|
|
11336
|
+
if (md === lines.slice(first.startLine - 1, last.endLine).join('\n')) return;
|
|
11337
|
+
const lead = convertLeadBlank(first.startLine, target) ? 1 : 0;
|
|
10838
11338
|
|
|
10839
11339
|
const result = commitRangeEdit({ lines, blocks, stack },
|
|
10840
|
-
first.startLine, last.endLine, md);
|
|
10841
|
-
|
|
10842
|
-
// onto the undo stack either, so there is nothing to render or roll back,
|
|
10843
|
-
// and no selection range to declare.
|
|
10844
|
-
if (result.op === null) return;
|
|
11340
|
+
first.startLine, last.endLine, separatedConvertText(md, first.startLine, last.endLine, target));
|
|
11341
|
+
if (result.op === null) return; // same answer from the commit itself: no step pushed
|
|
10845
11342
|
const prevLines = lines;
|
|
10846
11343
|
lines = result.lines;
|
|
10847
11344
|
// §3.3: 「操作後集合塌縮為操作結果所涵蓋的行區間」. Declared IMMEDIATELY
|
|
@@ -10849,8 +11346,8 @@
|
|
|
10849
11346
|
// the declaration at its very top, so one left standing lands on some
|
|
10850
11347
|
// later, unrelated render.
|
|
10851
11348
|
declareCollapse({
|
|
10852
|
-
startLine: first.startLine,
|
|
10853
|
-
endLine: first.startLine + md.split('\n').length - 1,
|
|
11349
|
+
startLine: first.startLine + lead,
|
|
11350
|
+
endLine: first.startLine + lead + md.split('\n').length - 1,
|
|
10854
11351
|
});
|
|
10855
11352
|
const editRange = result.op
|
|
10856
11353
|
? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
|
|
@@ -10865,7 +11362,7 @@
|
|
|
10865
11362
|
// pre-commit startLine — it is the very line the collapse above declares,
|
|
10866
11363
|
// i.e. the operated span's post-commit first line. The plain path is the
|
|
10867
11364
|
// one path that does not move it (the commit replaces the span in place).
|
|
10868
|
-
return first.startLine;
|
|
11365
|
+
return first.startLine + lead;
|
|
10869
11366
|
}
|
|
10870
11367
|
|
|
10871
11368
|
// Is the editor session switchAwayFrom() would resolve open on THIS block?
|
|
@@ -10896,6 +11393,9 @@
|
|
|
10896
11393
|
// block from the one that moved up into its place. An identity taken before
|
|
10897
11394
|
// `tail` existed (none today) never reads as removed.
|
|
10898
11395
|
function selfCommitRemovedBlock(identity) {
|
|
11396
|
+
// v3.17.3 C4: the same epoch compare as reresolveBlockElAfterSelfCommit() below; a
|
|
11397
|
+
// reconcile that landed meanwhile changed `lines` for another reason.
|
|
11398
|
+
if (identity && identity.reconcileEpoch !== reconcileEpoch) return false;
|
|
10899
11399
|
return !!identity && identity.tail !== undefined &&
|
|
10900
11400
|
lines.length - identity.tail < identityStartLineNow(identity);
|
|
10901
11401
|
}
|
|
@@ -11634,6 +12134,10 @@
|
|
|
11634
12134
|
{ el: i > 0 ? all[i - 1] : null, attrs: headAttrs, back: true },
|
|
11635
12135
|
{ el: (j >= 0 && j + 1 < all.length) ? all[j + 1] : null, attrs: tailAttrs, back: false },
|
|
11636
12136
|
];
|
|
12137
|
+
// v3.17.3 A7: the neighbouring run each side compared equal to — the list
|
|
12138
|
+
// the span joins there, blank line or not — for a caller that has to
|
|
12139
|
+
// number into it (convertBlocksIntoList()).
|
|
12140
|
+
const joined = { above: null, below: null };
|
|
11637
12141
|
for (let k = 0; k < sides.length; k++) {
|
|
11638
12142
|
const side = sides[k];
|
|
11639
12143
|
if (!side.el || !side.attrs) continue;
|
|
@@ -11645,21 +12149,27 @@
|
|
|
11645
12149
|
if ((Number(head.getAttribute('data-indent')) || 0) !== side.attrs.indent) continue;
|
|
11646
12150
|
const headType = head.getAttribute('data-list-type') === 'ol' ? 'ol' : 'ul';
|
|
11647
12151
|
if (headType !== side.attrs.listType) continue;
|
|
11648
|
-
//
|
|
11649
|
-
//
|
|
11650
|
-
//
|
|
11651
|
-
//
|
|
11652
|
-
// one
|
|
11653
|
-
|
|
12152
|
+
// No block owns a separator (buildBlockMap() strips a token's trailing
|
|
12153
|
+
// newlines), but the lines between two blocks are not all blank: a link
|
|
12154
|
+
// reference definition is on lines no block owns either (final review
|
|
12155
|
+
// I1, R54). So the blank run is eaten only when it reaches the
|
|
12156
|
+
// neighbour's run exactly; one that stops at another line (a definition)
|
|
12157
|
+
// means the two are separate lists in the file, and nothing joins —
|
|
12158
|
+
// eating the run made the definition the converted item's text. Eating
|
|
12159
|
+
// ALL of a run that reaches is also 「正規化連續空行」: measured,
|
|
12160
|
+
// '- a\n\n\n- b\n' is still ONE loose list, so stopping after one blank
|
|
12161
|
+
// would leave the degrade in place.
|
|
12162
|
+
let edge = side.back ? startLine : endLine;
|
|
11654
12163
|
if (side.back) {
|
|
11655
|
-
while (
|
|
11656
|
-
startLine -= 1; moved = true;
|
|
11657
|
-
}
|
|
12164
|
+
while (edge >= 2 && String(lines[edge - 2]).trim() === '') edge -= 1;
|
|
11658
12165
|
} else {
|
|
11659
|
-
while (
|
|
11660
|
-
endLine += 1; moved = true;
|
|
11661
|
-
}
|
|
12166
|
+
while (edge < lines.length && String(lines[edge]).trim() === '') edge += 1;
|
|
11662
12167
|
}
|
|
12168
|
+
const reach = side.back ? edge - 1 : edge + 1;
|
|
12169
|
+
if (!blocks.some((b) => (side.back ? b.endLine : b.startLine) === reach)) continue;
|
|
12170
|
+
joined[side.back ? 'above' : 'below'] = nrun;
|
|
12171
|
+
const moved = side.back ? edge !== startLine : edge !== endLine;
|
|
12172
|
+
if (side.back) startLine = edge; else endLine = edge;
|
|
11663
12173
|
// No separator between us and it: they are already two runs that
|
|
11664
12174
|
// markdown keeps apart for a reason this rule does not touch (a
|
|
11665
12175
|
// delimiter change, `- a` / `* b`). Nothing to eat, nothing to gate.
|
|
@@ -11673,7 +12183,23 @@
|
|
|
11673
12183
|
return { startLine: range.startLine, endLine: range.endLine, ok: false };
|
|
11674
12184
|
}
|
|
11675
12185
|
}
|
|
11676
|
-
return { startLine: startLine, endLine: endLine, ok: true };
|
|
12186
|
+
return { startLine: startLine, endLine: endLine, ok: true, joined: joined };
|
|
12187
|
+
}
|
|
12188
|
+
|
|
12189
|
+
// v3.17.3 A7 / N4 (R6, R41): the bytes of the ordered run `run` renumbered
|
|
12190
|
+
// from `firstOrdinal`, for a gesture whose new item(s) join that list from
|
|
12191
|
+
// above — the run's own re-serialization with every item carried (each
|
|
12192
|
+
// item's bytes replayed, only its marker re-stated: the commit a typed
|
|
12193
|
+
// `1. x` gets once x is typed), and its current line range. The caller
|
|
12194
|
+
// writes them in its OWN op, so one undo step takes the join and the new
|
|
12195
|
+
// numbers back together. null when the run cannot be located or the run
|
|
12196
|
+
// gate refuses it (the join then leaves its numbers as they were).
|
|
12197
|
+
function renumberedRunFrom(run, firstOrdinal) {
|
|
12198
|
+
if (!run || !run.length) return null;
|
|
12199
|
+
const range = runRangeOfBlocks({ lines, blocks, stack }, run);
|
|
12200
|
+
if (!range || !listRunSupportsStructuralEdit(run, null, { columnOnly: true })) return null;
|
|
12201
|
+
const out = serializeListEls(run, { carryOver: bystanderCarryOver(run, null), firstOrdinal: firstOrdinal });
|
|
12202
|
+
return out.md === '' ? null : { range: range, md: out.md };
|
|
11677
12203
|
}
|
|
11678
12204
|
|
|
11679
12205
|
// The {listType, indent} a span member will carry once the operand set
|
|
@@ -12093,18 +12619,6 @@
|
|
|
12093
12619
|
async function convertBlocksIntoList(blockEls, recs, kinds, target) {
|
|
12094
12620
|
const attrs = convertMd.listAttrsFor(target);
|
|
12095
12621
|
if (!attrs) return;
|
|
12096
|
-
// One item per member, joined by a bare newline: the members' own
|
|
12097
|
-
// separators are INSIDE the commit range and are replaced, which is what
|
|
12098
|
-
// makes N paragraphs one TIGHT list. A blank between them would make the
|
|
12099
|
-
// list loose and degrade every item read-only (§4.3 rule 2).
|
|
12100
|
-
const pieces = [];
|
|
12101
|
-
for (let i = 0; i < recs.length; i++) {
|
|
12102
|
-
const stripped = convertMd.stripMarker(
|
|
12103
|
-
lines.slice(recs[i].startLine - 1, recs[i].endLine), kinds[i]);
|
|
12104
|
-
if (!stripped.ok) { refuseStructuralListEdit('此區塊的格式無法轉換'); return; }
|
|
12105
|
-
pieces.push(convertMd.emitAs(stripped.content, target, {}).join('\n'));
|
|
12106
|
-
}
|
|
12107
|
-
const md = pieces.join('\n');
|
|
12108
12622
|
const first = recs[0];
|
|
12109
12623
|
const last = recs[recs.length - 1];
|
|
12110
12624
|
|
|
@@ -12117,16 +12631,77 @@
|
|
|
12117
12631
|
{ startLine: first.startLine, endLine: last.endLine }, blockEls, self, self);
|
|
12118
12632
|
if (!merged.ok) { refuseStructuralListEdit(); return; }
|
|
12119
12633
|
|
|
12634
|
+
// v3.17.3 A7 (R6) + A9 (R5): the items JOIN the list on either side
|
|
12635
|
+
// (merged.joined), so they are numbered and markered as its items. They
|
|
12636
|
+
// are written with its bullet character / ordinal delimiter, because with
|
|
12637
|
+
// any other one markdown reads them as a list of their own beside it (why,
|
|
12638
|
+
// not a check — see joinedStyle()); numbered, they continue from the list
|
|
12639
|
+
// above (the number its last top-level item has in the file, as
|
|
12640
|
+
// listJoinOf() reads it) or start at 1; and the ordered list they join
|
|
12641
|
+
// BELOW is renumbered after them — through the run's own re-serialization
|
|
12642
|
+
// with every item carried, the commit a typed `1. x` gets once x is typed
|
|
12643
|
+
// (each item's bytes replayed, only its marker re-stated). Same commit,
|
|
12644
|
+
// so one Ctrl+Z puts the paragraph and the old numbers back. Before this
|
|
12645
|
+
// `Intro` over `1. a\n2. b` wrote `1. Intro\n1. a\n2. b`.
|
|
12646
|
+
// `joined` only ever names a run whose head has the target's list type
|
|
12647
|
+
// (widenRangeForListMerge() sets it after that comparison), so `ref` is a
|
|
12648
|
+
// li of that type; null only when its marker cannot be read off the file.
|
|
12649
|
+
const joinedStyle = (run, which) => {
|
|
12650
|
+
if (!run || !run.length) return null;
|
|
12651
|
+
const tops = run.filter((x) => (Number(x.getAttribute('data-indent')) || 0) === 0);
|
|
12652
|
+
const ref = which === 'above' ? tops[tops.length - 1] : tops[0];
|
|
12653
|
+
const st = ref ? sourceMarkerOf(ref) : null;
|
|
12654
|
+
return st ? { style: st, ref: ref } : null;
|
|
12655
|
+
};
|
|
12656
|
+
const above = joinedStyle(merged.joined && merged.joined.above, 'above');
|
|
12657
|
+
const below = joinedStyle(merged.joined && merged.joined.below, 'below');
|
|
12658
|
+
const style = above ? above.style : below ? below.style : null;
|
|
12659
|
+
let ordinal = 1;
|
|
12660
|
+
if (attrs.listType === 'ol' && above) {
|
|
12661
|
+
const rec = blockRecOf(above.ref);
|
|
12662
|
+
const m = rec ? /^\s*(\d{1,9})[.)]/.exec(lines[rec.startLine - 1] || '') : null;
|
|
12663
|
+
ordinal = m ? Number(m[1]) + 1 : 1;
|
|
12664
|
+
}
|
|
12665
|
+
// One item per member, joined by a bare newline: the members' own
|
|
12666
|
+
// separators are INSIDE the commit range and are replaced, which is what
|
|
12667
|
+
// makes N paragraphs one TIGHT list. A blank between them would make the
|
|
12668
|
+
// list loose and degrade every item read-only (§4.3 rule 2).
|
|
12669
|
+
const pieces = [];
|
|
12670
|
+
for (let i = 0; i < recs.length; i++) {
|
|
12671
|
+
const stripped = convertMd.stripMarker(
|
|
12672
|
+
lines.slice(recs[i].startLine - 1, recs[i].endLine), kinds[i]);
|
|
12673
|
+
if (!stripped.ok) { refuseStructuralListEdit('此區塊的格式無法轉換'); return; }
|
|
12674
|
+
const piece = convertMd.emitAs(stripped.content, target, { ordinal: ordinal + i });
|
|
12675
|
+
// One character either way, so the continuation indent emitAs() put
|
|
12676
|
+
// under the marker still fits.
|
|
12677
|
+
if (style) piece[0] = attrs.listType === 'ol' ? piece[0].replace(/^(\d+)\./, '$1' + style) : style + piece[0].slice(1);
|
|
12678
|
+
pieces.push(piece.join('\n'));
|
|
12679
|
+
}
|
|
12680
|
+
const convertedMd = pieces.join('\n');
|
|
12681
|
+
let md = convertedMd;
|
|
12682
|
+
let endLine = merged.endLine;
|
|
12683
|
+
if (attrs.listType === 'ol' && below && (!above || below.style === above.style)) {
|
|
12684
|
+
const tail = renumberedRunFrom(merged.joined.below, ordinal + recs.length);
|
|
12685
|
+
if (tail && tail.range.startLine === merged.endLine + 1) {
|
|
12686
|
+
md = convertedMd + '\n' + tail.md; endLine = tail.range.endLine;
|
|
12687
|
+
}
|
|
12688
|
+
}
|
|
12689
|
+
|
|
12690
|
+
// v3.17.4 D3: a side the items join is not separated; every other side
|
|
12691
|
+
// keeps a blank line to the definition / text next to it.
|
|
12692
|
+
const skip = { lead: !!(merged.joined && merged.joined.above), trail: !!(merged.joined && merged.joined.below) };
|
|
12693
|
+
const lead = convertLeadBlank(merged.startLine, target, skip) ? 1 : 0;
|
|
12120
12694
|
const result = commitRangeEdit({ lines, blocks, stack },
|
|
12121
|
-
merged.startLine, merged.endLine,
|
|
12695
|
+
merged.startLine, endLine, separatedConvertText(md, merged.startLine, endLine, target, skip));
|
|
12122
12696
|
if (result.op === null) return;
|
|
12123
12697
|
const prevLines = lines;
|
|
12124
12698
|
lines = result.lines;
|
|
12125
|
-
// §3.3's collapse: the
|
|
12126
|
-
// commit range
|
|
12699
|
+
// §3.3's collapse: the converted items' own lines, which start the
|
|
12700
|
+
// (possibly widened) commit range; a list below renumbered in the same
|
|
12701
|
+
// commit is not part of the result.
|
|
12127
12702
|
declareCollapse({
|
|
12128
|
-
startLine: merged.startLine,
|
|
12129
|
-
endLine: merged.startLine +
|
|
12703
|
+
startLine: merged.startLine + lead,
|
|
12704
|
+
endLine: merged.startLine + lead + convertedMd.split('\n').length - 1,
|
|
12130
12705
|
});
|
|
12131
12706
|
const editRange = result.op
|
|
12132
12707
|
? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
|
|
@@ -12139,7 +12714,7 @@
|
|
|
12139
12714
|
// v3.2.1 §Task 5: the caller's `focusLine` — the WIDENED range's first
|
|
12140
12715
|
// line, which is where the merged list actually starts. `recs[0].startLine`
|
|
12141
12716
|
// would be wrong whenever §4.3 rule 2 ate a separator above the span.
|
|
12142
|
-
return merged.startLine;
|
|
12717
|
+
return merged.startLine + lead;
|
|
12143
12718
|
}
|
|
12144
12719
|
|
|
12145
12720
|
// The `.ed-li-check` chrome for a li that has just BECOME a task item.
|
|
@@ -12325,15 +12900,68 @@
|
|
|
12325
12900
|
// through its serializer, rewriting `|a|b|` as `| a | b |`. The baseline
|
|
12326
12901
|
// strings themselves are untouched: burst histories restore them as HTML.
|
|
12327
12902
|
// An allow-list of the declarations that count, so a style nobody has
|
|
12328
|
-
// thought about reads as not an edit only when it is a style
|
|
12329
|
-
// foreign
|
|
12903
|
+
// thought about reads as not an edit only when it is a style. (Other
|
|
12904
|
+
// foreign attributes stopped counting in v3.17.3, see BURST_KEPT_ATTRS.)
|
|
12330
12905
|
const BURST_KEPT_STYLE = /^(?:text-align|--ed-[\w-]+)$/;
|
|
12906
|
+
// v3.17.3 C9 (R15): and no attribute counts unless the editor or the
|
|
12907
|
+
// renderer writes it. A writing extension marks the editable element it
|
|
12908
|
+
// sits in (Grammarly: data-gramm, data-gramm_editor, data-enable-grammarly;
|
|
12909
|
+
// LanguageTool: data-lt-*), and a table burst's editEl is the whole table,
|
|
12910
|
+
// so a mark on one cell read as typing: the status went dirty and Ctrl+S
|
|
12911
|
+
// re-serialized the table (measured: the pipes `|a|b|` came back padded).
|
|
12912
|
+
// The paragraph surface was never affected, its own attributes sit outside
|
|
12913
|
+
// the editEl's innerHTML. An ALLOW-list, so an attribute nobody has thought
|
|
12914
|
+
// about is ignored (the failure that costs nothing) rather than an editor
|
|
12915
|
+
// attribute silently dropped from the list costing a real edit. Enumerated
|
|
12916
|
+
// from `setAttribute(`, `.dataset.` and `.className` writes in this file and
|
|
12917
|
+
// from the attributes the server-side renderer emits into blocks:
|
|
12918
|
+
// markup the editor's own edits change class, href, title, src, alt,
|
|
12919
|
+
// start, type, checked, disabled (task checkbox), id, name, target, rel,
|
|
12920
|
+
// width, height, colspan, rowspan, align, value, lang, dir
|
|
12921
|
+
// renderer / block structure data-block-id, data-block-type,
|
|
12922
|
+
// data-svg-plate, data-hard-break, data-indent, data-list-type,
|
|
12923
|
+
// data-list-start, data-list-loose, data-checked, data-task,
|
|
12924
|
+
// data-run-start, data-placeholder, data-kind, data-level,
|
|
12925
|
+
// data-drawio-pages
|
|
12926
|
+
// editor state on the surface contenteditable, spellcheck,
|
|
12927
|
+
// tabindex, role, aria-label and every aria-*, every data-ed-*
|
|
12928
|
+
// a column's alignment style, filtered to text-align
|
|
12929
|
+
// and --ed-* above (setColumnAlign() writes a style attribute)
|
|
12930
|
+
// 'style' itself is handled by BURST_KEPT_STYLE, not listed here.
|
|
12931
|
+
const BURST_KEPT_ATTRS = new Set(['class', 'href', 'title', 'src', 'alt', 'start', 'type', 'checked', 'disabled',
|
|
12932
|
+
'id', 'name', 'target', 'rel', 'width', 'height', 'colspan', 'rowspan', 'align', 'value', 'lang', 'dir',
|
|
12933
|
+
'data-block-id', 'data-block-type', 'data-svg-plate', 'data-hard-break', 'data-indent', 'data-list-type',
|
|
12934
|
+
'data-list-start', 'data-list-loose', 'data-checked', 'data-task', 'data-run-start', 'data-placeholder',
|
|
12935
|
+
'data-kind', 'data-level', 'data-drawio-pages', 'contenteditable', 'spellcheck', 'tabindex', 'role']);
|
|
12936
|
+
function burstAttrIsKept(name) {
|
|
12937
|
+
const n = name.toLowerCase();
|
|
12938
|
+
return n === 'style' || BURST_KEPT_ATTRS.has(n) || n.indexOf('aria-') === 0 || n.indexOf('data-ed-') === 0;
|
|
12939
|
+
}
|
|
12940
|
+
// Whether `html` carries any attribute outside the list (or a style): the
|
|
12941
|
+
// cheap gate in front of the parse. A hit that is only text that looks like
|
|
12942
|
+
// an attribute costs a parse and changes nothing.
|
|
12943
|
+
function burstHasForeignMarkup(html) {
|
|
12944
|
+
if (html.indexOf('style=') !== -1) return true;
|
|
12945
|
+
const tagRe = /<[a-zA-Z][^>]*>/g;
|
|
12946
|
+
let t;
|
|
12947
|
+
while ((t = tagRe.exec(html))) {
|
|
12948
|
+
const attrRe = /\s([^\s"'<>\/=]+)=/g;
|
|
12949
|
+
let a;
|
|
12950
|
+
while ((a = attrRe.exec(t[0]))) if (!burstAttrIsKept(a[1])) return true;
|
|
12951
|
+
}
|
|
12952
|
+
return false;
|
|
12953
|
+
}
|
|
12331
12954
|
const burstFingerprints = new Map();
|
|
12332
12955
|
function burstEditFingerprint(html) {
|
|
12333
12956
|
const hit = burstFingerprints.get(html);
|
|
12334
12957
|
if (hit !== undefined) return hit;
|
|
12335
12958
|
const tpl = document.createElement('template');
|
|
12336
12959
|
tpl.innerHTML = html;
|
|
12960
|
+
Array.prototype.forEach.call(tpl.content.querySelectorAll('*'), (el) => {
|
|
12961
|
+
Array.prototype.slice.call(el.attributes).forEach((at) => {
|
|
12962
|
+
if (!burstAttrIsKept(at.name)) el.removeAttribute(at.name);
|
|
12963
|
+
});
|
|
12964
|
+
});
|
|
12337
12965
|
Array.prototype.forEach.call(tpl.content.querySelectorAll('[style]'), (el) => {
|
|
12338
12966
|
const kept = [];
|
|
12339
12967
|
String(el.getAttribute('style')).split(';').forEach((decl) => {
|
|
@@ -12354,12 +12982,12 @@
|
|
|
12354
12982
|
return out;
|
|
12355
12983
|
}
|
|
12356
12984
|
// Both sides go through the same parse and serialize whenever either holds
|
|
12357
|
-
// a style, so a string the page serialized and one re-serialized here are
|
|
12985
|
+
// a style or an attribute outside the list, so a string the page serialized and one re-serialized here are
|
|
12358
12986
|
// never compared with each other.
|
|
12359
12987
|
function sameBurstHtml(a, b) {
|
|
12360
12988
|
if (a === b) return true;
|
|
12361
12989
|
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
|
12362
|
-
if (a
|
|
12990
|
+
if (!burstHasForeignMarkup(a) && !burstHasForeignMarkup(b)) return false;
|
|
12363
12991
|
return burstEditFingerprint(a) === burstEditFingerprint(b);
|
|
12364
12992
|
}
|
|
12365
12993
|
|
|
@@ -12505,6 +13133,7 @@
|
|
|
12505
13133
|
// §10-gap fix (review): untouched AND was pristine — an ordinary
|
|
12506
13134
|
// "insert +, click away without typing" changed-my-mind. Auto-remove
|
|
12507
13135
|
// the block instead of leaving its skeleton on disk.
|
|
13136
|
+
if (wasPristineForThisBlock && pristineItemSurvivesSave(burst)) return true;
|
|
12508
13137
|
if (wasPristineForThisBlock) return await discardPristineInsert();
|
|
12509
13138
|
return true;
|
|
12510
13139
|
}
|
|
@@ -13131,8 +13760,14 @@
|
|
|
13131
13760
|
// armed on one of these, or Shift+Clicking the ⠿ handle would build a
|
|
13132
13761
|
// selection instead of opening the menu, and a drag inside the raw
|
|
13133
13762
|
// textarea would fight its own text selection.
|
|
13763
|
+
// v3.17.3 A13: the diagram panel's source and the in-place code edit are
|
|
13764
|
+
// two more editors with a text selection of their own (ED_OWN_TEXT_EDITORS).
|
|
13765
|
+
// Measured before: a drag from the panel's source up over the blocks above
|
|
13766
|
+
// selected three blocks and closed the panel.
|
|
13767
|
+
const ED_OWN_TEXT_EDITORS = 'textarea.ed-diagram-src, pre[data-ed-code-edit]';
|
|
13134
13768
|
const ED_SEL_GESTURE_CHROME = '.ed-handle, .ed-handle-menu, .ed-insert, .ed-slash-menu, ' +
|
|
13135
|
-
'.ed-te-menu, .ed-te-grip, .ed-tb-insert, .ed-seltb, .ed-linkbar, .ed-linkcard, .ed-imgtb, .ed-conflict, .ed-raw'
|
|
13769
|
+
'.ed-te-menu, .ed-te-grip, .ed-tb-insert, .ed-seltb, .ed-linkbar, .ed-linkcard, .ed-imgtb, .ed-conflict, .ed-raw, ' +
|
|
13770
|
+
ED_OWN_TEXT_EDITORS;
|
|
13136
13771
|
function isSelGestureChrome(target) {
|
|
13137
13772
|
return !!(target && target.closest && target.closest(ED_SEL_GESTURE_CHROME));
|
|
13138
13773
|
}
|
|
@@ -13191,6 +13826,25 @@
|
|
|
13191
13826
|
// false by the next pointerdown so a gesture whose click never arrives
|
|
13192
13827
|
// (an abort, a release outside the window) cannot swallow a later one.
|
|
13193
13828
|
let blockSelClickSuppressed = false;
|
|
13829
|
+
// v3.17.3 A13: the diagram source or code edit (ED_OWN_TEXT_EDITORS) the
|
|
13830
|
+
// current press began in. Its trailing click, released outside that editor,
|
|
13831
|
+
// lands on an ancestor of both ends, and the click router's "outside any
|
|
13832
|
+
// block" branch would then resolve the editor the user is selecting in
|
|
13833
|
+
// (measured: a drag from the panel's source out to the page margin closed
|
|
13834
|
+
// the panel). The click belongs to the editor. Set by the pointerdown
|
|
13835
|
+
// listener, spent by that click, and cleared a task after the release
|
|
13836
|
+
// either way, so a press whose click never comes swallows nothing later.
|
|
13837
|
+
let ownTextPress = null;
|
|
13838
|
+
document.addEventListener('pointerup', () => {
|
|
13839
|
+
if (ownTextPress) setTimeout(() => { ownTextPress = null; }, 0);
|
|
13840
|
+
});
|
|
13841
|
+
// Fix round 1 (R47 M2): a native drag of selected text ends without a
|
|
13842
|
+
// pointerup (Chromium sends pointercancel when the drag starts) and without
|
|
13843
|
+
// a click, so the mark is dropped there and at the drag's end. Measured
|
|
13844
|
+
// before: the next click with no press of its own (a synthetic one outside
|
|
13845
|
+
// every block) was swallowed and the panel stayed open.
|
|
13846
|
+
document.addEventListener('pointercancel', () => { ownTextPress = null; });
|
|
13847
|
+
document.addEventListener('dragend', () => { ownTextPress = null; });
|
|
13194
13848
|
|
|
13195
13849
|
function armBlockSelDrag(e) {
|
|
13196
13850
|
if (isSelGestureChrome(e.target)) return;
|
|
@@ -13406,20 +14060,69 @@
|
|
|
13406
14060
|
await new Promise((r) => setTimeout(r, SETTLE_POLL_MS));
|
|
13407
14061
|
}
|
|
13408
14062
|
}
|
|
13409
|
-
// R94: `blockEl` once every in-flight commit has landed
|
|
13410
|
-
//
|
|
13411
|
-
// gesture is refused with the notice
|
|
13412
|
-
//
|
|
13413
|
-
// below the split, and a
|
|
13414
|
-
//
|
|
14063
|
+
// R94: `blockEl` once every in-flight commit has landed — the node itself
|
|
14064
|
+
// when that render kept it. v3.17.3 B4 (R17): one the render replaced is
|
|
14065
|
+
// found again, and the gesture is refused with the notice only when the
|
|
14066
|
+
// block is gone. Measured before: a split's render replaced every block
|
|
14067
|
+
// below the split, and a plain text commit's render every block, so a ⠿
|
|
14068
|
+
// 刪除 picked on another paragraph during the write deleted nothing and
|
|
14069
|
+
// showed 「文件已更新,請重試這個操作」. The identity is what the block READ
|
|
14070
|
+
// on screen at the gesture (drawnBlockPrint()), not its source: mid-flight
|
|
14071
|
+
// `lines` already holds the commit while `blocks` may or may not (R94), so
|
|
14072
|
+
// no line number or source taken then is trustworthy, while the drawn text
|
|
14073
|
+
// is what the user pointed at, typing included. Found again by
|
|
14074
|
+
// uniqueBlockElByPrint(), R104's rule (the one block of its type that reads
|
|
14075
|
+
// so, uniqueness being the proof of position), and never across a
|
|
14076
|
+
// reconciliation (R102).
|
|
14077
|
+
//
|
|
14078
|
+
// Fix round 1 (R46): only when all three hold — the print was unique AT
|
|
14079
|
+
// THE PRESS (the block it named is the one that reads so), it is unique
|
|
14080
|
+
// after the write, and no undo or redo rendered meanwhile (whose result is
|
|
14081
|
+
// not on screen at the press, so the screen the user pointed at says
|
|
14082
|
+
// nothing about the document it lands). Measured before: with `Omega.`
|
|
14083
|
+
// twice on screen and a 復原 rendering that turns the first back into
|
|
14084
|
+
// `Alpha.`, ⠿ 刪除 on the first deleted the second, silently. Otherwise the
|
|
14085
|
+
// notice, as before.
|
|
13415
14086
|
async function settledGestureBlock(blockEl) {
|
|
13416
14087
|
if (!blockEl) return null;
|
|
13417
14088
|
if (commitsSettled()) return blockEl;
|
|
14089
|
+
const print = drawnBlockPrint(blockEl);
|
|
14090
|
+
const refindable = historyRendersInFlight === 0 && uniqueBlockElByPrint(print) === blockEl;
|
|
14091
|
+
const epoch = reconcileEpoch;
|
|
14092
|
+
const historyAt = historyRenderSeq;
|
|
13418
14093
|
await settleCommits();
|
|
13419
14094
|
if (blockEl.isConnected) return blockEl;
|
|
14095
|
+
const live = refindable && epoch === reconcileEpoch && historyAt === historyRenderSeq && !reconcileBlocksCommits()
|
|
14096
|
+
? uniqueBlockElByPrint(print) : null;
|
|
14097
|
+
if (live) return live;
|
|
13420
14098
|
showError(DROPPED_GESTURE_MESSAGE, null, null);
|
|
13421
14099
|
return null;
|
|
13422
14100
|
}
|
|
14101
|
+
// v3.17.3 B4: a block as the screen draws it — its type and its text, white
|
|
14102
|
+
// space folded (a typed NBSP reads as the render's space). A list item's is
|
|
14103
|
+
// its own line, not its children's; any other block's is its whole text
|
|
14104
|
+
// without the editor's chrome (⠿, +, buttons), which a hover may add or
|
|
14105
|
+
// drop between two renders.
|
|
14106
|
+
const DRAWN_PRINT_CHROME = '.ed-handle, .ed-insert, button';
|
|
14107
|
+
function drawnBlockPrint(blockEl) {
|
|
14108
|
+
let text = '';
|
|
14109
|
+
if (blockEl.getAttribute('data-block-type') === 'li') {
|
|
14110
|
+
const own = blockContentEl(blockEl);
|
|
14111
|
+
text = own ? own.textContent : '';
|
|
14112
|
+
} else {
|
|
14113
|
+
const copy = blockEl.cloneNode(true);
|
|
14114
|
+
copy.querySelectorAll(DRAWN_PRINT_CHROME).forEach((x) => x.remove());
|
|
14115
|
+
text = copy.textContent;
|
|
14116
|
+
}
|
|
14117
|
+
return { type: blockEl.getAttribute('data-block-type'), text: text.replace(/\s+/g, ' ').trim() };
|
|
14118
|
+
}
|
|
14119
|
+
function uniqueBlockElByPrint(print) {
|
|
14120
|
+
const hits = Array.prototype.slice.call(contentEl.querySelectorAll('.ed-block[data-block-id]')).filter((el) => {
|
|
14121
|
+
const p = drawnBlockPrint(el);
|
|
14122
|
+
return p.type === print.type && p.text === print.text;
|
|
14123
|
+
});
|
|
14124
|
+
return hits.length === 1 ? hits[0] : null;
|
|
14125
|
+
}
|
|
13423
14126
|
|
|
13424
14127
|
// R93: a task item as a checkbox press saw it. `fromEnd` counts rendered
|
|
13425
14128
|
// blocks after it (a commit above moves every id, never that count);
|
|
@@ -13968,6 +14671,15 @@
|
|
|
13968
14671
|
// v3.14.0 T1: Escape in a transient abandons it — it wrote nothing, so
|
|
13969
14672
|
// there is nothing to stash for Ctrl+Z. The only block of an empty
|
|
13970
14673
|
// document stays (D3), emptied.
|
|
14674
|
+
// v3.17.3 B11 (R10): only an EMPTY one. A transient with text is
|
|
14675
|
+
// committed, the path leaving it takes (resolveTransientBurst(): one op,
|
|
14676
|
+
// the caret at the new paragraph's end), so one Ctrl+Z takes it back.
|
|
14677
|
+
// Measured before: type `hello` in a new paragraph, Escape — the text was
|
|
14678
|
+
// gone and Ctrl+Z did not bring it back (the transient had no stash).
|
|
14679
|
+
if (burst.transient && !transientSurfaceEmpty(burst.blockEl)) {
|
|
14680
|
+
await switchAwayFrom();
|
|
14681
|
+
return;
|
|
14682
|
+
}
|
|
13971
14683
|
if (burst.transient) {
|
|
13972
14684
|
burst.history.dispose();
|
|
13973
14685
|
currentBurst = null;
|
|
@@ -14222,6 +14934,9 @@
|
|
|
14222
14934
|
function burstUndo(editEl) {
|
|
14223
14935
|
const burst = currentBurst;
|
|
14224
14936
|
if (!burst || burst.editEl !== editEl) return;
|
|
14937
|
+
// v3.17.3 A8: an undo is not a save — a pristine item it resolves is
|
|
14938
|
+
// taken away (willAutoRemove below), whatever an earlier Ctrl+S marked.
|
|
14939
|
+
burst.forSave = false;
|
|
14225
14940
|
const state = burst.history.undo();
|
|
14226
14941
|
if (state !== null) {
|
|
14227
14942
|
editEl.innerHTML = state;
|
|
@@ -14264,8 +14979,9 @@
|
|
|
14264
14979
|
// caret and the burst stayed where it was. With nothing left to step back
|
|
14265
14980
|
// through here, taking that transient away is the Enter's undo step; the
|
|
14266
14981
|
// caret stays put. Only in the block the Enter was pressed in (`stayEl`):
|
|
14267
|
-
// a transient the user has already left behind is on its way out (
|
|
14268
|
-
//
|
|
14982
|
+
// a transient the user has already left behind is on its way out (the
|
|
14983
|
+
// next render drops it: transientKeptThroughRender() keeps only one the
|
|
14984
|
+
// user is still at), and a Ctrl+Z in another
|
|
14269
14985
|
// block means that block's undo. MEASURED without the `stayEl` test (ER
|
|
14270
14986
|
// 'burst undo: … cascades to the previous commit'): Enter at the end,
|
|
14271
14987
|
// a click straight into the paragraph above, typing, Ctrl+Z twice — the
|
|
@@ -15051,9 +15767,19 @@
|
|
|
15051
15767
|
// old refusal said could not be done (two commits, two undo ops). Lists
|
|
15052
15768
|
// that are adjacent blocks (a marker change between them, no gap) move as
|
|
15053
15769
|
// one group, so an item of the second can land under an item of the first.
|
|
15770
|
+
// v3.17.4 D7 (R9): adjacent in the SOURCE too — only blank lines between
|
|
15771
|
+
// them. A definition is no block, so two lists around one are DOM
|
|
15772
|
+
// neighbours; as one group the second list's first item (no headroom)
|
|
15773
|
+
// zeroed the whole batch's delta. They are two groups, as with a paragraph
|
|
15774
|
+
// between, and the definition is a line between segments, copied as it is.
|
|
15054
15775
|
async function tabSpanAcrossLists(els, recs, dir) {
|
|
15055
15776
|
const isLi = (el) => el.getAttribute('data-block-type') === 'li';
|
|
15056
15777
|
const all = allBlockEls();
|
|
15778
|
+
const linesTouch = (x, y) => {
|
|
15779
|
+
const a = blockRecOf(x);
|
|
15780
|
+
const b = blockRecOf(y);
|
|
15781
|
+
return !!a && !!b && lines.slice(a.endLine, b.startLine - 1).every((l) => l.trim() === '');
|
|
15782
|
+
};
|
|
15057
15783
|
const groups = [];
|
|
15058
15784
|
for (let i = 0; i < els.length; i++) {
|
|
15059
15785
|
if (!isLi(els[i])) continue;
|
|
@@ -15062,7 +15788,8 @@
|
|
|
15062
15788
|
const run = listRunOf(els[i]);
|
|
15063
15789
|
if (!run.length) { refuseStructuralListEdit(); return; }
|
|
15064
15790
|
const prev = groups[groups.length - 1];
|
|
15065
|
-
|
|
15791
|
+
const prevLast = prev && prev.run[prev.run.length - 1];
|
|
15792
|
+
if (prev && all.indexOf(run[0]) === all.indexOf(prevLast) + 1 && linesTouch(prevLast, run[0])) {
|
|
15066
15793
|
prev.run = prev.run.concat(run);
|
|
15067
15794
|
g = prev;
|
|
15068
15795
|
} else {
|
|
@@ -15099,7 +15826,8 @@
|
|
|
15099
15826
|
});
|
|
15100
15827
|
// Segments in document order: each list group re-serialized, each other
|
|
15101
15828
|
// member's own lines (a heading re-depthed). Whatever lies between two
|
|
15102
|
-
// segments — blank lines
|
|
15829
|
+
// segments — blank lines and the definition lines no block owns, by the
|
|
15830
|
+
// check above — is copied as it is.
|
|
15103
15831
|
const segs = [];
|
|
15104
15832
|
for (let i = 0; i < recs.length; i++) {
|
|
15105
15833
|
if (isLi(els[i])) continue;
|
|
@@ -15135,7 +15863,9 @@
|
|
|
15135
15863
|
at = sg.end + 1;
|
|
15136
15864
|
});
|
|
15137
15865
|
out.push(...lines.slice(at - 1, hi));
|
|
15138
|
-
|
|
15866
|
+
// R56: every line between the segments (blocks' own ranges and list runs,
|
|
15867
|
+
// neither of which holds a definition) is sliced from `lines` verbatim.
|
|
15868
|
+
const result = commitRangeEdit({ lines, blocks, stack }, lo, hi, out.join('\n'), { carriesGap: true });
|
|
15139
15869
|
if (result.op === null) return;
|
|
15140
15870
|
endBurstWithoutResolve();
|
|
15141
15871
|
const prevLines = lines;
|
|
@@ -15173,8 +15903,9 @@
|
|
|
15173
15903
|
// Every member was a paragraph, or every heading already sat at its clamp:
|
|
15174
15904
|
// §3.5 rules that a no-op, so nothing is committed and nothing re-renders.
|
|
15175
15905
|
if (!changed) return;
|
|
15906
|
+
// R56: `span` is the range sliced from `lines`, only heading lines rewritten.
|
|
15176
15907
|
const result = commitRangeEdit({ lines, blocks, stack },
|
|
15177
|
-
first.startLine, last.endLine, span.join('\n'));
|
|
15908
|
+
first.startLine, last.endLine, span.join('\n'), { carriesGap: true });
|
|
15178
15909
|
if (result.op === null) return;
|
|
15179
15910
|
const prevLines = lines;
|
|
15180
15911
|
lines = result.lines;
|
|
@@ -15431,6 +16162,126 @@
|
|
|
15431
16162
|
await convertBlockViaMenu(li, 'text', { caretToEnd: false });
|
|
15432
16163
|
}
|
|
15433
16164
|
|
|
16165
|
+
// v3.17.3 B9 (R11): plain Delete with the caret at the end of a list item
|
|
16166
|
+
// (or in an empty one) pulls the next block's text up into it, as in
|
|
16167
|
+
// Notion: the next item of the same list when it owns no children, or the
|
|
16168
|
+
// paragraph / heading right after the list. Every other neighbour keeps
|
|
16169
|
+
// today's behaviour, the browser's own Delete, which changes nothing at the
|
|
16170
|
+
// end of the surface: an item with children (they would have no parent),
|
|
16171
|
+
// an item of another list, a transient, a code block, table, diagram,
|
|
16172
|
+
// image or divider, the end of the document. true = handled.
|
|
16173
|
+
function liDeleteAtEnd(e, editEl) {
|
|
16174
|
+
if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey || e.isComposing || e.keyCode === 229) return false;
|
|
16175
|
+
const burst = currentBurst;
|
|
16176
|
+
if (!burst || burst.editEl !== editEl || burst.blockType !== 'li') return false;
|
|
16177
|
+
const place = caretPlace(editEl, true);
|
|
16178
|
+
if (place !== 'end' && place !== 'empty') return false;
|
|
16179
|
+
const li = closestLiBlock(editEl);
|
|
16180
|
+
if (!li || liTextEl(li) !== editEl) return false;
|
|
16181
|
+
const run = listRunOf(li);
|
|
16182
|
+
if (!run.length) return false;
|
|
16183
|
+
const next = nextBlockEl(li);
|
|
16184
|
+
if (!next || isTransient(next)) return false;
|
|
16185
|
+
const nextSurface = mergeSurfaceOf(next);
|
|
16186
|
+
if (!nextSurface) return false;
|
|
16187
|
+
const nextIsLi = next.getAttribute('data-block-type') === 'li';
|
|
16188
|
+
// R34: the item and, for an item below, that item too must be joinable
|
|
16189
|
+
// (one source line, no children), and the run one the structural
|
|
16190
|
+
// serializer takes. Otherwise the browser's own Delete, never a
|
|
16191
|
+
// refusal notice.
|
|
16192
|
+
const own = blockRecOf(li);
|
|
16193
|
+
if (!own || own.endLine !== own.startLine) return false;
|
|
16194
|
+
if (nextIsLi) {
|
|
16195
|
+
if (run.indexOf(next) === -1 || !liJoinable(next)) return false;
|
|
16196
|
+
} else if (run[run.length - 1] !== li) return false;
|
|
16197
|
+
if (!listRunSupportsStructuralEdit(run, nextIsLi ? [li, next] : [li])) return false;
|
|
16198
|
+
e.preventDefault();
|
|
16199
|
+
startStructuralKey();
|
|
16200
|
+
liPullNextUp(burst, editEl, li, run, next, nextSurface, place === 'empty').catch(structuralKeyFailed);
|
|
16201
|
+
return true;
|
|
16202
|
+
}
|
|
16203
|
+
|
|
16204
|
+
// The element a Delete join appends into, or takes the text of: a loose
|
|
16205
|
+
// list item's surface holds its text in ONE <p> (v3.11), and the join goes
|
|
16206
|
+
// inside that paragraph. Measured before: `- a` + blank + `- b`, Delete at
|
|
16207
|
+
// the end of `a` put two <p> in one surface and wrote `-` (both texts lost).
|
|
16208
|
+
// Anything else is the surface itself. Also the destination of Backspace's
|
|
16209
|
+
// join into a loose item (joinIntoPrevious(), v3.17.3 N1).
|
|
16210
|
+
//
|
|
16211
|
+
// The whitespace test is list-md.js's isBlankText() (not exported), copied
|
|
16212
|
+
// exactly: a text node that is pure whitespace AND holds a newline, the
|
|
16213
|
+
// pretty-print artifact. A bare space is inline spacing and counts.
|
|
16214
|
+
function joinHostOf(surface) {
|
|
16215
|
+
const kids = Array.prototype.filter.call(surface.childNodes,
|
|
16216
|
+
(n) => !(n.nodeType === 3 && /\n/.test(n.textContent) && /^\s*$/.test(n.textContent)));
|
|
16217
|
+
return kids.length === 1 && kids[0].nodeName === 'P' ? kids[0] : surface;
|
|
16218
|
+
}
|
|
16219
|
+
|
|
16220
|
+
// R34: a list item a Delete join may pull up — it owns one source line
|
|
16221
|
+
// and no nested items. An item over several lines (a hard wrap, a
|
|
16222
|
+
// continuation paragraph, a fenced child) counts as one with children.
|
|
16223
|
+
function liJoinable(li) {
|
|
16224
|
+
const rec = blockRecOf(li);
|
|
16225
|
+
return !!rec && rec.endLine === rec.startLine && !liBlockHasChildren(li);
|
|
16226
|
+
}
|
|
16227
|
+
|
|
16228
|
+
// The item's pending typing as its own op (D4): the item's own lines in its
|
|
16229
|
+
// list's serialization (lineMeta's blank entries carry no blockId, so none
|
|
16230
|
+
// is taken). Several lines replace the item's one source line, which
|
|
16231
|
+
// commitEdit() takes; the join's range ends at a block after the item, so
|
|
16232
|
+
// it moves with them. No gesture is known to reach that today: a typed
|
|
16233
|
+
// newline in the surface makes liDeleteAtEnd() decline (the run check's
|
|
16234
|
+
// multi-line target), and a Shift+Enter is `<br>` on the same line. null
|
|
16235
|
+
// when nothing was typed.
|
|
16236
|
+
function liTypedInBurst(burst, run, li) {
|
|
16237
|
+
if (sameBurstHtml(burstBaselineHtml(burst.editEl), burst.original)) return null;
|
|
16238
|
+
burst.history.flushTyping();
|
|
16239
|
+
const out = serializeListEls(run, { carryOver: bystanderCarryOver(run, li) });
|
|
16240
|
+
const id = String(burst.blockId);
|
|
16241
|
+
const mdLines = out.md.split('\n');
|
|
16242
|
+
const own = [];
|
|
16243
|
+
out.lineMeta.forEach((m, k) => { if (m.blockId === id) own.push(mdLines[k]); });
|
|
16244
|
+
return own.length ? { blockId: burst.blockId, text: own.join('\n') } : null;
|
|
16245
|
+
}
|
|
16246
|
+
|
|
16247
|
+
// The join behind liDeleteAtEnd(): the next block's text appended to the
|
|
16248
|
+
// item at the seam, the next block removed, then the item's whole list
|
|
16249
|
+
// (and the paragraph's lines, when that was the next block) rewritten as
|
|
16250
|
+
// ONE op, mirroring joinIntoPrevious() for Backspace. Synchronous up to the
|
|
16251
|
+
// commit (no await before the DOM change, the v3.13.1 guard).
|
|
16252
|
+
async function liPullNextUp(burst, editEl, li, run, next, nextSurface, empty) {
|
|
16253
|
+
const runRange = runRangeOfBlocks({ lines, blocks, stack }, run);
|
|
16254
|
+
const liRec = blockRecOf(li);
|
|
16255
|
+
const nextRec = blockRecOf(next);
|
|
16256
|
+
if (!runRange || !liRec || !nextRec) return;
|
|
16257
|
+
const nextIsLi = next.getAttribute('data-block-type') === 'li';
|
|
16258
|
+
// liDeleteAtEnd() checked the run and both items (R34) synchronously.
|
|
16259
|
+
const typed = empty ? null : liTypedInBurst(burst, run, li);
|
|
16260
|
+
const span = nextIsLi ? run.filter((el) => el !== next) : run;
|
|
16261
|
+
let seam = 0;
|
|
16262
|
+
withFocusoutSuppressed(() => {
|
|
16263
|
+
seam = joinSeam(joinHostOf(editEl), Array.prototype.slice.call(joinHostOf(nextSurface).childNodes), false);
|
|
16264
|
+
structureMutated = true;
|
|
16265
|
+
restoreSelection(editEl, seam, seam);
|
|
16266
|
+
if (nextIsLi) mutateListRun(() => removeListItem(next));
|
|
16267
|
+
else next.parentNode.removeChild(next);
|
|
16268
|
+
});
|
|
16269
|
+
endBurstWithoutResolve();
|
|
16270
|
+
const serialize = () => {
|
|
16271
|
+
const out = serializeListEls(span, { carryOver: bystanderCarryOver(span, li) });
|
|
16272
|
+
return { text: out.md, lineMap: { ids: flightLineMapOf(out.lineMeta, runRange.startLine).ids, provisional: null } };
|
|
16273
|
+
};
|
|
16274
|
+
const s = serialize();
|
|
16275
|
+
const ok = await commitTypingThenStructure({ typed, text: s.text,
|
|
16276
|
+
range: (bs) => {
|
|
16277
|
+
const r = runRangeOfBlocks({ blocks: bs }, run);
|
|
16278
|
+
return [r.startLine, nextIsLi ? r.endLine : bs.find((b) => b.id === nextRec.id).endLine];
|
|
16279
|
+
},
|
|
16280
|
+
oldRange: [runRange.startLine, nextIsLi ? runRange.endLine : nextRec.endLine], lineMap: s.lineMap,
|
|
16281
|
+
pending: { surfaces: [editEl], serialize } });
|
|
16282
|
+
if (ok) focusBlockAtLineOffset(liRec.startLine, seam);
|
|
16283
|
+
}
|
|
16284
|
+
|
|
15434
16285
|
// Tab / Shift+Tab on a list item (and, v3.14.0 T3, Backspace at the start
|
|
15435
16286
|
// of a nested one, which is Shift+Tab). Extracted unchanged from
|
|
15436
16287
|
// handleLiKeydown(); `outdent` stands for the Shift key. `caretToEnd`
|
|
@@ -15505,6 +16356,9 @@
|
|
|
15505
16356
|
// item goes and a transient paragraph takes its place (Enter's row-3
|
|
15506
16357
|
// path). Anywhere else the browser's own deletion.
|
|
15507
16358
|
if (e.key === 'Backspace') return liBackspaceAtStart(e, editEl);
|
|
16359
|
+
// v3.17.3 B9 (R11): Delete at an item's end pulls the next item, or the
|
|
16360
|
+
// paragraph / heading after the list, up (liDeleteAtEnd()).
|
|
16361
|
+
if (e.key === 'Delete') return liDeleteAtEnd(e, editEl);
|
|
15508
16362
|
if (e.key !== 'Enter' && e.key !== 'Tab') return false;
|
|
15509
16363
|
e.preventDefault();
|
|
15510
16364
|
// T21 item 2, the li half — see handleBurstKeydown()'s Tab branch. Ahead
|
|
@@ -19815,6 +20669,9 @@
|
|
|
19815
20669
|
// Two items that were already neighbours in two DIFFERENT lists stay two
|
|
19816
20670
|
// lists, with whatever separated them.
|
|
19817
20671
|
const keepsApart = (x, y) => all[all.indexOf(x) + 1] === y && runOf.get(x) !== runOf.get(y);
|
|
20672
|
+
// v3.17.4 D5 (R8): the source lines of every gap copied below, so a
|
|
20673
|
+
// definition among them is not appended a second time.
|
|
20674
|
+
const replayedLines = new Set();
|
|
19818
20675
|
let lastEl = null;
|
|
19819
20676
|
for (let k = 0; k < seq.length;) {
|
|
19820
20677
|
let unit;
|
|
@@ -19832,7 +20689,10 @@
|
|
|
19832
20689
|
const r1 = blockRecOf(unit[unit.length - 1]);
|
|
19833
20690
|
unitLines = lines.slice(r0.startLine - 1, r1.endLine);
|
|
19834
20691
|
} else {
|
|
19835
|
-
|
|
20692
|
+
// v3.17.3 A9 (review I1): a moved item's old marker is another
|
|
20693
|
+
// list's; it takes the marker of the list it lands in (list-md.js's
|
|
20694
|
+
// tiered vote, `foreign`).
|
|
20695
|
+
const res = serializeListEls(unit, { carryOver: carry, foreign: (el) => moving.indexOf(el) !== -1 });
|
|
19836
20696
|
unitLines = res.md.split('\n');
|
|
19837
20697
|
meta = res.lineMeta;
|
|
19838
20698
|
}
|
|
@@ -19846,8 +20706,10 @@
|
|
|
19846
20706
|
if (lastEl) {
|
|
19847
20707
|
const a = blockRecOf(lastEl);
|
|
19848
20708
|
const b = blockRecOf(unit[0]);
|
|
19849
|
-
if (all[all.indexOf(lastEl) + 1] === unit[0])
|
|
19850
|
-
|
|
20709
|
+
if (all[all.indexOf(lastEl) + 1] === unit[0]) {
|
|
20710
|
+
out.push(...lines.slice(a.endLine, b.startLine - 1));
|
|
20711
|
+
for (let n = a.endLine + 1; n < b.startLine; n++) replayedLines.add(n);
|
|
20712
|
+
} else out.push('');
|
|
19851
20713
|
}
|
|
19852
20714
|
const base0 = out.length;
|
|
19853
20715
|
unitLines.forEach((l, n) => {
|
|
@@ -19862,7 +20724,7 @@
|
|
|
19862
20724
|
if (seq[seq.length - 1] !== regionEls[regionEls.length - 1] && lastRec.endLine < lines.length &&
|
|
19863
20725
|
String(lines[lastRec.endLine]).trim() !== '') out.push('');
|
|
19864
20726
|
|
|
19865
|
-
const result = commitRangeEdit({ lines, blocks, stack }, firstRec.startLine, lastRec.endLine, out.join('\n'));
|
|
20727
|
+
const result = commitRangeEdit({ lines, blocks, stack }, firstRec.startLine, lastRec.endLine, out.join('\n'), { replayedLines });
|
|
19866
20728
|
if (result.op === null) return;
|
|
19867
20729
|
endBurstWithoutResolve();
|
|
19868
20730
|
const prevLines = lines;
|
|
@@ -20169,6 +21031,8 @@
|
|
|
20169
21031
|
// next one.
|
|
20170
21032
|
blockSelClickSuppressed = false;
|
|
20171
21033
|
if (blockSelDrag) endBlockSelDrag();
|
|
21034
|
+
// v3.17.3 A13: and which own-text editor this press began in, if any.
|
|
21035
|
+
ownTextPress = e.target.closest ? e.target.closest(ED_OWN_TEXT_EDITORS) : null;
|
|
20172
21036
|
// Every defensive teardown above still runs — they are no-ops that must not
|
|
20173
21037
|
// be skipped — but nothing behind the modal ARMS a new gesture. The backdrop
|
|
20174
21038
|
// already makes this unreachable (measured: a Shift+press-drag on the canvas
|
|
@@ -21025,7 +21889,10 @@
|
|
|
21025
21889
|
closeLinkField({ restore: true });
|
|
21026
21890
|
return;
|
|
21027
21891
|
}
|
|
21028
|
-
|
|
21892
|
+
// v3.17.3 C6: below the selection it can pass the viewport's bottom edge;
|
|
21893
|
+
// keep it inside, with openLinkField()'s margin.
|
|
21894
|
+
const fieldH = f.el.getBoundingClientRect().height;
|
|
21895
|
+
f.el.style.top = Math.round(Math.min(r.bottom + f.dy, window.innerHeight - fieldH - 4)) + 'px';
|
|
21029
21896
|
f.el.style.left = Math.round(r.left + f.dx) + 'px';
|
|
21030
21897
|
}
|
|
21031
21898
|
|
|
@@ -21407,7 +22274,7 @@
|
|
|
21407
22274
|
// final fix wave (R93) it also holds the task checkbox (`pressedCheck`):
|
|
21408
22275
|
// three classes and the checkbox.
|
|
21409
22276
|
let checkPress = null; // { el, epoch, snap }: the last press on a task checkbox (R89, R93)
|
|
21410
|
-
let degradedPress = null; //
|
|
22277
|
+
let degradedPress = null; // degradedPressRecord(): the last press on a degraded block (R102)
|
|
21411
22278
|
let degradedReleaseOpened = false; // T9b (R50): the last press's release already opened its block
|
|
21412
22279
|
document.addEventListener('mousedown', (e) => {
|
|
21413
22280
|
degradedReleaseOpened = false;
|
|
@@ -21433,10 +22300,7 @@
|
|
|
21433
22300
|
// the click handler can find it again after that commit's render
|
|
21434
22301
|
// replaced it. Never an identity taken during a flight (R94).
|
|
21435
22302
|
const pressedBlock = !pressedCheck && e.target && e.target.closest ? e.target.closest('.ed-block') : null;
|
|
21436
|
-
degradedPress = pressedBlock && isDegradedBlock(pressedBlock)
|
|
21437
|
-
? { el: pressedBlock, epoch: reconcileEpoch,
|
|
21438
|
-
identity: commitsSettled() && !reconcileBlocksCommits() ? captureBlockIdentity(pressedBlock) : null }
|
|
21439
|
-
: null;
|
|
22303
|
+
degradedPress = pressedBlock && isDegradedBlock(pressedBlock) ? degradedPressRecord(pressedBlock) : null;
|
|
21440
22304
|
// v3.15.0 final fix wave (R93): the task checkbox joins the list, for
|
|
21441
22305
|
// the reason the trace above records. Its press blurred the surface
|
|
21442
22306
|
// being typed in, that commit's render replaced the checkbox before the
|
|
@@ -21455,6 +22319,13 @@
|
|
|
21455
22319
|
// below and clear the set it just built; one released on the page
|
|
21456
22320
|
// margin would hit the outside-a-block switchAwayFrom() instead.
|
|
21457
22321
|
if (blockSelClickSuppressed) { blockSelClickSuppressed = false; return; }
|
|
22322
|
+
// v3.17.3 A13: see `ownTextPress`. Released inside the editor, the click
|
|
22323
|
+
// carries on as before (the open-edit branch below answers it).
|
|
22324
|
+
if (ownTextPress) {
|
|
22325
|
+
const own = ownTextPress;
|
|
22326
|
+
ownTextPress = null;
|
|
22327
|
+
if (!own.contains(e.target)) return;
|
|
22328
|
+
}
|
|
21458
22329
|
// v3.17.0 T9b (R50): the release of this press already answered it.
|
|
21459
22330
|
if (degradedReleaseOpened) { degradedReleaseOpened = false; return; }
|
|
21460
22331
|
if (!e.target || !e.target.closest) { await switchAwayFrom(); closeGutterMenu(); return; }
|
|
@@ -21733,6 +22604,24 @@
|
|
|
21733
22604
|
ev.clientX < blockEl.getBoundingClientRect().left;
|
|
21734
22605
|
}
|
|
21735
22606
|
|
|
22607
|
+
// R102 / E4 (R13): what a press on a degraded block records, before its blur
|
|
22608
|
+
// starts the commit of whatever was being typed. `epoch`, the reconciliation
|
|
22609
|
+
// it saw. `identity`, only when nothing is in flight and the screen is
|
|
22610
|
+
// `lines` (never one taken during a flight, R94). Otherwise, while a write
|
|
22611
|
+
// is in flight, its `print` as the screen draws it, whether that print named
|
|
22612
|
+
// this block alone at the press (`printUnique`, with no undo or redo
|
|
22613
|
+
// rendering) and the undo/redo render count then (`historyAt`). Final review
|
|
22614
|
+
// I3: taken at the PRESS, because the release may come after the write has
|
|
22615
|
+
// landed and replaced the node, when there is nothing left to print.
|
|
22616
|
+
function degradedPressRecord(blockEl) {
|
|
22617
|
+
const settled = commitsSettled();
|
|
22618
|
+
const identity = settled && !reconcileBlocksCommits() ? captureBlockIdentity(blockEl) : null;
|
|
22619
|
+
const print = !identity && !settled ? drawnBlockPrint(blockEl) : null;
|
|
22620
|
+
return { el: blockEl, epoch: reconcileEpoch, identity, print,
|
|
22621
|
+
printUnique: !!print && historyRendersInFlight === 0 && uniqueBlockElByPrint(print) === blockEl,
|
|
22622
|
+
historyAt: historyRenderSeq };
|
|
22623
|
+
}
|
|
22624
|
+
|
|
21736
22625
|
// The degraded block's open (a raw box, or the in-place code edit), from a
|
|
21737
22626
|
// click on it (`e`) or from the release of a press whose click was lost
|
|
21738
22627
|
// (`upBlock`, the block the button came up over: the gesture is the
|
|
@@ -21743,6 +22632,29 @@
|
|
|
21743
22632
|
const identity = press ? press.identity
|
|
21744
22633
|
: (commitsSettled() && !reconcileBlocksCommits() ? captureBlockIdentity(blockEl) : null);
|
|
21745
22634
|
const topAtClick = blockEl.getBoundingClientRect().top;
|
|
22635
|
+
// v3.17.4 E4 (R13): no identity because a write was in flight at the press
|
|
22636
|
+
// (R94). Measured before: a quote clicked 50 ms after a split drew the
|
|
22637
|
+
// notice once the render landed and opened nothing, since the render
|
|
22638
|
+
// replaced the node and there was nothing to find it again by. So the
|
|
22639
|
+
// block is printed as it read on screen at the press, and after the write
|
|
22640
|
+
// it is found again by that print (settledGestureBlock()'s rule, R17 / R46:
|
|
22641
|
+
// unique at the press and after the write, no undo or redo meanwhile, no
|
|
22642
|
+
// reconciliation owed); refused with the notice only when that fails. The
|
|
22643
|
+
// order below stays switchAwayFrom() then settleCommits(): R102's open of
|
|
22644
|
+
// a block whose screen agrees with `lines` needs the switch to run before
|
|
22645
|
+
// any reconciliation is owed, and a wait placed first would let the owed
|
|
22646
|
+
// redraw run ahead of the open.
|
|
22647
|
+
// Final review I3: the print is the press's own (degradedPressRecord()),
|
|
22648
|
+
// so a release that comes after the write landed can still find the block;
|
|
22649
|
+
// a click with no press of its own prints the block now. Either way the
|
|
22650
|
+
// re-find requires the press's reconciliation epoch (checked below for
|
|
22651
|
+
// every path), its undo/redo render count, and no reconciliation owed.
|
|
22652
|
+
const pressPrint = !!(press && press.print);
|
|
22653
|
+
const flightPrint = pressPrint ? press.print
|
|
22654
|
+
: (!identity && !commitsSettled() ? drawnBlockPrint(blockEl) : null);
|
|
22655
|
+
const refindable = pressPrint ? press.printUnique
|
|
22656
|
+
: !!flightPrint && historyRendersInFlight === 0 && uniqueBlockElByPrint(flightPrint) === blockEl;
|
|
22657
|
+
const historyAt = pressPrint ? press.historyAt : historyRenderSeq;
|
|
21746
22658
|
const ok = await switchAwayFrom();
|
|
21747
22659
|
if (!ok) return;
|
|
21748
22660
|
await settleCommits();
|
|
@@ -21750,7 +22662,11 @@
|
|
|
21750
22662
|
// the one block of its type with that source — a start line is no proof
|
|
21751
22663
|
// (R95's removal of an emptied paragraph shifts lines unlogged, and an
|
|
21752
22664
|
// identical quote then sat on the clicked one's line and was opened).
|
|
21753
|
-
|
|
22665
|
+
let live = refindBlock(blockEl, identity);
|
|
22666
|
+
if (!live && refindable && epochAtPress === reconcileEpoch && historyAt === historyRenderSeq &&
|
|
22667
|
+
!reconcileBlocksCommits()) {
|
|
22668
|
+
live = uniqueBlockElByPrint(flightPrint);
|
|
22669
|
+
}
|
|
21754
22670
|
if (!live || reconcileEpoch !== epochAtPress ||
|
|
21755
22671
|
(reconcileBlocksCommits() && !blockReadsAsDrawn(live))) {
|
|
21756
22672
|
showError(DROPPED_GESTURE_MESSAGE, null, null);
|
|
@@ -21899,10 +22815,31 @@
|
|
|
21899
22815
|
// copy of save()'s own body, just its one caller-side precondition named
|
|
21900
22816
|
// once.
|
|
21901
22817
|
async function commitThenSave() {
|
|
22818
|
+
markBurstForSave();
|
|
21902
22819
|
const ok = await switchAwayFrom();
|
|
21903
22820
|
if (ok) return save();
|
|
21904
22821
|
}
|
|
21905
22822
|
|
|
22823
|
+
// v3.17.3 A8 (R9): an empty list item a "/" item, + or the toolbar just
|
|
22824
|
+
// inserted (pristineInsert) is taken away again when its burst resolves
|
|
22825
|
+
// untouched — "insert, then leave without typing" is a change of mind. A
|
|
22826
|
+
// save is not leaving: with the caret still in the item it stays, written
|
|
22827
|
+
// as its skeleton (`- [ ] ` for 待辦清單), and the caret stays in it, as an
|
|
22828
|
+
// empty item Enter made does. Measured before: Alpha., Enter, 「/待辦」,
|
|
22829
|
+
// Enter, Ctrl+S — the item vanished, focus fell to the body, and the file
|
|
22830
|
+
// was saved without it. The mark is set on the burst a save is about to
|
|
22831
|
+
// resolve and read only by pristineItemSurvivesSave(); burstUndo() clears
|
|
22832
|
+
// it, since an undo of the item is the one resolution that must remove it.
|
|
22833
|
+
function markBurstForSave() {
|
|
22834
|
+
if (currentBurst) currentBurst.forSave = true;
|
|
22835
|
+
}
|
|
22836
|
+
function pristineItemSurvivesSave(burst) {
|
|
22837
|
+
if (!burst.forSave || burst.blockType !== 'li' || !burst.editEl.isConnected ||
|
|
22838
|
+
!burst.blockEl.contains(document.activeElement)) return false;
|
|
22839
|
+
startBurst(burst.editEl);
|
|
22840
|
+
return true;
|
|
22841
|
+
}
|
|
22842
|
+
|
|
21906
22843
|
// v3.17.0 T9b added this queue for the callers that ask for a save while an
|
|
21907
22844
|
// earlier one may still be on its way (a Ctrl+S held through a code edit's
|
|
21908
22845
|
// or diagram panel's re-entry, a second Ctrl+S while the first one's
|
|
@@ -22036,6 +22973,21 @@
|
|
|
22036
22973
|
}
|
|
22037
22974
|
|
|
22038
22975
|
// ── undo / redo ───────────────────────────────────────────────────────
|
|
22976
|
+
// v3.17.3 fix round 1 (R46): the renders of undo() / redo() (and of the
|
|
22977
|
+
// wave editor's whole-session undo / redo they call), counted, so a
|
|
22978
|
+
// gesture that waited for a write can tell that write was an undo or redo
|
|
22979
|
+
// (settledGestureBlock()).
|
|
22980
|
+
let historyRenderSeq = 0;
|
|
22981
|
+
let historyRendersInFlight = 0;
|
|
22982
|
+
async function historyRerender(editRange) {
|
|
22983
|
+
historyRenderSeq++;
|
|
22984
|
+
historyRendersInFlight++;
|
|
22985
|
+
try {
|
|
22986
|
+
return await safeRerenderAll(editRange);
|
|
22987
|
+
} finally {
|
|
22988
|
+
historyRendersInFlight--;
|
|
22989
|
+
}
|
|
22990
|
+
}
|
|
22039
22991
|
async function undo() {
|
|
22040
22992
|
// T21 item 1: an Escape that discarded a burst leaves exactly one thing
|
|
22041
22993
|
// for the next Ctrl+Z to undo, and it is not on this stack — the burst
|
|
@@ -22107,7 +23059,7 @@
|
|
|
22107
23059
|
endLine: r.op.startLine + r.op.after.length - 1,
|
|
22108
23060
|
delta: r.op.before.length - r.op.after.length }
|
|
22109
23061
|
: undefined;
|
|
22110
|
-
const ok = await
|
|
23062
|
+
const ok = await historyRerender(editRange);
|
|
22111
23063
|
if (!ok) {
|
|
22112
23064
|
// Reverse the undo attempt: push the op back and restore `lines` to
|
|
22113
23065
|
// what was on screen before this undo was requested.
|
|
@@ -22153,7 +23105,7 @@
|
|
|
22153
23105
|
endLine: Math.max(r.op.startLine - 1, r.op.endLine),
|
|
22154
23106
|
delta: r.op.after.length - r.op.before.length }
|
|
22155
23107
|
: undefined;
|
|
22156
|
-
const ok = await
|
|
23108
|
+
const ok = await historyRerender(editRange);
|
|
22157
23109
|
if (!ok) {
|
|
22158
23110
|
// Reverse the redo attempt: pop the op back off and restore `lines`.
|
|
22159
23111
|
const rollback = stack.undo(lines);
|
|
@@ -22189,7 +23141,32 @@
|
|
|
22189
23141
|
// steps the burst, exactly as Ctrl+Z does there. It never commits what is
|
|
22190
23142
|
// open first and then undoes the whole commit (a transient typed into
|
|
22191
23143
|
// vanished entirely that way).
|
|
22192
|
-
|
|
23144
|
+
// v3.17.3 C1 (R22): the order is explicit — an open code edit, then an
|
|
23145
|
+
// open diagram panel (its own text history: the panel stays open and the
|
|
23146
|
+
// file is not reached), then a burst, and the document stack LAST.
|
|
23147
|
+
// v3.17.3 A12 (R22): the document stack waits for a write in flight and
|
|
23148
|
+
// then undoes it — one press, one step. Measured before: one press during
|
|
23149
|
+
// the render of a raw box's Ctrl+Enter left that commit standing, and a
|
|
23150
|
+
// second press undid it. Read from the code: the box keeps its session
|
|
23151
|
+
// (`activeEditor`) until its render lands, so undo()'s switchAwayFrom()
|
|
23152
|
+
// resolved that box again instead of the press reaching the stack (not
|
|
23153
|
+
// traced further). Only this layer waits: the code edit's, the panel's
|
|
23154
|
+
// and the burst's histories never reach a write. Fix round 1 (R47 M1):
|
|
23155
|
+
// the routing runs again once the wait is over — an edit opened meanwhile
|
|
23156
|
+
// (a code edit, the panel, typing) owns the press, as R22 orders.
|
|
23157
|
+
if (undoInOpenEdit()) return;
|
|
23158
|
+
if (!commitsSettled()) {
|
|
23159
|
+
await settleCommits();
|
|
23160
|
+
if (undoInOpenEdit()) return;
|
|
23161
|
+
}
|
|
23162
|
+
const saved = captureCaretForSave();
|
|
23163
|
+
await undo();
|
|
23164
|
+
restoreCaretForSave(saved);
|
|
23165
|
+
}
|
|
23166
|
+
// R22's first three layers: true when one of them took the press.
|
|
23167
|
+
function undoInOpenEdit() {
|
|
23168
|
+
if (codeEditIsOpen()) { stepCodeEditHistory(codeEditState, -1); return true; }
|
|
23169
|
+
if (diagramPanelIsOpen()) { stepDiagramHistory(diagramPanelState, -1); return true; }
|
|
22193
23170
|
const b = currentBurst;
|
|
22194
23171
|
if (b && b.editEl && b.blockType !== 'table' && b.editEl.isConnected &&
|
|
22195
23172
|
b.editEl.contains(document.activeElement) && burstHasUncommittedEdit()) {
|
|
@@ -22197,18 +23174,40 @@
|
|
|
22197
23174
|
// transient left standing after Ctrl+S, say) leaves the button the
|
|
22198
23175
|
// document undo it always was.
|
|
22199
23176
|
burstUndo(b.editEl);
|
|
22200
|
-
return;
|
|
23177
|
+
return true;
|
|
22201
23178
|
}
|
|
22202
|
-
|
|
22203
|
-
|
|
22204
|
-
|
|
23179
|
+
return false;
|
|
23180
|
+
}
|
|
23181
|
+
|
|
23182
|
+
// A12: two presses that both waited step twice, one after the other: the
|
|
23183
|
+
// first to wake starts its undo's render within the same task chain (no
|
|
23184
|
+
// timer between the wake and beginCommitFlight()), so the other one's next
|
|
23185
|
+
// poll finds that render in flight and waits again. Pinned by the checks
|
|
23186
|
+
// 「v3173 A12: two toolbar 復原 …」. Fix round 1 (R47): the keyboard's
|
|
23187
|
+
// Ctrl+Z / Ctrl+Y take this wait only from a raw box whose commit is
|
|
23188
|
+
// rendering (see the keydown handler).
|
|
23189
|
+
async function settledHistoryStep(step) {
|
|
23190
|
+
if (!commitsSettled()) await settleCommits();
|
|
23191
|
+
return step();
|
|
22205
23192
|
}
|
|
22206
23193
|
|
|
22207
23194
|
async function redoViaToolbar() {
|
|
23195
|
+
// v3.17.3 C1 (R22): undoViaToolbar()'s order; the document stack last.
|
|
23196
|
+
if (redoInOpenEdit()) return;
|
|
23197
|
+
// A12: and waits for a write in flight the same way, routing again after.
|
|
23198
|
+
if (!commitsSettled()) {
|
|
23199
|
+
await settleCommits();
|
|
23200
|
+
if (redoInOpenEdit()) return;
|
|
23201
|
+
}
|
|
22208
23202
|
const saved = captureCaretForSave();
|
|
22209
23203
|
await redo();
|
|
22210
23204
|
restoreCaretForSave(saved);
|
|
22211
23205
|
}
|
|
23206
|
+
function redoInOpenEdit() {
|
|
23207
|
+
if (codeEditIsOpen()) { stepCodeEditHistory(codeEditState, 1); return true; }
|
|
23208
|
+
if (diagramPanelIsOpen()) { stepDiagramHistory(diagramPanelState, 1); return true; }
|
|
23209
|
+
return false;
|
|
23210
|
+
}
|
|
22212
23211
|
|
|
22213
23212
|
// ── v3.1.0 §4: the always-visible editing toolbar ──────────────────────
|
|
22214
23213
|
// The version's headline feature: before this there was no toolbar at all,
|
|
@@ -23314,7 +24313,9 @@
|
|
|
23314
24313
|
// The hidden file input both image paths open; `onFiles` gets the picked
|
|
23315
24314
|
// files (never called for an empty pick). v3.15.0 B1 fix: shared with
|
|
23316
24315
|
// pickImageIntoParagraph().
|
|
23317
|
-
|
|
24316
|
+
// `onCancel` (v3.17.3 A6): the dialog was dismissed, which the browser says
|
|
24317
|
+
// with a `cancel` event on the input; the input goes either way.
|
|
24318
|
+
function openImagePicker(onFiles, { multiple = true, onCancel } = {}) {
|
|
23318
24319
|
const input = document.createElement('input');
|
|
23319
24320
|
input.type = 'file';
|
|
23320
24321
|
// v3.4.1: extension tokens sit beside the MIME tokens deliberately.
|
|
@@ -23335,6 +24336,10 @@
|
|
|
23335
24336
|
if (!files.length) return;
|
|
23336
24337
|
Promise.resolve(onFiles(files)).catch(showUnexpectedFailure);
|
|
23337
24338
|
});
|
|
24339
|
+
input.addEventListener('cancel', () => {
|
|
24340
|
+
input.remove();
|
|
24341
|
+
if (typeof onCancel === 'function') Promise.resolve(onCancel()).catch(showUnexpectedFailure);
|
|
24342
|
+
});
|
|
23338
24343
|
input.click();
|
|
23339
24344
|
}
|
|
23340
24345
|
|
|
@@ -23394,6 +24399,459 @@
|
|
|
23394
24399
|
});
|
|
23395
24400
|
}
|
|
23396
24401
|
|
|
24402
|
+
// ── v3.17.3 B2 (R12): selected text dragged to another block ───────────
|
|
24403
|
+
// Before, the browser's own move ran in two halves: the source's
|
|
24404
|
+
// deleteByDrag went into the source's burst and the target's insertFromDrop
|
|
24405
|
+
// into the target's DOM, and the source's commit re-rendered over the
|
|
24406
|
+
// target. Measured: `Alpha bravo charlie.` / `Omega delta.`, `bravo`
|
|
24407
|
+
// dragged into `Omega`, the file then held neither `bravo` (both engines).
|
|
24408
|
+
// Now a drag that starts in the document and lands in ANOTHER block is the
|
|
24409
|
+
// editor's: the drop is cancelled (and with it both browser halves) and the
|
|
24410
|
+
// move is written by moveDroppedText() as one op. Both ends must be a plain
|
|
24411
|
+
// text surface (a paragraph, a heading or a list item); a table cell, a
|
|
24412
|
+
// code block, a diagram, an image, a raw box, the code edit or a transient
|
|
24413
|
+
// paragraph at either end refuses the drop with a notice, and the source
|
|
24414
|
+
// keeps its text; so does a drop outside every block (fix round 1, R38: the
|
|
24415
|
+
// search box, the link field, the page around the document). v3.17.4 D1
|
|
24416
|
+
// (R4): a drop inside the drag's own text surface is the editor's move too
|
|
24417
|
+
// (moveDroppedText(), one op): left to the browser, Chromium's own move
|
|
24418
|
+
// wrote a U+00A0 where it closed the gap. A drag that did not start in the
|
|
24419
|
+
// document, or that carries files, is not this code's at all.
|
|
24420
|
+
//
|
|
24421
|
+
// `textDrag`: { blockEl, surface, range, text, handled, hadFiles } from dragstart to
|
|
24422
|
+
// just after dragend, or null. `surface` / `range` are null when the drag
|
|
24423
|
+
// is not a text selection in a plain surface (a table cell, an image).
|
|
24424
|
+
// `handled` (set at the drop): this code took the drop, so any input the
|
|
24425
|
+
// browser still raises for it (the source's deleteByDrag, an
|
|
24426
|
+
// insertFromDrop) is cancelled by the beforeinput guard below. Measured
|
|
24427
|
+
// with an event trace on a refused drop: neither engine raised either once
|
|
24428
|
+
// the drop was cancelled; the guard is there so an engine that does cannot
|
|
24429
|
+
// write the source behind the move.
|
|
24430
|
+
let textDrag = null;
|
|
24431
|
+
const TEXT_DROP_REFUSED_MESSAGE = '這段文字無法放在這裡,原文保持不動';
|
|
24432
|
+
|
|
24433
|
+
function elementOfNode(node) {
|
|
24434
|
+
return node && (node.nodeType === 1 ? node : node.parentElement);
|
|
24435
|
+
}
|
|
24436
|
+
// The plain text surface of `blockEl` a drag can move text out of or into:
|
|
24437
|
+
// a paragraph, heading or list item's armed surface; never a transient
|
|
24438
|
+
// (its text has no line yet, R12: refused) or a picture.
|
|
24439
|
+
function textDropSurfaceOf(blockEl) {
|
|
24440
|
+
if (!blockEl || isTransient(blockEl) || blockEl.getAttribute('data-block-id') === null) return null;
|
|
24441
|
+
if (isImageBlock(blockEl)) return null;
|
|
24442
|
+
return mergeSurfaceOf(blockEl);
|
|
24443
|
+
}
|
|
24444
|
+
// The browser's caret under a point, as an offset in `surface`'s text, or
|
|
24445
|
+
// null when the point is not over that surface's text.
|
|
24446
|
+
function surfaceOffsetFromPoint(surface, x, y) {
|
|
24447
|
+
let node = null;
|
|
24448
|
+
let off = 0;
|
|
24449
|
+
if (typeof document.caretPositionFromPoint === 'function') {
|
|
24450
|
+
const p = document.caretPositionFromPoint(x, y);
|
|
24451
|
+
if (p) { node = p.offsetNode; off = p.offset; }
|
|
24452
|
+
} else if (typeof document.caretRangeFromPoint === 'function') {
|
|
24453
|
+
const r = document.caretRangeFromPoint(x, y);
|
|
24454
|
+
if (r) { node = r.startContainer; off = r.startOffset; }
|
|
24455
|
+
}
|
|
24456
|
+
if (!node || !surface.contains(node)) return null;
|
|
24457
|
+
return offsetInSurface(surface, node, off);
|
|
24458
|
+
}
|
|
24459
|
+
function refuseTextDrop() {
|
|
24460
|
+
showNotice(TEXT_DROP_REFUSED_MESSAGE);
|
|
24461
|
+
}
|
|
24462
|
+
|
|
24463
|
+
// The block a drag event is over, or null outside every block.
|
|
24464
|
+
function dragBlockAt(e) {
|
|
24465
|
+
const t = elementOfNode(e.target);
|
|
24466
|
+
return t && contentEl.contains(t) ? t.closest('.ed-block') : null;
|
|
24467
|
+
}
|
|
24468
|
+
// The drag this code answers for `e`, or null: an in-document drag
|
|
24469
|
+
// (`textDrag`). An event that carries files belongs to it only when its
|
|
24470
|
+
// dragstart carried them too (a picture dragged out of the page, refused
|
|
24471
|
+
// rather than uploaded as a copy, v3.17.4 D4); a picture or a .drawio from
|
|
24472
|
+
// the desktop is wireImageDrop()'s whatever state a previous drag left.
|
|
24473
|
+
function textDragFor(e) {
|
|
24474
|
+
return textDrag && (!dragCarriesFiles(e) || textDrag.hadFiles) ? textDrag : null;
|
|
24475
|
+
}
|
|
24476
|
+
|
|
24477
|
+
function wireTextDrop() {
|
|
24478
|
+
// Fix round 1 (I1): `dragend` is dispatched to the node the drag started
|
|
24479
|
+
// on, and a move detaches that node (the deleted range, a dropped empty
|
|
24480
|
+
// mark, normalize()), so a document listener never hears it. Measured
|
|
24481
|
+
// before: after moving a whole bold word, `textDrag` stayed set with
|
|
24482
|
+
// `handled`, and the next external drop (a text, a PNG file) was
|
|
24483
|
+
// cancelled with the refusal notice. The listener therefore goes on the
|
|
24484
|
+
// node itself (it still fires there once detached), the document one is
|
|
24485
|
+
// kept for every other ending, and the next press clears it whatever
|
|
24486
|
+
// happened.
|
|
24487
|
+
const endTextDrag = (drag) => {
|
|
24488
|
+
setTimeout(() => { if (textDrag === drag) textDrag = null; }, 0);
|
|
24489
|
+
};
|
|
24490
|
+
document.addEventListener('dragstart', (e) => {
|
|
24491
|
+
textDrag = null;
|
|
24492
|
+
const t = elementOfNode(e.target);
|
|
24493
|
+
if (!t || !contentEl.contains(t)) return;
|
|
24494
|
+
const blockEl = t.closest('.ed-block');
|
|
24495
|
+
if (!blockEl || !contentEl.contains(blockEl)) return;
|
|
24496
|
+
// `hadFiles` (v3.17.4 D4, R6): the dragstart already carried Files, so an
|
|
24497
|
+
// in-page drag (a picture) is told apart from a file dropped from outside.
|
|
24498
|
+
const drag = { blockEl, surface: null, range: null, text: '', handled: false, hadFiles: dragCarriesFiles(e) };
|
|
24499
|
+
const sel = window.getSelection();
|
|
24500
|
+
const surface = textDropSurfaceOf(blockEl);
|
|
24501
|
+
if (surface && sel.rangeCount && !sel.isCollapsed) {
|
|
24502
|
+
const r = sel.getRangeAt(0);
|
|
24503
|
+
// The selection is what is dragged only when the press was on it (a
|
|
24504
|
+
// picture inside the same block drags itself, not the selection).
|
|
24505
|
+
if (surface.contains(r.startContainer) && surface.contains(r.endContainer) && r.intersectsNode(t)) {
|
|
24506
|
+
drag.surface = surface;
|
|
24507
|
+
drag.range = r.cloneRange();
|
|
24508
|
+
drag.text = r.toString();
|
|
24509
|
+
}
|
|
24510
|
+
}
|
|
24511
|
+
textDrag = drag;
|
|
24512
|
+
if (e.target && typeof e.target.addEventListener === 'function') {
|
|
24513
|
+
e.target.addEventListener('dragend', () => endTextDrag(drag), { once: true });
|
|
24514
|
+
}
|
|
24515
|
+
}, true);
|
|
24516
|
+
const clearOnPress = () => { textDrag = null; };
|
|
24517
|
+
document.addEventListener('pointerdown', clearOnPress, true);
|
|
24518
|
+
document.addEventListener('mousedown', clearOnPress, true);
|
|
24519
|
+
// A drop needs a dragover the page accepted. A text surface accepts one
|
|
24520
|
+
// by itself; a code block, a diagram, a picture or the page outside the
|
|
24521
|
+
// blocks does not, and the refusal there would be silent. So everywhere
|
|
24522
|
+
// but the drag's own block the drag is accepted, and the drop arrives
|
|
24523
|
+
// and says why it is refused.
|
|
24524
|
+
const acceptTextDrag = (e) => {
|
|
24525
|
+
const drag = textDragFor(e);
|
|
24526
|
+
if (!drag) return;
|
|
24527
|
+
const t = elementOfNode(e.target);
|
|
24528
|
+
if (!t || t.isContentEditable) return;
|
|
24529
|
+
// A drag that carried Files from its dragstart (a picture) over its own
|
|
24530
|
+
// non-text block is taken here too, so wireImageDrop's bubble dragover
|
|
24531
|
+
// cannot answer it with a copy (v3.17.4 D4).
|
|
24532
|
+
const ownFiles = drag.hadFiles && dragBlockAt(e) === drag.blockEl && !textDropSurfaceOf(drag.blockEl);
|
|
24533
|
+
if (dragBlockAt(e) === drag.blockEl && !ownFiles) return;
|
|
24534
|
+
if (ownFiles) e.stopPropagation();
|
|
24535
|
+
e.preventDefault();
|
|
24536
|
+
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
|
|
24537
|
+
};
|
|
24538
|
+
// WebKit also needs the dragenter accepted: otherwise the drag's current
|
|
24539
|
+
// target becomes the body and no dragover or drop reaches the block
|
|
24540
|
+
// (measured on a code block: dragenter, then dragend, nothing between).
|
|
24541
|
+
document.addEventListener('dragenter', acceptTextDrag, true);
|
|
24542
|
+
document.addEventListener('dragover', acceptTextDrag, true);
|
|
24543
|
+
// Capture phase, so it runs ahead of wireImageDrop()'s listener, which a
|
|
24544
|
+
// picture dragged out of the document would otherwise reach.
|
|
24545
|
+
document.addEventListener('drop', (e) => {
|
|
24546
|
+
const drag = textDragFor(e);
|
|
24547
|
+
if (!drag || waveEditorIsModal()) return;
|
|
24548
|
+
const destBlockEl = dragBlockAt(e);
|
|
24549
|
+
if (destBlockEl === drag.blockEl) {
|
|
24550
|
+
// A block that is not a text surface (a code block's code edit, a
|
|
24551
|
+
// table's cells, a raw box, a picture block's alt field) keeps its own
|
|
24552
|
+
// drop handling inside itself, as before v3.17.4.
|
|
24553
|
+
if (!textDropSurfaceOf(drag.blockEl)) {
|
|
24554
|
+
// Carrying Files from its dragstart (a picture dropped back on
|
|
24555
|
+
// itself): neither an upload nor a replace, and no notice (v3.17.4
|
|
24556
|
+
// D4). Without Files the block's own drop handling is left alone.
|
|
24557
|
+
if (drag.hadFiles) { e.preventDefault(); e.stopPropagation(); drag.handled = true; }
|
|
24558
|
+
return;
|
|
24559
|
+
}
|
|
24560
|
+
e.preventDefault();
|
|
24561
|
+
e.stopPropagation();
|
|
24562
|
+
drag.handled = true;
|
|
24563
|
+
// v3.17.4 D1 (R4): a drag with no text selection (a picture inside
|
|
24564
|
+
// the paragraph drags itself) dropped in its own block does nothing,
|
|
24565
|
+
// with no notice; so does a drop point off the surface's text.
|
|
24566
|
+
if (!drag.surface || !drag.text) return;
|
|
24567
|
+
const ownOffset = surfaceOffsetFromPoint(drag.surface, e.clientX, e.clientY);
|
|
24568
|
+
if (ownOffset === null) return;
|
|
24569
|
+
// The selected text moved within its own block: the editor's move.
|
|
24570
|
+
moveDroppedText(drag.blockEl, drag.range, drag.blockEl, ownOffset).catch(structuralKeyFailed);
|
|
24571
|
+
return;
|
|
24572
|
+
}
|
|
24573
|
+
e.preventDefault();
|
|
24574
|
+
e.stopPropagation();
|
|
24575
|
+
drag.handled = true;
|
|
24576
|
+
// Fix round 1 (M4, R38): outside every block (the search box, the link
|
|
24577
|
+
// field, the page around the document) the drop is refused too; before,
|
|
24578
|
+
// an input there took the text out of the document.
|
|
24579
|
+
if (!destBlockEl) { refuseTextDrop(); return; }
|
|
24580
|
+
const carried = e.dataTransfer ? e.dataTransfer.getData('text/plain') : '';
|
|
24581
|
+
const destSurface = textDropSurfaceOf(destBlockEl);
|
|
24582
|
+
const destOffset = destSurface ? surfaceOffsetFromPoint(destSurface, e.clientX, e.clientY) : null;
|
|
24583
|
+
if (!drag.surface || !drag.text || !carried || destOffset === null) { refuseTextDrop(); return; }
|
|
24584
|
+
moveDroppedText(drag.blockEl, drag.range, destBlockEl, destOffset).catch(structuralKeyFailed);
|
|
24585
|
+
}, true);
|
|
24586
|
+
// Cleared once the drag's own trailing input (deleteByDrag) has had its
|
|
24587
|
+
// turn, which comes right after dragend.
|
|
24588
|
+
document.addEventListener('dragend', () => endTextDrag(textDrag), true);
|
|
24589
|
+
}
|
|
24590
|
+
|
|
24591
|
+
// The dragged fragment, marks included: Range.cloneContents() keeps a mark
|
|
24592
|
+
// only when the range crosses it, so a selection wholly inside `**bravo**`
|
|
24593
|
+
// would come out as plain `bravo`. The inline elements between the range
|
|
24594
|
+
// and the surface are put back around it (shallow clones, attributes kept,
|
|
24595
|
+
// so a link keeps its href).
|
|
24596
|
+
function fragmentWithMarks(range, surface) {
|
|
24597
|
+
let frag = range.cloneContents();
|
|
24598
|
+
for (let n = elementOfNode(range.commonAncestorContainer); n && n !== surface && surface.contains(n);
|
|
24599
|
+
n = n.parentElement) {
|
|
24600
|
+
if (n.nodeName === 'P' || n.nodeName === 'DIV') continue; // a loose item's own paragraph
|
|
24601
|
+
const wrap = n.cloneNode(false);
|
|
24602
|
+
wrap.appendChild(frag);
|
|
24603
|
+
frag = document.createDocumentFragment();
|
|
24604
|
+
frag.appendChild(wrap);
|
|
24605
|
+
}
|
|
24606
|
+
return frag;
|
|
24607
|
+
}
|
|
24608
|
+
|
|
24609
|
+
// Fix round 1 (I2, R37): the moved fragment fitted to the drop point. A
|
|
24610
|
+
// mark it carries that the drop point already sits inside is unwrapped:
|
|
24611
|
+
// `**bravo**` dropped into `**delta**` wrote `**de**bravo**lta**`, which
|
|
24612
|
+
// reparses with bravo NOT bold. A link dropped inside a link has no
|
|
24613
|
+
// Markdown at all (links do not nest; measured, the destination link came
|
|
24614
|
+
// back broken), and inside inline code no mark and no line break can be written (a
|
|
24615
|
+
// code span holds text only; a break used to vanish silently, v3.17.4 D2),
|
|
24616
|
+
// so those two are refused: false.
|
|
24617
|
+
const MARK_KIND = { STRONG: 'b', B: 'b', EM: 'i', I: 'i', DEL: 's', S: 's', STRIKE: 's', U: 'u', CODE: 'code', A: 'a' };
|
|
24618
|
+
function fitFragmentToDrop(frag, atNode, surface) {
|
|
24619
|
+
const kinds = {};
|
|
24620
|
+
for (let n = elementOfNode(atNode); n && n !== surface && surface.contains(n); n = n.parentElement) {
|
|
24621
|
+
if (MARK_KIND[n.nodeName]) kinds[MARK_KIND[n.nodeName]] = true;
|
|
24622
|
+
}
|
|
24623
|
+
const els = Array.prototype.slice.call(frag.querySelectorAll('*'));
|
|
24624
|
+
if (kinds.a && els.some((el) => el.nodeName === 'A')) return false;
|
|
24625
|
+
if (kinds.code && els.length) return false;
|
|
24626
|
+
els.forEach((el) => {
|
|
24627
|
+
if (!kinds[MARK_KIND[el.nodeName]] || el.nodeName === 'A') return;
|
|
24628
|
+
while (el.firstChild) el.parentNode.insertBefore(el.firstChild, el);
|
|
24629
|
+
el.parentNode.removeChild(el);
|
|
24630
|
+
});
|
|
24631
|
+
return true;
|
|
24632
|
+
}
|
|
24633
|
+
// Merges `a` with its next sibling while both are the same mark
|
|
24634
|
+
// (sameSeamMark()), repeated inward, as joinSeam() does at a join.
|
|
24635
|
+
function mergeMarkSeam(a) {
|
|
24636
|
+
let n = a;
|
|
24637
|
+
while (n && sameSeamMark(n, n.nextSibling)) {
|
|
24638
|
+
const b = n.nextSibling;
|
|
24639
|
+
const inner = n.lastChild;
|
|
24640
|
+
while (b.firstChild) n.appendChild(b.firstChild);
|
|
24641
|
+
b.parentNode.removeChild(b);
|
|
24642
|
+
n = inner;
|
|
24643
|
+
}
|
|
24644
|
+
}
|
|
24645
|
+
|
|
24646
|
+
// One end of a drag move: the lines it rewrites and how to serialize it.
|
|
24647
|
+
// A paragraph or heading rewrites its own lines; a list item rewrites its
|
|
24648
|
+
// whole run (its markers are run-global), with every other item replayed
|
|
24649
|
+
// from the file. null: this end cannot be written here.
|
|
24650
|
+
function textMoveEnd(blockEl, surface) {
|
|
24651
|
+
const type = blockEl.getAttribute('data-block-type');
|
|
24652
|
+
if (type === 'li') {
|
|
24653
|
+
const run = listRunOf(blockEl);
|
|
24654
|
+
const range = run.length ? runRangeOfBlocks({ lines, blocks, stack }, run) : null;
|
|
24655
|
+
if (!range || blockOwnsNoLine(blockEl)) return null;
|
|
24656
|
+
return { kind: 'run', run, lis: [blockEl], first: range.startLine, last: range.endLine };
|
|
24657
|
+
}
|
|
24658
|
+
const rec = blockRecOf(blockEl);
|
|
24659
|
+
if (!rec || rec.endLine < rec.startLine) return null;
|
|
24660
|
+
if (inlineMd.serializeInline(surface).unsupported.length) return null;
|
|
24661
|
+
return { kind: 'block', blockEl, surface, type, depth: blockDepthOf(type, surface), id: rec.id,
|
|
24662
|
+
first: rec.startLine, last: rec.endLine };
|
|
24663
|
+
}
|
|
24664
|
+
// { text, ids } for one end, ids relative to its first line; text null when
|
|
24665
|
+
// the end is a paragraph left with no text (it is removed, as an emptied
|
|
24666
|
+
// paragraph is, R95).
|
|
24667
|
+
function textMoveEndText(end) {
|
|
24668
|
+
if (end.kind === 'run') {
|
|
24669
|
+
const carry = bystanderCarryOver(end.run, end.lis[0]);
|
|
24670
|
+
if (carry && end.lis[1]) delete carry[end.lis[1].getAttribute('data-block-id')];
|
|
24671
|
+
const out = serializeListEls(end.run, { carryOver: carry });
|
|
24672
|
+
return { text: out.md, ids: flightLineMapOf(out.lineMeta, 0).ids };
|
|
24673
|
+
}
|
|
24674
|
+
if (end.type === 'paragraph' && end.surface.textContent === '' && !end.surface.querySelector('img')) {
|
|
24675
|
+
return { text: null, ids: {} };
|
|
24676
|
+
}
|
|
24677
|
+
return { text: surfaceCommitText(end.surface, end.type, end.depth), ids: { [end.id]: 0 } };
|
|
24678
|
+
}
|
|
24679
|
+
|
|
24680
|
+
// v3.17.3 B2 (R12): the move behind a text drop. `srcRange` is the dragged
|
|
24681
|
+
// selection in `srcBlockEl`'s surface, `destOffset` the drop point as an
|
|
24682
|
+
// offset in `destBlockEl`'s surface. The dragged fragment, with its marks,
|
|
24683
|
+
// leaves the source and goes in at the drop point, and the span from the
|
|
24684
|
+
// first affected line to the last is written as ONE commitRangeEdit (one
|
|
24685
|
+
// Ctrl+Z): both ends serialized, the lines between them kept byte for byte.
|
|
24686
|
+
// Pending typing is committed first, as its own op (D4), through the same
|
|
24687
|
+
// switchAwayFrom() a ⠿ drag takes, and both blocks are found again after it
|
|
24688
|
+
// (refindBlock(), as performBlockDrop() does). From there on it is
|
|
24689
|
+
// synchronous up to commitTypingThenStructure() (the v3.13.1 guard); a
|
|
24690
|
+
// failed render keeps the moved text on screen as a pending structure (D7).
|
|
24691
|
+
// v3.17.4 D1 (R4): `srcBlockEl` may be `destBlockEl` (a move inside one
|
|
24692
|
+
// block); `destOffset` is then in the surface's text BEFORE the move, a
|
|
24693
|
+
// drop inside the dragged text [from, to] writes nothing, and one past it
|
|
24694
|
+
// lands `to - from` earlier once the text has left.
|
|
24695
|
+
async function moveDroppedText(srcBlockEl, srcRange, destBlockEl, destOffset) {
|
|
24696
|
+
const srcSurface0 = textDropSurfaceOf(srcBlockEl);
|
|
24697
|
+
if (!srcSurface0 || !srcSurface0.contains(srcRange.startContainer) ||
|
|
24698
|
+
!srcSurface0.contains(srcRange.endContainer)) { refuseTextDrop(); return; }
|
|
24699
|
+
const from = offsetInSurface(srcSurface0, srcRange.startContainer, srcRange.startOffset);
|
|
24700
|
+
const to = offsetInSurface(srcSurface0, srcRange.endContainer, srcRange.endOffset);
|
|
24701
|
+
const dragged = srcSurface0.textContent.slice(from, to);
|
|
24702
|
+
if (!(to > from)) { refuseTextDrop(); return; }
|
|
24703
|
+
const same = srcBlockEl === destBlockEl;
|
|
24704
|
+
if (same && destOffset >= from && destOffset <= to) return;
|
|
24705
|
+
// Where the text goes in, counted once the dragged text has left.
|
|
24706
|
+
const insertAt = same && destOffset > to ? destOffset - (to - from) : destOffset;
|
|
24707
|
+
// v3.15.0 R100 (b), as performBlockDrop(): no identity is taken while a
|
|
24708
|
+
// commit is in flight; both ends must still be on screen once it lands.
|
|
24709
|
+
if (!commitsSettled()) {
|
|
24710
|
+
await settleCommits();
|
|
24711
|
+
if (!srcBlockEl.isConnected || !destBlockEl.isConnected) {
|
|
24712
|
+
showError(DROPPED_GESTURE_MESSAGE, null, null);
|
|
24713
|
+
return;
|
|
24714
|
+
}
|
|
24715
|
+
}
|
|
24716
|
+
const srcIdentity = captureBlockIdentity(srcBlockEl);
|
|
24717
|
+
const destIdentity = captureBlockIdentity(destBlockEl);
|
|
24718
|
+
const srcSelf = ownsOpenSession(srcBlockEl);
|
|
24719
|
+
const destSelf = ownsOpenSession(destBlockEl);
|
|
24720
|
+
if (!(await switchAwayFrom())) return;
|
|
24721
|
+
if (!commitsSettled()) await settleCommits();
|
|
24722
|
+
const src = refindBlock(srcBlockEl, srcIdentity, srcSelf);
|
|
24723
|
+
const dest = refindBlock(destBlockEl, destIdentity, destSelf);
|
|
24724
|
+
const srcSurface = src ? textDropSurfaceOf(src) : null;
|
|
24725
|
+
const destSurface = dest ? textDropSurfaceOf(dest) : null;
|
|
24726
|
+
if (!srcSurface || !destSurface || (src === dest) !== same ||
|
|
24727
|
+
srcSurface.textContent.slice(from, to) !== dragged || destOffset > destSurface.textContent.length) {
|
|
24728
|
+
showError(DROPPED_GESTURE_MESSAGE, null, null);
|
|
24729
|
+
return;
|
|
24730
|
+
}
|
|
24731
|
+
let ends;
|
|
24732
|
+
const a = textMoveEnd(src, srcSurface);
|
|
24733
|
+
const b = textMoveEnd(dest, destSurface);
|
|
24734
|
+
if (!a || !b) { refuseTextDrop(); return; }
|
|
24735
|
+
if (same) {
|
|
24736
|
+
// One block: one end (a list item's run with `lis: [src]`).
|
|
24737
|
+
if (a.kind === 'run' && !listRunSupportsStructuralEdit(a.run, src)) { refuseTextDrop(); return; }
|
|
24738
|
+
ends = [a];
|
|
24739
|
+
} else if (a.kind === 'run' && b.kind === 'run' && a.run[0] === b.run[0]) {
|
|
24740
|
+
if (!listRunSupportsStructuralEdit(a.run, [src, dest])) { refuseTextDrop(); return; }
|
|
24741
|
+
ends = [{ kind: 'run', run: a.run, lis: [src, dest], first: a.first, last: a.last }];
|
|
24742
|
+
} else {
|
|
24743
|
+
if ((a.kind === 'run' && !listRunSupportsStructuralEdit(a.run, src)) ||
|
|
24744
|
+
(b.kind === 'run' && !listRunSupportsStructuralEdit(b.run, dest))) { refuseTextDrop(); return; }
|
|
24745
|
+
ends = a.first < b.first ? [a, b] : [b, a];
|
|
24746
|
+
if (ends[0].last >= ends[1].first) { refuseTextDrop(); return; }
|
|
24747
|
+
}
|
|
24748
|
+
const range = document.createRange();
|
|
24749
|
+
const p = pointInSurface(srcSurface, from);
|
|
24750
|
+
const q = pointInSurface(srcSurface, to);
|
|
24751
|
+
range.setStart(p[0], p[1]);
|
|
24752
|
+
range.setEnd(q[0], q[1]);
|
|
24753
|
+
// The fragment is built, fitted to where it lands and asked of the inline
|
|
24754
|
+
// serializer before anything on screen changes; the source's own nodes
|
|
24755
|
+
// are untouched by all three (cloneContents()).
|
|
24756
|
+
const frag = fragmentWithMarks(range, srcSurface);
|
|
24757
|
+
// Where it lands: in one block, the point is taken after the dragged text
|
|
24758
|
+
// has left, so the fit reads a copy of the surface with that delete
|
|
24759
|
+
// already done (the same steps as the real one below).
|
|
24760
|
+
let at = pointInSurface(destSurface, destOffset);
|
|
24761
|
+
let fitAt = at;
|
|
24762
|
+
let fitSurface = destSurface;
|
|
24763
|
+
if (same) {
|
|
24764
|
+
fitSurface = srcSurface.cloneNode(true);
|
|
24765
|
+
const p0 = pointInSurface(fitSurface, from);
|
|
24766
|
+
const q0 = pointInSurface(fitSurface, to);
|
|
24767
|
+
const gone = document.createRange();
|
|
24768
|
+
gone.setStart(p0[0], p0[1]);
|
|
24769
|
+
gone.setEnd(q0[0], q0[1]);
|
|
24770
|
+
gone.deleteContents();
|
|
24771
|
+
dropEmptyInlines(fitSurface);
|
|
24772
|
+
fitSurface.normalize();
|
|
24773
|
+
fitAt = pointInSurface(fitSurface, insertAt);
|
|
24774
|
+
}
|
|
24775
|
+
if (!fitFragmentToDrop(frag, fitAt[0], fitSurface)) { refuseTextDrop(); return; }
|
|
24776
|
+
// A heading is one line: a hard break in the moved text becomes a space.
|
|
24777
|
+
if (dest.getAttribute('data-block-type') === 'heading') {
|
|
24778
|
+
Array.prototype.slice.call(frag.querySelectorAll('br')).forEach((br) => {
|
|
24779
|
+
br.parentNode.replaceChild(document.createTextNode(' '), br);
|
|
24780
|
+
});
|
|
24781
|
+
}
|
|
24782
|
+
const probe = document.createElement('p');
|
|
24783
|
+
probe.appendChild(frag.cloneNode(true));
|
|
24784
|
+
if (inlineMd.serializeInline(probe).unsupported.length) { refuseTextDrop(); return; }
|
|
24785
|
+
if (currentBurst) endBurstWithoutResolve();
|
|
24786
|
+
startStructuralKey();
|
|
24787
|
+
const movedLen = frag.textContent.length;
|
|
24788
|
+
withFocusoutSuppressed(() => {
|
|
24789
|
+
range.deleteContents();
|
|
24790
|
+
dropEmptyInlines(srcSurface);
|
|
24791
|
+
srcSurface.normalize();
|
|
24792
|
+
if (same) at = pointInSurface(destSurface, insertAt);
|
|
24793
|
+
const firstIn = frag.firstChild;
|
|
24794
|
+
const lastIn = frag.lastChild;
|
|
24795
|
+
const ins = document.createRange();
|
|
24796
|
+
ins.setStart(at[0], at[1]);
|
|
24797
|
+
ins.collapse(true);
|
|
24798
|
+
ins.insertNode(frag);
|
|
24799
|
+
// A moved mark that lands against one of the same kind becomes one
|
|
24800
|
+
// mark (`**bravo**` dropped at the start of `**delta**` is
|
|
24801
|
+
// `**bravodelta**`, not two bold runs with an empty HTML comment
|
|
24802
|
+
// between them). normalize()
|
|
24803
|
+
// first: insertNode() splits the text node it lands in and leaves an
|
|
24804
|
+
// empty one at the seam. Right seam first: the left one may absorb the
|
|
24805
|
+
// first inserted node. A text node normalize() merged away has no mark
|
|
24806
|
+
// seam to join.
|
|
24807
|
+
destSurface.normalize();
|
|
24808
|
+
if (lastIn && lastIn.isConnected) mergeMarkSeam(lastIn);
|
|
24809
|
+
if (firstIn && firstIn.isConnected && firstIn.previousSibling) mergeMarkSeam(firstIn.previousSibling);
|
|
24810
|
+
dropEmptyInlines(destSurface);
|
|
24811
|
+
destSurface.normalize();
|
|
24812
|
+
structureMutated = true;
|
|
24813
|
+
});
|
|
24814
|
+
const first = ends[0].first;
|
|
24815
|
+
const last = ends[ends.length - 1].last;
|
|
24816
|
+
const serialize = () => {
|
|
24817
|
+
const t0 = textMoveEndText(ends[0]);
|
|
24818
|
+
const ids = {};
|
|
24819
|
+
const place = (rel, at) => Object.keys(rel).forEach((id) => { ids[id] = at + rel[id]; });
|
|
24820
|
+
if (ends.length === 1) {
|
|
24821
|
+
place(t0.ids, first);
|
|
24822
|
+
return { text: t0.text, lineMap: { ids, provisional: null } };
|
|
24823
|
+
}
|
|
24824
|
+
const t1 = textMoveEndText(ends[1]);
|
|
24825
|
+
let gap = lines.slice(ends[0].last, ends[1].first - 1);
|
|
24826
|
+
// An emptied paragraph goes with one blank line next to it.
|
|
24827
|
+
if (t0.text === null && gap.length && gap[0].trim() === '') gap = gap.slice(1);
|
|
24828
|
+
if (t1.text === null && gap.length && gap[gap.length - 1].trim() === '') gap = gap.slice(0, -1);
|
|
24829
|
+
const head = t0.text === null ? [] : t0.text.split('\n');
|
|
24830
|
+
place(t0.ids, first);
|
|
24831
|
+
place(t1.ids, first + head.length + gap.length);
|
|
24832
|
+
const out = head.concat(gap, t1.text === null ? [] : t1.text.split('\n'));
|
|
24833
|
+
// Final review I3 (R56): `gap` is every line between the two ends,
|
|
24834
|
+
// sliced from `lines` (only a blank next to an emptied paragraph is
|
|
24835
|
+
// dropped), so the text carries a definition there already. Unflagged,
|
|
24836
|
+
// keepDefinitionLines() appended a second copy. The one-end text (one
|
|
24837
|
+
// list run) has no gap: a list run never spans a definition.
|
|
24838
|
+
return { text: out.join('\n'), lineMap: { ids, provisional: null }, carriesGap: true };
|
|
24839
|
+
};
|
|
24840
|
+
const s = serialize();
|
|
24841
|
+
const destId = dest.getAttribute('data-block-id');
|
|
24842
|
+
const ok = await commitTypingThenStructure({ typed: null, text: s.text, carriesGap: s.carriesGap === true,
|
|
24843
|
+
range: () => [first, last], oldRange: [first, last], lineMap: s.lineMap,
|
|
24844
|
+
pending: { surfaces: same ? [srcSurface] : [srcSurface, destSurface], serialize } });
|
|
24845
|
+
if (!ok) return;
|
|
24846
|
+
// The moved text stays selected where it landed, as the browser's own
|
|
24847
|
+
// move leaves it.
|
|
24848
|
+
const landed = s.lineMap.ids[destId] !== undefined ? blockElAtLine(s.lineMap.ids[destId]) : null;
|
|
24849
|
+
const surface = landed ? blockContentEl(landed) : null;
|
|
24850
|
+
if (!surface || !surface.classList.contains('ed-wys-armed')) return;
|
|
24851
|
+
surface.focus();
|
|
24852
|
+
restoreSelection(surface, insertAt, insertAt + movedLen);
|
|
24853
|
+
}
|
|
24854
|
+
|
|
23397
24855
|
// ── 追加 2: paste ──────────────────────────────────────────────────────
|
|
23398
24856
|
// Reads a real DataTransfer into the plain { 'mime/type': string | Blob }
|
|
23399
24857
|
// shape paste-md.js's pickPayload() documents. Only ONE image flavour is
|
|
@@ -23620,6 +25078,7 @@
|
|
|
23620
25078
|
paintSaveStatus(documentIsDirty() ? 'dirty' : 'clean');
|
|
23621
25079
|
wireToolbarTracking();
|
|
23622
25080
|
wireImageDrop();
|
|
25081
|
+
wireTextDrop();
|
|
23623
25082
|
wirePlainPasteArming();
|
|
23624
25083
|
// No separate first paint of the edge hint here: on this call the
|
|
23625
25084
|
// remembered toolbar signature is still its initial empty string, which no
|
|
@@ -23931,6 +25390,7 @@
|
|
|
23931
25390
|
// Both presses used to get the one cached `switching` promise and run
|
|
23932
25391
|
// two save()s back to back, and the second 409'd into the conflict card.
|
|
23933
25392
|
if (switching) { switching.then((ok) => { if (ok) saveAfterLastSave(); }); return; }
|
|
25393
|
+
markBurstForSave();
|
|
23934
25394
|
switchAwayFrom().then((ok) => { if (ok) save(); });
|
|
23935
25395
|
return;
|
|
23936
25396
|
}
|
|
@@ -23949,6 +25409,25 @@
|
|
|
23949
25409
|
|
|
23950
25410
|
const inTextarea = e.target && e.target.tagName === 'TEXTAREA' &&
|
|
23951
25411
|
e.target.classList.contains('ed-raw');
|
|
25412
|
+
// v3.17.3 fix round 1 (R47): a raw box whose Ctrl+Enter / ✓ commit is
|
|
25413
|
+
// rendering has already handed its text to the document — Ctrl+Z / Ctrl+Y
|
|
25414
|
+
// there step the document once the write lands, not the box's native
|
|
25415
|
+
// history. Measured before (A12's steps, focus still in the box): the
|
|
25416
|
+
// press undid nothing, both engines, at 50 and 500 ms.
|
|
25417
|
+
if (inTextarea && e.target === rawBoxCommitting && (e.ctrlKey || e.metaKey) && !e.altKey &&
|
|
25418
|
+
(e.key === 'z' || e.key === 'Z' || e.key === 'y')) {
|
|
25419
|
+
e.preventDefault();
|
|
25420
|
+
// Final review M5: as the toolbar's 復原 / 重做 do — the caret is taken
|
|
25421
|
+
// once the write has landed and put back after the step, and a failure
|
|
25422
|
+
// is shown rather than left as an unhandled rejection.
|
|
25423
|
+
const step = e.key === 'y' || e.shiftKey ? redo : undo;
|
|
25424
|
+
settledHistoryStep(async () => {
|
|
25425
|
+
const saved = captureCaretForSave();
|
|
25426
|
+
await step();
|
|
25427
|
+
restoreCaretForSave(saved);
|
|
25428
|
+
}).catch(showUnexpectedFailure);
|
|
25429
|
+
return;
|
|
25430
|
+
}
|
|
23952
25431
|
if (inTextarea) return; // Ctrl+Enter/Esc handled by openRawEditor()'s own per-instance listener
|
|
23953
25432
|
|
|
23954
25433
|
// v3.13.1 T1: a key that edits or moves, pressed while a commit is in
|
|
@@ -24118,6 +25597,12 @@
|
|
|
24118
25597
|
if (!e.shiftKey && e.key === 'Enter') {
|
|
24119
25598
|
const from = selectionFocusBlockEl();
|
|
24120
25599
|
const members = selectionLib ? selectionLib.membersOf(blockSelection, blocks) : [];
|
|
25600
|
+
// v3.17.3 A1: a selected image block shows its toolbar instead, and
|
|
25601
|
+
// keeps the block selected for Esc to come back to.
|
|
25602
|
+
if (from && members.length === 1 && isImageBlock(from) && openImageToolbarByKeyboard(from)) {
|
|
25603
|
+
e.preventDefault();
|
|
25604
|
+
return;
|
|
25605
|
+
}
|
|
24121
25606
|
if (from && members.length === 1 && !waveDiagramInside(from)) {
|
|
24122
25607
|
e.preventDefault();
|
|
24123
25608
|
clearBlockSelection();
|
|
@@ -24243,6 +25728,13 @@
|
|
|
24243
25728
|
return;
|
|
24244
25729
|
}
|
|
24245
25730
|
|
|
25731
|
+
// Fix round 1 (R47): NOT waited for, unlike the toolbar's. A Ctrl+Z
|
|
25732
|
+
// during a render is the R77 / R83 / R84 machinery's own input (the undo
|
|
25733
|
+
// supersedes the render in flight, and its failures reconcile); routed
|
|
25734
|
+
// through settledHistoryStep() it waited for the split instead, and 33
|
|
25735
|
+
// render-guard: / raw-box: runs (both engines) lost their third render.
|
|
25736
|
+
// Only the raw box whose commit is rendering waits (above), because there
|
|
25737
|
+
// the press never reached the document at all.
|
|
24246
25738
|
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key === 'z') {
|
|
24247
25739
|
e.preventDefault();
|
|
24248
25740
|
undo();
|
|
@@ -24551,6 +26043,15 @@
|
|
|
24551
26043
|
if (e.cancelable) e.preventDefault();
|
|
24552
26044
|
noteStaleScreenDrop();
|
|
24553
26045
|
}, true);
|
|
26046
|
+
// v3.17.3 B2 (R12): a text drop the editor took (wireTextDrop()) is written
|
|
26047
|
+
// by moveDroppedText() alone; the browser's own halves of the move (the
|
|
26048
|
+
// source's deleteByDrag, an insertFromDrop) never reach a surface, should
|
|
26049
|
+
// an engine raise them after the cancelled drop (neither did, measured).
|
|
26050
|
+
document.addEventListener('beforeinput', (e) => {
|
|
26051
|
+
if (!textDrag || !textDrag.handled) return;
|
|
26052
|
+
if (e.inputType !== 'deleteByDrag' && e.inputType !== 'insertFromDrop') return;
|
|
26053
|
+
e.preventDefault();
|
|
26054
|
+
}, true);
|
|
24554
26055
|
document.addEventListener('compositionend', (e) => {
|
|
24555
26056
|
if (!staleScreenInput(e.target) || !e.data) return;
|
|
24556
26057
|
takeBackComposedText(e.data);
|
|
@@ -25880,6 +27381,7 @@
|
|
|
25880
27381
|
b.type = 'button';
|
|
25881
27382
|
b.className = 'ed-imgtb-btn' + (extraClass ? ' ' + extraClass : '');
|
|
25882
27383
|
b.textContent = label;
|
|
27384
|
+
b.tabIndex = -1; // 0 only while the toolbar is open by keyboard (A1)
|
|
25883
27385
|
b.addEventListener('click', (e) => { e.stopPropagation(); onClick(); });
|
|
25884
27386
|
imgTbButtons.appendChild(b);
|
|
25885
27387
|
return b;
|
|
@@ -25895,6 +27397,14 @@
|
|
|
25895
27397
|
let imgTbBlock = null; // the .ed-block the toolbar is drawn for
|
|
25896
27398
|
let imgTbHideTimer = null;
|
|
25897
27399
|
let imgTbAlt = null; // { identity, original } while the input is showing
|
|
27400
|
+
// v3.17.3 A1: opened from a selected image block with Enter. The buttons are
|
|
27401
|
+
// then in the Tab order (tabindex 0), the toolbar stays until Esc or focus
|
|
27402
|
+
// leaves it, and it is repositioned on scroll instead of hidden.
|
|
27403
|
+
let imgTbKeyboard = false;
|
|
27404
|
+
// v3.17.3 A2: alt text typed and not applied when the image left the
|
|
27405
|
+
// viewport and the field went away; the next 替代文字 on the same source
|
|
27406
|
+
// starts from it. { source, value } or null.
|
|
27407
|
+
let imgTbAltDraft = null;
|
|
25898
27408
|
|
|
25899
27409
|
// An image block: a paragraph whose source is one image (D16) and whose
|
|
25900
27410
|
// rendered content is exactly one img. .drawio / .xml keep their own flow.
|
|
@@ -25922,21 +27432,35 @@
|
|
|
25922
27432
|
if (imgTbHideTimer !== null) { clearTimeout(imgTbHideTimer); imgTbHideTimer = null; }
|
|
25923
27433
|
}
|
|
25924
27434
|
function scheduleImageToolbarHide() {
|
|
25925
|
-
if (imgTb.hidden || imgTbAlt || imgTbHideTimer !== null) return;
|
|
27435
|
+
if (imgTb.hidden || imgTbAlt || imgTbKeyboard || imgTbHideTimer !== null) return;
|
|
25926
27436
|
imgTbHideTimer = setTimeout(() => { imgTbHideTimer = null; if (!imgTbAlt) hideImageToolbar(); }, IMGTB_HIDE_MS);
|
|
25927
27437
|
}
|
|
25928
|
-
|
|
27438
|
+
// `opts.keepDraft` (A2): the field went away because its image left the
|
|
27439
|
+
// viewport, so the text typed in it is kept for the next 替代文字 on the
|
|
27440
|
+
// same source. Every other way out discards it.
|
|
27441
|
+
function hideImageToolbar(opts) {
|
|
25929
27442
|
cancelImageToolbarHide();
|
|
25930
27443
|
const wasAlt = imgTbAlt;
|
|
27444
|
+
const wasKeyboard = imgTbKeyboard;
|
|
27445
|
+
const hadFocus = imgTb.contains(document.activeElement);
|
|
27446
|
+
if (wasAlt && opts && opts.keepDraft && imgTbInput.value !== wasAlt.original) {
|
|
27447
|
+
const rec = imgTbBlock && imgTbBlock.isConnected ? blockRecOf(imgTbBlock) : null;
|
|
27448
|
+
imgTbAltDraft = rec ? { source: blockSourceOf(rec), value: imgTbInput.value } : null;
|
|
27449
|
+
} else if (wasAlt) imgTbAltDraft = null;
|
|
25931
27450
|
imgTbAlt = null;
|
|
25932
27451
|
imgTbBlock = null;
|
|
27452
|
+
imgTbKeyboard = false;
|
|
27453
|
+
imgTbButtonsEl.forEach((b) => { b.tabIndex = -1; });
|
|
25933
27454
|
if (wasAlt) {
|
|
25934
27455
|
imgTbInput.hidden = true;
|
|
25935
27456
|
imgTbButtons.hidden = false;
|
|
25936
27457
|
if (document.activeElement === imgTbInput) imgTbInput.blur();
|
|
27458
|
+
setDirty();
|
|
25937
27459
|
}
|
|
25938
27460
|
if (imgTb.hidden) return;
|
|
25939
27461
|
imgTb.hidden = true;
|
|
27462
|
+
// Opened by keyboard: focus goes back to the block selection it came from.
|
|
27463
|
+
if (wasKeyboard && hadFocus && blockSelection) applySelectionClasses();
|
|
25940
27464
|
}
|
|
25941
27465
|
function reconcileImageToolbar() {
|
|
25942
27466
|
if (imgTbBlock !== null && !imgTbBlock.isConnected) hideImageToolbar();
|
|
@@ -25944,10 +27468,10 @@
|
|
|
25944
27468
|
function placeImageToolbar(blockEl) {
|
|
25945
27469
|
const img = blockEl.querySelector('img');
|
|
25946
27470
|
const r = img ? img.getBoundingClientRect() : null;
|
|
25947
|
-
if (!r || (r.width === 0 && r.height === 0)) { hideImageToolbar(); return false; }
|
|
27471
|
+
if (!r || (r.width === 0 && r.height === 0)) { hideImageToolbar({ keepDraft: true }); return false; }
|
|
25948
27472
|
imgTb.hidden = false;
|
|
25949
27473
|
const top = Math.max(r.top + IMGTB_INSET_PX, toolbarTopInset() + 4);
|
|
25950
|
-
if (top > r.bottom - 4) { hideImageToolbar(); return false; }
|
|
27474
|
+
if (top > r.bottom - 4) { hideImageToolbar({ keepDraft: true }); return false; }
|
|
25951
27475
|
imgTb.style.top = Math.round(top) + 'px';
|
|
25952
27476
|
imgTb.style.left = Math.round(Math.max(4, Math.min(r.left + IMGTB_INSET_PX,
|
|
25953
27477
|
document.documentElement.clientWidth - imgTb.offsetWidth - 4))) + 'px';
|
|
@@ -25966,24 +27490,40 @@
|
|
|
25966
27490
|
// everything that could have moved it (R104: by source, and only when it is
|
|
25967
27491
|
// the one such block), one commitRangeEdit over its line, nothing written
|
|
25968
27492
|
// when the source reads the same. false: refused or the render failed.
|
|
25969
|
-
|
|
25970
|
-
|
|
27493
|
+
//
|
|
27494
|
+
// `trace` (A4), when given, learns whether the file now refers to the new
|
|
27495
|
+
// destination: `referenced` is false after a refusal or a failed render
|
|
27496
|
+
// (rolled back), true when it landed, was superseded by a newer render
|
|
27497
|
+
// (the commit stands), or changed nothing.
|
|
27498
|
+
async function applyImageEdit(blockEl, identity, patch, trace) {
|
|
27499
|
+
const note = (v) => { if (trace) trace.referenced = v; };
|
|
27500
|
+
if (!identity || !inlineMd) { note(false); return false; }
|
|
25971
27501
|
await settleCommits();
|
|
25972
27502
|
const ok = await switchAwayFrom();
|
|
25973
|
-
if (!ok) return false;
|
|
27503
|
+
if (!ok) { note(false); return false; }
|
|
25974
27504
|
let el = blockEl && blockEl.isConnected ? blockEl : null;
|
|
25975
27505
|
let rec = el ? blockRecOf(el) : null;
|
|
25976
27506
|
if (!rec || blockSourceOf(rec) !== identity.source || identity.reconcileEpoch !== reconcileEpoch) {
|
|
25977
27507
|
el = uniqueBlockElBySource(identity);
|
|
25978
27508
|
rec = el ? blockRecOf(el) : null;
|
|
25979
27509
|
}
|
|
25980
|
-
if (!el || !rec || reconcileBlocksCommits()) { showError(DROPPED_GESTURE_MESSAGE, null, null); return false; }
|
|
27510
|
+
if (!el || !rec || reconcileBlocksCommits()) { note(false); showError(DROPPED_GESTURE_MESSAGE, null, null); return false; }
|
|
25981
27511
|
const source = blockSourceOf(rec);
|
|
25982
27512
|
const next = inlineMd.rewriteImage(source, patch);
|
|
25983
|
-
if (next === source) return true;
|
|
27513
|
+
if (next === source) { note(true); return true; }
|
|
25984
27514
|
const r = await commitFencedRangeAndRender(rec.startLine, rec.endLine, next);
|
|
27515
|
+
note(r !== false && r !== null);
|
|
25985
27516
|
return r === true;
|
|
25986
27517
|
}
|
|
27518
|
+
// A4: a file this page uploaded and the file then did not take. The server
|
|
27519
|
+
// deletes only what its own POST /api/asset wrote; any failure is ignored,
|
|
27520
|
+
// the orphan is what it was before this call.
|
|
27521
|
+
function discardUploadedAsset(assetPath) {
|
|
27522
|
+
try {
|
|
27523
|
+
fetch('/api/asset?fileId=' + encodeURIComponent(String(ED.fileId)) + '&path=' + encodeURIComponent(assetPath),
|
|
27524
|
+
{ method: 'DELETE', headers: { 'content-type': 'application/json' } }).catch(() => {});
|
|
27525
|
+
} catch (e) { /* ignored */ }
|
|
27526
|
+
}
|
|
25987
27527
|
|
|
25988
27528
|
// 替換 (and T8's drop): the picked file replaces the picture, keeping alt
|
|
25989
27529
|
// and title. A file that is not a raster picture writes nothing (R39).
|
|
@@ -26014,17 +27554,36 @@
|
|
|
26014
27554
|
if (!live) { showError(DROPPED_GESTURE_MESSAGE, null, null); return false; }
|
|
26015
27555
|
const path = await uploadImageBlob(file);
|
|
26016
27556
|
if (!path) return false;
|
|
26017
|
-
|
|
27557
|
+
// A4: the paragraph can change while the upload is out; the edit is then
|
|
27558
|
+
// refused, and the file it uploaded must not stay behind in assets/.
|
|
27559
|
+
const trace = { referenced: undefined };
|
|
27560
|
+
let applied = false;
|
|
27561
|
+
try {
|
|
27562
|
+
applied = await applyImageEdit(el, live, { dest: markdownImageDest(path) }, trace);
|
|
27563
|
+
} finally {
|
|
27564
|
+
if (!applied && trace.referenced === false) discardUploadedAsset(path);
|
|
27565
|
+
}
|
|
27566
|
+
return applied;
|
|
26018
27567
|
}
|
|
26019
27568
|
|
|
26020
|
-
|
|
27569
|
+
// `refocus`: focus goes back to the button that opened the field. Only Esc
|
|
27570
|
+
// asks for it, and a blur whose focus stays inside the toolbar. Final review
|
|
27571
|
+
// I2: a blur to anything else (a click into the text) is where the user is
|
|
27572
|
+
// going, and chrome never steals an edit — measured before, the click left
|
|
27573
|
+
// focus on 替代文字 and the next keys never reached the text.
|
|
27574
|
+
function exitImageAltMode(refocus) {
|
|
26021
27575
|
if (!imgTbAlt) return;
|
|
26022
27576
|
imgTbAlt = null;
|
|
26023
|
-
|
|
27577
|
+
imgTbAltDraft = null;
|
|
26024
27578
|
imgTbButtons.hidden = false;
|
|
27579
|
+
// By keyboard, focus goes to the button that opened the field (moved
|
|
27580
|
+
// before the field is hidden, so the toolbar never sees focus leave).
|
|
27581
|
+
if (imgTbKeyboard && refocus === true) imgTbAltButton.focus({ preventScroll: true });
|
|
27582
|
+
imgTbInput.hidden = true;
|
|
26025
27583
|
if (document.activeElement === imgTbInput) imgTbInput.blur();
|
|
26026
27584
|
if (imgTbBlock) placeImageToolbar(imgTbBlock);
|
|
26027
27585
|
scheduleImageToolbarHide();
|
|
27586
|
+
setDirty();
|
|
26028
27587
|
}
|
|
26029
27588
|
async function openImageAltMode() {
|
|
26030
27589
|
const first = imgTbBlock;
|
|
@@ -26040,26 +27599,49 @@
|
|
|
26040
27599
|
const parsed = inlineMd.parseImageParagraph(blockSourceOf(blockRecOf(live)));
|
|
26041
27600
|
imgTbBlock = live;
|
|
26042
27601
|
imgTbAlt = { identity: captureBlockIdentity(live), original: parsed.alt };
|
|
27602
|
+
const draft = imgTbAltDraft && imgTbAltDraft.source === blockSourceOf(blockRecOf(live)) ? imgTbAltDraft : null;
|
|
27603
|
+
imgTbAltDraft = null;
|
|
26043
27604
|
imgTbButtons.hidden = true;
|
|
26044
27605
|
imgTbInput.hidden = false;
|
|
26045
|
-
imgTbInput.value = parsed.alt;
|
|
27606
|
+
imgTbInput.value = draft ? draft.value : parsed.alt;
|
|
26046
27607
|
if (!placeImageToolbar(live)) return;
|
|
26047
27608
|
imgTbInput.focus({ preventScroll: true });
|
|
26048
|
-
imgTbInput.
|
|
27609
|
+
if (draft) imgTbInput.setSelectionRange(draft.value.length, draft.value.length);
|
|
27610
|
+
else imgTbInput.select();
|
|
27611
|
+
setDirty();
|
|
26049
27612
|
}
|
|
26050
27613
|
imgTbInput.addEventListener('keydown', (e) => {
|
|
26051
|
-
if (e.key === 'Escape') {
|
|
26052
|
-
|
|
27614
|
+
if (e.key === 'Escape') {
|
|
27615
|
+
// A5: an Esc that belongs to an IME composition cancels the composition,
|
|
27616
|
+
// not the field (the Enter branch below has the same guard). It stays
|
|
27617
|
+
// off the document handlers, whose Esc rungs would act on it.
|
|
27618
|
+
if (e.isComposing || e.keyCode === 229) { e.stopPropagation(); return; }
|
|
27619
|
+
e.preventDefault(); e.stopPropagation(); exitImageAltMode(true); return;
|
|
27620
|
+
}
|
|
27621
|
+
// R11: leaving the keyboard-opened field by Tab / Shift+Tab commits it
|
|
27622
|
+
// like Enter and returns focus to the image's block selection (the
|
|
27623
|
+
// commit re-renders the block, so no button is left to walk on to). The
|
|
27624
|
+
// mouse-opened field keeps the browser's own Tab.
|
|
27625
|
+
const byTab = e.key === 'Tab' && imgTbKeyboard && !e.isComposing && e.keyCode !== 229;
|
|
27626
|
+
if ((e.key !== 'Enter' && !byTab) || e.isComposing) return;
|
|
26053
27627
|
e.preventDefault();
|
|
26054
27628
|
e.stopPropagation();
|
|
26055
27629
|
const alt = imgTbAlt;
|
|
26056
27630
|
const blk = imgTbBlock;
|
|
26057
27631
|
const value = imgTbInput.value;
|
|
27632
|
+
const keptSel = byTab && blockSelection ? { anchorLine: blockSelection.anchorLine, focusLine: blockSelection.focusLine } : null;
|
|
26058
27633
|
hideImageToolbar();
|
|
26059
27634
|
if (!alt || value === alt.original) return;
|
|
26060
|
-
applyImageEdit(blk, alt.identity, { alt: value })
|
|
27635
|
+
applyImageEdit(blk, alt.identity, { alt: value })
|
|
27636
|
+
.then(() => { if (keptSel) setBlockSelection(keptSel); })
|
|
27637
|
+
.catch(showUnexpectedFailure);
|
|
27638
|
+
});
|
|
27639
|
+
imgTbInput.addEventListener('blur', (e) => {
|
|
27640
|
+
if (!imgTbAlt) return;
|
|
27641
|
+
const to = e.relatedTarget;
|
|
27642
|
+
exitImageAltMode(!!(to && to.nodeType === 1 && to.closest('.ed-imgtb')));
|
|
26061
27643
|
});
|
|
26062
|
-
imgTbInput.addEventListener('
|
|
27644
|
+
imgTbInput.addEventListener('input', () => { setDirty(); });
|
|
26063
27645
|
|
|
26064
27646
|
// Final review I1: the 🖼 path's order (pickAndInsertImage()). The press
|
|
26065
27647
|
// resolves any open session first and takes the block's identity while its
|
|
@@ -26082,7 +27664,7 @@
|
|
|
26082
27664
|
if (!blk || !blk.isConnected) return;
|
|
26083
27665
|
pickReplacementImage(blk).catch(showUnexpectedFailure);
|
|
26084
27666
|
});
|
|
26085
|
-
addImgTbButton('替代文字', '', () => { openImageAltMode().catch(showUnexpectedFailure); });
|
|
27667
|
+
const imgTbAltButton = addImgTbButton('替代文字', '', () => { openImageAltMode().catch(showUnexpectedFailure); });
|
|
26086
27668
|
addImgTbButton('刪除', 'ed-imgtb-delete', () => {
|
|
26087
27669
|
const blk = imgTbBlock;
|
|
26088
27670
|
hideImageToolbar();
|
|
@@ -26091,21 +27673,79 @@
|
|
|
26091
27673
|
deleteBlockViaGutter(blk).catch(showUnexpectedFailure);
|
|
26092
27674
|
});
|
|
26093
27675
|
|
|
27676
|
+
const imgTbButtonsEl = [].slice.call(imgTbButtons.children);
|
|
27677
|
+
|
|
27678
|
+
// A1: the keyboard way in. Enter on one selected image block shows the
|
|
27679
|
+
// toolbar and puts focus on 替換; Tab / Shift+Tab and the left / right
|
|
27680
|
+
// arrows walk the three buttons (round the ends), Enter or Space presses
|
|
27681
|
+
// one, Esc goes back to the block selection. The block stays selected the
|
|
27682
|
+
// whole time. false: nothing was shown (the caller keeps its old Enter).
|
|
27683
|
+
function openImageToolbarByKeyboard(blockEl) {
|
|
27684
|
+
if (waveEditorIsModal() || !blockEl || !blockEl.isConnected || !isImageBlock(blockEl)) return false;
|
|
27685
|
+
cancelImageToolbarHide();
|
|
27686
|
+
if (imgTbAlt) exitImageAltMode();
|
|
27687
|
+
try { blockEl.scrollIntoView({ block: 'nearest' }); } catch (e) { /* ignored */ }
|
|
27688
|
+
imgTbBlock = blockEl;
|
|
27689
|
+
imgTbButtons.hidden = false;
|
|
27690
|
+
imgTbInput.hidden = true;
|
|
27691
|
+
if (!placeImageToolbar(blockEl)) return false;
|
|
27692
|
+
imgTbKeyboard = true;
|
|
27693
|
+
imgTbButtonsEl.forEach((b) => { b.tabIndex = 0; });
|
|
27694
|
+
imgTbButtonsEl[0].focus({ preventScroll: true });
|
|
27695
|
+
return true;
|
|
27696
|
+
}
|
|
27697
|
+
imgTb.addEventListener('keydown', (e) => {
|
|
27698
|
+
if (e.target === imgTbInput || !imgTbKeyboard) return;
|
|
27699
|
+
if (e.ctrlKey || e.metaKey) return; // Ctrl+S and the like stay the document's
|
|
27700
|
+
// Nothing else may reach the document handlers: with a block selected
|
|
27701
|
+
// they would read Enter as "open the source" and Tab as "next block".
|
|
27702
|
+
e.stopPropagation();
|
|
27703
|
+
if (e.key === 'Escape') { e.preventDefault(); hideImageToolbar(); return; }
|
|
27704
|
+
const at = imgTbButtonsEl.indexOf(document.activeElement);
|
|
27705
|
+
const step = e.key === 'ArrowRight' || (e.key === 'Tab' && !e.shiftKey) ? 1
|
|
27706
|
+
: e.key === 'ArrowLeft' || (e.key === 'Tab' && e.shiftKey) ? -1 : 0;
|
|
27707
|
+
if (step === 0) return;
|
|
27708
|
+
e.preventDefault();
|
|
27709
|
+
const n = imgTbButtonsEl.length;
|
|
27710
|
+
imgTbButtonsEl[((at < 0 ? 0 : at) + step + n) % n].focus({ preventScroll: true });
|
|
27711
|
+
});
|
|
27712
|
+
// Focus leaving the toolbar for good (a click elsewhere) ends the keyboard
|
|
27713
|
+
// mode. Decided after the event: moving focus between the buttons, or into
|
|
27714
|
+
// the alt field, passes through a moment with none.
|
|
27715
|
+
imgTb.addEventListener('focusout', () => {
|
|
27716
|
+
if (!imgTbKeyboard) return;
|
|
27717
|
+
setTimeout(() => {
|
|
27718
|
+
if (imgTbKeyboard && !imgTbAlt && !imgTb.hidden && !imgTb.contains(document.activeElement)) hideImageToolbar();
|
|
27719
|
+
}, 0);
|
|
27720
|
+
});
|
|
27721
|
+
|
|
26094
27722
|
// Presses keep focus where it is; the alt input is the one thing that takes it.
|
|
26095
27723
|
imgTb.addEventListener('mousedown', (e) => { if (e.target !== imgTbInput) e.preventDefault(); });
|
|
26096
27724
|
document.addEventListener('mouseover', (ev) => {
|
|
26097
27725
|
const t = ev.target;
|
|
26098
|
-
if (!t || typeof t.closest !== 'function' || imgTbAlt) return;
|
|
27726
|
+
if (!t || typeof t.closest !== 'function' || imgTbAlt || imgTbKeyboard) return;
|
|
26099
27727
|
if (t.closest('.ed-imgtb')) { cancelImageToolbarHide(); return; }
|
|
26100
27728
|
const blk = t.closest('.ed-block[data-block-type="paragraph"]');
|
|
26101
27729
|
if (blk && isImageBlock(blk)) showImageToolbarFor(blk); else scheduleImageToolbarHide();
|
|
26102
27730
|
}, true);
|
|
26103
27731
|
document.documentElement.addEventListener('mouseleave', () => { scheduleImageToolbarHide(); });
|
|
27732
|
+
// A2: while the alt field is open (or the toolbar was opened by keyboard) a
|
|
27733
|
+
// scroll or resize moves the toolbar with its image instead of closing it;
|
|
27734
|
+
// it goes only when the image has left the viewport, and the typed text is
|
|
27735
|
+
// kept for the next 替代文字 on the same source. A hover toolbar still closes
|
|
27736
|
+
// at once.
|
|
27737
|
+
function repositionOrHideImageToolbar() {
|
|
27738
|
+
if (!(imgTbAlt || imgTbKeyboard)) { hideImageToolbar(); return; }
|
|
27739
|
+
const img = imgTbBlock && imgTbBlock.isConnected ? imgTbBlock.querySelector('img') : null;
|
|
27740
|
+
const r = img ? img.getBoundingClientRect() : null;
|
|
27741
|
+
if (r && r.bottom > 0 && r.top < document.documentElement.clientHeight) placeImageToolbar(imgTbBlock);
|
|
27742
|
+
else hideImageToolbar({ keepDraft: true });
|
|
27743
|
+
}
|
|
26104
27744
|
document.addEventListener('scroll', (ev) => {
|
|
26105
27745
|
if (ev.target === imgTbInput || imgTb.contains(ev.target)) return;
|
|
26106
|
-
|
|
27746
|
+
repositionOrHideImageToolbar();
|
|
26107
27747
|
}, { passive: true, capture: true });
|
|
26108
|
-
window.addEventListener('resize', () => {
|
|
27748
|
+
window.addEventListener('resize', () => { repositionOrHideImageToolbar(); });
|
|
26109
27749
|
|
|
26110
27750
|
// ── v3.17.0 T5: the diagram panel — the source and a live preview (D15) ──
|
|
26111
27751
|
//
|
|
@@ -26155,6 +27795,7 @@
|
|
|
26155
27795
|
}
|
|
26156
27796
|
function disposeDiagramPanel(s) {
|
|
26157
27797
|
if (s.timer) { clearTimeout(s.timer); s.timer = null; }
|
|
27798
|
+
if (s.historyTimer) { clearTimeout(s.historyTimer); s.historyTimer = null; }
|
|
26158
27799
|
s.previewSeq++; // an answer still out lands nowhere
|
|
26159
27800
|
s.listeners.abort();
|
|
26160
27801
|
if (s.offTheme) { s.offTheme(); s.offTheme = null; }
|
|
@@ -26279,6 +27920,7 @@
|
|
|
26279
27920
|
originalHtml: blockEl.innerHTML, darkAtOpen: diagramThemeIsDark(),
|
|
26280
27921
|
timer: null, previewSeq: 0, inflight: null, lastResult: undefined, reentering: null, pressInside: false,
|
|
26281
27922
|
listeners: new AbortController(), offTheme: null, editor: null,
|
|
27923
|
+
history: null, historyTimer: null, composing: false,
|
|
26282
27924
|
};
|
|
26283
27925
|
s.editor = {
|
|
26284
27926
|
blockEl,
|
|
@@ -26302,9 +27944,24 @@
|
|
|
26302
27944
|
ta.focus({ preventScroll: true });
|
|
26303
27945
|
if (o.seed) ta.setSelectionRange(o.seed.selStart, o.seed.selEnd);
|
|
26304
27946
|
else ta.setSelectionRange(0, 0);
|
|
27947
|
+
// v3.17.3 C7: the panel's own text history, the code edit's model. The
|
|
27948
|
+
// Ctrl+S re-entry hands the closing panel's history to this one (what
|
|
27949
|
+
// was typed before the save stays undoable); any other open starts one.
|
|
27950
|
+
s.history = (o.seed && o.seed.history) || new codeEditLib.TextHistory({ text: ta.value, selStart: ta.selectionStart, selEnd: ta.selectionEnd });
|
|
27951
|
+
if (o.seed && o.seed.history) pushDiagramHistory(s);
|
|
26305
27952
|
|
|
26306
27953
|
const on = { signal: s.listeners.signal };
|
|
26307
|
-
ta.addEventListener('input', () => { autoSize(ta); scheduleDiagramPreview(s); setDirty(); }, on);
|
|
27954
|
+
ta.addEventListener('input', () => { autoSize(ta); scheduleDiagramPreview(s); scheduleDiagramHistory(s); setDirty(); }, on);
|
|
27955
|
+
ta.addEventListener('compositionstart', () => { s.composing = true; }, on);
|
|
27956
|
+
ta.addEventListener('compositionend', () => { s.composing = false; scheduleDiagramHistory(s); }, on);
|
|
27957
|
+
// The browser's own undo (a menu, a gesture) steps the panel's history
|
|
27958
|
+
// too, never the textarea's native stack (a history step sets the value
|
|
27959
|
+
// and empties that).
|
|
27960
|
+
ta.addEventListener('beforeinput', (e) => {
|
|
27961
|
+
if (e.inputType !== 'historyUndo' && e.inputType !== 'historyRedo') return;
|
|
27962
|
+
e.preventDefault();
|
|
27963
|
+
stepDiagramHistory(s, e.inputType === 'historyUndo' ? -1 : 1);
|
|
27964
|
+
}, on);
|
|
26308
27965
|
panel.addEventListener('keydown', (e) => onDiagramPanelKeydown(s, e), on);
|
|
26309
27966
|
// R36: a press inside never ends the panel. On anything but the source
|
|
26310
27967
|
// and the preview it keeps focus where it is; a focusout with no
|
|
@@ -26358,18 +28015,28 @@
|
|
|
26358
28015
|
else if (e.key === 'Enter' && mod) act = 'commit';
|
|
26359
28016
|
else if (mod && !e.altKey && !e.shiftKey && (e.key === 's' || e.key === 'S')) act = 'save';
|
|
26360
28017
|
else if (e.key === 'Tab' && !mod && !e.altKey && !e.shiftKey && e.target === s.ta) act = 'tab';
|
|
28018
|
+
else if (mod && !e.altKey && !e.shiftKey && (e.key === 'z' || e.key === 'Z')) act = 'undo';
|
|
28019
|
+
else if (mod && !e.altKey && ((!e.shiftKey && (e.key === 'y' || e.key === 'Y')) ||
|
|
28020
|
+
(e.shiftKey && (e.key === 'z' || e.key === 'Z')))) act = 'redo';
|
|
26361
28021
|
if (!act) return;
|
|
26362
28022
|
e.preventDefault();
|
|
26363
28023
|
e.stopPropagation();
|
|
26364
|
-
if (act === 'tab') {
|
|
28024
|
+
if (act === 'tab') {
|
|
28025
|
+
// One step of its own, after the typing before it.
|
|
28026
|
+
flushDiagramTyping(s);
|
|
28027
|
+
insertIntoDiagramSource(s.ta, ' ');
|
|
28028
|
+
flushDiagramTyping(s);
|
|
28029
|
+
return;
|
|
28030
|
+
}
|
|
28031
|
+
if (act === 'undo' || act === 'redo') { stepDiagramHistory(s, act === 'undo' ? -1 : 1); return; }
|
|
26365
28032
|
if (s.reentering) { s.reentering[act] = true; return; }
|
|
26366
28033
|
if (s.inflight || liveDiagramPanel() !== s) return; // a commit is out: it ends the panel, or leaves it open on failure
|
|
26367
28034
|
if (act === 'cancel') cancelDiagramPanel(s).catch(showUnexpectedFailure);
|
|
26368
28035
|
else if (act === 'commit') commitDiagramPanel(s).catch(showUnexpectedFailure);
|
|
26369
28036
|
else commitDiagramPanelAndReenter(s, { save: true }).catch(showUnexpectedFailure);
|
|
26370
28037
|
}
|
|
26371
|
-
// Through the browser's own insertText
|
|
26372
|
-
//
|
|
28038
|
+
// Through the browser's own insertText (its input event schedules the
|
|
28039
|
+
// preview); Ctrl+Z takes the two spaces back through the panel's history.
|
|
26373
28040
|
function insertIntoDiagramSource(ta, text) {
|
|
26374
28041
|
let ok = false;
|
|
26375
28042
|
try { ok = document.execCommand('insertText', false, text); } catch (e) { ok = false; }
|
|
@@ -26379,6 +28046,45 @@
|
|
|
26379
28046
|
}
|
|
26380
28047
|
}
|
|
26381
28048
|
|
|
28049
|
+
// v3.17.3 C1 / C7: the panel's text history, stepped by Ctrl+Z / Ctrl+Y in
|
|
28050
|
+
// the panel and by the toolbar's 復原 / 重做 while it is open. A step at
|
|
28051
|
+
// most once per idle tick of typing (CODE_EDIT_TICK_MS, the code edit's),
|
|
28052
|
+
// never inside an IME composition. A step changes the panel's text only:
|
|
28053
|
+
// the file is reached when the panel commits, as one document step.
|
|
28054
|
+
function pushDiagramHistory(s) {
|
|
28055
|
+
const ta = s.ta;
|
|
28056
|
+
s.history.push({ text: ta.value, selStart: ta.selectionStart, selEnd: ta.selectionEnd });
|
|
28057
|
+
}
|
|
28058
|
+
function scheduleDiagramHistory(s) {
|
|
28059
|
+
if (s.historyTimer) clearTimeout(s.historyTimer);
|
|
28060
|
+
s.historyTimer = setTimeout(() => {
|
|
28061
|
+
s.historyTimer = null;
|
|
28062
|
+
if (liveDiagramPanel() !== s || s.composing) return;
|
|
28063
|
+
pushDiagramHistory(s);
|
|
28064
|
+
}, CODE_EDIT_TICK_MS);
|
|
28065
|
+
}
|
|
28066
|
+
function flushDiagramTyping(s) {
|
|
28067
|
+
if (s.historyTimer) { clearTimeout(s.historyTimer); s.historyTimer = null; }
|
|
28068
|
+
if (!s.composing) pushDiagramHistory(s);
|
|
28069
|
+
}
|
|
28070
|
+
function stepDiagramHistory(s, dir) {
|
|
28071
|
+
flushDiagramTyping(s);
|
|
28072
|
+
const snap = dir < 0 ? s.history.undo() : s.history.redo();
|
|
28073
|
+
if (!snap) return; // nothing left: the document stack is never reached from here
|
|
28074
|
+
const ta = s.ta;
|
|
28075
|
+
ta.value = snap.text;
|
|
28076
|
+
if (document.activeElement === ta) ta.setSelectionRange(snap.selStart, snap.selEnd);
|
|
28077
|
+
autoSize(ta);
|
|
28078
|
+
scheduleDiagramPreview(s);
|
|
28079
|
+
setDirty(); // R47: the dot and the status follow the panel's text
|
|
28080
|
+
}
|
|
28081
|
+
// Whether the panel is open right now, with no side effect (unlike
|
|
28082
|
+
// liveDiagramPanel(), which disposes a panel a render took away).
|
|
28083
|
+
function diagramPanelIsOpen() {
|
|
28084
|
+
const s = diagramPanelState;
|
|
28085
|
+
return !!s && activeEditor === s.editor && s.panel.isConnected;
|
|
28086
|
+
}
|
|
28087
|
+
|
|
26382
28088
|
function scheduleDiagramPreview(s) {
|
|
26383
28089
|
if (s.timer) clearTimeout(s.timer);
|
|
26384
28090
|
s.timer = setTimeout(() => { s.timer = null; runDiagramPreview(s); }, DIAGRAM_PREVIEW_MS);
|
|
@@ -26574,6 +28280,7 @@
|
|
|
26574
28280
|
const ta = s.ta;
|
|
26575
28281
|
const startLine = s.startLine;
|
|
26576
28282
|
const sent = ta.value; // what this commit writes
|
|
28283
|
+
flushDiagramTyping(s); // v3.17.3 C7: the typing before the save is a step of the history carried on
|
|
26577
28284
|
const flags = { cancel: false, commit: false, save: false };
|
|
26578
28285
|
s.reentering = flags;
|
|
26579
28286
|
const hold = { keys: [], clickedAway: false, passTarget: ta };
|
|
@@ -26593,7 +28300,7 @@
|
|
|
26593
28300
|
if (!target && ok && !hold.clickedAway && s.lastResult !== RENDER_SUPERSEDED) {
|
|
26594
28301
|
const el = blockElAtLine(startLine);
|
|
26595
28302
|
const seed = { value: ta.value, selStart: ta.selectionStart, selEnd: ta.selectionEnd,
|
|
26596
|
-
previewHtml: s.preview.innerHTML };
|
|
28303
|
+
previewHtml: s.preview.innerHTML, history: s.history };
|
|
26597
28304
|
if (el && diagramPanelKindOf(el, s.kind) && await openDiagramPanel(el, { seed, kind: s.kind })) target = liveDiagramPanel();
|
|
26598
28305
|
}
|
|
26599
28306
|
if (ok && opts.save) saveAfterLastSave();
|
|
@@ -26643,7 +28350,7 @@
|
|
|
26643
28350
|
dropped = true;
|
|
26644
28351
|
}
|
|
26645
28352
|
}
|
|
26646
|
-
if (changed) { autoSize(ta); scheduleDiagramPreview(s); setDirty(); }
|
|
28353
|
+
if (changed) { autoSize(ta); scheduleDiagramPreview(s); flushDiagramTyping(s); setDirty(); }
|
|
26647
28354
|
if (dropped) showNotice(FLIGHT_DROPPED_MESSAGE);
|
|
26648
28355
|
return leave;
|
|
26649
28356
|
}
|
|
@@ -27313,7 +29020,7 @@
|
|
|
27313
29020
|
const back = popped.length === offer.count &&
|
|
27314
29021
|
lines.slice(b.startLine, b.startLine + n).join('\n') === offer.baseSource;
|
|
27315
29022
|
if (back) setDirty();
|
|
27316
|
-
const ok = back && await
|
|
29023
|
+
const ok = back && await historyRerender({
|
|
27317
29024
|
startLine: b.startLine + 1, endLine: b.startLine + n, delta: lines.length - prevLines.length,
|
|
27318
29025
|
});
|
|
27319
29026
|
if (!ok) {
|
|
@@ -27351,7 +29058,7 @@
|
|
|
27351
29058
|
if (ok) {
|
|
27352
29059
|
setDirty();
|
|
27353
29060
|
const b = blocks.find((x) => x.id === ro.offer.blockId);
|
|
27354
|
-
ok = await
|
|
29061
|
+
ok = await historyRerender(b ? { startLine: b.startLine + 1, endLine: b.startLine + 1,
|
|
27355
29062
|
delta: lines.length - prevLines.length } : undefined);
|
|
27356
29063
|
}
|
|
27357
29064
|
if (!ok) {
|