@helping-ai-workflow/md2doc 2.11.1 → 3.0.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
@@ -1080,18 +1080,27 @@ ${itemsHtml}
1080
1080
  // `.content { min-width: 0; flex: 1 1 auto; }` block, which
1081
1081
  // test/md2doc.test.js matches verbatim).
1082
1082
  //
1083
- // Why it exists: the gutter buttons hang OUTSIDE the content box, and the
1084
- // table row grip straddles the table's left border (half of it outside
1085
- // too — see the .ed-te-grip-row comment above). Without real padding the
1086
- // two fight over the same few pixels, which is what forced the earlier
1087
- // (now reverted) "inset the row grip into the table" hack that ended up
1088
- // covering the first cell's text. 56px of content padding plus moving the
1089
- // gutter buttons out (⠿ to left:-36px, + to left:-54px) separates them
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
1093
- // [contentLeft-10, contentLeft+10] — an 8px gap, and the grip's inner half
1094
- // stays within the cell's own 14px padding, so it never touches cell text.
1083
+ // Why it exists: the gutter buttons hang OUTSIDE the content box. 56px of
1084
+ // content padding plus moving the gutter buttons out puts the pair at
1085
+ // [contentLeft-40, contentLeft-4] (v2.11.1; it was
1086
+ // [contentLeft-54, contentLeft-18] until spec §4.2's own numbers were
1087
+ // restored), clear of .sidebar-splitter — see the .content rule below.
1088
+ //
1089
+ // GEOMETRY, AS MEASURED (S4 T1; the previous version of this comment
1090
+ // claimed "an 8px gap" between the gutter and the table row grip, a number
1091
+ // left over from the v2.11.0 pair position that v2.11.1 invalidated — the
1092
+ // real gap was MINUS 6px, i.e. an overlap):
1093
+ // ⠿ [contentLeft-22, contentLeft-4] (--ed-gutter-btn wide)
1094
+ // row grip [contentLeft, contentLeft+20] (--ed-te-grip-row-w wide)
1095
+ // The grip used to be centred on the table's left border at
1096
+ // [contentLeft-10, contentLeft+10], which overlapped the ⠿'s right 6px and
1097
+ // — being position: fixed, z-index 7, on document.body — won the hit test
1098
+ // there. spec §4.2 conflict 2's ruling (grip entirely inside the table)
1099
+ // is landed in lib/editor/client.js's updateTableEdgeGrips(); the
1100
+ // clearance that remains is exactly --ed-gutter-gap, because the ⠿'s right
1101
+ // edge and the grip's left edge are both derived from it. The grip is kept
1102
+ // off the first cell's TEXT by --ed-te-cell-pad-left below, not by the
1103
+ // 14px default padding, which is narrower than the grip.
1095
1104
  // (The padding was 48px until §4.2's hit-test conflict 1 was fixed; see the
1096
1105
  // .content rule in editModeLayoutCss below for why it is 56px now.)
1097
1106
  // S1 Task 5: the ordered-list ordinal is drawn by a CSS counter, and the
@@ -1147,10 +1156,11 @@ ${itemsHtml}
1147
1156
  further in, to spec §4.2's own [contentLeft-40, contentLeft-4] — the
1148
1157
  clearance is 16px at this padding rather than 2px. The padding stays at
1149
1158
  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
1151
- still straddles contentLeft at [contentLeft-10, contentLeft+10], so its
1152
- 8px gap to ⠿ is unchanged. Guarded by the elementFromPoint(splitter.right
1153
- - 2, y) assertion §4.2 asks for, in
1159
+ the one the §4.2 conflict-1 guard is written against.) The table row grip no
1160
+ longer reaches this far left at all: §4.2 conflict 2 moved it inside the
1161
+ table to [contentLeft, contentLeft+20] (S4 T1), so nothing but the gutter
1162
+ pair itself occupies [contentLeft-40, contentLeft-4]. Guarded by the
1163
+ elementFromPoint(splitter.right - 2, y) assertion §4.2 asks for, in
1154
1164
  test/editor-client-runtime.test.js. */
1155
1165
  .content { padding-left: 56px; }
1156
1166
 
@@ -1221,9 +1231,10 @@ ${itemsHtml}
1221
1231
  [contentLeft-54, contentLeft-18], which with 56px of padding sits wholly
