@helping-ai-workflow/md2doc 3.2.1 → 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,
@@ -1158,7 +1534,7 @@
1158
1534
  // 所以在 `burstSurvived` 那裡也留了一行對照註解。不搬 `burstSurvived`
1159
1535
  // 到還原之前,是刻意的取捨:搬了會讓剛開的 burst 先被 dispose 再由收尾
1160
1536
  // 重開一次,並把每次 undo 的工具列 degrade 原封不動地留著。
1161
- // teardown 十二項彼此的相對順序沒有被動到,條件式的 activeEditor 歸零
1537
+ // teardown 各項彼此的相對順序沒有被動到,條件式的 activeEditor 歸零
1162
1538
  // 仍然在 DOM 手術之後求值。
1163
1539
  const newLen = plan.replaceSpan.newParts.length;
1164
1540
  if (newLen > 0) {
@@ -1182,7 +1558,7 @@
1182
1558
  }
1183
1559
 
1184
1560
  // ── teardown ────────────────────────────────────────────────────────
1185
- // Controller ruling F1: the patch path runs the SAME twelve teardown items
1561
+ // Controller ruling F1: the patch path runs the SAME teardown items
1186
1562
  // applyFullRender() runs, unconditionally and in the same relative order.
1187
1563
  // The rationale comments below are copied from applyFullRender() because
1188
1564
  // they document WHY each one exists; the one thing that differs is the
@@ -1682,6 +2058,183 @@
1682
2058
  ta.style.height = (ta.scrollHeight + 2) + 'px';
1683
2059
  }
1684
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
+
1685
2238
  // v3.1.0 修正 4: `range` ({startLine, endLine}, 1-indexed inclusive) opens
1686
2239
  // the raw editor over an EXPLICIT span of source lines instead of the
1687
2240
  // block's own record. Two callers need it and one property makes both
@@ -1764,7 +2317,7 @@
1764
2317
  // delegator so
1765
2318
  // every caller — the delegated degraded-block click, openRawViaGutter(),
1766
2319
  // the burst-degrade path — is closed by one check.
1767
- if (blockOwnsNoLine(blockEl)) { refuseStructuralListEdit(NO_SOURCE_LINE_MESSAGE); return; }
2320
+ if (blockOwnsNoLine(blockEl)) { refuseStructuralListEdit(noSourceLineMessageFor(blockEl)); return; }
1768
2321
 
1769
2322
  // v3.1.0 修正 4: the span this editor owns. Without `range` it is exactly
1770
2323
  // the block's own record, so `source` below is byte-identical to what
@@ -1776,7 +2329,7 @@
1776
2329
  // The same inverted-range refusal blockOwnsNoLine() just made for the
1777
2330
  // block, re-asked of the range actually being edited: a caller-supplied
1778
2331
  // span can be malformed even when the anchor block is fine.
1779
- if (endLine < startLine) { refuseStructuralListEdit(NO_SOURCE_LINE_MESSAGE); return; }
2332
+ if (endLine < startLine) { refuseStructuralListEdit(noSourceLineMessageFor(blockEl)); return; }
1780
2333
  // A list item's rendered chrome (the .ed-li-marker span, the optional
1781
2334
  // .ed-li-check span, the .ed-li-text surface) is REBUILT BY THE RENDERER
1782
2335
  // from the source line's marker — it is not recoverable from a captured
@@ -1798,7 +2351,11 @@
1798
2351
 
1799
2352
  const ta = document.createElement('textarea');
1800
2353
  ta.className = 'ed-raw';
1801
- ta.value = source;
2354
+ // F9: `seed.value` 通常 === `source`;只有空圍欄那一種形狀會多一行
2355
+ // 空白本文(見 rawEditorSeed())。`source` 不可以跟著換掉 ——
2356
+ // reanchor() 拿它跟檔案裡的位元組逐字比。
2357
+ const seed = rawEditorSeed(source);
2358
+ ta.value = seed.value;
1802
2359
 
1803
2360
  // `restore`/`commit` are hoisted function declarations (defined further
1804
2361
  // down in this closure) — referencing them here, before their textual
@@ -1810,7 +2367,10 @@
1810
2367
  // commit()/restore() closures used by the manual Ctrl+Enter/✓/Esc/✕ UI.
1811
2368
  activeEditor = {
1812
2369
  blockEl,
1813
- hasChanges: () => ta.value !== source,
2370
+ // F9: 比對 `seed.value` 而不是 `source` —— 空圍欄那條路上種進去的那
2371
+ // 一行空白是這個編輯器自己放的,不是使用者打的,把它算成「有變更」
2372
+ // 會讓「開起來看一眼就關掉」變成一次靜默的寫檔。
2373
+ hasChanges: () => ta.value !== seed.value,
1814
2374
  commitNow: commit,
1815
2375
  cancelNow: cancelAndMaybeDiscard,
1816
2376
  reanchor,
@@ -1836,7 +2396,8 @@
1836
2396
  blockEl.appendChild(wrap);
1837
2397
  autoSize(ta);
1838
2398
  ta.focus();
1839
- ta.setSelectionRange(ta.value.length, ta.value.length);
2399
+ // F9: 不再無條件落在值的結尾 —— 見 rawEditorSeed() 自己的註解。
2400
+ ta.setSelectionRange(seed.caret, seed.caret);
1840
2401
 
1841
2402
  async function restore() {
1842
2403
  // v3.1.0 修正 4: `rangeMode` cannot restore by rewriting innerHTML —
@@ -2111,7 +2672,8 @@
2111
2672
  // '# D\n\n- a\n\n- - b\n' --type Z--> '# D\n\n- a\n\n- Z\n- - b\n'
2112
2673
  //
2113
2674
  // Refusing here closes every such path with one rule, instead of teaching
2114
- // 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
2115
2677
  // still rendered, still selectable, and its CHILD — which does own its line —
2116
2678
  // stays fully editable.
2117
2679
  function blockOwnsNoLine(blockEl) {
@@ -2121,6 +2683,47 @@
2121
2683
  return !!rec && rec.endLine < rec.startLine;
2122
2684
  }
2123
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
+
2124
2727
  // Task 4 fix round 1: the message the two blockOwnsNoLine() guards above
2125
2728
  // (openRawEditor / deleteBlockViaGutter) pass into the SHARED
2126
2729
  // refuseStructuralListEdit() helper (defined further down, next to
@@ -2133,6 +2736,23 @@
2133
2736
  // operations it refuses — an insert that answered '無法刪除' would be
2134
2737
  // telling the user something that is not true of the button they pressed.
2135
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
+ }
2136
2756
 
2137
2757
  // The list markers standing at the head of one source line, left to right.
2138
2758
  // A same-line nest ('- 1. b') puts one marker per nesting level on the line;
@@ -2603,6 +3223,151 @@
2603
3223
  sel.addRange(range);
2604
3224
  }
