@helping-ai-workflow/md2doc 3.0.0 → 3.0.2

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.
@@ -1667,6 +1667,57 @@
1667
1667
  // function did by returning early.
1668
1668
  function computeIndentClamp(spanEls, opBlockEl, opOldIndent, opts) {
1669
1669
  if (!indentClamp || !spanEls || !spanEls.length) return null;
1670
+ // v3.0.2 — the structural answer to the stale-`run` pattern that v3.0.1
1671
+ // point-fixed at three call sites, and that both reviewers asked to have
1672
+ // fixed at the call convention instead. There are FIVE call sites, not
1673
+ // three (3483 batch delete, 3812 convert, 5778 batch Tab/Shift+Tab, 6028
1674
+ // caret Tab/Shift+Tab — both indent directions share ONE call site each,
1675
+ // batch vs. caret, not one call site per direction — and the drop's
1676
+ // compute at 8516), and every one of them hands over a `listRunOf()`
1677
+ // result in document order. Two ways that goes stale, both of which
1678
+ // produced a confident wrong number rather than a refusal: a member
1679
+ // DETACHED (getAttribute still answers on a detached node) and a span
1680
+ // still attached but no longer in the run's document order (indexOf()
1681
+ // then reports an opIndex for a layout that no longer exists, so §3.4
1682
+ // rule 2's scope starts in the wrong place). Re-deriving the span here
1683
+ // instead of checking it was rejected: it would quietly change the
1684
+ // contract from "clamp this span" to "clamp the run containing this
1685
+ // op", and the drop is the one caller subtle enough that taking that
1686
+ // expressiveness away is not free.
1687
+ //
1688
+ // It does NOT throw. This file's defensive style (safeRerenderAll at
1689
+ // ~990) is that the editor must not die on one path's failure. But the
1690
+ // refusal is NOT free either: at the drop (8516) a null clamp feeds
1691
+ // spanIndentsAreAnchored() the RAW reordered indents, and that refuses
1692
+ // 1425 of 4067 legal drops where the clamped answer refuses only 14 (the
1693
+ // clamp rescues the other 1411; the measured numbers are recorded at
1694
+ // line ~371). So a false positive here costs the user up to 1411 legal
1695
+ // batch drags, replaced by a banner —
1696
+ // which is why the assert is worth this cost even though the batch-
1697
+ // delete, convert and drop sites each have a scenario whose correct
1698
+ // answer needs a real reclamp (a wrongly-firing assert breaks them
1699
+ // visibly), while neither Tab call site (5778 batch, 6028 caret) has a
1700
+ // scenario that reclamps a TRAILING sibling — see
1701
+ // test/editor-client-runtime.test.js for the per-site detail.
1702
+ const fresh = listRunOf(spanEls[0]);
1703
+ if (fresh.length !== spanEls.length ||
1704
+ fresh.some((el, i) => el !== spanEls[i])) {
1705
+ // Minor 5: the length pair alone is silent on the reordered-but-same-
1706
+ // length case (the ONLY case where an index is diagnostic — a length
1707
+ // mismatch is already self-explanatory), so report the first index at
1708
+ // which the two runs disagree too.
1709
+ const minLen = Math.min(fresh.length, spanEls.length);
1710
+ let badIndex = -1;
1711
+ for (let i = 0; i < minLen; i++) {
1712
+ if (fresh[i] !== spanEls[i]) { badIndex = i; break; }
1713
+ }
1714
+ if (badIndex < 0 && fresh.length !== spanEls.length) badIndex = minLen;
1715
+ console.error('computeIndentClamp: stale span — the run has ' + fresh.length +
1716
+ ' members, the caller passed ' + spanEls.length +
1717
+ ', first differing at index ' + badIndex +
1718
+ '; refusing rather than clamping against a layout that no longer exists');
1719
+ return null;
1720
+ }
1670
1721
  const opEls = Array.isArray(opBlockEl) ? opBlockEl : [opBlockEl];
1671
1722
  const opIndex = [];
1672
1723
  for (let k = 0; k < opEls.length; k++) {
@@ -2009,6 +2060,30 @@
2009
2060
  '<path d="M6.6 7.2v4.2"/><path d="M9.4 7.2v4.2"/>',
2010
2061
  // MD 原始碼 — angle brackets: source, not prose.
2011
2062
  code: '<path d="M6 3.6L2 8l4 4.4"/><path d="M10 3.6L14 8l-4 4.4"/>',
2063
+ // ── v3.0.1: one icon per 轉換成 target ────────────────────────────────
2064
+ // Keys are CONVERT_TARGETS[].id verbatim; openConvertSubmenu() looks them
2065
+ // up by that id, so a rename on either side must be made on both.
2066
+ // 文字 — a paragraph pilcrow, drawn rather than typed so it inherits the
2067
+ // same stroke weight as every sibling icon.
2068
+ text: '<path d="M9 2.5H6.2a2.7 2.7 0 0 0 0 5.4H9"/><path d="M9 2.5v11"/><path d="M11.8 2.5v11"/>',
2069
+ // 標題 1–6 — an "H" plus the level, as two strokes and a numeral bar set.
2070
+ h1: '<path d="M3 3v10"/><path d="M8 3v10"/><path d="M3 8h5"/><path d="M11.5 6.5l1.5-1v7.5"/>',
2071
+ h2: '<path d="M3 3v10"/><path d="M8 3v10"/><path d="M3 8h5"/><path d="M11 6.6a1.6 1.6 0 0 1 2.7 1.1c0 1.4-2.7 2.6-2.7 5.3h3"/>',
2072
+ h3: '<path d="M3 3v10"/><path d="M8 3v10"/><path d="M3 8h5"/><path d="M11 5.8h2.7l-1.6 2.6a1.8 1.8 0 1 1-1.3 3"/>',
2073
+ h4: '<path d="M3 3v10"/><path d="M8 3v10"/><path d="M3 8h5"/><path d="M13 5.8v7.2"/><path d="M13 10.7h-2.6l2.2-4.9"/>',
2074
+ h5: '<path d="M3 3v10"/><path d="M8 3v10"/><path d="M3 8h5"/><path d="M13.5 5.8h-2.6v2.8a1.9 1.9 0 1 1-.5 3.6"/>',
2075
+ h6: '<path d="M3 3v10"/><path d="M8 3v10"/><path d="M3 8h5"/><path d="M13.4 6.2a1.9 1.9 0 0 0-3 1.5v3.2a1.8 1.8 0 1 0 3.1-1.3 1.8 1.8 0 0 0-3.1 1.3"/>',
2076
+ // 項目符號列表 — three dots with three rules.
2077
+ ul: '<circle cx="3.2" cy="4.2" r="1"/><circle cx="3.2" cy="8" r="1"/><circle cx="3.2" cy="11.8" r="1"/>' +
2078
+ '<path d="M6.4 4.2h7.4"/><path d="M6.4 8h7.4"/><path d="M6.4 11.8h7.4"/>',
2079
+ // 編號列表 — the same three rules led by 1/2/3 strokes.
2080
+ ol: '<path d="M2.2 3.4l1-.6v2.8"/><path d="M2 8.2a1 1 0 0 1 1.7.7c0 .9-1.7 1.6-1.7 2.4h1.9"/>' +
2081
+ '<path d="M2.1 11.4h1.6l-1 1.1a1 1 0 1 1-.7 1.2"/>' +
2082
+ '<path d="M6.4 4.2h7.4"/><path d="M6.4 8.6h7.4"/><path d="M6.4 12.9h7.4"/>',
2083
+ // 待辦清單 — a checked box.
2084
+ task: '<rect x="2.2" y="2.6" width="10.8" height="10.8" rx="2.4"/><path d="M5.2 8.2l2.1 2.1 3.8-4"/>',
2085
+ // 引用 — a quote bar plus two shortened rules.
2086
+ quote: '<path d="M3 3.4v9.2"/><path d="M6.4 5.4h7.2"/><path d="M6.4 8h7.2"/><path d="M6.4 10.6h4.6"/>',
2012
2087
  };
2013
2088
  function menuIconMarkup(name) {
2014
2089
  return '<svg class="ed-menu-icon" viewBox="0 0 16 16" width="16" height="16" ' +
@@ -2191,7 +2266,13 @@
2191
2266
  const b = document.createElement('button');
2192
2267
  b.type = 'button';
2193
2268
  b.className = 'ed-handle-menu-btn';
2194
- b.textContent = t.label;
2269
+ // v3.0.1: the submenu carries icons too. Without them its labels start
2270
+ // at the button's own padding while the parent menu's start past a 16px
2271
+ // icon, so the two levels' text does not share a vertical line — the
2272
+ // exact defect the user reported. `.ed-handle-menu-btn` is already a
2273
+ // flex row with a gap, so an icon plus a text node is all it takes.
2274
+ b.innerHTML = menuIconMarkup(t.id);
2275
+ b.appendChild(document.createTextNode(t.label));
2195
2276
  b.setAttribute('aria-label', 'Convert to ' + t.id);
2196
2277
  b.addEventListener('click', (e) => {
2197
2278
  e.stopPropagation();
@@ -4186,7 +4267,16 @@
4186
4267
  // S1 removed the pre-per-li 'list' branch that lived here: blockmap has not
4187
4268
  // emitted type:'list' blocks since Phase 4, so no startBurst() call could
4188
4269
  // produce blockType === 'list' and the branch was already dead code.
4189
- const result = burst.blockType === 'table' ? tableMd.serializeTable(burst.editEl)
4270
+ // v3.0.1: hand the serializer the table's CURRENT source lines so it can
4271
+ // reuse the author's own column widths instead of flattening the whole
4272
+ // table to minimal form over a one-cell edit. extractBlockSource() is the
4273
+ // same helper the raw editor prefills from, so "what the file says" has
4274
+ // exactly one definition.
4275
+ const tableBlockRec = burst.blockType === 'table'
4276
+ ? blocks.find((b) => b.id === burst.blockId) : null;
4277
+ const result = burst.blockType === 'table'
4278
+ ? tableMd.serializeTable(burst.editEl,
4279
+ tableBlockRec ? extractBlockSource(lines, tableBlockRec) : null)
4190
4280
  : inlineMd.serializeInline(burst.editEl);
4191
4281
  if (result.unsupported.length > 0) {
4192
4282
  // Degrade-never-lose (same contract as Phase 2's openWysiwygEditor()
@@ -4452,6 +4542,15 @@
4452
4542
  window.__edTestForceRerender = function () { return rerenderAll(); };
4453
4543
  window.__edTestTruncateTo = function (n) { lines = lines.slice(0, n); };
4454
4544
 
4545
+ // v3.0.2: computeIndentClamp() lives past this file's node early-exit
4546
+ // (module.exports at line ~509 ships the pure core only) and nothing on
4547
+ // window reaches it, so the stale-span assert added to it in v3.0.2 had
4548
+ // no way to be tested at all. This is the seventh __edTest seam and the
4549
+ // only production surface that change adds.
4550
+ window.__edTestClampProbe = function (spanEls, opBlockEl, opOldIndent, opts) {
4551
+ return computeIndentClamp(spanEls, opBlockEl, opOldIndent, opts);
4552
+ };
4553
+
4455
4554
  // ── S3 Task 4: the entry and exit GESTURES ─────────────────────────────
4456
4555
  // §4.4 entries: (a) press inside a block and drag across its boundary;
4457
4556
  // (b) Shift+Click; (c) Shift+↑↓. Exits: Escape (Task 3's keydown prologue)
@@ -5468,16 +5567,29 @@
5468
5567
  function indentListItem(blockEl) {
5469
5568
  const self = liAttrs(blockEl);
5470
5569
  if (!self) return false;
5471
- // Rule (d): the first item of a LIST has nothing above it to nest under.
5472
- // Without this the §3.4 clamp would happily read the previous list's last
5473
- // item as "the previous block" and indent this one underneath it, merging
5474
- // two lists the user never asked to join. Pre-S1 this fell out of
5475
- // `previousElementSibling` being null inside the item's own <ul>.
5476
- if (self.listStart) return false;
5477
5570
  const all = allBlockEls();
5478
5571
  const i = all.indexOf(blockEl);
5479
5572
  if (i < 0) return false;
5480
5573
  const prev = liAttrs(all[i - 1]);
5574
+ // Rule (d), narrowed (v3.0.1). The old rule was `if (self.listStart)
5575
+ // return false` — the first item of a LIST has nothing above it to nest
5576
+ // under. That over-refused: a bullet outdented to indent 0 directly
5577
+ // BELOW an ordered item is a listStart (a ul following an ol) whose
5578
+ // previous block is a perfectly good parent, and the user could never
5579
+ // indent it back. The half that still has to be refused is the one the
5580
+ // old rule was really aimed at: two list tokens the user kept apart with
5581
+ // a BLANK LINE. Indenting the second one makes listRunOf() span both,
5582
+ // and re-serializing that span swallows the blank line — a rewrite of
5583
+ // lines nobody touched, which is the one thing this editor must never do.
5584
+ // Contiguity is a fact about the FILE, so it is read off `blocks`, not
5585
+ // guessed from the DOM.
5586
+ if (self.listStart) {
5587
+ if (!prev) return false;
5588
+ const prevRec = blockRecOf(all[i - 1]);
5589
+ const selfRec = blockRecOf(blockEl);
5590
+ if (!prevRec || !selfRec) return false;
5591
+ if (prevRec.endLine + 1 !== selfRec.startLine) return false;
5592
+ }
5481
5593
  const max = prev ? prev.indent + 1 : 0;
5482
5594
  const next = Math.min(self.indent + 1, max);
5483
5595
  if (next === self.indent) return false;
@@ -5554,22 +5666,39 @@
5554
5666
 
5555
5667
  // How far ONE member may move, as a magnitude (0 or 1), never a signed
5556
5668
  // delta. `dir` is +1 (Tab) or -1 (Shift+Tab). The ceiling restates
5557
- // indentListItem()'s own single-item rule — the list-start clause included,
5558
- // so a set that opens a list cannot nest itself under the previous list's
5559
- // last item — and the floor is outdentListItem()'s (indent 0 cannot rise).
5560
- // Both read PRE-move indents, which is §3.4's global convention ("the
5561
- // operated block's indent" always means the value before the operation), and
5562
- // it is what makes the minimum below well-defined: every member is measured
5563
- // against the document as it stands, not against members already moved.
5669
+ // indentListItem()'s own single-item rule, v3.0.1's narrowed list-start
5670
+ // clause included: a listStart member still refuses UNLESS the block
5671
+ // immediately before it (by file line, via blockRecOf — same contiguity
5672
+ // check, same code shape as indentListItem()'s, kept in that shape
5673
+ // deliberately so the two stay in visible agreement) is a contiguous list
5674
+ // item, in which case it has real head-room like any other item. Before
5675
+ // v3.0.1 this was unconditional (`if (self.listStart) return 0;`) —
5676
+ // batch Tab on a listStart block was refused outright, matching
5677
+ // indentListItem()'s then-unconditional refusal. Narrowing ONE of the two
5678
+ // without the other silently reintroduces the single-item defect on the
5679
+ // batch axis: a listStart item directly below a contiguous ordered item,
5680
+ // selected as part of a batch, would stay refused even though the very
5681
+ // same block indents fine on its own. The floor is outdentListItem()'s
5682
+ // (indent 0 cannot rise), untouched by this narrowing. Both read PRE-move
5683
+ // indents, which is §3.4's global convention ("the operated block's
5684
+ // indent" always means the value before the operation), and it is what
5685
+ // makes the minimum below well-defined: every member is measured against
5686
+ // the document as it stands, not against members already moved.
5564
5687
  function memberIndentHeadroom(el, dir) {
5565
5688
  const self = liAttrs(el);
5566
5689
  if (!self) return 0;
5567
5690
  if (dir < 0) return self.indent > 0 ? 1 : 0;
5568
- if (self.listStart) return 0;
5569
5691
  const all = allBlockEls();
5570
5692
  const i = all.indexOf(el);
5571
5693
  if (i < 0) return 0;
5572
5694
  const prev = liAttrs(all[i - 1]);
5695
+ if (self.listStart) {
5696
+ if (!prev) return 0;
5697
+ const prevRec = blockRecOf(all[i - 1]);
5698
+ const selfRec = blockRecOf(el);
5699
+ if (!prevRec || !selfRec) return 0;
5700
+ if (prevRec.endLine + 1 !== selfRec.startLine) return 0;
5701
+ }
5573
5702
  const max = prev ? prev.indent + 1 : 0;
5574
5703
  return Math.max(0, Math.min(1, max - self.indent));
5575
5704
  }
@@ -5623,7 +5752,30 @@
5623
5752
  // segment delta for the blocks below instead of N independent clamps, and
5624
5753
  // its rule 1 then walks the operated blocks in document order so each is
5625
5754
  // measured against the member above it that the same pass just settled.
5626
- applyIndentClamp(run, liEls, oldIndent);
5755
+ //
5756
+ // v3.0.1 (review round 1 follow-up): `run` above is batchRunOf()'s
5757
+ // PRE-mutation snapshot — the exact same staleness handleLiKeydown()'s
5758
+ // single-item Tab branch had before its own v3.0.1 fix (see that
5759
+ // call's comment). For a single-member batch on a listStart item whose
5760
+ // ceiling memberIndentHeadroom() has just narrowed (a bullet directly
5761
+ // below a contiguous ordered item), the pre-mutation run is `[liEls[0]]`
5762
+ // alone — batchRunOf() never walked backward past the list-start
5763
+ // boundary, because before this fix nothing could ever cross it. With
5764
+ // no anchor IN the span, applyIndentClamp()'s rule 1 has nothing to
5765
+ // bound the indent the forEach above just wrote against, and clamps it
5766
+ // straight back to 0 — silently undoing the write, MEASURED directly:
5767
+ // narrowing memberIndentHeadroom() alone left the covering test RED.
5768
+ // A FRESH listRunOf(liEls[0]) call here — taken after the write above —
5769
+ // walks back from the NEW indent and finds the real parent, exactly
5770
+ // like the post-mutation `liveRun = listRunOf(liEls[0])` a few lines
5771
+ // below (the COMMIT span) already does; using the same fresh call for
5772
+ // the clamp keeps the two consistent, same reasoning as
5773
+ // handleLiKeydown()'s Tab branch. Provably identical to the old stale
5774
+ // `run` for every case where no member was ever a list-start
5775
+ // (listRunOf() slices by INDEX RANGE, and neither the range's
5776
+ // start/end index nor the elements between them shift when only
5777
+ // already-run-member indents change).
5778
+ applyIndentClamp(listRunOf(liEls[0]), liEls, oldIndent);
5627
5779
  });
5628
5780
  // Re-derived AFTER the mutation, like the caret Tab's: an indent change can
5629
5781
  // move a block between runs.
@@ -5854,7 +6006,26 @@
5854
6006
  const oldIndent = Number(li.getAttribute('data-indent')) || 0;
5855
6007
  const changed = mutateListRun(() => {
5856
6008
  if (!(e.shiftKey ? outdentListItem(li) : indentListItem(li))) return false;
5857
- applyIndentClamp(run, li, oldIndent);
6009
+ // v3.0.1: `run` above is a PRE-mutation snapshot, captured while `li`
6010
+ // still had its OLD indent. That is fine for outdentListItem() — the
6011
+ // clamp's model rereads every element's CURRENT data-indent off the
6012
+ // DOM, so it self-corrects regardless of which elements the snapshot
6013
+ // names — but indentListItem() can now move a rule-(d) list-start
6014
+ // item (a bullet directly below an ordered item, say) whose own
6015
+ // pre-mutation run is `[li]` alone: nothing shallower ever preceded
6016
+ // it, because runBlocksOf() stops at a list-start by design. With no
6017
+ // anchor IN the span, applyIndentClamp()'s rule 1 has nothing to
6018
+ // bound the indent this call just wrote against, and clamps it
6019
+ // straight back to 0 — silently undoing the write. A FRESH
6020
+ // listRunOf(li) call here — taken after the write above — walks back
6021
+ // from li's NEW indent and finds the real parent, exactly like the
6022
+ // post-mutation `run = listRunOf(li)` a few lines below (the COMMIT
6023
+ // span) already does; using the same fresh call for the clamp keeps
6024
+ // the two consistent. Provably identical to the old stale `run` for
6025
+ // every case where li was never a list-start (listRunOf() slices by
6026
+ // INDEX RANGE, and neither the range's start/end index nor the
6027
+ // elements between them shift when only li's own indent changes).
6028
+ applyIndentClamp(listRunOf(li), li, oldIndent);
5858
6029
  return true;
5859
6030
  });
5860
6031
  if (!changed) return true;
@@ -6426,8 +6597,12 @@
6426
6597
  function tableIdentityOf(tableEl) {
6427
6598
  const headerRow = tableEl ? headerRowOf(tableEl) : null;
6428
6599
  if (!headerRow) return null;
6429
- return allRowsOf(tableEl).length + '' +
6430
- Array.prototype.slice.call(headerRow.cells).map((c) => c.textContent).join('');
6600
+ // The two delimiters are written as ESCAPES, not as literal control
6601
+ // bytes. They used to be literal, which made grep classify this whole
6602
+ // 9643-line file as binary and SILENTLY TRUNCATE its output — a real
6603
+ // investigation was misled by it. The runtime value is identical.
6604
+ return allRowsOf(tableEl).length + '\x01' +
6605
+ Array.prototype.slice.call(headerRow.cells).map((c) => c.textContent).join('\x00');
6431
6606
  }
6432
6607
  async function ensureTableBurstOpen(tableEl) {
6433
6608
  if (currentBurst && currentBurst.blockType === 'table' && currentBurst.editEl === tableEl) return tableEl;
@@ -6907,19 +7082,20 @@
6907
7082
  // listener (wired near the bottom of this file) that already drives
6908
7083
  // updateTableInsertBubbles() — see its own comment for the coalescing
6909
7084
  // contract this reuses.
6910
- // Geometry, per axis (they are NOT mirror images — see below):
7085
+ // Geometry, per axis (v3.0.1: they are mirror images again):
6911
7086
  // COLUMN grip: its CENTRELINE coincides with the table's TOP border, so
6912
7087
  // its hit rect straddles that border by half its own height on each
6913
7088
  // side (P0-a).
6914
- // ROW grip: its LEFT EDGE coincides with the table's LEFT border, so the
6915
- // whole grip is INSIDE the table. This is spec §4.2 hit-test
6916
- // conflict 2's ruling, landed in S4 T1.
6917
- //
6918
- // Why the row axis had to break the symmetry. The block's own gutter ⠿
6919
- // hangs outside the content box at [blockLeft-22, blockLeft-4], and a
6920
- // table block's own table starts AT blockLeft, so a row grip centred on
6921
- // the table's left border occupied [blockLeft-10, blockLeft+10] and
6922
- // overlapped the ⠿'s right 6px. Both are position: fixed / absolutely
7089
+ // ROW grip: its CENTRELINE coincides with the table's LEFT border, so
7090
+ // its hit rect straddles that border by half its own width on each
7091
+ // side — same shape as the column grip, restored in v3.0.1 (spec §4.2
7092
+ // hit-test conflict 2).
7093
+ //
7094
+ // History: why the row axis briefly broke the symmetry, and how that was
7095
+ // undone. The block's own gutter ⠿ hangs outside the content box, and a
7096
+ // table block's own table starts at that same left edge, so a row grip
7097
+ // centred on the table's left border occupied [blockLeft-10, blockLeft+10]
7098
+ // and overlapped the ⠿'s right 6px. Both are position: fixed / absolutely
6923
7099
  // positioned overlays and the grip is a document.body child at z-index 7,
6924
7100
  // so the grip won that hit test whenever it was showing — which is
6925
7101
  // whenever the pointer had recently been over a table cell. MEASURED on
@@ -6930,20 +7106,32 @@
6930
7106
  // 402..407. S4 makes the ⠿ a drag handle, so those 6px would have started
6931
7107
  // a table ROW drag instead of a block move.
6932
7108
  //
6933
- // An earlier revision (v2.10.1) already tried the inset and was reverted,
7109
+ // S4 T1 (v2.12.0) fixed that by INSETTING the grip instead — its left edge
7110
+ // on the table's left border, entirely inside the table, no more straddle.
7111
+ // That broke the row/column symmetry (the column grip still straddles the
7112
+ // table's top border, because nothing of the page's own chrome lives up
7113
+ // there) and was reported as a v3.0.1 visual regression. An even earlier
7114
+ // revision (v2.10.1) had already tried an inset like it and been reverted,
6934
7115
  // because a 20px grip inside a cell whose left padding is 14px sits on
6935
- // that cell's TEXT. That is fixed on the CSS side this time rather than by
6936
- // giving the grip back its outside half: lib/md2doc.js pads the first
6937
- // column to --ed-te-cell-pad-left (the grip's own width plus the gutter
6938
- // pair's breathing gap) in EDIT MODE ONLY. The ⠿ ↔ grip clearance that
6939
- // remains is exactly that same breathing-gap token — see the edit-mode
6940
- // :root block in lib/md2doc.js, which spells both tokens out; this file
6941
- // deliberately does not name them, because test/editor-client.test.js
6942
- // forbids the retired hover-gutter class name as a bare substring and the
6943
- // token names contain it. Neither edge is written down twice, so the two
6944
- // cannot drift apart again — drift is what produced this defect (the CSS
6945
- // comment claiming "an 8px gap" was describing v2.11.0's pair position,
6946
- // which v2.11.1 replaced).
7116
+ // that cell's TEXT — S4 T1 avoided repeating that by widening the first
7117
+ // column's own edit-mode padding to clear the inset grip.
7118
+ //
7119
+ // v3.0.1 undoes the inset rather than re-tuning it: the row grip goes back
7120
+ // to straddling the border (this file's change, see updateTableEdgeGrips()
7121
+ // below), and the ⠿/+ pair itself moves further LEFT instead (a shift
7122
+ // token declared alongside the gutter's own tokens in lib/md2doc.js) — the
7123
+ // overlap is removed at its SOURCE, not by moving the grip out of the
7124
+ // ⠿'s way. That also means the first column's edit-mode padding widening
7125
+ // is gone: the grip's straddling half (10px) fits inside the column's
7126
+ // default 14px padding on its own, so v2.10.1's original defect does not
7127
+ // come back. Neither the ⠿'s right edge nor the grip's left edge is
7128
+ // written down as a literal on either side of the gap between them — see
7129
+ // the shift-token comment in lib/md2doc.js's edit-mode :root block, which
7130
+ // this file deliberately does not name (test/editor-client.test.js forbids
7131
+ // the retired hover-gutter class name as a bare substring and the token
7132
+ // name contains it) — so the two cannot drift apart the way v2.11.0's pair
7133
+ // position once did (the CSS comment claiming "an 8px gap" was describing
7134
+ // that stale position, which v2.11.1 replaced).
6947
7135
  // test/editor-client-runtime.test.js asserts BOTH directions: every pixel
6948
7136
  // of a block's ⠿ answers that ⠿ (row grip hidden and showing), and the row
6949
7137
  // grip's own centre answers the row grip.
@@ -6962,6 +7150,19 @@
6962
7150
  // reported overlap corner AND verifies the z-index ordering directly —
6963
7151
  // re-verify it if either the grip's or bubble's z-index ever changes.
6964
7152
 
7153
+ // --ed-te-grip-row-w, read once and cached. The token is the single source
7154
+ // of truth for the grip's width (lib/md2doc.js declares it); parsing it here
7155
+ // means the JS never carries a second copy of the number.
7156
+ let gripRowWidthCache = null;
7157
+ function gripRowWidthPx() {
7158
+ if (gripRowWidthCache !== null) return gripRowWidthCache;
7159
+ const raw = getComputedStyle(document.documentElement)
7160
+ .getPropertyValue('--ed-te-grip-row-w');
7161
+ const n = parseFloat(raw);
7162
+ gripRowWidthCache = Number.isFinite(n) && n > 0 ? n : 20;
7163
+ return gripRowWidthCache;
7164
+ }
7165
+
6965
7166
  function buildTableGrip(cls, ariaLabel) {
6966
7167
  const b = document.createElement('button');
6967
7168
  b.type = 'button';
@@ -6998,6 +7199,40 @@
6998
7199
  let gripColTableEl = null;
6999
7200
  let gripColIndex = null;
7000
7201
 
7202
+ // v3.0.2 — the gutter keep-alive for the table row grip. Held in its own
7203
+ // module-level reference, NOT read back off `gripRowTableEl`, because
7204
+ // hideTableGrips() below nulls that at its own line 7246-7248: a teardown
7205
+ // that went looking for the block through it would find null exactly when
7206
+ // it needs to clear the class, and rerenderAll()'s hideTableGrips() call
7207
+ // reaches it with the table already detached.
7208
+ let gutterKeepEl = null;
7209
+
7210
+ function setGutterKeep(blockEl) {
7211
+ const next = blockEl || null;
7212
+ if (gutterKeepEl === next) return;
7213
+ if (gutterKeepEl) gutterKeepEl.classList.remove('ed-keep-lit');
7214
+ gutterKeepEl = next;
7215
+ if (gutterKeepEl) gutterKeepEl.classList.add('ed-keep-lit');
7216
+ }
7217
+
7218
+ function clearGutterKeep() { setGutterKeep(null); }
7219
+
7220
+ // The block whose gutter the CURRENTLY SHOWN row grip is covering, or null
7221
+ // when there is nothing to keep lit. Two conditions, both load-bearing:
7222
+ //
7223
+ // • gripRowTableEl / gripRowEl may be null while a grip is nonetheless
7224
+ // visible — a header-only table takes the else branch at 7466-7470 and
7225
+ // nulls both, then the column grip is shown unconditionally just
7226
+ // below. Both callers of this function are reached in that state.
7227
+ // • only the HEADER row's grip shares a Y with the ⠿. Keeping the
7228
+ // gutter lit for a body row's grip would light the ⠿ in a place the
7229
+ // defect never occurred — a behaviour change beyond the fix.
7230
+ function headerGripBlock() {
7231
+ if (!gripRowTableEl || !gripRowEl) return null;
7232
+ if (gripRowEl !== headerRowOf(gripRowTableEl)) return null;
7233
+ return gripRowTableEl.closest('.ed-block');
7234
+ }
7235
+
7001
7236
  function hideTableGrips() {
7002
7237
  rowGrip.hidden = true;
7003
7238
  colGrip.hidden = true;
@@ -7012,6 +7247,7 @@
7012
7247
  gripRowEl = null;
7013
7248
  gripColTableEl = null;
7014
7249
  gripColIndex = null;
7250
+ clearGutterKeep();
7015
7251
  }
7016
7252
 
7017
7253
  // Bug fix (user acceptance) — history: grips were originally BOTH
@@ -7025,16 +7261,18 @@
7025
7261
  // gripCenter(), which jump straight to the grip's own coordinates) could
7026
7262
  // ever land on it; a real mouse gesture could not.
7027
7263
  //
7028
- // Current geometry (the two axes DIVERGED in S4 T1, §4.2 conflict 2): the
7029
- // COLUMN grip still straddles the table's TOP border, half above / half
7030
- // below, so the corridor-crossing bug described above still applies to it
7031
- // exactly as originally described and pointInColGripZone() still covers
7032
- // the outside-the-border corridor. The ROW grip is now entirely INSIDE the
7033
- // table (its left edge on the table's left border), so a pointer
7034
- // travelling from a cell to it never leaves the table and there is no
7035
- // corridor to cross — pointInRowGripZone() is the padded grip rect alone.
7036
- // See its own comment for why the old formula was deleted rather than
7037
- // left in place to evaluate to the empty set.
7264
+ // Current geometry (v3.0.1 restored the row axis to mirror the column
7265
+ // axis — see the "Notion-style row/column grip handles" section comment
7266
+ // above for the S4 T1 detour into an inset row grip and how it was
7267
+ // undone): BOTH grips straddle their own border again, the COLUMN grip
7268
+ // half above / half below the table's TOP border and the ROW grip half
7269
+ // outside / half inside the table's LEFT border. The corridor-crossing
7270
+ // bug described above therefore applies to BOTH axes' straddling shape in
7271
+ // principle, and pointInColGripZone() still covers the column's
7272
+ // outside-the-border corridor exactly as before. pointInRowGripZone()
7273
+ // deliberately does NOT get an equivalent corridor formula back, even
7274
+ // though the straddle is back — see its own comment for why the padded
7275
+ // grip rect alone is still sufficient.
7038
7276
  //
7039
7277
  // Review fix (Important, first pass over-permissive): the first version of
7040
7278
  // this fix kept a grip visible while the pointer was ANYWHERE within the
@@ -7047,30 +7285,28 @@
7047
7285
  // SPECIFIC shown grip's own anchor (pointInRowGripZone()/
7048
7286
  // pointInColGripZone() below) instead of the whole table.
7049
7287
  //
7050
- // What each keep-zone covers TODAY — and the two axes diverged when §4.2
7051
- // conflict 2 moved the row grip inside the table (S4 T1):
7052
- // pointInColGripZone() still keeps the ORIGINAL corridor shape: the
7053
- // union of (the column grip's own padded rect) and (the straight strip
7054
- // between the grip's own TOP edge and the table's top border, x clamped
7055
- // to the anchor COLUMN's own horizontal extent, padded). The column
7056
- // grip still straddles the top border, so that corridor is still real.
7057
- // pointInRowGripZone() is now the padded grip rect ALONE. Its corridor
7058
- // was the strip between the grip's own LEFT edge and the table's LEFT
7059
- // border; with the grip's left edge ON that border the strip is empty,
7060
- // and §4.6 explicitly warns that leaving the formula in place would
7061
- // make it (and the two scenarios downstream of it) silently vacuous
7062
- // rather than red. It is deleted rather than left to read as if it did
7063
- // something. Nothing is lost: the corridor existed because a pointer
7064
- // travelling from a cell to a border-straddling grip had to leave the
7065
- // table on the way, and a grip that is entirely inside the table has no
7066
- // such approach — the pointer goes cell -> grip, and the
7067
- // `target.closest('.ed-te-grip-row')` early return at the top of
7068
- // updateTableEdgeGrips() covers the moment it arrives. The observable
7069
- // change is that leaving the table on the left now hides the row grip
7070
- // immediately, at the anchor row's own height as well as at any other
7071
- // row's; the scenario named below asserts both halves, because "hidden
7072
- // at another row's height" alone is true of any geometry once the
7073
- // corridor is gone.
7288
+ // What each keep-zone covers TODAY:
7289
+ // pointInColGripZone() keeps the ORIGINAL corridor shape: the union of
7290
+ // (the column grip's own padded rect) and (the straight strip between
7291
+ // the grip's own TOP edge and the table's top border, x clamped to the
7292
+ // anchor COLUMN's own horizontal extent, padded). The column grip
7293
+ // straddles the top border, so that corridor is real.
7294
+ // pointInRowGripZone() is the padded grip rect ALONE, no corridor term —
7295
+ // kept that way through the v3.0.1 straddle restoration on purpose,
7296
+ // not left behind by accident. A pointer travelling from inside a body
7297
+ // cell to the row grip's own centre (which sits exactly on the
7298
+ // table's left border) never has to leave the table's horizontal
7299
+ // extent on the way there, so the padded rect alone already covers
7300
+ // every REAL (non-teleporting) approach this file's own test suite
7301
+ // exercises — see "table grips: the row grip straddles the table's
7302
+ // left border again…" in test/editor-client-runtime.test.js. That is
7303
+ // narrower than the column axis's guarantee (nothing approaches the
7304
+ // row grip from further out along the table's own left edge the way a
7305
+ // pointer can approach the column grip from above it), which is why
7306
+ // the two functions are allowed to stay asymmetric even though the
7307
+ // grips' shapes are not. If a future report finds a real pointer path
7308
+ // that does need a corridor term here, that is a new, separate defect
7309
+ // — not evidence this reasoning was wrong when it was written.
7074
7310
  // A pointer outside either grip's own zone is a genuine exit and still
7075
7311
  // hides the grip via hideTableGrips(), same as before. Neither fix touches
7076
7312
  // either grip's size or z-index, so the click-priority guarantee (bubble
@@ -7090,16 +7326,32 @@
7090
7326
  function pointInRowGripZone(x, y) {
7091
7327
  if (rowGrip.hidden || !gripRowTableEl || !gripRowEl ||
7092
7328
  !document.body.contains(gripRowEl) || !document.body.contains(gripRowTableEl)) return false;
7093
- // §4.2 衝突 2(S4 T1)之後 row grip 整個落在表格內側,左緣就壓在表格
7094
- // 左邊界上,所以舊的走廊公式 `x >= gr.left && x <= tableRect.left` 塌成
7095
- // 空集合。§4.6 已經預告過這件事:留著它不會變紅,只會變成空測。這裡
7096
- // 直接刪掉,keep-zone 只剩 grip 自己的 padded rect。
7097
- // 走廊本來只為了一件事存在——指標從儲存格走向一個跨在邊界上的 grip
7098
- // 時必定會先離開表格;grip 內移之後那條路徑整段都在表格內(先是
7099
- // 儲存格,接著是 grip 自己,由 updateTableEdgeGrips() 開頭那個
7100
- // `target.closest('.ed-te-grip-row')` 早退接手),所以沒有走廊可走。
7101
- // 對使用者可見的差別是:從左側離開表格會立刻收掉 row grip,錨定那一
7102
- // 列的高度也不例外。
7329
+ // v3.0.1 恢復跨界:row grip 的中心線重新落在表格左邊界上(不再是
7330
+ // S4 T1 那個左緣頂在邊界上、整個縮進表格內側的做法)。與 ⠿ 的重疊
7331
+ // 改在源頭解決——往左再位移的是頁面 gutter 的 ⠿/+ 那一對按鈕本身
7332
+ // (多移動一個位移 token,宣告在 lib/md2doc.js 的編輯模式 :root 區
7333
+ // 塊,此檔案依慣例不直接引用其名稱),不是移動 grip。
7334
+ //
7335
+ // 走廊公式沒有跟著補回來,是刻意維持的範圍決定,不是漏掉:
7336
+ // TE_GRIP_ZONE_PAD_PX=4 的 padded rect 涵蓋 [tableLeft-14,
7337
+ // tableLeft+14](grip 本身 20px 寬、跨在邊界上是 [tableLeft-10,
7338
+ // tableLeft+10],外加 4px 緩衝)。指標從儲存格內側走向 grip 中心
7339
+ // (正好落在 tableLeft)的整段路徑,x 一律 >= tableLeft,從未離開
7340
+ // 表格的水平範圍,所以「儲存格 -> grip」這條測試套件唯一驗證過的
7341
+ // 真實(非瞬移)路徑,本來就不需要走廊公式。哪天真的量到一條需要
7342
+ // 走廊的真實路徑(例如從表格左側、gutter 那一側靠近),那是新的、
7343
+ // 獨立的缺陷,不代表這裡當初的推論錯了。
7344
+ //
7345
+ // 誠實記一筆巧合,不要誤讀成保證:⠿ 的右緣恰好落在 tableLeft-14,
7346
+ // 與這個 padded rect 的左緣相觸、不重疊——這是 TE_GRIP_ZONE_PAD_PX
7347
+ // 這個寫死的 4,與 lib/md2doc.js 那個 4px 呼吸間距 token 兩個各自
7348
+ // 獨立宣告出來的巧合,不是誰推導誰的保證。任一邊數值改動,觸點都
7349
+ // 可能變成 1px 縫隙或重疊,沒有任何型別檢查會抓到。這是既有狀態,
7350
+ // 不是本次修正引入或修掉的東西。
7351
+ //
7352
+ // 對使用者可見的差別:離開表格左側不再等於立刻收掉 row grip——padded
7353
+ // rect 本身就已經探出表格邊界 14px,所以指標要離開這整個 padded rect
7354
+ // (水平或垂直)才會真的收掉,錨定那一列的高度也不例外。
7103
7355
  return pointInPaddedRect(x, y, rowGrip.getBoundingClientRect(), TE_GRIP_ZONE_PAD_PX);
7104
7356
  }
7105
7357
 
@@ -7141,7 +7393,15 @@
7141
7393
  // whatever was last shown untouched instead; hideTableGrips() (called
7142
7394
  // from table-leave, burst-end, and drag-start elsewhere) already covers
7143
7395
  // every path that actually needs to clear it.
7144
- if (target && target.closest && (target.closest('.ed-te-grip-row') || target.closest('.ed-te-grip-col'))) return;
7396
+ if (target && target.closest && (target.closest('.ed-te-grip-row') || target.closest('.ed-te-grip-col'))) {
7397
+ // v3.0.2: only the ROW grip is the one that covers the ⠿'s approach.
7398
+ // Testing the hit rather than reusing the branch condition matters —
7399
+ // the column grip sits above the table's top border, nowhere near the
7400
+ // ⠿, and lighting the gutter from there is the behaviour change this
7401
+ // fix is scoped to avoid.
7402
+ setGutterKeep(target.closest('.ed-te-grip-row') ? headerGripBlock() : null);
7403
+ return;
7404
+ }
7145
7405
  const blockEl = target && target.closest && target.closest('.ed-block[data-block-type="table"]');
7146
7406
  const tableEl = blockEl ? blockContentEl(blockEl) : null;
7147
7407
  const cellEl = (tableEl && target && target.closest) ? target.closest('th, td') : null;
@@ -7154,10 +7414,15 @@
7154
7414
  // comment for why not the whole table). Keep that grip up while still
7155
7415
  // in its zone; only actually hide once the pointer has left both
7156
7416
  // zones entirely.
7157
- if (pointInRowGripZone(x, y) || pointInColGripZone(x, y)) return;
7417
+ const inRowZone = pointInRowGripZone(x, y);
7418
+ if (inRowZone || pointInColGripZone(x, y)) {
7419
+ setGutterKeep(inRowZone ? headerGripBlock() : null);
7420
+ return;
7421
+ }
7158
7422
  hideTableGrips();
7159
7423
  return;
7160
7424
  }
7425
+ clearGutterKeep();
7161
7426
 
7162
7427
  const tableRect = tableEl.getBoundingClientRect();
7163
7428
  const rowEl = cellEl.parentElement;
@@ -7167,10 +7432,11 @@
7167
7432
  // Row grip: every row, including the header — the first row of a
7168
7433
  // markdown table IS the header, so any row must be draggable to the
7169
7434
  // top to become it. ONE position rule for all of them (user acceptance:
7170
- // 「grip 位置都一樣」) — its LEFT EDGE on the table's left border, i.e.
7171
- // entirely inside the table (spec §4.2 hit-test conflict 2, landed
7172
- // S4 T1 — see the geometry note above buildTableGrip() for the
7173
- // measurement that forced it), vertically centred on its own row; no
7435
+ // 「grip 位置都一樣」) — its CENTRELINE on the table's left border, i.e.
7436
+ // straddling it half outside / half inside the table, restored in
7437
+ // v3.0.1 (spec §4.2 hit-test conflict 2 — see the geometry note above
7438
+ // buildTableGrip() for the S4 T1 detour into an inset grip and how it
7439
+ // was undone), vertically centred on its own row; no
7174
7440
  // header special case. The
7175
7441
  // one exception is a header-only table (no body rows): its single row
7176
7442
  // is thead's only row, and dragging it away would empty the thead —
@@ -7183,16 +7449,18 @@
7183
7449
  // Fallback height matches .ed-te-grip-row's own CSS height exactly
7184
7450
  // (28) — offsetHeight reads 0 while `hidden` (display: none) is still
7185
7451
  // true on the FIRST show of a hover session, before the
7186
- // `hidden = false` assignment below takes effect. There is no width
7187
- // fallback any more, and that is the point: the left edge is the
7188
- // table's own border, so the grip's WIDTH is not an input to its
7189
- // position at all. It used to be (`tableRect.left - gw / 2`), which
7190
- // meant the CSS width and this literal `20` had to agree for the
7191
- // gutter clearance to hold — exactly the drift §4.2 conflict 2 is
7192
- // about. The width now lives in one place only, the
7193
- // --ed-te-grip-row-w token in lib/md2doc.js.
7452
+ // `hidden = false` assignment below takes effect.
7194
7453
  const gh = rowGrip.offsetHeight || 28;
7195
- rowGrip.style.left = tableRect.left + 'px';
7454
+ // v3.0.1: back to STRADDLING the table's left border (the S4 T1 inset
7455
+ // was a workaround for the ⠿ hit-test overlap; the ⠿/+ pair has
7456
+ // since moved further left via a CSS shift token in lib/md2doc.js,
7457
+ // which removes the overlap at its source). The width is read from the
7458
+ // token rather than written as a literal here: offsetWidth reads 0
7459
+ // while `hidden` is still true on the first show of a hover session,
7460
+ // and a hard-coded fallback is exactly the CSS/JS drift the token
7461
+ // exists to prevent.
7462
+ const gw = rowGrip.offsetWidth || gripRowWidthPx();
7463
+ rowGrip.style.left = (tableRect.left - gw / 2) + 'px';
7196
7464
  rowGrip.style.top = (r.top + r.height / 2 - gh / 2) + 'px';
7197
7465
  rowGrip.hidden = false;
7198
7466
  } else {