@helping-ai-workflow/md2doc 3.0.2 → 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.
@@ -520,7 +520,18 @@
520
520
  const convertMd = window.md2docConvertMd;
521
521
  const selectionLib = window.md2docSelection;
522
522
  const historyLib = window.md2docHistory;
523
+ // v3.1.0 Task E: the four Phase-1 modules, injected by server.js ahead of
524
+ // this file. Read the same way every earlier module is — off `window`, not
525
+ // via require() (this script is a plain <script> tag in the page, not a
526
+ // bundle). Each is read defensively at its call sites: an older cached page
527
+ // that predates the injection must degrade to "the toolbar is absent",
528
+ // never to "the whole client runtime throws before it arms anything".
529
+ const toolbarModel = window.md2docToolbarModel;
530
+ const pasteMd = window.md2docPasteMd;
531
+ const assetLib = window.md2docAsset;
532
+ const docSourceLib = window.md2docDocSource;
523
533
  let lines = ED.lines, blocks = ED.blocks, mtimeMs = ED.mtimeMs;
534
+ let lastParts = null;
524
535
  // 檔案原本的換行符。lines 內部永遠是不含 \r 的純內容行;只有 save()
525
536
  // 會把它接回這個 EOL,render 一律用 \n(spec §3.11)。
526
537
  const EOL = ED.eol || '\n';
@@ -816,7 +827,7 @@
816
827
  // response — WITHOUT touching contentEl.innerHTML or `blocks`, and shows
817
828
  // a dismissible banner explaining what happened. Never throws: every
818
829
  // await is inside its own try/catch, so callers never see a rejection.