2605
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
+
2606
3371
  // Paste handler support: insert plain text at the caret via Range surgery,
2607
3372
  // keeping the serializer's input domain closed to plain text + the inline
2608
3373
  // elements it itself produces (bold/italic/code/links/br) — see the brief's
@@ -2625,6 +3390,9 @@
2625
3390
  const sel = window.getSelection();
2626
3391
  if (!sel.rangeCount) return;
2627
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));
2628
3396
  range.deleteContents();
2629
3397
  const segments = String(text).split(/\r\n|\r|\n/);
2630
3398
  segments.forEach((seg, i) => {
@@ -2639,6 +3407,7 @@
2639
3407
  range.setStartAfter(node);
2640
3408
  range.setEndAfter(node);
2641
3409
  });
3410
+ dropEmptied(leftovers);
2642
3411
  sel.removeAllRanges();
2643
3412
  sel.addRange(range);
2644
3413
  }
@@ -2652,11 +3421,20 @@
2652
3421
  const sel = window.getSelection();
2653
3422
  if (!sel.rangeCount) return;
2654
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));
2655
3427
  range.deleteContents();
2656
3428
  const br = document.createElement('br');
2657
3429
  range.insertNode(br);
2658
3430
  range.setStartAfter(br);
2659
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);
2660
3438
  sel.removeAllRanges();
2661
3439
  sel.addRange(range);
2662
3440
  }
@@ -3204,6 +3982,21 @@
3204
3982
  });
3205
3983
  sub.style.top = anchorBtn.offsetTop + 'px';
3206
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
+ }
3207
4000
  convertSubmenu = sub;
3208
4001
  convertSubmenuViaHover = !!viaHover;
3209
4002
  // The aim triangle needs a previous sample to have an apex; the first
@@ -3538,21 +4331,19 @@
3538
4331
  if (kind === 'code') {
3539
4332
  // Code blocks are never WYSIWYG-armed (armEditables() above has no
3540
4333
  // 'code' branch) — same degraded-block contract as any other fence:
3541
- // click opens the raw in-place source editor directly. openRawEditor()
3542
- // itself places the caret at the END of the textarea (its generic
3543
- // contract, shared with every other raw-edit open path) — that lands
3544
- // AFTER the closing fence, not on the blank BODY line
3545
- // BLOCK_SKELETONS['code'] (see its own comment) puts there
3546
- // specifically so typing has somewhere to land. Move it there once
3547
- // 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.
3548
4346
  await openRawEditor(blockEl);
3549
- const ta = blockEl.querySelector('textarea.ed-raw');
3550
- if (ta) {
3551
- const nlIdx = ta.value.indexOf('\n');
3552
- const pos = nlIdx === -1 ? ta.value.length : nlIdx + 1;
3553
- ta.setSelectionRange(pos, pos);
3554
- ta.focus();
3555
- }
3556
4347
  return;
3557
4348
  }
3558
4349
  if (kind === 'table') {
@@ -3618,7 +4409,7 @@
3618
4409
  // li block grows no + until S2; S2 is next, and a guard that has to be
3619
4410
  // remembered later is a guard that will not be.
3620
4411
  if (blockOwnsNoLine(liveBlockEl)) {
3621
- refuseStructuralListEdit(NO_SOURCE_LINE_INSERT_MESSAGE);
4412
+ refuseStructuralListEdit(noSourceLineInsertMessageFor(liveBlockEl));
3622
4413
  return;
3623
4414
  }
3624
4415
  const customLines = opts && Array.isArray(opts.lines) ? opts.lines : null;
@@ -3683,7 +4474,7 @@
3683
4474
  // commitBlockInsertion() is about to read `endLine` and `lines[endLine]`
3684
4475
  // off. Re-checked against the block actually used.
3685
4476
  if (blockOwnsNoLine(anchorEl)) {
3686
- refuseStructuralListEdit(NO_SOURCE_LINE_INSERT_MESSAGE);
4477
+ refuseStructuralListEdit(noSourceLineInsertMessageFor(anchorEl));
3687
4478
  return;
3688
4479
  }
3689
4480
  }
@@ -3916,7 +4707,7 @@
3916
4707
  // A block that owns no source line has an INVERTED range
3917
4708
  // (endLine === startLine - 1) and every commit helper handed one does
3918
4709
  // something plausible and wrong.
3919
- if (blockOwnsNoLine(liveBlockEl)) { refuseStructuralListEdit(NO_SOURCE_LINE_MESSAGE); return null; }
4710
+ if (blockOwnsNoLine(liveBlockEl)) { refuseStructuralListEdit(noSourceLineMessageFor(liveBlockEl)); return null; }
3920
4711
  const rec = blockRecOf(liveBlockEl);
3921
4712
  if (!rec) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return null; }
3922
4713
  if (anchor) anchor.line = rec.startLine;
@@ -3961,7 +4752,7 @@
3961
4752
  // but "another module filters it" is an argument about another file's
3962
4753
  // output, and this is the guard that stands between an inverted range and
3963
4754
  // a commit helper.
3964
- if (blockOwnsNoLine(el)) { refuseStructuralListEdit(NO_SOURCE_LINE_MESSAGE); return null; }
4755
+ if (blockOwnsNoLine(el)) { refuseStructuralListEdit(noSourceLineMessageFor(el)); return null; }
3965
4756
  els.push(el);
3966
4757
  }
3967
4758
  return { els: els, recs: members, batch: members.length > 1 };
@@ -4155,10 +4946,16 @@
4155
4946
  // caret and FALSE of the toolbar, and the first version of this guard
4156
4947
  // skipped both. The bar can be down to 4 while the caret is still sitting
4157
4948
  // on a live surface, and that is the ORDINARY case, not an edge one: it
4158
- // is what happens on the PATCH render path, i.e. on every commit after
4159
- // the first of a page load (`lastParts` is null only for that first one,
4160
- // so the fallback route — the one this guard was measured on — is
4161
- // unreachable after it).
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.
4162
4959
  //
4163
4960
  // INSTRUMENTED inside applyPatch() on a primed ⠿刪除 refusal, one log
4164
4961
  // line per step, rather than read off the source:
@@ -4174,15 +4971,29 @@
4174
4971
  // restoreAfterStructuralOp active:DIV.ed-li-text ed-wys-armed
