@helping-ai-workflow/md2doc 3.0.1 → 3.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/editor/client.js +110 -2
- package/lib/editor/table-md.js +66 -34
- package/lib/md2doc.js +11 -0
- package/package.json +1 -1
package/lib/editor/client.js
CHANGED
|
@@ -1667,6 +1667,57 @@
|
|
|
1667
1667
|
// function did by returning early.
|
|
1668
1668
|
function computeIndentClamp(spanEls, opBlockEl, opOldIndent, opts) {
|
|
1669
1669
|
if (!indentClamp || !spanEls || !spanEls.length) return null;
|
|
1670
|
+
// v3.0.2 — the structural answer to the stale-`run` pattern that v3.0.1
|
|
1671
|
+
// point-fixed at three call sites, and that both reviewers asked to have
|
|
1672
|
+
// fixed at the call convention instead. There are FIVE call sites, not
|
|
1673
|
+
// three (3483 batch delete, 3812 convert, 5778 batch Tab/Shift+Tab, 6028
|
|
1674
|
+
// caret Tab/Shift+Tab — both indent directions share ONE call site each,
|
|
1675
|
+
// batch vs. caret, not one call site per direction — and the drop's
|
|
1676
|
+
// compute at 8516), and every one of them hands over a `listRunOf()`
|
|
1677
|
+
// result in document order. Two ways that goes stale, both of which
|
|
1678
|
+
// produced a confident wrong number rather than a refusal: a member
|
|
1679
|
+
// DETACHED (getAttribute still answers on a detached node) and a span
|
|
1680
|
+
// still attached but no longer in the run's document order (indexOf()
|
|
1681
|
+
// then reports an opIndex for a layout that no longer exists, so §3.4
|
|
1682
|
+
// rule 2's scope starts in the wrong place). Re-deriving the span here
|
|
1683
|
+
// instead of checking it was rejected: it would quietly change the
|
|
1684
|
+
// contract from "clamp this span" to "clamp the run containing this
|
|
1685
|
+
// op", and the drop is the one caller subtle enough that taking that
|
|
1686
|
+
// expressiveness away is not free.
|
|
1687
|
+
//
|
|
1688
|
+
// It does NOT throw. This file's defensive style (safeRerenderAll at
|
|
1689
|
+
// ~990) is that the editor must not die on one path's failure. But the
|
|
1690
|
+
// refusal is NOT free either: at the drop (8516) a null clamp feeds
|
|
1691
|
+
// spanIndentsAreAnchored() the RAW reordered indents, and that refuses
|
|
1692
|
+
// 1425 of 4067 legal drops where the clamped answer refuses only 14 (the
|
|
1693
|
+
// clamp rescues the other 1411; the measured numbers are recorded at
|
|
1694
|
+
// line ~371). So a false positive here costs the user up to 1411 legal
|
|
1695
|
+
// batch drags, replaced by a banner —
|
|
1696
|
+
// which is why the assert is worth this cost even though the batch-
|
|
1697
|
+
// delete, convert and drop sites each have a scenario whose correct
|
|
1698
|
+
// answer needs a real reclamp (a wrongly-firing assert breaks them
|
|
1699
|
+
// visibly), while neither Tab call site (5778 batch, 6028 caret) has a
|
|
1700
|
+
// scenario that reclamps a TRAILING sibling — see
|
|
1701
|
+
// test/editor-client-runtime.test.js for the per-site detail.
|
|
1702
|
+
const fresh = listRunOf(spanEls[0]);
|
|
1703
|
+
if (fresh.length !== spanEls.length ||
|
|
1704
|
+
fresh.some((el, i) => el !== spanEls[i])) {
|
|
1705
|
+
// Minor 5: the length pair alone is silent on the reordered-but-same-
|
|
1706
|
+
// length case (the ONLY case where an index is diagnostic — a length
|
|
1707
|
+
// mismatch is already self-explanatory), so report the first index at
|
|
1708
|
+
// which the two runs disagree too.
|
|
1709
|
+
const minLen = Math.min(fresh.length, spanEls.length);
|
|
1710
|
+
let badIndex = -1;
|
|
1711
|
+
for (let i = 0; i < minLen; i++) {
|
|
1712
|
+
if (fresh[i] !== spanEls[i]) { badIndex = i; break; }
|
|
1713
|
+
}
|
|
1714
|
+
if (badIndex < 0 && fresh.length !== spanEls.length) badIndex = minLen;
|
|
1715
|
+
console.error('computeIndentClamp: stale span — the run has ' + fresh.length +
|
|
1716
|
+
' members, the caller passed ' + spanEls.length +
|
|
1717
|
+
', first differing at index ' + badIndex +
|
|
1718
|
+
'; refusing rather than clamping against a layout that no longer exists');
|
|
1719
|
+
return null;
|
|
1720
|
+
}
|
|
1670
1721
|
const opEls = Array.isArray(opBlockEl) ? opBlockEl : [opBlockEl];
|
|
1671
1722
|
const opIndex = [];
|
|
1672
1723
|
for (let k = 0; k < opEls.length; k++) {
|
|
@@ -4491,6 +4542,15 @@
|
|
|
4491
4542
|
window.__edTestForceRerender = function () { return rerenderAll(); };
|
|
4492
4543
|
window.__edTestTruncateTo = function (n) { lines = lines.slice(0, n); };
|
|
4493
4544
|
|
|
4545
|
+
// v3.0.2: computeIndentClamp() lives past this file's node early-exit
|
|
4546
|
+
// (module.exports at line ~509 ships the pure core only) and nothing on
|
|
4547
|
+
// window reaches it, so the stale-span assert added to it in v3.0.2 had
|
|
4548
|
+
// no way to be tested at all. This is the seventh __edTest seam and the
|
|
4549
|
+
// only production surface that change adds.
|
|
4550
|
+
window.__edTestClampProbe = function (spanEls, opBlockEl, opOldIndent, opts) {
|
|
4551
|
+
return computeIndentClamp(spanEls, opBlockEl, opOldIndent, opts);
|
|
4552
|
+
};
|
|
4553
|
+
|
|
4494
4554
|
// ── S3 Task 4: the entry and exit GESTURES ─────────────────────────────
|
|
4495
4555
|
// §4.4 entries: (a) press inside a block and drag across its boundary;
|
|
4496
4556
|
// (b) Shift+Click; (c) Shift+↑↓. Exits: Escape (Task 3's keydown prologue)
|
|
@@ -7139,6 +7199,40 @@
|
|
|
7139
7199
|
let gripColTableEl = null;
|
|
7140
7200
|
let gripColIndex = null;
|
|
7141
7201
|
|
|
7202
|
+
// v3.0.2 — the gutter keep-alive for the table row grip. Held in its own
|
|
7203
|
+
// module-level reference, NOT read back off `gripRowTableEl`, because
|
|
7204
|
+
// hideTableGrips() below nulls that at its own line 7246-7248: a teardown
|
|
7205
|
+
// that went looking for the block through it would find null exactly when
|
|
7206
|
+
// it needs to clear the class, and rerenderAll()'s hideTableGrips() call
|
|
7207
|
+
// reaches it with the table already detached.
|
|
7208
|
+
let gutterKeepEl = null;
|
|
7209
|
+
|
|
7210
|
+
function setGutterKeep(blockEl) {
|
|
7211
|
+
const next = blockEl || null;
|
|
7212
|
+
if (gutterKeepEl === next) return;
|
|
7213
|
+
if (gutterKeepEl) gutterKeepEl.classList.remove('ed-keep-lit');
|
|
7214
|
+
gutterKeepEl = next;
|
|
7215
|
+
if (gutterKeepEl) gutterKeepEl.classList.add('ed-keep-lit');
|
|
7216
|
+
}
|
|
7217
|
+
|
|
7218
|
+
function clearGutterKeep() { setGutterKeep(null); }
|
|
7219
|
+
|
|
7220
|
+
// The block whose gutter the CURRENTLY SHOWN row grip is covering, or null
|
|
7221
|
+
// when there is nothing to keep lit. Two conditions, both load-bearing:
|
|
7222
|
+
//
|
|
7223
|
+
// • gripRowTableEl / gripRowEl may be null while a grip is nonetheless
|
|
7224
|
+
// visible — a header-only table takes the else branch at 7466-7470 and
|
|
7225
|
+
// nulls both, then the column grip is shown unconditionally just
|
|
7226
|
+
// below. Both callers of this function are reached in that state.
|
|
7227
|
+
// • only the HEADER row's grip shares a Y with the ⠿. Keeping the
|
|
7228
|
+
// gutter lit for a body row's grip would light the ⠿ in a place the
|
|
7229
|
+
// defect never occurred — a behaviour change beyond the fix.
|
|
7230
|
+
function headerGripBlock() {
|
|
7231
|
+
if (!gripRowTableEl || !gripRowEl) return null;
|
|
7232
|
+
if (gripRowEl !== headerRowOf(gripRowTableEl)) return null;
|
|
7233
|
+
return gripRowTableEl.closest('.ed-block');
|
|
7234
|
+
}
|
|
7235
|
+
|
|
7142
7236
|
function hideTableGrips() {
|
|
7143
7237
|
rowGrip.hidden = true;
|
|
7144
7238
|
colGrip.hidden = true;
|
|
@@ -7153,6 +7247,7 @@
|
|
|
7153
7247
|
gripRowEl = null;
|
|
7154
7248
|
gripColTableEl = null;
|
|
7155
7249
|
gripColIndex = null;
|
|
7250
|
+
clearGutterKeep();
|
|
7156
7251
|
}
|
|
7157
7252
|
|
|
7158
7253
|
// Bug fix (user acceptance) — history: grips were originally BOTH
|
|
@@ -7298,7 +7393,15 @@
|
|
|
7298
7393
|
// whatever was last shown untouched instead; hideTableGrips() (called
|
|
7299
7394
|
// from table-leave, burst-end, and drag-start elsewhere) already covers
|
|
7300
7395
|
// every path that actually needs to clear it.
|
|
7301
|
-
if (target && target.closest && (target.closest('.ed-te-grip-row') || target.closest('.ed-te-grip-col')))
|
|
7396
|
+
if (target && target.closest && (target.closest('.ed-te-grip-row') || target.closest('.ed-te-grip-col'))) {
|
|
7397
|
+
// v3.0.2: only the ROW grip is the one that covers the ⠿'s approach.
|
|
7398
|
+
// Testing the hit rather than reusing the branch condition matters —
|
|
7399
|
+
// the column grip sits above the table's top border, nowhere near the
|
|
7400
|
+
// ⠿, and lighting the gutter from there is the behaviour change this
|
|
7401
|
+
// fix is scoped to avoid.
|
|
7402
|
+
setGutterKeep(target.closest('.ed-te-grip-row') ? headerGripBlock() : null);
|
|
7403
|
+
return;
|
|
7404
|
+
}
|
|
7302
7405
|
const blockEl = target && target.closest && target.closest('.ed-block[data-block-type="table"]');
|
|
7303
7406
|
const tableEl = blockEl ? blockContentEl(blockEl) : null;
|
|
7304
7407
|
const cellEl = (tableEl && target && target.closest) ? target.closest('th, td') : null;
|
|
@@ -7311,10 +7414,15 @@
|
|
|
7311
7414
|
// comment for why not the whole table). Keep that grip up while still
|
|
7312
7415
|
// in its zone; only actually hide once the pointer has left both
|
|
7313
7416
|
// zones entirely.
|
|
7314
|
-
|
|
7417
|
+
const inRowZone = pointInRowGripZone(x, y);
|
|
7418
|
+
if (inRowZone || pointInColGripZone(x, y)) {
|
|
7419
|
+
setGutterKeep(inRowZone ? headerGripBlock() : null);
|
|
7420
|
+
return;
|
|
7421
|
+
}
|
|
7315
7422
|
hideTableGrips();
|
|
7316
7423
|
return;
|
|
7317
7424
|
}
|
|
7425
|
+
clearGutterKeep();
|
|
7318
7426
|
|
|
7319
7427
|
const tableRect = tableEl.getBoundingClientRect();
|
|
7320
7428
|
const rowEl = cellEl.parentElement;
|
package/lib/editor/table-md.js
CHANGED
|
@@ -365,19 +365,18 @@
|
|
|
365
365
|
// from the last position to the first and requires ALL THREE widths
|
|
366
366
|
// dropped even though "Name" and "Note" still sit at index-shifted but
|
|
367
367
|
// TEXT-unchanged positions; only text-matching, combined with the
|
|
368
|
-
// all-or-nothing gate below, gets that right).
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
// pins this so the next change to it is deliberate.
|
|
368
|
+
// all-or-nothing gate below, gets that right).
|
|
369
|
+
//
|
|
370
|
+
// v3.0.2 superseded the 2026-09-02 review round-1 ruling that a renamed
|
|
371
|
+
// header or a deleted column must discard the whole table's width memory:
|
|
372
|
+
// the forward scan below skips an original column that no longer appears,
|
|
373
|
+
// and only a genuine column REORDER still takes the whole-table refusal.
|
|
374
|
+
// What is still NOT handled — deliberately — is a table with DUPLICATE
|
|
375
|
+
// header names: the scan pairs by TEXT, so after a delete a survivor can
|
|
376
|
+
// inherit the same-named other column's width. Telling the instances
|
|
377
|
+
// apart needs identity matching, which is a larger change than this
|
|
378
|
+
// patch release carries. See test/table-width.test.js cases 14, 15, 20,
|
|
379
|
+
// 21, 23 and 24, which pin every branch of the above.
|
|
381
380
|
const original = columnWidthsOf(originalMd);
|
|
382
381
|
let widths = null;
|
|
383
382
|
// Set for a column that falls off the END of the original header list
|
|
@@ -415,31 +414,51 @@
|
|
|
415
414
|
const matchedSepFields = new Array(headerContents.length);
|
|
416
415
|
if (original) {
|
|
417
416
|
const matched = new Array(headerContents.length);
|
|
417
|
+
// v3.0.2: a FORWARD SCAN, not the pre-v3.0.2 lockstep compare. The
|
|
418
|
+
// lockstep version left `j` parked on the first original column that
|
|
419
|
+
// did not match, so it could never recover from one mismatch — and
|
|
420
|
+
// "some original column was never consumed" is the signature of a
|
|
421
|
+
// DELETE and a RENAME just as much as of the column REORDER the gate
|
|
422
|
+
// below actually exists to refuse. Scanning forward lets a delete or
|
|
423
|
+
// a rename skip the original it no longer has, and moves the refusal
|
|
424
|
+
// into an explicit test (`moved`) instead of a side effect of `j`.
|
|
425
|
+
const unmatchedNames = [];
|
|
418
426
|
let j = 0;
|
|
419
427
|
for (let i = 0; i < headerContents.length; i++) {
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
428
|
+
const name = headerContents[i].trim();
|
|
429
|
+
let k = -1;
|
|
430
|
+
for (let q = j; q < original.headerFields.length; q++) {
|
|
431
|
+
if (original.headerFields[q].trim() === name) { k = q; break; }
|
|
432
|
+
}
|
|
433
|
+
if (k >= 0) {
|
|
434
|
+
matched[i] = original.widths[k];
|
|
435
|
+
matchedSepFields[i] = original.sepFields[k];
|
|
436
|
+
j = k + 1;
|
|
425
437
|
} else {
|
|
426
438
|
matched[i] = undefined;
|
|
439
|
+
// The EMPTY name is exempt from the reorder test below, and the
|
|
440
|
+
// exemption is load-bearing, not defensive: insertColumn()
|
|
441
|
+
// (lib/editor/client.js:6448-6465) creates a th (or td) with no
|
|
442
|
+
// content at all — the element itself at line 6451 — so every
|
|
443
|
+
// inserted column is named ''. A table that
|
|
444
|
+
// already holds one blank header would therefore see the new
|
|
445
|
+
// column's '' "found elsewhere in the original" and take the
|
|
446
|
+
// whole-table refusal — a straight regression against v3.0.1 on
|
|
447
|
+
// the ordinary insert-a-column-twice flow.
|
|
448
|
+
if (name !== '') unmatchedNames.push(name);
|
|
427
449
|
if (j >= original.headerFields.length) sepFallbackNatural[i] = true;
|
|
428
450
|
}
|
|
429
451
|
}
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
//
|
|
435
|
-
//
|
|
436
|
-
//
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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) {
|
|
452
|
+
// A column that could not find itself in what remains of the original,
|
|
453
|
+
// yet whose name IS somewhere in the original, has MOVED relative to
|
|
454
|
+
// the others. That is the one shape a per-column width guess gets
|
|
455
|
+
// silently wrong ("a column move must carry its alignment with it"),
|
|
456
|
+
// so it still discards the width source for the WHOLE table. A name
|
|
457
|
+
// that is absent from the original entirely is an insert or a rename:
|
|
458
|
+
// nothing stale to carry, so its neighbours keep their widths.
|
|
459
|
+
const moved = unmatchedNames.some((n) =>
|
|
460
|
+
original.headerFields.some((f) => f.trim() === n));
|
|
461
|
+
if (!moved) {
|
|
443
462
|
widths = matched;
|
|
444
463
|
// A matched column is then widened, for every row of that column
|
|
445
464
|
// at once (header + separator + every body row), to whatever the
|
|
@@ -485,9 +504,22 @@
|
|
|
485
504
|
// (`widths` itself is null: cases 1 and 7 stay byte-identical to
|
|
486
505
|
// the pre-v3.0.1 minimal form), or a mismatch that is neither a
|
|
487
506
|
// matched-but-non-uniform column nor a trailing new one (a
|
|
488
|
-
// renamed header, or a row-drag promotion)
|
|
489
|
-
//
|
|
490
|
-
//
|
|
507
|
+
// renamed header, or a row-drag promotion). Since v3.0.2 the
|
|
508
|
+
// gate above no longer discards `widths` for a rename, so this
|
|
509
|
+
// branch is now the live path for a renamed column: its header
|
|
510
|
+
// and body rows take padField()'s `|| 0` natural width and its
|
|
511
|
+
// separator takes the bare 3-dash minimal. A row-drag promotion
|
|
512
|
+
// ONLY leaves every column unmatched — and so only produces the
|
|
513
|
+
// same all-undefined `widths`, byte-for-byte the `null` output,
|
|
514
|
+
// because every preference above falls through to this 0 — when
|
|
515
|
+
// none of the promoted row's cells collides with an ORIGINAL
|
|
516
|
+
// header name. A collision re-enters the by-text match above (the
|
|
517
|
+
// `k >= 0` branch), so that column inherits the OLD column's
|
|
518
|
+
// width and the row emits half-aligned rather than fully minimal
|
|
519
|
+
// (measured: promoting a 'Note' / 'zzz' row past an original
|
|
520
|
+
// 'Name | Note' header keeps column 0 at the original Note
|
|
521
|
+
// column's width while column 1 falls to 3-dash minimal). The two
|
|
522
|
+
// promotion scenarios this suite covers are both collision-free.
|
|
491
523
|
const origField = matchedSepFields[i];
|
|
492
524
|
const w = widths
|
|
493
525
|
? (widths[i] !== undefined ? widths[i]
|
package/lib/md2doc.js
CHANGED
|
@@ -2185,6 +2185,17 @@ ${itemsHtml}
|
|
|
2185
2185
|
}
|
|
2186
2186
|
.ed-block:hover .ed-handle,
|
|
2187
2187
|
.ed-handle:focus { opacity: 1; }
|
|
2188
|
+
/* v3.0.2: a table's row grip is a document.body child at z-index 7 that
|
|
2189
|
+
straddles the table's left border, and a table block's ⠿ sits at the
|
|
2190
|
+
header row's own Y — so a pointer travelling left toward the ⠿ crosses
|
|
2191
|
+
the grip, elementFromPoint stops returning the block, and :hover above
|
|
2192
|
+
goes false mid-approach. The grip cannot move (it must be a body child
|
|
2193
|
+
to stay put while the table scrolls), so lib/editor/client.js holds the
|
|
2194
|
+
gutter lit with this class for the duration instead. It does NOT
|
|
2195
|
+
restore :hover, so .ed-block:hover's own outline still blinks there —
|
|
2196
|
+
a recorded, unfixed residue. */
|
|
2197
|
+
.ed-block.ed-keep-lit .ed-handle,
|
|
2198
|
+
.ed-block.ed-keep-lit .ed-insert { opacity: 1; }
|
|
2188
2199
|
.ed-handle:hover { background: rgba(0, 0, 0, 0.08); }
|
|
2189
2200
|
/* The ⠿ handle's menu: 轉換成 › / 建立副本 / 刪除 / MD 原始碼 (spec §3.7),
|
|
2190
2201
|
each led by a 16px currentColor icon (v2.12.0; drawn in
|