@helping-ai-workflow/md2doc 2.11.0 → 2.11.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.
@@ -1553,6 +1553,16 @@
1553
1553
  el.className = 'ed-handle';
1554
1554
  el.textContent = '⠿';
1555
1555
  el.setAttribute('aria-label', '區塊選項');
1556
+ // v2.11.1: a <button> is a sequential focus stop, and there is one of
1557
+ // these plus one + standing immediately after EVERY block — so any Tab
1558
+ // that reaches the browser walks straight into gutter chrome, which is the
1559
+ // most jarring shape of the two escape classes fixed above. Both are
1560
+ // mouse-only affordances with no keyboard contract of their own (the ⠿
1561
+ // menu is opened by click; nothing here is reachable or operable by
1562
+ // keyboard today), so they are removed from the tab order rather than
1563
+ // given one they do not have. tabindex="-1" keeps them programmatically
1564
+ // and click-focusable, so `.ed-handle:focus { opacity: 1 }` still works.
1565
+ el.setAttribute('tabindex', '-1');
1556
1566
  // Deliberately NOT wired with its own addEventListener here (see the
1557
1567
  // paragraph above) — including for Final-review Finding 5a's mousedown
1558
1568
  // preventDefault() (see wireBlockSelection()'s delegated 'mousedown'
@@ -1565,7 +1575,7 @@
1565
1575
  return el;
1566
1576
  }
1567
1577
 
1568
- // The single shared ⠿ menu (spec §3.7: 轉換成 › / 複製 / 刪除 / MD 原始碼)
1578
+ // The single shared ⠿ menu (spec §3.7: 轉換成 › / 建立副本 / 刪除 / MD 原始碼)
1569
1579
  // — built once, moved into whichever block's DOM the user opened it on,
1570
1580
  // same pattern as `selToolbar` elsewhere in this file. `gutterMenuBlockEl`
1571
1581
  // names which block it's currently open for. Because the node is a
@@ -1609,7 +1619,7 @@
1609
1619
  openConvertSubmenu(gutterMenuConvert);
1610
1620
  });
1611
1621
 
1612
- gutterMenuDuplicate = item('複製', 'Duplicate this block', (e) => {
1622
+ gutterMenuDuplicate = item('建立副本', 'Duplicate this block', (e) => {
1613
1623
  e.stopPropagation();
1614
1624
  const blockEl = gutterMenuBlockEl;
1615
1625
  closeGutterMenu();
@@ -1745,6 +1755,8 @@
1745
1755
  el.className = 'ed-insert';
1746
1756
  el.textContent = '+';
1747
1757
  el.setAttribute('aria-label', '插入區塊');
1758
+ // Not a tab stop — see buildGutterHandle() above for the whole reason.
1759
+ el.setAttribute('tabindex', '-1');
1748
1760
  return el;
1749
1761
  }
1750
1762
 
@@ -1944,7 +1956,7 @@
1944
1956
  // the ids — see captureBlockIdentity()'s comment.
1945
1957
  const identity = captureBlockIdentity(blockEl);
1946
1958
  // S2 Task 7: the FOURTH and last call site of the hole 轉換 (Task 2), 刪除
1947
- // and 複製 (Task 6) already closed, and the one that was latent only
1959
+ // and 建立副本 (Task 6) already closed, and the one that was latent only
1948
1960
  // because a li had no + to press. Finding 5a's delegated mousedown
1949
1961
  // preventDefault() names '.ed-insert' as well as '.ed-handle', so the
1950
1962
  // burst survives the press and the commit that lands inside
@@ -1987,7 +1999,7 @@
1987
1999
  //
1988
2000
  // 1. THE INSERTION POINT IS THE END OF THE ANCHOR'S SUBTREE, not the
1989
2001
  // anchor's own last line. This is the ruling §4.3 already made for
1990
- // 複製 (「副本插在該 block 整棵子樹之後」), and it is what makes every
2002
+ // 建立副本 (「副本插在該 block 整棵子樹之後」), and it is what makes every
1991
2003
  // non-list kind safe here. Measured on ['# Doc','','- alpha',
1992
2004
  // ' - child',' - grand','']: anchored on `child`,
1993
2005
  // commitBlockInsertion() with the 段落 skeleton yields
@@ -2004,7 +2016,7 @@
2004
2016
  // measured, '# Doc\n\n- alpha\n - child\n\n -\n' has a NESTED
2005
2017
  // list with loose === true, so every item of it grows a <p>,
2006
2018
  // serializeBlocks() pushes 'P' for each and the run degrades read-only
2007
- // with no banner. Same fork 複製 hit in Task 6, and the same answer:
2019
+ // with no banner. Same fork 建立副本 hit in Task 6, and the same answer:
2008
2020
  // route the li through its own run's re-serialization, which emits no
2009
2021
  // blank at all, re-runs §3.8's renumbering, and — the point of carry 2
2010
2022
  // — takes the new item's indent prefix from the serializer's own
@@ -2183,7 +2195,7 @@
2183
2195
  if (!rec) { showBanner(DROPPED_GESTURE_MESSAGE, null, null); return; }
2184
2196
  const kind = liveBlockEl.getAttribute('data-block-type');
2185
2197
 
2186
- // §4.3's run-wide gate: 轉換/複製/刪除/拖曳 all pass through
2198
+ // §4.3's run-wide gate: 轉換/建立副本/刪除/拖曳 all pass through
2187
2199
  // listRunSupportsStructuralEdit() BEFORE any mutation, the same door
2188
2200
  // Tab/Enter/checkbox already use. Its input is §3.4 rule 2's SCOPE, which
2189
2201
  // is exactly what listRunOf() returns (the outermost run PLUS every
@@ -2272,7 +2284,7 @@
2272
2284
  return document.querySelector('.ed-block[data-block-id="' + at.id + '"]');
2273
2285
  }
2274
2286
 
2275
- // S2 Task 6 — 複製 (§4.3).
2287
+ // S2 Task 6 — 建立副本 (§4.3).
2276
2288
  //
2277
2289
  // The copy is inserted after the block's ENTIRE SUBTREE, never after its own
2278
2290
  // line. The spec records the measurement and it reproduces here:
@@ -2348,16 +2360,16 @@
2348
2360
  }
2349
2361
  }
