mirror of
				https://github.com/Theodor-Springmann-Stiftung/kgpz_web.git
				synced 2025-10-30 01:25:30 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			414 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			414 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| import { ExecuteNextSettle } from "./helpers.js";
 | |
| // ===========================
 | |
| // AKTEURE/AUTHORS SCROLLSPY WEB COMPONENT
 | |
| // ===========================
 | |
| 
 | |
| export class AkteureScrollspy extends HTMLElement {
 | |
| 	constructor() {
 | |
| 		super();
 | |
| 		this.scrollTimeout = null;
 | |
| 		this.clickHandlers = [];
 | |
| 		this.manualNavigation = false;
 | |
| 		this.handleScroll = this.handleScroll.bind(this);
 | |
| 	}
 | |
| 
 | |
| 	handleScroll() {
 | |
| 		clearTimeout(this.scrollTimeout);
 | |
| 		this.scrollTimeout = setTimeout(() => {
 | |
| 			this.updateActiveLink();
 | |
| 			this.updateSidebarScrollToTopButton();
 | |
| 		}, 50);
 | |
| 	}
 | |
| 
 | |
| 	connectedCallback() {
 | |
| 		// Use a simple timeout to ensure DOM is settled after the component is connected.
 | |
| 		// This is more reliable than the external settleQueue for popstate navigation.
 | |
| 		setTimeout(() => {
 | |
| 			this.initializeScrollspyAfterDelay();
 | |
| 		}, 50);
 | |
| 
 | |
| 		// Handle page restoration from bfcache
 | |
| 		window.addEventListener("pageshow", (event) => {
 | |
| 			if (event.persisted) {
 | |
| 				// Page was restored from bfcache, re-initialize
 | |
| 				this.cleanup();
 | |
| 				this.initializeScrollspy();
 | |
| 			}
 | |
| 		});
 | |
| 	}
 | |
| 
 | |
| 	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() {
 | |
| 		window.addEventListener("scroll", this.handleScroll);
 | |
| 
 | |
| 		// 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: "instant",
 | |
| 						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 - with small delay to ensure layout is settled
 | |
| 		setTimeout(() => {
 | |
| 			this.updateActiveLink();
 | |
| 		}, 300);
 | |
| 
 | |
| 		// 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
 | |
| 		window.removeEventListener("scroll", this.handleScroll);
 | |
| 
 | |
| 		// 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);
 | 
