Skip to content

Commit 7f8696c

Browse files
committed
fix: highlight only parts
1 parent 210675b commit 7f8696c

File tree

2 files changed

+128
-111
lines changed

2 files changed

+128
-111
lines changed

src/client/ConfigNavigationClient.js

Lines changed: 46 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -58,97 +58,64 @@ const closeOtherDetails = function(keepOpenSet) {
5858
};
5959

6060
const highlightActiveOnPageLink = function(hash) {
61-
const hashToUse = hash || location.hash;
62-
if (!hashToUse) {
61+
// Use the passed hash parameter, not location.hash (which doesn't update with pushState)
62+
if (!hash) {
6363
return;
6464
}
6565

66-
const activeHash = hashToUse.substring(1);
67-
const allLinks = document.querySelectorAll('a');
66+
const activeHash = hash.substring(1);
6867

69-
for (let i = 0; i < allLinks.length; i++) {
70-
const link = allLinks[i];
71-
link.classList.remove('active');
72-
73-
if (link.parentElement && link.parentElement.parentElement && link.parentElement.parentElement.tagName === 'UL') {
74-
link.parentElement.parentElement.classList.remove('active');
68+
const updateTOC = () => {
69+
// Remove active class from all TOC links
70+
const allLinks = document.querySelectorAll('.table-of-contents a');
71+
for (let i = 0; i < allLinks.length; i++) {
72+
allLinks[i].classList.remove('table-of-contents__link--active');
7573
}
76-
}
7774

78-
const activeLinks = document.querySelectorAll("a[href='#" + activeHash + "']");
79-
for (let i = 0; i < activeLinks.length; i++) {
80-
activeLinks[i].classList.add('active');
81-
}
75+
// Add active class to matching TOC links
76+
const activeLinks = document.querySelectorAll(".table-of-contents a[href='#" + activeHash + "']");
77+
for (let i = 0; i < activeLinks.length; i++) {
78+
activeLinks[i].classList.add('table-of-contents__link--active');
79+
}
80+
};
81+
82+
// Update immediately
83+
updateTOC();
84+
85+
// Keep updating after scroll ends to override Docusaurus IntersectionObserver
86+
let scrollEndTimer;
87+
const handleScrollEnd = () => {
88+
clearTimeout(scrollEndTimer);
89+
scrollEndTimer = setTimeout(() => {
90+
updateTOC();
91+
window.removeEventListener('scroll', handleScrollEnd);
92+
}, 50);
93+
};
94+
95+
window.addEventListener('scroll', handleScrollEnd);
96+
handleScrollEnd();
8297
};
8398

8499
const highlightDetailsOnActiveHash = function(activeHash, doNotOpen) {
85-
const activeAnchors = document.querySelectorAll(".anchor[id='" + activeHash + "']");
86-
const detailsElements = document.querySelectorAll('details');
87-
const activeSectionElements = document.querySelectorAll('.active-section');
88-
89-
for (let i = 0; i < activeSectionElements.length; i++) {
90-
activeSectionElements[i].classList.remove('active-section');
91-
}
92-
93-
for (let i = 0; i < activeAnchors.length; i++) {
94-
const headline = activeAnchors[i].parentElement;
95-
const headlineRank = activeAnchors[i].parentElement.nodeName.substr(1);
96-
let el = headline;
97-
98-
while (el) {
99-
if (el.tagName !== 'BR' && el.tagName !== 'HR') {
100-
el.classList.add('active-section');
101-
}
102-
el = el.nextElementSibling;
103-
104-
if (el) {
105-
const elRank = el.nodeName.substr(1);
106-
if (elRank > 0 && elRank <= headlineRank) {
107-
break;
108-
}
109-
}
110-
}
111-
}
112-
113-
for (let i = 0; i < detailsElements.length; i++) {
114-
detailsElements[i].classList.remove('active');
115-
}
116-
117-
if (activeAnchors.length > 0) {
118-
for (let i = 0; i < activeAnchors.length; i++) {
119-
let element = activeAnchors[i];
120-
121-
for (; element && element !== document; element = element.parentElement) {
122-
if (element.tagName === 'DETAILS') {
123-
element.classList.add('active');
124-
125-
if (!doNotOpen) {
126-
element.open = true;
127-
element.setAttribute('data-collapsed', 'false');
128-
const collapsibleContent = element.querySelector(':scope > div[style]');
129-
if (collapsibleContent) {
130-
collapsibleContent.style.display = 'block';
131-
collapsibleContent.style.overflow = 'visible';
132-
collapsibleContent.style.height = 'auto';
133-
}
134-
}
135-
}
136-
}
137-
}
138-
}
100+
// NOTE: This function ONLY manages details expansion, NOT highlighting
101+
// Highlighting is managed by DetailsClicksClient to avoid race conditions
139102

103+
// Find the target element and handle its details
140104
const targetElement = activeHash ? document.getElementById(activeHash) : null;
141105
if (targetElement) {
142106
const parentDetails = getParentDetailsElements(targetElement);
143107
const keepOpenSet = new Set(parentDetails);
144108

109+
// Close other unrelated details
145110
if (!doNotOpen) {
146111
closeOtherDetails(keepOpenSet);
147112
}
148113

114+
// Open parent details - NO highlighting here!
149115
for (let i = 0; i < parentDetails.length; i++) {
150116
const element = parentDetails[i];
151-
element.classList.add('active');
117+
118+
// Open all parent details
152119
if (!doNotOpen) {
153120
element.open = true;
154121
element.setAttribute('data-collapsed', 'false');
@@ -169,7 +136,7 @@ const handleHashNavigation = function(hash) {
169136

170137
const targetId = hash.substring(1);
171138
highlightDetailsOnActiveHash(targetId);
172-
highlightActiveOnPageLink();
139+
highlightActiveOnPageLink(hash);
173140
};
174141

175142
// Hash link clicks are handled by DetailsClicksClient to avoid race conditions
@@ -209,10 +176,10 @@ export function onRouteDidUpdate({ location, previousLocation }) {
209176
handleHashNavigation(location.hash);
210177

211178
if (targetEl) {
212-
setTimeout(() => {
213-
const y = targetEl.getBoundingClientRect().top + window.scrollY - 280;
179+
requestAnimationFrame(() => {
180+
const y = targetEl.getBoundingClientRect().top + window.scrollY - 100;
214181
window.scrollTo({ top: y, behavior: 'smooth' });
215-
}, 150);
182+
});
216183
}
217184
}
218185
});
@@ -229,18 +196,19 @@ export function onRouteDidUpdate({ location, previousLocation }) {
229196
handleHashNavigation(hash);
230197

231198
if (targetEl) {
232-
setTimeout(() => {
233-
const y = targetEl.getBoundingClientRect().top + window.scrollY - 280;
199+
requestAnimationFrame(() => {
200+
const y = targetEl.getBoundingClientRect().top + window.scrollY - 100;
234201
window.scrollTo({ top: y, behavior: 'smooth' });
235-
}, 150);
202+
});
236203
}
237204
});
238205

239206
// NOTE: hashchange only handles highlighting, NOT scrolling (to avoid race condition)
240207
// Scrolling is handled by DetailsClicksClient's universal click handler
241208
window.addEventListener('hashchange', function(e) {
209+
// Extract hash from newURL to handle pushState correctly
242210
const newHash = e.newURL ? e.newURL.split('#')[1] : '';
243-
const hashToUse = newHash ? '#' + newHash : location.hash;
211+
const hashToUse = newHash ? '#' + newHash : '';
244212

245213
if (!hashToUse) {
246214
return;

src/client/DetailsClicksClient.js

Lines changed: 82 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -580,7 +580,27 @@ const preserveExpansionStates = ExecutionEnvironment.canUseDOM ? function(skipEv
580580
}, 50);
581581
};
582582

583-
document.querySelectorAll('details, .tabs-container').forEach(function(el, index) {
583+
// Remove all existing highlights before re-initializing
584+
// This prevents stale highlights from previous navigation
585+
document.querySelectorAll('.-contains-target-link').forEach(el => {
586+
el.classList.remove('-contains-target-link');
587+
});
588+
589+
// Sort elements by depth (deepest first) to process children before parents
590+
// This prevents race condition where both parent and child get highlighted
591+
const elements = Array.from(document.querySelectorAll('details, .tabs-container'));
592+
const elementsWithDepth = elements.map(el => {
593+
let depth = 0;
594+
let current = el.parentElement;
595+
while (current) {
596+
if (current.tagName === 'DETAILS') depth++;
597+
current = current.parentElement;
598+
}
599+
return { el, depth };
600+
});
601+
elementsWithDepth.sort((a, b) => b.depth - a.depth); // Deepest first
602+
603+
elementsWithDepth.forEach(function({ el, depth }, index) {
584604
const expansionKey = "x" + (el.id || index);
585605
const stateChangeElAll = el.querySelectorAll(':scope > summary, :scope > [role="tablist"] > *');
586606
const anchorLinks = el.querySelectorAll(':scope a[href="' + location.hash + '"]');
@@ -589,8 +609,17 @@ const preserveExpansionStates = ExecutionEnvironment.canUseDOM ? function(skipEv
589609
const targetEl = targetId ? document.getElementById(targetId) : null;
590610
const containsTarget = targetEl && (el.id === targetId || el.contains(targetEl));
591611

612+
// Check if this element's DIRECT summary (not nested) has a link to the target
613+
const hasSummaryLink = el.querySelectorAll(':scope > summary a[href="' + location.hash + '"]').length > 0;
614+
615+
// Only highlight if no child details element already has the highlight class
616+
// (children are processed first due to depth sorting)
617+
const childDetailsHighlighted = Array.from(el.querySelectorAll('details')).some(
618+
child => child.classList.contains('-contains-target-link')
619+
);
620+
592621
if (anchorLinks.length > 0 || containsTarget) {
593-
if (el.querySelectorAll(':scope > summary a[href="' + location.hash + '"]').length > 0) {
622+
if (hasSummaryLink && !childDetailsHighlighted) {
594623
el.classList.add("-contains-target-link");
595624
}
596625
state.set(expansionKey, 1);
@@ -600,7 +629,6 @@ const preserveExpansionStates = ExecutionEnvironment.canUseDOM ? function(skipEv
600629
// NOTE: Initial page load scrolling is handled by ConfigNavigationClient
601630
}
602631
} else {
603-
el.classList.remove("-contains-target-link");
604632
state.delete(expansionKey);
605633
}
606634

@@ -610,14 +638,14 @@ const preserveExpansionStates = ExecutionEnvironment.canUseDOM ? function(skipEv
610638
const state = new URLSearchParams(window.location.search.substring(1));
611639
if ((el.open && el.getAttribute("data-expandable") != "false") || el.classList.contains("tabs-container")) {
612640
if (anchorLinks.length == 1) {
613-
if (anchorLinks[0].getAttribute("href") == location.hash) {
614-
el.classList.add("-contains-target-link");
615-
}
641+
// NOTE: Highlighting is managed by initializeDetailsElement (initial load)
642+
// and universal click handler (user clicks). Do NOT manage highlights here
643+
// to avoid race conditions and double highlighting.
616644
} else {
617645
state.set(expansionKey, i);
618646
}
619647
} else {
620-
el.classList.remove("-contains-target-link");
648+
// NOTE: Do NOT remove highlight class here - only click handler manages highlights
621649
state.delete(expansionKey);
622650
}
623651

@@ -650,6 +678,7 @@ const preserveExpansionStates = ExecutionEnvironment.canUseDOM ? function(skipEv
650678
const newHash = anchorLink.getAttribute("href");
651679
const targetId = newHash.substring(1);
652680

681+
// Remove highlight from elements that don't link to the new hash
653682
document.querySelectorAll(".-contains-target-link").forEach(function(el) {
654683
if (el.querySelectorAll(':scope > summary a[href="' + newHash + '"]').length == 0) {
655684
el.classList.remove("-contains-target-link");
@@ -670,17 +699,18 @@ const preserveExpansionStates = ExecutionEnvironment.canUseDOM ? function(skipEv
670699
closeOtherDetails(keepOpenSet);
671700
parentDetails.forEach(openDetailsElement);
672701

702+
// Add orange border ONLY to the innermost/direct details element
703+
if (parentDetails.length > 0) {
704+
const directTargetDetails = parentDetails[0]; // First element is innermost
705+
directTargetDetails.classList.add("-contains-target-link");
706+
}
707+
673708
// replaceState doesn't trigger hashchange, so we must scroll here
674-
setTimeout(() => {
709+
requestAnimationFrame(() => {
675710
const targetRect = targetEl.getBoundingClientRect();
676-
if (targetRect.top !== 0 || targetRect.left !== 0) {
677-
window.scroll({
678-
behavior: 'smooth',
679-
left: 0,
680-
top: window.scrollY + targetRect.top - 280
681-
});
682-
}
683-
}, 150);
711+
const y = window.scrollY + targetRect.top - 100;
712+
window.scrollTo({ top: y, behavior: 'smooth' });
713+
});
684714
} else if (!el.hasAttribute("open")) {
685715
anchorLink.parentElement.click();
686716
}
@@ -729,6 +759,8 @@ const preserveExpansionStates = ExecutionEnvironment.canUseDOM ? function(skipEv
729759
// Universal hash link handler (including TOC sidebar)
730760
// Fixes Docusaurus's buggy hash navigation and enables CSS :target highlighting
731761
if (ExecutionEnvironment.canUseDOM) {
762+
let scrollTimeout = null;
763+
732764
document.addEventListener('click', function(e) {
733765
let target = e.target;
734766
let anchor = null;
@@ -756,26 +788,43 @@ if (ExecutionEnvironment.canUseDOM) {
756788
closeOtherDetails(keepOpenSet);
757789
parentDetails.forEach(openDetailsElement);
758790

759-
setTimeout(() => {
760-
const currentScrollY = window.scrollY;
791+
// Remove all existing highlights first
792+
document.querySelectorAll('.-contains-target-link').forEach(el => {
793+
el.classList.remove('-contains-target-link');
794+
});
761795

762-
// Setting location.hash triggers :target CSS for highlighting
763-
if (window.location.hash !== hash) {
764-
window.location.hash = hash;
765-
}
796+
// Add highlight to the innermost parent details element
797+
if (parentDetails.length > 0) {
798+
parentDetails[0].classList.add('-contains-target-link');
799+
}
766800

767-
// Cancel browser's automatic scroll
768-
window.scrollTo(0, currentScrollY);
801+
// Update URL hash for history/bookmarking
802+
const oldURL = window.location.href;
803+
window.history.pushState(null, '', hash);
804+
const newURL = window.location.href;
769805

770-
// Do our own controlled scroll
771-
setTimeout(() => {
772-
const rect = targetEl.getBoundingClientRect();
773-
if (rect.top !== 0 || rect.left !== 0) {
774-
const y = window.scrollY + rect.top - 280;
775-
window.scrollTo({ top: y, behavior: 'smooth' });
776-
}
777-
}, 50);
778-
}, 150);
806+
// Trigger hashchange event manually for other listeners (including sidebar highlighting)
807+
window.dispatchEvent(new HashChangeEvent('hashchange', {
808+
oldURL: oldURL,
809+
newURL: newURL
810+
}));
811+
812+
// Cancel any pending scroll
813+
if (scrollTimeout) {
814+
clearTimeout(scrollTimeout);
815+
}
816+
817+
// Smooth scroll to target with proper offset
818+
scrollTimeout = requestAnimationFrame(() => {
819+
const rect = targetEl.getBoundingClientRect();
820+
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
821+
const targetY = scrollTop + rect.top - 100;
822+
823+
window.scrollTo({
824+
top: targetY,
825+
behavior: 'smooth'
826+
});
827+
});
779828
}
780829
}
781830
}, true); // Capture phase

0 commit comments

Comments
 (0)