@helping-ai-workflow/md2doc 3.2.0 → 3.2.1

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.
@@ -1023,6 +1023,31 @@
1023
1023
  // property before we are called; it is a PRECONDITION of everything
1024
1024
  // below, not something this function re-derives.
1025
1025
  const children = Array.prototype.slice.call(contentEl.children);
1026
+ // v3.2.1: 「被改寫區塊內的游標」焦點修復。動機來自 undo/redo 的量測——
1027
+ // undo/redo 並非普遍掉焦點:游標在被改寫區間【之外】時 activeElement /
1028
+ // 所屬 block / caret offset 全部存活(連一次讓自己 data-block-id 8→7 的
1029
+ // 結構性 undo 都扛得住);只有游標落在被改寫區塊【內】才掉到 BODY,之後
1030
+ // 打字被靜默丟棄。所以判準是位置,不是操作種類,也完全不需要 op→block
1031
+ // 映射(實測 op.startLine 在 14 個實例中只有 4 個正確,對 move / insert
1032
+ // 都指向沒有擁有者的空行——該設計已退掉)。
1033
+ //
1034
+ // ⚠ 觸發面不是「只有 undo/redo」:任何一次 patch 只要聚焦的 block 落在
1035
+ // replaceSpan 內就會走到,fix round 1 的 review 點名了兩類非 undo 的呼叫者——
1036
+ // * 一般的 burst 提交:endBurstWithoutResolve() 不 blur,焦點還留在那個
1037
+ // 即將被換掉的面上;
1038
+ // * 帶 declaredRange 的批次結構操作:焦點坐在 `.ed-block` 外框上(roving
1039
+ // tabindex)。
1040
+ // 這兩類上還原完之後常常會被下面 applySelectionClasses() 自己的
1041
+ // `focusEl.focus()` 立刻覆蓋掉(它把焦點搬回選取的 focus block),也就是多
1042
+ // 一趟 focus 往返而已;journey 案例 3 / 4 / 6 走的正是這些路徑,皆綠。
1043
+ //
1044
+ // 必須在 DOM 手術之前算:removeChild 之後 activeElement 已經是 BODY,
1045
+ // 而 `children` 是 patch 前的快照,也是 plan 的索引所指的那份清單。
1046
+ const focusedBlockEl = document.activeElement &&
1047
+ document.activeElement.closest ? document.activeElement.closest('.ed-block') : null;
1048
+ const ci = focusedBlockEl ? children.indexOf(focusedBlockEl) : -1;
1049
+ const focusWasInSpan = ci >= 0 &&
1050
+ ci >= plan.replaceSpan.oldStart && ci <= plan.replaceSpan.oldEnd;
1026
1051
  // Fix round 1 (review Important 1): `blocks`/`lastParts` are the JOIN KEY
1027
1052
  // between the module's model and the DOM, and they are overwritten BEFORE
1028
1053
  // the DOM is touched — armEditables() reads the new `blocks` (see its call
@@ -1109,6 +1134,53 @@
1109
1134
  return applyFullRender(j, window.scrollY, declaredRange);
1110
1135
  }
1111
1136
 
1137
+ if (focusWasInSpan) {
1138
+ // 落在被置換區間內的游標,其節點已經被移除。把焦點放到對應的新節點
1139
+ // (夾在新中段的範圍內),caret 置尾。
1140
+ //
1141
+ // 位置刻意在 teardown 之前,而真正的機制是 fix round 1 的 review 查出來
1142
+ // 的這一條(第一版註解寫的「靠收尾那段焦點檢查認養」是錯的,那段在這條
1143
+ // 路徑上是死碼):`surface.focus()` 會【同步】觸發 document 層那個委派的
1144
+ // focusin。undo() 早已 await 完 switchAwayFrom(),所以 `switching` 與
1145
+ // `currentBurst` 都是 null——focusin 的兩個 early-return 都不成立——而新
1146
+ // 節點在 insertBefore 之前就被 armEditables(tpl.content) 武裝過了,於是
1147
+ // startBurst() 當場就跑完。打字有人追蹤、Ctrl+S 才寫得進檔案(實測拿掉
1148
+ // 這一塊時 undo 之後打的字連磁碟都到不了)。收尾最後那段
1149
+ // `if (!currentBurst && …)` 的焦點檢查因此在這條路徑上恆為 false。
1150
+ //
1151
+ // 代價要說清楚:下面的 `burstSurvived` 是在這裡【之後】才求值的,所以在
1152
+ // 走到這一塊的 render 上,它量到的是「剛剛由還原開出來的那個 burst」,
1153
+ // 而不是它註解所寫的「patch 前就開著、而且面活過 patch」。受影響的是三個
1154
+ // 以它為閘門的收尾項:currentBurst 的 dispose、resetSelToolbarState()、
1155
+ // resetToolbarBlock()——三者都會改走 survived 分支。結果是好的(剛開的
1156
+ // burst 不被丟掉、startBurst() 剛掛上的 selectionchange listener 不被拆、
1157
+ // toolbarBlockEl 保住 focusin 剛設好的活節點),但那是巧合而不是設計,
1158
+ // 所以在 `burstSurvived` 那裡也留了一行對照註解。不搬 `burstSurvived`
1159
+ // 到還原之前,是刻意的取捨:搬了會讓剛開的 burst 先被 dispose 再由收尾
1160
+ // 重開一次,並把每次 undo 的工具列 degrade 原封不動地留著。
1161
+ // teardown 十二項彼此的相對順序沒有被動到,條件式的 activeEditor 歸零
1162
+ // 仍然在 DOM 手術之後求值。
1163
+ const newLen = plan.replaceSpan.newParts.length;
1164
+ if (newLen > 0) {
1165
+ // landing 恆落在新中段內:ci >= oldStart 保證非負,min() 夾住上界,
1166
+ // 而 patch 後的子節點是 prefix(keepPrefix) + 新中段(newLen) + suffix。
1167
+ // 零寬區間(insert,oldEnd === oldStart - 1)根本進不了 focusWasInSpan。
1168
+ const landing = plan.keepPrefix + Math.min(ci - plan.replaceSpan.oldStart, newLen - 1);
1169
+ const el = contentEl.children[landing];
1170
+ const surface = el ? blockContentEl(el) : null;
1171
+ if (surface) {
1172
+ surface.focus();
1173
+ try {
1174
+ const r = document.createRange();
1175
+ r.selectNodeContents(surface);
1176
+ r.collapse(false);
1177
+ const s = window.getSelection();
1178
+ s.removeAllRanges(); s.addRange(r);
1179
+ } catch (e) { /* best-effort,同 focusBlockAtLine 的處置 */ }
1180
+ }
1181
+ }
1182
+ }
1183
+
1112
1184
  // ── teardown ────────────────────────────────────────────────────────
1113
1185
  // Controller ruling F1: the patch path runs the SAME twelve teardown items
1114
1186
  // applyFullRender() runs, unconditionally and in the same relative order.
@@ -1219,6 +1291,18 @@
1219
1291
  // 的**相對順序**與 applyFullRender() 一致,把 resetSelToolbarState() /
1220
1292
  // resetToolbarBlock() 搬到第 3 項旁邊才是真的改了順序。條件只算一次,
1221
1293
  // 而且算在會動到 currentBurst 的那一項之前(算完才輪得到把它歸零)。
1294
+ // ⚠ v3.2.1 fix round 1:這個節點 identity 判準本身沒變,但它求值的時機
1295
+ // 現在落在上面那塊焦點還原【之後】。還原的 `surface.focus()` 在
1296
+ // switchAwayFrom() 已經結算完的路徑上(undo()/redo())會【同步】開出一個新
1297
+ // 的 burst(見那裡的註解),所以在那種 render 上這裡量到的 true 說的是
1298
+ // 「現在有一個 burst,而且它的面在文件上」,不是「patch 前那個 burst 活下
1299
+ // 來了」。下面三個以它為閘門的項因此會走 survived 分支。實測(undo 走
1300
+ // patch 路徑):收尾後工具列 disabled 停在 7/22,不是 resetToolbarBlock()
1301
+ // 跑過該有的 18/22 —— 也就是 resetToolbarBlock() 確實被跳過了,這是
1302
+ // burstSurvived 為 true 的直接證據。反過來,若還原當下 currentBurst 還沒被
1303
+ // 歸零(別條路徑),focusin 的 `if (currentBurst) return;` 會擋掉開 burst,
1304
+ // 這裡照舊量到「舊 burst 的面被拔掉了」=false,收尾最後那段焦點檢查才是
1305
+ // 接手的人。兩種情形都不需要這一項改判準。
1222
1306
  const burstSurvived = !!(currentBurst && currentBurst.editEl
1223
1307
  && document.body.contains(currentBurst.editEl));
1224
1308
  // Task 2 (Phase 3): same belt-and-braces reasoning for the always-on
@@ -1284,11 +1368,10 @@
1284
1368
  // whose focus never leaves kills every block-dependent toolbar button until
1285
1369
  // the user clicks away and back (MEASURED on the smoke fixture: disabled
1286
1370
  // goes 7/22 -> 18/22, and nothing calls updateToolbar() again until the
1287
- // next click). It has THREE further side effects, and all
1288
- // three ride along safely: (1) the `docMode === 'preview'` re-arm cannot
1289
- // matter here — preview sets every surface contenteditable="false", so no
1290
- // burst can be open to survive; (2) updateToolbar() is re-derived from the
1291
- // DOM on the next interaction; (3) closeToolbarMenu() — skipping it leaves
1371
+ // next click). It has TWO further side effects (v3.2.1: the third was
1372
+ // preview mode's contenteditable re-arm, removed with the mode itself),
1373
+ // and both ride along safely: (1) updateToolbar() is re-derived from the
1374
+ // DOM on the next interaction; (2) closeToolbarMenu() — skipping it leaves
1292
1375
  // the H▾ dropdown standing across a patch, which is benign because
1293
1376
  // `toolbarMenuEl` is appended to document.body, not into a block, so the
1294
1377
  // patch never detaches it, and applyHeadingLevel() re-resolves through
@@ -1428,20 +1511,24 @@
1428
1511
  async function applyFullRender(j, scrollY, declaredRange) {
1429
1512
  blocks = j.blocks;
1430
1513
  lastParts = j.parts; // v3.2.0: 供下一次 patch 當 oldParts
1431
- contentEl.innerHTML = j.parts.join('\n'); // fallback 路徑用 join 還原
1432
- // Task 2 (Phase 3): re-arm every WYSIWYG-eligible paragraph/heading (and
1433
- // attach a ⠿ handle to every non-table block) in the freshly-swapped
1434
- // DOM — see armEditables() below. Must run before anything else touches
1435
- // the fresh nodes (diagram re-init, reader rebind, focus restoration).
1436
- armEditables(contentEl);
1514
+ // v3.2.1 R2: 必須先於 innerHTML 寫入。這個寫入 detach 掉聚焦的 raw
1515
+ // textarea,同步 focusout 重入 switchAwayFrom() → resolveOpenSession() →
1516
+ // commitNow(),於是同一次編輯提交兩次(實測衍生新 block 時磁碟上文字出現
1517
+ // 兩次;停在同一個 block 內則第二次回 op === null 退化成取消,所以看不出來)。
1437
1518
  // Whatever editor (if any) was open a moment ago just got detached by
1438
- // the innerHTML replacement above — its own restore()/commit() never
1519
+ // the innerHTML replacement below — its own restore()/commit() never
1439
1520
  // ran, so it never got a chance to null this out itself. Do it here,
1440
1521
  // unconditionally, on every successful swap: this is what makes the
1441
1522
  // undo/redo lockout regression (see the `activeEditor` comment above)
1442
1523
  // structurally impossible even from a call site that forgets to call
1443
1524
  // switchAwayFrom() first.
1444
1525
  activeEditor = null;
1526
+ contentEl.innerHTML = j.parts.join('\n'); // fallback 路徑用 join 還原
1527
+ // Task 2 (Phase 3): re-arm every WYSIWYG-eligible paragraph/heading (and
1528
+ // attach a ⠿ handle to every non-table block) in the freshly-swapped
1529
+ // DOM — see armEditables() below. Must run before anything else touches
1530
+ // the fresh nodes (diagram re-init, reader rebind, focus restoration).
1531
+ armEditables(contentEl);
1445
1532
  // §10-gap fix (review): same belt-and-braces reasoning for
1446
1533
  // `pristineInsert` — its window is meant to close via one of the
1447
1534
  // explicit resolution hooks (resolveBurst()/revertBurstAndEnd()/
@@ -1764,12 +1851,17 @@
1764
1851
  await safeRerenderAll();
1765
1852
  return;
1766
1853
  }
1767
- blockEl.innerHTML = original;
1854
+ // v3.2.1 R1: 清 activeEditor 必須先於這個 innerHTML 寫入。寫入會 detach
1855
+ // 聚焦的 textarea,Chromium 同步派發 focusout,而 focusout handler 只要
1856
+ // activeEditor 非 null 就會走 switchAwayFrom() → resolveOpenSession() →
1857
+ // commitNow(),把使用者按 Escape 要丟棄的文字提交進檔案(實測落到磁碟)。
1858
+ // 同一函式的 rangeMode 分支早就是這個順序,理由相同。
1768
1859
  // Only clear `activeEditor` if it's still THIS block's entry — it may
1769
1860
  // already have been cleared out from under us (e.g. by rerenderAll()'s
1770
1861
  // own defensive reset on a successful swap elsewhere), and this must
1771
1862
  // never stomp some OTHER block's activeEditor set after this one.
1772
1863
  if (activeEditor && activeEditor.blockEl === blockEl) activeEditor = null;
1864
+ blockEl.innerHTML = original;
1773
1865
  // No re-wiring needed here (unlike the old per-block gutter): block
1774
1866
  // selection / the edit bar are handled by ONE delegated `document`
1775
1867
  // click listener (see wireBlockSelection() below), so this
@@ -2576,7 +2668,51 @@
2576
2668
  // switchAwayFrom() first resolves any editor that's currently open on this
2577
2669
  // (or another) block, same precondition as undo()/redo() below, so this
2578
2670
  // never races a concurrent commit or operates on stale `lines`/`blocks`.
