@helping-ai-workflow/md2doc 3.17.2 → 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.
@@ -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
- function commitRangeEdit(state, startLine, endLine, newText) {
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 after = newText.split('\n');
64
- if (before.join('\n') === after.join('\n')) {
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.
@@ -128,7 +233,7 @@
128
233
  const refusal = refuseInvertedRange(state, 'commitTightRemoval', startLine, endLine);
129
234
  if (refusal) return refusal;
130
235
  const anchor = state.blocks.filter((b) => b.endLine <= endLine).pop();
131
- const span = growOverBlanks(state.lines, startLine, endLine, 'both');
236
+ const span = growOverBlanks(state.lines, startLine, endLine, 'both', state.blocks);
132
237
  const before = state.lines.slice(span[0] - 1, span[1]);
133
238
  const op = { startLine: span[0], endLine: span[1], before, after: [] };
134
239
  const r = ops.replaceLines(state.lines, span[0], span[1], []);
@@ -278,6 +383,9 @@
278
383
  // * no blocks at all — an empty or whitespace-only file. Its whole line
279
384
  // range is replaced by [...newLines, ''], so the file ends with exactly
280
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.
281
389
  // Returns { lines, blocks, op, newStartLine }; op is null only for a
282
390
  // refusal (commitBlockInsertion()'s inverted-anchor case).
283
391
  function commitInsertionAt(state, anchorBlockId, newLines) {
@@ -292,7 +400,9 @@
292
400
  state.stack.push(op);
293
401
  return { lines: r.lines, blocks: state.blocks, op, newStartLine: 1 };
294
402
  }
295
- const z = commitRangeEdit(state, 1, state.lines.length, newLines.concat(['']).join('\n'));
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'));
296
406
  return { lines: z.lines, blocks: z.blocks, op: z.op, newStartLine: 1 };
297
407
  }
298
408
  const first = state.blocks[0];
@@ -329,14 +439,28 @@
329
439
  // 'below', R58) grows op 2 over the blank lines to that side.
330
440
  // v3.15.0 B2 fix round 1 (R58): `start..end` grown over the blank lines
331
441
  // right above it (`side` 'above'), right below it ('below') or both ('both',
332
- // R62). Every line
333
- // between two blocks is blank by construction, so this is the separator to
334
- // the neighbouring block — the one a list item eats to join that list tight.
335
- function growOverBlanks(lines, start, end, side) {
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 : [];
336
452
  let s = start;
337
453
  let e = end;
338
- if (side === 'above' || side === 'both') while (s >= 2 && String(lines[s - 2]).trim() === '') s--;
339
- if (side === 'below' || side === 'both') while (e < lines.length && String(lines[e]).trim() === '') e++;
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
+ }
340
464
  return [s, e];
341
465
  }
342
466
 
@@ -350,7 +474,11 @@
350
474
  return { lines: r.lines, blocks: r.blocks, op: r.op, newStartLine: before ? at : at + 1 };
351
475
  }
352
476
 
353
- function commitShortcut(state, at, literalLines, convertedLines, join) {
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) {
354
482
  let first;
355
483
  let start;
356
484
  if (at && at.blockId !== undefined && at.blockId !== null) {
@@ -367,8 +495,21 @@
367
495
  const mid = { lines: first.lines, blocks: first.blocks, stack: state.stack };
368
496
  // R58: a list item joining the list above or below takes the separator
369
497
  // between the two into op 2's range, so the list stays tight.
370
- const span = growOverBlanks(mid.lines, start, start + literalLines.length - 1, join || null);
371
- const second = commitRangeEdit(mid, span[0], span[1], convertedLines.join('\n'));
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);
372
513
  if (second.op === null) {
373
514
  // Nothing to convert (cannot happen for a real marker: the literal is
374
515
  // escaped and the conversion is not). Op 1 alone is not a shortcut, so
@@ -377,7 +518,7 @@
377
518
  return { lines: state.lines, blocks: state.blocks, ops: [null, null], newStartLine: null };
378
519
  }
379
520
  return { lines: second.lines, blocks: second.blocks, ops: [first.op, second.op],
380
- newStartLine: span[0], literalStartLine: start };
521
+ newStartLine: span[0], literalStartLine: start, tailEnd: tailEnd };
381
522
  }
382
523
 
383
524
  // ── S4 Task 3: relocating ONE block's lines (spec §4.5) ────────────────
@@ -483,7 +624,10 @@
483
624
  if (!plan) {
484
625
  return { lines: state.lines, blocks: state.blocks, op: null, noop: 'same-position' };
485
626
  }
486
- const r = commitRangeEdit(state, plan.startLine, plan.endLine, plan.after.join('\n'));
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 });
487
631
  r.newRange = plan.newRange;
488
632
  return r;
489
633
  }
@@ -1421,8 +1565,19 @@
1421
1565
  // v3.17.2 B1 (v3.17.0 final review T2 L74): and so is the text typed into
1422
1566
  // an open raw box. Measured before: type in the box, and the status
1423
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.
1424
1571
  return stack.isDirty() || burstHasUncommittedEdit() || waveUnwrittenEdit || pendingStructure !== null ||
1425
- codeEditHasUncommittedText() || diagramPanelHasUncommittedText() || rawEditHasUncommittedText();
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; }
1426
1581
  }
1427
1582
  // Guarded the same way, for the same reason (the beforeunload handler).
1428
1583
  function rawEditHasUncommittedText() {
@@ -2244,14 +2399,19 @@
2244
2399
  absorbedOutsideContent(lastRenderLines, blocks,
2245
2400
  lines, j.blocks,
2246
2401
  j.blocks[j.blocks.length - 1]);
2247
- const plan = (editRange && window.md2docPatchmap && !lastPartsIsBootstrapSeed && domMatchesLastRender())
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())
2248
2408
  ? window.md2docPatchmap.patchmap({
2249
2409
  oldBlocks: blocks, newBlocks: j.blocks,
2250
2410
  oldParts: lastParts, newParts: j.parts, editRange: editRange,
2251
2411
  })
2252
2412
  : null;
2253
2413
  const ok = plan ? await applyPatch(j, plan, declaredRange)
2254
- : await applyFullRender(j, scrollY, declaredRange);
2414
+ : await applyFullRender(j, scrollY, declaredRange, keptTransient);
2255
2415
  // applyPatch() 與 applyFullRender() 今天都只回 true:applyFullRender() 全函
2256
2416
  // 式唯一的 return 就是尾端
2257
2417
  // 那個 `return true`,而 applyPatch() 要嘛回 true、要嘛在自己的 catch 裡
@@ -2893,7 +3053,7 @@
2893
3053
  return true;
2894
3054
  }
2895
3055
 
2896
- async function applyFullRender(j, scrollY, declaredRange) {
3056
+ async function applyFullRender(j, scrollY, declaredRange, keptTransient) {
2897
3057
  blocks = j.blocks;
2898
3058
  lastParts = j.parts; // v3.2.0: 供下一次 patch 當 oldParts
2899
3059
  lastPartsIsBootstrapSeed = false;
@@ -2928,8 +3088,10 @@
2928
3088
  // fresh assignment.
2929
3089
  pristineInsert = null;
2930
3090
  // v3.14.0 T1: the transient lived in the `.content` children the
2931
- // 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).
2932
3093
  transient = null;
3094
+ if (keptTransient) restoreKeptTransient(keptTransient);
2933
3095
  // Task 2 (Phase 3): same belt-and-braces reasoning for the always-on
2934
3096
  // WYSIWYG burst — whatever block had one open a moment ago was just
2935
3097
  // detached by the innerHTML replacement above, so its own focusout
@@ -3061,10 +3223,79 @@
3061
3223
  }
3062
3224
  }
3063
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);
3064
3229
  setDirty();
3065
3230
  return true;
3066
3231
  }
3067
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
+
3068
3299
  // ── v3.15.0 T4 (D12, R9): diagram engines on demand ────────────────────
