/** * CKEditor PhotoSwipe Integration * Handles click-to-lightbox functionality for images in CKEditor content */ import mediaViewer from './media_viewer.js'; import galleryManager from './gallery_manager.js'; import appContext from '../components/app_context.js'; import type { MediaItem } from './media_viewer.js'; import type { GalleryItem } from './gallery_manager.js'; /** * Configuration for CKEditor PhotoSwipe integration */ interface CKEditorPhotoSwipeConfig { enableGalleryMode?: boolean; showHints?: boolean; hintDelay?: number; excludeSelector?: string; } /** * Integration manager for CKEditor and PhotoSwipe */ class CKEditorPhotoSwipeIntegration { private static instance: CKEditorPhotoSwipeIntegration; private config: Required; private observers: Map = new Map(); private processedImages: WeakSet = new WeakSet(); private containerGalleries: Map = new Map(); private hintPool: HTMLElement[] = []; private activeHints: Map = new Map(); private hintTimeouts: Map = new Map(); private constructor() { this.config = { enableGalleryMode: true, showHints: true, hintDelay: 2000, excludeSelector: '.no-lightbox, .cke_widget_element' }; } /** * Get singleton instance */ static getInstance(): CKEditorPhotoSwipeIntegration { if (!CKEditorPhotoSwipeIntegration.instance) { CKEditorPhotoSwipeIntegration.instance = new CKEditorPhotoSwipeIntegration(); } return CKEditorPhotoSwipeIntegration.instance; } /** * Setup integration for a CKEditor content container */ setupContainer(container: HTMLElement | JQuery, config?: Partial): void { const element = container instanceof $ ? container[0] : container; if (!element) return; // Merge configuration if (config) { this.config = { ...this.config, ...config }; } // Process existing images this.processImages(element); // Setup mutation observer for dynamically added images this.observeContainer(element); // Setup gallery if enabled if (this.config.enableGalleryMode) { this.setupGalleryMode(element); } } /** * Process all images in a container */ private processImages(container: HTMLElement): void { const images = container.querySelectorAll(`img:not(${this.config.excludeSelector})`); images.forEach(img => { if (!this.processedImages.has(img)) { this.setupImageLightbox(img); this.processedImages.add(img); } }); } /** * Setup lightbox for a single image */ private setupImageLightbox(img: HTMLImageElement): void { // Skip if already processed or is a CKEditor widget element if (img.closest('.cke_widget_element') || img.closest('.ck-widget')) { return; } // Make image clickable and mark it as PhotoSwipe-enabled img.style.cursor = 'zoom-in'; img.style.transition = 'opacity 0.2s'; img.classList.add('photoswipe-enabled'); img.setAttribute('data-photoswipe', 'true'); // Store event handlers for cleanup const mouseEnterHandler = () => { img.style.opacity = '0.9'; if (this.config.showHints) { this.showHint(img); } }; const mouseLeaveHandler = () => { img.style.opacity = '1'; this.hideHint(img); }; // Add hover effect with cleanup tracking img.addEventListener('mouseenter', mouseEnterHandler); img.addEventListener('mouseleave', mouseLeaveHandler); // Store handlers for cleanup (img as any)._photoswipeHandlers = { mouseEnterHandler, mouseLeaveHandler }; // Add double-click handler to prevent default navigation behavior const dblClickHandler = (e: MouseEvent) => { // Only prevent double-click in specific contexts to avoid breaking other features if (img.closest('.attachment-detail-wrapper') || img.closest('.note-detail-editable-text') || img.closest('.note-detail-readonly-text')) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); // Trigger the same behavior as single click (open lightbox) img.click(); } }; img.addEventListener('dblclick', dblClickHandler, true); // Use capture phase to ensure we get it first (img as any)._photoswipeHandlers.dblClickHandler = dblClickHandler; // Add click handler img.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); // Check if we should open as gallery const container = img.closest('.note-detail-editable-text, .note-detail-readonly-text'); if (container && this.config.enableGalleryMode) { const gallery = this.containerGalleries.get(container as HTMLElement); if (gallery && gallery.length > 1) { // Find index of clicked image const index = gallery.findIndex(item => { const itemElement = document.querySelector(`img[src="${item.src}"]`); return itemElement === img; }); galleryManager.openGallery(gallery, index >= 0 ? index : 0, { showThumbnails: true, showCounter: true, enableKeyboardNav: true, loop: true }); return; } } // Open single image this.openSingleImage(img); }); // Add keyboard support img.setAttribute('tabindex', '0'); img.setAttribute('role', 'button'); img.setAttribute('aria-label', 'Click to view in lightbox'); img.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); img.click(); } }); } /** * Open a single image in lightbox */ private openSingleImage(img: HTMLImageElement): void { const item: MediaItem = { src: img.src, alt: img.alt || 'Image', title: img.title || img.alt, element: img, width: img.naturalWidth || undefined, height: img.naturalHeight || undefined }; mediaViewer.openSingle(item, { bgOpacity: 0.95, showHideOpacity: true, pinchToClose: true, closeOnScroll: false, closeOnVerticalDrag: true, wheelToZoom: true, getThumbBoundsFn: () => { const rect = img.getBoundingClientRect(); return { x: rect.left, y: rect.top, w: rect.width }; } }, { onClose: () => { // Check if we're in attachment detail view and need to reset viewScope const activeContext = appContext.tabManager.getActiveContext(); if (activeContext?.viewScope?.viewMode === 'attachments') { // Get the note ID from the image source const attachmentMatch = img.src.match(/\/api\/attachments\/([A-Za-z0-9_]+)\/image\//); if (attachmentMatch) { const currentAttachmentId = activeContext.viewScope.attachmentId; if (currentAttachmentId === attachmentMatch[1]) { // Actually reset the viewScope instead of just logging try { if (activeContext.note) { activeContext.setNote(activeContext.note.noteId, { viewScope: { viewMode: 'default' } }); } } catch (error) { console.error('Failed to reset viewScope after PhotoSwipe close:', error); } } } } // Restore focus to the image img.focus(); } }); } /** * Setup gallery mode for a container */ private setupGalleryMode(container: HTMLElement): void { const images = container.querySelectorAll(`img:not(${this.config.excludeSelector})`); if (images.length <= 1) return; const galleryItems: GalleryItem[] = []; images.forEach((img, index) => { // Skip CKEditor widget elements if (img.closest('.cke_widget_element') || img.closest('.ck-widget')) { return; } const item: GalleryItem = { src: img.src, alt: img.alt || `Image ${index + 1}`, title: img.title || img.alt, element: img, index: index, width: img.naturalWidth || undefined, height: img.naturalHeight || undefined }; // Check for caption const figure = img.closest('figure'); if (figure) { const caption = figure.querySelector('figcaption'); if (caption) { item.caption = caption.textContent || undefined; } } galleryItems.push(item); }); if (galleryItems.length > 0) { this.containerGalleries.set(container, galleryItems); } } /** * Observe container for dynamic changes */ private observeContainer(container: HTMLElement): void { // Disconnect existing observer if any const existingObserver = this.observers.get(container); if (existingObserver) { existingObserver.disconnect(); } const observer = new MutationObserver((mutations) => { let hasNewImages = false; mutations.forEach(mutation => { if (mutation.type === 'childList') { mutation.addedNodes.forEach(node => { if (node.nodeType === Node.ELEMENT_NODE) { const element = node as HTMLElement; if (element.tagName === 'IMG') { hasNewImages = true; } else if (element.querySelector('img')) { hasNewImages = true; } } }); } }); if (hasNewImages) { // Process new images this.processImages(container); // Update gallery if enabled if (this.config.enableGalleryMode) { this.setupGalleryMode(container); } } }); observer.observe(container, { childList: true, subtree: true }); this.observers.set(container, observer); } /** * Get or create a hint element from the pool */ private getHintFromPool(): HTMLElement { let hint = this.hintPool.pop(); if (!hint) { hint = document.createElement('div'); hint.className = 'ckeditor-image-hint'; hint.textContent = 'Click to view in lightbox'; hint.style.cssText = ` position: absolute; background: rgba(0, 0, 0, 0.8); color: white; padding: 4px 8px; border-radius: 4px; font-size: 12px; z-index: 1000; pointer-events: none; opacity: 0; transition: opacity 0.3s; display: none; `; } return hint; } /** * Return hint to pool */ private returnHintToPool(hint: HTMLElement): void { hint.style.opacity = '0'; hint.style.display = 'none'; if (this.hintPool.length < 10) { // Keep max 10 hints in pool this.hintPool.push(hint); } else { hint.remove(); } } /** * Show hint for an image */ private showHint(img: HTMLImageElement): void { // Check if hint already exists const imgId = img.dataset.imgId || `img-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; if (!img.dataset.imgId) { img.dataset.imgId = imgId; } // Clear any existing timeout const existingTimeout = this.hintTimeouts.get(imgId); if (existingTimeout) { clearTimeout(existingTimeout); this.hintTimeouts.delete(imgId); } let hint = this.activeHints.get(imgId); if (hint) { hint.style.opacity = '1'; return; } // Get hint from pool hint = this.getHintFromPool(); this.activeHints.set(imgId, hint); // Position and show hint if (!hint.parentElement) { document.body.appendChild(hint); } const imgRect = img.getBoundingClientRect(); hint.style.display = 'block'; hint.style.left = `${imgRect.left + (imgRect.width - hint.offsetWidth) / 2}px`; hint.style.top = `${imgRect.top - hint.offsetHeight - 5}px`; // Show hint requestAnimationFrame(() => { hint.style.opacity = '1'; }); // Auto-hide after delay const timeout = window.setTimeout(() => { this.hideHint(img); }, this.config.hintDelay); this.hintTimeouts.set(imgId, timeout); } /** * Hide hint for an image */ private hideHint(img: HTMLImageElement): void { const imgId = img.dataset.imgId; if (!imgId) return; // Clear timeout const timeout = this.hintTimeouts.get(imgId); if (timeout) { clearTimeout(timeout); this.hintTimeouts.delete(imgId); } const hint = this.activeHints.get(imgId); if (hint) { hint.style.opacity = '0'; this.activeHints.delete(imgId); setTimeout(() => { this.returnHintToPool(hint); }, 300); } } /** * Cleanup integration for a container */ cleanupContainer(container: HTMLElement | JQuery): void { const element = container instanceof $ ? container[0] : container; if (!element) return; // Disconnect observer const observer = this.observers.get(element); if (observer) { observer.disconnect(); this.observers.delete(element); } // Clear gallery this.containerGalleries.delete(element); // Remove event handlers and hints const images = element.querySelectorAll('img'); images.forEach(img => { this.hideHint(img); // Remove event handlers const handlers = (img as any)._photoswipeHandlers; if (handlers) { img.removeEventListener('mouseenter', handlers.mouseEnterHandler); img.removeEventListener('mouseleave', handlers.mouseLeaveHandler); if (handlers.dblClickHandler) { img.removeEventListener('dblclick', handlers.dblClickHandler, true); } delete (img as any)._photoswipeHandlers; } // Mark as unprocessed this.processedImages.delete(img); }); } /** * Update configuration */ updateConfig(config: Partial): void { this.config = { ...this.config, ...config }; } /** * Cleanup all integrations */ cleanup(): void { // Disconnect all observers this.observers.forEach(observer => observer.disconnect()); this.observers.clear(); // Clear all galleries this.containerGalleries.clear(); // Clear all hints this.activeHints.forEach(hint => hint.remove()); this.activeHints.clear(); // Clear all timeouts this.hintTimeouts.forEach(timeout => clearTimeout(timeout)); this.hintTimeouts.clear(); // Clear hint pool this.hintPool.forEach(hint => hint.remove()); this.hintPool = []; // Clear processed images this.processedImages = new WeakSet(); } } // Export singleton instance export default CKEditorPhotoSwipeIntegration.getInstance();