2671
+ //
2672
+ // v3.2.1 final wave: THIN WRAPPER + `finally`, the same shape the other four
2673
+ // sinks use, because this function's own early exits were live holes in
2674
+ // exactly the promise this version's CHANGELOG makes. MEASURED on ddac6ce
2675
+ // with real keystrokes, three runs each: typing in `# Title` then Shift+Tab
2676
+ // went H1.ed-wys-armed / 15 enabled → BODY / 4 enabled, and `###### Title`
2677
+ // then Tab did the same; `## Title` + Tab (the unclamped control) stayed
2678
+ // H3.ed-wys-armed / 15. The clamp is reachable from an ordinary gesture:
2679
+ // handleBurstKeydown()'s `changeHeadingDepth(currentBurst.blockEl,
2680
+ // e.shiftKey ? -1 : 1)` passes a raw ±1 with no `delta === 0` filter (unlike
2681
+ // applyHeadingLevel(), which computes a delta and can hand over 0), so Tab
2682
+ // at either end of H1..H6 lands on `newDepth === curDepth` — an early return
2683
+ // that sits BELOW the switchAwayFrom() that already committed and re-rendered
2684
+ // and ABOVE the restore at the tail.
2685
+ //
2686
+ // The body answers a line on every exit it can name one for:
2687
+ // * success result.op.startLine (the commit's own first
2688
+ // written line — see the note at the tail)
2689
+ // * newDepth === curDepth block.startLine
2690
+ // * result.op === null block.startLine
2691
+ // `block` is looked up out of `blocks` AFTER switchAwayFrom(), so its
2692
+ // startLine is already a post-render coordinate. INSTRUMENTED at that exit
2693
+ // rather than reasoned: on `# Title` typed into and then Shift+Tab-ed, the
2694
+ // clamped return printed
2695
+ // {sameBlocksArray:false, sameNode:false, blockId:0, startLine:1,
2696
+ // resolvesToId:0, resolvedText:'TitleX'}
2697
+ // — switchAwayFrom() had replaced both the `blocks` array and the heading's
2698
+ // DOM node, and `block.startLine` still resolved to the LIVE block carrying
2699
+ // the just-committed text, which is exactly what focusBlockAtLine() needs.
2700
+ //
2701
+ // The two re-query failures above it (`blockEl` gone, `block` not in
2702
+ // `blocks`) deliberately answer nothing: there is no block left to aim at,
2703
+ // and guessing a line is worse than restoring no caret.
2579
2704
  async function changeHeadingDepth(blockEl, delta) {
2705
+ let focusLine = null;
2706
+ const anchor = {};
2707
+ try {
2708
+ const out = await changeHeadingDepthBody(blockEl, delta, anchor);
2709
+ if (out != null) focusLine = out;
2710
+ } finally {
2711
+ restoreAfterStructuralOp(focusLine, anchor.line);
2712
+ }
2713
+ }
2714
+
2715
+ async function changeHeadingDepthBody(blockEl, delta, anchor) {
2580
2716
  if (!blockEl) return;
2581
2717
  const blockId = Number(blockEl.getAttribute('data-block-id'));
2582
2718
  // Commits (never discards) whatever burst/editor is open first — same
@@ -2609,6 +2745,9 @@
2609
2745
  }
2610
2746
  const block = blocks.find((b) => b.id === blockId);
2611
2747
  if (!block) return;
2748
+ // Post-switchAwayFrom(), post-render: the landing for every exit below
2749
+ // that writes nothing. See restoreAfterStructuralOp().
2750
+ if (anchor) anchor.line = block.startLine;
2612
2751
  const curLine = lines[block.startLine - 1];
2613
2752
  const curDepth = headingDepthOf(curLine);
2614
2753
  const newDepth = Math.max(1, Math.min(6, curDepth + delta));
@@ -2627,6 +2766,28 @@
2627
2766
  if (!okRender) {
2628
2767
  lines = rollbackFailedRender({ lines, stack }, result, prevLines);
2629
2768
  }
2769
+ // v3.2.1 (controller ruling T5-1): the OTHER sink. H▾ on a block that is
2770
+ // ALREADY a heading routes HERE, not to convertBlockViaMenu() —
2771
+ // applyHeadingLevel() takes that road only for a NON-heading — so without
2772
+ // this, H2 → H4 still ends the way every conversion used to: the render's
2773
+ // teardown ran resetToolbarBlock(), activeElement is BODY and the bar is
2774
+ // down to 4 enabled buttons, and a second operation needs a fresh click.
2775
+ //
2776
+ // Same predicate and the same measurement as convertBlockViaMenu()'s exit
2777
+ // — the wrapper's restoreAfterStructuralOp() is now literally the same
2778
+ // function, so see its note for why it is `blockSelection` and not operand
2779
+ // cardinality, and why forcing a caret while a selection stands
2780
+ // manufactures a broken state rather than fixing one.
2781
+ //
2782
+ // The line is `result.op.startLine`, the commit's OWN first written line:
2783
+ // commitEdit() resolves the block and hands commitRangeEdit() that
2784
+ // startLine, which is where replaceLines() writes `after`. This function
2785
+ // declares no collapse of its own (it is not a set operation), so the
2786
+ // commit result is the only post-commit value it holds — and it is a
2787
+ // RESULT, not a pre-commit `block.startLine` capture. An insertion above
2788
+ // this block cannot have happened between the two, because the commit IS
2789
+ // the only thing that ran.
2790
+ return result.op.startLine;
2630
2791
  }
2631
2792
 
2632
2793
  // ── Task 2 (Phase 3): always-on WYSIWYG editing + burst undo ───────────
@@ -3566,6 +3727,26 @@
3566
3727
  const target = blocks.find((b) => b.startLine === result.newStartLine);
3567
3728
  if (target) pristineInsert = { blockId: target.id };
3568
3729
  await focusInsertedBlock(result.newStartLine, kind);
3730
+ } else if (kind === 'line') {
3731
+ // v3.2.1: <hr> 沒有 placeholder,所以不 focusInsertedBlock()(那是上面
3732
+ // 那個 `kind !== 'line'` 既有的刻意 opt-out)。這一支只做工具列復原:把
3733
+ // toolbarBlockEl 指向新插入的 hr block,讓 bar 不要停在 render teardown
3734
+ // 留下的 4 顆。
3735
+ //
3736
+ // ⚠ 明說量測到的事,不要說希望發生的事:**caret 沒有被還原**。實測
3737
+ // (alpha / bravo / charlie,點進 bravo 再按 分隔線)這一支停用時是
3738
+ // {activeElement: BODY, enabled: 4}、啟用後是 {activeElement: BODY,
3739
+ // enabled: 15},兩者磁碟 byte-identical。也就是說使用者按完分隔線仍然
3740
+ // 沒有游標,要再點一次 block 才能繼續打字 —— 工具列復原就是這一支交付的
3741
+ // 全部。要連 caret 一起還原是另一個決定(游標該落在錨點 block 還是 hr
3742
+ // 之後的 block),不在本 task 的範圍內。
3743
+ //
3744
+ // Final wave, M4: gated on `blockSelection` like the other four restore
3745
+ // sites. Harmless today — a + 分隔線 pressed with a set standing writes
3746
+ // no declareCollapse(), so re-aiming the bar at the hr cannot contradict
3747
+ // a tinted set the way a forced caret would — but "the one site that does
3748
+ // not ask the question" is how the next divergence gets in.
3749
+ if (!blockSelection) reaimToolbarBlockAtLine(result.newStartLine);
3569
3750
  }
3570
3751
  }
3571
3752
 
@@ -3685,7 +3866,34 @@
3685
3866
  // already been raised — otherwise `{ els, recs, batch }`: the LIVE block
3686
3867
  // elements in document order, their records out of `blocks` (by reference),
3687
3868
  // and whether more than one block is being operated on.
3688
- async function resolveGutterOperands(blockEl) {
3869
+ //
3870
+ // v3.2.1 final wave: `anchor` is an optional OUT-PARAMETER. When given, its
3871
+ // `.line` is set to the grip block's start line as soon as that block has
3872
+ // been re-resolved against the post-switchAwayFrom() `blocks` — i.e. a
3873
+ // number that is already valid on the freshly rendered DOM. Every refusal
3874
+ // BELOW that assignment therefore leaves the caller holding a live
3875
+ // coordinate for a block the refusal did not touch.
3876
+ //
3877
+ // FIVE exits sit ABOVE it and all of them leave `.line` undefined, which is
3878
+ // the honest answer in each case (N3 — the first version of this sentence
3879
+ // said "the two exits", the same undercount M2 existed to fix, so they are
3880
+ // counted out here one by one):
3881
+ // 1. `if (!blockEl) return null` — no gesture at all.
3882
+ // 2. `if (!ok) return null` — switchAwayFrom()'s commit FAILED
3883
+ // and the session it could not commit is still open with its own
3884
+ // banner; aiming a caret anywhere would fight it.
3885
+ // 3. the dropped-gesture re-resolve — the block is genuinely gone.
3886
+ // 4. the no-source-line refusal — the block is there, but its range
3887
+ // is inverted (endLine === startLine - 1), and
3888
+ // `blocks.find(b => b.startLine === …)` would resolve some OTHER block.
3889
+ // 5. `if (!rec)` (a second DROPPED_GESTURE) — the one the undercount lost.
3890
+ // It is NOT absorbed by 4: blockOwnsNoLine() is `!!rec && rec.endLine <
3891
+ // rec.startLine`, so it answers FALSE when the id is absent from
3892
+ // `blocks`, and it also answers false for a provisional block whose
3893
+ // `data-block-id` is null (blockRecOf() returns null for both). Either
3894
+ // way there is no record to read a startLine off.
3895
+ // See restoreAfterStructuralOp().
3896
+ async function resolveGutterOperands(blockEl, anchor) {
3689
3897
  if (!blockEl) return null;
3690
3898
  // Finding 5a's mousedown preventDefault() deliberately keeps a dirty burst
3691
3899
  // alive across the ⠿ press, so the commit that lands inside
@@ -3711,6 +3919,7 @@
3711
3919
  if (blockOwnsNoLine(liveBlockEl)) { refuseStructuralListEdit(NO_SOURCE_LINE_MESSAGE); return null; }
3712
3920
  const rec = blockRecOf(liveBlockEl);
3713
3921
  if (!rec) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return null; }
3922
+ if (anchor) anchor.line = rec.startLine;
3714
3923
 
3715
3924
  const res = selectionLib
3716
3925
  ? selectionLib.resolveMembership(blockSelection, blocks, rec)
@@ -3884,7 +4093,148 @@
3884
4093
  return run;
3885
4094
  }
3886
4095
 