3069
3300
  //
3070
3301
  // The edit page inlines Mermaid / WaveDrom / the KaTeX stylesheet only when
@@ -4118,6 +4349,10 @@
4118
4349
  // answer — the "/" menu's 引用 opens the box seeded `> ` with the caret at
4119
4350
  // its end. hasChanges() compares against it, so a box left untouched stays
4120
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;
4121
4356
  async function openRawEditor(blockEl, range, seedOverride) {
4122
4357
  if (blockEl.querySelector('.ed-raw')) return; // already editing this block
4123
4358
  // Task G fix round 1 (npm test RED on f63c88a): the guard below used to
@@ -4420,8 +4655,8 @@
4420
4655
  // delegates), so the two branches are the same pipeline — replaceLines()
4421
4656
  // + one undo op — differing only in which lines they name.
4422
4657
  const result = rangeMode
4423
- ? commitRangeEdit({ lines, blocks, stack }, startLine, endLine, ta.value)
4424
- : 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 });
4425
4660
  if (result.op === null) {
4426
4661
  // §10-gap fix (review): an explicit commit (Ctrl+Enter/✓) whose
4427
4662
  // text happens to be byte-identical to `source` is still an
@@ -4436,7 +4671,14 @@
4436
4671
  endLine: Math.max(result.op.startLine - 1, result.op.endLine),
4437
4672
  delta: result.op.after.length - result.op.before.length }
4438
4673
  : undefined;
4439
- const ok = await safeRerenderAll(editRange);
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
+ }
4440
4682
  if (!ok) {
4441
4683
  // Roll back the optimistic edit: pop the op safeRerenderAll's
4442
4684
  // failure means was never actually rendered, so `lines` stays
@@ -4804,6 +5046,22 @@
4804
5046
  }, () => {});
4805
5047
  }
4806
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
+
4807
5065
  // Opens the edit on `blockEl` with the caret at `opts.offset` (default 0)
4808
5066
  // or under the click point `opts.clientX/clientY`. false when the block is
4809
5067
  // not a fenced code block (the caller opens the raw box instead); true
@@ -4847,6 +5105,7 @@
4847
5105
  const host = blockEl.firstElementChild;
4848
5106
  const body = fence.body.join('\n');
4849
5107
  let caret = o.clientX !== undefined ? codeCaretFromPoint(host, o.clientX, o.clientY) : null;
5108
+ if (caret !== null) caret = codeEditBodyOffset(host.textContent, fence.body, caret);
4850
5109
  if (caret === null) caret = o.offset || 0;
4851
5110
  caret = Math.max(0, Math.min(body.length, caret));
4852
5111
  const s = {
@@ -5018,7 +5277,7 @@
5018
5277
  // text. RENDER_SUPERSEDED: not success yet, the caller touches no DOM. true:
5019
5278
  // rendered.
5020
5279
  async function commitFencedRangeAndRender(startLine, endLine, text) {
5021
- const result = commitRangeEdit({ lines, blocks, stack }, startLine, endLine, text);
5280
+ const result = commitRangeEdit({ lines, blocks, stack }, startLine, endLine, text, { verbatim: true });
5022
5281
  if (result.op === null) return null;
5023
5282
  const prevLines = lines;
5024
5283
  lines = result.lines;
@@ -5916,7 +6175,30 @@
5916
6175
  // Enter made the item) takes the spacing of the list it joins, which the
5917
6176
  // renderer stamped from marked's own looseness as data-list-loose.
5918
6177
  function serializeListEls(els, opts) {
5919
- return listMd.serializeBlocks(els, Object.assign({ blankBefore: looseBlankBefore }, opts || {}));
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;
5920
6202
  }
5921
6203
  function looseBlankBefore(el, prevEl) {
5922
6204
  if (!prevEl) return false;
@@ -8689,8 +8971,13 @@
8689
8971
  const original = currentBurst && currentBurst.blockEl === blockEl ? currentBurst.original : null;
8690
8972
  if (currentBurst && currentBurst.blockEl === blockEl) endBurstWithoutResolve();
8691
8973
  // R58 (`joinSide`): the blank lines to the list being joined go too.
8692
- const range = growOverBlanks(lines, block.startLine, block.endLine, joinSide || null);
8693
- const result = commitRangeEdit({ lines, blocks, stack }, range[0], range[1], newLines.join('\n'));
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'));
8694
8981
  if (result.op === null) return null;
8695
8982
  const prevLines = lines;
8696
8983
  lines = result.lines;
@@ -8742,6 +9029,17 @@
8742
9029
  });
8743
9030
  const startLine = await replaceParagraphWithLines(live, liveSurface, newLines);
8744
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
+ },
8745
9043
  });
8746
9044
  }
8747
9045
 
@@ -8860,8 +9158,13 @@
8860
9158
  }
8861
9159
  const join = isListKind(match.kind) ? listJoinOf(blockEl, match.kind) : null;
8862
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;
8863
9166
  await commitShortcutAt(blockEl, surface, isT, match, literal.split('\n'), lines2, markerLen,
8864
- join ? join.side : null);
9167
+ join ? join.side : null, tail);
8865
9168
  } finally {
8866
9169
  if (rawBoxKind) shortcutRawBoxFlight = false;
8867
9170
  endCommitFlight();
@@ -8901,6 +9204,20 @@
8901
9204
  const all = allBlockEls().filter((el) => el === blockEl || !el.hasAttribute('data-ed-transient'));
8902
9205
  const i = all.indexOf(blockEl);
8903
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
+ };
8904
9221
  const sideOf = (el, above) => {
8905
9222
  if (!el || el.getAttribute('data-block-type') !== 'li' || el.getAttribute('data-block-id') === null) return null;
8906
9223
  if (!above && (Number(el.getAttribute('data-indent')) || 0) !== 0) return null;
@@ -8915,11 +9232,12 @@
8915
9232
  const refRec = blockRecOf(ref);
8916
9233
  const edgeRec = blockRecOf(el);
8917
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;
8918
9236
  const m = /^(?:([-*+])|(\d{1,9})([.)]))(?:[ \t]|$)/.exec(lines[refRec.startLine - 1] || '');
8919
9237
  if (!m) return null;
8920
9238
  return { side: above ? 'above' : 'below', bullet: m[1] || '-',
8921
9239
  ordinal: m[2] ? Number(m[2]) + (above ? 1 : 0) : 1, delim: m[3] || '.',
8922
- at: above ? edgeRec.endLine : edgeRec.startLine };
9240
+ at: above ? edgeRec.endLine : edgeRec.startLine, run: run };
8923
9241
  };
8924
9242
  const above = sideOf(all[i - 1], true);
8925
9243
  const below = sideOf(all[i + 1], false);
@@ -8939,11 +9257,12 @@
8939
9257
  // writes it at the top level, and in an ordered list the empty path
8940
9258
  // renumbers while this one keeps the number typed.
8941
9259
  const oneList = isTransient(blockEl) && all[i + 1].getAttribute('data-list-start') !== '1';
8942
- if (!oneList) return Object.assign(above, { side: 'both' });
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 });
8943
9262
  const aRec = blockRecOf(all[i - 1]);
8944
9263
  const bRec = blockRecOf(all[i + 1]);
8945
9264
  const tight = !!(aRec && bRec && bRec.startLine === aRec.endLine + 1);
8946
- return tight ? Object.assign(above, { side: 'both' }) : null;
9265
+ return tight ? Object.assign(above, { side: 'both', below: below }) : null;
8947
9266
  }
8948
9267
  return above || below;
8949
9268
  }
@@ -8992,7 +9311,7 @@
8992
9311
  await landSlashItem(result.newStartLine, item, true);
8993
9312
  }
8994
9313
 
