/** * InhaltsverzeichnisScrollspy Web Component * * Manages dynamic show/hide of content in the Inhaltsverzeichnis based on: * 1. Page container visibility (>40% threshold) * 2. Single page viewer mode * * Features: * - Short mode: Hides continuation entries and multipart parts * - Full mode: Shows all content when page is prominently visible * - Self-contained with proper lifecycle management * - HTMX-safe cleanup */ export class InhaltsverzeichnisScrollspy extends HTMLElement { constructor() { super(); this.pageObserver = null; this.pageContainers = new Map(); // pageNumber -> { container, entries, state } this.singlePageViewerActive = false; this.singlePageViewerCurrentPage = null; // Track which page is currently viewed in single page mode this.boundHandleSinglePageViewer = this.handleSinglePageViewer.bind(this); this.eventListenersAttached = false; // Track if global event listeners are attached } connectedCallback() { // Use requestAnimationFrame to ensure DOM is fully settled after HTMX content swap requestAnimationFrame(() => { this.setupScrollspy(); this.setupSinglePageViewerDetection(); }); } disconnectedCallback() { this.cleanup(); } setupScrollspy() { // Find all page containers in the newspaper layout const newspaperPageContainers = document.querySelectorAll('.newspaper-page-container[data-page-container]'); if (newspaperPageContainers.length === 0) { // No page containers found initially - retry after a short delay for HTMX content setTimeout(() => { const retryContainers = document.querySelectorAll('.newspaper-page-container[data-page-container]'); if (retryContainers.length > 0) { this.initializeScrollspy(retryContainers); } }, 100); return; } this.initializeScrollspy(newspaperPageContainers); } initializeScrollspy(newspaperPageContainers) { // Clear existing state to prevent conflicts during HTMX navigation if (this.pageObserver) { this.pageObserver.disconnect(); } this.pageContainers.clear(); // Map page containers to their corresponding Inhaltsverzeichnis entries newspaperPageContainers.forEach(container => { const pageNumber = container.getAttribute('data-page-container'); const isBeilage = container.hasAttribute('data-beilage'); // Find corresponding Inhaltsverzeichnis entries const entries = this.findInhaltsEntriesForPage(pageNumber, isBeilage); if (entries.length > 0) { this.pageContainers.set(pageNumber, { container, entries, state: 'short', // Default state isBeilage }); } }); // Set up intersection observer with 50% threshold this.pageObserver = new IntersectionObserver((observerEntries) => { observerEntries.forEach(entry => { const pageNumber = entry.target.getAttribute('data-page-container'); const pageData = this.pageContainers.get(pageNumber); if (pageData) { // Use 50% visibility threshold for full mode const shouldBeFullMode = entry.isIntersecting && entry.intersectionRatio >= 0.5; const newState = shouldBeFullMode || this.singlePageViewerActive ? 'full' : 'short'; const stateChanged = pageData.state !== newState; if (stateChanged) { pageData.state = newState; this.updateEntriesState(pageData); } else if (newState === 'full' && entry.isIntersecting && entry.intersectionRatio >= 0.5) { // Page is becoming visible and should be highlighted - trigger scroll even if state didn't change this.scrollPageIntoInhaltsverzeichnis(pageData); } } }); }, { threshold: [0, 0.5, 1.0], // Watch for 50% threshold rootMargin: '0px' }); // Observe all page containers this.pageContainers.forEach(({ container }) => { this.pageObserver.observe(container); }); // Initialize all entries to short mode this.pageContainers.forEach(pageData => { this.updateEntriesState(pageData); }); } findInhaltsEntriesForPage(pageNumber, isBeilage = false) { // Look for page entries in the Inhaltsverzeichnis that match this page const selector = isBeilage ? `[data-page-container="${pageNumber}"][data-beilage="true"]` : `[data-page-container="${pageNumber}"]:not([data-beilage])`; const pageEntryContainer = this.querySelector(selector); if (!pageEntryContainer) { return []; } // Get all inhalts-entry elements within this page container return Array.from(pageEntryContainer.querySelectorAll('.inhalts-entry')); } updateEntriesState(pageData) { const { entries, state } = pageData; if (state === 'full') { // Page is expanded: show all entries and highlight page elements entries.forEach(entry => { entry.style.display = ''; }); this.highlightPageElements(pageData, true); } else { // Page is collapsed: hide continuation entries and remove highlighting entries.forEach(entry => { const isContinuation = entry.hasAttribute('data-is-continuation'); entry.style.display = isContinuation ? 'none' : ''; }); this.highlightPageElements(pageData, false); } } highlightPageElements(pageData, highlight) { const pageNumber = pageData.container.getAttribute('data-page-container'); // 1. Highlight in Inhaltsverzeichnis (page number + icon + border) const pageLink = this.querySelector(`[data-page-number="${pageNumber}"]`); const iconContainer = pageLink?.closest('.page-entry')?.querySelector('.icon-container'); const pageEntryContainer = pageLink?.closest('.page-entry'); // Keep page number and icons unchanged in Inhaltsverzeichnis if (pageEntryContainer) { if (highlight) { pageEntryContainer.classList.add('!border-l-red-500'); pageEntryContainer.classList.remove('border-slate-300'); } else { pageEntryContainer.classList.remove('!border-l-red-500'); pageEntryContainer.classList.add('border-slate-300'); } // Always scroll highlighted entry into view when it becomes active if (highlight) { this.scrollEntryIntoView(pageEntryContainer); } } // 2. Highlight in layout view (page indicator above image) const layoutPageIndicator = document.querySelector(`[data-page="${pageNumber}"].page-indicator`); if (layoutPageIndicator) { // Only highlight primary (non-greyed) icons in layout view too const layoutPrimaryIcons = layoutPageIndicator.querySelectorAll('i:not(.text-slate-400)'); if (highlight) { layoutPageIndicator.classList.add('!bg-red-50', '!text-red-600'); layoutPrimaryIcons.forEach(icon => icon.classList.add('!text-red-600')); } else { layoutPageIndicator.classList.remove('!bg-red-50', '!text-red-600'); layoutPrimaryIcons.forEach(icon => icon.classList.remove('!text-red-600')); } } } scrollEntryIntoView(pageEntryContainer) { // Find the scrollable Inhaltsverzeichnis container const scrollableContainer = document.querySelector('.overflow-y-auto'); if (!scrollableContainer || !pageEntryContainer) { return; } // Check if this is the first or last page entry to handle edge cases const allPageEntries = scrollableContainer.querySelectorAll('.page-entry'); const isFirstPage = allPageEntries.length > 0 && allPageEntries[0] === pageEntryContainer; const isLastPage = allPageEntries.length > 0 && allPageEntries[allPageEntries.length - 1] === pageEntryContainer; if (isFirstPage) { // Scroll to the very top for the first page scrollableContainer.scrollTo({ top: 0, behavior: 'smooth' }); return; } if (isLastPage) { // Scroll to the very bottom for the last page scrollableContainer.scrollTo({ top: scrollableContainer.scrollHeight, behavior: 'smooth' }); return; } // Get container and element positions for middle pages const containerRect = scrollableContainer.getBoundingClientRect(); const elementRect = pageEntryContainer.getBoundingClientRect(); // Check if element is already fully visible const isVisible = elementRect.top >= containerRect.top && elementRect.bottom <= containerRect.bottom; if (!isVisible) { // Calculate the scroll position to center the element in the container const containerScrollTop = scrollableContainer.scrollTop; const elementTop = elementRect.top - containerRect.top + containerScrollTop; const containerHeight = containerRect.height; const elementHeight = elementRect.height; // Center the element in the container const scrollTo = elementTop - (containerHeight - elementHeight) / 2; scrollableContainer.scrollTo({ top: Math.max(0, scrollTo), behavior: 'smooth' }); } } scrollPageIntoInhaltsverzeichnis(pageData) { const pageNumber = pageData.container.getAttribute('data-page-container'); const pageLink = this.querySelector(`[data-page-number="${pageNumber}"]`); const pageEntryContainer = pageLink?.closest('.page-entry'); if (pageEntryContainer) { this.scrollEntryIntoView(pageEntryContainer); } } setupSinglePageViewerDetection() { // Only attach event listeners if not already attached if (!this.eventListenersAttached) { document.addEventListener('singlepageviewer:opened', this.boundHandleSinglePageViewer); document.addEventListener('singlepageviewer:closed', this.boundHandleSinglePageViewer); document.addEventListener('singlepageviewer:pagechanged', this.boundHandleSinglePageViewer); this.eventListenersAttached = true; } // Check initial state this.checkSinglePageViewerState(); } handleSinglePageViewer(event) { const wasActive = this.singlePageViewerActive; this.singlePageViewerActive = event.type === 'singlepageviewer:opened' || (event.type === 'singlepageviewer:pagechanged' && this.singlePageViewerActive); // Track which page is currently being viewed in single page mode if ((this.singlePageViewerActive || event.type === 'singlepageviewer:pagechanged') && event.detail?.pageNumber) { this.singlePageViewerCurrentPage = event.detail.pageNumber.toString(); } else if (event.type === 'singlepageviewer:closed') { this.singlePageViewerCurrentPage = null; this.singlePageViewerActive = false; } // Re-evaluate all page states this.pageContainers.forEach(pageData => { const pageNumber = pageData.container.getAttribute('data-page-container'); let newState; if (this.singlePageViewerActive) { // In single page viewer: only expand and highlight the current page, collapse all others newState = pageNumber === this.singlePageViewerCurrentPage ? 'full' : 'short'; } else { // Normal mode: based on scroll position newState = this.isPageContainerVisible(pageData.container) ? 'full' : 'short'; } if (pageData.state !== newState) { pageData.state = newState; this.updateEntriesState(pageData); } }); } checkSinglePageViewerState() { // Check if single page viewer is currently active const viewer = document.querySelector('single-page-viewer[active]'); this.singlePageViewerActive = viewer !== null; } isPageContainerVisible(container) { const rect = container.getBoundingClientRect(); const viewportHeight = window.innerHeight; const visibleTop = Math.max(rect.top, 0); const visibleBottom = Math.min(rect.bottom, viewportHeight); const visibleHeight = Math.max(0, visibleBottom - visibleTop); const containerHeight = rect.height; return (visibleHeight / containerHeight) >= 0.5; } cleanup() { if (this.pageObserver) { this.pageObserver.disconnect(); this.pageObserver = null; } // Only remove event listeners if they were attached if (this.eventListenersAttached) { document.removeEventListener('singlepageviewer:opened', this.boundHandleSinglePageViewer); document.removeEventListener('singlepageviewer:closed', this.boundHandleSinglePageViewer); document.removeEventListener('singlepageviewer:pagechanged', this.boundHandleSinglePageViewer); this.eventListenersAttached = false; } this.pageContainers.clear(); } } // Register the custom element customElements.define('inhaltsverzeichnis-scrollspy', InhaltsverzeichnisScrollspy);