diff --git a/strictdoc/export/html/_static/toc_highlighting.js b/strictdoc/export/html/_static/toc_highlighting.js index fb96016af..37abe9fbf 100644 --- a/strictdoc/export/html/_static/toc_highlighting.js +++ b/strictdoc/export/html/_static/toc_highlighting.js @@ -1,21 +1,29 @@ +// 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; +// * 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; @@ -31,12 +39,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 } - - // ! depends on TOC markup - tocHighlightingState.contentFrameTop = contentFrame.offsetParent - ? contentFrame.offsetTop - : contentFrame.parentNode.offsetTop; + if (!tocFrame || !contentFrame) { return } const anchorObserver = new IntersectionObserver( handleIntersect, @@ -45,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 @@ -67,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); } @@ -75,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); @@ -85,45 +88,58 @@ function highlightTOC(tocFrame, contentFrame, anchorObserver) { } function handleHashChange() { + // * May fire before links are collected; guard against early hashchange. const hash = window.location.hash; - const match = hash.match(/#(.*)/); - const fragment = match ? match[1] : null; + const fragment = hash ? decodeURIComponent(hash.slice(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; } tocHighlightingState.links.forEach(link => { targetItem(link, false) }); - // * When updating the hash - // * and there's a fragment, - fragment - // * and the corresponding link-anchor pair is registered, - && tocHighlightingState.data[fragment] - // * highlight the corresponding link. - && targetItem(tocHighlightingState.data[fragment].link) + // * If there's a fragment and a mapped pair, highlight its link. + if (fragment) { + let pair = tocHighlightingState.data[fragment]; + if (!pair || !pair.link) { + // Try to resolve moved/renumbered ids by suffix + const resolved = resolveMovedFragment(fragment); + if (resolved) { + TOC_HIGHLIGHT_DEBUG && console.log('handleHashChange(): remapped fragment', fragment, '→', resolved); + history.replaceState(null, '', '#' + encodeURIComponent(resolved)); + pair = tocHighlightingState.data[resolved]; + } + } + if (pair && pair.link) { + targetItem(pair.link); + } else { + // No mapping found — keep URL as-is and move on silently + // TOC_HIGHLIGHT_DEBUG && + console.warn('handleHashChange(): no mapping for fragment', fragment); + return; + } + } } 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 || tocHighlightingState.links.length === 0) { + 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] = { 'link': link, ...tocHighlightingState.data[id] } - // ! depends on TOC markup - // is link in collapsible node and precedes the UL - // ! expected UL or null - const ul = link.nextSibling; + // * If a link precedes a nested