@helping-ai-workflow/md2doc 2.11.0 → 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',
@@ -1553,6 +1622,16 @@
1553
1622
  el.className = 'ed-handle';
1554
1623
  el.textContent = '⠿';
1555
1624
  el.setAttribute('aria-label', '區塊選項');
1625
+ // v2.11.1: a <button> is a sequential focus stop, and there is one of
1626
+ // these plus one + standing immediately after EVERY block — so any Tab
1627
+ // that reaches the browser walks straight into gutter chrome, which is the
1628
+ // most jarring shape of the two escape classes fixed above. Both are
1629
+ // mouse-only affordances with no keyboard contract of their own (the ⠿
1630
+ // menu is opened by click; nothing here is reachable or operable by
1631
+ // keyboard today), so they are removed from the tab order rather than
1632
+ // given one they do not have. tabindex="-1" keeps them programmatically
1633
+ // and click-focusable, so `.ed-handle:focus { opacity: 1 }` still works.
1634
+ el.setAttribute('tabindex', '-1');
1556
1635
  // Deliberately NOT wired with its own addEventListener here (see the
1557
1636
  // paragraph above) — including for Final-review Finding 5a's mousedown
1558
1637
  // preventDefault() (see wireBlockSelection()'s delegated 'mousedown'
@@ -1565,7 +1644,7 @@
1565
1644
  return el;
1566
1645
  }
1567
1646
 
1568
- // The single shared ⠿ menu (spec §3.7: 轉換成 › / 複製 / 刪除 / MD 原始碼)
1647
+ // The single shared ⠿ menu (spec §3.7: 轉換成 › / 建立副本 / 刪除 / MD 原始碼)
1569
1648
  // — built once, moved into whichever block's DOM the user opened it on,
1570
1649
  // same pattern as `selToolbar` elsewhere in this file. `gutterMenuBlockEl`
1571
1650
  // names which block it's currently open for. Because the node is a
@@ -1586,35 +1665,175 @@
1586
1665
  // without a second selector.
1587
1666
  let convertSubmenu = null;
1588
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
+
1589
1778
  function buildGutterMenu() {
1590
1779
  const el = document.createElement('div');
1591
1780
  el.className = 'ed-handle-menu';
1592
1781
 
1593
- function item(label, aria, onClick) {
1782
+ function item(label, aria, onClick, icon) {
1594
1783
  const b = document.createElement('button');
1595
1784
  b.type = 'button';
1596
1785
  b.className = 'ed-handle-menu-btn';
1597
- 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));
1598
1794
  b.setAttribute('aria-label', aria);
1599
1795
  b.addEventListener('click', onClick);
1600
1796
  el.appendChild(b);
1601
1797
  return b;
1602
1798
  }
1603
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
+
1604
1811
  // 轉換成 is the one item that does NOT close the menu — it grows a
1605
1812
  // submenu, and a second press folds it back up.
1606
1813
  gutterMenuConvert = item('轉換成 ›', 'Convert this block', (e) => {
1607
1814
  e.stopPropagation();
1608
- if (convertSubmenu) { closeConvertSubmenu(); return; }
1609
- openConvertSubmenu(gutterMenuConvert);
1610
- });
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');
1611
1830
 
1612
- gutterMenuDuplicate = item('複製', 'Duplicate this block', (e) => {
1831
+ gutterMenuDuplicate = item('建立副本', 'Duplicate this block', (e) => {
1613
1832
  e.stopPropagation();
1614
1833
  const blockEl = gutterMenuBlockEl;
1615
1834
  closeGutterMenu();
1616
1835
  duplicateBlockViaMenu(blockEl);
1617
- });
1836
+ }, 'duplicate');
1618
1837
 
1619
1838
  // §10-gap fix: block-level DELETE. Reuses commitListBlockRemoval()
1620
1839
  // unchanged (that function was already fully block-type-agnostic —
@@ -1628,14 +1847,14 @@
1628
1847
  const blockEl = gutterMenuBlockEl;
1629
1848
  closeGutterMenu();
1630
1849
  deleteBlockViaGutter(blockEl);
1631
- });
1850
+ }, 'trash');
1632
1851
 
1633
1852
  gutterMenuMd = item('MD 原始碼', 'Switch to raw markdown edit', (e) => {
1634
1853
  e.stopPropagation();
1635
1854
  const blockEl = gutterMenuBlockEl;
1636
1855
  closeGutterMenu();
1637
1856
  openRawViaGutter(blockEl);
1638
- });
1857
+ }, 'code');
1639
1858
 
1640
1859
  return el;
1641
1860
  }
@@ -1647,7 +1866,7 @@
1647
1866
  // `left: 100%` (lib/md2doc.js) resolves against the menu's padding box —
1648
1867
  // no viewport arithmetic, and the panel travels with the menu when the menu
1649
1868
  // is moved into another block.
1650
- function openConvertSubmenu(anchorBtn) {
1869
+ function openConvertSubmenu(anchorBtn, viaHover) {
1651
1870
  closeConvertSubmenu();
1652
1871
  const sub = document.createElement('div');
1653
1872
  sub.className = 'ed-handle-menu ed-handle-submenu';
@@ -1668,9 +1887,22 @@
1668
1887
  sub.style.top = anchorBtn.offsetTop + 'px';
1669
1888
  anchorBtn.parentNode.appendChild(sub);
1670
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);
1671
1899
  }
1672
1900
 
1673
1901
  function closeConvertSubmenu() {
1902
+ cancelSubmenuClose();
1903
+ submenuAimPrev = null;
1904
+ convertSubmenuViaHover = false;
1905
+ document.removeEventListener('mousemove', onSubmenuPointerMove);
1674
1906
  if (convertSubmenu) { convertSubmenu.remove(); convertSubmenu = null; }
1675
1907
  }
1676
1908
 
@@ -1686,6 +1918,46 @@
1686
1918
  gutterMenuBlockEl = null;
1687
1919
  }
1688
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
+
1689
1961
  function toggleGutterMenu(blockEl) {
1690
1962
  if (!blockEl) return;
1691
1963
  if (gutterMenuBlockEl === blockEl) { closeGutterMenu(); return; }
@@ -1702,21 +1974,10 @@
1702
1974
  closeConvertSubmenu();
1703
1975
  gutterMenuBlockEl = blockEl;
1704
1976
  const blockType = blockEl.getAttribute('data-block-type');
1705
- // Spec §7: a table block has no 轉換成 at all (there is no target that
1706
- // could carry a table's cells, and every one of the twelve would destroy
1707
- // them).
1708
- //
1709
- // 'hr' and 'html' are withheld for a different, measured reason: the
1710
- // gesture would LIE. convert-md strips a block's marker to get its
1711
- // content, and an <hr> has no content — its source line IS the marker.
1712
- // Measured: 'hr' → 項目符號列表 writes '- ---', which marked re-lexes
1713
- // as an hr again, so the file's bytes change, the block type does not,
1714
- // and no banner is shown. 'hr' → 文字 is a byte no-op, also silent.
1715
- // An 'html' block is raw passthrough for the same reason: there is no
1716
- // marker to strip and no content to re-host. Nothing is lost either way,
1717
- // but an item that appears to work and does nothing is worse than an
1718
- // item that is not offered.
1719
- 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);
1720
1981
  gutterMenuDuplicate.hidden = false;
