@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.
@@ -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 opIndex = spanEls.indexOf(opBlockEl);
1300
- if (opIndex < 0) return;
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
- b.textContent = label;
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) { closeConvertSubmenu(); return; }
1619
- openConvertSubmenu(gutterMenuConvert);
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: a table block has no 轉換成 at all (there is no target that
1716
- // could carry a table's cells, and every one of the twelve would destroy
1717
- // them).
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
- gutterMenuMd.hidden = (blockType === 'li');
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
- async function convertBlockViaMenu(blockEl, target) {
2169
- if (!blockEl || !target) return;
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 deleteListItemViaGutter()'s own note.
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
- // not-yet-implemented refusals below and, further down, of stripMarker():
2206
- // a multi-line li must report §4.1's 「此清單含不支援的格式,無法調整結構」
2207
- // and not convert-md.js's per-block 「此區塊的格式無法轉換」, which is what
2208
- // it would get if stripMarker() saw it first (that function refuses a
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
- // it is what notices if this order is ever flipped.
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
- // own text or line count, so a multi-line li refuses as a TARGET while
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 (kind === 'li') {
2218
- liRun = listRunOf(liveBlockEl);
2219
- if (!liRun.length) return;
2220
- if (!listRunSupportsStructuralEdit(liRun, liveBlockEl)) { refuseStructuralListEdit(); return; }
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 block stays a li, so the run stays a
2224
- // run and the existing re-serialization machinery applies unchanged.
2225
- if (kind === 'li' && convertMd.targetIsList(target)) {
2226
- await convertListItemType(liveBlockEl, liRun, target);
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 item LEAVES the run, so the span
2230
- // has to be rebuilt in three pieces and §4.3 rule 1's blank lines put
2231
- // between them — the plain path below would leave the converted line
2232
- // mid-list with no separator and lazy continuation would swallow it into
2233
- // the item above (measured, §4.3 rule 1).
2234
- if (kind === 'li') {
2235
- await convertListItemAway(liveBlockEl, liRun, rec, target);
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: a non-list block BECOMES a li, so §4.3 rule 2's looseness
2239
- // policy applies — eat the separator to an adjacent run of the same list
2240
- // type, or the merged list goes LOOSE and every item of it degrades
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 convertBlockIntoList(liveBlockEl, rec, kind, target);
2722
+ await convertBlocksIntoList(els, recs, shape.kinds, target);
2244
2723
  return;
2245
2724
  }
2246
2725
 
2247
- const src = lines.slice(rec.startLine - 1, rec.endLine);
2248
- const stripped = convertMd.stripMarker(src, kind);
2249
- if (!stripped.ok) { refuseStructuralListEdit('此區塊的格式無法轉換'); return; }
2250
- const newLines = convertMd.emitAs(stripped.content, target, {});
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
- rec.startLine, rec.endLine, newLines.join('\n'));
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 (duplicateListItem() below), which emits no blank at
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
- if (!blockEl) return;
2322
- const identity = captureBlockIdentity(blockEl);
2323
- // Finding 5a's mousedown preventDefault() deliberately keeps a dirty burst
2324
- // alive across the ⠿ press, so the commit that lands inside
2325
- // switchAwayFrom() below can be a rewrite of the very block we are about
2326
- // to duplicate — in which case reresolveBlockEl()'s SOURCE fingerprint is
2327
- // guaranteed to miss, because WE are the reason the source changed. Same
2328
- // narrowed recovery convertBlockViaMenu() uses, for the same reason; see
2329
- // reresolveBlockElAfterSelfCommit().
2330
- const selfSession = ownsOpenSession(blockEl);
2331
- const ok = await switchAwayFrom();
2332
- if (!ok) return;
2333
- let liveBlockEl = blockEl;
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
- const result = commitBlockInsertion({ lines, blocks, stack }, liveBlockId,
2354
- lines.slice(rec.startLine - 1, rec.endLine));
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 duplicateListItem(liEl) {
2370
- const run = listRunOf(liEl);
2371
- if (!run.length) return;
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
- if (!listRunSupportsStructuralEdit(run, liEl)) { refuseStructuralListEdit(); return; }
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
- const subtree = subtreeBlocksAfter(liEl, Number(liEl.getAttribute('data-indent')) || 0);
2392
- const lastEl = subtree.length ? subtree[subtree.length - 1] : liEl;
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 copy = liEl.cloneNode(true);
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([copy], run.slice(at + 1));
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
- lastEl.parentNode.insertBefore(copy, lastEl.nextSibling);
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
- if (!blockEl) return;
2434
- // T7: same startLine + source-fingerprint recovery as insertBlockBelow()
2435
- // above, and the stake here is higher an id shift used to make this
2436
- // delete a DIFFERENT block's lines, with the menu the user pressed
2437
- // pointing at a block that survived.
2438
- const identity = captureBlockIdentity(blockEl);
2439
- // S2 Task 6: the SAME hole convertBlockViaMenu() closed in Task 2, which
2440
- // this path never got. Finding 5a's mousedown preventDefault() keeps a
2441
- // dirty burst alive across the ⠿ press, so the commit that lands inside
2442
- // switchAwayFrom() below can be a rewrite of the very block being deleted
2443
- // and reresolveBlockEl()'s fingerprint is that block's SOURCE, so it is
2444
- // guaranteed to MISS, because WE are the reason the source changed.
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
- // deleteListItemViaGutter() for the measurements and the routing.
2484
- if (liveBlockEl.getAttribute('data-block-type') === 'li') {
2485
- await deleteListItemViaGutter(liveBlockEl);
2960
+ // deleteListItemsViaGutter() for the measurements and the routing.
2961
+ if (shape.allLi) {
2962
+ await deleteListItemsViaGutter(els);
2486
2963
  return;
2487
2964
  }
2488
- const result = commitListBlockRemoval({ lines, blocks, stack }, liveBlockId);
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 deleteListItemViaGutter(liEl) {
2526
- const run = listRunOf(liEl);
2527
- if (!run.length) return;
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, liEl)) { refuseStructuralListEdit(); return; }
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
- const oldIndent = Number(liEl.getAttribute('data-indent')) || 0;
2538
- const survivors = run.filter((el) => el !== liEl);
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 `liEl` is still in the span: `{ removed: true }` is
2541
- // what tells the pure function that this block can no longer anchor
2542
- // anything, and rule 2's scope is measured from its position.
2543
- applyIndentClamp(run, liEl, oldIndent, { removed: true });
2544
- removeListItem(liEl);
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 convertListItemAway() above.
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 `liEl` has become
2688
- // `attrs`. Everything except the converted item keeps what it already has.
2689
- function postConvertLiAttrs(el, liEl, attrs) {
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 === liEl
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 convertListItemType(liEl, run, target) {
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, liEl, attrs), postConvertLiAttrs(tailEl, liEl, attrs));
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
- liEl.setAttribute('data-list-type', attrs.listType);
2728
- liEl.setAttribute('data-task', attrs.task ? '1' : '0');
2729
- const box = liCheckEl(liEl);
2730
- if (attrs.task) {
2731
- // Insert BEFORE the surface: §4.1 fixes the child order as
2732
- // marker check text, and list-md.js's firstChildWithClass() plus
2733
- // the delegated checkbox-toggle listener both assume it.
2734
- if (!box) liEl.insertBefore(buildLiCheckbox(), liTextEl(liEl));
2735
- } else if (box) {
2736
- box.remove();
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 convertListItemAway(liEl, run, rec, target) {
3314
+ async function convertListItemsAway(liEls, run, recs, target) {
2792
3315
  const range = runRangeOfBlocks({ lines, blocks, stack }, run);
2793
3316
  if (!range) return;
2794
- const stripped = convertMd.stripMarker(lines.slice(rec.startLine - 1, rec.endLine), 'li');
2795
- if (!stripped.ok) { refuseStructuralListEdit('此區塊的格式無法轉換'); return; }
2796
- const convertedLines = convertMd.emitAs(stripped.content, target, {});
2797
-
2798
- const oldIndent = Number(liEl.getAttribute('data-indent')) || 0;
2799
- const idx = run.indexOf(liEl);
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(idx + 1);
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, liEl, oldIndent, { operatedBecomes: { type: convertedBlockType(target) } });
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 convertListItemType()'s note.
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
- pieces.push(convertedLines.join('\n'));
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 convertBlockIntoList(blockEl, rec, kind, target) {
3437
+ async function convertBlocksIntoList(blockEls, recs, kinds, target) {
2877
3438
  const attrs = convertMd.listAttrsFor(target);
2878
3439
  if (!attrs) return;
2879
- const stripped = convertMd.stripMarker(lines.slice(rec.startLine - 1, rec.endLine), kind);
2880
- if (!stripped.ok) { refuseStructuralListEdit('此區塊的格式無法轉換'); return; }
2881
- const newLines = convertMd.emitAs(stripped.content, target, {});
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
- // block's post-conversion identity is (target list type, indent 0) on both
2885
- // edges — it emits one item, however many physical lines that item spans.
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: rec.startLine, endLine: rec.endLine }, [blockEl], self, self);
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, newLines.join('\n'));
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
- if (!targetEl) return multi.length === 0;
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
- const targetRaw = targetEl.getAttribute('data-block-id');
3679
- const targetRec = blocks.find((b) => b.id === Number(targetRaw));
3680
- if (targetRec && targetRec.endLine > targetRec.startLine) return false;
3681
- // getAttribute() strings on both sides the same convention
3682
- // unsupportedByLi[].blockId uses.
3683
- return multi.indexOf(targetRaw) === -1;
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) return;
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
- // Task 6: Esc during an in-flight row drag cancels JUST the drag (no
6618
- // mutation the row was never actually moved, only the indicator line
6619
- // tracked the pointer) a DISTINCT gesture from Esc-reverts-burst
6620
- // (handleTableCellKeydown() below), which must never fire for this same
6621
- // keypress. Checked before EVERY other branch (including Ctrl+S) so a
6622
- // drag in flight always wins the keystroke.
6623
- if (tePointer && tePointer.dragging && e.key === 'Escape') {
6624
- e.preventDefault();
6625
- cancelTeDrag();
6626
- return;
6627
- }
6628
- // Task 6: Esc with the edge menu open closes JUST the menu — same
6629
- // "intercept before the wys-cell/wys-armed Escape branches" reasoning,
6630
- // since the cell that was focused (if any) before the menu opened is
6631
- // still focused underneath it (the menu's own mousedown preventDefault()
6632
- // never stole focus).
6633
- // Final review I1: the condition is `(teMenuKind || teHighlightEls.length)`,
6634
- // NOT `teMenuKind` alone the exact same widening the pointerdown dismiss
6635
- // gate already got, and for the same reason: a header grip's click
6636
- // deliberately produces a highlight with NO menu (its only menu item,
6637
- // "delete row", cannot apply to a header), so teMenuKind stays null. Gated
6638
- // on teMenuKind alone, that Esc fell through to handleTableCellKeydown()'s
6639
- // own Escape branch -> revertTableBurstAndEnd(), throwing away everything
6640
- // typed into the burst. With a BODY row's menu open the identical keypress
6641
- // merely closes the menu, so the header row would have been the one place
6642
- // where dismissing a selection is destructive.
6643
- if ((teMenuKind || teHighlightEls.length) && e.key === 'Escape') {
6644
- e.preventDefault();
6645
- hideTableEdgeMenu();
6646
- return;
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
  });