@helping-ai-workflow/md2doc 3.14.1 → 3.16.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.
@@ -423,5 +423,24 @@
423
423
  return serializeInline(rootEl).unsupported.length === 0;
424
424
  }
425
425
 
426
- return { serializeInline, canWysiwyg, escapeText, escapeBlockStarts };
426
+ // v3.15.0 T5 (D15): what the URL field writes into an href. serializeAnchor
427
+ // puts the href into `[text](href)` unescaped, so a space or a parenthesis
428
+ // would end the destination early; those and angle brackets are
429
+ // percent-encoded. A bare host gets https://; anything that already names a
430
+ // scheme, an anchor or a path is kept.
431
+ function normalizeHref(raw) {
432
+ let h = String(raw == null ? '' : raw).trim();
433
+ if (h === '') return '';
434
+ if (!/^([a-z][a-z0-9+.-]*:|#|\/|\.\/|\.\.\/)/i.test(h)) h = 'https://' + h;
435
+ return h.replace(/ /g, '%20').replace(/\(/g, '%28').replace(/\)/g, '%29')
436
+ .replace(/</g, '%3C').replace(/>/g, '%3E');
437
+ }
438
+
439
+ // A clipboard text that is exactly one http(s) URL (the address bar adds a
440
+ // trailing newline, some pages surrounding spaces).
441
+ function isPastedUrl(text) {
442
+ return /^https?:\/\/\S+$/.test(String(text == null ? '' : text).trim());
443
+ }
444
+
445
+ return { serializeInline, canWysiwyg, escapeText, escapeBlockStarts, normalizeHref, isPastedUrl };
427
446
  });
@@ -96,6 +96,12 @@
96
96
  const span = { startLine: op.startLine, endLine: op.startLine + op.after.length - 1 };
97
97
  return { lines: replaceLines(lines, span.startLine, span.endLine, op.before).lines, op };
98
98
  };
99
+ // v3.15.0 T9 (D21): the op undo() would pop next, or null. Read, never
100
+ // popped: rollbackFailedRender() (lib/editor/client.js) undoes a failed
101
+ // commit only while that commit is still the newest op.
102
+ UndoStack.prototype.top = function () {
103
+ return this._done.length ? this._done[this._done.length - 1] : null;
104
+ };
99
105
  // v3.4.0 batch3 Task 7 fix 1 (F3) — read and write the REDO tail.
100
106
  //
101
107
  // `push()` clears `_undone`, and that is correct for an ordinary edit: once
@@ -494,15 +494,21 @@
494
494
  // gives the item a source line of its own (blockmap can then hand it a
495
495
  // well-formed range). Only the checkbox forces the same-line form.
496
496
  //
497
- // The prefix is FLUSHED as its own line — degrading to literal '[ ]', which
498
- // is what the source said anyway — whenever the next block is not deeper,
499
- // i.e. when the item has no child to attach to. Merging it onto a SIBLING
500
- // would invent nesting that the document never had.
497
+ // The prefix is FLUSHED as its own line — `- [ ] `, its checkbox kept by
498
+ // the one space after it (v3.15.0 T3, R44; see flushPending()) — whenever
499
+ // the next block is not deeper, i.e. when the item has no child to attach
500
+ // to. Merging it onto a SIBLING would invent nesting that the document
501
+ // never had.
501
502
  let pending = null; // { text, cols, indent, blockId, indentPrefix, marker }
502
503
 