1721
1982
  gutterMenuDelete.hidden = false;
1722
1983
  // RULING F-O: 'MD 原始碼' is hidden for a list item PERMANENTLY, not as a
@@ -1728,7 +1989,28 @@
1728
1989
  // between blocks, so this must be reset on every open, not set once.
1729
1990
  // (test/editor-reader-rebind.test.js drives raw-edit through this button
1730
1991
  // by its exact text on a paragraph.)
1731
- 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;
1732
2014
  blockEl.appendChild(gutterMenu);
1733
2015
  }
1734
2016
 
@@ -1745,6 +2027,8 @@
1745
2027
  el.className = 'ed-insert';
1746
2028
  el.textContent = '+';
1747
2029
  el.setAttribute('aria-label', '插入區塊');
2030
+ // Not a tab stop — see buildGutterHandle() above for the whole reason.
2031
+ el.setAttribute('tabindex', '-1');
1748
2032
  return el;
1749
2033
  }
1750
2034
 
@@ -1944,7 +2228,7 @@
1944
2228
  // the ids — see captureBlockIdentity()'s comment.
1945
2229
  const identity = captureBlockIdentity(blockEl);
1946
2230
  // S2 Task 7: the FOURTH and last call site of the hole 轉換 (Task 2), 刪除
1947
- // and 複製 (Task 6) already closed, and the one that was latent only
2231
+ // and 建立副本 (Task 6) already closed, and the one that was latent only
1948
2232
  // because a li had no + to press. Finding 5a's delegated mousedown
1949
2233
  // preventDefault() names '.ed-insert' as well as '.ed-handle', so the
1950
2234
  // burst survives the press and the commit that lands inside
@@ -1987,7 +2271,7 @@
1987
2271
  //
1988
2272
  // 1. THE INSERTION POINT IS THE END OF THE ANCHOR'S SUBTREE, not the
1989
2273
  // anchor's own last line. This is the ruling §4.3 already made for
1990
- // 複製 (「副本插在該 block 整棵子樹之後」), and it is what makes every
2274
+ // 建立副本 (「副本插在該 block 整棵子樹之後」), and it is what makes every
1991
2275
  // non-list kind safe here. Measured on ['# Doc','','- alpha',
1992
2276
  // ' - child',' - grand','']: anchored on `child`,
1993
2277
  // commitBlockInsertion() with the 段落 skeleton yields
@@ -2004,7 +2288,7 @@
2004
2288
  // measured, '# Doc\n\n- alpha\n - child\n\n -\n' has a NESTED
2005
2289
  // list with loose === true, so every item of it grows a <p>,
2006
2290
  // serializeBlocks() pushes 'P' for each and the run degrades read-only
2007
- // with no banner. Same fork 複製 hit in Task 6, and the same answer:
2291
+ // with no banner. Same fork 建立副本 hit in Task 6, and the same answer:
2008
2292
  // route the li through its own run's re-serialization, which emits no
2009
2293
  // blank at all, re-runs §3.8's renumbering, and — the point of carry 2
2010
2294
  // — takes the new item's indent prefix from the serializer's own
@@ -2153,97 +2437,323 @@
2153
2437
  // li -> heading would hit the degrade path and refuse itself. Reading `lines`
2154
2438
  // also means the inline content is never re-serialized, so escapeText() never
2155
2439
  // runs over it and a `~5px` in the converted block stays `~5px`.
2156
- async function convertBlockViaMenu(blockEl, target) {
2157
- 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.
2158
2475
  const identity = captureBlockIdentity(blockEl);
2159
- // Whether the session switchAwayFrom() is about to resolve belongs to THIS
2160
- // block. Finding 5a's mousedown preventDefault() deliberately keeps a dirty
2161
- // burst alive across the ⠿ press, so the commit that lands inside
2162
- // switchAwayFrom() below can be a rewrite of the very block we are about to
2163
- // convert — in which case reresolveBlockEl()'s source fingerprint is
2164
- // guaranteed to miss, because WE are the reason the source changed. That is
2165
- // not a dropped gesture; startLine + type still name the block, and the
2166
- // fingerprint's job (proving an UNRELATED commit did not move somebody else
2167
- // into this slot) is done by those two here.
2168
2476
  const selfSession = ownsOpenSession(blockEl);
2169
2477
  const ok = await switchAwayFrom();
2170
- if (!ok) return;
2478
+ if (!ok) return null;
2171
2479
  let liveBlockEl = blockEl;
2172
2480
  if (!document.body.contains(blockEl)) {
2173
2481
  liveBlockEl = reresolveBlockEl(identity) ||
2174
2482
  (selfSession ? reresolveBlockElAfterSelfCommit(identity) : null);
2175
- 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;
2176
2672
  }
2177
- // Same refusal deleteBlockViaGutter() makes, for the same reason: a block
2178
- // that owns no source line has an INVERTED range (endLine === startLine-1),
2179
- // and every commit helper handed one does something plausible and wrong.
2180
- if (blockOwnsNoLine(liveBlockEl)) { refuseStructuralListEdit(NO_SOURCE_LINE_MESSAGE); return; }
2181
- const liveBlockId = Number(liveBlockEl.getAttribute('data-block-id'));
2182
- const rec = blocks.find((b) => b.id === liveBlockId);
2183
- if (!rec) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
2184
- const kind = liveBlockEl.getAttribute('data-block-type');
2185
2673
 
2186
- // §4.3's run-wide gate: 轉換/複製/刪除/拖曳 all pass through
2674
+ // §4.3's run-wide gate: 轉換/建立副本/刪除/拖曳 all pass through
2187
2675
  // listRunSupportsStructuralEdit() BEFORE any mutation, the same door
2188
2676
  // Tab/Enter/checkbox already use. Its input is §3.4 rule 2's SCOPE, which
2189
2677
  // is exactly what listRunOf() returns (the outermost run PLUS every
2190
- // descendant of its members) — see deleteListItemViaGutter()'s own note.
2678
+ // descendant of its members) — see deleteListItemsViaGutter()'s own note.
2191
2679
  //
2192
2680
  // ORDERING IS LOAD-BEARING, not incidental. This sits AHEAD of the
2193
- // not-yet-implemented refusals below and, further down, of stripMarker():
2194
- // a multi-line li must report §4.1's 「此清單含不支援的格式,無法調整結構」
2195
- // and not convert-md.js's per-block 「此區塊的格式無法轉換」, which is what
2196
- // 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
2197
2685
  // multi-line li too, for its own, narrower reason). The runtime scenario
2198
- // 'a multi-line li refuses with the §4.1 banner' asserts the MESSAGE, so
2199
- // 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.
2200
2688
  //
2201
- // A conversion is NOT column-only (§4.1 修訂 2): it rewrites the item's
2202
- // own text or line count, so a multi-line li refuses as a TARGET while
2203
- // 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.
2204
2694
  let liRun = null;
2205
- if (kind === 'li') {
2206
- liRun = listRunOf(liveBlockEl);
2207
- if (!liRun.length) return;
2208
- 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; }
2209
2699
  }
