@helping-ai-workflow/md2doc 3.17.3 → 3.17.5

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;
@@ -171,6 +171,10 @@
171
171
  return '[' + label + '](' + (href || '') + ')';
172
172
  }
173
173
 
174
+ // (What a mark's own edges must look like -- whitespace and punctuation
175
+ // just inside its delimiters -- is a separate rule, v3.17.5, written above
176
+ // `splitEdges` / `appendPiece` further down.)
177
+ //
174
178
  // v3.4.0 / backlog 3 (Ruling T6-7, then a fix-round regression): STRONG
175
179
  // and EM each emit a fixed, context-blind '*'-based delimiter for their
176
180
  // own tag — '**'/'**' or '*'/'*' — with no awareness of what the
@@ -267,6 +271,116 @@
267
271
  // STRONG, DEL/S, U, the citation label) only ever needs `.md`, because
268
272
  // its own outer delimiter is a fixed, known tag regardless of what's
269
273
  // nested inside.
274
+ // v3.17.5 M1 + R1: a delimiter run only counts when CommonMark's flanking
275
+ // rule accepts it, and STRONG / EM / DEL used to wrap their content blind.
276
+ // Measured on 4d7669f, all three rendered literal asterisks in the reader:
277
+ // Delete down to a leading space `alpha ** charlie** delta`
278
+ // Ctrl+B on 「引號」 `前面**「引號」**後面`
279
+ // Ctrl+B on `charlie ` (with space) `alpha **charlie **delta`
280
+ // The rule, in marked 14's own classes (whitespace = JS `\s`, punctuation =
281
+ // `\p{P}\p{S}`): the character just INSIDE a run may not be whitespace, and
282
+ // when it is punctuation the character just OUTSIDE must be whitespace,
283
+ // punctuation or the edge of the text.
284
+ //
285
+ // Two fixes, one per half of the rule:
286
+ // * whitespace inside -> moved outside (`splitEdges`), byte for byte, so
287
+ // ` charlie` in bold becomes ` **charlie**`; a mark holding nothing
288
+ // else is no mark at all. A hard break (`\` + newline) at an edge
289
+ // moves out whole: peeling only its newline would leave the backslash
290
+ // escaping the delimiter.
291
+ // * punctuation inside, a word character outside -> `<!-- -->` between
292
+ // them (`appendPiece`). The comment is the same inert separator the
293
+ // same-tag guard above uses; marked masks an HTML tag as punctuation,
294
+ // so the run then has punctuation on both sides, which the rule allows.
295
+ // Only `*` runs need it: marked's `~~` is not punctuation-flanking
296
+ // (`~~中文。~~後面` parses), and `_` is never emitted.
297
+ // The punctuation check runs where two pieces are joined, on the bytes
298
+ // themselves, because the character outside a run may belong to a sibling,
299
+ // or to a parent's sibling when the run starts or ends a bare SPAN: every
300
+ // level joins its own pieces, so a run at the edge of a nested call is
301
+ // checked again by its parent. A run's inside is found by skipping its bare
302
+ // `*`s: escapeText() escapes every literal `*`, so a `*` at either end of a
303
+ // piece is a delimiter unless an odd run of backslashes precedes it.
304
+ const FLANK_WS = /\s/;
305
+ const FLANK_PUNCT = /[\p{P}\p{S}]/u;
306
+ function isWordish(ch) {
307
+ return ch !== undefined && !FLANK_WS.test(ch) && !FLANK_PUNCT.test(ch);
308
+ }
309
+ function lastCodePoint(s, end) {
310
+ if (end <= 0) return undefined;
311
+ const lo = s.charCodeAt(end - 1);
312
+ if (lo >= 0xdc00 && lo <= 0xdfff && end >= 2) {
313
+ const hi = s.charCodeAt(end - 2);
314
+ if (hi >= 0xd800 && hi <= 0xdbff) return s.slice(end - 2, end);
315
+ }
316
+ return s[end - 1];
317
+ }
318
+ function firstCodePoint(s, start) {
319
+ if (start >= s.length) return undefined;
320
+ return String.fromCodePoint(s.codePointAt(start));
321
+ }
322
+ // The character just inside a leading `*` run, or undefined when `s` does
323
+ // not open with one.
324
+ function openingRunInside(s) {
325
+ let i = 0;
326
+ while (s[i] === '*') i++;
327
+ return i ? firstCodePoint(s, i) : undefined;
328
+ }
329
+ // The character just inside a trailing `*` run, or undefined when `s` does
330
+ // not close with one.
331
+ function closingRunInside(s) {
332
+ let k = 0;
333
+ while (k < s.length && s[s.length - 1 - k] === '*') k++;
334
+ if (!k) return undefined;
335
+ let b = 0;
336
+ while (s[s.length - 1 - k - b] === '\\') b++;
337
+ if (b % 2) return k > 1 ? '*' : undefined; // the run's first `*` is an escaped literal
338
+ return lastCodePoint(s, s.length - k);
339
+ }
340
+ // Two v3.17.5 final-review findings shape the outside characters:
341
+ // * I2 -- marked reads the character before an opening run as ONE UTF-16
342
+ // unit (`token.raw.slice(-1)`), so an astral symbol there (📌, \p{So})
343
+ // is a lone surrogate to it, i.e. a word character. Match that.
344
+ // * I1 -- a closing run touching the next piece's opening run fuses with
345
+ // it (`**注意:***斜體*`); the character after the fused run is the one
346
+ // inside the right piece's run, not its `*`. The opening side needs no
347
+ // such care: once the left run has closed, marked sees a `*`
348
+ // (punctuation) before the rest, which the rule accepts.
349
+ function needsFlankSeparator(left, right) {
350
+ if (!left || !right) return false;
351
+ const open = openingRunInside(right);
352
+ if (open !== undefined && FLANK_PUNCT.test(open) && isWordish(left[left.length - 1])) return true;
353
+ const close = closingRunInside(left);
354
+ const after = right[0] === '*' ? openingRunInside(right) : firstCodePoint(right, 0);
355
+ return close !== undefined && FLANK_PUNCT.test(close) && isWordish(after);
356
+ }
357
+ function appendPiece(out, piece) {
358
+ return out + (needsFlankSeparator(out, piece) ? '<!-- -->' : '') + piece;
359
+ }
360
+ // `md` -> { lead, core, trail }: whitespace (and whole hard breaks) peeled
361
+ // off both ends of a mark's content.
362
+ function splitEdges(md) {
363
+ let start = 0;
364
+ let end = md.length;
365
+ for (;;) {
366
+ if (start < end && FLANK_WS.test(md[start])) { start++; continue; }
367
+ // a leading `\` is always a hard break's: a literal one is `\\`
368
+ if (md[start] === '\\' && md[start + 1] === '\n' && start + 2 <= end) { start += 2; continue; }
369
+ break;
370
+ }
371
+ for (;;) {
372
+ if (end > start && md[end - 1] === '\n') {
373
+ let b = 0;
374
+ while (end - 2 - b >= start && md[end - 2 - b] === '\\') b++;
375
+ end -= b % 2 ? 2 : 1;
376
+ continue;
377
+ }
378
+ if (end > start && FLANK_WS.test(md[end - 1])) { end--; continue; }
379
+ break;
380
+ }
381
+ return { lead: md.slice(0, start), core: md.slice(start, end), trail: md.slice(end) };
382
+ }
383
+
270
384
  function markIsSearchHit(node) {
271
385
  if (typeof node.getAttribute !== 'function') return false;
272
386
  return (' ' + (node.getAttribute('class') || '') + ' ').indexOf(' search-hit ') !== -1;
@@ -279,7 +393,7 @@
279
393
  const node = nodes[i];
280
394
  if (node.nodeType === 3) {
281
395
  const text = escapeText(node.textContent);
282
- out += text;
396
+ out = appendPiece(out, text);
283
397
  if (text.length) lastMarkTag = null;
284
398
  firstSegment = false;
285
399
  continue;
@@ -291,7 +405,7 @@
291
405
  if (!firstSegment) added += '<br>';
292
406
  const inner = walkChildren(node.childNodes, unsupported);
293
407
  added += inner.md;
294
- out += added;
408
+ out = appendPiece(out, added);
295
409
  if (added.length) lastMarkTag = inner.md.length ? inner.lastMarkTag : null;
296
410
  firstSegment = false;
297
411
  continue;
@@ -300,7 +414,7 @@
300
414
  // v3.14.0 T9: the reader's search paints its hits as this element and
301
415
  // it is transient, so it serializes as what it wraps.
302
416
  const inner = walkChildren(node.childNodes, unsupported);
303
- out += inner.md;
417
+ out = appendPiece(out, inner.md);
304
418
  if (inner.md.length) lastMarkTag = inner.lastMarkTag;
305
419
  firstSegment = false;
306
420
  continue;
@@ -310,23 +424,25 @@
310
424
  unsupported.push(name);
311
425
  } else {
312
426
  const inner = walkChildren(node.childNodes, unsupported);
313
- out += inner.md;
427
+ out = appendPiece(out, inner.md);
314
428
  if (inner.md.length) lastMarkTag = inner.lastMarkTag;
315
429
  }
316
430
  firstSegment = false;
317
431
  continue;
318
432
  }