8995
- async function commitShortcutAt(blockEl, surface, isT, match, literalLines, converted, markerLen, join) {
9314
+ async function commitShortcutAt(blockEl, surface, isT, match, literalLines, converted, markerLen, join, tail) {
8996
9315
  let at;
8997
9316
  if (isT) {
8998
9317
  const anchorEl = transientAnchorEl(blockEl);
@@ -9006,7 +9325,7 @@
9006
9325
  }
9007
9326
  const rec = isT ? null : blocks.find((b) => b.id === at.blockId);
9008
9327
  if (!isT && !rec) return;
9009
- const r = commitShortcut({ lines, blocks, stack }, at, literalLines, converted, join);
9328
+ const r = commitShortcut({ lines, blocks, stack }, at, literalLines, converted, join, tail || null);
9010
9329
  const op1 = r.ops[0];
9011
9330
  const op2 = r.ops[1];
9012
9331
  if (!op2) return;
@@ -9017,13 +9336,13 @@
9017
9336
  if (isT) {
9018
9337
  const first = op1 || op2;
9019
9338
  editRange = { startLine: Math.min(first.startLine, first.endLine + 1),
9020
- endLine: Math.max(first.startLine - 1, first.endLine), delta };
9339
+ endLine: Math.max(first.startLine - 1, first.endLine, r.tailEnd || 0), delta };
9021
9340
  const shifted = {};
9022
9341
  blocks.forEach((b) => { if (b.startLine >= first.startLine) shifted[b.id] = b.startLine + delta; });
9023
9342
  pendingFlightLineMap = { ids: shifted, provisional: r.newStartLine };
9024
9343
  } else {
9025
- const grown = growOverBlanks(prevLines, rec.startLine, rec.endLine, join || null);
9026
- 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 };
9027
9346
  pendingFlightLineMap = { ids: { [rec.id]: r.newStartLine }, provisional: r.newStartLine };
9028
9347
  }
9029
9348
  const original = !isT && currentBurst && currentBurst.blockEl === blockEl ? currentBurst.original : null;
@@ -9293,8 +9612,10 @@
9293
9612
  // R4: the text a paragraph / heading surface commits as — the heading's
9294
9613
  // own `#` run in front, a paragraph's line-start markers escaped (F-1).
9295
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); }
9296
9617
  function commitTextOf(md, blockType, depth) {
9297
- if (blockType === 'heading') return md === '' ? '#'.repeat(depth) : '#'.repeat(depth) + ' ' + md;
9618
+ if (blockType === 'heading') return md === '' ? '#'.repeat(depth) : '#'.repeat(depth) + ' ' + escapeClosingHashes(md);
9298
9619
  return blockType === 'paragraph' ? inlineMd.escapeBlockStarts(md) : md;
9299
9620
  }
9300
9621
  function surfaceCommitText(surface, blockType, depth) {
@@ -9443,15 +9764,23 @@
9443
9764
  }
9444
9765
  if (place === 'middle') { await splitBlockAtCaret(burst, editEl, blockEl); return; }
9445
9766
  const live = await settleEnterSource(burst, blockEl);
9446
- 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) {
9447
9775
  const surface = blockContentEl(live);
9448
9776
  if (place === 'start') {
9449
9777
  createTransient(previousBlockEl(live), { focus: false, byEnter: true, stayEl: live });
9450
- if (!surface || !surface.classList.contains('ed-wys-armed')) return;
9778
+ if (!placeCaret || !surface || !surface.classList.contains('ed-wys-armed')) return;
9451
9779
  if (document.activeElement !== surface) surface.focus();
9452
9780
  const r = document.createRange();
9453
9781
  r.selectNodeContents(surface);
9454
9782
  r.collapse(true);
9783
+ const sel = window.getSelection();
9455
9784
  sel.removeAllRanges();
9456
9785
  sel.addRange(r);
9457
9786
  // R3: a surface that kept focus through the commit gets no focusin, and
@@ -9463,6 +9792,36 @@
9463
9792
  anchorCaret: surface ? { surface, offset: surface.textContent.length } : null });
9464
9793
  }
9465
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
+
9466
9825
  // The edge cases' first step: commit what is pending in the block Enter was
9467
9826
  // pressed in (R7) and hand back the live element it is now — re-resolved by
9468
9827
  // its start line when the commit re-rendered it, or the paragraph a
@@ -9564,10 +9923,13 @@
9564
9923
  // wrote as its own op before the structure ({ blockId, text }, or null).
9565
9924
  // The retry writes it as its own op again, so one Ctrl+Z still takes the
9566
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()).
9567
9929
  function setPendingStructure(startLine, endLine, surfaces, serialize, covers, typed) {
9568
9930
  pendingStructure = { range: { startLine, endLine },
9569
9931
  before: lines.slice(startLine - 1, endLine).join('\n'), surfaces, serialize, covers: covers || null,
9570
- typedIds: new Set(), typed: typed || null };
9932
+ typedIds: new Set(), typed: typed || null, after: null };
9571
9933
  setDirty(); // R34: it is unsaved work (documentIsDirty())
9572
9934
  }
9573
9935
 
@@ -9627,7 +9989,7 @@
9627
9989
  // `text: null` is a removal (an emptied block whose removal did not land).
9628
9990
  const result = s.text === null
9629
9991
  ? commitRangeRemoval(state, ps.range.startLine, endLine)
9630
- : commitRangeEdit(state, ps.range.startLine, endLine, s.text);
9992
+ : commitRangeEdit(state, ps.range.startLine, endLine, s.text, { carriesGap: s.carriesGap === true });
9631
9993
  if (result.op !== null) lines = result.lines;
9632
9994
  else if (typedOp) lines = typedOp.lines;
9633
9995
  const editRange = { startLine: ps.range.startLine, endLine: ps.range.endLine,
@@ -9679,6 +10041,14 @@
9679
10041
  restoreSelection(surface, caretTag.anchor, caretTag.focus);
9680
10042
  }
9681
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();
9682
10052
  return true;
9683
10053
  }
9684
10054
 
@@ -9744,8 +10114,10 @@
9744
10114
  // Spec §3.2. Backspace with the caret at the start of a paragraph joins it
9745
10115
  // to the block above (a paragraph, a heading, or a list item's text); at
9746
10116
  // the start of a heading it makes the heading a paragraph. Delete at the
9747
- // end of a paragraph or heading pulls the next one up. A neighbour that has
9748
- // no text to join (a table, a code block, a list item for Delete) is
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
9749
10121
  // selected instead, and an EMPTY block below such a neighbour goes away
9750
10122
  // with the caret landing at the neighbour's end (R12). Every case is one
9751
10123
  // undo step; typing the key commits first is its own earlier step (D4).
@@ -9910,7 +10282,7 @@
9910
10282
  const range = o.range(state.blocks);
9911
10283
  second = o.text === null
9912
10284
  ? commitRangeRemoval(state, range[0], range[1])
9913
- : commitRangeEdit(state, range[0], range[1], o.text);
10285
+ : commitRangeEdit(state, range[0], range[1], o.text, { carriesGap: o.carriesGap === true });
9914
10286
  }
9915
10287
  lines = second.lines;
9916
10288
  const delta = (typedOp ? typedOp.op.after.length - typedOp.op.before.length : 0) +
@@ -10001,7 +10373,10 @@
10001
10373
  const typed = empty ? null : typedInBurst(burst);
10002
10374
  let seam = 0;
10003
10375
  withFocusoutSuppressed(() => {
10004
- seam = joinSeam(prevSurface, Array.prototype.slice.call(editEl.childNodes), prevType === 'heading');
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');
10005
10380
  structureMutated = true;
10006
10381
  prevSurface.focus();
10007
10382
  restoreSelection(prevSurface, seam, seam);
@@ -10141,7 +10516,19 @@
10141
10516
  else selectBlock(next);
10142
10517
  return;
10143
10518
  }
10144
- // D14: a list item is never pulled up into a paragraph; it is selected.
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
+ }
10145
10532
  if (!nextSurface || nextType === 'li') { selectBlock(next); return; }