819
- async function rerenderAll() {
830
+ async function rerenderAll(editRange) {
820
831
  const scrollY = window.scrollY;
821
832
  // S3 Task 5 (§4.4 step 2): the line range this render's operation declared
822
833
  // for the rebuilt selection. Consumed HERE, before the first failure exit,
@@ -849,7 +860,7 @@
849
860
  showBanner('Render failed — malformed server response. Your edit was not applied.', null, null);
850
861
  return false;
851
862
  }
852
- if (typeof j.bodyHtml !== 'string' || !Array.isArray(j.blocks)) {
863
+ if (!Array.isArray(j.parts) || !Array.isArray(j.blocks)) {
853
864
  showBanner('Render failed — malformed server response. Your edit was not applied.', null, null);
854
865
  return false;
855
866
  }
@@ -857,21 +868,667 @@
857
868
  // gesture asked for it has succeeded. Review recommendation 5 — a stale
858
869
  // structural refusal is cleared HERE and nowhere else.
859
870
  dismissRefusalBanner();
871
+ // Task E: rerenderAll() now takes `editRange` as its own parameter —
872
+ // every call site that has a real op computes it there (see the 18
873
+ // safeRerenderAll() call sites that pass one) and threads it down through
874
+ // safeRerenderAll() to here. Passed straight through to
875
+ // applyRenderResult() as the second positional argument; Task F is what
876
+ // actually reads it to decide patch-vs-fallback.
877
+ //
878
+ // Fix round 1: `scrollY` and `declaredRange` are captured ABOVE, before
879
+ // the extraction boundary (they must be read early — see their own
880
+ // comments — well before this point), so they are NOT part of the
881
+ // moved tail and are out of `applyFullRender`'s scope unless threaded
882
+ // through explicitly. Passed positionally here and through
883
+ // applyRenderResult() rather than folded into `j` (which is the
884
+ // server's response payload, not render-context the CALLER derived).
885
+ return applyRenderResult(j, editRange, scrollY, declaredRange);
886
+ }
887
+
888
+ // v3.2.0 Task F: patch-vs-fallback dispatch.
889
+ //
890
+ // `scrollY`/`declaredRange` are rerenderAll()'s own pre-fetch captures
891
+ // (see its comments) — applyFullRender() needs them for the same
892
+ // window.scrollTo()/rebuildBlockSelection() calls the tail always made,
893
+ // but as a sibling function it cannot reach rerenderAll()'s locals by
894
+ // closure, so they ride along here instead.
895
+ //
896
+ // `scrollY` is forwarded to applyFullRender() ONLY (controller ruling F3):
897
+ // the fallback needs it because `contentEl.innerHTML = ...` destroys and
898
+ // rebuilds every node, collapsing the scroll extent to zero for an instant;
899
+ // the patch path leaves the prefix and suffix nodes attached, so the scroll
900
+ // position never moves and re-applying a pre-fetch scrollY would fight the
901
+ // browser (the reader may legitimately be somewhere else by the time the
902
+ // response lands).
903
+ //
904
+ // `editRange` gates the patch attempt AS A BOOLEAN and in no other way: the
905
+ // 3 call sites with no committed op pass nothing and fall back by contract.
906
+ // Its VALUE is forwarded to patchmap(), which never reads it (決議 4(b) — it
907
+ // is advisory, and seeding the span from an op's line range measurably
908
+ // widens the answer), so neither the direction nor the width of the range
909
+ // affects the plan. `lastParts` is null
910
+ // on the first render of a session (the bootstrap payload carries no parts),
911
+ // which makes patchmap() return null — so the first commit after a page load
912
+ // is ALWAYS a full render. That is specified behaviour, not a bug.
913
+ // Fix round 2: applyPatch()'s index arithmetic addresses `contentEl`'s
914
+ // element children POSITIONALLY against `lastParts`. That correspondence is
915
+ // not free — several gestures mutate .content's child list DIRECTLY, in the
916
+ // window between two renders, and every one of them breaks it:
917
+ //
918
+ // :insertListItemAfter() inserts a PROVISIONAL li (no data-block-id at
919
+ // all) as a sibling before its own commit renders
920
+ // :splitListItemAtCaret() same shape, on Enter inside a li
921
+ // :duplicateListItem() inserts cloneNode()d copies, which carry the
922
+ // ORIGINAL's data-block-id — duplicated ids
923
+ // :moveListItem() re-inserts existing li elements at a new
924
+ // position: the COUNT is unchanged and every id
925
+ // is still present, only the ORDER moved
926
+ // :removeListItem() REMOVES li elements outright (the Backspace-
927
+ // join and multi-item-delete paths), so the
928
+ // child count drops below lastParts.length
929
+ // before the render that would explain it
930
+ //
931
+ // MEASURED on the +→清單 gesture with a dirty burst open on the anchor
932
+ // (test/editor-client-runtime.test.js:3918): two commits land back to back,
933
+ // and at the second one `lastParts.length` is 3 while contentEl has 4
934
+ // children. patchmap computes a zero-width insertion at index 2, the removal
935
+ // loop removes nothing, and the fragment is inserted BEFORE the provisional
936
+ // node — which nobody then removes. Four li blocks where three belong. The
937
+ // fallback never had the problem: `innerHTML =` wipes whatever the gesture
938
+ // left behind.
939
+ //
940
+ // So the patch path checks, cheaply and positionally, that the DOM still
941
+ // reads exactly as the last render left it: same number of children, and
942
+ // child i still carrying block i's id. Any of the four mutations above fails
943
+ // one of those, and that render falls back — i.e. exactly the pre-v3.2.0
944
+ // behaviour for those gestures, which is why this cannot regress them.
945
+ //
946
+ // ⚠ What it does NOT check is whether a kept node's MARKUP still equals the
947
+ // part that produced it — a gesture that rewrites an attribute in place
948
+ // (data-indent, data-list-start) without changing the child list passes this
949
+ // guard. That is a different family (kept nodes carrying stale local state)
950
+ // and is tracked separately; do not grow this predicate into it without
951
+ // measuring the cost, because unlike this walk that one is not O(1) per node.
952
+ function domMatchesLastRender() {
953
+ if (!Array.isArray(lastParts) || !Array.isArray(blocks)) return false;
954
+ if (blocks.length !== lastParts.length) return false;
955
+ const kids = contentEl.children;
956
+ if (kids.length !== lastParts.length) return false;
957
+ for (let i = 0; i < kids.length; i++) {
958
+ // Compared against blocks[i].id rather than against `i`: the two are the
959
+ // same today (blockmap.js hands out a positional ordinal, which is what
960
+ // makes patchmap's idDelta rewrite work at all) but the JOIN KEY is the
961
+ // id, and stating it that way is what keeps this honest if that identity
962
+ // is ever relaxed.
963
+ if (kids[i].getAttribute('data-block-id') !== String(blocks[i].id)) return false;
964
+ }
965
+ return true;
966
+ }
967
+
968
+ async function applyRenderResult(j, editRange, scrollY, declaredRange) {
969
+ const plan = (editRange && window.md2docPatchmap && domMatchesLastRender())
970
+ ? window.md2docPatchmap.patchmap({
971
+ oldBlocks: blocks, newBlocks: j.blocks,
972
+ oldParts: lastParts, newParts: j.parts, editRange: editRange,
973
+ })
974
+ : null;
975
+ if (plan) return applyPatch(j, plan, declaredRange);
976
+ return applyFullRender(j, scrollY, declaredRange);
977
+ }
978
+
979
+ // Incremental DOM patch: replace ONLY the middle span patchmap identified,
980
+ // leaving every prefix and suffix node attached. Keeping those nodes in
981
+ // place is the whole point — it is what preserves document.activeElement,
982
+ // the caret offset, already-rendered mermaid/WaveDrom SVGs and the scroll
983
+ // position across a commit.
984
+ //
985
+ // `declaredRange` arrives as a PARAMETER and is never re-read from
986
+ // `pendingSelectionRange` here (controller ruling F2): rerenderAll() already
987
+ // consumed that module-level slot before it even issued the fetch (see its
988
+ // own comment for why it must be consumed that early), so re-reading it here
989
+ // would always yield undefined and silently drop selection restoration on
990
+ // every patched commit.
991
+ async function applyPatch(j, plan, declaredRange) {
992
+ // 步驟 4 的五步。順序是 spec 決議 2(c) 定死的:
993
+ // 1) 消費 pendingSelectionRange(已由 rerenderAll() 在 fetch 之前做掉,
994
+ // 值以 declaredRange 參數傳進來——ruling F2)
995
+ // 2) 組 <template> 並 armEditables(t.content) —— 唯一可以吃容器的一件事
996
+ // 3) 移除 replaceSpan 節點、insertBefore 插入
997
+ // 4) 重寫被保留後綴節點的 data-block-id
998
+ // 5) rebuildBlockSelection(declaredRange) → __md2docInitDiagrams(contentEl)
999
+ // → __md2docRebindReader() → applySelectionClasses()
1000
+ //
1001
+ // Step 5's internal order matches applyFullRender()'s, deliberately:
1002
+ // rebuildBlockSelection() sits BEFORE the two `try`-swallowed rebind
1003
+ // blocks so a diagram-init throw can never skip it (applyFullRender()'s
1004
+ // own comment says exactly that). The brief's snippet listed the rebinds
1005
+ // first; its prose says to preserve the existing relative order, and the
1006
+ // prose is what is right — a selection silently lost on the render that
1007
+ // introduced the document's first mermaid fence is the failure it names.
1008
+ //
1009
+ // The snapshot is taken BEFORE any mutation: every index below
1010
+ // (`oldStart`..`oldEnd`, and the `oldEnd + 1` reference node) is an index
1011
+ // into the PRE-patch child list, which is what patchmap computed against.
1012
+ // contentEl's element children are the `.ed-block` parts, 1:1 and in
1013
+ // document order: lib/md2doc.js emits each part as a single
1014
+ // `<div class="ed-block" …>…</div>` (the only two emit sites) and the
1015
+ // client only ever assigns `parts.join('\n')` into contentEl. The chrome
1016
+ // is never a direct child — the ⠿ handle, the + button, the gutter menu
1017
+ // and the insert menu all go on a BLOCK, and the source-mode textarea is
1018
+ // inserted as contentEl's SIBLING.
1019
+ //
1020
+ // Fix round 2: that is NOT unconditionally true between renders — four
1021
+ // list gestures mutate this child list directly. domMatchesLastRender()
1022
+ // (see its comment, above applyRenderResult) is what establishes the
1023
+ // property before we are called; it is a PRECONDITION of everything
1024
+ // below, not something this function re-derives.
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;
1051
+ // Fix round 1 (review Important 1): `blocks`/`lastParts` are the JOIN KEY
1052
+ // between the module's model and the DOM, and they are overwritten BEFORE
1053
+ // the DOM is touched — armEditables() reads the new `blocks` (see its call
1054
+ // below), so the order is forced. That opens a window in which a throw
1055
+ // would leave the model describing a DOM that was never built: the
1056
+ // exception bubbles to safeRerenderAll()'s catch, the user is told "Your
1057
+ // edit was not applied", `false` comes back — and the NEXT commit then
1058
+ // diffs parts describing blocks that do not exist, so every index is
1059
+ // wrong and nothing reports it. Same silent-failure shape as ruling F4.
1060
+ //
1061
+ // applyFullRender() has no such window: its `blocks`/`lastParts` →
1062
+ // innerHTML → armEditables() order means the same throw leaves a
1063
+ // consistent "DOM swapped, just not armed" state that the next render
1064
+ // heals. So the patch path buys the equivalent guarantee explicitly:
1065
+ // capture, and put the whole mutation window in a try that rolls the two
1066
+ // back and lets the FALLBACK land the commit instead of losing it.
1067
+ const prevBlocks = blocks;
1068
+ const prevLastParts = lastParts;
860
1069
  blocks = j.blocks;
861
- contentEl.innerHTML = j.bodyHtml;
862
- // Task 2 (Phase 3): re-arm every WYSIWYG-eligible paragraph/heading (and
863
- // attach a ⠿ handle to every non-table block) in the freshly-swapped
864
- // DOM — see armEditables() below. Must run before anything else touches
865
- // the fresh nodes (diagram re-init, reader rebind, focus restoration).
866
- armEditables(contentEl);
1070
+ lastParts = j.parts;
1071
+
1072
+ try {
1073
+ // <template> 是刻意指名的,不是「隨便一種容器」:
1074
+ // createDocumentFragment() + innerHTML -> innerHTML 不存在,什麼都插不進去、不報錯
1075
+ // Range.createContextualFragment() -> 文件裡作者手寫的 raw <script> 每次 patch 重新執行
1076
+ // 暫存 <div> 也正確,只多一圈 child drain。
1077
+ const tpl = document.createElement('template');
1078
+ tpl.innerHTML = plan.replaceSpan.newParts.join('\n');
1079
+ // 必須在 blocks 已換成新陣列之後——armEditables 的 li 分支走
1080
+ // canWysiwygForLi → blockOwnsNoLine,那支讀的是模組層的 `blocks`;
1081
+ // 用舊陣列判斷會把一個「不擁有任何來源行」的 li 誤武裝成 contenteditable。
1082
+ //
1083
+ // ⚠ 只對新片段呼叫,永遠不要對被保留的前綴/後綴節點再跑一次:
1084
+ // armEditables 不是 idempotent(⠿ handle 與 + button 是無條件
1085
+ // appendChild),double-arm 實測會變成 10 個 handle 而不是 5 個。
1086
+ armEditables(tpl.content);
1087
+
1088
+ // 保留節點永不 detach:只移除中段,其餘原地不動。
1089
+ // 不得用 replaceChildren() 或 remove-and-re-append 重建整份子節點列表——
1090
+ // 實測 detach 再插回同一位置會讓 document.activeElement 掉回 BODY。
1091
+ //
1092
+ // Fix round 1 (review, comment-only): this removes ELEMENTS, so the `\n`
1093
+ // TEXT nodes `parts.join('\n')` left between the removed blocks stay
1094
+ // behind. Harmless today — `contentEl.children` and allBlockEls()'s
1095
+ // querySelectorAll both skip text nodes, and nothing in this file reads
1096
+ // `contentEl.childNodes` — but it does mean `.content`'s child-NODE shape
1097
+ // after a patch differs from its shape after a full render, which would
1098
+ // matter to any future test that asserts on childNodes rather than
1099
+ // children.
1100
+ for (let i = plan.replaceSpan.oldStart; i <= plan.replaceSpan.oldEnd; i++) {
1101
+ if (children[i]) contentEl.removeChild(children[i]);
1102
+ }
1103
+ // 參考節點是「第一個被保留的後綴節點」。它在 children 快照裡的 index 是
1104
+ // oldEnd + 1,而且因為後綴從未被 detach,那個 object 現在仍在 DOM 裡。
1105
+ // 後綴為空時是 null,insertBefore(frag, null) 等同 append。
1106
+ const refNode = children[plan.replaceSpan.oldEnd + 1] || null;
1107
+ contentEl.insertBefore(tpl.content, refNode); // fragment 會被排空並保序
1108
+
1109
+ // 被保留的後綴節點的 data-block-id 已經過期(id 是位置序號)。
1110
+ // 前綴恆為 0:id 即索引,前綴在兩份陣列裡都佔 0..k-1。
1111
+ if (plan.idDelta !== 0) {
1112
+ const all = contentEl.children;
1113
+ for (let i = all.length - plan.keepSuffix; i < all.length; i++) {
1114
+ const el = all[i];
1115
+ const oldId = Number(el.getAttribute('data-block-id'));
1116
+ el.setAttribute('data-block-id', String(oldId + plan.idDelta));
1117
+ }
1118
+ }
1119
+ } catch (e) {
1120
+ // Roll the model back to what the (still-unmutated, or only partly
1121
+ // mutated) DOM last agreed with, then land the commit through the
1122
+ // fallback rather than losing it — applyFullRender() rebuilds .content
1123
+ // wholesale, so it is correct from a half-patched DOM too.
1124
+ //
1125
+ // ⚠ `window.scrollY` here does NOT violate ruling F3. F3 forbids
1126
+ // re-applying rerenderAll()'s STALE pre-fetch snapshot on the patch
1127
+ // path, because the patch path never collapses the scroll extent. This
1128
+ // is the CURRENT scroll position, read at this instant, handed to an
1129
+ // innerHTML swap that genuinely does collapse it. Different value,
1130
+ // different reason. Do not "correct" it to the `scrollY` parameter —
1131
+ // applyPatch() deliberately does not take one.
1132
+ blocks = prevBlocks;
1133
+ lastParts = prevLastParts;
1134
+ return applyFullRender(j, window.scrollY, declaredRange);
1135
+ }
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
+
1184
+ // ── teardown ────────────────────────────────────────────────────────
1185
+ // Controller ruling F1: the patch path runs the SAME twelve teardown items
1186
+ // applyFullRender() runs, unconditionally and in the same relative order.
1187
+ // The rationale comments below are copied from applyFullRender() because
1188
+ // they document WHY each one exists; the one thing that differs is the
1189
+ // trigger — there the nodes were detached by the innerHTML replacement,
1190
+ // here they were detached by the removeChild loop above (or, for state
1191
+ // that points at a node in the KEPT span, they were not detached at all,
1192
+ // which is exactly why leaving the state standing is not automatically
1193
+ // safe either: `blocks` and every data-block-id under it just changed).
1194
+ //
1195
+ // Fix round 1 (review, comment-only) — the consequence a future change to
1196
+ // this list has to know about: the fallback's innerHTML replacement also
1197
+ // WIPES any uncommitted burst text, which is what keeps the DOM and
1198
+ // `lines[]` consistent when item 3 disposes the burst. The patch path
1199
+ // leaves that text sitting in a kept node while `currentBurst` has already
1200
+ // been disposed. Unreachable today — every call site goes through
1201
+ // switchAwayFrom() first, which is what applyFullRender()'s own comment
1202
+ // calls "technically unreachable" — but that is precisely what this
1203
+ // belt-and-braces teardown is insurance against, and on THIS path a future
1204
+ // call site that forgets switchAwayFrom() leaves text visible in the DOM,
1205
+ // absent from `lines[]`, with its undo history already disposed.
1206
+ //
1207
+ // Whatever editor (if any) was open a moment ago may have just been
1208
+ // detached by the span removal above — its own restore()/commit() never
1209
+ // ran, so it never got a chance to null this out itself. On the FALLBACK
1210
+ // path this is done unconditionally, which is what makes the undo/redo
1211
+ // lockout regression (see the `activeEditor` comment above) structurally
1212
+ // impossible even from a call site that forgets to call switchAwayFrom().
1213
+ //
1214
+ // ── Final review item 1 / 決議 3(1) 的 activeEditor 對稱項 ───────────
1215
+ // 決議 3(1) 的論證逐字適用在這裡,而它原本沒有被問過:fallback 之所以
1216
+ // 可以無條件歸零,是因為 `contentEl.innerHTML = ` 把每個節點都拔掉了
1217
+ // ——面也一起從螢幕上消失。patch 保留節點,而 openRawEditor() 的
1218
+ // `.ed-raw` 包裝是塞進**區塊自己**(`blockEl.innerHTML = '';
1219
+ // blockEl.appendChild(wrap)`),所以 contentEl 的子節點數與每一個
1220
+ // data-block-id 都沒有變、domMatchesLastRender() 照過、patch 照跑——
1221
+ // 落在前綴/後綴裡的 textarea 於是活過 patch,而這一行把它變成沒人
1222
+ // 追蹤的殭屍:switchAwayFrom() 再也看不到它,點開別處不會自動提交,
1223
+ // 而下一次任何 rerenderAll() 會把它連同未提交的文字一起蓋掉。
1224
+ //
1225
+ // 實測可達(scratchpad/item1-scenarioB.js,兩次 run 一致)。序列是
1226
+ // 「render 往返途中開一個 raw editor」,與決議 3(2) 為 currentBurst 記下
1227
+ // 的那條 race 同形,只是觸發點必須是**在 fetch 之前就 settle 掉
1228
+ // switchAwayFrom() 的**站點(undo()/redo()/changeHeadingDepth()…),
1229
+ // 因為 resolveBurst() 自己那條路徑把整個 render 關在 `switching` 的
1230
+ // single-flight 裡,反而把並行的點擊序列化掉:
1231
+ // openRawEditor-entry {blockId:'2', currentBurst:false, activeEditor:false}
1232
+ // dispatch {patch:true, keepPrefix:4, oldStart:4, oldEnd:4,
1233
+ // activeEditor:true, rawTextareas:1}
1234
+ // patch-teardown {activeEditor:true, activeEditorBlockAttached:true,
1235
+ // activeEditorBlockId:'2', rawTextareas:1}
1236
+ // 接著在存活的 textarea 裡打字、點到別的區塊、Ctrl+S:
1237
+ // [after clicking away] {rawTextareas:1, rawValue:'…const LOST = 2;'}
1238
+ // [file on disk] '# Doc\n\nCode anchor.\n\n```js\nconst x = 1;\n```\n…'
1239
+ // [LOST typing present in file?] NO — data loss
1240
+ // 螢幕上看得到,檔案裡沒有——與決議 3(1) 為 burst 記下的完全同一種缺陷。
1241
+ //
1242
+ // 判準與 burst 那項同形,而且必須同形:實測
1243
+ // `document.body.contains(activeEditor.blockEl)`,不得由 patchmap 的
1244
+ // index 算術推論。推論錯的代價是 activeEditor 指著已 detach 的節點,
1245
+ // 正好是上面那段「undo/redo lockout」在防的事——所以節點沒活下來時
1246
+ // 一律走原本的無條件歸零,lockout 仍然結構上不可能。
1247
+ //
1248
+ // 多一道 burst 不需要的閘門:burst 的 `blockId` 是唯一會 stale 的欄位,
1249
+ // 其餘都從新的 `blocks` 現查;openRawEditor() 的 session 則把
1250
+ // `blockId`/`startLine`/`endLine` 三個都閉包成常數,所以留下來之前要先
1251
+ // 讓它把三者從現在的 DOM 屬性與新的 `blocks` 重新推導成功
1252
+ // (reanchor(),見那裡的註解——推導不出來就退回歸零)。
1253
+ if (!(activeEditor && activeEditor.blockEl
1254
+ && document.body.contains(activeEditor.blockEl)
1255
+ && activeEditor.reanchor())) {
1256
+ activeEditor = null;
1257
+ }
1258
+ // §10-gap fix (review): same belt-and-braces reasoning for
1259
+ // `pristineInsert` — its window is meant to close via one of the
1260
+ // explicit resolution hooks (resolveBurst()/revertBurstAndEnd()/
1261
+ // revertTableBurstAndEnd()/cancelAndMaybeDiscard()) BEFORE any
1262
+ // rerenderAll() reaches here.
1263
+ //
1264
+ // Final review item 1, second half — 這一項**刻意沒有**跟著上面開例外,
1265
+ // 而且理由不是「到不了」:同一條 race 一樣到得了這裡。是這個狀態被
1266
+ // 這次 render 本身作廢了。`pristineInsert` 的整個意思是「undo stack 的
1267
+ // **最頂端**那一筆就是我這個插入 op」——discardPristineInsert() 走
1268
+ // `stack.discardTop()`(lib/editor/lineops.js),它無條件 pop 最頂端那筆
1269
+ // 並就地反轉,不驗證那筆是不是自己的。而任何走到 applyPatch 的 render
1270
+ // 都帶著一個 truthy 的 editRange,也就是**剛剛有一筆 op 被推上 stack**
1271
+ // (commitRangeEdit / commitBlockInsertion / undo()/redo() 的搬動)。所以
1272
+ // patch 之後把 `pristineInsert` 留著,等於讓之後任何一次「放棄這個空
1273
+ // 區塊」去 pop 並反轉**別人的**那一筆——那是比它要防的孤兒佔位區塊更
1274
+ // 嚴重的失敗。要讓這一項變成可以開例外,得先讓 discardTop() 認得 op
1275
+ // 的 identity(例如 discardPristineInsert() 記下 op 物件本身、pop 前先
1276
+ // 比對),在那之前無條件歸零就是正解。
1277
+ pristineInsert = null;
1278
+ // ── Task G / 決議 3:teardown 的三項例外 ─────────────────────────
1279
+ // 判準必須是「burst 的編輯面在 patch 之後仍然連在文件上」的**節點
1280
+ // identity 實測**,不得由 index 算術推論。推論錯的代價是 burst 指著已
1281
+ // detach 的節點——正是上面 activeEditor / toolbarBlockEl 那兩段註解在防的
1282
+ // 事,而且跳過 dispose() 時若節點其實已被換掉,會同時留下一疊
1283
+ // innerHTML 快照與一個閉包住 detached 節點的 captureFn。
1284
+ //
1285
+ // 為什麼只在 patch 路徑需要這個例外:fallback 的 `contentEl.innerHTML = `
1286
+ // 把每一個節點都拔掉,所以 12 項 teardown 把任何編輯面孤兒化都是安全的
1287
+ // (螢幕上也一併消失)。patch 保留前綴與後綴節點,所以同一套 teardown
1288
+ // 會把一個螢幕上還在、焦點還在的面變成沒人追蹤的殭屍。
1289
+ //
1290
+ // 三項例外各自留在原位 gate,不抄成一個 if 區塊:ruling F1 要求這 12 項
1291
+ // 的**相對順序**與 applyFullRender() 一致,把 resetSelToolbarState() /
1292
+ // resetToolbarBlock() 搬到第 3 項旁邊才是真的改了順序。條件只算一次,
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
+ // 接手的人。兩種情形都不需要這一項改判準。
1306
+ const burstSurvived = !!(currentBurst && currentBurst.editEl
1307
+ && document.body.contains(currentBurst.editEl));
1308
+ // Task 2 (Phase 3): same belt-and-braces reasoning for the always-on
1309
+ // WYSIWYG burst — nulling here, not in resolveBurst(), is what keeps this
1310
+ // a single source of truth, mirroring activeEditor just above.
1311
+ // Task G: skipped when the burst's surface survived the patch — nulling it
1312
+ // there is what strands a live, focused, still-typeable surface with
1313
+ // nothing tracking it (the `input`/`focusout` handlers are both predicated
1314
+ // on `currentBurst.editEl === editEl`, and `focusin` only fires on
1315
+ // ENTERING, so focus that never left never reopens one).
1316
+ if (!burstSurvived && currentBurst) { currentBurst.history.dispose(); currentBurst = null; }
1317
+ // Task 5: the hover-edge insert bubbles and the row/column grip handles
1318
+ // are positioned against a specific table's geometry; a stale bubble left
1319
+ // visible (pointing at now-detached or re-rendered geometry) would
1320
+ // misbehave on the next click. hideTableGrips() clears their tracked
1321
+ // table/row/column references too.
1322
+ hideTableInsertBubbles();
1323
+ hideTableGrips();
1324
+ // Task 4 fix (review finding): the SAME reasoning applies to the
1325
+ // selection toolbar and its document-level selectionchange listener.
1326
+ // Every call site today resolves the session via switchAwayFrom() first
1327
+ // (making this technically unreachable), but that safety depends on every
1328
+ // FUTURE call site remembering to — exactly how the Task 3 listener-leak
1329
+ // regression happened. resetSelToolbarState() is idempotent.
1330
+ // Task G: travels with the `currentBurst` exception above, and must. The
1331
+ // `selectionchange` listener it removes is attached by startBurst() /
1332
+ // startTableBurst() and by NOTHING else, so on a surviving burst whose
1333
+ // focus never leaves there is no later focusin to re-attach it — measured,
1334
+ // the floating selection toolbar and the fixed bar's five inline-format
1335
+ // buttons (which piggyback on that same listener, and which this version's
1336
+ // `hasSelection` wiring also rides) stay dead until the user clicks away
1337
+ // and back.
1338
+ if (!burstSurvived) resetSelToolbarState();
1339
+ // Task 6: same belt-and-braces reasoning again for the edge-click menu
1340
+ // and any in-flight row drag.
1341
+ hideTableEdgeMenu();
1342
+ cancelTeDrag();
1343
+ // S4 Task 2: same belt-and-braces reasoning for an in-flight ⠿ block drag —
1344
+ // the gesture's ordinal refers to a block index that this patch may have
1345
+ // just renumbered. Nothing was mutated by the drag, so there is nothing to
1346
+ // revert; the state simply must not survive the render.
1347
+ cancelBlockDrag();
1348
+ // Final-review Finding 5b (Important): the ⠿ gutter menu — toggleGutterMenu()
1349
+ // appends the ONE shared `gutterMenu` node as a CHILD of whichever block
1350
+ // it's open for, so a removed block took it away without its own close
1351
+ // path ever running. Left uncleared, `gutterMenuBlockEl` keeps pointing at
1352
+ // a detached node and the NEXT toggleGutterMenu() call on that same stale
1353
+ // reference would treat the menu as already open instead of opening fresh.
1354
+ // closeGutterMenu() is idempotent.
1355
+ closeGutterMenu();
1356
+ // §10-gap fix: same belt-and-braces reasoning for the + insert menu —
1357
+ // it's the same "singleton node appended as a child of whichever block
1358
+ // it's open for" idiom as gutterMenu just above.
1359
+ closeInsertMenu();
1360
+ // v3.1.0 §4: and the SAME belt-and-braces reasoning one more time for the
1361
+ // toolbar's tracked block. `toolbarBlockEl` points INTO the subtree that
1362
+ // was just patched, and a detached node's getAttribute() still answers
1363
+ // perfectly happily: left standing, the bar would keep reporting the old
1364
+ // block's type/indent/heading depth and every button would act on a node
1365
+ // no click can ever reach again.
1366
+ // Task G: same exception, same reason — `toolbarBlockEl` is only ever
1367
+ // re-set on focusin / the click delegation, so clearing it under a burst
1368
+ // whose focus never leaves kills every block-dependent toolbar button until
1369
+ // the user clicks away and back (MEASURED on the smoke fixture: disabled
1370
+ // goes 7/22 -> 18/22, and nothing calls updateToolbar() again until the
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
1375
+ // the H▾ dropdown standing across a patch, which is benign because
1376
+ // `toolbarMenuEl` is appended to document.body, not into a block, so the
1377
+ // patch never detaches it, and applyHeadingLevel() re-resolves through
1378
+ // liveToolbarBlock(), which self-heals a detached `toolbarBlockEl`.
1379
+ if (!burstSurvived) resetToolbarBlock();
1380
+
1381
+ // Task G step 2: re-derive the surviving burst's `blockId` FROM THE DOM
1382
+ // ATTRIBUTE — never assign it independently.
1383
+ //
1384
+ // startBurst()/startTableBurst() derive `blockId` from
1385
+ // `blockEl.getAttribute('data-block-id')`, so on a kept node it is the
1386
+ // FIELD that goes stale, not the attribute: the id rewrite inside the try
1387
+ // above already renumbered every kept suffix node. bystanderCarryOver()
1388
+ // compares `String(currentBurst.blockId)` against a node's getAttribute()
1389
+ // result AS STRINGS; if the field and the attribute disagree in either
1390
+ // direction a dirty burst stops being excluded from the replay and the
1391
+ // replay overwrites the user's uncommitted typing. Hence the fixed order:
1392
+ // the patch rewrites the attribute first, this re-derives the field from
1393
+ // it. `blockEl` is `editEl.closest('.ed-block')` captured at burst-start
1394
+ // and, because `burstSurvived` measured `editEl` as still attached, it is
1395
+ // the same still-attached element.
1396
+ if (burstSurvived) {
1397
+ currentBurst.blockId = Number(currentBurst.blockEl.getAttribute('data-block-id'));
1398
+ }
1399
+
1400
+ // S3 Task 5 (§4.4's ordered three steps): armEditables() above was step 1;
1401
+ // this is steps 2 and 3 — rebuild the member set from the declared (or
1402
+ // still-standing) LINE RANGE against the freshly built `blocks`. It sits
1403
+ // AFTER the unconditional teardown above so nothing there can null what it
1404
+ // just set, and BEFORE the two `try`-swallowed rebind blocks below so a
1405
+ // diagram-init throw can never skip it.
1406
+ //
1407
+ // Fix round 1 (review, ruling F3 amendment): there are TWO scroll sources
1408
+ // applyFullRender()'s `window.scrollTo(0, scrollY)` compensates, and only
1409
+ // one of them is absent here. The one F3 correctly ruled out is the
1410
+ // innerHTML swap collapsing the scroll extent — that cannot happen on this
1411
+ // path, and re-applying the stale pre-fetch snapshot would fight the
1412
+ // browser. The other is rebuildBlockSelection()'s own tail: it ends in
1413
+ // `focusEl.focus()`, which scrolls the focus holder into view (the comment
1414
+ // above applyFullRender()'s scrollTo says exactly this). That source is
1415
+ // very much still live here, so a keyboard-driven patched commit taken
1416
+ // while the block selection is scrolled off-screen would jump the viewport
1417
+ // where the fallback would not.
1418
+ //
1419
+ // Compensated precisely: read the CURRENT scroll position immediately
1420
+ // before, restore it immediately after, and only if the call actually
1421
+ // moved it. Never the pre-fetch `scrollY` — applyPatch() deliberately does
1422
+ // not take one.
1423
+ const scrollBeforeSelection = window.scrollY;
1424
+ rebuildBlockSelection(declaredRange);
1425
+ if (window.scrollY !== scrollBeforeSelection) {
1426
+ window.scrollTo(0, scrollBeforeSelection);
1427
+ }
1428
+ // 這三個都是 document-scoped,必須在插入之後、對整份文件跑:
1429
+ // __md2docInitDiagrams 的 WaveDrom 分支自述「has no per-root scoping API
1430
+ // — ProcessAll() always scans the whole document」,實測對 detached
1431
+ // fragment 呼叫時 mermaid 畫得出來但 WaveDrom 完全沒動。
1432
+ if (window.__md2docInitDiagrams) {
1433
+ try {
1434
+ window.__md2docInitDiagrams(contentEl);
1435
+ } catch (e) {
1436
+ // Same Phase-1 known limitation as the fallback path: a newly
1437
+ // introduced diagram type whose library global was never loaded stays
1438
+ // unrendered until reload. Never let that break the rest of the editor.
1439
+ }
1440
+ }
1441
+ if (window.__md2docRebindReader) {
1442
+ try {
1443
+ window.__md2docRebindReader();
1444
+ } catch (e) {
1445
+ // Never let a reader-runtime rebind failure break block selection/save/undo.
1446
+ }
1447
+ }
1448
+ // Fix round 1 (review, comment-only): whenever rebuildBlockSelection()
1449
+ // above does NOT take its early-return path it has already called
1450
+ // applySelectionClasses() once, via setBlockSelection()/
1451
+ // clearBlockSelection() — so on those renders this is a redundant SECOND
1452
+ // pass, costing one extra blur/focus round trip. It is kept unconditional
1453
+ // anyway, because the case where it is genuinely load-bearing is exactly
1454
+ // the case where rebuildBlockSelection() early-returns: a plain typing
1455
+ // commit with no selection anywhere, where nothing else would clear the
1456
+ // KEPT nodes' stale `.ed-selected`/tabindex.
1457
+ //
1458
+ // applySelectionClasses() 必須跑:它是 total 的(先把 `.ed-selected` 與
1459
+ // tabindex 從每一個區塊清掉再重套)。innerHTML 置換時代那份「保留節點的
1460
+ // 殘留 class 自動消失」的免費午餐在 patch 路徑沒有了。
1461
+ applySelectionClasses();
1462
+
1463
+ // ── Task G step 3 / 決議 3(1):patch 收尾的焦點檢查 ────────────────
1464
+ // endBurstWithoutResolve() 有 11 個呼叫點,全部在 render 之前,而
1465
+ // ⠿/+/工具列的 mousedown 都無條件 preventDefault()——刻意的,好讓
1466
+ // 焦點留在 armed 面上(既有 runtime 測試斷言「⠿ 的 mousedown 不得 blur
1467
+ // 還開著的 dirty burst」)。今天安全只因為 innerHTML 置換把那個節點
1468
+ // 拔掉;patch 保留節點,就把「焦點還在、currentBurst 為 null」從邊角
1469
+ // 變成日常。那個狀態下:打字不被追蹤(input listener 以
1470
+ // currentBurst.editEl 為前提)、移開焦點什麼都不提交(focusout 同理)
1471
+ // ——螢幕上看得到,檔案裡沒有。
1472
+ //
1473
+ // 位置是刻意的:在 teardown 之後(這時 currentBurst 才真的歸零了),
1474
+ // 也在 rebuildBlockSelection() / applySelectionClasses() 之後——那兩支的
1475
+ // focusEl.focus() 可能把焦點搬到一個 `.ed-block` 上(那是真的移走了,
1476
+ // 不該重開 burst)。先開再讓它搬走只會多觸發一輪 focusout →
1477
+ // resolveBurst()。又因為 resetSelToolbarState() 已經跑完(!burstSurvived
1478
+ // 才會走到這裡),startBurst() 重新掛上的 selectionchange listener 不會被
1479
+ // 回頭拆掉。
1480
+ //
1481
+ // ⚠ 這一步會改動 switchAwayFrom() 的後置條件。resolveOpenSession() 解掉
1482
+ // currentBurst、await 完 render 之後就直接 return true,不再重檢;而工具列
1483
+ // 與 ⠿/+ 的 mousedown 都 preventDefault(),焦點會留在原面上。所以這裡可能
1484
+ // 在那個被 await 的 render 裡重開一個 burst,讓 switchAwayFrom() 帶著一個
1485
+ // 開著的 session 回傳 true——它自己的 doc comment 說它是「單一、完整的
1486
+ // make it safe to proceed 閘門」。這是刻意的取捨:那個面上的鍵入沒人追蹤才是
1487
+ // 資料遺失,而重開的 burst 是乾淨的(original 就是當下的 innerHTML),
1488
+ // 下一次 focusout 會把它零編輯解掉。已知的一個後果是本輪修掉的那個:
1489
+ // openRawEditor() 原本只擋 activeEditor,於是它註冊 activeEditor 之後的
1490
+ // ta.focus() 會把這個 bystander burst 的 focusout 變成一次 switchAwayFrom(),
1491
+ // 連帶把剛開的 raw editor 一起收掉——見那裡的 Task G fix round 1 註解。
1492
+ // 任何新的「開一個 session 然後自己搬焦點」的路徑都要先解掉 burst。
1493
+ //
1494
+ // startBurst() 吃 editEl(`.ed-wys-armed` 本人)、startTableBurst() 吃 cellEl
1495
+ // (`.ed-wys-cell` 本人,它自己 closest('table'))——兩者都正好是
1496
+ // activeElement,所以直接傳。兩者都不呼叫 focus(),不搞 scrollIntoView,
1497
+ // 所以這裡**不需要** rebuildBlockSelection() 那種 scroll 補償。
1498
+ const ae = document.activeElement;
1499
+ if (!currentBurst && ae && contentEl.contains(ae)) {
1500
+ if (ae.classList.contains('ed-wys-cell')) startTableBurst(ae);
1501
+ else if (ae.classList.contains('ed-wys-armed')) startBurst(ae);
1502
+ }
1503
+ // Same tail as applyFullRender(): the dirty dot and the `true` that tells
1504
+ // every safeRerenderAll() caller the commit landed. Omitting either is the
1505
+ // exact silent-failure shape ruling F4 warns about — safeRerenderAll()'s
1506
+ // try/catch would swallow a throw and the DOM would still look right.
1507
+ setDirty();
1508
+ return true;
1509
+ }
1510
+
1511
+ async function applyFullRender(j, scrollY, declaredRange) {
1512
+ blocks = j.blocks;
1513
+ lastParts = j.parts; // v3.2.0: 供下一次 patch 當 oldParts
1514
+ // v3.2.1 R2: 必須先於 innerHTML 寫入。這個寫入 detach 掉聚焦的 raw
1515
+ // textarea,同步 focusout 重入 switchAwayFrom() → resolveOpenSession() →
1516
+ // commitNow(),於是同一次編輯提交兩次(實測衍生新 block 時磁碟上文字出現
1517
+ // 兩次;停在同一個 block 內則第二次回 op === null 退化成取消,所以看不出來)。
867
1518
  // Whatever editor (if any) was open a moment ago just got detached by
868
- // the innerHTML replacement above — its own restore()/commit() never
1519
+ // the innerHTML replacement below — its own restore()/commit() never
869
1520
  // ran, so it never got a chance to null this out itself. Do it here,
870
1521
  // unconditionally, on every successful swap: this is what makes the
871
1522
  // undo/redo lockout regression (see the `activeEditor` comment above)
872
1523
  // structurally impossible even from a call site that forgets to call
873
1524
  // switchAwayFrom() first.
874
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);
875
1532
  // §10-gap fix (review): same belt-and-braces reasoning for
876
1533
  // `pristineInsert` — its window is meant to close via one of the
877
1534
  // explicit resolution hooks (resolveBurst()/revertBurstAndEnd()/
@@ -943,6 +1600,19 @@
943
1600
  // it's the same "singleton node appended as a child of whichever block
944
1601
  // it's open for" idiom as gutterMenu just above.
945
1602
  closeInsertMenu();
1603
+ // v3.1.0 §4: and the SAME belt-and-braces reasoning one more time for the
1604
+ // toolbar's tracked block. The toolbar node itself is mounted on
1605
+ // document.body (see mountToolbar() below) precisely so the innerHTML
1606
+ // replacement above cannot destroy it — but `toolbarBlockEl` points INTO
1607
+ // the subtree that was just swapped, and a detached node's
1608
+ // getAttribute() still answers perfectly happily: left standing, the bar
1609
+ // would keep reporting the old block's type/indent/heading depth and
1610
+ // every button would act on a node no click can ever reach again. Zeroing
1611
+ // it re-derives the model's documented no-block state (undo/redo/outline/
1612
+ // preview stay live, everything else greys out) — the same contract
1613
+ // closeGutterMenu()/closeInsertMenu() above have, and idempotent for the
1614
+ // same reason.
1615
+ resetToolbarBlock();
946
1616
  // S3 Task 5 (§4.4's ordered three steps): armEditables() above was step
947
1617
  // 1; this is steps 2 and 3 — rebuild the member set from the declared (or
948
1618
  // still-standing) LINE RANGE against the freshly built `blocks`, clear it
@@ -990,9 +1660,16 @@
990
1660
  // Defensive wrapper around every rerenderAll() call site: guarantees the
991
1661
  // caller never sees an unhandled rejection even if a future change to
992
1662
  // rerenderAll() (or one of its callees) introduces a stray throw.
993
- async function safeRerenderAll() {
1663
+ //
1664
+ // Task E: `editRange` passes straight through to rerenderAll(). Callers
1665
+ // with a real committed op compute it there and pass it; the 3 no-op call
1666
+ // sites (restore()'s rangeMode exit, the per-li unsupported-format
1667
+ // refusal, and commitListStructure()'s no-range exit) pass nothing, which
1668
+ // falls back to a full re-render by contract — patch can only compare
1669
+ // renderer output, never DOM the user has already touched.
1670
+ async function safeRerenderAll(editRange) {
994
1671
  try {
995
- return await rerenderAll();
1672
+ return await rerenderAll(editRange);
996
1673
  } catch (e) {
997
1674
  showBanner('Render failed — unexpected error (' + describeFailure(e) +
998
1675
  '). Your edit was not applied.', null, null);
@@ -1005,9 +1682,48 @@
1005
1682
  ta.style.height = (ta.scrollHeight + 2) + 'px';
1006
1683
  }
1007
1684
 
1008
- async function openRawEditor(blockEl) {
1685
+ // v3.1.0 修正 4: `range` ({startLine, endLine}, 1-indexed inclusive) opens
1686
+ // the raw editor over an EXPLICIT span of source lines instead of the
1687
+ // block's own record. Two callers need it and one property makes both
1688
+ // work: a list item (whose source line carries its marker and leading
1689
+ // columns, and which must NOT go through the run re-serializer while a
1690
+ // textarea is standing where its .ed-li-text should be) and a multi-block
1691
+ // ⠿ selection (whose span is several blocks' lines at once). Both also
1692
+ // switch this editor's teardown from an innerHTML rewrite to a real
1693
+ // re-render — see restore() below for why that is not optional.
1694
+ async function openRawEditor(blockEl, range) {
1009
1695
  if (blockEl.querySelector('.ed-raw')) return; // already editing this block
1010
- if (activeEditor && activeEditor.blockEl !== blockEl) {
1696
+ // Task G fix round 1 (npm test RED on f63c88a): the guard below used to
1697
+ // read `activeEditor` ALONE, so a WYSIWYG burst open on a DIFFERENT block
1698
+ // survived into this function — and then `ta.focus()` (a few lines down,
1699
+ // after `activeEditor` has already been registered) blurs that surface,
1700
+ // whose delegated focusout calls switchAwayFrom(). resolveOpenSession()
1701
+ // resolves BOTH kinds of session, so it resolves the burst AND then finds
1702
+ // the brand-new, unmodified `activeEditor` and calls cancelNow() on it —
1703
+ // which for a pristine insert is cancelAndMaybeDiscard() -> the freshly
1704
+ // inserted block is discarded and its textarea never survives the gesture.
1705
+ //
1706
+ // MEASURED (scratchpad taskG-repro-code.js, replaying
1707
+ // test/editor-client-runtime.test.js's Section 10「abandoned inserts
1708
+ // (all 5 kinds)」): + -> 程式碼 with stale focus left on an untouched
1709
+ // bystander paragraph produced
1710
+ // openRawEditor {currentBurst:true, activeEditor:false}
1711
+ // resolveOpenSession {currentBurst:true, activeEditor:true, pristineInsert:6}
1712
+ // cancelAndMaybeDiscard {blockId:6, pristineInsert:6}
1713
+ // and zero code blocks left in the DOM. Only 程式碼 is affected because it
1714
+ // is the one insert kind that registers an `activeEditor` before moving
1715
+ // focus; the other four just focus another armed surface.
1716
+ //
1717
+ // The burst arm was previously unreachable — every caller resolved the
1718
+ // session first — which is why the guard only ever named `activeEditor`.
1719
+ // applyPatch()'s step-3 focus check makes it reachable: it adopts focus
1720
+ // that a preventDefault()-ing gesture left standing on a bystander, so a
1721
+ // burst can now be open on a block nobody is interacting with. Resolving
1722
+ // it HERE (before `activeEditor` is registered, so the focusout that
1723
+ // `ta.focus()` fires finds nothing to resolve) is the same defense-in-
1724
+ // depth this guard already documents, applied to the other session kind.
1725
+ if ((activeEditor && activeEditor.blockEl !== blockEl) ||
1726
+ (currentBurst && currentBurst.blockEl !== blockEl)) {
1011
1727
  // Click-to-switch (see `activeEditor` / switchAwayFrom() comments
1012
1728
  // above): resolve whatever editor IS open before opening this one.
1013
1729
  // Defense in depth — the delegated click listener (wireBlockSelection
@@ -1023,7 +1739,10 @@
1023
1739
  const ok = await switchAwayFrom();
1024
1740
  if (!ok) return; // the open editor's auto-commit failed; stay put
1025
1741
  }
1026
- const blockId = Number(blockEl.getAttribute('data-block-id'));
1742
+ // v3.2.0 (final review item 1): `let`, not `const` — reanchor() below
1743
+ // re-derives all three of `blockId`/`startLine`/`endLine` when this
1744
+ // session survives an incremental patch. See its own comment.
1745
+ let blockId = Number(blockEl.getAttribute('data-block-id'));
1027
1746
  const block = blocks.find((b) => b.id === blockId);
1028
1747
  if (!block) return;
1029
1748
  // Task 4 fix round 1 (Critical, second path): a block that owns no source
@@ -1034,16 +1753,45 @@
1034
1753
  // INSERTS the text as a new line instead of replacing anything, leaving
1035
1754
  // the clicked item untouched and a stray line in the file (measured:
1036
1755
  // '# Doc\n\n- a\n\n- - b\n' + 'ZZZ' -> '# Doc\n\n- a\n\nZZZ\n- - b\n').
1037
- // The only such blocks today are list items (RULING F-O independently
1038
- // forbids a textarea inside one), and they are never armed, so before
1039
- // Task 4 gave every block a ⠿ this was reached by a plain body click and
1040
- // stayed unreported. Guarded HERE rather than at the click delegator so
1756
+ // The only such blocks today are list items, and they are never armed, so
1757
+ // before Task 4 gave every block a ⠿ this was reached by a plain body
1758
+ // click and stayed unreported. (Until v3.1.0 this note also cited RULING
1759
+ // F-O as an independent second reason. F-O is superseded for the ⠿ menu
1760
+ // — see toggleGutterMenu() below — so this guard now carries the case
1761
+ // ALONE, and it is the one that always mattered: a block that owns no
1762
+ // source line has nothing to seed a textarea WITH, no matter how the
1763
+ // editor is torn down afterwards.) Guarded HERE rather than at the click
1764
+ // delegator so
1041
1765
  // every caller — the delegated degraded-block click, openRawViaGutter(),
1042
1766
  // the burst-degrade path — is closed by one check.
1043
1767
  if (blockOwnsNoLine(blockEl)) { refuseStructuralListEdit(NO_SOURCE_LINE_MESSAGE); return; }
1044
1768
 
1769
+ // v3.1.0 修正 4: the span this editor owns. Without `range` it is exactly
1770
+ // the block's own record, so `source` below is byte-identical to what
1771
+ // extractBlockSource(lines, block) returned before — that helper IS this
1772
+ // slice, and every pre-v3.1.0 call site keeps its behaviour unchanged.
1773
+ // v3.2.0 (final review item 1): `let` for the same reason as `blockId`.
1774
+ let startLine = range ? range.startLine : block.startLine;
1775
+ let endLine = range ? range.endLine : block.endLine;
1776
+ // The same inverted-range refusal blockOwnsNoLine() just made for the
1777
+ // block, re-asked of the range actually being edited: a caller-supplied
1778
+ // span can be malformed even when the anchor block is fine.
1779
+ if (endLine < startLine) { refuseStructuralListEdit(NO_SOURCE_LINE_MESSAGE); return; }
1780
+ // A list item's rendered chrome (the .ed-li-marker span, the optional
1781
+ // .ed-li-check span, the .ed-li-text surface) is REBUILT BY THE RENDERER
1782
+ // from the source line's marker — it is not recoverable from a captured
1783
+ // innerHTML string once the item's own text has been rewritten, which is
1784
+ // exactly what RULING F-O objected to. So a li (and any explicit range)
1785
+ // commits through commitRangeEdit() over these lines rather than through
1786
+ // the run serializer, and tears down through a real re-render — which
1787
+ // removes F-O's stated premise rather than working around it. Ruling 13
1788
+ // (v3.1.0) supersedes F-O for THIS surface, the ⠿ menu's MD 原始碼, and
1789
+ // for that surface only: the burst-degrade refusal in resolveBurst() and
1790
+ // the blockOwnsNoLine() guard just above both still stand, for reasons
1791
+ // that were never F-O's.
1792
+ const rangeMode = !!range || blockEl.getAttribute('data-block-type') === 'li';
1045
1793
  const original = blockEl.innerHTML;
1046
- const source = extractBlockSource(lines, block);
1794
+ const source = lines.slice(startLine - 1, endLine).join('\n');
1047
1795
 
1048
1796
  const wrap = document.createElement('div');
1049
1797
  wrap.className = 'ed-editing';
@@ -1065,6 +1813,7 @@
1065
1813
  hasChanges: () => ta.value !== source,
1066
1814
  commitNow: commit,
1067
1815
  cancelNow: cancelAndMaybeDiscard,
1816
+ reanchor,
1068
1817
  };
1069
1818
 
1070
1819
  const controls = document.createElement('div');
@@ -1089,13 +1838,30 @@
1089
1838
  ta.focus();
1090
1839
  ta.setSelectionRange(ta.value.length, ta.value.length);
1091
1840
 
1092
- function restore() {
1093
- blockEl.innerHTML = original;
1841
+ async function restore() {
1842
+ // v3.1.0 修正 4: `rangeMode` cannot restore by rewriting innerHTML —
1843
+ // for a li that string would have to carry the marker/check/text chrome
1844
+ // back, and for a multi-block span there is more than one block's DOM
1845
+ // to put back in the first place. A re-render rebuilds all of it from
1846
+ // `lines`, which this path never touched (restore() NEVER commits), so
1847
+ // the document is byte-identical either way — this is a different way
1848
+ // of repainting the same bytes, not a different edit.
1849
+ if (rangeMode) {
1850
+ if (activeEditor && activeEditor.blockEl === blockEl) activeEditor = null;
1851
+ await safeRerenderAll();
1852
+ return;
1853
+ }
1854
+ // v3.2.1 R1: 清 activeEditor 必須先於這個 innerHTML 寫入。寫入會 detach
1855
+ // 聚焦的 textarea,Chromium 同步派發 focusout,而 focusout handler 只要
1856
+ // activeEditor 非 null 就會走 switchAwayFrom() → resolveOpenSession() →
1857
+ // commitNow(),把使用者按 Escape 要丟棄的文字提交進檔案(實測落到磁碟)。
1858
+ // 同一函式的 rangeMode 分支早就是這個順序,理由相同。
1094
1859
  // Only clear `activeEditor` if it's still THIS block's entry — it may
1095
1860
  // already have been cleared out from under us (e.g. by rerenderAll()'s
1096
1861
  // own defensive reset on a successful swap elsewhere), and this must
1097
1862
  // never stomp some OTHER block's activeEditor set after this one.
1098
1863
  if (activeEditor && activeEditor.blockEl === blockEl) activeEditor = null;
1864
+ blockEl.innerHTML = original;
1099
1865
  // No re-wiring needed here (unlike the old per-block gutter): block
1100
1866
  // selection / the edit bar are handled by ONE delegated `document`
1101
1867
  // click listener (see wireBlockSelection() below), so this
@@ -1113,11 +1879,41 @@
1113
1879
  async function cancelAndMaybeDiscard() {
1114
1880
  const wasPristineForThisBlock = !!(pristineInsert && pristineInsert.blockId === blockId);
1115
1881
  if (wasPristineForThisBlock) pristineInsert = null;
1116
- restore();
1882
+ await restore();
1117
1883
  if (wasPristineForThisBlock) return await discardPristineInsert();
1118
1884
  return true;
1119
1885
  }
1120
1886
 
1887
+ // v3.2.0 final review item 1 — 決議 3(1) 的 activeEditor 對稱項。
1888
+ //
1889
+ // 這個 session 的 `blockId` / `startLine` / `endLine` 是開啟當下從**那一
1890
+ // 份** `blocks` 抓下來的;fallback 路徑上那從來不是問題,因為
1891
+ // `contentEl.innerHTML = ` 會把這個 textarea 一起拔掉。patch 路徑保留
1892
+ // 前綴/後綴節點,所以它可以活過一次 render,而 render 之後 `blocks`
1893
+ // 已整份換掉、被保留後綴節點的 data-block-id 也被重寫過。
1894
+ //
1895
+ // applyPatch() 的 teardown 在實測 `document.body.contains(blockEl)` 成立
1896
+ // 之後呼叫這支,把三個欄位從**現在**的 DOM 屬性與新的 `blocks` 重新
1897
+ // 推導。回傳 false 代表推導不出來,呼叫端必須退回原本的無條件歸零:
1898
+ // - `range`:呼叫端指定的跨區塊 span(⠿ 選單的「MD 原始碼」批次路徑)
1899
+ // 在新的 `blocks` 裡沒有單一對應記錄,無從重新推導。
1900
+ // - 區塊在新的 `blocks` 裡不存在(防禦;節點還在就不該發生)。
1901
+ // - 重新切出來的來源**不是** byte-identical:那表示這個區塊底下的行
1902
+ // 真的變了,`ta.value` 與 `source` 都已經不是檔案裡的東西,沿用這個
1903
+ // session 會把過期的文字提交回去。patch 只保留 parts 相同的節點,所以
1904
+ // 正常情況下這個等式成立;不成立時退回歸零是保守的正解。
1905
+ function reanchor() {
1906
+ if (range) return false;
1907
+ const id = Number(blockEl.getAttribute('data-block-id'));
1908
+ const b = blocks.find((x) => x.id === id);
1909
+ if (!b || b.endLine < b.startLine) return false;
1910
+ if (lines.slice(b.startLine - 1, b.endLine).join('\n') !== source) return false;
1911
+ blockId = id;
1912
+ startLine = b.startLine;
1913
+ endLine = b.endLine;
1914
+ return true;
1915
+ }
1916
+
1121
1917
  // Returns true when the editor is resolved (committed, or a no-op
1122
1918
  // commit that fell back to a cancel) — safe for a caller (switchAwayFrom
1123
1919
  // included) to proceed. Returns false only when the render actually
@@ -1127,7 +1923,13 @@
1127
1923
  // network hiccup must never silently discard what they typed). The
1128
1924
  // failure banner is already shown by safeRerenderAll()/rerenderAll().
1129
1925
  async function commit() {
1130
- const result = commitEdit({ lines, blocks, stack }, blockId, ta.value);
1926
+ // v3.1.0 修正 4: an explicit range commits over ITS OWN span. commitEdit()
1927
+ // is the by-id wrapper over exactly this call (it looks the block up and
1928
+ // delegates), so the two branches are the same pipeline — replaceLines()
1929
+ // + one undo op — differing only in which lines they name.
1930
+ const result = rangeMode
1931
+ ? commitRangeEdit({ lines, blocks, stack }, startLine, endLine, ta.value)
1932
+ : commitEdit({ lines, blocks, stack }, blockId, ta.value);
1131
1933
  if (result.op === null) {
1132
1934
  // §10-gap fix (review): an explicit commit (Ctrl+Enter/✓) whose
1133
1935
  // text happens to be byte-identical to `source` is still an
@@ -1137,7 +1939,12 @@
1137
1939
  }
1138
1940
  const prevLines = lines;
1139
1941
  lines = result.lines;
1140
- const ok = await safeRerenderAll();
1942
+ const editRange = result.op
1943
+ ? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
1944
+ endLine: Math.max(result.op.startLine - 1, result.op.endLine),
1945
+ delta: result.op.after.length - result.op.before.length }
1946
+ : undefined;
1947
+ const ok = await safeRerenderAll(editRange);
1141
1948
  if (!ok) {
1142
1949
  // Roll back the optimistic edit: pop the op safeRerenderAll's
1143
1950
  // failure means was never actually rendered, so `lines` stays
@@ -1147,9 +1954,48 @@
1147
1954
  lines = rollbackFailedRender({ lines, stack }, result, prevLines);
1148
1955
  return false;
1149
1956
  }
1150
- // Success: rerenderAll() already replaced the whole .content subtree
1151
- // (this block included) and nulled `activeEditor` itself — nothing
1152
- // left to do here.
1957
+ // Success. On the FALLBACK path rerenderAll() replaced the whole
1958
+ // .content subtree (this block included) and nulled `activeEditor`
1959
+ // itself, so there was nothing left to do here.
1960
+ //
1961
+ // v3.2.0 final review item 1: that is no longer unconditional. The
1962
+ // patch path only detaches this block when it lands inside
1963
+ // `replaceSpan` — and a commit very often leaves it OUTSIDE: whenever
1964
+ // the edit's visible effect is a NEW block AFTER this one (a line
1965
+ // appended below a fence, a paragraph typed under a paragraph) this
1966
+ // block's own part is byte-identical before and after, so patchmap
1967
+ // keeps it in `keepPrefix` and the textarea stays on screen, on top of
1968
+ // content that was already repainted around it.
1969
+ //
1970
+ // MEASURED at HEAD, before this repair (scratchpad/item1-zombie.js
1971
+ // --head): Ctrl+Enter on a code fence with one line appended commits
1972
+ // correctly to the file but leaves `rawTextareas: 1` standing forever
1973
+ // — unclickable, because both the delegated click handler and
1974
+ // openRawEditor() early-return on a block that already has a `.ed-raw`.
1975
+ // With the `activeEditor` exception above it is worse than cosmetic:
1976
+ // the session stays live with its ORIGINAL `source`, so the next
1977
+ // switchAwayFrom() sees hasChanges() and commits the same text a
1978
+ // second time (measured: 'const LOST = 2;' twice in the file).
1979
+ //
1980
+ // A kept node's part is byte-identical by construction — that is the
1981
+ // only reason patchmap kept it — so `original` (this block's innerHTML
1982
+ // captured at open, i.e. post-armEditables) is exactly the content to
1983
+ // put back. Same one-line repaint the non-range restore() below uses,
1984
+ // for the same reason.
1985
+ //
1986
+ // ⚠ 順序不可對調,而且不是防禦性的:`blockEl.innerHTML =` 會把還握著
1987
+ // 焦點的 textarea 就地 detach,Chromium 在那當下**同步**送出一次
1988
+ // focusout,委派的 focusout handler 走 switchAwayFrom() →
1989
+ // resolveOpenSession() → activeEditor.commitNow()。先重繪再歸零時,
1990
+ // 那一發看到的還是這個 session、hasChanges() 仍為真,於是把同一段文字
1991
+ // 再提交一次(實測:'ZZZ tail.' 在檔案裡出現兩次,stack 是
1992
+ // commit → focusout → switchAwayFrom → resolveOpenSession → commitNow)。
1993
+ // 先歸零就沒有東西可以被重新解析。rangeMode 的 restore() 早就是這個
1994
+ // 順序,理由相同。
1995
+ if (activeEditor && activeEditor.blockEl === blockEl) activeEditor = null;
1996
+ if (document.body.contains(blockEl) && blockEl.querySelector('.ed-raw')) {
1997
+ blockEl.innerHTML = original;
1998
+ }
1153
1999
  return true;
1154
2000
  }
1155
2001
 
@@ -1822,7 +2668,51 @@
1822
2668
  // switchAwayFrom() first resolves any editor that's currently open on this
1823
2669
  // (or another) block, same precondition as undo()/redo() below, so this
1824
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.
1825
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) {
1826
2716
  if (!blockEl) return;
1827
2717
  const blockId = Number(blockEl.getAttribute('data-block-id'));
1828
2718
  // Commits (never discards) whatever burst/editor is open first — same
@@ -1855,6 +2745,9 @@
1855
2745
  }
1856
2746
  const block = blocks.find((b) => b.id === blockId);
1857
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;
1858
2751
  const curLine = lines[block.startLine - 1];
1859
2752
  const curDepth = headingDepthOf(curLine);
1860
2753
  const newDepth = Math.max(1, Math.min(6, curDepth + delta));
@@ -1864,10 +2757,37 @@
1864
2757
  if (result.op === null) return;
1865
2758
  const prevLines = lines;
1866
2759
  lines = result.lines;
1867
- const okRender = await safeRerenderAll();
2760
+ const editRange = result.op
2761
+ ? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
2762
+ endLine: Math.max(result.op.startLine - 1, result.op.endLine),
2763
+ delta: result.op.after.length - result.op.before.length }
2764
+ : undefined;
2765
+ const okRender = await safeRerenderAll(editRange);
1868
2766
  if (!okRender) {
1869
2767
  lines = rollbackFailedRender({ lines, stack }, result, prevLines);
1870
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;
1871
2791
  }
1872
2792
 
1873
2793
  // ── Task 2 (Phase 3): always-on WYSIWYG editing + burst undo ───────────
@@ -2378,37 +3298,42 @@
2378
3298
  gutterMenuConvert.hidden = convertWithheldFor(blockType);
2379
3299
  gutterMenuDuplicate.hidden = false;
2380
3300
  gutterMenuDelete.hidden = false;
2381
- // RULING F-O: 'MD 原始碼' is hidden for a list item PERMANENTLY, not as a
2382
- // phased measure. openRawEditor() replaces the block's innerHTML with a
2383
- // <textarea>, and a li is one line of a run that is serialized as a whole
2384
- // — a textarea inside it is content the serializer cannot represent, and
2385
- // restore() would have to rebuild the marker/check/text chrome from a
2386
- // string. Every other block type keeps it: the menu is a SINGLETON moved
2387
- // between blocks, so this must be reset on every open, not set once.
3301
+ // 'MD 原始碼' is offered for EVERY block type, list items included, and
3302
+ // for a multi-block selection. The menu is a SINGLETON moved between
3303
+ // blocks, so this is still written on every open rather than set once.
2388
3304
  // (test/editor-reader-rebind.test.js drives raw-edit through this button
2389
3305
  // by its exact text on a paragraph.)
2390
3306
  //
2391
- // §3.7's other sentence about this item — 「多選時不顯示 `MD 原始碼`」 —
2392
- // is the half S3 shipped without. openRawViaGutter() rewrites ONE block's
2393
- // source lines, so over a set of N it silently answers for the grip's
2394
- // block and ignores the rest: an item that appears to act on the selection
2395
- // and does not.
3307
+ // ⚠ THIS REVERSES RULING F-O, deliberately and at spec level (v3.1.0
3308
+ // Ruling 13), for this surface and this surface only. F-O read: 'MD
3309
+ // 原始碼' is hidden for a list item PERMANENTLY, because openRawEditor()
3310
+ // replaces the block's innerHTML with a <textarea> — and a li is one line
3311
+ // of a run serialized as a whole, so a textarea inside it is content the
3312
+ // serializer cannot represent, and restore() would have to rebuild the
3313
+ // marker/check/text chrome out of a string. 修正 4 removes that premise
3314
+ // instead of arguing with it: a li now raw-edits an explicit LINE RANGE,
3315
+ // commits it through commitRangeEdit() rather than the run
3316
+ // re-serializer, and RESTORES BY RE-RENDERING rather than by rewriting
3317
+ // innerHTML, so no serializer ever sees the textarea and no chrome is
3318
+ // ever rebuilt by hand.
2396
3319
  //
2397
- // The question is §3.3's MEMBERSHIP question, asked exactly the way every
2398
- // set operation asks it (resolveGutterOperands() → resolveMembership(),
2399
- // identifying the grip block by REFERENCE into `blocks` — Task 1 carry 4),
2400
- // and not "is anything selected". Two states answer differently and both
2401
- // keep the item: a set of exactly the grip block is a set of ONE, which
2402
- // raw-edits fine; and a set standing elsewhere in the document is not this
2403
- // grip's set at all — §3.3 turns that gesture into a single-block operation
2404
- // before it acts, so the item is right there too.
2405
- const mdRec = blockRecOf(blockEl);
2406
- const mdMembership = (selectionLib && mdRec)
2407
- ? selectionLib.resolveMembership(blockSelection, blocks, mdRec)
2408
- : { mode: 'single', members: mdRec ? [mdRec] : [] };
2409
- const mdMultiSelected =
2410
- mdMembership.mode === 'batch' && mdMembership.members.length > 1;
2411
- gutterMenuMd.hidden = (blockType === 'li') || mdMultiSelected;
3320
+ // F-O's OTHER call sites are NOT in scope and still refuse, each for a
3321
+ // reason that was never F-O's: openRawEditor()'s blockOwnsNoLine() guard
3322
+ // (a block with an inverted range has no line to seed a textarea with)
3323
+ // and resolveBurst()'s unsupported-inline-content path (that refusal is
3324
+ // about the CONTENT, not the container).
3325
+ //
3326
+ // §3.7's other sentence about this item — 「多選時不顯示 `MD 原始碼`」 —
3327
+ // is also lifted, and for the same kind of reason: it existed because
3328
+ // openRawViaGutter() rewrote ONE block's source lines, so over a set of N
3329
+ // it silently answered for the grip's block and ignored the rest. It now
3330
+ // opens on the WHOLE span's line range (§3.3 membership, asked through
3331
+ // resolveGutterOperands() so the contiguity and no-source-line gates come
3332
+ // with it), so the item does what it appears to do.
3333
+ // The membership question itself moved WITH the decision it feeds, into
3334
+ // openRawViaGutter() (it now chooses the RANGE instead of the
3335
+ // visibility), so it is asked exactly once, in the place that acts on it.
3336
+ gutterMenuMd.hidden = false;
2412
3337
  blockEl.appendChild(gutterMenu);
2413
3338
  }
2414
3339
 
@@ -2522,6 +3447,13 @@
2522
3447
  list: ['-'],
2523
3448
  table: ['| A | B |', '|---|---|', '| | |'],
2524
3449
  code: ['```', '', '```'],
3450
+ // v3.1.0 §4: the toolbar's ― button. '---' is safe as a thematic break
3451
+ // here specifically because commitBlockInsertion() ALWAYS writes a
3452
+ // leading blank line (see its own comment) — a '---' directly under a
3453
+ // non-blank line would instead lex as a setext H2 and silently promote
3454
+ // whatever preceded it. Nothing else in this file may reuse this
3455
+ // skeleton through a path that does not guarantee that blank.
3456
+ line: ['---'],
2525
3457
  };
2526
3458
 
2527
3459
  // Selects the entirety of `el`'s content (used right after focusing a
@@ -2567,7 +3499,28 @@
2567
3499
  const prevLines = lines;
2568
3500
  lines = discarded.lines;
2569
3501
  setDirty();
2570
- const okRender = await safeRerenderAll();
3502
+ // v3.2.0: UndoStack.undo()/discardTop() reverse the op when applying it but
3503
+ // return the unmodified FORWARD op, so the range actually rewritten is
3504
+ // [op.startLine, op.startLine + op.after.length - 1] and the delta is the
3505
+ // mirror of the forward one. Using op.endLine here would name the opposite
3506
+ // direction — the span the op wrote on the way FORWARD, not the one this
3507
+ // reversal just rewrote.
3508
+ //
3509
+ // Final review item 4 — what naming it wrongly would cost today: nothing.
3510
+ // Per 決議 4(b) `editRange` is ADVISORY. patchmap() destructures
3511
+ // oldBlocks/newBlocks/oldParts/newParts and never reads it; the kept
3512
+ // prefix/suffix is the longest common run of the two PART arrays, and
3513
+ // seeding it from an op's line range only ever made the answer worse
3514
+ // (measured in 決議 4(b): seeded kept 0 of 5, pure-parts kept 4 of 5).
3515
+ // Its one client-side use is the truthiness gate in applyRenderResult().
3516
+ // So the direction changes no plan — this is simply the correct value for
3517
+ // whenever something does start reading it.
3518
+ const editRange = discarded.op
3519
+ ? { startLine: discarded.op.startLine,
3520
+ endLine: discarded.op.startLine + discarded.op.after.length - 1,
3521
+ delta: discarded.op.before.length - discarded.op.after.length }
3522
+ : undefined;
3523
+ const okRender = await safeRerenderAll(editRange);
2571
3524
  if (!okRender) {
2572
3525
  stack.push(discarded.op); // put it back — see discardTop()'s own comment
2573
3526
  lines = prevLines;
@@ -2620,7 +3573,16 @@
2620
3573
  // dirty burst, swapping the whole `.content` subtree and detaching
2621
3574
  // `blockEl` along with it — same "ensureTableBurstOpen()'s Finding 6"
2622
3575
  // recovery idiom used throughout this file), THEN acts.
2623
- async function insertBlockBelow(blockEl, kind) {
3576
+ // v3.1.0 §4 / 追加 2 / 追加 3: `opts.lines` supplies EXPLICIT source lines
3577
+ // instead of one of the five placeholder skeletons — the image-drop path
3578
+ // (`![](assets/…)`) and the paste path (turndown's markdown) both need to
3579
+ // land real content, and both must land it as SOURCE so it goes through
3580
+ // commitBlockInsertion()/replaceLines() like every other edit rather than
3581
+ // being spliced into the DOM behind serializeBlocks()'s back. Everything
3582
+ // else about the insert — the run-wide gate, the subtree anchor, the
3583
+ // rollback — is shared, which is the whole reason this is one extra
3584
+ // parameter and not a second insertion path.
3585
+ async function insertBlockBelow(blockEl, kind, opts) {
2624
3586
  if (!blockEl) return;
2625
3587
  // T7: captured BEFORE switchAwayFrom(), because that is what can renumber
2626
3588
  // the ids — see captureBlockIdentity()'s comment.
@@ -2659,7 +3621,8 @@
2659
3621
  refuseStructuralListEdit(NO_SOURCE_LINE_INSERT_MESSAGE);
2660
3622
  return;
2661
3623
  }
2662
- const newLines = BLOCK_SKELETONS[kind];
3624
+ const customLines = opts && Array.isArray(opts.lines) ? opts.lines : null;
3625
+ const newLines = customLines || BLOCK_SKELETONS[kind];
2663
3626
  if (!newLines) return;
2664
3627
 
2665
3628
  // ── S2 Task 7: a LIST ITEM anchor (§6's S1 note item 3) ────────────────
@@ -2730,7 +3693,12 @@
2730
3693
  const result = commitBlockInsertion({ lines, blocks, stack }, liveBlockId, newLines);
2731
3694
  const prevLines = lines;
2732
3695
  lines = result.lines;
2733
- const okRender = await safeRerenderAll();
3696
+ const editRange = result.op
3697
+ ? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
3698
+ endLine: Math.max(result.op.startLine - 1, result.op.endLine),
3699
+ delta: result.op.after.length - result.op.before.length }
3700
+ : undefined;
3701
+ const okRender = await safeRerenderAll(editRange);
2734
3702
  if (!okRender) {
2735
3703
  lines = rollbackFailedRender({ lines, stack }, result, prevLines);
2736
3704
  return;
@@ -2739,9 +3707,47 @@
2739
3707
  // see `pristineInsert`'s own comment for the full contract. `blocks`
2740
3708
  // was just reassigned by the successful rerenderAll() above, so this
2741
3709
  // is the server-authoritative id for the block at `newStartLine`.
2742
- const target = blocks.find((b) => b.startLine === result.newStartLine);
2743
- if (target) pristineInsert = { blockId: target.id };
2744
- await focusInsertedBlock(result.newStartLine, kind);
3710
+ //
3711
+ // v3.1.0: a custom-lines insert is NEITHER pristine NOR focus-and-select.
3712
+ // `pristineInsert` means "a placeholder the user has not touched yet, so
3713
+ // abandoning it removes it again" — applied to a dropped image or a
3714
+ // pasted paragraph it would silently delete real content the moment the
3715
+ // user clicked elsewhere without typing. focusAndSelectAll() is wrong for
3716
+ // the same reason: it selects the block's whole content so the first
3717
+ // keystroke replaces it, which is what a ZWSP placeholder wants and what
3718
+ // a just-pasted paragraph very much does not.
3719
+ //
3720
+ // 'line' is excluded for the same reason from the other direction: both
3721
+ // are PLACEHOLDER semantics, and an <hr> has no placeholder. There is
3722
+ // nothing for focusAndSelectAll() to select (the block's content element
3723
+ // is the rule itself), and "abandoning it un-typed removes it again"
3724
+ // describes a skeleton waiting to be filled in — a thematic break is
3725
+ // finished the moment it exists.
3726
+ if (!customLines && kind !== 'line') {
3727
+ const target = blocks.find((b) => b.startLine === result.newStartLine);
3728
+ if (target) pristineInsert = { blockId: target.id };
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);
3750
+ }
2745
3751
  }
2746
3752
 
2747
3753
 
@@ -2860,7 +3866,34 @@
2860
3866
  // already been raised — otherwise `{ els, recs, batch }`: the LIVE block
2861
3867
  // elements in document order, their records out of `blocks` (by reference),
2862
3868
  // and whether more than one block is being operated on.
2863
- 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) {
2864
3897
  if (!blockEl) return null;
2865
3898
  // Finding 5a's mousedown preventDefault() deliberately keeps a dirty burst
2866
3899
  // alive across the ⠿ press, so the commit that lands inside
@@ -2886,6 +3919,7 @@
2886
3919
  if (blockOwnsNoLine(liveBlockEl)) { refuseStructuralListEdit(NO_SOURCE_LINE_MESSAGE); return null; }
2887
3920
  const rec = blockRecOf(liveBlockEl);
2888
3921
  if (!rec) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return null; }
3922
+ if (anchor) anchor.line = rec.startLine;
2889
3923
 
2890
3924
  const res = selectionLib
2891
3925
  ? selectionLib.resolveMembership(blockSelection, blocks, rec)
@@ -3059,7 +4093,148 @@
3059
4093
  return run;
3060
4094
  }
