@helping-ai-workflow/md2doc 3.17.3 → 3.17.4

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.
@@ -123,8 +123,20 @@
123
123
  // caller has the definitions appended. A missing flag costs a second copy,
124
124
  // a wrong one the definition, so the flag is only for text that is built
125
125
  // by slicing `lines` across every gap of the range.
126
- function keepDefinitionLines(state, startLine, endLine, after) {
127
- const groups = definitionLinesIn(state, startLine, endLine);
126
+ //
127
+ // v3.17.4 D5 (R8): a gesture that replays SOME of its gaps verbatim (a span
128
+ // move keeps the lines between two blocks that stay neighbours, and drops
129
+ // the others) cannot say carriesGap for the whole range. It names the source
130
+ // line numbers it copied instead, `replayedLines` (a Set), and a group whose
131
+ // every line is in it is skipped; every other group is appended as above.
132
+ // Still the gesture's own fact, per group instead of all-or-nothing: the
133
+ // text is never read, so a copy inside a fence is still code. Same costs —
134
+ // a line named that was not copied loses its definition — so a line goes in
135
+ // the set only where the caller slices it from `lines` (census row in
136
+ // test/editor-client.test.js).
137
+ function keepDefinitionLines(state, startLine, endLine, after, replayedLines) {
138
+ const groups = definitionLinesIn(state, startLine, endLine)
139
+ .filter((g) => !(replayedLines && g.at.every((n) => replayedLines.has(n))));
128
140
  if (!groups.length) return after;
129
141
  if (collapseBlankRuns(after).join('\n') === withoutDefinitionLines(state, startLine, endLine, groups).join('\n')) return null;
130
142
  let tail = after.length;
@@ -152,12 +164,15 @@
152
164
  // definition the user deleted or changed there is not kept as well.
153
165
  // Fix round 3 (R56): `opts.carriesGap` is structural text that already
154
166
  // holds every line between the range's blocks verbatim (keepDefinitionLines()).
167
+ // v3.17.4 D5 (R8): `opts.replayedLines` names the source lines it holds
168
+ // verbatim when only some gaps are replayed (keepDefinitionLines()).
155
169
  function commitRangeEdit(state, startLine, endLine, newText, opts) {
156
170
  const refusal = refuseInvertedRange(state, 'commitRangeEdit', startLine, endLine);
157
171
  if (refusal) return refusal;
158
172
  const before = state.lines.slice(startLine - 1, endLine);
159
173
  const typed = newText.split('\n');
160
- const after = opts && (opts.verbatim || opts.carriesGap) ? typed : keepDefinitionLines(state, startLine, endLine, typed);
174
+ const after = opts && (opts.verbatim || opts.carriesGap) ? typed
175
+ : keepDefinitionLines(state, startLine, endLine, typed, opts && opts.replayedLines);
161
176
  if (after === null || before.join('\n') === after.join('\n')) {
162
177
  return { lines: state.lines, blocks: state.blocks, op: null };
163
178
  }
@@ -1054,6 +1069,21 @@
1054
1069
  // ── DOM wiring (browser only) ─────────────────────────────────────────
1055
1070
  const ED = window.__ED__;
1056
1071
  const inlineMd = window.md2docInlineMd;
1072
+ // The alt field decodes every named character reference the way the reader's
1073
+ // browser does. The reader's alt is an ATTRIBUTE, where a legacy name followed
1074
+ // by more letters (&notit;, &copyx;) stays literal; a textarea (text context)
1075
+ // would give the prefix's character (¬it;), so the probe parses an img alt
1076
+ // inside an inert template. Not a div: Chromium's innerHTML fast path decodes
1077
+ // an attribute there as text (measured, final review I5).
1078
+ {
1079
+ const refProbe = document.createElement('template');
1080
+ inlineMd.setNamedReferenceDecoder((name) => {
1081
+ refProbe.innerHTML = '<img alt="&' + name + ';">';
1082
+ const img = refProbe.content.firstChild;
1083
+ const v = img && img.getAttribute('alt');
1084
+ return typeof v !== 'string' || v === '&' + name + ';' ? undefined : v;
1085
+ });
1086
+ }
1057
1087
  const tableMd = window.md2docTableMd;
1058
1088
  const listMd = window.md2docListMd;
1059
1089
  const indentClamp = window.md2docIndentClamp;
@@ -1576,7 +1606,10 @@
1576
1606
  // beforeunload handler, and the toolbar's state is declared far below.
1577
1607
  function imgTbHasPendingAlt() {
1578
1608
  try {
1579
- return !!imgTbAlt && !imgTbInput.hidden && imgTbInput.value !== imgTbAlt.original;
1609
+ if (imgTbAlt) return !imgTbInput.hidden && imgTbInput.value !== imgTbAlt.original;
1610
+ // R12: a kept draft is unsaved work while its image is still in the
1611
+ // document (a draft whose source is gone stops counting).
1612
+ return !!imgTbAltDraft && blocks.some((b) => blockSourceOf(b) === imgTbAltDraft.source);
1580
1613
  } catch (e) { return false; }
1581
1614
  }
1582
1615
  // Guarded the same way, for the same reason (the beforeunload handler).
@@ -11181,6 +11214,31 @@
11181
11214
  }
11182
11215
  }
11183
11216
 
11217
+ // v3.17.4 D3 (R7): a block converted to text, a list kind or a quote is
11218
+ // written over its own lines only, so a non-blank line directly above or
11219
+ // below it (a link reference definition, a paragraph) would be read as a lazy
11220
+ // line of it. These two answer, for the converted lines firstLine..lastLine
11221
+ // (1-indexed inclusive), whether a blank line has to be written before and
11222
+ // after them. Heading and code targets are fenced off by their own syntax and
11223
+ // are left alone. `skip` names a side the caller handles itself (a list item
11224
+ // that joins the list there must NOT be separated from it).
11225
+ function convertNeedsBlank(target) {
11226
+ return !(/^h[1-6]$/.test(target) || target === 'code');
11227
+ }
11228
+ function convertLeadBlank(firstLine, target, skip) {
11229
+ if (!convertNeedsBlank(target) || (skip && skip.lead)) return false;
11230
+ const prev = lines[firstLine - 2];
11231
+ return prev !== undefined && String(prev).trim() !== '';
11232
+ }
11233
+ function separatedConvertText(md, firstLine, lastLine, target, skip) {
11234
+ if (!convertNeedsBlank(target)) return md;
11235
+ let out = md;
11236
+ if (convertLeadBlank(firstLine, target, skip)) out = '\n' + out;
11237
+ const next = lines[lastLine];
11238
+ if (!(skip && skip.trail) && next !== undefined && String(next).trim() !== '') out = out + '\n';
11239
+ return out;
11240
+ }
11241
+
11184
11242
  async function convertBlockViaMenuBody(blockEl, target, anchor) {
11185
11243
  if (!blockEl || !target) return;
11186
11244
  // §3.3's membership rules and the whole shared preamble — see
@@ -11270,13 +11328,17 @@
11270
11328
  pieces.push(convertMd.emitAs(stripped.content, target, {}).join('\n'));
11271
11329
  }
11272
11330
  const md = pieces.join('\n\n');
11331
+ // Nothing changed (converting an H2 to 標題 2, a paragraph to 文字, a quote
11332
+ // to 引用): nothing is written and nothing pushed onto the undo stack, so
11333
+ // there is nothing to render or roll back and no selection range to
11334
+ // declare. Asked before the D3 separation, which would otherwise turn an
11335
+ // own-type convert next to a tight neighbour into a blank-line edit.
11336
+ if (md === lines.slice(first.startLine - 1, last.endLine).join('\n')) return;
11337
+ const lead = convertLeadBlank(first.startLine, target) ? 1 : 0;
11273
11338
 
11274
11339
  const result = commitRangeEdit({ lines, blocks, stack },
11275
- first.startLine, last.endLine, md);
11276
- // Nothing changed (converting an H2 to 標題 2) — and nothing was pushed
11277
- // onto the undo stack either, so there is nothing to render or roll back,
11278
- // and no selection range to declare.
11279
- if (result.op === null) return;
11340
+ first.startLine, last.endLine, separatedConvertText(md, first.startLine, last.endLine, target));
11341
+ if (result.op === null) return; // same answer from the commit itself: no step pushed
11280
11342
  const prevLines = lines;
