@helping-ai-workflow/md2doc 2.11.1 → 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
@@ -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
@@ -2050,6 +2050,36 @@ ${itemsHtml}
2050
2050
  output, same precedent as the lightbox selectors above. */
2051
2051
  .ed-block { position: relative; cursor: pointer; }
2052
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; }
2053
2083
  /* white-space: pre-line is LOAD-BEARING, not styling — do not relax it.
2054
2084
  A hard-wrapped ("lazy continuation") list item's own content contains a
2055
2085
  real newline, and its .ed-li-text is an editing host. Under the default
@@ -2111,7 +2141,9 @@ ${itemsHtml}
2111
2141
  .ed-block:hover .ed-handle,
2112
2142
  .ed-handle:focus { opacity: 1; }
2113
2143
  .ed-handle:hover { background: rgba(0, 0, 0, 0.08); }
2114
- /* 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).
2115
2147
  Dark translucent panel, bordered rows — same visual language as
2116
2148
  .ed-seltb below.
2117
2149
 
@@ -2135,7 +2167,23 @@ ${itemsHtml}
2135
2167
  background: rgba(255, 255, 255, 0.08); color: inherit;
2136
2168
  font: inherit; font-size: 12px; line-height: 1;
2137
2169
  text-align: left; white-space: nowrap; cursor: pointer;
2138
- }
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; }
2139
2187
  /* The 轉換成 submenu carries BOTH classes, so it inherits every rule above
2140
2188
  and only overrides the horizontal offset. It is a CHILD of the menu, and
2141
2189
  the menu is itself position:absolute, so the menu's own padding box is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helping-ai-workflow/md2doc",
3
- "version": "2.11.1",
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",