4175
4972
  // toolbarBlockElNull:true
4176
4973
  // — applyPatch()'s `focusWasInSpan` block hands focus back to the NEW
4177
- // surface, and `burstSurvived` is only evaluated afterwards. On this
4178
- // route `currentBurst` is still the OLD burst (resolveBurst()'s own
4179
- // teardown has not nulled it yet, which is also why focusin's
4180
- // `if (currentBurst) return;` stops the focus() from opening a fresh
4181
- // one), and that old burst's editEl was detached by the patch — so
4182
- // `burstSurvived` is false and `resetToolbarBlock()` runs AFTER the
4183
- // caret was handed back. The unprimed run of the same gesture took the
4184
- // FALLBACK route and arrived here with active:BODY, which is why the
4185
- // first wave's measurements never saw this.
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.
4186
4997
  //
4187
4998
  // So: do not drag the caret (it never left), but DO re-aim the bar, at
4188
4999
  // the block the caret is actually in rather than at `anchorLine` — those
@@ -5457,6 +6268,7 @@
5457
6268
  }
5458
6269
 
5459
6270
  function startBurst(editEl) {
6271
+ dropDiscardedBurst();
5460
6272
  const blockEl = editEl.closest('.ed-block');
5461
6273
  if (!blockEl) return;
5462
6274
  const blockId = Number(blockEl.getAttribute('data-block-id'));
@@ -5471,6 +6283,7 @@
5471
6283
  original: burstBaselineHtml(editEl),
5472
6284
  history,
5473
6285
  };
6286
+ watchBurstForDirtyDot(editEl);
5474
6287
  selToolbarEditEl = editEl;
5475
6288
  if (!selToolbarListener) {
5476
6289
  selToolbarListener = onSelectionChangeForToolbar;
@@ -5790,6 +6603,51 @@
5790
6603
  return document.querySelector('.ed-block[data-block-id="' + target.id + '"]');
5791
6604
  }
5792
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
+
5793
6651
  // ── S3 Task 2: block multi-select state ────────────────────────────────
5794
6652
  // The selection's identity is a LINE RANGE, never ids and never nodes.
5795
6653
  // buildBlockMap renumbers every id from 0 on every render (blockmap.js's
@@ -5995,8 +6853,9 @@
5995
6853
  // v3.0.2: computeIndentClamp() lives past this file's node early-exit
5996
6854
  // (module.exports at line ~509 ships the pure core only) and nothing on
5997
6855
  // window reaches it, so the stale-span assert added to it in v3.0.2 had
5998
- // no way to be tested at all. This is the seventh __edTest seam and the
5999
- // 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.
6000
6859
  window.__edTestClampProbe = function (spanEls, opBlockEl, opOldIndent, opts) {
6001
6860
  return computeIndentClamp(spanEls, opBlockEl, opOldIndent, opts);
6002
6861
  };
@@ -6605,17 +7464,122 @@
6605
7464
  // real commit is also possible).
6606
7465
  const wasPristineForThisBlock = !!(pristineInsert && pristineInsert.blockId === burst.blockId);
6607
7466
  if (wasPristineForThisBlock) pristineInsert = null;
6608
- burst.history.dispose();
6609
- editEl.innerHTML = burst.original;
6610
- currentBurst = null;
6611
- 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).
6612
7472
  if (wasPristineForThisBlock) {
7473
+ burst.history.dispose();
7474
+ editEl.innerHTML = burst.original;
7475
+ currentBurst = null;
7476
+ resetSelToolbarState();
6613
7477
  await discardPristineInsert(); // rerenderAll() already detaches editEl — nothing left to blur()
6614
7478
  return;
6615
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();
6616
7502
  editEl.blur();
6617
7503
  }
6618
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
+
6619
7583
  // Ctrl+Z inside a burst: step the burst-local history first; only once
6620
7584
  // it's exhausted (atBottom — the surface is already back to its pre-focus
6621
7585
  // baseline) does this cascade OUT to the document-level undo() stack,
@@ -6680,6 +7644,27 @@
6680
7644
  // paste toggle inside a table cell silently skipped its own burst-history
6681
7645
  // snapshot — Ctrl+Z after it would step PAST the mark (or straight to
6682
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).
6683
7668
  function snapBurstIfActive(root, reason) {
6684
7669
  if (currentBurst && (currentBurst.editEl === root || currentBurst.editEl.contains(root))) {
6685
7670
  currentBurst.history.snap(reason);
@@ -6710,6 +7695,14 @@
6710
7695
  if (e.key === 'Tab') e.preventDefault();
6711
7696
  return;
6712
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;
6713
7706
  // Task 8 (Phase 4): per-li burst — Enter / Shift+Enter / Tab / Shift+Tab
6714
7707
  // are owned by handleLiKeydown() below (spec §4's key semantics for li
6715
7708
  // surfaces, acceptance rows 1, 3, 5, 6, 7, 8). Every other key (Escape,
@@ -6740,6 +7733,19 @@
6740
7733
  // whatever the commit message that introduced this said.
6741
7734
  if (e.key === 'Tab') {
6742
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
+ }
6743
7749
  if (currentBurst.blockType === 'heading') {
6744
7750
  changeHeadingDepth(currentBurst.blockEl, e.shiftKey ? -1 : 1);
6745
7751
  }
@@ -6748,6 +7754,7 @@
6748
7754
  if (e.key === 'Enter') {
6749
7755
  e.preventDefault();
6750
7756
  if (e.shiftKey) {
7757
+ snapBurstIfActive(editEl, 'br-pre');
6751
7758
  insertBrAtCaret();
6752
7759
  snapBurstIfActive(editEl, 'br');
6753
7760
  } else {
@@ -6897,9 +7904,10 @@
6897
7904
  }
6898
7905
 
6899
7906
  // Splits `blockEl` into two sibling BLOCKS at the caret via Range surgery —
6900
- // the same extractContents()-based pattern wrapRangeIn() above already uses,
7907
+ // the same extractContents()-based pattern extractRangeInto() below uses,
6901
7908
  // so inline formatting (a caret mid-<strong>, say) splits cleanly instead of
6902
- // being torn.
7909
+ // being torn, and sharing collectLeftovers()/dropEmptied() with it so the
7910
+ // split does not leave an emptied mark behind — see inside.
6903
7911
  //
6904
7912
  // The caret lives inside the block's own `<div class="ed-li-text">` surface,
6905
7913
  // so the tail range runs to the END OF THAT DIV and the new block gets a
@@ -6930,10 +7938,21 @@
6930
7938
  // Containment is checked BEFORE deleteContents() so the refusal below is a
6931
7939
  // true no-op rather than "the selection was deleted, then we gave up".
6932
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);
6933
7951
  if (!range.collapsed) range.deleteContents(); // collapses to the start point
6934
7952
  const tailRange = document.createRange();
6935
7953
  tailRange.setStart(range.startContainer, range.startOffset);
6936
7954
  tailRange.setEnd(textEl, textEl.childNodes.length);
7955
+ collectLeftovers(tailRange, textEl, leftovers);
6937
7956
  const tailFrag = tailRange.extractContents();
6938
7957
 
6939
7958
  const newBlock = document.createElement('div');
@@ -6958,6 +7977,12 @@
6958
7977
  newText.appendChild(tailFrag);
6959
7978
  newBlock.appendChild(newText);
6960
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);
6961
7986
  return newBlock;
6962
7987
  }
