@helping-ai-workflow/md2doc 3.13.0 → 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
@@ -1110,7 +1185,9 @@
1110
1185
  let switching = null;
1111
1186
  function switchAwayFrom() {
1112
1187
  if (switching) return switching;
1113
- switching = resolveOpenSession().finally(() => { switching = null; });
1188
+ // v3.13.1 T1: a held-key replay that waited on this resolution resumes
1189
+ // once it is over (scheduleFlightFlush() is a no-op with nothing held).
1190
+ switching = resolveOpenSession().finally(() => { switching = null; scheduleFlightFlush(); });
1114
1191
  return switching;
1115
1192
  }
1116
1193
 
@@ -1126,6 +1203,9 @@
1126
1203
  // open with its edit intact, banner already shown, caller must abandon
1127
1204
  // whatever it was about to do).
1128
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;
1129
1209
  if (currentBurst) {
1130
1210
  const ok = await resolveBurst();
1131
1211
  if (!ok) return false;
@@ -1190,7 +1270,11 @@
1190
1270
  // The distance was a second record of "are we standing where we saved";
1191
1271
  // `UndoStack` now answers that question itself, and a boolean has no zero
1192
1272
  // to walk through. See lineops.js's own comment for the full story.
1193
- 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;
1194
1278
  }
1195
1279
 
1196
1280
  function setDirty() {
@@ -1685,6 +1769,11 @@
1685
1769
  // through explicitly. Passed positionally here and through
1686
1770
  // applyRenderResult() rather than folded into `j` (which is the
1687
1771
  // server's response payload, not render-context the CALLER derived).
1772
+ // v3.13.1 R11: the selection right before the DOM is replaced — a click,
1773
+ // drag or double-click into another block during this request has
1774
+ // already put it there. The focusin re-resolve and the flight replay
1775
+ // restore it, anchor and focus both.
1776
+ lastRenderSel = selectionSnapshotIn(activeFlightSurface());
1688
1777
  return applyRenderResult(j, editRange, scrollY, declaredRange);
1689
1778
  }
1690
1779
 
@@ -1869,6 +1958,9 @@
1869
1958
  // 要為一次沒上畫面的編輯升起警告」這件事寫在這裡,而不是留給讀者去讀
1870
1959
  // 它們的本體。
1871
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();
1872
1964
  lastRenderLines = lines.slice();
1873
1965
  lastRenderEndedUnclosed = nowUnclosed;
1874
1966
  // 升 banner 排在套用之後:套用是把新的 DOM 放上畫面那一步,警告的是使用
@@ -2521,6 +2613,9 @@
2521
2613
  // insertBlockBelow() rerenderAll() awaits, so it can never clobber a
2522
2614
  // fresh assignment.
2523
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;
2524
2619
  // Task 2 (Phase 3): same belt-and-braces reasoning for the always-on
2525
2620
  // WYSIWYG burst — whatever block had one open a moment ago was just
2526
2621
  // detached by the innerHTML replacement above, so its own focusout
@@ -2664,14 +2759,531 @@
2664
2759
  // refusal, and commitListStructure()'s no-range exit) pass nothing, which
2665
2760
  // falls back to a full re-render by contract — patch can only compare
2666
2761
  // renderer output, never DOM the user has already touched.