10146
10533
  const nextRec = blockRecOf(next);
10147
10534
  const curRec = curIsTransient ? null : blocks.find((b) => b.id === burst.blockId);
@@ -10176,6 +10563,54 @@
10176
10563
  if (ok) focusBlockAtLineOffset(headLine, seam);
10177
10564
  }
10178
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
+
10179
10614
  // S2 Task 7 — the li half of +. The new item is spliced into the run's own
10180
10615
  // span and the WHOLE span is re-serialized over the run's line range: one
10181
10616
  // commitRangeEdit, therefore one undo op, no leading blank line (so the run
@@ -10896,6 +11331,9 @@
10896
11331
  // block from the one that moved up into its place. An identity taken before
10897
11332
  // `tail` existed (none today) never reads as removed.
10898
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;
10899
11337
  return !!identity && identity.tail !== undefined &&
10900
11338
  lines.length - identity.tail < identityStartLineNow(identity);
10901
11339
  }
@@ -11634,6 +12072,10 @@
11634
12072
  { el: i > 0 ? all[i - 1] : null, attrs: headAttrs, back: true },
11635
12073
  { el: (j >= 0 && j + 1 < all.length) ? all[j + 1] : null, attrs: tailAttrs, back: false },
11636
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 };
11637
12079
  for (let k = 0; k < sides.length; k++) {
11638
12080
  const side = sides[k];
11639
12081
  if (!side.el || !side.attrs) continue;
@@ -11645,21 +12087,27 @@
11645
12087
  if ((Number(head.getAttribute('data-indent')) || 0) !== side.attrs.indent) continue;
11646
12088
  const headType = head.getAttribute('data-list-type') === 'ol' ? 'ol' : 'ul';
11647
12089
  if (headType !== side.attrs.listType) continue;
11648
- // Every line between two adjacent blocks is blank by construction
11649
- // (buildBlockMap() strips a token's trailing newlines, so no block ever
11650
- // owns a separator). Eating ALL of them is also 「正規化連續空行」:
11651
- // measured, '- a\n\n\n- b\n' is still ONE loose list, so stopping after
11652
- // one blank would leave the degrade in place.
11653
- let moved = false;
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;
11654
12101
  if (side.back) {
11655
- while (startLine >= 2 && String(lines[startLine - 2]).trim() === '') {
11656
- startLine -= 1; moved = true;
11657
- }
12102
+ while (edge >= 2 && String(lines[edge - 2]).trim() === '') edge -= 1;
11658
12103
  } else {
11659
- while (endLine < lines.length && String(lines[endLine]).trim() === '') {
11660
- endLine += 1; moved = true;
11661
- }
12104
+ while (edge < lines.length && String(lines[edge]).trim() === '') edge += 1;
11662
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;
11663
12111
  // No separator between us and it: they are already two runs that
11664
12112
  // markdown keeps apart for a reason this rule does not touch (a
11665
12113
  // delimiter change, `- a` / `* b`). Nothing to eat, nothing to gate.
@@ -11673,7 +12121,23 @@
11673
12121
  return { startLine: range.startLine, endLine: range.endLine, ok: false };
11674
12122
  }
11675
12123
  }
11676
- 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 };
11677
12141
  }
11678
12142
 
11679
12143
  // The {listType, indent} a span member will carry once the operand set
@@ -12093,18 +12557,6 @@
12093
12557
  async function convertBlocksIntoList(blockEls, recs, kinds, target) {
12094
12558
  const attrs = convertMd.listAttrsFor(target);
12095
12559
  if (!attrs) return;
12096
- // One item per member, joined by a bare newline: the members' own
12097
- // separators are INSIDE the commit range and are replaced, which is what
12098
- // makes N paragraphs one TIGHT list. A blank between them would make the
12099
- // list loose and degrade every item read-only (§4.3 rule 2).
12100
- const pieces = [];
12101
- for (let i = 0; i < recs.length; i++) {
12102
- const stripped = convertMd.stripMarker(
12103
- lines.slice(recs[i].startLine - 1, recs[i].endLine), kinds[i]);
12104
- if (!stripped.ok) { refuseStructuralListEdit('此區塊的格式無法轉換'); return; }
12105
- pieces.push(convertMd.emitAs(stripped.content, target, {}).join('\n'));
12106
- }
12107
- const md = pieces.join('\n');
12108
12560
  const first = recs[0];
12109
12561
  const last = recs[recs.length - 1];
12110
12562
 
@@ -12117,16 +12569,73 @@
12117
12569
  { startLine: first.startLine, endLine: last.endLine }, blockEls, self, self);
12118
12570
  if (!merged.ok) { refuseStructuralListEdit(); return; }
12119
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
+
12120
12628
  const result = commitRangeEdit({ lines, blocks, stack },
12121
- merged.startLine, merged.endLine, md);
12629
+ merged.startLine, endLine, md);
12122
12630
  if (result.op === null) return;
12123
12631
  const prevLines = lines;
12124
12632
  lines = result.lines;
12125
- // §3.3's collapse: the emitted items occupy the whole (possibly widened)
12126
- // commit range — nothing else is written here.
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.
12127
12636
  declareCollapse({
12128
12637
  startLine: merged.startLine,
12129
- endLine: merged.startLine + md.split('\n').length - 1,
12638
+ endLine: merged.startLine + convertedMd.split('\n').length - 1,
12130
12639
  });
12131
12640
  const editRange = result.op
12132
12641
  ? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
@@ -12325,15 +12834,68 @@
12325
12834
  // through its serializer, rewriting `|a|b|` as `| a | b |`. The baseline
12326
12835
  // strings themselves are untouched: burst histories restore them as HTML.
12327
12836
  // An allow-list of the declarations that count, so a style nobody has
12328
- // thought about reads as not an edit only when it is a style; any other
12329
- // foreign attribute still counts, as before.
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.)
12330
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
+ }
12331
12888
  const burstFingerprints = new Map();
12332
12889
  function burstEditFingerprint(html) {
12333
12890
  const hit = burstFingerprints.get(html);
12334
12891
  if (hit !== undefined) return hit;
12335
12892
  const tpl = document.createElement('template');
12336
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
+ });
12337
12899
  Array.prototype.forEach.call(tpl.content.querySelectorAll('[style]'), (el) => {
12338
12900
  const kept = [];
12339
12901
  String(el.getAttribute('style')).split(';').forEach((decl) => {
@@ -12354,12 +12916,12 @@
12354
12916
  return out;
12355
12917
  }
12356
12918
  // Both sides go through the same parse and serialize whenever either holds
12357
- // a style, so a string the page serialized and one re-serialized here are
12919
+ // a style or an attribute outside the list, so a string the page serialized and one re-serialized here are
12358
12920
  // never compared with each other.
12359
12921
  function sameBurstHtml(a, b) {
12360
12922
  if (a === b) return true;
12361
12923
  if (typeof a !== 'string' || typeof b !== 'string') return false;
12362
- if (a.indexOf('style=') === -1 && b.indexOf('style=') === -1) return false;
12924
+ if (!burstHasForeignMarkup(a) && !burstHasForeignMarkup(b)) return false;
12363
12925
  return burstEditFingerprint(a) === burstEditFingerprint(b);
12364
12926
  }
12365
12927
 
@@ -12505,6 +13067,7 @@
12505
13067
  // §10-gap fix (review): untouched AND was pristine — an ordinary
12506
13068
  // "insert +, click away without typing" changed-my-mind. Auto-remove
12507
13069
  // the block instead of leaving its skeleton on disk.
13070
+ if (wasPristineForThisBlock && pristineItemSurvivesSave(burst)) return true;
12508
13071
  if (wasPristineForThisBlock) return await discardPristineInsert();
12509
13072
  return true;
12510
13073
  }
@@ -13131,8 +13694,14 @@
13131
13694
  // armed on one of these, or Shift+Clicking the ⠿ handle would build a
13132
13695
  // selection instead of opening the menu, and a drag inside the raw
13133
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]';
13134
13702
  const ED_SEL_GESTURE_CHROME = '.ed-handle, .ed-handle-menu, .ed-insert, .ed-slash-menu, ' +
13135
- '.ed-te-menu, .ed-te-grip, .ed-tb-insert, .ed-seltb, .ed-linkbar, .ed-linkcard, .ed-imgtb, .ed-conflict, .ed-raw';
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;
13136
13705
  function isSelGestureChrome(target) {
13137
13706
  return !!(target && target.closest && target.closest(ED_SEL_GESTURE_CHROME));
13138
13707
  }
@@ -13191,6 +13760,25 @@
13191
13760
  // false by the next pointerdown so a gesture whose click never arrives
13192
13761
  // (an abort, a release outside the window) cannot swallow a later one.
13193
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; });
13194
13782
 
