@helping-ai-workflow/md2doc 3.2.0 → 3.3.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.
@@ -474,8 +474,8 @@
474
474
  // first; S2 gives li blocks a + and it goes live.
475
475
  //
476
476
  // One helper rather than two `if (result.op === null) return;` lines,
477
- // because the idiom is copy-pasted and the seventh site will not remember
478
- // either. Declared in the pure core so it is reachable from node: the branch
477
+ // because the idiom is copy-pasted and the next site to copy it will not
478
+ // remember either. Declared in the pure core so it is reachable from node: the branch
479
479
  // cannot be driven through a gesture today, and a guard that can only be
480
480
  // checked by grepping for its own source text is the shape that already
481
481
  // failed review once on this plan.
@@ -532,6 +532,20 @@
532
532
  const docSourceLib = window.md2docDocSource;
533
533
  let lines = ED.lines, blocks = ED.blocks, mtimeMs = ED.mtimeMs;
534
534
  let lastParts = null;
535
+ // F10: 上一次【成功套用】的 render 留下來的東西 —— 當時的整份 `lines`,以及
536
+ // 當時的文件是不是以一道沒閉合的圍欄收尾。
537
+ //
538
+ // fix round 2 (K7): 這裡不再說「跟 `blocks` 一起前進」,那句是錯的。`blocks`
539
+ // 是被 applyPatch()/applyFullRender() 自己換掉的(applyFullRender() 的第一行
540
+ // 就是 `blocks = j.blocks`),時機不受套用成功與否管;下面這些狀態則是
541
+ // applyRenderResult() 在【呼叫那些 apply 之前】讀、在【套用成功之後】才寫的,所以
542
+ // 判別式讀到的一定是上一份已經上過畫面的狀態。
543
+ //
544
+ // 開場值從 bootstrap payload 直接算(ED.lines 與 ED.blocks 是
545
+ // lib/editor/server.js 的 GET /edit/:id 在同一個回應裡送出來的),這樣一份
546
+ // 【開檔時就已經沒閉合】的文件不會在使用者的下一個手勢上被誣賴成吞噬。
547
+ let lastRenderLines = ED.lines.slice();
548
+ let lastRenderEndedUnclosed = endsWithUnclosedFence(ED.lines, ED.blocks);
535
549
  // 檔案原本的換行符。lines 內部永遠是不含 \r 的純內容行;只有 save()
536
550
  // 會把它接回這個 EOL,render 一律用 \n(spec §3.11)。
537
551
  const EOL = ED.eol || '\n';
@@ -576,6 +590,36 @@
576
590
  // section further down for the full shape and lifecycle.
577
591
  let currentBurst = null; // { blockEl, editEl, blockId, blockType, depth, original, history } | null
578
592
 
593
+ // T21 item 1: the burst Escape most recently threw away, kept alive for
594
+ // exactly one document-level Ctrl+Z. Escape means DISCARD and still does —
595
+ // what changed is that the discard is no longer unrecoverable. Driven on
596
+ // this branch before the fix, on all three burst surfaces (paragraph, table
597
+ // cell, list item): type, Escape, then Ctrl+Z ×3 and Ctrl+Y ×3 — the text
598
+ // was gone after all six, and the title went '● doc' -> 'doc', so the
599
+ // beforeunload guard was disarmed over it too.
600
+ //
601
+ // The burst's OWN history is what carries the text back, which is why this
602
+ // holds the history object rather than an HTML string: revertBurstAndEnd()
603
+ // snapshots on both sides of the revert (the discipline every other
604
+ // mutation in this file already follows — see snapBurstIfActive()), so
605
+ // restoring is one history.undo() and the entries UNDER it stay reachable
606
+ // afterwards. `reverted` is the staleness guard: anything that repaints the
607
+ // surface between the Escape and the Ctrl+Z (a render, another block's
608
+ // commit) makes this stash unusable, and it is dropped rather than applied
609
+ // over a document that moved.
610
+ //
611
+ // One slot, cleared the moment any burst opens (startBurst()/
612
+ // startTableBurst()) or the page re-renders (rerenderAll()), so the only
613
+ // window in which it answers a Ctrl+Z is the one the user is complaining
614
+ // about: Escape, then Ctrl+Z.
615
+ let discardedBurst = null; // { history, editEl, blockType, cellIdx, reverted } | null
616
+
617
+ function dropDiscardedBurst() {
618
+ if (!discardedBurst) return;
619
+ discardedBurst.history.dispose();
620
+ discardedBurst = null;
621
+ }
622
+
579
623
  // §10-gap fix (review): the block insertBlockBelow() most recently
580
624
  // inserted, tracked from the moment its edit surface is first focused
581
625
  // until its FIRST resolution (blur, Escape, explicit commit, or Ctrl+Z) —
@@ -728,8 +772,81 @@
728
772
  return await activeEditor.commitNow(); // false → editor stays open, banner already shown
729
773
  }
730
774
 
775
+ // `stack.dirtyDepth` is `_done.length - _savedDepth` (lineops.js), so it
776
+ // moves when an op is pushed onto or popped off the undo stack, or when a
777
+ // save re-baselines it. The burst substrate keeps what the user typed in
778
+ // the DOM until the burst resolves, and none of those has happened yet at
779
+ // that point — so a block that has been typed into but not yet left reads
780
+ // as clean. MEASURED on `# Doc\n\nAlpha paragraph.\n` (click the
781
+ // paragraph, type, then navigate away):
782
+ // mid-burst document.title "doc" no beforeunload dialog
783
+ // after blur document.title "● doc" dialog raised
784
+ // This names that gap, and is the shape bystanderCarryOver() already uses
785
+ // to pick out the block whose bytes are not in `lines` yet — including its
786
+ // `currentBurst.editEl` guard, kept here because this also runs inside the
787
+ // beforeunload handler, where a throw would drop the navigation guard
788
+ // rather than surface.
789
+ function burstHasUncommittedEdit() {
790
+ return !!(currentBurst && currentBurst.editEl &&
791
+ burstBaselineHtml(currentBurst.editEl) !== currentBurst.original);
792
+ }
793
+
731
794
  function setDirty() {
732
- document.title = (stack.dirtyDepth !== 0 ? '● ' : '') + baseTitle;
795
+ const dirty = stack.dirtyDepth !== 0 || burstHasUncommittedEdit();
796
+ document.title = (dirty ? '● ' : '') + baseTitle;
797
+ }
798
+
799
+ // setDirty() is push-driven: something has to call it. MEASURED by keeping
800
+ // the predicate above but removing the watcher below — typing into a
801
+ // paragraph still left the title at "doc", so nothing repaints it while a
802
+ // burst is open. That is the second half of the gap. The dot needs
803
+ // something that fires while the burst is still open, and again when the
804
+ // surface goes back to its baseline without a commit.
805
+ //
806
+ // A mutation watcher rather than a list of call sites, because the
807
+ // gestures that dirty a burst and the gestures that clean it again do not
808
+ // share an entry point: typing raises an `input` event, a selection-
809
+ // toolbar mark does not (MEASURED: pressing B over a selection in a
810
+ // paragraph burst leaves the surface `<strong>Alpha</strong> paragraph.`
811
+ // with the document-level `input` listener's inputType log still empty),
812
+ // and a burst-local Ctrl+Z swaps the whole surface in through innerHTML.
813
+ //
814
+ // `attributes` is watched because a gesture exists that writes an
815
+ // attribute and nothing else into a burst that is left open: the
816
+ // edge menu's 對齊 reaches cycleColumnAlign()'s
817
+ // `cell.setAttribute('style', 'text-align:…')`, and runCycleAlign() around
818
+ // it only snap()s the burst history. MEASURED with `attributes` unwatched,
819
+ // driving that menu with real mouse events: `document.title` stayed "doc"
820
+ // while the unload guard DID fire — the predicate saw the edit and the dot
821
+ // did not.
822
+ //
823
+ // 🔗 over an existing link writes an attribute too
824
+ // (applyLinkToggleBody()'s `whole.setAttribute('href', …)`) and does NOT
825
+ // establish this: MEASURED through a real modal rather than a stubbed
826
+ // window.prompt, the prompt takes focus off the surface first, which
827
+ // resolves and commits the burst, so `stack.dirtyDepth` has already moved
828
+ // by the time the attribute lands. Not every attribute write in this file
829
+ // was measured. The table selection chrome is another that reaches a burst
830
+ // surface; burstBaselineHtml() normalises it away, and MEASURED, neither
831
+ // it nor a bare cell click produces a dot.
832
+ //
833
+ // Cost per delivered batch is a `document.title` write, plus a
834
+ // burstBaselineHtml() while `stack.dirtyDepth` still reads clean (the `||`
835
+ // short-circuits past it otherwise). MEASURED, innerHTML serialize plus
836
+ // its indexOf scans on a rendered table block: 0.02 ms at 7.4 KB, 0.06 ms
837
+ // at 21 KB, 0.19 ms at 71 KB.
838
+ const burstDirtyWatcher = new MutationObserver(setDirty);
839
+
840
+ // Called wherever a burst opens. disconnect() first because observe() ADDS
841
+ // a target rather than replacing one, so re-pointing is what keeps a single
842
+ // watcher single. MEASURED, observing surface A then surface B on the same
843
+ // observer and mutating both: without the disconnect the callback is
844
+ // delivered records from A as well as B, with it only from B.
845
+ function watchBurstForDirtyDot(editEl) {
846
+ burstDirtyWatcher.disconnect();
847
+ burstDirtyWatcher.observe(editEl, {
848
+ childList: true, subtree: true, characterData: true, attributes: true,
849
+ });
733
850
  }
734
851
 
735
852
  // ── banners (conflict / render-failed / save-failed) ──────────────────
@@ -744,12 +861,17 @@
744
861
  // three describe the state of the FILE or the connection and must stay until
745
862
  // the user dismisses them.
746
863
  let activeBannerIsRefusal = false;
864
+ // F10 fix round 1 的對稱項:這條 banner 描述的是【文件現在的形狀】,跟
865
+ // refusal 一樣需要一個「後來的事件可以把它收掉」的記號,但收掉它的條件不
866
+ // 同(見 dismissSwallowBanner()),所以是另一個旗標而不是共用那一個。
867
+ let activeBannerIsSwallow = false;
747
868
  function showBanner(message, actionLabel, onAction) {
748
869
  if (activeBanner) { activeBanner.remove(); activeBanner = null; }
749
870
  // Reset unconditionally: refuseStructuralListEdit() sets it back to true
750
871
  // straight after its own call, so every OTHER caller gets false without
751
872
  // having to know this flag exists.
752
873
  activeBannerIsRefusal = false;
874
+ activeBannerIsSwallow = false;
753
875
  const el = document.createElement('div');
754
876
  el.className = 'ed-conflict';
755
877
  const msg = document.createElement('span');
@@ -768,7 +890,11 @@
768
890
  dismissBtn.setAttribute('aria-label', 'Dismiss');
769
891
  dismissBtn.addEventListener('click', () => {
770
892
  el.remove();
771
- if (activeBanner === el) { activeBanner = null; activeBannerIsRefusal = false; }
893
+ if (activeBanner === el) {
894
+ activeBanner = null;
895
+ activeBannerIsRefusal = false;
896
+ activeBannerIsSwallow = false;
897
+ }
772
898
  });
773
899
  el.appendChild(dismissBtn);
774
900
  document.body.appendChild(el);
@@ -803,6 +929,151 @@
803
929
  );
804
930
  }
805
931
 
932
+ // F10 ——「一道沒閉合的圍欄把後面的內容吃掉了」那一句,單獨一個常數。
933
+ // test/editor-journey.test.js 的 F10 那一列釘的是這串文字本身,而不是
934
+ // 「畫面上有一條 .ed-conflict」。那個 class 只有 showBanner() 一個產生點,
935
+ // 而 showBanner() 的呼叫端(grep 全檔逐一開過)橫跨這些彼此無關的家族:
936
+ // - 磁碟衝突:showConflictBanner()
937
+ // - render 失敗:rerenderAll() 的 'Render failed — network error'/
938
+ // 'Render failed — ' + reason/'Render failed — malformed server response'
939
+ // ,加上 safeRerenderAll() 的 'Render failed — unexpected error'
940
+ // - save 失敗:save() 的 'Save failed — network error'/
941
+ // 'Save failed — malformed server response'/'Save failed — ' + reason
942
+ // - 手勢在 render 與 render 之間失去目標:DROPPED_GESTURE_MESSAGE
943
+ // - 清單/表格的結構性拒絕:refuseStructuralListEdit()、'無法刪除最後一欄'、
944
+ // '無法刪除標題列'、'無法刪除最後一列'
945
+ // - burst 降級:'含不支援的格式,改用原始碼編輯'
946
+ // - 工具列插圖:TOOLBAR_UNSUPPORTED_IMAGE_MESSAGE、'圖片讀取失敗 — '、
947
+ // '圖片上傳失敗 — '(網路 throw/非 2xx/回應格式不正確)、
948
+ // TOOLBAR_NO_ANCHOR_MESSAGE
949
+ // 光看 class 分不出是哪一個。
950
+ const SWALLOW_MESSAGE = '這道 ``` 圍欄沒有閉合,它後面的內容全部被吃進' +
951
+ '同一個程式碼區塊裡了。把收尾的圍欄補回去就會復原。';
952
+
953
+ // F10 —— 吞噬的【形狀】,不是計數。
954
+ //
955
+ // 這支問的是「最後一個區塊是不是一道沒閉合的圍欄」:type 是 code、它的開頭
956
+ // 那一行真的是一道圍欄(縮排式 code block 的 type 也是 code、開頭行不是圍欄,
957
+ // 靠 fenceOpenerOf() 排掉),而它底下沒有任何一行能收掉那道圍欄(closesFence()
958
+ // 認字元與長度 —— 見 fenceOpenerOf() 與 closesFence() 的註解,K1/K3)。只有開頭圍欄、還沒有本文與收尾
959
+ // 的單行 code 區塊也算沒閉合。
960
+ //
961
+ // 為什麼只看最後一個區塊:**頂層**的一道沒閉合的圍欄會一路吃到檔尾,所以它
962
+ // 就是最後一個區塊。巢狀在 blockquote/清單項裡的圍欄不是這樣,但它也不吃到
963
+ // 檔尾 —— MEASURED(buildBlockMap):
964
+ // '# Doc\n\n> ```js\n> code\n\nTail.\n' heading blockquote[3,4] paragraph[6,6]
965
+ // '# Doc\n\n- ```js\n code\n\nTail.\n' heading li[3,4] paragraph[6,6]
966
+ // '# Doc\n\n```js\ncode\n\nTail.\n' heading code[3,6]
967
+ // 容器在空行就結束了,'Tail.' 還在外面、沒有被吃掉,所以這支不看它、也不需要
968
+ // 看它。
969
+ //
970
+ // `srcLines` 是 1-indexed 行號要索引的那個陣列(呼叫端傳模組層的 `lines`
971
+ // 或 bootstrap 的 ED.lines),`blockList` 是與它同一份快照的 block 陣列。
972
+ function endsWithUnclosedFence(srcLines, blockList) {
973
+ if (!Array.isArray(blockList) || blockList.length === 0) return false;
974
+ const last = blockList[blockList.length - 1];
975
+ if (!last || last.type !== 'code') return false;
976
+ const opener = fenceOpenerOf(srcLines[last.startLine - 1]);
977
+ if (!opener) return false; // 縮排式 code block:type 也是 code,但開頭不是圍欄
978
+ for (let n = last.startLine + 1; n <= last.endLine; n++) {
979
+ if (closesFence(srcLines[n - 1], opener)) return false;
980
+ }
981
+ return true;
982
+ }
983
+
984
+ // F10 —— 吞噬與【修剪】的差別是【歸屬】,而歸屬要算數量,不是問有沒有。
985
+ //
986
+ // 「形狀 ∧ 區塊少了」擋掉了純計數判別的那族誤報,但放進來另一族:使用者把收
987
+ // 尾圍欄連同它後面的東西一起【刪掉】(往檔尾 trim),區塊照樣掉、文件照樣以沒
988
+ // 閉合的圍欄收尾 —— 可是圍欄後面本來就沒東西了,是使用者自己刪的。那時候那句
989
+ // 話的每一個子句(「後面的內容全部被吃進去」「補回收尾圍欄就會復原」)都是假的。
990
+ //
991
+ // 真正的差別:吞噬時,本來活在 fenced code block【外面】的行跑進了裡面;修剪
992
+ // 時那些行只是不見了。這裡用內容比對,不做行號對齊(對齊在插入/刪除混在一起
993
+ // 時很脆,這個做法完全不需要它)。
994
+ //
995
+ // fix round 3 (L1):上一版問的是【有沒有】—— 「這一行以前在外面,而現在不在
996
+ // 外面了」。那個問法朝相反的方向各說一種謊,下面每一種都是驅動出來的:
997
+ // 往檔尾 trim,而圍欄本文有一行【也】出現在被刪掉的尾巴裡(散文重複了它自己
998
+ // 用圍欄展示的那道指令,是這個形狀最普通的樣子)→ 什麼都沒被吃掉卻會叫。
999
+ // 真吞噬,但被吃掉的那一行文件別處【還有一份】→ 「現在不在外面」不成立,
1000
+ // 於是不叫。被吃掉的每一行都有副本時整個吞噬都消音。
1001
+ // 被吃掉的是一個【縮排式】code block → 上一版把它算成「本來就在 code 裡」,
1002
+ // 於是不叫。
1003
+ //
1004
+ // 所以改成算數量,而且是【逐行】判:走過新的尾端圍欄本文裡出現過的每一種非
1005
+ // 空行內容,只要有一行【自己】同時滿足下面這些條件,就是吞噬,當場 return:
1006
+ //
1007
+ // wasOutCount > nowOutCount —— 這一行在上一份/這一份 render 裡出現在
1008
+ // fenced code block【外面】的次數。外面的份數變少 = 它被吃進去了。
1009
+ // nowTotal >= prevTotal —— 同一行在上一份/這一份【整份文件裡】出現的次數
1010
+ // (圍欄裡的份數也算)。沒有變少 = 它只是換了位置,不是被刪掉。
1011
+ //
1012
+ // fix round 4 (M1):前半與後半在上一版是【各自加總】再比一次的,那讓不同的
1013
+ // 行互相抵銷,不論哪個方向都出事。真吞噬靜默:code block 裡有一行重複(`}`
1014
+ // 是日常樣子),一次編輯刪掉其中一份並且刪掉收尾圍欄 —— 被吃掉的那一行自己
1015
+ // 前半與後半都成立,卻被那個消失的 `}` 拉低總數而抵銷掉。誤報回來:往檔尾
1016
+ // trim 的同一次編輯裡把本文某一行複製一份 —— 被 trim 掉的那一行撐前半、被複
1017
+ // 製的那一行撐後半,湊出一個沒有任何一行自己成立的「真」。
1018
+ //
1019
+ // 「總數」刻意只算這一行、而且連圍欄裡的份數一起算,不是整份文件的行數:
1020
+ // 最常見的真吞噬是【只刪掉收尾圍欄那一行】,整份文件的非空行數會因此少一行,
1021
+ // 用文件層級的總數當門檻會把它殺掉(那也是為什麼不能用 Δlines >= 0)。
1022
+ //
1023
+ // 「不在 fenced code block 內」不是「不在 code block 內」:縮排式 code block
1024
+ // 的 type 也是 code,但它是文件裡看得見的內容,被一道圍欄吃掉一樣是吞噬。
1025
+ // 靠 fenceOpenerOf() 分辨(開頭那一行是不是圍欄)。
1026
+
1027
+ // 一份 render 裡,每一種非空行內容出現了幾次:`out` 只數【不在任何 fenced
1028
+ // code block 內】的那些,`all` 數整份文件。
1029
+ function lineCounts(srcLines, blockList) {
1030
+ const inFence = {};
1031
+ for (const b of blockList) {
1032
+ if (!b || b.type !== 'code') continue;
1033
+ if (!fenceOpenerOf(srcLines[b.startLine - 1])) continue;
1034
+ for (let n = b.startLine; n <= b.endLine; n++) inFence[n] = true;
1035
+ }
1036
+ const out = Object.create(null);
1037
+ const all = Object.create(null);
1038
+ for (let n = 1; n <= srcLines.length; n++) {
1039
+ const row = srcLines[n - 1];
1040
+ if (typeof row !== 'string' || row.trim() === '') continue;
1041
+ all[row] = (all[row] || 0) + 1;
1042
+ if (!inFence[n]) out[row] = (out[row] || 0) + 1;
1043
+ }
1044
+ return { out: out, all: all };
1045
+ }
1046
+ // `tail` 是新的 block 陣列的最後一個(呼叫端已經確認它是一道沒閉合的圍欄)。
1047
+ function absorbedOutsideContent(prevLines, prevBlocks, newLines, newBlocks, tail) {
1048
+ const was = lineCounts(prevLines, prevBlocks);
1049
+ const now = lineCounts(newLines, newBlocks);
1050
+ const seen = Object.create(null);
1051
+ for (let n = tail.startLine + 1; n <= tail.endLine; n++) {
1052
+ const row = newLines[n - 1];
1053
+ if (typeof row !== 'string' || row.trim() === '' || seen[row]) continue;
1054
+ seen[row] = true;
1055
+ const wasOutCount = was.out[row] || 0;
1056
+ const nowOutCount = now.out[row] || 0;
1057
+ const prevTotal = was.all[row] || 0;
1058
+ const nowTotal = now.all[row] || 0;
1059
+ if (wasOutCount > nowOutCount && nowTotal >= prevTotal) return true;
1060
+ }
1061
+ return false;
1062
+ }
1063
+
1064
+ // F10 fix round 1 —— 吞噬的 banner 必須退場。
1065
+ //
1066
+ // 它不是 refusal,所以 dismissRefusalBanner() 不會碰它(那支只清
1067
+ // `activeBannerIsRefusal`)。退場的條件不是「後來有一次成功的編輯」而是
1068
+ // 「文件不再以沒閉合的圍欄收尾」:圍欄還開著的時候,後面的內容就還被吃著,
1069
+ // 那時候把警告收掉才是說謊。呼叫點在 applyRenderResult() 套用成功之後。
1070
+ function dismissSwallowBanner() {
1071
+ if (!activeBanner || !activeBannerIsSwallow) return;
1072
+ activeBanner.remove();
1073
+ activeBanner = null;
1074
+ activeBannerIsSwallow = false;
1075
+ }
1076
+
806
1077
  function describeFailure(e) {
807
1078
  return (e && e.message) ? e.message : String(e);
808
1079
  }
@@ -828,6 +1099,11 @@
828
1099
  // a dismissible banner explaining what happened. Never throws: every
829
1100
  // await is inside its own try/catch, so callers never see a rejection.
830
1101
  async function rerenderAll(editRange) {
1102
+ // T21 item 1: this render is about to replace the surfaces the stash names,
1103
+ // so whatever Escape put there stops being restorable here. The staleness
1104
+ // guard in restoreDiscardedBurst() would refuse it anyway; dropping it at
1105
+ // the source is what keeps a dead history object from being held.
1106
+ dropDiscardedBurst();
831
1107
  const scrollY = window.scrollY;
832
1108
  // S3 Task 5 (§4.4 step 2): the line range this render's operation declared
833
1109
  // for the rebuilt selection. Consumed HERE, before the first failure exit,
@@ -966,14 +1242,114 @@
966
1242
  }
967
1243
 
968
1244
  async function applyRenderResult(j, editRange, scrollY, declaredRange) {
1245
+ // F10 —— 判別式:【形狀 ∧ 歸屬】。
1246
+ //
1247
+ // 第一版是純計數(Δblocks < 0 且 Δlines >= 0)。實測它在再普通不過的編輯
1248
+ // 上就會叫,磁碟位元組卻完全正確 —— 全文原始碼裡把一行的字清掉但行留著
1249
+ // (blocks 4->3、lines 8->8)、⠿「MD 原始碼」裡把一段清成空字串或一個空白
1250
+ // (同樣 4->3、8->8)、把每一段的字都清掉(4->1、8->8)。計數分不開「一個
1251
+ // 區塊因為自己的內容被清空而消失」與「一個區塊因為被別人吸收而消失」。
1252
+ //
1253
+ // 第二版是「形狀 ∧ 區塊少了」。形狀那一半擋掉了上面那族,但效果那一半只
1254
+ // 是計數換個位置,於是放進來一族新的:使用者把收尾圍欄連同它後面的東西一
1255
+ // 起【刪掉】(往檔尾 trim),區塊照樣 4~5 掉到 3、文件照樣以沒閉合的圍欄
1256
+ // 收尾,可是圍欄後面本來就沒東西了 —— 是使用者自己刪的。那時候那句話的
1257
+ // 每一個子句都是假的。
1258
+ //
1259
+ // 這一版把效果那一半換成【歸屬】(見 absorbedOutsideContent()):新的尾端
1260
+ // 圍欄的本文裡,有沒有哪一行在上一份 render 裡活在 code block 外面、而在
1261
+ // 這一份已經不在外面了。吞噬時有(那些行被吃進去了),修剪時沒有(那些行
1262
+ // 不見了,不是搬進去)。
1263
+ //
1264
+ // 每一項各擋掉一族,而且各有一列 journey 案例釘住它 —— 把那一項【單獨】拿
1265
+ // 掉,那一列就紅。這是機械掃出來的,不是看出來的:判別式、歸屬測試自己的組
1266
+ // 成、圍欄配對器的每一條規則,各自單獨 ablate 一次,每次都把 F9/F10 那一段
1267
+ // 的案例逐列跑過,記錄哪一列紅。
1268
+ // nowUnclosed —— 使用者自己按「轉換成 › 程式碼」把重複的一段
1269
+ // 包進圍欄裡,外面的份數當然少一份,但文件並沒
1270
+ // 有以沒閉合的圍欄收尾(PIN-shape)
1271
+ // !lastRenderEndedUnclosed —— 一開檔就沒閉合的文件靠它不被誣賴(PIN-prev、
1272
+ // G7)
1273
+ // absorbedOutsideContent —— 往檔尾 trim 與「圍欄後面沒東西」靠它擋
1274
+ // (K2 的 X4/P1/P2/P7、控制列 C3)
1275
+ //
1276
+ // 量到的漏網(false negative;同一次掃描量到的誤報是零)。把一道 '```' 用
1277
+ // 全文原始碼插進 baseline('# Doc' / 'Alpha.' / 一段 ```js 圍欄 / '## Next' /
1278
+ // 'Bravo.' / 'Charlie.' / '- x' / '- y')的每一個行位置,逐一【驅動】過,
1279
+ // 再拿 buildBlockMap 算出的真值(有沒有本來在 code block 外面的非空行跑進
1280
+ // 去了)對答案:
1281
+ // 插在第 6..16 行之前 真的吃到東西 → 有叫
1282
+ // 插在第 4、5、17、18 行之前 什麼都沒吃到 → 沒叫(正確)
1283
+ // 插在第 1、2、3 行之前 真的吃到東西 → 【沒叫】
1284
+ // 第 1/2/3 行那些格的機制跟計數無關:新的開頭圍欄跟文件裡【原本就有的】
1285
+ // 那道收尾圍欄配成一對,所以吃掉東西的那個 code block 是【閉合】的,文件
1286
+ // 也不是以沒閉合的圍欄收尾 —— 形狀那一半看不到它。而且那個結果跟使用者
1287
+ // 自己按 ⠿「轉換成 › 程式碼」得到的形狀一模一樣(同一份語料裡那一列必須
1288
+ // 靜默),兩者只差在意圖,文件本身分不出來。
1289
+ //
1290
+ // 另一族漏網(同樣是量到的,同樣不打算靠再改一版判別式去關):**同一次編輯
1291
+ // 刪掉收尾圍欄,而且也刪掉了被吃那一行內容的另一份**。被吃的那一行前半成
1292
+ // 立(圍欄外面的份數變少),但後半的整份文件份數也跟著少了,所以那一行自己
1293
+ // 不成立,於是靜默。瀏覽器裡驅動確認過:'Bravo.' 在圍欄裡與圍欄外各一份,
1294
+ // 刪掉圍欄裡那一份與收尾圍欄之後,外面那個 'Bravo.' 真的被吃進去(區塊由
1295
+ // 區塊數真的變少、磁碟是 '# Doc\n\n```js\n\nBravo.\n'),banner 不出現。
1296
+ //
1297
+ // 為什麼不關:拿「這一行在圍欄裡的份數變多了」(nowIn > wasIn)去換掉後半,
1298
+ // 在同一份位置真值的掃描上只關掉一部分漏網,卻把誤報從零帶到有 —— 對一個
1299
+ // 警告來說,誤報那個方向才是致命的(叫錯一次,使用者就再也不信它),而漏掉
1300
+ // 一次警告只是把使用者留在 v3.3.0 之前的狀態:本來就沒有信號。所以這些
1301
+ // 漏網是**寫下來的已知缺口**,不是待修的 bug,也刻意不寫成測試案例 —— 把缺
1302
+ // 口釘成「預期行為」會讓將來真的補起來時看起來像退步。
1303
+ //
1304
+ // 反方向已知的例外,而且它是**設計的極限、不是沒修**:使用者往檔尾 trim,
1305
+ // 同一次編輯又把被 trim 掉那一行的內容複製進圍欄本文。實測那些手勢產生的
1306
+ // 文件**逐位元組相同**('# Doc\n\n```sh\nsetup\nsetup\n' —— 一邊是把
1307
+ // 'setup' 複製進去再 trim,另一邊是單純刪掉收尾圍欄讓外面那個 'setup' 被吃
1308
+ // 進去),所以任何只看文件內容的判別式都分不開它們。這一格會叫,而它叫的
1309
+ // 那份文件確實跟一次真吞噬的結果一模一樣。
1310
+ //
1311
+ // 讀的時機:`blocks` 與 `lines` 都必須在呼叫 applyPatch()/
1312
+ // applyFullRender() 之前讀 —— applyFullRender() 的第一行就是
1313
+ // `blocks = j.blocks`,applyPatch() 也在它那段 mutation window 之前把
1314
+ // `blocks` 換掉(先存一份 prevBlocks 好在自己的 catch 裡回捲)。`lines`
1315
+ // 已經是這次 render 的輸入(rerenderAll() 送出去的 body 就是
1316
+ // `lines.join('\n')`),而上一份的 `lines` 存在 `lastRenderLines` 裡。
1317
+ const nowUnclosed = endsWithUnclosedFence(lines, j.blocks);
1318
+ const swallowed = nowUnclosed && !lastRenderEndedUnclosed &&
1319
+ absorbedOutsideContent(lastRenderLines, blocks,
1320
+ lines, j.blocks,
1321
+ j.blocks[j.blocks.length - 1]);
969
1322
  const plan = (editRange && window.md2docPatchmap && domMatchesLastRender())
970
1323
  ? window.md2docPatchmap.patchmap({
971
1324
  oldBlocks: blocks, newBlocks: j.blocks,
972
1325
  oldParts: lastParts, newParts: j.parts, editRange: editRange,
973
1326
  })
974
1327
  : null;
975
- if (plan) return applyPatch(j, plan, declaredRange);
976
- return applyFullRender(j, scrollY, declaredRange);
1328
+ const ok = plan ? await applyPatch(j, plan, declaredRange)
1329
+ : await applyFullRender(j, scrollY, declaredRange);
1330
+ // applyPatch() 與 applyFullRender() 今天都只回 true:applyFullRender() 全函
1331
+ // 式唯一的 return 就是尾端
1332
+ // 那個 `return true`,而 applyPatch() 要嘛回 true、要嘛在自己的 catch 裡
1333
+ // 把整件事交給 applyFullRender()。所以這道門今天不會擋下任何東西;它存
1334
+ // 在是為了把「render 沒套用成功就不要前進上面那些 lastRender* 狀態、也
1335
+ // 要為一次沒上畫面的編輯升起警告」這件事寫在這裡,而不是留給讀者去讀
1336
+ // 它們的本體。
1337
+ if (!ok) return false;
1338
+ lastRenderLines = lines.slice();
1339
+ lastRenderEndedUnclosed = nowUnclosed;
1340
+ // 升 banner 排在套用之後:套用是把新的 DOM 放上畫面那一步,警告的是使用
1341
+ // 者【現在看到的】那份文件。applyPatch()/applyFullRender() 自己不呼叫
1342
+ // showBanner(),所以這一句不會被同一次 render 裡的另一句蓋掉。
1343
+ if (swallowed) {
1344
+ showBanner(SWALLOW_MESSAGE, null, null);
1345
+ activeBannerIsSwallow = true; // 順序同 refuseStructuralListEdit():
1346
+ // showBanner() 自己會把旗標歸零。
1347
+ } else if (!nowUnclosed) {
1348
+ // 圍欄補回去了(或者這次 render 之後文件本來就不是那個形狀)——
1349
+ // 警告已經不再是真的,收掉。見 dismissSwallowBanner() 的註解。
1350
+ dismissSwallowBanner();
1351
+ }
1352
+ return true;
977
1353
  }
978
1354
 
979
1355
  // Incremental DOM patch: replace ONLY the middle span patchmap identified,
@@ -1023,6 +1399,31 @@
1023
1399
  // property before we are called; it is a PRECONDITION of everything
1024
1400
  // below, not something this function re-derives.
1025
1401
  const children = Array.prototype.slice.call(contentEl.children);
1402
+ // v3.2.1: 「被改寫區塊內的游標」焦點修復。動機來自 undo/redo 的量測——
1403
+ // undo/redo 並非普遍掉焦點:游標在被改寫區間【之外】時 activeElement /
1404
+ // 所屬 block / caret offset 全部存活(連一次讓自己 data-block-id 8→7 的
1405
+ // 結構性 undo 都扛得住);只有游標落在被改寫區塊【內】才掉到 BODY,之後
1406
+ // 打字被靜默丟棄。所以判準是位置,不是操作種類,也完全不需要 op→block
1407
+ // 映射(實測 op.startLine 在 14 個實例中只有 4 個正確,對 move / insert
1408
+ // 都指向沒有擁有者的空行——該設計已退掉)。
1409
+ //
1410
+ // ⚠ 觸發面不是「只有 undo/redo」:任何一次 patch 只要聚焦的 block 落在
1411
+ // replaceSpan 內就會走到,fix round 1 的 review 點名了兩類非 undo 的呼叫者——
1412
+ // * 一般的 burst 提交:endBurstWithoutResolve() 不 blur,焦點還留在那個
1413
+ // 即將被換掉的面上;
1414
+ // * 帶 declaredRange 的批次結構操作:焦點坐在 `.ed-block` 外框上(roving
1415
+ // tabindex)。
1416
+ // 這兩類上還原完之後常常會被下面 applySelectionClasses() 自己的
1417
+ // `focusEl.focus()` 立刻覆蓋掉(它把焦點搬回選取的 focus block),也就是多
1418
+ // 一趟 focus 往返而已;journey 案例 3 / 4 / 6 走的正是這些路徑,皆綠。
1419
+ //
1420
+ // 必須在 DOM 手術之前算:removeChild 之後 activeElement 已經是 BODY,
1421
+ // 而 `children` 是 patch 前的快照,也是 plan 的索引所指的那份清單。
1422
+ const focusedBlockEl = document.activeElement &&
1423
+ document.activeElement.closest ? document.activeElement.closest('.ed-block') : null;
1424
+ const ci = focusedBlockEl ? children.indexOf(focusedBlockEl) : -1;
1425
+ const focusWasInSpan = ci >= 0 &&
1426
+ ci >= plan.replaceSpan.oldStart && ci <= plan.replaceSpan.oldEnd;
1026
1427
  // Fix round 1 (review Important 1): `blocks`/`lastParts` are the JOIN KEY
1027
1428
  // between the module's model and the DOM, and they are overwritten BEFORE
1028
1429
  // the DOM is touched — armEditables() reads the new `blocks` (see its call
@@ -1109,8 +1510,55 @@
1109
1510
  return applyFullRender(j, window.scrollY, declaredRange);
1110
1511
  }
1111
1512
 
