@helping-ai-workflow/md2doc 3.0.1 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/editor/asset.js +235 -0
- package/lib/editor/client.js +2017 -85
- package/lib/editor/docsource.js +105 -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/table-md.js +66 -34
- package/lib/editor/toolbar-model.js +160 -0
- package/lib/md2doc.js +176 -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,8 +868,567 @@
|
|
|
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
|
+
// Fix round 1 (review Important 1): `blocks`/`lastParts` are the JOIN KEY
|
|
1027
|
+
// between the module's model and the DOM, and they are overwritten BEFORE
|
|
1028
|
+
// the DOM is touched — armEditables() reads the new `blocks` (see its call
|
|
1029
|
+
// below), so the order is forced. That opens a window in which a throw
|
|
1030
|
+
// would leave the model describing a DOM that was never built: the
|
|
1031
|
+
// exception bubbles to safeRerenderAll()'s catch, the user is told "Your
|
|
1032
|
+
// edit was not applied", `false` comes back — and the NEXT commit then
|
|
1033
|
+
// diffs parts describing blocks that do not exist, so every index is
|
|
1034
|
+
// wrong and nothing reports it. Same silent-failure shape as ruling F4.
|
|
1035
|
+
//
|
|
1036
|
+
// applyFullRender() has no such window: its `blocks`/`lastParts` →
|
|
1037
|
+
// innerHTML → armEditables() order means the same throw leaves a
|
|
1038
|
+
// consistent "DOM swapped, just not armed" state that the next render
|
|
1039
|
+
// heals. So the patch path buys the equivalent guarantee explicitly:
|
|
1040
|
+
// capture, and put the whole mutation window in a try that rolls the two
|
|
1041
|
+
// back and lets the FALLBACK land the commit instead of losing it.
|
|
1042
|
+
const prevBlocks = blocks;
|
|
1043
|
+
const prevLastParts = lastParts;
|
|
1044
|
+
blocks = j.blocks;
|
|
1045
|
+
lastParts = j.parts;
|
|
1046
|
+
|
|
1047
|
+
try {
|
|
1048
|
+
// <template> 是刻意指名的,不是「隨便一種容器」:
|
|
1049
|
+
// createDocumentFragment() + innerHTML -> innerHTML 不存在,什麼都插不進去、不報錯
|
|
1050
|
+
// Range.createContextualFragment() -> 文件裡作者手寫的 raw <script> 每次 patch 重新執行
|
|
1051
|
+
// 暫存 <div> 也正確,只多一圈 child drain。
|
|
1052
|
+
const tpl = document.createElement('template');
|
|
1053
|
+
tpl.innerHTML = plan.replaceSpan.newParts.join('\n');
|
|
1054
|
+
// 必須在 blocks 已換成新陣列之後——armEditables 的 li 分支走
|
|
1055
|
+
// canWysiwygForLi → blockOwnsNoLine,那支讀的是模組層的 `blocks`;
|
|
1056
|
+
// 用舊陣列判斷會把一個「不擁有任何來源行」的 li 誤武裝成 contenteditable。
|
|
1057
|
+
//
|
|
1058
|
+
// ⚠ 只對新片段呼叫,永遠不要對被保留的前綴/後綴節點再跑一次:
|
|
1059
|
+
// armEditables 不是 idempotent(⠿ handle 與 + button 是無條件
|
|
1060
|
+
// appendChild),double-arm 實測會變成 10 個 handle 而不是 5 個。
|
|
1061
|
+
armEditables(tpl.content);
|
|
1062
|
+
|
|
1063
|
+
// 保留節點永不 detach:只移除中段,其餘原地不動。
|
|
1064
|
+
// 不得用 replaceChildren() 或 remove-and-re-append 重建整份子節點列表——
|
|
1065
|
+
// 實測 detach 再插回同一位置會讓 document.activeElement 掉回 BODY。
|
|
1066
|
+
//
|
|
1067
|
+
// Fix round 1 (review, comment-only): this removes ELEMENTS, so the `\n`
|
|
1068
|
+
// TEXT nodes `parts.join('\n')` left between the removed blocks stay
|
|
1069
|
+
// behind. Harmless today — `contentEl.children` and allBlockEls()'s
|
|
1070
|
+
// querySelectorAll both skip text nodes, and nothing in this file reads
|
|
1071
|
+
// `contentEl.childNodes` — but it does mean `.content`'s child-NODE shape
|
|
1072
|
+
// after a patch differs from its shape after a full render, which would
|
|
1073
|
+
// matter to any future test that asserts on childNodes rather than
|
|
1074
|
+
// children.
|
|
1075
|
+
for (let i = plan.replaceSpan.oldStart; i <= plan.replaceSpan.oldEnd; i++) {
|
|
1076
|
+
if (children[i]) contentEl.removeChild(children[i]);
|
|
1077
|
+
}
|
|
1078
|
+
// 參考節點是「第一個被保留的後綴節點」。它在 children 快照裡的 index 是
|
|
1079
|
+
// oldEnd + 1,而且因為後綴從未被 detach,那個 object 現在仍在 DOM 裡。
|
|
1080
|
+
// 後綴為空時是 null,insertBefore(frag, null) 等同 append。
|
|
1081
|
+
const refNode = children[plan.replaceSpan.oldEnd + 1] || null;
|
|
1082
|
+
contentEl.insertBefore(tpl.content, refNode); // fragment 會被排空並保序
|
|
1083
|
+
|
|
1084
|
+
// 被保留的後綴節點的 data-block-id 已經過期(id 是位置序號)。
|
|
1085
|
+
// 前綴恆為 0:id 即索引,前綴在兩份陣列裡都佔 0..k-1。
|
|
1086
|
+
if (plan.idDelta !== 0) {
|
|
1087
|
+
const all = contentEl.children;
|
|
1088
|
+
for (let i = all.length - plan.keepSuffix; i < all.length; i++) {
|
|
1089
|
+
const el = all[i];
|
|
1090
|
+
const oldId = Number(el.getAttribute('data-block-id'));
|
|
1091
|
+
el.setAttribute('data-block-id', String(oldId + plan.idDelta));
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
} catch (e) {
|
|
1095
|
+
// Roll the model back to what the (still-unmutated, or only partly
|
|
1096
|
+
// mutated) DOM last agreed with, then land the commit through the
|
|
1097
|
+
// fallback rather than losing it — applyFullRender() rebuilds .content
|
|
1098
|
+
// wholesale, so it is correct from a half-patched DOM too.
|
|
1099
|
+
//
|
|
1100
|
+
// ⚠ `window.scrollY` here does NOT violate ruling F3. F3 forbids
|
|
1101
|
+
// re-applying rerenderAll()'s STALE pre-fetch snapshot on the patch
|
|
1102
|
+
// path, because the patch path never collapses the scroll extent. This
|
|
1103
|
+
// is the CURRENT scroll position, read at this instant, handed to an
|
|
1104
|
+
// innerHTML swap that genuinely does collapse it. Different value,
|
|
1105
|
+
// different reason. Do not "correct" it to the `scrollY` parameter —
|
|
1106
|
+
// applyPatch() deliberately does not take one.
|
|
1107
|
+
blocks = prevBlocks;
|
|
1108
|
+
lastParts = prevLastParts;
|
|
1109
|
+
return applyFullRender(j, window.scrollY, declaredRange);
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// ── teardown ────────────────────────────────────────────────────────
|
|
1113
|
+
// Controller ruling F1: the patch path runs the SAME twelve teardown items
|
|
1114
|
+
// applyFullRender() runs, unconditionally and in the same relative order.
|
|
1115
|
+
// The rationale comments below are copied from applyFullRender() because
|
|
1116
|
+
// they document WHY each one exists; the one thing that differs is the
|
|
1117
|
+
// trigger — there the nodes were detached by the innerHTML replacement,
|
|
1118
|
+
// here they were detached by the removeChild loop above (or, for state
|
|
1119
|
+
// that points at a node in the KEPT span, they were not detached at all,
|
|
1120
|
+
// which is exactly why leaving the state standing is not automatically
|
|
1121
|
+
// safe either: `blocks` and every data-block-id under it just changed).
|
|
1122
|
+
//
|
|
1123
|
+
// Fix round 1 (review, comment-only) — the consequence a future change to
|
|
1124
|
+
// this list has to know about: the fallback's innerHTML replacement also
|
|
1125
|
+
// WIPES any uncommitted burst text, which is what keeps the DOM and
|
|
1126
|
+
// `lines[]` consistent when item 3 disposes the burst. The patch path
|
|
1127
|
+
// leaves that text sitting in a kept node while `currentBurst` has already
|
|
1128
|
+
// been disposed. Unreachable today — every call site goes through
|
|
1129
|
+
// switchAwayFrom() first, which is what applyFullRender()'s own comment
|
|
1130
|
+
// calls "technically unreachable" — but that is precisely what this
|
|
1131
|
+
// belt-and-braces teardown is insurance against, and on THIS path a future
|
|
1132
|
+
// call site that forgets switchAwayFrom() leaves text visible in the DOM,
|
|
1133
|
+
// absent from `lines[]`, with its undo history already disposed.
|
|
1134
|
+
//
|
|
1135
|
+
// Whatever editor (if any) was open a moment ago may have just been
|
|
1136
|
+
// detached by the span removal above — its own restore()/commit() never
|
|
1137
|
+
// ran, so it never got a chance to null this out itself. On the FALLBACK
|
|
1138
|
+
// path this is done unconditionally, which is what makes the undo/redo
|
|
1139
|
+
// lockout regression (see the `activeEditor` comment above) structurally
|
|
1140
|
+
// impossible even from a call site that forgets to call switchAwayFrom().
|
|
1141
|
+
//
|
|
1142
|
+
// ── Final review item 1 / 決議 3(1) 的 activeEditor 對稱項 ───────────
|
|
1143
|
+
// 決議 3(1) 的論證逐字適用在這裡,而它原本沒有被問過:fallback 之所以
|
|
1144
|
+
// 可以無條件歸零,是因為 `contentEl.innerHTML = ` 把每個節點都拔掉了
|
|
1145
|
+
// ——面也一起從螢幕上消失。patch 保留節點,而 openRawEditor() 的
|
|
1146
|
+
// `.ed-raw` 包裝是塞進**區塊自己**(`blockEl.innerHTML = '';
|
|
1147
|
+
// blockEl.appendChild(wrap)`),所以 contentEl 的子節點數與每一個
|
|
1148
|
+
// data-block-id 都沒有變、domMatchesLastRender() 照過、patch 照跑——
|
|
1149
|
+
// 落在前綴/後綴裡的 textarea 於是活過 patch,而這一行把它變成沒人
|
|
1150
|
+
// 追蹤的殭屍:switchAwayFrom() 再也看不到它,點開別處不會自動提交,
|
|
1151
|
+
// 而下一次任何 rerenderAll() 會把它連同未提交的文字一起蓋掉。
|
|
1152
|
+
//
|
|
1153
|
+
// 實測可達(scratchpad/item1-scenarioB.js,兩次 run 一致)。序列是
|
|
1154
|
+
// 「render 往返途中開一個 raw editor」,與決議 3(2) 為 currentBurst 記下
|
|
1155
|
+
// 的那條 race 同形,只是觸發點必須是**在 fetch 之前就 settle 掉
|
|
1156
|
+
// switchAwayFrom() 的**站點(undo()/redo()/changeHeadingDepth()…),
|
|
1157
|
+
// 因為 resolveBurst() 自己那條路徑把整個 render 關在 `switching` 的
|
|
1158
|
+
// single-flight 裡,反而把並行的點擊序列化掉:
|
|
1159
|
+
// openRawEditor-entry {blockId:'2', currentBurst:false, activeEditor:false}
|
|
1160
|
+
// dispatch {patch:true, keepPrefix:4, oldStart:4, oldEnd:4,
|
|
1161
|
+
// activeEditor:true, rawTextareas:1}
|
|
1162
|
+
// patch-teardown {activeEditor:true, activeEditorBlockAttached:true,
|
|
1163
|
+
// activeEditorBlockId:'2', rawTextareas:1}
|
|
1164
|
+
// 接著在存活的 textarea 裡打字、點到別的區塊、Ctrl+S:
|
|
1165
|
+
// [after clicking away] {rawTextareas:1, rawValue:'…const LOST = 2;'}
|
|
1166
|
+
// [file on disk] '# Doc\n\nCode anchor.\n\n```js\nconst x = 1;\n```\n…'
|
|
1167
|
+
// [LOST typing present in file?] NO — data loss
|
|
1168
|
+
// 螢幕上看得到,檔案裡沒有——與決議 3(1) 為 burst 記下的完全同一種缺陷。
|
|
1169
|
+
//
|
|
1170
|
+
// 判準與 burst 那項同形,而且必須同形:實測
|
|
1171
|
+
// `document.body.contains(activeEditor.blockEl)`,不得由 patchmap 的
|
|
1172
|
+
// index 算術推論。推論錯的代價是 activeEditor 指著已 detach 的節點,
|
|
1173
|
+
// 正好是上面那段「undo/redo lockout」在防的事——所以節點沒活下來時
|
|
1174
|
+
// 一律走原本的無條件歸零,lockout 仍然結構上不可能。
|
|
1175
|
+
//
|
|
1176
|
+
// 多一道 burst 不需要的閘門:burst 的 `blockId` 是唯一會 stale 的欄位,
|
|
1177
|
+
// 其餘都從新的 `blocks` 現查;openRawEditor() 的 session 則把
|
|
1178
|
+
// `blockId`/`startLine`/`endLine` 三個都閉包成常數,所以留下來之前要先
|
|
1179
|
+
// 讓它把三者從現在的 DOM 屬性與新的 `blocks` 重新推導成功
|
|
1180
|
+
// (reanchor(),見那裡的註解——推導不出來就退回歸零)。
|
|
1181
|
+
if (!(activeEditor && activeEditor.blockEl
|
|
1182
|
+
&& document.body.contains(activeEditor.blockEl)
|
|
1183
|
+
&& activeEditor.reanchor())) {
|
|
1184
|
+
activeEditor = null;
|
|
1185
|
+
}
|
|
1186
|
+
// §10-gap fix (review): same belt-and-braces reasoning for
|
|
1187
|
+
// `pristineInsert` — its window is meant to close via one of the
|
|
1188
|
+
// explicit resolution hooks (resolveBurst()/revertBurstAndEnd()/
|
|
1189
|
+
// revertTableBurstAndEnd()/cancelAndMaybeDiscard()) BEFORE any
|
|
1190
|
+
// rerenderAll() reaches here.
|
|
1191
|
+
//
|
|
1192
|
+
// Final review item 1, second half — 這一項**刻意沒有**跟著上面開例外,
|
|
1193
|
+
// 而且理由不是「到不了」:同一條 race 一樣到得了這裡。是這個狀態被
|
|
1194
|
+
// 這次 render 本身作廢了。`pristineInsert` 的整個意思是「undo stack 的
|
|
1195
|
+
// **最頂端**那一筆就是我這個插入 op」——discardPristineInsert() 走
|
|
1196
|
+
// `stack.discardTop()`(lib/editor/lineops.js),它無條件 pop 最頂端那筆
|
|
1197
|
+
// 並就地反轉,不驗證那筆是不是自己的。而任何走到 applyPatch 的 render
|
|
1198
|
+
// 都帶著一個 truthy 的 editRange,也就是**剛剛有一筆 op 被推上 stack**
|
|
1199
|
+
// (commitRangeEdit / commitBlockInsertion / undo()/redo() 的搬動)。所以
|
|
1200
|
+
// patch 之後把 `pristineInsert` 留著,等於讓之後任何一次「放棄這個空
|
|
1201
|
+
// 區塊」去 pop 並反轉**別人的**那一筆——那是比它要防的孤兒佔位區塊更
|
|
1202
|
+
// 嚴重的失敗。要讓這一項變成可以開例外,得先讓 discardTop() 認得 op
|
|
1203
|
+
// 的 identity(例如 discardPristineInsert() 記下 op 物件本身、pop 前先
|
|
1204
|
+
// 比對),在那之前無條件歸零就是正解。
|
|
1205
|
+
pristineInsert = null;
|
|
1206
|
+
// ── Task G / 決議 3:teardown 的三項例外 ─────────────────────────
|
|
1207
|
+
// 判準必須是「burst 的編輯面在 patch 之後仍然連在文件上」的**節點
|
|
1208
|
+
// identity 實測**,不得由 index 算術推論。推論錯的代價是 burst 指著已
|
|
1209
|
+
// detach 的節點——正是上面 activeEditor / toolbarBlockEl 那兩段註解在防的
|
|
1210
|
+
// 事,而且跳過 dispose() 時若節點其實已被換掉,會同時留下一疊
|
|
1211
|
+
// innerHTML 快照與一個閉包住 detached 節點的 captureFn。
|
|
1212
|
+
//
|
|
1213
|
+
// 為什麼只在 patch 路徑需要這個例外:fallback 的 `contentEl.innerHTML = `
|
|
1214
|
+
// 把每一個節點都拔掉,所以 12 項 teardown 把任何編輯面孤兒化都是安全的
|
|
1215
|
+
// (螢幕上也一併消失)。patch 保留前綴與後綴節點,所以同一套 teardown
|
|
1216
|
+
// 會把一個螢幕上還在、焦點還在的面變成沒人追蹤的殭屍。
|
|
1217
|
+
//
|
|
1218
|
+
// 三項例外各自留在原位 gate,不抄成一個 if 區塊:ruling F1 要求這 12 項
|
|
1219
|
+
// 的**相對順序**與 applyFullRender() 一致,把 resetSelToolbarState() /
|
|
1220
|
+
// resetToolbarBlock() 搬到第 3 項旁邊才是真的改了順序。條件只算一次,
|
|
1221
|
+
// 而且算在會動到 currentBurst 的那一項之前(算完才輪得到把它歸零)。
|
|
1222
|
+
const burstSurvived = !!(currentBurst && currentBurst.editEl
|
|
1223
|
+
&& document.body.contains(currentBurst.editEl));
|
|
1224
|
+
// Task 2 (Phase 3): same belt-and-braces reasoning for the always-on
|
|
1225
|
+
// WYSIWYG burst — nulling here, not in resolveBurst(), is what keeps this
|
|
1226
|
+
// a single source of truth, mirroring activeEditor just above.
|
|
1227
|
+
// Task G: skipped when the burst's surface survived the patch — nulling it
|
|
1228
|
+
// there is what strands a live, focused, still-typeable surface with
|
|
1229
|
+
// nothing tracking it (the `input`/`focusout` handlers are both predicated
|
|
1230
|
+
// on `currentBurst.editEl === editEl`, and `focusin` only fires on
|
|
1231
|
+
// ENTERING, so focus that never left never reopens one).
|
|
1232
|
+
if (!burstSurvived && currentBurst) { currentBurst.history.dispose(); currentBurst = null; }
|
|
1233
|
+
// Task 5: the hover-edge insert bubbles and the row/column grip handles
|
|
1234
|
+
// are positioned against a specific table's geometry; a stale bubble left
|
|
1235
|
+
// visible (pointing at now-detached or re-rendered geometry) would
|
|
1236
|
+
// misbehave on the next click. hideTableGrips() clears their tracked
|
|
1237
|
+
// table/row/column references too.
|
|
1238
|
+
hideTableInsertBubbles();
|
|
1239
|
+
hideTableGrips();
|
|
1240
|
+
// Task 4 fix (review finding): the SAME reasoning applies to the
|
|
1241
|
+
// selection toolbar and its document-level selectionchange listener.
|
|
1242
|
+
// Every call site today resolves the session via switchAwayFrom() first
|
|
1243
|
+
// (making this technically unreachable), but that safety depends on every
|
|
1244
|
+
// FUTURE call site remembering to — exactly how the Task 3 listener-leak
|
|
1245
|
+
// regression happened. resetSelToolbarState() is idempotent.
|
|
1246
|
+
// Task G: travels with the `currentBurst` exception above, and must. The
|
|
1247
|
+
// `selectionchange` listener it removes is attached by startBurst() /
|
|
1248
|
+
// startTableBurst() and by NOTHING else, so on a surviving burst whose
|
|
1249
|
+
// focus never leaves there is no later focusin to re-attach it — measured,
|
|
1250
|
+
// the floating selection toolbar and the fixed bar's five inline-format
|
|
1251
|
+
// buttons (which piggyback on that same listener, and which this version's
|
|
1252
|
+
// `hasSelection` wiring also rides) stay dead until the user clicks away
|
|
1253
|
+
// and back.
|
|
1254
|
+
if (!burstSurvived) resetSelToolbarState();
|
|
1255
|
+
// Task 6: same belt-and-braces reasoning again for the edge-click menu
|
|
1256
|
+
// and any in-flight row drag.
|
|
1257
|
+
hideTableEdgeMenu();
|
|
1258
|
+
cancelTeDrag();
|
|
1259
|
+
// S4 Task 2: same belt-and-braces reasoning for an in-flight ⠿ block drag —
|
|
1260
|
+
// the gesture's ordinal refers to a block index that this patch may have
|
|
1261
|
+
// just renumbered. Nothing was mutated by the drag, so there is nothing to
|
|
1262
|
+
// revert; the state simply must not survive the render.
|
|
1263
|
+
cancelBlockDrag();
|
|
1264
|
+
// Final-review Finding 5b (Important): the ⠿ gutter menu — toggleGutterMenu()
|
|
1265
|
+
// appends the ONE shared `gutterMenu` node as a CHILD of whichever block
|
|
1266
|
+
// it's open for, so a removed block took it away without its own close
|
|
1267
|
+
// path ever running. Left uncleared, `gutterMenuBlockEl` keeps pointing at
|
|
1268
|
+
// a detached node and the NEXT toggleGutterMenu() call on that same stale
|
|
1269
|
+
// reference would treat the menu as already open instead of opening fresh.
|
|
1270
|
+
// closeGutterMenu() is idempotent.
|
|
1271
|
+
closeGutterMenu();
|
|
1272
|
+
// §10-gap fix: same belt-and-braces reasoning for the + insert menu —
|
|
1273
|
+
// it's the same "singleton node appended as a child of whichever block
|
|
1274
|
+
// it's open for" idiom as gutterMenu just above.
|
|
1275
|
+
closeInsertMenu();
|
|
1276
|
+
// v3.1.0 §4: and the SAME belt-and-braces reasoning one more time for the
|
|
1277
|
+
// toolbar's tracked block. `toolbarBlockEl` points INTO the subtree that
|
|
1278
|
+
// was just patched, and a detached node's getAttribute() still answers
|
|
1279
|
+
// perfectly happily: left standing, the bar would keep reporting the old
|
|
1280
|
+
// block's type/indent/heading depth and every button would act on a node
|
|
1281
|
+
// no click can ever reach again.
|
|
1282
|
+
// Task G: same exception, same reason — `toolbarBlockEl` is only ever
|
|
1283
|
+
// re-set on focusin / the click delegation, so clearing it under a burst
|
|
1284
|
+
// whose focus never leaves kills every block-dependent toolbar button until
|
|
1285
|
+
// the user clicks away and back (MEASURED on the smoke fixture: disabled
|
|
1286
|
+
// goes 7/22 -> 18/22, and nothing calls updateToolbar() again until the
|
|
1287
|
+
// next click). It has THREE further side effects, and all
|
|
1288
|
+
// three ride along safely: (1) the `docMode === 'preview'` re-arm cannot
|
|
1289
|
+
// matter here — preview sets every surface contenteditable="false", so no
|
|
1290
|
+
// burst can be open to survive; (2) updateToolbar() is re-derived from the
|
|
1291
|
+
// DOM on the next interaction; (3) closeToolbarMenu() — skipping it leaves
|
|
1292
|
+
// the H▾ dropdown standing across a patch, which is benign because
|
|
1293
|
+
// `toolbarMenuEl` is appended to document.body, not into a block, so the
|
|
1294
|
+
// patch never detaches it, and applyHeadingLevel() re-resolves through
|
|
1295
|
+
// liveToolbarBlock(), which self-heals a detached `toolbarBlockEl`.
|
|
1296
|
+
if (!burstSurvived) resetToolbarBlock();
|
|
1297
|
+
|
|
1298
|
+
// Task G step 2: re-derive the surviving burst's `blockId` FROM THE DOM
|
|
1299
|
+
// ATTRIBUTE — never assign it independently.
|
|
1300
|
+
//
|
|
1301
|
+
// startBurst()/startTableBurst() derive `blockId` from
|
|
1302
|
+
// `blockEl.getAttribute('data-block-id')`, so on a kept node it is the
|
|
1303
|
+
// FIELD that goes stale, not the attribute: the id rewrite inside the try
|
|
1304
|
+
// above already renumbered every kept suffix node. bystanderCarryOver()
|
|
1305
|
+
// compares `String(currentBurst.blockId)` against a node's getAttribute()
|
|
1306
|
+
// result AS STRINGS; if the field and the attribute disagree in either
|
|
1307
|
+
// direction a dirty burst stops being excluded from the replay and the
|
|
1308
|
+
// replay overwrites the user's uncommitted typing. Hence the fixed order:
|
|
1309
|
+
// the patch rewrites the attribute first, this re-derives the field from
|
|
1310
|
+
// it. `blockEl` is `editEl.closest('.ed-block')` captured at burst-start
|
|
1311
|
+
// and, because `burstSurvived` measured `editEl` as still attached, it is
|
|
1312
|
+
// the same still-attached element.
|
|
1313
|
+
if (burstSurvived) {
|
|
1314
|
+
currentBurst.blockId = Number(currentBurst.blockEl.getAttribute('data-block-id'));
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// S3 Task 5 (§4.4's ordered three steps): armEditables() above was step 1;
|
|
1318
|
+
// this is steps 2 and 3 — rebuild the member set from the declared (or
|
|
1319
|
+
// still-standing) LINE RANGE against the freshly built `blocks`. It sits
|
|
1320
|
+
// AFTER the unconditional teardown above so nothing there can null what it
|
|
1321
|
+
// just set, and BEFORE the two `try`-swallowed rebind blocks below so a
|
|
1322
|
+
// diagram-init throw can never skip it.
|
|
1323
|
+
//
|
|
1324
|
+
// Fix round 1 (review, ruling F3 amendment): there are TWO scroll sources
|
|
1325
|
+
// applyFullRender()'s `window.scrollTo(0, scrollY)` compensates, and only
|
|
1326
|
+
// one of them is absent here. The one F3 correctly ruled out is the
|
|
1327
|
+
// innerHTML swap collapsing the scroll extent — that cannot happen on this
|
|
1328
|
+
// path, and re-applying the stale pre-fetch snapshot would fight the
|
|
1329
|
+
// browser. The other is rebuildBlockSelection()'s own tail: it ends in
|
|
1330
|
+
// `focusEl.focus()`, which scrolls the focus holder into view (the comment
|
|
1331
|
+
// above applyFullRender()'s scrollTo says exactly this). That source is
|
|
1332
|
+
// very much still live here, so a keyboard-driven patched commit taken
|
|
1333
|
+
// while the block selection is scrolled off-screen would jump the viewport
|
|
1334
|
+
// where the fallback would not.
|
|
1335
|
+
//
|
|
1336
|
+
// Compensated precisely: read the CURRENT scroll position immediately
|
|
1337
|
+
// before, restore it immediately after, and only if the call actually
|
|
1338
|
+
// moved it. Never the pre-fetch `scrollY` — applyPatch() deliberately does
|
|
1339
|
+
// not take one.
|
|
1340
|
+
const scrollBeforeSelection = window.scrollY;
|
|
1341
|
+
rebuildBlockSelection(declaredRange);
|
|
1342
|
+
if (window.scrollY !== scrollBeforeSelection) {
|
|
1343
|
+
window.scrollTo(0, scrollBeforeSelection);
|
|
1344
|
+
}
|
|
1345
|
+
// 這三個都是 document-scoped,必須在插入之後、對整份文件跑:
|
|
1346
|
+
// __md2docInitDiagrams 的 WaveDrom 分支自述「has no per-root scoping API
|
|
1347
|
+
// — ProcessAll() always scans the whole document」,實測對 detached
|
|
1348
|
+
// fragment 呼叫時 mermaid 畫得出來但 WaveDrom 完全沒動。
|
|
1349
|
+
if (window.__md2docInitDiagrams) {
|
|
1350
|
+
try {
|
|
1351
|
+
window.__md2docInitDiagrams(contentEl);
|
|
1352
|
+
} catch (e) {
|
|
1353
|
+
// Same Phase-1 known limitation as the fallback path: a newly
|
|
1354
|
+
// introduced diagram type whose library global was never loaded stays
|
|
1355
|
+
// unrendered until reload. Never let that break the rest of the editor.
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
if (window.__md2docRebindReader) {
|
|
1359
|
+
try {
|
|
1360
|
+
window.__md2docRebindReader();
|
|
1361
|
+
} catch (e) {
|
|
1362
|
+
// Never let a reader-runtime rebind failure break block selection/save/undo.
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
// Fix round 1 (review, comment-only): whenever rebuildBlockSelection()
|
|
1366
|
+
// above does NOT take its early-return path it has already called
|
|
1367
|
+
// applySelectionClasses() once, via setBlockSelection()/
|
|
1368
|
+
// clearBlockSelection() — so on those renders this is a redundant SECOND
|
|
1369
|
+
// pass, costing one extra blur/focus round trip. It is kept unconditional
|
|
1370
|
+
// anyway, because the case where it is genuinely load-bearing is exactly
|
|
1371
|
+
// the case where rebuildBlockSelection() early-returns: a plain typing
|
|
1372
|
+
// commit with no selection anywhere, where nothing else would clear the
|
|
1373
|
+
// KEPT nodes' stale `.ed-selected`/tabindex.
|
|
1374
|
+
//
|
|
1375
|
+
// applySelectionClasses() 必須跑:它是 total 的(先把 `.ed-selected` 與
|
|
1376
|
+
// tabindex 從每一個區塊清掉再重套)。innerHTML 置換時代那份「保留節點的
|
|
1377
|
+
// 殘留 class 自動消失」的免費午餐在 patch 路徑沒有了。
|
|
1378
|
+
applySelectionClasses();
|
|
1379
|
+
|
|
1380
|
+
// ── Task G step 3 / 決議 3(1):patch 收尾的焦點檢查 ────────────────
|
|
1381
|
+
// endBurstWithoutResolve() 有 11 個呼叫點,全部在 render 之前,而
|
|
1382
|
+
// ⠿/+/工具列的 mousedown 都無條件 preventDefault()——刻意的,好讓
|
|
1383
|
+
// 焦點留在 armed 面上(既有 runtime 測試斷言「⠿ 的 mousedown 不得 blur
|
|
1384
|
+
// 還開著的 dirty burst」)。今天安全只因為 innerHTML 置換把那個節點
|
|
1385
|
+
// 拔掉;patch 保留節點,就把「焦點還在、currentBurst 為 null」從邊角
|
|
1386
|
+
// 變成日常。那個狀態下:打字不被追蹤(input listener 以
|
|
1387
|
+
// currentBurst.editEl 為前提)、移開焦點什麼都不提交(focusout 同理)
|
|
1388
|
+
// ——螢幕上看得到,檔案裡沒有。
|
|
1389
|
+
//
|
|
1390
|
+
// 位置是刻意的:在 teardown 之後(這時 currentBurst 才真的歸零了),
|
|
1391
|
+
// 也在 rebuildBlockSelection() / applySelectionClasses() 之後——那兩支的
|
|
1392
|
+
// focusEl.focus() 可能把焦點搬到一個 `.ed-block` 上(那是真的移走了,
|
|
1393
|
+
// 不該重開 burst)。先開再讓它搬走只會多觸發一輪 focusout →
|
|
1394
|
+
// resolveBurst()。又因為 resetSelToolbarState() 已經跑完(!burstSurvived
|
|
1395
|
+
// 才會走到這裡),startBurst() 重新掛上的 selectionchange listener 不會被
|
|
1396
|
+
// 回頭拆掉。
|
|
1397
|
+
//
|
|
1398
|
+
// ⚠ 這一步會改動 switchAwayFrom() 的後置條件。resolveOpenSession() 解掉
|
|
1399
|
+
// currentBurst、await 完 render 之後就直接 return true,不再重檢;而工具列
|
|
1400
|
+
// 與 ⠿/+ 的 mousedown 都 preventDefault(),焦點會留在原面上。所以這裡可能
|
|
1401
|
+
// 在那個被 await 的 render 裡重開一個 burst,讓 switchAwayFrom() 帶著一個
|
|
1402
|
+
// 開著的 session 回傳 true——它自己的 doc comment 說它是「單一、完整的
|
|
1403
|
+
// make it safe to proceed 閘門」。這是刻意的取捨:那個面上的鍵入沒人追蹤才是
|
|
1404
|
+
// 資料遺失,而重開的 burst 是乾淨的(original 就是當下的 innerHTML),
|
|
1405
|
+
// 下一次 focusout 會把它零編輯解掉。已知的一個後果是本輪修掉的那個:
|
|
1406
|
+
// openRawEditor() 原本只擋 activeEditor,於是它註冊 activeEditor 之後的
|
|
1407
|
+
// ta.focus() 會把這個 bystander burst 的 focusout 變成一次 switchAwayFrom(),
|
|
1408
|
+
// 連帶把剛開的 raw editor 一起收掉——見那裡的 Task G fix round 1 註解。
|
|
1409
|
+
// 任何新的「開一個 session 然後自己搬焦點」的路徑都要先解掉 burst。
|
|
1410
|
+
//
|
|
1411
|
+
// startBurst() 吃 editEl(`.ed-wys-armed` 本人)、startTableBurst() 吃 cellEl
|
|
1412
|
+
// (`.ed-wys-cell` 本人,它自己 closest('table'))——兩者都正好是
|
|
1413
|
+
// activeElement,所以直接傳。兩者都不呼叫 focus(),不搞 scrollIntoView,
|
|
1414
|
+
// 所以這裡**不需要** rebuildBlockSelection() 那種 scroll 補償。
|
|
1415
|
+
const ae = document.activeElement;
|
|
1416
|
+
if (!currentBurst && ae && contentEl.contains(ae)) {
|
|
1417
|
+
if (ae.classList.contains('ed-wys-cell')) startTableBurst(ae);
|
|
1418
|
+
else if (ae.classList.contains('ed-wys-armed')) startBurst(ae);
|
|
1419
|
+
}
|
|
1420
|
+
// Same tail as applyFullRender(): the dirty dot and the `true` that tells
|
|
1421
|
+
// every safeRerenderAll() caller the commit landed. Omitting either is the
|
|
1422
|
+
// exact silent-failure shape ruling F4 warns about — safeRerenderAll()'s
|
|
1423
|
+
// try/catch would swallow a throw and the DOM would still look right.
|
|
1424
|
+
setDirty();
|
|
1425
|
+
return true;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
async function applyFullRender(j, scrollY, declaredRange) {
|
|
860
1429
|
blocks = j.blocks;
|
|
861
|
-
|
|
1430
|
+
lastParts = j.parts; // v3.2.0: 供下一次 patch 當 oldParts
|
|
1431
|
+
contentEl.innerHTML = j.parts.join('\n'); // fallback 路徑用 join 還原
|
|
862
1432
|
// Task 2 (Phase 3): re-arm every WYSIWYG-eligible paragraph/heading (and
|
|
863
1433
|
// attach a ⠿ handle to every non-table block) in the freshly-swapped
|
|
864
1434
|
// DOM — see armEditables() below. Must run before anything else touches
|
|
@@ -943,6 +1513,19 @@
|
|
|
943
1513
|
// it's the same "singleton node appended as a child of whichever block
|
|
944
1514
|
// it's open for" idiom as gutterMenu just above.
|
|
945
1515
|
closeInsertMenu();
|
|
1516
|
+
// v3.1.0 §4: and the SAME belt-and-braces reasoning one more time for the
|
|
1517
|
+
// toolbar's tracked block. The toolbar node itself is mounted on
|
|
1518
|
+
// document.body (see mountToolbar() below) precisely so the innerHTML
|
|
1519
|
+
// replacement above cannot destroy it — but `toolbarBlockEl` points INTO
|
|
1520
|
+
// the subtree that was just swapped, and a detached node's
|
|
1521
|
+
// getAttribute() still answers perfectly happily: left standing, the bar
|
|
1522
|
+
// would keep reporting the old block's type/indent/heading depth and
|
|
1523
|
+
// every button would act on a node no click can ever reach again. Zeroing
|
|
1524
|
+
// it re-derives the model's documented no-block state (undo/redo/outline/
|
|
1525
|
+
// preview stay live, everything else greys out) — the same contract
|
|
1526
|
+
// closeGutterMenu()/closeInsertMenu() above have, and idempotent for the
|
|
1527
|
+
// same reason.
|
|
1528
|
+
resetToolbarBlock();
|
|
946
1529
|
// S3 Task 5 (§4.4's ordered three steps): armEditables() above was step
|
|
947
1530
|
// 1; this is steps 2 and 3 — rebuild the member set from the declared (or
|
|
948
1531
|
// still-standing) LINE RANGE against the freshly built `blocks`, clear it
|
|
@@ -990,9 +1573,16 @@
|
|
|
990
1573
|
// Defensive wrapper around every rerenderAll() call site: guarantees the
|
|
991
1574
|
// caller never sees an unhandled rejection even if a future change to
|
|
992
1575
|
// rerenderAll() (or one of its callees) introduces a stray throw.
|
|
993
|
-
|
|
1576
|
+
//
|
|
1577
|
+
// Task E: `editRange` passes straight through to rerenderAll(). Callers
|
|
1578
|
+
// with a real committed op compute it there and pass it; the 3 no-op call
|
|
1579
|
+
// sites (restore()'s rangeMode exit, the per-li unsupported-format
|
|
1580
|
+
// refusal, and commitListStructure()'s no-range exit) pass nothing, which
|
|
1581
|
+
// falls back to a full re-render by contract — patch can only compare
|
|
1582
|
+
// renderer output, never DOM the user has already touched.
|
|
1583
|
+
async function safeRerenderAll(editRange) {
|
|
994
1584
|
try {
|
|
995
|
-
return await rerenderAll();
|
|
1585
|
+
return await rerenderAll(editRange);
|
|
996
1586
|
} catch (e) {
|
|
997
1587
|
showBanner('Render failed — unexpected error (' + describeFailure(e) +
|
|
998
1588
|
'). Your edit was not applied.', null, null);
|
|
@@ -1005,9 +1595,48 @@
|
|
|
1005
1595
|
ta.style.height = (ta.scrollHeight + 2) + 'px';
|
|
1006
1596
|
}
|
|
1007
1597
|
|
|
1008
|
-
|
|
1598
|
+
// v3.1.0 修正 4: `range` ({startLine, endLine}, 1-indexed inclusive) opens
|
|
1599
|
+
// the raw editor over an EXPLICIT span of source lines instead of the
|
|
1600
|
+
// block's own record. Two callers need it and one property makes both
|
|
1601
|
+
// work: a list item (whose source line carries its marker and leading
|
|
1602
|
+
// columns, and which must NOT go through the run re-serializer while a
|
|
1603
|
+
// textarea is standing where its .ed-li-text should be) and a multi-block
|
|
1604
|
+
// ⠿ selection (whose span is several blocks' lines at once). Both also
|
|
1605
|
+
// switch this editor's teardown from an innerHTML rewrite to a real
|
|
1606
|
+
// re-render — see restore() below for why that is not optional.
|
|
1607
|
+
async function openRawEditor(blockEl, range) {
|
|
1009
1608
|
if (blockEl.querySelector('.ed-raw')) return; // already editing this block
|
|
1010
|
-
|
|
1609
|
+
// Task G fix round 1 (npm test RED on f63c88a): the guard below used to
|
|
1610
|
+
// read `activeEditor` ALONE, so a WYSIWYG burst open on a DIFFERENT block
|
|
1611
|
+
// survived into this function — and then `ta.focus()` (a few lines down,
|
|
1612
|
+
// after `activeEditor` has already been registered) blurs that surface,
|
|
1613
|
+
// whose delegated focusout calls switchAwayFrom(). resolveOpenSession()
|
|
1614
|
+
// resolves BOTH kinds of session, so it resolves the burst AND then finds
|
|
1615
|
+
// the brand-new, unmodified `activeEditor` and calls cancelNow() on it —
|
|
1616
|
+
// which for a pristine insert is cancelAndMaybeDiscard() -> the freshly
|
|
1617
|
+
// inserted block is discarded and its textarea never survives the gesture.
|
|
1618
|
+
//
|
|
1619
|
+
// MEASURED (scratchpad taskG-repro-code.js, replaying
|
|
1620
|
+
// test/editor-client-runtime.test.js's Section 10「abandoned inserts
|
|
1621
|
+
// (all 5 kinds)」): + -> 程式碼 with stale focus left on an untouched
|
|
1622
|
+
// bystander paragraph produced
|
|
1623
|
+
// openRawEditor {currentBurst:true, activeEditor:false}
|
|
1624
|
+
// resolveOpenSession {currentBurst:true, activeEditor:true, pristineInsert:6}
|
|
1625
|
+
// cancelAndMaybeDiscard {blockId:6, pristineInsert:6}
|
|
1626
|
+
// and zero code blocks left in the DOM. Only 程式碼 is affected because it
|
|
1627
|
+
// is the one insert kind that registers an `activeEditor` before moving
|
|
1628
|
+
// focus; the other four just focus another armed surface.
|
|
1629
|
+
//
|
|
1630
|
+
// The burst arm was previously unreachable — every caller resolved the
|
|
1631
|
+
// session first — which is why the guard only ever named `activeEditor`.
|
|
1632
|
+
// applyPatch()'s step-3 focus check makes it reachable: it adopts focus
|
|
1633
|
+
// that a preventDefault()-ing gesture left standing on a bystander, so a
|
|
1634
|
+
// burst can now be open on a block nobody is interacting with. Resolving
|
|
1635
|
+
// it HERE (before `activeEditor` is registered, so the focusout that
|
|
1636
|
+
// `ta.focus()` fires finds nothing to resolve) is the same defense-in-
|
|
1637
|
+
// depth this guard already documents, applied to the other session kind.
|
|
1638
|
+
if ((activeEditor && activeEditor.blockEl !== blockEl) ||
|
|
1639
|
+
(currentBurst && currentBurst.blockEl !== blockEl)) {
|
|
1011
1640
|
// Click-to-switch (see `activeEditor` / switchAwayFrom() comments
|
|
1012
1641
|
// above): resolve whatever editor IS open before opening this one.
|
|
1013
1642
|
// Defense in depth — the delegated click listener (wireBlockSelection
|
|
@@ -1023,7 +1652,10 @@
|
|
|
1023
1652
|
const ok = await switchAwayFrom();
|
|
1024
1653
|
if (!ok) return; // the open editor's auto-commit failed; stay put
|
|
1025
1654
|
}
|
|
1026
|
-
const
|
|
1655
|
+
// v3.2.0 (final review item 1): `let`, not `const` — reanchor() below
|
|
1656
|
+
// re-derives all three of `blockId`/`startLine`/`endLine` when this
|
|
1657
|
+
// session survives an incremental patch. See its own comment.
|
|
1658
|
+
let blockId = Number(blockEl.getAttribute('data-block-id'));
|
|
1027
1659
|
const block = blocks.find((b) => b.id === blockId);
|
|
1028
1660
|
if (!block) return;
|
|
1029
1661
|
// Task 4 fix round 1 (Critical, second path): a block that owns no source
|
|
@@ -1034,16 +1666,45 @@
|
|
|
1034
1666
|
// INSERTS the text as a new line instead of replacing anything, leaving
|
|
1035
1667
|
// the clicked item untouched and a stray line in the file (measured:
|
|
1036
1668
|
// '# 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
|
-
//
|
|
1669
|
+
// The only such blocks today are list items, and they are never armed, so
|
|
1670
|
+
// before Task 4 gave every block a ⠿ this was reached by a plain body
|
|
1671
|
+
// click and stayed unreported. (Until v3.1.0 this note also cited RULING
|
|
1672
|
+
// F-O as an independent second reason. F-O is superseded for the ⠿ menu
|
|
1673
|
+
// — see toggleGutterMenu() below — so this guard now carries the case
|
|
1674
|
+
// ALONE, and it is the one that always mattered: a block that owns no
|
|
1675
|
+
// source line has nothing to seed a textarea WITH, no matter how the
|
|
1676
|
+
// editor is torn down afterwards.) Guarded HERE rather than at the click
|
|
1677
|
+
// delegator so
|
|
1041
1678
|
// every caller — the delegated degraded-block click, openRawViaGutter(),
|
|
1042
1679
|
// the burst-degrade path — is closed by one check.
|
|
1043
1680
|
if (blockOwnsNoLine(blockEl)) { refuseStructuralListEdit(NO_SOURCE_LINE_MESSAGE); return; }
|
|
1044
1681
|
|
|
1682
|
+
// v3.1.0 修正 4: the span this editor owns. Without `range` it is exactly
|
|
1683
|
+
// the block's own record, so `source` below is byte-identical to what
|
|
1684
|
+
// extractBlockSource(lines, block) returned before — that helper IS this
|
|
1685
|
+
// slice, and every pre-v3.1.0 call site keeps its behaviour unchanged.
|
|
1686
|
+
// v3.2.0 (final review item 1): `let` for the same reason as `blockId`.
|
|
1687
|
+
let startLine = range ? range.startLine : block.startLine;
|
|
1688
|
+
let endLine = range ? range.endLine : block.endLine;
|
|
1689
|
+
// The same inverted-range refusal blockOwnsNoLine() just made for the
|
|
1690
|
+
// block, re-asked of the range actually being edited: a caller-supplied
|
|
1691
|
+
// span can be malformed even when the anchor block is fine.
|
|
1692
|
+
if (endLine < startLine) { refuseStructuralListEdit(NO_SOURCE_LINE_MESSAGE); return; }
|
|
1693
|
+
// A list item's rendered chrome (the .ed-li-marker span, the optional
|
|
1694
|
+
// .ed-li-check span, the .ed-li-text surface) is REBUILT BY THE RENDERER
|
|
1695
|
+
// from the source line's marker — it is not recoverable from a captured
|
|
1696
|
+
// innerHTML string once the item's own text has been rewritten, which is
|
|
1697
|
+
// exactly what RULING F-O objected to. So a li (and any explicit range)
|
|
1698
|
+
// commits through commitRangeEdit() over these lines rather than through
|
|
1699
|
+
// the run serializer, and tears down through a real re-render — which
|
|
1700
|
+
// removes F-O's stated premise rather than working around it. Ruling 13
|
|
1701
|
+
// (v3.1.0) supersedes F-O for THIS surface, the ⠿ menu's MD 原始碼, and
|
|
1702
|
+
// for that surface only: the burst-degrade refusal in resolveBurst() and
|
|
1703
|
+
// the blockOwnsNoLine() guard just above both still stand, for reasons
|
|
1704
|
+
// that were never F-O's.
|
|
1705
|
+
const rangeMode = !!range || blockEl.getAttribute('data-block-type') === 'li';
|
|
1045
1706
|
const original = blockEl.innerHTML;
|
|
1046
|
-
const source =
|
|
1707
|
+
const source = lines.slice(startLine - 1, endLine).join('\n');
|
|
1047
1708
|
|
|
1048
1709
|
const wrap = document.createElement('div');
|
|
1049
1710
|
wrap.className = 'ed-editing';
|
|
@@ -1065,6 +1726,7 @@
|
|
|
1065
1726
|
hasChanges: () => ta.value !== source,
|
|
1066
1727
|
commitNow: commit,
|
|
1067
1728
|
cancelNow: cancelAndMaybeDiscard,
|
|
1729
|
+
reanchor,
|
|
1068
1730
|
};
|
|
1069
1731
|
|
|
1070
1732
|
const controls = document.createElement('div');
|
|
@@ -1089,7 +1751,19 @@
|
|
|
1089
1751
|
ta.focus();
|
|
1090
1752
|
ta.setSelectionRange(ta.value.length, ta.value.length);
|
|
1091
1753
|
|
|
1092
|
-
function restore() {
|
|
1754
|
+
async function restore() {
|
|
1755
|
+
// v3.1.0 修正 4: `rangeMode` cannot restore by rewriting innerHTML —
|
|
1756
|
+
// for a li that string would have to carry the marker/check/text chrome
|
|
1757
|
+
// back, and for a multi-block span there is more than one block's DOM
|
|
1758
|
+
// to put back in the first place. A re-render rebuilds all of it from
|
|
1759
|
+
// `lines`, which this path never touched (restore() NEVER commits), so
|
|
1760
|
+
// the document is byte-identical either way — this is a different way
|
|
1761
|
+
// of repainting the same bytes, not a different edit.
|
|
1762
|
+
if (rangeMode) {
|
|
1763
|
+
if (activeEditor && activeEditor.blockEl === blockEl) activeEditor = null;
|
|
1764
|
+
await safeRerenderAll();
|
|
1765
|
+
return;
|
|
1766
|
+
}
|
|
1093
1767
|
blockEl.innerHTML = original;
|
|
1094
1768
|
// Only clear `activeEditor` if it's still THIS block's entry — it may
|
|
1095
1769
|
// already have been cleared out from under us (e.g. by rerenderAll()'s
|
|
@@ -1113,11 +1787,41 @@
|
|
|
1113
1787
|
async function cancelAndMaybeDiscard() {
|
|
1114
1788
|
const wasPristineForThisBlock = !!(pristineInsert && pristineInsert.blockId === blockId);
|
|
1115
1789
|
if (wasPristineForThisBlock) pristineInsert = null;
|
|
1116
|
-
restore();
|
|
1790
|
+
await restore();
|
|
1117
1791
|
if (wasPristineForThisBlock) return await discardPristineInsert();
|
|
1118
1792
|
return true;
|
|
1119
1793
|
}
|
|
1120
1794
|
|
|
1795
|
+
// v3.2.0 final review item 1 — 決議 3(1) 的 activeEditor 對稱項。
|
|
1796
|
+
//
|
|
1797
|
+
// 這個 session 的 `blockId` / `startLine` / `endLine` 是開啟當下從**那一
|
|
1798
|
+
// 份** `blocks` 抓下來的;fallback 路徑上那從來不是問題,因為
|
|
1799
|
+
// `contentEl.innerHTML = ` 會把這個 textarea 一起拔掉。patch 路徑保留
|
|
1800
|
+
// 前綴/後綴節點,所以它可以活過一次 render,而 render 之後 `blocks`
|
|
1801
|
+
// 已整份換掉、被保留後綴節點的 data-block-id 也被重寫過。
|
|
1802
|
+
//
|
|
1803
|
+
// applyPatch() 的 teardown 在實測 `document.body.contains(blockEl)` 成立
|
|
1804
|
+
// 之後呼叫這支,把三個欄位從**現在**的 DOM 屬性與新的 `blocks` 重新
|
|
1805
|
+
// 推導。回傳 false 代表推導不出來,呼叫端必須退回原本的無條件歸零:
|
|
1806
|
+
// - `range`:呼叫端指定的跨區塊 span(⠿ 選單的「MD 原始碼」批次路徑)
|
|
1807
|
+
// 在新的 `blocks` 裡沒有單一對應記錄,無從重新推導。
|
|
1808
|
+
// - 區塊在新的 `blocks` 裡不存在(防禦;節點還在就不該發生)。
|
|
1809
|
+
// - 重新切出來的來源**不是** byte-identical:那表示這個區塊底下的行
|
|
1810
|
+
// 真的變了,`ta.value` 與 `source` 都已經不是檔案裡的東西,沿用這個
|
|
1811
|
+
// session 會把過期的文字提交回去。patch 只保留 parts 相同的節點,所以
|
|
1812
|
+
// 正常情況下這個等式成立;不成立時退回歸零是保守的正解。
|
|
1813
|
+
function reanchor() {
|
|
1814
|
+
if (range) return false;
|
|
1815
|
+
const id = Number(blockEl.getAttribute('data-block-id'));
|
|
1816
|
+
const b = blocks.find((x) => x.id === id);
|
|
1817
|
+
if (!b || b.endLine < b.startLine) return false;
|
|
1818
|
+
if (lines.slice(b.startLine - 1, b.endLine).join('\n') !== source) return false;
|
|
1819
|
+
blockId = id;
|
|
1820
|
+
startLine = b.startLine;
|
|
1821
|
+
endLine = b.endLine;
|
|
1822
|
+
return true;
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1121
1825
|
// Returns true when the editor is resolved (committed, or a no-op
|
|
1122
1826
|
// commit that fell back to a cancel) — safe for a caller (switchAwayFrom
|
|
1123
1827
|
// included) to proceed. Returns false only when the render actually
|
|
@@ -1127,7 +1831,13 @@
|
|
|
1127
1831
|
// network hiccup must never silently discard what they typed). The
|
|
1128
1832
|
// failure banner is already shown by safeRerenderAll()/rerenderAll().
|
|
1129
1833
|
async function commit() {
|
|
1130
|
-
|
|
1834
|
+
// v3.1.0 修正 4: an explicit range commits over ITS OWN span. commitEdit()
|
|
1835
|
+
// is the by-id wrapper over exactly this call (it looks the block up and
|
|
1836
|
+
// delegates), so the two branches are the same pipeline — replaceLines()
|
|
1837
|
+
// + one undo op — differing only in which lines they name.
|
|
1838
|
+
const result = rangeMode
|
|
1839
|
+
? commitRangeEdit({ lines, blocks, stack }, startLine, endLine, ta.value)
|
|
1840
|
+
: commitEdit({ lines, blocks, stack }, blockId, ta.value);
|
|
1131
1841
|
if (result.op === null) {
|
|
1132
1842
|
// §10-gap fix (review): an explicit commit (Ctrl+Enter/✓) whose
|
|
1133
1843
|
// text happens to be byte-identical to `source` is still an
|
|
@@ -1137,7 +1847,12 @@
|
|
|
1137
1847
|
}
|
|
1138
1848
|
const prevLines = lines;
|
|
1139
1849
|
lines = result.lines;
|
|
1140
|
-
const
|
|
1850
|
+
const editRange = result.op
|
|
1851
|
+
? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
|
|
1852
|
+
endLine: Math.max(result.op.startLine - 1, result.op.endLine),
|
|
1853
|
+
delta: result.op.after.length - result.op.before.length }
|
|
1854
|
+
: undefined;
|
|
1855
|
+
const ok = await safeRerenderAll(editRange);
|
|
1141
1856
|
if (!ok) {
|
|
1142
1857
|
// Roll back the optimistic edit: pop the op safeRerenderAll's
|
|
1143
1858
|
// failure means was never actually rendered, so `lines` stays
|
|
@@ -1147,9 +1862,48 @@
|
|
|
1147
1862
|
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
1148
1863
|
return false;
|
|
1149
1864
|
}
|
|
1150
|
-
// Success
|
|
1151
|
-
// (this block included) and nulled `activeEditor`
|
|
1152
|
-
// left to do here.
|
|
1865
|
+
// Success. On the FALLBACK path rerenderAll() replaced the whole
|
|
1866
|
+
// .content subtree (this block included) and nulled `activeEditor`
|
|
1867
|
+
// itself, so there was nothing left to do here.
|
|
1868
|
+
//
|
|
1869
|
+
// v3.2.0 final review item 1: that is no longer unconditional. The
|
|
1870
|
+
// patch path only detaches this block when it lands inside
|
|
1871
|
+
// `replaceSpan` — and a commit very often leaves it OUTSIDE: whenever
|
|
1872
|
+
// the edit's visible effect is a NEW block AFTER this one (a line
|
|
1873
|
+
// appended below a fence, a paragraph typed under a paragraph) this
|
|
1874
|
+
// block's own part is byte-identical before and after, so patchmap
|
|
1875
|
+
// keeps it in `keepPrefix` and the textarea stays on screen, on top of
|
|
1876
|
+
// content that was already repainted around it.
|
|
1877
|
+
//
|
|
1878
|
+
// MEASURED at HEAD, before this repair (scratchpad/item1-zombie.js
|
|
1879
|
+
// --head): Ctrl+Enter on a code fence with one line appended commits
|
|
1880
|
+
// correctly to the file but leaves `rawTextareas: 1` standing forever
|
|
1881
|
+
// — unclickable, because both the delegated click handler and
|
|
1882
|
+
// openRawEditor() early-return on a block that already has a `.ed-raw`.
|
|
1883
|
+
// With the `activeEditor` exception above it is worse than cosmetic:
|
|
1884
|
+
// the session stays live with its ORIGINAL `source`, so the next
|
|
1885
|
+
// switchAwayFrom() sees hasChanges() and commits the same text a
|
|
1886
|
+
// second time (measured: 'const LOST = 2;' twice in the file).
|
|
1887
|
+
//
|
|
1888
|
+
// A kept node's part is byte-identical by construction — that is the
|
|
1889
|
+
// only reason patchmap kept it — so `original` (this block's innerHTML
|
|
1890
|
+
// captured at open, i.e. post-armEditables) is exactly the content to
|
|
1891
|
+
// put back. Same one-line repaint the non-range restore() below uses,
|
|
1892
|
+
// for the same reason.
|
|
1893
|
+
//
|
|
1894
|
+
// ⚠ 順序不可對調,而且不是防禦性的:`blockEl.innerHTML =` 會把還握著
|
|
1895
|
+
// 焦點的 textarea 就地 detach,Chromium 在那當下**同步**送出一次
|
|
1896
|
+
// focusout,委派的 focusout handler 走 switchAwayFrom() →
|
|
1897
|
+
// resolveOpenSession() → activeEditor.commitNow()。先重繪再歸零時,
|
|
1898
|
+
// 那一發看到的還是這個 session、hasChanges() 仍為真,於是把同一段文字
|
|
1899
|
+
// 再提交一次(實測:'ZZZ tail.' 在檔案裡出現兩次,stack 是
|
|
1900
|
+
// commit → focusout → switchAwayFrom → resolveOpenSession → commitNow)。
|
|
1901
|
+
// 先歸零就沒有東西可以被重新解析。rangeMode 的 restore() 早就是這個
|
|
1902
|
+
// 順序,理由相同。
|
|
1903
|
+
if (activeEditor && activeEditor.blockEl === blockEl) activeEditor = null;
|
|
1904
|
+
if (document.body.contains(blockEl) && blockEl.querySelector('.ed-raw')) {
|
|
1905
|
+
blockEl.innerHTML = original;
|
|
1906
|
+
}
|
|
1153
1907
|
return true;
|
|
1154
1908
|
}
|
|
1155
1909
|
|
|
@@ -1667,6 +2421,57 @@
|
|
|
1667
2421
|
// function did by returning early.
|
|
1668
2422
|
function computeIndentClamp(spanEls, opBlockEl, opOldIndent, opts) {
|
|
1669
2423
|
if (!indentClamp || !spanEls || !spanEls.length) return null;
|
|
2424
|
+
// v3.0.2 — the structural answer to the stale-`run` pattern that v3.0.1
|
|
2425
|
+
// point-fixed at three call sites, and that both reviewers asked to have
|
|
2426
|
+
// fixed at the call convention instead. There are FIVE call sites, not
|
|
2427
|
+
// three (3483 batch delete, 3812 convert, 5778 batch Tab/Shift+Tab, 6028
|
|
2428
|
+
// caret Tab/Shift+Tab — both indent directions share ONE call site each,
|
|
2429
|
+
// batch vs. caret, not one call site per direction — and the drop's
|
|
2430
|
+
// compute at 8516), and every one of them hands over a `listRunOf()`
|
|
2431
|
+
// result in document order. Two ways that goes stale, both of which
|
|
2432
|
+
// produced a confident wrong number rather than a refusal: a member
|
|
2433
|
+
// DETACHED (getAttribute still answers on a detached node) and a span
|
|
2434
|
+
// still attached but no longer in the run's document order (indexOf()
|
|
2435
|
+
// then reports an opIndex for a layout that no longer exists, so §3.4
|
|
2436
|
+
// rule 2's scope starts in the wrong place). Re-deriving the span here
|
|
2437
|
+
// instead of checking it was rejected: it would quietly change the
|
|
2438
|
+
// contract from "clamp this span" to "clamp the run containing this
|
|
2439
|
+
// op", and the drop is the one caller subtle enough that taking that
|
|
2440
|
+
// expressiveness away is not free.
|
|
2441
|
+
//
|
|
2442
|
+
// It does NOT throw. This file's defensive style (safeRerenderAll at
|
|
2443
|
+
// ~990) is that the editor must not die on one path's failure. But the
|
|
2444
|
+
// refusal is NOT free either: at the drop (8516) a null clamp feeds
|
|
2445
|
+
// spanIndentsAreAnchored() the RAW reordered indents, and that refuses
|
|
2446
|
+
// 1425 of 4067 legal drops where the clamped answer refuses only 14 (the
|
|
2447
|
+
// clamp rescues the other 1411; the measured numbers are recorded at
|
|
2448
|
+
// line ~371). So a false positive here costs the user up to 1411 legal
|
|
2449
|
+
// batch drags, replaced by a banner —
|
|
2450
|
+
// which is why the assert is worth this cost even though the batch-
|
|
2451
|
+
// delete, convert and drop sites each have a scenario whose correct
|
|
2452
|
+
// answer needs a real reclamp (a wrongly-firing assert breaks them
|
|
2453
|
+
// visibly), while neither Tab call site (5778 batch, 6028 caret) has a
|
|
2454
|
+
// scenario that reclamps a TRAILING sibling — see
|
|
2455
|
+
// test/editor-client-runtime.test.js for the per-site detail.
|
|
2456
|
+
const fresh = listRunOf(spanEls[0]);
|
|
2457
|
+
if (fresh.length !== spanEls.length ||
|
|
2458
|
+
fresh.some((el, i) => el !== spanEls[i])) {
|
|
2459
|
+
// Minor 5: the length pair alone is silent on the reordered-but-same-
|
|
2460
|
+
// length case (the ONLY case where an index is diagnostic — a length
|
|
2461
|
+
// mismatch is already self-explanatory), so report the first index at
|
|
2462
|
+
// which the two runs disagree too.
|
|
2463
|
+
const minLen = Math.min(fresh.length, spanEls.length);
|
|
2464
|
+
let badIndex = -1;
|
|
2465
|
+
for (let i = 0; i < minLen; i++) {
|
|
2466
|
+
if (fresh[i] !== spanEls[i]) { badIndex = i; break; }
|
|
2467
|
+
}
|
|
2468
|
+
if (badIndex < 0 && fresh.length !== spanEls.length) badIndex = minLen;
|
|
2469
|
+
console.error('computeIndentClamp: stale span — the run has ' + fresh.length +
|
|
2470
|
+
' members, the caller passed ' + spanEls.length +
|
|
2471
|
+
', first differing at index ' + badIndex +
|
|
2472
|
+
'; refusing rather than clamping against a layout that no longer exists');
|
|
2473
|
+
return null;
|
|
2474
|
+
}
|
|
1670
2475
|
const opEls = Array.isArray(opBlockEl) ? opBlockEl : [opBlockEl];
|
|
1671
2476
|
const opIndex = [];
|
|
1672
2477
|
for (let k = 0; k < opEls.length; k++) {
|
|
@@ -1813,7 +2618,12 @@
|
|
|
1813
2618
|
if (result.op === null) return;
|
|
1814
2619
|
const prevLines = lines;
|
|
1815
2620
|
lines = result.lines;
|
|
1816
|
-
const
|
|
2621
|
+
const editRange = result.op
|
|
2622
|
+
? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
|
|
2623
|
+
endLine: Math.max(result.op.startLine - 1, result.op.endLine),
|
|
2624
|
+
delta: result.op.after.length - result.op.before.length }
|
|
2625
|
+
: undefined;
|
|
2626
|
+
const okRender = await safeRerenderAll(editRange);
|
|
1817
2627
|
if (!okRender) {
|
|
1818
2628
|
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
1819
2629
|
}
|
|
@@ -2327,37 +3137,42 @@
|
|
|
2327
3137
|
gutterMenuConvert.hidden = convertWithheldFor(blockType);
|
|
2328
3138
|
gutterMenuDuplicate.hidden = false;
|
|
2329
3139
|
gutterMenuDelete.hidden = false;
|
|
2330
|
-
//
|
|
2331
|
-
//
|
|
2332
|
-
//
|
|
2333
|
-
// — a textarea inside it is content the serializer cannot represent, and
|
|
2334
|
-
// restore() would have to rebuild the marker/check/text chrome from a
|
|
2335
|
-
// string. Every other block type keeps it: the menu is a SINGLETON moved
|
|
2336
|
-
// between blocks, so this must be reset on every open, not set once.
|
|
3140
|
+
// 'MD 原始碼' is offered for EVERY block type, list items included, and
|
|
3141
|
+
// for a multi-block selection. The menu is a SINGLETON moved between
|
|
3142
|
+
// blocks, so this is still written on every open rather than set once.
|
|
2337
3143
|
// (test/editor-reader-rebind.test.js drives raw-edit through this button
|
|
2338
3144
|
// by its exact text on a paragraph.)
|
|
2339
3145
|
//
|
|
2340
|
-
//
|
|
2341
|
-
//
|
|
2342
|
-
//
|
|
2343
|
-
//
|
|
2344
|
-
//
|
|
3146
|
+
// ⚠ THIS REVERSES RULING F-O, deliberately and at spec level (v3.1.0
|
|
3147
|
+
// Ruling 13), for this surface and this surface only. F-O read: 'MD
|
|
3148
|
+
// 原始碼' is hidden for a list item PERMANENTLY, because openRawEditor()
|
|
3149
|
+
// replaces the block's innerHTML with a <textarea> — and a li is one line
|
|
3150
|
+
// of a run serialized as a whole, so a textarea inside it is content the
|
|
3151
|
+
// serializer cannot represent, and restore() would have to rebuild the
|
|
3152
|
+
// marker/check/text chrome out of a string. 修正 4 removes that premise
|
|
3153
|
+
// instead of arguing with it: a li now raw-edits an explicit LINE RANGE,
|
|
3154
|
+
// commits it through commitRangeEdit() rather than the run
|
|
3155
|
+
// re-serializer, and RESTORES BY RE-RENDERING rather than by rewriting
|
|
3156
|
+
// innerHTML, so no serializer ever sees the textarea and no chrome is
|
|
3157
|
+
// ever rebuilt by hand.
|
|
2345
3158
|
//
|
|
2346
|
-
//
|
|
2347
|
-
//
|
|
2348
|
-
//
|
|
2349
|
-
// and
|
|
2350
|
-
//
|
|
2351
|
-
//
|
|
2352
|
-
//
|
|
2353
|
-
//
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
3159
|
+
// F-O's OTHER call sites are NOT in scope and still refuse, each for a
|
|
3160
|
+
// reason that was never F-O's: openRawEditor()'s blockOwnsNoLine() guard
|
|
3161
|
+
// (a block with an inverted range has no line to seed a textarea with)
|
|
3162
|
+
// and resolveBurst()'s unsupported-inline-content path (that refusal is
|
|
3163
|
+
// about the CONTENT, not the container).
|
|
3164
|
+
//
|
|
3165
|
+
// §3.7's other sentence about this item — 「多選時不顯示 `MD 原始碼`」 —
|
|
3166
|
+
// is also lifted, and for the same kind of reason: it existed because
|
|
3167
|
+
// openRawViaGutter() rewrote ONE block's source lines, so over a set of N
|
|
3168
|
+
// it silently answered for the grip's block and ignored the rest. It now
|
|
3169
|
+
// opens on the WHOLE span's line range (§3.3 membership, asked through
|
|
3170
|
+
// resolveGutterOperands() so the contiguity and no-source-line gates come
|
|
3171
|
+
// with it), so the item does what it appears to do.
|
|
3172
|
+
// The membership question itself moved WITH the decision it feeds, into
|
|
3173
|
+
// openRawViaGutter() (it now chooses the RANGE instead of the
|
|
3174
|
+
// visibility), so it is asked exactly once, in the place that acts on it.
|
|
3175
|
+
gutterMenuMd.hidden = false;
|
|
2361
3176
|
blockEl.appendChild(gutterMenu);
|
|
2362
3177
|
}
|
|
2363
3178
|
|
|
@@ -2471,6 +3286,13 @@
|
|
|
2471
3286
|
list: ['-'],
|
|
2472
3287
|
table: ['| A | B |', '|---|---|', '| | |'],
|
|
2473
3288
|
code: ['```', '', '```'],
|
|
3289
|
+
// v3.1.0 §4: the toolbar's ― button. '---' is safe as a thematic break
|
|
3290
|
+
// here specifically because commitBlockInsertion() ALWAYS writes a
|
|
3291
|
+
// leading blank line (see its own comment) — a '---' directly under a
|
|
3292
|
+
// non-blank line would instead lex as a setext H2 and silently promote
|
|
3293
|
+
// whatever preceded it. Nothing else in this file may reuse this
|
|
3294
|
+
// skeleton through a path that does not guarantee that blank.
|
|
3295
|
+
line: ['---'],
|
|
2474
3296
|
};
|
|
2475
3297
|
|
|
2476
3298
|
// Selects the entirety of `el`'s content (used right after focusing a
|
|
@@ -2516,7 +3338,28 @@
|
|
|
2516
3338
|
const prevLines = lines;
|
|
2517
3339
|
lines = discarded.lines;
|
|
2518
3340
|
setDirty();
|
|
2519
|
-
|
|
3341
|
+
// v3.2.0: UndoStack.undo()/discardTop() reverse the op when applying it but
|
|
3342
|
+
// return the unmodified FORWARD op, so the range actually rewritten is
|
|
3343
|
+
// [op.startLine, op.startLine + op.after.length - 1] and the delta is the
|
|
3344
|
+
// mirror of the forward one. Using op.endLine here would name the opposite
|
|
3345
|
+
// direction — the span the op wrote on the way FORWARD, not the one this
|
|
3346
|
+
// reversal just rewrote.
|
|
3347
|
+
//
|
|
3348
|
+
// Final review item 4 — what naming it wrongly would cost today: nothing.
|
|
3349
|
+
// Per 決議 4(b) `editRange` is ADVISORY. patchmap() destructures
|
|
3350
|
+
// oldBlocks/newBlocks/oldParts/newParts and never reads it; the kept
|
|
3351
|
+
// prefix/suffix is the longest common run of the two PART arrays, and
|
|
3352
|
+
// seeding it from an op's line range only ever made the answer worse
|
|
3353
|
+
// (measured in 決議 4(b): seeded kept 0 of 5, pure-parts kept 4 of 5).
|
|
3354
|
+
// Its one client-side use is the truthiness gate in applyRenderResult().
|
|
3355
|
+
// So the direction changes no plan — this is simply the correct value for
|
|
3356
|
+
// whenever something does start reading it.
|
|
3357
|
+
const editRange = discarded.op
|
|
3358
|
+
? { startLine: discarded.op.startLine,
|
|
3359
|
+
endLine: discarded.op.startLine + discarded.op.after.length - 1,
|
|
3360
|
+
delta: discarded.op.before.length - discarded.op.after.length }
|
|
3361
|
+
: undefined;
|
|
3362
|
+
const okRender = await safeRerenderAll(editRange);
|
|
2520
3363
|
if (!okRender) {
|
|
2521
3364
|
stack.push(discarded.op); // put it back — see discardTop()'s own comment
|
|
2522
3365
|
lines = prevLines;
|
|
@@ -2569,7 +3412,16 @@
|
|
|
2569
3412
|
// dirty burst, swapping the whole `.content` subtree and detaching
|
|
2570
3413
|
// `blockEl` along with it — same "ensureTableBurstOpen()'s Finding 6"
|
|
2571
3414
|
// recovery idiom used throughout this file), THEN acts.
|
|
2572
|
-
|
|
3415
|
+
// v3.1.0 §4 / 追加 2 / 追加 3: `opts.lines` supplies EXPLICIT source lines
|
|
3416
|
+
// instead of one of the five placeholder skeletons — the image-drop path
|
|
3417
|
+
// (``) and the paste path (turndown's markdown) both need to
|
|
3418
|
+
// land real content, and both must land it as SOURCE so it goes through
|
|
3419
|
+
// commitBlockInsertion()/replaceLines() like every other edit rather than
|
|
3420
|
+
// being spliced into the DOM behind serializeBlocks()'s back. Everything
|
|
3421
|
+
// else about the insert — the run-wide gate, the subtree anchor, the
|
|
3422
|
+
// rollback — is shared, which is the whole reason this is one extra
|
|
3423
|
+
// parameter and not a second insertion path.
|
|
3424
|
+
async function insertBlockBelow(blockEl, kind, opts) {
|
|
2573
3425
|
if (!blockEl) return;
|
|
2574
3426
|
// T7: captured BEFORE switchAwayFrom(), because that is what can renumber
|
|
2575
3427
|
// the ids — see captureBlockIdentity()'s comment.
|
|
@@ -2608,7 +3460,8 @@
|
|
|
2608
3460
|
refuseStructuralListEdit(NO_SOURCE_LINE_INSERT_MESSAGE);
|
|
2609
3461
|
return;
|
|
2610
3462
|
}
|
|
2611
|
-
const
|
|
3463
|
+
const customLines = opts && Array.isArray(opts.lines) ? opts.lines : null;
|
|
3464
|
+
const newLines = customLines || BLOCK_SKELETONS[kind];
|
|
2612
3465
|
if (!newLines) return;
|
|
2613
3466
|
|
|
2614
3467
|
// ── S2 Task 7: a LIST ITEM anchor (§6's S1 note item 3) ────────────────
|
|
@@ -2679,7 +3532,12 @@
|
|
|
2679
3532
|
const result = commitBlockInsertion({ lines, blocks, stack }, liveBlockId, newLines);
|
|
2680
3533
|
const prevLines = lines;
|
|
2681
3534
|
lines = result.lines;
|
|
2682
|
-
const
|
|
3535
|
+
const editRange = result.op
|
|
3536
|
+
? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
|
|
3537
|
+
endLine: Math.max(result.op.startLine - 1, result.op.endLine),
|
|
3538
|
+
delta: result.op.after.length - result.op.before.length }
|
|
3539
|
+
: undefined;
|
|
3540
|
+
const okRender = await safeRerenderAll(editRange);
|
|
2683
3541
|
if (!okRender) {
|
|
2684
3542
|
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
2685
3543
|
return;
|
|
@@ -2688,9 +3546,27 @@
|
|
|
2688
3546
|
// see `pristineInsert`'s own comment for the full contract. `blocks`
|
|
2689
3547
|
// was just reassigned by the successful rerenderAll() above, so this
|
|
2690
3548
|
// is the server-authoritative id for the block at `newStartLine`.
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
3549
|
+
//
|
|
3550
|
+
// v3.1.0: a custom-lines insert is NEITHER pristine NOR focus-and-select.
|
|
3551
|
+
// `pristineInsert` means "a placeholder the user has not touched yet, so
|
|
3552
|
+
// abandoning it removes it again" — applied to a dropped image or a
|
|
3553
|
+
// pasted paragraph it would silently delete real content the moment the
|
|
3554
|
+
// user clicked elsewhere without typing. focusAndSelectAll() is wrong for
|
|
3555
|
+
// the same reason: it selects the block's whole content so the first
|
|
3556
|
+
// keystroke replaces it, which is what a ZWSP placeholder wants and what
|
|
3557
|
+
// a just-pasted paragraph very much does not.
|
|
3558
|
+
//
|
|
3559
|
+
// 'line' is excluded for the same reason from the other direction: both
|
|
3560
|
+
// are PLACEHOLDER semantics, and an <hr> has no placeholder. There is
|
|
3561
|
+
// nothing for focusAndSelectAll() to select (the block's content element
|
|
3562
|
+
// is the rule itself), and "abandoning it un-typed removes it again"
|
|
3563
|
+
// describes a skeleton waiting to be filled in — a thematic break is
|
|
3564
|
+
// finished the moment it exists.
|
|
3565
|
+
if (!customLines && kind !== 'line') {
|
|
3566
|
+
const target = blocks.find((b) => b.startLine === result.newStartLine);
|
|
3567
|
+
if (target) pristineInsert = { blockId: target.id };
|
|
3568
|
+
await focusInsertedBlock(result.newStartLine, kind);
|
|
3569
|
+
}
|
|
2694
3570
|
}
|
|
2695
3571
|
|
|
2696
3572
|
|
|
@@ -3143,7 +4019,12 @@
|
|
|
3143
4019
|
startLine: first.startLine,
|
|
3144
4020
|
endLine: first.startLine + md.split('\n').length - 1,
|
|
3145
4021
|
});
|
|
3146
|
-
const
|
|
4022
|
+
const editRange = result.op
|
|
4023
|
+
? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
|
|
4024
|
+
endLine: Math.max(result.op.startLine - 1, result.op.endLine),
|
|
4025
|
+
delta: result.op.after.length - result.op.before.length }
|
|
4026
|
+
: undefined;
|
|
4027
|
+
const okRender = await safeRerenderAll(editRange);
|
|
3147
4028
|
if (!okRender) {
|
|
3148
4029
|
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
3149
4030
|
}
|
|
@@ -3238,7 +4119,12 @@
|
|
|
3238
4119
|
// answer as "keep what is standing" and says so explicitly, which is what
|
|
3239
4120
|
// keeps this correct if the insertion point ever moves.
|
|
3240
4121
|
declareCollapse({ startLine: first.startLine, endLine: last.endLine });
|
|
3241
|
-
|
|
4122
|
+
const editRange = result.op
|
|
4123
|
+
? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
|
|
4124
|
+
endLine: Math.max(result.op.startLine - 1, result.op.endLine),
|
|
4125
|
+
delta: result.op.after.length - result.op.before.length }
|
|
4126
|
+
: undefined;
|
|
4127
|
+
if (!(await safeRerenderAll(editRange))) {
|
|
3242
4128
|
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
3243
4129
|
}
|
|
3244
4130
|
}
|
|
@@ -3370,7 +4256,12 @@
|
|
|
3370
4256
|
// inverted range, and §4.4 says a range that no longer resolves clears).
|
|
3371
4257
|
// Declared immediately before the render, never earlier (Task 5 carry 5).
|
|
3372
4258
|
declareCollapse(null);
|
|
3373
|
-
const
|
|
4259
|
+
const editRange = result.op
|
|
4260
|
+
? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
|
|
4261
|
+
endLine: Math.max(result.op.startLine - 1, result.op.endLine),
|
|
4262
|
+
delta: result.op.after.length - result.op.before.length }
|
|
4263
|
+
: undefined;
|
|
4264
|
+
const okRender = await safeRerenderAll(editRange);
|
|
3374
4265
|
if (!okRender) {
|
|
3375
4266
|
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
3376
4267
|
}
|
|
@@ -3804,7 +4695,12 @@
|
|
|
3804
4695
|
const prevLines = lines;
|
|
3805
4696
|
lines = result.lines;
|
|
3806
4697
|
declareCollapse({ startLine: outStart, endLine: outEnd });
|
|
3807
|
-
|
|
4698
|
+
const editRange = result.op
|
|
4699
|
+
? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
|
|
4700
|
+
endLine: Math.max(result.op.startLine - 1, result.op.endLine),
|
|
4701
|
+
delta: result.op.after.length - result.op.before.length }
|
|
4702
|
+
: undefined;
|
|
4703
|
+
if (!(await safeRerenderAll(editRange))) {
|
|
3808
4704
|
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
3809
4705
|
}
|
|
3810
4706
|
}
|
|
@@ -3861,7 +4757,12 @@
|
|
|
3861
4757
|
startLine: merged.startLine,
|
|
3862
4758
|
endLine: merged.startLine + md.split('\n').length - 1,
|
|
3863
4759
|
});
|
|
3864
|
-
|
|
4760
|
+
const editRange = result.op
|
|
4761
|
+
? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
|
|
4762
|
+
endLine: Math.max(result.op.startLine - 1, result.op.endLine),
|
|
4763
|
+
delta: result.op.after.length - result.op.before.length }
|
|
4764
|
+
: undefined;
|
|
4765
|
+
if (!(await safeRerenderAll(editRange))) {
|
|
3865
4766
|
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
3866
4767
|
}
|
|
3867
4768
|
}
|
|
@@ -3927,7 +4828,27 @@
|
|
|
3927
4828
|
const ok = await switchAwayFrom();
|
|
3928
4829
|
if (!ok) return;
|
|
3929
4830
|
}
|
|
3930
|
-
|
|
4831
|
+
// v3.1.0 修正 4: which LINES this raw edit owns is now §3.3's membership
|
|
4832
|
+
// question, asked through the same door every other ⠿ operation uses.
|
|
4833
|
+
// resolveGutterOperands() brings the whole shared preamble with it — the
|
|
4834
|
+
// re-resolve after a commit that re-rendered, the no-source-line refusal,
|
|
4835
|
+
// the emptiness and CONTIGUITY gates — which is what makes a span range
|
|
4836
|
+
// safe to take as `recs[0].startLine .. recs[last].endLine`: a disjoint
|
|
4837
|
+
// selection would refuse here rather than quietly swallowing the blocks
|
|
4838
|
+
// sitting in the gap. Its own switchAwayFrom() is a no-op by this point
|
|
4839
|
+
// (the branch above already resolved whatever was open).
|
|
4840
|
+
const operands = await resolveGutterOperands(blockEl);
|
|
4841
|
+
if (!operands) return;
|
|
4842
|
+
const els = operands.els;
|
|
4843
|
+
const recs = operands.recs;
|
|
4844
|
+
const liveBlockEl = els[0];
|
|
4845
|
+
const isLi = liveBlockEl.getAttribute('data-block-type') === 'li';
|
|
4846
|
+
// A single non-li block keeps the pre-v3.1.0 path exactly: no range, so
|
|
4847
|
+
// openRawEditor() uses the block's own record and restores by innerHTML.
|
|
4848
|
+
const range = (recs.length > 1 || isLi)
|
|
4849
|
+
? { startLine: recs[0].startLine, endLine: recs[recs.length - 1].endLine }
|
|
4850
|
+
: null;
|
|
4851
|
+
await openRawEditor(liveBlockEl, range);
|
|
3931
4852
|
}
|
|
3932
4853
|
|
|
3933
4854
|
// Starts a burst on `editEl` (a `.ed-wys-armed` content element) — called
|
|
@@ -4119,10 +5040,20 @@
|
|
|
4119
5040
|
const { md: runMd, unsupported, unsupportedByLi, lineMeta } =
|
|
4120
5041
|
listMd.serializeBlocks(runEls, { carryOver: carry });
|
|
4121
5042
|
// Refuse if the EDITED li itself has unsupported inline content.
|
|
4122
|
-
//
|
|
4123
|
-
//
|
|
4124
|
-
//
|
|
4125
|
-
//
|
|
5043
|
+
// Show banner + teardown + rerenderAll (file is untouched, burst never
|
|
5044
|
+
// wrote to `lines`) + return false — this path does NOT fall back to
|
|
5045
|
+
// openRawEditor().
|
|
5046
|
+
//
|
|
5047
|
+
// ⚠ v3.1.0 Ruling 13 supersedes RULING F-O for the ⠿ menu's MD 原始碼
|
|
5048
|
+
// (see toggleGutterMenu()), and NOT for this site. Two different
|
|
5049
|
+
// reasons: F-O was about the CONTAINER (a textarea cannot live inside a
|
|
5050
|
+
// serialized run), which 修正 4 answers by restoring through a
|
|
5051
|
+
// re-render; this refusal is about the CONTENT — the run's own
|
|
5052
|
+
// serializer has just reported it cannot round-trip this item, and
|
|
5053
|
+
// silently opening an editor from inside a burst resolution would be a
|
|
5054
|
+
// second, unasked-for gesture on top of a failed one. The banner's
|
|
5055
|
+
// advice ('改用原始碼編輯') is now literally actionable, which it was
|
|
5056
|
+
// not before: the user can reach exactly that through the ⠿ menu.
|
|
4126
5057
|
const editedIdStr = String(burst.blockId);
|
|
4127
5058
|
if (unsupportedByLi.some((u) => u.blockId === editedIdStr)) {
|
|
4128
5059
|
showBanner('含不支援的格式,改用原始碼編輯', null, null);
|
|
@@ -4202,7 +5133,12 @@
|
|
|
4202
5133
|
}
|
|
4203
5134
|
const liPrevLines = lines;
|
|
4204
5135
|
lines = liCommitResult.lines;
|
|
4205
|
-
const
|
|
5136
|
+
const editRange = liCommitResult.op
|
|
5137
|
+
? { startLine: Math.min(liCommitResult.op.startLine, liCommitResult.op.endLine + 1),
|
|
5138
|
+
endLine: Math.max(liCommitResult.op.startLine - 1, liCommitResult.op.endLine),
|
|
5139
|
+
delta: liCommitResult.op.after.length - liCommitResult.op.before.length }
|
|
5140
|
+
: undefined;
|
|
5141
|
+
const liOk = await safeRerenderAll(editRange);
|
|
4206
5142
|
if (!liOk) {
|
|
4207
5143
|
lines = rollbackFailedRender({ lines, stack }, liCommitResult, liPrevLines);
|
|
4208
5144
|
return false;
|
|
@@ -4258,7 +5194,12 @@
|
|
|
4258
5194
|
}
|
|
4259
5195
|
const prevLines = lines;
|
|
4260
5196
|
lines = commitResult.lines;
|
|
4261
|
-
const
|
|
5197
|
+
const editRange = commitResult.op
|
|
5198
|
+
? { startLine: Math.min(commitResult.op.startLine, commitResult.op.endLine + 1),
|
|
5199
|
+
endLine: Math.max(commitResult.op.startLine - 1, commitResult.op.endLine),
|
|
5200
|
+
delta: commitResult.op.after.length - commitResult.op.before.length }
|
|
5201
|
+
: undefined;
|
|
5202
|
+
const ok = await safeRerenderAll(editRange);
|
|
4262
5203
|
if (!ok) {
|
|
4263
5204
|
lines = rollbackFailedRender({ lines, stack }, commitResult, prevLines);
|
|
4264
5205
|
// Burst stays open: DOM/history untouched, banner already shown by
|
|
@@ -4491,6 +5432,15 @@
|
|
|
4491
5432
|
window.__edTestForceRerender = function () { return rerenderAll(); };
|
|
4492
5433
|
window.__edTestTruncateTo = function (n) { lines = lines.slice(0, n); };
|
|
4493
5434
|
|
|
5435
|
+
// v3.0.2: computeIndentClamp() lives past this file's node early-exit
|
|
5436
|
+
// (module.exports at line ~509 ships the pure core only) and nothing on
|
|
5437
|
+
// window reaches it, so the stale-span assert added to it in v3.0.2 had
|
|
5438
|
+
// no way to be tested at all. This is the seventh __edTest seam and the
|
|
5439
|
+
// only production surface that change adds.
|
|
5440
|
+
window.__edTestClampProbe = function (spanEls, opBlockEl, opOldIndent, opts) {
|
|
5441
|
+
return computeIndentClamp(spanEls, opBlockEl, opOldIndent, opts);
|
|
5442
|
+
};
|
|
5443
|
+
|
|
4494
5444
|
// ── S3 Task 4: the entry and exit GESTURES ─────────────────────────────
|
|
4495
5445
|
// §4.4 entries: (a) press inside a block and drag across its boundary;
|
|
4496
5446
|
// (b) Shift+Click; (c) Shift+↑↓. Exits: Escape (Task 3's keydown prologue)
|
|
@@ -4904,7 +5854,12 @@
|
|
|
4904
5854
|
// still sitting there un-committed — re-render anyway so what's on screen
|
|
4905
5855
|
// is always exactly what's in `lines`.
|
|
4906
5856
|
if (result.op !== null) lines = result.lines;
|
|
4907
|
-
const
|
|
5857
|
+
const editRange = result.op
|
|
5858
|
+
? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
|
|
5859
|
+
endLine: Math.max(result.op.startLine - 1, result.op.endLine),
|
|
5860
|
+
delta: result.op.after.length - result.op.before.length }
|
|
5861
|
+
: undefined;
|
|
5862
|
+
const ok = await safeRerenderAll(editRange);
|
|
4908
5863
|
if (!ok) {
|
|
4909
5864
|
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
4910
5865
|
// Deliberately NOT a second safeRerenderAll(), unlike the two refusals
|
|
@@ -5773,7 +6728,12 @@
|
|
|
5773
6728
|
// §3.3's collapse: a depth change rewrites no line COUNT, so the set
|
|
5774
6729
|
// collapses onto the lines it already held.
|
|
5775
6730
|
declareCollapse({ startLine: first.startLine, endLine: last.endLine });
|
|
5776
|
-
const
|
|
6731
|
+
const editRange = result.op
|
|
6732
|
+
? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
|
|
6733
|
+
endLine: Math.max(result.op.startLine - 1, result.op.endLine),
|
|
6734
|
+
delta: result.op.after.length - result.op.before.length }
|
|
6735
|
+
: undefined;
|
|
6736
|
+
const okRender = await safeRerenderAll(editRange);
|
|
5777
6737
|
if (!okRender) {
|
|
5778
6738
|
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
5779
6739
|
}
|
|
@@ -5806,6 +6766,63 @@
|
|
|
5806
6766
|
await changeHeadingDepthsInSpan(els, recs, dir);
|
|
5807
6767
|
}
|
|
5808
6768
|
|
|
6769
|
+
// v3.1.0 §4: the toolbar's ⇥ / ⇤ buttons. A ZERO-ARGUMENT commit wrapper,
|
|
6770
|
+
// placed beside tabSelection(dir) above because it is the same shape of
|
|
6771
|
+
// thing: a gesture that is not a keystroke, driving a mutation whose only
|
|
6772
|
+
// existing entry point wanted a KeyboardEvent.
|
|
6773
|
+
//
|
|
6774
|
+
// Why this function has to exist at all: indentListItem()/outdentListItem()
|
|
6775
|
+
// (further up) only ever write `data-indent` on the DOM and return a
|
|
6776
|
+
// boolean. They never touch `lines`, never push an undo op, never
|
|
6777
|
+
// re-render. Wiring a toolbar button straight to either of them produces a
|
|
6778
|
+
// list that visibly re-indents and then snaps back on the next render, with
|
|
6779
|
+
// the file byte-identical throughout. Everything that makes Tab a real edit
|
|
6780
|
+
// lives in handleLiKeydown()'s Tab branch — the run-wide gate, the mutation
|
|
6781
|
+
// under mutateListRun()'s focusout suppression, the indent clamp, the
|
|
6782
|
+
// bystander carry-over and the one-op commit — so that is what is
|
|
6783
|
+
// reproduced here, in the same order, with `e.shiftKey` replaced by `dir`.
|
|
6784
|
+
//
|
|
6785
|
+
// `caretLiBlock()` is zero-argument (it reads window.getSelection()), which
|
|
6786
|
+
// is what makes an editEl-free wrapper possible: the toolbar has no event
|
|
6787
|
+
// target inside the run, only a live caret — and the caret is the right
|
|
6788
|
+
// answer anyway, since a run has one editable surface per item.
|
|
6789
|
+
//
|
|
6790
|
+
// dir > 0 indents, dir < 0 outdents.
|
|
6791
|
+
async function indentCaretLi(dir) {
|
|
6792
|
+
const li = caretLiBlock();
|
|
6793
|
+
if (!li) return;
|
|
6794
|
+
let run = listRunOf(li);
|
|
6795
|
+
if (!run.length) return;
|
|
6796
|
+
// `columnOnly`: an indent change rewrites nothing but leading columns, so
|
|
6797
|
+
// a hard-wrapped item is allowed to be the target — same reading, and the
|
|
6798
|
+
// same measurement, as the Tab branch this mirrors.
|
|
6799
|
+
if (!listRunSupportsStructuralEdit(run, li, { columnOnly: true })) {
|
|
6800
|
+
refuseStructuralListEdit();
|
|
6801
|
+
return;
|
|
6802
|
+
}
|
|
6803
|
+
const oldIndent = Number(li.getAttribute('data-indent')) || 0;
|
|
6804
|
+
const changed = mutateListRun(() => {
|
|
6805
|
+
if (!(dir < 0 ? outdentListItem(li) : indentListItem(li))) return false;
|
|
6806
|
+
// v3.0.1, carried over VERBATIM and deliberately: the clamp must read a
|
|
6807
|
+
// FRESH listRunOf(li) — taken after the write above — not the
|
|
6808
|
+
// pre-mutation `run` captured outside. indentListItem() can move a
|
|
6809
|
+
// rule-(d) list-start item whose pre-mutation run is `[li]` alone; with
|
|
6810
|
+
// no anchor in the span, applyIndentClamp()'s rule 1 has nothing to
|
|
6811
|
+
// bound the new indent against and clamps it straight back to 0,
|
|
6812
|
+
// silently undoing the write. See handleLiKeydown()'s own copy of this
|
|
6813
|
+
// note for the full derivation.
|
|
6814
|
+
applyIndentClamp(listRunOf(li), li, oldIndent);
|
|
6815
|
+
return true;
|
|
6816
|
+
});
|
|
6817
|
+
if (!changed) return; // at the boundary: nothing mutated, nothing committed
|
|
6818
|
+
run = listRunOf(li);
|
|
6819
|
+
// Column-only: nothing's CONTENT moved, so every block in the span — the
|
|
6820
|
+
// target included — is a bystander whose source bytes come back
|
|
6821
|
+
// untouched. Built once and shared with runLineOfBlock() below.
|
|
6822
|
+
const carry = bystanderCarryOver(run, null);
|
|
6823
|
+
await commitListStructure(run, runLineOfBlock(run, li, carry), true, { carryOver: carry });
|
|
6824
|
+
}
|
|
6825
|
+
|
|
5809
6826
|
// §3.6's 「Delete 整批刪」, whose 2026-08-31 ruling is that it goes through
|
|
5810
6827
|
// the SAME batch path as the ⠿ menu's 刪除 and that no second deletion path
|
|
5811
6828
|
// is written. deleteBlockViaGutter() already resolves §3.3's membership for
|
|
@@ -7139,6 +8156,40 @@
|
|
|
7139
8156
|
let gripColTableEl = null;
|
|
7140
8157
|
let gripColIndex = null;
|
|
7141
8158
|
|
|
8159
|
+
// v3.0.2 — the gutter keep-alive for the table row grip. Held in its own
|
|
8160
|
+
// module-level reference, NOT read back off `gripRowTableEl`, because
|
|
8161
|
+
// hideTableGrips() below nulls that at its own line 7246-7248: a teardown
|
|
8162
|
+
// that went looking for the block through it would find null exactly when
|
|
8163
|
+
// it needs to clear the class, and rerenderAll()'s hideTableGrips() call
|
|
8164
|
+
// reaches it with the table already detached.
|
|
8165
|
+
let gutterKeepEl = null;
|
|
8166
|
+
|
|
8167
|
+
function setGutterKeep(blockEl) {
|
|
8168
|
+
const next = blockEl || null;
|
|
8169
|
+
if (gutterKeepEl === next) return;
|
|
8170
|
+
if (gutterKeepEl) gutterKeepEl.classList.remove('ed-keep-lit');
|
|
8171
|
+
gutterKeepEl = next;
|
|
8172
|
+
if (gutterKeepEl) gutterKeepEl.classList.add('ed-keep-lit');
|
|
8173
|
+
}
|
|
8174
|
+
|
|
8175
|
+
function clearGutterKeep() { setGutterKeep(null); }
|
|
8176
|
+
|
|
8177
|
+
// The block whose gutter the CURRENTLY SHOWN row grip is covering, or null
|
|
8178
|
+
// when there is nothing to keep lit. Two conditions, both load-bearing:
|
|
8179
|
+
//
|
|
8180
|
+
// • gripRowTableEl / gripRowEl may be null while a grip is nonetheless
|
|
8181
|
+
// visible — a header-only table takes the else branch at 7466-7470 and
|
|
8182
|
+
// nulls both, then the column grip is shown unconditionally just
|
|
8183
|
+
// below. Both callers of this function are reached in that state.
|
|
8184
|
+
// • only the HEADER row's grip shares a Y with the ⠿. Keeping the
|
|
8185
|
+
// gutter lit for a body row's grip would light the ⠿ in a place the
|
|
8186
|
+
// defect never occurred — a behaviour change beyond the fix.
|
|
8187
|
+
function headerGripBlock() {
|
|
8188
|
+
if (!gripRowTableEl || !gripRowEl) return null;
|
|
8189
|
+
if (gripRowEl !== headerRowOf(gripRowTableEl)) return null;
|
|
8190
|
+
return gripRowTableEl.closest('.ed-block');
|
|
8191
|
+
}
|
|
8192
|
+
|
|
7142
8193
|
function hideTableGrips() {
|
|
7143
8194
|
rowGrip.hidden = true;
|
|
7144
8195
|
colGrip.hidden = true;
|
|
@@ -7153,6 +8204,7 @@
|
|
|
7153
8204
|
gripRowEl = null;
|
|
7154
8205
|
gripColTableEl = null;
|
|
7155
8206
|
gripColIndex = null;
|
|
8207
|
+
clearGutterKeep();
|
|
7156
8208
|
}
|
|
7157
8209
|
|
|
7158
8210
|
// Bug fix (user acceptance) — history: grips were originally BOTH
|
|
@@ -7298,7 +8350,15 @@
|
|
|
7298
8350
|
// whatever was last shown untouched instead; hideTableGrips() (called
|
|
7299
8351
|
// from table-leave, burst-end, and drag-start elsewhere) already covers
|
|
7300
8352
|
// every path that actually needs to clear it.
|
|
7301
|
-
if (target && target.closest && (target.closest('.ed-te-grip-row') || target.closest('.ed-te-grip-col')))
|
|
8353
|
+
if (target && target.closest && (target.closest('.ed-te-grip-row') || target.closest('.ed-te-grip-col'))) {
|
|
8354
|
+
// v3.0.2: only the ROW grip is the one that covers the ⠿'s approach.
|
|
8355
|
+
// Testing the hit rather than reusing the branch condition matters —
|
|
8356
|
+
// the column grip sits above the table's top border, nowhere near the
|
|
8357
|
+
// ⠿, and lighting the gutter from there is the behaviour change this
|
|
8358
|
+
// fix is scoped to avoid.
|
|
8359
|
+
setGutterKeep(target.closest('.ed-te-grip-row') ? headerGripBlock() : null);
|
|
8360
|
+
return;
|
|
8361
|
+
}
|
|
7302
8362
|
const blockEl = target && target.closest && target.closest('.ed-block[data-block-type="table"]');
|
|
7303
8363
|
const tableEl = blockEl ? blockContentEl(blockEl) : null;
|
|
7304
8364
|
const cellEl = (tableEl && target && target.closest) ? target.closest('th, td') : null;
|
|
@@ -7311,10 +8371,15 @@
|
|
|
7311
8371
|
// comment for why not the whole table). Keep that grip up while still
|
|
7312
8372
|
// in its zone; only actually hide once the pointer has left both
|
|
7313
8373
|
// zones entirely.
|
|
7314
|
-
|
|
8374
|
+
const inRowZone = pointInRowGripZone(x, y);
|
|
8375
|
+
if (inRowZone || pointInColGripZone(x, y)) {
|
|
8376
|
+
setGutterKeep(inRowZone ? headerGripBlock() : null);
|
|
8377
|
+
return;
|
|
8378
|
+
}
|
|
7315
8379
|
hideTableGrips();
|
|
7316
8380
|
return;
|
|
7317
8381
|
}
|
|
8382
|
+
clearGutterKeep();
|
|
7318
8383
|
|
|
7319
8384
|
const tableRect = tableEl.getBoundingClientRect();
|
|
7320
8385
|
const rowEl = cellEl.parentElement;
|
|
@@ -8205,7 +9270,12 @@
|
|
|
8205
9270
|
// (blockSelection)` guard is what stops this single-block gesture from
|
|
8206
9271
|
// INVENTING a selection when none was standing.
|
|
8207
9272
|
declareCollapse(result.newRange);
|
|
8208
|
-
const
|
|
9273
|
+
const editRange = result.op
|
|
9274
|
+
? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
|
|
9275
|
+
endLine: Math.max(result.op.startLine - 1, result.op.endLine),
|
|
9276
|
+
delta: result.op.after.length - result.op.before.length }
|
|
9277
|
+
: undefined;
|
|
9278
|
+
const okRender = await safeRerenderAll(editRange);
|
|
8209
9279
|
if (!okRender) {
|
|
8210
9280
|
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
8211
9281
|
}
|
|
@@ -8804,16 +9874,40 @@
|
|
|
8804
9874
|
// same way activateCell() used to — brief: "wire your cell edit root the
|
|
8805
9875
|
// same way paragraph editing does (reuse, don't fork)").
|
|
8806
9876
|
function onSelectionChangeForToolbar() {
|
|
8807
|
-
|
|
8808
|
-
|
|
8809
|
-
|
|
8810
|
-
|
|
8811
|
-
|
|
8812
|
-
|
|
8813
|
-
|
|
8814
|
-
|
|
9877
|
+
// v3.1.0: the fixed toolbar's five inline-format buttons enable/disable on
|
|
9878
|
+
// exactly the same question this listener already answers ("is there a
|
|
9879
|
+
// non-collapsed selection inside `selToolbarEditEl`?"), so it piggybacks
|
|
9880
|
+
// here instead of attaching a SECOND document-level selectionchange
|
|
9881
|
+
// listener.
|
|
9882
|
+
//
|
|
9883
|
+
// That is not just tidiness. test/editor-client-runtime.test.js
|
|
9884
|
+
// instruments document.addEventListener for this exact type and asserts
|
|
9885
|
+
// the live count is 1 during a session and 0 after — the property being
|
|
9886
|
+
// that a burst's listener is removed on rerender and never stacks. A
|
|
9887
|
+
// permanent second listener would not leak or stack, but it would move
|
|
9888
|
+
// that counter's baseline and force three numeric assertions plus a
|
|
9889
|
+
// waitForFunction(count === 0) to be rewritten around it, weakening a
|
|
9890
|
+
// leak guard to accommodate a convenience. Reusing this one keeps the
|
|
9891
|
+
// guard exactly as written, and is also strictly more correct: with no
|
|
9892
|
+
// burst open `selToolbarEditEl` is null, so `hasSelection` is false —
|
|
9893
|
+
// which is precisely when this listener is not attached.
|
|
9894
|
+
try {
|
|
9895
|
+
const selNow = window.getSelection();
|
|
9896
|
+
if (!selNow || selNow.rangeCount === 0 || selNow.isCollapsed) { hideSelToolbar(); return; }
|
|
9897
|
+
const range = selNow.getRangeAt(0);
|
|
9898
|
+
if (!selToolbarEditEl ||
|
|
9899
|
+
!selToolbarEditEl.contains(range.startContainer) ||
|
|
9900
|
+
!selToolbarEditEl.contains(range.endContainer)) {
|
|
9901
|
+
hideSelToolbar();
|
|
9902
|
+
return;
|
|
9903
|
+
}
|
|
9904
|
+
positionSelToolbar(range);
|
|
9905
|
+
} finally {
|
|
9906
|
+
// In a `finally` so every early return above still refreshes the bar —
|
|
9907
|
+
// the collapse-to-caret exits are exactly the ones that must turn the
|
|
9908
|
+
// mark buttons back off.
|
|
9909
|
+
updateToolbar();
|
|
8815
9910
|
}
|
|
8816
|
-
positionSelToolbar(range);
|
|
8817
9911
|
}
|
|
8818
9912
|
|
|
8819
9913
|
// Nearest ancestor of `node` (inclusive) with tagName `tag`, stopping at
|
|
@@ -9305,7 +10399,28 @@
|
|
|
9305
10399
|
// DECLARED rather than cleared outright so the rollback below leaves a
|
|
9306
10400
|
// standing selection alone when the render fails.
|
|
9307
10401
|
declareSelectionRange(null);
|
|
9308
|
-
|
|
10402
|
+
// v3.2.0: UndoStack.undo()/discardTop() reverse the op when applying it but
|
|
10403
|
+
// return the unmodified FORWARD op, so the range actually rewritten is
|
|
10404
|
+
// [op.startLine, op.startLine + op.after.length - 1] and the delta is the
|
|
10405
|
+
// mirror of the forward one. Using op.endLine here would name the opposite
|
|
10406
|
+
// direction — the span the op wrote on the way FORWARD, not the one this
|
|
10407
|
+
// reversal just rewrote.
|
|
10408
|
+
//
|
|
10409
|
+
// Final review item 4 — what naming it wrongly would cost today: nothing.
|
|
10410
|
+
// Per 決議 4(b) `editRange` is ADVISORY. patchmap() destructures
|
|
10411
|
+
// oldBlocks/newBlocks/oldParts/newParts and never reads it; the kept
|
|
10412
|
+
// prefix/suffix is the longest common run of the two PART arrays, and
|
|
10413
|
+
// seeding it from an op's line range only ever made the answer worse
|
|
10414
|
+
// (measured in 決議 4(b): seeded kept 0 of 5, pure-parts kept 4 of 5).
|
|
10415
|
+
// Its one client-side use is the truthiness gate in applyRenderResult().
|
|
10416
|
+
// So the direction changes no plan — this is simply the correct value for
|
|
10417
|
+
// whenever something does start reading it.
|
|
10418
|
+
const editRange = r.op
|
|
10419
|
+
? { startLine: r.op.startLine,
|
|
10420
|
+
endLine: r.op.startLine + r.op.after.length - 1,
|
|
10421
|
+
delta: r.op.before.length - r.op.after.length }
|
|
10422
|
+
: undefined;
|
|
10423
|
+
const ok = await safeRerenderAll(editRange);
|
|
9309
10424
|
if (!ok) {
|
|
9310
10425
|
// Reverse the undo attempt: push the op back and restore `lines` to
|
|
9311
10426
|
// what was on screen before this undo was requested.
|
|
@@ -9321,7 +10436,15 @@
|
|
|
9321
10436
|
if (!r) return;
|
|
9322
10437
|
lines = r.lines;
|
|
9323
10438
|
declareSelectionRange(null); // §4.4, same as undo() above
|
|
9324
|
-
|
|
10439
|
+
// redo() is NOT reversed — it goes through
|
|
10440
|
+
// replaceLines(lines, op.startLine, op.endLine, op.after), so the
|
|
10441
|
+
// ordinary forward formula applies here, unlike undo() above.
|
|
10442
|
+
const editRange = r.op
|
|
10443
|
+
? { startLine: Math.min(r.op.startLine, r.op.endLine + 1),
|
|
10444
|
+
endLine: Math.max(r.op.startLine - 1, r.op.endLine),
|
|
10445
|
+
delta: r.op.after.length - r.op.before.length }
|
|
10446
|
+
: undefined;
|
|
10447
|
+
const ok = await safeRerenderAll(editRange);
|
|
9325
10448
|
if (!ok) {
|
|
9326
10449
|
// Reverse the redo attempt: pop the op back off and restore `lines`.
|
|
9327
10450
|
const rollback = stack.undo(lines);
|
|
@@ -9329,6 +10452,806 @@
|
|
|
9329
10452
|
}
|
|
9330
10453
|
}
|
|
9331
10454
|
|
|
10455
|
+
// ── v3.1.0 §4: the always-visible editing toolbar ──────────────────────
|
|
10456
|
+
// The version's headline feature: before this there was no toolbar at all,
|
|
10457
|
+
// and every editing affordance was hidden behind a hover gutter, a caret
|
|
10458
|
+
// position or a text selection. window.md2docToolbarModel (injected by
|
|
10459
|
+
// server.js, built and reviewed as lib/editor/toolbar-model.js) owns the
|
|
10460
|
+
// 22-button roster, the group order that decides separator placement, and
|
|
10461
|
+
// the whole active/disabled derivation; everything below is mounting and
|
|
10462
|
+
// wiring. Nothing here re-derives a button's state by hand — a second
|
|
10463
|
+
// opinion about when ⇥ is legal is exactly what the pure-module split
|
|
10464
|
+
// exists to prevent.
|
|
10465
|
+
//
|
|
10466
|
+
// EVERY existing entry point stays: the ⠿ menu, the + menu and the
|
|
10467
|
+
// floating selection toolbar are untouched. This is one more road to the
|
|
10468
|
+
// same operations, not a replacement for any of them.
|
|
10469
|
+
let toolbarEl = null;
|
|
10470
|
+
let toolbarBtns = null; // { [buttonId]: HTMLButtonElement }
|
|
10471
|
+
let toolbarMenuEl = null; // the H▾ dropdown panel, or null when closed
|
|
10472
|
+
let toolbarBlockEl = null;
|
|
10473
|
+
let toolbarStateSig = '';
|
|
10474
|
+
// 追加 4's three-state document mode: 'edit' | 'source' | 'preview'.
|
|
10475
|
+
// docsource.js owns the cycle order and the lines[]<->string conversion.
|
|
10476
|
+
let docMode = 'edit';
|
|
10477
|
+
let sourceTextarea = null;
|
|
10478
|
+
let sourceBaseLines = null;
|
|
10479
|
+
// 追加 2: Ctrl+Shift+V arms a plain-text-only paste for the NEXT paste
|
|
10480
|
+
// event. A ClipboardEvent carries no modifier state of its own, so the
|
|
10481
|
+
// intent has to be captured on the keystroke that produced it.
|
|
10482
|
+
let plainPasteArmed = false;
|
|
10483
|
+
|
|
10484
|
+
const TOOLBAR_UNSUPPORTED_IMAGE_MESSAGE = '只接受 PNG / JPEG / GIF / WebP 圖片';
|
|
10485
|
+
const TOOLBAR_NO_ANCHOR_MESSAGE = '找不到可以插入圖片的位置';
|
|
10486
|
+
|
|
10487
|
+
// Extra visual classes for the three inline buttons whose own glyph is the
|
|
10488
|
+
// affordance (B is bold, I is italic, S is struck through) — the same
|
|
10489
|
+
// idiom .ed-seltb-b/-i/-s use.
|
|
10490
|
+
const TOOLBAR_BTN_CLASS = {
|
|
10491
|
+
bold: 'ed-toolbar-b', italic: 'ed-toolbar-i', strike: 'ed-toolbar-s',
|
|
10492
|
+
};
|
|
10493
|
+
|
|
10494
|
+
// The buttons whose model state can ever report `active: true` — i.e. the
|
|
10495
|
+
// real toggles, and the only ones that may carry aria-pressed. Every other
|
|
10496
|
+
// button is a command, not a toggle.
|
|
10497
|
+
// v3.2.0: derived, not mirrored. A seventh toggle added to toolbar-model.js
|
|
10498
|
+
// used to need a matching edit here, and forgetting it made aria-pressed lie.
|
|
10499
|
+
const TOOLBAR_TOGGLE_IDS = new Set(
|
|
10500
|
+
(toolbarModel ? toolbarModel.BUTTONS : []).filter((b) => b.toggle).map((b) => b.id));
|
|
10501
|
+
|
|
10502
|
+
// The block the bar currently reports on, or null. `null` is the model's
|
|
10503
|
+
// documented no-block state (undo/redo/outline/preview live, everything
|
|
10504
|
+
// else greyed), which is exactly what rerenderAll()'s teardown wants — see
|
|
10505
|
+
// resetToolbarBlock() below.
|
|
10506
|
+
function liveToolbarBlock() {
|
|
10507
|
+
if (toolbarBlockEl && document.body.contains(toolbarBlockEl)) return toolbarBlockEl;
|
|
10508
|
+
toolbarBlockEl = null;
|
|
10509
|
+
return null;
|
|
10510
|
+
}
|
|
10511
|
+
|
|
10512
|
+
// Called from rerenderAll()'s belt-and-braces teardown. A detached node's
|
|
10513
|
+
// getAttribute() still answers, so a tracked block that survived the
|
|
10514
|
+
// .content swap would keep the bar reporting a block no click can reach.
|
|
10515
|
+
function resetToolbarBlock() {
|
|
10516
|
+
toolbarBlockEl = null;
|
|
10517
|
+
closeToolbarMenu();
|
|
10518
|
+
// A render that lands while the document is in preview mode re-arms every
|
|
10519
|
+
// surface as editable (armEditables() has no notion of modes), so the
|
|
10520
|
+
// mode's one real effect has to be re-applied on the fresh DOM.
|
|
10521
|
+
if (docMode === 'preview') applyPreviewEditability(false);
|
|
10522
|
+
updateToolbar();
|
|
10523
|
+
}
|
|
10524
|
+
|
|
10525
|
+
// Final review item 5: the EXACT predicate applyMarkToggle() and
|
|
10526
|
+
// applyLinkToggle() gate on — all four of their conditions, not three.
|
|
10527
|
+
// Kept as one function so the toolbar's enablement and the actions' own
|
|
10528
|
+
// refusal cannot drift apart: whenever this says true, both actions run.
|
|
10529
|
+
//
|
|
10530
|
+
// The fourth condition (containment) is the one `hasSelection` used to
|
|
10531
|
+
// omit, and it is reachable: a drag-select that STARTS inside an open
|
|
10532
|
+
// burst and ENDS in the next block leaves the selection non-collapsed with
|
|
10533
|
+
// `selToolbarEditEl` still set. The floating selection toolbar already
|
|
10534
|
+
// asks this same question of itself and correctly hides (see its
|
|
10535
|
+
// selectionchange listener above), but updateToolbar() still ran and lit
|
|
10536
|
+
// the five inline-format buttons over a selection every one of them
|
|
10537
|
+
// silently refuses — precisely the dead-button failure this version's
|
|
10538
|
+
// enablement wiring exists to remove.
|
|
10539
|
+
function hasFormattableSelection(sel) {
|
|
10540
|
+
if (!selToolbarEditEl || !sel || sel.rangeCount === 0 || sel.isCollapsed) return false;
|
|
10541
|
+
const range = sel.getRangeAt(0);
|
|
10542
|
+
if (range.collapsed) return false;
|
|
10543
|
+
return selToolbarEditEl.contains(range.startContainer) &&
|
|
10544
|
+
selToolbarEditEl.contains(range.endContainer);
|
|
10545
|
+
}
|
|
10546
|
+
|
|
10547
|
+
// ctx for toolbarModel.deriveState(). Every field is read off the DOM /
|
|
10548
|
+
// `lines` at call time; nothing is cached, so a stale value cannot outlive
|
|
10549
|
+
// a render.
|
|
10550
|
+
function toolbarContext() {
|
|
10551
|
+
const blockEl = liveToolbarBlock();
|
|
10552
|
+
const blockType = blockEl ? blockEl.getAttribute('data-block-type') : null;
|
|
10553
|
+
let headingDepth = 1;
|
|
10554
|
+
if (blockType === 'heading') {
|
|
10555
|
+
const rec = blockRecOf(blockEl);
|
|
10556
|
+
if (rec && rec.endLine >= rec.startLine) headingDepth = headingDepthOf(lines[rec.startLine - 1]);
|
|
10557
|
+
}
|
|
10558
|
+
const sel = typeof window.getSelection === 'function' ? window.getSelection() : null;
|
|
10559
|
+
return {
|
|
10560
|
+
blockType: blockType,
|
|
10561
|
+
indent: blockEl ? (Number(blockEl.getAttribute('data-indent')) || 0) : 0,
|
|
10562
|
+
headingDepth: headingDepth,
|
|
10563
|
+
inList: blockType === 'li',
|
|
10564
|
+
listOrdered: !!blockEl && blockEl.getAttribute('data-list-type') === 'ol',
|
|
10565
|
+
// The mark buttons act through applyMarkToggle()/applyLinkToggle();
|
|
10566
|
+
// hasFormattableSelection() (just above) is their refusal test spelled
|
|
10567
|
+
// once, rather than a looser "is anything selected anywhere on the page".
|
|
10568
|
+
hasSelection: hasFormattableSelection(sel),
|
|
10569
|
+
mode: docMode,
|
|
10570
|
+
};
|
|
10571
|
+
}
|
|
10572
|
+
|
|
10573
|
+
function updateToolbar() {
|
|
10574
|
+
if (!toolbarEl || !toolbarBtns || !toolbarModel) return;
|
|
10575
|
+
const state = toolbarModel.deriveState(toolbarContext());
|
|
10576
|
+
// selectionchange fires on every caret move, so the state is diffed
|
|
10577
|
+
// before any DOM is written — 22 attribute writes per keystroke is a
|
|
10578
|
+
// cost with no matching benefit.
|
|
10579
|
+
const sig = JSON.stringify(state);
|
|
10580
|
+
if (sig === toolbarStateSig) return;
|
|
10581
|
+
toolbarStateSig = sig;
|
|
10582
|
+
Object.keys(toolbarBtns).forEach((id) => {
|
|
10583
|
+
const st = state[id];
|
|
10584
|
+
const btn = toolbarBtns[id];
|
|
10585
|
+
if (!st || !btn) return;
|
|
10586
|
+
btn.disabled = !!st.disabled;
|
|
10587
|
+
// aria-pressed ONLY on the buttons that can actually be pressed-in.
|
|
10588
|
+
// Writing it to all 22 made a screen reader announce `undo`, `image`
|
|
10589
|
+
// and `table` as toggle buttons stuck in the "not pressed" state,
|
|
10590
|
+
// which is a lie about what they do.
|
|
10591
|
+
if (TOOLBAR_TOGGLE_IDS.has(id)) {
|
|
10592
|
+
btn.setAttribute('aria-pressed', st.active ? 'true' : 'false');
|
|
10593
|
+
}
|
|
10594
|
+
// Only `headings` carries a label (H / H1..H6); every other button
|
|
10595
|
+
// keeps the glyph it was built with.
|
|
10596
|
+
if (typeof st.label === 'string') btn.textContent = st.label;
|
|
10597
|
+
});
|
|
10598
|
+
}
|
|
10599
|
+
|
|
10600
|
+
function buildToolbar() {
|
|
10601
|
+
const bar = document.createElement('div');
|
|
10602
|
+
bar.className = 'ed-toolbar';
|
|
10603
|
+
bar.setAttribute('role', 'toolbar');
|
|
10604
|
+
bar.setAttribute('aria-label', '編輯工具列');
|
|
10605
|
+
const btns = {};
|
|
10606
|
+
let prevGroup = null;
|
|
10607
|
+
toolbarModel.BUTTONS.forEach((def) => {
|
|
10608
|
+
// GROUPS order doubles as separator placement: one divider wherever
|
|
10609
|
+
// consecutive buttons change group.
|
|
10610
|
+
if (prevGroup !== null && def.group !== prevGroup) {
|
|
10611
|
+
const sep = document.createElement('span');
|
|
10612
|
+
sep.className = 'ed-toolbar-sep';
|
|
10613
|
+
bar.appendChild(sep);
|
|
10614
|
+
}
|
|
10615
|
+
prevGroup = def.group;
|
|
10616
|
+
const btn = document.createElement('button');
|
|
10617
|
+
btn.type = 'button';
|
|
10618
|
+
btn.className = 'ed-toolbar-btn' +
|
|
10619
|
+
(TOOLBAR_BTN_CLASS[def.id] ? ' ' + TOOLBAR_BTN_CLASS[def.id] : '');
|
|
10620
|
+
btn.textContent = def.icon;
|
|
10621
|
+
btn.title = def.title;
|
|
10622
|
+
btn.setAttribute('aria-label', def.title);
|
|
10623
|
+
btn.setAttribute('data-ed-tb', def.id);
|
|
10624
|
+
// Not a tab stop, same as the ⠿ / + gutter buttons: this is chrome,
|
|
10625
|
+
// and the Tab key belongs to the document's own structure.
|
|
10626
|
+
btn.setAttribute('tabindex', '-1');
|
|
10627
|
+
// ⚠ LOAD-BEARING, not a nicety. Without it the five inline-format
|
|
10628
|
+
// buttons are dead on arrival: a <button> taking focus on mousedown
|
|
10629
|
+
// fires the armed surface's focusout, which commits and re-renders,
|
|
10630
|
+
// so applyMarkToggle()'s `selToolbarEditEl` is null and the selection
|
|
10631
|
+
// is gone before the click handler ever runs. buildSelToolbar() and
|
|
10632
|
+
// both table grip builders defend themselves the identical way.
|
|
10633
|
+
btn.addEventListener('mousedown', (e) => e.preventDefault());
|
|
10634
|
+
btn.addEventListener('click', (e) => {
|
|
10635
|
+
e.stopPropagation();
|
|
10636
|
+
Promise.resolve(runToolbarAction(def.id, btn)).catch(() => {}).then(updateToolbar);
|
|
10637
|
+
});
|
|
10638
|
+
bar.appendChild(btn);
|
|
10639
|
+
btns[def.id] = btn;
|
|
10640
|
+
});
|
|
10641
|
+
toolbarBtns = btns;
|
|
10642
|
+
// v3.1.0 的 spec 承諾「元素存在但無內容,§3 上線時填入、不需要重排版面」,
|
|
10643
|
+
// 但元素從未被建立。這裡把承諾兌現:佔好寬度、無內容。
|
|
10644
|
+
const statusEl = document.createElement('span');
|
|
10645
|
+
statusEl.className = 'ed-toolbar-status';
|
|
10646
|
+
bar.appendChild(statusEl);
|
|
10647
|
+
return bar;
|
|
10648
|
+
}
|
|
10649
|
+
|
|
10650
|
+
async function runToolbarAction(id, btnEl) {
|
|
10651
|
+
const blockEl = liveToolbarBlock();
|
|
10652
|
+
switch (id) {
|
|
10653
|
+
// ⚠ The BROWSER-side zero-argument undo()/redo(), never `stack.undo` /
|
|
10654
|
+
// `stack.redo`. Those are the pure core's helpers (they are what
|
|
10655
|
+
// rollbackFailedRender() reverses an op with) and calling them from
|
|
10656
|
+
// here would skip switchAwayFrom() and safeRerenderAll() — the exact
|
|
10657
|
+
// shape this file records as having produced a silent total lockout
|
|
10658
|
+
// recoverable only by reloading the page.
|
|
10659
|
+
case 'undo': return undo();
|
|
10660
|
+
case 'redo': return redo();
|
|
10661
|
+
case 'headings': return toggleToolbarHeadingMenu(btnEl);
|
|
10662
|
+
// The five conversion targets are convert-md.js's own ids, so the
|
|
10663
|
+
// gesture goes through convertBlockViaMenu()'s §3.7 / §4.3 gates
|
|
10664
|
+
// (withheld types, mixed spans, run support) exactly like the ⠿
|
|
10665
|
+
// submenu's items do.
|
|
10666
|
+
case 'quote': return blockEl ? convertBlockViaMenu(blockEl, 'quote') : undefined;
|
|
10667
|
+
case 'code': return blockEl ? convertBlockViaMenu(blockEl, 'code') : undefined;
|
|
10668
|
+
case 'list': return blockEl ? convertBlockViaMenu(blockEl, 'ul') : undefined;
|
|
10669
|
+
case 'ordered-list': return blockEl ? convertBlockViaMenu(blockEl, 'ol') : undefined;
|
|
10670
|
+
case 'check': return blockEl ? convertBlockViaMenu(blockEl, 'task') : undefined;
|
|
10671
|
+
case 'bold': return applyMarkToggle('STRONG');
|
|
10672
|
+
case 'italic': return applyMarkToggle('EM');
|
|
10673
|
+
case 'strike': return applyMarkToggle('DEL');
|
|
10674
|
+
case 'inline-code': return applyMarkToggle('CODE');
|
|
10675
|
+
case 'link': return applyLinkToggle();
|
|
10676
|
+
// NOT indentListItem()/outdentListItem(): those only write data-indent
|
|
10677
|
+
// on the DOM and never commit. See indentCaretLi()'s own comment.
|
|
10678
|
+
case 'outdent': return indentCaretLi(-1);
|
|
10679
|
+
case 'indent': return indentCaretLi(1);
|
|
10680
|
+
case 'table': return blockEl ? insertBlockBelow(blockEl, 'table') : undefined;
|
|
10681
|
+
case 'line': return blockEl ? insertBlockBelow(blockEl, 'line') : undefined;
|
|
10682
|
+
case 'insert-after': return blockEl ? insertBlockBelow(blockEl, 'paragraph') : undefined;
|
|
10683
|
+
case 'insert-before': return blockEl ? insertBlockAbove(blockEl) : undefined;
|
|
10684
|
+
case 'image': return pickAndInsertImage();
|
|
10685
|
+
case 'outline': return toggleOutlineSidebar();
|
|
10686
|
+
case 'preview': return cycleDocMode();
|
|
10687
|
+
default: return undefined;
|
|
10688
|
+
}
|
|
10689
|
+
}
|
|
10690
|
+
|
|
10691
|
+
// ── headings ▾ ─────────────────────────────────────────────────────────
|
|
10692
|
+
function closeToolbarMenu() {
|
|
10693
|
+
if (toolbarMenuEl) { toolbarMenuEl.remove(); toolbarMenuEl = null; }
|
|
10694
|
+
}
|
|
10695
|
+
|
|
10696
|
+
function toggleToolbarHeadingMenu(btnEl) {
|
|
10697
|
+
if (toolbarMenuEl) { closeToolbarMenu(); return; }
|
|
10698
|
+
const menu = document.createElement('div');
|
|
10699
|
+
menu.className = 'ed-toolbar-menu';
|
|
10700
|
+
[1, 2, 3, 4, 5, 6].forEach((n) => {
|
|
10701
|
+
const item = document.createElement('button');
|
|
10702
|
+
item.type = 'button';
|
|
10703
|
+
item.className = 'ed-toolbar-menu-btn';
|
|
10704
|
+
item.textContent = '標題 ' + n;
|
|
10705
|
+
item.setAttribute('tabindex', '-1');
|
|
10706
|
+
item.addEventListener('mousedown', (e) => e.preventDefault());
|
|
10707
|
+
item.addEventListener('click', (e) => {
|
|
10708
|
+
e.stopPropagation();
|
|
10709
|
+
closeToolbarMenu();
|
|
10710
|
+
Promise.resolve(applyHeadingLevel(n)).catch(() => {}).then(updateToolbar);
|
|
10711
|
+
});
|
|
10712
|
+
menu.appendChild(item);
|
|
10713
|
+
});
|
|
10714
|
+
document.body.appendChild(menu);
|
|
10715
|
+
const r = btnEl.getBoundingClientRect();
|
|
10716
|
+
menu.style.top = (r.bottom + 4) + 'px';
|
|
10717
|
+
menu.style.left = Math.max(4, Math.min(r.left, window.innerWidth - 140)) + 'px';
|
|
10718
|
+
toolbarMenuEl = menu;
|
|
10719
|
+
}
|
|
10720
|
+
|
|
10721
|
+
// changeHeadingDepth(blockEl, delta) takes a RELATIVE delta and has no
|
|
10722
|
+
// blockType guard, while headingDepthOf() answers 1 for any non-heading
|
|
10723
|
+
// line — so calling it on a paragraph silently prefixes '## ' and turns it
|
|
10724
|
+
// into an H2 while bypassing convertBlockViaMenu()'s gates entirely. Both
|
|
10725
|
+
// halves of that are handled here: a non-heading is CONVERTED through the
|
|
10726
|
+
// gate (convert-md.js's 'h1'..'h6' targets already carry the level), and a
|
|
10727
|
+
// heading gets a delta computed against its own current depth.
|
|
10728
|
+
async function applyHeadingLevel(n) {
|
|
10729
|
+
const blockEl = liveToolbarBlock();
|
|
10730
|
+
if (!blockEl) return;
|
|
10731
|
+
if (blockEl.getAttribute('data-block-type') !== 'heading') {
|
|
10732
|
+
await convertBlockViaMenu(blockEl, 'h' + n);
|
|
10733
|
+
return;
|
|
10734
|
+
}
|
|
10735
|
+
const rec = blockRecOf(blockEl);
|
|
10736
|
+
if (!rec || rec.endLine < rec.startLine) return;
|
|
10737
|
+
const delta = n - headingDepthOf(lines[rec.startLine - 1]);
|
|
10738
|
+
if (delta === 0) return;
|
|
10739
|
+
await changeHeadingDepth(blockEl, delta);
|
|
10740
|
+
}
|
|
10741
|
+
|
|
10742
|
+
// ── insert above ───────────────────────────────────────────────────────
|
|
10743
|
+
// commitBlockInsertion() only ever inserts AFTER a block's range, so
|
|
10744
|
+
// "insert above" is expressed the way this file already expresses it (see
|
|
10745
|
+
// convertEmptyTopLevelLiToParagraph()'s own preceding-block lookup):
|
|
10746
|
+
// anchor on the block before this one. That keeps one insertion path and
|
|
10747
|
+
// one set of gates rather than teaching the pure core a direction.
|
|
10748
|
+
async function insertBlockAbove(blockEl) {
|
|
10749
|
+
const rec = blockRecOf(blockEl);
|
|
10750
|
+
if (!rec) return;
|
|
10751
|
+
const preceding = blocks
|
|
10752
|
+
.filter((b) => b.id !== rec.id && b.endLine < rec.startLine && b.endLine >= b.startLine)
|
|
10753
|
+
.pop();
|
|
10754
|
+
if (preceding) {
|
|
10755
|
+
const anchorEl = blockElAtLine(preceding.startLine);
|
|
10756
|
+
if (anchorEl) { await insertBlockBelow(anchorEl, 'paragraph'); return; }
|
|
10757
|
+
}
|
|
10758
|
+
// Nothing above to anchor on — see insertParagraphAtTop(). Refusing here
|
|
10759
|
+
// instead would leave the document's FIRST block permanently impossible
|
|
10760
|
+
// to precede: the ⠿ and + menus both insert below, so this button is the
|
|
10761
|
+
// only affordance that reaches that position at all.
|
|
10762
|
+
await insertParagraphAtTop(blockEl);
|
|
10763
|
+
}
|
|
10764
|
+
|
|
10765
|
+
// The document's first block has no predecessor to insert BELOW, and
|
|
10766
|
+
// commitBlockInsertion() only ever inserts after a block's range. Rather
|
|
10767
|
+
// than teach the pure core a direction (a change under byte-stability and
|
|
10768
|
+
// roundtrip fixtures), the insert is expressed as a REPLACE of that
|
|
10769
|
+
// block's own first source line by [skeleton, '', that same line] — the
|
|
10770
|
+
// same commitRangeEdit()/replaceLines() pipeline as every other edit,
|
|
10771
|
+
// therefore one undo op, and no new primitive.
|
|
10772
|
+
//
|
|
10773
|
+
// The switchAwayFrom() + re-resolve preamble is insertBlockBelow()'s,
|
|
10774
|
+
// for its reasons: the resolution can commit a dirty burst, whose
|
|
10775
|
+
// rerenderAll() detaches the very node this was called with.
|
|
10776
|
+
async function insertParagraphAtTop(blockEl) {
|
|
10777
|
+
const identity = captureBlockIdentity(blockEl);
|
|
10778
|
+
const selfSession = ownsOpenSession(blockEl);
|
|
10779
|
+
const ok = await switchAwayFrom();
|
|
10780
|
+
if (!ok) return;
|
|
10781
|
+
let liveBlockEl = blockEl;
|
|
10782
|
+
if (!document.body.contains(blockEl)) {
|
|
10783
|
+
liveBlockEl = reresolveBlockEl(identity) ||
|
|
10784
|
+
(selfSession ? reresolveBlockElAfterSelfCommit(identity) : null);
|
|
10785
|
+
if (!liveBlockEl) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
10786
|
+
}
|
|
10787
|
+
const rec = blockRecOf(liveBlockEl);
|
|
10788
|
+
// A block that owns no source line has an inverted range, and there is
|
|
10789
|
+
// no line to splice in front of.
|
|
10790
|
+
if (!rec || rec.endLine < rec.startLine) {
|
|
10791
|
+
refuseStructuralListEdit(NO_SOURCE_LINE_INSERT_MESSAGE);
|
|
10792
|
+
return;
|
|
10793
|
+
}
|
|
10794
|
+
const at = rec.startLine;
|
|
10795
|
+
const newText = BLOCK_SKELETONS.paragraph.concat(['', lines[at - 1]]).join('\n');
|
|
10796
|
+
const result = commitRangeEdit({ lines, blocks, stack }, at, at, newText);
|
|
10797
|
+
if (result.op === null) return;
|
|
10798
|
+
const prevLines = lines;
|
|
10799
|
+
lines = result.lines;
|
|
10800
|
+
const editRange = result.op
|
|
10801
|
+
? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
|
|
10802
|
+
endLine: Math.max(result.op.startLine - 1, result.op.endLine),
|
|
10803
|
+
delta: result.op.after.length - result.op.before.length }
|
|
10804
|
+
: undefined;
|
|
10805
|
+
const okRender = await safeRerenderAll(editRange);
|
|
10806
|
+
if (!okRender) {
|
|
10807
|
+
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
10808
|
+
return;
|
|
10809
|
+
}
|
|
10810
|
+
// The new paragraph starts exactly where the old first line did — same
|
|
10811
|
+
// pristine + focus contract insertBlockBelow() gives a skeleton insert.
|
|
10812
|
+
const target = blocks.find((b) => b.startLine === at);
|
|
10813
|
+
if (target) pristineInsert = { blockId: target.id };
|
|
10814
|
+
await focusInsertedBlock(at, 'paragraph');
|
|
10815
|
+
}
|
|
10816
|
+
|
|
10817
|
+
// ── 大綱 ───────────────────────────────────────────────────────────────
|
|
10818
|
+
// TWO attributes, chosen by viewport, and that is not an accident.
|
|
10819
|
+
//
|
|
10820
|
+
// `body[data-sidebar-open]` is the reader runtime's own attribute (see
|
|
10821
|
+
// lib/md2doc.js's setSidebarOpen()) and it drives the MOBILE drawer. The
|
|
10822
|
+
// first cut of this function reused it on desktop too, on the premise that
|
|
10823
|
+
// nothing outside the max-width:1080px block read it. That premise was
|
|
10824
|
+
// false, and the failure was severe: `body[data-sidebar-open]
|
|
10825
|
+
// .sidebar-scrim { display: block; }` sits at BASE scope, so on a desktop
|
|
10826
|
+
// viewport the button painted a full-viewport 35%-black scrim over the
|
|
10827
|
+
// whole page (position:fixed; inset:0; z-index:98) — the document went
|
|
10828
|
+
// grey and unclickable, and the scrim's own click handler then called
|
|
10829
|
+
// setSidebarOpen(false), so the first click anywhere undid it and the
|
|
10830
|
+
// whole gesture read as a flicker.
|
|
10831
|
+
//
|
|
10832
|
+
// Desktop therefore gets a PRIVATE attribute nothing else reads,
|
|
10833
|
+
// `data-ed-outline-hidden`, matched by an edit-mode-only rule in
|
|
10834
|
+
// lib/md2doc.js. Named for the non-default state because the desktop
|
|
10835
|
+
// outline is a sticky column shown by default, which is also what lets
|
|
10836
|
+
// aria-expanded below stay truthful on both viewports.
|
|
10837
|
+
function toggleOutlineSidebar() {
|
|
10838
|
+
// The same 1080px breakpoint the stylesheet's own drawer block uses.
|
|
10839
|
+
const mobile = typeof window.matchMedia === 'function' &&
|
|
10840
|
+
window.matchMedia('(max-width: 1080px)').matches;
|
|
10841
|
+
let expanded;
|
|
10842
|
+
if (mobile) {
|
|
10843
|
+
expanded = !document.body.hasAttribute('data-sidebar-open');
|
|
10844
|
+
if (expanded) document.body.setAttribute('data-sidebar-open', '');
|
|
10845
|
+
else document.body.removeAttribute('data-sidebar-open');
|
|
10846
|
+
} else {
|
|
10847
|
+
expanded = document.body.hasAttribute('data-ed-outline-hidden');
|
|
10848
|
+
if (expanded) document.body.removeAttribute('data-ed-outline-hidden');
|
|
10849
|
+
else document.body.setAttribute('data-ed-outline-hidden', '');
|
|
10850
|
+
}
|
|
10851
|
+
const toggle = document.getElementById('sidebar-toggle');
|
|
10852
|
+
if (toggle) toggle.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
|
10853
|
+
}
|
|
10854
|
+
|
|
10855
|
+
// ── 追加 3: images ─────────────────────────────────────────────────────
|
|
10856
|
+
function lastBlockEl() {
|
|
10857
|
+
const all = contentEl.querySelectorAll('.ed-block');
|
|
10858
|
+
return all.length ? all[all.length - 1] : null;
|
|
10859
|
+
}
|
|
10860
|
+
|
|
10861
|
+
function blobToBase64(blob) {
|
|
10862
|
+
return new Promise((resolve, reject) => {
|
|
10863
|
+
const fr = new FileReader();
|
|
10864
|
+
fr.onerror = () => reject(new Error('could not read the image'));
|
|
10865
|
+
fr.onload = () => {
|
|
10866
|
+
// readAsDataURL gives 'data:<mime>;base64,<payload>' — the endpoint
|
|
10867
|
+
// wants the payload alone, and derives the extension from the mime
|
|
10868
|
+
// it is told, never from this prefix.
|
|
10869
|
+
const s = String(fr.result || '');
|
|
10870
|
+
const comma = s.indexOf(',');
|
|
10871
|
+
resolve(comma === -1 ? '' : s.slice(comma + 1));
|
|
10872
|
+
};
|
|
10873
|
+
fr.readAsDataURL(blob);
|
|
10874
|
+
});
|
|
10875
|
+
}
|
|
10876
|
+
|
|
10877
|
+
// Uploads one blob and answers its 'assets/<name>' path, or null (a banner
|
|
10878
|
+
// is already showing by then). The MIME whitelist is asked BEFORE the round
|
|
10879
|
+
// trip so an unsupported drop is refused instantly and locally — the server
|
|
10880
|
+
// asks the same question again, and it is the server's answer that is
|
|
10881
|
+
// authoritative.
|
|
10882
|
+
async function uploadImageBlob(blob) {
|
|
10883
|
+
if (!blob) return null;
|
|
10884
|
+
if (!assetLib || !assetLib.extFor(blob.type)) {
|
|
10885
|
+
showBanner(TOOLBAR_UNSUPPORTED_IMAGE_MESSAGE, null, null);
|
|
10886
|
+
return null;
|
|
10887
|
+
}
|
|
10888
|
+
let data;
|
|
10889
|
+
try {
|
|
10890
|
+
data = await blobToBase64(blob);
|
|
10891
|
+
} catch (e) {
|
|
10892
|
+
showBanner('圖片讀取失敗 — ' + describeFailure(e), null, null);
|
|
10893
|
+
return null;
|
|
10894
|
+
}
|
|
10895
|
+
let res;
|
|
10896
|
+
try {
|
|
10897
|
+
res = await fetch('/api/asset', {
|
|
10898
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
10899
|
+
body: JSON.stringify({
|
|
10900
|
+
fileId: ED.fileId, mime: blob.type, name: blob.name || 'image', data: data,
|
|
10901
|
+
}),
|
|
10902
|
+
});
|
|
10903
|
+
} catch (e) {
|
|
10904
|
+
showBanner('圖片上傳失敗 — ' + describeFailure(e) + '。檔案未寫入。', null, null);
|
|
10905
|
+
return null;
|
|
10906
|
+
}
|
|
10907
|
+
if (!res.ok) {
|
|
10908
|
+
const reason = await describeHttpFailure(res);
|
|
10909
|
+
showBanner('圖片上傳失敗 — ' + reason + '。檔案未寫入。', null, null);
|
|
10910
|
+
return null;
|
|
10911
|
+
}
|
|
10912
|
+
let j;
|
|
10913
|
+
try { j = await res.json(); } catch (e) { j = null; }
|
|
10914
|
+
if (!j || typeof j.path !== 'string') {
|
|
10915
|
+
showBanner('圖片上傳失敗 — 伺服器回應格式不正確。', null, null);
|
|
10916
|
+
return null;
|
|
10917
|
+
}
|
|
10918
|
+
return j.path;
|
|
10919
|
+
}
|
|
10920
|
+
|
|
10921
|
+
// asset.js's sanitizeName() deliberately keeps spaces (and everything else
|
|
10922
|
+
// that is not a path separator or a drive colon) — a screenshot really is
|
|
10923
|
+
// called 'Screen Shot 2026-09-03 at 14.02.11.png'. A bare space, or a
|
|
10924
|
+
// parenthesis, inside an inline image destination ends the destination
|
|
10925
|
+
// early, so `` renders as literal text. Percent-
|
|
10926
|
+
// encoding exactly those characters fixes it in both directions: browsers
|
|
10927
|
+
// resolve the escape, and md2doc's own resolveAssetPath() decodes the
|
|
10928
|
+
// filePart before probing the disk (it also probes the raw form, so
|
|
10929
|
+
// non-ASCII characters are deliberately left alone rather than escaped).
|
|
10930
|
+
// '%' itself is encoded FIRST by being in the same character class, so a
|
|
10931
|
+
// filename that already contains one cannot be double-decoded.
|
|
10932
|
+
const TOOLBAR_MD_DEST_UNSAFE_RE = /[%\s()<>"']/g;
|
|
10933
|
+
function markdownImageDest(p) {
|
|
10934
|
+
return String(p == null ? '' : p).replace(TOOLBAR_MD_DEST_UNSAFE_RE, (c) => {
|
|
10935
|
+
const hex = c.charCodeAt(0).toString(16).toUpperCase();
|
|
10936
|
+
return '%' + (hex.length < 2 ? '0' + hex : hex);
|
|
10937
|
+
});
|
|
10938
|
+
}
|
|
10939
|
+
|
|
10940
|
+
// Uploads every image in `blobs` and lands them as ONE block insertion.
|
|
10941
|
+
// One insertion, not one per image: each insert re-renders and detaches the
|
|
10942
|
+
// anchor, so a loop would be inserting against a stale node from the second
|
|
10943
|
+
// image onward — and it would also cost one undo op per image for what the
|
|
10944
|
+
// user experienced as a single drop. Blank lines between the paths keep
|
|
10945
|
+
// them separate paragraphs instead of one paragraph of soft breaks.
|
|
10946
|
+
async function insertImages(blobs, anchorEl) {
|
|
10947
|
+
const paths = [];
|
|
10948
|
+
for (let i = 0; i < blobs.length; i += 1) {
|
|
10949
|
+
const p = await uploadImageBlob(blobs[i]);
|
|
10950
|
+
if (p) paths.push(p);
|
|
10951
|
+
}
|
|
10952
|
+
if (!paths.length) return;
|
|
10953
|
+
const target = (anchorEl && document.body.contains(anchorEl)) ? anchorEl : lastBlockEl();
|
|
10954
|
+
if (!target) { showBanner(TOOLBAR_NO_ANCHOR_MESSAGE, null, null); return; }
|
|
10955
|
+
const newLines = [];
|
|
10956
|
+
paths.forEach((p, i) => {
|
|
10957
|
+
if (i > 0) newLines.push('');
|
|
10958
|
+
newLines.push(' + ')');
|
|
10959
|
+
});
|
|
10960
|
+
await insertBlockBelow(target, 'paragraph', { lines: newLines });
|
|
10961
|
+
}
|
|
10962
|
+
|
|
10963
|
+
function pickAndInsertImage() {
|
|
10964
|
+
const input = document.createElement('input');
|
|
10965
|
+
input.type = 'file';
|
|
10966
|
+
input.accept = 'image/png,image/jpeg,image/gif,image/webp';
|
|
10967
|
+
input.multiple = true;
|
|
10968
|
+
input.hidden = true;
|
|
10969
|
+
const anchorEl = liveToolbarBlock() || lastBlockEl();
|
|
10970
|
+
document.body.appendChild(input);
|
|
10971
|
+
input.addEventListener('change', () => {
|
|
10972
|
+
const files = input.files ? Array.prototype.slice.call(input.files) : [];
|
|
10973
|
+
input.remove();
|
|
10974
|
+
if (files.length) insertImages(files, anchorEl);
|
|
10975
|
+
});
|
|
10976
|
+
input.click();
|
|
10977
|
+
}
|
|
10978
|
+
|
|
10979
|
+
function dragCarriesFiles(e) {
|
|
10980
|
+
const dt = e.dataTransfer;
|
|
10981
|
+
if (!dt || !dt.types) return false;
|
|
10982
|
+
return Array.prototype.indexOf.call(dt.types, 'Files') !== -1;
|
|
10983
|
+
}
|
|
10984
|
+
|
|
10985
|
+
function wireImageDrop() {
|
|
10986
|
+
// ⚠ BOTH listeners are gated on `docMode === 'edit'`, and that gate is
|
|
10987
|
+
// load-bearing rather than tidy-minded.
|
|
10988
|
+
//
|
|
10989
|
+
// In SOURCE mode `enterSourceMode()` only sets `contentEl.hidden = true` —
|
|
10990
|
+
// every `.ed-block` is still in the DOM, so `lastBlockEl()` resolves and an
|
|
10991
|
+
// ungated drop ran the whole upload plus an `insertBlockBelow()` commit
|
|
10992
|
+
// behind the hidden content, against `lines`. The textarea the user is
|
|
10993
|
+
// looking at still held the PRE-drop source, and `leaveSourceMode()` then
|
|
10994
|
+
// commits `commitRangeEdit(1, lines.length, ta.value)` over the post-drop
|
|
10995
|
+
// `lines` — erasing the reference that was just inserted. MEASURED:
|
|
10996
|
+
// '# Doc\n\nBody para.\n' -> source mode -> drop x.png -> type EDITED ->
|
|
10997
|
+
// leave, and the file ends '# Doc\n\nBody para.\n\nEDITED\n' with
|
|
10998
|
+
// assets/x.png orphaned on disk. No error, no visible effect, and a stray
|
|
10999
|
+
// binary left in what is usually a git working tree.
|
|
11000
|
+
//
|
|
11001
|
+
// In PREVIEW mode the toolbar reports every editing button as disabled, so
|
|
11002
|
+
// the mode PRESENTS as read-only; a drop that silently wrote a file and
|
|
11003
|
+
// inserted a block would make that presentation a lie.
|
|
11004
|
+
document.addEventListener('dragover', (e) => {
|
|
11005
|
+
if (docMode !== 'edit') return;
|
|
11006
|
+
if (!dragCarriesFiles(e)) return;
|
|
11007
|
+
e.preventDefault();
|
|
11008
|
+
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
|
|
11009
|
+
});
|
|
11010
|
+
document.addEventListener('drop', (e) => {
|
|
11011
|
+
if (docMode !== 'edit') return;
|
|
11012
|
+
if (!dragCarriesFiles(e)) return;
|
|
11013
|
+
const files = e.dataTransfer
|
|
11014
|
+
? Array.prototype.slice.call(e.dataTransfer.files || [])
|
|
11015
|
+
: [];
|
|
11016
|
+
const images = files.filter((f) => !!(assetLib && assetLib.extFor(f.type)));
|
|
11017
|
+
if (!images.length) return; // not ours: let the page do whatever it would
|
|
11018
|
+
e.preventDefault();
|
|
11019
|
+
const anchorEl = (e.target && e.target.closest) ? e.target.closest('.ed-block') : null;
|
|
11020
|
+
insertImages(images, anchorEl || liveToolbarBlock() || lastBlockEl());
|
|
11021
|
+
});
|
|
11022
|
+
}
|
|
11023
|
+
|
|
11024
|
+
// ── 追加 2: paste ──────────────────────────────────────────────────────
|
|
11025
|
+
// Reads a real DataTransfer into the plain { 'mime/type': string | Blob }
|
|
11026
|
+
// shape paste-md.js's pickPayload() documents. Only ONE image flavour is
|
|
11027
|
+
// taken: pickPayload picks the first it finds anyway, and a clipboard can
|
|
11028
|
+
// carry several encodings of the same bitmap.
|
|
11029
|
+
function clipboardItemsOf(dt) {
|
|
11030
|
+
const items = {};
|
|
11031
|
+
if (!dt) return items;
|
|
11032
|
+
try { items['text/plain'] = dt.getData('text/plain') || ''; } catch (err) { items['text/plain'] = ''; }
|
|
11033
|
+
try { items['text/html'] = dt.getData('text/html') || ''; } catch (err) { items['text/html'] = ''; }
|
|
11034
|
+
const files = dt.files ? Array.prototype.slice.call(dt.files) : [];
|
|
11035
|
+
for (let i = 0; i < files.length; i += 1) {
|
|
11036
|
+
if (files[i] && String(files[i].type).indexOf('image/') === 0) {
|
|
11037
|
+
items[files[i].type] = files[i];
|
|
11038
|
+
break;
|
|
11039
|
+
}
|
|
11040
|
+
}
|
|
11041
|
+
return items;
|
|
11042
|
+
}
|
|
11043
|
+
|
|
11044
|
+
// A converted payload that is ONE line carrying no markdown syntax at all
|
|
11045
|
+
// is indistinguishable from the plain-text paste this editor has always
|
|
11046
|
+
// done — and that case (copy a word or a phrase out of a browser, paste it
|
|
11047
|
+
// mid sentence) must keep landing at the caret rather than becoming a new
|
|
11048
|
+
// block. Anything else is real structure and takes the source path.
|
|
11049
|
+
//
|
|
11050
|
+
// The test is split in two because markdown's characters are NOT all alike.
|
|
11051
|
+
// The first cut used one character class for both kinds and put '-', '#'
|
|
11052
|
+
// and '!' in it — which re-introduced exactly the regression this test
|
|
11053
|
+
// exists to prevent, since 'e-mail', 'state-of-the-art' and 'Hello!' are
|
|
11054
|
+
// ordinary prose that would have become new paragraph blocks. Those three
|
|
11055
|
+
// only begin a construct at LINE POSITION, so they belong in the
|
|
11056
|
+
// start-anchored test, not the anywhere test. ('![' is still caught: the
|
|
11057
|
+
// '[' is in the anywhere class.)
|
|
11058
|
+
const TOOLBAR_INLINE_UNSAFE_RE = /[*_`~\[\]<>|\\]/;
|
|
11059
|
+
const TOOLBAR_BLOCK_START_RE =
|
|
11060
|
+
/^(?: {4}|\t|#{1,6}(?:\s|$)|[-*+][ \t]|\d+[.)][ \t]|>|`{3}|~{3}|-{3}|={3})/;
|
|
11061
|
+
function isInlineSafePaste(md) {
|
|
11062
|
+
if (md === '' || md.indexOf('\n') !== -1) return false;
|
|
11063
|
+
if (TOOLBAR_INLINE_UNSAFE_RE.test(md)) return false;
|
|
11064
|
+
return !TOOLBAR_BLOCK_START_RE.test(md);
|
|
11065
|
+
}
|
|
11066
|
+
|
|
11067
|
+
function handleArmedPaste(dt, editEl) {
|
|
11068
|
+
const items = clipboardItemsOf(dt);
|
|
11069
|
+
const carriesSomething = Object.keys(items).some((k) => (
|
|
11070
|
+
typeof items[k] === 'string' ? items[k] !== '' : !!items[k]));
|
|
11071
|
+
// An EMPTY paste is not the paste the gesture asked for, and must not
|
|
11072
|
+
// consume the Ctrl+Shift+V arming. MEASURED in headless Chromium: the
|
|
11073
|
+
// Ctrl+Shift+V keystroke ITSELF dispatches a paste event carrying no
|
|
11074
|
+
// flavours at all (`types: []`, `getData('text/plain') === ''`) before
|
|
11075
|
+
// any real clipboard content arrives. Consuming `plainPasteArmed` there
|
|
11076
|
+
// disarmed the very paste the user had just asked to be plain — the
|
|
11077
|
+
// gesture silently degraded to a normal rich paste. Returning early also
|
|
11078
|
+
// saves an insertTextAtCaret('') that would snapshot the burst for a
|
|
11079
|
+
// no-op edit.
|
|
11080
|
+
if (!carriesSomething) return;
|
|
11081
|
+
const plainOnly = plainPasteArmed;
|
|
11082
|
+
plainPasteArmed = false;
|
|
11083
|
+
const payload = pasteMd
|
|
11084
|
+
? pasteMd.pickPayload(items, plainOnly)
|
|
11085
|
+
: { kind: 'text', value: dt ? dt.getData('text/plain') : '' };
|
|
11086
|
+
if (payload.kind === 'image') {
|
|
11087
|
+
insertImages([payload.blob], editEl.closest('.ed-block'));
|
|
11088
|
+
return;
|
|
11089
|
+
}
|
|
11090
|
+
if (payload.kind === 'markdown') {
|
|
11091
|
+
const md = String(payload.value == null ? '' : payload.value).replace(/\r\n?/g, '\n');
|
|
11092
|
+
if (md.trim() === '') return;
|
|
11093
|
+
if (isInlineSafePaste(md)) {
|
|
11094
|
+
insertTextAtCaret(md);
|
|
11095
|
+
snapBurstIfActive(editEl, 'paste');
|
|
11096
|
+
return;
|
|
11097
|
+
}
|
|
11098
|
+
// Landed as SOURCE below the caret's block, never spliced into the
|
|
11099
|
+
// WYSIWYG DOM: that is what guarantees it passes through
|
|
11100
|
+
// commitBlockInsertion()/replaceLines() and therefore through
|
|
11101
|
+
// serializeBlocks()'s existing gates, instead of arriving as a pile of
|
|
11102
|
+
// foreign nodes the serializer would have to make sense of afterwards.
|
|
11103
|
+
insertBlockBelow(editEl.closest('.ed-block'), 'paragraph', { lines: md.split('\n') });
|
|
11104
|
+
return;
|
|
11105
|
+
}
|
|
11106
|
+
insertTextAtCaret(payload.value || '');
|
|
11107
|
+
snapBurstIfActive(editEl, 'paste');
|
|
11108
|
+
}
|
|
11109
|
+
|
|
11110
|
+
function wirePlainPasteArming() {
|
|
11111
|
+
// Capture phase: this only records intent, and must do so before any
|
|
11112
|
+
// other keydown handler can preventDefault() its way past it.
|
|
11113
|
+
document.addEventListener('keydown', (e) => {
|
|
11114
|
+
plainPasteArmed = !!((e.ctrlKey || e.metaKey) && e.shiftKey &&
|
|
11115
|
+
(e.key === 'v' || e.key === 'V'));
|
|
11116
|
+
}, true);
|
|
11117
|
+
}
|
|
11118
|
+
|
|
11119
|
+
// ── 追加 4: the whole-document source escape hatch ─────────────────────
|
|
11120
|
+
function applyPreviewEditability(editable) {
|
|
11121
|
+
const surfaces = contentEl.querySelectorAll('.ed-wys-armed, .ed-wys-cell');
|
|
11122
|
+
for (let i = 0; i < surfaces.length; i += 1) {
|
|
11123
|
+
surfaces[i].setAttribute('contenteditable', editable ? 'true' : 'false');
|
|
11124
|
+
}
|
|
11125
|
+
}
|
|
11126
|
+
|
|
11127
|
+
function enterSourceMode() {
|
|
11128
|
+
// The pre-edit snapshot fromSource() diffs against. Omitting it makes
|
|
11129
|
+
// `changed` unconditionally true (docsource.js's documented fail-safe),
|
|
11130
|
+
// i.e. a needless write of byte-identical content on every exit.
|
|
11131
|
+
sourceBaseLines = lines.slice();
|
|
11132
|
+
const ta = document.createElement('textarea');
|
|
11133
|
+
ta.className = 'ed-source';
|
|
11134
|
+
ta.setAttribute('aria-label', '文件原始碼');
|
|
11135
|
+
ta.value = docSourceLib.toSource(lines, EOL);
|
|
11136
|
+
contentEl.hidden = true;
|
|
11137
|
+
contentEl.parentNode.insertBefore(ta, contentEl);
|
|
11138
|
+
sourceTextarea = ta;
|
|
11139
|
+
ta.focus();
|
|
11140
|
+
}
|
|
11141
|
+
|
|
11142
|
+
// Returns false when the commit's render failed — the textarea is then left
|
|
11143
|
+
// standing with the user's text intact (a failed round trip must never be
|
|
11144
|
+
// the thing that discards a whole document's edit), and the mode does not
|
|
11145
|
+
// advance.
|
|
11146
|
+
async function leaveSourceMode() {
|
|
11147
|
+
const ta = sourceTextarea;
|
|
11148
|
+
if (!ta) return true;
|
|
11149
|
+
const parsed = docSourceLib.fromSource(ta.value, EOL, sourceBaseLines);
|
|
11150
|
+
if (parsed.changed) {
|
|
11151
|
+
// 全量置換 through the SAME replaceLines() pipeline every other edit
|
|
11152
|
+
// uses — one commitRangeEdit over the whole document, therefore one
|
|
11153
|
+
// undo op, and no second writer of `lines`.
|
|
11154
|
+
const result = commitRangeEdit({ lines, blocks, stack }, 1, lines.length,
|
|
11155
|
+
parsed.lines.join('\n'));
|
|
11156
|
+
if (result.op !== null) {
|
|
11157
|
+
const prevLines = lines;
|
|
11158
|
+
lines = result.lines;
|
|
11159
|
+
setDirty();
|
|
11160
|
+
const editRange = result.op
|
|
11161
|
+
? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
|
|
11162
|
+
endLine: Math.max(result.op.startLine - 1, result.op.endLine),
|
|
11163
|
+
delta: result.op.after.length - result.op.before.length }
|
|
11164
|
+
: undefined;
|
|
11165
|
+
const ok = await safeRerenderAll(editRange);
|
|
11166
|
+
if (!ok) {
|
|
11167
|
+
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
11168
|
+
setDirty();
|
|
11169
|
+
return false;
|
|
11170
|
+
}
|
|
11171
|
+
}
|
|
11172
|
+
}
|
|
11173
|
+
sourceTextarea = null;
|
|
11174
|
+
sourceBaseLines = null;
|
|
11175
|
+
ta.remove();
|
|
11176
|
+
contentEl.hidden = false;
|
|
11177
|
+
return true;
|
|
11178
|
+
}
|
|
11179
|
+
|
|
11180
|
+
async function cycleDocMode() {
|
|
11181
|
+
if (!docSourceLib) return;
|
|
11182
|
+
await setDocMode(docSourceLib.next(docMode));
|
|
11183
|
+
}
|
|
11184
|
+
|
|
11185
|
+
async function setDocMode(next) {
|
|
11186
|
+
if (next === docMode) return;
|
|
11187
|
+
if (docMode === 'edit') {
|
|
11188
|
+
// Same precondition every structural operation has: resolve whatever
|
|
11189
|
+
// session is open before the DOM it lives in is swapped or hidden.
|
|
11190
|
+
const ok = await switchAwayFrom();
|
|
11191
|
+
if (!ok) return;
|
|
11192
|
+
}
|
|
11193
|
+
if (docMode === 'source') {
|
|
11194
|
+
const ok = await leaveSourceMode();
|
|
11195
|
+
if (!ok) return;
|
|
11196
|
+
}
|
|
11197
|
+
if (docMode === 'preview') applyPreviewEditability(true);
|
|
11198
|
+
docMode = next;
|
|
11199
|
+
if (docMode === 'source') enterSourceMode();
|
|
11200
|
+
if (docMode === 'preview') applyPreviewEditability(false);
|
|
11201
|
+
document.body.setAttribute('data-ed-mode', docMode);
|
|
11202
|
+
updateToolbar();
|
|
11203
|
+
}
|
|
11204
|
+
|
|
11205
|
+
// ── mount ──────────────────────────────────────────────────────────────
|
|
11206
|
+
function wireToolbarTracking() {
|
|
11207
|
+
// Which block the bar reports on. Delegated at document level (never a
|
|
11208
|
+
// per-block listener) for the same reason every other affordance in this
|
|
11209
|
+
// file is: blocks are rebuilt wholesale on every render.
|
|
11210
|
+
document.addEventListener('focusin', (e) => {
|
|
11211
|
+
if (!e.target || !e.target.closest) return;
|
|
11212
|
+
if (e.target.closest('.ed-toolbar') || e.target.closest('.ed-toolbar-menu')) return;
|
|
11213
|
+
const blockEl = e.target.closest('.ed-block');
|
|
11214
|
+
if (!blockEl) return;
|
|
11215
|
+
toolbarBlockEl = blockEl;
|
|
11216
|
+
updateToolbar();
|
|
11217
|
+
});
|
|
11218
|
+
document.addEventListener('click', (e) => {
|
|
11219
|
+
if (!e.target || !e.target.closest) return;
|
|
11220
|
+
if (e.target.closest('.ed-toolbar') || e.target.closest('.ed-toolbar-menu')) return;
|
|
11221
|
+
closeToolbarMenu();
|
|
11222
|
+
const blockEl = e.target.closest('.ed-block');
|
|
11223
|
+
if (blockEl) toolbarBlockEl = blockEl;
|
|
11224
|
+
updateToolbar();
|
|
11225
|
+
});
|
|
11226
|
+
// NOTE: there is deliberately NO selectionchange listener here — the
|
|
11227
|
+
// burst's own onSelectionChangeForToolbar() calls updateToolbar() for us.
|
|
11228
|
+
// See that function for why a second one must not be attached.
|
|
11229
|
+
}
|
|
11230
|
+
|
|
11231
|
+
function mountToolbar() {
|
|
11232
|
+
// An older cached page can be running this runtime without the v3.1.0
|
|
11233
|
+
// module tags. Degrade to "no toolbar" rather than throwing before
|
|
11234
|
+
// armEditables() has run.
|
|
11235
|
+
if (!toolbarModel) return;
|
|
11236
|
+
toolbarEl = buildToolbar();
|
|
11237
|
+
// ⚠ document.body, NOT contentEl. rerenderAll()'s fallback path does
|
|
11238
|
+
// `contentEl.innerHTML = j.parts.join('\n')` (v3.2.0: `/api/render`
|
|
11239
|
+
// answers `{ parts, blocks }` and there is no `bodyHtml` on the wire any
|
|
11240
|
+
// more — see applyFullRender()), replacing .content's children wholesale,
|
|
11241
|
+
// and only armEditables() rebuilds anything afterwards — it knows nothing
|
|
11242
|
+
// about a toolbar. A bar mounted inside .content would
|
|
11243
|
+
// vanish on the first commit with nothing left to bring it back. Every
|
|
11244
|
+
// persistent chrome node in this file is on document.body for exactly
|
|
11245
|
+
// this reason: the conflict banner, the selection toolbar, the table
|
|
11246
|
+
// grips and the table edge menu.
|
|
11247
|
+
document.body.appendChild(toolbarEl);
|
|
11248
|
+
document.body.setAttribute('data-ed-mode', docMode);
|
|
11249
|
+
wireToolbarTracking();
|
|
11250
|
+
wireImageDrop();
|
|
11251
|
+
wirePlainPasteArming();
|
|
11252
|
+
updateToolbar();
|
|
11253
|
+
}
|
|
11254
|
+
|
|
9332
11255
|
// ── global key handling ─────────────────────────────────────────────────
|
|
9333
11256
|
document.addEventListener('keydown', (e) => {
|
|
9334
11257
|
// ── S3 §4.4: Escape priority is drag > menu > selection > burst ─────
|
|
@@ -9709,8 +11632,13 @@
|
|
|
9709
11632
|
if (!editEl) return;
|
|
9710
11633
|
e.preventDefault();
|
|
9711
11634
|
const dt = e.clipboardData || window.clipboardData;
|
|
9712
|
-
|
|
9713
|
-
|
|
11635
|
+
// v3.1.0 追加 2: paste-md.js decides what the clipboard actually holds
|
|
11636
|
+
// (text/html -> markdown, an image, or plain text) and this routes the
|
|
11637
|
+
// answer. The TABLE-CELL branch above deliberately keeps its plain-text
|
|
11638
|
+
// behaviour: a cell cannot host a block, and a converted markdown table
|
|
11639
|
+
// pasted into one cell would be a nested structure the serializer has no
|
|
11640
|
+
// way to represent.
|
|
11641
|
+
handleArmedPaste(dt, editEl);
|
|
9714
11642
|
});
|
|
9715
11643
|
|
|
9716
11644
|
// Task 5: hover-edge insert bubbles — one delegated, rAF-throttled
|
|
@@ -9800,4 +11728,8 @@
|
|
|
9800
11728
|
|
|
9801
11729
|
armEditables(contentEl);
|
|
9802
11730
|
wireBlockSelection();
|
|
11731
|
+
// v3.1.0 §4: last, so every const it closes over (stack, BLOCK_SKELETONS,
|
|
11732
|
+
// selToolbar, …) is initialised and the bar's first deriveState() sees the
|
|
11733
|
+
// armed document rather than an empty one.
|
|
11734
|
+
mountToolbar();
|
|
9803
11735
|
})();
|