-
Notifications
You must be signed in to change notification settings - Fork 36
TOC highlighting refactor and robustness fixes #2443
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
mettta
wants to merge
8
commits into
main
Choose a base branch
from
mettta/TOC_highlighting_ref
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
a2210b1
refactor(toc_highlighting): cleanup redundant guards
mettta 8558611
refactor(toc_highlighting): simplify hash handling
mettta 37333e3
refactor(toc_highlighting): unified coordinate checks in Intersection…
mettta 21d0011
refactor(toc_highlighting): handle rootBounds=null with viewport fall…
mettta e4c5a7e
refactor(toc_highlighting): optimize anchor re-scan and keep mapping …
mettta 79dfd77
refactor(toc_highlighting): refine comments; drop unused contentFrame…
mettta 03de370
refactor(toc_highlighting): replace silent guards with asserts and mo…
mettta 47f9ef3
feat(toc_highlighting): add resolveMovedFragment() to remap outdated …
mettta File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
|
|
@@ -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,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); | ||
|
|
@@ -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] = []; | ||
|
|
@@ -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, | ||
|
|
@@ -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); | ||
|
|
@@ -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); | ||
|
|
@@ -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) | ||
| ) { | ||
|
|
@@ -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); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
|
|
@@ -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 | ||
|
|
@@ -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 { | ||
|
|
@@ -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 | ||
|
|
||
| console.assert(element, 'fireItem(): expected a valid element'); | ||
| if(on) { | ||
| element.setAttribute('intersected', ''); | ||
| } else { | ||
|
|
@@ -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 | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.