@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.
@@ -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'))) return;
7396
+ if (target && target.closest && (target.closest('.ed-te-grip-row') || target.closest('.ed-te-grip-col'))) {
7397
+ // v3.0.2: only the ROW grip is the one that covers the ⠿'s approach.
7398
+ // Testing the hit rather than reusing the branch condition matters —
7399
+ // the column grip sits above the table's top border, nowhere near the
7400
+ // ⠿, and lighting the gutter from there is the behaviour change this
7401
+ // fix is scoped to avoid.
7402
+ setGutterKeep(target.closest('.ed-te-grip-row') ? headerGripBlock() : null);
7403
+ return;
7404
+ }
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
- if (pointInRowGripZone(x, y) || pointInColGripZone(x, y)) return;
7417
+ const inRowZone = pointInRowGripZone(x, y);
7418
+ if (inRowZone || pointInColGripZone(x, y)) {
7419
+ setGutterKeep(inRowZone ? headerGripBlock() : null);
7420
+ return;
7421
+ }
7315
7422
  hideTableGrips();
7316
7423
  return;
7317
7424
  }
7425
+ clearGutterKeep();
7318
7426
 
7319
7427
  const tableRect = tableEl.getBoundingClientRect();
7320
7428
  const rowEl = cellEl.parentElement;
@@ -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). 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.
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
- 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++;
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
- // 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) {
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) — moot in practice,
489
- // since the all-or-nothing gate above already discarded
490
- // `widths` entirely for those cases.
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helping-ai-workflow/md2doc",
3
- "version": "3.0.1",
3
+ "version": "3.0.2",
4
4
  "description": "Markdown → HTML / PDF renderer with WaveDrom, Mermaid, and Graphviz support",
5
5
  "keywords": [
6
6
  "markdown",