/*
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:
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();
});