2210
2700
 
2211
- // S2 Task 3: li → a LIST target. The block stays a li, so the run stays a
2212
- // run and the existing re-serialization machinery applies unchanged.
2213
- if (kind === 'li' && convertMd.targetIsList(target)) {
2214
- 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);
2215
2706
  return;
2216
2707
  }
2217
- // S2 Task 4: li → a NON-list target. The item LEAVES the run, so the span
2218
- // has to be rebuilt in three pieces and §4.3 rule 1's blank lines put
2219
- // between them — the plain path below would leave the converted line
2220
- // mid-list with no separator and lazy continuation would swallow it into
2221
- // the item above (measured, §4.3 rule 1).
2222
- if (kind === 'li') {
2223
- 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);
2224
2715
  return;
2225
2716
  }
2226
- // S2 Task 5: a non-list block BECOMES a li, so §4.3 rule 2's looseness
2227
- // policy applies — eat the separator to an adjacent run of the same list
2228
- // type, or the merged list goes LOOSE and every item of it degrades
2229
- // 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.
2230
2721
  if (convertMd.targetIsList(target)) {
2231
- await convertBlockIntoList(liveBlockEl, rec, kind, target);
2722
+ await convertBlocksIntoList(els, recs, shape.kinds, target);
2232
2723
  return;
2233
2724
  }
2234
2725
 
2235
- const src = lines.slice(rec.startLine - 1, rec.endLine);
2236
- const stripped = convertMd.stripMarker(src, kind);
2237
- if (!stripped.ok) { refuseStructuralListEdit('此區塊的格式無法轉換'); return; }
2238
- 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');
2239
2740
 
2240
2741
  const result = commitRangeEdit({ lines, blocks, stack },
2241
- rec.startLine, rec.endLine, newLines.join('\n'));
2742
+ first.startLine, last.endLine, md);
2242
2743
  // Nothing changed (converting an H2 to 標題 2) — and nothing was pushed
2243
- // 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.
2244
2746
  if (result.op === null) return;
2245
2747
  const prevLines = lines;
2246
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
+ });
2247
2757
  const okRender = await safeRerenderAll();
2248
2758
  if (!okRender) {
2249
2759
  lines = rollbackFailedRender({ lines, stack }, result, prevLines);
@@ -2272,7 +2782,7 @@
2272
2782
  return document.querySelector('.ed-block[data-block-id="' + at.id + '"]');
2273
2783
  }
2274
2784
 
2275
- // S2 Task 6 — 複製 (§4.3).
2785
+ // S2 Task 6 — 建立副本 (§4.3).
2276
2786
  //
2277
2787
  // The copy is inserted after the block's ENTIRE SUBTREE, never after its own
2278
2788
  // line. The spec records the measurement and it reproduces here:
@@ -2298,7 +2808,7 @@
2298
2808
  // them (list-md.js:462) and the WHOLE run degrades read-only with no
2299
2809
  // banner — §4.3 rule 2's defect, re-opened by a duplicate instead of by a
2300
2810
  // conversion. A li therefore duplicates through its own RUN's
2301
- // re-serialization (duplicateListItem() below), which emits no blank at
2811
+ // re-serialization (duplicateListItems() below), which emits no blank at
2302
2812
  // all and re-runs §3.8's renumbering on the way.
2303
2813
  //
2304
2814
  // Neither path re-serializes the copy's CONTENT: the non-li path slices
@@ -2306,64 +2816,62 @@
2306
2816
  // under the ORIGINAL's block id, so list-md.js replays the file's own bytes
2307
2817
  // for it and only re-states the marker. Both keep a `~5px` a `~5px`.
2308
2818
  async function duplicateBlockViaMenu(blockEl) {
2309
- if (!blockEl) return;
2310
- const identity = captureBlockIdentity(blockEl);
2311
- // Finding 5a's mousedown preventDefault() deliberately keeps a dirty burst
2312
- // alive across the ⠿ press, so the commit that lands inside
2313
- // switchAwayFrom() below can be a rewrite of the very block we are about
2314
- // to duplicate — in which case reresolveBlockEl()'s SOURCE fingerprint is
2315
- // guaranteed to miss, because WE are the reason the source changed. Same
2316
- // narrowed recovery convertBlockViaMenu() uses, for the same reason; see
2317
- // reresolveBlockElAfterSelfCommit().
2318
- const selfSession = ownsOpenSession(blockEl);
2319
- const ok = await switchAwayFrom();
2320
- if (!ok) return;
2321
- let liveBlockEl = blockEl;
2322
- if (!document.body.contains(blockEl)) {
2323
- liveBlockEl = reresolveBlockEl(identity) ||
2324
- (selfSession ? reresolveBlockElAfterSelfCommit(identity) : null);
2325
- if (!liveBlockEl) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
2326
- }
2327
- // Same refusal deleteBlockViaGutter() and convertBlockViaMenu() make, for
2328
- // the same reason: a block that owns no source line has an INVERTED range
2329
- // (endLine === startLine - 1) and every commit helper handed one does
2330
- // something plausible and wrong.
2331
- if (blockOwnsNoLine(liveBlockEl)) { refuseStructuralListEdit(NO_SOURCE_LINE_MESSAGE); return; }
2332
- const liveBlockId = Number(liveBlockEl.getAttribute('data-block-id'));
2333
- const rec = blocks.find((b) => b.id === liveBlockId);
2334
- if (!rec) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
2335
-
2336
- if (liveBlockEl.getAttribute('data-block-type') === 'li') {
2337
- 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);
2338
2832
  return;
2339
2833
  }
2340
2834
 
2341
- const result = commitBlockInsertion({ lines, blocks, stack }, liveBlockId,
2342
- 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));
2343
2844
  if (result.op === null) return;
2344
2845
  const prevLines = lines;
2345
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 });
2346
2852
  if (!(await safeRerenderAll())) {
2347
2853
  lines = rollbackFailedRender({ lines, stack }, result, prevLines);
2348
2854
  }
2349
2855
  }
2350
2856
 
2351
- // The li half of 複製. The copy is spliced into the run's own span and the
2857
+ // The li half of 建立副本. The copy is spliced into the run's own span and the
2352
2858
  // WHOLE span is re-serialized over the run's line range — one commitRangeEdit,
2353
- // therefore one undo op (§4.3: 複製與刪除均為單一 undo), no leading blank, and
2859
+ // therefore one undo op (§4.3: 建立副本與刪除均為單一 undo), no leading blank, and
2354
2860
  // §3.8's renumbering falls out of the re-serialization ('1. alpha' duplicated