2350
2362
 
2351
- // The li half of 複製. The copy is spliced into the run's own span and the
2363
+ // The li half of 建立副本. The copy is spliced into the run's own span and the
2352
2364
  // WHOLE span is re-serialized over the run's line range — one commitRangeEdit,
2353
- // therefore one undo op (§4.3: 複製與刪除均為單一 undo), no leading blank, and
2365
+ // therefore one undo op (§4.3: 建立副本與刪除均為單一 undo), no leading blank, and
2354
2366
  // §3.8's renumbering falls out of the re-serialization ('1. alpha' duplicated
2355
2367
  // gives '1. alpha / 2. alpha / 3. bravo', not '1. alpha / 1. alpha / 2.
2356
2368
  // bravo').
2357
2369
  async function duplicateListItem(liEl) {
2358
2370
  const run = listRunOf(liEl);
2359
2371
  if (!run.length) return;
2360
- // §4.3's run-wide gate — 轉換/複製/刪除/拖曳 each make this call for
2372
+ // §4.3's run-wide gate — 轉換/建立副本/刪除/拖曳 each make this call for
2361
2373
  // themselves; there is no shared helper. Its input is §3.4 rule 2's scope,
2362
2374
  // which is exactly what listRunOf() returns (the outermost run PLUS every
2363
2375
  // descendant of its members). A duplicate is NOT column-only (§4.1 修訂 2:
@@ -3773,7 +3785,24 @@
3773
3785
  // wireBurstListeners() below), Shift+Enter inserts a <br> and snapshots
3774
3786
  // it, Escape reverts, Ctrl+Z/Y drive the burst-local history.
3775
3787
  function handleBurstKeydown(e, editEl) {
3776
- if (!currentBurst || currentBurst.editEl !== editEl) return;
3788
+ if (!currentBurst || currentBurst.editEl !== editEl) {
3789
+ // v2.11.1 acceptance, escape class B. This bail is reachable with the
3790
+ // surface STILL FOCUSED and still `.ed-wys-armed`: resolveBurst() nulls
3791
+ // `currentBurst` without blurring (Ctrl+S is the everyday way in), and
3792
+ // the delegated handler's call site below `return`s unconditionally, so
3793
+ // nothing else in the document handler runs either. For every other key
3794
+ // that is the right answer — the surface is a plain contenteditable and
3795
+ // the browser's default IS the behaviour we want. Tab is the one key
3796
+ // whose default is not "insert something" but "walk the caret out of the
3797
+ // document": measured on 2.11.0 it moved focus to that same item's own +
3798
+ // button (Shift+Tab, to the previous block's ⠿). Spec §3.5 names this
3799
+ // outright — 必須 preventDefault(),否則 Tab 在 body 上是瀏覽器焦點巡覽.
3800
+ // Swallowed, not acted on: there is no burst to act within, and an
3801
+ // indent from a resolved burst would be a structural edit the user did
3802
+ // not ask for.
3803
+ if (e.key === 'Tab') e.preventDefault();
3804
+ return;
3805
+ }
3777
3806
  // Task 8 (Phase 4): per-li burst — Enter / Shift+Enter / Tab / Shift+Tab
3778
3807
  // are owned by handleLiKeydown() below (spec §4's key semantics for li
3779
3808
  // surfaces, acceptance rows 1, 3, 5, 6, 7, 8). Every other key (Escape,
@@ -6462,6 +6491,28 @@
6462
6491
  if (insertMenuBlockEl) closeInsertMenu();
6463
6492
  if (e.target.closest(ED_LIGHTBOX_TARGETS)) return; // let the lightbox open, unchanged
6464
6493
  let blockEl = e.target.closest('.ed-block');
6494
+ // v2.11.1: `.ed-block::before` (lib/md2doc.js's editModeLayoutCss) makes
6495
+ // the 40px gutter part of the block's HIT area so that hovering it keeps
6496
+ // the +/⠿ pair visible. That is a hover fix, and it must not become a
6497
+ // click fix by accident: before it, a click in the gutter band hit
6498
+ // main.content and meant "clicked outside any block" — which for a
6499
+ // DEGRADED block (blockquote, fenced code, an unsupported table) is the
6500
+ // difference between committing whatever was open and silently opening
6501
+ // that block's raw source editor from 20px away from it.
6502
+ //
6503
+ // Read only when the click landed on the block's OWN box (a click on any
6504
+ // descendant — the text surface, a marker, a checkbox, a gutter button —
6505
+ // is unaffected) and only when the event actually carries coordinates:
6506
+ // a synthesized `new MouseEvent('click', {bubbles:true})` and
6507
+ // `el.click()` both report clientX/clientY 0, which several scenarios in
6508
+ // test/editor-client-runtime.test.js use precisely because they mean
6509
+ // "the block itself", not "a point". `offsetX < 0` looks like the
6510
+ // tidier test and is NOT usable: for a synthesized event Chromium still
6511
+ // derives offsetX from clientX 0, so it comes back as minus the block's
6512
+ // whole left offset and every such click reads as a gutter click.
6513
+ if (blockEl && e.target === blockEl && (e.clientX || e.clientY)) {
6514
+ if (e.clientX < blockEl.getBoundingClientRect().left) blockEl = null;
6515
+ }
6465
6516
  if (!blockEl) { await switchAwayFrom(); return; } // clicked outside any block
6466
6517
 
6467
6518
  // Task 5: a table block is now armed exactly like paragraph/heading/
@@ -6638,6 +6689,44 @@
6638
6689
  return;
6639
6690
  }
6640
6691
 
6692
+ // v2.11.1 acceptance, escape class A: Tab with NOTHING focused. Every
6693
+ // branch above is keyed on the event target being some edit surface, and
6694
+ // after a commit / Escape / Ctrl+Z, or a click on a bullet marker or in
6695
+ // the block's own gutter, focus is on BODY and the target is BODY — so no
6696
+ // branch matched and the browser ran its own sequential focus navigation,
6697
+ // landing on whichever gutter <button> happens to come next in document
6698
+ // order. Spec §3.5: 必須 preventDefault(),否則 Tab 在 body 上是瀏覽器焦點
6699
+ // 巡覽. This is deliberately a silent no-op rather than "indent the block
6700
+ // nearest the caret": with no focus there is no caret, so there is no
6701
+ // block the key could mean.
6702
+ //
6703
+ // Scoped so a real control keeps its keyboard contract: the reader's own
6704
+ // search input and the raw editor's textarea (which returned above) are
6705
+ // still tabbable, and so is anything else the user has deliberately
6706
+ // focused. What is swallowed is Tab from inside a `.ed-block` and Tab with
6707
+ // no focus at all — the two states the editor puts the user in.
6708
+ if (e.key === 'Tab') {
6709
+ const inBlock = e.target && e.target.closest && e.target.closest('.ed-block');
6710
+ // A REAL control inside a block keeps its keyboard contract. The raw
6711
+ // source editor's own 完成/取消 buttons are the case that matters: Tab
6712
+ // out of its textarea is how a keyboard user reaches them (the textarea
6713
+ // itself returned above), and swallowing the next Tab would trap focus
6714
+ // on the button it just landed on. The ⠿ menu's buttons are the same
6715
+ // shape. The two GUTTER buttons are excluded from that exemption on
6716
+ // purpose — they are the chrome this fix exists to keep out of the tab
6717
+ // order, and buildGutterHandle()/buildGutterInsertButton() give them
6718
+ // tabindex="-1" for the same reason.
6719
+ const control = e.target && e.target.closest && e.target.closest(
6720
+ 'button:not(.ed-handle):not(.ed-insert), input, select, textarea, a[href], [tabindex]:not([tabindex="-1"])');
6721
+ const focused = document.activeElement;
6722
+ const nothingFocused = !focused || focused === document.body ||
6723
+ focused === document.documentElement;
6724
+ if ((inBlock && !control) || nothingFocused) {
6725
+ e.preventDefault();
6726
+ return;
6727
+ }
6728
+ }
6729
+
6641
6730
  if (e.key === 'Escape') {
6642
6731
  e.preventDefault();
6643
6732
  closeGutterMenu();
@@ -377,6 +377,61 @@
377
377
  return n > 0 ? new Array(n + 1).join(' ') : '';
378
378
  }
379
379
 
380
+ // ── The setext-underline hazard on an EMPTY bulleted item ───────────────
381
+ // An empty item is emitted as a BARE marker ('-', no trailing space): '- '
382
+ // lexes as a PARAGRAPH, which is why lib/editor/client.js's
383
+ // BLOCK_SKELETONS.list is a bare marker too, and that reasoning is not being
384
+ // undone here. But CommonMark gives that same bare '-' a SECOND reading, and
385
+ // which one wins is decided by the line ABOVE it: an EMPTY list item may not
386
+ // interrupt a paragraph, and a line consisting of nothing but '-' standing
387
+ // at an open paragraph's own content column is a SETEXT H2 UNDERLINE.
388
+ //
389
+ // That is exactly the line an indent produces. `- beta` + Enter + Tab wrote
390
+ //
391
+ // - alpha
392
+ // - beta
393
+ // -
394
+ //
395
+ // and marked (14.1.4) reads it back as `<li><h2>beta</h2></li>`: the new
396
+ // item is gone and the parent's text has been re-typed as a heading. Two
397
+ // ordinary keystrokes, silent content destruction, measured on 2.11.0.
398
+ //
399
+ // The hazard is POSITIONAL, not a property of the marker. It exists only
400
+ // where the previously emitted line is a paragraph at this line's own
401
+ // column, which inside a run means precisely "this item is the FIRST item of
402
+ // a deeper nesting" (`prev.indent < indent`) — the parent's own text (or its
403
+ // lazy continuation) is then the line immediately above, and a child's
404
+ // marker column IS the parent's content column by construction. An empty
405
+ // item that follows a SAME-level sibling is safe (the line above is a marker
406
+ // line, so '-' can only be another marker there), and so is one whose
407
+ // predecessor is deeper.
408
+ //
409
+ // Every other shape is left byte-identical:
410
+ // * ordered — '1.' is not a run of dashes, so it is not a setext underline;
411
+ // * task — a content-free task item never emits a marker line at all
412
+ // (it becomes `pending`, a same-line prefix), which is what the
413
+ // `head === indentPrefix + marker` test below detects;
414
+ // * non-empty items, and every empty item at top level.
415
+ //
416
+ // The escape is a U+200B ZERO WIDTH SPACE: real, non-whitespace content to
417
+ // the block lexer (so the line is a list item, not an underline) and nothing
418
+ // at all to a reader. It is the same trade-off client.js already documents
419
+ // for BLOCK_SKELETONS.paragraph. It never becomes part of the user's text:
420
+ // lib/md2doc.js's edit-mode list renderer renders a U+200B-only item as an
421
+ // EMPTY surface, so the next keystroke lands in an empty item, and the
422
+ // itemMd normalisation above takes the character back off on the way out.
423
+ const SETEXT_ESCAPE = '\u200b';
424
+ const SETEXT_ESCAPE_RE = /^\u200b+$/;
425
+ function escapeSetextHazard(firstOwnLine, ctx) {
426
+ if (firstOwnLine !== '') return firstOwnLine;
427
+ if (ctx.listType !== 'ul' || ctx.isTask) return firstOwnLine;
428
+ // A `pending` task prefix has already been joined onto `head`, so the line
429
+ // is not a bare run of dashes and needs nothing.
430
+ if (ctx.head !== ctx.indentPrefix + ctx.marker) return firstOwnLine;
431
+ if (!ctx.prev || ctx.indent <= ctx.prev.indent) return firstOwnLine;
432
+ return SETEXT_ESCAPE;
433
+ }
434
+
380
435
  // `opts.carryOver` (spec §3.4, 多行 li 的旁觀者規則): a map of block id →
381
436
  // that block's ORIGINAL source lines. A hard-wrapped item named there is NOT
382
437
  // re-serialized; its own bytes are replayed with the column difference of its
@@ -536,6 +591,15 @@
536
591
  const res = inlineMd.serializeInline({ childNodes: inlineKids });
537
592
  itemMd = res.md;
538
593
  res.unsupported.forEach((u) => innerUnsupported.push(u));
594
+ // The other half of the SETEXT ESCAPE applied at the emission site
595
+ // below: a U+200B is this serializer's own way of saying "empty item
596
+ // in a position where a bare marker would re-lex as a heading
597
+ // underline", so this serializer is also the one that takes it back
598
+ // off. Without it, an escaped item that later moves somewhere the
599
+ // escape is not needed (Shift+Tab back to the top level) would keep a
600
+ // zero-width character it never asked for, and the item would stop
601
+ // reading as empty to every `itemMd === ''` test in this function.
602
+ if (SETEXT_ESCAPE_RE.test(itemMd)) itemMd = '';
539
603
  }
