@helping-ai-workflow/md2doc 3.0.2 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,8 +868,567 @@
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
+ // Fix round 1 (review Important 1): `blocks`/`lastParts` are the JOIN KEY
1027
+ // between the module's model and the DOM, and they are overwritten BEFORE
1028
+ // the DOM is touched — armEditables() reads the new `blocks` (see its call
1029
+ // below), so the order is forced. That opens a window in which a throw
1030
+ // would leave the model describing a DOM that was never built: the
1031
+ // exception bubbles to safeRerenderAll()'s catch, the user is told "Your
1032
+ // edit was not applied", `false` comes back — and the NEXT commit then
1033
+ // diffs parts describing blocks that do not exist, so every index is
1034
+ // wrong and nothing reports it. Same silent-failure shape as ruling F4.
1035
+ //
1036
+ // applyFullRender() has no such window: its `blocks`/`lastParts` →
1037
+ // innerHTML → armEditables() order means the same throw leaves a
1038
+ // consistent "DOM swapped, just not armed" state that the next render
1039
+ // heals. So the patch path buys the equivalent guarantee explicitly:
1040
+ // capture, and put the whole mutation window in a try that rolls the two
1041
+ // back and lets the FALLBACK land the commit instead of losing it.
1042
+ const prevBlocks = blocks;
1043
+ const prevLastParts = lastParts;
860
1044
  blocks = j.blocks;