1222
1232
  inside the content column (2px clear of .sidebar-splitter's right edge —
1223
1233
  see the padding rule's own comment for the measurement), so + never
1224
- reaches a negative viewport x and never covers the splitter. The 20px
1225
- table row grip still straddles contentLeft at
1226
- [contentLeft-10, contentLeft+10] - an 8px gap, unchanged. */
1234
+ reaches a negative viewport x and never covers the splitter. The table
1235
+ row grip is at [contentLeft, contentLeft+20] since §4.2 conflict 2
1236
+ landed (S4 T1), so the clearance from the ⠿'s right edge is exactly
1237
+ --ed-gutter-gap and cannot drift: both edges are derived from it. */
1227
1238
  /* Stated ONCE, as tokens, because §4.2's three numbers are not independent:
1228
1239
  the gutter is exactly two buttons wide plus the deliberate right-hand
1229
1240
  breathing gap, and the hover zone below is exactly the gutter. Writing any
@@ -1232,6 +1243,19 @@ ${itemsHtml}
1232
1243
  --ed-gutter-btn: 18px;
1233
1244
  --ed-gutter-gap: 4px;
1234
1245
  --ed-gutter-w: calc(var(--ed-gutter-btn) * 2 + var(--ed-gutter-gap));
1246
+ /* spec §4.2 hit-test conflict 2, landed S4 T1. The table row grip now
1247
+ sits ENTIRELY inside the table (its LEFT edge on the table's left
1248
+ border — see updateTableEdgeGrips() in lib/editor/client.js), which
1249
+ leaves the ⠿'s right edge and the grip's left edge exactly
1250
+ --ed-gutter-gap apart: the 4px breathing gap §4.2 already keeps on the
1251
+ right of the gutter pair IS the separation. Nothing new is written
1252
+ down, so nothing new can drift.
1253
+ The consequence is this token: the grip is 20px wide and the first
1254
+ column's default padding is 14px, so an inset grip would sit on that
1255
+ cell's TEXT — the v2.10.1 user-acceptance defect that got the first
1256
+ attempt at this ruling reverted. The first column is padded past the
1257
+ grip instead, by the grip's own width plus that same gap. */
1258
+ --ed-te-cell-pad-left: calc(var(--ed-te-grip-row-w) + var(--ed-gutter-gap));
1235
1259
  }
1236
1260
  .ed-handle {
1237
1261
  left: calc(-1 * (var(--ed-gutter-btn) + var(--ed-gutter-gap))); top: 0;
@@ -1283,6 +1307,16 @@ ${itemsHtml}
1283
1307
  content: ""; position: absolute;
1284
1308
  left: calc(-1 * var(--ed-gutter-w)); top: 0;
1285
1309
  width: var(--ed-gutter-w); height: 100%;
1310
+ }
1311
+
1312
+ /* §4.2 conflict 2's other half: the row grip lives inside the first column
1313
+ now, so that column's cells must be padded past it. EDIT MODE ONLY —
1314
+ reader HTML and PDF output keep the shared 7px 14px and stay
1315
+ byte-identical. Only the LEFT padding changes, and only on the first
1316
+ cell of each row; every other cell is untouched. */
1317
+ .ed-block[data-block-type="table"] th:first-child,
1318
+ .ed-block[data-block-type="table"] td:first-child {
1319
+ padding-left: var(--ed-te-cell-pad-left);
1286
1320
  }`;
1287
1321
 
1288
1322
  const html = `<!DOCTYPE html>
@@ -2050,6 +2084,36 @@ ${itemsHtml}
2050
2084
  output, same precedent as the lightbox selectors above. */
2051
2085
  .ed-block { position: relative; cursor: pointer; }