4096
+ // ── v3.2.1 final wave: ONE landing for all four structural sinks ────────
4097
+ //
4098
+ // The v3.2.1 wrappers restored only on the SUCCESS line the body answered.
4099
+ // Measured on ddac6ce, 轉換成 → 引用 on a hard-wrapped list item (an ENABLED
4100
+ // menu item — `disabled === false`, measured) whose own burst was dirty:
4101
+ // DIV.ed-li-text ed-wys-armed / 16 enabled → BODY / 4 enabled, plus the
4102
+ // banner 「此清單含不支援的格式,無法調整結構」. The `finally` WAS reached;
4103
+ // `focusLine` was simply null there, so focusBlockAtLine() was skipped and
4104
+ // reaimToolbarBlockAtLine(null) returned on its first line — while
4105
+ // resolveGutterOperands()'s own switchAwayFrom() had already committed the
4106
+ // burst and re-rendered, which is what took the foothold away.
4107
+ //
4108
+ // Hence the second coordinate. `focusLine` is the body's SUCCESS landing and
4109
+ // is used exactly as before. `anchorLine` is resolveGutterOperands()'s live
4110
+ // grip line (see its `anchor` out-parameter) and is used ONLY when the body
4111
+ // answered nothing — a refusal, a no-op commit, or a rolled-back render.
4112
+ //
4113
+ // The foothold check on that second path is deliberate and is NOT symmetric
4114
+ // with the first. A refusal can also arrive with the user's caret still
4115
+ // exactly where they left it (a CLEAN burst goes out through
4116
+ // endBurstWithoutResolve(): no commit, no render, focus never moves —
4117
+ // measured: 轉換成 → 引用 on the same hard-wrapped li WITHOUT typing first
4118
+ // leaves DIV.ed-li-text ed-wys-armed / 16 enabled on ddac6ce). Restoring
4119
+ // unconditionally there would drag that caret to the end of the block for no
4120
+ // reason — the same mistake fix round 1 corrected in applyLinkToggle(), and
4121
+ // this is the same predicate it settled on.
4122
+ //
4123
+ // M3, the predicate divergence: the four sinks now AGREE that a rolled-back
4124
+ // render restores. Verified by reading rather than driven (a render failure
4125
+ // needs the server to fail mid-gesture): every `return false` inside
4126
+ // rerenderAll() is one of its four fetch/parse exits, all of them above the
4127
+ // call to applyRenderResult(); applyPatch() and applyFullRender() contain no
4128
+ // `return false` at all (grep: zero hits between them). So a `false` answer
4129
+ // from safeRerenderAll() means the DOM was never touched — the anchor block
4130
+ // is still on screen under the same id — while the switchAwayFrom() that ran
4131
+ // BEFORE it may already have re-rendered and reset the bar. Restoring is
4132
+ // therefore the right answer, and returning empty-handed (what
4133
+ // duplicateBlockViaMenuBody() and deleteBlockViaGutterBody() used to do on
4134
+ // `!okRender`) strands the user in exactly the I1/I2 shape. The one failure
4135
+ // this does NOT cover is a THROW inside applyPatch()/applyFullRender(),
4136
+ // caught by safeRerenderAll(): that can leave the DOM half-swapped, and
4137
+ // neither the old behaviour nor this one has an answer for it.
4138
+ function restoreAfterStructuralOp(focusLine, anchorLine) {
4139
+ // 有 blockSelection 站著時什麼都不做 —— 見 convertBlockViaMenu() 底下那段
4140
+ // 註解:強行還原 caret 會做出「底色仍說已選取、Delete 卻是死鍵」的混種狀態。
4141
+ if (blockSelection) return;
4142
+ if (focusLine != null) {
4143
+ focusBlockAtLine(focusLine, true); // caretToEnd
4144
+ // 降級目標(quote / code)沒有可聚焦編輯面,focusBlockAtLine 會安靜
4145
+ // no-op;把 toolbarBlockEl 指回那個 block,工具列才不會塌成 4 顆。
4146
+ reaimToolbarBlockAtLine(focusLine);
4147
+ return;
4148
+ }
4149
+ if (anchorLine == null) return;
4150
+ const held = document.activeElement;
4151
+ const heldSurface = held && held.closest
4152
+ ? held.closest('.ed-wys-armed, .ed-wys-cell, .ed-raw') : null;
4153
+ if (heldSurface) {
4154
+ // ⚠ C1. "The caret never left, so there is nothing to do" was TRUE of the
4155
+ // caret and FALSE of the toolbar, and the first version of this guard
4156
+ // skipped both. The bar can be down to 4 while the caret is still sitting
4157
+ // on a live surface, and that is the ORDINARY case, not an edge one: it
4158
+ // is what happens on the PATCH render path, i.e. on every commit after
4159
+ // the first of a page load (`lastParts` is null only for that first one,
4160
+ // so the fallback route — the one this guard was measured on — is
4161
+ // unreachable after it).
4162
+ //
4163
+ // INSTRUMENTED inside applyPatch() on a primed ⠿刪除 refusal, one log
4164
+ // line per step, rather than read off the source:
4165
+ // route PATCH
4166
+ // focusWasInSpan? v:true currentBurst:true burstElAttached:false
4167
+ // active:BODY
4168
+ // afterFocusRestore active:DIV.ed-li-text ed-wys-armed
4169
+ // toolbarBlockElNull:false
4170
+ // burstSurvived v:false currentBurst:true
4171
+ // active:DIV.ed-li-text ed-wys-armed
4172
+ // resetToolbarBlock? willRun:true
4173
+ // afterResetToolbarBlock toolbarBlockElNull:true
4174
+ // restoreAfterStructuralOp active:DIV.ed-li-text ed-wys-armed
4175
+ // toolbarBlockElNull:true
4176
+ // — applyPatch()'s `focusWasInSpan` block hands focus back to the NEW
4177
+ // surface, and `burstSurvived` is only evaluated afterwards. On this
4178
+ // route `currentBurst` is still the OLD burst (resolveBurst()'s own
4179
+ // teardown has not nulled it yet, which is also why focusin's
4180
+ // `if (currentBurst) return;` stops the focus() from opening a fresh
4181
+ // one), and that old burst's editEl was detached by the patch — so
4182
+ // `burstSurvived` is false and `resetToolbarBlock()` runs AFTER the
4183
+ // caret was handed back. The unprimed run of the same gesture took the
4184
+ // FALLBACK route and arrived here with active:BODY, which is why the
4185
+ // first wave's measurements never saw this.
4186
+ //
4187
+ // So: do not drag the caret (it never left), but DO re-aim the bar, at
4188
+ // the block the caret is actually in rather than at `anchorLine` — those
4189
+ // are the same block on every path measured, and when they ever differ
4190
+ // the caret's own block is the honest answer.
4191
+ const blk = heldSurface.closest('.ed-block');
4192
+ if (blk && document.body.contains(blk)) { toolbarBlockEl = blk; updateToolbar(); }
4193
+ return;
4194
+ }
4195
+ focusBlockAtLine(anchorLine, true); // caretToEnd
4196
+ reaimToolbarBlockAtLine(anchorLine);
4197
+ }
4198
+
4199
+ // v3.2.1: the caret restore is a THIN WRAPPER around the conversion body so
4200
+ // that it sits at the function's EXIT and every exit reaches it. The body's
4201
+ // pre-commit refusal exits, enumerated (final wave, M2 — the sentence that
4202
+ // stood here said "three" and named three of them):
4203
+ // 1. `if (!operands) return` — resolveGutterOperands() answered
4204
+ // null (dropped gesture, no source
4205
+ // line, empty/gapped/no-element
4206
+ // batch)
4207
+ // 2. the mixed list/non-list refusal (BATCH_MIXED_MESSAGE)
4208
+ // 3. the withheld-type refusal (BATCH_CONVERT_WITHHELD_MESSAGES)
4209
+ // 4. `if (!liRun)` (BATCH_MULTIRUN_MESSAGE)
4210
+ // 5. `!listRunSupportsStructuralEdit()` (§4.1's run-wide gate)
4211
+ // 6. `if (!stripped.ok)` (per-block 「此區塊的格式無法轉換」)
4212
+ // — six, not three, plus `result.op === null` (a commit that wrote nothing)
4213
+ // and the rolled-back render on the tail. Every one of them is reachable
4214
+ // from a lit ⠿ menu item, and the li → list / li → non-list / blocks → list
4215
+ // helpers this function tail-calls carry their own refusals on top.
4216
+ //
4217
+ // The body returns the operated block's post-commit start line, or undefined
4218
+ // when it refused or wrote nothing — `anchor.line` is what covers the
4219
+ // second case (see restoreAfterStructuralOp()).
3887
4220
  async function convertBlockViaMenu(blockEl, target) {
4221
+ let focusLine = null;
4222
+ const anchor = {};
4223
+ try {
4224
+ const out = await convertBlockViaMenuBody(blockEl, target, anchor);
4225
+ if (out != null) focusLine = out;
4226
+ } finally {
4227
+ // v3.2.1: 判準是「有沒有 blockSelection 站著」,不是 operand 數量 ——
4228
+ // 實測 operands.els.length === 1 在「有選取」與「無選取」兩態都成立,
4229
+ // 鑑別力為零。有選取時 declareCollapse() → applySelectionClasses() 已經
4230
+ // 在全新 DOM 上把 roving focus 裝回 .ed-block wrapper(9 種轉換形狀全部
4231
+ // 如此),這時強行還原 caret 會製造混種狀態:底色仍說「已選取」,Delete
4232
+ // 卻變成死鍵、Shift+↓ 降級成移動文字游標。所以有選取時什麼都不做。
4233
+ restoreAfterStructuralOp(focusLine, anchor.line);
4234
+ }
4235
+ }
4236
+
4237
+ async function convertBlockViaMenuBody(blockEl, target, anchor) {
3888
4238
  if (!blockEl || !target) return;
3889
4239
  // §3.3's membership rules and the whole shared preamble — see
3890
4240
  // resolveGutterOperands() above. `els` is ONE contiguous span; the plan's
@@ -3894,7 +4244,7 @@
3894
4244
  // fenced block raw-edited into two paragraphs changes the BLOCK count
3895
4245
  // without changing the LINE count, so an id-indexed delete hit the wrong
3896
4246
  // block, and the same shape silently rewrote a neighbouring table).
3897
- const operands = await resolveGutterOperands(blockEl);
4247
+ const operands = await resolveGutterOperands(blockEl, anchor);
3898
4248
  if (!operands) return;
3899
4249
  const els = operands.els;
3900
4250
  const recs = operands.recs;
@@ -3967,8 +4317,7 @@
3967
4317
  // the run stays a run and the existing re-serialization machinery applies
3968
4318
  // unchanged.
3969
4319
  if (shape.allLi && convertMd.targetIsList(target)) {
3970
- await convertListItemsType(els, liRun, recs, target);
3971
- return;
4320
+ return await convertListItemsType(els, liRun, recs, target);
3972
4321
  }
3973
4322
  // S2 Task 4 / S3 Task 6: li → a NON-list target. The items LEAVE the run,
3974
4323
  // so the span has to be rebuilt in three pieces and §4.3 rule 1's blank
@@ -3976,16 +4325,14 @@
3976
4325
  // lines mid-list with no separator and lazy continuation would swallow them
3977
4326
  // into the item above (measured, §4.3 rule 1).
3978
4327
  if (shape.allLi) {
3979
- await convertListItemsAway(els, liRun, recs, target);
3980
- return;
4328
+ return await convertListItemsAway(els, liRun, recs, target);
3981
4329
  }
3982
4330
  // S2 Task 5 / S3 Task 6: non-list blocks BECOME list items, so §4.3 rule
3983
4331
  // 2's looseness policy applies — eat the separator to an adjacent run of
3984
4332
  // the same list type, or the merged list goes LOOSE and every item of it
3985
4333
  // degrades read-only. Same rule, same helper the li → li path above uses.
3986
4334
  if (convertMd.targetIsList(target)) {
3987
- await convertBlocksIntoList(els, recs, shape.kinds, target);
3988
- return;
4335
+ return await convertBlocksIntoList(els, recs, shape.kinds, target);
3989
4336
  }
3990
4337
 
3991
4338
  // The plain path: N non-list blocks to a non-list target. One commit over
@@ -4028,6 +4375,11 @@
4028
4375
  if (!okRender) {
4029
4376
  lines = rollbackFailedRender({ lines, stack }, result, prevLines);
4030
4377
  }
4378
+ // v3.2.1 §Task 5: the caller's `focusLine`. NOT a new value and NOT a
4379
+ // pre-commit startLine — it is the very line the collapse above declares,
4380
+ // i.e. the operated span's post-commit first line. The plain path is the
4381
+ // one path that does not move it (the commit replaces the span in place).
4382
+ return first.startLine;
4031
4383
  }
4032
4384
 
4033
4385
  // Is the editor session switchAwayFrom() would resolve open on THIS block?
@@ -4085,12 +4437,51 @@
4085
4437
  // `lines`, and the li path carries the clone through bystanderCarryOver()
4086
4438
  // under the ORIGINAL's block id, so list-md.js replays the file's own bytes
4087
4439
  // for it and only re-states the marker. Both keep a `~5px` a `~5px`.
4440
+ //
4441
+ // v3.2.1: the caret restore is a THIN WRAPPER around the duplication body,
4442
+ // same shape and same reason as convertBlockViaMenu() above: the body has
4443
+ // several exits that duplicate nothing — resolveGutterOperands() answering
4444
+ // null, the mixed-list refusal, each of duplicateListItems()'s four, the
4445
+ // op === null no-op and the failed render on the non-li tail — all of them
4446
+ // reachable from a lit ⠿ menu item, and the wrapper is what makes every one
4447
+ // of them reach the restore. The body answers the ORIGINAL span's first
4448
+ // start line on success and undefined on every one of those exits.
4449
+ //
4450
+ // Final wave: "reach the restore" was true and not sufficient — with
4451
+ // `focusLine` null the restore had nothing to aim at and did nothing. The
4452
+ // refusal exits now land on `anchor.line`; see restoreAfterStructuralOp().
4453
+ //
4454
+ // 落點是【原件】而不是副本。三個理由,其中兩個是這個檔案自己已經寫下的事實:
4455
+ // 1. 一個選單項目只能有一個答案。有 blockSelection 站著時,§3.3 的塌縮把
4456
+ // 集合留在【原件】上(見 body 裡 declareCollapse() 那段註解:副本插在
4457
+ // 所有被複製的東西【下面】,所以原件的行區間原封不動)。落在副本會讓
4458
+ // 「按完 建立副本 之後手上握著什麼」取決於一個看不見的先前狀態。
4459
+ // 2. 原件的行號直接可讀,副本的行號在 li 路徑上要另外付代價。副本帶著
4460
+ // 原件的 data-block-id,所以 runLineOfBlock() 的
4461
+ // `lineMeta.findIndex((m) => m.blockId === targetId)` 回的是【原件】那
4462
+ // 一行。這不是做不到 —— runLineOfBlock() 對沒有 id 的 block 另有一支
4463
+ // 按位置數行的 fallback,而副本在 span 裡的位置是已知的 `at + 1 + i`。
4464
+ // 代價在別的地方:那個共用的 id 正是 bystanderCarryOver() 逐位元組重播
4465
+ // 副本、而不是重新 escape 它的原因(見下方 duplicateListItems() 的註解,
4466
+ // 一個 '~5px' 不重播就會變成 '\~5px')。所以這是一個取捨,不是一道牆。
4467
+ // 3. 畫面上沒有東西在使用者底下移動:副本長在下面,原件原地不動。
4088
4468
  async function duplicateBlockViaMenu(blockEl) {
4469
+ let focusLine = null;
4470
+ const anchor = {};
4471
+ try {
4472
+ const out = await duplicateBlockViaMenuBody(blockEl, anchor);
4473
+ if (out != null) focusLine = out;
4474
+ } finally {
4475
+ restoreAfterStructuralOp(focusLine, anchor.line);
4476
+ }
4477
+ }
4478
+
4479
+ async function duplicateBlockViaMenuBody(blockEl, anchor) {
4089
4480
  // §3.3's membership rules plus the shared preamble — see
4090
4481
  // resolveGutterOperands(). The whole span is duplicated by ONE commit; a
4091
4482
  // per-member loop would re-render between items and invalidate every id in
4092
4483
  // between.
4093
- const operands = await resolveGutterOperands(blockEl);
4484
+ const operands = await resolveGutterOperands(blockEl, anchor);
4094
4485
  if (!operands) return;
4095
4486
  const els = operands.els;
4096
4487
  const recs = operands.recs;
@@ -4098,8 +4489,12 @@
4098
4489
  if (shape.anyLi && !shape.allLi) { refuseStructuralListEdit(BATCH_MIXED_MESSAGE); return; }
4099
4490
 
4100
4491
  if (shape.allLi) {
4101
- await duplicateListItems(els, recs);
4102
- return;
4492
+ // recs[0].startLine is unchanged by this commit — duplicateListItems()'s
4493
+ // own declareCollapse() comment is the argument: the copies land after
4494
+ // everything the set owns, and no member's line COUNT can change (a
4495
+ // multi-line target is refused, and §3.8's renumbering only ever changes
4496
+ // marker WIDTH), so the originals still occupy the range they occupied.
4497
+ return (await duplicateListItems(els, recs)) ? recs[0].startLine : undefined;
4103
4498
  }
4104
4499
 
4105
4500
  // The non-li span duplicates as ONE slice of `lines` — separators between
@@ -4126,7 +4521,13 @@
4126
4521
  : undefined;
4127
4522
  if (!(await safeRerenderAll(editRange))) {
4128
4523
  lines = rollbackFailedRender({ lines, stack }, result, prevLines);
4524
+ return;
4129
4525
  }
4526
+ // The insertion is anchored on the LAST member and lands BELOW it, so
4527
+ // ops.shiftBlocks() moved only ids greater than that one — the span's own
4528
+ // first start line is the same number before and after, which is the same
4529
+ // fact declareCollapse() above relies on.
4530
+ return first.startLine;
4130
4531
  }
