@helping-ai-workflow/md2doc 3.13.1 → 3.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -236,6 +236,57 @@
236
236
  return { lines: r.lines, blocks, op, newStartLine };
237
237
  }
238
238
 
239
+ // v3.14.0 T1: where a transient paragraph's lines land when it is
240
+ // committed. `anchorBlockId` is the real block the transient sits after
241
+ // (the `data-block-id` of transientAnchorEl() in the browser half), or
242
+ // null when nothing precedes it. Three cases, each ONE op:
243
+ // * anchored — exactly commitBlockInsertion(), blank-line rules included.
244
+ // * null with blocks present — the transient opens the document. The
245
+ // first block's start line is replaced by [...newLines, '', that line]
246
+ // through commitRangeEdit(), and EVERY block is shifted: commitRangeEdit
247
+ // shifts only the blocks after its anchor, and here the anchor itself
248
+ // (the first block) moves too.
249
+ // * no blocks at all — an empty or whitespace-only file. Its whole line
250
+ // range is replaced by [...newLines, ''], so the file ends with exactly
251
+ // one newline whatever blank lines it held.
252
+ // Returns { lines, blocks, op, newStartLine }; op is null only for a
253
+ // refusal (commitBlockInsertion()'s inverted-anchor case).
254
+ function commitInsertionAt(state, anchorBlockId, newLines) {
255
+ if (anchorBlockId !== null && anchorBlockId !== undefined) {
256
+ return commitBlockInsertion(state, anchorBlockId, newLines);
257
+ }
258
+ if (!state.blocks.length) {
259
+ if (!state.lines.length) {
260
+ const after = newLines.concat(['']);
261
+ const op = { startLine: 1, endLine: 0, before: [], after };
262
+ const r = ops.insertLines(state.lines, 0, after);
263
+ state.stack.push(op);
264
+ return { lines: r.lines, blocks: state.blocks, op, newStartLine: 1 };
265
+ }
266
+ const z = commitRangeEdit(state, 1, state.lines.length, newLines.concat(['']).join('\n'));
267
+ return { lines: z.lines, blocks: z.blocks, op: z.op, newStartLine: 1 };
268
+ }
269
+ const first = state.blocks[0];
270
+ const at = first.startLine;
271
+ const r = commitRangeEdit(state, at, at, newLines.concat(['', state.lines[at - 1]]).join('\n'));
272
+ if (r.op === null) return { lines: r.lines, blocks: r.blocks, op: null, newStartLine: null };
273
+ const delta = r.lines.length - state.lines.length;
274
+ const blocks = state.blocks.map((b) =>
275
+ Object.assign({}, b, { startLine: b.startLine + delta, endLine: b.endLine + delta }));
276
+ return { lines: r.lines, blocks, op: r.op, newStartLine: at };
277
+ }
278
+
279
+ // v3.14.0 T2: Enter mid-paragraph (or mid-heading). The block's lines are
280
+ // replaced by the left half, one blank line and the right half — ONE op
281
+ // through commitRangeEdit(), so one Ctrl+Z puts the paragraph back.
282
+ // `rightStartLine` is where the right half starts once this lands.
283
+ function commitSplit(state, blockId, leftText, rightText) {
284
+ const block = state.blocks.find((b) => b.id === blockId);
285
+ const r = commitRangeEdit(state, block.startLine, block.endLine, leftText + '\n\n' + rightText);
286
+ return { lines: r.lines, blocks: r.blocks, op: r.op,
287
+ rightStartLine: block.startLine + leftText.split('\n').length + 1 };
288
+ }
289
+
239
290
  // ── S4 Task 3: relocating ONE block's lines (spec §4.5) ────────────────
240
291
  //
241
292
  // A move is ONE commitRangeEdit over
@@ -742,7 +793,7 @@
742
793
  }
743
794
 
744
795
  if (typeof module === 'object' && module.exports) {
745
- module.exports = { pasteIsAllListLines, LIST_LINE_RE, extractBlockSource, commitEdit, commitRangeEdit, commitRangeRemoval, commitListBlockRemoval, commitBlockInsertion, planBlockMove, commitBlockMove, reorderSpanRange, spanMoveRange, spanIndentsAreAnchored, blockMoveSeamRefusal, rollbackFailedRender, headingDepthOf, withHeadingDepth, resolveDrawioLanding, shiftBlocksAfterBodyEdit, waveDiscardIsSafe, waveOfferHolds, waveRedoHolds };
796
+ module.exports = { pasteIsAllListLines, LIST_LINE_RE, extractBlockSource, commitEdit, commitRangeEdit, commitRangeRemoval, commitListBlockRemoval, commitBlockInsertion, commitInsertionAt, commitSplit, planBlockMove, commitBlockMove, reorderSpanRange, spanMoveRange, spanIndentsAreAnchored, blockMoveSeamRefusal, rollbackFailedRender, headingDepthOf, withHeadingDepth, resolveDrawioLanding, shiftBlocksAfterBodyEdit, waveDiscardIsSafe, waveOfferHolds, waveRedoHolds };
746
797
  return; // node: pure core only
747
798
  }
748
799
 
@@ -1005,8 +1056,32 @@
1005
1056
  // instead of leaving the skeleton on disk. Editing ANYTHING clears it
1006
1057
  // (see each call site below) — from that point on the block is a normal,
1007
1058
  // permanently-committed one like any other.
1059
+ // Since v3.14.0 (T1) the 段落 kind never reaches this: a new paragraph is a
1060
+ // transient that writes nothing in the first place (see createTransient()),
1061
+ // so four skeletons — heading, list, table, code — use it today.
1008
1062
  let pristineInsert = null; // { blockId } | null
1009
1063
 
1064
+ // v3.14.0 T1: the transient empty paragraph (spec §2) — an id-less
1065
+ // `.ed-block[data-ed-transient]` that writes no byte until it has content.
1066
+ // `transient` is the one that is on screen (at most one, D3), or null:
1067
+ // { el, surface, byEnter, anchorCaret, stayEl }. A render rebuilds `.content` and
1068
+ // drops it (applyFullRender() nulls this). `transientLandedLine` is the
1069
+ // start line its last successful commit landed on (R8) — read by callers
1070
+ // that commit a transient and then act on the block it became.
1071
+ let transient = null;
1072
+ let transientLandedLine = null;
1073
+ // Fix round 1 (I2): the line shifts landed transient commits made, so a
1074
+ // gesture that captured a block's identity before one can still find it
1075
+ // (identityStartLineNow()).
1076
+ const transientCommits = { seq: 0, log: [] };
1077
+ // v3.14.0 T2 (D7): a structural edit whose render failed and that the
1078
+ // screen still shows — see retryPendingStructure(). null, or
1079
+ // { range, before, surfaces, serialize }.
1080
+ let pendingStructure = null;
1081
+ // D13 / R1: the hint on an empty paragraph. "/" does not exist yet, so the
1082
+ // hint does not name it; v3.15.0 changes this one constant.
1083
+ const PLACEHOLDER_TEXT = '輸入文字';
1084
+
1010
1085
  // Task 5 fix (found via a standalone repro harness — see the task-5
1011
1086
  // report): a table mutation that DETACHES the focused cell — whether by
1012
1087
  // reassigning tableEl.innerHTML wholesale or by re-parenting the cell/row
@@ -1128,6 +1203,9 @@
1128
1203
  // open with its edit intact, banner already shown, caller must abandon
1129
1204
  // whatever it was about to do).
1130
1205
  async function resolveOpenSession() {
1206
+ // v3.14.0 T2 (D7): a structure whose render failed goes first — anything
1207
+ // committed before it would render, and a render drops its screen half.
1208
+ if (pendingStructure && !(await retryPendingStructure())) return false;
1131
1209
  if (currentBurst) {
1132
1210
  const ok = await resolveBurst();
1133
1211
  if (!ok) return false;
@@ -1192,7 +1270,11 @@
1192
1270
  // The distance was a second record of "are we standing where we saved";
1193
1271
  // `UndoStack` now answers that question itself, and a boolean has no zero
1194
1272
  // to walk through. See lineops.js's own comment for the full story.
1195
- return stack.isDirty() || burstHasUncommittedEdit() || waveUnwrittenEdit;
1273
+ // v3.14.0 T6 (R34): a structure whose render failed is on screen and not
1274
+ // in `lines` (pendingStructure) — unsaved work like an open burst's text.
1275
+ // Measured before: a failed split, then the file changed on disk, and
1276
+ // onDiskMtime() reloaded the page silently, the split gone.
1277
+ return stack.isDirty() || burstHasUncommittedEdit() || waveUnwrittenEdit || pendingStructure !== null;
1196
1278
  }
1197
1279
 
1198
1280
  function setDirty() {
@@ -1876,6 +1958,9 @@
1876
1958
  // 要為一次沒上畫面的編輯升起警告」這件事寫在這裡,而不是留給讀者去讀
1877
1959
  // 它們的本體。
1878
1960
  if (!ok) return false;
1961
+ // v3.14.0 T4: a render that leaves no block at all (the last one deleted)
1962
+ // is an empty document again, with a transient to type into.
1963
+ ensureEmptyDocTransient();
1879
1964
  lastRenderLines = lines.slice();
1880
1965
  lastRenderEndedUnclosed = nowUnclosed;
1881
1966
  // 升 banner 排在套用之後:套用是把新的 DOM 放上畫面那一步,警告的是使用
@@ -2528,6 +2613,9 @@
2528
2613
  // insertBlockBelow() rerenderAll() awaits, so it can never clobber a
2529
2614
  // fresh assignment.
2530
2615
  pristineInsert = null;
2616
+ // v3.14.0 T1: the transient lived in the `.content` children the
2617
+ // innerHTML write above just replaced — every render drops it (D3).
2618
+ transient = null;
2531
2619
  // Task 2 (Phase 3): same belt-and-braces reasoning for the always-on
2532
2620
  // WYSIWYG burst — whatever block had one open a moment ago was just
2533
2621
  // detached by the innerHTML replacement above, so its own focusout
@@ -2737,9 +2825,15 @@
2737
2825
  // * A "positioned" entry is the first one held after a pointer press into
2738
2826
  // a surface during the flight: it carries a tag (surface, block id,
2739
2827
  // table cell index) and the full selection (anchor and focus offsets),
2740
- // restored before it replays. Offsets are shifted past the held text
2741
- // that will be inserted before them in the same surface by the time it
2742
- // replays (heldShiftBefore()). The tag is re-pointed when a render
2828
+ // restored before it replays. Its offsets are the ones the user saw,
2829
+ // in the surface's text at hold time, and the tag records how many
2830
+ // replayed edits that surface had logged by then (`logAt`). Just before
2831
+ // it replays, the offsets are mapped through the edits logged in that
2832
+ // surface AFTER that point — the ones the user's text did not hold yet
2833
+ // — as measured (flightEditLog, mapOffsetThroughLog(); v3.14.0 T7, D10,
2834
+ // fix round 1). When a render replaces the surface, the offsets are
2835
+ // mapped through the old surface's later edits right there and the tag
2836
+ // starts over against the new one's log. The tag is re-pointed when a render
2743
2837
  // replaces its surface: by the committed run's own line map for an item
2744
2838
  // of a list run the commit rewrote (an item the split pushed down a
2745
2839
  // line, the provisional item), otherwise by its block's start line,
@@ -2851,6 +2945,7 @@
2851
2945
  cellIndex: isCell && blockEl
2852
2946
  ? Array.prototype.indexOf.call(blockEl.querySelectorAll('.ed-wys-cell'), snap.surface) : null,
2853
2947
  anchor: snap.anchor, focus: snap.focus,
2948
+ logAt: flightLogLength(snap.surface),
2854
2949
  };
2855
2950
  }
2856
2951
 
@@ -2870,24 +2965,44 @@
2870
2965
  return map;
2871
2966
  }
2872
2967
 
2873
- // How far held text will have pushed `offset` in `surface` by the time an
2874
- // entry positioned there replays: the text of every run pinned (or, for
2875
- // the opening run, typed) in the same surface at or before that offset.
2876
- function heldShiftBefore(surface, offset) {
2877
- let pin = null;
2878
- let shift = 0;
2879
- flightBuffer.forEach((en, i) => {
2880
- if (en.pos || i === 0) pin = en.pos || en.hint;
2881
- if (en.kind !== 'text' || !pin || pin.surface !== surface || pin.anchor === null) return;
2882
- if (Math.min(pin.anchor, pin.focus === null ? pin.anchor : pin.focus) <= offset) shift += en.data.length;
2883
- });
2884
- return shift;
2968
+ // v3.14.0 T7 (D10): what each replayed entry did to its surface, measured
2969
+ // rather than predicted: surface -> [{ at, delta }] in replay order, cleared
2970
+ // when the buffer empties. A positioned entry's offsets were taken in the
2971
+ // DOM the user clicked, which none of the held edits had reached yet; each
2972
+ // logged edit moves an offset at or past its `at` by its `delta`, never
2973
+ // back over `at`. Predicting the shift at hold time counted only
2974
+ // held TEXT: measured before, a Backspace,
2975
+ // a paste or a replaced word ahead of a second click in the same paragraph
2976
+ // put that click's keys one character, two, or eight away from it, and two
2977
+ // clicks at one spot replayed their keys in reverse order.
2978
+ const flightEditLog = new Map();
2979
+ function surfaceTextLength(surface) {
2980
+ const r = document.createRange();
2981
+ r.selectNodeContents(surface);
2982
+ return r.toString().length;
2983
+ }
2984
+ function flightLogLength(surface) {
2985
+ const log = surface ? flightEditLog.get(surface) : null;
2986
+ return log ? log.length : 0;
2987
+ }
2988
+ // `tag`'s offset `o` through the edits its surface logged after the tag
2989
+ // was taken (fix round 1, I1: mapping the whole log shifted a tag taken in
2990
+ // a surface that already held an earlier replayed edit a second time).
2991
+ function mapOffsetThroughLog(tag, o) {
2992
+ if (o === null || o === undefined || !tag || !tag.surface) return o;
2993
+ const log = flightEditLog.get(tag.surface) || [];
2994
+ for (let i = tag.logAt || 0; i < log.length; i++) {
2995
+ const m = log[i];
2996
+ o = o < m.at ? o : Math.max(m.at, o + m.delta);
2997
+ }
2998
+ return o;
2885
2999
  }
2886
- function shiftedTag(tag) {
2887
- if (!tag || tag.anchor === null) return tag;
2888
- tag.anchor += heldShiftBefore(tag.surface, tag.anchor);
2889
- if (tag.focus !== null) tag.focus += heldShiftBefore(tag.surface, tag.focus);
2890
- return tag;
3000
+ // Ctrl/Cmd+B / I / U (no Alt, no Shift): the native formatting command.
3001
+ function formatCommandForKey(e) {
3002
+ const mod = FLIGHT_IS_MAC ? (e.metaKey && !e.ctrlKey) : (e.ctrlKey && !e.metaKey);
3003
+ if (!mod || e.altKey || e.shiftKey || typeof e.key !== 'string') return null;
3004
+ const k = e.key.toLowerCase();
3005
+ return k === 'b' ? 'bold' : k === 'i' ? 'italic' : k === 'u' ? 'underline' : null;
2891
3006
  }
2892
3007
 
2893
3008
  // Pins the run of continuation entries that opened this flight (if it is
@@ -2905,7 +3020,7 @@
2905
3020
  entry.hint = flightTagOf(selectionSnapshotIn(surface));
2906
3021
  if (flightSelMoved) {
2907
3022
  flightSelMoved = false;
2908
- entry.pos = shiftedTag(flightTagOf(selectionSnapshotIn(surface)));
3023
+ entry.pos = flightTagOf(selectionSnapshotIn(surface));
2909
3024
  }
2910
3025
  const last = flightBuffer[flightBuffer.length - 1];
2911
3026
  if (entry.kind === 'text' && !entry.pos && last && last.kind === 'text') {
@@ -2919,7 +3034,7 @@
2919
3034
  function reanchorFlightBuffer(before, editRange, lineMap) {
2920
3035
  if (flightSelMoved && lastRenderSel) {
2921
3036
  flightSelMoved = false;
2922
- flightBuffer.push({ kind: 'focus', pos: shiftedTag(flightTagOf(lastRenderSel)) });
3037
+ flightBuffer.push({ kind: 'focus', pos: flightTagOf(lastRenderSel) });
2923
3038
  }
2924
3039
  flightFallback = lastRenderSel && !lastRenderSel.surface.isConnected
2925
3040
  ? flightTagOf(lastRenderSel) : null;
@@ -2942,7 +3057,23 @@
2942
3057
  if (editRange && line > editRange.endLine) line += editRange.delta;
2943
3058
  }
2944
3059
  }
3060
+ // Fix round 1 (I1): on a commit render, the render replaced the surface
3061
+ // with one built from the source that commit wrote, which holds every
3062
+ // edit the replay made in the old one before the commit. So the offsets
3063
+ // are mapped through the old surface's later edits now, and the tag
3064
+ // starts over against the new surface's log. This holds for a commit
3065
+ // render only (final review M2): a rollback render (the
3066
+ // PENDING_STRUCTURE_LOST branch) or a global undo() render builds the
3067
+ // surface from a source without those edits, and a held click's offset
3068
+ // can then be off by their length. Accepted for v3.14.0: neither is
3069
+ // reached by a known gesture short of four inside one render. Measured before: click B@5, X, click Omega, Y, click
3070
+ // B@10, Z — Z landed one character short, `BravoX parZagraph`.
3071
+ if (pos.surface) {
3072
+ pos.anchor = mapOffsetThroughLog(pos, pos.anchor);
3073
+ pos.focus = mapOffsetThroughLog(pos, pos.focus);
3074
+ }
2945
3075
  pos.surface = null;
3076
+ pos.logAt = 0;
2946
3077
  if (line === null) return;
2947
3078
  const nb = blocks.find((b) => b.startLine === line);
2948
3079
  const nEl = nb ? document.querySelector('.ed-block[data-block-id="' + nb.id + '"]') : null;
@@ -2952,11 +3083,19 @@
2952
3083
  : blockContentEl(nEl);
2953
3084
  if (surf && !surf.matches('.ed-wys-armed, .ed-wys-cell')) surf = null;
2954
3085
  pos.surface = surf;
3086
+ pos.logAt = flightLogLength(surf);
2955
3087
  pos.blockId = String(nb.id);
2956
3088
  };
2957
3089
  flightBuffer.forEach((en) => { remap(en.pos); remap(en.hint); });
2958
3090
  remap(flightFallback);
3091
+ focusinWaitTags.forEach(remap);
2959
3092
  }
3093
+ // v3.14.0 T1 fix round 1 (I1): the surfaces a focusin is waiting to open a
3094
+ // burst on while a resolution renders (see the focusin handler). Each is a
3095
+ // flight tag, re-pointed by every landed render exactly like a held entry's,
3096
+ // because the block's id is not a stable way back once a render changed the
3097
+ // block count above it.
3098
+ const focusinWaitTags = [];
2960
3099
 
2961
3100
  // A deletion chord's input type, and a caret chord's movement, per
2962
3101
  // platform (Ctrl on Linux/Windows, Alt and Cmd on a Mac).
@@ -2987,7 +3126,7 @@
2987
3126
  return null;
2988
3127
  }
2989
3128
  function isHeldTyping(en) {
2990
- return en.kind === 'text' || en.kind === 'paste' || en.kind === 'delete' ||
3129
+ return en.kind === 'text' || en.kind === 'paste' || en.kind === 'delete' || en.kind === 'format' ||
2991
3130
  (en.kind === 'key' && !caretMoveForKey(en));
2992
3131
  }
2993
3132
  function dropHeldTyping() {
@@ -3047,6 +3186,11 @@
3047
3186
  routePaste(target, item.dt);
3048
3187
  } else if (item.kind === 'delete') {
3049
3188
  nativeFlightDeletion(item.inputType, target);
3189
+ } else if (item.kind === 'format') {
3190
+ // v3.14.0 T7 (D9): the command the live chord runs natively.
3191
+ snapBurstIfActive(target, 'replay-pre');
3192
+ document.execCommand(item.command);
3193
+ snapBurstIfActive(target, 'replay');
3050
3194
  } else if (item.kind === 'key') {
3051
3195
  const ev = new KeyboardEvent('keydown', { key: item.key, shiftKey: item.shiftKey,
3052
3196
  ctrlKey: item.ctrlKey, altKey: item.altKey, metaKey: item.metaKey, bubbles: true, cancelable: true });
@@ -3076,14 +3220,16 @@
3076
3220
  target.focus();
3077
3221
  if (commitFlight || switching) break;
3078
3222
  }
3079
- restoreSelection(target, item.pos.anchor, item.pos.focus);
3223
+ restoreSelection(target, mapOffsetThroughLog(item.pos, item.pos.anchor),
3224
+ mapOffsetThroughLog(item.pos, item.pos.focus));
3080
3225
  } else {
3081
3226
  target = activeFlightSurface();
3082
3227
  const fb = flightFallback;
3083
3228
  if (!target && fb && fb.surface && fb.surface.isConnected) {
3084
3229
  fb.surface.focus();
3085
3230
  if (commitFlight || switching) break;
3086
- restoreSelection(fb.surface, fb.anchor, fb.focus);
3231
+ restoreSelection(fb.surface, mapOffsetThroughLog(fb, fb.anchor),
3232
+ mapOffsetThroughLog(fb, fb.focus));
3087
3233
  flightFallback = null;
3088
3234
  target = activeFlightSurface();
3089
3235
  }
@@ -3091,10 +3237,26 @@
3091
3237
  }
3092
3238
  flightBuffer.shift();
