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