3061
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()).
3062
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) {
3063
4238
  if (!blockEl || !target) return;
3064
4239
  // §3.3's membership rules and the whole shared preamble — see
3065
4240
  // resolveGutterOperands() above. `els` is ONE contiguous span; the plan's
@@ -3069,7 +4244,7 @@
3069
4244
  // fenced block raw-edited into two paragraphs changes the BLOCK count
3070
4245
  // without changing the LINE count, so an id-indexed delete hit the wrong
3071
4246
  // block, and the same shape silently rewrote a neighbouring table).
3072
- const operands = await resolveGutterOperands(blockEl);
4247
+ const operands = await resolveGutterOperands(blockEl, anchor);
3073
4248
  if (!operands) return;
3074
4249
  const els = operands.els;
3075
4250
  const recs = operands.recs;
@@ -3142,8 +4317,7 @@
3142
4317
  // the run stays a run and the existing re-serialization machinery applies
3143
4318
  // unchanged.
3144
4319
  if (shape.allLi && convertMd.targetIsList(target)) {
3145
- await convertListItemsType(els, liRun, recs, target);
3146
- return;
4320
+ return await convertListItemsType(els, liRun, recs, target);
3147
4321
  }
3148
4322
  // S2 Task 4 / S3 Task 6: li → a NON-list target. The items LEAVE the run,
3149
4323
  // so the span has to be rebuilt in three pieces and §4.3 rule 1's blank
@@ -3151,16 +4325,14 @@
3151
4325
  // lines mid-list with no separator and lazy continuation would swallow them
3152
4326
  // into the item above (measured, §4.3 rule 1).
3153
4327
  if (shape.allLi) {
3154
- await convertListItemsAway(els, liRun, recs, target);
3155
- return;
4328
+ return await convertListItemsAway(els, liRun, recs, target);
3156
4329
  }
3157
4330
  // S2 Task 5 / S3 Task 6: non-list blocks BECOME list items, so §4.3 rule
3158
4331
  // 2's looseness policy applies — eat the separator to an adjacent run of
3159
4332
  // the same list type, or the merged list goes LOOSE and every item of it
3160
4333
  // degrades read-only. Same rule, same helper the li → li path above uses.
3161
4334
  if (convertMd.targetIsList(target)) {
3162
- await convertBlocksIntoList(els, recs, shape.kinds, target);
3163
- return;
4335
+ return await convertBlocksIntoList(els, recs, shape.kinds, target);
3164
4336
  }
3165
4337
 
3166
4338
  // The plain path: N non-list blocks to a non-list target. One commit over
@@ -3194,10 +4366,20 @@
3194
4366
  startLine: first.startLine,
3195
4367
  endLine: first.startLine + md.split('\n').length - 1,
3196
4368
  });
