@helping-ai-workflow/md2doc 2.10.1 → 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.
package/lib/md2doc.js CHANGED
@@ -175,23 +175,98 @@ try {
175
175
  process.exit(1);
176
176
  }
177
177
 
178
- // Render one list token into per-li ed-block HTML.
179
- // biRef is a shared box { v: <int> } that advances through blocks[] in the
180
- // same DFS order as blockmap.js pushListItemBlocks:
178
+ // S1: render one list token into a FLAT sequence of per-li ed-block HTML.
179
+ // There is NO <ul>/<ol> container and no <li> every list item, at every
180
+ // depth, is a top-level `<div class="ed-block" data-block-type="li">` sibling
181
+ // of every other block, and nesting is carried by `data-indent` alone.
182
+ //
183
+ // `biRef` is a shared box that advances through blocks[] in the same DFS order
184
+ // as blockmap.js pushListItemBlocks:
181
185
  // 1. Push the item's own block.
182
186
  // 2. Recurse into that item's nested child lists, left-to-right.
183
187
  // 3. Move on to the next sibling item.
184
- // RULING F-L: use marked.Parser.parseInline ONLY when the item is tight
185
- // (!item.loose) AND its own non-list tokens are exactly one 'text' token.
188
+ // The ONLY change from the pre-S1 nested renderer is WHERE the child HTML
189
+ // lands as a sibling in the same output array, never inside the parent's
190
+ // element.
191
+ //
192
+ // RULING F-L is unchanged: use marked.Parser.parseInline ONLY when the item is
193
+ // tight (!item.loose) AND its own non-list tokens are exactly one 'text' token.
186
194
  // In every other case emit marked.parser(ownTokens) so that loose items keep
187
195
  // their <p> wrapper — list-md.js detects loose items by the <p> and flags them
