@helping-ai-workflow/md2doc 3.0.2 → 3.2.1
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/asset.js +235 -0
- package/lib/editor/client.js +3054 -140
- package/lib/editor/docsource.js +115 -0
- package/lib/editor/paste-md.js +275 -0
- package/lib/editor/patchmap.js +83 -0
- package/lib/editor/server.js +195 -3
- package/lib/editor/toolbar-model.js +175 -0
- package/lib/md2doc.js +165 -4
- package/package.json +3 -2
package/lib/editor/client.js
CHANGED
|
@@ -520,7 +520,18 @@
|
|
|
520
520
|
const convertMd = window.md2docConvertMd;
|
|
521
521
|
const selectionLib = window.md2docSelection;
|
|
522
522
|
const historyLib = window.md2docHistory;
|
|
523
|
+
// v3.1.0 Task E: the four Phase-1 modules, injected by server.js ahead of
|
|
524
|
+
// this file. Read the same way every earlier module is — off `window`, not
|
|
525
|
+
// via require() (this script is a plain <script> tag in the page, not a
|
|
526
|
+
// bundle). Each is read defensively at its call sites: an older cached page
|
|
527
|
+
// that predates the injection must degrade to "the toolbar is absent",
|
|
528
|
+
// never to "the whole client runtime throws before it arms anything".
|
|
529
|
+
const toolbarModel = window.md2docToolbarModel;
|
|
530
|
+
const pasteMd = window.md2docPasteMd;
|
|
531
|
+
const assetLib = window.md2docAsset;
|
|
532
|
+
const docSourceLib = window.md2docDocSource;
|
|
523
533
|
let lines = ED.lines, blocks = ED.blocks, mtimeMs = ED.mtimeMs;
|
|
534
|
+
let lastParts = null;
|
|
524
535
|
// 檔案原本的換行符。lines 內部永遠是不含 \r 的純內容行;只有 save()
|
|
525
536
|
// 會把它接回這個 EOL,render 一律用 \n(spec §3.11)。
|
|
526
537
|
const EOL = ED.eol || '\n';
|
|
@@ -816,7 +827,7 @@
|
|
|
816
827
|
// response — WITHOUT touching contentEl.innerHTML or `blocks`, and shows
|
|
817
828
|
// a dismissible banner explaining what happened. Never throws: every
|
|
818
829
|
// await is inside its own try/catch, so callers never see a rejection.
|
|
819
|
-
async function rerenderAll() {
|
|
830
|
+
async function rerenderAll(editRange) {
|
|
820
831
|
const scrollY = window.scrollY;
|
|
821
832
|
// S3 Task 5 (§4.4 step 2): the line range this render's operation declared
|
|
822
833
|
// for the rebuilt selection. Consumed HERE, before the first failure exit,
|
|
@@ -849,7 +860,7 @@
|
|
|
849
860
|
showBanner('Render failed — malformed server response. Your edit was not applied.', null, null);
|
|
850
861
|
return false;
|
|
851
862
|
}
|
|
852
|
-
if (
|
|
863
|
+
if (!Array.isArray(j.parts) || !Array.isArray(j.blocks)) {
|
|
853
864
|
showBanner('Render failed — malformed server response. Your edit was not applied.', null, null);
|
|
854
865
|
return false;
|
|
855
866
|
}
|
|
@@ -857,21 +868,667 @@
|
|
|
857
868
|
// gesture asked for it has succeeded. Review recommendation 5 — a stale
|
|
858
869
|
// structural refusal is cleared HERE and nowhere else.
|
|
859
870
|
dismissRefusalBanner();
|
|
871
|
+
// Task E: rerenderAll() now takes `editRange` as its own parameter —
|
|
872
|
+
// every call site that has a real op computes it there (see the 18
|
|
873
|
+
// safeRerenderAll() call sites that pass one) and threads it down through
|
|
874
|
+
// safeRerenderAll() to here. Passed straight through to
|
|
875
|
+
// applyRenderResult() as the second positional argument; Task F is what
|
|
876
|
+
// actually reads it to decide patch-vs-fallback.
|
|
877
|
+
//
|
|
878
|
+
// Fix round 1: `scrollY` and `declaredRange` are captured ABOVE, before
|
|
879
|
+
// the extraction boundary (they must be read early — see their own
|
|
880
|
+
// comments — well before this point), so they are NOT part of the
|
|
881
|
+
// moved tail and are out of `applyFullRender`'s scope unless threaded
|
|
882
|
+
// through explicitly. Passed positionally here and through
|
|
883
|
+
// applyRenderResult() rather than folded into `j` (which is the
|
|
884
|
+
// server's response payload, not render-context the CALLER derived).
|
|
885
|
+
return applyRenderResult(j, editRange, scrollY, declaredRange);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// v3.2.0 Task F: patch-vs-fallback dispatch.
|
|
889
|
+
//
|
|
890
|
+
// `scrollY`/`declaredRange` are rerenderAll()'s own pre-fetch captures
|
|
891
|
+
// (see its comments) — applyFullRender() needs them for the same
|
|
892
|
+
// window.scrollTo()/rebuildBlockSelection() calls the tail always made,
|
|
893
|
+
// but as a sibling function it cannot reach rerenderAll()'s locals by
|
|
894
|
+
// closure, so they ride along here instead.
|
|
895
|
+
//
|
|
896
|
+
// `scrollY` is forwarded to applyFullRender() ONLY (controller ruling F3):
|
|
897
|
+
// the fallback needs it because `contentEl.innerHTML = ...` destroys and
|
|
898
|
+
// rebuilds every node, collapsing the scroll extent to zero for an instant;
|
|
899
|
+
// the patch path leaves the prefix and suffix nodes attached, so the scroll
|
|
900
|
+
// position never moves and re-applying a pre-fetch scrollY would fight the
|
|
901
|
+
// browser (the reader may legitimately be somewhere else by the time the
|
|
902
|
+
// response lands).
|
|
903
|
+
//
|
|
904
|
+
// `editRange` gates the patch attempt AS A BOOLEAN and in no other way: the
|
|
905
|
+
// 3 call sites with no committed op pass nothing and fall back by contract.
|
|
906
|
+
// Its VALUE is forwarded to patchmap(), which never reads it (決議 4(b) — it
|
|
907
|
+
// is advisory, and seeding the span from an op's line range measurably
|
|
908
|
+
// widens the answer), so neither the direction nor the width of the range
|
|
909
|
+
// affects the plan. `lastParts` is null
|
|
910
|
+
// on the first render of a session (the bootstrap payload carries no parts),
|
|
911
|
+
// which makes patchmap() return null — so the first commit after a page load
|
|
912
|
+
// is ALWAYS a full render. That is specified behaviour, not a bug.
|
|
913
|
+
// Fix round 2: applyPatch()'s index arithmetic addresses `contentEl`'s
|
|
914
|
+
// element children POSITIONALLY against `lastParts`. That correspondence is
|
|
915
|
+
// not free — several gestures mutate .content's child list DIRECTLY, in the
|
|
916
|
+
// window between two renders, and every one of them breaks it:
|
|
917
|
+
//
|
|
918
|
+
// :insertListItemAfter() inserts a PROVISIONAL li (no data-block-id at
|
|
919
|
+
// all) as a sibling before its own commit renders
|
|
920
|
+
// :splitListItemAtCaret() same shape, on Enter inside a li
|
|
921
|
+
// :duplicateListItem() inserts cloneNode()d copies, which carry the
|
|
922
|
+
// ORIGINAL's data-block-id — duplicated ids
|
|
923
|
+
// :moveListItem() re-inserts existing li elements at a new
|
|
924
|
+
// position: the COUNT is unchanged and every id
|
|
925
|
+
// is still present, only the ORDER moved
|
|
926
|
+
// :removeListItem() REMOVES li elements outright (the Backspace-
|
|
927
|
+
// join and multi-item-delete paths), so the
|
|
928
|
+
// child count drops below lastParts.length
|
|
929
|
+
// before the render that would explain it
|
|
930
|
+
//
|
|
931
|
+
// MEASURED on the +→清單 gesture with a dirty burst open on the anchor
|
|
932
|
+
// (test/editor-client-runtime.test.js:3918): two commits land back to back,
|
|
933
|
+
// and at the second one `lastParts.length` is 3 while contentEl has 4
|
|
934
|
+
// children. patchmap computes a zero-width insertion at index 2, the removal
|
|
935
|
+
// loop removes nothing, and the fragment is inserted BEFORE the provisional
|
|
936
|
+
// node — which nobody then removes. Four li blocks where three belong. The
|
|
937
|
+
// fallback never had the problem: `innerHTML =` wipes whatever the gesture
|
|
938
|
+
// left behind.
|
|
939
|
+
//
|
|
940
|
+
// So the patch path checks, cheaply and positionally, that the DOM still
|
|
941
|
+
// reads exactly as the last render left it: same number of children, and
|
|
942
|
+
// child i still carrying block i's id. Any of the four mutations above fails
|
|
943
|
+
// one of those, and that render falls back — i.e. exactly the pre-v3.2.0
|
|
944
|
+
// behaviour for those gestures, which is why this cannot regress them.
|
|
945
|
+
//
|
|
946
|
+
// ⚠ What it does NOT check is whether a kept node's MARKUP still equals the
|
|
947
|
+
// part that produced it — a gesture that rewrites an attribute in place
|
|
948
|
+
// (data-indent, data-list-start) without changing the child list passes this
|
|
949
|
+
// guard. That is a different family (kept nodes carrying stale local state)
|
|
950
|
+
// and is tracked separately; do not grow this predicate into it without
|
|
951
|
+
// measuring the cost, because unlike this walk that one is not O(1) per node.
|
|
952
|
+
function domMatchesLastRender() {
|
|
953
|
+
if (!Array.isArray(lastParts) || !Array.isArray(blocks)) return false;
|
|
954
|
+
if (blocks.length !== lastParts.length) return false;
|
|
955
|
+
const kids = contentEl.children;
|
|
956
|
+
if (kids.length !== lastParts.length) return false;
|
|
957
|
+
for (let i = 0; i < kids.length; i++) {
|
|
958
|
+
// Compared against blocks[i].id rather than against `i`: the two are the
|
|
959
|
+
// same today (blockmap.js hands out a positional ordinal, which is what
|
|
960
|
+
// makes patchmap's idDelta rewrite work at all) but the JOIN KEY is the
|
|
961
|
+
// id, and stating it that way is what keeps this honest if that identity
|
|
962
|
+
// is ever relaxed.
|
|
963
|
+
if (kids[i].getAttribute('data-block-id') !== String(blocks[i].id)) return false;
|
|
964
|
+
}
|
|
965
|
+
return true;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
async function applyRenderResult(j, editRange, scrollY, declaredRange) {
|
|
969
|
+
const plan = (editRange && window.md2docPatchmap && domMatchesLastRender())
|
|
970
|
+
? window.md2docPatchmap.patchmap({
|
|
971
|
+
oldBlocks: blocks, newBlocks: j.blocks,
|
|
972
|
+
oldParts: lastParts, newParts: j.parts, editRange: editRange,
|
|
973
|
+
})
|
|
974
|
+
: null;
|
|
975
|
+
if (plan) return applyPatch(j, plan, declaredRange);
|
|
976
|
+
return applyFullRender(j, scrollY, declaredRange);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// Incremental DOM patch: replace ONLY the middle span patchmap identified,
|
|
980
|
+
// leaving every prefix and suffix node attached. Keeping those nodes in
|
|
981
|
+
// place is the whole point — it is what preserves document.activeElement,
|
|
982
|
+
// the caret offset, already-rendered mermaid/WaveDrom SVGs and the scroll
|
|
983
|
+
// position across a commit.
|
|
984
|
+
//
|
|
985
|
+
// `declaredRange` arrives as a PARAMETER and is never re-read from
|
|
986
|
+
// `pendingSelectionRange` here (controller ruling F2): rerenderAll() already
|
|
987
|
+
// consumed that module-level slot before it even issued the fetch (see its
|
|
988
|
+
// own comment for why it must be consumed that early), so re-reading it here
|
|
989
|
+
// would always yield undefined and silently drop selection restoration on
|
|
990
|
+
// every patched commit.
|
|
991
|
+
async function applyPatch(j, plan, declaredRange) {
|
|
992
|
+
// 步驟 4 的五步。順序是 spec 決議 2(c) 定死的:
|
|
993
|
+
// 1) 消費 pendingSelectionRange(已由 rerenderAll() 在 fetch 之前做掉,
|
|
994
|
+
// 值以 declaredRange 參數傳進來——ruling F2)
|
|
995
|
+
// 2) 組 <template> 並 armEditables(t.content) —— 唯一可以吃容器的一件事
|
|
996
|
+
// 3) 移除 replaceSpan 節點、insertBefore 插入
|
|
997
|
+
// 4) 重寫被保留後綴節點的 data-block-id
|
|
998
|
+
// 5) rebuildBlockSelection(declaredRange) → __md2docInitDiagrams(contentEl)
|
|
999
|
+
// → __md2docRebindReader() → applySelectionClasses()
|
|
1000
|
+
//
|
|
1001
|
+
// Step 5's internal order matches applyFullRender()'s, deliberately:
|
|
1002
|
+
// rebuildBlockSelection() sits BEFORE the two `try`-swallowed rebind
|
|
1003
|
+
// blocks so a diagram-init throw can never skip it (applyFullRender()'s
|
|
1004
|
+
// own comment says exactly that). The brief's snippet listed the rebinds
|
|
1005
|
+
// first; its prose says to preserve the existing relative order, and the
|
|
1006
|
+
// prose is what is right — a selection silently lost on the render that
|
|
1007
|
+
// introduced the document's first mermaid fence is the failure it names.
|
|
1008
|
+
//
|
|
1009
|
+
// The snapshot is taken BEFORE any mutation: every index below
|
|
1010
|
+
// (`oldStart`..`oldEnd`, and the `oldEnd + 1` reference node) is an index
|
|
1011
|
+
// into the PRE-patch child list, which is what patchmap computed against.
|
|
1012
|
+
// contentEl's element children are the `.ed-block` parts, 1:1 and in
|
|
1013
|
+
// document order: lib/md2doc.js emits each part as a single
|
|
1014
|
+
// `<div class="ed-block" …>…</div>` (the only two emit sites) and the
|
|
1015
|
+
// client only ever assigns `parts.join('\n')` into contentEl. The chrome
|
|
1016
|
+
// is never a direct child — the ⠿ handle, the + button, the gutter menu
|
|
1017
|
+
// and the insert menu all go on a BLOCK, and the source-mode textarea is
|
|
1018
|
+
// inserted as contentEl's SIBLING.
|
|
1019
|
+
//
|
|
1020
|
+
// Fix round 2: that is NOT unconditionally true between renders — four
|
|
1021
|
+
// list gestures mutate this child list directly. domMatchesLastRender()
|
|
1022
|
+
// (see its comment, above applyRenderResult) is what establishes the
|
|
1023
|
+
// property before we are called; it is a PRECONDITION of everything
|
|
1024
|
+
// below, not something this function re-derives.
|
|
1025
|
+
const children = Array.prototype.slice.call(contentEl.children);
|
|
1026
|
+
// v3.2.1: 「被改寫區塊內的游標」焦點修復。動機來自 undo/redo 的量測——
|
|
1027
|
+
// undo/redo 並非普遍掉焦點:游標在被改寫區間【之外】時 activeElement /
|
|
1028
|
+
// 所屬 block / caret offset 全部存活(連一次讓自己 data-block-id 8→7 的
|
|
1029
|
+
// 結構性 undo 都扛得住);只有游標落在被改寫區塊【內】才掉到 BODY,之後
|
|
1030
|
+
// 打字被靜默丟棄。所以判準是位置,不是操作種類,也完全不需要 op→block
|
|
1031
|
+
// 映射(實測 op.startLine 在 14 個實例中只有 4 個正確,對 move / insert
|
|
1032
|
+
// 都指向沒有擁有者的空行——該設計已退掉)。
|
|
1033
|
+
//
|
|
1034
|
+
// ⚠ 觸發面不是「只有 undo/redo」:任何一次 patch 只要聚焦的 block 落在
|
|
1035
|
+
// replaceSpan 內就會走到,fix round 1 的 review 點名了兩類非 undo 的呼叫者——
|
|
1036
|
+
// * 一般的 burst 提交:endBurstWithoutResolve() 不 blur,焦點還留在那個
|
|
1037
|
+
// 即將被換掉的面上;
|
|
1038
|
+
// * 帶 declaredRange 的批次結構操作:焦點坐在 `.ed-block` 外框上(roving
|
|
1039
|
+
// tabindex)。
|
|
1040
|
+
// 這兩類上還原完之後常常會被下面 applySelectionClasses() 自己的
|
|
1041
|
+
// `focusEl.focus()` 立刻覆蓋掉(它把焦點搬回選取的 focus block),也就是多
|
|
1042
|
+
// 一趟 focus 往返而已;journey 案例 3 / 4 / 6 走的正是這些路徑,皆綠。
|
|
1043
|
+
//
|
|
1044
|
+
// 必須在 DOM 手術之前算:removeChild 之後 activeElement 已經是 BODY,
|
|
1045
|
+
// 而 `children` 是 patch 前的快照,也是 plan 的索引所指的那份清單。
|
|
1046
|
+
const focusedBlockEl = document.activeElement &&
|
|
1047
|
+
document.activeElement.closest ? document.activeElement.closest('.ed-block') : null;
|
|
1048
|
+
const ci = focusedBlockEl ? children.indexOf(focusedBlockEl) : -1;
|
|
1049
|
+
const focusWasInSpan = ci >= 0 &&
|
|
1050
|
+
ci >= plan.replaceSpan.oldStart && ci <= plan.replaceSpan.oldEnd;
|
|
1051
|
+
// Fix round 1 (review Important 1): `blocks`/`lastParts` are the JOIN KEY
|
|
1052
|
+
// between the module's model and the DOM, and they are overwritten BEFORE
|
|
1053
|
+
// the DOM is touched — armEditables() reads the new `blocks` (see its call
|
|
1054
|
+
// below), so the order is forced. That opens a window in which a throw
|
|
1055
|
+
// would leave the model describing a DOM that was never built: the
|
|
1056
|
+
// exception bubbles to safeRerenderAll()'s catch, the user is told "Your
|
|
1057
|
+
// edit was not applied", `false` comes back — and the NEXT commit then
|
|
1058
|
+
// diffs parts describing blocks that do not exist, so every index is
|
|
1059
|
+
// wrong and nothing reports it. Same silent-failure shape as ruling F4.
|
|
1060
|
+
//
|
|
1061
|
+
// applyFullRender() has no such window: its `blocks`/`lastParts` →
|
|
1062
|
+
// innerHTML → armEditables() order means the same throw leaves a
|
|
1063
|
+
// consistent "DOM swapped, just not armed" state that the next render
|
|
1064
|
+
// heals. So the patch path buys the equivalent guarantee explicitly:
|
|
1065
|
+
// capture, and put the whole mutation window in a try that rolls the two
|
|
1066
|
+
// back and lets the FALLBACK land the commit instead of losing it.
|
|
1067
|
+
const prevBlocks = blocks;
|
|
1068
|
+
const prevLastParts = lastParts;
|
|
860
1069
|
blocks = j.blocks;
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
1070
|
+
lastParts = j.parts;
|
|
1071
|
+
|
|
1072
|
+
try {
|
|
1073
|
+
// <template> 是刻意指名的,不是「隨便一種容器」:
|
|
1074
|
+
// createDocumentFragment() + innerHTML -> innerHTML 不存在,什麼都插不進去、不報錯
|
|
1075
|
+
// Range.createContextualFragment() -> 文件裡作者手寫的 raw <script> 每次 patch 重新執行
|
|
1076
|
+
// 暫存 <div> 也正確,只多一圈 child drain。
|
|
1077
|
+
const tpl = document.createElement('template');
|
|
1078
|
+
tpl.innerHTML = plan.replaceSpan.newParts.join('\n');
|
|
1079
|
+
// 必須在 blocks 已換成新陣列之後——armEditables 的 li 分支走
|
|
1080
|
+
// canWysiwygForLi → blockOwnsNoLine,那支讀的是模組層的 `blocks`;
|
|
1081
|
+
// 用舊陣列判斷會把一個「不擁有任何來源行」的 li 誤武裝成 contenteditable。
|
|
1082
|
+
//
|
|
1083
|
+
// ⚠ 只對新片段呼叫,永遠不要對被保留的前綴/後綴節點再跑一次:
|
|
1084
|
+
// armEditables 不是 idempotent(⠿ handle 與 + button 是無條件
|
|
1085
|
+
// appendChild),double-arm 實測會變成 10 個 handle 而不是 5 個。
|
|
1086
|
+
armEditables(tpl.content);
|
|
1087
|
+
|
|
1088
|
+
// 保留節點永不 detach:只移除中段,其餘原地不動。
|
|
1089
|
+
// 不得用 replaceChildren() 或 remove-and-re-append 重建整份子節點列表——
|
|
1090
|
+
// 實測 detach 再插回同一位置會讓 document.activeElement 掉回 BODY。
|
|
1091
|
+
//
|
|
1092
|
+
// Fix round 1 (review, comment-only): this removes ELEMENTS, so the `\n`
|
|
1093
|
+
// TEXT nodes `parts.join('\n')` left between the removed blocks stay
|
|
1094
|
+
// behind. Harmless today — `contentEl.children` and allBlockEls()'s
|
|
1095
|
+
// querySelectorAll both skip text nodes, and nothing in this file reads
|
|
1096
|
+
// `contentEl.childNodes` — but it does mean `.content`'s child-NODE shape
|
|
1097
|
+
// after a patch differs from its shape after a full render, which would
|
|
1098
|
+
// matter to any future test that asserts on childNodes rather than
|
|
1099
|
+
// children.
|
|
1100
|
+
for (let i = plan.replaceSpan.oldStart; i <= plan.replaceSpan.oldEnd; i++) {
|
|
1101
|
+
if (children[i]) contentEl.removeChild(children[i]);
|
|
1102
|
+
}
|
|
1103
|
+
// 參考節點是「第一個被保留的後綴節點」。它在 children 快照裡的 index 是
|
|
1104
|
+
// oldEnd + 1,而且因為後綴從未被 detach,那個 object 現在仍在 DOM 裡。
|
|
1105
|
+
// 後綴為空時是 null,insertBefore(frag, null) 等同 append。
|
|
1106
|
+
const refNode = children[plan.replaceSpan.oldEnd + 1] || null;
|
|
1107
|
+
contentEl.insertBefore(tpl.content, refNode); // fragment 會被排空並保序
|
|
1108
|
+
|
|
1109
|
+
// 被保留的後綴節點的 data-block-id 已經過期(id 是位置序號)。
|
|
1110
|
+
// 前綴恆為 0:id 即索引,前綴在兩份陣列裡都佔 0..k-1。
|
|
1111
|
+
if (plan.idDelta !== 0) {
|
|
1112
|
+
const all = contentEl.children;
|
|
1113
|
+
for (let i = all.length - plan.keepSuffix; i < all.length; i++) {
|
|
1114
|
+
const el = all[i];
|
|
1115
|
+
const oldId = Number(el.getAttribute('data-block-id'));
|
|
1116
|
+
el.setAttribute('data-block-id', String(oldId + plan.idDelta));
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
} catch (e) {
|
|
1120
|
+
// Roll the model back to what the (still-unmutated, or only partly
|
|
1121
|
+
// mutated) DOM last agreed with, then land the commit through the
|
|
1122
|
+
// fallback rather than losing it — applyFullRender() rebuilds .content
|
|
1123
|
+
// wholesale, so it is correct from a half-patched DOM too.
|
|
1124
|
+
//
|
|
1125
|
+
// ⚠ `window.scrollY` here does NOT violate ruling F3. F3 forbids
|
|
1126
|
+
// re-applying rerenderAll()'s STALE pre-fetch snapshot on the patch
|
|
1127
|
+
// path, because the patch path never collapses the scroll extent. This
|
|
1128
|
+
// is the CURRENT scroll position, read at this instant, handed to an
|
|
1129
|
+
// innerHTML swap that genuinely does collapse it. Different value,
|
|
1130
|
+
// different reason. Do not "correct" it to the `scrollY` parameter —
|
|
1131
|
+
// applyPatch() deliberately does not take one.
|
|
1132
|
+
blocks = prevBlocks;
|
|
1133
|
+
lastParts = prevLastParts;
|
|
1134
|
+
return applyFullRender(j, window.scrollY, declaredRange);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
if (focusWasInSpan) {
|
|
1138
|
+
// 落在被置換區間內的游標,其節點已經被移除。把焦點放到對應的新節點
|
|
1139
|
+
// (夾在新中段的範圍內),caret 置尾。
|
|
1140
|
+
//
|
|
1141
|
+
// 位置刻意在 teardown 之前,而真正的機制是 fix round 1 的 review 查出來
|
|
1142
|
+
// 的這一條(第一版註解寫的「靠收尾那段焦點檢查認養」是錯的,那段在這條
|
|
1143
|
+
// 路徑上是死碼):`surface.focus()` 會【同步】觸發 document 層那個委派的
|
|
1144
|
+
// focusin。undo() 早已 await 完 switchAwayFrom(),所以 `switching` 與
|
|
1145
|
+
// `currentBurst` 都是 null——focusin 的兩個 early-return 都不成立——而新
|
|
1146
|
+
// 節點在 insertBefore 之前就被 armEditables(tpl.content) 武裝過了,於是
|
|
1147
|
+
// startBurst() 當場就跑完。打字有人追蹤、Ctrl+S 才寫得進檔案(實測拿掉
|
|
1148
|
+
// 這一塊時 undo 之後打的字連磁碟都到不了)。收尾最後那段
|
|
1149
|
+
// `if (!currentBurst && …)` 的焦點檢查因此在這條路徑上恆為 false。
|
|
1150
|
+
//
|
|
1151
|
+
// 代價要說清楚:下面的 `burstSurvived` 是在這裡【之後】才求值的,所以在
|
|
1152
|
+
// 走到這一塊的 render 上,它量到的是「剛剛由還原開出來的那個 burst」,
|
|
1153
|
+
// 而不是它註解所寫的「patch 前就開著、而且面活過 patch」。受影響的是三個
|
|
1154
|
+
// 以它為閘門的收尾項:currentBurst 的 dispose、resetSelToolbarState()、
|
|
1155
|
+
// resetToolbarBlock()——三者都會改走 survived 分支。結果是好的(剛開的
|
|
1156
|
+
// burst 不被丟掉、startBurst() 剛掛上的 selectionchange listener 不被拆、
|
|
1157
|
+
// toolbarBlockEl 保住 focusin 剛設好的活節點),但那是巧合而不是設計,
|
|
1158
|
+
// 所以在 `burstSurvived` 那裡也留了一行對照註解。不搬 `burstSurvived`
|
|
1159
|
+
// 到還原之前,是刻意的取捨:搬了會讓剛開的 burst 先被 dispose 再由收尾
|
|
1160
|
+
// 重開一次,並把每次 undo 的工具列 degrade 原封不動地留著。
|
|
1161
|
+
// teardown 十二項彼此的相對順序沒有被動到,條件式的 activeEditor 歸零
|
|
1162
|
+
// 仍然在 DOM 手術之後求值。
|
|
1163
|
+
const newLen = plan.replaceSpan.newParts.length;
|
|
1164
|
+
if (newLen > 0) {
|
|
1165
|
+
// landing 恆落在新中段內:ci >= oldStart 保證非負,min() 夾住上界,
|
|
1166
|
+
// 而 patch 後的子節點是 prefix(keepPrefix) + 新中段(newLen) + suffix。
|
|
1167
|
+
// 零寬區間(insert,oldEnd === oldStart - 1)根本進不了 focusWasInSpan。
|
|
1168
|
+
const landing = plan.keepPrefix + Math.min(ci - plan.replaceSpan.oldStart, newLen - 1);
|
|
1169
|
+
const el = contentEl.children[landing];
|
|
1170
|
+
const surface = el ? blockContentEl(el) : null;
|
|
1171
|
+
if (surface) {
|
|
1172
|
+
surface.focus();
|
|
1173
|
+
try {
|
|
1174
|
+
const r = document.createRange();
|
|
1175
|
+
r.selectNodeContents(surface);
|
|
1176
|
+
r.collapse(false);
|
|
1177
|
+
const s = window.getSelection();
|
|
1178
|
+
s.removeAllRanges(); s.addRange(r);
|
|
1179
|
+
} catch (e) { /* best-effort,同 focusBlockAtLine 的處置 */ }
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// ── teardown ────────────────────────────────────────────────────────
|
|
1185
|
+
// Controller ruling F1: the patch path runs the SAME twelve teardown items
|
|
1186
|
+
// applyFullRender() runs, unconditionally and in the same relative order.
|
|
1187
|
+
// The rationale comments below are copied from applyFullRender() because
|
|
1188
|
+
// they document WHY each one exists; the one thing that differs is the
|
|
1189
|
+
// trigger — there the nodes were detached by the innerHTML replacement,
|
|
1190
|
+
// here they were detached by the removeChild loop above (or, for state
|
|
1191
|
+
// that points at a node in the KEPT span, they were not detached at all,
|
|
1192
|
+
// which is exactly why leaving the state standing is not automatically
|
|
1193
|
+
// safe either: `blocks` and every data-block-id under it just changed).
|
|
1194
|
+
//
|
|
1195
|
+
// Fix round 1 (review, comment-only) — the consequence a future change to
|
|
1196
|
+
// this list has to know about: the fallback's innerHTML replacement also
|
|
1197
|
+
// WIPES any uncommitted burst text, which is what keeps the DOM and
|
|
1198
|
+
// `lines[]` consistent when item 3 disposes the burst. The patch path
|
|
1199
|
+
// leaves that text sitting in a kept node while `currentBurst` has already
|
|
1200
|
+
// been disposed. Unreachable today — every call site goes through
|
|
1201
|
+
// switchAwayFrom() first, which is what applyFullRender()'s own comment
|
|
1202
|
+
// calls "technically unreachable" — but that is precisely what this
|
|
1203
|
+
// belt-and-braces teardown is insurance against, and on THIS path a future
|
|
1204
|
+
// call site that forgets switchAwayFrom() leaves text visible in the DOM,
|
|
1205
|
+
// absent from `lines[]`, with its undo history already disposed.
|
|
1206
|
+
//
|
|
1207
|
+
// Whatever editor (if any) was open a moment ago may have just been
|
|
1208
|
+
// detached by the span removal above — its own restore()/commit() never
|
|
1209
|
+
// ran, so it never got a chance to null this out itself. On the FALLBACK
|
|
1210
|
+
// path this is done unconditionally, which is what makes the undo/redo
|
|
1211
|
+
// lockout regression (see the `activeEditor` comment above) structurally
|
|
1212
|
+
// impossible even from a call site that forgets to call switchAwayFrom().
|
|
1213
|
+
//
|
|
1214
|
+
// ── Final review item 1 / 決議 3(1) 的 activeEditor 對稱項 ───────────
|
|
1215
|
+
// 決議 3(1) 的論證逐字適用在這裡,而它原本沒有被問過:fallback 之所以
|
|
1216
|
+
// 可以無條件歸零,是因為 `contentEl.innerHTML = ` 把每個節點都拔掉了
|
|
1217
|
+
// ——面也一起從螢幕上消失。patch 保留節點,而 openRawEditor() 的
|
|
1218
|
+
// `.ed-raw` 包裝是塞進**區塊自己**(`blockEl.innerHTML = '';
|
|
1219
|
+
// blockEl.appendChild(wrap)`),所以 contentEl 的子節點數與每一個
|
|
1220
|
+
// data-block-id 都沒有變、domMatchesLastRender() 照過、patch 照跑——
|
|
1221
|
+
// 落在前綴/後綴裡的 textarea 於是活過 patch,而這一行把它變成沒人
|
|
1222
|
+
// 追蹤的殭屍:switchAwayFrom() 再也看不到它,點開別處不會自動提交,
|
|
1223
|
+
// 而下一次任何 rerenderAll() 會把它連同未提交的文字一起蓋掉。
|
|
1224
|
+
//
|
|
1225
|
+
// 實測可達(scratchpad/item1-scenarioB.js,兩次 run 一致)。序列是
|
|
1226
|
+
// 「render 往返途中開一個 raw editor」,與決議 3(2) 為 currentBurst 記下
|
|
1227
|
+
// 的那條 race 同形,只是觸發點必須是**在 fetch 之前就 settle 掉
|
|
1228
|
+
// switchAwayFrom() 的**站點(undo()/redo()/changeHeadingDepth()…),
|
|
1229
|
+
// 因為 resolveBurst() 自己那條路徑把整個 render 關在 `switching` 的
|
|
1230
|
+
// single-flight 裡,反而把並行的點擊序列化掉:
|
|
1231
|
+
// openRawEditor-entry {blockId:'2', currentBurst:false, activeEditor:false}
|
|
1232
|
+
// dispatch {patch:true, keepPrefix:4, oldStart:4, oldEnd:4,
|
|
1233
|
+
// activeEditor:true, rawTextareas:1}
|
|
1234
|
+
// patch-teardown {activeEditor:true, activeEditorBlockAttached:true,
|
|
1235
|
+
// activeEditorBlockId:'2', rawTextareas:1}
|
|
1236
|
+
// 接著在存活的 textarea 裡打字、點到別的區塊、Ctrl+S:
|
|
1237
|
+
// [after clicking away] {rawTextareas:1, rawValue:'…const LOST = 2;'}
|
|
1238
|
+
// [file on disk] '# Doc\n\nCode anchor.\n\n```js\nconst x = 1;\n```\n…'
|
|
1239
|
+
// [LOST typing present in file?] NO — data loss
|
|
1240
|
+
// 螢幕上看得到,檔案裡沒有——與決議 3(1) 為 burst 記下的完全同一種缺陷。
|
|
1241
|
+
//
|
|
1242
|
+
// 判準與 burst 那項同形,而且必須同形:實測
|
|
1243
|
+
// `document.body.contains(activeEditor.blockEl)`,不得由 patchmap 的
|
|
1244
|
+
// index 算術推論。推論錯的代價是 activeEditor 指著已 detach 的節點,
|
|
1245
|
+
// 正好是上面那段「undo/redo lockout」在防的事——所以節點沒活下來時
|
|
1246
|
+
// 一律走原本的無條件歸零,lockout 仍然結構上不可能。
|
|
1247
|
+
//
|
|
1248
|
+
// 多一道 burst 不需要的閘門:burst 的 `blockId` 是唯一會 stale 的欄位,
|
|
1249
|
+
// 其餘都從新的 `blocks` 現查;openRawEditor() 的 session 則把
|
|
1250
|
+
// `blockId`/`startLine`/`endLine` 三個都閉包成常數,所以留下來之前要先
|
|
1251
|
+
// 讓它把三者從現在的 DOM 屬性與新的 `blocks` 重新推導成功
|
|
1252
|
+
// (reanchor(),見那裡的註解——推導不出來就退回歸零)。
|
|
1253
|
+
if (!(activeEditor && activeEditor.blockEl
|
|
1254
|
+
&& document.body.contains(activeEditor.blockEl)
|
|
1255
|
+
&& activeEditor.reanchor())) {
|
|
1256
|
+
activeEditor = null;
|
|
1257
|
+
}
|
|
1258
|
+
// §10-gap fix (review): same belt-and-braces reasoning for
|
|
1259
|
+
// `pristineInsert` — its window is meant to close via one of the
|
|
1260
|
+
// explicit resolution hooks (resolveBurst()/revertBurstAndEnd()/
|
|
1261
|
+
// revertTableBurstAndEnd()/cancelAndMaybeDiscard()) BEFORE any
|
|
1262
|
+
// rerenderAll() reaches here.
|
|
1263
|
+
//
|
|
1264
|
+
// Final review item 1, second half — 這一項**刻意沒有**跟著上面開例外,
|
|
1265
|
+
// 而且理由不是「到不了」:同一條 race 一樣到得了這裡。是這個狀態被
|
|
1266
|
+
// 這次 render 本身作廢了。`pristineInsert` 的整個意思是「undo stack 的
|
|
1267
|
+
// **最頂端**那一筆就是我這個插入 op」——discardPristineInsert() 走
|
|
1268
|
+
// `stack.discardTop()`(lib/editor/lineops.js),它無條件 pop 最頂端那筆
|
|
1269
|
+
// 並就地反轉,不驗證那筆是不是自己的。而任何走到 applyPatch 的 render
|
|
1270
|
+
// 都帶著一個 truthy 的 editRange,也就是**剛剛有一筆 op 被推上 stack**
|
|
1271
|
+
// (commitRangeEdit / commitBlockInsertion / undo()/redo() 的搬動)。所以
|
|
1272
|
+
// patch 之後把 `pristineInsert` 留著,等於讓之後任何一次「放棄這個空
|
|
1273
|
+
// 區塊」去 pop 並反轉**別人的**那一筆——那是比它要防的孤兒佔位區塊更
|
|
1274
|
+
// 嚴重的失敗。要讓這一項變成可以開例外,得先讓 discardTop() 認得 op
|
|
1275
|
+
// 的 identity(例如 discardPristineInsert() 記下 op 物件本身、pop 前先
|
|
1276
|
+
// 比對),在那之前無條件歸零就是正解。
|
|
1277
|
+
pristineInsert = null;
|
|
1278
|
+
// ── Task G / 決議 3:teardown 的三項例外 ─────────────────────────
|
|
1279
|
+
// 判準必須是「burst 的編輯面在 patch 之後仍然連在文件上」的**節點
|
|
1280
|
+
// identity 實測**,不得由 index 算術推論。推論錯的代價是 burst 指著已
|
|
1281
|
+
// detach 的節點——正是上面 activeEditor / toolbarBlockEl 那兩段註解在防的
|
|
1282
|
+
// 事,而且跳過 dispose() 時若節點其實已被換掉,會同時留下一疊
|
|
1283
|
+
// innerHTML 快照與一個閉包住 detached 節點的 captureFn。
|
|
1284
|
+
//
|
|
1285
|
+
// 為什麼只在 patch 路徑需要這個例外:fallback 的 `contentEl.innerHTML = `
|
|
1286
|
+
// 把每一個節點都拔掉,所以 12 項 teardown 把任何編輯面孤兒化都是安全的
|
|
1287
|
+
// (螢幕上也一併消失)。patch 保留前綴與後綴節點,所以同一套 teardown
|
|
1288
|
+
// 會把一個螢幕上還在、焦點還在的面變成沒人追蹤的殭屍。
|
|
1289
|
+
//
|
|
1290
|
+
// 三項例外各自留在原位 gate,不抄成一個 if 區塊:ruling F1 要求這 12 項
|
|
1291
|
+
// 的**相對順序**與 applyFullRender() 一致,把 resetSelToolbarState() /
|
|
1292
|
+
// resetToolbarBlock() 搬到第 3 項旁邊才是真的改了順序。條件只算一次,
|
|
1293
|
+
// 而且算在會動到 currentBurst 的那一項之前(算完才輪得到把它歸零)。
|
|
1294
|
+
// ⚠ v3.2.1 fix round 1:這個節點 identity 判準本身沒變,但它求值的時機
|
|
1295
|
+
// 現在落在上面那塊焦點還原【之後】。還原的 `surface.focus()` 在
|
|
1296
|
+
// switchAwayFrom() 已經結算完的路徑上(undo()/redo())會【同步】開出一個新
|
|
1297
|
+
// 的 burst(見那裡的註解),所以在那種 render 上這裡量到的 true 說的是
|
|
1298
|
+
// 「現在有一個 burst,而且它的面在文件上」,不是「patch 前那個 burst 活下
|
|
1299
|
+
// 來了」。下面三個以它為閘門的項因此會走 survived 分支。實測(undo 走
|
|
1300
|
+
// patch 路徑):收尾後工具列 disabled 停在 7/22,不是 resetToolbarBlock()
|
|
1301
|
+
// 跑過該有的 18/22 —— 也就是 resetToolbarBlock() 確實被跳過了,這是
|
|
1302
|
+
// burstSurvived 為 true 的直接證據。反過來,若還原當下 currentBurst 還沒被
|
|
1303
|
+
// 歸零(別條路徑),focusin 的 `if (currentBurst) return;` 會擋掉開 burst,
|
|
1304
|
+
// 這裡照舊量到「舊 burst 的面被拔掉了」=false,收尾最後那段焦點檢查才是
|
|
1305
|
+
// 接手的人。兩種情形都不需要這一項改判準。
|
|
1306
|
+
const burstSurvived = !!(currentBurst && currentBurst.editEl
|
|
1307
|
+
&& document.body.contains(currentBurst.editEl));
|
|
1308
|
+
// Task 2 (Phase 3): same belt-and-braces reasoning for the always-on
|
|
1309
|
+
// WYSIWYG burst — nulling here, not in resolveBurst(), is what keeps this
|
|
1310
|
+
// a single source of truth, mirroring activeEditor just above.
|
|
1311
|
+
// Task G: skipped when the burst's surface survived the patch — nulling it
|
|
1312
|
+
// there is what strands a live, focused, still-typeable surface with
|
|
1313
|
+
// nothing tracking it (the `input`/`focusout` handlers are both predicated
|
|
1314
|
+
// on `currentBurst.editEl === editEl`, and `focusin` only fires on
|
|
1315
|
+
// ENTERING, so focus that never left never reopens one).
|
|
1316
|
+
if (!burstSurvived && currentBurst) { currentBurst.history.dispose(); currentBurst = null; }
|
|
1317
|
+
// Task 5: the hover-edge insert bubbles and the row/column grip handles
|
|
1318
|
+
// are positioned against a specific table's geometry; a stale bubble left
|
|
1319
|
+
// visible (pointing at now-detached or re-rendered geometry) would
|
|
1320
|
+
// misbehave on the next click. hideTableGrips() clears their tracked
|
|
1321
|
+
// table/row/column references too.
|
|
1322
|
+
hideTableInsertBubbles();
|
|
1323
|
+
hideTableGrips();
|
|
1324
|
+
// Task 4 fix (review finding): the SAME reasoning applies to the
|
|
1325
|
+
// selection toolbar and its document-level selectionchange listener.
|
|
1326
|
+
// Every call site today resolves the session via switchAwayFrom() first
|
|
1327
|
+
// (making this technically unreachable), but that safety depends on every
|
|
1328
|
+
// FUTURE call site remembering to — exactly how the Task 3 listener-leak
|
|
1329
|
+
// regression happened. resetSelToolbarState() is idempotent.
|
|
1330
|
+
// Task G: travels with the `currentBurst` exception above, and must. The
|
|
1331
|
+
// `selectionchange` listener it removes is attached by startBurst() /
|
|
1332
|
+
// startTableBurst() and by NOTHING else, so on a surviving burst whose
|
|
1333
|
+
// focus never leaves there is no later focusin to re-attach it — measured,
|
|
1334
|
+
// the floating selection toolbar and the fixed bar's five inline-format
|
|
1335
|
+
// buttons (which piggyback on that same listener, and which this version's
|
|
1336
|
+
// `hasSelection` wiring also rides) stay dead until the user clicks away
|
|
1337
|
+
// and back.
|
|
1338
|
+
if (!burstSurvived) resetSelToolbarState();
|
|
1339
|
+
// Task 6: same belt-and-braces reasoning again for the edge-click menu
|
|
1340
|
+
// and any in-flight row drag.
|
|
1341
|
+
hideTableEdgeMenu();
|
|
1342
|
+
cancelTeDrag();
|
|
1343
|
+
// S4 Task 2: same belt-and-braces reasoning for an in-flight ⠿ block drag —
|
|
1344
|
+
// the gesture's ordinal refers to a block index that this patch may have
|
|
1345
|
+
// just renumbered. Nothing was mutated by the drag, so there is nothing to
|
|
1346
|
+
// revert; the state simply must not survive the render.
|
|
1347
|
+
cancelBlockDrag();
|
|
1348
|
+
// Final-review Finding 5b (Important): the ⠿ gutter menu — toggleGutterMenu()
|
|
1349
|
+
// appends the ONE shared `gutterMenu` node as a CHILD of whichever block
|
|
1350
|
+
// it's open for, so a removed block took it away without its own close
|
|
1351
|
+
// path ever running. Left uncleared, `gutterMenuBlockEl` keeps pointing at
|
|
1352
|
+
// a detached node and the NEXT toggleGutterMenu() call on that same stale
|
|
1353
|
+
// reference would treat the menu as already open instead of opening fresh.
|
|
1354
|
+
// closeGutterMenu() is idempotent.
|
|
1355
|
+
closeGutterMenu();
|
|
1356
|
+
// §10-gap fix: same belt-and-braces reasoning for the + insert menu —
|
|
1357
|
+
// it's the same "singleton node appended as a child of whichever block
|
|
1358
|
+
// it's open for" idiom as gutterMenu just above.
|
|
1359
|
+
closeInsertMenu();
|
|
1360
|
+
// v3.1.0 §4: and the SAME belt-and-braces reasoning one more time for the
|
|
1361
|
+
// toolbar's tracked block. `toolbarBlockEl` points INTO the subtree that
|
|
1362
|
+
// was just patched, and a detached node's getAttribute() still answers
|
|
1363
|
+
// perfectly happily: left standing, the bar would keep reporting the old
|
|
1364
|
+
// block's type/indent/heading depth and every button would act on a node
|
|
1365
|
+
// no click can ever reach again.
|
|
1366
|
+
// Task G: same exception, same reason — `toolbarBlockEl` is only ever
|
|
1367
|
+
// re-set on focusin / the click delegation, so clearing it under a burst
|
|
1368
|
+
// whose focus never leaves kills every block-dependent toolbar button until
|
|
1369
|
+
// the user clicks away and back (MEASURED on the smoke fixture: disabled
|
|
1370
|
+
// goes 7/22 -> 18/22, and nothing calls updateToolbar() again until the
|
|
1371
|
+
// next click). It has TWO further side effects (v3.2.1: the third was
|
|
1372
|
+
// preview mode's contenteditable re-arm, removed with the mode itself),
|
|
1373
|
+
// and both ride along safely: (1) updateToolbar() is re-derived from the
|
|
1374
|
+
// DOM on the next interaction; (2) closeToolbarMenu() — skipping it leaves
|
|
1375
|
+
// the H▾ dropdown standing across a patch, which is benign because
|
|
1376
|
+
// `toolbarMenuEl` is appended to document.body, not into a block, so the
|
|
1377
|
+
// patch never detaches it, and applyHeadingLevel() re-resolves through
|
|
1378
|
+
// liveToolbarBlock(), which self-heals a detached `toolbarBlockEl`.
|
|
1379
|
+
if (!burstSurvived) resetToolbarBlock();
|
|
1380
|
+
|
|
1381
|
+
// Task G step 2: re-derive the surviving burst's `blockId` FROM THE DOM
|
|
1382
|
+
// ATTRIBUTE — never assign it independently.
|
|
1383
|
+
//
|
|
1384
|
+
// startBurst()/startTableBurst() derive `blockId` from
|
|
1385
|
+
// `blockEl.getAttribute('data-block-id')`, so on a kept node it is the
|
|
1386
|
+
// FIELD that goes stale, not the attribute: the id rewrite inside the try
|
|
1387
|
+
// above already renumbered every kept suffix node. bystanderCarryOver()
|
|
1388
|
+
// compares `String(currentBurst.blockId)` against a node's getAttribute()
|
|
1389
|
+
// result AS STRINGS; if the field and the attribute disagree in either
|
|
1390
|
+
// direction a dirty burst stops being excluded from the replay and the
|
|
1391
|
+
// replay overwrites the user's uncommitted typing. Hence the fixed order:
|
|
1392
|
+
// the patch rewrites the attribute first, this re-derives the field from
|
|
1393
|
+
// it. `blockEl` is `editEl.closest('.ed-block')` captured at burst-start
|
|
1394
|
+
// and, because `burstSurvived` measured `editEl` as still attached, it is
|
|
1395
|
+
// the same still-attached element.
|
|
1396
|
+
if (burstSurvived) {
|
|
1397
|
+
currentBurst.blockId = Number(currentBurst.blockEl.getAttribute('data-block-id'));
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
// S3 Task 5 (§4.4's ordered three steps): armEditables() above was step 1;
|
|
1401
|
+
// this is steps 2 and 3 — rebuild the member set from the declared (or
|
|
1402
|
+
// still-standing) LINE RANGE against the freshly built `blocks`. It sits
|
|
1403
|
+
// AFTER the unconditional teardown above so nothing there can null what it
|
|
1404
|
+
// just set, and BEFORE the two `try`-swallowed rebind blocks below so a
|
|
1405
|
+
// diagram-init throw can never skip it.
|
|
1406
|
+
//
|
|
1407
|
+
// Fix round 1 (review, ruling F3 amendment): there are TWO scroll sources
|
|
1408
|
+
// applyFullRender()'s `window.scrollTo(0, scrollY)` compensates, and only
|
|
1409
|
+
// one of them is absent here. The one F3 correctly ruled out is the
|
|
1410
|
+
// innerHTML swap collapsing the scroll extent — that cannot happen on this
|
|
1411
|
+
// path, and re-applying the stale pre-fetch snapshot would fight the
|
|
1412
|
+
// browser. The other is rebuildBlockSelection()'s own tail: it ends in
|
|
1413
|
+
// `focusEl.focus()`, which scrolls the focus holder into view (the comment
|
|
1414
|
+
// above applyFullRender()'s scrollTo says exactly this). That source is
|
|
1415
|
+
// very much still live here, so a keyboard-driven patched commit taken
|
|
1416
|
+
// while the block selection is scrolled off-screen would jump the viewport
|
|
1417
|
+
// where the fallback would not.
|
|
1418
|
+
//
|
|
1419
|
+
// Compensated precisely: read the CURRENT scroll position immediately
|
|
1420
|
+
// before, restore it immediately after, and only if the call actually
|
|
1421
|
+
// moved it. Never the pre-fetch `scrollY` — applyPatch() deliberately does
|
|
1422
|
+
// not take one.
|
|
1423
|
+
const scrollBeforeSelection = window.scrollY;
|
|
1424
|
+
rebuildBlockSelection(declaredRange);
|
|
1425
|
+
if (window.scrollY !== scrollBeforeSelection) {
|
|
1426
|
+
window.scrollTo(0, scrollBeforeSelection);
|
|
1427
|
+
}
|
|
1428
|
+
// 這三個都是 document-scoped,必須在插入之後、對整份文件跑:
|
|
1429
|
+
// __md2docInitDiagrams 的 WaveDrom 分支自述「has no per-root scoping API
|
|
1430
|
+
// — ProcessAll() always scans the whole document」,實測對 detached
|
|
1431
|
+
// fragment 呼叫時 mermaid 畫得出來但 WaveDrom 完全沒動。
|
|
1432
|
+
if (window.__md2docInitDiagrams) {
|
|
1433
|
+
try {
|
|
1434
|
+
window.__md2docInitDiagrams(contentEl);
|
|
1435
|
+
} catch (e) {
|
|
1436
|
+
// Same Phase-1 known limitation as the fallback path: a newly
|
|
1437
|
+
// introduced diagram type whose library global was never loaded stays
|
|
1438
|
+
// unrendered until reload. Never let that break the rest of the editor.
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
if (window.__md2docRebindReader) {
|
|
1442
|
+
try {
|
|
1443
|
+
window.__md2docRebindReader();
|
|
1444
|
+
} catch (e) {
|
|
1445
|
+
// Never let a reader-runtime rebind failure break block selection/save/undo.
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
// Fix round 1 (review, comment-only): whenever rebuildBlockSelection()
|
|
1449
|
+
// above does NOT take its early-return path it has already called
|
|
1450
|
+
// applySelectionClasses() once, via setBlockSelection()/
|
|
1451
|
+
// clearBlockSelection() — so on those renders this is a redundant SECOND
|
|
1452
|
+
// pass, costing one extra blur/focus round trip. It is kept unconditional
|
|
1453
|
+
// anyway, because the case where it is genuinely load-bearing is exactly
|
|
1454
|
+
// the case where rebuildBlockSelection() early-returns: a plain typing
|
|
1455
|
+
// commit with no selection anywhere, where nothing else would clear the
|
|
1456
|
+
// KEPT nodes' stale `.ed-selected`/tabindex.
|
|
1457
|
+
//
|
|
1458
|
+
// applySelectionClasses() 必須跑:它是 total 的(先把 `.ed-selected` 與
|
|
1459
|
+
// tabindex 從每一個區塊清掉再重套)。innerHTML 置換時代那份「保留節點的
|
|
1460
|
+
// 殘留 class 自動消失」的免費午餐在 patch 路徑沒有了。
|
|
1461
|
+
applySelectionClasses();
|
|
1462
|
+
|
|
1463
|
+
// ── Task G step 3 / 決議 3(1):patch 收尾的焦點檢查 ────────────────
|
|
1464
|
+
// endBurstWithoutResolve() 有 11 個呼叫點,全部在 render 之前,而
|
|
1465
|
+
// ⠿/+/工具列的 mousedown 都無條件 preventDefault()——刻意的,好讓
|
|
1466
|
+
// 焦點留在 armed 面上(既有 runtime 測試斷言「⠿ 的 mousedown 不得 blur
|
|
1467
|
+
// 還開著的 dirty burst」)。今天安全只因為 innerHTML 置換把那個節點
|
|
1468
|
+
// 拔掉;patch 保留節點,就把「焦點還在、currentBurst 為 null」從邊角
|
|
1469
|
+
// 變成日常。那個狀態下:打字不被追蹤(input listener 以
|
|
1470
|
+
// currentBurst.editEl 為前提)、移開焦點什麼都不提交(focusout 同理)
|
|
1471
|
+
// ——螢幕上看得到,檔案裡沒有。
|
|
1472
|
+
//
|
|
1473
|
+
// 位置是刻意的:在 teardown 之後(這時 currentBurst 才真的歸零了),
|
|
1474
|
+
// 也在 rebuildBlockSelection() / applySelectionClasses() 之後——那兩支的
|
|
1475
|
+
// focusEl.focus() 可能把焦點搬到一個 `.ed-block` 上(那是真的移走了,
|
|
1476
|
+
// 不該重開 burst)。先開再讓它搬走只會多觸發一輪 focusout →
|
|
1477
|
+
// resolveBurst()。又因為 resetSelToolbarState() 已經跑完(!burstSurvived
|
|
1478
|
+
// 才會走到這裡),startBurst() 重新掛上的 selectionchange listener 不會被
|
|
1479
|
+
// 回頭拆掉。
|
|
1480
|
+
//
|
|
1481
|
+
// ⚠ 這一步會改動 switchAwayFrom() 的後置條件。resolveOpenSession() 解掉
|
|
1482
|
+
// currentBurst、await 完 render 之後就直接 return true,不再重檢;而工具列
|
|
1483
|
+
// 與 ⠿/+ 的 mousedown 都 preventDefault(),焦點會留在原面上。所以這裡可能
|
|
1484
|
+
// 在那個被 await 的 render 裡重開一個 burst,讓 switchAwayFrom() 帶著一個
|
|
1485
|
+
// 開著的 session 回傳 true——它自己的 doc comment 說它是「單一、完整的
|
|
1486
|
+
// make it safe to proceed 閘門」。這是刻意的取捨:那個面上的鍵入沒人追蹤才是
|
|
1487
|
+
// 資料遺失,而重開的 burst 是乾淨的(original 就是當下的 innerHTML),
|
|
1488
|
+
// 下一次 focusout 會把它零編輯解掉。已知的一個後果是本輪修掉的那個:
|
|
1489
|
+
// openRawEditor() 原本只擋 activeEditor,於是它註冊 activeEditor 之後的
|
|
1490
|
+
// ta.focus() 會把這個 bystander burst 的 focusout 變成一次 switchAwayFrom(),
|
|
1491
|
+
// 連帶把剛開的 raw editor 一起收掉——見那裡的 Task G fix round 1 註解。
|
|
1492
|
+
// 任何新的「開一個 session 然後自己搬焦點」的路徑都要先解掉 burst。
|
|
1493
|
+
//
|
|
1494
|
+
// startBurst() 吃 editEl(`.ed-wys-armed` 本人)、startTableBurst() 吃 cellEl
|
|
1495
|
+
// (`.ed-wys-cell` 本人,它自己 closest('table'))——兩者都正好是
|
|
1496
|
+
// activeElement,所以直接傳。兩者都不呼叫 focus(),不搞 scrollIntoView,
|
|
1497
|
+
// 所以這裡**不需要** rebuildBlockSelection() 那種 scroll 補償。
|
|
1498
|
+
const ae = document.activeElement;
|
|
1499
|
+
if (!currentBurst && ae && contentEl.contains(ae)) {
|
|
1500
|
+
if (ae.classList.contains('ed-wys-cell')) startTableBurst(ae);
|
|
1501
|
+
else if (ae.classList.contains('ed-wys-armed')) startBurst(ae);
|
|
1502
|
+
}
|
|
1503
|
+
// Same tail as applyFullRender(): the dirty dot and the `true` that tells
|
|
1504
|
+
// every safeRerenderAll() caller the commit landed. Omitting either is the
|
|
1505
|
+
// exact silent-failure shape ruling F4 warns about — safeRerenderAll()'s
|
|
1506
|
+
// try/catch would swallow a throw and the DOM would still look right.
|
|
1507
|
+
setDirty();
|
|
1508
|
+
return true;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
async function applyFullRender(j, scrollY, declaredRange) {
|
|
1512
|
+
blocks = j.blocks;
|
|
1513
|
+
lastParts = j.parts; // v3.2.0: 供下一次 patch 當 oldParts
|
|
1514
|
+
// v3.2.1 R2: 必須先於 innerHTML 寫入。這個寫入 detach 掉聚焦的 raw
|
|
1515
|
+
// textarea,同步 focusout 重入 switchAwayFrom() → resolveOpenSession() →
|
|
1516
|
+
// commitNow(),於是同一次編輯提交兩次(實測衍生新 block 時磁碟上文字出現
|
|
1517
|
+
// 兩次;停在同一個 block 內則第二次回 op === null 退化成取消,所以看不出來)。
|
|
867
1518
|
// Whatever editor (if any) was open a moment ago just got detached by
|
|
868
|
-
// the innerHTML replacement
|
|
1519
|
+
// the innerHTML replacement below — its own restore()/commit() never
|
|
869
1520
|
// ran, so it never got a chance to null this out itself. Do it here,
|
|
870
1521
|
// unconditionally, on every successful swap: this is what makes the
|
|
871
1522
|
// undo/redo lockout regression (see the `activeEditor` comment above)
|
|
872
1523
|
// structurally impossible even from a call site that forgets to call
|
|
873
1524
|
// switchAwayFrom() first.
|
|
874
1525
|
activeEditor = null;
|
|
1526
|
+
contentEl.innerHTML = j.parts.join('\n'); // fallback 路徑用 join 還原
|
|
1527
|
+
// Task 2 (Phase 3): re-arm every WYSIWYG-eligible paragraph/heading (and
|
|
1528
|
+
// attach a ⠿ handle to every non-table block) in the freshly-swapped
|
|
1529
|
+
// DOM — see armEditables() below. Must run before anything else touches
|
|
1530
|
+
// the fresh nodes (diagram re-init, reader rebind, focus restoration).
|
|
1531
|
+
armEditables(contentEl);
|
|
875
1532
|
// §10-gap fix (review): same belt-and-braces reasoning for
|
|
876
1533
|
// `pristineInsert` — its window is meant to close via one of the
|
|
877
1534
|
// explicit resolution hooks (resolveBurst()/revertBurstAndEnd()/
|
|
@@ -943,6 +1600,19 @@
|
|
|
943
1600
|
// it's the same "singleton node appended as a child of whichever block
|
|
944
1601
|
// it's open for" idiom as gutterMenu just above.
|
|
945
1602
|
closeInsertMenu();
|
|
1603
|
+
// v3.1.0 §4: and the SAME belt-and-braces reasoning one more time for the
|
|
1604
|
+
// toolbar's tracked block. The toolbar node itself is mounted on
|
|
1605
|
+
// document.body (see mountToolbar() below) precisely so the innerHTML
|
|
1606
|
+
// replacement above cannot destroy it — but `toolbarBlockEl` points INTO
|
|
1607
|
+
// the subtree that was just swapped, and a detached node's
|
|
1608
|
+
// getAttribute() still answers perfectly happily: left standing, the bar
|
|
1609
|
+
// would keep reporting the old block's type/indent/heading depth and
|
|
1610
|
+
// every button would act on a node no click can ever reach again. Zeroing
|
|
1611
|
+
// it re-derives the model's documented no-block state (undo/redo/outline/
|
|
1612
|
+
// preview stay live, everything else greys out) — the same contract
|
|
1613
|
+
// closeGutterMenu()/closeInsertMenu() above have, and idempotent for the
|
|
1614
|
+
// same reason.
|
|
1615
|
+
resetToolbarBlock();
|
|
946
1616
|
// S3 Task 5 (§4.4's ordered three steps): armEditables() above was step
|
|
947
1617
|
// 1; this is steps 2 and 3 — rebuild the member set from the declared (or
|
|
948
1618
|
// still-standing) LINE RANGE against the freshly built `blocks`, clear it
|
|
@@ -990,9 +1660,16 @@
|
|
|
990
1660
|
// Defensive wrapper around every rerenderAll() call site: guarantees the
|
|
991
1661
|
// caller never sees an unhandled rejection even if a future change to
|
|
992
1662
|
// rerenderAll() (or one of its callees) introduces a stray throw.
|
|
993
|
-
|
|
1663
|
+
//
|
|
1664
|
+
// Task E: `editRange` passes straight through to rerenderAll(). Callers
|
|
1665
|
+
// with a real committed op compute it there and pass it; the 3 no-op call
|
|
1666
|
+
// sites (restore()'s rangeMode exit, the per-li unsupported-format
|
|
1667
|
+
// refusal, and commitListStructure()'s no-range exit) pass nothing, which
|
|
1668
|
+
// falls back to a full re-render by contract — patch can only compare
|
|
1669
|
+
// renderer output, never DOM the user has already touched.
|
|
1670
|
+
async function safeRerenderAll(editRange) {
|
|
994
1671
|
try {
|
|
995
|
-
return await rerenderAll();
|
|
1672
|
+
return await rerenderAll(editRange);
|
|
996
1673
|
} catch (e) {
|
|
997
1674
|
showBanner('Render failed — unexpected error (' + describeFailure(e) +
|
|
998
1675
|
'). Your edit was not applied.', null, null);
|
|
@@ -1005,9 +1682,48 @@
|
|
|
1005
1682
|
ta.style.height = (ta.scrollHeight + 2) + 'px';
|
|
1006
1683
|
}
|
|
1007
1684
|
|
|
1008
|
-
|
|
1685
|
+
// v3.1.0 修正 4: `range` ({startLine, endLine}, 1-indexed inclusive) opens
|
|
1686
|
+
// the raw editor over an EXPLICIT span of source lines instead of the
|
|
1687
|
+
// block's own record. Two callers need it and one property makes both
|
|
1688
|
+
// work: a list item (whose source line carries its marker and leading
|
|
1689
|
+
// columns, and which must NOT go through the run re-serializer while a
|
|
1690
|
+
// textarea is standing where its .ed-li-text should be) and a multi-block
|
|
1691
|
+
// ⠿ selection (whose span is several blocks' lines at once). Both also
|
|
1692
|
+
// switch this editor's teardown from an innerHTML rewrite to a real
|
|
1693
|
+
// re-render — see restore() below for why that is not optional.
|
|
1694
|
+
async function openRawEditor(blockEl, range) {
|
|
1009
1695
|
if (blockEl.querySelector('.ed-raw')) return; // already editing this block
|
|
1010
|
-
|
|
1696
|
+
// Task G fix round 1 (npm test RED on f63c88a): the guard below used to
|
|
1697
|
+
// read `activeEditor` ALONE, so a WYSIWYG burst open on a DIFFERENT block
|
|
1698
|
+
// survived into this function — and then `ta.focus()` (a few lines down,
|
|
1699
|
+
// after `activeEditor` has already been registered) blurs that surface,
|
|
1700
|
+
// whose delegated focusout calls switchAwayFrom(). resolveOpenSession()
|
|
1701
|
+
// resolves BOTH kinds of session, so it resolves the burst AND then finds
|
|
1702
|
+
// the brand-new, unmodified `activeEditor` and calls cancelNow() on it —
|
|
1703
|
+
// which for a pristine insert is cancelAndMaybeDiscard() -> the freshly
|
|
1704
|
+
// inserted block is discarded and its textarea never survives the gesture.
|
|
1705
|
+
//
|
|
1706
|
+
// MEASURED (scratchpad taskG-repro-code.js, replaying
|
|
1707
|
+
// test/editor-client-runtime.test.js's Section 10「abandoned inserts
|
|
1708
|
+
// (all 5 kinds)」): + -> 程式碼 with stale focus left on an untouched
|
|
1709
|
+
// bystander paragraph produced
|
|
1710
|
+
// openRawEditor {currentBurst:true, activeEditor:false}
|
|
1711
|
+
// resolveOpenSession {currentBurst:true, activeEditor:true, pristineInsert:6}
|
|
1712
|
+
// cancelAndMaybeDiscard {blockId:6, pristineInsert:6}
|
|
1713
|
+
// and zero code blocks left in the DOM. Only 程式碼 is affected because it
|
|
1714
|
+
// is the one insert kind that registers an `activeEditor` before moving
|
|
1715
|
+
// focus; the other four just focus another armed surface.
|
|
1716
|
+
//
|
|
1717
|
+
// The burst arm was previously unreachable — every caller resolved the
|
|
1718
|
+
// session first — which is why the guard only ever named `activeEditor`.
|
|
1719
|
+
// applyPatch()'s step-3 focus check makes it reachable: it adopts focus
|
|
1720
|
+
// that a preventDefault()-ing gesture left standing on a bystander, so a
|
|
1721
|
+
// burst can now be open on a block nobody is interacting with. Resolving
|
|
1722
|
+
// it HERE (before `activeEditor` is registered, so the focusout that
|
|
1723
|
+
// `ta.focus()` fires finds nothing to resolve) is the same defense-in-
|
|
1724
|
+
// depth this guard already documents, applied to the other session kind.
|
|
1725
|
+
if ((activeEditor && activeEditor.blockEl !== blockEl) ||
|
|
1726
|
+
(currentBurst && currentBurst.blockEl !== blockEl)) {
|
|
1011
1727
|
// Click-to-switch (see `activeEditor` / switchAwayFrom() comments
|
|
1012
1728
|
// above): resolve whatever editor IS open before opening this one.
|
|
1013
1729
|
// Defense in depth — the delegated click listener (wireBlockSelection
|
|
@@ -1023,7 +1739,10 @@
|
|
|
1023
1739
|
const ok = await switchAwayFrom();
|
|
1024
1740
|
if (!ok) return; // the open editor's auto-commit failed; stay put
|
|
1025
1741
|
}
|
|
1026
|
-
const
|
|
1742
|
+
// v3.2.0 (final review item 1): `let`, not `const` — reanchor() below
|
|
1743
|
+
// re-derives all three of `blockId`/`startLine`/`endLine` when this
|
|
1744
|
+
// session survives an incremental patch. See its own comment.
|
|
1745
|
+
let blockId = Number(blockEl.getAttribute('data-block-id'));
|
|
1027
1746
|
const block = blocks.find((b) => b.id === blockId);
|
|
1028
1747
|
if (!block) return;
|
|
1029
1748
|
// Task 4 fix round 1 (Critical, second path): a block that owns no source
|
|
@@ -1034,16 +1753,45 @@
|
|
|
1034
1753
|
// INSERTS the text as a new line instead of replacing anything, leaving
|
|
1035
1754
|
// the clicked item untouched and a stray line in the file (measured:
|
|
1036
1755
|
// '# Doc\n\n- a\n\n- - b\n' + 'ZZZ' -> '# Doc\n\n- a\n\nZZZ\n- - b\n').
|
|
1037
|
-
// The only such blocks today are list items
|
|
1038
|
-
//
|
|
1039
|
-
//
|
|
1040
|
-
//
|
|
1756
|
+
// The only such blocks today are list items, and they are never armed, so
|
|
1757
|
+
// before Task 4 gave every block a ⠿ this was reached by a plain body
|
|
1758
|
+
// click and stayed unreported. (Until v3.1.0 this note also cited RULING
|
|
1759
|
+
// F-O as an independent second reason. F-O is superseded for the ⠿ menu
|
|
1760
|
+
// — see toggleGutterMenu() below — so this guard now carries the case
|
|
1761
|
+
// ALONE, and it is the one that always mattered: a block that owns no
|
|
1762
|
+
// source line has nothing to seed a textarea WITH, no matter how the
|
|
1763
|
+
// editor is torn down afterwards.) Guarded HERE rather than at the click
|
|
1764
|
+
// delegator so
|
|
1041
1765
|
// every caller — the delegated degraded-block click, openRawViaGutter(),
|
|
1042
1766
|
// the burst-degrade path — is closed by one check.
|
|
1043
1767
|
if (blockOwnsNoLine(blockEl)) { refuseStructuralListEdit(NO_SOURCE_LINE_MESSAGE); return; }
|
|
1044
1768
|
|
|
1769
|
+
// v3.1.0 修正 4: the span this editor owns. Without `range` it is exactly
|
|
1770
|
+
// the block's own record, so `source` below is byte-identical to what
|
|
1771
|
+
// extractBlockSource(lines, block) returned before — that helper IS this
|
|
1772
|
+
// slice, and every pre-v3.1.0 call site keeps its behaviour unchanged.
|
|
1773
|
+
// v3.2.0 (final review item 1): `let` for the same reason as `blockId`.
|
|
1774
|
+
let startLine = range ? range.startLine : block.startLine;
|
|
1775
|
+
let endLine = range ? range.endLine : block.endLine;
|
|
1776
|
+
// The same inverted-range refusal blockOwnsNoLine() just made for the
|
|
1777
|
+
// block, re-asked of the range actually being edited: a caller-supplied
|
|
1778
|
+
// span can be malformed even when the anchor block is fine.
|
|
1779
|
+
if (endLine < startLine) { refuseStructuralListEdit(NO_SOURCE_LINE_MESSAGE); return; }
|
|
1780
|
+
// A list item's rendered chrome (the .ed-li-marker span, the optional
|
|
1781
|
+
// .ed-li-check span, the .ed-li-text surface) is REBUILT BY THE RENDERER
|
|
1782
|
+
// from the source line's marker — it is not recoverable from a captured
|
|
1783
|
+
// innerHTML string once the item's own text has been rewritten, which is
|
|
1784
|
+
// exactly what RULING F-O objected to. So a li (and any explicit range)
|
|
1785
|
+
// commits through commitRangeEdit() over these lines rather than through
|
|
1786
|
+
// the run serializer, and tears down through a real re-render — which
|
|
1787
|
+
// removes F-O's stated premise rather than working around it. Ruling 13
|
|
1788
|
+
// (v3.1.0) supersedes F-O for THIS surface, the ⠿ menu's MD 原始碼, and
|
|
1789
|
+
// for that surface only: the burst-degrade refusal in resolveBurst() and
|
|
1790
|
+
// the blockOwnsNoLine() guard just above both still stand, for reasons
|
|
1791
|
+
// that were never F-O's.
|
|
1792
|
+
const rangeMode = !!range || blockEl.getAttribute('data-block-type') === 'li';
|
|
1045
1793
|
const original = blockEl.innerHTML;
|
|
1046
|
-
const source =
|
|
1794
|
+
const source = lines.slice(startLine - 1, endLine).join('\n');
|
|
1047
1795
|
|
|
1048
1796
|
const wrap = document.createElement('div');
|
|
1049
1797
|
wrap.className = 'ed-editing';
|
|
@@ -1065,6 +1813,7 @@
|
|
|
1065
1813
|
hasChanges: () => ta.value !== source,
|
|
1066
1814
|
commitNow: commit,
|
|
1067
1815
|
cancelNow: cancelAndMaybeDiscard,
|
|
1816
|
+
reanchor,
|
|
1068
1817
|
};
|
|
1069
1818
|
|
|
1070
1819
|
const controls = document.createElement('div');
|
|
@@ -1089,13 +1838,30 @@
|
|
|
1089
1838
|
ta.focus();
|
|
1090
1839
|
ta.setSelectionRange(ta.value.length, ta.value.length);
|
|
1091
1840
|
|
|
1092
|
-
function restore() {
|
|
1093
|
-
|
|
1841
|
+
async function restore() {
|
|
1842
|
+
// v3.1.0 修正 4: `rangeMode` cannot restore by rewriting innerHTML —
|
|
1843
|
+
// for a li that string would have to carry the marker/check/text chrome
|
|
1844
|
+
// back, and for a multi-block span there is more than one block's DOM
|
|
1845
|
+
// to put back in the first place. A re-render rebuilds all of it from
|
|
1846
|
+
// `lines`, which this path never touched (restore() NEVER commits), so
|
|
1847
|
+
// the document is byte-identical either way — this is a different way
|
|
1848
|
+
// of repainting the same bytes, not a different edit.
|
|
1849
|
+
if (rangeMode) {
|
|
1850
|
+
if (activeEditor && activeEditor.blockEl === blockEl) activeEditor = null;
|
|
1851
|
+
await safeRerenderAll();
|
|
1852
|
+
return;
|
|
1853
|
+
}
|
|
1854
|
+
// v3.2.1 R1: 清 activeEditor 必須先於這個 innerHTML 寫入。寫入會 detach
|
|
1855
|
+
// 聚焦的 textarea,Chromium 同步派發 focusout,而 focusout handler 只要
|
|
1856
|
+
// activeEditor 非 null 就會走 switchAwayFrom() → resolveOpenSession() →
|
|
1857
|
+
// commitNow(),把使用者按 Escape 要丟棄的文字提交進檔案(實測落到磁碟)。
|
|
1858
|
+
// 同一函式的 rangeMode 分支早就是這個順序,理由相同。
|
|
1094
1859
|
// Only clear `activeEditor` if it's still THIS block's entry — it may
|
|
1095
1860
|
// already have been cleared out from under us (e.g. by rerenderAll()'s
|
|
1096
1861
|
// own defensive reset on a successful swap elsewhere), and this must
|
|
1097
1862
|
// never stomp some OTHER block's activeEditor set after this one.
|
|
1098
1863
|
if (activeEditor && activeEditor.blockEl === blockEl) activeEditor = null;
|
|
1864
|
+
blockEl.innerHTML = original;
|
|
1099
1865
|
// No re-wiring needed here (unlike the old per-block gutter): block
|
|
1100
1866
|
// selection / the edit bar are handled by ONE delegated `document`
|
|
1101
1867
|
// click listener (see wireBlockSelection() below), so this
|
|
@@ -1113,11 +1879,41 @@
|
|
|
1113
1879
|
async function cancelAndMaybeDiscard() {
|
|
1114
1880
|
const wasPristineForThisBlock = !!(pristineInsert && pristineInsert.blockId === blockId);
|
|
1115
1881
|
if (wasPristineForThisBlock) pristineInsert = null;
|
|
1116
|
-
restore();
|
|
1882
|
+
await restore();
|
|
1117
1883
|
if (wasPristineForThisBlock) return await discardPristineInsert();
|
|
1118
1884
|
return true;
|
|
1119
1885
|
}
|
|
1120
1886
|
|
|
1887
|
+
// v3.2.0 final review item 1 — 決議 3(1) 的 activeEditor 對稱項。
|
|
1888
|
+
//
|
|
1889
|
+
// 這個 session 的 `blockId` / `startLine` / `endLine` 是開啟當下從**那一
|
|
1890
|
+
// 份** `blocks` 抓下來的;fallback 路徑上那從來不是問題,因為
|
|
1891
|
+
// `contentEl.innerHTML = ` 會把這個 textarea 一起拔掉。patch 路徑保留
|
|
1892
|
+
// 前綴/後綴節點,所以它可以活過一次 render,而 render 之後 `blocks`
|
|
1893
|
+
// 已整份換掉、被保留後綴節點的 data-block-id 也被重寫過。
|
|
1894
|
+
//
|
|
1895
|
+
// applyPatch() 的 teardown 在實測 `document.body.contains(blockEl)` 成立
|
|
1896
|
+
// 之後呼叫這支,把三個欄位從**現在**的 DOM 屬性與新的 `blocks` 重新
|
|
1897
|
+
// 推導。回傳 false 代表推導不出來,呼叫端必須退回原本的無條件歸零:
|
|
1898
|
+
// - `range`:呼叫端指定的跨區塊 span(⠿ 選單的「MD 原始碼」批次路徑)
|
|
1899
|
+
// 在新的 `blocks` 裡沒有單一對應記錄,無從重新推導。
|
|
1900
|
+
// - 區塊在新的 `blocks` 裡不存在(防禦;節點還在就不該發生)。
|
|
1901
|
+
// - 重新切出來的來源**不是** byte-identical:那表示這個區塊底下的行
|
|
1902
|
+
// 真的變了,`ta.value` 與 `source` 都已經不是檔案裡的東西,沿用這個
|
|
1903
|
+
// session 會把過期的文字提交回去。patch 只保留 parts 相同的節點,所以
|
|
1904
|
+
// 正常情況下這個等式成立;不成立時退回歸零是保守的正解。
|
|
1905
|
+
function reanchor() {
|
|
1906
|
+
if (range) return false;
|
|
1907
|
+
const id = Number(blockEl.getAttribute('data-block-id'));
|
|
1908
|
+
const b = blocks.find((x) => x.id === id);
|
|
1909
|
+
if (!b || b.endLine < b.startLine) return false;
|
|
1910
|
+
if (lines.slice(b.startLine - 1, b.endLine).join('\n') !== source) return false;
|
|
1911
|
+
blockId = id;
|
|
1912
|
+
startLine = b.startLine;
|
|
1913
|
+
endLine = b.endLine;
|
|
1914
|
+
return true;
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1121
1917
|
// Returns true when the editor is resolved (committed, or a no-op
|
|
1122
1918
|
// commit that fell back to a cancel) — safe for a caller (switchAwayFrom
|
|
1123
1919
|
// included) to proceed. Returns false only when the render actually
|
|
@@ -1127,7 +1923,13 @@
|
|
|
1127
1923
|
// network hiccup must never silently discard what they typed). The
|
|
1128
1924
|
// failure banner is already shown by safeRerenderAll()/rerenderAll().
|
|
1129
1925
|
async function commit() {
|
|
1130
|
-
|
|
1926
|
+
// v3.1.0 修正 4: an explicit range commits over ITS OWN span. commitEdit()
|
|
1927
|
+
// is the by-id wrapper over exactly this call (it looks the block up and
|
|
1928
|
+
// delegates), so the two branches are the same pipeline — replaceLines()
|
|
1929
|
+
// + one undo op — differing only in which lines they name.
|
|
1930
|
+
const result = rangeMode
|
|
1931
|
+
? commitRangeEdit({ lines, blocks, stack }, startLine, endLine, ta.value)
|
|
1932
|
+
: commitEdit({ lines, blocks, stack }, blockId, ta.value);
|
|
1131
1933
|
if (result.op === null) {
|
|
1132
1934
|
// §10-gap fix (review): an explicit commit (Ctrl+Enter/✓) whose
|
|
1133
1935
|
// text happens to be byte-identical to `source` is still an
|
|
@@ -1137,7 +1939,12 @@
|
|
|
1137
1939
|
}
|
|
1138
1940
|
const prevLines = lines;
|
|
1139
1941
|
lines = result.lines;
|
|
1140
|
-
const
|
|
1942
|
+
const editRange = result.op
|
|
1943
|
+
? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
|
|
1944
|
+
endLine: Math.max(result.op.startLine - 1, result.op.endLine),
|
|
1945
|
+
delta: result.op.after.length - result.op.before.length }
|
|
1946
|
+
: undefined;
|
|
1947
|
+
const ok = await safeRerenderAll(editRange);
|
|
1141
1948
|
if (!ok) {
|
|
1142
1949
|
// Roll back the optimistic edit: pop the op safeRerenderAll's
|
|
1143
1950
|
// failure means was never actually rendered, so `lines` stays
|
|
@@ -1147,9 +1954,48 @@
|
|
|
1147
1954
|
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
1148
1955
|
return false;
|
|
1149
1956
|
}
|
|
1150
|
-
// Success
|
|
1151
|
-
// (this block included) and nulled `activeEditor`
|
|
1152
|
-
// left to do here.
|
|
1957
|
+
// Success. On the FALLBACK path rerenderAll() replaced the whole
|
|
1958
|
+
// .content subtree (this block included) and nulled `activeEditor`
|
|
1959
|
+
// itself, so there was nothing left to do here.
|
|
1960
|
+
//
|
|
1961
|
+
// v3.2.0 final review item 1: that is no longer unconditional. The
|
|
1962
|
+
// patch path only detaches this block when it lands inside
|
|
1963
|
+
// `replaceSpan` — and a commit very often leaves it OUTSIDE: whenever
|
|
1964
|
+
// the edit's visible effect is a NEW block AFTER this one (a line
|
|
1965
|
+
// appended below a fence, a paragraph typed under a paragraph) this
|
|
1966
|
+
// block's own part is byte-identical before and after, so patchmap
|
|
1967
|
+
// keeps it in `keepPrefix` and the textarea stays on screen, on top of
|
|
1968
|
+
// content that was already repainted around it.
|
|
1969
|
+
//
|
|
1970
|
+
// MEASURED at HEAD, before this repair (scratchpad/item1-zombie.js
|
|
1971
|
+
// --head): Ctrl+Enter on a code fence with one line appended commits
|
|
1972
|
+
// correctly to the file but leaves `rawTextareas: 1` standing forever
|
|
1973
|
+
// — unclickable, because both the delegated click handler and
|
|
1974
|
+
// openRawEditor() early-return on a block that already has a `.ed-raw`.
|
|
1975
|
+
// With the `activeEditor` exception above it is worse than cosmetic:
|
|
1976
|
+
// the session stays live with its ORIGINAL `source`, so the next
|
|
1977
|
+
// switchAwayFrom() sees hasChanges() and commits the same text a
|
|
1978
|
+
// second time (measured: 'const LOST = 2;' twice in the file).
|
|
1979
|
+
//
|
|
1980
|
+
// A kept node's part is byte-identical by construction — that is the
|
|
1981
|
+
// only reason patchmap kept it — so `original` (this block's innerHTML
|
|
1982
|
+
// captured at open, i.e. post-armEditables) is exactly the content to
|
|
1983
|
+
// put back. Same one-line repaint the non-range restore() below uses,
|
|
1984
|
+
// for the same reason.
|
|
1985
|
+
//
|
|
1986
|
+
// ⚠ 順序不可對調,而且不是防禦性的:`blockEl.innerHTML =` 會把還握著
|
|
1987
|
+
// 焦點的 textarea 就地 detach,Chromium 在那當下**同步**送出一次
|
|
1988
|
+
// focusout,委派的 focusout handler 走 switchAwayFrom() →
|
|
1989
|
+
// resolveOpenSession() → activeEditor.commitNow()。先重繪再歸零時,
|
|
1990
|
+
// 那一發看到的還是這個 session、hasChanges() 仍為真,於是把同一段文字
|
|
1991
|
+
// 再提交一次(實測:'ZZZ tail.' 在檔案裡出現兩次,stack 是
|
|
1992
|
+
// commit → focusout → switchAwayFrom → resolveOpenSession → commitNow)。
|
|
1993
|
+
// 先歸零就沒有東西可以被重新解析。rangeMode 的 restore() 早就是這個
|
|
1994
|
+
// 順序,理由相同。
|
|
1995
|
+
if (activeEditor && activeEditor.blockEl === blockEl) activeEditor = null;
|
|
1996
|
+
if (document.body.contains(blockEl) && blockEl.querySelector('.ed-raw')) {
|
|
1997
|
+
blockEl.innerHTML = original;
|
|
1998
|
+
}
|
|
1153
1999
|
return true;
|
|
1154
2000
|
}
|
|
1155
2001
|
|
|
@@ -1822,7 +2668,51 @@
|
|
|
1822
2668
|
// switchAwayFrom() first resolves any editor that's currently open on this
|
|
1823
2669
|
// (or another) block, same precondition as undo()/redo() below, so this
|
|
1824
2670
|
// never races a concurrent commit or operates on stale `lines`/`blocks`.
|
|
2671
|
+
//
|
|
2672
|
+
// v3.2.1 final wave: THIN WRAPPER + `finally`, the same shape the other four
|
|
2673
|
+
// sinks use, because this function's own early exits were live holes in
|
|
2674
|
+
// exactly the promise this version's CHANGELOG makes. MEASURED on ddac6ce
|
|
2675
|
+
// with real keystrokes, three runs each: typing in `# Title` then Shift+Tab
|
|
2676
|
+
// went H1.ed-wys-armed / 15 enabled → BODY / 4 enabled, and `###### Title`
|
|
2677
|
+
// then Tab did the same; `## Title` + Tab (the unclamped control) stayed
|
|
2678
|
+
// H3.ed-wys-armed / 15. The clamp is reachable from an ordinary gesture:
|
|
2679
|
+
// handleBurstKeydown()'s `changeHeadingDepth(currentBurst.blockEl,
|
|
2680
|
+
// e.shiftKey ? -1 : 1)` passes a raw ±1 with no `delta === 0` filter (unlike
|
|
2681
|
+
// applyHeadingLevel(), which computes a delta and can hand over 0), so Tab
|
|
2682
|
+
// at either end of H1..H6 lands on `newDepth === curDepth` — an early return
|
|
2683
|
+
// that sits BELOW the switchAwayFrom() that already committed and re-rendered
|
|
2684
|
+
// and ABOVE the restore at the tail.
|
|
2685
|
+
//
|
|
2686
|
+
// The body answers a line on every exit it can name one for:
|
|
2687
|
+
// * success result.op.startLine (the commit's own first
|
|
2688
|
+
// written line — see the note at the tail)
|
|
2689
|
+
// * newDepth === curDepth block.startLine
|
|
2690
|
+
// * result.op === null block.startLine
|
|
2691
|
+
// `block` is looked up out of `blocks` AFTER switchAwayFrom(), so its
|
|
2692
|
+
// startLine is already a post-render coordinate. INSTRUMENTED at that exit
|
|
2693
|
+
// rather than reasoned: on `# Title` typed into and then Shift+Tab-ed, the
|
|
2694
|
+
// clamped return printed
|
|
2695
|
+
// {sameBlocksArray:false, sameNode:false, blockId:0, startLine:1,
|
|
2696
|
+
// resolvesToId:0, resolvedText:'TitleX'}
|
|
2697
|
+
// — switchAwayFrom() had replaced both the `blocks` array and the heading's
|
|
2698
|
+
// DOM node, and `block.startLine` still resolved to the LIVE block carrying
|
|
2699
|
+
// the just-committed text, which is exactly what focusBlockAtLine() needs.
|
|
2700
|
+
//
|
|
2701
|
+
// The two re-query failures above it (`blockEl` gone, `block` not in
|
|
2702
|
+
// `blocks`) deliberately answer nothing: there is no block left to aim at,
|
|
2703
|
+
// and guessing a line is worse than restoring no caret.
|
|
1825
2704
|
async function changeHeadingDepth(blockEl, delta) {
|
|
2705
|
+
let focusLine = null;
|
|
2706
|
+
const anchor = {};
|
|
2707
|
+
try {
|
|
2708
|
+
const out = await changeHeadingDepthBody(blockEl, delta, anchor);
|
|
2709
|
+
if (out != null) focusLine = out;
|
|
2710
|
+
} finally {
|
|
2711
|
+
restoreAfterStructuralOp(focusLine, anchor.line);
|
|
2712
|
+
}
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
async function changeHeadingDepthBody(blockEl, delta, anchor) {
|
|
1826
2716
|
if (!blockEl) return;
|
|
1827
2717
|
const blockId = Number(blockEl.getAttribute('data-block-id'));
|
|
1828
2718
|
// Commits (never discards) whatever burst/editor is open first — same
|
|
@@ -1855,6 +2745,9 @@
|
|
|
1855
2745
|
}
|
|
1856
2746
|
const block = blocks.find((b) => b.id === blockId);
|
|
1857
2747
|
if (!block) return;
|
|
2748
|
+
// Post-switchAwayFrom(), post-render: the landing for every exit below
|
|
2749
|
+
// that writes nothing. See restoreAfterStructuralOp().
|
|
2750
|
+
if (anchor) anchor.line = block.startLine;
|
|
1858
2751
|
const curLine = lines[block.startLine - 1];
|
|
1859
2752
|
const curDepth = headingDepthOf(curLine);
|
|
1860
2753
|
const newDepth = Math.max(1, Math.min(6, curDepth + delta));
|
|
@@ -1864,10 +2757,37 @@
|
|
|
1864
2757
|
if (result.op === null) return;
|
|
1865
2758
|
const prevLines = lines;
|
|
1866
2759
|
lines = result.lines;
|
|
1867
|
-
const
|
|
2760
|
+
const editRange = result.op
|
|
2761
|
+
? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
|
|
2762
|
+
endLine: Math.max(result.op.startLine - 1, result.op.endLine),
|
|
2763
|
+
delta: result.op.after.length - result.op.before.length }
|
|
2764
|
+
: undefined;
|
|
2765
|
+
const okRender = await safeRerenderAll(editRange);
|
|
1868
2766
|
if (!okRender) {
|
|
1869
2767
|
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
1870
2768
|
}
|
|
2769
|
+
// v3.2.1 (controller ruling T5-1): the OTHER sink. H▾ on a block that is
|
|
2770
|
+
// ALREADY a heading routes HERE, not to convertBlockViaMenu() —
|
|
2771
|
+
// applyHeadingLevel() takes that road only for a NON-heading — so without
|
|
2772
|
+
// this, H2 → H4 still ends the way every conversion used to: the render's
|
|
2773
|
+
// teardown ran resetToolbarBlock(), activeElement is BODY and the bar is
|
|
2774
|
+
// down to 4 enabled buttons, and a second operation needs a fresh click.
|
|
2775
|
+
//
|
|
2776
|
+
// Same predicate and the same measurement as convertBlockViaMenu()'s exit
|
|
2777
|
+
// — the wrapper's restoreAfterStructuralOp() is now literally the same
|
|
2778
|
+
// function, so see its note for why it is `blockSelection` and not operand
|
|
2779
|
+
// cardinality, and why forcing a caret while a selection stands
|
|
2780
|
+
// manufactures a broken state rather than fixing one.
|
|
2781
|
+
//
|
|
2782
|
+
// The line is `result.op.startLine`, the commit's OWN first written line:
|
|
2783
|
+
// commitEdit() resolves the block and hands commitRangeEdit() that
|
|
2784
|
+
// startLine, which is where replaceLines() writes `after`. This function
|
|
2785
|
+
// declares no collapse of its own (it is not a set operation), so the
|
|
2786
|
+
// commit result is the only post-commit value it holds — and it is a
|
|
2787
|
+
// RESULT, not a pre-commit `block.startLine` capture. An insertion above
|
|
2788
|
+
// this block cannot have happened between the two, because the commit IS
|
|
2789
|
+
// the only thing that ran.
|
|
2790
|
+
return result.op.startLine;
|
|
1871
2791
|
}
|
|
1872
2792
|
|
|
1873
2793
|
// ── Task 2 (Phase 3): always-on WYSIWYG editing + burst undo ───────────
|
|
@@ -2378,37 +3298,42 @@
|
|
|
2378
3298
|
gutterMenuConvert.hidden = convertWithheldFor(blockType);
|
|
2379
3299
|
gutterMenuDuplicate.hidden = false;
|
|
2380
3300
|
gutterMenuDelete.hidden = false;
|
|
2381
|
-
//
|
|
2382
|
-
//
|
|
2383
|
-
//
|
|
2384
|
-
// — a textarea inside it is content the serializer cannot represent, and
|
|
2385
|
-
// restore() would have to rebuild the marker/check/text chrome from a
|
|
2386
|
-
// string. Every other block type keeps it: the menu is a SINGLETON moved
|
|
2387
|
-
// between blocks, so this must be reset on every open, not set once.
|
|
3301
|
+
// 'MD 原始碼' is offered for EVERY block type, list items included, and
|
|
3302
|
+
// for a multi-block selection. The menu is a SINGLETON moved between
|
|
3303
|
+
// blocks, so this is still written on every open rather than set once.
|
|
2388
3304
|
// (test/editor-reader-rebind.test.js drives raw-edit through this button
|
|
2389
3305
|
// by its exact text on a paragraph.)
|
|
2390
3306
|
//
|
|
2391
|
-
//
|
|
2392
|
-
//
|
|
2393
|
-
//
|
|
2394
|
-
//
|
|
2395
|
-
//
|
|
3307
|
+
// ⚠ THIS REVERSES RULING F-O, deliberately and at spec level (v3.1.0
|
|
3308
|
+
// Ruling 13), for this surface and this surface only. F-O read: 'MD
|
|
3309
|
+
// 原始碼' is hidden for a list item PERMANENTLY, because openRawEditor()
|
|
3310
|
+
// replaces the block's innerHTML with a <textarea> — and a li is one line
|
|
3311
|
+
// of a run serialized as a whole, so a textarea inside it is content the
|
|
3312
|
+
// serializer cannot represent, and restore() would have to rebuild the
|
|
3313
|
+
// marker/check/text chrome out of a string. 修正 4 removes that premise
|
|
3314
|
+
// instead of arguing with it: a li now raw-edits an explicit LINE RANGE,
|
|
3315
|
+
// commits it through commitRangeEdit() rather than the run
|
|
3316
|
+
// re-serializer, and RESTORES BY RE-RENDERING rather than by rewriting
|
|
3317
|
+
// innerHTML, so no serializer ever sees the textarea and no chrome is
|
|
3318
|
+
// ever rebuilt by hand.
|
|
2396
3319
|
//
|
|
2397
|
-
//
|
|
2398
|
-
//
|
|
2399
|
-
//
|
|
2400
|
-
// and
|
|
2401
|
-
//
|
|
2402
|
-
//
|
|
2403
|
-
//
|
|
2404
|
-
//
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
3320
|
+
// F-O's OTHER call sites are NOT in scope and still refuse, each for a
|
|
3321
|
+
// reason that was never F-O's: openRawEditor()'s blockOwnsNoLine() guard
|
|
3322
|
+
// (a block with an inverted range has no line to seed a textarea with)
|
|
3323
|
+
// and resolveBurst()'s unsupported-inline-content path (that refusal is
|
|
3324
|
+
// about the CONTENT, not the container).
|
|
3325
|
+
//
|
|
3326
|
+
// §3.7's other sentence about this item — 「多選時不顯示 `MD 原始碼`」 —
|
|
3327
|
+
// is also lifted, and for the same kind of reason: it existed because
|
|
3328
|
+
// openRawViaGutter() rewrote ONE block's source lines, so over a set of N
|
|
3329
|
+
// it silently answered for the grip's block and ignored the rest. It now
|
|
3330
|
+
// opens on the WHOLE span's line range (§3.3 membership, asked through
|
|
3331
|
+
// resolveGutterOperands() so the contiguity and no-source-line gates come
|
|
3332
|
+
// with it), so the item does what it appears to do.
|
|
3333
|
+
// The membership question itself moved WITH the decision it feeds, into
|
|
3334
|
+
// openRawViaGutter() (it now chooses the RANGE instead of the
|
|
3335
|
+
// visibility), so it is asked exactly once, in the place that acts on it.
|
|
3336
|
+
gutterMenuMd.hidden = false;
|
|
2412
3337
|
blockEl.appendChild(gutterMenu);
|
|
2413
3338
|
}
|
|
2414
3339
|
|
|
@@ -2522,6 +3447,13 @@
|
|
|
2522
3447
|
list: ['-'],
|
|
2523
3448
|
table: ['| A | B |', '|---|---|', '| | |'],
|
|
2524
3449
|
code: ['```', '', '```'],
|
|
3450
|
+
// v3.1.0 §4: the toolbar's ― button. '---' is safe as a thematic break
|
|
3451
|
+
// here specifically because commitBlockInsertion() ALWAYS writes a
|
|
3452
|
+
// leading blank line (see its own comment) — a '---' directly under a
|
|
3453
|
+
// non-blank line would instead lex as a setext H2 and silently promote
|
|
3454
|
+
// whatever preceded it. Nothing else in this file may reuse this
|
|
3455
|
+
// skeleton through a path that does not guarantee that blank.
|
|
3456
|
+
line: ['---'],
|
|
2525
3457
|
};
|
|
2526
3458
|
|
|
2527
3459
|
// Selects the entirety of `el`'s content (used right after focusing a
|
|
@@ -2567,7 +3499,28 @@
|
|
|
2567
3499
|
const prevLines = lines;
|
|
2568
3500
|
lines = discarded.lines;
|
|
2569
3501
|
setDirty();
|
|
2570
|
-
|
|
3502
|
+
// v3.2.0: UndoStack.undo()/discardTop() reverse the op when applying it but
|
|
3503
|
+
// return the unmodified FORWARD op, so the range actually rewritten is
|
|
3504
|
+
// [op.startLine, op.startLine + op.after.length - 1] and the delta is the
|
|
3505
|
+
// mirror of the forward one. Using op.endLine here would name the opposite
|
|
3506
|
+
// direction — the span the op wrote on the way FORWARD, not the one this
|
|
3507
|
+
// reversal just rewrote.
|
|
3508
|
+
//
|
|
3509
|
+
// Final review item 4 — what naming it wrongly would cost today: nothing.
|
|
3510
|
+
// Per 決議 4(b) `editRange` is ADVISORY. patchmap() destructures
|
|
3511
|
+
// oldBlocks/newBlocks/oldParts/newParts and never reads it; the kept
|
|
3512
|
+
// prefix/suffix is the longest common run of the two PART arrays, and
|
|
3513
|
+
// seeding it from an op's line range only ever made the answer worse
|
|
3514
|
+
// (measured in 決議 4(b): seeded kept 0 of 5, pure-parts kept 4 of 5).
|
|
3515
|
+
// Its one client-side use is the truthiness gate in applyRenderResult().
|
|
3516
|
+
// So the direction changes no plan — this is simply the correct value for
|
|
3517
|
+
// whenever something does start reading it.
|
|
3518
|
+
const editRange = discarded.op
|
|
3519
|
+
? { startLine: discarded.op.startLine,
|
|
3520
|
+
endLine: discarded.op.startLine + discarded.op.after.length - 1,
|
|
3521
|
+
delta: discarded.op.before.length - discarded.op.after.length }
|
|
3522
|
+
: undefined;
|
|
3523
|
+
const okRender = await safeRerenderAll(editRange);
|
|
2571
3524
|
if (!okRender) {
|
|
2572
3525
|
stack.push(discarded.op); // put it back — see discardTop()'s own comment
|
|
2573
3526
|
lines = prevLines;
|
|
@@ -2620,7 +3573,16 @@
|
|
|
2620
3573
|
// dirty burst, swapping the whole `.content` subtree and detaching
|
|
2621
3574
|
// `blockEl` along with it — same "ensureTableBurstOpen()'s Finding 6"
|
|
2622
3575
|
// recovery idiom used throughout this file), THEN acts.
|
|
2623
|
-
|
|
3576
|
+
// v3.1.0 §4 / 追加 2 / 追加 3: `opts.lines` supplies EXPLICIT source lines
|
|
3577
|
+
// instead of one of the five placeholder skeletons — the image-drop path
|
|
3578
|
+
// (``) and the paste path (turndown's markdown) both need to
|
|
3579
|
+
// land real content, and both must land it as SOURCE so it goes through
|
|
3580
|
+
// commitBlockInsertion()/replaceLines() like every other edit rather than
|
|
3581
|
+
// being spliced into the DOM behind serializeBlocks()'s back. Everything
|
|
3582
|
+
// else about the insert — the run-wide gate, the subtree anchor, the
|
|
3583
|
+
// rollback — is shared, which is the whole reason this is one extra
|
|
3584
|
+
// parameter and not a second insertion path.
|
|
3585
|
+
async function insertBlockBelow(blockEl, kind, opts) {
|
|
2624
3586
|
if (!blockEl) return;
|
|
2625
3587
|
// T7: captured BEFORE switchAwayFrom(), because that is what can renumber
|
|
2626
3588
|
// the ids — see captureBlockIdentity()'s comment.
|
|
@@ -2659,7 +3621,8 @@
|
|
|
2659
3621
|
refuseStructuralListEdit(NO_SOURCE_LINE_INSERT_MESSAGE);
|
|
2660
3622
|
return;
|
|
2661
3623
|
}
|
|
2662
|
-
const
|
|
3624
|
+
const customLines = opts && Array.isArray(opts.lines) ? opts.lines : null;
|
|
3625
|
+
const newLines = customLines || BLOCK_SKELETONS[kind];
|
|
2663
3626
|
if (!newLines) return;
|
|
2664
3627
|
|
|
2665
3628
|
// ── S2 Task 7: a LIST ITEM anchor (§6's S1 note item 3) ────────────────
|
|
@@ -2730,7 +3693,12 @@
|
|
|
2730
3693
|
const result = commitBlockInsertion({ lines, blocks, stack }, liveBlockId, newLines);
|
|
2731
3694
|
const prevLines = lines;
|
|
2732
3695
|
lines = result.lines;
|
|
2733
|
-
const
|
|
3696
|
+
const editRange = result.op
|
|
3697
|
+
? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
|
|
3698
|
+
endLine: Math.max(result.op.startLine - 1, result.op.endLine),
|
|
3699
|
+
delta: result.op.after.length - result.op.before.length }
|
|
3700
|
+
: undefined;
|
|
3701
|
+
const okRender = await safeRerenderAll(editRange);
|
|
2734
3702
|
if (!okRender) {
|
|
2735
3703
|
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
2736
3704
|
return;
|
|
@@ -2739,9 +3707,47 @@
|
|
|
2739
3707
|
// see `pristineInsert`'s own comment for the full contract. `blocks`
|
|
2740
3708
|
// was just reassigned by the successful rerenderAll() above, so this
|
|
2741
3709
|
// is the server-authoritative id for the block at `newStartLine`.
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
3710
|
+
//
|
|
3711
|
+
// v3.1.0: a custom-lines insert is NEITHER pristine NOR focus-and-select.
|
|
3712
|
+
// `pristineInsert` means "a placeholder the user has not touched yet, so
|
|
3713
|
+
// abandoning it removes it again" — applied to a dropped image or a
|
|
3714
|
+
// pasted paragraph it would silently delete real content the moment the
|
|
3715
|
+
// user clicked elsewhere without typing. focusAndSelectAll() is wrong for
|
|
3716
|
+
// the same reason: it selects the block's whole content so the first
|
|
3717
|
+
// keystroke replaces it, which is what a ZWSP placeholder wants and what
|
|
3718
|
+
// a just-pasted paragraph very much does not.
|
|
3719
|
+
//
|
|
3720
|
+
// 'line' is excluded for the same reason from the other direction: both
|
|
3721
|
+
// are PLACEHOLDER semantics, and an <hr> has no placeholder. There is
|
|
3722
|
+
// nothing for focusAndSelectAll() to select (the block's content element
|
|
3723
|
+
// is the rule itself), and "abandoning it un-typed removes it again"
|
|
3724
|
+
// describes a skeleton waiting to be filled in — a thematic break is
|
|
3725
|
+
// finished the moment it exists.
|
|
3726
|
+
if (!customLines && kind !== 'line') {
|
|
3727
|
+
const target = blocks.find((b) => b.startLine === result.newStartLine);
|
|
3728
|
+
if (target) pristineInsert = { blockId: target.id };
|
|
3729
|
+
await focusInsertedBlock(result.newStartLine, kind);
|
|
3730
|
+
} else if (kind === 'line') {
|
|
3731
|
+
// v3.2.1: <hr> 沒有 placeholder,所以不 focusInsertedBlock()(那是上面
|
|
3732
|
+
// 那個 `kind !== 'line'` 既有的刻意 opt-out)。這一支只做工具列復原:把
|
|
3733
|
+
// toolbarBlockEl 指向新插入的 hr block,讓 bar 不要停在 render teardown
|
|
3734
|
+
// 留下的 4 顆。
|
|
3735
|
+
//
|
|
3736
|
+
// ⚠ 明說量測到的事,不要說希望發生的事:**caret 沒有被還原**。實測
|
|
3737
|
+
// (alpha / bravo / charlie,點進 bravo 再按 分隔線)這一支停用時是
|
|
3738
|
+
// {activeElement: BODY, enabled: 4}、啟用後是 {activeElement: BODY,
|
|
3739
|
+
// enabled: 15},兩者磁碟 byte-identical。也就是說使用者按完分隔線仍然
|
|
3740
|
+
// 沒有游標,要再點一次 block 才能繼續打字 —— 工具列復原就是這一支交付的
|
|
3741
|
+
// 全部。要連 caret 一起還原是另一個決定(游標該落在錨點 block 還是 hr
|
|
3742
|
+
// 之後的 block),不在本 task 的範圍內。
|
|
3743
|
+
//
|
|
3744
|
+
// Final wave, M4: gated on `blockSelection` like the other four restore
|
|
3745
|
+
// sites. Harmless today — a + 分隔線 pressed with a set standing writes
|
|
3746
|
+
// no declareCollapse(), so re-aiming the bar at the hr cannot contradict
|
|
3747
|
+
// a tinted set the way a forced caret would — but "the one site that does
|
|
3748
|
+
// not ask the question" is how the next divergence gets in.
|
|
3749
|
+
if (!blockSelection) reaimToolbarBlockAtLine(result.newStartLine);
|
|
3750
|
+
}
|
|
2745
3751
|
}
|
|
2746
3752
|
|
|
2747
3753
|
|
|
@@ -2860,7 +3866,34 @@
|
|
|
2860
3866
|
// already been raised — otherwise `{ els, recs, batch }`: the LIVE block
|
|
2861
3867
|
// elements in document order, their records out of `blocks` (by reference),
|
|
2862
3868
|
// and whether more than one block is being operated on.
|
|
2863
|
-
|
|
3869
|
+
//
|
|
3870
|
+
// v3.2.1 final wave: `anchor` is an optional OUT-PARAMETER. When given, its
|
|
3871
|
+
// `.line` is set to the grip block's start line as soon as that block has
|
|
3872
|
+
// been re-resolved against the post-switchAwayFrom() `blocks` — i.e. a
|
|
3873
|
+
// number that is already valid on the freshly rendered DOM. Every refusal
|
|
3874
|
+
// BELOW that assignment therefore leaves the caller holding a live
|
|
3875
|
+
// coordinate for a block the refusal did not touch.
|
|
3876
|
+
//
|
|
3877
|
+
// FIVE exits sit ABOVE it and all of them leave `.line` undefined, which is
|
|
3878
|
+
// the honest answer in each case (N3 — the first version of this sentence
|
|
3879
|
+
// said "the two exits", the same undercount M2 existed to fix, so they are
|
|
3880
|
+
// counted out here one by one):
|
|
3881
|
+
// 1. `if (!blockEl) return null` — no gesture at all.
|
|
3882
|
+
// 2. `if (!ok) return null` — switchAwayFrom()'s commit FAILED
|
|
3883
|
+
// and the session it could not commit is still open with its own
|
|
3884
|
+
// banner; aiming a caret anywhere would fight it.
|
|
3885
|
+
// 3. the dropped-gesture re-resolve — the block is genuinely gone.
|
|
3886
|
+
// 4. the no-source-line refusal — the block is there, but its range
|
|
3887
|
+
// is inverted (endLine === startLine - 1), and
|
|
3888
|
+
// `blocks.find(b => b.startLine === …)` would resolve some OTHER block.
|
|
3889
|
+
// 5. `if (!rec)` (a second DROPPED_GESTURE) — the one the undercount lost.
|
|
3890
|
+
// It is NOT absorbed by 4: blockOwnsNoLine() is `!!rec && rec.endLine <
|
|
3891
|
+
// rec.startLine`, so it answers FALSE when the id is absent from
|
|
3892
|
+
// `blocks`, and it also answers false for a provisional block whose
|
|
3893
|
+
// `data-block-id` is null (blockRecOf() returns null for both). Either
|
|
3894
|
+
// way there is no record to read a startLine off.
|
|
3895
|
+
// See restoreAfterStructuralOp().
|
|
3896
|
+
async function resolveGutterOperands(blockEl, anchor) {
|
|
2864
3897
|
if (!blockEl) return null;
|
|
2865
3898
|
// Finding 5a's mousedown preventDefault() deliberately keeps a dirty burst
|
|
2866
3899
|
// alive across the ⠿ press, so the commit that lands inside
|
|
@@ -2886,6 +3919,7 @@
|
|
|
2886
3919
|
if (blockOwnsNoLine(liveBlockEl)) { refuseStructuralListEdit(NO_SOURCE_LINE_MESSAGE); return null; }
|
|
2887
3920
|
const rec = blockRecOf(liveBlockEl);
|
|
2888
3921
|
if (!rec) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return null; }
|
|
3922
|
+
if (anchor) anchor.line = rec.startLine;
|
|
2889
3923
|
|
|
2890
3924
|
const res = selectionLib
|
|
2891
3925
|
? selectionLib.resolveMembership(blockSelection, blocks, rec)
|
|
@@ -3059,7 +4093,148 @@
|
|
|
3059
4093
|
return run;
|
|
3060
4094
|
}
|
|
3061
4095
|
|
|
4096
|
+
// ── v3.2.1 final wave: ONE landing for all four structural sinks ────────
|
|
4097
|
+
//
|
|
4098
|
+
// The v3.2.1 wrappers restored only on the SUCCESS line the body answered.
|
|
4099
|
+
// Measured on ddac6ce, 轉換成 → 引用 on a hard-wrapped list item (an ENABLED
|
|
4100
|
+
// menu item — `disabled === false`, measured) whose own burst was dirty:
|
|
4101
|
+
// DIV.ed-li-text ed-wys-armed / 16 enabled → BODY / 4 enabled, plus the
|
|
4102
|
+
// banner 「此清單含不支援的格式,無法調整結構」. The `finally` WAS reached;
|
|
4103
|
+
// `focusLine` was simply null there, so focusBlockAtLine() was skipped and
|
|
4104
|
+
// reaimToolbarBlockAtLine(null) returned on its first line — while
|
|
4105
|
+
// resolveGutterOperands()'s own switchAwayFrom() had already committed the
|
|
4106
|
+
// burst and re-rendered, which is what took the foothold away.
|
|
4107
|
+
//
|
|
4108
|
+
// Hence the second coordinate. `focusLine` is the body's SUCCESS landing and
|
|
4109
|
+
// is used exactly as before. `anchorLine` is resolveGutterOperands()'s live
|
|
4110
|
+
// grip line (see its `anchor` out-parameter) and is used ONLY when the body
|
|
4111
|
+
// answered nothing — a refusal, a no-op commit, or a rolled-back render.
|
|
4112
|
+
//
|
|
4113
|
+
// The foothold check on that second path is deliberate and is NOT symmetric
|
|
4114
|
+
// with the first. A refusal can also arrive with the user's caret still
|
|
4115
|
+
// exactly where they left it (a CLEAN burst goes out through
|
|
4116
|
+
// endBurstWithoutResolve(): no commit, no render, focus never moves —
|
|
4117
|
+
// measured: 轉換成 → 引用 on the same hard-wrapped li WITHOUT typing first
|
|
4118
|
+
// leaves DIV.ed-li-text ed-wys-armed / 16 enabled on ddac6ce). Restoring
|
|
4119
|
+
// unconditionally there would drag that caret to the end of the block for no
|
|
4120
|
+
// reason — the same mistake fix round 1 corrected in applyLinkToggle(), and
|
|
4121
|
+
// this is the same predicate it settled on.
|
|
4122
|
+
//
|
|
4123
|
+
// M3, the predicate divergence: the four sinks now AGREE that a rolled-back
|
|
4124
|
+
// render restores. Verified by reading rather than driven (a render failure
|
|
4125
|
+
// needs the server to fail mid-gesture): every `return false` inside
|
|
4126
|
+
// rerenderAll() is one of its four fetch/parse exits, all of them above the
|
|
4127
|
+
// call to applyRenderResult(); applyPatch() and applyFullRender() contain no
|
|
4128
|
+
// `return false` at all (grep: zero hits between them). So a `false` answer
|
|
4129
|
+
// from safeRerenderAll() means the DOM was never touched — the anchor block
|
|
4130
|
+
// is still on screen under the same id — while the switchAwayFrom() that ran
|
|
4131
|
+
// BEFORE it may already have re-rendered and reset the bar. Restoring is
|
|
4132
|
+
// therefore the right answer, and returning empty-handed (what
|
|
4133
|
+
// duplicateBlockViaMenuBody() and deleteBlockViaGutterBody() used to do on
|
|
4134
|
+
// `!okRender`) strands the user in exactly the I1/I2 shape. The one failure
|
|
4135
|
+
// this does NOT cover is a THROW inside applyPatch()/applyFullRender(),
|
|
4136
|
+
// caught by safeRerenderAll(): that can leave the DOM half-swapped, and
|
|
4137
|
+
// neither the old behaviour nor this one has an answer for it.
|
|
4138
|
+
function restoreAfterStructuralOp(focusLine, anchorLine) {
|
|
4139
|
+
// 有 blockSelection 站著時什麼都不做 —— 見 convertBlockViaMenu() 底下那段
|
|
4140
|
+
// 註解:強行還原 caret 會做出「底色仍說已選取、Delete 卻是死鍵」的混種狀態。
|
|
4141
|
+
if (blockSelection) return;
|
|
4142
|
+
if (focusLine != null) {
|
|
4143
|
+
focusBlockAtLine(focusLine, true); // caretToEnd
|
|
4144
|
+
// 降級目標(quote / code)沒有可聚焦編輯面,focusBlockAtLine 會安靜
|
|
4145
|
+
// no-op;把 toolbarBlockEl 指回那個 block,工具列才不會塌成 4 顆。
|
|
4146
|
+
reaimToolbarBlockAtLine(focusLine);
|
|
4147
|
+
return;
|
|
4148
|
+
}
|
|
4149
|
+
if (anchorLine == null) return;
|
|
4150
|
+
const held = document.activeElement;
|
|
4151
|
+
const heldSurface = held && held.closest
|
|
4152
|
+
? held.closest('.ed-wys-armed, .ed-wys-cell, .ed-raw') : null;
|
|
4153
|
+
if (heldSurface) {
|
|
4154
|
+
// ⚠ C1. "The caret never left, so there is nothing to do" was TRUE of the
|
|
4155
|
+
// caret and FALSE of the toolbar, and the first version of this guard
|
|
4156
|
+
// skipped both. The bar can be down to 4 while the caret is still sitting
|
|
4157
|
+
// on a live surface, and that is the ORDINARY case, not an edge one: it
|
|
4158
|
+
// is what happens on the PATCH render path, i.e. on every commit after
|
|
4159
|
+
// the first of a page load (`lastParts` is null only for that first one,
|
|
4160
|
+
// so the fallback route — the one this guard was measured on — is
|
|
4161
|
+
// unreachable after it).
|
|
4162
|
+
//
|
|
4163
|
+
// INSTRUMENTED inside applyPatch() on a primed ⠿刪除 refusal, one log
|
|
4164
|
+
// line per step, rather than read off the source:
|
|
4165
|
+
// route PATCH
|
|
4166
|
+
// focusWasInSpan? v:true currentBurst:true burstElAttached:false
|
|
4167
|
+
// active:BODY
|
|
4168
|
+
// afterFocusRestore active:DIV.ed-li-text ed-wys-armed
|
|
4169
|
+
// toolbarBlockElNull:false
|
|
4170
|
+
// burstSurvived v:false currentBurst:true
|
|
4171
|
+
// active:DIV.ed-li-text ed-wys-armed
|
|
4172
|
+
// resetToolbarBlock? willRun:true
|
|
4173
|
+
// afterResetToolbarBlock toolbarBlockElNull:true
|
|
4174
|
+
// restoreAfterStructuralOp active:DIV.ed-li-text ed-wys-armed
|
|
4175
|
+
// toolbarBlockElNull:true
|
|
4176
|
+
// — applyPatch()'s `focusWasInSpan` block hands focus back to the NEW
|
|
4177
|
+
// surface, and `burstSurvived` is only evaluated afterwards. On this
|
|
4178
|
+
// route `currentBurst` is still the OLD burst (resolveBurst()'s own
|
|
4179
|
+
// teardown has not nulled it yet, which is also why focusin's
|
|
4180
|
+
// `if (currentBurst) return;` stops the focus() from opening a fresh
|
|
4181
|
+
// one), and that old burst's editEl was detached by the patch — so
|
|
4182
|
+
// `burstSurvived` is false and `resetToolbarBlock()` runs AFTER the
|
|
4183
|
+
// caret was handed back. The unprimed run of the same gesture took the
|
|
4184
|
+
// FALLBACK route and arrived here with active:BODY, which is why the
|
|
4185
|
+
// first wave's measurements never saw this.
|
|
4186
|
+
//
|
|
4187
|
+
// So: do not drag the caret (it never left), but DO re-aim the bar, at
|
|
4188
|
+
// the block the caret is actually in rather than at `anchorLine` — those
|
|
4189
|
+
// are the same block on every path measured, and when they ever differ
|
|
4190
|
+
// the caret's own block is the honest answer.
|
|
4191
|
+
const blk = heldSurface.closest('.ed-block');
|
|
4192
|
+
if (blk && document.body.contains(blk)) { toolbarBlockEl = blk; updateToolbar(); }
|
|
4193
|
+
return;
|
|
4194
|
+
}
|
|
4195
|
+
focusBlockAtLine(anchorLine, true); // caretToEnd
|
|
4196
|
+
reaimToolbarBlockAtLine(anchorLine);
|
|
4197
|
+
}
|
|
4198
|
+
|
|
4199
|
+
// v3.2.1: the caret restore is a THIN WRAPPER around the conversion body so
|
|
4200
|
+
// that it sits at the function's EXIT and every exit reaches it. The body's
|
|
4201
|
+
// pre-commit refusal exits, enumerated (final wave, M2 — the sentence that
|
|
4202
|
+
// stood here said "three" and named three of them):
|
|
4203
|
+
// 1. `if (!operands) return` — resolveGutterOperands() answered
|
|
4204
|
+
// null (dropped gesture, no source
|
|
4205
|
+
// line, empty/gapped/no-element
|
|
4206
|
+
// batch)
|
|
4207
|
+
// 2. the mixed list/non-list refusal (BATCH_MIXED_MESSAGE)
|
|
4208
|
+
// 3. the withheld-type refusal (BATCH_CONVERT_WITHHELD_MESSAGES)
|
|
4209
|
+
// 4. `if (!liRun)` (BATCH_MULTIRUN_MESSAGE)
|
|
4210
|
+
// 5. `!listRunSupportsStructuralEdit()` (§4.1's run-wide gate)
|
|
4211
|
+
// 6. `if (!stripped.ok)` (per-block 「此區塊的格式無法轉換」)
|
|
4212
|
+
// — six, not three, plus `result.op === null` (a commit that wrote nothing)
|
|
4213
|
+
// and the rolled-back render on the tail. Every one of them is reachable
|
|
4214
|
+
// from a lit ⠿ menu item, and the li → list / li → non-list / blocks → list
|
|
4215
|
+
// helpers this function tail-calls carry their own refusals on top.
|
|
4216
|
+
//
|
|
4217
|
+
// The body returns the operated block's post-commit start line, or undefined
|
|
4218
|
+
// when it refused or wrote nothing — `anchor.line` is what covers the
|
|
4219
|
+
// second case (see restoreAfterStructuralOp()).
|
|
3062
4220
|
async function convertBlockViaMenu(blockEl, target) {
|
|
4221
|
+
let focusLine = null;
|
|
4222
|
+
const anchor = {};
|
|
4223
|
+
try {
|
|
4224
|
+
const out = await convertBlockViaMenuBody(blockEl, target, anchor);
|
|
4225
|
+
if (out != null) focusLine = out;
|
|
4226
|
+
} finally {
|
|
4227
|
+
// v3.2.1: 判準是「有沒有 blockSelection 站著」,不是 operand 數量 ——
|
|
4228
|
+
// 實測 operands.els.length === 1 在「有選取」與「無選取」兩態都成立,
|
|
4229
|
+
// 鑑別力為零。有選取時 declareCollapse() → applySelectionClasses() 已經
|
|
4230
|
+
// 在全新 DOM 上把 roving focus 裝回 .ed-block wrapper(9 種轉換形狀全部
|
|
4231
|
+
// 如此),這時強行還原 caret 會製造混種狀態:底色仍說「已選取」,Delete
|
|
4232
|
+
// 卻變成死鍵、Shift+↓ 降級成移動文字游標。所以有選取時什麼都不做。
|
|
4233
|
+
restoreAfterStructuralOp(focusLine, anchor.line);
|
|
4234
|
+
}
|
|
4235
|
+
}
|
|
4236
|
+
|
|
4237
|
+
async function convertBlockViaMenuBody(blockEl, target, anchor) {
|
|
3063
4238
|
if (!blockEl || !target) return;
|
|
3064
4239
|
// §3.3's membership rules and the whole shared preamble — see
|
|
3065
4240
|
// resolveGutterOperands() above. `els` is ONE contiguous span; the plan's
|
|
@@ -3069,7 +4244,7 @@
|
|
|
3069
4244
|
// fenced block raw-edited into two paragraphs changes the BLOCK count
|
|
3070
4245
|
// without changing the LINE count, so an id-indexed delete hit the wrong
|
|
3071
4246
|
// block, and the same shape silently rewrote a neighbouring table).
|
|
3072
|
-
const operands = await resolveGutterOperands(blockEl);
|
|
4247
|
+
const operands = await resolveGutterOperands(blockEl, anchor);
|
|
3073
4248
|
if (!operands) return;
|
|
3074
4249
|
const els = operands.els;
|
|
3075
4250
|
const recs = operands.recs;
|
|
@@ -3142,8 +4317,7 @@
|
|
|
3142
4317
|
// the run stays a run and the existing re-serialization machinery applies
|
|
3143
4318
|
// unchanged.
|
|
3144
4319
|
if (shape.allLi && convertMd.targetIsList(target)) {
|
|
3145
|
-
await convertListItemsType(els, liRun, recs, target);
|
|
3146
|
-
return;
|
|
4320
|
+
return await convertListItemsType(els, liRun, recs, target);
|
|
3147
4321
|
}
|
|
3148
4322
|
// S2 Task 4 / S3 Task 6: li → a NON-list target. The items LEAVE the run,
|
|
3149
4323
|
// so the span has to be rebuilt in three pieces and §4.3 rule 1's blank
|
|
@@ -3151,16 +4325,14 @@
|
|
|
3151
4325
|
// lines mid-list with no separator and lazy continuation would swallow them
|
|
3152
4326
|
// into the item above (measured, §4.3 rule 1).
|
|
3153
4327
|
if (shape.allLi) {
|
|
3154
|
-
await convertListItemsAway(els, liRun, recs, target);
|
|
3155
|
-
return;
|
|
4328
|
+
return await convertListItemsAway(els, liRun, recs, target);
|
|
3156
4329
|
}
|
|
3157
4330
|
// S2 Task 5 / S3 Task 6: non-list blocks BECOME list items, so §4.3 rule
|
|
3158
4331
|
// 2's looseness policy applies — eat the separator to an adjacent run of
|
|
3159
4332
|
// the same list type, or the merged list goes LOOSE and every item of it
|
|
3160
4333
|
// degrades read-only. Same rule, same helper the li → li path above uses.
|
|
3161
4334
|
if (convertMd.targetIsList(target)) {
|
|
3162
|
-
await convertBlocksIntoList(els, recs, shape.kinds, target);
|
|
3163
|
-
return;
|
|
4335
|
+
return await convertBlocksIntoList(els, recs, shape.kinds, target);
|
|
3164
4336
|
}
|
|
3165
4337
|
|
|
3166
4338
|
// The plain path: N non-list blocks to a non-list target. One commit over
|
|
@@ -3194,10 +4366,20 @@
|
|
|
3194
4366
|
startLine: first.startLine,
|
|
3195
4367
|
endLine: first.startLine + md.split('\n').length - 1,
|
|
3196
4368
|
});
|
|
3197
|
-
const
|
|
4369
|
+
const editRange = result.op
|
|
4370
|
+
? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
|
|
4371
|
+
endLine: Math.max(result.op.startLine - 1, result.op.endLine),
|
|
4372
|
+
delta: result.op.after.length - result.op.before.length }
|
|
4373
|
+
: undefined;
|
|
4374
|
+
const okRender = await safeRerenderAll(editRange);
|
|
3198
4375
|
if (!okRender) {
|
|
3199
4376
|
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
3200
4377
|
}
|
|
4378
|
+
// v3.2.1 §Task 5: the caller's `focusLine`. NOT a new value and NOT a
|
|
4379
|
+
// pre-commit startLine — it is the very line the collapse above declares,
|
|
4380
|
+
// i.e. the operated span's post-commit first line. The plain path is the
|
|
4381
|
+
// one path that does not move it (the commit replaces the span in place).
|
|
4382
|
+
return first.startLine;
|
|
3201
4383
|
}
|
|
3202
4384
|
|
|
3203
4385
|
// Is the editor session switchAwayFrom() would resolve open on THIS block?
|
|
@@ -3255,12 +4437,51 @@
|
|
|
3255
4437
|
// `lines`, and the li path carries the clone through bystanderCarryOver()
|
|
3256
4438
|
// under the ORIGINAL's block id, so list-md.js replays the file's own bytes
|
|
3257
4439
|
// for it and only re-states the marker. Both keep a `~5px` a `~5px`.
|
|
4440
|
+
//
|
|
4441
|
+
// v3.2.1: the caret restore is a THIN WRAPPER around the duplication body,
|
|
4442
|
+
// same shape and same reason as convertBlockViaMenu() above: the body has
|
|
4443
|
+
// several exits that duplicate nothing — resolveGutterOperands() answering
|
|
4444
|
+
// null, the mixed-list refusal, each of duplicateListItems()'s four, the
|
|
4445
|
+
// op === null no-op and the failed render on the non-li tail — all of them
|
|
4446
|
+
// reachable from a lit ⠿ menu item, and the wrapper is what makes every one
|
|
4447
|
+
// of them reach the restore. The body answers the ORIGINAL span's first
|
|
4448
|
+
// start line on success and undefined on every one of those exits.
|
|
4449
|
+
//
|
|
4450
|
+
// Final wave: "reach the restore" was true and not sufficient — with
|
|
4451
|
+
// `focusLine` null the restore had nothing to aim at and did nothing. The
|
|
4452
|
+
// refusal exits now land on `anchor.line`; see restoreAfterStructuralOp().
|
|
4453
|
+
//
|
|
4454
|
+
// 落點是【原件】而不是副本。三個理由,其中兩個是這個檔案自己已經寫下的事實:
|
|
4455
|
+
// 1. 一個選單項目只能有一個答案。有 blockSelection 站著時,§3.3 的塌縮把
|
|
4456
|
+
// 集合留在【原件】上(見 body 裡 declareCollapse() 那段註解:副本插在
|
|
4457
|
+
// 所有被複製的東西【下面】,所以原件的行區間原封不動)。落在副本會讓
|
|
4458
|
+
// 「按完 建立副本 之後手上握著什麼」取決於一個看不見的先前狀態。
|
|
4459
|
+
// 2. 原件的行號直接可讀,副本的行號在 li 路徑上要另外付代價。副本帶著
|
|
4460
|
+
// 原件的 data-block-id,所以 runLineOfBlock() 的
|
|
4461
|
+
// `lineMeta.findIndex((m) => m.blockId === targetId)` 回的是【原件】那
|
|
4462
|
+
// 一行。這不是做不到 —— runLineOfBlock() 對沒有 id 的 block 另有一支
|
|
4463
|
+
// 按位置數行的 fallback,而副本在 span 裡的位置是已知的 `at + 1 + i`。
|
|
4464
|
+
// 代價在別的地方:那個共用的 id 正是 bystanderCarryOver() 逐位元組重播
|
|
4465
|
+
// 副本、而不是重新 escape 它的原因(見下方 duplicateListItems() 的註解,
|
|
4466
|
+
// 一個 '~5px' 不重播就會變成 '\~5px')。所以這是一個取捨,不是一道牆。
|
|
4467
|
+
// 3. 畫面上沒有東西在使用者底下移動:副本長在下面,原件原地不動。
|
|
3258
4468
|
async function duplicateBlockViaMenu(blockEl) {
|
|
4469
|
+
let focusLine = null;
|
|
4470
|
+
const anchor = {};
|
|
4471
|
+
try {
|
|
4472
|
+
const out = await duplicateBlockViaMenuBody(blockEl, anchor);
|
|
4473
|
+
if (out != null) focusLine = out;
|
|
4474
|
+
} finally {
|
|
4475
|
+
restoreAfterStructuralOp(focusLine, anchor.line);
|
|
4476
|
+
}
|
|
4477
|
+
}
|
|
4478
|
+
|
|
4479
|
+
async function duplicateBlockViaMenuBody(blockEl, anchor) {
|
|
3259
4480
|
// §3.3's membership rules plus the shared preamble — see
|
|
3260
4481
|
// resolveGutterOperands(). The whole span is duplicated by ONE commit; a
|
|
3261
4482
|
// per-member loop would re-render between items and invalidate every id in
|
|
3262
4483
|
// between.
|
|
3263
|
-
const operands = await resolveGutterOperands(blockEl);
|
|
4484
|
+
const operands = await resolveGutterOperands(blockEl, anchor);
|
|
3264
4485
|
if (!operands) return;
|
|
3265
4486
|
const els = operands.els;
|
|
3266
4487
|
const recs = operands.recs;
|
|
@@ -3268,8 +4489,12 @@
|
|
|
3268
4489
|
if (shape.anyLi && !shape.allLi) { refuseStructuralListEdit(BATCH_MIXED_MESSAGE); return; }
|
|
3269
4490
|
|
|
3270
4491
|
if (shape.allLi) {
|
|
3271
|
-
|
|
3272
|
-
|
|
4492
|
+
// recs[0].startLine is unchanged by this commit — duplicateListItems()'s
|
|
4493
|
+
// own declareCollapse() comment is the argument: the copies land after
|
|
4494
|
+
// everything the set owns, and no member's line COUNT can change (a
|
|
4495
|
+
// multi-line target is refused, and §3.8's renumbering only ever changes
|
|
4496
|
+
// marker WIDTH), so the originals still occupy the range they occupied.
|
|
4497
|
+
return (await duplicateListItems(els, recs)) ? recs[0].startLine : undefined;
|
|
3273
4498
|
}
|
|
3274
4499
|
|
|
3275
4500
|
// The non-li span duplicates as ONE slice of `lines` — separators between
|
|
@@ -3289,9 +4514,20 @@
|
|
|
3289
4514
|
// answer as "keep what is standing" and says so explicitly, which is what
|
|
3290
4515
|
// keeps this correct if the insertion point ever moves.
|
|
3291
4516
|
declareCollapse({ startLine: first.startLine, endLine: last.endLine });
|
|
3292
|
-
|
|
4517
|
+
const editRange = result.op
|
|
4518
|
+
? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
|
|
4519
|
+
endLine: Math.max(result.op.startLine - 1, result.op.endLine),
|
|
4520
|
+
delta: result.op.after.length - result.op.before.length }
|
|
4521
|
+
: undefined;
|
|
4522
|
+
if (!(await safeRerenderAll(editRange))) {
|
|
3293
4523
|
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
4524
|
+
return;
|
|
3294
4525
|
}
|
|
4526
|
+
// The insertion is anchored on the LAST member and lands BELOW it, so
|
|
4527
|
+
// ops.shiftBlocks() moved only ids greater than that one — the span's own
|
|
4528
|
+
// first start line is the same number before and after, which is the same
|
|
4529
|
+
// fact declareCollapse() above relies on.
|
|
4530
|
+
return first.startLine;
|
|
3295
4531
|
}
|
|
3296
4532
|
|
|
3297
4533
|
// The li half of 建立副本. The copy is spliced into the run's own span and the
|
|
@@ -3300,10 +4536,15 @@
|
|
|
3300
4536
|
// §3.8's renumbering falls out of the re-serialization ('1. alpha' duplicated
|
|
3301
4537
|
// gives '1. alpha / 2. alpha / 3. bravo', not '1. alpha / 1. alpha / 2.
|
|
3302
4538
|
// bravo').
|
|
4539
|
+
//
|
|
4540
|
+
// v3.2.1: returns commitListStructure()'s own true/false, and false for each
|
|
4541
|
+
// of the four refusal exits — duplicateBlockViaMenu()'s wrapper needs to tell
|
|
4542
|
+
// a refusal (nothing was duplicated, the block is still where it was) apart
|
|
4543
|
+
// from a completed duplicate.
|
|
3303
4544
|
async function duplicateListItems(liEls, recs) {
|
|
3304
4545
|
// Every list batch is one run's problem — see batchRunOf().
|
|
3305
4546
|
const run = batchRunOf(liEls);
|
|
3306
|
-
if (!run) { refuseStructuralListEdit(BATCH_MULTIRUN_MESSAGE); return; }
|
|
4547
|
+
if (!run) { refuseStructuralListEdit(BATCH_MULTIRUN_MESSAGE); return false; }
|
|
3307
4548
|
// §4.3's run-wide gate — 轉換/建立副本/刪除/拖曳 each make this call for
|
|
3308
4549
|
// themselves; there is no shared helper. Its input is §3.4 rule 2's scope,
|
|
3309
4550
|
// which is exactly what listRunOf() returns (the outermost run PLUS every
|
|
@@ -3311,14 +4552,14 @@
|
|
|
3311
4552
|
// it adds the item's lines over again), so a multi-line li refuses as a
|
|
3312
4553
|
// TARGET while remaining a perfectly good bystander — and in a batch EVERY
|
|
3313
4554
|
// member is a target.
|
|
3314
|
-
if (!listRunSupportsStructuralEdit(run, liEls)) { refuseStructuralListEdit(); return; }
|
|
4555
|
+
if (!listRunSupportsStructuralEdit(run, liEls)) { refuseStructuralListEdit(); return false; }
|
|
3315
4556
|
// Captured BEFORE the copy enters the span. The copy carries the
|
|
3316
4557
|
// ORIGINAL's data-block-id — that is what makes bystanderCarryOver() replay
|
|
3317
4558
|
// its bytes rather than re-escape them — so runRangeOfBlocks() would
|
|
3318
4559
|
// resolve it to the original's record, and on a duplicate of the span's
|
|
3319
4560
|
// LAST member that silently re-states the range's end line.
|
|
3320
4561
|
const range = runRangeOfBlocks({ lines, blocks, stack }, run);
|
|
3321
|
-
if (!range) return;
|
|
4562
|
+
if (!range) return false;
|
|
3322
4563
|
|
|
3323
4564
|
// §4.3, measured: after the SUBTREE, not after the item's own line.
|
|
3324
4565
|
// subtreeBlocksAfter() is the flat model's subtree — the contiguous run of
|
|
@@ -3331,7 +4572,7 @@
|
|
|
3331
4572
|
const subtree = subtreeBlocksAfter(anchorLi, Number(anchorLi.getAttribute('data-indent')) || 0);
|
|
3332
4573
|
const lastEl = subtree.length ? subtree[subtree.length - 1] : anchorLi;
|
|
3333
4574
|
const at = run.indexOf(lastEl);
|
|
3334
|
-
if (at < 0) return;
|
|
4575
|
+
if (at < 0) return false;
|
|
3335
4576
|
|
|
3336
4577
|
const copies = liEls.map((liEl) => liEl.cloneNode(true));
|
|
3337
4578
|
// `data-list-start` is the ONLY carrier of "marked's lexer opened a new
|
|
@@ -3374,33 +4615,161 @@
|
|
|
3374
4615
|
startLine: recs[0].startLine,
|
|
3375
4616
|
endLine: recs[recs.length - 1].endLine,
|
|
3376
4617
|
});
|
|
3377
|
-
await commitListStructure(span, null, false,
|
|
4618
|
+
return await commitListStructure(span, null, false,
|
|
3378
4619
|
{ presetRange: range, carryOver: bystanderCarryOver(span) });
|
|
3379
4620
|
}
|
|
3380
4621
|
// Deletes `blockEl`'s ENTIRE line range (generalizing commitListBlockRemoval()
|
|
3381
4622
|
// — unchanged, see its own comment — to any block type, not just an
|
|
3382
4623
|
// emptied-out list). Same resolve-first / re-query-live-block-by-id
|
|
3383
4624
|
// precondition as insertBlockBelow() above.
|
|
4625
|
+
//
|
|
4626
|
+
// v3.2.1: the caret restore is a THIN WRAPPER around the removal body, same
|
|
4627
|
+
// shape as convertBlockViaMenu() above. A delete has no "back to the block it
|
|
4628
|
+
// operated on" — the block is gone — so the body answers the HOLE's line
|
|
4629
|
+
// coordinate (the removed span's own first start line) and
|
|
4630
|
+
// blockLineAfterRemoval() below turns that into a block that still exists.
|
|
4631
|
+
//
|
|
4632
|
+
// 有 blockSelection 站著時什麼都不做的判準與 convertBlockViaMenu() 相同,
|
|
4633
|
+
// 但在這條路徑上它幾乎總是讓路:批次刪除的 §3.3 塌縮是 declareCollapse(null)
|
|
4634
|
+
// =清空集合(見 body 內那段註解),render 之後 blockSelection 已經是 null。
|
|
4635
|
+
// 那是對的 —— 沒有集合站著就沒有「底色仍說已選取、Delete 卻是死鍵」的混種
|
|
4636
|
+
// 狀態可言,而使用者剛把手上唯一的著力點刪掉,更需要拿回一個。
|
|
3384
4637
|
async function deleteBlockViaGutter(blockEl) {
|
|
4638
|
+
let removed = null;
|
|
4639
|
+
const anchor = {};
|
|
4640
|
+
try {
|
|
4641
|
+
const out = await deleteBlockViaGutterBody(blockEl, anchor);
|
|
4642
|
+
if (out) removed = out;
|
|
4643
|
+
} finally {
|
|
4644
|
+
// Final wave: on the refusal / rolled-back-render exits `removed` is
|
|
4645
|
+
// null, so the landing falls through to `anchor.line` — the block the
|
|
4646
|
+
// gesture named, which a refusal left exactly where it was. On the
|
|
4647
|
+
// SUCCESS path that block no longer exists, which is why the success
|
|
4648
|
+
// coordinate is blockLineAfterRemoval()'s carried neighbour and not the
|
|
4649
|
+
// anchor: restoreAfterStructuralOp() only consults `anchor.line` when
|
|
4650
|
+
// the first argument is null.
|
|
4651
|
+
const focusLine = removed ? blockLineAfterRemoval(removed) : null;
|
|
4652
|
+
// `anchor.line` is the line of the block that was just DELETED, so it is
|
|
4653
|
+
// offered only when the body refused. A successful delete that cannot
|
|
4654
|
+
// name a landing (blockLineAfterRemoval() answering null — an emptied
|
|
4655
|
+
// document) must restore nothing rather than aim at the hole.
|
|
4656
|
+
restoreAfterStructuralOp(focusLine, removed ? null : anchor.line);
|
|
4657
|
+
}
|
|
4658
|
+
}
|
|
4659
|
+
|
|
4660
|
+
// The block the user is left holding once a span has been removed. Takes the
|
|
4661
|
+
// `{ prevLine, spanIsFirst }` the body captured BEFORE its commit:
|
|
4662
|
+
//
|
|
4663
|
+
// prevLine the block immediately ABOVE the removed span, or null when
|
|
4664
|
+
// there is none. A PRE-commit start line, and stable across the
|
|
4665
|
+
// render for ONE reason that covers every path, not a reason
|
|
4666
|
+
// per path:
|
|
4667
|
+
//
|
|
4668
|
+
// commitRangeRemoval() and commitRangeEdit() compute the
|
|
4669
|
+
// SAME anchor — `blocks.filter((b) => b.endLine <= endLine)
|
|
4670
|
+
// .pop()`, textually identical in both — and hand it to
|
|
4671
|
+
// ops.shiftBlocks(), which moves a block only when
|
|
4672
|
+
// `b.id > editedId` (lineops.js). Ids are positional
|
|
4673
|
+
// ordinals in document order, so the block above the span has
|
|
4674
|
+
// a SMALLER id than every block inside it, and the anchor is
|
|
4675
|
+
// at least the span's last member (that member is itself in
|
|
4676
|
+
// the filtered list). The neighbour therefore fails
|
|
4677
|
+
// `b.id > editedId` whichever helper runs.
|
|
4678
|
+
//
|
|
4679
|
+
// ⚠ Fix round 3. The sentence that stood here said the li path
|
|
4680
|
+
// calls NEITHER of those helpers. That is false, and its own
|
|
4681
|
+
// named example is the counter-example: commitListStructure()
|
|
4682
|
+
// routes `md === ''` — no survivors, i.e. a WHOLE-RUN delete —
|
|
4683
|
+
// straight into commitRangeRemoval(). Instrumented and driven
|
|
4684
|
+
// rather than read: 刪除 over all three items of
|
|
4685
|
+
// '- alpha / - bravo / - charlie' printed
|
|
4686
|
+
// commitRangeRemoval range=5..7 anchor=id4(7..7)
|
|
4687
|
+
// shiftBlocks editedId=4 delta=-4 movedIds=[5] untouchedIds=[0,1,2,3,4]
|
|
4688
|
+
// while a partial delete of the same run printed
|
|
4689
|
+
// commitRangeEdit range=5..7 anchor=id4(7..7)
|
|
4690
|
+
// shiftBlocks editedId=4 delta=-1 movedIds=[5] untouchedIds=[0,1,2,3,4]
|
|
4691
|
+
// — two different helpers, the same anchor, and in both the
|
|
4692
|
+
// neighbour (id1) sits in `untouchedIds`. Five gestures were
|
|
4693
|
+
// instrumented this way (whole run, middle item, first item, a
|
|
4694
|
+
// non-li paragraph, and a run that is the entire document) and
|
|
4695
|
+
// the neighbour was untouched in all five.
|
|
4696
|
+
// spanIsFirst true when the removed span WAS the document's first block, so
|
|
4697
|
+
// "no previous block" is a fact rather than a failed lookup —
|
|
4698
|
+
// see deleteBlockViaGutterBody()'s own comment for why those two
|
|
4699
|
+
// are not the same answer.
|
|
4700
|
+
//
|
|
4701
|
+
// 「上面那一個、游標落在它的結尾」是 Notion 的答案,也是 Backspace 併行的
|
|
4702
|
+
// 落點;這個編輯器的 ⠿ / 建立副本 / 轉換成 整組詞彙都是 Notion 的。
|
|
4703
|
+
//
|
|
4704
|
+
// ⚠ Fix round 1, item 2: this used to INFER the neighbour from the hole's
|
|
4705
|
+
// coordinate — "the largest post-render startLine strictly below it". That is
|
|
4706
|
+
// wrong whenever commitRangeRemoval() takes its `sl -= 1` branch (the line
|
|
4707
|
+
// after the span is not blank, so it absorbs the blank ABOVE instead): every
|
|
4708
|
+
// block BELOW the hole then moves up too, the first of them lands one line
|
|
4709
|
+
// ABOVE the hole's own start line, and taking the maximum picks THAT
|
|
4710
|
+
// deterministically over the real previous block. Driven on '…Bravo para.\n' immediately followed by
|
|
4711
|
+
// a block that interrupts a paragraph — an ATX heading, a list, a fenced
|
|
4712
|
+
// code block, an hr — the caret landed on the block BELOW the hole in all
|
|
4713
|
+
// four, and on the code / hr shapes (no focusable surface) it landed nowhere
|
|
4714
|
+
// at all. The answer is not derivable from the hole; it has to be carried.
|
|
4715
|
+
//
|
|
4716
|
+
// With `spanIsFirst`, whatever is first NOW is the block that moved up into
|
|
4717
|
+
// the hole — `blocks[0]`, no search needed. Answers null for a delete that
|
|
4718
|
+
// emptied the document, and for the unreachable "span not found" case, where
|
|
4719
|
+
// guessing a block is worse than restoring no caret at all.
|
|
4720
|
+
function blockLineAfterRemoval(removed) {
|
|
4721
|
+
if (removed.prevLine != null) return removed.prevLine;
|
|
4722
|
+
if (!removed.spanIsFirst) return null;
|
|
4723
|
+
return blocks.length ? blocks[0].startLine : null;
|
|
4724
|
+
}
|
|
4725
|
+
|
|
4726
|
+
async function deleteBlockViaGutterBody(blockEl, anchor) {
|
|
3385
4727
|
// §3.3's membership rules plus the shared preamble (identity capture,
|
|
3386
4728
|
// switchAwayFrom(), the re-resolve after a commit that re-rendered, and the
|
|
3387
4729
|
// no-source-line refusal) — see resolveGutterOperands(). The stake on the
|
|
3388
4730
|
// re-resolve is highest here: an id shift used to make this delete a
|
|
3389
4731
|
// DIFFERENT block's lines, with the ⠿ menu the user pressed pointing at a
|
|
3390
4732
|
// block that survived.
|
|
3391
|
-
const operands = await resolveGutterOperands(blockEl);
|
|
4733
|
+
const operands = await resolveGutterOperands(blockEl, anchor);
|
|
3392
4734
|
if (!operands) return;
|
|
3393
4735
|
const els = operands.els;
|
|
3394
4736
|
const recs = operands.recs;
|
|
3395
4737
|
const shape = spanListKinds(els);
|
|
3396
4738
|
if (shape.anyLi && !shape.allLi) { refuseStructuralListEdit(BATCH_MIXED_MESSAGE); return; }
|
|
4739
|
+
// Fix round 1, item 2: the block immediately ABOVE the span, read out of
|
|
4740
|
+
// the PRE-commit `blocks` and carried to the wrapper. It cannot be
|
|
4741
|
+
// recovered afterwards from the hole's coordinate — see
|
|
4742
|
+
// blockLineAfterRemoval()'s own comment for the measurement. `blocks` is in
|
|
4743
|
+
// document order (blockmap.js hands out a positional ordinal), so the
|
|
4744
|
+
// element before recs[0] is that neighbour.
|
|
4745
|
+
//
|
|
4746
|
+
// Located by OBJECT IDENTITY, and the three outcomes are kept apart on
|
|
4747
|
+
// purpose (fix round 2, item 3 — `at > 0` folded the third into the
|
|
4748
|
+
// second and would have landed the caret on the document's first block
|
|
4749
|
+
// with no signal at all):
|
|
4750
|
+
// at > 0 there is a previous block; its start line is the landing.
|
|
4751
|
+
// at === 0 the span IS the document's first block — `spanIsFirst` tells
|
|
4752
|
+
// blockLineAfterRemoval() to take the moved-up fallback.
|
|
4753
|
+
// at < 0 recs[0] is not in `blocks`. Unreachable as written:
|
|
4754
|
+
// resolveGutterOperands() builds `recs` from this very array
|
|
4755
|
+
// (blockRecOf()'s `blocks.find()` for the single case,
|
|
4756
|
+
// selection.js's membersOf() `blocks.filter()` for the batch —
|
|
4757
|
+
// both hand back the SAME object references), and nothing
|
|
4758
|
+
// awaits between its return and this line, so `blocks` cannot
|
|
4759
|
+
// have been replaced in between. If it ever becomes reachable,
|
|
4760
|
+
// both flags stay false/null and the wrapper simply restores no
|
|
4761
|
+
// caret and re-aims the toolbar — a bar-only degrade, never a
|
|
4762
|
+
// landing on the wrong block.
|
|
4763
|
+
const at = blocks.indexOf(recs[0]);
|
|
4764
|
+
const prevLine = at > 0 ? blocks[at - 1].startLine : null;
|
|
4765
|
+
const spanIsFirst = at === 0;
|
|
3397
4766
|
// Spec §6, "S1 期間的已知危險" item 1: a LIST ITEM's delete is not a line
|
|
3398
4767
|
// splice. S1 is what first put a ⠿ on a li, and the plain range removal
|
|
3399
4768
|
// below corrupts a list three separate ways — see
|
|
3400
4769
|
// deleteListItemsViaGutter() for the measurements and the routing.
|
|
3401
4770
|
if (shape.allLi) {
|
|
3402
|
-
await deleteListItemsViaGutter(els)
|
|
3403
|
-
|
|
4771
|
+
return (await deleteListItemsViaGutter(els))
|
|
4772
|
+
? { prevLine: prevLine, spanIsFirst: spanIsFirst } : undefined;
|
|
3404
4773
|
}
|
|
3405
4774
|
// A contiguous span of non-list blocks is one line range: the separators
|
|
3406
4775
|
// between its members are inside it by construction (members are adjacent
|
|
@@ -3421,10 +4790,17 @@
|
|
|
3421
4790
|
// inverted range, and §4.4 says a range that no longer resolves clears).
|
|
3422
4791
|
// Declared immediately before the render, never earlier (Task 5 carry 5).
|
|
3423
4792
|
declareCollapse(null);
|
|
3424
|
-
const
|
|
4793
|
+
const editRange = result.op
|
|
4794
|
+
? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
|
|
4795
|
+
endLine: Math.max(result.op.startLine - 1, result.op.endLine),
|
|
4796
|
+
delta: result.op.after.length - result.op.before.length }
|
|
4797
|
+
: undefined;
|
|
4798
|
+
const okRender = await safeRerenderAll(editRange);
|
|
3425
4799
|
if (!okRender) {
|
|
3426
4800
|
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
4801
|
+
return;
|
|
3427
4802
|
}
|
|
4803
|
+
return { prevLine: prevLine, spanIsFirst: spanIsFirst };
|
|
3428
4804
|
}
|
|
3429
4805
|
|
|
3430
4806
|
// Spec §6, "S1 期間的已知危險" item 1 — the ⠿ delete of a LIST ITEM.
|
|
@@ -3455,19 +4831,24 @@
|
|
|
3455
4831
|
// commit the re-serialized survivors over that range with every one of them
|
|
3456
4832
|
// carried over verbatim — nothing here rewrites any survivor's CONTENT, only
|
|
3457
4833
|
// its marker and its leading columns.
|
|
4834
|
+
//
|
|
4835
|
+
// v3.2.1: returns commitListStructure()'s own true/false, and false for each
|
|
4836
|
+
// of its three refusal exits — deleteBlockViaGutter()'s wrapper needs to tell
|
|
4837
|
+
// a refusal (nothing was removed, the block is still where it was) apart from
|
|
4838
|
+
// a completed delete before it goes looking for a neighbour.
|
|
3458
4839
|
async function deleteListItemsViaGutter(liEls) {
|
|
3459
4840
|
// Every list batch is one run's problem — see batchRunOf().
|
|
3460
4841
|
const run = batchRunOf(liEls);
|
|
3461
|
-
if (!run) { refuseStructuralListEdit(BATCH_MULTIRUN_MESSAGE); return; }
|
|
4842
|
+
if (!run) { refuseStructuralListEdit(BATCH_MULTIRUN_MESSAGE); return false; }
|
|
3462
4843
|
// §4.3's run-wide gate, whose input is §3.4 rule 2's scope — which is
|
|
3463
4844
|
// exactly what listRunOf() returns (the outermost run PLUS every
|
|
3464
4845
|
// descendant of its members), so the deeper runs this delete is about to
|
|
3465
4846
|
// re-indent are covered, not just the target's own. Deleting is NOT
|
|
3466
4847
|
// column-only: it removes the target's lines outright, so a multi-line
|
|
3467
4848
|
// target refuses per §4.1 — and in a batch every member is a target.
|
|
3468
|
-
if (!listRunSupportsStructuralEdit(run, liEls)) { refuseStructuralListEdit(); return; }
|
|
4849
|
+
if (!listRunSupportsStructuralEdit(run, liEls)) { refuseStructuralListEdit(); return false; }
|
|
3469
4850
|
const range = runRangeOfBlocks({ lines, blocks, stack }, run);
|
|
3470
|
-
if (!range) return;
|
|
4851
|
+
if (!range) return false;
|
|
3471
4852
|
// §3.4 rule 3's batch anchor: the SMALLEST old indent in the set, never the
|
|
3472
4853
|
// first member's — see spanMinIndent().
|
|
3473
4854
|
const oldIndent = spanMinIndent(liEls);
|
|
@@ -3493,7 +4874,7 @@
|
|
|
3493
4874
|
// this path is the one it makes after the commit — the `!range` bail above
|
|
3494
4875
|
// it cannot fire, `presetRange` is non-null by construction here.
|
|
3495
4876
|
declareCollapse(null);
|
|
3496
|
-
await commitListStructure(survivors, null, false,
|
|
4877
|
+
return await commitListStructure(survivors, null, false,
|
|
3497
4878
|
{ presetRange: range, carryOver: bystanderCarryOver(survivors) });
|
|
3498
4879
|
}
|
|
3499
4880
|
|
|
@@ -3715,6 +5096,10 @@
|
|
|
3715
5096
|
});
|
|
3716
5097
|
await commitListStructure(run, null, false,
|
|
3717
5098
|
{ presetRange: range, carryOver: bystanderCarryOver(run) });
|
|
5099
|
+
// v3.2.1 §Task 5: the caller's `focusLine` — the same post-shift line the
|
|
5100
|
+
// collapse just declared, never the pre-commit startLine (the merge
|
|
5101
|
+
// widening above moves it by exactly `shift`).
|
|
5102
|
+
return recs[0].startLine + shift;
|
|
3718
5103
|
}
|
|
3719
5104
|
|
|
3720
5105
|
// The `data-block-type` a conversion target will carry once it is committed.
|
|
@@ -3855,9 +5240,19 @@
|
|
|
3855
5240
|
const prevLines = lines;
|
|
3856
5241
|
lines = result.lines;
|
|
3857
5242
|
declareCollapse({ startLine: outStart, endLine: outEnd });
|
|
3858
|
-
|
|
5243
|
+
const editRange = result.op
|
|
5244
|
+
? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
|
|
5245
|
+
endLine: Math.max(result.op.startLine - 1, result.op.endLine),
|
|
5246
|
+
delta: result.op.after.length - result.op.before.length }
|
|
5247
|
+
: undefined;
|
|
5248
|
+
if (!(await safeRerenderAll(editRange))) {
|
|
3859
5249
|
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
3860
5250
|
}
|
|
5251
|
+
// v3.2.1 §Task 5: the caller's `focusLine` — the converted piece's own
|
|
5252
|
+
// first emitted line, which is what the collapse above declares. The
|
|
5253
|
+
// pre-commit startLine is wrong here: the split rebuilds the run in three
|
|
5254
|
+
// pieces and §4.3 rule 1's blanks move the converted half.
|
|
5255
|
+
return outStart;
|
|
3861
5256
|
}
|
|
3862
5257
|
|
|
3863
5258
|
// S2 Task 5 — 轉換成 › 項目符號列表 / 編號列表 / 待辦清單 on a block that is
|
|
@@ -3912,9 +5307,18 @@
|
|
|
3912
5307
|
startLine: merged.startLine,
|
|
3913
5308
|
endLine: merged.startLine + md.split('\n').length - 1,
|
|
3914
5309
|
});
|
|
3915
|
-
|
|
5310
|
+
const editRange = result.op
|
|
5311
|
+
? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
|
|
5312
|
+
endLine: Math.max(result.op.startLine - 1, result.op.endLine),
|
|
5313
|
+
delta: result.op.after.length - result.op.before.length }
|
|
5314
|
+
: undefined;
|
|
5315
|
+
if (!(await safeRerenderAll(editRange))) {
|
|
3916
5316
|
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
3917
5317
|
}
|
|
5318
|
+
// v3.2.1 §Task 5: the caller's `focusLine` — the WIDENED range's first
|
|
5319
|
+
// line, which is where the merged list actually starts. `recs[0].startLine`
|
|
5320
|
+
// would be wrong whenever §4.3 rule 2 ate a separator above the span.
|
|
5321
|
+
return merged.startLine;
|
|
3918
5322
|
}
|
|
3919
5323
|
|
|
3920
5324
|
// The `.ed-li-check` chrome for a li that has just BECOME a task item.
|
|
@@ -3967,18 +5371,44 @@
|
|
|
3967
5371
|
// focus/blur cycle on that same block (click into it, click back out
|
|
3968
5372
|
// — e.g. after Esc-ing the raw editor) re-armed that same stale DOM
|
|
3969
5373
|
// and committed it as if it were live content: the "discarded" edit
|
|
3970
|
-
// resurrected itself into `lines`.
|
|
3971
|
-
//
|
|
3972
|
-
//
|
|
3973
|
-
|
|
3974
|
-
currentBurst
|
|
5374
|
+
// resurrected itself into `lines`.
|
|
5375
|
+
// v3.2.1: 先清 currentBurst 再寫 innerHTML。對 TABLE burst,editEl 是
|
|
5376
|
+
// <table> 而聚焦節點是子代 .ed-wys-cell,所以這個寫入會 detach 聚焦的
|
|
5377
|
+
// cell;Chromium 同步派發 focusout,而 focusout 的 table 分支只要
|
|
5378
|
+
// currentBurst 非 null 就會 resolveBurst() 並提交 —— 這顆按鈕的職責是
|
|
5379
|
+
// 丟棄,實測卻把打的字寫進磁碟。revertTableBurstAndEnd() 早就是這個
|
|
5380
|
+
// 順序,而它的註解正是說明「先清 currentBurst」才是它不需要抑制旗標
|
|
5381
|
+
// 的原因;上一版這裡的註解宣稱在鏡像那份契約,實際把它倒過來了。
|
|
5382
|
+
const dyingBurst = currentBurst;
|
|
3975
5383
|
currentBurst = null;
|
|
5384
|
+
dyingBurst.history.dispose();
|
|
5385
|
+
dyingBurst.editEl.innerHTML = dyingBurst.original;
|
|
3976
5386
|
resetSelToolbarState();
|
|
3977
5387
|
} else {
|
|
3978
5388
|
const ok = await switchAwayFrom();
|
|
3979
5389
|
if (!ok) return;
|
|
3980
5390
|
}
|
|
3981
|
-
|
|
5391
|
+
// v3.1.0 修正 4: which LINES this raw edit owns is now §3.3's membership
|
|
5392
|
+
// question, asked through the same door every other ⠿ operation uses.
|
|
5393
|
+
// resolveGutterOperands() brings the whole shared preamble with it — the
|
|
5394
|
+
// re-resolve after a commit that re-rendered, the no-source-line refusal,
|
|
5395
|
+
// the emptiness and CONTIGUITY gates — which is what makes a span range
|
|
5396
|
+
// safe to take as `recs[0].startLine .. recs[last].endLine`: a disjoint
|
|
5397
|
+
// selection would refuse here rather than quietly swallowing the blocks
|
|
5398
|
+
// sitting in the gap. Its own switchAwayFrom() is a no-op by this point
|
|
5399
|
+
// (the branch above already resolved whatever was open).
|
|
5400
|
+
const operands = await resolveGutterOperands(blockEl);
|
|
5401
|
+
if (!operands) return;
|
|
5402
|
+
const els = operands.els;
|
|
5403
|
+
const recs = operands.recs;
|
|
5404
|
+
const liveBlockEl = els[0];
|
|
5405
|
+
const isLi = liveBlockEl.getAttribute('data-block-type') === 'li';
|
|
5406
|
+
// A single non-li block keeps the pre-v3.1.0 path exactly: no range, so
|
|
5407
|
+
// openRawEditor() uses the block's own record and restores by innerHTML.
|
|
5408
|
+
const range = (recs.length > 1 || isLi)
|
|
5409
|
+
? { startLine: recs[0].startLine, endLine: recs[recs.length - 1].endLine }
|
|
5410
|
+
: null;
|
|
5411
|
+
await openRawEditor(liveBlockEl, range);
|
|
3982
5412
|
}
|
|
3983
5413
|
|
|
3984
5414
|
// Starts a burst on `editEl` (a `.ed-wys-armed` content element) — called
|
|
@@ -4170,10 +5600,20 @@
|
|
|
4170
5600
|
const { md: runMd, unsupported, unsupportedByLi, lineMeta } =
|
|
4171
5601
|
listMd.serializeBlocks(runEls, { carryOver: carry });
|
|
4172
5602
|
// Refuse if the EDITED li itself has unsupported inline content.
|
|
4173
|
-
//
|
|
4174
|
-
//
|
|
4175
|
-
//
|
|
4176
|
-
//
|
|
5603
|
+
// Show banner + teardown + rerenderAll (file is untouched, burst never
|
|
5604
|
+
// wrote to `lines`) + return false — this path does NOT fall back to
|
|
5605
|
+
// openRawEditor().
|
|
5606
|
+
//
|
|
5607
|
+
// ⚠ v3.1.0 Ruling 13 supersedes RULING F-O for the ⠿ menu's MD 原始碼
|
|
5608
|
+
// (see toggleGutterMenu()), and NOT for this site. Two different
|
|
5609
|
+
// reasons: F-O was about the CONTAINER (a textarea cannot live inside a
|
|
5610
|
+
// serialized run), which 修正 4 answers by restoring through a
|
|
5611
|
+
// re-render; this refusal is about the CONTENT — the run's own
|
|
5612
|
+
// serializer has just reported it cannot round-trip this item, and
|
|
5613
|
+
// silently opening an editor from inside a burst resolution would be a
|
|
5614
|
+
// second, unasked-for gesture on top of a failed one. The banner's
|
|
5615
|
+
// advice ('改用原始碼編輯') is now literally actionable, which it was
|
|
5616
|
+
// not before: the user can reach exactly that through the ⠿ menu.
|
|
4177
5617
|
const editedIdStr = String(burst.blockId);
|
|
4178
5618
|
if (unsupportedByLi.some((u) => u.blockId === editedIdStr)) {
|
|
4179
5619
|
showBanner('含不支援的格式,改用原始碼編輯', null, null);
|
|
@@ -4253,7 +5693,12 @@
|
|
|
4253
5693
|
}
|
|
4254
5694
|
const liPrevLines = lines;
|
|
4255
5695
|
lines = liCommitResult.lines;
|
|
4256
|
-
const
|
|
5696
|
+
const editRange = liCommitResult.op
|
|
5697
|
+
? { startLine: Math.min(liCommitResult.op.startLine, liCommitResult.op.endLine + 1),
|
|
5698
|
+
endLine: Math.max(liCommitResult.op.startLine - 1, liCommitResult.op.endLine),
|
|
5699
|
+
delta: liCommitResult.op.after.length - liCommitResult.op.before.length }
|
|
5700
|
+
: undefined;
|
|
5701
|
+
const liOk = await safeRerenderAll(editRange);
|
|
4257
5702
|
if (!liOk) {
|
|
4258
5703
|
lines = rollbackFailedRender({ lines, stack }, liCommitResult, liPrevLines);
|
|
4259
5704
|
return false;
|
|
@@ -4309,7 +5754,12 @@
|
|
|
4309
5754
|
}
|
|
4310
5755
|
const prevLines = lines;
|
|
4311
5756
|
lines = commitResult.lines;
|
|
4312
|
-
const
|
|
5757
|
+
const editRange = commitResult.op
|
|
5758
|
+
? { startLine: Math.min(commitResult.op.startLine, commitResult.op.endLine + 1),
|
|
5759
|
+
endLine: Math.max(commitResult.op.startLine - 1, commitResult.op.endLine),
|
|
5760
|
+
delta: commitResult.op.after.length - commitResult.op.before.length }
|
|
5761
|
+
: undefined;
|
|
5762
|
+
const ok = await safeRerenderAll(editRange);
|
|
4313
5763
|
if (!ok) {
|
|
4314
5764
|
lines = rollbackFailedRender({ lines, stack }, commitResult, prevLines);
|
|
4315
5765
|
// Burst stays open: DOM/history untouched, banner already shown by
|
|
@@ -4964,7 +6414,12 @@
|
|
|
4964
6414
|
// still sitting there un-committed — re-render anyway so what's on screen
|
|
4965
6415
|
// is always exactly what's in `lines`.
|
|
4966
6416
|
if (result.op !== null) lines = result.lines;
|
|
4967
|
-
const
|
|
6417
|
+
const editRange = result.op
|
|
6418
|
+
? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
|
|
6419
|
+
endLine: Math.max(result.op.startLine - 1, result.op.endLine),
|
|
6420
|
+
delta: result.op.after.length - result.op.before.length }
|
|
6421
|
+
: undefined;
|
|
6422
|
+
const ok = await safeRerenderAll(editRange);
|
|
4968
6423
|
if (!ok) {
|
|
4969
6424
|
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
4970
6425
|
// Deliberately NOT a second safeRerenderAll(), unlike the two refusals
|
|
@@ -5833,7 +7288,12 @@
|
|
|
5833
7288
|
// §3.3's collapse: a depth change rewrites no line COUNT, so the set
|
|
5834
7289
|
// collapses onto the lines it already held.
|
|
5835
7290
|
declareCollapse({ startLine: first.startLine, endLine: last.endLine });
|
|
5836
|
-
const
|
|
7291
|
+
const editRange = result.op
|
|
7292
|
+
? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
|
|
7293
|
+
endLine: Math.max(result.op.startLine - 1, result.op.endLine),
|
|
7294
|
+
delta: result.op.after.length - result.op.before.length }
|
|
7295
|
+
: undefined;
|
|
7296
|
+
const okRender = await safeRerenderAll(editRange);
|
|
5837
7297
|
if (!okRender) {
|
|
5838
7298
|
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
5839
7299
|
}
|
|
@@ -5866,6 +7326,63 @@
|
|
|
5866
7326
|
await changeHeadingDepthsInSpan(els, recs, dir);
|
|
5867
7327
|
}
|
|
5868
7328
|
|
|
7329
|
+
// v3.1.0 §4: the toolbar's ⇥ / ⇤ buttons. A ZERO-ARGUMENT commit wrapper,
|
|
7330
|
+
// placed beside tabSelection(dir) above because it is the same shape of
|
|
7331
|
+
// thing: a gesture that is not a keystroke, driving a mutation whose only
|
|
7332
|
+
// existing entry point wanted a KeyboardEvent.
|
|
7333
|
+
//
|
|
7334
|
+
// Why this function has to exist at all: indentListItem()/outdentListItem()
|
|
7335
|
+
// (further up) only ever write `data-indent` on the DOM and return a
|
|
7336
|
+
// boolean. They never touch `lines`, never push an undo op, never
|
|
7337
|
+
// re-render. Wiring a toolbar button straight to either of them produces a
|
|
7338
|
+
// list that visibly re-indents and then snaps back on the next render, with
|
|
7339
|
+
// the file byte-identical throughout. Everything that makes Tab a real edit
|
|
7340
|
+
// lives in handleLiKeydown()'s Tab branch — the run-wide gate, the mutation
|
|
7341
|
+
// under mutateListRun()'s focusout suppression, the indent clamp, the
|
|
7342
|
+
// bystander carry-over and the one-op commit — so that is what is
|
|
7343
|
+
// reproduced here, in the same order, with `e.shiftKey` replaced by `dir`.
|
|
7344
|
+
//
|
|
7345
|
+
// `caretLiBlock()` is zero-argument (it reads window.getSelection()), which
|
|
7346
|
+
// is what makes an editEl-free wrapper possible: the toolbar has no event
|
|
7347
|
+
// target inside the run, only a live caret — and the caret is the right
|
|
7348
|
+
// answer anyway, since a run has one editable surface per item.
|
|
7349
|
+
//
|
|
7350
|
+
// dir > 0 indents, dir < 0 outdents.
|
|
7351
|
+
async function indentCaretLi(dir) {
|
|
7352
|
+
const li = caretLiBlock();
|
|
7353
|
+
if (!li) return;
|
|
7354
|
+
let run = listRunOf(li);
|
|
7355
|
+
if (!run.length) return;
|
|
7356
|
+
// `columnOnly`: an indent change rewrites nothing but leading columns, so
|
|
7357
|
+
// a hard-wrapped item is allowed to be the target — same reading, and the
|
|
7358
|
+
// same measurement, as the Tab branch this mirrors.
|
|
7359
|
+
if (!listRunSupportsStructuralEdit(run, li, { columnOnly: true })) {
|
|
7360
|
+
refuseStructuralListEdit();
|
|
7361
|
+
return;
|
|
7362
|
+
}
|
|
7363
|
+
const oldIndent = Number(li.getAttribute('data-indent')) || 0;
|
|
7364
|
+
const changed = mutateListRun(() => {
|
|
7365
|
+
if (!(dir < 0 ? outdentListItem(li) : indentListItem(li))) return false;
|
|
7366
|
+
// v3.0.1, carried over VERBATIM and deliberately: the clamp must read a
|
|
7367
|
+
// FRESH listRunOf(li) — taken after the write above — not the
|
|
7368
|
+
// pre-mutation `run` captured outside. indentListItem() can move a
|
|
7369
|
+
// rule-(d) list-start item whose pre-mutation run is `[li]` alone; with
|
|
7370
|
+
// no anchor in the span, applyIndentClamp()'s rule 1 has nothing to
|
|
7371
|
+
// bound the new indent against and clamps it straight back to 0,
|
|
7372
|
+
// silently undoing the write. See handleLiKeydown()'s own copy of this
|
|
7373
|
+
// note for the full derivation.
|
|
7374
|
+
applyIndentClamp(listRunOf(li), li, oldIndent);
|
|
7375
|
+
return true;
|
|
7376
|
+
});
|
|
7377
|
+
if (!changed) return; // at the boundary: nothing mutated, nothing committed
|
|
7378
|
+
run = listRunOf(li);
|
|
7379
|
+
// Column-only: nothing's CONTENT moved, so every block in the span — the
|
|
7380
|
+
// target included — is a bystander whose source bytes come back
|
|
7381
|
+
// untouched. Built once and shared with runLineOfBlock() below.
|
|
7382
|
+
const carry = bystanderCarryOver(run, null);
|
|
7383
|
+
await commitListStructure(run, runLineOfBlock(run, li, carry), true, { carryOver: carry });
|
|
7384
|
+
}
|
|
7385
|
+
|
|
5869
7386
|
// §3.6's 「Delete 整批刪」, whose 2026-08-31 ruling is that it goes through
|
|
5870
7387
|
// the SAME batch path as the ⠿ menu's 刪除 and that no second deletion path
|
|
5871
7388
|
// is written. deleteBlockViaGutter() already resolves §3.3's membership for
|
|
@@ -6076,10 +7593,34 @@
|
|
|
6076
7593
|
// (/[ \t]+$/) does not, so it would survive into the committed line.
|
|
6077
7594
|
// Either way the user sees an empty item and must get a bare '-'. Clear
|
|
6078
7595
|
// the surface under the SAME normalisation the branch condition used —
|
|
6079
|
-
// liSurfaceHoldsNothing() —
|
|
6080
|
-
//
|
|
6081
|
-
//
|
|
6082
|
-
//
|
|
7596
|
+
// liSurfaceHoldsNothing() — and only once the outdent above has
|
|
7597
|
+
// actually happened, since a refused press must leave the DOM
|
|
7598
|
+
// byte-identical.
|
|
7599
|
+
//
|
|
7600
|
+
// ⚠ Final wave, I4. The sentence that stood here said this innerHTML
|
|
7601
|
+
// assignment is itself a reason the write must sit inside
|
|
7602
|
+
// mutateListRun()'s suppression span — "an innerHTML assignment on the
|
|
7603
|
+
// focused node triggers the very same Chromium unfocus quirk". MEASURED
|
|
7604
|
+
// false, and its own premise is the counter-example: `textEl` IS the
|
|
7605
|
+
// focused node, and writing its innerHTML replaces only its SUBTREE —
|
|
7606
|
+
// the element itself is never detached. Control experiment, driven in a
|
|
7607
|
+
// real page: focus a `.ed-li-text`, count focusout on it and on
|
|
7608
|
+
// `document` (capture), then assign `''` — 0 focusout, 0 blur,
|
|
7609
|
+
// `document.activeElement` still that same node, node still in the
|
|
7610
|
+
// document.
|
|
7611
|
+
//
|
|
7612
|
+
// What the span IS for is written at `suppressLiFocusout`'s own
|
|
7613
|
+
// declaration: a structural list key can move, split or REMOVE the
|
|
7614
|
+
// focused block, and Chromium's synchronous unfocus then reaches
|
|
7615
|
+
// resolveBurst() with the run still in its pre-mutation shape. This
|
|
7616
|
+
// assignment is inside the span because it is part of the same
|
|
7617
|
+
// mutation, not because it contributes a focusout of its own.
|
|
7618
|
+
//
|
|
7619
|
+
// No claim is made here about WHICH callee needs it, and deliberately
|
|
7620
|
+
// not about outdentListItem(): that function is attribute-only in this
|
|
7621
|
+
// flat model — it calls setBlockIndent() on the block and its subtree
|
|
7622
|
+
// (`data-indent` + a CSS custom property) and re-parents nothing, so it
|
|
7623
|
+
// is not the answer either.
|
|
6083
7624
|
const textEl = liTextEl(li);
|
|
6084
7625
|
if (textEl && liSurfaceHoldsNothing(textEl)) textEl.innerHTML = '';
|
|
6085
7626
|
return true;
|
|
@@ -6832,6 +8373,20 @@
|
|
|
6832
8373
|
// (possibly now-stale, post-delete) column/row it came from.
|
|
6833
8374
|
let teHighlightEls = [];
|
|
6834
8375
|
|
|
8376
|
+
// v3.2.1 fix round 1: same idiom as `suppressTableFocusout`/
|
|
8377
|
+
// `suppressLiFocusout` above — a flag set for the exact synchronous-ish
|
|
8378
|
+
// span of one operation that would otherwise be torn down by a listener
|
|
8379
|
+
// reacting to a side effect THAT OPERATION ITSELF triggers. Here:
|
|
8380
|
+
// runCycleAlign()'s `ensureTableBurstOpen()` can call a cell's `.focus()`,
|
|
8381
|
+
// which can scroll the page for real — and the resulting 'scroll' event
|
|
8382
|
+
// is asynchronous relative to the focus() call (measured: it does not
|
|
8383
|
+
// reliably land before runCycleAlign()'s own code resumes after the
|
|
8384
|
+
// `await`), so a plain "check teMenuKind again afterward" re-open would
|
|
8385
|
+
// race it and sometimes miss. onAnyScroll() checks this flag before
|
|
8386
|
+
// calling hideTableEdgeMenu() so the menu's open state is never torn down
|
|
8387
|
+
// in the first place during that span, whichever tick the event lands on.
|
|
8388
|
+
let suppressEdgeMenuAutoHide = false;
|
|
8389
|
+
|
|
6835
8390
|
function clearEdgeHighlight() {
|
|
6836
8391
|
// classList.remove() leaves a dangling `class=""` behind on any element
|
|
6837
8392
|
// the renderer/armEditables() emitted with NO class of its own — a
|
|
@@ -7055,14 +8610,84 @@
|
|
|
7055
8610
|
const colIndex = teMenuColIndex;
|
|
7056
8611
|
if (!tableEl || colIndex == null) return;
|
|
7057
8612
|
// Final-review Finding 6: same live-table swap as runDeleteColumn() above.
|
|
7058
|
-
|
|
7059
|
-
|
|
7060
|
-
|
|
7061
|
-
|
|
7062
|
-
//
|
|
7063
|
-
//
|
|
7064
|
-
//
|
|
7065
|
-
//
|
|
8613
|
+
// v3.2.1 fix round 1: ensureTableBurstOpen() below can call a cell's
|
|
8614
|
+
// .focus(), which can scroll the page for real — and v3.2.1's onAnyScroll
|
|
8615
|
+
// (this file's module-level scroll listener) calls hideTableEdgeMenu() on
|
|
8616
|
+
// every scroll, which would reset teMenuKind/teMenuTableEl/teMenuColIndex
|
|
8617
|
+
// to null and un-highlight the column, breaking this function's own
|
|
8618
|
+
// "stays open, repeated clicks keep cycling" contract below. Measured
|
|
8619
|
+
// TWICE: (1) a plain "re-check state and re-show if it changed" fix
|
|
8620
|
+
// RACES the resulting 'scroll' event, which is asynchronous relative to
|
|
8621
|
+
// the `.focus()` call and does not reliably land before this function
|
|
8622
|
+
// resumes after the `await` — so suppress the menu's own auto-hide for
|
|
8623
|
+
// this span instead (`suppressEdgeMenuAutoHide`, see its own comment).
|
|
8624
|
+
// (2) clearing that suppression in a plain `finally` right after the
|
|
8625
|
+
// `await` ALSO raced it — the pending 'scroll' event can land AFTER this
|
|
8626
|
+
// function has already resumed and cleared the flag, since a
|
|
8627
|
+
// `focus()`-triggered scroll is not guaranteed to be delivered within
|
|
8628
|
+
// the same microtask turn as the promise chain above. Measured fix:
|
|
8629
|
+
// browsers dispatch a pending scroll before the next
|
|
8630
|
+
// requestAnimationFrame callback runs (it is part of the "update the
|
|
8631
|
+
// rendering" steps that precede rAF callbacks in the same frame), so
|
|
8632
|
+
// holding the flag through one rAF tick lets a pending scroll land AND
|
|
8633
|
+
// be swallowed before this lifts it.
|
|
8634
|
+
let liveTableEl;
|
|
8635
|
+
suppressEdgeMenuAutoHide = true;
|
|
8636
|
+
try {
|
|
8637
|
+
liveTableEl = await ensureTableBurstOpen(tableEl);
|
|
8638
|
+
if (!liveTableEl) { suppressEdgeMenuAutoHide = false; return; }
|
|
8639
|
+
cycleColumnAlign(liveTableEl, colIndex);
|
|
8640
|
+
currentBurst.history.snap('align-col');
|
|
8641
|
+
} catch (e) {
|
|
8642
|
+
suppressEdgeMenuAutoHide = false;
|
|
8643
|
+
throw e;
|
|
8644
|
+
}
|
|
8645
|
+
requestAnimationFrame(() => {
|
|
8646
|
+
suppressEdgeMenuAutoHide = false;
|
|
8647
|
+
// Fix round 2 (Important, caught by the re-reviewer): suppressing
|
|
8648
|
+
// hideTableEdgeMenu() only stops onAnyScroll from closing the menu —
|
|
8649
|
+
// it does NOT stop `ensureTableBurstOpen()`'s own `switchAwayFrom()`
|
|
8650
|
+
// from closing it for an UNRELATED, legitimate reason when a burst was
|
|
8651
|
+
// open on a DIFFERENT block: switchAwayFrom() -> resolveBurst() ->
|
|
8652
|
+
// hideTableEdgeMenu() (unconditional, not suppressed — only
|
|
8653
|
+
// onAnyScroll's call is gated) nulls teMenuKind/teMenuTableEl/
|
|
8654
|
+
// teMenuColIndex and clears the highlight. Without this guard the
|
|
8655
|
+
// unconditional reposition below would then make that CORRECTLY-
|
|
8656
|
+
// closed menu visible again — empty of highlight, and inert (its own
|
|
8657
|
+
// teMenuKind/teMenuColIndex are null, so a second 對齊/刪除欄 click
|
|
8658
|
+
// reads no target and does nothing) — floating over the document
|
|
8659
|
+
// until the next scroll happens to close it for an unrelated reason.
|
|
8660
|
+
// Only reposition when the menu is STILL the one this call opened:
|
|
8661
|
+
// same kind, same column, and the table identity matches EITHER
|
|
8662
|
+
// `tableEl` (showColumnMenu() was called with this — the pre-swap
|
|
8663
|
+
// identity, read from its own definition above — and nothing since
|
|
8664
|
+
// has touched teMenuTableEl) or `liveTableEl` (belt-and-braces, in
|
|
8665
|
+
// case some future path re-opens the menu against the live node).
|
|
8666
|
+
if (teMenuKind !== 'col' || teMenuColIndex !== colIndex ||
|
|
8667
|
+
(teMenuTableEl !== liveTableEl && teMenuTableEl !== tableEl)) return;
|
|
8668
|
+
// Stays open (unlike delete) UNLESS ensureTableBurstOpen()'s
|
|
8669
|
+
// switchAwayFrom() legitimately closed it above (guard just above) —
|
|
8670
|
+
// repeated clicks keep cycling. The column's cells are the SAME nodes
|
|
8671
|
+
// (cycleColumnAlign() only touches the `style` attribute) so the
|
|
8672
|
+
// existing highlight is still valid — nothing to rehighlight.
|
|
8673
|
+
// `.ed-te-menu` is `position: fixed` (viewport-relative) though, so if
|
|
8674
|
+
// the suppressed span above actually scrolled the page, its left/top
|
|
8675
|
+
// are now stale relative to the header cell; reposition on every call
|
|
8676
|
+
// that gets past the identity guard three lines up (same math as
|
|
8677
|
+
// showColumnMenu(), skipping its toggle-closed branch — that branch would
|
|
8678
|
+
// wrongly close a menu that never actually asked to close). Final wave,
|
|
8679
|
+
// M1: "unconditionally" is what this said before that guard was added,
|
|
8680
|
+
// and it stopped being true then — a call whose menu is no longer the one
|
|
8681
|
+
// it opened returns above and repositions nothing. A no-op scroll makes
|
|
8682
|
+
// this a no-op reposition (same numbers back).
|
|
8683
|
+
const headerRow = headerRowOf(liveTableEl);
|
|
8684
|
+
const cell = headerRow ? headerRow.cells[colIndex] : null;
|
|
8685
|
+
if (cell) {
|
|
8686
|
+
const r = cell.getBoundingClientRect();
|
|
8687
|
+
positionTeMenu(r.left, r.top);
|
|
8688
|
+
teEdgeMenu.style.top = (r.top - teEdgeMenu.offsetHeight - TE_MENU_GAP_PX) + 'px';
|
|
8689
|
+
}
|
|
8690
|
+
});
|
|
7066
8691
|
}
|
|
7067
8692
|
|
|
7068
8693
|
// ── Notion-style row/column grip handles ─────────────────────────────
|
|
@@ -8313,7 +9938,12 @@
|
|
|
8313
9938
|
// (blockSelection)` guard is what stops this single-block gesture from
|
|
8314
9939
|
// INVENTING a selection when none was standing.
|
|
8315
9940
|
declareCollapse(result.newRange);
|
|
8316
|
-
const
|
|
9941
|
+
const editRange = result.op
|
|
9942
|
+
? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
|
|
9943
|
+
endLine: Math.max(result.op.startLine - 1, result.op.endLine),
|
|
9944
|
+
delta: result.op.after.length - result.op.before.length }
|
|
9945
|
+
: undefined;
|
|
9946
|
+
const okRender = await safeRerenderAll(editRange);
|
|
8317
9947
|
if (!okRender) {
|
|
8318
9948
|
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
8319
9949
|
}
|
|
@@ -8912,16 +10542,40 @@
|
|
|
8912
10542
|
// same way activateCell() used to — brief: "wire your cell edit root the
|
|
8913
10543
|
// same way paragraph editing does (reuse, don't fork)").
|
|
8914
10544
|
function onSelectionChangeForToolbar() {
|
|
8915
|
-
|
|
8916
|
-
|
|
8917
|
-
|
|
8918
|
-
|
|
8919
|
-
|
|
8920
|
-
|
|
8921
|
-
|
|
8922
|
-
|
|
10545
|
+
// v3.1.0: the fixed toolbar's five inline-format buttons enable/disable on
|
|
10546
|
+
// exactly the same question this listener already answers ("is there a
|
|
10547
|
+
// non-collapsed selection inside `selToolbarEditEl`?"), so it piggybacks
|
|
10548
|
+
// here instead of attaching a SECOND document-level selectionchange
|
|
10549
|
+
// listener.
|
|
10550
|
+
//
|
|
10551
|
+
// That is not just tidiness. test/editor-client-runtime.test.js
|
|
10552
|
+
// instruments document.addEventListener for this exact type and asserts
|
|
10553
|
+
// the live count is 1 during a session and 0 after — the property being
|
|
10554
|
+
// that a burst's listener is removed on rerender and never stacks. A
|
|
10555
|
+
// permanent second listener would not leak or stack, but it would move
|
|
10556
|
+
// that counter's baseline and force three numeric assertions plus a
|
|
10557
|
+
// waitForFunction(count === 0) to be rewritten around it, weakening a
|
|
10558
|
+
// leak guard to accommodate a convenience. Reusing this one keeps the
|
|
10559
|
+
// guard exactly as written, and is also strictly more correct: with no
|
|
10560
|
+
// burst open `selToolbarEditEl` is null, so `hasSelection` is false —
|
|
10561
|
+
// which is precisely when this listener is not attached.
|
|
10562
|
+
try {
|
|
10563
|
+
const selNow = window.getSelection();
|
|
10564
|
+
if (!selNow || selNow.rangeCount === 0 || selNow.isCollapsed) { hideSelToolbar(); return; }
|
|
10565
|
+
const range = selNow.getRangeAt(0);
|
|
10566
|
+
if (!selToolbarEditEl ||
|
|
10567
|
+
!selToolbarEditEl.contains(range.startContainer) ||
|
|
10568
|
+
!selToolbarEditEl.contains(range.endContainer)) {
|
|
10569
|
+
hideSelToolbar();
|
|
10570
|
+
return;
|
|
10571
|
+
}
|
|
10572
|
+
positionSelToolbar(range);
|
|
10573
|
+
} finally {
|
|
10574
|
+
// In a `finally` so every early return above still refreshes the bar —
|
|
10575
|
+
// the collapse-to-caret exits are exactly the ones that must turn the
|
|
10576
|
+
// mark buttons back off.
|
|
10577
|
+
updateToolbar();
|
|
8923
10578
|
}
|
|
8924
|
-
positionSelToolbar(range);
|
|
8925
10579
|
}
|
|
8926
10580
|
|
|
8927
10581
|
// Nearest ancestor of `node` (inclusive) with tagName `tag`, stopping at
|
|
@@ -8976,6 +10630,56 @@
|
|
|
8976
10630
|
return r;
|
|
8977
10631
|
}
|
|
8978
10632
|
|
|
10633
|
+
// v3.2.1: 在字元空間修剪 Range 的前後空白,再交給 wrapRangeIn()。
|
|
10634
|
+
// 不用 startOffset/endOffset 步進:當邊界容器是【元素節點】時那些 offset
|
|
10635
|
+
// 索引的是【子節點】而非字元,一次遞減會跳過整個文字節點。實測人類拖曳
|
|
10636
|
+
// 選取 105 次都不會產生元素邊界,但編輯器自己的 reselectAndReposition() 會。
|
|
10637
|
+
function trimRangeToText(range) {
|
|
10638
|
+
const parts = [];
|
|
10639
|
+
const take = (n) => {
|
|
10640
|
+
const len = n.data.length;
|
|
10641
|
+
let a, b;
|
|
10642
|
+
try { a = range.comparePoint(n, 0); b = range.comparePoint(n, len); }
|
|
10643
|
+
catch (e) { return; }
|
|
10644
|
+
if (b < 0 || a > 0) return; // 整個節點落在範圍外
|
|
10645
|
+
const from = (n === range.startContainer && n.nodeType === 3) ? range.startOffset : 0;
|
|
10646
|
+
const to = (n === range.endContainer && n.nodeType === 3) ? range.endOffset : len;
|
|
10647
|
+
if (to > from) parts.push({ n: n, from: from, to: to });
|
|
10648
|
+
};
|
|
10649
|
+
const root = range.commonAncestorContainer;
|
|
10650
|
+
const walk = (node) => {
|
|
10651
|
+
if (node.nodeType === 3) { take(node); return; }
|
|
10652
|
+
for (let c = node.firstChild; c; c = c.nextSibling) walk(c);
|
|
10653
|
+
};
|
|
10654
|
+
// v3.2.1 review: root.nodeType === 3 的情況已經由 walk() 自己的
|
|
10655
|
+
// `node.nodeType === 3` 分支正確處理(single-text-node 選取時
|
|
10656
|
+
// startContainer/endContainer 必為同一個 text node,也就是這裡的
|
|
10657
|
+
// root——take() 的 `n === range.startContainer`/`n === range.endContainer`
|
|
10658
|
+
// 判斷照樣成立)。原本 `root.nodeType === 3 ? root : root` 兩支完全相同,
|
|
10659
|
+
// 是無效的死碼,這裡直接呼叫 walk(root);journey 測試(選取整個落在單一
|
|
10660
|
+
// text node 內)已驗證這條路徑產出正確的 from/to。
|
|
10661
|
+
walk(root);
|
|
10662
|
+
if (!parts.length) return false;
|
|
10663
|
+
// 從頭砍掉空白
|
|
10664
|
+
while (parts.length) {
|
|
10665
|
+
const p = parts[0];
|
|
10666
|
+
while (p.from < p.to && /\s/.test(p.n.data[p.from])) p.from++;
|
|
10667
|
+
if (p.from < p.to) break;
|
|
10668
|
+
parts.shift();
|
|
10669
|
+
}
|
|
10670
|
+
// 從尾砍掉空白
|
|
10671
|
+
while (parts.length) {
|
|
10672
|
+
const p = parts[parts.length - 1];
|
|
10673
|
+
while (p.to > p.from && /\s/.test(p.n.data[p.to - 1])) p.to--;
|
|
10674
|
+
if (p.to > p.from) break;
|
|
10675
|
+
parts.pop();
|
|
10676
|
+
}
|
|
10677
|
+
if (!parts.length) return false; // 全空白選取 → 不包裝
|
|
10678
|
+
range.setStart(parts[0].n, parts[0].from);
|
|
10679
|
+
range.setEnd(parts[parts.length - 1].n, parts[parts.length - 1].to);
|
|
10680
|
+
return true;
|
|
10681
|
+
}
|
|
10682
|
+
|
|
8979
10683
|
// Marks of `tag` that the range overlaps (fully or partially) — called
|
|
8980
10684
|
// only AFTER wholeSelectionMark() has already returned null, so any hit
|
|
8981
10685
|
// here is by construction a partial-overlap case (see the toggle-policy
|
|
@@ -9039,26 +10743,190 @@
|
|
|
9039
10743
|
snapBurstIfActive(root, 'mark');
|
|
9040
10744
|
return;
|
|
9041
10745
|
}
|
|
10746
|
+
if (!trimRangeToText(range)) return; // 全空白/collapsed 選取:no-op
|
|
9042
10747
|
reselectAndReposition(wrapRangeIn(range, tag));
|
|
9043
10748
|
snapBurstIfActive(root, 'mark');
|
|
9044
10749
|
}
|
|
9045
10750
|
|
|
10751
|
+
// Resolves once a `focusout` has been dispatched at `el` AND that dispatch
|
|
10752
|
+
// has fully finished, or after `ms` milliseconds if no focusout arrives at
|
|
10753
|
+
// all — whichever comes first.
|
|
10754
|
+
//
|
|
10755
|
+
// Two things about it are load-bearing and both were measured on the 🔗
|
|
10756
|
+
// gesture:
|
|
10757
|
+
//
|
|
10758
|
+
// * The wait is needed because the blur a native modal causes is delivered
|
|
10759
|
+
// on the browser's own schedule, NOT as a task queued by the code that
|
|
10760
|
+
// opened the modal: window.prompt() returned at t=27ms and the focusout
|
|
10761
|
+
// landed at t=28ms, i.e. after the caller had already returned. So a plain
|
|
10762
|
+
// setTimeout(0) is not a substitute for listening.
|
|
10763
|
+
// * The resolve is handed to a TIMER rather than called from the listener.
|
|
10764
|
+
// A listener's own resolve() only queues a microtask, and the HTML spec
|
|
10765
|
+
// performs a microtask checkpoint after EACH listener callback of a
|
|
10766
|
+
// browser-initiated dispatch — so an awaiter resumed from there runs
|
|
10767
|
+
// BEFORE the document-level focusout delegator further along the same
|
|
10768
|
+
// propagation path, and therefore before anything that delegator starts
|
|
10769
|
+
// exists to be waited on. Measured exactly that way: resolving from the
|
|
10770
|
+
// listener left `switching` still null at the awaiter, and the caret was
|
|
10771
|
+
// lost to the render that followed. A timer moves the resume to the next
|
|
10772
|
+
// task, after the whole dispatch.
|
|
10773
|
+
function nextFocusout(el, ms) {
|
|
10774
|
+
return new Promise((resolve) => {
|
|
10775
|
+
let deadline = null;
|
|
10776
|
+
const finish = () => {
|
|
10777
|
+
el.removeEventListener('focusout', onFocusout);
|
|
10778
|
+
if (deadline !== null) { clearTimeout(deadline); deadline = null; }
|
|
10779
|
+
resolve();
|
|
10780
|
+
};
|
|
10781
|
+
const onFocusout = () => { setTimeout(finish, 0); };
|
|
10782
|
+
el.addEventListener('focusout', onFocusout);
|
|
10783
|
+
deadline = setTimeout(finish, ms);
|
|
10784
|
+
});
|
|
10785
|
+
}
|
|
10786
|
+
|
|
10787
|
+
// v3.2.1: THIN WRAPPER over the toggle body below, same shape as
|
|
10788
|
+
// convertBlockViaMenu() — but the restoration cannot sit in a plain
|
|
10789
|
+
// synchronous finally here, and that is the whole difference. MEASURED, with
|
|
10790
|
+
// an in-page focus/mutation trace on the 🔗 button:
|
|
10791
|
+
//
|
|
10792
|
+
// t=21ms window.prompt() entered t=27ms prompt() returned
|
|
10793
|
+
// t=28ms focusout on the armed surface (the modal's own blur, delivered
|
|
10794
|
+
// after the body had already finished its DOM surgery)
|
|
10795
|
+
// t=31ms focusin — the browser hands focus straight back
|
|
10796
|
+
// t=52ms a SECOND focusout, this one from applyFullRender() (captured
|
|
10797
|
+
// stack: applyFullRender <- applyRenderResult <- rerenderAll)
|
|
10798
|
+
// t=66ms document.activeElement === BODY, toolbar down to 4 buttons
|
|
10799
|
+
//
|
|
10800
|
+
// So the thing that costs the user their foothold is not the modal: focus
|
|
10801
|
+
// comes back on its own. It is that the document's focusout delegator read
|
|
10802
|
+
// the modal's blur as "the user left this surface" and ran switchAwayFrom()
|
|
10803
|
+
// -> resolveBurst() -> commitEdit() -> rerenderAll(), and that render
|
|
10804
|
+
// replaced the whole .content subtree the restored focus was sitting in.
|
|
10805
|
+
// The other four inline-format buttons (toolbar-model.js's `group: 'inline'`
|
|
10806
|
+
// is bold / italic / strike / inline-code / link — five, of which link is
|
|
10807
|
+
// this one) never hit this, because none of them opens a modal and so none
|
|
10808
|
+
// of them ever blurs.
|
|
10809
|
+
//
|
|
10810
|
+
// The two awaits below are therefore each waiting for one specific thing:
|
|
10811
|
+
// nextFocusout() for that blur to be dispatched at all, and `switching` for
|
|
10812
|
+
// the commit + render it started to finish. Only then is there a live block
|
|
10813
|
+
// to aim at.
|
|
10814
|
+
async function applyLinkToggle() {
|
|
10815
|
+
const root = selToolbarEditEl;
|
|
10816
|
+
const sel = typeof window.getSelection === 'function' ? window.getSelection() : null;
|
|
10817
|
+
// hasFormattableSelection() is the body's own four guards spelled once (see
|
|
10818
|
+
// its comment). When it is false the body returns before it can reach
|
|
10819
|
+
// window.prompt(), so there is no modal, no blur, and nothing to wait for.
|
|
10820
|
+
// Waiting anyway would stall this call for the whole timeout and then move
|
|
10821
|
+
// the caret to the end of a block the user never edited.
|
|
10822
|
+
//
|
|
10823
|
+
// ⚠ Final wave, I3. `willPrompt` is a NECESSARY condition for a modal, not
|
|
10824
|
+
// a sufficient one, and the sentence that used to stand at the restore
|
|
10825
|
+
// said otherwise. Those four guards do match the body's four, but the body
|
|
10826
|
+
// has a FIFTH pre-prompt exit they know nothing about: the
|
|
10827
|
+
// `overlapping.length > 0` branch, where a selection that only PARTIALLY
|
|
10828
|
+
// overlaps an existing link unwraps it and returns without ever calling
|
|
10829
|
+
// window.prompt(). MEASURED on ddac6ce — selecting `lpha lin` across
|
|
10830
|
+
// `Alpha <a>linked</a> …` and clicking 🔗 gave `dialogs: 0` while
|
|
10831
|
+
// `willPrompt` was true. Behaviour was still correct there, but only
|
|
10832
|
+
// because the `stillHeld` gate below happened to catch it; the guarantee
|
|
10833
|
+
// the comment claimed did not exist. So the body now REPORTS whether it
|
|
10834
|
+
// prompted and the wait is gated on that report rather than on the
|
|
10835
|
+
// prediction. `blurred` still has to be ARMED before the body runs (the
|
|
10836
|
+
// blur it waits for is caused from inside it) — an armed-but-unused
|
|
10837
|
+
// promise settles on its own 400ms timeout, which removes its own listener
|
|
10838
|
+
// and resolves nobody.
|
|
10839
|
+
const willPrompt = hasFormattableSelection(sel);
|
|
10840
|
+
// Captured BEFORE the body: `selToolbarEditEl` is one of the things the
|
|
10841
|
+
// render nulls. The LINE survives that render — resolveBurst() commits
|
|
10842
|
+
// through commitEdit(state, burst.blockId, newText), an in-place replace of
|
|
10843
|
+
// this block's own line range, and ops.shiftBlocks() only shifts blocks
|
|
10844
|
+
// whose id is GREATER than the edited one, so nothing can move this block's
|
|
10845
|
+
// own start line.
|
|
10846
|
+
const rec = willPrompt && root && root.closest
|
|
10847
|
+
? blockRecOf(root.closest('.ed-block')) : null;
|
|
10848
|
+
const line = rec ? rec.startLine : null;
|
|
10849
|
+
// Armed before the body runs, because the blur it is waiting for is caused
|
|
10850
|
+
// by a call inside the body. The timeout is the fallback for a browser that
|
|
10851
|
+
// does not blur on a modal at all; its cost when it fires is one caret
|
|
10852
|
+
// moved to the end of the block the link was just written into.
|
|
10853
|
+
const blurred = willPrompt && root ? nextFocusout(root, 400) : null;
|
|
10854
|
+
let prompted = false;
|
|
10855
|
+
try {
|
|
10856
|
+
prompted = applyLinkToggleBody() === true;
|
|
10857
|
+
} finally {
|
|
10858
|
+
if (blurred && prompted) {
|
|
10859
|
+
await blurred;
|
|
10860
|
+
if (switching) await switching;
|
|
10861
|
+
// Fix round 1, item 1: reaching here means the modal DID open (the
|
|
10862
|
+
// body said so), not that the user confirmed it. On Esc / cancel the body does no DOM
|
|
10863
|
+
// surgery, the burst goes out through endBurstWithoutResolve(), there
|
|
10864
|
+
// is no commit and no render — and the focus the modal took comes back
|
|
10865
|
+
// by itself (the t=31ms focusin in the trace above). Restoring on top
|
|
10866
|
+
// of that collapses the user's selection to a caret at the block's end.
|
|
10867
|
+
// Driven, 🔗 then Esc: with this gate the selection is still 'Alpha'
|
|
10868
|
+
// (non-collapsed, 20 buttons); without it, '' and collapsed.
|
|
10869
|
+
//
|
|
10870
|
+
// So the restore only fires when the foothold is actually GONE. The
|
|
10871
|
+
// accept path is unaffected — the render has just run and
|
|
10872
|
+
// activeElement is BODY there — and a 400ms timeout misfire becomes
|
|
10873
|
+
// harmless for the same reason.
|
|
10874
|
+
const held = document.activeElement;
|
|
10875
|
+
const stillHeld = held && held.closest &&
|
|
10876
|
+
held.closest('.ed-wys-armed, .ed-wys-cell, .ed-raw');
|
|
10877
|
+
// 有 blockSelection 站著時什麼都不做 —— 與 convertBlockViaMenu() 同一個
|
|
10878
|
+
// 判準、同一個理由:強行還原 caret 會做出「底色仍說已選取、Delete 卻是
|
|
10879
|
+
// 死鍵」的混種狀態。
|
|
10880
|
+
if (!stillHeld && !blockSelection && line != null) {
|
|
10881
|
+
focusBlockAtLine(line, true); // caretToEnd
|
|
10882
|
+
}
|
|
10883
|
+
// ⚠ C1, second occurrence of the same root cause. The re-aim used to
|
|
10884
|
+
// sit INSIDE the `!stillHeld` branch, so a call that kept its foothold
|
|
10885
|
+
// restored nothing at all — and on the PATCH render path (every commit
|
|
10886
|
+
// after a page load's first) applyPatch() hands focus back to the new
|
|
10887
|
+
// surface and only THEN runs resetToolbarBlock(), so "foothold held"
|
|
10888
|
+
// and "bar collapsed to 4" are the ordinary combination, not a
|
|
10889
|
+
// contradiction. Measured, 🔗 accept: unprimed P.ed-wys-armed/15,
|
|
10890
|
+
// primed P.ed-wys-armed/4. The caret restore stays gated (fix round
|
|
10891
|
+
// 1's Esc/cancel finding); only the bar comes out.
|
|
10892
|
+
if (!blockSelection && line != null) {
|
|
10893
|
+
// 就【這個還原目前所攜帶的座標】(一個 block 的起始行)而言,TABLE
|
|
10894
|
+
// 儲存格是還原不回 caret 的形狀:`root` 是 CELL,但 `line` 只能指到
|
|
10895
|
+
// 整張表這個 block,而 blockContentEl() 對 table 回傳
|
|
10896
|
+
// firstElementChild = <table> 本身,它沒有 tabindex、focus() 是
|
|
10897
|
+
// no-op。實測結果因此是 journey 的 bar-only 形狀(V2d 釘住):連結
|
|
10898
|
+
// 正確寫進磁碟、工具列 15 顆仍瞄著那張表,但 activeElement 是 BODY。
|
|
10899
|
+
// 這不是死路 —— 儲存格的 (row, col) 在 re-render 之間是穩定的,帶著
|
|
10900
|
+
// 它就能還原;只是本版沒有帶。reaim 是目前這個形狀下唯一還拿得回來
|
|
10901
|
+
// 的東西 —— 沒有它工具列會塌成 4 顆。
|
|
10902
|
+
reaimToolbarBlockAtLine(line);
|
|
10903
|
+
}
|
|
10904
|
+
}
|
|
10905
|
+
}
|
|
10906
|
+
}
|
|
10907
|
+
|
|
9046
10908
|
// Link is its own entry point (not applyMarkToggle) because "unwrap" here
|
|
9047
10909
|
// means "prompt to edit or clear the URL", and "wrap" means "prompt for a
|
|
9048
10910
|
// URL first" — both need window.prompt() before any DOM surgery happens.
|
|
9049
|
-
|
|
10911
|
+
//
|
|
10912
|
+
// Final wave, I3: returns TRUE iff this call actually reached a
|
|
10913
|
+
// window.prompt(), and false on every exit that did not — the four guards at
|
|
10914
|
+
// the top AND the partial-overlap unwrap in the middle, which is the one the
|
|
10915
|
+
// wrapper's `willPrompt` prediction cannot see. The wrapper waits for the
|
|
10916
|
+
// modal's blur only on a true.
|
|
10917
|
+
function applyLinkToggleBody() {
|
|
9050
10918
|
const root = selToolbarEditEl;
|
|
9051
|
-
if (!root) return;
|
|
10919
|
+
if (!root) return false;
|
|
9052
10920
|
const sel = window.getSelection();
|
|
9053
|
-
if (!sel.rangeCount) return;
|
|
10921
|
+
if (!sel.rangeCount) return false;
|
|
9054
10922
|
const range = sel.getRangeAt(0);
|
|
9055
|
-
if (range.collapsed) return;
|
|
9056
|
-
if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) return;
|
|
10923
|
+
if (range.collapsed) return false;
|
|
10924
|
+
if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) return false;
|
|
9057
10925
|
|
|
9058
10926
|
const whole = wholeSelectionMark(range, 'A', root);
|
|
9059
10927
|
if (whole) {
|
|
9060
10928
|
const url = window.prompt('連結網址(留空以移除連結):', whole.getAttribute('href') || '');
|
|
9061
|
-
if (url === null) return; // cancelled — leave the link untouched
|
|
10929
|
+
if (url === null) return true; // cancelled — leave the link untouched
|
|
9062
10930
|
if (url.trim() === '') {
|
|
9063
10931
|
reselectAndReposition(unwrapElement(whole));
|
|
9064
10932
|
} else {
|
|
@@ -9066,7 +10934,7 @@
|
|
|
9066
10934
|
reselectAndReposition(range);
|
|
9067
10935
|
}
|
|
9068
10936
|
snapBurstIfActive(root, 'mark');
|
|
9069
|
-
return;
|
|
10937
|
+
return true;
|
|
9070
10938
|
}
|
|
9071
10939
|
const overlapping = overlappingMarks(range, 'A', root);
|
|
9072
10940
|
if (overlapping.length > 0) {
|
|
@@ -9074,10 +10942,10 @@
|
|
|
9074
10942
|
overlappingMarks(extended, 'A', root).forEach((m) => unwrapElement(m));
|
|
9075
10943
|
reselectAndReposition(extended);
|
|
9076
10944
|
snapBurstIfActive(root, 'mark');
|
|
9077
|
-
return;
|
|
10945
|
+
return false; // unwrapped in place — no modal ever opened
|
|
9078
10946
|
}
|
|
9079
10947
|
const url = window.prompt('連結網址:', 'https://');
|
|
9080
|
-
if (url === null || url.trim() === '') return; // cancelled or empty — no-op
|
|
10948
|
+
if (url === null || url.trim() === '') return true; // cancelled or empty — no-op
|
|
9081
10949
|
const el = document.createElement('a');
|
|
9082
10950
|
el.setAttribute('href', url.trim());
|
|
9083
10951
|
el.appendChild(range.extractContents());
|
|
@@ -9086,6 +10954,7 @@
|
|
|
9086
10954
|
r.selectNodeContents(el);
|
|
9087
10955
|
reselectAndReposition(r);
|
|
9088
10956
|
snapBurstIfActive(root, 'mark');
|
|
10957
|
+
return true;
|
|
9089
10958
|
}
|
|
9090
10959
|
|
|
9091
10960
|
function buildSelToolbar() {
|
|
@@ -9128,7 +10997,14 @@
|
|
|
9128
10997
|
// `position: fixed`.
|
|
9129
10998
|
function positionSelToolbar(range) {
|
|
9130
10999
|
const rect = range.getBoundingClientRect();
|
|
9131
|
-
|
|
11000
|
+
// v3.2.1: 原本的零矩形檢查是死碼 —— 實測捲出視野的 Range 仍保有
|
|
11001
|
+
// 151x17 的矩形,永遠到不了這一支。改成真正的視窗外檢查。
|
|
11002
|
+
const topInset = parseFloat(getComputedStyle(document.documentElement)
|
|
11003
|
+
.getPropertyValue('--ed-toolbar-h')) || 0;
|
|
11004
|
+
if (rect.bottom <= topInset || rect.top >= window.innerHeight ||
|
|
11005
|
+
rect.right <= 0 || rect.left >= window.innerWidth) {
|
|
11006
|
+
hideSelToolbar(); return;
|
|
11007
|
+
}
|
|
9132
11008
|
if (!selToolbar.parentNode) document.body.appendChild(selToolbar);
|
|
9133
11009
|
const gap = 8, margin = 4;
|
|
9134
11010
|
const tbRect = selToolbar.getBoundingClientRect();
|
|
@@ -9413,7 +11289,28 @@
|
|
|
9413
11289
|
// DECLARED rather than cleared outright so the rollback below leaves a
|
|
9414
11290
|
// standing selection alone when the render fails.
|
|
9415
11291
|
declareSelectionRange(null);
|
|
9416
|
-
|
|
11292
|
+
// v3.2.0: UndoStack.undo()/discardTop() reverse the op when applying it but
|
|
11293
|
+
// return the unmodified FORWARD op, so the range actually rewritten is
|
|
11294
|
+
// [op.startLine, op.startLine + op.after.length - 1] and the delta is the
|
|
11295
|
+
// mirror of the forward one. Using op.endLine here would name the opposite
|
|
11296
|
+
// direction — the span the op wrote on the way FORWARD, not the one this
|
|
11297
|
+
// reversal just rewrote.
|
|
11298
|
+
//
|
|
11299
|
+
// Final review item 4 — what naming it wrongly would cost today: nothing.
|
|
11300
|
+
// Per 決議 4(b) `editRange` is ADVISORY. patchmap() destructures
|
|
11301
|
+
// oldBlocks/newBlocks/oldParts/newParts and never reads it; the kept
|
|
11302
|
+
// prefix/suffix is the longest common run of the two PART arrays, and
|
|
11303
|
+
// seeding it from an op's line range only ever made the answer worse
|
|
11304
|
+
// (measured in 決議 4(b): seeded kept 0 of 5, pure-parts kept 4 of 5).
|
|
11305
|
+
// Its one client-side use is the truthiness gate in applyRenderResult().
|
|
11306
|
+
// So the direction changes no plan — this is simply the correct value for
|
|
11307
|
+
// whenever something does start reading it.
|
|
11308
|
+
const editRange = r.op
|
|
11309
|
+
? { startLine: r.op.startLine,
|
|
11310
|
+
endLine: r.op.startLine + r.op.after.length - 1,
|
|
11311
|
+
delta: r.op.before.length - r.op.after.length }
|
|
11312
|
+
: undefined;
|
|
11313
|
+
const ok = await safeRerenderAll(editRange);
|
|
9417
11314
|
if (!ok) {
|
|
9418
11315
|
// Reverse the undo attempt: push the op back and restore `lines` to
|
|
9419
11316
|
// what was on screen before this undo was requested.
|
|
@@ -9429,7 +11326,15 @@
|
|
|
9429
11326
|
if (!r) return;
|
|
9430
11327
|
lines = r.lines;
|
|
9431
11328
|
declareSelectionRange(null); // §4.4, same as undo() above
|
|
9432
|
-
|
|
11329
|
+
// redo() is NOT reversed — it goes through
|
|
11330
|
+
// replaceLines(lines, op.startLine, op.endLine, op.after), so the
|
|
11331
|
+
// ordinary forward formula applies here, unlike undo() above.
|
|
11332
|
+
const editRange = r.op
|
|
11333
|
+
? { startLine: Math.min(r.op.startLine, r.op.endLine + 1),
|
|
11334
|
+
endLine: Math.max(r.op.startLine - 1, r.op.endLine),
|
|
11335
|
+
delta: r.op.after.length - r.op.before.length }
|
|
11336
|
+
: undefined;
|
|
11337
|
+
const ok = await safeRerenderAll(editRange);
|
|
9433
11338
|
if (!ok) {
|
|
9434
11339
|
// Reverse the redo attempt: pop the op back off and restore `lines`.
|
|
9435
11340
|
const rollback = stack.undo(lines);
|
|
@@ -9437,6 +11342,954 @@
|
|
|
9437
11342
|
}
|
|
9438
11343
|
}
|
|
9439
11344
|
|
|
11345
|
+
// ── v3.1.0 §4: the always-visible editing toolbar ──────────────────────
|
|
11346
|
+
// The version's headline feature: before this there was no toolbar at all,
|
|
11347
|
+
// and every editing affordance was hidden behind a hover gutter, a caret
|
|
11348
|
+
// position or a text selection. window.md2docToolbarModel (injected by
|
|
11349
|
+
// server.js, built and reviewed as lib/editor/toolbar-model.js) owns the
|
|
11350
|
+
// 22-button roster, the group order that decides separator placement, and
|
|
11351
|
+
// the whole active/disabled derivation; everything below is mounting and
|
|
11352
|
+
// wiring. Nothing here re-derives a button's state by hand — a second
|
|
11353
|
+
// opinion about when ⇥ is legal is exactly what the pure-module split
|
|
11354
|
+
// exists to prevent.
|
|
11355
|
+
//
|
|
11356
|
+
// EVERY existing entry point stays: the ⠿ menu, the + menu and the
|
|
11357
|
+
// floating selection toolbar are untouched. This is one more road to the
|
|
11358
|
+
// same operations, not a replacement for any of them.
|
|
11359
|
+
let toolbarEl = null;
|
|
11360
|
+
let toolbarBtns = null; // { [buttonId]: HTMLButtonElement }
|
|
11361
|
+
let toolbarMenuEl = null; // the H▾ dropdown panel, or null when closed
|
|
11362
|
+
let toolbarBlockEl = null;
|
|
11363
|
+
let toolbarStateSig = '';
|
|
11364
|
+
// 追加 4's document mode: 'edit' | 'source'. v3.2.1 removed the third
|
|
11365
|
+
// state ('preview'); docsource.js's MODES comment records why.
|
|
11366
|
+
// docsource.js owns the cycle order and the lines[]<->string conversion.
|
|
11367
|
+
let docMode = 'edit';
|
|
11368
|
+
let sourceTextarea = null;
|
|
11369
|
+
let sourceBaseLines = null;
|
|
11370
|
+
// 追加 2: Ctrl+Shift+V arms a plain-text-only paste for the NEXT paste
|
|
11371
|
+
// event. A ClipboardEvent carries no modifier state of its own, so the
|
|
11372
|
+
// intent has to be captured on the keystroke that produced it.
|
|
11373
|
+
let plainPasteArmed = false;
|
|
11374
|
+
|
|
11375
|
+
const TOOLBAR_UNSUPPORTED_IMAGE_MESSAGE = '只接受 PNG / JPEG / GIF / WebP 圖片';
|
|
11376
|
+
const TOOLBAR_NO_ANCHOR_MESSAGE = '找不到可以插入圖片的位置';
|
|
11377
|
+
|
|
11378
|
+
// Extra visual classes for the three inline buttons whose own glyph is the
|
|
11379
|
+
// affordance (B is bold, I is italic, S is struck through) — the same
|
|
11380
|
+
// idiom .ed-seltb-b/-i/-s use.
|
|
11381
|
+
const TOOLBAR_BTN_CLASS = {
|
|
11382
|
+
bold: 'ed-toolbar-b', italic: 'ed-toolbar-i', strike: 'ed-toolbar-s',
|
|
11383
|
+
};
|
|
11384
|
+
|
|
11385
|
+
// The buttons whose model state can ever report `active: true` — i.e. the
|
|
11386
|
+
// real toggles, and the only ones that may carry aria-pressed. Every other
|
|
11387
|
+
// button is a command, not a toggle.
|
|
11388
|
+
// v3.2.0: derived, not mirrored. A seventh toggle added to toolbar-model.js
|
|
11389
|
+
// used to need a matching edit here, and forgetting it made aria-pressed lie.
|
|
11390
|
+
const TOOLBAR_TOGGLE_IDS = new Set(
|
|
11391
|
+
(toolbarModel ? toolbarModel.BUTTONS : []).filter((b) => b.toggle).map((b) => b.id));
|
|
11392
|
+
|
|
11393
|
+
// The block the bar currently reports on, or null. `null` is the model's
|
|
11394
|
+
// documented no-block state (undo/redo/outline/preview live, everything
|
|
11395
|
+
// else greyed), which is exactly what rerenderAll()'s teardown wants — see
|
|
11396
|
+
// resetToolbarBlock() below.
|
|
11397
|
+
function liveToolbarBlock() {
|
|
11398
|
+
if (toolbarBlockEl && document.body.contains(toolbarBlockEl)) return toolbarBlockEl;
|
|
11399
|
+
toolbarBlockEl = null;
|
|
11400
|
+
return null;
|
|
11401
|
+
}
|
|
11402
|
+
|
|
11403
|
+
// Called from rerenderAll()'s belt-and-braces teardown. A detached node's
|
|
11404
|
+
// getAttribute() still answers, so a tracked block that survived the
|
|
11405
|
+
// .content swap would keep the bar reporting a block no click can reach.
|
|
11406
|
+
function resetToolbarBlock() {
|
|
11407
|
+
toolbarBlockEl = null;
|
|
11408
|
+
closeToolbarMenu();
|
|
11409
|
+
updateToolbar();
|
|
11410
|
+
}
|
|
11411
|
+
|
|
11412
|
+
// v3.2.1: point the bar back at the block a structural op just produced.
|
|
11413
|
+
//
|
|
11414
|
+
// rerenderAll()'s teardown calls resetToolbarBlock() (toolbarBlockEl = null,
|
|
11415
|
+
// bar repainted in its no-block state: 4 enabled buttons), and nothing
|
|
11416
|
+
// re-points it unless a focusin/click lands on a block. That is fine when
|
|
11417
|
+
// the op ends with focusBlockAtLine() — the surface's focus event runs
|
|
11418
|
+
// wireToolbarTracking()'s focusin handler, which does exactly this. It is
|
|
11419
|
+
// NOT fine for a target with no focusable surface (quote / code / hr), where
|
|
11420
|
+
// focusBlockAtLine() silently no-ops and the bar would stay collapsed on a
|
|
11421
|
+
// block that is right there on screen. updateToolbar() is what repaints;
|
|
11422
|
+
// resetToolbarBlock() is idempotent, so re-pointing after it is safe.
|
|
11423
|
+
// Resolves nothing and touches nothing when `line` is null or the block is
|
|
11424
|
+
// gone (a refusal, a rolled-back render).
|
|
11425
|
+
function reaimToolbarBlockAtLine(line) {
|
|
11426
|
+
if (line == null) return;
|
|
11427
|
+
const target = blocks.find((b) => b.startLine === line);
|
|
11428
|
+
if (!target) return;
|
|
11429
|
+
const blockEl = document.querySelector('.ed-block[data-block-id="' + target.id + '"]');
|
|
11430
|
+
if (!blockEl) return;
|
|
11431
|
+
toolbarBlockEl = blockEl;
|
|
11432
|
+
updateToolbar();
|
|
11433
|
+
}
|
|
11434
|
+
|
|
11435
|
+
// Final review item 5: the EXACT predicate applyMarkToggle() and
|
|
11436
|
+
// applyLinkToggle() gate on — all four of their conditions, not three.
|
|
11437
|
+
// Kept as one function so the toolbar's enablement and the actions' own
|
|
11438
|
+
// refusal cannot drift apart: whenever this says true, both actions run.
|
|
11439
|
+
//
|
|
11440
|
+
// The fourth condition (containment) is the one `hasSelection` used to
|
|
11441
|
+
// omit, and it is reachable: a drag-select that STARTS inside an open
|
|
11442
|
+
// burst and ENDS in the next block leaves the selection non-collapsed with
|
|
11443
|
+
// `selToolbarEditEl` still set. The floating selection toolbar already
|
|
11444
|
+
// asks this same question of itself and correctly hides (see its
|
|
11445
|
+
// selectionchange listener above), but updateToolbar() still ran and lit
|
|
11446
|
+
// the five inline-format buttons over a selection every one of them
|
|
11447
|
+
// silently refuses — precisely the dead-button failure this version's
|
|
11448
|
+
// enablement wiring exists to remove.
|
|
11449
|
+
function hasFormattableSelection(sel) {
|
|
11450
|
+
if (!selToolbarEditEl || !sel || sel.rangeCount === 0 || sel.isCollapsed) return false;
|
|
11451
|
+
const range = sel.getRangeAt(0);
|
|
11452
|
+
if (range.collapsed) return false;
|
|
11453
|
+
return selToolbarEditEl.contains(range.startContainer) &&
|
|
11454
|
+
selToolbarEditEl.contains(range.endContainer);
|
|
11455
|
+
}
|
|
11456
|
+
|
|
11457
|
+
// ctx for toolbarModel.deriveState(). Every field is read off the DOM /
|
|
11458
|
+
// `lines` at call time; nothing is cached, so a stale value cannot outlive
|
|
11459
|
+
// a render.
|
|
11460
|
+
function toolbarContext() {
|
|
11461
|
+
const blockEl = liveToolbarBlock();
|
|
11462
|
+
const blockType = blockEl ? blockEl.getAttribute('data-block-type') : null;
|
|
11463
|
+
let headingDepth = 1;
|
|
11464
|
+
if (blockType === 'heading') {
|
|
11465
|
+
const rec = blockRecOf(blockEl);
|
|
11466
|
+
if (rec && rec.endLine >= rec.startLine) headingDepth = headingDepthOf(lines[rec.startLine - 1]);
|
|
11467
|
+
}
|
|
11468
|
+
const sel = typeof window.getSelection === 'function' ? window.getSelection() : null;
|
|
11469
|
+
return {
|
|
11470
|
+
blockType: blockType,
|
|
11471
|
+
indent: blockEl ? (Number(blockEl.getAttribute('data-indent')) || 0) : 0,
|
|
11472
|
+
headingDepth: headingDepth,
|
|
11473
|
+
inList: blockType === 'li',
|
|
11474
|
+
listOrdered: !!blockEl && blockEl.getAttribute('data-list-type') === 'ol',
|
|
11475
|
+
// The mark buttons act through applyMarkToggle()/applyLinkToggle();
|
|
11476
|
+
// hasFormattableSelection() (just above) is their refusal test spelled
|
|
11477
|
+
// once, rather than a looser "is anything selected anywhere on the page".
|
|
11478
|
+
hasSelection: hasFormattableSelection(sel),
|
|
11479
|
+
mode: docMode,
|
|
11480
|
+
};
|
|
11481
|
+
}
|
|
11482
|
+
|
|
11483
|
+
function updateToolbar() {
|
|
11484
|
+
if (!toolbarEl || !toolbarBtns || !toolbarModel) return;
|
|
11485
|
+
const state = toolbarModel.deriveState(toolbarContext());
|
|
11486
|
+
// selectionchange fires on every caret move, so the state is diffed
|
|
11487
|
+
// before any DOM is written — 22 attribute writes per keystroke is a
|
|
11488
|
+
// cost with no matching benefit.
|
|
11489
|
+
const sig = JSON.stringify(state);
|
|
11490
|
+
if (sig === toolbarStateSig) return;
|
|
11491
|
+
toolbarStateSig = sig;
|
|
11492
|
+
Object.keys(toolbarBtns).forEach((id) => {
|
|
11493
|
+
const st = state[id];
|
|
11494
|
+
const btn = toolbarBtns[id];
|
|
11495
|
+
if (!st || !btn) return;
|
|
11496
|
+
btn.disabled = !!st.disabled;
|
|
11497
|
+
// aria-pressed ONLY on the buttons that can actually be pressed-in.
|
|
11498
|
+
// Writing it to all 22 made a screen reader announce `undo`, `image`
|
|
11499
|
+
// and `table` as toggle buttons stuck in the "not pressed" state,
|
|
11500
|
+
// which is a lie about what they do.
|
|
11501
|
+
if (TOOLBAR_TOGGLE_IDS.has(id)) {
|
|
11502
|
+
btn.setAttribute('aria-pressed', st.active ? 'true' : 'false');
|
|
11503
|
+
}
|
|
11504
|
+
// `headings` (H / H1..H6) and, since v3.2.1, the mode button carry a
|
|
11505
|
+
// derived label; every other button keeps the glyph it was built with.
|
|
11506
|
+
if (typeof st.label === 'string') btn.textContent = st.label;
|
|
11507
|
+
// v3.2.1: a per-mode title CANNOT be baked in buildToolbar() — the mode
|
|
11508
|
+
// button's title names the NEXT state, so it has to be rewritten every
|
|
11509
|
+
// time the mode changes. It rides the same diffed loop as everything
|
|
11510
|
+
// else: `title` is part of the state object, therefore part of `sig`.
|
|
11511
|
+
if (typeof st.title === 'string') {
|
|
11512
|
+
btn.title = st.title;
|
|
11513
|
+
btn.setAttribute('aria-label', st.title);
|
|
11514
|
+
}
|
|
11515
|
+
});
|
|
11516
|
+
}
|
|
11517
|
+
|
|
11518
|
+
function buildToolbar() {
|
|
11519
|
+
const bar = document.createElement('div');
|
|
11520
|
+
bar.className = 'ed-toolbar';
|
|
11521
|
+
bar.setAttribute('role', 'toolbar');
|
|
11522
|
+
bar.setAttribute('aria-label', '編輯工具列');
|
|
11523
|
+
const btns = {};
|
|
11524
|
+
let prevGroup = null;
|
|
11525
|
+
toolbarModel.BUTTONS.forEach((def) => {
|
|
11526
|
+
// GROUPS order doubles as separator placement: one divider wherever
|
|
11527
|
+
// consecutive buttons change group.
|
|
11528
|
+
if (prevGroup !== null && def.group !== prevGroup) {
|
|
11529
|
+
const sep = document.createElement('span');
|
|
11530
|
+
sep.className = 'ed-toolbar-sep';
|
|
11531
|
+
bar.appendChild(sep);
|
|
11532
|
+
}
|
|
11533
|
+
prevGroup = def.group;
|
|
11534
|
+
const btn = document.createElement('button');
|
|
11535
|
+
btn.type = 'button';
|
|
11536
|
+
btn.className = 'ed-toolbar-btn' +
|
|
11537
|
+
(TOOLBAR_BTN_CLASS[def.id] ? ' ' + TOOLBAR_BTN_CLASS[def.id] : '');
|
|
11538
|
+
btn.textContent = def.icon;
|
|
11539
|
+
btn.title = def.title;
|
|
11540
|
+
btn.setAttribute('aria-label', def.title);
|
|
11541
|
+
btn.setAttribute('data-ed-tb', def.id);
|
|
11542
|
+
// Not a tab stop, same as the ⠿ / + gutter buttons: this is chrome,
|
|
11543
|
+
// and the Tab key belongs to the document's own structure.
|
|
11544
|
+
btn.setAttribute('tabindex', '-1');
|
|
11545
|
+
// ⚠ LOAD-BEARING, not a nicety. Without it the five inline-format
|
|
11546
|
+
// buttons are dead on arrival: a <button> taking focus on mousedown
|
|
11547
|
+
// fires the armed surface's focusout, which commits and re-renders,
|
|
11548
|
+
// so applyMarkToggle()'s `selToolbarEditEl` is null and the selection
|
|
11549
|
+
// is gone before the click handler ever runs. buildSelToolbar() and
|
|
11550
|
+
// both table grip builders defend themselves the identical way.
|
|
11551
|
+
btn.addEventListener('mousedown', (e) => e.preventDefault());
|
|
11552
|
+
btn.addEventListener('click', (e) => {
|
|
11553
|
+
e.stopPropagation();
|
|
11554
|
+
Promise.resolve(runToolbarAction(def.id, btn)).catch(() => {}).then(updateToolbar);
|
|
11555
|
+
});
|
|
11556
|
+
bar.appendChild(btn);
|
|
11557
|
+
btns[def.id] = btn;
|
|
11558
|
+
});
|
|
11559
|
+
// Fix round 1, item 3: a click on a DISABLED toolbar button used to take
|
|
11560
|
+
// the caret with it — the same gesture and the same symptom this release
|
|
11561
|
+
// exists for. MEASURED, and the two halves matter separately:
|
|
11562
|
+
//
|
|
11563
|
+
// * `mousedown` and `click` are NOT dispatched at all for a disabled
|
|
11564
|
+
// <button> — 0 of each at a capture-phase listener on `document`, for
|
|
11565
|
+
// the whole propagation path, so the per-button mousedown
|
|
11566
|
+
// preventDefault() a few lines above cannot run and neither could one
|
|
11567
|
+
// on this container.
|
|
11568
|
+
// * `pointerdown` IS dispatched (1 at that same document listener) and is
|
|
11569
|
+
// cancellable, and cancelling it suppresses the compatibility mousedown
|
|
11570
|
+
// together with its default action — which is the focus move.
|
|
11571
|
+
//
|
|
11572
|
+
// Driven, caret in a paragraph, clicking the disabled 🅑: without this
|
|
11573
|
+
// handler activeElement was BODY in both the untouched-burst state (bar
|
|
11574
|
+
// still 15) and the typed-in state (bar down to 4, because that focusout
|
|
11575
|
+
// ran a full commit + render); with it, P.ed-wys-armed / 15 in both.
|
|
11576
|
+
//
|
|
11577
|
+
// Scope: capture phase on the bar, and preventDefault() ONLY when the
|
|
11578
|
+
// press resolves to a disabled button. Never stopPropagation() — the
|
|
11579
|
+
// document-level pointerdown delegator (table grips, ⠿ block drag, block
|
|
11580
|
+
// selection) still sees the event, and finds nothing to arm, exactly as
|
|
11581
|
+
// before. An ENABLED button, a separator, the status slot and the bar's
|
|
11582
|
+
// own background are all untouched. The H▾ dropdown is NOT inside this
|
|
11583
|
+
// subtree (toggleToolbarHeadingMenu() appends it to document.body), so it
|
|
11584
|
+
// is out of reach here and keeps its own per-item mousedown handler.
|
|
11585
|
+
bar.addEventListener('pointerdown', (e) => {
|
|
11586
|
+
const pressed = e.target && e.target.closest ? e.target.closest('button') : null;
|
|
11587
|
+
if (pressed && pressed.disabled) e.preventDefault();
|
|
11588
|
+
}, true);
|
|
11589
|
+
toolbarBtns = btns;
|
|
11590
|
+
// v3.1.0 的 spec 承諾「元素存在但無內容,§3 上線時填入、不需要重排版面」,
|
|
11591
|
+
// 但元素從未被建立。這裡把承諾兌現:佔好寬度。內容不再是空的 ——
|
|
11592
|
+
// paintModeStatus() 會在 mountToolbar() 掛上之後、以及每次 setDocMode()
|
|
11593
|
+
// 切換時寫入當前模式名稱。
|
|
11594
|
+
const statusEl = document.createElement('span');
|
|
11595
|
+
statusEl.className = 'ed-toolbar-status';
|
|
11596
|
+
// Final wave, M7: the only announcement a mode switch ever gets. Every
|
|
11597
|
+
// toolbar button is `tabindex="-1"` (see buildToolbar()'s own note), so a
|
|
11598
|
+
// screen reader is never focused on the mode button when it is pressed and
|
|
11599
|
+
// hears nothing about the state that just changed; the slot's text is the
|
|
11600
|
+
// only thing that says so. `role="status"` carries an implicit
|
|
11601
|
+
// aria-live="polite", and the explicit attribute is written alongside it
|
|
11602
|
+
// because that implication is the part assistive tech has historically
|
|
11603
|
+
// been inconsistent about — belt and braces, one line, no layout cost.
|
|
11604
|
+
statusEl.setAttribute('role', 'status');
|
|
11605
|
+
statusEl.setAttribute('aria-live', 'polite');
|
|
11606
|
+
bar.appendChild(statusEl);
|
|
11607
|
+
return bar;
|
|
11608
|
+
}
|
|
11609
|
+
|
|
11610
|
+
async function runToolbarAction(id, btnEl) {
|
|
11611
|
+
const blockEl = liveToolbarBlock();
|
|
11612
|
+
switch (id) {
|
|
11613
|
+
// ⚠ The BROWSER-side zero-argument undo()/redo(), never `stack.undo` /
|
|
11614
|
+
// `stack.redo`. Those are the pure core's helpers (they are what
|
|
11615
|
+
// rollbackFailedRender() reverses an op with) and calling them from
|
|
11616
|
+
// here would skip switchAwayFrom() and safeRerenderAll() — the exact
|
|
11617
|
+
// shape this file records as having produced a silent total lockout
|
|
11618
|
+
// recoverable only by reloading the page.
|
|
11619
|
+
case 'undo': return undo();
|
|
11620
|
+
case 'redo': return redo();
|
|
11621
|
+
case 'headings': return toggleToolbarHeadingMenu(btnEl);
|
|
11622
|
+
// The five conversion targets are convert-md.js's own ids, so the
|
|
11623
|
+
// gesture goes through convertBlockViaMenu()'s §3.7 / §4.3 gates
|
|
11624
|
+
// (withheld types, mixed spans, run support) exactly like the ⠿
|
|
11625
|
+
// submenu's items do.
|
|
11626
|
+
case 'quote': return blockEl ? convertBlockViaMenu(blockEl, 'quote') : undefined;
|
|
11627
|
+
case 'code': return blockEl ? convertBlockViaMenu(blockEl, 'code') : undefined;
|
|
11628
|
+
case 'list': return blockEl ? convertBlockViaMenu(blockEl, 'ul') : undefined;
|
|
11629
|
+
case 'ordered-list': return blockEl ? convertBlockViaMenu(blockEl, 'ol') : undefined;
|
|
11630
|
+
case 'check': return blockEl ? convertBlockViaMenu(blockEl, 'task') : undefined;
|
|
11631
|
+
case 'bold': return applyMarkToggle('STRONG');
|
|
11632
|
+
case 'italic': return applyMarkToggle('EM');
|
|
11633
|
+
case 'strike': return applyMarkToggle('DEL');
|
|
11634
|
+
case 'inline-code': return applyMarkToggle('CODE');
|
|
11635
|
+
case 'link': return applyLinkToggle();
|
|
11636
|
+
// NOT indentListItem()/outdentListItem(): those only write data-indent
|
|
11637
|
+
// on the DOM and never commit. See indentCaretLi()'s own comment.
|
|
11638
|
+
case 'outdent': return indentCaretLi(-1);
|
|
11639
|
+
case 'indent': return indentCaretLi(1);
|
|
11640
|
+
case 'table': return blockEl ? insertBlockBelow(blockEl, 'table') : undefined;
|
|
11641
|
+
case 'line': return blockEl ? insertBlockBelow(blockEl, 'line') : undefined;
|
|
11642
|
+
case 'insert-after': return blockEl ? insertBlockBelow(blockEl, 'paragraph') : undefined;
|
|
11643
|
+
case 'insert-before': return blockEl ? insertBlockAbove(blockEl) : undefined;
|
|
11644
|
+
case 'image': return pickAndInsertImage();
|
|
11645
|
+
case 'outline': return toggleOutlineSidebar();
|
|
11646
|
+
case 'preview': return cycleDocMode();
|
|
11647
|
+
default: return undefined;
|
|
11648
|
+
}
|
|
11649
|
+
}
|
|
11650
|
+
|
|
11651
|
+
// ── headings ▾ ─────────────────────────────────────────────────────────
|
|
11652
|
+
function closeToolbarMenu() {
|
|
11653
|
+
if (toolbarMenuEl) { toolbarMenuEl.remove(); toolbarMenuEl = null; }
|
|
11654
|
+
}
|
|
11655
|
+
|
|
11656
|
+
function toggleToolbarHeadingMenu(btnEl) {
|
|
11657
|
+
if (toolbarMenuEl) { closeToolbarMenu(); return; }
|
|
11658
|
+
const menu = document.createElement('div');
|
|
11659
|
+
menu.className = 'ed-toolbar-menu';
|
|
11660
|
+
[1, 2, 3, 4, 5, 6].forEach((n) => {
|
|
11661
|
+
const item = document.createElement('button');
|
|
11662
|
+
item.type = 'button';
|
|
11663
|
+
item.className = 'ed-toolbar-menu-btn';
|
|
11664
|
+
item.textContent = '標題 ' + n;
|
|
11665
|
+
item.setAttribute('tabindex', '-1');
|
|
11666
|
+
item.addEventListener('mousedown', (e) => e.preventDefault());
|
|
11667
|
+
item.addEventListener('click', (e) => {
|
|
11668
|
+
e.stopPropagation();
|
|
11669
|
+
closeToolbarMenu();
|
|
11670
|
+
Promise.resolve(applyHeadingLevel(n)).catch(() => {}).then(updateToolbar);
|
|
11671
|
+
});
|
|
11672
|
+
menu.appendChild(item);
|
|
11673
|
+
});
|
|
11674
|
+
document.body.appendChild(menu);
|
|
11675
|
+
const r = btnEl.getBoundingClientRect();
|
|
11676
|
+
menu.style.top = (r.bottom + 4) + 'px';
|
|
11677
|
+
menu.style.left = Math.max(4, Math.min(r.left, window.innerWidth - 140)) + 'px';
|
|
11678
|
+
toolbarMenuEl = menu;
|
|
11679
|
+
}
|
|
11680
|
+
|
|
11681
|
+
// changeHeadingDepth(blockEl, delta) takes a RELATIVE delta and has no
|
|
11682
|
+
// blockType guard, while headingDepthOf() answers 1 for any non-heading
|
|
11683
|
+
// line — so calling it on a paragraph silently prefixes '## ' and turns it
|
|
11684
|
+
// into an H2 while bypassing convertBlockViaMenu()'s gates entirely. Both
|
|
11685
|
+
// halves of that are handled here: a non-heading is CONVERTED through the
|
|
11686
|
+
// gate (convert-md.js's 'h1'..'h6' targets already carry the level), and a
|
|
11687
|
+
// heading gets a delta computed against its own current depth.
|
|
11688
|
+
async function applyHeadingLevel(n) {
|
|
11689
|
+
const blockEl = liveToolbarBlock();
|
|
11690
|
+
if (!blockEl) return;
|
|
11691
|
+
if (blockEl.getAttribute('data-block-type') !== 'heading') {
|
|
11692
|
+
await convertBlockViaMenu(blockEl, 'h' + n);
|
|
11693
|
+
return;
|
|
11694
|
+
}
|
|
11695
|
+
const rec = blockRecOf(blockEl);
|
|
11696
|
+
if (!rec || rec.endLine < rec.startLine) return;
|
|
11697
|
+
const delta = n - headingDepthOf(lines[rec.startLine - 1]);
|
|
11698
|
+
if (delta === 0) return;
|
|
11699
|
+
await changeHeadingDepth(blockEl, delta);
|
|
11700
|
+
}
|
|
11701
|
+
|
|
11702
|
+
// ── insert above ───────────────────────────────────────────────────────
|
|
11703
|
+
// commitBlockInsertion() only ever inserts AFTER a block's range, so
|
|
11704
|
+
// "insert above" is expressed the way this file already expresses it (see
|
|
11705
|
+
// convertEmptyTopLevelLiToParagraph()'s own preceding-block lookup):
|
|
11706
|
+
// anchor on the block before this one. That keeps one insertion path and
|
|
11707
|
+
// one set of gates rather than teaching the pure core a direction.
|
|
11708
|
+
async function insertBlockAbove(blockEl) {
|
|
11709
|
+
const rec = blockRecOf(blockEl);
|
|
11710
|
+
if (!rec) return;
|
|
11711
|
+
const preceding = blocks
|
|
11712
|
+
.filter((b) => b.id !== rec.id && b.endLine < rec.startLine && b.endLine >= b.startLine)
|
|
11713
|
+
.pop();
|
|
11714
|
+
if (preceding) {
|
|
11715
|
+
const anchorEl = blockElAtLine(preceding.startLine);
|
|
11716
|
+
if (anchorEl) { await insertBlockBelow(anchorEl, 'paragraph'); return; }
|
|
11717
|
+
}
|
|
11718
|
+
// Nothing above to anchor on — see insertParagraphAtTop(). Refusing here
|
|
11719
|
+
// instead would leave the document's FIRST block permanently impossible
|
|
11720
|
+
// to precede: the ⠿ and + menus both insert below, so this button is the
|
|
11721
|
+
// only affordance that reaches that position at all.
|
|
11722
|
+
await insertParagraphAtTop(blockEl);
|
|
11723
|
+
}
|
|
11724
|
+
|
|
11725
|
+
// The document's first block has no predecessor to insert BELOW, and
|
|
11726
|
+
// commitBlockInsertion() only ever inserts after a block's range. Rather
|
|
11727
|
+
// than teach the pure core a direction (a change under byte-stability and
|
|
11728
|
+
// roundtrip fixtures), the insert is expressed as a REPLACE of that
|
|
11729
|
+
// block's own first source line by [skeleton, '', that same line] — the
|
|
11730
|
+
// same commitRangeEdit()/replaceLines() pipeline as every other edit,
|
|
11731
|
+
// therefore one undo op, and no new primitive.
|
|
11732
|
+
//
|
|
11733
|
+
// The switchAwayFrom() + re-resolve preamble is insertBlockBelow()'s,
|
|
11734
|
+
// for its reasons: the resolution can commit a dirty burst, whose
|
|
11735
|
+
// rerenderAll() detaches the very node this was called with.
|
|
11736
|
+
async function insertParagraphAtTop(blockEl) {
|
|
11737
|
+
const identity = captureBlockIdentity(blockEl);
|
|
11738
|
+
const selfSession = ownsOpenSession(blockEl);
|
|
11739
|
+
const ok = await switchAwayFrom();
|
|
11740
|
+
if (!ok) return;
|
|
11741
|
+
let liveBlockEl = blockEl;
|
|
11742
|
+
if (!document.body.contains(blockEl)) {
|
|
11743
|
+
liveBlockEl = reresolveBlockEl(identity) ||
|
|
11744
|
+
(selfSession ? reresolveBlockElAfterSelfCommit(identity) : null);
|
|
11745
|
+
if (!liveBlockEl) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
11746
|
+
}
|
|
11747
|
+
const rec = blockRecOf(liveBlockEl);
|
|
11748
|
+
// A block that owns no source line has an inverted range, and there is
|
|
11749
|
+
// no line to splice in front of.
|
|
11750
|
+
if (!rec || rec.endLine < rec.startLine) {
|
|
11751
|
+
refuseStructuralListEdit(NO_SOURCE_LINE_INSERT_MESSAGE);
|
|
11752
|
+
return;
|
|
11753
|
+
}
|
|
11754
|
+
const at = rec.startLine;
|
|
11755
|
+
const newText = BLOCK_SKELETONS.paragraph.concat(['', lines[at - 1]]).join('\n');
|
|
11756
|
+
const result = commitRangeEdit({ lines, blocks, stack }, at, at, newText);
|
|
11757
|
+
if (result.op === null) return;
|
|
11758
|
+
const prevLines = lines;
|
|
11759
|
+
lines = result.lines;
|
|
11760
|
+
const editRange = result.op
|
|
11761
|
+
? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
|
|
11762
|
+
endLine: Math.max(result.op.startLine - 1, result.op.endLine),
|
|
11763
|
+
delta: result.op.after.length - result.op.before.length }
|
|
11764
|
+
: undefined;
|
|
11765
|
+
const okRender = await safeRerenderAll(editRange);
|
|
11766
|
+
if (!okRender) {
|
|
11767
|
+
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
11768
|
+
return;
|
|
11769
|
+
}
|
|
11770
|
+
// The new paragraph starts exactly where the old first line did — same
|
|
11771
|
+
// pristine + focus contract insertBlockBelow() gives a skeleton insert.
|
|
11772
|
+
const target = blocks.find((b) => b.startLine === at);
|
|
11773
|
+
if (target) pristineInsert = { blockId: target.id };
|
|
11774
|
+
await focusInsertedBlock(at, 'paragraph');
|
|
11775
|
+
}
|
|
11776
|
+
|
|
11777
|
+
// ── 大綱 ───────────────────────────────────────────────────────────────
|
|
11778
|
+
// TWO attributes, chosen by viewport, and that is not an accident.
|
|
11779
|
+
//
|
|
11780
|
+
// `body[data-sidebar-open]` is the reader runtime's own attribute (see
|
|
11781
|
+
// lib/md2doc.js's setSidebarOpen()) and it drives the MOBILE drawer. The
|
|
11782
|
+
// first cut of this function reused it on desktop too, on the premise that
|
|
11783
|
+
// nothing outside the max-width:1080px block read it. That premise was
|
|
11784
|
+
// false, and the failure was severe: `body[data-sidebar-open]
|
|
11785
|
+
// .sidebar-scrim { display: block; }` sits at BASE scope, so on a desktop
|
|
11786
|
+
// viewport the button painted a full-viewport 35%-black scrim over the
|
|
11787
|
+
// whole page (position:fixed; inset:0; z-index:98) — the document went
|
|
11788
|
+
// grey and unclickable, and the scrim's own click handler then called
|
|
11789
|
+
// setSidebarOpen(false), so the first click anywhere undid it and the
|
|
11790
|
+
// whole gesture read as a flicker.
|
|
11791
|
+
//
|
|
11792
|
+
// Desktop therefore gets a PRIVATE attribute nothing else reads,
|
|
11793
|
+
// `data-ed-outline-hidden`, matched by an edit-mode-only rule in
|
|
11794
|
+
// lib/md2doc.js. Named for the non-default state because the desktop
|
|
11795
|
+
// outline is a sticky column shown by default, which is also what lets
|
|
11796
|
+
// aria-expanded below stay truthful on both viewports.
|
|
11797
|
+
function toggleOutlineSidebar() {
|
|
11798
|
+
// The same 1080px breakpoint the stylesheet's own drawer block uses.
|
|
11799
|
+
const mobile = typeof window.matchMedia === 'function' &&
|
|
11800
|
+
window.matchMedia('(max-width: 1080px)').matches;
|
|
11801
|
+
let expanded;
|
|
11802
|
+
if (mobile) {
|
|
11803
|
+
expanded = !document.body.hasAttribute('data-sidebar-open');
|
|
11804
|
+
if (expanded) document.body.setAttribute('data-sidebar-open', '');
|
|
11805
|
+
else document.body.removeAttribute('data-sidebar-open');
|
|
11806
|
+
} else {
|
|
11807
|
+
expanded = document.body.hasAttribute('data-ed-outline-hidden');
|
|
11808
|
+
if (expanded) document.body.removeAttribute('data-ed-outline-hidden');
|
|
11809
|
+
else document.body.setAttribute('data-ed-outline-hidden', '');
|
|
11810
|
+
}
|
|
11811
|
+
const toggle = document.getElementById('sidebar-toggle');
|
|
11812
|
+
if (toggle) toggle.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
|
11813
|
+
}
|
|
11814
|
+
|
|
11815
|
+
// ── 追加 3: images ─────────────────────────────────────────────────────
|
|
11816
|
+
function lastBlockEl() {
|
|
11817
|
+
const all = contentEl.querySelectorAll('.ed-block');
|
|
11818
|
+
return all.length ? all[all.length - 1] : null;
|
|
11819
|
+
}
|
|
11820
|
+
|
|
11821
|
+
function blobToBase64(blob) {
|
|
11822
|
+
return new Promise((resolve, reject) => {
|
|
11823
|
+
const fr = new FileReader();
|
|
11824
|
+
fr.onerror = () => reject(new Error('could not read the image'));
|
|
11825
|
+
fr.onload = () => {
|
|
11826
|
+
// readAsDataURL gives 'data:<mime>;base64,<payload>' — the endpoint
|
|
11827
|
+
// wants the payload alone, and derives the extension from the mime
|
|
11828
|
+
// it is told, never from this prefix.
|
|
11829
|
+
const s = String(fr.result || '');
|
|
11830
|
+
const comma = s.indexOf(',');
|
|
11831
|
+
resolve(comma === -1 ? '' : s.slice(comma + 1));
|
|
11832
|
+
};
|
|
11833
|
+
fr.readAsDataURL(blob);
|
|
11834
|
+
});
|
|
11835
|
+
}
|
|
11836
|
+
|
|
11837
|
+
// Uploads one blob and answers its 'assets/<name>' path, or null (a banner
|
|
11838
|
+
// is already showing by then). The MIME whitelist is asked BEFORE the round
|
|
11839
|
+
// trip so an unsupported drop is refused instantly and locally — the server
|
|
11840
|
+
// asks the same question again, and it is the server's answer that is
|
|
11841
|
+
// authoritative.
|
|
11842
|
+
async function uploadImageBlob(blob) {
|
|
11843
|
+
if (!blob) return null;
|
|
11844
|
+
if (!assetLib || !assetLib.extFor(blob.type)) {
|
|
11845
|
+
showBanner(TOOLBAR_UNSUPPORTED_IMAGE_MESSAGE, null, null);
|
|
11846
|
+
return null;
|
|
11847
|
+
}
|
|
11848
|
+
let data;
|
|
11849
|
+
try {
|
|
11850
|
+
data = await blobToBase64(blob);
|
|
11851
|
+
} catch (e) {
|
|
11852
|
+
showBanner('圖片讀取失敗 — ' + describeFailure(e), null, null);
|
|
11853
|
+
return null;
|
|
11854
|
+
}
|
|
11855
|
+
let res;
|
|
11856
|
+
try {
|
|
11857
|
+
res = await fetch('/api/asset', {
|
|
11858
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
11859
|
+
body: JSON.stringify({
|
|
11860
|
+
fileId: ED.fileId, mime: blob.type, name: blob.name || 'image', data: data,
|
|
11861
|
+
}),
|
|
11862
|
+
});
|
|
11863
|
+
} catch (e) {
|
|
11864
|
+
showBanner('圖片上傳失敗 — ' + describeFailure(e) + '。檔案未寫入。', null, null);
|
|
11865
|
+
return null;
|
|
11866
|
+
}
|
|
11867
|
+
if (!res.ok) {
|
|
11868
|
+
const reason = await describeHttpFailure(res);
|
|
11869
|
+
showBanner('圖片上傳失敗 — ' + reason + '。檔案未寫入。', null, null);
|
|
11870
|
+
return null;
|
|
11871
|
+
}
|
|
11872
|
+
let j;
|
|
11873
|
+
try { j = await res.json(); } catch (e) { j = null; }
|
|
11874
|
+
if (!j || typeof j.path !== 'string') {
|
|
11875
|
+
showBanner('圖片上傳失敗 — 伺服器回應格式不正確。', null, null);
|
|
11876
|
+
return null;
|
|
11877
|
+
}
|
|
11878
|
+
return j.path;
|
|
11879
|
+
}
|
|
11880
|
+
|
|
11881
|
+
// asset.js's sanitizeName() deliberately keeps spaces (and everything else
|
|
11882
|
+
// that is not a path separator or a drive colon) — a screenshot really is
|
|
11883
|
+
// called 'Screen Shot 2026-09-03 at 14.02.11.png'. A bare space, or a
|
|
11884
|
+
// parenthesis, inside an inline image destination ends the destination
|
|
11885
|
+
// early, so `` renders as literal text. Percent-
|
|
11886
|
+
// encoding exactly those characters fixes it in both directions: browsers
|
|
11887
|
+
// resolve the escape, and md2doc's own resolveAssetPath() decodes the
|
|
11888
|
+
// filePart before probing the disk (it also probes the raw form, so
|
|
11889
|
+
// non-ASCII characters are deliberately left alone rather than escaped).
|
|
11890
|
+
// '%' itself is encoded FIRST by being in the same character class, so a
|
|
11891
|
+
// filename that already contains one cannot be double-decoded.
|
|
11892
|
+
const TOOLBAR_MD_DEST_UNSAFE_RE = /[%\s()<>"']/g;
|
|
11893
|
+
function markdownImageDest(p) {
|
|
11894
|
+
return String(p == null ? '' : p).replace(TOOLBAR_MD_DEST_UNSAFE_RE, (c) => {
|
|
11895
|
+
const hex = c.charCodeAt(0).toString(16).toUpperCase();
|
|
11896
|
+
return '%' + (hex.length < 2 ? '0' + hex : hex);
|
|
11897
|
+
});
|
|
11898
|
+
}
|
|
11899
|
+
|
|
11900
|
+
// Uploads every image in `blobs` and lands them as ONE block insertion.
|
|
11901
|
+
// One insertion, not one per image: each insert re-renders and detaches the
|
|
11902
|
+
// anchor, so a loop would be inserting against a stale node from the second
|
|
11903
|
+
// image onward — and it would also cost one undo op per image for what the
|
|
11904
|
+
// user experienced as a single drop. Blank lines between the paths keep
|
|
11905
|
+
// them separate paragraphs instead of one paragraph of soft breaks.
|
|
11906
|
+
async function insertImages(blobs, anchorEl) {
|
|
11907
|
+
const paths = [];
|
|
11908
|
+
for (let i = 0; i < blobs.length; i += 1) {
|
|
11909
|
+
const p = await uploadImageBlob(blobs[i]);
|
|
11910
|
+
if (p) paths.push(p);
|
|
11911
|
+
}
|
|
11912
|
+
if (!paths.length) return;
|
|
11913
|
+
const target = (anchorEl && document.body.contains(anchorEl)) ? anchorEl : lastBlockEl();
|
|
11914
|
+
if (!target) { showBanner(TOOLBAR_NO_ANCHOR_MESSAGE, null, null); return; }
|
|
11915
|
+
const newLines = [];
|
|
11916
|
+
paths.forEach((p, i) => {
|
|
11917
|
+
if (i > 0) newLines.push('');
|
|
11918
|
+
newLines.push(' + ')');
|
|
11919
|
+
});
|
|
11920
|
+
await insertBlockBelow(target, 'paragraph', { lines: newLines });
|
|
11921
|
+
}
|
|
11922
|
+
|
|
11923
|
+
// v3.2.1: 「插入圖片竟然無法在我當前的位置後插入,每次都跑到最後」. Two
|
|
11924
|
+
// separate mechanisms put the image at EOF, and BOTH were measured — the
|
|
11925
|
+
// second one is the reason this function is longer than it looks like it
|
|
11926
|
+
// needs to be.
|
|
11927
|
+
//
|
|
11928
|
+
// (a) `input.click()` below is a SYNTHETIC click. It bubbles to the
|
|
11929
|
+
// document delegator, which reads it as a click landing outside every
|
|
11930
|
+
// block → switchAwayFrom() → commit + re-render → the anchor node
|
|
11931
|
+
// captured a moment earlier is detached → insertImages() falls back to
|
|
11932
|
+
// lastBlockEl() and the image lands at the end of the file.
|
|
11933
|
+
// Deterministic whenever the block the user is in is dirty, which is
|
|
11934
|
+
// exactly the reported gesture.
|
|
11935
|
+
//
|
|
11936
|
+
// (b) stopPropagation() on that click is NECESSARY BUT NOT SUFFICIENT. It
|
|
11937
|
+
// fixes the reported gesture, yet the anchor stayed a raw DOM node
|
|
11938
|
+
// held across the file dialog: a FULL render landing between the click
|
|
11939
|
+
// and the `change` event detaches it and the image is back at EOF.
|
|
11940
|
+
// The v3.2.0 patch path is not the exposure — two forced mid-dialog
|
|
11941
|
+
// renders (a commit in another block, a ⠿ 刪除 of another block) were
|
|
11942
|
+
// measured to patch and leave the anchor attached — but a full render
|
|
11943
|
+
// is one patchmap() miss away (it returns null whenever there is no
|
|
11944
|
+
// usable editRange), and a real OS file dialog stays open for as long
|
|
11945
|
+
// as the user takes. So the window is small, not hypothetical.
|
|
11946
|
+
//
|
|
11947
|
+
// Hence both halves: stop the synthetic click from reading as an
|
|
11948
|
+
// outside-click, AND resolve the open session BEFORE the picker opens while
|
|
11949
|
+
// carrying the anchor as a stable IDENTITY that is re-resolved when the
|
|
11950
|
+
// files come back, instead of as a node reference. The identity triple is
|
|
11951
|
+
// insertBlockBelow()'s (captureBlockIdentity / ownsOpenSession /
|
|
11952
|
+
// reresolveBlockEl + reresolveBlockElAfterSelfCommit) — that function solved
|
|
11953
|
+
// this same problem for its own anchor and the image path simply never
|
|
11954
|
+
// adopted it. Do not "simplify" the re-resolution away: it is the half that
|
|
11955
|
+
// survives the dialog.
|
|
11956
|
+
//
|
|
11957
|
+
// ⚠ ORDERING: `await switchAwayFrom()` runs BEFORE input.click(). Chrome
|
|
11958
|
+
// gates the file picker on TRANSIENT user activation, which is time-based
|
|
11959
|
+
// (~5 s) and is NOT spent by awaiting a promise, so the picker still opens.
|
|
11960
|
+
// The journey scenario presses 🖼 on a DIRTY block, so a real commit — an
|
|
11961
|
+
// /api/render round trip — runs inside that await; if the activation were
|
|
11962
|
+
// spent, waitForFileChooser() there would time out. A commit slower than
|
|
11963
|
+
// the activation window would be the one way this ordering could bite.
|
|
11964
|
+
async function pickAndInsertImage() {
|
|
11965
|
+
// Captured BEFORE switchAwayFrom(), because that is what can renumber the
|
|
11966
|
+
// ids and rewrite the source — see captureBlockIdentity()'s comment.
|
|
11967
|
+
const blockEl = liveToolbarBlock() || lastBlockEl();
|
|
11968
|
+
const identity = captureBlockIdentity(blockEl);
|
|
11969
|
+
// The commit inside switchAwayFrom() can be a rewrite of THIS block's own
|
|
11970
|
+
// source, in which case reresolveBlockEl()'s fingerprint is guaranteed to
|
|
11971
|
+
// miss because WE are the reason the source changed; the narrowed
|
|
11972
|
+
// re-resolve is used only then. Same reasoning as insertBlockBelow().
|
|
11973
|
+
const selfSession = ownsOpenSession(blockEl);
|
|
11974
|
+
if (!(await switchAwayFrom())) return;
|
|
11975
|
+
const anchorEl = (blockEl && document.body.contains(blockEl))
|
|
11976
|
+
? blockEl
|
|
11977
|
+
: (reresolveBlockEl(identity) ||
|
|
11978
|
+
(selfSession ? reresolveBlockElAfterSelfCommit(identity) : null));
|
|
11979
|
+
if (!anchorEl) { showBanner(TOOLBAR_NO_ANCHOR_MESSAGE, null, null); return; }
|
|
11980
|
+
// Re-captured AFTER the commit: `identity` above holds the PRE-commit
|
|
11981
|
+
// fingerprint, which the commit we just awaited may itself have
|
|
11982
|
+
// invalidated. What has to survive the dialog is the anchor as it stands
|
|
11983
|
+
// now.
|
|
11984
|
+
const dialogIdentity = captureBlockIdentity(anchorEl);
|
|
11985
|
+
const input = document.createElement('input');
|
|
11986
|
+
input.type = 'file';
|
|
11987
|
+
input.accept = 'image/png,image/jpeg,image/gif,image/webp';
|
|
11988
|
+
input.multiple = true;
|
|
11989
|
+
input.hidden = true;
|
|
11990
|
+
// (a) above.
|
|
11991
|
+
input.addEventListener('click', (e) => e.stopPropagation());
|
|
11992
|
+
document.body.appendChild(input);
|
|
11993
|
+
input.addEventListener('change', () => {
|
|
11994
|
+
const files = input.files ? Array.prototype.slice.call(input.files) : [];
|
|
11995
|
+
input.remove();
|
|
11996
|
+
if (!files.length) return;
|
|
11997
|
+
// (b) above. A render that landed while the dialog was open detached
|
|
11998
|
+
// `anchorEl`; the identity still names the block the user pointed at.
|
|
11999
|
+
// If even that misses, the block the user aimed at no longer exists as
|
|
12000
|
+
// it was — refuse (and skip the upload, so no orphan lands in the
|
|
12001
|
+
// user's assets/) rather than silently appending at EOF, which is the
|
|
12002
|
+
// very symptom being fixed.
|
|
12003
|
+
const live = document.body.contains(anchorEl)
|
|
12004
|
+
? anchorEl : reresolveBlockEl(dialogIdentity);
|
|
12005
|
+
if (!live) { showBanner(TOOLBAR_NO_ANCHOR_MESSAGE, null, null); return; }
|
|
12006
|
+
insertImages(files, live);
|
|
12007
|
+
});
|
|
12008
|
+
input.click();
|
|
12009
|
+
}
|
|
12010
|
+
|
|
12011
|
+
function dragCarriesFiles(e) {
|
|
12012
|
+
const dt = e.dataTransfer;
|
|
12013
|
+
if (!dt || !dt.types) return false;
|
|
12014
|
+
return Array.prototype.indexOf.call(dt.types, 'Files') !== -1;
|
|
12015
|
+
}
|
|
12016
|
+
|
|
12017
|
+
function wireImageDrop() {
|
|
12018
|
+
// ⚠ BOTH listeners are gated on `docMode === 'edit'`, and that gate is
|
|
12019
|
+
// load-bearing rather than tidy-minded.
|
|
12020
|
+
//
|
|
12021
|
+
// In SOURCE mode `enterSourceMode()` only sets `contentEl.hidden = true` —
|
|
12022
|
+
// every `.ed-block` is still in the DOM, so `lastBlockEl()` resolves and an
|
|
12023
|
+
// ungated drop ran the whole upload plus an `insertBlockBelow()` commit
|
|
12024
|
+
// behind the hidden content, against `lines`. The textarea the user is
|
|
12025
|
+
// looking at still held the PRE-drop source, and `leaveSourceMode()` then
|
|
12026
|
+
// commits `commitRangeEdit(1, lines.length, ta.value)` over the post-drop
|
|
12027
|
+
// `lines` — erasing the reference that was just inserted. MEASURED:
|
|
12028
|
+
// '# Doc\n\nBody para.\n' -> source mode -> drop x.png -> type EDITED ->
|
|
12029
|
+
// leave, and the file ends '# Doc\n\nBody para.\n\nEDITED\n' with
|
|
12030
|
+
// assets/x.png orphaned on disk. No error, no visible effect, and a stray
|
|
12031
|
+
// binary left in what is usually a git working tree.
|
|
12032
|
+
//
|
|
12033
|
+
// v3.2.1: `!== 'edit'` rather than `=== 'source'` on purpose. Source is
|
|
12034
|
+
// the only other mode today, but the gate's reason is "the content the
|
|
12035
|
+
// user is looking at is not the content a drop would write into" — a
|
|
12036
|
+
// future third mode inherits the refusal rather than the hole.
|
|
12037
|
+
document.addEventListener('dragover', (e) => {
|
|
12038
|
+
if (docMode !== 'edit') return;
|
|
12039
|
+
if (!dragCarriesFiles(e)) return;
|
|
12040
|
+
e.preventDefault();
|
|
12041
|
+
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
|
|
12042
|
+
});
|
|
12043
|
+
document.addEventListener('drop', (e) => {
|
|
12044
|
+
if (docMode !== 'edit') return;
|
|
12045
|
+
if (!dragCarriesFiles(e)) return;
|
|
12046
|
+
const files = e.dataTransfer
|
|
12047
|
+
? Array.prototype.slice.call(e.dataTransfer.files || [])
|
|
12048
|
+
: [];
|
|
12049
|
+
const images = files.filter((f) => !!(assetLib && assetLib.extFor(f.type)));
|
|
12050
|
+
if (!images.length) return; // not ours: let the page do whatever it would
|
|
12051
|
+
e.preventDefault();
|
|
12052
|
+
const anchorEl = (e.target && e.target.closest) ? e.target.closest('.ed-block') : null;
|
|
12053
|
+
insertImages(images, anchorEl || liveToolbarBlock() || lastBlockEl());
|
|
12054
|
+
});
|
|
12055
|
+
}
|
|
12056
|
+
|
|
12057
|
+
// ── 追加 2: paste ──────────────────────────────────────────────────────
|
|
12058
|
+
// Reads a real DataTransfer into the plain { 'mime/type': string | Blob }
|
|
12059
|
+
// shape paste-md.js's pickPayload() documents. Only ONE image flavour is
|
|
12060
|
+
// taken: pickPayload picks the first it finds anyway, and a clipboard can
|
|
12061
|
+
// carry several encodings of the same bitmap.
|
|
12062
|
+
function clipboardItemsOf(dt) {
|
|
12063
|
+
const items = {};
|
|
12064
|
+
if (!dt) return items;
|
|
12065
|
+
try { items['text/plain'] = dt.getData('text/plain') || ''; } catch (err) { items['text/plain'] = ''; }
|
|
12066
|
+
try { items['text/html'] = dt.getData('text/html') || ''; } catch (err) { items['text/html'] = ''; }
|
|
12067
|
+
const files = dt.files ? Array.prototype.slice.call(dt.files) : [];
|
|
12068
|
+
for (let i = 0; i < files.length; i += 1) {
|
|
12069
|
+
if (files[i] && String(files[i].type).indexOf('image/') === 0) {
|
|
12070
|
+
items[files[i].type] = files[i];
|
|
12071
|
+
break;
|
|
12072
|
+
}
|
|
12073
|
+
}
|
|
12074
|
+
return items;
|
|
12075
|
+
}
|
|
12076
|
+
|
|
12077
|
+
// A converted payload that is ONE line carrying no markdown syntax at all
|
|
12078
|
+
// is indistinguishable from the plain-text paste this editor has always
|
|
12079
|
+
// done — and that case (copy a word or a phrase out of a browser, paste it
|
|
12080
|
+
// mid sentence) must keep landing at the caret rather than becoming a new
|
|
12081
|
+
// block. Anything else is real structure and takes the source path.
|
|
12082
|
+
//
|
|
12083
|
+
// The test is split in two because markdown's characters are NOT all alike.
|
|
12084
|
+
// The first cut used one character class for both kinds and put '-', '#'
|
|
12085
|
+
// and '!' in it — which re-introduced exactly the regression this test
|
|
12086
|
+
// exists to prevent, since 'e-mail', 'state-of-the-art' and 'Hello!' are
|
|
12087
|
+
// ordinary prose that would have become new paragraph blocks. Those three
|
|
12088
|
+
// only begin a construct at LINE POSITION, so they belong in the
|
|
12089
|
+
// start-anchored test, not the anywhere test. ('![' is still caught: the
|
|
12090
|
+
// '[' is in the anywhere class.)
|
|
12091
|
+
const TOOLBAR_INLINE_UNSAFE_RE = /[*_`~\[\]<>|\\]/;
|
|
12092
|
+
const TOOLBAR_BLOCK_START_RE =
|
|
12093
|
+
/^(?: {4}|\t|#{1,6}(?:\s|$)|[-*+][ \t]|\d+[.)][ \t]|>|`{3}|~{3}|-{3}|={3})/;
|
|
12094
|
+
function isInlineSafePaste(md) {
|
|
12095
|
+
if (md === '' || md.indexOf('\n') !== -1) return false;
|
|
12096
|
+
if (TOOLBAR_INLINE_UNSAFE_RE.test(md)) return false;
|
|
12097
|
+
return !TOOLBAR_BLOCK_START_RE.test(md);
|
|
12098
|
+
}
|
|
12099
|
+
|
|
12100
|
+
function handleArmedPaste(dt, editEl) {
|
|
12101
|
+
const items = clipboardItemsOf(dt);
|
|
12102
|
+
const carriesSomething = Object.keys(items).some((k) => (
|
|
12103
|
+
typeof items[k] === 'string' ? items[k] !== '' : !!items[k]));
|
|
12104
|
+
// An EMPTY paste is not the paste the gesture asked for, and must not
|
|
12105
|
+
// consume the Ctrl+Shift+V arming. MEASURED in headless Chromium: the
|
|
12106
|
+
// Ctrl+Shift+V keystroke ITSELF dispatches a paste event carrying no
|
|
12107
|
+
// flavours at all (`types: []`, `getData('text/plain') === ''`) before
|
|
12108
|
+
// any real clipboard content arrives. Consuming `plainPasteArmed` there
|
|
12109
|
+
// disarmed the very paste the user had just asked to be plain — the
|
|
12110
|
+
// gesture silently degraded to a normal rich paste. Returning early also
|
|
12111
|
+
// saves an insertTextAtCaret('') that would snapshot the burst for a
|
|
12112
|
+
// no-op edit.
|
|
12113
|
+
if (!carriesSomething) return;
|
|
12114
|
+
const plainOnly = plainPasteArmed;
|
|
12115
|
+
plainPasteArmed = false;
|
|
12116
|
+
const payload = pasteMd
|
|
12117
|
+
? pasteMd.pickPayload(items, plainOnly)
|
|
12118
|
+
: { kind: 'text', value: dt ? dt.getData('text/plain') : '' };
|
|
12119
|
+
if (payload.kind === 'image') {
|
|
12120
|
+
insertImages([payload.blob], editEl.closest('.ed-block'));
|
|
12121
|
+
return;
|
|
12122
|
+
}
|
|
12123
|
+
if (payload.kind === 'markdown') {
|
|
12124
|
+
const md = String(payload.value == null ? '' : payload.value).replace(/\r\n?/g, '\n');
|
|
12125
|
+
if (md.trim() === '') return;
|
|
12126
|
+
if (isInlineSafePaste(md)) {
|
|
12127
|
+
insertTextAtCaret(md);
|
|
12128
|
+
snapBurstIfActive(editEl, 'paste');
|
|
12129
|
+
return;
|
|
12130
|
+
}
|
|
12131
|
+
// Landed as SOURCE below the caret's block, never spliced into the
|
|
12132
|
+
// WYSIWYG DOM: that is what guarantees it passes through
|
|
12133
|
+
// commitBlockInsertion()/replaceLines() and therefore through
|
|
12134
|
+
// serializeBlocks()'s existing gates, instead of arriving as a pile of
|
|
12135
|
+
// foreign nodes the serializer would have to make sense of afterwards.
|
|
12136
|
+
insertBlockBelow(editEl.closest('.ed-block'), 'paragraph', { lines: md.split('\n') });
|
|
12137
|
+
return;
|
|
12138
|
+
}
|
|
12139
|
+
insertTextAtCaret(payload.value || '');
|
|
12140
|
+
snapBurstIfActive(editEl, 'paste');
|
|
12141
|
+
}
|
|
12142
|
+
|
|
12143
|
+
function wirePlainPasteArming() {
|
|
12144
|
+
// Capture phase: this only records intent, and must do so before any
|
|
12145
|
+
// other keydown handler can preventDefault() its way past it.
|
|
12146
|
+
document.addEventListener('keydown', (e) => {
|
|
12147
|
+
plainPasteArmed = !!((e.ctrlKey || e.metaKey) && e.shiftKey &&
|
|
12148
|
+
(e.key === 'v' || e.key === 'V'));
|
|
12149
|
+
}, true);
|
|
12150
|
+
}
|
|
12151
|
+
|
|
12152
|
+
// ── 追加 4: the whole-document source escape hatch ─────────────────────
|
|
12153
|
+
// v3.2.1: applyPreviewEditability() lived here and was preview mode's ONLY
|
|
12154
|
+
// effect. Preview is gone (docsource.js's MODES comment carries the
|
|
12155
|
+
// measurement), and with it the last caller of this function.
|
|
12156
|
+
|
|
12157
|
+
function enterSourceMode() {
|
|
12158
|
+
// The pre-edit snapshot fromSource() diffs against. Omitting it makes
|
|
12159
|
+
// `changed` unconditionally true (docsource.js's documented fail-safe),
|
|
12160
|
+
// i.e. a needless write of byte-identical content on every exit.
|
|
12161
|
+
sourceBaseLines = lines.slice();
|
|
12162
|
+
const ta = document.createElement('textarea');
|
|
12163
|
+
ta.className = 'ed-source';
|
|
12164
|
+
ta.setAttribute('aria-label', '文件原始碼');
|
|
12165
|
+
ta.value = docSourceLib.toSource(lines, EOL);
|
|
12166
|
+
contentEl.hidden = true;
|
|
12167
|
+
contentEl.parentNode.insertBefore(ta, contentEl);
|
|
12168
|
+
sourceTextarea = ta;
|
|
12169
|
+
ta.focus();
|
|
12170
|
+
}
|
|
12171
|
+
|
|
12172
|
+
// Returns false when the commit's render failed — the textarea is then left
|
|
12173
|
+
// standing with the user's text intact (a failed round trip must never be
|
|
12174
|
+
// the thing that discards a whole document's edit), and the mode does not
|
|
12175
|
+
// advance.
|
|
12176
|
+
async function leaveSourceMode() {
|
|
12177
|
+
const ta = sourceTextarea;
|
|
12178
|
+
if (!ta) return true;
|
|
12179
|
+
const parsed = docSourceLib.fromSource(ta.value, EOL, sourceBaseLines);
|
|
12180
|
+
if (parsed.changed) {
|
|
12181
|
+
// 全量置換 through the SAME replaceLines() pipeline every other edit
|
|
12182
|
+
// uses — one commitRangeEdit over the whole document, therefore one
|
|
12183
|
+
// undo op, and no second writer of `lines`.
|
|
12184
|
+
const result = commitRangeEdit({ lines, blocks, stack }, 1, lines.length,
|
|
12185
|
+
parsed.lines.join('\n'));
|
|
12186
|
+
if (result.op !== null) {
|
|
12187
|
+
const prevLines = lines;
|
|
12188
|
+
lines = result.lines;
|
|
12189
|
+
setDirty();
|
|
12190
|
+
const editRange = result.op
|
|
12191
|
+
? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
|
|
12192
|
+
endLine: Math.max(result.op.startLine - 1, result.op.endLine),
|
|
12193
|
+
delta: result.op.after.length - result.op.before.length }
|
|
12194
|
+
: undefined;
|
|
12195
|
+
const ok = await safeRerenderAll(editRange);
|
|
12196
|
+
if (!ok) {
|
|
12197
|
+
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
12198
|
+
setDirty();
|
|
12199
|
+
return false;
|
|
12200
|
+
}
|
|
12201
|
+
}
|
|
12202
|
+
}
|
|
12203
|
+
sourceTextarea = null;
|
|
12204
|
+
sourceBaseLines = null;
|
|
12205
|
+
ta.remove();
|
|
12206
|
+
contentEl.hidden = false;
|
|
12207
|
+
return true;
|
|
12208
|
+
}
|
|
12209
|
+
|
|
12210
|
+
async function cycleDocMode() {
|
|
12211
|
+
if (!docSourceLib) return;
|
|
12212
|
+
await setDocMode(docSourceLib.next(docMode));
|
|
12213
|
+
}
|
|
12214
|
+
|
|
12215
|
+
async function setDocMode(next) {
|
|
12216
|
+
if (next === docMode) return;
|
|
12217
|
+
if (docMode === 'edit') {
|
|
12218
|
+
// Same precondition every structural operation has: resolve whatever
|
|
12219
|
+
// session is open before the DOM it lives in is swapped or hidden.
|
|
12220
|
+
const ok = await switchAwayFrom();
|
|
12221
|
+
if (!ok) return;
|
|
12222
|
+
}
|
|
12223
|
+
if (docMode === 'source') {
|
|
12224
|
+
const ok = await leaveSourceMode();
|
|
12225
|
+
if (!ok) return;
|
|
12226
|
+
}
|
|
12227
|
+
docMode = next;
|
|
12228
|
+
if (docMode === 'source') enterSourceMode();
|
|
12229
|
+
document.body.setAttribute('data-ed-mode', docMode);
|
|
12230
|
+
paintModeStatus();
|
|
12231
|
+
updateToolbar();
|
|
12232
|
+
}
|
|
12233
|
+
|
|
12234
|
+
// v3.2.1: 模式必須說出來 —— 使用者按第二下以為卡住,其實是進了 source。
|
|
12235
|
+
// Called from setDocMode() AND from mountToolbar(), so the slot is truthful
|
|
12236
|
+
// from the first paint rather than only after the first mode change.
|
|
12237
|
+
function paintModeStatus() {
|
|
12238
|
+
const statusEl = document.querySelector('.ed-toolbar-status');
|
|
12239
|
+
if (statusEl) statusEl.textContent = docMode === 'source' ? '原始碼' : '編輯';
|
|
12240
|
+
}
|
|
12241
|
+
|
|
12242
|
+
// ── mount ──────────────────────────────────────────────────────────────
|
|
12243
|
+
function wireToolbarTracking() {
|
|
12244
|
+
// Which block the bar reports on. Delegated at document level (never a
|
|
12245
|
+
// per-block listener) for the same reason every other affordance in this
|
|
12246
|
+
// file is: blocks are rebuilt wholesale on every render.
|
|
12247
|
+
document.addEventListener('focusin', (e) => {
|
|
12248
|
+
if (!e.target || !e.target.closest) return;
|
|
12249
|
+
if (e.target.closest('.ed-toolbar') || e.target.closest('.ed-toolbar-menu')) return;
|
|
12250
|
+
const blockEl = e.target.closest('.ed-block');
|
|
12251
|
+
if (!blockEl) return;
|
|
12252
|
+
toolbarBlockEl = blockEl;
|
|
12253
|
+
updateToolbar();
|
|
12254
|
+
});
|
|
12255
|
+
document.addEventListener('click', (e) => {
|
|
12256
|
+
if (!e.target || !e.target.closest) return;
|
|
12257
|
+
if (e.target.closest('.ed-toolbar') || e.target.closest('.ed-toolbar-menu')) return;
|
|
12258
|
+
closeToolbarMenu();
|
|
12259
|
+
const blockEl = e.target.closest('.ed-block');
|
|
12260
|
+
if (blockEl) toolbarBlockEl = blockEl;
|
|
12261
|
+
updateToolbar();
|
|
12262
|
+
});
|
|
12263
|
+
// NOTE: there is deliberately NO selectionchange listener here — the
|
|
12264
|
+
// burst's own onSelectionChangeForToolbar() calls updateToolbar() for us.
|
|
12265
|
+
// See that function for why a second one must not be attached.
|
|
12266
|
+
}
|
|
12267
|
+
|
|
12268
|
+
function mountToolbar() {
|
|
12269
|
+
// An older cached page can be running this runtime without the v3.1.0
|
|
12270
|
+
// module tags. Degrade to "no toolbar" rather than throwing before
|
|
12271
|
+
// armEditables() has run.
|
|
12272
|
+
if (!toolbarModel) return;
|
|
12273
|
+
toolbarEl = buildToolbar();
|
|
12274
|
+
// ⚠ document.body, NOT contentEl. rerenderAll()'s fallback path does
|
|
12275
|
+
// `contentEl.innerHTML = j.parts.join('\n')` (v3.2.0: `/api/render`
|
|
12276
|
+
// answers `{ parts, blocks }` and there is no `bodyHtml` on the wire any
|
|
12277
|
+
// more — see applyFullRender()), replacing .content's children wholesale,
|
|
12278
|
+
// and only armEditables() rebuilds anything afterwards — it knows nothing
|
|
12279
|
+
// about a toolbar. A bar mounted inside .content would
|
|
12280
|
+
// vanish on the first commit with nothing left to bring it back. Every
|
|
12281
|
+
// persistent chrome node in this file is on document.body for exactly
|
|
12282
|
+
// this reason: the conflict banner, the selection toolbar, the table
|
|
12283
|
+
// grips and the table edge menu.
|
|
12284
|
+
document.body.appendChild(toolbarEl);
|
|
12285
|
+
document.body.setAttribute('data-ed-mode', docMode);
|
|
12286
|
+
paintModeStatus();
|
|
12287
|
+
wireToolbarTracking();
|
|
12288
|
+
wireImageDrop();
|
|
12289
|
+
wirePlainPasteArming();
|
|
12290
|
+
updateToolbar();
|
|
12291
|
+
}
|
|
12292
|
+
|
|
9440
12293
|
// ── global key handling ─────────────────────────────────────────────────
|
|
9441
12294
|
document.addEventListener('keydown', (e) => {
|
|
9442
12295
|
// ── S3 §4.4: Escape priority is drag > menu > selection > burst ─────
|
|
@@ -9817,8 +12670,13 @@
|
|
|
9817
12670
|
if (!editEl) return;
|
|
9818
12671
|
e.preventDefault();
|
|
9819
12672
|
const dt = e.clipboardData || window.clipboardData;
|
|
9820
|
-
|
|
9821
|
-
|
|
12673
|
+
// v3.1.0 追加 2: paste-md.js decides what the clipboard actually holds
|
|
12674
|
+
// (text/html -> markdown, an image, or plain text) and this routes the
|
|
12675
|
+
// answer. The TABLE-CELL branch above deliberately keeps its plain-text
|
|
12676
|
+
// behaviour: a cell cannot host a block, and a converted markdown table
|
|
12677
|
+
// pasted into one cell would be a nested structure the serializer has no
|
|
12678
|
+
// way to represent.
|
|
12679
|
+
handleArmedPaste(dt, editEl);
|
|
9822
12680
|
});
|
|
9823
12681
|
|
|
9824
12682
|
// Task 5: hover-edge insert bubbles — one delegated, rAF-throttled
|
|
@@ -9888,6 +12746,58 @@
|
|
|
9888
12746
|
});
|
|
9889
12747
|
});
|
|
9890
12748
|
|
|
12749
|
+
// v3.2.1: client.js 原本的 scroll 監聽器數量是 0,所以每一個 position:fixed
|
|
12750
|
+
// 的浮層在捲動後都停在原視窗座標。grip/bubble 有 mousemove 驅動可重新升起,
|
|
12751
|
+
// 隱藏它們就夠;.ed-seltb 沒有(它只由 selectionchange 與
|
|
12752
|
+
// applyMarkToggle/applyLinkToggle 驅動,而捲動不觸發 selectionchange),
|
|
12753
|
+
// 純隱藏會讓它永遠回不來,所以它要重新定位、只在選取整個離開視窗時才隱藏。
|
|
12754
|
+
// capture 階段是必要的:真正會自己捲的不是 .content(overflow-y: visible,
|
|
12755
|
+
// 實測 scrollHeight === clientHeight,捲的其實是 document)——是側欄的
|
|
12756
|
+
// .toc-list/#search-results-list(皆 overflow-y: auto,TOC 標題夠多時
|
|
12757
|
+
// 實測 scrollHeight > clientHeight)與 .lightbox-stage(overflow: auto)。
|
|
12758
|
+
// 這些容器的 scroll 事件不冒泡,只有 capture 階段收得到。
|
|
12759
|
+
// 副作用(已記錄,非缺陷):正因為 capture 連這些側欄容器的捲動都收,捲動
|
|
12760
|
+
// TOC 或搜尋結果清單時——即使正文內容一格都沒動——也會隱藏 table grips
|
|
12761
|
+
// 並關掉開著的 .ed-te-menu。
|
|
12762
|
+
//
|
|
12763
|
+
// 這個監聽器刻意是「模組層級、只掛一次、永不移除」,不跟著任何一次
|
|
12764
|
+
// burst 的生命週期走(不在 resetSelToolbarState() 裡拆)。理由:真正會
|
|
12765
|
+
// 刪資料的 .ed-te-menu/grip 家族(見上面 Task 5 的 mousemove 委派)不需要
|
|
12766
|
+
// 任何 burst 就能被叫出來 —— 使用者可以單純把游標移到表格上(觸發上面那個
|
|
12767
|
+
// 無條件掛載的 mousemove)升起 grip、打開 .ed-te-menu,此時完全沒有
|
|
12768
|
+
// selToolbarListener(那個監聽器只在 burst 開著時才存在)。若把這個捲動
|
|
12769
|
+
// 監聽器綁在 burst 身上,恰好在「grip/選單開著但沒有 burst」這個狀態下
|
|
12770
|
+
// 它會不存在。resetSelToolbarState() 本身也會在每次 rerender 時執行(六個
|
|
12771
|
+
// 呼叫點之一在本檔的 rerenderAll() 路徑上),把監聽器綁在它身上還會讓
|
|
12772
|
+
// 監聽器在 session 中途被拆掉、且永不重掛。
|
|
12773
|
+
let selScrollRaf = 0;
|
|
12774
|
+
function onAnyScroll() {
|
|
12775
|
+
if (tePointer && tePointer.dragging) return; // 拖曳中不動
|
|
12776
|
+
hideTableGrips();
|
|
12777
|
+
hideTableInsertBubbles();
|
|
12778
|
+
// v3.2.1 fix round 1: suppressEdgeMenuAutoHide — see its own comment —
|
|
12779
|
+
// is set for runCycleAlign()'s ensureTableBurstOpen() span, whose
|
|
12780
|
+
// cell.focus() can itself be the cause of this very scroll event; that
|
|
12781
|
+
// operation repositions the menu itself afterward, so skip tearing its
|
|
12782
|
+
// state down here (grip/bubble/toolbar-menu hiding above and below is
|
|
12783
|
+
// unaffected — they re-raise on the next mousemove regardless).
|
|
12784
|
+
if (!suppressEdgeMenuAutoHide) hideTableEdgeMenu(); // ← 真正會刪資料的是選單,不是 grip
|
|
12785
|
+
closeToolbarMenu();
|
|
12786
|
+
if (selScrollRaf) return;
|
|
12787
|
+
selScrollRaf = requestAnimationFrame(() => {
|
|
12788
|
+
selScrollRaf = 0;
|
|
12789
|
+
if (!selToolbar.parentNode) return; // 沒升起 → 0 次 layout read
|
|
12790
|
+
const sel = window.getSelection();
|
|
12791
|
+
if (!sel || !sel.rangeCount || sel.isCollapsed) { hideSelToolbar(); return; }
|
|
12792
|
+
const range = sel.getRangeAt(0);
|
|
12793
|
+
if (!selToolbarEditEl ||
|
|
12794
|
+
!selToolbarEditEl.contains(range.startContainer) ||
|
|
12795
|
+
!selToolbarEditEl.contains(range.endContainer)) { hideSelToolbar(); return; }
|
|
12796
|
+
positionSelToolbar(range);
|
|
12797
|
+
});
|
|
12798
|
+
}
|
|
12799
|
+
document.addEventListener('scroll', onAnyScroll, { passive: true, capture: true });
|
|
12800
|
+
|
|
9891
12801
|
window.addEventListener('beforeunload', (e) => {
|
|
9892
12802
|
if (stack.dirtyDepth !== 0) {
|
|
9893
12803
|
e.preventDefault();
|
|
@@ -9908,4 +12818,8 @@
|
|
|
9908
12818
|
|
|
9909
12819
|
armEditables(contentEl);
|
|
9910
12820
|
wireBlockSelection();
|
|
12821
|
+
// v3.1.0 §4: last, so every const it closes over (stack, BLOCK_SKELETONS,
|
|
12822
|
+
// selToolbar, …) is initialised and the bar's first deriveState() sees the
|
|
12823
|
+
// armed document rather than an empty one.
|
|
12824
|
+
mountToolbar();
|
|
9911
12825
|
})();
|