4131
4532
 
4132
4533
  // The li half of 建立副本. The copy is spliced into the run's own span and the
@@ -4135,10 +4536,15 @@
4135
4536
  // §3.8's renumbering falls out of the re-serialization ('1. alpha' duplicated
4136
4537
  // gives '1. alpha / 2. alpha / 3. bravo', not '1. alpha / 1. alpha / 2.
4137
4538
  // bravo').
4539
+ //
4540
+ // v3.2.1: returns commitListStructure()'s own true/false, and false for each
4541
+ // of the four refusal exits — duplicateBlockViaMenu()'s wrapper needs to tell
4542
+ // a refusal (nothing was duplicated, the block is still where it was) apart
4543
+ // from a completed duplicate.
4138
4544
  async function duplicateListItems(liEls, recs) {
4139
4545
  // Every list batch is one run's problem — see batchRunOf().
4140
4546
  const run = batchRunOf(liEls);
4141
- if (!run) { refuseStructuralListEdit(BATCH_MULTIRUN_MESSAGE); return; }
4547
+ if (!run) { refuseStructuralListEdit(BATCH_MULTIRUN_MESSAGE); return false; }
4142
4548
  // §4.3's run-wide gate — 轉換/建立副本/刪除/拖曳 each make this call for
4143
4549
  // themselves; there is no shared helper. Its input is §3.4 rule 2's scope,
4144
4550
  // which is exactly what listRunOf() returns (the outermost run PLUS every
@@ -4146,14 +4552,14 @@
4146
4552
  // it adds the item's lines over again), so a multi-line li refuses as a
4147
4553
  // TARGET while remaining a perfectly good bystander — and in a batch EVERY
4148
4554
  // member is a target.
4149
- if (!listRunSupportsStructuralEdit(run, liEls)) { refuseStructuralListEdit(); return; }
4555
+ if (!listRunSupportsStructuralEdit(run, liEls)) { refuseStructuralListEdit(); return false; }
4150
4556
  // Captured BEFORE the copy enters the span. The copy carries the
4151
4557
  // ORIGINAL's data-block-id — that is what makes bystanderCarryOver() replay
4152
4558
  // its bytes rather than re-escape them — so runRangeOfBlocks() would
4153
4559
  // resolve it to the original's record, and on a duplicate of the span's
4154
4560
  // LAST member that silently re-states the range's end line.
4155
4561
  const range = runRangeOfBlocks({ lines, blocks, stack }, run);
4156
- if (!range) return;
4562
+ if (!range) return false;
4157
4563
 
4158
4564
  // §4.3, measured: after the SUBTREE, not after the item's own line.
4159
4565
  // subtreeBlocksAfter() is the flat model's subtree — the contiguous run of
@@ -4166,7 +4572,7 @@
4166
4572
  const subtree = subtreeBlocksAfter(anchorLi, Number(anchorLi.getAttribute('data-indent')) || 0);
4167
4573
  const lastEl = subtree.length ? subtree[subtree.length - 1] : anchorLi;
4168
4574
  const at = run.indexOf(lastEl);
4169
- if (at < 0) return;
4575
+ if (at < 0) return false;
4170
4576
 
4171
4577
  const copies = liEls.map((liEl) => liEl.cloneNode(true));
4172
4578
  // `data-list-start` is the ONLY carrier of "marked's lexer opened a new
@@ -4209,33 +4615,161 @@
4209
4615
  startLine: recs[0].startLine,
4210
4616
  endLine: recs[recs.length - 1].endLine,
4211
4617
  });
4212
- await commitListStructure(span, null, false,
4618
+ return await commitListStructure(span, null, false,
4213
4619
  { presetRange: range, carryOver: bystanderCarryOver(span) });
4214
4620
  }
4215
4621
  // Deletes `blockEl`'s ENTIRE line range (generalizing commitListBlockRemoval()
4216
4622
  // — unchanged, see its own comment — to any block type, not just an
4217
4623
  // emptied-out list). Same resolve-first / re-query-live-block-by-id
4218
4624
  // precondition as insertBlockBelow() above.
4625
+ //
4626
+ // v3.2.1: the caret restore is a THIN WRAPPER around the removal body, same
4627
+ // shape as convertBlockViaMenu() above. A delete has no "back to the block it
4628
+ // operated on" — the block is gone — so the body answers the HOLE's line
4629
+ // coordinate (the removed span's own first start line) and
4630
+ // blockLineAfterRemoval() below turns that into a block that still exists.
4631
+ //
4632
+ // 有 blockSelection 站著時什麼都不做的判準與 convertBlockViaMenu() 相同,
4633
+ // 但在這條路徑上它幾乎總是讓路:批次刪除的 §3.3 塌縮是 declareCollapse(null)
4634
+ // =清空集合(見 body 內那段註解),render 之後 blockSelection 已經是 null。
4635
+ // 那是對的 —— 沒有集合站著就沒有「底色仍說已選取、Delete 卻是死鍵」的混種
4636
+ // 狀態可言,而使用者剛把手上唯一的著力點刪掉,更需要拿回一個。
4219
4637
  async function deleteBlockViaGutter(blockEl) {
4638
+ let removed = null;
4639
+ const anchor = {};
4640
+ try {
4641
+ const out = await deleteBlockViaGutterBody(blockEl, anchor);
4642
+ if (out) removed = out;
4643
+ } finally {
4644
+ // Final wave: on the refusal / rolled-back-render exits `removed` is
4645
+ // null, so the landing falls through to `anchor.line` — the block the
4646
+ // gesture named, which a refusal left exactly where it was. On the
4647
+ // SUCCESS path that block no longer exists, which is why the success
4648
+ // coordinate is blockLineAfterRemoval()'s carried neighbour and not the
4649
+ // anchor: restoreAfterStructuralOp() only consults `anchor.line` when
4650
+ // the first argument is null.
4651
+ const focusLine = removed ? blockLineAfterRemoval(removed) : null;
4652
+ // `anchor.line` is the line of the block that was just DELETED, so it is
4653
+ // offered only when the body refused. A successful delete that cannot
4654
+ // name a landing (blockLineAfterRemoval() answering null — an emptied
4655
+ // document) must restore nothing rather than aim at the hole.
4656
+ restoreAfterStructuralOp(focusLine, removed ? null : anchor.line);
4657
+ }
4658
+ }
4659
+
4660
+ // The block the user is left holding once a span has been removed. Takes the
4661
+ // `{ prevLine, spanIsFirst }` the body captured BEFORE its commit:
4662
+ //
4663
+ // prevLine the block immediately ABOVE the removed span, or null when
4664
+ // there is none. A PRE-commit start line, and stable across the
4665
+ // render for ONE reason that covers every path, not a reason
4666
+ // per path:
4667
+ //
4668
+ // commitRangeRemoval() and commitRangeEdit() compute the
4669
+ // SAME anchor — `blocks.filter((b) => b.endLine <= endLine)
4670
+ // .pop()`, textually identical in both — and hand it to
4671
+ // ops.shiftBlocks(), which moves a block only when
4672
+ // `b.id > editedId` (lineops.js). Ids are positional
4673
+ // ordinals in document order, so the block above the span has
4674
+ // a SMALLER id than every block inside it, and the anchor is
4675
+ // at least the span's last member (that member is itself in
4676
+ // the filtered list). The neighbour therefore fails
4677
+ // `b.id > editedId` whichever helper runs.
4678
+ //
4679
+ // ⚠ Fix round 3. The sentence that stood here said the li path
4680
+ // calls NEITHER of those helpers. That is false, and its own
4681
+ // named example is the counter-example: commitListStructure()
4682
+ // routes `md === ''` — no survivors, i.e. a WHOLE-RUN delete —
4683
+ // straight into commitRangeRemoval(). Instrumented and driven
4684
+ // rather than read: 刪除 over all three items of
4685
+ // '- alpha / - bravo / - charlie' printed
4686
+ // commitRangeRemoval range=5..7 anchor=id4(7..7)
4687
+ // shiftBlocks editedId=4 delta=-4 movedIds=[5] untouchedIds=[0,1,2,3,4]
4688
+ // while a partial delete of the same run printed
4689
+ // commitRangeEdit range=5..7 anchor=id4(7..7)
4690
+ // shiftBlocks editedId=4 delta=-1 movedIds=[5] untouchedIds=[0,1,2,3,4]
4691
+ // — two different helpers, the same anchor, and in both the
4692
+ // neighbour (id1) sits in `untouchedIds`. Five gestures were
4693
+ // instrumented this way (whole run, middle item, first item, a
4694
+ // non-li paragraph, and a run that is the entire document) and
4695
+ // the neighbour was untouched in all five.
4696
+ // spanIsFirst true when the removed span WAS the document's first block, so
4697
+ // "no previous block" is a fact rather than a failed lookup —
4698
+ // see deleteBlockViaGutterBody()'s own comment for why those two
4699
+ // are not the same answer.
4700
+ //
4701
+ // 「上面那一個、游標落在它的結尾」是 Notion 的答案,也是 Backspace 併行的
4702
+ // 落點;這個編輯器的 ⠿ / 建立副本 / 轉換成 整組詞彙都是 Notion 的。
4703
+ //
4704
+ // ⚠ Fix round 1, item 2: this used to INFER the neighbour from the hole's
4705
+ // coordinate — "the largest post-render startLine strictly below it". That is
4706
+ // wrong whenever commitRangeRemoval() takes its `sl -= 1` branch (the line
4707
+ // after the span is not blank, so it absorbs the blank ABOVE instead): every
4708
+ // block BELOW the hole then moves up too, the first of them lands one line
4709
+ // ABOVE the hole's own start line, and taking the maximum picks THAT
4710
+ // deterministically over the real previous block. Driven on '…Bravo para.\n' immediately followed by
4711
+ // a block that interrupts a paragraph — an ATX heading, a list, a fenced
4712
+ // code block, an hr — the caret landed on the block BELOW the hole in all
4713
+ // four, and on the code / hr shapes (no focusable surface) it landed nowhere
4714
+ // at all. The answer is not derivable from the hole; it has to be carried.
4715
+ //
4716
+ // With `spanIsFirst`, whatever is first NOW is the block that moved up into
4717
+ // the hole — `blocks[0]`, no search needed. Answers null for a delete that
4718
+ // emptied the document, and for the unreachable "span not found" case, where
4719
+ // guessing a block is worse than restoring no caret at all.
4720
+ function blockLineAfterRemoval(removed) {
4721
+ if (removed.prevLine != null) return removed.prevLine;
4722
+ if (!removed.spanIsFirst) return null;
4723
+ return blocks.length ? blocks[0].startLine : null;
4724
+ }
4725
+
4726
+ async function deleteBlockViaGutterBody(blockEl, anchor) {
4220
4727
  // §3.3's membership rules plus the shared preamble (identity capture,
4221
4728
  // switchAwayFrom(), the re-resolve after a commit that re-rendered, and the
4222
4729
  // no-source-line refusal) — see resolveGutterOperands(). The stake on the
4223
4730
  // re-resolve is highest here: an id shift used to make this delete a
4224
4731
  // DIFFERENT block's lines, with the ⠿ menu the user pressed pointing at a
4225
4732
  // block that survived.
4226
- const operands = await resolveGutterOperands(blockEl);
4733
+ const operands = await resolveGutterOperands(blockEl, anchor);
4227
4734
  if (!operands) return;
4228
4735
  const els = operands.els;
4229
4736
  const recs = operands.recs;
4230
4737
  const shape = spanListKinds(els);
4231
4738
  if (shape.anyLi && !shape.allLi) { refuseStructuralListEdit(BATCH_MIXED_MESSAGE); return; }
4739
+ // Fix round 1, item 2: the block immediately ABOVE the span, read out of
4740
+ // the PRE-commit `blocks` and carried to the wrapper. It cannot be
4741
+ // recovered afterwards from the hole's coordinate — see
4742
+ // blockLineAfterRemoval()'s own comment for the measurement. `blocks` is in
4743
+ // document order (blockmap.js hands out a positional ordinal), so the
4744
+ // element before recs[0] is that neighbour.
4745
+ //
4746
+ // Located by OBJECT IDENTITY, and the three outcomes are kept apart on
4747
+ // purpose (fix round 2, item 3 — `at > 0` folded the third into the
4748
+ // second and would have landed the caret on the document's first block
4749
+ // with no signal at all):
4750
+ // at > 0 there is a previous block; its start line is the landing.
4751
+ // at === 0 the span IS the document's first block — `spanIsFirst` tells
4752
+ // blockLineAfterRemoval() to take the moved-up fallback.
4753
+ // at < 0 recs[0] is not in `blocks`. Unreachable as written:
4754
+ // resolveGutterOperands() builds `recs` from this very array
4755
+ // (blockRecOf()'s `blocks.find()` for the single case,
4756
+ // selection.js's membersOf() `blocks.filter()` for the batch —
4757
+ // both hand back the SAME object references), and nothing
4758
+ // awaits between its return and this line, so `blocks` cannot
4759
+ // have been replaced in between. If it ever becomes reachable,
4760
+ // both flags stay false/null and the wrapper simply restores no
4761
+ // caret and re-aims the toolbar — a bar-only degrade, never a
4762
+ // landing on the wrong block.
4763
+ const at = blocks.indexOf(recs[0]);
4764
+ const prevLine = at > 0 ? blocks[at - 1].startLine : null;
4765
+ const spanIsFirst = at === 0;
4232
4766
  // Spec §6, "S1 期間的已知危險" item 1: a LIST ITEM's delete is not a line
4233
4767
  // splice. S1 is what first put a ⠿ on a li, and the plain range removal
4234
4768
  // below corrupts a list three separate ways — see
4235
4769
  // deleteListItemsViaGutter() for the measurements and the routing.
4236
4770
  if (shape.allLi) {
4237
- await deleteListItemsViaGutter(els);
4238
- return;
4771
+ return (await deleteListItemsViaGutter(els))
4772
+ ? { prevLine: prevLine, spanIsFirst: spanIsFirst } : undefined;
4239
4773
  }
4240
4774
  // A contiguous span of non-list blocks is one line range: the separators
4241
4775
  // between its members are inside it by construction (members are adjacent
@@ -4264,7 +4798,9 @@
4264
4798
  const okRender = await safeRerenderAll(editRange);
4265
4799
  if (!okRender) {
4266
4800
  lines = rollbackFailedRender({ lines, stack }, result, prevLines);
4801
+ return;
4267
4802
  }
4803
+ return { prevLine: prevLine, spanIsFirst: spanIsFirst };
4268
4804
  }