2355
2861
  // gives '1. alpha / 2. alpha / 3. bravo', not '1. alpha / 1. alpha / 2.
2356
2862
  // bravo').
2357
- async function duplicateListItem(liEl) {
2358
- const run = listRunOf(liEl);
2359
- if (!run.length) return;
2360
- // §4.3's run-wide gate 轉換/複製/刪除/拖曳 each make this call for
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; }
2867
+ // §4.3's run-wide gate — 轉換/建立副本/刪除/拖曳 each make this call for
2361
2868
  // themselves; there is no shared helper. Its input is §3.4 rule 2's scope,
2362
2869
  // which is exactly what listRunOf() returns (the outermost run PLUS every
2363
2870
  // descendant of its members). A duplicate is NOT column-only (§4.1 修訂 2:
2364
2871
  // it adds the item's lines over again), so a multi-line li refuses as a
2365
- // TARGET while remaining a perfectly good bystander.
2366
- 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; }
2367
2875
  // Captured BEFORE the copy enters the span. The copy carries the
2368
2876
  // ORIGINAL's data-block-id — that is what makes bystanderCarryOver() replay
2369
2877
  // its bytes rather than re-escape them — so runRangeOfBlocks() would
@@ -2376,22 +2884,29 @@
2376
2884
  // subtreeBlocksAfter() is the flat model's subtree — the contiguous run of
2377
2885
  // following blocks at a STRICTLY greater indent — and listRunOf() already
2378
2886
  // covers every one of them, so the insertion point is always inside `run`.
2379
- const subtree = subtreeBlocksAfter(liEl, Number(liEl.getAttribute('data-indent')) || 0);
2380
- 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;
2381
2893
  const at = run.indexOf(lastEl);
2382
2894
  if (at < 0) return;
2383
2895
 
2384
- const copy = liEl.cloneNode(true);
2896
+ const copies = liEls.map((liEl) => liEl.cloneNode(true));
2385
2897
  // `data-list-start` is the ONLY carrier of "marked's lexer opened a new
2386
2898
  // list token here" (§3.8 rule (d)) and serializeBlocks() resets the
2387
2899
  // ordinal counter on it. A copy is never a token boundary — it sits inside
2388
2900
  // the run it was cloned from — so a clone that kept the attribute would
2389
2901
  // restart the numbering: duplicating the first item of '1. alpha / 2.
2390
2902
  // bravo' emits '1. alpha / 1. alpha / 2. bravo'.
2391
- copy.removeAttribute('data-list-start');
2392
- 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));
2393
2905
  mutateListRun(() => {
2394
- 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); });
2395
2910
  });
2396
2911
  // No `mutatedEl`: nothing in this span had its CONTENT rewritten in the
2397
2912
  // DOM, the copy included. The map is what keeps both lines byte-identical
@@ -2410,6 +2925,15 @@
2410
2925
  // list-md.js re-states each line's marker from that element's OWN
2411
2926
  // attributes — which is the §3.8 renumbering, and which is also how the
2412
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
+ });
2413
2937
  await commitListStructure(span, null, false,
2414
2938
  { presetRange: range, carryOver: bystanderCarryOver(span) });
2415
2939
  }
@@ -2418,64 +2942,45 @@
2418
2942
  // emptied-out list). Same resolve-first / re-query-live-block-by-id
2419
2943
  // precondition as insertBlockBelow() above.
2420
2944
  async function deleteBlockViaGutter(blockEl) {
2421
- if (!blockEl) return;
2422
- // T7: same startLine + source-fingerprint recovery as insertBlockBelow()
2423
- // above, and the stake here is higher an id shift used to make this
2424
- // delete a DIFFERENT block's lines, with the menu the user pressed
2425
- // pointing at a block that survived.
2426
- const identity = captureBlockIdentity(blockEl);
2427
- // S2 Task 6: the SAME hole convertBlockViaMenu() closed in Task 2, which
2428
- // this path never got. Finding 5a's mousedown preventDefault() keeps a
2429
- // dirty burst alive across the ⠿ press, so the commit that lands inside
2430
- // switchAwayFrom() below can be a rewrite of the very block being deleted
2431
- // and reresolveBlockEl()'s fingerprint is that block's SOURCE, so it is
2432
- // guaranteed to MISS, because WE are the reason the source changed.
2433
- // Measured before this line existed: edit a paragraph, press ⠿ without
2434
- // blurring, 刪除 — the gesture was dropped with '文件已更新,請重試這個操作'
2435
- // and the block stayed on screen. The narrowed re-resolve (startLine +
2436
- // type, no fingerprint) is used ONLY when the session that just committed
2437
- // was this block's OWN; reresolveBlockEl() keeps its fingerprint for
2438
- // everybody else.
2439
- const selfSession = ownsOpenSession(blockEl);
2440
- const ok = await switchAwayFrom();
2441
- if (!ok) return;
2442
- let liveBlockEl = blockEl;
2443
- if (!document.body.contains(blockEl)) {
2444
- liveBlockEl = reresolveBlockEl(identity) ||
2445
- (selfSession ? reresolveBlockElAfterSelfCommit(identity) : null);
2446
- if (!liveBlockEl) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
2447
- }
2448
- const liveBlockId = Number(liveBlockEl.getAttribute('data-block-id'));
2449
- const block = blocks.find((b) => b.id === liveBlockId);
2450
- if (!block) return;
2451
- // Task 4 fix round 1 (Critical): refuse a block that owns no source line.
2452
- // Its range is INVERTED (endLine === startLine - 1 — see blockOwnsNoLine()),
2453
- // and commitListBlockRemoval() -> commitRangeRemoval() does not guard
2454
- // `endLine >= startLine`: with sl=5, el=4 the blank-line absorption reads
2455
- // state.lines[sl - 2], which for an inverted range is a blank line
2456
- // belonging to a DIFFERENT block, finds it blank, and deletes it. Nothing
2457
- // visible happens — no error, no banner, the .ed-block count is unchanged
2458
- // — but the file loses a separator (measured: '# Doc\n\n- a\n\n- - b\n'
2459
- // -> '# Doc\n\n- a\n- - b\n').
2460
- //
2461
- // Same predicate canWysiwygForLi() already refuses on, so "cannot be
2462
- // armed" and "cannot be deleted" stay one decision. The ⠿ itself is NOT
2463
- // gated on it: Task 4 requires every block to have a handle, and hiding it
2464
- // would trade that requirement for a delete-path bug. Re-checked against
2465
- // the LIVE block, after switchAwayFrom()'s possible re-render renumbered
2466
- // the ids.
2467
- 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; }
2468
2957
  // Spec §6, "S1 期間的已知危險" item 1: a LIST ITEM's delete is not a line
2469
2958
  // splice. S1 is what first put a ⠿ on a li, and the plain range removal
2470
2959
  // below corrupts a list three separate ways — see