2762
+ //
2763
+ // v3.13.1 T1: every render is also a "commit in flight" for the input guard
2764
+ // below — see `commitFlight`. A render that lands re-points every held
2765
+ // entry's tag at the surface that now holds the same source.
2667
2766
  async function safeRerenderAll(editRange) {
2767
+ const blocksBefore = blocks;
2768
+ const lineMap = pendingFlightLineMap;
2769
+ pendingFlightLineMap = null;
2770
+ beginCommitFlight();
2771
+ let ok = false;
2668
2772
  try {
2669
- return await rerenderAll(editRange);
2773
+ ok = await rerenderAll(editRange);
2774
+ return ok;
2670
2775
  } catch (e) {
2671
2776
  showError('這次修改沒有套用:發生未預期的錯誤(' + describeFailure(e) +
2672
2777
  ')。文件維持修改前的樣子。', null, null);
2673
2778
  return false;
2779
+ } finally {
2780
+ if (ok) reanchorFlightBuffer(blocksBefore, editRange, lineMap);
2781
+ // A failed render: nothing places focus afterwards (commitListStructure()
2782
+ // skips its focusBlockAtLine(), and a failed blur-commit refocuses the
2783
+ // block it could not commit), so the opening run replays where it was
2784
+ // typed. Measured before round 3: edit A, click B@5, `ZZ`, render 500 —
2785
+ // `ZZ` went to the start of A.
2786
+ else pinFirstFlightRun(null);
2787
+ endCommitFlight();
2788
+ }
2789
+ }
2790
+
2791
+ // ── v3.13.1 T1: the commit-in-flight input guard ──────────────────────
2792
+ // A commit awaits /api/render and then rebuilds the surfaces it covers from
2793
+ // `lines`. Anything typed into a surface while that request is out lands in
2794
+ // DOM the render is about to throw away. Measured before this (audit
2795
+ // 2026-10-10): Enter then an immediate `type('new')` in a tight list lost
2796
+ // every character in Chromium and wrote an extra empty `-` item; with the
2797
+ // render held 800 ms, 100% loss in both engines.
2798
+ //
2799
+ // While a flight is active (flightActive(): a commit in flight, or held
2800
+ // entries still waiting to replay), what is typed into an armed surface is held in
2801
+ // `flightBuffer` instead of reaching the DOM — text, paste, and the keys
2802
+ // that edit or move: Enter, Tab, Backspace/Delete (and their word/line
2803
+ // chords), arrows, Home/End. Escape drops everything held (R12: Escape
2804
+ // means "throw away what I have not committed", in flight or not). Once
2805
+ // the render has landed and its caller has placed focus, the entries are
2806
+ // replayed in order, each through the path the live input would have taken
2807
+ // (ruling R13): text through the browser's own insertText (which replaces a
2808
+ // selection and rebalances whitespace; insertTextAtCaret() is the fallback),
2809
+ // paste through routePaste(), keys as a keydown through the
2810
+ // document handler — so ↑/↓ can still cross blocks and a table cell still
2811
+ // owns its keys — and, when no handler claims the key, its native default
2812
+ // (execCommand deletion, Selection.modify movement).
2813
+ //
2814
+ // WHERE an entry replays (fix rounds 1-3):
2815
+ // * A "continuation" entry (pos === null) replays at the selection the
2816
+ // replay has reached — wherever focus is once the caller placed it, or
2817
+ // wherever an earlier replayed key moved it (a held ArrowDown that walks
2818
+ // to the next block takes the keys typed after it there). A held key
2819
+ // never moves the DOM selection; a pointer press does, so the press
2820
+ // first PINS the run of continuation entries that opened the flight to
2821
+ // the selection it is about to move away from (pinFirstFlightRun(); a
2822
+ // failed render pins it too, since then no caller places focus). Every
2823
+ // other run already starts at a positioned entry. Measured before round
2824
+ // 3: click B@5, `xy`, click Omega, `Z` replayed `xy` at B's start.
2825
+ // * A "positioned" entry is the first one held after a pointer press into
2826
+ // a surface during the flight: it carries a tag (surface, block id,
2827
+ // table cell index) and the full selection (anchor and focus offsets),
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
2837
+ // replaces its surface: by the committed run's own line map for an item
2838
+ // of a list run the commit rewrote (an item the split pushed down a
2839
+ // line, the provisional item), otherwise by its block's start line,
2840
+ // shifted by the commit's line delta when it lay below the edited range.
2841
+ // Measured before round 2: Enter, `ab`, click `charlie`, `Z` saved
2842
+ // `- abZ` with charlie untouched.
2843
+ // * A pointer press during the flight with nothing typed after it becomes
2844
+ // a { kind: 'focus' } entry, so the click is not lost to the commit's
2845
+ // own focusBlockAtLine().
2846
+ //
2847
+ // A failed render keeps the buffer too: rerenderAll() leaves `.content`
2848
+ // untouched on failure, every surface is still connected, and the replay
2849
+ // lands where it was typed.
2850
+ let commitFlight = 0;
2851
+ const flightBuffer = [];
2852
+ // A pointer press into a surface during a flight: the next held entry is
2853
+ // positioned (see above).
2854
+ let flightSelMoved = false;
2855
+ // Set while an entry replays: the replay's own keydown / insertText must
2856
+ // not be held again.
2857
+ let flightReplaying = false;
2858
+ function flightActive() {
2859
+ return !flightReplaying && (commitFlight > 0 || flightBuffer.length > 0);
2860
+ }
2861
+ // The selection right before the latest render replaced the DOM (R11).
2862
+ let lastRenderSel = null;
2863
+ // Where continuation entries go when the render took focus away and no
2864
+ // caller put it anywhere (e.g. a Ctrl+S commit of the focused block).
2865
+ let flightFallback = null;
2866
+ // commitListStructure() -> safeRerenderAll(): the run's line map.
2867
+ let pendingFlightLineMap = null;
2868
+ const FLIGHT_DROPPED_MESSAGE = '重新整理期間打的字沒有地方放,已略過';
2869
+
2870
+ function beginCommitFlight() {
2871
+ commitFlight++;
2872
+ }
2873
+
2874
+ // Not a synchronous decrement. The caller's continuation after
2875
+ // `await safeRerenderAll(...)` — focusBlockAtLine(), an arrow walk's
2876
+ // landing — is a microtask, so it runs before this timer and focus is
2877
+ // placed before the replay. Keys that arrive between the render landing
2878
+ // and the timer are still held, behind the earlier ones.
2879
+ function endCommitFlight() {
2880
+ setTimeout(() => {
2881
+ commitFlight--;
2882
+ if (!commitFlight) flushFlightBuffer();
2883
+ }, 0);
2884
+ }
2885
+
2886
+ // A replay that had to wait for a focus change resumes from here (the
2887
+ // focusin handlers call it once their burst is open).
2888
+ function scheduleFlightFlush() {
2889
+ if (flightBuffer.length && !commitFlight) setTimeout(flushFlightBuffer, 0);
2890
+ }
2891
+
2892
+ function flightSurfaceOf(node) {
2893
+ const el = node && (node.nodeType === 1 ? node : node.parentElement);
2894
+ return el && el.closest ? el.closest('.ed-wys-armed, .ed-wys-cell') : null;
2895
+ }
2896
+ function activeFlightSurface() {
2897
+ return flightSurfaceOf(document.activeElement);
2898
+ }
2899
+
2900
+ // Selection points as character offsets inside a surface, counted over
2901
+ // text nodes only — the same count pointInSurface() walks back.
2902
+ function offsetInSurface(surface, node, offset) {
2903
+ const r = document.createRange();
2904
+ r.selectNodeContents(surface);
2905
+ r.setEnd(node, offset);
2906
+ return r.toString().length;
2907
+ }
2908
+ function pointInSurface(surface, offset) {
2909
+ let left = offset;
2910
+ let lastText = null;
2911
+ const walker = document.createTreeWalker(surface, NodeFilter.SHOW_TEXT);
2912
+ for (let t = walker.nextNode(); t; t = walker.nextNode()) {
2913
+ if (left <= t.data.length) return [t, left];
2914
+ left -= t.data.length;
2915
+ lastText = t;
2916
+ }
2917
+ return lastText ? [lastText, lastText.data.length] : [surface, 0];
2918
+ }
2919
+ // { surface, anchor, focus } for the selection in `surface`; offsets are
2920
+ // null when the selection is not inside it.
2921
+ function selectionSnapshotIn(surface) {
2922
+ if (!surface) return null;
2923
+ const sel = window.getSelection();
2924
+ if (!sel.rangeCount || !surface.contains(sel.anchorNode) || !surface.contains(sel.focusNode)) {
2925
+ return { surface, anchor: null, focus: null };
2926
+ }
2927
+ return { surface,
2928
+ anchor: offsetInSurface(surface, sel.anchorNode, sel.anchorOffset),
2929
+ focus: offsetInSurface(surface, sel.focusNode, sel.focusOffset) };
2930
+ }
2931
+ function restoreSelection(surface, anchor, focus) {
2932
+ if (anchor === null || anchor === undefined) return;
2933
+ const a = pointInSurface(surface, anchor);
2934
+ const f = pointInSurface(surface, focus === null || focus === undefined ? anchor : focus);
2935
+ window.getSelection().setBaseAndExtent(a[0], a[1], f[0], f[1]);
2936
+ }
2937
+
2938
+ function flightTagOf(snap) {
2939
+ if (!snap || !snap.surface) return null;
2940
+ const blockEl = snap.surface.closest('.ed-block');
2941
+ const isCell = snap.surface.classList.contains('ed-wys-cell');
2942
+ return {
2943
+ surface: snap.surface,
2944
+ blockId: blockEl ? blockEl.getAttribute('data-block-id') : null,
2945
+ cellIndex: isCell && blockEl
2946
+ ? Array.prototype.indexOf.call(blockEl.querySelectorAll('.ed-wys-cell'), snap.surface) : null,
2947
+ anchor: snap.anchor, focus: snap.focus,
2948
+ logAt: flightLogLength(snap.surface),
2949
+ };
2950
+ }
2951
+
2952
+ // The committed run's lines after the commit: the serializer's own
2953
+ // line -> block map (`lineMeta`, one entry per emitted line), keyed by the
2954
+ // block ids the run had before it. The provisional item has no id.
2955
+ function flightLineMapOf(lineMeta, startLine) {
2956
+ const map = { ids: {}, provisional: null };
2957
+ (lineMeta || []).forEach((m, k) => {
2958
+ if (m.blank) return;
2959
+ if (m.blockId === null || m.blockId === undefined) {
2960
+ if (map.provisional === null) map.provisional = startLine + k;
2961
+ } else if (!(m.blockId in map.ids)) {
2962
+ map.ids[m.blockId] = startLine + k;
2963
+ }
2964
+ });
2965
+ return map;
2966
+ }
2967
+
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;
2999
+ }
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;
3006
+ }
3007
+
3008
+ // Pins the run of continuation entries that opened this flight (if it is
3009
+ // still unpinned) to `snap`, or to its own first entry's hint — where it
3010
+ // was typed — when there is no usable selection.
3011
+ function pinFirstFlightRun(snap) {
3012
+ const first = flightBuffer[0];
3013
+ if (!first || first.pos) return;
3014
+ const tag = snap && snap.anchor !== null ? flightTagOf(snap) : null;
3015
+ first.pos = tag || (first.hint ? Object.assign({}, first.hint) : null);
3016
+ }
3017
+
3018
+ function holdFlightEntry(surface, entry) {
3019
+ entry.pos = null;
3020
+ entry.hint = flightTagOf(selectionSnapshotIn(surface));
3021
+ if (flightSelMoved) {
3022
+ flightSelMoved = false;
3023
+ entry.pos = flightTagOf(selectionSnapshotIn(surface));
3024
+ }
3025
+ const last = flightBuffer[flightBuffer.length - 1];
3026
+ if (entry.kind === 'text' && !entry.pos && last && last.kind === 'text') {
3027
+ last.data += entry.data;
3028
+ return;
3029
+ }
3030
+ flightBuffer.push(entry);
3031
+ }
3032
+
3033
+ // After a render lands: re-point every tag whose surface it replaced.
3034
+ function reanchorFlightBuffer(before, editRange, lineMap) {
3035
+ if (flightSelMoved && lastRenderSel) {
3036
+ flightSelMoved = false;
3037
+ flightBuffer.push({ kind: 'focus', pos: flightTagOf(lastRenderSel) });
3038
+ }
3039
+ flightFallback = lastRenderSel && !lastRenderSel.surface.isConnected
3040
+ ? flightTagOf(lastRenderSel) : null;
3041
+ const remap = (pos) => {
3042
+ if (!pos) return;
3043
+ if (pos.surface && pos.surface.isConnected) {
3044
+ const own = pos.surface.closest('.ed-block');
3045
+ pos.blockId = own ? own.getAttribute('data-block-id') : pos.blockId;
3046
+ return;
3047
+ }
3048
+ let line = null;
3049
+ if (lineMap) {
3050
+ if (pos.blockId === null) line = lineMap.provisional;
3051
+ else if (pos.blockId in lineMap.ids) line = lineMap.ids[pos.blockId];
3052
+ }
3053
+ if (line === null && pos.blockId !== null) {
3054
+ const rec = (before || []).find((b) => b.id === Number(pos.blockId));
3055
+ if (rec) {
3056
+ line = rec.startLine;
3057
+ if (editRange && line > editRange.endLine) line += editRange.delta;
3058
+ }
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
+ }
3075
+ pos.surface = null;
3076
+ pos.logAt = 0;
3077
+ if (line === null) return;
3078
+ const nb = blocks.find((b) => b.startLine === line);
3079
+ const nEl = nb ? document.querySelector('.ed-block[data-block-id="' + nb.id + '"]') : null;
3080
+ if (!nEl) return;
3081
+ let surf = pos.cellIndex !== null && pos.cellIndex !== undefined
3082
+ ? nEl.querySelectorAll('.ed-wys-cell')[pos.cellIndex] || null
3083
+ : blockContentEl(nEl);
3084
+ if (surf && !surf.matches('.ed-wys-armed, .ed-wys-cell')) surf = null;
3085
+ pos.surface = surf;
3086
+ pos.logAt = flightLogLength(surf);
3087
+ pos.blockId = String(nb.id);
3088
+ };
3089
+ flightBuffer.forEach((en) => { remap(en.pos); remap(en.hint); });
3090
+ remap(flightFallback);
3091
+ focusinWaitTags.forEach(remap);
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 = [];
3099
+
3100
+ // A deletion chord's input type, and a caret chord's movement, per
3101
+ // platform (Ctrl on Linux/Windows, Alt and Cmd on a Mac).
3102
+ const FLIGHT_IS_MAC = /Mac|iPhone|iPad/.test(navigator.platform);
3103
+ function deletionForKey(e) {
3104
+ const word = FLIGHT_IS_MAC ? (e.altKey && !e.metaKey && !e.ctrlKey) : (e.ctrlKey && !e.metaKey && !e.altKey);
3105
+ const line = FLIGHT_IS_MAC && e.metaKey && !e.altKey && !e.ctrlKey;
3106
+ const plain = !e.ctrlKey && !e.metaKey && !e.altKey;
3107
+ if (e.key === 'Backspace') {
3108
+ return plain ? 'deleteContentBackward' : word ? 'deleteWordBackward' : line ? 'deleteSoftLineBackward' : null;
3109
+ }
3110
+ if (e.key === 'Delete') {
3111
+ return plain ? 'deleteContentForward' : word ? 'deleteWordForward' : line ? 'deleteSoftLineForward' : null;
2674
3112
  }
3113
+ return null;
3114
+ }
3115
+ function caretMoveForKey(e) {
3116
+ const word = FLIGHT_IS_MAC ? (e.altKey && !e.metaKey && !e.ctrlKey) : (e.ctrlKey && !e.metaKey && !e.altKey);
3117
+ const plain = !e.ctrlKey && !e.metaKey && !e.altKey;
3118
+ const extend = !!e.shiftKey;
3119
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
3120
+ if (!plain && !word) return null;
3121
+ return { dir: e.key === 'ArrowLeft' ? 'backward' : 'forward', unit: word ? 'word' : 'character', extend };
3122
+ }
3123
+ if (!plain) return null;
3124
+ if (e.key === 'Home' || e.key === 'End') return { dir: e.key === 'Home' ? 'backward' : 'forward', unit: 'lineboundary', extend };
3125
+ if (e.key === 'ArrowUp' || e.key === 'ArrowDown') return { dir: e.key === 'ArrowUp' ? 'backward' : 'forward', unit: 'line', extend };
3126
+ return null;
3127
+ }
3128
+ function isHeldTyping(en) {
3129
+ return en.kind === 'text' || en.kind === 'paste' || en.kind === 'delete' || en.kind === 'format' ||
3130
+ (en.kind === 'key' && !caretMoveForKey(en));
3131
+ }
3132
+ function dropHeldTyping() {
3133
+ const kept = [];
3134
+ let carry = null;
3135
+ flightBuffer.forEach((en) => {
3136
+ if (isHeldTyping(en)) {
3137
+ if (en.pos) carry = en.pos;
3138
+ return;
3139
+ }
3140
+ if (en.pos) carry = null;
3141
+ else if (carry) { en.pos = carry; carry = null; }
3142
+ kept.push(en);
3143
+ });
3144
+ flightBuffer.length = 0;
3145
+ kept.forEach((en) => flightBuffer.push(en));
3146
+ }
3147
+ function flightHoldsKey(e) {
3148
+ if ((e.key === 'Enter' || e.key === 'Tab') && !e.ctrlKey && !e.metaKey && !e.altKey) return true;
3149
+ return !!(deletionForKey(e) || caretMoveForKey(e));
3150
+ }
3151
+
3152
+ // The native default of a deletion, against the restored selection: a
3153
+ // selection is deleted whole; a collapsed caret deletes one character, or
3154
+ // is first extended by a word / to the line boundary.
3155
+ function nativeFlightDeletion(t, target) {
3156
+ const sel = window.getSelection();
3157
+ if (!sel.rangeCount) return;
3158
+ const back = /Backward$/.test(t);
3159
+ const unit = /Word/.test(t) ? 'word' : (/Line/.test(t) ? 'lineboundary' : null);
3160
+ if (sel.isCollapsed && unit) {
3161
+ if (!sel.modify) return;
3162
+ sel.modify('extend', back ? 'backward' : 'forward', unit);
3163
+ // Nothing to delete in that direction: a collapsed 'delete' would take
3164
+ // a character instead.
3165
+ if (sel.isCollapsed) return;
3166
+ }
3167
+ snapBurstIfActive(target, 'replay-pre');
3168
+ document.execCommand(sel.isCollapsed && !back ? 'forwardDelete' : 'delete');
3169
+ snapBurstIfActive(target, 'replay');
3170
+ }
3171
+
3172
+ function replayFlightEntry(item, target) {
3173
+ if (item.kind === 'text') {
3174
+ // The browser's own insertText, not insertTextAtCaret(): it replaces a
3175
+ // selection the same way, and it also rebalances the whitespace around
3176
+ // the caret the way live typing does (measured: a held Ctrl+Backspace
3177
+ // leaves `ab` + NBSP, and only the native insertion of the next `e`
3178
+ // turns that back into `ab e`).
3179
+ snapBurstIfActive(target, 'replay-pre');
3180
+ if (/[\r\n]/.test(item.data) || !document.execCommand('insertText', false, item.data)) {
3181
+ insertTextAtCaret(item.data);
3182
+ }
3183
+ snapBurstIfActive(target, 'replay');
3184
+ } else if (item.kind === 'paste') {
3185
+ plainPasteArmed = item.plainOnly;
3186
+ routePaste(target, item.dt);
3187
+ } else if (item.kind === 'delete') {
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');
3194
+ } else if (item.kind === 'key') {
3195
+ const ev = new KeyboardEvent('keydown', { key: item.key, shiftKey: item.shiftKey,
3196
+ ctrlKey: item.ctrlKey, altKey: item.altKey, metaKey: item.metaKey, bubbles: true, cancelable: true });
3197
+ target.dispatchEvent(ev);
3198
+ if (ev.defaultPrevented) return;
3199
+ const del = deletionForKey(item);
3200
+ if (del) { nativeFlightDeletion(del, target); return; }
3201
+ const move = caretMoveForKey(item);
3202
+ const sel = window.getSelection();
3203
+ if (move && sel.modify) sel.modify(move.extend ? 'extend' : 'move', move.dir, move.unit);
3204
+ }
3205
+ }
3206
+
3207
+ function flushFlightBuffer() {
3208
+ if (commitFlight || switching) return;
3209
+ let dropped = false;
3210
+ // Replay in order. A replayed key, or the focus change to a positioned
3211
+ // entry's surface, can start a new commit (or wait on one); the rest
3212
+ // waits and resumes from its endCommitFlight() or the focusin handlers.
3213
+ while (flightBuffer.length && !commitFlight && !switching) {
3214
+ const item = flightBuffer[0];
3215
+ let target = null;
3216
+ if (item.pos) {
3217
+ target = item.pos.surface && item.pos.surface.isConnected ? item.pos.surface : null;
3218
+ if (!target) { flightBuffer.shift(); dropped = true; continue; }
3219
+ if (activeFlightSurface() !== target) {
3220
+ target.focus();
3221
+ if (commitFlight || switching) break;
3222
+ }
3223
+ restoreSelection(target, mapOffsetThroughLog(item.pos, item.pos.anchor),
3224
+ mapOffsetThroughLog(item.pos, item.pos.focus));
3225
+ } else {
3226
+ target = activeFlightSurface();
3227
+ const fb = flightFallback;
3228
+ if (!target && fb && fb.surface && fb.surface.isConnected) {
3229
+ fb.surface.focus();
3230
+ if (commitFlight || switching) break;
3231
+ restoreSelection(fb.surface, mapOffsetThroughLog(fb, fb.anchor),
3232
+ mapOffsetThroughLog(fb, fb.focus));
3233
+ flightFallback = null;
3234
+ target = activeFlightSurface();
3235
+ }
3236
+ if (!target) { flightBuffer.shift(); dropped = true; continue; }
3237
+ }
3238
+ flightBuffer.shift();
3239
+ if (item.kind !== 'focus') {
3240
+ const pre = selectionSnapshotIn(target);
3241
+ const len0 = surfaceTextLength(target);
3242
+ flightReplaying = true;
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
+ }
3257
+ }
3258
+ }
3259
+ if (!flightBuffer.length) flightEditLog.clear();
3260
+ if (!flightBuffer.length && !commitFlight) flightFallback = null;
3261
+ if (dropped) showNotice(FLIGHT_DROPPED_MESSAGE);
3262
+ }
3263
+
3264
+ // An IME composition cannot be cancelled (its beforeinput is not
3265
+ // cancelable), so the committed text is already in the DOM when
3266
+ // compositionend reports it. It is taken back out when it sits right
3267
+ // before the caret, which leaves the buffer its only owner — a render that
3268
+ // keeps this surface (a failed one, or a patch that does not cover it)
3269
+ // would otherwise show it twice once the replay lands. Returns false when
3270
+ // it cannot be found there; the caller holds it anyway, and the render
3271
+ // that replaces the surface is what removes the DOM copy.
3272
+ function takeBackComposedText(data) {
3273
+ const sel = window.getSelection();
3274
+ if (!data || !sel.rangeCount) return false;
3275
+ const r = sel.getRangeAt(0);
3276
+ if (!r.collapsed || r.startContainer.nodeType !== 3) return false;
3277
+ const node = r.startContainer;
3278
+ const start = r.startOffset - data.length;
3279
+ if (start < 0 || node.data.slice(start, r.startOffset) !== data) return false;
3280
+ node.deleteData(start, data.length);
3281
+ const c = document.createRange();
3282
+ c.setStart(node, start);
3283
+ c.collapse(true);
3284
+ sel.removeAllRanges();
3285
+ sel.addRange(c);
3286
+ return true;
2675
3287
  }
