Skip to content

Commit fce6f58

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

File tree

2 files changed

+121
-96
lines changed

2 files changed

+121
-96
lines changed

src/client/ConfigNavigationClient.js

Lines changed: 55 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -58,97 +58,87 @@ 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) {
85100
const activeAnchors = document.querySelectorAll(".anchor[id='" + activeHash + "']");
86101
const detailsElements = document.querySelectorAll('details');
87102
const activeSectionElements = document.querySelectorAll('.active-section');
88103

104+
// Clear all previous highlights
89105
for (let i = 0; i < activeSectionElements.length; i++) {
90106
activeSectionElements[i].classList.remove('active-section');
91107
}
92108

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-
113109
for (let i = 0; i < detailsElements.length; i++) {
114110
detailsElements[i].classList.remove('active');
115111
}
116112

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-
}
113+
// Add active-section class only to the heading itself
114+
for (let i = 0; i < activeAnchors.length; i++) {
115+
const headline = activeAnchors[i].parentElement;
116+
headline.classList.add('active-section');
138117
}
139118

119+
// Find the target element and handle its details
140120
const targetElement = activeHash ? document.getElementById(activeHash) : null;
141121
if (targetElement) {
142122
const parentDetails = getParentDetailsElements(targetElement);
143123
const keepOpenSet = new Set(parentDetails);
144124

125+
// Close other unrelated details
145126
if (!doNotOpen) {
146127
closeOtherDetails(keepOpenSet);
147128
}
148129

130+
// Open parent details and mark only the DIRECT target detail as active
131+
let directTargetDetail = null;
149132
for (let i = 0; i < parentDetails.length; i++) {
150133
const element = parentDetails[i];
151-
element.classList.add('active');
134+
135+
// The first (innermost) detail that contains the target is the direct one
136+
if (i === 0) {
137+
directTargetDetail = element;
138+
element.classList.add('active');
139+
}
140+
141+
// Open all parent details
152142
if (!doNotOpen) {
153143
element.open = true;
154144
element.setAttribute('data-collapsed', 'false');
@@ -209,10 +199,10 @@ export function onRouteDidUpdate({ location, previousLocation }) {
209199
handleHashNavigation(location.hash);
210200

211201
if (targetEl) {
212-
setTimeout(() => {
213-
const y = targetEl.getBoundingClientRect().top + window.scrollY - 280;
202+
requestAnimationFrame(() => {
203+
const y = targetEl.getBoundingClientRect().top + window.scrollY - 100;
214204
window.scrollTo({ top: y, behavior: 'smooth' });
215-
}, 150);
205+
});
216206
}
217207
}
218208
});
@@ -229,18 +219,19 @@ export function onRouteDidUpdate({ location, previousLocation }) {
229219
handleHashNavigation(hash);
230220

231221
if (targetEl) {
232-
setTimeout(() => {
233-
const y = targetEl.getBoundingClientRect().top + window.scrollY - 280;
222+
requestAnimationFrame(() => {
223+
const y = targetEl.getBoundingClientRect().top + window.scrollY - 100;
234224
window.scrollTo({ top: y, behavior: 'smooth' });
235-
}, 150);
225+
});
236226
}
237227
});
238228