540
604
 
541
605
  // Anything in the block that is neither chrome nor the text surface is
@@ -717,7 +781,10 @@
717
781
  prev = { indent: indent, listType: listType };
718
782
  return;
719
783
  }
720
- lines.push((head + ownLines[0]).replace(/[ \t]+$/, ''));
784
+ lines.push((head + escapeSetextHazard(ownLines[0], {
785
+ head: head, indentPrefix: indentPrefix, marker: marker,
786
+ listType: listType, isTask: isTask, indent: indent, prev: prev,
787
+ })).replace(/[ \t]+$/, ''));
721
788
  lineMeta.push({ blockId: blockId, indentPrefix: metaPrefix, marker: marker });
722
789
  for (let k = 1; k < ownLines.length; k++) {
723
790
  const body = ownLines[k].replace(/^[ \t]+/, '').replace(/[ \t]+$/, '');
package/lib/md2doc.js CHANGED
@@ -284,6 +284,15 @@ function renderEditModeList(listToken, blocks, biRef, out) {
284
284
  // but would render as a phantom blank line inside the item.
285
285
  inner = marked.parser(ownTokens).trim();
286
286
  }
287
+ // The receiving half of list-md.js's SETEXT ESCAPE (see
288
+ // escapeSetextHazard() there): an empty list item that is the first child
289
+ // of a deeper nesting is written to disk as `- <U+200B>`, because a bare
290
+ // `-` on that line is a setext H2 underline for the parent's own text and
291
+ // destroys both blocks. The zero-width space is the serializer's, not the
292
+ // user's, so it is taken back off here — the surface renders EMPTY, which
293
+ // is what keeps the next keystroke from landing next to an invisible
294
+ // character and writing it back out inside the user's own text.
295
+ if (/^\u200b+$/.test(inner)) inner = '';
287
296
  const check = b.task
288
297
  ? `<span class="ed-li-check" data-checked="${b.checked ? 1 : 0}" role="checkbox" aria-checked="${!!b.checked}"></span>`
289
298
  : '';
@@ -1078,8 +1087,9 @@ ${itemsHtml}
1078
1087
  // (now reverted) "inset the row grip into the table" hack that ended up
1079
1088
  // covering the first cell's text. 56px of content padding plus moving the
1080
1089
  // gutter buttons out (⠿ to left:-36px, + to left:-54px) separates them
1081
- // properly: the gutter pair occupies [contentLeft-54, contentLeft-18] and
1082
- // the 20px-wide row grip straddles contentLeft at
1090
+ // properly: the gutter pair occupies [contentLeft-40, contentLeft-4]
1091
+ // (v2.11.1; it was [contentLeft-54, contentLeft-18] until spec §4.2's own
1092
+ // numbers were restored) and the 20px-wide row grip straddles contentLeft at
1083
1093
  // [contentLeft-10, contentLeft+10] — an 8px gap, and the grip's inner half
1084
1094
  // stays within the cell's own 14px padding, so it never touches cell text.
1085
1095
  // (The padding was 48px until §4.2's hit-test conflict 1 was fixed; see the
@@ -1133,7 +1143,11 @@ ${itemsHtml}
1133
1143
 
1134
1144
  8 more pixels of padding move the pair to [contentLeft-54,
1135
1145
  contentLeft-18] = [splitterRight+2, splitterRight+20], i.e. entirely
1136
- inside the content column with 2px of clearance. The 20px table row grip
1146
+ inside the content column with 2px of clearance. (v2.11.1 pulled the pair
1147
+ further in, to spec §4.2's own [contentLeft-40, contentLeft-4] — the
1148
+ clearance is 16px at this padding rather than 2px. The padding stays at
1149
+ 56px: nothing asks for the text column to move, and this measurement is
1150
+ the one the §4.2 conflict-1 guard is written against.) The 20px table row grip
1137
1151
  still straddles contentLeft at [contentLeft-10, contentLeft+10], so its
1138
1152
  8px gap to ⠿ is unchanged. Guarded by the elementFromPoint(splitter.right
1139
1153
  - 2, y) assertion §4.2 asks for, in
@@ -1210,8 +1224,66 @@ ${itemsHtml}
1210
1224
  reaches a negative viewport x and never covers the splitter. The 20px
1211
1225
  table row grip still straddles contentLeft at
1212
1226
  [contentLeft-10, contentLeft+10] - an 8px gap, unchanged. */
1213
- .ed-handle { left: -36px; top: 0; }
1214
- .ed-insert { left: -54px; top: 0; }`;
1227
+ /* Stated ONCE, as tokens, because §4.2's three numbers are not independent:
1228
+ the gutter is exactly two buttons wide plus the deliberate right-hand
1229
+ breathing gap, and the hover zone below is exactly the gutter. Writing any
1230
+ of them as a second literal is how the corridor comes back. */
1231
+ :root {
1232
+ --ed-gutter-btn: 18px;
1233
+ --ed-gutter-gap: 4px;
1234
+ --ed-gutter-w: calc(var(--ed-gutter-btn) * 2 + var(--ed-gutter-gap));
1235
+ }
1236
+ .ed-handle {
1237
+ left: calc(-1 * (var(--ed-gutter-btn) + var(--ed-gutter-gap))); top: 0;
1238
+ width: var(--ed-gutter-btn);
1239
+ }
1240
+ .ed-insert {
1241
+ left: calc(-1 * var(--ed-gutter-w)); top: 0;
1242
+ width: var(--ed-gutter-btn);
1243
+ }
1244
+ /* v2.11.1: the gutter's HOVER ZONE, and it is the reason the pair could move
1245
+ back to spec §4.2's own numbers at all.
1246
+
1247
+ Both buttons are revealed by .ed-block:hover, and :hover is true only
1248
+ over the block's border box or over one of the buttons themselves. At
1249
+ -36/-54 that left the band [blockLeft-18, blockLeft) belonging to neither:
1250
+ measured at 1400x900, elementFromPoint() returned main.content for
1251
+ x 394..411, and a real pointer walking out of the text at 100 px/s held
1252
+ the ⠿ at opacity 0 for 16 consecutive frames (~270 ms) — the cursor is
1253
+ between the text and the ⠿ and the ⠿ is not there. Conforming to §4.2
1254
+ ([contentLeft-40, contentLeft-4], the + and ⠿ flush, 4px of breathing
1255
+ room on the right) narrows that band to the 4px gap but does not close it:
1256
+ with the pair moved and this rule taken back out, a 2px-per-frame walk
1257
+ across the same row measured 0.06 / 0.49 / 0.53 at three of its 23 stops —
1258
+ a flicker instead of a disappearance, but still the ⠿ dimming under the
1259
+ cursor that is travelling to it. Geometry alone cannot close it, because §4.2's 4px gap is
1260
+ deliberate (5.3 item 3a's elementFromPoint must land ON the ⠿).
1261
+
1262
+ Two more holes have the same shape and the same cure: the buttons are 20px
1263
+ tall at top:0 while an li row is 24.75px, so the bottom ~4.75px of EVERY
1264
+ row is an empty gutter; and a 61.5px heading's vertical centre is 20px
1265
+ below the bottom of its own ⠿, so moving left from the middle of a heading
1266
+ never reached anything at all.
1267
+
1268
+ One absolutely-positioned pseudo-element spanning the whole gutter for the
1269
+ block's whole height makes :hover continuously true from the text out
1270
+ past the +, at every Y. Its width is the --ed-gutter-w token the pair's
1271
+ own offsets are built from, so the two cannot drift apart — if they do,
1272
+ the corridor comes straight back.
1273
+ .ed-block is already position:relative, and position: absolute keeps
1274
+ this out of the li row's flex flow. It is deliberately NOT
1275
+ pointer-events: none: that would stop it being hit-tested, which is the
1276
+ entire mechanism. It therefore also changes what a click in the band
1277
+ hits (main.content -> the block), which wireBlockSelection() in
1278
+ lib/editor/client.js compensates for explicitly — see the
1279
+ clientX-vs-block-rect guard just above its "clicked outside any block"
1280
+ branch. (No backticks in this comment: it lives inside a JS template
1281
+ literal.) */
1282
+ .ed-block::before {
1283
+ content: ""; position: absolute;
1284
+ left: calc(-1 * var(--ed-gutter-w)); top: 0;
1285
+ width: var(--ed-gutter-w); height: 100%;
1286
+ }`;
1215
1287
 
1216
1288
  const html = `<!DOCTYPE html>
1217
1289
  <html lang="en">
@@ -2039,7 +2111,7 @@ ${itemsHtml}
2039
2111
  .ed-block:hover .ed-handle,
2040
2112
  .ed-handle:focus { opacity: 1; }
2041
2113
  .ed-handle:hover { background: rgba(0, 0, 0, 0.08); }
2042
- /* The ⠿ handle's menu: 轉換成 › / 複製 / 刪除 / MD 原始碼 (spec §3.7).
2114
+ /* The ⠿ handle's menu: 轉換成 › / 建立副本 / 刪除 / MD 原始碼 (spec §3.7).
2043
2115
  Dark translucent panel, bordered rows — same visual language as
2044
2116
  .ed-seltb below.
2045
2117
 
@@ -2086,7 +2158,9 @@ ${itemsHtml}
2086
2158
  wording allows this ("left gutter, above or beside it").
2087
2159
 
2088
2160
  S1 Task 5 (D6): edit mode now overrides BOTH buttons to sit side by side
2089
- (+ at left:-54px, ⠿ at left:-36px, both top:0) — see editModeLayoutCss.
2161
+ (v2.11.1, spec §4.2's own numbers: + at left:-40px, ⠿ at left:-22px,
2162
+ both top:0, plus a .ed-block::before hover zone spanning the pair) — see
2163
+ editModeLayoutCss.
2090
2164
  That became possible only once .content gained 48px of edit-mode padding;
2091
2165
  the stacked geometry declared here is what any NON-edit render would use,
2092
2166
  and those never emit .ed-block at all, so it is inert there. Kept rather
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helping-ai-workflow/md2doc",
3
- "version": "2.11.0",
3
+ "version": "2.11.1",
4
4
  "description": "Markdown → HTML / PDF renderer with WaveDrom, Mermaid, and Graphviz support",
5
5
  "keywords": [
6
6
  "markdown",