4269
4805
 
4270
4806
  // Spec §6, "S1 期間的已知危險" item 1 — the ⠿ delete of a LIST ITEM.
@@ -4295,19 +4831,24 @@
4295
4831
  // commit the re-serialized survivors over that range with every one of them
4296
4832
  // carried over verbatim — nothing here rewrites any survivor's CONTENT, only
4297
4833
  // its marker and its leading columns.
4834
+ //
4835
+ // v3.2.1: returns commitListStructure()'s own true/false, and false for each
4836
+ // of its three refusal exits — deleteBlockViaGutter()'s wrapper needs to tell
4837
+ // a refusal (nothing was removed, the block is still where it was) apart from
4838
+ // a completed delete before it goes looking for a neighbour.
4298
4839
  async function deleteListItemsViaGutter(liEls) {
4299
4840
  // Every list batch is one run's problem — see batchRunOf().
4300
4841
  const run = batchRunOf(liEls);
4301
- if (!run) { refuseStructuralListEdit(BATCH_MULTIRUN_MESSAGE); return; }
4842
+ if (!run) { refuseStructuralListEdit(BATCH_MULTIRUN_MESSAGE); return false; }
4302
4843
  // §4.3's run-wide gate, whose input is §3.4 rule 2's scope — which is
4303
4844
  // exactly what listRunOf() returns (the outermost run PLUS every
4304
4845
  // descendant of its members), so the deeper runs this delete is about to
4305
4846
  // re-indent are covered, not just the target's own. Deleting is NOT
4306
4847
  // column-only: it removes the target's lines outright, so a multi-line
4307
4848
  // target refuses per §4.1 — and in a batch every member is a target.
4308
- if (!listRunSupportsStructuralEdit(run, liEls)) { refuseStructuralListEdit(); return; }
4849
+ if (!listRunSupportsStructuralEdit(run, liEls)) { refuseStructuralListEdit(); return false; }
4309
4850
  const range = runRangeOfBlocks({ lines, blocks, stack }, run);
4310
- if (!range) return;
4851
+ if (!range) return false;
4311
4852
  // §3.4 rule 3's batch anchor: the SMALLEST old indent in the set, never the
4312
4853
  // first member's — see spanMinIndent().
4313
4854
  const oldIndent = spanMinIndent(liEls);
@@ -4333,7 +4874,7 @@
4333
4874
  // this path is the one it makes after the commit — the `!range` bail above
4334
4875
  // it cannot fire, `presetRange` is non-null by construction here.
4335
4876
  declareCollapse(null);
4336
- await commitListStructure(survivors, null, false,
4877
+ return await commitListStructure(survivors, null, false,
4337
4878
  { presetRange: range, carryOver: bystanderCarryOver(survivors) });
4338
4879
  }
4339
4880
 
@@ -4555,6 +5096,10 @@
4555
5096
  });
4556
5097
  await commitListStructure(run, null, false,
4557
5098
  { presetRange: range, carryOver: bystanderCarryOver(run) });
5099
+ // v3.2.1 §Task 5: the caller's `focusLine` — the same post-shift line the
5100
+ // collapse just declared, never the pre-commit startLine (the merge
5101
+ // widening above moves it by exactly `shift`).
5102
+ return recs[0].startLine + shift;
4558
5103
  }
4559
5104
 
4560
5105
  // The `data-block-type` a conversion target will carry once it is committed.
@@ -4703,6 +5248,11 @@
4703
5248
  if (!(await safeRerenderAll(editRange))) {
4704
5249
  lines = rollbackFailedRender({ lines, stack }, result, prevLines);
4705
5250
  }
5251
+ // v3.2.1 §Task 5: the caller's `focusLine` — the converted piece's own
5252
+ // first emitted line, which is what the collapse above declares. The
5253
+ // pre-commit startLine is wrong here: the split rebuilds the run in three
5254
+ // pieces and §4.3 rule 1's blanks move the converted half.
5255
+ return outStart;
4706
5256
  }
4707
5257
 
4708
5258
  // S2 Task 5 — 轉換成 › 項目符號列表 / 編號列表 / 待辦清單 on a block that is
@@ -4765,6 +5315,10 @@
4765
5315
  if (!(await safeRerenderAll(editRange))) {
4766
5316
  lines = rollbackFailedRender({ lines, stack }, result, prevLines);
4767
5317
  }
5318
+ // v3.2.1 §Task 5: the caller's `focusLine` — the WIDENED range's first
5319
+ // line, which is where the merged list actually starts. `recs[0].startLine`
5320
+ // would be wrong whenever §4.3 rule 2 ate a separator above the span.
5321
+ return merged.startLine;
4768
5322
  }
4769
5323
 
4770
5324
  // The `.ed-li-check` chrome for a li that has just BECOME a task item.
@@ -4817,12 +5371,18 @@
4817
5371
  // focus/blur cycle on that same block (click into it, click back out
4818
5372
  // — e.g. after Esc-ing the raw editor) re-armed that same stale DOM
4819
5373
  // and committed it as if it were live content: the "discarded" edit
4820
- // resurrected itself into `lines`. Restore the pre-edit snapshot
4821
- // FIRST, mirroring revertBurstAndEnd()'s own contract, so nothing is
4822
- // left behind for a later burst to accidentally pick up and commit.
4823
- currentBurst.editEl.innerHTML = currentBurst.original;
4824
- currentBurst.history.dispose();
5374
+ // resurrected itself into `lines`.
5375
+ // v3.2.1: 先清 currentBurst 再寫 innerHTML。對 TABLE burst,editEl 是
5376
+ // <table> 而聚焦節點是子代 .ed-wys-cell,所以這個寫入會 detach 聚焦的
5377
+ // cell;Chromium 同步派發 focusout,而 focusout 的 table 分支只要
5378
+ // currentBurst 非 null 就會 resolveBurst() 並提交 —— 這顆按鈕的職責是
5379
+ // 丟棄,實測卻把打的字寫進磁碟。revertTableBurstAndEnd() 早就是這個
5380
+ // 順序,而它的註解正是說明「先清 currentBurst」才是它不需要抑制旗標
5381
+ // 的原因;上一版這裡的註解宣稱在鏡像那份契約,實際把它倒過來了。
5382
+ const dyingBurst = currentBurst;
4825
5383
  currentBurst = null;
5384
+ dyingBurst.history.dispose();
5385
+ dyingBurst.editEl.innerHTML = dyingBurst.original;
4826
5386
  resetSelToolbarState();
4827
5387
  } else {
4828
5388
  const ok = await switchAwayFrom();
@@ -7033,10 +7593,34 @@
7033
7593
  // (/[ \t]+$/) does not, so it would survive into the committed line.
7034
7594
  // Either way the user sees an empty item and must get a bare '-'. Clear
7035
7595
  // the surface under the SAME normalisation the branch condition used —
7036
- // liSurfaceHoldsNothing() — inside the suppression span (an innerHTML
7037
- // assignment on the focused node triggers the very same Chromium unfocus
7038
- // quirk), and only once the outdent above has actually happened, since a
7039
- // refused press must leave the DOM byte-identical.
7596
+ // liSurfaceHoldsNothing() — and only once the outdent above has
7597
+ // actually happened, since a refused press must leave the DOM
7598
+ // byte-identical.
7599
+ //
7600
+ // ⚠ Final wave, I4. The sentence that stood here said this innerHTML
7601
+ // assignment is itself a reason the write must sit inside
7602
+ // mutateListRun()'s suppression span — "an innerHTML assignment on the
7603
+ // focused node triggers the very same Chromium unfocus quirk". MEASURED
7604
+ // false, and its own premise is the counter-example: `textEl` IS the
7605
+ // focused node, and writing its innerHTML replaces only its SUBTREE —
7606
+ // the element itself is never detached. Control experiment, driven in a
7607
+ // real page: focus a `.ed-li-text`, count focusout on it and on
7608
+ // `document` (capture), then assign `''` — 0 focusout, 0 blur,
7609
+ // `document.activeElement` still that same node, node still in the
7610
+ // document.
7611
+ //
7612
+ // What the span IS for is written at `suppressLiFocusout`'s own
7613
+ // declaration: a structural list key can move, split or REMOVE the
7614
+ // focused block, and Chromium's synchronous unfocus then reaches
7615
+ // resolveBurst() with the run still in its pre-mutation shape. This
7616
+ // assignment is inside the span because it is part of the same
7617
+ // mutation, not because it contributes a focusout of its own.
7618
+ //
7619
+ // No claim is made here about WHICH callee needs it, and deliberately
7620
+ // not about outdentListItem(): that function is attribute-only in this
7621
+ // flat model — it calls setBlockIndent() on the block and its subtree
7622
+ // (`data-indent` + a CSS custom property) and re-parents nothing, so it
7623
+ // is not the answer either.
7040
7624
  const textEl = liTextEl(li);
7041
7625
  if (textEl && liSurfaceHoldsNothing(textEl)) textEl.innerHTML = '';
7042
7626
  return true;
@@ -7789,6 +8373,20 @@
7789
8373
  // (possibly now-stale, post-delete) column/row it came from.
7790
8374
  let teHighlightEls = [];
7791
8375
 
8376
+ // v3.2.1 fix round 1: same idiom as `suppressTableFocusout`/
8377
+ // `suppressLiFocusout` above — a flag set for the exact synchronous-ish
8378
+ // span of one operation that would otherwise be torn down by a listener
8379
+ // reacting to a side effect THAT OPERATION ITSELF triggers. Here:
8380
+ // runCycleAlign()'s `ensureTableBurstOpen()` can call a cell's `.focus()`,
8381
+ // which can scroll the page for real — and the resulting 'scroll' event
8382
+ // is asynchronous relative to the focus() call (measured: it does not
8383
+ // reliably land before runCycleAlign()'s own code resumes after the
8384
+ // `await`), so a plain "check teMenuKind again afterward" re-open would
8385
+ // race it and sometimes miss. onAnyScroll() checks this flag before
8386
+ // calling hideTableEdgeMenu() so the menu's open state is never torn down
8387
+ // in the first place during that span, whichever tick the event lands on.
8388
+ let suppressEdgeMenuAutoHide = false;
8389
+
7792
8390
  function clearEdgeHighlight() {
7793
8391
  // classList.remove() leaves a dangling `class=""` behind on any element
7794
8392
  // the renderer/armEditables() emitted with NO class of its own — a
@@ -8012,14 +8610,84 @@
8012
8610
  const colIndex = teMenuColIndex;
8013
8611
  if (!tableEl || colIndex == null) return;
8014
8612
  // Final-review Finding 6: same live-table swap as runDeleteColumn() above.
8015
- const liveTableEl = await ensureTableBurstOpen(tableEl);
8016
- if (!liveTableEl) return;
8017
- cycleColumnAlign(liveTableEl, colIndex);
8018
- currentBurst.history.snap('align-col');
8019
- // Stays open (unlike delete): repeated clicks keep cycling. The
8020
- // column's cells are the SAME nodes (cycleColumnAlign() only touches
8021
- // the `style` attribute) so the existing highlight is still valid —
8022
- // nothing to reposition/rehighlight.
8613
+ // v3.2.1 fix round 1: ensureTableBurstOpen() below can call a cell's
8614
+ // .focus(), which can scroll the page for real — and v3.2.1's onAnyScroll
8615
+ // (this file's module-level scroll listener) calls hideTableEdgeMenu() on
8616
+ // every scroll, which would reset teMenuKind/teMenuTableEl/teMenuColIndex
8617
+ // to null and un-highlight the column, breaking this function's own
8618
+ // "stays open, repeated clicks keep cycling" contract below. Measured
8619
+ // TWICE: (1) a plain "re-check state and re-show if it changed" fix
8620
+ // RACES the resulting 'scroll' event, which is asynchronous relative to
8621
+ // the `.focus()` call and does not reliably land before this function
8622
+ // resumes after the `await` — so suppress the menu's own auto-hide for
8623
+ // this span instead (`suppressEdgeMenuAutoHide`, see its own comment).
8624
+ // (2) clearing that suppression in a plain `finally` right after the
8625
+ // `await` ALSO raced it — the pending 'scroll' event can land AFTER this
8626
+ // function has already resumed and cleared the flag, since a
8627
+ // `focus()`-triggered scroll is not guaranteed to be delivered within
8628
+ // the same microtask turn as the promise chain above. Measured fix:
8629
+ // browsers dispatch a pending scroll before the next
8630
+ // requestAnimationFrame callback runs (it is part of the "update the
8631
+ // rendering" steps that precede rAF callbacks in the same frame), so
8632
+ // holding the flag through one rAF tick lets a pending scroll land AND
8633
+ // be swallowed before this lifts it.
8634
+ let liveTableEl;
8635
+ suppressEdgeMenuAutoHide = true;
8636
+ try {
8637
+ liveTableEl = await ensureTableBurstOpen(tableEl);
8638
+ if (!liveTableEl) { suppressEdgeMenuAutoHide = false; return; }
8639
+ cycleColumnAlign(liveTableEl, colIndex);
8640
+ currentBurst.history.snap('align-col');
8641
+ } catch (e) {
8642
+ suppressEdgeMenuAutoHide = false;
8643
+ throw e;
8644
+ }
8645
+ requestAnimationFrame(() => {
8646
+ suppressEdgeMenuAutoHide = false;
8647
+ // Fix round 2 (Important, caught by the re-reviewer): suppressing
8648
+ // hideTableEdgeMenu() only stops onAnyScroll from closing the menu —
8649
+ // it does NOT stop `ensureTableBurstOpen()`'s own `switchAwayFrom()`
8650
+ // from closing it for an UNRELATED, legitimate reason when a burst was
8651
+ // open on a DIFFERENT block: switchAwayFrom() -> resolveBurst() ->
8652
+ // hideTableEdgeMenu() (unconditional, not suppressed — only
8653
+ // onAnyScroll's call is gated) nulls teMenuKind/teMenuTableEl/
8654
+ // teMenuColIndex and clears the highlight. Without this guard the
8655
+ // unconditional reposition below would then make that CORRECTLY-
8656
+ // closed menu visible again — empty of highlight, and inert (its own
8657
+ // teMenuKind/teMenuColIndex are null, so a second 對齊/刪除欄 click
8658
+ // reads no target and does nothing) — floating over the document
8659
+ // until the next scroll happens to close it for an unrelated reason.
8660
+ // Only reposition when the menu is STILL the one this call opened:
8661
+ // same kind, same column, and the table identity matches EITHER
8662
+ // `tableEl` (showColumnMenu() was called with this — the pre-swap
8663
+ // identity, read from its own definition above — and nothing since
8664
+ // has touched teMenuTableEl) or `liveTableEl` (belt-and-braces, in
8665
+ // case some future path re-opens the menu against the live node).
8666
+ if (teMenuKind !== 'col' || teMenuColIndex !== colIndex ||
8667
+ (teMenuTableEl !== liveTableEl && teMenuTableEl !== tableEl)) return;
8668
+ // Stays open (unlike delete) UNLESS ensureTableBurstOpen()'s
8669
+ // switchAwayFrom() legitimately closed it above (guard just above) —
8670
+ // repeated clicks keep cycling. The column's cells are the SAME nodes
8671
+ // (cycleColumnAlign() only touches the `style` attribute) so the
8672
+ // existing highlight is still valid — nothing to rehighlight.
8673
+ // `.ed-te-menu` is `position: fixed` (viewport-relative) though, so if
8674
+ // the suppressed span above actually scrolled the page, its left/top
8675
+ // are now stale relative to the header cell; reposition on every call
8676
+ // that gets past the identity guard three lines up (same math as
8677
+ // showColumnMenu(), skipping its toggle-closed branch — that branch would
8678
+ // wrongly close a menu that never actually asked to close). Final wave,
8679
+ // M1: "unconditionally" is what this said before that guard was added,
8680
+ // and it stopped being true then — a call whose menu is no longer the one
8681
+ // it opened returns above and repositions nothing. A no-op scroll makes
8682
+ // this a no-op reposition (same numbers back).
8683
+ const headerRow = headerRowOf(liveTableEl);
8684
+ const cell = headerRow ? headerRow.cells[colIndex] : null;
8685
+ if (cell) {
8686
+ const r = cell.getBoundingClientRect();
8687
+ positionTeMenu(r.left, r.top);
8688
+ teEdgeMenu.style.top = (r.top - teEdgeMenu.offsetHeight - TE_MENU_GAP_PX) + 'px';
8689
+ }
8690
+ });
8023
8691
  }
8024
8692
 
8025
8693
  // ── Notion-style row/column grip handles ─────────────────────────────
@@ -9962,6 +10630,56 @@
9962
10630
  return r;
9963
10631
  }