2676
3288
 
2677
3289
  function autoSize(ta) {
@@ -2916,6 +3528,9 @@
2916
3528
  // v3.2.0 (final review item 1): `let`, not `const` — reanchor() below
2917
3529
  // re-derives all three of `blockId`/`startLine`/`endLine` when this
2918
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;
2919
3534
  let blockId = Number(blockEl.getAttribute('data-block-id'));
2920
3535
  const block = blocks.find((b) => b.id === blockId);
2921
3536
  if (!block) return;
@@ -4122,9 +4737,40 @@
4122
4737
  // `Alpha <em><br></em> bold text here.` — dropping before the insert would
4123
4738
  // have taken that <em> and the caret's new home with it.
4124
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
+ }
4125
4758
  sel.removeAllRanges();
4126
4759
  sel.addRange(range);
4127
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
+ }
4128
4774
 
4129
4775
  // Heading ± buttons on the bar: a pure source-level transform (just the
4130
4776
  // leading `#` run) via the SAME commitEdit()/replaceLines() pipeline as
@@ -4179,6 +4825,7 @@
4179
4825
 
4180
4826
  async function changeHeadingDepthBody(blockEl, delta, anchor) {
4181
4827
  if (!blockEl) return;
4828
+ if (blockEl.getAttribute('data-block-id') === null) return; // R2: id-less → not block 0
4182
4829
  const blockId = Number(blockEl.getAttribute('data-block-id'));
4183
4830
  // Commits (never discards) whatever burst/editor is open first — same
4184
4831
  // precondition undo()/redo() use below, so this never races a concurrent
@@ -4302,6 +4949,9 @@
4302
4949
  if (blockType === 'heading') stripHeadingAnchor(editEl);
4303
4950
  editEl.setAttribute('contenteditable', 'true');
4304
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);
4305
4955
  } else if (blockType === 'li') {
4306
4956
  // Task 6 (Phase 4): per-li arming. Each
4307
4957
  // `.ed-block[data-block-type="li"]` is armed independently: only its
@@ -4928,19 +5578,11 @@
4928
5578
  // this uses the bare marker `-` instead — verified to lex as `list` with
4929
5579
  // one empty `<li>`.
4930
5580
  //
4931
- // The 段落 skeleton similarly can't be a truly empty line: a blank line by
4932
- // itself is consumed by marked's lexer as a `space` token between
4933
- // neighboring blocks, never becomes its own `paragraph` token, and would
4934
- // leave the + menu unable to find (or focus) any block at all — see
4935
- // commitBlockInsertion()'s newStartLine contract, which callers use to
4936
- // locate the inserted block in the server's recomputed block list.
4937
- // U+200B (zero-width space) is real, non-whitespace-per-`\s` text that
4938
- // marked DOES lex as its own paragraph, and renders as `<p>​</p>` —
4939
- // visually empty. focusInsertedBlock() below selects that single
4940
- // character so an immediate keystroke replaces it, matching the "empty
4941
- // paragraph to type into" intent; if the user commits without typing at
4942
- // all, the ZWSP is what ends up on disk (a known, documented trade-off —
4943
- // 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.
4944
5586
  //
4945
5587
  // 程式碼: a bare two-line fence pair (```/```, nothing between) is what
4946
5588
  // "fence pair with empty body" reads as most literally, and DOES lex as
@@ -4954,7 +5596,6 @@
4954
5596
  // browser probe). A three-line fence with one blank line between them
4955
5597
  // (still `text: ''` per marked — verified) gives that line to land on.
4956
5598
  const BLOCK_SKELETONS = {
4957
- paragraph: ['​'],
4958
5599
  heading: ['## '],
4959
5600
  list: ['-'],
4960
5601
  table: ['| A | B |', '|---|---|', '| | |'],
@@ -4972,8 +5613,8 @@
4972
5613
  // freshly-inserted, placeholder-only block) so the user's very first
4973
5614
  // keystroke replaces the placeholder instead of being inserted next to
4974
5615
  // it. A no-op-equivalent (nothing to select) on the genuinely-empty
4975
- // heading/list/table skeletons; load-bearing only for the paragraph
4976
- // 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).
4977
5618
  function focusAndSelectAll(el) {
4978
5619
  el.focus();
4979
5620
  const range = document.createRange();
@@ -5098,6 +5739,16 @@
5098
5739
  // parameter and not a second insertion path.
5099
5740
  async function insertBlockBelow(blockEl, kind, opts) {
5100
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;
5101
5752
  // T7: captured BEFORE switchAwayFrom(), because that is what can renumber
5102
5753
  // the ids — see captureBlockIdentity()'s comment.
5103
5754
  const identity = captureBlockIdentity(blockEl);
@@ -5149,7 +5800,8 @@
5149
5800
  // 建立副本 (「副本插在該 block 整棵子樹之後」), and it is what makes every
5150
5801
  // non-list kind safe here. Measured on ['# Doc','','- alpha',
5151
5802
  // ' - child',' - grand','']: anchored on `child`,
5152
- // 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
5153
5805
  // '# Doc\n\n- alpha\n - child\n\n<ZWSP>\n\n - grand\n', and
5154
5806
  // marked lexes ' - grand' after a paragraph as an INDENTED CODE
5155
5807
  // BLOCK — the grandchild's content is gone. Anchored on the end of the
@@ -5235,7 +5887,7 @@
5235
5887
  // pasted paragraph it would silently delete real content the moment the
5236
5888
  // user clicked elsewhere without typing. focusAndSelectAll() is wrong for
5237
5889
  // the same reason: it selects the block's whole content so the first
5238
- // 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
5239
5891
  // a just-pasted paragraph very much does not.
5240
5892
  //
5241
5893
  // 'line' is excluded for the same reason from the other direction: both
@@ -5289,6 +5941,1396 @@
5289
5941
  }
5290
5942
 
5291
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
+
5292
7334
  // S2 Task 7 — the li half of +. The new item is spliced into the run's own
5293
7335
  // span and the WHOLE span is re-serialized over the run's line range: one
5294
7336
  // commitRangeEdit, therefore one undo op, no leading blank line (so the run
@@ -5679,12 +7721,12 @@
5679
7721
  // never reported, never tested for duplicate/delete) — widening past
5680
7722
  // convertBlockViaMenu() to the other three callers remains a separate
5681
7723
  // decision this task did not make and should not make silently.
5682
- function restoreAfterStructuralOp(focusLine, anchorLine, allowRawEditRescue) {
7724
+ function restoreAfterStructuralOp(focusLine, anchorLine, allowRawEditRescue, caretToEnd) {
5683
7725
  // 有 blockSelection 站著時什麼都不做 —— 見 convertBlockViaMenu() 底下那段
5684
7726
  // 註解:強行還原 caret 會做出「底色仍說已選取、Delete 卻是死鍵」的混種狀態。
5685
7727
  if (blockSelection) return;
5686
7728
  if (focusLine != null) {
5687
- focusBlockAtLine(focusLine, true); // caretToEnd
7729
+ focusBlockAtLine(focusLine, caretToEnd !== false); // caretToEnd unless asked otherwise
5688
7730
  // 降級目標(quote / code)沒有可聚焦編輯面,focusBlockAtLine 會安靜
5689
7731
  // no-op;把 toolbarBlockEl 指回那個 block,工具列才不會塌成 4 顆。
5690
7732
  reaimToolbarBlockAtLine(focusLine);
@@ -5804,7 +7846,10 @@
5804
7846
  // The body returns the operated block's post-commit start line, or undefined
5805
7847
  // when it refused or wrote nothing — `anchor.line` is what covers the
5806
7848
  // second case (see restoreAfterStructuralOp()).
5807
- 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) {
5808
7853
  let focusLine = null;
5809
7854
  const anchor = {};
5810
7855
  try {
@@ -5820,7 +7865,7 @@
5820
7865
  // Task 9 review I4: `true` — this is the ONE call site backlog #6's
5821
7866
  // raw-editor rescue is scoped to. See restoreAfterStructuralOp()'s
5822
7867
  // own `allowRawEditRescue` comment.
5823
- restoreAfterStructuralOp(focusLine, anchor.line, true);
7868
+ restoreAfterStructuralOp(focusLine, anchor.line, true, !(opts && opts.caretToEnd === false));
5824
7869
  }
5825
7870
  }
5826
7871
 
@@ -5963,7 +8008,8 @@
5963
8008
  // block the user never pointed at.
5964
8009
  function reresolveBlockElAfterSelfCommit(identity) {
5965
8010
  if (!identity) return null;
5966
- const at = blocks.find((b) => b.startLine === identity.startLine);
8011
+ const startLine = identityStartLineNow(identity);
8012
+ const at = blocks.find((b) => b.startLine === startLine);
5967
8013
  if (!at || at.type !== identity.type) return null;
5968
8014
  return document.querySelector('.ed-block[data-block-id="' + at.id + '"]');
5969
8015
  }
@@ -7324,8 +9370,16 @@
7324
9370
  function burstBaselineHtml(editEl) {
7325
9371
  const html = editEl.innerHTML;
7326
9372
  if (html.indexOf('ed-te-hl') === -1 && html.indexOf('ed-te-row-dragging') === -1 &&
7327
- html.indexOf('ed-cell-range') === -1) return html;
9373
+ html.indexOf('ed-cell-range') === -1 && html.indexOf('search-hit') === -1) return html;
7328
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
+ });
7329
9383
  const marked = clone.querySelectorAll('.ed-te-hl, .ed-te-row-dragging, .ed-cell-range');
7330
9384
  Array.prototype.forEach.call(marked, (el) => {
7331
9385
  el.classList.remove('ed-te-hl');
@@ -7345,6 +9399,32 @@
7345
9399
  dropDiscardedRawEdit();
7346
9400
  const blockEl = editEl.closest('.ed-block');
7347
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;
7348
9428
  const blockId = Number(blockEl.getAttribute('data-block-id'));
7349
9429
  const block = blocks.find((b) => b.id === blockId);
7350
9430
  if (!block) return;
@@ -7404,6 +9484,9 @@
7404
9484
  // ordinal into.
7405
9485
  cancelBlockDrag();
7406
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);
7407
9490
  // §10-gap fix (review): this burst's block's "pristine" window (see
7408
9491
  // `pristineInsert`'s own comment) closes right here, the moment its
7409
9492
  // OWN first burst resolves — one way (a real edit, below) or the
@@ -7579,11 +9662,19 @@
7579
9662
  commitMd = runMd;
7580
9663
  commitStart = range.startLine;
7581
9664
  commitEnd = range.endLine;
9665
+ // v3.13.1 T1 fix round 3: the whole run is rewritten — including any
9666
+ // provisional item still in it, which then gets a line of its own and
9667
+ // pushes the items after it down one. Keys held for those items find
9668
+ // them by this map (see flightLineMapOf()). Measured without it: a
9669
+ // failed Enter render, then `ab` replayed into the new item, then the
9670
+ // blur commit of this run — `Z` held for charlie landed in `ab`.
9671
+ pendingFlightLineMap = flightLineMapOf(lineMeta, range.startLine);
7582
9672
  }
7583
9673
  const liCommitResult = (commitMd === '')
7584
9674
  ? commitRangeRemoval({ lines, blocks, stack }, commitStart, commitEnd)
7585
9675
  : commitRangeEdit({ lines, blocks, stack }, commitStart, commitEnd, commitMd);
7586
9676
  if (liCommitResult.op === null) {
9677
+ pendingFlightLineMap = null;
7587
9678
  endBurstWithoutResolve();
7588
9679
  return true;
7589
9680
  }
@@ -7637,8 +9728,14 @@
7637
9728
  // commit '#'.repeat(depth) + ' ' with nothing after the space. Every
7638
9729
  // non-heading burst's `depth` is null (blockDepthOf() only computes it for
7639
9730
  // 'heading'), so it takes the plain result.md branch.
7640
- const newText = burst.depth === null ? result.md :
7641
- (result.md === '' ? '#'.repeat(burst.depth) : '#'.repeat(burst.depth) + ' ' + result.md);
9731
+ // v3.13.1 T2 (F-1): a paragraph's text is escaped where it would read as
9732
+ // a block marker (`## `, `- `, `> `, `1. ` …), or the next render turns
9733
+ // the paragraph into that block. A heading's text sits after its own
9734
+ // `# ` and needs none; a table's rows START with `|`, so its markdown
9735
+ // must never reach the escape — hence the blockType test, not just depth.
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);
7642
9739
  // S1 removed the pre-per-li "a whole-list burst that serialized to ''"
7643
9740
  // branch that lived here alongside the dead 'list' serializer arm above.
7644
9741
  // The per-li equivalent — a run whose every item was removed — is handled
@@ -8193,11 +10290,30 @@
8193
10290
  if (raw === null) return null;
8194
10291
  const b = blocks.find((x) => x.id === Number(raw));
8195
10292
  if (!b) return null;
8196
- 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;
8197
10312
  }
8198
10313
  function reresolveBlockEl(identity) {
8199
10314
  if (!identity) return null;
8200
- const at = blocks.find((b) => b.startLine === identity.startLine);
10315
+ const startLine = identityStartLineNow(identity);
10316
+ const at = blocks.find((b) => b.startLine === startLine);
8201
10317
  if (!at || at.type !== identity.type || blockSourceOf(at) !== identity.source) return null;
8202
10318
  return document.querySelector('.ed-block[data-block-id="' + at.id + '"]');
8203
10319
  }
@@ -8354,7 +10470,7 @@
8354
10470
  async function commitListStructure(runEls, focusStartLine, caretToEnd, opts) {
8355
10471
  const span = runEls || [];
8356
10472
  const presetRange = opts && opts.presetRange;
8357
- const { md } = serializeListEls(span, { carryOver: (opts && opts.carryOver) || null });
10473
+ const { md, lineMeta } = serializeListEls(span, { carryOver: (opts && opts.carryOver) || null });
8358
10474
  // The run's line range is read back off its own li blocks' ids — which
8359
10475
  // requires at least one li to still BE there. A caller whose mutation
8360
10476
  // removed the run's last item therefore captures the range BEFORE mutating
@@ -8383,6 +10499,10 @@
8383
10499
  endLine: Math.max(result.op.startLine - 1, result.op.endLine),
8384
10500
  delta: result.op.after.length - result.op.before.length }
8385
10501
  : undefined;
10502
+ // v3.13.1 T1 fix round 2: where each item of the run will start once this
10503
+ // commit lands, so a key held for an item of this very run — one the
10504
+ // split pushed down a line, or the provisional item itself — finds it.
10505
+ if (result.op !== null) pendingFlightLineMap = flightLineMapOf(lineMeta, range.startLine);
8386
10506
  const ok = await safeRerenderAll(editRange);
8387
10507
  if (!ok) {
8388
10508
  lines = rollbackFailedRender({ lines, stack }, result, prevLines);
@@ -8395,12 +10515,59 @@
8395
10515
  // successful render (any later commit, undo, or redo) re-derives the DOM
8396
10516
  // from `lines`. Same convention as insertBlockBelow() /
8397
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);
8398
10526
  return false;
8399
10527
  }