6963
7988
 
@@ -7465,6 +8490,18 @@
7465
8490
  // last-item case, where the answer must be an EMPTY span (serializes to
7466
8491
  // '', which is what takes commitListStructure()'s range-removal path).
7467
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.
7468
8505
  mutateListRun(() => removeListItem(li));
7469
8506
  // No `mutatedEl`: `li` is not IN `survivors`, and every block that is was
7470
8507
  // left exactly as the file has it.
@@ -7487,8 +8524,15 @@
7487
8524
  function handleLiKeydown(e, editEl) {
7488
8525
  if (e.key !== 'Enter' && e.key !== 'Tab') return false;
7489
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
+ }
7490
8533
  // Row 2: Shift+Enter is an in-block line break, not a structural change.
7491
8534
  if (e.key === 'Enter' && e.shiftKey) {
8535
+ snapBurstIfActive(editEl, 'br-pre');
7492
8536
  insertBrAtCaret();
7493
8537
  snapBurstIfActive(editEl, 'br');
7494
8538
  return true;
@@ -7521,6 +8565,16 @@
7521
8565
  // which is a complete no-op — nothing mutated, nothing committed, file
7522
8566
  // byte-identical.
7523
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.
7524
8578
  const changed = mutateListRun(() => {
7525
8579
  if (!(e.shiftKey ? outdentListItem(li) : indentListItem(li))) return false;
7526
8580
  // v3.0.1: `run` above is a PRE-mutation snapshot, captured while `li`
@@ -7583,6 +8637,9 @@
7583
8637
  // Row 3: one press = one outdent, with the SAME semantics as Shift+Tab
7584
8638
  // (adoption included). RULING F-Q: an item that OWNS a sublist takes this
7585
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.
7586
8643
  const outdented = mutateListRun(() => {
7587
8644
  if (!outdentListItem(li)) return false;
7588
8645
  // The surface can still hold things the user reads as "nothing" but a
@@ -7646,6 +8703,10 @@
7646
8703
  return true;
7647
8704
  }
7648
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.
7649
8710
  const newLi = mutateListRun(() => splitListItemAtCaret(li));
7650
8711
  if (!newLi) return true;
7651
8712
  run = listRunOf(newLi);
@@ -7695,6 +8756,7 @@
7695
8756
  // branch — so a burst-undo snapshot restore below reproduces a fully
7696
8757
  // re-armed table, not a plain static one).
7697
8758
  function startTableBurst(cellEl) {
8759
+ dropDiscardedBurst();
7698
8760
  const tableEl = cellEl.closest('table');
7699
8761
  const blockEl = tableEl && tableEl.closest('.ed-block');
7700
8762
  if (!blockEl) return;
@@ -7708,6 +8770,7 @@
7708
8770
  depth: null, original: burstBaselineHtml(tableEl), history,
7709
8771
  activeCellEl: cellEl,
7710
8772
  };
8773
+ watchBurstForDirtyDot(tableEl);
7711
8774
  selToolbarEditEl = cellEl;
7712
8775
  if (!selToolbarListener) {
7713
8776
  selToolbarListener = onSelectionChangeForToolbar;
@@ -7731,6 +8794,21 @@
7731
8794
  }
7732
8795
  const blockEl = tableEl.closest('.ed-block');
7733
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);
7734
8812
  if (switching) await switching;
7735
8813
  if (currentBurst && currentBurst.blockType === 'table' && currentBurst.blockEl &&
7736
8814
  currentBurst.blockEl.getAttribute('data-block-id') === blockId) {
@@ -7741,9 +8819,56 @@
7741
8819
  if (currentBurst) return; // a concurrent focusin (a DIFFERENT block) already won the race
7742
8820
  let liveCellEl = cellEl;
7743
8821
  if (!document.body.contains(cellEl)) {
7744
- const liveBlockEl = blockId != null ? document.querySelector('.ed-block[data-block-id="' + blockId + '"]') : null;
7745
- const liveTableEl = liveBlockEl ? blockContentEl(liveBlockEl) : null;
7746
- 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;
7747
8872
  if (!liveCellEl || !liveCellEl.classList.contains('ed-wys-cell')) return;
7748
8873
  liveCellEl.focus();
7749
8874
  return;
@@ -7751,13 +8876,123 @@
7751
8876
  startTableBurst(liveCellEl);
7752
8877
  }
7753
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.
7754
8900
  function moveActiveTableCell(cellEl, delta) {
7755
8901
  const tableEl = cellEl.closest('table');
7756
8902
  const cells = tableCellsOf(tableEl);
7757
- const idx = cells.indexOf(cellEl);
7758
- const next = Math.max(0, Math.min(cells.length - 1, idx + delta));
7759
- const target = cells[next];
7760
- 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);
7761
8996
  }
7762
8997
 
7763
8998
  // Esc inside a table burst: revert to the pre-focus baseline and end the
@@ -7783,15 +9018,25 @@
7783
9018
  // as revertBurstAndEnd() above.
7784
9019
  const wasPristineForThisBlock = !!(pristineInsert && pristineInsert.blockId === burst.blockId);
7785
9020
  if (wasPristineForThisBlock) pristineInsert = null;
7786
- 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();
7787
9026
  currentBurst = null;
7788
9027
  resetSelToolbarState();
7789
9028
  hideTableInsertBubbles();
7790
9029
  hideTableGrips();
7791
9030
  hideTableEdgeMenu();
7792
9031
  cancelTeDrag();
7793
- tableEl.innerHTML = burst.original;
7794
- 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
+ });
7795
9040
  }
7796
9041
 
7797
9042
  // Ctrl+Z inside a table burst: symmetric to burstUndo() above, but a