9964
10632
 
10633
+ // v3.2.1: 在字元空間修剪 Range 的前後空白,再交給 wrapRangeIn()。
10634
+ // 不用 startOffset/endOffset 步進:當邊界容器是【元素節點】時那些 offset
10635
+ // 索引的是【子節點】而非字元,一次遞減會跳過整個文字節點。實測人類拖曳
10636
+ // 選取 105 次都不會產生元素邊界,但編輯器自己的 reselectAndReposition() 會。
10637
+ function trimRangeToText(range) {
10638
+ const parts = [];
10639
+ const take = (n) => {
10640
+ const len = n.data.length;
10641
+ let a, b;
10642
+ try { a = range.comparePoint(n, 0); b = range.comparePoint(n, len); }
10643
+ catch (e) { return; }
10644
+ if (b < 0 || a > 0) return; // 整個節點落在範圍外
10645
+ const from = (n === range.startContainer && n.nodeType === 3) ? range.startOffset : 0;
10646
+ const to = (n === range.endContainer && n.nodeType === 3) ? range.endOffset : len;
10647
+ if (to > from) parts.push({ n: n, from: from, to: to });
10648
+ };
10649
+ const root = range.commonAncestorContainer;
10650
+ const walk = (node) => {
10651
+ if (node.nodeType === 3) { take(node); return; }
10652
+ for (let c = node.firstChild; c; c = c.nextSibling) walk(c);
10653
+ };
10654
+ // v3.2.1 review: root.nodeType === 3 的情況已經由 walk() 自己的
10655
+ // `node.nodeType === 3` 分支正確處理(single-text-node 選取時
10656
+ // startContainer/endContainer 必為同一個 text node,也就是這裡的
10657
+ // root——take() 的 `n === range.startContainer`/`n === range.endContainer`
10658
+ // 判斷照樣成立)。原本 `root.nodeType === 3 ? root : root` 兩支完全相同,
10659
+ // 是無效的死碼,這裡直接呼叫 walk(root);journey 測試(選取整個落在單一
10660
+ // text node 內)已驗證這條路徑產出正確的 from/to。
10661
+ walk(root);
10662
+ if (!parts.length) return false;
10663
+ // 從頭砍掉空白
10664
+ while (parts.length) {
10665
+ const p = parts[0];
10666
+ while (p.from < p.to && /\s/.test(p.n.data[p.from])) p.from++;
10667
+ if (p.from < p.to) break;
10668
+ parts.shift();
10669
+ }
10670
+ // 從尾砍掉空白
10671
+ while (parts.length) {
10672
+ const p = parts[parts.length - 1];
10673
+ while (p.to > p.from && /\s/.test(p.n.data[p.to - 1])) p.to--;
10674
+ if (p.to > p.from) break;
10675
+ parts.pop();
10676
+ }
10677
+ if (!parts.length) return false; // 全空白選取 → 不包裝
10678
+ range.setStart(parts[0].n, parts[0].from);
10679
+ range.setEnd(parts[parts.length - 1].n, parts[parts.length - 1].to);
10680
+ return true;
10681
+ }
10682
+
9965
10683
  // Marks of `tag` that the range overlaps (fully or partially) — called
9966
10684
  // only AFTER wholeSelectionMark() has already returned null, so any hit
9967
10685
  // here is by construction a partial-overlap case (see the toggle-policy
@@ -10025,26 +10743,190 @@
10025
10743
  snapBurstIfActive(root, 'mark');
10026
10744
  return;
10027
10745
  }
10746
+ if (!trimRangeToText(range)) return; // 全空白/collapsed 選取:no-op
10028
10747
  reselectAndReposition(wrapRangeIn(range, tag));
10029
10748
  snapBurstIfActive(root, 'mark');
10030
10749
  }
10031
10750
 
10751
+ // Resolves once a `focusout` has been dispatched at `el` AND that dispatch
10752
+ // has fully finished, or after `ms` milliseconds if no focusout arrives at
10753
+ // all — whichever comes first.
10754
+ //
10755
+ // Two things about it are load-bearing and both were measured on the 🔗
10756
+ // gesture:
10757
+ //
10758
+ // * The wait is needed because the blur a native modal causes is delivered
10759
+ // on the browser's own schedule, NOT as a task queued by the code that
10760
+ // opened the modal: window.prompt() returned at t=27ms and the focusout
10761
+ // landed at t=28ms, i.e. after the caller had already returned. So a plain
10762
+ // setTimeout(0) is not a substitute for listening.
10763
+ // * The resolve is handed to a TIMER rather than called from the listener.
10764
+ // A listener's own resolve() only queues a microtask, and the HTML spec
10765
+ // performs a microtask checkpoint after EACH listener callback of a
10766
+ // browser-initiated dispatch — so an awaiter resumed from there runs
10767
+ // BEFORE the document-level focusout delegator further along the same
10768
+ // propagation path, and therefore before anything that delegator starts
10769
+ // exists to be waited on. Measured exactly that way: resolving from the
10770
+ // listener left `switching` still null at the awaiter, and the caret was
10771
+ // lost to the render that followed. A timer moves the resume to the next
10772
+ // task, after the whole dispatch.
10773
+ function nextFocusout(el, ms) {
10774
+ return new Promise((resolve) => {
10775
+ let deadline = null;
10776
+ const finish = () => {
10777
+ el.removeEventListener('focusout', onFocusout);
10778
+ if (deadline !== null) { clearTimeout(deadline); deadline = null; }
10779
+ resolve();
10780
+ };
10781
+ const onFocusout = () => { setTimeout(finish, 0); };
10782
+ el.addEventListener('focusout', onFocusout);
10783
+ deadline = setTimeout(finish, ms);
10784
+ });
10785
+ }
10786
+
10787
+ // v3.2.1: THIN WRAPPER over the toggle body below, same shape as
10788
+ // convertBlockViaMenu() — but the restoration cannot sit in a plain
10789
+ // synchronous finally here, and that is the whole difference. MEASURED, with
10790
+ // an in-page focus/mutation trace on the 🔗 button:
10791
+ //
10792
+ // t=21ms window.prompt() entered t=27ms prompt() returned
10793
+ // t=28ms focusout on the armed surface (the modal's own blur, delivered
10794
+ // after the body had already finished its DOM surgery)
10795
+ // t=31ms focusin — the browser hands focus straight back
10796
+ // t=52ms a SECOND focusout, this one from applyFullRender() (captured
10797
+ // stack: applyFullRender <- applyRenderResult <- rerenderAll)
10798
+ // t=66ms document.activeElement === BODY, toolbar down to 4 buttons
10799
+ //
10800
+ // So the thing that costs the user their foothold is not the modal: focus
10801
+ // comes back on its own. It is that the document's focusout delegator read
10802
+ // the modal's blur as "the user left this surface" and ran switchAwayFrom()
10803
+ // -> resolveBurst() -> commitEdit() -> rerenderAll(), and that render
10804
+ // replaced the whole .content subtree the restored focus was sitting in.
10805
+ // The other four inline-format buttons (toolbar-model.js's `group: 'inline'`
10806
+ // is bold / italic / strike / inline-code / link — five, of which link is
10807
+ // this one) never hit this, because none of them opens a modal and so none
10808
+ // of them ever blurs.
10809
+ //
10810
+ // The two awaits below are therefore each waiting for one specific thing:
10811
+ // nextFocusout() for that blur to be dispatched at all, and `switching` for
10812
+ // the commit + render it started to finish. Only then is there a live block
10813
+ // to aim at.
10814
+ async function applyLinkToggle() {
10815
+ const root = selToolbarEditEl;
10816
+ const sel = typeof window.getSelection === 'function' ? window.getSelection() : null;
10817
+ // hasFormattableSelection() is the body's own four guards spelled once (see
10818
+ // its comment). When it is false the body returns before it can reach
10819
+ // window.prompt(), so there is no modal, no blur, and nothing to wait for.
10820
+ // Waiting anyway would stall this call for the whole timeout and then move
10821
+ // the caret to the end of a block the user never edited.
10822
+ //
10823
+ // ⚠ Final wave, I3. `willPrompt` is a NECESSARY condition for a modal, not
10824
+ // a sufficient one, and the sentence that used to stand at the restore
10825
+ // said otherwise. Those four guards do match the body's four, but the body
10826
+ // has a FIFTH pre-prompt exit they know nothing about: the
10827
+ // `overlapping.length > 0` branch, where a selection that only PARTIALLY
10828
+ // overlaps an existing link unwraps it and returns without ever calling
10829
+ // window.prompt(). MEASURED on ddac6ce — selecting `lpha lin` across
10830
+ // `Alpha <a>linked</a> …` and clicking 🔗 gave `dialogs: 0` while
10831
+ // `willPrompt` was true. Behaviour was still correct there, but only
10832
+ // because the `stillHeld` gate below happened to catch it; the guarantee
10833
+ // the comment claimed did not exist. So the body now REPORTS whether it
10834
+ // prompted and the wait is gated on that report rather than on the
10835
+ // prediction. `blurred` still has to be ARMED before the body runs (the
10836
+ // blur it waits for is caused from inside it) — an armed-but-unused
10837
+ // promise settles on its own 400ms timeout, which removes its own listener
10838
+ // and resolves nobody.
10839
+ const willPrompt = hasFormattableSelection(sel);
10840
+ // Captured BEFORE the body: `selToolbarEditEl` is one of the things the
10841
+ // render nulls. The LINE survives that render — resolveBurst() commits
10842
+ // through commitEdit(state, burst.blockId, newText), an in-place replace of
10843
+ // this block's own line range, and ops.shiftBlocks() only shifts blocks
10844
+ // whose id is GREATER than the edited one, so nothing can move this block's
10845
+ // own start line.
10846
+ const rec = willPrompt && root && root.closest
10847
+ ? blockRecOf(root.closest('.ed-block')) : null;
10848
+ const line = rec ? rec.startLine : null;
10849
+ // Armed before the body runs, because the blur it is waiting for is caused
10850
+ // by a call inside the body. The timeout is the fallback for a browser that
10851
+ // does not blur on a modal at all; its cost when it fires is one caret
10852
+ // moved to the end of the block the link was just written into.
10853
+ const blurred = willPrompt && root ? nextFocusout(root, 400) : null;
10854
+ let prompted = false;
10855
+ try {
10856
+ prompted = applyLinkToggleBody() === true;
10857
+ } finally {
10858
+ if (blurred && prompted) {
10859
+ await blurred;
10860
+ if (switching) await switching;
10861
+ // Fix round 1, item 1: reaching here means the modal DID open (the
10862
+ // body said so), not that the user confirmed it. On Esc / cancel the body does no DOM
10863
+ // surgery, the burst goes out through endBurstWithoutResolve(), there
10864
+ // is no commit and no render — and the focus the modal took comes back
10865
+ // by itself (the t=31ms focusin in the trace above). Restoring on top
10866
+ // of that collapses the user's selection to a caret at the block's end.
10867
+ // Driven, 🔗 then Esc: with this gate the selection is still 'Alpha'
10868
+ // (non-collapsed, 20 buttons); without it, '' and collapsed.
10869
+ //
10870
+ // So the restore only fires when the foothold is actually GONE. The
10871
+ // accept path is unaffected — the render has just run and
10872
+ // activeElement is BODY there — and a 400ms timeout misfire becomes
10873
+ // harmless for the same reason.
10874
+ const held = document.activeElement;
10875
+ const stillHeld = held && held.closest &&
10876
+ held.closest('.ed-wys-armed, .ed-wys-cell, .ed-raw');
10877
+ // 有 blockSelection 站著時什麼都不做 —— 與 convertBlockViaMenu() 同一個
10878
+ // 判準、同一個理由:強行還原 caret 會做出「底色仍說已選取、Delete 卻是
10879
+ // 死鍵」的混種狀態。
10880
+ if (!stillHeld && !blockSelection && line != null) {
10881
+ focusBlockAtLine(line, true); // caretToEnd
10882
+ }
10883
+ // ⚠ C1, second occurrence of the same root cause. The re-aim used to
10884
+ // sit INSIDE the `!stillHeld` branch, so a call that kept its foothold
10885
+ // restored nothing at all — and on the PATCH render path (every commit
10886
+ // after a page load's first) applyPatch() hands focus back to the new
10887
+ // surface and only THEN runs resetToolbarBlock(), so "foothold held"
10888
+ // and "bar collapsed to 4" are the ordinary combination, not a
10889
+ // contradiction. Measured, 🔗 accept: unprimed P.ed-wys-armed/15,
10890
+ // primed P.ed-wys-armed/4. The caret restore stays gated (fix round
10891
+ // 1's Esc/cancel finding); only the bar comes out.
10892
+ if (!blockSelection && line != null) {
10893
+ // 就【這個還原目前所攜帶的座標】(一個 block 的起始行)而言,TABLE
10894
+ // 儲存格是還原不回 caret 的形狀:`root` 是 CELL,但 `line` 只能指到
10895
+ // 整張表這個 block,而 blockContentEl() 對 table 回傳
10896
+ // firstElementChild = <table> 本身,它沒有 tabindex、focus() 是
10897
+ // no-op。實測結果因此是 journey 的 bar-only 形狀(V2d 釘住):連結
10898
+ // 正確寫進磁碟、工具列 15 顆仍瞄著那張表,但 activeElement 是 BODY。
10899
+ // 這不是死路 —— 儲存格的 (row, col) 在 re-render 之間是穩定的,帶著
10900
+ // 它就能還原;只是本版沒有帶。reaim 是目前這個形狀下唯一還拿得回來
10901
+ // 的東西 —— 沒有它工具列會塌成 4 顆。
10902
+ reaimToolbarBlockAtLine(line);
10903
+ }
10904
+ }
10905
+ }
10906
+ }
10907
+
10032
10908
  // Link is its own entry point (not applyMarkToggle) because "unwrap" here