8400
10528
  if (focusStartLine != null) focusBlockAtLine(focusStartLine, caretToEnd);
8401
10529
  return true;
8402
10530
  }
8403
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
+
8404
10571
  // The line `targetBlock`'s own marker will occupy once the run span it belongs
8405
10572
  // to is committed by commitListStructure() above.
8406
10573
  //
@@ -8584,6 +10751,34 @@
8584
10751
  // this unconditionally qualifies as "abandoned" (no separate
8585
10752
  // unchanged-check needed here, unlike resolveBurst()'s branch, where a
8586
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
+ }
8587
10782
  const wasPristineForThisBlock = !!(pristineInsert && pristineInsert.blockId === burst.blockId);
8588
10783
  if (wasPristineForThisBlock) pristineInsert = null;
8589
10784
  // T21 item 1: a pristine insert keeps the old, unrecoverable shape, and
@@ -8624,7 +10819,13 @@
8624
10819
  };
8625
10820
  currentBurst = null;
8626
10821
  resetSelToolbarState();
8627
- 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);
8628
10829
  }
8629
10830
 
8630
10831
  // The table twin of the stash above — a table burst's editEl is the whole
@@ -8679,6 +10880,11 @@
8679
10880
  : d.editEl;
8680
10881
  if (!entry) { d.history.dispose(); return false; }