2471
- // deleteListItemViaGutter() for the measurements and the routing.
2472
- if (liveBlockEl.getAttribute('data-block-type') === 'li') {
2473
- await deleteListItemViaGutter(liveBlockEl);
2960
+ // deleteListItemsViaGutter() for the measurements and the routing.
2961
+ if (shape.allLi) {
2962
+ await deleteListItemsViaGutter(els);
2474
2963
  return;
2475
2964
  }
2476
- 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);
2477
2977
  const prevLines = lines;
2478
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);
2479
2984
  const okRender = await safeRerenderAll();
2480
2985
  if (!okRender) {
2481
2986
  lines = rollbackFailedRender({ lines, stack }, result, prevLines);
@@ -2510,31 +3015,44 @@
2510
3015
  // commit the re-serialized survivors over that range with every one of them
2511
3016
  // carried over verbatim — nothing here rewrites any survivor's CONTENT, only
2512
3017
  // its marker and its leading columns.
2513
- async function deleteListItemViaGutter(liEl) {
2514
- const run = listRunOf(liEl);
2515
- 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; }
2516
3022
  // §4.3's run-wide gate, whose input is §3.4 rule 2's scope — which is
2517
3023
  // exactly what listRunOf() returns (the outermost run PLUS every
2518
3024
  // descendant of its members), so the deeper runs this delete is about to
2519
3025
  // re-indent are covered, not just the target's own. Deleting is NOT
2520
3026
  // column-only: it removes the target's lines outright, so a multi-line
2521
- // target refuses per §4.1.
2522
- 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; }
2523
3029
  const range = runRangeOfBlocks({ lines, blocks, stack }, run);
2524
3030
  if (!range) return;
2525
- const oldIndent = Number(liEl.getAttribute('data-indent')) || 0;
2526
- 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);
2527
3035
  mutateListRun(() => {
2528
- // Clamp FIRST, while `liEl` is still in the span: `{ removed: true }` is
2529
- // what tells the pure function that this block can no longer anchor
2530
- // anything, and rule 2's scope is measured from its position.
2531
- applyIndentClamp(run, liEl, oldIndent, { removed: true });
2532
- 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));
2533
3045
  });
2534
3046
  // No `mutatedEl`: the deleted block is not among the survivors, and every
2535
3047
  // survivor's own bytes are exactly what the file already holds. The marker
2536
3048
  // is re-stated by the serializer regardless of the carry-over, which is
2537
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);
2538
3056
  await commitListStructure(survivors, null, false,
2539
3057
  { presetRange: range, carryOver: bystanderCarryOver(survivors) });
2540
3058
  }
@@ -2592,7 +3110,7 @@
2592
3110
  // BETWEEN two runs, to neither. So the commit range has to be widened
2593
3111
  // past runRangeOfBlocks(listRunOf(...)) explicitly. This is one of only
2594
3112
  // two places where that happens (§3.4's 2026-08-30 erratum); the other is
2595
- // §4.3 rule 1's edge blanks in convertListItemAway() above.
3113
+ // §4.3 rule 1's edge blanks in convertListItemsAway() above.
2596
3114
  //
2597
3115
  // 2. The run-wide gate has to hold for BOTH runs. Merging a DEGRADED run
2598
3116
  // into a healthy one freezes the healthy one too — and declining to merge
@@ -2672,18 +3190,18 @@
2672
3190
  return { startLine: startLine, endLine: endLine, ok: true };
2673
3191
  }
2674
3192
 
2675
- // The {listType, indent} a span member will carry once `liEl` has become
2676
- // `attrs`. Everything except the converted item keeps what it already has.
2677
- 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) {
2678
3196
  return {
2679
- listType: el === liEl
3197
+ listType: liEls.indexOf(el) !== -1
2680
3198
  ? attrs.listType
2681
3199
  : (el.getAttribute('data-list-type') === 'ol' ? 'ol' : 'ul'),
2682
3200
  indent: Number(el.getAttribute('data-indent')) || 0,
2683
3201
  };
2684
3202
  }
2685
3203
 
2686
- async function convertListItemType(liEl, run, target) {
3204
+ async function convertListItemsType(liEls, run, recs, target) {
2687
3205
  const range = runRangeOfBlocks({ lines, blocks, stack }, run);
2688
3206
  if (!range) return;
2689
3207
  const attrs = convertMd.listAttrsFor(target);
@@ -2707,22 +3225,30 @@
2707
3225
  if ((Number(el.getAttribute('data-indent')) || 0) === headIndent) tailEl = el;
2708
3226
  });
2709
3227
  const merged = widenRangeForListMerge(range, run,
2710
- postConvertLiAttrs(headEl, liEl, attrs), postConvertLiAttrs(tailEl, liEl, attrs));
3228
+ postConvertLiAttrs(headEl, liEls, attrs), postConvertLiAttrs(tailEl, liEls, attrs));
2711
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;
2712
3236
  range.startLine = merged.startLine;
2713
3237
  range.endLine = merged.endLine;
2714
3238
  mutateListRun(() => {
2715
- liEl.setAttribute('data-list-type', attrs.listType);
2716
- liEl.setAttribute('data-task', attrs.task ? '1' : '0');
2717
- const box = liCheckEl(liEl);
2718
- if (attrs.task) {
2719
- // Insert BEFORE the surface: §4.1 fixes the child order as
2720
- // marker check text, and list-md.js's firstChildWithClass() plus
2721
- // the delegated checkbox-toggle listener both assume it.
2722
- if (!box) liEl.insertBefore(buildLiCheckbox(), liTextEl(liEl));
2723
- } else if (box) {
2724
- box.remove();
2725
- }
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
+ });
2726
3252
  });
2727
3253
  // NO `mutatedEl` — deliberately, and this contradicts the plan's Task 3
2728
3254
  // sketch, which passes `liEl`. bystanderCarryOver(span, mutatedEl)
@@ -2738,6 +3264,15 @@
2738
3264
  // a carried line, i.e. it re-states the marker from the NEW attributes,
2739
3265
  // and SRC_MARKER_RE eats the old bullet AND the old GFM checkbox off the
2740
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
+ });
2741
3276
  await commitListStructure(run, null, false,
2742
3277
  { presetRange: range, carryOver: bystanderCarryOver(run) });
2743
3278
  }
@@ -2776,17 +3311,39 @@
2776
3311
  // run-wide veto and re-checked against the TARGET's line range only).
2777
3312
  // Measured; the 'a multi-line bystander is replayed, not refused' scenario
2778
3313
  // is what notices.