10033
10909
  // means "prompt to edit or clear the URL", and "wrap" means "prompt for a
10034
10910
  // URL first" — both need window.prompt() before any DOM surgery happens.
10035
- function applyLinkToggle() {
10911
+ //
10912
+ // Final wave, I3: returns TRUE iff this call actually reached a
10913
+ // window.prompt(), and false on every exit that did not — the four guards at
10914
+ // the top AND the partial-overlap unwrap in the middle, which is the one the
10915
+ // wrapper's `willPrompt` prediction cannot see. The wrapper waits for the
10916
+ // modal's blur only on a true.
10917
+ function applyLinkToggleBody() {
10036
10918
  const root = selToolbarEditEl;
10037
- if (!root) return;
10919
+ if (!root) return false;
10038
10920
  const sel = window.getSelection();
10039
- if (!sel.rangeCount) return;
10921
+ if (!sel.rangeCount) return false;
10040
10922
  const range = sel.getRangeAt(0);
10041
- if (range.collapsed) return;
10042
- if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) return;
10923
+ if (range.collapsed) return false;
10924
+ if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) return false;
10043
10925
 
10044
10926
  const whole = wholeSelectionMark(range, 'A', root);
10045
10927
  if (whole) {
10046
10928
  const url = window.prompt('連結網址(留空以移除連結):', whole.getAttribute('href') || '');
10047
- if (url === null) return; // cancelled — leave the link untouched
10929
+ if (url === null) return true; // cancelled — leave the link untouched
10048
10930
  if (url.trim() === '') {
10049
10931
  reselectAndReposition(unwrapElement(whole));
10050
10932
  } else {
@@ -10052,7 +10934,7 @@
10052
10934
  reselectAndReposition(range);
10053
10935
  }
10054
10936
  snapBurstIfActive(root, 'mark');
10055
- return;
10937
+ return true;
10056
10938
  }
10057
10939
  const overlapping = overlappingMarks(range, 'A', root);
10058
10940
  if (overlapping.length > 0) {
@@ -10060,10 +10942,10 @@
10060
10942
  overlappingMarks(extended, 'A', root).forEach((m) => unwrapElement(m));
10061
10943
  reselectAndReposition(extended);
10062
10944
  snapBurstIfActive(root, 'mark');
10063
- return;
10945
+ return false; // unwrapped in place — no modal ever opened
10064
10946
  }
10065
10947
  const url = window.prompt('連結網址:', 'https://');
10066
- if (url === null || url.trim() === '') return; // cancelled or empty — no-op
10948
+ if (url === null || url.trim() === '') return true; // cancelled or empty — no-op
10067
10949
  const el = document.createElement('a');
10068
10950
  el.setAttribute('href', url.trim());
10069
10951
  el.appendChild(range.extractContents());
@@ -10072,6 +10954,7 @@
10072
10954
  r.selectNodeContents(el);
10073
10955
  reselectAndReposition(r);
10074
10956
  snapBurstIfActive(root, 'mark');
10957
+ return true;
10075
10958
  }
10076
10959
 
10077
10960
  function buildSelToolbar() {
@@ -10114,7 +10997,14 @@
10114
10997
  // `position: fixed`.
10115
10998
  function positionSelToolbar(range) {
10116
10999
  const rect = range.getBoundingClientRect();
10117
- if (rect.width === 0 && rect.height === 0) { hideSelToolbar(); return; }
11000
+ // v3.2.1: 原本的零矩形檢查是死碼 —— 實測捲出視野的 Range 仍保有
11001
+ // 151x17 的矩形,永遠到不了這一支。改成真正的視窗外檢查。
11002
+ const topInset = parseFloat(getComputedStyle(document.documentElement)
11003
+ .getPropertyValue('--ed-toolbar-h')) || 0;
11004
+ if (rect.bottom <= topInset || rect.top >= window.innerHeight ||
11005
+ rect.right <= 0 || rect.left >= window.innerWidth) {
11006
+ hideSelToolbar(); return;
11007
+ }
10118
11008
  if (!selToolbar.parentNode) document.body.appendChild(selToolbar);
10119
11009
  const gap = 8, margin = 4;
10120
11010
  const tbRect = selToolbar.getBoundingClientRect();
@@ -10471,7 +11361,8 @@
10471
11361
  let toolbarMenuEl = null; // the H▾ dropdown panel, or null when closed
10472
11362
  let toolbarBlockEl = null;
10473
11363
  let toolbarStateSig = '';
10474
- // 追加 4's three-state document mode: 'edit' | 'source' | 'preview'.
11364
+ // 追加 4's document mode: 'edit' | 'source'. v3.2.1 removed the third
11365
+ // state ('preview'); docsource.js's MODES comment records why.
10475
11366
  // docsource.js owns the cycle order and the lines[]<->string conversion.
10476
11367
  let docMode = 'edit';
10477
11368
  let sourceTextarea = null;
@@ -10515,10 +11406,29 @@
10515
11406
  function resetToolbarBlock() {
10516
11407
  toolbarBlockEl = null;
10517
11408
  closeToolbarMenu();
10518
- // A render that lands while the document is in preview mode re-arms every
10519
- // surface as editable (armEditables() has no notion of modes), so the
10520
- // mode's one real effect has to be re-applied on the fresh DOM.
10521
- if (docMode === 'preview') applyPreviewEditability(false);
11409
+ updateToolbar();
11410
+ }
11411
+
11412
+ // v3.2.1: point the bar back at the block a structural op just produced.
11413
+ //
11414
+ // rerenderAll()'s teardown calls resetToolbarBlock() (toolbarBlockEl = null,
11415
+ // bar repainted in its no-block state: 4 enabled buttons), and nothing
11416
+ // re-points it unless a focusin/click lands on a block. That is fine when
11417
+ // the op ends with focusBlockAtLine() — the surface's focus event runs
11418
+ // wireToolbarTracking()'s focusin handler, which does exactly this. It is
11419
+ // NOT fine for a target with no focusable surface (quote / code / hr), where
11420
+ // focusBlockAtLine() silently no-ops and the bar would stay collapsed on a
11421
+ // block that is right there on screen. updateToolbar() is what repaints;
11422
+ // resetToolbarBlock() is idempotent, so re-pointing after it is safe.
11423
+ // Resolves nothing and touches nothing when `line` is null or the block is
11424
+ // gone (a refusal, a rolled-back render).
11425
+ function reaimToolbarBlockAtLine(line) {
11426
+ if (line == null) return;
11427
+ const target = blocks.find((b) => b.startLine === line);
11428
+ if (!target) return;
11429
+ const blockEl = document.querySelector('.ed-block[data-block-id="' + target.id + '"]');
11430
+ if (!blockEl) return;
11431
+ toolbarBlockEl = blockEl;
10522
11432
  updateToolbar();
10523
11433
  }
10524
11434
 
@@ -10591,9 +11501,17 @@
10591
11501
  if (TOOLBAR_TOGGLE_IDS.has(id)) {
10592
11502
  btn.setAttribute('aria-pressed', st.active ? 'true' : 'false');
10593
11503
  }
10594
- // Only `headings` carries a label (H / H1..H6); every other button
10595
- // keeps the glyph it was built with.
11504
+ // `headings` (H / H1..H6) and, since v3.2.1, the mode button carry a
11505
+ // derived label; every other button keeps the glyph it was built with.
10596
11506
  if (typeof st.label === 'string') btn.textContent = st.label;
11507
+ // v3.2.1: a per-mode title CANNOT be baked in buildToolbar() — the mode
11508
+ // button's title names the NEXT state, so it has to be rewritten every
11509
+ // time the mode changes. It rides the same diffed loop as everything
11510
+ // else: `title` is part of the state object, therefore part of `sig`.
11511
+ if (typeof st.title === 'string') {
11512
+ btn.title = st.title;
11513
+ btn.setAttribute('aria-label', st.title);
11514
+ }
10597
11515
  });
10598
11516
  }
10599
11517
 
@@ -10638,11 +11556,53 @@
10638
11556
  bar.appendChild(btn);
10639
11557
  btns[def.id] = btn;
10640
11558
  });
11559
+ // Fix round 1, item 3: a click on a DISABLED toolbar button used to take
11560
+ // the caret with it — the same gesture and the same symptom this release
11561
+ // exists for. MEASURED, and the two halves matter separately:
11562
+ //
11563
+ // * `mousedown` and `click` are NOT dispatched at all for a disabled
11564
+ // <button> — 0 of each at a capture-phase listener on `document`, for
11565
+ // the whole propagation path, so the per-button mousedown
11566
+ // preventDefault() a few lines above cannot run and neither could one
11567
+ // on this container.
11568
+ // * `pointerdown` IS dispatched (1 at that same document listener) and is
11569
+ // cancellable, and cancelling it suppresses the compatibility mousedown
11570
+ // together with its default action — which is the focus move.
11571
+ //
11572
+ // Driven, caret in a paragraph, clicking the disabled 🅑: without this
11573
+ // handler activeElement was BODY in both the untouched-burst state (bar
11574
+ // still 15) and the typed-in state (bar down to 4, because that focusout
11575
+ // ran a full commit + render); with it, P.ed-wys-armed / 15 in both.
11576
+ //
11577
+ // Scope: capture phase on the bar, and preventDefault() ONLY when the
11578
+ // press resolves to a disabled button. Never stopPropagation() — the
11579
+ // document-level pointerdown delegator (table grips, ⠿ block drag, block
11580
+ // selection) still sees the event, and finds nothing to arm, exactly as
11581
+ // before. An ENABLED button, a separator, the status slot and the bar's
11582
+ // own background are all untouched. The H▾ dropdown is NOT inside this
11583
+ // subtree (toggleToolbarHeadingMenu() appends it to document.body), so it
11584
+ // is out of reach here and keeps its own per-item mousedown handler.
11585
+ bar.addEventListener('pointerdown', (e) => {
11586
+ const pressed = e.target && e.target.closest ? e.target.closest('button') : null;
11587
+ if (pressed && pressed.disabled) e.preventDefault();
11588
+ }, true);
10641
11589
  toolbarBtns = btns;
10642
11590
  // v3.1.0 的 spec 承諾「元素存在但無內容,§3 上線時填入、不需要重排版面」,
10643
- // 但元素從未被建立。這裡把承諾兌現:佔好寬度、無內容。
11591
+ // 但元素從未被建立。這裡把承諾兌現:佔好寬度。內容不再是空的 ——
11592
+ // paintModeStatus() 會在 mountToolbar() 掛上之後、以及每次 setDocMode()
11593
+ // 切換時寫入當前模式名稱。
10644
11594
  const statusEl = document.createElement('span');
10645
11595
  statusEl.className = 'ed-toolbar-status';
11596
+ // Final wave, M7: the only announcement a mode switch ever gets. Every
11597
+ // toolbar button is `tabindex="-1"` (see buildToolbar()'s own note), so a
11598
+ // screen reader is never focused on the mode button when it is pressed and
11599
+ // hears nothing about the state that just changed; the slot's text is the
11600
+ // only thing that says so. `role="status"` carries an implicit
11601
+ // aria-live="polite", and the explicit attribute is written alongside it
11602
+ // because that implication is the part assistive tech has historically
11603
+ // been inconsistent about — belt and braces, one line, no layout cost.
11604
+ statusEl.setAttribute('role', 'status');
11605
+ statusEl.setAttribute('aria-live', 'polite');
10646
11606
  bar.appendChild(statusEl);