1513
+ if (focusWasInSpan) {
1514
+ // 落在被置換區間內的游標,其節點已經被移除。把焦點放到對應的新節點
1515
+ // (夾在新中段的範圍內),caret 置尾。
1516
+ //
1517
+ // 位置刻意在 teardown 之前,而真正的機制是 fix round 1 的 review 查出來
1518
+ // 的這一條(第一版註解寫的「靠收尾那段焦點檢查認養」是錯的,那段在這條
1519
+ // 路徑上是死碼):`surface.focus()` 會【同步】觸發 document 層那個委派的
1520
+ // focusin。undo() 早已 await 完 switchAwayFrom(),所以 `switching` 與
1521
+ // `currentBurst` 都是 null——focusin 的兩個 early-return 都不成立——而新
1522
+ // 節點在 insertBefore 之前就被 armEditables(tpl.content) 武裝過了,於是
1523
+ // startBurst() 當場就跑完。打字有人追蹤、Ctrl+S 才寫得進檔案(實測拿掉
1524
+ // 這一塊時 undo 之後打的字連磁碟都到不了)。收尾最後那段
1525
+ // `if (!currentBurst && …)` 的焦點檢查因此在這條路徑上恆為 false。
1526
+ //
1527
+ // 代價要說清楚:下面的 `burstSurvived` 是在這裡【之後】才求值的,所以在
1528
+ // 走到這一塊的 render 上,它量到的是「剛剛由還原開出來的那個 burst」,
1529
+ // 而不是它註解所寫的「patch 前就開著、而且面活過 patch」。受影響的是三個
1530
+ // 以它為閘門的收尾項:currentBurst 的 dispose、resetSelToolbarState()、
1531
+ // resetToolbarBlock()——三者都會改走 survived 分支。結果是好的(剛開的
1532
+ // burst 不被丟掉、startBurst() 剛掛上的 selectionchange listener 不被拆、
1533
+ // toolbarBlockEl 保住 focusin 剛設好的活節點),但那是巧合而不是設計,
1534
+ // 所以在 `burstSurvived` 那裡也留了一行對照註解。不搬 `burstSurvived`
1535
+ // 到還原之前,是刻意的取捨:搬了會讓剛開的 burst 先被 dispose 再由收尾
1536
+ // 重開一次,並把每次 undo 的工具列 degrade 原封不動地留著。
1537
+ // teardown 各項彼此的相對順序沒有被動到,條件式的 activeEditor 歸零
1538
+ // 仍然在 DOM 手術之後求值。
1539
+ const newLen = plan.replaceSpan.newParts.length;
1540
+ if (newLen > 0) {
1541
+ // landing 恆落在新中段內:ci >= oldStart 保證非負,min() 夾住上界,
1542
+ // 而 patch 後的子節點是 prefix(keepPrefix) + 新中段(newLen) + suffix。
1543
+ // 零寬區間(insert,oldEnd === oldStart - 1)根本進不了 focusWasInSpan。
1544
+ const landing = plan.keepPrefix + Math.min(ci - plan.replaceSpan.oldStart, newLen - 1);
1545
+ const el = contentEl.children[landing];
1546
+ const surface = el ? blockContentEl(el) : null;
1547
+ if (surface) {
1548
+ surface.focus();
1549
+ try {
1550
+ const r = document.createRange();
1551
+ r.selectNodeContents(surface);
1552
+ r.collapse(false);
1553
+ const s = window.getSelection();
1554
+ s.removeAllRanges(); s.addRange(r);
1555
+ } catch (e) { /* best-effort,同 focusBlockAtLine 的處置 */ }
1556
+ }
1557
+ }
1558
+ }
1559
+
1112
1560
  // ── teardown ────────────────────────────────────────────────────────
1113
- // Controller ruling F1: the patch path runs the SAME twelve teardown items
1561
+ // Controller ruling F1: the patch path runs the SAME teardown items
1114
1562
  // applyFullRender() runs, unconditionally and in the same relative order.
1115
1563
  // The rationale comments below are copied from applyFullRender() because
1116
1564
  // they document WHY each one exists; the one thing that differs is the
@@ -1219,6 +1667,18 @@
1219
1667
  // 的**相對順序**與 applyFullRender() 一致,把 resetSelToolbarState() /
1220
1668
  // resetToolbarBlock() 搬到第 3 項旁邊才是真的改了順序。條件只算一次,
1221
1669
  // 而且算在會動到 currentBurst 的那一項之前(算完才輪得到把它歸零)。
1670
+ // ⚠ v3.2.1 fix round 1:這個節點 identity 判準本身沒變,但它求值的時機
1671
+ // 現在落在上面那塊焦點還原【之後】。還原的 `surface.focus()` 在
1672
+ // switchAwayFrom() 已經結算完的路徑上(undo()/redo())會【同步】開出一個新
1673
+ // 的 burst(見那裡的註解),所以在那種 render 上這裡量到的 true 說的是
1674
+ // 「現在有一個 burst,而且它的面在文件上」,不是「patch 前那個 burst 活下
1675
+ // 來了」。下面三個以它為閘門的項因此會走 survived 分支。實測(undo 走
1676
+ // patch 路徑):收尾後工具列 disabled 停在 7/22,不是 resetToolbarBlock()
1677
+ // 跑過該有的 18/22 —— 也就是 resetToolbarBlock() 確實被跳過了,這是
1678
+ // burstSurvived 為 true 的直接證據。反過來,若還原當下 currentBurst 還沒被
1679
+ // 歸零(別條路徑),focusin 的 `if (currentBurst) return;` 會擋掉開 burst,
1680
+ // 這裡照舊量到「舊 burst 的面被拔掉了」=false,收尾最後那段焦點檢查才是
1681
+ // 接手的人。兩種情形都不需要這一項改判準。
1222
1682
  const burstSurvived = !!(currentBurst && currentBurst.editEl
1223
1683
  && document.body.contains(currentBurst.editEl));
1224
1684
  // Task 2 (Phase 3): same belt-and-braces reasoning for the always-on
@@ -1284,11 +1744,10 @@
1284
1744
  // whose focus never leaves kills every block-dependent toolbar button until
1285
1745
  // the user clicks away and back (MEASURED on the smoke fixture: disabled
1286
1746
  // 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
1747
+ // next click). It has TWO further side effects (v3.2.1: the third was
1748
+ // preview mode's contenteditable re-arm, removed with the mode itself),
1749
+ // and both ride along safely: (1) updateToolbar() is re-derived from the
1750
+ // DOM on the next interaction; (2) closeToolbarMenu() — skipping it leaves
1292
1751
  // the H▾ dropdown standing across a patch, which is benign because
1293
1752
  // `toolbarMenuEl` is appended to document.body, not into a block, so the
1294
1753
  // patch never detaches it, and applyHeadingLevel() re-resolves through
@@ -1428,20 +1887,24 @@
1428
1887
  async function applyFullRender(j, scrollY, declaredRange) {
1429
1888
  blocks = j.blocks;
1430
1889
  lastParts = j.parts; // v3.2.0: 供下一次 patch 當 oldParts
1431
- contentEl.innerHTML = j.parts.join('\n'); // fallback 路徑用 join 還原
1432
- // Task 2 (Phase 3): re-arm every WYSIWYG-eligible paragraph/heading (and
1433
- // attach a ⠿ handle to every non-table block) in the freshly-swapped
1434
- // DOM — see armEditables() below. Must run before anything else touches
1435
- // the fresh nodes (diagram re-init, reader rebind, focus restoration).
1436
- armEditables(contentEl);
1890
+ // v3.2.1 R2: 必須先於 innerHTML 寫入。這個寫入 detach 掉聚焦的 raw
1891
+ // textarea,同步 focusout 重入 switchAwayFrom() → resolveOpenSession() →
1892
+ // commitNow(),於是同一次編輯提交兩次(實測衍生新 block 時磁碟上文字出現
1893
+ // 兩次;停在同一個 block 內則第二次回 op === null 退化成取消,所以看不出來)。
1437
1894
  // Whatever editor (if any) was open a moment ago just got detached by
1438
- // the innerHTML replacement above — its own restore()/commit() never
1895
+ // the innerHTML replacement below — its own restore()/commit() never
1439
1896
  // ran, so it never got a chance to null this out itself. Do it here,
1440
1897
  // unconditionally, on every successful swap: this is what makes the
1441
1898
  // undo/redo lockout regression (see the `activeEditor` comment above)
1442
1899
  // structurally impossible even from a call site that forgets to call
1443
1900
  // switchAwayFrom() first.
1444
1901
  activeEditor = null;
1902
+ contentEl.innerHTML = j.parts.join('\n'); // fallback 路徑用 join 還原
1903
+ // Task 2 (Phase 3): re-arm every WYSIWYG-eligible paragraph/heading (and
1904
+ // attach a ⠿ handle to every non-table block) in the freshly-swapped
1905
+ // DOM — see armEditables() below. Must run before anything else touches
1906
+ // the fresh nodes (diagram re-init, reader rebind, focus restoration).
1907
+ armEditables(contentEl);
1445
1908
  // §10-gap fix (review): same belt-and-braces reasoning for
1446
1909
  // `pristineInsert` — its window is meant to close via one of the
1447
1910
  // explicit resolution hooks (resolveBurst()/revertBurstAndEnd()/
@@ -1595,6 +2058,183 @@
1595
2058
  ta.style.height = (ta.scrollHeight + 2) + 'px';
1596
2059
  }
1597
2060
 
2061
+ // F9 —— raw 原始碼編輯器開啟時的 textarea 種子與 caret 落點。
2062
+ //
2063
+ // openRawEditor() 以前收尾一律是
2064
+ // `ta.setSelectionRange(ta.value.length, ta.value.length)`,每一種區塊都落
2065
+ // 在值的結尾。對 fenced code 那個位置在【收尾 fence 之後】。MEASURED
2066
+ // (test/editor-journey.test.js 的 F9 那一列,fixture
2067
+ // '# Doc\n\nAlpha.\n\n```js\nconst a = 1;\n```\n\nBravo.\n',用真的滑鼠
2068
+ // 按壓點進 code block):修之前 selectionStart = selectionEnd = 22,而值是
2069
+ // '```js\nconst a = 1;\n```',長度正是 22 —— caret 就在收尾那串反引號
2070
+ // 之後,打一個字進去,收尾那一行變成 '```Z',圍欄不再閉合。
2071
+ //
2072
+ // 這支回 `{ value, caret }`:`value` 是要放進 textarea 的字串(通常就是傳
2073
+ // 進來的原始碼;只有下面 b/b' 那些形狀會被動到),`caret` 是 offset。
2074
+ //
2075
+ // 規則不是「caret 永遠不會落在圍欄標記上」—— 那句話是假的,見下面的例外。
2076
+ // 規則是:**只要來源裡存在一個不在圍欄標記上的落點,就選它;空圍欄則種一行
2077
+ // 本文出來給它落**。
2078
+ //
2079
+ // a) 第一行是一道開頭圍欄(fenceOpenerOf())→ caret 落在下一行行首,那是
2080
+ // 第一個位在程式碼本文裡的 offset。
2081
+ // b) 【空的圍欄】(a 的次分類:下一行就是它的【配對】收尾圍欄)→ 本文區一
2082
+ // 行都沒有,兩端的候選位置都在圍欄標記上,**原始碼裡沒有安全的 offset**。
2083
+ // 所以在開頭與收尾圍欄之間種一行空白進 `value`,caret 落在那一行。
2084
+ // b') 同樣的空圍欄長在一段跨區塊 span 的【尾端】(⠿ 選單的「MD 原始碼」框
2085
+ // 得出來,例如 'Alpha.\n\n```js\n```')→ 同樣種一行。
2086
+ // c) 最後一行是某道圍欄的【配對】收尾、而第一行不是開頭圍欄 → 結尾落點會
2087
+ // 落在那道收尾圍欄上,改落在前一行的行尾。
2088
+ // 其餘一律維持結尾落點。
2089
+ //
2090
+ // caret 仍然會落在一行【長得像圍欄】的行上的情形(掃過一批來源形狀量到的 ——
2091
+ // 段落/引用/表格/清單項/html/縮排式 code/各種圍欄與跨區塊 span —— 這些
2092
+ // 情形都不是破壞):
2093
+ // - 來源只有一行,而那一行是一道孤零零的開頭圍欄 '```js'(⠿ 對一個只有開
2094
+ // 頭圍欄的 code 區塊就會給出這個)。沒有別的行可選;在那裡打字得到
2095
+ // '```jsX',仍然是一道開頭圍欄(info string 從 js 變成 jsX),區塊的收尾
2096
+ // 狀態不變。
2097
+ // - 來源尾端那一行長得像圍欄,但在這段來源裡【不收掉任何圍欄】,例如
2098
+ // 'Alpha.\n\n```js\ncode\n~~~':那道 ```js 沒有被收掉,'~~~' 是它的本文。
2099
+ // 在那裡打字只是改本文。
2100
+ // - a 分支落到的那一行【本身長得像圍欄】,因為它是外層圍欄的本文 —— 巢狀
2101
+ // 圍欄 '~~~\n```\nx\n```\n~~~' 的 caret 就落在第二行那個 ``` 的行首
2102
+ // (test/editor-journey.test.js 的巢狀圍欄那一列釘的正是這個落點)。在那裡
2103
+ // 打字得到 'Z```',那一行仍然只是外層圍欄的本文,內外圍欄的配對都不變。
2104
+ //
2105
+ // b 分支不會讓文件變髒:openRawEditor() 的 `hasChanges()` 比對的是這支回
2106
+ // 的 `value`(不是檔案裡那段 `source`),所以剛開起來的 textarea 仍然算
2107
+ // 「沒有變更」,關掉走的是 restore()/cancelAndMaybeDiscard() 這條不寫檔的
2108
+ // 路。`source` 本身留給 reanchor() 用,它比的是檔案的真實位元組。
2109
+ //
2110
+ // 刻意【沒有】照 task-5 brief 一併把 blockquote/image 改成「第一行行首」。
2111
+ // MEASURED(lib/editor/blockmap.js 的 buildBlockMap):
2112
+ // '> line one\n> line two' -> blockquote
2113
+ // 'X> line one\n> line two' -> paragraph, blockquote ← 行首落點
2114
+ // '> line one\n> line twoX' -> blockquote ← 結尾落點
2115
+ // 對 blockquote 而言「第一行行首」在 '>' 的前面,打第一個字就把那一行踢出
2116
+ // 引用區塊,比它要取代的結尾落點更糟。image 打在頭尾都等價('X![a](b.png)' 與
2117
+ // '![a](b.png)X' 都還是 paragraph)。
2118
+ //
2119
+ // 這支為什麼只動圍欄:圍欄是這些形狀裡唯一【結尾就是一個收尾標記】的。把一
2120
+ // 個字打在其他形狀的結尾只是加字 —— MEASURED(buildBlockMap,左為原文、右
2121
+ // 為結尾多打一個 X 之後的 type,左右相同就是沒壞):
2122
+ // '| A | B |\n|---|---|\n| 1 | 2 |' table -> table
2123
+ // ' a\n b'(縮排式 code) code -> code
2124
+ // '- alpha' li -> li
2125
+ // '<div>\nhi\n</div>' html -> html
2126
+ // '> a\n> b' blockquote -> blockquote
2127
+ // '![a](b.png)' paragraph -> paragraph
2128
+ // 圍欄的 type 也一樣是 code -> code,所以【單看一個孤立的區塊看不出差別】;
2129
+ // 差別在整份文件上:收尾圍欄被打壞之後就不再收尾,它後面的東西全部併進來。
2130
+ // 那是 test/editor-journey.test.js 的 F10 那一列量到的:5 個區塊掉到 3 個,
2131
+ // 尾巴進磁碟。
2132
+ // 一道圍欄的【開頭】,回 { char, len };不是開頭圍欄時回 null。
2133
+ //
2134
+ // fix round 2 (K1/K3): 這裡與 closesFence() 取代了原本那對只認「長得像圍欄」
2135
+ // 的 regex。收尾圍欄必須跟開頭圍欄【同一種字元、而且不比它短】,而反引號
2136
+ // 圍欄的 info string 不得再含反引號。它們各修各的:K1 那個被種進空白行的缺
2137
+ // 陷靠的是【收尾必須配對】那一條 —— 把 info string 那條單獨拿掉再掃一次圍欄形
2138
+ // 狀,巢狀那幾種逐字不變(只有名字裡有 backtick-info 的那些形狀會變)。info
2139
+ // string 那條自己的壞法在下面。
2140
+ //
2141
+ // 少了收尾配對那條,'~~~' 開頭、下一行是 '```' 的巢狀圍欄會被誤判成【空圍
2142
+ // 欄】,於是 rawEditorSeed() 那個 grown 分支往使用者的檔案裡種進一行空白,而
2143
+ // 且提交時真的寫到磁碟(40c9569 沒有這個問題,是 588ae30 引進的)。同一個誤判
2144
+ // 的另一面在 endsWithUnclosedFence():一份以光禿禿 '~~~' 收尾的文件會被當成
2145
+ // 「圍欄有收好」,真正的吞噬因此靜默。
2146
+ //
2147
+ // 少了 info string 那條:'``` a`b' 這一行 marked 當它是【段落】(反引號圍欄的
2148
+ // info string 不能含反引號),下一行才是真正的開頭圍欄。把段落那一行當成開頭
2149
+ // 圍欄之後,它會跟後面那道真的收尾圍欄配成一對,rawEditorSeed() 於是把 caret
2150
+ // 放到【真開頭圍欄】那一行的行首 —— 打一個字就把 '```js' 變成 'Z```js',開頭
2151
+ // 圍欄消失、收尾那道 ``` 反而變成開頭,它後面的東西被吃進去(實測 ablation
2152
+ // 之後 blocks 從 heading paragraph code[4,6] paragraph 變成
2153
+ // heading paragraph[3,5] code[6,8])。這條由 test/editor-journey.test.js 的
2154
+ // 「a backtick in the info string is not a fence opener」那一列釘住。
2155
+ function fenceOpenerOf(line) {
2156
+ if (typeof line !== 'string') return null;
2157
+ const m = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
2158
+ if (!m) return null;
2159
+ const char = m[1].charAt(0);
2160
+ if (char === '`' && m[2].indexOf('`') !== -1) return null;
2161
+ return { char: char, len: m[1].length };
2162
+ }
2163
+ // `line` 能不能收掉 `opener` 開的那道圍欄。
2164
+ //
2165
+ // fix round 3 (L2): 這支的需求是 **marked 的行為**,不是 CommonMark 的條文,
2166
+ // 也不是比較有道理的那個讀法 —— `j.blocks` 就是 marked 的 tokenizer 產出的,
2167
+ // 這支只要跟它講的不一樣,判別式就會拿一份跟畫面上不同的文件在推理。
2168
+ // marked 的 fences rule 收尾那一段是 `(?: {0,3}\1[~`]* *(?=\n|$)|$)`:最多三個
2169
+ // 前導空格、接著跟開頭圍欄【逐字相同】的那一串(同字元同長度,`\1` 是
2170
+ // backreference)、再接任意個 ` 或 ~、然後只能是空格。
2171
+ //
2172
+ // MEASURED(把候選行放進 '```\nx\n<候選>\nAFTER\n',看 buildBlockMap 有沒有
2173
+ // 把 AFTER 留在 code block 外面)—— 上一版跟 marked 在這些行上不一致,而且不
2174
+ // 一致的方向不是單向的:
2175
+ // '```' + 一個 tab marked 說【沒收】,上一版說收了 —— 正是 K3 那個 FN1 形狀
2176
+ // '``` ' + tab、'```' + tab + ' ' 同上
2177
+ // '```~~'(混合的尾巴) marked 說【收了】,上一版說沒收
2178
+ // 尾端只認空格(marked 是 ` *`,不是 `[ \t]*`),而開頭那一串之後可以再跟
2179
+ // 任意個 ` 或 ~。
2180
+ function closesFence(line, opener) {
2181
+ if (typeof line !== 'string' || !opener) return false;
2182
+ let i = 0;
2183
+ while (i < 3 && line.charAt(i) === ' ') i++;
2184
+ for (let k = 0; k < opener.len; k++) {
2185
+ if (line.charAt(i + k) !== opener.char) return false;
2186
+ }
2187
+ i += opener.len;
2188
+ while (i < line.length && (line.charAt(i) === '`' || line.charAt(i) === '~')) i++;
2189
+ while (i < line.length && line.charAt(i) === ' ') i++;
2190
+ return i === line.length;
2191
+ }
2192
+ // 把 rows 掃一遍,回傳每一對【配對成功】的圍欄 { openIdx, closeIdx }。
2193
+ // 沒有配到收尾的開頭圍欄不會出現在結果裡。
2194
+ function fencePairs(rows) {
2195
+ const pairs = [];
2196
+ let open = null;
2197
+ for (let i = 0; i < rows.length; i++) {
2198
+ if (open) {
2199
+ if (closesFence(rows[i], open.opener)) { pairs.push({ openIdx: open.idx, closeIdx: i }); open = null; }
2200
+ continue;
2201
+ }
2202
+ const o = fenceOpenerOf(rows[i]);
2203
+ if (o) open = { opener: o, idx: i };
2204
+ }
2205
+ return pairs;
2206
+ }
2207
+ function rowStartOffset(rows, idx) {
2208
+ let n = 0;
2209
+ for (let i = 0; i < idx; i++) n += rows[i].length + 1;
2210
+ return n;
2211
+ }
2212
+ function rawEditorSeed(text) {
2213
+ const rows = text.split('\n');
2214
+ // 只有一行的來源直接落在結尾。那一行是一道孤零零的開頭圍欄時,caret 就在
2215
+ // 標記行上 —— 見上面那段註解裡明講的那些例外。
2216
+ if (rows.length === 1) return { value: text, caret: text.length };
2217
+ const pairs = fencePairs(rows);
2218
+ if (fenceOpenerOf(rows[0])) {
2219
+ const first = pairs.filter(function (p) { return p.openIdx === 0; })[0];
2220
+ if (first && first.closeIdx === 1) {
2221
+ const grown = [rows[0], ''].concat(rows.slice(1));
2222
+ return { value: grown.join('\n'), caret: rowStartOffset(grown, 1) };
2223
+ }
2224
+ return { value: text, caret: rowStartOffset(rows, 1) };
2225
+ }
2226
+ const lastIdx = rows.length - 1;
2227
+ const tailPair = pairs.filter(function (p) { return p.closeIdx === lastIdx; })[0];
2228
+ if (tailPair) {
2229
+ if (tailPair.openIdx === lastIdx - 1) {
2230
+ const grown = rows.slice(0, lastIdx).concat(['']).concat(rows.slice(lastIdx));
2231
+ return { value: grown.join('\n'), caret: rowStartOffset(grown, lastIdx) };
2232
+ }
2233
+ return { value: text, caret: rowStartOffset(rows, lastIdx) - 1 };
2234
+ }
2235
+ return { value: text, caret: text.length };
2236
+ }
2237
+
1598
2238
  // v3.1.0 修正 4: `range` ({startLine, endLine}, 1-indexed inclusive) opens
1599
2239
  // the raw editor over an EXPLICIT span of source lines instead of the
1600
2240
  // block's own record. Two callers need it and one property makes both
@@ -1677,7 +2317,7 @@
1677
2317
  // delegator so
1678
2318
  // every caller — the delegated degraded-block click, openRawViaGutter(),
1679
2319
  // the burst-degrade path — is closed by one check.
1680
- if (blockOwnsNoLine(blockEl)) { refuseStructuralListEdit(NO_SOURCE_LINE_MESSAGE); return; }
2320
+ if (blockOwnsNoLine(blockEl)) { refuseStructuralListEdit(noSourceLineMessageFor(blockEl)); return; }
1681
2321
 
1682
2322
  // v3.1.0 修正 4: the span this editor owns. Without `range` it is exactly
1683
2323
  // the block's own record, so `source` below is byte-identical to what
@@ -1689,7 +2329,7 @@
1689
2329
  // The same inverted-range refusal blockOwnsNoLine() just made for the
1690
2330
  // block, re-asked of the range actually being edited: a caller-supplied
1691
2331
  // span can be malformed even when the anchor block is fine.
1692
- if (endLine < startLine) { refuseStructuralListEdit(NO_SOURCE_LINE_MESSAGE); return; }
2332
+ if (endLine < startLine) { refuseStructuralListEdit(noSourceLineMessageFor(blockEl)); return; }
1693
2333
  // A list item's rendered chrome (the .ed-li-marker span, the optional
1694
2334
  // .ed-li-check span, the .ed-li-text surface) is REBUILT BY THE RENDERER
1695
2335
  // from the source line's marker — it is not recoverable from a captured
@@ -1711,7 +2351,11 @@
1711
2351
 
1712
2352
  const ta = document.createElement('textarea');
1713
2353
  ta.className = 'ed-raw';
1714
- ta.value = source;
2354
+ // F9: `seed.value` 通常 === `source`;只有空圍欄那一種形狀會多一行
2355
+ // 空白本文(見 rawEditorSeed())。`source` 不可以跟著換掉 ——
2356
+ // reanchor() 拿它跟檔案裡的位元組逐字比。
2357
+ const seed = rawEditorSeed(source);
2358
+ ta.value = seed.value;
1715
2359
 
1716
2360
  // `restore`/`commit` are hoisted function declarations (defined further
1717
2361
  // down in this closure) — referencing them here, before their textual
@@ -1723,7 +2367,10 @@
1723
2367
  // commit()/restore() closures used by the manual Ctrl+Enter/✓/Esc/✕ UI.
1724
2368
  activeEditor = {
1725
2369
  blockEl,
1726
- hasChanges: () => ta.value !== source,
2370
+ // F9: 比對 `seed.value` 而不是 `source` —— 空圍欄那條路上種進去的那
2371
+ // 一行空白是這個編輯器自己放的,不是使用者打的,把它算成「有變更」
2372
+ // 會讓「開起來看一眼就關掉」變成一次靜默的寫檔。
2373
+ hasChanges: () => ta.value !== seed.value,
1727
2374
  commitNow: commit,
1728
2375
  cancelNow: cancelAndMaybeDiscard,
1729
2376
  reanchor,
@@ -1749,7 +2396,8 @@
1749
2396
  blockEl.appendChild(wrap);
1750
2397
  autoSize(ta);
1751
2398
  ta.focus();
1752
- ta.setSelectionRange(ta.value.length, ta.value.length);
2399
+ // F9: 不再無條件落在值的結尾 —— 見 rawEditorSeed() 自己的註解。
2400
+ ta.setSelectionRange(seed.caret, seed.caret);
1753
2401
 
1754
2402
  async function restore() {
1755
2403
  // v3.1.0 修正 4: `rangeMode` cannot restore by rewriting innerHTML —
@@ -1764,12 +2412,17 @@
1764
2412
  await safeRerenderAll();
1765
2413
  return;
1766
2414
  }
1767
- blockEl.innerHTML = original;
2415
+ // v3.2.1 R1: 清 activeEditor 必須先於這個 innerHTML 寫入。寫入會 detach
2416
+ // 聚焦的 textarea,Chromium 同步派發 focusout,而 focusout handler 只要
2417
+ // activeEditor 非 null 就會走 switchAwayFrom() → resolveOpenSession() →
2418
+ // commitNow(),把使用者按 Escape 要丟棄的文字提交進檔案(實測落到磁碟)。
2419
+ // 同一函式的 rangeMode 分支早就是這個順序,理由相同。
1768
2420
  // Only clear `activeEditor` if it's still THIS block's entry — it may
1769
2421
  // already have been cleared out from under us (e.g. by rerenderAll()'s
1770
2422
  // own defensive reset on a successful swap elsewhere), and this must
1771
2423
  // never stomp some OTHER block's activeEditor set after this one.
1772
2424
  if (activeEditor && activeEditor.blockEl === blockEl) activeEditor = null;
2425
+ blockEl.innerHTML = original;
1773
2426
  // No re-wiring needed here (unlike the old per-block gutter): block
1774
2427
  // selection / the edit bar are handled by ONE delegated `document`
1775
2428
  // click listener (see wireBlockSelection() below), so this
@@ -2019,7 +2672,8 @@
2019
2672
  // '# D\n\n- a\n\n- - b\n' --type Z--> '# D\n\n- a\n\n- Z\n- - b\n'
2020
2673
  //
2021
2674
  // Refusing here closes every such path with one rule, instead of teaching
2022
- // seven commit helpers about a range shape none of them expect. The block is
2675
+ // each commit helper that reaches ops.replaceLines() (grep
2676
+ // `ops.replaceLines(`) about a range shape none of them expect. The block is
2023
2677
  // still rendered, still selectable, and its CHILD — which does own its line —
2024
2678
  // stays fully editable.
2025
2679
  function blockOwnsNoLine(blockEl) {
@@ -2029,6 +2683,47 @@
2029
2683
  return !!rec && rec.endLine < rec.startLine;
2030
2684
  }
2031
2685
 
2686
+ // Two DIFFERENT things now arrive with an empty range, and blockOwnsNoLine()
2687
+ // cannot tell them apart because the range shape is identical:
2688
+ //
2689
+ // '- - a' the outer item genuinely owns no line — its content begins
2690
+ // with its child. A modelled, legitimate shape.
2691
+ // degraded blockmap.js could not LOCATE this item's nested list inside
2692
+ // the item's own text, so it emptied the whole subtree rather
2693
+ // than hand out line numbers it had guessed. The item owns a
2694
+ // line; nobody can say which.
2695
+ //
2696
+ // Answering the second with the first's wording ('沒有自己的來源行') would
2697
+ // tell the user something untrue about their document — that item HAS a
2698
+ // source line. So the flag blockmap.js sets on the degraded records is read
2699
+ // here, instead of re-deriving a cause from `endLine < startLine`, which
2700
+ // carries none.
2701
+ //
2702
+ // No markdown MEASURED so far reaches the second branch: every string a full
2703
+ // journey run hands buildBlockMap(), and every trailing-space variant of
2704
+ // those, produced no `unlocatable` records. So the branch is driven
2705
+ // instead by making the real blockmap search fail — grep
2706
+ // test/editor-journey.test.js for 'already degraded', which wraps marked's
2707
+ // lexer for one row so the server serves a genuinely degraded document, then
2708
+ // clicks the degraded item and reads the banner off the page. MEASURED by that
2709
+ // row, and only that row: the click renders UNLOCATABLE_LINE_MESSAGE, opens no
2710
+ // textarea, and leaves the file byte-identical.
2711
+ //
2712
+ // The other half of the pair is pinned somewhere else and it is worth knowing
2713
+ // where, because no single file renders both: test/editor-client-runtime.test.js
2714
+ // clicks the outer item of a '- - b' same-line nest and asserts the banner
2715
+ // reads NO_SOURCE_LINE_MESSAGE — grep it for '沒有自己的來源行'. Those two
2716
+ // assertions, in two files, are what keep the two causes from collapsing back
2717
+ // into one sentence. This branch is a net for a shape nobody has produced yet,
2718
+ // not a path.
2719
+ function blockRangeIsUnlocatable(blockEl) {
2720
+ if (!blockEl || !blockEl.getAttribute) return false;
2721
+ const raw = blockEl.getAttribute('data-block-id');
2722
+ if (raw === null) return false;
2723
+ const rec = blocks.find((b) => b.id === Number(raw));
2724
+ return !!(rec && rec.unlocatable);
2725
+ }
2726
+
2032
2727
  // Task 4 fix round 1: the message the two blockOwnsNoLine() guards above
2033
2728
  // (openRawEditor / deleteBlockViaGutter) pass into the SHARED
2034
2729
  // refuseStructuralListEdit() helper (defined further down, next to
@@ -2041,6 +2736,23 @@
2041
2736
  // operations it refuses — an insert that answered '無法刪除' would be
2042
2737
  // telling the user something that is not true of the button they pressed.
2043
2738
  const NO_SOURCE_LINE_INSERT_MESSAGE = '此項目沒有自己的來源行,無法在其後插入區塊';
2739
+ // The degraded-subtree wording (see blockRangeIsUnlocatable() above). Each
2740
+ // one NAMES ITS OPERATION exactly as the pair above does — that is the whole
2741
+ // reason the pair above is a pair — and differs from it only in the clause
2742
+ // that states the cause. Deliberately says nothing about the refusal being
2743
+ // temporary: nothing lifts it, so wording like 「已暫停」 would promise a state
2744
+ // change that never comes.
2745
+ const UNLOCATABLE_LINE_MESSAGE = '這段巢狀清單對不到自己的來源行,無法刪除或直接編輯';
2746
+ const UNLOCATABLE_LINE_INSERT_MESSAGE = '這段巢狀清單對不到自己的來源行,無法在其後插入區塊';
2747
+ // Both refusals go through one chooser each, so a call site cannot pick the
2748
+ // right guard and the wrong sentence.
2749
+ function noSourceLineMessageFor(blockEl) {
2750
+ return blockRangeIsUnlocatable(blockEl) ? UNLOCATABLE_LINE_MESSAGE : NO_SOURCE_LINE_MESSAGE;
2751
+ }
2752
+ function noSourceLineInsertMessageFor(blockEl) {
2753
+ return blockRangeIsUnlocatable(blockEl)
2754
+ ? UNLOCATABLE_LINE_INSERT_MESSAGE : NO_SOURCE_LINE_INSERT_MESSAGE;
2755
+ }
2044
2756
 
2045
2757
  // The list markers standing at the head of one source line, left to right.
2046
2758
  // A same-line nest ('- 1. b') puts one marker per nesting level on the line;
@@ -2511,6 +3223,151 @@
2511
3223
  sel.addRange(range);
2512
3224
  }
2513
3225
 
3226
+ // ── Task 15: the two selections Tab writes ───────────────────────────────
3227
+ //
3228
+ // Both go through setBaseAndExtent() rather than the removeAllRanges() +
3229
+ // addRange() pair placeCaretAtEnd() just above uses, because Tab now writes
3230
+ // a selection on every press and the spellings differ in what they
3231
+ // dispatch. Counted on a live cell, applying boundary values that were
3232
+ // ALREADY the ones in force: setBaseAndExtent() dispatched no
3233
+ // selectionchange, removeAllRanges() + addRange() dispatched one, and
3234
+ // selectNodeContents() followed by that pair dispatched one. The resulting
3235
+ // Range is otherwise the same: over a cell holding 'c2', both spellings of
3236
+ // "select the contents" read back start TD offset 0 to end TD offset 1
3237
+ // covering 'c2', and both spellings of each caret placement read back the
3238
+ // matching collapsed pair. On a cell emptied first, setBaseAndExtent()
3239
+ // placed a collapsed selection instead of throwing.
3240
+ //
3241
+ // The element-anchored boundary points are load-bearing beyond tidiness —
3242
+ // see `tabSelectedCellEl` further down, whose release condition is exactly
3243
+ // "the live selection is no longer these".
3244
+ function selectSurfaceContents(el) {
3245
+ const sel = window.getSelection();
3246
+ if (!sel) return;
3247
+ sel.setBaseAndExtent(el, 0, el, el.childNodes.length);
3248
+ }
3249
+
3250
+ function placeCaretAtSurfaceStart(el) {
3251
+ const sel = window.getSelection();
3252
+ if (!sel) return;
3253
+ sel.setBaseAndExtent(el, 0, el, 0);
3254
+ }
3255
+
3256
+ // ── Marks a Range operation leaves behind holding nothing ────────────────
3257
+ //
3258
+ // extractContents() and deleteContents() each act on an element the range
3259
+ // only PARTIALLY contains by taking its contained part and leaving the
3260
+ // original element in place. When the boundary sits at offset 0 of that
3261
+ // element's own first child, everything it held goes and the original is
3262
+ // left holding nothing that renders. Measured, bolding a selection that
3263
+ // starts at the first character of an <em>:
3264
+ //
3265
+ // before `Alpha <em>ital</em> bold text here.`
3266
+ // after `Alpha <em></em><strong><em>ital</em> bold</strong> text here.`
3267
+ //
3268
+ // Nothing downstream drops that <em>: it still serialises to its delimiter
3269
+ // on each side. The bytes measured on disk, one gesture per line, all from
3270
+ // the same selection:
3271
+ //
3272
+ // toolbar 粗體 `Alpha *****ital* bold** text here.`
3273
+ // toolbar 🔗 `Alpha **[*ital* bold](https://example.com) text here.`
3274
+ // Shift+Enter `Alpha **<br> text here.`
3275
+ // paste `Alpha **PASTED text here.`
3276
+ // Enter in a li `- Alpha **`
3277
+ //
3278
+ // Each of those re-parses with the stray asterisks as literal text and comes
3279
+ // back escaped on the next save — 「*字號前面都有 \ 跳脫」, the symptom this
3280
+ // was reported as. Journey row `escape-cycle` drives that whole cycle.
3281
+ //
3282
+ // Collecting and dropping are separate functions because the operation
3283
+ // between them differs per call site: extractContents() into a new element
3284
+ // (the mark and link paths), deleteContents() then an insert (Shift+Enter,
3285
+ // paste), and a delete plus a tail extraction (the list split, which folds
3286
+ // its delete range and its tail range into the same collection).
3287
+
3288
+ const EDIT_SURFACE_SEL = '.ed-wys-armed, .ed-wys-cell';
3289
+
3290
+ // The editing surface `node` sits in — a paragraph / heading / list-item
3291
+ // surface, or a table cell. This is what the walk below terminates at, and
3292
+ // the reason it names the CELL rather than the <table>: a cell left holding
3293
+ // nothing must stay a cell, and it does because the walk never reaches it.
3294
+ function editSurfaceOf(node) {
3295
+ const el = node && (node.nodeType === 1 ? node : node.parentNode);
3296
+ return el && el.closest ? el.closest(EDIT_SURFACE_SEL) : null;
3297
+ }
3298
+
3299
+ // The elements the operation could leave empty: the element ancestors of
3300
+ // `range`'s start container and of its end container, up to (not including)
3301
+ // `root`, each tagged with its element depth below `root`. Called BEFORE the
3302
+ // operation, because the Range's boundary points move the moment it runs.
3303
+ //
3304
+ // `into` lets a call site fold several ranges into the same collection; an
3305
+ // element already collected is not collected again.
3306
+ //
3307
+ // A boundary that is not inside `root` contributes nothing, and a falsy
3308
+ // `root` collects nothing at all. Without a terminator inside the editing
3309
+ // surface the walk would climb into the page chrome, and there is nothing up
3310
+ // there this function could nominate for removal. Making that true HERE rather than
3311
+ // trusting each caller is what lets the removal below dereference
3312
+ // `parentNode` unguarded.
3313
+ function collectLeftovers(range, root, into) {
3314
+ const leftovers = into || [];
3315
+ if (!root) return leftovers;
3316
+ const collect = (node) => {
3317
+ if (!node || !root.contains(node)) return;
3318
+ const chain = [];
3319
+ for (let n = node; n && n !== root; n = n.parentNode) {
3320
+ if (n.nodeType === 1) chain.push(n);
3321
+ }
3322
+ // `chain` is deepest-first, so `chain.length - i` is the element depth
3323
+ // below `root` — a value that compares correctly across chains, which a
3324
+ // plain per-chain index would not.
3325
+ chain.forEach((n, i) => {
3326
+ if (!leftovers.some((x) => x.el === n)) leftovers.push({ el: n, depth: chain.length - i });
3327
+ });
3328
+ };
3329
+ collect(range.startContainer);
3330
+ collect(range.endContainer);
3331
+ return leftovers;
3332
+ }
3333
+
3334
+ // Removes the collected elements the operation left holding nothing. Call it
3335
+ // AFTER whatever the call site puts in the range's place, so an element that
3336
+ // now holds the new node is not mistaken for an empty leftover.
3337
+ //
3338
+ // "Holds nothing" is `no child ELEMENT and no text`. Every conjunct of that
3339
+ // test has a journey row that reds when that conjunct alone is removed:
3340
+ //
3341
+ // * the text test alone would delete a mark left holding a <br> — a hard
3342
+ // break has no text, but it is content the user typed. Journey row
3343
+ // `keeps-br`.
3344
+ // * the element test alone would delete a mark left holding TEXT, which is
3345
+ // what a selection starting partway into that mark leaves behind. Journey
3346
+ // row `keeps-text`.
3347
+ //
3348
+ // What it is NOT is a child count. A fully-taken boundary text node is not
3349
+ // removed by extractContents() or by deleteContents(); its data is replaced
3350
+ // with the remainder, so the node survives holding the empty string. Probed
3351
+ // right after the extraction on the bold row — the surviving <em> reported
3352
+ // `<em></em>`, its childNodes still holding a text node whose data was "". A child-count test
3353
+ // never fires on the very shape this exists for.
3354
+ //
3355
+ // Deepest first, so a mark left empty only by the removal of an inner one is
3356
+ // still seen as empty when its own turn comes. Journey row `nested-order`.
3357
+ //
3358
+ // `parentNode` is dereferenced unguarded: a candidate is an ancestor of a
3359
+ // boundary container and a strict descendant of `root` — collectLeftovers()
3360
+ // enforces the descendant part rather than trusting callers. A partially
3361
+ // contained ancestor is exactly what these Range methods leave in place: the
3362
+ // `before`/`after` measurement above is that, seen directly. And the
3363
+ // deepest-first order means a candidate is reached only after every candidate
3364
+ // it contains, so nothing detaches a candidate before its turn.
3365
+ function dropEmptied(leftovers) {
3366
+ leftovers.slice().sort((a, b) => b.depth - a.depth).forEach((x) => {
3367
+ if (!x.el.firstElementChild && x.el.textContent === '') x.el.parentNode.removeChild(x.el);
3368
+ });
3369
+ }
3370
+
2514
3371
  // Paste handler support: insert plain text at the caret via Range surgery,