11281
11343
  lines = result.lines;
11282
11344
  // §3.3: 「操作後集合塌縮為操作結果所涵蓋的行區間」. Declared IMMEDIATELY
@@ -11284,8 +11346,8 @@
11284
11346
  // the declaration at its very top, so one left standing lands on some
11285
11347
  // later, unrelated render.
11286
11348
  declareCollapse({
11287
- startLine: first.startLine,
11288
- endLine: first.startLine + md.split('\n').length - 1,
11349
+ startLine: first.startLine + lead,
11350
+ endLine: first.startLine + lead + md.split('\n').length - 1,
11289
11351
  });
11290
11352
  const editRange = result.op
11291
11353
  ? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
@@ -11300,7 +11362,7 @@
11300
11362
  // pre-commit startLine — it is the very line the collapse above declares,
11301
11363
  // i.e. the operated span's post-commit first line. The plain path is the
11302
11364
  // one path that does not move it (the commit replaces the span in place).
11303
- return first.startLine;
11365
+ return first.startLine + lead;
11304
11366
  }
11305
11367
 
11306
11368
  // Is the editor session switchAwayFrom() would resolve open on THIS block?
@@ -12625,8 +12687,12 @@
12625
12687
  }
12626
12688
  }
12627
12689
 
12690
+ // v3.17.4 D3: a side the items join is not separated; every other side
12691
+ // keeps a blank line to the definition / text next to it.
12692
+ const skip = { lead: !!(merged.joined && merged.joined.above), trail: !!(merged.joined && merged.joined.below) };
12693
+ const lead = convertLeadBlank(merged.startLine, target, skip) ? 1 : 0;
12628
12694
  const result = commitRangeEdit({ lines, blocks, stack },
12629
- merged.startLine, endLine, md);
12695
+ merged.startLine, endLine, separatedConvertText(md, merged.startLine, endLine, target, skip));
12630
12696
  if (result.op === null) return;
12631
12697
  const prevLines = lines;
12632
12698
  lines = result.lines;
@@ -12634,8 +12700,8 @@
12634
12700
  // (possibly widened) commit range; a list below renumbered in the same
12635
12701
  // commit is not part of the result.
12636
12702
  declareCollapse({
12637
- startLine: merged.startLine,
12638
- endLine: merged.startLine + convertedMd.split('\n').length - 1,
12703
+ startLine: merged.startLine + lead,
12704
+ endLine: merged.startLine + lead + convertedMd.split('\n').length - 1,
12639
12705
  });
12640
12706
  const editRange = result.op
12641
12707
  ? { startLine: Math.min(result.op.startLine, result.op.endLine + 1),
@@ -12648,7 +12714,7 @@
12648
12714
  // v3.2.1 §Task 5: the caller's `focusLine` — the WIDENED range's first
12649
12715
  // line, which is where the merged list actually starts. `recs[0].startLine`
12650
12716
  // would be wrong whenever §4.3 rule 2 ate a separator above the span.
12651
- return merged.startLine;
12717
+ return merged.startLine + lead;
12652
12718
  }
12653
12719
 
12654
12720
  // The `.ed-li-check` chrome for a li that has just BECOME a task item.
@@ -15701,9 +15767,19 @@
15701
15767
  // old refusal said could not be done (two commits, two undo ops). Lists
15702
15768
  // that are adjacent blocks (a marker change between them, no gap) move as
15703
15769
  // one group, so an item of the second can land under an item of the first.