8681
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
+ }
8682
10888
  if (!currentBurst || currentBurst.editEl !== d.editEl) { d.history.dispose(); return false; }
8683
10889
  currentBurst.history.dispose();
8684
10890
  currentBurst.history = d.history;
@@ -8776,9 +10982,47 @@
8776
10982
  const state = burst.history.undo();
8777
10983
  if (state !== null) {
8778
10984
  editEl.innerHTML = state;
10985
+ syncEmptyMark(editEl);
8779
10986
  placeCaretAtEnd(editEl); // best-effort — see the file-level caret-quirk note
8780
10987
  return;
8781
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
+ }
8782
11026
  // §10-gap fix (review): if this burst is an untouched pristine insert,
8783
11027
  // switchAwayFrom() below will itself auto-remove the block (resolveBurst()'s
8784
11028
  // pristineInsert branch) — that auto-remove IS the undo the user just
@@ -8800,6 +11044,7 @@
8800
11044
  const state = burst.history.redo();
8801
11045
  if (state !== null) {
8802
11046
  editEl.innerHTML = state;
11047
+ syncEmptyMark(editEl);
8803
11048
  placeCaretAtEnd(editEl);
8804
11049
  return;
8805
11050
  }
@@ -8849,6 +11094,19 @@
8849
11094
  //
8850
11095
  // `reason` is metadata only — createBurstHistory().snap() does not read it
8851
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
+
8852
11110
  function snapBurstIfActive(root, reason) {
8853
11111
  if (currentBurst && (currentBurst.editEl === root || currentBurst.editEl.contains(root))) {
8854
11112
  currentBurst.history.snap(reason);
@@ -8861,6 +11119,13 @@
8861
11119
  // wireBurstListeners() below), Shift+Enter inserts a <br> and snapshots
8862
11120
  // it, Escape reverts, Ctrl+Z/Y drive the burst-local history.
8863
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
+ }
8864
11129
  if (!currentBurst || currentBurst.editEl !== editEl) {
8865
11130
  // v2.11.1 acceptance, escape class B. This bail is reachable with the
8866
11131
  // surface STILL FOCUSED and still `.ed-wys-armed`: resolveBurst() nulls
@@ -8913,6 +11178,11 @@
8913
11178
  if (currentBurst.blockType === 'li') {
8914
11179
  if (handleLiKeydown(e, editEl)) return;
8915
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;
8916
11186
  // Task 6 — spec §3.5's other two rows. Tab is CONSUMED here: the
8917
11187
  // alternative is not "nothing happens", it is the browser's own focus
8918
11188
  // traversal walking the caret out of the document body, which is both a
@@ -8954,13 +11224,19 @@
8954
11224
  return;
8955
11225
  }
8956
11226
  if (e.key === 'Enter') {
8957
- e.preventDefault();
8958
11227
  if (e.shiftKey) {
11228
+ e.preventDefault();
8959
11229
  snapBurstIfActive(editEl, 'br-pre');
8960
11230
  insertBrAtCaret();
8961
11231
  snapBurstIfActive(editEl, 'br');
8962
- } 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();
8963
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);
8964
11240
  }
8965
11241
  return;
8966
11242
  }
@@ -9131,6 +11407,34 @@
9131
11407
  //
9132
11408
  // Returns the new block element, or null when the caret is not inside
9133
11409
  // `blockEl`'s own surface (nothing mutated).
11410
+ // v3.13.1 T1: the item splitListItemAtCaret() just built is a plain div
11411
+ // until the commit's render replaces it — not editable, so the caret stayed
11412
+ // in the OLD item and anything typed before the render landed went there.
11413
+ // Arm it the way armEditables() arms an li (the same two writes), focus it
11414
+ // and put the caret at its start, where the render's own
11415
+ // focusBlockAtLine() will put it again.
11416
+ // The focusin this raises starts no burst: Enter's burst is still current
11417
+ // here, so that handler's `if (currentBurst) return;` returns.
11418
+ function armProvisionalLi(newLi) {
11419
+ const textEl = liTextEl(newLi);
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();
11430
+ const range = document.createRange();
11431
+ range.selectNodeContents(el);
11432
+ range.collapse(true);
11433
+ const sel = window.getSelection();
11434
+ sel.removeAllRanges();
11435
+ sel.addRange(range);
11436
+ }
11437
+
9134
11438
  function splitListItemAtCaret(blockEl) {
9135
11439
  const sel = window.getSelection();
9136
11440
  if (!sel.rangeCount) return null;
@@ -9765,18 +12069,15 @@
9765
12069
 
9766
12070
  // Row 3, top-level press: spec §4 — "at top level the next press converts
9767
12071
  // the block to a paragraph". Markdown cannot persist an EMPTY paragraph, so
9768
- // this reuses the repo's existing §10 pristine-insert machinery instead of
9769
- // inventing a second empty-block representation: remove the li (commit #1,
9770
- // the run re-serialize), then insertBlockBelow() the paragraph skeleton
9771
- // anchored to whatever block now PRECEDES the removal point (commit #2). The
9772
- // user lands in a focused provisional paragraph that becomes real the moment
9773
- // anything is typed into it, and self-removes at zero net undo cost if
9774
- // abandoned (discardPristineInsert()).
9775
- //
9776
- // RULING F-J: this is therefore the ONE structural key press that is not a
9777
- // single undo op. Observed granularity (asserted in
9778
- // test/editor-client-runtime.test.js): Ctrl+Z #1 removes the provisional
9779
- // 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()).
9780
12081
  async function convertEmptyTopLevelLiToParagraph(runEls, li) {
9781
12082
  // Both captured BEFORE the mutation. The range, because removing the run's
9782
12083
  // last item leaves commitListStructure() nothing to derive it from. The
@@ -9819,20 +12120,136 @@
9819
12120
  const ok = await commitListStructure(survivors, null, false,
9820
12121
  { presetRange: range, carryOver: bystanderCarryOver(survivors) });
9821
12122
  if (!ok) return;
9822
- // Nothing precedes the removal point (the list opened the document):
9823
- // commitBlockInsertion() can only insert BELOW an existing block, so the
9824
- // paragraph step is skipped. The li removal still stands — no content is
9825
- // lost, the user just has to type where they want the paragraph.
9826
- 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; }
9827
12127
  const anchorEl = blockElAtLine(precedingBlock.startLine);
9828
12128
  if (!anchorEl) return;
9829
- 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;
9830
12242
  }