10647
11607
  return bar;
10648
11608
  }
@@ -10960,18 +11920,90 @@
10960
11920
  await insertBlockBelow(target, 'paragraph', { lines: newLines });
10961
11921
  }
10962
11922
 
10963
- function pickAndInsertImage() {
11923
+ // v3.2.1: 「插入圖片竟然無法在我當前的位置後插入,每次都跑到最後」. Two
11924
+ // separate mechanisms put the image at EOF, and BOTH were measured — the
11925
+ // second one is the reason this function is longer than it looks like it
11926
+ // needs to be.
11927
+ //
11928
+ // (a) `input.click()` below is a SYNTHETIC click. It bubbles to the
11929
+ // document delegator, which reads it as a click landing outside every
11930
+ // block → switchAwayFrom() → commit + re-render → the anchor node
11931
+ // captured a moment earlier is detached → insertImages() falls back to
11932
+ // lastBlockEl() and the image lands at the end of the file.
11933
+ // Deterministic whenever the block the user is in is dirty, which is
11934
+ // exactly the reported gesture.
11935
+ //
11936
+ // (b) stopPropagation() on that click is NECESSARY BUT NOT SUFFICIENT. It
11937
+ // fixes the reported gesture, yet the anchor stayed a raw DOM node
11938
+ // held across the file dialog: a FULL render landing between the click
11939
+ // and the `change` event detaches it and the image is back at EOF.
11940
+ // The v3.2.0 patch path is not the exposure — two forced mid-dialog
11941
+ // renders (a commit in another block, a ⠿ 刪除 of another block) were
11942
+ // measured to patch and leave the anchor attached — but a full render
11943
+ // is one patchmap() miss away (it returns null whenever there is no
11944
+ // usable editRange), and a real OS file dialog stays open for as long
11945
+ // as the user takes. So the window is small, not hypothetical.
11946
+ //
11947
+ // Hence both halves: stop the synthetic click from reading as an
11948
+ // outside-click, AND resolve the open session BEFORE the picker opens while
11949
+ // carrying the anchor as a stable IDENTITY that is re-resolved when the
11950
+ // files come back, instead of as a node reference. The identity triple is
11951
+ // insertBlockBelow()'s (captureBlockIdentity / ownsOpenSession /
11952
+ // reresolveBlockEl + reresolveBlockElAfterSelfCommit) — that function solved
11953
+ // this same problem for its own anchor and the image path simply never
11954
+ // adopted it. Do not "simplify" the re-resolution away: it is the half that
11955
+ // survives the dialog.
11956
+ //
11957
+ // ⚠ ORDERING: `await switchAwayFrom()` runs BEFORE input.click(). Chrome
11958
+ // gates the file picker on TRANSIENT user activation, which is time-based
11959
+ // (~5 s) and is NOT spent by awaiting a promise, so the picker still opens.
11960
+ // The journey scenario presses 🖼 on a DIRTY block, so a real commit — an
11961
+ // /api/render round trip — runs inside that await; if the activation were
11962
+ // spent, waitForFileChooser() there would time out. A commit slower than
11963
+ // the activation window would be the one way this ordering could bite.
11964
+ async function pickAndInsertImage() {
11965
+ // Captured BEFORE switchAwayFrom(), because that is what can renumber the
11966
+ // ids and rewrite the source — see captureBlockIdentity()'s comment.
11967
+ const blockEl = liveToolbarBlock() || lastBlockEl();
11968
+ const identity = captureBlockIdentity(blockEl);
11969
+ // The commit inside switchAwayFrom() can be a rewrite of THIS block's own
11970
+ // source, in which case reresolveBlockEl()'s fingerprint is guaranteed to
11971
+ // miss because WE are the reason the source changed; the narrowed
11972
+ // re-resolve is used only then. Same reasoning as insertBlockBelow().
11973
+ const selfSession = ownsOpenSession(blockEl);
11974
+ if (!(await switchAwayFrom())) return;
11975
+ const anchorEl = (blockEl && document.body.contains(blockEl))
11976
+ ? blockEl
11977
+ : (reresolveBlockEl(identity) ||
11978
+ (selfSession ? reresolveBlockElAfterSelfCommit(identity) : null));
11979
+ if (!anchorEl) { showBanner(TOOLBAR_NO_ANCHOR_MESSAGE, null, null); return; }
11980
+ // Re-captured AFTER the commit: `identity` above holds the PRE-commit
11981
+ // fingerprint, which the commit we just awaited may itself have
11982
+ // invalidated. What has to survive the dialog is the anchor as it stands
11983
+ // now.
11984
+ const dialogIdentity = captureBlockIdentity(anchorEl);
10964
11985
  const input = document.createElement('input');
10965
11986
  input.type = 'file';
10966
11987
  input.accept = 'image/png,image/jpeg,image/gif,image/webp';
10967
11988
  input.multiple = true;
10968
11989
  input.hidden = true;
10969
- const anchorEl = liveToolbarBlock() || lastBlockEl();
11990
+ // (a) above.
11991
+ input.addEventListener('click', (e) => e.stopPropagation());
10970
11992
  document.body.appendChild(input);
10971
11993
  input.addEventListener('change', () => {
10972
11994
  const files = input.files ? Array.prototype.slice.call(input.files) : [];
10973
11995
  input.remove();
10974
- if (files.length) insertImages(files, anchorEl);
11996
+ if (!files.length) return;
11997
+ // (b) above. A render that landed while the dialog was open detached
11998
+ // `anchorEl`; the identity still names the block the user pointed at.
11999
+ // If even that misses, the block the user aimed at no longer exists as
12000
+ // it was — refuse (and skip the upload, so no orphan lands in the
12001
+ // user's assets/) rather than silently appending at EOF, which is the
12002
+ // very symptom being fixed.
12003
+ const live = document.body.contains(anchorEl)
12004
+ ? anchorEl : reresolveBlockEl(dialogIdentity);
12005
+ if (!live) { showBanner(TOOLBAR_NO_ANCHOR_MESSAGE, null, null); return; }
12006
+ insertImages(files, live);
10975
12007
  });
10976
12008
  input.click();
10977
12009
  }
@@ -10998,9 +12030,10 @@
10998
12030
  // assets/x.png orphaned on disk. No error, no visible effect, and a stray
10999
12031
  // binary left in what is usually a git working tree.
11000
12032
  //
11001
- // In PREVIEW mode the toolbar reports every editing button as disabled, so
11002
- // the mode PRESENTS as read-only; a drop that silently wrote a file and
11003
- // inserted a block would make that presentation a lie.
12033
+ // v3.2.1: `!== 'edit'` rather than `=== 'source'` on purpose. Source is
12034
+ // the only other mode today, but the gate's reason is "the content the
12035
+ // user is looking at is not the content a drop would write into" — a
12036
+ // future third mode inherits the refusal rather than the hole.
11004
12037
  document.addEventListener('dragover', (e) => {
11005
12038
  if (docMode !== 'edit') return;
11006
12039
  if (!dragCarriesFiles(e)) return;
@@ -11117,12 +12150,9 @@
11117
12150
  }
11118
12151
 
11119
12152
  // ── 追加 4: the whole-document source escape hatch ─────────────────────
11120
- function applyPreviewEditability(editable) {
11121
- const surfaces = contentEl.querySelectorAll('.ed-wys-armed, .ed-wys-cell');
11122
- for (let i = 0; i < surfaces.length; i += 1) {
11123
- surfaces[i].setAttribute('contenteditable', editable ? 'true' : 'false');
11124
- }
11125
- }
12153
+ // v3.2.1: applyPreviewEditability() lived here and was preview mode's ONLY
12154
+ // effect. Preview is gone (docsource.js's MODES comment carries the
12155
+ // measurement), and with it the last caller of this function.
11126
12156
 
11127
12157
  function enterSourceMode() {
11128
12158
  // The pre-edit snapshot fromSource() diffs against. Omitting it makes
@@ -11194,14 +12224,21 @@
11194
12224
  const ok = await leaveSourceMode();
11195
12225
  if (!ok) return;
11196
12226
  }
11197
- if (docMode === 'preview') applyPreviewEditability(true);
11198
12227
  docMode = next;
11199
12228
  if (docMode === 'source') enterSourceMode();
11200
- if (docMode === 'preview') applyPreviewEditability(false);
11201
12229
  document.body.setAttribute('data-ed-mode', docMode);
12230
+ paintModeStatus();
11202
12231
  updateToolbar();
11203
12232
  }
11204
12233
 
12234
+ // v3.2.1: 模式必須說出來 —— 使用者按第二下以為卡住,其實是進了 source。
12235
+ // Called from setDocMode() AND from mountToolbar(), so the slot is truthful
12236
+ // from the first paint rather than only after the first mode change.
12237
+ function paintModeStatus() {
12238
+ const statusEl = document.querySelector('.ed-toolbar-status');
12239
+ if (statusEl) statusEl.textContent = docMode === 'source' ? '原始碼' : '編輯';
12240
+ }
12241
+
11205
12242
  // ── mount ──────────────────────────────────────────────────────────────
11206
12243
  function wireToolbarTracking() {
11207
12244
  // Which block the bar reports on. Delegated at document level (never a
@@ -11246,6 +12283,7 @@
11246
12283
  // grips and the table edge menu.
11247
12284
  document.body.appendChild(toolbarEl);
11248
12285
  document.body.setAttribute('data-ed-mode', docMode);
12286
+ paintModeStatus();
11249
12287
  wireToolbarTracking();
11250
12288
  wireImageDrop();
11251
12289
  wirePlainPasteArming();
@@ -11708,6 +12746,58 @@
11708
12746
  });
11709
12747
  });
11710
12748
 
12749
+ // v3.2.1: client.js 原本的 scroll 監聽器數量是 0,所以每一個 position:fixed
12750
+ // 的浮層在捲動後都停在原視窗座標。grip/bubble 有 mousemove 驅動可重新升起,
12751
+ // 隱藏它們就夠;.ed-seltb 沒有(它只由 selectionchange 與
12752
+ // applyMarkToggle/applyLinkToggle 驅動,而捲動不觸發 selectionchange),
12753
+ // 純隱藏會讓它永遠回不來,所以它要重新定位、只在選取整個離開視窗時才隱藏。
12754
+ // capture 階段是必要的:真正會自己捲的不是 .content(overflow-y: visible,
12755
+ // 實測 scrollHeight === clientHeight,捲的其實是 document)——是側欄的
12756
+ // .toc-list/#search-results-list(皆 overflow-y: auto,TOC 標題夠多時
12757
+ // 實測 scrollHeight > clientHeight)與 .lightbox-stage(overflow: auto)。
12758
+ // 這些容器的 scroll 事件不冒泡,只有 capture 階段收得到。
12759
+ // 副作用(已記錄,非缺陷):正因為 capture 連這些側欄容器的捲動都收,捲動
12760
+ // TOC 或搜尋結果清單時——即使正文內容一格都沒動——也會隱藏 table grips
12761
+ // 並關掉開著的 .ed-te-menu。
12762
+ //
12763
+ // 這個監聽器刻意是「模組層級、只掛一次、永不移除」,不跟著任何一次
12764
+ // burst 的生命週期走(不在 resetSelToolbarState() 裡拆)。理由:真正會
12765
+ // 刪資料的 .ed-te-menu/grip 家族(見上面 Task 5 的 mousemove 委派)不需要
12766
+ // 任何 burst 就能被叫出來 —— 使用者可以單純把游標移到表格上(觸發上面那個
12767
+ // 無條件掛載的 mousemove)升起 grip、打開 .ed-te-menu,此時完全沒有
12768
+ // selToolbarListener(那個監聽器只在 burst 開著時才存在)。若把這個捲動
12769
+ // 監聽器綁在 burst 身上,恰好在「grip/選單開著但沒有 burst」這個狀態下
12770
+ // 它會不存在。resetSelToolbarState() 本身也會在每次 rerender 時執行(六個
12771
+ // 呼叫點之一在本檔的 rerenderAll() 路徑上),把監聽器綁在它身上還會讓
12772
+ // 監聽器在 session 中途被拆掉、且永不重掛。
12773
+ let selScrollRaf = 0;
12774
+ function onAnyScroll() {
12775
+ if (tePointer && tePointer.dragging) return; // 拖曳中不動
12776
+ hideTableGrips();
12777
+ hideTableInsertBubbles();
12778
+ // v3.2.1 fix round 1: suppressEdgeMenuAutoHide — see its own comment —
12779
+ // is set for runCycleAlign()'s ensureTableBurstOpen() span, whose
12780
+ // cell.focus() can itself be the cause of this very scroll event; that
12781
+ // operation repositions the menu itself afterward, so skip tearing its
12782
+ // state down here (grip/bubble/toolbar-menu hiding above and below is
12783
+ // unaffected — they re-raise on the next mousemove regardless).
12784
+ if (!suppressEdgeMenuAutoHide) hideTableEdgeMenu(); // ← 真正會刪資料的是選單,不是 grip
12785
+ closeToolbarMenu();
12786
+ if (selScrollRaf) return;
12787
+ selScrollRaf = requestAnimationFrame(() => {
12788
+ selScrollRaf = 0;
12789
+ if (!selToolbar.parentNode) return; // 沒升起 → 0 次 layout read
12790
+ const sel = window.getSelection();
12791
+ if (!sel || !sel.rangeCount || sel.isCollapsed) { hideSelToolbar(); return; }
12792
+ const range = sel.getRangeAt(0);
12793
+ if (!selToolbarEditEl ||
12794
+ !selToolbarEditEl.contains(range.startContainer) ||
12795
+ !selToolbarEditEl.contains(range.endContainer)) { hideSelToolbar(); return; }
12796
+ positionSelToolbar(range);
12797
+ });
12798
+ }
12799
+ document.addEventListener('scroll', onAnyScroll, { passive: true, capture: true });
12800
+
11711
12801
  window.addEventListener('beforeunload', (e) => {
11712
12802
  if (stack.dirtyDepth !== 0) {
11713
12803
  e.preventDefault();