@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.
- package/lib/editor/client.js +247 -49
- package/lib/editor/inline-md.js +154 -32
- package/lib/editor/paste-md.js +135 -0
- package/lib/md2doc.js +21 -4
- package/lib/theme/tokens.js +2 -1
- package/package.json +1 -1
package/lib/editor/client.js
CHANGED
|
@@ -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
|
-
|
|
127
|
-
|
|
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
|
|
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 (¬it;, ©x;) 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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])
|
|
20629
|
-
|
|
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; //
|
|
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
|
-
|
|
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).
|
|
24291
|
-
// the drag's own
|
|
24292
|
-
//
|
|
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`)
|
|
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
|
-
|
|
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 ||
|
|
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)
|
|
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
|
|
24456
|
-
//
|
|
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.
|
|
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 (
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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 })
|
|
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;
|
package/lib/editor/inline-md.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
340
|
-
|
|
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 `<` -> `<` rule above, so it can never collide with a real
|
|
354
471
|
// toolbar-made underline.
|
|
355
|
-
out
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
528
|
-
// reader decodes — marked passes them into the alt attribute
|
|
529
|
-
// the browser decodes them — so the field shows what the
|
|
530
|
-
// decimal or hex number
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
//
|
|
534
|
-
// is written so that it renders as that
|
|
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 = { '[': '\\[', ']': '\\]', '&': '&', '`': '`', '\\': '\' };
|
|
536
654
|
const ALT_NAMED_CHAR = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: '\u00a0' };
|
|
537
|
-
const
|
|
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
|
-
|
|
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
|
});
|
package/lib/editor/paste-md.js
CHANGED
|
@@ -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
|
-
|
|
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>'
|
package/lib/theme/tokens.js
CHANGED
|
@@ -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
|
-
|
|
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;',
|