2515
3372
  // keeping the serializer's input domain closed to plain text + the inline
2516
3373
  // elements it itself produces (bold/italic/code/links/br) — see the brief's
@@ -2533,6 +3390,9 @@
2533
3390
  const sel = window.getSelection();
2534
3391
  if (!sel.rangeCount) return;
2535
3392
  const range = sel.getRangeAt(0);
3393
+ // Same leftover as the toolbar's: a paste over a selection that starts at
3394
+ // the first character of an <em> left `Alpha **PASTED text here.` on disk.
3395
+ const leftovers = collectLeftovers(range, editSurfaceOf(range.startContainer));
2536
3396
  range.deleteContents();
2537
3397
  const segments = String(text).split(/\r\n|\r|\n/);
2538
3398
  segments.forEach((seg, i) => {
@@ -2547,6 +3407,7 @@
2547
3407
  range.setStartAfter(node);
2548
3408
  range.setEndAfter(node);
2549
3409
  });
3410
+ dropEmptied(leftovers);
2550
3411
  sel.removeAllRanges();
2551
3412
  sel.addRange(range);
2552
3413
  }
@@ -2560,11 +3421,20 @@
2560
3421
  const sel = window.getSelection();
2561
3422
  if (!sel.rangeCount) return;
2562
3423
  const range = sel.getRangeAt(0);
3424
+ // Same leftover as the toolbar's: Shift+Enter over a selection that starts
3425
+ // at the first character of an <em> left `Alpha **<br> text here.` on disk.
3426
+ const leftovers = collectLeftovers(range, editSurfaceOf(range.startContainer));
2563
3427
  range.deleteContents();
2564
3428
  const br = document.createElement('br');
2565
3429
  range.insertNode(br);
2566
3430
  range.setStartAfter(br);
2567
3431
  range.setEndAfter(br);
3432
+ // After the <br> is in place, because the <br> can land INSIDE a mark this
3433
+ // very delete emptied and that mark must then be kept. Measured, Shift+Enter
3434
+ // over a selection covering the whole of `<em>ital</em>`:
3435
+ // `Alpha <em><br></em> bold text here.` — dropping before the insert would
3436
+ // have taken that <em> and the caret's new home with it.
3437
+ dropEmptied(leftovers);
2568
3438
  sel.removeAllRanges();
2569
3439
  sel.addRange(range);
2570
3440
  }
@@ -2576,7 +3446,51 @@
2576
3446
  // switchAwayFrom() first resolves any editor that's currently open on this
2577
3447
  // (or another) block, same precondition as undo()/redo() below, so this
2578
3448
  // never races a concurrent commit or operates on stale `lines`/`blocks`.
3449
+ //
3450
+ // v3.2.1 final wave: THIN WRAPPER + `finally`, the same shape the other four
3451
+ // sinks use, because this function's own early exits were live holes in
3452
+ // exactly the promise this version's CHANGELOG makes. MEASURED on ddac6ce
3453
+ // with real keystrokes, three runs each: typing in `# Title` then Shift+Tab
3454
+ // went H1.ed-wys-armed / 15 enabled → BODY / 4 enabled, and `###### Title`
3455
+ // then Tab did the same; `## Title` + Tab (the unclamped control) stayed
3456
+ // H3.ed-wys-armed / 15. The clamp is reachable from an ordinary gesture:
3457
+ // handleBurstKeydown()'s `changeHeadingDepth(currentBurst.blockEl,
3458
+ // e.shiftKey ? -1 : 1)` passes a raw ±1 with no `delta === 0` filter (unlike
3459
+ // applyHeadingLevel(), which computes a delta and can hand over 0), so Tab
3460
+ // at either end of H1..H6 lands on `newDepth === curDepth` — an early return
3461
+ // that sits BELOW the switchAwayFrom() that already committed and re-rendered
3462
+ // and ABOVE the restore at the tail.
3463
+ //
3464
+ // The body answers a line on every exit it can name one for:
3465
+ // * success result.op.startLine (the commit's own first
3466
+ // written line — see the note at the tail)
3467
+ // * newDepth === curDepth block.startLine
3468
+ // * result.op === null block.startLine
3469
+ // `block` is looked up out of `blocks` AFTER switchAwayFrom(), so its
3470
+ // startLine is already a post-render coordinate. INSTRUMENTED at that exit
3471
+ // rather than reasoned: on `# Title` typed into and then Shift+Tab-ed, the
3472
+ // clamped return printed
3473
+ // {sameBlocksArray:false, sameNode:false, blockId:0, startLine:1,
3474
+ // resolvesToId:0, resolvedText:'TitleX'}
3475
+ // — switchAwayFrom() had replaced both the `blocks` array and the heading's
3476
+ // DOM node, and `block.startLine` still resolved to the LIVE block carrying
3477
+ // the just-committed text, which is exactly what focusBlockAtLine() needs.
3478
+ //
3479
+ // The two re-query failures above it (`blockEl` gone, `block` not in
3480
+ // `blocks`) deliberately answer nothing: there is no block left to aim at,
3481
+ // and guessing a line is worse than restoring no caret.
2579
3482
  async function changeHeadingDepth(blockEl, delta) {
3483
+ let focusLine = null;
3484
+ const anchor = {};
3485
+ try {
3486
+ const out = await changeHeadingDepthBody(blockEl, delta, anchor);
3487
+ if (out != null) focusLine = out;
3488
+ } finally {
3489
+ restoreAfterStructuralOp(focusLine, anchor.line);
3490
+ }
3491
+ }
3492
+
3493
+ async function changeHeadingDepthBody(blockEl, delta, anchor) {
2580
3494
  if (!blockEl) return;
2581
3495
  const blockId = Number(blockEl.getAttribute('data-block-id'));
2582
3496
  // Commits (never discards) whatever burst/editor is open first — same
@@ -2609,6 +3523,9 @@
2609
3523
  }
2610
3524
  const block = blocks.find((b) => b.id === blockId);
2611
3525
  if (!block) return;
3526
+ // Post-switchAwayFrom(), post-render: the landing for every exit below
3527
+ // that writes nothing. See restoreAfterStructuralOp().
3528
+ if (anchor) anchor.line = block.startLine;
2612
3529
  const curLine = lines[block.startLine - 1];
2613
3530
  const curDepth = headingDepthOf(curLine);
2614
3531
  const newDepth = Math.max(1, Math.min(6, curDepth + delta));
@@ -2627,6 +3544,28 @@
2627
3544
  if (!okRender) {
2628
3545
  lines = rollbackFailedRender({ lines, stack }, result, prevLines);
2629
3546
  }
3547
+ // v3.2.1 (controller ruling T5-1): the OTHER sink. H▾ on a block that is
3548
+ // ALREADY a heading routes HERE, not to convertBlockViaMenu() —
3549
+ // applyHeadingLevel() takes that road only for a NON-heading — so without
3550
+ // this, H2 → H4 still ends the way every conversion used to: the render's
3551
+ // teardown ran resetToolbarBlock(), activeElement is BODY and the bar is
3552
+ // down to 4 enabled buttons, and a second operation needs a fresh click.
3553
+ //
3554
+ // Same predicate and the same measurement as convertBlockViaMenu()'s exit
3555
+ // — the wrapper's restoreAfterStructuralOp() is now literally the same
3556
+ // function, so see its note for why it is `blockSelection` and not operand
3557
+ // cardinality, and why forcing a caret while a selection stands
3558
+ // manufactures a broken state rather than fixing one.
3559
+ //
3560
+ // The line is `result.op.startLine`, the commit's OWN first written line:
3561
+ // commitEdit() resolves the block and hands commitRangeEdit() that
3562
+ // startLine, which is where replaceLines() writes `after`. This function
3563
+ // declares no collapse of its own (it is not a set operation), so the
3564
+ // commit result is the only post-commit value it holds — and it is a
3565
+ // RESULT, not a pre-commit `block.startLine` capture. An insertion above
3566
+ // this block cannot have happened between the two, because the commit IS
3567
+ // the only thing that ran.
3568
+ return result.op.startLine;
2630
3569
  }
2631
3570
 
2632
3571
  // ── Task 2 (Phase 3): always-on WYSIWYG editing + burst undo ───────────
@@ -3043,6 +3982,21 @@
3043
3982
  });
3044
3983
  sub.style.top = anchorBtn.offsetTop + 'px';
3045
3984
  anchorBtn.parentNode.appendChild(sub);
3985
+ // Clamp the panel's top into the viewport. Measured on this branch:
3986
+ // opening ⠿ then hovering 轉換成 never reads window.innerHeight before
3987
+ // this line, confirming the clamp below did not already exist somewhere
3988
+ // else in this path. getBoundingClientRect() is read after the append
3989
+ // above, so the height it reports is the panel as laid out here, not a
3990
+ // guessed constant, and it already reflects .ed-handle-submenu's own
3991
+ // max-height cap (lib/md2doc.js) when a short viewport has forced that
3992
+ // cap to bite. Its `bottom` already carries the containing block's
3993
+ // screen position, because the `top` set above resolves against that
3994
+ // block (position: absolute). Shifting `top` up by the bottom overflow
3995
+ // is the clamp.
3996
+ const overflowBottom = sub.getBoundingClientRect().bottom - window.innerHeight;
3997
+ if (overflowBottom > 0) {
3998
+ sub.style.top = (anchorBtn.offsetTop - overflowBottom) + 'px';
3999
+ }
3046
4000
  convertSubmenu = sub;
3047
4001
  convertSubmenuViaHover = !!viaHover;
3048
4002
  // The aim triangle needs a previous sample to have an apex; the first
@@ -3377,21 +4331,19 @@
3377
4331
  if (kind === 'code') {
3378
4332
  // Code blocks are never WYSIWYG-armed (armEditables() above has no
3379
4333
  // 'code' branch) — same degraded-block contract as any other fence:
3380
- // click opens the raw in-place source editor directly. openRawEditor()
3381
- // itself places the caret at the END of the textarea (its generic
3382
- // contract, shared with every other raw-edit open path) — that lands
3383
- // AFTER the closing fence, not on the blank BODY line
3384
- // BLOCK_SKELETONS['code'] (see its own comment) puts there
3385
- // specifically so typing has somewhere to land. Move it there once
3386
- // the textarea exists.
4334
+ // click opens the raw in-place source editor directly.
4335
+ //
4336
+ // F9: this branch used to re-place the caret itself, because
4337
+ // openRawEditor() put it at the END of the textarea — i.e. after the
4338
+ // closing fence, not on the blank BODY line BLOCK_SKELETONS['code']
4339
+ // (`['```', '', '```']`, see its own comment) puts there specifically
4340
+ // so typing has somewhere to land. openRawEditor() now asks
4341
+ // rawEditorSeed() (see its own comment), and for that skeleton — whose
4342
+ // middle row is '' and therefore not a bare fence — it returns the
4343
+ // same offset this branch used to compute, indexOf('\n') + 1, with the
4344
+ // value untouched. So the fix-up is deleted rather than kept as a
4345
+ // no-op that reads like it is still load-bearing.
3387
4346
  await openRawEditor(blockEl);
3388
- const ta = blockEl.querySelector('textarea.ed-raw');
3389
- if (ta) {
3390
- const nlIdx = ta.value.indexOf('\n');
3391
- const pos = nlIdx === -1 ? ta.value.length : nlIdx + 1;
3392
- ta.setSelectionRange(pos, pos);
3393
- ta.focus();
3394
- }
3395
4347
  return;
3396
4348
  }
3397
4349
  if (kind === 'table') {
@@ -3457,7 +4409,7 @@
3457
4409
  // li block grows no + until S2; S2 is next, and a guard that has to be
3458
4410
  // remembered later is a guard that will not be.
3459
4411
  if (blockOwnsNoLine(liveBlockEl)) {
3460
- refuseStructuralListEdit(NO_SOURCE_LINE_INSERT_MESSAGE);
4412
+ refuseStructuralListEdit(noSourceLineInsertMessageFor(liveBlockEl));
3461
4413
  return;
3462
4414
  }
3463
4415
  const customLines = opts && Array.isArray(opts.lines) ? opts.lines : null;
@@ -3522,7 +4474,7 @@
3522
4474
  // commitBlockInsertion() is about to read `endLine` and `lines[endLine]`
3523
4475
  // off. Re-checked against the block actually used.
3524
4476
  if (blockOwnsNoLine(anchorEl)) {
3525
- refuseStructuralListEdit(NO_SOURCE_LINE_INSERT_MESSAGE);
4477
+ refuseStructuralListEdit(noSourceLineInsertMessageFor(anchorEl));
3526
4478
  return;
3527
4479
  }
3528
4480
  }
@@ -3566,6 +4518,26 @@
3566
4518
  const target = blocks.find((b) => b.startLine === result.newStartLine);
3567
4519
  if (target) pristineInsert = { blockId: target.id };
3568
4520
  await focusInsertedBlock(result.newStartLine, kind);
4521
+ } else if (kind === 'line') {
4522
+ // v3.2.1: <hr> 沒有 placeholder,所以不 focusInsertedBlock()(那是上面
4523
+ // 那個 `kind !== 'line'` 既有的刻意 opt-out)。這一支只做工具列復原:把
4524
+ // toolbarBlockEl 指向新插入的 hr block,讓 bar 不要停在 render teardown
4525
+ // 留下的 4 顆。
4526
+ //
4527
+ // ⚠ 明說量測到的事,不要說希望發生的事:**caret 沒有被還原**。實測
4528
+ // (alpha / bravo / charlie,點進 bravo 再按 分隔線)這一支停用時是
4529
+ // {activeElement: BODY, enabled: 4}、啟用後是 {activeElement: BODY,
4530
+ // enabled: 15},兩者磁碟 byte-identical。也就是說使用者按完分隔線仍然
4531
+ // 沒有游標,要再點一次 block 才能繼續打字 —— 工具列復原就是這一支交付的
4532
+ // 全部。要連 caret 一起還原是另一個決定(游標該落在錨點 block 還是 hr
4533
+ // 之後的 block),不在本 task 的範圍內。
4534
+ //
4535
+ // Final wave, M4: gated on `blockSelection` like the other four restore
4536
+ // sites. Harmless today — a + 分隔線 pressed with a set standing writes
4537
+ // no declareCollapse(), so re-aiming the bar at the hr cannot contradict
4538
+ // a tinted set the way a forced caret would — but "the one site that does
4539
+ // not ask the question" is how the next divergence gets in.
4540
+ if (!blockSelection) reaimToolbarBlockAtLine(result.newStartLine);
3569
4541
  }
3570
4542
  }
3571
4543
 
@@ -3685,7 +4657,34 @@
3685
4657
  // already been raised — otherwise `{ els, recs, batch }`: the LIVE block
3686
4658
  // elements in document order, their records out of `blocks` (by reference),
3687
4659
  // and whether more than one block is being operated on.
3688
- async function resolveGutterOperands(blockEl) {
4660
+ //
4661
+ // v3.2.1 final wave: `anchor` is an optional OUT-PARAMETER. When given, its
4662
+ // `.line` is set to the grip block's start line as soon as that block has
4663
+ // been re-resolved against the post-switchAwayFrom() `blocks` — i.e. a
4664
+ // number that is already valid on the freshly rendered DOM. Every refusal
4665
+ // BELOW that assignment therefore leaves the caller holding a live
4666
+ // coordinate for a block the refusal did not touch.
4667
+ //
4668
+ // FIVE exits sit ABOVE it and all of them leave `.line` undefined, which is
4669
+ // the honest answer in each case (N3 — the first version of this sentence
4670
+ // said "the two exits", the same undercount M2 existed to fix, so they are
4671
+ // counted out here one by one):
4672
+ // 1. `if (!blockEl) return null` — no gesture at all.
4673
+ // 2. `if (!ok) return null` — switchAwayFrom()'s commit FAILED
4674
+ // and the session it could not commit is still open with its own
4675
+ // banner; aiming a caret anywhere would fight it.
4676
+ // 3. the dropped-gesture re-resolve — the block is genuinely gone.
4677
+ // 4. the no-source-line refusal — the block is there, but its range
4678
+ // is inverted (endLine === startLine - 1), and
4679
+ // `blocks.find(b => b.startLine === …)` would resolve some OTHER block.
4680
+ // 5. `if (!rec)` (a second DROPPED_GESTURE) — the one the undercount lost.
4681
+ // It is NOT absorbed by 4: blockOwnsNoLine() is `!!rec && rec.endLine <
4682
+ // rec.startLine`, so it answers FALSE when the id is absent from
4683
+ // `blocks`, and it also answers false for a provisional block whose
4684
+ // `data-block-id` is null (blockRecOf() returns null for both). Either
4685
+ // way there is no record to read a startLine off.
4686
+ // See restoreAfterStructuralOp().
4687
+ async function resolveGutterOperands(blockEl, anchor) {
3689
4688
  if (!blockEl) return null;
3690
4689
  // Finding 5a's mousedown preventDefault() deliberately keeps a dirty burst
3691
4690
  // alive across the ⠿ press, so the commit that lands inside
@@ -3708,9 +4707,10 @@
3708
4707
  // A block that owns no source line has an INVERTED range
3709
4708
  // (endLine === startLine - 1) and every commit helper handed one does
3710
4709
  // something plausible and wrong.
3711
- if (blockOwnsNoLine(liveBlockEl)) { refuseStructuralListEdit(NO_SOURCE_LINE_MESSAGE); return null; }
4710
+ if (blockOwnsNoLine(liveBlockEl)) { refuseStructuralListEdit(noSourceLineMessageFor(liveBlockEl)); return null; }
3712
4711
  const rec = blockRecOf(liveBlockEl);
3713
4712
  if (!rec) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return null; }
4713
+ if (anchor) anchor.line = rec.startLine;
3714
4714
 
3715
4715
  const res = selectionLib
3716
4716
  ? selectionLib.resolveMembership(blockSelection, blocks, rec)
@@ -3752,7 +4752,7 @@
3752
4752
  // but "another module filters it" is an argument about another file's
3753
4753
  // output, and this is the guard that stands between an inverted range and
3754
4754
  // a commit helper.
3755
- if (blockOwnsNoLine(el)) { refuseStructuralListEdit(NO_SOURCE_LINE_MESSAGE); return null; }
4755
+ if (blockOwnsNoLine(el)) { refuseStructuralListEdit(noSourceLineMessageFor(el)); return null; }
3756
4756
  els.push(el);
3757
4757
  }
3758
4758
  return { els: els, recs: members, batch: members.length > 1 };
@@ -3884,7 +4884,168 @@
3884
4884
  return run;
3885
4885
  }
3886
4886
 
4887
+ // ── v3.2.1 final wave: ONE landing for all four structural sinks ────────
4888
+ //
4889
+ // The v3.2.1 wrappers restored only on the SUCCESS line the body answered.
4890
+ // Measured on ddac6ce, 轉換成 → 引用 on a hard-wrapped list item (an ENABLED
4891
+ // menu item — `disabled === false`, measured) whose own burst was dirty:
4892
+ // DIV.ed-li-text ed-wys-armed / 16 enabled → BODY / 4 enabled, plus the
4893
+ // banner 「此清單含不支援的格式,無法調整結構」. The `finally` WAS reached;
4894
+ // `focusLine` was simply null there, so focusBlockAtLine() was skipped and
4895
+ // reaimToolbarBlockAtLine(null) returned on its first line — while
4896
+ // resolveGutterOperands()'s own switchAwayFrom() had already committed the
4897
+ // burst and re-rendered, which is what took the foothold away.
4898
+ //
4899
+ // Hence the second coordinate. `focusLine` is the body's SUCCESS landing and
4900
+ // is used exactly as before. `anchorLine` is resolveGutterOperands()'s live
4901
+ // grip line (see its `anchor` out-parameter) and is used ONLY when the body
4902
+ // answered nothing — a refusal, a no-op commit, or a rolled-back render.
4903
+ //
4904
+ // The foothold check on that second path is deliberate and is NOT symmetric
4905
+ // with the first. A refusal can also arrive with the user's caret still
4906
+ // exactly where they left it (a CLEAN burst goes out through
4907
+ // endBurstWithoutResolve(): no commit, no render, focus never moves —
4908
+ // measured: 轉換成 → 引用 on the same hard-wrapped li WITHOUT typing first
4909
+ // leaves DIV.ed-li-text ed-wys-armed / 16 enabled on ddac6ce). Restoring
4910
+ // unconditionally there would drag that caret to the end of the block for no
4911
+ // reason — the same mistake fix round 1 corrected in applyLinkToggle(), and
4912
+ // this is the same predicate it settled on.
4913
+ //
4914
+ // M3, the predicate divergence: the four sinks now AGREE that a rolled-back
4915
+ // render restores. Verified by reading rather than driven (a render failure
4916
+ // needs the server to fail mid-gesture): every `return false` inside
4917
+ // rerenderAll() is one of its four fetch/parse exits, all of them above the
4918
+ // call to applyRenderResult(); applyPatch() and applyFullRender() contain no
4919
+ // `return false` at all (grep: zero hits between them). So a `false` answer
4920
+ // from safeRerenderAll() means the DOM was never touched — the anchor block
4921
+ // is still on screen under the same id — while the switchAwayFrom() that ran
4922
+ // BEFORE it may already have re-rendered and reset the bar. Restoring is
4923
+ // therefore the right answer, and returning empty-handed (what
4924
+ // duplicateBlockViaMenuBody() and deleteBlockViaGutterBody() used to do on
4925
+ // `!okRender`) strands the user in exactly the I1/I2 shape. The one failure
4926
+ // this does NOT cover is a THROW inside applyPatch()/applyFullRender(),
4927
+ // caught by safeRerenderAll(): that can leave the DOM half-swapped, and
4928
+ // neither the old behaviour nor this one has an answer for it.
4929
+ function restoreAfterStructuralOp(focusLine, anchorLine) {
4930
+ // 有 blockSelection 站著時什麼都不做 —— 見 convertBlockViaMenu() 底下那段
4931
+ // 註解:強行還原 caret 會做出「底色仍說已選取、Delete 卻是死鍵」的混種狀態。
4932
+ if (blockSelection) return;
4933
+ if (focusLine != null) {
4934
+ focusBlockAtLine(focusLine, true); // caretToEnd
4935
+ // 降級目標(quote / code)沒有可聚焦編輯面,focusBlockAtLine 會安靜
4936
+ // no-op;把 toolbarBlockEl 指回那個 block,工具列才不會塌成 4 顆。
4937
+ reaimToolbarBlockAtLine(focusLine);
4938
+ return;
4939
+ }
4940
+ if (anchorLine == null) return;
4941
+ const held = document.activeElement;
4942
+ const heldSurface = held && held.closest
4943
+ ? held.closest('.ed-wys-armed, .ed-wys-cell, .ed-raw') : null;
4944
+ if (heldSurface) {
4945
+ // ⚠ C1. "The caret never left, so there is nothing to do" was TRUE of the
4946
+ // caret and FALSE of the toolbar, and the first version of this guard
4947
+ // skipped both. The bar can be down to 4 while the caret is still sitting
4948
+ // on a live surface, and that is the ORDINARY case, not an edge one: it
4949
+ // is what happens on the PATCH render path — most commits past the
4950
+ // first page load take that route, but the FALLBACK stays reachable
4951
+ // through the whole session on its own routes, not just the first
4952
+ // render: `domMatchesLastRender()` (see its own comment) fails on a
4953
+ // block-count, dom-child-count, or per-block-id mismatch just as
4954
+ // readily as on a null `lastParts`, and `safeRerenderAll()`'s
4955
+ // no-`editRange` callers — restore()'s rangeMode exit, the per-li
4956
+ // unsupported-format refusal, commitListStructure()'s no-range exit
4957
+ // (see the wrapper's own comment just above it) — fall back by
4958
+ // contract regardless of dom state.
4959
+ //
4960
+ // INSTRUMENTED inside applyPatch() on a primed ⠿刪除 refusal, one log
4961
+ // line per step, rather than read off the source:
4962
+ // route PATCH
4963
+ // focusWasInSpan? v:true currentBurst:true burstElAttached:false
4964
+ // active:BODY
4965
+ // afterFocusRestore active:DIV.ed-li-text ed-wys-armed
4966
+ // toolbarBlockElNull:false
4967
+ // burstSurvived v:false currentBurst:true
4968
+ // active:DIV.ed-li-text ed-wys-armed
4969
+ // resetToolbarBlock? willRun:true
4970
+ // afterResetToolbarBlock toolbarBlockElNull:true
4971
+ // restoreAfterStructuralOp active:DIV.ed-li-text ed-wys-armed
4972
+ // toolbarBlockElNull:true
4973
+ // — applyPatch()'s `focusWasInSpan` block hands focus back to the NEW
4974
+ // surface via `surface.focus()`, which dispatches focusin
4975
+ // SYNCHRONOUSLY. The first version of this comment read `currentBurst`
4976
+ // as still the OLD burst at guard time, with focusin's
4977
+ // `if (currentBurst) return;` stopping a fresh one from opening — that
4978
+ // was never measured, and does not hold on this route: `switching` is
4979
+ // truthy here (this very commit is the render switchAwayFrom() is
4980
+ // awaiting), so the handler suspends at `if (switching) await
4981
+ // switching;`, the line immediately before that guard, and cannot get
4982
+ // past it until this same call to applyPatch() returns. By then
4983
+ // applyPatch()'s OWN focus-adoption tail has already run — the
4984
+ // `if (!currentBurst && ae && contentEl.contains(ae)) { … startBurst(ae);
4985
+ // … }` further down this function, whose own comment notes undo's
4986
+ // synchronous focusin leaves that check "恆為 false" there (currentBurst
4987
+ // already set by then) — the mirror image of this route, where
4988
+ // currentBurst is null at that point and the check fires, opening a
4989
+ // fresh burst on the very surface `focus()` landed on. The suspended
4990
+ // focusin's `if (currentBurst) return;` reads THAT burst once it
4991
+ // resumes, not the pre-patch one persisting. Either way `burstSurvived`
4992
+ // is false and `resetToolbarBlock()` runs AFTER the caret was handed
4993
+ // back — that half stood correct and still does. The unprimed run of
4994
+ // the same gesture took the FALLBACK route and arrived here with
4995
+ // active:BODY, which is why the first wave's measurements never saw
4996
+ // this.
4997
+ //
4998
+ // So: do not drag the caret (it never left), but DO re-aim the bar, at
4999
+ // the block the caret is actually in rather than at `anchorLine` — those
5000
+ // are the same block on every path measured, and when they ever differ
5001
+ // the caret's own block is the honest answer.
5002
+ const blk = heldSurface.closest('.ed-block');
5003
+ if (blk && document.body.contains(blk)) { toolbarBlockEl = blk; updateToolbar(); }
5004
+ return;
5005
+ }
5006
+ focusBlockAtLine(anchorLine, true); // caretToEnd
5007
+ reaimToolbarBlockAtLine(anchorLine);
5008
+ }
5009
+
5010
+ // v3.2.1: the caret restore is a THIN WRAPPER around the conversion body so
5011
+ // that it sits at the function's EXIT and every exit reaches it. The body's
5012
+ // pre-commit refusal exits, enumerated (final wave, M2 — the sentence that
5013
+ // stood here said "three" and named three of them):
5014
+ // 1. `if (!operands) return` — resolveGutterOperands() answered
5015
+ // null (dropped gesture, no source
5016
+ // line, empty/gapped/no-element
5017
+ // batch)
5018
+ // 2. the mixed list/non-list refusal (BATCH_MIXED_MESSAGE)
5019
+ // 3. the withheld-type refusal (BATCH_CONVERT_WITHHELD_MESSAGES)
5020
+ // 4. `if (!liRun)` (BATCH_MULTIRUN_MESSAGE)
5021
+ // 5. `!listRunSupportsStructuralEdit()` (§4.1's run-wide gate)
5022
+ // 6. `if (!stripped.ok)` (per-block 「此區塊的格式無法轉換」)
5023
+ // — six, not three, plus `result.op === null` (a commit that wrote nothing)
5024
+ // and the rolled-back render on the tail. Every one of them is reachable
5025
+ // from a lit ⠿ menu item, and the li → list / li → non-list / blocks → list
5026
+ // helpers this function tail-calls carry their own refusals on top.
5027
+ //
5028
+ // The body returns the operated block's post-commit start line, or undefined
5029
+ // when it refused or wrote nothing — `anchor.line` is what covers the
5030
+ // second case (see restoreAfterStructuralOp()).
3887
5031
  async function convertBlockViaMenu(blockEl, target) {
5032
+ let focusLine = null;
5033
+ const anchor = {};
5034
+ try {
5035
+ const out = await convertBlockViaMenuBody(blockEl, target, anchor);
5036
+ if (out != null) focusLine = out;
5037
+ } finally {
5038
+ // v3.2.1: 判準是「有沒有 blockSelection 站著」,不是 operand 數量 ——
5039
+ // 實測 operands.els.length === 1 在「有選取」與「無選取」兩態都成立,
5040
+ // 鑑別力為零。有選取時 declareCollapse() → applySelectionClasses() 已經
5041
+ // 在全新 DOM 上把 roving focus 裝回 .ed-block wrapper(9 種轉換形狀全部
5042
+ // 如此),這時強行還原 caret 會製造混種狀態:底色仍說「已選取」,Delete
5043
+ // 卻變成死鍵、Shift+↓ 降級成移動文字游標。所以有選取時什麼都不做。
5044
+ restoreAfterStructuralOp(focusLine, anchor.line);
5045
+ }
5046
+ }
5047
+
5048
+ async function convertBlockViaMenuBody(blockEl, target, anchor) {
3888
5049
  if (!blockEl || !target) return;
3889
5050
  // §3.3's membership rules and the whole shared preamble — see
3890
5051
  // resolveGutterOperands() above. `els` is ONE contiguous span; the plan's
@@ -3894,7 +5055,7 @@
3894
5055
  // fenced block raw-edited into two paragraphs changes the BLOCK count
3895
5056
  // without changing the LINE count, so an id-indexed delete hit the wrong
3896
5057
  // block, and the same shape silently rewrote a neighbouring table).
3897
- const operands = await resolveGutterOperands(blockEl);
5058
+ const operands = await resolveGutterOperands(blockEl, anchor);
3898
5059
  if (!operands) return;
3899
5060
  const els = operands.els;
3900
5061
  const recs = operands.recs;
@@ -3967,8 +5128,7 @@
3967
5128
  // the run stays a run and the existing re-serialization machinery applies
3968
5129
  // unchanged.
3969
5130
  if (shape.allLi && convertMd.targetIsList(target)) {
3970
- await convertListItemsType(els, liRun, recs, target);
3971
- return;
5131
+ return await convertListItemsType(els, liRun, recs, target);
3972
5132
  }
3973
5133
  // S2 Task 4 / S3 Task 6: li → a NON-list target. The items LEAVE the run,
3974
5134
  // so the span has to be rebuilt in three pieces and §4.3 rule 1's blank
@@ -3976,16 +5136,14 @@
3976
5136
  // lines mid-list with no separator and lazy continuation would swallow them
3977
5137
  // into the item above (measured, §4.3 rule 1).
3978
5138
  if (shape.allLi) {
3979
- await convertListItemsAway(els, liRun, recs, target);
3980
- return;
5139
+ return await convertListItemsAway(els, liRun, recs, target);
3981
5140
  }
3982
5141
  // S2 Task 5 / S3 Task 6: non-list blocks BECOME list items, so §4.3 rule
3983
5142
  // 2's looseness policy applies — eat the separator to an adjacent run of
3984
5143
  // the same list type, or the merged list goes LOOSE and every item of it
3985
5144
  // degrades read-only. Same rule, same helper the li → li path above uses.
3986
5145
  if (convertMd.targetIsList(target)) {
3987
- await convertBlocksIntoList(els, recs, shape.kinds, target);
3988
- return;
5146
+ return await convertBlocksIntoList(els, recs, shape.kinds, target);
3989
5147
  }
3990
5148
 
3991
5149
  // The plain path: N non-list blocks to a non-list target. One commit over
@@ -4028,6 +5186,11 @@
4028
5186
  if (!okRender) {
4029
5187
  lines = rollbackFailedRender({ lines, stack }, result, prevLines);
4030
5188
  }
5189
+ // v3.2.1 §Task 5: the caller's `focusLine`. NOT a new value and NOT a
5190
+ // pre-commit startLine — it is the very line the collapse above declares,
5191
+ // i.e. the operated span's post-commit first line. The plain path is the
5192
+ // one path that does not move it (the commit replaces the span in place).
5193
+ return first.startLine;
4031
5194
  }
4032
5195
 
4033
5196
  // Is the editor session switchAwayFrom() would resolve open on THIS block?
@@ -4085,12 +5248,51 @@
4085
5248
  // `lines`, and the li path carries the clone through bystanderCarryOver()
4086
5249
  // under the ORIGINAL's block id, so list-md.js replays the file's own bytes
4087
5250
  // for it and only re-states the marker. Both keep a `~5px` a `~5px`.
5251
+ //
5252
+ // v3.2.1: the caret restore is a THIN WRAPPER around the duplication body,
5253
+ // same shape and same reason as convertBlockViaMenu() above: the body has
5254
+ // several exits that duplicate nothing — resolveGutterOperands() answering
5255
+ // null, the mixed-list refusal, each of duplicateListItems()'s four, the
5256
+ // op === null no-op and the failed render on the non-li tail — all of them
5257
+ // reachable from a lit ⠿ menu item, and the wrapper is what makes every one
5258
+ // of them reach the restore. The body answers the ORIGINAL span's first
5259
+ // start line on success and undefined on every one of those exits.
5260
+ //
5261
+ // Final wave: "reach the restore" was true and not sufficient — with
5262
+ // `focusLine` null the restore had nothing to aim at and did nothing. The
5263
+ // refusal exits now land on `anchor.line`; see restoreAfterStructuralOp().
5264
+ //
5265
+ // 落點是【原件】而不是副本。三個理由,其中兩個是這個檔案自己已經寫下的事實:
5266
+ // 1. 一個選單項目只能有一個答案。有 blockSelection 站著時,§3.3 的塌縮把
5267
+ // 集合留在【原件】上(見 body 裡 declareCollapse() 那段註解:副本插在
5268
+ // 所有被複製的東西【下面】,所以原件的行區間原封不動)。落在副本會讓
5269
+ // 「按完 建立副本 之後手上握著什麼」取決於一個看不見的先前狀態。
5270
+ // 2. 原件的行號直接可讀,副本的行號在 li 路徑上要另外付代價。副本帶著
5271
+ // 原件的 data-block-id,所以 runLineOfBlock() 的
5272
+ // `lineMeta.findIndex((m) => m.blockId === targetId)` 回的是【原件】那
5273
+ // 一行。這不是做不到 —— runLineOfBlock() 對沒有 id 的 block 另有一支
5274
+ // 按位置數行的 fallback,而副本在 span 裡的位置是已知的 `at + 1 + i`。
5275
+ // 代價在別的地方:那個共用的 id 正是 bystanderCarryOver() 逐位元組重播
5276
+ // 副本、而不是重新 escape 它的原因(見下方 duplicateListItems() 的註解,
5277
+ // 一個 '~5px' 不重播就會變成 '\~5px')。所以這是一個取捨,不是一道牆。
5278
+ // 3. 畫面上沒有東西在使用者底下移動:副本長在下面,原件原地不動。
4088
5279
  async function duplicateBlockViaMenu(blockEl) {
5280
+ let focusLine = null;
5281
+ const anchor = {};
5282
+ try {
5283
+ const out = await duplicateBlockViaMenuBody(blockEl, anchor);
5284
+ if (out != null) focusLine = out;
5285
+ } finally {
5286
+ restoreAfterStructuralOp(focusLine, anchor.line);
5287
+ }
5288
+ }
5289
+
5290
+ async function duplicateBlockViaMenuBody(blockEl, anchor) {
4089
5291
  // §3.3's membership rules plus the shared preamble — see
4090
5292
  // resolveGutterOperands(). The whole span is duplicated by ONE commit; a
4091
5293
  // per-member loop would re-render between items and invalidate every id in
4092
5294
  // between.
4093
- const operands = await resolveGutterOperands(blockEl);
5295
+ const operands = await resolveGutterOperands(blockEl, anchor);
4094
5296
  if (!operands) return;
4095
5297
  const els = operands.els;
4096
5298
  const recs = operands.recs;
@@ -4098,8 +5300,12 @@
4098
5300
  if (shape.anyLi && !shape.allLi) { refuseStructuralListEdit(BATCH_MIXED_MESSAGE); return; }
4099
5301
 
4100
5302
  if (shape.allLi) {
4101
- await duplicateListItems(els, recs);
4102
- return;
5303
+ // recs[0].startLine is unchanged by this commit — duplicateListItems()'s
5304
+ // own declareCollapse() comment is the argument: the copies land after
5305
+ // everything the set owns, and no member's line COUNT can change (a
5306
+ // multi-line target is refused, and §3.8's renumbering only ever changes
5307
+ // marker WIDTH), so the originals still occupy the range they occupied.
5308
+ return (await duplicateListItems(els, recs)) ? recs[0].startLine : undefined;
4103
5309
  }
4104
5310
 
4105
5311
  // The non-li span duplicates as ONE slice of `lines` — separators between
@@ -4126,7 +5332,13 @@
4126
5332
  : undefined;
4127
5333
  if (!(await safeRerenderAll(editRange))) {
4128
5334
  lines = rollbackFailedRender({ lines, stack }, result, prevLines);
5335
+ return;
4129
5336
  }
5337
+ // The insertion is anchored on the LAST member and lands BELOW it, so
5338
+ // ops.shiftBlocks() moved only ids greater than that one — the span's own
5339
+ // first start line is the same number before and after, which is the same
5340
+ // fact declareCollapse() above relies on.
5341
+ return first.startLine;
4130
5342
  }