861
- contentEl.innerHTML = j.bodyHtml;
1045
+ lastParts = j.parts;
1046
+
1047
+ try {
1048
+ // <template> 是刻意指名的,不是「隨便一種容器」:
1049
+ // createDocumentFragment() + innerHTML -> innerHTML 不存在,什麼都插不進去、不報錯
1050
+ // Range.createContextualFragment() -> 文件裡作者手寫的 raw <script> 每次 patch 重新執行
1051
+ // 暫存 <div> 也正確,只多一圈 child drain。
1052
+ const tpl = document.createElement('template');
1053
+ tpl.innerHTML = plan.replaceSpan.newParts.join('\n');
1054
+ // 必須在 blocks 已換成新陣列之後——armEditables 的 li 分支走
1055
+ // canWysiwygForLi → blockOwnsNoLine,那支讀的是模組層的 `blocks`;
1056
+ // 用舊陣列判斷會把一個「不擁有任何來源行」的 li 誤武裝成 contenteditable。
1057
+ //
1058
+ // ⚠ 只對新片段呼叫,永遠不要對被保留的前綴/後綴節點再跑一次:
1059
+ // armEditables 不是 idempotent(⠿ handle 與 + button 是無條件
1060
+ // appendChild),double-arm 實測會變成 10 個 handle 而不是 5 個。
1061
+ armEditables(tpl.content);
1062
+
1063
+ // 保留節點永不 detach:只移除中段,其餘原地不動。
1064
+ // 不得用 replaceChildren() 或 remove-and-re-append 重建整份子節點列表——
1065
+ // 實測 detach 再插回同一位置會讓 document.activeElement 掉回 BODY。
1066
+ //
1067
+ // Fix round 1 (review, comment-only): this removes ELEMENTS, so the `\n`
1068
+ // TEXT nodes `parts.join('\n')` left between the removed blocks stay
1069
+ // behind. Harmless today — `contentEl.children` and allBlockEls()'s
1070
+ // querySelectorAll both skip text nodes, and nothing in this file reads
1071
+ // `contentEl.childNodes` — but it does mean `.content`'s child-NODE shape
1072
+ // after a patch differs from its shape after a full render, which would
1073
+ // matter to any future test that asserts on childNodes rather than
1074
+ // children.
1075
+ for (let i = plan.replaceSpan.oldStart; i <= plan.replaceSpan.oldEnd; i++) {
1076
+ if (children[i]) contentEl.removeChild(children[i]);
1077
+ }
1078
+ // 參考節點是「第一個被保留的後綴節點」。它在 children 快照裡的 index 是
1079
+ // oldEnd + 1,而且因為後綴從未被 detach,那個 object 現在仍在 DOM 裡。
1080
+ // 後綴為空時是 null,insertBefore(frag, null) 等同 append。
1081
+ const refNode = children[plan.replaceSpan.oldEnd + 1] || null;
1082
+ contentEl.insertBefore(tpl.content, refNode); // fragment 會被排空並保序
1083
+
1084
+ // 被保留的後綴節點的 data-block-id 已經過期(id 是位置序號)。
1085
+ // 前綴恆為 0:id 即索引,前綴在兩份陣列裡都佔 0..k-1。
1086
+ if (plan.idDelta !== 0) {
1087
+ const all = contentEl.children;
1088
+ for (let i = all.length - plan.keepSuffix; i < all.length; i++) {
1089
+ const el = all[i];
1090
+ const oldId = Number(el.getAttribute('data-block-id'));
1091
+ el.setAttribute('data-block-id', String(oldId + plan.idDelta));
1092
+ }
1093
+ }
1094
+ } catch (e) {
1095
+ // Roll the model back to what the (still-unmutated, or only partly
1096
+ // mutated) DOM last agreed with, then land the commit through the
1097
+ // fallback rather than losing it — applyFullRender() rebuilds .content
1098
+ // wholesale, so it is correct from a half-patched DOM too.
1099
+ //
1100
+ // ⚠ `window.scrollY` here does NOT violate ruling F3. F3 forbids
1101
+ // re-applying rerenderAll()'s STALE pre-fetch snapshot on the patch
1102
+ // path, because the patch path never collapses the scroll extent. This
1103
+ // is the CURRENT scroll position, read at this instant, handed to an
1104
+ // innerHTML swap that genuinely does collapse it. Different value,
1105
+ // different reason. Do not "correct" it to the `scrollY` parameter —
1106
+ // applyPatch() deliberately does not take one.
1107
+ blocks = prevBlocks;
1108
+ lastParts = prevLastParts;
1109
+ return applyFullRender(j, window.scrollY, declaredRange);
1110
+ }
1111
+
1112
+ // ── teardown ────────────────────────────────────────────────────────
1113
+ // Controller ruling F1: the patch path runs the SAME twelve teardown items
1114
+ // applyFullRender() runs, unconditionally and in the same relative order.
1115
+ // The rationale comments below are copied from applyFullRender() because
1116
+ // they document WHY each one exists; the one thing that differs is the
1117
+ // trigger — there the nodes were detached by the innerHTML replacement,
1118
+ // here they were detached by the removeChild loop above (or, for state
1119
+ // that points at a node in the KEPT span, they were not detached at all,
1120
+ // which is exactly why leaving the state standing is not automatically
1121
+ // safe either: `blocks` and every data-block-id under it just changed).
1122
+ //
1123
+ // Fix round 1 (review, comment-only) — the consequence a future change to
1124
+ // this list has to know about: the fallback's innerHTML replacement also
1125
+ // WIPES any uncommitted burst text, which is what keeps the DOM and
1126
+ // `lines[]` consistent when item 3 disposes the burst. The patch path
1127
+ // leaves that text sitting in a kept node while `currentBurst` has already
1128
+ // been disposed. Unreachable today — every call site goes through
1129
+ // switchAwayFrom() first, which is what applyFullRender()'s own comment
1130
+ // calls "technically unreachable" — but that is precisely what this
1131
+ // belt-and-braces teardown is insurance against, and on THIS path a future
1132
+ // call site that forgets switchAwayFrom() leaves text visible in the DOM,
1133
+ // absent from `lines[]`, with its undo history already disposed.
1134
+ //
1135
+ // Whatever editor (if any) was open a moment ago may have just been
1136
+ // detached by the span removal above — its own restore()/commit() never
1137
+ // ran, so it never got a chance to null this out itself. On the FALLBACK
1138
+ // path this is done unconditionally, which is what makes the undo/redo
1139
+ // lockout regression (see the `activeEditor` comment above) structurally
1140
+ // impossible even from a call site that forgets to call switchAwayFrom().
1141
+ //
1142
+ // ── Final review item 1 / 決議 3(1) 的 activeEditor 對稱項 ───────────
1143
+ // 決議 3(1) 的論證逐字適用在這裡,而它原本沒有被問過:fallback 之所以
1144
+ // 可以無條件歸零,是因為 `contentEl.innerHTML = ` 把每個節點都拔掉了
1145
+ // ——面也一起從螢幕上消失。patch 保留節點,而 openRawEditor() 的
1146
+ // `.ed-raw` 包裝是塞進**區塊自己**(`blockEl.innerHTML = '';
1147
+ // blockEl.appendChild(wrap)`),所以 contentEl 的子節點數與每一個
1148
+ // data-block-id 都沒有變、domMatchesLastRender() 照過、patch 照跑——
1149
+ // 落在前綴/後綴裡的 textarea 於是活過 patch,而這一行把它變成沒人
1150
+ // 追蹤的殭屍:switchAwayFrom() 再也看不到它,點開別處不會自動提交,
1151
+ // 而下一次任何 rerenderAll() 會把它連同未提交的文字一起蓋掉。
1152
+ //
1153
+ // 實測可達(scratchpad/item1-scenarioB.js,兩次 run 一致)。序列是
1154
+ // 「render 往返途中開一個 raw editor」,與決議 3(2) 為 currentBurst 記下
1155
+ // 的那條 race 同形,只是觸發點必須是**在 fetch 之前就 settle 掉
1156
+ // switchAwayFrom() 的**站點(undo()/redo()/changeHeadingDepth()…),
1157
+ // 因為 resolveBurst() 自己那條路徑把整個 render 關在 `switching` 的
1158
+ // single-flight 裡,反而把並行的點擊序列化掉:
1159
+ // openRawEditor-entry {blockId:'2', currentBurst:false, activeEditor:false}
1160
+ // dispatch {patch:true, keepPrefix:4, oldStart:4, oldEnd:4,
1161
+ // activeEditor:true, rawTextareas:1}
1162
+ // patch-teardown {activeEditor:true, activeEditorBlockAttached:true,
1163
+ // activeEditorBlockId:'2', rawTextareas:1}
1164
+ // 接著在存活的 textarea 裡打字、點到別的區塊、Ctrl+S:
1165
+ // [after clicking away] {rawTextareas:1, rawValue:'…const LOST = 2;'}
1166
+ // [file on disk] '# Doc\n\nCode anchor.\n\n```js\nconst x = 1;\n```\n…'
1167
+ // [LOST typing present in file?] NO — data loss
1168
+ // 螢幕上看得到,檔案裡沒有——與決議 3(1) 為 burst 記下的完全同一種缺陷。
1169
+ //
1170
+ // 判準與 burst 那項同形,而且必須同形:實測
1171
+ // `document.body.contains(activeEditor.blockEl)`,不得由 patchmap 的
1172
+ // index 算術推論。推論錯的代價是 activeEditor 指著已 detach 的節點,
1173
+ // 正好是上面那段「undo/redo lockout」在防的事——所以節點沒活下來時
1174
+ // 一律走原本的無條件歸零,lockout 仍然結構上不可能。
1175
+ //
1176
+ // 多一道 burst 不需要的閘門:burst 的 `blockId` 是唯一會 stale 的欄位,
1177
+ // 其餘都從新的 `blocks` 現查;openRawEditor() 的 session 則把
1178
+ // `blockId`/`startLine`/`endLine` 三個都閉包成常數,所以留下來之前要先
1179
+ // 讓它把三者從現在的 DOM 屬性與新的 `blocks` 重新推導成功
1180
+ // (reanchor(),見那裡的註解——推導不出來就退回歸零)。
1181
+ if (!(activeEditor && activeEditor.blockEl
1182
+ && document.body.contains(activeEditor.blockEl)
1183
+ && activeEditor.reanchor())) {
1184
+ activeEditor = null;
1185
+ }
1186
+ // §10-gap fix (review): same belt-and-braces reasoning for
1187
+ // `pristineInsert` — its window is meant to close via one of the
1188
+ // explicit resolution hooks (resolveBurst()/revertBurstAndEnd()/
1189
+ // revertTableBurstAndEnd()/cancelAndMaybeDiscard()) BEFORE any
1190
+ // rerenderAll() reaches here.
1191
+ //
1192
+ // Final review item 1, second half — 這一項**刻意沒有**跟著上面開例外,
1193
+ // 而且理由不是「到不了」:同一條 race 一樣到得了這裡。是這個狀態被
1194
+ // 這次 render 本身作廢了。`pristineInsert` 的整個意思是「undo stack 的
1195
+ // **最頂端**那一筆就是我這個插入 op」——discardPristineInsert() 走
1196
+ // `stack.discardTop()`(lib/editor/lineops.js),它無條件 pop 最頂端那筆
1197
+ // 並就地反轉,不驗證那筆是不是自己的。而任何走到 applyPatch 的 render
1198
+ // 都帶著一個 truthy 的 editRange,也就是**剛剛有一筆 op 被推上 stack**
1199
+ // (commitRangeEdit / commitBlockInsertion / undo()/redo() 的搬動)。所以
1200
+ // patch 之後把 `pristineInsert` 留著,等於讓之後任何一次「放棄這個空
1201
+ // 區塊」去 pop 並反轉**別人的**那一筆——那是比它要防的孤兒佔位區塊更
1202
+ // 嚴重的失敗。要讓這一項變成可以開例外,得先讓 discardTop() 認得 op
1203
+ // 的 identity(例如 discardPristineInsert() 記下 op 物件本身、pop 前先
1204
+ // 比對),在那之前無條件歸零就是正解。
1205
+ pristineInsert = null;
1206
+ // ── Task G / 決議 3:teardown 的三項例外 ─────────────────────────
1207
+ // 判準必須是「burst 的編輯面在 patch 之後仍然連在文件上」的**節點
1208
+ // identity 實測**,不得由 index 算術推論。推論錯的代價是 burst 指著已
1209
+ // detach 的節點——正是上面 activeEditor / toolbarBlockEl 那兩段註解在防的
1210
+ // 事,而且跳過 dispose() 時若節點其實已被換掉,會同時留下一疊
1211
+ // innerHTML 快照與一個閉包住 detached 節點的 captureFn。
1212
+ //
1213
+ // 為什麼只在 patch 路徑需要這個例外:fallback 的 `contentEl.innerHTML = `
1214
+ // 把每一個節點都拔掉,所以 12 項 teardown 把任何編輯面孤兒化都是安全的
1215
+ // (螢幕上也一併消失)。patch 保留前綴與後綴節點,所以同一套 teardown
1216
+ // 會把一個螢幕上還在、焦點還在的面變成沒人追蹤的殭屍。
1217
+ //
1218
+ // 三項例外各自留在原位 gate,不抄成一個 if 區塊:ruling F1 要求這 12 項
1219
+ // 的**相對順序**與 applyFullRender() 一致,把 resetSelToolbarState() /
1220
+ // resetToolbarBlock() 搬到第 3 項旁邊才是真的改了順序。條件只算一次,
1221
+ // 而且算在會動到 currentBurst 的那一項之前(算完才輪得到把它歸零)。
1222
+ const burstSurvived = !!(currentBurst && currentBurst.editEl
1223
+ && document.body.contains(currentBurst.editEl));
1224
+ // Task 2 (Phase 3): same belt-and-braces reasoning for the always-on
1225
+ // WYSIWYG burst — nulling here, not in resolveBurst(), is what keeps this
1226
+ // a single source of truth, mirroring activeEditor just above.
1227
+ // Task G: skipped when the burst's surface survived the patch — nulling it
1228
+ // there is what strands a live, focused, still-typeable surface with
1229
+ // nothing tracking it (the `input`/`focusout` handlers are both predicated
1230
+ // on `currentBurst.editEl === editEl`, and `focusin` only fires on
1231
+ // ENTERING, so focus that never left never reopens one).
1232
+ if (!burstSurvived && currentBurst) { currentBurst.history.dispose(); currentBurst = null; }
1233
+ // Task 5: the hover-edge insert bubbles and the row/column grip handles
1234
+ // are positioned against a specific table's geometry; a stale bubble left
1235
+ // visible (pointing at now-detached or re-rendered geometry) would
1236
+ // misbehave on the next click. hideTableGrips() clears their tracked
1237
+ // table/row/column references too.
1238
+ hideTableInsertBubbles();
1239
+ hideTableGrips();
1240
+ // Task 4 fix (review finding): the SAME reasoning applies to the
1241
+ // selection toolbar and its document-level selectionchange listener.
1242
+ // Every call site today resolves the session via switchAwayFrom() first
1243
+ // (making this technically unreachable), but that safety depends on every
1244
+ // FUTURE call site remembering to — exactly how the Task 3 listener-leak
1245
+ // regression happened. resetSelToolbarState() is idempotent.
1246
+ // Task G: travels with the `currentBurst` exception above, and must. The
1247
+ // `selectionchange` listener it removes is attached by startBurst() /
1248
+ // startTableBurst() and by NOTHING else, so on a surviving burst whose
1249
+ // focus never leaves there is no later focusin to re-attach it — measured,
1250
+ // the floating selection toolbar and the fixed bar's five inline-format
1251
+ // buttons (which piggyback on that same listener, and which this version's
1252
+ // `hasSelection` wiring also rides) stay dead until the user clicks away
1253
+ // and back.
1254
+ if (!burstSurvived) resetSelToolbarState();
1255
+ // Task 6: same belt-and-braces reasoning again for the edge-click menu
1256
+ // and any in-flight row drag.
1257
+ hideTableEdgeMenu();
1258
+ cancelTeDrag();
1259
+ // S4 Task 2: same belt-and-braces reasoning for an in-flight ⠿ block drag —
1260
+ // the gesture's ordinal refers to a block index that this patch may have
1261
+ // just renumbered. Nothing was mutated by the drag, so there is nothing to
1262
+ // revert; the state simply must not survive the render.
1263
+ cancelBlockDrag();
1264
+ // Final-review Finding 5b (Important): the ⠿ gutter menu — toggleGutterMenu()
1265
+ // appends the ONE shared `gutterMenu` node as a CHILD of whichever block
1266
+ // it's open for, so a removed block took it away without its own close
1267
+ // path ever running. Left uncleared, `gutterMenuBlockEl` keeps pointing at
1268
+ // a detached node and the NEXT toggleGutterMenu() call on that same stale
1269
+ // reference would treat the menu as already open instead of opening fresh.
1270
+ // closeGutterMenu() is idempotent.
1271
+ closeGutterMenu();
1272
+ // §10-gap fix: same belt-and-braces reasoning for the + insert menu —
1273
+ // it's the same "singleton node appended as a child of whichever block
1274
+ // it's open for" idiom as gutterMenu just above.
1275
+ closeInsertMenu();
1276
+ // v3.1.0 §4: and the SAME belt-and-braces reasoning one more time for the
1277
+ // toolbar's tracked block. `toolbarBlockEl` points INTO the subtree that
1278
+ // was just patched, and a detached node's getAttribute() still answers
1279
+ // perfectly happily: left standing, the bar would keep reporting the old
1280
+ // block's type/indent/heading depth and every button would act on a node
1281
+ // no click can ever reach again.
1282
+ // Task G: same exception, same reason — `toolbarBlockEl` is only ever
1283
+ // re-set on focusin / the click delegation, so clearing it under a burst
1284
+ // whose focus never leaves kills every block-dependent toolbar button until
1285
+ // the user clicks away and back (MEASURED on the smoke fixture: disabled
1286
+ // goes 7/22 -> 18/22, and nothing calls updateToolbar() again until the
1287
+ // next click). It has THREE further side effects, and all
1288
+ // three ride along safely: (1) the `docMode === 'preview'` re-arm cannot
1289
+ // matter here — preview sets every surface contenteditable="false", so no
1290
+ // burst can be open to survive; (2) updateToolbar() is re-derived from the
1291
+ // DOM on the next interaction; (3) closeToolbarMenu() — skipping it leaves
1292
+ // the H▾ dropdown standing across a patch, which is benign because
1293
+ // `toolbarMenuEl` is appended to document.body, not into a block, so the
1294
+ // patch never detaches it, and applyHeadingLevel() re-resolves through
1295
+ // liveToolbarBlock(), which self-heals a detached `toolbarBlockEl`.
1296
+ if (!burstSurvived) resetToolbarBlock();
1297
+
1298
+ // Task G step 2: re-derive the surviving burst's `blockId` FROM THE DOM
1299
+ // ATTRIBUTE — never assign it independently.
1300
+ //
1301
+ // startBurst()/startTableBurst() derive `blockId` from
1302
+ // `blockEl.getAttribute('data-block-id')`, so on a kept node it is the
1303
+ // FIELD that goes stale, not the attribute: the id rewrite inside the try
1304
+ // above already renumbered every kept suffix node. bystanderCarryOver()
1305
+ // compares `String(currentBurst.blockId)` against a node's getAttribute()
1306
+ // result AS STRINGS; if the field and the attribute disagree in either
1307
+ // direction a dirty burst stops being excluded from the replay and the
1308
+ // replay overwrites the user's uncommitted typing. Hence the fixed order:
1309
+ // the patch rewrites the attribute first, this re-derives the field from
1310
+ // it. `blockEl` is `editEl.closest('.ed-block')` captured at burst-start
1311
+ // and, because `burstSurvived` measured `editEl` as still attached, it is
1312
+ // the same still-attached element.
1313
+ if (burstSurvived) {
1314
+ currentBurst.blockId = Number(currentBurst.blockEl.getAttribute('data-block-id'));
1315
+ }
1316
+
1317
+ // S3 Task 5 (§4.4's ordered three steps): armEditables() above was step 1;
1318
+ // this is steps 2 and 3 — rebuild the member set from the declared (or
1319
+ // still-standing) LINE RANGE against the freshly built `blocks`. It sits
1320
+ // AFTER the unconditional teardown above so nothing there can null what it
1321
+ // just set, and BEFORE the two `try`-swallowed rebind blocks below so a
1322
+ // diagram-init throw can never skip it.
1323
+ //
1324
+ // Fix round 1 (review, ruling F3 amendment): there are TWO scroll sources
1325
+ // applyFullRender()'s `window.scrollTo(0, scrollY)` compensates, and only
1326
+ // one of them is absent here. The one F3 correctly ruled out is the
1327
+ // innerHTML swap collapsing the scroll extent — that cannot happen on this
1328
+ // path, and re-applying the stale pre-fetch snapshot would fight the
1329
+ // browser. The other is rebuildBlockSelection()'s own tail: it ends in
1330
+ // `focusEl.focus()`, which scrolls the focus holder into view (the comment
1331
+ // above applyFullRender()'s scrollTo says exactly this). That source is
1332
+ // very much still live here, so a keyboard-driven patched commit taken
1333
+ // while the block selection is scrolled off-screen would jump the viewport
1334
+ // where the fallback would not.
1335
+ //
1336
+ // Compensated precisely: read the CURRENT scroll position immediately
1337
+ // before, restore it immediately after, and only if the call actually
1338
+ // moved it. Never the pre-fetch `scrollY` — applyPatch() deliberately does
1339
+ // not take one.
1340
+ const scrollBeforeSelection = window.scrollY;
1341
+ rebuildBlockSelection(declaredRange);
1342
+ if (window.scrollY !== scrollBeforeSelection) {
1343
+ window.scrollTo(0, scrollBeforeSelection);
1344
+ }
1345
+ // 這三個都是 document-scoped,必須在插入之後、對整份文件跑:
1346
+ // __md2docInitDiagrams 的 WaveDrom 分支自述「has no per-root scoping API
1347
+ // — ProcessAll() always scans the whole document」,實測對 detached
1348
+ // fragment 呼叫時 mermaid 畫得出來但 WaveDrom 完全沒動。
1349
+ if (window.__md2docInitDiagrams) {
1350
+ try {
1351
+ window.__md2docInitDiagrams(contentEl);
1352
+ } catch (e) {
1353
+ // Same Phase-1 known limitation as the fallback path: a newly
1354
+ // introduced diagram type whose library global was never loaded stays
1355
+ // unrendered until reload. Never let that break the rest of the editor.
1356
+ }
1357
+ }
1358
+ if (window.__md2docRebindReader) {
1359
+ try {
1360
+ window.__md2docRebindReader();
1361
+ } catch (e) {
1362
+ // Never let a reader-runtime rebind failure break block selection/save/undo.
1363
+ }
1364
+ }
1365
+ // Fix round 1 (review, comment-only): whenever rebuildBlockSelection()
1366
+ // above does NOT take its early-return path it has already called
1367
+ // applySelectionClasses() once, via setBlockSelection()/
1368
+ // clearBlockSelection() — so on those renders this is a redundant SECOND
1369
+ // pass, costing one extra blur/focus round trip. It is kept unconditional
1370
+ // anyway, because the case where it is genuinely load-bearing is exactly
1371
+ // the case where rebuildBlockSelection() early-returns: a plain typing
1372
+ // commit with no selection anywhere, where nothing else would clear the
1373
+ // KEPT nodes' stale `.ed-selected`/tabindex.
1374
+ //
1375
+ // applySelectionClasses() 必須跑:它是 total 的(先把 `.ed-selected` 與
1376
+ // tabindex 從每一個區塊清掉再重套)。innerHTML 置換時代那份「保留節點的
1377
+ // 殘留 class 自動消失」的免費午餐在 patch 路徑沒有了。
1378
+ applySelectionClasses();
1379
+
1380
+ // ── Task G step 3 / 決議 3(1):patch 收尾的焦點檢查 ────────────────
1381
+ // endBurstWithoutResolve() 有 11 個呼叫點,全部在 render 之前,而
1382
+ // ⠿/+/工具列的 mousedown 都無條件 preventDefault()——刻意的,好讓
1383
+ // 焦點留在 armed 面上(既有 runtime 測試斷言「⠿ 的 mousedown 不得 blur
1384
+ // 還開著的 dirty burst」)。今天安全只因為 innerHTML 置換把那個節點
1385
+ // 拔掉;patch 保留節點,就把「焦點還在、currentBurst 為 null」從邊角
1386
+ // 變成日常。那個狀態下:打字不被追蹤(input listener 以
1387
+ // currentBurst.editEl 為前提)、移開焦點什麼都不提交(focusout 同理)
1388
+ // ——螢幕上看得到,檔案裡沒有。
1389
+ //
1390
+ // 位置是刻意的:在 teardown 之後(這時 currentBurst 才真的歸零了),
1391
+ // 也在 rebuildBlockSelection() / applySelectionClasses() 之後——那兩支的
1392
+ // focusEl.focus() 可能把焦點搬到一個 `.ed-block` 上(那是真的移走了,
1393
+ // 不該重開 burst)。先開再讓它搬走只會多觸發一輪 focusout →
1394
+ // resolveBurst()。又因為 resetSelToolbarState() 已經跑完(!burstSurvived
1395
+ // 才會走到這裡),startBurst() 重新掛上的 selectionchange listener 不會被
1396
+ // 回頭拆掉。
1397
+ //
1398
+ // ⚠ 這一步會改動 switchAwayFrom() 的後置條件。resolveOpenSession() 解掉
1399
+ // currentBurst、await 完 render 之後就直接 return true,不再重檢;而工具列
1400
+ // 與 ⠿/+ 的 mousedown 都 preventDefault(),焦點會留在原面上。所以這裡可能
1401
+ // 在那個被 await 的 render 裡重開一個 burst,讓 switchAwayFrom() 帶著一個
1402
+ // 開著的 session 回傳 true——它自己的 doc comment 說它是「單一、完整的
1403
+ // make it safe to proceed 閘門」。這是刻意的取捨:那個面上的鍵入沒人追蹤才是
1404
+ // 資料遺失,而重開的 burst 是乾淨的(original 就是當下的 innerHTML),
1405
+ // 下一次 focusout 會把它零編輯解掉。已知的一個後果是本輪修掉的那個:
1406
+ // openRawEditor() 原本只擋 activeEditor,於是它註冊 activeEditor 之後的
1407
+ // ta.focus() 會把這個 bystander burst 的 focusout 變成一次 switchAwayFrom(),
1408
+ // 連帶把剛開的 raw editor 一起收掉——見那裡的 Task G fix round 1 註解。
1409
+ // 任何新的「開一個 session 然後自己搬焦點」的路徑都要先解掉 burst。
1410
+ //
1411
+ // startBurst() 吃 editEl(`.ed-wys-armed` 本人)、startTableBurst() 吃 cellEl
1412
+ // (`.ed-wys-cell` 本人,它自己 closest('table'))——兩者都正好是
1413
+ // activeElement,所以直接傳。兩者都不呼叫 focus(),不搞 scrollIntoView,
1414
+ // 所以這裡**不需要** rebuildBlockSelection() 那種 scroll 補償。
1415
+ const ae = document.activeElement;
1416
+ if (!currentBurst && ae && contentEl.contains(ae)) {
1417
+ if (ae.classList.contains('ed-wys-cell')) startTableBurst(ae);
1418
+ else if (ae.classList.contains('ed-wys-armed')) startBurst(ae);
1419
+ }
1420
+ // Same tail as applyFullRender(): the dirty dot and the `true` that tells
1421
+ // every safeRerenderAll() caller the commit landed. Omitting either is the
1422
+ // exact silent-failure shape ruling F4 warns about — safeRerenderAll()'s
1423
+ // try/catch would swallow a throw and the DOM would still look right.
1424
+ setDirty();
1425
+ return true;
1426
+ }
1427
+
1428
+ async function applyFullRender(j, scrollY, declaredRange) {
1429
+ blocks = j.blocks;
1430
+ lastParts = j.parts; // v3.2.0: 供下一次 patch 當 oldParts
1431
+ contentEl.innerHTML = j.parts.join('\n'); // fallback 路徑用 join 還原
862
1432
  // Task 2 (Phase 3): re-arm every WYSIWYG-eligible paragraph/heading (and
863
1433
  // attach a ⠿ handle to every non-table block) in the freshly-swapped
864
1434
  // DOM — see armEditables() below. Must run before anything else touches
@@ -943,6 +1513,19 @@
943
1513
  // it's the same "singleton node appended as a child of whichever block
944
1514
  // it's open for" idiom as gutterMenu just above.
945
1515
  closeInsertMenu();
1516
+ // v3.1.0 §4: and the SAME belt-and-braces reasoning one more time for the
1517
+ // toolbar's tracked block. The toolbar node itself is mounted on
1518
+ // document.body (see mountToolbar() below) precisely so the innerHTML
1519
+ // replacement above cannot destroy it — but `toolbarBlockEl` points INTO
1520
+ // the subtree that was just swapped, and a detached node's
1521
+ // getAttribute() still answers perfectly happily: left standing, the bar
1522
+ // would keep reporting the old block's type/indent/heading depth and
1523
+ // every button would act on a node no click can ever reach again. Zeroing
1524
+ // it re-derives the model's documented no-block state (undo/redo/outline/
1525
+ // preview stay live, everything else greys out) — the same contract
1526
+ // closeGutterMenu()/closeInsertMenu() above have, and idempotent for the
1527
+ // same reason.
1528
+ resetToolbarBlock();
946
1529
  // S3 Task 5 (§4.4's ordered three steps): armEditables() above was step
947
1530
  // 1; this is steps 2 and 3 — rebuild the member set from the declared (or
948
1531
  // still-standing) LINE RANGE against the freshly built `blocks`, clear it
@@ -990,9 +1573,16 @@
990
1573
  // Defensive wrapper around every rerenderAll() call site: guarantees the
991
1574
  // caller never sees an unhandled rejection even if a future change to
992
1575
  // rerenderAll() (or one of its callees) introduces a stray throw.
993
- async function safeRerenderAll() {
1576
+ //
1577
+ // Task E: `editRange` passes straight through to rerenderAll(). Callers
1578
+ // with a real committed op compute it there and pass it; the 3 no-op call
1579
+ // sites (restore()'s rangeMode exit, the per-li unsupported-format
1580
+ // refusal, and commitListStructure()'s no-range exit) pass nothing, which
1581
+ // falls back to a full re-render by contract — patch can only compare
1582
+ // renderer output, never DOM the user has already touched.
1583
+ async function safeRerenderAll(editRange) {
994
1584
  try {
995
- return await rerenderAll();
1585
+ return await rerenderAll(editRange);
996
1586
  } catch (e) {
997
1587
  showBanner('Render failed — unexpected error (' + describeFailure(e) +
998
1588
  '). Your edit was not applied.', null, null);
@@ -1005,9 +1595,48 @@
1005
1595
  ta.style.height = (ta.scrollHeight + 2) + 'px';
1006
1596
  }
1007
1597
 
1008
- async function openRawEditor(blockEl) {
1598
+ // v3.1.0 修正 4: `range` ({startLine, endLine}, 1-indexed inclusive) opens
1599
+ // the raw editor over an EXPLICIT span of source lines instead of the
1600
+ // block's own record. Two callers need it and one property makes both
1601
+ // work: a list item (whose source line carries its marker and leading
1602
+ // columns, and which must NOT go through the run re-serializer while a
1603
+ // textarea is standing where its .ed-li-text should be) and a multi-block
1604
+ // ⠿ selection (whose span is several blocks' lines at once). Both also
1605
+ // switch this editor's teardown from an innerHTML rewrite to a real
1606
+ // re-render — see restore() below for why that is not optional.
1607
+ async function openRawEditor(blockEl, range) {
1009
1608
  if (blockEl.querySelector('.ed-raw')) return; // already editing this block
1010
- if (activeEditor && activeEditor.blockEl !== blockEl) {
1609
+ // Task G fix round 1 (npm test RED on f63c88a): the guard below used to
1610
+ // read `activeEditor` ALONE, so a WYSIWYG burst open on a DIFFERENT block
1611
+ // survived into this function — and then `ta.focus()` (a few lines down,
1612
+ // after `activeEditor` has already been registered) blurs that surface,
1613
+ // whose delegated focusout calls switchAwayFrom(). resolveOpenSession()
1614
+ // resolves BOTH kinds of session, so it resolves the burst AND then finds
1615
+ // the brand-new, unmodified `activeEditor` and calls cancelNow() on it —
1616
+ // which for a pristine insert is cancelAndMaybeDiscard() -> the freshly
1617
+ // inserted block is discarded and its textarea never survives the gesture.
1618
+ //
1619
+ // MEASURED (scratchpad taskG-repro-code.js, replaying
1620
+ // test/editor-client-runtime.test.js's Section 10「abandoned inserts
1621
+ // (all 5 kinds)」): + -> 程式碼 with stale focus left on an untouched
1622
+ // bystander paragraph produced
1623
+ // openRawEditor {currentBurst:true, activeEditor:false}
1624
+ // resolveOpenSession {currentBurst:true, activeEditor:true, pristineInsert:6}
1625
+ // cancelAndMaybeDiscard {blockId:6, pristineInsert:6}
1626
+ // and zero code blocks left in the DOM. Only 程式碼 is affected because it
1627
+ // is the one insert kind that registers an `activeEditor` before moving
1628
+ // focus; the other four just focus another armed surface.
1629
+ //
1630
+ // The burst arm was previously unreachable — every caller resolved the
1631
+ // session first — which is why the guard only ever named `activeEditor`.
1632
+ // applyPatch()'s step-3 focus check makes it reachable: it adopts focus
1633
+ // that a preventDefault()-ing gesture left standing on a bystander, so a
1634
+ // burst can now be open on a block nobody is interacting with. Resolving
1635
+ // it HERE (before `activeEditor` is registered, so the focusout that
1636
+ // `ta.focus()` fires finds nothing to resolve) is the same defense-in-
1637
+ // depth this guard already documents, applied to the other session kind.
1638
+ if ((activeEditor && activeEditor.blockEl !== blockEl) ||
1639
+ (currentBurst && currentBurst.blockEl !== blockEl)) {
1011
1640
  // Click-to-switch (see `activeEditor` / switchAwayFrom() comments
1012
1641
  // above): resolve whatever editor IS open before opening this one.
1013
1642
  // Defense in depth — the delegated click listener (wireBlockSelection
@@ -1023,7 +1652,10 @@
1023
1652
  const ok = await switchAwayFrom();
1024
1653
  if (!ok) return; // the open editor's auto-commit failed; stay put
1025
1654
  }
1026
- const blockId = Number(blockEl.getAttribute('data-block-id'));
1655
+ // v3.2.0 (final review item 1): `let`, not `const` — reanchor() below
1656
+ // re-derives all three of `blockId`/`startLine`/`endLine` when this
1657
+ // session survives an incremental patch. See its own comment.
1658
+ let blockId = Number(blockEl.getAttribute('data-block-id'));
1027
1659
  const block = blocks.find((b) => b.id === blockId);
1028
1660
  if (!block) return;
1029
1661
  // Task 4 fix round 1 (Critical, second path): a block that owns no source
@@ -1034,16 +1666,45 @@
1034
1666
  // INSERTS the text as a new line instead of replacing anything, leaving
1035
1667
  // the clicked item untouched and a stray line in the file (measured:
1036
1668
  // '# 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
1669
+ // The only such blocks today are list items, and they are never armed, so
1670
+ // before Task 4 gave every block a ⠿ this was reached by a plain body
1671
+ // click and stayed unreported. (Until v3.1.0 this note also cited RULING
1672
+ // F-O as an independent second reason. F-O is superseded for the ⠿ menu
1673
+ // — see toggleGutterMenu() below — so this guard now carries the case
1674
+ // ALONE, and it is the one that always mattered: a block that owns no
1675
+ // source line has nothing to seed a textarea WITH, no matter how the
1676
+ // editor is torn down afterwards.) Guarded HERE rather than at the click
1677
+ // delegator so
1041
1678
  // every caller — the delegated degraded-block click, openRawViaGutter(),
1042
1679
  // the burst-degrade path — is closed by one check.
1043
1680
  if (blockOwnsNoLine(blockEl)) { refuseStructuralListEdit(NO_SOURCE_LINE_MESSAGE); return; }
1044
1681
 
1682
+ // v3.1.0 修正 4: the span this editor owns. Without `range` it is exactly
1683
+ // the block's own record, so `source` below is byte-identical to what
1684
+ // extractBlockSource(lines, block) returned before — that helper IS this
1685
+ // slice, and every pre-v3.1.0 call site keeps its behaviour unchanged.
1686
+ // v3.2.0 (final review item 1): `let` for the same reason as `blockId`.
1687
+ let startLine = range ? range.startLine : block.startLine;
1688
+ let endLine = range ? range.endLine : block.endLine;
1689
+ // The same inverted-range refusal blockOwnsNoLine() just made for the
1690
+ // block, re-asked of the range actually being edited: a caller-supplied
1691
+ // span can be malformed even when the anchor block is fine.
1692
+ if (endLine < startLine) { refuseStructuralListEdit(NO_SOURCE_LINE_MESSAGE); return; }
1693
+ // A list item's rendered chrome (the .ed-li-marker span, the optional
1694
+ // .ed-li-check span, the .ed-li-text surface) is REBUILT BY THE RENDERER
1695
+ // from the source line's marker — it is not recoverable from a captured
1696
+ // innerHTML string once the item's own text has been rewritten, which is
1697
+ // exactly what RULING F-O objected to. So a li (and any explicit range)
1698
+ // commits through commitRangeEdit() over these lines rather than through
1699
+ // the run serializer, and tears down through a real re-render — which
1700
+ // removes F-O's stated premise rather than working around it. Ruling 13
1701
+ // (v3.1.0) supersedes F-O for THIS surface, the ⠿ menu's MD 原始碼, and
1702
+ // for that surface only: the burst-degrade refusal in resolveBurst() and
1703
+ // the blockOwnsNoLine() guard just above both still stand, for reasons
1704
+ // that were never F-O's.
1705
+ const rangeMode = !!range || blockEl.getAttribute('data-block-type') === 'li';
1045
1706
  const original = blockEl.innerHTML;
1046
- const source = extractBlockSource(lines, block);
1707
+ const source = lines.slice(startLine - 1, endLine).join('\n');
1047
1708
 
1048
1709
  const wrap = document.createElement('div');
1049
1710
  wrap.className = 'ed-editing';
@@ -1065,6 +1726,7 @@
1065
1726
  hasChanges: () => ta.value !== source,
1066
1727
  commitNow: commit,
1067
1728
  cancelNow: cancelAndMaybeDiscard,
1729
+ reanchor,
1068
1730
  };
1069
1731
 
1070
1732
  const controls = document.createElement('div');
@@ -1089,7 +1751,19 @@
1089
1751
  ta.focus();
1090
1752
  ta.setSelectionRange(ta.value.length, ta.value.length);
1091
1753
 
1092
- function restore() {
1754
+ async function restore() {
1755
+ // v3.1.0 修正 4: `rangeMode` cannot restore by rewriting innerHTML —
1756
+ // for a li that string would have to carry the marker/check/text chrome
1757
+ // back, and for a multi-block span there is more than one block's DOM
1758
+ // to put back in the first place. A re-render rebuilds all of it from
1759
+ // `lines`, which this path never touched (restore() NEVER commits), so
1760
+ // the document is byte-identical either way — this is a different way
1761
+ // of repainting the same bytes, not a different edit.
1762
+ if (rangeMode) {
1763
+ if (activeEditor && activeEditor.blockEl === blockEl) activeEditor = null;
1764
+ await safeRerenderAll();
1765
+ return;
1766
+ }
1093
1767
  blockEl.innerHTML = original;
1094
1768
  // Only clear `activeEditor` if it's still THIS block's entry — it may
1095
1769
  // already have been cleared out from under us (e.g. by rerenderAll()'s
@@ -1113,11 +1787,41 @@
1113
1787
  async function cancelAndMaybeDiscard() {
1114
1788
  const wasPristineForThisBlock = !!(pristineInsert && pristineInsert.blockId === blockId);
1115
1789
  if (wasPristineForThisBlock) pristineInsert = null;
1116
- restore();
1790
+ await restore();
1117
1791
  if (wasPristineForThisBlock) return await discardPristineInsert();
1118
1792
  return true;
1119
1793
  }
1120
1794
 
1795
+ // v3.2.0 final review item 1 — 決議 3(1) 的 activeEditor 對稱項。
1796
+ //
1797
+ // 這個 session 的 `blockId` / `startLine` / `endLine` 是開啟當下從**那一
1798
+ // 份** `blocks` 抓下來的;fallback 路徑上那從來不是問題,因為
1799
+ // `contentEl.innerHTML = ` 會把這個 textarea 一起拔掉。patch 路徑保留
1800
+ // 前綴/後綴節點,所以它可以活過一次 render,而 render 之後 `blocks`
1801
+ // 已整份換掉、被保留後綴節點的 data-block-id 也被重寫過。
1802
+ //
1803
+ // applyPatch() 的 teardown 在實測 `document.body.contains(blockEl)` 成立
1804
+ // 之後呼叫這支,把三個欄位從**現在**的 DOM 屬性與新的 `blocks` 重新
1805
+ // 推導。回傳 false 代表推導不出來,呼叫端必須退回原本的無條件歸零:
1806
+ // - `range`:呼叫端指定的跨區塊 span(⠿ 選單的「MD 原始碼」批次路徑)
1807
+ // 在新的 `blocks` 裡沒有單一對應記錄,無從重新推導。
1808
+ // - 區塊在新的 `blocks` 裡不存在(防禦;節點還在就不該發生)。
1809
+ // - 重新切出來的來源**不是** byte-identical:那表示這個區塊底下的行
1810
+ // 真的變了,`ta.value` 與 `source` 都已經不是檔案裡的東西,沿用這個
1811
+ // session 會把過期的文字提交回去。patch 只保留 parts 相同的節點,所以
1812
+ // 正常情況下這個等式成立;不成立時退回歸零是保守的正解。
1813
+ function reanchor() {
1814
+ if (range) return false;
1815
+ const id = Number(blockEl.getAttribute('data-block-id'));
1816
+ const b = blocks.find((x) => x.id === id);
1817
+ if (!b || b.endLine < b.startLine) return false;
1818
+ if (lines.slice(b.startLine - 1, b.endLine).join('\n') !== source) return false;
1819
+ blockId = id;
1820
+ startLine = b.startLine;
1821
+ endLine = b.endLine;
1822
+ return true;
1823
+ }
1824
+
1121
1825
  // Returns true when the editor is resolved (committed, or a no-op
1122
1826
  // commit that fell back to a cancel) — safe for a caller (switchAwayFrom
1123
1827
  // included) to proceed. Returns false only when the render actually
@@ -1127,7 +1831,13 @@
1127
1831
  // network hiccup must never silently discard what they typed). The
1128
1832
  // failure banner is already shown by safeRerenderAll()/rerenderAll().
1129
1833
  async function commit() {
1130
- const result = commitEdit({ lines, blocks, stack }, blockId, ta.value);
1834
+ // v3.1.0 修正 4: an explicit range commits over ITS OWN span. commitEdit()
1835
+ // is the by-id wrapper over exactly this call (it looks the block up and
1836
+ // delegates), so the two branches are the same pipeline — replaceLines()
1837
+ // + one undo op — differing only in which lines they name.
1838
+ const result = rangeMode
1839
+ ? commitRangeEdit({ lines, blocks, stack }, startLine, endLine, ta.value)
1840
+ : commitEdit({ lines, blocks, stack }, blockId, ta.value);
1131
1841
  if (result.op === null) {
1132
1842
  // §10-gap fix (review): an explicit commit (Ctrl+Enter/✓) whose
1133
1843
  // text happens to be byte-identical to `source` is still an
@@ -1137,7 +1847,12 @@
1137
1847
  }
1138
1848
  const prevLines = lines;
1139
1849
  lines = result.lines;
1140
- const ok = await safeRerenderAll();
1850
+ const editRange = result.op
1851
+ ? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
1852
+ endLine: Math.max(result.op.startLine - 1, result.op.endLine),
1853
+ delta: result.op.after.length - result.op.before.length }
1854
+ : undefined;
1855
+ const ok = await safeRerenderAll(editRange);
1141
1856
  if (!ok) {
1142
1857
  // Roll back the optimistic edit: pop the op safeRerenderAll's
1143
1858
  // failure means was never actually rendered, so `lines` stays
@@ -1147,9 +1862,48 @@
1147
1862
  lines = rollbackFailedRender({ lines, stack }, result, prevLines);
1148
1863
  return false;
1149
1864
  }
1150
- // Success: rerenderAll() already replaced the whole .content subtree
1151
- // (this block included) and nulled `activeEditor` itself — nothing
1152
- // left to do here.
1865
+ // Success. On the FALLBACK path rerenderAll() replaced the whole
1866
+ // .content subtree (this block included) and nulled `activeEditor`
1867
+ // itself, so there was nothing left to do here.
1868
+ //
1869
+ // v3.2.0 final review item 1: that is no longer unconditional. The
1870
+ // patch path only detaches this block when it lands inside
1871
+ // `replaceSpan` — and a commit very often leaves it OUTSIDE: whenever
1872
+ // the edit's visible effect is a NEW block AFTER this one (a line
1873
+ // appended below a fence, a paragraph typed under a paragraph) this
1874
+ // block's own part is byte-identical before and after, so patchmap
1875
+ // keeps it in `keepPrefix` and the textarea stays on screen, on top of
1876
+ // content that was already repainted around it.
1877
+ //
1878
+ // MEASURED at HEAD, before this repair (scratchpad/item1-zombie.js
1879
+ // --head): Ctrl+Enter on a code fence with one line appended commits
1880
+ // correctly to the file but leaves `rawTextareas: 1` standing forever
1881
+ // — unclickable, because both the delegated click handler and
1882
+ // openRawEditor() early-return on a block that already has a `.ed-raw`.
1883
+ // With the `activeEditor` exception above it is worse than cosmetic:
1884
+ // the session stays live with its ORIGINAL `source`, so the next
1885
+ // switchAwayFrom() sees hasChanges() and commits the same text a
1886
+ // second time (measured: 'const LOST = 2;' twice in the file).
1887
+ //
1888
+ // A kept node's part is byte-identical by construction — that is the
1889
+ // only reason patchmap kept it — so `original` (this block's innerHTML
1890
+ // captured at open, i.e. post-armEditables) is exactly the content to
1891
+ // put back. Same one-line repaint the non-range restore() below uses,
1892
+ // for the same reason.
1893
+ //
1894
+ // ⚠ 順序不可對調,而且不是防禦性的:`blockEl.innerHTML =` 會把還握著
1895
+ // 焦點的 textarea 就地 detach,Chromium 在那當下**同步**送出一次
1896
+ // focusout,委派的 focusout handler 走 switchAwayFrom() →
1897
+ // resolveOpenSession() → activeEditor.commitNow()。先重繪再歸零時,
1898
+ // 那一發看到的還是這個 session、hasChanges() 仍為真,於是把同一段文字
1899
+ // 再提交一次(實測:'ZZZ tail.' 在檔案裡出現兩次,stack 是
1900
+ // commit → focusout → switchAwayFrom → resolveOpenSession → commitNow)。
1901
+ // 先歸零就沒有東西可以被重新解析。rangeMode 的 restore() 早就是這個
1902
+ // 順序,理由相同。
1903
+ if (activeEditor && activeEditor.blockEl === blockEl) activeEditor = null;
1904
+ if (document.body.contains(blockEl) && blockEl.querySelector('.ed-raw')) {
1905
+ blockEl.innerHTML = original;
1906
+ }
1153
1907
  return true;
1154
1908
  }
1155
1909
 
@@ -1864,7 +2618,12 @@
1864
2618
  if (result.op === null) return;
1865
2619
  const prevLines = lines;
1866
2620
  lines = result.lines;
1867
- const okRender = await safeRerenderAll();
2621
+ const editRange = result.op
2622
+ ? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
2623
+ endLine: Math.max(result.op.startLine - 1, result.op.endLine),
2624
+ delta: result.op.after.length - result.op.before.length }
2625
+ : undefined;
2626
+ const okRender = await safeRerenderAll(editRange);
1868
2627
  if (!okRender) {
1869
2628
  lines = rollbackFailedRender({ lines, stack }, result, prevLines);
1870
2629
  }
@@ -2378,37 +3137,42 @@
2378
3137
  gutterMenuConvert.hidden = convertWithheldFor(blockType);
2379
3138
  gutterMenuDuplicate.hidden = false;
2380
3139
  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.
3140
+ // 'MD 原始碼' is offered for EVERY block type, list items included, and
3141
+ // for a multi-block selection. The menu is a SINGLETON moved between
3142
+ // blocks, so this is still written on every open rather than set once.
2388
3143
  // (test/editor-reader-rebind.test.js drives raw-edit through this button
2389
3144
  // by its exact text on a paragraph.)
2390
3145
  //
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.
3146
+ // ⚠ THIS REVERSES RULING F-O, deliberately and at spec level (v3.1.0
3147
+ // Ruling 13), for this surface and this surface only. F-O read: 'MD
3148
+ // 原始碼' is hidden for a list item PERMANENTLY, because openRawEditor()
3149
+ // replaces the block's innerHTML with a <textarea> — and a li is one line
3150
+ // of a run serialized as a whole, so a textarea inside it is content the
3151
+ // serializer cannot represent, and restore() would have to rebuild the
3152
+ // marker/check/text chrome out of a string. 修正 4 removes that premise
3153
+ // instead of arguing with it: a li now raw-edits an explicit LINE RANGE,
3154
+ // commits it through commitRangeEdit() rather than the run
3155
+ // re-serializer, and RESTORES BY RE-RENDERING rather than by rewriting
3156
+ // innerHTML, so no serializer ever sees the textarea and no chrome is
3157
+ // ever rebuilt by hand.
3158
+ //
3159
+ // F-O's OTHER call sites are NOT in scope and still refuse, each for a
3160
+ // reason that was never F-O's: openRawEditor()'s blockOwnsNoLine() guard
3161
+ // (a block with an inverted range has no line to seed a textarea with)
3162
+ // and resolveBurst()'s unsupported-inline-content path (that refusal is
3163
+ // about the CONTENT, not the container).
2396
3164
  //
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;
3165
+ // §3.7's other sentence about this item — 「多選時不顯示 `MD 原始碼`」 —
3166
+ // is also lifted, and for the same kind of reason: it existed because
3167
+ // openRawViaGutter() rewrote ONE block's source lines, so over a set of N
3168
+ // it silently answered for the grip's block and ignored the rest. It now
3169
+ // opens on the WHOLE span's line range (§3.3 membership, asked through
3170
+ // resolveGutterOperands() so the contiguity and no-source-line gates come
3171
+ // with it), so the item does what it appears to do.
3172
+ // The membership question itself moved WITH the decision it feeds, into
3173
+ // openRawViaGutter() (it now chooses the RANGE instead of the
3174
+ // visibility), so it is asked exactly once, in the place that acts on it.
3175
+ gutterMenuMd.hidden = false;
2412
3176
  blockEl.appendChild(gutterMenu);
2413
3177
  }
2414
3178
 
@@ -2522,6 +3286,13 @@
2522
3286
  list: ['-'],
2523
3287
  table: ['| A | B |', '|---|---|', '| | |'],
2524
3288
  code: ['```', '', '```'],
3289
+ // v3.1.0 §4: the toolbar's ― button. '---' is safe as a thematic break
3290
+ // here specifically because commitBlockInsertion() ALWAYS writes a
3291
+ // leading blank line (see its own comment) — a '---' directly under a
3292
+ // non-blank line would instead lex as a setext H2 and silently promote
3293
+ // whatever preceded it. Nothing else in this file may reuse this
3294
+ // skeleton through a path that does not guarantee that blank.
3295
+ line: ['---'],
2525
3296
  };
2526
3297
 
2527
3298
  // Selects the entirety of `el`'s content (used right after focusing a
@@ -2567,7 +3338,28 @@
2567
3338
  const prevLines = lines;
2568
3339
  lines = discarded.lines;
2569
3340
  setDirty();
2570
- const okRender = await safeRerenderAll();
3341
+ // v3.2.0: UndoStack.undo()/discardTop() reverse the op when applying it but
3342
+ // return the unmodified FORWARD op, so the range actually rewritten is
3343
+ // [op.startLine, op.startLine + op.after.length - 1] and the delta is the
3344
+ // mirror of the forward one. Using op.endLine here would name the opposite
3345
+ // direction — the span the op wrote on the way FORWARD, not the one this
3346
+ // reversal just rewrote.
3347
+ //
3348
+ // Final review item 4 — what naming it wrongly would cost today: nothing.
3349
+ // Per 決議 4(b) `editRange` is ADVISORY. patchmap() destructures
3350
+ // oldBlocks/newBlocks/oldParts/newParts and never reads it; the kept
3351
+ // prefix/suffix is the longest common run of the two PART arrays, and
3352
+ // seeding it from an op's line range only ever made the answer worse
3353
+ // (measured in 決議 4(b): seeded kept 0 of 5, pure-parts kept 4 of 5).
3354
+ // Its one client-side use is the truthiness gate in applyRenderResult().
3355
+ // So the direction changes no plan — this is simply the correct value for
3356
+ // whenever something does start reading it.
3357
+ const editRange = discarded.op
3358
+ ? { startLine: discarded.op.startLine,
3359
+ endLine: discarded.op.startLine + discarded.op.after.length - 1,
3360
+ delta: discarded.op.before.length - discarded.op.after.length }
3361
+ : undefined;
3362
+ const okRender = await safeRerenderAll(editRange);
2571
3363
  if (!okRender) {
2572
3364
  stack.push(discarded.op); // put it back — see discardTop()'s own comment
2573
3365
  lines = prevLines;
@@ -2620,7 +3412,16 @@
2620
3412
  // dirty burst, swapping the whole `.content` subtree and detaching
2621
3413
  // `blockEl` along with it — same "ensureTableBurstOpen()'s Finding 6"
2622
3414
  // recovery idiom used throughout this file), THEN acts.
2623
- async function insertBlockBelow(blockEl, kind) {
3415
+ // v3.1.0 §4 / 追加 2 / 追加 3: `opts.lines` supplies EXPLICIT source lines
3416
+ // instead of one of the five placeholder skeletons — the image-drop path
3417
+ // (`![](assets/…)`) and the paste path (turndown's markdown) both need to
3418
+ // land real content, and both must land it as SOURCE so it goes through
3419
+ // commitBlockInsertion()/replaceLines() like every other edit rather than
3420
+ // being spliced into the DOM behind serializeBlocks()'s back. Everything
3421
+ // else about the insert — the run-wide gate, the subtree anchor, the
3422
+ // rollback — is shared, which is the whole reason this is one extra
3423
+ // parameter and not a second insertion path.
3424
+ async function insertBlockBelow(blockEl, kind, opts) {
2624
3425
  if (!blockEl) return;
2625
3426
  // T7: captured BEFORE switchAwayFrom(), because that is what can renumber
2626
3427
  // the ids — see captureBlockIdentity()'s comment.
@@ -2659,7 +3460,8 @@
2659
3460
  refuseStructuralListEdit(NO_SOURCE_LINE_INSERT_MESSAGE);
2660
3461
  return;
2661
3462
  }
2662
- const newLines = BLOCK_SKELETONS[kind];
3463
+ const customLines = opts && Array.isArray(opts.lines) ? opts.lines : null;
3464
+ const newLines = customLines || BLOCK_SKELETONS[kind];
2663
3465
  if (!newLines) return;
2664
3466
 
2665
3467
  // ── S2 Task 7: a LIST ITEM anchor (§6's S1 note item 3) ────────────────
@@ -2730,7 +3532,12 @@
2730
3532
  const result = commitBlockInsertion({ lines, blocks, stack }, liveBlockId, newLines);
2731
3533
  const prevLines = lines;
2732
3534
  lines = result.lines;
2733
- const okRender = await safeRerenderAll();
3535
+ const editRange = result.op
3536
+ ? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
3537
+ endLine: Math.max(result.op.startLine - 1, result.op.endLine),
3538
+ delta: result.op.after.length - result.op.before.length }
3539
+ : undefined;
3540
+ const okRender = await safeRerenderAll(editRange);
2734
3541
  if (!okRender) {
2735
3542
  lines = rollbackFailedRender({ lines, stack }, result, prevLines);
2736
3543
  return;
@@ -2739,9 +3546,27 @@
2739
3546
  // see `pristineInsert`'s own comment for the full contract. `blocks`
2740
3547
  // was just reassigned by the successful rerenderAll() above, so this
2741
3548
  // 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);
3549
+ //
3550
+ // v3.1.0: a custom-lines insert is NEITHER pristine NOR focus-and-select.
3551
+ // `pristineInsert` means "a placeholder the user has not touched yet, so
3552
+ // abandoning it removes it again" — applied to a dropped image or a
3553
+ // pasted paragraph it would silently delete real content the moment the
3554
+ // user clicked elsewhere without typing. focusAndSelectAll() is wrong for
3555
+ // the same reason: it selects the block's whole content so the first
3556
+ // keystroke replaces it, which is what a ZWSP placeholder wants and what
3557
+ // a just-pasted paragraph very much does not.
3558
+ //
3559
+ // 'line' is excluded for the same reason from the other direction: both
3560
+ // are PLACEHOLDER semantics, and an <hr> has no placeholder. There is
3561
+ // nothing for focusAndSelectAll() to select (the block's content element
3562
+ // is the rule itself), and "abandoning it un-typed removes it again"
3563
+ // describes a skeleton waiting to be filled in — a thematic break is
3564
+ // finished the moment it exists.
3565
+ if (!customLines && kind !== 'line') {
3566
+ const target = blocks.find((b) => b.startLine === result.newStartLine);
3567
+ if (target) pristineInsert = { blockId: target.id };
3568
+ await focusInsertedBlock(result.newStartLine, kind);
3569
+ }
2745
3570
  }
2746
3571
 
2747
3572
 
@@ -3194,7 +4019,12 @@
3194
4019
  startLine: first.startLine,
3195
4020
  endLine: first.startLine + md.split('\n').length - 1,
3196
4021
  });
3197
- const okRender = await safeRerenderAll();
4022
+ const editRange = result.op
4023
+ ? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
4024
+ endLine: Math.max(result.op.startLine - 1, result.op.endLine),
4025
+ delta: result.op.after.length - result.op.before.length }
4026
+ : undefined;
4027
+ const okRender = await safeRerenderAll(editRange);
3198
4028
  if (!okRender) {
3199
4029
  lines = rollbackFailedRender({ lines, stack }, result, prevLines);
3200
4030
  }
@@ -3289,7 +4119,12 @@
3289
4119
  // answer as "keep what is standing" and says so explicitly, which is what
3290
4120
  // keeps this correct if the insertion point ever moves.
3291
4121
  declareCollapse({ startLine: first.startLine, endLine: last.endLine });
3292
- if (!(await safeRerenderAll())) {
4122
+ const editRange = result.op
4123
+ ? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
4124
+ endLine: Math.max(result.op.startLine - 1, result.op.endLine),
4125
+ delta: result.op.after.length - result.op.before.length }
4126
+ : undefined;
4127
+ if (!(await safeRerenderAll(editRange))) {
3293
4128
  lines = rollbackFailedRender({ lines, stack }, result, prevLines);
3294
4129
  }
3295
4130
  }
