mirror of
				https://github.com/Theodor-Springmann-Stiftung/kgpz_web.git
				synced 2025-10-31 01:55:29 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			314 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			314 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| /**
 | |
|  * 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);
 | |
|     }
 | |
| 
 | |
|     connectedCallback() {
 | |
|         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) {
 | |
|             return; // No page containers found
 | |
|         }
 | |
| 
 | |
|         // 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() {
 | |
|         // Listen for single page viewer events
 | |
|         document.addEventListener('singlepageviewer:opened', this.boundHandleSinglePageViewer);
 | |
|         document.addEventListener('singlepageviewer:closed', this.boundHandleSinglePageViewer);
 | |
|         document.addEventListener('singlepageviewer:pagechanged', this.boundHandleSinglePageViewer);
 | |
| 
 | |
|         // 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;
 | |
|         }
 | |
| 
 | |
|         document.removeEventListener('singlepageviewer:opened', this.boundHandleSinglePageViewer);
 | |
|         document.removeEventListener('singlepageviewer:closed', this.boundHandleSinglePageViewer);
 | |
|         document.removeEventListener('singlepageviewer:pagechanged', this.boundHandleSinglePageViewer);
 | |
| 
 | |
|         this.pageContainers.clear();
 | |
|     }
 | |
| }
 | |
| 
 | |
| // Register the custom element
 | |
| customElements.define('inhaltsverzeichnis-scrollspy', InhaltsverzeichnisScrollspy); | 
