@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.
- package/lib/editor/client.js +1750 -280
- package/lib/editor/indent-clamp.js +6 -1
- package/lib/editor/list-md.js +68 -1
- package/lib/editor/selection.js +204 -0
- package/lib/editor/server.js +7 -0
- package/lib/md2doc.js +130 -8
- package/package.json +2 -2
package/lib/editor/client.js
CHANGED
|
@@ -233,6 +233,7 @@
|
|
|
233
233
|
const listMd = window.md2docListMd;
|
|
234
234
|
const indentClamp = window.md2docIndentClamp;
|
|
235
235
|
const convertMd = window.md2docConvertMd;
|
|
236
|
+
const selectionLib = window.md2docSelection;
|
|
236
237
|
const historyLib = window.md2docHistory;
|
|
237
238
|
let lines = ED.lines, blocks = ED.blocks, mtimeMs = ED.mtimeMs;
|
|
238
239
|
// 檔案原本的換行符。lines 內部永遠是不含 \r 的純內容行;只有 save()
|
|
@@ -440,8 +441,19 @@
|
|
|
440
441
|
// extra button ahead of the always-present ✕ dismiss button (e.g. the
|
|
441
442
|
// conflict banner's "Reload"); omit them for a plain dismiss-only notice.
|
|
442
443
|
let activeBanner = null;
|
|
444
|
+
// Review recommendation 5 (2026-08-31): whether the banner on screen is a
|
|
445
|
+
// structural REFUSAL — 「無法整批操作」 and its siblings — as opposed to a
|
|
446
|
+
// conflict / render-failed / save-failed notice. Only a refusal is cleared
|
|
447
|
+
// by a later gesture that succeeds (see dismissRefusalBanner()); the other
|
|
448
|
+
// three describe the state of the FILE or the connection and must stay until
|
|
449
|
+
// the user dismisses them.
|
|
450
|
+
let activeBannerIsRefusal = false;
|
|
443
451
|
function showBanner(message, actionLabel, onAction) {
|
|
444
452
|
if (activeBanner) { activeBanner.remove(); activeBanner = null; }
|
|
453
|
+
// Reset unconditionally: refuseStructuralListEdit() sets it back to true
|
|
454
|
+
// straight after its own call, so every OTHER caller gets false without
|
|
455
|
+
// having to know this flag exists.
|
|
456
|
+
activeBannerIsRefusal = false;
|
|
445
457
|
const el = document.createElement('div');
|
|
446
458
|
el.className = 'ed-conflict';
|
|
447
459
|
const msg = document.createElement('span');
|
|
@@ -460,7 +472,7 @@
|
|
|
460
472
|
dismissBtn.setAttribute('aria-label', 'Dismiss');
|
|
461
473
|
dismissBtn.addEventListener('click', () => {
|
|
462
474
|
el.remove();
|
|
463
|
-
if (activeBanner === el) activeBanner = null;
|
|
475
|
+
if (activeBanner === el) { activeBanner = null; activeBannerIsRefusal = false; }
|
|
464
476
|
});
|
|
465
477
|
el.appendChild(dismissBtn);
|
|
466
478
|
document.body.appendChild(el);
|
|
@@ -468,6 +480,24 @@
|
|
|
468
480
|
return el;
|
|
469
481
|
}
|
|
470
482
|
|
|
483
|
+
// Review recommendation 5: a refusal banner must not outlive the gesture that
|
|
484
|
+
// raised it. refuseStructuralListEdit() has been dismiss-only since S1, and
|
|
485
|
+
// S3 made it far more reachable (7 of the T8 sweep's 13 selection shapes
|
|
486
|
+
// refuse), so 「選取範圍同時含有清單項目與其他區塊,無法整批操作」 could stand
|
|
487
|
+
// over a document the user had since successfully edited — a plain lie about
|
|
488
|
+
// the state of the document. Called from rerenderAll()'s success path, which
|
|
489
|
+
// is the one point every structural operation reaches only by having WORKED:
|
|
490
|
+
// every failure exit above it returns before this. Deliberately NOT called
|
|
491
|
+
// from the Escape / clear-selection paths — dismissing a selection is not the
|
|
492
|
+
// same event as a later gesture succeeding, and the refusal is still the true
|
|
493
|
+
// answer to the gesture the user last attempted.
|
|
494
|
+
function dismissRefusalBanner() {
|
|
495
|
+
if (!activeBanner || !activeBannerIsRefusal) return;
|
|
496
|
+
activeBanner.remove();
|
|
497
|
+
activeBanner = null;
|
|
498
|
+
activeBannerIsRefusal = false;
|
|
499
|
+
}
|
|
500
|
+
|
|
471
501
|
function showConflictBanner() {
|
|
472
502
|
showBanner(
|
|
473
503
|
'File changed on disk — reload to pick up external edits ' +
|
|
@@ -503,6 +533,14 @@
|
|
|
503
533
|
// await is inside its own try/catch, so callers never see a rejection.
|
|
504
534
|
async function rerenderAll() {
|
|
505
535
|
const scrollY = window.scrollY;
|
|
536
|
+
// S3 Task 5 (§4.4 step 2): the line range this render's operation declared
|
|
537
|
+
// for the rebuilt selection. Consumed HERE, before the first failure exit,
|
|
538
|
+
// rather than down at the rebuild itself: every `return false` below
|
|
539
|
+
// leaves `blockSelection` untouched — which is what makes a line-range
|
|
540
|
+
// selection survive a failed render for free — and a declaration left
|
|
541
|
+
// standing would then land on some LATER, unrelated render instead.
|
|
542
|
+
const declaredRange = pendingSelectionRange;
|
|
543
|
+
pendingSelectionRange = undefined;
|
|
506
544
|
let res;
|
|
507
545
|
try {
|
|
508
546
|
res = await fetch('/api/render', {
|
|
@@ -530,6 +568,10 @@
|
|
|
530
568
|
showBanner('Render failed — malformed server response. Your edit was not applied.', null, null);
|
|
531
569
|
return false;
|
|
532
570
|
}
|
|
571
|
+
// Past every failure exit: this render is going to happen, so whatever
|
|
572
|
+
// gesture asked for it has succeeded. Review recommendation 5 — a stale
|
|
573
|
+
// structural refusal is cleared HERE and nowhere else.
|
|
574
|
+
dismissRefusalBanner();
|
|
533
575
|
blocks = j.blocks;
|
|
534
576
|
contentEl.innerHTML = j.bodyHtml;
|
|
535
577
|
// Task 2 (Phase 3): re-arm every WYSIWYG-eligible paragraph/heading (and
|
|
@@ -611,6 +653,17 @@
|
|
|
611
653
|
// it's the same "singleton node appended as a child of whichever block
|
|
612
654
|
// it's open for" idiom as gutterMenu just above.
|
|
613
655
|
closeInsertMenu();
|
|
656
|
+
// S3 Task 5 (§4.4's ordered three steps): armEditables() above was step
|
|
657
|
+
// 1; this is steps 2 and 3 — rebuild the member set from the declared (or
|
|
658
|
+
// still-standing) LINE RANGE against the freshly built `blocks`, clear it
|
|
659
|
+
// if that range no longer resolves, and give the focus endpoint a real
|
|
660
|
+
// roving-tabindex holder. It sits AFTER the unconditional teardown above
|
|
661
|
+
// so nothing there can null what it just set, and BEFORE the two
|
|
662
|
+
// `try`-swallowed rebind blocks below so a diagram-init throw can never
|
|
663
|
+
// skip it. It is also deliberately ABOVE window.scrollTo(): the .focus()
|
|
664
|
+
// it ends in scrolls the holder into view, and the scroll restore below
|
|
665
|
+
// is what puts the reader back where they were.
|
|
666
|
+
rebuildBlockSelection(declaredRange);
|
|
614
667
|
window.scrollTo(0, scrollY);
|
|
615
668
|
if (window.__md2docInitDiagrams) {
|
|
616
669
|
try {
|
|
@@ -1294,10 +1347,26 @@
|
|
|
1294
1347
|
// has to: `opIndex` is an index into it, and rule 2's scope starts after it);
|
|
1295
1348
|
// clampIndents() reports no indent for a removed block, so the write-back
|
|
1296
1349
|
// below never touches the element that is on its way out.
|
|
1350
|
+
//
|
|
1351
|
+
// S3 Task 6: `opBlockEl` may also be an ARRAY — spec §3.4 rule 3's multi-block
|
|
1352
|
+
// operation, which clampIndents() has accepted since S1 (`opIndex` may be an
|
|
1353
|
+
// array of indices). `opOldIndent` is then the SMALLEST old indent in the set,
|
|
1354
|
+
// never the first member's: §3.4 rule 3 records both ways the first member
|
|
1355
|
+
// goes wrong (a delete drives a later member to indent −1; a batch Tab whose
|
|
1356
|
+
// first member is already at its ceiling no-ops the whole set). The caller
|
|
1357
|
+
// computes it with spanMinIndent(). A member that is not in `spanEls` aborts
|
|
1358
|
+
// the whole clamp rather than clamping a subset — a partial op set makes rule
|
|
1359
|
+
// 2's scope start in the wrong place, which is a silent wrong answer.
|
|
1297
1360
|
function applyIndentClamp(spanEls, opBlockEl, opOldIndent, opts) {
|
|
1298
1361
|
if (!indentClamp || !spanEls || !spanEls.length) return;
|
|
1299
|
-
const
|
|
1300
|
-
|
|
1362
|
+
const opEls = Array.isArray(opBlockEl) ? opBlockEl : [opBlockEl];
|
|
1363
|
+
const opIndex = [];
|
|
1364
|
+
for (let k = 0; k < opEls.length; k++) {
|
|
1365
|
+
const at = spanEls.indexOf(opEls[k]);
|
|
1366
|
+
if (at < 0) return;
|
|
1367
|
+
opIndex.push(at);
|
|
1368
|
+
}
|
|
1369
|
+
if (!opIndex.length) return;
|
|
1301
1370
|
const model = spanEls.map((el, i) => ({
|
|
1302
1371
|
id: i, // index-as-id: the span IS the universe here
|
|
1303
1372
|
type: el.getAttribute('data-block-type') === 'li' ? 'li' : 'other',
|
|
@@ -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: 轉換成 › /
|
|
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
|
-
|
|
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) {
|
|
1609
|
-
|
|
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('
|
|
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
|
|
1706
|
-
//
|
|
1707
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
2157
|
-
|
|
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:
|
|
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
|
|
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
|
-
//
|
|
2194
|
-
//
|
|
2195
|
-
//
|
|
2196
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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 (
|
|
2206
|
-
liRun =
|
|
2207
|
-
if (!liRun
|
|
2208
|
-
if (!listRunSupportsStructuralEdit(liRun,
|
|
2695
|
+
if (shape.allLi) {
|
|
2696
|
+
liRun = batchRunOf(els);
|
|
2697
|
+
if (!liRun) { refuseStructuralListEdit(BATCH_MULTIRUN_MESSAGE); return; }
|
|
2698
|
+
if (!listRunSupportsStructuralEdit(liRun, els)) { refuseStructuralListEdit(); return; }
|
|
2209
2699
|
}
|
|
2210
2700
|
|
|
2211
|
-
// S2 Task 3: li → a LIST target. The
|
|
2212
|
-
// run and the existing re-serialization machinery applies
|
|
2213
|
-
|
|
2214
|
-
|
|
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
|
|
2218
|
-
// has to be rebuilt in three pieces and §4.3 rule 1's blank
|
|
2219
|
-
// between them — the plain path below would leave the converted
|
|
2220
|
-
// mid-list with no separator and lazy continuation would swallow
|
|
2221
|
-
// the item above (measured, §4.3 rule 1).
|
|
2222
|
-
if (
|
|
2223
|
-
await
|
|
2708
|
+
// S2 Task 4 / S3 Task 6: li → a NON-list target. The items LEAVE the run,
|
|
2709
|
+
// so the span has to be rebuilt in three pieces and §4.3 rule 1's blank
|
|
2710
|
+
// lines put between them — the plain path below would leave the converted
|
|
2711
|
+
// lines mid-list with no separator and lazy continuation would swallow them
|
|
2712
|
+
// into the item above (measured, §4.3 rule 1).
|
|
2713
|
+
if (shape.allLi) {
|
|
2714
|
+
await convertListItemsAway(els, liRun, recs, target);
|
|
2224
2715
|
return;
|
|
2225
2716
|
}
|
|
2226
|
-
// S2 Task 5:
|
|
2227
|
-
// policy applies — eat the separator to an adjacent run of
|
|
2228
|
-
// type, or the merged list goes LOOSE and every item of it
|
|
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
|
|
2722
|
+
await convertBlocksIntoList(els, recs, shape.kinds, target);
|
|
2232
2723
|
return;
|
|
2233
2724
|
}
|
|
2234
2725
|
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
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
|
-
|
|
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 —
|
|
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 (
|
|
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
|
-
|
|
2310
|
-
|
|
2311
|
-
//
|
|
2312
|
-
//
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
if (
|
|
2321
|
-
|
|
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
|
-
|
|
2342
|
-
|
|
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
|
|
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:
|
|
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
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2380
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
2422
|
-
//
|
|
2423
|
-
//
|
|
2424
|
-
//
|
|
2425
|
-
// pointing at a
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
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
|
-
//
|
|
2472
|
-
if (
|
|
2473
|
-
await
|
|
2960
|
+
// deleteListItemsViaGutter() for the measurements and the routing.
|
|
2961
|
+
if (shape.allLi) {
|
|
2962
|
+
await deleteListItemsViaGutter(els);
|
|
2474
2963
|
return;
|
|
2475
2964
|
}
|
|
2476
|
-
|
|
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
|
|
2514
|
-
|
|
2515
|
-
|
|
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,
|
|
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
|
-
|
|
2526
|
-
|
|
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
|
|
2529
|
-
// what tells the pure function that
|
|
2530
|
-
// anything, and rule 2's scope is measured from
|
|
2531
|
-
|
|
2532
|
-
|
|
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
|
|
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
|
|
2676
|
-
// `attrs`. Everything
|
|
2677
|
-
function postConvertLiAttrs(el,
|
|
3193
|
+
// The {listType, indent} a span member will carry once the operand set
|
|
3194
|
+
// `liEls` has become `attrs`. Everything outside the set keeps what it has.
|
|
3195
|
+
function postConvertLiAttrs(el, liEls, attrs) {
|
|
2678
3196
|
return {
|
|
2679
|
-
listType: el
|
|
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
|
|
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,
|
|
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
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
box
|
|
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
|
|
3314
|
+
async function convertListItemsAway(liEls, run, recs, target) {
|
|
2780
3315
|
const range = runRangeOfBlocks({ lines, blocks, stack }, run);
|
|
2781
3316
|
if (!range) return;
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
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(
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
3437
|
+
async function convertBlocksIntoList(blockEls, recs, kinds, target) {
|
|
2865
3438
|
const attrs = convertMd.listAttrsFor(target);
|
|
2866
3439
|
if (!attrs) return;
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
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
|
-
//
|
|
2873
|
-
// edges — it emits one item, however many physical lines
|
|
3456
|
+
// span's post-conversion identity is (target list type, indent 0) on both
|
|
3457
|
+
// edges — it emits one item per member, however many physical lines each
|
|
3458
|
+
// item spans.
|
|
2874
3459
|
const self = { listType: attrs.listType, indent: 0 };
|
|
2875
3460
|
const merged = widenRangeForListMerge(
|
|
2876
|
-
{ startLine:
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
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)
|
|
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)
|
|
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
|
-
//
|
|
6567
|
-
//
|
|
6568
|
-
//
|
|
6569
|
-
// (
|
|
6570
|
-
//
|
|
6571
|
-
//
|
|
6572
|
-
|
|
6573
|
-
|
|
6574
|
-
|
|
6575
|
-
|
|
6576
|
-
|
|
6577
|
-
//
|
|
6578
|
-
//
|
|
6579
|
-
//
|
|
6580
|
-
//
|
|
6581
|
-
//
|
|
6582
|
-
//
|
|
6583
|
-
//
|
|
6584
|
-
//
|
|
6585
|
-
//
|
|
6586
|
-
//
|
|
6587
|
-
//
|
|
6588
|
-
//
|
|
6589
|
-
//
|
|
6590
|
-
//
|
|
6591
|
-
//
|
|
6592
|
-
|
|
6593
|
-
|
|
6594
|
-
|
|
6595
|
-
|
|
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
|
});
|