3197
- const okRender = await safeRerenderAll();
4369
+ const editRange = result.op
4370
+ ? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
4371
+ endLine: Math.max(result.op.startLine - 1, result.op.endLine),
4372
+ delta: result.op.after.length - result.op.before.length }
4373
+ : undefined;
4374
+ const okRender = await safeRerenderAll(editRange);
3198
4375
  if (!okRender) {
3199
4376
  lines = rollbackFailedRender({ lines, stack }, result, prevLines);
3200
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;
3201
4383
  }
3202
4384
 
3203
4385
  // Is the editor session switchAwayFrom() would resolve open on THIS block?
@@ -3255,12 +4437,51 @@
3255
4437
  // `lines`, and the li path carries the clone through bystanderCarryOver()
3256
4438
  // under the ORIGINAL's block id, so list-md.js replays the file's own bytes
3257
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. 畫面上沒有東西在使用者底下移動:副本長在下面,原件原地不動。
3258
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) {
3259
4480
  // §3.3's membership rules plus the shared preamble — see
3260
4481
  // resolveGutterOperands(). The whole span is duplicated by ONE commit; a
3261
4482
  // per-member loop would re-render between items and invalidate every id in
3262
4483
  // between.
3263
- const operands = await resolveGutterOperands(blockEl);
4484
+ const operands = await resolveGutterOperands(blockEl, anchor);
3264
4485
  if (!operands) return;
3265
4486
  const els = operands.els;
3266
4487
  const recs = operands.recs;
@@ -3268,8 +4489,12 @@
3268
4489
  if (shape.anyLi && !shape.allLi) { refuseStructuralListEdit(BATCH_MIXED_MESSAGE); return; }
3269
4490
 
3270
4491
  if (shape.allLi) {
3271
- await duplicateListItems(els, recs);
3272
- 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;
3273
4498
  }
3274
4499
 
3275
4500
  // The non-li span duplicates as ONE slice of `lines` — separators between
@@ -3289,9 +4514,20 @@
3289
4514
  // answer as "keep what is standing" and says so explicitly, which is what
3290
4515
  // keeps this correct if the insertion point ever moves.
3291
4516
  declareCollapse({ startLine: first.startLine, endLine: last.endLine });
3292
- if (!(await safeRerenderAll())) {
4517
+ const editRange = result.op
4518
+ ? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
4519
+ endLine: Math.max(result.op.startLine - 1, result.op.endLine),
4520
+ delta: result.op.after.length - result.op.before.length }
4521
+ : undefined;
4522
+ if (!(await safeRerenderAll(editRange))) {
3293
4523
  lines = rollbackFailedRender({ lines, stack }, result, prevLines);
4524
+ return;
3294
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;
3295
4531
  }
3296
4532
 
3297
4533
  // The li half of 建立副本. The copy is spliced into the run's own span and the
@@ -3300,10 +4536,15 @@
3300
4536
  // §3.8's renumbering falls out of the re-serialization ('1. alpha' duplicated
3301
4537
  // gives '1. alpha / 2. alpha / 3. bravo', not '1. alpha / 1. alpha / 2.
3302
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.
3303
4544
  async function duplicateListItems(liEls, recs) {
3304
4545
  // Every list batch is one run's problem — see batchRunOf().
3305
4546
  const run = batchRunOf(liEls);
3306
- if (!run) { refuseStructuralListEdit(BATCH_MULTIRUN_MESSAGE); return; }
4547
+ if (!run) { refuseStructuralListEdit(BATCH_MULTIRUN_MESSAGE); return false; }
3307
4548
  // §4.3's run-wide gate — 轉換/建立副本/刪除/拖曳 each make this call for
3308
4549
  // themselves; there is no shared helper. Its input is §3.4 rule 2's scope,
3309
4550
  // which is exactly what listRunOf() returns (the outermost run PLUS every
@@ -3311,14 +4552,14 @@
3311
4552
  // it adds the item's lines over again), so a multi-line li refuses as a
3312
4553
  // TARGET while remaining a perfectly good bystander — and in a batch EVERY
3313
4554
  // member is a target.
3314
- if (!listRunSupportsStructuralEdit(run, liEls)) { refuseStructuralListEdit(); return; }
4555
+ if (!listRunSupportsStructuralEdit(run, liEls)) { refuseStructuralListEdit(); return false; }
3315
4556
  // Captured BEFORE the copy enters the span. The copy carries the
3316
4557
  // ORIGINAL's data-block-id — that is what makes bystanderCarryOver() replay
3317
4558
  // its bytes rather than re-escape them — so runRangeOfBlocks() would
3318
4559
  // resolve it to the original's record, and on a duplicate of the span's
3319
4560
  // LAST member that silently re-states the range's end line.
3320
4561
  const range = runRangeOfBlocks({ lines, blocks, stack }, run);
3321
- if (!range) return;
4562
+ if (!range) return false;
3322
4563
 
3323
4564
  // §4.3, measured: after the SUBTREE, not after the item's own line.
3324
4565
  // subtreeBlocksAfter() is the flat model's subtree — the contiguous run of
@@ -3331,7 +4572,7 @@
3331
4572
  const subtree = subtreeBlocksAfter(anchorLi, Number(anchorLi.getAttribute('data-indent')) || 0);
3332
4573
  const lastEl = subtree.length ? subtree[subtree.length - 1] : anchorLi;
3333
4574
  const at = run.indexOf(lastEl);
3334
- if (at < 0) return;
4575
+ if (at < 0) return false;
3335
4576
 
3336
4577
  const copies = liEls.map((liEl) => liEl.cloneNode(true));
3337
4578
  // `data-list-start` is the ONLY carrier of "marked's lexer opened a new
@@ -3374,33 +4615,161 @@
3374
4615
  startLine: recs[0].startLine,
3375
4616
  endLine: recs[recs.length - 1].endLine,
3376
4617
  });