188
- // as unsupported per-li (spec §8). Stripping the <p> would cause silent
189
- // data-shape corruption on the first edit.
190
- function renderEditModeList(listToken, blocks, biRef) {
191
- const tag = listToken.ordered ? 'ol' : 'ul';
192
- const startAttr = (listToken.ordered && listToken.start && listToken.start !== 1)
193
- ? ` start="${listToken.start}"` : '';
194
- const items = listToken.items.map((item) => {
196
+ // unsupported per-li (spec §8). Stripping the <p> would be silent data-shape
197
+ // corruption on the first edit.
198
+ //
199
+ // The marker's TEXT is deliberately NOT written into the HTML: a `.ed-li-marker`
200
+ // span is emitted empty and CSS draws the bullet / ordinal / checkbox, so
201
+ // renumbering a run never has to redraw the DOM. Two hooks exist purely for
202
+ // that CSS (they have no consumer inside this function, and none in this
203
+ // task — see the S1 plan's controller note R1):
204
+ // * `style="--ed-indent:<indent>"` — the indent custom property.
205
+ // * `data-run-start="1"` on the FIRST block of every run — where an ordered
206
+ // list's CSS counter must reset.
207
+ // `data-run-start` uses spec §3.8's run rule, and it is the SAME rule
208
+ // lib/editor/list-md.js's serializeBlocks() applies when it restarts an
209
+ // ordinal: a run starts when the previous block is not an li, OR is shallower,
210
+ // OR is at the same depth with a different data-list-type. Deeper items never
211
+ // break the run they are nested under, which is why `types` (the last list
212
+ // type seen AT EACH DEPTH) is consulted rather than the immediately previous
213
+ // block — a nested item sitting between two same-depth items must not make a
214
+ // list-type change invisible. If this and serializeBlocks() ever disagree, the
215
+ // symptom is wrong ordinals or a wrong commit range.
216
+ //
217
+ // THIRD hook, and a spec gap this task had to close: `data-list-start="1"` on
218
+ // the first block of EVERY marked list token, at every depth. Spec §3.8's three
219
+ // operational rules cannot separate two ADJACENT lists of the same type —
220
+ // `marked.lexer('- a\n* c\n')` returns TWO list tokens (a bullet-char change
221
+ // starts a new list, blank line or not) whose blocks are all
222
+ // data-indent="0" data-list-type="ul", i.e. indistinguishable to rules (a),
223
+ // (b) and (c). Before flattening, the two <ul> roots carried that distinction;
224
+ // afterwards nothing did, and a run scan merged them — re-markering and
225
+ // re-flowing a list the user never touched. The renderer is the only place
226
+ // that still knows where marked's token boundaries were, so it stamps them,
227
+ // and the client's run scan treats the attribute as rule (d): a run never
228
+ // crosses a data-list-start AT ITS OWN DEPTH.
229
+ //
230
+ // "Every token", not "every TOP-LEVEL token": the same delimiter-change shape
231
+ // nests. `- a / (2sp)1. x / (2sp)1) y / - d` is an outer ul plus TWO nested ol
232
+ // tokens, and stamping only the outer one renumbered `1) y` to `2. y`.
233
+ // listRunOf() spans the outermost run PLUS its subtrees, so a nested token
234
+ // boundary is always inside a span serializeBlocks() is handed — the earlier
235
+ // claim that a cross-list span could never reach it was wrong.
236
+ function liRunStartsHere(runState, b) {
237
+ const prev = runState.prevLi;
238
+ const isStart = !prev || b.indent > prev.indent || runState.types[b.indent] !== b.listType;
239
+ for (let k = runState.types.length - 1; k > b.indent; k--) runState.types[k] = undefined;
240
+ runState.types[b.indent] = b.listType;
241
+ runState.prevLi = b;
242
+ return isStart;
243
+ }
244
+
245
+ // A new list TOKEN closes every run open at its own depth or deeper, and
246
+ // leaves the shallower ones alone — the depths ABOVE a nested sublist are
247
+ // still inside the same enclosing list, so `- a / (2sp)1. x / - d` must not
248
+ // treat `- d` as opening a new top-level run.
249
+ function resetLiRunStateAtDepth(runState, indent) {
250
+ runState.types.length = Math.min(runState.types.length, indent);
251
+ }
252
+
253
+ // Rule (c): any non-li block terminates every open run, at every depth.
254
+ function resetLiRunState(runState) {
255
+ runState.prevLi = null;
256
+ runState.types = [];
257
+ }
258
+
259
+ function renderEditModeList(listToken, blocks, biRef, out) {
260
+ const parts = out || [];
261
+ // EVERY list token begins a new list, at whatever depth it sits: close the
262
+ // runs open at that depth and below (never the shallower ones — see
263
+ // resetLiRunStateAtDepth) so this token's first block is stamped, then stamp
264
+ // it. The token's depth is its first item's block indent; the render walk and
265
+ // blockmap.js consume blocks[] in lockstep, so blocks[biRef.v] is that block.
266
+ let listStart = true;
267
+ const firstBlock = blocks[biRef.v];
268
+ resetLiRunStateAtDepth(biRef, firstBlock ? firstBlock.indent : 0);
269
+ listToken.items.forEach((item) => {
195
270
  const b = blocks[biRef.v++];
196
271
  const ownTokens = item.tokens.filter((tk) => tk.type !== 'list');
197
272
  let inner;
@@ -201,22 +276,44 @@ function renderEditModeList(listToken, blocks, biRef) {
201
276
  inner = marked.Parser.parseInline(ownTokens[0].tokens);
202
277
  } else {
203
278
  // Loose item or non-standard own-token shape: use marked.parser, which
204
- // emits block-level HTML (including <p> for loose items). The <p>
279
+ // emits block-level HTML (including <p> for loose items). The <p>
205
280
  // presence is load-bearing for list-md.js's loose-item detection.
206
- inner = marked.parser(ownTokens);
281
+ // Trimmed because the surface is `white-space: pre-line` (see the CSS):
282
+ // marked pretty-prints a trailing "\n" after the closing tag, which is
283
+ // insignificant to every serializer (list-md.js drops it via isBlankText)
284
+ // but would render as a phantom blank line inside the item.
285
+ inner = marked.parser(ownTokens).trim();
207
286
  }
208
- const check = b.listType === 'task'
209
- ? `<span class="ed-li-check" data-checked="${b.checked ? 1 : 0}" role="checkbox" aria-checked="${b.checked}"></span>`
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 = '';
296
+ const check = b.task
297
+ ? `<span class="ed-li-check" data-checked="${b.checked ? 1 : 0}" role="checkbox" aria-checked="${!!b.checked}"></span>`
210
298
  : '';
299
+ const runStart = liRunStartsHere(biRef, b) ? ' data-run-start="1"' : '';
300
+ const listStartAttr = listStart ? ' data-list-start="1"' : '';
301
+ listStart = false;
302
+ parts.push(
303
+ `<div class="ed-block" data-block-id="${b.id}" data-block-type="li"` +
304
+ ` data-list-type="${b.listType}" data-task="${b.task ? 1 : 0}" data-indent="${b.indent}"` +
305
+ runStart + listStartAttr + ` style="--ed-indent:${b.indent}">` +
306
+ `<span class="ed-li-marker" aria-hidden="true"></span>` + check +
307
+ `<div class="ed-li-text">${inner}</div>` +
308
+ `</div>`
309
+ );
211
310
  // Recurse into nested child lists AFTER this item's block (matches
212
- // pushListItemBlocks order: own block nested lists next sibling).
213
- const children = item.tokens.filter((tk) => tk.type === 'list')
214
- .map((ct) => renderEditModeList(ct, blocks, biRef)).join('\n');
215
- return `<li class="ed-block" data-block-id="${b.id}" data-block-type="li"` +
216
- ` data-list-type="${b.listType}" data-indent="${b.indent}">` +
217
- check + `<div class="ed-li-text">${inner}</div>` + children + '</li>';
311
+ // pushListItemBlocks order: own block -> nested lists -> next sibling).
312
+ // The child HTML is pushed into the SAME array, as a SIBLING.
313
+ item.tokens.filter((tk) => tk.type === 'list')
314
+ .forEach((ct) => renderEditModeList(ct, blocks, biRef, parts));
218
315
  });
219
- return `<${tag}${startAttr}>\n${items.join('\n')}\n</${tag}>`;
316
+ return parts.join('\n');
220
317
  }
221
318
 
222
319
  async function renderMarkdown(mdText, srcPath, opts = {}) {
@@ -616,6 +713,29 @@ ${itemsHtml}
616
713
  return inlineImagesInHtmlChunk(baseHtml.apply(this, arguments));
617
714
  };
618
715
 
716
+ // Spec §3.12 — a markdown HARD BREAK and a literal '<br>' the source spelled
717
+ // out by hand both reach the DOM as an identical <br> element, and
718
+ // lib/editor/inline-md.js sees only the DOM. So editing ANY block holding a
719
+ // hard break degraded it to a literal '<br>' and cost the file a line
720
+ // (measured, list AND paragraph: '- a··' / ' b ~t' -> '- a<br>b \~t').
721
+ //
722
+ // THIS LAYER is the only place the two are still distinguishable: marked
723
+ // lexes a hard break as a `br` token, which arrives here, and a literal
724
+ // '<br>' as an inline `html` token, which goes to renderer.html above
725
+ // (verified: marked.lexer('x \ny')[0].tokens -> ['text','br','text'] while
726
+ // marked.lexer('x<br>y')[0].tokens -> ['text','html','text']). That
727
+ // separation holds because `breaks: false` is pinned in the setOptions()
728
+ // call below — under breaks:true every single newline would arrive here too
729
+ // and the marker would stop meaning "hard break".
730
+ //
731
+ // Edit mode only: reader and PDF output must stay byte-identical, and the
732
+ // attribute has no reader-side meaning. The Shift+Enter literal-'<br>'
733
+ // round-trip contract is untouched by construction — such a <br> never
734
+ // passes through this function.
735
+ if (opts.editMode) {
736
+ renderer.br = function() { return '<br data-hard-break="1">'; };
737
+ }
738
+
619
739
  renderer.code = function(token) {
620
740
  // token is either a string (old API) or {text, lang} object (new API)
621
741
  const lang = (typeof token === 'object') ? (token.lang || '') : (arguments[1] || '');
@@ -774,13 +894,17 @@ ${itemsHtml}
774
894
  // using the same renderer/options set above.
775
895
  const tokens = marked.lexer(mdPre);
776
896
  const parts = [];
777
- const biRef = { v: 0 };
897
+ // `v` is the block cursor; `prevLi`/`types` are the §3.8 run state the
898
+ // flat list renderer above threads through its own recursion (see
899
+ // liRunStartsHere()).
900
+ const biRef = { v: 0, prevLi: null, types: [] };
778
901
  for (const t of tokens) {
779
902
  if (t.type === 'space') continue;
780
903
  if (t.type === 'list') {
781
904
  parts.push(renderEditModeList(t, blocks, biRef));
782
905
  continue;
783
906
  }
907
+ resetLiRunState(biRef); // §3.8 rule (c): a non-li block ends every run
784
908
  const b = blocks[biRef.v++];
785
909
  const inner = marked.parser([t]);
786
910
  parts.push(
@@ -788,6 +912,14 @@ ${itemsHtml}
788
912
  inner + '</div>'
789
913
  );
790
914
  }
915
+ // Development-time alignment guard: the render walk and blockmap.js's
916
+ // pushListItemBlocks must consume blocks[] in lockstep. A mismatch means
917
+ // the two DFS orders have drifted apart, which silently mis-attributes
918
+ // every subsequent block id — far better to fail loudly here.
919
+ if (biRef.v !== blocks.length) {
920
+ throw new Error('edit-mode render consumed ' + biRef.v +
921
+ ' blocks but the block map has ' + blocks.length);
922
+ }
791
923
  bodyHtml = parts.join('\n');
792
924
  } else {
793
925
  bodyHtml = marked.parse(mdPre);
@@ -939,6 +1071,220 @@ ${itemsHtml}
939
1071
  });
940
1072
  </script>`;
941
1073
 
1074
+ // Edit-mode-only layout: give the block gutter (⠿ / +) its own room.
1075
+ //
1076
+ // The stylesheet above is SHARED by reader HTML, PDF export and edit mode,
1077
+ // so a bare `.content { padding-left }` there would shift every rendered
1078
+ // document — not wanted. This chunk is emitted ONLY when opts.editMode is
1079
+ // set, and it is a ruleset of its OWN (never merged into the existing
1080
+ // `.content { min-width: 0; flex: 1 1 auto; }` block, which
1081
+ // test/md2doc.test.js matches verbatim).
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.
1095
+ // (The padding was 48px until §4.2's hit-test conflict 1 was fixed; see the
1096
+ // .content rule in editModeLayoutCss below for why it is 56px now.)
1097
+ // S1 Task 5: the ordered-list ordinal is drawn by a CSS counter, and the
1098
+ // flat DOM took away the one thing a real <ol> gave for free — a counter
1099
+ // SCOPE per nesting level. Every li is a sibling of every other li now, so a
1100
+ // single shared counter has no way to leave the outer run's value alone
1101
+ // while a nested run counts: `1. / (nested) 1. / 2. / 2.` would render its
1102
+ // last item as "3.", because the nested run's reset landed in the same
1103
+ // scope. One counter PER DEPTH fixes it — the nested run resets ed-ol-1 and
1104
+ // never touches ed-ol-0 — and `data-indent` is the right key because it is
1105
+ // the attribute client.js keeps in step with the model (setBlockIndent()).
1106
+ //
1107
+ // Reset happens on `data-run-start`, which the renderer stamps per spec
1108
+ // §3.8 and client.js re-derives after a structural key (refreshRunStarts()).
1109
+ // `counter-reset: n 0` then `counter-increment: n` on the same element is
1110
+ // resolved reset-then-increment, so a run's first item is 1.
1111
+ //
1112
+ // Depths beyond MAX get no counter of their own and no fallback rule — there
1113
+ // is no depth-agnostic ordered rule below this loop — so their ::before
1114
+ // computes to `none` and the marker renders BLANK (measured at indent 10 and
1115
+ // 11). A missing ordinal beats a wrong one, and real markdown does not nest
1116
+ // that far; raise ED_OL_MAX_DEPTH if it ever does.
1117
+ const ED_OL_MAX_DEPTH = 9;
1118
+ let edOlCounterCss = '';
1119
+ for (let d = 0; d <= ED_OL_MAX_DEPTH; d++) {
1120
+ const sel = '.ed-block[data-block-type="li"][data-indent="' + d + '"]';
1121
+ edOlCounterCss +=
1122
+ '\n ' + sel + ' { counter-increment: ed-ol-' + d + '; }' +
1123
+ '\n ' + sel + '[data-run-start="1"] { counter-reset: ed-ol-' + d + ' 0;' +
1124
+ ' counter-increment: ed-ol-' + d + '; }' +
1125
+ '\n .ed-block[data-list-type="ol"][data-indent="' + d + '"] > .ed-li-marker::before' +
1126
+ ' { content: counter(ed-ol-' + d + ') "."; }';
1127
+ }
1128
+
1129
+ const editModeLayoutCss = `
1130
+ /* 56px, not 48px — spec §4.2's hit-test conflict 1 (gutter vs
1131
+ .sidebar-splitter), which was the one of its three that never got a
1132
+ guard. .sidebar-splitter's right edge IS .content's border-box left edge
1133
+ (they are adjacent flex items), so the gutter pair at
1134
+ [contentLeft-54, contentLeft-18] hung 6px back over the splitter's own
1135
+ drag zone at 48px of padding. Measured at 1400x900: splitter [324, 356],
1136
+ .ed-insert [350, 368], elementFromPoint(354, +'s own y) === button
1137
+ .ed-insert, and a real pointer drag started at x=352 or x=354 left the
1138
+ sidebar at 300px instead of resizing it to 420 — while x=336 and x=348
1139
+ both worked. Worse, .ed-insert is only 20px tall and li rows carried none
1140
+ at all before S2, so the dead zone was striped both vertically and within
1141
+ a row: the same x resized the sidebar or silently opened an insert menu
1142
+ depending on which pixel row the user grabbed.
1143
+
1144
+ 8 more pixels of padding move the pair to [contentLeft-54,
1145
+ contentLeft-18] = [splitterRight+2, splitterRight+20], i.e. entirely
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
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
1154
+ test/editor-client-runtime.test.js. */
1155
+ .content { padding-left: 56px; }
1156
+
1157
+ /* S1: every block is one full-width row, whatever its type or depth. The
1158
+ indent is a property of the item's own MARKER, never of its box, so all
1159
+ the hover outlines match and — the load-bearing part — the absolutely
1160
+ positioned .ed-handle / .ed-insert keep one origin and therefore one
1161
+ vertical axis. Putting the indent on the .ed-block itself (padding or
1162
+ margin) drags that origin along with it and breaks exactly the promise
1163
+ this layout exists to make.
1164
+
1165
+ flex, not grid: the child count varies (marker, optional .ed-li-check,
1166
+ text), and a fixed grid track list would push a plain item's text into
1167
+ the column a task item's checkbox occupies. */
1168
+ .ed-block[data-block-type="li"] {
1169
+ display: flex; align-items: baseline; column-gap: 6px;
1170
+ }
1171
+ /* width, NOT min-width: the marker box is FIXED, so "10." and up overflow
1172
+ into the gutter instead of widening the column and pushing their own row's
1173
+ text 2-3px right of items 1-9. That is what a real <ol> does, and it is
1174
+ what keeps every item's text on one left edge.
1175
+
1176
+ "display: flex; justify-content: flex-end" is what makes that overflow go
1177
+ LEFT, and it is not interchangeable with "text-align: right". Once the
1178
+ content is wider than the box, text-align has nothing left to distribute:
1179
+ the line box is already full, so the glyphs grow from the box's LEFT edge
1180
+ rightward, across the 6px column-gap and onto the item's own text.
1181
+ Measured with the old rule at 15px on a 120-item list: the marker box is
1182
+ [72, 90] and .ed-li-text starts at 96, while marker.scrollWidth reports 21
1183
+ at item 10 and 29 at item 100 — i.e. ink out to x=101, five pixels INTO
1184
+ the text, which a screenshot reads back as "100item 100". Font-dependent
1185
+ in degree only (under DejaVu Sans it collides at two digits, a 10-item
1186
+ list), never in direction.
1187
+
1188
+ A flex container overflows toward its START side when justified to the
1189
+ end — the same "overflow goes the other way" property that safe/unsafe
1190
+ alignment exists to talk about — so the ink now grows leftward into the
1191
+ gutter, which is where the space is. The guard is
1192
+ "marker.scrollWidth === marker.clientWidth" (scrollWidth counts END-side
1193
+ overflow only, so left overflow is invisible to it by construction) in
1194
+ test/editor-client-runtime.test.js; asserting .ed-li-text's left edge does
1195
+ NOT catch this, because the box is fixed and that edge never moves. */
1196
+ .ed-block[data-block-type="li"] > .ed-li-marker {
1197
+ flex: 0 0 auto;
1198
+ margin-left: calc(var(--ed-indent, 0) * 1.6em);
1199
+ width: 1.2em;
1200
+ display: flex; justify-content: flex-end;
1201
+ color: #6b7280; -webkit-user-select: none; user-select: none;
1202
+ }
1203
+ /* min-width: 0 so an unbreakable token inside an item cannot widen the row
1204
+ past its siblings — that would break the equal-width guarantee above. */
1205
+ .ed-block[data-block-type="li"] > .ed-li-text { flex: 1 1 auto; min-width: 0; }
1206
+ /* column-gap already provides the marker/checkbox separation. */
1207
+ .ed-block[data-block-type="li"] > .ed-li-check { flex: 0 0 auto; margin-right: 0; }
1208
+ .ed-block[data-list-type="ul"] > .ed-li-marker::before { content: "\\2022"; }${edOlCounterCss}
1209
+ /* A BULLETED task item's checkbox IS its marker, so the • is suppressed and
1210
+ the marker box collapses (the indent lives on its margin and survives).
1211
+ An ORDERED task item keeps its number — that is what GFM renders — and
1212
+ the counter rules above outrank this one on specificity for ol, so
1213
+ scoping this to ul is what keeps the two cases apart. */
1214
+ .ed-block[data-list-type="ul"][data-task="1"] > .ed-li-marker { width: 0; }
1215
+ .ed-block[data-list-type="ul"][data-task="1"] > .ed-li-marker::before { content: none; }
1216
+
1217
+ /* D6: + immediately left of ⠿, on the same row, no gap — replacing the
1218
+ stacked layout the shared stylesheet still declares (see .ed-insert
1219
+ there, and why it had to stack before the gutter existed). The 48px of
1220
+ content padding above is what buys the room: the pair occupies
1221
+ [contentLeft-54, contentLeft-18], which with 56px of padding sits wholly
1222
+ inside the content column (2px clear of .sidebar-splitter's right edge —
1223
+ 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. */
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
+ }`;
1287
+
942
1288
  const html = `<!DOCTYPE html>
943
1289
  <html lang="en">
944
1290
  <head>
@@ -1704,8 +2050,24 @@ ${itemsHtml}
1704
2050
  output, same precedent as the lightbox selectors above. */
1705
2051
  .ed-block { position: relative; cursor: pointer; }
1706
2052
  .ed-block:hover { outline: 1px dashed #b0b0b0; }
1707
- .ed-li-text { display: block; min-height: 1em; }
1708
- li.ed-block { cursor: text; }
2053
+ /* white-space: pre-line is LOAD-BEARING, not styling do not relax it.
2054
+ A hard-wrapped ("lazy continuation") list item's own content contains a
2055
+ real newline, and its .ed-li-text is an editing host. Under the default
2056
+ white-space:normal Chromium NORMALISES that newline to a space on the
2057
+ FIRST keystroke (measured: "alpha\ncont" -> "alphaZ cont"), so the item's
2058
+ source wrapping is destroyed by any edit and cannot be round-tripped —
2059
+ which is what forced an earlier revision to refuse such items outright and
2060
+ make ~22% of real list items read-only. pre-line (not pre-wrap) is the
2061
+ narrowest fix: newlines become significant, while runs of spaces still
2062
+ collapse exactly as before, so nothing about a single-line item changes.
2063
+ (No backticks in this comment: it lives inside a JS template literal.) */
2064
+ .ed-li-text { display: block; min-height: 1em; white-space: pre-line; }
2065
+ /* S1: a list item is a flat <div class="ed-block" data-block-type="li">,
2066
+ never an <li> — the selector follows the DOM. The marker's own glyph /
2067
+ ordinal, the --ed-indent indent and the run-scoped ordered counter are
2068
+ Task 5's and live in editModeLayoutCss (edit-mode only), fed by the hooks
2069
+ renderEditModeList() emits here. */
2070
+ .ed-block[data-block-type="li"] { cursor: text; }
1709
2071
  .ed-li-check { display: inline-block; width: 14px; height: 14px; margin-right: 6px;
1710
2072
  border: 1px solid #8a8a8a; border-radius: 3px; vertical-align: middle; cursor: pointer; }
1711
2073
  .ed-li-check[data-checked="1"] { background: #3b82f6; border-color: #3b82f6; }
@@ -1749,23 +2111,38 @@ ${itemsHtml}
1749
2111
  .ed-block:hover .ed-handle,
1750
2112
  .ed-handle:focus { opacity: 1; }
1751
2113
  .ed-handle:hover { background: rgba(0, 0, 0, 0.08); }
1752
- /* The ⠿ handle's small menu: heading ± / MD 原始碼 / close. Dark
1753
- translucent pill, bordered icon buttons — same visual language as
1754
- .ed-seltb below. */
2114
+ /* The ⠿ handle's menu: 轉換成 / 建立副本 / 刪除 / MD 原始碼 (spec §3.7).
2115
+ Dark translucent panel, bordered rows — same visual language as
2116
+ .ed-seltb below.
2117
+
2118
+ S2: flex-direction is COLUMN, and that is behaviour rather than styling.
2119
+ The panel now carries word labels instead of the old ±/✕ glyphs, and a
2120
+ row of four CJK labels is wider than the content column on a narrow
2121
+ window — the last item scrolls out of reach. A stacked panel is also what
2122
+ lets 轉換成 grow a submenu beside its OWN row rather than below the whole
2123
+ bar. test/editor-client-runtime.test.js asserts the computed
2124
+ flex-direction and that every button shares one left edge. */
1755
2125
  .ed-handle-menu {
1756
2126
  position: absolute; top: -4px; left: -4px; z-index: 6;
1757
- display: flex; align-items: center; gap: 4px;
1758
- padding: 4px 6px; border-radius: 8px;
2127
+ display: flex; flex-direction: column; align-items: stretch; gap: 2px;
2128
+ padding: 4px; border-radius: 8px;
1759
2129
  background: rgba(16, 18, 21, 0.92); color: #e6edf3;
1760
2130
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
1761
2131
  }
1762
2132
  .ed-handle-menu-btn {
1763
- min-width: 28px; height: 26px; padding: 0 8px;
2133
+ min-width: 132px; height: 26px; padding: 0 10px;
1764
2134
  border: 1px solid rgba(255, 255, 255, 0.25); border-radius: 6px;
1765
2135
  background: rgba(255, 255, 255, 0.08); color: inherit;
1766
2136
  font: inherit; font-size: 12px; line-height: 1;
1767
- white-space: nowrap; cursor: pointer;
1768
- }
2137
+ text-align: left; white-space: nowrap; cursor: pointer;
2138
+ }
2139
+ /* The 轉換成 submenu carries BOTH classes, so it inherits every rule above
2140
+ and only overrides the horizontal offset. It is a CHILD of the menu, and
2141
+ the menu is itself position:absolute, so the menu's own padding box is
2142
+ this element's containing block and left:100% lands it flush against
2143
+ the menu's right edge. Source order matters: equal specificity with
2144
+ .ed-handle-menu's own left:-4px, so this rule must stay AFTER it. */
2145
+ .ed-handle-submenu { left: 100%; margin-left: 4px; }
1769
2146
  .ed-handle-menu-btn:hover { background: rgba(255, 255, 255, 0.18); }
1770
2147
  .ed-handle-menu-btn[hidden] { display: none; }
1771
2148
  /* §10-gap fix: block-level INSERT — the + button sits NEXT TO the ⠿
@@ -1778,7 +2155,17 @@ ${itemsHtml}
1778
2155
  layout — measured directly). A second 18px-wide button placed FURTHER
1779
2156
  left (e.g. left:-44px) would land at a NEGATIVE viewport x and be
1780
2157
  unreachable/unclickable outside the visible page. The brief's own
1781
- wording allows this ("left gutter, above or beside it"). */
2158
+ wording allows this ("left gutter, above or beside it").
2159
+
2160
+ S1 Task 5 (D6): edit mode now overrides BOTH buttons to sit side by side
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.
2164
+ That became possible only once .content gained 48px of edit-mode padding;
2165
+ the stacked geometry declared here is what any NON-edit render would use,
2166
+ and those never emit .ed-block at all, so it is inert there. Kept rather
2167
+ than deleted because it is the correct fallback if the gutter padding is
2168
+ ever conditioned differently. */
1782
2169
  .ed-insert {
1783
2170
  position: absolute; left: -22px; top: -22px; width: 18px; height: 20px;
1784
2171
  display: flex; align-items: center; justify-content: center;
@@ -1866,10 +2253,13 @@ ${itemsHtml}
1866
2253
  affordance) with two real, adequately-sized (>=18x24px) click/drag
1867
2254
  targets. '.ed-te-grip-row' is a vertical 6-dot handle shown at the LEFT
1868
2255
  EDGE of the hovered row -- every row, the HEADER included (spec 3.10:
1869
- the header is draggable too), and sitting just INSIDE the table's left
1870
- border rather than outside it, because the space outside belongs to the
1871
- block's own gutter; '.ed-te-grip-col' is a horizontal 6-dot handle shown
1872
- just ABOVE the hovered column (every column). Dots are plain <span>s laid out via CSS grid with
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
2262
+ handle shown just ABOVE the hovered column (every column). Dots are plain <span>s laid out via CSS grid with
1873
2263
  place-content: center, so the dot cluster stays compact/centered
1874
2264
  regardless of the button's own (larger, hit-target-sized) box — no
1875
2265
  images, no background gradients. '.ed-te-grip-dragging' is EITHER
@@ -1946,6 +2336,7 @@ ${itemsHtml}
1946
2336
  background: #fff; color: #b00020; border: none; border-radius: 4px;
1947
2337
  padding: 4px 12px; cursor: pointer; font-weight: bold;
1948
2338
  }
2339
+ ${opts.editMode ? editModeLayoutCss : ''}
1949
2340
  </style>
1950
2341
  ${usesMath ? buildKatexStyleTag() : ''}
1951
2342
  </head>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helping-ai-workflow/md2doc",
3
- "version": "2.10.1",
3
+ "version": "2.11.1",
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/render-api.test.js && node test/lineops.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/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",