@helping-ai-workflow/md2doc 2.11.1 → 2.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/editor/client.js +1650 -269
- package/lib/editor/indent-clamp.js +6 -1
- package/lib/editor/selection.js +204 -0
- package/lib/editor/server.js +7 -0
- package/lib/md2doc.js +50 -2
- package/package.json +2 -2
package/lib/editor/client.js
CHANGED
|
@@ -233,6 +233,7 @@
|
|
|
233
233
|
const listMd = window.md2docListMd;
|
|
234
234
|
const indentClamp = window.md2docIndentClamp;
|
|
235
235
|
const convertMd = window.md2docConvertMd;
|
|
236
|
+
const selectionLib = window.md2docSelection;
|
|
236
237
|
const historyLib = window.md2docHistory;
|
|
237
238
|
let lines = ED.lines, blocks = ED.blocks, mtimeMs = ED.mtimeMs;
|
|
238
239
|
// 檔案原本的換行符。lines 內部永遠是不含 \r 的純內容行;只有 save()
|
|
@@ -440,8 +441,19 @@
|
|
|
440
441
|
// extra button ahead of the always-present ✕ dismiss button (e.g. the
|
|
441
442
|
// conflict banner's "Reload"); omit them for a plain dismiss-only notice.
|
|
442
443
|
let activeBanner = null;
|
|
444
|
+
// Review recommendation 5 (2026-08-31): whether the banner on screen is a
|
|
445
|
+
// structural REFUSAL — 「無法整批操作」 and its siblings — as opposed to a
|
|
446
|
+
// conflict / render-failed / save-failed notice. Only a refusal is cleared
|
|
447
|
+
// by a later gesture that succeeds (see dismissRefusalBanner()); the other
|
|
448
|
+
// three describe the state of the FILE or the connection and must stay until
|
|
449
|
+
// the user dismisses them.
|
|
450
|
+
let activeBannerIsRefusal = false;
|
|
443
451
|
function showBanner(message, actionLabel, onAction) {
|
|
444
452
|
if (activeBanner) { activeBanner.remove(); activeBanner = null; }
|
|
453
|
+
// Reset unconditionally: refuseStructuralListEdit() sets it back to true
|
|
454
|
+
// straight after its own call, so every OTHER caller gets false without
|
|
455
|
+
// having to know this flag exists.
|
|
456
|
+
activeBannerIsRefusal = false;
|
|
445
457
|
const el = document.createElement('div');
|
|
446
458
|
el.className = 'ed-conflict';
|
|
447
459
|
const msg = document.createElement('span');
|
|
@@ -460,7 +472,7 @@
|
|
|
460
472
|
dismissBtn.setAttribute('aria-label', 'Dismiss');
|
|
461
473
|
dismissBtn.addEventListener('click', () => {
|
|
462
474
|
el.remove();
|
|
463
|
-
if (activeBanner === el) activeBanner = null;
|
|
475
|
+
if (activeBanner === el) { activeBanner = null; activeBannerIsRefusal = false; }
|
|
464
476
|
});
|
|
465
477
|
el.appendChild(dismissBtn);
|
|
466
478
|
document.body.appendChild(el);
|
|
@@ -468,6 +480,24 @@
|
|
|
468
480
|
return el;
|
|
469
481
|
}
|
|
470
482
|
|
|
483
|
+
// Review recommendation 5: a refusal banner must not outlive the gesture that
|
|
484
|
+
// raised it. refuseStructuralListEdit() has been dismiss-only since S1, and
|
|
485
|
+
// S3 made it far more reachable (7 of the T8 sweep's 13 selection shapes
|
|
486
|
+
// refuse), so 「選取範圍同時含有清單項目與其他區塊,無法整批操作」 could stand
|
|
487
|
+
// over a document the user had since successfully edited — a plain lie about
|
|
488
|
+
// the state of the document. Called from rerenderAll()'s success path, which
|
|
489
|
+
// is the one point every structural operation reaches only by having WORKED:
|
|
490
|
+
// every failure exit above it returns before this. Deliberately NOT called
|
|
491
|
+
// from the Escape / clear-selection paths — dismissing a selection is not the
|
|
492
|
+
// same event as a later gesture succeeding, and the refusal is still the true
|
|
493
|
+
// answer to the gesture the user last attempted.
|
|
494
|
+
function dismissRefusalBanner() {
|
|
495
|
+
if (!activeBanner || !activeBannerIsRefusal) return;
|
|
496
|
+
activeBanner.remove();
|
|
497
|
+
activeBanner = null;
|
|
498
|
+
activeBannerIsRefusal = false;
|
|
499
|
+
}
|
|
500
|
+
|
|
471
501
|
function showConflictBanner() {
|
|
472
502
|
showBanner(
|
|
473
503
|
'File changed on disk — reload to pick up external edits ' +
|
|
@@ -503,6 +533,14 @@
|
|
|
503
533
|
// await is inside its own try/catch, so callers never see a rejection.
|
|
504
534
|
async function rerenderAll() {
|
|
505
535
|
const scrollY = window.scrollY;
|
|
536
|
+
// S3 Task 5 (§4.4 step 2): the line range this render's operation declared
|
|
537
|
+
// for the rebuilt selection. Consumed HERE, before the first failure exit,
|
|
538
|
+
// rather than down at the rebuild itself: every `return false` below
|
|
539
|
+
// leaves `blockSelection` untouched — which is what makes a line-range
|
|
540
|
+
// selection survive a failed render for free — and a declaration left
|
|
541
|
+
// standing would then land on some LATER, unrelated render instead.
|
|
542
|
+
const declaredRange = pendingSelectionRange;
|
|
543
|
+
pendingSelectionRange = undefined;
|
|
506
544
|
let res;
|
|
507
545
|
try {
|
|
508
546
|
res = await fetch('/api/render', {
|
|
@@ -530,6 +568,10 @@
|
|
|
530
568
|
showBanner('Render failed — malformed server response. Your edit was not applied.', null, null);
|
|
531
569
|
return false;
|
|
532
570
|
}
|
|
571
|
+
// Past every failure exit: this render is going to happen, so whatever
|
|
572
|
+
// gesture asked for it has succeeded. Review recommendation 5 — a stale
|
|
573
|
+
// structural refusal is cleared HERE and nowhere else.
|
|
574
|
+
dismissRefusalBanner();
|
|
533
575
|
blocks = j.blocks;
|
|
534
576
|
contentEl.innerHTML = j.bodyHtml;
|
|
535
577
|
// Task 2 (Phase 3): re-arm every WYSIWYG-eligible paragraph/heading (and
|
|
@@ -611,6 +653,17 @@
|
|
|
611
653
|
// it's the same "singleton node appended as a child of whichever block
|
|
612
654
|
// it's open for" idiom as gutterMenu just above.
|
|
613
655
|
closeInsertMenu();
|
|
656
|
+
// S3 Task 5 (§4.4's ordered three steps): armEditables() above was step
|
|
657
|
+
// 1; this is steps 2 and 3 — rebuild the member set from the declared (or
|
|
658
|
+
// still-standing) LINE RANGE against the freshly built `blocks`, clear it
|
|
659
|
+
// if that range no longer resolves, and give the focus endpoint a real
|
|
660
|
+
// roving-tabindex holder. It sits AFTER the unconditional teardown above
|
|
661
|
+
// so nothing there can null what it just set, and BEFORE the two
|
|
662
|
+
// `try`-swallowed rebind blocks below so a diagram-init throw can never
|
|
663
|
+
// skip it. It is also deliberately ABOVE window.scrollTo(): the .focus()
|
|
664
|
+
// it ends in scrolls the holder into view, and the scroll restore below
|
|
665
|
+
// is what puts the reader back where they were.
|
|
666
|
+
rebuildBlockSelection(declaredRange);
|
|
614
667
|
window.scrollTo(0, scrollY);
|
|
615
668
|
if (window.__md2docInitDiagrams) {
|
|
616
669
|
try {
|
|
@@ -1294,10 +1347,26 @@
|
|
|
1294
1347
|
// has to: `opIndex` is an index into it, and rule 2's scope starts after it);
|
|
1295
1348
|
// clampIndents() reports no indent for a removed block, so the write-back
|
|
1296
1349
|
// below never touches the element that is on its way out.
|
|
1350
|
+
//
|
|
1351
|
+
// S3 Task 6: `opBlockEl` may also be an ARRAY — spec §3.4 rule 3's multi-block
|
|
1352
|
+
// operation, which clampIndents() has accepted since S1 (`opIndex` may be an
|
|
1353
|
+
// array of indices). `opOldIndent` is then the SMALLEST old indent in the set,
|
|
1354
|
+
// never the first member's: §3.4 rule 3 records both ways the first member
|
|
1355
|
+
// goes wrong (a delete drives a later member to indent −1; a batch Tab whose
|
|
1356
|
+
// first member is already at its ceiling no-ops the whole set). The caller
|
|
1357
|
+
// computes it with spanMinIndent(). A member that is not in `spanEls` aborts
|
|
1358
|
+
// the whole clamp rather than clamping a subset — a partial op set makes rule
|
|
1359
|
+
// 2's scope start in the wrong place, which is a silent wrong answer.
|
|
1297
1360
|
function applyIndentClamp(spanEls, opBlockEl, opOldIndent, opts) {
|
|
1298
1361
|
if (!indentClamp || !spanEls || !spanEls.length) return;
|
|
1299
|
-
const
|
|
1300
|
-
|
|
1362
|
+
const opEls = Array.isArray(opBlockEl) ? opBlockEl : [opBlockEl];
|
|
1363
|
+
const opIndex = [];
|
|
1364
|
+
for (let k = 0; k < opEls.length; k++) {
|
|
1365
|
+
const at = spanEls.indexOf(opEls[k]);
|
|
1366
|
+
if (at < 0) return;
|
|
1367
|
+
opIndex.push(at);
|
|
1368
|
+
}
|
|
1369
|
+
if (!opIndex.length) return;
|
|
1301
1370
|
const model = spanEls.map((el, i) => ({
|
|
1302
1371
|
id: i, // index-as-id: the span IS the universe here
|
|
1303
1372
|
type: el.getAttribute('data-block-type') === 'li' ? 'li' : 'other',
|
|
@@ -1596,35 +1665,175 @@
|
|
|
1596
1665
|
// without a second selector.
|
|
1597
1666
|
let convertSubmenu = null;
|
|
1598
1667
|
|
|
1668
|
+
// ── v2.12.0 Task 4b, half 1: an icon at the head of every item ──────────
|
|
1669
|
+
// User request: 「選單每個功能開頭給一個圖示,完全照抄 notion」. Notion's
|
|
1670
|
+
// visual LANGUAGE, drawn here rather than their assets copied: 16x16 on a
|
|
1671
|
+
// 0 0 16 16 viewBox, 1.5px stroke, fill:none, round caps and joins, and
|
|
1672
|
+
// stroke="currentColor" so an icon is simply the colour of the row it sits
|
|
1673
|
+
// in — retheme .ed-handle-menu and the icons follow for free, which a hex
|
|
1674
|
+
// literal here would break silently.
|
|
1675
|
+
//
|
|
1676
|
+
// The menu is a SINGLETON built once at module scope and moved between
|
|
1677
|
+
// blocks, so this markup is parsed exactly once for the life of the page.
|
|
1678
|
+
// Nothing here may run per open.
|
|
1679
|
+
const MENU_ICON_PATHS = {
|
|
1680
|
+
// 轉換成 — a turn/redirect arrow: out to the right, then down. "this block
|
|
1681
|
+
// becomes that one".
|
|
1682
|
+
convert: '<path d="M2.5 4.5h6a3 3 0 0 1 3 3v4.6"/><path d="M9 9.6l2.5 2.5 2.5-2.5"/>',
|
|
1683
|
+
// 建立副本 — two offset rounded cards, the standard duplicate glyph. The
|
|
1684
|
+
// back card is an L-shaped outline rather than a second full rect so the
|
|
1685
|
+
// two do not draw a line through each other.
|
|
1686
|
+
duplicate: '<rect x="5.5" y="5.5" width="8" height="8" rx="2"/>' +
|
|
1687
|
+
'<path d="M10.5 5.5V4.5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h1"/>',
|
|
1688
|
+
// 刪除 — a trash can: lid, handle, tapered body, two ribs.
|
|
1689
|
+
trash: '<path d="M2.5 4.5h11"/>' +
|
|
1690
|
+
'<path d="M6.4 4.5V3.2a1.2 1.2 0 0 1 1.2-1.2h.8a1.2 1.2 0 0 1 1.2 1.2v1.3"/>' +
|
|
1691
|
+
'<path d="M4.2 4.5l.6 8.1a1.4 1.4 0 0 0 1.4 1.3h3.6a1.4 1.4 0 0 0 1.4-1.3l.6-8.1"/>' +
|
|
1692
|
+
'<path d="M6.6 7.2v4.2"/><path d="M9.4 7.2v4.2"/>',
|
|
1693
|
+
// MD 原始碼 — angle brackets: source, not prose.
|
|
1694
|
+
code: '<path d="M6 3.6L2 8l4 4.4"/><path d="M10 3.6L14 8l-4 4.4"/>',
|
|
1695
|
+
};
|
|
1696
|
+
function menuIconMarkup(name) {
|
|
1697
|
+
return '<svg class="ed-menu-icon" viewBox="0 0 16 16" width="16" height="16" ' +
|
|
1698
|
+
'aria-hidden="true" focusable="false" fill="none" stroke="currentColor" ' +
|
|
1699
|
+
'stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">' +
|
|
1700
|
+
MENU_ICON_PATHS[name] + '</svg>';
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
// ── v2.12.0 Task 4b, half 2: the submenu opens on HOVER ─────────────────
|
|
1704
|
+
// User request: 「"轉換成" hover 沒有自動顯示子選單」.
|
|
1705
|
+
//
|
|
1706
|
+
// The naive "open on mouseenter, close on mouseleave" reproduces the gutter
|
|
1707
|
+
// corridor defect v2.11.1 had just finished fixing, and MEASURED on this
|
|
1708
|
+
// branch at 1400x900 it is worse than the plan predicted:
|
|
1709
|
+
//
|
|
1710
|
+
// * `.ed-handle-submenu { left: 100%; margin-left: 4px }` over a menu with
|
|
1711
|
+
// 4px of padding leaves x in [item.right + 4, sub.left) — exactly 4
|
|
1712
|
+
// device px on the item's own row — hit-testing to the BLOCK underneath.
|
|
1713
|
+
// A mouseleave-closes rule shuts the panel while the pointer is inside
|
|
1714
|
+
// that band, on its way in.
|
|
1715
|
+
// * The far worse one: the submenu is 342px tall against the menu's 118px,
|
|
1716
|
+
// so a straight line from 轉換成 to the panel's LAST row is 344px long
|
|
1717
|
+
// and leaves the item/panel pair for 334ms at 1200px/s, 367ms at 600,
|
|
1718
|
+
// 535ms at 300 and 1068ms at 150 — first across 建立副本 / 刪除 /
|
|
1719
|
+
// MD 原始碼, then across bare page BELOW the menu. No close delay covers
|
|
1720
|
+
// that AND still shuts the panel promptly when the user really has
|
|
1721
|
+
// settled on 刪除; the plan's suggested 150-250ms covers neither end.
|
|
1722
|
+
//
|
|
1723
|
+
// So the rule is the classic menu-aim one, and it is a DIRECTION test rather
|
|
1724
|
+
// than a distance or a timer: while the pointer is moving INTO the panel —
|
|
1725
|
+
// inside the triangle whose apex is where it was one sample ago and whose
|
|
1726
|
+
// base is the panel's near (left) edge, top to bottom — nothing closes the
|
|
1727
|
+
// submenu, whatever it happens to be passing over on the way. Every other
|
|
1728
|
+
// sample (a different parent item, bare page, off the menu entirely)
|
|
1729
|
+
// SCHEDULES the close, and the grace period below is only what covers a
|
|
1730
|
+
// single sample the triangle misses. Escape / outside click /
|
|
1731
|
+
// closeGutterMenu() are unchanged and still immediate.
|
|
1732
|
+
const SUBMENU_CLOSE_MS = 300;
|
|
1733
|
+
let submenuCloseTimer = null;
|
|
1734
|
+
let submenuAimPrev = null;
|
|
1735
|
+
// Whether the standing panel was opened by the pointer rather than by a
|
|
1736
|
+
// click — see the 轉換成 item's own handler for what it is for.
|
|
1737
|
+
let convertSubmenuViaHover = false;
|
|
1738
|
+
|
|
1739
|
+
function cancelSubmenuClose() {
|
|
1740
|
+
if (submenuCloseTimer !== null) { clearTimeout(submenuCloseTimer); submenuCloseTimer = null; }
|
|
1741
|
+
}
|
|
1742
|
+
function scheduleSubmenuClose() {
|
|
1743
|
+
// Deliberately NOT restarted while one is already counting down: the
|
|
1744
|
+
// countdown starts at the first sample that says "not on the way in", and
|
|
1745
|
+
// any sample that says otherwise cancels it outright. Restarting per
|
|
1746
|
+
// mousemove would make the close time depend on how much the user jiggles.
|
|
1747
|
+
if (submenuCloseTimer !== null) return;
|
|
1748
|
+
submenuCloseTimer = setTimeout(() => {
|
|
1749
|
+
submenuCloseTimer = null;
|
|
1750
|
+
closeConvertSubmenu();
|
|
1751
|
+
}, SUBMENU_CLOSE_MS);
|
|
1752
|
+
}
|
|
1753
|
+
function pointInTriangle(px, py, ax, ay, bx, by, cx, cy) {
|
|
1754
|
+
const cross = (x1, y1, x2, y2, x3, y3) => (x1 - x3) * (y2 - y3) - (x2 - x3) * (y1 - y3);
|
|
1755
|
+
const d1 = cross(px, py, ax, ay, bx, by);
|
|
1756
|
+
const d2 = cross(px, py, bx, by, cx, cy);
|
|
1757
|
+
const d3 = cross(px, py, cx, cy, ax, ay);
|
|
1758
|
+
return !(((d1 < 0) || (d2 < 0) || (d3 < 0)) && ((d1 > 0) || (d2 > 0) || (d3 > 0)));
|
|
1759
|
+
}
|
|
1760
|
+
function onSubmenuPointerMove(e) {
|
|
1761
|
+
if (!convertSubmenu) return;
|
|
1762
|
+
const prev = submenuAimPrev;
|
|
1763
|
+
submenuAimPrev = { x: e.clientX, y: e.clientY };
|
|
1764
|
+
const t = e.target;
|
|
1765
|
+
const inSub = !!(t && t.closest && t.closest('.ed-handle-submenu'));
|
|
1766
|
+
const btn = t && t.closest ? t.closest('.ed-handle-menu-btn') : null;
|
|
1767
|
+
if (inSub || (btn && btn === gutterMenuConvert)) { cancelSubmenuClose(); return; }
|
|
1768
|
+
if (prev) {
|
|
1769
|
+
const r = convertSubmenu.getBoundingClientRect();
|
|
1770
|
+
if (pointInTriangle(e.clientX, e.clientY, prev.x, prev.y, r.left, r.top, r.left, r.bottom)) {
|
|
1771
|
+
cancelSubmenuClose();
|
|
1772
|
+
return;
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
scheduleSubmenuClose();
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1599
1778
|
function buildGutterMenu() {
|
|
1600
1779
|
const el = document.createElement('div');
|
|
1601
1780
|
el.className = 'ed-handle-menu';
|
|
1602
1781
|
|
|
1603
|
-
function item(label, aria, onClick) {
|
|
1782
|
+
function item(label, aria, onClick, icon) {
|
|
1604
1783
|
const b = document.createElement('button');
|
|
1605
1784
|
b.type = 'button';
|
|
1606
1785
|
b.className = 'ed-handle-menu-btn';
|
|
1607
|
-
|
|
1786
|
+
// The icon first, then the label as a bare TEXT NODE. Never
|
|
1787
|
+
// `b.textContent = label` after this (it would wipe the icon), and
|
|
1788
|
+
// deliberately no wrapper element around the label: every menu helper in
|
|
1789
|
+
// this repo and in test/editor-client-runtime.test.js finds an item by
|
|
1790
|
+
// EXACT `b.textContent`, and an <svg> contributes none of its own, so the
|
|
1791
|
+
// label reads back byte-identical with the icon in front of it.
|
|
1792
|
+
b.innerHTML = menuIconMarkup(icon);
|
|
1793
|
+
b.appendChild(document.createTextNode(label));
|
|
1608
1794
|
b.setAttribute('aria-label', aria);
|
|
1609
1795
|
b.addEventListener('click', onClick);
|
|
1610
1796
|
el.appendChild(b);
|
|
1611
1797
|
return b;
|
|
1612
1798
|
}
|
|
1613
1799
|
|
|
1800
|
+
// Hover-open. One delegated listener on the singleton menu, for the life of
|
|
1801
|
+
// the page. The submenu is a CHILD of the menu so its own mouseovers bubble
|
|
1802
|
+
// through here too, which is why this is an IDENTITY check against
|
|
1803
|
+
// gutterMenuConvert rather than a label match.
|
|
1804
|
+
el.addEventListener('mouseover', (e) => {
|
|
1805
|
+
const btn = e.target && e.target.closest ? e.target.closest('.ed-handle-menu-btn') : null;
|
|
1806
|
+
if (!gutterMenuConvert || btn !== gutterMenuConvert || gutterMenuConvert.hidden) return;
|
|
1807
|
+
cancelSubmenuClose();
|
|
1808
|
+
if (!convertSubmenu) openConvertSubmenu(gutterMenuConvert, true);
|
|
1809
|
+
});
|
|
1810
|
+
|
|
1614
1811
|
// 轉換成 is the one item that does NOT close the menu — it grows a
|
|
1615
1812
|
// submenu, and a second press folds it back up.
|
|
1616
1813
|
gutterMenuConvert = item('轉換成 ›', 'Convert this block', (e) => {
|
|
1617
1814
|
e.stopPropagation();
|
|
1618
|
-
if (convertSubmenu) {
|
|
1619
|
-
|
|
1620
|
-
|
|
1815
|
+
if (convertSubmenu) {
|
|
1816
|
+
// A click folds a CLICK-opened panel back up — the S2 toggle, which is
|
|
1817
|
+
// the path clickGutterMenuItem() / convertVia() drive throughout the
|
|
1818
|
+
// test suite and which must not regress. It must NOT fold up a panel
|
|
1819
|
+
// the pointer's own hover just opened: the pointer is on the item, so
|
|
1820
|
+
// no further mouseover would ever fire, and the panel would become
|
|
1821
|
+
// unreachable by mouse — the exact opposite of what was asked for.
|
|
1822
|
+
// The click does consume the hover flag, so a second one still folds.
|
|
1823
|
+
if (!convertSubmenuViaHover) { closeConvertSubmenu(); return; }
|
|
1824
|
+
convertSubmenuViaHover = false;
|
|
1825
|
+
cancelSubmenuClose();
|
|
1826
|
+
return;
|
|
1827
|
+
}
|
|
1828
|
+
openConvertSubmenu(gutterMenuConvert, false);
|
|
1829
|
+
}, 'convert');
|
|
1621
1830
|
|
|
1622
1831
|
gutterMenuDuplicate = item('建立副本', 'Duplicate this block', (e) => {
|
|
1623
1832
|
e.stopPropagation();
|
|
1624
1833
|
const blockEl = gutterMenuBlockEl;
|
|
1625
1834
|
closeGutterMenu();
|
|
1626
1835
|
duplicateBlockViaMenu(blockEl);
|
|
1627
|
-
});
|
|
1836
|
+
}, 'duplicate');
|
|
1628
1837
|
|
|
1629
1838
|
// §10-gap fix: block-level DELETE. Reuses commitListBlockRemoval()
|
|
1630
1839
|
// unchanged (that function was already fully block-type-agnostic —
|
|
@@ -1638,14 +1847,14 @@
|
|
|
1638
1847
|
const blockEl = gutterMenuBlockEl;
|
|
1639
1848
|
closeGutterMenu();
|
|
1640
1849
|
deleteBlockViaGutter(blockEl);
|
|
1641
|
-
});
|
|
1850
|
+
}, 'trash');
|
|
1642
1851
|
|
|
1643
1852
|
gutterMenuMd = item('MD 原始碼', 'Switch to raw markdown edit', (e) => {
|
|
1644
1853
|
e.stopPropagation();
|
|
1645
1854
|
const blockEl = gutterMenuBlockEl;
|
|
1646
1855
|
closeGutterMenu();
|
|
1647
1856
|
openRawViaGutter(blockEl);
|
|
1648
|
-
});
|
|
1857
|
+
}, 'code');
|
|
1649
1858
|
|
|
1650
1859
|
return el;
|
|
1651
1860
|
}
|
|
@@ -1657,7 +1866,7 @@
|
|
|
1657
1866
|
// `left: 100%` (lib/md2doc.js) resolves against the menu's padding box —
|
|
1658
1867
|
// no viewport arithmetic, and the panel travels with the menu when the menu
|
|
1659
1868
|
// is moved into another block.
|
|
1660
|
-
function openConvertSubmenu(anchorBtn) {
|
|
1869
|
+
function openConvertSubmenu(anchorBtn, viaHover) {
|
|
1661
1870
|
closeConvertSubmenu();
|
|
1662
1871
|
const sub = document.createElement('div');
|
|
1663
1872
|
sub.className = 'ed-handle-menu ed-handle-submenu';
|
|
@@ -1678,9 +1887,22 @@
|
|
|
1678
1887
|
sub.style.top = anchorBtn.offsetTop + 'px';
|
|
1679
1888
|
anchorBtn.parentNode.appendChild(sub);
|
|
1680
1889
|
convertSubmenu = sub;
|
|
1890
|
+
convertSubmenuViaHover = !!viaHover;
|
|
1891
|
+
// The aim triangle needs a previous sample to have an apex; the first
|
|
1892
|
+
// mousemove after the open only records one. Cleared here so a panel
|
|
1893
|
+
// re-opened on another block cannot aim from the old block's geometry.
|
|
1894
|
+
submenuAimPrev = null;
|
|
1895
|
+
// Attached only while a panel is standing, and removed again below — a
|
|
1896
|
+
// document-level mousemove listener that outlived the panel would be a
|
|
1897
|
+
// per-move cost on every page for nothing.
|
|
1898
|
+
document.addEventListener('mousemove', onSubmenuPointerMove);
|
|
1681
1899
|
}
|
|
1682
1900
|
|
|
1683
1901
|
function closeConvertSubmenu() {
|
|
1902
|
+
cancelSubmenuClose();
|
|
1903
|
+
submenuAimPrev = null;
|
|
1904
|
+
convertSubmenuViaHover = false;
|
|
1905
|
+
document.removeEventListener('mousemove', onSubmenuPointerMove);
|
|
1684
1906
|
if (convertSubmenu) { convertSubmenu.remove(); convertSubmenu = null; }
|
|
1685
1907
|
}
|
|
1686
1908
|
|
|
@@ -1696,6 +1918,46 @@
|
|
|
1696
1918
|
gutterMenuBlockEl = null;
|
|
1697
1919
|
}
|
|
1698
1920
|
|
|
1921
|
+
// ── The one rule about 轉換成 and block type ────────────────────────────
|
|
1922
|
+
// The block types 轉換成 is WITHHELD from, and the banner the BATCH path
|
|
1923
|
+
// shows when a span holds one. ONE table, TWO call sites — toggleGutterMenu()
|
|
1924
|
+
// just below hides the item on a single block's ⠿; convertBlockViaMenu()
|
|
1925
|
+
// refuses a span that holds one — and that is the point of it being a shared
|
|
1926
|
+
// predicate rather than a type test written out twice. MEASURED 2026-08-31,
|
|
1927
|
+
// with the batch path gating only 'table': a selection over a paragraph, an
|
|
1928
|
+
// hr and a paragraph, converted from a grip on a paragraph, wrote
|
|
1929
|
+
// '# Doc\n\n- alpha\n- ---\n- bravo\n' with NO banner — '- ---' being the
|
|
1930
|
+
// exact byte sequence the 'hr' reason below names as why the item is not
|
|
1931
|
+
// offered. A type withheld in one place and silently allowed in the other IS
|
|
1932
|
+
// the defect; deriving both from here is what makes a fourth type impossible
|
|
1933
|
+
// to add to one affordance and forget in the other.
|
|
1934
|
+
//
|
|
1935
|
+
// Why these three:
|
|
1936
|
+
// 'table' — §7: there is no target that could carry a table's cells, and
|
|
1937
|
+
// every one of the twelve would destroy them.
|
|
1938
|
+
// 'hr' / 'html' — the gesture would LIE. convert-md strips a block's
|
|
1939
|
+
// MARKER to get its content, and an <hr> has no content: its source line
|
|
1940
|
+
// IS the marker. Measured: 'hr' → 項目符號列表 writes '- ---', which
|
|
1941
|
+
// marked re-lexes as an hr again, so the file's bytes change, the block
|
|
1942
|
+
// type does not, and nothing is said; 'hr' → 文字 is a byte no-op, also
|
|
1943
|
+
// silent. An 'html' block is raw passthrough for the same reason — no
|
|
1944
|
+
// marker to strip and nothing to re-host. Nothing is LOST either way,
|
|
1945
|
+
// but an item that appears to work and does nothing is worse than an
|
|
1946
|
+
// item that is not offered.
|
|
1947
|
+
//
|
|
1948
|
+
// Each type names ITSELF in the banner: a user whose selection holds an hr
|
|
1949
|
+
// must not be told it holds a table. One template, three labels — the
|
|
1950
|
+
// wording is per type, the RULE is not.
|
|
1951
|
+
const BATCH_CONVERT_WITHHELD_MESSAGES = {
|
|
1952
|
+
table: '選取範圍含有表格,無法整批轉換',
|
|
1953
|
+
hr: '選取範圍含有分隔線,無法整批轉換',
|
|
1954
|
+
html: '選取範圍含有 HTML 區塊,無法整批轉換',
|
|
1955
|
+
};
|
|
1956
|
+
function convertWithheldFor(blockType) {
|
|
1957
|
+
return Object.prototype.hasOwnProperty.call(
|
|
1958
|
+
BATCH_CONVERT_WITHHELD_MESSAGES, blockType);
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1699
1961
|
function toggleGutterMenu(blockEl) {
|
|
1700
1962
|
if (!blockEl) return;
|
|
1701
1963
|
if (gutterMenuBlockEl === blockEl) { closeGutterMenu(); return; }
|
|
@@ -1712,21 +1974,10 @@
|
|
|
1712
1974
|
closeConvertSubmenu();
|
|
1713
1975
|
gutterMenuBlockEl = blockEl;
|
|
1714
1976
|
const blockType = blockEl.getAttribute('data-block-type');
|
|
1715
|
-
// Spec §7
|
|
1716
|
-
//
|
|
1717
|
-
//
|
|
1718
|
-
|
|
1719
|
-
// 'hr' and 'html' are withheld for a different, measured reason: the
|
|
1720
|
-
// gesture would LIE. convert-md strips a block's marker to get its
|
|
1721
|
-
// content, and an <hr> has no content — its source line IS the marker.
|
|
1722
|
-
// Measured: 'hr' → 項目符號列表 writes '- ---', which marked re-lexes
|
|
1723
|
-
// as an hr again, so the file's bytes change, the block type does not,
|
|
1724
|
-
// and no banner is shown. 'hr' → 文字 is a byte no-op, also silent.
|
|
1725
|
-
// An 'html' block is raw passthrough for the same reason: there is no
|
|
1726
|
-
// marker to strip and no content to re-host. Nothing is lost either way,
|
|
1727
|
-
// but an item that appears to work and does nothing is worse than an
|
|
1728
|
-
// item that is not offered.
|
|
1729
|
-
gutterMenuConvert.hidden = (blockType === 'table' || blockType === 'hr' || blockType === 'html');
|
|
1977
|
+
// Spec §7 / §3.7. Which types, and why, is stated ONCE just above — this
|
|
1978
|
+
// is one of that predicate's two call sites, and convertBlockViaMenu()'s
|
|
1979
|
+
// batch refusal is the other.
|
|
1980
|
+
gutterMenuConvert.hidden = convertWithheldFor(blockType);
|
|
1730
1981
|
gutterMenuDuplicate.hidden = false;
|
|
1731
1982
|
gutterMenuDelete.hidden = false;
|
|
1732
1983
|
// RULING F-O: 'MD 原始碼' is hidden for a list item PERMANENTLY, not as a
|
|
@@ -1738,7 +1989,28 @@
|
|
|
1738
1989
|
// between blocks, so this must be reset on every open, not set once.
|
|
1739
1990
|
// (test/editor-reader-rebind.test.js drives raw-edit through this button
|
|
1740
1991
|
// by its exact text on a paragraph.)
|
|
1741
|
-
|
|
1992
|
+
//
|
|
1993
|
+
// §3.7's other sentence about this item — 「多選時不顯示 `MD 原始碼`」 —
|
|
1994
|
+
// is the half S3 shipped without. openRawViaGutter() rewrites ONE block's
|
|
1995
|
+
// source lines, so over a set of N it silently answers for the grip's
|
|
1996
|
+
// block and ignores the rest: an item that appears to act on the selection
|
|
1997
|
+
// and does not.
|
|
1998
|
+
//
|
|
1999
|
+
// The question is §3.3's MEMBERSHIP question, asked exactly the way every
|
|
2000
|
+
// set operation asks it (resolveGutterOperands() → resolveMembership(),
|
|
2001
|
+
// identifying the grip block by REFERENCE into `blocks` — Task 1 carry 4),
|
|
2002
|
+
// and not "is anything selected". Two states answer differently and both
|
|
2003
|
+
// keep the item: a set of exactly the grip block is a set of ONE, which
|
|
2004
|
+
// raw-edits fine; and a set standing elsewhere in the document is not this
|
|
2005
|
+
// grip's set at all — §3.3 turns that gesture into a single-block operation
|
|
2006
|
+
// before it acts, so the item is right there too.
|
|
2007
|
+
const mdRec = blockRecOf(blockEl);
|
|
2008
|
+
const mdMembership = (selectionLib && mdRec)
|
|
2009
|
+
? selectionLib.resolveMembership(blockSelection, blocks, mdRec)
|
|
2010
|
+
: { mode: 'single', members: mdRec ? [mdRec] : [] };
|
|
2011
|
+
const mdMultiSelected =
|
|
2012
|
+
mdMembership.mode === 'batch' && mdMembership.members.length > 1;
|
|
2013
|
+
gutterMenuMd.hidden = (blockType === 'li') || mdMultiSelected;
|
|
1742
2014
|
blockEl.appendChild(gutterMenu);
|
|
1743
2015
|
}
|
|
1744
2016
|
|
|
@@ -2165,97 +2437,323 @@
|
|
|
2165
2437
|
// li -> heading would hit the degrade path and refuse itself. Reading `lines`
|
|
2166
2438
|
// also means the inline content is never re-serialized, so escapeText() never
|
|
2167
2439
|
// runs over it and a `~5px` in the converted block stays `~5px`.
|
|
2168
|
-
|
|
2169
|
-
|
|
2440
|
+
// ── S3 Task 6: the ⠿ gesture's OPERAND SET (spec §3.3) ──────────────────
|
|
2441
|
+
//
|
|
2442
|
+
// Up to S2 every gutter operation worked on exactly one block and the three
|
|
2443
|
+
// entry points below carried a byte-identical preamble: capture identity,
|
|
2444
|
+
// resolve whatever session is open, re-find the block if that commit
|
|
2445
|
+
// re-rendered, refuse a block that owns no source line. §3.3 turns that ONE
|
|
2446
|
+
// element into a SPAN — 「grip 在選取集合內 → 作用整個集合;grip 在集合外 →
|
|
2447
|
+
// 先把集合換成該單一 block 再作用」— so the preamble moved here and grew a
|
|
2448
|
+
// membership step.
|
|
2449
|
+
//
|
|
2450
|
+
// ORDERING IS LOAD-BEARING. Membership is resolved AFTER switchAwayFrom(),
|
|
2451
|
+
// never before: that call can commit an open burst, and its rerenderAll()
|
|
2452
|
+
// renumbers every block id and hands back a fresh `blocks` array. A record
|
|
2453
|
+
// captured ahead of it is a dangling reference — and resolveMembership()
|
|
2454
|
+
// compares by REFERENCE (Task 1 carry 4: ids are forbidden architecturally
|
|
2455
|
+
// and line tuples are genuinely ambiguous, since `- - - a` yields two
|
|
2456
|
+
// structurally identical phantoms {startLine:1, endLine:0}), so a stale
|
|
2457
|
+
// record silently answers 'single' and the batch degrades to one block. The
|
|
2458
|
+
// selection itself survives that render for free — its identity is a LINE
|
|
2459
|
+
// RANGE, which is exactly what no render can invalidate.
|
|
2460
|
+
//
|
|
2461
|
+
// Returns `null` when the gesture is refused or dropped — the banner has
|
|
2462
|
+
// already been raised — otherwise `{ els, recs, batch }`: the LIVE block
|
|
2463
|
+
// elements in document order, their records out of `blocks` (by reference),
|
|
2464
|
+
// and whether more than one block is being operated on.
|
|
2465
|
+
async function resolveGutterOperands(blockEl) {
|
|
2466
|
+
if (!blockEl) return null;
|
|
2467
|
+
// Finding 5a's mousedown preventDefault() deliberately keeps a dirty burst
|
|
2468
|
+
// alive across the ⠿ press, so the commit that lands inside
|
|
2469
|
+
// switchAwayFrom() below can be a rewrite of the very block the gesture
|
|
2470
|
+
// names — in which case reresolveBlockEl()'s SOURCE fingerprint is
|
|
2471
|
+
// guaranteed to miss, because WE are the reason the source changed. That
|
|
2472
|
+
// is not a dropped gesture; startLine + type still name the block, and the
|
|
2473
|
+
// fingerprint's job (proving an UNRELATED commit did not move somebody
|
|
2474
|
+
// else into this slot) is done by those two here.
|
|
2170
2475
|
const identity = captureBlockIdentity(blockEl);
|
|
2171
|
-
// Whether the session switchAwayFrom() is about to resolve belongs to THIS
|
|
2172
|
-
// block. Finding 5a's mousedown preventDefault() deliberately keeps a dirty
|
|
2173
|
-
// burst alive across the ⠿ press, so the commit that lands inside
|
|
2174
|
-
// switchAwayFrom() below can be a rewrite of the very block we are about to
|
|
2175
|
-
// convert — in which case reresolveBlockEl()'s source fingerprint is
|
|
2176
|
-
// guaranteed to miss, because WE are the reason the source changed. That is
|
|
2177
|
-
// not a dropped gesture; startLine + type still name the block, and the
|
|
2178
|
-
// fingerprint's job (proving an UNRELATED commit did not move somebody else
|
|
2179
|
-
// into this slot) is done by those two here.
|
|
2180
2476
|
const selfSession = ownsOpenSession(blockEl);
|
|
2181
2477
|
const ok = await switchAwayFrom();
|
|
2182
|
-
if (!ok) return;
|
|
2478
|
+
if (!ok) return null;
|
|
2183
2479
|
let liveBlockEl = blockEl;
|
|
2184
2480
|
if (!document.body.contains(blockEl)) {
|
|
2185
2481
|
liveBlockEl = reresolveBlockEl(identity) ||
|
|
2186
2482
|
(selfSession ? reresolveBlockElAfterSelfCommit(identity) : null);
|
|
2187
|
-
if (!liveBlockEl) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
2483
|
+
if (!liveBlockEl) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return null; }
|
|
2484
|
+
}
|
|
2485
|
+
// A block that owns no source line has an INVERTED range
|
|
2486
|
+
// (endLine === startLine - 1) and every commit helper handed one does
|
|
2487
|
+
// something plausible and wrong.
|
|
2488
|
+
if (blockOwnsNoLine(liveBlockEl)) { refuseStructuralListEdit(NO_SOURCE_LINE_MESSAGE); return null; }
|
|
2489
|
+
const rec = blockRecOf(liveBlockEl);
|
|
2490
|
+
if (!rec) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return null; }
|
|
2491
|
+
|
|
2492
|
+
const res = selectionLib
|
|
2493
|
+
? selectionLib.resolveMembership(blockSelection, blocks, rec)
|
|
2494
|
+
: { mode: 'single', members: [rec] };
|
|
2495
|
+
if (res.mode !== 'batch') {
|
|
2496
|
+
// §3.3's second half: 「grip 在集合外 → 先把集合換成該單一 block 再作用」.
|
|
2497
|
+
// Done here rather than left to the post-operation collapse so a REFUSAL
|
|
2498
|
+
// also leaves the documented state behind, instead of a set somewhere
|
|
2499
|
+
// else in the document that the user's next keystroke would batch over.
|
|
2500
|
+
// No selection standing at all stays exactly as it was pre-S3.
|
|
2501
|
+
if (blockSelection) {
|
|
2502
|
+
setBlockSelection({ anchorLine: rec.startLine, focusLine: rec.startLine });
|
|
2503
|
+
}
|
|
2504
|
+
return { els: [liveBlockEl], recs: [rec], batch: false };
|
|
2505
|
+
}
|
|
2506
|
+
const members = res.members;
|
|
2507
|
+
// Task 1 carry 6: `spanIsContiguous([])` is TRUE — no members, no gaps — so
|
|
2508
|
+
// emptiness is checked separately and gets its own wording. Unreachable
|
|
2509
|
+
// through the menu today (resolveMembership() only answers 'batch' when the
|
|
2510
|
+
// grip block is itself a member), but the two states are genuinely
|
|
2511
|
+
// different and a single gate would report the wrong one if it ever is.
|
|
2512
|
+
if (!members.length) { refuseStructuralListEdit(BATCH_EMPTY_MESSAGE); return null; }
|
|
2513
|
+
// Task 1 carry 2: a gap is NOT only what a disjoint selection produces. A
|
|
2514
|
+
// no-line phantom can sit BETWEEN two real members — `- a\n- - b\n- c\n`
|
|
2515
|
+
// yields li{1,1} | phantom{2,1} | li{2,2} | li{3,3}, so selecting lines 1–2
|
|
2516
|
+
// (an entirely natural gesture) lands on indices 0 and 2. The batch cannot
|
|
2517
|
+
// be expressed as one contiguous index range, so it refuses rather than
|
|
2518
|
+
// writing a range it cannot honour.
|
|
2519
|
+
if (!selectionLib.spanIsContiguous(members, blocks)) {
|
|
2520
|
+
refuseStructuralListEdit(BATCH_GAP_MESSAGE);
|
|
2521
|
+
return null;
|
|
2522
|
+
}
|
|
2523
|
+
const els = [];
|
|
2524
|
+
for (let i = 0; i < members.length; i++) {
|
|
2525
|
+
const el = blockElById(members[i].id);
|
|
2526
|
+
if (!el) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return null; }
|
|
2527
|
+
// Belt to membersOf()'s own braces: selection.js excludes a block that
|
|
2528
|
+
// owns no line from every member set it builds, so this is unreachable —
|
|
2529
|
+
// but "another module filters it" is an argument about another file's
|
|
2530
|
+
// output, and this is the guard that stands between an inverted range and
|
|
2531
|
+
// a commit helper.
|
|
2532
|
+
if (blockOwnsNoLine(el)) { refuseStructuralListEdit(NO_SOURCE_LINE_MESSAGE); return null; }
|
|
2533
|
+
els.push(el);
|
|
2534
|
+
}
|
|
2535
|
+
return { els: els, recs: members, batch: members.length > 1 };
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
// §3.3's collapse: 「操作後集合塌縮為「操作結果所涵蓋的行區間」」. Declared
|
|
2539
|
+
// IMMEDIATELY before the render and never earlier — rerenderAll() consumes the
|
|
2540
|
+
// declaration at its very top, before its first failure exit, so one left
|
|
2541
|
+
// standing lands on some LATER, unrelated render (Task 5 carry 5).
|
|
2542
|
+
//
|
|
2543
|
+
// ONLY when a set was actually standing, and that guard is load-bearing:
|
|
2544
|
+
// without it an ordinary single-block ⠿ 建立副本 or 轉換成 on a document with
|
|
2545
|
+
// NO selection would CREATE one — collapseTo() resolves the declared range,
|
|
2546
|
+
// membersOf() finds the block, and setBlockSelection() tints it and pulls the
|
|
2547
|
+
// roving focus onto it. That is a selection the user never made, and it would
|
|
2548
|
+
// change the resting state of every pre-S3 gutter gesture. resolveGutterOperands()
|
|
2549
|
+
// has already applied §3.3's 「grip 在集合外 → 先把集合換成該單一 block」, so
|
|
2550
|
+
// `blockSelection` is non-null here exactly when the gesture was a set
|
|
2551
|
+
// operation. Leaving the declaration UNSET is the correct answer otherwise:
|
|
2552
|
+
// `undefined` means "keep whatever is standing", and nothing is.
|
|
2553
|
+
function declareCollapse(range) {
|
|
2554
|
+
if (blockSelection) declareSelectionRange(range);
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
const BATCH_EMPTY_MESSAGE = '沒有選取任何區塊';
|
|
2558
|
+
const BATCH_GAP_MESSAGE = '選取範圍不連續,無法整批操作';
|
|
2559
|
+
const BATCH_MIXED_MESSAGE = '選取範圍同時含有清單項目與其他區塊,無法整批操作';
|
|
2560
|
+
const BATCH_MULTIRUN_MESSAGE = '選取範圍跨越兩個清單,無法整批操作';
|
|
2561
|
+
// 轉換成's own refusals are NOT declared here. They are per block TYPE, and
|
|
2562
|
+
// they are the same ruling §3.7 applies to a single block's ⠿ — so they live
|
|
2563
|
+
// with that predicate, in BATCH_CONVERT_WITHHELD_MESSAGES above
|
|
2564
|
+
// toggleGutterMenu(), where the menu-hiding call site can share them. The
|
|
2565
|
+
// four constants above are shape-level and belong to every batch operation.
|
|
2566
|
+
|
|
2567
|
+
function blockElById(id) {
|
|
2568
|
+
return document.querySelector('.ed-block[data-block-id="' + id + '"]');
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
// §3.4 rule 3's batch anchor: 「多 block 操作的錨點 = 選取集合中最小的舊
|
|
2572
|
+
// indent」, not the first member. Deleting `{a(0), b(1)}` anchored on a(0) is
|
|
2573
|
+
// right, but anchored on the FIRST member of `{b(1), …}` the following
|
|
2574
|
+
// segment head's bound is computed against 1 instead of 0 and a same-segment
|
|
2575
|
+
// sibling lands at −1 (undefined in columns).
|
|
2576
|
+
//
|
|
2577
|
+
// ⚠ This value is the anchor for the blocks BELOW the operated set — the
|
|
2578
|
+
// bound applyIndentClamp() re-measures them against — and NOTHING else.
|
|
2579
|
+
// §3.4 rule 3 also cites a batch TAB whose first member is at its ceiling as
|
|
2580
|
+
// a failure of first-member anchoring, but that half was superseded by the
|
|
2581
|
+
// 2026-08-31 D1 review: how far the set itself may move is batchIndentDelta()
|
|
2582
|
+
// and is the minimum head-room across ALL members, so a set holding a member
|
|
2583
|
+
// at its ceiling is a whole-batch no-op regardless of what its shallowest
|
|
2584
|
+
// member could have done alone. Do not re-derive Tab's delta from here.
|
|
2585
|
+
function spanMinIndent(els) {
|
|
2586
|
+
let min = null;
|
|
2587
|
+
(els || []).forEach((el) => {
|
|
2588
|
+
const v = Number(el.getAttribute('data-indent')) || 0;
|
|
2589
|
+
if (min === null || v < min) min = v;
|
|
2590
|
+
});
|
|
2591
|
+
return min === null ? 0 : min;
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
// The kinds a span holds, and the one shape no batch path can express.
|
|
2595
|
+
// A contiguous span of list items is rewritten through its RUN's
|
|
2596
|
+
// re-serialization; a contiguous span of non-list blocks is a plain line
|
|
2597
|
+
// splice. A span holding BOTH is neither: the run's survivors have to be
|
|
2598
|
+
// re-emitted at the same time as a line range outside the run is removed, and
|
|
2599
|
+
// the blank-line policy at the seam between them has no ruling in the spec.
|
|
2600
|
+
// Refused with its own banner rather than guessed at.
|
|
2601
|
+
function spanListKinds(els) {
|
|
2602
|
+
const kinds = els.map((el) => el.getAttribute('data-block-type'));
|
|
2603
|
+
return {
|
|
2604
|
+
kinds: kinds,
|
|
2605
|
+
allLi: kinds.length > 0 && kinds.every((k) => k === 'li'),
|
|
2606
|
+
anyLi: kinds.indexOf('li') !== -1,
|
|
2607
|
+
};
|
|
2608
|
+
}
|
|
2609
|
+
|
|
2610
|
+
// Every list batch is one run's problem: listRunOf() is the span the commit
|
|
2611
|
+
// re-serializes, and a member outside it would be rewritten by a range that
|
|
2612
|
+
// does not cover it. Contiguity in `blocks` does not imply one run — two
|
|
2613
|
+
// adjacent runs separated by a delimiter change (`- a` / `* b`) are adjacent
|
|
2614
|
+
// blocks with no phantom between them.
|
|
2615
|
+
function batchRunOf(liEls) {
|
|
2616
|
+
const run = listRunOf(liEls[0]);
|
|
2617
|
+
if (!run.length) return null;
|
|
2618
|
+
for (let i = 0; i < liEls.length; i++) if (run.indexOf(liEls[i]) === -1) return null;
|
|
2619
|
+
return run;
|
|
2620
|
+
}
|
|
2621
|
+
|
|
2622
|
+
async function convertBlockViaMenu(blockEl, target) {
|
|
2623
|
+
if (!blockEl || !target) return;
|
|
2624
|
+
// §3.3's membership rules and the whole shared preamble — see
|
|
2625
|
+
// resolveGutterOperands() above. `els` is ONE contiguous span; the plan's
|
|
2626
|
+
// central constraint is that every path below takes that span whole rather
|
|
2627
|
+
// than looping, because a loop re-renders between items and invalidates
|
|
2628
|
+
// every id in between (the defect class recorded in this file twice: a
|
|
2629
|
+
// fenced block raw-edited into two paragraphs changes the BLOCK count
|
|
2630
|
+
// without changing the LINE count, so an id-indexed delete hit the wrong
|
|
2631
|
+
// block, and the same shape silently rewrote a neighbouring table).
|
|
2632
|
+
const operands = await resolveGutterOperands(blockEl);
|
|
2633
|
+
if (!operands) return;
|
|
2634
|
+
const els = operands.els;
|
|
2635
|
+
const recs = operands.recs;
|
|
2636
|
+
const shape = spanListKinds(els);
|
|
2637
|
+
if (shape.anyLi && !shape.allLi) { refuseStructuralListEdit(BATCH_MIXED_MESSAGE); return; }
|
|
2638
|
+
// §3.7 / §7 withhold 轉換成 from some block types ENTIRELY —
|
|
2639
|
+
// toggleGutterMenu() hides the item outright, and the list plus every
|
|
2640
|
+
// reason is in BATCH_CONVERT_WITHHELD_MESSAGES above it. This is the SAME
|
|
2641
|
+
// predicate, asked of a SET: none of those reasons stops applying because
|
|
2642
|
+
// the block happens to be one member of a span, and a batch whose grip is a
|
|
2643
|
+
// paragraph reaches the withheld member anyway. MEASURED, before this gate
|
|
2644
|
+
// existed: a table span wrote '> | A | B |' … / '- | A | B |' with its other
|
|
2645
|
+
// rows as continuations, an hr span wrote '- ---', an html span wrote
|
|
2646
|
+
// '> <div>x</div>'. Nothing is LOST in any of them (they all still lex as
|
|
2647
|
+
// what they were) — which is exactly why it is so quiet: the single-block ⠿
|
|
2648
|
+
// and the batch ⠿ give OPPOSITE answers to the same question with nothing
|
|
2649
|
+
// on screen saying so, the shape §3.6's 2026-08-31 「不得另寫一條」 ruling
|
|
2650
|
+
// exists to prevent. ⇒ REFUSE, with a banner (§3.6: 靜默不動作是缺陷), file
|
|
2651
|
+
// byte-identical.
|
|
2652
|
+
//
|
|
2653
|
+
// The banner names the type that is ACTUALLY in the span, not one of the
|
|
2654
|
+
// four shape-level messages above: BATCH_MIXED names list items vs other
|
|
2655
|
+
// blocks (a paragraph+table span holds no list item at all) and BATCH_GAP
|
|
2656
|
+
// names non-contiguity — either would name something the user's selection
|
|
2657
|
+
// has not done. So would telling a user with an hr in the selection that it
|
|
2658
|
+
// contains a table.
|
|
2659
|
+
//
|
|
2660
|
+
// SCOPED TO 轉換成. 建立副本 / 刪除 / the Delete key / Tab over a span
|
|
2661
|
+
// holding any of these types are measured correct and have no equivalent
|
|
2662
|
+
// objection — they do not need a target that can carry cells, or a marker
|
|
2663
|
+
// to strip — so the predicate deliberately does NOT reach
|
|
2664
|
+
// resolveGutterOperands(), and the T8 sweep's three withheld-type rows keep
|
|
2665
|
+
// those five cells as APPLYING so a widening shows up as a failure too.
|
|
2666
|
+
// Placed BELOW the mixed gate so no span that already refuses changes which
|
|
2667
|
+
// banner it gets.
|
|
2668
|
+
const withheld = shape.kinds.filter(convertWithheldFor)[0];
|
|
2669
|
+
if (withheld) {
|
|
2670
|
+
refuseStructuralListEdit(BATCH_CONVERT_WITHHELD_MESSAGES[withheld]);
|
|
2671
|
+
return;
|
|
2188
2672
|
}
|
|
2189
|
-
// Same refusal deleteBlockViaGutter() makes, for the same reason: a block
|
|
2190
|
-
// that owns no source line has an INVERTED range (endLine === startLine-1),
|
|
2191
|
-
// and every commit helper handed one does something plausible and wrong.
|
|
2192
|
-
if (blockOwnsNoLine(liveBlockEl)) { refuseStructuralListEdit(NO_SOURCE_LINE_MESSAGE); return; }
|
|
2193
|
-
const liveBlockId = Number(liveBlockEl.getAttribute('data-block-id'));
|
|
2194
|
-
const rec = blocks.find((b) => b.id === liveBlockId);
|
|
2195
|
-
if (!rec) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
2196
|
-
const kind = liveBlockEl.getAttribute('data-block-type');
|
|
2197
2673
|
|
|
2198
2674
|
// §4.3's run-wide gate: 轉換/建立副本/刪除/拖曳 all pass through
|
|
2199
2675
|
// listRunSupportsStructuralEdit() BEFORE any mutation, the same door
|
|
2200
2676
|
// Tab/Enter/checkbox already use. Its input is §3.4 rule 2's SCOPE, which
|
|
2201
2677
|
// is exactly what listRunOf() returns (the outermost run PLUS every
|
|
2202
|
-
// descendant of its members) — see
|
|
2678
|
+
// descendant of its members) — see deleteListItemsViaGutter()'s own note.
|
|
2203
2679
|
//
|
|
2204
2680
|
// ORDERING IS LOAD-BEARING, not incidental. This sits AHEAD of the
|
|
2205
|
-
//
|
|
2206
|
-
//
|
|
2207
|
-
//
|
|
2208
|
-
//
|
|
2681
|
+
// refusals below and, further down, of stripMarker(): a multi-line li must
|
|
2682
|
+
// report §4.1's 「此清單含不支援的格式,無法調整結構」 and not
|
|
2683
|
+
// convert-md.js's per-block 「此區塊的格式無法轉換」, which is what it
|
|
2684
|
+
// would get if stripMarker() saw it first (that function refuses a
|
|
2209
2685
|
// multi-line li too, for its own, narrower reason). The runtime scenario
|
|
2210
|
-
// 'a multi-line li refuses with the §4.1 banner' asserts the MESSAGE, so
|
|
2211
|
-
//
|
|
2686
|
+
// 'a multi-line li refuses with the §4.1 banner' asserts the MESSAGE, so it
|
|
2687
|
+
// is what notices if this order is ever flipped.
|
|
2212
2688
|
//
|
|
2213
|
-
// A conversion is NOT column-only (§4.1 修訂 2): it rewrites the item's
|
|
2214
|
-
//
|
|
2215
|
-
// remaining a perfectly good bystander.
|
|
2689
|
+
// A conversion is NOT column-only (§4.1 修訂 2): it rewrites the item's own
|
|
2690
|
+
// text or line count, so a multi-line li refuses as a TARGET while
|
|
2691
|
+
// remaining a perfectly good bystander. S3 Task 6 passes the WHOLE operand
|
|
2692
|
+
// set as the target list — every member is being rewritten, so every member
|
|
2693
|
+
// has to clear the gate, not just the one the ⠿ was pressed on.
|
|
2216
2694
|
let liRun = null;
|
|
2217
|
-
if (
|
|
2218
|
-
liRun =
|
|
2219
|
-
if (!liRun
|
|
2220
|
-
if (!listRunSupportsStructuralEdit(liRun,
|
|
2695
|
+
if (shape.allLi) {
|
|
2696
|
+
liRun = batchRunOf(els);
|
|
2697
|
+
if (!liRun) { refuseStructuralListEdit(BATCH_MULTIRUN_MESSAGE); return; }
|
|
2698
|
+
if (!listRunSupportsStructuralEdit(liRun, els)) { refuseStructuralListEdit(); return; }
|
|
2221
2699
|
}
|
|
2222
2700
|
|
|
2223
|
-
// S2 Task 3: li → a LIST target. The
|
|
2224
|
-
// run and the existing re-serialization machinery applies
|
|
2225
|
-
|
|
2226
|
-
|
|
2701
|
+
// S2 Task 3 / S3 Task 6: li → a LIST target. The blocks stay list items, so
|
|
2702
|
+
// the run stays a run and the existing re-serialization machinery applies
|
|
2703
|
+
// unchanged.
|
|
2704
|
+
if (shape.allLi && convertMd.targetIsList(target)) {
|
|
2705
|
+
await convertListItemsType(els, liRun, recs, target);
|
|
2227
2706
|
return;
|
|
2228
2707
|
}
|
|
2229
|
-
// S2 Task 4: li → a NON-list target. The
|
|
2230
|
-
// has to be rebuilt in three pieces and §4.3 rule 1's blank
|
|
2231
|
-
// between them — the plain path below would leave the converted
|
|
2232
|
-
// mid-list with no separator and lazy continuation would swallow
|
|
2233
|
-
// the item above (measured, §4.3 rule 1).
|
|
2234
|
-
if (
|
|
2235
|
-
await
|
|
2708
|
+
// S2 Task 4 / S3 Task 6: li → a NON-list target. The items LEAVE the run,
|
|
2709
|
+
// so the span has to be rebuilt in three pieces and §4.3 rule 1's blank
|
|
2710
|
+
// lines put between them — the plain path below would leave the converted
|
|
2711
|
+
// lines mid-list with no separator and lazy continuation would swallow them
|
|
2712
|
+
// into the item above (measured, §4.3 rule 1).
|
|
2713
|
+
if (shape.allLi) {
|
|
2714
|
+
await convertListItemsAway(els, liRun, recs, target);
|
|
2236
2715
|
return;
|
|
2237
2716
|
}
|
|
2238
|
-
// S2 Task 5:
|
|
2239
|
-
// policy applies — eat the separator to an adjacent run of
|
|
2240
|
-
// type, or the merged list goes LOOSE and every item of it
|
|
2241
|
-
// read-only. Same rule, same helper the li → li path above uses.
|
|
2717
|
+
// S2 Task 5 / S3 Task 6: non-list blocks BECOME list items, so §4.3 rule
|
|
2718
|
+
// 2's looseness policy applies — eat the separator to an adjacent run of
|
|
2719
|
+
// the same list type, or the merged list goes LOOSE and every item of it
|
|
2720
|
+
// degrades read-only. Same rule, same helper the li → li path above uses.
|
|
2242
2721
|
if (convertMd.targetIsList(target)) {
|
|
2243
|
-
await
|
|
2722
|
+
await convertBlocksIntoList(els, recs, shape.kinds, target);
|
|
2244
2723
|
return;
|
|
2245
2724
|
}
|
|
2246
2725
|
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2726
|
+
// The plain path: N non-list blocks to a non-list target. One commit over
|
|
2727
|
+
// the span's whole line range, each member converted from its OWN source
|
|
2728
|
+
// lines (never re-serialized, which is what keeps a `~5px` a `~5px`) and
|
|
2729
|
+
// the pieces separated by a blank line so they re-lex as N blocks.
|
|
2730
|
+
const first = recs[0];
|
|
2731
|
+
const last = recs[recs.length - 1];
|
|
2732
|
+
const pieces = [];
|
|
2733
|
+
for (let i = 0; i < recs.length; i++) {
|
|
2734
|
+
const src = lines.slice(recs[i].startLine - 1, recs[i].endLine);
|
|
2735
|
+
const stripped = convertMd.stripMarker(src, shape.kinds[i]);
|
|
2736
|
+
if (!stripped.ok) { refuseStructuralListEdit('此區塊的格式無法轉換'); return; }
|
|
2737
|
+
pieces.push(convertMd.emitAs(stripped.content, target, {}).join('\n'));
|
|
2738
|
+
}
|
|
2739
|
+
const md = pieces.join('\n\n');
|
|
2251
2740
|
|
|
2252
2741
|
const result = commitRangeEdit({ lines, blocks, stack },
|
|
2253
|
-
|
|
2742
|
+
first.startLine, last.endLine, md);
|
|
2254
2743
|
// Nothing changed (converting an H2 to 標題 2) — and nothing was pushed
|
|
2255
|
-
// onto the undo stack either, so there is nothing to render or roll back
|
|
2744
|
+
// onto the undo stack either, so there is nothing to render or roll back,
|
|
2745
|
+
// and no selection range to declare.
|
|
2256
2746
|
if (result.op === null) return;
|
|
2257
2747
|
const prevLines = lines;
|
|
2258
2748
|
lines = result.lines;
|
|
2749
|
+
// §3.3: 「操作後集合塌縮為操作結果所涵蓋的行區間」. Declared IMMEDIATELY
|
|
2750
|
+
// before the render, never earlier (Task 5 carry 5): rerenderAll() consumes
|
|
2751
|
+
// the declaration at its very top, so one left standing lands on some
|
|
2752
|
+
// later, unrelated render.
|
|
2753
|
+
declareCollapse({
|
|
2754
|
+
startLine: first.startLine,
|
|
2755
|
+
endLine: first.startLine + md.split('\n').length - 1,
|
|
2756
|
+
});
|
|
2259
2757
|
const okRender = await safeRerenderAll();
|
|
2260
2758
|
if (!okRender) {
|
|
2261
2759
|
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
@@ -2310,7 +2808,7 @@
|
|
|
2310
2808
|
// them (list-md.js:462) and the WHOLE run degrades read-only with no
|
|
2311
2809
|
// banner — §4.3 rule 2's defect, re-opened by a duplicate instead of by a
|
|
2312
2810
|
// conversion. A li therefore duplicates through its own RUN's
|
|
2313
|
-
// re-serialization (
|
|
2811
|
+
// re-serialization (duplicateListItems() below), which emits no blank at
|
|
2314
2812
|
// all and re-runs §3.8's renumbering on the way.
|
|
2315
2813
|
//
|
|
2316
2814
|
// Neither path re-serializes the copy's CONTENT: the non-li path slices
|
|
@@ -2318,43 +2816,39 @@
|
|
|
2318
2816
|
// under the ORIGINAL's block id, so list-md.js replays the file's own bytes
|
|
2319
2817
|
// for it and only re-states the marker. Both keep a `~5px` a `~5px`.
|
|
2320
2818
|
async function duplicateBlockViaMenu(blockEl) {
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
//
|
|
2324
|
-
//
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
if (
|
|
2333
|
-
|
|
2334
|
-
if (!document.body.contains(blockEl)) {
|
|
2335
|
-
liveBlockEl = reresolveBlockEl(identity) ||
|
|
2336
|
-
(selfSession ? reresolveBlockElAfterSelfCommit(identity) : null);
|
|
2337
|
-
if (!liveBlockEl) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
2338
|
-
}
|
|
2339
|
-
// Same refusal deleteBlockViaGutter() and convertBlockViaMenu() make, for
|
|
2340
|
-
// the same reason: a block that owns no source line has an INVERTED range
|
|
2341
|
-
// (endLine === startLine - 1) and every commit helper handed one does
|
|
2342
|
-
// something plausible and wrong.
|
|
2343
|
-
if (blockOwnsNoLine(liveBlockEl)) { refuseStructuralListEdit(NO_SOURCE_LINE_MESSAGE); return; }
|
|
2344
|
-
const liveBlockId = Number(liveBlockEl.getAttribute('data-block-id'));
|
|
2345
|
-
const rec = blocks.find((b) => b.id === liveBlockId);
|
|
2346
|
-
if (!rec) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
2347
|
-
|
|
2348
|
-
if (liveBlockEl.getAttribute('data-block-type') === 'li') {
|
|
2349
|
-
await duplicateListItem(liveBlockEl);
|
|
2819
|
+
// §3.3's membership rules plus the shared preamble — see
|
|
2820
|
+
// resolveGutterOperands(). The whole span is duplicated by ONE commit; a
|
|
2821
|
+
// per-member loop would re-render between items and invalidate every id in
|
|
2822
|
+
// between.
|
|
2823
|
+
const operands = await resolveGutterOperands(blockEl);
|
|
2824
|
+
if (!operands) return;
|
|
2825
|
+
const els = operands.els;
|
|
2826
|
+
const recs = operands.recs;
|
|
2827
|
+
const shape = spanListKinds(els);
|
|
2828
|
+
if (shape.anyLi && !shape.allLi) { refuseStructuralListEdit(BATCH_MIXED_MESSAGE); return; }
|
|
2829
|
+
|
|
2830
|
+
if (shape.allLi) {
|
|
2831
|
+
await duplicateListItems(els, recs);
|
|
2350
2832
|
return;
|
|
2351
2833
|
}
|
|
2352
2834
|
|
|
2353
|
-
|
|
2354
|
-
|
|
2835
|
+
// The non-li span duplicates as ONE slice of `lines` — separators between
|
|
2836
|
+
// its members included, which is what makes the copies re-lex as the same N
|
|
2837
|
+
// blocks rather than one merged one. commitBlockInsertion() anchors on the
|
|
2838
|
+
// LAST member (it inserts BELOW its anchor), and its own leading blank is
|
|
2839
|
+
// the separator between the original span and the copy.
|
|
2840
|
+
const first = recs[0];
|
|
2841
|
+
const last = recs[recs.length - 1];
|
|
2842
|
+
const result = commitBlockInsertion({ lines, blocks, stack }, last.id,
|
|
2843
|
+
lines.slice(first.startLine - 1, last.endLine));
|
|
2355
2844
|
if (result.op === null) return;
|
|
2356
2845
|
const prevLines = lines;
|
|
2357
2846
|
lines = result.lines;
|
|
2847
|
+
// §3.3's collapse. A duplicate inserts BELOW everything it copied, so the
|
|
2848
|
+
// originals keep the exact line range they had — declaring it is the same
|
|
2849
|
+
// answer as "keep what is standing" and says so explicitly, which is what
|
|
2850
|
+
// keeps this correct if the insertion point ever moves.
|
|
2851
|
+
declareCollapse({ startLine: first.startLine, endLine: last.endLine });
|
|
2358
2852
|
if (!(await safeRerenderAll())) {
|
|
2359
2853
|
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
2360
2854
|
}
|
|
@@ -2366,16 +2860,18 @@
|
|
|
2366
2860
|
// §3.8's renumbering falls out of the re-serialization ('1. alpha' duplicated
|
|
2367
2861
|
// gives '1. alpha / 2. alpha / 3. bravo', not '1. alpha / 1. alpha / 2.
|
|
2368
2862
|
// bravo').
|
|
2369
|
-
async function
|
|
2370
|
-
|
|
2371
|
-
|
|
2863
|
+
async function duplicateListItems(liEls, recs) {
|
|
2864
|
+
// Every list batch is one run's problem — see batchRunOf().
|
|
2865
|
+
const run = batchRunOf(liEls);
|
|
2866
|
+
if (!run) { refuseStructuralListEdit(BATCH_MULTIRUN_MESSAGE); return; }
|
|
2372
2867
|
// §4.3's run-wide gate — 轉換/建立副本/刪除/拖曳 each make this call for
|
|
2373
2868
|
// themselves; there is no shared helper. Its input is §3.4 rule 2's scope,
|
|
2374
2869
|
// which is exactly what listRunOf() returns (the outermost run PLUS every
|
|
2375
2870
|
// descendant of its members). A duplicate is NOT column-only (§4.1 修訂 2:
|
|
2376
2871
|
// it adds the item's lines over again), so a multi-line li refuses as a
|
|
2377
|
-
// TARGET while remaining a perfectly good bystander
|
|
2378
|
-
|
|
2872
|
+
// TARGET while remaining a perfectly good bystander — and in a batch EVERY
|
|
2873
|
+
// member is a target.
|
|
2874
|
+
if (!listRunSupportsStructuralEdit(run, liEls)) { refuseStructuralListEdit(); return; }
|
|
2379
2875
|
// Captured BEFORE the copy enters the span. The copy carries the
|
|
2380
2876
|
// ORIGINAL's data-block-id — that is what makes bystanderCarryOver() replay
|
|
2381
2877
|
// its bytes rather than re-escape them — so runRangeOfBlocks() would
|
|
@@ -2388,22 +2884,29 @@
|
|
|
2388
2884
|
// subtreeBlocksAfter() is the flat model's subtree — the contiguous run of
|
|
2389
2885
|
// following blocks at a STRICTLY greater indent — and listRunOf() already
|
|
2390
2886
|
// covers every one of them, so the insertion point is always inside `run`.
|
|
2391
|
-
|
|
2392
|
-
|
|
2887
|
+
// For a BATCH the anchor is the LAST member's subtree, which is the direct
|
|
2888
|
+
// generalization: the copies land after everything the set owns, in the
|
|
2889
|
+
// set's own document order.
|
|
2890
|
+
const anchorLi = liEls[liEls.length - 1];
|
|
2891
|
+
const subtree = subtreeBlocksAfter(anchorLi, Number(anchorLi.getAttribute('data-indent')) || 0);
|
|
2892
|
+
const lastEl = subtree.length ? subtree[subtree.length - 1] : anchorLi;
|
|
2393
2893
|
const at = run.indexOf(lastEl);
|
|
2394
2894
|
if (at < 0) return;
|
|
2395
2895
|
|
|
2396
|
-
const
|
|
2896
|
+
const copies = liEls.map((liEl) => liEl.cloneNode(true));
|
|
2397
2897
|
// `data-list-start` is the ONLY carrier of "marked's lexer opened a new
|
|
2398
2898
|
// list token here" (§3.8 rule (d)) and serializeBlocks() resets the
|
|
2399
2899
|
// ordinal counter on it. A copy is never a token boundary — it sits inside
|
|
2400
2900
|
// the run it was cloned from — so a clone that kept the attribute would
|
|
2401
2901
|
// restart the numbering: duplicating the first item of '1. alpha / 2.
|
|
2402
2902
|
// bravo' emits '1. alpha / 1. alpha / 2. bravo'.
|
|
2403
|
-
copy.removeAttribute('data-list-start');
|
|
2404
|
-
const span = run.slice(0, at + 1).concat(
|
|
2903
|
+
copies.forEach((copy) => copy.removeAttribute('data-list-start'));
|
|
2904
|
+
const span = run.slice(0, at + 1).concat(copies, run.slice(at + 1));
|
|
2405
2905
|
mutateListRun(() => {
|
|
2406
|
-
|
|
2906
|
+
// One fixed reference node, so the copies land in their own order:
|
|
2907
|
+
// insertBefore(c1, ref) then insertBefore(c2, ref) gives lastEl, c1, c2.
|
|
2908
|
+
const ref = lastEl.nextSibling;
|
|
2909
|
+
copies.forEach((copy) => { lastEl.parentNode.insertBefore(copy, ref); });
|
|
2407
2910
|
});
|
|
2408
2911
|
// No `mutatedEl`: nothing in this span had its CONTENT rewritten in the
|
|
2409
2912
|
// DOM, the copy included. The map is what keeps both lines byte-identical
|
|
@@ -2422,6 +2925,15 @@
|
|
|
2422
2925
|
// list-md.js re-states each line's marker from that element's OWN
|
|
2423
2926
|
// attributes — which is the §3.8 renumbering, and which is also how the
|
|
2424
2927
|
// copy keeps its 型態 / 縮排 / 勾選狀態.
|
|
2928
|
+
// §3.3's collapse, declared immediately before the render inside
|
|
2929
|
+
// commitListStructure(). The copies land AFTER everything the set owns and
|
|
2930
|
+
// no member's line COUNT can change (a multi-line target is refused above,
|
|
2931
|
+
// and §3.8's renumbering only ever changes marker WIDTH), so the originals
|
|
2932
|
+
// still occupy exactly the range they occupied before the commit.
|
|
2933
|
+
declareCollapse({
|
|
2934
|
+
startLine: recs[0].startLine,
|
|
2935
|
+
endLine: recs[recs.length - 1].endLine,
|
|
2936
|
+
});
|
|
2425
2937
|
await commitListStructure(span, null, false,
|
|
2426
2938
|
{ presetRange: range, carryOver: bystanderCarryOver(span) });
|
|
2427
2939
|
}
|
|
@@ -2430,64 +2942,45 @@
|
|
|
2430
2942
|
// emptied-out list). Same resolve-first / re-query-live-block-by-id
|
|
2431
2943
|
// precondition as insertBlockBelow() above.
|
|
2432
2944
|
async function deleteBlockViaGutter(blockEl) {
|
|
2433
|
-
|
|
2434
|
-
//
|
|
2435
|
-
//
|
|
2436
|
-
//
|
|
2437
|
-
// pointing at a
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
// Measured before this line existed: edit a paragraph, press ⠿ without
|
|
2446
|
-
// blurring, 刪除 — the gesture was dropped with '文件已更新,請重試這個操作'
|
|
2447
|
-
// and the block stayed on screen. The narrowed re-resolve (startLine +
|
|
2448
|
-
// type, no fingerprint) is used ONLY when the session that just committed
|
|
2449
|
-
// was this block's OWN; reresolveBlockEl() keeps its fingerprint for
|
|
2450
|
-
// everybody else.
|
|
2451
|
-
const selfSession = ownsOpenSession(blockEl);
|
|
2452
|
-
const ok = await switchAwayFrom();
|
|
2453
|
-
if (!ok) return;
|
|
2454
|
-
let liveBlockEl = blockEl;
|
|
2455
|
-
if (!document.body.contains(blockEl)) {
|
|
2456
|
-
liveBlockEl = reresolveBlockEl(identity) ||
|
|
2457
|
-
(selfSession ? reresolveBlockElAfterSelfCommit(identity) : null);
|
|
2458
|
-
if (!liveBlockEl) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
|
|
2459
|
-
}
|
|
2460
|
-
const liveBlockId = Number(liveBlockEl.getAttribute('data-block-id'));
|
|
2461
|
-
const block = blocks.find((b) => b.id === liveBlockId);
|
|
2462
|
-
if (!block) return;
|
|
2463
|
-
// Task 4 fix round 1 (Critical): refuse a block that owns no source line.
|
|
2464
|
-
// Its range is INVERTED (endLine === startLine - 1 — see blockOwnsNoLine()),
|
|
2465
|
-
// and commitListBlockRemoval() -> commitRangeRemoval() does not guard
|
|
2466
|
-
// `endLine >= startLine`: with sl=5, el=4 the blank-line absorption reads
|
|
2467
|
-
// state.lines[sl - 2], which for an inverted range is a blank line
|
|
2468
|
-
// belonging to a DIFFERENT block, finds it blank, and deletes it. Nothing
|
|
2469
|
-
// visible happens — no error, no banner, the .ed-block count is unchanged
|
|
2470
|
-
// — but the file loses a separator (measured: '# Doc\n\n- a\n\n- - b\n'
|
|
2471
|
-
// -> '# Doc\n\n- a\n- - b\n').
|
|
2472
|
-
//
|
|
2473
|
-
// Same predicate canWysiwygForLi() already refuses on, so "cannot be
|
|
2474
|
-
// armed" and "cannot be deleted" stay one decision. The ⠿ itself is NOT
|
|
2475
|
-
// gated on it: Task 4 requires every block to have a handle, and hiding it
|
|
2476
|
-
// would trade that requirement for a delete-path bug. Re-checked against
|
|
2477
|
-
// the LIVE block, after switchAwayFrom()'s possible re-render renumbered
|
|
2478
|
-
// the ids.
|
|
2479
|
-
if (blockOwnsNoLine(liveBlockEl)) { refuseStructuralListEdit(NO_SOURCE_LINE_MESSAGE); return; }
|
|
2945
|
+
// §3.3's membership rules plus the shared preamble (identity capture,
|
|
2946
|
+
// switchAwayFrom(), the re-resolve after a commit that re-rendered, and the
|
|
2947
|
+
// no-source-line refusal) — see resolveGutterOperands(). The stake on the
|
|
2948
|
+
// re-resolve is highest here: an id shift used to make this delete a
|
|
2949
|
+
// DIFFERENT block's lines, with the ⠿ menu the user pressed pointing at a
|
|
2950
|
+
// block that survived.
|
|
2951
|
+
const operands = await resolveGutterOperands(blockEl);
|
|
2952
|
+
if (!operands) return;
|
|
2953
|
+
const els = operands.els;
|
|
2954
|
+
const recs = operands.recs;
|
|
2955
|
+
const shape = spanListKinds(els);
|
|
2956
|
+
if (shape.anyLi && !shape.allLi) { refuseStructuralListEdit(BATCH_MIXED_MESSAGE); return; }
|
|
2480
2957
|
// Spec §6, "S1 期間的已知危險" item 1: a LIST ITEM's delete is not a line
|
|
2481
2958
|
// splice. S1 is what first put a ⠿ on a li, and the plain range removal
|
|
2482
2959
|
// below corrupts a list three separate ways — see
|
|
2483
|
-
//
|
|
2484
|
-
if (
|
|
2485
|
-
await
|
|
2960
|
+
// deleteListItemsViaGutter() for the measurements and the routing.
|
|
2961
|
+
if (shape.allLi) {
|
|
2962
|
+
await deleteListItemsViaGutter(els);
|
|
2486
2963
|
return;
|
|
2487
2964
|
}
|
|
2488
|
-
|
|
2965
|
+
// A contiguous span of non-list blocks is one line range: the separators
|
|
2966
|
+
// between its members are inside it by construction (members are adjacent
|
|
2967
|
+
// in `blocks`, so nothing else lives between them), and commitRangeRemoval()
|
|
2968
|
+
// absorbs exactly one adjacent blank on the outside — the same blank-line
|
|
2969
|
+
// contract commitListBlockRemoval() documents, which is literally this call
|
|
2970
|
+
// for a single block. The one-block case keeps going through that wrapper so
|
|
2971
|
+
// the shipped single-block path is byte-for-byte the S2 one.
|
|
2972
|
+
const first = recs[0];
|
|
2973
|
+
const last = recs[recs.length - 1];
|
|
2974
|
+
const result = els.length === 1
|
|
2975
|
+
? commitListBlockRemoval({ lines, blocks, stack }, first.id)
|
|
2976
|
+
: commitRangeRemoval({ lines, blocks, stack }, first.startLine, last.endLine);
|
|
2489
2977
|
const prevLines = lines;
|
|
2490
2978
|
lines = result.lines;
|
|
2979
|
+
// §3.3's collapse: a delete's result covers NO lines, so the set is cleared
|
|
2980
|
+
// rather than collapsed (Task 1 carry 5 — collapseTo() answers null for an
|
|
2981
|
+
// inverted range, and §4.4 says a range that no longer resolves clears).
|
|
2982
|
+
// Declared immediately before the render, never earlier (Task 5 carry 5).
|
|
2983
|
+
declareCollapse(null);
|
|
2491
2984
|
const okRender = await safeRerenderAll();
|
|
2492
2985
|
if (!okRender) {
|
|
2493
2986
|
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
@@ -2522,31 +3015,44 @@
|
|
|
2522
3015
|
// commit the re-serialized survivors over that range with every one of them
|
|
2523
3016
|
// carried over verbatim — nothing here rewrites any survivor's CONTENT, only
|
|
2524
3017
|
// its marker and its leading columns.
|
|
2525
|
-
async function
|
|
2526
|
-
|
|
2527
|
-
|
|
3018
|
+
async function deleteListItemsViaGutter(liEls) {
|
|
3019
|
+
// Every list batch is one run's problem — see batchRunOf().
|
|
3020
|
+
const run = batchRunOf(liEls);
|
|
3021
|
+
if (!run) { refuseStructuralListEdit(BATCH_MULTIRUN_MESSAGE); return; }
|
|
2528
3022
|
// §4.3's run-wide gate, whose input is §3.4 rule 2's scope — which is
|
|
2529
3023
|
// exactly what listRunOf() returns (the outermost run PLUS every
|
|
2530
3024
|
// descendant of its members), so the deeper runs this delete is about to
|
|
2531
3025
|
// re-indent are covered, not just the target's own. Deleting is NOT
|
|
2532
3026
|
// column-only: it removes the target's lines outright, so a multi-line
|
|
2533
|
-
// target refuses per §4.1.
|
|
2534
|
-
if (!listRunSupportsStructuralEdit(run,
|
|
3027
|
+
// target refuses per §4.1 — and in a batch every member is a target.
|
|
3028
|
+
if (!listRunSupportsStructuralEdit(run, liEls)) { refuseStructuralListEdit(); return; }
|
|
2535
3029
|
const range = runRangeOfBlocks({ lines, blocks, stack }, run);
|
|
2536
3030
|
if (!range) return;
|
|
2537
|
-
|
|
2538
|
-
|
|
3031
|
+
// §3.4 rule 3's batch anchor: the SMALLEST old indent in the set, never the
|
|
3032
|
+
// first member's — see spanMinIndent().
|
|
3033
|
+
const oldIndent = spanMinIndent(liEls);
|
|
3034
|
+
const survivors = run.filter((el) => liEls.indexOf(el) === -1);
|
|
2539
3035
|
mutateListRun(() => {
|
|
2540
|
-
// Clamp FIRST, while
|
|
2541
|
-
// what tells the pure function that
|
|
2542
|
-
// anything, and rule 2's scope is measured from
|
|
2543
|
-
|
|
2544
|
-
|
|
3036
|
+
// Clamp FIRST, while every member is still in the span: `{ removed: true }`
|
|
3037
|
+
// is what tells the pure function that these blocks can no longer anchor
|
|
3038
|
+
// anything, and rule 2's scope is measured from the LAST of their
|
|
3039
|
+
// positions. ONE call for the whole set, not one per member: clampIndents()
|
|
3040
|
+
// takes an array of operated indices precisely so a batch computes one
|
|
3041
|
+
// segment delta per §3.4 rule 3 instead of N independent clamps, which is
|
|
3042
|
+
// what splits the user's siblings apart.
|
|
3043
|
+
applyIndentClamp(run, liEls, oldIndent, { removed: true });
|
|
3044
|
+
liEls.forEach((el) => removeListItem(el));
|
|
2545
3045
|
});
|
|
2546
3046
|
// No `mutatedEl`: the deleted block is not among the survivors, and every
|
|
2547
3047
|
// survivor's own bytes are exactly what the file already holds. The marker
|
|
2548
3048
|
// is re-stated by the serializer regardless of the carry-over, which is
|
|
2549
3049
|
// what renumbers the run (§3.8) and applies the clamped indent.
|
|
3050
|
+
//
|
|
3051
|
+
// §3.3's collapse: a delete's result covers no lines, so the set is cleared.
|
|
3052
|
+
// Declared immediately before commitListStructure(), whose only render on
|
|
3053
|
+
// this path is the one it makes after the commit — the `!range` bail above
|
|
3054
|
+
// it cannot fire, `presetRange` is non-null by construction here.
|
|
3055
|
+
declareCollapse(null);
|
|
2550
3056
|
await commitListStructure(survivors, null, false,
|
|
2551
3057
|
{ presetRange: range, carryOver: bystanderCarryOver(survivors) });
|
|
2552
3058
|
}
|
|
@@ -2604,7 +3110,7 @@
|
|
|
2604
3110
|
// BETWEEN two runs, to neither. So the commit range has to be widened
|
|
2605
3111
|
// past runRangeOfBlocks(listRunOf(...)) explicitly. This is one of only
|
|
2606
3112
|
// two places where that happens (§3.4's 2026-08-30 erratum); the other is
|
|
2607
|
-
// §4.3 rule 1's edge blanks in
|
|
3113
|
+
// §4.3 rule 1's edge blanks in convertListItemsAway() above.
|
|
2608
3114
|
//
|
|
2609
3115
|
// 2. The run-wide gate has to hold for BOTH runs. Merging a DEGRADED run
|
|
2610
3116
|
// into a healthy one freezes the healthy one too — and declining to merge
|
|
@@ -2684,18 +3190,18 @@
|
|
|
2684
3190
|
return { startLine: startLine, endLine: endLine, ok: true };
|
|
2685
3191
|
}
|
|
2686
3192
|
|
|
2687
|
-
// The {listType, indent} a span member will carry once
|
|
2688
|
-
// `attrs`. Everything
|
|
2689
|
-
function postConvertLiAttrs(el,
|
|
3193
|
+
// The {listType, indent} a span member will carry once the operand set
|
|
3194
|
+
// `liEls` has become `attrs`. Everything outside the set keeps what it has.
|
|
3195
|
+
function postConvertLiAttrs(el, liEls, attrs) {
|
|
2690
3196
|
return {
|
|
2691
|
-
listType: el
|
|
3197
|
+
listType: liEls.indexOf(el) !== -1
|
|
2692
3198
|
? attrs.listType
|
|
2693
3199
|
: (el.getAttribute('data-list-type') === 'ol' ? 'ol' : 'ul'),
|
|
2694
3200
|
indent: Number(el.getAttribute('data-indent')) || 0,
|
|
2695
3201
|
};
|
|
2696
3202
|
}
|
|
2697
3203
|
|
|
2698
|
-
async function
|
|
3204
|
+
async function convertListItemsType(liEls, run, recs, target) {
|
|
2699
3205
|
const range = runRangeOfBlocks({ lines, blocks, stack }, run);
|
|
2700
3206
|
if (!range) return;
|
|
2701
3207
|
const attrs = convertMd.listAttrsFor(target);
|
|
@@ -2719,22 +3225,30 @@
|
|
|
2719
3225
|
if ((Number(el.getAttribute('data-indent')) || 0) === headIndent) tailEl = el;
|
|
2720
3226
|
});
|
|
2721
3227
|
const merged = widenRangeForListMerge(range, run,
|
|
2722
|
-
postConvertLiAttrs(headEl,
|
|
3228
|
+
postConvertLiAttrs(headEl, liEls, attrs), postConvertLiAttrs(tailEl, liEls, attrs));
|
|
2723
3229
|
if (!merged.ok) { refuseStructuralListEdit(); return; }
|
|
3230
|
+
// How far the run's own first line MOVES. widenRangeForListMerge() only ever
|
|
3231
|
+
// widens the range BACKWARDS over blank separators (§4.3 rule 2's tight
|
|
3232
|
+
// merge), and the markdown written over the widened range is the run's own
|
|
3233
|
+
// serialization with no leading blank — so every line of the run shifts up
|
|
3234
|
+
// by exactly this much, and the collapse range below has to shift with it.
|
|
3235
|
+
const shift = merged.startLine - range.startLine;
|
|
2724
3236
|
range.startLine = merged.startLine;
|
|
2725
3237
|
range.endLine = merged.endLine;
|
|
2726
3238
|
mutateListRun(() => {
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
box
|
|
2737
|
-
|
|
3239
|
+
liEls.forEach((liEl) => {
|
|
3240
|
+
liEl.setAttribute('data-list-type', attrs.listType);
|
|
3241
|
+
liEl.setAttribute('data-task', attrs.task ? '1' : '0');
|
|
3242
|
+
const box = liCheckEl(liEl);
|
|
3243
|
+
if (attrs.task) {
|
|
3244
|
+
// Insert BEFORE the surface: §4.1 fixes the child order as
|
|
3245
|
+
// marker → check → text, and list-md.js's firstChildWithClass() plus
|
|
3246
|
+
// the delegated checkbox-toggle listener both assume it.
|
|
3247
|
+
if (!box) liEl.insertBefore(buildLiCheckbox(), liTextEl(liEl));
|
|
3248
|
+
} else if (box) {
|
|
3249
|
+
box.remove();
|
|
3250
|
+
}
|
|
3251
|
+
});
|
|
2738
3252
|
});
|
|
2739
3253
|
// NO `mutatedEl` — deliberately, and this contradicts the plan's Task 3
|
|
2740
3254
|
// sketch, which passes `liEl`. bystanderCarryOver(span, mutatedEl)
|
|
@@ -2750,6 +3264,15 @@
|
|
|
2750
3264
|
// a carried line, i.e. it re-states the marker from the NEW attributes,
|
|
2751
3265
|
// and SRC_MARKER_RE eats the old bullet AND the old GFM checkbox off the
|
|
2752
3266
|
// carried source. That is what makes '- [x] alpha' → '- alpha' work.
|
|
3267
|
+
//
|
|
3268
|
+
// §3.3's collapse: the operand set stays exactly where it is — the members
|
|
3269
|
+
// are still list items in the same run and only their MARKERS are re-stated,
|
|
3270
|
+
// so no line count inside the run can change. Only the merge widening moves
|
|
3271
|
+
// them, by `shift`.
|
|
3272
|
+
declareCollapse({
|
|
3273
|
+
startLine: recs[0].startLine + shift,
|
|
3274
|
+
endLine: recs[recs.length - 1].endLine + shift,
|
|
3275
|
+
});
|
|
2753
3276
|
await commitListStructure(run, null, false,
|
|
2754
3277
|
{ presetRange: range, carryOver: bystanderCarryOver(run) });
|
|
2755
3278
|
}
|
|
@@ -2788,17 +3311,39 @@
|
|
|
2788
3311
|
// run-wide veto and re-checked against the TARGET's line range only).
|
|
2789
3312
|
// Measured; the 'a multi-line bystander is replayed, not refused' scenario
|
|
2790
3313
|
// is what notices.
|
|
2791
|
-
async function
|
|
3314
|
+
async function convertListItemsAway(liEls, run, recs, target) {
|
|
2792
3315
|
const range = runRangeOfBlocks({ lines, blocks, stack }, run);
|
|
2793
3316
|
if (!range) return;
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
3317
|
+
// Each member's own bytes, read from `lines` and never re-serialized — that
|
|
3318
|
+
// is what keeps a `~5px` a `~5px`. Joined by a blank line so N converted
|
|
3319
|
+
// items re-lex as N blocks and not one lazy continuation of the first.
|
|
3320
|
+
const convertedPieces = [];
|
|
3321
|
+
for (let i = 0; i < liEls.length; i++) {
|
|
3322
|
+
const rec = recs[i];
|
|
3323
|
+
const stripped = convertMd.stripMarker(lines.slice(rec.startLine - 1, rec.endLine), 'li');
|
|
3324
|
+
if (!stripped.ok) { refuseStructuralListEdit('此區塊的格式無法轉換'); return; }
|
|
3325
|
+
convertedPieces.push(convertMd.emitAs(stripped.content, target, {}).join('\n'));
|
|
3326
|
+
}
|
|
3327
|
+
const convertedMd = convertedPieces.join('\n\n');
|
|
3328
|
+
|
|
3329
|
+
// §3.4 rule 3's batch anchor — the SMALLEST old indent in the set.
|
|
3330
|
+
const oldIndent = spanMinIndent(liEls);
|
|
3331
|
+
// ONE contiguous index range, never a loop over `run.indexOf(member)`
|
|
3332
|
+
// per item: the run is split ONCE into the survivors before the set and the
|
|
3333
|
+
// survivors after it. The operand set is contiguous in `blocks` (checked by
|
|
3334
|
+
// spanIsContiguous() before any of this) and the run is a contiguous slice
|
|
3335
|
+
// of the same list, so the two indices below bracket exactly `liEls.length`
|
|
3336
|
+
// members — asserted rather than assumed, because this arithmetic is what
|
|
3337
|
+
// writes the bytes and an off-by-one here silently re-serializes a
|
|
3338
|
+
// bystander into the converted half.
|
|
3339
|
+
const idx = run.indexOf(liEls[0]);
|
|
3340
|
+
const lastIdx = run.indexOf(liEls[liEls.length - 1]);
|
|
3341
|
+
if (idx < 0 || lastIdx < idx || lastIdx - idx + 1 !== liEls.length) {
|
|
3342
|
+
refuseStructuralListEdit(BATCH_GAP_MESSAGE);
|
|
3343
|
+
return;
|
|
3344
|
+
}
|
|
2800
3345
|
const before = run.slice(0, idx);
|
|
2801
|
-
const after = run.slice(
|
|
3346
|
+
const after = run.slice(lastIdx + 1);
|
|
2802
3347
|
|
|
2803
3348
|
// §3.4, and the FIRST production caller of the pure clamp's
|
|
2804
3349
|
// `operatedBecomes` branch (RULING T6-B). `liEl` stays in the span — the
|
|
@@ -2824,17 +3369,21 @@
|
|
|
2824
3369
|
// The 'the §3.4 segment deltas survive the split commit' scenario is that
|
|
2825
3370
|
// shape, and it is the one that goes red without this option.
|
|
2826
3371
|
mutateListRun(() => {
|
|
2827
|
-
applyIndentClamp(run,
|
|
3372
|
+
applyIndentClamp(run, liEls, oldIndent, { operatedBecomes: { type: convertedBlockType(target) } });
|
|
2828
3373
|
});
|
|
2829
3374
|
|
|
2830
3375
|
// No `mutatedEl`: the converted block is in neither half, and every
|
|
2831
3376
|
// survivor's bytes are exactly what the file already holds. Naming a block
|
|
2832
3377
|
// here EXCLUDES it from the replay map, which is what sends its content
|
|
2833
|
-
// back through escapeText() — see
|
|
3378
|
+
// back through escapeText() — see convertListItemsType()'s note.
|
|
2834
3379
|
const carry = bystanderCarryOver(before.concat(after));
|
|
2835
3380
|
const pieces = [];
|
|
2836
3381
|
if (before.length) pieces.push(listMd.serializeBlocks(before, { carryOver: carry }).md);
|
|
2837
|
-
|
|
3382
|
+
// Which piece the converted blocks are — the collapse range below counts
|
|
3383
|
+
// emitted LINES up to it, and "0 or 1" is only true while `before` is the
|
|
3384
|
+
// one optional piece ahead of it.
|
|
3385
|
+
const convertedPieceIdx = pieces.length;
|
|
3386
|
+
pieces.push(convertedMd);
|
|
2838
3387
|
if (after.length) pieces.push(listMd.serializeBlocks(after, { carryOver: carry }).md);
|
|
2839
3388
|
let md = pieces.join('\n\n');
|
|
2840
3389
|
|
|
@@ -2845,15 +3394,27 @@
|
|
|
2845
3394
|
// 3), in which case '- alpha / bravo' re-lexes as one item. The blank is
|
|
2846
3395
|
// added only when the neighbour is not already blank, which is also what
|
|
2847
3396
|
// 「正規化連續空行」 amounts to here: no double separator is ever created.
|
|
3397
|
+
let lead = 0;
|
|
2848
3398
|
if (!before.length && range.startLine > 1 &&
|
|
2849
|
-
String(lines[range.startLine - 2]).trim() !== '') md = '\n' + md;
|
|
3399
|
+
String(lines[range.startLine - 2]).trim() !== '') { md = '\n' + md; lead = 1; }
|
|
2850
3400
|
if (!after.length && range.endLine < lines.length &&
|
|
2851
3401
|
String(lines[range.endLine]).trim() !== '') md = md + '\n';
|
|
2852
3402
|
|
|
3403
|
+
// §3.3's collapse: 「操作後集合塌縮為操作結果所涵蓋的行區間」— the CONVERTED
|
|
3404
|
+
// blocks' own lines, not the whole commit range (which is the entire run,
|
|
3405
|
+
// bystanders included). Counted in emitted lines: every '\n\n' join
|
|
3406
|
+
// between two pieces adds one blank line on top of the piece's own lines,
|
|
3407
|
+
// and `lead` is the one §4.3 rule 1 puts at the very front.
|
|
3408
|
+
let offset = lead;
|
|
3409
|
+
for (let k = 0; k < convertedPieceIdx; k++) offset += pieces[k].split('\n').length + 1;
|
|
3410
|
+
const outStart = range.startLine + offset;
|
|
3411
|
+
const outEnd = outStart + convertedMd.split('\n').length - 1;
|
|
3412
|
+
|
|
2853
3413
|
const result = commitRangeEdit({ lines, blocks, stack }, range.startLine, range.endLine, md);
|
|
2854
3414
|
if (result.op === null) return;
|
|
2855
3415
|
const prevLines = lines;
|
|
2856
3416
|
lines = result.lines;
|
|
3417
|
+
declareCollapse({ startLine: outStart, endLine: outEnd });
|
|
2857
3418
|
if (!(await safeRerenderAll())) {
|
|
2858
3419
|
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
2859
3420
|
}
|
|
@@ -2873,26 +3434,44 @@
|
|
|
2873
3434
|
// to no run. The gate that DOES apply is the one inside
|
|
2874
3435
|
// widenRangeForListMerge(), on whichever neighbouring run this block is
|
|
2875
3436
|
// about to merge into.
|
|
2876
|
-
async function
|
|
3437
|
+
async function convertBlocksIntoList(blockEls, recs, kinds, target) {
|
|
2877
3438
|
const attrs = convertMd.listAttrsFor(target);
|
|
2878
3439
|
if (!attrs) return;
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
3440
|
+
// One item per member, joined by a bare newline: the members' own
|
|
3441
|
+
// separators are INSIDE the commit range and are replaced, which is what
|
|
3442
|
+
// makes N paragraphs one TIGHT list. A blank between them would make the
|
|
3443
|
+
// list loose and degrade every item read-only (§4.3 rule 2).
|
|
3444
|
+
const pieces = [];
|
|
3445
|
+
for (let i = 0; i < recs.length; i++) {
|
|
3446
|
+
const stripped = convertMd.stripMarker(
|
|
3447
|
+
lines.slice(recs[i].startLine - 1, recs[i].endLine), kinds[i]);
|
|
3448
|
+
if (!stripped.ok) { refuseStructuralListEdit('此區塊的格式無法轉換'); return; }
|
|
3449
|
+
pieces.push(convertMd.emitAs(stripped.content, target, {}).join('\n'));
|
|
3450
|
+
}
|
|
3451
|
+
const md = pieces.join('\n');
|
|
3452
|
+
const first = recs[0];
|
|
3453
|
+
const last = recs[recs.length - 1];
|
|
2882
3454
|
|
|
2883
3455
|
// emitAs() puts a list target at column 0 with no indent prefix, so the
|
|
2884
|
-
//
|
|
2885
|
-
// edges — it emits one item, however many physical lines
|
|
3456
|
+
// span's post-conversion identity is (target list type, indent 0) on both
|
|
3457
|
+
// edges — it emits one item per member, however many physical lines each
|
|
3458
|
+
// item spans.
|
|
2886
3459
|
const self = { listType: attrs.listType, indent: 0 };
|
|
2887
3460
|
const merged = widenRangeForListMerge(
|
|
2888
|
-
{ startLine:
|
|
3461
|
+
{ startLine: first.startLine, endLine: last.endLine }, blockEls, self, self);
|
|
2889
3462
|
if (!merged.ok) { refuseStructuralListEdit(); return; }
|
|
2890
3463
|
|
|
2891
3464
|
const result = commitRangeEdit({ lines, blocks, stack },
|
|
2892
|
-
merged.startLine, merged.endLine,
|
|
3465
|
+
merged.startLine, merged.endLine, md);
|
|
2893
3466
|
if (result.op === null) return;
|
|
2894
3467
|
const prevLines = lines;
|
|
2895
3468
|
lines = result.lines;
|
|
3469
|
+
// §3.3's collapse: the emitted items occupy the whole (possibly widened)
|
|
3470
|
+
// commit range — nothing else is written here.
|
|
3471
|
+
declareCollapse({
|
|
3472
|
+
startLine: merged.startLine,
|
|
3473
|
+
endLine: merged.startLine + md.split('\n').length - 1,
|
|
3474
|
+
});
|
|
2896
3475
|
if (!(await safeRerenderAll())) {
|
|
2897
3476
|
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
2898
3477
|
}
|
|
@@ -3308,6 +3887,408 @@
|
|
|
3308
3887
|
return document.querySelector('.ed-block[data-block-id="' + target.id + '"]');
|
|
3309
3888
|
}
|
|
3310
3889
|
|
|
3890
|
+
// ── S3 Task 2: block multi-select state ────────────────────────────────
|
|
3891
|
+
// The selection's identity is a LINE RANGE, never ids and never nodes.
|
|
3892
|
+
// buildBlockMap renumbers every id from 0 on every render (blockmap.js's
|
|
3893
|
+
// `nextId = {v:0}`) and every batch operation triggers a full
|
|
3894
|
+
// rerenderAll(), so an id or an element held across a commit is a dangling
|
|
3895
|
+
// reference into a document that no longer exists — the same reasoning
|
|
3896
|
+
// blockElAtLine() above is written down for. All the range arithmetic and
|
|
3897
|
+
// §3.3's membership rules live in the pure, node-tested
|
|
3898
|
+
// lib/editor/selection.js (window.md2docSelection); this file only paints.
|
|
3899
|
+
let blockSelection = null; // { anchorLine, focusLine } | null
|
|
3900
|
+
|
|
3901
|
+
// §4.4: the focus endpoint's block element. Null when the selection's focus
|
|
3902
|
+
// line is not (or is no longer) some block's startLine — the caller then
|
|
3903
|
+
// simply holds no focus rather than guessing at a neighbour.
|
|
3904
|
+
function selectionFocusBlockEl() {
|
|
3905
|
+
if (!blockSelection) return null;
|
|
3906
|
+
return blockElAtLine(blockSelection.focusLine);
|
|
3907
|
+
}
|
|
3908
|
+
|
|
3909
|
+
// Repaints `.ed-selected` and the roving tabindex from `blockSelection`.
|
|
3910
|
+
// Idempotent and total: it clears both attributes off EVERY block first, so
|
|
3911
|
+
// it is equally the "apply" and the "clear" path and no stale tint can
|
|
3912
|
+
// survive a state change.
|
|
3913
|
+
function applySelectionClasses() {
|
|
3914
|
+
const members = blockSelection && selectionLib
|
|
3915
|
+
? selectionLib.membersOf(blockSelection, blocks) : [];
|
|
3916
|
+
const ids = new Set(members.map((b) => String(b.id)));
|
|
3917
|
+
for (const el of allBlockEls()) {
|
|
3918
|
+
el.classList.toggle('ed-selected', ids.has(el.getAttribute('data-block-id')));
|
|
3919
|
+
el.removeAttribute('tabindex');
|
|
3920
|
+
}
|
|
3921
|
+
// §4.4 wants a REAL focus holder: with focus left on <body> the keydown
|
|
3922
|
+
// dispatch has nothing to anchor on and the browser's own Tab order walks
|
|
3923
|
+
// straight past the selection. A ROVING tabindex="-1" (exactly one block
|
|
3924
|
+
// focusable at a time, moved as the focus endpoint moves) is the standard
|
|
3925
|
+
// answer, and -1 rather than 0 keeps every block out of the sequential Tab
|
|
3926
|
+
// order — Tab inside the editor is already a structural key. There is no
|
|
3927
|
+
// other tabindex anywhere in lib/; this is greenfield.
|
|
3928
|
+
//
|
|
3929
|
+
// Focusing a .ed-block is inert for the burst machinery: the delegated
|
|
3930
|
+
// focusin handler bails unless the target closes onto .ed-wys-cell or
|
|
3931
|
+
// .ed-wys-armed, and the block WRAPPER is neither (armEditables() arms
|
|
3932
|
+
// blockContentEl(), a child).
|
|
3933
|
+
const focusEl = selectionFocusBlockEl();
|
|
3934
|
+
if (focusEl) { focusEl.setAttribute('tabindex', '-1'); focusEl.focus(); }
|
|
3935
|
+
}
|
|
3936
|
+
|
|
3937
|
+
// S3 Task 4: the keyboard's anchor for §4.4 entry (c) once the set is gone.
|
|
3938
|
+
// MEASURED on 2026-08-30, contradicting Task 2 carry 7 / Task 3 carry 7:
|
|
3939
|
+
// removing the roving `tabindex` from the block that currently has DOM
|
|
3940
|
+
// focus BLURS it in Chromium (`document.activeElement` becomes <body>), so
|
|
3941
|
+
// applySelectionClasses()'s "clear both attributes off EVERY block first"
|
|
3942
|
+
// means a cleared selection leaves NO focused block behind. Without this
|
|
3943
|
+
// memory there is nothing for Shift+↑↓ to resume from after an Escape: the
|
|
3944
|
+
// caret's own surface owns those keys (they are the browser's
|
|
3945
|
+
// extend-the-text-selection gesture, and the burst short-circuit keeps them
|
|
3946
|
+
// that way), and <body> has no block to anchor on.
|
|
3947
|
+
let lastSelectionFocusLine = null;
|
|
3948
|
+
|
|
3949
|
+
function setBlockSelection(sel) {
|
|
3950
|
+
if (sel && Number.isFinite(Number(sel.focusLine))) lastSelectionFocusLine = Number(sel.focusLine);
|
|
3951
|
+
blockSelection = sel;
|
|
3952
|
+
applySelectionClasses();
|
|
3953
|
+
}
|
|
3954
|
+
function clearBlockSelection() { blockSelection = null; applySelectionClasses(); }
|
|
3955
|
+
|
|
3956
|
+
// ── S3 Task 5: surviving rerenderAll() (§4.4's ordered three steps) ────
|
|
3957
|
+
// `contentEl.innerHTML` is replaced wholesale on every commit, so the
|
|
3958
|
+
// painted tint and the roving focus holder are destroyed by definition —
|
|
3959
|
+
// MEASURED on this branch: with a selection over lines 3–4 standing, a
|
|
3960
|
+
// rerenderAll() leaves `blockSelection` intact (its identity is a line
|
|
3961
|
+
// range, which no render can invalidate) but the fresh server HTML carries
|
|
3962
|
+
// no `.ed-selected` and no `tabindex`, so `document.activeElement` is
|
|
3963
|
+
// `<body>` and the keyboard is dead while the model still says two blocks
|
|
3964
|
+
// are selected. The model and the paint drift apart, and every batch
|
|
3965
|
+
// operation in Tasks 6/7 ends in exactly this swap.
|
|
3966
|
+
//
|
|
3967
|
+
// NOTE for the plan's own wording: it says "without the rebuild the tint
|
|
3968
|
+
// survives (it is re-derived from lines) but focus falls back to <body>".
|
|
3969
|
+
// Only the second half is true. Nothing re-derives the tint — the swap
|
|
3970
|
+
// simply throws the classes away with the nodes that carried them.
|
|
3971
|
+
//
|
|
3972
|
+
// §4.4 also says each structural operation DECLARES the line range it
|
|
3973
|
+
// produced, and that a range which no longer resolves clears the selection
|
|
3974
|
+
// rather than leaving it dangling. `pendingSelectionRange` is that
|
|
3975
|
+
// declaration: `undefined` means "nothing declared, keep whatever is
|
|
3976
|
+
// standing" (a burst commit must not destroy a selection it never touched),
|
|
3977
|
+
// `null` means "clear" (undo/redo), and a `{startLine, endLine}` means
|
|
3978
|
+
// "collapse to this" (Tasks 6/7's batch operations).
|
|
3979
|
+
let pendingSelectionRange;
|
|
3980
|
+
function declareSelectionRange(range) { pendingSelectionRange = range; }
|
|
3981
|
+
|
|
3982
|
+
// §4.4 step 3: the rebuilt set must have a REAL focus holder, not <body>.
|
|
3983
|
+
// A declared range's focus endpoint is a LINE, and after a render that line
|
|
3984
|
+
// is often the INSIDE of a block rather than its startLine — collapseTo()
|
|
3985
|
+
// hands back the range's endLine, and a table or a fence owns four lines
|
|
3986
|
+
// for one block. selectionFocusBlockEl() answers null for such a line
|
|
3987
|
+
// (Task 2 carry 5), i.e. a selection with no focus holder and a dead
|
|
3988
|
+
// keyboard, which is the whole failure this task exists to prevent. So the
|
|
3989
|
+
// focus endpoint is snapped onto the startLine of the member it lands in,
|
|
3990
|
+
// on the side it was already on so the gesture stays reversible.
|
|
3991
|
+
//
|
|
3992
|
+
// The snap is REFUSED if it would change the member set: Tasks 6/7 compute
|
|
3993
|
+
// the batch anchor from these members and that anchor writes bytes, so a
|
|
3994
|
+
// focus holder is not worth a silently different set. That branch is
|
|
3995
|
+
// defensive — snapping only ever shrinks the range towards the anchor, and
|
|
3996
|
+
// a member that would drop out would have to start after the block the
|
|
3997
|
+
// focus line lands in — but "buildBlockMap never emits that" is an argument
|
|
3998
|
+
// about another file's output, not an invariant this one can enforce.
|
|
3999
|
+
function selectionWithFocusHolder(sel, members) {
|
|
4000
|
+
if (blockElAtLine(sel.focusLine)) return sel;
|
|
4001
|
+
const snapTo = Number(sel.focusLine) < Number(sel.anchorLine)
|
|
4002
|
+
? members[0] : members[members.length - 1];
|
|
4003
|
+
if (!snapTo) return sel;
|
|
4004
|
+
const candidate = { anchorLine: sel.anchorLine, focusLine: snapTo.startLine };
|
|
4005
|
+
const after = selectionLib.membersOf(candidate, blocks);
|
|
4006
|
+
if (after.length !== members.length) return sel;
|
|
4007
|
+
for (let i = 0; i < after.length; i++) if (after[i] !== members[i]) return sel;
|
|
4008
|
+
return blockElAtLine(candidate.focusLine) ? candidate : sel;
|
|
4009
|
+
}
|
|
4010
|
+
|
|
4011
|
+
// §4.4 step 2, called from rerenderAll() AFTER its unconditional teardown
|
|
4012
|
+
// (so nothing below can null what this just set) and BEFORE the two
|
|
4013
|
+
// `try`-swallowed rebind blocks (so a diagram-init throw cannot skip it).
|
|
4014
|
+
// `armEditables()` still runs first, per its own comment.
|
|
4015
|
+
function rebuildBlockSelection(declaredRange) {
|
|
4016
|
+
// Nothing standing and nothing declared: the fresh DOM already carries no
|
|
4017
|
+
// tint and no tabindex, so there is nothing to repaint and no reason to
|
|
4018
|
+
// walk every block on a render that has no selection anywhere near it.
|
|
4019
|
+
if (declaredRange === undefined && !blockSelection) return;
|
|
4020
|
+
const sel = declaredRange === undefined
|
|
4021
|
+
? blockSelection
|
|
4022
|
+
: (selectionLib ? selectionLib.collapseTo(declaredRange) : null);
|
|
4023
|
+
if (!sel || !selectionLib) { clearBlockSelection(); return; }
|
|
4024
|
+
const members = selectionLib.membersOf(sel, blocks);
|
|
4025
|
+
if (!members.length) { clearBlockSelection(); return; }
|
|
4026
|
+
setBlockSelection(selectionWithFocusHolder(sel, members));
|
|
4027
|
+
}
|
|
4028
|
+
|
|
4029
|
+
// The block record behind a rendered `.ed-block`, or null. Every gesture
|
|
4030
|
+
// below turns an element (or a point) into a LINE this way — the line is
|
|
4031
|
+
// the selection's identity, the id is only how the DOM addresses it in
|
|
4032
|
+
// between two renders.
|
|
4033
|
+
function blockRecOf(blockEl) {
|
|
4034
|
+
if (!blockEl || !blockEl.getAttribute) return null;
|
|
4035
|
+
const raw = blockEl.getAttribute('data-block-id');
|
|
4036
|
+
if (raw === null) return null; // provisional block: no record yet
|
|
4037
|
+
return blocks.find((b) => b.id === Number(raw)) || null;
|
|
4038
|
+
}
|
|
4039
|
+
|
|
4040
|
+
// Test-only hooks. Task 2 shipped the two WRITE hooks (no gesture created a
|
|
4041
|
+
// selection yet); Task 4 adds the READ one so a gesture scenario can assert
|
|
4042
|
+
// the resulting member set BY LINE RANGE rather than by counting tinted
|
|
4043
|
+
// nodes — a test that counts `.ed-selected` silently passes or fails on
|
|
4044
|
+
// Task 2's CSS instead of on the gesture under test. `memberLines` is the
|
|
4045
|
+
// model's own answer (selection.js against `blocks`); `domSelectedLines` is
|
|
4046
|
+
// the same question asked of the DOM, so a scenario can pin both and catch
|
|
4047
|
+
// the two drifting apart. They are the browser-side counterpart of the
|
|
4048
|
+
// node-side `module.exports` guard at the top of this file — the same
|
|
4049
|
+
// "expose the seam the tests need, in the one environment that has it"
|
|
4050
|
+
// split — and nothing in the product calls them.
|
|
4051
|
+
window.__edTestSetSelection = function (anchorLine, focusLine) {
|
|
4052
|
+
setBlockSelection({ anchorLine: anchorLine, focusLine: focusLine });
|
|
4053
|
+
};
|
|
4054
|
+
window.__edTestClearSelection = function () { clearBlockSelection(); };
|
|
4055
|
+
window.__edTestGetSelection = function () {
|
|
4056
|
+
if (!blockSelection) return null;
|
|
4057
|
+
const members = selectionLib ? selectionLib.membersOf(blockSelection, blocks) : [];
|
|
4058
|
+
return {
|
|
4059
|
+
anchorLine: blockSelection.anchorLine,
|
|
4060
|
+
focusLine: blockSelection.focusLine,
|
|
4061
|
+
memberLines: members.map((b) => [b.startLine, b.endLine]),
|
|
4062
|
+
domSelectedLines: allBlockEls()
|
|
4063
|
+
.filter((el) => el.classList.contains('ed-selected'))
|
|
4064
|
+
.map((el) => { const r = blockRecOf(el); return r ? [r.startLine, r.endLine] : null; }),
|
|
4065
|
+
focusHolderId: (function () {
|
|
4066
|
+
const el = selectionFocusBlockEl();
|
|
4067
|
+
return el ? el.getAttribute('data-block-id') : null;
|
|
4068
|
+
})(),
|
|
4069
|
+
};
|
|
4070
|
+
};
|
|
4071
|
+
// Task 5 seams. `__edTestForceRerender` runs the REAL rerenderAll() (not
|
|
4072
|
+
// safeRerenderAll(), so a scenario sees a throw rather than a banner) —
|
|
4073
|
+
// every batch operation in Tasks 6/7 ends in one, and this is how a
|
|
4074
|
+
// scenario exercises the swap without also exercising a batch operation
|
|
4075
|
+
// that does not exist yet. `__edTestTruncateTo` drops `lines` to its first
|
|
4076
|
+
// n, which is how a scenario reaches the "the selection's lines no longer
|
|
4077
|
+
// parse" state without an operation that deletes blocks.
|
|
4078
|
+
// Task 6 seam: the raw `blocks` records. A scenario needs them to prove the
|
|
4079
|
+
// shape of its OWN fixture — specifically that a no-line PHANTOM really does
|
|
4080
|
+
// sit BETWEEN two members, which is an INVERTED {startLine, endLine} at a
|
|
4081
|
+
// known index. Nothing in the DOM carries that fact: the phantom's element is
|
|
4082
|
+
// there, its (missing) line range is not, so a gap scenario that asserted the
|
|
4083
|
+
// gap from the DOM alone would be asserting something it merely believes.
|
|
4084
|
+
window.__edTestBlocks = function () {
|
|
4085
|
+
return blocks.map((b) => ({
|
|
4086
|
+
id: b.id, type: b.type, startLine: b.startLine, endLine: b.endLine,
|
|
4087
|
+
}));
|
|
4088
|
+
};
|
|
4089
|
+
window.__edTestForceRerender = function () { return rerenderAll(); };
|
|
4090
|
+
window.__edTestTruncateTo = function (n) { lines = lines.slice(0, n); };
|
|
4091
|
+
|
|
4092
|
+
// ── S3 Task 4: the entry and exit GESTURES ─────────────────────────────
|
|
4093
|
+
// §4.4 entries: (a) press inside a block and drag across its boundary;
|
|
4094
|
+
// (b) Shift+Click; (c) Shift+↑↓. Exits: Escape (Task 3's keydown prologue)
|
|
4095
|
+
// and a click inside any block without Shift. Scrolling and window blur
|
|
4096
|
+
// deliberately do NOT clear — the drag they abort is torn down, the
|
|
4097
|
+
// selection it built stands.
|
|
4098
|
+
//
|
|
4099
|
+
// Everything below turns a POINT or an ELEMENT into a LINE and hands it to
|
|
4100
|
+
// selection.js; no gesture ever holds an id or a node across a repaint.
|
|
4101
|
+
|
|
4102
|
+
// Chrome that owns its own press/click semantics. A gesture must never be
|
|
4103
|
+
// armed on one of these, or Shift+Clicking the ⠿ handle would build a
|
|
4104
|
+
// selection instead of opening the menu, and a drag inside the raw
|
|
4105
|
+
// textarea would fight its own text selection.
|
|
4106
|
+
const ED_SEL_GESTURE_CHROME = '.ed-handle, .ed-handle-menu, .ed-insert, .ed-insert-menu, ' +
|
|
4107
|
+
'.ed-te-menu, .ed-te-grip, .ed-tb-insert, .ed-seltb, .ed-conflict, .ed-raw';
|
|
4108
|
+
function isSelGestureChrome(target) {
|
|
4109
|
+
return !!(target && target.closest && target.closest(ED_SEL_GESTURE_CHROME));
|
|
4110
|
+
}
|
|
4111
|
+
|
|
4112
|
+
// The line a gesture landing on `target` selects, or null when there is
|
|
4113
|
+
// none. Task 1 carry 3 / Task 2 carry 5: the focus endpoint must always be
|
|
4114
|
+
// the `startLine` of a block that OWNS a line — selectionFocusBlockEl()
|
|
4115
|
+
// returns null for anything else, which would leave a selection with no
|
|
4116
|
+
// focus holder and a dead keyboard. A phantom (the outer item of a
|
|
4117
|
+
// same-line nest, `endLine < startLine`) is therefore never the answer; its
|
|
4118
|
+
// own first line-owning descendant is, since that descendant is what the
|
|
4119
|
+
// user sees inside the phantom's box.
|
|
4120
|
+
function selectableLineOf(target) {
|
|
4121
|
+
let el = target && target.closest ? target.closest('.ed-block') : null;
|
|
4122
|
+
while (el) {
|
|
4123
|
+
const rec = blockRecOf(el);
|
|
4124
|
+
if (rec && rec.endLine >= rec.startLine) return rec.startLine;
|
|
4125
|
+
const inner = el.querySelector('.ed-block');
|
|
4126
|
+
const innerRec = inner ? blockRecOf(inner) : null;
|
|
4127
|
+
if (innerRec && innerRec.endLine >= innerRec.startLine) return innerRec.startLine;
|
|
4128
|
+
el = el.parentElement && el.parentElement.closest ? el.parentElement.closest('.ed-block') : null;
|
|
4129
|
+
}
|
|
4130
|
+
return null;
|
|
4131
|
+
}
|
|
4132
|
+
|
|
4133
|
+
function selectableLineAtPoint(clientX, clientY) {
|
|
4134
|
+
// Coordinates, not `e.target`: once a drag is under pointer capture every
|
|
4135
|
+
// pointer event retargets to the capture element, so the target says
|
|
4136
|
+
// nothing about what is under the cursor. elementFromPoint always does.
|
|
4137
|
+
const el = document.elementFromPoint(clientX, clientY);
|
|
4138
|
+
if (!el || !contentEl.contains(el)) return null;
|
|
4139
|
+
return selectableLineOf(el);
|
|
4140
|
+
}
|
|
4141
|
+
|
|
4142
|
+
function focusedSelectableLine() {
|
|
4143
|
+
const el = document.activeElement;
|
|
4144
|
+
return el && el.closest ? selectableLineOf(el) : null;
|
|
4145
|
+
}
|
|
4146
|
+
|
|
4147
|
+
function clearNativeTextSelection() {
|
|
4148
|
+
const s = window.getSelection();
|
|
4149
|
+
if (s && typeof s.removeAllRanges === 'function') s.removeAllRanges();
|
|
4150
|
+
}
|
|
4151
|
+
|
|
4152
|
+
// The in-flight press. `dragging` flips only once the pointer has crossed
|
|
4153
|
+
// into a DIFFERENT block — §4.4's entry threshold is that boundary, not a
|
|
4154
|
+
// pixel distance, so a press-and-wiggle inside one block stays ordinary
|
|
4155
|
+
// text selection.
|
|
4156
|
+
let blockSelDrag = null;
|
|
4157
|
+
// A gesture's own trailing `click`. A press in one block released in
|
|
4158
|
+
// another still fires one (on their common ancestor), and
|
|
4159
|
+
// wireBlockSelection()'s click handler would answer it with either
|
|
4160
|
+
// switchAwayFrom() (released outside any block) or the §4.4 exit rule
|
|
4161
|
+
// (released inside one) — the second of which would clear the very
|
|
4162
|
+
// selection the drag just built. Consumed exactly once, and re-armed to
|
|
4163
|
+
// false by the next pointerdown so a gesture whose click never arrives
|
|
4164
|
+
// (an abort, a release outside the window) cannot swallow a later one.
|
|
4165
|
+
let blockSelClickSuppressed = false;
|
|
4166
|
+
|
|
4167
|
+
function armBlockSelDrag(e) {
|
|
4168
|
+
if (isSelGestureChrome(e.target)) return;
|
|
4169
|
+
const line = selectableLineOf(e.target);
|
|
4170
|
+
if (line === null) return;
|
|
4171
|
+
blockSelDrag = {
|
|
4172
|
+
pointerId: e.pointerId,
|
|
4173
|
+
originLine: line,
|
|
4174
|
+
// §4.4's table exception: a drag whose origin is a cell and which never
|
|
4175
|
+
// leaves that table keeps native text selection. Measured: a table is
|
|
4176
|
+
// exactly ONE .ed-block, so the block-boundary rule already says the
|
|
4177
|
+
// same thing — this is kept explicit because it is the spec's own
|
|
4178
|
+
// wording and because it stays correct if a cell ever comes to contain
|
|
4179
|
+
// blocks of its own.
|
|
4180
|
+
originTableEl: e.target.closest ? e.target.closest('table') : null,
|
|
4181
|
+
focusLine: line,
|
|
4182
|
+
dragging: false,
|
|
4183
|
+
captureEl: null,
|
|
4184
|
+
};
|
|
4185
|
+
}
|
|
4186
|
+
|
|
4187
|
+
// Mirrors the table drag's capture skeleton (setPointerCapture at the top
|
|
4188
|
+
// of the te pointerdown, releaseTeCapture() on every exit): capture is what
|
|
4189
|
+
// keeps pointermove/up arriving once the cursor leaves the window. Taken at
|
|
4190
|
+
// ENGAGE time rather than at press time, so a press that turns out to be
|
|
4191
|
+
// plain text selection is never interfered with, and on documentElement
|
|
4192
|
+
// rather than the pressed node, which a mid-gesture commit can detach.
|
|
4193
|
+
function captureBlockSelDrag(e) {
|
|
4194
|
+
const el = document.documentElement;
|
|
4195
|
+
if (el && typeof el.setPointerCapture === 'function') {
|
|
4196
|
+
try { el.setPointerCapture(e.pointerId); blockSelDrag.captureEl = el; }
|
|
4197
|
+
catch (err) { /* not capturable here — the buttons/blur/cancel guards still apply */ }
|
|
4198
|
+
}
|
|
4199
|
+
}
|
|
4200
|
+
|
|
4201
|
+
// Unconditional teardown of the in-flight press, called from pointerup,
|
|
4202
|
+
// pointercancel, the window blur listener, the next pointerdown, and the
|
|
4203
|
+
// "no buttons are down any more" guard in pointermove. The SELECTION is
|
|
4204
|
+
// never touched here: §4.4 says blur does not clear, and an aborted drag
|
|
4205
|
+
// must leave a complete set rather than a half-built one.
|
|
4206
|
+
function endBlockSelDrag() {
|
|
4207
|
+
if (!blockSelDrag) return;
|
|
4208
|
+
const st = blockSelDrag;
|
|
4209
|
+
blockSelDrag = null;
|
|
4210
|
+
if (st.captureEl && typeof st.captureEl.releasePointerCapture === 'function') {
|
|
4211
|
+
try { st.captureEl.releasePointerCapture(st.pointerId); } catch (err) { /* already released */ }
|
|
4212
|
+
}
|
|
4213
|
+
if (st.dragging) blockSelClickSuppressed = true;
|
|
4214
|
+
}
|
|
4215
|
+
|
|
4216
|
+
function updateBlockSelDrag(e) {
|
|
4217
|
+
if (!blockSelDrag || e.pointerId !== blockSelDrag.pointerId) return;
|
|
4218
|
+
if (tePointer) return; // a grip gesture owns this pointer
|
|
4219
|
+
// The browser can simply never deliver a pointerup (released over browser
|
|
4220
|
+
// chrome, over another window). The next move with no button held is the
|
|
4221
|
+
// only signal left that the gesture is over.
|
|
4222
|
+
if (typeof e.buttons === 'number' && e.buttons === 0) { endBlockSelDrag(); return; }
|
|
4223
|
+
const overEl = document.elementFromPoint(e.clientX, e.clientY);
|
|
4224
|
+
if (!blockSelDrag.dragging) {
|
|
4225
|
+
if (blockSelDrag.originTableEl && overEl && blockSelDrag.originTableEl.contains(overEl)) return;
|
|
4226
|
+
const line = selectableLineAtPoint(e.clientX, e.clientY);
|
|
4227
|
+
if (line === null || line === blockSelDrag.originLine) return;
|
|
4228
|
+
blockSelDrag.dragging = true;
|
|
4229
|
+
blockSelDrag.focusLine = line;
|
|
4230
|
+
captureBlockSelDrag(e);
|
|
4231
|
+
} else {
|
|
4232
|
+
const line = selectableLineAtPoint(e.clientX, e.clientY);
|
|
4233
|
+
// Off every block (the page margin, an overlay): keep the last block the
|
|
4234
|
+
// drag actually reached rather than collapsing the set mid-gesture.
|
|
4235
|
+
if (line !== null) blockSelDrag.focusLine = line;
|
|
4236
|
+
}
|
|
4237
|
+
e.preventDefault();
|
|
4238
|
+
// The press started a native text selection that keeps extending with the
|
|
4239
|
+
// pointer; once the gesture is a BLOCK selection the two must not both be
|
|
4240
|
+
// painted. preventDefault() on pointermove does not stop it, so it is
|
|
4241
|
+
// dropped explicitly on every frame of the drag.
|
|
4242
|
+
clearNativeTextSelection();
|
|
4243
|
+
setBlockSelection({ anchorLine: blockSelDrag.originLine, focusLine: blockSelDrag.focusLine });
|
|
4244
|
+
}
|
|
4245
|
+
|
|
4246
|
+
// §4.4 entry (b). The anchor is the standing selection's own anchor, or —
|
|
4247
|
+
// entering fresh — the block that holds the caret, so Shift+Click reads as
|
|
4248
|
+
// "from where I am to here". With neither, it collapses onto the clicked
|
|
4249
|
+
// block, which is extendTo()'s own answer for a null selection.
|
|
4250
|
+
function beginShiftClickSelection(e) {
|
|
4251
|
+
if (isSelGestureChrome(e.target)) return;
|
|
4252
|
+
const line = selectableLineOf(e.target);
|
|
4253
|
+
if (line === null) return;
|
|
4254
|
+
const seed = focusedSelectableLine();
|
|
4255
|
+
// Shift+Click INSIDE the block that already holds the caret is the one
|
|
4256
|
+
// Shift+Click that must stay native: it is how a user extends a text
|
|
4257
|
+
// selection to a point, and there is no second block to take.
|
|
4258
|
+
if (!blockSelection && seed !== null && seed === line) return;
|
|
4259
|
+
// Cancels the caret placement (and the focus move that would start a
|
|
4260
|
+
// burst on the clicked block) before it happens; the trailing click is
|
|
4261
|
+
// consumed by the flag.
|
|
4262
|
+
e.preventDefault();
|
|
4263
|
+
blockSelClickSuppressed = true;
|
|
4264
|
+
clearNativeTextSelection();
|
|
4265
|
+
const base = blockSelection || (seed === null ? null : { anchorLine: seed, focusLine: seed });
|
|
4266
|
+
setBlockSelection(selectionLib.extendTo(base, line));
|
|
4267
|
+
}
|
|
4268
|
+
|
|
4269
|
+
// §4.4 entry (c) / its extension. Returns true when it owned the key.
|
|
4270
|
+
// stepFocus() skips blocks that own no source line, so every press MOVES —
|
|
4271
|
+
// Task 1 carry 3's "the first Shift+↓ does nothing, the second one moves".
|
|
4272
|
+
function stepSelectionFocus(dir) {
|
|
4273
|
+
if (!selectionLib) return false;
|
|
4274
|
+
if (blockSelection) {
|
|
4275
|
+
setBlockSelection(selectionLib.stepFocus(blockSelection, blocks, dir));
|
|
4276
|
+
return true;
|
|
4277
|
+
}
|
|
4278
|
+
// Entering fresh: the block that holds the roving focus, or — once a
|
|
4279
|
+
// clear has blurred it (see lastSelectionFocusLine) — the line the last
|
|
4280
|
+
// selection ended on, provided it still names a block that owns a line.
|
|
4281
|
+
let seed = focusedSelectableLine();
|
|
4282
|
+
if (seed === null && lastSelectionFocusLine !== null) {
|
|
4283
|
+
const rec = blocks.find((b) => b.startLine === lastSelectionFocusLine &&
|
|
4284
|
+
b.endLine >= b.startLine);
|
|
4285
|
+
if (rec) seed = rec.startLine;
|
|
4286
|
+
}
|
|
4287
|
+
if (seed === null) return false;
|
|
4288
|
+
setBlockSelection(selectionLib.stepFocus({ anchorLine: seed, focusLine: seed }, blocks, dir));
|
|
4289
|
+
return true;
|
|
4290
|
+
}
|
|
4291
|
+
|
|
3311
4292
|
// ── T7: surviving a commit that renumbers every block id ───────────────
|
|
3312
4293
|
// A gutter gesture (⠿ delete, + insert) resolves any open burst FIRST, and
|
|
3313
4294
|
// that resolution can commit a DIFFERENT block's dirty editor, re-render,
|
|
@@ -3662,7 +4643,13 @@
|
|
|
3662
4643
|
if (res.unsupported[i] !== 'MULTILINE') return false;
|
|
3663
4644
|
}
|
|
3664
4645
|
if (opts && opts.columnOnly) return true;
|
|
3665
|
-
|
|
4646
|
+
// S3 Task 6: `targetEl` may be an ARRAY — a batch operation has N targets in
|
|
4647
|
+
// one run and every one of them has to clear §4.1's multi-line gate, not
|
|
4648
|
+
// just the one the ⠿ was pressed on. An empty array is the same question as
|
|
4649
|
+
// no target at all (nobody is being rewritten).
|
|
4650
|
+
const targetEls = targetEl === null || targetEl === undefined
|
|
4651
|
+
? [] : (Array.isArray(targetEl) ? targetEl : [targetEl]);
|
|
4652
|
+
if (!targetEls.length) return multi.length === 0;
|
|
3666
4653
|
// T7: the AUTHORITATIVE multi-line test, and it is not `multi`.
|
|
3667
4654
|
// `multiLineBlockIds` reports a '\n' in the item's surface text, which
|
|
3668
4655
|
// sees a LAZY continuation and is blind to a markdown HARD BREAK (two
|
|
@@ -3675,12 +4662,15 @@
|
|
|
3675
4662
|
// Chromium leaves when the last character is deleted, and an emptied item
|
|
3676
4663
|
// must stay removable). `multi` is kept as well — it costs nothing and
|
|
3677
4664
|
// covers any surface newline that is not a line-range fact.
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
|
|
4665
|
+
for (let t = 0; t < targetEls.length; t++) {
|
|
4666
|
+
const targetRaw = targetEls[t].getAttribute('data-block-id');
|
|
4667
|
+
const targetRec = blocks.find((b) => b.id === Number(targetRaw));
|
|
4668
|
+
if (targetRec && targetRec.endLine > targetRec.startLine) return false;
|
|
4669
|
+
// getAttribute() strings on both sides — the same convention
|
|
4670
|
+
// unsupportedByLi[].blockId uses.
|
|
4671
|
+
if (multi.indexOf(targetRaw) !== -1) return false;
|
|
4672
|
+
}
|
|
4673
|
+
return true;
|
|
3684
4674
|
}
|
|
3685
4675
|
|
|
3686
4676
|
// Esc inside a burst: revert to snapshot 0 (the pre-focus baseline) and
|
|
@@ -4157,6 +5147,222 @@
|
|
|
4157
5147
|
return true;
|
|
4158
5148
|
}
|
|
4159
5149
|
|
|
5150
|
+
// ── S3 Task 7: §3.5's batch Tab / Shift+Tab over a standing selection ──
|
|
5151
|
+
//
|
|
5152
|
+
// The whole task in one sentence: compute the delta ONCE, from the member
|
|
5153
|
+
// with the MINIMUM old indent, apply it to the whole set, and only THEN
|
|
5154
|
+
// clamp per item. Running the single-item arithmetic per member instead
|
|
5155
|
+
// breaks the sibling relationships the user selected — §3.5's own worked
|
|
5156
|
+
// example is `- a / (2sp)- b / (2sp)- c` with b+c selected: per item, c's
|
|
5157
|
+
// ceiling is 2 (because b sits at 1) so c moves there and is ADOPTED as b's
|
|
5158
|
+
// child, when the correct answer is that nothing moves at all.
|
|
5159
|
+
//
|
|
5160
|
+
// And the one number is the MINIMUM AVAILABLE HEAD-ROOM across the members,
|
|
5161
|
+
// not the head-room of the shallowest one. This is review defect D1
|
|
5162
|
+
// (2026-08-31), MEASURED on 8486ecd: `- a / (2sp)- b / (2sp)- c / - d` with
|
|
5163
|
+
// b, c and d selected took its delta from d (the minimum INDENT, +1) and
|
|
5164
|
+
// applied it to all three; applyIndentClamp() then walked the operated
|
|
5165
|
+
// blocks in document order, pulled b back to its own ceiling of 1 — and
|
|
5166
|
+
// recomputed c's ceiling against the JUST-SETTLED b, so c was allowed to
|
|
5167
|
+
// stay at 2 and became b's CHILD. Only the constrained member came back.
|
|
5168
|
+
// That is the 「半移動」 §3.4 rule 4 forbids: 「段內相對關係必須保持」.
|
|
5169
|
+
//
|
|
5170
|
+
// So a member with no head-room floors the WHOLE set at zero. Three reasons
|
|
5171
|
+
// this is the branch and not "move what can move":
|
|
5172
|
+
// * it is what Shift+Tab has always done — its delta is floored by a
|
|
5173
|
+
// member already at indent 0, and T7 carry 5 records that as a
|
|
5174
|
+
// deliberate no-op for exactly this reason;
|
|
5175
|
+
// * §3.4 rule 4 requires the members' relative relationships to survive
|
|
5176
|
+
// the operation, and a partial move is precisely what breaks them;
|
|
5177
|
+
// * CHANGELOG v2.12.0 already promises it — "a set that cannot move as a
|
|
5178
|
+
// whole does not move at all rather than half-moving".
|
|
5179
|
+
//
|
|
5180
|
+
// ⚠ §3.4 rule 3's SECOND worked example is written the other way round
|
|
5181
|
+
// (「批次 Tab 選 b(1)..d(0) 時第一成員 b 已在上界 ⇒ delta 0 ⇒ 整批 no-op」 is
|
|
5182
|
+
// listed there as a FAILURE of first-member anchoring). It cannot be
|
|
5183
|
+
// satisfied at the same time as rule 4 on the D1 fixture — d moving while b
|
|
5184
|
+
// and c are clamped IS the half-move — and the review ruled for rule 4. The
|
|
5185
|
+
// b(1)..d(0) set is therefore a whole-batch no-op now, and T7's scenario for
|
|
5186
|
+
// it was migrated with that reason in its assertion message.
|
|
5187
|
+
//
|
|
5188
|
+
// spanMinIndent() is still §3.4 rule 3's anchor VALUE for applyIndentClamp()
|
|
5189
|
+
// — the bound the blocks BELOW the set are re-measured against — and that
|
|
5190
|
+
// use is unchanged. Only the set's own movement is computed here.
|
|
5191
|
+
|
|
5192
|
+
// How far ONE member may move, as a magnitude (0 or 1), never a signed
|
|
5193
|
+
// delta. `dir` is +1 (Tab) or -1 (Shift+Tab). The ceiling restates
|
|
5194
|
+
// indentListItem()'s own single-item rule — the list-start clause included,
|
|
5195
|
+
// so a set that opens a list cannot nest itself under the previous list's
|
|
5196
|
+
// last item — and the floor is outdentListItem()'s (indent 0 cannot rise).
|
|
5197
|
+
// Both read PRE-move indents, which is §3.4's global convention ("the
|
|
5198
|
+
// operated block's indent" always means the value before the operation), and
|
|
5199
|
+
// it is what makes the minimum below well-defined: every member is measured
|
|
5200
|
+
// against the document as it stands, not against members already moved.
|
|
5201
|
+
function memberIndentHeadroom(el, dir) {
|
|
5202
|
+
const self = liAttrs(el);
|
|
5203
|
+
if (!self) return 0;
|
|
5204
|
+
if (dir < 0) return self.indent > 0 ? 1 : 0;
|
|
5205
|
+
if (self.listStart) return 0;
|
|
5206
|
+
const all = allBlockEls();
|
|
5207
|
+
const i = all.indexOf(el);
|
|
5208
|
+
if (i < 0) return 0;
|
|
5209
|
+
const prev = liAttrs(all[i - 1]);
|
|
5210
|
+
const max = prev ? prev.indent + 1 : 0;
|
|
5211
|
+
return Math.max(0, Math.min(1, max - self.indent));
|
|
5212
|
+
}
|
|
5213
|
+
|
|
5214
|
+
// How far the WHOLE set may move, as one SIGNED number: the smallest
|
|
5215
|
+
// head-room any member has, in `dir`'s direction. Zero for an empty set and
|
|
5216
|
+
// zero the moment one member cannot move, which is the D1 ruling.
|
|
5217
|
+
function batchIndentDelta(liEls, dir) {
|
|
5218
|
+
const els = liEls || [];
|
|
5219
|
+
if (!els.length) return 0;
|
|
5220
|
+
let room = 1;
|
|
5221
|
+
for (let i = 0; i < els.length; i++) {
|
|
5222
|
+
const r = memberIndentHeadroom(els[i], dir);
|
|
5223
|
+
if (r < room) room = r;
|
|
5224
|
+
if (room === 0) return 0;
|
|
5225
|
+
}
|
|
5226
|
+
return dir < 0 ? -room : room;
|
|
5227
|
+
}
|
|
5228
|
+
|
|
5229
|
+
// The li half. One mutation, one clamp, one commit — never a loop over the
|
|
5230
|
+
// members, for the reason the whole batch layer exists: a loop re-renders
|
|
5231
|
+
// between items and invalidates every id in between.
|
|
5232
|
+
async function indentListItemsBySelection(liEls, recs, dir) {
|
|
5233
|
+
// Every list batch is one run's problem — see batchRunOf().
|
|
5234
|
+
const run = batchRunOf(liEls);
|
|
5235
|
+
if (!run) { refuseStructuralListEdit(BATCH_MULTIRUN_MESSAGE); return; }
|
|
5236
|
+
// `columnOnly`: an indent change rewrites nothing but leading columns, so a
|
|
5237
|
+
// hard-wrapped member is a legal TARGET here — the same deviation the caret
|
|
5238
|
+
// Tab's own gate documents, and in a batch every member is a target.
|
|
5239
|
+
if (!listRunSupportsStructuralEdit(run, liEls, { columnOnly: true })) {
|
|
5240
|
+
refuseStructuralListEdit(); return;
|
|
5241
|
+
}
|
|
5242
|
+
const oldIndent = spanMinIndent(liEls);
|
|
5243
|
+
const delta = batchIndentDelta(liEls, dir);
|
|
5244
|
+
// A zero delta is a COMPLETE no-op — nothing mutated, nothing committed,
|
|
5245
|
+
// file byte-identical, the set left standing — exactly what the single-item
|
|
5246
|
+
// Tab does at its own boundary, and NOT a refusal (no banner: the spec's
|
|
5247
|
+
// 「靜默不動作是缺陷」 is about refusals, and this is the documented
|
|
5248
|
+
// boundary answer §3.5 gives for a set at its ceiling). On §3.5's b+c
|
|
5249
|
+
// example this IS the right answer, and it is the answer per-item maths
|
|
5250
|
+
// cannot give; after D1 it is also the answer whenever ANY member is at
|
|
5251
|
+
// its ceiling, which is what keeps the members' relative order intact.
|
|
5252
|
+
if (delta === 0) return;
|
|
5253
|
+
const first = recs[0];
|
|
5254
|
+
const last = recs[recs.length - 1];
|
|
5255
|
+
mutateListRun(() => {
|
|
5256
|
+
liEls.forEach((el) => setBlockIndent(el,
|
|
5257
|
+
Math.max(0, (Number(el.getAttribute('data-indent')) || 0) + delta)));
|
|
5258
|
+
// ONE call for the whole set, never one per member: clampIndents() takes
|
|
5259
|
+
// an ARRAY of operated indices precisely so §3.4 rule 3 computes one
|
|
5260
|
+
// segment delta for the blocks below instead of N independent clamps, and
|
|
5261
|
+
// its rule 1 then walks the operated blocks in document order so each is
|
|
5262
|
+
// measured against the member above it that the same pass just settled.
|
|
5263
|
+
applyIndentClamp(run, liEls, oldIndent);
|
|
5264
|
+
});
|
|
5265
|
+
// Re-derived AFTER the mutation, like the caret Tab's: an indent change can
|
|
5266
|
+
// move a block between runs.
|
|
5267
|
+
const liveRun = listRunOf(liEls[0]);
|
|
5268
|
+
if (!liveRun.length) return;
|
|
5269
|
+
// Column-only: nothing's CONTENT moved, so every block in the span — the
|
|
5270
|
+
// members included — is a bystander whose source bytes must come back
|
|
5271
|
+
// untouched.
|
|
5272
|
+
const carry = bystanderCarryOver(liveRun, null);
|
|
5273
|
+
// §3.3's collapse, declared immediately before the commit's render (Task 5
|
|
5274
|
+
// carry 5) and only when a set was standing (Task 6 carry 6). A
|
|
5275
|
+
// column-only edit changes no line COUNT and moves no line, so the members
|
|
5276
|
+
// keep exactly the range they had.
|
|
5277
|
+
declareCollapse({ startLine: first.startLine, endLine: last.endLine });
|
|
5278
|
+
await commitListStructure(liveRun, null, false, { carryOver: carry });
|
|
5279
|
+
}
|
|
5280
|
+
|
|
5281
|
+
// The non-li half — §3.5's heading row, batched: Tab lowers a heading one
|
|
5282
|
+
// level (clamped to H6), Shift+Tab raises it (clamped to H1), and a
|
|
5283
|
+
// paragraph / quote / code member is a true no-op.
|
|
5284
|
+
//
|
|
5285
|
+
// The span is rewritten IN PLACE inside its own line range: each heading
|
|
5286
|
+
// member's line is re-emitted at the new depth and every other line —
|
|
5287
|
+
// separators and non-heading members alike — comes back verbatim. That is
|
|
5288
|
+
// what makes 「段落 no-op」 a real no-op instead of a re-serialization that
|
|
5289
|
+
// could move bytes nobody asked it to, and it is why this is ONE
|
|
5290
|
+
// commitRangeEdit and therefore one undo op.
|
|
5291
|
+
async function changeHeadingDepthsInSpan(els, recs, dir) {
|
|
5292
|
+
const first = recs[0];
|
|
5293
|
+
const last = recs[recs.length - 1];
|
|
5294
|
+
const span = lines.slice(first.startLine - 1, last.endLine);
|
|
5295
|
+
let changed = false;
|
|
5296
|
+
for (let i = 0; i < recs.length; i++) {
|
|
5297
|
+
if (recs[i].type !== 'heading') continue;
|
|
5298
|
+
const at = recs[i].startLine - first.startLine;
|
|
5299
|
+
const cur = span[at];
|
|
5300
|
+
// headingDepthOf() answers 1 for ANY line, matched or not, so a SETEXT
|
|
5301
|
+
// heading ('Title' over '====') would be handed a '#' it never asked for
|
|
5302
|
+
// and its underline left behind. Only a real ATX line is rewritten.
|
|
5303
|
+
if (typeof cur !== 'string' || !/^#{1,6}(\s|$)/.test(cur)) continue;
|
|
5304
|
+
const depth = headingDepthOf(cur);
|
|
5305
|
+
const next = Math.max(1, Math.min(6, depth + dir));
|
|
5306
|
+
if (next === depth) continue;
|
|
5307
|
+
span[at] = withHeadingDepth(cur, next);
|
|
5308
|
+
changed = true;
|
|
5309
|
+
}
|
|
5310
|
+
// Every member was a paragraph, or every heading already sat at its clamp:
|
|
5311
|
+
// §3.5 rules that a no-op, so nothing is committed and nothing re-renders.
|
|
5312
|
+
if (!changed) return;
|
|
5313
|
+
const result = commitRangeEdit({ lines, blocks, stack },
|
|
5314
|
+
first.startLine, last.endLine, span.join('\n'));
|
|
5315
|
+
if (result.op === null) return;
|
|
5316
|
+
const prevLines = lines;
|
|
5317
|
+
lines = result.lines;
|
|
5318
|
+
// §3.3's collapse: a depth change rewrites no line COUNT, so the set
|
|
5319
|
+
// collapses onto the lines it already held.
|
|
5320
|
+
declareCollapse({ startLine: first.startLine, endLine: last.endLine });
|
|
5321
|
+
const okRender = await safeRerenderAll();
|
|
5322
|
+
if (!okRender) {
|
|
5323
|
+
lines = rollbackFailedRender({ lines, stack }, result, prevLines);
|
|
5324
|
+
}
|
|
5325
|
+
}
|
|
5326
|
+
|
|
5327
|
+
// The keydown entry point. The "grip" is the roving focus holder, which is a
|
|
5328
|
+
// member of the set by construction, so resolveGutterOperands() answers
|
|
5329
|
+
// 'batch' and hands back the whole span — together with the shared preamble
|
|
5330
|
+
// every ⠿ operation already goes through (switchAwayFrom(), the re-resolve
|
|
5331
|
+
// after a commit that re-rendered, the no-source-line refusal, the emptiness
|
|
5332
|
+
// and contiguity gates). Membership resolves by REFERENCE against the live
|
|
5333
|
+
// `blocks`, which is exactly why the record must come from there and not from
|
|
5334
|
+
// a fresh find() (Task 1 carry 4).
|
|
5335
|
+
async function tabSelection(dir) {
|
|
5336
|
+
const focusEl = selectionFocusBlockEl();
|
|
5337
|
+
if (!focusEl) return;
|
|
5338
|
+
const operands = await resolveGutterOperands(focusEl);
|
|
5339
|
+
if (!operands) return;
|
|
5340
|
+
const els = operands.els;
|
|
5341
|
+
const recs = operands.recs;
|
|
5342
|
+
const shape = spanListKinds(els);
|
|
5343
|
+
// §3.6's 2026-08-31 ruling, inherited rather than re-invented. NOTE: this
|
|
5344
|
+
// CONTRADICTS the S3 plan's Task 7 text ("a batch containing both kinds
|
|
5345
|
+
// applies each rule to its own kind") — see the Task 7 carry. A mixed span
|
|
5346
|
+
// would need the run's survivors re-serialized at the same time as lines
|
|
5347
|
+
// outside the run are rewritten, i.e. two commits and therefore two undo
|
|
5348
|
+
// ops, which §3.4's 「一次使用者手勢 = 恰好一個 undo op」 forbids.
|
|
5349
|
+
if (shape.anyLi && !shape.allLi) { refuseStructuralListEdit(BATCH_MIXED_MESSAGE); return; }
|
|
5350
|
+
if (shape.allLi) { await indentListItemsBySelection(els, recs, dir); return; }
|
|
5351
|
+
await changeHeadingDepthsInSpan(els, recs, dir);
|
|
5352
|
+
}
|
|
5353
|
+
|
|
5354
|
+
// §3.6's 「Delete 整批刪」, whose 2026-08-31 ruling is that it goes through
|
|
5355
|
+
// the SAME batch path as the ⠿ menu's 刪除 and that no second deletion path
|
|
5356
|
+
// is written. deleteBlockViaGutter() already resolves §3.3's membership for
|
|
5357
|
+
// itself and routes an all-li span to deleteListItemsViaGutter(), so the key
|
|
5358
|
+
// is one call — every refusal, every collapse and the one-undo-op guarantee
|
|
5359
|
+
// come with it for free rather than being restated here.
|
|
5360
|
+
async function deleteSelection() {
|
|
5361
|
+
const focusEl = selectionFocusBlockEl();
|
|
5362
|
+
if (!focusEl) return;
|
|
5363
|
+
await deleteBlockViaGutter(focusEl);
|
|
5364
|
+
}
|
|
5365
|
+
|
|
4160
5366
|
// ── Task 8 (Phase 4): Notion key semantics on per-li blocks ─────────────
|
|
4161
5367
|
// Spec §4's "key semantics on li surfaces", acceptance rows 1, 3, 5, 6, 7,
|
|
4162
5368
|
// 8. Structurally different from Task 4's whole-list handleListKeydown()
|
|
@@ -4183,6 +5389,10 @@
|
|
|
4183
5389
|
// no-source-line callers pass their own text instead of a second function.
|
|
4184
5390
|
function refuseStructuralListEdit(message) {
|
|
4185
5391
|
showBanner(message || '此清單含不支援的格式,無法調整結構', null, null);
|
|
5392
|
+
// Review recommendation 5: mark it, so the next SUCCESSFUL render clears
|
|
5393
|
+
// it (dismissRefusalBanner(), called from rerenderAll()). Set after the
|
|
5394
|
+
// showBanner() call, which resets the flag for every other caller.
|
|
5395
|
+
activeBannerIsRefusal = true;
|
|
4186
5396
|
}
|
|
4187
5397
|
|
|
4188
5398
|
// Row 3, top-level press: spec §4 — "at top level the next press converts
|
|
@@ -5924,6 +7134,12 @@
|
|
|
5924
7134
|
// capture) BEFORE starting a new one, so a stuck drag can never survive
|
|
5925
7135
|
// into the next gesture and a fresh pointerdown always starts clean.
|
|
5926
7136
|
if (tePointer) cancelTeDrag();
|
|
7137
|
+
// S3 Task 4: same defensive reasoning one line up, for the block-selection
|
|
7138
|
+
// press — and the flag that suppresses a gesture's own trailing click is
|
|
7139
|
+
// re-armed here so a gesture whose click never arrived cannot swallow the
|
|
7140
|
+
// next one.
|
|
7141
|
+
blockSelClickSuppressed = false;
|
|
7142
|
+
if (blockSelDrag) endBlockSelDrag();
|
|
5927
7143
|
const hit = hitTestGrip(e.target);
|
|
5928
7144
|
// A click on a DIFFERENT zone (or entirely outside any zone) dismisses
|
|
5929
7145
|
// whatever menu is already open, same "any other click closes the ⠿
|
|
@@ -5939,7 +7155,18 @@
|
|
|
5939
7155
|
// teMenuKind alone, or that highlight would survive until
|
|
5940
7156
|
// resolveBurst() instead of clearing on the next click.
|
|
5941
7157
|
if ((teMenuKind || teHighlightEls.length) && !isSameSelection) hideTableEdgeMenu();
|
|
5942
|
-
if (!hit)
|
|
7158
|
+
if (!hit) {
|
|
7159
|
+
// S3 Task 4 (recon hazard 1): this listener already fires on EVERY left
|
|
7160
|
+
// click and is a no-op whenever hitTestGrip() finds nothing — which is
|
|
7161
|
+
// exactly the branch the block-selection gestures belong on. Arming
|
|
7162
|
+
// them HERE, inside the incumbent handler, is what makes "a grip hit
|
|
7163
|
+
// still wins" true by construction: a second, competing pointerdown
|
|
7164
|
+
// listener would have to re-derive the hit test, and would race this
|
|
7165
|
+
// one's menu dismiss above depending on registration order.
|
|
7166
|
+
if (e.shiftKey) { beginShiftClickSelection(e); return; }
|
|
7167
|
+
armBlockSelDrag(e);
|
|
7168
|
+
return;
|
|
7169
|
+
}
|
|
5943
7170
|
e.preventDefault();
|
|
5944
7171
|
tePointer = { hit, startX: e.clientX, startY: e.clientY, dragging: false,
|
|
5945
7172
|
pointerId: e.pointerId, captureEl: e.target };
|
|
@@ -5989,7 +7216,20 @@
|
|
|
5989
7216
|
else updateColDropIndicator(e.clientX);
|
|
5990
7217
|
});
|
|
5991
7218
|
|
|
7219
|
+
// S3 Task 4: the block-selection drag's own pointermove. Registered after
|
|
7220
|
+
// the table drag's (above), and bails while `tePointer` is set, so a grip
|
|
7221
|
+
// gesture is never fought over. Not folded into that listener because its
|
|
7222
|
+
// very first line is `if (!tePointer) return;` — the state this one runs in.
|
|
7223
|
+
document.addEventListener('pointermove', (e) => {
|
|
7224
|
+
updateBlockSelDrag(e);
|
|
7225
|
+
});
|
|
7226
|
+
|
|
5992
7227
|
document.addEventListener('pointerup', async (e) => {
|
|
7228
|
+
// S3 Task 4: recon hazard 2 — there is no `mouseup` listener anywhere in
|
|
7229
|
+
// this file, so the block-selection drag ends on the pointer events, the
|
|
7230
|
+
// same skeleton the table drag uses. Before the `!tePointer` bail: a
|
|
7231
|
+
// selection drag is armed precisely when no grip gesture is in flight.
|
|
7232
|
+
endBlockSelDrag();
|
|
5993
7233
|
if (!tePointer) return;
|
|
5994
7234
|
const st = tePointer;
|
|
5995
7235
|
releaseTeCapture(st);
|
|
@@ -6035,6 +7275,11 @@
|
|
|
6035
7275
|
// actually moved mid-drag, only the indicator line).
|
|
6036
7276
|
document.addEventListener('pointercancel', () => {
|
|
6037
7277
|
cancelTeDrag();
|
|
7278
|
+
// S3 Task 4: the same abort ends a block-selection drag. The selection it
|
|
7279
|
+
// has built so far STANDS (§4.4: nothing but Escape and a plain click
|
|
7280
|
+
// clears) — what must not survive is the live drag, or the next stray
|
|
7281
|
+
// pointer move would keep extending a gesture the user already ended.
|
|
7282
|
+
endBlockSelDrag();
|
|
6038
7283
|
});
|
|
6039
7284
|
|
|
6040
7285
|
// Review fix (Critical): the whole BROWSER WINDOW losing focus mid-
|
|
@@ -6046,6 +7291,10 @@
|
|
|
6046
7291
|
// when no gesture is in flight, so this is safe to fire on every blur.
|
|
6047
7292
|
window.addEventListener('blur', () => {
|
|
6048
7293
|
cancelTeDrag();
|
|
7294
|
+
// S3 Task 4: §4.4 says window blur does NOT clear the selection — but the
|
|
7295
|
+
// drag in flight when focus left is exactly as undeliverable-pointerup as
|
|
7296
|
+
// a row drag's, so it is torn down here too.
|
|
7297
|
+
endBlockSelDrag();
|
|
6049
7298
|
});
|
|
6050
7299
|
|
|
6051
7300
|
// ── Phase-2 Task 4: floating selection toolbar (bold/italic/code/link) ──
|
|
@@ -6386,6 +7635,12 @@
|
|
|
6386
7635
|
(e.target.closest('.ed-handle') || e.target.closest('.ed-insert'))) e.preventDefault();
|
|
6387
7636
|
});
|
|
6388
7637
|
document.addEventListener('click', async (e) => {
|
|
7638
|
+
// S3 Task 4: the trailing click of a gesture that already answered this
|
|
7639
|
+
// press itself (a drag across a block boundary, a Shift+Click). A drag
|
|
7640
|
+
// released in another block would otherwise hit the §4.4 exit rule
|
|
7641
|
+
// below and clear the set it just built; one released on the page
|
|
7642
|
+
// margin would hit the outside-a-block switchAwayFrom() instead.
|
|
7643
|
+
if (blockSelClickSuppressed) { blockSelClickSuppressed = false; return; }
|
|
6389
7644
|
if (!e.target || !e.target.closest) { await switchAwayFrom(); closeGutterMenu(); closeInsertMenu(); return; }
|
|
6390
7645
|
// showBanner() appends `.ed-conflict` to document.body — OUTSIDE any
|
|
6391
7646
|
// .ed-block — so without this guard a click on the banner's own
|
|
@@ -6489,6 +7744,15 @@
|
|
|
6489
7744
|
// Any other click closes an already-open ⠿ menu / + menu.
|
|
6490
7745
|
if (gutterMenuBlockEl) closeGutterMenu();
|
|
6491
7746
|
if (insertMenuBlockEl) closeInsertMenu();
|
|
7747
|
+
// S3 Task 4, §4.4 exit: a click INSIDE any block, without Shift, clears
|
|
7748
|
+
// the whole set. Deliberately below the ⠿/+/menu/checkbox branches
|
|
7749
|
+
// above, which all return early — §3.3 decides batch-vs-single by
|
|
7750
|
+
// whether the GRIP is inside the set, so a grip click must not clear it
|
|
7751
|
+
// first. A click OUTSIDE every block is deliberately not an exit: §4.4
|
|
7752
|
+
// lists exactly Escape and this one. clearBlockSelection() steals no
|
|
7753
|
+
// focus (Task 3 carry 7), so the caret still lands where the user
|
|
7754
|
+
// clicked.
|
|
7755
|
+
if (blockSelection && !e.shiftKey && e.target.closest('.ed-block')) clearBlockSelection();
|
|
6492
7756
|
if (e.target.closest(ED_LIGHTBOX_TARGETS)) return; // let the lightbox open, unchanged
|
|
6493
7757
|
let blockEl = e.target.closest('.ed-block');
|
|
6494
7758
|
// v2.11.1: `.ed-block::before` (lib/md2doc.js's editModeLayoutCss) makes
|
|
@@ -6589,6 +7853,14 @@
|
|
|
6589
7853
|
const r = stack.undo(lines);
|
|
6590
7854
|
if (!r) return;
|
|
6591
7855
|
lines = r.lines;
|
|
7856
|
+
// S3 Task 5 (§4.4): undo/redo ALWAYS clears the block selection.
|
|
7857
|
+
// `UndoStack`'s op is exactly {startLine, endLine, before, after}
|
|
7858
|
+
// (lineops.js) and carries no selection state, so there is nothing to
|
|
7859
|
+
// restore a set to — and Task 5's rebuild would otherwise keep the old
|
|
7860
|
+
// line range standing over a document that just changed underneath it.
|
|
7861
|
+
// DECLARED rather than cleared outright so the rollback below leaves a
|
|
7862
|
+
// standing selection alone when the render fails.
|
|
7863
|
+
declareSelectionRange(null);
|
|
6592
7864
|
const ok = await safeRerenderAll();
|
|
6593
7865
|
if (!ok) {
|
|
6594
7866
|
// Reverse the undo attempt: push the op back and restore `lines` to
|
|
@@ -6604,6 +7876,7 @@
|
|
|
6604
7876
|
const r = stack.redo(lines);
|
|
6605
7877
|
if (!r) return;
|
|
6606
7878
|
lines = r.lines;
|
|
7879
|
+
declareSelectionRange(null); // §4.4, same as undo() above
|
|
6607
7880
|
const ok = await safeRerenderAll();
|
|
6608
7881
|
if (!ok) {
|
|
6609
7882
|
// Reverse the redo attempt: pop the op back off and restore `lines`.
|
|
@@ -6614,36 +7887,66 @@
|
|
|
6614
7887
|
|
|
6615
7888
|
// ── global key handling ─────────────────────────────────────────────────
|
|
6616
7889
|
document.addEventListener('keydown', (e) => {
|
|
6617
|
-
//
|
|
6618
|
-
//
|
|
6619
|
-
//
|
|
6620
|
-
// (
|
|
6621
|
-
//
|
|
6622
|
-
//
|
|
6623
|
-
|
|
6624
|
-
|
|
6625
|
-
|
|
6626
|
-
|
|
6627
|
-
|
|
6628
|
-
//
|
|
6629
|
-
//
|
|
6630
|
-
//
|
|
6631
|
-
//
|
|
6632
|
-
//
|
|
6633
|
-
//
|
|
6634
|
-
//
|
|
6635
|
-
//
|
|
6636
|
-
//
|
|
6637
|
-
//
|
|
6638
|
-
//
|
|
6639
|
-
//
|
|
6640
|
-
//
|
|
6641
|
-
//
|
|
6642
|
-
//
|
|
6643
|
-
|
|
6644
|
-
|
|
6645
|
-
|
|
6646
|
-
|
|
7890
|
+
// ── S3 §4.4: Escape priority is drag > menu > selection > burst ─────
|
|
7891
|
+
// A new ordered PROLOGUE, not a reshuffle of what follows (§4.4 is
|
|
7892
|
+
// explicit about that). The two target-based short-circuits further down
|
|
7893
|
+
// (.ed-wys-cell, .ed-wys-armed) `return` for EVERY key, so the gutter /
|
|
7894
|
+
// insert menu's own Escape branch — which sits BELOW them — was
|
|
7895
|
+
// unreachable whenever an armed surface held focus. And it always does
|
|
7896
|
+
// while a ⠿ menu is open: wireBlockSelection()'s mousedown
|
|
7897
|
+
// preventDefault() deliberately keeps focus inside the burst so a dirty
|
|
7898
|
+
// block's own ⠿ click cannot race its blur-commit.
|
|
7899
|
+
//
|
|
7900
|
+
// Measured on 2026-08-30 against v2.11.0: typing " EDITED" into a
|
|
7901
|
+
// paragraph, opening its ⠿ menu and pressing Escape reached
|
|
7902
|
+
// handleBurstKeydown() -> revertBurstAndEnd() — the paragraph went back
|
|
7903
|
+
// to "alpha", the uncommitted edit was destroyed, AND the menu stayed on
|
|
7904
|
+
// screen. Exactly inverted from what the user asked for.
|
|
7905
|
+
//
|
|
7906
|
+
// ⚠ CORRECTED 2026-08-31 (review recommendation 6). Until this date the
|
|
7907
|
+
// comment here read "the two Escape branches immediately below ... are
|
|
7908
|
+
// deliberately kept: they still carry their non-Escape duties". They had
|
|
7909
|
+
// none — both were gated on `&& e.key === 'Escape'` and both conditions
|
|
7910
|
+
// are re-asked verbatim in this prologue, which returns, so neither could
|
|
7911
|
+
// ever run again. They have been DELETED and the reasoning they carried
|
|
7912
|
+
// is folded in here, which is where the live checks are:
|
|
7913
|
+
//
|
|
7914
|
+
// * the row drag wins the keystroke ahead of everything else, Ctrl+S
|
|
7915
|
+
// included, so a drag in flight is never resolved by a save;
|
|
7916
|
+
// * the edge-menu condition is `(teMenuKind || teHighlightEls.length)`,
|
|
7917
|
+
// NOT `teMenuKind` alone. That widening was made in S0 (final review
|
|
7918
|
+
// I1) for the HEADER grip, whose click deliberately produces a
|
|
7919
|
+
// highlight with no menu (its only item, "delete row", cannot apply to
|
|
7920
|
+
// a header), so teMenuKind stays null. Gated on teMenuKind alone the
|
|
7921
|
+
// Escape fell through to handleTableCellKeydown()'s own Escape branch
|
|
7922
|
+
// -> revertTableBurstAndEnd(), throwing away everything typed into the
|
|
7923
|
+
// burst — so the header row would have been the one place where
|
|
7924
|
+
// dismissing a selection is destructive. Do not narrow it back.
|
|
7925
|
+
//
|
|
7926
|
+
// The `closeGutterMenu()` source-presence guard in
|
|
7927
|
+
// test/editor-client.test.js is NOT affected: it matches the literal
|
|
7928
|
+
// `e.key === 'Escape') { e.preventDefault(); closeGutterMenu();`, which is
|
|
7929
|
+
// the THIRD Escape branch, much further down in this same listener, and
|
|
7930
|
+
// that one is still live for a keystroke this prologue lets through.
|
|
7931
|
+
//
|
|
7932
|
+
// Placement note: this sits ABOVE the `.ed-raw` textarea bail below, which
|
|
7933
|
+
// is where the two now-deleted Escape branches sat too. Harmless for the raw
|
|
7934
|
+
// editor: its own per-instance keydown listener runs in the TARGET phase,
|
|
7935
|
+
// before this document-level one, so its Escape has already been handled;
|
|
7936
|
+
// and the one menu item that opens a raw editor (MD 原始碼) calls
|
|
7937
|
+
// closeGutterMenu() first, so gutterMenuBlockEl is null by then.
|
|
7938
|
+
if (e.key === 'Escape') {
|
|
7939
|
+
if (tePointer && tePointer.dragging) { e.preventDefault(); cancelTeDrag(); return; }
|
|
7940
|
+
if (teMenuKind || teHighlightEls.length) { e.preventDefault(); hideTableEdgeMenu(); return; }
|
|
7941
|
+
if (gutterMenuBlockEl || insertMenuBlockEl) {
|
|
7942
|
+
e.preventDefault();
|
|
7943
|
+
closeGutterMenu();
|
|
7944
|
+
closeInsertMenu();
|
|
7945
|
+
return;
|
|
7946
|
+
}
|
|
7947
|
+
if (blockSelection) { e.preventDefault(); clearBlockSelection(); return; }
|
|
7948
|
+
// Nothing above owns this Escape — fall through to the burst
|
|
7949
|
+
// short-circuits below, where Escape-reverts-the-burst still belongs.
|
|
6647
7950
|
}
|
|
6648
7951
|
|
|
6649
7952
|
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key === 's') {
|
|
@@ -6672,8 +7975,30 @@
|
|
|
6672
7975
|
// heading/list (Enter is an UNCONDITIONAL <br>, Tab moves the active
|
|
6673
7976
|
// cell without ending the burst) — handleTableCellKeydown() owns that
|
|
6674
7977
|
// entire surface, mirroring handleBurstKeydown() just below.
|
|
7978
|
+
// S3: undo/redo is the one pair of keys the two short-circuits below must
|
|
7979
|
+
// NOT claim when there is no burst to own them. resolveBurst()'s zero-edit
|
|
7980
|
+
// path calls endBurstWithoutResolve() and returns WITHOUT re-rendering, so
|
|
7981
|
+
// after a Ctrl+S on an untouched surface `currentBurst` is null while that
|
|
7982
|
+
// surface still holds native focus and its .ed-wys-armed / .ed-wys-cell
|
|
7983
|
+
// class. The next Ctrl+Z then matched a short-circuit, reached
|
|
7984
|
+
// handleBurstKeydown() / handleTableCellKeydown(), and died on their
|
|
7985
|
+
// identical `!currentBurst` bail on the first line — the global undo()
|
|
7986
|
+
// further down was never reached.
|
|
7987
|
+
//
|
|
7988
|
+
// Measured on 2026-08-30 against v2.11.0, for paragraph, li AND table:
|
|
7989
|
+
// type a character, Ctrl+S, click back in, Ctrl+S again (an ordinary habit
|
|
7990
|
+
// keystroke), Ctrl+Z — the file did not change. Pre-existing on main;
|
|
7991
|
+
// fixed here because this is the same dispatch S3 rewrites.
|
|
7992
|
+
//
|
|
7993
|
+
// Deliberately narrow: only the undo/redo keys, and only with no burst.
|
|
7994
|
+
// Every other key on an armed surface keeps going to its burst handler
|
|
7995
|
+
// exactly as before, burst or no burst.
|
|
7996
|
+
const undoRedoKey = (e.ctrlKey || e.metaKey) &&
|
|
7997
|
+
(e.key === 'z' || e.key === 'y' || (e.shiftKey && e.key === 'Z'));
|
|
7998
|
+
const burstOwnsKey = !!currentBurst || !undoRedoKey;
|
|
7999
|
+
|
|
6675
8000
|
const cellEl = e.target && e.target.closest && e.target.closest('.ed-wys-cell');
|
|
6676
|
-
if (cellEl) {
|
|
8001
|
+
if (cellEl && burstOwnsKey) {
|
|
6677
8002
|
handleTableCellKeydown(e, cellEl);
|
|
6678
8003
|
return;
|
|
6679
8004
|
}
|
|
@@ -6684,11 +8009,55 @@
|
|
|
6684
8009
|
// preventDefault() for those keys), so nothing below this must run for
|
|
6685
8010
|
// it either.
|
|
6686
8011
|
const wysArmedEl = e.target && e.target.closest && e.target.closest('.ed-wys-armed');
|
|
6687
|
-
if (wysArmedEl) {
|
|
8012
|
+
if (wysArmedEl && burstOwnsKey) {
|
|
6688
8013
|
handleBurstKeydown(e, wysArmedEl);
|
|
6689
8014
|
return;
|
|
6690
8015
|
}
|
|
6691
8016
|
|
|
8017
|
+
// S3 Task 4, §4.4 entry (c). Deliberately BELOW the two burst
|
|
8018
|
+
// short-circuits above: inside an armed surface (or a table cell)
|
|
8019
|
+
// Shift+↑↓ is the browser's own extend-the-text-selection gesture and
|
|
8020
|
+
// must stay that way. A standing selection's focus holder is a plain
|
|
8021
|
+
// `.ed-block` — neither `.ed-wys-armed` nor `.ed-wys-cell` — so the keys
|
|
8022
|
+
// reach here exactly when block selection is what they can mean.
|
|
8023
|
+
if (e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey &&
|
|
8024
|
+
(e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
|
|
8025
|
+
if (stepSelectionFocus(e.key === 'ArrowUp' ? -1 : 1)) { e.preventDefault(); return; }
|
|
8026
|
+
}
|
|
8027
|
+
|
|
8028
|
+
// ── S3 Task 7: §3.5's 選取集合語意 and §3.6's 「Delete 整批刪」 ─────────
|
|
8029
|
+
// Deliberately BELOW the two burst short-circuits, exactly like the
|
|
8030
|
+
// Shift+↑↓ branch above: inside an armed surface Tab indents the caret's
|
|
8031
|
+
// own item and Delete removes a CHARACTER, and inside a table cell Tab
|
|
8032
|
+
// navigates cells — three contracts this must not touch. A standing
|
|
8033
|
+
// selection puts DOM focus on a plain `.ed-block` wrapper, which is
|
|
8034
|
+
// neither `.ed-wys-armed` nor `.ed-wys-cell`, so these keys arrive here
|
|
8035
|
+
// exactly when block selection is what they can mean.
|
|
8036
|
+
//
|
|
8037
|
+
// `blockSelection` is the gate, and it is the whole guard: with no set
|
|
8038
|
+
// standing there is no focus holder either, and every key below behaves
|
|
8039
|
+
// exactly as it did pre-S3.
|
|
8040
|
+
if (blockSelection && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
|
8041
|
+
if (e.key === 'Tab') {
|
|
8042
|
+
// §3.5, in as many words: 必須 preventDefault(),否則 Tab 在 body 上是
|
|
8043
|
+
// 瀏覽器焦點巡覽.
|
|
8044
|
+
e.preventDefault();
|
|
8045
|
+
tabSelection(e.shiftKey ? -1 : 1);
|
|
8046
|
+
return;
|
|
8047
|
+
}
|
|
8048
|
+
// Backspace is the same gesture as Delete, deliberately: with a set
|
|
8049
|
+
// standing, focus is on a block wrapper and not on any text surface, so
|
|
8050
|
+
// neither key can mean "delete a character" — and a user who selected
|
|
8051
|
+
// blocks and reached for Backspace meant the selection. preventDefault()
|
|
8052
|
+
// matters for its own reason here: an unhandled Backspace outside an
|
|
8053
|
+
// editable is history-navigation in some configurations.
|
|
8054
|
+
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
8055
|
+
e.preventDefault();
|
|
8056
|
+
deleteSelection();
|
|
8057
|
+
return;
|
|
8058
|
+
}
|
|
8059
|
+
}
|
|
8060
|
+
|
|
6692
8061
|
// v2.11.1 acceptance, escape class A: Tab with NOTHING focused. Every
|
|
6693
8062
|
// branch above is keyed on the event target being some edit surface, and
|
|
6694
8063
|
// after a commit / Escape / Ctrl+Z, or a click on a bullet marker or in
|
|
@@ -6931,6 +8300,18 @@
|
|
|
6931
8300
|
else rowGrip.hidden = true;
|
|
6932
8301
|
return;
|
|
6933
8302
|
}
|
|
8303
|
+
// S3 Task 4 (recon hazard 3): a block-selection drag needs the same
|
|
8304
|
+
// gate, for the same reason — this listener keeps firing every frame
|
|
8305
|
+
// regardless, and would repaint the + bubbles and reposition the grips
|
|
8306
|
+
// onto whatever row the cursor is dragging across, on top of the tint.
|
|
8307
|
+
// Both are HIDDEN rather than merely left un-recomputed, so anything
|
|
8308
|
+
// already showing from the frame before the boundary was crossed does
|
|
8309
|
+
// not linger stale for the rest of the gesture.
|
|
8310
|
+
if (blockSelDrag && blockSelDrag.dragging) {
|
|
8311
|
+
hideTableInsertBubbles();
|
|
8312
|
+
hideTableGrips();
|
|
8313
|
+
return;
|
|
8314
|
+
}
|
|
6934
8315
|
updateTableInsertBubbles(tbMoveX, tbMoveY, tbMoveTarget);
|
|
6935
8316
|
updateTableEdgeGrips(tbMoveX, tbMoveY, tbMoveTarget);
|
|
6936
8317
|
});
|