@@ -3421,7 +4256,12 @@
3421
4256
  // inverted range, and §4.4 says a range that no longer resolves clears).
3422
4257
  // Declared immediately before the render, never earlier (Task 5 carry 5).
3423
4258
  declareCollapse(null);
3424
- const okRender = await safeRerenderAll();
4259
+ const editRange = result.op
4260
+ ? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
4261
+ endLine: Math.max(result.op.startLine - 1, result.op.endLine),
4262
+ delta: result.op.after.length - result.op.before.length }
4263
+ : undefined;
4264
+ const okRender = await safeRerenderAll(editRange);
3425
4265
  if (!okRender) {
3426
4266
  lines = rollbackFailedRender({ lines, stack }, result, prevLines);
3427
4267
  }
@@ -3855,7 +4695,12 @@
3855
4695
  const prevLines = lines;
3856
4696
  lines = result.lines;
3857
4697
  declareCollapse({ startLine: outStart, endLine: outEnd });
3858
- if (!(await safeRerenderAll())) {
4698
+ const editRange = result.op
4699
+ ? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
4700
+ endLine: Math.max(result.op.startLine - 1, result.op.endLine),
4701
+ delta: result.op.after.length - result.op.before.length }
4702
+ : undefined;
4703
+ if (!(await safeRerenderAll(editRange))) {
3859
4704
  lines = rollbackFailedRender({ lines, stack }, result, prevLines);
3860
4705
  }
