@helping-ai-workflow/md2doc 3.0.0 → 3.0.1

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.
@@ -2009,6 +2009,30 @@
2009
2009
  '<path d="M6.6 7.2v4.2"/><path d="M9.4 7.2v4.2"/>',
2010
2010
  // MD 原始碼 — angle brackets: source, not prose.
2011
2011
  code: '<path d="M6 3.6L2 8l4 4.4"/><path d="M10 3.6L14 8l-4 4.4"/>',
2012
+ // ── v3.0.1: one icon per 轉換成 target ────────────────────────────────
2013
+ // Keys are CONVERT_TARGETS[].id verbatim; openConvertSubmenu() looks them
2014
+ // up by that id, so a rename on either side must be made on both.
2015
+ // 文字 — a paragraph pilcrow, drawn rather than typed so it inherits the
2016
+ // same stroke weight as every sibling icon.
2017
+ 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"/>',
2018
+ // 標題 1–6 — an "H" plus the level, as two strokes and a numeral bar set.
2019
+ h1: '<path d="M3 3v10"/><path d="M8 3v10"/><path d="M3 8h5"/><path d="M11.5 6.5l1.5-1v7.5"/>',
2020
+ 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"/>',
2021
+ 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"/>',
2022
+ 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"/>',
2023
+ 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"/>',
2024
+ 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"/>',
2025
+ // 項目符號列表 — three dots with three rules.
2026
+ 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"/>' +
2027
+ '<path d="M6.4 4.2h7.4"/><path d="M6.4 8h7.4"/><path d="M6.4 11.8h7.4"/>',
2028
+ // 編號列表 — the same three rules led by 1/2/3 strokes.
2029
+ 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"/>' +
2030
+ '<path d="M2.1 11.4h1.6l-1 1.1a1 1 0 1 1-.7 1.2"/>' +
2031
+ '<path d="M6.4 4.2h7.4"/><path d="M6.4 8.6h7.4"/><path d="M6.4 12.9h7.4"/>',
2032
+ // 待辦清單 — a checked box.
2033
+ 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"/>',
2034
+ // 引用 — a quote bar plus two shortened rules.
2035
+ 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
2036
  };