319
- if (name === 'STRONG' || name === 'B') {
320
- out += (lastMarkTag === 'STRONG' ? '<!-- -->' : '') +
321
- '**' + walkChildren(node.childNodes, unsupported).md + '**';
322
- lastMarkTag = 'STRONG';
323
- firstSegment = false;
324
- continue;
325
- }
326
- if (name === 'EM' || name === 'I') {
327
- out += (lastMarkTag === 'EM' ? '<!-- -->' : '') +
328
- '*' + walkChildren(node.childNodes, unsupported).md + '*';
329
- lastMarkTag = 'EM';
433
+ if (name === 'STRONG' || name === 'B' || name === 'EM' || name === 'I') {
434
+ const tag = (name === 'STRONG' || name === 'B') ? 'STRONG' : 'EM';
435
+ const delim = tag === 'STRONG' ? '**' : '*';
436
+ const e = splitEdges(walkChildren(node.childNodes, unsupported).md);
437
+ if (e.core) {
438
+ // a hoisted leading space already separates two same-tag runs
439
+ const guard = !e.lead && lastMarkTag === tag ? '<!-- -->' : '';
440
+ out = appendPiece(out, e.lead + guard + delim + e.core + delim + e.trail);
441
+ lastMarkTag = e.trail ? null : tag;
442
+ } else if (e.lead || e.trail) {
443
+ out = appendPiece(out, e.lead + e.trail);
444
+ lastMarkTag = null;
445
+ }
330
446
  firstSegment = false;
331
447
  continue;
332
448
  }
@@ -336,8 +452,9 @@
336
452
  // accepted on input (some contenteditable/paste paths produce it)
337
453
  // but the toolbar/serializer always speak DEL, matching what marked
338
454
  // itself emits.
339
- out += '~~' + walkChildren(node.childNodes, unsupported).md + '~~';
340
- lastMarkTag = null;
455
+ const e = splitEdges(walkChildren(node.childNodes, unsupported).md);
456
+ out = appendPiece(out, e.core ? e.lead + '~~' + e.core + '~~' + e.trail : e.lead + e.trail);
457
+ if (e.core || e.lead || e.trail) lastMarkTag = null;
341
458
  firstSegment = false;
342
459
  continue;
343
460
  }