3377
- await commitListStructure(span, null, false,
4618
+ return await commitListStructure(span, null, false,
3378
4619
  { presetRange: range, carryOver: bystanderCarryOver(span) });
3379
4620
  }
3380
4621
  // Deletes `blockEl`'s ENTIRE line range (generalizing commitListBlockRemoval()
3381
4622
  // — unchanged, see its own comment — to any block type, not just an
3382
4623
  // emptied-out list). Same resolve-first / re-query-live-block-by-id
3383
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
+ // 狀態可言,而使用者剛把手上唯一的著力點刪掉,更需要拿回一個。
3384
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) {
3385
4727
  // §3.3's membership rules plus the shared preamble (identity capture,
3386
4728
  // switchAwayFrom(), the re-resolve after a commit that re-rendered, and the
3387
4729
  // no-source-line refusal) — see resolveGutterOperands(). The stake on the
3388
4730
  // re-resolve is highest here: an id shift used to make this delete a
3389
4731
  // DIFFERENT block's lines, with the ⠿ menu the user pressed pointing at a
3390
4732
  // block that survived.
3391
- const operands = await resolveGutterOperands(blockEl);
4733
+ const operands = await resolveGutterOperands(blockEl, anchor);
3392
4734
  if (!operands) return;
3393
4735
  const els = operands.els;
3394
4736
  const recs = operands.recs;
3395
4737
  const shape = spanListKinds(els);
3396
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;
3397
4766
  // Spec §6, "S1 期間的已知危險" item 1: a LIST ITEM's delete is not a line
3398
4767
  // splice. S1 is what first put a ⠿ on a li, and the plain range removal
3399
4768
  // below corrupts a list three separate ways — see
3400
4769
  // deleteListItemsViaGutter() for the measurements and the routing.
3401
4770
  if (shape.allLi) {
3402
- await deleteListItemsViaGutter(els);
3403
- return;
4771
+ return (await deleteListItemsViaGutter(els))
4772
+ ? { prevLine: prevLine, spanIsFirst: spanIsFirst } : undefined;
3404
4773
  }
3405
4774
  // A contiguous span of non-list blocks is one line range: the separators
3406
4775
  // between its members are inside it by construction (members are adjacent
@@ -3421,10 +4790,17 @@
3421
4790
  // inverted range, and §4.4 says a range that no longer resolves clears).
3422
4791
  // Declared immediately before the render, never earlier (Task 5 carry 5).
3423
4792
  declareCollapse(null);
3424
- const okRender = await safeRerenderAll();
4793
+ const editRange = result.op
4794
+ ? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
4795
+ endLine: Math.max(result.op.startLine - 1, result.op.endLine),
4796
+ delta: result.op.after.length - result.op.before.length }
4797
+ : undefined;
4798
+ const okRender = await safeRerenderAll(editRange);
3425
4799
  if (!okRender) {
3426
4800
  lines = rollbackFailedRender({ lines, stack }, result, prevLines);
4801
+ return;
3427
4802
  }
4803
+ return { prevLine: prevLine, spanIsFirst: spanIsFirst };
3428
4804
  }
3429
4805
 
3430
4806
  // Spec §6, "S1 期間的已知危險" item 1 — the ⠿ delete of a LIST ITEM.
@@ -3455,19 +4831,24 @@
3455
4831
  // commit the re-serialized survivors over that range with every one of them
3456
4832
  // carried over verbatim — nothing here rewrites any survivor's CONTENT, only
3457
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.
3458
4839
  async function deleteListItemsViaGutter(liEls) {
3459
4840
  // Every list batch is one run's problem — see batchRunOf().
3460
4841
  const run = batchRunOf(liEls);
3461
- if (!run) { refuseStructuralListEdit(BATCH_MULTIRUN_MESSAGE); return; }
4842
+ if (!run) { refuseStructuralListEdit(BATCH_MULTIRUN_MESSAGE); return false; }
3462
4843
  // §4.3's run-wide gate, whose input is §3.4 rule 2's scope — which is
3463
4844
  // exactly what listRunOf() returns (the outermost run PLUS every
3464
4845
  // descendant of its members), so the deeper runs this delete is about to
3465
4846
  // re-indent are covered, not just the target's own. Deleting is NOT
3466
4847
  // column-only: it removes the target's lines outright, so a multi-line
3467
4848
  // target refuses per §4.1 — and in a batch every member is a target.
3468
- if (!listRunSupportsStructuralEdit(run, liEls)) { refuseStructuralListEdit(); return; }
4849
+ if (!listRunSupportsStructuralEdit(run, liEls)) { refuseStructuralListEdit(); return false; }
3469
4850
  const range = runRangeOfBlocks({ lines, blocks, stack }, run);
3470
- if (!range) return;
4851
+ if (!range) return false;
3471
4852
  // §3.4 rule 3's batch anchor: the SMALLEST old indent in the set, never the
3472
4853
  // first member's — see spanMinIndent().
3473
4854
  const oldIndent = spanMinIndent(liEls);
@@ -3493,7 +4874,7 @@
3493
4874
  // this path is the one it makes after the commit — the `!range` bail above
3494
4875
  // it cannot fire, `presetRange` is non-null by construction here.
3495
4876
  declareCollapse(null);
3496
- await commitListStructure(survivors, null, false,
4877
+ return await commitListStructure(survivors, null, false,
3497
4878
  { presetRange: range, carryOver: bystanderCarryOver(survivors) });
3498
4879
  }
3499
4880
 
@@ -3715,6 +5096,10 @@
3715
5096
  });
3716
5097
  await commitListStructure(run, null, false,
3717
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;
3718
5103
  }
3719
5104
 
3720
5105
  // The `data-block-type` a conversion target will carry once it is committed.
@@ -3855,9 +5240,19 @@
3855
5240
  const prevLines = lines;
3856
5241
  lines = result.lines;
3857
5242
  declareCollapse({ startLine: outStart, endLine: outEnd });
3858
- if (!(await safeRerenderAll())) {
5243
+ const editRange = result.op
5244
+ ? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
5245
+ endLine: Math.max(result.op.startLine - 1, result.op.endLine),
5246
+ delta: result.op.after.length - result.op.before.length }
5247
+ : undefined;
5248
+ if (!(await safeRerenderAll(editRange))) {
3859
5249
  lines = rollbackFailedRender({ lines, stack }, result, prevLines);
3860
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;
3861
5256
  }
3862
5257
 
3863
5258
  // S2 Task 5 — 轉換成 › 項目符號列表 / 編號列表 / 待辦清單 on a block that is
@@ -3912,9 +5307,18 @@
3912
5307
  startLine: merged.startLine,
3913
5308
  endLine: merged.startLine + md.split('\n').length - 1,
3914
5309
  });
3915
- if (!(await safeRerenderAll())) {
5310
+ const editRange = result.op
5311
+ ? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
5312
+ endLine: Math.max(result.op.startLine - 1, result.op.endLine),
5313
+ delta: result.op.after.length - result.op.before.length }
5314
+ : undefined;
5315
+ if (!(await safeRerenderAll(editRange))) {
3916
5316
  lines = rollbackFailedRender({ lines, stack }, result, prevLines);
3917
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;
3918
5322
  }
3919
5323
 
3920
5324
  // The `.ed-li-check` chrome for a li that has just BECOME a task item.
@@ -3967,18 +5371,44 @@
3967
5371
  // focus/blur cycle on that same block (click into it, click back out
3968
5372
  // — e.g. after Esc-ing the raw editor) re-armed that same stale DOM
3969
5373
  // and committed it as if it were live content: the "discarded" edit
3970
- // resurrected itself into `lines`. Restore the pre-edit snapshot
3971
- // FIRST, mirroring revertBurstAndEnd()'s own contract, so nothing is
3972
- // left behind for a later burst to accidentally pick up and commit.
3973
- currentBurst.editEl.innerHTML = currentBurst.original;
3974
- 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;
3975
5383
  currentBurst = null;
5384
+ dyingBurst.history.dispose();
5385
+ dyingBurst.editEl.innerHTML = dyingBurst.original;
3976
5386
  resetSelToolbarState();
3977
5387
  } else {
3978
5388
  const ok = await switchAwayFrom();
3979
5389
  if (!ok) return;
3980
5390
  }
3981
- openRawEditor(blockEl);
5391
+ // v3.1.0 修正 4: which LINES this raw edit owns is now §3.3's membership
5392
+ // question, asked through the same door every other ⠿ operation uses.
5393
+ // resolveGutterOperands() brings the whole shared preamble with it — the
5394
+ // re-resolve after a commit that re-rendered, the no-source-line refusal,
5395
+ // the emptiness and CONTIGUITY gates — which is what makes a span range
5396
+ // safe to take as `recs[0].startLine .. recs[last].endLine`: a disjoint
5397
+ // selection would refuse here rather than quietly swallowing the blocks
5398
+ // sitting in the gap. Its own switchAwayFrom() is a no-op by this point
5399
+ // (the branch above already resolved whatever was open).
5400
+ const operands = await resolveGutterOperands(blockEl);
5401
+ if (!operands) return;
5402
+ const els = operands.els;
5403
+ const recs = operands.recs;
5404
+ const liveBlockEl = els[0];
5405
+ const isLi = liveBlockEl.getAttribute('data-block-type') === 'li';
5406
+ // A single non-li block keeps the pre-v3.1.0 path exactly: no range, so
5407
+ // openRawEditor() uses the block's own record and restores by innerHTML.
5408
+ const range = (recs.length > 1 || isLi)
5409
+ ? { startLine: recs[0].startLine, endLine: recs[recs.length - 1].endLine }
5410
+ : null;
5411
+ await openRawEditor(liveBlockEl, range);
3982
5412
  }
3983
5413
 
3984
5414
  // Starts a burst on `editEl` (a `.ed-wys-armed` content element) — called
@@ -4170,10 +5600,20 @@
4170
5600
  const { md: runMd, unsupported, unsupportedByLi, lineMeta } =
4171
5601
  listMd.serializeBlocks(runEls, { carryOver: carry });
4172
5602
  // Refuse if the EDITED li itself has unsupported inline content.
4173
- // RULING F-O: do NOT call openRawEditor() on a list block — injecting
4174
- // a textarea into list structure corrupts list-md serialization and
4175
- // renders badly. Show banner + teardown + rerenderAll (file is untouched,
4176
- // burst never wrote to `lines`) + return false.
5603
+ // Show banner + teardown + rerenderAll (file is untouched, burst never
5604
+ // wrote to `lines`) + return false — this path does NOT fall back to
5605
+ // openRawEditor().
5606
+ //
5607
+ // ⚠ v3.1.0 Ruling 13 supersedes RULING F-O for the ⠿ menu's MD 原始碼
5608
+ // (see toggleGutterMenu()), and NOT for this site. Two different
5609
+ // reasons: F-O was about the CONTAINER (a textarea cannot live inside a
5610
+ // serialized run), which 修正 4 answers by restoring through a
5611
+ // re-render; this refusal is about the CONTENT — the run's own
5612
+ // serializer has just reported it cannot round-trip this item, and
5613
+ // silently opening an editor from inside a burst resolution would be a
5614
+ // second, unasked-for gesture on top of a failed one. The banner's
5615
+ // advice ('改用原始碼編輯') is now literally actionable, which it was
5616
+ // not before: the user can reach exactly that through the ⠿ menu.
4177
5617
  const editedIdStr = String(burst.blockId);
4178
5618
  if (unsupportedByLi.some((u) => u.blockId === editedIdStr)) {
4179
5619
  showBanner('含不支援的格式,改用原始碼編輯', null, null);
@@ -4253,7 +5693,12 @@
4253
5693
  }
4254
5694
  const liPrevLines = lines;
4255
5695
  lines = liCommitResult.lines;
4256
- const liOk = await safeRerenderAll();
5696
+ const editRange = liCommitResult.op
5697
+ ? { startLine: Math.min(liCommitResult.op.startLine, liCommitResult.op.endLine + 1),
5698
+ endLine: Math.max(liCommitResult.op.startLine - 1, liCommitResult.op.endLine),
5699
+ delta: liCommitResult.op.after.length - liCommitResult.op.before.length }
5700
+ : undefined;
5701
+ const liOk = await safeRerenderAll(editRange);
4257
5702
  if (!liOk) {
4258
5703
  lines = rollbackFailedRender({ lines, stack }, liCommitResult, liPrevLines);
4259
5704
  return false;
@@ -4309,7 +5754,12 @@
4309
5754
  }
4310
5755
  const prevLines = lines;
4311
5756
  lines = commitResult.lines;
4312
- const ok = await safeRerenderAll();
5757
+ const editRange = commitResult.op
5758
+ ? { startLine: Math.min(commitResult.op.startLine, commitResult.op.endLine + 1),
5759
+ endLine: Math.max(commitResult.op.startLine - 1, commitResult.op.endLine),
5760
+ delta: commitResult.op.after.length - commitResult.op.before.length }
5761
+ : undefined;
5762
+ const ok = await safeRerenderAll(editRange);
4313
5763
  if (!ok) {
4314
5764
  lines = rollbackFailedRender({ lines, stack }, commitResult, prevLines);
4315
5765
  // Burst stays open: DOM/history untouched, banner already shown by
@@ -4964,7 +6414,12 @@
4964
6414
  // still sitting there un-committed — re-render anyway so what's on screen
4965
6415
  // is always exactly what's in `lines`.
4966
6416
  if (result.op !== null) lines = result.lines;
4967
- const ok = await safeRerenderAll();
6417
+ const editRange = result.op
6418
+ ? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
6419
+ endLine: Math.max(result.op.startLine - 1, result.op.endLine),
6420
+ delta: result.op.after.length - result.op.before.length }
6421
+ : undefined;
6422
+ const ok = await safeRerenderAll(editRange);
4968
6423
  if (!ok) {
4969
6424
  lines = rollbackFailedRender({ lines, stack }, result, prevLines);
4970
6425
  // Deliberately NOT a second safeRerenderAll(), unlike the two refusals
@@ -5833,7 +7288,12 @@
5833
7288
  // §3.3's collapse: a depth change rewrites no line COUNT, so the set
5834
7289
  // collapses onto the lines it already held.
5835
7290
  declareCollapse({ startLine: first.startLine, endLine: last.endLine });
5836
- const okRender = await safeRerenderAll();
7291
+ const editRange = result.op
7292
+ ? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
7293
+ endLine: Math.max(result.op.startLine - 1, result.op.endLine),
7294
+ delta: result.op.after.length - result.op.before.length }
7295
+ : undefined;
7296
+ const okRender = await safeRerenderAll(editRange);
5837
7297
  if (!okRender) {
5838
7298
  lines = rollbackFailedRender({ lines, stack }, result, prevLines);
5839
7299
  }
@@ -5866,6 +7326,63 @@
5866
7326
  await changeHeadingDepthsInSpan(els, recs, dir);
5867
7327
  }
5868
7328
 