15770
+ // v3.17.4 D7 (R9): adjacent in the SOURCE too — only blank lines between
15771
+ // them. A definition is no block, so two lists around one are DOM
15772
+ // neighbours; as one group the second list's first item (no headroom)
15773
+ // zeroed the whole batch's delta. They are two groups, as with a paragraph
15774
+ // between, and the definition is a line between segments, copied as it is.
15704
15775
  async function tabSpanAcrossLists(els, recs, dir) {
15705
15776
  const isLi = (el) => el.getAttribute('data-block-type') === 'li';
15706
15777
  const all = allBlockEls();
15778
+ const linesTouch = (x, y) => {
15779
+ const a = blockRecOf(x);
15780
+ const b = blockRecOf(y);
15781
+ return !!a && !!b && lines.slice(a.endLine, b.startLine - 1).every((l) => l.trim() === '');
15782
+ };
15707
15783
  const groups = [];
15708
15784
  for (let i = 0; i < els.length; i++) {
15709
15785
  if (!isLi(els[i])) continue;
@@ -15712,7 +15788,8 @@
15712
15788
  const run = listRunOf(els[i]);
15713
15789
  if (!run.length) { refuseStructuralListEdit(); return; }
15714
15790
  const prev = groups[groups.length - 1];
15715
- if (prev && all.indexOf(run[0]) === all.indexOf(prev.run[prev.run.length - 1]) + 1) {
15791
+ const prevLast = prev && prev.run[prev.run.length - 1];
15792
+ if (prev && all.indexOf(run[0]) === all.indexOf(prevLast) + 1 && linesTouch(prevLast, run[0])) {
15716
15793
  prev.run = prev.run.concat(run);
15717
15794
  g = prev;
15718
15795
  } else {
@@ -15749,7 +15826,8 @@
15749
15826
  });
15750
15827
  // Segments in document order: each list group re-serialized, each other
15751
15828
  // member's own lines (a heading re-depthed). Whatever lies between two
15752
- // segments — blank lines, by the check above — is copied as it is.
15829
+ // segments — blank lines and the definition lines no block owns, by the
15830
+ // check above — is copied as it is.
15753
15831
  const segs = [];
15754
15832
  for (let i = 0; i < recs.length; i++) {
15755
15833
  if (isLi(els[i])) continue;
@@ -20591,6 +20669,9 @@
20591
20669
  // Two items that were already neighbours in two DIFFERENT lists stay two
20592
20670
  // lists, with whatever separated them.
20593
20671
  const keepsApart = (x, y) => all[all.indexOf(x) + 1] === y && runOf.get(x) !== runOf.get(y);
20672
+ // v3.17.4 D5 (R8): the source lines of every gap copied below, so a
20673
+ // definition among them is not appended a second time.
20674
+ const replayedLines = new Set();
20594
20675
  let lastEl = null;
20595
20676
  for (let k = 0; k < seq.length;) {
20596
20677
  let unit;
@@ -20625,8 +20706,10 @@
20625
20706
  if (lastEl) {
20626
20707
  const a = blockRecOf(lastEl);
20627
20708
  const b = blockRecOf(unit[0]);
20628
- if (all[all.indexOf(lastEl) + 1] === unit[0]) out.push(...lines.slice(a.endLine, b.startLine - 1));
20629
- else out.push('');
20709
+ if (all[all.indexOf(lastEl) + 1] === unit[0]) {
20710
+ out.push(...lines.slice(a.endLine, b.startLine - 1));
20711
+ for (let n = a.endLine + 1; n < b.startLine; n++) replayedLines.add(n);
20712
+ } else out.push('');
20630
20713
  }
20631
20714
  const base0 = out.length;
20632
20715
  unitLines.forEach((l, n) => {
@@ -20641,7 +20724,7 @@
20641
20724
  if (seq[seq.length - 1] !== regionEls[regionEls.length - 1] && lastRec.endLine < lines.length &&
20642
20725
  String(lines[lastRec.endLine]).trim() !== '') out.push('');
20643
20726
 
20644
- const result = commitRangeEdit({ lines, blocks, stack }, firstRec.startLine, lastRec.endLine, out.join('\n'));
20727
+ const result = commitRangeEdit({ lines, blocks, stack }, firstRec.startLine, lastRec.endLine, out.join('\n'), { replayedLines });
20645
20728
  if (result.op === null) return;
20646
20729
  endBurstWithoutResolve();
20647
20730
  const prevLines = lines;
@@ -22191,7 +22274,7 @@
22191
22274
  // final fix wave (R93) it also holds the task checkbox (`pressedCheck`):
22192
22275
  // three classes and the checkbox.
22193
22276
  let checkPress = null; // { el, epoch, snap }: the last press on a task checkbox (R89, R93)
22194
- let degradedPress = null; // { el, epoch, identity }: the last press on a degraded block (R102)
22277
+ let degradedPress = null; // degradedPressRecord(): the last press on a degraded block (R102)
22195
22278
  let degradedReleaseOpened = false; // T9b (R50): the last press's release already opened its block
22196
22279
  document.addEventListener('mousedown', (e) => {
22197
22280
  degradedReleaseOpened = false;
@@ -22217,10 +22300,7 @@
22217
22300
  // the click handler can find it again after that commit's render
22218
22301
  // replaced it. Never an identity taken during a flight (R94).
22219
22302
  const pressedBlock = !pressedCheck && e.target && e.target.closest ? e.target.closest('.ed-block') : null;
22220
- degradedPress = pressedBlock && isDegradedBlock(pressedBlock)
22221
- ? { el: pressedBlock, epoch: reconcileEpoch,
22222
- identity: commitsSettled() && !reconcileBlocksCommits() ? captureBlockIdentity(pressedBlock) : null }
22223
- : null;
22303
+ degradedPress = pressedBlock && isDegradedBlock(pressedBlock) ? degradedPressRecord(pressedBlock) : null;
22224
22304
  // v3.15.0 final fix wave (R93): the task checkbox joins the list, for
22225
22305
  // the reason the trace above records. Its press blurred the surface
22226
22306
  // being typed in, that commit's render replaced the checkbox before the
@@ -22524,6 +22604,24 @@
22524
22604
  ev.clientX < blockEl.getBoundingClientRect().left;
22525
22605
  }
22526
22606
 
22607
+ // R102 / E4 (R13): what a press on a degraded block records, before its blur
22608
+ // starts the commit of whatever was being typed. `epoch`, the reconciliation
22609
+ // it saw. `identity`, only when nothing is in flight and the screen is
22610
+ // `lines` (never one taken during a flight, R94). Otherwise, while a write
22611
+ // is in flight, its `print` as the screen draws it, whether that print named
22612
+ // this block alone at the press (`printUnique`, with no undo or redo
22613
+ // rendering) and the undo/redo render count then (`historyAt`). Final review
22614
+ // I3: taken at the PRESS, because the release may come after the write has
22615
+ // landed and replaced the node, when there is nothing left to print.
22616
+ function degradedPressRecord(blockEl) {
22617
+ const settled = commitsSettled();
22618
+ const identity = settled && !reconcileBlocksCommits() ? captureBlockIdentity(blockEl) : null;
22619
+ const print = !identity && !settled ? drawnBlockPrint(blockEl) : null;
22620
+ return { el: blockEl, epoch: reconcileEpoch, identity, print,
22621
+ printUnique: !!print && historyRendersInFlight === 0 && uniqueBlockElByPrint(print) === blockEl,
22622
+ historyAt: historyRenderSeq };
22623
+ }
22624
+
22527
22625
  // The degraded block's open (a raw box, or the in-place code edit), from a
22528
22626
  // click on it (`e`) or from the release of a press whose click was lost
22529
22627
  // (`upBlock`, the block the button came up over: the gesture is the
@@ -22534,6 +22632,29 @@
22534
22632
  const identity = press ? press.identity
22535
22633
  : (commitsSettled() && !reconcileBlocksCommits() ? captureBlockIdentity(blockEl) : null);
22536
22634
  const topAtClick = blockEl.getBoundingClientRect().top;
22635
+ // v3.17.4 E4 (R13): no identity because a write was in flight at the press
22636
+ // (R94). Measured before: a quote clicked 50 ms after a split drew the
22637
+ // notice once the render landed and opened nothing, since the render
22638
+ // replaced the node and there was nothing to find it again by. So the
22639
+ // block is printed as it read on screen at the press, and after the write
22640
+ // it is found again by that print (settledGestureBlock()'s rule, R17 / R46:
22641
+ // unique at the press and after the write, no undo or redo meanwhile, no
22642
+ // reconciliation owed); refused with the notice only when that fails. The
22643
+ // order below stays switchAwayFrom() then settleCommits(): R102's open of
22644
+ // a block whose screen agrees with `lines` needs the switch to run before
22645
+ // any reconciliation is owed, and a wait placed first would let the owed
22646
+ // redraw run ahead of the open.
22647
+ // Final review I3: the print is the press's own (degradedPressRecord()),
22648
+ // so a release that comes after the write landed can still find the block;
22649
+ // a click with no press of its own prints the block now. Either way the
22650
+ // re-find requires the press's reconciliation epoch (checked below for
22651
+ // every path), its undo/redo render count, and no reconciliation owed.
22652
+ const pressPrint = !!(press && press.print);
22653
+ const flightPrint = pressPrint ? press.print
22654
+ : (!identity && !commitsSettled() ? drawnBlockPrint(blockEl) : null);
22655
+ const refindable = pressPrint ? press.printUnique
22656
+ : !!flightPrint && historyRendersInFlight === 0 && uniqueBlockElByPrint(flightPrint) === blockEl;
22657
+ const historyAt = pressPrint ? press.historyAt : historyRenderSeq;
22537
22658
  const ok = await switchAwayFrom();
22538
22659
  if (!ok) return;
22539
22660
  await settleCommits();
@@ -22541,7 +22662,11 @@
22541
22662
  // the one block of its type with that source — a start line is no proof
22542
22663
  // (R95's removal of an emptied paragraph shifts lines unlogged, and an
22543
22664
  // identical quote then sat on the clicked one's line and was opened).
22544
- const live = refindBlock(blockEl, identity);
22665
+ let live = refindBlock(blockEl, identity);
22666
+ if (!live && refindable && epochAtPress === reconcileEpoch && historyAt === historyRenderSeq &&
22667
+ !reconcileBlocksCommits()) {
22668
+ live = uniqueBlockElByPrint(flightPrint);
22669
+ }
22545
22670
  if (!live || reconcileEpoch !== epochAtPress ||
22546
22671
  (reconcileBlocksCommits() && !blockReadsAsDrawn(live))) {
22547
22672
  showError(DROPPED_GESTURE_MESSAGE, null, null);
@@ -24287,12 +24412,13 @@
24287
24412
  // code block, a diagram, an image, a raw box, the code edit or a transient
24288
24413
  // paragraph at either end refuses the drop with a notice, and the source
24289
24414
  // keeps its text; so does a drop outside every block (fix round 1, R38: the
24290
- // search box, the link field, the page around the document). A drop inside
24291
- // the drag's own block is left to the browser (its two halves land in one
24292
- // burst, which commits them together), and a drag that did not start in the
24415
+ // search box, the link field, the page around the document). v3.17.4 D1
24416
+ // (R4): a drop inside the drag's own text surface is the editor's move too
24417
+ // (moveDroppedText(), one op): left to the browser, Chromium's own move
24418
+ // wrote a U+00A0 where it closed the gap. A drag that did not start in the
24293
24419
  // document, or that carries files, is not this code's at all.
24294
24420
  //
24295
- // `textDrag`: { blockEl, surface, range, text, handled } from dragstart to
24421
+ // `textDrag`: { blockEl, surface, range, text, handled, hadFiles } from dragstart to
24296
24422
  // just after dragend, or null. `surface` / `range` are null when the drag
24297
24423
  // is not a text selection in a plain surface (a table cell, an image).
24298
24424
  // `handled` (set at the drop): this code took the drop, so any input the
@@ -24302,7 +24428,7 @@
24302
24428
  // the drop was cancelled; the guard is there so an engine that does cannot
24303
24429
  // write the source behind the move.
24304
24430
  let textDrag = null;
24305
- const TEXT_DROP_REFUSED_MESSAGE = '文字只能拖放到另一個段落、標題或清單項目裡,原文保持不動';
24431
+ const TEXT_DROP_REFUSED_MESSAGE = '這段文字無法放在這裡,原文保持不動';
24306
24432
 
24307
24433
  function elementOfNode(node) {
24308
24434
  return node && (node.nodeType === 1 ? node : node.parentElement);
@@ -24340,10 +24466,12 @@
24340
24466
  return t && contentEl.contains(t) ? t.closest('.ed-block') : null;
24341
24467
  }
24342
24468
  // The drag this code answers for `e`, or null: an in-document drag
24343
- // (`textDrag`), never one that carries files — a picture or a .drawio from
24469
+ // (`textDrag`). An event that carries files belongs to it only when its
24470
+ // dragstart carried them too (a picture dragged out of the page, refused
24471
+ // rather than uploaded as a copy, v3.17.4 D4); a picture or a .drawio from
24344
24472
  // the desktop is wireImageDrop()'s whatever state a previous drag left.
24345
24473
  function textDragFor(e) {
24346
- return textDrag && !dragCarriesFiles(e) ? textDrag : null;
24474
+ return textDrag && (!dragCarriesFiles(e) || textDrag.hadFiles) ? textDrag : null;
24347
24475
  }
24348
24476
 
24349
24477
  function wireTextDrop() {
@@ -24365,7 +24493,9 @@
24365
24493
  if (!t || !contentEl.contains(t)) return;
24366
24494
  const blockEl = t.closest('.ed-block');
24367
24495
  if (!blockEl || !contentEl.contains(blockEl)) return;
24368
- const drag = { blockEl, surface: null, range: null, text: '', handled: false };
24496
+ // `hadFiles` (v3.17.4 D4, R6): the dragstart already carried Files, so an
24497
+ // in-page drag (a picture) is told apart from a file dropped from outside.
24498
+ const drag = { blockEl, surface: null, range: null, text: '', handled: false, hadFiles: dragCarriesFiles(e) };
24369
24499
  const sel = window.getSelection();
24370
24500
  const surface = textDropSurfaceOf(blockEl);
24371
24501
  if (surface && sel.rangeCount && !sel.isCollapsed) {
@@ -24395,7 +24525,13 @@
24395
24525
  const drag = textDragFor(e);
24396
24526
  if (!drag) return;
24397
24527
  const t = elementOfNode(e.target);
24398
- if (!t || dragBlockAt(e) === drag.blockEl || t.isContentEditable) return;
24528
+ if (!t || t.isContentEditable) return;
24529
+ // A drag that carried Files from its dragstart (a picture) over its own
24530
+ // non-text block is taken here too, so wireImageDrop's bubble dragover
24531
+ // cannot answer it with a copy (v3.17.4 D4).
24532
+ const ownFiles = drag.hadFiles && dragBlockAt(e) === drag.blockEl && !textDropSurfaceOf(drag.blockEl);
24533
+ if (dragBlockAt(e) === drag.blockEl && !ownFiles) return;
24534
+ if (ownFiles) e.stopPropagation();
24399
24535
  e.preventDefault();
24400
24536
  if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
24401
24537
  };
@@ -24410,7 +24546,30 @@
24410
24546
  const drag = textDragFor(e);
24411
24547
  if (!drag || waveEditorIsModal()) return;
24412
24548
  const destBlockEl = dragBlockAt(e);
24413
- if (destBlockEl === drag.blockEl) return;
24549
+ if (destBlockEl === drag.blockEl) {
24550
+ // A block that is not a text surface (a code block's code edit, a
24551
+ // table's cells, a raw box, a picture block's alt field) keeps its own
24552
+ // drop handling inside itself, as before v3.17.4.
24553
+ if (!textDropSurfaceOf(drag.blockEl)) {
24554
+ // Carrying Files from its dragstart (a picture dropped back on
24555
+ // itself): neither an upload nor a replace, and no notice (v3.17.4
24556
+ // D4). Without Files the block's own drop handling is left alone.
24557
+ if (drag.hadFiles) { e.preventDefault(); e.stopPropagation(); drag.handled = true; }
24558
+ return;
24559
+ }
24560
+ e.preventDefault();
24561
+ e.stopPropagation();
24562
+ drag.handled = true;
24563
+ // v3.17.4 D1 (R4): a drag with no text selection (a picture inside
24564
+ // the paragraph drags itself) dropped in its own block does nothing,
24565
+ // with no notice; so does a drop point off the surface's text.
24566
+ if (!drag.surface || !drag.text) return;
24567
+ const ownOffset = surfaceOffsetFromPoint(drag.surface, e.clientX, e.clientY);
24568
+ if (ownOffset === null) return;
24569
+ // The selected text moved within its own block: the editor's move.
24570
+ moveDroppedText(drag.blockEl, drag.range, drag.blockEl, ownOffset).catch(structuralKeyFailed);
24571
+ return;
24572
+ }
24414
24573
  e.preventDefault();
24415
24574
  e.stopPropagation();
24416
24575
  drag.handled = true;
@@ -24452,8 +24611,9 @@
24452
24611
  // `**bravo**` dropped into `**delta**` wrote `**de**bravo**lta**`, which
24453
24612
  // reparses with bravo NOT bold. A link dropped inside a link has no
24454
24613
  // Markdown at all (links do not nest; measured, the destination link came
24455
- // back broken), and inside inline code no mark can be written, so those
24456
- // two are refused: false.
24614
+ // back broken), and inside inline code no mark and no line break can be written (a
24615
+ // code span holds text only; a break used to vanish silently, v3.17.4 D2),
24616
+ // so those two are refused: false.
24457
24617
  const MARK_KIND = { STRONG: 'b', B: 'b', EM: 'i', I: 'i', DEL: 's', S: 's', STRIKE: 's', U: 'u', CODE: 'code', A: 'a' };
24458
24618
  function fitFragmentToDrop(frag, atNode, surface) {
24459
24619
  const kinds = {};
@@ -24462,7 +24622,7 @@
24462
24622
  }
24463
24623
  const els = Array.prototype.slice.call(frag.querySelectorAll('*'));
24464
24624
  if (kinds.a && els.some((el) => el.nodeName === 'A')) return false;
24465
- if (kinds.code && els.some((el) => el.nodeName !== 'BR')) return false;
24625
+ if (kinds.code && els.length) return false;
24466
24626
  els.forEach((el) => {
24467
24627
  if (!kinds[MARK_KIND[el.nodeName]] || el.nodeName === 'A') return;
24468
24628
  while (el.firstChild) el.parentNode.insertBefore(el.firstChild, el);
@@ -24528,6 +24688,10 @@
24528
24688
  // (refindBlock(), as performBlockDrop() does). From there on it is
24529
24689
  // synchronous up to commitTypingThenStructure() (the v3.13.1 guard); a
24530
24690
  // failed render keeps the moved text on screen as a pending structure (D7).
24691
+ // v3.17.4 D1 (R4): `srcBlockEl` may be `destBlockEl` (a move inside one
24692
+ // block); `destOffset` is then in the surface's text BEFORE the move, a
24693
+ // drop inside the dragged text [from, to] writes nothing, and one past it
24694
+ // lands `to - from` earlier once the text has left.
24531
24695
  async function moveDroppedText(srcBlockEl, srcRange, destBlockEl, destOffset) {
24532
24696
  const srcSurface0 = textDropSurfaceOf(srcBlockEl);
24533
24697
  if (!srcSurface0 || !srcSurface0.contains(srcRange.startContainer) ||
@@ -24536,6 +24700,10 @@
24536
24700
  const to = offsetInSurface(srcSurface0, srcRange.endContainer, srcRange.endOffset);
24537
24701
  const dragged = srcSurface0.textContent.slice(from, to);
24538
24702
  if (!(to > from)) { refuseTextDrop(); return; }
24703
+ const same = srcBlockEl === destBlockEl;
24704
+ if (same && destOffset >= from && destOffset <= to) return;
24705
+ // Where the text goes in, counted once the dragged text has left.
24706
+ const insertAt = same && destOffset > to ? destOffset - (to - from) : destOffset;
24539
24707
  // v3.15.0 R100 (b), as performBlockDrop(): no identity is taken while a
24540
24708
  // commit is in flight; both ends must still be on screen once it lands.
24541
24709
  if (!commitsSettled()) {
@@ -24555,7 +24723,7 @@
24555
24723
  const dest = refindBlock(destBlockEl, destIdentity, destSelf);
24556
24724
  const srcSurface = src ? textDropSurfaceOf(src) : null;
24557
24725
  const destSurface = dest ? textDropSurfaceOf(dest) : null;
24558
- if (!srcSurface || !destSurface || src === dest ||
24726
+ if (!srcSurface || !destSurface || (src === dest) !== same ||
24559
24727
  srcSurface.textContent.slice(from, to) !== dragged || destOffset > destSurface.textContent.length) {
24560
24728
  showError(DROPPED_GESTURE_MESSAGE, null, null);
24561
24729
  return;
@@ -24564,7 +24732,11 @@
24564
24732
  const a = textMoveEnd(src, srcSurface);
24565
24733
  const b = textMoveEnd(dest, destSurface);
24566
24734
  if (!a || !b) { refuseTextDrop(); return; }
24567
- if (a.kind === 'run' && b.kind === 'run' && a.run[0] === b.run[0]) {
24735
+ if (same) {
24736
+ // One block: one end (a list item's run with `lis: [src]`).
24737
+ if (a.kind === 'run' && !listRunSupportsStructuralEdit(a.run, src)) { refuseTextDrop(); return; }
24738
+ ends = [a];
24739
+ } else if (a.kind === 'run' && b.kind === 'run' && a.run[0] === b.run[0]) {
24568
24740
  if (!listRunSupportsStructuralEdit(a.run, [src, dest])) { refuseTextDrop(); return; }
24569
24741
  ends = [{ kind: 'run', run: a.run, lis: [src, dest], first: a.first, last: a.last }];
24570
24742
  } else {
@@ -24581,9 +24753,26 @@
24581
24753
  // The fragment is built, fitted to where it lands and asked of the inline
24582
24754
  // serializer before anything on screen changes; the source's own nodes
24583
24755
  // are untouched by all three (cloneContents()).
24584
- const at = pointInSurface(destSurface, destOffset);
24585
24756
  const frag = fragmentWithMarks(range, srcSurface);
24586
- if (!fitFragmentToDrop(frag, at[0], destSurface)) { refuseTextDrop(); return; }
24757
+ // Where it lands: in one block, the point is taken after the dragged text
24758
+ // has left, so the fit reads a copy of the surface with that delete
24759
+ // already done (the same steps as the real one below).
24760
+ let at = pointInSurface(destSurface, destOffset);
24761
+ let fitAt = at;
24762
+ let fitSurface = destSurface;
24763
+ if (same) {
24764
+ fitSurface = srcSurface.cloneNode(true);
24765
+ const p0 = pointInSurface(fitSurface, from);
24766
+ const q0 = pointInSurface(fitSurface, to);
24767
+ const gone = document.createRange();
24768
+ gone.setStart(p0[0], p0[1]);
24769
+ gone.setEnd(q0[0], q0[1]);
24770
+ gone.deleteContents();
24771
+ dropEmptyInlines(fitSurface);
24772
+ fitSurface.normalize();
24773
+ fitAt = pointInSurface(fitSurface, insertAt);
24774
+ }
24775
+ if (!fitFragmentToDrop(frag, fitAt[0], fitSurface)) { refuseTextDrop(); return; }
24587
24776
  // A heading is one line: a hard break in the moved text becomes a space.
24588
24777
  if (dest.getAttribute('data-block-type') === 'heading') {
24589
24778
  Array.prototype.slice.call(frag.querySelectorAll('br')).forEach((br) => {
@@ -24600,6 +24789,7 @@
24600
24789
  range.deleteContents();
24601
24790
  dropEmptyInlines(srcSurface);
24602
24791
  srcSurface.normalize();
24792
+ if (same) at = pointInSurface(destSurface, insertAt);
24603
24793
  const firstIn = frag.firstChild;
24604
24794
  const lastIn = frag.lastChild;
24605
24795
  const ins = document.createRange();
@@ -24651,7 +24841,7 @@
24651
24841
  const destId = dest.getAttribute('data-block-id');
24652
24842
  const ok = await commitTypingThenStructure({ typed: null, text: s.text, carriesGap: s.carriesGap === true,
24653
24843
  range: () => [first, last], oldRange: [first, last], lineMap: s.lineMap,
24654
- pending: { surfaces: [srcSurface, destSurface], serialize } });
24844
+ pending: { surfaces: same ? [srcSurface] : [srcSurface, destSurface], serialize } });
24655
24845
  if (!ok) return;
24656
24846
  // The moved text stays selected where it landed, as the browser's own
24657
24847
  // move leaves it.
@@ -24659,7 +24849,7 @@
24659
24849
  const surface = landed ? blockContentEl(landed) : null;
24660
24850
  if (!surface || !surface.classList.contains('ed-wys-armed')) return;
24661
24851
  surface.focus();
24662
- restoreSelection(surface, destOffset, destOffset + movedLen);
24852
+ restoreSelection(surface, insertAt, insertAt + movedLen);
24663
24853
  }
24664
24854
 
24665
24855
  // ── 追加 2: paste ──────────────────────────────────────────────────────
@@ -27256,7 +27446,7 @@
27256
27446
  if (wasAlt && opts && opts.keepDraft && imgTbInput.value !== wasAlt.original) {
27257
27447
  const rec = imgTbBlock && imgTbBlock.isConnected ? blockRecOf(imgTbBlock) : null;
27258
27448
  imgTbAltDraft = rec ? { source: blockSourceOf(rec), value: imgTbInput.value } : null;
27259
- } else imgTbAltDraft = null;
27449
+ } else if (wasAlt) imgTbAltDraft = null;
27260
27450
  imgTbAlt = null;
27261
27451
  imgTbBlock = null;
27262
27452
  imgTbKeyboard = false;
@@ -27428,15 +27618,23 @@
27428
27618
  if (e.isComposing || e.keyCode === 229) { e.stopPropagation(); return; }
27429
27619
  e.preventDefault(); e.stopPropagation(); exitImageAltMode(true); return;
27430
27620
  }
27431
- if (e.key !== 'Enter' || e.isComposing) return;
27621
+ // R11: leaving the keyboard-opened field by Tab / Shift+Tab commits it
27622
+ // like Enter and returns focus to the image's block selection (the
27623
+ // commit re-renders the block, so no button is left to walk on to). The
27624
+ // mouse-opened field keeps the browser's own Tab.
27625
+ const byTab = e.key === 'Tab' && imgTbKeyboard && !e.isComposing && e.keyCode !== 229;
27626
+ if ((e.key !== 'Enter' && !byTab) || e.isComposing) return;
27432
27627
  e.preventDefault();
27433
27628
  e.stopPropagation();
27434
27629
  const alt = imgTbAlt;
27435
27630
  const blk = imgTbBlock;
27436
27631
  const value = imgTbInput.value;
27632
+ const keptSel = byTab && blockSelection ? { anchorLine: blockSelection.anchorLine, focusLine: blockSelection.focusLine } : null;
27437
27633
  hideImageToolbar();
27438
27634
  if (!alt || value === alt.original) return;
27439
- applyImageEdit(blk, alt.identity, { alt: value }).catch(showUnexpectedFailure);
27635
+ applyImageEdit(blk, alt.identity, { alt: value })
27636
+ .then(() => { if (keptSel) setBlockSelection(keptSel); })
27637
+ .catch(showUnexpectedFailure);
27440
27638
  });
27441
27639
  imgTbInput.addEventListener('blur', (e) => {
27442
27640
  if (!imgTbAlt) return;
@@ -524,17 +524,21 @@
524
524
  // the ampersand and the backslash are written as the character references
525
525
  // the browser reads back as the same character. The reader side below
526
526
  // undoes those, plus the backslash escapes older files hold.
527
- // Final review M2 (R25 / R7): and every other character reference the
528
- // reader decodes — marked passes them into the alt attribute untouched and
529
- // the browser decodes them — so the field shows what the reader shows: a
530
- // decimal or hex number, and the named amp, lt, gt, quot, apos and nbsp. With
531
- // only `&amp;`, `&#96;` and `&#92;` read before, a source `a &lt; b` showed "a &lt; b" in the field
532
- // and, once edited, was written `a &amp;lt; b`, which the reader shows as
533
- // "a &lt; b". A name outside that list stays as written in the field, and
534
- // is written so that it renders as that text.
527
+ // Final review M2 (R25 / R7), widened by v3.17.4 E1 (R10): every character
528
+ // reference the reader decodes — marked passes them into the alt attribute
529
+ // untouched and the browser decodes them — so the field shows what the
530
+ // reader shows. A decimal or hex number needs no table. A name goes through
531
+ // the installed decoder (the client installs one that asks the page itself,
532
+ // so every name the browser knows decodes); with none installed, the six
533
+ // names amp, lt, gt, quot, apos and nbsp. A name the decoder does not know
534
+ // stays as written in the field, and is written so that it renders as that
535
+ // text. An edited alt is written as the decoded character itself.
535
536
  const ALT_ESCAPE = { '[': '\\[', ']': '\\]', '&': '&amp;', '`': '&#96;', '\\': '&#92;' };
536
537
  const ALT_NAMED_CHAR = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: '\u00a0' };
537
- const ALT_UNESCAPE = /\\([!-\/:-@\[-`{-~])|&(?:#([0-9]+)|#[xX]([0-9a-fA-F]+)|(amp|lt|gt|quot|apos|nbsp));/g;
538
+ const defaultNamedReference = (name) => ALT_NAMED_CHAR[name];
539
+ let namedReference = defaultNamedReference;
540
+ function setNamedReferenceDecoder(fn) { namedReference = typeof fn === 'function' ? fn : defaultNamedReference; }
541
+ const ALT_UNESCAPE = /\\([!-\/:-@\[-`{-~])|&(?:#([0-9]+)|#[xX]([0-9a-fA-F]+)|([A-Za-z][A-Za-z0-9]*));/g;
538
542
  // A number the HTML parser would not give as itself (zero, a surrogate,
539
543
  // past U+10FFFF) is U+FFFD there, so it is here too.
540
544
  function altCodePoint(n) {
@@ -544,7 +548,8 @@
544
548
  if (c !== undefined) return c;
545
549
  if (dec !== undefined) return altCodePoint(parseInt(dec, 10));
546
550
  if (hex !== undefined) return altCodePoint(parseInt(hex, 16));
547
- return ALT_NAMED_CHAR[name];
551
+ const r = namedReference(name);
552
+ return typeof r === 'string' ? r : m;
548
553
  }
549
554
 
550
555
  function parseImageParagraph(text) {
@@ -576,5 +581,5 @@
576
581
  return out;
577
582
  }
578
583
 
579
- return { serializeInline, canWysiwyg, escapeText, escapeBlockStarts, normalizeHref, isPastedUrl, parseImageParagraph, rewriteImage };
584
+ return { serializeInline, canWysiwyg, escapeText, escapeBlockStarts, normalizeHref, isPastedUrl, parseImageParagraph, rewriteImage, setNamedReferenceDecoder };
580
585
  });
package/lib/md2doc.js CHANGED
@@ -1082,6 +1082,13 @@ ${itemsHtml}
1082
1082
  openSvg = 0;
1083
1083
  return isSvgOnly(src);
1084
1084
  }
1085
+ // v3.17.4 E5: an svg that is real inline html among a block's own inline
1086
+ // tokens (a code span naming it is not one). The reader marks the block
1087
+ // with data-svg-plate so dark mode plates it, whatever text sits beside it.
1088
+ function hasInlineSvg(toks) {
1089
+ return Array.isArray(toks) && toks.some((tk) => tk && ((tk.type === 'html' && /<svg[\s>]/i.test(String(tk.raw || ''))) ||
1090
+ (tk.type === 'text' && hasInlineSvg(tk.tokens))));
1091
+ }
1085
1092
 
1086
1093
  const baseHtml = renderer.html.bind(renderer);
1087
1094
  renderer.html = function(token) {
@@ -1241,6 +1248,9 @@ ${itemsHtml}
1241
1248
  };
1242
1249
  const baseParagraph = renderer.paragraph.bind(renderer);
1243
1250
  renderer.paragraph = function(token) {
1251
+ // A loose item's text paragraph is made up by marked from the already
1252
+ // rendered body, so its svg is real html there (a code span is escaped).
1253
+ const madeUp = skipMadeUpParagraph;
1244
1254
  if (skipMadeUpParagraph) {
1245
1255
  skipMadeUpParagraph = false;
1246
1256
  } else {
@@ -1250,6 +1260,9 @@ ${itemsHtml}
1250
1260
  if (!opts.editMode && wrapsSvgParagraph(token.raw)) {
1251
1261
  return baseParagraph(token).replace(/^<p>/, '<p class="inline-svg">');
1252
1262
  }
1263
+ if (!opts.editMode && (hasInlineSvg(token.tokens) || (madeUp && /<svg[\s>]/i.test(String(token.text || ''))))) {
1264
+ return baseParagraph(token).replace(/^<p>/, '<p data-svg-plate="1">');
1265
+ }
1253
1266
  return baseParagraph(token);
1254
1267
  };
1255
1268
 
@@ -1264,7 +1277,11 @@ ${itemsHtml}
1264
1277
  } finally {
1265
1278
  looseItemText = outerLoose;
1266
1279
  }
1267
- if (!token.task) return html;
1280
+ // v3.17.4 E5: a tight item's own text sits straight in the li (no p). Marked
1281
+ // last, because the task wrapping below anchors on a bare li tag.
1282
+ const plateTight = (h) => (!opts.editMode && !token.loose && hasInlineSvg(token.tokens))
1283
+ ? h.replace(/^<li( class="[^"]*")?/, '$& data-svg-plate="1"') : h;
1284
+ if (!token.task) return plateTight(html);
1268
1285
  // v3.10.0: a task item drops its bullet and keeps its own text in a span,
1269
1286
  // so a checked item's strike-through stops at its own words — CSS
1270
1287
  // text-decoration cannot be undone on a nested child list. Shapes: tight
@@ -1273,8 +1290,8 @@ ${itemsHtml}
1273
1290
  // first: a block child's opening tag, the loose item's </p>, or </li>.
1274
1291
  const cls = token.checked ? 'task-item is-checked' : 'task-item';
1275
1292
  const m = html.match(/^<li>([\s\S]*?<input[^>]*>)([\s\S]*?)(<(?:ul|ol|blockquote|pre|table|div|p|h[1-6])[\s>][\s\S]*|<\/p>[\s\S]*|<\/li>\s*)$/);
1276
- if (!m) return html.replace(/^<li>/, '<li class="' + cls + '">');
1277
- return '<li class="' + cls + '">' + m[1] + ' <span class="task-text">' + m[2].trim() + '</span>' + m[3];
1293
+ if (!m) return plateTight(html.replace(/^<li>/, '<li class="' + cls + '">'));
1294
+ return plateTight('<li class="' + cls + '">' + m[1] + ' <span class="task-text">' + m[2].trim() + '</span>' + m[3]);
1278
1295
  };
1279
1296
 
1280
1297
  const baseBlockquote = renderer.blockquote.bind(renderer);
@@ -1405,7 +1422,7 @@ ${itemsHtml}
1405
1422
  // v3.14.0 T10: the reader wraps an svg-only block in .inline-svg for the
1406
1423
  // dark white plate; edit mode keeps its DOM, so it marks the wrapper.
1407
1424
  const svgPlate = ((t.type === 'html' && t.block && wrapsSvg(t.text)) ||
1408
- (t.type === 'paragraph' && wrapsSvgParagraph(t.raw))) ? ' data-svg-plate="1"' : '';
1425
+ (t.type === 'paragraph' && (wrapsSvgParagraph(t.raw) || hasInlineSvg(t.tokens)))) ? ' data-svg-plate="1"' : '';
1409
1426
  parts.push(
1410
1427
  `<div class="ed-block" data-block-id="${b.id}" data-block-type="${b.type}"${svgPlate}>` +
1411
1428
  inner + '</div>'
@@ -168,7 +168,8 @@ function themeCss() {
168
168
  ' ' + D + ' .content .drawio { background: #ffffff; border-radius: 6px; box-shadow: 0 0 0 10px #ffffff; }',
169
169
  // R7 甲: a Markdown-native inline svg (wrapped in .inline-svg by the renderer) gets the
170
170
  // same white plate; its ink stays the light text colour because the plate is always white.
171
- ' ' + D + ' .content .inline-svg > svg, ' + D + ' .content .ed-block[data-svg-plate="1"] > svg, ' + D + ' .content .ed-block[data-svg-plate="1"] > p > svg, ' + D + ' .content .ed-block[data-svg-plate="1"] .ed-li-text > svg, ' + D + ' .content .ed-block[data-svg-plate="1"] .ed-li-text > p > svg { background: #ffffff; color: #1f2328; border-radius: 4px; box-shadow: 0 0 0 10px #ffffff; }',
171
+ // A task item's svg sits one level down, inside span.task-text (tight li or loose p).
172
+ ' ' + D + ' .content .inline-svg > svg, ' + D + ' .content p[data-svg-plate="1"] > svg, ' + D + ' .content li[data-svg-plate="1"] > svg, ' + D + ' .content li[data-svg-plate="1"] > p > svg, ' + D + ' .content li[data-svg-plate="1"] > .task-text > svg, ' + D + ' .content p[data-svg-plate="1"] > .task-text > svg, ' + D + ' .content .ed-block[data-svg-plate="1"] > svg, ' + D + ' .content .ed-block[data-svg-plate="1"] > p > svg, ' + D + ' .content .ed-block[data-svg-plate="1"] .ed-li-text > svg, ' + D + ' .content .ed-block[data-svg-plate="1"] .ed-li-text > p > svg { background: #ffffff; color: #1f2328; border-radius: 4px; box-shadow: 0 0 0 10px #ffffff; }',
172
173
  ' ' + D + ' .lightbox-canvas > svg[data-md2doc-recoloured] { background: var(--md-bg); }',
173
174
  ' }',
174
175
  ' .md2doc-theme-toggle { position: fixed; right: 20px; bottom: 20px; z-index: 60; width: 40px; height: 40px;',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helping-ai-workflow/md2doc",
3
- "version": "3.17.3",
3
+ "version": "3.17.4",
4
4
  "description": "Markdown → HTML / PDF renderer with WaveDrom, Mermaid, Graphviz, and draw.io support",
5
5
  "keywords": [
6
6
  "markdown",