From a2210b13ec9df5637ec526a0c936d905deab9f64 Mon Sep 17 00:00:00 2001 From: Maryna Balioura Date: Thu, 28 Aug 2025 05:30:15 +0200 Subject: [PATCH 1/8] refactor(toc_highlighting): cleanup redundant guards --- .../export/html/_static/toc_highlighting.js | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/strictdoc/export/html/_static/toc_highlighting.js b/strictdoc/export/html/_static/toc_highlighting.js index fb96016af..c2f127582 100644 --- a/strictdoc/export/html/_static/toc_highlighting.js +++ b/strictdoc/export/html/_static/toc_highlighting.js @@ -31,7 +31,7 @@ window.addEventListener("load",function(){ const tocList = tocFrame ? tocFrame.querySelector(TOC_LIST_SELECTOR) : null; const contentFrame = document.querySelector(CONTENT_FRAME_SELECTOR)?.parentNode; - if(!tocFrame || !tocList || !contentFrame) { return } + if (!tocFrame || !contentFrame) { return } // ! depends on TOC markup tocHighlightingState.contentFrameTop = contentFrame.offsetParent @@ -89,8 +89,7 @@ function handleHashChange() { const match = hash.match(/#(.*)/); const fragment = match ? match[1] : null; - // Guard: no links collected yet (e.g., empty TOC or init race) - if (!tocHighlightingState.links || typeof tocHighlightingState.links.forEach !== 'function') { + if (!tocHighlightingState.links || tocHighlightingState.links.length === 0) { return; } @@ -109,12 +108,13 @@ function handleHashChange() { function processLinkList(tocFrame) { // * Collects all links in the TOC tocHighlightingState.links = tocFrame.querySelectorAll(TOC_ELEMENT_SELECTOR); - if (!tocHighlightingState.links || tocHighlightingState.links.length === 0) { + if (tocHighlightingState.links.length === 0) { return; } tocHighlightingState.links.length && tocHighlightingState.links.forEach(link => { const id = link.getAttribute('anchor'); + if (!id) return; // Skip links without an anchor attribute tocHighlightingState.data[id] = { 'link': link, ...tocHighlightingState.data[id] @@ -123,7 +123,7 @@ function processLinkList(tocFrame) { // ! depends on TOC markup // is link in collapsible node and precedes the UL // ! expected UL or null - const ul = link.nextSibling; + const ul = link.nextElementSibling; if (ul && ul.nodeName === 'UL') { // register folder @@ -131,8 +131,8 @@ function processLinkList(tocFrame) { // register closer const lastLink = findDeepestLastChild(ul); - const lastAnchor = lastLink.getAttribute('anchor'); - + const lastAnchor = lastLink?.getAttribute('anchor'); + if (!lastAnchor) return; if (!tocHighlightingState.closerForFolder[lastAnchor]) { tocHighlightingState.closerForFolder[lastAnchor] = []; @@ -146,10 +146,8 @@ function processAnchorList(contentFrame, anchorObserver) { anchorObserver.disconnect(); // FIXME don`t work: have to hack at #rootBounds_null // * Collects all anchors in the document - tocHighlightingState.anchors = null; tocHighlightingState.anchors = contentFrame.querySelectorAll(CONTENT_ELEMENT_SELECTOR); - tocHighlightingState.anchors.length - && tocHighlightingState.anchors.forEach(anchor => { + tocHighlightingState.anchors.forEach(anchor => { const id = anchor.id; tocHighlightingState.data[id] = { 'anchor': anchor, From 85586112ef793e4e1153a849ca39bb7dad83e4c0 Mon Sep 17 00:00:00 2001 From: Maryna Balioura Date: Thu, 28 Aug 2025 05:30:32 +0200 Subject: [PATCH 2/8] refactor(toc_highlighting): simplify hash handling --- strictdoc/export/html/_static/toc_highlighting.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/strictdoc/export/html/_static/toc_highlighting.js b/strictdoc/export/html/_static/toc_highlighting.js index c2f127582..5336b76a3 100644 --- a/strictdoc/export/html/_static/toc_highlighting.js +++ b/strictdoc/export/html/_static/toc_highlighting.js @@ -86,8 +86,7 @@ function highlightTOC(tocFrame, contentFrame, anchorObserver) { function handleHashChange() { const hash = window.location.hash; - const match = hash.match(/#(.*)/); - const fragment = match ? match[1] : null; + const fragment = hash ? decodeURIComponent(hash.slice(1)) : null; if (!tocHighlightingState.links || tocHighlightingState.links.length === 0) { return; From 37333e3db34a6882d0f6b15f53939203d601ddd9 Mon Sep 17 00:00:00 2001 From: Maryna Balioura Date: Thu, 28 Aug 2025 05:45:20 +0200 Subject: [PATCH 3/8] refactor(toc_highlighting): unified coordinate checks in IntersectionObserver --- strictdoc/export/html/_static/toc_highlighting.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/strictdoc/export/html/_static/toc_highlighting.js b/strictdoc/export/html/_static/toc_highlighting.js index 5336b76a3..7ec8fc70c 100644 --- a/strictdoc/export/html/_static/toc_highlighting.js +++ b/strictdoc/export/html/_static/toc_highlighting.js @@ -218,15 +218,15 @@ function handleIntersect(entries, observer) { if( // * If the node goes up ⬆️ off the screen - entry.boundingClientRect.y < tocHighlightingState.contentFrameTop + entry.boundingClientRect.top <= entry.rootBounds.top // * and this is the last child of the section && tocHighlightingState.closerForFolder[anchor] ) { // * When the LAST CHILD of the section disappears - // * over the upper boundary ( < tocHighlightingState.contentFrameTop), + // * over the upper boundary (<= entry.rootBounds.top), // * strictly speaking, this occurs when the lower bound disappears: // * entry.boundingClientRect.bottom. - // * But we will use the upper bound, entry.boundingClientRect.y + // * But we will use the upper bound, entry.boundingClientRect.top // * which will be less than or equal to the lower bound. // ** remove highlights from closer`s parent folder in the TOC From 21d001139bab1fe96aa1c4ef087357082558b06e Mon Sep 17 00:00:00 2001 From: Maryna Balioura Date: Thu, 28 Aug 2025 06:05:08 +0200 Subject: [PATCH 4/8] refactor(toc_highlighting): handle rootBounds=null with viewport fallback --- .../export/html/_static/toc_highlighting.js | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/strictdoc/export/html/_static/toc_highlighting.js b/strictdoc/export/html/_static/toc_highlighting.js index 7ec8fc70c..9e5ff49bf 100644 --- a/strictdoc/export/html/_static/toc_highlighting.js +++ b/strictdoc/export/html/_static/toc_highlighting.js @@ -142,7 +142,7 @@ function processLinkList(tocFrame) { } function processAnchorList(contentFrame, anchorObserver) { - anchorObserver.disconnect(); // FIXME don`t work: have to hack at #rootBounds_null + anchorObserver.disconnect(); // FIXME Re-subscribe anchors (can be optimized later to avoid full re-scan) // * Collects all anchors in the document tocHighlightingState.anchors = contentFrame.querySelectorAll(CONTENT_ELEMENT_SELECTOR); @@ -161,14 +161,11 @@ function handleIntersect(entries, observer) { entries.forEach((entry) => { - // #rootBounds_null - // rootBounds: null - // after frame reload and before init - if(!entry.rootBounds) { - return - } - const anchor = entry.target.id; + // * Sometimes rootBounds is null right after IO init; fall back to viewport bounds. + const topBound = entry.rootBounds ? entry.rootBounds.top : 0; + const bottomBound = entry.rootBounds ? entry.rootBounds.bottom : window.innerHeight; + // * For anchors that go into the viewport, // * finds the corresponding links const link = tocHighlightingState.data[anchor].link; @@ -207,7 +204,7 @@ function handleIntersect(entries, observer) { if( // * If the node goes down ⬇️ off the screen - entry.boundingClientRect.bottom >= entry.rootBounds.bottom + entry.boundingClientRect.bottom >= bottomBound // * and it's a folder && tocHighlightingState.folderSet.has(anchor) ) { @@ -218,12 +215,12 @@ function handleIntersect(entries, observer) { if( // * If the node goes up ⬆️ off the screen - entry.boundingClientRect.top <= entry.rootBounds.top + entry.boundingClientRect.top <= topBound // * and this is the last child of the section && tocHighlightingState.closerForFolder[anchor] ) { // * When the LAST CHILD of the section disappears - // * over the upper boundary (<= entry.rootBounds.top), + // * over the upper boundary (<= topBound), // * strictly speaking, this occurs when the lower bound disappears: // * entry.boundingClientRect.bottom. // * But we will use the upper bound, entry.boundingClientRect.top From e4c5a7e0cd2326a99f45bf1b1121ffc35469f7a3 Mon Sep 17 00:00:00 2001 From: Maryna Balioura Date: Thu, 28 Aug 2025 07:21:51 +0200 Subject: [PATCH 5/8] refactor(toc_highlighting): optimize anchor re-scan and keep mapping valid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Introduced anchorsCount/anchorsSig to detect changes and avoid redundant re-observe. - Rebuild anchor→data mapping after resetState() even if set unchanged. - Guarded handleIntersect against missing mapping during TOC mutations. --- .../export/html/_static/toc_highlighting.js | 54 +++++++++++++++++-- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/strictdoc/export/html/_static/toc_highlighting.js b/strictdoc/export/html/_static/toc_highlighting.js index 9e5ff49bf..41c07456a 100644 --- a/strictdoc/export/html/_static/toc_highlighting.js +++ b/strictdoc/export/html/_static/toc_highlighting.js @@ -6,16 +6,21 @@ const TOC_ELEMENT_SELECTOR = 'a'; const CONTENT_FRAME_SELECTOR = 'turbo-frame#frame_document_content'; // replacing => parentNode is needed const CONTENT_ELEMENT_SELECTOR = 'sdoc-anchor'; +// * Runtime state; +// * anchorsCount/anchorsSig skip unnecessary re-observe on TOC mutations. let tocHighlightingState = { data: {}, links: null, anchors: null, + anchorsCount: -1, + anchorsSig: 0, contentFrameTop: undefined, closerForFolder: {}, folderSet: new Set(), }; function resetState() { + // * Keep anchorsCount/anchorsSig to detect changes across TOC mutations. tocHighlightingState.data = {}; tocHighlightingState.links = null; tocHighlightingState.anchors = null; @@ -142,11 +147,54 @@ function processLinkList(tocFrame) { } function processAnchorList(contentFrame, anchorObserver) { - anchorObserver.disconnect(); // FIXME Re-subscribe anchors (can be optimized later to avoid full re-scan) + // * Re-scan content anchors; + // * detect cheap changes via count + order-sensitive signature. // * Collects all anchors in the document - tocHighlightingState.anchors = contentFrame.querySelectorAll(CONTENT_ELEMENT_SELECTOR); - tocHighlightingState.anchors.forEach(anchor => { + const newAnchors = contentFrame.querySelectorAll(CONTENT_ELEMENT_SELECTOR); + + // * Build order-sensitive signature to detect renames/reorders without full re-subscribe. + let sig = 0; + for (let i = 0; i < newAnchors.length; i++) { + const id = newAnchors[i].id || ""; + // djb2-like rolling hash with index mix; kept in 32-bit int space + let h = 5381; + for (let j = 0; j < id.length; j++) { + h = ((h << 5) + h) ^ id.charCodeAt(j); + } + // mix position to make reorders detectable + sig = (sig ^ ((h + i * 2654435761) | 0)) | 0; + } + + // * Set unchanged → keep IO subscriptions; rebuild data[id].anchor after resetState(). + const unchanged = ( + tocHighlightingState.anchorsCount === newAnchors.length && + tocHighlightingState.anchorsSig === sig + ); + + if (unchanged) { + // * After resetState(), mapping in data[] is empty. + // We must rebuild anchor→data mapping even if the set is unchanged, + // otherwise IntersectionObserver events may hit undefined. + tocHighlightingState.anchors = newAnchors; + newAnchors.forEach(anchor => { + const id = anchor.id; + tocHighlightingState.data[id] = { + 'anchor': anchor, + ...tocHighlightingState.data[id] + }; + }); + return; + } + + // * Set changed → drop old IO targets and re‑subscribe. + anchorObserver.disconnect(); // ** Re-subscribe anchors only when content changed + + tocHighlightingState.anchors = newAnchors; + tocHighlightingState.anchorsCount = newAnchors.length; + tocHighlightingState.anchorsSig = sig; + + newAnchors.forEach(anchor => { const id = anchor.id; tocHighlightingState.data[id] = { 'anchor': anchor, From 79dfd77e10acf78b72fc75f4a486678f10480e77 Mon Sep 17 00:00:00 2001 From: Maryna Balioura Date: Thu, 28 Aug 2025 07:26:33 +0200 Subject: [PATCH 6/8] refactor(toc_highlighting): refine comments; drop unused contentFrameTop metric --- .../export/html/_static/toc_highlighting.js | 41 +++++++++---------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/strictdoc/export/html/_static/toc_highlighting.js b/strictdoc/export/html/_static/toc_highlighting.js index 41c07456a..3bf37539e 100644 --- a/strictdoc/export/html/_static/toc_highlighting.js +++ b/strictdoc/export/html/_static/toc_highlighting.js @@ -1,9 +1,12 @@ +// TOC highlighting: map content to TOC +// and toggle TOC states using IntersectionObserver. + const TOC_HIGHLIGHT_DEBUG = false; const TOC_FRAME_SELECTOR = 'turbo-frame#frame-toc'; // updating const TOC_LIST_SELECTOR = 'ul#toc'; const TOC_ELEMENT_SELECTOR = 'a'; -const CONTENT_FRAME_SELECTOR = 'turbo-frame#frame_document_content'; // replacing => parentNode is needed +const CONTENT_FRAME_SELECTOR = 'turbo-frame#frame_document_content'; // action="replace" => parentNode is needed const CONTENT_ELEMENT_SELECTOR = 'sdoc-anchor'; // * Runtime state; @@ -38,11 +41,6 @@ window.addEventListener("load",function(){ if (!tocFrame || !contentFrame) { return } - // ! depends on TOC markup - tocHighlightingState.contentFrameTop = contentFrame.offsetParent - ? contentFrame.offsetTop - : contentFrame.parentNode.offsetTop; - const anchorObserver = new IntersectionObserver( handleIntersect, { @@ -50,8 +48,8 @@ window.addEventListener("load",function(){ rootMargin: "0px", }); - // * Then we will refresh when the TOC tree is updated& - // * The content in the tocFrame frame will mutate: + // * On TOC updates, rebuild mappings; + // * processAnchorList decides whether to re‑observe anchors. const mutatingFrame = tocFrame; new MutationObserver(function (mutationsList, observer) { // * Use requestAnimationFrame to put highlightTOC @@ -72,7 +70,7 @@ window.addEventListener("load",function(){ } ); - // * Call for the first time only if the TOC actually contains items. + // * First init only if TOC already has items; otherwise MO will trigger later. if (tocList && tocList.querySelector(TOC_ELEMENT_SELECTOR)) { highlightTOC(tocFrame, contentFrame, anchorObserver); } @@ -80,7 +78,7 @@ window.addEventListener("load",function(){ },false); function highlightTOC(tocFrame, contentFrame, anchorObserver) { - + // * Rebuild in order: links → anchors → hash highlight. resetState(); processLinkList(tocFrame); processAnchorList(contentFrame, anchorObserver); @@ -90,6 +88,7 @@ function highlightTOC(tocFrame, contentFrame, anchorObserver) { } function handleHashChange() { + // * May fire before links are collected; guard against early hashchange. const hash = window.location.hash; const fragment = hash ? decodeURIComponent(hash.slice(1)) : null; @@ -100,23 +99,21 @@ function handleHashChange() { tocHighlightingState.links.forEach(link => { targetItem(link, false) }); - // * When updating the hash - // * and there's a fragment, + // * If there's a fragment and a mapped pair, highlight its link. fragment - // * and the corresponding link-anchor pair is registered, && tocHighlightingState.data[fragment] - // * highlight the corresponding link. && targetItem(tocHighlightingState.data[fragment].link) } function processLinkList(tocFrame) { - // * Collects all links in the TOC + // * Collect TOC links; NodeList is never null. Skip if empty. tocHighlightingState.links = tocFrame.querySelectorAll(TOC_ELEMENT_SELECTOR); if (tocHighlightingState.links.length === 0) { return; } tocHighlightingState.links.length && tocHighlightingState.links.forEach(link => { + // * Map only links that have an "anchor" attribute. const id = link.getAttribute('anchor'); if (!id) return; // Skip links without an anchor attribute tocHighlightingState.data[id] = { @@ -124,9 +121,7 @@ function processLinkList(tocFrame) { ...tocHighlightingState.data[id] } - // ! depends on TOC markup - // is link in collapsible node and precedes the UL - // ! expected UL or null + // * If a link precedes a nested