13195
13783
  function armBlockSelDrag(e) {
13196
13784
  if (isSelGestureChrome(e.target)) return;
@@ -13406,20 +13994,69 @@
13406
13994
  await new Promise((r) => setTimeout(r, SETTLE_POLL_MS));
13407
13995
  }
13408
13996
  }
13409
- // R94: `blockEl` once every in-flight commit has landed, if that render
13410
- // kept the node; one that replaced it leaves nothing to point at, and the
13411
- // gesture is refused with the notice — never re-found by a guess. Measured
13412
- // (final re-review, follow-up round): a split's render replaced every block
13413
- // below the split, and a blockquote above it too, so a gesture on those is
13414
- // refused; it is acted on only where the render kept the node.
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.
13415
14020
  async function settledGestureBlock(blockEl) {
13416
14021
  if (!blockEl) return null;
13417
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;
13418
14027
  await settleCommits();
13419
14028
  if (blockEl.isConnected) return blockEl;
14029
+ const live = refindable && epoch === reconcileEpoch && historyAt === historyRenderSeq && !reconcileBlocksCommits()
14030
+ ? uniqueBlockElByPrint(print) : null;
14031
+ if (live) return live;
13420
14032
  showError(DROPPED_GESTURE_MESSAGE, null, null);
13421
14033
  return null;
13422
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
+ }
13423
14060
 
13424
14061
  // R93: a task item as a checkbox press saw it. `fromEnd` counts rendered
13425
14062
  // blocks after it (a commit above moves every id, never that count);
@@ -13968,6 +14605,15 @@
13968
14605
  // v3.14.0 T1: Escape in a transient abandons it — it wrote nothing, so
13969
14606
  // there is nothing to stash for Ctrl+Z. The only block of an empty
13970
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
+ }
13971
14617
  if (burst.transient) {
13972
14618
  burst.history.dispose();
13973
14619
  currentBurst = null;
@@ -14222,6 +14868,9 @@
14222
14868
  function burstUndo(editEl) {
14223
14869
  const burst = currentBurst;
14224
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;
14225
14874
  const state = burst.history.undo();
14226
14875
  if (state !== null) {
14227
14876
  editEl.innerHTML = state;
@@ -14264,8 +14913,9 @@
14264
14913
  // caret and the burst stayed where it was. With nothing left to step back
14265
14914
  // through here, taking that transient away is the Enter's undo step; the
14266
14915
  // caret stays put. Only in the block the Enter was pressed in (`stayEl`):
14267
- // a transient the user has already left behind is on its way out (its
14268
- // removal waits for the press that left it), and a Ctrl+Z in another
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
14269
14919
  // block means that block's undo. MEASURED without the `stayEl` test (ER
14270
14920
  // 'burst undo: … cascades to the previous commit'): Enter at the end,
14271
14921
  // a click straight into the paragraph above, typing, Ctrl+Z twice — the
@@ -15135,7 +15785,9 @@
15135
15785
  at = sg.end + 1;
15136
15786
  });
15137
15787
  out.push(...lines.slice(at - 1, hi));
15138
- const result = commitRangeEdit({ lines, blocks, stack }, lo, hi, out.join('\n'));
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 });
15139
15791
  if (result.op === null) return;
15140
15792
  endBurstWithoutResolve();
15141
15793
  const prevLines = lines;
@@ -15173,8 +15825,9 @@
15173
15825
  // Every member was a paragraph, or every heading already sat at its clamp:
15174
15826
  // §3.5 rules that a no-op, so nothing is committed and nothing re-renders.
15175
15827
  if (!changed) return;
15828
+ // R56: `span` is the range sliced from `lines`, only heading lines rewritten.
15176
15829
  const result = commitRangeEdit({ lines, blocks, stack },
15177
- first.startLine, last.endLine, span.join('\n'));
15830
+ first.startLine, last.endLine, span.join('\n'), { carriesGap: true });
15178
15831
  if (result.op === null) return;
15179
15832
  const prevLines = lines;
15180
15833
  lines = result.lines;
@@ -15431,6 +16084,126 @@
15431
16084
  await convertBlockViaMenu(li, 'text', { caretToEnd: false });
15432
16085
  }
15433
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
+
15434
16207
  // Tab / Shift+Tab on a list item (and, v3.14.0 T3, Backspace at the start
15435
16208
  // of a nested one, which is Shift+Tab). Extracted unchanged from
15436
16209
  // handleLiKeydown(); `outdent` stands for the Shift key. `caretToEnd`
@@ -15505,6 +16278,9 @@
15505
16278
  // item goes and a transient paragraph takes its place (Enter's row-3
15506
16279
  // path). Anywhere else the browser's own deletion.
15507
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);
15508
16284
  if (e.key !== 'Enter' && e.key !== 'Tab') return false;
15509
16285
  e.preventDefault();
15510
16286
  // T21 item 2, the li half — see handleBurstKeydown()'s Tab branch. Ahead
@@ -19832,7 +20608,10 @@
19832
20608
  const r1 = blockRecOf(unit[unit.length - 1]);
19833
20609
  unitLines = lines.slice(r0.startLine - 1, r1.endLine);
19834
20610
  } else {
19835
- const res = serializeListEls(unit, { carryOver: carry });
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 });
19836
20615
  unitLines = res.md.split('\n');
19837
20616
  meta = res.lineMeta;
19838
20617
  }
@@ -20169,6 +20948,8 @@
20169
20948
  // next one.
20170
20949
  blockSelClickSuppressed = false;
20171
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;
20172
20953
  // Every defensive teardown above still runs — they are no-ops that must not
20173
20954
  // be skipped — but nothing behind the modal ARMS a new gesture. The backdrop
20174
20955
  // already makes this unreachable (measured: a Shift+press-drag on the canvas
@@ -21025,7 +21806,10 @@
21025
21806
  closeLinkField({ restore: true });
21026
21807
  return;
21027
21808
  }
21028
- f.el.style.top = Math.round(r.bottom + f.dy) + 'px';
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';
21029
21813
  f.el.style.left = Math.round(r.left + f.dx) + 'px';
21030
21814
  }
21031
21815
 
@@ -21455,6 +22239,13 @@
21455
22239
  // below and clear the set it just built; one released on the page
21456
22240
  // margin would hit the outside-a-block switchAwayFrom() instead.
21457
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
+ }
21458
22249
  // v3.17.0 T9b (R50): the release of this press already answered it.
21459
22250
  if (degradedReleaseOpened) { degradedReleaseOpened = false; return; }
21460
22251
  if (!e.target || !e.target.closest) { await switchAwayFrom(); closeGutterMenu(); return; }
@@ -21899,10 +22690,31 @@
21899
22690
  // copy of save()'s own body, just its one caller-side precondition named
21900
22691
  // once.
21901
22692
  async function commitThenSave() {
22693
+ markBurstForSave();
21902
22694
  const ok = await switchAwayFrom();
21903
22695
  if (ok) return save();
21904
22696
  }