2013
2037
  function menuIconMarkup(name) {
2014
2038
  return '<svg class="ed-menu-icon" viewBox="0 0 16 16" width="16" height="16" ' +
@@ -2191,7 +2215,13 @@
2191
2215
  const b = document.createElement('button');
2192
2216
  b.type = 'button';
2193
2217
  b.className = 'ed-handle-menu-btn';
2194
- b.textContent = t.label;
2218
+ // v3.0.1: the submenu carries icons too. Without them its labels start
2219
+ // at the button's own padding while the parent menu's start past a 16px
2220
+ // icon, so the two levels' text does not share a vertical line — the
2221
+ // exact defect the user reported. `.ed-handle-menu-btn` is already a
2222
+ // flex row with a gap, so an icon plus a text node is all it takes.
2223
+ b.innerHTML = menuIconMarkup(t.id);
2224
+ b.appendChild(document.createTextNode(t.label));
2195
2225
  b.setAttribute('aria-label', 'Convert to ' + t.id);
2196
2226
  b.addEventListener('click', (e) => {
2197
2227
  e.stopPropagation();
@@ -4186,7 +4216,16 @@
4186
4216
  // S1 removed the pre-per-li 'list' branch that lived here: blockmap has not
4187
4217
  // emitted type:'list' blocks since Phase 4, so no startBurst() call could
4188
4218
  // produce blockType === 'list' and the branch was already dead code.
4189
- const result = burst.blockType === 'table' ? tableMd.serializeTable(burst.editEl)
4219
+ // v3.0.1: hand the serializer the table's CURRENT source lines so it can
4220
+ // reuse the author's own column widths instead of flattening the whole
4221
+ // table to minimal form over a one-cell edit. extractBlockSource() is the
4222
+ // same helper the raw editor prefills from, so "what the file says" has
4223
+ // exactly one definition.
4224
+ const tableBlockRec = burst.blockType === 'table'
4225
+ ? blocks.find((b) => b.id === burst.blockId) : null;
4226
+ const result = burst.blockType === 'table'
4227
+ ? tableMd.serializeTable(burst.editEl,
4228
+ tableBlockRec ? extractBlockSource(lines, tableBlockRec) : null)
4190
4229
  : inlineMd.serializeInline(burst.editEl);
4191
4230
  if (result.unsupported.length > 0) {
4192
4231
  // Degrade-never-lose (same contract as Phase 2's openWysiwygEditor()
@@ -5468,16 +5507,29 @@
5468
5507
  function indentListItem(blockEl) {
5469
5508
  const self = liAttrs(blockEl);
5470
5509
  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
5510
  const all = allBlockEls();
5478
5511
  const i = all.indexOf(blockEl);
5479
5512
  if (i < 0) return false;
5480
5513
  const prev = liAttrs(all[i - 1]);
5514
+ // Rule (d), narrowed (v3.0.1). The old rule was `if (self.listStart)
5515
+ // return false` — the first item of a LIST has nothing above it to nest
5516
+ // under. That over-refused: a bullet outdented to indent 0 directly
5517
+ // BELOW an ordered item is a listStart (a ul following an ol) whose
5518
+ // previous block is a perfectly good parent, and the user could never
5519
+ // indent it back. The half that still has to be refused is the one the
5520
+ // old rule was really aimed at: two list tokens the user kept apart with
5521
+ // a BLANK LINE. Indenting the second one makes listRunOf() span both,
5522
+ // and re-serializing that span swallows the blank line — a rewrite of
5523
+ // lines nobody touched, which is the one thing this editor must never do.
5524
+ // Contiguity is a fact about the FILE, so it is read off `blocks`, not
5525
+ // guessed from the DOM.
5526
+ if (self.listStart) {
5527
+ if (!prev) return false;
5528
+ const prevRec = blockRecOf(all[i - 1]);
5529
+ const selfRec = blockRecOf(blockEl);
5530
+ if (!prevRec || !selfRec) return false;
5531
+ if (prevRec.endLine + 1 !== selfRec.startLine) return false;
5532
+ }
5481
5533
  const max = prev ? prev.indent + 1 : 0;
5482
5534
  const next = Math.min(self.indent + 1, max);
5483
5535
  if (next === self.indent) return false;
@@ -5554,22 +5606,39 @@
5554
5606
 
5555
5607
  // How far ONE member may move, as a magnitude (0 or 1), never a signed
5556
5608
  // 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.
5609
+ // indentListItem()'s own single-item rule, v3.0.1's narrowed list-start
5610
+ // clause included: a listStart member still refuses UNLESS the block
5611
+ // immediately before it (by file line, via blockRecOf — same contiguity
5612
+ // check, same code shape as indentListItem()'s, kept in that shape
5613
+ // deliberately so the two stay in visible agreement) is a contiguous list
5614
+ // item, in which case it has real head-room like any other item. Before
5615
+ // v3.0.1 this was unconditional (`if (self.listStart) return 0;`) —
5616
+ // batch Tab on a listStart block was refused outright, matching
5617
+ // indentListItem()'s then-unconditional refusal. Narrowing ONE of the two
5618
+ // without the other silently reintroduces the single-item defect on the
5619
+ // batch axis: a listStart item directly below a contiguous ordered item,
5620
+ // selected as part of a batch, would stay refused even though the very
5621
+ // same block indents fine on its own. The floor is outdentListItem()'s
5622
+ // (indent 0 cannot rise), untouched by this narrowing. Both read PRE-move
5623
+ // indents, which is §3.4's global convention ("the operated block's
5624
+ // indent" always means the value before the operation), and it is what
5625
+ // makes the minimum below well-defined: every member is measured against
5626
+ // the document as it stands, not against members already moved.
5564
5627
  function memberIndentHeadroom(el, dir) {
5565
5628
  const self = liAttrs(el);
5566
5629
  if (!self) return 0;
5567
5630
  if (dir < 0) return self.indent > 0 ? 1 : 0;
5568
- if (self.listStart) return 0;
5569
5631
  const all = allBlockEls();
5570
5632
  const i = all.indexOf(el);
5571
5633
  if (i < 0) return 0;
5572
5634
  const prev = liAttrs(all[i - 1]);
5635
+ if (self.listStart) {
5636
+ if (!prev) return 0;
5637
+ const prevRec = blockRecOf(all[i - 1]);
5638
+ const selfRec = blockRecOf(el);
5639
+ if (!prevRec || !selfRec) return 0;
5640
+ if (prevRec.endLine + 1 !== selfRec.startLine) return 0;
5641
+ }
5573
5642
  const max = prev ? prev.indent + 1 : 0;
5574
5643
  return Math.max(0, Math.min(1, max - self.indent));
5575
5644
  }
@@ -5623,7 +5692,30 @@
5623
5692
  // segment delta for the blocks below instead of N independent clamps, and
5624
5693
  // its rule 1 then walks the operated blocks in document order so each is
5625
5694
  // measured against the member above it that the same pass just settled.
5626
- applyIndentClamp(run, liEls, oldIndent);
5695
+ //
5696
+ // v3.0.1 (review round 1 follow-up): `run` above is batchRunOf()'s
5697
+ // PRE-mutation snapshot — the exact same staleness handleLiKeydown()'s
5698
+ // single-item Tab branch had before its own v3.0.1 fix (see that
5699
+ // call's comment). For a single-member batch on a listStart item whose
5700
+ // ceiling memberIndentHeadroom() has just narrowed (a bullet directly
5701
+ // below a contiguous ordered item), the pre-mutation run is `[liEls[0]]`
5702
+ // alone — batchRunOf() never walked backward past the list-start
5703
+ // boundary, because before this fix nothing could ever cross it. With
5704
+ // no anchor IN the span, applyIndentClamp()'s rule 1 has nothing to
5705
+ // bound the indent the forEach above just wrote against, and clamps it
5706
+ // straight back to 0 — silently undoing the write, MEASURED directly:
5707
+ // narrowing memberIndentHeadroom() alone left the covering test RED.
5708
+ // A FRESH listRunOf(liEls[0]) call here — taken after the write above —
5709
+ // walks back from the NEW indent and finds the real parent, exactly
5710
+ // like the post-mutation `liveRun = listRunOf(liEls[0])` a few lines
5711
+ // below (the COMMIT span) already does; using the same fresh call for
5712
+ // the clamp keeps the two consistent, same reasoning as
5713
+ // handleLiKeydown()'s Tab branch. Provably identical to the old stale
5714
+ // `run` for every case where no member was ever a list-start
5715
+ // (listRunOf() slices by INDEX RANGE, and neither the range's
5716
+ // start/end index nor the elements between them shift when only
5717
+ // already-run-member indents change).
5718
+ applyIndentClamp(listRunOf(liEls[0]), liEls, oldIndent);
5627
5719
  });
5628
5720
  // Re-derived AFTER the mutation, like the caret Tab's: an indent change can
5629
5721
  // move a block between runs.
@@ -5854,7 +5946,26 @@
5854
5946
  const oldIndent = Number(li.getAttribute('data-indent')) || 0;
5855
5947
  const changed = mutateListRun(() => {
5856
5948
  if (!(e.shiftKey ? outdentListItem(li) : indentListItem(li))) return false;
5857
- applyIndentClamp(run, li, oldIndent);
5949
+ // v3.0.1: `run` above is a PRE-mutation snapshot, captured while `li`
5950
+ // still had its OLD indent. That is fine for outdentListItem() — the
5951
+ // clamp's model rereads every element's CURRENT data-indent off the
5952
+ // DOM, so it self-corrects regardless of which elements the snapshot
5953
+ // names — but indentListItem() can now move a rule-(d) list-start
5954
+ // item (a bullet directly below an ordered item, say) whose own
5955
+ // pre-mutation run is `[li]` alone: nothing shallower ever preceded
5956
+ // it, because runBlocksOf() stops at a list-start by design. With no
5957
+ // anchor IN the span, applyIndentClamp()'s rule 1 has nothing to
5958
+ // bound the indent this call just wrote against, and clamps it
5959
+ // straight back to 0 — silently undoing the write. A FRESH
5960
+ // listRunOf(li) call here — taken after the write above — walks back
5961
+ // from li's NEW indent and finds the real parent, exactly like the
5962
+ // post-mutation `run = listRunOf(li)` a few lines below (the COMMIT
5963
+ // span) already does; using the same fresh call for the clamp keeps
5964
+ // the two consistent. Provably identical to the old stale `run` for
5965
+ // every case where li was never a list-start (listRunOf() slices by
5966
+ // INDEX RANGE, and neither the range's start/end index nor the
5967
+ // elements between them shift when only li's own indent changes).
5968
+ applyIndentClamp(listRunOf(li), li, oldIndent);
5858
5969
  return true;
5859
5970
  });
5860
5971
  if (!changed) return true;
@@ -6426,8 +6537,12 @@
6426
6537
  function tableIdentityOf(tableEl) {
6427
6538
  const headerRow = tableEl ? headerRowOf(tableEl) : null;
6428
6539
  if (!headerRow) return null;
6429
- return allRowsOf(tableEl).length + '' +
6430
- Array.prototype.slice.call(headerRow.cells).map((c) => c.textContent).join('');
6540
+ // The two delimiters are written as ESCAPES, not as literal control
6541
+ // bytes. They used to be literal, which made grep classify this whole
6542
+ // 9643-line file as binary and SILENTLY TRUNCATE its output — a real
6543
+ // investigation was misled by it. The runtime value is identical.
6544
+ return allRowsOf(tableEl).length + '\x01' +
6545
+ Array.prototype.slice.call(headerRow.cells).map((c) => c.textContent).join('\x00');
6431
6546
  }
6432
6547
  async function ensureTableBurstOpen(tableEl) {
6433
6548
  if (currentBurst && currentBurst.blockType === 'table' && currentBurst.editEl === tableEl) return tableEl;
@@ -6907,19 +7022,20 @@
6907
7022
  // listener (wired near the bottom of this file) that already drives
6908
7023
  // updateTableInsertBubbles() — see its own comment for the coalescing
6909
7024
  // contract this reuses.
6910
- // Geometry, per axis (they are NOT mirror images — see below):
7025
+ // Geometry, per axis (v3.0.1: they are mirror images again):
6911
7026
  // COLUMN grip: its CENTRELINE coincides with the table's TOP border, so
6912
7027
  // its hit rect straddles that border by half its own height on each
6913
7028
  // 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
7029
+ // ROW grip: its CENTRELINE coincides with the table's LEFT border, so
7030
+ // its hit rect straddles that border by half its own width on each
7031
+ // side — same shape as the column grip, restored in v3.0.1 (spec §4.2
7032
+ // hit-test conflict 2).
7033
+ //
7034
+ // History: why the row axis briefly broke the symmetry, and how that was
7035
+ // undone. The block's own gutter ⠿ hangs outside the content box, and a
7036
+ // table block's own table starts at that same left edge, so a row grip
7037
+ // centred on the table's left border occupied [blockLeft-10, blockLeft+10]
7038
+ // and overlapped the ⠿'s right 6px. Both are position: fixed / absolutely
6923
7039
  // positioned overlays and the grip is a document.body child at z-index 7,
6924
7040
  // so the grip won that hit test whenever it was showing — which is
6925
7041
  // whenever the pointer had recently been over a table cell. MEASURED on
@@ -6930,20 +7046,32 @@
6930
7046
  // 402..407. S4 makes the ⠿ a drag handle, so those 6px would have started
6931
7047
  // a table ROW drag instead of a block move.
6932
7048
  //
6933
- // An earlier revision (v2.10.1) already tried the inset and was reverted,
7049
+ // S4 T1 (v2.12.0) fixed that by INSETTING the grip instead — its left edge
7050
+ // on the table's left border, entirely inside the table, no more straddle.
7051
+ // That broke the row/column symmetry (the column grip still straddles the
7052
+ // table's top border, because nothing of the page's own chrome lives up
7053
+ // there) and was reported as a v3.0.1 visual regression. An even earlier
7054
+ // revision (v2.10.1) had already tried an inset like it and been reverted,
6934
7055
  // 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).
7056
+ // that cell's TEXT — S4 T1 avoided repeating that by widening the first
7057
+ // column's own edit-mode padding to clear the inset grip.
7058
+ //
7059
+ // v3.0.1 undoes the inset rather than re-tuning it: the row grip goes back
7060
+ // to straddling the border (this file's change, see updateTableEdgeGrips()
7061
+ // below), and the ⠿/+ pair itself moves further LEFT instead (a shift
7062
+ // token declared alongside the gutter's own tokens in lib/md2doc.js) — the
7063
+ // overlap is removed at its SOURCE, not by moving the grip out of the
7064
+ // ⠿'s way. That also means the first column's edit-mode padding widening
7065
+ // is gone: the grip's straddling half (10px) fits inside the column's
7066
+ // default 14px padding on its own, so v2.10.1's original defect does not
7067
+ // come back. Neither the ⠿'s right edge nor the grip's left edge is
7068
+ // written down as a literal on either side of the gap between them — see
7069
+ // the shift-token comment in lib/md2doc.js's edit-mode :root block, which
7070
+ // this file deliberately does not name (test/editor-client.test.js forbids
7071
+ // the retired hover-gutter class name as a bare substring and the token
7072
+ // name contains it) — so the two cannot drift apart the way v2.11.0's pair
7073
+ // position once did (the CSS comment claiming "an 8px gap" was describing
7074
+ // that stale position, which v2.11.1 replaced).
6947
7075
  // test/editor-client-runtime.test.js asserts BOTH directions: every pixel
6948
7076
  // of a block's ⠿ answers that ⠿ (row grip hidden and showing), and the row
6949
7077
  // grip's own centre answers the row grip.
@@ -6962,6 +7090,19 @@
6962
7090
  // reported overlap corner AND verifies the z-index ordering directly —
6963
7091
  // re-verify it if either the grip's or bubble's z-index ever changes.
6964
7092
 
7093
+ // --ed-te-grip-row-w, read once and cached. The token is the single source
7094
+ // of truth for the grip's width (lib/md2doc.js declares it); parsing it here
7095
+ // means the JS never carries a second copy of the number.
7096
+ let gripRowWidthCache = null;
7097
+ function gripRowWidthPx() {
7098
+ if (gripRowWidthCache !== null) return gripRowWidthCache;
7099
+ const raw = getComputedStyle(document.documentElement)
7100
+ .getPropertyValue('--ed-te-grip-row-w');
7101
+ const n = parseFloat(raw);
7102
+ gripRowWidthCache = Number.isFinite(n) && n > 0 ? n : 20;
7103
+ return gripRowWidthCache;
7104
+ }
7105
+
6965
7106
  function buildTableGrip(cls, ariaLabel) {
6966
7107
  const b = document.createElement('button');
6967
7108
  b.type = 'button';
@@ -7025,16 +7166,18 @@
7025
7166
  // gripCenter(), which jump straight to the grip's own coordinates) could
7026
7167
  // ever land on it; a real mouse gesture could not.
7027
7168
  //
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.
7169
+ // Current geometry (v3.0.1 restored the row axis to mirror the column
7170
+ // axis — see the "Notion-style row/column grip handles" section comment
7171
+ // above for the S4 T1 detour into an inset row grip and how it was
7172
+ // undone): BOTH grips straddle their own border again, the COLUMN grip
7173
+ // half above / half below the table's TOP border and the ROW grip half
7174
+ // outside / half inside the table's LEFT border. The corridor-crossing
7175
+ // bug described above therefore applies to BOTH axes' straddling shape in
7176
+ // principle, and pointInColGripZone() still covers the column's
7177
+ // outside-the-border corridor exactly as before. pointInRowGripZone()
7178
+ // deliberately does NOT get an equivalent corridor formula back, even
7179
+ // though the straddle is back — see its own comment for why the padded
7180
+ // grip rect alone is still sufficient.
7038
7181
  //
7039
7182
  // Review fix (Important, first pass over-permissive): the first version of
7040
7183
  // this fix kept a grip visible while the pointer was ANYWHERE within the
@@ -7047,30 +7190,28 @@
7047
7190
  // SPECIFIC shown grip's own anchor (pointInRowGripZone()/
7048
7191
  // pointInColGripZone() below) instead of the whole table.
7049
7192
  //
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.
7193
+ // What each keep-zone covers TODAY:
7194
+ // pointInColGripZone() keeps the ORIGINAL corridor shape: the union of
7195
+ // (the column grip's own padded rect) and (the straight strip between
7196
+ // the grip's own TOP edge and the table's top border, x clamped to the
7197
+ // anchor COLUMN's own horizontal extent, padded). The column grip
7198
+ // straddles the top border, so that corridor is real.
7199
+ // pointInRowGripZone() is the padded grip rect ALONE, no corridor term —
7200
+ // kept that way through the v3.0.1 straddle restoration on purpose,
7201
+ // not left behind by accident. A pointer travelling from inside a body
7202
+ // cell to the row grip's own centre (which sits exactly on the
7203
+ // table's left border) never has to leave the table's horizontal
7204
+ // extent on the way there, so the padded rect alone already covers
7205
+ // every REAL (non-teleporting) approach this file's own test suite
7206
+ // exercises — see "table grips: the row grip straddles the table's
7207
+ // left border again…" in test/editor-client-runtime.test.js. That is
7208
+ // narrower than the column axis's guarantee (nothing approaches the
7209
+ // row grip from further out along the table's own left edge the way a
7210
+ // pointer can approach the column grip from above it), which is why
7211
+ // the two functions are allowed to stay asymmetric even though the
7212
+ // grips' shapes are not. If a future report finds a real pointer path
7213
+ // that does need a corridor term here, that is a new, separate defect
7214
+ // — not evidence this reasoning was wrong when it was written.
7074
7215
  // A pointer outside either grip's own zone is a genuine exit and still
7075
7216
  // hides the grip via hideTableGrips(), same as before. Neither fix touches
7076
7217
  // either grip's size or z-index, so the click-priority guarantee (bubble
@@ -7090,16 +7231,32 @@
7090
7231
  function pointInRowGripZone(x, y) {
7091
7232
  if (rowGrip.hidden || !gripRowTableEl || !gripRowEl ||
7092
7233
  !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
- // 列的高度也不例外。
7234
+ // v3.0.1 恢復跨界:row grip 的中心線重新落在表格左邊界上(不再是
7235
+ // S4 T1 那個左緣頂在邊界上、整個縮進表格內側的做法)。與 ⠿ 的重疊
7236
+ // 改在源頭解決——往左再位移的是頁面 gutter 的 ⠿/+ 那一對按鈕本身
7237
+ // (多移動一個位移 token,宣告在 lib/md2doc.js 的編輯模式 :root 區
7238
+ // 塊,此檔案依慣例不直接引用其名稱),不是移動 grip。
7239
+ //
7240
+ // 走廊公式沒有跟著補回來,是刻意維持的範圍決定,不是漏掉:
7241
+ // TE_GRIP_ZONE_PAD_PX=4 的 padded rect 涵蓋 [tableLeft-14,
7242
+ // tableLeft+14](grip 本身 20px 寬、跨在邊界上是 [tableLeft-10,
7243
+ // tableLeft+10],外加 4px 緩衝)。指標從儲存格內側走向 grip 中心
7244
+ // (正好落在 tableLeft)的整段路徑,x 一律 >= tableLeft,從未離開
7245
+ // 表格的水平範圍,所以「儲存格 -> grip」這條測試套件唯一驗證過的
7246
+ // 真實(非瞬移)路徑,本來就不需要走廊公式。哪天真的量到一條需要
7247
+ // 走廊的真實路徑(例如從表格左側、gutter 那一側靠近),那是新的、
7248
+ // 獨立的缺陷,不代表這裡當初的推論錯了。
7249
+ //
7250
+ // 誠實記一筆巧合,不要誤讀成保證:⠿ 的右緣恰好落在 tableLeft-14,
7251
+ // 與這個 padded rect 的左緣相觸、不重疊——這是 TE_GRIP_ZONE_PAD_PX
7252
+ // 這個寫死的 4,與 lib/md2doc.js 那個 4px 呼吸間距 token 兩個各自
7253
+ // 獨立宣告出來的巧合,不是誰推導誰的保證。任一邊數值改動,觸點都
7254
+ // 可能變成 1px 縫隙或重疊,沒有任何型別檢查會抓到。這是既有狀態,
7255
+ // 不是本次修正引入或修掉的東西。
7256
+ //
7257
+ // 對使用者可見的差別:離開表格左側不再等於立刻收掉 row grip——padded
7258
+ // rect 本身就已經探出表格邊界 14px,所以指標要離開這整個 padded rect
7259
+ // (水平或垂直)才會真的收掉,錨定那一列的高度也不例外。
7103
7260
  return pointInPaddedRect(x, y, rowGrip.getBoundingClientRect(), TE_GRIP_ZONE_PAD_PX);
7104
7261
  }
7105
7262
 
@@ -7167,10 +7324,11 @@
7167
7324
  // Row grip: every row, including the header — the first row of a
7168
7325
  // markdown table IS the header, so any row must be draggable to the
7169
7326
  // 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
7327
+ // 「grip 位置都一樣」) — its CENTRELINE on the table's left border, i.e.
7328
+ // straddling it half outside / half inside the table, restored in
7329
+ // v3.0.1 (spec §4.2 hit-test conflict 2 — see the geometry note above
7330
+ // buildTableGrip() for the S4 T1 detour into an inset grip and how it
7331
+ // was undone), vertically centred on its own row; no
7174
7332
  // header special case. The
7175
7333
  // one exception is a header-only table (no body rows): its single row
7176
7334
  // is thead's only row, and dragging it away would empty the thead —
@@ -7183,16 +7341,18 @@
7183
7341
  // Fallback height matches .ed-te-grip-row's own CSS height exactly
7184
7342
  // (28) — offsetHeight reads 0 while `hidden` (display: none) is still
7185
7343
  // 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.
7344
+ // `hidden = false` assignment below takes effect.
7194
7345
  const gh = rowGrip.offsetHeight || 28;
7195
- rowGrip.style.left = tableRect.left + 'px';
7346
+ // v3.0.1: back to STRADDLING the table's left border (the S4 T1 inset
7347
+ // was a workaround for the ⠿ hit-test overlap; the ⠿/+ pair has
7348
+ // since moved further left via a CSS shift token in lib/md2doc.js,
7349
+ // which removes the overlap at its source). The width is read from the
7350
+ // token rather than written as a literal here: offsetWidth reads 0
7351
+ // while `hidden` is still true on the first show of a hover session,
7352
+ // and a hard-coded fallback is exactly the CSS/JS drift the token
7353
+ // exists to prevent.
7354
+ const gw = rowGrip.offsetWidth || gripRowWidthPx();
7355
+ rowGrip.style.left = (tableRect.left - gw / 2) + 'px';
7196
7356
  rowGrip.style.top = (r.top + r.height / 2 - gh / 2) + 'px';
7197
7357
  rowGrip.hidden = false;
7198
7358
  } else {
@@ -29,10 +29,38 @@
29
29
  // nothing to do with text alignment. Reading them as alignment would be a
30
30
  // bug; they are deliberately never consulted below.
31
31
  //
32
- // ── Emission form (spec §4 / task-5 brief) ─────────────────────────────
33
- // '| a | b |' rows (single-space padding); '|---|' separator row — no
34
- // padding around the dashes, ':' alignment variants (':---' / '---:' /
35
- // ':---:'), dashes never stretched to content width; literal '|' inside
32
+ // ── Emission form (spec §4 / task-5 brief, amended by v3.0.1) ──────────
33
+ // There are TWO emission forms, and exactly one gate selects between them:
34
+ // whether serializeTable() was handed an `originalMd` it could turn into a
35
+ // usable width source (columnWidthsOf() parsed it, AND the greedy
36
+ // header-TEXT walk in serializeTable() matched EVERY original column, in
37
+ // order — the all-or-nothing gate). No second argument, an unparseable or
38
+ // ragged original, a renamed header, a dropped column: all of them land on
39
+ // form A.
40
+ //
41
+ // A. MINIMAL form (pre-v3.0.1, unchanged, and still what
42
+ // `serializeTable(tableEl)` with no second argument emits — byte for
43
+ // byte): '| a | b |' rows with single-space padding; a '|---|'
44
+ // separator row with no padding around the dashes, ':' alignment
45
+ // variants (':---' / '---:' / ':---:'), dashes never stretched to
46
+ // content width, always at least 3 of them.
47
+ //
48
+ // B. WIDTH-PRESERVING form (v3.0.1 欄寬保留): every row of a column,
49
+ // header and body alike, is padded out to that column's preserved
50
+ // width, and the separator's dash run IS stretched to the same width
51
+ // — with NO 3-dash floor, because a legal hand-aligned '|:-:|' must
52
+ // not be widened (see sepCellFor() for that floor's exact scope). A
53
+ // preserved width only exists for a column whose original fields were
54
+ // the same width on every row AND wider than 3; a matched column that
55
+ // fails that test still reuses its ORIGINAL separator field's width
56
+ // rather than collapsing to the minimal 3. On top of that, F1: when a
57
+ // matched column's original separator field already expresses the
58
+ // column's current alignment and is already exactly the target width,
59
+ // that field is re-emitted VERBATIM — padding and all — so the padded
60
+ // separators Prettier and VS Code emit ('| ----- | --- |') survive a
61
+ // no-op instead of being rebuilt flush ('|-------|-----|').
62
+ //
63
+ // Independent of the form: a literal '|' inside
36
64
  // a cell -> '&#124;'; a cell newline is the literal '<br>' inline-md.js's
37
65
  // serializeInline() already emits for both a real <br> node and a
38
66
  // contenteditable <div> line-split boundary — AND (final-review Finding 1
@@ -121,6 +149,116 @@
121
149
  return false;
122
150
  }
123
151
 
152
+ // ── v3.0.1 欄寬保留 ────────────────────────────────────────────────────
153
+ // A cell FIELD is the text between two '|' delimiters, PADDING INCLUDED:
154
+ // in '| a | b |' the fields are ' a ' and ' b '. A column only counts
155
+ // as a deliberately hand-aligned width source when its field is the SAME
156
+ // width on EVERY row of the original (header, separator, every body row)
157
+ // AND that width is greater than 3 — see columnWidthsOf() below for why
158
+ // both conditions are required (C2, 2026-09-02 review round 1: a uniform
159
+ // width of 3 is indistinguishable from this serializer's own minimal
160
+ // form, which is exactly 3 on every row by construction).
161
+ //
162
+ // A raw '|' never appears inside a cell in source THIS serializer produced
163
+ // (it emits '&#124;'), but a hand-written table may use the '\|' escape, so
164
+ // the split must not break on an escaped delimiter.
165
+ function splitFields(line) {
166
+ const t = line.trim();
167
+ if (t.charAt(0) !== '|' || t.charAt(t.length - 1) !== '|' || t.length < 2) return null;
168
+ const inner = t.slice(1, -1);
169
+ const out = [];
170
+ let cur = '';
171
+ for (let i = 0; i < inner.length; i++) {
172
+ const c = inner.charAt(i);
173
+ if (c === '\\' && inner.charAt(i + 1) === '|') { cur += '\\|'; i += 1; continue; }
174
+ if (c === '|') { out.push(cur); cur = ''; continue; }
175
+ cur += c;
176
+ }
177
+ out.push(cur);
178
+ return out;
179
+ }
180
+
181
+ // `{ widths, headerFields, sepFields }` describing the ORIGINAL table, or
182
+ // null when `originalMd` is absent or is not a table this can read. null
183
+ // means "no width source" and every caller below then emits minimal form
184
+ // — the pre-v3.0.1 behaviour, unchanged. `headerFields` (each original
185
+ // header field, trimmed) is what serializeTable() below uses to line a
186
+ // CURRENT column back up with the original column it corresponds to — see
187
+ // the comment at its call site for why index alone is not enough.
188
+ // `sepFields` (each original SEPARATOR field VERBATIM — padding, colons and
189
+ // all; always captured, independent of the `uniform`/`> 3` gate below)
190
+ // serves the separator emission site twice: its LENGTH is the fallback
191
+ // width for a matched column whose header/body rows aren't uniform (I1,
192
+ // 2026-09-02 review round 1), and the field ITSELF is re-emitted unchanged
193
+ // when nothing about that column's separator actually changed (F1,
194
+ // 2026-09-02 final review) — see sepFieldAlign() and the emission site.
195
+ //
196
+ // A column only counts as a width source when its field is the SAME width
197
+ // on EVERY row (header, separator, every body row) — that is what a
198
+ // deliberately hand-aligned column looks like — AND that width is > 3. A
199
+ // column whose original fields are NOT all equal was never hand-aligned to
200
+ // begin with: it's v3.0.0's un-padded minimal form, where each row's field
201
+ // is only as wide as that row's own content happens to be (e.g. header
202
+ // field 6, a body field 7, another body field 5 — three different
203
+ // numbers, no alignment to preserve). The `> 3` half of the gate catches
204
+ // the case that slips past the uniform check alone: a minimal-form table
205
+ // (single-char cells, say) reports the SAME width — exactly 3 — on every
206
+ // row, because ' a ' and '---' are both 3 characters; that is what THIS
207
+ // serializer's own minimal form always looks like, not evidence of a
208
+ // deliberate alignment (C2, review round 1). Without the `> 3` half, an
209
+ // ordinary minimal-form table would misread itself as "already aligned"
210
+ // and start re-widening on every edit — the header, the separator, AND
211
+ // every untouched row — even though v3.0.0 left all three byte-identical.
212
+ // Reporting `undefined` for such a column — rather than the width seen —
213
+ // means every caller's `widths[i] || 0` / `row[i] === undefined` checks
214
+ // fall through to independent per-row minimal form for it, exactly as if
215
+ // the column had no original at all.
216
+ function columnWidthsOf(originalMd) {
217
+ if (typeof originalMd !== 'string' || originalMd === '') return null;
218
+ const rows = originalMd.split('\n').filter((l) => l.trim() !== '');
219
+ if (rows.length < 2) return null;
220
+ const first = splitFields(rows[0]);
221
+ if (!first) return null;
222
+ const widths = first.map((f) => f.length);
223
+ const uniform = widths.map(() => true);
224
+ let sepFields = null;
225
+ for (let r = 1; r < rows.length; r++) {
226
+ const fields = splitFields(rows[r]);
227
+ // A ragged original is not a width source — bail rather than guess.
228
+ if (!fields || fields.length !== widths.length) return null;
229
+ if (r === 1) sepFields = fields.slice();
230
+ for (let c = 0; c < fields.length; c++) {
231
+ if (fields[c].length !== widths[c]) uniform[c] = false;
232
+ }
233
+ }
234
+ for (let c = 0; c < widths.length; c++) {
235
+ if (!uniform[c] || widths[c] <= 3) widths[c] = undefined;
236
+ }
237
+ return { widths, headerFields: first.map((f) => f.trim()), sepFields };
238
+ }
239
+
240
+ // ' content ' right-padded out to `width`. NEVER truncates: a cell whose
241
+ // content grew past the original column simply widens that column, and a
242
+ // deliberately blank hand-aligned cell (e.g. `| |` kept wide on
243
+ // purpose) is reproduced at its full original width, never shrunk down to
244
+ // its own natural 2 chars — padding is a MINIMUM, not a maximum;
245
+ // truncating here would delete the user's alignment (or their text).
246
+ //
247
+ // (2026-09-02 review round 1, C2: an earlier version special-cased an
248
+ // EMPTY cell to always emit the natural 2-char field, to stop a freshly
249
+ // hover-inserted row/column from being padded out to its column's width.
250
+ // That was a misdiagnosis — the actual cause was columnWidthsOf() treating
251
+ // a uniform width of 3 (== this serializer's own minimal form) as a
252
+ // hand-aligned width source, so an ordinary just-inserted minimal-form
253
+ // column looked "aligned" and got synced. Fixing that at the source (the
254
+ // `> 3` gate above) made the empty-cell special case both unnecessary AND
255
+ // actively wrong — it silently discarded a genuinely blank, intentionally
256
+ // padded cell's width in an aligned table. Removed.)
257
+ function padField(content, width) {
258
+ const field = ' ' + content + ' ';
259
+ return field.length >= width ? field : field + ' '.repeat(width - field.length);
260
+ }
261
+
124
262
  function cellAlign(cell) {
125
263
  const style = cell.getAttribute('style');
126
264
  if (!style) return null;
@@ -128,24 +266,65 @@
128
266
  return m ? m[1] : null;
129
267
  }
130
268
 
131
- function sepCellFor(align) {
132
- if (align === 'left') return ':---';
133
- if (align === 'right') return '---:';
134
- if (align === 'center') return ':---:';
135
- return '---';
269
+ // The alignment an ORIGINAL separator field already expresses, in the same
270
+ // vocabulary cellAlign() above returns ('left' / 'right' / 'center' /
271
+ // null), so the two can be compared directly at the emission site.
272
+ // Returns `undefined` — which cellAlign() can never return — for anything
273
+ // that is not a bare separator field, so a field this can't fully account
274
+ // for can never be re-emitted verbatim. F1 (2026-09-02 final review).
275
+ function sepFieldAlign(field) {
276
+ const t = field.trim();
277
+ if (!/^:?-+:?$/.test(t)) return undefined;
278
+ const l = t.charAt(0) === ':';
279
+ const r = t.charAt(t.length - 1) === ':';
280
+ if (l && r) return 'center';
281
+ if (l) return 'left';
282
+ if (r) return 'right';
283
+ return null;
284
+ }
285
+
286
+ // The dash run is stretched to `width`, minus whatever the colons consume.
287
+ // The 3-dash floor belongs ONLY to the no-width path (`w === 0`): that is
288
+ // GFM's own minimum, and it's also exactly the minimal form ('---' /
289
+ // ':---' / '---:' / ':---:') a width of 0 produces — what makes "no
290
+ // original" and "minimal-form original" the same answer. A KNOWN width
291
+ // (`w > 0`) must NOT be floored up to 3: a narrow hand-aligned separator
292
+ // like '|:--:|' (width 4) or '|:-:|' (width 3) is legal GFM (marked lexes
293
+ // it as one table token with `align === 'center'`), and flooring it to
294
+ // '|:---:|' rewrites a line the user never touched — C1 (2026-09-02
295
+ // review round 1): every center-aligned column narrower than 5, and every
296
+ // left/right-aligned column narrower than 4, silently widened on an
297
+ // otherwise-unchanged table, which made commitEdit() write the file and
298
+ // push an undo entry on a plain click-in/click-away. Floor of 1 instead:
299
+ // a dash run can't go negative once the colon(s) are subtracted, but it's
300
+ // otherwise free to reproduce whatever width the original actually had.
301
+ function sepCellFor(align, width) {
302
+ const w = typeof width === 'number' ? width : 0;
303
+ const floor = w > 0 ? 1 : 3;
304
+ if (align === 'left') return ':' + '-'.repeat(Math.max(floor, w - 1));
305
+ if (align === 'right') return '-'.repeat(Math.max(floor, w - 1)) + ':';
306
+ if (align === 'center') return ':' + '-'.repeat(Math.max(floor, w - 2)) + ':';
307
+ return '-'.repeat(Math.max(floor, w));
308
+ }
309
+
310
+ // The cell's markdown content, WITHOUT padding. Rendered exactly once per
311
+ // cell (never twice — inlineMd.serializeInline() would double-report
312
+ // `unsupported` for the same cell), so width measurement and emission
313
+ // below both read from these same strings.
314
+ function cellContentOf(cell, unsupported) {
315
+ if (cellHasCodePipe(cell)) unsupported.push('CODE');
316
+ const res = inlineMd.serializeInline(cell);
317
+ res.unsupported.forEach((u) => unsupported.push(u));
318
+ return escapeNewlines(escapePipes(res.md));
136
319
  }
137
320
 
138
- function serializeRow(cells, unsupported) {
139
- const parts = cells.map((cell) => {
140
- if (cellHasCodePipe(cell)) unsupported.push('CODE');
141
- const res = inlineMd.serializeInline(cell);
142
- res.unsupported.forEach((u) => unsupported.push(u));
143
- return escapeNewlines(escapePipes(res.md));
144
- });
145
- return '| ' + parts.join(' | ') + ' |';
321
+ function serializeRow(contents, widths) {
322
+ const parts = contents.map((content, i) =>
323
+ padField(content, widths ? (widths[i] || 0) : 0));
324
+ return '|' + parts.join('|') + '|';
146
325
  }
147
326
 
148
- function serializeTable(tableEl) {
327
+ function serializeTable(tableEl, originalMd) {
149
328
  const unsupported = [];
150
329
  const thead = firstChildNamed(tableEl, 'THEAD');
151
330
  const tbody = firstChildNamed(tableEl, 'TBODY');
@@ -165,14 +344,190 @@
165
344
  return { md: '', unsupported: ['TABLE_RAGGED'] };
166
345
  }
167
346
 
347
+ // Render every cell's content up front (same order the pre-v3.0.1 code
348
+ // walked: header first, then each body row left-to-right), so the width
349
+ // pass below and the emission pass after it share one rendering.
350
+ const headerContents = headerCells.map((c) => cellContentOf(c, unsupported));
351
+ const bodyContentRows = bodyRows.map((tr) =>
352
+ elementChildren(tr).filter(isCell).map((c) => cellContentOf(c, unsupported)));
353
+
354
+ // Read the ORIGINAL widths first, then line each CURRENT header column
355
+ // up with the original column it corresponds to BY TEXT, not by raw
356
+ // index. A column inserted in the MIDDLE (insertColumn() in client.js)
357
+ // shifts every later column's index by one without changing what that
358
+ // column actually is — matching by index alone would hand the shifted
359
+ // column a stale neighbour's width, and the freshly-inserted empty
360
+ // column would inherit whatever width used to sit at its new index (a
361
+ // plain-index version of this walk is byte-identical to this one for a
362
+ // trailing/mid-table INSERT — padField()'s natural fallback already
363
+ // covers the inserted column either way — but it is NOT for a column
364
+ // MOVE: test/editor-client-runtime.test.js:7876-7927 drags "Detail"
365
+ // from the last position to the first and requires ALL THREE widths
366
+ // dropped even though "Name" and "Note" still sit at index-shifted but
367
+ // TEXT-unchanged positions; only text-matching, combined with the
368
+ // all-or-nothing gate below, gets that right). A mismatch that is
369
+ // simply a RENAMED header (or a dragged ROW promoting a different row
370
+ // to "header" — the per-column-alignment-survives-header-change
371
+ // scenario) is walked the exact same way as any other mismatch: it
372
+ // doesn't advance `j`, so — per the all-or-nothing gate below — it
373
+ // discards the width source for the WHOLE table, not just that one
374
+ // column. That reflow-on-rename is TODAY'S shipped, pre-existing
375
+ // behaviour (v3.0.0 already reflows the whole table on any edit; a
376
+ // rename doing the same is not a regression) — deliberately NOT
377
+ // "fixed" here per the 2026-09-02 review round-1 ruling: detecting
378
+ // rename-vs-permutation is design work that doesn't belong in a patch
379
+ // release. See test/table-width.test.js's "renamed header" case, which
380
+ // pins this so the next change to it is deliberate.
381
+ const original = columnWidthsOf(originalMd);
382
+ let widths = null;
383
+ // Set for a column that falls off the END of the original header list
384
+ // (every original column has already been matched by the time this one
385
+ // is reached) — a genuinely NEW, appended column, e.g. test case 6's
386
+ // 'City'. Its separator still tracks its OWN header's natural width
387
+ // below. This exists to reconcile the brief's OWN case 6: the brief's
388
+ // Step 5 code unconditionally used `widths[i] || 0` for the separator
389
+ // (which is 0, i.e. the bare 3-dash minimal, for any unmatched column),
390
+ // but the brief's own expected string for case 6 has 'City' getting a
391
+ // 6-dash separator — 'City''s own rendered width, not 3 dashes. No
392
+ // pre-existing test forces this; it exists purely to make the brief's
393
+ // Step 5 code and the brief's own case 6 expectation agree with each
394
+ // other. Any OTHER mismatch (a renamed header, or a row-drag promotion)
395
+ // gets no special separator treatment: it falls all the way back to the
396
+ // 3-dash minimal, same as `widths[i]` itself already falls back to
397
+ // natural per-row padding for it — moot in practice, since the
398
+ // all-or-nothing gate below discards `widths` entirely for those cases
399
+ // anyway, so this array is never even consulted for them.
400
+ const sepFallbackNatural = new Array(headerContents.length).fill(false);
401
+ // The ORIGINAL separator row's own field, VERBATIM, for each MATCHED
402
+ // column. Used two ways at the emission site below:
403
+ //
404
+ // • its LENGTH is the fallback width for a matched column whose
405
+ // header/body rows are not themselves uniform (`original.widths[j]`
406
+ // is `undefined` there — see columnWidthsOf()). I1 (review round 1):
407
+ // without this, such a column's header/body rows fell back to their
408
+ // own natural content width (which happens to reproduce the original
409
+ // bytes when nothing changed), but the separator fell back to the
410
+ // bare 3-dash minimal — rewriting a separator line the user never
411
+ // touched the moment ANY other cell in the table was edited.
412
+ // • the FIELD ITSELF is re-emitted unchanged when the column's
413
+ // separator did not actually change (F1, final review) — see the
414
+ // verbatim gate at the emission site.
415
+ const matchedSepFields = new Array(headerContents.length);
416
+ if (original) {
417
+ const matched = new Array(headerContents.length);
418
+ let j = 0;
419
+ for (let i = 0; i < headerContents.length; i++) {
420
+ if (j < original.headerFields.length &&
421
+ headerContents[i].trim() === original.headerFields[j].trim()) {
422
+ matched[i] = original.widths[j];
423
+ matchedSepFields[i] = original.sepFields[j];
424
+ j++;
425
+ } else {
426
+ matched[i] = undefined;
427
+ if (j >= original.headerFields.length) sepFallbackNatural[i] = true;
428
+ }
429
+ }
430
+ // Only trust this walk as a width source AT ALL if it found EVERY
431
+ // original column, in order, somewhere in the current header row (j
432
+ // reached the end). A PARTIAL match — some columns matched, some
433
+ // didn't — means the table's column identity changed in a way this
434
+ // can't safely map: a column DRAG that reorders existing columns
435
+ // (rather than just adding one) still finds two of three headers
436
+ // byte-identical to their originals via this same greedy walk, but
437
+ // "a column move must carry its alignment with it" requires ALL
438
+ // THREE widths dropped, not just the moved one — a per-column guess
439
+ // here would silently keep stale padding on a column that only
440
+ // happens to still have the same text. So a partial match discards
441
+ // the width source for the WHOLE table, same as no original at all.
442
+ if (j === original.headerFields.length) {
443
+ widths = matched;
444
+ // A matched column is then widened, for every row of that column
445
+ // at once (header + separator + every body row), to whatever the
446
+ // grown content now needs — the padding floor rises but never
447
+ // falls, and never truncates (test case 5): "a cell whose content
448
+ // grew past the original column widens that COLUMN", not just its
449
+ // own line. An unmatched column's width stays `undefined` here —
450
+ // comparing a number against `undefined` is always false, so this
451
+ // loop leaves it alone and padField()'s `|| 0` fallback below
452
+ // gives it independent per-row minimal form instead.
453
+ const allRows = [headerContents].concat(bodyContentRows);
454
+ for (let i = 0; i < widths.length; i++) {
455
+ let needed = widths[i];
456
+ allRows.forEach((row) => {
457
+ if (row[i] === undefined) return;
458
+ const natural = row[i].length + 2; // 1-space padding, both sides
459
+ if (natural > needed) needed = natural;
460
+ });
461
+ widths[i] = needed;
462
+ }
463
+ }
464
+ }
465
+
168
466
  const lines = [];
169
- lines.push(serializeRow(headerCells, unsupported));
170
- lines.push('|' + headerCells.map((c) => sepCellFor(cellAlign(c))).join('|') + '|');
467
+ lines.push(serializeRow(headerContents, widths));
468
+ lines.push('|' + headerCells.map((c, i) => {
469
+ // Preference order for a column's separator width:
470
+ // 1. widths[i] — this column is a full hand-aligned width source
471
+ // (matched, uniform, and > 3). Same width the header/body rows
472
+ // already use.
473
+ // 2. matchedSepFields[i].length — this column WAS matched to an
474
+ // original column, just not a uniform-width one (I1: reproduces
475
+ // the ORIGINAL separator's own field width instead of collapsing
476
+ // to the bare 3-dash minimal on an edit elsewhere in the table).
477
+ // 3. headerContents[i].length + 2, but ONLY for a column that fell
478
+ // off the END of the original header list (sepFallbackNatural —
479
+ // see above): a genuinely new, appended column still gets a
480
+ // separator matching its own header's natural width (case 6:
481
+ // 'City' has no original width, but its separator is 6 dashes),
482
+ // unlike the header/body rows above, whose padField() `|| 0`
483
+ // deliberately leaves such a column unsynced.
484
+ // 4. 0 (the plain 3-dash minimal) — no original table at all
485
+ // (`widths` itself is null: cases 1 and 7 stay byte-identical to
486
+ // the pre-v3.0.1 minimal form), or a mismatch that is neither a
487
+ // matched-but-non-uniform column nor a trailing new one (a
488
+ // renamed header, or a row-drag promotion) — moot in practice,
489
+ // since the all-or-nothing gate above already discarded
490
+ // `widths` entirely for those cases.
491
+ const origField = matchedSepFields[i];
492
+ const w = widths
493
+ ? (widths[i] !== undefined ? widths[i]
494
+ : origField !== undefined ? origField.length
495
+ : (sepFallbackNatural[i] ? headerContents[i].length + 2 : 0))
496
+ : 0;
497
+ const align = cellAlign(c);
498
+ // F1 (2026-09-02 final review, Critical): once the width is settled,
499
+ // prefer the ORIGINAL field's own BYTES over rebuilding them. Every
500
+ // preference above only ever agreed on the WIDTH of the separator
501
+ // field; sepCellFor() then rebuilt its contents as a dash run flush
502
+ // against both delimiters. For the padded separator row Prettier,
503
+ // prettier-plugin-markdown and VS Code's table formatter all emit —
504
+ // '| ----- | --- |', the most common machine-aligned markdown table
505
+ // in the wild — that is the same width but different bytes, so a pure
506
+ // no-op (click into the table, click away) came back as
507
+ // '|-------|-----|'. commitRangeEdit() short-circuits only on a
508
+ // byte-identical result, so that was a REAL op: an undo entry pushed,
509
+ // `lines` replaced, and the next save writing a line the user never
510
+ // touched.
511
+ //
512
+ // Re-emit the original field verbatim only when BOTH hold, so this can
513
+ // never smuggle a stale separator past a genuine change:
514
+ // (a) the field already expresses the alignment this column now has
515
+ // (sepFieldAlign() returns `undefined` for anything that isn't a
516
+ // plain separator field, which cellAlign() can never equal, so an
517
+ // unparseable field always falls through); and
518
+ // (b) the target width `w` is exactly that field's own length — any
519
+ // width GROWTH (a cell that outgrew its column, cases 5 and 12)
520
+ // lands here with w > origField.length and falls through.
521
+ // Everything that falls through takes today's sepCellFor() path,
522
+ // unchanged.
523
+ if (widths && origField !== undefined &&
524
+ origField.length === w && sepFieldAlign(origField) === align) {
525
+ return origField;
526
+ }
527
+ return sepCellFor(align, w);
528
+ }).join('|') + '|');
171
529
 
172
- bodyRows.forEach((tr) => {
173
- const cells = elementChildren(tr).filter(isCell);
174
- lines.push(serializeRow(cells, unsupported));
175
- });
530
+ bodyContentRows.forEach((contents) => lines.push(serializeRow(contents, widths)));
176
531
 
177
532
  return { md: lines.join('\n'), unsupported };
178
533
  }
package/lib/md2doc.js CHANGED
@@ -1086,21 +1086,29 @@ ${itemsHtml}
1086
1086
  // [contentLeft-54, contentLeft-18] until spec §4.2's own numbers were
1087
1087
  // restored), clear of .sidebar-splitter — see the .content rule below.
1088
1088
  //
1089
- // GEOMETRY, AS MEASURED (S4 T1; the previous version of this comment
1090
- // claimed "an 8px gap" between the gutter and the table row grip, a number
1091
- // left over from the v2.11.0 pair position that v2.11.1 invalidated — the
1092
- // real gap was MINUS 6px, i.e. an overlap):
1089
+ // GEOMETRY, AS MEASURED (v2.12.0/S4 T1; the previous version of this
1090
+ // comment claimed "an 8px gap" between the gutter and the table row grip, a
1091
+ // number left over from the v2.11.0 pair position that v2.11.1
1092
+ // invalidated — the real gap was MINUS 6px, i.e. an overlap):
1093
1093
  // ⠿ [contentLeft-22, contentLeft-4] (--ed-gutter-btn wide)
1094
- // row grip [contentLeft, contentLeft+20] (--ed-te-grip-row-w wide)
1095
- // The grip used to be centred on the table's left border at
1096
- // [contentLeft-10, contentLeft+10], which overlapped the ⠿'s right 6px and
1097
- // — being position: fixed, z-index 7, on document.body — won the hit test
1098
- // there. spec §4.2 conflict 2's ruling (grip entirely inside the table)
1099
- // is landed in lib/editor/client.js's updateTableEdgeGrips(); the
1094
+ // row grip [contentLeft-10, contentLeft+10] (--ed-te-grip-row-w wide,
1095
+ // centred on the table's left border)
1096
+ // The grip straddling the table's left border overlapped the ⠿'s right 6px
1097
+ // and — being position: fixed, z-index 7, on document.body — won the hit
1098
+ // test there. v2.12.0/S4 T1's fix moved the grip entirely inside the table
1099
+ // instead (its left edge on the border), which fixed the overlap but
1100
+ // broke the row/column symmetry the user expected (the column grip still
1101
+ // straddles the table's TOP border) — reported as a v3.0.1 regression.
1102
+ // v3.0.1 fixes it at the source instead: the ⠿/+ pair itself moves
1103
+ // further LEFT by --ed-gutter-shift below, which removes the overlap
1104
+ // without moving the grip at all, so the grip can straddle the border
1105
+ // again (see updateTableEdgeGrips() in lib/editor/client.js). The
1100
1106
  // clearance that remains is exactly --ed-gutter-gap, because the ⠿'s right
1101
- // edge and the grip's left edge are both derived from it. The grip is kept
1102
- // off the first cell's TEXT by --ed-te-cell-pad-left below, not by the
1103
- // 14px default padding, which is narrower than the grip.
1107
+ // edge and the grip's left edge are both derived from it. The grip's
1108
+ // straddling half (10px) sits well inside the first cell's default 14px
1109
+ // left padding, so no extra edit-mode padding is needed any more — the
1110
+ // v2.10.1-era --ed-te-cell-pad-left token this comment used to describe is
1111
+ // gone.
1104
1112
  // (The padding was 48px until §4.2's hit-test conflict 1 was fixed; see the
1105
1113
  // .content rule in editModeLayoutCss below for why it is 56px now.)
1106
1114
  // S1 Task 5: the ordered-list ordinal is drawn by a CSS counter, and the
@@ -1156,12 +1164,14 @@ ${itemsHtml}
1156
1164
  further in, to spec §4.2's own [contentLeft-40, contentLeft-4] — the
1157
1165
  clearance is 16px at this padding rather than 2px. The padding stays at
1158
1166
  56px: nothing asks for the text column to move, and this measurement is
1159
- the one the §4.2 conflict-1 guard is written against.) The table row grip no
1160
- longer reaches this far left at all: §4.2 conflict 2 moved it inside the
1161
- table to [contentLeft, contentLeft+20] (S4 T1), so nothing but the gutter
1162
- pair itself occupies [contentLeft-40, contentLeft-4]. Guarded by the
1163
- elementFromPoint(splitter.right - 2, y) assertion §4.2 asks for, in
1164
- test/editor-client-runtime.test.js. */
1167
+ the one the §4.2 conflict-1 guard is written against.) v3.0.1 shifts the
1168
+ pair a further --ed-gutter-shift (10px) left, to [contentLeft-50,
1169
+ contentLeft-14], so the table row grip can straddle the table's own left
1170
+ border again instead of sitting inside it — see the --ed-gutter-shift
1171
+ comment above .ed-handle below. That still leaves 6px of clearance from
1172
+ splitterRight (contentLeft-56), so the conflict-1 guard still holds.
1173
+ Guarded by the elementFromPoint(splitter.right - 2, y) assertion §4.2
1174
+ asks for, in test/editor-client-runtime.test.js. */
1165
1175
  .content { padding-left: 56px; }
1166
1176
 
1167
1177
  /* S1: every block is one full-width row, whatever its type or depth. The
@@ -1231,38 +1241,41 @@ ${itemsHtml}
1231
1241
  [contentLeft-54, contentLeft-18], which with 56px of padding sits wholly
1232
1242
  inside the content column (2px clear of .sidebar-splitter's right edge —
1233
1243
  see the padding rule's own comment for the measurement), so + never
1234
- reaches a negative viewport x and never covers the splitter. The table
1235
- row grip is at [contentLeft, contentLeft+20] since §4.2 conflict 2
1236
- landed (S4 T1), so the clearance from the ⠿'s right edge is exactly
1237
- --ed-gutter-gap and cannot drift: both edges are derived from it. */
1244
+ reaches a negative viewport x and never covers the splitter. As of
1245
+ v3.0.1 the table row grip straddles the table's left border again
1246
+ (centred on it, [tableLeft-10, tableLeft+10] — see updateTableEdgeGrips()
1247
+ in lib/editor/client.js), and the pair itself has moved left by
1248
+ --ed-gutter-shift below so the clearance from the ⠿'s right edge is
1249
+ still exactly --ed-gutter-gap and cannot drift: both edges are derived
1250
+ from it. */
1238
1251
  /* Stated ONCE, as tokens, because §4.2's three numbers are not independent:
1239
1252
  the gutter is exactly two buttons wide plus the deliberate right-hand
1240
- breathing gap, and the hover zone below is exactly the gutter. Writing any
1241
- of them as a second literal is how the corridor comes back. */
1253
+ breathing gap, and the hover zone below is exactly the gutter plus
1254
+ whatever --ed-gutter-shift moved the pair by. Writing any of them as a
1255
+ second literal is how the corridor comes back. */
1242
1256
  :root {
1243
1257
  --ed-gutter-btn: 18px;
1244
1258
  --ed-gutter-gap: 4px;
1245
1259
  --ed-gutter-w: calc(var(--ed-gutter-btn) * 2 + var(--ed-gutter-gap));
1246
- /* spec §4.2 hit-test conflict 2, landed S4 T1. The table row grip now
1247
- sits ENTIRELY inside the table (its LEFT edge on the table's left
1248
- border — see updateTableEdgeGrips() in lib/editor/client.js), which
1249
- leaves the ⠿'s right edge and the grip's left edge exactly
1250
- --ed-gutter-gap apart: the 4px breathing gap §4.2 already keeps on the
1251
- right of the gutter pair IS the separation. Nothing new is written
1252
- down, so nothing new can drift.
1253
- The consequence is this token: the grip is 20px wide and the first
1254
- column's default padding is 14px, so an inset grip would sit on that
1255
- cell's TEXT — the v2.10.1 user-acceptance defect that got the first
1256
- attempt at this ruling reverted. The first column is padded past the
1257
- grip instead, by the grip's own width plus that same gap. */
1258
- --ed-te-cell-pad-left: calc(var(--ed-te-grip-row-w) + var(--ed-gutter-gap));
1259
1260
  }
1261
+ /* v3.0.1: the pair moves LEFT by --ed-gutter-shift so the table row grip
1262
+ can straddle the table's left border again (see updateTableEdgeGrips()
1263
+ in lib/editor/client.js) while still keeping --ed-gutter-gap between the
1264
+ ⠿'s right edge and the grip's left edge. Written as a token because the
1265
+ two rules below and the acceptance test all derive from it. Measured at
1266
+ 1400x900 (README.md, first table): pre-shift ⠿=[390,408] +=[372,390]
1267
+ tableLeft=412; a 10px shift lands ⠿=[380,398] +=[362,380], grip
1268
+ (straddling, 20px wide, centred on tableLeft) = [402,422], leaving
1269
+ exactly --ed-gutter-gap (4px) between the ⠿'s right edge (398) and the
1270
+ grip's left edge (402), and +'s left edge (362) stays well clear of 0. */
1271
+ :root { --ed-gutter-shift: 10px; }
1260
1272
  .ed-handle {
1261
- left: calc(-1 * (var(--ed-gutter-btn) + var(--ed-gutter-gap))); top: 0;
1273
+ left: calc(-1 * (var(--ed-gutter-btn) + var(--ed-gutter-gap) + var(--ed-gutter-shift)));
1274
+ top: 0;
1262
1275
  width: var(--ed-gutter-btn);
1263
1276
  }
1264
1277
  .ed-insert {
1265
- left: calc(-1 * var(--ed-gutter-w)); top: 0;
1278
+ left: calc(-1 * (var(--ed-gutter-w) + var(--ed-gutter-shift))); top: 0;
1266
1279
  width: var(--ed-gutter-btn);
1267
1280
  }
1268
1281
  /* v2.11.1: the gutter's HOVER ZONE, and it is the reason the pair could move
@@ -1291,9 +1304,17 @@ ${itemsHtml}
1291
1304
 
1292
1305
  One absolutely-positioned pseudo-element spanning the whole gutter for the
1293
1306
  block's whole height makes :hover continuously true from the text out
1294
- past the +, at every Y. Its width is the --ed-gutter-w token the pair's
1295
- own offsets are built from, so the two cannot drift apart — if they do,
1296
- the corridor comes straight back.
1307
+ past the +, at every Y. Its span is derived from the SAME two tokens the
1308
+ pair's own offsets are: --ed-gutter-w (the pair's own width, the gutter
1309
+ proper) plus --ed-gutter-shift (how far v3.0.1 moved the pair left to
1310
+ clear the straddling table row grip). Both edges therefore track the
1311
+ pair automatically — the zone's left edge is exactly the +'s left edge
1312
+ and its right edge is the block's, with no literal repeated anywhere —
1313
+ so the two cannot drift apart. If they ever do, the corridor comes
1314
+ straight back: v3.0.1 shifted the pair to [blockLeft-50, blockLeft-14]
1315
+ while this rule still read the un-shifted --ed-gutter-w alone, leaving
1316
+ it spanning [blockLeft-40, blockLeft] — 10px short on the left of the
1317
+ very buttons it exists to keep reachable.
1297
1318
  .ed-block is already position:relative, and position: absolute keeps
1298
1319
  this out of the li row's flex flow. It is deliberately NOT
1299
1320
  pointer-events: none: that would stop it being hit-tested, which is the
@@ -1305,18 +1326,8 @@ ${itemsHtml}
1305
1326
  literal.) */
1306
1327
  .ed-block::before {
1307
1328
  content: ""; position: absolute;
1308
- left: calc(-1 * var(--ed-gutter-w)); top: 0;
1309
- width: var(--ed-gutter-w); height: 100%;
1310
- }
1311
-
1312
- /* §4.2 conflict 2's other half: the row grip lives inside the first column
1313
- now, so that column's cells must be padded past it. EDIT MODE ONLY —
1314
- reader HTML and PDF output keep the shared 7px 14px and stay
1315
- byte-identical. Only the LEFT padding changes, and only on the first
1316
- cell of each row; every other cell is untouched. */
1317
- .ed-block[data-block-type="table"] th:first-child,
1318
- .ed-block[data-block-type="table"] td:first-child {
1319
- padding-left: var(--ed-te-cell-pad-left);
1329
+ left: calc(-1 * (var(--ed-gutter-w) + var(--ed-gutter-shift))); top: 0;
1330
+ width: calc(var(--ed-gutter-w) + var(--ed-gutter-shift)); height: 100%;
1320
1331
  }`;
1321
1332
 
1322
1333
  const html = `<!DOCTYPE html>
@@ -2196,7 +2207,11 @@ ${itemsHtml}
2196
2207
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
2197
2208
  }
2198
2209
  .ed-handle-menu-btn {
2199
- min-width: 132px; height: 26px; padding: 0 10px;
2210
+ /* v3.0.1: the icon sits FLUSH LEFT and the label sits one space-width
2211
+ after it (user request: 「符號靠左對齊,文字跟符號間距一個空格」).
2212
+ The right padding is untouched — it is what keeps the 轉換成 row's
2213
+ chevron off the panel edge. */
2214
+ min-width: 132px; height: 26px; padding: 0 10px 0 6px;
2200
2215
  border: 1px solid rgba(255, 255, 255, 0.25); border-radius: 6px;
2201
2216
  background: rgba(255, 255, 255, 0.08); color: inherit;
2202
2217
  font: inherit; font-size: 12px; line-height: 1;
@@ -2207,8 +2222,10 @@ ${itemsHtml}
2207
2222
  width, so S2's one-left-edge / monotonic-top invariants are untouched.
2208
2223
  The submenu's own buttons carry this class and no icon; a lone text node
2209
2224
  is an anonymous flex item at flex-start, i.e. exactly where text-align
2210
- put it. */
2211
- display: flex; align-items: center; gap: 8px;
2225
+ put it.
2226
+ v3.0.1: gap dropped from 8px to 4px — one space-width at this 12px
2227
+ font size — now that every row (both menu levels) carries an icon. */
2228
+ display: flex; align-items: center; gap: 4px;
2212
2229
  }
2213
2230
  /* Notion's own sizing. The stroke colour is NOT set here on purpose: the
2214
2231
  markup carries stroke="currentColor" (lib/editor/client.js), so the icon
@@ -2216,8 +2233,20 @@ ${itemsHtml}
2216
2233
  retheme for free. A hex here would break that silently. (No backticks in
2217
2234
  this comment: it lives inside a JS template literal.)
2218
2235
  flex: 0 0 auto because a 16px-wide flex item next to a nowrap label
2219
- would otherwise be the thing that shrinks. */
2220
- .ed-menu-icon { flex: 0 0 auto; width: 16px; height: 16px; }
2236
+ would otherwise be the thing that shrinks.
2237
+ v3.0.1: the selector is ".ed-handle-menu-btn .ed-menu-icon", not the
2238
+ bare class — the gutter menu is appended INTO a ".ed-block"
2239
+ (lib/editor/client.js's blockEl.appendChild(gutterMenu)), which is a
2240
+ descendant of <main class="content">, and ".content svg" above
2241
+ carries "margin: 0 auto" at specificity (0,1,1) (one class, one
2242
+ type). The bare ".ed-menu-icon" is only (0,1,0) and LOSES that fight
2243
+ regardless of source order, so every icon picked up centering
2244
+ auto-margins instead of sitting flush left — measured on this branch
2245
+ as an icon offset that swung with label length (11px-40px) instead
2246
+ of a constant one. Scoping under ".ed-handle-menu-btn" raises this
2247
+ to (0,2,0), which wins on class count alone. (No backticks in this
2248
+ comment: it lives inside a JS template literal.) */
2249
+ .ed-handle-menu-btn .ed-menu-icon { flex: 0 0 auto; width: 16px; height: 16px; margin: 0; }
2221
2250
  /* The 轉換成 submenu carries BOTH classes, so it inherits every rule above
2222
2251
  and only overrides the horizontal offset. It is a CHILD of the menu, and
2223
2252
  the menu is itself position:absolute, so the menu's own padding box is
@@ -2361,18 +2390,23 @@ ${itemsHtml}
2361
2390
  affordance) with two real, adequately-sized (>=18x24px) click/drag
2362
2391
  targets. '.ed-te-grip-row' is a vertical 6-dot handle shown at the LEFT
2363
2392
  EDGE of the hovered row -- every row, the HEADER included (spec 3.10:
2364
- the header is draggable too), sitting ENTIRELY INSIDE the table with its
2365
- LEFT edge on the table's left border (spec 4.2 hit-test conflict 2,
2366
- landed in S4 T1). The two axes are therefore NOT mirror images any more:
2367
- '.ed-te-grip-col' still straddles the table's TOP border, because nothing
2368
- of the page's own chrome lives up there. The row axis cannot: MEASURED on
2369
- v2.12.0 at 1400x900, the straddling grip occupied [tableLeft-10,
2370
- tableLeft+10] while the block's own '.ed-handle' occupied [blockLeft-22,
2371
- blockLeft-4] with tableLeft == blockLeft, so the grip (position: fixed,
2372
- z-index 7, on document.body) won the hit test for the RIGHT 6px of every
2373
- ⠿ whose Y band it reached. The first column's own padding is widened in
2374
- the edit-mode-only layout chunk near the end of this stylesheet so the
2375
- inset grip still never reaches cell TEXT; '.ed-te-grip-col' is a horizontal 6-dot
2393
+ the header is draggable too), STRADDLING the table's left border,
2394
+ centred on it (spec 4.2 hit-test conflict 2). v2.12.0/S4 T1 tried moving
2395
+ it entirely inside the table instead (left edge on the border) to stop it
2396
+ winning the hit test against the block's own ⠿ — MEASURED at 1400x900,
2397
+ the straddling grip occupied [tableLeft-10, tableLeft+10] while
2398
+ '.ed-handle' occupied [blockLeft-22, blockLeft-4] with tableLeft ==
2399
+ blockLeft, so the grip (position: fixed, z-index 7, on document.body)
2400
+ won the hit test for the RIGHT 6px of every ⠿ whose Y band it reached —
2401
+ but that broke the row/column symmetry the user expected ('.ed-te-grip-col'
2402
+ still straddles the table's TOP border, because nothing of the page's
2403
+ own chrome lives up there), and was reported as a regression. v3.0.1
2404
+ restores the straddle and instead moves the ⠿/+ pair itself further left
2405
+ by --ed-gutter-shift, which removes the overlap at its source (see the
2406
+ edit-mode-only layout chunk near the end of this stylesheet); the row
2407
+ grip's straddling half sits well inside the first cell's default 14px
2408
+ padding, so no extra padding token is needed either.
2409
+ '.ed-te-grip-col' is a horizontal 6-dot
2376
2410
  handle shown just ABOVE the hovered column (every column). Dots are plain <span>s laid out via CSS grid with
2377
2411
  place-content: center, so the dot cluster stays compact/centered
2378
2412
  regardless of the button's own (larger, hit-target-sized) box — no
@@ -2391,12 +2425,12 @@ ${itemsHtml}
2391
2425
  .ed-te-grip:hover { background: rgba(0, 0, 0, 0.06); }
2392
2426
  .ed-te-grip-dot { width: 3px; height: 3px; border-radius: 50%; background: #9ca3af; }
2393
2427
  .ed-te-grip:hover .ed-te-grip-dot { background: #6b7280; }
2394
- /* spec §4.2 hit-test conflict 2 (S4 T1): the row grip's width is a TOKEN
2395
- because two other numbers are derived from it — lib/editor/client.js pins
2396
- the grip's LEFT edge to the table's left border (so the whole grip is
2397
- inside the first column and shares no pixel with the block gutter's ⠿),
2398
- and the edit-mode first-column padding must be wide enough to host it
2399
- without covering cell text. Written once here, derived twice below. */
2428
+ /* spec §4.2 hit-test conflict 2: the row grip's width is a TOKEN because
2429
+ lib/editor/client.js's updateTableEdgeGrips() reads it (via
2430
+ gripRowWidthPx()) to CENTRE the grip on the table's left border —
2431
+ tableRect.left - gw / 2. Written once here, read once there, so the CSS
2432
+ width and the JS position can never drift apart the way they did when
2433
+ the position was a hard-coded literal 20. */
2400
2434
  :root { --ed-te-grip-row-w: 20px; }
2401
2435
  .ed-te-grip-row {
2402
2436
  width: var(--ed-te-grip-row-w); height: 28px; cursor: grab; z-index: 7;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helping-ai-workflow/md2doc",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
4
4
  "description": "Markdown → HTML / PDF renderer with WaveDrom, Mermaid, and Graphviz support",
5
5
  "keywords": [
6
6
  "markdown",
@@ -36,7 +36,7 @@
36
36
  },
37
37
  "scripts": {
38
38
  "preinstall": "node scripts/preinstall.js",
39
- "test": "node test/md2doc.test.js && node test/blockmap.test.js && node test/images.test.js && node test/scroll-anchor.test.js && node test/lightbox.test.js && node test/lightbox-anno.test.js && node test/lightbox-anno-style.test.js && node test/reader-panels.test.js && node test/cli.test.js && node test/code-operator.test.js && node test/convert-md.test.js && node test/render-api.test.js && node test/lineops.test.js && node test/indent-clamp.test.js && node test/selection.test.js && node test/editmode-render.test.js && node test/editmode-wavedrom-reinit.test.js && node test/editor-server.test.js && node test/cli-edit.test.js && node test/open-viewer.test.js && node test/editor-client.test.js && node test/editor-client-runtime.test.js && node test/roundtrip.test.js && node test/byte-stability.test.js && node test/editor-server-throw.test.js && node test/editor-reader-rebind.test.js && node test/inline-md.test.js && node test/table-md.test.js && node test/gate-compat.test.js && node test/history.test.js && node test/list-md.test.js"
39
+ "test": "node test/md2doc.test.js && node test/blockmap.test.js && node test/images.test.js && node test/scroll-anchor.test.js && node test/lightbox.test.js && node test/lightbox-anno.test.js && node test/lightbox-anno-style.test.js && node test/reader-panels.test.js && node test/cli.test.js && node test/code-operator.test.js && node test/convert-md.test.js && node test/render-api.test.js && node test/lineops.test.js && node test/indent-clamp.test.js && node test/selection.test.js && node test/editmode-render.test.js && node test/editmode-wavedrom-reinit.test.js && node test/editor-server.test.js && node test/cli-edit.test.js && node test/open-viewer.test.js && node test/editor-client.test.js && node test/editor-client-runtime.test.js && node test/roundtrip.test.js && node test/byte-stability.test.js && node test/editor-server-throw.test.js && node test/editor-reader-rebind.test.js && node test/inline-md.test.js && node test/table-md.test.js && node test/gate-compat.test.js && node test/history.test.js && node test/list-md.test.js && node test/table-width.test.js"
40
40
  },
41
41
  "repository": {
42
42
  "type": "git",