@@ -7891,6 +9136,7 @@
7891
9136
  if (!currentBurst || currentBurst.blockType !== 'table' || currentBurst.editEl !== tableEl) return;
7892
9137
  if (e.key === 'Enter') {
7893
9138
  e.preventDefault();
9139
+ snapBurstIfActive(tableEl, 'br-pre');
7894
9140
  insertBrAtCaret();
7895
9141
  snapBurstIfActive(tableEl, 'br');
7896
9142
  return;
@@ -8205,6 +9451,7 @@
8205
9451
  // click) — never the possibly-now-detached `tableEl` itself.
8206
9452
  const liveTableEl = await ensureTableBurstOpen(tableEl);
8207
9453
  if (!liveTableEl) return;
9454
+ currentBurst.history.snap('insert-col-pre');
8208
9455
  insertColumn(liveTableEl, colIndex);
8209
9456
  armNewTableCells(liveTableEl);
8210
9457
  currentBurst.history.snap('insert-col');
@@ -8218,11 +9465,25 @@
8218
9465
  const afterRowIndex = Number(rowInsertBubble.dataset.afterRowIndex); // -1 -> header (first body row)
8219
9466
  if (!tableEl || Number.isNaN(afterRowIndex)) return;
8220
9467
  // Final-review Finding 6: same live-table swap as onColInsertBubbleClick()
8221
- // above — `afterRowIndex` is a plain integer so it stays valid across
8222
- // 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.
8223
9483
  const liveTableEl = await ensureTableBurstOpen(tableEl);
8224
9484
  if (!liveTableEl) return;
8225
9485
  const afterRow = afterRowIndex >= 0 ? bodyRowsOf(liveTableEl)[afterRowIndex] : null;
9486
+ currentBurst.history.snap('insert-row-pre');
8226
9487
  insertRow(liveTableEl, afterRow);
8227
9488
  armNewTableCells(liveTableEl);
8228
9489
  currentBurst.history.snap('insert-row');
@@ -8242,7 +9503,52 @@
8242
9503
  // pointer (Event#target) at those coordinates.
8243
9504
  function updateTableInsertBubbles(x, y, target) {
8244
9505
  const blockEl = target && target.closest && target.closest('.ed-block[data-block-type="table"]');
8245
- 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
+ }
8246
9552
  if (!tableEl || !tableEl.classList.contains('ed-wys-table')) {
8247
9553
  hideTableInsertBubbles();
8248
9554
  hoveredInsertTableEl = null;
@@ -8337,8 +9643,11 @@
8337
9643
  // currently holds focus, so Chromium fires a synchronous focusout
8338
9644
  // mid-mutation — exactly the quirk tableBurstUndo()/tableBurstRedo()'s
8339
9645
  // innerHTML-snapshot restore has to guard. performRowDrop() therefore
8340
- // wraps it in `suppressTableFocusout` (the third of the flag's four sites)
8341
- // 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
8342
9651
  // revision moved the <tr> with a plain `insertBefore()`, which reparents
8343
9652
  // in one synchronous step, never blurs, and needed no guard — the header
8344
9653
  // promotion requirement is what retired that. The menu's delete ops (which
@@ -8347,9 +9656,17 @@
8347
9656
  //
8348
9657
  // Both the menu (delete/align) and the drag's DOM move are burst
8349
9658
  // mutations: ensureTableBurstOpen() (Task 5, above) auto-starts a burst
8350
- // on this table if none is open yet, then the op mutates the live DOM and
8351
- // calls currentBurst.history.snap() — committed on table-leave like any
8352
- // 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
8353
9670
  // Phase-2 click-select edit toolbar used for these same three guards (see
8354
9671
  // commit 0661cde, now dead code with zero call sites — this task is what
8355
9672
  // revives deleteRow()/deleteColumn()/cycleColumnAlign() unchanged, per
@@ -8478,6 +9795,35 @@
8478
9795
  teEdgeMenu.hidden = false;
8479
9796
  }
8480
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
+
8481
9827
  function showColumnMenu(tableEl, colIndex) {
8482
9828
  if (teMenuKind === 'col' && teMenuTableEl === tableEl && teMenuColIndex === colIndex) {
8483
9829
  hideTableEdgeMenu(); // re-clicking the same column's edge toggles the menu closed
@@ -8494,7 +9840,15 @@
8494
9840
  const cell = headerRow ? headerRow.cells[colIndex] : null;
8495
9841
  const r = (cell || tableEl).getBoundingClientRect();
8496
9842
  positionTeMenu(r.left, r.top);
8497
- 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);
8498
9852
  }
8499
9853
 
8500
9854
  function showRowMenu(tableEl, rowEl) {
@@ -8513,6 +9867,20 @@
8513
9867
  const tableRect = tableEl.getBoundingClientRect();
8514
9868
  positionTeMenu(tableRect.left, r.top);
8515
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
+ }
8516
9884
  }
8517
9885
 
8518
9886
  // Moves `currentBurst.activeCellEl` to a cell that will SURVIVE deleting
@@ -8568,6 +9936,7 @@
8568
9936
  showBanner('無法刪除最後一欄', null, null);
8569
9937
  return;
8570
9938
  }
9939
+ currentBurst.history.snap('delete-col-pre');
8571
9940
  refocusAwayFromColumn(liveTableEl, colIndex);
8572
9941
  deleteColumn(liveTableEl, colIndex);
8573
9942
  currentBurst.history.snap('delete-col');
@@ -8599,6 +9968,7 @@
8599
9968
  showBanner('無法刪除最後一列', null, null);
8600
9969
  return;
8601
9970
  }
9971
+ currentBurst.history.snap('delete-row-pre');
8602
9972
  refocusAwayFromRow(liveTableEl, liveRowEl);
8603
9973
  deleteRow(liveRowEl);
8604
9974
  currentBurst.history.snap('delete-row');
@@ -8636,6 +10006,7 @@
8636
10006
  try {
8637
10007
  liveTableEl = await ensureTableBurstOpen(tableEl);
8638
10008
  if (!liveTableEl) { suppressEdgeMenuAutoHide = false; return; }
10009
+ currentBurst.history.snap('align-col-pre');
8639
10010
  cycleColumnAlign(liveTableEl, colIndex);
8640
10011
  currentBurst.history.snap('align-col');
8641
10012
  } catch (e) {
@@ -8685,7 +10056,7 @@
8685
10056
  if (cell) {
8686
10057
  const r = cell.getBoundingClientRect();
8687
10058
  positionTeMenu(r.left, r.top);
8688
- teEdgeMenu.style.top = (r.top - teEdgeMenu.offsetHeight - TE_MENU_GAP_PX) + 'px';
10059
+ placeTeMenuAboveCell(r);
8689
10060
  }
8690
10061
  });