4131
5343
 
4132
5344
  // The li half of 建立副本. The copy is spliced into the run's own span and the
@@ -4135,10 +5347,15 @@
4135
5347
  // §3.8's renumbering falls out of the re-serialization ('1. alpha' duplicated
4136
5348
  // gives '1. alpha / 2. alpha / 3. bravo', not '1. alpha / 1. alpha / 2.
4137
5349
  // bravo').
5350
+ //
5351
+ // v3.2.1: returns commitListStructure()'s own true/false, and false for each
5352
+ // of the four refusal exits — duplicateBlockViaMenu()'s wrapper needs to tell
5353
+ // a refusal (nothing was duplicated, the block is still where it was) apart
5354
+ // from a completed duplicate.
4138
5355
  async function duplicateListItems(liEls, recs) {
4139
5356
  // Every list batch is one run's problem — see batchRunOf().
4140
5357
  const run = batchRunOf(liEls);
4141
- if (!run) { refuseStructuralListEdit(BATCH_MULTIRUN_MESSAGE); return; }
5358
+ if (!run) { refuseStructuralListEdit(BATCH_MULTIRUN_MESSAGE); return false; }
4142
5359
  // §4.3's run-wide gate — 轉換/建立副本/刪除/拖曳 each make this call for
4143
5360
  // themselves; there is no shared helper. Its input is §3.4 rule 2's scope,
4144
5361
  // which is exactly what listRunOf() returns (the outermost run PLUS every
@@ -4146,14 +5363,14 @@
4146
5363
  // it adds the item's lines over again), so a multi-line li refuses as a
4147
5364
  // TARGET while remaining a perfectly good bystander — and in a batch EVERY
4148
5365
  // member is a target.
4149
- if (!listRunSupportsStructuralEdit(run, liEls)) { refuseStructuralListEdit(); return; }
5366
+ if (!listRunSupportsStructuralEdit(run, liEls)) { refuseStructuralListEdit(); return false; }
4150
5367
  // Captured BEFORE the copy enters the span. The copy carries the
4151
5368
  // ORIGINAL's data-block-id — that is what makes bystanderCarryOver() replay
4152
5369
  // its bytes rather than re-escape them — so runRangeOfBlocks() would
4153
5370
  // resolve it to the original's record, and on a duplicate of the span's
4154
5371
  // LAST member that silently re-states the range's end line.
4155
5372
  const range = runRangeOfBlocks({ lines, blocks, stack }, run);
4156
- if (!range) return;
5373
+ if (!range) return false;
4157
5374
 
4158
5375
  // §4.3, measured: after the SUBTREE, not after the item's own line.
4159
5376
  // subtreeBlocksAfter() is the flat model's subtree — the contiguous run of
@@ -4166,7 +5383,7 @@
4166
5383
  const subtree = subtreeBlocksAfter(anchorLi, Number(anchorLi.getAttribute('data-indent')) || 0);
4167
5384
  const lastEl = subtree.length ? subtree[subtree.length - 1] : anchorLi;
4168
5385
  const at = run.indexOf(lastEl);
4169
- if (at < 0) return;
5386
+ if (at < 0) return false;
4170
5387
 
4171
5388
  const copies = liEls.map((liEl) => liEl.cloneNode(true));
4172
5389
  // `data-list-start` is the ONLY carrier of "marked's lexer opened a new
@@ -4209,33 +5426,161 @@
4209
5426
  startLine: recs[0].startLine,
4210
5427
  endLine: recs[recs.length - 1].endLine,
4211
5428
  });
4212
- await commitListStructure(span, null, false,
5429
+ return await commitListStructure(span, null, false,
4213
5430
  { presetRange: range, carryOver: bystanderCarryOver(span) });
4214
5431
  }
4215
5432
  // Deletes `blockEl`'s ENTIRE line range (generalizing commitListBlockRemoval()
4216
5433
  // — unchanged, see its own comment — to any block type, not just an
4217
5434
  // emptied-out list). Same resolve-first / re-query-live-block-by-id
4218
5435
  // precondition as insertBlockBelow() above.
5436
+ //
5437
+ // v3.2.1: the caret restore is a THIN WRAPPER around the removal body, same
5438
+ // shape as convertBlockViaMenu() above. A delete has no "back to the block it
5439
+ // operated on" — the block is gone — so the body answers the HOLE's line
5440
+ // coordinate (the removed span's own first start line) and
5441
+ // blockLineAfterRemoval() below turns that into a block that still exists.
5442
+ //
5443
+ // 有 blockSelection 站著時什麼都不做的判準與 convertBlockViaMenu() 相同,
5444
+ // 但在這條路徑上它幾乎總是讓路:批次刪除的 §3.3 塌縮是 declareCollapse(null)
5445
+ // =清空集合(見 body 內那段註解),render 之後 blockSelection 已經是 null。
5446
+ // 那是對的 —— 沒有集合站著就沒有「底色仍說已選取、Delete 卻是死鍵」的混種
5447
+ // 狀態可言,而使用者剛把手上唯一的著力點刪掉,更需要拿回一個。
4219
5448
  async function deleteBlockViaGutter(blockEl) {
5449
+ let removed = null;
5450
+ const anchor = {};
5451
+ try {
5452
+ const out = await deleteBlockViaGutterBody(blockEl, anchor);
5453
+ if (out) removed = out;
5454
+ } finally {
5455
+ // Final wave: on the refusal / rolled-back-render exits `removed` is
5456
+ // null, so the landing falls through to `anchor.line` — the block the
5457
+ // gesture named, which a refusal left exactly where it was. On the
5458
+ // SUCCESS path that block no longer exists, which is why the success
5459
+ // coordinate is blockLineAfterRemoval()'s carried neighbour and not the
5460
+ // anchor: restoreAfterStructuralOp() only consults `anchor.line` when
5461
+ // the first argument is null.
5462
+ const focusLine = removed ? blockLineAfterRemoval(removed) : null;
5463
+ // `anchor.line` is the line of the block that was just DELETED, so it is
5464
+ // offered only when the body refused. A successful delete that cannot
5465
+ // name a landing (blockLineAfterRemoval() answering null — an emptied
5466
+ // document) must restore nothing rather than aim at the hole.
5467
+ restoreAfterStructuralOp(focusLine, removed ? null : anchor.line);
5468
+ }
5469
+ }
5470
+
5471
+ // The block the user is left holding once a span has been removed. Takes the
5472
+ // `{ prevLine, spanIsFirst }` the body captured BEFORE its commit:
5473
+ //
5474
+ // prevLine the block immediately ABOVE the removed span, or null when
5475
+ // there is none. A PRE-commit start line, and stable across the
5476
+ // render for ONE reason that covers every path, not a reason
5477
+ // per path:
5478
+ //
5479
+ // commitRangeRemoval() and commitRangeEdit() compute the
5480
+ // SAME anchor — `blocks.filter((b) => b.endLine <= endLine)
5481
+ // .pop()`, textually identical in both — and hand it to
5482
+ // ops.shiftBlocks(), which moves a block only when
5483
+ // `b.id > editedId` (lineops.js). Ids are positional
5484
+ // ordinals in document order, so the block above the span has
5485
+ // a SMALLER id than every block inside it, and the anchor is
5486
+ // at least the span's last member (that member is itself in
5487
+ // the filtered list). The neighbour therefore fails
5488
+ // `b.id > editedId` whichever helper runs.
5489
+ //
5490
+ // ⚠ Fix round 3. The sentence that stood here said the li path
5491
+ // calls NEITHER of those helpers. That is false, and its own
5492
+ // named example is the counter-example: commitListStructure()
5493
+ // routes `md === ''` — no survivors, i.e. a WHOLE-RUN delete —
5494
+ // straight into commitRangeRemoval(). Instrumented and driven
5495
+ // rather than read: 刪除 over all three items of
5496
+ // '- alpha / - bravo / - charlie' printed
5497
+ // commitRangeRemoval range=5..7 anchor=id4(7..7)
5498
+ // shiftBlocks editedId=4 delta=-4 movedIds=[5] untouchedIds=[0,1,2,3,4]
5499
+ // while a partial delete of the same run printed
5500
+ // commitRangeEdit range=5..7 anchor=id4(7..7)
5501
+ // shiftBlocks editedId=4 delta=-1 movedIds=[5] untouchedIds=[0,1,2,3,4]
5502
+ // — two different helpers, the same anchor, and in both the
5503
+ // neighbour (id1) sits in `untouchedIds`. Five gestures were
5504
+ // instrumented this way (whole run, middle item, first item, a
5505
+ // non-li paragraph, and a run that is the entire document) and
5506
+ // the neighbour was untouched in all five.
5507
+ // spanIsFirst true when the removed span WAS the document's first block, so
5508
+ // "no previous block" is a fact rather than a failed lookup —
5509
+ // see deleteBlockViaGutterBody()'s own comment for why those two
5510
+ // are not the same answer.
5511
+ //
5512
+ // 「上面那一個、游標落在它的結尾」是 Notion 的答案,也是 Backspace 併行的
5513
+ // 落點;這個編輯器的 ⠿ / 建立副本 / 轉換成 整組詞彙都是 Notion 的。
5514
+ //
5515
+ // ⚠ Fix round 1, item 2: this used to INFER the neighbour from the hole's
5516
+ // coordinate — "the largest post-render startLine strictly below it". That is
5517
+ // wrong whenever commitRangeRemoval() takes its `sl -= 1` branch (the line
5518
+ // after the span is not blank, so it absorbs the blank ABOVE instead): every
5519
+ // block BELOW the hole then moves up too, the first of them lands one line
5520
+ // ABOVE the hole's own start line, and taking the maximum picks THAT
5521
+ // deterministically over the real previous block. Driven on '…Bravo para.\n' immediately followed by
5522
+ // a block that interrupts a paragraph — an ATX heading, a list, a fenced
5523
+ // code block, an hr — the caret landed on the block BELOW the hole in all
5524
+ // four, and on the code / hr shapes (no focusable surface) it landed nowhere
5525
+ // at all. The answer is not derivable from the hole; it has to be carried.
5526
+ //
5527
+ // With `spanIsFirst`, whatever is first NOW is the block that moved up into
5528
+ // the hole — `blocks[0]`, no search needed. Answers null for a delete that
5529
+ // emptied the document, and for the unreachable "span not found" case, where
5530
+ // guessing a block is worse than restoring no caret at all.
5531
+ function blockLineAfterRemoval(removed) {
5532
+ if (removed.prevLine != null) return removed.prevLine;
5533
+ if (!removed.spanIsFirst) return null;
5534
+ return blocks.length ? blocks[0].startLine : null;
5535
+ }
5536
+
5537
+ async function deleteBlockViaGutterBody(blockEl, anchor) {
4220
5538
  // §3.3's membership rules plus the shared preamble (identity capture,
4221
5539
  // switchAwayFrom(), the re-resolve after a commit that re-rendered, and the
4222
5540
  // no-source-line refusal) — see resolveGutterOperands(). The stake on the
4223
5541
  // re-resolve is highest here: an id shift used to make this delete a
4224
5542
  // DIFFERENT block's lines, with the ⠿ menu the user pressed pointing at a
4225
5543
  // block that survived.
4226
- const operands = await resolveGutterOperands(blockEl);
5544
+ const operands = await resolveGutterOperands(blockEl, anchor);
4227
5545
  if (!operands) return;
4228
5546
  const els = operands.els;
4229
5547
  const recs = operands.recs;
4230
5548
  const shape = spanListKinds(els);
4231
5549
  if (shape.anyLi && !shape.allLi) { refuseStructuralListEdit(BATCH_MIXED_MESSAGE); return; }
5550
+ // Fix round 1, item 2: the block immediately ABOVE the span, read out of
5551
+ // the PRE-commit `blocks` and carried to the wrapper. It cannot be
5552
+ // recovered afterwards from the hole's coordinate — see
5553
+ // blockLineAfterRemoval()'s own comment for the measurement. `blocks` is in
5554
+ // document order (blockmap.js hands out a positional ordinal), so the
5555
+ // element before recs[0] is that neighbour.
5556
+ //
5557
+ // Located by OBJECT IDENTITY, and the three outcomes are kept apart on
5558
+ // purpose (fix round 2, item 3 — `at > 0` folded the third into the
5559
+ // second and would have landed the caret on the document's first block
5560
+ // with no signal at all):
5561
+ // at > 0 there is a previous block; its start line is the landing.
5562
+ // at === 0 the span IS the document's first block — `spanIsFirst` tells
5563
+ // blockLineAfterRemoval() to take the moved-up fallback.
5564
+ // at < 0 recs[0] is not in `blocks`. Unreachable as written:
5565
+ // resolveGutterOperands() builds `recs` from this very array
5566
+ // (blockRecOf()'s `blocks.find()` for the single case,
5567
+ // selection.js's membersOf() `blocks.filter()` for the batch —
5568
+ // both hand back the SAME object references), and nothing
5569
+ // awaits between its return and this line, so `blocks` cannot
5570
+ // have been replaced in between. If it ever becomes reachable,
5571
+ // both flags stay false/null and the wrapper simply restores no
5572
+ // caret and re-aims the toolbar — a bar-only degrade, never a
5573
+ // landing on the wrong block.
5574
+ const at = blocks.indexOf(recs[0]);
5575
+ const prevLine = at > 0 ? blocks[at - 1].startLine : null;
5576
+ const spanIsFirst = at === 0;
4232
5577
  // Spec §6, "S1 期間的已知危險" item 1: a LIST ITEM's delete is not a line
4233
5578
  // splice. S1 is what first put a ⠿ on a li, and the plain range removal
4234
5579
  // below corrupts a list three separate ways — see
4235
5580
  // deleteListItemsViaGutter() for the measurements and the routing.
4236
5581
  if (shape.allLi) {
4237
- await deleteListItemsViaGutter(els);
4238
- return;
5582
+ return (await deleteListItemsViaGutter(els))
5583
+ ? { prevLine: prevLine, spanIsFirst: spanIsFirst } : undefined;
4239
5584
  }
4240
5585
  // A contiguous span of non-list blocks is one line range: the separators
4241
5586
  // between its members are inside it by construction (members are adjacent
@@ -4264,7 +5609,9 @@
4264
5609
  const okRender = await safeRerenderAll(editRange);
4265
5610
  if (!okRender) {
4266
5611
  lines = rollbackFailedRender({ lines, stack }, result, prevLines);
5612
+ return;
4267
5613
  }
5614
+ return { prevLine: prevLine, spanIsFirst: spanIsFirst };
4268
5615
  }
4269
5616
 
4270
5617
  // Spec §6, "S1 期間的已知危險" item 1 — the ⠿ delete of a LIST ITEM.
@@ -4295,19 +5642,24 @@
4295
5642
  // commit the re-serialized survivors over that range with every one of them
4296
5643
  // carried over verbatim — nothing here rewrites any survivor's CONTENT, only
4297
5644
  // its marker and its leading columns.
5645
+ //
5646
+ // v3.2.1: returns commitListStructure()'s own true/false, and false for each
5647
+ // of its three refusal exits — deleteBlockViaGutter()'s wrapper needs to tell
5648
+ // a refusal (nothing was removed, the block is still where it was) apart from
5649
+ // a completed delete before it goes looking for a neighbour.
4298
5650
  async function deleteListItemsViaGutter(liEls) {
4299
5651
  // Every list batch is one run's problem — see batchRunOf().
4300
5652
  const run = batchRunOf(liEls);
4301
- if (!run) { refuseStructuralListEdit(BATCH_MULTIRUN_MESSAGE); return; }
5653
+ if (!run) { refuseStructuralListEdit(BATCH_MULTIRUN_MESSAGE); return false; }
4302
5654
  // §4.3's run-wide gate, whose input is §3.4 rule 2's scope — which is
4303
5655
  // exactly what listRunOf() returns (the outermost run PLUS every
4304
5656
  // descendant of its members), so the deeper runs this delete is about to
4305
5657
  // re-indent are covered, not just the target's own. Deleting is NOT
4306
5658
  // column-only: it removes the target's lines outright, so a multi-line
4307
5659
  // target refuses per §4.1 — and in a batch every member is a target.
4308
- if (!listRunSupportsStructuralEdit(run, liEls)) { refuseStructuralListEdit(); return; }
5660
+ if (!listRunSupportsStructuralEdit(run, liEls)) { refuseStructuralListEdit(); return false; }
4309
5661
  const range = runRangeOfBlocks({ lines, blocks, stack }, run);
4310
- if (!range) return;
5662
+ if (!range) return false;
4311
5663
  // §3.4 rule 3's batch anchor: the SMALLEST old indent in the set, never the
4312
5664
  // first member's — see spanMinIndent().
4313
5665
  const oldIndent = spanMinIndent(liEls);
@@ -4333,7 +5685,7 @@
4333
5685
  // this path is the one it makes after the commit — the `!range` bail above
4334
5686
  // it cannot fire, `presetRange` is non-null by construction here.
4335
5687
  declareCollapse(null);
4336
- await commitListStructure(survivors, null, false,
5688
+ return await commitListStructure(survivors, null, false,
4337
5689
  { presetRange: range, carryOver: bystanderCarryOver(survivors) });
4338
5690
  }
4339
5691
 
@@ -4555,6 +5907,10 @@
4555
5907
  });
4556
5908
  await commitListStructure(run, null, false,
4557
5909
  { presetRange: range, carryOver: bystanderCarryOver(run) });
5910
+ // v3.2.1 §Task 5: the caller's `focusLine` — the same post-shift line the
5911
+ // collapse just declared, never the pre-commit startLine (the merge
5912
+ // widening above moves it by exactly `shift`).
5913
+ return recs[0].startLine + shift;
4558
5914
  }
4559
5915
 
4560
5916
  // The `data-block-type` a conversion target will carry once it is committed.
@@ -4703,6 +6059,11 @@
4703
6059
  if (!(await safeRerenderAll(editRange))) {
4704
6060
  lines = rollbackFailedRender({ lines, stack }, result, prevLines);
4705
6061
  }
6062
+ // v3.2.1 §Task 5: the caller's `focusLine` — the converted piece's own
6063
+ // first emitted line, which is what the collapse above declares. The
6064
+ // pre-commit startLine is wrong here: the split rebuilds the run in three
6065
+ // pieces and §4.3 rule 1's blanks move the converted half.
6066
+ return outStart;
4706
6067
  }
4707
6068
 
4708
6069
  // S2 Task 5 — 轉換成 › 項目符號列表 / 編號列表 / 待辦清單 on a block that is
@@ -4765,6 +6126,10 @@
4765
6126
  if (!(await safeRerenderAll(editRange))) {
4766
6127
  lines = rollbackFailedRender({ lines, stack }, result, prevLines);
4767
6128
  }
6129
+ // v3.2.1 §Task 5: the caller's `focusLine` — the WIDENED range's first
6130
+ // line, which is where the merged list actually starts. `recs[0].startLine`
6131
+ // would be wrong whenever §4.3 rule 2 ate a separator above the span.
6132
+ return merged.startLine;
4768
6133
  }
4769
6134
 
4770
6135
  // The `.ed-li-check` chrome for a li that has just BECOME a task item.
@@ -4817,12 +6182,18 @@
4817
6182
  // focus/blur cycle on that same block (click into it, click back out
4818
6183
  // — e.g. after Esc-ing the raw editor) re-armed that same stale DOM
4819
6184
  // and committed it as if it were live content: the "discarded" edit
4820
- // resurrected itself into `lines`. Restore the pre-edit snapshot
4821
- // FIRST, mirroring revertBurstAndEnd()'s own contract, so nothing is
4822
- // left behind for a later burst to accidentally pick up and commit.
4823
- currentBurst.editEl.innerHTML = currentBurst.original;
4824
- currentBurst.history.dispose();
6185
+ // resurrected itself into `lines`.
6186
+ // v3.2.1: 先清 currentBurst 再寫 innerHTML。對 TABLE burst,editEl 是
6187
+ // <table> 而聚焦節點是子代 .ed-wys-cell,所以這個寫入會 detach 聚焦的
6188
+ // cell;Chromium 同步派發 focusout,而 focusout 的 table 分支只要
6189
+ // currentBurst 非 null 就會 resolveBurst() 並提交 —— 這顆按鈕的職責是
6190
+ // 丟棄,實測卻把打的字寫進磁碟。revertTableBurstAndEnd() 早就是這個
6191
+ // 順序,而它的註解正是說明「先清 currentBurst」才是它不需要抑制旗標
6192
+ // 的原因;上一版這裡的註解宣稱在鏡像那份契約,實際把它倒過來了。
6193
+ const dyingBurst = currentBurst;
4825
6194
  currentBurst = null;
6195
+ dyingBurst.history.dispose();
6196
+ dyingBurst.editEl.innerHTML = dyingBurst.original;
4826
6197
  resetSelToolbarState();
4827
6198
  } else {
4828
6199
  const ok = await switchAwayFrom();
@@ -4897,6 +6268,7 @@
4897
6268
  }
4898
6269
 
4899
6270
  function startBurst(editEl) {
6271
+ dropDiscardedBurst();
4900
6272
  const blockEl = editEl.closest('.ed-block');
4901
6273
  if (!blockEl) return;
4902
6274
  const blockId = Number(blockEl.getAttribute('data-block-id'));
@@ -4911,6 +6283,7 @@
4911
6283
  original: burstBaselineHtml(editEl),
4912
6284
  history,
4913
6285
  };
6286
+ watchBurstForDirtyDot(editEl);
4914
6287
  selToolbarEditEl = editEl;
4915
6288
  if (!selToolbarListener) {
4916
6289
  selToolbarListener = onSelectionChangeForToolbar;
@@ -5230,6 +6603,51 @@
5230
6603
  return document.querySelector('.ed-block[data-block-id="' + target.id + '"]');
5231
6604
  }
5232
6605
 
6606
+ // ── Task 15: where a keyboard caret can actually land ────────────────────
6607
+ //
6608
+ // Hands back the element inside `blockEl` that can hold a caret, or null
6609
+ // when the block has none. It answers with the target rather than a yes/no
6610
+ // so the Tab walk out of a table takes its landing site from the same
6611
+ // measurement that decided the block was reachable at all.
6612
+ //
6613
+ // The class tokens asked for are the ones armEditables() writes: a single
6614
+ // contenteditable surface carrying 'ed-wys-armed' (its paragraph/heading
6615
+ // branch, and the armed row of its li branch), and a table's per-cell
6616
+ // surfaces carrying 'ed-wys-cell'. A block armEditables() left degraded
6617
+ // carries neither.
6618
+ //
6619
+ // Asking a class rather than calling .focus() and reading
6620
+ // document.activeElement back is not caution. Driven over a fixture whose
6621
+ // blocks were a table, a fenced code block, another table, a blockquote, an
6622
+ // image-only paragraph, a thematic break and a plain paragraph,
6623
+ // blockContentEl(blockEl).focus() left document.activeElement on BODY for
6624
+ // every one of them except the paragraph — the tables included, because
6625
+ // blockContentEl() of a table block is the <table> itself and focusing that
6626
+ // moved nothing, while focusing one of its cells does.
6627
+ //
6628
+ // querySelector cannot reach past this block into a nested item's: driven on
6629
+ // a fixture whose list nests, every '.ed-block' in the document was a direct
6630
+ // child of '.content' and none had another '.ed-block' as an ancestor.
6631
+ // md2doc.js emits a nested item's block as a SIBLING carrying 'data-indent',
6632
+ // not inside its parent's.
6633
+ function focusableSurfaceOf(blockEl) {
6634
+ if (!blockEl) return null;
6635
+ return blockEl.querySelector('.ed-wys-armed') || blockEl.querySelector('.ed-wys-cell');
6636
+ }
6637
+
6638
+ // The nearest sibling block in `dir` (+1 forward, -1 back) that has one, or
6639
+ // null. Nothing tests for '.ed-block' on the way past: on the same fixture
6640
+ // every child of '.content' was one, and armEditables() only ever walks
6641
+ // '.ed-block' elements, so an element that was not one carries no surface
6642
+ // for focusableSurfaceOf() to find.
6643
+ function adjacentFocusableBlock(blockEl, dir) {
6644
+ const step = (el) => (dir > 0 ? el.nextElementSibling : el.previousElementSibling);
6645
+ for (let el = blockEl ? step(blockEl) : null; el; el = step(el)) {
6646
+ if (focusableSurfaceOf(el)) return el;
6647
+ }
6648
+ return null;
6649
+ }
6650
+
5233
6651
  // ── S3 Task 2: block multi-select state ────────────────────────────────
5234
6652
  // The selection's identity is a LINE RANGE, never ids and never nodes.
5235
6653
  // buildBlockMap renumbers every id from 0 on every render (blockmap.js's
@@ -5435,8 +6853,9 @@
5435
6853
  // v3.0.2: computeIndentClamp() lives past this file's node early-exit
5436
6854
  // (module.exports at line ~509 ships the pure core only) and nothing on
5437
6855
  // window reaches it, so the stale-span assert added to it in v3.0.2 had
5438
- // no way to be tested at all. This is the seventh __edTest seam and the
5439
- // only production surface that change adds.
6856
+ // no way to be tested at all. This joins the other test seams on window
6857
+ // (grep `window.__edTest`) and is the only production surface that change
6858
+ // adds.
5440
6859
  window.__edTestClampProbe = function (spanEls, opBlockEl, opOldIndent, opts) {
5441
6860
  return computeIndentClamp(spanEls, opBlockEl, opOldIndent, opts);
5442
6861
  };
@@ -6045,17 +7464,122 @@
6045
7464
  // real commit is also possible).
6046
7465
  const wasPristineForThisBlock = !!(pristineInsert && pristineInsert.blockId === burst.blockId);
6047
7466
  if (wasPristineForThisBlock) pristineInsert = null;
6048
- burst.history.dispose();
6049
- editEl.innerHTML = burst.original;
6050
- currentBurst = null;
6051
- resetSelToolbarState();
7467
+ // T21 item 1: a pristine insert keeps the old, unrecoverable shape, and
7468
+ // that is not an oversight — discardPristineInsert() REMOVES the block, so
7469
+ // there is no surface left to restore into and nothing was typed that the
7470
+ // skeleton itself did not carry (the pristine window closes at this
7471
+ // block's first real edit; see pristineInsert's own comment).
6052
7472
  if (wasPristineForThisBlock) {
7473
+ burst.history.dispose();
7474
+ editEl.innerHTML = burst.original;
7475
+ currentBurst = null;
7476
+ resetSelToolbarState();
6053
7477
  await discardPristineInsert(); // rerenderAll() already detaches editEl — nothing left to blur()
6054
7478
  return;
6055
7479
  }
7480
+ // The paired snapshot, the same shape snapBurstIfActive() spells out for
7481
+ // the programmatic mutations that already snap on both sides of
7482
+ // themselves (grep `history.snap('` for them): the state being thrown away
7483
+ // has to be on the stack ABOVE the state being restored, or the undo lands
7484
+ // one entry short. flushTyping() is the pre half here rather than a snap()
7485
+ // — after typing, the debounce is what is holding the typed state.
7486
+ //
7487
+ // Both lines were ablated one at a time and the gesture re-driven, on the
7488
+ // paragraph, and each one alone loses the text: with flushTyping() removed,
7489
+ // Escape then Ctrl+Z left the paragraph reading 'The migration has three
7490
+ // phases.' — the typing gone and not on the document stack either; with the
7491
+ // snap() removed, the same gesture left the same text, i.e. the Ctrl+Z
7492
+ // looked like it did nothing.
7493
+ burst.history.flushTyping();
7494
+ editEl.innerHTML = burst.original;
7495
+ burst.history.snap('escape-discard');
7496
+ discardedBurst = {
7497
+ history: burst.history, editEl, blockType: burst.blockType, cellIdx: -1,
7498
+ reverted: burstBaselineHtml(editEl),
7499
+ };
7500
+ currentBurst = null;
7501
+ resetSelToolbarState();
6056
7502
  editEl.blur();
6057
7503
  }
6058
7504
 
7505
+ // The table twin of the stash above — a table burst's editEl is the whole
7506
+ // <table> and the focused node is one cell, so restoring has to put the
7507
+ // caret back in a cell, by ORDINAL among tableCellsOf() (the same handle
7508
+ // tableBurstUndo() uses, and for the same reason: the innerHTML swap
7509
+ // replaces every cell node).
7510
+ function stashDiscardedTableBurst(burst, tableEl, cellIdx, revertFn) {
7511
+ burst.history.flushTyping();
7512
+ revertFn();
7513
+ burst.history.snap('escape-discard');
7514
+ discardedBurst = {
7515
+ history: burst.history, editEl: tableEl, blockType: 'table', cellIdx,
7516
+ reverted: burstBaselineHtml(tableEl),
7517
+ };
7518
+ }
7519
+
7520
+ // Ctrl+Z with no burst open, when the last thing that happened was an
7521
+ // Escape: put back what that Escape discarded and re-open the burst around
7522
+ // it, so the restored text is a live edit again — dirty dot lit, committed
7523
+ // by the next blur, and one more Ctrl+Z steps back through the same history
7524
+ // to the pre-edit baseline and then cascades out to the document stack.
7525
+ //
7526
+ // Returns false when there is nothing to restore or the stash has gone
7527
+ // stale, and the caller then does the ordinary document undo — which is
7528
+ // what an Escape that discarded NOTHING (focus a surface, press Escape
7529
+ // without editing) must also do: its history has a single entry, undo()
7530
+ // answers null, and the user's Ctrl+Z is not swallowed.
7531
+ //
7532
+ // The order — focus FIRST, then write — is load-bearing. focus() opens a
7533
+ // fresh burst whose `original` is the reverted surface, so the restored
7534
+ // text reads as an edit against it; writing first and focusing after would
7535
+ // make the restored text the new baseline, and resolveBurst()'s zero-edit
7536
+ // guard would then drop it on the way out without committing.
7537
+ function restoreDiscardedBurst() {
7538
+ const d = discardedBurst;
7539
+ discardedBurst = null;
7540
+ if (!d) return false;
7541
+ if (!document.body.contains(d.editEl) || burstBaselineHtml(d.editEl) !== d.reverted) {
7542
+ d.history.dispose();
7543
+ return false;
7544
+ }
7545
+ const typed = d.history.undo();
7546
+ if (typed === null) { d.history.dispose(); return false; }
7547
+ const isTable = d.blockType === 'table';
7548
+ const cells = isTable ? tableCellsOf(d.editEl) : [];
7549
+ const entry = isTable
7550
+ ? (cells[Math.max(0, Math.min(cells.length - 1, d.cellIdx))] || cells[0])
7551
+ : d.editEl;
7552
+ if (!entry) { d.history.dispose(); return false; }
7553
+ entry.focus();
7554
+ if (!currentBurst || currentBurst.editEl !== d.editEl) { d.history.dispose(); return false; }
7555
+ currentBurst.history.dispose();
7556
+ currentBurst.history = d.history;
7557
+ if (isTable) {
7558
+ // See suppressTableFocusout's own comment: this reassignment detaches
7559
+ // the focused cell, and Chromium fires the blur/focusout for it
7560
+ // synchronously, with the burst deliberately still live.
7561
+ const tableEl = d.editEl;
7562
+ suppressTableFocusout = true;
7563
+ try {
7564
+ tableEl.innerHTML = typed;
7565
+ } finally {
7566
+ suppressTableFocusout = false;
7567
+ }
7568
+ const newCells = tableCellsOf(tableEl);
7569
+ const target = newCells[Math.max(0, Math.min(newCells.length - 1, d.cellIdx))] || newCells[0];
7570
+ if (target) {
7571
+ currentBurst.activeCellEl = target;
7572
+ selToolbarEditEl = target;
7573
+ target.focus();
7574
+ placeCaretAtEnd(target);
7575
+ }
7576
+ return true;
7577
+ }
7578
+ d.editEl.innerHTML = typed;
7579
+ placeCaretAtEnd(d.editEl);
7580
+ return true;
7581
+ }
7582
+
6059
7583
  // Ctrl+Z inside a burst: step the burst-local history first; only once
6060
7584
  // it's exhausted (atBottom — the surface is already back to its pre-focus
6061
7585
  // baseline) does this cascade OUT to the document-level undo() stack,
@@ -6120,6 +7644,27 @@
6120
7644
  // paste toggle inside a table cell silently skipped its own burst-history
6121
7645
  // snapshot — Ctrl+Z after it would step PAST the mark (or straight to
6122
7646
  // cascading out of the burst) instead of reverting just that toggle.
