@helping-ai-workflow/md2doc 3.2.1 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/editor/blockmap.js +146 -11
- package/lib/editor/cli.js +50 -1
- package/lib/editor/client.js +2236 -105
- package/lib/editor/server.js +75 -3
- package/lib/editor/toolbar-model.js +75 -14
- package/lib/md2doc.js +199 -20
- package/package.json +1 -1
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,
|
|
@@ -1158,7 +1534,7 @@
|
|
|
1158
1534
|
// 所以在 `burstSurvived` 那裡也留了一行對照註解。不搬 `burstSurvived`
|
|
1159
1535
|
// 到還原之前,是刻意的取捨:搬了會讓剛開的 burst 先被 dispose 再由收尾
|
|
1160
1536
|
// 重開一次,並把每次 undo 的工具列 degrade 原封不動地留著。
|
|
1161
|
-
// teardown
|
|
1537
|
+
// teardown 各項彼此的相對順序沒有被動到,條件式的 activeEditor 歸零
|
|
1162
1538
|
// 仍然在 DOM 手術之後求值。
|
|
1163
1539
|
const newLen = plan.replaceSpan.newParts.length;
|
|
1164
1540
|
if (newLen > 0) {
|
|
@@ -1182,7 +1558,7 @@
|
|
|
1182
1558
|
}
|
|
1183
1559
|
|
|
1184
1560
|
// ── teardown ────────────────────────────────────────────────────────
|
|
1185
|
-
// Controller ruling F1: the patch path runs the SAME
|
|
1561
|
+
// Controller ruling F1: the patch path runs the SAME teardown items
|
|
1186
1562
|
// applyFullRender() runs, unconditionally and in the same relative order.
|
|
1187
1563
|
// The rationale comments below are copied from applyFullRender() because
|
|
1188
1564
|
// they document WHY each one exists; the one thing that differs is the
|
|
@@ -1682,6 +2058,183 @@
|
|
|
1682
2058
|
ta.style.height = (ta.scrollHeight + 2) + 'px';
|
|
1683
2059
|
}
|
|
1684
2060
|
|
|
2061
|
+
// F9 —— raw 原始碼編輯器開啟時的 textarea 種子與 caret 落點。
|
|
2062
|
+
//
|
|
2063
|
+
// openRawEditor() 以前收尾一律是
|
|
2064
|
+
// `ta.setSelectionRange(ta.value.length, ta.value.length)`,每一種區塊都落
|
|
2065
|
+
// 在值的結尾。對 fenced code 那個位置在【收尾 fence 之後】。MEASURED
|
|
2066
|
+
// (test/editor-journey.test.js 的 F9 那一列,fixture
|
|
2067
|
+
// '# Doc\n\nAlpha.\n\n```js\nconst a = 1;\n```\n\nBravo.\n',用真的滑鼠
|
|
2068
|
+
// 按壓點進 code block):修之前 selectionStart = selectionEnd = 22,而值是
|
|
2069
|
+
// '```js\nconst a = 1;\n```',長度正是 22 —— caret 就在收尾那串反引號
|
|
2070
|
+
// 之後,打一個字進去,收尾那一行變成 '```Z',圍欄不再閉合。
|
|
2071
|
+
//
|
|
2072
|
+
// 這支回 `{ value, caret }`:`value` 是要放進 textarea 的字串(通常就是傳
|
|
2073
|
+
// 進來的原始碼;只有下面 b/b' 那些形狀會被動到),`caret` 是 offset。
|
|
2074
|
+
//
|
|
2075
|
+
// 規則不是「caret 永遠不會落在圍欄標記上」—— 那句話是假的,見下面的例外。
|
|
2076
|
+
// 規則是:**只要來源裡存在一個不在圍欄標記上的落點,就選它;空圍欄則種一行
|
|
2077
|
+
// 本文出來給它落**。
|
|
2078
|
+
//
|
|
2079
|
+
// a) 第一行是一道開頭圍欄(fenceOpenerOf())→ caret 落在下一行行首,那是
|
|
2080
|
+
// 第一個位在程式碼本文裡的 offset。
|
|
2081
|
+
// b) 【空的圍欄】(a 的次分類:下一行就是它的【配對】收尾圍欄)→ 本文區一
|
|
2082
|
+
// 行都沒有,兩端的候選位置都在圍欄標記上,**原始碼裡沒有安全的 offset**。
|
|
2083
|
+
// 所以在開頭與收尾圍欄之間種一行空白進 `value`,caret 落在那一行。
|
|
2084
|
+
// b') 同樣的空圍欄長在一段跨區塊 span 的【尾端】(⠿ 選單的「MD 原始碼」框
|
|
2085
|
+
// 得出來,例如 'Alpha.\n\n```js\n```')→ 同樣種一行。
|
|
2086
|
+
// c) 最後一行是某道圍欄的【配對】收尾、而第一行不是開頭圍欄 → 結尾落點會
|
|
2087
|
+
// 落在那道收尾圍欄上,改落在前一行的行尾。
|
|
2088
|
+
// 其餘一律維持結尾落點。
|
|
2089
|
+
//
|
|
2090
|
+
// caret 仍然會落在一行【長得像圍欄】的行上的情形(掃過一批來源形狀量到的 ——
|
|
2091
|
+
// 段落/引用/表格/清單項/html/縮排式 code/各種圍欄與跨區塊 span —— 這些
|
|
2092
|
+
// 情形都不是破壞):
|
|
2093
|
+
// - 來源只有一行,而那一行是一道孤零零的開頭圍欄 '```js'(⠿ 對一個只有開
|
|
2094
|
+
// 頭圍欄的 code 區塊就會給出這個)。沒有別的行可選;在那裡打字得到
|
|
2095
|
+
// '```jsX',仍然是一道開頭圍欄(info string 從 js 變成 jsX),區塊的收尾
|
|
2096
|
+
// 狀態不變。
|
|
2097
|
+
// - 來源尾端那一行長得像圍欄,但在這段來源裡【不收掉任何圍欄】,例如
|
|
2098
|
+
// 'Alpha.\n\n```js\ncode\n~~~':那道 ```js 沒有被收掉,'~~~' 是它的本文。
|
|
2099
|
+
// 在那裡打字只是改本文。
|
|
2100
|
+
// - a 分支落到的那一行【本身長得像圍欄】,因為它是外層圍欄的本文 —— 巢狀
|
|
2101
|
+
// 圍欄 '~~~\n```\nx\n```\n~~~' 的 caret 就落在第二行那個 ``` 的行首
|
|
2102
|
+
// (test/editor-journey.test.js 的巢狀圍欄那一列釘的正是這個落點)。在那裡
|
|
2103
|
+
// 打字得到 'Z```',那一行仍然只是外層圍欄的本文,內外圍欄的配對都不變。
|
|
2104
|
+
//
|
|
2105
|
+
// b 分支不會讓文件變髒:openRawEditor() 的 `hasChanges()` 比對的是這支回
|
|
2106
|
+
// 的 `value`(不是檔案裡那段 `source`),所以剛開起來的 textarea 仍然算
|
|
2107
|
+
// 「沒有變更」,關掉走的是 restore()/cancelAndMaybeDiscard() 這條不寫檔的
|
|
2108
|
+
// 路。`source` 本身留給 reanchor() 用,它比的是檔案的真實位元組。
|
|
2109
|
+
//
|
|
2110
|
+
// 刻意【沒有】照 task-5 brief 一併把 blockquote/image 改成「第一行行首」。
|
|
2111
|
+
// MEASURED(lib/editor/blockmap.js 的 buildBlockMap):
|
|
2112
|
+
// '> line one\n> line two' -> blockquote
|
|
2113
|
+
// 'X> line one\n> line two' -> paragraph, blockquote ← 行首落點
|
|
2114
|
+
// '> line one\n> line twoX' -> blockquote ← 結尾落點
|
|
2115
|
+
// 對 blockquote 而言「第一行行首」在 '>' 的前面,打第一個字就把那一行踢出
|
|
2116
|
+
// 引用區塊,比它要取代的結尾落點更糟。image 打在頭尾都等價('X' 與
|
|
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
|
+
|
|
1685
2238
|
// v3.1.0 修正 4: `range` ({startLine, endLine}, 1-indexed inclusive) opens
|
|
1686
2239
|
// the raw editor over an EXPLICIT span of source lines instead of the
|
|
1687
2240
|
// block's own record. Two callers need it and one property makes both
|
|
@@ -1764,7 +2317,7 @@
|
|
|
1764
2317
|
// delegator so
|
|
1765
2318
|
// every caller — the delegated degraded-block click, openRawViaGutter(),
|
|
1766
2319
|
// the burst-degrade path — is closed by one check.
|
|
1767
|
-
if (blockOwnsNoLine(blockEl)) { refuseStructuralListEdit(
|
|
2320
|
+
if (blockOwnsNoLine(blockEl)) { refuseStructuralListEdit(noSourceLineMessageFor(blockEl)); return; }
|
|
1768
2321
|
|
|
1769
2322
|
// v3.1.0 修正 4: the span this editor owns. Without `range` it is exactly
|
|
1770
2323
|
// the block's own record, so `source` below is byte-identical to what
|
|
@@ -1776,7 +2329,7 @@
|
|
|
1776
2329
|
// The same inverted-range refusal blockOwnsNoLine() just made for the
|
|
1777
2330
|
// block, re-asked of the range actually being edited: a caller-supplied
|
|
1778
2331
|
// span can be malformed even when the anchor block is fine.
|
|
1779
|
-
if (endLine < startLine) { refuseStructuralListEdit(
|
|
2332
|
+
if (endLine < startLine) { refuseStructuralListEdit(noSourceLineMessageFor(blockEl)); return; }
|
|
1780
2333
|
// A list item's rendered chrome (the .ed-li-marker span, the optional
|
|
1781
2334
|
// .ed-li-check span, the .ed-li-text surface) is REBUILT BY THE RENDERER
|
|
1782
2335
|
// from the source line's marker — it is not recoverable from a captured
|
|
@@ -1798,7 +2351,11 @@
|
|
|
1798
2351
|
|
|
1799
2352
|
const ta = document.createElement('textarea');
|
|
1800
2353
|
ta.className = 'ed-raw';
|
|
1801
|
-
|
|
2354
|
+
// F9: `seed.value` 通常 === `source`;只有空圍欄那一種形狀會多一行
|
|
2355
|
+
// 空白本文(見 rawEditorSeed())。`source` 不可以跟著換掉 ——
|
|
2356
|
+
// reanchor() 拿它跟檔案裡的位元組逐字比。
|
|
2357
|
+
const seed = rawEditorSeed(source);
|
|
2358
|
+
ta.value = seed.value;
|
|
1802
2359
|
|
|
1803
2360
|
// `restore`/`commit` are hoisted function declarations (defined further
|
|
1804
2361
|
// down in this closure) — referencing them here, before their textual
|
|
@@ -1810,7 +2367,10 @@
|
|
|
1810
2367
|
// commit()/restore() closures used by the manual Ctrl+Enter/✓/Esc/✕ UI.
|
|
1811
2368
|
activeEditor = {
|
|
1812
2369
|
blockEl,
|
|
1813
|
-
|
|
2370
|
+
// F9: 比對 `seed.value` 而不是 `source` —— 空圍欄那條路上種進去的那
|
|
2371
|
+
// 一行空白是這個編輯器自己放的,不是使用者打的,把它算成「有變更」
|
|
2372
|
+
// 會讓「開起來看一眼就關掉」變成一次靜默的寫檔。
|
|
2373
|
+
hasChanges: () => ta.value !== seed.value,
|
|
1814
2374
|
commitNow: commit,
|
|
1815
2375
|
cancelNow: cancelAndMaybeDiscard,
|
|
1816
2376
|
reanchor,
|
|
@@ -1836,7 +2396,8 @@
|
|
|
1836
2396
|
blockEl.appendChild(wrap);
|
|
1837
2397
|
autoSize(ta);
|
|
1838
2398
|
ta.focus();
|
|
1839
|
-
|
|
2399
|
+
// F9: 不再無條件落在值的結尾 —— 見 rawEditorSeed() 自己的註解。
|
|
2400
|
+
ta.setSelectionRange(seed.caret, seed.caret);
|
|
1840
2401
|
|
|
1841
2402
|
async function restore() {
|
|
1842
2403
|
// v3.1.0 修正 4: `rangeMode` cannot restore by rewriting innerHTML —
|
|
@@ -2111,7 +2672,8 @@
|
|
|
2111
2672
|
// '# D\n\n- a\n\n- - b\n' --type Z--> '# D\n\n- a\n\n- Z\n- - b\n'
|
|
2112
2673
|
//
|
|
2113
2674
|
// Refusing here closes every such path with one rule, instead of teaching
|
|
2114
|
-
//
|
|
2675
|
+
// each commit helper that reaches ops.replaceLines() (grep
|
|
2676
|
+
// `ops.replaceLines(`) about a range shape none of them expect. The block is
|
|
2115
2677
|
// still rendered, still selectable, and its CHILD — which does own its line —
|
|
2116
2678
|
// stays fully editable.
|
|
2117
2679
|
function blockOwnsNoLine(blockEl) {
|
|
@@ -2121,6 +2683,47 @@
|
|
|
2121
2683
|
return !!rec && rec.endLine < rec.startLine;
|
|
2122
2684
|
}
|
|
2123
2685
|
|
|
2686
|
+
// Two DIFFERENT things now arrive with an empty range, and blockOwnsNoLine()
|
|
2687
|
+
// cannot tell them apart because the range shape is identical:
|
|
2688
|
+
//
|
|
2689
|
+
// '- - a' the outer item genuinely owns no line — its content begins
|
|
2690
|
+
// with its child. A modelled, legitimate shape.
|
|
2691
|
+
// degraded blockmap.js could not LOCATE this item's nested list inside
|
|
2692
|
+
// the item's own text, so it emptied the whole subtree rather
|
|
2693
|
+
// than hand out line numbers it had guessed. The item owns a
|
|
2694
|
+
// line; nobody can say which.
|
|
2695
|
+
//
|
|
2696
|
+
// Answering the second with the first's wording ('沒有自己的來源行') would
|
|
2697
|
+
// tell the user something untrue about their document — that item HAS a
|
|
2698
|
+
// source line. So the flag blockmap.js sets on the degraded records is read
|
|
2699
|
+
// here, instead of re-deriving a cause from `endLine < startLine`, which
|
|
2700
|
+
// carries none.
|
|
2701
|
+
//
|
|
2702
|
+
// No markdown MEASURED so far reaches the second branch: every string a full
|
|
2703
|
+
// journey run hands buildBlockMap(), and every trailing-space variant of
|
|
2704
|
+
// those, produced no `unlocatable` records. So the branch is driven
|
|
2705
|
+
// instead by making the real blockmap search fail — grep
|
|
2706
|
+
// test/editor-journey.test.js for 'already degraded', which wraps marked's
|
|
2707
|
+
// lexer for one row so the server serves a genuinely degraded document, then
|
|
2708
|
+
// clicks the degraded item and reads the banner off the page. MEASURED by that
|
|
2709
|
+
// row, and only that row: the click renders UNLOCATABLE_LINE_MESSAGE, opens no
|
|
2710
|
+
// textarea, and leaves the file byte-identical.
|
|
2711
|
+
//
|
|
2712
|
+
// The other half of the pair is pinned somewhere else and it is worth knowing
|
|
2713
|
+
// where, because no single file renders both: test/editor-client-runtime.test.js
|
|
2714
|
+
// clicks the outer item of a '- - b' same-line nest and asserts the banner
|
|
2715
|
+
// reads NO_SOURCE_LINE_MESSAGE — grep it for '沒有自己的來源行'. Those two
|
|
2716
|
+
// assertions, in two files, are what keep the two causes from collapsing back
|
|
2717
|
+
// into one sentence. This branch is a net for a shape nobody has produced yet,
|
|
2718
|
+
// not a path.
|
|
2719
|
+
function blockRangeIsUnlocatable(blockEl) {
|
|
2720
|
+
if (!blockEl || !blockEl.getAttribute) return false;
|
|
2721
|
+
const raw = blockEl.getAttribute('data-block-id');
|
|
2722
|
+
if (raw === null) return false;
|
|
2723
|
+
const rec = blocks.find((b) => b.id === Number(raw));
|
|
2724
|
+
return !!(rec && rec.unlocatable);
|
|
2725
|
+
}
|
|
2726
|
+
|
|
2124
2727
|
// Task 4 fix round 1: the message the two blockOwnsNoLine() guards above
|
|
2125
2728
|
// (openRawEditor / deleteBlockViaGutter) pass into the SHARED
|
|
2126
2729
|
// refuseStructuralListEdit() helper (defined further down, next to
|
|
@@ -2133,6 +2736,23 @@
|
|
|
2133
2736
|
// operations it refuses — an insert that answered '無法刪除' would be
|
|
2134
2737
|
// telling the user something that is not true of the button they pressed.
|
|
2135
2738
|
const NO_SOURCE_LINE_INSERT_MESSAGE = '此項目沒有自己的來源行,無法在其後插入區塊';
|
|
2739
|
+
// The degraded-subtree wording (see blockRangeIsUnlocatable() above). Each
|
|
2740
|
+
// one NAMES ITS OPERATION exactly as the pair above does — that is the whole
|
|
2741
|
+
// reason the pair above is a pair — and differs from it only in the clause
|
|
2742
|
+
// that states the cause. Deliberately says nothing about the refusal being
|
|
2743
|
+
// temporary: nothing lifts it, so wording like 「已暫停」 would promise a state
|
|
2744
|
+
// change that never comes.
|
|
2745
|
+
const UNLOCATABLE_LINE_MESSAGE = '這段巢狀清單對不到自己的來源行,無法刪除或直接編輯';
|
|
2746
|
+
const UNLOCATABLE_LINE_INSERT_MESSAGE = '這段巢狀清單對不到自己的來源行,無法在其後插入區塊';
|
|
2747
|
+
// Both refusals go through one chooser each, so a call site cannot pick the
|
|
2748
|
+
// right guard and the wrong sentence.
|
|
2749
|
+
function noSourceLineMessageFor(blockEl) {
|
|
2750
|
+
return blockRangeIsUnlocatable(blockEl) ? UNLOCATABLE_LINE_MESSAGE : NO_SOURCE_LINE_MESSAGE;
|
|
2751
|
+
}
|
|
2752
|
+
function noSourceLineInsertMessageFor(blockEl) {
|
|
2753
|
+
return blockRangeIsUnlocatable(blockEl)
|
|
2754
|
+
? UNLOCATABLE_LINE_INSERT_MESSAGE : NO_SOURCE_LINE_INSERT_MESSAGE;
|
|
2755
|
+
}
|
|
2136
2756
|
|
|
2137
2757
|
// The list markers standing at the head of one source line, left to right.
|
|
2138
2758
|
// A same-line nest ('- 1. b') puts one marker per nesting level on the line;
|
|
@@ -2603,6 +3223,151 @@
|
|
|
2603
3223
|
sel.addRange(range);
|
|
2604
3224
|
}
|
|
2605
3225
|
|
|
3226
|
+
// ── Task 15: the two selections Tab writes ───────────────────────────────
|
|
3227
|
+
//
|
|
3228
|
+
// Both go through setBaseAndExtent() rather than the removeAllRanges() +
|
|
3229
|
+
// addRange() pair placeCaretAtEnd() just above uses, because Tab now writes
|
|
3230
|
+
// a selection on every press and the spellings differ in what they
|
|
3231
|
+
// dispatch. Counted on a live cell, applying boundary values that were
|
|
3232
|
+
// ALREADY the ones in force: setBaseAndExtent() dispatched no
|
|
3233
|
+
// selectionchange, removeAllRanges() + addRange() dispatched one, and
|
|
3234
|
+
// selectNodeContents() followed by that pair dispatched one. The resulting
|
|
3235
|
+
// Range is otherwise the same: over a cell holding 'c2', both spellings of
|
|
3236
|
+
// "select the contents" read back start TD offset 0 to end TD offset 1
|
|
3237
|
+
// covering 'c2', and both spellings of each caret placement read back the
|
|
3238
|
+
// matching collapsed pair. On a cell emptied first, setBaseAndExtent()
|
|
3239
|
+
// placed a collapsed selection instead of throwing.
|
|
3240
|
+
//
|
|
3241
|
+
// The element-anchored boundary points are load-bearing beyond tidiness —
|
|
3242
|
+
// see `tabSelectedCellEl` further down, whose release condition is exactly
|
|
3243
|
+
// "the live selection is no longer these".
|
|
3244
|
+
function selectSurfaceContents(el) {
|
|
3245
|
+
const sel = window.getSelection();
|
|
3246
|
+
if (!sel) return;
|
|
3247
|
+
sel.setBaseAndExtent(el, 0, el, el.childNodes.length);
|
|
3248
|
+
}
|
|
3249
|
+
|
|
3250
|
+
function placeCaretAtSurfaceStart(el) {
|
|
3251
|
+
const sel = window.getSelection();
|
|
3252
|
+
if (!sel) return;
|
|
3253
|
+
sel.setBaseAndExtent(el, 0, el, 0);
|
|
3254
|
+
}
|
|
3255
|
+
|
|
3256
|
+
// ── Marks a Range operation leaves behind holding nothing ────────────────
|
|
3257
|
+
//
|
|
3258
|
+
// extractContents() and deleteContents() each act on an element the range
|
|
3259
|
+
// only PARTIALLY contains by taking its contained part and leaving the
|
|
3260
|
+
// original element in place. When the boundary sits at offset 0 of that
|
|
3261
|
+
// element's own first child, everything it held goes and the original is
|
|
3262
|
+
// left holding nothing that renders. Measured, bolding a selection that
|
|
3263
|
+
// starts at the first character of an <em>:
|
|
3264
|
+
//
|
|
3265
|
+
// before `Alpha <em>ital</em> bold text here.`
|
|
3266
|
+
// after `Alpha <em></em><strong><em>ital</em> bold</strong> text here.`
|
|
3267
|
+
//
|
|
3268
|
+
// Nothing downstream drops that <em>: it still serialises to its delimiter
|
|
3269
|
+
// on each side. The bytes measured on disk, one gesture per line, all from
|
|
3270
|
+
// the same selection:
|
|
3271
|
+
//
|
|
3272
|
+
// toolbar 粗體 `Alpha *****ital* bold** text here.`
|
|
3273
|
+
// toolbar 🔗 `Alpha **[*ital* bold](https://example.com) text here.`
|
|
3274
|
+
// Shift+Enter `Alpha **<br> text here.`
|
|
3275
|
+
// paste `Alpha **PASTED text here.`
|
|
3276
|
+
// Enter in a li `- Alpha **`
|
|
3277
|
+
//
|
|
3278
|
+
// Each of those re-parses with the stray asterisks as literal text and comes
|
|
3279
|
+
// back escaped on the next save — 「*字號前面都有 \ 跳脫」, the symptom this
|
|
3280
|
+
// was reported as. Journey row `escape-cycle` drives that whole cycle.
|
|
3281
|
+
//
|
|
3282
|
+
// Collecting and dropping are separate functions because the operation
|
|
3283
|
+
// between them differs per call site: extractContents() into a new element
|
|
3284
|
+
// (the mark and link paths), deleteContents() then an insert (Shift+Enter,
|
|
3285
|
+
// paste), and a delete plus a tail extraction (the list split, which folds
|
|
3286
|
+
// its delete range and its tail range into the same collection).
|
|
3287
|
+
|
|
3288
|
+
const EDIT_SURFACE_SEL = '.ed-wys-armed, .ed-wys-cell';
|
|
3289
|
+
|
|
3290
|
+
// The editing surface `node` sits in — a paragraph / heading / list-item
|
|
3291
|
+
// surface, or a table cell. This is what the walk below terminates at, and
|
|
3292
|
+
// the reason it names the CELL rather than the <table>: a cell left holding
|
|
3293
|
+
// nothing must stay a cell, and it does because the walk never reaches it.
|
|
3294
|
+
function editSurfaceOf(node) {
|
|
3295
|
+
const el = node && (node.nodeType === 1 ? node : node.parentNode);
|
|
3296
|
+
return el && el.closest ? el.closest(EDIT_SURFACE_SEL) : null;
|
|
3297
|
+
}
|
|
3298
|
+
|
|
3299
|
+
// The elements the operation could leave empty: the element ancestors of
|
|
3300
|
+
// `range`'s start container and of its end container, up to (not including)
|
|
3301
|
+
// `root`, each tagged with its element depth below `root`. Called BEFORE the
|
|
3302
|
+
// operation, because the Range's boundary points move the moment it runs.
|
|
3303
|
+
//
|
|
3304
|
+
// `into` lets a call site fold several ranges into the same collection; an
|
|
3305
|
+
// element already collected is not collected again.
|
|
3306
|
+
//
|
|
3307
|
+
// A boundary that is not inside `root` contributes nothing, and a falsy
|
|
3308
|
+
// `root` collects nothing at all. Without a terminator inside the editing
|
|
3309
|
+
// surface the walk would climb into the page chrome, and there is nothing up
|
|
3310
|
+
// there this function could nominate for removal. Making that true HERE rather than
|
|
3311
|
+
// trusting each caller is what lets the removal below dereference
|
|
3312
|
+
// `parentNode` unguarded.
|
|
3313
|
+
function collectLeftovers(range, root, into) {
|
|
3314
|
+
const leftovers = into || [];
|
|
3315
|
+
if (!root) return leftovers;
|
|
3316
|
+
const collect = (node) => {
|
|
3317
|
+
if (!node || !root.contains(node)) return;
|
|
3318
|
+
const chain = [];
|
|
3319
|
+
for (let n = node; n && n !== root; n = n.parentNode) {
|
|
3320
|
+
if (n.nodeType === 1) chain.push(n);
|
|
3321
|
+
}
|
|
3322
|
+
// `chain` is deepest-first, so `chain.length - i` is the element depth
|
|
3323
|
+
// below `root` — a value that compares correctly across chains, which a
|
|
3324
|
+
// plain per-chain index would not.
|
|
3325
|
+
chain.forEach((n, i) => {
|
|
3326
|
+
if (!leftovers.some((x) => x.el === n)) leftovers.push({ el: n, depth: chain.length - i });
|
|
3327
|
+
});
|
|
3328
|
+
};
|
|
3329
|
+
collect(range.startContainer);
|
|
3330
|
+
collect(range.endContainer);
|
|
3331
|
+
return leftovers;
|
|
3332
|
+
}
|
|
3333
|
+
|
|
3334
|
+
// Removes the collected elements the operation left holding nothing. Call it
|
|
3335
|
+
// AFTER whatever the call site puts in the range's place, so an element that
|
|
3336
|
+
// now holds the new node is not mistaken for an empty leftover.
|
|
3337
|
+
//
|
|
3338
|
+
// "Holds nothing" is `no child ELEMENT and no text`. Every conjunct of that
|
|
3339
|
+
// test has a journey row that reds when that conjunct alone is removed:
|
|
3340
|
+
//
|
|
3341
|
+
// * the text test alone would delete a mark left holding a <br> — a hard
|
|
3342
|
+
// break has no text, but it is content the user typed. Journey row
|
|
3343
|
+
// `keeps-br`.
|
|
3344
|
+
// * the element test alone would delete a mark left holding TEXT, which is
|
|
3345
|
+
// what a selection starting partway into that mark leaves behind. Journey
|
|
3346
|
+
// row `keeps-text`.
|
|
3347
|
+
//
|
|
3348
|
+
// What it is NOT is a child count. A fully-taken boundary text node is not
|
|
3349
|
+
// removed by extractContents() or by deleteContents(); its data is replaced
|
|
3350
|
+
// with the remainder, so the node survives holding the empty string. Probed
|
|
3351
|
+
// right after the extraction on the bold row — the surviving <em> reported
|
|
3352
|
+
// `<em></em>`, its childNodes still holding a text node whose data was "". A child-count test
|
|
3353
|
+
// never fires on the very shape this exists for.
|
|
3354
|
+
//
|
|
3355
|
+
// Deepest first, so a mark left empty only by the removal of an inner one is
|
|
3356
|
+
// still seen as empty when its own turn comes. Journey row `nested-order`.
|
|
3357
|
+
//
|
|
3358
|
+
// `parentNode` is dereferenced unguarded: a candidate is an ancestor of a
|
|
3359
|
+
// boundary container and a strict descendant of `root` — collectLeftovers()
|
|
3360
|
+
// enforces the descendant part rather than trusting callers. A partially
|
|
3361
|
+
// contained ancestor is exactly what these Range methods leave in place: the
|
|
3362
|
+
// `before`/`after` measurement above is that, seen directly. And the
|
|
3363
|
+
// deepest-first order means a candidate is reached only after every candidate
|
|
3364
|
+
// it contains, so nothing detaches a candidate before its turn.
|
|
3365
|
+
function dropEmptied(leftovers) {
|
|
3366
|
+
leftovers.slice().sort((a, b) => b.depth - a.depth).forEach((x) => {
|
|
3367
|
+
if (!x.el.firstElementChild && x.el.textContent === '') x.el.parentNode.removeChild(x.el);
|
|
3368
|
+
});
|
|
3369
|
+
}
|
|
3370
|
+
|
|
2606
3371
|
// Paste handler support: insert plain text at the caret via Range surgery,
|
|
2607
3372
|
// keeping the serializer's input domain closed to plain text + the inline
|
|
2608
3373
|
// elements it itself produces (bold/italic/code/links/br) — see the brief's
|
|
@@ -2625,6 +3390,9 @@
|
|
|
2625
3390
|
const sel = window.getSelection();
|
|
2626
3391
|
if (!sel.rangeCount) return;
|
|
2627
3392
|
const range = sel.getRangeAt(0);
|
|
3393
|
+
// Same leftover as the toolbar's: a paste over a selection that starts at
|
|
3394
|
+
// the first character of an <em> left `Alpha **PASTED text here.` on disk.
|
|
3395
|
+
const leftovers = collectLeftovers(range, editSurfaceOf(range.startContainer));
|
|
2628
3396
|
range.deleteContents();
|
|
2629
3397
|
const segments = String(text).split(/\r\n|\r|\n/);
|
|
2630
3398
|
segments.forEach((seg, i) => {
|
|
@@ -2639,6 +3407,7 @@
|
|
|
2639
3407
|
range.setStartAfter(node);
|
|
2640
3408
|
range.setEndAfter(node);
|
|
2641
3409
|
});
|
|
3410
|
+
dropEmptied(leftovers);
|
|
2642
3411
|
sel.removeAllRanges();
|
|
2643
3412
|
sel.addRange(range);
|
|
2644
3413
|
}
|
|
@@ -2652,11 +3421,20 @@
|
|
|
2652
3421
|
const sel = window.getSelection();
|
|
2653
3422
|
if (!sel.rangeCount) return;
|
|
2654
3423
|
const range = sel.getRangeAt(0);
|
|
3424
|
+
// Same leftover as the toolbar's: Shift+Enter over a selection that starts
|
|
3425
|
+
// at the first character of an <em> left `Alpha **<br> text here.` on disk.
|
|
3426
|
+
const leftovers = collectLeftovers(range, editSurfaceOf(range.startContainer));
|
|
2655
3427
|
range.deleteContents();
|
|
2656
3428
|
const br = document.createElement('br');
|
|
2657
3429
|
range.insertNode(br);
|
|
2658
3430
|
range.setStartAfter(br);
|
|
2659
3431
|
range.setEndAfter(br);
|
|
3432
|
+
// After the <br> is in place, because the <br> can land INSIDE a mark this
|
|
3433
|
+
// very delete emptied and that mark must then be kept. Measured, Shift+Enter
|
|
3434
|
+
// over a selection covering the whole of `<em>ital</em>`:
|
|
3435
|
+
// `Alpha <em><br></em> bold text here.` — dropping before the insert would
|
|
3436
|
+
// have taken that <em> and the caret's new home with it.
|
|
3437
|
+
dropEmptied(leftovers);
|
|
2660
3438
|
sel.removeAllRanges();
|
|
2661
3439
|
sel.addRange(range);
|
|
2662
3440
|
}
|
|
@@ -3204,6 +3982,21 @@
|
|
|
3204
3982
|
});
|
|
3205
3983
|
sub.style.top = anchorBtn.offsetTop + 'px';
|
|
3206
3984
|
anchorBtn.parentNode.appendChild(sub);
|
|
3985
|
+
// Clamp the panel's top into the viewport. Measured on this branch:
|
|
3986
|
+
// opening ⠿ then hovering 轉換成 never reads window.innerHeight before
|
|
3987
|
+
// this line, confirming the clamp below did not already exist somewhere
|
|
3988
|
+
// else in this path. getBoundingClientRect() is read after the append
|
|
3989
|
+
// above, so the height it reports is the panel as laid out here, not a
|
|
3990
|
+
// guessed constant, and it already reflects .ed-handle-submenu's own
|
|
3991
|
+
// max-height cap (lib/md2doc.js) when a short viewport has forced that
|
|
3992
|
+
// cap to bite. Its `bottom` already carries the containing block's
|
|
3993
|
+
// screen position, because the `top` set above resolves against that
|
|
3994
|
+
// block (position: absolute). Shifting `top` up by the bottom overflow
|
|
3995
|
+
// is the clamp.
|
|
3996
|
+
const overflowBottom = sub.getBoundingClientRect().bottom - window.innerHeight;
|
|
3997
|
+
if (overflowBottom > 0) {
|
|
3998
|
+
sub.style.top = (anchorBtn.offsetTop - overflowBottom) + 'px';
|
|
3999
|
+
}
|
|
3207
4000
|
convertSubmenu = sub;
|
|
3208
4001
|
convertSubmenuViaHover = !!viaHover;
|
|
3209
4002
|
// The aim triangle needs a previous sample to have an apex; the first
|
|
@@ -3538,21 +4331,19 @@
|
|
|
3538
4331
|
if (kind === 'code') {
|
|
3539
4332
|
// Code blocks are never WYSIWYG-armed (armEditables() above has no
|
|
3540
4333
|
// 'code' branch) — same degraded-block contract as any other fence:
|
|
3541
|
-
// click opens the raw in-place source editor directly.
|
|
3542
|
-
//
|
|
3543
|
-
//
|
|
3544
|
-
//
|
|
3545
|
-
// BLOCK_SKELETONS['code']
|
|
3546
|
-
//
|
|
3547
|
-
//
|
|
4334
|
+
// click opens the raw in-place source editor directly.
|
|
4335
|
+
//
|
|
4336
|
+
// F9: this branch used to re-place the caret itself, because
|
|
4337
|
+
// openRawEditor() put it at the END of the textarea — i.e. after the
|
|
4338
|
+
// closing fence, not on the blank BODY line BLOCK_SKELETONS['code']
|
|
4339
|
+
// (`['```', '', '```']`, see its own comment) puts there specifically
|
|
4340
|
+
// so typing has somewhere to land. openRawEditor() now asks
|
|
4341
|
+
// rawEditorSeed() (see its own comment), and for that skeleton — whose
|
|
4342
|
+
// middle row is '' and therefore not a bare fence — it returns the
|
|
4343
|
+
// same offset this branch used to compute, indexOf('\n') + 1, with the
|
|
4344
|
+
// value untouched. So the fix-up is deleted rather than kept as a
|
|
4345
|
+
// no-op that reads like it is still load-bearing.
|
|
3548
4346
|
await openRawEditor(blockEl);
|
|
3549
|
-
const ta = blockEl.querySelector('textarea.ed-raw');
|
|
3550
|
-
if (ta) {
|
|
3551
|
-
const nlIdx = ta.value.indexOf('\n');
|
|
3552
|
-
const pos = nlIdx === -1 ? ta.value.length : nlIdx + 1;
|
|
3553
|
-
ta.setSelectionRange(pos, pos);
|
|
3554
|
-
ta.focus();
|
|
3555
|
-
}
|
|
3556
4347
|
return;
|
|
3557
4348
|
}
|
|
3558
4349
|
if (kind === 'table') {
|
|
@@ -3618,7 +4409,7 @@
|
|
|
3618
4409
|
// li block grows no + until S2; S2 is next, and a guard that has to be
|
|
3619
4410
|
// remembered later is a guard that will not be.
|
|
3620
4411
|
if (blockOwnsNoLine(liveBlockEl)) {
|
|
3621
|
-
refuseStructuralListEdit(
|
|
4412
|
+
refuseStructuralListEdit(noSourceLineInsertMessageFor(liveBlockEl));
|
|
3622
4413
|
return;
|
|
3623
4414
|
}
|
|
3624
4415
|
const customLines = opts && Array.isArray(opts.lines) ? opts.lines : null;
|
|
@@ -3683,7 +4474,7 @@
|
|
|
3683
4474
|
// commitBlockInsertion() is about to read `endLine` and `lines[endLine]`
|
|
3684
4475
|
// off. Re-checked against the block actually used.
|
|
3685
4476
|
if (blockOwnsNoLine(anchorEl)) {
|
|
3686
|
-
refuseStructuralListEdit(
|
|
4477
|
+
refuseStructuralListEdit(noSourceLineInsertMessageFor(anchorEl));
|
|
3687
4478
|
return;
|
|
3688
4479
|
}
|
|
3689
4480
|
}
|
|
@@ -3916,7 +4707,7 @@
|
|
|
3916
4707
|
// A block that owns no source line has an INVERTED range
|
|
3917
4708
|
// (endLine === startLine - 1) and every commit helper handed one does
|
|
3918
4709
|
// something plausible and wrong.
|
|
3919
|
-
if (blockOwnsNoLine(liveBlockEl)) { refuseStructuralListEdit(
|
|
4710
|
+
if (blockOwnsNoLine(liveBlockEl)) { refuseStructuralListEdit(noSourceLineMessageFor(liveBlockEl)); return null; }
|
|
3920
4711
|
const rec = blockRecOf(liveBlockEl);
|
|
3921
4712
|
if (!rec) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return null; }
|
|
3922
4713
|
if (anchor) anchor.line = rec.startLine;
|
|
@@ -3961,7 +4752,7 @@
|
|
|
3961
4752
|
// but "another module filters it" is an argument about another file's
|
|
3962
4753
|
// output, and this is the guard that stands between an inverted range and
|
|
3963
4754
|
// a commit helper.
|
|
3964
|
-
if (blockOwnsNoLine(el)) { refuseStructuralListEdit(
|
|
4755
|
+
if (blockOwnsNoLine(el)) { refuseStructuralListEdit(noSourceLineMessageFor(el)); return null; }
|
|
3965
4756
|
els.push(el);
|
|
3966
4757
|
}
|
|
3967
4758
|
return { els: els, recs: members, batch: members.length > 1 };
|
|
@@ -4155,10 +4946,16 @@
|
|
|
4155
4946
|
// caret and FALSE of the toolbar, and the first version of this guard
|
|
4156
4947
|
// skipped both. The bar can be down to 4 while the caret is still sitting
|
|
4157
4948
|
// on a live surface, and that is the ORDINARY case, not an edge one: it
|
|
4158
|
-
// is what happens on the PATCH render path
|
|
4159
|
-
//
|
|
4160
|
-
//
|
|
4161
|
-
//
|
|
4949
|
+
// is what happens on the PATCH render path — most commits past the
|
|
4950
|
+
// first page load take that route, but the FALLBACK stays reachable
|
|
4951
|
+
// through the whole session on its own routes, not just the first
|
|
4952
|
+
// render: `domMatchesLastRender()` (see its own comment) fails on a
|
|
4953
|
+
// block-count, dom-child-count, or per-block-id mismatch just as
|
|
4954
|
+
// readily as on a null `lastParts`, and `safeRerenderAll()`'s
|
|
4955
|
+
// no-`editRange` callers — restore()'s rangeMode exit, the per-li
|
|
4956
|
+
// unsupported-format refusal, commitListStructure()'s no-range exit
|
|
4957
|
+
// (see the wrapper's own comment just above it) — fall back by
|
|
4958
|
+
// contract regardless of dom state.
|
|
4162
4959
|
//
|
|
4163
4960
|
// INSTRUMENTED inside applyPatch() on a primed ⠿刪除 refusal, one log
|
|
4164
4961
|
// line per step, rather than read off the source:
|
|
@@ -4174,15 +4971,29 @@
|
|
|
4174
4971
|
// restoreAfterStructuralOp active:DIV.ed-li-text ed-wys-armed
|
|
4175
4972
|
// toolbarBlockElNull:true
|
|
4176
4973
|
// — applyPatch()'s `focusWasInSpan` block hands focus back to the NEW
|
|
4177
|
-
// surface
|
|
4178
|
-
//
|
|
4179
|
-
//
|
|
4180
|
-
// `if (currentBurst) return;`
|
|
4181
|
-
//
|
|
4182
|
-
//
|
|
4183
|
-
//
|
|
4184
|
-
//
|
|
4185
|
-
//
|
|
4974
|
+
// surface via `surface.focus()`, which dispatches focusin
|
|
4975
|
+
// SYNCHRONOUSLY. The first version of this comment read `currentBurst`
|
|
4976
|
+
// as still the OLD burst at guard time, with focusin's
|
|
4977
|
+
// `if (currentBurst) return;` stopping a fresh one from opening — that
|
|
4978
|
+
// was never measured, and does not hold on this route: `switching` is
|
|
4979
|
+
// truthy here (this very commit is the render switchAwayFrom() is
|
|
4980
|
+
// awaiting), so the handler suspends at `if (switching) await
|
|
4981
|
+
// switching;`, the line immediately before that guard, and cannot get
|
|
4982
|
+
// past it until this same call to applyPatch() returns. By then
|
|
4983
|
+
// applyPatch()'s OWN focus-adoption tail has already run — the
|
|
4984
|
+
// `if (!currentBurst && ae && contentEl.contains(ae)) { … startBurst(ae);
|
|
4985
|
+
// … }` further down this function, whose own comment notes undo's
|
|
4986
|
+
// synchronous focusin leaves that check "恆為 false" there (currentBurst
|
|
4987
|
+
// already set by then) — the mirror image of this route, where
|
|
4988
|
+
// currentBurst is null at that point and the check fires, opening a
|
|
4989
|
+
// fresh burst on the very surface `focus()` landed on. The suspended
|
|
4990
|
+
// focusin's `if (currentBurst) return;` reads THAT burst once it
|
|
4991
|
+
// resumes, not the pre-patch one persisting. Either way `burstSurvived`
|
|
4992
|
+
// is false and `resetToolbarBlock()` runs AFTER the caret was handed
|
|
4993
|
+
// back — that half stood correct and still does. The unprimed run of
|
|
4994
|
+
// the same gesture took the FALLBACK route and arrived here with
|
|
4995
|
+
// active:BODY, which is why the first wave's measurements never saw
|
|
4996
|
+
// this.
|
|
4186
4997
|
//
|
|
4187
4998
|
// So: do not drag the caret (it never left), but DO re-aim the bar, at
|
|
4188
4999
|
// the block the caret is actually in rather than at `anchorLine` — those
|
|
@@ -5457,6 +6268,7 @@
|
|
|
5457
6268
|
}
|
|
5458
6269
|
|
|
5459
6270
|
function startBurst(editEl) {
|
|
6271
|
+
dropDiscardedBurst();
|
|
5460
6272
|
const blockEl = editEl.closest('.ed-block');
|
|
5461
6273
|
if (!blockEl) return;
|
|
5462
6274
|
const blockId = Number(blockEl.getAttribute('data-block-id'));
|
|
@@ -5471,6 +6283,7 @@
|
|
|
5471
6283
|
original: burstBaselineHtml(editEl),
|
|
5472
6284
|
history,
|
|
5473
6285
|
};
|
|
6286
|
+
watchBurstForDirtyDot(editEl);
|
|
5474
6287
|
selToolbarEditEl = editEl;
|
|
5475
6288
|
if (!selToolbarListener) {
|
|
5476
6289
|
selToolbarListener = onSelectionChangeForToolbar;
|
|
@@ -5790,6 +6603,51 @@
|
|
|
5790
6603
|
return document.querySelector('.ed-block[data-block-id="' + target.id + '"]');
|
|
5791
6604
|
}
|
|
5792
6605
|
|
|
6606
|
+
// ── Task 15: where a keyboard caret can actually land ────────────────────
|
|
6607
|
+
//
|
|
6608
|
+
// Hands back the element inside `blockEl` that can hold a caret, or null
|
|
6609
|
+
// when the block has none. It answers with the target rather than a yes/no
|
|
6610
|
+
// so the Tab walk out of a table takes its landing site from the same
|
|
6611
|
+
// measurement that decided the block was reachable at all.
|
|
6612
|
+
//
|
|
6613
|
+
// The class tokens asked for are the ones armEditables() writes: a single
|
|
6614
|
+
// contenteditable surface carrying 'ed-wys-armed' (its paragraph/heading
|
|
6615
|
+
// branch, and the armed row of its li branch), and a table's per-cell
|
|
6616
|
+
// surfaces carrying 'ed-wys-cell'. A block armEditables() left degraded
|
|
6617
|
+
// carries neither.
|
|
6618
|
+
//
|
|
6619
|
+
// Asking a class rather than calling .focus() and reading
|
|
6620
|
+
// document.activeElement back is not caution. Driven over a fixture whose
|
|
6621
|
+
// blocks were a table, a fenced code block, another table, a blockquote, an
|
|
6622
|
+
// image-only paragraph, a thematic break and a plain paragraph,
|
|
6623
|
+
// blockContentEl(blockEl).focus() left document.activeElement on BODY for
|
|
6624
|
+
// every one of them except the paragraph — the tables included, because
|
|
6625
|
+
// blockContentEl() of a table block is the <table> itself and focusing that
|
|
6626
|
+
// moved nothing, while focusing one of its cells does.
|
|
6627
|
+
//
|
|
6628
|
+
// querySelector cannot reach past this block into a nested item's: driven on
|
|
6629
|
+
// a fixture whose list nests, every '.ed-block' in the document was a direct
|
|
6630
|
+
// child of '.content' and none had another '.ed-block' as an ancestor.
|
|
6631
|
+
// md2doc.js emits a nested item's block as a SIBLING carrying 'data-indent',
|
|
6632
|
+
// not inside its parent's.
|
|
6633
|
+
function focusableSurfaceOf(blockEl) {
|
|
6634
|
+
if (!blockEl) return null;
|
|
6635
|
+
return blockEl.querySelector('.ed-wys-armed') || blockEl.querySelector('.ed-wys-cell');
|
|
6636
|
+
}
|
|
6637
|
+
|
|
6638
|
+
// The nearest sibling block in `dir` (+1 forward, -1 back) that has one, or
|
|
6639
|
+
// null. Nothing tests for '.ed-block' on the way past: on the same fixture
|
|
6640
|
+
// every child of '.content' was one, and armEditables() only ever walks
|
|
6641
|
+
// '.ed-block' elements, so an element that was not one carries no surface
|
|
6642
|
+
// for focusableSurfaceOf() to find.
|
|
6643
|
+
function adjacentFocusableBlock(blockEl, dir) {
|
|
6644
|
+
const step = (el) => (dir > 0 ? el.nextElementSibling : el.previousElementSibling);
|
|
6645
|
+
for (let el = blockEl ? step(blockEl) : null; el; el = step(el)) {
|
|
6646
|
+
if (focusableSurfaceOf(el)) return el;
|
|
6647
|
+
}
|
|
6648
|
+
return null;
|
|
6649
|
+
}
|
|
6650
|
+
|
|
5793
6651
|
// ── S3 Task 2: block multi-select state ────────────────────────────────
|
|
5794
6652
|
// The selection's identity is a LINE RANGE, never ids and never nodes.
|
|
5795
6653
|
// buildBlockMap renumbers every id from 0 on every render (blockmap.js's
|
|
@@ -5995,8 +6853,9 @@
|
|
|
5995
6853
|
// v3.0.2: computeIndentClamp() lives past this file's node early-exit
|
|
5996
6854
|
// (module.exports at line ~509 ships the pure core only) and nothing on
|
|
5997
6855
|
// window reaches it, so the stale-span assert added to it in v3.0.2 had
|
|
5998
|
-
// no way to be tested at all. This
|
|
5999
|
-
// 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.
|
|
6000
6859
|
window.__edTestClampProbe = function (spanEls, opBlockEl, opOldIndent, opts) {
|
|
6001
6860
|
return computeIndentClamp(spanEls, opBlockEl, opOldIndent, opts);
|
|
6002
6861
|
};
|
|
@@ -6605,17 +7464,122 @@
|
|
|
6605
7464
|
// real commit is also possible).
|
|
6606
7465
|
const wasPristineForThisBlock = !!(pristineInsert && pristineInsert.blockId === burst.blockId);
|
|
6607
7466
|
if (wasPristineForThisBlock) pristineInsert = null;
|
|
6608
|
-
|
|
6609
|
-
|
|
6610
|
-
|
|
6611
|
-
|
|
7467
|
+
// T21 item 1: a pristine insert keeps the old, unrecoverable shape, and
|
|
7468
|
+
// that is not an oversight — discardPristineInsert() REMOVES the block, so
|
|
7469
|
+
// there is no surface left to restore into and nothing was typed that the
|
|
7470
|
+
// skeleton itself did not carry (the pristine window closes at this
|
|
7471
|
+
// block's first real edit; see pristineInsert's own comment).
|
|
6612
7472
|
if (wasPristineForThisBlock) {
|
|
7473
|
+
burst.history.dispose();
|
|
7474
|
+
editEl.innerHTML = burst.original;
|
|
7475
|
+
currentBurst = null;
|
|
7476
|
+
resetSelToolbarState();
|
|
6613
7477
|
await discardPristineInsert(); // rerenderAll() already detaches editEl — nothing left to blur()
|
|
6614
7478
|
return;
|
|
6615
7479
|
}
|
|
7480
|
+
// The paired snapshot, the same shape snapBurstIfActive() spells out for
|
|
7481
|
+
// the programmatic mutations that already snap on both sides of
|
|
7482
|
+
// themselves (grep `history.snap('` for them): the state being thrown away
|
|
7483
|
+
// has to be on the stack ABOVE the state being restored, or the undo lands
|
|
7484
|
+
// one entry short. flushTyping() is the pre half here rather than a snap()
|
|
7485
|
+
// — after typing, the debounce is what is holding the typed state.
|
|
7486
|
+
//
|
|
7487
|
+
// Both lines were ablated one at a time and the gesture re-driven, on the
|
|
7488
|
+
// paragraph, and each one alone loses the text: with flushTyping() removed,
|
|
7489
|
+
// Escape then Ctrl+Z left the paragraph reading 'The migration has three
|
|
7490
|
+
// phases.' — the typing gone and not on the document stack either; with the
|
|
7491
|
+
// snap() removed, the same gesture left the same text, i.e. the Ctrl+Z
|
|
7492
|
+
// looked like it did nothing.
|
|
7493
|
+
burst.history.flushTyping();
|
|
7494
|
+
editEl.innerHTML = burst.original;
|
|
7495
|
+
burst.history.snap('escape-discard');
|
|
7496
|
+
discardedBurst = {
|
|
7497
|
+
history: burst.history, editEl, blockType: burst.blockType, cellIdx: -1,
|
|
7498
|
+
reverted: burstBaselineHtml(editEl),
|
|
7499
|
+
};
|
|
7500
|
+
currentBurst = null;
|
|
7501
|
+
resetSelToolbarState();
|
|
6616
7502
|
editEl.blur();
|
|
6617
7503
|
}
|
|
6618
7504
|
|
|
7505
|
+
// The table twin of the stash above — a table burst's editEl is the whole
|
|
7506
|
+
// <table> and the focused node is one cell, so restoring has to put the
|
|
7507
|
+
// caret back in a cell, by ORDINAL among tableCellsOf() (the same handle
|
|
7508
|
+
// tableBurstUndo() uses, and for the same reason: the innerHTML swap
|
|
7509
|
+
// replaces every cell node).
|
|
7510
|
+
function stashDiscardedTableBurst(burst, tableEl, cellIdx, revertFn) {
|
|
7511
|
+
burst.history.flushTyping();
|
|
7512
|
+
revertFn();
|
|
7513
|
+
burst.history.snap('escape-discard');
|
|
7514
|
+
discardedBurst = {
|
|
7515
|
+
history: burst.history, editEl: tableEl, blockType: 'table', cellIdx,
|
|
7516
|
+
reverted: burstBaselineHtml(tableEl),
|
|
7517
|
+
};
|
|
7518
|
+
}
|
|
7519
|
+
|
|
7520
|
+
// Ctrl+Z with no burst open, when the last thing that happened was an
|
|
7521
|
+
// Escape: put back what that Escape discarded and re-open the burst around
|
|
7522
|
+
// it, so the restored text is a live edit again — dirty dot lit, committed
|
|
7523
|
+
// by the next blur, and one more Ctrl+Z steps back through the same history
|
|
7524
|
+
// to the pre-edit baseline and then cascades out to the document stack.
|
|
7525
|
+
//
|
|
7526
|
+
// Returns false when there is nothing to restore or the stash has gone
|
|
7527
|
+
// stale, and the caller then does the ordinary document undo — which is
|
|
7528
|
+
// what an Escape that discarded NOTHING (focus a surface, press Escape
|
|
7529
|
+
// without editing) must also do: its history has a single entry, undo()
|
|
7530
|
+
// answers null, and the user's Ctrl+Z is not swallowed.
|
|
7531
|
+
//
|
|
7532
|
+
// The order — focus FIRST, then write — is load-bearing. focus() opens a
|
|
7533
|
+
// fresh burst whose `original` is the reverted surface, so the restored
|
|
7534
|
+
// text reads as an edit against it; writing first and focusing after would
|
|
7535
|
+
// make the restored text the new baseline, and resolveBurst()'s zero-edit
|
|
7536
|
+
// guard would then drop it on the way out without committing.
|
|
7537
|
+
function restoreDiscardedBurst() {
|
|
7538
|
+
const d = discardedBurst;
|
|
7539
|
+
discardedBurst = null;
|
|
7540
|
+
if (!d) return false;
|
|
7541
|
+
if (!document.body.contains(d.editEl) || burstBaselineHtml(d.editEl) !== d.reverted) {
|
|
7542
|
+
d.history.dispose();
|
|
7543
|
+
return false;
|
|
7544
|
+
}
|
|
7545
|
+
const typed = d.history.undo();
|
|
7546
|
+
if (typed === null) { d.history.dispose(); return false; }
|
|
7547
|
+
const isTable = d.blockType === 'table';
|
|
7548
|
+
const cells = isTable ? tableCellsOf(d.editEl) : [];
|
|
7549
|
+
const entry = isTable
|
|
7550
|
+
? (cells[Math.max(0, Math.min(cells.length - 1, d.cellIdx))] || cells[0])
|
|
7551
|
+
: d.editEl;
|
|
7552
|
+
if (!entry) { d.history.dispose(); return false; }
|
|
7553
|
+
entry.focus();
|
|
7554
|
+
if (!currentBurst || currentBurst.editEl !== d.editEl) { d.history.dispose(); return false; }
|
|
7555
|
+
currentBurst.history.dispose();
|
|
7556
|
+
currentBurst.history = d.history;
|
|
7557
|
+
if (isTable) {
|
|
7558
|
+
// See suppressTableFocusout's own comment: this reassignment detaches
|
|
7559
|
+
// the focused cell, and Chromium fires the blur/focusout for it
|
|
7560
|
+
// synchronously, with the burst deliberately still live.
|
|
7561
|
+
const tableEl = d.editEl;
|
|
7562
|
+
suppressTableFocusout = true;
|
|
7563
|
+
try {
|
|
7564
|
+
tableEl.innerHTML = typed;
|
|
7565
|
+
} finally {
|
|
7566
|
+
suppressTableFocusout = false;
|
|
7567
|
+
}
|
|
7568
|
+
const newCells = tableCellsOf(tableEl);
|
|
7569
|
+
const target = newCells[Math.max(0, Math.min(newCells.length - 1, d.cellIdx))] || newCells[0];
|
|
7570
|
+
if (target) {
|
|
7571
|
+
currentBurst.activeCellEl = target;
|
|
7572
|
+
selToolbarEditEl = target;
|
|
7573
|
+
target.focus();
|
|
7574
|
+
placeCaretAtEnd(target);
|
|
7575
|
+
}
|
|
7576
|
+
return true;
|
|
7577
|
+
}
|
|
7578
|
+
d.editEl.innerHTML = typed;
|
|
7579
|
+
placeCaretAtEnd(d.editEl);
|
|
7580
|
+
return true;
|
|
7581
|
+
}
|
|
7582
|
+
|
|
6619
7583
|
// Ctrl+Z inside a burst: step the burst-local history first; only once
|
|
6620
7584
|
// it's exhausted (atBottom — the surface is already back to its pre-focus
|
|
6621
7585
|
// baseline) does this cascade OUT to the document-level undo() stack,
|
|
@@ -6680,6 +7644,27 @@
|
|
|
6680
7644
|
// paste toggle inside a table cell silently skipped its own burst-history
|
|
6681
7645
|
// snapshot — Ctrl+Z after it would step PAST the mark (or straight to
|
|
6682
7646
|
// cascading out of the burst) instead of reverting just that toggle.
|
|
7647
|
+
//
|
|
7648
|
+
// v3.3.0 地基 B — every caller of THIS function now snapshots on BOTH sides
|
|
7649
|
+
// of its mutation (the `*-pre` reasons are the pre-mutation half). The table
|
|
7650
|
+
// structural ops — insert row, insert column, delete row, delete column,
|
|
7651
|
+
// cycle column align, drag row, drag column — take the same paired shape but
|
|
7652
|
+
// call currentBurst.history.snap() directly; grep `history.snap('` to see
|
|
7653
|
+
// all of them at once. The burst history's
|
|
7654
|
+
// undo() pops the top and returns the entry BELOW it, so the stack top has
|
|
7655
|
+
// to describe the DOM as it currently stands; a lone post-mutation snap
|
|
7656
|
+
// makes the top "typed text + the mark" with nothing between it and the
|
|
7657
|
+
// block's opening state, and one Ctrl+Z therefore threw away the sentence
|
|
7658
|
+
// the user had just typed along with the mark. MEASURED before the fix, on
|
|
7659
|
+
// a paragraph typed into and then bolded from the toolbar: the DOM after
|
|
7660
|
+
// Ctrl+Z was "Alpha paragraph." — the whole ` typed sentence` gone.
|
|
7661
|
+
//
|
|
7662
|
+
// The extra call is free when nothing has changed: history.snap() compares
|
|
7663
|
+
// its capture against the stack top and pushes only on a difference, so a
|
|
7664
|
+
// pre-mutation snap taken when the top is already current adds no entry.
|
|
7665
|
+
//
|
|
7666
|
+
// `reason` is metadata only — createBurstHistory().snap() does not read it
|
|
7667
|
+
// (see lib/editor/history.js).
|
|
6683
7668
|
function snapBurstIfActive(root, reason) {
|
|
6684
7669
|
if (currentBurst && (currentBurst.editEl === root || currentBurst.editEl.contains(root))) {
|
|
6685
7670
|
currentBurst.history.snap(reason);
|
|
@@ -6710,6 +7695,14 @@
|
|
|
6710
7695
|
if (e.key === 'Tab') e.preventDefault();
|
|
6711
7696
|
return;
|
|
6712
7697
|
}
|
|
7698
|
+
// T21 item 2: the landing mark survives a RUN of Tabs and nothing else.
|
|
7699
|
+
// Shift is excluded because it is Shift+Tab's own first keydown, and
|
|
7700
|
+
// excluding it is what lets a backwards walk keep walking. Every other
|
|
7701
|
+
// key — a character, Ctrl, Escape — is the user starting to work on the
|
|
7702
|
+
// block they landed on, and from then on Tab means what it has always
|
|
7703
|
+
// meant here (heading depth / list indent). Set BEFORE the li dispatch
|
|
7704
|
+
// below so both surfaces see the same cleared mark.
|
|
7705
|
+
if (e.key !== 'Tab' && e.key !== 'Shift') currentBurst.arrivedByTab = false;
|
|
6713
7706
|
// Task 8 (Phase 4): per-li burst — Enter / Shift+Enter / Tab / Shift+Tab
|
|
6714
7707
|
// are owned by handleLiKeydown() below (spec §4's key semantics for li
|
|
6715
7708
|
// surfaces, acceptance rows 1, 3, 5, 6, 7, 8). Every other key (Escape,
|
|
@@ -6740,6 +7733,19 @@
|
|
|
6740
7733
|
// whatever the commit message that introduced this said.
|
|
6741
7734
|
if (e.key === 'Tab') {
|
|
6742
7735
|
e.preventDefault();
|
|
7736
|
+
// T21 item 2: a Tab that ARRIVED here keeps walking instead of editing
|
|
7737
|
+
// what it landed on. Task 15 gave Tab a cross-block landing but left the
|
|
7738
|
+
// next Tab reading as this branch, and a key being used to navigate then
|
|
7739
|
+
// silently rewrote the document: driven on the fixture table, pressing
|
|
7740
|
+
// its last body cell and then Tab four times took '### Build snippet' to
|
|
7741
|
+
// '######' and Ctrl+S wrote that to disk; backwards from the first
|
|
7742
|
+
// header cell, Shift+Tab twice outdented ' - epsilon' to '- epsilon' on
|
|
7743
|
+
// disk. Both were silent — a heading changing size and the title's dot
|
|
7744
|
+
// were the only tells.
|
|
7745
|
+
if (currentBurst.arrivedByTab) {
|
|
7746
|
+
walkToAdjacentBlockByTab(currentBurst.blockEl, e.shiftKey ? -1 : 1);
|
|
7747
|
+
return;
|
|
7748
|
+
}
|
|
6743
7749
|
if (currentBurst.blockType === 'heading') {
|
|
6744
7750
|
changeHeadingDepth(currentBurst.blockEl, e.shiftKey ? -1 : 1);
|
|
6745
7751
|
}
|
|
@@ -6748,6 +7754,7 @@
|
|
|
6748
7754
|
if (e.key === 'Enter') {
|
|
6749
7755
|
e.preventDefault();
|
|
6750
7756
|
if (e.shiftKey) {
|
|
7757
|
+
snapBurstIfActive(editEl, 'br-pre');
|
|
6751
7758
|
insertBrAtCaret();
|
|
6752
7759
|
snapBurstIfActive(editEl, 'br');
|
|
6753
7760
|
} else {
|
|
@@ -6897,9 +7904,10 @@
|
|
|
6897
7904
|
}
|
|
6898
7905
|
|
|
6899
7906
|
// Splits `blockEl` into two sibling BLOCKS at the caret via Range surgery —
|
|
6900
|
-
// the same extractContents()-based pattern
|
|
7907
|
+
// the same extractContents()-based pattern extractRangeInto() below uses,
|
|
6901
7908
|
// so inline formatting (a caret mid-<strong>, say) splits cleanly instead of
|
|
6902
|
-
// being torn
|
|
7909
|
+
// being torn, and sharing collectLeftovers()/dropEmptied() with it so the
|
|
7910
|
+
// split does not leave an emptied mark behind — see inside.
|
|
6903
7911
|
//
|
|
6904
7912
|
// The caret lives inside the block's own `<div class="ed-li-text">` surface,
|
|
6905
7913
|
// so the tail range runs to the END OF THAT DIV and the new block gets a
|
|
@@ -6930,10 +7938,21 @@
|
|
|
6930
7938
|
// Containment is checked BEFORE deleteContents() so the refusal below is a
|
|
6931
7939
|
// true no-op rather than "the selection was deleted, then we gave up".
|
|
6932
7940
|
if (range.startContainer !== textEl && !textEl.contains(range.startContainer)) return null;
|
|
7941
|
+
// Leftover marks, collected across this function's range operations before
|
|
7942
|
+
// they run, and dropped once the tail extraction below has run.
|
|
7943
|
+
// `textEl` — this item's own `.ed-li-text` — is the walk's terminator, not
|
|
7944
|
+
// the document root: the surface itself must survive an Enter that empties
|
|
7945
|
+
// it. Measured before this: Enter with the caret on the first character of
|
|
7946
|
+
// the <em> in `- Alpha *ital* rest` left the upper item on disk as
|
|
7947
|
+
// `- Alpha **`. Nothing was lost there, every character stayed in the right
|
|
7948
|
+
// item — the damage was the stray delimiters, which escape to `\*\*` on the
|
|
7949
|
+
// save after that.
|
|
7950
|
+
const leftovers = collectLeftovers(range, textEl);
|
|
6933
7951
|
if (!range.collapsed) range.deleteContents(); // collapses to the start point
|
|
6934
7952
|
const tailRange = document.createRange();
|
|
6935
7953
|
tailRange.setStart(range.startContainer, range.startOffset);
|
|
6936
7954
|
tailRange.setEnd(textEl, textEl.childNodes.length);
|
|
7955
|
+
collectLeftovers(tailRange, textEl, leftovers);
|
|
6937
7956
|
const tailFrag = tailRange.extractContents();
|
|
6938
7957
|
|
|
6939
7958
|
const newBlock = document.createElement('div');
|
|
@@ -6958,6 +7977,12 @@
|
|
|
6958
7977
|
newText.appendChild(tailFrag);
|
|
6959
7978
|
newBlock.appendChild(newText);
|
|
6960
7979
|
blockEl.parentNode.insertBefore(newBlock, blockEl.nextSibling);
|
|
7980
|
+
// After the delete and the tail extraction have run. Dropping between them
|
|
7981
|
+
// would judge a mark before the extraction had emptied it — the pre-fix bug
|
|
7982
|
+
// again. What does NOT matter is where this sits relative to the new item
|
|
7983
|
+
// being built: measured in review, dropping before the tail is rehomed but
|
|
7984
|
+
// after those Range operations gives byte-identical output.
|
|
7985
|
+
dropEmptied(leftovers);
|
|
6961
7986
|
return newBlock;
|
|
6962
7987
|
}
|
|
6963
7988
|
|
|
@@ -7465,6 +8490,18 @@
|
|
|
7465
8490
|
// last-item case, where the answer must be an EMPTY span (serializes to
|
|
7466
8491
|
// '', which is what takes commitListStructure()'s range-removal path).
|
|
7467
8492
|
const survivors = runEls.filter((el) => el !== li);
|
|
8493
|
+
// Load-bearing, not incidental: `li` is the item the caret is IN, and
|
|
8494
|
+
// removeListItem() detaches exactly that node — the one Chromium
|
|
8495
|
+
// currently has focus on — from the DOM. Drop this suppression span and
|
|
8496
|
+
// that detach's synchronous blur/focusout reaches resolveBurst() mid-
|
|
8497
|
+
// mutation, with the run still in its PRE-removal shape (see
|
|
8498
|
+
// `suppressLiFocusout`'s own comment above: "observed: '- <br>' written
|
|
8499
|
+
// back for an item that had just been deleted"). That costs an extra
|
|
8500
|
+
// resolveBurst({type:'li'}) committing a run nobody meant to commit, on
|
|
8501
|
+
// top of the one commitListStructure() below already performs — one
|
|
8502
|
+
// keystroke becomes two undo ops, and the second undo writes that stray
|
|
8503
|
+
// '- <br>' to disk. Do not fold this into a "the node barely moved"
|
|
8504
|
+
// cleanup.
|
|
7468
8505
|
mutateListRun(() => removeListItem(li));
|
|
7469
8506
|
// No `mutatedEl`: `li` is not IN `survivors`, and every block that is was
|
|
7470
8507
|
// left exactly as the file has it.
|
|
@@ -7487,8 +8524,15 @@
|
|
|
7487
8524
|
function handleLiKeydown(e, editEl) {
|
|
7488
8525
|
if (e.key !== 'Enter' && e.key !== 'Tab') return false;
|
|
7489
8526
|
e.preventDefault();
|
|
8527
|
+
// T21 item 2, the li half — see handleBurstKeydown()'s Tab branch. Ahead
|
|
8528
|
+
// of the run gates below because a walk needs no run: it is leaving.
|
|
8529
|
+
if (e.key === 'Tab' && currentBurst && currentBurst.arrivedByTab) {
|
|
8530
|
+
walkToAdjacentBlockByTab(currentBurst.blockEl, e.shiftKey ? -1 : 1);
|
|
8531
|
+
return true;
|
|
8532
|
+
}
|
|
7490
8533
|
// Row 2: Shift+Enter is an in-block line break, not a structural change.
|
|
7491
8534
|
if (e.key === 'Enter' && e.shiftKey) {
|
|
8535
|
+
snapBurstIfActive(editEl, 'br-pre');
|
|
7492
8536
|
insertBrAtCaret();
|
|
7493
8537
|
snapBurstIfActive(editEl, 'br');
|
|
7494
8538
|
return true;
|
|
@@ -7521,6 +8565,16 @@
|
|
|
7521
8565
|
// which is a complete no-op — nothing mutated, nothing committed, file
|
|
7522
8566
|
// byte-identical.
|
|
7523
8567
|
const oldIndent = Number(li.getAttribute('data-indent')) || 0;
|
|
8568
|
+
// INERT here: indentListItem()/outdentListItem() are attribute-only in
|
|
8569
|
+
// this flat model (setBlockIndent() on `li` and its subtree — see the
|
|
8570
|
+
// note on outdentListItem() further down this function) and never
|
|
8571
|
+
// detach the focused node, so the synchronous blur/focusout this span
|
|
8572
|
+
// exists to catch never fires, whether or not the flag is set.
|
|
8573
|
+
// Measured: primed a burst on `li`, ran this mutation, and no
|
|
8574
|
+
// focusout fired — `document.activeElement` stayed put and the node
|
|
8575
|
+
// never left the document. Kept for uniformity with the load-bearing
|
|
8576
|
+
// span in convertEmptyTopLevelLiToParagraph() above, not because this
|
|
8577
|
+
// one guards anything measured.
|
|
7524
8578
|
const changed = mutateListRun(() => {
|
|
7525
8579
|
if (!(e.shiftKey ? outdentListItem(li) : indentListItem(li))) return false;
|
|
7526
8580
|
// v3.0.1: `run` above is a PRE-mutation snapshot, captured while `li`
|
|
@@ -7583,6 +8637,9 @@
|
|
|
7583
8637
|
// Row 3: one press = one outdent, with the SAME semantics as Shift+Tab
|
|
7584
8638
|
// (adoption included). RULING F-Q: an item that OWNS a sublist takes this
|
|
7585
8639
|
// path too — its subtree travels with it, so there is nothing to orphan.
|
|
8640
|
+
// INERT here too, same measurement as the Tab span above:
|
|
8641
|
+
// outdentListItem() is attribute-only and never detaches the focused
|
|
8642
|
+
// node.
|
|
7586
8643
|
const outdented = mutateListRun(() => {
|
|
7587
8644
|
if (!outdentListItem(li)) return false;
|
|
7588
8645
|
// The surface can still hold things the user reads as "nothing" but a
|
|
@@ -7646,6 +8703,10 @@
|
|
|
7646
8703
|
return true;
|
|
7647
8704
|
}
|
|
7648
8705
|
// Row 1: split at the caret; the caret goes to the START of the new block.
|
|
8706
|
+
// INERT here too, same measurement as the Tab span above:
|
|
8707
|
+
// splitListItemAtCaret() keeps `li` itself in place (only `newLi` is a
|
|
8708
|
+
// freshly-inserted sibling) and never touches the focused node's
|
|
8709
|
+
// attachment.
|
|
7649
8710
|
const newLi = mutateListRun(() => splitListItemAtCaret(li));
|
|
7650
8711
|
if (!newLi) return true;
|
|
7651
8712
|
run = listRunOf(newLi);
|
|
@@ -7695,6 +8756,7 @@
|
|
|
7695
8756
|
// branch — so a burst-undo snapshot restore below reproduces a fully
|
|
7696
8757
|
// re-armed table, not a plain static one).
|
|
7697
8758
|
function startTableBurst(cellEl) {
|
|
8759
|
+
dropDiscardedBurst();
|
|
7698
8760
|
const tableEl = cellEl.closest('table');
|
|
7699
8761
|
const blockEl = tableEl && tableEl.closest('.ed-block');
|
|
7700
8762
|
if (!blockEl) return;
|
|
@@ -7708,6 +8770,7 @@
|
|
|
7708
8770
|
depth: null, original: burstBaselineHtml(tableEl), history,
|
|
7709
8771
|
activeCellEl: cellEl,
|
|
7710
8772
|
};
|
|
8773
|
+
watchBurstForDirtyDot(tableEl);
|
|
7711
8774
|
selToolbarEditEl = cellEl;
|
|
7712
8775
|
if (!selToolbarListener) {
|
|
7713
8776
|
selToolbarListener = onSelectionChangeForToolbar;
|
|
@@ -7731,6 +8794,21 @@
|
|
|
7731
8794
|
}
|
|
7732
8795
|
const blockEl = tableEl.closest('.ed-block');
|
|
7733
8796
|
const blockId = blockEl ? blockEl.getAttribute('data-block-id') : null;
|
|
8797
|
+
// Captured HERE, while the pressed cell is still in the document (the
|
|
8798
|
+
// delegated focusin listener that calls this passes its own `e.target`),
|
|
8799
|
+
// for the stale recovery further down — see its comment for what each
|
|
8800
|
+
// handle is for and what was measured about it. A commit resolved by the
|
|
8801
|
+
// `await` below rebinds `blocks` to the new render's list
|
|
8802
|
+
// (applyFullRender()'s own first lines), so a startLine belonging to the
|
|
8803
|
+
// pre-commit list has to be read before it.
|
|
8804
|
+
const pressedRowEl = cellEl.parentElement;
|
|
8805
|
+
const pressedRow = pressedRowEl ? allRowsOf(tableEl).indexOf(pressedRowEl) : -1;
|
|
8806
|
+
const pressedCol = pressedRowEl
|
|
8807
|
+
? Array.prototype.indexOf.call(pressedRowEl.cells, cellEl) : -1;
|
|
8808
|
+
const blockNum = blockEl ? Number(blockEl.getAttribute('data-block-id')) : null;
|
|
8809
|
+
const blockAtPress = blockNum != null ? blocks.find((b) => b.id === blockNum) : null;
|
|
8810
|
+
const pressedStartLine = blockAtPress ? blockAtPress.startLine : null;
|
|
8811
|
+
const pressedIdentity = tableIdentityOf(tableEl);
|
|
7734
8812
|
if (switching) await switching;
|
|
7735
8813
|
if (currentBurst && currentBurst.blockType === 'table' && currentBurst.blockEl &&
|
|
7736
8814
|
currentBurst.blockEl.getAttribute('data-block-id') === blockId) {
|
|
@@ -7741,9 +8819,56 @@
|
|
|
7741
8819
|
if (currentBurst) return; // a concurrent focusin (a DIFFERENT block) already won the race
|
|
7742
8820
|
let liveCellEl = cellEl;
|
|
7743
8821
|
if (!document.body.contains(cellEl)) {
|
|
7744
|
-
|
|
7745
|
-
|
|
7746
|
-
|
|
8822
|
+
// Reached when the pressed cell's node is gone from the document by the
|
|
8823
|
+
// time this focusin resumes. Instrumented on the F8 fixture (a
|
|
8824
|
+
// paragraph left dirty, then a press into the table): the mousedown's
|
|
8825
|
+
// focusout resolves that paragraph, and applyRenderResult()'s patch
|
|
8826
|
+
// gate consults domMatchesLastRender(), which reports false while
|
|
8827
|
+
// `lastParts` is still null — as it is until a render has landed — so
|
|
8828
|
+
// the render goes to applyFullRender(), whose `contentEl.innerHTML =`
|
|
8829
|
+
// rewrites .content wholesale. A probe around the `await` above
|
|
8830
|
+
// recorded route ["applyFullRender"] together with
|
|
8831
|
+
// `document.body.contains(cellEl)` false, on each reproduction driven.
|
|
8832
|
+
// Recovering the cell as `tableCellsOf(...)[0]` therefore dropped the
|
|
8833
|
+
// caret into the header cell rather than the pressed one; driven to
|
|
8834
|
+
// disk, a `QQ` typed straight after the press came back as
|
|
8835
|
+
// `| QQA | B |`.
|
|
8836
|
+
//
|
|
8837
|
+
// What DOES come back after that rewrite, measured across the same
|
|
8838
|
+
// commit: the pressed cell's (row, col) seat in its table. Naming the
|
|
8839
|
+
// table again is the other half, and the handle this recovery already
|
|
8840
|
+
// used was driven alongside the one ensureTableBurstOpen() re-resolves
|
|
8841
|
+
// through — neither subsumes the other:
|
|
8842
|
+
// a raw edit adding a LINE ahead of the table, block count unchanged
|
|
8843
|
+
// -> data-block-id still names this table; startLine names the
|
|
8844
|
+
// paragraph that grew
|
|
8845
|
+
// a raw edit adding a BLOCK ahead of the table, line count unchanged
|
|
8846
|
+
// -> data-block-id names a renumbered neighbour; startLine still
|
|
8847
|
+
// names this table
|
|
8848
|
+
// so the id is asked first and the startLine handle answers when it
|
|
8849
|
+
// cannot, for the reason ensureTableBurstOpen()'s S1 comment writes
|
|
8850
|
+
// down. Whichever one answers, tableIdentityOf() has to agree before a
|
|
8851
|
+
// caret is placed: a commit that renumbers ids can leave the captured
|
|
8852
|
+
// id pointing at a DIFFERENT table, and the same (row, col) seat
|
|
8853
|
+
// inside THAT table is one the user never pressed — driven on a
|
|
8854
|
+
// fixture holding a second table, dropping this check landed the caret
|
|
8855
|
+
// in the neighbour's body cell. A commit that moves this table's start
|
|
8856
|
+
// line AND renumbers its id leaves nothing for either handle to
|
|
8857
|
+
// resolve; no caret is then placed, the conservative half of the trade
|
|
8858
|
+
// ensureTableBurstOpen() already takes.
|
|
8859
|
+
const liveTableFrom = (candidateBlockEl) => {
|
|
8860
|
+
const t = candidateBlockEl ? blockContentEl(candidateBlockEl) : null;
|
|
8861
|
+
if (!t || !t.classList || !t.classList.contains('ed-wys-table')) return null;
|
|
8862
|
+
if (pressedIdentity == null || tableIdentityOf(t) !== pressedIdentity) return null;
|
|
8863
|
+
return t;
|
|
8864
|
+
};
|
|
8865
|
+
const liveTableEl =
|
|
8866
|
+
liveTableFrom(blockId != null
|
|
8867
|
+
? document.querySelector('.ed-block[data-block-id="' + blockId + '"]') : null) ||
|
|
8868
|
+
liveTableFrom(pressedStartLine != null ? blockElAtLine(pressedStartLine) : null);
|
|
8869
|
+
const liveRowEl = (liveTableEl && pressedRow >= 0)
|
|
8870
|
+
? allRowsOf(liveTableEl)[pressedRow] : null;
|
|
8871
|
+
liveCellEl = (liveRowEl && pressedCol >= 0) ? liveRowEl.cells[pressedCol] : null;
|
|
7747
8872
|
if (!liveCellEl || !liveCellEl.classList.contains('ed-wys-cell')) return;
|
|
7748
8873
|
liveCellEl.focus();
|
|
7749
8874
|
return;
|
|
@@ -7751,13 +8876,123 @@
|
|
|
7751
8876
|
startTableBurst(liveCellEl);
|
|
7752
8877
|
}
|
|
7753
8878
|
|
|
8879
|
+
// Tab / Shift+Tab inside a table. Two things were measured on a table whose
|
|
8880
|
+
// cells were A, B, c1 and c2, before this version, and both are fixed here:
|
|
8881
|
+
//
|
|
8882
|
+
// * At either end of the cell list the target index was CLAMPED, so the
|
|
8883
|
+
// press resolved back to the cell it started from. The handler still ran
|
|
8884
|
+
// and its preventDefault() was readable in the bubble phase, so the
|
|
8885
|
+
// browser's own Tab traversal was consumed as well. Pressed in c2 it
|
|
8886
|
+
// left document.activeElement on that same TD, the block count and the
|
|
8887
|
+
// file's bytes unchanged, and the only visible effect was the caret
|
|
8888
|
+
// moving to the end of the cell it was already in. Shift+Tab in A was
|
|
8889
|
+
// the same dead key.
|
|
8890
|
+
// * The landing placed a COLLAPSED caret at the end of the target cell, so
|
|
8891
|
+
// typing straight after a Tab appended rather than replaced: Tab out of
|
|
8892
|
+
// A and then typing ZZ left B holding 'BZZ'.
|
|
8893
|
+
//
|
|
8894
|
+
// Now a step within the table SELECTS the target cell's contents, which is
|
|
8895
|
+
// what makes "Tab, then type" a replacement the way a spreadsheet does, and
|
|
8896
|
+
// a step off either end leaves the table — leaveTableByTab() below.
|
|
8897
|
+
//
|
|
8898
|
+
// Tab does not add a row past the last cell. That is the product's answer,
|
|
8899
|
+
// not something missing here.
|
|
7754
8900
|
function moveActiveTableCell(cellEl, delta) {
|
|
7755
8901
|
const tableEl = cellEl.closest('table');
|
|
7756
8902
|
const cells = tableCellsOf(tableEl);
|
|
7757
|
-
const
|
|
7758
|
-
|
|
7759
|
-
|
|
7760
|
-
|
|
8903
|
+
const next = cells.indexOf(cellEl) + delta;
|
|
8904
|
+
if (next >= 0 && next < cells.length) {
|
|
8905
|
+
const target = cells[next];
|
|
8906
|
+
target.focus();
|
|
8907
|
+
selectSurfaceContents(target);
|
|
8908
|
+
// Keep the floating .ed-seltb down over exactly this selection — see
|
|
8909
|
+
// `tabSelectedCellEl`'s own comment for what releases it again.
|
|
8910
|
+
tabSelectedCellEl = target;
|
|
8911
|
+
return;
|
|
8912
|
+
}
|
|
8913
|
+
leaveTableByTab(tableEl, delta);
|
|
8914
|
+
}
|
|
8915
|
+
|
|
8916
|
+
// Tab off either end of a table: to the nearest block in that direction that
|
|
8917
|
+
// focusableSurfaceOf() finds a surface in, degraded blocks stepped over.
|
|
8918
|
+
//
|
|
8919
|
+
// The burst is resolved FIRST, rather than focusing the destination and
|
|
8920
|
+
// leaving the resulting focusout to do it. Focus leaving the table is itself
|
|
8921
|
+
// what ends a table burst, and a burst carrying an edit ends by committing
|
|
8922
|
+
// and re-rendering, which rewrites '.content' wholesale. Driven on a table
|
|
8923
|
+
// left dirty when the press lands, watching focusin/focusout for that one
|
|
8924
|
+
// press:
|
|
8925
|
+
//
|
|
8926
|
+
// resolve first (this shape) — focusout on the cell, then a single
|
|
8927
|
+
// focusin, on the destination this function re-resolved. The element
|
|
8928
|
+
// captured before the resolution was gone from the document by then;
|
|
8929
|
+
// ablating the re-resolution and focusing THAT captured node instead
|
|
8930
|
+
// landed the caret on no editable surface — document.activeElement came
|
|
8931
|
+
// back carrying no class and the whole page as its text.
|
|
8932
|
+
// focus first — focusout on the cell, focusin on the destination,
|
|
8933
|
+
// focusout on it again as the render detaches it, focusin on its
|
|
8934
|
+
// replacement. The caret does arrive, by a longer road and through a
|
|
8935
|
+
// node that is being taken apart underneath it.
|
|
8936
|
+
//
|
|
8937
|
+
// Resolving first and re-resolving afterwards is also the shape
|
|
8938
|
+
// ensureTableBurstOpen() above already uses against the same hazard.
|
|
8939
|
+
//
|
|
8940
|
+
// captureBlockIdentity() / reresolveBlockEl() is the handle, for the reason
|
|
8941
|
+
// their own comment writes down: blockmap.js renumbers ids from zero on
|
|
8942
|
+
// every render, so a captured id can name the destination's NEIGHBOUR after
|
|
8943
|
+
// a commit that changed the block count, and the start line plus the block's
|
|
8944
|
+
// own source lines is what proves the block sitting there afterwards is the
|
|
8945
|
+
// one the press was aimed at. When it cannot be proven nothing is focused —
|
|
8946
|
+
// the conservative half of the trade the gutter gestures already take.
|
|
8947
|
+
//
|
|
8948
|
+
// Nothing is focused when no such block exists either, and the caller's
|
|
8949
|
+
// preventDefault() still stands over that press. Handing it back to the
|
|
8950
|
+
// browser is not the alternative: buildGutterHandle() sets tabindex="-1" on
|
|
8951
|
+
// the per-block chrome precisely because a browser-run Tab walks into it,
|
|
8952
|
+
// and the document-level Tab branch near the bottom of this file already
|
|
8953
|
+
// treats a Tab from inside an '.ed-block' as a deliberate silent no-op.
|
|
8954
|
+
// T21 item 2 split this in two. The body below is the LANDING, and it is no
|
|
8955
|
+
// longer only a table's exit: once a Tab has moved the caret across a block
|
|
8956
|
+
// boundary, the next Tab has to be able to move it again, so the block it
|
|
8957
|
+
// just landed on calls the same function. leaveTableByTab() underneath is
|
|
8958
|
+
// the table's own name for it, kept because the whole comment above is
|
|
8959
|
+
// written about that gesture.
|
|
8960
|
+
//
|
|
8961
|
+
// `arrivedByTab` is the landing's mark on the burst it just opened, and the
|
|
8962
|
+
// burst object is the right place for it: bursts are per focus, so the mark
|
|
8963
|
+
// cannot outlive the surface it describes, and clicking into some other
|
|
8964
|
+
// block opens a burst without it. handleBurstKeydown()/handleLiKeydown()
|
|
8965
|
+
// read it to decide that a further Tab means "move on" rather than "edit
|
|
8966
|
+
// this" — see the measurement in handleBurstKeydown()'s Tab branch.
|
|
8967
|
+
//
|
|
8968
|
+
// The mark is set for whatever kind of burst the landing opened, and only
|
|
8969
|
+
// the paragraph/heading/li Tab branches ever READ it: a table cell's Tab is
|
|
8970
|
+
// owned by handleTableCellKeydown(), which already means "move on" (to the
|
|
8971
|
+
// next cell, and off the end back through here). Scoping the WRITE to
|
|
8972
|
+
// non-table destinations as well was tried and dropped — ablating that
|
|
8973
|
+
// condition alone changed no measured behaviour (the row that Tabs out of
|
|
8974
|
+
// one table, into the next, and then on to its second cell stayed green
|
|
8975
|
+
// either way), so it was a condition no test could speak for.
|
|
8976
|
+
async function walkToAdjacentBlockByTab(fromBlockEl, delta) {
|
|
8977
|
+
const destBlockEl = adjacentFocusableBlock(fromBlockEl, delta);
|
|
8978
|
+
if (!destBlockEl) return;
|
|
8979
|
+
const identity = captureBlockIdentity(destBlockEl);
|
|
8980
|
+
const ok = await switchAwayFrom();
|
|
8981
|
+
if (!ok) return;
|
|
8982
|
+
const liveBlockEl = document.body.contains(destBlockEl)
|
|
8983
|
+
? destBlockEl : reresolveBlockEl(identity);
|
|
8984
|
+
const surface = focusableSurfaceOf(liveBlockEl);
|
|
8985
|
+
if (!surface) return;
|
|
8986
|
+
surface.focus();
|
|
8987
|
+
// A caret at the start of the destination, not a selection of the whole
|
|
8988
|
+
// of it: selecting a paragraph the user only tabbed past would put every
|
|
8989
|
+
// word of it one keystroke from being replaced.
|
|
8990
|
+
placeCaretAtSurfaceStart(surface);
|
|
8991
|
+
if (currentBurst) currentBurst.arrivedByTab = true;
|
|
8992
|
+
}
|
|
8993
|
+
|
|
8994
|
+
async function leaveTableByTab(tableEl, delta) {
|
|
8995
|
+
await walkToAdjacentBlockByTab(tableEl ? tableEl.closest('.ed-block') : null, delta);
|
|
7761
8996
|
}
|
|
7762
8997
|
|
|
7763
8998
|
// Esc inside a table burst: revert to the pre-focus baseline and end the
|
|
@@ -7783,15 +9018,25 @@
|
|
|
7783
9018
|
// as revertBurstAndEnd() above.
|
|
7784
9019
|
const wasPristineForThisBlock = !!(pristineInsert && pristineInsert.blockId === burst.blockId);
|
|
7785
9020
|
if (wasPristineForThisBlock) pristineInsert = null;
|
|
7786
|
-
|
|
9021
|
+
// T21 item 1: same split as revertBurstAndEnd() above — a pristine insert
|
|
9022
|
+
// is about to lose its whole block, so nothing is stashed for it.
|
|
9023
|
+
const cells = tableCellsOf(tableEl);
|
|
9024
|
+
const cellIdx = cells.indexOf(burst.activeCellEl || cellEl);
|
|
9025
|
+
if (wasPristineForThisBlock) burst.history.dispose();
|
|
7787
9026
|
currentBurst = null;
|
|
7788
9027
|
resetSelToolbarState();
|
|
7789
9028
|
hideTableInsertBubbles();
|
|
7790
9029
|
hideTableGrips();
|
|
7791
9030
|
hideTableEdgeMenu();
|
|
7792
9031
|
cancelTeDrag();
|
|
7793
|
-
|
|
7794
|
-
|
|
9032
|
+
if (wasPristineForThisBlock) {
|
|
9033
|
+
tableEl.innerHTML = burst.original;
|
|
9034
|
+
await discardPristineInsert();
|
|
9035
|
+
return;
|
|
9036
|
+
}
|
|
9037
|
+
stashDiscardedTableBurst(burst, tableEl, cellIdx, () => {
|
|
9038
|
+
tableEl.innerHTML = burst.original;
|
|
9039
|
+
});
|
|
7795
9040
|
}
|
|
7796
9041
|
|
|
7797
9042
|
// Ctrl+Z inside a table burst: symmetric to burstUndo() above, but a
|
|
@@ -7891,6 +9136,7 @@
|
|
|
7891
9136
|
if (!currentBurst || currentBurst.blockType !== 'table' || currentBurst.editEl !== tableEl) return;
|
|
7892
9137
|
if (e.key === 'Enter') {
|
|
7893
9138
|
e.preventDefault();
|
|
9139
|
+
snapBurstIfActive(tableEl, 'br-pre');
|
|
7894
9140
|
insertBrAtCaret();
|
|
7895
9141
|
snapBurstIfActive(tableEl, 'br');
|
|
7896
9142
|
return;
|
|
@@ -8205,6 +9451,7 @@
|
|
|
8205
9451
|
// click) — never the possibly-now-detached `tableEl` itself.
|
|
8206
9452
|
const liveTableEl = await ensureTableBurstOpen(tableEl);
|
|
8207
9453
|
if (!liveTableEl) return;
|
|
9454
|
+
currentBurst.history.snap('insert-col-pre');
|
|
8208
9455
|
insertColumn(liveTableEl, colIndex);
|
|
8209
9456
|
armNewTableCells(liveTableEl);
|
|
8210
9457
|
currentBurst.history.snap('insert-col');
|
|
@@ -8218,11 +9465,25 @@
|
|
|
8218
9465
|
const afterRowIndex = Number(rowInsertBubble.dataset.afterRowIndex); // -1 -> header (first body row)
|
|
8219
9466
|
if (!tableEl || Number.isNaN(afterRowIndex)) return;
|
|
8220
9467
|
// Final-review Finding 6: same live-table swap as onColInsertBubbleClick()
|
|
8221
|
-
// above —
|
|
8222
|
-
//
|
|
9468
|
+
// above — the table element reference has to be re-resolved.
|
|
9469
|
+
//
|
|
9470
|
+
// ⚠ RETRACTION. An earlier revision of this comment went on to say that
|
|
9471
|
+
// `afterRowIndex`, being a plain integer, stays valid across that swap.
|
|
9472
|
+
// Ruling T3-9 measured the opposite and parked the fix in v3.4.0: the
|
|
9473
|
+
// index names a row of the table the bubble was positioned against, and
|
|
9474
|
+
// after a handover it is read against a different element's row list, so
|
|
9475
|
+
// `bodyRowsOf(liveTableEl)[afterRowIndex]` comes back `undefined`,
|
|
9476
|
+
// `afterRow` is falsy, and insertRow() below takes its
|
|
9477
|
+
// `tbody.insertBefore(newRow, tbody.firstChild)` branch — the same branch
|
|
9478
|
+
// `afterRowIndex: -1` uses deliberately. The row lands at the TOP of the
|
|
9479
|
+
// table rather than the gesture being dropped. Nothing here guards it yet.
|
|
9480
|
+
// Task 14's hide-on-approach fix (see updateTableInsertBubbles() below)
|
|
9481
|
+
// made this bubble reliably pressable, which widened the exposure of that
|
|
9482
|
+
// deferred defect rather than narrowing it.
|
|
8223
9483
|
const liveTableEl = await ensureTableBurstOpen(tableEl);
|
|
8224
9484
|
if (!liveTableEl) return;
|
|
8225
9485
|
const afterRow = afterRowIndex >= 0 ? bodyRowsOf(liveTableEl)[afterRowIndex] : null;
|
|
9486
|
+
currentBurst.history.snap('insert-row-pre');
|
|
8226
9487
|
insertRow(liveTableEl, afterRow);
|
|
8227
9488
|
armNewTableCells(liveTableEl);
|
|
8228
9489
|
currentBurst.history.snap('insert-row');
|
|
@@ -8242,7 +9503,52 @@
|
|
|
8242
9503
|
// pointer (Event#target) at those coordinates.
|
|
8243
9504
|
function updateTableInsertBubbles(x, y, target) {
|
|
8244
9505
|
const blockEl = target && target.closest && target.closest('.ed-block[data-block-type="table"]');
|
|
8245
|
-
|
|
9506
|
+
// v3.3.0 F7: a raised bubble, and the row grip, are `position: fixed`
|
|
9507
|
+
// overlays on document.body painted OVER the very band that raises the
|
|
9508
|
+
// bubble — the bubble is centred on the boundary it offers and the row
|
|
9509
|
+
// grip straddles the table's left border — so a pointer reaching for a
|
|
9510
|
+
// bubble arrives on one of them and `target` stops being a table
|
|
9511
|
+
// descendant. Resolving the table from `target` alone then hides the
|
|
9512
|
+
// bubble the pointer is reaching for, the next mousemove finds a cell
|
|
9513
|
+
// again and raises it, and the bubble flickers along the whole
|
|
9514
|
+
// approach; whether it is up when the press lands comes down to where
|
|
9515
|
+
// the last move happened to fall. MEASURED on this repo's puppeteer,
|
|
9516
|
+
// walking the pointer horizontally across the row bubble's own band: the
|
|
9517
|
+
// mousemove targets alternated between the bubble and whatever is
|
|
9518
|
+
// painted under it, and the bubble went hidden on the alternating
|
|
9519
|
+
// events where the bubble itself was the target. So fall back to the table
|
|
9520
|
+
// this hover already resolved, and leave the proximity tests below to
|
|
9521
|
+
// decide visibility from x/y exactly as they do for any other pointer
|
|
9522
|
+
// position, rather than freezing whatever was last shown.
|
|
9523
|
+
//
|
|
9524
|
+
// The `.ed-block::before` corridor needs no entry here: it is a
|
|
9525
|
+
// pseudo-element OF the block, so a hit inside it reports the block as
|
|
9526
|
+
// `target` and the closest() above already matches (MEASURED at
|
|
9527
|
+
// x = tableLeft - 10: `DIV.ed-block`). That is why nothing in this fix
|
|
9528
|
+
// touches its `pointer-events`, which lib/md2doc.js documents as
|
|
9529
|
+
// deliberately absent — being hit-testable is what that corridor is for.
|
|
9530
|
+
// That "for" is this fix's hit-test above, not the corridor's ONLY job:
|
|
9531
|
+
// the same rule also carries the block's own ⠿/+ gutter :hover reveal
|
|
9532
|
+
// (lib/md2doc.js's own comment on `.ed-block::before` — "deliberately
|
|
9533
|
+
// NOT pointer-events: none: that would stop it being hit-tested, which
|
|
9534
|
+
// is the entire mechanism"). This fix measured that the hit-test still
|
|
9535
|
+
// resolves to the block for the bubble; it did not re-measure whether
|
|
9536
|
+
// the gutter's hover reveal still behaves as before, so setting
|
|
9537
|
+
// `pointer-events: none` here to simplify the table-bubble case remains
|
|
9538
|
+
// untried, not merely undesirable.
|
|
9539
|
+
//
|
|
9540
|
+
// The COLUMN grip is deliberately absent from the list below: it is
|
|
9541
|
+
// anchored on the header cell's CENTRE while the column bubble's band
|
|
9542
|
+
// hugs that same cell's RIGHT edge. MEASURED on the fixture used to
|
|
9543
|
+
// drive this fix, the column grip spanned x [639.3, 667.3] while the
|
|
9544
|
+
// header cells' right edges sat at 894 and 1375.5 — the grip's rect
|
|
9545
|
+
// never reaches either band, so a guard for it here is one no test on
|
|
9546
|
+
// this fixture could hold down.
|
|
9547
|
+
let tableEl = blockEl ? blockContentEl(blockEl) : null;
|
|
9548
|
+
if (!tableEl && target && target.closest &&
|
|
9549
|
+
target.closest('.ed-tb-insert, .ed-te-grip-row')) {
|
|
9550
|
+
tableEl = hoveredInsertTableEl;
|
|
9551
|
+
}
|
|
8246
9552
|
if (!tableEl || !tableEl.classList.contains('ed-wys-table')) {
|
|
8247
9553
|
hideTableInsertBubbles();
|
|
8248
9554
|
hoveredInsertTableEl = null;
|
|
@@ -8337,8 +9643,11 @@
|
|
|
8337
9643
|
// currently holds focus, so Chromium fires a synchronous focusout
|
|
8338
9644
|
// mid-mutation — exactly the quirk tableBurstUndo()/tableBurstRedo()'s
|
|
8339
9645
|
// innerHTML-snapshot restore has to guard. performRowDrop() therefore
|
|
8340
|
-
// wraps it in `suppressTableFocusout`
|
|
8341
|
-
//
|
|
9646
|
+
// wraps it in `suppressTableFocusout` — one of the sites that set that flag,
|
|
9647
|
+
// alongside tableBurstUndo(), tableBurstRedo(), performColDrop() and
|
|
9648
|
+
// restoreDiscardedBurst()'s table branch; test/editor-client.test.js pins
|
|
9649
|
+
// the set and audits each one for its try/finally — and puts focus back
|
|
9650
|
+
// explicitly via restoreTableFocus(). An earlier
|
|
8342
9651
|
// revision moved the <tr> with a plain `insertBefore()`, which reparents
|
|
8343
9652
|
// in one synchronous step, never blurs, and needed no guard — the header
|
|
8344
9653
|
// promotion requirement is what retired that. The menu's delete ops (which
|
|
@@ -8347,9 +9656,17 @@
|
|
|
8347
9656
|
//
|
|
8348
9657
|
// Both the menu (delete/align) and the drag's DOM move are burst
|
|
8349
9658
|
// mutations: ensureTableBurstOpen() (Task 5, above) auto-starts a burst
|
|
8350
|
-
// on this table if none is open yet, then the op
|
|
8351
|
-
//
|
|
8352
|
-
// 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
|
|
8353
9670
|
// Phase-2 click-select edit toolbar used for these same three guards (see
|
|
8354
9671
|
// commit 0661cde, now dead code with zero call sites — this task is what
|
|
8355
9672
|
// revives deleteRow()/deleteColumn()/cycleColumnAlign() unchanged, per
|
|
@@ -8478,6 +9795,35 @@
|
|
|
8478
9795
|
teEdgeMenu.hidden = false;
|
|
8479
9796
|
}
|
|
8480
9797
|
|
|
9798
|
+
// The column menu's vertical placement, shared by showColumnMenu() and by
|
|
9799
|
+
// runCycleAlign()'s reposition so the two can never drift apart. Above the
|
|
9800
|
+
// header cell by default, BELOW it when there is not enough room above —
|
|
9801
|
+
// the same "no room above, go below" fallback positionSelToolbar() uses.
|
|
9802
|
+
//
|
|
9803
|
+
// The floor is `.ed-toolbar`'s height, not zero. That toolbar is
|
|
9804
|
+
// `position: fixed` at the top of the viewport at z-index 101 and this menu
|
|
9805
|
+
// is at 12, so a menu placed inside that band is painted UNDER it and stops
|
|
9806
|
+
// being clickable while still reporting `hidden === false` and a perfectly
|
|
9807
|
+
// ordinary rectangle. Measured at 800x600 on a document whose first block is
|
|
9808
|
+
// the table: the menu's own top was 20.5px and
|
|
9809
|
+
// `document.elementsFromPoint()` at the 對齊 button's own centre returned
|
|
9810
|
+
// `DIV.ed-toolbar` first, with the button second — so the click landed on the
|
|
9811
|
+
// toolbar and runCycleAlign() never ran.
|
|
9812
|
+
//
|
|
9813
|
+
// That band became reachable when the edit page stopped reserving 60px for
|
|
9814
|
+
// the .sidebar-toggle it hides (lib/md2doc.js's `@media (max-width: 1080px)`
|
|
9815
|
+
// .page-layout rule): the same first-block table's header top moved from
|
|
9816
|
+
// ~104px to ~60px, which puts the menu under the toolbar while the grip that
|
|
9817
|
+
// opens it is still comfortably clickable. Both numbers were measured on
|
|
9818
|
+
// this branch, on either side of that change.
|
|
9819
|
+
function placeTeMenuAboveCell(r) {
|
|
9820
|
+
const topInset = parseFloat(getComputedStyle(document.documentElement)
|
|
9821
|
+
.getPropertyValue('--ed-toolbar-h')) || 0;
|
|
9822
|
+
let top = r.top - teEdgeMenu.offsetHeight - TE_MENU_GAP_PX;
|
|
9823
|
+
if (top < topInset) top = r.bottom + TE_MENU_GAP_PX;
|
|
9824
|
+
teEdgeMenu.style.top = top + 'px';
|
|
9825
|
+
}
|
|
9826
|
+
|
|
8481
9827
|
function showColumnMenu(tableEl, colIndex) {
|
|
8482
9828
|
if (teMenuKind === 'col' && teMenuTableEl === tableEl && teMenuColIndex === colIndex) {
|
|
8483
9829
|
hideTableEdgeMenu(); // re-clicking the same column's edge toggles the menu closed
|
|
@@ -8494,7 +9840,15 @@
|
|
|
8494
9840
|
const cell = headerRow ? headerRow.cells[colIndex] : null;
|
|
8495
9841
|
const r = (cell || tableEl).getBoundingClientRect();
|
|
8496
9842
|
positionTeMenu(r.left, r.top);
|
|
8497
|
-
|
|
9843
|
+
// Task 9 fix round 1 (Ruling T9-5) left this line unfloored, on a
|
|
9844
|
+
// measurement that said the col grip stops being clickable before the
|
|
9845
|
+
// header's top can get low enough to matter. That measurement is
|
|
9846
|
+
// RETRACTED: it only ever asked whether the menu's top went NEGATIVE, and
|
|
9847
|
+
// the menu is unreachable well before that — it goes under `.ed-toolbar`,
|
|
9848
|
+
// which is 44px tall and paints over this menu. See
|
|
9849
|
+
// placeTeMenuAboveCell() for what was measured instead, and for why the
|
|
9850
|
+
// band is reachable by an ordinary mouse now.
|
|
9851
|
+
placeTeMenuAboveCell(r);
|
|
8498
9852
|
}
|
|
8499
9853
|
|
|
8500
9854
|
function showRowMenu(tableEl, rowEl) {
|
|
@@ -8513,6 +9867,20 @@
|
|
|
8513
9867
|
const tableRect = tableEl.getBoundingClientRect();
|
|
8514
9868
|
positionTeMenu(tableRect.left, r.top);
|
|
8515
9869
|
teEdgeMenu.style.left = (tableRect.left - teEdgeMenu.offsetWidth - TE_MENU_GAP_PX) + 'px';
|
|
9870
|
+
// Task 9 fix round 1 (Ruling T9-5): same clamp shape as
|
|
9871
|
+
// openConvertSubmenu()'s .ed-handle-submenu fix — measure the menu's
|
|
9872
|
+
// own rendered bottom now that positionTeMenu() has placed and shown
|
|
9873
|
+
// it, and shift `top` up by the bottom overflow when there is one.
|
|
9874
|
+
// `.ed-te-menu` is `position: fixed` (lib/md2doc.js), so its own
|
|
9875
|
+
// getBoundingClientRect() is already in viewport coordinates. Scoped to
|
|
9876
|
+
// showRowMenu(): the row menu's `top` starts at the row's own top,
|
|
9877
|
+
// which is the case the review measured overflowing at an ordinary
|
|
9878
|
+
// window height. showColumnMenu() positions upward from the header
|
|
9879
|
+
// instead and carries its own floor — placeTeMenuAboveCell().
|
|
9880
|
+
const overflowBottom = teEdgeMenu.getBoundingClientRect().bottom - window.innerHeight;
|
|
9881
|
+
if (overflowBottom > 0) {
|
|
9882
|
+
teEdgeMenu.style.top = (r.top - overflowBottom) + 'px';
|
|
9883
|
+
}
|
|
8516
9884
|
}
|
|
8517
9885
|
|
|
8518
9886
|
// Moves `currentBurst.activeCellEl` to a cell that will SURVIVE deleting
|
|
@@ -8568,6 +9936,7 @@
|
|
|
8568
9936
|
showBanner('無法刪除最後一欄', null, null);
|
|
8569
9937
|
return;
|
|
8570
9938
|
}
|
|
9939
|
+
currentBurst.history.snap('delete-col-pre');
|
|
8571
9940
|
refocusAwayFromColumn(liveTableEl, colIndex);
|
|
8572
9941
|
deleteColumn(liveTableEl, colIndex);
|
|
8573
9942
|
currentBurst.history.snap('delete-col');
|
|
@@ -8599,6 +9968,7 @@
|
|
|
8599
9968
|
showBanner('無法刪除最後一列', null, null);
|
|
8600
9969
|
return;
|
|
8601
9970
|
}
|
|
9971
|
+
currentBurst.history.snap('delete-row-pre');
|
|
8602
9972
|
refocusAwayFromRow(liveTableEl, liveRowEl);
|
|
8603
9973
|
deleteRow(liveRowEl);
|
|
8604
9974
|
currentBurst.history.snap('delete-row');
|
|
@@ -8636,6 +10006,7 @@
|
|
|
8636
10006
|
try {
|
|
8637
10007
|
liveTableEl = await ensureTableBurstOpen(tableEl);
|
|
8638
10008
|
if (!liveTableEl) { suppressEdgeMenuAutoHide = false; return; }
|
|
10009
|
+
currentBurst.history.snap('align-col-pre');
|
|
8639
10010
|
cycleColumnAlign(liveTableEl, colIndex);
|
|
8640
10011
|
currentBurst.history.snap('align-col');
|
|
8641
10012
|
} catch (e) {
|
|
@@ -8685,7 +10056,7 @@
|
|
|
8685
10056
|
if (cell) {
|
|
8686
10057
|
const r = cell.getBoundingClientRect();
|
|
8687
10058
|
positionTeMenu(r.left, r.top);
|
|
8688
|
-
|
|
10059
|
+
placeTeMenuAboveCell(r);
|
|
8689
10060
|
}
|
|
8690
10061
|
});
|
|
8691
10062
|
}
|
|
@@ -9018,6 +10389,16 @@
|
|
|
9018
10389
|
// whatever was last shown untouched instead; hideTableGrips() (called
|
|
9019
10390
|
// from table-leave, burst-end, and drag-start elsewhere) already covers
|
|
9020
10391
|
// every path that actually needs to clear it.
|
|
10392
|
+
//
|
|
10393
|
+
// ⚠ KNOWN GAP, deferred by Ruling T14-5 to v3.4.0: the list below names the
|
|
10394
|
+
// two grips and NOT '.ed-tb-insert', so the hover-insert + bubble — which
|
|
10395
|
+
// is the same kind of `position: fixed` overlay painted over the same band
|
|
10396
|
+
// — falls through to the branches beneath and takes the ⠿ grips down while
|
|
10397
|
+
// the pointer stands on it. The reason it was not closed here is that
|
|
10398
|
+
// widening this test also widens the early return's setGutterKeep() call,
|
|
10399
|
+
// whose behaviour on the + bubble is unmeasured. A reader of this function
|
|
10400
|
+
// should know it is knowingly incomplete rather than infer the list is the
|
|
10401
|
+
// whole set.
|
|
9021
10402
|
if (target && target.closest && (target.closest('.ed-te-grip-row') || target.closest('.ed-te-grip-col'))) {
|
|
9022
10403
|
// v3.0.2: only the ROW grip is the one that covers the ⠿'s approach.
|
|
9023
10404
|
// Testing the hit rather than reusing the branch condition matters —
|
|
@@ -9364,6 +10745,7 @@
|
|
|
9364
10745
|
order.splice(toIndex > rowIndex ? toIndex - 1 : toIndex, 0, moved);
|
|
9365
10746
|
if (order.every((row, i) => row === liveRows[i])) return; // 原地放回:不動 DOM、不 snap
|
|
9366
10747
|
|
|
10748
|
+
currentBurst.history.snap('drag-row-pre');
|
|
9367
10749
|
const aligns = columnAlignsOf(liveTableEl);
|
|
9368
10750
|
const activeIndex = (currentBurst && currentBurst.activeCellEl)
|
|
9369
10751
|
? tableCellsOf(liveTableEl).indexOf(currentBurst.activeCellEl) : -1;
|
|
@@ -9421,6 +10803,7 @@
|
|
|
9421
10803
|
// 看不出任何異常。寧可整個不動。
|
|
9422
10804
|
const dropRows = allRowsOf(liveTableEl);
|
|
9423
10805
|
if (!dropRows.length || dropRows.some((row) => !row.cells[fromIndex])) return;
|
|
10806
|
+
currentBurst.history.snap('drag-col-pre');
|
|
9424
10807
|
suppressTableFocusout = true;
|
|
9425
10808
|
try {
|
|
9426
10809
|
dropRows.forEach((row) => {
|
|
@@ -10508,6 +11891,60 @@
|
|
|
10508
11891
|
// is active, so this always names whatever element the toolbar's mark
|
|
10509
11892
|
// toggles should act on right now.
|
|
10510
11893
|
let selToolbarEditEl = null;
|
|
11894
|
+
// Task 15: the cell whose whole contents the Tab walk selected, or null.
|
|
11895
|
+
// Tab now writes a full-cell selection on every press, and
|
|
11896
|
+
// onSelectionChangeForToolbar() below asks only "is there a non-collapsed
|
|
11897
|
+
// selection inside `selToolbarEditEl`" — which a Tab-made selection answers
|
|
11898
|
+
// yes to, so the floating .ed-seltb rose over the cell on every press.
|
|
11899
|
+
//
|
|
11900
|
+
// Keyed on the CELL ELEMENT, never on a Range. A release condition phrased
|
|
11901
|
+
// as "the selection went collapsed" would fire on its own during a
|
|
11902
|
+
// re-render: measured across a commit that took the full-render route, a
|
|
11903
|
+
// Range built over a cell before it neither threw nor reported itself
|
|
11904
|
+
// broken afterwards, while the cell it was built from did — see the probe
|
|
11905
|
+
// figures quoted with the release check in onSelectionChangeForToolbar().
|
|
11906
|
+
//
|
|
11907
|
+
// Released the moment the live selection stops being exactly this cell's
|
|
11908
|
+
// contents, which is also what keeps a HAND-MADE selection out of the
|
|
11909
|
+
// suppression: the boundary points written here are the cell element itself
|
|
11910
|
+
// at offset zero and at its child count, while a selection the user builds
|
|
11911
|
+
// is anchored in the text. Driven on a cell holding 'c2', a drag across it,
|
|
11912
|
+
// a double click, a triple click and Ctrl+A each read back a startContainer
|
|
11913
|
+
// of '#text', so none of them matches.
|
|
11914
|
+
//
|
|
11915
|
+
// What this cannot reach, and does not cause: a press that begins INSIDE the
|
|
11916
|
+
// standing selection is Chromium's own drag-the-selected-text gesture.
|
|
11917
|
+
// Driven straight after a Tab, it dispatched dragstart and dragend and not
|
|
11918
|
+
// one selectionchange, so no release condition of any shape gets a hearing —
|
|
11919
|
+
// the same with this suppression removed. Anything that collapses the
|
|
11920
|
+
// selection first (a click, an arrow key) puts an ordinary drag back, and
|
|
11921
|
+
// that drag releases here. Meanwhile the FIXED toolbar's inline buttons stay
|
|
11922
|
+
// enabled throughout: hasFormattableSelection() answers for those and knows
|
|
11923
|
+
// nothing about this flag, measured with B live over a suppressed selection.
|
|
11924
|
+
let tabSelectedCellEl = null;
|
|
11925
|
+
// Task 16: set when positionSelToolbar() below takes .ed-seltb down over a
|
|
11926
|
+
// selection that left the viewport while staying alive, and cleared when
|
|
11927
|
+
// the bar goes back up. Its lifetime has to reach OUTSIDE the
|
|
11928
|
+
// `selToolbar.parentNode` gate in onAnyScroll(), because that gate is what
|
|
11929
|
+
// the removal turns against the bar: with the node detached the gate
|
|
11930
|
+
// returns before anything can re-ask.
|
|
11931
|
+
//
|
|
11932
|
+
// Measured on a paragraph selection in a document of filler paragraphs,
|
|
11933
|
+
// before this flag existed — scrolled to +150 the bar followed, its top
|
|
11934
|
+
// moving 192 -> 42; at +1500 it was gone from the DOM; scrolled back to the
|
|
11935
|
+
// top it stayed gone while the selection still read non-collapsed with its
|
|
11936
|
+
// rect back at top 234. A synthetic selectionchange dispatched at that
|
|
11937
|
+
// point put the bar back at top 192, which is what says the missing state
|
|
11938
|
+
// was the permission to re-ask rather than a live selection.
|
|
11939
|
+
//
|
|
11940
|
+
// Opposite polarity to tabSelectedCellEl above: that flag holds the bar
|
|
11941
|
+
// DOWN, this one lets it back UP. Keyed on neither a cell nor a Range — the
|
|
11942
|
+
// defect reproduces on a paragraph, where there is no cell to key on, and
|
|
11943
|
+
// the re-ask reads the selection back off the document when it fires. It
|
|
11944
|
+
// says nothing about WHY the bar is down right now: measured after a Tab
|
|
11945
|
+
// walk moved on from a scrolled-away selection, it read true while Task
|
|
11946
|
+
// 15's suppression was what held the bar down.
|
|
11947
|
+
let selToolbarOffViewport = false;
|
|
10511
11948
|
// The currently-attached onSelectionChangeForToolbar function reference (or
|
|
10512
11949
|
// null) — kept at this module scope, NOT just inside openWysiwygEditor()'s
|
|
10513
11950
|
// closure, specifically so a call site OUTSIDE that closure (rerenderAll()
|
|
@@ -10528,6 +11965,13 @@
|
|
|
10528
11965
|
}
|
|
10529
11966
|
hideSelToolbar();
|
|
10530
11967
|
selToolbarEditEl = null;
|
|
11968
|
+
// Task 15: the Tab suppression is part of the same "a session is open"
|
|
11969
|
+
// state, so it goes down with the rest of it.
|
|
11970
|
+
tabSelectedCellEl = null;
|
|
11971
|
+
// Task 16: same reasoning — "the bar is down because it scrolled out of
|
|
11972
|
+
// view" is a statement about an open session's selection, and there is no
|
|
11973
|
+
// session left to make it about.
|
|
11974
|
+
selToolbarOffViewport = false;
|
|
10531
11975
|
}
|
|
10532
11976
|
|
|
10533
11977
|
// Shows/repositions/hides the floating selection toolbar as the selection
|
|
@@ -10569,6 +12013,37 @@
|
|
|
10569
12013
|
hideSelToolbar();
|
|
10570
12014
|
return;
|
|
10571
12015
|
}
|
|
12016
|
+
// Task 15 (T15-5): a selection the Tab walk wrote keeps this bar down.
|
|
12017
|
+
// Asked here, below the collapsed exit above, on purpose — focusing a
|
|
12018
|
+
// cell parks a collapsed selection in it a moment before the walk writes
|
|
12019
|
+
// the real one, and releasing on that would let the bar up again on the
|
|
12020
|
+
// very press this is suppressing.
|
|
12021
|
+
//
|
|
12022
|
+
// The BOUNDARY comparison is the working half: it says whether this is
|
|
12023
|
+
// still the Tab's own selection or one the user has since built by hand,
|
|
12024
|
+
// and each of its four terms was ablated on its own against a selection
|
|
12025
|
+
// differing in that term alone — every one of them left its own input
|
|
12026
|
+
// wrongly suppressed, and no other.
|
|
12027
|
+
//
|
|
12028
|
+
// The isConnected term is belt and braces, and honestly so: ablating it
|
|
12029
|
+
// changed no measured behaviour, because a detached cell fails the
|
|
12030
|
+
// boundary comparison as well. It is asked anyway because the cell
|
|
12031
|
+
// element is the only handle that can be asked at all — driven across a
|
|
12032
|
+
// commit that took the full-render route, a Range built over a cell
|
|
12033
|
+
// beforehand reported startContainer.isConnected true and covered the
|
|
12034
|
+
// empty string afterwards, while that same cell reported isConnected
|
|
12035
|
+
// false. A Range collapses in silence, so a release keyed on one would
|
|
12036
|
+
// fire during a re-render of its own accord.
|
|
12037
|
+
if (tabSelectedCellEl) {
|
|
12038
|
+
if (tabSelectedCellEl.isConnected &&
|
|
12039
|
+
range.startContainer === tabSelectedCellEl && range.startOffset === 0 &&
|
|
12040
|
+
range.endContainer === tabSelectedCellEl &&
|
|
12041
|
+
range.endOffset === tabSelectedCellEl.childNodes.length) {
|
|
12042
|
+
hideSelToolbar();
|
|
12043
|
+
return;
|
|
12044
|
+
}
|
|
12045
|
+
tabSelectedCellEl = null;
|
|
12046
|
+
}
|
|
10572
12047
|
positionSelToolbar(range);
|
|
10573
12048
|
} finally {
|
|
10574
12049
|
// In a `finally` so every early return above still refreshes the bar —
|
|
@@ -10618,13 +12093,29 @@
|
|
|
10618
12093
|
return r;
|
|
10619
12094
|
}
|
|
10620
12095
|
|
|
12096
|
+
// Extracts the range's contents into `el`, puts `el` where the range was,
|
|
12097
|
+
// and drops the marks the extraction emptied — the fused form of
|
|
12098
|
+
// collectLeftovers() + dropEmptied() above, which is all an extractContents()
|
|
12099
|
+
// call site needs. applyMarkToggle()'s wrap branch reaches it through
|
|
12100
|
+
// wrapRangeIn() below; applyLinkToggleBody()'s new-link path calls it
|
|
12101
|
+
// directly. A native Ctrl+B does not arrive here at all: client.js binds no
|
|
12102
|
+
// Ctrl/Cmd + b/i/u, so that keystroke reaches the contenteditable as the
|
|
12103
|
+
// browser's own execCommand and produces a <b>. The journey suite drives
|
|
12104
|
+
// that keystroke and the toolbar button side by side, each with its own tag
|
|
12105
|
+
// pattern, for exactly that reason.
|
|
12106
|
+
function extractRangeInto(range, el, root) {
|
|
12107
|
+
const leftovers = collectLeftovers(range, root);
|
|
12108
|
+
el.appendChild(range.extractContents());
|
|
12109
|
+
range.insertNode(el);
|
|
12110
|
+
dropEmptied(leftovers);
|
|
12111
|
+
return el;
|
|
12112
|
+
}
|
|
12113
|
+
|
|
10621
12114
|
// Wraps the range's contents in a brand-new `<tag>` element via Range
|
|
10622
12115
|
// surgery (extractContents/insertNode — plain DOM Range methods, NOT
|
|
10623
12116
|
// execCommand). Returns a Range spanning the new element's contents.
|
|
10624
|
-
function wrapRangeIn(range, tag) {
|
|
10625
|
-
const el = document.createElement(tag.toLowerCase());
|
|
10626
|
-
el.appendChild(range.extractContents());
|
|
10627
|
-
range.insertNode(el);
|
|
12117
|
+
function wrapRangeIn(range, tag, root) {
|
|
12118
|
+
const el = extractRangeInto(range, document.createElement(tag.toLowerCase()), root);
|
|
10628
12119
|
const r = document.createRange();
|
|
10629
12120
|
r.selectNodeContents(el);
|
|
10630
12121
|
return r;
|
|
@@ -10728,12 +12219,14 @@
|
|
|
10728
12219
|
|
|
10729
12220
|
const whole = wholeSelectionMark(range, tag, root);
|
|
10730
12221
|
if (whole) {
|
|
12222
|
+
snapBurstIfActive(root, 'mark-pre');
|
|
10731
12223
|
reselectAndReposition(unwrapElement(whole));
|
|
10732
12224
|
snapBurstIfActive(root, 'mark');
|
|
10733
12225
|
return;
|
|
10734
12226
|
}
|
|
10735
12227
|
const overlapping = overlappingMarks(range, tag, root);
|
|
10736
12228
|
if (overlapping.length > 0) {
|
|
12229
|
+
snapBurstIfActive(root, 'mark-pre');
|
|
10737
12230
|
const extended = extendRangeOverMarks(range, overlapping);
|
|
10738
12231
|
// Re-query against the EXTENDED range: `overlapping` above was
|
|
10739
12232
|
// computed from the original (smaller) range, and every mark the
|
|
@@ -10744,7 +12237,8 @@
|
|
|
10744
12237
|
return;
|
|
10745
12238
|
}
|
|
10746
12239
|
if (!trimRangeToText(range)) return; // 全空白/collapsed 選取:no-op
|
|
10747
|
-
|
|
12240
|
+
snapBurstIfActive(root, 'mark-pre');
|
|
12241
|
+
reselectAndReposition(wrapRangeIn(range, tag, root));
|
|
10748
12242
|
snapBurstIfActive(root, 'mark');
|
|
10749
12243
|
}
|
|
10750
12244
|
|
|
@@ -10927,6 +12421,7 @@
|
|
|
10927
12421
|
if (whole) {
|
|
10928
12422
|
const url = window.prompt('連結網址(留空以移除連結):', whole.getAttribute('href') || '');
|
|
10929
12423
|
if (url === null) return true; // cancelled — leave the link untouched
|
|
12424
|
+
snapBurstIfActive(root, 'mark-pre');
|
|
10930
12425
|
if (url.trim() === '') {
|
|
10931
12426
|
reselectAndReposition(unwrapElement(whole));
|
|
10932
12427
|
} else {
|
|
@@ -10938,6 +12433,7 @@
|
|
|
10938
12433
|
}
|
|
10939
12434
|
const overlapping = overlappingMarks(range, 'A', root);
|
|
10940
12435
|
if (overlapping.length > 0) {
|
|
12436
|
+
snapBurstIfActive(root, 'mark-pre');
|
|
10941
12437
|
const extended = extendRangeOverMarks(range, overlapping);
|
|
10942
12438
|
overlappingMarks(extended, 'A', root).forEach((m) => unwrapElement(m));
|
|
10943
12439
|
reselectAndReposition(extended);
|
|
@@ -10946,10 +12442,10 @@
|
|
|
10946
12442
|
}
|
|
10947
12443
|
const url = window.prompt('連結網址:', 'https://');
|
|
10948
12444
|
if (url === null || url.trim() === '') return true; // cancelled or empty — no-op
|
|
12445
|
+
snapBurstIfActive(root, 'mark-pre');
|
|
10949
12446
|
const el = document.createElement('a');
|
|
10950
12447
|
el.setAttribute('href', url.trim());
|
|
10951
|
-
|
|
10952
|
-
range.insertNode(el);
|
|
12448
|
+
extractRangeInto(range, el, root);
|
|
10953
12449
|
const r = document.createRange();
|
|
10954
12450
|
r.selectNodeContents(el);
|
|
10955
12451
|
reselectAndReposition(r);
|
|
@@ -10957,15 +12453,21 @@
|
|
|
10957
12453
|
return true;
|
|
10958
12454
|
}
|
|
10959
12455
|
|
|
12456
|
+
// The .ed-seltb buttons, paired with the mark each one toggles, for
|
|
12457
|
+
// paintSelToolbarMarks() below. Filled by buildSelToolbar(), which runs once.
|
|
12458
|
+
const selToolbarMarkBtns = [];
|
|
12459
|
+
|
|
10960
12460
|
function buildSelToolbar() {
|
|
10961
12461
|
const el = document.createElement('div');
|
|
10962
12462
|
el.className = 'ed-seltb';
|
|
10963
|
-
function addBtn(cls, label, ariaLabel, onClick) {
|
|
12463
|
+
function addBtn(cls, label, ariaLabel, tag, onClick) {
|
|
10964
12464
|
const b = document.createElement('button');
|
|
10965
12465
|
b.type = 'button';
|
|
10966
12466
|
b.className = 'ed-seltb-btn ' + cls;
|
|
10967
12467
|
b.textContent = label;
|
|
10968
12468
|
b.setAttribute('aria-label', ariaLabel);
|
|
12469
|
+
b.setAttribute('aria-pressed', 'false');
|
|
12470
|
+
selToolbarMarkBtns.push({ btn: b, tag: tag });
|
|
10969
12471
|
// Keep the DOM selection intact across the click: without this, the
|
|
10970
12472
|
// button (outside the contenteditable root) stealing focus on
|
|
10971
12473
|
// mousedown would collapse the selection before the click handler
|
|
@@ -10975,15 +12477,53 @@
|
|
|
10975
12477
|
el.appendChild(b);
|
|
10976
12478
|
return b;
|
|
10977
12479
|
}
|
|
10978
|
-
addBtn('ed-seltb-b', 'B', 'Bold', () => applyMarkToggle('STRONG'));
|
|
10979
|
-
addBtn('ed-seltb-i', 'I', 'Italic', () => applyMarkToggle('EM'));
|
|
10980
|
-
addBtn('ed-seltb-s', 'S', '刪除線', () => applyMarkToggle('DEL'));
|
|
10981
|
-
addBtn('ed-seltb-u', 'U', '底線', () => applyMarkToggle('U'));
|
|
10982
|
-
addBtn('ed-seltb-code', '<>', 'Code', () => applyMarkToggle('CODE'));
|
|
10983
|
-
addBtn('ed-seltb-link', '\u{1F517}', 'Link', () => applyLinkToggle());
|
|
12480
|
+
addBtn('ed-seltb-b', 'B', 'Bold', 'STRONG', () => applyMarkToggle('STRONG'));
|
|
12481
|
+
addBtn('ed-seltb-i', 'I', 'Italic', 'EM', () => applyMarkToggle('EM'));
|
|
12482
|
+
addBtn('ed-seltb-s', 'S', '刪除線', 'DEL', () => applyMarkToggle('DEL'));
|
|
12483
|
+
addBtn('ed-seltb-u', 'U', '底線', 'U', () => applyMarkToggle('U'));
|
|
12484
|
+
addBtn('ed-seltb-code', '<>', 'Code', 'CODE', () => applyMarkToggle('CODE'));
|
|
12485
|
+
addBtn('ed-seltb-link', '\u{1F517}', 'Link', 'A', () => applyLinkToggle());
|
|
10984
12486
|
return el;
|
|
10985
12487
|
}
|
|
10986
12488
|
|
|
12489
|
+
// v3.3.0 (F2): the floating bar's own four-state painting, from the same
|
|
12490
|
+
// `ctx.marks` the fixed toolbar's five are derived from.
|
|
12491
|
+
//
|
|
12492
|
+
// ⚠ `aria-disabled`, never the `disabled` property. Each of these buttons
|
|
12493
|
+
// keeps the selection alive across its own click through the `mousedown`
|
|
12494
|
+
// preventDefault() addBtn() installs above, and a disabled <button>
|
|
12495
|
+
// dispatches no mousedown at all, so that preventDefault would never run.
|
|
12496
|
+
//
|
|
12497
|
+
// MEASURED, real mouse press (move / down / 80ms / up) on the B button over
|
|
12498
|
+
// an inert selection, with a capture-phase counter on `document` and a click
|
|
12499
|
+
// counter on the button itself. As it ships:
|
|
12500
|
+
// {"active":"P.ed-wys-armed","sel":" ","seltb":true,"downs":1,"clicks":1}
|
|
12501
|
+
// and the block's innerHTML byte-identical before and after. The same press
|
|
12502
|
+
// with `disabled` set on that button instead:
|
|
12503
|
+
// {"active":"BODY.","sel":" ","seltb":false,"downs":0,"clicks":0}
|
|
12504
|
+
// — no mousedown reaches the document at all, the focus lands on BODY and
|
|
12505
|
+
// the bar is gone by the time the press ends. buildToolbar() can afford
|
|
12506
|
+
// `disabled` on the fixed bar because it installs a capture-phase
|
|
12507
|
+
// `pointerdown` guard on that bar's container; there is no such
|
|
12508
|
+
// container-level interception here.
|
|
12509
|
+
//
|
|
12510
|
+
// So the press on an inert button still runs applyMarkToggle(), which is
|
|
12511
|
+
// fine: the same measurement shows the block unchanged. Nothing here needs
|
|
12512
|
+
// to intercept it.
|
|
12513
|
+
let selToolbarMarkSig = null;
|
|
12514
|
+
function paintSelToolbarMarks(marks) {
|
|
12515
|
+
const sig = JSON.stringify(marks);
|
|
12516
|
+
if (sig === selToolbarMarkSig) return;
|
|
12517
|
+
selToolbarMarkSig = sig;
|
|
12518
|
+
selToolbarMarkBtns.forEach((entry) => {
|
|
12519
|
+
const m = marks ? marks[entry.tag] : null;
|
|
12520
|
+
entry.btn.setAttribute('aria-pressed',
|
|
12521
|
+
m === 'whole' ? 'true' : (m === 'partial' ? 'mixed' : 'false'));
|
|
12522
|
+
if (m === 'inert') entry.btn.setAttribute('aria-disabled', 'true');
|
|
12523
|
+
else entry.btn.removeAttribute('aria-disabled');
|
|
12524
|
+
});
|
|
12525
|
+
}
|
|
12526
|
+
|
|
10987
12527
|
const selToolbar = buildSelToolbar();
|
|
10988
12528
|
|
|
10989
12529
|
function hideSelToolbar() {
|
|
@@ -11003,9 +12543,17 @@
|
|
|
11003
12543
|
.getPropertyValue('--ed-toolbar-h')) || 0;
|
|
11004
12544
|
if (rect.bottom <= topInset || rect.top >= window.innerHeight ||
|
|
11005
12545
|
rect.right <= 0 || rect.left >= window.innerWidth) {
|
|
12546
|
+
// Task 16: of the exits that take this bar down, this is the one that
|
|
12547
|
+
// leaves a live selection behind, so it is the one that arms the
|
|
12548
|
+
// re-ask on scroll. Walked by grepping hideSelToolbar(), its other
|
|
12549
|
+
// callers are reached by a session ending, by the selection collapsing
|
|
12550
|
+
// or leaving the edit root, by Task 15's Tab suppression, or by a mark
|
|
12551
|
+
// toggle with no range to reselect.
|
|
12552
|
+
selToolbarOffViewport = true;
|
|
11006
12553
|
hideSelToolbar(); return;
|
|
11007
12554
|
}
|
|
11008
12555
|
if (!selToolbar.parentNode) document.body.appendChild(selToolbar);
|
|
12556
|
+
selToolbarOffViewport = false;
|
|
11009
12557
|
const gap = 8, margin = 4;
|
|
11010
12558
|
const tbRect = selToolbar.getBoundingClientRect();
|
|
11011
12559
|
let top = rect.top - tbRect.height - gap;
|
|
@@ -11047,9 +12595,13 @@
|
|
|
11047
12595
|
// handler's async switchAwayFrom()->resolveBurst()->rerenderAll()
|
|
11048
12596
|
// commit chain BEFORE the click event that opens the menu ever fires.
|
|
11049
12597
|
// At human click speed (a real, non-zero gap between mousedown and
|
|
11050
|
-
// mouseup) that commit's /api/render round trip can finish and
|
|
11051
|
-
// `.content` — detaching THIS very button
|
|
11052
|
-
//
|
|
12598
|
+
// mouseup) that commit's /api/render round trip can finish and REPLACE
|
|
12599
|
+
// THE BLOCK ELEMENT inside `.content` — detaching THIS very button,
|
|
12600
|
+
// which lives in that block — before mouseup. (`.content` itself is
|
|
12601
|
+
// NOT swapped: applyFullRender() assigns `contentEl.innerHTML`, which
|
|
12602
|
+
// rebuilds its children but keeps the element, and the patch route
|
|
12603
|
+
// removes just the one block; both were traced, see the v3.3.0 note
|
|
12604
|
+
// below.) A click event never fires at all for a target removed from the
|
|
11053
12605
|
// document between mousedown and mouseup: the first click is silently
|
|
11054
12606
|
// eaten (no menu opens), and only a second click on the fresh,
|
|
11055
12607
|
// re-armed handle actually works. preventDefault() here stops the
|
|
@@ -11058,9 +12610,53 @@
|
|
|
11058
12610
|
// §10-gap fix: same reasoning as the ⠿ handle above, for the + insert
|
|
11059
12611
|
// button — a mousedown-triggered blur on a dirty burst elsewhere would
|
|
11060
12612
|
// otherwise race this button's own click the exact same way.
|
|
12613
|
+
// v3.3.0 階段 0: the two MENU-ITEM classes join the list. `.ed-handle` /
|
|
12614
|
+
// `.ed-insert` are the buttons that OPEN the menus;
|
|
12615
|
+
// `.ed-handle-menu-btn` / `.ed-insert-menu-btn` are the items INSIDE them
|
|
12616
|
+
// (buildGutterMenu()/buildInsertMenu() give every item one of those two
|
|
12617
|
+
// classes), and neither menu's items carry a mousedown preventDefault()
|
|
12618
|
+
// of their own. The ⠿/+ gutter builds exactly four kinds of pressable
|
|
12619
|
+
// BUTTON — the handle, the + button, a gutter-menu item (the 轉換成
|
|
12620
|
+
// submenu's buttons share that class) and an insert-menu item — and
|
|
12621
|
+
// before this line only the first two were covered. The menu PANEL
|
|
12622
|
+
// itself (`div.ed-handle-menu`, which has padding) is deliberately not
|
|
12623
|
+
// in that list: a press landing in the gap between two items still
|
|
12624
|
+
// steals focus, and is out of scope here.
|
|
12625
|
+
//
|
|
12626
|
+
// Trace taken with ONLY `.ed-handle`/`.ed-insert` in this list, dirty
|
|
12627
|
+
// burst on the block, an 80 ms press on 建立副本, listeners on mousedown /
|
|
12628
|
+
// focusout / fetch / mouseup / click plus a MutationObserver naming the
|
|
12629
|
+
// removed node (unprimed fallback route | primed patch route):
|
|
12630
|
+
//
|
|
12631
|
+
// +7ms | +5ms mousedown on .ed-handle-menu-btn, defaultPrevented=false
|
|
12632
|
+
// +7ms | +5ms focusout on the burst's .ed-wys-armed, activeElement→BODY
|
|
12633
|
+
// +8ms | +6ms POST /api/render (the focusout handler's commit)
|
|
12634
|
+
// +28ms | +12ms response applied — the removed node is the BLOCK ELEMENT,
|
|
12635
|
+
// removed from `.content` (which keeps its own identity)
|
|
12636
|
+
// and replaced by a different node with the same
|
|
12637
|
+
// data-block-id; the fallback rebuilds all of `.content`'s
|
|
12638
|
+
// children, the patch route removes just this one. The menu
|
|
12639
|
+
// — parked by toggleGutterMenu()/toggleInsertMenu() with
|
|
12640
|
+
// blockEl.appendChild() — is removed WITH the block.
|
|
12641
|
+
// +93ms | +91ms mouseup, target now the replacement block's surface, and
|
|
12642
|
+
// NO click event at all: the pressed node left the document
|
|
12643
|
+
// between mousedown and mouseup.
|
|
12644
|
+
//
|
|
12645
|
+
// Same trace with these two selectors added: no focusout and no
|
|
12646
|
+
// /api/render before mouseup — the item keeps its click, and the commit
|
|
12647
|
+
// runs afterwards from inside the item's own handler.
|
|
11061
12648
|
document.addEventListener('mousedown', (e) => {
|
|
12649
|
+
// T21 item 2: pointing at something is the other way (besides pressing a
|
|
12650
|
+
// key that is not Tab — see handleBurstKeydown()) of saying the walk is
|
|
12651
|
+
// over. A click that lands on the SAME surface the walk arrived at does
|
|
12652
|
+
// not open a new burst, so without this the mark would survive a
|
|
12653
|
+
// deliberate caret placement and the next Tab would leave instead of
|
|
12654
|
+
// indenting the item the user had just aimed at.
|
|
12655
|
+
if (currentBurst) currentBurst.arrivedByTab = false;
|
|
11062
12656
|
if (e.target && e.target.closest &&
|
|
11063
|
-
(e.target.closest('.ed-handle') || e.target.closest('.ed-insert')
|
|
12657
|
+
(e.target.closest('.ed-handle') || e.target.closest('.ed-insert') ||
|
|
12658
|
+
e.target.closest('.ed-handle-menu-btn') ||
|
|
12659
|
+
e.target.closest('.ed-insert-menu-btn'))) e.preventDefault();
|
|
11064
12660
|
});
|
|
11065
12661
|
document.addEventListener('click', async (e) => {
|
|
11066
12662
|
// S3 Task 4: the trailing click of a gesture that already answered this
|
|
@@ -11269,6 +12865,12 @@
|
|
|
11269
12865
|
|
|
11270
12866
|
// ── undo / redo ───────────────────────────────────────────────────────
|
|
11271
12867
|
async function undo() {
|
|
12868
|
+
// T21 item 1: an Escape that discarded a burst leaves exactly one thing
|
|
12869
|
+
// for the next Ctrl+Z to undo, and it is not on this stack — the burst
|
|
12870
|
+
// never committed, so `lines` never saw it. restoreDiscardedBurst()
|
|
12871
|
+
// answers false whenever there is no such thing (which is almost always),
|
|
12872
|
+
// and the ordinary document undo below runs untouched.
|
|
12873
|
+
if (restoreDiscardedBurst()) return;
|
|
11272
12874
|
// Resolve any open editor BEFORE the .content-replacing swap below —
|
|
11273
12875
|
// see the `activeEditor` / switchAwayFrom() comments near its
|
|
11274
12876
|
// declaration for why this is required (undo/redo silently detaching an
|
|
@@ -11385,8 +12987,19 @@
|
|
|
11385
12987
|
// The buttons whose model state can ever report `active: true` — i.e. the
|
|
11386
12988
|
// real toggles, and the only ones that may carry aria-pressed. Every other
|
|
11387
12989
|
// button is a command, not a toggle.
|
|
11388
|
-
// v3.2.0: derived, not mirrored.
|
|
11389
|
-
//
|
|
12990
|
+
// v3.2.0: derived, not mirrored. Adding a toggle to toolbar-model.js used to
|
|
12991
|
+
// need a matching edit here, and forgetting it made aria-pressed lie.
|
|
12992
|
+
//
|
|
12993
|
+
// v3.3.0 (F2), recorded rather than changed: `check` is outside this set on
|
|
12994
|
+
// purpose. It is `toggle: false` while its conversion-group siblings quote /
|
|
12995
|
+
// code / list / ordered-list are `toggle: true`, which is honest only for as
|
|
12996
|
+
// long as deriveState() never sets `check.active` — and it does not: that
|
|
12997
|
+
// function assigns nothing to `state.check` beyond the disabled flags every
|
|
12998
|
+
// button gets. What would change that is a rule lighting ☑ when the current
|
|
12999
|
+
// block is already a task item. The flag would have to flip in the same
|
|
13000
|
+
// edit, because this set is what decides whether aria-pressed is written at
|
|
13001
|
+
// all, and a button that can report `active` while sitting outside it says
|
|
13002
|
+
// nothing where it should say `true`.
|
|
11390
13003
|
const TOOLBAR_TOGGLE_IDS = new Set(
|
|
11391
13004
|
(toolbarModel ? toolbarModel.BUTTONS : []).filter((b) => b.toggle).map((b) => b.id));
|
|
11392
13005
|
|
|
@@ -11454,6 +13067,67 @@
|
|
|
11454
13067
|
selToolbarEditEl.contains(range.endContainer);
|
|
11455
13068
|
}
|
|
11456
13069
|
|
|
13070
|
+
// The mark elements the toolbars toggle. 'U' is here because .ed-seltb has a
|
|
13071
|
+
// U button (buildSelToolbar(), earlier in this file); toolbar-model.js's
|
|
13072
|
+
// MARK_TAG_BY_ID covers the fixed toolbar's inline group and does not
|
|
13073
|
+
// mention it.
|
|
13074
|
+
const MARK_TAGS = ['STRONG', 'EM', 'DEL', 'CODE', 'A', 'U'];
|
|
13075
|
+
|
|
13076
|
+
// v3.3.0 (F2): which of those marks the current selection is in, as the
|
|
13077
|
+
// four-state vocabulary both toolbars paint from — or null when there is no
|
|
13078
|
+
// formattable selection at all. The three non-null states below name the
|
|
13079
|
+
// branch applyMarkToggle() would take on that tag, in its own order:
|
|
13080
|
+
//
|
|
13081
|
+
// 'whole' `wholeSelectionMark()` hit — the unwrap branch.
|
|
13082
|
+
// 'partial' `overlappingMarks()` non-empty — the extend-and-unwrap branch.
|
|
13083
|
+
// 'none' neither — the wrap branch, which would wrap something.
|
|
13084
|
+
// 'inert' neither, and the wrap branch would refuse.
|
|
13085
|
+
//
|
|
13086
|
+
// Two things about 'inert' are load-bearing and neither is guessable from
|
|
13087
|
+
// the button:
|
|
13088
|
+
//
|
|
13089
|
+
// * It is asked per tag, not once. Only applyMarkToggle()'s THIRD branch is
|
|
13090
|
+
// guarded by trimRangeToText(); the first two run to completion on an
|
|
13091
|
+
// all-whitespace selection. MEASURED: with the single space inside
|
|
13092
|
+
// `**bold text**` selected, pressing B took the unwrap branch — the
|
|
13093
|
+
// <strong> was gone, the paragraph's text was unchanged, and the file
|
|
13094
|
+
// saved back with no `**` in it. So drawing every button inert there
|
|
13095
|
+
// would grey out a button that acts.
|
|
13096
|
+
// * 'A' never reaches it. The link button does not go through
|
|
13097
|
+
// applyMarkToggle() at all — applyLinkToggleBody() above has the same
|
|
13098
|
+
// first two branches but its third calls window.prompt() and
|
|
13099
|
+
// extractRangeInto() with no trim guard anywhere, so an all-whitespace
|
|
13100
|
+
// selection prompts and wraps. MEASURED: selecting the single space in
|
|
13101
|
+
// `Alpha bold text …` and pressing 🔗 raised the dialog 「連結網址:」 and
|
|
13102
|
+
// left `Alpha<a href="https://probe/"> </a>bold text …` in the block. So
|
|
13103
|
+
// drawing 🔗 inert there would grey out a button that acts.
|
|
13104
|
+
//
|
|
13105
|
+
// trimRangeToText() is tag-independent, so it is asked once. It is asked
|
|
13106
|
+
// with a CLONE: it calls setStart/setEnd on the range it is handed whenever
|
|
13107
|
+
// it returns true, and this function runs on every selectionchange, so the
|
|
13108
|
+
// live range would have the user's own selection edges pulled in off every
|
|
13109
|
+
// caret move that the open burst's selectionchange listener hears. `wholeSelectionMark()` and `overlappingMarks()` take the live
|
|
13110
|
+
// range. test/editor-journey.test.js drives a keyboard selection across a
|
|
13111
|
+
// marked run and asserts, at each step, that the range's boundary points are
|
|
13112
|
+
// byte-identical either side of the selectionchange dispatch and that the
|
|
13113
|
+
// string it covers has grown by one character. Ablated to check the suite can
|
|
13114
|
+
// see it: putting a setStart() into wholeSelectionMark(), and again into
|
|
13115
|
+
// overlappingMarks(), each turned the suite red.
|
|
13116
|
+
function selectionMarkStates(sel) {
|
|
13117
|
+
if (!hasFormattableSelection(sel)) return null;
|
|
13118
|
+
const root = selToolbarEditEl;
|
|
13119
|
+
const range = sel.getRangeAt(0);
|
|
13120
|
+
const wrappable = trimRangeToText(range.cloneRange());
|
|
13121
|
+
const out = {};
|
|
13122
|
+
for (const tag of MARK_TAGS) {
|
|
13123
|
+
if (wholeSelectionMark(range, tag, root)) out[tag] = 'whole';
|
|
13124
|
+
else if (overlappingMarks(range, tag, root).length > 0) out[tag] = 'partial';
|
|
13125
|
+
else if (wrappable || tag === 'A') out[tag] = 'none';
|
|
13126
|
+
else out[tag] = 'inert';
|
|
13127
|
+
}
|
|
13128
|
+
return out;
|
|
13129
|
+
}
|
|
13130
|
+
|
|
11457
13131
|
// ctx for toolbarModel.deriveState(). Every field is read off the DOM /
|
|
11458
13132
|
// `lines` at call time; nothing is cached, so a stale value cannot outlive
|
|
11459
13133
|
// a render.
|
|
@@ -11476,13 +13150,25 @@
|
|
|
11476
13150
|
// hasFormattableSelection() (just above) is their refusal test spelled
|
|
11477
13151
|
// once, rather than a looser "is anything selected anywhere on the page".
|
|
11478
13152
|
hasSelection: hasFormattableSelection(sel),
|
|
13153
|
+
// The identity behind that boolean: selectionMarkStates() (just above)
|
|
13154
|
+
// measures it here because toolbar-model.js may not touch the DOM, and
|
|
13155
|
+
// .ed-seltb is painted from the same measurement — see updateToolbar().
|
|
13156
|
+
marks: selectionMarkStates(sel),
|
|
11479
13157
|
mode: docMode,
|
|
11480
13158
|
};
|
|
11481
13159
|
}
|
|
11482
13160
|
|
|
11483
13161
|
function updateToolbar() {
|
|
11484
13162
|
if (!toolbarEl || !toolbarBtns || !toolbarModel) return;
|
|
11485
|
-
const
|
|
13163
|
+
const ctx = toolbarContext();
|
|
13164
|
+
// Both bars, one measurement. paintSelToolbarMarks() runs BEFORE the `sig`
|
|
13165
|
+
// early-return below, because that signature is built from the toolbar's
|
|
13166
|
+
// own buttons and .ed-seltb has one — U — that none of them speaks for:
|
|
13167
|
+
// moving the selection between plain text and a <u> run changes nothing in
|
|
13168
|
+
// `state`, so the early return would fire and U would keep whatever it last
|
|
13169
|
+
// said. Pinned by a journey row that drives exactly that move.
|
|
13170
|
+
paintSelToolbarMarks(ctx.marks);
|
|
13171
|
+
const state = toolbarModel.deriveState(ctx);
|
|
11486
13172
|
// selectionchange fires on every caret move, so the state is diffed
|
|
11487
13173
|
// before any DOM is written — 22 attribute writes per keystroke is a
|
|
11488
13174
|
// cost with no matching benefit.
|
|
@@ -11499,7 +13185,12 @@
|
|
|
11499
13185
|
// and `table` as toggle buttons stuck in the "not pressed" state,
|
|
11500
13186
|
// which is a lie about what they do.
|
|
11501
13187
|
if (TOOLBAR_TOGGLE_IDS.has(id)) {
|
|
11502
|
-
|
|
13188
|
+
// v3.3.0 (F2): three values, not two. `mixed` is what the five inline
|
|
13189
|
+
// buttons report over a selection that overlaps marks of their tag
|
|
13190
|
+
// without lying inside one — pressing them there removes the marks
|
|
13191
|
+
// rather than adding any, and 'true'/'false' can say neither.
|
|
13192
|
+
btn.setAttribute('aria-pressed',
|
|
13193
|
+
st.mixed ? 'mixed' : (st.active ? 'true' : 'false'));
|
|
11503
13194
|
}
|
|
11504
13195
|
// `headings` (H / H1..H6) and, since v3.2.1, the mode button carry a
|
|
11505
13196
|
// derived label; every other button keeps the glyph it was built with.
|
|
@@ -11513,6 +13204,187 @@
|
|
|
11513
13204
|
btn.setAttribute('aria-label', st.title);
|
|
11514
13205
|
}
|
|
11515
13206
|
});
|
|
13207
|
+
// Label edits above change the row's width (H becomes H1, 編輯 becomes
|
|
13208
|
+
// 原始碼), which moves the scroll maximum, so repaint the edge hint here
|
|
13209
|
+
// too. Placed inside the diffed branch on purpose: an unchanged sig means
|
|
13210
|
+
// no attribute was written, therefore no width moved.
|
|
13211
|
+
paintToolbarOverflow();
|
|
13212
|
+
// The write above can disable the button the keyboard cursor is sitting
|
|
13213
|
+
// on. A real focus would have been thrown back to BODY by the browser at
|
|
13214
|
+
// this point, and Tab from BODY goes nowhere — driven on this build, a
|
|
13215
|
+
// dozen consecutive Tabs starting from BODY left activeElement on BODY
|
|
13216
|
+
// every time — so the keyboard would be stuck there. A virtual cursor has
|
|
13217
|
+
// nothing to lose: it steps to the next enabled button instead.
|
|
13218
|
+
const cursorBtn = toolbarRovingBtns()[toolbarRovingIndex];
|
|
13219
|
+
if (cursorBtn && cursorBtn.disabled) moveToolbarCursor(1);
|
|
13220
|
+
}
|
|
13221
|
+
|
|
13222
|
+
// F12: which end of the bar still has buttons behind it. The stylesheet
|
|
13223
|
+
// turns each token into an edge gradient; nothing here decides how it looks.
|
|
13224
|
+
//
|
|
13225
|
+
// The one-pixel margin is a deliberate cushion and NOT a measured
|
|
13226
|
+
// requirement — say so rather than inventing a reason for it. Driven at 900
|
|
13227
|
+
// and at 420 px wide, the largest scrollLeft the bar accepts came back equal
|
|
13228
|
+
// to scrollWidth minus clientWidth to the pixel (75 and 555), so a strict
|
|
13229
|
+
// comparison would have been correct at both. The cushion is there because
|
|
13230
|
+
// scrollWidth is reported as a whole number over a row whose own width is
|
|
13231
|
+
// not, and what it costs when the two agree is that the gradient goes out a
|
|
13232
|
+
// pixel before the travel ends.
|
|
13233
|
+
function paintToolbarOverflow() {
|
|
13234
|
+
if (!toolbarEl) return;
|
|
13235
|
+
const max = toolbarEl.scrollWidth - toolbarEl.clientWidth;
|
|
13236
|
+
const sl = toolbarEl.scrollLeft;
|
|
13237
|
+
const tokens = [];
|
|
13238
|
+
if (sl > 1) tokens.push('left');
|
|
13239
|
+
if (sl < max - 1) tokens.push('right');
|
|
13240
|
+
const v = tokens.join(' ');
|
|
13241
|
+
if (toolbarEl.getAttribute('data-ed-tb-overflow') !== v) {
|
|
13242
|
+
toolbarEl.setAttribute('data-ed-tb-overflow', v);
|
|
13243
|
+
}
|
|
13244
|
+
}
|
|
13245
|
+
|
|
13246
|
+
// ── F12, keyboard entry to the toolbar ────────────────────────────────
|
|
13247
|
+
//
|
|
13248
|
+
// The cursor here is VIRTUAL: no toolbar button ever takes DOM focus, and
|
|
13249
|
+
// the caret stays exactly where the user left it. That is not a stylistic
|
|
13250
|
+
// choice, it is what makes the rest of this safe. buildToolbar()'s own
|
|
13251
|
+
// ⚠ LOAD-BEARING note records why: a toolbar <button> taking focus fires
|
|
13252
|
+
// the armed surface's focusout, which commits and re-renders, and the
|
|
13253
|
+
// selection the pressed button was about to act on is gone before its click
|
|
13254
|
+
// handler runs. The mousedown preventDefault() there stops that happening
|
|
13255
|
+
// by mouse; keeping DOM focus still means it cannot happen by keyboard
|
|
13256
|
+
// either, and no focusout exemption has to be invented for the way back
|
|
13257
|
+
// out.
|
|
13258
|
+
//
|
|
13259
|
+
// toolbarRovingIndex is an index into the bar's buttons in DOM order, or
|
|
13260
|
+
// -1 while the mode is off.
|
|
13261
|
+
let toolbarRovingIndex = -1;
|
|
13262
|
+
|
|
13263
|
+
function toolbarRovingBtns() {
|
|
13264
|
+
return toolbarEl ? Array.prototype.slice.call(
|
|
13265
|
+
toolbarEl.querySelectorAll('.ed-toolbar-btn')) : [];
|
|
13266
|
+
}
|
|
13267
|
+
|
|
13268
|
+
function paintToolbarRoving() {
|
|
13269
|
+
const btns = toolbarRovingBtns();
|
|
13270
|
+
btns.forEach((b, i) => {
|
|
13271
|
+
if (i === toolbarRovingIndex) b.setAttribute('data-ed-tb-cursor', '');
|
|
13272
|
+
else b.removeAttribute('data-ed-tb-cursor');
|
|
13273
|
+
});
|
|
13274
|
+
if (!toolbarEl) return;
|
|
13275
|
+
if (toolbarRovingIndex < 0 || !btns[toolbarRovingIndex]) {
|
|
13276
|
+
toolbarEl.removeAttribute('data-ed-tb-keynav');
|
|
13277
|
+
} else {
|
|
13278
|
+
toolbarEl.setAttribute('data-ed-tb-keynav',
|
|
13279
|
+
btns[toolbarRovingIndex].getAttribute('data-ed-tb'));
|
|
13280
|
+
}
|
|
13281
|
+
}
|
|
13282
|
+
|
|
13283
|
+
// Scroll the cursor into the strip the buttons rest in, which is narrower
|
|
13284
|
+
// than the bar: the mode slot occupies the right-hand end of it. The right
|
|
13285
|
+
// boundary is read off the slot's own rect rather than repeating the
|
|
13286
|
+
// padding-right the stylesheet reserves, so the two cannot drift apart.
|
|
13287
|
+
// The arithmetic is spelled out rather than delegated to scrollIntoView(),
|
|
13288
|
+
// whose contract is written against the whole chain of scrollable ancestors
|
|
13289
|
+
// while the only box that should move here is this one.
|
|
13290
|
+
function revealToolbarCursor(btn) {
|
|
13291
|
+
if (!toolbarEl) return;
|
|
13292
|
+
const bar = toolbarEl.getBoundingClientRect();
|
|
13293
|
+
const slotEl = toolbarEl.querySelector('.ed-toolbar-status');
|
|
13294
|
+
const right = slotEl
|
|
13295
|
+
? Math.min(bar.right, slotEl.getBoundingClientRect().left) : bar.right;
|
|
13296
|
+
// Rounded AWAY from the edge being cleared, not by the nearest whole
|
|
13297
|
+
// number. These boxes have fractional widths while scrollLeft is stored
|
|
13298
|
+
// whole, so asking for a flush landing can round the button back under the
|
|
13299
|
+
// edge it was supposed to clear: driven with plain subtraction, walking
|
|
13300
|
+
// leftwards along the row at a 420px viewport parked stops at a left edge
|
|
13301
|
+
// of -0.39, -0.19 and -0.09, and flooring instead put every one of them
|
|
13302
|
+
// back inside. Rounding this way spends at most a whole pixel of extra
|
|
13303
|
+
// travel and cannot undershoot. What it does not buy is the last
|
|
13304
|
+
// fractional pixel at the very end of the travel — the row's own width
|
|
13305
|
+
// ends in .109 and scrollWidth is whole, so the trailing button still
|
|
13306
|
+
// overhangs the slot by that much with the bar scrolled as far as it
|
|
13307
|
+
// goes; that residue is the one the reachability row's own note describes.
|
|
13308
|
+
const r = btn.getBoundingClientRect();
|
|
13309
|
+
if (r.left < bar.left) {
|
|
13310
|
+
toolbarEl.scrollLeft = Math.floor(toolbarEl.scrollLeft - (bar.left - r.left));
|
|
13311
|
+
} else if (r.right > right) {
|
|
13312
|
+
toolbarEl.scrollLeft = Math.ceil(toolbarEl.scrollLeft + (r.right - right));
|
|
13313
|
+
}
|
|
13314
|
+
}
|
|
13315
|
+
|
|
13316
|
+
// Steps over disabled buttons. The walk is bounded by the button count and
|
|
13317
|
+
// wraps, so a bar with nothing enabled would leave the index where it was
|
|
13318
|
+
// rather than spin — see the model's own guarantee that this cannot happen
|
|
13319
|
+
// (deriveState() keeps `preview` out of every disabling branch, including
|
|
13320
|
+
// the source-mode override; T11-2 later exempted `outline` from that
|
|
13321
|
+
// override too, so the enabled set there is `['outline','preview']` — the
|
|
13322
|
+
// guarantee holds more strongly than when this was written, not less).
|
|
13323
|
+
function moveToolbarCursor(delta) {
|
|
13324
|
+
const btns = toolbarRovingBtns();
|
|
13325
|
+
if (!btns.length) return;
|
|
13326
|
+
let i = toolbarRovingIndex;
|
|
13327
|
+
for (let step = 0; step < btns.length; step++) {
|
|
13328
|
+
i = ((i + delta) % btns.length + btns.length) % btns.length;
|
|
13329
|
+
if (!btns[i].disabled) {
|
|
13330
|
+
toolbarRovingIndex = i;
|
|
13331
|
+
paintToolbarRoving();
|
|
13332
|
+
revealToolbarCursor(btns[i]);
|
|
13333
|
+
return;
|
|
13334
|
+
}
|
|
13335
|
+
}
|
|
13336
|
+
}
|
|
13337
|
+
|
|
13338
|
+
// No "already in the mode" guard, because the key branch cannot deliver one:
|
|
13339
|
+
// while the mode is on, the entry chord's own key is not one of the keys the
|
|
13340
|
+
// mode names, so it takes the hand-back path and ends the mode instead. The
|
|
13341
|
+
// gesture is therefore a toggle, and that is pinned rather than inferred.
|
|
13342
|
+
// moveToolbarCursor() is the null guard as well — it starts by asking the
|
|
13343
|
+
// bar for its buttons, and answers an empty list when there is no bar.
|
|
13344
|
+
function enterToolbarKeynav() {
|
|
13345
|
+
toolbarRovingIndex = -1;
|
|
13346
|
+
moveToolbarCursor(1);
|
|
13347
|
+
}
|
|
13348
|
+
|
|
13349
|
+
function exitToolbarKeynav() {
|
|
13350
|
+
if (toolbarRovingIndex < 0) return false;
|
|
13351
|
+
toolbarRovingIndex = -1;
|
|
13352
|
+
paintToolbarRoving();
|
|
13353
|
+
return true;
|
|
13354
|
+
}
|
|
13355
|
+
|
|
13356
|
+
// Activation goes through the same call the click handler makes, so a
|
|
13357
|
+
// keyboard press and a mouse press cannot drift apart.
|
|
13358
|
+
//
|
|
13359
|
+
// WHERE THE CARET ENDS UP AFTERWARDS, driven button by button over the
|
|
13360
|
+
// enabled row in a plain paragraph, pressing Enter on the cursor and then
|
|
13361
|
+
// typing to see whether the characters land anywhere:
|
|
13362
|
+
//
|
|
13363
|
+
// * quote, code and line end on BODY and typing goes nowhere, with a
|
|
13364
|
+
// clean burst and with a dirty one alike.
|
|
13365
|
+
// * undo, redo and image join them ONLY when the surface has an
|
|
13366
|
+
// uncommitted edit — the action resolves that burst first, and the
|
|
13367
|
+
// re-render is what drops the caret. With nothing pending they land back
|
|
13368
|
+
// on the armed paragraph and typing resumes.
|
|
13369
|
+
// * everything else lands on an editable surface and typing resumes.
|
|
13370
|
+
//
|
|
13371
|
+
// A mouse click on those same buttons does exactly the same thing, so this
|
|
13372
|
+
// is not something the keyboard route introduced — but a mouse user climbs
|
|
13373
|
+
// out by clicking into the text and a keyboard user cannot, because Tab from
|
|
13374
|
+
// BODY goes nowhere. Deferred to v3.4.0 rather than fixed here: the fix is
|
|
13375
|
+
// in the activation path, which the mouse shares.
|
|
13376
|
+
//
|
|
13377
|
+
// THE WAY OUT until then, driven end to end from a stranded BODY: the entry
|
|
13378
|
+
// chord still works there (the bar's own state survives, and its row is
|
|
13379
|
+
// fully enabled), so walking to the mode button and pressing Enter puts the
|
|
13380
|
+
// caret in the source textarea, where typing lands. Coming back the same way
|
|
13381
|
+
// returns the document to edit mode but leaves the caret on BODY again — the
|
|
13382
|
+
// hatch is somewhere to keep typing, not a way back to the WYSIWYG caret.
|
|
13383
|
+
function activateToolbarCursor() {
|
|
13384
|
+
const btn = toolbarRovingBtns()[toolbarRovingIndex];
|
|
13385
|
+
if (!btn || btn.disabled) return;
|
|
13386
|
+
Promise.resolve(runToolbarAction(btn.getAttribute('data-ed-tb'), btn))
|
|
13387
|
+
.catch(() => {}).then(updateToolbar);
|
|
11516
13388
|
}
|
|
11517
13389
|
|
|
11518
13390
|
function buildToolbar() {
|
|
@@ -11649,6 +13521,10 @@
|
|
|
11649
13521
|
}
|
|
11650
13522
|
|
|
11651
13523
|
// ── headings ▾ ─────────────────────────────────────────────────────────
|
|
13524
|
+
// KNOWN GAP, documented rather than fixed: the toolbar's keyboard cursor can
|
|
13525
|
+
// open and close this dropdown, but its items are still reachable by mouse
|
|
13526
|
+
// only — they carry no cursor of their own and the mode's arrow keys move
|
|
13527
|
+
// along the bar, not into the panel. No keyboard route reaches them.
|
|
11652
13528
|
function closeToolbarMenu() {
|
|
11653
13529
|
if (toolbarMenuEl) { toolbarMenuEl.remove(); toolbarMenuEl = null; }
|
|
11654
13530
|
}
|
|
@@ -11673,8 +13549,21 @@
|
|
|
11673
13549
|
});
|
|
11674
13550
|
document.body.appendChild(menu);
|
|
11675
13551
|
const r = btnEl.getBoundingClientRect();
|
|
11676
|
-
|
|
13552
|
+
const top = r.bottom + 4;
|
|
13553
|
+
menu.style.top = top + 'px';
|
|
11677
13554
|
menu.style.left = Math.max(4, Math.min(r.left, window.innerWidth - 140)) + 'px';
|
|
13555
|
+
// Task 9 fix round 1 (Ruling T9-5): same clamp shape as
|
|
13556
|
+
// openConvertSubmenu()'s .ed-handle-submenu fix above — measure the
|
|
13557
|
+
// panel's own rendered bottom after it is appended and positioned, and
|
|
13558
|
+
// shift `top` up by the bottom overflow when there is one.
|
|
13559
|
+
// `.ed-toolbar-menu` is `position: fixed` (lib/md2doc.js), so its own
|
|
13560
|
+
// getBoundingClientRect() is already in viewport coordinates and `top`
|
|
13561
|
+
// needs no containing-block translation the way the absolute-positioned
|
|
13562
|
+
// submenu did.
|
|
13563
|
+
const overflowBottom = menu.getBoundingClientRect().bottom - window.innerHeight;
|
|
13564
|
+
if (overflowBottom > 0) {
|
|
13565
|
+
menu.style.top = (top - overflowBottom) + 'px';
|
|
13566
|
+
}
|
|
11678
13567
|
toolbarMenuEl = menu;
|
|
11679
13568
|
}
|
|
11680
13569
|
|
|
@@ -11748,7 +13637,7 @@
|
|
|
11748
13637
|
// A block that owns no source line has an inverted range, and there is
|
|
11749
13638
|
// no line to splice in front of.
|
|
11750
13639
|
if (!rec || rec.endLine < rec.startLine) {
|
|
11751
|
-
refuseStructuralListEdit(
|
|
13640
|
+
refuseStructuralListEdit(noSourceLineInsertMessageFor(liveBlockEl));
|
|
11752
13641
|
return;
|
|
11753
13642
|
}
|
|
11754
13643
|
const at = rec.startLine;
|
|
@@ -12124,6 +14013,7 @@
|
|
|
12124
14013
|
const md = String(payload.value == null ? '' : payload.value).replace(/\r\n?/g, '\n');
|
|
12125
14014
|
if (md.trim() === '') return;
|
|
12126
14015
|
if (isInlineSafePaste(md)) {
|
|
14016
|
+
snapBurstIfActive(editEl, 'paste-pre');
|
|
12127
14017
|
insertTextAtCaret(md);
|
|
12128
14018
|
snapBurstIfActive(editEl, 'paste');
|
|
12129
14019
|
return;
|
|
@@ -12136,6 +14026,7 @@
|
|
|
12136
14026
|
insertBlockBelow(editEl.closest('.ed-block'), 'paragraph', { lines: md.split('\n') });
|
|
12137
14027
|
return;
|
|
12138
14028
|
}
|
|
14029
|
+
snapBurstIfActive(editEl, 'paste-pre');
|
|
12139
14030
|
insertTextAtCaret(payload.value || '');
|
|
12140
14031
|
snapBurstIfActive(editEl, 'paste');
|
|
12141
14032
|
}
|
|
@@ -12256,6 +14147,16 @@
|
|
|
12256
14147
|
if (!e.target || !e.target.closest) return;
|
|
12257
14148
|
if (e.target.closest('.ed-toolbar') || e.target.closest('.ed-toolbar-menu')) return;
|
|
12258
14149
|
closeToolbarMenu();
|
|
14150
|
+
// F12: a press somewhere else is a hand-back, so the bar's keyboard
|
|
14151
|
+
// cursor goes with it. Deliberately hung on click and not on focusin:
|
|
14152
|
+
// SOME activations do land a focusin on an editable surface, and hanging
|
|
14153
|
+
// the hand-back there would end the mode after a single press for those.
|
|
14154
|
+
// Driven with an uncommitted edit pending, the ones that fire a focusin
|
|
14155
|
+
// are list, ordered-list, check, table, insert-before, insert-after and
|
|
14156
|
+
// preview; headings, outline, and everything in the landing note on
|
|
14157
|
+
// activateToolbarCursor() fire none. So this cannot be a blanket claim
|
|
14158
|
+
// about activation, and the earlier version of this comment made it one.
|
|
14159
|
+
exitToolbarKeynav();
|
|
12259
14160
|
const blockEl = e.target.closest('.ed-block');
|
|
12260
14161
|
if (blockEl) toolbarBlockEl = blockEl;
|
|
12261
14162
|
updateToolbar();
|
|
@@ -12282,16 +14183,110 @@
|
|
|
12282
14183
|
// this reason: the conflict banner, the selection toolbar, the table
|
|
12283
14184
|
// grips and the table edge menu.
|
|
12284
14185
|
document.body.appendChild(toolbarEl);
|
|
14186
|
+
// A scroll event does not bubble, so this one is on the bar itself rather
|
|
14187
|
+
// than folded into the document-level capture-phase listener at the foot
|
|
14188
|
+
// of this file. That listener does receive the bar's own scroll —
|
|
14189
|
+
// measured, capture reaches a target that never bubbles — but what it
|
|
14190
|
+
// does with it is tear down grips and menus, which has nothing to do with
|
|
14191
|
+
// repainting an edge gradient.
|
|
14192
|
+
//
|
|
14193
|
+
// The resize listener is not redundant with the scroll one. A viewport
|
|
14194
|
+
// change moves clientWidth, and whether it also produces a scroll event
|
|
14195
|
+
// depends on where the bar happens to be resting: driven from 420px out
|
|
14196
|
+
// to 1400px, the bar sitting at its scroll maximum got a scroll event
|
|
14197
|
+
// (the clamp back to zero), and the bar already at zero got none at all,
|
|
14198
|
+
// while both had to end up with the hint out.
|
|
14199
|
+
toolbarEl.addEventListener('scroll', paintToolbarOverflow, { passive: true });
|
|
14200
|
+
window.addEventListener('resize', paintToolbarOverflow);
|
|
12285
14201
|
document.body.setAttribute('data-ed-mode', docMode);
|
|
12286
14202
|
paintModeStatus();
|
|
12287
14203
|
wireToolbarTracking();
|
|
12288
14204
|
wireImageDrop();
|
|
12289
14205
|
wirePlainPasteArming();
|
|
14206
|
+
// No separate first paint of the edge hint here: on this call the
|
|
14207
|
+
// remembered toolbar signature is still its initial empty string, which no
|
|
14208
|
+
// JSON.stringify() of a state object can equal, so updateToolbar() takes
|
|
14209
|
+
// its write branch and repaints on the way out. Driven at a 420px-wide
|
|
14210
|
+
// viewport, the bar carries the right-hand token before any gesture.
|
|
12290
14211
|
updateToolbar();
|
|
12291
14212
|
}
|
|
12292
14213
|
|
|
12293
14214
|
// ── global key handling ─────────────────────────────────────────────────
|
|
12294
14215
|
document.addEventListener('keydown', (e) => {
|
|
14216
|
+
// ── F12: the toolbar's keyboard mode owns its own keys ─────────────
|
|
14217
|
+
// Above everything, including the `.ed-raw` textarea bail further down.
|
|
14218
|
+
// That bail returns for EVERY key, so an entry gesture placed under it
|
|
14219
|
+
// would be dead while a degraded block's raw editor holds focus — driven
|
|
14220
|
+
// from that editor, the gesture lands on the bar and the caret stays in
|
|
14221
|
+
// the textarea. Source mode is a different surface (`textarea.ed-source`,
|
|
14222
|
+
// which that bail does not match) and reaches the bar as well; there the
|
|
14223
|
+
// model disables the row apart from the button that gets you back out, so
|
|
14224
|
+
// the cursor lands on that one.
|
|
14225
|
+
//
|
|
14226
|
+
// Escape is NOT claimed here. It belongs to the ordered prologue just
|
|
14227
|
+
// below, which already ranks a drag and an open menu above everything
|
|
14228
|
+
// else; the rung that leaves this mode was inserted there, in rank order,
|
|
14229
|
+
// rather than jumped ahead of them.
|
|
14230
|
+
//
|
|
14231
|
+
// Anything this mode does not name hands the keyboard straight back: the
|
|
14232
|
+
// mode ends and the keystroke carries on down this listener to whatever
|
|
14233
|
+
// would have had it.
|
|
14234
|
+
//
|
|
14235
|
+
// The modifier names in that list are KEY NAMES, not modifier flags:
|
|
14236
|
+
// `e.key === 'Control'` is the Control key pressed BY ITSELF, which
|
|
14237
|
+
// browsers dispatch a keydown for — driven, and the row asserts that
|
|
14238
|
+
// dispatch before it asserts anything about the cue, because a browser
|
|
14239
|
+
// that stayed silent would make the whole check pass while proving
|
|
14240
|
+
// nothing. Without the exclusions that keydown reaches the hand-back, so a
|
|
14241
|
+
// user who presses Control to begin a chord loses the cursor before
|
|
14242
|
+
// finishing it. That bare press is the shape that separates them, and each
|
|
14243
|
+
// is ablated on its own against it rather than reasoned about as a set.
|
|
14244
|
+
//
|
|
14245
|
+
// A chord does NOT separate them, which is why an earlier reading here
|
|
14246
|
+
// called `Control` and `Meta` unpinnable: Ctrl+S ends the mode and writes
|
|
14247
|
+
// the file whether or not `Control` is excluded, because the `s` behind it
|
|
14248
|
+
// is unnamed here and hands back before the save branch below runs. The
|
|
14249
|
+
// exclusion does its work at the modifier's own keydown, which lands
|
|
14250
|
+
// before the key a chord is named for ever arrives.
|
|
14251
|
+
//
|
|
14252
|
+
// `Alt` and `Shift` carry a second duty on top of that:
|
|
14253
|
+
//
|
|
14254
|
+
// Alt — WITHOUT it the entry chord stops toggling. The Alt keydown
|
|
14255
|
+
// would end the mode, and the F10 behind it would then arrive
|
|
14256
|
+
// with the mode already off and re-enter, so pressing the chord
|
|
14257
|
+
// a second time would look like it did nothing. WITH it, the
|
|
14258
|
+
// Alt keydown is ignored and the F10 lands as an unnamed key,
|
|
14259
|
+
// which is what ends the mode. The exclusion is the cause of the
|
|
14260
|
+
// toggle, not a defence against it.
|
|
14261
|
+
// Shift — keeps Shift+Arrow on the toolbar cursor. Without it the Shift
|
|
14262
|
+
// keydown ends the mode and the arrow behind it falls through to
|
|
14263
|
+
// the block-selection extension instead.
|
|
14264
|
+
//
|
|
14265
|
+
// A menu raised from this bar goes down on the way past. Without this the
|
|
14266
|
+
// hand-back cleared the cursor cue and left the H▾ dropdown standing, so
|
|
14267
|
+
// the two disagreed and only Escape was any use. Escape is excluded
|
|
14268
|
+
// because the ordered prologue below owns it and takes the menu down
|
|
14269
|
+
// there, in rank order; Enter and Space are excluded because they are the
|
|
14270
|
+
// keys that opened the menu and have to keep closing it.
|
|
14271
|
+
if (toolbarRovingIndex >= 0) {
|
|
14272
|
+
if (toolbarMenuEl && e.key !== 'Escape' && e.key !== 'Enter' && e.key !== ' ') {
|
|
14273
|
+
closeToolbarMenu();
|
|
14274
|
+
}
|
|
14275
|
+
if (e.key === 'ArrowRight') { e.preventDefault(); moveToolbarCursor(1); return; }
|
|
14276
|
+
if (e.key === 'ArrowLeft') { e.preventDefault(); moveToolbarCursor(-1); return; }
|
|
14277
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
14278
|
+
e.preventDefault(); activateToolbarCursor(); return;
|
|
14279
|
+
}
|
|
14280
|
+
if (e.key !== 'Escape' && e.key !== 'Alt' && e.key !== 'Shift' &&
|
|
14281
|
+
e.key !== 'Control' && e.key !== 'Meta') {
|
|
14282
|
+
exitToolbarKeynav();
|
|
14283
|
+
}
|
|
14284
|
+
} else if (e.altKey && e.key === 'F10') {
|
|
14285
|
+
e.preventDefault();
|
|
14286
|
+
enterToolbarKeynav();
|
|
14287
|
+
return;
|
|
14288
|
+
}
|
|
14289
|
+
|
|
12295
14290
|
// ── S3 §4.4: Escape priority is drag > menu > selection > burst ─────
|
|
12296
14291
|
// A new ordered PROLOGUE, not a reshuffle of what follows (§4.4 is
|
|
12297
14292
|
// explicit about that). The two target-based short-circuits further down
|
|
@@ -12354,6 +14349,11 @@
|
|
|
12354
14349
|
closeInsertMenu();
|
|
12355
14350
|
return;
|
|
12356
14351
|
}
|
|
14352
|
+
// F12, in rank order: the H▾ dropdown is a menu raised from inside the
|
|
14353
|
+
// bar, so it goes out before the bar's keyboard mode does, matching the
|
|
14354
|
+
// way the menu rungs above rank against the selection rung below.
|
|
14355
|
+
if (toolbarMenuEl) { e.preventDefault(); closeToolbarMenu(); return; }
|
|
14356
|
+
if (exitToolbarKeynav()) { e.preventDefault(); return; }
|
|
12357
14357
|
if (blockSelection) { e.preventDefault(); clearBlockSelection(); return; }
|
|
12358
14358
|
// Nothing above owns this Escape — fall through to the burst
|
|
12359
14359
|
// short-circuits below, where Escape-reverts-the-burst still belongs.
|
|
@@ -12643,18 +14643,121 @@
|
|
|
12643
14643
|
switchAwayFrom();
|
|
12644
14644
|
});
|
|
12645
14645
|
|
|
12646
|
-
|
|
12647
|
-
|
|
14646
|
+
// The `inputType` values Chromium reports for the BROWSER's own formatting
|
|
14647
|
+
// commands. md2doc binds no formatting shortcut of its own — the keys its
|
|
14648
|
+
// `ctrlKey || metaKey` branches act on are `Enter`, `z`, `y`, shift+`Z`,
|
|
14649
|
+
// `s` and shift+`v`/`V`: the raw editor's commit, undo, redo, save and
|
|
14650
|
+
// plain-paste arming. `b`, `i` and `u` are not among them, so Ctrl+B
|
|
14651
|
+
// reaches the contenteditable as the browser's own execCommand and does not
|
|
14652
|
+
// pass through any snapBurstIfActive() call site.
|
|
14653
|
+
//
|
|
14654
|
+
// MEASURED (puppeteer 24.42.0 / headless Chromium, an `.ed-wys-armed`
|
|
14655
|
+
// paragraph, ' typed sentence' typed then a five-character selection
|
|
14656
|
+
// bolded with Ctrl+B) — exactly two events, in this order:
|
|
14657
|
+
//
|
|
14658
|
+
// beforeinput inputType="formatBold" innerHTML "Alpha paragraph. typed sentence"
|
|
14659
|
+
// input inputType="formatBold" innerHTML "Alpha paragraph. <b>typed</b> sentence"
|
|
14660
|
+
//
|
|
14661
|
+
// i.e. the mutation is the beforeinput's default action, so a beforeinput
|
|
14662
|
+
// listener still sees the pre-mutation DOM. That is what makes the pair
|
|
14663
|
+
// below the native-command equivalent of the `*-pre` / post snapshots every
|
|
14664
|
+
// programmatic call site takes (grep `snapBurstIfActive(` and
|
|
14665
|
+
// `history.snap('` for the two families).
|
|
14666
|
+
//
|
|
14667
|
+
// Three of the inputTypes below were actually driven end to end and are
|
|
14668
|
+
// pinned by journey rows: formatBold (Ctrl+B), formatItalic (Ctrl+I),
|
|
14669
|
+
// formatUnderline (Ctrl+U) — measured tags `<b>` / `<i>` / `<u>`. The rest —
|
|
14670
|
+
// formatStrikeThrough, formatSuperscript, formatSubscript, formatRemove —
|
|
14671
|
+
// are listed on the same mechanism but have NOT been driven here; they cost
|
|
14672
|
+
// nothing when they never fire, and they are not a claim that they work.
|
|
14673
|
+
//
|
|
14674
|
+
// A Set, not an object literal: an object literal inherits from
|
|
14675
|
+
// Object.prototype, so `types['constructor']` and `types['toString']` are
|
|
14676
|
+
// truthy and an inputType named like a prototype key would take the format
|
|
14677
|
+
// path. `.has()` answers only for what is actually listed.
|
|
14678
|
+
const NATIVE_FORMAT_INPUT_TYPES = new Set([
|
|
14679
|
+
'formatBold', 'formatItalic', 'formatUnderline', 'formatStrikeThrough',
|
|
14680
|
+
'formatSuperscript', 'formatSubscript', 'formatRemove'
|
|
14681
|
+
]);
|
|
14682
|
+
|
|
14683
|
+
// Resolves the burst history that owns `e.target`, or null. This is the
|
|
14684
|
+
// selection logic the `input` listener carried inline before v3.3.0, lifted
|
|
14685
|
+
// out unchanged so the new `beforeinput` listener answers the same question
|
|
14686
|
+
// the same way: a `.ed-wys-cell` only counts when the current burst is the
|
|
14687
|
+
// table burst over its own <table>, and any other surface only counts when
|
|
14688
|
+
// it IS the current burst's editEl.
|
|
14689
|
+
function burstHistoryForInputEvent(e) {
|
|
14690
|
+
if (!e.target || !e.target.closest) return null;
|
|
14691
|
+
const cellEl = e.target.closest('.ed-wys-cell');
|
|
12648
14692
|
if (cellEl) {
|
|
12649
14693
|
const tableEl = cellEl.closest('table');
|
|
12650
14694
|
if (currentBurst && currentBurst.blockType === 'table' && currentBurst.editEl === tableEl) {
|
|
12651
|
-
currentBurst.history
|
|
14695
|
+
return currentBurst.history;
|
|
12652
14696
|
}
|
|
14697
|
+
return null;
|
|
14698
|
+
}
|
|
14699
|
+
const editEl = e.target.closest('.ed-wys-armed');
|
|
14700
|
+
if (!editEl || !currentBurst || currentBurst.editEl !== editEl) return null;
|
|
14701
|
+
return currentBurst.history;
|
|
14702
|
+
}
|
|
14703
|
+
|
|
14704
|
+
// Capture phase, matching the phase the ordering above was measured in.
|
|
14705
|
+
document.addEventListener('beforeinput', (e) => {
|
|
14706
|
+
if (!NATIVE_FORMAT_INPUT_TYPES.has(e.inputType)) return;
|
|
14707
|
+
const history = burstHistoryForInputEvent(e);
|
|
14708
|
+
if (!history) return;
|
|
14709
|
+
history.snap('native-format-pre');
|
|
14710
|
+
}, true);
|
|
14711
|
+
|
|
14712
|
+
document.addEventListener('input', (e) => {
|
|
14713
|
+
const history = burstHistoryForInputEvent(e);
|
|
14714
|
+
if (!history) return;
|
|
14715
|
+
if (NATIVE_FORMAT_INPUT_TYPES.has(e.inputType)) {
|
|
14716
|
+
// The post-mutation half, and it is LOAD-BEARING — do not simplify it
|
|
14717
|
+
// back to noteTyping(). noteTyping() does not push anything for this
|
|
14718
|
+
// event: it snaps only when the PREVIOUS pending capture is already
|
|
14719
|
+
// >= debounceMs old, then sets `isPendingSnap = true` (history.js,
|
|
14720
|
+
// noteTyping()). Nothing clears that flag on a timer — only snap(),
|
|
14721
|
+
// start() and dispose() assign it false — so the pending capture stands
|
|
14722
|
+
// until something flushes it, and undo() flushes it as its first act.
|
|
14723
|
+
// By then the capture is of the DOM as it is at UNDO time, not as it
|
|
14724
|
+
// was when the command ran.
|
|
14725
|
+
//
|
|
14726
|
+
// TRACED by wrapping window.md2docHistory.createBurstHistory and
|
|
14727
|
+
// logging every call with the stack size on both sides. Two gestures,
|
|
14728
|
+
// this line present vs. swapped for noteTyping():
|
|
14729
|
+
//
|
|
14730
|
+
// ── Ctrl+B, keep typing inside the window, Ctrl+Z ────────────────
|
|
14731
|
+
// with: snap('native-format-pre') 1->2 snap('native-format') 2->3
|
|
14732
|
+
// noteTyping() 3->3 x2 undo() 3->3
|
|
14733
|
+
// DOM after undo "Alpha paragraph. <b>typed</b> sentence"
|
|
14734
|
+
// without: snap('native-format-pre') 1->2 noteTyping() 2->2 x3
|
|
14735
|
+
// undo() 2->2
|
|
14736
|
+
// DOM after undo "Alpha paragraph. typed sentence"
|
|
14737
|
+
//
|
|
14738
|
+
// Read the `undo() N->N` rows: the size does not drop, so undo() pushed
|
|
14739
|
+
// one entry (its flushTyping()) and popped one. WITHOUT this line that
|
|
14740
|
+
// flushed entry is "bold + XY", so the pop lands one entry short of the
|
|
14741
|
+
// bold and the bold is undone together with everything typed after it —
|
|
14742
|
+
// this task's own defect with a different prefix. The journey row
|
|
14743
|
+
// 「原生 Ctrl+B 後【400ms 內】繼續打字」 pins exactly that.
|
|
14744
|
+
//
|
|
14745
|
+
// ── Ctrl+B, 250ms pause, Ctrl+Z (the four earlier rows) ──────────
|
|
14746
|
+
// with: snap('native-format-pre') 1->2 snap('native-format') 2->3
|
|
14747
|
+
// undo() 3->2
|
|
14748
|
+
// without: snap('native-format-pre') 1->2 noteTyping() 2->2
|
|
14749
|
+
// undo() 2->2
|
|
14750
|
+
// both: DOM after undo "Alpha paragraph. typed sentence"
|
|
14751
|
+
//
|
|
14752
|
+
// Those four rows do NOT pin this line, and the reason is NOT that the
|
|
14753
|
+
// pause cleared anything — the `undo() 2->2` above shows the pending
|
|
14754
|
+
// capture was still there and was flushed. It is that with nothing
|
|
14755
|
+
// typed after the command, the flushed capture EQUALS what this line
|
|
14756
|
+
// would have pushed, so undo steps back to the same entry either way.
|
|
14757
|
+
history.snap('native-format');
|
|
12653
14758
|
return;
|
|
12654
14759
|
}
|
|
12655
|
-
|
|
12656
|
-
if (!editEl || !currentBurst || currentBurst.editEl !== editEl) return;
|
|
12657
|
-
currentBurst.history.noteTyping();
|
|
14760
|
+
history.noteTyping();
|
|
12658
14761
|
});
|
|
12659
14762
|
|
|
12660
14763
|
document.addEventListener('paste', (e) => {
|
|
@@ -12662,6 +14765,7 @@
|
|
|
12662
14765
|
if (cellEl) {
|
|
12663
14766
|
e.preventDefault();
|
|
12664
14767
|
const dt = e.clipboardData || window.clipboardData;
|
|
14768
|
+
snapBurstIfActive(cellEl.closest('table'), 'paste-pre');
|
|
12665
14769
|
insertTextAtCaret(dt ? dt.getData('text/plain') : '');
|
|
12666
14770
|
snapBurstIfActive(cellEl.closest('table'), 'paste');
|
|
12667
14771
|
return;
|
|
@@ -12786,20 +14890,47 @@
|
|
|
12786
14890
|
if (selScrollRaf) return;
|
|
12787
14891
|
selScrollRaf = requestAnimationFrame(() => {
|
|
12788
14892
|
selScrollRaf = 0;
|
|
12789
|
-
|
|
14893
|
+
// 沒升起 → 0 次 layout read。Task 16 adds a boolean read here and
|
|
14894
|
+
// nothing else. Measured over a scroll burst against instrumented
|
|
14895
|
+
// Range/Element rect getters and getComputedStyle: with nothing
|
|
14896
|
+
// selected, with a burst open over a collapsed caret, and with a
|
|
14897
|
+
// selection that went collapsed while the bar was scrolled away, the
|
|
14898
|
+
// reads positionSelToolbar() performs stayed at zero — the same
|
|
14899
|
+
// figures as before this line changed. What does read layout per
|
|
14900
|
+
// scroll now is a live non-collapsed selection inside the edit root:
|
|
14901
|
+
// over the same burst that went from two Range rects to eighteen, one
|
|
14902
|
+
// per scroll event, which is the state this branch exists to re-ask.
|
|
14903
|
+
if (!selToolbar.parentNode && !selToolbarOffViewport) return;
|
|
12790
14904
|
const sel = window.getSelection();
|
|
12791
14905
|
if (!sel || !sel.rangeCount || sel.isCollapsed) { hideSelToolbar(); return; }
|
|
12792
14906
|
const range = sel.getRangeAt(0);
|
|
12793
14907
|
if (!selToolbarEditEl ||
|
|
12794
14908
|
!selToolbarEditEl.contains(range.startContainer) ||
|
|
12795
14909
|
!selToolbarEditEl.contains(range.endContainer)) { hideSelToolbar(); return; }
|
|
14910
|
+
// Task 15's suppression is asked on this path too, and it has to be:
|
|
14911
|
+
// before Task 16 the gate above returned on a detached bar, so a
|
|
14912
|
+
// scroll could not raise one. It can now, and a Tab-made full-cell
|
|
14913
|
+
// selection is a live non-collapsed selection inside the edit root —
|
|
14914
|
+
// the shape the checks above admit. Measured with this line dropped,
|
|
14915
|
+
// on a hand selection raised over a cell, scrolled out of view and
|
|
14916
|
+
// then walked on by Tab: the scroll that Tab's own focus() causes
|
|
14917
|
+
// appended .ed-seltb over the Tab's selection, and it was down again by
|
|
14918
|
+
// the time the gesture settled — so the settled state reads the same
|
|
14919
|
+
// with this line and without it, and a MutationObserver counting the
|
|
14920
|
+
// append is what sees the difference.
|
|
14921
|
+
//
|
|
14922
|
+
// The flag is read, not the boundary comparison that releases it in
|
|
14923
|
+
// onSelectionChangeForToolbar(): a scroll has no release to make, it
|
|
14924
|
+
// has to honour what the last selectionchange decided, and reading the
|
|
14925
|
+
// flag costs no layout.
|
|
14926
|
+
if (tabSelectedCellEl) { hideSelToolbar(); return; }
|
|
12796
14927
|
positionSelToolbar(range);
|
|
12797
14928
|
});
|
|
12798
14929
|
}
|
|
12799
14930
|
document.addEventListener('scroll', onAnyScroll, { passive: true, capture: true });
|
|
12800
14931
|
|
|
12801
14932
|
window.addEventListener('beforeunload', (e) => {
|
|
12802
|
-
if (stack.dirtyDepth !== 0) {
|
|
14933
|
+
if (stack.dirtyDepth !== 0 || burstHasUncommittedEdit()) {
|
|
12803
14934
|
e.preventDefault();
|
|
12804
14935
|
e.returnValue = '';
|
|
12805
14936
|
return '';
|