@helping-ai-workflow/md2doc 2.11.0 → 2.12.0

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.
@@ -30,7 +30,12 @@
30
30
  // §3.4's global convention). For a multi-block operation
31
31
  // this is the SMALLEST old indent in the set — anchoring on
32
32
  // the first member instead drives later members negative on
33
- // a delete and no-ops an entire batch Tab.
33
+ // a delete (§3.4 rule 3's first worked failure). It is the
34
+ // bound for the blocks BELOW the set only; how far the set
35
+ // ITSELF may move is a separate question the caller answers
36
+ // (client.js batchIndentDelta(), which since the 2026-08-31
37
+ // D1 review takes the minimum head-room across ALL members,
38
+ // not the head-room of this shallowest one).
34
39
  // opts.removed — the operated block(s) no longer exist.
35
40
  // opts.operatedBecomes — the operated block is still there but is no
36
41
  // longer a list item (§3.3 conversion); the value
@@ -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]+$/, '');
@@ -0,0 +1,204 @@
1
+ 'use strict';
2
+ /* Spec §3.3 / §3.6 / §4.4 — block 多選的純模型.
3
+ UMD, same shape as indent-clamp.js: `require`-able in node for the unit
4
+ tests, and injected into the editor page as `window.md2docSelection`. */
5
+ (function (root, factory) {
6
+ if (typeof module === 'object' && module.exports) module.exports = factory();
7
+ else root.md2docSelection = factory();
8
+ })(typeof self !== 'undefined' ? self : this, function () {
9
+
10
+ // A selection's identity is a LINE RANGE — never block ids, never DOM nodes.
11
+ // buildBlockMap() renumbers every id from 0 on every render (blockmap.js:170)
12
+ // and every batch operation triggers a full rerenderAll(), so an id or an
13
+ // element held across a commit is a dangling reference into a document that
14
+ // no longer exists. Every post-commit recovery path already in client.js
15
+ // (blockElAtLine, reresolveBlockEl, focusBlockAtLine) goes through startLine
16
+ // for exactly this reason. Nothing in this file reads the DOM, and nothing
17
+ // outside it may hand this file an id.
18
+ //
19
+ // a selection — { anchorLine, focusLine }, or null for "nothing selected"
20
+ // a block — a record from buildBlockMap(): { id, type, startLine,
21
+ // endLine, ... }. Read here for startLine/endLine only;
22
+ // `id` is passed through untouched for the caller's own use.
23
+ // `blocks` — the whole live block list, in document order.
24
+
25
+ // ── the no-line exclusion ────────────────────────────────────────────
26
+ // buildBlockMap emits a block for the OUTER item of a same-line nest
27
+ // ("- - a" -> outer {startLine:3, endLine:2}, inner {startLine:3,
28
+ // endLine:3}), and its range is INVERTED because it owns no source line of
29
+ // its own. Such a block has no line for a range to touch, so it can never be
30
+ // a member of a line-range selection. This is the same predicate as
31
+ // blockOwnsNoLine() in client.js:928, which is the guard every structural
32
+ // path in the editor already checks before it writes bytes.
33
+ function ownsALine(b) {
34
+ return !!b && typeof b.startLine === 'number' && typeof b.endLine === 'number'
35
+ && b.endLine >= b.startLine;
36
+ }
37
+
38
+ function lineOf(v) {
39
+ const n = Number(v);
40
+ return Number.isFinite(n) ? n : null;
41
+ }
42
+
43
+ // ── normalize ────────────────────────────────────────────────────────
44
+ // Orders anchor/focus into the {startLine, endLine} shape the block records
45
+ // and the UndoStack op shape already use, so the two never have to be
46
+ // mentally converted at a call site. Returns null rather than a NaN range
47
+ // for "no selection" — a NaN range compares false against everything and
48
+ // would silently produce an empty member set that looks like a real answer.
49
+ function normalize(sel) {
50
+ if (!sel) return null;
51
+ const a = lineOf(sel.anchorLine);
52
+ const f = lineOf(sel.focusLine);
53
+ if (a === null || f === null) return null;
54
+ return { startLine: Math.min(a, f), endLine: Math.max(a, f) };
55
+ }
56
+
57
+ // ── membership ───────────────────────────────────────────────────────
58
+ // Membership is INTERSECTION, not containment: a multi-line block touched
59
+ // anywhere is wholly selected. Block selection has no partial state (§3.6 —
60
+ // the visual is a tint over whole blocks), so a code fence whose middle line
61
+ // is in range is in the set exactly as much as a one-line paragraph is.
62
+ // Returns block RECORDS in document order, never ids and never elements.
63
+ function membersOf(sel, blocks) {
64
+ const r = normalize(sel);
65
+ if (!r) return [];
66
+ return (blocks || []).filter(
67
+ (b) => ownsALine(b) && b.startLine <= r.endLine && b.endLine >= r.startLine);
68
+ }
69
+
70
+ // There is deliberately NO per-block `isSelected()` here. One existed until
71
+ // 2026-08-31 and never had a production caller in `lib/`: every paint and
72
+ // every operation asks membersOf() for the whole set at once, because a batch
73
+ // needs the set's contiguity and its index range, not N independent yes/no
74
+ // answers. Its assertions were all over SINGLE-line blocks, so the
75
+ // intersection-not-containment rule was pinned by membersOf()'s multi-line
76
+ // fixture alone and nothing was lost by removing it. A future caller that
77
+ // genuinely wants one block's answer should re-add it WITH that caller and
78
+ // with a multi-line fixture, rather than inherit an untested export.
79
+
80
+ // ── extendTo ─────────────────────────────────────────────────────────
81
+ // Shift+Click and a drag both move the FOCUS and keep the anchor where the
82
+ // gesture started; that is what makes a selection reversible by dragging
83
+ // back. With no prior selection there is no anchor to keep, so the gesture
84
+ // starts one collapsed at that line.
85
+ function extendTo(sel, line) {
86
+ const l = lineOf(line);
87
+ if (l === null) return sel || null;
88
+ const a = sel ? lineOf(sel.anchorLine) : null;
89
+ return { anchorLine: a === null ? l : a, focusLine: l };
90
+ }
91
+
92
+ // ── stepFocus ────────────────────────────────────────────────────────
93
+ // §4.4's Shift+↑↓. Moves by BLOCK, not by line: blocks do not tile the
94
+ // document (blank separator lines belong to nothing), so a per-line step
95
+ // would park the focus on a line that owns no block and collapse the set to
96
+ // nothing on the way past. Blocks that own no source line are skipped for
97
+ // the same reason membersOf() excludes them — landing on one would leave a
98
+ // selection whose member set is empty.
99
+ //
100
+ // Clamped at both ends: stepping past either end is a no-op, never an error
101
+ // and never a wrap. `dir` is any negative number for up, anything else down.
102
+ function stepFocus(sel, blocks, dir) {
103
+ if (!sel) return sel || null;
104
+ const nav = (blocks || []).filter(ownsALine);
105
+ if (!nav.length) return sel;
106
+ const focus = lineOf(sel.focusLine);
107
+ if (focus === null) return sel;
108
+ const step = dir < 0 ? -1 : 1;
109
+
110
+ // The focus normally sits exactly on a block's startLine, because that is
111
+ // what this function and the gesture handlers write. If it does not (a
112
+ // caller resolved a raw click y-coordinate, say), take the nearest block
113
+ // at or after it, and the last block when the line is past the end.
114
+ let i = nav.findIndex((b) => b.startLine === focus);
115
+ if (i === -1) i = nav.findIndex((b) => b.startLine > focus);
116
+ if (i === -1) i = nav.length - 1;
117
+
118
+ const j = Math.max(0, Math.min(nav.length - 1, i + step));
119
+ return { anchorLine: sel.anchorLine, focusLine: nav[j].startLine };
120
+ }
121
+
122
+ // ── §3.3's membership rule ───────────────────────────────────────────
123
+ // "grip 在選取集合內 → 作用整個集合;grip 在集合外 → 先把集合換成該單一
124
+ // block 再作用." One helper so every operation asks the question the same
125
+ // way instead of each menu handler re-deriving it.
126
+ //
127
+ // `opBlock` MUST be a record out of `blocks` — identity is by reference, not
128
+ // by id (this module never touches ids) and not by line tuple (a nest three
129
+ // deep like "- - - a" produces two structurally identical phantoms, so a
130
+ // tuple compare is genuinely ambiguous). A record from anywhere else answers
131
+ // 'single', which is the conservative direction: it operates on one block
132
+ // rather than silently on N.
133
+ //
134
+ // "By reference" is a TESTED contract, not a description of the current line
135
+ // of code. `id` is passed through untouched and never compared, so a shallow
136
+ // COPY of a member — same id, same lines, different object — is a different
137
+ // block here and answers 'single'. test/selection.test.js pins exactly that
138
+ // case, because it is the only fixture that separates this from
139
+ // `members.some((m) => m.id === opBlock.id)`: every other fixture in the
140
+ // suite hands in a record straight out of `blocks`, where the two agree.
141
+ //
142
+ // A selection of exactly the grip block is 'batch' with one member, not a
143
+ // fallthrough to 'single'. The two answers are byte-identical for a single
144
+ // block today, but a caller that special-cased size 1 would drift the moment
145
+ // batch and single paths diverge.
146
+ function resolveMembership(sel, blocks, opBlock) {
147
+ const members = membersOf(sel, blocks);
148
+ if (opBlock && members.indexOf(opBlock) !== -1) return { mode: 'batch', members };
149
+ return { mode: 'single', members: opBlock ? [opBlock] : [] };
150
+ }
151
+
152
+ // ── collapseTo ───────────────────────────────────────────────────────
153
+ // §3.3: "操作後集合塌縮為「操作結果所涵蓋的行區間」". Every structural
154
+ // operation declares the line range it produced; rerenderAll() re-derives
155
+ // the member set from that range against the freshly built block list. §4.4:
156
+ // if the range does not resolve, the selection is CLEARED rather than left
157
+ // dangling — an operation that removed everything it touched declares an
158
+ // empty (inverted) range, and that is not a selection of one line.
159
+ function collapseTo(range) {
160
+ if (!range) return null;
161
+ const s = lineOf(range.startLine);
162
+ const e = lineOf(range.endLine);
163
+ if (s === null || e === null || e < s) return null;
164
+ return { anchorLine: s, focusLine: e };
165
+ }
166
+
167
+ // ── spanIsContiguous ─────────────────────────────────────────────────
168
+ // Batch convert/delete/duplicate rewrite their single-item entry points to
169
+ // take ONE contiguous index range — a loop would re-render between items and
170
+ // invalidate every id in between, which is the defect class recorded at
171
+ // client.js:3298 and :4806. This is the gate that says a set can be
172
+ // expressed that way.
173
+ //
174
+ // Indices are taken over the WHOLE `blocks` list, phantoms included. A
175
+ // no-line phantom sitting between two members ('- a\n- - b\n- c\n' ->
176
+ // li{1,1} | phantom{2,1} | li{2,2} | li{3,3}) is never a member, but it IS
177
+ // in the DOM run a batch operation would slice, and every structural path
178
+ // already refuses a phantom (blockOwnsNoLine). Reporting false there hands
179
+ // the caller a refusal instead of a range it cannot honour.
180
+ function spanIsContiguous(members, blocks) {
181
+ const list = members || [];
182
+ if (list.length <= 1) {
183
+ // 0 members has no gap to find; the caller checks emptiness itself,
184
+ // because "nothing selected" and "a set with a hole in it" deserve
185
+ // different messages.
186
+ return list.every((m) => (blocks || []).indexOf(m) !== -1);
187
+ }
188
+ const idx = list.map((m) => (blocks || []).indexOf(m));
189
+ if (idx.some((i) => i < 0)) return false;
190
+ idx.sort((a, b) => a - b);
191
+ for (let k = 1; k < idx.length; k++) if (idx[k] !== idx[k - 1] + 1) return false;
192
+ return true;
193
+ }
194
+
195
+ return {
196
+ normalize,
197
+ membersOf,
198
+ extendTo,
199
+ stepFocus,
200
+ resolveMembership,
201
+ collapseTo,
202
+ spanIsContiguous,
203
+ };
204
+ });
@@ -23,6 +23,12 @@ const INDENT_CLAMP_SRC = fs.readFileSync(path.join(__dirname, 'indent-clamp.js')
23
23
  // indent-clamp.js above — it only has to land before client.js reads
24
24
  // window.md2docConvertMd.
25
25
  const CONVERT_MD_SRC = fs.readFileSync(path.join(__dirname, 'convert-md.js'), 'utf8');
26
+ // S3 spec §3.3/§3.6/§4.4: the pure block multi-select model — line-range
27
+ // normalization, membership, Shift+arrow stepping, the §3.3 grip rule and the
28
+ // post-operation collapse. Same "no dependency on any other editor module"
29
+ // property as indent-clamp.js and convert-md.js above; it only has to land
30
+ // before client.js reads window.md2docSelection.
31
+ const SELECTION_SRC = fs.readFileSync(path.join(__dirname, 'selection.js'), 'utf8');
26
32
 
27
33
  function readJson(req, limitBytes = 50 * 1024 * 1024) {
28
34
  return new Promise((resolve, reject) => {
@@ -159,6 +165,7 @@ async function createEditorServer({ files, idleTimeoutMs = 30000, clientJs = '',
159
165
  `<script>${HISTORY_SRC}</script>\n` +
160
166
  `<script>${INDENT_CLAMP_SRC}</script>\n` +
161
167
  `<script>${CONVERT_MD_SRC}</script>\n` +
168
+ `<script>${SELECTION_SRC}</script>\n` +
162
169
  `<script>${clientJs}</script>\n`;
163
170
  // Splice at the LAST "</body>" — the document's real closing tag.
164
171
  // The first occurrence can sit inside an inlined diagram bundle's JS
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">
@@ -1978,6 +2050,36 @@ ${itemsHtml}
1978
2050
  output, same precedent as the lightbox selectors above. */
1979
2051
  .ed-block { position: relative; cursor: pointer; }
1980
2052
  .ed-block:hover { outline: 1px dashed #b0b0b0; }
2053
+ /* S3: the block-level multi-select tint (spec §3.6). rgba, never opaque —
2054
+ §3.6 says the text underneath stays readable. #3b82f6 is the editor's
2055
+ established blue (.ed-wys-armed:focus, .ed-li-check[data-checked="1"],
2056
+ .ed-te-hl).
2057
+
2058
+ The descendant rules and their !important are not decoration, they are
2059
+ the whole problem: <pre> (#f6f8fa), <th> (#f6f8fa), the zebra stripe
2060
+ tr:nth-child(even) (#fafbfc) and the sticky first column (#ffffff) all
2061
+ carry OPAQUE backgrounds, and the sticky column additionally paints at
2062
+ z-index: 1 over anything drawn beneath it. Two of those beat a plain
2063
+ descendant selector on specificity alone — counted:
2064
+ .content table tbody tr:nth-child(even) td:first-child is (0,3,4)
2065
+ against this rule's (0,3,2), and .content table tbody td:first-child is
2066
+ (0,2,3) against the same (0,3,2) — so without !important the tint is
2067
+ invisible on exactly the blocks a user is most likely to select. .ed-te-hl
2068
+ needed the identical treatment for the identical reason. This ADDS rules
2069
+ rather than reordering the sticky/zebra/header set, which CLAUDE.md says
2070
+ wins by source order and must not be moved.
2071
+ (No backticks in this comment: it lives inside a JS template literal.)
2072
+
2073
+ :focus outline is suppressed because the roving tabindex below focuses a
2074
+ .ed-block for real (spec §4.4 wants a real focus holder, not <body>) and
2075
+ the tint — not a second ring — is the selection's affordance. */
2076
+ .ed-block.ed-selected { background: rgba(59, 130, 246, 0.15); }
2077
+ .ed-block.ed-selected pre,
2078
+ .ed-block.ed-selected th,
2079
+ .ed-block.ed-selected tr:nth-child(even),
2080
+ .ed-block.ed-selected tbody td:first-child,
2081
+ .ed-block.ed-selected thead th:first-child { background: rgba(59, 130, 246, 0.15) !important; }
2082
+ .ed-block.ed-selected:focus { outline: none; }
1981
2083
  /* white-space: pre-line is LOAD-BEARING, not styling — do not relax it.
1982
2084
  A hard-wrapped ("lazy continuation") list item's own content contains a
1983
2085
  real newline, and its .ed-li-text is an editing host. Under the default
@@ -2039,7 +2141,9 @@ ${itemsHtml}
2039
2141
  .ed-block:hover .ed-handle,
2040
2142
  .ed-handle:focus { opacity: 1; }
2041
2143
  .ed-handle:hover { background: rgba(0, 0, 0, 0.08); }
2042
- /* The ⠿ handle's menu: 轉換成 › / 複製 / 刪除 / MD 原始碼 (spec §3.7).
2144
+ /* The ⠿ handle's menu: 轉換成 › / 建立副本 / 刪除 / MD 原始碼 (spec §3.7),
2145
+ each led by a 16px currentColor icon (v2.12.0; drawn in
2146
+ lib/editor/client.js's MENU_ICON_PATHS).
2043
2147
  Dark translucent panel, bordered rows — same visual language as
2044
2148
  .ed-seltb below.
2045
2149
 
@@ -2063,7 +2167,23 @@ ${itemsHtml}
2063
2167
  background: rgba(255, 255, 255, 0.08); color: inherit;
2064
2168
  font: inherit; font-size: 12px; line-height: 1;
2065
2169
  text-align: left; white-space: nowrap; cursor: pointer;
2066
- }
2170
+ /* v2.12.0: a flex ROW so the icon and the label share a baseline-free
2171
+ centre line and a fixed gap. The row is still one flex item of the
2172
+ column above, and align-items: stretch there keeps every row the same
2173
+ width, so S2's one-left-edge / monotonic-top invariants are untouched.
2174
+ The submenu's own buttons carry this class and no icon; a lone text node
2175
+ is an anonymous flex item at flex-start, i.e. exactly where text-align
2176
+ put it. */
2177
+ display: flex; align-items: center; gap: 8px;
2178
+ }
2179
+ /* Notion's own sizing. The stroke colour is NOT set here on purpose: the
2180
+ markup carries stroke="currentColor" (lib/editor/client.js), so the icon
2181
+ is whatever colour .ed-handle-menu's own color property is, and follows a
2182
+ retheme for free. A hex here would break that silently. (No backticks in
2183
+ this comment: it lives inside a JS template literal.)
2184
+ flex: 0 0 auto because a 16px-wide flex item next to a nowrap label
2185
+ would otherwise be the thing that shrinks. */
2186
+ .ed-menu-icon { flex: 0 0 auto; width: 16px; height: 16px; }
2067
2187
  /* The 轉換成 submenu carries BOTH classes, so it inherits every rule above
2068
2188
  and only overrides the horizontal offset. It is a CHILD of the menu, and
2069
2189
  the menu is itself position:absolute, so the menu's own padding box is
@@ -2086,7 +2206,9 @@ ${itemsHtml}
2086
2206
  wording allows this ("left gutter, above or beside it").
2087
2207
 
2088
2208
  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.
2209
+ (v2.11.1, spec §4.2's own numbers: + at left:-40px, ⠿ at left:-22px,
2210
+ both top:0, plus a .ed-block::before hover zone spanning the pair) — see
2211
+ editModeLayoutCss.
2090
2212
  That became possible only once .content gained 48px of edit-mode padding;
2091
2213
  the stacked geometry declared here is what any NON-edit render would use,
2092
2214
  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.12.0",
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/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"
40
40
  },
41
41
  "repository": {
42
42
  "type": "git",