7329
+ // v3.1.0 §4: the toolbar's ⇥ / ⇤ buttons. A ZERO-ARGUMENT commit wrapper,
7330
+ // placed beside tabSelection(dir) above because it is the same shape of
7331
+ // thing: a gesture that is not a keystroke, driving a mutation whose only
7332
+ // existing entry point wanted a KeyboardEvent.
7333
+ //
7334
+ // Why this function has to exist at all: indentListItem()/outdentListItem()
7335
+ // (further up) only ever write `data-indent` on the DOM and return a
7336
+ // boolean. They never touch `lines`, never push an undo op, never
7337
+ // re-render. Wiring a toolbar button straight to either of them produces a
7338
+ // list that visibly re-indents and then snaps back on the next render, with
7339
+ // the file byte-identical throughout. Everything that makes Tab a real edit
7340
+ // lives in handleLiKeydown()'s Tab branch — the run-wide gate, the mutation
7341
+ // under mutateListRun()'s focusout suppression, the indent clamp, the
7342
+ // bystander carry-over and the one-op commit — so that is what is
7343
+ // reproduced here, in the same order, with `e.shiftKey` replaced by `dir`.
7344
+ //
7345
+ // `caretLiBlock()` is zero-argument (it reads window.getSelection()), which
7346
+ // is what makes an editEl-free wrapper possible: the toolbar has no event
7347
+ // target inside the run, only a live caret — and the caret is the right
7348
+ // answer anyway, since a run has one editable surface per item.
7349
+ //
7350
+ // dir > 0 indents, dir < 0 outdents.
7351
+ async function indentCaretLi(dir) {
7352
+ const li = caretLiBlock();
7353
+ if (!li) return;
7354
+ let run = listRunOf(li);
7355
+ if (!run.length) return;
7356
+ // `columnOnly`: an indent change rewrites nothing but leading columns, so
7357
+ // a hard-wrapped item is allowed to be the target — same reading, and the
7358
+ // same measurement, as the Tab branch this mirrors.
7359
+ if (!listRunSupportsStructuralEdit(run, li, { columnOnly: true })) {
7360
+ refuseStructuralListEdit();
7361
+ return;
7362
+ }
7363
+ const oldIndent = Number(li.getAttribute('data-indent')) || 0;
7364
+ const changed = mutateListRun(() => {
7365
+ if (!(dir < 0 ? outdentListItem(li) : indentListItem(li))) return false;
7366
+ // v3.0.1, carried over VERBATIM and deliberately: the clamp must read a
7367
+ // FRESH listRunOf(li) — taken after the write above — not the
7368
+ // pre-mutation `run` captured outside. indentListItem() can move a
7369
+ // rule-(d) list-start item whose pre-mutation run is `[li]` alone; with
7370
+ // no anchor in the span, applyIndentClamp()'s rule 1 has nothing to
7371
+ // bound the new indent against and clamps it straight back to 0,
7372
+ // silently undoing the write. See handleLiKeydown()'s own copy of this
7373
+ // note for the full derivation.
7374
+ applyIndentClamp(listRunOf(li), li, oldIndent);
7375
+ return true;
7376
+ });
7377
+ if (!changed) return; // at the boundary: nothing mutated, nothing committed
7378
+ run = listRunOf(li);
7379
+ // Column-only: nothing's CONTENT moved, so every block in the span — the
7380
+ // target included — is a bystander whose source bytes come back
7381
+ // untouched. Built once and shared with runLineOfBlock() below.
7382
+ const carry = bystanderCarryOver(run, null);
7383
+ await commitListStructure(run, runLineOfBlock(run, li, carry), true, { carryOver: carry });
7384
+ }
7385
+
5869
7386
  // §3.6's 「Delete 整批刪」, whose 2026-08-31 ruling is that it goes through
5870
7387
  // the SAME batch path as the ⠿ menu's 刪除 and that no second deletion path
5871
7388
  // is written. deleteBlockViaGutter() already resolves §3.3's membership for
@@ -6076,10 +7593,34 @@
6076
7593
  // (/[ \t]+$/) does not, so it would survive into the committed line.
6077
7594
  // Either way the user sees an empty item and must get a bare '-'. Clear
6078
7595
  // the surface under the SAME normalisation the branch condition used —
6079
- // liSurfaceHoldsNothing() — inside the suppression span (an innerHTML
6080
- // assignment on the focused node triggers the very same Chromium unfocus
6081
- // quirk), and only once the outdent above has actually happened, since a
6082
- // 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.
6083
7624
  const textEl = liTextEl(li);
6084
7625
  if (textEl && liSurfaceHoldsNothing(textEl)) textEl.innerHTML = '';
6085
7626
  return true;
@@ -6832,6 +8373,20 @@
6832
8373
  // (possibly now-stale, post-delete) column/row it came from.
6833
8374
  let teHighlightEls = [];
6834
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
+
6835
8390
  function clearEdgeHighlight() {
6836
8391
  // classList.remove() leaves a dangling `class=""` behind on any element
6837
8392
  // the renderer/armEditables() emitted with NO class of its own — a
@@ -7055,14 +8610,84 @@
7055
8610
  const colIndex = teMenuColIndex;
7056
8611
  if (!tableEl || colIndex == null) return;
7057
8612
  // Final-review Finding 6: same live-table swap as runDeleteColumn() above.
7058
- const liveTableEl = await ensureTableBurstOpen(tableEl);
7059
- if (!liveTableEl) return;
7060
- cycleColumnAlign(liveTableEl, colIndex);
7061
- currentBurst.history.snap('align-col');
7062
- // Stays open (unlike delete): repeated clicks keep cycling. The
7063
- // column's cells are the SAME nodes (cycleColumnAlign() only touches
7064
- // the `style` attribute) so the existing highlight is still valid —
7065
- // 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
+ });
7066
8691
  }
7067
8692
 
7068
8693
  // ── Notion-style row/column grip handles ─────────────────────────────
@@ -8313,7 +9938,12 @@
8313
9938
  // (blockSelection)` guard is what stops this single-block gesture from
8314
9939
  // INVENTING a selection when none was standing.
8315
9940
  declareCollapse(result.newRange);
8316
- const okRender = await safeRerenderAll();
9941
+ const editRange = result.op
9942
+ ? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
9943
+ endLine: Math.max(result.op.startLine - 1, result.op.endLine),
9944
+ delta: result.op.after.length - result.op.before.length }
9945
+ : undefined;
9946
+ const okRender = await safeRerenderAll(editRange);
8317
9947
  if (!okRender) {
8318
9948
  lines = rollbackFailedRender({ lines, stack }, result, prevLines);
8319
9949
  }
@@ -8912,16 +10542,40 @@
8912
10542
  // same way activateCell() used to — brief: "wire your cell edit root the
8913
10543
  // same way paragraph editing does (reuse, don't fork)").
8914
10544
  function onSelectionChangeForToolbar() {
8915
- const sel = window.getSelection();
8916
- if (!sel || sel.rangeCount === 0 || sel.isCollapsed) { hideSelToolbar(); return; }
8917
- const range = sel.getRangeAt(0);
8918
- if (!selToolbarEditEl ||
8919
- !selToolbarEditEl.contains(range.startContainer) ||
8920
- !selToolbarEditEl.contains(range.endContainer)) {
8921
- hideSelToolbar();
8922
- return;
10545
+ // v3.1.0: the fixed toolbar's five inline-format buttons enable/disable on
10546
+ // exactly the same question this listener already answers ("is there a
10547
+ // non-collapsed selection inside `selToolbarEditEl`?"), so it piggybacks
10548
+ // here instead of attaching a SECOND document-level selectionchange
10549
+ // listener.
10550
+ //
10551
+ // That is not just tidiness. test/editor-client-runtime.test.js
10552
+ // instruments document.addEventListener for this exact type and asserts
10553
+ // the live count is 1 during a session and 0 after — the property being
10554
+ // that a burst's listener is removed on rerender and never stacks. A
10555
+ // permanent second listener would not leak or stack, but it would move
10556
+ // that counter's baseline and force three numeric assertions plus a
10557
+ // waitForFunction(count === 0) to be rewritten around it, weakening a
10558
+ // leak guard to accommodate a convenience. Reusing this one keeps the
10559
+ // guard exactly as written, and is also strictly more correct: with no
10560
+ // burst open `selToolbarEditEl` is null, so `hasSelection` is false —
10561
+ // which is precisely when this listener is not attached.
10562
+ try {
10563
+ const selNow = window.getSelection();
10564
+ if (!selNow || selNow.rangeCount === 0 || selNow.isCollapsed) { hideSelToolbar(); return; }
10565
+ const range = selNow.getRangeAt(0);
10566
+ if (!selToolbarEditEl ||
10567
+ !selToolbarEditEl.contains(range.startContainer) ||
10568
+ !selToolbarEditEl.contains(range.endContainer)) {
10569
+ hideSelToolbar();
10570
+ return;
10571
+ }
10572
+ positionSelToolbar(range);
10573
+ } finally {
10574
+ // In a `finally` so every early return above still refreshes the bar —
10575
+ // the collapse-to-caret exits are exactly the ones that must turn the
10576
+ // mark buttons back off.
10577
+ updateToolbar();
8923
10578
  }
8924
- positionSelToolbar(range);
8925
10579
  }
8926
10580
 
8927
10581
  // Nearest ancestor of `node` (inclusive) with tagName `tag`, stopping at
@@ -8976,6 +10630,56 @@
8976
10630
  return r;
8977
10631
  }
8978
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
+
8979
10683
  // Marks of `tag` that the range overlaps (fully or partially) — called
8980
10684
  // only AFTER wholeSelectionMark() has already returned null, so any hit
8981
10685
  // here is by construction a partial-overlap case (see the toggle-policy
@@ -9039,26 +10743,190 @@
9039
10743
  snapBurstIfActive(root, 'mark');
9040
10744
  return;
9041
10745
  }
10746
+ if (!trimRangeToText(range)) return; // 全空白/collapsed 選取:no-op
9042
10747
  reselectAndReposition(wrapRangeIn(range, tag));
9043
10748
  snapBurstIfActive(root, 'mark');
9044
10749
  }
9045
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
+
9046
10908
  // Link is its own entry point (not applyMarkToggle) because "unwrap" here
9047
10909
  // means "prompt to edit or clear the URL", and "wrap" means "prompt for a
9048
10910
  // URL first" — both need window.prompt() before any DOM surgery happens.
9049
- 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() {
9050
10918
  const root = selToolbarEditEl;
9051
- if (!root) return;
10919
+ if (!root) return false;
9052
10920
  const sel = window.getSelection();
9053
- if (!sel.rangeCount) return;
10921
+ if (!sel.rangeCount) return false;
9054
10922
  const range = sel.getRangeAt(0);
9055
- if (range.collapsed) return;
9056
- 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;
9057
10925
 
9058
10926
  const whole = wholeSelectionMark(range, 'A', root);
9059
10927
  if (whole) {
9060
10928
  const url = window.prompt('連結網址(留空以移除連結):', whole.getAttribute('href') || '');
9061
- if (url === null) return; // cancelled — leave the link untouched
10929
+ if (url === null) return true; // cancelled — leave the link untouched
9062
10930
  if (url.trim() === '') {
9063
10931
  reselectAndReposition(unwrapElement(whole));
9064
10932
  } else {
@@ -9066,7 +10934,7 @@
9066
10934
  reselectAndReposition(range);
9067
10935
  }
9068
10936
  snapBurstIfActive(root, 'mark');
9069
- return;
10937
+ return true;
9070
10938
  }
9071
10939
  const overlapping = overlappingMarks(range, 'A', root);
9072
10940
  if (overlapping.length > 0) {
@@ -9074,10 +10942,10 @@
9074
10942
  overlappingMarks(extended, 'A', root).forEach((m) => unwrapElement(m));
9075
10943
  reselectAndReposition(extended);
9076
10944
  snapBurstIfActive(root, 'mark');
9077
- return;
10945
+ return false; // unwrapped in place — no modal ever opened
9078
10946
  }
9079
10947
  const url = window.prompt('連結網址:', 'https://');
9080
- if (url === null || url.trim() === '') return; // cancelled or empty — no-op
10948
+ if (url === null || url.trim() === '') return true; // cancelled or empty — no-op
9081
10949
  const el = document.createElement('a');
9082
10950
  el.setAttribute('href', url.trim());
9083
10951
  el.appendChild(range.extractContents());
@@ -9086,6 +10954,7 @@
9086
10954
  r.selectNodeContents(el);
9087
10955
  reselectAndReposition(r);
9088
10956
  snapBurstIfActive(root, 'mark');
10957
+ return true;
9089
10958
  }
9090
10959
 