8691
10062
  }
@@ -9018,6 +10389,16 @@
9018
10389
  // whatever was last shown untouched instead; hideTableGrips() (called
9019
10390
  // from table-leave, burst-end, and drag-start elsewhere) already covers
9020
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.
9021
10402
  if (target && target.closest && (target.closest('.ed-te-grip-row') || target.closest('.ed-te-grip-col'))) {
9022
10403
  // v3.0.2: only the ROW grip is the one that covers the ⠿'s approach.
9023
10404
  // Testing the hit rather than reusing the branch condition matters —
@@ -9364,6 +10745,7 @@
9364
10745
  order.splice(toIndex > rowIndex ? toIndex - 1 : toIndex, 0, moved);
9365
10746
  if (order.every((row, i) => row === liveRows[i])) return; // 原地放回:不動 DOM、不 snap
9366
10747
 
10748
+ currentBurst.history.snap('drag-row-pre');
9367
10749
  const aligns = columnAlignsOf(liveTableEl);
9368
10750
  const activeIndex = (currentBurst && currentBurst.activeCellEl)
9369
10751
  ? tableCellsOf(liveTableEl).indexOf(currentBurst.activeCellEl) : -1;
@@ -9421,6 +10803,7 @@
9421
10803
  // 看不出任何異常。寧可整個不動。
9422
10804
  const dropRows = allRowsOf(liveTableEl);
9423
10805
  if (!dropRows.length || dropRows.some((row) => !row.cells[fromIndex])) return;
10806
+ currentBurst.history.snap('drag-col-pre');
9424
10807
  suppressTableFocusout = true;
9425
10808
  try {
9426
10809
  dropRows.forEach((row) => {
@@ -10508,6 +11891,60 @@
10508
11891
  // is active, so this always names whatever element the toolbar's mark
10509
11892
  // toggles should act on right now.
10510
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;
10511
11948
  // The currently-attached onSelectionChangeForToolbar function reference (or
10512
11949
  // null) — kept at this module scope, NOT just inside openWysiwygEditor()'s
10513
11950
  // closure, specifically so a call site OUTSIDE that closure (rerenderAll()
@@ -10528,6 +11965,13 @@
10528
11965
  }
10529
11966
  hideSelToolbar();
10530
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;
10531
11975
  }
10532
11976
 
10533
11977
  // Shows/repositions/hides the floating selection toolbar as the selection
@@ -10569,6 +12013,37 @@
10569
12013
  hideSelToolbar();
10570
12014
  return;
10571
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
+ }
10572
12047
  positionSelToolbar(range);
10573
12048
  } finally {
10574
12049
  // In a `finally` so every early return above still refreshes the bar —
@@ -10618,13 +12093,29 @@
10618
12093
  return r;
10619
12094
  }
10620
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
+
10621
12114
  // Wraps the range's contents in a brand-new `<tag>` element via Range
10622
12115
  // surgery (extractContents/insertNode — plain DOM Range methods, NOT
10623
12116
  // execCommand). Returns a Range spanning the new element's contents.
