@helping-ai-workflow/md2doc 3.13.0 → 3.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/editor/client.js +2768 -202
- package/lib/editor/inline-md.js +66 -1
- package/lib/editor/list-md.js +8 -1
- package/lib/editor/toolbar-model.js +11 -1
- package/lib/md2doc.js +453 -83
- package/lib/theme/runtime.js +279 -27
- package/lib/theme/tokens.js +3 -0
- package/package.json +1 -1
package/lib/md2doc.js
CHANGED
|
@@ -185,6 +185,9 @@ function buildKatexStyleTag() {
|
|
|
185
185
|
// it per-call would grow the registry unboundedly in a long-lived process
|
|
186
186
|
// (the editor server calls renderMarkdown() once per edit).
|
|
187
187
|
let marked, Renderer;
|
|
188
|
+
// v3.13.1 (T9b): set by renderMarkdown() around its synchronous parse — see the
|
|
189
|
+
// blockKatex renderer below. Null at every other time.
|
|
190
|
+
let blockMathText = null;
|
|
188
191
|
try {
|
|
189
192
|
({ marked, Renderer } = require('marked'));
|
|
190
193
|
|
|
@@ -232,6 +235,14 @@ try {
|
|
|
232
235
|
// default no-space-adjacency rules keep prose currency ($5 to $10) unrendered.
|
|
233
236
|
const markedKatex = require('marked-katex-extension');
|
|
234
237
|
marked.use(markedKatex({ throwOnError: false }));
|
|
238
|
+
// v3.13.1 (T9b): a $$ block renders through the extension's own renderer,
|
|
239
|
+
// which the per-call renderer overrides never reach, so the search index
|
|
240
|
+
// would miss it. This one hands its TeX to the render in progress, then
|
|
241
|
+
// returns false so the extension's renderer still draws the formula.
|
|
242
|
+
marked.use({ extensions: [{ name: 'blockKatex', renderer(token) {
|
|
243
|
+
if (blockMathText) blockMathText(token.text);
|
|
244
|
+
return false;
|
|
245
|
+
} }] });
|
|
235
246
|
} catch (e) {
|
|
236
247
|
console.error('[ERROR] marked not found — install with: npm install marked');
|
|
237
248
|
console.error(e.message);
|
|
@@ -335,7 +346,10 @@ function renderEditModeList(listToken, blocks, biRef, out) {
|
|
|
335
346
|
let inner;
|
|
336
347
|
if (!item.loose && ownTokens.length === 1 && ownTokens[0].type === 'text') {
|
|
337
348
|
// Tight item with a single text token: use static parseInline to get
|
|
338
|
-
// clean inline HTML without a <p> wrapper.
|
|
349
|
+
// clean inline HTML without a <p> wrapper. parseInline never reaches the
|
|
350
|
+
// block renderers, so the item's text is indexed for search here
|
|
351
|
+
// (v3.13.1 T9b; biRef.indexInline is set by renderMarkdown).
|
|
352
|
+
if (biRef.indexInline) biRef.indexInline(ownTokens[0].tokens);
|
|
339
353
|
inner = marked.Parser.parseInline(ownTokens[0].tokens);
|
|
340
354
|
} else {
|
|
341
355
|
// Loose item or non-standard own-token shape: use marked.parser, which
|
|
@@ -345,7 +359,10 @@ function renderEditModeList(listToken, blocks, biRef, out) {
|
|
|
345
359
|
// marked pretty-prints a trailing "\n" after the closing tag, which is
|
|
346
360
|
// insignificant to every serializer (list-md.js drops it via isBlankText)
|
|
347
361
|
// but would render as a phantom blank line inside the item.
|
|
348
|
-
|
|
362
|
+
// biRef.parseItem (renderMarkdown) is marked.parser with the search
|
|
363
|
+
// index told that a text run here is wrapped in a made-up paragraph,
|
|
364
|
+
// as in a loose item, so each item is indexed once (v3.13.1 T9b).
|
|
365
|
+
inner = (biRef.parseItem ? biRef.parseItem(ownTokens) : marked.parser(ownTokens)).trim();
|
|
349
366
|
}
|
|
350
367
|
// The receiving half of list-md.js's SETEXT ESCAPE (see
|
|
351
368
|
// escapeSetextHazard() there): an empty list item that is the first child
|
|
@@ -732,21 +749,27 @@ async function renderMarkdown(mdText, srcPath, opts = {}) {
|
|
|
732
749
|
const sections = [];
|
|
733
750
|
let currentSection = null;
|
|
734
751
|
|
|
735
|
-
|
|
752
|
+
// The search index: one string per section, built from the text the
|
|
753
|
+
// reader sees, the way the in-text marks read it (highlightFirstOccurrence
|
|
754
|
+
// in the reader runtime). Both sides must agree, or the results list finds
|
|
755
|
+
// a phrase the reader cannot mark.
|
|
756
|
+
function startSection({ id, depth, text, search }) {
|
|
736
757
|
currentSection = {
|
|
737
758
|
id,
|
|
738
759
|
depth,
|
|
739
760
|
title: text,
|
|
740
|
-
searchTextParts: [text],
|
|
761
|
+
searchTextParts: [search === undefined ? text : search.replace(/\s+/g, ' ').trim()],
|
|
741
762
|
};
|
|
742
763
|
sections.push(currentSection);
|
|
743
764
|
}
|
|
744
765
|
|
|
766
|
+
// Takes plain text (sectionInlineText, code, TeX): no tag stripping here,
|
|
767
|
+
// so code such as "if (a<b && c>d)" is indexed as written.
|
|
745
768
|
function appendSectionText(value) {
|
|
746
769
|
if (!currentSection || !value) {
|
|
747
770
|
return;
|
|
748
771
|
}
|
|
749
|
-
const clean =
|
|
772
|
+
const clean = String(value).replace(/\s+/g, ' ').trim();
|
|
750
773
|
if (clean) {
|
|
751
774
|
currentSection.searchTextParts.push(clean);
|
|
752
775
|
}
|
|
@@ -758,13 +781,37 @@ async function renderMarkdown(mdText, srcPath, opts = {}) {
|
|
|
758
781
|
}
|
|
759
782
|
for (const cell of cells) {
|
|
760
783
|
if (cell && Array.isArray(cell.tokens)) {
|
|
761
|
-
appendSectionText(
|
|
784
|
+
appendSectionText(sectionInlineText(cell.tokens));
|
|
762
785
|
} else if (cell && typeof cell.text === 'string') {
|
|
763
|
-
appendSectionText(cell.text);
|
|
786
|
+
appendSectionText(stripHtmlTags(cell.text));
|
|
764
787
|
}
|
|
765
788
|
}
|
|
766
789
|
}
|
|
767
790
|
|
|
791
|
+
// v3.13.1 (T9b): inline tokens as the reader sees them, for the search
|
|
792
|
+
// index. Inline math is its TeX source (the reader runtime reads a formula
|
|
793
|
+
// from its TeX annotation); a hard break or a literal <br> is a space (the
|
|
794
|
+
// runtime reads a br element as one); other inline html carries no text;
|
|
795
|
+
// an image's alt text is not on the page. A text token's text is escaped
|
|
796
|
+
// by marked, so a tag in it is real markup (the checkbox a loose task item
|
|
797
|
+
// gets) and is stripped before the entities are decoded.
|
|
798
|
+
function sectionInlineText(tokens) {
|
|
799
|
+
if (!Array.isArray(tokens)) {
|
|
800
|
+
return '';
|
|
801
|
+
}
|
|
802
|
+
return tokens
|
|
803
|
+
.map((item) => {
|
|
804
|
+
if (item.type === 'br') return ' ';
|
|
805
|
+
if (item.type === 'html') return /^<br\b/i.test(item.text || '') ? ' ' : '';
|
|
806
|
+
if (item.type === 'image') return '';
|
|
807
|
+
if (item.type === 'inlineKatex') return item.text || '';
|
|
808
|
+
if (item.type === 'codespan') return unescapeHtml(item.text || '');
|
|
809
|
+
if (item.tokens) return sectionInlineText(item.tokens);
|
|
810
|
+
return unescapeHtml(stripHtmlTags(String(item.text || '').replace(/<br\b[^>]*>/gi, ' ')));
|
|
811
|
+
})
|
|
812
|
+
.join('');
|
|
813
|
+
}
|
|
814
|
+
|
|
768
815
|
function buildTocTree(items) {
|
|
769
816
|
const root = [];
|
|
770
817
|
const stack = [{ depth: 0, children: root }];
|
|
@@ -978,9 +1025,64 @@ ${itemsHtml}
|
|
|
978
1025
|
return out + '>';
|
|
979
1026
|
};
|
|
980
1027
|
|
|
1028
|
+
// R7 甲 (v3.13.1): a block that is nothing but one or more native <svg>
|
|
1029
|
+
// elements gets the class inline-svg, so dark mode can put it on a white
|
|
1030
|
+
// plate like images and draw.io. Decided on the block's own source, not
|
|
1031
|
+
// its tokens: marked lexes '<svg><text>x</text></svg>' on one line as a
|
|
1032
|
+
// paragraph of html,html,text,html,html tokens, and '<svg ...>' alone on
|
|
1033
|
+
// its first line as one block html token. Outside the svg elements only
|
|
1034
|
+
// whitespace may appear, so an svg inside prose is never wrapped. Reader
|
|
1035
|
+
// output only: edit mode keeps its block DOM exactly as it was.
|
|
1036
|
+
// Not covered: an svg in a tight list item or a table cell reaches the
|
|
1037
|
+
// renderer as inline tokens with no block of its own, so it gets no plate.
|
|
1038
|
+
// HTML comments are dropped before any <svg> is looked at: '<!-- <svg> -->'
|
|
1039
|
+
// opens nothing.
|
|
1040
|
+
function noComments(src) { return String(src || '').replace(/<!--[\s\S]*?-->/g, ''); }
|
|
1041
|
+
function isSvgOnly(src) {
|
|
1042
|
+
const s = noComments(src).trim();
|
|
1043
|
+
if (!/^<svg[\s>]/i.test(s) || !/<\/svg>$/i.test(s)) return false;
|
|
1044
|
+
const tag = /<(\/?)svg\b[^>]*?(\/?)>/gi;
|
|
1045
|
+
let depth = 0, last = 0, m;
|
|
1046
|
+
while ((m = tag.exec(s)) !== null) {
|
|
1047
|
+
if (depth === 0 && s.slice(last, m.index).trim()) return false;
|
|
1048
|
+
if (m[1]) depth--; else if (!m[2]) depth++;
|
|
1049
|
+
if (depth < 0) return false;
|
|
1050
|
+
last = tag.lastIndex;
|
|
1051
|
+
}
|
|
1052
|
+
return depth === 0 && !s.slice(last).trim();
|
|
1053
|
+
}
|
|
1054
|
+
// An outer <svg> that a blank line split into several html blocks is still
|
|
1055
|
+
// open while its later blocks render; a <div> there would close it early, so
|
|
1056
|
+
// no html block is wrapped until it closes. Only raw html blocks can keep an
|
|
1057
|
+
// svg open: a paragraph's <p> and </p> end any open svg in the browser's
|
|
1058
|
+
// parser (measured in Chromium and WebKit), so a paragraph always resets the
|
|
1059
|
+
// count, and text in a code span or an svg left unclosed inside a paragraph
|
|
1060
|
+
// never holds later blocks hostage. Counted per block, in document order.
|
|
1061
|
+
let openSvg = 0;
|
|
1062
|
+
function svgNet(src) {
|
|
1063
|
+
const tag = /<(\/?)svg\b[^>]*?(\/?)>/gi;
|
|
1064
|
+
let n = 0, m;
|
|
1065
|
+
const s = noComments(src);
|
|
1066
|
+
while ((m = tag.exec(s)) !== null) { if (m[1]) n--; else if (!m[2]) n++; }
|
|
1067
|
+
return n;
|
|
1068
|
+
}
|
|
1069
|
+
function wrapsSvg(src) {
|
|
1070
|
+
const wrap = openSvg === 0 && isSvgOnly(src);
|
|
1071
|
+
openSvg = Math.max(0, openSvg + svgNet(src));
|
|
1072
|
+
return wrap;
|
|
1073
|
+
}
|
|
1074
|
+
function wrapsSvgParagraph(src) {
|
|
1075
|
+
openSvg = 0;
|
|
1076
|
+
return isSvgOnly(src);
|
|
1077
|
+
}
|
|
1078
|
+
|
|
981
1079
|
const baseHtml = renderer.html.bind(renderer);
|
|
982
1080
|
renderer.html = function(token) {
|
|
983
|
-
|
|
1081
|
+
const out = inlineImagesInHtmlChunk(baseHtml.apply(this, arguments));
|
|
1082
|
+
if (!opts.editMode && token && token.block && wrapsSvg(token.text)) {
|
|
1083
|
+
return '<div class="inline-svg">' + out.trim() + '</div>\n';
|
|
1084
|
+
}
|
|
1085
|
+
return out;
|
|
984
1086
|
};
|
|
985
1087
|
|
|
986
1088
|
// Spec §3.12 — a markdown HARD BREAK and a literal '<br>' the source spelled
|
|
@@ -1048,6 +1150,8 @@ ${itemsHtml}
|
|
|
1048
1150
|
return `\n<div class="mermaid">\n${escapeHtml(code)}\n</div>\n`;
|
|
1049
1151
|
}
|
|
1050
1152
|
if (lang === 'math') {
|
|
1153
|
+
// Indexed as its TeX, like inline and $$ math (T9b).
|
|
1154
|
+
appendSectionText(code);
|
|
1051
1155
|
// KaTeX renders synchronously to static HTML (class="katex"); no client
|
|
1052
1156
|
// runtime, no async post-pass. throwOnError:false degrades a bad formula
|
|
1053
1157
|
// to red error text instead of crashing the whole render.
|
|
@@ -1068,7 +1172,9 @@ ${itemsHtml}
|
|
|
1068
1172
|
}
|
|
1069
1173
|
// Default: fenced code. v3.10.0 colours registered languages here, at
|
|
1070
1174
|
// render time (lib/highlight.js); an unknown or missing language stays
|
|
1071
|
-
// plain escaped text.
|
|
1175
|
+
// plain escaped text. v3.13.1 (T9b): indexed for search as written, so a
|
|
1176
|
+
// name that only a code block holds is found and marked.
|
|
1177
|
+
appendSectionText(code);
|
|
1072
1178
|
const coloured = highlightCode(code, lang);
|
|
1073
1179
|
// The info string goes into an attribute: escape it (an info string such
|
|
1074
1180
|
// as x" onmouseover=… must stay text). Ordinary language names are unchanged.
|
|
@@ -1103,21 +1209,54 @@ ${itemsHtml}
|
|
|
1103
1209
|
id: headingId,
|
|
1104
1210
|
text: headingText,
|
|
1105
1211
|
});
|
|
1106
|
-
startSection({ depth, id: headingId, text: headingText });
|
|
1212
|
+
startSection({ depth, id: headingId, text: headingText, search: sectionInlineText(token.tokens) });
|
|
1107
1213
|
|
|
1108
1214
|
return `<h${depth} id="${headingId}" class="heading-with-anchor" data-reader-heading data-reader-depth="${depth}">${headingHtml}<a class="heading-anchor" href="#${headingId}" aria-label="Link to this section">#</a></h${depth}>\n`;
|
|
1109
1215
|
};
|
|
1110
1216
|
|
|
1217
|
+
// A list item's text is indexed as it renders, in reading order with its
|
|
1218
|
+
// block children: marked's parser hands each of the item's block-level
|
|
1219
|
+
// text tokens (the only text tokens that carry inline .tokens) to
|
|
1220
|
+
// renderer.text. In a loose item the parser then wraps that run of text in
|
|
1221
|
+
// a paragraph it makes up from the HTML it just rendered (a formula as
|
|
1222
|
+
// KaTeX markup); renderer.text arms a one-shot skip for that paragraph.
|
|
1223
|
+
// Real paragraph tokens (in a loose item, those after a nested list) are
|
|
1224
|
+
// indexed by renderer.paragraph as usual.
|
|
1225
|
+
let looseItemText = false;
|
|
1226
|
+
let skipMadeUpParagraph = false;
|
|
1227
|
+
const baseText = renderer.text.bind(renderer);
|
|
1228
|
+
renderer.text = function(token) {
|
|
1229
|
+
if (token && Array.isArray(token.tokens)) {
|
|
1230
|
+
appendSectionText(sectionInlineText(token.tokens));
|
|
1231
|
+
if (looseItemText) skipMadeUpParagraph = true;
|
|
1232
|
+
}
|
|
1233
|
+
return baseText(token);
|
|
1234
|
+
};
|
|
1111
1235
|
const baseParagraph = renderer.paragraph.bind(renderer);
|
|
1112
1236
|
renderer.paragraph = function(token) {
|
|
1113
|
-
|
|
1237
|
+
if (skipMadeUpParagraph) {
|
|
1238
|
+
skipMadeUpParagraph = false;
|
|
1239
|
+
} else {
|
|
1240
|
+
appendSectionText(sectionInlineText(token.tokens));
|
|
1241
|
+
}
|
|
1242
|
+
// R7 甲: see isSvgOnly above renderer.html.
|
|
1243
|
+
if (!opts.editMode && wrapsSvgParagraph(token.raw)) {
|
|
1244
|
+
return baseParagraph(token).replace(/^<p>/, '<p class="inline-svg">');
|
|
1245
|
+
}
|
|
1114
1246
|
return baseParagraph(token);
|
|
1115
1247
|
};
|
|
1116
1248
|
|
|
1117
1249
|
const baseListitem = renderer.listitem.bind(renderer);
|
|
1118
1250
|
renderer.listitem = function(token) {
|
|
1119
|
-
|
|
1120
|
-
const
|
|
1251
|
+
// Indexes nothing itself: see renderer.text above.
|
|
1252
|
+
const outerLoose = looseItemText;
|
|
1253
|
+
looseItemText = !!token.loose;
|
|
1254
|
+
let html;
|
|
1255
|
+
try {
|
|
1256
|
+
html = baseListitem(token);
|
|
1257
|
+
} finally {
|
|
1258
|
+
looseItemText = outerLoose;
|
|
1259
|
+
}
|
|
1121
1260
|
if (!token.task) return html;
|
|
1122
1261
|
// v3.10.0: a task item drops its bullet and keeps its own text in a span,
|
|
1123
1262
|
// so a checked item's strike-through stops at its own words — CSS
|
|
@@ -1133,8 +1272,14 @@ ${itemsHtml}
|
|
|
1133
1272
|
|
|
1134
1273
|
const baseBlockquote = renderer.blockquote.bind(renderer);
|
|
1135
1274
|
renderer.blockquote = function(token) {
|
|
1136
|
-
|
|
1137
|
-
|
|
1275
|
+
// Its paragraphs index themselves (indexing them here too doubled them).
|
|
1276
|
+
const outerLoose = looseItemText;
|
|
1277
|
+
looseItemText = false;
|
|
1278
|
+
try {
|
|
1279
|
+
return baseBlockquote(token);
|
|
1280
|
+
} finally {
|
|
1281
|
+
looseItemText = outerLoose;
|
|
1282
|
+
}
|
|
1138
1283
|
};
|
|
1139
1284
|
|
|
1140
1285
|
renderer.table = function(token) {
|
|
@@ -1205,6 +1350,8 @@ ${itemsHtml}
|
|
|
1205
1350
|
return `<a href="#${escAttr(slug)}">[${escAttr(body)}]</a>`;
|
|
1206
1351
|
});
|
|
1207
1352
|
|
|
1353
|
+
// A $$ block's TeX reaches the index through the blockKatex hook at the
|
|
1354
|
+
// top of the file, set only while this render's synchronous parse runs.
|
|
1208
1355
|
if (opts.editMode) {
|
|
1209
1356
|
// Ranges are computed on mdPre. The [[...]] preprocessing above only
|
|
1210
1357
|
// replaces text within single lines and never adds/removes newlines,
|
|
@@ -1224,19 +1371,41 @@ ${itemsHtml}
|
|
|
1224
1371
|
// flat list renderer above threads through its own recursion (see
|
|
1225
1372
|
// liRunStartsHere()).
|
|
1226
1373
|
const biRef = { v: 0, prevLi: null, types: [] };
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1374
|
+
// The edit page searches this index too: renderEditModeList indexes each
|
|
1375
|
+
// item through these two, once, in the edit DOM's order (an item's own
|
|
1376
|
+
// block, then its nested lists).
|
|
1377
|
+
biRef.indexInline = (inlineTokens) => appendSectionText(sectionInlineText(inlineTokens));
|
|
1378
|
+
biRef.parseItem = (ownTokens) => {
|
|
1379
|
+
const outerLoose = looseItemText;
|
|
1380
|
+
looseItemText = true;
|
|
1381
|
+
try {
|
|
1382
|
+
return marked.parser(ownTokens);
|
|
1383
|
+
} finally {
|
|
1384
|
+
looseItemText = outerLoose;
|
|
1232
1385
|
}
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1386
|
+
};
|
|
1387
|
+
try {
|
|
1388
|
+
blockMathText = appendSectionText;
|
|
1389
|
+
for (const t of tokens) {
|
|
1390
|
+
if (t.type === 'space') continue;
|
|
1391
|
+
if (t.type === 'list') {
|
|
1392
|
+
parts.push(...renderEditModeList(t, blocks, biRef));
|
|
1393
|
+
continue;
|
|
1394
|
+
}
|
|
1395
|
+
resetLiRunState(biRef); // §3.8 rule (c): a non-li block ends every run
|
|
1396
|
+
const b = blocks[biRef.v++];
|
|
1397
|
+
const inner = marked.parser([t]);
|
|
1398
|
+
// v3.14.0 T10: the reader wraps an svg-only block in .inline-svg for the
|
|
1399
|
+
// dark white plate; edit mode keeps its DOM, so it marks the wrapper.
|
|
1400
|
+
const svgPlate = ((t.type === 'html' && t.block && wrapsSvg(t.text)) ||
|
|
1401
|
+
(t.type === 'paragraph' && wrapsSvgParagraph(t.raw))) ? ' data-svg-plate="1"' : '';
|
|
1402
|
+
parts.push(
|
|
1403
|
+
`<div class="ed-block" data-block-id="${b.id}" data-block-type="${b.type}"${svgPlate}>` +
|
|
1404
|
+
inner + '</div>'
|
|
1405
|
+
);
|
|
1406
|
+
}
|
|
1407
|
+
} finally {
|
|
1408
|
+
blockMathText = null;
|
|
1240
1409
|
}
|
|
1241
1410
|
// Development-time alignment guard: the render walk and blockmap.js's
|
|
1242
1411
|
// pushListItemBlocks must consume blocks[] in lockstep. A mismatch means
|
|
@@ -1270,7 +1439,12 @@ ${itemsHtml}
|
|
|
1270
1439
|
}
|
|
1271
1440
|
bodyHtml = parts.join('\n');
|
|
1272
1441
|
} else {
|
|
1273
|
-
|
|
1442
|
+
try {
|
|
1443
|
+
blockMathText = appendSectionText;
|
|
1444
|
+
bodyHtml = marked.parse(mdPre);
|
|
1445
|
+
} finally {
|
|
1446
|
+
blockMathText = null;
|
|
1447
|
+
}
|
|
1274
1448
|
}
|
|
1275
1449
|
// Both the ```math fence and the $/$$ extension emit class="katex" — a single
|
|
1276
1450
|
// post-parse scan is the source of truth for conditional CSS injection.
|
|
@@ -1286,6 +1460,11 @@ ${itemsHtml}
|
|
|
1286
1460
|
).replace(/</g, '\\u003c');
|
|
1287
1461
|
if (tocItems.length > 0) {
|
|
1288
1462
|
const tocTree = buildTocTree(tocItems);
|
|
1463
|
+
// v3.13.1 (R3): two or more H1s kept as the TOC's top level are
|
|
1464
|
+
// chapters; the marker lets CSS set each chapter apart with a rule and
|
|
1465
|
+
// a gap. A flattened single-H1 TOC (its H2s on top) never carries it.
|
|
1466
|
+
const tocRoots = flattenSingleRoot(tocTree);
|
|
1467
|
+
const tocChapters = tocRoots === tocTree && tocTree.filter((n) => n.depth === 1).length >= 2;
|
|
1289
1468
|
tocHtml = `<aside class="reader-sidebar" data-reader-sidebar>
|
|
1290
1469
|
<section class="reader-tools">
|
|
1291
1470
|
<label class="reader-search-label" for="doc-search-input">Search</label>
|
|
@@ -1304,7 +1483,7 @@ ${itemsHtml}
|
|
|
1304
1483
|
</div>
|
|
1305
1484
|
<div id="search-results-list"></div>
|
|
1306
1485
|
</section>
|
|
1307
|
-
<nav class="toc" aria-label="Table of contents" data-reader-toc>
|
|
1486
|
+
<nav class="toc" aria-label="Table of contents" data-reader-toc${tocChapters ? ' data-toc-chapters' : ''}>
|
|
1308
1487
|
<div class="toc-header">
|
|
1309
1488
|
<span class="toc-title">Contents</span>
|
|
1310
1489
|
<div class="toc-header-actions">
|
|
@@ -1314,7 +1493,7 @@ ${itemsHtml}
|
|
|
1314
1493
|
</div>
|
|
1315
1494
|
<div class="toc-breadcrumb" data-toc-breadcrumb aria-label="Current location"></div>
|
|
1316
1495
|
</div>
|
|
1317
|
-
${renderTocNodes(
|
|
1496
|
+
${renderTocNodes(tocRoots)}
|
|
1318
1497
|
</nav>
|
|
1319
1498
|
</aside>
|
|
1320
1499
|
<div class="sidebar-splitter" id="sidebar-splitter" role="separator" aria-orientation="vertical" aria-label="Resize sidebar"></div>`;
|
|
@@ -2539,6 +2718,7 @@ const html = `<!DOCTYPE html>
|
|
|
2539
2718
|
padding: 0 2px;
|
|
2540
2719
|
border-radius: 3px;
|
|
2541
2720
|
}
|
|
2721
|
+
mark.search-hit-block { display: block; }
|
|
2542
2722
|
.toc {
|
|
2543
2723
|
display: flex;
|
|
2544
2724
|
flex-direction: column;
|
|
@@ -2674,6 +2854,12 @@ const html = `<!DOCTYPE html>
|
|
|
2674
2854
|
.toc-list-level-1 > .toc-item > details > summary > a {
|
|
2675
2855
|
font-size: 0.95em;
|
|
2676
2856
|
}
|
|
2857
|
+
/* v3.13.1 (R3): chapters of a multi-H1 TOC, set apart by a rule and a gap; weight unchanged. */
|
|
2858
|
+
.toc[data-toc-chapters] > .toc-list-level-1 > .toc-item + .toc-item {
|
|
2859
|
+
margin-top: 10px;
|
|
2860
|
+
padding-top: 6px;
|
|
2861
|
+
border-top: 1px solid #d8dee4;
|
|
2862
|
+
}
|
|
2677
2863
|
.toc-list-level-2 > .toc-item > a,
|
|
2678
2864
|
.toc-list-level-2 > .toc-item > details > summary > a {
|
|
2679
2865
|
font-size: 0.9em;
|
|
@@ -3264,6 +3450,16 @@ ${usesDrawio ? ` /* v3.4.0 §4: a document with no drawio reference must not pa
|
|
|
3264
3450
|
outline: 2px solid var(--md-accent); outline-offset: 2px;
|
|
3265
3451
|
caret-color: var(--md-accent);
|
|
3266
3452
|
}
|
|
3453
|
+
/* v3.14.0 T4: the empty-paragraph hint. syncEmptyMark() in the client sets
|
|
3454
|
+
data-ed-empty on a paragraph surface with no text; a real paragraph shows
|
|
3455
|
+
the hint while focused, the transient (an empty document's, or one Enter
|
|
3456
|
+
left behind) shows it even unfocused so a blank line is never a blank
|
|
3457
|
+
page. An empty transient also gets a line of height of its own. */
|
|
3458
|
+
.ed-block[data-block-type="paragraph"] > .ed-wys-armed[data-ed-empty]:focus::before,
|
|
3459
|
+
.ed-block[data-ed-transient] > .ed-wys-armed[data-ed-empty]::before {
|
|
3460
|
+
content: attr(data-placeholder); color: var(--md-muted-2); pointer-events: none;
|
|
3461
|
+
}
|
|
3462
|
+
.ed-block[data-ed-transient] > .ed-wys-armed { min-height: 1.6em; }
|
|
3267
3463
|
/* Task 5: table cells armed PERMANENTLY (one edit surface per CELL, not
|
|
3268
3464
|
per table root — see armEditables()'s 'table' branch in the client
|
|
3269
3465
|
runtime) — same "no persistent outline, blue on :focus" language as
|
|
@@ -3995,6 +4191,7 @@ ${mermaidInitTag}`)
|
|
|
3995
4191
|
results: [],
|
|
3996
4192
|
selectedResultIndex: -1,
|
|
3997
4193
|
activeHighlight: null,
|
|
4194
|
+
activeHighlights: [],
|
|
3998
4195
|
};
|
|
3999
4196
|
window.__readerState = readerState;
|
|
4000
4197
|
|
|
@@ -4137,14 +4334,18 @@ ${mermaidInitTag}`)
|
|
|
4137
4334
|
// already above the line and would steal the highlight from the row that was
|
|
4138
4335
|
// just clicked. The clicked heading stays the answer for as long as it is
|
|
4139
4336
|
// still between the page top and the line, and the first scroll that moves
|
|
4140
|
-
// it out of that band hands tracking back to the line.
|
|
4337
|
+
// it out of that band hands tracking back to the line. The last sections of
|
|
4338
|
+
// a document can be too short to ever reach the line: at the bottom of the
|
|
4339
|
+
// page a clicked heading anywhere on screen keeps its row lit. Only a click
|
|
4340
|
+
// pins, so a plain scroll to the bottom still follows the line.
|
|
4141
4341
|
let pinnedHeadingId = null;
|
|
4142
4342
|
function readingLineHeading() {
|
|
4143
4343
|
const line = window.innerHeight * 0.35;
|
|
4144
4344
|
if (pinnedHeadingId) {
|
|
4145
4345
|
const pinned = document.getElementById(pinnedHeadingId);
|
|
4146
4346
|
const top = pinned ? pinned.getBoundingClientRect().top : Infinity;
|
|
4147
|
-
|
|
4347
|
+
const atBottom = window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 2;
|
|
4348
|
+
if (pinned && tocLinks.has(pinned.id) && top >= -40 && (top <= line || (atBottom && top < window.innerHeight))) return pinned;
|
|
4148
4349
|
pinnedHeadingId = null;
|
|
4149
4350
|
}
|
|
4150
4351
|
let first = null;
|
|
@@ -4183,15 +4384,54 @@ ${mermaidInitTag}`)
|
|
|
4183
4384
|
syncActiveHeading(node.id);
|
|
4184
4385
|
syncReadingTitle(node);
|
|
4185
4386
|
}
|
|
4186
|
-
|
|
4387
|
+
// Scrolls are not the only thing that moves a heading across the line: a
|
|
4388
|
+
// height-only window resize moves the line itself (and reflows nothing, so
|
|
4389
|
+
// no scroll follows), and a column resize reflows the text under it.
|
|
4390
|
+
function scheduleReadingLinePass() {
|
|
4187
4391
|
if (readingLinePending) return;
|
|
4188
4392
|
readingLinePending = true;
|
|
4189
4393
|
requestAnimationFrame(readingLinePass);
|
|
4190
|
-
}
|
|
4394
|
+
}
|
|
4395
|
+
window.addEventListener('scroll', scheduleReadingLinePass, { passive: true });
|
|
4396
|
+
window.addEventListener('resize', scheduleReadingLinePass);
|
|
4397
|
+
// Ends the freeze a selected search result put on the highlight. The freeze
|
|
4398
|
+
// also kept the TOC from opening the hit's path, and the next pass may find
|
|
4399
|
+
// the same section (the reader is still inside the hit's section), which
|
|
4400
|
+
// syncActiveHeading skips as no change: so the next frame re-syncs the row
|
|
4401
|
+
// itself, opening its path and centring it.
|
|
4402
|
+
function endSearchFreeze() {
|
|
4403
|
+
observerFrozen = false;
|
|
4404
|
+
requestAnimationFrame(resyncTocToScroll);
|
|
4405
|
+
}
|
|
4406
|
+
// A TOC click is the reader leaving a search hit: it also ends the freeze,
|
|
4407
|
+
// so the clicked row lights.
|
|
4191
4408
|
document.addEventListener('click', (event) => {
|
|
4192
4409
|
const link = event.target && event.target.closest && event.target.closest('.toc a[href^="#"]');
|
|
4193
|
-
if (link)
|
|
4410
|
+
if (!link) return;
|
|
4411
|
+
pinnedHeadingId = link.getAttribute('href').slice(1);
|
|
4412
|
+
endSearchFreeze();
|
|
4194
4413
|
});
|
|
4414
|
+
// So does a scroll of the page by hand (R6b, R17): a wheel, a touch drag or
|
|
4415
|
+
// a scroll key. Inside the sidebar (the results list, the TOC) or the
|
|
4416
|
+
// lightbox the same gestures work that control, and keep the freeze; so do
|
|
4417
|
+
// a sideways wheel (a wide table's swipe), ctrl+wheel (zoom) and a key typed
|
|
4418
|
+
// into a field. A programmatic scroll (the result jump itself) fires none of
|
|
4419
|
+
// these events.
|
|
4420
|
+
const SCROLL_KEYS = new Set(['PageUp', 'PageDown', 'Home', 'End', 'ArrowUp', 'ArrowDown', ' ', 'Spacebar']);
|
|
4421
|
+
const unfreezeOnUserScroll = (event) => {
|
|
4422
|
+
if (!observerFrozen) return;
|
|
4423
|
+
const target = event.target;
|
|
4424
|
+
if (event.type === 'wheel' && (event.deltaY === 0 || event.ctrlKey)) return;
|
|
4425
|
+
if (event.type === 'keydown') {
|
|
4426
|
+
if (!SCROLL_KEYS.has(event.key) || lightboxIsOpen()) return;
|
|
4427
|
+
if (target instanceof Element && (target.closest('input, textarea, select') || target.isContentEditable)) return;
|
|
4428
|
+
}
|
|
4429
|
+
if (target instanceof Element && target.closest('.reader-sidebar, .lightbox')) return;
|
|
4430
|
+
endSearchFreeze();
|
|
4431
|
+
};
|
|
4432
|
+
window.addEventListener('wheel', unfreezeOnUserScroll, { passive: true });
|
|
4433
|
+
window.addEventListener('touchmove', unfreezeOnUserScroll, { passive: true });
|
|
4434
|
+
window.addEventListener('keydown', unfreezeOnUserScroll);
|
|
4195
4435
|
function bindHeadingObserver() {
|
|
4196
4436
|
readingLinePass();
|
|
4197
4437
|
}
|
|
@@ -4296,13 +4536,22 @@ ${mermaidInitTag}`)
|
|
|
4296
4536
|
});
|
|
4297
4537
|
}
|
|
4298
4538
|
|
|
4539
|
+
// A hit that crosses inline markup is several marks (see
|
|
4540
|
+
// highlightFirstOccurrence); each is unwrapped and its own parent normalised,
|
|
4541
|
+
// which rejoins the text it was cut from, so main.content is byte-identical.
|
|
4542
|
+
// A math mark wraps a whole formula and cut nothing, so it is only unwrapped.
|
|
4299
4543
|
function clearSelectedHighlight() {
|
|
4300
|
-
const
|
|
4301
|
-
|
|
4302
|
-
|
|
4303
|
-
|
|
4304
|
-
|
|
4305
|
-
|
|
4544
|
+
const marks = readerState.activeHighlights.length
|
|
4545
|
+
? readerState.activeHighlights
|
|
4546
|
+
: (readerState.activeHighlight ? [readerState.activeHighlight] : []);
|
|
4547
|
+
marks.forEach((mark) => {
|
|
4548
|
+
const parent = mark.parentNode;
|
|
4549
|
+
if (!parent) return;
|
|
4550
|
+
while (mark.firstChild) parent.insertBefore(mark.firstChild, mark);
|
|
4551
|
+
parent.removeChild(mark);
|
|
4552
|
+
if (!mark.classList.contains('search-hit-math')) parent.normalize();
|
|
4553
|
+
});
|
|
4554
|
+
readerState.activeHighlights = [];
|
|
4306
4555
|
readerState.activeHighlight = null;
|
|
4307
4556
|
}
|
|
4308
4557
|
|
|
@@ -4349,80 +4598,179 @@ ${mermaidInitTag}`)
|
|
|
4349
4598
|
)).join('');
|
|
4350
4599
|
}
|
|
4351
4600
|
|
|
4601
|
+
// On the edit page every block sits in a .ed-block wrapper, so the heading's
|
|
4602
|
+
// siblings are wrappers: the section ends at the next wrapper that is, or
|
|
4603
|
+
// directly holds, a heading of the same or a shallower depth. In the reader
|
|
4604
|
+
// the unit is the heading itself and the walk is the one it always was.
|
|
4352
4605
|
function sectionBoundary(sectionId) {
|
|
4353
4606
|
const start = document.getElementById(sectionId);
|
|
4354
4607
|
if (!start) return null;
|
|
4355
4608
|
const startDepth = Number(start.getAttribute('data-reader-depth') || '1');
|
|
4609
|
+
const unit = start.closest('.ed-block') || start;
|
|
4356
4610
|
let end = null;
|
|
4357
|
-
let node =
|
|
4611
|
+
let node = unit.nextElementSibling;
|
|
4358
4612
|
while (node) {
|
|
4359
|
-
|
|
4360
|
-
|
|
4613
|
+
let heading = null;
|
|
4614
|
+
if (node.matches && node.matches('[data-reader-heading]')) heading = node;
|
|
4615
|
+
else if (unit !== start && node.querySelector) heading = node.querySelector(':scope > [data-reader-heading]');
|
|
4616
|
+
if (heading) {
|
|
4617
|
+
const depth = Number(heading.getAttribute('data-reader-depth') || '1');
|
|
4361
4618
|
if (depth <= startDepth) {
|
|
4362
|
-
end =
|
|
4619
|
+
end = heading;
|
|
4363
4620
|
break;
|
|
4364
4621
|
}
|
|
4365
4622
|
}
|
|
4366
4623
|
node = node.nextElementSibling;
|
|
4367
4624
|
}
|
|
4368
|
-
return { start, end };
|
|
4369
|
-
}
|
|
4370
|
-
|
|
4625
|
+
return { start, end, container: unit.parentNode };
|
|
4626
|
+
}
|
|
4627
|
+
|
|
4628
|
+
// A whitespace-only text node is the real space between words when it sits in
|
|
4629
|
+
// an inline element or between two inline siblings ("**bold** *italic*").
|
|
4630
|
+
// Between blocks (and inside list / table structure) it is markup layout:
|
|
4631
|
+
// block boundaries get a virtual space instead, and a mark must never land
|
|
4632
|
+
// there (a mark as a direct child of a ul or tr).
|
|
4633
|
+
const INLINE_SELECTOR = 'a, abbr, b, bdi, bdo, cite, code, data, del, dfn, em, i, img, ins, kbd, mark, q, s, samp, small, span, strong, sub, sup, time, u, var';
|
|
4634
|
+
function isInlineSibling(node) {
|
|
4635
|
+
if (!node) return false;
|
|
4636
|
+
if (node.nodeType === Node.TEXT_NODE) return !!node.nodeValue.trim();
|
|
4637
|
+
return node.nodeType === Node.ELEMENT_NODE && node.matches(INLINE_SELECTOR);
|
|
4638
|
+
}
|
|
4639
|
+
function isInlineSpace(node) {
|
|
4640
|
+
const parent = node.parentElement;
|
|
4641
|
+
if (!parent) return false;
|
|
4642
|
+
if (parent.matches(INLINE_SELECTOR)) return true;
|
|
4643
|
+
return isInlineSibling(node.previousSibling) && isInlineSibling(node.nextSibling);
|
|
4644
|
+
}
|
|
4645
|
+
|
|
4646
|
+
// The section's text as one string, read the way the search index reads it
|
|
4647
|
+
// (renderer side, sectionInlineText): whitespace runs fold to one space and
|
|
4648
|
+
// letters to lower case, as normalizeText folds the query; a br element is a
|
|
4649
|
+
// space; a formula (.katex) is its TeX source, taken from its annotation, and
|
|
4650
|
+
// its MathML and glyph markup are never walked; two blocks are joined by a
|
|
4651
|
+
// space. Every flat position maps back to its (node, offset), so a phrase can
|
|
4652
|
+
// cross a heading's .sec number, inline code, bold, a soft or hard line break
|
|
4653
|
+
// or a formula. Spaces that stand for a br or a block boundary map to no node
|
|
4654
|
+
// and are never wrapped. The walk stops at the first node that completes a
|
|
4655
|
+
// hit, so a long section is not read past its first occurrence.
|
|
4371
4656
|
function highlightFirstOccurrence(sectionId, query) {
|
|
4372
4657
|
const bounds = sectionBoundary(sectionId);
|
|
4373
4658
|
if (!bounds) return null;
|
|
4374
|
-
const { start, end } = bounds;
|
|
4375
|
-
const container = start.parentNode;
|
|
4659
|
+
const { start, end, container } = bounds;
|
|
4376
4660
|
if (!container) return null;
|
|
4377
|
-
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, {
|
|
4661
|
+
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, {
|
|
4378
4662
|
acceptNode(node) {
|
|
4379
|
-
if (
|
|
4380
|
-
|
|
4381
|
-
|
|
4663
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
4664
|
+
if (node.matches(SKIP_SELECTOR) || node.matches('.reader-sidebar, .heading-anchor, button')) return NodeFilter.FILTER_REJECT;
|
|
4665
|
+
return node.matches('br, .katex') ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
|
|
4666
|
+
}
|
|
4667
|
+
if (!node.nodeValue) return NodeFilter.FILTER_REJECT;
|
|
4668
|
+
if (!node.nodeValue.trim() && !isInlineSpace(node)) return NodeFilter.FILTER_REJECT;
|
|
4382
4669
|
return NodeFilter.FILTER_ACCEPT;
|
|
4383
4670
|
},
|
|
4384
4671
|
});
|
|
4385
|
-
|
|
4386
|
-
|
|
4672
|
+
// Nothing before the heading is in range, so the walk starts at it.
|
|
4673
|
+
walker.currentNode = start;
|
|
4674
|
+
const BLOCK = 'p, li, .ed-li-text, td, th, h1, h2, h3, h4, h5, h6, pre, blockquote, dt, dd, figcaption, .katex-display';
|
|
4675
|
+
const nodes = [];
|
|
4676
|
+
let flat = '';
|
|
4677
|
+
const at = [];
|
|
4678
|
+
let space = true;
|
|
4679
|
+
let lastBlock = null;
|
|
4680
|
+
let idx = -1;
|
|
4387
4681
|
while (walker.nextNode()) {
|
|
4388
4682
|
const node = walker.currentNode;
|
|
4389
|
-
if (!inRange) {
|
|
4390
|
-
if (start.contains(node)) {
|
|
4391
|
-
inRange = true;
|
|
4392
|
-
} else if (start.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING) {
|
|
4393
|
-
inRange = true;
|
|
4394
|
-
} else {
|
|
4395
|
-
continue;
|
|
4396
|
-
}
|
|
4397
|
-
}
|
|
4398
|
-
if (node.parentElement && node.parentElement.closest('.heading-anchor')) continue;
|
|
4399
4683
|
if (end) {
|
|
4400
4684
|
const pos = end.compareDocumentPosition(node);
|
|
4401
4685
|
if (end === node || end.contains(node) || (pos & Node.DOCUMENT_POSITION_FOLLOWING)) {
|
|
4402
4686
|
break;
|
|
4403
4687
|
}
|
|
4404
4688
|
}
|
|
4405
|
-
|
|
4406
|
-
|
|
4407
|
-
|
|
4408
|
-
|
|
4409
|
-
|
|
4410
|
-
|
|
4689
|
+
if (node.nodeName === 'BR') {
|
|
4690
|
+
if (!space) {
|
|
4691
|
+
flat += ' ';
|
|
4692
|
+
at.push(null);
|
|
4693
|
+
space = true;
|
|
4694
|
+
}
|
|
4695
|
+
continue;
|
|
4696
|
+
}
|
|
4697
|
+
const isMath = node.nodeType === Node.ELEMENT_NODE;
|
|
4698
|
+
let text = node.nodeValue;
|
|
4699
|
+
if (isMath) {
|
|
4700
|
+
const tex = node.querySelector('annotation[encoding="application/x-tex"]');
|
|
4701
|
+
text = tex ? tex.textContent : '';
|
|
4702
|
+
// Resume the walk after the formula's last descendant.
|
|
4703
|
+
let last = node;
|
|
4704
|
+
while (last.lastChild) last = last.lastChild;
|
|
4705
|
+
walker.currentNode = last;
|
|
4706
|
+
}
|
|
4707
|
+
const ni = nodes.length;
|
|
4708
|
+
nodes.push(node);
|
|
4709
|
+
const prevLen = flat.length;
|
|
4710
|
+
const block = (node.parentElement && node.parentElement.closest(BLOCK)) || (isMath ? node : null);
|
|
4711
|
+
if (ni && block !== lastBlock && !space) {
|
|
4712
|
+
flat += ' ';
|
|
4713
|
+
at.push(null);
|
|
4714
|
+
space = true;
|
|
4715
|
+
}
|
|
4716
|
+
lastBlock = block;
|
|
4717
|
+
for (let i = 0; i < text.length; i++) {
|
|
4718
|
+
if (/\\s/.test(text[i])) {
|
|
4719
|
+
if (space) continue;
|
|
4720
|
+
flat += ' ';
|
|
4721
|
+
at.push([ni, i]);
|
|
4722
|
+
space = true;
|
|
4723
|
+
} else {
|
|
4724
|
+
// A few characters lower-case to two code units; map each to i.
|
|
4725
|
+
const lower = text[i].toLowerCase();
|
|
4726
|
+
flat += lower;
|
|
4727
|
+
for (let u = 0; u < lower.length; u++) at.push([ni, i]);
|
|
4728
|
+
space = false;
|
|
4729
|
+
}
|
|
4730
|
+
}
|
|
4731
|
+
// Only a hit that ends in this node's text can be new.
|
|
4732
|
+
idx = flat.indexOf(query, Math.max(0, prevLen - query.length + 1));
|
|
4733
|
+
if (idx !== -1) break;
|
|
4734
|
+
}
|
|
4735
|
+
if (idx === -1) return null;
|
|
4736
|
+
// node index -> [first offset, last offset] of the hit inside that node
|
|
4737
|
+
const spans = new Map();
|
|
4738
|
+
for (let k = idx; k < idx + query.length; k++) {
|
|
4739
|
+
const p = at[k];
|
|
4740
|
+
if (!p) continue;
|
|
4741
|
+
const span = spans.get(p[0]) || [p[1], p[1]];
|
|
4742
|
+
span[1] = p[1];
|
|
4743
|
+
spans.set(p[0], span);
|
|
4744
|
+
}
|
|
4745
|
+
// One mark per node, wrapping only that node's slice, so the .sec / code /
|
|
4746
|
+
// bold wrappers stay whole siblings and clearing restores the markup
|
|
4747
|
+
// exactly. Last node first; each node is split only once anyway. A formula
|
|
4748
|
+
// is marked whole from outside, so KaTeX's own markup is never cut; a
|
|
4749
|
+
// display formula's mark goes around its .katex-display (KaTeX styles
|
|
4750
|
+
// .katex-display > .katex) and is a block, so its background shows.
|
|
4751
|
+
const marks = [];
|
|
4752
|
+
Array.from(spans.keys()).sort((a, b) => b - a).forEach((ni) => {
|
|
4753
|
+
const node = nodes[ni];
|
|
4411
4754
|
const mark = document.createElement('mark');
|
|
4412
4755
|
mark.className = 'search-hit is-selected';
|
|
4413
|
-
|
|
4414
|
-
|
|
4415
|
-
|
|
4416
|
-
|
|
4417
|
-
|
|
4418
|
-
|
|
4419
|
-
|
|
4756
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
4757
|
+
const display = node.parentElement && node.parentElement.classList.contains('katex-display') ? node.parentElement : null;
|
|
4758
|
+
const target = display || node;
|
|
4759
|
+
mark.classList.add('search-hit-math');
|
|
4760
|
+
if (display) mark.classList.add('search-hit-block');
|
|
4761
|
+
target.parentNode.insertBefore(mark, target);
|
|
4762
|
+
mark.appendChild(target);
|
|
4420
4763
|
} else {
|
|
4421
|
-
|
|
4764
|
+
const span = spans.get(ni);
|
|
4765
|
+
const range = document.createRange();
|
|
4766
|
+
range.setStart(node, span[0]);
|
|
4767
|
+
range.setEnd(node, span[1] + 1);
|
|
4768
|
+
range.surroundContents(mark);
|
|
4422
4769
|
}
|
|
4423
|
-
|
|
4424
|
-
}
|
|
4425
|
-
|
|
4770
|
+
marks.unshift(mark);
|
|
4771
|
+
});
|
|
4772
|
+
readerState.activeHighlights = marks;
|
|
4773
|
+
return marks[0] || null;
|
|
4426
4774
|
}
|
|
4427
4775
|
|
|
4428
4776
|
function jumpToAndHighlight(result) {
|
|
@@ -4670,6 +5018,11 @@ ${mermaidInitTag}`)
|
|
|
4670
5018
|
var anchorTop = 0;
|
|
4671
5019
|
var anchorFrame = 0;
|
|
4672
5020
|
var suppressAnchorCapture = false;
|
|
5021
|
+
// Set by any scroll that is not our own restore, cleared by a capture: the
|
|
5022
|
+
// anchor then describes a position the page has already left (R1, R9).
|
|
5023
|
+
var anchorStale = false;
|
|
5024
|
+
// scrollY when the anchor was captured or last restored by us.
|
|
5025
|
+
var anchorScrollY = 0;
|
|
4673
5026
|
// Column width the anchor was last captured/held at; -1 until the column
|
|
4674
5027
|
// ResizeObserver below is installed. See captureScrollAnchor's first guard.
|
|
4675
5028
|
var lastAnchorColW = -1;
|
|
@@ -4715,8 +5068,13 @@ ${mermaidInitTag}`)
|
|
|
4715
5068
|
}
|
|
4716
5069
|
}
|
|
4717
5070
|
if (!node) return;
|
|
5071
|
+
// A display formula's search mark is itself a child of the column, and
|
|
5072
|
+
// clearing the search removes it: pin the formula inside it instead.
|
|
5073
|
+
if (node.matches('mark.search-hit') && node.firstElementChild) node = node.firstElementChild;
|
|
4718
5074
|
anchorNode = node;
|
|
4719
5075
|
anchorTop = node.getBoundingClientRect().top;
|
|
5076
|
+
anchorScrollY = window.scrollY;
|
|
5077
|
+
anchorStale = false;
|
|
4720
5078
|
}
|
|
4721
5079
|
|
|
4722
5080
|
function restoreScrollAnchor() {
|
|
@@ -4724,9 +5082,13 @@ ${mermaidInitTag}`)
|
|
|
4724
5082
|
var delta = anchorNode.getBoundingClientRect().top - anchorTop;
|
|
4725
5083
|
if (!delta) return;
|
|
4726
5084
|
window.scrollTo(window.scrollX, window.scrollY + delta);
|
|
5085
|
+
// Our own correction is not a newer scroll: without this, the next frame
|
|
5086
|
+
// of a splitter drag reads it as one and drops the anchor.
|
|
5087
|
+
anchorScrollY = window.scrollY;
|
|
4727
5088
|
}
|
|
4728
5089
|
|
|
4729
5090
|
window.addEventListener('scroll', function () {
|
|
5091
|
+
if (!suppressAnchorCapture) anchorStale = true;
|
|
4730
5092
|
if (anchorFrame) return;
|
|
4731
5093
|
anchorFrame = window.requestAnimationFrame(function () {
|
|
4732
5094
|
anchorFrame = 0;
|
|
@@ -4735,6 +5097,13 @@ ${mermaidInitTag}`)
|
|
|
4735
5097
|
}, { passive: true });
|
|
4736
5098
|
|
|
4737
5099
|
function holdScrollAnchor() {
|
|
5100
|
+
// A scroll issued before this reflow's frame may not have dispatched its
|
|
5101
|
+
// event yet (resize runs before scroll in a frame): compare offsets too.
|
|
5102
|
+
// A shorter document clamping the offset is not somebody else's scroll.
|
|
5103
|
+
var maxY = document.documentElement.scrollHeight - window.innerHeight;
|
|
5104
|
+
var moved = Math.abs(window.scrollY - anchorScrollY) > 1;
|
|
5105
|
+
var clamped = window.scrollY < anchorScrollY && window.scrollY >= maxY - 1;
|
|
5106
|
+
if (anchorStale || (moved && !clamped)) anchorNode = null;
|
|
4738
5107
|
// Hold the anchor across the whole reflow: the scrollTo below fires scroll
|
|
4739
5108
|
// events that would otherwise re-capture a mid-reflow position.
|
|
4740
5109
|
suppressAnchorCapture = true;
|
|
@@ -4774,6 +5143,7 @@ ${mermaidInitTag}`)
|
|
|
4774
5143
|
if (w === lastAnchorColW) return;
|
|
4775
5144
|
lastAnchorColW = w;
|
|
4776
5145
|
holdScrollAnchor();
|
|
5146
|
+
scheduleReadingLinePass();
|
|
4777
5147
|
}).observe(contentEl);
|
|
4778
5148
|
}
|
|
4779
5149
|
|