7647
+ //
7648
+ // v3.3.0 地基 B — every caller of THIS function now snapshots on BOTH sides
7649
+ // of its mutation (the `*-pre` reasons are the pre-mutation half). The table
7650
+ // structural ops — insert row, insert column, delete row, delete column,
7651
+ // cycle column align, drag row, drag column — take the same paired shape but
7652
+ // call currentBurst.history.snap() directly; grep `history.snap('` to see
7653
+ // all of them at once. The burst history's
7654
+ // undo() pops the top and returns the entry BELOW it, so the stack top has
7655
+ // to describe the DOM as it currently stands; a lone post-mutation snap
7656
+ // makes the top "typed text + the mark" with nothing between it and the
7657
+ // block's opening state, and one Ctrl+Z therefore threw away the sentence
7658
+ // the user had just typed along with the mark. MEASURED before the fix, on
7659
+ // a paragraph typed into and then bolded from the toolbar: the DOM after
7660
+ // Ctrl+Z was "Alpha paragraph." — the whole ` typed sentence` gone.
7661
+ //
7662
+ // The extra call is free when nothing has changed: history.snap() compares
7663
+ // its capture against the stack top and pushes only on a difference, so a
7664
+ // pre-mutation snap taken when the top is already current adds no entry.
7665
+ //
7666
+ // `reason` is metadata only — createBurstHistory().snap() does not read it
7667
+ // (see lib/editor/history.js).
6123
7668
  function snapBurstIfActive(root, reason) {
6124
7669
  if (currentBurst && (currentBurst.editEl === root || currentBurst.editEl.contains(root))) {
6125
7670
  currentBurst.history.snap(reason);
@@ -6150,6 +7695,14 @@
6150
7695
  if (e.key === 'Tab') e.preventDefault();
6151
7696
  return;
6152
7697
  }
7698
+ // T21 item 2: the landing mark survives a RUN of Tabs and nothing else.
7699
+ // Shift is excluded because it is Shift+Tab's own first keydown, and
7700
+ // excluding it is what lets a backwards walk keep walking. Every other
7701
+ // key — a character, Ctrl, Escape — is the user starting to work on the
7702
+ // block they landed on, and from then on Tab means what it has always
7703
+ // meant here (heading depth / list indent). Set BEFORE the li dispatch
7704
+ // below so both surfaces see the same cleared mark.
7705
+ if (e.key !== 'Tab' && e.key !== 'Shift') currentBurst.arrivedByTab = false;
6153
7706
  // Task 8 (Phase 4): per-li burst — Enter / Shift+Enter / Tab / Shift+Tab
6154
7707
  // are owned by handleLiKeydown() below (spec §4's key semantics for li
6155
7708
  // surfaces, acceptance rows 1, 3, 5, 6, 7, 8). Every other key (Escape,
@@ -6180,6 +7733,19 @@
6180
7733
  // whatever the commit message that introduced this said.
6181
7734
  if (e.key === 'Tab') {
6182
7735
  e.preventDefault();
7736
+ // T21 item 2: a Tab that ARRIVED here keeps walking instead of editing
7737
+ // what it landed on. Task 15 gave Tab a cross-block landing but left the
7738
+ // next Tab reading as this branch, and a key being used to navigate then
7739
+ // silently rewrote the document: driven on the fixture table, pressing
7740
+ // its last body cell and then Tab four times took '### Build snippet' to
7741
+ // '######' and Ctrl+S wrote that to disk; backwards from the first
7742
+ // header cell, Shift+Tab twice outdented ' - epsilon' to '- epsilon' on
7743
+ // disk. Both were silent — a heading changing size and the title's dot
7744
+ // were the only tells.
7745
+ if (currentBurst.arrivedByTab) {
7746
+ walkToAdjacentBlockByTab(currentBurst.blockEl, e.shiftKey ? -1 : 1);
7747
+ return;
7748
+ }
6183
7749
  if (currentBurst.blockType === 'heading') {
6184
7750
  changeHeadingDepth(currentBurst.blockEl, e.shiftKey ? -1 : 1);
6185
7751
  }
@@ -6188,6 +7754,7 @@
6188
7754
  if (e.key === 'Enter') {
6189
7755
  e.preventDefault();
6190
7756
  if (e.shiftKey) {
7757
+ snapBurstIfActive(editEl, 'br-pre');
6191
7758
  insertBrAtCaret();
6192
7759
  snapBurstIfActive(editEl, 'br');
6193
7760
  } else {
@@ -6337,9 +7904,10 @@
6337
7904
  }
6338
7905
 
6339
7906
  // Splits `blockEl` into two sibling BLOCKS at the caret via Range surgery —
6340
- // the same extractContents()-based pattern wrapRangeIn() above already uses,
7907
+ // the same extractContents()-based pattern extractRangeInto() below uses,
6341
7908
  // so inline formatting (a caret mid-<strong>, say) splits cleanly instead of
6342
- // being torn.
7909
+ // being torn, and sharing collectLeftovers()/dropEmptied() with it so the
7910
+ // split does not leave an emptied mark behind — see inside.
6343
7911
  //
6344
7912
  // The caret lives inside the block's own `<div class="ed-li-text">` surface,
6345
7913
  // so the tail range runs to the END OF THAT DIV and the new block gets a
@@ -6370,10 +7938,21 @@
6370
7938
  // Containment is checked BEFORE deleteContents() so the refusal below is a
6371
7939
  // true no-op rather than "the selection was deleted, then we gave up".
6372
7940
  if (range.startContainer !== textEl && !textEl.contains(range.startContainer)) return null;
7941
+ // Leftover marks, collected across this function's range operations before
7942
+ // they run, and dropped once the tail extraction below has run.
7943
+ // `textEl` — this item's own `.ed-li-text` — is the walk's terminator, not
7944
+ // the document root: the surface itself must survive an Enter that empties
7945
+ // it. Measured before this: Enter with the caret on the first character of
7946
+ // the <em> in `- Alpha *ital* rest` left the upper item on disk as
7947
+ // `- Alpha **`. Nothing was lost there, every character stayed in the right
7948
+ // item — the damage was the stray delimiters, which escape to `\*\*` on the
7949
+ // save after that.
7950
+ const leftovers = collectLeftovers(range, textEl);
6373
7951
  if (!range.collapsed) range.deleteContents(); // collapses to the start point
6374
7952
  const tailRange = document.createRange();
6375
7953
  tailRange.setStart(range.startContainer, range.startOffset);
6376
7954
  tailRange.setEnd(textEl, textEl.childNodes.length);
7955
+ collectLeftovers(tailRange, textEl, leftovers);
6377
7956
  const tailFrag = tailRange.extractContents();
6378
7957
 
6379
7958
  const newBlock = document.createElement('div');
@@ -6398,6 +7977,12 @@
6398
7977
  newText.appendChild(tailFrag);
6399
7978
  newBlock.appendChild(newText);
6400
7979
  blockEl.parentNode.insertBefore(newBlock, blockEl.nextSibling);
7980
+ // After the delete and the tail extraction have run. Dropping between them
7981
+ // would judge a mark before the extraction had emptied it — the pre-fix bug
7982
+ // again. What does NOT matter is where this sits relative to the new item
7983
+ // being built: measured in review, dropping before the tail is rehomed but
7984
+ // after those Range operations gives byte-identical output.
7985
+ dropEmptied(leftovers);
6401
7986
  return newBlock;
6402
7987
  }
6403
7988
 
@@ -6905,6 +8490,18 @@
6905
8490
  // last-item case, where the answer must be an EMPTY span (serializes to
6906
8491
  // '', which is what takes commitListStructure()'s range-removal path).
6907
8492
  const survivors = runEls.filter((el) => el !== li);
8493
+ // Load-bearing, not incidental: `li` is the item the caret is IN, and
8494
+ // removeListItem() detaches exactly that node — the one Chromium
8495
+ // currently has focus on — from the DOM. Drop this suppression span and
8496
+ // that detach's synchronous blur/focusout reaches resolveBurst() mid-
8497
+ // mutation, with the run still in its PRE-removal shape (see
8498
+ // `suppressLiFocusout`'s own comment above: "observed: '- <br>' written
8499
+ // back for an item that had just been deleted"). That costs an extra
8500
+ // resolveBurst({type:'li'}) committing a run nobody meant to commit, on
8501
+ // top of the one commitListStructure() below already performs — one
8502
+ // keystroke becomes two undo ops, and the second undo writes that stray
8503
+ // '- <br>' to disk. Do not fold this into a "the node barely moved"
8504
+ // cleanup.
6908
8505
  mutateListRun(() => removeListItem(li));
6909
8506
  // No `mutatedEl`: `li` is not IN `survivors`, and every block that is was
6910
8507
  // left exactly as the file has it.
@@ -6927,8 +8524,15 @@
6927
8524
  function handleLiKeydown(e, editEl) {
6928
8525
  if (e.key !== 'Enter' && e.key !== 'Tab') return false;
6929
8526
  e.preventDefault();
8527
+ // T21 item 2, the li half — see handleBurstKeydown()'s Tab branch. Ahead
8528
+ // of the run gates below because a walk needs no run: it is leaving.
8529
+ if (e.key === 'Tab' && currentBurst && currentBurst.arrivedByTab) {
8530
+ walkToAdjacentBlockByTab(currentBurst.blockEl, e.shiftKey ? -1 : 1);
8531
+ return true;
8532
+ }
6930
8533
  // Row 2: Shift+Enter is an in-block line break, not a structural change.
6931
8534
  if (e.key === 'Enter' && e.shiftKey) {
8535
+ snapBurstIfActive(editEl, 'br-pre');
6932
8536
  insertBrAtCaret();
6933
8537
  snapBurstIfActive(editEl, 'br');
6934
8538
  return true;
@@ -6961,6 +8565,16 @@
6961
8565
  // which is a complete no-op — nothing mutated, nothing committed, file
6962
8566
  // byte-identical.
6963
8567
  const oldIndent = Number(li.getAttribute('data-indent')) || 0;
8568
+ // INERT here: indentListItem()/outdentListItem() are attribute-only in
8569
+ // this flat model (setBlockIndent() on `li` and its subtree — see the
8570
+ // note on outdentListItem() further down this function) and never
8571
+ // detach the focused node, so the synchronous blur/focusout this span
8572
+ // exists to catch never fires, whether or not the flag is set.
8573
+ // Measured: primed a burst on `li`, ran this mutation, and no
8574
+ // focusout fired — `document.activeElement` stayed put and the node
8575
+ // never left the document. Kept for uniformity with the load-bearing
8576
+ // span in convertEmptyTopLevelLiToParagraph() above, not because this
8577
+ // one guards anything measured.
6964
8578
  const changed = mutateListRun(() => {
6965
8579
  if (!(e.shiftKey ? outdentListItem(li) : indentListItem(li))) return false;
6966
8580
  // v3.0.1: `run` above is a PRE-mutation snapshot, captured while `li`
@@ -7023,6 +8637,9 @@
7023
8637
  // Row 3: one press = one outdent, with the SAME semantics as Shift+Tab
7024
8638
  // (adoption included). RULING F-Q: an item that OWNS a sublist takes this
7025
8639
  // path too — its subtree travels with it, so there is nothing to orphan.
8640
+ // INERT here too, same measurement as the Tab span above:
8641
+ // outdentListItem() is attribute-only and never detaches the focused
8642
+ // node.
7026
8643
  const outdented = mutateListRun(() => {
7027
8644
  if (!outdentListItem(li)) return false;
7028
8645
  // The surface can still hold things the user reads as "nothing" but a
@@ -7033,10 +8650,34 @@
7033
8650
  // (/[ \t]+$/) does not, so it would survive into the committed line.
7034
8651
  // Either way the user sees an empty item and must get a bare '-'. Clear
7035
8652
  // the surface under the SAME normalisation the branch condition used —
7036
- // liSurfaceHoldsNothing() — inside the suppression span (an innerHTML
7037
- // assignment on the focused node triggers the very same Chromium unfocus
7038
- // quirk), and only once the outdent above has actually happened, since a
7039
- // refused press must leave the DOM byte-identical.
8653
+ // liSurfaceHoldsNothing() — and only once the outdent above has
8654
+ // actually happened, since a refused press must leave the DOM
8655
+ // byte-identical.
8656
+ //
8657
+ // ⚠ Final wave, I4. The sentence that stood here said this innerHTML
8658
+ // assignment is itself a reason the write must sit inside
8659
+ // mutateListRun()'s suppression span — "an innerHTML assignment on the
8660
+ // focused node triggers the very same Chromium unfocus quirk". MEASURED
8661
+ // false, and its own premise is the counter-example: `textEl` IS the
8662
+ // focused node, and writing its innerHTML replaces only its SUBTREE —
8663
+ // the element itself is never detached. Control experiment, driven in a
8664
+ // real page: focus a `.ed-li-text`, count focusout on it and on
8665
+ // `document` (capture), then assign `''` — 0 focusout, 0 blur,
8666
+ // `document.activeElement` still that same node, node still in the
8667
+ // document.
8668
+ //
8669
+ // What the span IS for is written at `suppressLiFocusout`'s own
8670
+ // declaration: a structural list key can move, split or REMOVE the
8671
+ // focused block, and Chromium's synchronous unfocus then reaches
8672
+ // resolveBurst() with the run still in its pre-mutation shape. This
8673
+ // assignment is inside the span because it is part of the same
8674
+ // mutation, not because it contributes a focusout of its own.
8675
+ //
8676
+ // No claim is made here about WHICH callee needs it, and deliberately
8677
+ // not about outdentListItem(): that function is attribute-only in this
8678
+ // flat model — it calls setBlockIndent() on the block and its subtree
8679
+ // (`data-indent` + a CSS custom property) and re-parents nothing, so it
8680
+ // is not the answer either.
7040
8681
  const textEl = liTextEl(li);
7041
8682
  if (textEl && liSurfaceHoldsNothing(textEl)) textEl.innerHTML = '';
7042
8683
  return true;
@@ -7062,6 +8703,10 @@
7062
8703
  return true;
7063
8704
  }
7064
8705
  // Row 1: split at the caret; the caret goes to the START of the new block.
8706
+ // INERT here too, same measurement as the Tab span above:
8707
+ // splitListItemAtCaret() keeps `li` itself in place (only `newLi` is a
8708
+ // freshly-inserted sibling) and never touches the focused node's
8709
+ // attachment.
7065
8710
  const newLi = mutateListRun(() => splitListItemAtCaret(li));
7066
8711
  if (!newLi) return true;
7067
8712
  run = listRunOf(newLi);
@@ -7111,6 +8756,7 @@
7111
8756
  // branch — so a burst-undo snapshot restore below reproduces a fully
7112
8757
  // re-armed table, not a plain static one).
7113
8758
  function startTableBurst(cellEl) {
8759
+ dropDiscardedBurst();
7114
8760
  const tableEl = cellEl.closest('table');
7115
8761
  const blockEl = tableEl && tableEl.closest('.ed-block');
7116
8762
  if (!blockEl) return;
@@ -7124,6 +8770,7 @@
7124
8770
  depth: null, original: burstBaselineHtml(tableEl), history,
7125
8771
  activeCellEl: cellEl,
7126
8772
  };
8773
+ watchBurstForDirtyDot(tableEl);
7127
8774
  selToolbarEditEl = cellEl;
7128
8775
  if (!selToolbarListener) {
7129
8776
  selToolbarListener = onSelectionChangeForToolbar;
@@ -7147,6 +8794,21 @@
7147
8794
  }
7148
8795
  const blockEl = tableEl.closest('.ed-block');
7149
8796
  const blockId = blockEl ? blockEl.getAttribute('data-block-id') : null;
8797
+ // Captured HERE, while the pressed cell is still in the document (the
8798
+ // delegated focusin listener that calls this passes its own `e.target`),
8799
+ // for the stale recovery further down — see its comment for what each
8800
+ // handle is for and what was measured about it. A commit resolved by the
8801
+ // `await` below rebinds `blocks` to the new render's list
8802
+ // (applyFullRender()'s own first lines), so a startLine belonging to the
8803
+ // pre-commit list has to be read before it.
8804
+ const pressedRowEl = cellEl.parentElement;
8805
+ const pressedRow = pressedRowEl ? allRowsOf(tableEl).indexOf(pressedRowEl) : -1;
8806
+ const pressedCol = pressedRowEl
8807
+ ? Array.prototype.indexOf.call(pressedRowEl.cells, cellEl) : -1;
8808
+ const blockNum = blockEl ? Number(blockEl.getAttribute('data-block-id')) : null;
8809
+ const blockAtPress = blockNum != null ? blocks.find((b) => b.id === blockNum) : null;
8810
+ const pressedStartLine = blockAtPress ? blockAtPress.startLine : null;
8811
+ const pressedIdentity = tableIdentityOf(tableEl);
7150
8812
  if (switching) await switching;
7151
8813
  if (currentBurst && currentBurst.blockType === 'table' && currentBurst.blockEl &&
7152
8814
  currentBurst.blockEl.getAttribute('data-block-id') === blockId) {
@@ -7157,9 +8819,56 @@
7157
8819
  if (currentBurst) return; // a concurrent focusin (a DIFFERENT block) already won the race
7158
8820
  let liveCellEl = cellEl;
7159
8821
  if (!document.body.contains(cellEl)) {
7160
- const liveBlockEl = blockId != null ? document.querySelector('.ed-block[data-block-id="' + blockId + '"]') : null;
7161
- const liveTableEl = liveBlockEl ? blockContentEl(liveBlockEl) : null;
7162
- liveCellEl = liveTableEl ? tableCellsOf(liveTableEl)[0] : null;
8822
+ // Reached when the pressed cell's node is gone from the document by the
8823
+ // time this focusin resumes. Instrumented on the F8 fixture (a
8824
+ // paragraph left dirty, then a press into the table): the mousedown's
8825
+ // focusout resolves that paragraph, and applyRenderResult()'s patch
8826
+ // gate consults domMatchesLastRender(), which reports false while
8827
+ // `lastParts` is still null — as it is until a render has landed — so
8828
+ // the render goes to applyFullRender(), whose `contentEl.innerHTML =`
8829
+ // rewrites .content wholesale. A probe around the `await` above
8830
+ // recorded route ["applyFullRender"] together with
8831
+ // `document.body.contains(cellEl)` false, on each reproduction driven.
8832
+ // Recovering the cell as `tableCellsOf(...)[0]` therefore dropped the
8833
+ // caret into the header cell rather than the pressed one; driven to
8834
+ // disk, a `QQ` typed straight after the press came back as
8835
+ // `| QQA | B |`.
8836
+ //
8837
+ // What DOES come back after that rewrite, measured across the same
8838
+ // commit: the pressed cell's (row, col) seat in its table. Naming the
8839
+ // table again is the other half, and the handle this recovery already
8840
+ // used was driven alongside the one ensureTableBurstOpen() re-resolves
8841
+ // through — neither subsumes the other:
8842
+ // a raw edit adding a LINE ahead of the table, block count unchanged
8843
+ // -> data-block-id still names this table; startLine names the
8844
+ // paragraph that grew
8845
+ // a raw edit adding a BLOCK ahead of the table, line count unchanged
8846
+ // -> data-block-id names a renumbered neighbour; startLine still
8847
+ // names this table
8848
+ // so the id is asked first and the startLine handle answers when it
8849
+ // cannot, for the reason ensureTableBurstOpen()'s S1 comment writes
8850
+ // down. Whichever one answers, tableIdentityOf() has to agree before a
8851
+ // caret is placed: a commit that renumbers ids can leave the captured
8852
+ // id pointing at a DIFFERENT table, and the same (row, col) seat
8853
+ // inside THAT table is one the user never pressed — driven on a
8854
+ // fixture holding a second table, dropping this check landed the caret
8855
+ // in the neighbour's body cell. A commit that moves this table's start
8856
+ // line AND renumbers its id leaves nothing for either handle to
8857
+ // resolve; no caret is then placed, the conservative half of the trade
8858
+ // ensureTableBurstOpen() already takes.
8859
+ const liveTableFrom = (candidateBlockEl) => {
8860
+ const t = candidateBlockEl ? blockContentEl(candidateBlockEl) : null;
8861
+ if (!t || !t.classList || !t.classList.contains('ed-wys-table')) return null;
8862
+ if (pressedIdentity == null || tableIdentityOf(t) !== pressedIdentity) return null;
8863
+ return t;
8864
+ };
8865
+ const liveTableEl =
8866
+ liveTableFrom(blockId != null
8867
+ ? document.querySelector('.ed-block[data-block-id="' + blockId + '"]') : null) ||
8868
+ liveTableFrom(pressedStartLine != null ? blockElAtLine(pressedStartLine) : null);
8869
+ const liveRowEl = (liveTableEl && pressedRow >= 0)
8870
+ ? allRowsOf(liveTableEl)[pressedRow] : null;
8871
+ liveCellEl = (liveRowEl && pressedCol >= 0) ? liveRowEl.cells[pressedCol] : null;
7163
8872
  if (!liveCellEl || !liveCellEl.classList.contains('ed-wys-cell')) return;
7164
8873
  liveCellEl.focus();
7165
8874
  return;
@@ -7167,13 +8876,123 @@
7167
8876
  startTableBurst(liveCellEl);
7168
8877
  }
7169
8878
 
8879
+ // Tab / Shift+Tab inside a table. Two things were measured on a table whose
8880
+ // cells were A, B, c1 and c2, before this version, and both are fixed here:
8881
+ //
8882
+ // * At either end of the cell list the target index was CLAMPED, so the
8883
+ // press resolved back to the cell it started from. The handler still ran
8884
+ // and its preventDefault() was readable in the bubble phase, so the
8885
+ // browser's own Tab traversal was consumed as well. Pressed in c2 it
8886
+ // left document.activeElement on that same TD, the block count and the
8887
+ // file's bytes unchanged, and the only visible effect was the caret
8888
+ // moving to the end of the cell it was already in. Shift+Tab in A was
8889
+ // the same dead key.
8890
+ // * The landing placed a COLLAPSED caret at the end of the target cell, so
8891
+ // typing straight after a Tab appended rather than replaced: Tab out of
8892
+ // A and then typing ZZ left B holding 'BZZ'.
8893
+ //
8894
+ // Now a step within the table SELECTS the target cell's contents, which is
8895
+ // what makes "Tab, then type" a replacement the way a spreadsheet does, and
8896
+ // a step off either end leaves the table — leaveTableByTab() below.
8897
+ //
8898
+ // Tab does not add a row past the last cell. That is the product's answer,
8899
+ // not something missing here.
7170
8900
  function moveActiveTableCell(cellEl, delta) {
7171
8901
  const tableEl = cellEl.closest('table');
7172
8902
  const cells = tableCellsOf(tableEl);
7173
- const idx = cells.indexOf(cellEl);
7174
- const next = Math.max(0, Math.min(cells.length - 1, idx + delta));
7175
- const target = cells[next];
7176
- if (target) { target.focus(); placeCaretAtEnd(target); }
8903
+ const next = cells.indexOf(cellEl) + delta;
8904
+ if (next >= 0 && next < cells.length) {
8905
+ const target = cells[next];
8906
+ target.focus();
8907
+ selectSurfaceContents(target);
8908
+ // Keep the floating .ed-seltb down over exactly this selection — see
8909
+ // `tabSelectedCellEl`'s own comment for what releases it again.
8910
+ tabSelectedCellEl = target;
8911
+ return;
8912
+ }
8913
+ leaveTableByTab(tableEl, delta);
8914
+ }
8915
+
8916
+ // Tab off either end of a table: to the nearest block in that direction that
8917
+ // focusableSurfaceOf() finds a surface in, degraded blocks stepped over.
8918
+ //
8919
+ // The burst is resolved FIRST, rather than focusing the destination and
8920
+ // leaving the resulting focusout to do it. Focus leaving the table is itself
8921
+ // what ends a table burst, and a burst carrying an edit ends by committing
8922
+ // and re-rendering, which rewrites '.content' wholesale. Driven on a table
8923
+ // left dirty when the press lands, watching focusin/focusout for that one
8924
+ // press:
8925
+ //
8926
+ // resolve first (this shape) — focusout on the cell, then a single
8927
+ // focusin, on the destination this function re-resolved. The element
8928
+ // captured before the resolution was gone from the document by then;
8929
+ // ablating the re-resolution and focusing THAT captured node instead
8930
+ // landed the caret on no editable surface — document.activeElement came
8931
+ // back carrying no class and the whole page as its text.
8932
+ // focus first — focusout on the cell, focusin on the destination,
8933
+ // focusout on it again as the render detaches it, focusin on its
8934
+ // replacement. The caret does arrive, by a longer road and through a
8935
+ // node that is being taken apart underneath it.
8936
+ //
8937
+ // Resolving first and re-resolving afterwards is also the shape
8938
+ // ensureTableBurstOpen() above already uses against the same hazard.
8939
+ //
8940
+ // captureBlockIdentity() / reresolveBlockEl() is the handle, for the reason
8941
+ // their own comment writes down: blockmap.js renumbers ids from zero on
8942
+ // every render, so a captured id can name the destination's NEIGHBOUR after
8943
+ // a commit that changed the block count, and the start line plus the block's
8944
+ // own source lines is what proves the block sitting there afterwards is the
8945
+ // one the press was aimed at. When it cannot be proven nothing is focused —
8946
+ // the conservative half of the trade the gutter gestures already take.
8947
+ //
8948
+ // Nothing is focused when no such block exists either, and the caller's
8949
+ // preventDefault() still stands over that press. Handing it back to the
8950
+ // browser is not the alternative: buildGutterHandle() sets tabindex="-1" on
8951
+ // the per-block chrome precisely because a browser-run Tab walks into it,
8952
+ // and the document-level Tab branch near the bottom of this file already
8953
+ // treats a Tab from inside an '.ed-block' as a deliberate silent no-op.
8954
+ // T21 item 2 split this in two. The body below is the LANDING, and it is no
8955
+ // longer only a table's exit: once a Tab has moved the caret across a block
8956
+ // boundary, the next Tab has to be able to move it again, so the block it
8957
+ // just landed on calls the same function. leaveTableByTab() underneath is
8958
+ // the table's own name for it, kept because the whole comment above is
8959
+ // written about that gesture.
8960
+ //
8961
+ // `arrivedByTab` is the landing's mark on the burst it just opened, and the
8962
+ // burst object is the right place for it: bursts are per focus, so the mark
8963
+ // cannot outlive the surface it describes, and clicking into some other
8964
+ // block opens a burst without it. handleBurstKeydown()/handleLiKeydown()
8965
+ // read it to decide that a further Tab means "move on" rather than "edit
8966
+ // this" — see the measurement in handleBurstKeydown()'s Tab branch.
8967
+ //
8968
+ // The mark is set for whatever kind of burst the landing opened, and only
8969
+ // the paragraph/heading/li Tab branches ever READ it: a table cell's Tab is
8970
+ // owned by handleTableCellKeydown(), which already means "move on" (to the
8971
+ // next cell, and off the end back through here). Scoping the WRITE to
8972
+ // non-table destinations as well was tried and dropped — ablating that
8973
+ // condition alone changed no measured behaviour (the row that Tabs out of
8974
+ // one table, into the next, and then on to its second cell stayed green
8975
+ // either way), so it was a condition no test could speak for.
8976
+ async function walkToAdjacentBlockByTab(fromBlockEl, delta) {
8977
+ const destBlockEl = adjacentFocusableBlock(fromBlockEl, delta);
8978
+ if (!destBlockEl) return;
8979
+ const identity = captureBlockIdentity(destBlockEl);
8980
+ const ok = await switchAwayFrom();
8981
+ if (!ok) return;
8982
+ const liveBlockEl = document.body.contains(destBlockEl)
8983
+ ? destBlockEl : reresolveBlockEl(identity);
8984
+ const surface = focusableSurfaceOf(liveBlockEl);
8985
+ if (!surface) return;
8986
+ surface.focus();
8987
+ // A caret at the start of the destination, not a selection of the whole
8988
+ // of it: selecting a paragraph the user only tabbed past would put every
8989
+ // word of it one keystroke from being replaced.
8990
+ placeCaretAtSurfaceStart(surface);
8991
+ if (currentBurst) currentBurst.arrivedByTab = true;
8992
+ }
8993
+
8994
+ async function leaveTableByTab(tableEl, delta) {
8995
+ await walkToAdjacentBlockByTab(tableEl ? tableEl.closest('.ed-block') : null, delta);
7177
8996
  }
7178
8997
 
7179
8998
  // Esc inside a table burst: revert to the pre-focus baseline and end the
@@ -7199,15 +9018,25 @@
7199
9018
  // as revertBurstAndEnd() above.
7200
9019
  const wasPristineForThisBlock = !!(pristineInsert && pristineInsert.blockId === burst.blockId);
7201
9020
  if (wasPristineForThisBlock) pristineInsert = null;
7202
- burst.history.dispose();
9021
+ // T21 item 1: same split as revertBurstAndEnd() above — a pristine insert
9022
+ // is about to lose its whole block, so nothing is stashed for it.
9023
+ const cells = tableCellsOf(tableEl);
9024
+ const cellIdx = cells.indexOf(burst.activeCellEl || cellEl);
9025
+ if (wasPristineForThisBlock) burst.history.dispose();
7203
9026
  currentBurst = null;
7204
9027
  resetSelToolbarState();
7205
9028
  hideTableInsertBubbles();
7206
9029
  hideTableGrips();
7207
9030
  hideTableEdgeMenu();
7208
9031
  cancelTeDrag();
7209
- tableEl.innerHTML = burst.original;
7210
- if (wasPristineForThisBlock) await discardPristineInsert();
9032
+ if (wasPristineForThisBlock) {
9033
+ tableEl.innerHTML = burst.original;
9034
+ await discardPristineInsert();
9035
+ return;
9036
+ }
9037
+ stashDiscardedTableBurst(burst, tableEl, cellIdx, () => {
9038
+ tableEl.innerHTML = burst.original;
9039
+ });
7211
9040
  }
7212
9041
 
7213
9042
  // Ctrl+Z inside a table burst: symmetric to burstUndo() above, but a
@@ -7307,6 +9136,7 @@
7307
9136
  if (!currentBurst || currentBurst.blockType !== 'table' || currentBurst.editEl !== tableEl) return;
7308
9137
  if (e.key === 'Enter') {
7309
9138
  e.preventDefault();
9139
+ snapBurstIfActive(tableEl, 'br-pre');
7310
9140
  insertBrAtCaret();
7311
9141
  snapBurstIfActive(tableEl, 'br');
7312
9142
  return;
@@ -7621,6 +9451,7 @@
7621
9451
  // click) — never the possibly-now-detached `tableEl` itself.
7622
9452
  const liveTableEl = await ensureTableBurstOpen(tableEl);
7623
9453
  if (!liveTableEl) return;
9454
+ currentBurst.history.snap('insert-col-pre');
7624
9455
  insertColumn(liveTableEl, colIndex);
7625
9456
  armNewTableCells(liveTableEl);
7626
9457
  currentBurst.history.snap('insert-col');
@@ -7634,11 +9465,25 @@
7634
9465
  const afterRowIndex = Number(rowInsertBubble.dataset.afterRowIndex); // -1 -> header (first body row)
7635
9466
  if (!tableEl || Number.isNaN(afterRowIndex)) return;
7636
9467
  // Final-review Finding 6: same live-table swap as onColInsertBubbleClick()
7637
- // above — `afterRowIndex` is a plain integer so it stays valid across
7638
- // the swap unchanged, only the table element reference needs re-resolving.
9468
+ // above — the table element reference has to be re-resolved.
9469
+ //
9470
+ // ⚠ RETRACTION. An earlier revision of this comment went on to say that
9471
+ // `afterRowIndex`, being a plain integer, stays valid across that swap.
9472
+ // Ruling T3-9 measured the opposite and parked the fix in v3.4.0: the
9473
+ // index names a row of the table the bubble was positioned against, and
9474
+ // after a handover it is read against a different element's row list, so
9475
+ // `bodyRowsOf(liveTableEl)[afterRowIndex]` comes back `undefined`,
9476
+ // `afterRow` is falsy, and insertRow() below takes its
9477
+ // `tbody.insertBefore(newRow, tbody.firstChild)` branch — the same branch
9478
+ // `afterRowIndex: -1` uses deliberately. The row lands at the TOP of the
9479
+ // table rather than the gesture being dropped. Nothing here guards it yet.
9480
+ // Task 14's hide-on-approach fix (see updateTableInsertBubbles() below)
9481
+ // made this bubble reliably pressable, which widened the exposure of that
9482
+ // deferred defect rather than narrowing it.
7639
9483
  const liveTableEl = await ensureTableBurstOpen(tableEl);
7640
9484
  if (!liveTableEl) return;
7641
9485
  const afterRow = afterRowIndex >= 0 ? bodyRowsOf(liveTableEl)[afterRowIndex] : null;
9486
+ currentBurst.history.snap('insert-row-pre');
7642
9487
  insertRow(liveTableEl, afterRow);
7643
9488
  armNewTableCells(liveTableEl);
7644
9489
  currentBurst.history.snap('insert-row');
@@ -7658,7 +9503,52 @@
7658
9503
  // pointer (Event#target) at those coordinates.
7659
9504
  function updateTableInsertBubbles(x, y, target) {
7660
9505
  const blockEl = target && target.closest && target.closest('.ed-block[data-block-type="table"]');
7661
- const tableEl = blockEl ? blockContentEl(blockEl) : null;
9506
+ // v3.3.0 F7: a raised bubble, and the row grip, are `position: fixed`
9507
+ // overlays on document.body painted OVER the very band that raises the
9508
+ // bubble — the bubble is centred on the boundary it offers and the row
9509
+ // grip straddles the table's left border — so a pointer reaching for a
9510
+ // bubble arrives on one of them and `target` stops being a table
9511
+ // descendant. Resolving the table from `target` alone then hides the
9512
+ // bubble the pointer is reaching for, the next mousemove finds a cell
9513
+ // again and raises it, and the bubble flickers along the whole
9514
+ // approach; whether it is up when the press lands comes down to where
9515
+ // the last move happened to fall. MEASURED on this repo's puppeteer,
9516
+ // walking the pointer horizontally across the row bubble's own band: the
9517
+ // mousemove targets alternated between the bubble and whatever is
9518
+ // painted under it, and the bubble went hidden on the alternating
9519
+ // events where the bubble itself was the target. So fall back to the table
9520
+ // this hover already resolved, and leave the proximity tests below to
9521
+ // decide visibility from x/y exactly as they do for any other pointer
9522
+ // position, rather than freezing whatever was last shown.
9523
+ //
9524
+ // The `.ed-block::before` corridor needs no entry here: it is a
9525
+ // pseudo-element OF the block, so a hit inside it reports the block as
9526
+ // `target` and the closest() above already matches (MEASURED at
9527
+ // x = tableLeft - 10: `DIV.ed-block`). That is why nothing in this fix
9528
+ // touches its `pointer-events`, which lib/md2doc.js documents as
9529
+ // deliberately absent — being hit-testable is what that corridor is for.
9530
+ // That "for" is this fix's hit-test above, not the corridor's ONLY job:
9531
+ // the same rule also carries the block's own ⠿/+ gutter :hover reveal
9532
+ // (lib/md2doc.js's own comment on `.ed-block::before` — "deliberately
9533
+ // NOT pointer-events: none: that would stop it being hit-tested, which
9534
+ // is the entire mechanism"). This fix measured that the hit-test still
9535
+ // resolves to the block for the bubble; it did not re-measure whether
9536
+ // the gutter's hover reveal still behaves as before, so setting
9537
+ // `pointer-events: none` here to simplify the table-bubble case remains
9538
+ // untried, not merely undesirable.
9539
+ //
9540
+ // The COLUMN grip is deliberately absent from the list below: it is
9541
+ // anchored on the header cell's CENTRE while the column bubble's band
9542
+ // hugs that same cell's RIGHT edge. MEASURED on the fixture used to
9543
+ // drive this fix, the column grip spanned x [639.3, 667.3] while the
9544
+ // header cells' right edges sat at 894 and 1375.5 — the grip's rect
9545
+ // never reaches either band, so a guard for it here is one no test on
9546
+ // this fixture could hold down.
9547
+ let tableEl = blockEl ? blockContentEl(blockEl) : null;
9548
+ if (!tableEl && target && target.closest &&
9549
+ target.closest('.ed-tb-insert, .ed-te-grip-row')) {
9550
+ tableEl = hoveredInsertTableEl;
9551
+ }
7662
9552
  if (!tableEl || !tableEl.classList.contains('ed-wys-table')) {
7663
9553
  hideTableInsertBubbles();
7664
9554
  hoveredInsertTableEl = null;
@@ -7753,8 +9643,11 @@
7753
9643
  // currently holds focus, so Chromium fires a synchronous focusout
7754
9644
  // mid-mutation — exactly the quirk tableBurstUndo()/tableBurstRedo()'s
7755
9645
  // innerHTML-snapshot restore has to guard. performRowDrop() therefore
7756
- // wraps it in `suppressTableFocusout` (the third of the flag's four sites)
7757
- // and puts focus back explicitly via restoreTableFocus(). An earlier
9646
+ // wraps it in `suppressTableFocusout` — one of the sites that set that flag,
9647
+ // alongside tableBurstUndo(), tableBurstRedo(), performColDrop() and
9648
+ // restoreDiscardedBurst()'s table branch; test/editor-client.test.js pins
9649
+ // the set and audits each one for its try/finally — and puts focus back
9650
+ // explicitly via restoreTableFocus(). An earlier
7758
9651
  // revision moved the <tr> with a plain `insertBefore()`, which reparents
7759
9652
  // in one synchronous step, never blurs, and needed no guard — the header
7760
9653
  // promotion requirement is what retired that. The menu's delete ops (which
@@ -7763,9 +9656,17 @@
7763
9656
  //
7764
9657
  // Both the menu (delete/align) and the drag's DOM move are burst
7765
9658
  // mutations: ensureTableBurstOpen() (Task 5, above) auto-starts a burst
7766
- // on this table if none is open yet, then the op mutates the live DOM and
7767
- // calls currentBurst.history.snap() — committed on table-leave like any
7768
- // other table edit. Refusal banners reuse the exact wording the retired
9659
+ // on this table if none is open yet, then the op snapshots the live DOM
9660
+ // (`snap('<reason>-pre')`), mutates it, and snapshots again — committed on
9661
+ // table-leave like any other table edit. The pre-snapshot is v3.3.0 地基 B;
9662
+ // see snapBurstIfActive()'s comment for why a lone post-snapshot loses the
9663
+ // text the user typed into a cell just before the structural edit. The
9664
+ // structural ops it names (insert/delete row and column, cycle column align,
9665
+ // drag row and column) call currentBurst.history.snap() DIRECTLY rather than
9666
+ // through snapBurstIfActive(), which is why they were not in that function's
9667
+ // call-site inventory — the guard snapBurstIfActive() applies is already
9668
+ // supplied here by ensureTableBurstOpen()'s non-null return, which is
9669
+ // itself conditioned on `currentBurst.editEl === liveTableEl`. Refusal banners reuse the exact wording the retired
7769
9670
  // Phase-2 click-select edit toolbar used for these same three guards (see
7770
9671
  // commit 0661cde, now dead code with zero call sites — this task is what
7771
9672
  // revives deleteRow()/deleteColumn()/cycleColumnAlign() unchanged, per
@@ -7789,6 +9690,20 @@
7789
9690
  // (possibly now-stale, post-delete) column/row it came from.
7790
9691
  let teHighlightEls = [];
7791
9692
 
9693
+ // v3.2.1 fix round 1: same idiom as `suppressTableFocusout`/
9694
+ // `suppressLiFocusout` above — a flag set for the exact synchronous-ish
9695
+ // span of one operation that would otherwise be torn down by a listener
9696
+ // reacting to a side effect THAT OPERATION ITSELF triggers. Here:
9697
+ // runCycleAlign()'s `ensureTableBurstOpen()` can call a cell's `.focus()`,
9698
+ // which can scroll the page for real — and the resulting 'scroll' event
9699
+ // is asynchronous relative to the focus() call (measured: it does not
9700
+ // reliably land before runCycleAlign()'s own code resumes after the
9701
+ // `await`), so a plain "check teMenuKind again afterward" re-open would
9702
+ // race it and sometimes miss. onAnyScroll() checks this flag before
9703
+ // calling hideTableEdgeMenu() so the menu's open state is never torn down
9704
+ // in the first place during that span, whichever tick the event lands on.
9705
+ let suppressEdgeMenuAutoHide = false;
9706
+
7792
9707
  function clearEdgeHighlight() {
7793
9708
  // classList.remove() leaves a dangling `class=""` behind on any element
7794
9709
  // the renderer/armEditables() emitted with NO class of its own — a
@@ -7880,6 +9795,35 @@
7880
9795
  teEdgeMenu.hidden = false;
7881
9796
  }
7882
9797
 
9798
+ // The column menu's vertical placement, shared by showColumnMenu() and by
9799
+ // runCycleAlign()'s reposition so the two can never drift apart. Above the
9800
+ // header cell by default, BELOW it when there is not enough room above —
9801
+ // the same "no room above, go below" fallback positionSelToolbar() uses.
9802
+ //
9803
+ // The floor is `.ed-toolbar`'s height, not zero. That toolbar is
9804
+ // `position: fixed` at the top of the viewport at z-index 101 and this menu
9805
+ // is at 12, so a menu placed inside that band is painted UNDER it and stops
9806
+ // being clickable while still reporting `hidden === false` and a perfectly
9807
+ // ordinary rectangle. Measured at 800x600 on a document whose first block is
9808
+ // the table: the menu's own top was 20.5px and
9809
+ // `document.elementsFromPoint()` at the 對齊 button's own centre returned
9810
+ // `DIV.ed-toolbar` first, with the button second — so the click landed on the
9811
+ // toolbar and runCycleAlign() never ran.
9812
+ //
9813
+ // That band became reachable when the edit page stopped reserving 60px for
9814
+ // the .sidebar-toggle it hides (lib/md2doc.js's `@media (max-width: 1080px)`
9815
+ // .page-layout rule): the same first-block table's header top moved from
9816
+ // ~104px to ~60px, which puts the menu under the toolbar while the grip that
9817
+ // opens it is still comfortably clickable. Both numbers were measured on
9818
+ // this branch, on either side of that change.
9819
+ function placeTeMenuAboveCell(r) {
9820
+ const topInset = parseFloat(getComputedStyle(document.documentElement)
9821
+ .getPropertyValue('--ed-toolbar-h')) || 0;
9822
+ let top = r.top - teEdgeMenu.offsetHeight - TE_MENU_GAP_PX;
9823
+ if (top < topInset) top = r.bottom + TE_MENU_GAP_PX;
9824
+ teEdgeMenu.style.top = top + 'px';
9825
+ }
9826
+
7883
9827
  function showColumnMenu(tableEl, colIndex) {
7884
9828
  if (teMenuKind === 'col' && teMenuTableEl === tableEl && teMenuColIndex === colIndex) {
7885
9829
  hideTableEdgeMenu(); // re-clicking the same column's edge toggles the menu closed
@@ -7896,7 +9840,15 @@
7896
9840
  const cell = headerRow ? headerRow.cells[colIndex] : null;
7897
9841
  const r = (cell || tableEl).getBoundingClientRect();
7898
9842
  positionTeMenu(r.left, r.top);
7899
- teEdgeMenu.style.top = (r.top - teEdgeMenu.offsetHeight - TE_MENU_GAP_PX) + 'px';
9843
+ // Task 9 fix round 1 (Ruling T9-5) left this line unfloored, on a
9844
+ // measurement that said the col grip stops being clickable before the
9845
+ // header's top can get low enough to matter. That measurement is
9846
+ // RETRACTED: it only ever asked whether the menu's top went NEGATIVE, and
9847
+ // the menu is unreachable well before that — it goes under `.ed-toolbar`,
9848
+ // which is 44px tall and paints over this menu. See
9849
+ // placeTeMenuAboveCell() for what was measured instead, and for why the
9850
+ // band is reachable by an ordinary mouse now.
9851
+ placeTeMenuAboveCell(r);
7900
9852
  }
7901
9853
 
7902
9854
  function showRowMenu(tableEl, rowEl) {
@@ -7915,6 +9867,20 @@
7915
9867
  const tableRect = tableEl.getBoundingClientRect();
7916
9868
  positionTeMenu(tableRect.left, r.top);
7917
9869
  teEdgeMenu.style.left = (tableRect.left - teEdgeMenu.offsetWidth - TE_MENU_GAP_PX) + 'px';
9870
+ // Task 9 fix round 1 (Ruling T9-5): same clamp shape as
9871
+ // openConvertSubmenu()'s .ed-handle-submenu fix — measure the menu's
9872
+ // own rendered bottom now that positionTeMenu() has placed and shown
9873
+ // it, and shift `top` up by the bottom overflow when there is one.
9874
+ // `.ed-te-menu` is `position: fixed` (lib/md2doc.js), so its own
9875
+ // getBoundingClientRect() is already in viewport coordinates. Scoped to
9876
+ // showRowMenu(): the row menu's `top` starts at the row's own top,
9877
+ // which is the case the review measured overflowing at an ordinary
9878
+ // window height. showColumnMenu() positions upward from the header
9879
+ // instead and carries its own floor — placeTeMenuAboveCell().
9880
+ const overflowBottom = teEdgeMenu.getBoundingClientRect().bottom - window.innerHeight;
9881
+ if (overflowBottom > 0) {
9882
+ teEdgeMenu.style.top = (r.top - overflowBottom) + 'px';
9883
+ }
7918
9884
  }
7919
9885
 
7920
9886
  // Moves `currentBurst.activeCellEl` to a cell that will SURVIVE deleting
@@ -7970,6 +9936,7 @@
7970
9936
  showBanner('無法刪除最後一欄', null, null);
7971
9937
  return;
7972
9938
  }
9939
+ currentBurst.history.snap('delete-col-pre');
7973
9940
  refocusAwayFromColumn(liveTableEl, colIndex);
7974
9941
  deleteColumn(liveTableEl, colIndex);
7975
9942
  currentBurst.history.snap('delete-col');
@@ -8001,6 +9968,7 @@
8001
9968
  showBanner('無法刪除最後一列', null, null);
8002
9969
  return;
8003
9970
  }
9971
+ currentBurst.history.snap('delete-row-pre');
8004
9972
  refocusAwayFromRow(liveTableEl, liveRowEl);
8005
9973
  deleteRow(liveRowEl);
8006
9974
  currentBurst.history.snap('delete-row');
@@ -8012,14 +9980,85 @@
8012
9980
  const colIndex = teMenuColIndex;
8013
9981
  if (!tableEl || colIndex == null) return;
8014
9982
  // Final-review Finding 6: same live-table swap as runDeleteColumn() above.
8015
- const liveTableEl = await ensureTableBurstOpen(tableEl);
8016
- if (!liveTableEl) return;
8017
- cycleColumnAlign(liveTableEl, colIndex);
8018
- currentBurst.history.snap('align-col');
8019
- // Stays open (unlike delete): repeated clicks keep cycling. The
8020
- // column's cells are the SAME nodes (cycleColumnAlign() only touches
8021
- // the `style` attribute) so the existing highlight is still valid —
8022
- // nothing to reposition/rehighlight.
9983
+ // v3.2.1 fix round 1: ensureTableBurstOpen() below can call a cell's
9984
+ // .focus(), which can scroll the page for real — and v3.2.1's onAnyScroll
9985
+ // (this file's module-level scroll listener) calls hideTableEdgeMenu() on
9986
+ // every scroll, which would reset teMenuKind/teMenuTableEl/teMenuColIndex
9987
+ // to null and un-highlight the column, breaking this function's own
9988
+ // "stays open, repeated clicks keep cycling" contract below. Measured
9989
+ // TWICE: (1) a plain "re-check state and re-show if it changed" fix
9990
+ // RACES the resulting 'scroll' event, which is asynchronous relative to
9991
+ // the `.focus()` call and does not reliably land before this function
9992
+ // resumes after the `await` — so suppress the menu's own auto-hide for
9993
+ // this span instead (`suppressEdgeMenuAutoHide`, see its own comment).
9994
+ // (2) clearing that suppression in a plain `finally` right after the
9995
+ // `await` ALSO raced it — the pending 'scroll' event can land AFTER this
9996
+ // function has already resumed and cleared the flag, since a
9997
+ // `focus()`-triggered scroll is not guaranteed to be delivered within
9998
+ // the same microtask turn as the promise chain above. Measured fix:
9999
+ // browsers dispatch a pending scroll before the next
10000
+ // requestAnimationFrame callback runs (it is part of the "update the
10001
+ // rendering" steps that precede rAF callbacks in the same frame), so
10002
+ // holding the flag through one rAF tick lets a pending scroll land AND
10003
+ // be swallowed before this lifts it.
10004
+ let liveTableEl;
10005
+ suppressEdgeMenuAutoHide = true;
10006
+ try {
10007
+ liveTableEl = await ensureTableBurstOpen(tableEl);
10008
+ if (!liveTableEl) { suppressEdgeMenuAutoHide = false; return; }
10009
+ currentBurst.history.snap('align-col-pre');
10010
+ cycleColumnAlign(liveTableEl, colIndex);
10011
+ currentBurst.history.snap('align-col');
10012
+ } catch (e) {
10013
+ suppressEdgeMenuAutoHide = false;
10014
+ throw e;
10015
+ }
10016
+ requestAnimationFrame(() => {
10017
+ suppressEdgeMenuAutoHide = false;
10018
+ // Fix round 2 (Important, caught by the re-reviewer): suppressing
10019
+ // hideTableEdgeMenu() only stops onAnyScroll from closing the menu —
10020
+ // it does NOT stop `ensureTableBurstOpen()`'s own `switchAwayFrom()`
10021
+ // from closing it for an UNRELATED, legitimate reason when a burst was
10022
+ // open on a DIFFERENT block: switchAwayFrom() -> resolveBurst() ->
10023
+ // hideTableEdgeMenu() (unconditional, not suppressed — only
10024
+ // onAnyScroll's call is gated) nulls teMenuKind/teMenuTableEl/
10025
+ // teMenuColIndex and clears the highlight. Without this guard the
10026
+ // unconditional reposition below would then make that CORRECTLY-
10027
+ // closed menu visible again — empty of highlight, and inert (its own
10028
+ // teMenuKind/teMenuColIndex are null, so a second 對齊/刪除欄 click
10029
+ // reads no target and does nothing) — floating over the document
10030
+ // until the next scroll happens to close it for an unrelated reason.
10031
+ // Only reposition when the menu is STILL the one this call opened:
10032
+ // same kind, same column, and the table identity matches EITHER
10033
+ // `tableEl` (showColumnMenu() was called with this — the pre-swap
10034
+ // identity, read from its own definition above — and nothing since
10035
+ // has touched teMenuTableEl) or `liveTableEl` (belt-and-braces, in
10036
+ // case some future path re-opens the menu against the live node).
10037
+ if (teMenuKind !== 'col' || teMenuColIndex !== colIndex ||
10038
+ (teMenuTableEl !== liveTableEl && teMenuTableEl !== tableEl)) return;
10039
+ // Stays open (unlike delete) UNLESS ensureTableBurstOpen()'s
10040
+ // switchAwayFrom() legitimately closed it above (guard just above) —
10041
+ // repeated clicks keep cycling. The column's cells are the SAME nodes
10042
+ // (cycleColumnAlign() only touches the `style` attribute) so the
10043
+ // existing highlight is still valid — nothing to rehighlight.
10044
+ // `.ed-te-menu` is `position: fixed` (viewport-relative) though, so if
10045
+ // the suppressed span above actually scrolled the page, its left/top
10046
+ // are now stale relative to the header cell; reposition on every call
10047
+ // that gets past the identity guard three lines up (same math as
10048
+ // showColumnMenu(), skipping its toggle-closed branch — that branch would
10049
+ // wrongly close a menu that never actually asked to close). Final wave,
10050
+ // M1: "unconditionally" is what this said before that guard was added,
10051
+ // and it stopped being true then — a call whose menu is no longer the one
10052
+ // it opened returns above and repositions nothing. A no-op scroll makes
10053
+ // this a no-op reposition (same numbers back).
10054
+ const headerRow = headerRowOf(liveTableEl);
10055
+ const cell = headerRow ? headerRow.cells[colIndex] : null;
10056
+ if (cell) {
10057
+ const r = cell.getBoundingClientRect();
10058
+ positionTeMenu(r.left, r.top);
10059
+ placeTeMenuAboveCell(r);
10060
+ }
10061
+ });
8023
10062
  }
8024
10063
 
8025
10064
  // ── Notion-style row/column grip handles ─────────────────────────────
@@ -8350,6 +10389,16 @@
8350
10389
  // whatever was last shown untouched instead; hideTableGrips() (called
8351
10390
  // from table-leave, burst-end, and drag-start elsewhere) already covers
8352
10391
  // every path that actually needs to clear it.
10392
+ //
10393
+ // ⚠ KNOWN GAP, deferred by Ruling T14-5 to v3.4.0: the list below names the
10394
+ // two grips and NOT '.ed-tb-insert', so the hover-insert + bubble — which
10395
+ // is the same kind of `position: fixed` overlay painted over the same band
10396
+ // — falls through to the branches beneath and takes the ⠿ grips down while
10397
+ // the pointer stands on it. The reason it was not closed here is that
10398
+ // widening this test also widens the early return's setGutterKeep() call,
10399
+ // whose behaviour on the + bubble is unmeasured. A reader of this function
10400
+ // should know it is knowingly incomplete rather than infer the list is the
10401
+ // whole set.
8353
10402
  if (target && target.closest && (target.closest('.ed-te-grip-row') || target.closest('.ed-te-grip-col'))) {
8354
10403
  // v3.0.2: only the ROW grip is the one that covers the ⠿'s approach.
8355
10404
  // Testing the hit rather than reusing the branch condition matters —
@@ -8696,6 +10745,7 @@
8696
10745
  order.splice(toIndex > rowIndex ? toIndex - 1 : toIndex, 0, moved);
8697
10746
  if (order.every((row, i) => row === liveRows[i])) return; // 原地放回:不動 DOM、不 snap
8698
10747
 
10748
+ currentBurst.history.snap('drag-row-pre');
8699
10749
  const aligns = columnAlignsOf(liveTableEl);
8700
10750
  const activeIndex = (currentBurst && currentBurst.activeCellEl)
8701
10751
  ? tableCellsOf(liveTableEl).indexOf(currentBurst.activeCellEl) : -1;
@@ -8753,6 +10803,7 @@
8753
10803
  // 看不出任何異常。寧可整個不動。
8754
10804
  const dropRows = allRowsOf(liveTableEl);
8755
10805
  if (!dropRows.length || dropRows.some((row) => !row.cells[fromIndex])) return;
10806
+ currentBurst.history.snap('drag-col-pre');
8756
10807
  suppressTableFocusout = true;
8757
10808
  try {
8758
10809
  dropRows.forEach((row) => {
@@ -9840,6 +11891,60 @@
9840
11891
  // is active, so this always names whatever element the toolbar's mark
9841
11892
  // toggles should act on right now.
9842
11893
  let selToolbarEditEl = null;
11894
+ // Task 15: the cell whose whole contents the Tab walk selected, or null.
11895
+ // Tab now writes a full-cell selection on every press, and
11896
+ // onSelectionChangeForToolbar() below asks only "is there a non-collapsed
11897
+ // selection inside `selToolbarEditEl`" — which a Tab-made selection answers
11898
+ // yes to, so the floating .ed-seltb rose over the cell on every press.
11899
+ //
11900
+ // Keyed on the CELL ELEMENT, never on a Range. A release condition phrased
11901
+ // as "the selection went collapsed" would fire on its own during a
11902
+ // re-render: measured across a commit that took the full-render route, a
11903
+ // Range built over a cell before it neither threw nor reported itself
11904
+ // broken afterwards, while the cell it was built from did — see the probe
11905
+ // figures quoted with the release check in onSelectionChangeForToolbar().
11906
+ //
11907
+ // Released the moment the live selection stops being exactly this cell's
11908
+ // contents, which is also what keeps a HAND-MADE selection out of the
11909
+ // suppression: the boundary points written here are the cell element itself
11910
+ // at offset zero and at its child count, while a selection the user builds
11911
+ // is anchored in the text. Driven on a cell holding 'c2', a drag across it,
11912
+ // a double click, a triple click and Ctrl+A each read back a startContainer
11913
+ // of '#text', so none of them matches.
11914
+ //
11915
+ // What this cannot reach, and does not cause: a press that begins INSIDE the
11916
+ // standing selection is Chromium's own drag-the-selected-text gesture.
11917
+ // Driven straight after a Tab, it dispatched dragstart and dragend and not
11918
+ // one selectionchange, so no release condition of any shape gets a hearing —
11919
+ // the same with this suppression removed. Anything that collapses the
11920
+ // selection first (a click, an arrow key) puts an ordinary drag back, and
11921
+ // that drag releases here. Meanwhile the FIXED toolbar's inline buttons stay
11922
+ // enabled throughout: hasFormattableSelection() answers for those and knows
11923
+ // nothing about this flag, measured with B live over a suppressed selection.
11924
+ let tabSelectedCellEl = null;
11925
+ // Task 16: set when positionSelToolbar() below takes .ed-seltb down over a
11926
+ // selection that left the viewport while staying alive, and cleared when
11927
+ // the bar goes back up. Its lifetime has to reach OUTSIDE the
11928
+ // `selToolbar.parentNode` gate in onAnyScroll(), because that gate is what
11929
+ // the removal turns against the bar: with the node detached the gate
11930
+ // returns before anything can re-ask.
11931
+ //
11932
+ // Measured on a paragraph selection in a document of filler paragraphs,
11933
+ // before this flag existed — scrolled to +150 the bar followed, its top
11934
+ // moving 192 -> 42; at +1500 it was gone from the DOM; scrolled back to the
11935
+ // top it stayed gone while the selection still read non-collapsed with its
11936
+ // rect back at top 234. A synthetic selectionchange dispatched at that
11937
+ // point put the bar back at top 192, which is what says the missing state
11938
+ // was the permission to re-ask rather than a live selection.
11939
+ //
11940
+ // Opposite polarity to tabSelectedCellEl above: that flag holds the bar
11941
+ // DOWN, this one lets it back UP. Keyed on neither a cell nor a Range — the
11942
+ // defect reproduces on a paragraph, where there is no cell to key on, and
11943
+ // the re-ask reads the selection back off the document when it fires. It
11944
+ // says nothing about WHY the bar is down right now: measured after a Tab
11945
+ // walk moved on from a scrolled-away selection, it read true while Task
11946
+ // 15's suppression was what held the bar down.
11947
+ let selToolbarOffViewport = false;
9843
11948
  // The currently-attached onSelectionChangeForToolbar function reference (or
9844
11949
  // null) — kept at this module scope, NOT just inside openWysiwygEditor()'s
9845
11950
  // closure, specifically so a call site OUTSIDE that closure (rerenderAll()
@@ -9860,6 +11965,13 @@
9860
11965
  }
9861
11966
  hideSelToolbar();
9862
11967
  selToolbarEditEl = null;
11968
+ // Task 15: the Tab suppression is part of the same "a session is open"
11969
+ // state, so it goes down with the rest of it.
11970
+ tabSelectedCellEl = null;
11971
+ // Task 16: same reasoning — "the bar is down because it scrolled out of
11972
+ // view" is a statement about an open session's selection, and there is no
11973
+ // session left to make it about.
11974
+ selToolbarOffViewport = false;
9863
11975
  }
9864
11976
 
9865
11977
  // Shows/repositions/hides the floating selection toolbar as the selection
@@ -9901,6 +12013,37 @@
9901
12013
  hideSelToolbar();
9902
12014
  return;
9903
12015
  }
12016
+ // Task 15 (T15-5): a selection the Tab walk wrote keeps this bar down.
12017
+ // Asked here, below the collapsed exit above, on purpose — focusing a
12018
+ // cell parks a collapsed selection in it a moment before the walk writes
12019
+ // the real one, and releasing on that would let the bar up again on the
12020
+ // very press this is suppressing.
12021
+ //
12022
+ // The BOUNDARY comparison is the working half: it says whether this is
12023
+ // still the Tab's own selection or one the user has since built by hand,
12024
+ // and each of its four terms was ablated on its own against a selection
12025
+ // differing in that term alone — every one of them left its own input
12026
+ // wrongly suppressed, and no other.
12027
+ //
12028
+ // The isConnected term is belt and braces, and honestly so: ablating it
12029
+ // changed no measured behaviour, because a detached cell fails the
12030
+ // boundary comparison as well. It is asked anyway because the cell
12031
+ // element is the only handle that can be asked at all — driven across a
12032
+ // commit that took the full-render route, a Range built over a cell
12033
+ // beforehand reported startContainer.isConnected true and covered the
12034
+ // empty string afterwards, while that same cell reported isConnected
12035
+ // false. A Range collapses in silence, so a release keyed on one would
12036
+ // fire during a re-render of its own accord.
12037
+ if (tabSelectedCellEl) {
12038
+ if (tabSelectedCellEl.isConnected &&
12039
+ range.startContainer === tabSelectedCellEl && range.startOffset === 0 &&
12040
+ range.endContainer === tabSelectedCellEl &&
12041
+ range.endOffset === tabSelectedCellEl.childNodes.length) {
12042
+ hideSelToolbar();
12043
+ return;
12044
+ }
12045
+ tabSelectedCellEl = null;
12046
+ }
9904
12047
  positionSelToolbar(range);
9905
12048
  } finally {
9906
12049
  // In a `finally` so every early return above still refreshes the bar —
@@ -9950,18 +12093,84 @@
9950
12093
  return r;
9951
12094
  }
9952
12095
 
12096
+ // Extracts the range's contents into `el`, puts `el` where the range was,
12097
+ // and drops the marks the extraction emptied — the fused form of
12098
+ // collectLeftovers() + dropEmptied() above, which is all an extractContents()
12099
+ // call site needs. applyMarkToggle()'s wrap branch reaches it through
12100
+ // wrapRangeIn() below; applyLinkToggleBody()'s new-link path calls it
12101
+ // directly. A native Ctrl+B does not arrive here at all: client.js binds no
12102
+ // Ctrl/Cmd + b/i/u, so that keystroke reaches the contenteditable as the
12103
+ // browser's own execCommand and produces a <b>. The journey suite drives
12104
+ // that keystroke and the toolbar button side by side, each with its own tag
12105
+ // pattern, for exactly that reason.
12106
+ function extractRangeInto(range, el, root) {
12107
+ const leftovers = collectLeftovers(range, root);
12108
+ el.appendChild(range.extractContents());
12109
+ range.insertNode(el);
12110
+ dropEmptied(leftovers);
12111
+ return el;
12112
+ }
12113
+
9953
12114
  // Wraps the range's contents in a brand-new `<tag>` element via Range
9954
12115
  // surgery (extractContents/insertNode — plain DOM Range methods, NOT
9955
12116
  // execCommand). Returns a Range spanning the new element's contents.
9956
- function wrapRangeIn(range, tag) {
9957
- const el = document.createElement(tag.toLowerCase());
9958
- el.appendChild(range.extractContents());
9959
- range.insertNode(el);
12117
+ function wrapRangeIn(range, tag, root) {
12118
+ const el = extractRangeInto(range, document.createElement(tag.toLowerCase()), root);
9960
12119
  const r = document.createRange();
9961
12120
  r.selectNodeContents(el);
9962
12121
  return r;
9963
12122
  }
9964
12123
 
12124
+ // v3.2.1: 在字元空間修剪 Range 的前後空白,再交給 wrapRangeIn()。
12125
+ // 不用 startOffset/endOffset 步進:當邊界容器是【元素節點】時那些 offset
12126
+ // 索引的是【子節點】而非字元,一次遞減會跳過整個文字節點。實測人類拖曳
12127
+ // 選取 105 次都不會產生元素邊界,但編輯器自己的 reselectAndReposition() 會。
12128
+ function trimRangeToText(range) {
12129
+ const parts = [];
12130
+ const take = (n) => {
12131
+ const len = n.data.length;
12132
+ let a, b;
12133
+ try { a = range.comparePoint(n, 0); b = range.comparePoint(n, len); }
12134
+ catch (e) { return; }
12135
+ if (b < 0 || a > 0) return; // 整個節點落在範圍外
12136
+ const from = (n === range.startContainer && n.nodeType === 3) ? range.startOffset : 0;
12137
+ const to = (n === range.endContainer && n.nodeType === 3) ? range.endOffset : len;
12138
+ if (to > from) parts.push({ n: n, from: from, to: to });
12139
+ };
12140
+ const root = range.commonAncestorContainer;
12141
+ const walk = (node) => {
12142
+ if (node.nodeType === 3) { take(node); return; }
12143
+ for (let c = node.firstChild; c; c = c.nextSibling) walk(c);
12144
+ };
12145
+ // v3.2.1 review: root.nodeType === 3 的情況已經由 walk() 自己的
12146
+ // `node.nodeType === 3` 分支正確處理(single-text-node 選取時
12147
+ // startContainer/endContainer 必為同一個 text node,也就是這裡的
12148
+ // root——take() 的 `n === range.startContainer`/`n === range.endContainer`
12149
+ // 判斷照樣成立)。原本 `root.nodeType === 3 ? root : root` 兩支完全相同,
12150
+ // 是無效的死碼,這裡直接呼叫 walk(root);journey 測試(選取整個落在單一
12151
+ // text node 內)已驗證這條路徑產出正確的 from/to。
12152
+ walk(root);
12153
+ if (!parts.length) return false;
12154
+ // 從頭砍掉空白
12155
+ while (parts.length) {
12156
+ const p = parts[0];
12157
+ while (p.from < p.to && /\s/.test(p.n.data[p.from])) p.from++;
12158
+ if (p.from < p.to) break;
12159
+ parts.shift();
12160
+ }
12161
+ // 從尾砍掉空白
12162
+ while (parts.length) {
12163
+ const p = parts[parts.length - 1];
12164
+ while (p.to > p.from && /\s/.test(p.n.data[p.to - 1])) p.to--;
12165
+ if (p.to > p.from) break;
12166
+ parts.pop();
12167
+ }
12168
+ if (!parts.length) return false; // 全空白選取 → 不包裝
12169
+ range.setStart(parts[0].n, parts[0].from);
12170
+ range.setEnd(parts[parts.length - 1].n, parts[parts.length - 1].to);
12171
+ return true;
12172
+ }
12173
+
9965
12174
  // Marks of `tag` that the range overlaps (fully or partially) — called
9966
12175
  // only AFTER wholeSelectionMark() has already returned null, so any hit
9967
12176
  // here is by construction a partial-overlap case (see the toggle-policy
@@ -10010,12 +12219,14 @@
10010
12219
 
10011
12220
  const whole = wholeSelectionMark(range, tag, root);
10012
12221
  if (whole) {
12222
+ snapBurstIfActive(root, 'mark-pre');
10013
12223
  reselectAndReposition(unwrapElement(whole));
10014
12224
  snapBurstIfActive(root, 'mark');
10015
12225
  return;
10016
12226
  }
10017
12227
  const overlapping = overlappingMarks(range, tag, root);
10018
12228
  if (overlapping.length > 0) {
12229
+ snapBurstIfActive(root, 'mark-pre');
10019
12230
  const extended = extendRangeOverMarks(range, overlapping);
10020
12231
  // Re-query against the EXTENDED range: `overlapping` above was
10021
12232
  // computed from the original (smaller) range, and every mark the
@@ -10025,26 +12236,192 @@
10025
12236
  snapBurstIfActive(root, 'mark');
10026
12237
  return;
10027
12238
  }
10028
- reselectAndReposition(wrapRangeIn(range, tag));
12239
+ if (!trimRangeToText(range)) return; // 全空白/collapsed 選取:no-op
12240
+ snapBurstIfActive(root, 'mark-pre');
12241
+ reselectAndReposition(wrapRangeIn(range, tag, root));
10029
12242
  snapBurstIfActive(root, 'mark');
10030
12243
  }
10031
12244
 
12245
+ // Resolves once a `focusout` has been dispatched at `el` AND that dispatch
12246
+ // has fully finished, or after `ms` milliseconds if no focusout arrives at
12247
+ // all — whichever comes first.
12248
+ //
12249
+ // Two things about it are load-bearing and both were measured on the 🔗
12250
+ // gesture:
12251
+ //
12252
+ // * The wait is needed because the blur a native modal causes is delivered
12253
+ // on the browser's own schedule, NOT as a task queued by the code that
12254
+ // opened the modal: window.prompt() returned at t=27ms and the focusout
12255
+ // landed at t=28ms, i.e. after the caller had already returned. So a plain
12256
+ // setTimeout(0) is not a substitute for listening.
12257
+ // * The resolve is handed to a TIMER rather than called from the listener.
12258
+ // A listener's own resolve() only queues a microtask, and the HTML spec
12259
+ // performs a microtask checkpoint after EACH listener callback of a
12260
+ // browser-initiated dispatch — so an awaiter resumed from there runs
12261
+ // BEFORE the document-level focusout delegator further along the same
12262
+ // propagation path, and therefore before anything that delegator starts
12263
+ // exists to be waited on. Measured exactly that way: resolving from the
12264
+ // listener left `switching` still null at the awaiter, and the caret was
12265
+ // lost to the render that followed. A timer moves the resume to the next
12266
+ // task, after the whole dispatch.
12267
+ function nextFocusout(el, ms) {
12268
+ return new Promise((resolve) => {
12269
+ let deadline = null;
12270
+ const finish = () => {
12271
+ el.removeEventListener('focusout', onFocusout);
12272
+ if (deadline !== null) { clearTimeout(deadline); deadline = null; }
12273
+ resolve();
12274
+ };
12275
+ const onFocusout = () => { setTimeout(finish, 0); };
12276
+ el.addEventListener('focusout', onFocusout);
12277
+ deadline = setTimeout(finish, ms);
12278
+ });
12279
+ }
12280
+
12281
+ // v3.2.1: THIN WRAPPER over the toggle body below, same shape as
12282
+ // convertBlockViaMenu() — but the restoration cannot sit in a plain
12283
+ // synchronous finally here, and that is the whole difference. MEASURED, with
12284
+ // an in-page focus/mutation trace on the 🔗 button:
12285
+ //
12286
+ // t=21ms window.prompt() entered t=27ms prompt() returned
12287
+ // t=28ms focusout on the armed surface (the modal's own blur, delivered
12288
+ // after the body had already finished its DOM surgery)
12289
+ // t=31ms focusin — the browser hands focus straight back
12290
+ // t=52ms a SECOND focusout, this one from applyFullRender() (captured
12291
+ // stack: applyFullRender <- applyRenderResult <- rerenderAll)
12292
+ // t=66ms document.activeElement === BODY, toolbar down to 4 buttons
12293
+ //
12294
+ // So the thing that costs the user their foothold is not the modal: focus
12295
+ // comes back on its own. It is that the document's focusout delegator read
12296
+ // the modal's blur as "the user left this surface" and ran switchAwayFrom()
12297
+ // -> resolveBurst() -> commitEdit() -> rerenderAll(), and that render
12298
+ // replaced the whole .content subtree the restored focus was sitting in.
12299
+ // The other four inline-format buttons (toolbar-model.js's `group: 'inline'`
12300
+ // is bold / italic / strike / inline-code / link — five, of which link is
12301
+ // this one) never hit this, because none of them opens a modal and so none
12302
+ // of them ever blurs.
12303
+ //
12304
+ // The two awaits below are therefore each waiting for one specific thing:
12305
+ // nextFocusout() for that blur to be dispatched at all, and `switching` for
12306
+ // the commit + render it started to finish. Only then is there a live block
12307
+ // to aim at.
12308
+ async function applyLinkToggle() {
12309
+ const root = selToolbarEditEl;
12310
+ const sel = typeof window.getSelection === 'function' ? window.getSelection() : null;
12311
+ // hasFormattableSelection() is the body's own four guards spelled once (see
12312
+ // its comment). When it is false the body returns before it can reach
12313
+ // window.prompt(), so there is no modal, no blur, and nothing to wait for.
12314
+ // Waiting anyway would stall this call for the whole timeout and then move
12315
+ // the caret to the end of a block the user never edited.
12316
+ //
12317
+ // ⚠ Final wave, I3. `willPrompt` is a NECESSARY condition for a modal, not
12318
+ // a sufficient one, and the sentence that used to stand at the restore
12319
+ // said otherwise. Those four guards do match the body's four, but the body
12320
+ // has a FIFTH pre-prompt exit they know nothing about: the
12321
+ // `overlapping.length > 0` branch, where a selection that only PARTIALLY
12322
+ // overlaps an existing link unwraps it and returns without ever calling
12323
+ // window.prompt(). MEASURED on ddac6ce — selecting `lpha lin` across
12324
+ // `Alpha <a>linked</a> …` and clicking 🔗 gave `dialogs: 0` while
12325
+ // `willPrompt` was true. Behaviour was still correct there, but only
12326
+ // because the `stillHeld` gate below happened to catch it; the guarantee
12327
+ // the comment claimed did not exist. So the body now REPORTS whether it
12328
+ // prompted and the wait is gated on that report rather than on the
12329
+ // prediction. `blurred` still has to be ARMED before the body runs (the
12330
+ // blur it waits for is caused from inside it) — an armed-but-unused
12331
+ // promise settles on its own 400ms timeout, which removes its own listener
12332
+ // and resolves nobody.
12333
+ const willPrompt = hasFormattableSelection(sel);
12334
+ // Captured BEFORE the body: `selToolbarEditEl` is one of the things the
12335
+ // render nulls. The LINE survives that render — resolveBurst() commits
12336
+ // through commitEdit(state, burst.blockId, newText), an in-place replace of
12337
+ // this block's own line range, and ops.shiftBlocks() only shifts blocks
12338
+ // whose id is GREATER than the edited one, so nothing can move this block's
12339
+ // own start line.
12340
+ const rec = willPrompt && root && root.closest
12341
+ ? blockRecOf(root.closest('.ed-block')) : null;
12342
+ const line = rec ? rec.startLine : null;
12343
+ // Armed before the body runs, because the blur it is waiting for is caused
12344
+ // by a call inside the body. The timeout is the fallback for a browser that
12345
+ // does not blur on a modal at all; its cost when it fires is one caret
12346
+ // moved to the end of the block the link was just written into.
12347
+ const blurred = willPrompt && root ? nextFocusout(root, 400) : null;
12348
+ let prompted = false;
12349
+ try {
12350
+ prompted = applyLinkToggleBody() === true;
12351
+ } finally {
12352
+ if (blurred && prompted) {
12353
+ await blurred;
12354
+ if (switching) await switching;
12355
+ // Fix round 1, item 1: reaching here means the modal DID open (the
12356
+ // body said so), not that the user confirmed it. On Esc / cancel the body does no DOM
12357
+ // surgery, the burst goes out through endBurstWithoutResolve(), there
12358
+ // is no commit and no render — and the focus the modal took comes back
12359
+ // by itself (the t=31ms focusin in the trace above). Restoring on top
12360
+ // of that collapses the user's selection to a caret at the block's end.
12361
+ // Driven, 🔗 then Esc: with this gate the selection is still 'Alpha'
12362
+ // (non-collapsed, 20 buttons); without it, '' and collapsed.
12363
+ //
12364
+ // So the restore only fires when the foothold is actually GONE. The
12365
+ // accept path is unaffected — the render has just run and
12366
+ // activeElement is BODY there — and a 400ms timeout misfire becomes
12367
+ // harmless for the same reason.
12368
+ const held = document.activeElement;
12369
+ const stillHeld = held && held.closest &&
12370
+ held.closest('.ed-wys-armed, .ed-wys-cell, .ed-raw');
12371
+ // 有 blockSelection 站著時什麼都不做 —— 與 convertBlockViaMenu() 同一個
12372
+ // 判準、同一個理由:強行還原 caret 會做出「底色仍說已選取、Delete 卻是
12373
+ // 死鍵」的混種狀態。
12374
+ if (!stillHeld && !blockSelection && line != null) {
12375
+ focusBlockAtLine(line, true); // caretToEnd
12376
+ }
12377
+ // ⚠ C1, second occurrence of the same root cause. The re-aim used to
12378
+ // sit INSIDE the `!stillHeld` branch, so a call that kept its foothold
12379
+ // restored nothing at all — and on the PATCH render path (every commit
12380
+ // after a page load's first) applyPatch() hands focus back to the new
12381
+ // surface and only THEN runs resetToolbarBlock(), so "foothold held"
12382
+ // and "bar collapsed to 4" are the ordinary combination, not a
12383
+ // contradiction. Measured, 🔗 accept: unprimed P.ed-wys-armed/15,
12384
+ // primed P.ed-wys-armed/4. The caret restore stays gated (fix round
12385
+ // 1's Esc/cancel finding); only the bar comes out.
12386
+ if (!blockSelection && line != null) {
12387
+ // 就【這個還原目前所攜帶的座標】(一個 block 的起始行)而言,TABLE
12388
+ // 儲存格是還原不回 caret 的形狀:`root` 是 CELL,但 `line` 只能指到
12389
+ // 整張表這個 block,而 blockContentEl() 對 table 回傳
12390
+ // firstElementChild = <table> 本身,它沒有 tabindex、focus() 是
12391
+ // no-op。實測結果因此是 journey 的 bar-only 形狀(V2d 釘住):連結
12392
+ // 正確寫進磁碟、工具列 15 顆仍瞄著那張表,但 activeElement 是 BODY。
12393
+ // 這不是死路 —— 儲存格的 (row, col) 在 re-render 之間是穩定的,帶著
12394
+ // 它就能還原;只是本版沒有帶。reaim 是目前這個形狀下唯一還拿得回來
12395
+ // 的東西 —— 沒有它工具列會塌成 4 顆。
12396
+ reaimToolbarBlockAtLine(line);
12397
+ }
12398
+ }
12399
+ }
12400
+ }
12401
+
10032
12402
  // Link is its own entry point (not applyMarkToggle) because "unwrap" here
10033
12403
  // means "prompt to edit or clear the URL", and "wrap" means "prompt for a
10034
12404
  // URL first" — both need window.prompt() before any DOM surgery happens.
10035
- function applyLinkToggle() {
12405
+ //
12406
+ // Final wave, I3: returns TRUE iff this call actually reached a
12407
+ // window.prompt(), and false on every exit that did not — the four guards at
12408
+ // the top AND the partial-overlap unwrap in the middle, which is the one the
12409
+ // wrapper's `willPrompt` prediction cannot see. The wrapper waits for the
12410
+ // modal's blur only on a true.
12411
+ function applyLinkToggleBody() {
10036
12412
  const root = selToolbarEditEl;
10037
- if (!root) return;
12413
+ if (!root) return false;
10038
12414
  const sel = window.getSelection();
10039
- if (!sel.rangeCount) return;
12415
+ if (!sel.rangeCount) return false;
10040
12416
  const range = sel.getRangeAt(0);
10041
- if (range.collapsed) return;
10042
- if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) return;
12417
+ if (range.collapsed) return false;
12418
+ if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) return false;
10043
12419
 
10044
12420
  const whole = wholeSelectionMark(range, 'A', root);
10045
12421
  if (whole) {
10046
12422
  const url = window.prompt('連結網址(留空以移除連結):', whole.getAttribute('href') || '');
10047
- if (url === null) return; // cancelled — leave the link untouched
12423
+ if (url === null) return true; // cancelled — leave the link untouched
12424
+ snapBurstIfActive(root, 'mark-pre');
10048
12425
  if (url.trim() === '') {
10049
12426
  reselectAndReposition(unwrapElement(whole));
10050
12427
  } else {
@@ -10052,37 +12429,45 @@
10052
12429
  reselectAndReposition(range);
10053
12430
  }
10054
12431
  snapBurstIfActive(root, 'mark');
10055
- return;
12432
+ return true;
10056
12433
  }
10057
12434
  const overlapping = overlappingMarks(range, 'A', root);
10058
12435
  if (overlapping.length > 0) {
12436
+ snapBurstIfActive(root, 'mark-pre');
10059
12437
  const extended = extendRangeOverMarks(range, overlapping);
10060
12438
  overlappingMarks(extended, 'A', root).forEach((m) => unwrapElement(m));
10061
12439
  reselectAndReposition(extended);
10062
12440
  snapBurstIfActive(root, 'mark');
10063
- return;
12441
+ return false; // unwrapped in place — no modal ever opened
10064
12442
  }
10065
12443
  const url = window.prompt('連結網址:', 'https://');
10066
- if (url === null || url.trim() === '') return; // cancelled or empty — no-op
12444
+ if (url === null || url.trim() === '') return true; // cancelled or empty — no-op
12445
+ snapBurstIfActive(root, 'mark-pre');
10067
12446
  const el = document.createElement('a');
10068
12447
  el.setAttribute('href', url.trim());
10069
- el.appendChild(range.extractContents());
10070
- range.insertNode(el);
12448
+ extractRangeInto(range, el, root);
10071
12449
  const r = document.createRange();
10072
12450
  r.selectNodeContents(el);
10073
12451
  reselectAndReposition(r);
10074
12452
  snapBurstIfActive(root, 'mark');
12453
+ return true;
10075
12454
  }
10076
12455
 
12456
+ // The .ed-seltb buttons, paired with the mark each one toggles, for
12457
+ // paintSelToolbarMarks() below. Filled by buildSelToolbar(), which runs once.
12458
+ const selToolbarMarkBtns = [];
12459
+
10077
12460
  function buildSelToolbar() {
10078
12461
  const el = document.createElement('div');
10079
12462
  el.className = 'ed-seltb';
10080
- function addBtn(cls, label, ariaLabel, onClick) {
12463
+ function addBtn(cls, label, ariaLabel, tag, onClick) {
10081
12464
  const b = document.createElement('button');
10082
12465
  b.type = 'button';
10083
12466
  b.className = 'ed-seltb-btn ' + cls;
10084
12467
  b.textContent = label;
10085
12468
  b.setAttribute('aria-label', ariaLabel);
12469
+ b.setAttribute('aria-pressed', 'false');
12470
+ selToolbarMarkBtns.push({ btn: b, tag: tag });
10086
12471
  // Keep the DOM selection intact across the click: without this, the
10087
12472
  // button (outside the contenteditable root) stealing focus on
10088
12473
  // mousedown would collapse the selection before the click handler
@@ -10092,15 +12477,53 @@
10092
12477
  el.appendChild(b);
10093
12478
  return b;
10094
12479
  }
10095
- addBtn('ed-seltb-b', 'B', 'Bold', () => applyMarkToggle('STRONG'));
10096
- addBtn('ed-seltb-i', 'I', 'Italic', () => applyMarkToggle('EM'));
10097
- addBtn('ed-seltb-s', 'S', '刪除線', () => applyMarkToggle('DEL'));
10098
- addBtn('ed-seltb-u', 'U', '底線', () => applyMarkToggle('U'));
10099
- addBtn('ed-seltb-code', '<>', 'Code', () => applyMarkToggle('CODE'));
10100
- addBtn('ed-seltb-link', '\u{1F517}', 'Link', () => applyLinkToggle());
12480
+ addBtn('ed-seltb-b', 'B', 'Bold', 'STRONG', () => applyMarkToggle('STRONG'));
12481
+ addBtn('ed-seltb-i', 'I', 'Italic', 'EM', () => applyMarkToggle('EM'));
12482
+ addBtn('ed-seltb-s', 'S', '刪除線', 'DEL', () => applyMarkToggle('DEL'));
12483
+ addBtn('ed-seltb-u', 'U', '底線', 'U', () => applyMarkToggle('U'));
12484
+ addBtn('ed-seltb-code', '<>', 'Code', 'CODE', () => applyMarkToggle('CODE'));
12485
+ addBtn('ed-seltb-link', '\u{1F517}', 'Link', 'A', () => applyLinkToggle());
10101
12486
  return el;
10102
12487
  }
10103
12488
 
12489
+ // v3.3.0 (F2): the floating bar's own four-state painting, from the same
12490
+ // `ctx.marks` the fixed toolbar's five are derived from.
12491
+ //
12492
+ // ⚠ `aria-disabled`, never the `disabled` property. Each of these buttons
12493
+ // keeps the selection alive across its own click through the `mousedown`
12494
+ // preventDefault() addBtn() installs above, and a disabled <button>
12495
+ // dispatches no mousedown at all, so that preventDefault would never run.
12496
+ //
12497
+ // MEASURED, real mouse press (move / down / 80ms / up) on the B button over
12498
+ // an inert selection, with a capture-phase counter on `document` and a click
12499
+ // counter on the button itself. As it ships:
12500
+ // {"active":"P.ed-wys-armed","sel":" ","seltb":true,"downs":1,"clicks":1}
12501
+ // and the block's innerHTML byte-identical before and after. The same press
12502
+ // with `disabled` set on that button instead:
12503
+ // {"active":"BODY.","sel":" ","seltb":false,"downs":0,"clicks":0}
12504
+ // — no mousedown reaches the document at all, the focus lands on BODY and
12505
+ // the bar is gone by the time the press ends. buildToolbar() can afford
12506
+ // `disabled` on the fixed bar because it installs a capture-phase
12507
+ // `pointerdown` guard on that bar's container; there is no such
12508
+ // container-level interception here.
12509
+ //
12510
+ // So the press on an inert button still runs applyMarkToggle(), which is
12511
+ // fine: the same measurement shows the block unchanged. Nothing here needs
12512
+ // to intercept it.
12513
+ let selToolbarMarkSig = null;
12514
+ function paintSelToolbarMarks(marks) {
12515
+ const sig = JSON.stringify(marks);
12516
+ if (sig === selToolbarMarkSig) return;
12517
+ selToolbarMarkSig = sig;
12518
+ selToolbarMarkBtns.forEach((entry) => {
12519
+ const m = marks ? marks[entry.tag] : null;
12520
+ entry.btn.setAttribute('aria-pressed',
12521
+ m === 'whole' ? 'true' : (m === 'partial' ? 'mixed' : 'false'));
12522
+ if (m === 'inert') entry.btn.setAttribute('aria-disabled', 'true');
12523
+ else entry.btn.removeAttribute('aria-disabled');
12524
+ });
12525
+ }
12526
+
10104
12527
  const selToolbar = buildSelToolbar();
10105
12528
 
10106
12529
  function hideSelToolbar() {
@@ -10114,8 +12537,23 @@
10114
12537
  // `position: fixed`.
10115
12538
  function positionSelToolbar(range) {
10116
12539
  const rect = range.getBoundingClientRect();
10117
- if (rect.width === 0 && rect.height === 0) { hideSelToolbar(); return; }
12540
+ // v3.2.1: 原本的零矩形檢查是死碼 —— 實測捲出視野的 Range 仍保有
12541
+ // 151x17 的矩形,永遠到不了這一支。改成真正的視窗外檢查。
12542
+ const topInset = parseFloat(getComputedStyle(document.documentElement)
12543
+ .getPropertyValue('--ed-toolbar-h')) || 0;
12544
+ if (rect.bottom <= topInset || rect.top >= window.innerHeight ||
12545
+ rect.right <= 0 || rect.left >= window.innerWidth) {
12546
+ // Task 16: of the exits that take this bar down, this is the one that
12547
+ // leaves a live selection behind, so it is the one that arms the
12548
+ // re-ask on scroll. Walked by grepping hideSelToolbar(), its other
12549
+ // callers are reached by a session ending, by the selection collapsing
12550
+ // or leaving the edit root, by Task 15's Tab suppression, or by a mark
12551
+ // toggle with no range to reselect.
12552
+ selToolbarOffViewport = true;
12553
+ hideSelToolbar(); return;
12554
+ }
10118
12555
  if (!selToolbar.parentNode) document.body.appendChild(selToolbar);
12556
+ selToolbarOffViewport = false;
10119
12557
  const gap = 8, margin = 4;
10120
12558
  const tbRect = selToolbar.getBoundingClientRect();
10121
12559
  let top = rect.top - tbRect.height - gap;
@@ -10157,9 +12595,13 @@
10157
12595
  // handler's async switchAwayFrom()->resolveBurst()->rerenderAll()
10158
12596
  // commit chain BEFORE the click event that opens the menu ever fires.
10159
12597
  // At human click speed (a real, non-zero gap between mousedown and
10160
- // mouseup) that commit's /api/render round trip can finish and swap
10161
- // `.content` — detaching THIS very button — before mouseup, and a
10162
- // click event never fires at all for a target removed from the
12598
+ // mouseup) that commit's /api/render round trip can finish and REPLACE
12599
+ // THE BLOCK ELEMENT inside `.content` — detaching THIS very button,
12600
+ // which lives in that block — before mouseup. (`.content` itself is
12601
+ // NOT swapped: applyFullRender() assigns `contentEl.innerHTML`, which
12602
+ // rebuilds its children but keeps the element, and the patch route
12603
+ // removes just the one block; both were traced, see the v3.3.0 note
12604
+ // below.) A click event never fires at all for a target removed from the
10163
12605
  // document between mousedown and mouseup: the first click is silently
10164
12606
  // eaten (no menu opens), and only a second click on the fresh,
10165
12607
  // re-armed handle actually works. preventDefault() here stops the
@@ -10168,9 +12610,53 @@
10168
12610
  // §10-gap fix: same reasoning as the ⠿ handle above, for the + insert
10169
12611
  // button — a mousedown-triggered blur on a dirty burst elsewhere would
10170
12612
  // otherwise race this button's own click the exact same way.
12613
+ // v3.3.0 階段 0: the two MENU-ITEM classes join the list. `.ed-handle` /
12614
+ // `.ed-insert` are the buttons that OPEN the menus;
12615
+ // `.ed-handle-menu-btn` / `.ed-insert-menu-btn` are the items INSIDE them
12616
+ // (buildGutterMenu()/buildInsertMenu() give every item one of those two
12617
+ // classes), and neither menu's items carry a mousedown preventDefault()
12618
+ // of their own. The ⠿/+ gutter builds exactly four kinds of pressable
12619
+ // BUTTON — the handle, the + button, a gutter-menu item (the 轉換成
12620
+ // submenu's buttons share that class) and an insert-menu item — and
12621
+ // before this line only the first two were covered. The menu PANEL
12622
+ // itself (`div.ed-handle-menu`, which has padding) is deliberately not
12623
+ // in that list: a press landing in the gap between two items still
12624
+ // steals focus, and is out of scope here.
12625
+ //
12626
+ // Trace taken with ONLY `.ed-handle`/`.ed-insert` in this list, dirty
12627
+ // burst on the block, an 80 ms press on 建立副本, listeners on mousedown /
12628
+ // focusout / fetch / mouseup / click plus a MutationObserver naming the
12629
+ // removed node (unprimed fallback route | primed patch route):
12630
+ //
12631
+ // +7ms | +5ms mousedown on .ed-handle-menu-btn, defaultPrevented=false
12632
+ // +7ms | +5ms focusout on the burst's .ed-wys-armed, activeElement→BODY
12633
+ // +8ms | +6ms POST /api/render (the focusout handler's commit)
12634
+ // +28ms | +12ms response applied — the removed node is the BLOCK ELEMENT,
12635
+ // removed from `.content` (which keeps its own identity)
12636
+ // and replaced by a different node with the same
12637
+ // data-block-id; the fallback rebuilds all of `.content`'s
12638
+ // children, the patch route removes just this one. The menu
12639
+ // — parked by toggleGutterMenu()/toggleInsertMenu() with
12640
+ // blockEl.appendChild() — is removed WITH the block.
12641
+ // +93ms | +91ms mouseup, target now the replacement block's surface, and
12642
+ // NO click event at all: the pressed node left the document
12643
+ // between mousedown and mouseup.
12644
+ //
12645
+ // Same trace with these two selectors added: no focusout and no
12646
+ // /api/render before mouseup — the item keeps its click, and the commit
12647
+ // runs afterwards from inside the item's own handler.
10171
12648
  document.addEventListener('mousedown', (e) => {
12649
+ // T21 item 2: pointing at something is the other way (besides pressing a
12650
+ // key that is not Tab — see handleBurstKeydown()) of saying the walk is
12651
+ // over. A click that lands on the SAME surface the walk arrived at does
12652
+ // not open a new burst, so without this the mark would survive a
12653
+ // deliberate caret placement and the next Tab would leave instead of
12654
+ // indenting the item the user had just aimed at.
12655
+ if (currentBurst) currentBurst.arrivedByTab = false;
10172
12656
  if (e.target && e.target.closest &&
10173
- (e.target.closest('.ed-handle') || e.target.closest('.ed-insert'))) e.preventDefault();
12657
+ (e.target.closest('.ed-handle') || e.target.closest('.ed-insert') ||
12658
+ e.target.closest('.ed-handle-menu-btn') ||
12659
+ e.target.closest('.ed-insert-menu-btn'))) e.preventDefault();
10174
12660
  });
10175
12661
  document.addEventListener('click', async (e) => {
10176
12662
  // S3 Task 4: the trailing click of a gesture that already answered this
@@ -10379,6 +12865,12 @@
10379
12865
 
10380
12866
  // ── undo / redo ───────────────────────────────────────────────────────
10381
12867
  async function undo() {
12868
+ // T21 item 1: an Escape that discarded a burst leaves exactly one thing
12869
+ // for the next Ctrl+Z to undo, and it is not on this stack — the burst
12870
+ // never committed, so `lines` never saw it. restoreDiscardedBurst()
12871
+ // answers false whenever there is no such thing (which is almost always),
12872
+ // and the ordinary document undo below runs untouched.
12873
+ if (restoreDiscardedBurst()) return;
10382
12874
  // Resolve any open editor BEFORE the .content-replacing swap below —
10383
12875
  // see the `activeEditor` / switchAwayFrom() comments near its
10384
12876
  // declaration for why this is required (undo/redo silently detaching an
@@ -10471,7 +12963,8 @@
10471
12963
  let toolbarMenuEl = null; // the H▾ dropdown panel, or null when closed
10472
12964
  let toolbarBlockEl = null;
10473
12965
  let toolbarStateSig = '';
10474
- // 追加 4's three-state document mode: 'edit' | 'source' | 'preview'.
12966
+ // 追加 4's document mode: 'edit' | 'source'. v3.2.1 removed the third
12967
+ // state ('preview'); docsource.js's MODES comment records why.
10475
12968
  // docsource.js owns the cycle order and the lines[]<->string conversion.
10476
12969
  let docMode = 'edit';
10477
12970
  let sourceTextarea = null;
@@ -10494,8 +12987,19 @@
10494
12987
  // The buttons whose model state can ever report `active: true` — i.e. the
10495
12988
  // real toggles, and the only ones that may carry aria-pressed. Every other
10496
12989
  // 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.
12990
+ // v3.2.0: derived, not mirrored. Adding a toggle to toolbar-model.js used to
12991
+ // need a matching edit here, and forgetting it made aria-pressed lie.
12992
+ //
12993
+ // v3.3.0 (F2), recorded rather than changed: `check` is outside this set on
12994
+ // purpose. It is `toggle: false` while its conversion-group siblings quote /
12995
+ // code / list / ordered-list are `toggle: true`, which is honest only for as
12996
+ // long as deriveState() never sets `check.active` — and it does not: that
12997
+ // function assigns nothing to `state.check` beyond the disabled flags every
12998
+ // button gets. What would change that is a rule lighting ☑ when the current
12999
+ // block is already a task item. The flag would have to flip in the same
13000
+ // edit, because this set is what decides whether aria-pressed is written at
13001
+ // all, and a button that can report `active` while sitting outside it says
13002
+ // nothing where it should say `true`.
10499
13003
  const TOOLBAR_TOGGLE_IDS = new Set(
10500
13004
  (toolbarModel ? toolbarModel.BUTTONS : []).filter((b) => b.toggle).map((b) => b.id));
10501
13005
 
@@ -10515,10 +13019,29 @@
10515
13019
  function resetToolbarBlock() {
10516
13020
  toolbarBlockEl = null;
10517
13021
  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);
13022
+ updateToolbar();
13023
+ }
13024
+
13025
+ // v3.2.1: point the bar back at the block a structural op just produced.
13026
+ //
13027
+ // rerenderAll()'s teardown calls resetToolbarBlock() (toolbarBlockEl = null,
13028
+ // bar repainted in its no-block state: 4 enabled buttons), and nothing
13029
+ // re-points it unless a focusin/click lands on a block. That is fine when
13030
+ // the op ends with focusBlockAtLine() — the surface's focus event runs
13031
+ // wireToolbarTracking()'s focusin handler, which does exactly this. It is
13032
+ // NOT fine for a target with no focusable surface (quote / code / hr), where
13033
+ // focusBlockAtLine() silently no-ops and the bar would stay collapsed on a
13034
+ // block that is right there on screen. updateToolbar() is what repaints;
13035
+ // resetToolbarBlock() is idempotent, so re-pointing after it is safe.
13036
+ // Resolves nothing and touches nothing when `line` is null or the block is
13037
+ // gone (a refusal, a rolled-back render).
13038
+ function reaimToolbarBlockAtLine(line) {
13039
+ if (line == null) return;
13040
+ const target = blocks.find((b) => b.startLine === line);
13041
+ if (!target) return;
13042
+ const blockEl = document.querySelector('.ed-block[data-block-id="' + target.id + '"]');
13043
+ if (!blockEl) return;
13044
+ toolbarBlockEl = blockEl;
10522
13045
  updateToolbar();
10523
13046
  }
10524
13047
 
@@ -10544,6 +13067,67 @@
10544
13067
  selToolbarEditEl.contains(range.endContainer);
10545
13068
  }
10546
13069
 
13070
+ // The mark elements the toolbars toggle. 'U' is here because .ed-seltb has a
13071
+ // U button (buildSelToolbar(), earlier in this file); toolbar-model.js's
13072
+ // MARK_TAG_BY_ID covers the fixed toolbar's inline group and does not
13073
+ // mention it.
13074
+ const MARK_TAGS = ['STRONG', 'EM', 'DEL', 'CODE', 'A', 'U'];
13075
+
13076
+ // v3.3.0 (F2): which of those marks the current selection is in, as the
13077
+ // four-state vocabulary both toolbars paint from — or null when there is no
13078
+ // formattable selection at all. The three non-null states below name the
13079
+ // branch applyMarkToggle() would take on that tag, in its own order:
13080
+ //
13081
+ // 'whole' `wholeSelectionMark()` hit — the unwrap branch.
13082
+ // 'partial' `overlappingMarks()` non-empty — the extend-and-unwrap branch.
13083
+ // 'none' neither — the wrap branch, which would wrap something.
13084
+ // 'inert' neither, and the wrap branch would refuse.
13085
+ //
13086
+ // Two things about 'inert' are load-bearing and neither is guessable from
13087
+ // the button:
13088
+ //
13089
+ // * It is asked per tag, not once. Only applyMarkToggle()'s THIRD branch is
13090
+ // guarded by trimRangeToText(); the first two run to completion on an
13091
+ // all-whitespace selection. MEASURED: with the single space inside
13092
+ // `**bold text**` selected, pressing B took the unwrap branch — the
13093
+ // <strong> was gone, the paragraph's text was unchanged, and the file
13094
+ // saved back with no `**` in it. So drawing every button inert there
13095
+ // would grey out a button that acts.
13096
+ // * 'A' never reaches it. The link button does not go through
13097
+ // applyMarkToggle() at all — applyLinkToggleBody() above has the same
13098
+ // first two branches but its third calls window.prompt() and
13099
+ // extractRangeInto() with no trim guard anywhere, so an all-whitespace
13100
+ // selection prompts and wraps. MEASURED: selecting the single space in
13101
+ // `Alpha bold text …` and pressing 🔗 raised the dialog 「連結網址:」 and
13102
+ // left `Alpha<a href="https://probe/"> </a>bold text …` in the block. So
13103
+ // drawing 🔗 inert there would grey out a button that acts.
13104
+ //
13105
+ // trimRangeToText() is tag-independent, so it is asked once. It is asked
13106
+ // with a CLONE: it calls setStart/setEnd on the range it is handed whenever
13107
+ // it returns true, and this function runs on every selectionchange, so the
13108
+ // live range would have the user's own selection edges pulled in off every
13109
+ // caret move that the open burst's selectionchange listener hears. `wholeSelectionMark()` and `overlappingMarks()` take the live
13110
+ // range. test/editor-journey.test.js drives a keyboard selection across a
13111
+ // marked run and asserts, at each step, that the range's boundary points are
13112
+ // byte-identical either side of the selectionchange dispatch and that the
13113
+ // string it covers has grown by one character. Ablated to check the suite can
13114
+ // see it: putting a setStart() into wholeSelectionMark(), and again into
13115
+ // overlappingMarks(), each turned the suite red.
13116
+ function selectionMarkStates(sel) {
13117
+ if (!hasFormattableSelection(sel)) return null;
13118
+ const root = selToolbarEditEl;
13119
+ const range = sel.getRangeAt(0);
13120
+ const wrappable = trimRangeToText(range.cloneRange());
13121
+ const out = {};
13122
+ for (const tag of MARK_TAGS) {
13123
+ if (wholeSelectionMark(range, tag, root)) out[tag] = 'whole';
13124
+ else if (overlappingMarks(range, tag, root).length > 0) out[tag] = 'partial';
13125
+ else if (wrappable || tag === 'A') out[tag] = 'none';
13126
+ else out[tag] = 'inert';
13127
+ }
13128
+ return out;
13129
+ }
13130
+
10547
13131
  // ctx for toolbarModel.deriveState(). Every field is read off the DOM /
10548
13132
  // `lines` at call time; nothing is cached, so a stale value cannot outlive
10549
13133
  // a render.
@@ -10566,13 +13150,25 @@
10566
13150
  // hasFormattableSelection() (just above) is their refusal test spelled
10567
13151
  // once, rather than a looser "is anything selected anywhere on the page".
10568
13152
  hasSelection: hasFormattableSelection(sel),
13153
+ // The identity behind that boolean: selectionMarkStates() (just above)
13154
+ // measures it here because toolbar-model.js may not touch the DOM, and
13155
+ // .ed-seltb is painted from the same measurement — see updateToolbar().
13156
+ marks: selectionMarkStates(sel),
10569
13157
  mode: docMode,
10570
13158
  };
10571
13159
  }
10572
13160
 
10573
13161
  function updateToolbar() {
10574
13162
  if (!toolbarEl || !toolbarBtns || !toolbarModel) return;
10575
- const state = toolbarModel.deriveState(toolbarContext());
13163
+ const ctx = toolbarContext();
13164
+ // Both bars, one measurement. paintSelToolbarMarks() runs BEFORE the `sig`
13165
+ // early-return below, because that signature is built from the toolbar's
13166
+ // own buttons and .ed-seltb has one — U — that none of them speaks for:
13167
+ // moving the selection between plain text and a <u> run changes nothing in
13168
+ // `state`, so the early return would fire and U would keep whatever it last
13169
+ // said. Pinned by a journey row that drives exactly that move.
13170
+ paintSelToolbarMarks(ctx.marks);
13171
+ const state = toolbarModel.deriveState(ctx);
10576
13172
  // selectionchange fires on every caret move, so the state is diffed
10577
13173
  // before any DOM is written — 22 attribute writes per keystroke is a
10578
13174
  // cost with no matching benefit.
@@ -10589,12 +13185,206 @@
10589
13185
  // and `table` as toggle buttons stuck in the "not pressed" state,
10590
13186
  // which is a lie about what they do.
10591
13187
  if (TOOLBAR_TOGGLE_IDS.has(id)) {
10592
- btn.setAttribute('aria-pressed', st.active ? 'true' : 'false');
13188
+ // v3.3.0 (F2): three values, not two. `mixed` is what the five inline
13189
+ // buttons report over a selection that overlaps marks of their tag
13190
+ // without lying inside one — pressing them there removes the marks
13191
+ // rather than adding any, and 'true'/'false' can say neither.
13192
+ btn.setAttribute('aria-pressed',
13193
+ st.mixed ? 'mixed' : (st.active ? 'true' : 'false'));
10593
13194
  }
10594
- // Only `headings` carries a label (H / H1..H6); every other button
10595
- // keeps the glyph it was built with.
13195
+ // `headings` (H / H1..H6) and, since v3.2.1, the mode button carry a
13196
+ // derived label; every other button keeps the glyph it was built with.
10596
13197
  if (typeof st.label === 'string') btn.textContent = st.label;
13198
+ // v3.2.1: a per-mode title CANNOT be baked in buildToolbar() — the mode
13199
+ // button's title names the NEXT state, so it has to be rewritten every
13200
+ // time the mode changes. It rides the same diffed loop as everything
13201
+ // else: `title` is part of the state object, therefore part of `sig`.
13202
+ if (typeof st.title === 'string') {
13203
+ btn.title = st.title;
13204
+ btn.setAttribute('aria-label', st.title);
13205
+ }
13206
+ });
13207
+ // Label edits above change the row's width (H becomes H1, 編輯 becomes
13208
+ // 原始碼), which moves the scroll maximum, so repaint the edge hint here
13209
+ // too. Placed inside the diffed branch on purpose: an unchanged sig means
13210
+ // no attribute was written, therefore no width moved.
13211
+ paintToolbarOverflow();
13212
+ // The write above can disable the button the keyboard cursor is sitting
13213
+ // on. A real focus would have been thrown back to BODY by the browser at
13214
+ // this point, and Tab from BODY goes nowhere — driven on this build, a
13215
+ // dozen consecutive Tabs starting from BODY left activeElement on BODY
13216
+ // every time — so the keyboard would be stuck there. A virtual cursor has
13217
+ // nothing to lose: it steps to the next enabled button instead.
13218
+ const cursorBtn = toolbarRovingBtns()[toolbarRovingIndex];
13219
+ if (cursorBtn && cursorBtn.disabled) moveToolbarCursor(1);
13220
+ }
13221
+
13222
+ // F12: which end of the bar still has buttons behind it. The stylesheet
13223
+ // turns each token into an edge gradient; nothing here decides how it looks.
13224
+ //
13225
+ // The one-pixel margin is a deliberate cushion and NOT a measured
13226
+ // requirement — say so rather than inventing a reason for it. Driven at 900
13227
+ // and at 420 px wide, the largest scrollLeft the bar accepts came back equal
13228
+ // to scrollWidth minus clientWidth to the pixel (75 and 555), so a strict
13229
+ // comparison would have been correct at both. The cushion is there because
13230
+ // scrollWidth is reported as a whole number over a row whose own width is
13231
+ // not, and what it costs when the two agree is that the gradient goes out a
13232
+ // pixel before the travel ends.
13233
+ function paintToolbarOverflow() {
13234
+ if (!toolbarEl) return;
13235
+ const max = toolbarEl.scrollWidth - toolbarEl.clientWidth;
13236
+ const sl = toolbarEl.scrollLeft;
13237
+ const tokens = [];
13238
+ if (sl > 1) tokens.push('left');
13239
+ if (sl < max - 1) tokens.push('right');
13240
+ const v = tokens.join(' ');
13241
+ if (toolbarEl.getAttribute('data-ed-tb-overflow') !== v) {
13242
+ toolbarEl.setAttribute('data-ed-tb-overflow', v);
13243
+ }
13244
+ }
13245
+
13246
+ // ── F12, keyboard entry to the toolbar ────────────────────────────────
13247
+ //
13248
+ // The cursor here is VIRTUAL: no toolbar button ever takes DOM focus, and
13249
+ // the caret stays exactly where the user left it. That is not a stylistic
13250
+ // choice, it is what makes the rest of this safe. buildToolbar()'s own
13251
+ // ⚠ LOAD-BEARING note records why: a toolbar <button> taking focus fires
13252
+ // the armed surface's focusout, which commits and re-renders, and the
13253
+ // selection the pressed button was about to act on is gone before its click
13254
+ // handler runs. The mousedown preventDefault() there stops that happening
13255
+ // by mouse; keeping DOM focus still means it cannot happen by keyboard
13256
+ // either, and no focusout exemption has to be invented for the way back
13257
+ // out.
13258
+ //
13259
+ // toolbarRovingIndex is an index into the bar's buttons in DOM order, or
13260
+ // -1 while the mode is off.
13261
+ let toolbarRovingIndex = -1;
13262
+
13263
+ function toolbarRovingBtns() {
13264
+ return toolbarEl ? Array.prototype.slice.call(
13265
+ toolbarEl.querySelectorAll('.ed-toolbar-btn')) : [];
13266
+ }
13267
+
13268
+ function paintToolbarRoving() {
13269
+ const btns = toolbarRovingBtns();
13270
+ btns.forEach((b, i) => {
13271
+ if (i === toolbarRovingIndex) b.setAttribute('data-ed-tb-cursor', '');
13272
+ else b.removeAttribute('data-ed-tb-cursor');
10597
13273
  });
13274
+ if (!toolbarEl) return;
13275
+ if (toolbarRovingIndex < 0 || !btns[toolbarRovingIndex]) {
13276
+ toolbarEl.removeAttribute('data-ed-tb-keynav');
13277
+ } else {
13278
+ toolbarEl.setAttribute('data-ed-tb-keynav',
13279
+ btns[toolbarRovingIndex].getAttribute('data-ed-tb'));
13280
+ }
13281
+ }
13282
+
13283
+ // Scroll the cursor into the strip the buttons rest in, which is narrower
13284
+ // than the bar: the mode slot occupies the right-hand end of it. The right
13285
+ // boundary is read off the slot's own rect rather than repeating the
13286
+ // padding-right the stylesheet reserves, so the two cannot drift apart.
13287
+ // The arithmetic is spelled out rather than delegated to scrollIntoView(),
13288
+ // whose contract is written against the whole chain of scrollable ancestors
13289
+ // while the only box that should move here is this one.
13290
+ function revealToolbarCursor(btn) {
13291
+ if (!toolbarEl) return;
13292
+ const bar = toolbarEl.getBoundingClientRect();
13293
+ const slotEl = toolbarEl.querySelector('.ed-toolbar-status');
13294
+ const right = slotEl
13295
+ ? Math.min(bar.right, slotEl.getBoundingClientRect().left) : bar.right;
13296
+ // Rounded AWAY from the edge being cleared, not by the nearest whole
13297
+ // number. These boxes have fractional widths while scrollLeft is stored
13298
+ // whole, so asking for a flush landing can round the button back under the
13299
+ // edge it was supposed to clear: driven with plain subtraction, walking
13300
+ // leftwards along the row at a 420px viewport parked stops at a left edge
13301
+ // of -0.39, -0.19 and -0.09, and flooring instead put every one of them
13302
+ // back inside. Rounding this way spends at most a whole pixel of extra
13303
+ // travel and cannot undershoot. What it does not buy is the last
13304
+ // fractional pixel at the very end of the travel — the row's own width
13305
+ // ends in .109 and scrollWidth is whole, so the trailing button still
13306
+ // overhangs the slot by that much with the bar scrolled as far as it
13307
+ // goes; that residue is the one the reachability row's own note describes.
13308
+ const r = btn.getBoundingClientRect();
13309
+ if (r.left < bar.left) {
13310
+ toolbarEl.scrollLeft = Math.floor(toolbarEl.scrollLeft - (bar.left - r.left));
13311
+ } else if (r.right > right) {
13312
+ toolbarEl.scrollLeft = Math.ceil(toolbarEl.scrollLeft + (r.right - right));
13313
+ }
13314
+ }
13315
+
13316
+ // Steps over disabled buttons. The walk is bounded by the button count and
13317
+ // wraps, so a bar with nothing enabled would leave the index where it was
13318
+ // rather than spin — see the model's own guarantee that this cannot happen
13319
+ // (deriveState() keeps `preview` out of every disabling branch, including
13320
+ // the source-mode override; T11-2 later exempted `outline` from that
13321
+ // override too, so the enabled set there is `['outline','preview']` — the
13322
+ // guarantee holds more strongly than when this was written, not less).
13323
+ function moveToolbarCursor(delta) {
13324
+ const btns = toolbarRovingBtns();
13325
+ if (!btns.length) return;
13326
+ let i = toolbarRovingIndex;
13327
+ for (let step = 0; step < btns.length; step++) {
13328
+ i = ((i + delta) % btns.length + btns.length) % btns.length;
13329
+ if (!btns[i].disabled) {
13330
+ toolbarRovingIndex = i;
13331
+ paintToolbarRoving();
13332
+ revealToolbarCursor(btns[i]);
13333
+ return;
13334
+ }
13335
+ }
13336
+ }
13337
+
13338
+ // No "already in the mode" guard, because the key branch cannot deliver one:
13339
+ // while the mode is on, the entry chord's own key is not one of the keys the
13340
+ // mode names, so it takes the hand-back path and ends the mode instead. The
13341
+ // gesture is therefore a toggle, and that is pinned rather than inferred.
13342
+ // moveToolbarCursor() is the null guard as well — it starts by asking the
13343
+ // bar for its buttons, and answers an empty list when there is no bar.
13344
+ function enterToolbarKeynav() {
13345
+ toolbarRovingIndex = -1;
13346
+ moveToolbarCursor(1);
13347
+ }
13348
+
13349
+ function exitToolbarKeynav() {
13350
+ if (toolbarRovingIndex < 0) return false;
13351
+ toolbarRovingIndex = -1;
13352
+ paintToolbarRoving();
13353
+ return true;
13354
+ }
13355
+
13356
+ // Activation goes through the same call the click handler makes, so a
13357
+ // keyboard press and a mouse press cannot drift apart.
13358
+ //
13359
+ // WHERE THE CARET ENDS UP AFTERWARDS, driven button by button over the
13360
+ // enabled row in a plain paragraph, pressing Enter on the cursor and then
13361
+ // typing to see whether the characters land anywhere:
13362
+ //
13363
+ // * quote, code and line end on BODY and typing goes nowhere, with a
13364
+ // clean burst and with a dirty one alike.
13365
+ // * undo, redo and image join them ONLY when the surface has an
13366
+ // uncommitted edit — the action resolves that burst first, and the
13367
+ // re-render is what drops the caret. With nothing pending they land back
13368
+ // on the armed paragraph and typing resumes.
13369
+ // * everything else lands on an editable surface and typing resumes.
13370
+ //
13371
+ // A mouse click on those same buttons does exactly the same thing, so this
13372
+ // is not something the keyboard route introduced — but a mouse user climbs
13373
+ // out by clicking into the text and a keyboard user cannot, because Tab from
13374
+ // BODY goes nowhere. Deferred to v3.4.0 rather than fixed here: the fix is
13375
+ // in the activation path, which the mouse shares.
13376
+ //
13377
+ // THE WAY OUT until then, driven end to end from a stranded BODY: the entry
13378
+ // chord still works there (the bar's own state survives, and its row is
13379
+ // fully enabled), so walking to the mode button and pressing Enter puts the
13380
+ // caret in the source textarea, where typing lands. Coming back the same way
13381
+ // returns the document to edit mode but leaves the caret on BODY again — the
13382
+ // hatch is somewhere to keep typing, not a way back to the WYSIWYG caret.
13383
+ function activateToolbarCursor() {
13384
+ const btn = toolbarRovingBtns()[toolbarRovingIndex];
13385
+ if (!btn || btn.disabled) return;
13386
+ Promise.resolve(runToolbarAction(btn.getAttribute('data-ed-tb'), btn))
13387
+ .catch(() => {}).then(updateToolbar);
10598
13388
  }
10599
13389
 
10600
13390
  function buildToolbar() {
@@ -10638,11 +13428,53 @@
10638
13428
  bar.appendChild(btn);
10639
13429
  btns[def.id] = btn;
10640
13430
  });
13431
+ // Fix round 1, item 3: a click on a DISABLED toolbar button used to take
13432
+ // the caret with it — the same gesture and the same symptom this release
13433
+ // exists for. MEASURED, and the two halves matter separately:
13434
+ //
13435
+ // * `mousedown` and `click` are NOT dispatched at all for a disabled
13436
+ // <button> — 0 of each at a capture-phase listener on `document`, for
13437
+ // the whole propagation path, so the per-button mousedown
13438
+ // preventDefault() a few lines above cannot run and neither could one
13439
+ // on this container.
13440
+ // * `pointerdown` IS dispatched (1 at that same document listener) and is
13441
+ // cancellable, and cancelling it suppresses the compatibility mousedown
13442
+ // together with its default action — which is the focus move.
13443
+ //
13444
+ // Driven, caret in a paragraph, clicking the disabled 🅑: without this
13445
+ // handler activeElement was BODY in both the untouched-burst state (bar
13446
+ // still 15) and the typed-in state (bar down to 4, because that focusout
13447
+ // ran a full commit + render); with it, P.ed-wys-armed / 15 in both.
13448
+ //
13449
+ // Scope: capture phase on the bar, and preventDefault() ONLY when the
13450
+ // press resolves to a disabled button. Never stopPropagation() — the
13451
+ // document-level pointerdown delegator (table grips, ⠿ block drag, block
13452
+ // selection) still sees the event, and finds nothing to arm, exactly as
13453
+ // before. An ENABLED button, a separator, the status slot and the bar's
13454
+ // own background are all untouched. The H▾ dropdown is NOT inside this
13455
+ // subtree (toggleToolbarHeadingMenu() appends it to document.body), so it
13456
+ // is out of reach here and keeps its own per-item mousedown handler.
13457
+ bar.addEventListener('pointerdown', (e) => {
13458
+ const pressed = e.target && e.target.closest ? e.target.closest('button') : null;
13459
+ if (pressed && pressed.disabled) e.preventDefault();
13460
+ }, true);
10641
13461
  toolbarBtns = btns;
10642
13462
  // v3.1.0 的 spec 承諾「元素存在但無內容,§3 上線時填入、不需要重排版面」,
10643
- // 但元素從未被建立。這裡把承諾兌現:佔好寬度、無內容。
13463
+ // 但元素從未被建立。這裡把承諾兌現:佔好寬度。內容不再是空的 ——
13464
+ // paintModeStatus() 會在 mountToolbar() 掛上之後、以及每次 setDocMode()
13465
+ // 切換時寫入當前模式名稱。
10644
13466
  const statusEl = document.createElement('span');
10645
13467
  statusEl.className = 'ed-toolbar-status';
13468
+ // Final wave, M7: the only announcement a mode switch ever gets. Every
13469
+ // toolbar button is `tabindex="-1"` (see buildToolbar()'s own note), so a
13470
+ // screen reader is never focused on the mode button when it is pressed and
13471
+ // hears nothing about the state that just changed; the slot's text is the
13472
+ // only thing that says so. `role="status"` carries an implicit
13473
+ // aria-live="polite", and the explicit attribute is written alongside it
13474
+ // because that implication is the part assistive tech has historically
13475
+ // been inconsistent about — belt and braces, one line, no layout cost.
13476
+ statusEl.setAttribute('role', 'status');
13477
+ statusEl.setAttribute('aria-live', 'polite');
10646
13478
  bar.appendChild(statusEl);
10647
13479
  return bar;
10648
13480
  }
@@ -10689,6 +13521,10 @@
10689
13521
  }
10690
13522
 
10691
13523
  // ── headings ▾ ─────────────────────────────────────────────────────────
13524
+ // KNOWN GAP, documented rather than fixed: the toolbar's keyboard cursor can
13525
+ // open and close this dropdown, but its items are still reachable by mouse
13526
+ // only — they carry no cursor of their own and the mode's arrow keys move
13527
+ // along the bar, not into the panel. No keyboard route reaches them.
10692
13528
  function closeToolbarMenu() {
10693
13529
  if (toolbarMenuEl) { toolbarMenuEl.remove(); toolbarMenuEl = null; }
10694
13530
  }
@@ -10713,8 +13549,21 @@
10713
13549
  });
10714
13550
  document.body.appendChild(menu);
10715
13551
  const r = btnEl.getBoundingClientRect();
10716
- menu.style.top = (r.bottom + 4) + 'px';
13552
+ const top = r.bottom + 4;
13553
+ menu.style.top = top + 'px';
10717
13554
  menu.style.left = Math.max(4, Math.min(r.left, window.innerWidth - 140)) + 'px';
13555
+ // Task 9 fix round 1 (Ruling T9-5): same clamp shape as
13556
+ // openConvertSubmenu()'s .ed-handle-submenu fix above — measure the
13557
+ // panel's own rendered bottom after it is appended and positioned, and
13558
+ // shift `top` up by the bottom overflow when there is one.
13559
+ // `.ed-toolbar-menu` is `position: fixed` (lib/md2doc.js), so its own
13560
+ // getBoundingClientRect() is already in viewport coordinates and `top`
13561
+ // needs no containing-block translation the way the absolute-positioned
13562
+ // submenu did.
13563
+ const overflowBottom = menu.getBoundingClientRect().bottom - window.innerHeight;
13564
+ if (overflowBottom > 0) {
13565
+ menu.style.top = (top - overflowBottom) + 'px';
13566
+ }
10718
13567
  toolbarMenuEl = menu;
10719
13568
  }