3861
4706
  }
@@ -3912,7 +4757,12 @@
3912
4757
  startLine: merged.startLine,
3913
4758
  endLine: merged.startLine + md.split('\n').length - 1,
3914
4759
  });
3915
- if (!(await safeRerenderAll())) {
4760
+ const editRange = result.op
4761
+ ? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
4762
+ endLine: Math.max(result.op.startLine - 1, result.op.endLine),
4763
+ delta: result.op.after.length - result.op.before.length }
4764
+ : undefined;
4765
+ if (!(await safeRerenderAll(editRange))) {
3916
4766
  lines = rollbackFailedRender({ lines, stack }, result, prevLines);
3917
4767
  }
3918
4768
  }
@@ -3978,7 +4828,27 @@
3978
4828
  const ok = await switchAwayFrom();
3979
4829
  if (!ok) return;
3980
4830
  }
3981
- openRawEditor(blockEl);
4831
+ // v3.1.0 修正 4: which LINES this raw edit owns is now §3.3's membership
4832
+ // question, asked through the same door every other ⠿ operation uses.
4833
+ // resolveGutterOperands() brings the whole shared preamble with it — the
4834
+ // re-resolve after a commit that re-rendered, the no-source-line refusal,
4835
+ // the emptiness and CONTIGUITY gates — which is what makes a span range
4836
+ // safe to take as `recs[0].startLine .. recs[last].endLine`: a disjoint
4837
+ // selection would refuse here rather than quietly swallowing the blocks
4838
+ // sitting in the gap. Its own switchAwayFrom() is a no-op by this point
4839
+ // (the branch above already resolved whatever was open).
4840
+ const operands = await resolveGutterOperands(blockEl);
4841
+ if (!operands) return;
4842
+ const els = operands.els;
4843
+ const recs = operands.recs;
4844
+ const liveBlockEl = els[0];
4845
+ const isLi = liveBlockEl.getAttribute('data-block-type') === 'li';
4846
+ // A single non-li block keeps the pre-v3.1.0 path exactly: no range, so
4847
+ // openRawEditor() uses the block's own record and restores by innerHTML.
4848
+ const range = (recs.length > 1 || isLi)
4849
+ ? { startLine: recs[0].startLine, endLine: recs[recs.length - 1].endLine }
4850
+ : null;
4851
+ await openRawEditor(liveBlockEl, range);
3982
4852
  }