9091
10960
  function buildSelToolbar() {
@@ -9128,7 +10997,14 @@
9128
10997
  // `position: fixed`.
9129
10998
  function positionSelToolbar(range) {
9130
10999
  const rect = range.getBoundingClientRect();
9131
- 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
+ }
9132
11008
  if (!selToolbar.parentNode) document.body.appendChild(selToolbar);
9133
11009
  const gap = 8, margin = 4;
9134
11010
  const tbRect = selToolbar.getBoundingClientRect();
@@ -9413,7 +11289,28 @@
9413
11289
  // DECLARED rather than cleared outright so the rollback below leaves a
9414
11290
  // standing selection alone when the render fails.
9415
11291
  declareSelectionRange(null);
9416
- const ok = await safeRerenderAll();
11292
+ // v3.2.0: UndoStack.undo()/discardTop() reverse the op when applying it but
11293
+ // return the unmodified FORWARD op, so the range actually rewritten is
11294
+ // [op.startLine, op.startLine + op.after.length - 1] and the delta is the
11295
+ // mirror of the forward one. Using op.endLine here would name the opposite
11296
+ // direction — the span the op wrote on the way FORWARD, not the one this
11297
+ // reversal just rewrote.
11298
+ //
11299
+ // Final review item 4 — what naming it wrongly would cost today: nothing.
11300
+ // Per 決議 4(b) `editRange` is ADVISORY. patchmap() destructures
11301
+ // oldBlocks/newBlocks/oldParts/newParts and never reads it; the kept
11302
+ // prefix/suffix is the longest common run of the two PART arrays, and
11303
+ // seeding it from an op's line range only ever made the answer worse
11304
+ // (measured in 決議 4(b): seeded kept 0 of 5, pure-parts kept 4 of 5).
11305
+ // Its one client-side use is the truthiness gate in applyRenderResult().
11306
+ // So the direction changes no plan — this is simply the correct value for
11307
+ // whenever something does start reading it.
11308
+ const editRange = r.op
11309
+ ? { startLine: r.op.startLine,
11310
+ endLine: r.op.startLine + r.op.after.length - 1,
11311
+ delta: r.op.before.length - r.op.after.length }
11312
+ : undefined;
11313
+ const ok = await safeRerenderAll(editRange);
9417
11314
  if (!ok) {
9418
11315
  // Reverse the undo attempt: push the op back and restore `lines` to
9419
11316
  // what was on screen before this undo was requested.
@@ -9429,7 +11326,15 @@
9429
11326
  if (!r) return;
9430
11327
  lines = r.lines;
9431
11328
  declareSelectionRange(null); // §4.4, same as undo() above
9432
- const ok = await safeRerenderAll();
11329
+ // redo() is NOT reversed — it goes through
11330
+ // replaceLines(lines, op.startLine, op.endLine, op.after), so the
11331
+ // ordinary forward formula applies here, unlike undo() above.
11332
+ const editRange = r.op
11333
+ ? { startLine: Math.min(r.op.startLine, r.op.endLine + 1),
11334
+ endLine: Math.max(r.op.startLine - 1, r.op.endLine),
11335
+ delta: r.op.after.length - r.op.before.length }
11336
+ : undefined;
11337
+ const ok = await safeRerenderAll(editRange);
9433
11338
  if (!ok) {
9434
11339
  // Reverse the redo attempt: pop the op back off and restore `lines`.
9435
11340
  const rollback = stack.undo(lines);
@@ -9437,6 +11342,954 @@
9437
11342
  }
9438
11343
  }
9439
11344
 
11345
+ // ── v3.1.0 §4: the always-visible editing toolbar ──────────────────────
11346
+ // The version's headline feature: before this there was no toolbar at all,
11347
+ // and every editing affordance was hidden behind a hover gutter, a caret
11348
+ // position or a text selection. window.md2docToolbarModel (injected by
11349
+ // server.js, built and reviewed as lib/editor/toolbar-model.js) owns the
11350
+ // 22-button roster, the group order that decides separator placement, and
11351
+ // the whole active/disabled derivation; everything below is mounting and
11352
+ // wiring. Nothing here re-derives a button's state by hand — a second
11353
+ // opinion about when ⇥ is legal is exactly what the pure-module split
11354
+ // exists to prevent.
11355
+ //
11356
+ // EVERY existing entry point stays: the ⠿ menu, the + menu and the
11357
+ // floating selection toolbar are untouched. This is one more road to the
11358
+ // same operations, not a replacement for any of them.
11359
+ let toolbarEl = null;
11360
+ let toolbarBtns = null; // { [buttonId]: HTMLButtonElement }
11361
+ let toolbarMenuEl = null; // the H▾ dropdown panel, or null when closed
11362
+ let toolbarBlockEl = null;
11363
+ let toolbarStateSig = '';
11364
+ // 追加 4's document mode: 'edit' | 'source'. v3.2.1 removed the third
11365
+ // state ('preview'); docsource.js's MODES comment records why.
11366
+ // docsource.js owns the cycle order and the lines[]<->string conversion.
11367
+ let docMode = 'edit';
11368
+ let sourceTextarea = null;
11369
+ let sourceBaseLines = null;
11370
+ // 追加 2: Ctrl+Shift+V arms a plain-text-only paste for the NEXT paste
11371
+ // event. A ClipboardEvent carries no modifier state of its own, so the
11372
+ // intent has to be captured on the keystroke that produced it.
11373
+ let plainPasteArmed = false;
11374
+
11375
+ const TOOLBAR_UNSUPPORTED_IMAGE_MESSAGE = '只接受 PNG / JPEG / GIF / WebP 圖片';
11376
+ const TOOLBAR_NO_ANCHOR_MESSAGE = '找不到可以插入圖片的位置';
11377
+
11378
+ // Extra visual classes for the three inline buttons whose own glyph is the
11379
+ // affordance (B is bold, I is italic, S is struck through) — the same
11380
+ // idiom .ed-seltb-b/-i/-s use.
11381
+ const TOOLBAR_BTN_CLASS = {
11382
+ bold: 'ed-toolbar-b', italic: 'ed-toolbar-i', strike: 'ed-toolbar-s',
11383
+ };
11384
+
11385
+ // The buttons whose model state can ever report `active: true` — i.e. the
11386
+ // real toggles, and the only ones that may carry aria-pressed. Every other
11387
+ // button is a command, not a toggle.
11388
+ // v3.2.0: derived, not mirrored. A seventh toggle added to toolbar-model.js
11389
+ // used to need a matching edit here, and forgetting it made aria-pressed lie.
11390
+ const TOOLBAR_TOGGLE_IDS = new Set(
11391
+ (toolbarModel ? toolbarModel.BUTTONS : []).filter((b) => b.toggle).map((b) => b.id));
11392
+
11393
+ // The block the bar currently reports on, or null. `null` is the model's
11394
+ // documented no-block state (undo/redo/outline/preview live, everything
11395
+ // else greyed), which is exactly what rerenderAll()'s teardown wants — see
11396
+ // resetToolbarBlock() below.
11397
+ function liveToolbarBlock() {
11398
+ if (toolbarBlockEl && document.body.contains(toolbarBlockEl)) return toolbarBlockEl;
11399
+ toolbarBlockEl = null;
11400
+ return null;
11401
+ }
11402
+
11403
+ // Called from rerenderAll()'s belt-and-braces teardown. A detached node's
11404
+ // getAttribute() still answers, so a tracked block that survived the
11405
+ // .content swap would keep the bar reporting a block no click can reach.
11406
+ function resetToolbarBlock() {
11407
+ toolbarBlockEl = null;
11408
+ closeToolbarMenu();
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;
11432
+ updateToolbar();
11433
+ }
11434
+
11435
+ // Final review item 5: the EXACT predicate applyMarkToggle() and
11436
+ // applyLinkToggle() gate on — all four of their conditions, not three.
11437
+ // Kept as one function so the toolbar's enablement and the actions' own
11438
+ // refusal cannot drift apart: whenever this says true, both actions run.
11439
+ //
11440
+ // The fourth condition (containment) is the one `hasSelection` used to
11441
+ // omit, and it is reachable: a drag-select that STARTS inside an open
11442
+ // burst and ENDS in the next block leaves the selection non-collapsed with
11443
+ // `selToolbarEditEl` still set. The floating selection toolbar already
11444
+ // asks this same question of itself and correctly hides (see its
11445
+ // selectionchange listener above), but updateToolbar() still ran and lit
11446
+ // the five inline-format buttons over a selection every one of them
11447
+ // silently refuses — precisely the dead-button failure this version's
11448
+ // enablement wiring exists to remove.
11449
+ function hasFormattableSelection(sel) {
11450
+ if (!selToolbarEditEl || !sel || sel.rangeCount === 0 || sel.isCollapsed) return false;
11451
+ const range = sel.getRangeAt(0);
11452
+ if (range.collapsed) return false;
11453
+ return selToolbarEditEl.contains(range.startContainer) &&
11454
+ selToolbarEditEl.contains(range.endContainer);
11455
+ }
11456
+
11457
+ // ctx for toolbarModel.deriveState(). Every field is read off the DOM /
11458
+ // `lines` at call time; nothing is cached, so a stale value cannot outlive
11459
+ // a render.
11460
+ function toolbarContext() {
11461
+ const blockEl = liveToolbarBlock();
11462
+ const blockType = blockEl ? blockEl.getAttribute('data-block-type') : null;
11463
+ let headingDepth = 1;
11464
+ if (blockType === 'heading') {
11465
+ const rec = blockRecOf(blockEl);
11466
+ if (rec && rec.endLine >= rec.startLine) headingDepth = headingDepthOf(lines[rec.startLine - 1]);
11467
+ }
11468
+ const sel = typeof window.getSelection === 'function' ? window.getSelection() : null;
11469
+ return {
11470
+ blockType: blockType,
11471
+ indent: blockEl ? (Number(blockEl.getAttribute('data-indent')) || 0) : 0,
11472
+ headingDepth: headingDepth,
11473
+ inList: blockType === 'li',
11474
+ listOrdered: !!blockEl && blockEl.getAttribute('data-list-type') === 'ol',
11475
+ // The mark buttons act through applyMarkToggle()/applyLinkToggle();
11476
+ // hasFormattableSelection() (just above) is their refusal test spelled
11477
+ // once, rather than a looser "is anything selected anywhere on the page".
11478
+ hasSelection: hasFormattableSelection(sel),
11479
+ mode: docMode,
11480
+ };
11481
+ }
11482
+
11483
+ function updateToolbar() {
11484
+ if (!toolbarEl || !toolbarBtns || !toolbarModel) return;
11485
+ const state = toolbarModel.deriveState(toolbarContext());
11486
+ // selectionchange fires on every caret move, so the state is diffed
11487
+ // before any DOM is written — 22 attribute writes per keystroke is a
11488
+ // cost with no matching benefit.
11489
+ const sig = JSON.stringify(state);
11490
+ if (sig === toolbarStateSig) return;
11491
+ toolbarStateSig = sig;
11492
+ Object.keys(toolbarBtns).forEach((id) => {
11493
+ const st = state[id];
11494
+ const btn = toolbarBtns[id];
11495
+ if (!st || !btn) return;
11496
+ btn.disabled = !!st.disabled;
11497
+ // aria-pressed ONLY on the buttons that can actually be pressed-in.
11498
+ // Writing it to all 22 made a screen reader announce `undo`, `image`
11499
+ // and `table` as toggle buttons stuck in the "not pressed" state,
11500
+ // which is a lie about what they do.
11501
+ if (TOOLBAR_TOGGLE_IDS.has(id)) {
11502
+ btn.setAttribute('aria-pressed', st.active ? 'true' : 'false');
11503
+ }
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.
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
+ }
11515
+ });
11516
+ }
11517
+
11518
+ function buildToolbar() {
11519
+ const bar = document.createElement('div');
11520
+ bar.className = 'ed-toolbar';
11521
+ bar.setAttribute('role', 'toolbar');
11522
+ bar.setAttribute('aria-label', '編輯工具列');
11523
+ const btns = {};
11524
+ let prevGroup = null;
11525
+ toolbarModel.BUTTONS.forEach((def) => {
11526
+ // GROUPS order doubles as separator placement: one divider wherever
11527
+ // consecutive buttons change group.
11528
+ if (prevGroup !== null && def.group !== prevGroup) {
11529
+ const sep = document.createElement('span');
11530
+ sep.className = 'ed-toolbar-sep';
11531
+ bar.appendChild(sep);
11532
+ }
11533
+ prevGroup = def.group;
11534
+ const btn = document.createElement('button');
11535
+ btn.type = 'button';
11536
+ btn.className = 'ed-toolbar-btn' +
11537
+ (TOOLBAR_BTN_CLASS[def.id] ? ' ' + TOOLBAR_BTN_CLASS[def.id] : '');
11538
+ btn.textContent = def.icon;
11539
+ btn.title = def.title;
11540
+ btn.setAttribute('aria-label', def.title);
11541
+ btn.setAttribute('data-ed-tb', def.id);
11542
+ // Not a tab stop, same as the ⠿ / + gutter buttons: this is chrome,
11543
+ // and the Tab key belongs to the document's own structure.
11544
+ btn.setAttribute('tabindex', '-1');
11545
+ // ⚠ LOAD-BEARING, not a nicety. Without it the five inline-format
11546
+ // buttons are dead on arrival: a <button> taking focus on mousedown
11547
+ // fires the armed surface's focusout, which commits and re-renders,
11548
+ // so applyMarkToggle()'s `selToolbarEditEl` is null and the selection
11549
+ // is gone before the click handler ever runs. buildSelToolbar() and
11550
+ // both table grip builders defend themselves the identical way.
11551
+ btn.addEventListener('mousedown', (e) => e.preventDefault());
11552
+ btn.addEventListener('click', (e) => {
11553
+ e.stopPropagation();
11554
+ Promise.resolve(runToolbarAction(def.id, btn)).catch(() => {}).then(updateToolbar);
11555
+ });
11556
+ bar.appendChild(btn);
11557
+ btns[def.id] = btn;
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);
11589
+ toolbarBtns = btns;
11590
+ // v3.1.0 的 spec 承諾「元素存在但無內容,§3 上線時填入、不需要重排版面」,
11591
+ // 但元素從未被建立。這裡把承諾兌現:佔好寬度。內容不再是空的 ——
11592
+ // paintModeStatus() 會在 mountToolbar() 掛上之後、以及每次 setDocMode()
11593
+ // 切換時寫入當前模式名稱。
11594
+ const statusEl = document.createElement('span');
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');
11606
+ bar.appendChild(statusEl);
11607
+ return bar;
11608
+ }
11609
+
11610
+ async function runToolbarAction(id, btnEl) {
11611
+ const blockEl = liveToolbarBlock();
11612
+ switch (id) {
11613
+ // ⚠ The BROWSER-side zero-argument undo()/redo(), never `stack.undo` /
11614
+ // `stack.redo`. Those are the pure core's helpers (they are what
11615
+ // rollbackFailedRender() reverses an op with) and calling them from
11616
+ // here would skip switchAwayFrom() and safeRerenderAll() — the exact
11617
+ // shape this file records as having produced a silent total lockout
11618
+ // recoverable only by reloading the page.
11619
+ case 'undo': return undo();
11620
+ case 'redo': return redo();
11621
+ case 'headings': return toggleToolbarHeadingMenu(btnEl);
11622
+ // The five conversion targets are convert-md.js's own ids, so the
11623
+ // gesture goes through convertBlockViaMenu()'s §3.7 / §4.3 gates
11624
+ // (withheld types, mixed spans, run support) exactly like the ⠿
11625
+ // submenu's items do.
11626
+ case 'quote': return blockEl ? convertBlockViaMenu(blockEl, 'quote') : undefined;
11627
+ case 'code': return blockEl ? convertBlockViaMenu(blockEl, 'code') : undefined;
11628
+ case 'list': return blockEl ? convertBlockViaMenu(blockEl, 'ul') : undefined;
11629
+ case 'ordered-list': return blockEl ? convertBlockViaMenu(blockEl, 'ol') : undefined;
11630
+ case 'check': return blockEl ? convertBlockViaMenu(blockEl, 'task') : undefined;
11631
+ case 'bold': return applyMarkToggle('STRONG');
11632
+ case 'italic': return applyMarkToggle('EM');
11633
+ case 'strike': return applyMarkToggle('DEL');
11634
+ case 'inline-code': return applyMarkToggle('CODE');
11635
+ case 'link': return applyLinkToggle();
11636
+ // NOT indentListItem()/outdentListItem(): those only write data-indent
11637
+ // on the DOM and never commit. See indentCaretLi()'s own comment.
11638
+ case 'outdent': return indentCaretLi(-1);
11639
+ case 'indent': return indentCaretLi(1);
11640
+ case 'table': return blockEl ? insertBlockBelow(blockEl, 'table') : undefined;
11641
+ case 'line': return blockEl ? insertBlockBelow(blockEl, 'line') : undefined;
11642
+ case 'insert-after': return blockEl ? insertBlockBelow(blockEl, 'paragraph') : undefined;
11643
+ case 'insert-before': return blockEl ? insertBlockAbove(blockEl) : undefined;
11644
+ case 'image': return pickAndInsertImage();
11645
+ case 'outline': return toggleOutlineSidebar();
11646
+ case 'preview': return cycleDocMode();
11647
+ default: return undefined;
11648
+ }
11649
+ }
11650
+
11651
+ // ── headings ▾ ─────────────────────────────────────────────────────────
11652
+ function closeToolbarMenu() {
11653
+ if (toolbarMenuEl) { toolbarMenuEl.remove(); toolbarMenuEl = null; }
11654
+ }
11655
+
11656
+ function toggleToolbarHeadingMenu(btnEl) {
11657
+ if (toolbarMenuEl) { closeToolbarMenu(); return; }
11658
+ const menu = document.createElement('div');
11659
+ menu.className = 'ed-toolbar-menu';
11660
+ [1, 2, 3, 4, 5, 6].forEach((n) => {
11661
+ const item = document.createElement('button');
11662
+ item.type = 'button';
11663
+ item.className = 'ed-toolbar-menu-btn';
11664
+ item.textContent = '標題 ' + n;
11665
+ item.setAttribute('tabindex', '-1');
11666
+ item.addEventListener('mousedown', (e) => e.preventDefault());
11667
+ item.addEventListener('click', (e) => {
11668
+ e.stopPropagation();
11669
+ closeToolbarMenu();
11670
+ Promise.resolve(applyHeadingLevel(n)).catch(() => {}).then(updateToolbar);
11671
+ });
11672
+ menu.appendChild(item);
11673
+ });
11674
+ document.body.appendChild(menu);
11675
+ const r = btnEl.getBoundingClientRect();
11676
+ menu.style.top = (r.bottom + 4) + 'px';
11677
+ menu.style.left = Math.max(4, Math.min(r.left, window.innerWidth - 140)) + 'px';
11678
+ toolbarMenuEl = menu;
11679
+ }
11680
+
11681
+ // changeHeadingDepth(blockEl, delta) takes a RELATIVE delta and has no
11682
+ // blockType guard, while headingDepthOf() answers 1 for any non-heading
11683
+ // line — so calling it on a paragraph silently prefixes '## ' and turns it
11684
+ // into an H2 while bypassing convertBlockViaMenu()'s gates entirely. Both
11685
+ // halves of that are handled here: a non-heading is CONVERTED through the
11686
+ // gate (convert-md.js's 'h1'..'h6' targets already carry the level), and a
11687
+ // heading gets a delta computed against its own current depth.
11688
+ async function applyHeadingLevel(n) {
11689
+ const blockEl = liveToolbarBlock();
11690
+ if (!blockEl) return;
11691
+ if (blockEl.getAttribute('data-block-type') !== 'heading') {
11692
+ await convertBlockViaMenu(blockEl, 'h' + n);
11693
+ return;
11694
+ }
11695
+ const rec = blockRecOf(blockEl);
11696
+ if (!rec || rec.endLine < rec.startLine) return;
11697
+ const delta = n - headingDepthOf(lines[rec.startLine - 1]);
11698
+ if (delta === 0) return;
11699
+ await changeHeadingDepth(blockEl, delta);
11700
+ }
11701
+
11702
+ // ── insert above ───────────────────────────────────────────────────────
11703
+ // commitBlockInsertion() only ever inserts AFTER a block's range, so
11704
+ // "insert above" is expressed the way this file already expresses it (see
11705
+ // convertEmptyTopLevelLiToParagraph()'s own preceding-block lookup):
11706
+ // anchor on the block before this one. That keeps one insertion path and
11707
+ // one set of gates rather than teaching the pure core a direction.
11708
+ async function insertBlockAbove(blockEl) {
11709
+ const rec = blockRecOf(blockEl);
11710
+ if (!rec) return;
11711
+ const preceding = blocks
11712
+ .filter((b) => b.id !== rec.id && b.endLine < rec.startLine && b.endLine >= b.startLine)
11713
+ .pop();
11714
+ if (preceding) {
11715
+ const anchorEl = blockElAtLine(preceding.startLine);
11716
+ if (anchorEl) { await insertBlockBelow(anchorEl, 'paragraph'); return; }
11717
+ }
11718
+ // Nothing above to anchor on — see insertParagraphAtTop(). Refusing here
11719
+ // instead would leave the document's FIRST block permanently impossible
11720
+ // to precede: the ⠿ and + menus both insert below, so this button is the
11721
+ // only affordance that reaches that position at all.
11722
+ await insertParagraphAtTop(blockEl);
11723
+ }
11724
+
11725
+ // The document's first block has no predecessor to insert BELOW, and
11726
+ // commitBlockInsertion() only ever inserts after a block's range. Rather
11727
+ // than teach the pure core a direction (a change under byte-stability and
11728
+ // roundtrip fixtures), the insert is expressed as a REPLACE of that
11729
+ // block's own first source line by [skeleton, '', that same line] — the
11730
+ // same commitRangeEdit()/replaceLines() pipeline as every other edit,
11731
+ // therefore one undo op, and no new primitive.
11732
+ //
11733
+ // The switchAwayFrom() + re-resolve preamble is insertBlockBelow()'s,
11734
+ // for its reasons: the resolution can commit a dirty burst, whose
11735
+ // rerenderAll() detaches the very node this was called with.
11736
+ async function insertParagraphAtTop(blockEl) {
11737
+ const identity = captureBlockIdentity(blockEl);
11738
+ const selfSession = ownsOpenSession(blockEl);
11739
+ const ok = await switchAwayFrom();
11740
+ if (!ok) return;
11741
+ let liveBlockEl = blockEl;
11742
+ if (!document.body.contains(blockEl)) {
11743
+ liveBlockEl = reresolveBlockEl(identity) ||
11744
+ (selfSession ? reresolveBlockElAfterSelfCommit(identity) : null);
11745
+ if (!liveBlockEl) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
11746
+ }
11747
+ const rec = blockRecOf(liveBlockEl);
11748
+ // A block that owns no source line has an inverted range, and there is
11749
+ // no line to splice in front of.
11750
+ if (!rec || rec.endLine < rec.startLine) {
11751
+ refuseStructuralListEdit(NO_SOURCE_LINE_INSERT_MESSAGE);
11752
+ return;
11753
+ }
11754
+ const at = rec.startLine;
11755
+ const newText = BLOCK_SKELETONS.paragraph.concat(['', lines[at - 1]]).join('\n');
11756
+ const result = commitRangeEdit({ lines, blocks, stack }, at, at, newText);
11757
+ if (result.op === null) return;
11758
+ const prevLines = lines;
11759
+ lines = result.lines;
11760
+ const editRange = result.op
11761
+ ? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
11762
+ endLine: Math.max(result.op.startLine - 1, result.op.endLine),
11763
+ delta: result.op.after.length - result.op.before.length }
11764
+ : undefined;
11765
+ const okRender = await safeRerenderAll(editRange);
11766
+ if (!okRender) {
11767
+ lines = rollbackFailedRender({ lines, stack }, result, prevLines);
11768
+ return;
11769
+ }
11770
+ // The new paragraph starts exactly where the old first line did — same
11771
+ // pristine + focus contract insertBlockBelow() gives a skeleton insert.
11772
+ const target = blocks.find((b) => b.startLine === at);
11773
+ if (target) pristineInsert = { blockId: target.id };
11774
+ await focusInsertedBlock(at, 'paragraph');
11775
+ }
11776
+
11777
+ // ── 大綱 ───────────────────────────────────────────────────────────────
11778
+ // TWO attributes, chosen by viewport, and that is not an accident.
11779
+ //
11780
+ // `body[data-sidebar-open]` is the reader runtime's own attribute (see
11781
+ // lib/md2doc.js's setSidebarOpen()) and it drives the MOBILE drawer. The
11782
+ // first cut of this function reused it on desktop too, on the premise that
11783
+ // nothing outside the max-width:1080px block read it. That premise was
11784
+ // false, and the failure was severe: `body[data-sidebar-open]
11785
+ // .sidebar-scrim { display: block; }` sits at BASE scope, so on a desktop
11786
+ // viewport the button painted a full-viewport 35%-black scrim over the
11787
+ // whole page (position:fixed; inset:0; z-index:98) — the document went
11788
+ // grey and unclickable, and the scrim's own click handler then called
11789
+ // setSidebarOpen(false), so the first click anywhere undid it and the
11790
+ // whole gesture read as a flicker.
11791
+ //
11792
+ // Desktop therefore gets a PRIVATE attribute nothing else reads,
11793
+ // `data-ed-outline-hidden`, matched by an edit-mode-only rule in
11794
+ // lib/md2doc.js. Named for the non-default state because the desktop
11795
+ // outline is a sticky column shown by default, which is also what lets
11796
+ // aria-expanded below stay truthful on both viewports.
11797
+ function toggleOutlineSidebar() {
11798
+ // The same 1080px breakpoint the stylesheet's own drawer block uses.
11799
+ const mobile = typeof window.matchMedia === 'function' &&
11800
+ window.matchMedia('(max-width: 1080px)').matches;
11801
+ let expanded;
11802
+ if (mobile) {
11803
+ expanded = !document.body.hasAttribute('data-sidebar-open');
11804
+ if (expanded) document.body.setAttribute('data-sidebar-open', '');
11805
+ else document.body.removeAttribute('data-sidebar-open');
11806
+ } else {
11807
+ expanded = document.body.hasAttribute('data-ed-outline-hidden');
11808
+ if (expanded) document.body.removeAttribute('data-ed-outline-hidden');
11809
+ else document.body.setAttribute('data-ed-outline-hidden', '');
11810
+ }
11811
+ const toggle = document.getElementById('sidebar-toggle');
11812
+ if (toggle) toggle.setAttribute('aria-expanded', expanded ? 'true' : 'false');
11813
+ }
11814
+
11815
+ // ── 追加 3: images ─────────────────────────────────────────────────────
11816
+ function lastBlockEl() {
11817
+ const all = contentEl.querySelectorAll('.ed-block');
11818
+ return all.length ? all[all.length - 1] : null;
11819
+ }
11820
+
11821
+ function blobToBase64(blob) {
11822
+ return new Promise((resolve, reject) => {
11823
+ const fr = new FileReader();
11824
+ fr.onerror = () => reject(new Error('could not read the image'));
11825
+ fr.onload = () => {
11826
+ // readAsDataURL gives 'data:<mime>;base64,<payload>' — the endpoint
11827
+ // wants the payload alone, and derives the extension from the mime
11828
+ // it is told, never from this prefix.
11829
+ const s = String(fr.result || '');
11830
+ const comma = s.indexOf(',');
11831
+ resolve(comma === -1 ? '' : s.slice(comma + 1));
11832
+ };
11833
+ fr.readAsDataURL(blob);
11834
+ });
11835
+ }
11836
+
11837
+ // Uploads one blob and answers its 'assets/<name>' path, or null (a banner
11838
+ // is already showing by then). The MIME whitelist is asked BEFORE the round
11839
+ // trip so an unsupported drop is refused instantly and locally — the server
11840
+ // asks the same question again, and it is the server's answer that is
11841
+ // authoritative.
11842
+ async function uploadImageBlob(blob) {
11843
+ if (!blob) return null;
11844
+ if (!assetLib || !assetLib.extFor(blob.type)) {
11845
+ showBanner(TOOLBAR_UNSUPPORTED_IMAGE_MESSAGE, null, null);
11846
+ return null;
11847
+ }
11848
+ let data;
11849
+ try {
11850
+ data = await blobToBase64(blob);
11851
+ } catch (e) {
11852
+ showBanner('圖片讀取失敗 — ' + describeFailure(e), null, null);
11853
+ return null;
11854
+ }
11855
+ let res;
11856
+ try {
11857
+ res = await fetch('/api/asset', {
11858
+ method: 'POST', headers: { 'content-type': 'application/json' },
11859
+ body: JSON.stringify({
11860
+ fileId: ED.fileId, mime: blob.type, name: blob.name || 'image', data: data,
11861
+ }),
11862
+ });
11863
+ } catch (e) {
11864
+ showBanner('圖片上傳失敗 — ' + describeFailure(e) + '。檔案未寫入。', null, null);
11865
+ return null;
11866
+ }
11867
+ if (!res.ok) {
11868
+ const reason = await describeHttpFailure(res);
11869
+ showBanner('圖片上傳失敗 — ' + reason + '。檔案未寫入。', null, null);
11870
+ return null;
11871
+ }
11872
+ let j;
11873
+ try { j = await res.json(); } catch (e) { j = null; }
11874
+ if (!j || typeof j.path !== 'string') {
11875
+ showBanner('圖片上傳失敗 — 伺服器回應格式不正確。', null, null);
11876
+ return null;
11877
+ }
11878
+ return j.path;
11879
+ }
11880
+
11881
+ // asset.js's sanitizeName() deliberately keeps spaces (and everything else
11882
+ // that is not a path separator or a drive colon) — a screenshot really is
11883
+ // called 'Screen Shot 2026-09-03 at 14.02.11.png'. A bare space, or a
11884
+ // parenthesis, inside an inline image destination ends the destination
11885
+ // early, so `![](assets/My Shot.png)` renders as literal text. Percent-
11886
+ // encoding exactly those characters fixes it in both directions: browsers
11887
+ // resolve the escape, and md2doc's own resolveAssetPath() decodes the
11888
+ // filePart before probing the disk (it also probes the raw form, so
11889
+ // non-ASCII characters are deliberately left alone rather than escaped).
11890
+ // '%' itself is encoded FIRST by being in the same character class, so a
11891
+ // filename that already contains one cannot be double-decoded.
11892
+ const TOOLBAR_MD_DEST_UNSAFE_RE = /[%\s()<>"']/g;
11893
+ function markdownImageDest(p) {
11894
+ return String(p == null ? '' : p).replace(TOOLBAR_MD_DEST_UNSAFE_RE, (c) => {
11895
+ const hex = c.charCodeAt(0).toString(16).toUpperCase();
11896
+ return '%' + (hex.length < 2 ? '0' + hex : hex);
11897
+ });
11898
+ }
11899
+
11900
+ // Uploads every image in `blobs` and lands them as ONE block insertion.
11901
+ // One insertion, not one per image: each insert re-renders and detaches the
11902
+ // anchor, so a loop would be inserting against a stale node from the second
11903
+ // image onward — and it would also cost one undo op per image for what the
11904
+ // user experienced as a single drop. Blank lines between the paths keep
11905
+ // them separate paragraphs instead of one paragraph of soft breaks.
11906
+ async function insertImages(blobs, anchorEl) {
11907
+ const paths = [];
11908
+ for (let i = 0; i < blobs.length; i += 1) {
11909
+ const p = await uploadImageBlob(blobs[i]);
11910
+ if (p) paths.push(p);
11911
+ }
11912
+ if (!paths.length) return;
11913
+ const target = (anchorEl && document.body.contains(anchorEl)) ? anchorEl : lastBlockEl();
11914
+ if (!target) { showBanner(TOOLBAR_NO_ANCHOR_MESSAGE, null, null); return; }
11915
+ const newLines = [];
11916
+ paths.forEach((p, i) => {
11917
+ if (i > 0) newLines.push('');
11918
+ newLines.push('![](' + markdownImageDest(p) + ')');
11919
+ });
11920
+ await insertBlockBelow(target, 'paragraph', { lines: newLines });
11921
+ }
11922
+
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);
11985
+ const input = document.createElement('input');
11986
+ input.type = 'file';
11987
+ input.accept = 'image/png,image/jpeg,image/gif,image/webp';
11988
+ input.multiple = true;
11989
+ input.hidden = true;
11990
+ // (a) above.
11991
+ input.addEventListener('click', (e) => e.stopPropagation());
11992
+ document.body.appendChild(input);
11993
+ input.addEventListener('change', () => {
11994
+ const files = input.files ? Array.prototype.slice.call(input.files) : [];
11995
+ input.remove();
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);
12007
+ });
12008
+ input.click();
12009
+ }
12010
+
12011
+ function dragCarriesFiles(e) {
12012
+ const dt = e.dataTransfer;
12013
+ if (!dt || !dt.types) return false;
12014
+ return Array.prototype.indexOf.call(dt.types, 'Files') !== -1;
12015
+ }
12016
+
12017
+ function wireImageDrop() {
12018
+ // ⚠ BOTH listeners are gated on `docMode === 'edit'`, and that gate is
12019
+ // load-bearing rather than tidy-minded.
12020
+ //
12021
+ // In SOURCE mode `enterSourceMode()` only sets `contentEl.hidden = true` —
12022
+ // every `.ed-block` is still in the DOM, so `lastBlockEl()` resolves and an
12023
+ // ungated drop ran the whole upload plus an `insertBlockBelow()` commit
12024
+ // behind the hidden content, against `lines`. The textarea the user is
12025
+ // looking at still held the PRE-drop source, and `leaveSourceMode()` then
12026
+ // commits `commitRangeEdit(1, lines.length, ta.value)` over the post-drop
12027
+ // `lines` — erasing the reference that was just inserted. MEASURED:
12028
+ // '# Doc\n\nBody para.\n' -> source mode -> drop x.png -> type EDITED ->
12029
+ // leave, and the file ends '# Doc\n\nBody para.\n\nEDITED\n' with
12030
+ // assets/x.png orphaned on disk. No error, no visible effect, and a stray
12031
+ // binary left in what is usually a git working tree.
12032
+ //
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.
12037
+ document.addEventListener('dragover', (e) => {
12038
+ if (docMode !== 'edit') return;
12039
+ if (!dragCarriesFiles(e)) return;
12040
+ e.preventDefault();
12041
+ if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
12042
+ });
12043
+ document.addEventListener('drop', (e) => {
12044
+ if (docMode !== 'edit') return;
12045
+ if (!dragCarriesFiles(e)) return;
12046
+ const files = e.dataTransfer
12047
+ ? Array.prototype.slice.call(e.dataTransfer.files || [])
12048
+ : [];
12049
+ const images = files.filter((f) => !!(assetLib && assetLib.extFor(f.type)));
12050
+ if (!images.length) return; // not ours: let the page do whatever it would
12051
+ e.preventDefault();
12052
+ const anchorEl = (e.target && e.target.closest) ? e.target.closest('.ed-block') : null;
12053
+ insertImages(images, anchorEl || liveToolbarBlock() || lastBlockEl());
12054
+ });
12055
+ }
12056
+
12057
+ // ── 追加 2: paste ──────────────────────────────────────────────────────
12058
+ // Reads a real DataTransfer into the plain { 'mime/type': string | Blob }
12059
+ // shape paste-md.js's pickPayload() documents. Only ONE image flavour is
12060
+ // taken: pickPayload picks the first it finds anyway, and a clipboard can
12061
+ // carry several encodings of the same bitmap.
12062
+ function clipboardItemsOf(dt) {
12063
+ const items = {};
12064
+ if (!dt) return items;
12065
+ try { items['text/plain'] = dt.getData('text/plain') || ''; } catch (err) { items['text/plain'] = ''; }
12066
+ try { items['text/html'] = dt.getData('text/html') || ''; } catch (err) { items['text/html'] = ''; }
12067
+ const files = dt.files ? Array.prototype.slice.call(dt.files) : [];
12068
+ for (let i = 0; i < files.length; i += 1) {
12069
+ if (files[i] && String(files[i].type).indexOf('image/') === 0) {
12070
+ items[files[i].type] = files[i];
12071
+ break;
12072
+ }
12073
+ }
12074
+ return items;
12075
+ }
12076
+
12077
+ // A converted payload that is ONE line carrying no markdown syntax at all
12078
+ // is indistinguishable from the plain-text paste this editor has always
12079
+ // done — and that case (copy a word or a phrase out of a browser, paste it
12080
+ // mid sentence) must keep landing at the caret rather than becoming a new
12081
+ // block. Anything else is real structure and takes the source path.
12082
+ //
12083
+ // The test is split in two because markdown's characters are NOT all alike.
12084
+ // The first cut used one character class for both kinds and put '-', '#'
12085
+ // and '!' in it — which re-introduced exactly the regression this test
12086
+ // exists to prevent, since 'e-mail', 'state-of-the-art' and 'Hello!' are
12087
+ // ordinary prose that would have become new paragraph blocks. Those three
12088
+ // only begin a construct at LINE POSITION, so they belong in the
12089
+ // start-anchored test, not the anywhere test. ('![' is still caught: the
12090
+ // '[' is in the anywhere class.)
12091
+ const TOOLBAR_INLINE_UNSAFE_RE = /[*_`~\[\]<>|\\]/;
12092
+ const TOOLBAR_BLOCK_START_RE =
12093
+ /^(?: {4}|\t|#{1,6}(?:\s|$)|[-*+][ \t]|\d+[.)][ \t]|>|`{3}|~{3}|-{3}|={3})/;
12094
+ function isInlineSafePaste(md) {
12095
+ if (md === '' || md.indexOf('\n') !== -1) return false;
12096
+ if (TOOLBAR_INLINE_UNSAFE_RE.test(md)) return false;
12097
+ return !TOOLBAR_BLOCK_START_RE.test(md);
12098
+ }
12099
+
12100
+ function handleArmedPaste(dt, editEl) {
12101
+ const items = clipboardItemsOf(dt);
12102
+ const carriesSomething = Object.keys(items).some((k) => (
12103
+ typeof items[k] === 'string' ? items[k] !== '' : !!items[k]));
12104
+ // An EMPTY paste is not the paste the gesture asked for, and must not
12105
+ // consume the Ctrl+Shift+V arming. MEASURED in headless Chromium: the
12106
+ // Ctrl+Shift+V keystroke ITSELF dispatches a paste event carrying no
12107
+ // flavours at all (`types: []`, `getData('text/plain') === ''`) before
12108
+ // any real clipboard content arrives. Consuming `plainPasteArmed` there
12109
+ // disarmed the very paste the user had just asked to be plain — the
12110
+ // gesture silently degraded to a normal rich paste. Returning early also
12111
+ // saves an insertTextAtCaret('') that would snapshot the burst for a
12112
+ // no-op edit.
12113
+ if (!carriesSomething) return;
12114
+ const plainOnly = plainPasteArmed;
12115
+ plainPasteArmed = false;
12116
+ const payload = pasteMd
12117
+ ? pasteMd.pickPayload(items, plainOnly)
12118
+ : { kind: 'text', value: dt ? dt.getData('text/plain') : '' };
12119
+ if (payload.kind === 'image') {
12120
+ insertImages([payload.blob], editEl.closest('.ed-block'));
12121
+ return;
12122
+ }
12123
+ if (payload.kind === 'markdown') {
12124
+ const md = String(payload.value == null ? '' : payload.value).replace(/\r\n?/g, '\n');
12125
+ if (md.trim() === '') return;
12126
+ if (isInlineSafePaste(md)) {
12127
+ insertTextAtCaret(md);
12128
+ snapBurstIfActive(editEl, 'paste');
12129
+ return;
12130
+ }
12131
+ // Landed as SOURCE below the caret's block, never spliced into the
12132
+ // WYSIWYG DOM: that is what guarantees it passes through
12133
+ // commitBlockInsertion()/replaceLines() and therefore through
12134
+ // serializeBlocks()'s existing gates, instead of arriving as a pile of
12135
+ // foreign nodes the serializer would have to make sense of afterwards.
12136
+ insertBlockBelow(editEl.closest('.ed-block'), 'paragraph', { lines: md.split('\n') });
12137
+ return;
12138
+ }
12139
+ insertTextAtCaret(payload.value || '');
12140
+ snapBurstIfActive(editEl, 'paste');
12141
+ }
12142
+
12143
+ function wirePlainPasteArming() {
12144
+ // Capture phase: this only records intent, and must do so before any
12145
+ // other keydown handler can preventDefault() its way past it.
12146
+ document.addEventListener('keydown', (e) => {
12147
+ plainPasteArmed = !!((e.ctrlKey || e.metaKey) && e.shiftKey &&
12148
+ (e.key === 'v' || e.key === 'V'));
12149
+ }, true);
12150
+ }
12151
+
12152
+ // ── 追加 4: the whole-document source escape hatch ─────────────────────
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.
12156
+
12157
+ function enterSourceMode() {
12158
+ // The pre-edit snapshot fromSource() diffs against. Omitting it makes
12159
+ // `changed` unconditionally true (docsource.js's documented fail-safe),
12160
+ // i.e. a needless write of byte-identical content on every exit.
12161
+ sourceBaseLines = lines.slice();
12162
+ const ta = document.createElement('textarea');
12163
+ ta.className = 'ed-source';
12164
+ ta.setAttribute('aria-label', '文件原始碼');
12165
+ ta.value = docSourceLib.toSource(lines, EOL);
12166
+ contentEl.hidden = true;
12167
+ contentEl.parentNode.insertBefore(ta, contentEl);
12168
+ sourceTextarea = ta;
12169
+ ta.focus();
12170
+ }
12171
+
12172
+ // Returns false when the commit's render failed — the textarea is then left
12173
+ // standing with the user's text intact (a failed round trip must never be
12174
+ // the thing that discards a whole document's edit), and the mode does not
12175
+ // advance.
12176
+ async function leaveSourceMode() {
12177
+ const ta = sourceTextarea;
12178
+ if (!ta) return true;
12179
+ const parsed = docSourceLib.fromSource(ta.value, EOL, sourceBaseLines);
12180
+ if (parsed.changed) {
12181
+ // 全量置換 through the SAME replaceLines() pipeline every other edit
12182
+ // uses — one commitRangeEdit over the whole document, therefore one
12183
+ // undo op, and no second writer of `lines`.
12184
+ const result = commitRangeEdit({ lines, blocks, stack }, 1, lines.length,
12185
+ parsed.lines.join('\n'));
12186
+ if (result.op !== null) {
12187
+ const prevLines = lines;
12188
+ lines = result.lines;
12189
+ setDirty();
12190
+ const editRange = result.op
12191
+ ? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
12192
+ endLine: Math.max(result.op.startLine - 1, result.op.endLine),
12193
+ delta: result.op.after.length - result.op.before.length }
12194
+ : undefined;
12195
+ const ok = await safeRerenderAll(editRange);
12196
+ if (!ok) {
12197
+ lines = rollbackFailedRender({ lines, stack }, result, prevLines);
12198
+ setDirty();
12199
+ return false;
12200
+ }
12201
+ }
12202
+ }
12203
+ sourceTextarea = null;
12204
+ sourceBaseLines = null;
12205
+ ta.remove();
12206
+ contentEl.hidden = false;
12207
+ return true;
12208
+ }
12209
+
12210
+ async function cycleDocMode() {
12211
+ if (!docSourceLib) return;
12212
+ await setDocMode(docSourceLib.next(docMode));
12213
+ }
12214
+
12215
+ async function setDocMode(next) {
12216
+ if (next === docMode) return;
12217
+ if (docMode === 'edit') {
12218
+ // Same precondition every structural operation has: resolve whatever
12219
+ // session is open before the DOM it lives in is swapped or hidden.
12220
+ const ok = await switchAwayFrom();
12221
+ if (!ok) return;
12222
+ }
12223
+ if (docMode === 'source') {
12224
+ const ok = await leaveSourceMode();
12225
+ if (!ok) return;
12226
+ }
12227
+ docMode = next;
12228
+ if (docMode === 'source') enterSourceMode();
12229
+ document.body.setAttribute('data-ed-mode', docMode);
12230
+ paintModeStatus();
12231
+ updateToolbar();
12232
+ }
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
+
12242
+ // ── mount ──────────────────────────────────────────────────────────────
12243
+ function wireToolbarTracking() {
12244
+ // Which block the bar reports on. Delegated at document level (never a
12245
+ // per-block listener) for the same reason every other affordance in this
12246
+ // file is: blocks are rebuilt wholesale on every render.
12247
+ document.addEventListener('focusin', (e) => {
12248
+ if (!e.target || !e.target.closest) return;
12249
+ if (e.target.closest('.ed-toolbar') || e.target.closest('.ed-toolbar-menu')) return;
12250
+ const blockEl = e.target.closest('.ed-block');
12251
+ if (!blockEl) return;
12252
+ toolbarBlockEl = blockEl;
12253
+ updateToolbar();
12254
+ });
12255
+ document.addEventListener('click', (e) => {
12256
+ if (!e.target || !e.target.closest) return;
12257
+ if (e.target.closest('.ed-toolbar') || e.target.closest('.ed-toolbar-menu')) return;
12258
+ closeToolbarMenu();
12259
+ const blockEl = e.target.closest('.ed-block');
12260
+ if (blockEl) toolbarBlockEl = blockEl;
12261
+ updateToolbar();
12262
+ });
12263
+ // NOTE: there is deliberately NO selectionchange listener here — the
12264
+ // burst's own onSelectionChangeForToolbar() calls updateToolbar() for us.
12265
+ // See that function for why a second one must not be attached.
12266
+ }
12267
+
12268
+ function mountToolbar() {
12269
+ // An older cached page can be running this runtime without the v3.1.0
12270
+ // module tags. Degrade to "no toolbar" rather than throwing before
12271
+ // armEditables() has run.
12272
+ if (!toolbarModel) return;
12273
+ toolbarEl = buildToolbar();
12274
+ // ⚠ document.body, NOT contentEl. rerenderAll()'s fallback path does
12275
+ // `contentEl.innerHTML = j.parts.join('\n')` (v3.2.0: `/api/render`
12276
+ // answers `{ parts, blocks }` and there is no `bodyHtml` on the wire any
12277
+ // more — see applyFullRender()), replacing .content's children wholesale,
12278
+ // and only armEditables() rebuilds anything afterwards — it knows nothing
12279
+ // about a toolbar. A bar mounted inside .content would
12280
+ // vanish on the first commit with nothing left to bring it back. Every
12281
+ // persistent chrome node in this file is on document.body for exactly
12282
+ // this reason: the conflict banner, the selection toolbar, the table
12283
+ // grips and the table edge menu.
12284
+ document.body.appendChild(toolbarEl);
12285
+ document.body.setAttribute('data-ed-mode', docMode);
12286
+ paintModeStatus();
12287
+ wireToolbarTracking();
12288
+ wireImageDrop();
12289
+ wirePlainPasteArming();
12290
+ updateToolbar();
12291
+ }
12292
+
9440
12293
  // ── global key handling ─────────────────────────────────────────────────