3093
3239
  if (item.kind !== 'focus') {
3240
+ const pre = selectionSnapshotIn(target);
3241
+ const len0 = surfaceTextLength(target);
3094
3242
  flightReplaying = true;
3095
3243
  try { replayFlightEntry(item, target); } finally { flightReplaying = false; }
3244
+ // T7: measured only where it can be — the surface still on screen
3245
+ // and still focused (an Enter that moved the caret on, a commit that
3246
+ // replaced the surface, is no edit IN it to map through).
3247
+ if (pre && pre.anchor !== null && target.isConnected && activeFlightSurface() === target) {
3248
+ const post = selectionSnapshotIn(target);
3249
+ if (post && post.focus !== null) {
3250
+ const delta = surfaceTextLength(target) - len0;
3251
+ const s0 = Math.min(pre.anchor, pre.focus === null ? pre.anchor : pre.focus);
3252
+ const at = Math.min(s0, post.focus - Math.max(delta, 0));
3253
+ if (!flightEditLog.has(target)) flightEditLog.set(target, []);
3254
+ flightEditLog.get(target).push({ at, delta });
3255
+ }
3256
+ }
3096
3257
  }
3097
3258
  }
3259
+ if (!flightBuffer.length) flightEditLog.clear();
3098
3260
  if (!flightBuffer.length && !commitFlight) flightFallback = null;
3099
3261
  if (dropped) showNotice(FLIGHT_DROPPED_MESSAGE);
3100
3262
  }
@@ -3366,6 +3528,9 @@
3366
3528
  // v3.2.0 (final review item 1): `let`, not `const` — reanchor() below
3367
3529
  // re-derives all three of `blockId`/`startLine`/`endLine` when this
3368
3530
  // session survives an incremental patch. See its own comment.
3531
+ // R2 (F1): an id-less block (a transient) has no source to edit, and
3532
+ // `Number(null)` would open block 0's.
3533
+ if (blockEl.getAttribute('data-block-id') === null) return;
3369
3534
  let blockId = Number(blockEl.getAttribute('data-block-id'));
3370
3535
  const block = blocks.find((b) => b.id === blockId);
3371
3536
  if (!block) return;
@@ -4572,9 +4737,40 @@
4572
4737
  // `Alpha <em><br></em> bold text here.` — dropping before the insert would
4573
4738
  // have taken that <em> and the caret's new home with it.
4574
4739
  dropEmptied(leftovers);
4740
+ // v3.14.0 T8: a <br> with nothing visible after it in the surface draws
4741
+ // no new line — the caret after it stays on the line above, and the next
4742
+ // character was typed BEFORE the break (measured: Shift+Enter at the end
4743
+ // of `Alpha.`, then `Z`, saved `Alpha.Z<br>`). A second, tail <br>
4744
+ // gives the new line something to stand on; the caret goes between the
4745
+ // two. inline-md.js writes nothing for a tail, so leaving straight away
4746
+ // writes the same `Alpha.<br>` as before.
4747
+ const surface = editSurfaceOf(br);
4748
+ if (surface) {
4749
+ const tail = tailBrAfter(br, surface);
4750
+ if (tail === false) {
4751
+ const t = document.createElement('br');
4752
+ t.setAttribute('data-ed-tail', '1');
4753
+ br.parentNode.insertBefore(t, br.nextSibling);
4754
+ }
4755
+ range.setStartAfter(br);
4756
+ range.setEndAfter(br);
4757
+ }
4575
4758
  sel.removeAllRanges();
4576
4759
  sel.addRange(range);
4577
4760
  }
4761
+ // What follows `br` inside `surface`: a tail <br> that is already there
4762
+ // (returned), something visible — text, an image, another <br> (true) — or
4763
+ // nothing (false).
4764
+ function tailBrAfter(br, surface) {
4765
+ const w = document.createTreeWalker(surface, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT);
4766
+ w.currentNode = br;
4767
+ for (let n = w.nextNode(); n; n = w.nextNode()) {
4768
+ if (n.nodeType === 3) { if (n.data.length) return true; continue; }
4769
+ if (n.nodeName === 'BR') return n.getAttribute('data-ed-tail') === '1' ? n : true;
4770
+ if (n.nodeName === 'IMG') return true;
4771
+ }
4772
+ return false;
4773
+ }
4578
4774
 
4579
4775
  // Heading ± buttons on the bar: a pure source-level transform (just the
4580
4776
  // leading `#` run) via the SAME commitEdit()/replaceLines() pipeline as
@@ -4629,6 +4825,7 @@
4629
4825
 