9831
12243
 
9832
12244
  // Returns true when the key was CONSUMED (Enter/Tab, incl. their no-op
9833
12245
  // outcomes); false lets handleBurstKeydown()'s shared Escape / Ctrl+Z /
9834
12246
  // Ctrl+Y branches run unchanged.
9835
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);
9836
12253
  if (e.key !== 'Enter' && e.key !== 'Tab') return false;
9837
12254
  e.preventDefault();
9838
12255
  // T21 item 2, the li half — see handleBurstKeydown()'s Tab branch. Ahead
@@ -9861,65 +12278,7 @@
9861
12278
  let run = listRunOf(li);
9862
12279
  if (!run.length) return true;
9863
12280
 
9864
- if (e.key === 'Tab') {
9865
- // `columnOnly`: an indent change rewrites nothing but leading columns, so
9866
- // a hard-wrapped item is allowed to be the target here — see
9867
- // listRunSupportsStructuralEdit()'s deviation note for the measurement.
9868
- if (!listRunSupportsStructuralEdit(run, li, { columnOnly: true })) {
9869
- refuseStructuralListEdit(); return true;
9870
- }
9871
- // Tab (spec §3.5): indentListItem() moves ONLY the caret item — its
9872
- // children keep their indent and become its siblings. Shift+Tab:
9873
- // outdentListItem() raises it one level, takes its own subtree with it,
9874
- // and adopts its former following same-level siblings. Both return false
9875
- // at their respective boundary (no previous sibling / already top level),
9876
- // which is a complete no-op — nothing mutated, nothing committed, file
9877
- // byte-identical.
9878
- const oldIndent = Number(li.getAttribute('data-indent')) || 0;
9879
- // INERT here: indentListItem()/outdentListItem() are attribute-only in
9880
- // this flat model (setBlockIndent() on `li` and its subtree — see the
9881
- // note on outdentListItem() further down this function) and never
9882
- // detach the focused node, so the synchronous blur/focusout this span
9883
- // exists to catch never fires, whether or not the flag is set.
9884
- // Measured: primed a burst on `li`, ran this mutation, and no
9885
- // focusout fired — `document.activeElement` stayed put and the node
9886
- // never left the document. Kept for uniformity with the load-bearing
9887
- // span in convertEmptyTopLevelLiToParagraph() above, not because this
9888
- // one guards anything measured.
9889
- const changed = mutateListRun(() => {
9890
- if (!(e.shiftKey ? outdentListItem(li) : indentListItem(li))) return false;
9891
- // v3.0.1: `run` above is a PRE-mutation snapshot, captured while `li`
9892
- // still had its OLD indent. That is fine for outdentListItem() — the
9893
- // clamp's model rereads every element's CURRENT data-indent off the
9894
- // DOM, so it self-corrects regardless of which elements the snapshot
9895
- // names — but indentListItem() can now move a rule-(d) list-start
9896
- // item (a bullet directly below an ordered item, say) whose own
9897
- // pre-mutation run is `[li]` alone: nothing shallower ever preceded
9898
- // it, because runBlocksOf() stops at a list-start by design. With no
9899
- // anchor IN the span, applyIndentClamp()'s rule 1 has nothing to
9900
- // bound the indent this call just wrote against, and clamps it
9901
- // straight back to 0 — silently undoing the write. A FRESH
9902
- // listRunOf(li) call here — taken after the write above — walks back
9903
- // from li's NEW indent and finds the real parent, exactly like the
9904
- // post-mutation `run = listRunOf(li)` a few lines below (the COMMIT
9905
- // span) already does; using the same fresh call for the clamp keeps
9906
- // the two consistent. Provably identical to the old stale `run` for
9907
- // every case where li was never a list-start (listRunOf() slices by
9908
- // INDEX RANGE, and neither the range's start/end index nor the
9909
- // elements between them shift when only li's own indent changes).
9910
- applyIndentClamp(listRunOf(li), li, oldIndent);
9911
- return true;
9912
- });
9913
- if (!changed) return true;
9914
- run = listRunOf(li);
9915
- // Column-only: nothing's CONTENT moved, so every block in the span —
9916
- // the target included — is a bystander whose source bytes must come
9917
- // back untouched. Built once and shared with runLineOfBlock() below,
9918
- // which indexes into the lines this very map decides.
9919
- const carry = bystanderCarryOver(run, null);
9920
- commitListStructure(run, runLineOfBlock(run, li, carry), true, { carryOver: carry });
9921
- return true;
9922
- }
12281
+ if (e.key === 'Tab') return liIndentStructure(li, run, e.shiftKey);
9923
12282
 
9924
12283
  // Enter.
9925
12284
  if (selectionSpansMultipleListItems()) {
@@ -10018,7 +12377,14 @@
10018
12377
  // splitListItemAtCaret() keeps `li` itself in place (only `newLi` is a
10019
12378
  // freshly-inserted sibling) and never touches the focused node's
10020
12379
  // attachment.
10021
- const newLi = mutateListRun(() => splitListItemAtCaret(li));
12380
+ // v3.13.1 T1: the new item is armed and focused inside the same span, so
12381
+ // the old item's focusout stays suppressed and the next keystroke is
12382
+ // already aimed at the new item while the commit below is in flight.
12383
+ const newLi = mutateListRun(() => {
12384
+ const n = splitListItemAtCaret(li);
12385
+ if (n) armProvisionalLi(n);
12386
+ return n;
12387
+ });
10022
12388
  if (!newLi) return true;
10023
12389
  run = listRunOf(newLi);
10024
12390
  // `li` had its text CUT IN TWO in the DOM; replaying its source would put
@@ -10945,8 +13311,27 @@
10945
13311
  while (n && !n.classList.contains('ed-block')) n = step(n);
10946
13312
  return n;
10947
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
+ }
10948
13332
  let dest = nextBlock(fromBlockEl);
10949
13333
  if (!dest) return;
13334
+ const destWasTransient = isTransient(dest);
10950
13335
  const srcStartLine = (blockRecOf(fromBlockEl) || {}).startLine;
10951
13336
  const destIdentity = captureBlockIdentity(dest);
10952
13337
  const ok = await switchAwayFrom();
@@ -10956,7 +13341,9 @@
10956
13341
  const liveSrc = srcRec
10957
13342
  ? document.querySelector('.ed-block[data-block-id="' + srcRec.id + '"]') : null;
10958
13343
  dest = (liveSrc && nextBlock(liveSrc)) || reresolveBlockEl(destIdentity);
10959
- 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; }
10960
13347
  }
10961
13348
  if (x !== null) arrowGoalX = x;
10962
13349
  arrowLandOn(dest, delta, x !== null ? x : arrowGoalX);
@@ -16067,6 +18454,9 @@
16067
18454
  // expression setDirty() paints the title dot from — not a second copy
16068
18455
  // of it.
16069
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,
16070
18460
  };
16071
18461
  }
16072
18462
 
@@ -16447,6 +18837,12 @@
16447
18837
  }
16448
18838
 
16449
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;
16450
18846
  const blockEl = liveToolbarBlock();