2779
- async function convertListItemAway(liEl, run, rec, target) {
3314
+ async function convertListItemsAway(liEls, run, recs, target) {
2780
3315
  const range = runRangeOfBlocks({ lines, blocks, stack }, run);
2781
3316
  if (!range) return;
2782
- const stripped = convertMd.stripMarker(lines.slice(rec.startLine - 1, rec.endLine), 'li');
2783
- if (!stripped.ok) { refuseStructuralListEdit('此區塊的格式無法轉換'); return; }
2784
- const convertedLines = convertMd.emitAs(stripped.content, target, {});
2785
-
2786
- const oldIndent = Number(liEl.getAttribute('data-indent')) || 0;
2787
- 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
+ }
2788
3345
  const before = run.slice(0, idx);
2789
- const after = run.slice(idx + 1);
3346
+ const after = run.slice(lastIdx + 1);
2790
3347
 
2791
3348
  // §3.4, and the FIRST production caller of the pure clamp's
2792
3349
  // `operatedBecomes` branch (RULING T6-B). `liEl` stays in the span — the
@@ -2812,17 +3369,21 @@
2812
3369
  // The 'the §3.4 segment deltas survive the split commit' scenario is that
2813
3370
  // shape, and it is the one that goes red without this option.
2814
3371
  mutateListRun(() => {
2815
- applyIndentClamp(run, liEl, oldIndent, { operatedBecomes: { type: convertedBlockType(target) } });
3372
+ applyIndentClamp(run, liEls, oldIndent, { operatedBecomes: { type: convertedBlockType(target) } });
2816
3373
  });
2817
3374
 
2818
3375
  // No `mutatedEl`: the converted block is in neither half, and every
2819
3376
  // survivor's bytes are exactly what the file already holds. Naming a block
2820
3377
  // here EXCLUDES it from the replay map, which is what sends its content
2821
- // back through escapeText() — see convertListItemType()'s note.
3378
+ // back through escapeText() — see convertListItemsType()'s note.
2822
3379
  const carry = bystanderCarryOver(before.concat(after));
2823
3380
  const pieces = [];
2824
3381
  if (before.length) pieces.push(listMd.serializeBlocks(before, { carryOver: carry }).md);
2825
- 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);
2826
3387
  if (after.length) pieces.push(listMd.serializeBlocks(after, { carryOver: carry }).md);
2827
3388
  let md = pieces.join('\n\n');
2828
3389
 
@@ -2833,15 +3394,27 @@
2833
3394
  // 3), in which case '- alpha / bravo' re-lexes as one item. The blank is
2834
3395
  // added only when the neighbour is not already blank, which is also what
2835
3396
  // 「正規化連續空行」 amounts to here: no double separator is ever created.
3397
+ let lead = 0;
2836
3398
  if (!before.length && range.startLine > 1 &&
2837
- String(lines[range.startLine - 2]).trim() !== '') md = '\n' + md;
3399
+ String(lines[range.startLine - 2]).trim() !== '') { md = '\n' + md; lead = 1; }
2838
3400
  if (!after.length && range.endLine < lines.length &&
2839
3401
  String(lines[range.endLine]).trim() !== '') md = md + '\n';
2840
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
+
2841
3413
  const result = commitRangeEdit({ lines, blocks, stack }, range.startLine, range.endLine, md);
2842
3414
  if (result.op === null) return;
2843
3415
  const prevLines = lines;
2844
3416
  lines = result.lines;
3417
+ declareCollapse({ startLine: outStart, endLine: outEnd });
2845
3418
  if (!(await safeRerenderAll())) {
2846
3419
  lines = rollbackFailedRender({ lines, stack }, result, prevLines);
2847
3420
  }
@@ -2861,26 +3434,44 @@
2861
3434
  // to no run. The gate that DOES apply is the one inside
2862
3435
  // widenRangeForListMerge(), on whichever neighbouring run this block is
2863
3436
  // about to merge into.
2864
- async function convertBlockIntoList(blockEl, rec, kind, target) {
3437
+ async function convertBlocksIntoList(blockEls, recs, kinds, target) {
2865
3438
  const attrs = convertMd.listAttrsFor(target);
2866
3439
  if (!attrs) return;
2867
- const stripped = convertMd.stripMarker(lines.slice(rec.startLine - 1, rec.endLine), kind);
2868
- if (!stripped.ok) { refuseStructuralListEdit('此區塊的格式無法轉換'); return; }
2869
- 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];
2870
3454
 
2871
3455
  // emitAs() puts a list target at column 0 with no indent prefix, so the
2872
- // block's post-conversion identity is (target list type, indent 0) on both
2873
- // 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.
2874
3459
  const self = { listType: attrs.listType, indent: 0 };
2875
3460
  const merged = widenRangeForListMerge(
2876
- { startLine: rec.startLine, endLine: rec.endLine }, [blockEl], self, self);
3461
+ { startLine: first.startLine, endLine: last.endLine }, blockEls, self, self);
2877
3462
  if (!merged.ok) { refuseStructuralListEdit(); return; }
2878
3463
 
2879
3464
  const result = commitRangeEdit({ lines, blocks, stack },
2880
- merged.startLine, merged.endLine, newLines.join('\n'));
3465
+ merged.startLine, merged.endLine, md);
2881
3466
  if (result.op === null) return;
2882
3467
  const prevLines = lines;
2883
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
+ });
2884
3475
  if (!(await safeRerenderAll())) {
2885
3476
  lines = rollbackFailedRender({ lines, stack }, result, prevLines);
2886
3477
  }
@@ -3296,6 +3887,408 @@
3296
3887
  return document.querySelector('.ed-block[data-block-id="' + target.id + '"]');
3297
3888
  }
3298
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
+
3299
4292
  // ── T7: surviving a commit that renumbers every block id ───────────────
3300
4293
  // A gutter gesture (⠿ delete, + insert) resolves any open burst FIRST, and
3301
4294
  // that resolution can commit a DIFFERENT block's dirty editor, re-render,
@@ -3650,7 +4643,13 @@
3650
4643
  if (res.unsupported[i] !== 'MULTILINE') return false;
3651
4644
  }
3652
4645
  if (opts && opts.columnOnly) return true;
3653
- 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;
3654
4653
  // T7: the AUTHORITATIVE multi-line test, and it is not `multi`.
3655
4654
  // `multiLineBlockIds` reports a '\n' in the item's surface text, which
3656
4655
  // sees a LAZY continuation and is blind to a markdown HARD BREAK (two
@@ -3663,12 +4662,15 @@
3663
4662
  // Chromium leaves when the last character is deleted, and an emptied item
3664
4663
  // must stay removable). `multi` is kept as well — it costs nothing and
3665
4664
  // covers any surface newline that is not a line-range fact.
3666
- const targetRaw = targetEl.getAttribute('data-block-id');
3667
- const targetRec = blocks.find((b) => b.id === Number(targetRaw));
3668
- if (targetRec && targetRec.endLine > targetRec.startLine) return false;
3669
- // getAttribute() strings on both sides the same convention
3670
- // unsupportedByLi[].blockId uses.
3671
- 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;
3672
4674
  }
3673
4675
 
3674
4676
  // Esc inside a burst: revert to snapshot 0 (the pre-focus baseline) and
@@ -3773,7 +4775,24 @@
3773
4775
  // wireBurstListeners() below), Shift+Enter inserts a <br> and snapshots
3774
4776
  // it, Escape reverts, Ctrl+Z/Y drive the burst-local history.