4630
4826
  async function changeHeadingDepthBody(blockEl, delta, anchor) {
4631
4827
  if (!blockEl) return;
4828
+ if (blockEl.getAttribute('data-block-id') === null) return; // R2: id-less → not block 0
4632
4829
  const blockId = Number(blockEl.getAttribute('data-block-id'));
4633
4830
  // Commits (never discards) whatever burst/editor is open first — same
4634
4831
  // precondition undo()/redo() use below, so this never races a concurrent
@@ -4752,6 +4949,9 @@
4752
4949
  if (blockType === 'heading') stripHeadingAnchor(editEl);
4753
4950
  editEl.setAttribute('contenteditable', 'true');
4754
4951
  editEl.classList.add('ed-wys-armed');
4952
+ // v3.14.0 T4: the hint a focused EMPTY paragraph shows (the CSS rule
4953
+ // reads this attribute; syncEmptyMark() decides when it shows).
4954
+ if (blockType === 'paragraph') editEl.setAttribute('data-placeholder', PLACEHOLDER_TEXT);
4755
4955
  } else if (blockType === 'li') {
4756
4956
  // Task 6 (Phase 4): per-li arming. Each
4757
4957
  // `.ed-block[data-block-type="li"]` is armed independently: only its
@@ -5378,19 +5578,11 @@
5378
5578
  // this uses the bare marker `-` instead — verified to lex as `list` with
5379
5579
  // one empty `<li>`.
5380
5580
  //
5381
- // The 段落 skeleton similarly can't be a truly empty line: a blank line by
5382
- // itself is consumed by marked's lexer as a `space` token between
5383
- // neighboring blocks, never becomes its own `paragraph` token, and would
5384
- // leave the + menu unable to find (or focus) any block at all — see
5385
- // commitBlockInsertion()'s newStartLine contract, which callers use to
5386
- // locate the inserted block in the server's recomputed block list.
5387
- // U+200B (zero-width space) is real, non-whitespace-per-`\s` text that
5388
- // marked DOES lex as its own paragraph, and renders as `<p>​</p>` —
5389
- // visually empty. focusInsertedBlock() below selects that single
5390
- // character so an immediate keystroke replaces it, matching the "empty
5391
- // paragraph to type into" intent; if the user commits without typing at
5392
- // all, the ZWSP is what ends up on disk (a known, documented trade-off —
5393
- // see the phase report).
5581
+ // There is no 段落 skeleton (v3.14.0 T1, D6): a blank line cannot be a
5582
+ // paragraph in markdown, and the zero-width-space line that stood in for
5583
+ // one up to v3.13 put an invisible character on disk whenever the user
5584
+ // walked away without typing. A new paragraph is now a transient (see
5585
+ // createTransient()), which writes nothing until it has content.
5394
5586
  //
5395
5587
  // 程式碼: a bare two-line fence pair (```/```, nothing between) is what
5396
5588
  // "fence pair with empty body" reads as most literally, and DOES lex as
@@ -5404,7 +5596,6 @@
5404
5596
  // browser probe). A three-line fence with one blank line between them
5405
5597
  // (still `text: ''` per marked — verified) gives that line to land on.
5406
5598
  const BLOCK_SKELETONS = {
5407
- paragraph: ['​'],
5408
5599
  heading: ['## '],
5409
5600
  list: ['-'],
5410
5601
  table: ['| A | B |', '|---|---|', '| | |'],
@@ -5422,8 +5613,8 @@
5422
5613
  // freshly-inserted, placeholder-only block) so the user's very first
5423
5614
  // keystroke replaces the placeholder instead of being inserted next to
5424
5615
  // it. A no-op-equivalent (nothing to select) on the genuinely-empty
5425
- // heading/list/table skeletons; load-bearing only for the paragraph
5426
- // skeleton's ZWSP placeholder (see BLOCK_SKELETONS above).
5616
+ // heading/list/table skeletons, which are all that reach it since the
5617
+ // paragraph skeleton went away (v3.14.0 T1).
5427
5618
  function focusAndSelectAll(el) {
5428
5619
  el.focus();
5429
5620
  const range = document.createRange();
@@ -5548,6 +5739,16 @@
5548
5739
  // parameter and not a second insertion path.
5549
5740
  async function insertBlockBelow(blockEl, kind, opts) {
5550
5741
  if (!blockEl) return;
5742
+ // v3.14.0 T1 (D6): a 段落 is no longer a ZWSP skeleton written to the
5743
+ // file — it is a transient that writes nothing until it has content.
5744
+ if (kind === 'paragraph' && !(opts && Array.isArray(opts.lines))) {
5745
+ await insertTransientBelow(blockEl);
5746
+ return;
5747
+ }
5748
+ // R2 (F1): an id-less anchor (a transient, a provisional li) has no line
5749
+ // to insert below, and `Number(null)` would read it as block 0. The
5750
+ // toolbar's and paste's transient routes are their own (Task 4 / Task 5).
5751
+ if (blockEl.getAttribute('data-block-id') === null) return;
5551
5752
  // T7: captured BEFORE switchAwayFrom(), because that is what can renumber
5552
5753
  // the ids — see captureBlockIdentity()'s comment.
5553
5754
  const identity = captureBlockIdentity(blockEl);
@@ -5599,7 +5800,8 @@
5599
5800
  // 建立副本 (「副本插在該 block 整棵子樹之後」), and it is what makes every
5600
5801
  // non-list kind safe here. Measured on ['# Doc','','- alpha',
5601
5802
  // ' - child',' - grand','']: anchored on `child`,
5602
- // commitBlockInsertion() with the 段落 skeleton yields
5803
+ // commitBlockInsertion() with the 段落 skeleton of the time (a ZWSP
5804
+ // line; since v3.14.0 a transient commits through the same anchor) yields
5603
5805
  // '# Doc\n\n- alpha\n - child\n\n<ZWSP>\n\n - grand\n', and
5604
5806
  // marked lexes ' - grand' after a paragraph as an INDENTED CODE
5605
5807
  // BLOCK — the grandchild's content is gone. Anchored on the end of the
@@ -5685,7 +5887,7 @@
5685
5887
  // pasted paragraph it would silently delete real content the moment the
5686
5888
  // user clicked elsewhere without typing. focusAndSelectAll() is wrong for
5687
5889
  // the same reason: it selects the block's whole content so the first
5688
- // keystroke replaces it, which is what a ZWSP placeholder wants and what
5890
+ // keystroke replaces it, which is what a placeholder skeleton wants and what
5689
5891
  // a just-pasted paragraph very much does not.
5690
5892
  //
5691
5893
  // 'line' is excluded for the same reason from the other direction: both
@@ -5739,6 +5941,1396 @@
5739
5941
  }
5740
5942
 
5741
5943
 
5944
+ // ── v3.14.0 T1: the transient empty paragraph ─────────────────────────
5945
+ //
5946
+ // Markup (D1): <div class="ed-block" data-block-type="paragraph"
5947
+ // data-ed-transient="1"><p class="ed-wys-armed" contenteditable="true"
5948
+ // data-placeholder="…"></p></div> — no data-block-id, no gutter chrome. It
5949
+ // is placed by DOM position: its commit lands after the previous real
5950
+ // block (the end of that block's subtree for a li), or at the top of the
5951
+ // document when nothing precedes it (D2, transientAnchorEl()). It writes no
5952
+ // byte until it has content (spec §2); its commit is one op.
5953
+ //
5954
+ // WALKER AUDIT (R2 / F1). Every `.ed-block` walker and every
5955
+ // `data-block-id` read in this file, against an id-less transient. "skip" =
5956
+ // returns null / does nothing for it; "include" = treats it as the
5957
+ // paragraph it is; "n/a" = cannot meet one (no transient survives a render,
5958
+ // and a patch render never runs while one exists: domMatchesLastRender()
5959
+ // is false for an id-less child).
5960
+ // applyPatch focusedBlockEl / idDelta rewrite / burstSurvived ... n/a (patch)
5961
+ // flightTagOf / reanchorFlightBuffer remap ............ include: blockId
5962
+ // null resolves through pendingFlightLineMap.provisional, which
5963
+ // commitLinesAtTransient() sets to the landing line
5964
+ // openRawEditor (+ reanchor) ............................ skip (id guard)
5965
+ // blockOwnsNoLine / blockRangeIsUnlocatable ............. skip (null-safe)
5966
+ // allBlockEls / liAttrs / subtreeBlocksAfter / runs ..... include as a
5967
+ // non-li: it ends a list run, as the paragraph it will become does
5968
+ // closestLiBlock ........................................ n/a (not a li)
5969
+ // runRangeOfBlocks / bystanderCarryOver / list commits .. skip (null-safe)
5970
+ // changeHeadingDepthBody ................................ skip (id guard)
5971
+ // armEditables .......................................... n/a (fresh DOM)
5972
+ // focusInsertedBlock / blockElAtLine / focusBlockAtLine . n/a (by line)
5973
+ // insertBlockBelow ...................................... skip (id guard;
5974
+ // its 段落 kind is routed here first)
5975
+ // startBurst ............................................ include (own
5976
+ // branch); any other id-less block: skip (R2)
5977
+ // setBlockSelection / selection walkers / blockRecOf .... skip (null)
5978
+ // captureBlockIdentity / reresolveBlockEl ............... skip (null)
5979
+ // commitListStructure targets ........................... n/a (li only)
5980
+ // handleBurstKeydown arrow probe / walkToAdjacentBlockByArrow /
5981
+ // adjacentFocusableBlock ............................ include: the
5982
+ // arrow walk lands in it like any armed paragraph (T5 owns leaving)
5983
+ // Tab walk (walkToAdjacentBlockByTab) .................. include
5984
+ // blockContentEl ........................................ include (the <p>)
5985
+ // table burst / cell walkers / grips / edge menu ........ n/a (tables)
5986
+ // ⠿ handle / + / drag / duplicate / copy ................ skip: it grows
5987
+ // no .ed-handle or .ed-insert, so no gutter gesture can start on it
5988
+ // gutter hover chrome / delegated block click ........... skip (no chrome;
5989
+ // a click inside it is a caret placement in an armed surface)
5990
+ // toolbar tracking (focusin / click) .................... include: the bar
5991
+ // reports a paragraph (D15 refinements are Task 4)
5992
+ // paste insertBlockBelow / insertImages anchors ......... skip (id guard
5993
+ // in insertBlockBelow; Task 4 / Task 5 route them)
5994
+ // focusin re-resolve by block id ........................ skip (null id)
5995
+ // raw textarea focusout ................................. n/a (no raw box)
5996
+ // wave editor open ...................................... n/a (no diagram)
5997
+ function isTransient(node) {
5998
+ const el = node && (node.nodeType === 1 ? node : node.parentElement);
5999
+ return !!(el && el.closest && el.closest('[data-ed-transient]'));
6000
+ }
6001
+
6002
+ function transientSurfaceEmpty(el) {
6003
+ const surface = el ? blockContentEl(el) : null;
6004
+ return !surface || (surface.textContent === '' && !surface.querySelector('img'));
6005
+ }
6006
+
6007
+ // D3: in an empty document the transient is the only block and is never
6008
+ // removed.
6009
+ function transientIsOnlyBlock(el) {
6010
+ return contentEl.querySelectorAll('.ed-block').length === 1 && el.parentNode === contentEl;
6011
+ }
6012
+
6013
+ // The `.ed-block` sibling right before `el`, transient or not, or null.
6014
+ function previousBlockEl(el) {
6015
+ let p = el.previousElementSibling;
6016
+ while (p && !p.classList.contains('ed-block')) p = p.previousElementSibling;
6017
+ return p;
6018
+ }
6019
+
6020
+ // The last block of `blockEl`'s subtree (itself for anything but a li with
6021
+ // deeper items after it). A transient on the way is stepped over, so a
6022
+ // transient sitting inside a subtree does not cut it short.
6023
+ function subtreeEndOf(blockEl) {
6024
+ const a = liAttrs(blockEl);
6025
+ if (!a) return blockEl;
6026
+ let end = blockEl;
6027
+ for (let n = blockEl.nextElementSibling; n; n = n.nextElementSibling) {
6028
+ if (!n.classList.contains('ed-block')) continue;
6029
+ if (n.hasAttribute('data-ed-transient')) continue;
6030
+ const na = liAttrs(n);
6031
+ if (!na || na.indent <= a.indent) break;
6032
+ end = n;
6033
+ }
6034
+ return end;
6035
+ }
6036
+
6037
+ // D2: the real block the transient's lines are committed after — the
6038
+ // previous `.ed-block[data-block-id]` sibling, or the end of its subtree
6039
+ // when it is a li — or null when nothing real precedes it.
6040
+ function transientAnchorEl(el) {
6041
+ let p = el.previousElementSibling;
6042
+ while (p && !(p.classList.contains('ed-block') && p.getAttribute('data-block-id') !== null)) {
6043
+ p = p.previousElementSibling;
6044
+ }
6045
+ return p ? subtreeEndOf(p) : null;
6046
+ }
6047
+
6048
+ function focusTransientStart(el) {
6049
+ const surface = blockContentEl(el);
6050
+ if (!surface) return;
6051
+ surface.focus();
6052
+ const range = document.createRange();
6053
+ range.selectNodeContents(surface);
6054
+ range.collapse(true);
6055
+ const sel = window.getSelection();
6056
+ sel.removeAllRanges();
6057
+ sel.addRange(range);
6058
+ }
6059
+
6060
+ // Builds the transient after `after` (an `.ed-block` child of `.content`),
6061
+ // or as the first child of `.content` when `after` is null. An existing
6062
+ // EMPTY transient is removed first (at most one, D3); when `after` IS that
6063
+ // empty transient, it is returned as it is (R20). `opts.focus` puts the
6064
+ // caret in it at offset 0, and the focusin that raises opens its burst.
6065
+ // Returns the element, or null when `after` is no longer in the document.
6066
+ function createTransient(after, opts) {
6067
+ const o = opts || {};
6068
+ if (after && transient && transient.el === after && after.isConnected && transientSurfaceEmpty(after)) {
6069
+ if (o.focus) focusTransientStart(after);
6070
+ return after;
6071
+ }
6072
+ if (after && !after.isConnected) return null;
6073
+ if (transient && transient.el.isConnected && transientSurfaceEmpty(transient.el)) removeTransient();
6074
+ const el = document.createElement('div');
6075
+ el.className = 'ed-block';
6076
+ el.setAttribute('data-block-type', 'paragraph');
6077
+ el.setAttribute('data-ed-transient', '1');
6078
+ const surface = document.createElement('p');
6079
+ surface.className = 'ed-wys-armed';
6080
+ surface.setAttribute('contenteditable', 'true');
6081
+ surface.setAttribute('data-placeholder', PLACEHOLDER_TEXT);
6082
+ el.appendChild(surface);
6083
+ if (after) after.parentNode.insertBefore(el, after.nextSibling);
6084
+ else contentEl.insertBefore(el, contentEl.firstChild);
6085
+ transient = { el, surface, byEnter: !!o.byEnter, anchorCaret: o.anchorCaret || null,
6086
+ stayEl: o.stayEl || null };
6087
+ syncEmptyMark(surface);
6088
+ if (o.focus) focusTransientStart(el);
6089
+ return el;
6090
+ }
6091
+
6092
+ // v3.14.0 T4: data-ed-empty on a paragraph surface that holds no text and no
6093
+ // image, off otherwise. The CSS hint keys off it. Called on focusin and
6094
+ // input of an armed paragraph surface, by createTransient(), and after the
6095
+ // places that rewrite a surface's innerHTML (burst undo / redo, Escape).
6096
+ function syncEmptyMark(surface) {
6097
+ if (!surface || surface.nodeType !== 1) return;
6098
+ if (surface.textContent === '' && !surface.querySelector('img')) surface.setAttribute('data-ed-empty', '1');
6099
+ else surface.removeAttribute('data-ed-empty');
6100
+ }
6101
+ function wireEmptyMark() {
6102
+ const handler = (e) => {
6103
+ const t = e.target;
6104
+ const surface = t && t.closest
6105
+ ? t.closest('.ed-block[data-block-type="paragraph"] > .ed-wys-armed') : null;
6106
+ if (surface) syncEmptyMark(surface);
6107
+ };
6108
+ document.addEventListener('focusin', handler, true);
6109
+ document.addEventListener('input', handler, true);
6110
+ }
6111
+
6112
+ // A press that blurs an empty transient must not move the page under
6113
+ // itself. Its mousedown is what resolves the transient's burst (focusout),
6114
+ // and removing the element right there pulls every block below it up by
6115
+ // its height before the mouseup — the click then lands on whatever is
6116
+ // under the pointer now, usually the gap or `main.content`, and the block
6117
+ // click delegator reads that as "clicked outside any block" and resolves
6118
+ // the burst the press had just opened on the block it really hit. MEASURED
6119
+ // (ER "empty-Enter on the ONLY item…", both engines in the click check
6120
+ // 'a press on the paragraph below an empty transient…'): the follow-up
6121
+ // typing was never committed. So while a button is down the removal waits
6122
+ // for the press to end (mouseup, then the click it dispatches), and only
6123
+ // removes the transient if nothing has claimed it meanwhile.
6124
+ let pointerPressed = false;
6125
+ const afterPressQueue = [];
6126
+ document.addEventListener('mousedown', () => { pointerPressed = true; }, true);
6127
+ const endPress = () => {
6128
+ pointerPressed = false;
6129
+ if (!afterPressQueue.length) return;
6130
+ const due = afterPressQueue.splice(0);
6131
+ setTimeout(() => due.forEach((fn) => fn()), 0);
6132
+ };
6133
+ document.addEventListener('mouseup', endPress, true);
6134
+ // v3.14.0 T4: a press can end without a mouseup (a native drag takes the
6135
+ // pointer, the window loses it), which used to leave the flag set until the
6136
+ // next click; those endings clear it too.
6137
+ document.addEventListener('pointercancel', endPress, true);
6138
+ document.addEventListener('dragend', endPress, true);
6139
+ window.addEventListener('blur', endPress);
6140
+ function removeTransientAfterPress(el) {
6141
+ const drop = () => {
6142
+ if (!transient || transient.el !== el || !el.isConnected) return;
6143
+ if (el.contains(document.activeElement) || !transientSurfaceEmpty(el)) return;
6144
+ if (transientIsOnlyBlock(el)) return;
6145
+ removeTransient();
6146
+ };
6147
+ if (pointerPressed) afterPressQueue.push(drop);
6148
+ else drop();
6149
+ }
6150
+
6151
+ // DOM only — no op, nothing written. Its burst (if open) ends first, so the
6152
+ // focusout the removal raises finds nothing to resolve.
6153
+ function removeTransient() {
6154
+ const t = transient;
6155
+ transient = null;
6156
+ if (!t) return;
6157
+ if (currentBurst && currentBurst.blockEl === t.el) endBurstWithoutResolve();
6158
+ if (t.el.parentNode) t.el.parentNode.removeChild(t.el);
6159
+ }
6160
+
6161
+ // Commits `newLines` at the transient's position: one op
6162
+ // (commitInsertionAt()), then a render. Keys held during that render that
6163
+ // were typed into the transient follow it to the line it lands on
6164
+ // (`provisional`). On success `transientLandedLine` names that line; on
6165
+ // failure `lines` is rolled back and the transient — with whatever the
6166
+ // user typed — stays on screen for the next attempt.
6167
+ async function commitLinesAtTransient(el, newLines) {
6168
+ const anchorEl = transientAnchorEl(el);
6169
+ if (anchorEl && blockOwnsNoLine(anchorEl)) {
6170
+ refuseStructuralListEdit(noSourceLineInsertMessageFor(anchorEl));
6171
+ return { ok: false, newStartLine: null };
6172
+ }
6173
+ const anchorId = anchorEl ? Number(anchorEl.getAttribute('data-block-id')) : null;
6174
+ const result = commitInsertionAt({ lines, blocks, stack }, anchorId, newLines);
6175
+ if (result.op === null) return { ok: false, newStartLine: null };
6176
+ const prevLines = lines;
6177
+ lines = result.lines;
6178
+ const editRange = { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
6179
+ endLine: Math.max(result.op.startLine - 1, result.op.endLine),
6180
+ delta: result.op.after.length - result.op.before.length };
6181
+ // Fix round 1 (I1): the commit is a pure insertion, so every block that
6182
+ // starts at or after the op's first line moves down by its delta —
6183
+ // including, at the top of the document, the first block whose line the
6184
+ // op re-states. Named here by id, because editRange alone cannot say it
6185
+ // for that first block (its line is INSIDE the edited range).
6186
+ const shifted = {};
6187
+ blocks.forEach((b) => {
6188
+ if (b.startLine >= result.op.startLine) shifted[b.id] = b.startLine + editRange.delta;
6189
+ });
6190
+ pendingFlightLineMap = { ids: shifted, provisional: result.newStartLine };
6191
+ const ok = await safeRerenderAll(editRange);
6192
+ if (!ok) {
6193
+ lines = rollbackFailedRender({ lines, stack }, result, prevLines);
6194
+ return { ok: false, newStartLine: null };
6195
+ }
6196
+ transientLandedLine = result.newStartLine;
6197
+ transientCommits.seq++;
6198
+ transientCommits.log.push({ seq: transientCommits.seq, at: result.op.startLine, delta: editRange.delta });
6199
+ if (transientCommits.log.length > 8) transientCommits.log.shift();
6200
+ return { ok: true, newStartLine: result.newStartLine };
6201
+ }
6202
+
6203
+ // resolveBurst()'s transient branch. Empty: kept (burst restarted) while
6204
+ // focus is still inside it — Ctrl+S or a toolbar press right after making
6205
+ // it must not eat it — or while it is the document's only block; removed
6206
+ // otherwise (D3). With text: committed, and the caret follows it to the
6207
+ // committed paragraph's end when the resolution began with focus inside.
6208
+ async function resolveTransientBurst(burst) {
6209
+ const el = burst.blockEl;
6210
+ const surface = burst.editEl;
6211
+ const focusInside = el.contains(document.activeElement);
6212
+ burst.history.flushTyping();
6213
+ // An emptied surface a browser left a <br> in serializes as '<br>': it
6214
+ // holds no text and no image, so it is empty (T4: Ctrl+S after typing and
6215
+ // erasing everything wrote that line).
6216
+ const md = transientSurfaceEmpty(el) ? ''
6217
+ : inlineMd.escapeBlockStarts(inlineMd.serializeInline(surface).md).replace(/\s+$/, '');
6218
+ if (md === '') {
6219
+ endBurstWithoutResolve();
6220
+ if (!el.isConnected) {
6221
+ if (transient && transient.el === el) transient = null;
6222
+ return true;
6223
+ }
6224
+ if (focusInside) {
6225
+ // A <br> a browser leaves behind in an emptied surface is not content.
6226
+ if (surface.innerHTML !== '') { surface.innerHTML = ''; syncEmptyMark(surface); focusTransientStart(el); }
6227
+ startBurst(surface);
6228
+ return true;
6229
+ }
6230
+ if (transientIsOnlyBlock(el)) return true;
6231
+ removeTransientAfterPress(el);
6232
+ return true;
6233
+ }
6234
+ const res = await commitLinesAtTransient(el, md.split('\n'));
6235
+ if (!res.ok) return false;
6236
+ if (focusInside) focusBlockAtLine(res.newStartLine, true);
6237
+ return true;
6238
+ }
6239
+
6240
+ // 在下方插入 / + 段落: a transient below `blockEl` (below its whole
6241
+ // subtree for a li). Same preamble as insertBlockBelow(): resolve whatever
6242
+ // is open, then re-resolve the block if that resolution re-rendered it.
6243
+ async function insertTransientBelow(blockEl) {
6244
+ if (!blockEl) return;
6245
+ const live = await liveBlockAfterSwitch(blockEl);
6246
+ if (!live) return;
6247
+ createTransient(subtreeEndOf(live), { focus: true });
6248
+ }
6249
+
6250
+ // 在上方插入: a transient right above `blockEl`, i.e. after the block before
6251
+ // it — after that block's whole subtree when it is a li, which is also
6252
+ // where its commit lands (D2), so the screen never shows it anywhere the
6253
+ // file will not.
6254
+ async function insertTransientAbove(blockEl) {
6255
+ if (!blockEl) return;
6256
+ const live = await liveBlockAfterSwitch(blockEl);
6257
+ if (!live) return;
6258
+ const prev = previousBlockEl(live);
6259
+ createTransient(prev ? (isTransient(prev) ? prev : subtreeEndOf(prev)) : null, { focus: true });
6260
+ }
6261
+
6262
+ // switchAwayFrom(), then the live element for `blockEl` (null = abandon).
6263
+ async function liveBlockAfterSwitch(blockEl) {
6264
+ const identity = captureBlockIdentity(blockEl);
6265
+ const selfSession = ownsOpenSession(blockEl);
6266
+ const ok = await switchAwayFrom();
6267
+ if (!ok) return null;
6268
+ if (document.body.contains(blockEl)) return blockEl;
6269
+ const live = reresolveBlockEl(identity) ||
6270
+ (selfSession ? reresolveBlockElAfterSelfCommit(identity) : null);
6271
+ if (!live) showError(DROPPED_GESTURE_MESSAGE, null, null);
6272
+ return live;
6273
+ }
6274
+
6275
+ // ── v3.14.0 T4: the toolbar on a transient paragraph ──────────────────
6276
+ //
6277
+ // toolbarOnTransient(action, el) → true when the press was handled here,
6278
+ // false when the caller should run its normal action (R8: this is the one
6279
+ // implementation of 在上方/在下方插入 on a transient; T1's earlier branch is
6280
+ // gone). `action` is a toolbar button id, or 'h1'..'h6' for the headings
6281
+ // menu. The inline mark ids, undo, redo, save, outline and the headings
6282
+ // opener are not block actions and always return false.
6283
+ // with text commit it (switchAwayFrom), re-target the bar at the block
6284
+ // it became, return false so the normal action runs on that
6285
+ // empty insert-after / insert-before: the caret goes (back) into it;
6286
+ // table / code / list / h1-h6 / line: its position takes the
6287
+ // skeleton (insertAtTransient); image: false (the picker's
6288
+ // anchor is the transient, insertImages() commits there);
6289
+ // quote / numbered / check / indent / outdent: disabled by
6290
+ // the model, a no-op here
6291
+ const TOOLBAR_TRANSIENT_PASS = new Set(['undo', 'redo', 'outline', 'save', 'headings',
6292
+ 'bold', 'italic', 'strike', 'inline-code', 'link']);
6293
+ async function toolbarOnTransient(action, el) {
6294
+ if (!el || !isTransient(el) || !el.isConnected) return false;
6295
+ if (TOOLBAR_TRANSIENT_PASS.has(action)) return false;
6296
+ if (!transientSurfaceEmpty(el)) {
6297
+ transientLandedLine = null;
6298
+ const ok = await switchAwayFrom();
6299
+ if (!ok || transientLandedLine === null) return true;
6300
+ const landed = blockElAtLine(transientLandedLine);
6301
+ if (!landed) return true;
6302
+ toolbarBlockEl = landed;
6303
+ return false;
6304
+ }
6305
+ switch (action) {
6306
+ case 'insert-after':
6307
+ case 'insert-before':
6308
+ focusTransientStart(el);
6309
+ return true;
6310
+ case 'table': case 'code': case 'list': case 'line':
6311
+ await insertAtTransient(el, action);
6312
+ return true;
6313
+ case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6':
6314
+ await insertAtTransient(el, 'heading', Number(action.slice(1)));
6315
+ return true;
6316
+ case 'image':
6317
+ return false;
6318
+ default:
6319
+ return true;
6320
+ }
6321
+ }
6322
+
6323
+ // Commits BLOCK_SKELETONS[kind] (a heading builds its own marker) at the
6324
+ // transient's position through commitLinesAtTransient(), as one op, then
6325
+ // focuses it the way insertBlockBelow() does — pristineInsert for the four
6326
+ // placeholder kinds, none for 'line', which instead gets a fresh transient
6327
+ // right after the rule so the caret has somewhere to go. One exception: a
6328
+ // 清單 right below a list item continues that list (insertBlockBelow's li
6329
+ // branch) instead of writing a blank-separated item that would make the
6330
+ // list loose.
6331
+ async function insertAtTransient(el, kind, depth) {
6332
+ const skeleton = kind === 'heading' ? ['#'.repeat(depth || 1) + ' '] : BLOCK_SKELETONS[kind];
6333
+ if (!skeleton) return;
6334
+ if (kind === 'list') {
6335
+ const anchor = transientAnchorEl(el);
6336
+ if (anchor && anchor.getAttribute('data-block-type') === 'li') {
6337
+ removeTransient();
6338
+ await insertBlockBelow(anchor, 'list');
6339
+ return;
6340
+ }
6341
+ }
6342
+ const res = await commitLinesAtTransient(el, skeleton.slice());
6343
+ if (!res.ok) return;
6344
+ if (kind === 'line') {
6345
+ const hr = blockElAtLine(res.newStartLine);
6346
+ if (hr) createTransient(hr, { focus: true });
6347
+ return;
6348
+ }
6349
+ const target = blocks.find((b) => b.startLine === res.newStartLine);
6350
+ if (target) pristineInsert = { blockId: target.id };
6351
+ await focusInsertedBlock(res.newStartLine, kind);
6352
+ }
6353
+
6354
+ // The transient's text as source markdown ('' when it holds none) — the
6355
+ // same serialization resolveTransientBurst() commits.
6356
+ function transientTextMd(el) {
6357
+ const surface = blockContentEl(el);
6358
+ if (!surface || transientSurfaceEmpty(el)) return '';
6359
+ return inlineMd.escapeBlockStarts(inlineMd.serializeInline(surface).md).replace(/\s+$/, '');
6360
+ }
6361
+
6362
+ // Block markdown (a pasted structure, a dropped or picked image) replaces
6363
+ // the transient: its own text first when it has some, a blank line, then
6364
+ // `pastedLines` — all one commit, one op. The caret ends at the end of the
6365
+ // last inserted block. Returns whether the commit landed.
6366
+ async function commitPastedLinesAtTransient(el, pastedLines) {
6367
+ const surface = blockContentEl(el);
6368
+ try {
6369
+ return await commitPastedLinesAtTransientBody(el, pastedLines, surface);
6370
+ } catch (e) {
6371
+ // Fix round 1 (I1): a throw must not leave the typed text on screen with
6372
+ // no burst to commit it (R32). The burst is restarted and the card shown.
6373
+ if (el.isConnected && surface && !currentBurst) startBurst(surface);
6374
+ showError('這次修改沒有套用:發生未預期的錯誤(' + describeFailure(e) +
6375
+ ')。文件維持修改前的樣子。', null, null);
6376
+ return false;
6377
+ }
6378
+ }
6379
+ async function commitPastedLinesAtTransientBody(el, pastedLines, surface) {
6380
+ const text = transientTextMd(el);
6381
+ const all = (text ? text.split('\n').concat(['']) : []).concat(pastedLines);
6382
+ // The text is committed here, with the paste; the burst that holds it must
6383
+ // not resolve it a second time when the render detaches the surface (the
6384
+ // focusout that raises runs before applyFullRender() clears the burst —
6385
+ // MEASURED in Chromium: the typed text was written twice).
6386
+ if (currentBurst && currentBurst.blockEl === el) endBurstWithoutResolve();
6387
+ const res = await commitLinesAtTransient(el, all);
6388
+ if (!res.ok) {
6389
+ if (el.isConnected && surface && !currentBurst) startBurst(surface);
6390
+ return false;
6391
+ }
6392
+ const lastLine = res.newStartLine + all.length - 1;
6393
+ let last = null;
6394
+ blocks.forEach((b) => { if (b.startLine <= lastLine) last = b; });
6395
+ if (last) focusBlockAtLine(last.startLine, true);
6396
+ return true;
6397
+ }
6398
+
6399
+ // An empty document has no block to type into: one focused transient is
6400
+ // its first paragraph (it is the only block, so D3 never removes it).
6401
+ function ensureEmptyDocTransient() {
6402
+ if (contentEl.querySelector('.ed-block')) return;
6403
+ createTransient(null, { focus: true });
6404
+ }
6405
+
6406
+ // Mousedown on the page below the last block starts a paragraph there.
6407
+ // Mousedown with preventDefault (R19), not pointerdown: the press must not
6408
+ // move focus, or the transient made here would be blurred away at once. It
6409
+ // acts only on the page's own empty surfaces (an allow-list), never on a
6410
+ // point that elementsFromPoint says is a block or any chrome; comparing the
6411
+ // y against the last block's bottom is region classification after that.
6412
+ // It does not read pointerPressed.
6413
+ function wireClickBelow() {
6414
+ document.addEventListener('mousedown', (e) => {
6415
+ clickBelowOnMousedown(e).catch((err) => {
6416
+ // Fix round 1 (I2): a rejection after preventDefault() must show the
6417
+ // card, not vanish as an unhandled rejection.
6418
+ showError('這次修改沒有套用:發生未預期的錯誤(' + describeFailure(err) +
6419
+ ')。文件維持修改前的樣子。', null, null);
6420
+ });
6421
+ });
6422
+ }
6423
+ async function clickBelowOnMousedown(e) {
6424
+ {
6425
+ if (e.button !== 0 || e.defaultPrevented || waveEditorIsModal()) return;
6426
+ const t = e.target;
6427
+ const surfaceTarget = t === document.documentElement || t === document.body ||
6428
+ (t.classList && t.classList.contains('page-layout')) ||
6429
+ (t.matches && t.matches('main.content'));
6430
+ if (!surfaceTarget) return;
6431
+ const hits = document.elementsFromPoint(e.clientX, e.clientY);
6432
+ if (hits.some((h) => h.closest &&
6433
+ h.closest('.ed-block, .reader-sidebar, .ed-toolbar, .ed-conflict, .md2doc-theme-toggle'))) return;
6434
+ const all = contentEl.querySelectorAll('.ed-block');
6435
+ if (!all.length) return;
6436
+ if (e.clientY <= all[all.length - 1].getBoundingClientRect().bottom) return;
6437
+ e.preventDefault();
6438
+ const focusLastOrCreate = () => {
6439
+ const now = contentEl.querySelectorAll('.ed-block');
6440
+ const last = now[now.length - 1];
6441
+ if (!last) return;
6442
+ if (!isTransient(last)) { createTransient(last, { focus: true }); return; }
6443
+ // Final review M6: one that holds typed text takes the caret at the
6444
+ // end of it, where a click below the last line lands.
6445
+ const surface = blockContentEl(last);
6446
+ if (surface && !transientSurfaceEmpty(last)) caretToSurface(surface, true);
6447
+ else focusTransientStart(last);
6448
+ };
6449
+ if (isTransient(all[all.length - 1])) { focusLastOrCreate(); return; }
6450
+ if (!(await switchAwayFrom())) return;
6451
+ focusLastOrCreate();
6452
+ }
6453
+ }
6454
+
6455
+ // ── v3.14.0 T2: Enter in a paragraph or a heading ─────────────────────
6456
+ //
6457
+ // Plain Enter splits the block at the caret: the left half keeps the block,
6458
+ // the right half becomes a paragraph below it, and the caret goes to the
6459
+ // right half's start (spec §3.1). At an edge nothing is split: at the end a
6460
+ // transient paragraph opens below with the caret in it, at the start one
6461
+ // opens above and the caret stays. One Enter is one undo step; typing it
6462
+ // commits first is its own earlier step (D4). Whitespace at the split point
6463
+ // is dropped from both halves, and so is a trailing <br> on the left (D5).
6464
+ //
6465
+ // The split goes through the v3.13.1 flight guard: the DOM is split and the
6466
+ // right half armed and focused synchronously, before the commit's render is
6467
+ // awaited, so keys typed meanwhile are held and replayed into the new
6468
+ // paragraph. The two edge cases await the commit of pending typing BEFORE
6469
+ // the transient is created (R7): that commit's render would drop a
6470
+ // transient made first.
6471
+
6472
+ // Where a collapsed caret sits in `surface`: 'start' (nothing before it),
6473
+ // 'end' (nothing after it, a trailing <br> that is the surface's last child
6474
+ // aside), 'empty' (both), 'middle', or null when the selection is not a
6475
+ // collapsed caret inside `surface`. "Nothing" is no <br>, no <img> and no
6476
+ // text but whitespace — whitespace at the split point is dropped anyway, so
6477
+ // a caret after a trailing space is at the end. `strict` (Backspace /
6478
+ // Delete) counts whitespace as text: there the key would delete it.
6479
+ function caretPlace(surface, strict) {
6480
+ const sel = window.getSelection();
6481
+ if (!surface || !sel.rangeCount || !sel.isCollapsed) return null;
6482
+ const r = sel.getRangeAt(0);
6483
+ if (!surface.contains(r.startContainer)) return null;
6484
+ const before = document.createRange();
6485
+ before.selectNodeContents(surface);
6486
+ before.setEnd(r.startContainer, r.startOffset);
6487
+ const after = document.createRange();
6488
+ after.selectNodeContents(surface);
6489
+ after.setStart(r.startContainer, r.startOffset);
6490
+ const last = surface.lastChild;
6491
+ if (last && last.nodeName === 'BR' && after.isPointInRange(last, 0)) after.setEndBefore(last);
6492
+ const holds = (range) => {
6493
+ const frag = range.cloneContents();
6494
+ const text = strict ? frag.textContent : frag.textContent.replace(/\s+/g, '');
6495
+ return text !== '' || !!frag.querySelector('br, img');
6496
+ };
6497
+ const b = holds(before);
6498
+ const a = holds(after);
6499
+ if (!b && !a) return 'empty';
6500
+ if (!b) return 'start';
6501
+ if (!a) return 'end';
6502
+ return 'middle';
6503
+ }
6504
+
6505
+ // R4: the text a paragraph / heading surface commits as — the heading's
6506
+ // own `#` run in front, a paragraph's line-start markers escaped (F-1).
6507
+ // resolveBurst() uses the same rule; split and merge serialize through it.
6508
+ function commitTextOf(md, blockType, depth) {
6509
+ if (blockType === 'heading') return md === '' ? '#'.repeat(depth) : '#'.repeat(depth) + ' ' + md;
6510
+ return blockType === 'paragraph' ? inlineMd.escapeBlockStarts(md) : md;
6511
+ }
6512
+ function surfaceCommitText(surface, blockType, depth) {
6513
+ return commitTextOf(inlineMd.serializeInline(surface).md, blockType, depth);
6514
+ }
6515
+ function burstCommitText(burst, surface) {
6516
+ return surfaceCommitText(surface || burst.editEl, burst.blockType, burst.depth);
6517
+ }
6518
+
6519
+ // Inline elements holding nothing a serializer emits (no text, no <br>, no
6520
+ // <img>) are removed, deepest first — the empty `<a>` or `<strong>` a split
6521
+ // right at a mark's edge leaves on one side would otherwise come back as
6522
+ // `[](url)` or `****`.
6523
+ function dropEmptyInlines(root) {
6524
+ Array.prototype.slice.call(root.querySelectorAll('*')).reverse().forEach((el) => {
6525
+ if (el.nodeName === 'BR' || el.nodeName === 'IMG') return;
6526
+ if (el.textContent === '' && !el.querySelector('br, img')) el.parentNode.removeChild(el);
6527
+ });
6528
+ }
6529
+ // The first (`atEnd` false) or last leaf node under `root`, or null.
6530
+ function edgeLeaf(root, atEnd) {
6531
+ let n = atEnd ? root.lastChild : root.firstChild;
6532
+ while (n && n.nodeType === 1 && (atEnd ? n.lastChild : n.firstChild)) n = atEnd ? n.lastChild : n.firstChild;
6533
+ return n;
6534
+ }
6535
+ // D5 on one half: its whitespace at the split point (the end of the left
6536
+ // half, the start of the right one) — a soft-wrap newline included — plus
6537
+ // one hard break (<br>) on that edge. Fix round 1 (C2): the newline was
6538
+ // kept, so a split at a soft wrap wrote an extra blank line, the right half
6539
+ // landed one line below where the caret was sent, and the next key was
6540
+ // lost (measured: `Alpha one\n\n\nbravo two.`, caret null).
6541
+ function trimSplitEdge(root, atEnd) {
6542
+ root.normalize();
6543
+ dropEmptyInlines(root);
6544
+ const br = edgeLeaf(root, atEnd);
6545
+ if (br && br.nodeName === 'BR') { br.parentNode.removeChild(br); dropEmptyInlines(root); }
6546
+ for (;;) {
6547
+ const leaf = edgeLeaf(root, atEnd);
6548
+ if (!leaf || leaf.nodeType !== 3) return;
6549
+ const t = atEnd ? leaf.data.replace(/[ \t\r\n ]+$/, '') : leaf.data.replace(/^[ \t\r\n ]+/, '');
6550
+ if (t === leaf.data) return;
6551
+ if (t !== '') { leaf.data = t; return; }
6552
+ leaf.parentNode.removeChild(leaf);
6553
+ dropEmptyInlines(root);
6554
+ }
6555
+ }
6556
+
6557
+ // Moves [caret, end of `surface`] into a new provisional paragraph — an
6558
+ // id-less `.ed-block[data-block-type="paragraph"]` right after the source
6559
+ // block — and returns its <p>, or null when the caret is not in `surface`.
6560
+ // Range surgery like splitListItemAtCaret(), so a caret inside a mark or a
6561
+ // link gives each half its own copy of it. The new block owns no line until
6562
+ // the commit's render replaces it (startBurst() opens no burst on it, R2).
6563
+ function splitSurfaceAtCaret(surface) {
6564
+ const sel = window.getSelection();
6565
+ if (!sel.rangeCount) return null;
6566
+ const range = sel.getRangeAt(0).cloneRange();
6567
+ if (!surface.contains(range.startContainer)) return null;
6568
+ const blockEl = surface.closest('.ed-block');
6569
+ if (!blockEl || !blockEl.parentNode) return null;
6570
+ const leftovers = collectLeftovers(range, surface);
6571
+ if (!range.collapsed) range.deleteContents();
6572
+ const tail = document.createRange();
6573
+ tail.setStart(range.startContainer, range.startOffset);
6574
+ tail.setEnd(surface, surface.childNodes.length);
6575
+ collectLeftovers(tail, surface, leftovers);
6576
+ const frag = tail.extractContents();
6577
+ const newBlock = document.createElement('div');
6578
+ newBlock.className = 'ed-block';
6579
+ newBlock.setAttribute('data-block-type', 'paragraph');
6580
+ const right = document.createElement('p');
6581
+ right.appendChild(frag);
6582
+ newBlock.appendChild(right);
6583
+ blockEl.parentNode.insertBefore(newBlock, blockEl.nextSibling);
6584
+ dropEmptied(leftovers);
6585
+ trimSplitEdge(surface, true);
6586
+ trimSplitEdge(right, false);
6587
+ return right;
6588
+ }
6589
+
6590
+ // The two halves of `surface` at the caret as detached copies, D5 applied
6591
+ // — for a transient, whose split is committed before anything is drawn.
6592
+ function halvesAtCaret(surface) {
6593
+ const at = window.getSelection().getRangeAt(0);
6594
+ const half = (left) => {
6595
+ const range = document.createRange();
6596
+ range.selectNodeContents(surface);
6597
+ if (left) range.setEnd(at.startContainer, at.startOffset);
6598
+ else range.setStart(at.startContainer, at.startOffset);
6599
+ const el = document.createElement('p');
6600
+ el.appendChild(range.cloneContents());
6601
+ trimSplitEdge(el, left);
6602
+ return el;
6603
+ };
6604
+ return { left: half(true), right: half(false) };
6605
+ }
6606
+
6607
+ // A structural key moves focus off (or detaches) the focused surface while
6608
+ // its burst is still current; the focusout that raises must not resolve the
6609
+ // burst — the key commits it itself. Same flag and same reason as
6610
+ // mutateListRun(), without the list-run bookkeeping.
6611
+ function withFocusoutSuppressed(fn) {
6612
+ suppressLiFocusout = true;
6613
+ try {
6614
+ return fn();
6615
+ } finally {
6616
+ suppressLiFocusout = false;
6617
+ }
6618
+ }
6619
+
6620
+ // Plain Enter in a paragraph, heading or transient surface (Shift+Enter is
6621
+ // a <br>; a chord still commits by blurring).
6622
+ async function paragraphEnter(e, editEl) {
6623
+ // R / Review Focus 4: an IME's own confirming Enter is the IME's.
6624
+ if (e.isComposing || e.keyCode === 229) return;
6625
+ e.preventDefault();
6626
+ const burst = currentBurst;
6627
+ if (!burst || burst.editEl !== editEl) return;
6628
+ // Content the inline serializer cannot write: the old commit-and-leave,
6629
+ // whose resolution falls back to the source editor.
6630
+ if (inlineMd.serializeInline(editEl).unsupported.length) { editEl.blur(); return; }
6631
+ // A selection standing in the surface is deleted first, one burst step.
6632
+ const sel = window.getSelection();
6633
+ if (sel.rangeCount && !sel.isCollapsed) {
6634
+ const r = sel.getRangeAt(0);
6635
+ if (!editEl.contains(r.startContainer) || !editEl.contains(r.endContainer)) return;
6636
+ snapBurstIfActive(editEl, 'enter-pre');
6637
+ const leftovers = collectLeftovers(r, editEl);
6638
+ r.deleteContents();
6639
+ dropEmptied(leftovers);
6640
+ sel.removeAllRanges();
6641
+ sel.addRange(r);
6642
+ snapBurstIfActive(editEl, 'enter');
6643
+ }
6644
+ const place = caretPlace(editEl);
6645
+ if (!place) return;
6646
+ const blockEl = editEl.closest('.ed-block');
6647
+ // Fix round 1 (C1): an emptied real block is one removal op and a
6648
+ // focused transient where it was. Committing it as text wrote a blank (or
6649
+ // a `<br>` html block), took the block away, and the lookup by start line
6650
+ // then found nothing — no transient, no caret, the next keys lost.
6651
+ if (place === 'empty' && !burst.transient) { await replaceEmptiedBlockWithTransient(burst, editEl); return; }
6652
+ if (burst.transient) {
6653
+ if (place === 'empty') return;
6654
+ if (place === 'middle') { await splitTransientAtCaret(blockEl, editEl); return; }
6655
+ }
6656
+ if (place === 'middle') { await splitBlockAtCaret(burst, editEl, blockEl); return; }
6657
+ const live = await settleEnterSource(burst, blockEl);
6658
+ if (!live) return;
6659
+ const surface = blockContentEl(live);
6660
+ if (place === 'start') {
6661
+ createTransient(previousBlockEl(live), { focus: false, byEnter: true, stayEl: live });
6662
+ if (!surface || !surface.classList.contains('ed-wys-armed')) return;
6663
+ if (document.activeElement !== surface) surface.focus();
6664
+ const r = document.createRange();
6665
+ r.selectNodeContents(surface);
6666
+ r.collapse(true);
6667
+ sel.removeAllRanges();
6668
+ sel.addRange(r);
6669
+ // R3: a surface that kept focus through the commit gets no focusin, and
6670
+ // its burst ended with that commit.
6671
+ if (!currentBurst) startBurst(surface);
6672
+ return;
6673
+ }
6674
+ createTransient(subtreeEndOf(live), { focus: true, byEnter: true,
6675
+ anchorCaret: surface ? { surface, offset: surface.textContent.length } : null });
6676
+ }
6677
+
6678
+ // The edge cases' first step: commit what is pending in the block Enter was
6679
+ // pressed in (R7) and hand back the live element it is now — re-resolved by
6680
+ // its start line when the commit re-rendered it, or the paragraph a
6681
+ // transient with text became. null = the commit failed, do nothing more.
6682
+ async function settleEnterSource(burst, blockEl) {
6683
+ if (burst.transient) {
6684
+ transientLandedLine = null;
6685
+ if (!(await switchAwayFrom())) return null;
6686
+ return transientLandedLine === null ? null : blockElAtLine(transientLandedLine);
6687
+ }
6688
+ const rec = blocks.find((b) => b.id === burst.blockId);
6689
+ if (!rec) return null;
6690
+ const startLine = rec.startLine;
6691
+ if (!(await switchAwayFrom())) return null;
6692
+ return blockEl.isConnected ? blockEl : blockElAtLine(startLine);
6693
+ }
6694
+
6695
+ // The split of a real paragraph / heading. Up to two ops in one render: the
6696
+ // pending typing over the unsplit block (D4), then commitSplit(). A failed
6697
+ // render rolls both back and leaves the screen split; pendingStructure
6698
+ // re-commits it from the live DOM on the next resolution (D7).
6699
+ async function splitBlockAtCaret(burst, editEl, blockEl) {
6700
+ const blockId = burst.blockId;
6701
+ const rec = blocks.find((b) => b.id === blockId);
6702
+ if (!rec) return;
6703
+ const typed = typedInBurst(burst);
6704
+ let right = null;
6705
+ // R5: the right half is armed and focused first, while the burst is still
6706
+ // current (so the focusin that raises opens nothing), then the burst ends.
6707
+ withFocusoutSuppressed(() => {
6708
+ right = splitSurfaceAtCaret(editEl);
6709
+ if (right) armProvisionalSurface(right);
6710
+ });
6711
+ if (!right) return;
6712
+ endBurstWithoutResolve();
6713
+ const leftText = surfaceCommitText(editEl, burst.blockType, burst.depth);
6714
+ const rightText = inlineMd.escapeBlockStarts(inlineMd.serializeInline(right).md);
6715
+ // commitSplit() starts the right half here whatever the typing op did:
6716
+ // that op rewrites only this block, so its start line does not move.
6717
+ const rightStartLine = rec.startLine + leftText.split('\n').length + 1;
6718
+ const ok = await commitTypingThenStructure({ typed,
6719
+ commit: (state) => commitSplit(state, blockId, leftText, rightText),
6720
+ oldRange: [rec.startLine, rec.endLine],
6721
+ lineMap: { ids: { [blockId]: rec.startLine }, provisional: rightStartLine },
6722
+ pending: { surfaces: [editEl, right], serialize: () => {
6723
+ const left = surfaceCommitText(editEl, burst.blockType, burst.depth);
6724
+ const text = left + '\n\n' + inlineMd.escapeBlockStarts(inlineMd.serializeInline(right).md);
6725
+ return { text, lineMap: { ids: { [blockId]: rec.startLine },
6726
+ provisional: rec.startLine + left.split('\n').length + 1 } };
6727
+ } } });
6728
+ if (ok) focusBlockAtLine(rightStartLine, false);
6729
+ }
6730
+
6731
+ // Enter in the middle of a transient with text: its two halves are its
6732
+ // commit — one op (commitLinesAtTransient()), caret to the right one's
6733
+ // start. Nothing is drawn first; a failed commit leaves the transient as it
6734
+ // was, its text and caret included, and keys held meanwhile replay there.
6735
+ async function splitTransientAtCaret(blockEl, editEl) {
6736
+ const h = halvesAtCaret(editEl);
6737
+ const left = inlineMd.escapeBlockStarts(inlineMd.serializeInline(h.left).md).split('\n');
6738
+ const right = inlineMd.escapeBlockStarts(inlineMd.serializeInline(h.right).md).split('\n');
6739
+ // The burst ends first, as commitListStructure()'s does: the render
6740
+ // detaches the focused transient, and Chromium's synchronous focusout
6741
+ // would otherwise resolve this burst a second time — measured: the
6742
+ // transient's whole text committed again above the two halves.
6743
+ endBurstWithoutResolve();
6744
+ const res = await commitLinesAtTransient(blockEl, left.concat([''], right));
6745
+ if (res.ok) {
6746
+ focusBlockAtLine(res.newStartLine + left.length + 1, false);
6747
+ return;
6748
+ }
6749
+ // Failed: the transient keeps its text, so it gets its burst back (its
6750
+ // baseline is empty, so the next resolution commits that text).
6751
+ if (editEl.isConnected && !currentBurst) startBurst(editEl);
6752
+ }
6753
+
6754
+ // ── D7: a structure whose render failed ──────────────────────────────────
6755
+ // The screen keeps what the user made; `lines` was rolled back. The next
6756
+ // resolution (resolveOpenSession(), first), a focus into any surface, or a
6757
+ // structural key (R6) re-commits it from the live DOM before anything else
6758
+ // can commit over it: a commit elsewhere would render, and a render drops
6759
+ // the screen's half of it. Task 2 introduces the slot for the paragraph
6760
+ // split; later tasks reuse it.
6761
+ // { range: {startLine, endLine}, before, surfaces, serialize }
6762
+ // `serialize()` → { text, lineMap }: what to write over `range`, and the
6763
+ // pendingFlightLineMap that re-points held keys at the result.
6764
+ const PENDING_STRUCTURE_LOST_MESSAGE =
6765
+ '先前沒有寫進檔案的分段/合併已無法補寫:文件在那之後被改過。畫面已換回檔案的內容。';
6766
+ // `covers(surface)` (optional): true when the retry's serialize() reads that
6767
+ // surface — a list run's retry reads every item of the run, not only the
6768
+ // ones the gesture touched. Without it, `surfaces` is the answer.
6769
+ // `typedIds` (fix round 1, I2): ids of covered blocks whose burst a retry
6770
+ // ended with typed text in it. Their text is then only in the DOM, so a
6771
+ // later serialize() must read them from there, not replay their source.
6772
+ function setPendingStructure(startLine, endLine, surfaces, serialize, covers) {
6773
+ pendingStructure = { range: { startLine, endLine },
6774
+ before: lines.slice(startLine - 1, endLine).join('\n'), surfaces, serialize, covers: covers || null,
6775
+ typedIds: new Set() };
6776
+ setDirty(); // R34: it is unsaved work (documentIsDirty())
6777
+ }
6778
+
6779
+ // Re-commits pendingStructure. true = nothing pending (any more); false =
6780
+ // its render failed again (banner shown, the slot stays). A surface the
6781
+ // retry serialized loses its burst without resolving: its text is in the
6782
+ // retry. A slot whose surfaces a render already replaced, or whose range
6783
+ // `lines` no longer holds, is dropped — there is nothing left to write it
6784
+ // from, or nowhere safe to write it.
6785
+ //
6786
+ // v3.14.0 T6: a caret that was in a surface the retry serialized is put
6787
+ // back on the same line and offset once it lands (a Ctrl+S right after a
6788
+ // failed list Enter used to leave focus on the body). And (R28) an open
6789
+ // burst with typed text on a surface the retry does NOT serialize — typing
6790
+ // done after a retry failed too, in another block — is resolved INSIDE the
6791
+ // retry: the structure op first (it happened first), then that burst's own
6792
+ // commit, which renders both. Before, the retry rendered first and dropped
6793
+ // that burst's surface, and its text was lost.
6794
+ async function retryPendingStructure() {
6795
+ const ps = pendingStructure;
6796
+ if (!ps) return true;
6797
+ if (!ps.surfaces.every((s) => s.isConnected) ||
6798
+ lines.slice(ps.range.startLine - 1, ps.range.endLine).join('\n') !== ps.before) {
6799
+ // R32: never silently. Something wrote `lines` or rendered over the
6800
+ // structure first (the R32 audit found no gesture that can); what the
6801
+ // screen showed of it cannot be written any more, so say so and show
6802
+ // the file as it is.
6803
+ pendingStructure = null;
6804
+ setDirty();
6805
+ showError(PENDING_STRUCTURE_LOST_MESSAGE, null, null);
6806
+ if (ps.surfaces.every((s) => s.isConnected)) await safeRerenderAll();
6807
+ return true;
6808
+ }
6809
+ const covers = (el) => !!el && (ps.covers ? ps.covers(el) : ps.surfaces.indexOf(el) !== -1);
6810
+ const caret = selectionSnapshotIn(activeFlightSurface());
6811
+ const caretTag = caret && caret.anchor !== null && covers(caret.surface) ? flightTagOf(caret) : null;
6812
+ // serialize() before the burst ends: a list run's bystander replay reads
6813
+ // the open burst to leave a typed-into item to the DOM.
6814
+ const s = ps.serialize(ps);
6815
+ if (currentBurst && covers(currentBurst.editEl)) {
6816
+ // I2: if this retry's render fails, that typing lives only in the DOM.
6817
+ if (burstHasUncommittedEdit() && currentBurst.blockId !== null) ps.typedIds.add(String(currentBurst.blockId));
6818
+ endBurstWithoutResolve();
6819
+ }
6820
+ const other = burstHasUncommittedEdit() ? currentBurst : null;
6821
+ // `text: null` is a removal (an emptied block whose removal did not land).
6822
+ const result = s.text === null
6823
+ ? commitRangeRemoval({ lines, blocks, stack }, ps.range.startLine, ps.range.endLine)
6824
+ : commitRangeEdit({ lines, blocks, stack }, ps.range.startLine, ps.range.endLine, s.text);
6825
+ const prevLines = lines;
6826
+ const prevBlocks = blocks;
6827
+ if (result.op !== null) lines = result.lines;
6828
+ const editRange = { startLine: ps.range.startLine, endLine: ps.range.endLine,
6829
+ delta: result.op ? result.op.after.length - result.op.before.length : 0 };
6830
+ const undoStructure = () => {
6831
+ lines = rollbackFailedRender({ lines, stack }, result, prevLines);
6832
+ blocks = prevBlocks;
6833
+ };
6834
+ let ok;
6835
+ if (other) {
6836
+ // R28. `blocks` shifted with the structure op, so the burst's commit
6837
+ // finds its block where the structure moved it. A render rebinds
6838
+ // `blocks`, which is how this tells whether one landed: a refusal can
6839
+ // render and still answer false, and a no-op answers true unrendered.
6840
+ const shifted = result.op !== null ? result.blocks : blocks;
6841
+ blocks = shifted;
6842
+ pendingFlightLineMap = s.lineMap;
6843
+ const resolved = await resolveBurst();
6844
+ if (blocks !== shifted) {
6845
+ if (pendingStructure === ps) { pendingStructure = null; setDirty(); }
6846
+ return resolved;
6847
+ }
6848
+ if (!resolved) { undoStructure(); return false; }
6849
+ pendingFlightLineMap = s.lineMap;
6850
+ ok = await safeRerenderAll(editRange);
6851
+ } else {
6852
+ pendingFlightLineMap = s.lineMap;
6853
+ ok = await safeRerenderAll(editRange);
6854
+ }
6855
+ if (!ok) { undoStructure(); return false; }
6856
+ if (pendingStructure === ps) { pendingStructure = null; setDirty(); }
6857
+ if (caretTag && !activeFlightSurface() && s.lineMap) {
6858
+ const line = caretTag.blockId === null ? s.lineMap.provisional : s.lineMap.ids[caretTag.blockId];
6859
+ const el = line === null || line === undefined ? null : blockElAtLine(line);
6860
+ const surface = el ? blockContentEl(el) : null;
6861
+ if (surface && surface.classList.contains('ed-wys-armed')) {
6862
+ surface.focus();
6863
+ restoreSelection(surface, caretTag.anchor, caretTag.focus);
6864
+ }
6865
+ }
6866
+ return true;
6867
+ }
6868
+
6869
+ // R6: a structural key pressed while a structure is pending. The retry goes
6870
+ // first and the key is held behind it (the retry's render is a flight), so
6871
+ // it — and anything typed after it — replays once the retry has landed, in
6872
+ // order. If the retry fails the key is dropped: the error card says why.
6873
+ function retryStructureThenReplay(e, surface) {
6874
+ e.preventDefault();
6875
+ const entry = { kind: 'key', key: e.key, shiftKey: e.shiftKey, ctrlKey: e.ctrlKey,
6876
+ altKey: e.altKey, metaKey: e.metaKey };
6877
+ const retry = retryPendingStructure();
6878
+ holdFlightEntry(surface, entry);
6879
+ const drop = () => {
6880
+ const i = flightBuffer.indexOf(entry);
6881
+ if (i !== -1) flightBuffer.splice(i, 1);
6882
+ };
6883
+ retry.then((ok) => {
6884
+ if (!ok) drop();
6885
+ scheduleFlightFlush();
6886
+ }, (err) => { drop(); structuralKeyFailed(err); });
6887
+ }
6888
+
6889
+ // R32 (fix round 1): a structural key's async work is fire-and-forget from
6890
+ // its keydown. A throw there must not vanish (an unhandled rejection) nor
6891
+ // leave held keys waiting for a flush nobody schedules — flightActive()
6892
+ // would then hold every later key. The flight count itself cannot stick:
6893
+ // safeRerenderAll() ends its flight in `finally`.
6894
+ function structuralKeyFailed(err) {
6895
+ if (typeof console !== 'undefined' && console.error) console.error('md2doc: a structural key failed', err);
6896
+ showError('這次修改沒有套用:發生未預期的錯誤(' + describeFailure(err) + ')。文件維持修改前的樣子。', null, null);
6897
+ scheduleFlightFlush();
6898
+ }
6899
+ function isStructuralKeyForRetry(e, surface) {
6900
+ if (e.isComposing || e.keyCode === 229 || e.ctrlKey || e.metaKey || e.altKey) return false;
6901
+ if ((e.key === 'Enter' && !e.shiftKey) || e.key === 'Tab') return true;
6902
+ // v3.14.0 T3: Backspace at a start / Delete at an end joins blocks.
6903
+ if (e.shiftKey || (e.key !== 'Backspace' && e.key !== 'Delete')) return false;
6904
+ const place = caretPlace(surface, true);
6905
+ return place === 'empty' || place === (e.key === 'Backspace' ? 'start' : 'end');
6906
+ }
6907
+
6908
+ // ── v3.14.0 T3: Backspace at a block start, Delete at a block end ──────
6909
+ //
6910
+ // Spec §3.2. Backspace with the caret at the start of a paragraph joins it
6911
+ // to the block above (a paragraph, a heading, or a list item's text); at
6912
+ // the start of a heading it makes the heading a paragraph. Delete at the
6913
+ // end of a paragraph or heading pulls the next one up. A neighbour that has
6914
+ // no text to join (a table, a code block, a list item for Delete) is
6915
+ // selected instead, and an EMPTY block below such a neighbour goes away
6916
+ // with the caret landing at the neighbour's end (R12). Every case is one
6917
+ // undo step; typing the key commits first is its own earlier step (D4).
6918
+ // The join is done on screen synchronously, the caret placed at the seam,
6919
+ // then committed through the flight guard (R5: the new caret first, then
6920
+ // the burst ends), and a failed render keeps the screen as joined
6921
+ // (pendingStructure, D7).
6922
+
6923
+ // Inline marks that merge across a seam when they meet: same tag, no
6924
+ // attributes. Two links merge only when their href is the same.
6925
+ const SEAM_MERGE_TAGS = ['STRONG', 'B', 'EM', 'I', 'DEL', 'S', 'U', 'CODE'];
6926
+ function sameSeamMark(a, b) {
6927
+ if (!a || !b || a.nodeType !== 1 || b.nodeType !== 1 || a.nodeName !== b.nodeName) return false;
6928
+ if (a.nodeName === 'A') return a.getAttribute('href') === b.getAttribute('href');
6929
+ return SEAM_MERGE_TAGS.indexOf(a.nodeName) !== -1 && !a.attributes.length && !b.attributes.length;
6930
+ }
6931
+
6932
+ // Appends `rightNodes` to `leftSurface` and returns the seam: the left
6933
+ // text length before the join. A trailing <br> on the left goes first (at
6934
+ // the end of a line it showed nothing; before joined text it would be a
6935
+ // hard break), and a right side that holds nothing is not appended at all
6936
+ // (an emptied surface keeps a placeholder <br>). At the seam, marks of the
6937
+ // same kind merge, repeated inward, so `**bo**` + `**ld**` is `**bold**`
6938
+ // and not `**bo****ld**`.
6939
+ //
6940
+ // `flatten` (R30, fix round 1 C3): the target is a heading, which is one
6941
+ // line — every hard break (<br>) and soft-wrap newline in the joined text
6942
+ // becomes one space. Measured before: `first\` + `second` merged into
6943
+ // `# D` wrote `# Dfirst\` and a stray `second` paragraph.
6944
+ function joinSeam(leftSurface, rightNodes, flatten) {
6945
+ // T8: a Shift+Enter tail <br> holds no line of its own (insertBrAtCaret()).
6946
+ while (leftSurface.lastChild && leftSurface.lastChild.nodeName === 'BR' &&
6947
+ leftSurface.lastChild.getAttribute('data-ed-tail') === '1') leftSurface.removeChild(leftSurface.lastChild);
6948
+ const tail = leftSurface.lastChild;
6949
+ if (tail && tail.nodeName === 'BR') leftSurface.removeChild(tail);
6950
+ leftSurface.normalize();
6951
+ const seam = leftSurface.textContent.length;
6952
+ const holder = document.createElement('p');
6953
+ rightNodes.forEach((n) => holder.appendChild(n));
6954
+ // Asked BEFORE the flatten below, which turns an emptied surface's
6955
+ // placeholder <br> into a space. Fix round 2: asked after it, that space
6956
+ // was appended to the heading — Backspace removing an emptied paragraph
6957
+ // under `# D` saved `# D `.
6958
+ if (holder.textContent === '' && !holder.querySelector('img')) return seam;
6959
+ if (flatten) {
6960
+ // A break at either edge of the joined text separates nothing, so the
6961
+ // space it would become goes too (fix round 2: a text ending in a
6962
+ // <br> joined a heading as `# Dfirst `).
6963
+ const breakAt = (atEnd) => {
6964
+ const leaf = edgeLeaf(holder, atEnd);
6965
+ if (!leaf) return false;
6966
+ if (leaf.nodeName === 'BR') return true;
6967
+ return leaf.nodeType === 3 && (atEnd ? /\n[ \t]*$/ : /^[ \t]*\n/).test(leaf.data);
6968
+ };
6969
+ // normalize() first: an editing surface often carries an EMPTY text
6970
+ // node after its last <br> (invisible in innerHTML), and edgeLeaf()
6971
+ // would answer that instead of the break.
6972
+ holder.normalize();
6973
+ const leadingBreak = breakAt(false);
6974
+ const trailingBreak = breakAt(true);
6975
+ Array.prototype.slice.call(holder.querySelectorAll('br')).forEach((br) => {
6976
+ br.parentNode.replaceChild(document.createTextNode('\n'), br);
6977
+ });
6978
+ holder.normalize();
6979
+ const walker = document.createTreeWalker(holder, NodeFilter.SHOW_TEXT);
6980
+ for (let t = walker.nextNode(); t; t = walker.nextNode()) {
6981
+ t.data = t.data.replace(/[ \t]*\n[ \t\n]*/g, ' ');
6982
+ }
6983
+ const first = edgeLeaf(holder, false);
6984
+ if (leadingBreak && first && first.nodeType === 3) first.data = first.data.replace(/^ /, '');
6985
+ const last = edgeLeaf(holder, true);
6986
+ if (trailingBreak && last && last.nodeType === 3) last.data = last.data.replace(/ $/, '');
6987
+ if (holder.textContent === '' && !holder.querySelector('img')) return seam;
6988
+ }
6989
+ let a = leftSurface.lastChild;
6990
+ while (holder.firstChild) leftSurface.appendChild(holder.firstChild);
6991
+ while (a && sameSeamMark(a, a.nextSibling)) {
6992
+ const b = a.nextSibling;
6993
+ const inner = a.lastChild;
6994
+ while (b.firstChild) a.appendChild(b.firstChild);
6995
+ b.parentNode.removeChild(b);
6996
+ a = inner;
6997
+ }
6998
+ leftSurface.normalize();
6999
+ return seam;
7000
+ }
7001
+
7002
+ // focusBlockAtLine() with the caret at a character offset.
7003
+ function focusBlockAtLineOffset(startLine, offset) {
7004
+ const el = blockElAtLine(startLine);
7005
+ const surface = el ? blockContentEl(el) : null;
7006
+ if (!surface || !surface.classList.contains('ed-wys-armed')) return;
7007
+ surface.focus();
7008
+ restoreSelection(surface, offset, offset);
7009
+ }
7010
+
7011
+ // The armed text surface a join can go into: a paragraph, a heading or a
7012
+ // list item, or null.
7013
+ function mergeSurfaceOf(blockEl) {
7014
+ if (!blockEl) return null;
7015
+ const t = blockEl.getAttribute('data-block-type');
7016
+ if (t !== 'paragraph' && t !== 'heading' && t !== 'li') return null;
7017
+ const s = blockContentEl(blockEl);
7018
+ return s && s.classList.contains('ed-wys-armed') ? s : null;
7019
+ }
7020
+ function nextBlockEl(el) {
7021
+ let n = el.nextElementSibling;
7022
+ while (n && !n.classList.contains('ed-block')) n = n.nextElementSibling;
7023
+ return n;
7024
+ }
7025
+ function selectBlock(blockEl) {
7026
+ const rec = blockRecOf(blockEl);
7027
+ if (!rec || rec.endLine < rec.startLine) return;
7028
+ setBlockSelection({ anchorLine: rec.startLine, focusLine: rec.startLine });
7029
+ }
7030
+ function caretToSurface(surface, atEnd) {
7031
+ surface.focus();
7032
+ const r = document.createRange();
7033
+ r.selectNodeContents(surface);
7034
+ r.collapse(!atEnd);
7035
+ const sel = window.getSelection();
7036
+ sel.removeAllRanges();
7037
+ sel.addRange(r);
7038
+ }
7039
+
7040
+ // Plain Backspace (dir -1) at the start, or plain Delete (dir 1) at the end,
7041
+ // of a paragraph, heading or transient surface. true = handled (and
7042
+ // preventDefault() done); false = the browser's own key.
7043
+ function blockEdgeDelete(e, editEl, dir) {
7044
+ if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey || e.isComposing || e.keyCode === 229) return false;
7045
+ const burst = currentBurst;
7046
+ if (!burst || burst.editEl !== editEl) return false;
7047
+ if (burst.blockType !== 'paragraph' && burst.blockType !== 'heading') return false;
7048
+ const place = caretPlace(editEl, true);
7049
+ if (dir < 0 ? (place !== 'start' && place !== 'empty') : (place !== 'end' && place !== 'empty')) return false;
7050
+ if (inlineMd.serializeInline(editEl).unsupported.length) return false;
7051
+ e.preventDefault();
7052
+ const blockEl = editEl.closest('.ed-block');
7053
+ (dir < 0 ? backspaceAtStart(burst, editEl, blockEl, place === 'empty')
7054
+ : deleteAtEnd(burst, editEl, blockEl, place === 'empty')).catch(structuralKeyFailed);
7055
+ return true;
7056
+ }
7057
+
7058
+ // The commit every structural key here ends with (the Enter split, every
7059
+ // Backspace / Delete join, the heading -> paragraph conversion, the
7060
+ // removal of an emptied block): the pending typing of the block the key
7061
+ // was pressed in (D4), then the structure — `o.commit(state)`, or `o.text`
7062
+ // over the range `o.range(blocks)` computes in the state after the typing
7063
+ // op (a removal when `text` is null) — in ONE render. `oldRange` is the
7064
+ // range in today's lines. A failed render rolls both ops back, newest
7065
+ // first, and records `o.pending` (D7). Fix round 1 (I2): the split used to
7066
+ // carry its own copy of all of this.
7067
+ async function commitTypingThenStructure(o) {
7068
+ const prevLines = lines;
7069
+ const first = o.typed ? commitEdit({ lines, blocks, stack }, o.typed.blockId, o.typed.text) : null;
7070
+ const typedOp = first && first.op ? first : null;
7071
+ const state = typedOp ? { lines: typedOp.lines, blocks: typedOp.blocks, stack } : { lines, blocks, stack };
7072
+ let second;
7073
+ if (o.commit) second = o.commit(state);
7074
+ else {
7075
+ const range = o.range(state.blocks);
7076
+ second = o.text === null
7077
+ ? commitRangeRemoval(state, range[0], range[1])
7078
+ : commitRangeEdit(state, range[0], range[1], o.text);
7079
+ }
7080
+ lines = second.lines;
7081
+ const delta = (typedOp ? typedOp.op.after.length - typedOp.op.before.length : 0) +
7082
+ (second.op ? second.op.after.length - second.op.before.length : 0);
7083
+ pendingFlightLineMap = o.lineMap;
7084
+ const ok = await safeRerenderAll({ startLine: o.oldRange[0], endLine: o.oldRange[1], delta });
7085
+ if (ok) return true;
7086
+ lines = rollbackFailedRender({ lines, stack }, second, typedOp ? typedOp.lines : prevLines);
7087
+ if (typedOp) lines = rollbackFailedRender({ lines, stack }, typedOp, prevLines);
7088
+ if (o.pending) setPendingStructure(o.oldRange[0], o.oldRange[1], o.pending.surfaces, o.pending.serialize);
7089
+ return false;
7090
+ }
7091
+
7092
+ function typedInBurst(burst) {
7093
+ if (burst.transient || burstBaselineHtml(burst.editEl) === burst.original) return null;
7094
+ burst.history.flushTyping();
7095
+ return { blockId: burst.blockId, text: burstCommitText(burst) };
7096
+ }
7097
+
7098
+ async function backspaceAtStart(burst, editEl, blockEl, empty) {
7099
+ if (burst.blockType === 'heading') { await headingToParagraph(burst, editEl, blockEl, empty); return; }
7100
+ const prev = previousBlockEl(blockEl);
7101
+ if (!prev) return;
7102
+ // An empty transient above (Enter at this block's start made it): the
7103
+ // Backspace takes it away. One with text: this block joins it (R31).
7104
+ if (isTransient(prev)) {
7105
+ if (transientSurfaceEmpty(prev)) removeTransient();
7106
+ else if (!burst.transient) await joinTransientIntoBlock(burst, editEl, prev, false);
7107
+ return;
7108
+ }
7109
+ const curIsTransient = !!burst.transient;
7110
+ const prevSurface = mergeSurfaceOf(prev);
7111
+ if (!prevSurface) {
7112
+ if (!empty) { selectBlock(prev); return; }
7113
+ // R12 (spec §3.2): an empty block below a table / code block goes, and
7114
+ // the caret lands at that block's end.
7115
+ if (curIsTransient) {
7116
+ removeTransient();
7117
+ arrowLandOn(prev, -1, null);
7118
+ return;
7119
+ }
7120
+ const rec = blocks.find((b) => b.id === burst.blockId);
7121
+ const prevRec = blockRecOf(prev);
7122
+ if (!rec || !prevRec) return;
7123
+ endBurstWithoutResolve();
7124
+ const ok = await commitTypingThenStructure({ typed: null, text: null,
7125
+ range: (bs) => { const r = bs.find((b) => b.id === rec.id); return [r.startLine, r.endLine]; },
7126
+ oldRange: [rec.startLine, rec.endLine], lineMap: { ids: {}, provisional: null },
7127
+ pending: { surfaces: [editEl], serialize: () => ({ text: null, lineMap: { ids: {}, provisional: null } }) } });
7128
+ if (ok) {
7129
+ const landed = blockElAtLine(prevRec.startLine);
7130
+ if (landed) arrowLandOn(landed, -1, null);
7131
+ }
7132
+ return;
7133
+ }
7134
+ if (curIsTransient && empty) {
7135
+ removeTransient();
7136
+ caretToSurface(prevSurface, true);
7137
+ return;
7138
+ }
7139
+ await joinIntoPrevious(burst, editEl, blockEl, prev, prevSurface, empty);
7140
+ }
7141
+
7142
+ // Backspace at the start of `blockEl` (a paragraph or a transient with
7143
+ // text) into the block above: a paragraph, a heading or a list item.
7144
+ // `empty`: the surface was emptied, so its typing is not committed as an op
7145
+ // of its own (final review I2) — it would write the placeholder <br> left
7146
+ // behind as a raw-html block, and one Ctrl+Z would land there. The join
7147
+ // alone is the step, as for every other emptied-block removal (C1, R12).
7148
+ async function joinIntoPrevious(burst, editEl, blockEl, prev, prevSurface, empty) {
7149
+ const curIsTransient = !!burst.transient;
7150
+ const prevRec = blockRecOf(prev);
7151
+ const curRec = curIsTransient ? null : blocks.find((b) => b.id === burst.blockId);
7152
+ if (!prevRec || (!curIsTransient && !curRec)) return;
7153
+ const prevType = prev.getAttribute('data-block-type');
7154
+ const prevDepth = blockDepthOf(prevType, prevSurface);
7155
+ let run = null;
7156
+ let runRange = null;
7157
+ if (prevType === 'li') {
7158
+ run = listRunOf(prev);
7159
+ runRange = run.length ? runRangeOfBlocks({ lines, blocks, stack }, run) : null;
7160
+ if (!runRange) return;
7161
+ if (!listRunSupportsStructuralEdit(run, prev)) { refuseStructuralListEdit(); return; }
7162
+ }
7163
+ const typed = empty ? null : typedInBurst(burst);
7164
+ let seam = 0;
7165
+ withFocusoutSuppressed(() => {
7166
+ seam = joinSeam(prevSurface, Array.prototype.slice.call(editEl.childNodes), prevType === 'heading');
7167
+ prevSurface.focus();
7168
+ restoreSelection(prevSurface, seam, seam);
7169
+ if (curIsTransient) removeTransient();
7170
+ else blockEl.parentNode.removeChild(blockEl);
7171
+ });
7172
+ endBurstWithoutResolve();
7173
+ const startLine = run ? runRange.startLine : prevRec.startLine;
7174
+ const curId = curRec ? curRec.id : null;
7175
+ const endIn = (bs, fallback) => {
7176
+ if (curId === null) return fallback;
7177
+ const r = bs.find((b) => b.id === curId);
7178
+ return r ? r.endLine : fallback;
7179
+ };
7180
+ const serialize = () => {
7181
+ if (run) {
7182
+ const out = serializeListEls(run, { carryOver: bystanderCarryOver(run, prev) });
7183
+ const map = flightLineMapOf(out.lineMeta, runRange.startLine);
7184
+ return { text: out.md, lineMap: { ids: map.ids, provisional: null } };
7185
+ }
7186
+ return { text: surfaceCommitText(prevSurface, prevType, prevDepth),
7187
+ lineMap: { ids: { [prevRec.id]: prevRec.startLine }, provisional: null } };
7188
+ };
7189
+ const s = serialize();
7190
+ const oldEnd = curRec ? curRec.endLine : (run ? runRange.endLine : prevRec.endLine);
7191
+ const ok = await commitTypingThenStructure({ typed, text: s.text,
7192
+ range: (bs) => [startLine, endIn(bs, run ? runRange.endLine : prevRec.endLine)],
7193
+ oldRange: [startLine, oldEnd], lineMap: s.lineMap,
7194
+ pending: { surfaces: [prevSurface], serialize } });
7195
+ if (ok) focusBlockAtLineOffset(prevRec.startLine, seam);
7196
+ }
7197
+
7198
+ // Backspace at the start of a heading: it becomes a paragraph (its text
7199
+ // escaped where it would read as a block marker). An empty heading is
7200
+ // removed and an empty transient paragraph takes its place.
7201
+ async function headingToParagraph(burst, editEl, blockEl, empty) {
7202
+ if (empty) { await replaceEmptiedBlockWithTransient(burst, editEl); return; }
7203
+ const rec = blocks.find((b) => b.id === burst.blockId);
7204
+ if (!rec) return;
7205
+ const typed = typedInBurst(burst);
7206
+ endBurstWithoutResolve();
7207
+ const text = () => inlineMd.escapeBlockStarts(inlineMd.serializeInline(editEl).md);
7208
+ const lineMap = { ids: { [rec.id]: rec.startLine }, provisional: null };
7209
+ const ok = await commitTypingThenStructure({ typed, text: text(),
7210
+ range: (bs) => { const r = bs.find((b) => b.id === rec.id); return [r.startLine, r.endLine]; },
7211
+ oldRange: [rec.startLine, rec.endLine], lineMap,
7212
+ pending: { surfaces: [editEl], serialize: () => ({ text: text(), lineMap }) } });
7213
+ if (ok) focusBlockAtLineOffset(rec.startLine, 0);
7214
+ }
7215
+
7216
+ // An emptied real block (paragraph or heading) under Enter, or an empty
7217
+ // heading under Backspace: ONE removal op (an empty block cannot be
7218
+ // written), then a focused transient where it was — after the block before
7219
+ // it (the end of that block's subtree for a li), or at the top. Keys typed
7220
+ // during the render are held and replay into that transient. As with the
7221
+ // empty-li Enter (T1), Ctrl+Z in that still-empty transient first takes the
7222
+ // transient away, then the next one reverts the removal.
7223
+ async function replaceEmptiedBlockWithTransient(burst, editEl) {
7224
+ const rec = blocks.find((b) => b.id === burst.blockId);
7225
+ if (!rec) return;
7226
+ const preceding = blocks.filter((b) => b.id !== rec.id && b.endLine < rec.startLine).pop() || null;
7227
+ endBurstWithoutResolve();
7228
+ const lineMap = { ids: {}, provisional: null };
7229
+ const ok = await commitTypingThenStructure({ typed: null, text: null,
7230
+ range: (bs) => { const r = bs.find((b) => b.id === rec.id); return [r.startLine, r.endLine]; },
7231
+ oldRange: [rec.startLine, rec.endLine], lineMap,
7232
+ pending: { surfaces: [editEl], serialize: () => ({ text: null, lineMap }) } });
7233
+ if (!ok) return;
7234
+ const anchor = preceding ? blockElAtLine(preceding.startLine) : null;
7235
+ if (preceding && !anchor) return;
7236
+ createTransient(anchor ? subtreeEndOf(anchor) : null, { focus: true });
7237
+ }
7238
+
7239
+
7240
+ // R31 (fix round 1): a transient WITH text next to the real block the key
7241
+ // was pressed in. Its text was never committed, so the join is ONE op on
7242
+ // the real block's range (after the block's own pending typing, D4): Delete
7243
+ // at the block's end pulls the transient's text up into the block;
7244
+ // Backspace at the block's start joins the block onto the end of the
7245
+ // transient's text, and the joined paragraph takes the block's lines.
7246
+ async function joinTransientIntoBlock(burst, editEl, transientEl, pullUp) {
7247
+ const rec = blocks.find((b) => b.id === burst.blockId);
7248
+ const tSurface = blockContentEl(transientEl);
7249
+ if (!rec || !tSurface) return;
7250
+ const typed = typedInBurst(burst);
7251
+ const into = pullUp ? editEl : tSurface;
7252
+ const from = pullUp ? tSurface : editEl;
7253
+ const flatten = pullUp && burst.blockType === 'heading';
7254
+ let seam = 0;
7255
+ withFocusoutSuppressed(() => {
7256
+ seam = joinSeam(into, Array.prototype.slice.call(from.childNodes), flatten);
7257
+ into.focus();
7258
+ restoreSelection(into, seam, seam);
7259
+ if (pullUp) {
7260
+ removeTransient();
7261
+ } else {
7262
+ // The transient now holds the joined text and stands in for the block
7263
+ // on screen until the render; the `transient` slot lets go of it so
7264
+ // nothing resolves it as a transient of its own.
7265
+ editEl.closest('.ed-block').parentNode.removeChild(editEl.closest('.ed-block'));
7266
+ if (transient && transient.el === transientEl) transient = null;
7267
+ }
7268
+ });
7269
+ endBurstWithoutResolve();
7270
+ const text = () => (pullUp
7271
+ ? surfaceCommitText(editEl, burst.blockType, burst.depth)
7272
+ : inlineMd.escapeBlockStarts(inlineMd.serializeInline(tSurface).md));
7273
+ const lineMap = { ids: { [rec.id]: rec.startLine }, provisional: null };
7274
+ const ok = await commitTypingThenStructure({ typed, text: text(),
7275
+ range: (bs) => { const r = bs.find((b) => b.id === rec.id); return [r.startLine, r.endLine]; },
7276
+ oldRange: [rec.startLine, rec.endLine], lineMap,
7277
+ pending: { surfaces: [into], serialize: () => ({ text: text(), lineMap }) } });
7278
+ if (ok) focusBlockAtLineOffset(rec.startLine, seam);
7279
+ }
7280
+
7281
+ async function deleteAtEnd(burst, editEl, blockEl, empty) {
7282
+ const next = nextBlockEl(blockEl);
7283
+ // Nothing below: nothing happens (an empty transient there is the last
7284
+ // block and stays, R12).
7285
+ if (!next) return;
7286
+ if (isTransient(next)) {
7287
+ if (transientSurfaceEmpty(next)) removeTransient();
7288
+ else if (!burst.transient) await joinTransientIntoBlock(burst, editEl, next, true);
7289
+ return;
7290
+ }
7291
+ const curIsTransient = !!burst.transient;
7292
+ const nextSurface = mergeSurfaceOf(next);
7293
+ const nextType = next.getAttribute('data-block-type');
7294
+ if (curIsTransient && empty) {
7295
+ removeTransient();
7296
+ if (nextSurface) caretToSurface(nextSurface, false);
7297
+ else selectBlock(next);
7298
+ return;
7299
+ }
7300
+ // D14: a list item is never pulled up into a paragraph; it is selected.
7301
+ if (!nextSurface || nextType === 'li') { selectBlock(next); return; }
7302
+ const nextRec = blockRecOf(next);
7303
+ const curRec = curIsTransient ? null : blocks.find((b) => b.id === burst.blockId);
7304
+ if (!nextRec || (!curIsTransient && !curRec)) return;
7305
+ // Final review I2, as in joinIntoPrevious(): an emptied block's typing is
7306
+ // not an op of its own.
7307
+ const typed = empty ? null : typedInBurst(burst);
7308
+ let seam = 0;
7309
+ withFocusoutSuppressed(() => {
7310
+ seam = joinSeam(editEl, Array.prototype.slice.call(nextSurface.childNodes), burst.blockType === 'heading');
7311
+ restoreSelection(editEl, seam, seam);
7312
+ next.parentNode.removeChild(next);
7313
+ });
7314
+ endBurstWithoutResolve();
7315
+ const headId = curRec ? curRec.id : nextRec.id;
7316
+ const headLine = curRec ? curRec.startLine : nextRec.startLine;
7317
+ const serialize = () => ({
7318
+ text: curIsTransient
7319
+ ? inlineMd.escapeBlockStarts(inlineMd.serializeInline(editEl).md)
7320
+ : surfaceCommitText(editEl, burst.blockType, burst.depth),
7321
+ lineMap: { ids: { [headId]: headLine }, provisional: null } });
7322
+ const s = serialize();
7323
+ const ok = await commitTypingThenStructure({ typed, text: s.text,
7324
+ range: (bs) => {
7325
+ const n = bs.find((b) => b.id === nextRec.id);
7326
+ const c = curRec ? bs.find((b) => b.id === curRec.id) : null;
7327
+ return [c ? c.startLine : n.startLine, n.endLine];
7328
+ },
7329
+ oldRange: [headLine, nextRec.endLine], lineMap: s.lineMap,
7330
+ pending: { surfaces: [editEl], serialize } });
7331
+ if (ok) focusBlockAtLineOffset(headLine, seam);
7332
+ }
7333
+
5742
7334
  // S2 Task 7 — the li half of +. The new item is spliced into the run's own
5743
7335
  // span and the WHOLE span is re-serialized over the run's line range: one
5744
7336
  // commitRangeEdit, therefore one undo op, no leading blank line (so the run
@@ -6129,12 +7721,12 @@
6129
7721
  // never reported, never tested for duplicate/delete) — widening past
6130
7722
  // convertBlockViaMenu() to the other three callers remains a separate
6131
7723
  // decision this task did not make and should not make silently.
6132
- function restoreAfterStructuralOp(focusLine, anchorLine, allowRawEditRescue) {
7724
+ function restoreAfterStructuralOp(focusLine, anchorLine, allowRawEditRescue, caretToEnd) {
6133
7725
  // 有 blockSelection 站著時什麼都不做 —— 見 convertBlockViaMenu() 底下那段
6134
7726
  // 註解:強行還原 caret 會做出「底色仍說已選取、Delete 卻是死鍵」的混種狀態。
6135
7727
  if (blockSelection) return;
6136
7728
  if (focusLine != null) {
6137
- focusBlockAtLine(focusLine, true); // caretToEnd
7729
+ focusBlockAtLine(focusLine, caretToEnd !== false); // caretToEnd unless asked otherwise
6138
7730
  // 降級目標(quote / code)沒有可聚焦編輯面,focusBlockAtLine 會安靜
6139
7731
  // no-op;把 toolbarBlockEl 指回那個 block,工具列才不會塌成 4 顆。
6140
7732
  reaimToolbarBlockAtLine(focusLine);
@@ -6254,7 +7846,10 @@
6254
7846
  // The body returns the operated block's post-commit start line, or undefined
6255
7847
  // when it refused or wrote nothing — `anchor.line` is what covers the
6256
7848
  // second case (see restoreAfterStructuralOp()).
6257
- async function convertBlockViaMenu(blockEl, target) {
7849
+ // `opts.caretToEnd === false` puts the caret at the start of the converted
7850
+ // block instead of its end (Backspace at a list item's start, final review
7851
+ // I1); the ⠿ menu passes nothing.
7852
+ async function convertBlockViaMenu(blockEl, target, opts) {
6258
7853
  let focusLine = null;
6259
7854
  const anchor = {};
6260
7855
  try {
@@ -6270,7 +7865,7 @@
6270
7865
  // Task 9 review I4: `true` — this is the ONE call site backlog #6's
6271
7866
  // raw-editor rescue is scoped to. See restoreAfterStructuralOp()'s
6272
7867
  // own `allowRawEditRescue` comment.
6273
- restoreAfterStructuralOp(focusLine, anchor.line, true);
7868
+ restoreAfterStructuralOp(focusLine, anchor.line, true, !(opts && opts.caretToEnd === false));
6274
7869
  }
6275
7870
  }
6276
7871
 
@@ -6413,7 +8008,8 @@
6413
8008
  // block the user never pointed at.
6414
8009
  function reresolveBlockElAfterSelfCommit(identity) {
6415
8010
  if (!identity) return null;
6416
- const at = blocks.find((b) => b.startLine === identity.startLine);
8011
+ const startLine = identityStartLineNow(identity);
8012
+ const at = blocks.find((b) => b.startLine === startLine);
6417
8013
  if (!at || at.type !== identity.type) return null;
6418
8014
  return document.querySelector('.ed-block[data-block-id="' + at.id + '"]');
6419
8015
  }
@@ -7774,8 +9370,16 @@
7774
9370
  function burstBaselineHtml(editEl) {
7775
9371
  const html = editEl.innerHTML;
7776
9372
  if (html.indexOf('ed-te-hl') === -1 && html.indexOf('ed-te-row-dragging') === -1 &&
7777
- html.indexOf('ed-cell-range') === -1) return html;
9373
+ html.indexOf('ed-cell-range') === -1 && html.indexOf('search-hit') === -1) return html;
7778
9374
  const clone = editEl.cloneNode(true);
9375
+ // v3.14.0 T9: the reader's search marks are transient chrome; unwrap them
9376
+ // and rejoin the text they cut.
9377
+ Array.prototype.slice.call(clone.querySelectorAll('mark.search-hit')).forEach((mk) => {
9378
+ const par = mk.parentNode;
9379
+ while (mk.firstChild) par.insertBefore(mk.firstChild, mk);
9380
+ par.removeChild(mk);
9381
+ par.normalize();
9382
+ });
7779
9383
  const marked = clone.querySelectorAll('.ed-te-hl, .ed-te-row-dragging, .ed-cell-range');
7780
9384
  Array.prototype.forEach.call(marked, (el) => {
7781
9385
  el.classList.remove('ed-te-hl');
@@ -7795,6 +9399,32 @@
7795
9399
  dropDiscardedRawEdit();
7796
9400
  const blockEl = editEl.closest('.ed-block');
7797
9401
  if (!blockEl) return;
9402
+ // v3.14.0 T1: a transient paragraph has no block record and no line. Its
9403
+ // burst carries `transient: true` and an empty baseline; resolveBurst()
9404
+ // commits it through commitLinesAtTransient() instead of commitEdit().
9405
+ if (blockEl.hasAttribute('data-ed-transient')) {
9406
+ const tHistory = historyLib.createBurstHistory(() => burstBaselineHtml(editEl), { debounceMs: 400 });
9407
+ tHistory.start();
9408
+ currentBurst = {
9409
+ blockEl, editEl, blockId: null, blockType: 'paragraph', transient: true,
9410
+ depth: null, original: '', history: tHistory,
9411
+ };
9412
+ watchBurstForDirtyDot(editEl);
9413
+ selToolbarEditEl = editEl;
9414
+ if (!selToolbarListener) {
9415
+ selToolbarListener = onSelectionChangeForToolbar;
9416
+ document.addEventListener('selectionchange', onSelectionChangeForToolbar);
9417
+ }
9418
+ return;
9419
+ }
9420
+ // R2 (F1): any OTHER id-less block (the provisional li an Enter builds
9421
+ // before its render lands) opens no burst. `Number(null)` is 0, so without
9422
+ // this the burst bound itself to block 0 and its commit could overwrite
9423
+ // the document's first line.
9424
+ // T6: also after that item's render FAILED. It is then part of a pending
9425
+ // run (setPendingListRun()), and the retry re-commits the whole live run,
9426
+ // this item's text included; a burst of its own would commit it alone.
9427
+ if (blockEl.getAttribute('data-block-id') === null) return;
7798
9428
  const blockId = Number(blockEl.getAttribute('data-block-id'));
7799
9429
  const block = blocks.find((b) => b.id === blockId);
7800
9430
  if (!block) return;
@@ -7854,6 +9484,9 @@
7854
9484
  // ordinal into.
7855
9485
  cancelBlockDrag();
7856
9486
  if (!burst) return true;
9487
+ // v3.14.0 T1: a transient's burst never reaches the commitEdit() path
9488
+ // below — it has no block to commit over. See resolveTransientBurst().
9489
+ if (burst.transient) return await resolveTransientBurst(burst);
7857
9490
  // §10-gap fix (review): this burst's block's "pristine" window (see
7858
9491
  // `pristineInsert`'s own comment) closes right here, the moment its
7859
9492
  // OWN first burst resolves — one way (a real edit, below) or the
@@ -8100,9 +9733,9 @@
8100
9733
  // the paragraph into that block. A heading's text sits after its own
8101
9734
  // `# ` and needs none; a table's rows START with `|`, so its markdown
8102
9735
  // must never reach the escape — hence the blockType test, not just depth.
8103
- const newText = burst.depth === null
8104
- ? (burst.blockType === 'paragraph' ? inlineMd.escapeBlockStarts(result.md) : result.md) :
8105
- (result.md === '' ? '#'.repeat(burst.depth) : '#'.repeat(burst.depth) + ' ' + result.md);
9736
+ // v3.14.0 T2 (R4): the rule itself lives in commitTextOf(), which the
9737
+ // split and the merge serialize through too.
9738
+ const newText = commitTextOf(result.md, burst.blockType, burst.depth);
8106
9739
  // S1 removed the pre-per-li "a whole-list burst that serialized to ''"
8107
9740
  // branch that lived here alongside the dead 'list' serializer arm above.
8108
9741
  // The per-li equivalent — a run whose every item was removed — is handled
@@ -8657,11 +10290,30 @@
8657
10290
  if (raw === null) return null;
8658
10291
  const b = blocks.find((x) => x.id === Number(raw));
8659
10292
  if (!b) return null;
8660
- return { startLine: b.startLine, type: b.type, source: blockSourceOf(b) };
10293
+ return { startLine: b.startLine, type: b.type, source: blockSourceOf(b),
10294
+ transientSeq: transientCommits.seq };
10295
+ }
10296
+ // v3.14.0 T1 fix round 1 (I2): where `identity`'s block starts NOW. A
10297
+ // transient's commit is a pure insertion — no existing line is rewritten —
10298
+ // so a block it moved is exactly `delta` lines further down, and the
10299
+ // fingerprint below still holds. Every other commit keeps the old rule
10300
+ // (its start line must not have moved, or the gesture is dropped). Measured
10301
+ // before: type into a transient, then + / ⠿ on any block below it — the
10302
+ // gesture's own switchAwayFrom() committed the transient, the target's
10303
+ // start line moved by ≥ 2, and the gesture was dropped with
10304
+ // DROPPED_GESTURE_MESSAGE.
10305
+ function identityStartLineNow(identity) {
10306
+ let line = identity.startLine;
10307
+ const since = identity.transientSeq === undefined ? transientCommits.seq : identity.transientSeq;
10308
+ transientCommits.log.forEach((c) => {
10309
+ if (c.seq > since && c.at <= line) line += c.delta;
10310
+ });
10311
+ return line;
8661
10312
  }
8662
10313
  function reresolveBlockEl(identity) {
8663
10314
  if (!identity) return null;
8664
- const at = blocks.find((b) => b.startLine === identity.startLine);
10315
+ const startLine = identityStartLineNow(identity);
10316
+ const at = blocks.find((b) => b.startLine === startLine);
8665
10317
  if (!at || at.type !== identity.type || blockSourceOf(at) !== identity.source) return null;
8666
10318
  return document.querySelector('.ed-block[data-block-id="' + at.id + '"]');
8667
10319
  }
@@ -8863,12 +10515,59 @@
8863
10515
  // successful render (any later commit, undo, or redo) re-derives the DOM
8864
10516
  // from `lines`. Same convention as insertBlockBelow() /
8865
10517
  // deleteBlockViaGutter() / resolveBurst()'s own failure paths.
10518
+ // v3.14.0 T6 (D7, R14 widened): "until the next successful render"
10519
+ // used to mean the next render threw the screen's run away — measured:
10520
+ // Enter after `bravo`, the render fails, type `new`, leave: the file
10521
+ // had no `- new`. The run is now pending (retryPendingStructure()):
10522
+ // the next resolution, focus into any surface, or a structural key
10523
+ // re-commits the whole LIVE run over the same range first, provisional
10524
+ // item and anything typed into it included, as one op.
10525
+ setPendingListRun(span, range, opts && opts.carryOver);
8866
10526
  return false;
8867
10527
  }
8868
10528
  if (focusStartLine != null) focusBlockAtLine(focusStartLine, caretToEnd);
8869
10529
  return true;
8870
10530
  }
8871
10531
 
10532
+ // T6: the pendingStructure slot for a list run whose commit did not land.
10533
+ // `span` is the post-mutation run the commit serialized; its provisional
10534
+ // item (no id) is the member the live run is re-derived from, else any
10535
+ // member still on screen. The bystander replay is the commit's own set —
10536
+ // `carry0` names exactly the items the gesture did not touch — re-read
10537
+ // from `lines` and minus an item typed into since (bystanderCarryOver()'s
10538
+ // dirty-burst rule), so a later edit in the run is not replayed away.
10539
+ function setPendingListRun(span, range, carry0) {
10540
+ const members = (span || []).slice();
10541
+ const liveMember = () => members.find((el) => el.isConnected && el.getAttribute('data-block-id') === null) ||
10542
+ members.find((el) => el.isConnected) || null;
10543
+ const liveRun = () => { const m = liveMember(); return m ? listRunOf(m) : []; };
10544
+ const surfaces = members.map(liTextEl).filter(Boolean);
10545
+ setPendingStructure(range.startLine, range.endLine, surfaces, (ps) => {
10546
+ const run = liveRun();
10547
+ if (!run.length) return { text: null, lineMap: null };
10548
+ let carry = null;
10549
+ if (carry0) {
10550
+ const fresh = bystanderCarryOver(run, null) || {};
10551
+ Object.keys(fresh).forEach((k) => {
10552
+ // Fix round 1 (I2): an item typed into after the failure, whose
10553
+ // burst an earlier (failed) retry ended, is read from the DOM.
10554
+ // Measured before: failed Enter, type in `charlie`, a second retry
10555
+ // fails, and the next commit wrote `- charlie` with no message.
10556
+ if (!(k in carry0) || (ps && ps.typedIds && ps.typedIds.has(k))) return;
10557
+ carry = carry || {};
10558
+ carry[k] = fresh[k];
10559
+ });
10560
+ }
10561
+ const res = serializeListEls(run, { carryOver: carry });
10562
+ return res.md === ''
10563
+ ? { text: null, lineMap: null }
10564
+ : { text: res.md, lineMap: flightLineMapOf(res.lineMeta, range.startLine) };
10565
+ }, (el) => {
10566
+ const b = el && el.closest ? el.closest('.ed-block') : null;
10567
+ return !!b && liveRun().indexOf(b) !== -1;
10568
+ });
10569
+ }
10570
+
8872
10571
  // The line `targetBlock`'s own marker will occupy once the run span it belongs
8873
10572
  // to is committed by commitListStructure() above.
8874
10573
  //
@@ -9052,6 +10751,34 @@
9052
10751
  // this unconditionally qualifies as "abandoned" (no separate
9053
10752
  // unchanged-check needed here, unlike resolveBurst()'s branch, where a
9054
10753
  // real commit is also possible).
10754
+ // v3.14.0 T1: Escape in a transient abandons it — it wrote nothing, so
10755
+ // there is nothing to stash for Ctrl+Z. The only block of an empty
10756
+ // document stays (D3), emptied.
10757
+ if (burst.transient) {
10758
+ burst.history.dispose();
10759
+ currentBurst = null;
10760
+ resetSelToolbarState();
10761
+ editEl.innerHTML = '';
10762
+ syncEmptyMark(editEl);
10763
+ if (transientIsOnlyBlock(burst.blockEl)) return; // still focused, still empty
10764
+ // T8 (D8): the caret does not fall to the body — it goes back where
10765
+ // the gesture that made the transient was pressed, else to the end of
10766
+ // the block above, so the next key is typed somewhere.
10767
+ const t = transient;
10768
+ const prev = previousBlockEl(burst.blockEl);
10769
+ removeTransient();
10770
+ if (t && t.anchorCaret && t.anchorCaret.surface && t.anchorCaret.surface.isConnected) {
10771
+ t.anchorCaret.surface.focus();
10772
+ restoreSelection(t.anchorCaret.surface, t.anchorCaret.offset, t.anchorCaret.offset);
10773
+ return;
10774
+ }
10775
+ const surface = prev ? blockContentEl(prev) : null;
10776
+ if (surface && surface.classList.contains('ed-wys-armed')) {
10777
+ surface.focus();
10778
+ placeCaretAtEnd(surface);
10779
+ }
10780
+ return;
10781
+ }
9055
10782
  const wasPristineForThisBlock = !!(pristineInsert && pristineInsert.blockId === burst.blockId);
9056
10783
  if (wasPristineForThisBlock) pristineInsert = null;
9057
10784
  // T21 item 1: a pristine insert keeps the old, unrecoverable shape, and
@@ -9092,7 +10819,13 @@
9092
10819
  };
9093
10820
  currentBurst = null;
9094
10821
  resetSelToolbarState();
9095
- editEl.blur();
10822
+ // v3.14.0 T8 (D8): the caret stays in the reverted surface, at its end,
10823
+ // instead of blur(): the key typed right after Escape went nowhere before
10824
+ // (measured: X, Escape, Y saved nothing of Y). The surface has no burst
10825
+ // now; the next input into it starts one (ensureBurst()). Ctrl+Z still
10826
+ // finds the stash above first (restoreDiscardedBurst()).
10827
+ syncEmptyMark(editEl);
10828
+ placeCaretAtEnd(editEl);
9096
10829
  }
9097
10830
 
9098
10831
  // The table twin of the stash above — a table burst's editEl is the whole
@@ -9147,6 +10880,11 @@
9147
10880
  : d.editEl;
9148
10881
  if (!entry) { d.history.dispose(); return false; }
9149
10882
  entry.focus();
10883
+ // T8: since Escape keeps the caret in the surface, focus() is usually a
10884
+ // no-op here and raises no focusin — open the burst directly.
10885
+ if (!currentBurst && document.activeElement === entry) {
10886
+ if (isTable) startTableBurst(entry); else startBurst(entry);
10887
+ }
9150
10888
  if (!currentBurst || currentBurst.editEl !== d.editEl) { d.history.dispose(); return false; }
9151
10889
  currentBurst.history.dispose();
9152
10890
  currentBurst.history = d.history;
@@ -9244,9 +10982,47 @@
9244
10982
  const state = burst.history.undo();
9245
10983
  if (state !== null) {
9246
10984
  editEl.innerHTML = state;
10985
+ syncEmptyMark(editEl);
9247
10986
  placeCaretAtEnd(editEl); // best-effort — see the file-level caret-quirk note
9248
10987
  return;
9249
10988
  }
10989
+ // v3.14.0 T1: an empty transient with nothing left to step back through
10990
+ // locally. It wrote nothing, so the undo the user asked for is taking it
10991
+ // away again — not the document op before it. The caret goes back where
10992
+ // the gesture that made it was pressed (anchorCaret), else to the end of
10993
+ // the block above. In an empty document it is the only block and stays.
10994
+ if (burst.transient && transientSurfaceEmpty(burst.blockEl) && !transientIsOnlyBlock(burst.blockEl)) {
10995
+ const t = transient;
10996
+ const prev = previousBlockEl(burst.blockEl);
10997
+ removeTransient();
10998
+ if (t && t.anchorCaret && t.anchorCaret.surface && t.anchorCaret.surface.isConnected) {
10999
+ t.anchorCaret.surface.focus();
11000
+ restoreSelection(t.anchorCaret.surface, t.anchorCaret.offset, t.anchorCaret.offset);
11001
+ return;
11002
+ }
11003
+ const surface = prev ? blockContentEl(prev) : null;
11004
+ if (surface && surface.classList.contains('ed-wys-armed')) {
11005
+ surface.focus();
11006
+ placeCaretAtEnd(surface);
11007
+ }
11008
+ return;
11009
+ }
11010
+ // v3.14.0 T2: Enter at a block's start left an empty transient ABOVE the
11011
+ // caret and the burst stayed where it was. With nothing left to step back
11012
+ // through here, taking that transient away is the Enter's undo step; the
11013
+ // caret stays put. Only in the block the Enter was pressed in (`stayEl`):
11014
+ // a transient the user has already left behind is on its way out (its
11015
+ // removal waits for the press that left it), and a Ctrl+Z in another
11016
+ // block means that block's undo. MEASURED without the `stayEl` test (ER
11017
+ // 'burst undo: … cascades to the previous commit'): Enter at the end,
11018
+ // a click straight into the paragraph above, typing, Ctrl+Z twice — the
11019
+ // second one took the still-present transient instead of the commit.
11020
+ if (!burst.transient && transient && transient.byEnter && transient.stayEl === burst.blockEl &&
11021
+ transient.el.isConnected && transientSurfaceEmpty(transient.el) &&
11022
+ !transientIsOnlyBlock(transient.el)) {
11023
+ removeTransient();
11024
+ return;
11025
+ }
9250
11026
  // §10-gap fix (review): if this burst is an untouched pristine insert,
9251
11027
  // switchAwayFrom() below will itself auto-remove the block (resolveBurst()'s
9252
11028
  // pristineInsert branch) — that auto-remove IS the undo the user just
@@ -9268,6 +11044,7 @@
9268
11044
  const state = burst.history.redo();
9269
11045
  if (state !== null) {
9270
11046
  editEl.innerHTML = state;
11047
+ syncEmptyMark(editEl);
9271
11048
  placeCaretAtEnd(editEl);
9272
11049
  return;
9273
11050
  }
@@ -9317,6 +11094,19 @@
9317
11094
  //
9318
11095
  // `reason` is metadata only — createBurstHistory().snap() does not read it
9319
11096
  // (see lib/editor/history.js).
11097
+ // v3.14.0 T8 (D8): a focused armed surface can be without a burst — after
11098
+ // Escape (the caret stays), after Ctrl+S on an untouched surface — and
11099
+ // input there was never committed (typing) or did nothing (Enter,
11100
+ // Backspace, Delete). The next input starts the burst first. Never during
11101
+ // a flight or a resolution (those own the surface), never in a table cell
11102
+ // (its own burst opens on focus).
11103
+ function ensureBurst(surface) {
11104
+ if (currentBurst || switching || flightActive()) return;
11105
+ if (!surface || !surface.isConnected || !surface.classList.contains('ed-wys-armed')) return;
11106
+ if (surface.closest('.ed-wys-cell')) return;
11107
+ startBurst(surface);
11108
+ }
11109
+
9320
11110
  function snapBurstIfActive(root, reason) {
9321
11111
  if (currentBurst && (currentBurst.editEl === root || currentBurst.editEl.contains(root))) {
9322
11112
  currentBurst.history.snap(reason);
@@ -9329,6 +11119,13 @@
9329
11119
  // wireBurstListeners() below), Shift+Enter inserts a <br> and snapshots
9330
11120
  // it, Escape reverts, Ctrl+Z/Y drive the burst-local history.
9331
11121
  function handleBurstKeydown(e, editEl) {
11122
+ // v3.14.0 T2 (R6): a structural key while a failed structure is still
11123
+ // pending re-commits that first — burst or no burst (the provisional half
11124
+ // of a failed split has none).
11125
+ if (pendingStructure && isStructuralKeyForRetry(e, editEl)) {
11126
+ retryStructureThenReplay(e, editEl);
11127
+ return;
11128
+ }
9332
11129
  if (!currentBurst || currentBurst.editEl !== editEl) {
9333
11130
  // v2.11.1 acceptance, escape class B. This bail is reachable with the
9334
11131
  // surface STILL FOCUSED and still `.ed-wys-armed`: resolveBurst() nulls
@@ -9381,6 +11178,11 @@
9381
11178
  if (currentBurst.blockType === 'li') {
9382
11179
  if (handleLiKeydown(e, editEl)) return;
9383
11180
  }
11181
+ // v3.14.0 T3: Backspace at a paragraph / heading / transient start, Delete
11182
+ // at its end — join, convert or select (blockEdgeDelete()); anywhere else
11183
+ // the browser's own deletion.
11184
+ if ((e.key === 'Backspace' || e.key === 'Delete') &&
11185
+ blockEdgeDelete(e, editEl, e.key === 'Backspace' ? -1 : 1)) return;
9384
11186
  // Task 6 — spec §3.5's other two rows. Tab is CONSUMED here: the
9385
11187
  // alternative is not "nothing happens", it is the browser's own focus
9386
11188
  // traversal walking the caret out of the document body, which is both a
@@ -9422,13 +11224,19 @@
9422
11224
  return;
9423
11225
  }
9424
11226
  if (e.key === 'Enter') {
9425
- e.preventDefault();
9426
11227
  if (e.shiftKey) {
11228
+ e.preventDefault();
9427
11229
  snapBurstIfActive(editEl, 'br-pre');
9428
11230
  insertBrAtCaret();
9429
11231
  snapBurstIfActive(editEl, 'br');
9430
- } else {
11232
+ } else if (e.ctrlKey || e.metaKey || e.altKey) {
11233
+ // A chord is not a paragraph break: it still commits by leaving.
11234
+ e.preventDefault();
9431
11235
  editEl.blur();
11236
+ } else {
11237
+ // v3.14.0 T2: split, or open a transient at an edge. It calls
11238
+ // preventDefault() itself — never for an IME's confirming Enter.
11239
+ paragraphEnter(e, editEl).catch(structuralKeyFailed);
9432
11240
  }
9433
11241
  return;
9434
11242
  }
@@ -9609,12 +11417,18 @@
9609
11417
  // here, so that handler's `if (currentBurst) return;` returns.
9610
11418
  function armProvisionalLi(newLi) {
9611
11419
  const textEl = liTextEl(newLi);
9612
- if (!textEl) return;
9613
- textEl.setAttribute('contenteditable', 'true');
9614
- textEl.classList.add('ed-wys-armed');
9615
- textEl.focus();
11420
+ if (textEl) armProvisionalSurface(textEl);
11421
+ }
11422
+ // v3.14.0 T2: the same two writes for any provisional surface — the li's
11423
+ // `.ed-li-text`, or the <p> a paragraph split builds — then focus and the
11424
+ // caret at its start, where the commit's own focusBlockAtLine() puts it
11425
+ // again once the render lands.
11426
+ function armProvisionalSurface(el) {
11427
+ el.setAttribute('contenteditable', 'true');
11428
+ el.classList.add('ed-wys-armed');
11429
+ el.focus();
9616
11430
  const range = document.createRange();
9617
- range.selectNodeContents(textEl);
11431
+ range.selectNodeContents(el);
9618
11432
  range.collapse(true);
9619
11433
  const sel = window.getSelection();
9620
11434
  sel.removeAllRanges();
@@ -10255,18 +12069,15 @@
10255
12069
 
10256
12070
  // Row 3, top-level press: spec §4 — "at top level the next press converts
10257
12071
  // the block to a paragraph". Markdown cannot persist an EMPTY paragraph, so
10258
- // this reuses the repo's existing §10 pristine-insert machinery instead of
10259
- // inventing a second empty-block representation: remove the li (commit #1,
10260
- // the run re-serialize), then insertBlockBelow() the paragraph skeleton
10261
- // anchored to whatever block now PRECEDES the removal point (commit #2). The
10262
- // user lands in a focused provisional paragraph that becomes real the moment
10263
- // anything is typed into it, and self-removes at zero net undo cost if
10264
- // abandoned (discardPristineInsert()).
10265
- //
10266
- // RULING F-J: this is therefore the ONE structural key press that is not a
10267
- // single undo op. Observed granularity (asserted in
10268
- // test/editor-client-runtime.test.js): Ctrl+Z #1 removes the provisional
10269
- // paragraph without popping the stack, Ctrl+Z #2 reverts the li removal.
12072
+ // the li is removed (ONE op, the run re-serialize) and a transient paragraph
12073
+ // takes its place, after whatever block now PRECEDES the removal point (the
12074
+ // end of its subtree when that is a li), or at the top of the document. The
12075
+ // transient writes nothing until something is typed into it.
12076
+ //
12077
+ // v3.14.0 T1 supersedes RULING F-J's two-step granularity (the ZWSP
12078
+ // paragraph was a second op): the key press is one undo op, and Ctrl+Z in
12079
+ // the still-empty transient takes the transient away without touching the
12080
+ // stack (burstUndo()).
10270
12081
  async function convertEmptyTopLevelLiToParagraph(runEls, li) {
10271
12082
  // Both captured BEFORE the mutation. The range, because removing the run's
10272
12083
  // last item leaves commitListStructure() nothing to derive it from. The
@@ -10309,20 +12120,136 @@
10309
12120
  const ok = await commitListStructure(survivors, null, false,
10310
12121
  { presetRange: range, carryOver: bystanderCarryOver(survivors) });
10311
12122
  if (!ok) return;
10312
- // Nothing precedes the removal point (the list opened the document):
10313
- // commitBlockInsertion() can only insert BELOW an existing block, so the
10314
- // paragraph step is skipped. The li removal still stands — no content is
10315
- // lost, the user just has to type where they want the paragraph.
10316
- if (!precedingBlock) return;
12123
+ // Nothing precedes the removal point (the list opened the document): the
12124
+ // transient opens the document, and its commit lands at the top
12125
+ // (commitInsertionAt() with no anchor).
12126
+ if (!precedingBlock) { createTransient(null, { focus: true }); return; }
10317
12127
  const anchorEl = blockElAtLine(precedingBlock.startLine);
10318
12128
  if (!anchorEl) return;
10319
- await insertBlockBelow(anchorEl, 'paragraph');
12129
+ createTransient(subtreeEndOf(anchorEl), { focus: true });
12130
+ }
12131
+
12132
+ function liBackspaceAtStart(e, editEl) {
12133
+ if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey || e.isComposing || e.keyCode === 229) return false;
12134
+ const place = caretPlace(editEl, true);
12135
+ if (place !== 'start' && place !== 'empty') return false;
12136
+ const li = closestLiBlock(editEl);
12137
+ if (!li) return false;
12138
+ if (!listRunOf(li).length) return false;
12139
+ e.preventDefault();
12140
+ liBackspaceStructure(li).catch(structuralKeyFailed);
12141
+ return true;
12142
+ }
12143
+ // R29 (fix round 1, I1): typing still in the item is committed as its own
12144
+ // op first (D4 wins over D14's single step), then the structure op runs on
12145
+ // the item as it now stands. Measured before: type X, Home, Backspace on a
12146
+ // nested item — one Ctrl+Z took back the X and the outdent together. Keys
12147
+ // typed while the typing op renders are held by the guard and replay after
12148
+ // the structure op (R7's shape).
12149
+ async function liBackspaceStructure(li) {
12150
+ const burst = currentBurst;
12151
+ if (burst && burst.blockType === 'li' && burst.blockEl === li &&
12152
+ burstBaselineHtml(burst.editEl) !== burst.original) {
12153
+ const rec = blockRecOf(li);
12154
+ if (!rec) return;
12155
+ if (!(await switchAwayFrom())) return;
12156
+ li = li.isConnected ? li : blockElAtLine(rec.startLine);
12157
+ if (!li) return;
12158
+ }
12159
+ const run = listRunOf(li);
12160
+ if (!run.length) return;
12161
+ if ((Number(li.getAttribute('data-indent')) || 0) > 0) {
12162
+ // Final review I1: the caret stays at the start of the item's text, as
12163
+ // in Notion, so a second Backspace converts it instead of deleting its
12164
+ // last character. Tab and Shift+Tab keep the caret at the end.
12165
+ liIndentStructure(li, run, true, false);
12166
+ return;
12167
+ }
12168
+ if (liOwnTextIsBlank(li)) {
12169
+ if (!listRunSupportsStructuralEdit(run, li)) { refuseStructuralListEdit(); return; }
12170
+ // RULING F-Q, as for Enter: an empty item that owns a sublist is not
12171
+ // removed from over its children.
12172
+ if (liBlockHasChildren(li)) return;
12173
+ await convertEmptyTopLevelLiToParagraph(run, li);
12174
+ return;
12175
+ }
12176
+ await convertBlockViaMenu(li, 'text', { caretToEnd: false });
12177
+ }
12178
+
12179
+ // Tab / Shift+Tab on a list item (and, v3.14.0 T3, Backspace at the start
12180
+ // of a nested one, which is Shift+Tab). Extracted unchanged from
12181
+ // handleLiKeydown(); `outdent` stands for the Shift key. `caretToEnd`
12182
+ // (default true, Tab's) is where the caret lands in the item afterwards;
12183
+ // Backspace passes false, the start of its text.
12184
+ function liIndentStructure(li, run, outdent, caretToEnd) {
12185
+ // `columnOnly`: an indent change rewrites nothing but leading columns, so
12186
+ // a hard-wrapped item is allowed to be the target here — see
12187
+ // listRunSupportsStructuralEdit()'s deviation note for the measurement.
12188
+ if (!listRunSupportsStructuralEdit(run, li, { columnOnly: true })) {
12189
+ refuseStructuralListEdit(); return true;
12190
+ }
12191
+ // Tab (spec §3.5): indentListItem() moves ONLY the caret item — its
12192
+ // children keep their indent and become its siblings. Shift+Tab:
12193
+ // outdentListItem() raises it one level, takes its own subtree with it,
12194
+ // and adopts its former following same-level siblings. Both return false
12195
+ // at their respective boundary (no previous sibling / already top level),
12196
+ // which is a complete no-op — nothing mutated, nothing committed, file
12197
+ // byte-identical.
12198
+ const oldIndent = Number(li.getAttribute('data-indent')) || 0;
12199
+ // INERT here: indentListItem()/outdentListItem() are attribute-only in
12200
+ // this flat model (setBlockIndent() on `li` and its subtree — see the
12201
+ // note on outdentListItem() further down this function) and never
12202
+ // detach the focused node, so the synchronous blur/focusout this span
12203
+ // exists to catch never fires, whether or not the flag is set.
12204
+ // Measured: primed a burst on `li`, ran this mutation, and no
12205
+ // focusout fired — `document.activeElement` stayed put and the node
12206
+ // never left the document. Kept for uniformity with the load-bearing
12207
+ // span in convertEmptyTopLevelLiToParagraph() above, not because this
12208
+ // one guards anything measured.
12209
+ const changed = mutateListRun(() => {
12210
+ if (!(outdent ? outdentListItem(li) : indentListItem(li))) return false;
12211
+ // v3.0.1: `run` above is a PRE-mutation snapshot, captured while `li`
12212
+ // still had its OLD indent. That is fine for outdentListItem() — the
12213
+ // clamp's model rereads every element's CURRENT data-indent off the
12214
+ // DOM, so it self-corrects regardless of which elements the snapshot
12215
+ // names — but indentListItem() can now move a rule-(d) list-start
12216
+ // item (a bullet directly below an ordered item, say) whose own
12217
+ // pre-mutation run is `[li]` alone: nothing shallower ever preceded
12218
+ // it, because runBlocksOf() stops at a list-start by design. With no
12219
+ // anchor IN the span, applyIndentClamp()'s rule 1 has nothing to
12220
+ // bound the indent this call just wrote against, and clamps it
12221
+ // straight back to 0 — silently undoing the write. A FRESH
12222
+ // listRunOf(li) call here — taken after the write above — walks back
12223
+ // from li's NEW indent and finds the real parent, exactly like the
12224
+ // post-mutation `run = listRunOf(li)` a few lines below (the COMMIT
12225
+ // span) already does; using the same fresh call for the clamp keeps
12226
+ // the two consistent. Provably identical to the old stale `run` for
12227
+ // every case where li was never a list-start (listRunOf() slices by
12228
+ // INDEX RANGE, and neither the range's start/end index nor the
12229
+ // elements between them shift when only li's own indent changes).
12230
+ applyIndentClamp(listRunOf(li), li, oldIndent);
12231
+ return true;
12232
+ });
12233
+ if (!changed) return true;
12234
+ run = listRunOf(li);
12235
+ // Column-only: nothing's CONTENT moved, so every block in the span —
12236
+ // the target included — is a bystander whose source bytes must come
12237
+ // back untouched. Built once and shared with runLineOfBlock() below,
12238
+ // which indexes into the lines this very map decides.
12239
+ const carry = bystanderCarryOver(run, null);
12240
+ commitListStructure(run, runLineOfBlock(run, li, carry), caretToEnd !== false, { carryOver: carry });
12241
+ return true;
10320
12242
  }
10321
12243
 
10322
12244
  // Returns true when the key was CONSUMED (Enter/Tab, incl. their no-op
10323
12245
  // outcomes); false lets handleBurstKeydown()'s shared Escape / Ctrl+Z /
10324
12246
  // Ctrl+Y branches run unchanged.
10325
12247
  function handleLiKeydown(e, editEl) {
12248
+ // v3.14.0 T3 (D14): Backspace at an item's start. Nested: Shift+Tab's
12249
+ // outdent. Top level with text: ⠿ 轉換成 文字. Top level and empty: the
12250
+ // item goes and a transient paragraph takes its place (Enter's row-3
12251
+ // path). Anywhere else the browser's own deletion.
12252
+ if (e.key === 'Backspace') return liBackspaceAtStart(e, editEl);
10326
12253
  if (e.key !== 'Enter' && e.key !== 'Tab') return false;
10327
12254
  e.preventDefault();
10328
12255
  // T21 item 2, the li half — see handleBurstKeydown()'s Tab branch. Ahead
@@ -10351,65 +12278,7 @@
10351
12278
  let run = listRunOf(li);
10352
12279
  if (!run.length) return true;
10353
12280
 
10354
- if (e.key === 'Tab') {
10355
- // `columnOnly`: an indent change rewrites nothing but leading columns, so
10356
- // a hard-wrapped item is allowed to be the target here — see
10357
- // listRunSupportsStructuralEdit()'s deviation note for the measurement.
10358
- if (!listRunSupportsStructuralEdit(run, li, { columnOnly: true })) {
10359
- refuseStructuralListEdit(); return true;
10360
- }
10361
- // Tab (spec §3.5): indentListItem() moves ONLY the caret item — its
10362
- // children keep their indent and become its siblings. Shift+Tab:
10363
- // outdentListItem() raises it one level, takes its own subtree with it,
10364
- // and adopts its former following same-level siblings. Both return false
10365
- // at their respective boundary (no previous sibling / already top level),
10366
- // which is a complete no-op — nothing mutated, nothing committed, file
10367
- // byte-identical.
10368
- const oldIndent = Number(li.getAttribute('data-indent')) || 0;
10369
- // INERT here: indentListItem()/outdentListItem() are attribute-only in
10370
- // this flat model (setBlockIndent() on `li` and its subtree — see the
10371
- // note on outdentListItem() further down this function) and never
10372
- // detach the focused node, so the synchronous blur/focusout this span
10373
- // exists to catch never fires, whether or not the flag is set.
10374
- // Measured: primed a burst on `li`, ran this mutation, and no
10375
- // focusout fired — `document.activeElement` stayed put and the node
10376
- // never left the document. Kept for uniformity with the load-bearing
10377
- // span in convertEmptyTopLevelLiToParagraph() above, not because this
10378
- // one guards anything measured.
10379
- const changed = mutateListRun(() => {
10380
- if (!(e.shiftKey ? outdentListItem(li) : indentListItem(li))) return false;
10381
- // v3.0.1: `run` above is a PRE-mutation snapshot, captured while `li`
10382
- // still had its OLD indent. That is fine for outdentListItem() — the
10383
- // clamp's model rereads every element's CURRENT data-indent off the
10384
- // DOM, so it self-corrects regardless of which elements the snapshot
10385
- // names — but indentListItem() can now move a rule-(d) list-start
10386
- // item (a bullet directly below an ordered item, say) whose own
10387
- // pre-mutation run is `[li]` alone: nothing shallower ever preceded
10388
- // it, because runBlocksOf() stops at a list-start by design. With no
10389
- // anchor IN the span, applyIndentClamp()'s rule 1 has nothing to
10390
- // bound the indent this call just wrote against, and clamps it
10391
- // straight back to 0 — silently undoing the write. A FRESH
10392
- // listRunOf(li) call here — taken after the write above — walks back
10393
- // from li's NEW indent and finds the real parent, exactly like the
10394
- // post-mutation `run = listRunOf(li)` a few lines below (the COMMIT
10395
- // span) already does; using the same fresh call for the clamp keeps
10396
- // the two consistent. Provably identical to the old stale `run` for
10397
- // every case where li was never a list-start (listRunOf() slices by
10398
- // INDEX RANGE, and neither the range's start/end index nor the
10399
- // elements between them shift when only li's own indent changes).
10400
- applyIndentClamp(listRunOf(li), li, oldIndent);
10401
- return true;
10402
- });
10403
- if (!changed) return true;
10404
- run = listRunOf(li);
10405
- // Column-only: nothing's CONTENT moved, so every block in the span —
10406
- // the target included — is a bystander whose source bytes must come
10407
- // back untouched. Built once and shared with runLineOfBlock() below,
10408
- // which indexes into the lines this very map decides.
10409
- const carry = bystanderCarryOver(run, null);
10410
- commitListStructure(run, runLineOfBlock(run, li, carry), true, { carryOver: carry });
10411
- return true;
10412
- }
12281
+ if (e.key === 'Tab') return liIndentStructure(li, run, e.shiftKey);
10413
12282
 
10414
12283
  // Enter.
10415
12284
  if (selectionSpansMultipleListItems()) {
@@ -11442,8 +13311,27 @@
11442
13311
  while (n && !n.classList.contains('ed-block')) n = step(n);
11443
13312
  return n;
11444
13313
  };
13314
+ // v3.14.0 T5: leaving a transient. Empty: it wrote nothing, so it is
13315
+ // removed and the walk lands on the real neighbour — no render. With
13316
+ // text: it is committed first (commitLinesAtTransient() records
13317
+ // transientLandedLine) and the walk goes on from the paragraph it became.
13318
+ if (isTransient(fromBlockEl)) {
13319
+ if (transientSurfaceEmpty(fromBlockEl)) {
13320
+ const target = nextBlock(fromBlockEl);
13321
+ if (!target) return;
13322
+ if (!transientIsOnlyBlock(fromBlockEl)) removeTransient();
13323
+ if (x !== null) arrowGoalX = x;
13324
+ arrowLandOn(target, delta, x !== null ? x : arrowGoalX);
13325
+ return;
13326
+ }
13327
+ transientLandedLine = null;
13328
+ if (!(await switchAwayFrom()) || transientLandedLine === null) return;
13329
+ fromBlockEl = blockElAtLine(transientLandedLine);
13330
+ if (!fromBlockEl) return;
13331
+ }
11445
13332
  let dest = nextBlock(fromBlockEl);
11446
13333
  if (!dest) return;
13334
+ const destWasTransient = isTransient(dest);
11447
13335
  const srcStartLine = (blockRecOf(fromBlockEl) || {}).startLine;
11448
13336
  const destIdentity = captureBlockIdentity(dest);
11449
13337
  const ok = await switchAwayFrom();
@@ -11453,7 +13341,9 @@
11453
13341
  const liveSrc = srcRec
11454
13342
  ? document.querySelector('.ed-block[data-block-id="' + srcRec.id + '"]') : null;
11455
13343
  dest = (liveSrc && nextBlock(liveSrc)) || reresolveBlockEl(destIdentity);
11456
- if (!dest) { showError(DROPPED_GESTURE_MESSAGE); return; }
13344
+ // A transient destination is gone with the render that committed the
13345
+ // source; there is nothing to re-resolve and nothing was dropped.
13346
+ if (!dest) { if (!destWasTransient) showError(DROPPED_GESTURE_MESSAGE); return; }
11457
13347
  }
11458
13348
  if (x !== null) arrowGoalX = x;
11459
13349
  arrowLandOn(dest, delta, x !== null ? x : arrowGoalX);
@@ -16564,6 +18454,9 @@
16564
18454
  // expression setDirty() paints the title dot from — not a second copy
16565
18455
  // of it.
16566
18456
  dirty: documentIsDirty(),
18457
+ // v3.14.0 T4 (D15): 'empty' | 'text' on a transient paragraph, else null.
18458
+ transient: blockEl && isTransient(blockEl)
18459
+ ? (transientSurfaceEmpty(blockEl) ? 'empty' : 'text') : null,
16567
18460
  };
16568
18461
  }
16569
18462
 
@@ -16944,6 +18837,12 @@
16944
18837
  }
16945
18838
 
16946
18839
  async function runToolbarAction(id, btnEl, viaKeyboard) {
18840
+ // R32 audit: a toolbar action can write `lines` without a resolution
18841
+ // first (indentCaretLi() commits a list structure directly), so a pending
18842
+ // structure is re-committed before any of them runs; if that fails the
18843
+ // press is dropped (the error card stays).
18844
+ if (pendingStructure && !(await retryPendingStructure())) return;
18845
+ if (await toolbarOnTransient(id, liveToolbarBlock())) return;
16947
18846
  const blockEl = liveToolbarBlock();
16948
18847
  switch (id) {
16949
18848
  // v3.4.0 §3: saveViaToolbar() — not save() directly. See its own
@@ -16980,8 +18879,8 @@
16980
18879
  case 'indent': return indentCaretLi(1);
16981
18880
  case 'table': return blockEl ? insertBlockBelow(blockEl, 'table') : undefined;
16982
18881
  case 'line': return blockEl ? insertBlockBelow(blockEl, 'line') : undefined;
16983
- case 'insert-after': return blockEl ? insertBlockBelow(blockEl, 'paragraph') : undefined;
16984
- case 'insert-before': return blockEl ? insertBlockAbove(blockEl) : undefined;
18882
+ case 'insert-after': return blockEl ? insertTransientBelow(blockEl) : undefined;
18883
+ case 'insert-before': return blockEl ? insertTransientAbove(blockEl) : undefined;
16985
18884
  // backlog #6: imageViaToolbar() — not pickAndInsertImage() directly.
16986
18885
  // See its own comment (shared with undoViaToolbar()/redoViaToolbar())
16987
18886
  // for the caret rescue.
@@ -17095,6 +18994,7 @@
17095
18994
  // gate (convert-md.js's 'h1'..'h6' targets already carry the level), and a
17096
18995
  // heading gets a delta computed against its own current depth.
17097
18996
  async function applyHeadingLevel(n) {
18997
+ if (await toolbarOnTransient('h' + n, liveToolbarBlock())) return;
17098
18998
  const blockEl = liveToolbarBlock();
17099
18999
  if (!blockEl) return;
17100
19000
  if (blockEl.getAttribute('data-block-type') !== 'heading') {
@@ -17109,79 +19009,12 @@
17109
19009
  }
17110
19010
 
17111
19011
  // ── insert above ───────────────────────────────────────────────────────
17112
- // commitBlockInsertion() only ever inserts AFTER a block's range, so
17113
- // "insert above" is expressed the way this file already expresses it (see
17114
- // convertEmptyTopLevelLiToParagraph()'s own preceding-block lookup):
17115
- // anchor on the block before this one. That keeps one insertion path and
17116
- // one set of gates rather than teaching the pure core a direction.
17117
- async function insertBlockAbove(blockEl) {
17118
- const rec = blockRecOf(blockEl);
17119
- if (!rec) return;
17120
- const preceding = blocks
17121
- .filter((b) => b.id !== rec.id && b.endLine < rec.startLine && b.endLine >= b.startLine)
17122
- .pop();
17123
- if (preceding) {
17124
- const anchorEl = blockElAtLine(preceding.startLine);
17125
- if (anchorEl) { await insertBlockBelow(anchorEl, 'paragraph'); return; }
17126
- }
17127
- // Nothing above to anchor on — see insertParagraphAtTop(). Refusing here
17128
- // instead would leave the document's FIRST block permanently impossible
17129
- // to precede: the ⠿ and + menus both insert below, so this button is the
17130
- // only affordance that reaches that position at all.
17131
- await insertParagraphAtTop(blockEl);
17132
- }
17133
-
17134
- // The document's first block has no predecessor to insert BELOW, and
17135
- // commitBlockInsertion() only ever inserts after a block's range. Rather
17136
- // than teach the pure core a direction (a change under byte-stability and
17137
- // roundtrip fixtures), the insert is expressed as a REPLACE of that
17138
- // block's own first source line by [skeleton, '', that same line] — the
17139
- // same commitRangeEdit()/replaceLines() pipeline as every other edit,
17140
- // therefore one undo op, and no new primitive.
17141
- //
17142
- // The switchAwayFrom() + re-resolve preamble is insertBlockBelow()'s,
17143
- // for its reasons: the resolution can commit a dirty burst, whose
17144
- // rerenderAll() detaches the very node this was called with.
17145
- async function insertParagraphAtTop(blockEl) {
17146
- const identity = captureBlockIdentity(blockEl);
17147
- const selfSession = ownsOpenSession(blockEl);
17148
- const ok = await switchAwayFrom();
17149
- if (!ok) return;
17150
- let liveBlockEl = blockEl;
17151
- if (!document.body.contains(blockEl)) {
17152
- liveBlockEl = reresolveBlockEl(identity) ||
17153
- (selfSession ? reresolveBlockElAfterSelfCommit(identity) : null);
17154
- if (!liveBlockEl) { showError(DROPPED_GESTURE_MESSAGE, null, null); return; }
17155
- }
17156
- const rec = blockRecOf(liveBlockEl);
17157
- // A block that owns no source line has an inverted range, and there is
17158
- // no line to splice in front of.
17159
- if (!rec || rec.endLine < rec.startLine) {
17160
- refuseStructuralListEdit(noSourceLineInsertMessageFor(liveBlockEl));
17161
- return;
17162
- }
17163
- const at = rec.startLine;
17164
- const newText = BLOCK_SKELETONS.paragraph.concat(['', lines[at - 1]]).join('\n');
17165
- const result = commitRangeEdit({ lines, blocks, stack }, at, at, newText);
17166
- if (result.op === null) return;
17167
- const prevLines = lines;
17168
- lines = result.lines;
17169
- const editRange = result.op
17170
- ? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
17171
- endLine: Math.max(result.op.startLine - 1, result.op.endLine),
17172
- delta: result.op.after.length - result.op.before.length }
17173
- : undefined;
17174
- const okRender = await safeRerenderAll(editRange);
17175
- if (!okRender) {
17176
- lines = rollbackFailedRender({ lines, stack }, result, prevLines);
17177
- return;
17178
- }
17179
- // The new paragraph starts exactly where the old first line did — same
17180
- // pristine + focus contract insertBlockBelow() gives a skeleton insert.
17181
- const target = blocks.find((b) => b.startLine === at);
17182
- if (target) pristineInsert = { blockId: target.id };
17183
- await focusInsertedBlock(at, 'paragraph');
17184
- }
19012
+ // v3.14.0 T1: 在上方插入 opens a transient above the block — see
19013
+ // insertTransientAbove(). It used to write a ZWSP paragraph, through
19014
+ // insertBlockBelow() on the preceding block or, for the document's first
19015
+ // block, a splice over that block's own first line; a transient needs
19016
+ // neither, because it writes nothing until it is committed, and its commit
19017
+ // (commitInsertionAt()) already covers the top of the document.
17185
19018
 
17186
19019
  // ── 大綱 ───────────────────────────────────────────────────────────────
17187
19020
  // TWO attributes, chosen by viewport, and that is not an accident.
@@ -17390,6 +19223,12 @@
17390
19223
  if (i > 0) newLines.push('');
17391
19224
  newLines.push('![](' + markdownImageDest(p) + ')');
17392
19225
  });
19226
+ // v3.14.0 T4: a transient has no source line to insert below; the images
19227
+ // replace it (after its own text, if it has any), as one op.
19228
+ if (isTransient(target)) {
19229
+ await commitPastedLinesAtTransient(target, newLines);
19230
+ return;
19231
+ }
17393
19232
  await insertBlockBelow(target, 'paragraph', { lines: newLines });
17394
19233
  }
17395
19234
 
@@ -17623,7 +19462,14 @@
17623
19462
  // serializeBlocks()'s existing gates, instead of arriving as a pile of
17624
19463
  // foreign nodes the serializer would have to make sense of afterwards.
17625
19464
  const pastedLines = md.split('\n');
17626
- insertBlockBelow(editEl.closest('.ed-block'), 'paragraph',
19465
+ // v3.14.0 T5: in a transient the pasted blocks replace it (after its
19466
+ // own text, if any) as one op.
19467
+ const hostBlock = editEl.closest('.ed-block');
19468
+ if (hostBlock && isTransient(hostBlock)) {
19469
+ commitPastedLinesAtTransient(hostBlock, pastedLines);
19470
+ return;
19471
+ }
19472
+ insertBlockBelow(hostBlock, 'paragraph',
17627
19473
  { lines: pastedLines, tight: pasteIsAllListLines(pastedLines) });
17628
19474
  return;
17629
19475
  }
@@ -18071,6 +19917,14 @@
18071
19917
  dropHeldTyping();
18072
19918
  return;
18073
19919
  }
19920
+ // v3.14.0 T7 (D9): Ctrl/Cmd+B/I/U is held too and replays as the same
19921
+ // native command — let through, it formatted DOM the render replaced.
19922
+ const formatCommand = flightSurface ? formatCommandForKey(e) : null;
19923
+ if (formatCommand) {
19924
+ e.preventDefault();
19925
+ holdFlightEntry(flightSurface, { kind: 'format', command: formatCommand });
19926
+ return;
19927
+ }
18074
19928
  if (flightSurface && flightHoldsKey(e)) {
18075
19929
  e.preventDefault();
18076
19930
  holdFlightEntry(flightSurface, { kind: 'key', key: e.key, shiftKey: e.shiftKey,
@@ -18119,6 +19973,8 @@
18119
19973
  // it either.
18120
19974
  const wysArmedEl = e.target && e.target.closest && e.target.closest('.ed-wys-armed');
18121
19975
  if (wysArmedEl && burstOwnsKey) {
19976
+ // T8: Enter / Backspace / Delete need the burst their handlers act in.
19977
+ if (!currentBurst && (e.key === 'Enter' || e.key === 'Backspace' || e.key === 'Delete')) ensureBurst(wysArmedEl);
18122
19978
  handleBurstKeydown(e, wysArmedEl);
18123
19979
  return;
18124
19980
  }
@@ -18318,6 +20174,15 @@
18318
20174
  // input/paste handling (class 'ed-wys-cell') is a separate branch at the
18319
20175
  // top of each listener below — see handleTableCellFocusIn() above.
18320
20176
  document.addEventListener('focusin', async (e) => {
20177
+ // v3.14.0 T2 (D7): focus going into any surface while a failed structure
20178
+ // is pending re-commits it first. Opening a burst over its range would
20179
+ // commit the left half over the whole block, and a commit anywhere else
20180
+ // renders and drops the screen half. The wait below then re-resolves this
20181
+ // surface across that render like any other resolution's.
20182
+ if (pendingStructure && !switching &&
20183
+ e.target && e.target.closest && e.target.closest('.ed-wys-armed, .ed-wys-cell')) {
20184
+ switchAwayFrom();
20185
+ }
18321
20186
  const cellEl = e.target && e.target.closest && e.target.closest('.ed-wys-cell');
18322
20187
  if (cellEl) { await handleTableCellFocusIn(cellEl); scheduleFlightFlush(); return; }
18323
20188
  const editEl = e.target && e.target.closest && e.target.closest('.ed-wys-armed');
@@ -18329,7 +20194,22 @@
18329
20194
  // be resolving from the focusout that just preceded this focusin — see
18330
20195
  // switchAwayFrom()'s single-flight `switching`. Await it before
18331
20196
  // starting a new burst so the two never race a concurrent commit.
18332
- if (switching) await switching;
20197
+ // v3.14.0 T1 fix round 1 (I1): the wait is tracked as a flight tag, so a
20198
+ // render that lands meanwhile re-points it at the block that now holds
20199
+ // the same source. Measured before: a press below a transient whose
20200
+ // commit inserted lines re-resolved by the OLD id and put the caret, and
20201
+ // the keys typed after the press, into the transient's new paragraph.
20202
+ // v3.14.0 T2: the provisional half of a pending split is tagged too — it
20203
+ // has no id, and its tag re-points through the retry's `provisional` line.
20204
+ const pendingHalf = !!(pendingStructure && pendingStructure.surfaces.indexOf(editEl) !== -1);
20205
+ const waitTag = switching && (blockId !== null || pendingHalf)
20206
+ ? { surface: editEl, blockId, cellIndex: null, anchor: null, focus: null } : null;
20207
+ if (waitTag) focusinWaitTags.push(waitTag);
20208
+ try {
20209
+ if (switching) await switching;
20210
+ } finally {
20211
+ if (waitTag) focusinWaitTags.splice(focusinWaitTags.indexOf(waitTag), 1);
20212
+ }
18333
20213
  // v3.13.1 T1 fix round 2: focus may have moved on while this waited — an
18334
20214
  // arrow walk lands on the NEXT block from inside that same resolution, and
18335
20215
  // its own focusin is queued behind this one. Starting a burst here would
@@ -18348,7 +20228,12 @@
18348
20228
  // target. Re-resolve the equivalent LIVE node by block id and move
18349
20229
  // focus there for real — that re-enters this same handler
18350
20230
  // synchronously (switching is null by now), which starts the burst.
18351
- const liveBlockEl = blockId != null ? document.querySelector('.ed-block[data-block-id="' + blockId + '"]') : null;
20231
+ // A tag no landed render re-pointed (a render outside safeRerenderAll())
20232
+ // still names the detached surface: the id lookup is all there is then.
20233
+ const remapped = waitTag && waitTag.surface !== editEl;
20234
+ const liveBlockEl = remapped
20235
+ ? (waitTag.surface && waitTag.surface.isConnected ? waitTag.surface.closest('.ed-block') : null)
20236
+ : (blockId != null ? document.querySelector('.ed-block[data-block-id="' + blockId + '"]') : null);
18352
20237
  liveEditEl = liveBlockEl ? blockContentEl(liveBlockEl) : null;
18353
20238
  if (!liveEditEl || !liveEditEl.classList.contains('ed-wys-armed')) return;
18354
20239
  liveEditEl.focus();
@@ -18578,6 +20463,37 @@
18578
20463
  setTimeout(() => { composedHeld = null; }, 0);
18579
20464
  }, true);
18580
20465
 
20466
+ // v3.14.0 T8: input into a focused armed surface that has no burst starts
20467
+ // one first, before the DOM changes, so the burst's baseline is the
20468
+ // surface as it was (ensureBurst()). Final review I3: a native format
20469
+ // chord (Ctrl+B / I / U, NATIVE_FORMAT_INPUT_TYPES) and a drop are input
20470
+ // too — measured before: Escape, select, Ctrl+B showed the bold and the
20471
+ // file kept the plain word. This listener is registered ahead of the
20472
+ // `native-format-pre` one below (both capture phase on document, so they
20473
+ // run in that order), so the burst exists when that one snaps it.
20474
+ document.addEventListener('beforeinput', (e) => {
20475
+ if (e.defaultPrevented) return;
20476
+ const t = e.inputType;
20477
+ if (t !== 'insertText' && t !== 'insertReplacementText' && t !== 'insertFromPaste' &&
20478
+ t !== 'insertLineBreak' && t !== 'insertParagraph' && !/^delete/.test(t) &&
20479
+ !NATIVE_FORMAT_INPUT_TYPES.has(t) && t !== 'insertFromDrop') return;
20480
+ const surface = e.target && e.target.closest ? e.target.closest('.ed-wys-armed') : null;
20481
+ if (surface) ensureBurst(surface);
20482
+ }, true);
20483
+ // Fix round 1 (I3, R35): an IME composition is input too. Its text arrives
20484
+ // as insertCompositionText, which the list above leaves out, so composed
20485
+ // text after Escape or after Ctrl+S on an untouched surface was never
20486
+ // committed (measured, Chromium via CDP: X, Escape, IME 你 → file
20487
+ // `Alpha.`). The burst opens at compositionstart, before the composition
20488
+ // touches the DOM, so its baseline is the surface as it was; it only opens
20489
+ // a burst, and commits nothing mid-composition. ensureBurst() never opens a
20490
+ // second one, and holds off during a flight (the flight guard owns the
20491
+ // composition then).
20492
+ document.addEventListener('compositionstart', (e) => {
20493
+ const surface = e.target && e.target.closest ? e.target.closest('.ed-wys-armed') : null;
20494
+ if (surface) ensureBurst(surface);
20495
+ }, true);
20496
+
18581
20497
  // Capture phase, matching the phase the ordering above was measured in.
18582
20498
  document.addEventListener('beforeinput', (e) => {
18583
20499
  if (!NATIVE_FORMAT_INPUT_TYPES.has(e.inputType)) return;
@@ -20432,4 +22348,9 @@
20432
22348
  // selToolbar, …) is initialised and the bar's first deriveState() sees the
20433
22349
  // armed document rather than an empty one.
20434
22350
  mountToolbar();
22351
+ // v3.14.0 T4: after the bar exists, so its focusin tracking sees the
22352
+ // empty document's transient and reports a paragraph.
22353
+ wireEmptyMark();
22354
+ wireClickBelow();
22355
+ ensureEmptyDocTransient();
20435
22356
  })();