@helping-ai-workflow/md2doc 3.13.0 → 3.13.1
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 +659 -14
- package/lib/editor/inline-md.js +45 -1
- package/lib/editor/list-md.js +8 -1
- package/lib/md2doc.js +423 -74
- 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,37 @@ ${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
|
+
parts.push(
|
|
1399
|
+
`<div class="ed-block" data-block-id="${b.id}" data-block-type="${b.type}">` +
|
|
1400
|
+
inner + '</div>'
|
|
1401
|
+
);
|
|
1402
|
+
}
|
|
1403
|
+
} finally {
|
|
1404
|
+
blockMathText = null;
|
|
1240
1405
|
}
|
|
1241
1406
|
// Development-time alignment guard: the render walk and blockmap.js's
|
|
1242
1407
|
// pushListItemBlocks must consume blocks[] in lockstep. A mismatch means
|
|
@@ -1270,7 +1435,12 @@ ${itemsHtml}
|
|
|
1270
1435
|
}
|
|
1271
1436
|
bodyHtml = parts.join('\n');
|
|
1272
1437
|
} else {
|
|
1273
|
-
|
|
1438
|
+
try {
|
|
1439
|
+
blockMathText = appendSectionText;
|
|
1440
|
+
bodyHtml = marked.parse(mdPre);
|
|
1441
|
+
} finally {
|
|
1442
|
+
blockMathText = null;
|
|
1443
|
+
}
|
|
1274
1444
|
}
|
|
1275
1445
|
// Both the ```math fence and the $/$$ extension emit class="katex" — a single
|
|
1276
1446
|
// post-parse scan is the source of truth for conditional CSS injection.
|
|
@@ -1286,6 +1456,11 @@ ${itemsHtml}
|
|
|
1286
1456
|
).replace(/</g, '\\u003c');
|
|
1287
1457
|
if (tocItems.length > 0) {
|
|
1288
1458
|
const tocTree = buildTocTree(tocItems);
|
|
1459
|
+
// v3.13.1 (R3): two or more H1s kept as the TOC's top level are
|
|
1460
|
+
// chapters; the marker lets CSS set each chapter apart with a rule and
|
|
1461
|
+
// a gap. A flattened single-H1 TOC (its H2s on top) never carries it.
|
|
1462
|
+
const tocRoots = flattenSingleRoot(tocTree);
|
|
1463
|
+
const tocChapters = tocRoots === tocTree && tocTree.filter((n) => n.depth === 1).length >= 2;
|
|
1289
1464
|
tocHtml = `<aside class="reader-sidebar" data-reader-sidebar>
|
|
1290
1465
|
<section class="reader-tools">
|
|
1291
1466
|
<label class="reader-search-label" for="doc-search-input">Search</label>
|
|
@@ -1304,7 +1479,7 @@ ${itemsHtml}
|
|
|
1304
1479
|
</div>
|
|
1305
1480
|
<div id="search-results-list"></div>
|
|
1306
1481
|
</section>
|
|
1307
|
-
<nav class="toc" aria-label="Table of contents" data-reader-toc>
|
|
1482
|
+
<nav class="toc" aria-label="Table of contents" data-reader-toc${tocChapters ? ' data-toc-chapters' : ''}>
|
|
1308
1483
|
<div class="toc-header">
|
|
1309
1484
|
<span class="toc-title">Contents</span>
|
|
1310
1485
|
<div class="toc-header-actions">
|
|
@@ -1314,7 +1489,7 @@ ${itemsHtml}
|
|
|
1314
1489
|
</div>
|
|
1315
1490
|
<div class="toc-breadcrumb" data-toc-breadcrumb aria-label="Current location"></div>
|
|
1316
1491
|
</div>
|
|
1317
|
-
${renderTocNodes(
|
|
1492
|
+
${renderTocNodes(tocRoots)}
|
|
1318
1493
|
</nav>
|
|
1319
1494
|
</aside>
|
|
1320
1495
|
<div class="sidebar-splitter" id="sidebar-splitter" role="separator" aria-orientation="vertical" aria-label="Resize sidebar"></div>`;
|
|
@@ -2539,6 +2714,7 @@ const html = `<!DOCTYPE html>
|
|
|
2539
2714
|
padding: 0 2px;
|
|
2540
2715
|
border-radius: 3px;
|
|
2541
2716
|
}
|
|
2717
|
+
mark.search-hit-block { display: block; }
|
|
2542
2718
|
.toc {
|
|
2543
2719
|
display: flex;
|
|
2544
2720
|
flex-direction: column;
|
|
@@ -2674,6 +2850,12 @@ const html = `<!DOCTYPE html>
|
|
|
2674
2850
|
.toc-list-level-1 > .toc-item > details > summary > a {
|
|
2675
2851
|
font-size: 0.95em;
|
|
2676
2852
|
}
|
|
2853
|
+
/* v3.13.1 (R3): chapters of a multi-H1 TOC, set apart by a rule and a gap; weight unchanged. */
|
|
2854
|
+
.toc[data-toc-chapters] > .toc-list-level-1 > .toc-item + .toc-item {
|
|
2855
|
+
margin-top: 10px;
|
|
2856
|
+
padding-top: 6px;
|
|
2857
|
+
border-top: 1px solid #d8dee4;
|
|
2858
|
+
}
|
|
2677
2859
|
.toc-list-level-2 > .toc-item > a,
|
|
2678
2860
|
.toc-list-level-2 > .toc-item > details > summary > a {
|
|
2679
2861
|
font-size: 0.9em;
|
|
@@ -3995,6 +4177,7 @@ ${mermaidInitTag}`)
|
|
|
3995
4177
|
results: [],
|
|
3996
4178
|
selectedResultIndex: -1,
|
|
3997
4179
|
activeHighlight: null,
|
|
4180
|
+
activeHighlights: [],
|
|
3998
4181
|
};
|
|
3999
4182
|
window.__readerState = readerState;
|
|
4000
4183
|
|
|
@@ -4137,14 +4320,18 @@ ${mermaidInitTag}`)
|
|
|
4137
4320
|
// already above the line and would steal the highlight from the row that was
|
|
4138
4321
|
// just clicked. The clicked heading stays the answer for as long as it is
|
|
4139
4322
|
// 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.
|
|
4323
|
+
// it out of that band hands tracking back to the line. The last sections of
|
|
4324
|
+
// a document can be too short to ever reach the line: at the bottom of the
|
|
4325
|
+
// page a clicked heading anywhere on screen keeps its row lit. Only a click
|
|
4326
|
+
// pins, so a plain scroll to the bottom still follows the line.
|
|
4141
4327
|
let pinnedHeadingId = null;
|
|
4142
4328
|
function readingLineHeading() {
|
|
4143
4329
|
const line = window.innerHeight * 0.35;
|
|
4144
4330
|
if (pinnedHeadingId) {
|
|
4145
4331
|
const pinned = document.getElementById(pinnedHeadingId);
|
|
4146
4332
|
const top = pinned ? pinned.getBoundingClientRect().top : Infinity;
|
|
4147
|
-
|
|
4333
|
+
const atBottom = window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 2;
|
|
4334
|
+
if (pinned && tocLinks.has(pinned.id) && top >= -40 && (top <= line || (atBottom && top < window.innerHeight))) return pinned;
|
|
4148
4335
|
pinnedHeadingId = null;
|
|
4149
4336
|
}
|
|
4150
4337
|
let first = null;
|
|
@@ -4183,15 +4370,54 @@ ${mermaidInitTag}`)
|
|
|
4183
4370
|
syncActiveHeading(node.id);
|
|
4184
4371
|
syncReadingTitle(node);
|
|
4185
4372
|
}
|
|
4186
|
-
|
|
4373
|
+
// Scrolls are not the only thing that moves a heading across the line: a
|
|
4374
|
+
// height-only window resize moves the line itself (and reflows nothing, so
|
|
4375
|
+
// no scroll follows), and a column resize reflows the text under it.
|
|
4376
|
+
function scheduleReadingLinePass() {
|
|
4187
4377
|
if (readingLinePending) return;
|
|
4188
4378
|
readingLinePending = true;
|
|
4189
4379
|
requestAnimationFrame(readingLinePass);
|
|
4190
|
-
}
|
|
4380
|
+
}
|
|
4381
|
+
window.addEventListener('scroll', scheduleReadingLinePass, { passive: true });
|
|
4382
|
+
window.addEventListener('resize', scheduleReadingLinePass);
|
|
4383
|
+
// Ends the freeze a selected search result put on the highlight. The freeze
|
|
4384
|
+
// also kept the TOC from opening the hit's path, and the next pass may find
|
|
4385
|
+
// the same section (the reader is still inside the hit's section), which
|
|
4386
|
+
// syncActiveHeading skips as no change: so the next frame re-syncs the row
|
|
4387
|
+
// itself, opening its path and centring it.
|
|
4388
|
+
function endSearchFreeze() {
|
|
4389
|
+
observerFrozen = false;
|
|
4390
|
+
requestAnimationFrame(resyncTocToScroll);
|
|
4391
|
+
}
|
|
4392
|
+
// A TOC click is the reader leaving a search hit: it also ends the freeze,
|
|
4393
|
+
// so the clicked row lights.
|
|
4191
4394
|
document.addEventListener('click', (event) => {
|
|
4192
4395
|
const link = event.target && event.target.closest && event.target.closest('.toc a[href^="#"]');
|
|
4193
|
-
if (link)
|
|
4396
|
+
if (!link) return;
|
|
4397
|
+
pinnedHeadingId = link.getAttribute('href').slice(1);
|
|
4398
|
+
endSearchFreeze();
|
|
4194
4399
|
});
|
|
4400
|
+
// So does a scroll of the page by hand (R6b, R17): a wheel, a touch drag or
|
|
4401
|
+
// a scroll key. Inside the sidebar (the results list, the TOC) or the
|
|
4402
|
+
// lightbox the same gestures work that control, and keep the freeze; so do
|
|
4403
|
+
// a sideways wheel (a wide table's swipe), ctrl+wheel (zoom) and a key typed
|
|
4404
|
+
// into a field. A programmatic scroll (the result jump itself) fires none of
|
|
4405
|
+
// these events.
|
|
4406
|
+
const SCROLL_KEYS = new Set(['PageUp', 'PageDown', 'Home', 'End', 'ArrowUp', 'ArrowDown', ' ', 'Spacebar']);
|
|
4407
|
+
const unfreezeOnUserScroll = (event) => {
|
|
4408
|
+
if (!observerFrozen) return;
|
|
4409
|
+
const target = event.target;
|
|
4410
|
+
if (event.type === 'wheel' && (event.deltaY === 0 || event.ctrlKey)) return;
|
|
4411
|
+
if (event.type === 'keydown') {
|
|
4412
|
+
if (!SCROLL_KEYS.has(event.key) || lightboxIsOpen()) return;
|
|
4413
|
+
if (target instanceof Element && (target.closest('input, textarea, select') || target.isContentEditable)) return;
|
|
4414
|
+
}
|
|
4415
|
+
if (target instanceof Element && target.closest('.reader-sidebar, .lightbox')) return;
|
|
4416
|
+
endSearchFreeze();
|
|
4417
|
+
};
|
|
4418
|
+
window.addEventListener('wheel', unfreezeOnUserScroll, { passive: true });
|
|
4419
|
+
window.addEventListener('touchmove', unfreezeOnUserScroll, { passive: true });
|
|
4420
|
+
window.addEventListener('keydown', unfreezeOnUserScroll);
|
|
4195
4421
|
function bindHeadingObserver() {
|
|
4196
4422
|
readingLinePass();
|
|
4197
4423
|
}
|
|
@@ -4296,13 +4522,22 @@ ${mermaidInitTag}`)
|
|
|
4296
4522
|
});
|
|
4297
4523
|
}
|
|
4298
4524
|
|
|
4525
|
+
// A hit that crosses inline markup is several marks (see
|
|
4526
|
+
// highlightFirstOccurrence); each is unwrapped and its own parent normalised,
|
|
4527
|
+
// which rejoins the text it was cut from, so main.content is byte-identical.
|
|
4528
|
+
// A math mark wraps a whole formula and cut nothing, so it is only unwrapped.
|
|
4299
4529
|
function clearSelectedHighlight() {
|
|
4300
|
-
const
|
|
4301
|
-
|
|
4302
|
-
|
|
4303
|
-
|
|
4304
|
-
|
|
4305
|
-
|
|
4530
|
+
const marks = readerState.activeHighlights.length
|
|
4531
|
+
? readerState.activeHighlights
|
|
4532
|
+
: (readerState.activeHighlight ? [readerState.activeHighlight] : []);
|
|
4533
|
+
marks.forEach((mark) => {
|
|
4534
|
+
const parent = mark.parentNode;
|
|
4535
|
+
if (!parent) return;
|
|
4536
|
+
while (mark.firstChild) parent.insertBefore(mark.firstChild, mark);
|
|
4537
|
+
parent.removeChild(mark);
|
|
4538
|
+
if (!mark.classList.contains('search-hit-math')) parent.normalize();
|
|
4539
|
+
});
|
|
4540
|
+
readerState.activeHighlights = [];
|
|
4306
4541
|
readerState.activeHighlight = null;
|
|
4307
4542
|
}
|
|
4308
4543
|
|
|
@@ -4368,61 +4603,153 @@ ${mermaidInitTag}`)
|
|
|
4368
4603
|
return { start, end };
|
|
4369
4604
|
}
|
|
4370
4605
|
|
|
4606
|
+
// A whitespace-only text node is the real space between words when it sits in
|
|
4607
|
+
// an inline element or between two inline siblings ("**bold** *italic*").
|
|
4608
|
+
// Between blocks (and inside list / table structure) it is markup layout:
|
|
4609
|
+
// block boundaries get a virtual space instead, and a mark must never land
|
|
4610
|
+
// there (a mark as a direct child of a ul or tr).
|
|
4611
|
+
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';
|
|
4612
|
+
function isInlineSibling(node) {
|
|
4613
|
+
if (!node) return false;
|
|
4614
|
+
if (node.nodeType === Node.TEXT_NODE) return !!node.nodeValue.trim();
|
|
4615
|
+
return node.nodeType === Node.ELEMENT_NODE && node.matches(INLINE_SELECTOR);
|
|
4616
|
+
}
|
|
4617
|
+
function isInlineSpace(node) {
|
|
4618
|
+
const parent = node.parentElement;
|
|
4619
|
+
if (!parent) return false;
|
|
4620
|
+
if (parent.matches(INLINE_SELECTOR)) return true;
|
|
4621
|
+
return isInlineSibling(node.previousSibling) && isInlineSibling(node.nextSibling);
|
|
4622
|
+
}
|
|
4623
|
+
|
|
4624
|
+
// The section's text as one string, read the way the search index reads it
|
|
4625
|
+
// (renderer side, sectionInlineText): whitespace runs fold to one space and
|
|
4626
|
+
// letters to lower case, as normalizeText folds the query; a br element is a
|
|
4627
|
+
// space; a formula (.katex) is its TeX source, taken from its annotation, and
|
|
4628
|
+
// its MathML and glyph markup are never walked; two blocks are joined by a
|
|
4629
|
+
// space. Every flat position maps back to its (node, offset), so a phrase can
|
|
4630
|
+
// cross a heading's .sec number, inline code, bold, a soft or hard line break
|
|
4631
|
+
// or a formula. Spaces that stand for a br or a block boundary map to no node
|
|
4632
|
+
// and are never wrapped. The walk stops at the first node that completes a
|
|
4633
|
+
// hit, so a long section is not read past its first occurrence.
|
|
4371
4634
|
function highlightFirstOccurrence(sectionId, query) {
|
|
4372
4635
|
const bounds = sectionBoundary(sectionId);
|
|
4373
4636
|
if (!bounds) return null;
|
|
4374
4637
|
const { start, end } = bounds;
|
|
4375
4638
|
const container = start.parentNode;
|
|
4376
4639
|
if (!container) return null;
|
|
4377
|
-
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, {
|
|
4640
|
+
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, {
|
|
4378
4641
|
acceptNode(node) {
|
|
4379
|
-
if (
|
|
4380
|
-
|
|
4381
|
-
|
|
4642
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
4643
|
+
if (node.matches(SKIP_SELECTOR) || node.matches('.reader-sidebar, .heading-anchor')) return NodeFilter.FILTER_REJECT;
|
|
4644
|
+
return node.matches('br, .katex') ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
|
|
4645
|
+
}
|
|
4646
|
+
if (!node.nodeValue) return NodeFilter.FILTER_REJECT;
|
|
4647
|
+
if (!node.nodeValue.trim() && !isInlineSpace(node)) return NodeFilter.FILTER_REJECT;
|
|
4382
4648
|
return NodeFilter.FILTER_ACCEPT;
|
|
4383
4649
|
},
|
|
4384
4650
|
});
|
|
4385
|
-
|
|
4386
|
-
|
|
4651
|
+
// Nothing before the heading is in range, so the walk starts at it.
|
|
4652
|
+
walker.currentNode = start;
|
|
4653
|
+
const BLOCK = 'p, li, td, th, h1, h2, h3, h4, h5, h6, pre, blockquote, dt, dd, figcaption, .katex-display';
|
|
4654
|
+
const nodes = [];
|
|
4655
|
+
let flat = '';
|
|
4656
|
+
const at = [];
|
|
4657
|
+
let space = true;
|
|
4658
|
+
let lastBlock = null;
|
|
4659
|
+
let idx = -1;
|
|
4387
4660
|
while (walker.nextNode()) {
|
|
4388
4661
|
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
4662
|
if (end) {
|
|
4400
4663
|
const pos = end.compareDocumentPosition(node);
|
|
4401
4664
|
if (end === node || end.contains(node) || (pos & Node.DOCUMENT_POSITION_FOLLOWING)) {
|
|
4402
4665
|
break;
|
|
4403
4666
|
}
|
|
4404
4667
|
}
|
|
4405
|
-
|
|
4406
|
-
|
|
4407
|
-
|
|
4408
|
-
|
|
4409
|
-
|
|
4410
|
-
|
|
4668
|
+
if (node.nodeName === 'BR') {
|
|
4669
|
+
if (!space) {
|
|
4670
|
+
flat += ' ';
|
|
4671
|
+
at.push(null);
|
|
4672
|
+
space = true;
|
|
4673
|
+
}
|
|
4674
|
+
continue;
|
|
4675
|
+
}
|
|
4676
|
+
const isMath = node.nodeType === Node.ELEMENT_NODE;
|
|
4677
|
+
let text = node.nodeValue;
|
|
4678
|
+
if (isMath) {
|
|
4679
|
+
const tex = node.querySelector('annotation[encoding="application/x-tex"]');
|
|
4680
|
+
text = tex ? tex.textContent : '';
|
|
4681
|
+
// Resume the walk after the formula's last descendant.
|
|
4682
|
+
let last = node;
|
|
4683
|
+
while (last.lastChild) last = last.lastChild;
|
|
4684
|
+
walker.currentNode = last;
|
|
4685
|
+
}
|
|
4686
|
+
const ni = nodes.length;
|
|
4687
|
+
nodes.push(node);
|
|
4688
|
+
const prevLen = flat.length;
|
|
4689
|
+
const block = (node.parentElement && node.parentElement.closest(BLOCK)) || (isMath ? node : null);
|
|
4690
|
+
if (ni && block !== lastBlock && !space) {
|
|
4691
|
+
flat += ' ';
|
|
4692
|
+
at.push(null);
|
|
4693
|
+
space = true;
|
|
4694
|
+
}
|
|
4695
|
+
lastBlock = block;
|
|
4696
|
+
for (let i = 0; i < text.length; i++) {
|
|
4697
|
+
if (/\\s/.test(text[i])) {
|
|
4698
|
+
if (space) continue;
|
|
4699
|
+
flat += ' ';
|
|
4700
|
+
at.push([ni, i]);
|
|
4701
|
+
space = true;
|
|
4702
|
+
} else {
|
|
4703
|
+
// A few characters lower-case to two code units; map each to i.
|
|
4704
|
+
const lower = text[i].toLowerCase();
|
|
4705
|
+
flat += lower;
|
|
4706
|
+
for (let u = 0; u < lower.length; u++) at.push([ni, i]);
|
|
4707
|
+
space = false;
|
|
4708
|
+
}
|
|
4709
|
+
}
|
|
4710
|
+
// Only a hit that ends in this node's text can be new.
|
|
4711
|
+
idx = flat.indexOf(query, Math.max(0, prevLen - query.length + 1));
|
|
4712
|
+
if (idx !== -1) break;
|
|
4713
|
+
}
|
|
4714
|
+
if (idx === -1) return null;
|
|
4715
|
+
// node index -> [first offset, last offset] of the hit inside that node
|
|
4716
|
+
const spans = new Map();
|
|
4717
|
+
for (let k = idx; k < idx + query.length; k++) {
|
|
4718
|
+
const p = at[k];
|
|
4719
|
+
if (!p) continue;
|
|
4720
|
+
const span = spans.get(p[0]) || [p[1], p[1]];
|
|
4721
|
+
span[1] = p[1];
|
|
4722
|
+
spans.set(p[0], span);
|
|
4723
|
+
}
|
|
4724
|
+
// One mark per node, wrapping only that node's slice, so the .sec / code /
|
|
4725
|
+
// bold wrappers stay whole siblings and clearing restores the markup
|
|
4726
|
+
// exactly. Last node first; each node is split only once anyway. A formula
|
|
4727
|
+
// is marked whole from outside, so KaTeX's own markup is never cut; a
|
|
4728
|
+
// display formula's mark goes around its .katex-display (KaTeX styles
|
|
4729
|
+
// .katex-display > .katex) and is a block, so its background shows.
|
|
4730
|
+
const marks = [];
|
|
4731
|
+
Array.from(spans.keys()).sort((a, b) => b - a).forEach((ni) => {
|
|
4732
|
+
const node = nodes[ni];
|
|
4411
4733
|
const mark = document.createElement('mark');
|
|
4412
4734
|
mark.className = 'search-hit is-selected';
|
|
4413
|
-
|
|
4414
|
-
|
|
4415
|
-
|
|
4416
|
-
|
|
4417
|
-
|
|
4418
|
-
|
|
4419
|
-
|
|
4735
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
4736
|
+
const display = node.parentElement && node.parentElement.classList.contains('katex-display') ? node.parentElement : null;
|
|
4737
|
+
const target = display || node;
|
|
4738
|
+
mark.classList.add('search-hit-math');
|
|
4739
|
+
if (display) mark.classList.add('search-hit-block');
|
|
4740
|
+
target.parentNode.insertBefore(mark, target);
|
|
4741
|
+
mark.appendChild(target);
|
|
4420
4742
|
} else {
|
|
4421
|
-
|
|
4743
|
+
const span = spans.get(ni);
|
|
4744
|
+
const range = document.createRange();
|
|
4745
|
+
range.setStart(node, span[0]);
|
|
4746
|
+
range.setEnd(node, span[1] + 1);
|
|
4747
|
+
range.surroundContents(mark);
|
|
4422
4748
|
}
|
|
4423
|
-
|
|
4424
|
-
}
|
|
4425
|
-
|
|
4749
|
+
marks.unshift(mark);
|
|
4750
|
+
});
|
|
4751
|
+
readerState.activeHighlights = marks;
|
|
4752
|
+
return marks[0] || null;
|
|
4426
4753
|
}
|
|
4427
4754
|
|
|
4428
4755
|
function jumpToAndHighlight(result) {
|
|
@@ -4670,6 +4997,11 @@ ${mermaidInitTag}`)
|
|
|
4670
4997
|
var anchorTop = 0;
|
|
4671
4998
|
var anchorFrame = 0;
|
|
4672
4999
|
var suppressAnchorCapture = false;
|
|
5000
|
+
// Set by any scroll that is not our own restore, cleared by a capture: the
|
|
5001
|
+
// anchor then describes a position the page has already left (R1, R9).
|
|
5002
|
+
var anchorStale = false;
|
|
5003
|
+
// scrollY when the anchor was captured or last restored by us.
|
|
5004
|
+
var anchorScrollY = 0;
|
|
4673
5005
|
// Column width the anchor was last captured/held at; -1 until the column
|
|
4674
5006
|
// ResizeObserver below is installed. See captureScrollAnchor's first guard.
|
|
4675
5007
|
var lastAnchorColW = -1;
|
|
@@ -4715,8 +5047,13 @@ ${mermaidInitTag}`)
|
|
|
4715
5047
|
}
|
|
4716
5048
|
}
|
|
4717
5049
|
if (!node) return;
|
|
5050
|
+
// A display formula's search mark is itself a child of the column, and
|
|
5051
|
+
// clearing the search removes it: pin the formula inside it instead.
|
|
5052
|
+
if (node.matches('mark.search-hit') && node.firstElementChild) node = node.firstElementChild;
|
|
4718
5053
|
anchorNode = node;
|
|
4719
5054
|
anchorTop = node.getBoundingClientRect().top;
|
|
5055
|
+
anchorScrollY = window.scrollY;
|
|
5056
|
+
anchorStale = false;
|
|
4720
5057
|
}
|
|
4721
5058
|
|
|
4722
5059
|
function restoreScrollAnchor() {
|
|
@@ -4724,9 +5061,13 @@ ${mermaidInitTag}`)
|
|
|
4724
5061
|
var delta = anchorNode.getBoundingClientRect().top - anchorTop;
|
|
4725
5062
|
if (!delta) return;
|
|
4726
5063
|
window.scrollTo(window.scrollX, window.scrollY + delta);
|
|
5064
|
+
// Our own correction is not a newer scroll: without this, the next frame
|
|
5065
|
+
// of a splitter drag reads it as one and drops the anchor.
|
|
5066
|
+
anchorScrollY = window.scrollY;
|
|
4727
5067
|
}
|
|
4728
5068
|
|
|
4729
5069
|
window.addEventListener('scroll', function () {
|
|
5070
|
+
if (!suppressAnchorCapture) anchorStale = true;
|
|
4730
5071
|
if (anchorFrame) return;
|
|
4731
5072
|
anchorFrame = window.requestAnimationFrame(function () {
|
|
4732
5073
|
anchorFrame = 0;
|
|
@@ -4735,6 +5076,13 @@ ${mermaidInitTag}`)
|
|
|
4735
5076
|
}, { passive: true });
|
|
4736
5077
|
|
|
4737
5078
|
function holdScrollAnchor() {
|
|
5079
|
+
// A scroll issued before this reflow's frame may not have dispatched its
|
|
5080
|
+
// event yet (resize runs before scroll in a frame): compare offsets too.
|
|
5081
|
+
// A shorter document clamping the offset is not somebody else's scroll.
|
|
5082
|
+
var maxY = document.documentElement.scrollHeight - window.innerHeight;
|
|
5083
|
+
var moved = Math.abs(window.scrollY - anchorScrollY) > 1;
|
|
5084
|
+
var clamped = window.scrollY < anchorScrollY && window.scrollY >= maxY - 1;
|
|
5085
|
+
if (anchorStale || (moved && !clamped)) anchorNode = null;
|
|
4738
5086
|
// Hold the anchor across the whole reflow: the scrollTo below fires scroll
|
|
4739
5087
|
// events that would otherwise re-capture a mid-reflow position.
|
|
4740
5088
|
suppressAnchorCapture = true;
|
|
@@ -4774,6 +5122,7 @@ ${mermaidInitTag}`)
|
|
|
4774
5122
|
if (w === lastAnchorColW) return;
|
|
4775
5123
|
lastAnchorColW = w;
|
|
4776
5124
|
holdScrollAnchor();
|
|
5125
|
+
scheduleReadingLinePass();
|
|
4777
5126
|
}).observe(contentEl);
|
|
4778
5127
|
}
|
|
4779
5128
|
|