/*
|
SITE GALLERY MANAGER
|
Handles two main functions:
|
|
1. Responsive Images
|
- Automatically loads appropriate image sizes based on screen width
|
- Listens for resize events and updates images accordingly
|
- Uses data-small, data-medium, and data-full attributes on images
|
|
2. Gallery Management
|
- HTML Structure:
|
<a class="open-gallery" data-opens="gallery-id" data-focus="image-to-show">
|
<figure><img...></figure>
|
</a>
|
|
<dialog class="gallery" id="gallery-id">
|
- Main image display (.images with multiple <figure>s)
|
- Navigation bar on bottom (nav.thumbnails) with:
|
- Previous/Next buttons
|
- Thumbnail list (toggles with .open class)
|
- Expand button (toggles .expanded class)
|
- Visibility toggle (toggles .hide-info class)
|
- Close button
|
</dialog>
|
|
Key Features:
|
- Opens in modal dialog with keyboard navigation
|
- Manages thumbnail navigation state
|
- Handles responsive image loading
|
- Uses class-based state management (open, expanded, hide-info)
|
- All event handling through delegation
|
- Built on UIHandler base class for state management
|
|
State Management:
|
- Dialog: open/closed
|
- Thumbnails: collapsed/open/expanded
|
- Info panel: visible/hidden
|
- Active image: tracked with .focused class
|
*/
|
|
// Constants
|
const BREAKPOINTS = {
|
MOBILE: 500,
|
TABLET: 1024
|
};
|
|
const GALLERY_STATES = {
|
DIALOG: 'dialog',
|
THUMBNAILS: 'thumbnails',
|
OPTIONS: 'options'
|
};
|
|
// Utility functions
|
|
const getImageSize = (width, dataset) => {
|
if (width > BREAKPOINTS.TABLET) {
|
return (dataset.full) ? dataset.full : dataset.large;
|
}
|
if (width > BREAKPOINTS.MOBILE) return dataset.medium;
|
return dataset.small;
|
};
|
|
class MediaManager extends window.UIHandler {
|
constructor() {
|
super();
|
this.previousWidth = window.innerWidth;
|
|
// Bind methods that exist
|
this.handleResize = this.handleResize.bind(this);
|
this.debouncedResize = this.debounce(this.handleResize, 150);
|
|
// Initialize
|
this.bindElements();
|
this.initializeHandlers(this.defineHandlers());
|
this.bindComponentEvents();
|
this.bindEvents();
|
this.initialize();
|
|
// // Handle URL hash if it exists
|
// if (window.location.hash) {
|
// // Wait for DOM to be fully loaded
|
// if (document.readyState === 'loading') {
|
// document.addEventListener('DOMContentLoaded', () => this.handleUrlHash());
|
// } else {
|
// // DOM is already ready
|
// this.handleUrlHash();
|
// }
|
// }
|
}
|
|
handleUrlHash() {
|
const target = document.querySelector(window.location.hash);
|
if (target?.tagName === 'LI') {
|
const galleryTrigger = target.querySelector('.open-gallery');
|
if (galleryTrigger) {
|
this.openDialog(galleryTrigger);
|
}
|
}
|
}
|
|
handleResize() {
|
const currentWidth = window.innerWidth;
|
if (Math.abs(currentWidth - this.previousWidth) > 100) {
|
this.previousWidth = currentWidth;
|
this.handleImageResize();
|
}
|
}
|
|
async handleImageResize() {
|
const currentWidth = window.innerWidth;
|
const loadPromises = [];
|
|
// Handle all visible images
|
for (const img of this.elements.images) {
|
if (!img.closest('dialog')) {
|
const size = getImageSize(currentWidth, img.dataset);
|
if (size && img.src !== size) {
|
loadPromises.push(
|
new Promise((resolve) => {
|
const tempImg = new Image();
|
tempImg.onload = () => {
|
img.src = size;
|
resolve();
|
};
|
tempImg.onerror = () => {
|
console.error(`Failed to load image: ${size}`);
|
resolve();
|
};
|
tempImg.src = size;
|
})
|
);
|
}
|
}
|
}
|
|
// Handle current gallery image if gallery is open
|
const activeDialog = document.querySelector('dialog[open]');
|
if (activeDialog) {
|
const activeFigure = activeDialog.querySelector('.focused');
|
if (activeFigure) {
|
const img = activeFigure.querySelector('img');
|
if (img) {
|
const size = getImageSize(currentWidth, activeFigure.dataset);
|
if (size && img.src !== size) {
|
loadPromises.push(
|
new Promise((resolve) => {
|
const tempImg = new Image();
|
tempImg.onload = () => {
|
img.src = size;
|
resolve();
|
};
|
tempImg.onerror = () => {
|
console.error(`Failed to load image: ${size}`);
|
resolve();
|
};
|
tempImg.src = size;
|
})
|
);
|
}
|
}
|
}
|
}
|
|
// Wait for all images to load
|
await Promise.all(loadPromises);
|
}
|
// Initialization Methods
|
async initialize() {
|
try {
|
// Initialize image handling
|
if (this.elements.images.length > 0) {
|
this.initializeImageObserver();
|
window.addEventListener('resize', this.debouncedResize);
|
}
|
|
// Initialize gallery
|
this.initializeGalleryFromData();
|
} catch (error) {
|
console.error('Gallery initialization failed:', error);
|
this.handleInitializationError();
|
}
|
}
|
|
async initializeGalleryFromData() {
|
const galleryData = window.gallery?.images;
|
|
if (galleryData && galleryData !== 'false') {
|
try {
|
const parsedGallery = JSON.parse(galleryData);
|
if(parsedGallery && parsedGallery !== 'false'){
|
this.elements.mainContainer?.insertAdjacentHTML('beforeend', parsedGallery);
|
// Wait for DOM update
|
await new Promise(resolve => setTimeout(resolve, 0));
|
this.initializeGallery();
|
}
|
|
} catch (e) {
|
console.error('Failed to parse gallery data:', e);
|
throw e; // Propagate error to main initialize catch block
|
}
|
}
|
}
|
initializeGallery() {
|
// Bind gallery-specific elements and handlers after gallery is added to DOM
|
this.bindGalleryElements();
|
this.initializeHandlers(this.defineGalleryHandlers());
|
this.bindComponentEvents();
|
|
// Bind keyboard navigation to each dialog
|
this.elements.dialogs?.forEach(dialog => {
|
dialog.addEventListener('keydown', (e) => {
|
if (e.key === 'ArrowLeft') this.navigateImages(e, 'previous');
|
if (e.key === 'ArrowRight') this.navigateImages(e, 'next');
|
if (e.key === 'Escape') this.closeDialog(dialog);
|
});
|
});
|
}
|
bindElements() {
|
this.elements = {
|
images: document.querySelectorAll('img:not(.avatar)'),
|
mainContainer: document.querySelector('.wp-site-blocks'),
|
galleryTriggers: document.querySelectorAll('.open-gallery')
|
};
|
}
|
|
bindGalleryElements() {
|
// Clean up any existing handlers first
|
this.elements.dialogs?.forEach(dialog => {
|
dialog._removeKeydownListener?.();
|
dialog._removeTouchListeners?.();
|
});
|
|
// Update elements
|
Object.assign(this.elements, {
|
dialogs: document.querySelectorAll('dialog'),
|
galleryControls: document.querySelectorAll('dialog, .open-gallery, button, .thumbnails a')
|
});
|
}
|
|
// Event Handlers
|
defineHandlers() {
|
return {
|
galleryTriggers: {
|
click: (e) => {
|
e.preventDefault();
|
e.stopPropagation();
|
|
const link = e.target.closest('.open-gallery');
|
if (link) {
|
this.openDialog(link);
|
}
|
}
|
}
|
};
|
}
|
|
defineGalleryHandlers() {
|
return {
|
galleryControls: {
|
click: (e) => {
|
|
const termLink = e.target.closest('.term-list a');
|
if (termLink) {
|
// Allow term links to work normally
|
return true;
|
}
|
|
e.preventDefault();
|
e.stopPropagation();
|
|
const target = e.target.closest('button, a');
|
if (!target) return;
|
|
if (target.classList.contains('open-gallery')) {
|
this.openDialog(target);
|
} else if (target.classList.contains('close')) {
|
this.closeDialog(target.closest('dialog'));
|
} else if (target.classList.contains('visible')) {
|
this.handleVisibilityToggle(e);
|
} else if (target.classList.contains('open-thumbnails')) {
|
this.handleThumbnailControl(e, 'open');
|
} else if (target.classList.contains('expand')) {
|
this.handleThumbnailControl(e, 'expand');
|
} else if (target.classList.contains('previous')) {
|
this.navigateImages(e, 'previous');
|
} else if (target.classList.contains('next')) {
|
this.navigateImages(e, 'next');
|
} else if (target.closest('.thumbnails a')) {
|
this.handleThumbnailSelection(e);
|
}
|
}
|
}
|
};
|
}
|
|
handleVisibilityToggle(event) {
|
const button = event.target.closest('button');
|
const dialog = button.closest('dialog');
|
if (!dialog) return;
|
|
dialog.classList.toggle('hide-info');
|
const isVisible = !dialog.classList.contains('hide-info');
|
|
button.setAttribute('aria-expanded', isVisible.toString());
|
button.setAttribute('title', isVisible ? 'Hide Options' : 'Show Options');
|
button.setAttribute('aria-label', isVisible ? 'Hide Options' : 'Show Options');
|
}
|
|
// Image Handling
|
initializeImageObserver() {
|
const options = {
|
root: null,
|
rootMargin: '50px',
|
threshold: 0.1
|
};
|
|
const observer = new IntersectionObserver((entries) => {
|
entries.forEach(entry => {
|
if (entry.isIntersecting) {
|
const img = entry.target;
|
if (!img.closest('dialog')) {
|
this.loadAppropriateImage(img);
|
observer.unobserve(img);
|
}
|
}
|
});
|
}, options);
|
|
this.elements.images.forEach(img => {
|
if (!img.closest('dialog')) {
|
observer.observe(img);
|
}
|
});
|
|
return observer;
|
}
|
|
loadAppropriateImage(img) {
|
const size = getImageSize(window.innerWidth, img.dataset);
|
|
if (!size) return;
|
|
img.classList.add('loading');
|
|
const tempImg = new Image();
|
|
tempImg.onload = () => {
|
img.src = size;
|
img.classList.remove('loading');
|
img.classList.add('loaded');
|
};
|
|
tempImg.onerror = () => {
|
console.error(`Failed to load image: ${size}`);
|
img.classList.remove('loading');
|
img.classList.add('error');
|
|
if (img.dataset.small && size !== img.dataset.small) {
|
this.loadAppropriateImage(img.dataset.small);
|
}
|
};
|
|
tempImg.src = size;
|
}
|
|
// Dialog Management
|
openDialog(trigger) {
|
const dialogId = trigger.dataset.opens;
|
const dialog = document.getElementById(dialogId);
|
if (!dialog) return;
|
|
// Save last focused element and show modal
|
this.lastFocusedElement = document.activeElement;
|
dialog.showModal();
|
|
// Set up focus trap and accessibility
|
this.setupFocusTrap(dialog);
|
dialog.setAttribute('role', 'dialog');
|
dialog.setAttribute('aria-modal', 'true');
|
dialog.setAttribute('aria-labelledby', 'gallery-title');
|
|
// Setup touch handling
|
this.setupTouchHandling(dialog);
|
|
// Update component state
|
this.setComponentState(GALLERY_STATES.DIALOG, true, {
|
element: dialog,
|
toggle: trigger,
|
focusElement: dialog.querySelector('button.close'),
|
ariaHidden: false,
|
cleanup: () => this.closeDialog(dialog)
|
});
|
|
// Focus initial image if specified
|
const focusId = trigger.dataset.focus || dialog.querySelector('.images figure')?.id;
|
if (focusId) {
|
this.updateImage(dialog.querySelector(`#${focusId}`));
|
}
|
}
|
|
setupFocusTrap(dialog) {
|
const focusableElements = dialog.querySelectorAll(
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
);
|
const firstFocusable = focusableElements[0];
|
const lastFocusable = focusableElements[focusableElements.length - 1];
|
|
const handleKeydown = (e) => {
|
if (e.key === 'Tab') {
|
if (e.shiftKey && document.activeElement === firstFocusable) {
|
lastFocusable.focus();
|
e.preventDefault();
|
} else if (!e.shiftKey && document.activeElement === lastFocusable) {
|
firstFocusable.focus();
|
e.preventDefault();
|
}
|
}
|
};
|
|
dialog.addEventListener('keydown', handleKeydown);
|
dialog._removeKeydownListener = () => {
|
dialog.removeEventListener('keydown', handleKeydown);
|
};
|
}
|
setupTouchHandling(dialog) {
|
let touchStartX = 0;
|
let touchStartY = 0;
|
|
const handleTouchStart = (e) => {
|
touchStartX = e.touches[0].clientX;
|
touchStartY = e.touches[0].clientY;
|
};
|
|
const handleTouchEnd = (e) => {
|
const diffX = e.changedTouches[0].clientX - touchStartX;
|
const diffY = e.changedTouches[0].clientY - touchStartY;
|
|
if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) {
|
this.navigateImages({target: dialog}, diffX < 0 ? 'next' : 'previous');
|
}
|
};
|
|
dialog.addEventListener('touchstart', handleTouchStart);
|
dialog.addEventListener('touchend', handleTouchEnd);
|
|
// Store cleanup function
|
dialog._removeTouchListeners = () => {
|
dialog.removeEventListener('touchstart', handleTouchStart);
|
dialog.removeEventListener('touchend', handleTouchEnd);
|
};
|
}
|
|
closeDialog(dialog) {
|
if (!dialog) return;
|
|
// Clean up event listeners
|
dialog._removeKeydownListener?.();
|
dialog._removeTouchListeners?.();
|
|
dialog.close();
|
|
// Restore focus
|
if (this.lastFocusedElement) {
|
this.lastFocusedElement.focus();
|
}
|
|
// Reset gallery states
|
const thumbnailsNav = dialog.querySelector('nav.thumbnails');
|
if (thumbnailsNav) {
|
thumbnailsNav.classList.remove('open', 'expanded');
|
this.setComponentState(GALLERY_STATES.THUMBNAILS, false, {
|
element: thumbnailsNav,
|
activeClass: 'open'
|
});
|
this.setComponentState(GALLERY_STATES.THUMBNAILS + '-expand', false, {
|
element: thumbnailsNav,
|
activeClass: 'expanded'
|
});
|
}
|
}
|
|
// Focus Management
|
trapFocus(dialog) {
|
const focusableElements = dialog.querySelectorAll(
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
);
|
this.dialogFocusableElements = {
|
first: focusableElements[0],
|
last: focusableElements[focusableElements.length - 1]
|
};
|
|
dialog.addEventListener('keydown', this.handleDialogKeydown);
|
}
|
|
handleDialogKeydown(e) {
|
if (e.key === 'Tab') {
|
const { first, last } = this.dialogFocusableElements;
|
if (e.shiftKey && document.activeElement === first) {
|
last.focus();
|
e.preventDefault();
|
} else if (!e.shiftKey && document.activeElement === last) {
|
first.focus();
|
e.preventDefault();
|
}
|
}
|
}
|
|
// Touch Handlers
|
handleTouchStart(e) {
|
this.touchStartX = e.touches[0].clientX;
|
this.touchStartY = e.touches[0].clientY;
|
}
|
|
handleTouchEnd(e) {
|
const diffX = e.changedTouches[0].clientX - this.touchStartX;
|
const diffY = e.changedTouches[0].clientY - this.touchStartY;
|
|
if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) {
|
this.navigateImages(e, diffX < 0 ? 'next' : 'previous');
|
}
|
}
|
|
// Image Navigation and Updates
|
updateImage(figure) {
|
if (!figure) return;
|
|
const dialog = figure.closest('dialog');
|
dialog.querySelector('.focused')?.classList.remove('focused');
|
figure.classList.add('focused');
|
|
const img = figure.querySelector('img');
|
|
if (img) {
|
const size = getImageSize(window.innerWidth, img.dataset);
|
if (size) {
|
this.loadAppropriateImage(img);
|
}
|
}
|
|
this.updateThumbnails(figure);
|
this.preloadAdjacentImages(figure);
|
}
|
|
preloadAdjacentImages(figure) {
|
const dialog = figure.closest('dialog');
|
if (!dialog) return;
|
|
const next = figure.nextElementSibling || dialog.querySelector('.images figure:first-child');
|
const prev = figure.previousElementSibling || dialog.querySelector('.images figure:last-child');
|
|
[next, prev].forEach(adjacentFigure => {
|
if (adjacentFigure) {
|
const img = adjacentFigure.querySelector('img');
|
if (img && !img.src) {
|
this.loadAppropriateImage(img);
|
}
|
}
|
});
|
}
|
|
navigateImages(event, direction) {
|
const dialog = event.target.closest('dialog');
|
if (!dialog) return;
|
|
const current = dialog.querySelector('.images .focused');
|
if (!current) return;
|
|
const next = direction === 'next'
|
? (current.nextElementSibling || dialog.querySelector('.images figure:first-child'))
|
: (current.previousElementSibling || dialog.querySelector('.images figure:last-child'));
|
|
if (next) {
|
this.updateImage(next);
|
}
|
}
|
|
// Thumbnail Management
|
updateThumbnails(figure) {
|
const thumbnailsNav = figure.closest('dialog')?.querySelector('nav.thumbnails .thumbnails');
|
if (!thumbnailsNav) return;
|
|
thumbnailsNav.querySelector('.focused')?.classList.remove('focused');
|
|
const thumbnail = thumbnailsNav.querySelector(`#for-${figure.id}`);
|
if (thumbnail) {
|
thumbnail.classList.add('focused');
|
thumbnail.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
|
}
|
}
|
|
handleThumbnailSelection(event) {
|
event.preventDefault();
|
const link = event.target.closest('a');
|
if (!link) return;
|
|
const figure = link.querySelector('figure');
|
if (!figure) return;
|
|
const targetId = figure.id.replace('for-', '');
|
const dialog = link.closest('dialog');
|
const targetFigure = dialog.querySelector(`#${targetId}`);
|
|
if (targetFigure) {
|
this.updateImage(targetFigure);
|
}
|
}
|
handleThumbnailControl(event, type) {
|
const button = event.target.closest('button');
|
const nav = button.closest('nav.thumbnails');
|
if (!nav) return;
|
|
if (type === 'open') {
|
nav.classList.toggle('open');
|
if (!nav.classList.contains('open')) {
|
nav.classList.remove('expanded');
|
const expandButton = nav.querySelector('button.expand');
|
if (expandButton) {
|
expandButton.setAttribute('aria-expanded', 'false');
|
expandButton.setAttribute('title', 'Expand Thumbnails');
|
expandButton.setAttribute('aria-label', 'Expand Thumbnails');
|
}
|
}
|
button.setAttribute('aria-expanded', nav.classList.contains('open'));
|
button.setAttribute('title', nav.classList.contains('open') ? 'Hide Thumbnails' : 'Show Thumbnails');
|
button.setAttribute('aria-label', nav.classList.contains('open') ? 'Hide Thumbnails' : 'Show Thumbnails');
|
} else if (type === 'expand') {
|
nav.classList.toggle('expanded');
|
button.setAttribute('aria-expanded', nav.classList.contains('expanded'));
|
button.setAttribute('title', nav.classList.contains('expanded') ? 'Condense Thumbnails' : 'Expand Thumbnails');
|
button.setAttribute('aria-label', nav.classList.contains('expanded') ? 'Condense Thumbnails' : 'Expand Thumbnails');
|
}
|
}
|
|
// Utility Methods
|
debounce(func, wait) {
|
let timeout;
|
return function executedFunction(...args) {
|
const later = () => {
|
clearTimeout(timeout);
|
func(...args);
|
};
|
clearTimeout(timeout);
|
timeout = setTimeout(later, wait);
|
};
|
}
|
|
handleInitializationError() {
|
const galleries = document.querySelectorAll('.open-gallery');
|
galleries.forEach(gallery => {
|
const img = gallery.querySelector('img');
|
if (img && img.dataset.full) {
|
gallery.href = img.dataset.full;
|
}
|
});
|
}
|
|
handleOutsideClick(event) {
|
if (this.isComponentActive(GALLERY_STATES.DIALOG)) {
|
const dialog = document.querySelector('dialog[open]');
|
if (dialog && !dialog.contains(event.target)) {
|
this.closeDialog(dialog);
|
}
|
}
|
}
|
|
handleEscapeKey(event) {
|
if (event.key === 'Escape' && this.isComponentActive(GALLERY_STATES.DIALOG)) {
|
const dialog = document.querySelector('dialog[open]');
|
if (dialog) {
|
this.closeDialog(dialog);
|
}
|
}
|
}
|
|
// Cleanup
|
cleanup() {
|
super.cleanup();
|
window.removeEventListener('resize', this.debouncedResize);
|
this.cleanupAllObservers();
|
|
// Dialog cleanup handled by individual dialog close
|
this.elements.dialogs?.forEach(dialog => {
|
dialog._removeKeydownListener?.();
|
dialog._removeTouchListeners?.();
|
});
|
}
|
}
|
|
|
// Initialize on DOM load
|
document.addEventListener('DOMContentLoaded', () => {
|
window.media = new MediaManager();
|
});
|