10720
13569
 
@@ -10788,7 +13637,7 @@
10788
13637
  // A block that owns no source line has an inverted range, and there is
10789
13638
  // no line to splice in front of.
10790
13639
  if (!rec || rec.endLine < rec.startLine) {
10791
- refuseStructuralListEdit(NO_SOURCE_LINE_INSERT_MESSAGE);
13640
+ refuseStructuralListEdit(noSourceLineInsertMessageFor(liveBlockEl));
10792
13641
  return;
10793
13642
  }
10794
13643
  const at = rec.startLine;
@@ -10960,18 +13809,90 @@
10960
13809
  await insertBlockBelow(target, 'paragraph', { lines: newLines });
10961
13810
  }
10962
13811
 
10963
- function pickAndInsertImage() {
13812
+ // v3.2.1: 「插入圖片竟然無法在我當前的位置後插入,每次都跑到最後」. Two
13813
+ // separate mechanisms put the image at EOF, and BOTH were measured — the
13814
+ // second one is the reason this function is longer than it looks like it
13815
+ // needs to be.
13816
+ //
13817
+ // (a) `input.click()` below is a SYNTHETIC click. It bubbles to the
13818
+ // document delegator, which reads it as a click landing outside every
13819
+ // block → switchAwayFrom() → commit + re-render → the anchor node
13820
+ // captured a moment earlier is detached → insertImages() falls back to
13821
+ // lastBlockEl() and the image lands at the end of the file.
13822
+ // Deterministic whenever the block the user is in is dirty, which is
13823
+ // exactly the reported gesture.
13824
+ //
13825
+ // (b) stopPropagation() on that click is NECESSARY BUT NOT SUFFICIENT. It
13826
+ // fixes the reported gesture, yet the anchor stayed a raw DOM node
13827
+ // held across the file dialog: a FULL render landing between the click
13828
+ // and the `change` event detaches it and the image is back at EOF.
13829
+ // The v3.2.0 patch path is not the exposure — two forced mid-dialog
13830
+ // renders (a commit in another block, a ⠿ 刪除 of another block) were
13831
+ // measured to patch and leave the anchor attached — but a full render
13832
+ // is one patchmap() miss away (it returns null whenever there is no
13833
+ // usable editRange), and a real OS file dialog stays open for as long
13834
+ // as the user takes. So the window is small, not hypothetical.
13835
+ //
13836
+ // Hence both halves: stop the synthetic click from reading as an
13837
+ // outside-click, AND resolve the open session BEFORE the picker opens while
13838
+ // carrying the anchor as a stable IDENTITY that is re-resolved when the
13839
+ // files come back, instead of as a node reference. The identity triple is
13840
+ // insertBlockBelow()'s (captureBlockIdentity / ownsOpenSession /
13841
+ // reresolveBlockEl + reresolveBlockElAfterSelfCommit) — that function solved
13842
+ // this same problem for its own anchor and the image path simply never
13843
+ // adopted it. Do not "simplify" the re-resolution away: it is the half that
13844
+ // survives the dialog.
13845
+ //
13846
+ // ⚠ ORDERING: `await switchAwayFrom()` runs BEFORE input.click(). Chrome
13847
+ // gates the file picker on TRANSIENT user activation, which is time-based
13848
+ // (~5 s) and is NOT spent by awaiting a promise, so the picker still opens.
13849
+ // The journey scenario presses 🖼 on a DIRTY block, so a real commit — an
13850
+ // /api/render round trip — runs inside that await; if the activation were
13851
+ // spent, waitForFileChooser() there would time out. A commit slower than
13852
+ // the activation window would be the one way this ordering could bite.
13853
+ async function pickAndInsertImage() {
13854
+ // Captured BEFORE switchAwayFrom(), because that is what can renumber the
13855
+ // ids and rewrite the source — see captureBlockIdentity()'s comment.
13856
+ const blockEl = liveToolbarBlock() || lastBlockEl();
13857
+ const identity = captureBlockIdentity(blockEl);
13858
+ // The commit inside switchAwayFrom() can be a rewrite of THIS block's own
13859
+ // source, in which case reresolveBlockEl()'s fingerprint is guaranteed to
13860
+ // miss because WE are the reason the source changed; the narrowed
13861
+ // re-resolve is used only then. Same reasoning as insertBlockBelow().
13862
+ const selfSession = ownsOpenSession(blockEl);
13863
+ if (!(await switchAwayFrom())) return;
13864
+ const anchorEl = (blockEl && document.body.contains(blockEl))
13865
+ ? blockEl
13866
+ : (reresolveBlockEl(identity) ||
13867
+ (selfSession ? reresolveBlockElAfterSelfCommit(identity) : null));
13868
+ if (!anchorEl) { showBanner(TOOLBAR_NO_ANCHOR_MESSAGE, null, null); return; }
13869
+ // Re-captured AFTER the commit: `identity` above holds the PRE-commit
13870
+ // fingerprint, which the commit we just awaited may itself have
13871
+ // invalidated. What has to survive the dialog is the anchor as it stands
13872
+ // now.
13873
+ const dialogIdentity = captureBlockIdentity(anchorEl);
10964
13874
  const input = document.createElement('input');
10965
13875
  input.type = 'file';
10966
13876
  input.accept = 'image/png,image/jpeg,image/gif,image/webp';
10967
13877
  input.multiple = true;
10968
13878
  input.hidden = true;
10969
- const anchorEl = liveToolbarBlock() || lastBlockEl();
13879
+ // (a) above.
13880
+ input.addEventListener('click', (e) => e.stopPropagation());
10970
13881
  document.body.appendChild(input);
10971
13882
  input.addEventListener('change', () => {
10972
13883
  const files = input.files ? Array.prototype.slice.call(input.files) : [];
10973
13884
  input.remove();
10974
- if (files.length) insertImages(files, anchorEl);
13885
+ if (!files.length) return;
13886
+ // (b) above. A render that landed while the dialog was open detached
13887
+ // `anchorEl`; the identity still names the block the user pointed at.
13888
+ // If even that misses, the block the user aimed at no longer exists as
13889
+ // it was — refuse (and skip the upload, so no orphan lands in the
13890
+ // user's assets/) rather than silently appending at EOF, which is the
13891
+ // very symptom being fixed.
13892
+ const live = document.body.contains(anchorEl)
13893
+ ? anchorEl : reresolveBlockEl(dialogIdentity);
13894
+ if (!live) { showBanner(TOOLBAR_NO_ANCHOR_MESSAGE, null, null); return; }
13895
+ insertImages(files, live);
10975
13896
  });
10976
13897
  input.click();
10977
13898
  }
@@ -10998,9 +13919,10 @@
10998
13919
  // assets/x.png orphaned on disk. No error, no visible effect, and a stray
10999
13920
  // binary left in what is usually a git working tree.
11000
13921
  //
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.
13922
+ // v3.2.1: `!== 'edit'` rather than `=== 'source'` on purpose. Source is
13923
+ // the only other mode today, but the gate's reason is "the content the
13924
+ // user is looking at is not the content a drop would write into" — a
13925
+ // future third mode inherits the refusal rather than the hole.
11004
13926
  document.addEventListener('dragover', (e) => {
11005
13927
  if (docMode !== 'edit') return;
11006
13928
  if (!dragCarriesFiles(e)) return;
@@ -11091,6 +14013,7 @@
11091
14013
  const md = String(payload.value == null ? '' : payload.value).replace(/\r\n?/g, '\n');
11092
14014
  if (md.trim() === '') return;
11093
14015
  if (isInlineSafePaste(md)) {
14016
+ snapBurstIfActive(editEl, 'paste-pre');
11094
14017
  insertTextAtCaret(md);
11095
14018
  snapBurstIfActive(editEl, 'paste');
11096
14019
  return;
@@ -11103,6 +14026,7 @@
11103
14026
  insertBlockBelow(editEl.closest('.ed-block'), 'paragraph', { lines: md.split('\n') });
11104
14027
  return;
11105
14028
  }
14029
+ snapBurstIfActive(editEl, 'paste-pre');
11106
14030
  insertTextAtCaret(payload.value || '');
11107
14031
  snapBurstIfActive(editEl, 'paste');
11108
14032
  }
@@ -11117,12 +14041,9 @@
11117
14041
  }
11118
14042
 
11119
14043
  // ── 追加 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
- }
14044
+ // v3.2.1: applyPreviewEditability() lived here and was preview mode's ONLY
14045
+ // effect. Preview is gone (docsource.js's MODES comment carries the
14046
+ // measurement), and with it the last caller of this function.
11126
14047
 