@@ -352,19 +469,19 @@
352
469
  // backtick, literal `<u>` typed as plain text is already escaped by
353
470
  // the `<` -> `&lt;` rule above, so it can never collide with a real
354
471
  // toolbar-made underline.
355
- out += '<u>' + walkChildren(node.childNodes, unsupported).md + '</u>';
472
+ out = appendPiece(out, '<u>' + walkChildren(node.childNodes, unsupported).md + '</u>');
356
473
  lastMarkTag = null;
357
474
  firstSegment = false;
358
475
  continue;
359
476
  }
360
477
  if (name === 'CODE') {
361
- out += serializeCode(node);
478
+ out = appendPiece(out, serializeCode(node));
362
479
  lastMarkTag = null;
363
480
  firstSegment = false;
364
481
  continue;
365
482
  }
366
483
  if (name === 'A') {
367
- out += serializeAnchor(node, unsupported);
484
+ out = appendPiece(out, serializeAnchor(node, unsupported));
368
485
  lastMarkTag = null;
369
486
  firstSegment = false;
370
487
  continue;
@@ -402,7 +519,7 @@
402
519
  // reach this code.
403
520
  const hardBreak = typeof node.getAttribute === 'function' &&
404
521
  node.getAttribute('data-hard-break') === '1';
405
- out += hardBreak ? '\\\n' : '<br>';
522
+ out = appendPiece(out, hardBreak ? '\\\n' : '<br>');
406
523
  lastMarkTag = null;
407
524
  firstSegment = false;
408
525
  continue;
@@ -524,17 +641,21 @@
524
641
  // the ampersand and the backslash are written as the character references
525
642
  // the browser reads back as the same character. The reader side below
526
643
  // 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.
644
+ // Final review M2 (R25 / R7), widened by v3.17.4 E1 (R10): every character
645
+ // reference the reader decodes — marked passes them into the alt attribute
646
+ // untouched and the browser decodes them — so the field shows what the
647
+ // reader shows. A decimal or hex number needs no table. A name goes through
648
+ // the installed decoder (the client installs one that asks the page itself,
649
+ // so every name the browser knows decodes); with none installed, the six
650
+ // names amp, lt, gt, quot, apos and nbsp. A name the decoder does not know
651
+ // stays as written in the field, and is written so that it renders as that
652
+ // text. An edited alt is written as the decoded character itself.
535
653
  const ALT_ESCAPE = { '[': '\\[', ']': '\\]', '&': '&amp;', '`': '&#96;', '\\': '&#92;' };
536
654
  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;
655
+ const defaultNamedReference = (name) => ALT_NAMED_CHAR[name];
656
+ let namedReference = defaultNamedReference;
657
+ function setNamedReferenceDecoder(fn) { namedReference = typeof fn === 'function' ? fn : defaultNamedReference; }
658
+ const ALT_UNESCAPE = /\\([!-\/:-@\[-`{-~])|&(?:#([0-9]+)|#[xX]([0-9a-fA-F]+)|([A-Za-z][A-Za-z0-9]*));/g;
538
659
  // A number the HTML parser would not give as itself (zero, a surrogate,
539
660
  // past U+10FFFF) is U+FFFD there, so it is here too.
540
661
  function altCodePoint(n) {
@@ -544,7 +665,8 @@
544
665
  if (c !== undefined) return c;
545
666
  if (dec !== undefined) return altCodePoint(parseInt(dec, 10));
546
667
  if (hex !== undefined) return altCodePoint(parseInt(hex, 16));
547
- return ALT_NAMED_CHAR[name];
668
+ const r = namedReference(name);
669
+ return typeof r === 'string' ? r : m;
548
670
  }
549
671
 
550
672
  function parseImageParagraph(text) {
@@ -576,5 +698,5 @@
576
698
  return out;
577
699
  }
578
700
 
579
- return { serializeInline, canWysiwyg, escapeText, escapeBlockStarts, normalizeHref, isPastedUrl, parseImageParagraph, rewriteImage };
701
+ return { serializeInline, canWysiwyg, escapeText, escapeBlockStarts, normalizeHref, isPastedUrl, parseImageParagraph, rewriteImage, setNamedReferenceDecoder };
580
702
  });
@@ -55,6 +55,140 @@ function addStrikethroughRule(service) {
55
55
  });
56
56
  }
57
57
 
58
+ // --- bold / italic next to punctuation (v3.17.5 R2) -------------------------
59
+ //
60
+ // CommonMark only takes a `*` run whose inside character, when it is
61
+ // punctuation (marked 14: \p{P}\p{S}), has whitespace, punctuation or the edge
62
+ // of the text on its outside. turndown's own strong / emphasis rules wrap
63
+ // blind, so a pasted `前面<b>「引號」</b>後面` became `前面**「引號」**後面` and
64
+ // the reader showed the asterisks. These two rules replace them and put
65
+ // `<!-- -->` (marked reads an HTML tag as punctuation) on a side that needs
66
+ // it -- the same separator, for the same rule, as walkChildren() in
67
+ // inline-md.js; the classes below are that file's FLANK_WS / FLANK_PUNCT.
68
+ // Whitespace at the edges never reaches them: turndown moves it outside
69
+ // first (`node.flankingWhitespace`), and a side that got some needs nothing.
70
+ //
71
+ // The outside character is read from the DOM, because the markdown around
72
+ // this node does not exist yet: the nearest text before / after it within the
73
+ // block, through bare inline wrappers. A neighbour that writes a delimiter or
74
+ // a bracket of its own there (a parent <b>, <a>, <code>...) is punctuation; an
75
+ // <img>, <br> or <hr> writes `)` or whitespace. Any other element with no text
76
+ // (a Word bookmark `<a name>`, an empty <span>, `<o:p>`) writes nothing and is
77
+ // looked past; the wrappers below write only their content (v3.17.5 final
78
+ // review I3: <u>, <sup>, <ins>... were missing, and an empty neighbour stopped
79
+ // the search).
80
+ //
81
+ // Before the run, the character is one UTF-16 unit, as marked reads it
82
+ // (`token.raw.slice(-1)`): an astral symbol there (📌) is a lone surrogate to
83
+ // it, a word character (final review I2).
84
+ var FLANK_WS = /\s/;
85
+ var FLANK_PUNCT = /[\p{P}\p{S}]/u;
86
+ var TRANSPARENT_INLINE = /^(SPAN|FONT|MARK|SMALL|BIG|ABBR|CITE|DFN|Q|TIME|DATA|BDI|BDO|U|INS|SUB|SUP|KBD|SAMP|VAR|LABEL|O:P)$/;
87
+ var WRITES_ITS_OWN = /^(IMG|BR|HR)$/;
88
+
89
+ function isWordish(ch) {
90
+ return ch !== undefined && !FLANK_WS.test(ch) && !FLANK_PUNCT.test(ch);
91
+ }
92
+
93
+ function edgeChar(text, last) {
94
+ if (!text) return undefined;
95
+ return last ? text[text.length - 1] : Array.from(text)[0];
96
+ }
97
+
98
+ // Only an inline element's text is read as the neighbour. A block, or an
99
+ // element not on these lists, is a line edge or unknown: the answer is
100
+ // "not a word character", so no separator -- which can never put `<!--` at a
101
+ // line start, where marked reads it as an HTML block that ends the paragraph
102
+ // (re-review I1: `<div>前</div><b>「x」</b>`).
103
+ var MARKUP_INLINE = /^(STRONG|B|EM|I|DEL|S|STRIKE|A|CODE)$/;
104
+
105
+ // An element with no text writes nothing only when it is one of the inline
106
+ // elements above and nothing inside it writes either. An <img>, <br> or <hr>
107
+ // writes `)` or a line break (re-review C1: `<span><br></span>` was looked
108
+ // past, and the separator landed at the start of a line); a block -- or an
109
+ // element not on the lists -- writes a line edge even when empty (round 3,
110
+ // I1': `<p></p>` is a blank line). A bold / italic with no text is dropped by
111
+ // its own rule whatever it holds (`<b><br></b>`).
112
+ function writesNothing(el) {
113
+ var name = el.nodeName;
114
+ if (/^(STRONG|B|EM|I)$/.test(name) && !el.textContent.trim()) return true;
115
+ if (!TRANSPARENT_INLINE.test(name) && !MARKUP_INLINE.test(name)) return false;
116
+ for (var c = el.firstChild; c; c = c.nextSibling) {
117
+ if (c.nodeType === 1 && !writesNothing(c)) return false;
118
+ }
119
+ return true;
120
+ }
121
+
122
+ // The character a neighbour writes at the edge facing the run: its last (or
123
+ // first) descendant that writes anything. A <br> / <img> / <hr> there, or a
124
+ // block or unknown element on the way, answers undefined -- so
125
+ // `<span>前<br></span>` is a line edge, not 前.
126
+ function edgeOf(node, last) {
127
+ if (node.nodeType === 3) return node.textContent ? edgeChar(node.textContent, last) : undefined;
128
+ if (node.nodeType !== 1) return undefined;
129
+ if (!TRANSPARENT_INLINE.test(node.nodeName) && !MARKUP_INLINE.test(node.nodeName)) return undefined;
130
+ for (var c = last ? node.lastChild : node.firstChild; c; c = last ? c.previousSibling : c.nextSibling) {
131
+ if (c.nodeType === 3 && !c.textContent) continue;
132
+ if (c.nodeType === 1 && !c.textContent && writesNothing(c)) continue;
133
+ if (c.nodeType !== 1 && c.nodeType !== 3) continue;
134
+ return edgeOf(c, last);
135
+ }
136
+ return undefined;
137
+ }
138
+
139
+ function outsideChar(node, dir) {
140
+ for (var n = node; n; n = n.parentNode) {
141
+ var sib = n[dir];
142
+ while (sib && (sib.nodeType === 1 ? !sib.textContent && writesNothing(sib)
143
+ : sib.nodeType !== 3 || !sib.textContent)) sib = sib[dir];
144
+ if (sib) return edgeOf(sib, dir === 'previousSibling');
145
+ var parent = n.parentNode;
146
+ if (!parent || parent.nodeType !== 1 || !TRANSPARENT_INLINE.test(parent.nodeName)) return undefined;
147
+ }
148
+ return undefined;
149
+ }
150
+
151
+ function insideChar(md, last) {
152
+ var chars = Array.from(md);
153
+ if (last) {
154
+ var i = chars.length - 1;
155
+ while (i >= 0 && chars[i] === '*') i -= 1;
156
+ return chars[i];
157
+ }
158
+ var j = 0;
159
+ while (j < chars.length && chars[j] === '*') j += 1;
160
+ return chars[j];
161
+ }
162
+
163
+ function flankedReplacement(delimiterOf) {
164
+ return function (content, node, options) {
165
+ if (!content.trim()) return '';
166
+ // turndown moves the text's own edge whitespace outside, but not a <br>'s
167
+ // ` \n` at an edge (final review): peel that too
168
+ var lead = /^\s*/.exec(content)[0];
169
+ var trail = /\s*$/.exec(content)[0];
170
+ var delim = delimiterOf(options);
171
+ var md = delim + content.slice(lead.length, content.length - trail.length) + delim;
172
+ var ws = node.flankingWhitespace || { leading: '', trailing: '' };
173
+ var before = !lead && !ws.leading && FLANK_PUNCT.test(insideChar(md, false) || '') &&
174
+ isWordish(outsideChar(node, 'previousSibling'));
175
+ var after = !trail && !ws.trailing && FLANK_PUNCT.test(insideChar(md, true) || '') &&
176
+ isWordish(outsideChar(node, 'nextSibling'));
177
+ return lead + (before ? '<!-- -->' : '') + md + (after ? '<!-- -->' : '') + trail;
178
+ };
179
+ }
180
+
181
+ function addFlankingEmphasisRules(service) {
182
+ service.addRule('strongFlanking', {
183
+ filter: ['strong', 'b'],
184
+ replacement: flankedReplacement(function (o) { return o.strongDelimiter; })
185
+ });
186
+ service.addRule('emphasisFlanking', {
187
+ filter: ['em', 'i'],
188
+ replacement: flankedReplacement(function (o) { return o.emDelimiter; })
189
+ });
190
+ }
191
+
58
192
  function isFirstTbody(el) {
59
193
  var prev = el.previousSibling;
60
194
  return (
@@ -209,6 +343,7 @@ function htmlToMarkdown(html) {
209
343
  emDelimiter: '*'
210
344
  });
211
345
  addStrikethroughRule(service);
346
+ addFlankingEmphasisRules(service);
212
347
  addTableRules(service);
213
348
  return service.turndown(html == null ? '' : String(html));
214
349
  }
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.5",
4
4
  "description": "Markdown → HTML / PDF renderer with WaveDrom, Mermaid, Graphviz, and draw.io support",
5
5
  "keywords": [
6
6
  "markdown",