504
+ // v3.15.0 T3 (R44): the flushed line keeps ONE space after its checkbox.
505
+ // `- [ ]` with nothing after it is a plain item whose text is "[ ]"
506
+ // (measured, marked) — the next commit of that item wrote `- \[ \]`, and
507
+ // typing into the empty to-do an Enter had made saved `- x\[ \]`. `- [ ] `
508
+ // is an empty to-do (the D5 skeleton), so that is what is written.
503
509
  function flushPending() {
504
510
  if (!pending) return;
505
- lines.push(pending.text.replace(/[ \t]+$/, ''));
511
+ lines.push(pending.text.replace(/[ \t]+$/, '') + ' ');
506
512
  lineMeta.push({ blockId: pending.blockId, indentPrefix: pending.indentPrefix,
507
513
  marker: pending.marker });
508
514
  pending = null;
@@ -4,7 +4,7 @@ const path = require('path');
4
4
  const http = require('http');
5
5
  const crypto = require('crypto');
6
6
  const drawioLib = require('../drawio.js');
7
- const { renderMarkdown } = require('../md2doc.js');
7
+ const { renderMarkdown, buildKatexStyleTag, diagramEnginePaths } = require('../md2doc.js');
8
8
 
9
9
  const LINEOPS_SRC = fs.readFileSync(path.join(__dirname, 'lineops.js'), 'utf8');
10
10
  const INLINE_MD_SRC = fs.readFileSync(path.join(__dirname, 'inline-md.js'), 'utf8');
@@ -25,6 +25,10 @@ const INDENT_CLAMP_SRC = fs.readFileSync(path.join(__dirname, 'indent-clamp.js')
25
25
  // indent-clamp.js above — it only has to land before client.js reads
26
26
  // window.md2docConvertMd.
27
27
  const CONVERT_MD_SRC = fs.readFileSync(path.join(__dirname, 'convert-md.js'), 'utf8');
28
+ // v3.15.0 T1: the "/" block menu's catalogue (its items, filter and
29
+ // skeletons). Pure, no dependency on any other editor module; it only has to
30
+ // land before client.js reads window.md2docBlockCatalog.
31
+ const BLOCK_CATALOG_SRC = fs.readFileSync(path.join(__dirname, 'block-catalog.js'), 'utf8');
28
32
  // v3.2.0: the pure string-diff module behind incremental DOM patching —
29
33
  // stripBlockId()/patchmap() compare oldParts[i] against newParts[j] with only
30
34
  // data-block-id normalized (see the global constraint on that). Same "no
@@ -190,6 +194,33 @@ function hostIsLoopback(hostHeader, port) {
190
194
  return false;
191
195
  }
192
196
 
197
+ // v3.15.0 T4 (D12, R9): the diagram engines an edit page loads on demand
198
+ // (client.js ensureDiagramEngines()) — the page inlines them only when the
199
+ // document used them at load. The paths are lib/md2doc.js's own resolved ones
200
+ // (B2 fix round 1, R61: one resolver, not two). Read per request (a few MB, a
201
+ // handful of requests per session at most).
202
+ function diagramLibBody(name) {
203
+ if (name === 'mermaid.js') {
204
+ const p = diagramEnginePaths.mermaid;
205
+ return p ? { type: 'application/javascript; charset=utf-8', body: fs.readFileSync(p, 'utf8') } : null;
206
+ }
207
+ if (name === 'wavedrom.js') {
208
+ // The skin first: the library reads the WaveSkin global it defines.
209
+ const skin = diagramEnginePaths.wavedromSkin;
210
+ const lib = diagramEnginePaths.wavedrom;
211
+ if (!skin || !lib) return null;
212
+ return { type: 'application/javascript; charset=utf-8',
213
+ body: fs.readFileSync(skin, 'utf8') + '\n' + fs.readFileSync(lib, 'utf8') };
214
+ }
215
+ if (name === 'katex.css') {
216
+ const tag = buildKatexStyleTag();
217
+ const open = tag.indexOf('>') + 1;
218
+ const close = tag.lastIndexOf('</style>');
219
+ return { type: 'text/css; charset=utf-8', body: tag.slice(open, close) };
220
+ }
221
+ return null;
222
+ }
223
+
193
224
  function send(res, status, obj) {
194
225
  const body = JSON.stringify(obj);
195
226
  res.writeHead(status, { 'content-type': 'application/json' });
@@ -736,6 +767,7 @@ async function createEditorServer({
736
767
  `<script>${HISTORY_SRC}</script>\n` +
737
768
  `<script>${INDENT_CLAMP_SRC}</script>\n` +
738
769
  `<script>${CONVERT_MD_SRC}</script>\n` +
770
+ `<script>${BLOCK_CATALOG_SRC}</script>\n` +
739
771
  `<script>${PATCHMAP_SRC}</script>\n` +
740
772
  `<script>${SELECTION_SRC}</script>\n` +
741
773
  `<script>${CELL_RANGE_SRC}</script>\n` +
@@ -776,6 +808,13 @@ async function createEditorServer({
776
808
  return res.end(out);
777
809
  }
778
810
 
811
+ if (req.method === 'GET' && url.pathname.indexOf('/api/lib/') === 0) {
812
+ const lib = diagramLibBody(url.pathname.slice('/api/lib/'.length));
813
+ if (!lib) return send(res, 404, { error: 'unknown library' });
814
+ res.writeHead(200, { 'content-type': lib.type, 'cache-control': 'no-store' });
815
+ return res.end(lib.body);
816
+ }
817
+
779
818
  if (req.method === 'POST' && url.pathname === '/api/render') {
780
819
  const { fileId, content } = await readJson(req);
781
820
  const file = absFiles[fileId];
@@ -37,7 +37,7 @@ const BUTTON_DEFS = [
37
37
  { id: 'italic', group: 'inline', label: 'I', icon: 'italic', title: '斜體', toggle: true },
38
38
  { id: 'strike', group: 'inline', label: 'S', icon: 'strikethrough', title: '刪除線', toggle: true },
39
39
  { id: 'inline-code', group: 'inline', label: '`', icon: 'code', title: '行內程式碼', toggle: true },
40
- { id: 'link', group: 'inline', label: '🔗', icon: 'link', title: '連結', toggle: true },
40
+ { id: 'link', group: 'inline', label: '🔗', icon: 'link', title: '連結 (Ctrl+K)', toggle: true },
41
41
 
42
42
  // indent
43
43
  { id: 'outdent', group: 'indent', label: '⇤', icon: 'indent-decrease', title: '減少縮排', toggle: false },
@@ -152,11 +152,13 @@ function deriveState(ctx) {
152
152
  // that case).
153
153
 
154
154
  // v3.14.0 T4 (D15): an EMPTY transient paragraph has no text to convert, so
155
- // the conversions that wrap existing text (quote, numbered list, check) and
156
155
  // the list-depth buttons stay off until it has some. ctx.transient is
157
156
  // 'empty' | 'text' | null; the client derives it from the DOM.
157
+ // v3.15.0 T2 (D11): quote, numbered list and check are on again — on an
158
+ // empty transient they insert their empty block there (the "/" menu's
159
+ // skeletons), so only indent / outdent have nothing to act on.
158
160
  if (c.transient === 'empty') {
159
- for (const id of ['quote', 'ordered-list', 'check', 'indent', 'outdent']) {
161
+ for (const id of ['indent', 'outdent']) {
160
162
  state[id].disabled = true;
161
163
  }
162
164
  }
package/lib/md2doc.js CHANGED
@@ -190,6 +190,9 @@ let marked, Renderer;
190
190
  let blockMathText = null;
191
191
  try {
192
192
  ({ marked, Renderer } = require('marked'));
193
+ // v3.15.0 B2 fix round 1 (R56): the empty to-do token rule, shared with the
194
+ // editor's block map — see lib/task-items.js.
195
+ require('./task-items.js');
193
196
 
194
197
  // Code-aware subscript / superscript as marked inline extensions.
195
198
  // The old raw-text pre-pass (mdPre.replace(/~([^~]+)~/...)) ran BEFORE marked
@@ -464,6 +467,7 @@ const READER_ICONS = {
464
467
  x: ICON_SVG_OPEN + '<path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>',
465
468
  copy: ICON_SVG_OPEN + '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>',
466
469
  check: ICON_SVG_OPEN + '<path d="M20 6 9 17l-5-5"/></svg>',
470
+ camera: ICON_SVG_OPEN + '<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/></svg>',
467
471
  };
468
472
 
469
473
  async function renderMarkdown(mdText, srcPath, opts = {}) {
@@ -1489,7 +1493,8 @@ ${itemsHtml}
1489
1493
  <div class="toc-header-actions">
1490
1494
  <button id="toc-expand-all" type="button" aria-label="Expand all" title="Expand all">${READER_ICONS.unfold}</button>
1491
1495
  <button id="toc-collapse-all" type="button" aria-label="Collapse all" title="Collapse all">${READER_ICONS.fold}</button>
1492
- <button id="toc-collapse-toggle" type="button" aria-label="Collapse table of contents" title="Collapse / expand sidebar">${READER_ICONS.panel}</button>
1496
+ ${opts.editMode ? '' : ` <button id="capture-mode-toggle" type="button" aria-label="Screenshot mode" title="Screenshot mode (Esc to exit)">${READER_ICONS.camera}</button>
1497
+ `} <button id="toc-collapse-toggle" type="button" aria-label="Collapse table of contents" title="Collapse / expand sidebar">${READER_ICONS.panel}</button>
1493
1498
  </div>
1494
1499
  <div class="toc-breadcrumb" data-toc-breadcrumb aria-label="Current location"></div>
1495
1500
  </div>
@@ -2408,7 +2413,8 @@ const html = `<!DOCTYPE html>
2408
2413
  TOC-list buttons (no list to act on) and shrink padding so the toggle
2409
2414
  fits inside the rail instead of being clipped by overflow:hidden. */
2410
2415
  body[data-toc-collapsed] #toc-expand-all,
2411
- body[data-toc-collapsed] #toc-collapse-all {
2416
+ body[data-toc-collapsed] #toc-collapse-all,
2417
+ body[data-toc-collapsed] #capture-mode-toggle {
2412
2418
  display: none;
2413
2419
  }
2414
2420
  body[data-toc-collapsed] .toc {
@@ -2878,6 +2884,42 @@ const html = `<!DOCTYPE html>
2878
2884
  min-width: 0;
2879
2885
  flex: 1 1 auto;
2880
2886
  }
2887
+ /* Screenshot mode (v3.16.0). A scroll-and-stitch full-page capture
2888
+ (FireShot and the like) glues one slice per screen, so whatever stays put
2889
+ while the page scrolls lands on every slice: hide all of it, and keep the
2890
+ column at the width it was read at (--md2doc-capture-w, measured on
2891
+ entry), centred, so its lines break as they did. */
2892
+ body[data-capture-mode] .reader-sidebar,
2893
+ body[data-capture-mode] .sidebar-splitter,
2894
+ body[data-capture-mode] .sidebar-scrim,
2895
+ body[data-capture-mode] .mobile-bar,
2896
+ body[data-capture-mode] .md2doc-theme-toggle {
2897
+ display: none;
2898
+ }
2899
+ body[data-capture-mode] .page-layout {
2900
+ display: block;
2901
+ }
2902
+ body[data-capture-mode] main.content {
2903
+ box-sizing: border-box;
2904
+ width: var(--md2doc-capture-w, auto);
2905
+ max-width: 100%;
2906
+ margin: 0 auto;
2907
+ }
2908
+ .capture-toast {
2909
+ position: fixed;
2910
+ top: 16px;
2911
+ left: 50%;
2912
+ transform: translateX(-50%);
2913
+ z-index: 300;
2914
+ padding: 8px 16px;
2915
+ border-radius: 999px;
2916
+ background: #24292e;
2917
+ color: #ffffff;
2918
+ font-size: 14px;
2919
+ line-height: 1.4;
2920
+ box-shadow: 0 4px 14px rgba(0,0,0,0.18);
2921
+ pointer-events: none;
2922
+ }
2881
2923
  .content p,
2882
2924
  .content li,
2883
2925
  .content blockquote,
@@ -3582,6 +3624,41 @@ ${usesDrawio ? ` /* v3.4.0 §4: a document with no drawio reference must not pa
3582
3624
  .ed-handle-submenu { left: 100%; margin-left: 4px; max-height: calc(100vh - 8px); overflow-y: auto; }
3583
3625
  .ed-handle-menu-btn:hover { background: var(--md-ed-hover); }
3584
3626
  .ed-handle-menu-btn[hidden] { display: none; }
3627
+ /* v3.15.0 T1: the "/" block menu (decision D9, layout M1). Fixed, placed
3628
+ 4px under the caret by the client runtime (above it when there is no
3629
+ room below) and closed on scroll. 280px wide, its list scrolls on its
3630
+ own under a 360px cap; group titles, 32px rows of a 16px icon, the label
3631
+ and a muted monospace hint on the right; a key-help footer. The row the
3632
+ keyboard cursor is on carries data-ed-slash-cursor. Same surface, ring
3633
+ and shadow roles as the other editor menus, so it follows the theme.
3634
+ (No backticks in this comment: it lives inside a JS template literal.) */
3635
+ .ed-slash-menu {
3636
+ position: fixed; z-index: 100; width: 280px; max-height: 360px;
3637
+ display: flex; flex-direction: column; box-sizing: border-box; overflow: hidden;
3638
+ border-radius: 8px; background: var(--md-ed-surface); color: var(--md-strong);
3639
+ box-shadow: 0 0 0 1px var(--md-ed-ring), 0 8px 24px var(--md-ed-shadow);
3640
+ font-size: 13px; line-height: 1.3;
3641
+ }
3642
+ .ed-slash-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 4px; }
3643
+ .ed-slash-group { padding: 8px 8px 4px; font-size: 11px; font-weight: 600; color: var(--md-muted); }
3644
+ .ed-slash-item {
3645
+ display: flex; align-items: center; gap: 8px; width: 100%; height: 32px;
3646
+ padding: 0 8px; box-sizing: border-box; margin: 0;
3647
+ border: 0; border-radius: 6px; background: transparent; color: inherit;
3648
+ font: inherit; text-align: left; cursor: pointer;
3649
+ }
3650
+ .ed-slash-item:hover, .ed-slash-item[data-ed-slash-cursor] { background: var(--md-ed-hover); }
3651
+ .ed-slash-item svg { flex: 0 0 auto; width: 16px; height: 16px; margin: 0; }
3652
+ .ed-slash-label { flex: 1 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
3653
+ .ed-slash-hint {
3654
+ flex: 0 0 auto; color: var(--md-muted); font-size: 12px;
3655
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
3656
+ }
3657
+ .ed-slash-empty { padding: 8px; color: var(--md-muted); }
3658
+ .ed-slash-foot {
3659
+ flex: 0 0 auto; padding: 6px 10px; font-size: 11px; color: var(--md-muted);
3660
+ border-top: 1px solid var(--md-ed-ring);
3661
+ }
3585
3662
  /* §10-gap fix: block-level INSERT — the + button sits NEXT TO the ⠿
3586
3663
  handle in the left gutter, same node/visibility-toggle shape as
3587
3664
  .ed-handle above (real per-block node, opacity-only reveal on hover).
@@ -3613,23 +3690,8 @@ ${usesDrawio ? ` /* v3.4.0 §4: a document with no drawio reference must not pa
3613
3690
  .ed-block:hover .ed-insert,
3614
3691
  .ed-insert:focus { opacity: 1; }
3615
3692
  .ed-insert:hover { background: var(--md-ed-hover); color: var(--md-strong); }
3616
- /* The + button's small menu: 段落/標題/清單/表格/程式碼 — same dark
3617
- translucent pill language as .ed-handle-menu above. */
3618
- .ed-insert-menu {
3619
- position: absolute; top: -4px; left: -4px; z-index: 6;
3620
- display: flex; align-items: center; gap: 4px;
3621
- padding: 4px 6px; border-radius: 8px;
3622
- background: var(--md-ed-surface); color: var(--md-strong);
3623
- box-shadow: 0 0 0 1px var(--md-ed-ring), 0 8px 24px var(--md-ed-shadow);
3624
- }
3625
- .ed-insert-menu-btn {
3626
- min-width: 28px; height: 26px; padding: 0 8px;
3627
- border: 1px solid transparent; border-radius: 6px;
3628
- background: transparent; color: inherit;
3629
- font: inherit; font-size: 12px; line-height: 1;
3630
- white-space: nowrap; cursor: pointer;
3631
- }
3632
- .ed-insert-menu-btn:hover { background: var(--md-ed-hover); }
3693
+ /* v3.15.0 T2: the + button opens the "/" block menu (.ed-slash-menu
3694
+ above) on a new empty paragraph; its own small pill menu is gone. */
3633
3695
  /* Task 5: hover-edge column/row insert bubbles — a SINGLETON pair of "+"
3634
3696
  buttons (client runtime repositions them via getBoundingClientRect(),
3635
3697
  never creates more than these two). Visual language deliberately
@@ -3648,6 +3710,32 @@ ${usesDrawio ? ` /* v3.4.0 §4: a document with no drawio reference must not pa
3648
3710
  }
3649
3711
  .ed-tb-insert:hover { background: var(--md-accent); color: var(--md-ed-surface); }
3650
3712
  .ed-tb-insert[hidden] { display: none; }
3713
+ /* v3.15.0 T8 (D20): the trailing add bars, a SINGLETON pair placed by the
3714
+ client runtime along the grid of the hovered table: .ed-tb-addrow under
3715
+ the last row (the grid's width; 22px tall, which the client lowers to
3716
+ the gap above the block under the table, R97), .ed-tb-addcol right of the
3717
+ last column (22px wide, the grid's height), each a centred plus on the
3718
+ hover tint. z-index 6 sits under the grips (7) and the bubbles (8), so a
3719
+ press where they overlap goes to those. Two rules, one per bar, because
3720
+ each is its own fixed overlay. */
3721
+ .ed-tb-addrow {
3722
+ position: fixed; z-index: 6;
3723
+ height: 22px; padding: 0; margin: 0; box-sizing: border-box;
3724
+ display: flex; align-items: center; justify-content: center;
3725
+ border: 0; border-radius: 4px;
3726
+ background: var(--md-ed-hover); color: var(--md-muted);
3727
+ font: inherit; font-size: 14px; line-height: 1; cursor: pointer;
3728
+ }
3729
+ .ed-tb-addcol {
3730
+ position: fixed; z-index: 6;
3731
+ width: 22px; padding: 0; margin: 0; box-sizing: border-box;
3732
+ display: flex; align-items: center; justify-content: center;
3733
+ border: 0; border-radius: 4px;
3734
+ background: var(--md-ed-hover); color: var(--md-muted);
3735
+ font: inherit; font-size: 14px; line-height: 1; cursor: pointer;
3736
+ }
3737
+ .ed-tb-addrow:hover, .ed-tb-addcol:hover { color: var(--md-accent); }
3738
+ .ed-tb-addrow[hidden], .ed-tb-addcol[hidden] { display: none; }
3651
3739
  /* Task 6: table edge-click menus (delete/align) + row drag-reorder.
3652
3740
  '.ed-te-hl' marks the currently-selected column's or row's CELLS
3653
3741
  (th+td) — never the <tr>, which paints nothing the user can see once a
@@ -3667,23 +3755,39 @@ ${usesDrawio ? ` /* v3.4.0 §4: a document with no drawio reference must not pa
3667
3755
  .ed-cell-range { background: var(--md-ed-range) !important; }
3668
3756
  .ed-cell-range-active { caret-color: transparent; }
3669
3757
  .ed-cell-range-active ::selection { background: transparent; }
3758
+ /* v3.15.0 T7 (D18/D19): the edge menu is a vertical list in the look of
3759
+ the block menu above (.ed-handle-menu): 30px rows of a 16px icon and a
3760
+ label, an alignment row of three icon buttons (aria-pressed marks the
3761
+ current one), a separator, and the delete row in the error colour. The
3762
+ row the keyboard cursor is on carries data-ed-te-cursor. Placed by the
3763
+ client runtime under the column grip or beside the row grip, clamped to
3764
+ the table and the viewport. */
3670
3765
  .ed-te-menu {
3671
3766
  position: fixed; z-index: 12;
3672
- display: flex; align-items: center; gap: 4px;
3673
- padding: 4px 6px; border-radius: 8px;
3767
+ display: flex; flex-direction: column; align-items: stretch; gap: 2px;
3768
+ padding: 4px; border-radius: 8px;
3674
3769
  background: var(--md-ed-surface); color: var(--md-strong);
3675
3770
  box-shadow: 0 0 0 1px var(--md-ed-ring), 0 8px 24px var(--md-ed-shadow);
3676
3771
  }
3677
3772
  .ed-te-menu[hidden] { display: none; }
3678
3773
  .ed-te-menu-btn {
3679
- min-width: 28px; height: 26px; padding: 0 8px;
3774
+ min-width: 132px; height: 30px; padding: 0 10px 0 8px;
3775
+ display: flex; align-items: center; gap: 8px;
3680
3776
  border: 1px solid transparent; border-radius: 6px;
3681
3777
  background: transparent; color: inherit;
3682
- font: inherit; font-size: 12px; line-height: 1;
3683
- white-space: nowrap; cursor: pointer;
3778
+ font: inherit; font-size: 13px; line-height: 1;
3779
+ text-align: left; white-space: nowrap; cursor: pointer;
3684
3780
  }
3781
+ .ed-te-menu-btn .ed-menu-icon { flex: 0 0 auto; width: 16px; height: 16px; margin: 0; }
3685
3782
  .ed-te-menu-btn:hover { background: var(--md-ed-hover); }
3686
3783
  .ed-te-menu-btn[hidden] { display: none; }
3784
+ .ed-te-menu-btn[data-ed-te-cursor] { background: var(--md-ed-hover); box-shadow: inset 0 0 0 1px var(--md-ed-ring); }
3785
+ .ed-te-menu-align { display: flex; gap: 2px; }
3786
+ .ed-te-menu-align[hidden] { display: none; }
3787
+ .ed-te-menu-align .ed-te-menu-btn { flex: 1 1 0; min-width: 0; padding: 0; justify-content: center; }
3788
+ .ed-te-menu-align .ed-te-menu-btn[aria-pressed="true"] { color: var(--md-accent); background: var(--md-ed-hover); }
3789
+ .ed-te-menu-sep { height: 1px; margin: 2px 4px; background: var(--md-border); }
3790
+ .ed-te-menu-delete { color: var(--md-ed-err); }
3687
3791
  .ed-te-drop-indicator {
3688
3792
  position: fixed; z-index: 8; height: 3px; border-radius: 2px;
3689
3793
  background: var(--md-accent); pointer-events: none;
@@ -3817,6 +3921,48 @@ ${usesDrawio ? ` /* v3.4.0 §4: a document with no drawio reference must not pa
3817
3921
  .ed-seltb-btn:hover:not([aria-disabled="true"]) { color: var(--md-strong); }
3818
3922
  .ed-seltb-sep { width: 1px; height: 18px; margin: 0 3px; background: var(--md-rule); align-self: center; }
3819
3923
  .ed-ico { display: block; flex: none; }
3924
+ /* v3.15.0 T5: the URL field. A separate fixed element laid over the
3925
+ selection toolbar (which is hidden while it is up), never a child of it.
3926
+ Same surface, ring and shadow roles as the toolbar so it follows the
3927
+ theme; the input uses the field role. z-index 16 keeps it over the
3928
+ toolbar (15) and under the banner (999). */
3929
+ .ed-linkbar {
3930
+ position: fixed; z-index: 16;
3931
+ display: flex; align-items: center; gap: 6px; box-sizing: border-box;
3932
+ width: 300px; max-width: calc(100vw - 8px); padding: 4px 4px 4px 8px; border-radius: 8px;
3933
+ background: var(--md-ed-surface); color: var(--md-strong);
3934
+ box-shadow: 0 0 0 1px var(--md-ed-ring), 0 8px 24px var(--md-ed-shadow);
3935
+ }
3936
+ .ed-linkbar-icon { flex: none; display: inline-grid; place-items: center; color: var(--md-muted); }
3937
+ .ed-linkbar-input {
3938
+ flex: 1 1 auto; min-width: 0; height: 28px; box-sizing: border-box; padding: 0 8px;
3939
+ border: 1px solid var(--md-ed-ring); border-radius: 6px;
3940
+ background: var(--md-ed-field); color: inherit; font: inherit; font-size: 13px;
3941
+ }
3942
+ .ed-linkbar-input:focus { outline: none; border-color: var(--md-muted); }
3943
+ .ed-linkbar-apply {
3944
+ flex: none; height: 28px; padding: 0 10px; border: 0; border-radius: 6px;
3945
+ background: transparent; color: inherit; font: inherit; font-size: 13px; cursor: pointer;
3946
+ }
3947
+ .ed-linkbar-apply:hover { background: var(--md-ed-hover); }
3948
+ /* v3.15.0 T6: the link card, 6px under a link that holds the caret. Same
3949
+ surface, ring and shadow roles; the href is cut by an ellipsis. */
3950
+ .ed-linkcard {
3951
+ position: fixed; z-index: 15;
3952
+ display: flex; align-items: center; gap: 2px; box-sizing: border-box;
3953
+ max-width: calc(100vw - 8px); padding: 4px 4px 4px 10px; border-radius: 8px;
3954
+ background: var(--md-ed-surface); color: var(--md-strong); font-size: 13px;
3955
+ box-shadow: 0 0 0 1px var(--md-ed-ring), 0 8px 24px var(--md-ed-shadow);
3956
+ }
3957
+ .ed-linkcard-href {
3958
+ flex: 0 1 auto; min-width: 0; max-width: 320px; margin-right: 6px;
3959
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--md-muted);
3960
+ }
3961
+ .ed-linkcard-btn {
3962
+ flex: none; height: 26px; padding: 0 8px; border: 0; border-radius: 6px;
3963
+ background: transparent; color: inherit; font: inherit; font-size: 13px; cursor: pointer;
3964
+ }
3965
+ .ed-linkcard-btn:hover { background: var(--md-ed-hover); }
3820
3966
  /* v3.1.0 §4: the always-visible editing toolbar. Its numeric language is
3821
3967
  .lightbox-bar's (8px/12px padding, 6px gap, 13px type) over .ed-seltb's
3822
3968
  dark pill, so the two floating surfaces read as one family.
@@ -4011,7 +4157,7 @@ ${usesDrawio ? ` /* v3.4.0 §4: a document with no drawio reference must not pa
4011
4157
  .ed-toolbar-status[data-state="failed"] { color: var(--md-ed-err); }
4012
4158
  .ed-status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--md-ed-dirty); display: inline-block; }
4013
4159
  .ed-toolbar-btn[data-ed-tb="save"][aria-pressed="true"] { color: var(--md-accent); background: var(--md-active-bg); }
4014
- /* The H▾ dropdown: same singleton-panel idiom as .ed-insert-menu, but
4160
+ /* The H▾ dropdown: same singleton-panel idiom as the ⠿ menu, but
4015
4161
  anchored to a fixed bar rather than moved into a block, so it is
4016
4162
  positioned in viewport coordinates by the client runtime.
4017
4163
  max-height/overflow-y: same backstop role as .ed-handle-submenu above
@@ -4602,17 +4748,31 @@ ${mermaidInitTag}`)
4602
4748
  // siblings are wrappers: the section ends at the next wrapper that is, or
4603
4749
  // directly holds, a heading of the same or a shallower depth. In the reader
4604
4750
  // the unit is the heading itself and the walk is the one it always was.
4751
+ // v3.15.0 R76: except for a heading nested in another block (a blockquote's
4752
+ // heading in the reader): the section index lists the text after that
4753
+ // block under the heading, so the unit is the top-level block that holds
4754
+ // it, and the walk (whose container is that block's parent) reaches the
4755
+ // text after the quote. Headings are still found only as top-level
4756
+ // siblings in the reader, so a quoted heading still ends no section.
4757
+ function topLevelBlockOf(node) {
4758
+ const root = node.closest('.content');
4759
+ if (!root) return node;
4760
+ let n = node;
4761
+ while (n.parentElement && n.parentElement !== root) n = n.parentElement;
4762
+ return n.parentElement === root ? n : node;
4763
+ }
4605
4764
  function sectionBoundary(sectionId) {
4606
4765
  const start = document.getElementById(sectionId);
4607
4766
  if (!start) return null;
4608
4767
  const startDepth = Number(start.getAttribute('data-reader-depth') || '1');
4609
- const unit = start.closest('.ed-block') || start;
4768
+ const wrapper = start.closest('.ed-block');
4769
+ const unit = wrapper || topLevelBlockOf(start);
4610
4770
  let end = null;
4611
4771
  let node = unit.nextElementSibling;
4612
4772
  while (node) {
4613
4773
  let heading = null;
4614
4774
  if (node.matches && node.matches('[data-reader-heading]')) heading = node;
4615
- else if (unit !== start && node.querySelector) heading = node.querySelector(':scope > [data-reader-heading]');
4775
+ else if (wrapper && node.querySelector) heading = node.querySelector(':scope > [data-reader-heading]');
4616
4776
  if (heading) {
4617
4777
  const depth = Number(heading.getAttribute('data-reader-depth') || '1');
4618
4778
  if (depth <= startDepth) {
@@ -4936,6 +5096,45 @@ ${mermaidInitTag}`)
4936
5096
  setSidebarOpen(false);
4937
5097
  }
4938
5098
  });
5099
+
5100
+ // ── Screenshot mode (v3.16.0): only the document, for a full-page capture ──
5101
+ // The CSS beside '.capture-toast' hides everything that stays put while the
5102
+ // page scrolls. The column keeps the width measured here, so nothing reflows
5103
+ // and the reading position holds. Esc leaves; a reload never comes back in it.
5104
+ const captureBtn = document.getElementById('capture-mode-toggle');
5105
+ let captureToastTimer = 0;
5106
+ function setCaptureMode(on) {
5107
+ clearTimeout(captureToastTimer);
5108
+ const oldToast = document.querySelector('.capture-toast');
5109
+ if (oldToast) oldToast.remove();
5110
+ if (!on) {
5111
+ document.body.removeAttribute('data-capture-mode');
5112
+ document.body.style.removeProperty('--md2doc-capture-w');
5113
+ return;
5114
+ }
5115
+ setSidebarOpen(false);
5116
+ const w = document.querySelector('main.content').getBoundingClientRect().width;
5117
+ document.body.style.setProperty('--md2doc-capture-w', w + 'px');
5118
+ document.body.setAttribute('data-capture-mode', '');
5119
+ const toast = document.createElement('div');
5120
+ toast.className = 'capture-toast';
5121
+ toast.setAttribute('role', 'status');
5122
+ toast.textContent = 'Screenshot mode — press Esc or reload to exit';
5123
+ document.body.appendChild(toast);
5124
+ // Gone before the capture tool scrolls, or it would be on every slice too.
5125
+ captureToastTimer = setTimeout(() => toast.remove(), 2000);
5126
+ }
5127
+ if (captureBtn) {
5128
+ captureBtn.addEventListener('click', () => setCaptureMode(true));
5129
+ }
5130
+ // Registered ahead of the lightbox's own Esc handler, which closes the
5131
+ // diagram: while one is open, its Esc is the lightbox's and the mode stays.
5132
+ document.addEventListener('keydown', (event) => {
5133
+ if (lightboxIsOpen()) return;
5134
+ if (event.key === 'Escape' && document.body.hasAttribute('data-capture-mode')) {
5135
+ setCaptureMode(false);
5136
+ }
5137
+ });
4939
5138
  document.addEventListener('click', (event) => {
4940
5139
  if (!document.body.hasAttribute('data-sidebar-open')) return;
4941
5140
  if (!event.target || !event.target.closest) return;
@@ -6381,7 +6580,11 @@ ${usesDrawioMultiPage ? ` // v3.4.0 batch2 Task 6: wired through a NAMED, reusa
6381
6580
  drawioRefs: Array.from(drawioRefs, ([p, stamp]) => ({ path: p, stamp })),
6382
6581
  };
6383
6582
  }
6384
- module.exports = { renderMarkdown };
6583
+ // v3.15.0 B2 fix round 1 (R61): the resolved engine paths, so the editor
6584
+ // server's on-demand GET /api/lib/<name> serves exactly what this file inlines
6585
+ // instead of resolving them a second time.
6586
+ const diagramEnginePaths = { mermaid: localMermaidJs, wavedrom: localWaveDromJs, wavedromSkin: localWaveDromSkin };
6587
+ module.exports = { renderMarkdown, buildKatexStyleTag, diagramEnginePaths };
6385
6588
 
6386
6589
  // Index of the first "{" in dot source that opens the graph body: not inside a
6387
6590
  // double-quoted string, a // or /* */ comment, a line starting with # (dot
@@ -0,0 +1,48 @@
1
+ 'use strict';
2
+ // v3.15.0 B2 fix round 1 (R56): an EMPTY to-do item at the token level.
3
+ //
4
+ // marked reads `[ ]` / `[x]` as a checkbox only when a space follows it, and
5
+ // it trims an item's trailing whitespace first when the item is the last one
6
+ // of a NESTED list: `- a\n - [ ] \n` (end of file, or a paragraph next) lexes
7
+ // the child as a plain item whose text is "[ ]" (measured, marked 14.1.4).
8
+ // The editor writes exactly that shape for a new empty to-do (Enter at the end
9
+ // of a nested last to-do), and no byte form of an empty nested task survives
10
+ // marked without visible content. So the READING side decides: a list item
11
+ // whose whole text is `[ ]`, `[x]` or `[X]` and that marked did not already
12
+ // read as a task is an empty to-do, checked accordingly.
13
+ //
14
+ // One place for both readers: this module installs the rule on marked's own
15
+ // `list` tokenizer, so every marked.lexer() / marked.parse() in the process —
16
+ // the reader renderer (lib/md2doc.js) and the editor's block map
17
+ // (lib/editor/blockmap.js), which both require this file — sees the same
18
+ // token. An escaped box (`- \[ \]`, what the editor writes for that text in a
19
+ // plain item) is text and stays text. Other renderers (GitHub) still show a
20
+ // literal "[ ]" for such an item; that is what the file says.
21
+ const { marked } = require('marked');
22
+
23
+ const EMPTY_BOX = /^\[([ xX])\]$/;
24
+
25
+ function normalizeListToken(listToken) {
26
+ if (!listToken || !Array.isArray(listToken.items)) return listToken;
27
+ listToken.items.forEach((item) => {
28
+ if (!item || item.task) return;
29
+ const m = EMPTY_BOX.exec(String(item.text || '').trim());
30
+ if (!m) return;
31
+ item.task = true;
32
+ item.checked = m[1] !== ' ';
33
+ item.text = '';
34
+ item.tokens = [];
35
+ });
36
+ return listToken;
37
+ }
38
+
39
+ marked.use({
40
+ tokenizer: {
41
+ list(src) {
42
+ const token = marked.Tokenizer.prototype.list.call(this, src);
43
+ return token ? normalizeListToken(token) : token;
44
+ },
45
+ },
46
+ });
47
+
48
+ module.exports = { normalizeListToken };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helping-ai-workflow/md2doc",
3
- "version": "3.14.1",
3
+ "version": "3.16.0",
4
4
  "description": "Markdown → HTML / PDF renderer with WaveDrom, Mermaid, Graphviz, and draw.io support",
5
5
  "keywords": [
6
6
  "markdown",
@@ -46,7 +46,7 @@
46
46
  },
47
47
  "scripts": {
48
48
  "preinstall": "node scripts/preinstall.js",
49
- "test": "node test/md2doc.test.js && node test/highlight.test.js && node test/blockmap.test.js && node test/drawio.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/cell-range.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/table-anchor-recovery.test.js && node test/gate-compat.test.js && node test/history.test.js && node test/list-md.test.js && node test/table-width.test.js && node test/toolbar-model.test.js && node test/asset.test.js && node test/paste-md.test.js && node test/patchmap.test.js && node test/wave-codec.test.js && node test/wave-draw.test.js && node test/wave-geometry.test.js && node test/wave-store.test.js && node test/editor-journey.test.js && node test/detach-census.test.js && node test/editor-arrow-nav.test.js && node test/editor-cell-range.test.js && node test/reader-design.test.js && node test/theme.test.js && node test/release-check.test.js && node test/wave-overflow.test.js",
49
+ "test": "node test/md2doc.test.js && node test/highlight.test.js && node test/blockmap.test.js && node test/drawio.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/block-catalog.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/cell-range.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/table-anchor-recovery.test.js && node test/gate-compat.test.js && node test/history.test.js && node test/list-md.test.js && node test/table-width.test.js && node test/toolbar-model.test.js && node test/asset.test.js && node test/paste-md.test.js && node test/patchmap.test.js && node test/wave-codec.test.js && node test/wave-draw.test.js && node test/wave-geometry.test.js && node test/wave-store.test.js && node test/editor-journey.test.js && node test/detach-census.test.js && node test/editor-arrow-nav.test.js && node test/editor-cell-range.test.js && node test/reader-design.test.js && node test/theme.test.js && node test/release-check.test.js && node test/wave-overflow.test.js",
50
50
  "test:browser": "node test/reader-click.test.js && node test/editor-click.test.js && node test/wave-canvas.test.js"
51
51
  },
52
52
  "repository": {