9441
12294
  document.addEventListener('keydown', (e) => {
9442
12295
  // ── S3 §4.4: Escape priority is drag > menu > selection > burst ─────
@@ -9817,8 +12670,13 @@
9817
12670
  if (!editEl) return;
9818
12671
  e.preventDefault();
9819
12672
  const dt = e.clipboardData || window.clipboardData;
9820
- insertTextAtCaret(dt ? dt.getData('text/plain') : '');
9821
- snapBurstIfActive(editEl, 'paste');
12673
+ // v3.1.0 追加 2: paste-md.js decides what the clipboard actually holds
12674
+ // (text/html -> markdown, an image, or plain text) and this routes the
12675
+ // answer. The TABLE-CELL branch above deliberately keeps its plain-text
12676
+ // behaviour: a cell cannot host a block, and a converted markdown table
12677
+ // pasted into one cell would be a nested structure the serializer has no
12678
+ // way to represent.
12679
+ handleArmedPaste(dt, editEl);
9822
12680
  });
9823
12681
 
9824
12682
  // Task 5: hover-edge insert bubbles — one delegated, rAF-throttled
@@ -9888,6 +12746,58 @@
9888
12746
  });
9889
12747
  });
9890
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
+
9891
12801
  window.addEventListener('beforeunload', (e) => {
9892
12802
  if (stack.dirtyDepth !== 0) {
9893
12803
  e.preventDefault();
@@ -9908,4 +12818,8 @@
9908
12818
 
9909
12819
  armEditables(contentEl);
9910
12820
  wireBlockSelection();
12821
+ // v3.1.0 §4: last, so every const it closes over (stack, BLOCK_SKELETONS,
12822
+ // selToolbar, …) is initialised and the bar's first deriveState() sees the
12823
+ // armed document rather than an empty one.
12824
+ mountToolbar();
9911
12825
  })();