16451
18847
  switch (id) {
16452
18848
  // v3.4.0 §3: saveViaToolbar() — not save() directly. See its own
@@ -16483,8 +18879,8 @@
16483
18879
  case 'indent': return indentCaretLi(1);
16484
18880
  case 'table': return blockEl ? insertBlockBelow(blockEl, 'table') : undefined;
16485
18881
  case 'line': return blockEl ? insertBlockBelow(blockEl, 'line') : undefined;
16486
- case 'insert-after': return blockEl ? insertBlockBelow(blockEl, 'paragraph') : undefined;
16487
- 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;
16488
18884
  // backlog #6: imageViaToolbar() — not pickAndInsertImage() directly.
16489
18885
  // See its own comment (shared with undoViaToolbar()/redoViaToolbar())
16490
18886
  // for the caret rescue.
@@ -16598,6 +18994,7 @@
16598
18994
  // gate (convert-md.js's 'h1'..'h6' targets already carry the level), and a
16599
18995
  // heading gets a delta computed against its own current depth.
16600
18996
  async function applyHeadingLevel(n) {
18997
+ if (await toolbarOnTransient('h' + n, liveToolbarBlock())) return;
16601
18998
  const blockEl = liveToolbarBlock();
16602
18999
  if (!blockEl) return;
16603
19000
  if (blockEl.getAttribute('data-block-type') !== 'heading') {
@@ -16612,79 +19009,12 @@
16612
19009
  }
16613
19010
 
16614
19011
  // ── insert above ───────────────────────────────────────────────────────
16615
- // commitBlockInsertion() only ever inserts AFTER a block's range, so
16616
- // "insert above" is expressed the way this file already expresses it (see
16617
- // convertEmptyTopLevelLiToParagraph()'s own preceding-block lookup):
16618
- // anchor on the block before this one. That keeps one insertion path and
16619
- // one set of gates rather than teaching the pure core a direction.
16620
- async function insertBlockAbove(blockEl) {
16621
- const rec = blockRecOf(blockEl);
16622
- if (!rec) return;
16623
- const preceding = blocks
16624
- .filter((b) => b.id !== rec.id && b.endLine < rec.startLine && b.endLine >= b.startLine)
16625
- .pop();
16626
- if (preceding) {
16627
- const anchorEl = blockElAtLine(preceding.startLine);
16628
- if (anchorEl) { await insertBlockBelow(anchorEl, 'paragraph'); return; }
16629
- }
16630
- // Nothing above to anchor on — see insertParagraphAtTop(). Refusing here
16631
- // instead would leave the document's FIRST block permanently impossible
16632
- // to precede: the ⠿ and + menus both insert below, so this button is the
16633
- // only affordance that reaches that position at all.
16634
- await insertParagraphAtTop(blockEl);
16635
- }
16636
-
16637
- // The document's first block has no predecessor to insert BELOW, and
16638
- // commitBlockInsertion() only ever inserts after a block's range. Rather
16639
- // than teach the pure core a direction (a change under byte-stability and
16640
- // roundtrip fixtures), the insert is expressed as a REPLACE of that
16641
- // block's own first source line by [skeleton, '', that same line] — the
16642
- // same commitRangeEdit()/replaceLines() pipeline as every other edit,
16643
- // therefore one undo op, and no new primitive.
16644
- //
16645
- // The switchAwayFrom() + re-resolve preamble is insertBlockBelow()'s,
16646
- // for its reasons: the resolution can commit a dirty burst, whose
16647
- // rerenderAll() detaches the very node this was called with.
16648
- async function insertParagraphAtTop(blockEl) {
16649
- const identity = captureBlockIdentity(blockEl);
16650
- const selfSession = ownsOpenSession(blockEl);
16651
- const ok = await switchAwayFrom();
16652
- if (!ok) return;
16653
- let liveBlockEl = blockEl;
16654
- if (!document.body.contains(blockEl)) {
16655
- liveBlockEl = reresolveBlockEl(identity) ||
16656
- (selfSession ? reresolveBlockElAfterSelfCommit(identity) : null);
16657
- if (!liveBlockEl) { showError(DROPPED_GESTURE_MESSAGE, null, null); return; }
16658
- }
16659
- const rec = blockRecOf(liveBlockEl);
16660
- // A block that owns no source line has an inverted range, and there is
16661
- // no line to splice in front of.
16662
- if (!rec || rec.endLine < rec.startLine) {
16663
- refuseStructuralListEdit(noSourceLineInsertMessageFor(liveBlockEl));
16664
- return;
16665
- }
16666
- const at = rec.startLine;
16667
- const newText = BLOCK_SKELETONS.paragraph.concat(['', lines[at - 1]]).join('\n');
16668
- const result = commitRangeEdit({ lines, blocks, stack }, at, at, newText);
16669
- if (result.op === null) return;
16670
- const prevLines = lines;
16671
- lines = result.lines;
16672
- const editRange = result.op
16673
- ? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
16674
- endLine: Math.max(result.op.startLine - 1, result.op.endLine),
16675
- delta: result.op.after.length - result.op.before.length }
16676
- : undefined;
16677
- const okRender = await safeRerenderAll(editRange);
16678
- if (!okRender) {
16679
- lines = rollbackFailedRender({ lines, stack }, result, prevLines);
16680
- return;
16681
- }
16682
- // The new paragraph starts exactly where the old first line did — same
16683
- // pristine + focus contract insertBlockBelow() gives a skeleton insert.
16684
- const target = blocks.find((b) => b.startLine === at);
16685
- if (target) pristineInsert = { blockId: target.id };
16686
- await focusInsertedBlock(at, 'paragraph');
16687
- }
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.
16688
19018
 
16689
19019
  // ── 大綱 ───────────────────────────────────────────────────────────────
16690
19020
  // TWO attributes, chosen by viewport, and that is not an accident.
@@ -16893,6 +19223,12 @@
16893
19223
  if (i > 0) newLines.push('');
16894
19224
  newLines.push('![](' + markdownImageDest(p) + ')');
16895
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
+ }
16896
19232
  await insertBlockBelow(target, 'paragraph', { lines: newLines });
16897
19233
  }
16898
19234
 
@@ -17126,7 +19462,14 @@
17126
19462
  // serializeBlocks()'s existing gates, instead of arriving as a pile of
17127
19463
  // foreign nodes the serializer would have to make sense of afterwards.
17128
19464
  const pastedLines = md.split('\n');
17129
- 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',
17130
19473
  { lines: pastedLines, tight: pasteIsAllListLines(pastedLines) });
17131
19474
  return;
17132
19475
  }
@@ -17550,6 +19893,46 @@
17550
19893
  e.target.classList.contains('ed-raw');
17551
19894
  if (inTextarea) return; // Ctrl+Enter/Esc handled by openRawEditor()'s own per-instance listener
17552
19895
 
19896
+ // v3.13.1 T1: a key that edits or moves, pressed while a commit is in
19897
+ // flight, would act on DOM the render is about to replace. Held, in order
19898
+ // with the text around it, and replayed by flushFlightBuffer() through
19899
+ // this same handler (see `commitFlight`). An IME's own keys (isComposing)
19900
+ // are left alone. Escape drops everything held (R12) and then carries on
19901
+ // to its usual meaning. A standing table cell range keeps its own keys —
19902
+ // its Delete clears the range, which a held character delete would not.
19903
+ if (flightActive() && !e.isComposing && !cellRangeBurst()) {
19904
+ const flightSurface = flightSurfaceOf(e.target);
19905
+ if (flightSurface && e.key === 'Escape') { flightBuffer.length = 0; flightSelMoved = false; }
19906
+ // Ctrl/Cmd+Z with keys held: the most recent thing to undo is that
19907
+ // typing, which never reached the document — dropping it IS the undo,
19908
+ // and the commit in flight stays (the next Ctrl+Z, with nothing held,
19909
+ // undoes it as usual). Measured before: the undo reverted the Enter
19910
+ // underneath the held keys, and they replayed into the next item
19911
+ // (`abccharlie`). Held navigation (and a click's focus entry) is not
19912
+ // typing and stays; a dropped entry's position passes to the next kept
19913
+ // entry of its run.
19914
+ if (flightSurface && flightBuffer.some(isHeldTyping) && (e.ctrlKey || e.metaKey) && !e.shiftKey &&
19915
+ !e.altKey && (e.key === 'z' || e.key === 'Z')) {
19916
+ e.preventDefault();
19917
+ dropHeldTyping();
19918
+ return;
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
+ }
19928
+ if (flightSurface && flightHoldsKey(e)) {
19929
+ e.preventDefault();
19930
+ holdFlightEntry(flightSurface, { kind: 'key', key: e.key, shiftKey: e.shiftKey,
19931
+ ctrlKey: e.ctrlKey, altKey: e.altKey, metaKey: e.metaKey });
19932
+ return;
19933
+ }
19934
+ }
19935
+
17553
19936
  // Task 5: a table cell (class 'ed-wys-cell') owns its own Enter/Tab/
17554
19937
  // Escape/Ctrl+Z/Ctrl+Y contract, materially different from paragraph/
17555
19938
  // heading/list (Enter is an UNCONDITIONAL <br>, Tab moves the active
@@ -17590,6 +19973,8 @@
17590
19973
  // it either.
17591
19974
  const wysArmedEl = e.target && e.target.closest && e.target.closest('.ed-wys-armed');
17592
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);
17593
19978
  handleBurstKeydown(e, wysArmedEl);
17594
19979
  return;
17595
19980
  }
@@ -17789,8 +20174,17 @@
17789
20174
  // input/paste handling (class 'ed-wys-cell') is a separate branch at the
17790
20175
  // top of each listener below — see handleTableCellFocusIn() above.
17791
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
+ }
17792
20186
  const cellEl = e.target && e.target.closest && e.target.closest('.ed-wys-cell');
17793
- if (cellEl) { await handleTableCellFocusIn(cellEl); return; }
20187
+ if (cellEl) { await handleTableCellFocusIn(cellEl); scheduleFlightFlush(); return; }
17794
20188
  const editEl = e.target && e.target.closest && e.target.closest('.ed-wys-armed');
17795
20189
  if (!editEl) return;
17796
20190
  if (currentBurst && currentBurst.editEl === editEl) return; // already tracking
@@ -17800,8 +20194,33 @@
17800
20194
  // be resolving from the focusout that just preceded this focusin — see
17801
20195
  // switchAwayFrom()'s single-flight `switching`. Await it before
17802
20196
  // starting a new burst so the two never race a concurrent commit.
17803
- if (switching) await switching;
17804
- if (currentBurst) return; // a concurrent focusin already won the race
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
+ }
20213
+ // v3.13.1 T1 fix round 2: focus may have moved on while this waited — an
20214
+ // arrow walk lands on the NEXT block from inside that same resolution, and
20215
+ // its own focusin is queued behind this one. Starting a burst here would
20216
+ // open it on a surface that no longer has focus and leave the one that
20217
+ // does without one: measured on base and head, Enter, `ab`, ArrowDown, `Z`
20218
+ // put `Z` in charlie's DOM, and the blur that followed committed nothing.
20219
+ if (document.body.contains(editEl) && !editEl.contains(document.activeElement)) {
20220
+ scheduleFlightFlush();
20221
+ return;
20222
+ }
20223
+ if (currentBurst) { scheduleFlightFlush(); return; } // a concurrent focusin already won the race
17805
20224
  let liveEditEl = editEl;