21905
22697
 
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
+
21906
22718
  // v3.17.0 T9b added this queue for the callers that ask for a save while an
21907
22719
  // earlier one may still be on its way (a Ctrl+S held through a code edit's
21908
22720
  // or diagram panel's re-entry, a second Ctrl+S while the first one's
@@ -22036,6 +22848,21 @@
22036
22848
  }
22037
22849
 
22038
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
+ }
22039
22866
  async function undo() {
22040
22867
  // T21 item 1: an Escape that discarded a burst leaves exactly one thing
22041
22868
  // for the next Ctrl+Z to undo, and it is not on this stack — the burst
@@ -22107,7 +22934,7 @@
22107
22934
  endLine: r.op.startLine + r.op.after.length - 1,
22108
22935
  delta: r.op.before.length - r.op.after.length }
22109
22936
  : undefined;
22110
- const ok = await safeRerenderAll(editRange);
22937
+ const ok = await historyRerender(editRange);
22111
22938
  if (!ok) {
22112
22939
  // Reverse the undo attempt: push the op back and restore `lines` to
22113
22940
  // what was on screen before this undo was requested.
@@ -22153,7 +22980,7 @@
22153
22980
  endLine: Math.max(r.op.startLine - 1, r.op.endLine),
22154
22981
  delta: r.op.after.length - r.op.before.length }
22155
22982
  : undefined;
22156
- const ok = await safeRerenderAll(editRange);
22983
+ const ok = await historyRerender(editRange);
22157
22984
  if (!ok) {
22158
22985
  // Reverse the redo attempt: pop the op back off and restore `lines`.
22159
22986
  const rollback = stack.undo(lines);
@@ -22189,7 +23016,32 @@
22189
23016
  // steps the burst, exactly as Ctrl+Z does there. It never commits what is
22190
23017
  // open first and then undoes the whole commit (a transient typed into
22191
23018
  // vanished entirely that way).
22192
- if (codeEditIsOpen()) { stepCodeEditHistory(codeEditState, -1); return; }
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; }
22193
23045
  const b = currentBurst;
22194
23046
  if (b && b.editEl && b.blockType !== 'table' && b.editEl.isConnected &&
22195
23047
  b.editEl.contains(document.activeElement) && burstHasUncommittedEdit()) {
@@ -22197,18 +23049,40 @@
22197
23049
  // transient left standing after Ctrl+S, say) leaves the button the
22198
23050
  // document undo it always was.
22199
23051
  burstUndo(b.editEl);
22200
- return;
23052
+ return true;
22201
23053
  }
22202
- const saved = captureCaretForSave();
22203
- await undo();
22204
- restoreCaretForSave(saved);
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();
22205
23067
  }
22206
23068
 
22207
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
+ }
22208
23077
  const saved = captureCaretForSave();
22209
23078
  await redo();
22210
23079
  restoreCaretForSave(saved);
22211
23080
  }
23081
+ function redoInOpenEdit() {
23082
+ if (codeEditIsOpen()) { stepCodeEditHistory(codeEditState, 1); return true; }
23083
+ if (diagramPanelIsOpen()) { stepDiagramHistory(diagramPanelState, 1); return true; }
23084
+ return false;
23085
+ }
22212
23086
 
22213
23087
  // ── v3.1.0 §4: the always-visible editing toolbar ──────────────────────
22214
23088
  // The version's headline feature: before this there was no toolbar at all,
@@ -23314,7 +24188,9 @@
23314
24188
  // The hidden file input both image paths open; `onFiles` gets the picked
23315
24189
  // files (never called for an empty pick). v3.15.0 B1 fix: shared with
23316
24190
  // pickImageIntoParagraph().
23317
- function openImagePicker(onFiles, { multiple = true } = {}) {
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 } = {}) {
23318
24194
  const input = document.createElement('input');
23319
24195
  input.type = 'file';
23320
24196
  // v3.4.1: extension tokens sit beside the MIME tokens deliberately.
@@ -23335,6 +24211,10 @@
23335
24211
  if (!files.length) return;
23336
24212
  Promise.resolve(onFiles(files)).catch(showUnexpectedFailure);
23337
24213
  });
24214
+ input.addEventListener('cancel', () => {
24215
+ input.remove();
24216
+ if (typeof onCancel === 'function') Promise.resolve(onCancel()).catch(showUnexpectedFailure);
24217
+ });
23338
24218
  input.click();
23339
24219
  }
23340
24220
 
@@ -23394,6 +24274,394 @@
23394
24274
  });
23395
24275
  }
23396
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
+
23397
24665
  // ── 追加 2: paste ──────────────────────────────────────────────────────
23398
24666
  // Reads a real DataTransfer into the plain { 'mime/type': string | Blob }
23399
24667
  // shape paste-md.js's pickPayload() documents. Only ONE image flavour is
@@ -23620,6 +24888,7 @@
23620
24888
  paintSaveStatus(documentIsDirty() ? 'dirty' : 'clean');
23621
24889
  wireToolbarTracking();
23622
24890
  wireImageDrop();
24891
+ wireTextDrop();
23623
24892
  wirePlainPasteArming();
23624
24893
  // No separate first paint of the edge hint here: on this call the
23625
24894
  // remembered toolbar signature is still its initial empty string, which no
@@ -23931,6 +25200,7 @@
23931
25200
  // Both presses used to get the one cached `switching` promise and run
23932
25201
  // two save()s back to back, and the second 409'd into the conflict card.
23933
25202
  if (switching) { switching.then((ok) => { if (ok) saveAfterLastSave(); }); return; }
25203
+ markBurstForSave();
23934
25204
  switchAwayFrom().then((ok) => { if (ok) save(); });
23935
25205
  return;
23936
25206
  }
@@ -23949,6 +25219,25 @@
23949
25219
 
23950
25220
  const inTextarea = e.target && e.target.tagName === 'TEXTAREA' &&
23951
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
+ }
23952
25241
  if (inTextarea) return; // Ctrl+Enter/Esc handled by openRawEditor()'s own per-instance listener
23953
25242
 
23954
25243
  // v3.13.1 T1: a key that edits or moves, pressed while a commit is in
@@ -24118,6 +25407,12 @@
24118
25407
  if (!e.shiftKey && e.key === 'Enter') {
24119
25408
  const from = selectionFocusBlockEl();
24120
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
+ }
24121
25416
  if (from && members.length === 1 && !waveDiagramInside(from)) {
24122
25417
  e.preventDefault();
24123
25418
  clearBlockSelection();
@@ -24243,6 +25538,13 @@
24243
25538
  return;
24244
25539
  }
