@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.
- package/lib/editor/client.js +260 -100
- package/lib/editor/table-md.js +379 -24
- package/lib/md2doc.js +112 -78
- package/package.json +2 -2
package/lib/editor/client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
5558
|
-
//
|
|
5559
|
-
//
|
|
5560
|
-
//
|
|
5561
|
-
//
|
|
5562
|
-
//
|
|
5563
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6430
|
-
|
|
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
|
|
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
|
|
6915
|
-
//
|
|
6916
|
-
//
|
|
6917
|
-
//
|
|
6918
|
-
//
|
|
6919
|
-
//
|
|
6920
|
-
//
|
|
6921
|
-
//
|
|
6922
|
-
//
|
|
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
|
-
//
|
|
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
|
|
6936
|
-
//
|
|
6937
|
-
//
|
|
6938
|
-
//
|
|
6939
|
-
//
|
|
6940
|
-
//
|
|
6941
|
-
//
|
|
6942
|
-
//
|
|
6943
|
-
//
|
|
6944
|
-
//
|
|
6945
|
-
//
|
|
6946
|
-
//
|
|
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 (
|
|
7029
|
-
//
|
|
7030
|
-
//
|
|
7031
|
-
//
|
|
7032
|
-
//
|
|
7033
|
-
//
|
|
7034
|
-
//
|
|
7035
|
-
//
|
|
7036
|
-
//
|
|
7037
|
-
//
|
|
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
|
|
7051
|
-
//
|
|
7052
|
-
//
|
|
7053
|
-
//
|
|
7054
|
-
//
|
|
7055
|
-
//
|
|
7056
|
-
//
|
|
7057
|
-
//
|
|
7058
|
-
//
|
|
7059
|
-
//
|
|
7060
|
-
//
|
|
7061
|
-
//
|
|
7062
|
-
//
|
|
7063
|
-
//
|
|
7064
|
-
//
|
|
7065
|
-
//
|
|
7066
|
-
//
|
|
7067
|
-
//
|
|
7068
|
-
//
|
|
7069
|
-
//
|
|
7070
|
-
//
|
|
7071
|
-
//
|
|
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
|
-
//
|
|
7094
|
-
//
|
|
7095
|
-
//
|
|
7096
|
-
//
|
|
7097
|
-
//
|
|
7098
|
-
//
|
|
7099
|
-
//
|
|
7100
|
-
//
|
|
7101
|
-
//
|
|
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
|
|
7171
|
-
//
|
|
7172
|
-
//
|
|
7173
|
-
//
|
|
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.
|
|
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
|
-
|
|
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 {
|
package/lib/editor/table-md.js
CHANGED
|
@@ -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
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
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 -> '|'; 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 '|'), 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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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(
|
|
139
|
-
const parts =
|
|
140
|
-
|
|
141
|
-
|
|
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(
|
|
170
|
-
lines.push('|' + headerCells.map((c) =>
|
|
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
|
-
|
|
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
|
|
1090
|
-
// claimed "an 8px gap" between the gutter and the table row grip, a
|
|
1091
|
-
// left over from the v2.11.0 pair position that v2.11.1
|
|
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,
|
|
1095
|
-
//
|
|
1096
|
-
//
|
|
1097
|
-
// — being position: fixed, z-index 7, on document.body — won the hit
|
|
1098
|
-
// there.
|
|
1099
|
-
//
|
|
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
|
|
1102
|
-
//
|
|
1103
|
-
//
|
|
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.)
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
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.
|
|
1235
|
-
row grip
|
|
1236
|
-
|
|
1237
|
-
|
|
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
|
|
1241
|
-
|
|
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)));
|
|
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
|
|
1295
|
-
own offsets are
|
|
1296
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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),
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
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
|
|
2395
|
-
|
|
2396
|
-
the grip
|
|
2397
|
-
|
|
2398
|
-
and the
|
|
2399
|
-
|
|
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.
|
|
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",
|