3983
4853
 
3984
4854
  // Starts a burst on `editEl` (a `.ed-wys-armed` content element) — called
@@ -4170,10 +5040,20 @@
4170
5040
  const { md: runMd, unsupported, unsupportedByLi, lineMeta } =
4171
5041
  listMd.serializeBlocks(runEls, { carryOver: carry });
4172
5042
  // 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.
5043
+ // Show banner + teardown + rerenderAll (file is untouched, burst never
5044
+ // wrote to `lines`) + return false — this path does NOT fall back to
5045
+ // openRawEditor().
5046
+ //
5047
+ // ⚠ v3.1.0 Ruling 13 supersedes RULING F-O for the ⠿ menu's MD 原始碼
5048
+ // (see toggleGutterMenu()), and NOT for this site. Two different
5049
+ // reasons: F-O was about the CONTAINER (a textarea cannot live inside a
5050
+ // serialized run), which 修正 4 answers by restoring through a
5051
+ // re-render; this refusal is about the CONTENT — the run's own
5052
+ // serializer has just reported it cannot round-trip this item, and
5053
+ // silently opening an editor from inside a burst resolution would be a
5054
+ // second, unasked-for gesture on top of a failed one. The banner's
5055
+ // advice ('改用原始碼編輯') is now literally actionable, which it was
5056
+ // not before: the user can reach exactly that through the ⠿ menu.
4177
5057
  const editedIdStr = String(burst.blockId);
4178
5058
  if (unsupportedByLi.some((u) => u.blockId === editedIdStr)) {
4179
5059
  showBanner('含不支援的格式,改用原始碼編輯', null, null);
@@ -4253,7 +5133,12 @@
4253
5133
  }
4254
5134
  const liPrevLines = lines;
4255
5135
  lines = liCommitResult.lines;
4256
- const liOk = await safeRerenderAll();
5136
+ const editRange = liCommitResult.op
5137
+ ? { startLine: Math.min(liCommitResult.op.startLine, liCommitResult.op.endLine + 1),
5138
+ endLine: Math.max(liCommitResult.op.startLine - 1, liCommitResult.op.endLine),
5139
+ delta: liCommitResult.op.after.length - liCommitResult.op.before.length }
5140
+ : undefined;
5141
+ const liOk = await safeRerenderAll(editRange);
4257
5142
  if (!liOk) {
4258
5143
  lines = rollbackFailedRender({ lines, stack }, liCommitResult, liPrevLines);
4259
5144
  return false;
@@ -4309,7 +5194,12 @@
4309
5194
  }
4310
5195
  const prevLines = lines;
4311
5196
  lines = commitResult.lines;
4312
- const ok = await safeRerenderAll();
5197
+ const editRange = commitResult.op
5198
+ ? { startLine: Math.min(commitResult.op.startLine, commitResult.op.endLine + 1),
5199
+ endLine: Math.max(commitResult.op.startLine - 1, commitResult.op.endLine),
5200
+ delta: commitResult.op.after.length - commitResult.op.before.length }
5201
+ : undefined;
5202
+ const ok = await safeRerenderAll(editRange);
4313
5203
  if (!ok) {
4314
5204
  lines = rollbackFailedRender({ lines, stack }, commitResult, prevLines);
4315
5205
  // Burst stays open: DOM/history untouched, banner already shown by
@@ -4964,7 +5854,12 @@
4964
5854
  // still sitting there un-committed — re-render anyway so what's on screen
4965
5855
  // is always exactly what's in `lines`.
4966
5856
  if (result.op !== null) lines = result.lines;
4967
- const ok = await safeRerenderAll();
5857
+ const editRange = result.op
5858
+ ? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
5859
+ endLine: Math.max(result.op.startLine - 1, result.op.endLine),
5860
+ delta: result.op.after.length - result.op.before.length }
5861
+ : undefined;
5862
+ const ok = await safeRerenderAll(editRange);
4968
5863
  if (!ok) {
4969
5864
  lines = rollbackFailedRender({ lines, stack }, result, prevLines);
4970
5865
  // Deliberately NOT a second safeRerenderAll(), unlike the two refusals
@@ -5833,7 +6728,12 @@
5833
6728
  // §3.3's collapse: a depth change rewrites no line COUNT, so the set
5834
6729
  // collapses onto the lines it already held.
5835
6730
  declareCollapse({ startLine: first.startLine, endLine: last.endLine });
5836
- const okRender = await safeRerenderAll();
6731
+ const editRange = result.op
6732
+ ? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
6733
+ endLine: Math.max(result.op.startLine - 1, result.op.endLine),
6734
+ delta: result.op.after.length - result.op.before.length }
6735
+ : undefined;
6736
+ const okRender = await safeRerenderAll(editRange);
5837
6737
  if (!okRender) {
5838
6738
  lines = rollbackFailedRender({ lines, stack }, result, prevLines);
5839
6739
  }
@@ -5866,6 +6766,63 @@
5866
6766
  await changeHeadingDepthsInSpan(els, recs, dir);
5867
6767
  }
5868
6768
 
6769
+ // v3.1.0 §4: the toolbar's ⇥ / ⇤ buttons. A ZERO-ARGUMENT commit wrapper,
6770
+ // placed beside tabSelection(dir) above because it is the same shape of
6771
+ // thing: a gesture that is not a keystroke, driving a mutation whose only
6772
+ // existing entry point wanted a KeyboardEvent.
6773
+ //
6774
+ // Why this function has to exist at all: indentListItem()/outdentListItem()
6775
+ // (further up) only ever write `data-indent` on the DOM and return a
6776
+ // boolean. They never touch `lines`, never push an undo op, never
6777
+ // re-render. Wiring a toolbar button straight to either of them produces a
6778
+ // list that visibly re-indents and then snaps back on the next render, with
6779
+ // the file byte-identical throughout. Everything that makes Tab a real edit
6780
+ // lives in handleLiKeydown()'s Tab branch — the run-wide gate, the mutation
6781
+ // under mutateListRun()'s focusout suppression, the indent clamp, the
6782
+ // bystander carry-over and the one-op commit — so that is what is
6783
+ // reproduced here, in the same order, with `e.shiftKey` replaced by `dir`.
6784
+ //
6785
+ // `caretLiBlock()` is zero-argument (it reads window.getSelection()), which
6786
+ // is what makes an editEl-free wrapper possible: the toolbar has no event
6787
+ // target inside the run, only a live caret — and the caret is the right
6788
+ // answer anyway, since a run has one editable surface per item.
6789
+ //
6790
+ // dir > 0 indents, dir < 0 outdents.
6791
+ async function indentCaretLi(dir) {
6792
+ const li = caretLiBlock();
6793
+ if (!li) return;
6794
+ let run = listRunOf(li);
6795
+ if (!run.length) return;
6796
+ // `columnOnly`: an indent change rewrites nothing but leading columns, so
6797
+ // a hard-wrapped item is allowed to be the target — same reading, and the
6798
+ // same measurement, as the Tab branch this mirrors.
6799
+ if (!listRunSupportsStructuralEdit(run, li, { columnOnly: true })) {
6800
+ refuseStructuralListEdit();
6801
+ return;
6802
+ }
6803
+ const oldIndent = Number(li.getAttribute('data-indent')) || 0;
6804
+ const changed = mutateListRun(() => {
6805
+ if (!(dir < 0 ? outdentListItem(li) : indentListItem(li))) return false;
6806
+ // v3.0.1, carried over VERBATIM and deliberately: the clamp must read a
6807
+ // FRESH listRunOf(li) — taken after the write above — not the
6808
+ // pre-mutation `run` captured outside. indentListItem() can move a
6809
+ // rule-(d) list-start item whose pre-mutation run is `[li]` alone; with
6810
+ // no anchor in the span, applyIndentClamp()'s rule 1 has nothing to
6811
+ // bound the new indent against and clamps it straight back to 0,
6812
+ // silently undoing the write. See handleLiKeydown()'s own copy of this
6813
+ // note for the full derivation.
6814
+ applyIndentClamp(listRunOf(li), li, oldIndent);
6815
+ return true;
6816
+ });
6817
+ if (!changed) return; // at the boundary: nothing mutated, nothing committed
6818
+ run = listRunOf(li);
6819
+ // Column-only: nothing's CONTENT moved, so every block in the span — the
6820
+ // target included — is a bystander whose source bytes come back
6821
+ // untouched. Built once and shared with runLineOfBlock() below.
6822
+ const carry = bystanderCarryOver(run, null);
6823
+ await commitListStructure(run, runLineOfBlock(run, li, carry), true, { carryOver: carry });
6824
+ }
6825
+
5869
6826
  // §3.6's 「Delete 整批刪」, whose 2026-08-31 ruling is that it goes through
5870
6827
  // the SAME batch path as the ⠿ menu's 刪除 and that no second deletion path
5871
6828
  // is written. deleteBlockViaGutter() already resolves §3.3's membership for
@@ -8313,7 +9270,12 @@
8313
9270
  // (blockSelection)` guard is what stops this single-block gesture from
8314
9271
  // INVENTING a selection when none was standing.
8315
9272
  declareCollapse(result.newRange);
8316
- const okRender = await safeRerenderAll();
9273
+ const editRange = result.op
9274
+ ? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
9275
+ endLine: Math.max(result.op.startLine - 1, result.op.endLine),
9276
+ delta: result.op.after.length - result.op.before.length }
9277
+ : undefined;
9278
+ const okRender = await safeRerenderAll(editRange);
8317
9279
  if (!okRender) {
8318
9280
  lines = rollbackFailedRender({ lines, stack }, result, prevLines);
8319
9281
  }
@@ -8912,16 +9874,40 @@
8912
9874
  // same way activateCell() used to — brief: "wire your cell edit root the
8913
9875
  // same way paragraph editing does (reuse, don't fork)").
8914
9876
  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;
9877
+ // v3.1.0: the fixed toolbar's five inline-format buttons enable/disable on
9878
+ // exactly the same question this listener already answers ("is there a
9879
+ // non-collapsed selection inside `selToolbarEditEl`?"), so it piggybacks
9880
+ // here instead of attaching a SECOND document-level selectionchange
9881
+ // listener.
9882
+ //
9883
+ // That is not just tidiness. test/editor-client-runtime.test.js
9884
+ // instruments document.addEventListener for this exact type and asserts
9885
+ // the live count is 1 during a session and 0 after — the property being
9886
+ // that a burst's listener is removed on rerender and never stacks. A
9887
+ // permanent second listener would not leak or stack, but it would move
9888
+ // that counter's baseline and force three numeric assertions plus a
9889
+ // waitForFunction(count === 0) to be rewritten around it, weakening a
9890
+ // leak guard to accommodate a convenience. Reusing this one keeps the
9891
+ // guard exactly as written, and is also strictly more correct: with no
9892
+ // burst open `selToolbarEditEl` is null, so `hasSelection` is false —
9893
+ // which is precisely when this listener is not attached.
9894
+ try {
9895
+ const selNow = window.getSelection();
9896
+ if (!selNow || selNow.rangeCount === 0 || selNow.isCollapsed) { hideSelToolbar(); return; }
9897
+ const range = selNow.getRangeAt(0);
9898
+ if (!selToolbarEditEl ||
9899
+ !selToolbarEditEl.contains(range.startContainer) ||
9900
+ !selToolbarEditEl.contains(range.endContainer)) {
9901
+ hideSelToolbar();
9902
+ return;
9903
+ }
9904
+ positionSelToolbar(range);
9905
+ } finally {
9906
+ // In a `finally` so every early return above still refreshes the bar —
9907
+ // the collapse-to-caret exits are exactly the ones that must turn the
9908
+ // mark buttons back off.
9909
+ updateToolbar();
8923
9910
  }
8924
- positionSelToolbar(range);
8925
9911
  }
8926
9912
 
8927
9913
  // Nearest ancestor of `node` (inclusive) with tagName `tag`, stopping at
@@ -9413,7 +10399,28 @@
9413
10399
  // DECLARED rather than cleared outright so the rollback below leaves a
9414
10400
  // standing selection alone when the render fails.
9415
10401
  declareSelectionRange(null);
9416
- const ok = await safeRerenderAll();
10402
+ // v3.2.0: UndoStack.undo()/discardTop() reverse the op when applying it but
10403
+ // return the unmodified FORWARD op, so the range actually rewritten is
10404
+ // [op.startLine, op.startLine + op.after.length - 1] and the delta is the
10405
+ // mirror of the forward one. Using op.endLine here would name the opposite
10406
+ // direction — the span the op wrote on the way FORWARD, not the one this
10407
+ // reversal just rewrote.
10408
+ //
10409
+ // Final review item 4 — what naming it wrongly would cost today: nothing.
10410
+ // Per 決議 4(b) `editRange` is ADVISORY. patchmap() destructures
10411
+ // oldBlocks/newBlocks/oldParts/newParts and never reads it; the kept
10412
+ // prefix/suffix is the longest common run of the two PART arrays, and
10413
+ // seeding it from an op's line range only ever made the answer worse
10414
+ // (measured in 決議 4(b): seeded kept 0 of 5, pure-parts kept 4 of 5).
10415
+ // Its one client-side use is the truthiness gate in applyRenderResult().
10416
+ // So the direction changes no plan — this is simply the correct value for
10417
+ // whenever something does start reading it.
10418
+ const editRange = r.op
10419
+ ? { startLine: r.op.startLine,
10420
+ endLine: r.op.startLine + r.op.after.length - 1,
10421
+ delta: r.op.before.length - r.op.after.length }
10422
+ : undefined;
10423
+ const ok = await safeRerenderAll(editRange);
9417
10424
  if (!ok) {
9418
10425
  // Reverse the undo attempt: push the op back and restore `lines` to
9419
10426
  // what was on screen before this undo was requested.
@@ -9429,7 +10436,15 @@
9429
10436
  if (!r) return;
9430
10437
  lines = r.lines;
9431
10438
  declareSelectionRange(null); // §4.4, same as undo() above
9432
- const ok = await safeRerenderAll();
10439
+ // redo() is NOT reversed — it goes through
10440
+ // replaceLines(lines, op.startLine, op.endLine, op.after), so the
10441
+ // ordinary forward formula applies here, unlike undo() above.
10442
+ const editRange = r.op
10443
+ ? { startLine: Math.min(r.op.startLine, r.op.endLine + 1),
10444
+ endLine: Math.max(r.op.startLine - 1, r.op.endLine),
10445
+ delta: r.op.after.length - r.op.before.length }
10446
+ : undefined;
10447
+ const ok = await safeRerenderAll(editRange);
9433
10448
  if (!ok) {
9434
10449
  // Reverse the redo attempt: pop the op back off and restore `lines`.
9435
10450
  const rollback = stack.undo(lines);
@@ -9437,6 +10452,806 @@
9437
10452
  }
9438
10453
  }