11127
14048
  function enterSourceMode() {
11128
14049
  // The pre-edit snapshot fromSource() diffs against. Omitting it makes
@@ -11194,14 +14115,21 @@
11194
14115
  const ok = await leaveSourceMode();
11195
14116
  if (!ok) return;
11196
14117
  }
11197
- if (docMode === 'preview') applyPreviewEditability(true);
11198
14118
  docMode = next;
11199
14119
  if (docMode === 'source') enterSourceMode();
11200
- if (docMode === 'preview') applyPreviewEditability(false);
11201
14120
  document.body.setAttribute('data-ed-mode', docMode);
14121
+ paintModeStatus();
11202
14122
  updateToolbar();
11203
14123
  }
11204
14124
 
14125
+ // v3.2.1: 模式必須說出來 —— 使用者按第二下以為卡住,其實是進了 source。
14126
+ // Called from setDocMode() AND from mountToolbar(), so the slot is truthful
14127
+ // from the first paint rather than only after the first mode change.
14128
+ function paintModeStatus() {
14129
+ const statusEl = document.querySelector('.ed-toolbar-status');
14130
+ if (statusEl) statusEl.textContent = docMode === 'source' ? '原始碼' : '編輯';
14131
+ }
14132
+
11205
14133
  // ── mount ──────────────────────────────────────────────────────────────