239229
// NOTE: hashchange only handles highlighting, NOT scrolling (to avoid race condition)
240230
// Scrolling is handled by DetailsClicksClient's universal click handler
241231
window.addEventListener('hashchange', function(e) {
232+
// Extract hash from newURL to handle pushState correctly
242233
const newHash = e.newURL ? e.newURL.split('#')[1] : '';
243-
const hashToUse = newHash ? '#' + newHash : location.hash;
234+
const hashToUse = newHash ? '#' + newHash : '';
244235

245236
if (!hashToUse) {
246237
return;

src/client/DetailsClicksClient.js

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

583-
document.querySelectorAll('details, .tabs-container').forEach(function(el, index) {
583+
// Sort elements by depth (deepest first) to process children before parents
584+
// This prevents race condition where both parent and child get highlighted
585+
const elements = Array.from(document.querySelectorAll('details, .tabs-container'));
586+
const elementsWithDepth = elements.map(el => {
587+
let depth = 0;
588+
let current = el.parentElement;
589+
while (current) {
590+
if (current.tagName === 'DETAILS') depth++;
591+
current = current.parentElement;
592+
}
593+
return { el, depth };
594+
});
595+
elementsWithDepth.sort((a, b) => b.depth - a.depth); // Deepest first
596+
597+
elementsWithDepth.forEach(function({ el, depth }, index) {
584598
const expansionKey = "x" + (el.id || index);
585599
const stateChangeElAll = el.querySelectorAll(':scope > summary, :scope > [role="tablist"] > *');
586600
const anchorLinks = el.querySelectorAll(':scope a[href="' + location.hash + '"]');
@@ -589,8 +603,17 @@ const preserveExpansionStates = ExecutionEnvironment.canUseDOM ? function(skipEv
589603
const targetEl = targetId ? document.getElementById(targetId) : null;
590604
const containsTarget = targetEl && (el.id === targetId || el.contains(targetEl));
591605

606+
// Check if this element's DIRECT summary (not nested) has a link to the target
607+
const hasSummaryLink = el.querySelectorAll(':scope > summary a[href="' + location.hash + '"]').length > 0;
608+
609+
// Only highlight if no child details element already has the highlight class
610+
// (children are processed first due to depth sorting)
611+
const childDetailsHighlighted = Array.from(el.querySelectorAll('details')).some(
612+
child => child.classList.contains('-contains-target-link')
613+
);
614+
592615
if (anchorLinks.length > 0 || containsTarget) {
593-
if (el.querySelectorAll(':scope > summary a[href="' + location.hash + '"]').length > 0) {
616+
if (hasSummaryLink && !childDetailsHighlighted) {
594617
el.classList.add("-contains-target-link");
595618
}
596619
state.set(expansionKey, 1);
@@ -610,14 +633,14 @@ const preserveExpansionStates = ExecutionEnvironment.canUseDOM ? function(skipEv
610633
const state = new URLSearchParams(window.location.search.substring(1));
611634
if ((el.open && el.getAttribute("data-expandable") != "false") || el.classList.contains("tabs-container")) {
612635
if (anchorLinks.length == 1) {
613-
if (anchorLinks[0].getAttribute("href") == location.hash) {
614-
el.classList.add("-contains-target-link");
615-
}
636+
// NOTE: Highlighting is managed by initializeDetailsElement (initial load)
637+
// and universal click handler (user clicks). Do NOT manage highlights here
638+
// to avoid race conditions and double highlighting.
616639
} else {
617640
state.set(expansionKey, i);
618641
}
619642
} else {
620-
el.classList.remove("-contains-target-link");
643+
// NOTE: Do NOT remove highlight class here - only click handler manages highlights
621644
state.delete(expansionKey);
622645
}
623646

@@ -650,6 +673,7 @@ const preserveExpansionStates = ExecutionEnvironment.canUseDOM ? function(skipEv
650673
const newHash = anchorLink.getAttribute("href");
651674
const targetId = newHash.substring(1);
652675

676+
// Remove highlight from elements that don't link to the new hash
653677
document.querySelectorAll(".-contains-target-link").forEach(function(el) {
654678
if (el.querySelectorAll(':scope > summary a[href="' + newHash + '"]').length == 0) {
655679
el.classList.remove("-contains-target-link");
@@ -670,17 +694,18 @@ const preserveExpansionStates = ExecutionEnvironment.canUseDOM ? function(skipEv
670694
closeOtherDetails(keepOpenSet);
671695
parentDetails.forEach(openDetailsElement);
672696

697+
// Add orange border ONLY to the innermost/direct details element
698+
if (parentDetails.length > 0) {
699+
const directTargetDetails = parentDetails[0]; // First element is innermost
700+
directTargetDetails.classList.add("-contains-target-link");
701+
}
702+
673703
// replaceState doesn't trigger hashchange, so we must scroll here
674-
setTimeout(() => {
704+
requestAnimationFrame(() => {
675705
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);
706+
const y = window.scrollY + targetRect.top - 100;
707+
window.scrollTo({ top: y, behavior: 'smooth' });
708+
});
684709
} else if (!el.hasAttribute("open")) {
685710
anchorLink.parentElement.click();
686711
}
@@ -729,6 +754,8 @@ const preserveExpansionStates = ExecutionEnvironment.canUseDOM ? function(skipEv
729754
// Universal hash link handler (including TOC sidebar)
730755
// Fixes Docusaurus's buggy hash navigation and enables CSS :target highlighting
731756
if (ExecutionEnvironment.canUseDOM) {
757+
let scrollTimeout = null;
758+
732759
document.addEventListener('click', function(e) {
733760
let target = e.target;
734761
let anchor = null;
@@ -756,26 +783,33 @@ if (ExecutionEnvironment.canUseDOM) {
756783
closeOtherDetails(keepOpenSet);
757784
parentDetails.forEach(openDetailsElement);
758785

759-
setTimeout(() => {
760-
const currentScrollY = window.scrollY;
786+
// Update URL hash for history/bookmarking
787+
const oldURL = window.location.href;
788+
window.history.pushState(null, '', hash);
789+
const newURL = window.location.href;
761790

762-
// Setting location.hash triggers :target CSS for highlighting
763-
if (window.location.hash !== hash) {
764-
window.location.hash = hash;
765-
}
791+
// Trigger hashchange event manually for other listeners (including sidebar highlighting)
792+
window.dispatchEvent(new HashChangeEvent('hashchange', {
793+
oldURL: oldURL,
794+
newURL: newURL
795+
}));
766796

767-
// Cancel browser's automatic scroll
768-
window.scrollTo(0, currentScrollY);
797+
// Cancel any pending scroll
798+
if (scrollTimeout) {
799+
clearTimeout(scrollTimeout);
800+
}
769801

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);
802+
// Smooth scroll to target with proper offset
803+
scrollTimeout = requestAnimationFrame(() => {
804+
const rect = targetEl.getBoundingClientRect();
805+
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
806+
const targetY = scrollTop + rect.top - 100;
807+
808+
window.scrollTo({
809+
top: targetY,
810+
behavior: 'smooth'
811+
});
812+
});
779813
}
780814
}
781815
}, true); // Capture phase

0 commit comments

Comments
 (0)