Skip to content
205 changes: 150 additions & 55 deletions strictdoc/export/html/_static/toc_highlighting.js
Original file line number Diff line number Diff line change
@@ -1,21 +1,29 @@
// TOC highlighting: map content <sdoc-anchor id> to TOC <a anchor>
// 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;
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -67,15 +70,15 @@ 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);
}

},false);

function highlightTOC(tocFrame, contentFrame, anchorObserver) {

// * Rebuild in order: links → anchors → hash highlight.
resetState();
processLinkList(tocFrame);
processAnchorList(contentFrame, anchorObserver);
Expand All @@ -85,54 +88,67 @@ 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 <ul>, register the folder and its "closer" anchors.
const ul = link.nextElementSibling;

if (ul && ul.nodeName === 'UL') {
// register folder
tocHighlightingState.folderSet.add(id);

// 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] = [];
Expand All @@ -143,13 +159,54 @@ function processLinkList(tocFrame) {
}

function processAnchorList(contentFrame, anchorObserver) {
anchorObserver.disconnect(); // FIXME don`t work: have to hack at #rootBounds_null
// * Re-scan content anchors;
// * detect cheap changes via count + order-sensitive signature.

// * Collects all anchors in the document
tocHighlightingState.anchors = null;
tocHighlightingState.anchors = contentFrame.querySelectorAll(CONTENT_ELEMENT_SELECTOR);
tocHighlightingState.anchors.length
&& 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,
Expand All @@ -164,23 +221,20 @@ 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;
// * For anchors that go into the viewport,
// * finds the corresponding links
// * Fallback: rootBounds can be null right after IO init; use viewport bounds in that case.
const topBound = entry.rootBounds ? entry.rootBounds.top : 0;
const bottomBound = entry.rootBounds ? entry.rootBounds.bottom : window.innerHeight;

// * IO may fire between resets; mapping may be missing — skip safely.
const link = tocHighlightingState.data[anchor].link;

// * if there is no menu item for the section in the TOC
if(!link) {
return
}

// ** Visible (any positive intersection). Highlight item and related folders.
if (entry.isIntersecting) { //! entry.intersectionRatio > 0 -- it happens to be equal to zero at the intersection!

TOC_HIGHLIGHT_DEBUG && console.group('🔶', entry.isIntersecting, entry.intersectionRatio, anchor, entry.intersectionRect.height);
Expand All @@ -197,11 +251,16 @@ function handleIntersect(entries, observer) {
if (tocHighlightingState.closerForFolder[anchor]) {
tocHighlightingState.closerForFolder[anchor].forEach(id => {
TOC_HIGHLIGHT_DEBUG && console.log(`🔴`, id, `(from ${anchor})`);
fireFolder(tocHighlightingState.data[id].link)
const pair2 = tocHighlightingState.data[id];
console.assert(pair2 && pair2.link, 'handleIntersect(): missing folder link for', id);
if (pair2 && pair2.link) {
fireFolder(pair2.link);
}
})
}
TOC_HIGHLIGHT_DEBUG && console.groupEnd();

// ** Not visible. Remove item highlight and conditionally de-highlight folders.
} else {

TOC_HIGHLIGHT_DEBUG && console.group('🔹', entry.isIntersecting, entry.intersectionRatio, anchor);
Expand All @@ -210,7 +269,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)
) {
Expand All @@ -221,21 +280,25 @@ function handleIntersect(entries, observer) {

if(
// * If the node goes up ⬆️ off the screen
entry.boundingClientRect.y < tocHighlightingState.contentFrameTop
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 ( < tocHighlightingState.contentFrameTop),
// * 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.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
tocHighlightingState.closerForFolder[anchor].forEach(id => {
TOC_HIGHLIGHT_DEBUG && console.log(`⚫ ⬆️`, id,);
fireFolder(tocHighlightingState.data[id].link, false)
const pair3 = tocHighlightingState.data[id];
console.assert(pair3 && pair3.link, 'handleIntersect(): missing folder link for', id);
if (pair3 && pair3.link) {
fireFolder(pair3.link, false);
}
});
}

Expand All @@ -246,6 +309,9 @@ function handleIntersect(entries, observer) {
}

function findDeepestLastChild(element) {
// * Walk down the last-child chain to find the last <a> inside a nested list
// * (depends on TOC markup):

// ! depends on TOC markup
// ul > li > div + a + ul > ...
// ul > li > a
Expand All @@ -262,9 +328,12 @@ function findDeepestLastChild(element) {
}

function targetItem(element, on = true) {
if (!element) { return } // Guard against race conditions:
// hashchange or intersection events may fire
// before the TOC is fully built, resulting in undefined link elements.
// * Toggle "targeted" attribute for direct hash navigation.

//// hashchange or intersection events may fire
//// before the TOC is fully built, resulting in undefined link elements.

console.assert(element, 'targetItem(): expected a valid element');
if(on) {
element.setAttribute('targeted', '');
} else {
Expand All @@ -273,7 +342,12 @@ function targetItem(element, on = true) {
}

function fireItem(element, on = true) {
if (!element) { return } // Guard against race conditions
// * Toggle "intersected" attribute for visible anchors.

//// Guard: events may fire before TOC is fully built.
////// Guard against race conditions
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed, let's not forget to remove this.


console.assert(element, 'fireItem(): expected a valid element');
if(on) {
element.setAttribute('intersected', '');
} else {
Expand All @@ -282,10 +356,31 @@ function fireItem(element, on = true) {
}

function fireFolder(element, on = true) {
if (!element) { return } // Guard against race conditions
// * Toggle "parented" attribute for section folders.

//// Guard against race conditions
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed, let's not forget to remove this.


console.assert(element, 'fireFolder(): expected a valid element');
if(on) {
element.setAttribute('parented', '');
} else {
element.removeAttribute('parented');
}
}

function resolveMovedFragment(oldId) {
// Heuristic: many ids look like "<numbering>-<slug>", where numbering changes on reorder.
// Try to map by the slug suffix after the first '-' if the exact id is missing.
const dash = oldId.indexOf('-');
if (dash === -1) return null; // no recognizable pattern
const suffix = oldId.slice(dash + 1);
if (!suffix) return null;

const keys = Object.keys(tocHighlightingState.data);
const candidates = keys.filter(k => k.endsWith(suffix));
if (candidates.length === 1) {
return candidates[0];
}
// If multiple candidates, don't guess.
return null;
}
Loading