10624
- function wrapRangeIn(range, tag) {
10625
- const el = document.createElement(tag.toLowerCase());
10626
- el.appendChild(range.extractContents());
10627
- range.insertNode(el);
12117
+ function wrapRangeIn(range, tag, root) {
12118
+ const el = extractRangeInto(range, document.createElement(tag.toLowerCase()), root);
10628
12119
  const r = document.createRange();
10629
12120
  r.selectNodeContents(el);
10630
12121
  return r;
@@ -10728,12 +12219,14 @@
10728
12219
 
10729
12220
  const whole = wholeSelectionMark(range, tag, root);
10730
12221
  if (whole) {
12222
+ snapBurstIfActive(root, 'mark-pre');
10731
12223
  reselectAndReposition(unwrapElement(whole));
10732
12224
  snapBurstIfActive(root, 'mark');
10733
12225
  return;
10734
12226
  }
10735
12227
  const overlapping = overlappingMarks(range, tag, root);
10736
12228
  if (overlapping.length > 0) {
12229
+ snapBurstIfActive(root, 'mark-pre');
10737
12230
  const extended = extendRangeOverMarks(range, overlapping);
10738
12231
  // Re-query against the EXTENDED range: `overlapping` above was
10739
12232
  // computed from the original (smaller) range, and every mark the
@@ -10744,7 +12237,8 @@
10744
12237
  return;
10745
12238
  }
10746
12239
  if (!trimRangeToText(range)) return; // 全空白/collapsed 選取:no-op
10747
- reselectAndReposition(wrapRangeIn(range, tag));
12240
+ snapBurstIfActive(root, 'mark-pre');
12241
+ reselectAndReposition(wrapRangeIn(range, tag, root));
10748
12242
  snapBurstIfActive(root, 'mark');
10749
12243
  }
10750
12244
 
@@ -10927,6 +12421,7 @@
10927
12421
  if (whole) {
10928
12422
  const url = window.prompt('連結網址(留空以移除連結):', whole.getAttribute('href') || '');
10929
12423
  if (url === null) return true; // cancelled — leave the link untouched
12424
+ snapBurstIfActive(root, 'mark-pre');
10930
12425
  if (url.trim() === '') {
10931
12426
  reselectAndReposition(unwrapElement(whole));
10932
12427
  } else {
@@ -10938,6 +12433,7 @@
10938
12433
  }
10939
12434
  const overlapping = overlappingMarks(range, 'A', root);
10940
12435
  if (overlapping.length > 0) {
12436
+ snapBurstIfActive(root, 'mark-pre');
10941
12437
  const extended = extendRangeOverMarks(range, overlapping);
10942
12438
  overlappingMarks(extended, 'A', root).forEach((m) => unwrapElement(m));
10943
12439
  reselectAndReposition(extended);
@@ -10946,10 +12442,10 @@
10946
12442
  }
10947
12443
  const url = window.prompt('連結網址:', 'https://');
10948
12444
  if (url === null || url.trim() === '') return true; // cancelled or empty — no-op
12445
+ snapBurstIfActive(root, 'mark-pre');
10949
12446
  const el = document.createElement('a');
10950
12447
  el.setAttribute('href', url.trim());
10951
- el.appendChild(range.extractContents());
10952
- range.insertNode(el);
12448
+ extractRangeInto(range, el, root);
10953
12449
  const r = document.createRange();
10954
12450
  r.selectNodeContents(el);
10955
12451
  reselectAndReposition(r);
@@ -10957,15 +12453,21 @@
10957
12453
  return true;
10958
12454
  }
10959
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
+
10960
12460
  function buildSelToolbar() {
10961
12461
  const el = document.createElement('div');
10962
12462
  el.className = 'ed-seltb';
10963
- function addBtn(cls, label, ariaLabel, onClick) {
12463
+ function addBtn(cls, label, ariaLabel, tag, onClick) {
10964
12464
  const b = document.createElement('button');
10965
12465
  b.type = 'button';
10966
12466
  b.className = 'ed-seltb-btn ' + cls;
10967
12467
  b.textContent = label;
10968
12468
  b.setAttribute('aria-label', ariaLabel);
12469
+ b.setAttribute('aria-pressed', 'false');
12470
+ selToolbarMarkBtns.push({ btn: b, tag: tag });
10969
12471
  // Keep the DOM selection intact across the click: without this, the
10970
12472
  // button (outside the contenteditable root) stealing focus on
10971
12473
  // mousedown would collapse the selection before the click handler
@@ -10975,15 +12477,53 @@
10975
12477
  el.appendChild(b);
10976
12478
  return b;
10977
12479
  }
10978
- addBtn('ed-seltb-b', 'B', 'Bold', () => applyMarkToggle('STRONG'));
10979
- addBtn('ed-seltb-i', 'I', 'Italic', () => applyMarkToggle('EM'));
10980
- addBtn('ed-seltb-s', 'S', '刪除線', () => applyMarkToggle('DEL'));
10981
- addBtn('ed-seltb-u', 'U', '底線', () => applyMarkToggle('U'));
10982
- addBtn('ed-seltb-code', '<>', 'Code', () => applyMarkToggle('CODE'));
10983
- 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());
10984
12486
  return el;
10985
12487
  }
10986
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
+
10987
12527
  const selToolbar = buildSelToolbar();
10988
12528
 
10989
12529
  function hideSelToolbar() {
@@ -11003,9 +12543,17 @@
11003
12543
  .getPropertyValue('--ed-toolbar-h')) || 0;
11004
12544
  if (rect.bottom <= topInset || rect.top >= window.innerHeight ||
11005
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;
11006
12553
  hideSelToolbar(); return;
11007
12554
  }
11008
12555
  if (!selToolbar.parentNode) document.body.appendChild(selToolbar);
12556
+ selToolbarOffViewport = false;
11009
12557
  const gap = 8, margin = 4;
11010
12558
  const tbRect = selToolbar.getBoundingClientRect();
11011
12559
  let top = rect.top - tbRect.height - gap;
@@ -11047,9 +12595,13 @@
11047
12595
  // handler's async switchAwayFrom()->resolveBurst()->rerenderAll()
11048
12596
  // commit chain BEFORE the click event that opens the menu ever fires.
11049
12597
  // At human click speed (a real, non-zero gap between mousedown and
11050
- // mouseup) that commit's /api/render round trip can finish and swap
11051
- // `.content` — detaching THIS very button — before mouseup, and a
11052
- // 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
11053
12605
  // document between mousedown and mouseup: the first click is silently
11054
12606
  // eaten (no menu opens), and only a second click on the fresh,
11055
12607
  // re-armed handle actually works. preventDefault() here stops the
@@ -11058,9 +12610,53 @@
11058
12610
  // §10-gap fix: same reasoning as the ⠿ handle above, for the + insert
11059
12611
  // button — a mousedown-triggered blur on a dirty burst elsewhere would
11060
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.
11061
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;
11062
12656
  if (e.target && e.target.closest &&
11063
- (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();
11064
12660
  });
11065
12661
  document.addEventListener('click', async (e) => {
11066
12662
  // S3 Task 4: the trailing click of a gesture that already answered this
@@ -11269,6 +12865,12 @@
11269
12865
 
11270
12866
  // ── undo / redo ───────────────────────────────────────────────────────
11271
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;
11272
12874
  // Resolve any open editor BEFORE the .content-replacing swap below —
11273
12875
  // see the `activeEditor` / switchAwayFrom() comments near its
11274
12876
  // declaration for why this is required (undo/redo silently detaching an
@@ -11385,8 +12987,19 @@
11385
12987
  // The buttons whose model state can ever report `active: true` — i.e. the
11386
12988
  // real toggles, and the only ones that may carry aria-pressed. Every other
11387
12989
  // button is a command, not a toggle.
11388
- // v3.2.0: derived, not mirrored. A seventh toggle added to toolbar-model.js
11389
- // used to need a matching edit here, and forgetting it made aria-pressed lie.
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`.
11390
13003
  const TOOLBAR_TOGGLE_IDS = new Set(
11391
13004
  (toolbarModel ? toolbarModel.BUTTONS : []).filter((b) => b.toggle).map((b) => b.id));
11392
13005
 
@@ -11454,6 +13067,67 @@
11454
13067
  selToolbarEditEl.contains(range.endContainer);
11455
13068
  }
11456
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
+
11457
13131
  // ctx for toolbarModel.deriveState(). Every field is read off the DOM /
11458
13132
  // `lines` at call time; nothing is cached, so a stale value cannot outlive
11459
13133
  // a render.
@@ -11476,13 +13150,25 @@
11476
13150
  // hasFormattableSelection() (just above) is their refusal test spelled
11477
13151
  // once, rather than a looser "is anything selected anywhere on the page".
11478
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),
11479
13157
  mode: docMode,
11480
13158
  };
11481
13159
  }
11482
13160
 
11483
13161
  function updateToolbar() {
11484
13162
  if (!toolbarEl || !toolbarBtns || !toolbarModel) return;
11485
- 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);
11486
13172
  // selectionchange fires on every caret move, so the state is diffed
11487
13173
  // before any DOM is written — 22 attribute writes per keystroke is a
11488
13174
  // cost with no matching benefit.
@@ -11499,7 +13185,12 @@
11499
13185
  // and `table` as toggle buttons stuck in the "not pressed" state,
11500
13186
  // which is a lie about what they do.
11501
13187
  if (TOOLBAR_TOGGLE_IDS.has(id)) {
11502
- 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'));
11503
13194
  }
11504
13195
  // `headings` (H / H1..H6) and, since v3.2.1, the mode button carry a
11505
13196
  // derived label; every other button keeps the glyph it was built with.
@@ -11513,6 +13204,187 @@
11513
13204
  btn.setAttribute('aria-label', st.title);
11514
13205
  }
11515
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');
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);
11516
13388
  }
11517
13389
 
11518
13390
  function buildToolbar() {
@@ -11649,6 +13521,10 @@
11649
13521
  }
11650
13522
 
11651
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.
11652
13528
  function closeToolbarMenu() {
11653
13529
  if (toolbarMenuEl) { toolbarMenuEl.remove(); toolbarMenuEl = null; }
11654
13530
  }
@@ -11673,8 +13549,21 @@
11673
13549
  });
11674
13550
  document.body.appendChild(menu);
11675
13551
  const r = btnEl.getBoundingClientRect();
11676
- menu.style.top = (r.bottom + 4) + 'px';
13552
+ const top = r.bottom + 4;
13553
+ menu.style.top = top + 'px';
11677
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
+ }
11678
13567
  toolbarMenuEl = menu;
11679
13568
  }
11680
13569
 
@@ -11748,7 +13637,7 @@
11748
13637
  // A block that owns no source line has an inverted range, and there is
11749
13638
  // no line to splice in front of.
11750
13639
  if (!rec || rec.endLine < rec.startLine) {
11751
- refuseStructuralListEdit(NO_SOURCE_LINE_INSERT_MESSAGE);
13640
+ refuseStructuralListEdit(noSourceLineInsertMessageFor(liveBlockEl));
11752
13641
  return;
11753
13642
  }
11754
13643
  const at = rec.startLine;
@@ -12124,6 +14013,7 @@
12124
14013
  const md = String(payload.value == null ? '' : payload.value).replace(/\r\n?/g, '\n');
12125
14014
  if (md.trim() === '') return;
12126
14015
  if (isInlineSafePaste(md)) {
14016
+ snapBurstIfActive(editEl, 'paste-pre');
12127
14017
  insertTextAtCaret(md);
12128
14018
  snapBurstIfActive(editEl, 'paste');
12129
14019
  return;
@@ -12136,6 +14026,7 @@
12136
14026
  insertBlockBelow(editEl.closest('.ed-block'), 'paragraph', { lines: md.split('\n') });
12137
14027
  return;
12138
14028
  }
14029
+ snapBurstIfActive(editEl, 'paste-pre');
12139
14030
  insertTextAtCaret(payload.value || '');
12140
14031
  snapBurstIfActive(editEl, 'paste');
12141
14032
  }
@@ -12256,6 +14147,16 @@
12256
14147
  if (!e.target || !e.target.closest) return;
12257
14148
  if (e.target.closest('.ed-toolbar') || e.target.closest('.ed-toolbar-menu')) return;
12258
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();
12259
14160
  const blockEl = e.target.closest('.ed-block');
12260
14161
  if (blockEl) toolbarBlockEl = blockEl;
12261
14162
  updateToolbar();
@@ -12282,16 +14183,110 @@
12282
14183
  // this reason: the conflict banner, the selection toolbar, the table
12283
14184
  // grips and the table edge menu.
12284
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);
12285
14201
  document.body.setAttribute('data-ed-mode', docMode);
12286
14202
  paintModeStatus();
12287
14203
  wireToolbarTracking();
12288
14204
  wireImageDrop();
12289
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.
12290
14211
  updateToolbar();
12291
14212
  }
12292
14213
 
12293
14214
  // ── global key handling ─────────────────────────────────────────────────
12294
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
+
12295
14290
  // ── S3 §4.4: Escape priority is drag > menu > selection > burst ─────
12296
14291
  // A new ordered PROLOGUE, not a reshuffle of what follows (§4.4 is
12297
14292
  // explicit about that). The two target-based short-circuits further down
@@ -12354,6 +14349,11 @@
12354
14349
  closeInsertMenu();
12355
14350
  return;
12356
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; }
12357
14357
  if (blockSelection) { e.preventDefault(); clearBlockSelection(); return; }
12358
14358
  // Nothing above owns this Escape — fall through to the burst
12359
14359
  // short-circuits below, where Escape-reverts-the-burst still belongs.
@@ -12643,18 +14643,121 @@
12643
14643
  switchAwayFrom();
12644
14644
  });
12645
14645
 
12646
- document.addEventListener('input', (e) => {
12647
- 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');
12648
14692
  if (cellEl) {
12649
14693
  const tableEl = cellEl.closest('table');
12650
14694
  if (currentBurst && currentBurst.blockType === 'table' && currentBurst.editEl === tableEl) {
12651
- currentBurst.history.noteTyping();
14695
+ return currentBurst.history;
12652
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');
12653
14758
  return;
12654
14759
  }
12655
- const editEl = e.target && e.target.closest && e.target.closest('.ed-wys-armed');
12656
- if (!editEl || !currentBurst || currentBurst.editEl !== editEl) return;
12657
- currentBurst.history.noteTyping();
14760
+ history.noteTyping();
12658
14761
  });
12659
14762
 
12660
14763
  document.addEventListener('paste', (e) => {
@@ -12662,6 +14765,7 @@
12662
14765
  if (cellEl) {
12663
14766
  e.preventDefault();
12664
14767
  const dt = e.clipboardData || window.clipboardData;
14768
+ snapBurstIfActive(cellEl.closest('table'), 'paste-pre');
12665
14769
  insertTextAtCaret(dt ? dt.getData('text/plain') : '');
12666
14770
  snapBurstIfActive(cellEl.closest('table'), 'paste');
12667
14771
  return;
@@ -12786,20 +14890,47 @@
12786
14890
  if (selScrollRaf) return;
12787
14891
  selScrollRaf = requestAnimationFrame(() => {
12788
14892
  selScrollRaf = 0;
12789
- if (!selToolbar.parentNode) return; // 沒升起 → 0 次 layout read
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;
12790
14904
  const sel = window.getSelection();
12791
14905
  if (!sel || !sel.rangeCount || sel.isCollapsed) { hideSelToolbar(); return; }
12792
14906
  const range = sel.getRangeAt(0);
12793
14907
  if (!selToolbarEditEl ||
12794
14908
  !selToolbarEditEl.contains(range.startContainer) ||
12795
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; }
12796
14927
  positionSelToolbar(range);
12797
14928
  });
12798
14929
  }
12799
14930
  document.addEventListener('scroll', onAnyScroll, { passive: true, capture: true });
12800
14931
 
12801
14932
  window.addEventListener('beforeunload', (e) => {
12802
- if (stack.dirtyDepth !== 0) {
14933
+ if (stack.dirtyDepth !== 0 || burstHasUncommittedEdit()) {
12803
14934
  e.preventDefault();
12804
14935
  e.returnValue = '';
12805
14936
  return '';