24245
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.
24246
25548
  if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key === 'z') {
24247
25549
  e.preventDefault();
24248
25550
  undo();
@@ -24551,6 +25853,15 @@
24551
25853
  if (e.cancelable) e.preventDefault();
24552
25854
  noteStaleScreenDrop();
24553
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);
24554
25865
  document.addEventListener('compositionend', (e) => {
24555
25866
  if (!staleScreenInput(e.target) || !e.data) return;
24556
25867
  takeBackComposedText(e.data);
@@ -25880,6 +27191,7 @@
25880
27191
  b.type = 'button';
25881
27192
  b.className = 'ed-imgtb-btn' + (extraClass ? ' ' + extraClass : '');
25882
27193
  b.textContent = label;
27194
+ b.tabIndex = -1; // 0 only while the toolbar is open by keyboard (A1)
25883
27195
  b.addEventListener('click', (e) => { e.stopPropagation(); onClick(); });
25884
27196
  imgTbButtons.appendChild(b);
25885
27197
  return b;
@@ -25895,6 +27207,14 @@
25895
27207
  let imgTbBlock = null; // the .ed-block the toolbar is drawn for
25896
27208
  let imgTbHideTimer = null;
25897
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;
25898
27218
 
25899
27219
  // An image block: a paragraph whose source is one image (D16) and whose
25900
27220
  // rendered content is exactly one img. .drawio / .xml keep their own flow.
@@ -25922,21 +27242,35 @@
25922
27242
  if (imgTbHideTimer !== null) { clearTimeout(imgTbHideTimer); imgTbHideTimer = null; }
25923
27243
  }
25924
27244
  function scheduleImageToolbarHide() {
25925
- if (imgTb.hidden || imgTbAlt || imgTbHideTimer !== null) return;
27245
+ if (imgTb.hidden || imgTbAlt || imgTbKeyboard || imgTbHideTimer !== null) return;
25926
27246
  imgTbHideTimer = setTimeout(() => { imgTbHideTimer = null; if (!imgTbAlt) hideImageToolbar(); }, IMGTB_HIDE_MS);
25927
27247
  }
25928
- function hideImageToolbar() {
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) {
25929
27252
  cancelImageToolbarHide();
25930
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;
25931
27260
  imgTbAlt = null;
25932
27261
  imgTbBlock = null;
27262
+ imgTbKeyboard = false;
27263
+ imgTbButtonsEl.forEach((b) => { b.tabIndex = -1; });
25933
27264
  if (wasAlt) {
25934
27265
  imgTbInput.hidden = true;
25935
27266
  imgTbButtons.hidden = false;
25936
27267
  if (document.activeElement === imgTbInput) imgTbInput.blur();
27268
+ setDirty();
25937
27269
  }
25938
27270
  if (imgTb.hidden) return;
25939
27271
  imgTb.hidden = true;
27272
+ // Opened by keyboard: focus goes back to the block selection it came from.
27273
+ if (wasKeyboard && hadFocus && blockSelection) applySelectionClasses();
25940
27274
  }
25941
27275
  function reconcileImageToolbar() {
25942
27276
  if (imgTbBlock !== null && !imgTbBlock.isConnected) hideImageToolbar();
@@ -25944,10 +27278,10 @@
25944
27278
  function placeImageToolbar(blockEl) {
25945
27279
  const img = blockEl.querySelector('img');
25946
27280
  const r = img ? img.getBoundingClientRect() : null;
25947
- 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; }
25948
27282
  imgTb.hidden = false;
25949
27283
  const top = Math.max(r.top + IMGTB_INSET_PX, toolbarTopInset() + 4);
25950
- if (top > r.bottom - 4) { hideImageToolbar(); return false; }
27284
+ if (top > r.bottom - 4) { hideImageToolbar({ keepDraft: true }); return false; }
25951
27285
  imgTb.style.top = Math.round(top) + 'px';
25952
27286
  imgTb.style.left = Math.round(Math.max(4, Math.min(r.left + IMGTB_INSET_PX,
25953
27287
  document.documentElement.clientWidth - imgTb.offsetWidth - 4))) + 'px';
@@ -25966,24 +27300,40 @@
25966
27300
  // everything that could have moved it (R104: by source, and only when it is
25967
27301
  // the one such block), one commitRangeEdit over its line, nothing written
25968
27302
  // when the source reads the same. false: refused or the render failed.
25969
- async function applyImageEdit(blockEl, identity, patch) {
25970
- if (!identity || !inlineMd) return false;
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; }
25971
27311
  await settleCommits();
25972
27312
  const ok = await switchAwayFrom();
25973
- if (!ok) return false;
27313
+ if (!ok) { note(false); return false; }
25974
27314
  let el = blockEl && blockEl.isConnected ? blockEl : null;
25975
27315
  let rec = el ? blockRecOf(el) : null;
25976
27316
  if (!rec || blockSourceOf(rec) !== identity.source || identity.reconcileEpoch !== reconcileEpoch) {
25977
27317
  el = uniqueBlockElBySource(identity);
25978
27318
  rec = el ? blockRecOf(el) : null;
25979
27319
  }
25980
- 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; }
25981
27321
  const source = blockSourceOf(rec);
25982
27322
  const next = inlineMd.rewriteImage(source, patch);
25983
- if (next === source) return true;
27323
+ if (next === source) { note(true); return true; }
25984
27324
  const r = await commitFencedRangeAndRender(rec.startLine, rec.endLine, next);
27325
+ note(r !== false && r !== null);
25985
27326
  return r === true;
25986
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
+ }
25987
27337
 
25988
27338
  // 替換 (and T8's drop): the picked file replaces the picture, keeping alt
25989
27339
  // and title. A file that is not a raster picture writes nothing (R39).
@@ -26014,17 +27364,36 @@
26014
27364
  if (!live) { showError(DROPPED_GESTURE_MESSAGE, null, null); return false; }
26015
27365
  const path = await uploadImageBlob(file);
26016
27366
  if (!path) return false;
26017
- return applyImageEdit(el, live, { dest: markdownImageDest(path) });
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;
26018
27377
  }
26019
27378
 
26020
- function exitImageAltMode() {
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) {
26021
27385
  if (!imgTbAlt) return;
26022
27386
  imgTbAlt = null;
26023
- imgTbInput.hidden = true;
27387
+ imgTbAltDraft = null;
26024
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;
26025
27393
  if (document.activeElement === imgTbInput) imgTbInput.blur();
26026
27394
  if (imgTbBlock) placeImageToolbar(imgTbBlock);
26027
27395
  scheduleImageToolbarHide();
27396
+ setDirty();
26028
27397
  }
26029
27398
  async function openImageAltMode() {
26030
27399
  const first = imgTbBlock;
@@ -26040,15 +27409,25 @@
26040
27409
  const parsed = inlineMd.parseImageParagraph(blockSourceOf(blockRecOf(live)));
26041
27410
  imgTbBlock = live;
26042
27411
  imgTbAlt = { identity: captureBlockIdentity(live), original: parsed.alt };
27412
+ const draft = imgTbAltDraft && imgTbAltDraft.source === blockSourceOf(blockRecOf(live)) ? imgTbAltDraft : null;
27413
+ imgTbAltDraft = null;
26043
27414
  imgTbButtons.hidden = true;
26044
27415
  imgTbInput.hidden = false;
26045
- imgTbInput.value = parsed.alt;
27416
+ imgTbInput.value = draft ? draft.value : parsed.alt;
26046
27417
  if (!placeImageToolbar(live)) return;
26047
27418
  imgTbInput.focus({ preventScroll: true });
26048
- imgTbInput.select();
27419
+ if (draft) imgTbInput.setSelectionRange(draft.value.length, draft.value.length);
27420
+ else imgTbInput.select();
27421
+ setDirty();
26049
27422
  }
26050
27423
  imgTbInput.addEventListener('keydown', (e) => {
26051
- if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); exitImageAltMode(); return; }
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
+ }
26052
27431
  if (e.key !== 'Enter' || e.isComposing) return;
26053
27432
  e.preventDefault();
26054
27433
  e.stopPropagation();
@@ -26059,7 +27438,12 @@
26059
27438
  if (!alt || value === alt.original) return;
26060
27439
  applyImageEdit(blk, alt.identity, { alt: value }).catch(showUnexpectedFailure);
26061
27440
  });
26062
- imgTbInput.addEventListener('blur', () => { if (imgTbAlt) exitImageAltMode(); });
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(); });
26063
27447
 
26064
27448
  // Final review I1: the 🖼 path's order (pickAndInsertImage()). The press
26065
27449
  // resolves any open session first and takes the block's identity while its
@@ -26082,7 +27466,7 @@
26082
27466
  if (!blk || !blk.isConnected) return;
26083
27467
  pickReplacementImage(blk).catch(showUnexpectedFailure);
26084
27468
  });
26085
- addImgTbButton('替代文字', '', () => { openImageAltMode().catch(showUnexpectedFailure); });
27469
+ const imgTbAltButton = addImgTbButton('替代文字', '', () => { openImageAltMode().catch(showUnexpectedFailure); });
26086
27470
  addImgTbButton('刪除', 'ed-imgtb-delete', () => {
26087
27471
  const blk = imgTbBlock;
26088
27472
  hideImageToolbar();
@@ -26091,21 +27475,79 @@
26091
27475
  deleteBlockViaGutter(blk).catch(showUnexpectedFailure);
26092
27476
  });
