refactor(toc, customizer): improve TOC architecture and reorganize customizer settings

- Reorganized Customizer structure for improved clarity and maintainability
- Introduced consistent default values for all settings to ensure stable fallbacks
  when no user preferences are defined

- Refactored scroll-driven TOC implementation:
  - Optimized scroll handling using requestAnimationFrame
  - Reduced layout thrashing and unnecessary DOM reads
  - Improved heading detection logic (deterministic viewport trigger)
  - Enhanced positioning logic (responsive alignment + sidebar awareness)
  - Improved footer collision handling for more robust layout behavior

- Added optional IntersectionObserver-based TOC implementation:
  - Event-driven alternative to scroll-based approach
  - Currently not enabled by default
  - May not be supported long-term due to less deterministic behavior

- General cleanup and internal consistency improvements

chore: bump version to 2.4.0
This commit is contained in:
2026-04-26 18:48:28 +02:00
parent 36cad12351
commit 6bf38ae05d
8 changed files with 864 additions and 91 deletions
+89 -36
View File
@@ -1,6 +1,41 @@
/**
* Floating TOC (Scroll-Driven Implementation)
*
* This implementation uses a scroll-based approach combined with
* requestAnimationFrame to determine the currently active heading.
* The active section is calculated based on a fixed viewport trigger
* (offset from the top), ensuring predictable and stable behavior.
*
* Design Goals:
* - Deterministic highlighting (no competing states)
* - Visual stability (no flickering or race conditions)
* - Theme compatibility (no reliance on experimental APIs)
* - Maintainable and debuggable logic
*
* Characteristics:
* - Uses a cached list of headings for efficient iteration
* - Throttles scroll handling via requestAnimationFrame
* - Minimizes DOM writes and layout recalculations
* - Separates layout (positioning) from state (active link)
*
* Trade-offs:
* - Runs continuously during scroll (minor CPU overhead)
* - Relies on getBoundingClientRect for visibility detection
* - Less "event-driven" than IntersectionObserver-based approaches
*
* Notes:
* This approach was chosen over IntersectionObserver to guarantee
* consistent ordering, eliminate flickering edge cases, and provide
* fully deterministic behavior across all layouts and browsers.
*/
document.addEventListener('DOMContentLoaded', function () {
var toc = document.getElementById('zeitfresser-floating-toc');
var title = document.querySelector('.zeitfresser-article-heading .page-title, .zeitfresser-article-heading .entry-title, .entry-header .entry-title');
var title = document.querySelector(
'.zeitfresser-article-heading .page-title, ' +
'.zeitfresser-article-heading .entry-title, ' +
'.entry-header .entry-title'
);
var progressBar = document.getElementById('zeitfresser-floating-toc-progress');
var nav = toc ? toc.querySelector('.zeitfresser-floating-toc__nav') : null;
@@ -13,8 +48,9 @@ document.addEventListener('DOMContentLoaded', function () {
var stickyTop = 100;
var headingOffset = 88;
var ticking = false;
var cachedSidebar = null;
var tocBottomOffset = null;
var cachedSidebar = null;
var headings = getHeadings();
function isDesktop() {
return desktopQuery.matches;
@@ -26,14 +62,13 @@ document.addEventListener('DOMContentLoaded', function () {
}
function getHeadings() {
return links.map(function (link) {
return {
link: link,
target: getTarget(link)
};
}).filter(function (item) {
return !!item.target;
});
return links
.map(function (link) {
return { link: link, target: getTarget(link) };
})
.filter(function (item) {
return !!item.target;
});
}
function getArticleElement() {
@@ -55,7 +90,7 @@ document.addEventListener('DOMContentLoaded', function () {
tocBottomOffset = parseInt(value, 10) || 12;
return tocBottomOffset;
}
function getRealSidebar() {
if (cachedSidebar) return cachedSidebar;
@@ -69,7 +104,9 @@ document.addEventListener('DOMContentLoaded', function () {
return rect.width > 200 && rect.height > 200;
})
.sort(function (a, b) {
return b.getBoundingClientRect().left - a.getBoundingClientRect().left;
var rectA = a.getBoundingClientRect();
var rectB = b.getBoundingClientRect();
return rectB.left - rectA.left;
})[0] || null;
return cachedSidebar;
@@ -96,7 +133,6 @@ document.addEventListener('DOMContentLoaded', function () {
if (!contentColumn) return;
var sidebar = getRealSidebar();
var contentRect = contentColumn.getBoundingClientRect();
var sidebarRect = sidebar ? sidebar.getBoundingClientRect() : null;
@@ -107,10 +143,9 @@ document.addEventListener('DOMContentLoaded', function () {
gap = Math.max(32, Math.min(gap, 120));
}
// Toc Content Breite
var maxWidth = Math.max(Math.round(contentRect.left - gap - 24), 180);
var tocWidth = Math.max(220, Math.min(260, maxWidth));
var tocLeft = Math.max(
24,
Math.round(contentRect.left - gap - tocWidth)
@@ -133,7 +168,6 @@ document.addEventListener('DOMContentLoaded', function () {
}
var article = getArticleElement();
if (!article) {
toc.style.transform = '';
return;
@@ -142,7 +176,6 @@ document.addEventListener('DOMContentLoaded', function () {
toc.style.transform = '';
var scrollTop = window.scrollY || window.pageYOffset;
var articleRect = article.getBoundingClientRect();
var articleBottom = articleRect.top + scrollTop + articleRect.height;
@@ -152,7 +185,6 @@ document.addEventListener('DOMContentLoaded', function () {
var tocBottom = tocTop + tocHeight;
var offset = getTocBottomOffset();
var maxBottom = articleBottom - offset;
var overflow = Math.ceil(tocBottom - maxBottom);
@@ -178,7 +210,6 @@ document.addEventListener('DOMContentLoaded', function () {
if (!progressBar) return;
var article = getArticleElement();
if (!article) {
progressBar.style.width = '0%';
return;
@@ -186,23 +217,30 @@ document.addEventListener('DOMContentLoaded', function () {
var rect = article.getBoundingClientRect();
var total = Math.max(article.offsetHeight - window.innerHeight, 1);
var progress = Math.min(Math.max((-rect.top / total) * 100, 0), 100);
var progress = Math.min(
Math.max((-rect.top / total) * 100, 0),
100
);
progressBar.style.width = progress + '%';
}
function updateActiveHeading() {
var headings = getHeadings();
if (!headings.length) return;
var currentId = headings[0].target.id;
var triggerY = headingOffset + 24;
headings.forEach(function (item) {
if (item.target.getBoundingClientRect().top <= triggerY) {
currentId = item.target.id;
for (var i = 0; i < headings.length; i++) {
var rectTop = headings[i].target.getBoundingClientRect().top;
if (rectTop <= triggerY) {
currentId = headings[i].target.id;
} else {
break;
}
});
}
setActiveLink(currentId);
}
@@ -228,7 +266,10 @@ document.addEventListener('DOMContentLoaded', function () {
event.preventDefault();
var top = target.getBoundingClientRect().top + window.scrollY - headingOffset;
var top =
target.getBoundingClientRect().top +
window.scrollY -
headingOffset;
window.scrollTo({
top: top,
@@ -240,18 +281,26 @@ document.addEventListener('DOMContentLoaded', function () {
});
if (nav) {
nav.addEventListener('wheel', function (event) {
var canScroll = nav.scrollHeight > nav.clientHeight;
if (!canScroll) return;
nav.addEventListener(
'wheel',
function (event) {
var canScroll = nav.scrollHeight > nav.clientHeight;
if (!canScroll) return;
var atTop = nav.scrollTop <= 0;
var atBottom = Math.ceil(nav.scrollTop + nav.clientHeight) >= nav.scrollHeight;
var atTop = nav.scrollTop <= 0;
var atBottom =
Math.ceil(nav.scrollTop + nav.clientHeight) >= nav.scrollHeight;
if ((event.deltaY < 0 && !atTop) || (event.deltaY > 0 && !atBottom)) {
event.preventDefault();
nav.scrollTop += event.deltaY;
}
}, { passive: false });
if (
(event.deltaY < 0 && !atTop) ||
(event.deltaY > 0 && !atBottom)
) {
event.preventDefault();
nav.scrollTop += event.deltaY;
}
},
{ passive: false }
);
}
// Initial run
@@ -266,4 +315,8 @@ document.addEventListener('DOMContentLoaded', function () {
window.addEventListener('scroll', onViewportChange, { passive: true });
window.addEventListener('resize', onViewportChange, { passive: true });
window.addEventListener('load', function () {
syncPosition();
});
});