9439
10454
 
10455
+ // ── v3.1.0 §4: the always-visible editing toolbar ──────────────────────
10456
+ // The version's headline feature: before this there was no toolbar at all,
10457
+ // and every editing affordance was hidden behind a hover gutter, a caret
10458
+ // position or a text selection. window.md2docToolbarModel (injected by
10459
+ // server.js, built and reviewed as lib/editor/toolbar-model.js) owns the
10460
+ // 22-button roster, the group order that decides separator placement, and
10461
+ // the whole active/disabled derivation; everything below is mounting and
10462
+ // wiring. Nothing here re-derives a button's state by hand — a second
10463
+ // opinion about when ⇥ is legal is exactly what the pure-module split
10464
+ // exists to prevent.
10465
+ //
10466
+ // EVERY existing entry point stays: the ⠿ menu, the + menu and the
10467
+ // floating selection toolbar are untouched. This is one more road to the
10468
+ // same operations, not a replacement for any of them.
10469
+ let toolbarEl = null;
10470
+ let toolbarBtns = null; // { [buttonId]: HTMLButtonElement }
10471
+ let toolbarMenuEl = null; // the H▾ dropdown panel, or null when closed
10472
+ let toolbarBlockEl = null;
10473
+ let toolbarStateSig = '';
10474
+ // 追加 4's three-state document mode: 'edit' | 'source' | 'preview'.
10475
+ // docsource.js owns the cycle order and the lines[]<->string conversion.
10476
+ let docMode = 'edit';
10477
+ let sourceTextarea = null;
10478
+ let sourceBaseLines = null;
10479
+ // 追加 2: Ctrl+Shift+V arms a plain-text-only paste for the NEXT paste
10480
+ // event. A ClipboardEvent carries no modifier state of its own, so the
10481
+ // intent has to be captured on the keystroke that produced it.
10482
+ let plainPasteArmed = false;
10483
+
10484
+ const TOOLBAR_UNSUPPORTED_IMAGE_MESSAGE = '只接受 PNG / JPEG / GIF / WebP 圖片';
10485
+ const TOOLBAR_NO_ANCHOR_MESSAGE = '找不到可以插入圖片的位置';
10486
+
10487
+ // Extra visual classes for the three inline buttons whose own glyph is the
10488
+ // affordance (B is bold, I is italic, S is struck through) — the same
10489
+ // idiom .ed-seltb-b/-i/-s use.
10490
+ const TOOLBAR_BTN_CLASS = {
10491
+ bold: 'ed-toolbar-b', italic: 'ed-toolbar-i', strike: 'ed-toolbar-s',
10492
+ };
10493
+
10494
+ // The buttons whose model state can ever report `active: true` — i.e. the
10495
+ // real toggles, and the only ones that may carry aria-pressed. Every other
10496
+ // button is a command, not a toggle.
10497
+ // v3.2.0: derived, not mirrored. A seventh toggle added to toolbar-model.js
10498
+ // used to need a matching edit here, and forgetting it made aria-pressed lie.
10499
+ const TOOLBAR_TOGGLE_IDS = new Set(
10500
+ (toolbarModel ? toolbarModel.BUTTONS : []).filter((b) => b.toggle).map((b) => b.id));
10501
+
10502
+ // The block the bar currently reports on, or null. `null` is the model's
10503
+ // documented no-block state (undo/redo/outline/preview live, everything
10504
+ // else greyed), which is exactly what rerenderAll()'s teardown wants — see
10505
+ // resetToolbarBlock() below.
10506
+ function liveToolbarBlock() {
10507
+ if (toolbarBlockEl && document.body.contains(toolbarBlockEl)) return toolbarBlockEl;
10508
+ toolbarBlockEl = null;
10509
+ return null;
10510
+ }
10511
+
10512
+ // Called from rerenderAll()'s belt-and-braces teardown. A detached node's
10513
+ // getAttribute() still answers, so a tracked block that survived the
10514
+ // .content swap would keep the bar reporting a block no click can reach.
10515
+ function resetToolbarBlock() {
10516
+ toolbarBlockEl = null;
10517
+ closeToolbarMenu();
10518
+ // A render that lands while the document is in preview mode re-arms every
10519
+ // surface as editable (armEditables() has no notion of modes), so the
10520
+ // mode's one real effect has to be re-applied on the fresh DOM.
10521
+ if (docMode === 'preview') applyPreviewEditability(false);
10522
+ updateToolbar();
10523
+ }
10524
+
10525
+ // Final review item 5: the EXACT predicate applyMarkToggle() and
10526
+ // applyLinkToggle() gate on — all four of their conditions, not three.
10527
+ // Kept as one function so the toolbar's enablement and the actions' own
10528
+ // refusal cannot drift apart: whenever this says true, both actions run.
10529
+ //
10530
+ // The fourth condition (containment) is the one `hasSelection` used to
10531
+ // omit, and it is reachable: a drag-select that STARTS inside an open
10532
+ // burst and ENDS in the next block leaves the selection non-collapsed with
10533
+ // `selToolbarEditEl` still set. The floating selection toolbar already
10534
+ // asks this same question of itself and correctly hides (see its
10535
+ // selectionchange listener above), but updateToolbar() still ran and lit
10536
+ // the five inline-format buttons over a selection every one of them
10537
+ // silently refuses — precisely the dead-button failure this version's
10538
+ // enablement wiring exists to remove.
10539
+ function hasFormattableSelection(sel) {
10540
+ if (!selToolbarEditEl || !sel || sel.rangeCount === 0 || sel.isCollapsed) return false;
10541
+ const range = sel.getRangeAt(0);
10542
+ if (range.collapsed) return false;
10543
+ return selToolbarEditEl.contains(range.startContainer) &&
10544
+ selToolbarEditEl.contains(range.endContainer);
10545
+ }
10546
+
10547
+ // ctx for toolbarModel.deriveState(). Every field is read off the DOM /
10548
+ // `lines` at call time; nothing is cached, so a stale value cannot outlive
10549
+ // a render.
10550
+ function toolbarContext() {
10551
+ const blockEl = liveToolbarBlock();
10552
+ const blockType = blockEl ? blockEl.getAttribute('data-block-type') : null;
10553
+ let headingDepth = 1;
10554
+ if (blockType === 'heading') {
10555
+ const rec = blockRecOf(blockEl);
10556
+ if (rec && rec.endLine >= rec.startLine) headingDepth = headingDepthOf(lines[rec.startLine - 1]);
10557
+ }
10558
+ const sel = typeof window.getSelection === 'function' ? window.getSelection() : null;
10559
+ return {
10560
+ blockType: blockType,
10561
+ indent: blockEl ? (Number(blockEl.getAttribute('data-indent')) || 0) : 0,
10562
+ headingDepth: headingDepth,
10563
+ inList: blockType === 'li',
10564
+ listOrdered: !!blockEl && blockEl.getAttribute('data-list-type') === 'ol',
10565
+ // The mark buttons act through applyMarkToggle()/applyLinkToggle();
10566
+ // hasFormattableSelection() (just above) is their refusal test spelled
10567
+ // once, rather than a looser "is anything selected anywhere on the page".
10568
+ hasSelection: hasFormattableSelection(sel),
10569
+ mode: docMode,
10570
+ };
10571
+ }
10572
+
10573
+ function updateToolbar() {
10574
+ if (!toolbarEl || !toolbarBtns || !toolbarModel) return;
10575
+ const state = toolbarModel.deriveState(toolbarContext());
10576
+ // selectionchange fires on every caret move, so the state is diffed
10577
+ // before any DOM is written — 22 attribute writes per keystroke is a
10578
+ // cost with no matching benefit.
10579
+ const sig = JSON.stringify(state);
10580
+ if (sig === toolbarStateSig) return;
10581
+ toolbarStateSig = sig;
10582
+ Object.keys(toolbarBtns).forEach((id) => {
10583
+ const st = state[id];
10584
+ const btn = toolbarBtns[id];
10585
+ if (!st || !btn) return;
10586
+ btn.disabled = !!st.disabled;
10587
+ // aria-pressed ONLY on the buttons that can actually be pressed-in.
10588
+ // Writing it to all 22 made a screen reader announce `undo`, `image`
10589
+ // and `table` as toggle buttons stuck in the "not pressed" state,
10590
+ // which is a lie about what they do.
10591
+ if (TOOLBAR_TOGGLE_IDS.has(id)) {
10592
+ btn.setAttribute('aria-pressed', st.active ? 'true' : 'false');
10593
+ }
10594
+ // Only `headings` carries a label (H / H1..H6); every other button
10595
+ // keeps the glyph it was built with.
10596
+ if (typeof st.label === 'string') btn.textContent = st.label;
10597
+ });
10598
+ }
10599
+
10600
+ function buildToolbar() {
10601
+ const bar = document.createElement('div');
10602
+ bar.className = 'ed-toolbar';
10603
+ bar.setAttribute('role', 'toolbar');
10604
+ bar.setAttribute('aria-label', '編輯工具列');
10605
+ const btns = {};
10606
+ let prevGroup = null;
10607
+ toolbarModel.BUTTONS.forEach((def) => {
10608
+ // GROUPS order doubles as separator placement: one divider wherever
10609
+ // consecutive buttons change group.
10610
+ if (prevGroup !== null && def.group !== prevGroup) {
10611
+ const sep = document.createElement('span');
10612
+ sep.className = 'ed-toolbar-sep';
10613
+ bar.appendChild(sep);
10614
+ }
10615
+ prevGroup = def.group;
10616
+ const btn = document.createElement('button');
10617
+ btn.type = 'button';
10618
+ btn.className = 'ed-toolbar-btn' +
10619
+ (TOOLBAR_BTN_CLASS[def.id] ? ' ' + TOOLBAR_BTN_CLASS[def.id] : '');
10620
+ btn.textContent = def.icon;
10621
+ btn.title = def.title;
10622
+ btn.setAttribute('aria-label', def.title);
10623
+ btn.setAttribute('data-ed-tb', def.id);
10624
+ // Not a tab stop, same as the ⠿ / + gutter buttons: this is chrome,
10625
+ // and the Tab key belongs to the document's own structure.
10626
+ btn.setAttribute('tabindex', '-1');
10627
+ // ⚠ LOAD-BEARING, not a nicety. Without it the five inline-format
10628
+ // buttons are dead on arrival: a <button> taking focus on mousedown
10629
+ // fires the armed surface's focusout, which commits and re-renders,
10630
+ // so applyMarkToggle()'s `selToolbarEditEl` is null and the selection
10631
+ // is gone before the click handler ever runs. buildSelToolbar() and
10632
+ // both table grip builders defend themselves the identical way.
10633
+ btn.addEventListener('mousedown', (e) => e.preventDefault());
10634
+ btn.addEventListener('click', (e) => {
10635
+ e.stopPropagation();
10636
+ Promise.resolve(runToolbarAction(def.id, btn)).catch(() => {}).then(updateToolbar);
10637
+ });
10638
+ bar.appendChild(btn);
10639
+ btns[def.id] = btn;
10640
+ });
10641
+ toolbarBtns = btns;
10642
+ // v3.1.0 的 spec 承諾「元素存在但無內容,§3 上線時填入、不需要重排版面」,
10643
+ // 但元素從未被建立。這裡把承諾兌現:佔好寬度、無內容。
10644
+ const statusEl = document.createElement('span');
10645
+ statusEl.className = 'ed-toolbar-status';
10646
+ bar.appendChild(statusEl);
10647
+ return bar;
10648
+ }
10649
+
10650
+ async function runToolbarAction(id, btnEl) {
10651
+ const blockEl = liveToolbarBlock();
10652
+ switch (id) {
10653
+ // ⚠ The BROWSER-side zero-argument undo()/redo(), never `stack.undo` /
10654
+ // `stack.redo`. Those are the pure core's helpers (they are what
10655
+ // rollbackFailedRender() reverses an op with) and calling them from
10656
+ // here would skip switchAwayFrom() and safeRerenderAll() — the exact
10657
+ // shape this file records as having produced a silent total lockout
10658
+ // recoverable only by reloading the page.
10659
+ case 'undo': return undo();
10660
+ case 'redo': return redo();
10661
+ case 'headings': return toggleToolbarHeadingMenu(btnEl);
10662
+ // The five conversion targets are convert-md.js's own ids, so the
10663
+ // gesture goes through convertBlockViaMenu()'s §3.7 / §4.3 gates
10664
+ // (withheld types, mixed spans, run support) exactly like the ⠿
10665
+ // submenu's items do.
10666
+ case 'quote': return blockEl ? convertBlockViaMenu(blockEl, 'quote') : undefined;
10667
+ case 'code': return blockEl ? convertBlockViaMenu(blockEl, 'code') : undefined;
10668
+ case 'list': return blockEl ? convertBlockViaMenu(blockEl, 'ul') : undefined;
10669
+ case 'ordered-list': return blockEl ? convertBlockViaMenu(blockEl, 'ol') : undefined;
10670
+ case 'check': return blockEl ? convertBlockViaMenu(blockEl, 'task') : undefined;
10671
+ case 'bold': return applyMarkToggle('STRONG');
10672
+ case 'italic': return applyMarkToggle('EM');
10673
+ case 'strike': return applyMarkToggle('DEL');
10674
+ case 'inline-code': return applyMarkToggle('CODE');
10675
+ case 'link': return applyLinkToggle();
10676
+ // NOT indentListItem()/outdentListItem(): those only write data-indent
10677
+ // on the DOM and never commit. See indentCaretLi()'s own comment.
10678
+ case 'outdent': return indentCaretLi(-1);
10679
+ case 'indent': return indentCaretLi(1);
10680
+ case 'table': return blockEl ? insertBlockBelow(blockEl, 'table') : undefined;
10681
+ case 'line': return blockEl ? insertBlockBelow(blockEl, 'line') : undefined;
10682
+ case 'insert-after': return blockEl ? insertBlockBelow(blockEl, 'paragraph') : undefined;
10683
+ case 'insert-before': return blockEl ? insertBlockAbove(blockEl) : undefined;
10684
+ case 'image': return pickAndInsertImage();
10685
+ case 'outline': return toggleOutlineSidebar();
10686
+ case 'preview': return cycleDocMode();
10687
+ default: return undefined;
10688
+ }
10689
+ }
10690
+
10691
+ // ── headings ▾ ─────────────────────────────────────────────────────────
10692
+ function closeToolbarMenu() {
10693
+ if (toolbarMenuEl) { toolbarMenuEl.remove(); toolbarMenuEl = null; }
10694
+ }
10695
+
10696
+ function toggleToolbarHeadingMenu(btnEl) {
10697
+ if (toolbarMenuEl) { closeToolbarMenu(); return; }
10698
+ const menu = document.createElement('div');
10699
+ menu.className = 'ed-toolbar-menu';
10700
+ [1, 2, 3, 4, 5, 6].forEach((n) => {
10701
+ const item = document.createElement('button');
10702
+ item.type = 'button';
10703
+ item.className = 'ed-toolbar-menu-btn';
10704
+ item.textContent = '標題 ' + n;
10705
+ item.setAttribute('tabindex', '-1');
10706
+ item.addEventListener('mousedown', (e) => e.preventDefault());
10707
+ item.addEventListener('click', (e) => {
10708
+ e.stopPropagation();
10709
+ closeToolbarMenu();
10710
+ Promise.resolve(applyHeadingLevel(n)).catch(() => {}).then(updateToolbar);
10711
+ });
10712
+ menu.appendChild(item);
10713
+ });
10714
+ document.body.appendChild(menu);
10715
+ const r = btnEl.getBoundingClientRect();
10716
+ menu.style.top = (r.bottom + 4) + 'px';
10717
+ menu.style.left = Math.max(4, Math.min(r.left, window.innerWidth - 140)) + 'px';
10718
+ toolbarMenuEl = menu;
10719
+ }
10720
+
10721
+ // changeHeadingDepth(blockEl, delta) takes a RELATIVE delta and has no
10722
+ // blockType guard, while headingDepthOf() answers 1 for any non-heading
10723
+ // line — so calling it on a paragraph silently prefixes '## ' and turns it
10724
+ // into an H2 while bypassing convertBlockViaMenu()'s gates entirely. Both
10725
+ // halves of that are handled here: a non-heading is CONVERTED through the
10726
+ // gate (convert-md.js's 'h1'..'h6' targets already carry the level), and a
10727
+ // heading gets a delta computed against its own current depth.
10728
+ async function applyHeadingLevel(n) {
10729
+ const blockEl = liveToolbarBlock();
10730
+ if (!blockEl) return;
10731
+ if (blockEl.getAttribute('data-block-type') !== 'heading') {
10732
+ await convertBlockViaMenu(blockEl, 'h' + n);
10733
+ return;
10734
+ }
10735
+ const rec = blockRecOf(blockEl);
10736
+ if (!rec || rec.endLine < rec.startLine) return;
10737
+ const delta = n - headingDepthOf(lines[rec.startLine - 1]);
10738
+ if (delta === 0) return;
10739
+ await changeHeadingDepth(blockEl, delta);
10740
+ }
10741
+
10742
+ // ── insert above ───────────────────────────────────────────────────────
10743
+ // commitBlockInsertion() only ever inserts AFTER a block's range, so
10744
+ // "insert above" is expressed the way this file already expresses it (see
10745
+ // convertEmptyTopLevelLiToParagraph()'s own preceding-block lookup):
10746
+ // anchor on the block before this one. That keeps one insertion path and
10747
+ // one set of gates rather than teaching the pure core a direction.
10748
+ async function insertBlockAbove(blockEl) {
10749
+ const rec = blockRecOf(blockEl);
10750
+ if (!rec) return;
10751
+ const preceding = blocks
10752
+ .filter((b) => b.id !== rec.id && b.endLine < rec.startLine && b.endLine >= b.startLine)
10753
+ .pop();
10754
+ if (preceding) {
10755
+ const anchorEl = blockElAtLine(preceding.startLine);
10756
+ if (anchorEl) { await insertBlockBelow(anchorEl, 'paragraph'); return; }
10757
+ }
10758
+ // Nothing above to anchor on — see insertParagraphAtTop(). Refusing here
10759
+ // instead would leave the document's FIRST block permanently impossible
10760
+ // to precede: the ⠿ and + menus both insert below, so this button is the
10761
+ // only affordance that reaches that position at all.
10762
+ await insertParagraphAtTop(blockEl);
10763
+ }
10764
+
10765
+ // The document's first block has no predecessor to insert BELOW, and
10766
+ // commitBlockInsertion() only ever inserts after a block's range. Rather
10767
+ // than teach the pure core a direction (a change under byte-stability and
10768
+ // roundtrip fixtures), the insert is expressed as a REPLACE of that
10769
+ // block's own first source line by [skeleton, '', that same line] — the
10770
+ // same commitRangeEdit()/replaceLines() pipeline as every other edit,
10771
+ // therefore one undo op, and no new primitive.
10772
+ //
10773
+ // The switchAwayFrom() + re-resolve preamble is insertBlockBelow()'s,
10774
+ // for its reasons: the resolution can commit a dirty burst, whose
10775
+ // rerenderAll() detaches the very node this was called with.
10776
+ async function insertParagraphAtTop(blockEl) {
10777
+ const identity = captureBlockIdentity(blockEl);
10778
+ const selfSession = ownsOpenSession(blockEl);
10779
+ const ok = await switchAwayFrom();
10780
+ if (!ok) return;
10781
+ let liveBlockEl = blockEl;
10782
+ if (!document.body.contains(blockEl)) {
10783
+ liveBlockEl = reresolveBlockEl(identity) ||
10784
+ (selfSession ? reresolveBlockElAfterSelfCommit(identity) : null);
10785
+ if (!liveBlockEl) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
10786
+ }
10787
+ const rec = blockRecOf(liveBlockEl);
10788
+ // A block that owns no source line has an inverted range, and there is
10789
+ // no line to splice in front of.
10790
+ if (!rec || rec.endLine < rec.startLine) {
10791
+ refuseStructuralListEdit(NO_SOURCE_LINE_INSERT_MESSAGE);
10792
+ return;
10793
+ }
10794
+ const at = rec.startLine;
10795
+ const newText = BLOCK_SKELETONS.paragraph.concat(['', lines[at - 1]]).join('\n');
10796
+ const result = commitRangeEdit({ lines, blocks, stack }, at, at, newText);
10797
+ if (result.op === null) return;
10798
+ const prevLines = lines;
10799
+ lines = result.lines;
10800
+ const editRange = result.op
10801
+ ? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
10802
+ endLine: Math.max(result.op.startLine - 1, result.op.endLine),
10803
+ delta: result.op.after.length - result.op.before.length }
10804
+ : undefined;
10805
+ const okRender = await safeRerenderAll(editRange);
10806
+ if (!okRender) {
10807
+ lines = rollbackFailedRender({ lines, stack }, result, prevLines);
10808
+ return;
10809
+ }
10810
+ // The new paragraph starts exactly where the old first line did — same
10811
+ // pristine + focus contract insertBlockBelow() gives a skeleton insert.
10812
+ const target = blocks.find((b) => b.startLine === at);
10813
+ if (target) pristineInsert = { blockId: target.id };
10814
+ await focusInsertedBlock(at, 'paragraph');
10815
+ }
10816
+
10817
+ // ── 大綱 ───────────────────────────────────────────────────────────────
10818
+ // TWO attributes, chosen by viewport, and that is not an accident.
10819
+ //
10820
+ // `body[data-sidebar-open]` is the reader runtime's own attribute (see
10821
+ // lib/md2doc.js's setSidebarOpen()) and it drives the MOBILE drawer. The
10822
+ // first cut of this function reused it on desktop too, on the premise that
10823
+ // nothing outside the max-width:1080px block read it. That premise was
10824
+ // false, and the failure was severe: `body[data-sidebar-open]
10825
+ // .sidebar-scrim { display: block; }` sits at BASE scope, so on a desktop
10826
+ // viewport the button painted a full-viewport 35%-black scrim over the
10827
+ // whole page (position:fixed; inset:0; z-index:98) — the document went
10828
+ // grey and unclickable, and the scrim's own click handler then called
10829
+ // setSidebarOpen(false), so the first click anywhere undid it and the
10830
+ // whole gesture read as a flicker.
10831
+ //
10832
+ // Desktop therefore gets a PRIVATE attribute nothing else reads,
10833
+ // `data-ed-outline-hidden`, matched by an edit-mode-only rule in
10834
+ // lib/md2doc.js. Named for the non-default state because the desktop
10835
+ // outline is a sticky column shown by default, which is also what lets
10836
+ // aria-expanded below stay truthful on both viewports.
10837
+ function toggleOutlineSidebar() {
10838
+ // The same 1080px breakpoint the stylesheet's own drawer block uses.
10839
+ const mobile = typeof window.matchMedia === 'function' &&
10840
+ window.matchMedia('(max-width: 1080px)').matches;
10841
+ let expanded;
10842
+ if (mobile) {
10843
+ expanded = !document.body.hasAttribute('data-sidebar-open');
10844
+ if (expanded) document.body.setAttribute('data-sidebar-open', '');
10845
+ else document.body.removeAttribute('data-sidebar-open');
10846
+ } else {
10847
+ expanded = document.body.hasAttribute('data-ed-outline-hidden');
10848
+ if (expanded) document.body.removeAttribute('data-ed-outline-hidden');
10849
+ else document.body.setAttribute('data-ed-outline-hidden', '');
10850
+ }
10851
+ const toggle = document.getElementById('sidebar-toggle');
10852
+ if (toggle) toggle.setAttribute('aria-expanded', expanded ? 'true' : 'false');
10853
+ }
10854
+
10855
+ // ── 追加 3: images ─────────────────────────────────────────────────────
10856
+ function lastBlockEl() {
10857
+ const all = contentEl.querySelectorAll('.ed-block');
10858
+ return all.length ? all[all.length - 1] : null;
10859
+ }
10860
+
10861
+ function blobToBase64(blob) {
10862
+ return new Promise((resolve, reject) => {
10863
+ const fr = new FileReader();
10864
+ fr.onerror = () => reject(new Error('could not read the image'));
10865
+ fr.onload = () => {
10866
+ // readAsDataURL gives 'data:<mime>;base64,<payload>' — the endpoint
10867
+ // wants the payload alone, and derives the extension from the mime
10868
+ // it is told, never from this prefix.
10869
+ const s = String(fr.result || '');
10870
+ const comma = s.indexOf(',');
10871
+ resolve(comma === -1 ? '' : s.slice(comma + 1));
10872
+ };
10873
+ fr.readAsDataURL(blob);
10874
+ });
10875
+ }
10876
+
10877
+ // Uploads one blob and answers its 'assets/<name>' path, or null (a banner
10878
+ // is already showing by then). The MIME whitelist is asked BEFORE the round
10879
+ // trip so an unsupported drop is refused instantly and locally — the server
10880
+ // asks the same question again, and it is the server's answer that is
10881
+ // authoritative.
10882
+ async function uploadImageBlob(blob) {
10883
+ if (!blob) return null;
10884
+ if (!assetLib || !assetLib.extFor(blob.type)) {
10885
+ showBanner(TOOLBAR_UNSUPPORTED_IMAGE_MESSAGE, null, null);
10886
+ return null;
10887
+ }
10888
+ let data;
10889
+ try {
10890
+ data = await blobToBase64(blob);
10891
+ } catch (e) {
10892
+ showBanner('圖片讀取失敗 — ' + describeFailure(e), null, null);
10893
+ return null;
10894
+ }
10895
+ let res;
10896
+ try {
10897
+ res = await fetch('/api/asset', {
10898
+ method: 'POST', headers: { 'content-type': 'application/json' },
10899
+ body: JSON.stringify({
10900
+ fileId: ED.fileId, mime: blob.type, name: blob.name || 'image', data: data,
10901
+ }),
10902
+ });
10903
+ } catch (e) {
10904
+ showBanner('圖片上傳失敗 — ' + describeFailure(e) + '。檔案未寫入。', null, null);
10905
+ return null;
10906
+ }
10907
+ if (!res.ok) {
10908
+ const reason = await describeHttpFailure(res);
10909
+ showBanner('圖片上傳失敗 — ' + reason + '。檔案未寫入。', null, null);
10910
+ return null;
10911
+ }
10912
+ let j;
10913
+ try { j = await res.json(); } catch (e) { j = null; }
10914
+ if (!j || typeof j.path !== 'string') {
10915
+ showBanner('圖片上傳失敗 — 伺服器回應格式不正確。', null, null);
10916
+ return null;
10917
+ }
10918
+ return j.path;
10919
+ }
10920
+
10921
+ // asset.js's sanitizeName() deliberately keeps spaces (and everything else
10922
+ // that is not a path separator or a drive colon) — a screenshot really is
10923
+ // called 'Screen Shot 2026-09-03 at 14.02.11.png'. A bare space, or a
10924
+ // parenthesis, inside an inline image destination ends the destination
10925
+ // early, so `![](assets/My Shot.png)` renders as literal text. Percent-
10926
+ // encoding exactly those characters fixes it in both directions: browsers
10927
+ // resolve the escape, and md2doc's own resolveAssetPath() decodes the
10928
+ // filePart before probing the disk (it also probes the raw form, so
10929
+ // non-ASCII characters are deliberately left alone rather than escaped).
10930
+ // '%' itself is encoded FIRST by being in the same character class, so a
10931
+ // filename that already contains one cannot be double-decoded.
10932
+ const TOOLBAR_MD_DEST_UNSAFE_RE = /[%\s()<>"']/g;
10933
+ function markdownImageDest(p) {
10934
+ return String(p == null ? '' : p).replace(TOOLBAR_MD_DEST_UNSAFE_RE, (c) => {
10935
+ const hex = c.charCodeAt(0).toString(16).toUpperCase();
10936
+ return '%' + (hex.length < 2 ? '0' + hex : hex);
10937
+ });
10938
+ }
10939
+
10940
+ // Uploads every image in `blobs` and lands them as ONE block insertion.
10941
+ // One insertion, not one per image: each insert re-renders and detaches the
10942
+ // anchor, so a loop would be inserting against a stale node from the second
10943
+ // image onward — and it would also cost one undo op per image for what the
10944
+ // user experienced as a single drop. Blank lines between the paths keep
10945
+ // them separate paragraphs instead of one paragraph of soft breaks.
10946
+ async function insertImages(blobs, anchorEl) {
10947
+ const paths = [];
10948
+ for (let i = 0; i < blobs.length; i += 1) {
10949
+ const p = await uploadImageBlob(blobs[i]);
10950
+ if (p) paths.push(p);
10951
+ }
10952
+ if (!paths.length) return;
10953
+ const target = (anchorEl && document.body.contains(anchorEl)) ? anchorEl : lastBlockEl();
10954
+ if (!target) { showBanner(TOOLBAR_NO_ANCHOR_MESSAGE, null, null); return; }
10955
+ const newLines = [];
10956
+ paths.forEach((p, i) => {
10957
+ if (i > 0) newLines.push('');
10958
+ newLines.push('![](' + markdownImageDest(p) + ')');
10959
+ });
10960
+ await insertBlockBelow(target, 'paragraph', { lines: newLines });
10961
+ }
10962
+
10963
+ function pickAndInsertImage() {
10964
+ const input = document.createElement('input');
10965
+ input.type = 'file';
10966
+ input.accept = 'image/png,image/jpeg,image/gif,image/webp';
10967
+ input.multiple = true;
10968
+ input.hidden = true;
10969
+ const anchorEl = liveToolbarBlock() || lastBlockEl();
10970
+ document.body.appendChild(input);
10971
+ input.addEventListener('change', () => {
10972
+ const files = input.files ? Array.prototype.slice.call(input.files) : [];
10973
+ input.remove();
10974
+ if (files.length) insertImages(files, anchorEl);
10975
+ });
10976
+ input.click();
10977
+ }
10978
+
10979
+ function dragCarriesFiles(e) {
10980
+ const dt = e.dataTransfer;
10981
+ if (!dt || !dt.types) return false;
10982
+ return Array.prototype.indexOf.call(dt.types, 'Files') !== -1;
10983
+ }
10984
+
10985
+ function wireImageDrop() {
10986
+ // ⚠ BOTH listeners are gated on `docMode === 'edit'`, and that gate is
10987
+ // load-bearing rather than tidy-minded.
10988
+ //
10989
+ // In SOURCE mode `enterSourceMode()` only sets `contentEl.hidden = true` —
10990
+ // every `.ed-block` is still in the DOM, so `lastBlockEl()` resolves and an
10991
+ // ungated drop ran the whole upload plus an `insertBlockBelow()` commit
10992
+ // behind the hidden content, against `lines`. The textarea the user is
10993
+ // looking at still held the PRE-drop source, and `leaveSourceMode()` then
10994
+ // commits `commitRangeEdit(1, lines.length, ta.value)` over the post-drop
10995
+ // `lines` — erasing the reference that was just inserted. MEASURED:
10996
+ // '# Doc\n\nBody para.\n' -> source mode -> drop x.png -> type EDITED ->
10997
+ // leave, and the file ends '# Doc\n\nBody para.\n\nEDITED\n' with
10998
+ // assets/x.png orphaned on disk. No error, no visible effect, and a stray
10999
+ // binary left in what is usually a git working tree.
11000
+ //
11001
+ // In PREVIEW mode the toolbar reports every editing button as disabled, so
11002
+ // the mode PRESENTS as read-only; a drop that silently wrote a file and
11003
+ // inserted a block would make that presentation a lie.
11004
+ document.addEventListener('dragover', (e) => {
11005
+ if (docMode !== 'edit') return;
11006
+ if (!dragCarriesFiles(e)) return;
11007
+ e.preventDefault();
11008
+ if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
11009
+ });
11010
+ document.addEventListener('drop', (e) => {
11011
+ if (docMode !== 'edit') return;
11012
+ if (!dragCarriesFiles(e)) return;
11013
+ const files = e.dataTransfer
11014
+ ? Array.prototype.slice.call(e.dataTransfer.files || [])
11015
+ : [];
11016
+ const images = files.filter((f) => !!(assetLib && assetLib.extFor(f.type)));
11017
+ if (!images.length) return; // not ours: let the page do whatever it would
11018
+ e.preventDefault();
11019
+ const anchorEl = (e.target && e.target.closest) ? e.target.closest('.ed-block') : null;
11020
+ insertImages(images, anchorEl || liveToolbarBlock() || lastBlockEl());
11021
+ });
11022
+ }
11023
+
11024
+ // ── 追加 2: paste ──────────────────────────────────────────────────────
11025
+ // Reads a real DataTransfer into the plain { 'mime/type': string | Blob }
11026
+ // shape paste-md.js's pickPayload() documents. Only ONE image flavour is
11027
+ // taken: pickPayload picks the first it finds anyway, and a clipboard can
11028
+ // carry several encodings of the same bitmap.
11029
+ function clipboardItemsOf(dt) {
11030
+ const items = {};
11031
+ if (!dt) return items;
11032
+ try { items['text/plain'] = dt.getData('text/plain') || ''; } catch (err) { items['text/plain'] = ''; }
11033
+ try { items['text/html'] = dt.getData('text/html') || ''; } catch (err) { items['text/html'] = ''; }
11034
+ const files = dt.files ? Array.prototype.slice.call(dt.files) : [];
11035
+ for (let i = 0; i < files.length; i += 1) {
11036
+ if (files[i] && String(files[i].type).indexOf('image/') === 0) {
11037
+ items[files[i].type] = files[i];
11038
+ break;
11039
+ }
11040
+ }
11041
+ return items;
11042
+ }
11043
+
11044
+ // A converted payload that is ONE line carrying no markdown syntax at all
11045
+ // is indistinguishable from the plain-text paste this editor has always
11046
+ // done — and that case (copy a word or a phrase out of a browser, paste it
11047
+ // mid sentence) must keep landing at the caret rather than becoming a new
11048
+ // block. Anything else is real structure and takes the source path.
11049
+ //
11050
+ // The test is split in two because markdown's characters are NOT all alike.
11051
+ // The first cut used one character class for both kinds and put '-', '#'
11052
+ // and '!' in it — which re-introduced exactly the regression this test
11053
+ // exists to prevent, since 'e-mail', 'state-of-the-art' and 'Hello!' are
11054
+ // ordinary prose that would have become new paragraph blocks. Those three
11055
+ // only begin a construct at LINE POSITION, so they belong in the
11056
+ // start-anchored test, not the anywhere test. ('![' is still caught: the
11057
+ // '[' is in the anywhere class.)
11058
+ const TOOLBAR_INLINE_UNSAFE_RE = /[*_`~\[\]<>|\\]/;
11059
+ const TOOLBAR_BLOCK_START_RE =
11060
+ /^(?: {4}|\t|#{1,6}(?:\s|$)|[-*+][ \t]|\d+[.)][ \t]|>|`{3}|~{3}|-{3}|={3})/;
11061
+ function isInlineSafePaste(md) {
11062
+ if (md === '' || md.indexOf('\n') !== -1) return false;
11063
+ if (TOOLBAR_INLINE_UNSAFE_RE.test(md)) return false;
11064
+ return !TOOLBAR_BLOCK_START_RE.test(md);
11065
+ }
11066
+
11067
+ function handleArmedPaste(dt, editEl) {
11068
+ const items = clipboardItemsOf(dt);
11069
+ const carriesSomething = Object.keys(items).some((k) => (
11070
+ typeof items[k] === 'string' ? items[k] !== '' : !!items[k]));
11071
+ // An EMPTY paste is not the paste the gesture asked for, and must not
11072
+ // consume the Ctrl+Shift+V arming. MEASURED in headless Chromium: the
11073
+ // Ctrl+Shift+V keystroke ITSELF dispatches a paste event carrying no
11074
+ // flavours at all (`types: []`, `getData('text/plain') === ''`) before
11075
+ // any real clipboard content arrives. Consuming `plainPasteArmed` there
11076
+ // disarmed the very paste the user had just asked to be plain — the
11077
+ // gesture silently degraded to a normal rich paste. Returning early also
11078
+ // saves an insertTextAtCaret('') that would snapshot the burst for a
11079
+ // no-op edit.
11080
+ if (!carriesSomething) return;
11081
+ const plainOnly = plainPasteArmed;
11082
+ plainPasteArmed = false;
11083
+ const payload = pasteMd
11084
+ ? pasteMd.pickPayload(items, plainOnly)
11085
+ : { kind: 'text', value: dt ? dt.getData('text/plain') : '' };
11086
+ if (payload.kind === 'image') {
11087
+ insertImages([payload.blob], editEl.closest('.ed-block'));
11088
+ return;
11089
+ }
11090
+ if (payload.kind === 'markdown') {
11091
+ const md = String(payload.value == null ? '' : payload.value).replace(/\r\n?/g, '\n');
11092
+ if (md.trim() === '') return;
11093
+ if (isInlineSafePaste(md)) {
11094
+ insertTextAtCaret(md);
11095
+ snapBurstIfActive(editEl, 'paste');
11096
+ return;
11097
+ }
11098
+ // Landed as SOURCE below the caret's block, never spliced into the
11099
+ // WYSIWYG DOM: that is what guarantees it passes through
11100
+ // commitBlockInsertion()/replaceLines() and therefore through
11101
+ // serializeBlocks()'s existing gates, instead of arriving as a pile of
11102
+ // foreign nodes the serializer would have to make sense of afterwards.
11103
+ insertBlockBelow(editEl.closest('.ed-block'), 'paragraph', { lines: md.split('\n') });
11104
+ return;
11105
+ }
11106
+ insertTextAtCaret(payload.value || '');
11107
+ snapBurstIfActive(editEl, 'paste');
11108
+ }
11109
+
11110
+ function wirePlainPasteArming() {
11111
+ // Capture phase: this only records intent, and must do so before any
11112
+ // other keydown handler can preventDefault() its way past it.
11113
+ document.addEventListener('keydown', (e) => {
11114
+ plainPasteArmed = !!((e.ctrlKey || e.metaKey) && e.shiftKey &&
11115
+ (e.key === 'v' || e.key === 'V'));
11116
+ }, true);
11117
+ }
11118
+
11119
+ // ── 追加 4: the whole-document source escape hatch ─────────────────────
11120
+ function applyPreviewEditability(editable) {
11121
+ const surfaces = contentEl.querySelectorAll('.ed-wys-armed, .ed-wys-cell');
11122
+ for (let i = 0; i < surfaces.length; i += 1) {
11123
+ surfaces[i].setAttribute('contenteditable', editable ? 'true' : 'false');
11124
+ }
11125
+ }
11126
+
11127
+ function enterSourceMode() {
11128
+ // The pre-edit snapshot fromSource() diffs against. Omitting it makes
11129
+ // `changed` unconditionally true (docsource.js's documented fail-safe),
11130
+ // i.e. a needless write of byte-identical content on every exit.
11131
+ sourceBaseLines = lines.slice();
11132
+ const ta = document.createElement('textarea');
11133
+ ta.className = 'ed-source';
11134
+ ta.setAttribute('aria-label', '文件原始碼');
11135
+ ta.value = docSourceLib.toSource(lines, EOL);
11136
+ contentEl.hidden = true;
11137
+ contentEl.parentNode.insertBefore(ta, contentEl);
11138
+ sourceTextarea = ta;
11139
+ ta.focus();
11140
+ }
11141
+
11142
+ // Returns false when the commit's render failed — the textarea is then left
11143
+ // standing with the user's text intact (a failed round trip must never be
11144
+ // the thing that discards a whole document's edit), and the mode does not
11145
+ // advance.
11146
+ async function leaveSourceMode() {
11147
+ const ta = sourceTextarea;
11148
+ if (!ta) return true;
11149
+ const parsed = docSourceLib.fromSource(ta.value, EOL, sourceBaseLines);
11150
+ if (parsed.changed) {
11151
+ // 全量置換 through the SAME replaceLines() pipeline every other edit
11152
+ // uses — one commitRangeEdit over the whole document, therefore one
11153
+ // undo op, and no second writer of `lines`.
11154
+ const result = commitRangeEdit({ lines, blocks, stack }, 1, lines.length,
11155
+ parsed.lines.join('\n'));
11156
+ if (result.op !== null) {
11157
+ const prevLines = lines;
11158
+ lines = result.lines;
11159
+ setDirty();
11160
+ const editRange = result.op
11161
+ ? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
11162
+ endLine: Math.max(result.op.startLine - 1, result.op.endLine),
11163
+ delta: result.op.after.length - result.op.before.length }
11164
+ : undefined;
11165
+ const ok = await safeRerenderAll(editRange);
11166
+ if (!ok) {
11167
+ lines = rollbackFailedRender({ lines, stack }, result, prevLines);
11168
+ setDirty();
11169
+ return false;
11170
+ }
11171
+ }
11172
+ }
11173
+ sourceTextarea = null;
11174
+ sourceBaseLines = null;
11175
+ ta.remove();
11176
+ contentEl.hidden = false;
11177
+ return true;
11178
+ }
11179
+
11180
+ async function cycleDocMode() {
11181
+ if (!docSourceLib) return;
11182
+ await setDocMode(docSourceLib.next(docMode));
11183
+ }
11184
+
11185
+ async function setDocMode(next) {
11186
+ if (next === docMode) return;
11187
+ if (docMode === 'edit') {
11188
+ // Same precondition every structural operation has: resolve whatever
11189
+ // session is open before the DOM it lives in is swapped or hidden.
11190
+ const ok = await switchAwayFrom();
11191
+ if (!ok) return;
11192
+ }
11193
+ if (docMode === 'source') {
11194
+ const ok = await leaveSourceMode();
11195
+ if (!ok) return;
11196
+ }
11197
+ if (docMode === 'preview') applyPreviewEditability(true);
11198
+ docMode = next;
11199
+ if (docMode === 'source') enterSourceMode();
11200
+ if (docMode === 'preview') applyPreviewEditability(false);
11201
+ document.body.setAttribute('data-ed-mode', docMode);
11202
+ updateToolbar();
11203
+ }
11204
+
11205
+ // ── mount ──────────────────────────────────────────────────────────────
11206
+ function wireToolbarTracking() {
11207
+ // Which block the bar reports on. Delegated at document level (never a
11208
+ // per-block listener) for the same reason every other affordance in this
11209
+ // file is: blocks are rebuilt wholesale on every render.
11210
+ document.addEventListener('focusin', (e) => {
11211
+ if (!e.target || !e.target.closest) return;
11212
+ if (e.target.closest('.ed-toolbar') || e.target.closest('.ed-toolbar-menu')) return;
11213
+ const blockEl = e.target.closest('.ed-block');
11214
+ if (!blockEl) return;
11215
+ toolbarBlockEl = blockEl;
11216
+ updateToolbar();
11217
+ });
11218
+ document.addEventListener('click', (e) => {
11219
+ if (!e.target || !e.target.closest) return;
11220
+ if (e.target.closest('.ed-toolbar') || e.target.closest('.ed-toolbar-menu')) return;
11221
+ closeToolbarMenu();
11222
+ const blockEl = e.target.closest('.ed-block');
11223
+ if (blockEl) toolbarBlockEl = blockEl;
11224
+ updateToolbar();
11225
+ });
11226
+ // NOTE: there is deliberately NO selectionchange listener here — the
11227
+ // burst's own onSelectionChangeForToolbar() calls updateToolbar() for us.
11228
+ // See that function for why a second one must not be attached.
11229
+ }
11230
+
11231
+ function mountToolbar() {
11232
+ // An older cached page can be running this runtime without the v3.1.0
11233
+ // module tags. Degrade to "no toolbar" rather than throwing before
11234
+ // armEditables() has run.
11235
+ if (!toolbarModel) return;
11236
+ toolbarEl = buildToolbar();
11237
+ // ⚠ document.body, NOT contentEl. rerenderAll()'s fallback path does
11238
+ // `contentEl.innerHTML = j.parts.join('\n')` (v3.2.0: `/api/render`
11239
+ // answers `{ parts, blocks }` and there is no `bodyHtml` on the wire any
11240
+ // more — see applyFullRender()), replacing .content's children wholesale,
11241
+ // and only armEditables() rebuilds anything afterwards — it knows nothing
11242
+ // about a toolbar. A bar mounted inside .content would
11243
+ // vanish on the first commit with nothing left to bring it back. Every
11244
+ // persistent chrome node in this file is on document.body for exactly
11245
+ // this reason: the conflict banner, the selection toolbar, the table
11246
+ // grips and the table edge menu.
11247
+ document.body.appendChild(toolbarEl);
11248
+ document.body.setAttribute('data-ed-mode', docMode);
11249
+ wireToolbarTracking();
11250
+ wireImageDrop();
11251
+ wirePlainPasteArming();
11252
+ updateToolbar();
11253
+ }
11254
+
9440
11255
  // ── global key handling ─────────────────────────────────────────────────
