Files
kgpz_web/views/transform/akteure.js
2025-09-22 21:03:27 +02:00

405 lines
12 KiB
JavaScript

// ===========================
// AKTEURE/AUTHORS SCROLLSPY WEB COMPONENT
// ===========================
export class AkteureScrollspy extends HTMLElement {
constructor() {
super();
this.scrollHandler = null;
this.scrollTimeout = null;
this.clickHandlers = [];
this.manualNavigation = false;
}
connectedCallback() {
// Small delay to ensure DOM is fully rendered after HTMX swap
setTimeout(() => {
this.initializeScrollspyAfterDelay();
}, 100);
}
initializeScrollspyAfterDelay() {
// Find sections and nav links
this.sections = document.querySelectorAll(".author-section");
this.navLinks = document.querySelectorAll(".scrollspy-link");
if (this.sections.length === 0 || this.navLinks.length === 0) {
// Retry after a bit more time if elements not found
setTimeout(() => {
this.sections = document.querySelectorAll(".author-section");
this.navLinks = document.querySelectorAll(".scrollspy-link");
if (this.sections.length > 0 && this.navLinks.length > 0) {
this.initializeScrollspy();
}
}, 500);
return;
}
this.initializeScrollspy();
}
disconnectedCallback() {
this.cleanup();
}
initializeScrollspy() {
// Set up scroll handler
this.scrollHandler = () => {
clearTimeout(this.scrollTimeout);
this.scrollTimeout = setTimeout(() => {
this.updateActiveLink();
this.updateSidebarScrollToTopButton();
}, 50);
};
window.addEventListener("scroll", this.scrollHandler);
// Add smooth scroll on link click
this.navLinks.forEach((link) => {
const clickHandler = (e) => {
e.preventDefault();
const targetId = link.getAttribute("data-target");
const target = document.getElementById(targetId);
if (target) {
// Immediately update the active link highlighting on click
this.updateActiveLinkImmediate(targetId);
// Temporarily disable automatic sidebar scrolling during manual navigation
this.manualNavigation = true;
target.scrollIntoView({
behavior: "smooth",
block: "start",
});
// Re-enable automatic scrolling after navigation completes
setTimeout(() => {
this.manualNavigation = false;
// Ensure the full marker is visible after scroll settles
this.ensureMarkerVisibility();
}, 1000);
}
};
this.clickHandlers.push({ link, handler: clickHandler });
link.addEventListener("click", clickHandler);
});
// Initial active link update
this.updateActiveLink();
// Initial scroll-to-top button update
this.updateSidebarScrollToTopButton();
}
ensureMarkerVisibility() {
const slider = document.getElementById("scrollspy-slider");
const nav = document.getElementById("scrollspy-nav");
if (!slider || !nav || slider.style.opacity === "0") {
return;
}
const navRect = nav.getBoundingClientRect();
const sliderTop = parseFloat(slider.style.top);
const sliderHeight = parseFloat(slider.style.height);
const sliderBottom = sliderTop + sliderHeight;
// Check if the marker extends beyond the visible area
const navScrollTop = nav.scrollTop;
const navVisibleBottom = navScrollTop + navRect.height;
if (sliderBottom > navVisibleBottom) {
// Marker extends below visible area - scroll down to show the bottom
const scrollTarget = sliderBottom - navRect.height + 20; // 20px padding
nav.scrollTo({
top: scrollTarget,
behavior: "smooth",
});
} else if (sliderTop < navScrollTop) {
// Marker extends above visible area - scroll up to show the top
const scrollTarget = sliderTop - 20; // 20px padding
nav.scrollTo({
top: Math.max(0, scrollTarget),
behavior: "smooth",
});
}
}
updateActiveLink() {
// Safety check: ensure DOM elements still exist
if (!this.sections || !this.navLinks) {
return;
}
const visibleSections = [];
const viewportTop = window.scrollY;
const viewportBottom = viewportTop + window.innerHeight;
// Check which sections have any part of their Werke or Beiträge visible
try {
this.sections.forEach((section) => {
if (!section || !section.getAttribute) return;
const sectionId = section.getAttribute("id");
// Find Werke and Beiträge sections within this author section
const werkeSection = section.querySelector(".akteur-werke-section");
const beitraegeSection = section.querySelector(".akteur-beitraege-section");
let isVisible = false;
// Check if any part of Werke section is visible
if (werkeSection) {
const werkeRect = werkeSection.getBoundingClientRect();
const werkeTopVisible = werkeRect.top < window.innerHeight;
const werkeBottomVisible = werkeRect.bottom > 0;
if (werkeTopVisible && werkeBottomVisible) {
isVisible = true;
}
}
// Check if any part of Beiträge section is visible
if (beitraegeSection && !isVisible) {
const beitraegeRect = beitraegeSection.getBoundingClientRect();
const beitraegeTopVisible = beitraegeRect.top < window.innerHeight;
const beitraegeBottomVisible = beitraegeRect.bottom > 0;
if (beitraegeTopVisible && beitraegeBottomVisible) {
isVisible = true;
}
}
// Fallback: if no Werke/Beiträge sections, check header visibility (for authors without content)
if (!werkeSection && !beitraegeSection) {
const headerElement = section.querySelector("div:first-child");
if (headerElement) {
const headerRect = headerElement.getBoundingClientRect();
const headerTopVisible = headerRect.top >= 0;
const headerBottomVisible = headerRect.bottom <= window.innerHeight;
if (headerTopVisible && headerBottomVisible) {
isVisible = true;
}
}
}
if (isVisible) {
visibleSections.push(sectionId);
}
});
} catch (e) {
// Handle case where sections became stale during DOM manipulation
return;
}
// Update highlighting with sliding background animation
const activeLinks = [];
const slider = document.getElementById("scrollspy-slider");
// Reset all links to default state (no background, just font weight for active)
this.navLinks.forEach((link) => {
link.classList.remove("font-medium");
const targetId = link.getAttribute("data-target");
if (visibleSections.includes(targetId)) {
link.classList.add("font-medium");
activeLinks.push(link);
}
});
// Calculate and animate the sliding background
if (activeLinks.length > 0 && slider) {
// Get the nav container for relative positioning
const nav = document.getElementById("scrollspy-nav");
const navRect = nav.getBoundingClientRect();
// Calculate the combined area of all active links
let minTop = Infinity;
let maxBottom = -Infinity;
activeLinks.forEach((link) => {
const linkRect = link.getBoundingClientRect();
const relativeTop = linkRect.top - navRect.top + nav.scrollTop;
const relativeBottom = relativeTop + linkRect.height;
minTop = Math.min(minTop, relativeTop);
maxBottom = Math.max(maxBottom, relativeBottom);
});
// Handle gaps between non-consecutive active links
let totalHeight = maxBottom - minTop;
// Position and size the slider
slider.style.top = `${minTop}px`;
slider.style.height = `${totalHeight}px`;
slider.style.opacity = "1";
// Ensure the full marker is visible
setTimeout(() => this.ensureMarkerVisibility(), 100);
} else if (slider) {
// Hide slider when no active links
slider.style.opacity = "0";
}
// Implement proportional scrolling to keep active links visible
if (activeLinks.length > 0) {
this.updateSidebarScroll(activeLinks);
}
}
updateActiveLinkImmediate(targetId) {
// Safety check: ensure DOM elements still exist
if (!this.navLinks) return;
const slider = document.getElementById("scrollspy-slider");
// Reset all links
try {
this.navLinks.forEach((link) => {
if (link && link.classList) {
link.classList.remove("font-medium");
}
});
} catch (e) {
// Handle case where navLinks became stale
return;
}
// Highlight the clicked link
const clickedLink = document.querySelector(`[data-target="${targetId}"]`);
if (clickedLink) {
clickedLink.classList.add("font-medium");
// Update slider position for the single clicked link
if (slider) {
const nav = document.getElementById("scrollspy-nav");
if (nav) {
const navRect = nav.getBoundingClientRect();
const linkRect = clickedLink.getBoundingClientRect();
const relativeTop = linkRect.top - navRect.top + nav.scrollTop;
slider.style.top = `${relativeTop}px`;
slider.style.height = `${linkRect.height}px`;
slider.style.opacity = "1";
}
}
}
}
updateSidebarScroll(activeLinks) {
// Skip automatic scrolling during manual navigation
if (this.manualNavigation) return;
const sidebar = document.getElementById("scrollspy-nav");
if (!sidebar) return;
// Get the first active link as reference
const firstActiveLink = activeLinks[0];
// Calculate the main content scroll progress
const documentHeight = Math.max(
document.body.scrollHeight,
document.body.offsetHeight,
document.documentElement.clientHeight,
document.documentElement.scrollHeight,
document.documentElement.offsetHeight,
);
const viewportHeight = window.innerHeight;
const maxScroll = documentHeight - viewportHeight;
const scrollProgress = maxScroll > 0 ? window.scrollY / maxScroll : 0;
// Calculate sidebar scroll dimensions
const sidebarHeight = sidebar.clientHeight;
const sidebarScrollHeight = sidebar.scrollHeight;
const maxSidebarScroll = sidebarScrollHeight - sidebarHeight;
if (maxSidebarScroll > 0) {
// Calculate proportional scroll position
const targetSidebarScroll = scrollProgress * maxSidebarScroll;
// Get the position of the first active link within the sidebar
const linkRect = firstActiveLink.getBoundingClientRect();
const sidebarRect = sidebar.getBoundingClientRect();
const linkOffsetInSidebar = linkRect.top - sidebarRect.top + sidebar.scrollTop;
// Calculate the desired position (center the active link in the sidebar viewport)
const sidebarCenter = sidebarHeight / 2;
const centeredPosition = linkOffsetInSidebar - sidebarCenter;
// Use a blend of proportional scrolling and centering for smooth behavior
const blendFactor = 0.7; // 70% proportional, 30% centering
const finalScrollPosition =
blendFactor * targetSidebarScroll + (1 - blendFactor) * centeredPosition;
// Clamp to valid scroll range
const clampedPosition = Math.max(0, Math.min(maxSidebarScroll, finalScrollPosition));
// Apply smooth scrolling, but only if the difference is significant
const currentScrollTop = sidebar.scrollTop;
const scrollDifference = Math.abs(clampedPosition - currentScrollTop);
if (scrollDifference > 10) {
// Only scroll if more than 10px difference
sidebar.scrollTo({
top: clampedPosition,
behavior: "smooth",
});
}
}
}
updateSidebarScrollToTopButton() {
const button = document.getElementById("sidebar-scroll-to-top");
if (!button) return;
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
const viewportHeight = window.innerHeight;
const shouldShow = scrollTop > viewportHeight * 0.5; // Show after scrolling 50% of viewport
if (shouldShow) {
button.classList.remove("opacity-0");
button.classList.add("opacity-100");
} else {
button.classList.remove("opacity-100");
button.classList.add("opacity-0");
}
}
cleanup() {
// Remove scroll listener
if (this.scrollHandler) {
window.removeEventListener("scroll", this.scrollHandler);
this.scrollHandler = null;
}
// Clear timeout
if (this.scrollTimeout) {
clearTimeout(this.scrollTimeout);
this.scrollTimeout = null;
}
// Remove click handlers
if (this.clickHandlers && this.clickHandlers.length > 0) {
this.clickHandlers.forEach(({ link, handler }) => {
if (link && handler) {
link.removeEventListener("click", handler);
}
});
}
// Reset slider
const slider = document.getElementById("scrollspy-slider");
if (slider) {
slider.style.opacity = "0";
slider.style.height = "0";
}
// Clear all references to DOM elements and state
this.sections = null;
this.navLinks = null;
this.clickHandlers = [];
this.manualNavigation = false;
}
}
// Register the web component
customElements.define("akteure-scrollspy", AkteureScrollspy);