3775
4777
  function handleBurstKeydown(e, editEl) {
3776
- if (!currentBurst || currentBurst.editEl !== editEl) return;
4778
+ if (!currentBurst || currentBurst.editEl !== editEl) {
4779
+ // v2.11.1 acceptance, escape class B. This bail is reachable with the
4780
+ // surface STILL FOCUSED and still `.ed-wys-armed`: resolveBurst() nulls
4781
+ // `currentBurst` without blurring (Ctrl+S is the everyday way in), and
4782
+ // the delegated handler's call site below `return`s unconditionally, so
4783
+ // nothing else in the document handler runs either. For every other key
4784
+ // that is the right answer — the surface is a plain contenteditable and
4785
+ // the browser's default IS the behaviour we want. Tab is the one key
4786
+ // whose default is not "insert something" but "walk the caret out of the
4787
+ // document": measured on 2.11.0 it moved focus to that same item's own +
4788
+ // button (Shift+Tab, to the previous block's ⠿). Spec §3.5 names this
4789
+ // outright — 必須 preventDefault(),否則 Tab 在 body 上是瀏覽器焦點巡覽.
4790
+ // Swallowed, not acted on: there is no burst to act within, and an
4791
+ // indent from a resolved burst would be a structural edit the user did
4792
+ // not ask for.
4793
+ if (e.key === 'Tab') e.preventDefault();
4794
+ return;
4795
+ }
3777
4796
  // Task 8 (Phase 4): per-li burst — Enter / Shift+Enter / Tab / Shift+Tab
3778
4797
  // are owned by handleLiKeydown() below (spec §4's key semantics for li
3779
4798
  // surfaces, acceptance rows 1, 3, 5, 6, 7, 8). Every other key (Escape,
@@ -4128,6 +5147,222 @@
4128
5147
  return true;
4129
5148
  }
4130
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
+
4131
5366
  // ── Task 8 (Phase 4): Notion key semantics on per-li blocks ─────────────
4132
5367
  // Spec §4's "key semantics on li surfaces", acceptance rows 1, 3, 5, 6, 7,
4133
5368
  // 8. Structurally different from Task 4's whole-list handleListKeydown()
@@ -4154,6 +5389,10 @@
4154
5389
  // no-source-line callers pass their own text instead of a second function.
4155
5390
  function refuseStructuralListEdit(message) {
4156
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;
4157
5396
  }
4158
5397
 
4159
5398
  // Row 3, top-level press: spec §4 — "at top level the next press converts
@@ -5895,6 +7134,12 @@
5895
7134
  // capture) BEFORE starting a new one, so a stuck drag can never survive
5896
7135
  // into the next gesture and a fresh pointerdown always starts clean.
5897
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();
5898
7143
  const hit = hitTestGrip(e.target);
5899
7144
  // A click on a DIFFERENT zone (or entirely outside any zone) dismisses
5900
7145
  // whatever menu is already open, same "any other click closes the ⠿
@@ -5910,7 +7155,18 @@
5910
7155
  // teMenuKind alone, or that highlight would survive until
5911
7156
  // resolveBurst() instead of clearing on the next click.
5912
7157
  if ((teMenuKind || teHighlightEls.length) && !isSameSelection) hideTableEdgeMenu();
5913
- 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
+ }
5914
7170
  e.preventDefault();
5915
7171
  tePointer = { hit, startX: e.clientX, startY: e.clientY, dragging: false,
5916
7172
  pointerId: e.pointerId, captureEl: e.target };
@@ -5960,7 +7216,20 @@
5960
7216
  else updateColDropIndicator(e.clientX);
5961
7217
  });
5962
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
+
5963
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();
5964
7233
  if (!tePointer) return;
5965
7234
  const st = tePointer;
5966
7235
  releaseTeCapture(st);
@@ -6006,6 +7275,11 @@
6006
7275
  // actually moved mid-drag, only the indicator line).
6007
7276
  document.addEventListener('pointercancel', () => {
6008
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();
6009
7283
  });
6010
7284
 
6011
7285
  // Review fix (Critical): the whole BROWSER WINDOW losing focus mid-
@@ -6017,6 +7291,10 @@
6017
7291
  // when no gesture is in flight, so this is safe to fire on every blur.
6018
7292
  window.addEventListener('blur', () => {
6019
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();
6020
7298
  });
6021
7299
 
6022
7300
  // ── Phase-2 Task 4: floating selection toolbar (bold/italic/code/link) ──
@@ -6357,6 +7635,12 @@
6357
7635
  (e.target.closest('.ed-handle') || e.target.closest('.ed-insert'))) e.preventDefault();
6358
7636
  });
6359
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; }
6360
7644
  if (!e.target || !e.target.closest) { await switchAwayFrom(); closeGutterMenu(); closeInsertMenu(); return; }
6361
7645
  // showBanner() appends `.ed-conflict` to document.body — OUTSIDE any
6362
7646
  // .ed-block — so without this guard a click on the banner's own
@@ -6460,8 +7744,39 @@
6460
7744
  // Any other click closes an already-open ⠿ menu / + menu.
6461
7745
  if (gutterMenuBlockEl) closeGutterMenu();
6462
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();
6463
7756
  if (e.target.closest(ED_LIGHTBOX_TARGETS)) return; // let the lightbox open, unchanged
6464
7757
  let blockEl = e.target.closest('.ed-block');
7758
+ // v2.11.1: `.ed-block::before` (lib/md2doc.js's editModeLayoutCss) makes
7759
+ // the 40px gutter part of the block's HIT area so that hovering it keeps
7760
+ // the +/⠿ pair visible. That is a hover fix, and it must not become a
7761
+ // click fix by accident: before it, a click in the gutter band hit
7762
+ // main.content and meant "clicked outside any block" — which for a
7763
+ // DEGRADED block (blockquote, fenced code, an unsupported table) is the
7764
+ // difference between committing whatever was open and silently opening
7765
+ // that block's raw source editor from 20px away from it.
7766
+ //
7767
+ // Read only when the click landed on the block's OWN box (a click on any
7768
+ // descendant — the text surface, a marker, a checkbox, a gutter button —
7769
+ // is unaffected) and only when the event actually carries coordinates:
7770
+ // a synthesized `new MouseEvent('click', {bubbles:true})` and
7771
+ // `el.click()` both report clientX/clientY 0, which several scenarios in
7772
+ // test/editor-client-runtime.test.js use precisely because they mean
7773
+ // "the block itself", not "a point". `offsetX < 0` looks like the
7774
+ // tidier test and is NOT usable: for a synthesized event Chromium still
7775
+ // derives offsetX from clientX 0, so it comes back as minus the block's
7776
+ // whole left offset and every such click reads as a gutter click.
7777
+ if (blockEl && e.target === blockEl && (e.clientX || e.clientY)) {
7778
+ if (e.clientX < blockEl.getBoundingClientRect().left) blockEl = null;
7779
+ }
6465
7780
  if (!blockEl) { await switchAwayFrom(); return; } // clicked outside any block
6466
7781
 
6467
7782
  // Task 5: a table block is now armed exactly like paragraph/heading/