9441
11256
  document.addEventListener('keydown', (e) => {
9442
11257
  // ── S3 §4.4: Escape priority is drag > menu > selection > burst ─────
@@ -9817,8 +11632,13 @@
9817
11632
  if (!editEl) return;
9818
11633
  e.preventDefault();
9819
11634
  const dt = e.clipboardData || window.clipboardData;
9820
- insertTextAtCaret(dt ? dt.getData('text/plain') : '');
9821
- snapBurstIfActive(editEl, 'paste');
11635
+ // v3.1.0 追加 2: paste-md.js decides what the clipboard actually holds
11636
+ // (text/html -> markdown, an image, or plain text) and this routes the
11637
+ // answer. The TABLE-CELL branch above deliberately keeps its plain-text
11638
+ // behaviour: a cell cannot host a block, and a converted markdown table
11639
+ // pasted into one cell would be a nested structure the serializer has no
11640
+ // way to represent.
11641
+ handleArmedPaste(dt, editEl);
9822
11642
  });
9823
11643
 
9824
11644
  // Task 5: hover-edge insert bubbles — one delegated, rAF-throttled
@@ -9908,4 +11728,8 @@
9908
11728
 
9909
11729
  armEditables(contentEl);
9910
11730
  wireBlockSelection();
11731
+ // v3.1.0 §4: last, so every const it closes over (stack, BLOCK_SKELETONS,
11732
+ // selToolbar, …) is initialised and the bar's first deriveState() sees the
11733
+ // armed document rather than an empty one.
11734
+ mountToolbar();
9911
11735
  })();