11206
14134
  function wireToolbarTracking() {
11207
14135
  // Which block the bar reports on. Delegated at document level (never a
@@ -11219,6 +14147,16 @@
11219
14147
  if (!e.target || !e.target.closest) return;
11220
14148
  if (e.target.closest('.ed-toolbar') || e.target.closest('.ed-toolbar-menu')) return;
11221
14149
  closeToolbarMenu();
14150
+ // F12: a press somewhere else is a hand-back, so the bar's keyboard
14151
+ // cursor goes with it. Deliberately hung on click and not on focusin:
14152
+ // SOME activations do land a focusin on an editable surface, and hanging
14153
+ // the hand-back there would end the mode after a single press for those.
14154
+ // Driven with an uncommitted edit pending, the ones that fire a focusin
14155
+ // are list, ordered-list, check, table, insert-before, insert-after and
14156
+ // preview; headings, outline, and everything in the landing note on
14157
+ // activateToolbarCursor() fire none. So this cannot be a blanket claim
14158
+ // about activation, and the earlier version of this comment made it one.
14159
+ exitToolbarKeynav();
11222
14160
  const blockEl = e.target.closest('.ed-block');
11223
14161
  if (blockEl) toolbarBlockEl = blockEl;
11224
14162
  updateToolbar();
@@ -11245,15 +14183,110 @@
11245
14183
  // this reason: the conflict banner, the selection toolbar, the table
11246
14184
  // grips and the table edge menu.
11247
14185
  document.body.appendChild(toolbarEl);
14186
+ // A scroll event does not bubble, so this one is on the bar itself rather
14187
+ // than folded into the document-level capture-phase listener at the foot
14188
+ // of this file. That listener does receive the bar's own scroll —
14189
+ // measured, capture reaches a target that never bubbles — but what it
14190
+ // does with it is tear down grips and menus, which has nothing to do with
14191
+ // repainting an edge gradient.
14192
+ //
14193
+ // The resize listener is not redundant with the scroll one. A viewport
14194
+ // change moves clientWidth, and whether it also produces a scroll event
14195
+ // depends on where the bar happens to be resting: driven from 420px out
14196
+ // to 1400px, the bar sitting at its scroll maximum got a scroll event
14197
+ // (the clamp back to zero), and the bar already at zero got none at all,
14198
+ // while both had to end up with the hint out.
14199
+ toolbarEl.addEventListener('scroll', paintToolbarOverflow, { passive: true });
14200
+ window.addEventListener('resize', paintToolbarOverflow);
11248
14201
  document.body.setAttribute('data-ed-mode', docMode);
14202
+ paintModeStatus();
11249
14203
  wireToolbarTracking();
11250
14204
  wireImageDrop();
11251
14205
  wirePlainPasteArming();
14206
+ // No separate first paint of the edge hint here: on this call the
14207
+ // remembered toolbar signature is still its initial empty string, which no
14208
+ // JSON.stringify() of a state object can equal, so updateToolbar() takes
14209
+ // its write branch and repaints on the way out. Driven at a 420px-wide
14210
+ // viewport, the bar carries the right-hand token before any gesture.
11252
14211
  updateToolbar();
11253
14212
  }
11254
14213
 
11255
14214
  // ── global key handling ─────────────────────────────────────────────────
11256
14215
  document.addEventListener('keydown', (e) => {
14216
+ // ── F12: the toolbar's keyboard mode owns its own keys ─────────────
14217
+ // Above everything, including the `.ed-raw` textarea bail further down.
14218
+ // That bail returns for EVERY key, so an entry gesture placed under it
14219
+ // would be dead while a degraded block's raw editor holds focus — driven
14220
+ // from that editor, the gesture lands on the bar and the caret stays in
14221
+ // the textarea. Source mode is a different surface (`textarea.ed-source`,
14222
+ // which that bail does not match) and reaches the bar as well; there the
14223
+ // model disables the row apart from the button that gets you back out, so
14224
+ // the cursor lands on that one.
14225
+ //
14226
+ // Escape is NOT claimed here. It belongs to the ordered prologue just
14227
+ // below, which already ranks a drag and an open menu above everything
14228
+ // else; the rung that leaves this mode was inserted there, in rank order,
14229
+ // rather than jumped ahead of them.
14230
+ //
14231
+ // Anything this mode does not name hands the keyboard straight back: the
14232
+ // mode ends and the keystroke carries on down this listener to whatever
14233
+ // would have had it.
14234
+ //
14235
+ // The modifier names in that list are KEY NAMES, not modifier flags:
14236
+ // `e.key === 'Control'` is the Control key pressed BY ITSELF, which
14237
+ // browsers dispatch a keydown for — driven, and the row asserts that
14238
+ // dispatch before it asserts anything about the cue, because a browser
14239
+ // that stayed silent would make the whole check pass while proving
14240
+ // nothing. Without the exclusions that keydown reaches the hand-back, so a
14241
+ // user who presses Control to begin a chord loses the cursor before
14242
+ // finishing it. That bare press is the shape that separates them, and each
14243
+ // is ablated on its own against it rather than reasoned about as a set.
14244
+ //
14245
+ // A chord does NOT separate them, which is why an earlier reading here
14246
+ // called `Control` and `Meta` unpinnable: Ctrl+S ends the mode and writes
14247
+ // the file whether or not `Control` is excluded, because the `s` behind it
14248
+ // is unnamed here and hands back before the save branch below runs. The
14249
+ // exclusion does its work at the modifier's own keydown, which lands
14250
+ // before the key a chord is named for ever arrives.
14251
+ //
14252
+ // `Alt` and `Shift` carry a second duty on top of that:
14253
+ //
14254
+ // Alt — WITHOUT it the entry chord stops toggling. The Alt keydown
14255
+ // would end the mode, and the F10 behind it would then arrive
14256
+ // with the mode already off and re-enter, so pressing the chord
14257
+ // a second time would look like it did nothing. WITH it, the
14258
+ // Alt keydown is ignored and the F10 lands as an unnamed key,
14259
+ // which is what ends the mode. The exclusion is the cause of the
14260
+ // toggle, not a defence against it.
14261
+ // Shift — keeps Shift+Arrow on the toolbar cursor. Without it the Shift
14262
+ // keydown ends the mode and the arrow behind it falls through to
14263
+ // the block-selection extension instead.
14264
+ //
14265
+ // A menu raised from this bar goes down on the way past. Without this the
14266
+ // hand-back cleared the cursor cue and left the H▾ dropdown standing, so
14267
+ // the two disagreed and only Escape was any use. Escape is excluded
14268
+ // because the ordered prologue below owns it and takes the menu down
14269
+ // there, in rank order; Enter and Space are excluded because they are the
14270
+ // keys that opened the menu and have to keep closing it.
14271
+ if (toolbarRovingIndex >= 0) {
14272
+ if (toolbarMenuEl && e.key !== 'Escape' && e.key !== 'Enter' && e.key !== ' ') {
14273
+ closeToolbarMenu();
14274
+ }
14275
+ if (e.key === 'ArrowRight') { e.preventDefault(); moveToolbarCursor(1); return; }
14276
+ if (e.key === 'ArrowLeft') { e.preventDefault(); moveToolbarCursor(-1); return; }
14277
+ if (e.key === 'Enter' || e.key === ' ') {
14278
+ e.preventDefault(); activateToolbarCursor(); return;
14279
+ }
14280
+ if (e.key !== 'Escape' && e.key !== 'Alt' && e.key !== 'Shift' &&
14281
+ e.key !== 'Control' && e.key !== 'Meta') {
14282
+ exitToolbarKeynav();
14283
+ }
14284
+ } else if (e.altKey && e.key === 'F10') {
14285
+ e.preventDefault();
14286
+ enterToolbarKeynav();
14287
+ return;
14288
+ }
14289
+
11257
14290
  // ── S3 §4.4: Escape priority is drag > menu > selection > burst ─────
11258
14291
  // A new ordered PROLOGUE, not a reshuffle of what follows (§4.4 is
11259
14292
  // explicit about that). The two target-based short-circuits further down
@@ -11316,6 +14349,11 @@
11316
14349
  closeInsertMenu();
11317
14350
  return;
11318
14351
  }
14352
+ // F12, in rank order: the H▾ dropdown is a menu raised from inside the
14353
+ // bar, so it goes out before the bar's keyboard mode does, matching the
14354
+ // way the menu rungs above rank against the selection rung below.
14355
+ if (toolbarMenuEl) { e.preventDefault(); closeToolbarMenu(); return; }
14356
+ if (exitToolbarKeynav()) { e.preventDefault(); return; }
11319
14357
  if (blockSelection) { e.preventDefault(); clearBlockSelection(); return; }
11320
14358
  // Nothing above owns this Escape — fall through to the burst
11321
14359
  // short-circuits below, where Escape-reverts-the-burst still belongs.
@@ -11605,18 +14643,121 @@
11605
14643
  switchAwayFrom();
11606
14644
  });
11607
14645
 
11608
- document.addEventListener('input', (e) => {
11609
- const cellEl = e.target && e.target.closest && e.target.closest('.ed-wys-cell');
14646
+ // The `inputType` values Chromium reports for the BROWSER's own formatting
14647
+ // commands. md2doc binds no formatting shortcut of its own — the keys its
14648
+ // `ctrlKey || metaKey` branches act on are `Enter`, `z`, `y`, shift+`Z`,
14649
+ // `s` and shift+`v`/`V`: the raw editor's commit, undo, redo, save and
14650
+ // plain-paste arming. `b`, `i` and `u` are not among them, so Ctrl+B
14651
+ // reaches the contenteditable as the browser's own execCommand and does not
14652
+ // pass through any snapBurstIfActive() call site.
14653
+ //
14654
+ // MEASURED (puppeteer 24.42.0 / headless Chromium, an `.ed-wys-armed`
14655
+ // paragraph, ' typed sentence' typed then a five-character selection
14656
+ // bolded with Ctrl+B) — exactly two events, in this order:
14657
+ //
14658
+ // beforeinput inputType="formatBold" innerHTML "Alpha paragraph. typed sentence"
14659
+ // input inputType="formatBold" innerHTML "Alpha paragraph. <b>typed</b> sentence"
14660
+ //
14661
+ // i.e. the mutation is the beforeinput's default action, so a beforeinput
14662
+ // listener still sees the pre-mutation DOM. That is what makes the pair
14663
+ // below the native-command equivalent of the `*-pre` / post snapshots every
14664
+ // programmatic call site takes (grep `snapBurstIfActive(` and
14665
+ // `history.snap('` for the two families).
14666
+ //
14667
+ // Three of the inputTypes below were actually driven end to end and are
14668
+ // pinned by journey rows: formatBold (Ctrl+B), formatItalic (Ctrl+I),
14669
+ // formatUnderline (Ctrl+U) — measured tags `<b>` / `<i>` / `<u>`. The rest —
14670
+ // formatStrikeThrough, formatSuperscript, formatSubscript, formatRemove —
14671
+ // are listed on the same mechanism but have NOT been driven here; they cost
14672
+ // nothing when they never fire, and they are not a claim that they work.
14673
+ //
14674
+ // A Set, not an object literal: an object literal inherits from
14675
+ // Object.prototype, so `types['constructor']` and `types['toString']` are
14676
+ // truthy and an inputType named like a prototype key would take the format
14677
+ // path. `.has()` answers only for what is actually listed.
14678
+ const NATIVE_FORMAT_INPUT_TYPES = new Set([
14679
+ 'formatBold', 'formatItalic', 'formatUnderline', 'formatStrikeThrough',
14680
+ 'formatSuperscript', 'formatSubscript', 'formatRemove'
14681
+ ]);
14682
+
14683
+ // Resolves the burst history that owns `e.target`, or null. This is the
14684
+ // selection logic the `input` listener carried inline before v3.3.0, lifted
14685
+ // out unchanged so the new `beforeinput` listener answers the same question
14686
+ // the same way: a `.ed-wys-cell` only counts when the current burst is the
14687
+ // table burst over its own <table>, and any other surface only counts when
14688
+ // it IS the current burst's editEl.
14689
+ function burstHistoryForInputEvent(e) {
14690
+ if (!e.target || !e.target.closest) return null;
14691
+ const cellEl = e.target.closest('.ed-wys-cell');
11610
14692
  if (cellEl) {
11611
14693
  const tableEl = cellEl.closest('table');
11612
14694
  if (currentBurst && currentBurst.blockType === 'table' && currentBurst.editEl === tableEl) {
11613
- currentBurst.history.noteTyping();
14695
+ return currentBurst.history;
11614
14696
  }
14697
+ return null;
14698
+ }
14699
+ const editEl = e.target.closest('.ed-wys-armed');
14700
+ if (!editEl || !currentBurst || currentBurst.editEl !== editEl) return null;
14701
+ return currentBurst.history;
14702
+ }
14703
+
14704
+ // Capture phase, matching the phase the ordering above was measured in.
14705
+ document.addEventListener('beforeinput', (e) => {
14706
+ if (!NATIVE_FORMAT_INPUT_TYPES.has(e.inputType)) return;
14707
+ const history = burstHistoryForInputEvent(e);
14708
+ if (!history) return;
14709
+ history.snap('native-format-pre');
14710
+ }, true);
14711
+
14712
+ document.addEventListener('input', (e) => {
14713
+ const history = burstHistoryForInputEvent(e);
14714
+ if (!history) return;
14715
+ if (NATIVE_FORMAT_INPUT_TYPES.has(e.inputType)) {
14716
+ // The post-mutation half, and it is LOAD-BEARING — do not simplify it
14717
+ // back to noteTyping(). noteTyping() does not push anything for this
14718
+ // event: it snaps only when the PREVIOUS pending capture is already
14719
+ // >= debounceMs old, then sets `isPendingSnap = true` (history.js,
14720
+ // noteTyping()). Nothing clears that flag on a timer — only snap(),
14721
+ // start() and dispose() assign it false — so the pending capture stands
14722
+ // until something flushes it, and undo() flushes it as its first act.
14723
+ // By then the capture is of the DOM as it is at UNDO time, not as it
14724
+ // was when the command ran.
14725
+ //
14726
+ // TRACED by wrapping window.md2docHistory.createBurstHistory and
14727
+ // logging every call with the stack size on both sides. Two gestures,
14728
+ // this line present vs. swapped for noteTyping():
14729
+ //
14730
+ // ── Ctrl+B, keep typing inside the window, Ctrl+Z ────────────────
14731
+ // with: snap('native-format-pre') 1->2 snap('native-format') 2->3
14732
+ // noteTyping() 3->3 x2 undo() 3->3
14733
+ // DOM after undo "Alpha paragraph. <b>typed</b> sentence"
14734
+ // without: snap('native-format-pre') 1->2 noteTyping() 2->2 x3
14735
+ // undo() 2->2
14736
+ // DOM after undo "Alpha paragraph. typed sentence"
14737
+ //
14738
+ // Read the `undo() N->N` rows: the size does not drop, so undo() pushed
14739
+ // one entry (its flushTyping()) and popped one. WITHOUT this line that
14740
+ // flushed entry is "bold + XY", so the pop lands one entry short of the
14741
+ // bold and the bold is undone together with everything typed after it —
14742
+ // this task's own defect with a different prefix. The journey row
14743
+ // 「原生 Ctrl+B 後【400ms 內】繼續打字」 pins exactly that.
14744
+ //
14745
+ // ── Ctrl+B, 250ms pause, Ctrl+Z (the four earlier rows) ──────────
14746
+ // with: snap('native-format-pre') 1->2 snap('native-format') 2->3
14747
+ // undo() 3->2
14748
+ // without: snap('native-format-pre') 1->2 noteTyping() 2->2
14749
+ // undo() 2->2
14750
+ // both: DOM after undo "Alpha paragraph. typed sentence"
14751
+ //
14752
+ // Those four rows do NOT pin this line, and the reason is NOT that the
14753
+ // pause cleared anything — the `undo() 2->2` above shows the pending
14754
+ // capture was still there and was flushed. It is that with nothing
14755
+ // typed after the command, the flushed capture EQUALS what this line
14756
+ // would have pushed, so undo steps back to the same entry either way.
14757
+ history.snap('native-format');
11615
14758
  return;
11616
14759
  }
11617
- const editEl = e.target && e.target.closest && e.target.closest('.ed-wys-armed');
11618
- if (!editEl || !currentBurst || currentBurst.editEl !== editEl) return;
11619
- currentBurst.history.noteTyping();
14760
+ history.noteTyping();
11620
14761
  });
11621
14762
 
11622
14763
  document.addEventListener('paste', (e) => {
@@ -11624,6 +14765,7 @@
11624
14765
  if (cellEl) {
11625
14766
  e.preventDefault();
11626
14767
  const dt = e.clipboardData || window.clipboardData;
14768
+ snapBurstIfActive(cellEl.closest('table'), 'paste-pre');
11627
14769
  insertTextAtCaret(dt ? dt.getData('text/plain') : '');
11628
14770
  snapBurstIfActive(cellEl.closest('table'), 'paste');
11629
14771
  return;
@@ -11708,8 +14850,87 @@
11708
14850
  });
11709
14851
  });
11710
14852
 
14853
+ // v3.2.1: client.js 原本的 scroll 監聽器數量是 0,所以每一個 position:fixed
14854
+ // 的浮層在捲動後都停在原視窗座標。grip/bubble 有 mousemove 驅動可重新升起,
14855
+ // 隱藏它們就夠;.ed-seltb 沒有(它只由 selectionchange 與
14856
+ // applyMarkToggle/applyLinkToggle 驅動,而捲動不觸發 selectionchange),
14857
+ // 純隱藏會讓它永遠回不來,所以它要重新定位、只在選取整個離開視窗時才隱藏。
14858
+ // capture 階段是必要的:真正會自己捲的不是 .content(overflow-y: visible,
14859
+ // 實測 scrollHeight === clientHeight,捲的其實是 document)——是側欄的
14860
+ // .toc-list/#search-results-list(皆 overflow-y: auto,TOC 標題夠多時
14861
+ // 實測 scrollHeight > clientHeight)與 .lightbox-stage(overflow: auto)。
14862
+ // 這些容器的 scroll 事件不冒泡,只有 capture 階段收得到。
14863
+ // 副作用(已記錄,非缺陷):正因為 capture 連這些側欄容器的捲動都收,捲動
14864
+ // TOC 或搜尋結果清單時——即使正文內容一格都沒動——也會隱藏 table grips
14865
+ // 並關掉開著的 .ed-te-menu。
14866
+ //
14867
+ // 這個監聽器刻意是「模組層級、只掛一次、永不移除」,不跟著任何一次
14868
+ // burst 的生命週期走(不在 resetSelToolbarState() 裡拆)。理由:真正會
14869
+ // 刪資料的 .ed-te-menu/grip 家族(見上面 Task 5 的 mousemove 委派)不需要
14870
+ // 任何 burst 就能被叫出來 —— 使用者可以單純把游標移到表格上(觸發上面那個
14871
+ // 無條件掛載的 mousemove)升起 grip、打開 .ed-te-menu,此時完全沒有
14872
+ // selToolbarListener(那個監聽器只在 burst 開著時才存在)。若把這個捲動
14873
+ // 監聽器綁在 burst 身上,恰好在「grip/選單開著但沒有 burst」這個狀態下
14874
+ // 它會不存在。resetSelToolbarState() 本身也會在每次 rerender 時執行(六個
14875
+ // 呼叫點之一在本檔的 rerenderAll() 路徑上),把監聽器綁在它身上還會讓
14876
+ // 監聽器在 session 中途被拆掉、且永不重掛。
14877
+ let selScrollRaf = 0;
14878
+ function onAnyScroll() {
14879
+ if (tePointer && tePointer.dragging) return; // 拖曳中不動
14880
+ hideTableGrips();
14881
+ hideTableInsertBubbles();
14882
+ // v3.2.1 fix round 1: suppressEdgeMenuAutoHide — see its own comment —
14883
+ // is set for runCycleAlign()'s ensureTableBurstOpen() span, whose
14884
+ // cell.focus() can itself be the cause of this very scroll event; that
14885
+ // operation repositions the menu itself afterward, so skip tearing its
14886
+ // state down here (grip/bubble/toolbar-menu hiding above and below is
14887
+ // unaffected — they re-raise on the next mousemove regardless).
14888
+ if (!suppressEdgeMenuAutoHide) hideTableEdgeMenu(); // ← 真正會刪資料的是選單,不是 grip
14889
+ closeToolbarMenu();
14890
+ if (selScrollRaf) return;
14891
+ selScrollRaf = requestAnimationFrame(() => {
14892
+ selScrollRaf = 0;
14893
+ // 沒升起 → 0 次 layout read。Task 16 adds a boolean read here and
14894
+ // nothing else. Measured over a scroll burst against instrumented
14895
+ // Range/Element rect getters and getComputedStyle: with nothing
14896
+ // selected, with a burst open over a collapsed caret, and with a
14897
+ // selection that went collapsed while the bar was scrolled away, the
14898
+ // reads positionSelToolbar() performs stayed at zero — the same
14899
+ // figures as before this line changed. What does read layout per
14900
+ // scroll now is a live non-collapsed selection inside the edit root:
14901
+ // over the same burst that went from two Range rects to eighteen, one
14902
+ // per scroll event, which is the state this branch exists to re-ask.
14903
+ if (!selToolbar.parentNode && !selToolbarOffViewport) return;
14904
+ const sel = window.getSelection();
14905
+ if (!sel || !sel.rangeCount || sel.isCollapsed) { hideSelToolbar(); return; }
14906
+ const range = sel.getRangeAt(0);
14907
+ if (!selToolbarEditEl ||
14908
+ !selToolbarEditEl.contains(range.startContainer) ||
14909
+ !selToolbarEditEl.contains(range.endContainer)) { hideSelToolbar(); return; }
14910
+ // Task 15's suppression is asked on this path too, and it has to be:
14911
+ // before Task 16 the gate above returned on a detached bar, so a
14912
+ // scroll could not raise one. It can now, and a Tab-made full-cell
14913
+ // selection is a live non-collapsed selection inside the edit root —
14914
+ // the shape the checks above admit. Measured with this line dropped,
14915
+ // on a hand selection raised over a cell, scrolled out of view and
14916
+ // then walked on by Tab: the scroll that Tab's own focus() causes
14917
+ // appended .ed-seltb over the Tab's selection, and it was down again by
14918
+ // the time the gesture settled — so the settled state reads the same
14919
+ // with this line and without it, and a MutationObserver counting the
14920
+ // append is what sees the difference.
14921
+ //
14922
+ // The flag is read, not the boundary comparison that releases it in
14923
+ // onSelectionChangeForToolbar(): a scroll has no release to make, it
14924
+ // has to honour what the last selectionchange decided, and reading the
14925
+ // flag costs no layout.
14926
+ if (tabSelectedCellEl) { hideSelToolbar(); return; }
14927
+ positionSelToolbar(range);
14928
+ });
14929
+ }
14930
+ document.addEventListener('scroll', onAnyScroll, { passive: true, capture: true });
14931
+
11711
14932
  window.addEventListener('beforeunload', (e) => {
11712
- if (stack.dirtyDepth !== 0) {
14933
+ if (stack.dirtyDepth !== 0 || burstHasUncommittedEdit()) {
11713
14934
  e.preventDefault();
11714
14935
  e.returnValue = '';
11715
14936
  return '';