@helping-ai-workflow/md2doc 3.2.0 → 3.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/editor/client.js +1175 -85
- package/lib/editor/docsource.js +16 -6
- package/lib/editor/toolbar-model.js +23 -8
- package/lib/md2doc.js +6 -6
- package/package.json +2 -2
package/lib/editor/client.js
CHANGED
|
@@ -1023,6 +1023,31 @@
|
|
|
1023
1023
|
// property before we are called; it is a PRECONDITION of everything
|
|
1024
1024
|
// below, not something this function re-derives.
|
|
1025
1025
|
const children = Array.prototype.slice.call(contentEl.children);
|
|
1026
|
+
// v3.2.1: 「被改寫區塊內的游標」焦點修復。動機來自 undo/redo 的量測——
|
|
1027
|
+
// undo/redo 並非普遍掉焦點:游標在被改寫區間【之外】時 activeElement /
|
|
1028
|
+
// 所屬 block / caret offset 全部存活(連一次讓自己 data-block-id 8→7 的
|
|
1029
|
+
// 結構性 undo 都扛得住);只有游標落在被改寫區塊【內】才掉到 BODY,之後
|
|
1030
|
+
// 打字被靜默丟棄。所以判準是位置,不是操作種類,也完全不需要 op→block
|
|
1031
|
+
// 映射(實測 op.startLine 在 14 個實例中只有 4 個正確,對 move / insert
|
|
1032
|
+
// 都指向沒有擁有者的空行——該設計已退掉)。
|
|
1033
|
+
//
|
|
1034
|
+
// ⚠ 觸發面不是「只有 undo/redo」:任何一次 patch 只要聚焦的 block 落在
|
|
1035
|
+
// replaceSpan 內就會走到,fix round 1 的 review 點名了兩類非 undo 的呼叫者——
|
|
1036
|
+
// * 一般的 burst 提交:endBurstWithoutResolve() 不 blur,焦點還留在那個
|
|
1037
|
+
// 即將被換掉的面上;
|
|
1038
|
+
// * 帶 declaredRange 的批次結構操作:焦點坐在 `.ed-block` 外框上(roving
|
|
1039
|
+
// tabindex)。
|
|
1040
|
+
// 這兩類上還原完之後常常會被下面 applySelectionClasses() 自己的
|
|
1041
|
+
// `focusEl.focus()` 立刻覆蓋掉(它把焦點搬回選取的 focus block),也就是多
|
|
1042
|
+
// 一趟 focus 往返而已;journey 案例 3 / 4 / 6 走的正是這些路徑,皆綠。
|
|
1043
|
+
//
|
|
1044
|
+
// 必須在 DOM 手術之前算:removeChild 之後 activeElement 已經是 BODY,
|
|
1045
|
+
// 而 `children` 是 patch 前的快照,也是 plan 的索引所指的那份清單。
|
|
1046
|
+
const focusedBlockEl = document.activeElement &&
|
|
1047
|
+
document.activeElement.closest ? document.activeElement.closest('.ed-block') : null;
|
|
1048
|
+
const ci = focusedBlockEl ? children.indexOf(focusedBlockEl) : -1;
|
|
1049
|
+
const focusWasInSpan = ci >= 0 &&
|
|
1050
|
+
ci >= plan.replaceSpan.oldStart && ci <= plan.replaceSpan.oldEnd;
|
|
1026
1051
|
// Fix round 1 (review Important 1): `blocks`/`lastParts` are the JOIN KEY
|
|
1027
1052
|
// between the module's model and the DOM, and they are overwritten BEFORE
|
|
1028
1053
|
// the DOM is touched — armEditables() reads the new `blocks` (see its call
|
|
@@ -1109,6 +1134,53 @@
|
|
|
1109
1134
|
return applyFullRender(j, window.scrollY, declaredRange);
|
|
1110
1135
|
}
|
|
1111
1136
|
|
|
1137
|
+
if (focusWasInSpan) {
|
|
1138
|
+
// 落在被置換區間內的游標,其節點已經被移除。把焦點放到對應的新節點
|
|
1139
|
+
// (夾在新中段的範圍內),caret 置尾。
|
|
1140
|
+
//
|
|
1141
|
+
// 位置刻意在 teardown 之前,而真正的機制是 fix round 1 的 review 查出來
|
|
1142
|
+
// 的這一條(第一版註解寫的「靠收尾那段焦點檢查認養」是錯的,那段在這條
|
|
1143
|
+
// 路徑上是死碼):`surface.focus()` 會【同步】觸發 document 層那個委派的
|
|
1144
|
+
// focusin。undo() 早已 await 完 switchAwayFrom(),所以 `switching` 與
|
|
1145
|
+
// `currentBurst` 都是 null——focusin 的兩個 early-return 都不成立——而新
|
|
1146
|
+
// 節點在 insertBefore 之前就被 armEditables(tpl.content) 武裝過了,於是
|
|
1147
|
+
// startBurst() 當場就跑完。打字有人追蹤、Ctrl+S 才寫得進檔案(實測拿掉
|
|
1148
|
+
// 這一塊時 undo 之後打的字連磁碟都到不了)。收尾最後那段
|
|
1149
|
+
// `if (!currentBurst && …)` 的焦點檢查因此在這條路徑上恆為 false。
|
|
1150
|
+
//
|
|
1151
|
+
// 代價要說清楚:下面的 `burstSurvived` 是在這裡【之後】才求值的,所以在
|
|
1152
|
+
// 走到這一塊的 render 上,它量到的是「剛剛由還原開出來的那個 burst」,
|
|
1153
|
+
// 而不是它註解所寫的「patch 前就開著、而且面活過 patch」。受影響的是三個
|
|
1154
|
+
// 以它為閘門的收尾項:currentBurst 的 dispose、resetSelToolbarState()、
|
|
1155
|
+
// resetToolbarBlock()——三者都會改走 survived 分支。結果是好的(剛開的
|
|
1156
|
+
// burst 不被丟掉、startBurst() 剛掛上的 selectionchange listener 不被拆、
|
|
1157
|
+
// toolbarBlockEl 保住 focusin 剛設好的活節點),但那是巧合而不是設計,
|
|
1158
|
+
// 所以在 `burstSurvived` 那裡也留了一行對照註解。不搬 `burstSurvived`
|
|
1159
|
+
// 到還原之前,是刻意的取捨:搬了會讓剛開的 burst 先被 dispose 再由收尾
|
|
1160
|
+
// 重開一次,並把每次 undo 的工具列 degrade 原封不動地留著。
|
|
1161
|
+
// teardown 十二項彼此的相對順序沒有被動到,條件式的 activeEditor 歸零
|
|
1162
|
+
// 仍然在 DOM 手術之後求值。
|
|
1163
|
+
const newLen = plan.replaceSpan.newParts.length;
|
|
1164
|
+
if (newLen > 0) {
|
|
1165
|
+
// landing 恆落在新中段內:ci >= oldStart 保證非負,min() 夾住上界,
|
|
1166
|
+
// 而 patch 後的子節點是 prefix(keepPrefix) + 新中段(newLen) + suffix。
|
|
1167
|
+
// 零寬區間(insert,oldEnd === oldStart - 1)根本進不了 focusWasInSpan。
|
|
1168
|
+
const landing = plan.keepPrefix + Math.min(ci - plan.replaceSpan.oldStart, newLen - 1);
|
|
1169
|
+
const el = contentEl.children[landing];
|
|
1170
|
+
const surface = el ? blockContentEl(el) : null;
|
|
1171
|
+
if (surface) {
|
|
1172
|
+
surface.focus();
|
|
1173
|
+
try {
|
|
1174
|
+
const r = document.createRange();
|
|
1175
|
+
r.selectNodeContents(surface);
|
|
1176
|
+
r.collapse(false);
|
|
1177
|
+
const s = window.getSelection();
|
|
1178
|
+
s.removeAllRanges(); s.addRange(r);
|
|
1179
|
+
} catch (e) { /* best-effort,同 focusBlockAtLine 的處置 */ }
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1112
1184
|
// ── teardown ────────────────────────────────────────────────────────
|
|
1113
1185
|
// Controller ruling F1: the patch path runs the SAME twelve teardown items
|
|
1114
1186
|
// applyFullRender() runs, unconditionally and in the same relative order.
|
|
@@ -1219,6 +1291,18 @@
|
|
|
1219
1291
|
// 的**相對順序**與 applyFullRender() 一致,把 resetSelToolbarState() /
|
|
1220
1292
|
// resetToolbarBlock() 搬到第 3 項旁邊才是真的改了順序。條件只算一次,
|
|
1221
1293
|
// 而且算在會動到 currentBurst 的那一項之前(算完才輪得到把它歸零)。
|
|
1294
|
+
// ⚠ v3.2.1 fix round 1:這個節點 identity 判準本身沒變,但它求值的時機
|
|
1295
|
+
// 現在落在上面那塊焦點還原【之後】。還原的 `surface.focus()` 在
|
|
1296
|
+
// switchAwayFrom() 已經結算完的路徑上(undo()/redo())會【同步】開出一個新
|
|
1297
|
+
// 的 burst(見那裡的註解),所以在那種 render 上這裡量到的 true 說的是
|
|
1298
|
+
// 「現在有一個 burst,而且它的面在文件上」,不是「patch 前那個 burst 活下
|
|
1299
|
+
// 來了」。下面三個以它為閘門的項因此會走 survived 分支。實測(undo 走
|
|
1300
|
+
// patch 路徑):收尾後工具列 disabled 停在 7/22,不是 resetToolbarBlock()
|
|
1301
|
+
// 跑過該有的 18/22 —— 也就是 resetToolbarBlock() 確實被跳過了,這是
|
|
1302
|
+
// burstSurvived 為 true 的直接證據。反過來,若還原當下 currentBurst 還沒被
|
|
1303
|
+
// 歸零(別條路徑),focusin 的 `if (currentBurst) return;` 會擋掉開 burst,
|
|
1304
|
+
// 這裡照舊量到「舊 burst 的面被拔掉了」=false,收尾最後那段焦點檢查才是
|
|
1305
|
+
// 接手的人。兩種情形都不需要這一項改判準。
|
|
1222
1306
|
const burstSurvived = !!(currentBurst && currentBurst.editEl
|
|
1223
1307
|
&& document.body.contains(currentBurst.editEl));
|
|
1224
1308
|
// Task 2 (Phase 3): same belt-and-braces reasoning for the always-on
|
|
@@ -1284,11 +1368,10 @@
|
|
|
1284
1368
|
// whose focus never leaves kills every block-dependent toolbar button until
|
|
1285
1369
|
// the user clicks away and back (MEASURED on the smoke fixture: disabled
|
|
1286
1370
|
// goes 7/22 -> 18/22, and nothing calls updateToolbar() again until the
|
|
1287
|
-
// next click). It has
|
|
1288
|
-
//
|
|
1289
|
-
//
|
|
1290
|
-
//
|
|
1291
|
-
// DOM on the next interaction; (3) closeToolbarMenu() — skipping it leaves
|
|
1371
|
+
// next click). It has TWO further side effects (v3.2.1: the third was
|
|
1372
|
+
// preview mode's contenteditable re-arm, removed with the mode itself),
|
|
1373
|
+
// and both ride along safely: (1) updateToolbar() is re-derived from the
|
|
1374
|
+
// DOM on the next interaction; (2) closeToolbarMenu() — skipping it leaves
|
|
1292
1375
|
// the H▾ dropdown standing across a patch, which is benign because
|
|
1293
1376
|
// `toolbarMenuEl` is appended to document.body, not into a block, so the
|
|
1294
1377
|
// patch never detaches it, and applyHeadingLevel() re-resolves through
|
|
@@ -1428,20 +1511,24 @@
|
|
|
1428
1511
|
async function applyFullRender(j, scrollY, declaredRange) {
|
|
1429
1512
|
blocks = j.blocks;
|
|
1430
1513
|
lastParts = j.parts; // v3.2.0: 供下一次 patch 當 oldParts
|
|
1431
|
-
|
|
1432
|
-
//
|
|
1433
|
-
//
|
|
1434
|
-
//
|
|
1435
|
-
// the fresh nodes (diagram re-init, reader rebind, focus restoration).
|
|
1436
|
-
armEditables(contentEl);
|
|
1514
|
+
// v3.2.1 R2: 必須先於 innerHTML 寫入。這個寫入 detach 掉聚焦的 raw
|
|
1515
|
+
// textarea,同步 focusout 重入 switchAwayFrom() → resolveOpenSession() →
|
|
1516
|
+
// commitNow(),於是同一次編輯提交兩次(實測衍生新 block 時磁碟上文字出現
|
|
1517
|
+
// 兩次;停在同一個 block 內則第二次回 op === null 退化成取消,所以看不出來)。
|
|
1437
1518
|
// Whatever editor (if any) was open a moment ago just got detached by
|
|
1438
|
-
// the innerHTML replacement
|
|
1519
|
+
// the innerHTML replacement below — its own restore()/commit() never
|
|
1439
1520
|
// ran, so it never got a chance to null this out itself. Do it here,
|
|
1440
1521
|
// unconditionally, on every successful swap: this is what makes the
|
|
1441
1522
|
// undo/redo lockout regression (see the `activeEditor` comment above)
|
|
1442
1523
|
// structurally impossible even from a call site that forgets to call
|
|
1443
1524
|
// switchAwayFrom() first.
|
|
1444
1525
|
activeEditor = null;
|
|
1526
|
+
contentEl.innerHTML = j.parts.join('\n'); // fallback 路徑用 join 還原
|
|
1527
|
+
// Task 2 (Phase 3): re-arm every WYSIWYG-eligible paragraph/heading (and
|
|
1528
|
+
// attach a ⠿ handle to every non-table block) in the freshly-swapped
|
|
1529
|
+
// DOM — see armEditables() below. Must run before anything else touches
|
|
1530
|
+
// the fresh nodes (diagram re-init, reader rebind, focus restoration).
|
|
1531
|
+
armEditables(contentEl);
|
|
1445
1532
|
// §10-gap fix (review): same belt-and-braces reasoning for
|
|
1446
1533
|
// `pristineInsert` — its window is meant to close via one of the
|
|
1447
1534
|
// explicit resolution hooks (resolveBurst()/revertBurstAndEnd()/
|
|
@@ -1764,12 +1851,17 @@
|
|
|
1764
1851
|
await safeRerenderAll();
|
|
1765
1852
|
return;
|
|
1766
1853
|
}
|
|
1767
|
-
|
|
1854
|
+
// v3.2.1 R1: 清 activeEditor 必須先於這個 innerHTML 寫入。寫入會 detach
|
|
1855
|
+
// 聚焦的 textarea,Chromium 同步派發 focusout,而 focusout handler 只要
|
|
1856
|
+
// activeEditor 非 null 就會走 switchAwayFrom() → resolveOpenSession() →
|
|
1857
|
+
// commitNow(),把使用者按 Escape 要丟棄的文字提交進檔案(實測落到磁碟)。
|
|
1858
|
+
// 同一函式的 rangeMode 分支早就是這個順序,理由相同。
|
|
1768
1859
|
// Only clear `activeEditor` if it's still THIS block's entry — it may
|
|
1769
1860
|
// already have been cleared out from under us (e.g. by rerenderAll()'s
|
|
1770
1861
|
// own defensive reset on a successful swap elsewhere), and this must
|
|
1771
1862
|
// never stomp some OTHER block's activeEditor set after this one.
|
|
1772
1863
|
if (activeEditor && activeEditor.blockEl === blockEl) activeEditor = null;
|
|
1864
|
+
blockEl.innerHTML = original;
|
|
1773
1865
|
// No re-wiring needed here (unlike the old per-block gutter): block
|
|
1774
1866
|
// selection / the edit bar are handled by ONE delegated `document`
|
|
1775
1867
|
// click listener (see wireBlockSelection() below), so this
|
|
@@ -2576,7 +2668,51 @@
|
|
|
2576
2668
|
// switchAwayFrom() first resolves any editor that's currently open on this
|
|
2577
2669
|
// (or another) block, same precondition as undo()/redo() below, so this
|
|
2578
2670
|
// never races a concurrent commit or operates on stale `lines`/`blocks`.
|
|
2671
|
+
//
|
|
2672
|
+
// v3.2.1 final wave: THIN WRAPPER + `finally`, the same shape the other four
|
|
2673
|
+
// sinks use, because this function's own early exits were live holes in
|
|
2674
|
+
// exactly the promise this version's CHANGELOG makes. MEASURED on ddac6ce
|
|
2675
|
+
// with real keystrokes, three runs each: typing in `# Title` then Shift+Tab
|
|
2676
|
+
// went H1.ed-wys-armed / 15 enabled → BODY / 4 enabled, and `###### Title`
|
|
2677
|
+
// then Tab did the same; `## Title` + Tab (the unclamped control) stayed
|
|
2678
|
+
// H3.ed-wys-armed / 15. The clamp is reachable from an ordinary gesture:
|
|
2679
|
+
// handleBurstKeydown()'s `changeHeadingDepth(currentBurst.blockEl,
|
|
2680
|
+
// e.shiftKey ? -1 : 1)` passes a raw ±1 with no `delta === 0` filter (unlike
|
|
2681
|
+
// applyHeadingLevel(), which computes a delta and can hand over 0), so Tab
|
|
2682
|
+
// at either end of H1..H6 lands on `newDepth === curDepth` — an early return
|
|
2683
|
+
// that sits BELOW the switchAwayFrom() that already committed and re-rendered
|
|
2684
|
+
// and ABOVE the restore at the tail.
|
|
2685
|
+
//
|
|
2686
|
+
// The body answers a line on every exit it can name one for:
|
|
2687
|
+
// * success result.op.startLine (the commit's own first
|
|
2688
|
+
// written line — see the note at the tail)
|
|
2689
|
+
// * newDepth === curDepth block.startLine
|
|
2690
|
+
// * result.op === null block.startLine
|
|
2691
|
+
// `block` is looked up out of `blocks` AFTER switchAwayFrom(), so its
|
|
2692
|
+
// startLine is already a post-render coordinate. INSTRUMENTED at that exit
|
|
2693
|
+
// rather than reasoned: on `# Title` typed into and then Shift+Tab-ed, the
|
|
2694
|
+
// clamped return printed
|
|
2695
|
+
// {sameBlocksArray:false, sameNode:false, blockId:0, startLine:1,
|
|
2696
|
+
// resolvesToId:0, resolvedText:'TitleX'}
|
|
2697
|
+
// — switchAwayFrom() had replaced both the `blocks` array and the heading's
|
|
2698
|
+
// DOM node, and `block.startLine` still resolved to the LIVE block carrying
|
|
2699
|
+
// the just-committed text, which is exactly what focusBlockAtLine() needs.
|
|
2700
|
+
//
|
|
2701
|
+
// The two re-query failures above it (`blockEl` gone, `block` not in
|
|
2702
|
+
// `blocks`) deliberately answer nothing: there is no block left to aim at,
|
|
2703
|
+
// and guessing a line is worse than restoring no caret.
|
|
2579
2704
|
async function changeHeadingDepth(blockEl, delta) {
|
|
2705
|
+
let focusLine = null;
|
|
2706
|
+
const anchor = {};
|
|
2707
|
+
try {
|
|
2708
|
+
const out = await changeHeadingDepthBody(blockEl, delta, anchor);
|
|
2709
|
+
if (out != null) focusLine = out;
|
|
2710
|
+
} finally {
|
|
2711
|
+
restoreAfterStructuralOp(focusLine, anchor.line);
|
|
2712
|
+
}
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
async function changeHeadingDepthBody(blockEl, delta, anchor) {
|
|
2580
2716
|
if (!blockEl) return;
|
|
2581
2717
|
const blockId = Number(blockEl.getAttribute('data-block-id'));
|
|
2582
2718
|
// Commits (never discards) whatever burst/editor is open first — same
|
|
@@ -2609,6 +2745,9 @@
|
|
|
2609
2745
|
}
|
|
2610
2746
|
const block = blocks.find((b) => b.id === blockId);
|
|
2611
2747
|
if (!block) return;
|
|
2748
|
+
// Post-switchAwayFrom(), post-render: the landing for every exit below
|
|
2749
|
+
// that writes nothing. See restoreAfterStructuralOp().
|
|
2750
|
+
if (anchor) anchor.line = block.startLine;
|
|
2612
2751
|
const curLine = lines[block.startLine - 1];
|
|
2613
2752
|
const curDepth = headingDepthOf(curLine);
|
|
2614
2753
|
const newDepth = Math.max(1, Math.min(6, curDepth + delta));
|
|
@@ -2627,6 +2766,28 @@
|
|
|
2627
2766
|
if (!okRender) {
|
|
2628
2767
|
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
2629
2768
|
}
|
|
2769
|
+
// v3.2.1 (controller ruling T5-1): the OTHER sink. H▾ on a block that is
|
|
2770
|
+
// ALREADY a heading routes HERE, not to convertBlockViaMenu() —
|
|
2771
|
+
// applyHeadingLevel() takes that road only for a NON-heading — so without
|
|
2772
|
+
// this, H2 → H4 still ends the way every conversion used to: the render's
|
|
2773
|
+
// teardown ran resetToolbarBlock(), activeElement is BODY and the bar is
|
|
2774
|
+
// down to 4 enabled buttons, and a second operation needs a fresh click.
|
|
2775
|
+
//
|
|
2776
|
+
// Same predicate and the same measurement as convertBlockViaMenu()'s exit
|
|
2777
|
+
// — the wrapper's restoreAfterStructuralOp() is now literally the same
|
|
2778
|
+
// function, so see its note for why it is `blockSelection` and not operand
|
|
2779
|
+
// cardinality, and why forcing a caret while a selection stands
|
|
2780
|
+
// manufactures a broken state rather than fixing one.
|
|
2781
|
+
//
|
|
2782
|
+
// The line is `result.op.startLine`, the commit's OWN first written line:
|
|
2783
|
+
// commitEdit() resolves the block and hands commitRangeEdit() that
|
|
2784
|
+
// startLine, which is where replaceLines() writes `after`. This function
|
|
2785
|
+
// declares no collapse of its own (it is not a set operation), so the
|
|
2786
|
+
// commit result is the only post-commit value it holds — and it is a
|
|
2787
|
+
// RESULT, not a pre-commit `block.startLine` capture. An insertion above
|
|
2788
|
+
// this block cannot have happened between the two, because the commit IS
|
|
2789
|
+
// the only thing that ran.
|
|
2790
|
+
return result.op.startLine;
|
|
2630
2791
|
}
|
|
2631
2792
|
|
|
2632
2793
|
// ── Task 2 (Phase 3): always-on WYSIWYG editing + burst undo ───────────
|
|
@@ -3566,6 +3727,26 @@
|
|
|
3566
3727
|
const target = blocks.find((b) => b.startLine === result.newStartLine);
|
|
3567
3728
|
if (target) pristineInsert = { blockId: target.id };
|
|
3568
3729
|
await focusInsertedBlock(result.newStartLine, kind);
|
|
3730
|
+
} else if (kind === 'line') {
|
|
3731
|
+
// v3.2.1: <hr> 沒有 placeholder,所以不 focusInsertedBlock()(那是上面
|
|
3732
|
+
// 那個 `kind !== 'line'` 既有的刻意 opt-out)。這一支只做工具列復原:把
|
|
3733
|
+
// toolbarBlockEl 指向新插入的 hr block,讓 bar 不要停在 render teardown
|
|
3734
|
+
// 留下的 4 顆。
|
|
3735
|
+
//
|
|
3736
|
+
// ⚠ 明說量測到的事,不要說希望發生的事:**caret 沒有被還原**。實測
|
|
3737
|
+
// (alpha / bravo / charlie,點進 bravo 再按 分隔線)這一支停用時是
|
|
3738
|
+
// {activeElement: BODY, enabled: 4}、啟用後是 {activeElement: BODY,
|
|
3739
|
+
// enabled: 15},兩者磁碟 byte-identical。也就是說使用者按完分隔線仍然
|
|
3740
|
+
// 沒有游標,要再點一次 block 才能繼續打字 —— 工具列復原就是這一支交付的
|
|
3741
|
+
// 全部。要連 caret 一起還原是另一個決定(游標該落在錨點 block 還是 hr
|
|
3742
|
+
// 之後的 block),不在本 task 的範圍內。
|
|
3743
|
+
//
|
|
3744
|
+
// Final wave, M4: gated on `blockSelection` like the other four restore
|
|
3745
|
+
// sites. Harmless today — a + 分隔線 pressed with a set standing writes
|
|
3746
|
+
// no declareCollapse(), so re-aiming the bar at the hr cannot contradict
|
|
3747
|
+
// a tinted set the way a forced caret would — but "the one site that does
|
|
3748
|
+
// not ask the question" is how the next divergence gets in.
|
|
3749
|
+
if (!blockSelection) reaimToolbarBlockAtLine(result.newStartLine);
|
|
3569
3750
|
}
|
|
3570
3751
|
}
|
|
3571
3752
|
|
|
@@ -3685,7 +3866,34 @@
|
|
|
3685
3866
|
// already been raised — otherwise `{ els, recs, batch }`: the LIVE block
|
|
3686
3867
|
// elements in document order, their records out of `blocks` (by reference),
|
|
3687
3868
|
// and whether more than one block is being operated on.
|
|
3688
|
-
|
|
3869
|
+
//
|
|
3870
|
+
// v3.2.1 final wave: `anchor` is an optional OUT-PARAMETER. When given, its
|
|
3871
|
+
// `.line` is set to the grip block's start line as soon as that block has
|
|
3872
|
+
// been re-resolved against the post-switchAwayFrom() `blocks` — i.e. a
|
|
3873
|
+
// number that is already valid on the freshly rendered DOM. Every refusal
|
|
3874
|
+
// BELOW that assignment therefore leaves the caller holding a live
|
|
3875
|
+
// coordinate for a block the refusal did not touch.
|
|
3876
|
+
//
|
|
3877
|
+
// FIVE exits sit ABOVE it and all of them leave `.line` undefined, which is
|
|
3878
|
+
// the honest answer in each case (N3 — the first version of this sentence
|
|
3879
|
+
// said "the two exits", the same undercount M2 existed to fix, so they are
|
|
3880
|
+
// counted out here one by one):
|
|
3881
|
+
// 1. `if (!blockEl) return null` — no gesture at all.
|
|
3882
|
+
// 2. `if (!ok) return null` — switchAwayFrom()'s commit FAILED
|
|
3883
|
+
// and the session it could not commit is still open with its own
|
|
3884
|
+
// banner; aiming a caret anywhere would fight it.
|
|
3885
|
+
// 3. the dropped-gesture re-resolve — the block is genuinely gone.
|
|
3886
|
+
// 4. the no-source-line refusal — the block is there, but its range
|
|
3887
|
+
// is inverted (endLine === startLine - 1), and
|
|
3888
|
+
// `blocks.find(b => b.startLine === …)` would resolve some OTHER block.
|
|
3889
|
+
// 5. `if (!rec)` (a second DROPPED_GESTURE) — the one the undercount lost.
|
|
3890
|
+
// It is NOT absorbed by 4: blockOwnsNoLine() is `!!rec && rec.endLine <
|
|
3891
|
+
// rec.startLine`, so it answers FALSE when the id is absent from
|
|
3892
|
+
// `blocks`, and it also answers false for a provisional block whose
|
|
3893
|
+
// `data-block-id` is null (blockRecOf() returns null for both). Either
|
|
3894
|
+
// way there is no record to read a startLine off.
|
|
3895
|
+
// See restoreAfterStructuralOp().
|
|
3896
|
+
async function resolveGutterOperands(blockEl, anchor) {
|
|
3689
3897
|
if (!blockEl) return null;
|
|
3690
3898
|
// Finding 5a's mousedown preventDefault() deliberately keeps a dirty burst
|
|
3691
3899
|
// alive across the ⠿ press, so the commit that lands inside
|
|
@@ -3711,6 +3919,7 @@
|
|
|
3711
3919
|
if (blockOwnsNoLine(liveBlockEl)) { refuseStructuralListEdit(NO_SOURCE_LINE_MESSAGE); return null; }
|
|
3712
3920
|
const rec = blockRecOf(liveBlockEl);
|
|
3713
3921
|
if (!rec) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return null; }
|
|
3922
|
+
if (anchor) anchor.line = rec.startLine;
|
|
3714
3923
|
|
|
3715
3924
|
const res = selectionLib
|
|
3716
3925
|
? selectionLib.resolveMembership(blockSelection, blocks, rec)
|
|
@@ -3884,7 +4093,148 @@
|
|
|
3884
4093
|
return run;
|
|
3885
4094
|
}
|
|
3886
4095
|
|
|
4096
|
+
// ── v3.2.1 final wave: ONE landing for all four structural sinks ────────
|
|
4097
|
+
//
|
|
4098
|
+
// The v3.2.1 wrappers restored only on the SUCCESS line the body answered.
|
|
4099
|
+
// Measured on ddac6ce, 轉換成 → 引用 on a hard-wrapped list item (an ENABLED
|
|
4100
|
+
// menu item — `disabled === false`, measured) whose own burst was dirty:
|
|
4101
|
+
// DIV.ed-li-text ed-wys-armed / 16 enabled → BODY / 4 enabled, plus the
|
|
4102
|
+
// banner 「此清單含不支援的格式,無法調整結構」. The `finally` WAS reached;
|
|
4103
|
+
// `focusLine` was simply null there, so focusBlockAtLine() was skipped and
|
|
4104
|
+
// reaimToolbarBlockAtLine(null) returned on its first line — while
|
|
4105
|
+
// resolveGutterOperands()'s own switchAwayFrom() had already committed the
|
|
4106
|
+
// burst and re-rendered, which is what took the foothold away.
|
|
4107
|
+
//
|
|
4108
|
+
// Hence the second coordinate. `focusLine` is the body's SUCCESS landing and
|
|
4109
|
+
// is used exactly as before. `anchorLine` is resolveGutterOperands()'s live
|
|
4110
|
+
// grip line (see its `anchor` out-parameter) and is used ONLY when the body
|
|
4111
|
+
// answered nothing — a refusal, a no-op commit, or a rolled-back render.
|
|
4112
|
+
//
|
|
4113
|
+
// The foothold check on that second path is deliberate and is NOT symmetric
|
|
4114
|
+
// with the first. A refusal can also arrive with the user's caret still
|
|
4115
|
+
// exactly where they left it (a CLEAN burst goes out through
|
|
4116
|
+
// endBurstWithoutResolve(): no commit, no render, focus never moves —
|
|
4117
|
+
// measured: 轉換成 → 引用 on the same hard-wrapped li WITHOUT typing first
|
|
4118
|
+
// leaves DIV.ed-li-text ed-wys-armed / 16 enabled on ddac6ce). Restoring
|
|
4119
|
+
// unconditionally there would drag that caret to the end of the block for no
|
|
4120
|
+
// reason — the same mistake fix round 1 corrected in applyLinkToggle(), and
|
|
4121
|
+
// this is the same predicate it settled on.
|
|
4122
|
+
//
|
|
4123
|
+
// M3, the predicate divergence: the four sinks now AGREE that a rolled-back
|
|
4124
|
+
// render restores. Verified by reading rather than driven (a render failure
|
|
4125
|
+
// needs the server to fail mid-gesture): every `return false` inside
|
|
4126
|
+
// rerenderAll() is one of its four fetch/parse exits, all of them above the
|
|
4127
|
+
// call to applyRenderResult(); applyPatch() and applyFullRender() contain no
|
|
4128
|
+
// `return false` at all (grep: zero hits between them). So a `false` answer
|
|
4129
|
+
// from safeRerenderAll() means the DOM was never touched — the anchor block
|
|
4130
|
+
// is still on screen under the same id — while the switchAwayFrom() that ran
|
|
4131
|
+
// BEFORE it may already have re-rendered and reset the bar. Restoring is
|
|
4132
|
+
// therefore the right answer, and returning empty-handed (what
|
|
4133
|
+
// duplicateBlockViaMenuBody() and deleteBlockViaGutterBody() used to do on
|
|
4134
|
+
// `!okRender`) strands the user in exactly the I1/I2 shape. The one failure
|
|
4135
|
+
// this does NOT cover is a THROW inside applyPatch()/applyFullRender(),
|
|
4136
|
+
// caught by safeRerenderAll(): that can leave the DOM half-swapped, and
|
|
4137
|
+
// neither the old behaviour nor this one has an answer for it.
|
|
4138
|
+
function restoreAfterStructuralOp(focusLine, anchorLine) {
|
|
4139
|
+
// 有 blockSelection 站著時什麼都不做 —— 見 convertBlockViaMenu() 底下那段
|
|
4140
|
+
// 註解:強行還原 caret 會做出「底色仍說已選取、Delete 卻是死鍵」的混種狀態。
|
|
4141
|
+
if (blockSelection) return;
|
|
4142
|
+
if (focusLine != null) {
|
|
4143
|
+
focusBlockAtLine(focusLine, true); // caretToEnd
|
|
4144
|
+
// 降級目標(quote / code)沒有可聚焦編輯面,focusBlockAtLine 會安靜
|
|
4145
|
+
// no-op;把 toolbarBlockEl 指回那個 block,工具列才不會塌成 4 顆。
|
|
4146
|
+
reaimToolbarBlockAtLine(focusLine);
|
|
4147
|
+
return;
|
|
4148
|
+
}
|
|
4149
|
+
if (anchorLine == null) return;
|
|
4150
|
+
const held = document.activeElement;
|
|
4151
|
+
const heldSurface = held && held.closest
|
|
4152
|
+
? held.closest('.ed-wys-armed, .ed-wys-cell, .ed-raw') : null;
|
|
4153
|
+
if (heldSurface) {
|
|
4154
|
+
// ⚠ C1. "The caret never left, so there is nothing to do" was TRUE of the
|
|
4155
|
+
// caret and FALSE of the toolbar, and the first version of this guard
|
|
4156
|
+
// skipped both. The bar can be down to 4 while the caret is still sitting
|
|
4157
|
+
// on a live surface, and that is the ORDINARY case, not an edge one: it
|
|
4158
|
+
// is what happens on the PATCH render path, i.e. on every commit after
|
|
4159
|
+
// the first of a page load (`lastParts` is null only for that first one,
|
|
4160
|
+
// so the fallback route — the one this guard was measured on — is
|
|
4161
|
+
// unreachable after it).
|
|
4162
|
+
//
|
|
4163
|
+
// INSTRUMENTED inside applyPatch() on a primed ⠿刪除 refusal, one log
|
|
4164
|
+
// line per step, rather than read off the source:
|
|
4165
|
+
// route PATCH
|
|
4166
|
+
// focusWasInSpan? v:true currentBurst:true burstElAttached:false
|
|
4167
|
+
// active:BODY
|
|
4168
|
+
// afterFocusRestore active:DIV.ed-li-text ed-wys-armed
|
|
4169
|
+
// toolbarBlockElNull:false
|
|
4170
|
+
// burstSurvived v:false currentBurst:true
|
|
4171
|
+
// active:DIV.ed-li-text ed-wys-armed
|
|
4172
|
+
// resetToolbarBlock? willRun:true
|
|
4173
|
+
// afterResetToolbarBlock toolbarBlockElNull:true
|
|
4174
|
+
// restoreAfterStructuralOp active:DIV.ed-li-text ed-wys-armed
|
|
4175
|
+
// toolbarBlockElNull:true
|
|
4176
|
+
// — applyPatch()'s `focusWasInSpan` block hands focus back to the NEW
|
|
4177
|
+
// surface, and `burstSurvived` is only evaluated afterwards. On this
|
|
4178
|
+
// route `currentBurst` is still the OLD burst (resolveBurst()'s own
|
|
4179
|
+
// teardown has not nulled it yet, which is also why focusin's
|
|
4180
|
+
// `if (currentBurst) return;` stops the focus() from opening a fresh
|
|
4181
|
+
// one), and that old burst's editEl was detached by the patch — so
|
|
4182
|
+
// `burstSurvived` is false and `resetToolbarBlock()` runs AFTER the
|
|
4183
|
+
// caret was handed back. The unprimed run of the same gesture took the
|
|
4184
|
+
// FALLBACK route and arrived here with active:BODY, which is why the
|
|
4185
|
+
// first wave's measurements never saw this.
|
|
4186
|
+
//
|
|
4187
|
+
// So: do not drag the caret (it never left), but DO re-aim the bar, at
|
|
4188
|
+
// the block the caret is actually in rather than at `anchorLine` — those
|
|
4189
|
+
// are the same block on every path measured, and when they ever differ
|
|
4190
|
+
// the caret's own block is the honest answer.
|
|
4191
|
+
const blk = heldSurface.closest('.ed-block');
|
|
4192
|
+
if (blk && document.body.contains(blk)) { toolbarBlockEl = blk; updateToolbar(); }
|
|
4193
|
+
return;
|
|
4194
|
+
}
|
|
4195
|
+
focusBlockAtLine(anchorLine, true); // caretToEnd
|
|
4196
|
+
reaimToolbarBlockAtLine(anchorLine);
|
|
4197
|
+
}
|
|
4198
|
+
|
|
4199
|
+
// v3.2.1: the caret restore is a THIN WRAPPER around the conversion body so
|
|
4200
|
+
// that it sits at the function's EXIT and every exit reaches it. The body's
|
|
4201
|
+
// pre-commit refusal exits, enumerated (final wave, M2 — the sentence that
|
|
4202
|
+
// stood here said "three" and named three of them):
|
|
4203
|
+
// 1. `if (!operands) return` — resolveGutterOperands() answered
|
|
4204
|
+
// null (dropped gesture, no source
|
|
4205
|
+
// line, empty/gapped/no-element
|
|
4206
|
+
// batch)
|
|
4207
|
+
// 2. the mixed list/non-list refusal (BATCH_MIXED_MESSAGE)
|
|
4208
|
+
// 3. the withheld-type refusal (BATCH_CONVERT_WITHHELD_MESSAGES)
|
|
4209
|
+
// 4. `if (!liRun)` (BATCH_MULTIRUN_MESSAGE)
|
|
4210
|
+
// 5. `!listRunSupportsStructuralEdit()` (§4.1's run-wide gate)
|
|
4211
|
+
// 6. `if (!stripped.ok)` (per-block 「此區塊的格式無法轉換」)
|
|
4212
|
+
// — six, not three, plus `result.op === null` (a commit that wrote nothing)
|
|
4213
|
+
// and the rolled-back render on the tail. Every one of them is reachable
|
|
4214
|
+
// from a lit ⠿ menu item, and the li → list / li → non-list / blocks → list
|
|
4215
|
+
// helpers this function tail-calls carry their own refusals on top.
|
|
4216
|
+
//
|
|
4217
|
+
// The body returns the operated block's post-commit start line, or undefined
|
|
4218
|
+
// when it refused or wrote nothing — `anchor.line` is what covers the
|
|
4219
|
+
// second case (see restoreAfterStructuralOp()).
|
|
3887
4220
|
async function convertBlockViaMenu(blockEl, target) {
|
|
4221
|
+
let focusLine = null;
|
|
4222
|
+
const anchor = {};
|
|
4223
|
+
try {
|
|
4224
|
+
const out = await convertBlockViaMenuBody(blockEl, target, anchor);
|
|
4225
|
+
if (out != null) focusLine = out;
|
|
4226
|
+
} finally {
|
|
4227
|
+
// v3.2.1: 判準是「有沒有 blockSelection 站著」,不是 operand 數量 ——
|
|
4228
|
+
// 實測 operands.els.length === 1 在「有選取」與「無選取」兩態都成立,
|
|
4229
|
+
// 鑑別力為零。有選取時 declareCollapse() → applySelectionClasses() 已經
|
|
4230
|
+
// 在全新 DOM 上把 roving focus 裝回 .ed-block wrapper(9 種轉換形狀全部
|
|
4231
|
+
// 如此),這時強行還原 caret 會製造混種狀態:底色仍說「已選取」,Delete
|
|
4232
|
+
// 卻變成死鍵、Shift+↓ 降級成移動文字游標。所以有選取時什麼都不做。
|
|
4233
|
+
restoreAfterStructuralOp(focusLine, anchor.line);
|
|
4234
|
+
}
|
|
4235
|
+
}
|
|
4236
|
+
|
|
4237
|
+
async function convertBlockViaMenuBody(blockEl, target, anchor) {
|
|
3888
4238
|
if (!blockEl || !target) return;
|
|
3889
4239
|
// §3.3's membership rules and the whole shared preamble — see
|
|
3890
4240
|
// resolveGutterOperands() above. `els` is ONE contiguous span; the plan's
|
|
@@ -3894,7 +4244,7 @@
|
|
|
3894
4244
|
// fenced block raw-edited into two paragraphs changes the BLOCK count
|
|
3895
4245
|
// without changing the LINE count, so an id-indexed delete hit the wrong
|
|
3896
4246
|
// block, and the same shape silently rewrote a neighbouring table).
|
|
3897
|
-
const operands = await resolveGutterOperands(blockEl);
|
|
4247
|
+
const operands = await resolveGutterOperands(blockEl, anchor);
|
|
3898
4248
|
if (!operands) return;
|
|
3899
4249
|
const els = operands.els;
|
|
3900
4250
|
const recs = operands.recs;
|
|
@@ -3967,8 +4317,7 @@
|
|
|
3967
4317
|
// the run stays a run and the existing re-serialization machinery applies
|
|
3968
4318
|
// unchanged.
|
|
3969
4319
|
if (shape.allLi && convertMd.targetIsList(target)) {
|
|
3970
|
-
await convertListItemsType(els, liRun, recs, target);
|
|
3971
|
-
return;
|
|
4320
|
+
return await convertListItemsType(els, liRun, recs, target);
|
|
3972
4321
|
}
|
|
3973
4322
|
// S2 Task 4 / S3 Task 6: li → a NON-list target. The items LEAVE the run,
|
|
3974
4323
|
// so the span has to be rebuilt in three pieces and §4.3 rule 1's blank
|
|
@@ -3976,16 +4325,14 @@
|
|
|
3976
4325
|
// lines mid-list with no separator and lazy continuation would swallow them
|
|
3977
4326
|
// into the item above (measured, §4.3 rule 1).
|
|
3978
4327
|
if (shape.allLi) {
|
|
3979
|
-
await convertListItemsAway(els, liRun, recs, target);
|
|
3980
|
-
return;
|
|
4328
|
+
return await convertListItemsAway(els, liRun, recs, target);
|
|
3981
4329
|
}
|
|
3982
4330
|
// S2 Task 5 / S3 Task 6: non-list blocks BECOME list items, so §4.3 rule
|
|
3983
4331
|
// 2's looseness policy applies — eat the separator to an adjacent run of
|
|
3984
4332
|
// the same list type, or the merged list goes LOOSE and every item of it
|
|
3985
4333
|
// degrades read-only. Same rule, same helper the li → li path above uses.
|
|
3986
4334
|
if (convertMd.targetIsList(target)) {
|
|
3987
|
-
await convertBlocksIntoList(els, recs, shape.kinds, target);
|
|
3988
|
-
return;
|
|
4335
|
+
return await convertBlocksIntoList(els, recs, shape.kinds, target);
|
|
3989
4336
|
}
|
|
3990
4337
|
|
|
3991
4338
|
// The plain path: N non-list blocks to a non-list target. One commit over
|
|
@@ -4028,6 +4375,11 @@
|
|
|
4028
4375
|
if (!okRender) {
|
|
4029
4376
|
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
4030
4377
|
}
|
|
4378
|
+
// v3.2.1 §Task 5: the caller's `focusLine`. NOT a new value and NOT a
|
|
4379
|
+
// pre-commit startLine — it is the very line the collapse above declares,
|
|
4380
|
+
// i.e. the operated span's post-commit first line. The plain path is the
|
|
4381
|
+
// one path that does not move it (the commit replaces the span in place).
|
|
4382
|
+
return first.startLine;
|
|
4031
4383
|
}
|
|
4032
4384
|
|
|
4033
4385
|
// Is the editor session switchAwayFrom() would resolve open on THIS block?
|
|
@@ -4085,12 +4437,51 @@
|
|
|
4085
4437
|
// `lines`, and the li path carries the clone through bystanderCarryOver()
|
|
4086
4438
|
// under the ORIGINAL's block id, so list-md.js replays the file's own bytes
|
|
4087
4439
|
// for it and only re-states the marker. Both keep a `~5px` a `~5px`.
|
|
4440
|
+
//
|
|
4441
|
+
// v3.2.1: the caret restore is a THIN WRAPPER around the duplication body,
|
|
4442
|
+
// same shape and same reason as convertBlockViaMenu() above: the body has
|
|
4443
|
+
// several exits that duplicate nothing — resolveGutterOperands() answering
|
|
4444
|
+
// null, the mixed-list refusal, each of duplicateListItems()'s four, the
|
|
4445
|
+
// op === null no-op and the failed render on the non-li tail — all of them
|
|
4446
|
+
// reachable from a lit ⠿ menu item, and the wrapper is what makes every one
|
|
4447
|
+
// of them reach the restore. The body answers the ORIGINAL span's first
|
|
4448
|
+
// start line on success and undefined on every one of those exits.
|
|
4449
|
+
//
|
|
4450
|
+
// Final wave: "reach the restore" was true and not sufficient — with
|
|
4451
|
+
// `focusLine` null the restore had nothing to aim at and did nothing. The
|
|
4452
|
+
// refusal exits now land on `anchor.line`; see restoreAfterStructuralOp().
|
|
4453
|
+
//
|
|
4454
|
+
// 落點是【原件】而不是副本。三個理由,其中兩個是這個檔案自己已經寫下的事實:
|
|
4455
|
+
// 1. 一個選單項目只能有一個答案。有 blockSelection 站著時,§3.3 的塌縮把
|
|
4456
|
+
// 集合留在【原件】上(見 body 裡 declareCollapse() 那段註解:副本插在
|
|
4457
|
+
// 所有被複製的東西【下面】,所以原件的行區間原封不動)。落在副本會讓
|
|
4458
|
+
// 「按完 建立副本 之後手上握著什麼」取決於一個看不見的先前狀態。
|
|
4459
|
+
// 2. 原件的行號直接可讀,副本的行號在 li 路徑上要另外付代價。副本帶著
|
|
4460
|
+
// 原件的 data-block-id,所以 runLineOfBlock() 的
|
|
4461
|
+
// `lineMeta.findIndex((m) => m.blockId === targetId)` 回的是【原件】那
|
|
4462
|
+
// 一行。這不是做不到 —— runLineOfBlock() 對沒有 id 的 block 另有一支
|
|
4463
|
+
// 按位置數行的 fallback,而副本在 span 裡的位置是已知的 `at + 1 + i`。
|
|
4464
|
+
// 代價在別的地方:那個共用的 id 正是 bystanderCarryOver() 逐位元組重播
|
|
4465
|
+
// 副本、而不是重新 escape 它的原因(見下方 duplicateListItems() 的註解,
|
|
4466
|
+
// 一個 '~5px' 不重播就會變成 '\~5px')。所以這是一個取捨,不是一道牆。
|
|
4467
|
+
// 3. 畫面上沒有東西在使用者底下移動:副本長在下面,原件原地不動。
|
|
4088
4468
|
async function duplicateBlockViaMenu(blockEl) {
|
|
4469
|
+
let focusLine = null;
|
|
4470
|
+
const anchor = {};
|
|
4471
|
+
try {
|
|
4472
|
+
const out = await duplicateBlockViaMenuBody(blockEl, anchor);
|
|
4473
|
+
if (out != null) focusLine = out;
|
|
4474
|
+
} finally {
|
|
4475
|
+
restoreAfterStructuralOp(focusLine, anchor.line);
|
|
4476
|
+
}
|
|
4477
|
+
}
|
|
4478
|
+
|
|
4479
|
+
async function duplicateBlockViaMenuBody(blockEl, anchor) {
|
|
4089
4480
|
// §3.3's membership rules plus the shared preamble — see
|
|
4090
4481
|
// resolveGutterOperands(). The whole span is duplicated by ONE commit; a
|
|
4091
4482
|
// per-member loop would re-render between items and invalidate every id in
|
|
4092
4483
|
// between.
|
|
4093
|
-
const operands = await resolveGutterOperands(blockEl);
|
|
4484
|
+
const operands = await resolveGutterOperands(blockEl, anchor);
|
|
4094
4485
|
if (!operands) return;
|
|
4095
4486
|
const els = operands.els;
|
|
4096
4487
|
const recs = operands.recs;
|
|
@@ -4098,8 +4489,12 @@
|
|
|
4098
4489
|
if (shape.anyLi && !shape.allLi) { refuseStructuralListEdit(BATCH_MIXED_MESSAGE); return; }
|
|
4099
4490
|
|
|
4100
4491
|
if (shape.allLi) {
|
|
4101
|
-
|
|
4102
|
-
|
|
4492
|
+
// recs[0].startLine is unchanged by this commit — duplicateListItems()'s
|
|
4493
|
+
// own declareCollapse() comment is the argument: the copies land after
|
|
4494
|
+
// everything the set owns, and no member's line COUNT can change (a
|
|
4495
|
+
// multi-line target is refused, and §3.8's renumbering only ever changes
|
|
4496
|
+
// marker WIDTH), so the originals still occupy the range they occupied.
|
|
4497
|
+
return (await duplicateListItems(els, recs)) ? recs[0].startLine : undefined;
|
|
4103
4498
|
}
|
|
4104
4499
|
|
|
4105
4500
|
// The non-li span duplicates as ONE slice of `lines` — separators between
|
|
@@ -4126,7 +4521,13 @@
|
|
|
4126
4521
|
: undefined;
|
|
4127
4522
|
if (!(await safeRerenderAll(editRange))) {
|
|
4128
4523
|
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
4524
|
+
return;
|
|
4129
4525
|
}
|
|
4526
|
+
// The insertion is anchored on the LAST member and lands BELOW it, so
|
|
4527
|
+
// ops.shiftBlocks() moved only ids greater than that one — the span's own
|
|
4528
|
+
// first start line is the same number before and after, which is the same
|
|
4529
|
+
// fact declareCollapse() above relies on.
|
|
4530
|
+
return first.startLine;
|
|
4130
4531
|
}
|
|
4131
4532
|
|
|
4132
4533
|
// The li half of 建立副本. The copy is spliced into the run's own span and the
|
|
@@ -4135,10 +4536,15 @@
|
|
|
4135
4536
|
// §3.8's renumbering falls out of the re-serialization ('1. alpha' duplicated
|
|
4136
4537
|
// gives '1. alpha / 2. alpha / 3. bravo', not '1. alpha / 1. alpha / 2.
|
|
4137
4538
|
// bravo').
|
|
4539
|
+
//
|
|
4540
|
+
// v3.2.1: returns commitListStructure()'s own true/false, and false for each
|
|
4541
|
+
// of the four refusal exits — duplicateBlockViaMenu()'s wrapper needs to tell
|
|
4542
|
+
// a refusal (nothing was duplicated, the block is still where it was) apart
|
|
4543
|
+
// from a completed duplicate.
|
|
4138
4544
|
async function duplicateListItems(liEls, recs) {
|
|
4139
4545
|
// Every list batch is one run's problem — see batchRunOf().
|
|
4140
4546
|
const run = batchRunOf(liEls);
|
|
4141
|
-
if (!run) { refuseStructuralListEdit(BATCH_MULTIRUN_MESSAGE); return; }
|
|
4547
|
+
if (!run) { refuseStructuralListEdit(BATCH_MULTIRUN_MESSAGE); return false; }
|
|
4142
4548
|
// §4.3's run-wide gate — 轉換/建立副本/刪除/拖曳 each make this call for
|
|
4143
4549
|
// themselves; there is no shared helper. Its input is §3.4 rule 2's scope,
|
|
4144
4550
|
// which is exactly what listRunOf() returns (the outermost run PLUS every
|
|
@@ -4146,14 +4552,14 @@
|
|
|
4146
4552
|
// it adds the item's lines over again), so a multi-line li refuses as a
|
|
4147
4553
|
// TARGET while remaining a perfectly good bystander — and in a batch EVERY
|
|
4148
4554
|
// member is a target.
|
|
4149
|
-
if (!listRunSupportsStructuralEdit(run, liEls)) { refuseStructuralListEdit(); return; }
|
|
4555
|
+
if (!listRunSupportsStructuralEdit(run, liEls)) { refuseStructuralListEdit(); return false; }
|
|
4150
4556
|
// Captured BEFORE the copy enters the span. The copy carries the
|
|
4151
4557
|
// ORIGINAL's data-block-id — that is what makes bystanderCarryOver() replay
|
|
4152
4558
|
// its bytes rather than re-escape them — so runRangeOfBlocks() would
|
|
4153
4559
|
// resolve it to the original's record, and on a duplicate of the span's
|
|
4154
4560
|
// LAST member that silently re-states the range's end line.
|
|
4155
4561
|
const range = runRangeOfBlocks({ lines, blocks, stack }, run);
|
|
4156
|
-
if (!range) return;
|
|
4562
|
+
if (!range) return false;
|
|
4157
4563
|
|
|
4158
4564
|
// §4.3, measured: after the SUBTREE, not after the item's own line.
|
|
4159
4565
|
// subtreeBlocksAfter() is the flat model's subtree — the contiguous run of
|
|
@@ -4166,7 +4572,7 @@
|
|
|
4166
4572
|
const subtree = subtreeBlocksAfter(anchorLi, Number(anchorLi.getAttribute('data-indent')) || 0);
|
|
4167
4573
|
const lastEl = subtree.length ? subtree[subtree.length - 1] : anchorLi;
|
|
4168
4574
|
const at = run.indexOf(lastEl);
|
|
4169
|
-
if (at < 0) return;
|
|
4575
|
+
if (at < 0) return false;
|
|
4170
4576
|
|
|
4171
4577
|
const copies = liEls.map((liEl) => liEl.cloneNode(true));
|
|
4172
4578
|
// `data-list-start` is the ONLY carrier of "marked's lexer opened a new
|
|
@@ -4209,33 +4615,161 @@
|
|
|
4209
4615
|
startLine: recs[0].startLine,
|
|
4210
4616
|
endLine: recs[recs.length - 1].endLine,
|
|
4211
4617
|
});
|
|
4212
|
-
await commitListStructure(span, null, false,
|
|
4618
|
+
return await commitListStructure(span, null, false,
|
|
4213
4619
|
{ presetRange: range, carryOver: bystanderCarryOver(span) });
|
|
4214
4620
|
}
|
|
4215
4621
|
// Deletes `blockEl`'s ENTIRE line range (generalizing commitListBlockRemoval()
|
|
4216
4622
|
// — unchanged, see its own comment — to any block type, not just an
|
|
4217
4623
|
// emptied-out list). Same resolve-first / re-query-live-block-by-id
|
|
4218
4624
|
// precondition as insertBlockBelow() above.
|
|
4625
|
+
//
|
|
4626
|
+
// v3.2.1: the caret restore is a THIN WRAPPER around the removal body, same
|
|
4627
|
+
// shape as convertBlockViaMenu() above. A delete has no "back to the block it
|
|
4628
|
+
// operated on" — the block is gone — so the body answers the HOLE's line
|
|
4629
|
+
// coordinate (the removed span's own first start line) and
|
|
4630
|
+
// blockLineAfterRemoval() below turns that into a block that still exists.
|
|
4631
|
+
//
|
|
4632
|
+
// 有 blockSelection 站著時什麼都不做的判準與 convertBlockViaMenu() 相同,
|
|
4633
|
+
// 但在這條路徑上它幾乎總是讓路:批次刪除的 §3.3 塌縮是 declareCollapse(null)
|
|
4634
|
+
// =清空集合(見 body 內那段註解),render 之後 blockSelection 已經是 null。
|
|
4635
|
+
// 那是對的 —— 沒有集合站著就沒有「底色仍說已選取、Delete 卻是死鍵」的混種
|
|
4636
|
+
// 狀態可言,而使用者剛把手上唯一的著力點刪掉,更需要拿回一個。
|
|
4219
4637
|
async function deleteBlockViaGutter(blockEl) {
|
|
4638
|
+
let removed = null;
|
|
4639
|
+
const anchor = {};
|
|
4640
|
+
try {
|
|
4641
|
+
const out = await deleteBlockViaGutterBody(blockEl, anchor);
|
|
4642
|
+
if (out) removed = out;
|
|
4643
|
+
} finally {
|
|
4644
|
+
// Final wave: on the refusal / rolled-back-render exits `removed` is
|
|
4645
|
+
// null, so the landing falls through to `anchor.line` — the block the
|
|
4646
|
+
// gesture named, which a refusal left exactly where it was. On the
|
|
4647
|
+
// SUCCESS path that block no longer exists, which is why the success
|
|
4648
|
+
// coordinate is blockLineAfterRemoval()'s carried neighbour and not the
|
|
4649
|
+
// anchor: restoreAfterStructuralOp() only consults `anchor.line` when
|
|
4650
|
+
// the first argument is null.
|
|
4651
|
+
const focusLine = removed ? blockLineAfterRemoval(removed) : null;
|
|
4652
|
+
// `anchor.line` is the line of the block that was just DELETED, so it is
|
|
4653
|
+
// offered only when the body refused. A successful delete that cannot
|
|
4654
|
+
// name a landing (blockLineAfterRemoval() answering null — an emptied
|
|
4655
|
+
// document) must restore nothing rather than aim at the hole.
|
|
4656
|
+
restoreAfterStructuralOp(focusLine, removed ? null : anchor.line);
|
|
4657
|
+
}
|
|
4658
|
+
}
|
|
4659
|
+
|
|
4660
|
+
// The block the user is left holding once a span has been removed. Takes the
|
|
4661
|
+
// `{ prevLine, spanIsFirst }` the body captured BEFORE its commit:
|
|
4662
|
+
//
|
|
4663
|
+
// prevLine the block immediately ABOVE the removed span, or null when
|
|
4664
|
+
// there is none. A PRE-commit start line, and stable across the
|
|
4665
|
+
// render for ONE reason that covers every path, not a reason
|
|
4666
|
+
// per path:
|
|
4667
|
+
//
|
|
4668
|
+
// commitRangeRemoval() and commitRangeEdit() compute the
|
|
4669
|
+
// SAME anchor — `blocks.filter((b) => b.endLine <= endLine)
|
|
4670
|
+
// .pop()`, textually identical in both — and hand it to
|
|
4671
|
+
// ops.shiftBlocks(), which moves a block only when
|
|
4672
|
+
// `b.id > editedId` (lineops.js). Ids are positional
|
|
4673
|
+
// ordinals in document order, so the block above the span has
|
|
4674
|
+
// a SMALLER id than every block inside it, and the anchor is
|
|
4675
|
+
// at least the span's last member (that member is itself in
|
|
4676
|
+
// the filtered list). The neighbour therefore fails
|
|
4677
|
+
// `b.id > editedId` whichever helper runs.
|
|
4678
|
+
//
|
|
4679
|
+
// ⚠ Fix round 3. The sentence that stood here said the li path
|
|
4680
|
+
// calls NEITHER of those helpers. That is false, and its own
|
|
4681
|
+
// named example is the counter-example: commitListStructure()
|
|
4682
|
+
// routes `md === ''` — no survivors, i.e. a WHOLE-RUN delete —
|
|
4683
|
+
// straight into commitRangeRemoval(). Instrumented and driven
|
|
4684
|
+
// rather than read: 刪除 over all three items of
|
|
4685
|
+
// '- alpha / - bravo / - charlie' printed
|
|
4686
|
+
// commitRangeRemoval range=5..7 anchor=id4(7..7)
|
|
4687
|
+
// shiftBlocks editedId=4 delta=-4 movedIds=[5] untouchedIds=[0,1,2,3,4]
|
|
4688
|
+
// while a partial delete of the same run printed
|
|
4689
|
+
// commitRangeEdit range=5..7 anchor=id4(7..7)
|
|
4690
|
+
// shiftBlocks editedId=4 delta=-1 movedIds=[5] untouchedIds=[0,1,2,3,4]
|
|
4691
|
+
// — two different helpers, the same anchor, and in both the
|
|
4692
|
+
// neighbour (id1) sits in `untouchedIds`. Five gestures were
|
|
4693
|
+
// instrumented this way (whole run, middle item, first item, a
|
|
4694
|
+
// non-li paragraph, and a run that is the entire document) and
|
|
4695
|
+
// the neighbour was untouched in all five.
|
|
4696
|
+
// spanIsFirst true when the removed span WAS the document's first block, so
|
|
4697
|
+
// "no previous block" is a fact rather than a failed lookup —
|
|
4698
|
+
// see deleteBlockViaGutterBody()'s own comment for why those two
|
|
4699
|
+
// are not the same answer.
|
|
4700
|
+
//
|
|
4701
|
+
// 「上面那一個、游標落在它的結尾」是 Notion 的答案,也是 Backspace 併行的
|
|
4702
|
+
// 落點;這個編輯器的 ⠿ / 建立副本 / 轉換成 整組詞彙都是 Notion 的。
|
|
4703
|
+
//
|
|
4704
|
+
// ⚠ Fix round 1, item 2: this used to INFER the neighbour from the hole's
|
|
4705
|
+
// coordinate — "the largest post-render startLine strictly below it". That is
|
|
4706
|
+
// wrong whenever commitRangeRemoval() takes its `sl -= 1` branch (the line
|
|
4707
|
+
// after the span is not blank, so it absorbs the blank ABOVE instead): every
|
|
4708
|
+
// block BELOW the hole then moves up too, the first of them lands one line
|
|
4709
|
+
// ABOVE the hole's own start line, and taking the maximum picks THAT
|
|
4710
|
+
// deterministically over the real previous block. Driven on '…Bravo para.\n' immediately followed by
|
|
4711
|
+
// a block that interrupts a paragraph — an ATX heading, a list, a fenced
|
|
4712
|
+
// code block, an hr — the caret landed on the block BELOW the hole in all
|
|
4713
|
+
// four, and on the code / hr shapes (no focusable surface) it landed nowhere
|
|
4714
|
+
// at all. The answer is not derivable from the hole; it has to be carried.
|
|
4715
|
+
//
|
|
4716
|
+
// With `spanIsFirst`, whatever is first NOW is the block that moved up into
|
|
4717
|
+
// the hole — `blocks[0]`, no search needed. Answers null for a delete that
|
|
4718
|
+
// emptied the document, and for the unreachable "span not found" case, where
|
|
4719
|
+
// guessing a block is worse than restoring no caret at all.
|
|
4720
|
+
function blockLineAfterRemoval(removed) {
|
|
4721
|
+
if (removed.prevLine != null) return removed.prevLine;
|
|
4722
|
+
if (!removed.spanIsFirst) return null;
|
|
4723
|
+
return blocks.length ? blocks[0].startLine : null;
|
|
4724
|
+
}
|
|
4725
|
+
|
|
4726
|
+
async function deleteBlockViaGutterBody(blockEl, anchor) {
|
|
4220
4727
|
// §3.3's membership rules plus the shared preamble (identity capture,
|
|
4221
4728
|
// switchAwayFrom(), the re-resolve after a commit that re-rendered, and the
|
|
4222
4729
|
// no-source-line refusal) — see resolveGutterOperands(). The stake on the
|
|
4223
4730
|
// re-resolve is highest here: an id shift used to make this delete a
|
|
4224
4731
|
// DIFFERENT block's lines, with the ⠿ menu the user pressed pointing at a
|
|
4225
4732
|
// block that survived.
|
|
4226
|
-
const operands = await resolveGutterOperands(blockEl);
|
|
4733
|
+
const operands = await resolveGutterOperands(blockEl, anchor);
|
|
4227
4734
|
if (!operands) return;
|
|
4228
4735
|
const els = operands.els;
|
|
4229
4736
|
const recs = operands.recs;
|
|
4230
4737
|
const shape = spanListKinds(els);
|
|
4231
4738
|
if (shape.anyLi && !shape.allLi) { refuseStructuralListEdit(BATCH_MIXED_MESSAGE); return; }
|
|
4739
|
+
// Fix round 1, item 2: the block immediately ABOVE the span, read out of
|
|
4740
|
+
// the PRE-commit `blocks` and carried to the wrapper. It cannot be
|
|
4741
|
+
// recovered afterwards from the hole's coordinate — see
|
|
4742
|
+
// blockLineAfterRemoval()'s own comment for the measurement. `blocks` is in
|
|
4743
|
+
// document order (blockmap.js hands out a positional ordinal), so the
|
|
4744
|
+
// element before recs[0] is that neighbour.
|
|
4745
|
+
//
|
|
4746
|
+
// Located by OBJECT IDENTITY, and the three outcomes are kept apart on
|
|
4747
|
+
// purpose (fix round 2, item 3 — `at > 0` folded the third into the
|
|
4748
|
+
// second and would have landed the caret on the document's first block
|
|
4749
|
+
// with no signal at all):
|
|
4750
|
+
// at > 0 there is a previous block; its start line is the landing.
|
|
4751
|
+
// at === 0 the span IS the document's first block — `spanIsFirst` tells
|
|
4752
|
+
// blockLineAfterRemoval() to take the moved-up fallback.
|
|
4753
|
+
// at < 0 recs[0] is not in `blocks`. Unreachable as written:
|
|
4754
|
+
// resolveGutterOperands() builds `recs` from this very array
|
|
4755
|
+
// (blockRecOf()'s `blocks.find()` for the single case,
|
|
4756
|
+
// selection.js's membersOf() `blocks.filter()` for the batch —
|
|
4757
|
+
// both hand back the SAME object references), and nothing
|
|
4758
|
+
// awaits between its return and this line, so `blocks` cannot
|
|
4759
|
+
// have been replaced in between. If it ever becomes reachable,
|
|
4760
|
+
// both flags stay false/null and the wrapper simply restores no
|
|
4761
|
+
// caret and re-aims the toolbar — a bar-only degrade, never a
|
|
4762
|
+
// landing on the wrong block.
|
|
4763
|
+
const at = blocks.indexOf(recs[0]);
|
|
4764
|
+
const prevLine = at > 0 ? blocks[at - 1].startLine : null;
|
|
4765
|
+
const spanIsFirst = at === 0;
|
|
4232
4766
|
// Spec §6, "S1 期間的已知危險" item 1: a LIST ITEM's delete is not a line
|
|
4233
4767
|
// splice. S1 is what first put a ⠿ on a li, and the plain range removal
|
|
4234
4768
|
// below corrupts a list three separate ways — see
|
|
4235
4769
|
// deleteListItemsViaGutter() for the measurements and the routing.
|
|
4236
4770
|
if (shape.allLi) {
|
|
4237
|
-
await deleteListItemsViaGutter(els)
|
|
4238
|
-
|
|
4771
|
+
return (await deleteListItemsViaGutter(els))
|
|
4772
|
+
? { prevLine: prevLine, spanIsFirst: spanIsFirst } : undefined;
|
|
4239
4773
|
}
|
|
4240
4774
|
// A contiguous span of non-list blocks is one line range: the separators
|
|
4241
4775
|
// between its members are inside it by construction (members are adjacent
|
|
@@ -4264,7 +4798,9 @@
|
|
|
4264
4798
|
const okRender = await safeRerenderAll(editRange);
|
|
4265
4799
|
if (!okRender) {
|
|
4266
4800
|
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
4801
|
+
return;
|
|
4267
4802
|
}
|
|
4803
|
+
return { prevLine: prevLine, spanIsFirst: spanIsFirst };
|
|
4268
4804
|
}
|
|
4269
4805
|
|
|
4270
4806
|
// Spec §6, "S1 期間的已知危險" item 1 — the ⠿ delete of a LIST ITEM.
|
|
@@ -4295,19 +4831,24 @@
|
|
|
4295
4831
|
// commit the re-serialized survivors over that range with every one of them
|
|
4296
4832
|
// carried over verbatim — nothing here rewrites any survivor's CONTENT, only
|
|
4297
4833
|
// its marker and its leading columns.
|
|
4834
|
+
//
|
|
4835
|
+
// v3.2.1: returns commitListStructure()'s own true/false, and false for each
|
|
4836
|
+
// of its three refusal exits — deleteBlockViaGutter()'s wrapper needs to tell
|
|
4837
|
+
// a refusal (nothing was removed, the block is still where it was) apart from
|
|
4838
|
+
// a completed delete before it goes looking for a neighbour.
|
|
4298
4839
|
async function deleteListItemsViaGutter(liEls) {
|
|
4299
4840
|
// Every list batch is one run's problem — see batchRunOf().
|
|
4300
4841
|
const run = batchRunOf(liEls);
|
|
4301
|
-
if (!run) { refuseStructuralListEdit(BATCH_MULTIRUN_MESSAGE); return; }
|
|
4842
|
+
if (!run) { refuseStructuralListEdit(BATCH_MULTIRUN_MESSAGE); return false; }
|
|
4302
4843
|
// §4.3's run-wide gate, whose input is §3.4 rule 2's scope — which is
|
|
4303
4844
|
// exactly what listRunOf() returns (the outermost run PLUS every
|
|
4304
4845
|
// descendant of its members), so the deeper runs this delete is about to
|
|
4305
4846
|
// re-indent are covered, not just the target's own. Deleting is NOT
|
|
4306
4847
|
// column-only: it removes the target's lines outright, so a multi-line
|
|
4307
4848
|
// target refuses per §4.1 — and in a batch every member is a target.
|
|
4308
|
-
if (!listRunSupportsStructuralEdit(run, liEls)) { refuseStructuralListEdit(); return; }
|
|
4849
|
+
if (!listRunSupportsStructuralEdit(run, liEls)) { refuseStructuralListEdit(); return false; }
|
|
4309
4850
|
const range = runRangeOfBlocks({ lines, blocks, stack }, run);
|
|
4310
|
-
if (!range) return;
|
|
4851
|
+
if (!range) return false;
|
|
4311
4852
|
// §3.4 rule 3's batch anchor: the SMALLEST old indent in the set, never the
|
|
4312
4853
|
// first member's — see spanMinIndent().
|
|
4313
4854
|
const oldIndent = spanMinIndent(liEls);
|
|
@@ -4333,7 +4874,7 @@
|
|
|
4333
4874
|
// this path is the one it makes after the commit — the `!range` bail above
|
|
4334
4875
|
// it cannot fire, `presetRange` is non-null by construction here.
|
|
4335
4876
|
declareCollapse(null);
|
|
4336
|
-
await commitListStructure(survivors, null, false,
|
|
4877
|
+
return await commitListStructure(survivors, null, false,
|
|
4337
4878
|
{ presetRange: range, carryOver: bystanderCarryOver(survivors) });
|
|
4338
4879
|
}
|
|
4339
4880
|
|
|
@@ -4555,6 +5096,10 @@
|
|
|
4555
5096
|
});
|
|
4556
5097
|
await commitListStructure(run, null, false,
|
|
4557
5098
|
{ presetRange: range, carryOver: bystanderCarryOver(run) });
|
|
5099
|
+
// v3.2.1 §Task 5: the caller's `focusLine` — the same post-shift line the
|
|
5100
|
+
// collapse just declared, never the pre-commit startLine (the merge
|
|
5101
|
+
// widening above moves it by exactly `shift`).
|
|
5102
|
+
return recs[0].startLine + shift;
|
|
4558
5103
|
}
|
|
4559
5104
|
|
|
4560
5105
|
// The `data-block-type` a conversion target will carry once it is committed.
|
|
@@ -4703,6 +5248,11 @@
|
|
|
4703
5248
|
if (!(await safeRerenderAll(editRange))) {
|
|
4704
5249
|
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
4705
5250
|
}
|
|
5251
|
+
// v3.2.1 §Task 5: the caller's `focusLine` — the converted piece's own
|
|
5252
|
+
// first emitted line, which is what the collapse above declares. The
|
|
5253
|
+
// pre-commit startLine is wrong here: the split rebuilds the run in three
|
|
5254
|
+
// pieces and §4.3 rule 1's blanks move the converted half.
|
|
5255
|
+
return outStart;
|
|
4706
5256
|
}
|
|
4707
5257
|
|
|
4708
5258
|
// S2 Task 5 — 轉換成 › 項目符號列表 / 編號列表 / 待辦清單 on a block that is
|
|
@@ -4765,6 +5315,10 @@
|
|
|
4765
5315
|
if (!(await safeRerenderAll(editRange))) {
|
|
4766
5316
|
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
4767
5317
|
}
|
|
5318
|
+
// v3.2.1 §Task 5: the caller's `focusLine` — the WIDENED range's first
|
|
5319
|
+
// line, which is where the merged list actually starts. `recs[0].startLine`
|
|
5320
|
+
// would be wrong whenever §4.3 rule 2 ate a separator above the span.
|
|
5321
|
+
return merged.startLine;
|
|
4768
5322
|
}
|
|
4769
5323
|
|
|
4770
5324
|
// The `.ed-li-check` chrome for a li that has just BECOME a task item.
|
|
@@ -4817,12 +5371,18 @@
|
|
|
4817
5371
|
// focus/blur cycle on that same block (click into it, click back out
|
|
4818
5372
|
// — e.g. after Esc-ing the raw editor) re-armed that same stale DOM
|
|
4819
5373
|
// and committed it as if it were live content: the "discarded" edit
|
|
4820
|
-
// resurrected itself into `lines`.
|
|
4821
|
-
//
|
|
4822
|
-
//
|
|
4823
|
-
|
|
4824
|
-
currentBurst
|
|
5374
|
+
// resurrected itself into `lines`.
|
|
5375
|
+
// v3.2.1: 先清 currentBurst 再寫 innerHTML。對 TABLE burst,editEl 是
|
|
5376
|
+
// <table> 而聚焦節點是子代 .ed-wys-cell,所以這個寫入會 detach 聚焦的
|
|
5377
|
+
// cell;Chromium 同步派發 focusout,而 focusout 的 table 分支只要
|
|
5378
|
+
// currentBurst 非 null 就會 resolveBurst() 並提交 —— 這顆按鈕的職責是
|
|
5379
|
+
// 丟棄,實測卻把打的字寫進磁碟。revertTableBurstAndEnd() 早就是這個
|
|
5380
|
+
// 順序,而它的註解正是說明「先清 currentBurst」才是它不需要抑制旗標
|
|
5381
|
+
// 的原因;上一版這裡的註解宣稱在鏡像那份契約,實際把它倒過來了。
|
|
5382
|
+
const dyingBurst = currentBurst;
|
|
4825
5383
|
currentBurst = null;
|
|
5384
|
+
dyingBurst.history.dispose();
|
|
5385
|
+
dyingBurst.editEl.innerHTML = dyingBurst.original;
|
|
4826
5386
|
resetSelToolbarState();
|
|
4827
5387
|
} else {
|
|
4828
5388
|
const ok = await switchAwayFrom();
|
|
@@ -7033,10 +7593,34 @@
|
|
|
7033
7593
|
// (/[ \t]+$/) does not, so it would survive into the committed line.
|
|
7034
7594
|
// Either way the user sees an empty item and must get a bare '-'. Clear
|
|
7035
7595
|
// the surface under the SAME normalisation the branch condition used —
|
|
7036
|
-
// liSurfaceHoldsNothing() —
|
|
7037
|
-
//
|
|
7038
|
-
//
|
|
7039
|
-
//
|
|
7596
|
+
// liSurfaceHoldsNothing() — and only once the outdent above has
|
|
7597
|
+
// actually happened, since a refused press must leave the DOM
|
|
7598
|
+
// byte-identical.
|
|
7599
|
+
//
|
|
7600
|
+
// ⚠ Final wave, I4. The sentence that stood here said this innerHTML
|
|
7601
|
+
// assignment is itself a reason the write must sit inside
|
|
7602
|
+
// mutateListRun()'s suppression span — "an innerHTML assignment on the
|
|
7603
|
+
// focused node triggers the very same Chromium unfocus quirk". MEASURED
|
|
7604
|
+
// false, and its own premise is the counter-example: `textEl` IS the
|
|
7605
|
+
// focused node, and writing its innerHTML replaces only its SUBTREE —
|
|
7606
|
+
// the element itself is never detached. Control experiment, driven in a
|
|
7607
|
+
// real page: focus a `.ed-li-text`, count focusout on it and on
|
|
7608
|
+
// `document` (capture), then assign `''` — 0 focusout, 0 blur,
|
|
7609
|
+
// `document.activeElement` still that same node, node still in the
|
|
7610
|
+
// document.
|
|
7611
|
+
//
|
|
7612
|
+
// What the span IS for is written at `suppressLiFocusout`'s own
|
|
7613
|
+
// declaration: a structural list key can move, split or REMOVE the
|
|
7614
|
+
// focused block, and Chromium's synchronous unfocus then reaches
|
|
7615
|
+
// resolveBurst() with the run still in its pre-mutation shape. This
|
|
7616
|
+
// assignment is inside the span because it is part of the same
|
|
7617
|
+
// mutation, not because it contributes a focusout of its own.
|
|
7618
|
+
//
|
|
7619
|
+
// No claim is made here about WHICH callee needs it, and deliberately
|
|
7620
|
+
// not about outdentListItem(): that function is attribute-only in this
|
|
7621
|
+
// flat model — it calls setBlockIndent() on the block and its subtree
|
|
7622
|
+
// (`data-indent` + a CSS custom property) and re-parents nothing, so it
|
|
7623
|
+
// is not the answer either.
|
|
7040
7624
|
const textEl = liTextEl(li);
|
|
7041
7625
|
if (textEl && liSurfaceHoldsNothing(textEl)) textEl.innerHTML = '';
|
|
7042
7626
|
return true;
|
|
@@ -7789,6 +8373,20 @@
|
|
|
7789
8373
|
// (possibly now-stale, post-delete) column/row it came from.
|
|
7790
8374
|
let teHighlightEls = [];
|
|
7791
8375
|
|
|
8376
|
+
// v3.2.1 fix round 1: same idiom as `suppressTableFocusout`/
|
|
8377
|
+
// `suppressLiFocusout` above — a flag set for the exact synchronous-ish
|
|
8378
|
+
// span of one operation that would otherwise be torn down by a listener
|
|
8379
|
+
// reacting to a side effect THAT OPERATION ITSELF triggers. Here:
|
|
8380
|
+
// runCycleAlign()'s `ensureTableBurstOpen()` can call a cell's `.focus()`,
|
|
8381
|
+
// which can scroll the page for real — and the resulting 'scroll' event
|
|
8382
|
+
// is asynchronous relative to the focus() call (measured: it does not
|
|
8383
|
+
// reliably land before runCycleAlign()'s own code resumes after the
|
|
8384
|
+
// `await`), so a plain "check teMenuKind again afterward" re-open would
|
|
8385
|
+
// race it and sometimes miss. onAnyScroll() checks this flag before
|
|
8386
|
+
// calling hideTableEdgeMenu() so the menu's open state is never torn down
|
|
8387
|
+
// in the first place during that span, whichever tick the event lands on.
|
|
8388
|
+
let suppressEdgeMenuAutoHide = false;
|
|
8389
|
+
|
|
7792
8390
|
function clearEdgeHighlight() {
|
|
7793
8391
|
// classList.remove() leaves a dangling `class=""` behind on any element
|
|
7794
8392
|
// the renderer/armEditables() emitted with NO class of its own — a
|
|
@@ -8012,14 +8610,84 @@
|
|
|
8012
8610
|
const colIndex = teMenuColIndex;
|
|
8013
8611
|
if (!tableEl || colIndex == null) return;
|
|
8014
8612
|
// Final-review Finding 6: same live-table swap as runDeleteColumn() above.
|
|
8015
|
-
|
|
8016
|
-
|
|
8017
|
-
|
|
8018
|
-
|
|
8019
|
-
//
|
|
8020
|
-
//
|
|
8021
|
-
//
|
|
8022
|
-
//
|
|
8613
|
+
// v3.2.1 fix round 1: ensureTableBurstOpen() below can call a cell's
|
|
8614
|
+
// .focus(), which can scroll the page for real — and v3.2.1's onAnyScroll
|
|
8615
|
+
// (this file's module-level scroll listener) calls hideTableEdgeMenu() on
|
|
8616
|
+
// every scroll, which would reset teMenuKind/teMenuTableEl/teMenuColIndex
|
|
8617
|
+
// to null and un-highlight the column, breaking this function's own
|
|
8618
|
+
// "stays open, repeated clicks keep cycling" contract below. Measured
|
|
8619
|
+
// TWICE: (1) a plain "re-check state and re-show if it changed" fix
|
|
8620
|
+
// RACES the resulting 'scroll' event, which is asynchronous relative to
|
|
8621
|
+
// the `.focus()` call and does not reliably land before this function
|
|
8622
|
+
// resumes after the `await` — so suppress the menu's own auto-hide for
|
|
8623
|
+
// this span instead (`suppressEdgeMenuAutoHide`, see its own comment).
|
|
8624
|
+
// (2) clearing that suppression in a plain `finally` right after the
|
|
8625
|
+
// `await` ALSO raced it — the pending 'scroll' event can land AFTER this
|
|
8626
|
+
// function has already resumed and cleared the flag, since a
|
|
8627
|
+
// `focus()`-triggered scroll is not guaranteed to be delivered within
|
|
8628
|
+
// the same microtask turn as the promise chain above. Measured fix:
|
|
8629
|
+
// browsers dispatch a pending scroll before the next
|
|
8630
|
+
// requestAnimationFrame callback runs (it is part of the "update the
|
|
8631
|
+
// rendering" steps that precede rAF callbacks in the same frame), so
|
|
8632
|
+
// holding the flag through one rAF tick lets a pending scroll land AND
|
|
8633
|
+
// be swallowed before this lifts it.
|
|
8634
|
+
let liveTableEl;
|
|
8635
|
+
suppressEdgeMenuAutoHide = true;
|
|
8636
|
+
try {
|
|
8637
|
+
liveTableEl = await ensureTableBurstOpen(tableEl);
|
|
8638
|
+
if (!liveTableEl) { suppressEdgeMenuAutoHide = false; return; }
|
|
8639
|
+
cycleColumnAlign(liveTableEl, colIndex);
|
|
8640
|
+
currentBurst.history.snap('align-col');
|
|
8641
|
+
} catch (e) {
|
|
8642
|
+
suppressEdgeMenuAutoHide = false;
|
|
8643
|
+
throw e;
|
|
8644
|
+
}
|
|
8645
|
+
requestAnimationFrame(() => {
|
|
8646
|
+
suppressEdgeMenuAutoHide = false;
|
|
8647
|
+
// Fix round 2 (Important, caught by the re-reviewer): suppressing
|
|
8648
|
+
// hideTableEdgeMenu() only stops onAnyScroll from closing the menu —
|
|
8649
|
+
// it does NOT stop `ensureTableBurstOpen()`'s own `switchAwayFrom()`
|
|
8650
|
+
// from closing it for an UNRELATED, legitimate reason when a burst was
|
|
8651
|
+
// open on a DIFFERENT block: switchAwayFrom() -> resolveBurst() ->
|
|
8652
|
+
// hideTableEdgeMenu() (unconditional, not suppressed — only
|
|
8653
|
+
// onAnyScroll's call is gated) nulls teMenuKind/teMenuTableEl/
|
|
8654
|
+
// teMenuColIndex and clears the highlight. Without this guard the
|
|
8655
|
+
// unconditional reposition below would then make that CORRECTLY-
|
|
8656
|
+
// closed menu visible again — empty of highlight, and inert (its own
|
|
8657
|
+
// teMenuKind/teMenuColIndex are null, so a second 對齊/刪除欄 click
|
|
8658
|
+
// reads no target and does nothing) — floating over the document
|
|
8659
|
+
// until the next scroll happens to close it for an unrelated reason.
|
|
8660
|
+
// Only reposition when the menu is STILL the one this call opened:
|
|
8661
|
+
// same kind, same column, and the table identity matches EITHER
|
|
8662
|
+
// `tableEl` (showColumnMenu() was called with this — the pre-swap
|
|
8663
|
+
// identity, read from its own definition above — and nothing since
|
|
8664
|
+
// has touched teMenuTableEl) or `liveTableEl` (belt-and-braces, in
|
|
8665
|
+
// case some future path re-opens the menu against the live node).
|
|
8666
|
+
if (teMenuKind !== 'col' || teMenuColIndex !== colIndex ||
|
|
8667
|
+
(teMenuTableEl !== liveTableEl && teMenuTableEl !== tableEl)) return;
|
|
8668
|
+
// Stays open (unlike delete) UNLESS ensureTableBurstOpen()'s
|
|
8669
|
+
// switchAwayFrom() legitimately closed it above (guard just above) —
|
|
8670
|
+
// repeated clicks keep cycling. The column's cells are the SAME nodes
|
|
8671
|
+
// (cycleColumnAlign() only touches the `style` attribute) so the
|
|
8672
|
+
// existing highlight is still valid — nothing to rehighlight.
|
|
8673
|
+
// `.ed-te-menu` is `position: fixed` (viewport-relative) though, so if
|
|
8674
|
+
// the suppressed span above actually scrolled the page, its left/top
|
|
8675
|
+
// are now stale relative to the header cell; reposition on every call
|
|
8676
|
+
// that gets past the identity guard three lines up (same math as
|
|
8677
|
+
// showColumnMenu(), skipping its toggle-closed branch — that branch would
|
|
8678
|
+
// wrongly close a menu that never actually asked to close). Final wave,
|
|
8679
|
+
// M1: "unconditionally" is what this said before that guard was added,
|
|
8680
|
+
// and it stopped being true then — a call whose menu is no longer the one
|
|
8681
|
+
// it opened returns above and repositions nothing. A no-op scroll makes
|
|
8682
|
+
// this a no-op reposition (same numbers back).
|
|
8683
|
+
const headerRow = headerRowOf(liveTableEl);
|
|
8684
|
+
const cell = headerRow ? headerRow.cells[colIndex] : null;
|
|
8685
|
+
if (cell) {
|
|
8686
|
+
const r = cell.getBoundingClientRect();
|
|
8687
|
+
positionTeMenu(r.left, r.top);
|
|
8688
|
+
teEdgeMenu.style.top = (r.top - teEdgeMenu.offsetHeight - TE_MENU_GAP_PX) + 'px';
|
|
8689
|
+
}
|
|
8690
|
+
});
|
|
8023
8691
|
}
|
|
8024
8692
|
|
|
8025
8693
|
// ── Notion-style row/column grip handles ─────────────────────────────
|
|
@@ -9962,6 +10630,56 @@
|
|
|
9962
10630
|
return r;
|
|
9963
10631
|
}
|
|
9964
10632
|
|
|
10633
|
+
// v3.2.1: 在字元空間修剪 Range 的前後空白,再交給 wrapRangeIn()。
|
|
10634
|
+
// 不用 startOffset/endOffset 步進:當邊界容器是【元素節點】時那些 offset
|
|
10635
|
+
// 索引的是【子節點】而非字元,一次遞減會跳過整個文字節點。實測人類拖曳
|
|
10636
|
+
// 選取 105 次都不會產生元素邊界,但編輯器自己的 reselectAndReposition() 會。
|
|
10637
|
+
function trimRangeToText(range) {
|
|
10638
|
+
const parts = [];
|
|
10639
|
+
const take = (n) => {
|
|
10640
|
+
const len = n.data.length;
|
|
10641
|
+
let a, b;
|
|
10642
|
+
try { a = range.comparePoint(n, 0); b = range.comparePoint(n, len); }
|
|
10643
|
+
catch (e) { return; }
|
|
10644
|
+
if (b < 0 || a > 0) return; // 整個節點落在範圍外
|
|
10645
|
+
const from = (n === range.startContainer && n.nodeType === 3) ? range.startOffset : 0;
|
|
10646
|
+
const to = (n === range.endContainer && n.nodeType === 3) ? range.endOffset : len;
|
|
10647
|
+
if (to > from) parts.push({ n: n, from: from, to: to });
|
|
10648
|
+
};
|
|
10649
|
+
const root = range.commonAncestorContainer;
|
|
10650
|
+
const walk = (node) => {
|
|
10651
|
+
if (node.nodeType === 3) { take(node); return; }
|
|
10652
|
+
for (let c = node.firstChild; c; c = c.nextSibling) walk(c);
|
|
10653
|
+
};
|
|
10654
|
+
// v3.2.1 review: root.nodeType === 3 的情況已經由 walk() 自己的
|
|
10655
|
+
// `node.nodeType === 3` 分支正確處理(single-text-node 選取時
|
|
10656
|
+
// startContainer/endContainer 必為同一個 text node,也就是這裡的
|
|
10657
|
+
// root——take() 的 `n === range.startContainer`/`n === range.endContainer`
|
|
10658
|
+
// 判斷照樣成立)。原本 `root.nodeType === 3 ? root : root` 兩支完全相同,
|
|
10659
|
+
// 是無效的死碼,這裡直接呼叫 walk(root);journey 測試(選取整個落在單一
|
|
10660
|
+
// text node 內)已驗證這條路徑產出正確的 from/to。
|
|
10661
|
+
walk(root);
|
|
10662
|
+
if (!parts.length) return false;
|
|
10663
|
+
// 從頭砍掉空白
|
|
10664
|
+
while (parts.length) {
|
|
10665
|
+
const p = parts[0];
|
|
10666
|
+
while (p.from < p.to && /\s/.test(p.n.data[p.from])) p.from++;
|
|
10667
|
+
if (p.from < p.to) break;
|
|
10668
|
+
parts.shift();
|
|
10669
|
+
}
|
|
10670
|
+
// 從尾砍掉空白
|
|
10671
|
+
while (parts.length) {
|
|
10672
|
+
const p = parts[parts.length - 1];
|
|
10673
|
+
while (p.to > p.from && /\s/.test(p.n.data[p.to - 1])) p.to--;
|
|
10674
|
+
if (p.to > p.from) break;
|
|
10675
|
+
parts.pop();
|
|
10676
|
+
}
|
|
10677
|
+
if (!parts.length) return false; // 全空白選取 → 不包裝
|
|
10678
|
+
range.setStart(parts[0].n, parts[0].from);
|
|
10679
|
+
range.setEnd(parts[parts.length - 1].n, parts[parts.length - 1].to);
|
|
10680
|
+
return true;
|
|
10681
|
+
}
|
|
10682
|
+
|
|
9965
10683
|
// Marks of `tag` that the range overlaps (fully or partially) — called
|
|
9966
10684
|
// only AFTER wholeSelectionMark() has already returned null, so any hit
|
|
9967
10685
|
// here is by construction a partial-overlap case (see the toggle-policy
|
|
@@ -10025,26 +10743,190 @@
|
|
|
10025
10743
|
snapBurstIfActive(root, 'mark');
|
|
10026
10744
|
return;
|
|
10027
10745
|
}
|
|
10746
|
+
if (!trimRangeToText(range)) return; // 全空白/collapsed 選取:no-op
|
|
10028
10747
|
reselectAndReposition(wrapRangeIn(range, tag));
|
|
10029
10748
|
snapBurstIfActive(root, 'mark');
|
|
10030
10749
|
}
|
|
10031
10750
|
|
|
10751
|
+
// Resolves once a `focusout` has been dispatched at `el` AND that dispatch
|
|
10752
|
+
// has fully finished, or after `ms` milliseconds if no focusout arrives at
|
|
10753
|
+
// all — whichever comes first.
|
|
10754
|
+
//
|
|
10755
|
+
// Two things about it are load-bearing and both were measured on the 🔗
|
|
10756
|
+
// gesture:
|
|
10757
|
+
//
|
|
10758
|
+
// * The wait is needed because the blur a native modal causes is delivered
|
|
10759
|
+
// on the browser's own schedule, NOT as a task queued by the code that
|
|
10760
|
+
// opened the modal: window.prompt() returned at t=27ms and the focusout
|
|
10761
|
+
// landed at t=28ms, i.e. after the caller had already returned. So a plain
|
|
10762
|
+
// setTimeout(0) is not a substitute for listening.
|
|
10763
|
+
// * The resolve is handed to a TIMER rather than called from the listener.
|
|
10764
|
+
// A listener's own resolve() only queues a microtask, and the HTML spec
|
|
10765
|
+
// performs a microtask checkpoint after EACH listener callback of a
|
|
10766
|
+
// browser-initiated dispatch — so an awaiter resumed from there runs
|
|
10767
|
+
// BEFORE the document-level focusout delegator further along the same
|
|
10768
|
+
// propagation path, and therefore before anything that delegator starts
|
|
10769
|
+
// exists to be waited on. Measured exactly that way: resolving from the
|
|
10770
|
+
// listener left `switching` still null at the awaiter, and the caret was
|
|
10771
|
+
// lost to the render that followed. A timer moves the resume to the next
|
|
10772
|
+
// task, after the whole dispatch.
|
|
10773
|
+
function nextFocusout(el, ms) {
|
|
10774
|
+
return new Promise((resolve) => {
|
|
10775
|
+
let deadline = null;
|
|
10776
|
+
const finish = () => {
|
|
10777
|
+
el.removeEventListener('focusout', onFocusout);
|
|
10778
|
+
if (deadline !== null) { clearTimeout(deadline); deadline = null; }
|
|
10779
|
+
resolve();
|
|
10780
|
+
};
|
|
10781
|
+
const onFocusout = () => { setTimeout(finish, 0); };
|
|
10782
|
+
el.addEventListener('focusout', onFocusout);
|
|
10783
|
+
deadline = setTimeout(finish, ms);
|
|
10784
|
+
});
|
|
10785
|
+
}
|
|
10786
|
+
|
|
10787
|
+
// v3.2.1: THIN WRAPPER over the toggle body below, same shape as
|
|
10788
|
+
// convertBlockViaMenu() — but the restoration cannot sit in a plain
|
|
10789
|
+
// synchronous finally here, and that is the whole difference. MEASURED, with
|
|
10790
|
+
// an in-page focus/mutation trace on the 🔗 button:
|
|
10791
|
+
//
|
|
10792
|
+
// t=21ms window.prompt() entered t=27ms prompt() returned
|
|
10793
|
+
// t=28ms focusout on the armed surface (the modal's own blur, delivered
|
|
10794
|
+
// after the body had already finished its DOM surgery)
|
|
10795
|
+
// t=31ms focusin — the browser hands focus straight back
|
|
10796
|
+
// t=52ms a SECOND focusout, this one from applyFullRender() (captured
|
|
10797
|
+
// stack: applyFullRender <- applyRenderResult <- rerenderAll)
|
|
10798
|
+
// t=66ms document.activeElement === BODY, toolbar down to 4 buttons
|
|
10799
|
+
//
|
|
10800
|
+
// So the thing that costs the user their foothold is not the modal: focus
|
|
10801
|
+
// comes back on its own. It is that the document's focusout delegator read
|
|
10802
|
+
// the modal's blur as "the user left this surface" and ran switchAwayFrom()
|
|
10803
|
+
// -> resolveBurst() -> commitEdit() -> rerenderAll(), and that render
|
|
10804
|
+
// replaced the whole .content subtree the restored focus was sitting in.
|
|
10805
|
+
// The other four inline-format buttons (toolbar-model.js's `group: 'inline'`
|
|
10806
|
+
// is bold / italic / strike / inline-code / link — five, of which link is
|
|
10807
|
+
// this one) never hit this, because none of them opens a modal and so none
|
|
10808
|
+
// of them ever blurs.
|
|
10809
|
+
//
|
|
10810
|
+
// The two awaits below are therefore each waiting for one specific thing:
|
|
10811
|
+
// nextFocusout() for that blur to be dispatched at all, and `switching` for
|
|
10812
|
+
// the commit + render it started to finish. Only then is there a live block
|
|
10813
|
+
// to aim at.
|
|
10814
|
+
async function applyLinkToggle() {
|
|
10815
|
+
const root = selToolbarEditEl;
|
|
10816
|
+
const sel = typeof window.getSelection === 'function' ? window.getSelection() : null;
|
|
10817
|
+
// hasFormattableSelection() is the body's own four guards spelled once (see
|
|
10818
|
+
// its comment). When it is false the body returns before it can reach
|
|
10819
|
+
// window.prompt(), so there is no modal, no blur, and nothing to wait for.
|
|
10820
|
+
// Waiting anyway would stall this call for the whole timeout and then move
|
|
10821
|
+
// the caret to the end of a block the user never edited.
|
|
10822
|
+
//
|
|
10823
|
+
// ⚠ Final wave, I3. `willPrompt` is a NECESSARY condition for a modal, not
|
|
10824
|
+
// a sufficient one, and the sentence that used to stand at the restore
|
|
10825
|
+
// said otherwise. Those four guards do match the body's four, but the body
|
|
10826
|
+
// has a FIFTH pre-prompt exit they know nothing about: the
|
|
10827
|
+
// `overlapping.length > 0` branch, where a selection that only PARTIALLY
|
|
10828
|
+
// overlaps an existing link unwraps it and returns without ever calling
|
|
10829
|
+
// window.prompt(). MEASURED on ddac6ce — selecting `lpha lin` across
|
|
10830
|
+
// `Alpha <a>linked</a> …` and clicking 🔗 gave `dialogs: 0` while
|
|
10831
|
+
// `willPrompt` was true. Behaviour was still correct there, but only
|
|
10832
|
+
// because the `stillHeld` gate below happened to catch it; the guarantee
|
|
10833
|
+
// the comment claimed did not exist. So the body now REPORTS whether it
|
|
10834
|
+
// prompted and the wait is gated on that report rather than on the
|
|
10835
|
+
// prediction. `blurred` still has to be ARMED before the body runs (the
|
|
10836
|
+
// blur it waits for is caused from inside it) — an armed-but-unused
|
|
10837
|
+
// promise settles on its own 400ms timeout, which removes its own listener
|
|
10838
|
+
// and resolves nobody.
|
|
10839
|
+
const willPrompt = hasFormattableSelection(sel);
|
|
10840
|
+
// Captured BEFORE the body: `selToolbarEditEl` is one of the things the
|
|
10841
|
+
// render nulls. The LINE survives that render — resolveBurst() commits
|
|
10842
|
+
// through commitEdit(state, burst.blockId, newText), an in-place replace of
|
|
10843
|
+
// this block's own line range, and ops.shiftBlocks() only shifts blocks
|
|
10844
|
+
// whose id is GREATER than the edited one, so nothing can move this block's
|
|
10845
|
+
// own start line.
|
|
10846
|
+
const rec = willPrompt && root && root.closest
|
|
10847
|
+
? blockRecOf(root.closest('.ed-block')) : null;
|
|
10848
|
+
const line = rec ? rec.startLine : null;
|
|
10849
|
+
// Armed before the body runs, because the blur it is waiting for is caused
|
|
10850
|
+
// by a call inside the body. The timeout is the fallback for a browser that
|
|
10851
|
+
// does not blur on a modal at all; its cost when it fires is one caret
|
|
10852
|
+
// moved to the end of the block the link was just written into.
|
|
10853
|
+
const blurred = willPrompt && root ? nextFocusout(root, 400) : null;
|
|
10854
|
+
let prompted = false;
|
|
10855
|
+
try {
|
|
10856
|
+
prompted = applyLinkToggleBody() === true;
|
|
10857
|
+
} finally {
|
|
10858
|
+
if (blurred && prompted) {
|
|
10859
|
+
await blurred;
|
|
10860
|
+
if (switching) await switching;
|
|
10861
|
+
// Fix round 1, item 1: reaching here means the modal DID open (the
|
|
10862
|
+
// body said so), not that the user confirmed it. On Esc / cancel the body does no DOM
|
|
10863
|
+
// surgery, the burst goes out through endBurstWithoutResolve(), there
|
|
10864
|
+
// is no commit and no render — and the focus the modal took comes back
|
|
10865
|
+
// by itself (the t=31ms focusin in the trace above). Restoring on top
|
|
10866
|
+
// of that collapses the user's selection to a caret at the block's end.
|
|
10867
|
+
// Driven, 🔗 then Esc: with this gate the selection is still 'Alpha'
|
|
10868
|
+
// (non-collapsed, 20 buttons); without it, '' and collapsed.
|
|
10869
|
+
//
|
|
10870
|
+
// So the restore only fires when the foothold is actually GONE. The
|
|
10871
|
+
// accept path is unaffected — the render has just run and
|
|
10872
|
+
// activeElement is BODY there — and a 400ms timeout misfire becomes
|
|
10873
|
+
// harmless for the same reason.
|
|
10874
|
+
const held = document.activeElement;
|
|
10875
|
+
const stillHeld = held && held.closest &&
|
|
10876
|
+
held.closest('.ed-wys-armed, .ed-wys-cell, .ed-raw');
|
|
10877
|
+
// 有 blockSelection 站著時什麼都不做 —— 與 convertBlockViaMenu() 同一個
|
|
10878
|
+
// 判準、同一個理由:強行還原 caret 會做出「底色仍說已選取、Delete 卻是
|
|
10879
|
+
// 死鍵」的混種狀態。
|
|
10880
|
+
if (!stillHeld && !blockSelection && line != null) {
|
|
10881
|
+
focusBlockAtLine(line, true); // caretToEnd
|
|
10882
|
+
}
|
|
10883
|
+
// ⚠ C1, second occurrence of the same root cause. The re-aim used to
|
|
10884
|
+
// sit INSIDE the `!stillHeld` branch, so a call that kept its foothold
|
|
10885
|
+
// restored nothing at all — and on the PATCH render path (every commit
|
|
10886
|
+
// after a page load's first) applyPatch() hands focus back to the new
|
|
10887
|
+
// surface and only THEN runs resetToolbarBlock(), so "foothold held"
|
|
10888
|
+
// and "bar collapsed to 4" are the ordinary combination, not a
|
|
10889
|
+
// contradiction. Measured, 🔗 accept: unprimed P.ed-wys-armed/15,
|
|
10890
|
+
// primed P.ed-wys-armed/4. The caret restore stays gated (fix round
|
|
10891
|
+
// 1's Esc/cancel finding); only the bar comes out.
|
|
10892
|
+
if (!blockSelection && line != null) {
|
|
10893
|
+
// 就【這個還原目前所攜帶的座標】(一個 block 的起始行)而言,TABLE
|
|
10894
|
+
// 儲存格是還原不回 caret 的形狀:`root` 是 CELL,但 `line` 只能指到
|
|
10895
|
+
// 整張表這個 block,而 blockContentEl() 對 table 回傳
|
|
10896
|
+
// firstElementChild = <table> 本身,它沒有 tabindex、focus() 是
|
|
10897
|
+
// no-op。實測結果因此是 journey 的 bar-only 形狀(V2d 釘住):連結
|
|
10898
|
+
// 正確寫進磁碟、工具列 15 顆仍瞄著那張表,但 activeElement 是 BODY。
|
|
10899
|
+
// 這不是死路 —— 儲存格的 (row, col) 在 re-render 之間是穩定的,帶著
|
|
10900
|
+
// 它就能還原;只是本版沒有帶。reaim 是目前這個形狀下唯一還拿得回來
|
|
10901
|
+
// 的東西 —— 沒有它工具列會塌成 4 顆。
|
|
10902
|
+
reaimToolbarBlockAtLine(line);
|
|
10903
|
+
}
|
|
10904
|
+
}
|
|
10905
|
+
}
|
|
10906
|
+
}
|
|
10907
|
+
|
|
10032
10908
|
// Link is its own entry point (not applyMarkToggle) because "unwrap" here
|
|
10033
10909
|
// means "prompt to edit or clear the URL", and "wrap" means "prompt for a
|
|
10034
10910
|
// URL first" — both need window.prompt() before any DOM surgery happens.
|
|
10035
|
-
|
|
10911
|
+
//
|
|
10912
|
+
// Final wave, I3: returns TRUE iff this call actually reached a
|
|
10913
|
+
// window.prompt(), and false on every exit that did not — the four guards at
|
|
10914
|
+
// the top AND the partial-overlap unwrap in the middle, which is the one the
|
|
10915
|
+
// wrapper's `willPrompt` prediction cannot see. The wrapper waits for the
|
|
10916
|
+
// modal's blur only on a true.
|
|
10917
|
+
function applyLinkToggleBody() {
|
|
10036
10918
|
const root = selToolbarEditEl;
|
|
10037
|
-
if (!root) return;
|
|
10919
|
+
if (!root) return false;
|
|
10038
10920
|
const sel = window.getSelection();
|
|
10039
|
-
if (!sel.rangeCount) return;
|
|
10921
|
+
if (!sel.rangeCount) return false;
|
|
10040
10922
|
const range = sel.getRangeAt(0);
|
|
10041
|
-
if (range.collapsed) return;
|
|
10042
|
-
if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) return;
|
|
10923
|
+
if (range.collapsed) return false;
|
|
10924
|
+
if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) return false;
|
|
10043
10925
|
|
|
10044
10926
|
const whole = wholeSelectionMark(range, 'A', root);
|
|
10045
10927
|
if (whole) {
|
|
10046
10928
|
const url = window.prompt('連結網址(留空以移除連結):', whole.getAttribute('href') || '');
|
|
10047
|
-
if (url === null) return; // cancelled — leave the link untouched
|
|
10929
|
+
if (url === null) return true; // cancelled — leave the link untouched
|
|
10048
10930
|
if (url.trim() === '') {
|
|
10049
10931
|
reselectAndReposition(unwrapElement(whole));
|
|
10050
10932
|
} else {
|
|
@@ -10052,7 +10934,7 @@
|
|
|
10052
10934
|
reselectAndReposition(range);
|
|
10053
10935
|
}
|
|
10054
10936
|
snapBurstIfActive(root, 'mark');
|
|
10055
|
-
return;
|
|
10937
|
+
return true;
|
|
10056
10938
|
}
|
|
10057
10939
|
const overlapping = overlappingMarks(range, 'A', root);
|
|
10058
10940
|
if (overlapping.length > 0) {
|
|
@@ -10060,10 +10942,10 @@
|
|
|
10060
10942
|
overlappingMarks(extended, 'A', root).forEach((m) => unwrapElement(m));
|
|
10061
10943
|
reselectAndReposition(extended);
|
|
10062
10944
|
snapBurstIfActive(root, 'mark');
|
|
10063
|
-
return;
|
|
10945
|
+
return false; // unwrapped in place — no modal ever opened
|
|
10064
10946
|
}
|
|
10065
10947
|
const url = window.prompt('連結網址:', 'https://');
|
|
10066
|
-
if (url === null || url.trim() === '') return; // cancelled or empty — no-op
|
|
10948
|
+
if (url === null || url.trim() === '') return true; // cancelled or empty — no-op
|
|
10067
10949
|
const el = document.createElement('a');
|
|
10068
10950
|
el.setAttribute('href', url.trim());
|
|
10069
10951
|
el.appendChild(range.extractContents());
|
|
@@ -10072,6 +10954,7 @@
|
|
|
10072
10954
|
r.selectNodeContents(el);
|
|
10073
10955
|
reselectAndReposition(r);
|
|
10074
10956
|
snapBurstIfActive(root, 'mark');
|
|
10957
|
+
return true;
|
|
10075
10958
|
}
|
|
10076
10959
|
|
|
10077
10960
|
function buildSelToolbar() {
|
|
@@ -10114,7 +10997,14 @@
|
|
|
10114
10997
|
// `position: fixed`.
|
|
10115
10998
|
function positionSelToolbar(range) {
|
|
10116
10999
|
const rect = range.getBoundingClientRect();
|
|
10117
|
-
|
|
11000
|
+
// v3.2.1: 原本的零矩形檢查是死碼 —— 實測捲出視野的 Range 仍保有
|
|
11001
|
+
// 151x17 的矩形,永遠到不了這一支。改成真正的視窗外檢查。
|
|
11002
|
+
const topInset = parseFloat(getComputedStyle(document.documentElement)
|
|
11003
|
+
.getPropertyValue('--ed-toolbar-h')) || 0;
|
|
11004
|
+
if (rect.bottom <= topInset || rect.top >= window.innerHeight ||
|
|
11005
|
+
rect.right <= 0 || rect.left >= window.innerWidth) {
|
|
11006
|
+
hideSelToolbar(); return;
|
|
11007
|
+
}
|
|
10118
11008
|
if (!selToolbar.parentNode) document.body.appendChild(selToolbar);
|
|
10119
11009
|
const gap = 8, margin = 4;
|
|
10120
11010
|
const tbRect = selToolbar.getBoundingClientRect();
|
|
@@ -10471,7 +11361,8 @@
|
|
|
10471
11361
|
let toolbarMenuEl = null; // the H▾ dropdown panel, or null when closed
|
|
10472
11362
|
let toolbarBlockEl = null;
|
|
10473
11363
|
let toolbarStateSig = '';
|
|
10474
|
-
// 追加 4's
|
|
11364
|
+
// 追加 4's document mode: 'edit' | 'source'. v3.2.1 removed the third
|
|
11365
|
+
// state ('preview'); docsource.js's MODES comment records why.
|
|
10475
11366
|
// docsource.js owns the cycle order and the lines[]<->string conversion.
|
|
10476
11367
|
let docMode = 'edit';
|
|
10477
11368
|
let sourceTextarea = null;
|
|
@@ -10515,10 +11406,29 @@
|
|
|
10515
11406
|
function resetToolbarBlock() {
|
|
10516
11407
|
toolbarBlockEl = null;
|
|
10517
11408
|
closeToolbarMenu();
|
|
10518
|
-
|
|
10519
|
-
|
|
10520
|
-
|
|
10521
|
-
|
|
11409
|
+
updateToolbar();
|
|
11410
|
+
}
|
|
11411
|
+
|
|
11412
|
+
// v3.2.1: point the bar back at the block a structural op just produced.
|
|
11413
|
+
//
|
|
11414
|
+
// rerenderAll()'s teardown calls resetToolbarBlock() (toolbarBlockEl = null,
|
|
11415
|
+
// bar repainted in its no-block state: 4 enabled buttons), and nothing
|
|
11416
|
+
// re-points it unless a focusin/click lands on a block. That is fine when
|
|
11417
|
+
// the op ends with focusBlockAtLine() — the surface's focus event runs
|
|
11418
|
+
// wireToolbarTracking()'s focusin handler, which does exactly this. It is
|
|
11419
|
+
// NOT fine for a target with no focusable surface (quote / code / hr), where
|
|
11420
|
+
// focusBlockAtLine() silently no-ops and the bar would stay collapsed on a
|
|
11421
|
+
// block that is right there on screen. updateToolbar() is what repaints;
|
|
11422
|
+
// resetToolbarBlock() is idempotent, so re-pointing after it is safe.
|
|
11423
|
+
// Resolves nothing and touches nothing when `line` is null or the block is
|
|
11424
|
+
// gone (a refusal, a rolled-back render).
|
|
11425
|
+
function reaimToolbarBlockAtLine(line) {
|
|
11426
|
+
if (line == null) return;
|
|
11427
|
+
const target = blocks.find((b) => b.startLine === line);
|
|
11428
|
+
if (!target) return;
|
|
11429
|
+
const blockEl = document.querySelector('.ed-block[data-block-id="' + target.id + '"]');
|
|
11430
|
+
if (!blockEl) return;
|
|
11431
|
+
toolbarBlockEl = blockEl;
|
|
10522
11432
|
updateToolbar();
|
|
10523
11433
|
}
|
|
10524
11434
|
|
|
@@ -10591,9 +11501,17 @@
|
|
|
10591
11501
|
if (TOOLBAR_TOGGLE_IDS.has(id)) {
|
|
10592
11502
|
btn.setAttribute('aria-pressed', st.active ? 'true' : 'false');
|
|
10593
11503
|
}
|
|
10594
|
-
//
|
|
10595
|
-
// keeps the glyph it was built with.
|
|
11504
|
+
// `headings` (H / H1..H6) and, since v3.2.1, the mode button carry a
|
|
11505
|
+
// derived label; every other button keeps the glyph it was built with.
|
|
10596
11506
|
if (typeof st.label === 'string') btn.textContent = st.label;
|
|
11507
|
+
// v3.2.1: a per-mode title CANNOT be baked in buildToolbar() — the mode
|
|
11508
|
+
// button's title names the NEXT state, so it has to be rewritten every
|
|
11509
|
+
// time the mode changes. It rides the same diffed loop as everything
|
|
11510
|
+
// else: `title` is part of the state object, therefore part of `sig`.
|
|
11511
|
+
if (typeof st.title === 'string') {
|
|
11512
|
+
btn.title = st.title;
|
|
11513
|
+
btn.setAttribute('aria-label', st.title);
|
|
11514
|
+
}
|
|
10597
11515
|
});
|
|
10598
11516
|
}
|
|
10599
11517
|
|
|
@@ -10638,11 +11556,53 @@
|
|
|
10638
11556
|
bar.appendChild(btn);
|
|
10639
11557
|
btns[def.id] = btn;
|
|
10640
11558
|
});
|
|
11559
|
+
// Fix round 1, item 3: a click on a DISABLED toolbar button used to take
|
|
11560
|
+
// the caret with it — the same gesture and the same symptom this release
|
|
11561
|
+
// exists for. MEASURED, and the two halves matter separately:
|
|
11562
|
+
//
|
|
11563
|
+
// * `mousedown` and `click` are NOT dispatched at all for a disabled
|
|
11564
|
+
// <button> — 0 of each at a capture-phase listener on `document`, for
|
|
11565
|
+
// the whole propagation path, so the per-button mousedown
|
|
11566
|
+
// preventDefault() a few lines above cannot run and neither could one
|
|
11567
|
+
// on this container.
|
|
11568
|
+
// * `pointerdown` IS dispatched (1 at that same document listener) and is
|
|
11569
|
+
// cancellable, and cancelling it suppresses the compatibility mousedown
|
|
11570
|
+
// together with its default action — which is the focus move.
|
|
11571
|
+
//
|
|
11572
|
+
// Driven, caret in a paragraph, clicking the disabled 🅑: without this
|
|
11573
|
+
// handler activeElement was BODY in both the untouched-burst state (bar
|
|
11574
|
+
// still 15) and the typed-in state (bar down to 4, because that focusout
|
|
11575
|
+
// ran a full commit + render); with it, P.ed-wys-armed / 15 in both.
|
|
11576
|
+
//
|
|
11577
|
+
// Scope: capture phase on the bar, and preventDefault() ONLY when the
|
|
11578
|
+
// press resolves to a disabled button. Never stopPropagation() — the
|
|
11579
|
+
// document-level pointerdown delegator (table grips, ⠿ block drag, block
|
|
11580
|
+
// selection) still sees the event, and finds nothing to arm, exactly as
|
|
11581
|
+
// before. An ENABLED button, a separator, the status slot and the bar's
|
|
11582
|
+
// own background are all untouched. The H▾ dropdown is NOT inside this
|
|
11583
|
+
// subtree (toggleToolbarHeadingMenu() appends it to document.body), so it
|
|
11584
|
+
// is out of reach here and keeps its own per-item mousedown handler.
|
|
11585
|
+
bar.addEventListener('pointerdown', (e) => {
|
|
11586
|
+
const pressed = e.target && e.target.closest ? e.target.closest('button') : null;
|
|
11587
|
+
if (pressed && pressed.disabled) e.preventDefault();
|
|
11588
|
+
}, true);
|
|
10641
11589
|
toolbarBtns = btns;
|
|
10642
11590
|
// v3.1.0 的 spec 承諾「元素存在但無內容,§3 上線時填入、不需要重排版面」,
|
|
10643
|
-
//
|
|
11591
|
+
// 但元素從未被建立。這裡把承諾兌現:佔好寬度。內容不再是空的 ——
|
|
11592
|
+
// paintModeStatus() 會在 mountToolbar() 掛上之後、以及每次 setDocMode()
|
|
11593
|
+
// 切換時寫入當前模式名稱。
|
|
10644
11594
|
const statusEl = document.createElement('span');
|
|
10645
11595
|
statusEl.className = 'ed-toolbar-status';
|
|
11596
|
+
// Final wave, M7: the only announcement a mode switch ever gets. Every
|
|
11597
|
+
// toolbar button is `tabindex="-1"` (see buildToolbar()'s own note), so a
|
|
11598
|
+
// screen reader is never focused on the mode button when it is pressed and
|
|
11599
|
+
// hears nothing about the state that just changed; the slot's text is the
|
|
11600
|
+
// only thing that says so. `role="status"` carries an implicit
|
|
11601
|
+
// aria-live="polite", and the explicit attribute is written alongside it
|
|
11602
|
+
// because that implication is the part assistive tech has historically
|
|
11603
|
+
// been inconsistent about — belt and braces, one line, no layout cost.
|
|
11604
|
+
statusEl.setAttribute('role', 'status');
|
|
11605
|
+
statusEl.setAttribute('aria-live', 'polite');
|
|
10646
11606
|
bar.appendChild(statusEl);
|
|
10647
11607
|
return bar;
|
|
10648
11608
|
}
|
|
@@ -10960,18 +11920,90 @@
|
|
|
10960
11920
|
await insertBlockBelow(target, 'paragraph', { lines: newLines });
|
|
10961
11921
|
}
|
|
10962
11922
|
|
|
10963
|
-
|
|
11923
|
+
// v3.2.1: 「插入圖片竟然無法在我當前的位置後插入,每次都跑到最後」. Two
|
|
11924
|
+
// separate mechanisms put the image at EOF, and BOTH were measured — the
|
|
11925
|
+
// second one is the reason this function is longer than it looks like it
|
|
11926
|
+
// needs to be.
|
|
11927
|
+
//
|
|
11928
|
+
// (a) `input.click()` below is a SYNTHETIC click. It bubbles to the
|
|
11929
|
+
// document delegator, which reads it as a click landing outside every
|
|
11930
|
+
// block → switchAwayFrom() → commit + re-render → the anchor node
|
|
11931
|
+
// captured a moment earlier is detached → insertImages() falls back to
|
|
11932
|
+
// lastBlockEl() and the image lands at the end of the file.
|
|
11933
|
+
// Deterministic whenever the block the user is in is dirty, which is
|
|
11934
|
+
// exactly the reported gesture.
|
|
11935
|
+
//
|
|
11936
|
+
// (b) stopPropagation() on that click is NECESSARY BUT NOT SUFFICIENT. It
|
|
11937
|
+
// fixes the reported gesture, yet the anchor stayed a raw DOM node
|
|
11938
|
+
// held across the file dialog: a FULL render landing between the click
|
|
11939
|
+
// and the `change` event detaches it and the image is back at EOF.
|
|
11940
|
+
// The v3.2.0 patch path is not the exposure — two forced mid-dialog
|
|
11941
|
+
// renders (a commit in another block, a ⠿ 刪除 of another block) were
|
|
11942
|
+
// measured to patch and leave the anchor attached — but a full render
|
|
11943
|
+
// is one patchmap() miss away (it returns null whenever there is no
|
|
11944
|
+
// usable editRange), and a real OS file dialog stays open for as long
|
|
11945
|
+
// as the user takes. So the window is small, not hypothetical.
|
|
11946
|
+
//
|
|
11947
|
+
// Hence both halves: stop the synthetic click from reading as an
|
|
11948
|
+
// outside-click, AND resolve the open session BEFORE the picker opens while
|
|
11949
|
+
// carrying the anchor as a stable IDENTITY that is re-resolved when the
|
|
11950
|
+
// files come back, instead of as a node reference. The identity triple is
|
|
11951
|
+
// insertBlockBelow()'s (captureBlockIdentity / ownsOpenSession /
|
|
11952
|
+
// reresolveBlockEl + reresolveBlockElAfterSelfCommit) — that function solved
|
|
11953
|
+
// this same problem for its own anchor and the image path simply never
|
|
11954
|
+
// adopted it. Do not "simplify" the re-resolution away: it is the half that
|
|
11955
|
+
// survives the dialog.
|
|
11956
|
+
//
|
|
11957
|
+
// ⚠ ORDERING: `await switchAwayFrom()` runs BEFORE input.click(). Chrome
|
|
11958
|
+
// gates the file picker on TRANSIENT user activation, which is time-based
|
|
11959
|
+
// (~5 s) and is NOT spent by awaiting a promise, so the picker still opens.
|
|
11960
|
+
// The journey scenario presses 🖼 on a DIRTY block, so a real commit — an
|
|
11961
|
+
// /api/render round trip — runs inside that await; if the activation were
|
|
11962
|
+
// spent, waitForFileChooser() there would time out. A commit slower than
|
|
11963
|
+
// the activation window would be the one way this ordering could bite.
|
|
11964
|
+
async function pickAndInsertImage() {
|
|
11965
|
+
// Captured BEFORE switchAwayFrom(), because that is what can renumber the
|
|
11966
|
+
// ids and rewrite the source — see captureBlockIdentity()'s comment.
|
|
11967
|
+
const blockEl = liveToolbarBlock() || lastBlockEl();
|
|
11968
|
+
const identity = captureBlockIdentity(blockEl);
|
|
11969
|
+
// The commit inside switchAwayFrom() can be a rewrite of THIS block's own
|
|
11970
|
+
// source, in which case reresolveBlockEl()'s fingerprint is guaranteed to
|
|
11971
|
+
// miss because WE are the reason the source changed; the narrowed
|
|
11972
|
+
// re-resolve is used only then. Same reasoning as insertBlockBelow().
|
|
11973
|
+
const selfSession = ownsOpenSession(blockEl);
|
|
11974
|
+
if (!(await switchAwayFrom())) return;
|
|
11975
|
+
const anchorEl = (blockEl && document.body.contains(blockEl))
|
|
11976
|
+
? blockEl
|
|
11977
|
+
: (reresolveBlockEl(identity) ||
|
|
11978
|
+
(selfSession ? reresolveBlockElAfterSelfCommit(identity) : null));
|
|
11979
|
+
if (!anchorEl) { showBanner(TOOLBAR_NO_ANCHOR_MESSAGE, null, null); return; }
|
|
11980
|
+
// Re-captured AFTER the commit: `identity` above holds the PRE-commit
|
|
11981
|
+
// fingerprint, which the commit we just awaited may itself have
|
|
11982
|
+
// invalidated. What has to survive the dialog is the anchor as it stands
|
|
11983
|
+
// now.
|
|
11984
|
+
const dialogIdentity = captureBlockIdentity(anchorEl);
|
|
10964
11985
|
const input = document.createElement('input');
|
|
10965
11986
|
input.type = 'file';
|
|
10966
11987
|
input.accept = 'image/png,image/jpeg,image/gif,image/webp';
|
|
10967
11988
|
input.multiple = true;
|
|
10968
11989
|
input.hidden = true;
|
|
10969
|
-
|
|
11990
|
+
// (a) above.
|
|
11991
|
+
input.addEventListener('click', (e) => e.stopPropagation());
|
|
10970
11992
|
document.body.appendChild(input);
|
|
10971
11993
|
input.addEventListener('change', () => {
|
|
10972
11994
|
const files = input.files ? Array.prototype.slice.call(input.files) : [];
|
|
10973
11995
|
input.remove();
|
|
10974
|
-
if (files.length)
|
|
11996
|
+
if (!files.length) return;
|
|
11997
|
+
// (b) above. A render that landed while the dialog was open detached
|
|
11998
|
+
// `anchorEl`; the identity still names the block the user pointed at.
|
|
11999
|
+
// If even that misses, the block the user aimed at no longer exists as
|
|
12000
|
+
// it was — refuse (and skip the upload, so no orphan lands in the
|
|
12001
|
+
// user's assets/) rather than silently appending at EOF, which is the
|
|
12002
|
+
// very symptom being fixed.
|
|
12003
|
+
const live = document.body.contains(anchorEl)
|
|
12004
|
+
? anchorEl : reresolveBlockEl(dialogIdentity);
|
|
12005
|
+
if (!live) { showBanner(TOOLBAR_NO_ANCHOR_MESSAGE, null, null); return; }
|
|
12006
|
+
insertImages(files, live);
|
|
10975
12007
|
});
|
|
10976
12008
|
input.click();
|
|
10977
12009
|
}
|
|
@@ -10998,9 +12030,10 @@
|
|
|
10998
12030
|
// assets/x.png orphaned on disk. No error, no visible effect, and a stray
|
|
10999
12031
|
// binary left in what is usually a git working tree.
|
|
11000
12032
|
//
|
|
11001
|
-
//
|
|
11002
|
-
// the
|
|
11003
|
-
//
|
|
12033
|
+
// v3.2.1: `!== 'edit'` rather than `=== 'source'` on purpose. Source is
|
|
12034
|
+
// the only other mode today, but the gate's reason is "the content the
|
|
12035
|
+
// user is looking at is not the content a drop would write into" — a
|
|
12036
|
+
// future third mode inherits the refusal rather than the hole.
|
|
11004
12037
|
document.addEventListener('dragover', (e) => {
|
|
11005
12038
|
if (docMode !== 'edit') return;
|
|
11006
12039
|
if (!dragCarriesFiles(e)) return;
|
|
@@ -11117,12 +12150,9 @@
|
|
|
11117
12150
|
}
|
|
11118
12151
|
|
|
11119
12152
|
// ── 追加 4: the whole-document source escape hatch ─────────────────────
|
|
11120
|
-
|
|
11121
|
-
|
|
11122
|
-
|
|
11123
|
-
surfaces[i].setAttribute('contenteditable', editable ? 'true' : 'false');
|
|
11124
|
-
}
|
|
11125
|
-
}
|
|
12153
|
+
// v3.2.1: applyPreviewEditability() lived here and was preview mode's ONLY
|
|
12154
|
+
// effect. Preview is gone (docsource.js's MODES comment carries the
|
|
12155
|
+
// measurement), and with it the last caller of this function.
|
|
11126
12156
|
|
|
11127
12157
|
function enterSourceMode() {
|
|
11128
12158
|
// The pre-edit snapshot fromSource() diffs against. Omitting it makes
|
|
@@ -11194,14 +12224,21 @@
|
|
|
11194
12224
|
const ok = await leaveSourceMode();
|
|
11195
12225
|
if (!ok) return;
|
|
11196
12226
|
}
|
|
11197
|
-
if (docMode === 'preview') applyPreviewEditability(true);
|
|
11198
12227
|
docMode = next;
|
|
11199
12228
|
if (docMode === 'source') enterSourceMode();
|
|
11200
|
-
if (docMode === 'preview') applyPreviewEditability(false);
|
|
11201
12229
|
document.body.setAttribute('data-ed-mode', docMode);
|
|
12230
|
+
paintModeStatus();
|
|
11202
12231
|
updateToolbar();
|
|
11203
12232
|
}
|
|
11204
12233
|
|
|
12234
|
+
// v3.2.1: 模式必須說出來 —— 使用者按第二下以為卡住,其實是進了 source。
|
|
12235
|
+
// Called from setDocMode() AND from mountToolbar(), so the slot is truthful
|
|
12236
|
+
// from the first paint rather than only after the first mode change.
|
|
12237
|
+
function paintModeStatus() {
|
|
12238
|
+
const statusEl = document.querySelector('.ed-toolbar-status');
|
|
12239
|
+
if (statusEl) statusEl.textContent = docMode === 'source' ? '原始碼' : '編輯';
|
|
12240
|
+
}
|
|
12241
|
+
|
|
11205
12242
|
// ── mount ──────────────────────────────────────────────────────────────
|
|
11206
12243
|
function wireToolbarTracking() {
|
|
11207
12244
|
// Which block the bar reports on. Delegated at document level (never a
|
|
@@ -11246,6 +12283,7 @@
|
|
|
11246
12283
|
// grips and the table edge menu.
|
|
11247
12284
|
document.body.appendChild(toolbarEl);
|
|
11248
12285
|
document.body.setAttribute('data-ed-mode', docMode);
|
|
12286
|
+
paintModeStatus();
|
|
11249
12287
|
wireToolbarTracking();
|
|
11250
12288
|
wireImageDrop();
|
|
11251
12289
|
wirePlainPasteArming();
|
|
@@ -11708,6 +12746,58 @@
|
|
|
11708
12746
|
});
|
|
11709
12747
|
});
|
|
11710
12748
|
|
|
12749
|
+
// v3.2.1: client.js 原本的 scroll 監聽器數量是 0,所以每一個 position:fixed
|
|
12750
|
+
// 的浮層在捲動後都停在原視窗座標。grip/bubble 有 mousemove 驅動可重新升起,
|
|
12751
|
+
// 隱藏它們就夠;.ed-seltb 沒有(它只由 selectionchange 與
|
|
12752
|
+
// applyMarkToggle/applyLinkToggle 驅動,而捲動不觸發 selectionchange),
|
|
12753
|
+
// 純隱藏會讓它永遠回不來,所以它要重新定位、只在選取整個離開視窗時才隱藏。
|
|
12754
|
+
// capture 階段是必要的:真正會自己捲的不是 .content(overflow-y: visible,
|
|
12755
|
+
// 實測 scrollHeight === clientHeight,捲的其實是 document)——是側欄的
|
|
12756
|
+
// .toc-list/#search-results-list(皆 overflow-y: auto,TOC 標題夠多時
|
|
12757
|
+
// 實測 scrollHeight > clientHeight)與 .lightbox-stage(overflow: auto)。
|
|
12758
|
+
// 這些容器的 scroll 事件不冒泡,只有 capture 階段收得到。
|
|
12759
|
+
// 副作用(已記錄,非缺陷):正因為 capture 連這些側欄容器的捲動都收,捲動
|
|
12760
|
+
// TOC 或搜尋結果清單時——即使正文內容一格都沒動——也會隱藏 table grips
|
|
12761
|
+
// 並關掉開著的 .ed-te-menu。
|
|
12762
|
+
//
|
|
12763
|
+
// 這個監聽器刻意是「模組層級、只掛一次、永不移除」,不跟著任何一次
|
|
12764
|
+
// burst 的生命週期走(不在 resetSelToolbarState() 裡拆)。理由:真正會
|
|
12765
|
+
// 刪資料的 .ed-te-menu/grip 家族(見上面 Task 5 的 mousemove 委派)不需要
|
|
12766
|
+
// 任何 burst 就能被叫出來 —— 使用者可以單純把游標移到表格上(觸發上面那個
|
|
12767
|
+
// 無條件掛載的 mousemove)升起 grip、打開 .ed-te-menu,此時完全沒有
|
|
12768
|
+
// selToolbarListener(那個監聽器只在 burst 開著時才存在)。若把這個捲動
|
|
12769
|
+
// 監聽器綁在 burst 身上,恰好在「grip/選單開著但沒有 burst」這個狀態下
|
|
12770
|
+
// 它會不存在。resetSelToolbarState() 本身也會在每次 rerender 時執行(六個
|
|
12771
|
+
// 呼叫點之一在本檔的 rerenderAll() 路徑上),把監聽器綁在它身上還會讓
|
|
12772
|
+
// 監聽器在 session 中途被拆掉、且永不重掛。
|
|
12773
|
+
let selScrollRaf = 0;
|
|
12774
|
+
function onAnyScroll() {
|
|
12775
|
+
if (tePointer && tePointer.dragging) return; // 拖曳中不動
|
|
12776
|
+
hideTableGrips();
|
|
12777
|
+
hideTableInsertBubbles();
|
|
12778
|
+
// v3.2.1 fix round 1: suppressEdgeMenuAutoHide — see its own comment —
|
|
12779
|
+
// is set for runCycleAlign()'s ensureTableBurstOpen() span, whose
|
|
12780
|
+
// cell.focus() can itself be the cause of this very scroll event; that
|
|
12781
|
+
// operation repositions the menu itself afterward, so skip tearing its
|
|
12782
|
+
// state down here (grip/bubble/toolbar-menu hiding above and below is
|
|
12783
|
+
// unaffected — they re-raise on the next mousemove regardless).
|
|
12784
|
+
if (!suppressEdgeMenuAutoHide) hideTableEdgeMenu(); // ← 真正會刪資料的是選單,不是 grip
|
|
12785
|
+
closeToolbarMenu();
|
|
12786
|
+
if (selScrollRaf) return;
|
|
12787
|
+
selScrollRaf = requestAnimationFrame(() => {
|
|
12788
|
+
selScrollRaf = 0;
|
|
12789
|
+
if (!selToolbar.parentNode) return; // 沒升起 → 0 次 layout read
|
|
12790
|
+
const sel = window.getSelection();
|
|
12791
|
+
if (!sel || !sel.rangeCount || sel.isCollapsed) { hideSelToolbar(); return; }
|
|
12792
|
+
const range = sel.getRangeAt(0);
|
|
12793
|
+
if (!selToolbarEditEl ||
|
|
12794
|
+
!selToolbarEditEl.contains(range.startContainer) ||
|
|
12795
|
+
!selToolbarEditEl.contains(range.endContainer)) { hideSelToolbar(); return; }
|
|
12796
|
+
positionSelToolbar(range);
|
|
12797
|
+
});
|
|
12798
|
+
}
|
|
12799
|
+
document.addEventListener('scroll', onAnyScroll, { passive: true, capture: true });
|
|
12800
|
+
|
|
11711
12801
|
window.addEventListener('beforeunload', (e) => {
|
|
11712
12802
|
if (stack.dirtyDepth !== 0) {
|
|
11713
12803
|
e.preventDefault();
|