26093
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
+
26094
27524
  // Presses keep focus where it is; the alt input is the one thing that takes it.
26095
27525
  imgTb.addEventListener('mousedown', (e) => { if (e.target !== imgTbInput) e.preventDefault(); });
26096
27526
  document.addEventListener('mouseover', (ev) => {
26097
27527
  const t = ev.target;
26098
- if (!t || typeof t.closest !== 'function' || imgTbAlt) return;
27528
+ if (!t || typeof t.closest !== 'function' || imgTbAlt || imgTbKeyboard) return;
26099
27529
  if (t.closest('.ed-imgtb')) { cancelImageToolbarHide(); return; }
26100
27530
  const blk = t.closest('.ed-block[data-block-type="paragraph"]');
26101
27531
  if (blk && isImageBlock(blk)) showImageToolbarFor(blk); else scheduleImageToolbarHide();
26102
27532
  }, true);
26103
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
+ }
26104
27546
  document.addEventListener('scroll', (ev) => {
26105
27547
  if (ev.target === imgTbInput || imgTb.contains(ev.target)) return;
26106
- hideImageToolbar();
27548
+ repositionOrHideImageToolbar();
26107
27549
  }, { passive: true, capture: true });
26108
- window.addEventListener('resize', () => { hideImageToolbar(); });
27550
+ window.addEventListener('resize', () => { repositionOrHideImageToolbar(); });
26109
27551
 
26110
27552
  // ── v3.17.0 T5: the diagram panel — the source and a live preview (D15) ──
26111
27553
  //
@@ -26155,6 +27597,7 @@
26155
27597
  }
26156
27598
  function disposeDiagramPanel(s) {
26157
27599
  if (s.timer) { clearTimeout(s.timer); s.timer = null; }
27600
+ if (s.historyTimer) { clearTimeout(s.historyTimer); s.historyTimer = null; }
26158
27601
  s.previewSeq++; // an answer still out lands nowhere
26159
27602
  s.listeners.abort();
26160
27603
  if (s.offTheme) { s.offTheme(); s.offTheme = null; }
@@ -26279,6 +27722,7 @@
26279
27722
  originalHtml: blockEl.innerHTML, darkAtOpen: diagramThemeIsDark(),
26280
27723
  timer: null, previewSeq: 0, inflight: null, lastResult: undefined, reentering: null, pressInside: false,
26281
27724
  listeners: new AbortController(), offTheme: null, editor: null,
27725
+ history: null, historyTimer: null, composing: false,
26282
27726
  };
26283
27727
  s.editor = {
26284
27728
  blockEl,
@@ -26302,9 +27746,24 @@
26302
27746
  ta.focus({ preventScroll: true });
26303
27747
  if (o.seed) ta.setSelectionRange(o.seed.selStart, o.seed.selEnd);
26304
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);
26305
27754
 
26306
27755
  const on = { signal: s.listeners.signal };
26307
- 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);
26308
27767
  panel.addEventListener('keydown', (e) => onDiagramPanelKeydown(s, e), on);
26309
27768
  // R36: a press inside never ends the panel. On anything but the source
26310
27769
  // and the preview it keeps focus where it is; a focusout with no
@@ -26358,18 +27817,28 @@
26358
27817
  else if (e.key === 'Enter' && mod) act = 'commit';
26359
27818
  else if (mod && !e.altKey && !e.shiftKey && (e.key === 's' || e.key === 'S')) act = 'save';
26360
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';
26361
27823
  if (!act) return;
26362
27824
  e.preventDefault();
26363
27825
  e.stopPropagation();
26364
- if (act === 'tab') { insertIntoDiagramSource(s.ta, ' '); return; }
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; }
26365
27834
  if (s.reentering) { s.reentering[act] = true; return; }
26366
27835
  if (s.inflight || liveDiagramPanel() !== s) return; // a commit is out: it ends the panel, or leaves it open on failure
26367
27836
  if (act === 'cancel') cancelDiagramPanel(s).catch(showUnexpectedFailure);
26368
27837
  else if (act === 'commit') commitDiagramPanel(s).catch(showUnexpectedFailure);
26369
27838
  else commitDiagramPanelAndReenter(s, { save: true }).catch(showUnexpectedFailure);
26370
27839
  }
26371
- // Through the browser's own insertText, so the textarea's native undo
26372
- // (Ctrl+Z inside it) takes the two spaces back too.
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.
26373
27842
  function insertIntoDiagramSource(ta, text) {
26374
27843
  let ok = false;
26375
27844
  try { ok = document.execCommand('insertText', false, text); } catch (e) { ok = false; }
@@ -26379,6 +27848,45 @@
26379
27848
  }
26380
27849
  }
26381
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
+
26382
27890
  function scheduleDiagramPreview(s) {
26383
27891
  if (s.timer) clearTimeout(s.timer);
26384
27892
  s.timer = setTimeout(() => { s.timer = null; runDiagramPreview(s); }, DIAGRAM_PREVIEW_MS);
@@ -26574,6 +28082,7 @@
26574
28082
  const ta = s.ta;
26575
28083
  const startLine = s.startLine;
26576
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
26577
28086
  const flags = { cancel: false, commit: false, save: false };
26578
28087
  s.reentering = flags;
26579
28088
  const hold = { keys: [], clickedAway: false, passTarget: ta };
@@ -26593,7 +28102,7 @@
26593
28102
  if (!target && ok && !hold.clickedAway && s.lastResult !== RENDER_SUPERSEDED) {
26594
28103
  const el = blockElAtLine(startLine);
26595
28104
  const seed = { value: ta.value, selStart: ta.selectionStart, selEnd: ta.selectionEnd,
26596
- previewHtml: s.preview.innerHTML };
28105
+ previewHtml: s.preview.innerHTML, history: s.history };
26597
28106
  if (el && diagramPanelKindOf(el, s.kind) && await openDiagramPanel(el, { seed, kind: s.kind })) target = liveDiagramPanel();
26598
28107
  }
26599
28108
  if (ok && opts.save) saveAfterLastSave();
@@ -26643,7 +28152,7 @@
26643
28152
  dropped = true;
26644
28153
  }
26645
28154
  }
26646
- if (changed) { autoSize(ta); scheduleDiagramPreview(s); setDirty(); }
28155
+ if (changed) { autoSize(ta); scheduleDiagramPreview(s); flushDiagramTyping(s); setDirty(); }
26647
28156
  if (dropped) showNotice(FLIGHT_DROPPED_MESSAGE);
26648
28157
  return leave;
26649
28158
  }
@@ -27313,7 +28822,7 @@
27313
28822
  const back = popped.length === offer.count &&
27314
28823
  lines.slice(b.startLine, b.startLine + n).join('\n') === offer.baseSource;
27315
28824
  if (back) setDirty();
27316
- const ok = back && await safeRerenderAll({
28825
+ const ok = back && await historyRerender({
27317
28826
  startLine: b.startLine + 1, endLine: b.startLine + n, delta: lines.length - prevLines.length,
27318
28827
  });
27319
28828
  if (!ok) {
@@ -27351,7 +28860,7 @@
27351
28860
  if (ok) {
27352
28861
  setDirty();
27353
28862
  const b = blocks.find((x) => x.id === ro.offer.blockId);
27354
- ok = await safeRerenderAll(b ? { startLine: b.startLine + 1, endLine: b.startLine + 1,
28863
+ ok = await historyRerender(b ? { startLine: b.startLine + 1, endLine: b.startLine + 1,
27355
28864
  delta: lines.length - prevLines.length } : undefined);
27356
28865
  }
27357
28866
  if (!ok) {