17806
20225
  if (!document.body.contains(editEl)) {
17807
20226
  // The awaited resolution above committed successfully and swapped the
@@ -17809,13 +20228,28 @@
17809
20228
  // target. Re-resolve the equivalent LIVE node by block id and move
17810
20229
  // focus there for real — that re-enters this same handler
17811
20230
  // synchronously (switching is null by now), which starts the burst.
17812
- 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);
17813
20237
  liveEditEl = liveBlockEl ? blockContentEl(liveBlockEl) : null;
17814
20238
  if (!liveEditEl || !liveEditEl.classList.contains('ed-wys-armed')) return;
17815
20239
  liveEditEl.focus();
20240
+ // v3.13.1 R11: focus() alone puts the caret at the surface's start, not
20241
+ // where the click that brought focus here put it. Measured on base and
20242
+ // head, both engines: edit A, click mid-B, type — the characters landed
20243
+ // at B's start; a drag or double-click selection collapsed. The
20244
+ // selection was read just before the render replaced B (lastRenderSel).
20245
+ if (lastRenderSel && lastRenderSel.surface === editEl) {
20246
+ restoreSelection(liveEditEl, lastRenderSel.anchor, lastRenderSel.focus);
20247
+ }
20248
+ scheduleFlightFlush();
17816
20249
  return;
17817
20250
  }
17818
20251
  startBurst(liveEditEl);
20252
+ scheduleFlightFlush();
17819
20253
  });
17820
20254
 
17821
20255
  document.addEventListener('focusout', (e) => {
@@ -17956,6 +20390,110 @@
17956
20390
  return currentBurst.history;
17957
20391
  }
17958
20392
 
20393
+ // v3.13.1 T1: a pointer press into a surface during a flight moves the
20394
+ // selection the user means the next held key for (see `flightSelMoved`).
20395
+ // A press anywhere else — the toolbar, the margin — is not one.
20396
+ // Capture phase: this runs before the press moves the selection, which is
20397
+ // what pins the opening run to where it was typed (see pinFirstFlightRun()).
20398
+ document.addEventListener('pointerdown', (e) => {
20399
+ if (!flightActive()) return;
20400
+ pinFirstFlightRun(selectionSnapshotIn(activeFlightSurface()));
20401
+ if (flightSurfaceOf(e.target)) flightSelMoved = true;
20402
+ }, true);
20403
+
20404
+ // v3.13.1 T1: the input half of the commit-in-flight guard (see
20405
+ // `commitFlight`). Registered ahead of the native-format listener below,
20406
+ // both in the capture phase, so a held keystroke never reaches it.
20407
+ //
20408
+ // `isComposing` insertText is skipped: an engine that reports a
20409
+ // composition's commit that way also reports it to compositionend below,
20410
+ // and the text must be held once.
20411
+ function flightSurfaceOfEvent(e) {
20412
+ return flightActive() ? flightSurfaceOf(e.target) : null;
20413
+ }
20414
+ // The text compositionend just held. An engine that inserts a
20415
+ // composition's commit AFTER compositionend (WebKit's
20416
+ // insertFromComposition) would put it in the DOM a second time; that
20417
+ // insertion is cancelled, or taken back out on its input event when it
20418
+ // cannot be cancelled. Cleared on the next task: the follow-up arrives in
20419
+ // the same one or not at all.
20420
+ let composedHeld = null;
20421
+ let composedTakeBack = null;
20422
+ document.addEventListener('beforeinput', (e) => {
20423
+ if (composedHeld !== null && e.data === composedHeld &&
20424
+ (e.inputType === 'insertFromComposition' || e.inputType === 'insertText')) {
20425
+ composedHeld = null;
20426
+ if (e.cancelable) e.preventDefault(); else composedTakeBack = e.data;
20427
+ return;
20428
+ }
20429
+ const surface = flightSurfaceOfEvent(e);
20430
+ if (!surface) return;
20431
+ const t = e.inputType;
20432
+ if ((t === 'insertText' || t === 'insertReplacementText') && !e.isComposing) {
20433
+ // insertReplacementText (autocorrect, spellcheck) carries its text in
20434
+ // dataTransfer in some engines and in `data` in others.
20435
+ const data = e.data != null ? e.data
20436
+ : (e.dataTransfer && e.dataTransfer.getData('text/plain')) || '';
20437
+ e.preventDefault();
20438
+ if (data) holdFlightEntry(surface, { kind: 'text', data });
20439
+ return;
20440
+ }
20441
+ // A deletion that did not come from a held key (the keyboard ones are
20442
+ // held at keydown — WebKit fires no beforeinput at all for Backspace on
20443
+ // an empty surface, measured): a context-menu delete, say.
20444
+ if (/^delete/.test(t) && t !== 'deleteByCut' && t !== 'deleteByDrag' && t !== 'deleteCompositionText') {
20445
+ e.preventDefault();
20446
+ holdFlightEntry(surface, { kind: 'delete', inputType: t });
20447
+ }
20448
+ }, true);
20449
+ document.addEventListener('input', (e) => {
20450
+ if (composedTakeBack !== null && e.data === composedTakeBack) takeBackComposedText(e.data);
20451
+ composedTakeBack = null;
20452
+ }, true);
20453
+ // Known limit: a composition still open when the flight ends is left to
20454
+ // the browser — its compositionend arrives with no flight to hold it, and
20455
+ // if the render replaced the surface mid-composition, what the engine does
20456
+ // with the rest of it is its own.
20457
+ document.addEventListener('compositionend', (e) => {
20458
+ const surface = flightSurfaceOfEvent(e);
20459
+ if (!surface || !e.data) return;
20460
+ takeBackComposedText(e.data);
20461
+ holdFlightEntry(surface, { kind: 'text', data: e.data });
20462
+ composedHeld = e.data;
20463
+ setTimeout(() => { composedHeld = null; }, 0);
20464
+ }, true);
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
+
17959
20497
  // Capture phase, matching the phase the ordering above was measured in.
17960
20498
  document.addEventListener('beforeinput', (e) => {
17961
20499
  if (!NATIVE_FORMAT_INPUT_TYPES.has(e.inputType)) return;
@@ -18022,10 +20560,35 @@
18022
20560
  // wave field is not — this is here so the boundary is one predicate rather
18023
20561
  // than a property of which targets those two selectors happen to match.
18024
20562
  if (waveEditorIsModal()) return;
18025
- const cellEl = e.target && e.target.closest && e.target.closest('.ed-wys-cell');
18026
- if (cellEl) {
20563
+ // v3.13.1 T1 fix round 1: a paste while a commit is in flight is held like
20564
+ // typing (measured before: lost outright) and replayed through
20565
+ // routePaste(), the path a live paste takes. The clipboard is readable
20566
+ // only during the event, so its flavours are copied out here.
20567
+ const flightSurface = flightSurfaceOfEvent(e);
20568
+ if (flightSurface) {
18027
20569
  e.preventDefault();
18028
- const dt = e.clipboardData || window.clipboardData;
20570
+ holdFlightEntry(flightSurface, { kind: 'paste',
20571
+ dt: snapshotClipboard(e.clipboardData || window.clipboardData), plainOnly: plainPasteArmed });
20572
+ plainPasteArmed = false;
20573
+ return;
20574
+ }
20575
+ const target = e.target && e.target.closest &&
20576
+ (e.target.closest('.ed-wys-cell') || e.target.closest('.ed-wys-armed'));
20577
+ if (!target) return;
20578
+ e.preventDefault();
20579
+ routePaste(target, e.clipboardData || window.clipboardData);
20580
+ });
20581
+ function snapshotClipboard(dt) {
20582
+ const data = {};
20583
+ ['text/plain', 'text/html'].forEach((k) => {
20584
+ try { data[k] = dt ? dt.getData(k) || '' : ''; } catch (err) { data[k] = ''; }
20585
+ });
20586
+ const files = dt && dt.files ? Array.prototype.slice.call(dt.files) : [];
20587
+ return { files, types: Object.keys(data).filter((k) => data[k]), getData: (k) => data[k] || '' };
20588
+ }
20589
+ function routePaste(target, dt) {
20590
+ const cellEl = target.closest('.ed-wys-cell');
20591
+ if (cellEl) {
18029
20592
  const grid = dt ? clipboardGrid(dt) : null;
18030
20593
  if (grid && pasteGridIntoTable(cellEl, grid)) return;
18031
20594
  snapBurstIfActive(cellEl.closest('table'), 'paste-pre');
@@ -18033,10 +20596,8 @@
18033
20596
  snapBurstIfActive(cellEl.closest('table'), 'paste');
18034
20597
  return;
18035
20598
  }
18036
- const editEl = e.target && e.target.closest && e.target.closest('.ed-wys-armed');
20599
+ const editEl = target.closest('.ed-wys-armed');
18037
20600
  if (!editEl) return;
18038
- e.preventDefault();
18039
- const dt = e.clipboardData || window.clipboardData;
18040
20601
  // v3.1.0 追加 2: paste-md.js decides what the clipboard actually holds
18041
20602
  // (text/html -> markdown, an image, or plain text) and this routes the
18042
20603
  // answer. The TABLE-CELL branch above deliberately keeps its plain-text
@@ -18044,7 +20605,7 @@
18044
20605
  // pasted into one cell would be a nested structure the serializer has no
18045
20606
  // way to represent.
18046
20607
  handleArmedPaste(dt, editEl);
18047
- });
20608
+ }
18048
20609
 
18049
20610
  // Task 5: hover-edge insert bubbles — one delegated, rAF-throttled
18050
20611
  // mousemove listener (never a per-block/per-boundary listener) drives
@@ -19787,4 +22348,9 @@
19787
22348
  // selToolbar, …) is initialised and the bar's first deriveState() sees the
19788
22349
  // armed document rather than an empty one.
19789
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();
19790
22356
  })();