2052
2086
  .ed-block:hover { outline: 1px dashed #b0b0b0; }
2087
+ /* S3: the block-level multi-select tint (spec §3.6). rgba, never opaque —
2088
+ §3.6 says the text underneath stays readable. #3b82f6 is the editor's
2089
+ established blue (.ed-wys-armed:focus, .ed-li-check[data-checked="1"],
2090
+ .ed-te-hl).
2091
+
2092
+ The descendant rules and their !important are not decoration, they are
2093
+ the whole problem: <pre> (#f6f8fa), <th> (#f6f8fa), the zebra stripe
2094
+ tr:nth-child(even) (#fafbfc) and the sticky first column (#ffffff) all
2095
+ carry OPAQUE backgrounds, and the sticky column additionally paints at
2096
+ z-index: 1 over anything drawn beneath it. Two of those beat a plain
2097
+ descendant selector on specificity alone — counted:
2098
+ .content table tbody tr:nth-child(even) td:first-child is (0,3,4)
2099
+ against this rule's (0,3,2), and .content table tbody td:first-child is
2100
+ (0,2,3) against the same (0,3,2) — so without !important the tint is
2101
+ invisible on exactly the blocks a user is most likely to select. .ed-te-hl
2102
+ needed the identical treatment for the identical reason. This ADDS rules
2103
+ rather than reordering the sticky/zebra/header set, which CLAUDE.md says
2104
+ wins by source order and must not be moved.
2105
+ (No backticks in this comment: it lives inside a JS template literal.)
2106
+
2107
+ :focus outline is suppressed because the roving tabindex below focuses a
2108
+ .ed-block for real (spec §4.4 wants a real focus holder, not <body>) and
2109
+ the tint — not a second ring — is the selection's affordance. */
2110
+ .ed-block.ed-selected { background: rgba(59, 130, 246, 0.15); }
2111
+ .ed-block.ed-selected pre,
2112
+ .ed-block.ed-selected th,
2113
+ .ed-block.ed-selected tr:nth-child(even),
2114
+ .ed-block.ed-selected tbody td:first-child,
2115
+ .ed-block.ed-selected thead th:first-child { background: rgba(59, 130, 246, 0.15) !important; }
2116
+ .ed-block.ed-selected:focus { outline: none; }
2053
2117
  /* white-space: pre-line is LOAD-BEARING, not styling — do not relax it.
2054
2118
  A hard-wrapped ("lazy continuation") list item's own content contains a
2055
2119
  real newline, and its .ed-li-text is an editing host. Under the default
@@ -2111,7 +2175,9 @@ ${itemsHtml}
2111
2175
  .ed-block:hover .ed-handle,
2112
2176
  .ed-handle:focus { opacity: 1; }
2113
2177
  .ed-handle:hover { background: rgba(0, 0, 0, 0.08); }
2114
- /* The ⠿ handle's menu: 轉換成 › / 建立副本 / 刪除 / MD 原始碼 (spec §3.7).
2178
+ /* The ⠿ handle's menu: 轉換成 › / 建立副本 / 刪除 / MD 原始碼 (spec §3.7),
2179
+ each led by a 16px currentColor icon (v2.12.0; drawn in
2180
+ lib/editor/client.js's MENU_ICON_PATHS).
2115
2181
  Dark translucent panel, bordered rows — same visual language as
2116
2182
  .ed-seltb below.
2117
2183
 
@@ -2135,7 +2201,23 @@ ${itemsHtml}
2135
2201
  background: rgba(255, 255, 255, 0.08); color: inherit;
2136
2202
  font: inherit; font-size: 12px; line-height: 1;
2137
2203
  text-align: left; white-space: nowrap; cursor: pointer;
2138
- }
2204
+ /* v2.12.0: a flex ROW so the icon and the label share a baseline-free
2205
+ centre line and a fixed gap. The row is still one flex item of the
2206
+ column above, and align-items: stretch there keeps every row the same
2207
+ width, so S2's one-left-edge / monotonic-top invariants are untouched.
2208
+ The submenu's own buttons carry this class and no icon; a lone text node
2209
+ is an anonymous flex item at flex-start, i.e. exactly where text-align
2210
+ put it. */
2211
+ display: flex; align-items: center; gap: 8px;
2212
+ }
2213
+ /* Notion's own sizing. The stroke colour is NOT set here on purpose: the
2214
+ markup carries stroke="currentColor" (lib/editor/client.js), so the icon
2215
+ is whatever colour .ed-handle-menu's own color property is, and follows a
2216
+ retheme for free. A hex here would break that silently. (No backticks in
2217
+ this comment: it lives inside a JS template literal.)
2218
+ flex: 0 0 auto because a 16px-wide flex item next to a nowrap label
2219
+ would otherwise be the thing that shrinks. */
2220
+ .ed-menu-icon { flex: 0 0 auto; width: 16px; height: 16px; }
2139
2221
  /* The 轉換成 submenu carries BOTH classes, so it inherits every rule above
2140
2222
  and only overrides the horizontal offset. It is a CHILD of the menu, and
2141
2223
  the menu is itself position:absolute, so the menu's own padding box is
@@ -2248,17 +2330,49 @@ ${itemsHtml}
2248
2330
  }
2249
2331
  .ed-te-drop-indicator[hidden] { display: none; }
2250
2332
  .ed-te-row-dragging { opacity: 0.4; }
2333
+ /* S4 Task 2 (spec §4.5): the block drag's own singleton drop indicator,
2334
+ shown while a block is being dragged by its ⠿. Same 3px blue rule as the
2335
+ table's, on a SEPARATE node rather than sharing '.ed-te-drop-indicator':
2336
+ that one is shared by the row and column axes and each of them has to
2337
+ rewrite the OTHER axis's dimension on every move or a stale value leaks
2338
+ in — a third writer multiplies that coupling. It is still ONE node for the
2339
+ whole document (the Global Constraint is "never one per boundary"), and
2340
+ the separate class is what lets a test assert that a block drag raised
2341
+ the BLOCK line rather than table chrome that happened to be on screen. */
2342
+ .ed-block-drop-indicator {
2343
+ position: fixed; z-index: 8; height: 3px; border-radius: 2px;
2344
+ background: #3b82f6; pointer-events: none;
2345
+ }
2346
+ .ed-block-drop-indicator[hidden] { display: none; }
2347
+ /* S4 Task 2b (user request, 2026-09-01): the drag's feedback on the SOURCE
2348
+ side. lib/editor/client.js puts '.ed-block-dragging' on <html> the moment
2349
+ a ⠿ drag engages and takes it off in teardownBlockDrag(), which every one
2350
+ of the five abort paths funnels through. On <html> rather than on the
2351
+ dragged block because the plan forbids mutating anything inside
2352
+ '.content' during a drag — that invariant is the only reason Escape /
2353
+ pointercancel / window blur can all clean up with nothing to revert.
2354
+ The universal selector plus !important is what the rule needs to be worth
2355
+ having: '.ed-handle' (the button the gesture starts on), '.ed-handle-menu-btn'
2356
+ and every table grip carry their own 'cursor: pointer', and a plain
2357
+ inherited cursor loses to all of them. */
2358
+ html.ed-block-dragging, html.ed-block-dragging * { cursor: grabbing !important; }
2251
2359
  /* Notion-style grip handles: replace the original invisible TE_EDGE_PX=8
2252
2360
  proximity zone (user-acceptance feedback: unusably small, no visible
2253
2361
  affordance) with two real, adequately-sized (>=18x24px) click/drag
2254
2362
  targets. '.ed-te-grip-row' is a vertical 6-dot handle shown at the LEFT
2255
2363
  EDGE of the hovered row -- every row, the HEADER included (spec 3.10:
2256
- the header is draggable too), STRADDLING the table's left border (its
2257
- centreline ON that border), which is the same rule '.ed-te-grip-col'
2258
- uses on the table's TOP border -- one geometry for both axes, no
2259
- per-row-type special case. The block's own gutter is kept clear of it by
2260
- the edit-mode-only layout chunk emitted near the end of this stylesheet,
2261
- not by insetting the grip; '.ed-te-grip-col' is a horizontal 6-dot
2364
+ the header is draggable too), sitting ENTIRELY INSIDE the table with its
2365
+ LEFT edge on the table's left border (spec 4.2 hit-test conflict 2,
2366
+ landed in S4 T1). The two axes are therefore NOT mirror images any more:
2367
+ '.ed-te-grip-col' still straddles the table's TOP border, because nothing
2368
+ of the page's own chrome lives up there. The row axis cannot: MEASURED on
2369
+ v2.12.0 at 1400x900, the straddling grip occupied [tableLeft-10,
2370
+ tableLeft+10] while the block's own '.ed-handle' occupied [blockLeft-22,
2371
+ blockLeft-4] with tableLeft == blockLeft, so the grip (position: fixed,
2372
+ z-index 7, on document.body) won the hit test for the RIGHT 6px of every
2373
+ ⠿ whose Y band it reached. The first column's own padding is widened in
2374
+ the edit-mode-only layout chunk near the end of this stylesheet so the
2375
+ inset grip still never reaches cell TEXT; '.ed-te-grip-col' is a horizontal 6-dot
2262
2376
  handle shown just ABOVE the hovered column (every column). Dots are plain <span>s laid out via CSS grid with
2263
2377
  place-content: center, so the dot cluster stays compact/centered
2264
2378
  regardless of the button's own (larger, hit-target-sized) box — no
@@ -2277,8 +2391,15 @@ ${itemsHtml}
2277
2391
  .ed-te-grip:hover { background: rgba(0, 0, 0, 0.06); }
2278
2392
  .ed-te-grip-dot { width: 3px; height: 3px; border-radius: 50%; background: #9ca3af; }
2279
2393
  .ed-te-grip:hover .ed-te-grip-dot { background: #6b7280; }
2394
+ /* spec §4.2 hit-test conflict 2 (S4 T1): the row grip's width is a TOKEN
2395
+ because two other numbers are derived from it — lib/editor/client.js pins
2396
+ the grip's LEFT edge to the table's left border (so the whole grip is
2397
+ inside the first column and shares no pixel with the block gutter's ⠿),
2398
+ and the edit-mode first-column padding must be wide enough to host it
2399
+ without covering cell text. Written once here, derived twice below. */
2400
+ :root { --ed-te-grip-row-w: 20px; }
2280
2401
  .ed-te-grip-row {
2281
- width: 20px; height: 28px; cursor: grab; z-index: 7;
2402
+ width: var(--ed-te-grip-row-w); height: 28px; cursor: grab; z-index: 7;
2282
2403
  grid-template-columns: repeat(2, 3px); grid-template-rows: repeat(3, 3px);
2283
2404
  gap: 3px 4px;
2284
2405
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helping-ai-workflow/md2doc",
3
- "version": "2.11.1",
3
+ "version": "3.0.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",