@@ -6538,6 +7853,14 @@
6538
7853
  const r = stack.undo(lines);
6539
7854
  if (!r) return;
6540
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);
6541
7864
  const ok = await safeRerenderAll();
6542
7865
  if (!ok) {
6543
7866
  // Reverse the undo attempt: push the op back and restore `lines` to
@@ -6553,6 +7876,7 @@
6553
7876
  const r = stack.redo(lines);
6554
7877
  if (!r) return;
6555
7878
  lines = r.lines;
7879
+ declareSelectionRange(null); // §4.4, same as undo() above
6556
7880
  const ok = await safeRerenderAll();
6557
7881
  if (!ok) {
6558
7882
  // Reverse the redo attempt: pop the op back off and restore `lines`.
@@ -6563,36 +7887,66 @@
6563
7887
 
6564
7888
  // ── global key handling ─────────────────────────────────────────────────
6565
7889
  document.addEventListener('keydown', (e) => {
6566
- // Task 6: Esc during an in-flight row drag cancels JUST the drag (no
6567
- // mutation the row was never actually moved, only the indicator line
6568
- // tracked the pointer) a DISTINCT gesture from Esc-reverts-burst
6569
- // (handleTableCellKeydown() below), which must never fire for this same
6570
- // keypress. Checked before EVERY other branch (including Ctrl+S) so a
6571
- // drag in flight always wins the keystroke.
6572
- if (tePointer && tePointer.dragging && e.key === 'Escape') {
6573
- e.preventDefault();
6574
- cancelTeDrag();
6575
- return;
6576
- }
6577
- // Task 6: Esc with the edge menu open closes JUST the menu — same
6578
- // "intercept before the wys-cell/wys-armed Escape branches" reasoning,
6579
- // since the cell that was focused (if any) before the menu opened is
6580
- // still focused underneath it (the menu's own mousedown preventDefault()
6581
- // never stole focus).
6582
- // Final review I1: the condition is `(teMenuKind || teHighlightEls.length)`,
6583
- // NOT `teMenuKind` alone the exact same widening the pointerdown dismiss
6584
- // gate already got, and for the same reason: a header grip's click
6585
- // deliberately produces a highlight with NO menu (its only menu item,
6586
- // "delete row", cannot apply to a header), so teMenuKind stays null. Gated
6587
- // on teMenuKind alone, that Esc fell through to handleTableCellKeydown()'s
6588
- // own Escape branch -> revertTableBurstAndEnd(), throwing away everything
6589
- // typed into the burst. With a BODY row's menu open the identical keypress
6590
- // merely closes the menu, so the header row would have been the one place
6591
- // where dismissing a selection is destructive.
6592
- if ((teMenuKind || teHighlightEls.length) && e.key === 'Escape') {
6593
- e.preventDefault();
6594
- hideTableEdgeMenu();
6595
- 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.
6596
7950
  }
6597
7951
 
6598
7952
  if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key === 's') {
@@ -6621,8 +7975,30 @@
6621
7975
  // heading/list (Enter is an UNCONDITIONAL <br>, Tab moves the active
6622
7976
  // cell without ending the burst) — handleTableCellKeydown() owns that
6623
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
+
6624
8000
  const cellEl = e.target && e.target.closest && e.target.closest('.ed-wys-cell');
6625
- if (cellEl) {
8001
+ if (cellEl && burstOwnsKey) {
6626
8002
  handleTableCellKeydown(e, cellEl);
6627
8003
  return;
6628
8004
  }
@@ -6633,11 +8009,93 @@
6633
8009
  // preventDefault() for those keys), so nothing below this must run for
6634
8010
  // it either.
6635
8011
  const wysArmedEl = e.target && e.target.closest && e.target.closest('.ed-wys-armed');
6636
- if (wysArmedEl) {
8012
+ if (wysArmedEl && burstOwnsKey) {
6637
8013
  handleBurstKeydown(e, wysArmedEl);
6638
8014
  return;
6639
8015
  }
6640
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
+
8061
+ // v2.11.1 acceptance, escape class A: Tab with NOTHING focused. Every
8062
+ // branch above is keyed on the event target being some edit surface, and
8063
+ // after a commit / Escape / Ctrl+Z, or a click on a bullet marker or in
8064
+ // the block's own gutter, focus is on BODY and the target is BODY — so no
8065
+ // branch matched and the browser ran its own sequential focus navigation,
8066
+ // landing on whichever gutter <button> happens to come next in document
8067
+ // order. Spec §3.5: 必須 preventDefault(),否則 Tab 在 body 上是瀏覽器焦點
8068
+ // 巡覽. This is deliberately a silent no-op rather than "indent the block
8069
+ // nearest the caret": with no focus there is no caret, so there is no
8070
+ // block the key could mean.
8071
+ //
8072
+ // Scoped so a real control keeps its keyboard contract: the reader's own
8073
+ // search input and the raw editor's textarea (which returned above) are
8074
+ // still tabbable, and so is anything else the user has deliberately
8075
+ // focused. What is swallowed is Tab from inside a `.ed-block` and Tab with
8076
+ // no focus at all — the two states the editor puts the user in.
8077
+ if (e.key === 'Tab') {
8078
+ const inBlock = e.target && e.target.closest && e.target.closest('.ed-block');
8079
+ // A REAL control inside a block keeps its keyboard contract. The raw
8080
+ // source editor's own 完成/取消 buttons are the case that matters: Tab
8081
+ // out of its textarea is how a keyboard user reaches them (the textarea
8082
+ // itself returned above), and swallowing the next Tab would trap focus
8083
+ // on the button it just landed on. The ⠿ menu's buttons are the same
8084
+ // shape. The two GUTTER buttons are excluded from that exemption on
8085
+ // purpose — they are the chrome this fix exists to keep out of the tab
8086
+ // order, and buildGutterHandle()/buildGutterInsertButton() give them
8087
+ // tabindex="-1" for the same reason.
8088
+ const control = e.target && e.target.closest && e.target.closest(
8089
+ 'button:not(.ed-handle):not(.ed-insert), input, select, textarea, a[href], [tabindex]:not([tabindex="-1"])');
8090
+ const focused = document.activeElement;
8091
+ const nothingFocused = !focused || focused === document.body ||
8092
+ focused === document.documentElement;
8093
+ if ((inBlock && !control) || nothingFocused) {
8094
+ e.preventDefault();
8095
+ return;
8096
+ }
8097
+ }
8098
+
6641
8099
  if (e.key === 'Escape') {
6642
8100
  e.preventDefault();
6643
8101
  closeGutterMenu();
@@ -6842,6 +8300,18 @@
6842
8300
  else rowGrip.hidden = true;
6843
8301
  return;
6844
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
+ }
6845
8315
  updateTableInsertBubbles(tbMoveX, tbMoveY, tbMoveTarget);
6846
8316
  updateTableEdgeGrips(tbMoveX, tbMoveY, tbMoveTarget);
6847
8317
  });