@helping-ai-workflow/md2doc 3.17.1 → 3.17.3
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 +1839 -159
- package/lib/editor/code-edit.js +27 -14
- 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 +34 -4
- 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 +25 -2
- package/lib/theme/tokens.js +4 -1
- package/package.json +1 -1
package/lib/editor/client.js
CHANGED
|
@@ -50,18 +50,115 @@
|
|
|
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
|
+
function keepDefinitionLines(state, startLine, endLine, after) {
|
|
127
|
+
const groups = definitionLinesIn(state, startLine, endLine);
|
|
128
|
+
if (!groups.length) return after;
|
|
129
|
+
if (collapseBlankRuns(after).join('\n') === withoutDefinitionLines(state, startLine, endLine, groups).join('\n')) return null;
|
|
130
|
+
let tail = after.length;
|
|
131
|
+
while (tail > 0 && after[tail - 1] === '') tail--;
|
|
132
|
+
const out = after.slice(0, tail);
|
|
133
|
+
const prev = startLine >= 2 ? state.lines[startLine - 2] : undefined;
|
|
134
|
+
if (!out.length && prev !== undefined && prev.trim() !== '') out.push('');
|
|
135
|
+
for (const g of groups) {
|
|
136
|
+
if (out.length && out[out.length - 1] !== '') out.push('');
|
|
137
|
+
out.push(...definitionLinesToWrite(g));
|
|
138
|
+
}
|
|
139
|
+
const next = state.lines[endLine];
|
|
140
|
+
if (tail === after.length && next !== undefined && next.trim() !== '') out.push('');
|
|
141
|
+
return out.concat(after.slice(tail));
|
|
142
|
+
}
|
|
143
|
+
|
|
53
144
|
// Pure: apply a raw-edit commit to an EXPLICIT line range (startLine..endLine,
|
|
54
145
|
// 1-indexed inclusive); push onto stack.
|
|
55
146
|
// Returns {lines, blocks, op}; op === null when text is unchanged.
|
|
56
147
|
// The shift anchor is the LAST block whose endLine <= the range's endLine,
|
|
57
148
|
// so that only blocks after the committed range are shifted — not blocks
|
|
58
149
|
// inside it. Guards the no-anchor case (range before every block).
|
|
59
|
-
|
|
150
|
+
// Fix round 1 (I1, R55): `opts.verbatim` is text the user wrote (the raw
|
|
151
|
+
// box, an in-place code edit): it replaces what it covers exactly, so a
|
|
152
|
+
// definition the user deleted or changed there is not kept as well.
|
|
153
|
+
// Fix round 3 (R56): `opts.carriesGap` is structural text that already
|
|
154
|
+
// holds every line between the range's blocks verbatim (keepDefinitionLines()).
|
|
155
|
+
function commitRangeEdit(state, startLine, endLine, newText, opts) {
|
|
60
156
|
const refusal = refuseInvertedRange(state, 'commitRangeEdit', startLine, endLine);
|
|
61
157
|
if (refusal) return refusal;
|
|
62
158
|
const before = state.lines.slice(startLine - 1, endLine);
|
|
63
|
-
const
|
|
64
|
-
|
|
159
|
+
const typed = newText.split('\n');
|
|
160
|
+
const after = opts && (opts.verbatim || opts.carriesGap) ? typed : keepDefinitionLines(state, startLine, endLine, typed);
|
|
161
|
+
if (after === null || before.join('\n') === after.join('\n')) {
|
|
65
162
|
return { lines: state.lines, blocks: state.blocks, op: null };
|
|
66
163
|
}
|
|
67
164
|
const op = { startLine, endLine, before, after };
|
|
@@ -75,9 +172,9 @@
|
|
|
75
172
|
// Pure: apply a raw-edit commit to (lines, blocks); push onto stack.
|
|
76
173
|
// Returns {lines, blocks, op}; op === null when text is unchanged.
|
|
77
174
|
// Wrapper over commitRangeEdit: looks up the block by id and delegates.
|
|
78
|
-
function commitEdit(state, blockId, newText) {
|
|
175
|
+
function commitEdit(state, blockId, newText, opts) {
|
|
79
176
|
const block = state.blocks.find((b) => b.id === blockId);
|
|
80
|
-
return commitRangeEdit(state, block.startLine, block.endLine, newText);
|
|
177
|
+
return commitRangeEdit(state, block.startLine, block.endLine, newText, opts);
|
|
81
178
|
}
|
|
82
179
|
|
|
83
180
|
// Generalized range removal: deletes startLine..endLine (zero lines) and
|
|
@@ -92,6 +189,14 @@
|
|
|
92
189
|
function commitRangeRemoval(state, startLine, endLine) {
|
|
93
190
|
const refusal = refuseInvertedRange(state, 'commitRangeRemoval', startLine, endLine);
|
|
94
191
|
if (refusal) return refusal;
|
|
192
|
+
// v3.17.3 N2b: a span delete across a definition removes only the blocks'
|
|
193
|
+
// own lines; the definition stays where the span was, the blank lines
|
|
194
|
+
// around the span untouched. Same anchor, same single op.
|
|
195
|
+
const kept = definitionLinesIn(state, startLine, endLine);
|
|
196
|
+
if (kept.length) {
|
|
197
|
+
return commitRangeEdit(state, startLine, endLine,
|
|
198
|
+
kept.map((g) => definitionLinesToWrite(g).join('\n')).join('\n\n'), { carriesGap: true });
|
|
199
|
+
}
|
|
95
200
|
const anchor = state.blocks.filter((b) => b.endLine <= endLine).pop();
|
|
96
201
|
let sl = startLine, el = endLine;
|
|
97
202
|
// state.lines[el] (0-indexed) is the line immediately AFTER the range.
|
|
@@ -108,6 +213,35 @@
|
|
|
108
213
|
return { lines: r.lines, blocks, op };
|
|
109
214
|
}
|
|
110
215
|
|
|
216
|
+
// v3.17.2 A3 (R65, v3.15.0 N5): do two list lines open items of the SAME
|
|
217
|
+
// list kind, i.e. would markdown read them as one list with nothing but
|
|
218
|
+
// blank lines between them? Same bullet character, or both ordered with
|
|
219
|
+
// the same delimiter (`.` / `)`); `- a` then `* b` are two lists however
|
|
220
|
+
// they are spaced.
|
|
221
|
+
const LIST_KIND_RE = /^ {0,3}(?:([-*+])|\d{1,9}([.)]))(?:[ \t]|$)/;
|
|
222
|
+
function sameListKind(lineA, lineB) {
|
|
223
|
+
const a = LIST_KIND_RE.exec(String(lineA == null ? '' : lineA));
|
|
224
|
+
const b = LIST_KIND_RE.exec(String(lineB == null ? '' : lineB));
|
|
225
|
+
if (!a || !b) return false;
|
|
226
|
+
return a[1] ? a[1] === b[1] : (!!a[2] && a[2] === b[2]);
|
|
227
|
+
}
|
|
228
|
+
// v3.17.2 A3: commitRangeRemoval()'s twin for a block that stood between
|
|
229
|
+
// two lists of the same kind: its lines and EVERY blank line on both sides
|
|
230
|
+
// go, so the two lists join TIGHT (one blank between them would make the
|
|
231
|
+
// joined list loose). One op; the shiftBlocks anchor as commitRangeRemoval().
|
|
232
|
+
function commitTightRemoval(state, startLine, endLine) {
|
|
233
|
+
const refusal = refuseInvertedRange(state, 'commitTightRemoval', startLine, endLine);
|
|
234
|
+
if (refusal) return refusal;
|
|
235
|
+
const anchor = state.blocks.filter((b) => b.endLine <= endLine).pop();
|
|
236
|
+
const span = growOverBlanks(state.lines, startLine, endLine, 'both', state.blocks);
|
|
237
|
+
const before = state.lines.slice(span[0] - 1, span[1]);
|
|
238
|
+
const op = { startLine: span[0], endLine: span[1], before, after: [] };
|
|
239
|
+
const r = ops.replaceLines(state.lines, span[0], span[1], []);
|
|
240
|
+
const blocks = anchor ? ops.shiftBlocks(state.blocks, anchor.id, r.delta) : state.blocks;
|
|
241
|
+
state.stack.push(op);
|
|
242
|
+
return { lines: r.lines, blocks, op };
|
|
243
|
+
}
|
|
244
|
+
|
|
111
245
|
// Task 4 fix (review, Important): removing the LAST remaining item of a
|
|
112
246
|
// list block (empty-Enter on a list with exactly one item) serializes to
|
|
113
247
|
// '' — committing that through commitEdit() would replace the block's
|
|
@@ -249,6 +383,9 @@
|
|
|
249
383
|
// * no blocks at all — an empty or whitespace-only file. Its whole line
|
|
250
384
|
// range is replaced by [...newLines, ''], so the file ends with exactly
|
|
251
385
|
// one newline whatever blank lines it held.
|
|
386
|
+
// v3.17.3 N2b (R54): or a file holding only link reference
|
|
387
|
+
// definitions, which make no block; their lines are kept after the new
|
|
388
|
+
// ones, one blank line apart.
|
|
252
389
|
// Returns { lines, blocks, op, newStartLine }; op is null only for a
|
|
253
390
|
// refusal (commitBlockInsertion()'s inverted-anchor case).
|
|
254
391
|
function commitInsertionAt(state, anchorBlockId, newLines) {
|
|
@@ -263,7 +400,9 @@
|
|
|
263
400
|
state.stack.push(op);
|
|
264
401
|
return { lines: r.lines, blocks: state.blocks, op, newStartLine: 1 };
|
|
265
402
|
}
|
|
266
|
-
const
|
|
403
|
+
const kept = state.lines.filter((l) => l.trim() !== '');
|
|
404
|
+
const z = commitRangeEdit(state, 1, state.lines.length,
|
|
405
|
+
newLines.concat(kept.length ? [''].concat(kept, ['']) : ['']).join('\n'));
|
|
267
406
|
return { lines: z.lines, blocks: z.blocks, op: z.op, newStartLine: 1 };
|
|
268
407
|
}
|
|
269
408
|
const first = state.blocks[0];
|
|
@@ -300,14 +439,28 @@
|
|
|
300
439
|
// 'below', R58) grows op 2 over the blank lines to that side.
|
|
301
440
|
// v3.15.0 B2 fix round 1 (R58): `start..end` grown over the blank lines
|
|
302
441
|
// right above it (`side` 'above'), right below it ('below') or both ('both',
|
|
303
|
-
// R62)
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
|
|
442
|
+
// R62): the separator to the neighbouring block, the one a list item eats to
|
|
443
|
+
// join that list tight. Final review I1 (R54): the lines between two blocks
|
|
444
|
+
// are NOT all blank — a link reference definition is on lines no block owns
|
|
445
|
+
// (blockmap.js, `defLinesAbove`). So a side is grown only when its blank run
|
|
446
|
+
// reaches a block of `blocks` exactly (the block above ends on the line
|
|
447
|
+
// before the run, the block below starts on the line after it) or the end
|
|
448
|
+
// of the file. A run that stops at an unowned line is left as it is: eating
|
|
449
|
+
// it made `- Intro` then `[r]: http://u` one item, the definition its text.
|
|
450
|
+
function growOverBlanks(lines, start, end, side, blocks) {
|
|
451
|
+
const bs = Array.isArray(blocks) ? blocks : [];
|
|
307
452
|
let s = start;
|
|
308
453
|
let e = end;
|
|
309
|
-
if (side === 'above' || side === 'both')
|
|
310
|
-
|
|
454
|
+
if (side === 'above' || side === 'both') {
|
|
455
|
+
let k = s;
|
|
456
|
+
while (k >= 2 && String(lines[k - 2]).trim() === '') k--;
|
|
457
|
+
if (k === 1 || bs.some((b) => b.endLine === k - 1)) s = k;
|
|
458
|
+
}
|
|
459
|
+
if (side === 'below' || side === 'both') {
|
|
460
|
+
let k = e;
|
|
461
|
+
while (k < lines.length && String(lines[k]).trim() === '') k++;
|
|
462
|
+
if (k === lines.length || bs.some((b) => b.startLine === k + 1)) e = k;
|
|
463
|
+
}
|
|
311
464
|
return [s, e];
|
|
312
465
|
}
|
|
313
466
|
|
|
@@ -321,7 +474,11 @@
|
|
|
321
474
|
return { lines: r.lines, blocks: r.blocks, op: r.op, newStartLine: before ? at : at + 1 };
|
|
322
475
|
}
|
|
323
476
|
|
|
324
|
-
|
|
477
|
+
// v3.17.3 N4 (R41): `tail` ({ range, md }, renumberedRunFrom()) is the
|
|
478
|
+
// ordered list right below, renumbered: op 2 also replaces its lines when
|
|
479
|
+
// they still follow op 2's range unchanged, so the join and the new numbers
|
|
480
|
+
// are one step. `tailEnd` (its last line before the ops) is reported then.
|
|
481
|
+
function commitShortcut(state, at, literalLines, convertedLines, join, tail) {
|
|
325
482
|
let first;
|
|
326
483
|
let start;
|
|
327
484
|
if (at && at.blockId !== undefined && at.blockId !== null) {
|
|
@@ -338,8 +495,21 @@
|
|
|
338
495
|
const mid = { lines: first.lines, blocks: first.blocks, stack: state.stack };
|
|
339
496
|
// R58: a list item joining the list above or below takes the separator
|
|
340
497
|
// between the two into op 2's range, so the list stays tight.
|
|
341
|
-
const span = growOverBlanks(mid.lines, start, start + literalLines.length - 1, join || null);
|
|
342
|
-
|
|
498
|
+
const span = growOverBlanks(mid.lines, start, start + literalLines.length - 1, join || null, mid.blocks);
|
|
499
|
+
let spanEnd = span[1];
|
|
500
|
+
let text = convertedLines.join('\n');
|
|
501
|
+
let tailEnd = null;
|
|
502
|
+
if (tail && tail.range && typeof tail.md === 'string') {
|
|
503
|
+
const n = tail.range.endLine - tail.range.startLine + 1;
|
|
504
|
+
const before = state.lines.slice(tail.range.startLine - 1, tail.range.endLine);
|
|
505
|
+
const here = mid.lines.slice(span[1], span[1] + n);
|
|
506
|
+
if (n > 0 && here.length === n && here.every((l, k) => l === before[k])) {
|
|
507
|
+
spanEnd = span[1] + n;
|
|
508
|
+
text += '\n' + tail.md;
|
|
509
|
+
tailEnd = tail.range.endLine;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
const second = commitRangeEdit(mid, span[0], spanEnd, text);
|
|
343
513
|
if (second.op === null) {
|
|
344
514
|
// Nothing to convert (cannot happen for a real marker: the literal is
|
|
345
515
|
// escaped and the conversion is not). Op 1 alone is not a shortcut, so
|
|
@@ -348,7 +518,7 @@
|
|
|
348
518
|
return { lines: state.lines, blocks: state.blocks, ops: [null, null], newStartLine: null };
|
|
349
519
|
}
|
|
350
520
|
return { lines: second.lines, blocks: second.blocks, ops: [first.op, second.op],
|
|
351
|
-
newStartLine: span[0], literalStartLine: start };
|
|
521
|
+
newStartLine: span[0], literalStartLine: start, tailEnd: tailEnd };
|
|
352
522
|
}
|
|
353
523
|
|
|
354
524
|
// ── S4 Task 3: relocating ONE block's lines (spec §4.5) ────────────────
|
|
@@ -454,7 +624,10 @@
|
|
|
454
624
|
if (!plan) {
|
|
455
625
|
return { lines: state.lines, blocks: state.blocks, op: null, noop: 'same-position' };
|
|
456
626
|
}
|
|
457
|
-
|
|
627
|
+
// R56: planBlockMove() builds `after` by slicing `lines` — the source's
|
|
628
|
+
// own span, everything between source and destination — so every
|
|
629
|
+
// definition line of the range is in it, where it stood.
|
|
630
|
+
const r = commitRangeEdit(state, plan.startLine, plan.endLine, plan.after.join('\n'), { carriesGap: true });
|
|
458
631
|
r.newRange = plan.newRange;
|
|
459
632
|
return r;
|
|
460
633
|
}
|
|
@@ -874,7 +1047,7 @@
|
|
|
874
1047
|
}
|
|
875
1048
|
|
|
876
1049
|
if (typeof module === 'object' && module.exports) {
|
|
877
|
-
module.exports = { pasteIsAllListLines, LIST_LINE_RE, extractBlockSource, commitEdit, commitRangeEdit, commitRangeRemoval, commitListBlockRemoval, commitBlockInsertion, commitInsertionAt, commitSplit, commitShortcut, growOverBlanks, commitTightInsert, planBlockMove, commitBlockMove, reorderSpanRange, spanMoveRange, spanIndentsAreAnchored, blockMoveSeamRefusal, rollbackFailedRender, transientCommitMd, headingDepthOf, withHeadingDepth, resolveDrawioLanding, shiftBlocksAfterBodyEdit, waveDiscardIsSafe, waveOfferHolds, waveRedoHolds };
|
|
1050
|
+
module.exports = { pasteIsAllListLines, LIST_LINE_RE, extractBlockSource, commitEdit, commitRangeEdit, commitRangeRemoval, sameListKind, commitTightRemoval, commitListBlockRemoval, commitBlockInsertion, commitInsertionAt, commitSplit, commitShortcut, growOverBlanks, commitTightInsert, planBlockMove, commitBlockMove, reorderSpanRange, spanMoveRange, spanIndentsAreAnchored, blockMoveSeamRefusal, rollbackFailedRender, transientCommitMd, headingDepthOf, withHeadingDepth, resolveDrawioLanding, shiftBlocksAfterBodyEdit, waveDiscardIsSafe, waveOfferHolds, waveRedoHolds };
|
|
878
1051
|
return; // node: pure core only
|
|
879
1052
|
}
|
|
880
1053
|
|
|
@@ -1355,7 +1528,7 @@
|
|
|
1355
1528
|
// rather than surface.
|
|
1356
1529
|
function burstHasUncommittedEdit() {
|
|
1357
1530
|
return !!(currentBurst && currentBurst.editEl &&
|
|
1358
|
-
burstBaselineHtml(currentBurst.editEl)
|
|
1531
|
+
!sameBurstHtml(burstBaselineHtml(currentBurst.editEl), currentBurst.original));
|
|
1359
1532
|
}
|
|
1360
1533
|
|
|
1361
1534
|
// v3.4.0 §3: single source of truth for "does the document have anything
|
|
@@ -1389,8 +1562,29 @@
|
|
|
1389
1562
|
// v3.17.0 T2 fix round 1 (R47): code typed into an open in-place edit is
|
|
1390
1563
|
// unsaved work too — it reaches `lines` only when the edit commits. T5
|
|
1391
1564
|
// (R56 (d)): so is the source typed into an open diagram panel.
|
|
1565
|
+
// v3.17.2 B1 (v3.17.0 final review T2 L74): and so is the text typed into
|
|
1566
|
+
// an open raw box. Measured before: type in the box, and the status
|
|
1567
|
+
// read clean and a tab close raised no dialog — the typing was lost.
|
|
1568
|
+
// v3.17.3 C8: and so is alt text typed into the image toolbar's field and
|
|
1569
|
+
// not yet applied (Enter). Measured before: the status read clean and a
|
|
1570
|
+
// tab close raised no dialog.
|
|
1392
1571
|
return stack.isDirty() || burstHasUncommittedEdit() || waveUnwrittenEdit || pendingStructure !== null ||
|
|
1393
|
-
codeEditHasUncommittedText() || diagramPanelHasUncommittedText()
|
|
1572
|
+
codeEditHasUncommittedText() || diagramPanelHasUncommittedText() || rawEditHasUncommittedText() ||
|
|
1573
|
+
imgTbHasPendingAlt();
|
|
1574
|
+
}
|
|
1575
|
+
// Guarded the way its neighbours are: documentIsDirty() also runs in the
|
|
1576
|
+
// beforeunload handler, and the toolbar's state is declared far below.
|
|
1577
|
+
function imgTbHasPendingAlt() {
|
|
1578
|
+
try {
|
|
1579
|
+
return !!imgTbAlt && !imgTbInput.hidden && imgTbInput.value !== imgTbAlt.original;
|
|
1580
|
+
} catch (e) { return false; }
|
|
1581
|
+
}
|
|
1582
|
+
// Guarded the same way, for the same reason (the beforeunload handler).
|
|
1583
|
+
function rawEditHasUncommittedText() {
|
|
1584
|
+
try {
|
|
1585
|
+
const ed = activeEditor;
|
|
1586
|
+
return !!ed && ed.kind === 'raw' && ed.blockEl.isConnected && ed.hasChanges();
|
|
1587
|
+
} catch (e) { return false; }
|
|
1394
1588
|
}
|
|
1395
1589
|
// Guarded the way burstHasUncommittedEdit() is: this also runs inside the
|
|
1396
1590
|
// beforeunload handler, where a throw would drop the navigation guard.
|
|
@@ -2205,14 +2399,19 @@
|
|
|
2205
2399
|
absorbedOutsideContent(lastRenderLines, blocks,
|
|
2206
2400
|
lines, j.blocks,
|
|
2207
2401
|
j.blocks[j.blocks.length - 1]);
|
|
2208
|
-
|
|
2402
|
+
// v3.17.3 B10: read against the blocks the transient was placed among,
|
|
2403
|
+
// before applyFullRender() replaces them. A transient on screen is a child
|
|
2404
|
+
// the last render did not draw, so domMatchesLastRender() is false and
|
|
2405
|
+
// this render is always the full one.
|
|
2406
|
+
const keptTransient = transientKeptThroughRender(editRange, isReconcile);
|
|
2407
|
+
const plan = (!keptTransient && editRange && window.md2docPatchmap && !lastPartsIsBootstrapSeed && domMatchesLastRender())
|
|
2209
2408
|
? window.md2docPatchmap.patchmap({
|
|
2210
2409
|
oldBlocks: blocks, newBlocks: j.blocks,
|
|
2211
2410
|
oldParts: lastParts, newParts: j.parts, editRange: editRange,
|
|
2212
2411
|
})
|
|
2213
2412
|
: null;
|
|
2214
2413
|
const ok = plan ? await applyPatch(j, plan, declaredRange)
|
|
2215
|
-
: await applyFullRender(j, scrollY, declaredRange);
|
|
2414
|
+
: await applyFullRender(j, scrollY, declaredRange, keptTransient);
|
|
2216
2415
|
// applyPatch() 與 applyFullRender() 今天都只回 true:applyFullRender() 全函
|
|
2217
2416
|
// 式唯一的 return 就是尾端
|
|
2218
2417
|
// 那個 `return true`,而 applyPatch() 要嘛回 true、要嘛在自己的 catch 裡
|
|
@@ -2854,7 +3053,7 @@
|
|
|
2854
3053
|
return true;
|
|
2855
3054
|
}
|
|
2856
3055
|
|
|
2857
|
-
async function applyFullRender(j, scrollY, declaredRange) {
|
|
3056
|
+
async function applyFullRender(j, scrollY, declaredRange, keptTransient) {
|
|
2858
3057
|
blocks = j.blocks;
|
|
2859
3058
|
lastParts = j.parts; // v3.2.0: 供下一次 patch 當 oldParts
|
|
2860
3059
|
lastPartsIsBootstrapSeed = false;
|
|
@@ -2889,8 +3088,10 @@
|
|
|
2889
3088
|
// fresh assignment.
|
|
2890
3089
|
pristineInsert = null;
|
|
2891
3090
|
// v3.14.0 T1: the transient lived in the `.content` children the
|
|
2892
|
-
// innerHTML write above just replaced — every render drops it (D3)
|
|
3091
|
+
// innerHTML write above just replaced — every render drops it (D3),
|
|
3092
|
+
// except the one transientKeptThroughRender() kept (v3.17.3 B10).
|
|
2893
3093
|
transient = null;
|
|
3094
|
+
if (keptTransient) restoreKeptTransient(keptTransient);
|
|
2894
3095
|
// Task 2 (Phase 3): same belt-and-braces reasoning for the always-on
|
|
2895
3096
|
// WYSIWYG burst — whatever block had one open a moment ago was just
|
|
2896
3097
|
// detached by the innerHTML replacement above, so its own focusout
|
|
@@ -3022,10 +3223,79 @@
|
|
|
3022
3223
|
}
|
|
3023
3224
|
}
|
|
3024
3225
|
reapDiagramPanel(); // T10: a panel this render took away is disposed here
|
|
3226
|
+
// v3.17.3 B10: last, after every teardown above (the burst one would
|
|
3227
|
+
// dispose the burst this focus opens).
|
|
3228
|
+
if (keptTransient && keptTransient.focused && transient === keptTransient.t) focusTransientStart(transient.el);
|
|
3025
3229
|
setDirty();
|
|
3026
3230
|
return true;
|
|
3027
3231
|
}
|
|
3028
3232
|
|
|
3233
|
+
// ── v3.17.3 B10: which transient a render keeps ───────────────────────
|
|
3234
|
+
//
|
|
3235
|
+
// A render replaces every `.content` child, so it used to drop the
|
|
3236
|
+
// transient with them (D3). That took away the empty paragraph an Enter at
|
|
3237
|
+
// a block's start leaves ABOVE the caret as soon as the block below it was
|
|
3238
|
+
// committed: measured, Enter at `Omega.`'s start, type X, then ArrowUp —
|
|
3239
|
+
// the paragraph was gone and the caret landed in `Alpha.` at offset 1; a
|
|
3240
|
+
// click on it instead left focus on the body. This is the one rule for that
|
|
3241
|
+
// (Task 7's nested-list commit goes through it too): an EMPTY transient
|
|
3242
|
+
// whose whole neighbourhood above sits before the render's edit — the
|
|
3243
|
+
// edit starts at or below the first real block after it — is the same
|
|
3244
|
+
// element after the render, back after the block it followed (the lines
|
|
3245
|
+
// above the edit do not move, so that block keeps its start line), still
|
|
3246
|
+
// `transient`, and focused again if it had focus. Anything else is dropped
|
|
3247
|
+
// as before: a transient with text is committed by its own path, never
|
|
3248
|
+
// rendered over; an edit at or above it may have moved or removed the block
|
|
3249
|
+
// it follows; a reconciliation redraw restores no transient.
|
|
3250
|
+
// R29 (fix round 1): and only while the user is still at it — focus is in
|
|
3251
|
+
// the transient, or in the block right below it that this render commits
|
|
3252
|
+
// (the caret an Enter at that block's start left there; an ArrowUp held
|
|
3253
|
+
// behind the commit still has it there when this runs). A transient the
|
|
3254
|
+
// user has left (a press into another block) is not kept: it was never
|
|
3255
|
+
// written, so it goes as an empty transient that was left goes (D3), and
|
|
3256
|
+
// while one stands on screen every render is a full one
|
|
3257
|
+
// (domMatchesLastRender()).
|
|
3258
|
+
// → null, or { t, anchorLine, focused }; anchorLine null = top of
|
|
3259
|
+
// `.content`.
|
|
3260
|
+
function transientKeptThroughRender(editRange, isReconcile) {
|
|
3261
|
+
const t = transient;
|
|
3262
|
+
if (!t || isReconcile || !editRange || !t.el.isConnected || t.el.parentNode !== contentEl) return null;
|
|
3263
|
+
if (!transientSurfaceEmpty(t.el) || transientIsOnlyBlock(t.el)) return null;
|
|
3264
|
+
const realSibling = (step) => {
|
|
3265
|
+
let n = step(t.el);
|
|
3266
|
+
while (n && !(n.classList.contains('ed-block') && n.getAttribute('data-block-id') !== null)) n = step(n);
|
|
3267
|
+
return n;
|
|
3268
|
+
};
|
|
3269
|
+
const belowEl = realSibling((n) => n.nextElementSibling);
|
|
3270
|
+
const below = blockRecOf(belowEl);
|
|
3271
|
+
if (!below || editRange.startLine < below.startLine) return null;
|
|
3272
|
+
const ae = document.activeElement;
|
|
3273
|
+
if (!ae || !(t.el.contains(ae) || belowEl.contains(ae))) return null;
|
|
3274
|
+
const aboveEl = realSibling((n) => n.previousElementSibling);
|
|
3275
|
+
const above = aboveEl ? blockRecOf(aboveEl) : null;
|
|
3276
|
+
if (aboveEl && !above) return null;
|
|
3277
|
+
return { t, anchorLine: above ? above.startLine : null, focused: t.el.contains(document.activeElement) };
|
|
3278
|
+
}
|
|
3279
|
+
// applyFullRender()'s half, against the new `blocks`: the element goes back
|
|
3280
|
+
// right after the block now starting at `anchorLine`. A block no longer
|
|
3281
|
+
// there leaves it dropped.
|
|
3282
|
+
function restoreKeptTransient(kept) {
|
|
3283
|
+
const el = kept.t.el;
|
|
3284
|
+
const after = kept.anchorLine === null ? null : blockElAtLine(kept.anchorLine);
|
|
3285
|
+
if (kept.anchorLine !== null && !after) return;
|
|
3286
|
+
if (after) after.parentNode.insertBefore(el, after.nextSibling);
|
|
3287
|
+
else contentEl.insertBefore(el, contentEl.firstChild);
|
|
3288
|
+
// M1 (fix round 1): `stayEl` is nulled, not re-pointed at the block now
|
|
3289
|
+
// below. burstUndo() reads it as "Ctrl+Z in that block takes the
|
|
3290
|
+
// transient away as the Enter's undo step", which holds only while
|
|
3291
|
+
// nothing has been committed since the Enter. This render committed
|
|
3292
|
+
// something (it has an editRange), so the next Ctrl+Z belongs to that
|
|
3293
|
+
// newer op; taking the older Enter's transient first would undo out of
|
|
3294
|
+
// order. The transient goes when it is left (D3).
|
|
3295
|
+
kept.t.stayEl = null;
|
|
3296
|
+
transient = kept.t;
|
|
3297
|
+
}
|
|
3298
|
+
|
|
3029
3299
|
// ── v3.15.0 T4 (D12, R9): diagram engines on demand ────────────────────
|
|
3030
3300
|
//
|
|
3031
3301
|
// The edit page inlines Mermaid / WaveDrom / the KaTeX stylesheet only when
|
|
@@ -3268,7 +3538,7 @@
|
|
|
3268
3538
|
}
|
|
3269
3539
|
function surfaceDrawnAs(original) {
|
|
3270
3540
|
const found = Array.prototype.filter.call(contentEl.querySelectorAll('.ed-wys-armed'),
|
|
3271
|
-
(el) => !el.closest('[data-ed-transient]') && burstBaselineHtml(el)
|
|
3541
|
+
(el) => !el.closest('[data-ed-transient]') && sameBurstHtml(burstBaselineHtml(el), original));
|
|
3272
3542
|
return found.length === 1 ? found[0] : null;
|
|
3273
3543
|
}
|
|
3274
3544
|
async function restoreAfterReconcile(carry, cap) {
|
|
@@ -4079,6 +4349,10 @@
|
|
|
4079
4349
|
// answer — the "/" menu's 引用 opens the box seeded `> ` with the caret at
|
|
4080
4350
|
// its end. hasChanges() compares against it, so a box left untouched stays
|
|
4081
4351
|
// pristine.
|
|
4352
|
+
// v3.17.3 fix round 1 (R47): the raw box textarea whose commit is
|
|
4353
|
+
// rendering (its text is already in `lines`), or null. The keydown handler
|
|
4354
|
+
// sends its Ctrl+Z / Ctrl+Y to the document instead of the box.
|
|
4355
|
+
let rawBoxCommitting = null;
|
|
4082
4356
|
async function openRawEditor(blockEl, range, seedOverride) {
|
|
4083
4357
|
if (blockEl.querySelector('.ed-raw')) return; // already editing this block
|
|
4084
4358
|
// Task G fix round 1 (npm test RED on f63c88a): the guard below used to
|
|
@@ -4209,6 +4483,8 @@
|
|
|
4209
4483
|
// 一行空白是這個編輯器自己放的,不是使用者打的,把它算成「有變更」
|
|
4210
4484
|
// 會讓「開起來看一眼就關掉」變成一次靜默的寫檔。
|
|
4211
4485
|
hasChanges: () => ta.value !== seed.value,
|
|
4486
|
+
// v3.17.2 B1: what rawEditHasUncommittedText() asks for.
|
|
4487
|
+
kind: 'raw',
|
|
4212
4488
|
// v3.15.0 B5 fix round 2 (R80): the session by value, in the
|
|
4213
4489
|
// discardedRawEdit shape, for reconcileScreen() to reopen it.
|
|
4214
4490
|
snapshot: () => ({ blockId, range, startLine, endLine, source,
|
|
@@ -4251,6 +4527,7 @@
|
|
|
4251
4527
|
// of repainting the same bytes, not a different edit.
|
|
4252
4528
|
if (rangeMode) {
|
|
4253
4529
|
if (activeEditor && activeEditor.blockEl === blockEl) activeEditor = null;
|
|
4530
|
+
setDirty(); // v3.17.2 B1: the box's typing is no longer unsaved work
|
|
4254
4531
|
await safeRerenderAll();
|
|
4255
4532
|
return;
|
|
4256
4533
|
}
|
|
@@ -4265,6 +4542,7 @@
|
|
|
4265
4542
|
// never stomp some OTHER block's activeEditor set after this one.
|
|
4266
4543
|
if (activeEditor && activeEditor.blockEl === blockEl) activeEditor = null;
|
|
4267
4544
|
blockEl.innerHTML = original;
|
|
4545
|
+
setDirty(); // v3.17.2 B1: the box's typing is no longer unsaved work
|
|
4268
4546
|
// No re-wiring needed here (unlike the old per-block gutter): block
|
|
4269
4547
|
// selection / the edit bar are handled by ONE delegated `document`
|
|
4270
4548
|
// click listener (see wireBlockSelection() below), so this
|
|
@@ -4377,8 +4655,8 @@
|
|
|
4377
4655
|
// delegates), so the two branches are the same pipeline — replaceLines()
|
|
4378
4656
|
// + one undo op — differing only in which lines they name.
|
|
4379
4657
|
const result = rangeMode
|
|
4380
|
-
? commitRangeEdit({ lines, blocks, stack }, startLine, endLine, ta.value)
|
|
4381
|
-
: commitEdit({ lines, blocks, stack }, blockId, ta.value);
|
|
4658
|
+
? commitRangeEdit({ lines, blocks, stack }, startLine, endLine, ta.value, { verbatim: true })
|
|
4659
|
+
: commitEdit({ lines, blocks, stack }, blockId, ta.value, { verbatim: true });
|
|
4382
4660
|
if (result.op === null) {
|
|
4383
4661
|
// §10-gap fix (review): an explicit commit (Ctrl+Enter/✓) whose
|
|
4384
4662
|
// text happens to be byte-identical to `source` is still an
|
|
@@ -4393,7 +4671,14 @@
|
|
|
4393
4671
|
endLine: Math.max(result.op.startLine - 1, result.op.endLine),
|
|
4394
4672
|
delta: result.op.after.length - result.op.before.length }
|
|
4395
4673
|
: undefined;
|
|
4396
|
-
|
|
4674
|
+
// R47: see `rawBoxCommitting`.
|
|
4675
|
+
rawBoxCommitting = ta;
|
|
4676
|
+
let ok;
|
|
4677
|
+
try {
|
|
4678
|
+
ok = await safeRerenderAll(editRange);
|
|
4679
|
+
} finally {
|
|
4680
|
+
if (rawBoxCommitting === ta) rawBoxCommitting = null;
|
|
4681
|
+
}
|
|
4397
4682
|
if (!ok) {
|
|
4398
4683
|
// Roll back the optimistic edit: pop the op safeRerenderAll's
|
|
4399
4684
|
// failure means was never actually rendered, so `lines` stays
|
|
@@ -4460,7 +4745,9 @@
|
|
|
4460
4745
|
return leave().then((ok) => { if (ok) handCaretOnBelow(tail); }).catch(showUnexpectedFailure);
|
|
4461
4746
|
}
|
|
4462
4747
|
|
|
4463
|
-
|
|
4748
|
+
// v3.17.2 B1: setDirty() too, as the code edit and the diagram panel do
|
|
4749
|
+
// (rawEditHasUncommittedText() is the predicate's half).
|
|
4750
|
+
ta.addEventListener('input', () => { autoSize(ta); setDirty(); });
|
|
4464
4751
|
ta.addEventListener('keydown', (e) => {
|
|
4465
4752
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
|
4466
4753
|
e.preventDefault();
|
|
@@ -4759,6 +5046,22 @@
|
|
|
4759
5046
|
}, () => {});
|
|
4760
5047
|
}
|
|
4761
5048
|
|
|
5049
|
+
// v3.17.3 C2: a click lands in the RENDERED text, where the fence's indent
|
|
5050
|
+
// is already stripped from every line; the edit's text is the body with the
|
|
5051
|
+
// indent still on each line. Add the stripped width of every line up to the
|
|
5052
|
+
// click's line back. Lines that do not pair up (never today) leave the
|
|
5053
|
+
// offset as measured.
|
|
5054
|
+
function codeEditBodyOffset(rendered, bodyLines, off) {
|
|
5055
|
+
const rl = String(rendered).split('\n');
|
|
5056
|
+
if (rl.length !== bodyLines.length) return off;
|
|
5057
|
+
let line = 0;
|
|
5058
|
+
let left = off;
|
|
5059
|
+
while (line < rl.length - 1 && left > rl[line].length) { left -= rl[line].length + 1; line++; }
|
|
5060
|
+
let add = 0;
|
|
5061
|
+
for (let i = 0; i <= line; i++) add += Math.max(0, bodyLines[i].length - rl[i].length);
|
|
5062
|
+
return off + add;
|
|
5063
|
+
}
|
|
5064
|
+
|
|
4762
5065
|
// Opens the edit on `blockEl` with the caret at `opts.offset` (default 0)
|
|
4763
5066
|
// or under the click point `opts.clientX/clientY`. false when the block is
|
|
4764
5067
|
// not a fenced code block (the caller opens the raw box instead); true
|
|
@@ -4802,12 +5105,14 @@
|
|
|
4802
5105
|
const host = blockEl.firstElementChild;
|
|
4803
5106
|
const body = fence.body.join('\n');
|
|
4804
5107
|
let caret = o.clientX !== undefined ? codeCaretFromPoint(host, o.clientX, o.clientY) : null;
|
|
5108
|
+
if (caret !== null) caret = codeEditBodyOffset(host.textContent, fence.body, caret);
|
|
4805
5109
|
if (caret === null) caret = o.offset || 0;
|
|
4806
5110
|
caret = Math.max(0, Math.min(body.length, caret));
|
|
4807
5111
|
const s = {
|
|
4808
5112
|
blockEl, host, fence, lang: fence.info, body, source,
|
|
4809
5113
|
blockId: rec.id, startLine: rec.startLine, endLine: rec.endLine,
|
|
4810
|
-
history:
|
|
5114
|
+
history: carriedCodeEditHistory(o, body, caret) ||
|
|
5115
|
+
new codeEditLib.TextHistory({ text: body, selStart: caret, selEnd: caret }),
|
|
4811
5116
|
originalHtml: host.innerHTML, shown: null, timer: null, composing: false,
|
|
4812
5117
|
plainOnly: true, inflight: null, listeners: new AbortController(), editor: null,
|
|
4813
5118
|
};
|
|
@@ -4855,6 +5160,18 @@
|
|
|
4855
5160
|
return true;
|
|
4856
5161
|
}
|
|
4857
5162
|
|
|
5163
|
+
// v3.17.2 B4 (v3.17.0 final review M1): the Ctrl+S / language re-entry
|
|
5164
|
+
// hands the closing edit's own history to the one it opens, while the block
|
|
5165
|
+
// holds exactly the text that history ends on. Measured before: type, Ctrl+S,
|
|
5166
|
+
// and Ctrl+Z inside the reopened edit did nothing (a fresh history had
|
|
5167
|
+
// nothing to step back to).
|
|
5168
|
+
function carriedCodeEditHistory(o, body, caret) {
|
|
5169
|
+
const h = o.history;
|
|
5170
|
+
if (!h || o.historyText !== body) return null;
|
|
5171
|
+
h.push({ text: body, selStart: caret, selEnd: caret });
|
|
5172
|
+
return h;
|
|
5173
|
+
}
|
|
5174
|
+
|
|
4858
5175
|
function codeEditSnapshot(s) {
|
|
4859
5176
|
const text = codeEditText(s);
|
|
4860
5177
|
const sel = codeEditSel(s);
|
|
@@ -4960,7 +5277,7 @@
|
|
|
4960
5277
|
// text. RENDER_SUPERSEDED: not success yet, the caller touches no DOM. true:
|
|
4961
5278
|
// rendered.
|
|
4962
5279
|
async function commitFencedRangeAndRender(startLine, endLine, text) {
|
|
4963
|
-
const result = commitRangeEdit({ lines, blocks, stack }, startLine, endLine, text);
|
|
5280
|
+
const result = commitRangeEdit({ lines, blocks, stack }, startLine, endLine, text, { verbatim: true });
|
|
4964
5281
|
if (result.op === null) return null;
|
|
4965
5282
|
const prevLines = lines;
|
|
4966
5283
|
lines = result.lines;
|
|
@@ -5013,6 +5330,17 @@
|
|
|
5013
5330
|
const caret = sel ? sel[0] : codeEditText(s).length;
|
|
5014
5331
|
const startLine = s.startLine;
|
|
5015
5332
|
if (opts.lang !== undefined) s.lang = codeEditLib.setFenceLang(s.lang, opts.lang);
|
|
5333
|
+
const history = s.history;
|
|
5334
|
+
const historyText = codeEditText(s);
|
|
5335
|
+
// v3.17.2 B5 (v3.17.0 final review M2): the block opened again is the one
|
|
5336
|
+
// this commit leaves at `startLine` — its lines read as what was written
|
|
5337
|
+
// (or, unchanged, as what was there). An untouched "/" insert is
|
|
5338
|
+
// discarded on save (R19), so nothing of its own is left to open, and the
|
|
5339
|
+
// block that moved up into its line is someone else's. Measured before:
|
|
5340
|
+
// Ctrl+S on the untouched insert opened the code block below it, and the
|
|
5341
|
+
// next keys went into that block.
|
|
5342
|
+
const discarding = !!(pristineInsert && pristineInsert.blockId === s.blockId) && !s.editor.hasChanges();
|
|
5343
|
+
const reopenAs = [codeEditSnapshot(s).value, s.source];
|
|
5016
5344
|
const hold = { keys: [], clickedAway: false };
|
|
5017
5345
|
codeEditReenterHold = hold;
|
|
5018
5346
|
let ok;
|
|
@@ -5023,9 +5351,11 @@
|
|
|
5023
5351
|
}
|
|
5024
5352
|
const keys = (opts.carry || []).concat(hold.keys);
|
|
5025
5353
|
let target = liveCodeEdit() === s ? s : null;
|
|
5026
|
-
if (!target && ok && !hold.clickedAway && s.lastResult !== RENDER_SUPERSEDED) {
|
|
5354
|
+
if (!target && ok && !hold.clickedAway && s.lastResult !== RENDER_SUPERSEDED && !discarding) {
|
|
5027
5355
|
const el = blockElAtLine(startLine);
|
|
5028
|
-
|
|
5356
|
+
const rec = el ? blockRecOf(el) : null;
|
|
5357
|
+
const reads = rec && rec.endLine >= rec.startLine ? lines.slice(rec.startLine - 1, rec.endLine).join('\n') : null;
|
|
5358
|
+
if (el && reopenAs.indexOf(reads) !== -1 && await editCodeInPlace(el, { offset: caret, keepScroll: true, history, historyText })) target = liveCodeEdit();
|
|
5029
5359
|
}
|
|
5030
5360
|
if (ok && opts.save) saveAfterLastSave();
|
|
5031
5361
|
if (!target || hold.clickedAway) {
|
|
@@ -5378,7 +5708,7 @@
|
|
|
5378
5708
|
// The CODE_LANGS entry id an info string resolves to ('' for none), or null
|
|
5379
5709
|
// for a word the table does not know.
|
|
5380
5710
|
function codeLangIdOf(info) {
|
|
5381
|
-
const word =
|
|
5711
|
+
const word = codeEditLib.langWord(info).toLowerCase(); // v3.17.2 A2: an attribute is no language
|
|
5382
5712
|
if (!word) return '';
|
|
5383
5713
|
const hit = codeEditLib.CODE_LANGS.find((l) => (l.id && l.id === word) || l.aliases.indexOf(word) !== -1);
|
|
5384
5714
|
return hit ? hit.id : null;
|
|
@@ -5845,7 +6175,30 @@
|
|
|
5845
6175
|
// Enter made the item) takes the spacing of the list it joins, which the
|
|
5846
6176
|
// renderer stamped from marked's own looseness as data-list-loose.
|
|
5847
6177
|
function serializeListEls(els, opts) {
|
|
5848
|
-
return listMd.serializeBlocks(els, Object.assign({ blankBefore: looseBlankBefore
|
|
6178
|
+
return listMd.serializeBlocks(els, Object.assign({ blankBefore: looseBlankBefore,
|
|
6179
|
+
sourceMarker: sourceMarkerOf }, opts || {}));
|
|
6180
|
+
}
|
|
6181
|
+
// v3.17.3 A9 (R5): the bullet character / ordinal delimiter this block's
|
|
6182
|
+
// own marker has in the file ('-', '*', '+', '.', ')'), or null for a block
|
|
6183
|
+
// with no record (an item just made). list-md.js asks it for every item it
|
|
6184
|
+
// re-serialises — a carried one answers from its carried line — and writes
|
|
6185
|
+
// each run in the style most of its items have, so an edit to `* b` no
|
|
6186
|
+
// longer rewrites the list as `- `. A zero-line block (the outer item of a
|
|
6187
|
+
// same-line nest, `- * b`) has its marker on the line of the item that owns
|
|
6188
|
+
// it, at its own depth's place in that line's marker chain.
|
|
6189
|
+
function sourceMarkerOf(el) {
|
|
6190
|
+
const rec = blockRecOf(el);
|
|
6191
|
+
if (!rec) return null;
|
|
6192
|
+
const chain = sourceMarkerChain(lines[rec.startLine - 1]);
|
|
6193
|
+
if (!chain.length) return null;
|
|
6194
|
+
let at = chain.length - 1;
|
|
6195
|
+
if (rec.endLine < rec.startLine) {
|
|
6196
|
+
const owner = blocks.find((b) => b.startLine === rec.startLine && b.endLine >= b.startLine);
|
|
6197
|
+
if (!owner) return null;
|
|
6198
|
+
at = chain.length - 1 - (owner.indent - rec.indent);
|
|
6199
|
+
if (at < 0) return null;
|
|
6200
|
+
}
|
|
6201
|
+
return chain[at].bullet || chain[at].delim || null;
|
|
5849
6202
|
}
|
|
5850
6203
|
function looseBlankBefore(el, prevEl) {
|
|
5851
6204
|
if (!prevEl) return false;
|
|
@@ -8618,8 +8971,13 @@
|
|
|
8618
8971
|
const original = currentBurst && currentBurst.blockEl === blockEl ? currentBurst.original : null;
|
|
8619
8972
|
if (currentBurst && currentBurst.blockEl === blockEl) endBurstWithoutResolve();
|
|
8620
8973
|
// R58 (`joinSide`): the blank lines to the list being joined go too.
|
|
8621
|
-
|
|
8622
|
-
|
|
8974
|
+
// v3.17.3 A6: no lines at all is a removal (the dismissed 圖片 picker),
|
|
8975
|
+
// the paragraph and one blank line with it, like removeEmptiedParagraph().
|
|
8976
|
+
const range = newLines.length === 0 ? [block.startLine, block.endLine]
|
|
8977
|
+
: growOverBlanks(lines, block.startLine, block.endLine, joinSide || null, blocks);
|
|
8978
|
+
const result = newLines.length === 0
|
|
8979
|
+
? commitRangeRemoval({ lines, blocks, stack }, range[0], range[1])
|
|
8980
|
+
: commitRangeEdit({ lines, blocks, stack }, range[0], range[1], newLines.join('\n'));
|
|
8623
8981
|
if (result.op === null) return null;
|
|
8624
8982
|
const prevLines = lines;
|
|
8625
8983
|
lines = result.lines;
|
|
@@ -8671,6 +9029,17 @@
|
|
|
8671
9029
|
});
|
|
8672
9030
|
const startLine = await replaceParagraphWithLines(live, liveSurface, newLines);
|
|
8673
9031
|
if (startLine !== null) focusBlockAtLine(startLine, true);
|
|
9032
|
+
}, {
|
|
9033
|
+
// v3.17.3 A6 (R8): a dismissed dialog consumes the "/圖片" text, by the
|
|
9034
|
+
// commit the picked file would have made, minus the image. A paragraph
|
|
9035
|
+
// that was edited meanwhile, or that cannot be found again, is left alone.
|
|
9036
|
+
onCancel: async () => {
|
|
9037
|
+
const live = refindBlock(blockEl, identity, selfSession);
|
|
9038
|
+
const liveSurface = live ? blockContentEl(live) : null;
|
|
9039
|
+
if (!liveSurface || slashSurfaceText(liveSurface) !== typed) return;
|
|
9040
|
+
const startLine = await replaceParagraphWithLines(live, liveSurface, []);
|
|
9041
|
+
if (startLine !== null) focusBlockAtLine(startLine, true);
|
|
9042
|
+
},
|
|
8674
9043
|
});
|
|
8675
9044
|
}
|
|
8676
9045
|
|
|
@@ -8789,8 +9158,13 @@
|
|
|
8789
9158
|
}
|
|
8790
9159
|
const join = isListKind(match.kind) ? listJoinOf(blockEl, match.kind) : null;
|
|
8791
9160
|
const lines2 = join ? listJoinLines(join, match.kind, rest === '' ? [] : rest.split('\n')) : converted;
|
|
9161
|
+
// v3.17.3 N4 (R41): an ordered item joining the ordered list below
|
|
9162
|
+
// (same delimiter) renumbers it in op 2 — renumberedRunFrom(), A7's.
|
|
9163
|
+
const belowJoin = !join ? null : join.side === 'below' ? join : join.side === 'both' ? join.below : null;
|
|
9164
|
+
const tail = match.kind === 'ol' && belowJoin && belowJoin.delim === join.delim
|
|
9165
|
+
? renumberedRunFrom(belowJoin.run, join.ordinal + 1) : null;
|
|
8792
9166
|
await commitShortcutAt(blockEl, surface, isT, match, literal.split('\n'), lines2, markerLen,
|
|
8793
|
-
join ? join.side : null);
|
|
9167
|
+
join ? join.side : null, tail);
|
|
8794
9168
|
} finally {
|
|
8795
9169
|
if (rawBoxKind) shortcutRawBoxFlight = false;
|
|
8796
9170
|
endCommitFlight();
|
|
@@ -8830,6 +9204,20 @@
|
|
|
8830
9204
|
const all = allBlockEls().filter((el) => el === blockEl || !el.hasAttribute('data-ed-transient'));
|
|
8831
9205
|
const i = all.indexOf(blockEl);
|
|
8832
9206
|
if (i < 0) return null;
|
|
9207
|
+
// Final review I1 (R54): the lines this item is written over (a real
|
|
9208
|
+
// block's own) or after (a transient's: right after its anchor, or before
|
|
9209
|
+
// the first block, as commitInsertionAt() writes it). A neighbour list with
|
|
9210
|
+
// a line that is not blank in between (a link reference definition, which
|
|
9211
|
+
// no block owns) is a separate list in the file: joining it would write
|
|
9212
|
+
// over that line or past it, so that side does not join.
|
|
9213
|
+
const ownRec = blockRecOf(blockEl);
|
|
9214
|
+
const anchorRec = ownRec ? null : blockRecOf(transientAnchorEl(blockEl));
|
|
9215
|
+
const ownBottom = ownRec ? ownRec.endLine : anchorRec ? anchorRec.endLine : (blocks.length ? blocks[0].startLine - 1 : 0);
|
|
9216
|
+
const ownTop = ownRec ? ownRec.startLine : ownBottom + 1;
|
|
9217
|
+
const gapIsBlank = (from, to) => {
|
|
9218
|
+
for (let n = from; n <= to; n++) if (String(lines[n - 1] === undefined ? '' : lines[n - 1]).trim() !== '') return false;
|
|
9219
|
+
return true;
|
|
9220
|
+
};
|
|
8833
9221
|
const sideOf = (el, above) => {
|
|
8834
9222
|
if (!el || el.getAttribute('data-block-type') !== 'li' || el.getAttribute('data-block-id') === null) return null;
|
|
8835
9223
|
if (!above && (Number(el.getAttribute('data-indent')) || 0) !== 0) return null;
|
|
@@ -8844,11 +9232,12 @@
|
|
|
8844
9232
|
const refRec = blockRecOf(ref);
|
|
8845
9233
|
const edgeRec = blockRecOf(el);
|
|
8846
9234
|
if (!refRec || !edgeRec || refRec.endLine < refRec.startLine || edgeRec.endLine < edgeRec.startLine) return null;
|
|
9235
|
+
if (above ? !gapIsBlank(edgeRec.endLine + 1, ownTop - 1) : !gapIsBlank(ownBottom + 1, edgeRec.startLine - 1)) return null;
|
|
8847
9236
|
const m = /^(?:([-*+])|(\d{1,9})([.)]))(?:[ \t]|$)/.exec(lines[refRec.startLine - 1] || '');
|
|
8848
9237
|
if (!m) return null;
|
|
8849
9238
|
return { side: above ? 'above' : 'below', bullet: m[1] || '-',
|
|
8850
9239
|
ordinal: m[2] ? Number(m[2]) + (above ? 1 : 0) : 1, delim: m[3] || '.',
|
|
8851
|
-
at: above ? edgeRec.endLine : edgeRec.startLine };
|
|
9240
|
+
at: above ? edgeRec.endLine : edgeRec.startLine, run: run };
|
|
8852
9241
|
};
|
|
8853
9242
|
const above = sideOf(all[i - 1], true);
|
|
8854
9243
|
const below = sideOf(all[i + 1], false);
|
|
@@ -8868,11 +9257,12 @@
|
|
|
8868
9257
|
// writes it at the top level, and in an ordered list the empty path
|
|
8869
9258
|
// renumbers while this one keeps the number typed.
|
|
8870
9259
|
const oneList = isTransient(blockEl) && all[i + 1].getAttribute('data-list-start') !== '1';
|
|
8871
|
-
|
|
9260
|
+
// v3.17.3 N4: `below` names the list below for the shortcut's renumbering.
|
|
9261
|
+
if (!oneList) return Object.assign(above, { side: 'both', below: below });
|
|
8872
9262
|
const aRec = blockRecOf(all[i - 1]);
|
|
8873
9263
|
const bRec = blockRecOf(all[i + 1]);
|
|
8874
9264
|
const tight = !!(aRec && bRec && bRec.startLine === aRec.endLine + 1);
|
|
8875
|
-
return tight ? Object.assign(above, { side: 'both' }) : null;
|
|
9265
|
+
return tight ? Object.assign(above, { side: 'both', below: below }) : null;
|
|
8876
9266
|
}
|
|
8877
9267
|
return above || below;
|
|
8878
9268
|
}
|
|
@@ -8921,7 +9311,7 @@
|
|
|
8921
9311
|
await landSlashItem(result.newStartLine, item, true);
|
|
8922
9312
|
}
|
|
8923
9313
|
|
|
8924
|
-
async function commitShortcutAt(blockEl, surface, isT, match, literalLines, converted, markerLen, join) {
|
|
9314
|
+
async function commitShortcutAt(blockEl, surface, isT, match, literalLines, converted, markerLen, join, tail) {
|
|
8925
9315
|
let at;
|
|
8926
9316
|
if (isT) {
|
|
8927
9317
|
const anchorEl = transientAnchorEl(blockEl);
|
|
@@ -8935,7 +9325,7 @@
|
|
|
8935
9325
|
}
|
|
8936
9326
|
const rec = isT ? null : blocks.find((b) => b.id === at.blockId);
|
|
8937
9327
|
if (!isT && !rec) return;
|
|
8938
|
-
const r = commitShortcut({ lines, blocks, stack }, at, literalLines, converted, join);
|
|
9328
|
+
const r = commitShortcut({ lines, blocks, stack }, at, literalLines, converted, join, tail || null);
|
|
8939
9329
|
const op1 = r.ops[0];
|
|
8940
9330
|
const op2 = r.ops[1];
|
|
8941
9331
|
if (!op2) return;
|
|
@@ -8946,13 +9336,13 @@
|
|
|
8946
9336
|
if (isT) {
|
|
8947
9337
|
const first = op1 || op2;
|
|
8948
9338
|
editRange = { startLine: Math.min(first.startLine, first.endLine + 1),
|
|
8949
|
-
endLine: Math.max(first.startLine - 1, first.endLine), delta };
|
|
9339
|
+
endLine: Math.max(first.startLine - 1, first.endLine, r.tailEnd || 0), delta };
|
|
8950
9340
|
const shifted = {};
|
|
8951
9341
|
blocks.forEach((b) => { if (b.startLine >= first.startLine) shifted[b.id] = b.startLine + delta; });
|
|
8952
9342
|
pendingFlightLineMap = { ids: shifted, provisional: r.newStartLine };
|
|
8953
9343
|
} else {
|
|
8954
|
-
const grown = growOverBlanks(prevLines, rec.startLine, rec.endLine, join || null);
|
|
8955
|
-
editRange = { startLine: grown[0], endLine: grown[1], delta };
|
|
9344
|
+
const grown = growOverBlanks(prevLines, rec.startLine, rec.endLine, join || null, blocks);
|
|
9345
|
+
editRange = { startLine: grown[0], endLine: Math.max(grown[1], r.tailEnd || 0), delta };
|
|
8956
9346
|
pendingFlightLineMap = { ids: { [rec.id]: r.newStartLine }, provisional: r.newStartLine };
|
|
8957
9347
|
}
|
|
8958
9348
|
const original = !isT && currentBurst && currentBurst.blockEl === blockEl ? currentBurst.original : null;
|
|
@@ -9222,8 +9612,10 @@
|
|
|
9222
9612
|
// R4: the text a paragraph / heading surface commits as — the heading's
|
|
9223
9613
|
// own `#` run in front, a paragraph's line-start markers escaped (F-1).
|
|
9224
9614
|
// resolveBurst() uses the same rule; split and merge serialize through it.
|
|
9615
|
+
// The closing-sequence escape lives in convert-md.js (the block menu uses it too).
|
|
9616
|
+
function escapeClosingHashes(md) { return convertMd.escapeClosingHashes(md); }
|
|
9225
9617
|
function commitTextOf(md, blockType, depth) {
|
|
9226
|
-
if (blockType === 'heading') return md === '' ? '#'.repeat(depth) : '#'.repeat(depth) + ' ' + md;
|
|
9618
|
+
if (blockType === 'heading') return md === '' ? '#'.repeat(depth) : '#'.repeat(depth) + ' ' + escapeClosingHashes(md);
|
|
9227
9619
|
return blockType === 'paragraph' ? inlineMd.escapeBlockStarts(md) : md;
|
|
9228
9620
|
}
|
|
9229
9621
|
function surfaceCommitText(surface, blockType, depth) {
|
|
@@ -9372,15 +9764,23 @@
|
|
|
9372
9764
|
}
|
|
9373
9765
|
if (place === 'middle') { await splitBlockAtCaret(burst, editEl, blockEl); return; }
|
|
9374
9766
|
const live = await settleEnterSource(burst, blockEl);
|
|
9375
|
-
if (!live) return;
|
|
9767
|
+
if (!live) { keepFailedEdgeEnter(burst, editEl, place); return; }
|
|
9768
|
+
openEdgeParagraph(live, place, true);
|
|
9769
|
+
}
|
|
9770
|
+
|
|
9771
|
+
// The paragraph an Enter at `live`'s edge opens: above it for 'start' (the
|
|
9772
|
+
// caret stays in `live`, at its start when `placeCaret`), below its subtree
|
|
9773
|
+
// for 'end' (focused).
|
|
9774
|
+
function openEdgeParagraph(live, place, placeCaret) {
|
|
9376
9775
|
const surface = blockContentEl(live);
|
|
9377
9776
|
if (place === 'start') {
|
|
9378
9777
|
createTransient(previousBlockEl(live), { focus: false, byEnter: true, stayEl: live });
|
|
9379
|
-
if (!surface || !surface.classList.contains('ed-wys-armed')) return;
|
|
9778
|
+
if (!placeCaret || !surface || !surface.classList.contains('ed-wys-armed')) return;
|
|
9380
9779
|
if (document.activeElement !== surface) surface.focus();
|
|
9381
9780
|
const r = document.createRange();
|
|
9382
9781
|
r.selectNodeContents(surface);
|
|
9383
9782
|
r.collapse(true);
|
|
9783
|
+
const sel = window.getSelection();
|
|
9384
9784
|
sel.removeAllRanges();
|
|
9385
9785
|
sel.addRange(r);
|
|
9386
9786
|
// R3: a surface that kept focus through the commit gets no focusin, and
|
|
@@ -9392,6 +9792,36 @@
|
|
|
9392
9792
|
anchorCaret: surface ? { surface, offset: surface.textContent.length } : null });
|
|
9393
9793
|
}
|
|
9394
9794
|
|
|
9795
|
+
// v3.17.3 B3: the edge Enter commits the typing before it first
|
|
9796
|
+
// (settleEnterSource()). When that commit's render failed, the Enter was
|
|
9797
|
+
// dropped: the card came up, no paragraph opened, and only a second Enter
|
|
9798
|
+
// opened one. Now the typing is kept as the pending structure (D7) — so the
|
|
9799
|
+
// one retry every other failed structure gets (a resolution such as Ctrl+S,
|
|
9800
|
+
// a focus into a surface, a structural key, runToolbarAction()) writes it —
|
|
9801
|
+
// and the Enter replays once that retry has landed (`after`), if the caret
|
|
9802
|
+
// is still in this block then. A caret that went elsewhere drops it: an
|
|
9803
|
+
// empty paragraph nobody is in would be removed again at once (D3). A
|
|
9804
|
+
// second Enter pressed meanwhile is held behind the retry and replays into
|
|
9805
|
+
// the paragraph this one opened, so it opens no second one.
|
|
9806
|
+
function keepFailedEdgeEnter(burst, editEl, place) {
|
|
9807
|
+
if (burst.transient || currentBurst !== burst || pendingStructure || !editEl.isConnected) return;
|
|
9808
|
+
if (!burstHasUncommittedEdit() || reconcileBlocksCommits()) return;
|
|
9809
|
+
const rec = blocks.find((b) => b.id === burst.blockId);
|
|
9810
|
+
if (!rec) return;
|
|
9811
|
+
const blockId = burst.blockId;
|
|
9812
|
+
const startLine = rec.startLine;
|
|
9813
|
+
setPendingStructure(rec.startLine, rec.endLine, [editEl], () => ({
|
|
9814
|
+
text: surfaceCommitText(editEl, burst.blockType, burst.depth),
|
|
9815
|
+
lineMap: { ids: { [blockId]: startLine }, provisional: null },
|
|
9816
|
+
}), null, null);
|
|
9817
|
+
pendingStructure.after = () => {
|
|
9818
|
+
const live = blockElAtLine(startLine);
|
|
9819
|
+
const surface = live ? blockContentEl(live) : null;
|
|
9820
|
+
if (!surface || !surface.contains(document.activeElement)) return;
|
|
9821
|
+
openEdgeParagraph(live, place, false);
|
|
9822
|
+
};
|
|
9823
|
+
}
|
|
9824
|
+
|
|
9395
9825
|
// The edge cases' first step: commit what is pending in the block Enter was
|
|
9396
9826
|
// pressed in (R7) and hand back the live element it is now — re-resolved by
|
|
9397
9827
|
// its start line when the commit re-rendered it, or the paragraph a
|
|
@@ -9493,10 +9923,13 @@
|
|
|
9493
9923
|
// wrote as its own op before the structure ({ blockId, text }, or null).
|
|
9494
9924
|
// The retry writes it as its own op again, so one Ctrl+Z still takes the
|
|
9495
9925
|
// structure back alone.
|
|
9926
|
+
// `after` (v3.17.3 B3, optional, set by the caller): run once a retry has
|
|
9927
|
+
// landed and put the caret back — the gesture that was waiting on the
|
|
9928
|
+
// structure (keepFailedEdgeEnter()).
|
|
9496
9929
|
function setPendingStructure(startLine, endLine, surfaces, serialize, covers, typed) {
|
|
9497
9930
|
pendingStructure = { range: { startLine, endLine },
|
|
9498
9931
|
before: lines.slice(startLine - 1, endLine).join('\n'), surfaces, serialize, covers: covers || null,
|
|
9499
|
-
typedIds: new Set(), typed: typed || null };
|
|
9932
|
+
typedIds: new Set(), typed: typed || null, after: null };
|
|
9500
9933
|
setDirty(); // R34: it is unsaved work (documentIsDirty())
|
|
9501
9934
|
}
|
|
9502
9935
|
|
|
@@ -9556,7 +9989,7 @@
|
|
|
9556
9989
|
// `text: null` is a removal (an emptied block whose removal did not land).
|
|
9557
9990
|
const result = s.text === null
|
|
9558
9991
|
? commitRangeRemoval(state, ps.range.startLine, endLine)
|
|
9559
|
-
: commitRangeEdit(state, ps.range.startLine, endLine, s.text);
|
|
9992
|
+
: commitRangeEdit(state, ps.range.startLine, endLine, s.text, { carriesGap: s.carriesGap === true });
|
|
9560
9993
|
if (result.op !== null) lines = result.lines;
|
|
9561
9994
|
else if (typedOp) lines = typedOp.lines;
|
|
9562
9995
|
const editRange = { startLine: ps.range.startLine, endLine: ps.range.endLine,
|
|
@@ -9608,6 +10041,14 @@
|
|
|
9608
10041
|
restoreSelection(surface, caretTag.anchor, caretTag.focus);
|
|
9609
10042
|
}
|
|
9610
10043
|
}
|
|
10044
|
+
// v3.17.3 B3: only when the caret was in the structure when the retry
|
|
10045
|
+
// began (a press elsewhere has moved on from the waiting gesture).
|
|
10046
|
+
// R30 (fix round 1): that includes a retry started by a toolbar action
|
|
10047
|
+
// or an undo (both resolve through resolveOpenSession() with the caret
|
|
10048
|
+
// still here). The failed Enter replays first and the action then
|
|
10049
|
+
// applies to the paragraph it opened — the order the user pressed them
|
|
10050
|
+
// in. Intended.
|
|
10051
|
+
if (ps.after && caretTag) ps.after();
|
|
9611
10052
|
return true;
|
|
9612
10053
|
}
|
|
9613
10054
|
|
|
@@ -9673,8 +10114,10 @@
|
|
|
9673
10114
|
// Spec §3.2. Backspace with the caret at the start of a paragraph joins it
|
|
9674
10115
|
// to the block above (a paragraph, a heading, or a list item's text); at
|
|
9675
10116
|
// the start of a heading it makes the heading a paragraph. Delete at the
|
|
9676
|
-
// end of a paragraph or heading pulls the next one up.
|
|
9677
|
-
// no
|
|
10117
|
+
// end of a paragraph or heading pulls the next one up (v3.17.3 B9: a list
|
|
10118
|
+
// item with no children too, see pullListItemUp(); Delete at a list item's
|
|
10119
|
+
// end is liDeleteAtEnd()). A neighbour that has no text to join (a table, a
|
|
10120
|
+
// code block, a list item that owns children, for Delete) is
|
|
9678
10121
|
// selected instead, and an EMPTY block below such a neighbour goes away
|
|
9679
10122
|
// with the caret landing at the neighbour's end (R12). Every case is one
|
|
9680
10123
|
// undo step; typing the key commits first is its own earlier step (D4).
|
|
@@ -9839,7 +10282,7 @@
|
|
|
9839
10282
|
const range = o.range(state.blocks);
|
|
9840
10283
|
second = o.text === null
|
|
9841
10284
|
? commitRangeRemoval(state, range[0], range[1])
|
|
9842
|
-
: commitRangeEdit(state, range[0], range[1], o.text);
|
|
10285
|
+
: commitRangeEdit(state, range[0], range[1], o.text, { carriesGap: o.carriesGap === true });
|
|
9843
10286
|
}
|
|
9844
10287
|
lines = second.lines;
|
|
9845
10288
|
const delta = (typedOp ? typedOp.op.after.length - typedOp.op.before.length : 0) +
|
|
@@ -9857,7 +10300,7 @@
|
|
|
9857
10300
|
}
|
|
9858
10301
|
|
|
9859
10302
|
function typedInBurst(burst) {
|
|
9860
|
-
if (burst.transient || burstBaselineHtml(burst.editEl)
|
|
10303
|
+
if (burst.transient || sameBurstHtml(burstBaselineHtml(burst.editEl), burst.original)) return null;
|
|
9861
10304
|
burst.history.flushTyping();
|
|
9862
10305
|
return { blockId: burst.blockId, text: burstCommitText(burst) };
|
|
9863
10306
|
}
|
|
@@ -9930,7 +10373,10 @@
|
|
|
9930
10373
|
const typed = empty ? null : typedInBurst(burst);
|
|
9931
10374
|
let seam = 0;
|
|
9932
10375
|
withFocusoutSuppressed(() => {
|
|
9933
|
-
|
|
10376
|
+
// v3.17.3 N1 (R33): into a loose item's <p>, not beside it (joinHostOf()).
|
|
10377
|
+
// Measured before: `- a`, blank, `- b`, blank, `P.`, Backspace at the
|
|
10378
|
+
// start of `P.` wrote `- a\n\n- P.\n` (`b` lost).
|
|
10379
|
+
seam = joinSeam(joinHostOf(prevSurface), Array.prototype.slice.call(editEl.childNodes), prevType === 'heading');
|
|
9934
10380
|
structureMutated = true;
|
|
9935
10381
|
prevSurface.focus();
|
|
9936
10382
|
restoreSelection(prevSurface, seam, seam);
|
|
@@ -10070,7 +10516,19 @@
|
|
|
10070
10516
|
else selectBlock(next);
|
|
10071
10517
|
return;
|
|
10072
10518
|
}
|
|
10073
|
-
//
|
|
10519
|
+
// v3.17.3 B9 (R11, supersedes D14's select for this case): a list item
|
|
10520
|
+
// with no children of its own is pulled up into a paragraph or heading,
|
|
10521
|
+
// as in Notion. One that owns children is still selected (pulling it up
|
|
10522
|
+
// would leave them with no parent), and so is one below a transient.
|
|
10523
|
+
// R34: an item over several source lines (a hard wrap, a continuation
|
|
10524
|
+
// paragraph, a fenced child) counts as one with children, and so does
|
|
10525
|
+
// one whose list the structural serializer refuses: both keep the
|
|
10526
|
+
// select, never a refusal notice.
|
|
10527
|
+
if (nextSurface && nextType === 'li' && !curIsTransient && liJoinable(next) &&
|
|
10528
|
+
listRunSupportsStructuralEdit(listRunOf(next), next)) {
|
|
10529
|
+
await pullListItemUp(burst, editEl, next, nextSurface, empty);
|
|
10530
|
+
return;
|
|
10531
|
+
}
|
|
10074
10532
|
if (!nextSurface || nextType === 'li') { selectBlock(next); return; }
|
|
10075
10533
|
const nextRec = blockRecOf(next);
|
|
10076
10534
|
const curRec = curIsTransient ? null : blocks.find((b) => b.id === burst.blockId);
|
|
@@ -10105,6 +10563,54 @@
|
|
|
10105
10563
|
if (ok) focusBlockAtLineOffset(headLine, seam);
|
|
10106
10564
|
}
|
|
10107
10565
|
|
|
10566
|
+
// v3.17.3 B9 (R11): Delete at the end of a paragraph or heading whose next
|
|
10567
|
+
// block is a list item with no children. The item's text joins the block
|
|
10568
|
+
// and the item leaves its list; the commit is ONE op over the block's
|
|
10569
|
+
// first line through the end of the list, written as the block, the lines
|
|
10570
|
+
// that separated it from the list (kept byte for byte), then the rest of
|
|
10571
|
+
// the list (its other items replayed from the file). An emptied list is
|
|
10572
|
+
// written as nothing. The block's own typing is its own earlier op (D4).
|
|
10573
|
+
async function pullListItemUp(burst, editEl, li, liSurface, empty) {
|
|
10574
|
+
const curRec = blocks.find((b) => b.id === burst.blockId);
|
|
10575
|
+
const run = listRunOf(li);
|
|
10576
|
+
const runRange = run.length ? runRangeOfBlocks({ lines, blocks, stack }, run) : null;
|
|
10577
|
+
// deleteAtEnd() checked the run and the item (R34) synchronously first.
|
|
10578
|
+
if (!curRec || !runRange || runRange.startLine <= curRec.endLine) return;
|
|
10579
|
+
const typed = empty ? null : typedInBurst(burst);
|
|
10580
|
+
const gap = lines.slice(curRec.endLine, runRange.startLine - 1);
|
|
10581
|
+
const survivors = run.filter((el) => el !== li);
|
|
10582
|
+
let seam = 0;
|
|
10583
|
+
withFocusoutSuppressed(() => {
|
|
10584
|
+
seam = joinSeam(editEl, Array.prototype.slice.call(joinHostOf(liSurface).childNodes), burst.blockType === 'heading');
|
|
10585
|
+
structureMutated = true;
|
|
10586
|
+
restoreSelection(editEl, seam, seam);
|
|
10587
|
+
mutateListRun(() => removeListItem(li));
|
|
10588
|
+
});
|
|
10589
|
+
endBurstWithoutResolve();
|
|
10590
|
+
const serialize = () => {
|
|
10591
|
+
const head = surfaceCommitText(editEl, burst.blockType, burst.depth);
|
|
10592
|
+
const ids = { [curRec.id]: curRec.startLine };
|
|
10593
|
+
if (!survivors.length) return { text: head, lineMap: { ids, provisional: null } };
|
|
10594
|
+
const out = serializeListEls(survivors, { carryOver: bystanderCarryOver(survivors) });
|
|
10595
|
+
const listStart = curRec.startLine + head.split('\n').length + gap.length;
|
|
10596
|
+
Object.assign(ids, flightLineMapOf(out.lineMeta, listStart).ids);
|
|
10597
|
+
// R56: `gap` is the lines between the block and the list, sliced from
|
|
10598
|
+
// `lines` verbatim, and a list run never spans a definition (every list
|
|
10599
|
+
// token opens a new run), so the text holds every definition line of
|
|
10600
|
+
// the range.
|
|
10601
|
+
return { text: [head].concat(gap, [out.md]).join('\n'), lineMap: { ids, provisional: null }, carriesGap: true };
|
|
10602
|
+
};
|
|
10603
|
+
const s = serialize();
|
|
10604
|
+
const ok = await commitTypingThenStructure({ typed, text: s.text, carriesGap: s.carriesGap === true,
|
|
10605
|
+
range: (bs) => {
|
|
10606
|
+
const c = bs.find((b) => b.id === curRec.id);
|
|
10607
|
+
return [c.startLine, runRangeOfBlocks({ blocks: bs }, run).endLine];
|
|
10608
|
+
},
|
|
10609
|
+
oldRange: [curRec.startLine, runRange.endLine], lineMap: s.lineMap,
|
|
10610
|
+
pending: { surfaces: [editEl], serialize } });
|
|
10611
|
+
if (ok) focusBlockAtLineOffset(curRec.startLine, seam);
|
|
10612
|
+
}
|
|
10613
|
+
|
|
10108
10614
|
// S2 Task 7 — the li half of +. The new item is spliced into the run's own
|
|
10109
10615
|
// span and the WHOLE span is re-serialized over the run's line range: one
|
|
10110
10616
|
// commitRangeEdit, therefore one undo op, no leading blank line (so the run
|
|
@@ -10825,6 +11331,9 @@
|
|
|
10825
11331
|
// block from the one that moved up into its place. An identity taken before
|
|
10826
11332
|
// `tail` existed (none today) never reads as removed.
|
|
10827
11333
|
function selfCommitRemovedBlock(identity) {
|
|
11334
|
+
// v3.17.3 C4: the same epoch compare as reresolveBlockElAfterSelfCommit() below; a
|
|
11335
|
+
// reconcile that landed meanwhile changed `lines` for another reason.
|
|
11336
|
+
if (identity && identity.reconcileEpoch !== reconcileEpoch) return false;
|
|
10828
11337
|
return !!identity && identity.tail !== undefined &&
|
|
10829
11338
|
lines.length - identity.tail < identityStartLineNow(identity);
|
|
10830
11339
|
}
|
|
@@ -11563,6 +12072,10 @@
|
|
|
11563
12072
|
{ el: i > 0 ? all[i - 1] : null, attrs: headAttrs, back: true },
|
|
11564
12073
|
{ el: (j >= 0 && j + 1 < all.length) ? all[j + 1] : null, attrs: tailAttrs, back: false },
|
|
11565
12074
|
];
|
|
12075
|
+
// v3.17.3 A7: the neighbouring run each side compared equal to — the list
|
|
12076
|
+
// the span joins there, blank line or not — for a caller that has to
|
|
12077
|
+
// number into it (convertBlocksIntoList()).
|
|
12078
|
+
const joined = { above: null, below: null };
|
|
11566
12079
|
for (let k = 0; k < sides.length; k++) {
|
|
11567
12080
|
const side = sides[k];
|
|
11568
12081
|
if (!side.el || !side.attrs) continue;
|
|
@@ -11574,21 +12087,27 @@
|
|
|
11574
12087
|
if ((Number(head.getAttribute('data-indent')) || 0) !== side.attrs.indent) continue;
|
|
11575
12088
|
const headType = head.getAttribute('data-list-type') === 'ol' ? 'ol' : 'ul';
|
|
11576
12089
|
if (headType !== side.attrs.listType) continue;
|
|
11577
|
-
//
|
|
11578
|
-
//
|
|
11579
|
-
//
|
|
11580
|
-
//
|
|
11581
|
-
// one
|
|
11582
|
-
|
|
12090
|
+
// No block owns a separator (buildBlockMap() strips a token's trailing
|
|
12091
|
+
// newlines), but the lines between two blocks are not all blank: a link
|
|
12092
|
+
// reference definition is on lines no block owns either (final review
|
|
12093
|
+
// I1, R54). So the blank run is eaten only when it reaches the
|
|
12094
|
+
// neighbour's run exactly; one that stops at another line (a definition)
|
|
12095
|
+
// means the two are separate lists in the file, and nothing joins —
|
|
12096
|
+
// eating the run made the definition the converted item's text. Eating
|
|
12097
|
+
// ALL of a run that reaches is also 「正規化連續空行」: measured,
|
|
12098
|
+
// '- a\n\n\n- b\n' is still ONE loose list, so stopping after one blank
|
|
12099
|
+
// would leave the degrade in place.
|
|
12100
|
+
let edge = side.back ? startLine : endLine;
|
|
11583
12101
|
if (side.back) {
|
|
11584
|
-
while (
|
|
11585
|
-
startLine -= 1; moved = true;
|
|
11586
|
-
}
|
|
12102
|
+
while (edge >= 2 && String(lines[edge - 2]).trim() === '') edge -= 1;
|
|
11587
12103
|
} else {
|
|
11588
|
-
while (
|
|
11589
|
-
endLine += 1; moved = true;
|
|
11590
|
-
}
|
|
12104
|
+
while (edge < lines.length && String(lines[edge]).trim() === '') edge += 1;
|
|
11591
12105
|
}
|
|
12106
|
+
const reach = side.back ? edge - 1 : edge + 1;
|
|
12107
|
+
if (!blocks.some((b) => (side.back ? b.endLine : b.startLine) === reach)) continue;
|
|
12108
|
+
joined[side.back ? 'above' : 'below'] = nrun;
|
|
12109
|
+
const moved = side.back ? edge !== startLine : edge !== endLine;
|
|
12110
|
+
if (side.back) startLine = edge; else endLine = edge;
|
|
11592
12111
|
// No separator between us and it: they are already two runs that
|
|
11593
12112
|
// markdown keeps apart for a reason this rule does not touch (a
|
|
11594
12113
|
// delimiter change, `- a` / `* b`). Nothing to eat, nothing to gate.
|
|
@@ -11602,7 +12121,23 @@
|
|
|
11602
12121
|
return { startLine: range.startLine, endLine: range.endLine, ok: false };
|
|
11603
12122
|
}
|
|
11604
12123
|
}
|
|
11605
|
-
return { startLine: startLine, endLine: endLine, ok: true };
|
|
12124
|
+
return { startLine: startLine, endLine: endLine, ok: true, joined: joined };
|
|
12125
|
+
}
|
|
12126
|
+
|
|
12127
|
+
// v3.17.3 A7 / N4 (R6, R41): the bytes of the ordered run `run` renumbered
|
|
12128
|
+
// from `firstOrdinal`, for a gesture whose new item(s) join that list from
|
|
12129
|
+
// above — the run's own re-serialization with every item carried (each
|
|
12130
|
+
// item's bytes replayed, only its marker re-stated: the commit a typed
|
|
12131
|
+
// `1. x` gets once x is typed), and its current line range. The caller
|
|
12132
|
+
// writes them in its OWN op, so one undo step takes the join and the new
|
|
12133
|
+
// numbers back together. null when the run cannot be located or the run
|
|
12134
|
+
// gate refuses it (the join then leaves its numbers as they were).
|
|
12135
|
+
function renumberedRunFrom(run, firstOrdinal) {
|
|
12136
|
+
if (!run || !run.length) return null;
|
|
12137
|
+
const range = runRangeOfBlocks({ lines, blocks, stack }, run);
|
|
12138
|
+
if (!range || !listRunSupportsStructuralEdit(run, null, { columnOnly: true })) return null;
|
|
12139
|
+
const out = serializeListEls(run, { carryOver: bystanderCarryOver(run, null), firstOrdinal: firstOrdinal });
|
|
12140
|
+
return out.md === '' ? null : { range: range, md: out.md };
|
|
11606
12141
|
}
|
|
11607
12142
|
|
|
11608
12143
|
// The {listType, indent} a span member will carry once the operand set
|
|
@@ -12022,18 +12557,6 @@
|
|
|
12022
12557
|
async function convertBlocksIntoList(blockEls, recs, kinds, target) {
|
|
12023
12558
|
const attrs = convertMd.listAttrsFor(target);
|
|
12024
12559
|
if (!attrs) return;
|
|
12025
|
-
// One item per member, joined by a bare newline: the members' own
|
|
12026
|
-
// separators are INSIDE the commit range and are replaced, which is what
|
|
12027
|
-
// makes N paragraphs one TIGHT list. A blank between them would make the
|
|
12028
|
-
// list loose and degrade every item read-only (§4.3 rule 2).
|
|
12029
|
-
const pieces = [];
|
|
12030
|
-
for (let i = 0; i < recs.length; i++) {
|
|
12031
|
-
const stripped = convertMd.stripMarker(
|
|
12032
|
-
lines.slice(recs[i].startLine - 1, recs[i].endLine), kinds[i]);
|
|
12033
|
-
if (!stripped.ok) { refuseStructuralListEdit('此區塊的格式無法轉換'); return; }
|
|
12034
|
-
pieces.push(convertMd.emitAs(stripped.content, target, {}).join('\n'));
|
|
12035
|
-
}
|
|
12036
|
-
const md = pieces.join('\n');
|
|
12037
12560
|
const first = recs[0];
|
|
12038
12561
|
const last = recs[recs.length - 1];
|
|
12039
12562
|
|
|
@@ -12046,16 +12569,73 @@
|
|
|
12046
12569
|
{ startLine: first.startLine, endLine: last.endLine }, blockEls, self, self);
|
|
12047
12570
|
if (!merged.ok) { refuseStructuralListEdit(); return; }
|
|
12048
12571
|
|
|
12572
|
+
// v3.17.3 A7 (R6) + A9 (R5): the items JOIN the list on either side
|
|
12573
|
+
// (merged.joined), so they are numbered and markered as its items. They
|
|
12574
|
+
// are written with its bullet character / ordinal delimiter, because with
|
|
12575
|
+
// any other one markdown reads them as a list of their own beside it (why,
|
|
12576
|
+
// not a check — see joinedStyle()); numbered, they continue from the list
|
|
12577
|
+
// above (the number its last top-level item has in the file, as
|
|
12578
|
+
// listJoinOf() reads it) or start at 1; and the ordered list they join
|
|
12579
|
+
// BELOW is renumbered after them — through the run's own re-serialization
|
|
12580
|
+
// with every item carried, the commit a typed `1. x` gets once x is typed
|
|
12581
|
+
// (each item's bytes replayed, only its marker re-stated). Same commit,
|
|
12582
|
+
// so one Ctrl+Z puts the paragraph and the old numbers back. Before this
|
|
12583
|
+
// `Intro` over `1. a\n2. b` wrote `1. Intro\n1. a\n2. b`.
|
|
12584
|
+
// `joined` only ever names a run whose head has the target's list type
|
|
12585
|
+
// (widenRangeForListMerge() sets it after that comparison), so `ref` is a
|
|
12586
|
+
// li of that type; null only when its marker cannot be read off the file.
|
|
12587
|
+
const joinedStyle = (run, which) => {
|
|
12588
|
+
if (!run || !run.length) return null;
|
|
12589
|
+
const tops = run.filter((x) => (Number(x.getAttribute('data-indent')) || 0) === 0);
|
|
12590
|
+
const ref = which === 'above' ? tops[tops.length - 1] : tops[0];
|
|
12591
|
+
const st = ref ? sourceMarkerOf(ref) : null;
|
|
12592
|
+
return st ? { style: st, ref: ref } : null;
|
|
12593
|
+
};
|
|
12594
|
+
const above = joinedStyle(merged.joined && merged.joined.above, 'above');
|
|
12595
|
+
const below = joinedStyle(merged.joined && merged.joined.below, 'below');
|
|
12596
|
+
const style = above ? above.style : below ? below.style : null;
|
|
12597
|
+
let ordinal = 1;
|
|
12598
|
+
if (attrs.listType === 'ol' && above) {
|
|
12599
|
+
const rec = blockRecOf(above.ref);
|
|
12600
|
+
const m = rec ? /^\s*(\d{1,9})[.)]/.exec(lines[rec.startLine - 1] || '') : null;
|
|
12601
|
+
ordinal = m ? Number(m[1]) + 1 : 1;
|
|
12602
|
+
}
|
|
12603
|
+
// One item per member, joined by a bare newline: the members' own
|
|
12604
|
+
// separators are INSIDE the commit range and are replaced, which is what
|
|
12605
|
+
// makes N paragraphs one TIGHT list. A blank between them would make the
|
|
12606
|
+
// list loose and degrade every item read-only (§4.3 rule 2).
|
|
12607
|
+
const pieces = [];
|
|
12608
|
+
for (let i = 0; i < recs.length; i++) {
|
|
12609
|
+
const stripped = convertMd.stripMarker(
|
|
12610
|
+
lines.slice(recs[i].startLine - 1, recs[i].endLine), kinds[i]);
|
|
12611
|
+
if (!stripped.ok) { refuseStructuralListEdit('此區塊的格式無法轉換'); return; }
|
|
12612
|
+
const piece = convertMd.emitAs(stripped.content, target, { ordinal: ordinal + i });
|
|
12613
|
+
// One character either way, so the continuation indent emitAs() put
|
|
12614
|
+
// under the marker still fits.
|
|
12615
|
+
if (style) piece[0] = attrs.listType === 'ol' ? piece[0].replace(/^(\d+)\./, '$1' + style) : style + piece[0].slice(1);
|
|
12616
|
+
pieces.push(piece.join('\n'));
|
|
12617
|
+
}
|
|
12618
|
+
const convertedMd = pieces.join('\n');
|
|
12619
|
+
let md = convertedMd;
|
|
12620
|
+
let endLine = merged.endLine;
|
|
12621
|
+
if (attrs.listType === 'ol' && below && (!above || below.style === above.style)) {
|
|
12622
|
+
const tail = renumberedRunFrom(merged.joined.below, ordinal + recs.length);
|
|
12623
|
+
if (tail && tail.range.startLine === merged.endLine + 1) {
|
|
12624
|
+
md = convertedMd + '\n' + tail.md; endLine = tail.range.endLine;
|
|
12625
|
+
}
|
|
12626
|
+
}
|
|
12627
|
+
|
|
12049
12628
|
const result = commitRangeEdit({ lines, blocks, stack },
|
|
12050
|
-
merged.startLine,
|
|
12629
|
+
merged.startLine, endLine, md);
|
|
12051
12630
|
if (result.op === null) return;
|
|
12052
12631
|
const prevLines = lines;
|
|
12053
12632
|
lines = result.lines;
|
|
12054
|
-
// §3.3's collapse: the
|
|
12055
|
-
// commit range
|
|
12633
|
+
// §3.3's collapse: the converted items' own lines, which start the
|
|
12634
|
+
// (possibly widened) commit range; a list below renumbered in the same
|
|
12635
|
+
// commit is not part of the result.
|
|
12056
12636
|
declareCollapse({
|
|
12057
12637
|
startLine: merged.startLine,
|
|
12058
|
-
endLine: merged.startLine +
|
|
12638
|
+
endLine: merged.startLine + convertedMd.split('\n').length - 1,
|
|
12059
12639
|
});
|
|
12060
12640
|
const editRange = result.op
|
|
12061
12641
|
? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
|
|
@@ -12242,6 +12822,109 @@
|
|
|
12242
12822
|
return clone.innerHTML;
|
|
12243
12823
|
}
|
|
12244
12824
|
|
|
12825
|
+
// v3.17.2 A6: "is this surface edited?" asks this, never `===` on two
|
|
12826
|
+
// burstBaselineHtml() strings. An inline style is markup any page script or
|
|
12827
|
+
// extension may write on an editable element, and only two of its
|
|
12828
|
+
// declarations are the document's: `text-align` (a column's alignment,
|
|
12829
|
+
// renderer.table and setColumnAlign()) and the editor's own `--ed-*`
|
|
12830
|
+
// custom properties. MEASURED before: Playwright's screenshot (caret
|
|
12831
|
+
// 'hide') writes `style="caret-color: transparent !important;"` on every
|
|
12832
|
+
// editable element and leaves `style=""` behind; with the caret in a table
|
|
12833
|
+
// cell the status went dirty and leaving the cell committed the table
|
|
12834
|
+
// through its serializer, rewriting `|a|b|` as `| a | b |`. The baseline
|
|
12835
|
+
// strings themselves are untouched: burst histories restore them as HTML.
|
|
12836
|
+
// An allow-list of the declarations that count, so a style nobody has
|
|
12837
|
+
// thought about reads as not an edit only when it is a style. (Other
|
|
12838
|
+
// foreign attributes stopped counting in v3.17.3, see BURST_KEPT_ATTRS.)
|
|
12839
|
+
const BURST_KEPT_STYLE = /^(?:text-align|--ed-[\w-]+)$/;
|
|
12840
|
+
// v3.17.3 C9 (R15): and no attribute counts unless the editor or the
|
|
12841
|
+
// renderer writes it. A writing extension marks the editable element it
|
|
12842
|
+
// sits in (Grammarly: data-gramm, data-gramm_editor, data-enable-grammarly;
|
|
12843
|
+
// LanguageTool: data-lt-*), and a table burst's editEl is the whole table,
|
|
12844
|
+
// so a mark on one cell read as typing: the status went dirty and Ctrl+S
|
|
12845
|
+
// re-serialized the table (measured: the pipes `|a|b|` came back padded).
|
|
12846
|
+
// The paragraph surface was never affected, its own attributes sit outside
|
|
12847
|
+
// the editEl's innerHTML. An ALLOW-list, so an attribute nobody has thought
|
|
12848
|
+
// about is ignored (the failure that costs nothing) rather than an editor
|
|
12849
|
+
// attribute silently dropped from the list costing a real edit. Enumerated
|
|
12850
|
+
// from `setAttribute(`, `.dataset.` and `.className` writes in this file and
|
|
12851
|
+
// from the attributes the server-side renderer emits into blocks:
|
|
12852
|
+
// markup the editor's own edits change class, href, title, src, alt,
|
|
12853
|
+
// start, type, checked, disabled (task checkbox), id, name, target, rel,
|
|
12854
|
+
// width, height, colspan, rowspan, align, value, lang, dir
|
|
12855
|
+
// renderer / block structure data-block-id, data-block-type,
|
|
12856
|
+
// data-svg-plate, data-hard-break, data-indent, data-list-type,
|
|
12857
|
+
// data-list-start, data-list-loose, data-checked, data-task,
|
|
12858
|
+
// data-run-start, data-placeholder, data-kind, data-level,
|
|
12859
|
+
// data-drawio-pages
|
|
12860
|
+
// editor state on the surface contenteditable, spellcheck,
|
|
12861
|
+
// tabindex, role, aria-label and every aria-*, every data-ed-*
|
|
12862
|
+
// a column's alignment style, filtered to text-align
|
|
12863
|
+
// and --ed-* above (setColumnAlign() writes a style attribute)
|
|
12864
|
+
// 'style' itself is handled by BURST_KEPT_STYLE, not listed here.
|
|
12865
|
+
const BURST_KEPT_ATTRS = new Set(['class', 'href', 'title', 'src', 'alt', 'start', 'type', 'checked', 'disabled',
|
|
12866
|
+
'id', 'name', 'target', 'rel', 'width', 'height', 'colspan', 'rowspan', 'align', 'value', 'lang', 'dir',
|
|
12867
|
+
'data-block-id', 'data-block-type', 'data-svg-plate', 'data-hard-break', 'data-indent', 'data-list-type',
|
|
12868
|
+
'data-list-start', 'data-list-loose', 'data-checked', 'data-task', 'data-run-start', 'data-placeholder',
|
|
12869
|
+
'data-kind', 'data-level', 'data-drawio-pages', 'contenteditable', 'spellcheck', 'tabindex', 'role']);
|
|
12870
|
+
function burstAttrIsKept(name) {
|
|
12871
|
+
const n = name.toLowerCase();
|
|
12872
|
+
return n === 'style' || BURST_KEPT_ATTRS.has(n) || n.indexOf('aria-') === 0 || n.indexOf('data-ed-') === 0;
|
|
12873
|
+
}
|
|
12874
|
+
// Whether `html` carries any attribute outside the list (or a style): the
|
|
12875
|
+
// cheap gate in front of the parse. A hit that is only text that looks like
|
|
12876
|
+
// an attribute costs a parse and changes nothing.
|
|
12877
|
+
function burstHasForeignMarkup(html) {
|
|
12878
|
+
if (html.indexOf('style=') !== -1) return true;
|
|
12879
|
+
const tagRe = /<[a-zA-Z][^>]*>/g;
|
|
12880
|
+
let t;
|
|
12881
|
+
while ((t = tagRe.exec(html))) {
|
|
12882
|
+
const attrRe = /\s([^\s"'<>\/=]+)=/g;
|
|
12883
|
+
let a;
|
|
12884
|
+
while ((a = attrRe.exec(t[0]))) if (!burstAttrIsKept(a[1])) return true;
|
|
12885
|
+
}
|
|
12886
|
+
return false;
|
|
12887
|
+
}
|
|
12888
|
+
const burstFingerprints = new Map();
|
|
12889
|
+
function burstEditFingerprint(html) {
|
|
12890
|
+
const hit = burstFingerprints.get(html);
|
|
12891
|
+
if (hit !== undefined) return hit;
|
|
12892
|
+
const tpl = document.createElement('template');
|
|
12893
|
+
tpl.innerHTML = html;
|
|
12894
|
+
Array.prototype.forEach.call(tpl.content.querySelectorAll('*'), (el) => {
|
|
12895
|
+
Array.prototype.slice.call(el.attributes).forEach((at) => {
|
|
12896
|
+
if (!burstAttrIsKept(at.name)) el.removeAttribute(at.name);
|
|
12897
|
+
});
|
|
12898
|
+
});
|
|
12899
|
+
Array.prototype.forEach.call(tpl.content.querySelectorAll('[style]'), (el) => {
|
|
12900
|
+
const kept = [];
|
|
12901
|
+
String(el.getAttribute('style')).split(';').forEach((decl) => {
|
|
12902
|
+
const at = decl.indexOf(':');
|
|
12903
|
+
if (at === -1) return;
|
|
12904
|
+
const prop = decl.slice(0, at).trim();
|
|
12905
|
+
const lower = prop.toLowerCase();
|
|
12906
|
+
if (BURST_KEPT_STYLE.test(prop.indexOf('--') === 0 ? prop : lower)) {
|
|
12907
|
+
kept.push((prop.indexOf('--') === 0 ? prop : lower) + ':' + decl.slice(at + 1).trim());
|
|
12908
|
+
}
|
|
12909
|
+
});
|
|
12910
|
+
if (kept.length) el.setAttribute('style', kept.join(';'));
|
|
12911
|
+
else el.removeAttribute('style');
|
|
12912
|
+
});
|
|
12913
|
+
const out = tpl.innerHTML;
|
|
12914
|
+
if (burstFingerprints.size >= 4) burstFingerprints.clear();
|
|
12915
|
+
burstFingerprints.set(html, out);
|
|
12916
|
+
return out;
|
|
12917
|
+
}
|
|
12918
|
+
// Both sides go through the same parse and serialize whenever either holds
|
|
12919
|
+
// a style or an attribute outside the list, so a string the page serialized and one re-serialized here are
|
|
12920
|
+
// never compared with each other.
|
|
12921
|
+
function sameBurstHtml(a, b) {
|
|
12922
|
+
if (a === b) return true;
|
|
12923
|
+
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
|
12924
|
+
if (!burstHasForeignMarkup(a) && !burstHasForeignMarkup(b)) return false;
|
|
12925
|
+
return burstEditFingerprint(a) === burstEditFingerprint(b);
|
|
12926
|
+
}
|
|
12927
|
+
|
|
12245
12928
|
function startBurst(editEl) {
|
|
12246
12929
|
// v3.15.0 B5 fix round 4 (R86 (2)): never on a screen that is not `lines`.
|
|
12247
12930
|
if (burstsLocked()) return;
|
|
@@ -12379,11 +13062,12 @@
|
|
|
12379
13062
|
// `commitResult.op === null` no-op path below, without ever reaching
|
|
12380
13063
|
// the serializer (and therefore without ever risking a canonicalizing
|
|
12381
13064
|
// rewrite of untouched content).
|
|
12382
|
-
if (burstBaselineHtml(burst.editEl)
|
|
13065
|
+
if (sameBurstHtml(burstBaselineHtml(burst.editEl), burst.original)) {
|
|
12383
13066
|
endBurstWithoutResolve();
|
|
12384
13067
|
// §10-gap fix (review): untouched AND was pristine — an ordinary
|
|
12385
13068
|
// "insert +, click away without typing" changed-my-mind. Auto-remove
|
|
12386
13069
|
// the block instead of leaving its skeleton on disk.
|
|
13070
|
+
if (wasPristineForThisBlock && pristineItemSurvivesSave(burst)) return true;
|
|
12387
13071
|
if (wasPristineForThisBlock) return await discardPristineInsert();
|
|
12388
13072
|
return true;
|
|
12389
13073
|
}
|
|
@@ -12645,6 +13329,29 @@
|
|
|
12645
13329
|
return true;
|
|
12646
13330
|
}
|
|
12647
13331
|
|
|
13332
|
+
// v3.17.2 A3 (R65, v3.15.0 N5): does the emptied paragraph stand between
|
|
13333
|
+
// two tight lists of the same kind? Then its removal joins them tight, as
|
|
13334
|
+
// Notion does. Measured before: `- a\n\nMid.\n\n- b` with `Mid.` emptied
|
|
13335
|
+
// became `- a\n\n- b`, one LOOSE list. A loose list on either side keeps
|
|
13336
|
+
// the blank line (the joined list is loose either way, and its spacing is
|
|
13337
|
+
// the user's); lists of different kinds stay two lists however they are
|
|
13338
|
+
// spaced, so they keep it too.
|
|
13339
|
+
function emptiedParagraphJoinsLists(rec, above) {
|
|
13340
|
+
const below = blocks.find((b) => b.startLine > rec.endLine) || null;
|
|
13341
|
+
if (!above || !below) return false;
|
|
13342
|
+
const upEl = blockElById(above.id);
|
|
13343
|
+
const downEl = blockElById(below.id);
|
|
13344
|
+
if (!upEl || !downEl || upEl.getAttribute('data-block-type') !== 'li' ||
|
|
13345
|
+
downEl.getAttribute('data-block-type') !== 'li') return false;
|
|
13346
|
+
const up = listRunOf(upEl);
|
|
13347
|
+
const down = listRunOf(downEl);
|
|
13348
|
+
if (!up.length || !down.length) return false;
|
|
13349
|
+
if (up.concat(down).some((el) => el.getAttribute('data-list-loose') === '1')) return false;
|
|
13350
|
+
const a = blockRecOf(up[0]);
|
|
13351
|
+
const b = blockRecOf(down[0]);
|
|
13352
|
+
return !!a && !!b && sameListKind(lines[a.startLine - 1], lines[b.startLine - 1]);
|
|
13353
|
+
}
|
|
13354
|
+
|
|
12648
13355
|
// R95: the emptied paragraph's removal, one op. Same failure contract as the
|
|
12649
13356
|
// paragraph commit below: a failed render rolls the op back and leaves the
|
|
12650
13357
|
// burst open. When focus is still inside (Ctrl+S, a toolbar press) a
|
|
@@ -12655,7 +13362,9 @@
|
|
|
12655
13362
|
if (!rec) { endBurstWithoutResolve(); return true; }
|
|
12656
13363
|
const focusInside = burst.blockEl.contains(document.activeElement);
|
|
12657
13364
|
const preceding = blocks.filter((b) => b.id !== rec.id && b.endLine < rec.startLine).pop() || null;
|
|
12658
|
-
const result =
|
|
13365
|
+
const result = emptiedParagraphJoinsLists(rec, preceding)
|
|
13366
|
+
? commitTightRemoval({ lines, blocks, stack }, rec.startLine, rec.endLine)
|
|
13367
|
+
: commitRangeRemoval({ lines, blocks, stack }, rec.startLine, rec.endLine);
|
|
12659
13368
|
if (result.op === null) { endBurstWithoutResolve(); return true; }
|
|
12660
13369
|
const prevLines = lines;
|
|
12661
13370
|
lines = result.lines;
|
|
@@ -12985,8 +13694,14 @@
|
|
|
12985
13694
|
// armed on one of these, or Shift+Clicking the ⠿ handle would build a
|
|
12986
13695
|
// selection instead of opening the menu, and a drag inside the raw
|
|
12987
13696
|
// textarea would fight its own text selection.
|
|
13697
|
+
// v3.17.3 A13: the diagram panel's source and the in-place code edit are
|
|
13698
|
+
// two more editors with a text selection of their own (ED_OWN_TEXT_EDITORS).
|
|
13699
|
+
// Measured before: a drag from the panel's source up over the blocks above
|
|
13700
|
+
// selected three blocks and closed the panel.
|
|
13701
|
+
const ED_OWN_TEXT_EDITORS = 'textarea.ed-diagram-src, pre[data-ed-code-edit]';
|
|
12988
13702
|
const ED_SEL_GESTURE_CHROME = '.ed-handle, .ed-handle-menu, .ed-insert, .ed-slash-menu, ' +
|
|
12989
|
-
'.ed-te-menu, .ed-te-grip, .ed-tb-insert, .ed-seltb, .ed-linkbar, .ed-linkcard, .ed-imgtb, .ed-conflict, .ed-raw'
|
|
13703
|
+
'.ed-te-menu, .ed-te-grip, .ed-tb-insert, .ed-seltb, .ed-linkbar, .ed-linkcard, .ed-imgtb, .ed-conflict, .ed-raw, ' +
|
|
13704
|
+
ED_OWN_TEXT_EDITORS;
|
|
12990
13705
|
function isSelGestureChrome(target) {
|
|
12991
13706
|
return !!(target && target.closest && target.closest(ED_SEL_GESTURE_CHROME));
|
|
12992
13707
|
}
|
|
@@ -13045,6 +13760,25 @@
|
|
|
13045
13760
|
// false by the next pointerdown so a gesture whose click never arrives
|
|
13046
13761
|
// (an abort, a release outside the window) cannot swallow a later one.
|
|
13047
13762
|
let blockSelClickSuppressed = false;
|
|
13763
|
+
// v3.17.3 A13: the diagram source or code edit (ED_OWN_TEXT_EDITORS) the
|
|
13764
|
+
// current press began in. Its trailing click, released outside that editor,
|
|
13765
|
+
// lands on an ancestor of both ends, and the click router's "outside any
|
|
13766
|
+
// block" branch would then resolve the editor the user is selecting in
|
|
13767
|
+
// (measured: a drag from the panel's source out to the page margin closed
|
|
13768
|
+
// the panel). The click belongs to the editor. Set by the pointerdown
|
|
13769
|
+
// listener, spent by that click, and cleared a task after the release
|
|
13770
|
+
// either way, so a press whose click never comes swallows nothing later.
|
|
13771
|
+
let ownTextPress = null;
|
|
13772
|
+
document.addEventListener('pointerup', () => {
|
|
13773
|
+
if (ownTextPress) setTimeout(() => { ownTextPress = null; }, 0);
|
|
13774
|
+
});
|
|
13775
|
+
// Fix round 1 (R47 M2): a native drag of selected text ends without a
|
|
13776
|
+
// pointerup (Chromium sends pointercancel when the drag starts) and without
|
|
13777
|
+
// a click, so the mark is dropped there and at the drag's end. Measured
|
|
13778
|
+
// before: the next click with no press of its own (a synthetic one outside
|
|
13779
|
+
// every block) was swallowed and the panel stayed open.
|
|
13780
|
+
document.addEventListener('pointercancel', () => { ownTextPress = null; });
|
|
13781
|
+
document.addEventListener('dragend', () => { ownTextPress = null; });
|
|
13048
13782
|
|
|
13049
13783
|
function armBlockSelDrag(e) {
|
|
13050
13784
|
if (isSelGestureChrome(e.target)) return;
|
|
@@ -13260,20 +13994,69 @@
|
|
|
13260
13994
|
await new Promise((r) => setTimeout(r, SETTLE_POLL_MS));
|
|
13261
13995
|
}
|
|
13262
13996
|
}
|
|
13263
|
-
// R94: `blockEl` once every in-flight commit has landed
|
|
13264
|
-
//
|
|
13265
|
-
// gesture is refused with the notice
|
|
13266
|
-
//
|
|
13267
|
-
// below the split, and a
|
|
13268
|
-
//
|
|
13997
|
+
// R94: `blockEl` once every in-flight commit has landed — the node itself
|
|
13998
|
+
// when that render kept it. v3.17.3 B4 (R17): one the render replaced is
|
|
13999
|
+
// found again, and the gesture is refused with the notice only when the
|
|
14000
|
+
// block is gone. Measured before: a split's render replaced every block
|
|
14001
|
+
// below the split, and a plain text commit's render every block, so a ⠿
|
|
14002
|
+
// 刪除 picked on another paragraph during the write deleted nothing and
|
|
14003
|
+
// showed 「文件已更新,請重試這個操作」. The identity is what the block READ
|
|
14004
|
+
// on screen at the gesture (drawnBlockPrint()), not its source: mid-flight
|
|
14005
|
+
// `lines` already holds the commit while `blocks` may or may not (R94), so
|
|
14006
|
+
// no line number or source taken then is trustworthy, while the drawn text
|
|
14007
|
+
// is what the user pointed at, typing included. Found again by
|
|
14008
|
+
// uniqueBlockElByPrint(), R104's rule (the one block of its type that reads
|
|
14009
|
+
// so, uniqueness being the proof of position), and never across a
|
|
14010
|
+
// reconciliation (R102).
|
|
14011
|
+
//
|
|
14012
|
+
// Fix round 1 (R46): only when all three hold — the print was unique AT
|
|
14013
|
+
// THE PRESS (the block it named is the one that reads so), it is unique
|
|
14014
|
+
// after the write, and no undo or redo rendered meanwhile (whose result is
|
|
14015
|
+
// not on screen at the press, so the screen the user pointed at says
|
|
14016
|
+
// nothing about the document it lands). Measured before: with `Omega.`
|
|
14017
|
+
// twice on screen and a 復原 rendering that turns the first back into
|
|
14018
|
+
// `Alpha.`, ⠿ 刪除 on the first deleted the second, silently. Otherwise the
|
|
14019
|
+
// notice, as before.
|
|
13269
14020
|
async function settledGestureBlock(blockEl) {
|
|
13270
14021
|
if (!blockEl) return null;
|
|
13271
14022
|
if (commitsSettled()) return blockEl;
|
|
14023
|
+
const print = drawnBlockPrint(blockEl);
|
|
14024
|
+
const refindable = historyRendersInFlight === 0 && uniqueBlockElByPrint(print) === blockEl;
|
|
14025
|
+
const epoch = reconcileEpoch;
|
|
14026
|
+
const historyAt = historyRenderSeq;
|
|
13272
14027
|
await settleCommits();
|
|
13273
14028
|
if (blockEl.isConnected) return blockEl;
|
|
14029
|
+
const live = refindable && epoch === reconcileEpoch && historyAt === historyRenderSeq && !reconcileBlocksCommits()
|
|
14030
|
+
? uniqueBlockElByPrint(print) : null;
|
|
14031
|
+
if (live) return live;
|
|
13274
14032
|
showError(DROPPED_GESTURE_MESSAGE, null, null);
|
|
13275
14033
|
return null;
|
|
13276
14034
|
}
|
|
14035
|
+
// v3.17.3 B4: a block as the screen draws it — its type and its text, white
|
|
14036
|
+
// space folded (a typed NBSP reads as the render's space). A list item's is
|
|
14037
|
+
// its own line, not its children's; any other block's is its whole text
|
|
14038
|
+
// without the editor's chrome (⠿, +, buttons), which a hover may add or
|
|
14039
|
+
// drop between two renders.
|
|
14040
|
+
const DRAWN_PRINT_CHROME = '.ed-handle, .ed-insert, button';
|
|
14041
|
+
function drawnBlockPrint(blockEl) {
|
|
14042
|
+
let text = '';
|
|
14043
|
+
if (blockEl.getAttribute('data-block-type') === 'li') {
|
|
14044
|
+
const own = blockContentEl(blockEl);
|
|
14045
|
+
text = own ? own.textContent : '';
|
|
14046
|
+
} else {
|
|
14047
|
+
const copy = blockEl.cloneNode(true);
|
|
14048
|
+
copy.querySelectorAll(DRAWN_PRINT_CHROME).forEach((x) => x.remove());
|
|
14049
|
+
text = copy.textContent;
|
|
14050
|
+
}
|
|
14051
|
+
return { type: blockEl.getAttribute('data-block-type'), text: text.replace(/\s+/g, ' ').trim() };
|
|
14052
|
+
}
|
|
14053
|
+
function uniqueBlockElByPrint(print) {
|
|
14054
|
+
const hits = Array.prototype.slice.call(contentEl.querySelectorAll('.ed-block[data-block-id]')).filter((el) => {
|
|
14055
|
+
const p = drawnBlockPrint(el);
|
|
14056
|
+
return p.type === print.type && p.text === print.text;
|
|
14057
|
+
});
|
|
14058
|
+
return hits.length === 1 ? hits[0] : null;
|
|
14059
|
+
}
|
|
13277
14060
|
|
|
13278
14061
|
// R93: a task item as a checkbox press saw it. `fromEnd` counts rendered
|
|
13279
14062
|
// blocks after it (a commit above moves every id, never that count);
|
|
@@ -13508,7 +14291,7 @@
|
|
|
13508
14291
|
// here too.
|
|
13509
14292
|
function bystanderCarryOver(span, mutatedEl) {
|
|
13510
14293
|
const dirtyId = (currentBurst && currentBurst.editEl &&
|
|
13511
|
-
burstBaselineHtml(currentBurst.editEl)
|
|
14294
|
+
!sameBurstHtml(burstBaselineHtml(currentBurst.editEl), currentBurst.original))
|
|
13512
14295
|
? String(currentBurst.blockId) : null;
|
|
13513
14296
|
const out = {};
|
|
13514
14297
|
let any = false;
|
|
@@ -13822,6 +14605,15 @@
|
|
|
13822
14605
|
// v3.14.0 T1: Escape in a transient abandons it — it wrote nothing, so
|
|
13823
14606
|
// there is nothing to stash for Ctrl+Z. The only block of an empty
|
|
13824
14607
|
// document stays (D3), emptied.
|
|
14608
|
+
// v3.17.3 B11 (R10): only an EMPTY one. A transient with text is
|
|
14609
|
+
// committed, the path leaving it takes (resolveTransientBurst(): one op,
|
|
14610
|
+
// the caret at the new paragraph's end), so one Ctrl+Z takes it back.
|
|
14611
|
+
// Measured before: type `hello` in a new paragraph, Escape — the text was
|
|
14612
|
+
// gone and Ctrl+Z did not bring it back (the transient had no stash).
|
|
14613
|
+
if (burst.transient && !transientSurfaceEmpty(burst.blockEl)) {
|
|
14614
|
+
await switchAwayFrom();
|
|
14615
|
+
return;
|
|
14616
|
+
}
|
|
13825
14617
|
if (burst.transient) {
|
|
13826
14618
|
burst.history.dispose();
|
|
13827
14619
|
currentBurst = null;
|
|
@@ -13935,7 +14727,7 @@
|
|
|
13935
14727
|
const d = discardedBurst;
|
|
13936
14728
|
discardedBurst = null;
|
|
13937
14729
|
if (!d) return false;
|
|
13938
|
-
if (!document.body.contains(d.editEl) || burstBaselineHtml(d.editEl)
|
|
14730
|
+
if (!document.body.contains(d.editEl) || !sameBurstHtml(burstBaselineHtml(d.editEl), d.reverted)) {
|
|
13939
14731
|
d.history.dispose();
|
|
13940
14732
|
return false;
|
|
13941
14733
|
}
|
|
@@ -14035,6 +14827,24 @@
|
|
|
14035
14827
|
}
|
|
14036
14828
|
const blockEl = blockElById(d.blockId);
|
|
14037
14829
|
if (!blockEl) return false;
|
|
14830
|
+
// v3.17.2 B3 (R75): a diagram panel's Esc opens the panel again, with
|
|
14831
|
+
// what was typed and the preview it had, so the edit goes on where it
|
|
14832
|
+
// stopped. Measured before: the raw box over the whole fence, no preview.
|
|
14833
|
+
// A panel that does not open (the block no longer reads as that diagram)
|
|
14834
|
+
// falls back to the raw box below.
|
|
14835
|
+
if (d.diagram) {
|
|
14836
|
+
const opened = await openDiagramPanel(blockEl, { kind: d.diagram.kind, seed: {
|
|
14837
|
+
value: d.diagram.value, selStart: d.diagram.selStart, selEnd: d.diagram.selEnd,
|
|
14838
|
+
previewHtml: d.diagram.previewHtml } });
|
|
14839
|
+
const live = blockElById(d.blockId);
|
|
14840
|
+
const panel = liveDiagramPanel();
|
|
14841
|
+
if (opened && panel && live && panel.blockEl === live) {
|
|
14842
|
+
scheduleDiagramPreview(panel);
|
|
14843
|
+
setDirty();
|
|
14844
|
+
return true;
|
|
14845
|
+
}
|
|
14846
|
+
if (!live || live.querySelector('.ed-diagram-panel')) return false;
|
|
14847
|
+
}
|
|
14038
14848
|
await openRawEditor(blockEl, d.range);
|
|
14039
14849
|
// Re-queried rather than reusing `blockEl` above: openRawEditor()'s own
|
|
14040
14850
|
// guard may have resolved a DIFFERENT open session first (switchAwayFrom()),
|
|
@@ -14058,6 +14868,9 @@
|
|
|
14058
14868
|
function burstUndo(editEl) {
|
|
14059
14869
|
const burst = currentBurst;
|
|
14060
14870
|
if (!burst || burst.editEl !== editEl) return;
|
|
14871
|
+
// v3.17.3 A8: an undo is not a save — a pristine item it resolves is
|
|
14872
|
+
// taken away (willAutoRemove below), whatever an earlier Ctrl+S marked.
|
|
14873
|
+
burst.forSave = false;
|
|
14061
14874
|
const state = burst.history.undo();
|
|
14062
14875
|
if (state !== null) {
|
|
14063
14876
|
editEl.innerHTML = state;
|
|
@@ -14100,8 +14913,9 @@
|
|
|
14100
14913
|
// caret and the burst stayed where it was. With nothing left to step back
|
|
14101
14914
|
// through here, taking that transient away is the Enter's undo step; the
|
|
14102
14915
|
// caret stays put. Only in the block the Enter was pressed in (`stayEl`):
|
|
14103
|
-
// a transient the user has already left behind is on its way out (
|
|
14104
|
-
//
|
|
14916
|
+
// a transient the user has already left behind is on its way out (the
|
|
14917
|
+
// next render drops it: transientKeptThroughRender() keeps only one the
|
|
14918
|
+
// user is still at), and a Ctrl+Z in another
|
|
14105
14919
|
// block means that block's undo. MEASURED without the `stayEl` test (ER
|
|
14106
14920
|
// 'burst undo: … cascades to the previous commit'): Enter at the end,
|
|
14107
14921
|
// a click straight into the paragraph above, typing, Ctrl+Z twice — the
|
|
@@ -14122,7 +14936,7 @@
|
|
|
14122
14936
|
// to resolve — nothing can change either condition between this check
|
|
14123
14937
|
// and that resolution running.
|
|
14124
14938
|
const willAutoRemove = !!(pristineInsert && pristineInsert.blockId === burst.blockId &&
|
|
14125
|
-
burstBaselineHtml(burst.editEl)
|
|
14939
|
+
sameBurstHtml(burstBaselineHtml(burst.editEl), burst.original));
|
|
14126
14940
|
switchAwayFrom().then((ok) => { if (ok && !willAutoRemove) undo(); });
|
|
14127
14941
|
}
|
|
14128
14942
|
|
|
@@ -14971,7 +15785,9 @@
|
|
|
14971
15785
|
at = sg.end + 1;
|
|
14972
15786
|
});
|
|
14973
15787
|
out.push(...lines.slice(at - 1, hi));
|
|
14974
|
-
|
|
15788
|
+
// R56: every line between the segments (blocks' own ranges and list runs,
|
|
15789
|
+
// neither of which holds a definition) is sliced from `lines` verbatim.
|
|
15790
|
+
const result = commitRangeEdit({ lines, blocks, stack }, lo, hi, out.join('\n'), { carriesGap: true });
|
|
14975
15791
|
if (result.op === null) return;
|
|
14976
15792
|
endBurstWithoutResolve();
|
|
14977
15793
|
const prevLines = lines;
|
|
@@ -15009,8 +15825,9 @@
|
|
|
15009
15825
|
// Every member was a paragraph, or every heading already sat at its clamp:
|
|
15010
15826
|
// §3.5 rules that a no-op, so nothing is committed and nothing re-renders.
|
|
15011
15827
|
if (!changed) return;
|
|
15828
|
+
// R56: `span` is the range sliced from `lines`, only heading lines rewritten.
|
|
15012
15829
|
const result = commitRangeEdit({ lines, blocks, stack },
|
|
15013
|
-
first.startLine, last.endLine, span.join('\n'));
|
|
15830
|
+
first.startLine, last.endLine, span.join('\n'), { carriesGap: true });
|
|
15014
15831
|
if (result.op === null) return;
|
|
15015
15832
|
const prevLines = lines;
|
|
15016
15833
|
lines = result.lines;
|
|
@@ -15240,7 +16057,7 @@
|
|
|
15240
16057
|
async function liBackspaceStructure(li) {
|
|
15241
16058
|
const burst = currentBurst;
|
|
15242
16059
|
if (burst && burst.blockType === 'li' && burst.blockEl === li &&
|
|
15243
|
-
burstBaselineHtml(burst.editEl)
|
|
16060
|
+
!sameBurstHtml(burstBaselineHtml(burst.editEl), burst.original)) {
|
|
15244
16061
|
const rec = blockRecOf(li);
|
|
15245
16062
|
if (!rec) return;
|
|
15246
16063
|
if (!(await switchAwayFrom())) return;
|
|
@@ -15267,6 +16084,126 @@
|
|
|
15267
16084
|
await convertBlockViaMenu(li, 'text', { caretToEnd: false });
|
|
15268
16085
|
}
|
|
15269
16086
|
|
|
16087
|
+
// v3.17.3 B9 (R11): plain Delete with the caret at the end of a list item
|
|
16088
|
+
// (or in an empty one) pulls the next block's text up into it, as in
|
|
16089
|
+
// Notion: the next item of the same list when it owns no children, or the
|
|
16090
|
+
// paragraph / heading right after the list. Every other neighbour keeps
|
|
16091
|
+
// today's behaviour, the browser's own Delete, which changes nothing at the
|
|
16092
|
+
// end of the surface: an item with children (they would have no parent),
|
|
16093
|
+
// an item of another list, a transient, a code block, table, diagram,
|
|
16094
|
+
// image or divider, the end of the document. true = handled.
|
|
16095
|
+
function liDeleteAtEnd(e, editEl) {
|
|
16096
|
+
if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey || e.isComposing || e.keyCode === 229) return false;
|
|
16097
|
+
const burst = currentBurst;
|
|
16098
|
+
if (!burst || burst.editEl !== editEl || burst.blockType !== 'li') return false;
|
|
16099
|
+
const place = caretPlace(editEl, true);
|
|
16100
|
+
if (place !== 'end' && place !== 'empty') return false;
|
|
16101
|
+
const li = closestLiBlock(editEl);
|
|
16102
|
+
if (!li || liTextEl(li) !== editEl) return false;
|
|
16103
|
+
const run = listRunOf(li);
|
|
16104
|
+
if (!run.length) return false;
|
|
16105
|
+
const next = nextBlockEl(li);
|
|
16106
|
+
if (!next || isTransient(next)) return false;
|
|
16107
|
+
const nextSurface = mergeSurfaceOf(next);
|
|
16108
|
+
if (!nextSurface) return false;
|
|
16109
|
+
const nextIsLi = next.getAttribute('data-block-type') === 'li';
|
|
16110
|
+
// R34: the item and, for an item below, that item too must be joinable
|
|
16111
|
+
// (one source line, no children), and the run one the structural
|
|
16112
|
+
// serializer takes. Otherwise the browser's own Delete, never a
|
|
16113
|
+
// refusal notice.
|
|
16114
|
+
const own = blockRecOf(li);
|
|
16115
|
+
if (!own || own.endLine !== own.startLine) return false;
|
|
16116
|
+
if (nextIsLi) {
|
|
16117
|
+
if (run.indexOf(next) === -1 || !liJoinable(next)) return false;
|
|
16118
|
+
} else if (run[run.length - 1] !== li) return false;
|
|
16119
|
+
if (!listRunSupportsStructuralEdit(run, nextIsLi ? [li, next] : [li])) return false;
|
|
16120
|
+
e.preventDefault();
|
|
16121
|
+
startStructuralKey();
|
|
16122
|
+
liPullNextUp(burst, editEl, li, run, next, nextSurface, place === 'empty').catch(structuralKeyFailed);
|
|
16123
|
+
return true;
|
|
16124
|
+
}
|
|
16125
|
+
|
|
16126
|
+
// The element a Delete join appends into, or takes the text of: a loose
|
|
16127
|
+
// list item's surface holds its text in ONE <p> (v3.11), and the join goes
|
|
16128
|
+
// inside that paragraph. Measured before: `- a` + blank + `- b`, Delete at
|
|
16129
|
+
// the end of `a` put two <p> in one surface and wrote `-` (both texts lost).
|
|
16130
|
+
// Anything else is the surface itself. Also the destination of Backspace's
|
|
16131
|
+
// join into a loose item (joinIntoPrevious(), v3.17.3 N1).
|
|
16132
|
+
//
|
|
16133
|
+
// The whitespace test is list-md.js's isBlankText() (not exported), copied
|
|
16134
|
+
// exactly: a text node that is pure whitespace AND holds a newline, the
|
|
16135
|
+
// pretty-print artifact. A bare space is inline spacing and counts.
|
|
16136
|
+
function joinHostOf(surface) {
|
|
16137
|
+
const kids = Array.prototype.filter.call(surface.childNodes,
|
|
16138
|
+
(n) => !(n.nodeType === 3 && /\n/.test(n.textContent) && /^\s*$/.test(n.textContent)));
|
|
16139
|
+
return kids.length === 1 && kids[0].nodeName === 'P' ? kids[0] : surface;
|
|
16140
|
+
}
|
|
16141
|
+
|
|
16142
|
+
// R34: a list item a Delete join may pull up — it owns one source line
|
|
16143
|
+
// and no nested items. An item over several lines (a hard wrap, a
|
|
16144
|
+
// continuation paragraph, a fenced child) counts as one with children.
|
|
16145
|
+
function liJoinable(li) {
|
|
16146
|
+
const rec = blockRecOf(li);
|
|
16147
|
+
return !!rec && rec.endLine === rec.startLine && !liBlockHasChildren(li);
|
|
16148
|
+
}
|
|
16149
|
+
|
|
16150
|
+
// The item's pending typing as its own op (D4): the item's own lines in its
|
|
16151
|
+
// list's serialization (lineMeta's blank entries carry no blockId, so none
|
|
16152
|
+
// is taken). Several lines replace the item's one source line, which
|
|
16153
|
+
// commitEdit() takes; the join's range ends at a block after the item, so
|
|
16154
|
+
// it moves with them. No gesture is known to reach that today: a typed
|
|
16155
|
+
// newline in the surface makes liDeleteAtEnd() decline (the run check's
|
|
16156
|
+
// multi-line target), and a Shift+Enter is `<br>` on the same line. null
|
|
16157
|
+
// when nothing was typed.
|
|
16158
|
+
function liTypedInBurst(burst, run, li) {
|
|
16159
|
+
if (sameBurstHtml(burstBaselineHtml(burst.editEl), burst.original)) return null;
|
|
16160
|
+
burst.history.flushTyping();
|
|
16161
|
+
const out = serializeListEls(run, { carryOver: bystanderCarryOver(run, li) });
|
|
16162
|
+
const id = String(burst.blockId);
|
|
16163
|
+
const mdLines = out.md.split('\n');
|
|
16164
|
+
const own = [];
|
|
16165
|
+
out.lineMeta.forEach((m, k) => { if (m.blockId === id) own.push(mdLines[k]); });
|
|
16166
|
+
return own.length ? { blockId: burst.blockId, text: own.join('\n') } : null;
|
|
16167
|
+
}
|
|
16168
|
+
|
|
16169
|
+
// The join behind liDeleteAtEnd(): the next block's text appended to the
|
|
16170
|
+
// item at the seam, the next block removed, then the item's whole list
|
|
16171
|
+
// (and the paragraph's lines, when that was the next block) rewritten as
|
|
16172
|
+
// ONE op, mirroring joinIntoPrevious() for Backspace. Synchronous up to the
|
|
16173
|
+
// commit (no await before the DOM change, the v3.13.1 guard).
|
|
16174
|
+
async function liPullNextUp(burst, editEl, li, run, next, nextSurface, empty) {
|
|
16175
|
+
const runRange = runRangeOfBlocks({ lines, blocks, stack }, run);
|
|
16176
|
+
const liRec = blockRecOf(li);
|
|
16177
|
+
const nextRec = blockRecOf(next);
|
|
16178
|
+
if (!runRange || !liRec || !nextRec) return;
|
|
16179
|
+
const nextIsLi = next.getAttribute('data-block-type') === 'li';
|
|
16180
|
+
// liDeleteAtEnd() checked the run and both items (R34) synchronously.
|
|
16181
|
+
const typed = empty ? null : liTypedInBurst(burst, run, li);
|
|
16182
|
+
const span = nextIsLi ? run.filter((el) => el !== next) : run;
|
|
16183
|
+
let seam = 0;
|
|
16184
|
+
withFocusoutSuppressed(() => {
|
|
16185
|
+
seam = joinSeam(joinHostOf(editEl), Array.prototype.slice.call(joinHostOf(nextSurface).childNodes), false);
|
|
16186
|
+
structureMutated = true;
|
|
16187
|
+
restoreSelection(editEl, seam, seam);
|
|
16188
|
+
if (nextIsLi) mutateListRun(() => removeListItem(next));
|
|
16189
|
+
else next.parentNode.removeChild(next);
|
|
16190
|
+
});
|
|
16191
|
+
endBurstWithoutResolve();
|
|
16192
|
+
const serialize = () => {
|
|
16193
|
+
const out = serializeListEls(span, { carryOver: bystanderCarryOver(span, li) });
|
|
16194
|
+
return { text: out.md, lineMap: { ids: flightLineMapOf(out.lineMeta, runRange.startLine).ids, provisional: null } };
|
|
16195
|
+
};
|
|
16196
|
+
const s = serialize();
|
|
16197
|
+
const ok = await commitTypingThenStructure({ typed, text: s.text,
|
|
16198
|
+
range: (bs) => {
|
|
16199
|
+
const r = runRangeOfBlocks({ blocks: bs }, run);
|
|
16200
|
+
return [r.startLine, nextIsLi ? r.endLine : bs.find((b) => b.id === nextRec.id).endLine];
|
|
16201
|
+
},
|
|
16202
|
+
oldRange: [runRange.startLine, nextIsLi ? runRange.endLine : nextRec.endLine], lineMap: s.lineMap,
|
|
16203
|
+
pending: { surfaces: [editEl], serialize } });
|
|
16204
|
+
if (ok) focusBlockAtLineOffset(liRec.startLine, seam);
|
|
16205
|
+
}
|
|
16206
|
+
|
|
15270
16207
|
// Tab / Shift+Tab on a list item (and, v3.14.0 T3, Backspace at the start
|
|
15271
16208
|
// of a nested one, which is Shift+Tab). Extracted unchanged from
|
|
15272
16209
|
// handleLiKeydown(); `outdent` stands for the Shift key. `caretToEnd`
|
|
@@ -15341,6 +16278,9 @@
|
|
|
15341
16278
|
// item goes and a transient paragraph takes its place (Enter's row-3
|
|
15342
16279
|
// path). Anywhere else the browser's own deletion.
|
|
15343
16280
|
if (e.key === 'Backspace') return liBackspaceAtStart(e, editEl);
|
|
16281
|
+
// v3.17.3 B9 (R11): Delete at an item's end pulls the next item, or the
|
|
16282
|
+
// paragraph / heading after the list, up (liDeleteAtEnd()).
|
|
16283
|
+
if (e.key === 'Delete') return liDeleteAtEnd(e, editEl);
|
|
15344
16284
|
if (e.key !== 'Enter' && e.key !== 'Tab') return false;
|
|
15345
16285
|
e.preventDefault();
|
|
15346
16286
|
// T21 item 2, the li half — see handleBurstKeydown()'s Tab branch. Ahead
|
|
@@ -16553,7 +17493,7 @@
|
|
|
16553
17493
|
// auto-remove an untouched pristine table insert, so a chained undo()
|
|
16554
17494
|
// must be skipped or it cascades one op too far.
|
|
16555
17495
|
const willAutoRemove = !!(pristineInsert && pristineInsert.blockId === burst.blockId &&
|
|
16556
|
-
burstBaselineHtml(tableEl)
|
|
17496
|
+
sameBurstHtml(burstBaselineHtml(tableEl), burst.original));
|
|
16557
17497
|
switchAwayFrom().then((ok) => { if (ok && !willAutoRemove) undo(); });
|
|
16558
17498
|
}
|
|
16559
17499
|
|
|
@@ -19668,7 +20608,10 @@
|
|
|
19668
20608
|
const r1 = blockRecOf(unit[unit.length - 1]);
|
|
19669
20609
|
unitLines = lines.slice(r0.startLine - 1, r1.endLine);
|
|
19670
20610
|
} else {
|
|
19671
|
-
|
|
20611
|
+
// v3.17.3 A9 (review I1): a moved item's old marker is another
|
|
20612
|
+
// list's; it takes the marker of the list it lands in (list-md.js's
|
|
20613
|
+
// tiered vote, `foreign`).
|
|
20614
|
+
const res = serializeListEls(unit, { carryOver: carry, foreign: (el) => moving.indexOf(el) !== -1 });
|
|
19672
20615
|
unitLines = res.md.split('\n');
|
|
19673
20616
|
meta = res.lineMeta;
|
|
19674
20617
|
}
|
|
@@ -20005,6 +20948,8 @@
|
|
|
20005
20948
|
// next one.
|
|
20006
20949
|
blockSelClickSuppressed = false;
|
|
20007
20950
|
if (blockSelDrag) endBlockSelDrag();
|
|
20951
|
+
// v3.17.3 A13: and which own-text editor this press began in, if any.
|
|
20952
|
+
ownTextPress = e.target.closest ? e.target.closest(ED_OWN_TEXT_EDITORS) : null;
|
|
20008
20953
|
// Every defensive teardown above still runs — they are no-ops that must not
|
|
20009
20954
|
// be skipped — but nothing behind the modal ARMS a new gesture. The backdrop
|
|
20010
20955
|
// already makes this unreachable (measured: a Shift+press-drag on the canvas
|
|
@@ -20861,7 +21806,10 @@
|
|
|
20861
21806
|
closeLinkField({ restore: true });
|
|
20862
21807
|
return;
|
|
20863
21808
|
}
|
|
20864
|
-
|
|
21809
|
+
// v3.17.3 C6: below the selection it can pass the viewport's bottom edge;
|
|
21810
|
+
// keep it inside, with openLinkField()'s margin.
|
|
21811
|
+
const fieldH = f.el.getBoundingClientRect().height;
|
|
21812
|
+
f.el.style.top = Math.round(Math.min(r.bottom + f.dy, window.innerHeight - fieldH - 4)) + 'px';
|
|
20865
21813
|
f.el.style.left = Math.round(r.left + f.dx) + 'px';
|
|
20866
21814
|
}
|
|
20867
21815
|
|
|
@@ -21291,6 +22239,13 @@
|
|
|
21291
22239
|
// below and clear the set it just built; one released on the page
|
|
21292
22240
|
// margin would hit the outside-a-block switchAwayFrom() instead.
|
|
21293
22241
|
if (blockSelClickSuppressed) { blockSelClickSuppressed = false; return; }
|
|
22242
|
+
// v3.17.3 A13: see `ownTextPress`. Released inside the editor, the click
|
|
22243
|
+
// carries on as before (the open-edit branch below answers it).
|
|
22244
|
+
if (ownTextPress) {
|
|
22245
|
+
const own = ownTextPress;
|
|
22246
|
+
ownTextPress = null;
|
|
22247
|
+
if (!own.contains(e.target)) return;
|
|
22248
|
+
}
|
|
21294
22249
|
// v3.17.0 T9b (R50): the release of this press already answered it.
|
|
21295
22250
|
if (degradedReleaseOpened) { degradedReleaseOpened = false; return; }
|
|
21296
22251
|
if (!e.target || !e.target.closest) { await switchAwayFrom(); closeGutterMenu(); return; }
|
|
@@ -21623,10 +22578,19 @@
|
|
|
21623
22578
|
let saveGen = 0;
|
|
21624
22579
|
let savesInFlight = 0;
|
|
21625
22580
|
let lastSave = null; // v3.17.0 T9b: the newest save's round trip (saveAfterLastSave())
|
|
22581
|
+
// v3.17.2 B2 (R66): every save goes out after the one before it has
|
|
22582
|
+
// answered, never alongside it. Sent alongside, it carried the baseline
|
|
22583
|
+
// the first reply was about to replace, the server answered 409 (sendSave()
|
|
22584
|
+
// says why) and the conflict card went up over a file this page had just
|
|
22585
|
+
// saved. MEASURED before: two Ctrl+S 50 ms apart with a slow reply — saves
|
|
22586
|
+
// [200, 409], status failed, the conflict card up. The queued save reads
|
|
22587
|
+
// `lines` and `mtimeMs` when it is sent, so it saves whatever is on screen
|
|
22588
|
+
// by then, against the baseline the first reply left.
|
|
21626
22589
|
async function save() {
|
|
21627
22590
|
saveGen++;
|
|
21628
22591
|
savesInFlight++;
|
|
21629
|
-
const
|
|
22592
|
+
const prev = lastSave;
|
|
22593
|
+
const sending = (prev ? prev.then(() => {}, () => {}) : Promise.resolve()).then(() => sendSave());
|
|
21630
22594
|
lastSave = sending;
|
|
21631
22595
|
try { return await sending; } finally { savesInFlight--; }
|
|
21632
22596
|
}
|
|
@@ -21726,26 +22690,40 @@
|
|
|
21726
22690
|
// copy of save()'s own body, just its one caller-side precondition named
|
|
21727
22691
|
// once.
|
|
21728
22692
|
async function commitThenSave() {
|
|
22693
|
+
markBurstForSave();
|
|
21729
22694
|
const ok = await switchAwayFrom();
|
|
21730
22695
|
if (ok) return save();
|
|
21731
22696
|
}
|
|
21732
22697
|
|
|
21733
|
-
// v3.17.
|
|
21734
|
-
//
|
|
21735
|
-
//
|
|
21736
|
-
//
|
|
21737
|
-
//
|
|
21738
|
-
//
|
|
21739
|
-
//
|
|
21740
|
-
//
|
|
21741
|
-
//
|
|
21742
|
-
//
|
|
21743
|
-
|
|
22698
|
+
// v3.17.3 A8 (R9): an empty list item a "/" item, + or the toolbar just
|
|
22699
|
+
// inserted (pristineInsert) is taken away again when its burst resolves
|
|
22700
|
+
// untouched — "insert, then leave without typing" is a change of mind. A
|
|
22701
|
+
// save is not leaving: with the caret still in the item it stays, written
|
|
22702
|
+
// as its skeleton (`- [ ] ` for 待辦清單), and the caret stays in it, as an
|
|
22703
|
+
// empty item Enter made does. Measured before: Alpha., Enter, 「/待辦」,
|
|
22704
|
+
// Enter, Ctrl+S — the item vanished, focus fell to the body, and the file
|
|
22705
|
+
// was saved without it. The mark is set on the burst a save is about to
|
|
22706
|
+
// resolve and read only by pristineItemSurvivesSave(); burstUndo() clears
|
|
22707
|
+
// it, since an undo of the item is the one resolution that must remove it.
|
|
22708
|
+
function markBurstForSave() {
|
|
22709
|
+
if (currentBurst) currentBurst.forSave = true;
|
|
22710
|
+
}
|
|
22711
|
+
function pristineItemSurvivesSave(burst) {
|
|
22712
|
+
if (!burst.forSave || burst.blockType !== 'li' || !burst.editEl.isConnected ||
|
|
22713
|
+
!burst.blockEl.contains(document.activeElement)) return false;
|
|
22714
|
+
startBurst(burst.editEl);
|
|
22715
|
+
return true;
|
|
22716
|
+
}
|
|
22717
|
+
|
|
22718
|
+
// v3.17.0 T9b added this queue for the callers that ask for a save while an
|
|
22719
|
+
// earlier one may still be on its way (a Ctrl+S held through a code edit's
|
|
22720
|
+
// or diagram panel's re-entry, a second Ctrl+S while the first one's
|
|
22721
|
+
// resolution is out). v3.17.2 B2 moved the queue into save() itself, so a
|
|
22722
|
+
// Ctrl+S pressed on its own while a save is out waits too (EJ
|
|
22723
|
+
// N5-two-saves); this is save() under the name those callers use. A save
|
|
22724
|
+
// queued behind a request that never answers waits with it.
|
|
21744
22725
|
function saveAfterLastSave() {
|
|
21745
|
-
|
|
21746
|
-
const next = (prev ? prev.then(() => {}, () => {}) : Promise.resolve()).then(() => save());
|
|
21747
|
-
lastSave = next;
|
|
21748
|
-
return next;
|
|
22726
|
+
return save();
|
|
21749
22727
|
}
|
|
21750
22728
|
|
|
21751
22729
|
// v3.4.0 §3: the save button's own caret rescue. commitThenSave() above
|
|
@@ -21870,6 +22848,21 @@
|
|
|
21870
22848
|
}
|
|
21871
22849
|
|
|
21872
22850
|
// ── undo / redo ───────────────────────────────────────────────────────
|
|
22851
|
+
// v3.17.3 fix round 1 (R46): the renders of undo() / redo() (and of the
|
|
22852
|
+
// wave editor's whole-session undo / redo they call), counted, so a
|
|
22853
|
+
// gesture that waited for a write can tell that write was an undo or redo
|
|
22854
|
+
// (settledGestureBlock()).
|
|
22855
|
+
let historyRenderSeq = 0;
|
|
22856
|
+
let historyRendersInFlight = 0;
|
|
22857
|
+
async function historyRerender(editRange) {
|
|
22858
|
+
historyRenderSeq++;
|
|
22859
|
+
historyRendersInFlight++;
|
|
22860
|
+
try {
|
|
22861
|
+
return await safeRerenderAll(editRange);
|
|
22862
|
+
} finally {
|
|
22863
|
+
historyRendersInFlight--;
|
|
22864
|
+
}
|
|
22865
|
+
}
|
|
21873
22866
|
async function undo() {
|
|
21874
22867
|
// T21 item 1: an Escape that discarded a burst leaves exactly one thing
|
|
21875
22868
|
// for the next Ctrl+Z to undo, and it is not on this stack — the burst
|
|
@@ -21941,7 +22934,7 @@
|
|
|
21941
22934
|
endLine: r.op.startLine + r.op.after.length - 1,
|
|
21942
22935
|
delta: r.op.before.length - r.op.after.length }
|
|
21943
22936
|
: undefined;
|
|
21944
|
-
const ok = await
|
|
22937
|
+
const ok = await historyRerender(editRange);
|
|
21945
22938
|
if (!ok) {
|
|
21946
22939
|
// Reverse the undo attempt: push the op back and restore `lines` to
|
|
21947
22940
|
// what was on screen before this undo was requested.
|
|
@@ -21987,7 +22980,7 @@
|
|
|
21987
22980
|
endLine: Math.max(r.op.startLine - 1, r.op.endLine),
|
|
21988
22981
|
delta: r.op.after.length - r.op.before.length }
|
|
21989
22982
|
: undefined;
|
|
21990
|
-
const ok = await
|
|
22983
|
+
const ok = await historyRerender(editRange);
|
|
21991
22984
|
if (!ok) {
|
|
21992
22985
|
// Reverse the redo attempt: pop the op back off and restore `lines`.
|
|
21993
22986
|
const rollback = stack.undo(lines);
|
|
@@ -22023,7 +23016,32 @@
|
|
|
22023
23016
|
// steps the burst, exactly as Ctrl+Z does there. It never commits what is
|
|
22024
23017
|
// open first and then undoes the whole commit (a transient typed into
|
|
22025
23018
|
// vanished entirely that way).
|
|
22026
|
-
|
|
23019
|
+
// v3.17.3 C1 (R22): the order is explicit — an open code edit, then an
|
|
23020
|
+
// open diagram panel (its own text history: the panel stays open and the
|
|
23021
|
+
// file is not reached), then a burst, and the document stack LAST.
|
|
23022
|
+
// v3.17.3 A12 (R22): the document stack waits for a write in flight and
|
|
23023
|
+
// then undoes it — one press, one step. Measured before: one press during
|
|
23024
|
+
// the render of a raw box's Ctrl+Enter left that commit standing, and a
|
|
23025
|
+
// second press undid it. Read from the code: the box keeps its session
|
|
23026
|
+
// (`activeEditor`) until its render lands, so undo()'s switchAwayFrom()
|
|
23027
|
+
// resolved that box again instead of the press reaching the stack (not
|
|
23028
|
+
// traced further). Only this layer waits: the code edit's, the panel's
|
|
23029
|
+
// and the burst's histories never reach a write. Fix round 1 (R47 M1):
|
|
23030
|
+
// the routing runs again once the wait is over — an edit opened meanwhile
|
|
23031
|
+
// (a code edit, the panel, typing) owns the press, as R22 orders.
|
|
23032
|
+
if (undoInOpenEdit()) return;
|
|
23033
|
+
if (!commitsSettled()) {
|
|
23034
|
+
await settleCommits();
|
|
23035
|
+
if (undoInOpenEdit()) return;
|
|
23036
|
+
}
|
|
23037
|
+
const saved = captureCaretForSave();
|
|
23038
|
+
await undo();
|
|
23039
|
+
restoreCaretForSave(saved);
|
|
23040
|
+
}
|
|
23041
|
+
// R22's first three layers: true when one of them took the press.
|
|
23042
|
+
function undoInOpenEdit() {
|
|
23043
|
+
if (codeEditIsOpen()) { stepCodeEditHistory(codeEditState, -1); return true; }
|
|
23044
|
+
if (diagramPanelIsOpen()) { stepDiagramHistory(diagramPanelState, -1); return true; }
|
|
22027
23045
|
const b = currentBurst;
|
|
22028
23046
|
if (b && b.editEl && b.blockType !== 'table' && b.editEl.isConnected &&
|
|
22029
23047
|
b.editEl.contains(document.activeElement) && burstHasUncommittedEdit()) {
|
|
@@ -22031,18 +23049,40 @@
|
|
|
22031
23049
|
// transient left standing after Ctrl+S, say) leaves the button the
|
|
22032
23050
|
// document undo it always was.
|
|
22033
23051
|
burstUndo(b.editEl);
|
|
22034
|
-
return;
|
|
23052
|
+
return true;
|
|
22035
23053
|
}
|
|
22036
|
-
|
|
22037
|
-
|
|
22038
|
-
|
|
23054
|
+
return false;
|
|
23055
|
+
}
|
|
23056
|
+
|
|
23057
|
+
// A12: two presses that both waited step twice, one after the other: the
|
|
23058
|
+
// first to wake starts its undo's render within the same task chain (no
|
|
23059
|
+
// timer between the wake and beginCommitFlight()), so the other one's next
|
|
23060
|
+
// poll finds that render in flight and waits again. Pinned by the checks
|
|
23061
|
+
// 「v3173 A12: two toolbar 復原 …」. Fix round 1 (R47): the keyboard's
|
|
23062
|
+
// Ctrl+Z / Ctrl+Y take this wait only from a raw box whose commit is
|
|
23063
|
+
// rendering (see the keydown handler).
|
|
23064
|
+
async function settledHistoryStep(step) {
|
|
23065
|
+
if (!commitsSettled()) await settleCommits();
|
|
23066
|
+
return step();
|
|
22039
23067
|
}
|
|
22040
23068
|
|
|
22041
23069
|
async function redoViaToolbar() {
|
|
23070
|
+
// v3.17.3 C1 (R22): undoViaToolbar()'s order; the document stack last.
|
|
23071
|
+
if (redoInOpenEdit()) return;
|
|
23072
|
+
// A12: and waits for a write in flight the same way, routing again after.
|
|
23073
|
+
if (!commitsSettled()) {
|
|
23074
|
+
await settleCommits();
|
|
23075
|
+
if (redoInOpenEdit()) return;
|
|
23076
|
+
}
|
|
22042
23077
|
const saved = captureCaretForSave();
|
|
22043
23078
|
await redo();
|
|
22044
23079
|
restoreCaretForSave(saved);
|
|
22045
23080
|
}
|
|
23081
|
+
function redoInOpenEdit() {
|
|
23082
|
+
if (codeEditIsOpen()) { stepCodeEditHistory(codeEditState, 1); return true; }
|
|
23083
|
+
if (diagramPanelIsOpen()) { stepDiagramHistory(diagramPanelState, 1); return true; }
|
|
23084
|
+
return false;
|
|
23085
|
+
}
|
|
22046
23086
|
|
|
22047
23087
|
// ── v3.1.0 §4: the always-visible editing toolbar ──────────────────────
|
|
22048
23088
|
// The version's headline feature: before this there was no toolbar at all,
|
|
@@ -23148,7 +24188,9 @@
|
|
|
23148
24188
|
// The hidden file input both image paths open; `onFiles` gets the picked
|
|
23149
24189
|
// files (never called for an empty pick). v3.15.0 B1 fix: shared with
|
|
23150
24190
|
// pickImageIntoParagraph().
|
|
23151
|
-
|
|
24191
|
+
// `onCancel` (v3.17.3 A6): the dialog was dismissed, which the browser says
|
|
24192
|
+
// with a `cancel` event on the input; the input goes either way.
|
|
24193
|
+
function openImagePicker(onFiles, { multiple = true, onCancel } = {}) {
|
|
23152
24194
|
const input = document.createElement('input');
|
|
23153
24195
|
input.type = 'file';
|
|
23154
24196
|
// v3.4.1: extension tokens sit beside the MIME tokens deliberately.
|
|
@@ -23169,6 +24211,10 @@
|
|
|
23169
24211
|
if (!files.length) return;
|
|
23170
24212
|
Promise.resolve(onFiles(files)).catch(showUnexpectedFailure);
|
|
23171
24213
|
});
|
|
24214
|
+
input.addEventListener('cancel', () => {
|
|
24215
|
+
input.remove();
|
|
24216
|
+
if (typeof onCancel === 'function') Promise.resolve(onCancel()).catch(showUnexpectedFailure);
|
|
24217
|
+
});
|
|
23172
24218
|
input.click();
|
|
23173
24219
|
}
|
|
23174
24220
|
|
|
@@ -23228,6 +24274,394 @@
|
|
|
23228
24274
|
});
|
|
23229
24275
|
}
|
|
23230
24276
|
|
|
24277
|
+
// ── v3.17.3 B2 (R12): selected text dragged to another block ───────────
|
|
24278
|
+
// Before, the browser's own move ran in two halves: the source's
|
|
24279
|
+
// deleteByDrag went into the source's burst and the target's insertFromDrop
|
|
24280
|
+
// into the target's DOM, and the source's commit re-rendered over the
|
|
24281
|
+
// target. Measured: `Alpha bravo charlie.` / `Omega delta.`, `bravo`
|
|
24282
|
+
// dragged into `Omega`, the file then held neither `bravo` (both engines).
|
|
24283
|
+
// Now a drag that starts in the document and lands in ANOTHER block is the
|
|
24284
|
+
// editor's: the drop is cancelled (and with it both browser halves) and the
|
|
24285
|
+
// move is written by moveDroppedText() as one op. Both ends must be a plain
|
|
24286
|
+
// text surface (a paragraph, a heading or a list item); a table cell, a
|
|
24287
|
+
// code block, a diagram, an image, a raw box, the code edit or a transient
|
|
24288
|
+
// paragraph at either end refuses the drop with a notice, and the source
|
|
24289
|
+
// keeps its text; so does a drop outside every block (fix round 1, R38: the
|
|
24290
|
+
// search box, the link field, the page around the document). A drop inside
|
|
24291
|
+
// the drag's own block is left to the browser (its two halves land in one
|
|
24292
|
+
// burst, which commits them together), and a drag that did not start in the
|
|
24293
|
+
// document, or that carries files, is not this code's at all.
|
|
24294
|
+
//
|
|
24295
|
+
// `textDrag`: { blockEl, surface, range, text, handled } from dragstart to
|
|
24296
|
+
// just after dragend, or null. `surface` / `range` are null when the drag
|
|
24297
|
+
// is not a text selection in a plain surface (a table cell, an image).
|
|
24298
|
+
// `handled` (set at the drop): this code took the drop, so any input the
|
|
24299
|
+
// browser still raises for it (the source's deleteByDrag, an
|
|
24300
|
+
// insertFromDrop) is cancelled by the beforeinput guard below. Measured
|
|
24301
|
+
// with an event trace on a refused drop: neither engine raised either once
|
|
24302
|
+
// the drop was cancelled; the guard is there so an engine that does cannot
|
|
24303
|
+
// write the source behind the move.
|
|
24304
|
+
let textDrag = null;
|
|
24305
|
+
const TEXT_DROP_REFUSED_MESSAGE = '文字只能拖放到另一個段落、標題或清單項目裡,原文保持不動';
|
|
24306
|
+
|
|
24307
|
+
function elementOfNode(node) {
|
|
24308
|
+
return node && (node.nodeType === 1 ? node : node.parentElement);
|
|
24309
|
+
}
|
|
24310
|
+
// The plain text surface of `blockEl` a drag can move text out of or into:
|
|
24311
|
+
// a paragraph, heading or list item's armed surface; never a transient
|
|
24312
|
+
// (its text has no line yet, R12: refused) or a picture.
|
|
24313
|
+
function textDropSurfaceOf(blockEl) {
|
|
24314
|
+
if (!blockEl || isTransient(blockEl) || blockEl.getAttribute('data-block-id') === null) return null;
|
|
24315
|
+
if (isImageBlock(blockEl)) return null;
|
|
24316
|
+
return mergeSurfaceOf(blockEl);
|
|
24317
|
+
}
|
|
24318
|
+
// The browser's caret under a point, as an offset in `surface`'s text, or
|
|
24319
|
+
// null when the point is not over that surface's text.
|
|
24320
|
+
function surfaceOffsetFromPoint(surface, x, y) {
|
|
24321
|
+
let node = null;
|
|
24322
|
+
let off = 0;
|
|
24323
|
+
if (typeof document.caretPositionFromPoint === 'function') {
|
|
24324
|
+
const p = document.caretPositionFromPoint(x, y);
|
|
24325
|
+
if (p) { node = p.offsetNode; off = p.offset; }
|
|
24326
|
+
} else if (typeof document.caretRangeFromPoint === 'function') {
|
|
24327
|
+
const r = document.caretRangeFromPoint(x, y);
|
|
24328
|
+
if (r) { node = r.startContainer; off = r.startOffset; }
|
|
24329
|
+
}
|
|
24330
|
+
if (!node || !surface.contains(node)) return null;
|
|
24331
|
+
return offsetInSurface(surface, node, off);
|
|
24332
|
+
}
|
|
24333
|
+
function refuseTextDrop() {
|
|
24334
|
+
showNotice(TEXT_DROP_REFUSED_MESSAGE);
|
|
24335
|
+
}
|
|
24336
|
+
|
|
24337
|
+
// The block a drag event is over, or null outside every block.
|
|
24338
|
+
function dragBlockAt(e) {
|
|
24339
|
+
const t = elementOfNode(e.target);
|
|
24340
|
+
return t && contentEl.contains(t) ? t.closest('.ed-block') : null;
|
|
24341
|
+
}
|
|
24342
|
+
// The drag this code answers for `e`, or null: an in-document drag
|
|
24343
|
+
// (`textDrag`), never one that carries files — a picture or a .drawio from
|
|
24344
|
+
// the desktop is wireImageDrop()'s whatever state a previous drag left.
|
|
24345
|
+
function textDragFor(e) {
|
|
24346
|
+
return textDrag && !dragCarriesFiles(e) ? textDrag : null;
|
|
24347
|
+
}
|
|
24348
|
+
|
|
24349
|
+
function wireTextDrop() {
|
|
24350
|
+
// Fix round 1 (I1): `dragend` is dispatched to the node the drag started
|
|
24351
|
+
// on, and a move detaches that node (the deleted range, a dropped empty
|
|
24352
|
+
// mark, normalize()), so a document listener never hears it. Measured
|
|
24353
|
+
// before: after moving a whole bold word, `textDrag` stayed set with
|
|
24354
|
+
// `handled`, and the next external drop (a text, a PNG file) was
|
|
24355
|
+
// cancelled with the refusal notice. The listener therefore goes on the
|
|
24356
|
+
// node itself (it still fires there once detached), the document one is
|
|
24357
|
+
// kept for every other ending, and the next press clears it whatever
|
|
24358
|
+
// happened.
|
|
24359
|
+
const endTextDrag = (drag) => {
|
|
24360
|
+
setTimeout(() => { if (textDrag === drag) textDrag = null; }, 0);
|
|
24361
|
+
};
|
|
24362
|
+
document.addEventListener('dragstart', (e) => {
|
|
24363
|
+
textDrag = null;
|
|
24364
|
+
const t = elementOfNode(e.target);
|
|
24365
|
+
if (!t || !contentEl.contains(t)) return;
|
|
24366
|
+
const blockEl = t.closest('.ed-block');
|
|
24367
|
+
if (!blockEl || !contentEl.contains(blockEl)) return;
|
|
24368
|
+
const drag = { blockEl, surface: null, range: null, text: '', handled: false };
|
|
24369
|
+
const sel = window.getSelection();
|
|
24370
|
+
const surface = textDropSurfaceOf(blockEl);
|
|
24371
|
+
if (surface && sel.rangeCount && !sel.isCollapsed) {
|
|
24372
|
+
const r = sel.getRangeAt(0);
|
|
24373
|
+
// The selection is what is dragged only when the press was on it (a
|
|
24374
|
+
// picture inside the same block drags itself, not the selection).
|
|
24375
|
+
if (surface.contains(r.startContainer) && surface.contains(r.endContainer) && r.intersectsNode(t)) {
|
|
24376
|
+
drag.surface = surface;
|
|
24377
|
+
drag.range = r.cloneRange();
|
|
24378
|
+
drag.text = r.toString();
|
|
24379
|
+
}
|
|
24380
|
+
}
|
|
24381
|
+
textDrag = drag;
|
|
24382
|
+
if (e.target && typeof e.target.addEventListener === 'function') {
|
|
24383
|
+
e.target.addEventListener('dragend', () => endTextDrag(drag), { once: true });
|
|
24384
|
+
}
|
|
24385
|
+
}, true);
|
|
24386
|
+
const clearOnPress = () => { textDrag = null; };
|
|
24387
|
+
document.addEventListener('pointerdown', clearOnPress, true);
|
|
24388
|
+
document.addEventListener('mousedown', clearOnPress, true);
|
|
24389
|
+
// A drop needs a dragover the page accepted. A text surface accepts one
|
|
24390
|
+
// by itself; a code block, a diagram, a picture or the page outside the
|
|
24391
|
+
// blocks does not, and the refusal there would be silent. So everywhere
|
|
24392
|
+
// but the drag's own block the drag is accepted, and the drop arrives
|
|
24393
|
+
// and says why it is refused.
|
|
24394
|
+
const acceptTextDrag = (e) => {
|
|
24395
|
+
const drag = textDragFor(e);
|
|
24396
|
+
if (!drag) return;
|
|
24397
|
+
const t = elementOfNode(e.target);
|
|
24398
|
+
if (!t || dragBlockAt(e) === drag.blockEl || t.isContentEditable) return;
|
|
24399
|
+
e.preventDefault();
|
|
24400
|
+
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
|
|
24401
|
+
};
|
|
24402
|
+
// WebKit also needs the dragenter accepted: otherwise the drag's current
|
|
24403
|
+
// target becomes the body and no dragover or drop reaches the block
|
|
24404
|
+
// (measured on a code block: dragenter, then dragend, nothing between).
|
|
24405
|
+
document.addEventListener('dragenter', acceptTextDrag, true);
|
|
24406
|
+
document.addEventListener('dragover', acceptTextDrag, true);
|
|
24407
|
+
// Capture phase, so it runs ahead of wireImageDrop()'s listener, which a
|
|
24408
|
+
// picture dragged out of the document would otherwise reach.
|
|
24409
|
+
document.addEventListener('drop', (e) => {
|
|
24410
|
+
const drag = textDragFor(e);
|
|
24411
|
+
if (!drag || waveEditorIsModal()) return;
|
|
24412
|
+
const destBlockEl = dragBlockAt(e);
|
|
24413
|
+
if (destBlockEl === drag.blockEl) return;
|
|
24414
|
+
e.preventDefault();
|
|
24415
|
+
e.stopPropagation();
|
|
24416
|
+
drag.handled = true;
|
|
24417
|
+
// Fix round 1 (M4, R38): outside every block (the search box, the link
|
|
24418
|
+
// field, the page around the document) the drop is refused too; before,
|
|
24419
|
+
// an input there took the text out of the document.
|
|
24420
|
+
if (!destBlockEl) { refuseTextDrop(); return; }
|
|
24421
|
+
const carried = e.dataTransfer ? e.dataTransfer.getData('text/plain') : '';
|
|
24422
|
+
const destSurface = textDropSurfaceOf(destBlockEl);
|
|
24423
|
+
const destOffset = destSurface ? surfaceOffsetFromPoint(destSurface, e.clientX, e.clientY) : null;
|
|
24424
|
+
if (!drag.surface || !drag.text || !carried || destOffset === null) { refuseTextDrop(); return; }
|
|
24425
|
+
moveDroppedText(drag.blockEl, drag.range, destBlockEl, destOffset).catch(structuralKeyFailed);
|
|
24426
|
+
}, true);
|
|
24427
|
+
// Cleared once the drag's own trailing input (deleteByDrag) has had its
|
|
24428
|
+
// turn, which comes right after dragend.
|
|
24429
|
+
document.addEventListener('dragend', () => endTextDrag(textDrag), true);
|
|
24430
|
+
}
|
|
24431
|
+
|
|
24432
|
+
// The dragged fragment, marks included: Range.cloneContents() keeps a mark
|
|
24433
|
+
// only when the range crosses it, so a selection wholly inside `**bravo**`
|
|
24434
|
+
// would come out as plain `bravo`. The inline elements between the range
|
|
24435
|
+
// and the surface are put back around it (shallow clones, attributes kept,
|
|
24436
|
+
// so a link keeps its href).
|
|
24437
|
+
function fragmentWithMarks(range, surface) {
|
|
24438
|
+
let frag = range.cloneContents();
|
|
24439
|
+
for (let n = elementOfNode(range.commonAncestorContainer); n && n !== surface && surface.contains(n);
|
|
24440
|
+
n = n.parentElement) {
|
|
24441
|
+
if (n.nodeName === 'P' || n.nodeName === 'DIV') continue; // a loose item's own paragraph
|
|
24442
|
+
const wrap = n.cloneNode(false);
|
|
24443
|
+
wrap.appendChild(frag);
|
|
24444
|
+
frag = document.createDocumentFragment();
|
|
24445
|
+
frag.appendChild(wrap);
|
|
24446
|
+
}
|
|
24447
|
+
return frag;
|
|
24448
|
+
}
|
|
24449
|
+
|
|
24450
|
+
// Fix round 1 (I2, R37): the moved fragment fitted to the drop point. A
|
|
24451
|
+
// mark it carries that the drop point already sits inside is unwrapped:
|
|
24452
|
+
// `**bravo**` dropped into `**delta**` wrote `**de**bravo**lta**`, which
|
|
24453
|
+
// reparses with bravo NOT bold. A link dropped inside a link has no
|
|
24454
|
+
// Markdown at all (links do not nest; measured, the destination link came
|
|
24455
|
+
// back broken), and inside inline code no mark can be written, so those
|
|
24456
|
+
// two are refused: false.
|
|
24457
|
+
const MARK_KIND = { STRONG: 'b', B: 'b', EM: 'i', I: 'i', DEL: 's', S: 's', STRIKE: 's', U: 'u', CODE: 'code', A: 'a' };
|
|
24458
|
+
function fitFragmentToDrop(frag, atNode, surface) {
|
|
24459
|
+
const kinds = {};
|
|
24460
|
+
for (let n = elementOfNode(atNode); n && n !== surface && surface.contains(n); n = n.parentElement) {
|
|
24461
|
+
if (MARK_KIND[n.nodeName]) kinds[MARK_KIND[n.nodeName]] = true;
|
|
24462
|
+
}
|
|
24463
|
+
const els = Array.prototype.slice.call(frag.querySelectorAll('*'));
|
|
24464
|
+
if (kinds.a && els.some((el) => el.nodeName === 'A')) return false;
|
|
24465
|
+
if (kinds.code && els.some((el) => el.nodeName !== 'BR')) return false;
|
|
24466
|
+
els.forEach((el) => {
|
|
24467
|
+
if (!kinds[MARK_KIND[el.nodeName]] || el.nodeName === 'A') return;
|
|
24468
|
+
while (el.firstChild) el.parentNode.insertBefore(el.firstChild, el);
|
|
24469
|
+
el.parentNode.removeChild(el);
|
|
24470
|
+
});
|
|
24471
|
+
return true;
|
|
24472
|
+
}
|
|
24473
|
+
// Merges `a` with its next sibling while both are the same mark
|
|
24474
|
+
// (sameSeamMark()), repeated inward, as joinSeam() does at a join.
|
|
24475
|
+
function mergeMarkSeam(a) {
|
|
24476
|
+
let n = a;
|
|
24477
|
+
while (n && sameSeamMark(n, n.nextSibling)) {
|
|
24478
|
+
const b = n.nextSibling;
|
|
24479
|
+
const inner = n.lastChild;
|
|
24480
|
+
while (b.firstChild) n.appendChild(b.firstChild);
|
|
24481
|
+
b.parentNode.removeChild(b);
|
|
24482
|
+
n = inner;
|
|
24483
|
+
}
|
|
24484
|
+
}
|
|
24485
|
+
|
|
24486
|
+
// One end of a drag move: the lines it rewrites and how to serialize it.
|
|
24487
|
+
// A paragraph or heading rewrites its own lines; a list item rewrites its
|
|
24488
|
+
// whole run (its markers are run-global), with every other item replayed
|
|
24489
|
+
// from the file. null: this end cannot be written here.
|
|
24490
|
+
function textMoveEnd(blockEl, surface) {
|
|
24491
|
+
const type = blockEl.getAttribute('data-block-type');
|
|
24492
|
+
if (type === 'li') {
|
|
24493
|
+
const run = listRunOf(blockEl);
|
|
24494
|
+
const range = run.length ? runRangeOfBlocks({ lines, blocks, stack }, run) : null;
|
|
24495
|
+
if (!range || blockOwnsNoLine(blockEl)) return null;
|
|
24496
|
+
return { kind: 'run', run, lis: [blockEl], first: range.startLine, last: range.endLine };
|
|
24497
|
+
}
|
|
24498
|
+
const rec = blockRecOf(blockEl);
|
|
24499
|
+
if (!rec || rec.endLine < rec.startLine) return null;
|
|
24500
|
+
if (inlineMd.serializeInline(surface).unsupported.length) return null;
|
|
24501
|
+
return { kind: 'block', blockEl, surface, type, depth: blockDepthOf(type, surface), id: rec.id,
|
|
24502
|
+
first: rec.startLine, last: rec.endLine };
|
|
24503
|
+
}
|
|
24504
|
+
// { text, ids } for one end, ids relative to its first line; text null when
|
|
24505
|
+
// the end is a paragraph left with no text (it is removed, as an emptied
|
|
24506
|
+
// paragraph is, R95).
|
|
24507
|
+
function textMoveEndText(end) {
|
|
24508
|
+
if (end.kind === 'run') {
|
|
24509
|
+
const carry = bystanderCarryOver(end.run, end.lis[0]);
|
|
24510
|
+
if (carry && end.lis[1]) delete carry[end.lis[1].getAttribute('data-block-id')];
|
|
24511
|
+
const out = serializeListEls(end.run, { carryOver: carry });
|
|
24512
|
+
return { text: out.md, ids: flightLineMapOf(out.lineMeta, 0).ids };
|
|
24513
|
+
}
|
|
24514
|
+
if (end.type === 'paragraph' && end.surface.textContent === '' && !end.surface.querySelector('img')) {
|
|
24515
|
+
return { text: null, ids: {} };
|
|
24516
|
+
}
|
|
24517
|
+
return { text: surfaceCommitText(end.surface, end.type, end.depth), ids: { [end.id]: 0 } };
|
|
24518
|
+
}
|
|
24519
|
+
|
|
24520
|
+
// v3.17.3 B2 (R12): the move behind a text drop. `srcRange` is the dragged
|
|
24521
|
+
// selection in `srcBlockEl`'s surface, `destOffset` the drop point as an
|
|
24522
|
+
// offset in `destBlockEl`'s surface. The dragged fragment, with its marks,
|
|
24523
|
+
// leaves the source and goes in at the drop point, and the span from the
|
|
24524
|
+
// first affected line to the last is written as ONE commitRangeEdit (one
|
|
24525
|
+
// Ctrl+Z): both ends serialized, the lines between them kept byte for byte.
|
|
24526
|
+
// Pending typing is committed first, as its own op (D4), through the same
|
|
24527
|
+
// switchAwayFrom() a ⠿ drag takes, and both blocks are found again after it
|
|
24528
|
+
// (refindBlock(), as performBlockDrop() does). From there on it is
|
|
24529
|
+
// synchronous up to commitTypingThenStructure() (the v3.13.1 guard); a
|
|
24530
|
+
// failed render keeps the moved text on screen as a pending structure (D7).
|
|
24531
|
+
async function moveDroppedText(srcBlockEl, srcRange, destBlockEl, destOffset) {
|
|
24532
|
+
const srcSurface0 = textDropSurfaceOf(srcBlockEl);
|
|
24533
|
+
if (!srcSurface0 || !srcSurface0.contains(srcRange.startContainer) ||
|
|
24534
|
+
!srcSurface0.contains(srcRange.endContainer)) { refuseTextDrop(); return; }
|
|
24535
|
+
const from = offsetInSurface(srcSurface0, srcRange.startContainer, srcRange.startOffset);
|
|
24536
|
+
const to = offsetInSurface(srcSurface0, srcRange.endContainer, srcRange.endOffset);
|
|
24537
|
+
const dragged = srcSurface0.textContent.slice(from, to);
|
|
24538
|
+
if (!(to > from)) { refuseTextDrop(); return; }
|
|
24539
|
+
// v3.15.0 R100 (b), as performBlockDrop(): no identity is taken while a
|
|
24540
|
+
// commit is in flight; both ends must still be on screen once it lands.
|
|
24541
|
+
if (!commitsSettled()) {
|
|
24542
|
+
await settleCommits();
|
|
24543
|
+
if (!srcBlockEl.isConnected || !destBlockEl.isConnected) {
|
|
24544
|
+
showError(DROPPED_GESTURE_MESSAGE, null, null);
|
|
24545
|
+
return;
|
|
24546
|
+
}
|
|
24547
|
+
}
|
|
24548
|
+
const srcIdentity = captureBlockIdentity(srcBlockEl);
|
|
24549
|
+
const destIdentity = captureBlockIdentity(destBlockEl);
|
|
24550
|
+
const srcSelf = ownsOpenSession(srcBlockEl);
|
|
24551
|
+
const destSelf = ownsOpenSession(destBlockEl);
|
|
24552
|
+
if (!(await switchAwayFrom())) return;
|
|
24553
|
+
if (!commitsSettled()) await settleCommits();
|
|
24554
|
+
const src = refindBlock(srcBlockEl, srcIdentity, srcSelf);
|
|
24555
|
+
const dest = refindBlock(destBlockEl, destIdentity, destSelf);
|
|
24556
|
+
const srcSurface = src ? textDropSurfaceOf(src) : null;
|
|
24557
|
+
const destSurface = dest ? textDropSurfaceOf(dest) : null;
|
|
24558
|
+
if (!srcSurface || !destSurface || src === dest ||
|
|
24559
|
+
srcSurface.textContent.slice(from, to) !== dragged || destOffset > destSurface.textContent.length) {
|
|
24560
|
+
showError(DROPPED_GESTURE_MESSAGE, null, null);
|
|
24561
|
+
return;
|
|
24562
|
+
}
|
|
24563
|
+
let ends;
|
|
24564
|
+
const a = textMoveEnd(src, srcSurface);
|
|
24565
|
+
const b = textMoveEnd(dest, destSurface);
|
|
24566
|
+
if (!a || !b) { refuseTextDrop(); return; }
|
|
24567
|
+
if (a.kind === 'run' && b.kind === 'run' && a.run[0] === b.run[0]) {
|
|
24568
|
+
if (!listRunSupportsStructuralEdit(a.run, [src, dest])) { refuseTextDrop(); return; }
|
|
24569
|
+
ends = [{ kind: 'run', run: a.run, lis: [src, dest], first: a.first, last: a.last }];
|
|
24570
|
+
} else {
|
|
24571
|
+
if ((a.kind === 'run' && !listRunSupportsStructuralEdit(a.run, src)) ||
|
|
24572
|
+
(b.kind === 'run' && !listRunSupportsStructuralEdit(b.run, dest))) { refuseTextDrop(); return; }
|
|
24573
|
+
ends = a.first < b.first ? [a, b] : [b, a];
|
|
24574
|
+
if (ends[0].last >= ends[1].first) { refuseTextDrop(); return; }
|
|
24575
|
+
}
|
|
24576
|
+
const range = document.createRange();
|
|
24577
|
+
const p = pointInSurface(srcSurface, from);
|
|
24578
|
+
const q = pointInSurface(srcSurface, to);
|
|
24579
|
+
range.setStart(p[0], p[1]);
|
|
24580
|
+
range.setEnd(q[0], q[1]);
|
|
24581
|
+
// The fragment is built, fitted to where it lands and asked of the inline
|
|
24582
|
+
// serializer before anything on screen changes; the source's own nodes
|
|
24583
|
+
// are untouched by all three (cloneContents()).
|
|
24584
|
+
const at = pointInSurface(destSurface, destOffset);
|
|
24585
|
+
const frag = fragmentWithMarks(range, srcSurface);
|
|
24586
|
+
if (!fitFragmentToDrop(frag, at[0], destSurface)) { refuseTextDrop(); return; }
|
|
24587
|
+
// A heading is one line: a hard break in the moved text becomes a space.
|
|
24588
|
+
if (dest.getAttribute('data-block-type') === 'heading') {
|
|
24589
|
+
Array.prototype.slice.call(frag.querySelectorAll('br')).forEach((br) => {
|
|
24590
|
+
br.parentNode.replaceChild(document.createTextNode(' '), br);
|
|
24591
|
+
});
|
|
24592
|
+
}
|
|
24593
|
+
const probe = document.createElement('p');
|
|
24594
|
+
probe.appendChild(frag.cloneNode(true));
|
|
24595
|
+
if (inlineMd.serializeInline(probe).unsupported.length) { refuseTextDrop(); return; }
|
|
24596
|
+
if (currentBurst) endBurstWithoutResolve();
|
|
24597
|
+
startStructuralKey();
|
|
24598
|
+
const movedLen = frag.textContent.length;
|
|
24599
|
+
withFocusoutSuppressed(() => {
|
|
24600
|
+
range.deleteContents();
|
|
24601
|
+
dropEmptyInlines(srcSurface);
|
|
24602
|
+
srcSurface.normalize();
|
|
24603
|
+
const firstIn = frag.firstChild;
|
|
24604
|
+
const lastIn = frag.lastChild;
|
|
24605
|
+
const ins = document.createRange();
|
|
24606
|
+
ins.setStart(at[0], at[1]);
|
|
24607
|
+
ins.collapse(true);
|
|
24608
|
+
ins.insertNode(frag);
|
|
24609
|
+
// A moved mark that lands against one of the same kind becomes one
|
|
24610
|
+
// mark (`**bravo**` dropped at the start of `**delta**` is
|
|
24611
|
+
// `**bravodelta**`, not two bold runs with an empty HTML comment
|
|
24612
|
+
// between them). normalize()
|
|
24613
|
+
// first: insertNode() splits the text node it lands in and leaves an
|
|
24614
|
+
// empty one at the seam. Right seam first: the left one may absorb the
|
|
24615
|
+
// first inserted node. A text node normalize() merged away has no mark
|
|
24616
|
+
// seam to join.
|
|
24617
|
+
destSurface.normalize();
|
|
24618
|
+
if (lastIn && lastIn.isConnected) mergeMarkSeam(lastIn);
|
|
24619
|
+
if (firstIn && firstIn.isConnected && firstIn.previousSibling) mergeMarkSeam(firstIn.previousSibling);
|
|
24620
|
+
dropEmptyInlines(destSurface);
|
|
24621
|
+
destSurface.normalize();
|
|
24622
|
+
structureMutated = true;
|
|
24623
|
+
});
|
|
24624
|
+
const first = ends[0].first;
|
|
24625
|
+
const last = ends[ends.length - 1].last;
|
|
24626
|
+
const serialize = () => {
|
|
24627
|
+
const t0 = textMoveEndText(ends[0]);
|
|
24628
|
+
const ids = {};
|
|
24629
|
+
const place = (rel, at) => Object.keys(rel).forEach((id) => { ids[id] = at + rel[id]; });
|
|
24630
|
+
if (ends.length === 1) {
|
|
24631
|
+
place(t0.ids, first);
|
|
24632
|
+
return { text: t0.text, lineMap: { ids, provisional: null } };
|
|
24633
|
+
}
|
|
24634
|
+
const t1 = textMoveEndText(ends[1]);
|
|
24635
|
+
let gap = lines.slice(ends[0].last, ends[1].first - 1);
|
|
24636
|
+
// An emptied paragraph goes with one blank line next to it.
|
|
24637
|
+
if (t0.text === null && gap.length && gap[0].trim() === '') gap = gap.slice(1);
|
|
24638
|
+
if (t1.text === null && gap.length && gap[gap.length - 1].trim() === '') gap = gap.slice(0, -1);
|
|
24639
|
+
const head = t0.text === null ? [] : t0.text.split('\n');
|
|
24640
|
+
place(t0.ids, first);
|
|
24641
|
+
place(t1.ids, first + head.length + gap.length);
|
|
24642
|
+
const out = head.concat(gap, t1.text === null ? [] : t1.text.split('\n'));
|
|
24643
|
+
// Final review I3 (R56): `gap` is every line between the two ends,
|
|
24644
|
+
// sliced from `lines` (only a blank next to an emptied paragraph is
|
|
24645
|
+
// dropped), so the text carries a definition there already. Unflagged,
|
|
24646
|
+
// keepDefinitionLines() appended a second copy. The one-end text (one
|
|
24647
|
+
// list run) has no gap: a list run never spans a definition.
|
|
24648
|
+
return { text: out.join('\n'), lineMap: { ids, provisional: null }, carriesGap: true };
|
|
24649
|
+
};
|
|
24650
|
+
const s = serialize();
|
|
24651
|
+
const destId = dest.getAttribute('data-block-id');
|
|
24652
|
+
const ok = await commitTypingThenStructure({ typed: null, text: s.text, carriesGap: s.carriesGap === true,
|
|
24653
|
+
range: () => [first, last], oldRange: [first, last], lineMap: s.lineMap,
|
|
24654
|
+
pending: { surfaces: [srcSurface, destSurface], serialize } });
|
|
24655
|
+
if (!ok) return;
|
|
24656
|
+
// The moved text stays selected where it landed, as the browser's own
|
|
24657
|
+
// move leaves it.
|
|
24658
|
+
const landed = s.lineMap.ids[destId] !== undefined ? blockElAtLine(s.lineMap.ids[destId]) : null;
|
|
24659
|
+
const surface = landed ? blockContentEl(landed) : null;
|
|
24660
|
+
if (!surface || !surface.classList.contains('ed-wys-armed')) return;
|
|
24661
|
+
surface.focus();
|
|
24662
|
+
restoreSelection(surface, destOffset, destOffset + movedLen);
|
|
24663
|
+
}
|
|
24664
|
+
|
|
23231
24665
|
// ── 追加 2: paste ──────────────────────────────────────────────────────
|
|
23232
24666
|
// Reads a real DataTransfer into the plain { 'mime/type': string | Blob }
|
|
23233
24667
|
// shape paste-md.js's pickPayload() documents. Only ONE image flavour is
|
|
@@ -23454,6 +24888,7 @@
|
|
|
23454
24888
|
paintSaveStatus(documentIsDirty() ? 'dirty' : 'clean');
|
|
23455
24889
|
wireToolbarTracking();
|
|
23456
24890
|
wireImageDrop();
|
|
24891
|
+
wireTextDrop();
|
|
23457
24892
|
wirePlainPasteArming();
|
|
23458
24893
|
// No separate first paint of the edge hint here: on this call the
|
|
23459
24894
|
// remembered toolbar signature is still its initial empty string, which no
|
|
@@ -23765,6 +25200,7 @@
|
|
|
23765
25200
|
// Both presses used to get the one cached `switching` promise and run
|
|
23766
25201
|
// two save()s back to back, and the second 409'd into the conflict card.
|
|
23767
25202
|
if (switching) { switching.then((ok) => { if (ok) saveAfterLastSave(); }); return; }
|
|
25203
|
+
markBurstForSave();
|
|
23768
25204
|
switchAwayFrom().then((ok) => { if (ok) save(); });
|
|
23769
25205
|
return;
|
|
23770
25206
|
}
|
|
@@ -23783,6 +25219,25 @@
|
|
|
23783
25219
|
|
|
23784
25220
|
const inTextarea = e.target && e.target.tagName === 'TEXTAREA' &&
|
|
23785
25221
|
e.target.classList.contains('ed-raw');
|
|
25222
|
+
// v3.17.3 fix round 1 (R47): a raw box whose Ctrl+Enter / ✓ commit is
|
|
25223
|
+
// rendering has already handed its text to the document — Ctrl+Z / Ctrl+Y
|
|
25224
|
+
// there step the document once the write lands, not the box's native
|
|
25225
|
+
// history. Measured before (A12's steps, focus still in the box): the
|
|
25226
|
+
// press undid nothing, both engines, at 50 and 500 ms.
|
|
25227
|
+
if (inTextarea && e.target === rawBoxCommitting && (e.ctrlKey || e.metaKey) && !e.altKey &&
|
|
25228
|
+
(e.key === 'z' || e.key === 'Z' || e.key === 'y')) {
|
|
25229
|
+
e.preventDefault();
|
|
25230
|
+
// Final review M5: as the toolbar's 復原 / 重做 do — the caret is taken
|
|
25231
|
+
// once the write has landed and put back after the step, and a failure
|
|
25232
|
+
// is shown rather than left as an unhandled rejection.
|
|
25233
|
+
const step = e.key === 'y' || e.shiftKey ? redo : undo;
|
|
25234
|
+
settledHistoryStep(async () => {
|
|
25235
|
+
const saved = captureCaretForSave();
|
|
25236
|
+
await step();
|
|
25237
|
+
restoreCaretForSave(saved);
|
|
25238
|
+
}).catch(showUnexpectedFailure);
|
|
25239
|
+
return;
|
|
25240
|
+
}
|
|
23786
25241
|
if (inTextarea) return; // Ctrl+Enter/Esc handled by openRawEditor()'s own per-instance listener
|
|
23787
25242
|
|
|
23788
25243
|
// v3.13.1 T1: a key that edits or moves, pressed while a commit is in
|
|
@@ -23952,6 +25407,12 @@
|
|
|
23952
25407
|
if (!e.shiftKey && e.key === 'Enter') {
|
|
23953
25408
|
const from = selectionFocusBlockEl();
|
|
23954
25409
|
const members = selectionLib ? selectionLib.membersOf(blockSelection, blocks) : [];
|
|
25410
|
+
// v3.17.3 A1: a selected image block shows its toolbar instead, and
|
|
25411
|
+
// keeps the block selected for Esc to come back to.
|
|
25412
|
+
if (from && members.length === 1 && isImageBlock(from) && openImageToolbarByKeyboard(from)) {
|
|
25413
|
+
e.preventDefault();
|
|
25414
|
+
return;
|
|
25415
|
+
}
|
|
23955
25416
|
if (from && members.length === 1 && !waveDiagramInside(from)) {
|
|
23956
25417
|
e.preventDefault();
|
|
23957
25418
|
clearBlockSelection();
|
|
@@ -24077,6 +25538,13 @@
|
|
|
24077
25538
|
return;
|
|
24078
25539
|
}
|
|
24079
25540
|
|
|
25541
|
+
// Fix round 1 (R47): NOT waited for, unlike the toolbar's. A Ctrl+Z
|
|
25542
|
+
// during a render is the R77 / R83 / R84 machinery's own input (the undo
|
|
25543
|
+
// supersedes the render in flight, and its failures reconcile); routed
|
|
25544
|
+
// through settledHistoryStep() it waited for the split instead, and 33
|
|
25545
|
+
// render-guard: / raw-box: runs (both engines) lost their third render.
|
|
25546
|
+
// Only the raw box whose commit is rendering waits (above), because there
|
|
25547
|
+
// the press never reached the document at all.
|
|
24080
25548
|
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key === 'z') {
|
|
24081
25549
|
e.preventDefault();
|
|
24082
25550
|
undo();
|
|
@@ -24385,6 +25853,15 @@
|
|
|
24385
25853
|
if (e.cancelable) e.preventDefault();
|
|
24386
25854
|
noteStaleScreenDrop();
|
|
24387
25855
|
}, true);
|
|
25856
|
+
// v3.17.3 B2 (R12): a text drop the editor took (wireTextDrop()) is written
|
|
25857
|
+
// by moveDroppedText() alone; the browser's own halves of the move (the
|
|
25858
|
+
// source's deleteByDrag, an insertFromDrop) never reach a surface, should
|
|
25859
|
+
// an engine raise them after the cancelled drop (neither did, measured).
|
|
25860
|
+
document.addEventListener('beforeinput', (e) => {
|
|
25861
|
+
if (!textDrag || !textDrag.handled) return;
|
|
25862
|
+
if (e.inputType !== 'deleteByDrag' && e.inputType !== 'insertFromDrop') return;
|
|
25863
|
+
e.preventDefault();
|
|
25864
|
+
}, true);
|
|
24388
25865
|
document.addEventListener('compositionend', (e) => {
|
|
24389
25866
|
if (!staleScreenInput(e.target) || !e.data) return;
|
|
24390
25867
|
takeBackComposedText(e.data);
|
|
@@ -25714,6 +27191,7 @@
|
|
|
25714
27191
|
b.type = 'button';
|
|
25715
27192
|
b.className = 'ed-imgtb-btn' + (extraClass ? ' ' + extraClass : '');
|
|
25716
27193
|
b.textContent = label;
|
|
27194
|
+
b.tabIndex = -1; // 0 only while the toolbar is open by keyboard (A1)
|
|
25717
27195
|
b.addEventListener('click', (e) => { e.stopPropagation(); onClick(); });
|
|
25718
27196
|
imgTbButtons.appendChild(b);
|
|
25719
27197
|
return b;
|
|
@@ -25729,6 +27207,14 @@
|
|
|
25729
27207
|
let imgTbBlock = null; // the .ed-block the toolbar is drawn for
|
|
25730
27208
|
let imgTbHideTimer = null;
|
|
25731
27209
|
let imgTbAlt = null; // { identity, original } while the input is showing
|
|
27210
|
+
// v3.17.3 A1: opened from a selected image block with Enter. The buttons are
|
|
27211
|
+
// then in the Tab order (tabindex 0), the toolbar stays until Esc or focus
|
|
27212
|
+
// leaves it, and it is repositioned on scroll instead of hidden.
|
|
27213
|
+
let imgTbKeyboard = false;
|
|
27214
|
+
// v3.17.3 A2: alt text typed and not applied when the image left the
|
|
27215
|
+
// viewport and the field went away; the next 替代文字 on the same source
|
|
27216
|
+
// starts from it. { source, value } or null.
|
|
27217
|
+
let imgTbAltDraft = null;
|
|
25732
27218
|
|
|
25733
27219
|
// An image block: a paragraph whose source is one image (D16) and whose
|
|
25734
27220
|
// rendered content is exactly one img. .drawio / .xml keep their own flow.
|
|
@@ -25756,21 +27242,35 @@
|
|
|
25756
27242
|
if (imgTbHideTimer !== null) { clearTimeout(imgTbHideTimer); imgTbHideTimer = null; }
|
|
25757
27243
|
}
|
|
25758
27244
|
function scheduleImageToolbarHide() {
|
|
25759
|
-
if (imgTb.hidden || imgTbAlt || imgTbHideTimer !== null) return;
|
|
27245
|
+
if (imgTb.hidden || imgTbAlt || imgTbKeyboard || imgTbHideTimer !== null) return;
|
|
25760
27246
|
imgTbHideTimer = setTimeout(() => { imgTbHideTimer = null; if (!imgTbAlt) hideImageToolbar(); }, IMGTB_HIDE_MS);
|
|
25761
27247
|
}
|
|
25762
|
-
|
|
27248
|
+
// `opts.keepDraft` (A2): the field went away because its image left the
|
|
27249
|
+
// viewport, so the text typed in it is kept for the next 替代文字 on the
|
|
27250
|
+
// same source. Every other way out discards it.
|
|
27251
|
+
function hideImageToolbar(opts) {
|
|
25763
27252
|
cancelImageToolbarHide();
|
|
25764
27253
|
const wasAlt = imgTbAlt;
|
|
27254
|
+
const wasKeyboard = imgTbKeyboard;
|
|
27255
|
+
const hadFocus = imgTb.contains(document.activeElement);
|
|
27256
|
+
if (wasAlt && opts && opts.keepDraft && imgTbInput.value !== wasAlt.original) {
|
|
27257
|
+
const rec = imgTbBlock && imgTbBlock.isConnected ? blockRecOf(imgTbBlock) : null;
|
|
27258
|
+
imgTbAltDraft = rec ? { source: blockSourceOf(rec), value: imgTbInput.value } : null;
|
|
27259
|
+
} else imgTbAltDraft = null;
|
|
25765
27260
|
imgTbAlt = null;
|
|
25766
27261
|
imgTbBlock = null;
|
|
27262
|
+
imgTbKeyboard = false;
|
|
27263
|
+
imgTbButtonsEl.forEach((b) => { b.tabIndex = -1; });
|
|
25767
27264
|
if (wasAlt) {
|
|
25768
27265
|
imgTbInput.hidden = true;
|
|
25769
27266
|
imgTbButtons.hidden = false;
|
|
25770
27267
|
if (document.activeElement === imgTbInput) imgTbInput.blur();
|
|
27268
|
+
setDirty();
|
|
25771
27269
|
}
|
|
25772
27270
|
if (imgTb.hidden) return;
|
|
25773
27271
|
imgTb.hidden = true;
|
|
27272
|
+
// Opened by keyboard: focus goes back to the block selection it came from.
|
|
27273
|
+
if (wasKeyboard && hadFocus && blockSelection) applySelectionClasses();
|
|
25774
27274
|
}
|
|
25775
27275
|
function reconcileImageToolbar() {
|
|
25776
27276
|
if (imgTbBlock !== null && !imgTbBlock.isConnected) hideImageToolbar();
|
|
@@ -25778,10 +27278,10 @@
|
|
|
25778
27278
|
function placeImageToolbar(blockEl) {
|
|
25779
27279
|
const img = blockEl.querySelector('img');
|
|
25780
27280
|
const r = img ? img.getBoundingClientRect() : null;
|
|
25781
|
-
if (!r || (r.width === 0 && r.height === 0)) { hideImageToolbar(); return false; }
|
|
27281
|
+
if (!r || (r.width === 0 && r.height === 0)) { hideImageToolbar({ keepDraft: true }); return false; }
|
|
25782
27282
|
imgTb.hidden = false;
|
|
25783
27283
|
const top = Math.max(r.top + IMGTB_INSET_PX, toolbarTopInset() + 4);
|
|
25784
|
-
if (top > r.bottom - 4) { hideImageToolbar(); return false; }
|
|
27284
|
+
if (top > r.bottom - 4) { hideImageToolbar({ keepDraft: true }); return false; }
|
|
25785
27285
|
imgTb.style.top = Math.round(top) + 'px';
|
|
25786
27286
|
imgTb.style.left = Math.round(Math.max(4, Math.min(r.left + IMGTB_INSET_PX,
|
|
25787
27287
|
document.documentElement.clientWidth - imgTb.offsetWidth - 4))) + 'px';
|
|
@@ -25800,24 +27300,40 @@
|
|
|
25800
27300
|
// everything that could have moved it (R104: by source, and only when it is
|
|
25801
27301
|
// the one such block), one commitRangeEdit over its line, nothing written
|
|
25802
27302
|
// when the source reads the same. false: refused or the render failed.
|
|
25803
|
-
|
|
25804
|
-
|
|
27303
|
+
//
|
|
27304
|
+
// `trace` (A4), when given, learns whether the file now refers to the new
|
|
27305
|
+
// destination: `referenced` is false after a refusal or a failed render
|
|
27306
|
+
// (rolled back), true when it landed, was superseded by a newer render
|
|
27307
|
+
// (the commit stands), or changed nothing.
|
|
27308
|
+
async function applyImageEdit(blockEl, identity, patch, trace) {
|
|
27309
|
+
const note = (v) => { if (trace) trace.referenced = v; };
|
|
27310
|
+
if (!identity || !inlineMd) { note(false); return false; }
|
|
25805
27311
|
await settleCommits();
|
|
25806
27312
|
const ok = await switchAwayFrom();
|
|
25807
|
-
if (!ok) return false;
|
|
27313
|
+
if (!ok) { note(false); return false; }
|
|
25808
27314
|
let el = blockEl && blockEl.isConnected ? blockEl : null;
|
|
25809
27315
|
let rec = el ? blockRecOf(el) : null;
|
|
25810
27316
|
if (!rec || blockSourceOf(rec) !== identity.source || identity.reconcileEpoch !== reconcileEpoch) {
|
|
25811
27317
|
el = uniqueBlockElBySource(identity);
|
|
25812
27318
|
rec = el ? blockRecOf(el) : null;
|
|
25813
27319
|
}
|
|
25814
|
-
if (!el || !rec || reconcileBlocksCommits()) { showError(DROPPED_GESTURE_MESSAGE, null, null); return false; }
|
|
27320
|
+
if (!el || !rec || reconcileBlocksCommits()) { note(false); showError(DROPPED_GESTURE_MESSAGE, null, null); return false; }
|
|
25815
27321
|
const source = blockSourceOf(rec);
|
|
25816
27322
|
const next = inlineMd.rewriteImage(source, patch);
|
|
25817
|
-
if (next === source) return true;
|
|
27323
|
+
if (next === source) { note(true); return true; }
|
|
25818
27324
|
const r = await commitFencedRangeAndRender(rec.startLine, rec.endLine, next);
|
|
27325
|
+
note(r !== false && r !== null);
|
|
25819
27326
|
return r === true;
|
|
25820
27327
|
}
|
|
27328
|
+
// A4: a file this page uploaded and the file then did not take. The server
|
|
27329
|
+
// deletes only what its own POST /api/asset wrote; any failure is ignored,
|
|
27330
|
+
// the orphan is what it was before this call.
|
|
27331
|
+
function discardUploadedAsset(assetPath) {
|
|
27332
|
+
try {
|
|
27333
|
+
fetch('/api/asset?fileId=' + encodeURIComponent(String(ED.fileId)) + '&path=' + encodeURIComponent(assetPath),
|
|
27334
|
+
{ method: 'DELETE', headers: { 'content-type': 'application/json' } }).catch(() => {});
|
|
27335
|
+
} catch (e) { /* ignored */ }
|
|
27336
|
+
}
|
|
25821
27337
|
|
|
25822
27338
|
// 替換 (and T8's drop): the picked file replaces the picture, keeping alt
|
|
25823
27339
|
// and title. A file that is not a raster picture writes nothing (R39).
|
|
@@ -25848,17 +27364,36 @@
|
|
|
25848
27364
|
if (!live) { showError(DROPPED_GESTURE_MESSAGE, null, null); return false; }
|
|
25849
27365
|
const path = await uploadImageBlob(file);
|
|
25850
27366
|
if (!path) return false;
|
|
25851
|
-
|
|
27367
|
+
// A4: the paragraph can change while the upload is out; the edit is then
|
|
27368
|
+
// refused, and the file it uploaded must not stay behind in assets/.
|
|
27369
|
+
const trace = { referenced: undefined };
|
|
27370
|
+
let applied = false;
|
|
27371
|
+
try {
|
|
27372
|
+
applied = await applyImageEdit(el, live, { dest: markdownImageDest(path) }, trace);
|
|
27373
|
+
} finally {
|
|
27374
|
+
if (!applied && trace.referenced === false) discardUploadedAsset(path);
|
|
27375
|
+
}
|
|
27376
|
+
return applied;
|
|
25852
27377
|
}
|
|
25853
27378
|
|
|
25854
|
-
|
|
27379
|
+
// `refocus`: focus goes back to the button that opened the field. Only Esc
|
|
27380
|
+
// asks for it, and a blur whose focus stays inside the toolbar. Final review
|
|
27381
|
+
// I2: a blur to anything else (a click into the text) is where the user is
|
|
27382
|
+
// going, and chrome never steals an edit — measured before, the click left
|
|
27383
|
+
// focus on 替代文字 and the next keys never reached the text.
|
|
27384
|
+
function exitImageAltMode(refocus) {
|
|
25855
27385
|
if (!imgTbAlt) return;
|
|
25856
27386
|
imgTbAlt = null;
|
|
25857
|
-
|
|
27387
|
+
imgTbAltDraft = null;
|
|
25858
27388
|
imgTbButtons.hidden = false;
|
|
27389
|
+
// By keyboard, focus goes to the button that opened the field (moved
|
|
27390
|
+
// before the field is hidden, so the toolbar never sees focus leave).
|
|
27391
|
+
if (imgTbKeyboard && refocus === true) imgTbAltButton.focus({ preventScroll: true });
|
|
27392
|
+
imgTbInput.hidden = true;
|
|
25859
27393
|
if (document.activeElement === imgTbInput) imgTbInput.blur();
|
|
25860
27394
|
if (imgTbBlock) placeImageToolbar(imgTbBlock);
|
|
25861
27395
|
scheduleImageToolbarHide();
|
|
27396
|
+
setDirty();
|
|
25862
27397
|
}
|
|
25863
27398
|
async function openImageAltMode() {
|
|
25864
27399
|
const first = imgTbBlock;
|
|
@@ -25874,15 +27409,25 @@
|
|
|
25874
27409
|
const parsed = inlineMd.parseImageParagraph(blockSourceOf(blockRecOf(live)));
|
|
25875
27410
|
imgTbBlock = live;
|
|
25876
27411
|
imgTbAlt = { identity: captureBlockIdentity(live), original: parsed.alt };
|
|
27412
|
+
const draft = imgTbAltDraft && imgTbAltDraft.source === blockSourceOf(blockRecOf(live)) ? imgTbAltDraft : null;
|
|
27413
|
+
imgTbAltDraft = null;
|
|
25877
27414
|
imgTbButtons.hidden = true;
|
|
25878
27415
|
imgTbInput.hidden = false;
|
|
25879
|
-
imgTbInput.value = parsed.alt;
|
|
27416
|
+
imgTbInput.value = draft ? draft.value : parsed.alt;
|
|
25880
27417
|
if (!placeImageToolbar(live)) return;
|
|
25881
27418
|
imgTbInput.focus({ preventScroll: true });
|
|
25882
|
-
imgTbInput.
|
|
27419
|
+
if (draft) imgTbInput.setSelectionRange(draft.value.length, draft.value.length);
|
|
27420
|
+
else imgTbInput.select();
|
|
27421
|
+
setDirty();
|
|
25883
27422
|
}
|
|
25884
27423
|
imgTbInput.addEventListener('keydown', (e) => {
|
|
25885
|
-
if (e.key === 'Escape') {
|
|
27424
|
+
if (e.key === 'Escape') {
|
|
27425
|
+
// A5: an Esc that belongs to an IME composition cancels the composition,
|
|
27426
|
+
// not the field (the Enter branch below has the same guard). It stays
|
|
27427
|
+
// off the document handlers, whose Esc rungs would act on it.
|
|
27428
|
+
if (e.isComposing || e.keyCode === 229) { e.stopPropagation(); return; }
|
|
27429
|
+
e.preventDefault(); e.stopPropagation(); exitImageAltMode(true); return;
|
|
27430
|
+
}
|
|
25886
27431
|
if (e.key !== 'Enter' || e.isComposing) return;
|
|
25887
27432
|
e.preventDefault();
|
|
25888
27433
|
e.stopPropagation();
|
|
@@ -25893,7 +27438,12 @@
|
|
|
25893
27438
|
if (!alt || value === alt.original) return;
|
|
25894
27439
|
applyImageEdit(blk, alt.identity, { alt: value }).catch(showUnexpectedFailure);
|
|
25895
27440
|
});
|
|
25896
|
-
imgTbInput.addEventListener('blur', () => {
|
|
27441
|
+
imgTbInput.addEventListener('blur', (e) => {
|
|
27442
|
+
if (!imgTbAlt) return;
|
|
27443
|
+
const to = e.relatedTarget;
|
|
27444
|
+
exitImageAltMode(!!(to && to.nodeType === 1 && to.closest('.ed-imgtb')));
|
|
27445
|
+
});
|
|
27446
|
+
imgTbInput.addEventListener('input', () => { setDirty(); });
|
|
25897
27447
|
|
|
25898
27448
|
// Final review I1: the 🖼 path's order (pickAndInsertImage()). The press
|
|
25899
27449
|
// resolves any open session first and takes the block's identity while its
|
|
@@ -25916,7 +27466,7 @@
|
|
|
25916
27466
|
if (!blk || !blk.isConnected) return;
|
|
25917
27467
|
pickReplacementImage(blk).catch(showUnexpectedFailure);
|
|
25918
27468
|
});
|
|
25919
|
-
addImgTbButton('替代文字', '', () => { openImageAltMode().catch(showUnexpectedFailure); });
|
|
27469
|
+
const imgTbAltButton = addImgTbButton('替代文字', '', () => { openImageAltMode().catch(showUnexpectedFailure); });
|
|
25920
27470
|
addImgTbButton('刪除', 'ed-imgtb-delete', () => {
|
|
25921
27471
|
const blk = imgTbBlock;
|
|
25922
27472
|
hideImageToolbar();
|
|
@@ -25925,21 +27475,79 @@
|
|
|
25925
27475
|
deleteBlockViaGutter(blk).catch(showUnexpectedFailure);
|
|
25926
27476
|
});
|
|
25927
27477
|
|
|
27478
|
+
const imgTbButtonsEl = [].slice.call(imgTbButtons.children);
|
|
27479
|
+
|
|
27480
|
+
// A1: the keyboard way in. Enter on one selected image block shows the
|
|
27481
|
+
// toolbar and puts focus on 替換; Tab / Shift+Tab and the left / right
|
|
27482
|
+
// arrows walk the three buttons (round the ends), Enter or Space presses
|
|
27483
|
+
// one, Esc goes back to the block selection. The block stays selected the
|
|
27484
|
+
// whole time. false: nothing was shown (the caller keeps its old Enter).
|
|
27485
|
+
function openImageToolbarByKeyboard(blockEl) {
|
|
27486
|
+
if (waveEditorIsModal() || !blockEl || !blockEl.isConnected || !isImageBlock(blockEl)) return false;
|
|
27487
|
+
cancelImageToolbarHide();
|
|
27488
|
+
if (imgTbAlt) exitImageAltMode();
|
|
27489
|
+
try { blockEl.scrollIntoView({ block: 'nearest' }); } catch (e) { /* ignored */ }
|
|
27490
|
+
imgTbBlock = blockEl;
|
|
27491
|
+
imgTbButtons.hidden = false;
|
|
27492
|
+
imgTbInput.hidden = true;
|
|
27493
|
+
if (!placeImageToolbar(blockEl)) return false;
|
|
27494
|
+
imgTbKeyboard = true;
|
|
27495
|
+
imgTbButtonsEl.forEach((b) => { b.tabIndex = 0; });
|
|
27496
|
+
imgTbButtonsEl[0].focus({ preventScroll: true });
|
|
27497
|
+
return true;
|
|
27498
|
+
}
|
|
27499
|
+
imgTb.addEventListener('keydown', (e) => {
|
|
27500
|
+
if (e.target === imgTbInput || !imgTbKeyboard) return;
|
|
27501
|
+
if (e.ctrlKey || e.metaKey) return; // Ctrl+S and the like stay the document's
|
|
27502
|
+
// Nothing else may reach the document handlers: with a block selected
|
|
27503
|
+
// they would read Enter as "open the source" and Tab as "next block".
|
|
27504
|
+
e.stopPropagation();
|
|
27505
|
+
if (e.key === 'Escape') { e.preventDefault(); hideImageToolbar(); return; }
|
|
27506
|
+
const at = imgTbButtonsEl.indexOf(document.activeElement);
|
|
27507
|
+
const step = e.key === 'ArrowRight' || (e.key === 'Tab' && !e.shiftKey) ? 1
|
|
27508
|
+
: e.key === 'ArrowLeft' || (e.key === 'Tab' && e.shiftKey) ? -1 : 0;
|
|
27509
|
+
if (step === 0) return;
|
|
27510
|
+
e.preventDefault();
|
|
27511
|
+
const n = imgTbButtonsEl.length;
|
|
27512
|
+
imgTbButtonsEl[((at < 0 ? 0 : at) + step + n) % n].focus({ preventScroll: true });
|
|
27513
|
+
});
|
|
27514
|
+
// Focus leaving the toolbar for good (a click elsewhere) ends the keyboard
|
|
27515
|
+
// mode. Decided after the event: moving focus between the buttons, or into
|
|
27516
|
+
// the alt field, passes through a moment with none.
|
|
27517
|
+
imgTb.addEventListener('focusout', () => {
|
|
27518
|
+
if (!imgTbKeyboard) return;
|
|
27519
|
+
setTimeout(() => {
|
|
27520
|
+
if (imgTbKeyboard && !imgTbAlt && !imgTb.hidden && !imgTb.contains(document.activeElement)) hideImageToolbar();
|
|
27521
|
+
}, 0);
|
|
27522
|
+
});
|
|
27523
|
+
|
|
25928
27524
|
// Presses keep focus where it is; the alt input is the one thing that takes it.
|
|
25929
27525
|
imgTb.addEventListener('mousedown', (e) => { if (e.target !== imgTbInput) e.preventDefault(); });
|
|
25930
27526
|
document.addEventListener('mouseover', (ev) => {
|
|
25931
27527
|
const t = ev.target;
|
|
25932
|
-
if (!t || typeof t.closest !== 'function' || imgTbAlt) return;
|
|
27528
|
+
if (!t || typeof t.closest !== 'function' || imgTbAlt || imgTbKeyboard) return;
|
|
25933
27529
|
if (t.closest('.ed-imgtb')) { cancelImageToolbarHide(); return; }
|
|
25934
27530
|
const blk = t.closest('.ed-block[data-block-type="paragraph"]');
|
|
25935
27531
|
if (blk && isImageBlock(blk)) showImageToolbarFor(blk); else scheduleImageToolbarHide();
|
|
25936
27532
|
}, true);
|
|
25937
27533
|
document.documentElement.addEventListener('mouseleave', () => { scheduleImageToolbarHide(); });
|
|
27534
|
+
// A2: while the alt field is open (or the toolbar was opened by keyboard) a
|
|
27535
|
+
// scroll or resize moves the toolbar with its image instead of closing it;
|
|
27536
|
+
// it goes only when the image has left the viewport, and the typed text is
|
|
27537
|
+
// kept for the next 替代文字 on the same source. A hover toolbar still closes
|
|
27538
|
+
// at once.
|
|
27539
|
+
function repositionOrHideImageToolbar() {
|
|
27540
|
+
if (!(imgTbAlt || imgTbKeyboard)) { hideImageToolbar(); return; }
|
|
27541
|
+
const img = imgTbBlock && imgTbBlock.isConnected ? imgTbBlock.querySelector('img') : null;
|
|
27542
|
+
const r = img ? img.getBoundingClientRect() : null;
|
|
27543
|
+
if (r && r.bottom > 0 && r.top < document.documentElement.clientHeight) placeImageToolbar(imgTbBlock);
|
|
27544
|
+
else hideImageToolbar({ keepDraft: true });
|
|
27545
|
+
}
|
|
25938
27546
|
document.addEventListener('scroll', (ev) => {
|
|
25939
27547
|
if (ev.target === imgTbInput || imgTb.contains(ev.target)) return;
|
|
25940
|
-
|
|
27548
|
+
repositionOrHideImageToolbar();
|
|
25941
27549
|
}, { passive: true, capture: true });
|
|
25942
|
-
window.addEventListener('resize', () => {
|
|
27550
|
+
window.addEventListener('resize', () => { repositionOrHideImageToolbar(); });
|
|
25943
27551
|
|
|
25944
27552
|
// ── v3.17.0 T5: the diagram panel — the source and a live preview (D15) ──
|
|
25945
27553
|
//
|
|
@@ -25989,6 +27597,7 @@
|
|
|
25989
27597
|
}
|
|
25990
27598
|
function disposeDiagramPanel(s) {
|
|
25991
27599
|
if (s.timer) { clearTimeout(s.timer); s.timer = null; }
|
|
27600
|
+
if (s.historyTimer) { clearTimeout(s.historyTimer); s.historyTimer = null; }
|
|
25992
27601
|
s.previewSeq++; // an answer still out lands nowhere
|
|
25993
27602
|
s.listeners.abort();
|
|
25994
27603
|
if (s.offTheme) { s.offTheme(); s.offTheme = null; }
|
|
@@ -26113,6 +27722,7 @@
|
|
|
26113
27722
|
originalHtml: blockEl.innerHTML, darkAtOpen: diagramThemeIsDark(),
|
|
26114
27723
|
timer: null, previewSeq: 0, inflight: null, lastResult: undefined, reentering: null, pressInside: false,
|
|
26115
27724
|
listeners: new AbortController(), offTheme: null, editor: null,
|
|
27725
|
+
history: null, historyTimer: null, composing: false,
|
|
26116
27726
|
};
|
|
26117
27727
|
s.editor = {
|
|
26118
27728
|
blockEl,
|
|
@@ -26136,9 +27746,24 @@
|
|
|
26136
27746
|
ta.focus({ preventScroll: true });
|
|
26137
27747
|
if (o.seed) ta.setSelectionRange(o.seed.selStart, o.seed.selEnd);
|
|
26138
27748
|
else ta.setSelectionRange(0, 0);
|
|
27749
|
+
// v3.17.3 C7: the panel's own text history, the code edit's model. The
|
|
27750
|
+
// Ctrl+S re-entry hands the closing panel's history to this one (what
|
|
27751
|
+
// was typed before the save stays undoable); any other open starts one.
|
|
27752
|
+
s.history = (o.seed && o.seed.history) || new codeEditLib.TextHistory({ text: ta.value, selStart: ta.selectionStart, selEnd: ta.selectionEnd });
|
|
27753
|
+
if (o.seed && o.seed.history) pushDiagramHistory(s);
|
|
26139
27754
|
|
|
26140
27755
|
const on = { signal: s.listeners.signal };
|
|
26141
|
-
ta.addEventListener('input', () => { autoSize(ta); scheduleDiagramPreview(s); setDirty(); }, on);
|
|
27756
|
+
ta.addEventListener('input', () => { autoSize(ta); scheduleDiagramPreview(s); scheduleDiagramHistory(s); setDirty(); }, on);
|
|
27757
|
+
ta.addEventListener('compositionstart', () => { s.composing = true; }, on);
|
|
27758
|
+
ta.addEventListener('compositionend', () => { s.composing = false; scheduleDiagramHistory(s); }, on);
|
|
27759
|
+
// The browser's own undo (a menu, a gesture) steps the panel's history
|
|
27760
|
+
// too, never the textarea's native stack (a history step sets the value
|
|
27761
|
+
// and empties that).
|
|
27762
|
+
ta.addEventListener('beforeinput', (e) => {
|
|
27763
|
+
if (e.inputType !== 'historyUndo' && e.inputType !== 'historyRedo') return;
|
|
27764
|
+
e.preventDefault();
|
|
27765
|
+
stepDiagramHistory(s, e.inputType === 'historyUndo' ? -1 : 1);
|
|
27766
|
+
}, on);
|
|
26142
27767
|
panel.addEventListener('keydown', (e) => onDiagramPanelKeydown(s, e), on);
|
|
26143
27768
|
// R36: a press inside never ends the panel. On anything but the source
|
|
26144
27769
|
// and the preview it keeps focus where it is; a focusout with no
|
|
@@ -26192,18 +27817,28 @@
|
|
|
26192
27817
|
else if (e.key === 'Enter' && mod) act = 'commit';
|
|
26193
27818
|
else if (mod && !e.altKey && !e.shiftKey && (e.key === 's' || e.key === 'S')) act = 'save';
|
|
26194
27819
|
else if (e.key === 'Tab' && !mod && !e.altKey && !e.shiftKey && e.target === s.ta) act = 'tab';
|
|
27820
|
+
else if (mod && !e.altKey && !e.shiftKey && (e.key === 'z' || e.key === 'Z')) act = 'undo';
|
|
27821
|
+
else if (mod && !e.altKey && ((!e.shiftKey && (e.key === 'y' || e.key === 'Y')) ||
|
|
27822
|
+
(e.shiftKey && (e.key === 'z' || e.key === 'Z')))) act = 'redo';
|
|
26195
27823
|
if (!act) return;
|
|
26196
27824
|
e.preventDefault();
|
|
26197
27825
|
e.stopPropagation();
|
|
26198
|
-
if (act === 'tab') {
|
|
27826
|
+
if (act === 'tab') {
|
|
27827
|
+
// One step of its own, after the typing before it.
|
|
27828
|
+
flushDiagramTyping(s);
|
|
27829
|
+
insertIntoDiagramSource(s.ta, ' ');
|
|
27830
|
+
flushDiagramTyping(s);
|
|
27831
|
+
return;
|
|
27832
|
+
}
|
|
27833
|
+
if (act === 'undo' || act === 'redo') { stepDiagramHistory(s, act === 'undo' ? -1 : 1); return; }
|
|
26199
27834
|
if (s.reentering) { s.reentering[act] = true; return; }
|
|
26200
27835
|
if (s.inflight || liveDiagramPanel() !== s) return; // a commit is out: it ends the panel, or leaves it open on failure
|
|
26201
27836
|
if (act === 'cancel') cancelDiagramPanel(s).catch(showUnexpectedFailure);
|
|
26202
27837
|
else if (act === 'commit') commitDiagramPanel(s).catch(showUnexpectedFailure);
|
|
26203
27838
|
else commitDiagramPanelAndReenter(s, { save: true }).catch(showUnexpectedFailure);
|
|
26204
27839
|
}
|
|
26205
|
-
// Through the browser's own insertText
|
|
26206
|
-
//
|
|
27840
|
+
// Through the browser's own insertText (its input event schedules the
|
|
27841
|
+
// preview); Ctrl+Z takes the two spaces back through the panel's history.
|
|
26207
27842
|
function insertIntoDiagramSource(ta, text) {
|
|
26208
27843
|
let ok = false;
|
|
26209
27844
|
try { ok = document.execCommand('insertText', false, text); } catch (e) { ok = false; }
|
|
@@ -26213,6 +27848,45 @@
|
|
|
26213
27848
|
}
|
|
26214
27849
|
}
|
|
26215
27850
|
|
|
27851
|
+
// v3.17.3 C1 / C7: the panel's text history, stepped by Ctrl+Z / Ctrl+Y in
|
|
27852
|
+
// the panel and by the toolbar's 復原 / 重做 while it is open. A step at
|
|
27853
|
+
// most once per idle tick of typing (CODE_EDIT_TICK_MS, the code edit's),
|
|
27854
|
+
// never inside an IME composition. A step changes the panel's text only:
|
|
27855
|
+
// the file is reached when the panel commits, as one document step.
|
|
27856
|
+
function pushDiagramHistory(s) {
|
|
27857
|
+
const ta = s.ta;
|
|
27858
|
+
s.history.push({ text: ta.value, selStart: ta.selectionStart, selEnd: ta.selectionEnd });
|
|
27859
|
+
}
|
|
27860
|
+
function scheduleDiagramHistory(s) {
|
|
27861
|
+
if (s.historyTimer) clearTimeout(s.historyTimer);
|
|
27862
|
+
s.historyTimer = setTimeout(() => {
|
|
27863
|
+
s.historyTimer = null;
|
|
27864
|
+
if (liveDiagramPanel() !== s || s.composing) return;
|
|
27865
|
+
pushDiagramHistory(s);
|
|
27866
|
+
}, CODE_EDIT_TICK_MS);
|
|
27867
|
+
}
|
|
27868
|
+
function flushDiagramTyping(s) {
|
|
27869
|
+
if (s.historyTimer) { clearTimeout(s.historyTimer); s.historyTimer = null; }
|
|
27870
|
+
if (!s.composing) pushDiagramHistory(s);
|
|
27871
|
+
}
|
|
27872
|
+
function stepDiagramHistory(s, dir) {
|
|
27873
|
+
flushDiagramTyping(s);
|
|
27874
|
+
const snap = dir < 0 ? s.history.undo() : s.history.redo();
|
|
27875
|
+
if (!snap) return; // nothing left: the document stack is never reached from here
|
|
27876
|
+
const ta = s.ta;
|
|
27877
|
+
ta.value = snap.text;
|
|
27878
|
+
if (document.activeElement === ta) ta.setSelectionRange(snap.selStart, snap.selEnd);
|
|
27879
|
+
autoSize(ta);
|
|
27880
|
+
scheduleDiagramPreview(s);
|
|
27881
|
+
setDirty(); // R47: the dot and the status follow the panel's text
|
|
27882
|
+
}
|
|
27883
|
+
// Whether the panel is open right now, with no side effect (unlike
|
|
27884
|
+
// liveDiagramPanel(), which disposes a panel a render took away).
|
|
27885
|
+
function diagramPanelIsOpen() {
|
|
27886
|
+
const s = diagramPanelState;
|
|
27887
|
+
return !!s && activeEditor === s.editor && s.panel.isConnected;
|
|
27888
|
+
}
|
|
27889
|
+
|
|
26216
27890
|
function scheduleDiagramPreview(s) {
|
|
26217
27891
|
if (s.timer) clearTimeout(s.timer);
|
|
26218
27892
|
s.timer = setTimeout(() => { s.timer = null; runDiagramPreview(s); }, DIAGRAM_PREVIEW_MS);
|
|
@@ -26341,7 +28015,12 @@
|
|
|
26341
28015
|
if (wasPristine) pristineInsert = null;
|
|
26342
28016
|
if (!wasPristine && s.ta.value !== s.body) {
|
|
26343
28017
|
dropDiscardedBurst();
|
|
26344
|
-
|
|
28018
|
+
// v3.17.2 B3 (R75): plus what the panel needs to open again on it
|
|
28019
|
+
// (restoreDiscardedRawEdit()); the raw-box shape stays the fallback.
|
|
28020
|
+
discardedRawEdit = Object.assign(diagramPanelSnapshot(s), { diagram: {
|
|
28021
|
+
kind: s.kind, value: s.ta.value, selStart: s.ta.selectionStart, selEnd: s.ta.selectionEnd,
|
|
28022
|
+
previewHtml: s.preview.innerHTML,
|
|
28023
|
+
} });
|
|
26345
28024
|
}
|
|
26346
28025
|
endDiagramPanelVisual(s);
|
|
26347
28026
|
if (wasPristine) return await discardPristineInsert();
|
|
@@ -26403,6 +28082,7 @@
|
|
|
26403
28082
|
const ta = s.ta;
|
|
26404
28083
|
const startLine = s.startLine;
|
|
26405
28084
|
const sent = ta.value; // what this commit writes
|
|
28085
|
+
flushDiagramTyping(s); // v3.17.3 C7: the typing before the save is a step of the history carried on
|
|
26406
28086
|
const flags = { cancel: false, commit: false, save: false };
|
|
26407
28087
|
s.reentering = flags;
|
|
26408
28088
|
const hold = { keys: [], clickedAway: false, passTarget: ta };
|
|
@@ -26422,7 +28102,7 @@
|
|
|
26422
28102
|
if (!target && ok && !hold.clickedAway && s.lastResult !== RENDER_SUPERSEDED) {
|
|
26423
28103
|
const el = blockElAtLine(startLine);
|
|
26424
28104
|
const seed = { value: ta.value, selStart: ta.selectionStart, selEnd: ta.selectionEnd,
|
|
26425
|
-
previewHtml: s.preview.innerHTML };
|
|
28105
|
+
previewHtml: s.preview.innerHTML, history: s.history };
|
|
26426
28106
|
if (el && diagramPanelKindOf(el, s.kind) && await openDiagramPanel(el, { seed, kind: s.kind })) target = liveDiagramPanel();
|
|
26427
28107
|
}
|
|
26428
28108
|
if (ok && opts.save) saveAfterLastSave();
|
|
@@ -26472,7 +28152,7 @@
|
|
|
26472
28152
|
dropped = true;
|
|
26473
28153
|
}
|
|
26474
28154
|
}
|
|
26475
|
-
if (changed) { autoSize(ta); scheduleDiagramPreview(s); setDirty(); }
|
|
28155
|
+
if (changed) { autoSize(ta); scheduleDiagramPreview(s); flushDiagramTyping(s); setDirty(); }
|
|
26476
28156
|
if (dropped) showNotice(FLIGHT_DROPPED_MESSAGE);
|
|
26477
28157
|
return leave;
|
|
26478
28158
|
}
|
|
@@ -27142,7 +28822,7 @@
|
|
|
27142
28822
|
const back = popped.length === offer.count &&
|
|
27143
28823
|
lines.slice(b.startLine, b.startLine + n).join('\n') === offer.baseSource;
|
|
27144
28824
|
if (back) setDirty();
|
|
27145
|
-
const ok = back && await
|
|
28825
|
+
const ok = back && await historyRerender({
|
|
27146
28826
|
startLine: b.startLine + 1, endLine: b.startLine + n, delta: lines.length - prevLines.length,
|
|
27147
28827
|
});
|
|
27148
28828
|
if (!ok) {
|
|
@@ -27180,7 +28860,7 @@
|
|
|
27180
28860
|
if (ok) {
|
|
27181
28861
|
setDirty();
|
|
27182
28862
|
const b = blocks.find((x) => x.id === ro.offer.blockId);
|
|
27183
|
-
ok = await
|
|
28863
|
+
ok = await historyRerender(b ? { startLine: b.startLine + 1, endLine: b.startLine + 1,
|
|
27184
28864
|
delta: lines.length - prevLines.length } : undefined);
|
|
27185
28865
|
}
|
|
27186
28866
|
if (!ok) {
|