mirror of
				https://github.com/zadam/trilium.git
				synced 2025-10-27 16:26:31 +01:00 
			
		
		
		
	Compare commits
	
		
			2 Commits
		
	
	
		
			feat/impro
			...
			feat/bette
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 5024e27885 | ||
|  | 78c27dbe04 | 
| @@ -55,7 +55,8 @@ | ||||
|     "split.js": "1.6.5", | ||||
|     "svg-pan-zoom": "3.6.2", | ||||
|     "tabulator-tables": "6.3.1", | ||||
|     "vanilla-js-wheel-zoom": "9.0.4" | ||||
|     "vanilla-js-wheel-zoom": "9.0.4", | ||||
|     "photoswipe": "^5.4.4" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@ckeditor/ckeditor5-inspector": "5.0.0", | ||||
|   | ||||
| @@ -13,6 +13,8 @@ import type ElectronRemote from "@electron/remote"; | ||||
| import type Electron from "electron"; | ||||
| import "./stylesheets/bootstrap.scss"; | ||||
| import "boxicons/css/boxicons.min.css"; | ||||
| import "./stylesheets/media-viewer.css"; | ||||
| import "./styles/gallery.css"; | ||||
| import "autocomplete.js/index_jquery.js"; | ||||
|  | ||||
| await appContext.earlyInit(); | ||||
|   | ||||
| @@ -2,6 +2,8 @@ import { t } from "../services/i18n.js"; | ||||
| import utils from "../services/utils.js"; | ||||
| import contextMenu from "./context_menu.js"; | ||||
| import imageService from "../services/image.js"; | ||||
| import mediaViewer from "../services/media_viewer.js"; | ||||
| import type { MediaItem } from "../services/media_viewer.js"; | ||||
|  | ||||
| const PROP_NAME = "imageContextMenuInstalled"; | ||||
|  | ||||
| @@ -18,6 +20,12 @@ function setupContextMenu($image: JQuery<HTMLElement>) { | ||||
|             x: e.pageX, | ||||
|             y: e.pageY, | ||||
|             items: [ | ||||
|                 { | ||||
|                     title: "View in Lightbox", | ||||
|                     command: "viewInLightbox", | ||||
|                     uiIcon: "bx bx-expand", | ||||
|                     enabled: true | ||||
|                 }, | ||||
|                 { | ||||
|                     title: t("image_context_menu.copy_reference_to_clipboard"), | ||||
|                     command: "copyImageReferenceToClipboard", | ||||
| @@ -30,7 +38,48 @@ function setupContextMenu($image: JQuery<HTMLElement>) { | ||||
|                 } | ||||
|             ], | ||||
|             selectMenuItemHandler: async ({ command }) => { | ||||
|                 if (command === "copyImageReferenceToClipboard") { | ||||
|                 if (command === "viewInLightbox") { | ||||
|                     const src = $image.attr("src"); | ||||
|                     const alt = $image.attr("alt"); | ||||
|                     const title = $image.attr("title"); | ||||
|                      | ||||
|                     if (!src) { | ||||
|                         console.error("Missing image source"); | ||||
|                         return; | ||||
|                     } | ||||
|                      | ||||
|                     const item: MediaItem = { | ||||
|                         src: src, | ||||
|                         alt: alt || "Image", | ||||
|                         title: title || alt, | ||||
|                         element: $image[0] as HTMLElement | ||||
|                     }; | ||||
|                      | ||||
|                     // Try to get actual dimensions | ||||
|                     const imgElement = $image[0] as HTMLImageElement; | ||||
|                     if (imgElement.naturalWidth && imgElement.naturalHeight) { | ||||
|                         item.width = imgElement.naturalWidth; | ||||
|                         item.height = imgElement.naturalHeight; | ||||
|                     } | ||||
|                      | ||||
|                     mediaViewer.openSingle(item, { | ||||
|                         bgOpacity: 0.95, | ||||
|                         showHideOpacity: true, | ||||
|                         pinchToClose: true, | ||||
|                         closeOnScroll: false, | ||||
|                         closeOnVerticalDrag: true, | ||||
|                         wheelToZoom: true, | ||||
|                         getThumbBoundsFn: () => { | ||||
|                             // Get position for zoom animation | ||||
|                             const rect = imgElement.getBoundingClientRect(); | ||||
|                             return { | ||||
|                                 x: rect.left, | ||||
|                                 y: rect.top, | ||||
|                                 w: rect.width | ||||
|                             }; | ||||
|                         } | ||||
|                     }); | ||||
|                 } else if (command === "copyImageReferenceToClipboard") { | ||||
|                     imageService.copyImageReferenceToClipboard($image); | ||||
|                 } else if (command === "copyImageToClipboard") { | ||||
|                     try { | ||||
|   | ||||
| @@ -3,6 +3,7 @@ import noteAutocompleteService from "./services/note_autocomplete.js"; | ||||
| import glob from "./services/glob.js"; | ||||
| import "./stylesheets/bootstrap.scss"; | ||||
| import "boxicons/css/boxicons.min.css"; | ||||
| import "./stylesheets/media-viewer.css"; | ||||
| import "autocomplete.js/index_jquery.js"; | ||||
|  | ||||
| glob.setupGlobs(); | ||||
|   | ||||
							
								
								
									
										521
									
								
								apps/client/src/services/ckeditor_photoswipe_integration.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										521
									
								
								apps/client/src/services/ckeditor_photoswipe_integration.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,521 @@ | ||||
| /** | ||||
|  * 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<CKEditorPhotoSwipeConfig>; | ||||
|     private observers: Map<HTMLElement, MutationObserver> = new Map(); | ||||
|     private processedImages: WeakSet<HTMLImageElement> = new WeakSet(); | ||||
|     private containerGalleries: Map<HTMLElement, GalleryItem[]> = new Map(); | ||||
|     private hintPool: HTMLElement[] = []; | ||||
|     private activeHints: Map<string, HTMLElement> = new Map(); | ||||
|     private hintTimeouts: Map<string, number> = 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<HTMLElement>, config?: Partial<CKEditorPhotoSwipeConfig>): 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<HTMLImageElement>(`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<HTMLImageElement>(`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<HTMLElement>): 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<HTMLImageElement>('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<CKEditorPhotoSwipeConfig>): 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(); | ||||
							
								
								
									
										387
									
								
								apps/client/src/services/gallery_manager.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										387
									
								
								apps/client/src/services/gallery_manager.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,387 @@ | ||||
| /** | ||||
|  * Tests for Gallery Manager | ||||
|  */ | ||||
|  | ||||
| import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; | ||||
| import galleryManager from './gallery_manager'; | ||||
| import mediaViewer from './media_viewer'; | ||||
| import type { GalleryItem, GalleryConfig } from './gallery_manager'; | ||||
| import type { MediaViewerCallbacks } from './media_viewer'; | ||||
|  | ||||
| // Mock media viewer | ||||
| vi.mock('./media_viewer', () => ({ | ||||
|     default: { | ||||
|         open: vi.fn(), | ||||
|         openSingle: vi.fn(), | ||||
|         close: vi.fn(), | ||||
|         next: vi.fn(), | ||||
|         prev: vi.fn(), | ||||
|         goTo: vi.fn(), | ||||
|         getCurrentIndex: vi.fn(() => 0), | ||||
|         isOpen: vi.fn(() => false), | ||||
|         getImageDimensions: vi.fn(() => Promise.resolve({ width: 800, height: 600 })) | ||||
|     } | ||||
| })); | ||||
|  | ||||
| // Mock froca | ||||
| vi.mock('./froca', () => ({ | ||||
|     default: { | ||||
|         getNoteComplement: vi.fn() | ||||
|     } | ||||
| })); | ||||
|  | ||||
| // Mock utils | ||||
| vi.mock('./utils', () => ({ | ||||
|     default: { | ||||
|         createImageSrcUrl: vi.fn((note: any) => `/api/images/${note.noteId}`), | ||||
|         randomString: vi.fn(() => 'test123') | ||||
|     } | ||||
| })); | ||||
|  | ||||
| describe('GalleryManager', () => { | ||||
|     let mockItems: GalleryItem[]; | ||||
|      | ||||
|     beforeEach(() => { | ||||
|         // Reset mocks | ||||
|         vi.clearAllMocks(); | ||||
|          | ||||
|         // Create mock gallery items | ||||
|         mockItems = [ | ||||
|             { | ||||
|                 src: '/api/images/note1/image1.jpg', | ||||
|                 alt: 'Image 1', | ||||
|                 title: 'First Image', | ||||
|                 noteId: 'note1', | ||||
|                 index: 0, | ||||
|                 width: 800, | ||||
|                 height: 600 | ||||
|             }, | ||||
|             { | ||||
|                 src: '/api/images/note1/image2.jpg', | ||||
|                 alt: 'Image 2', | ||||
|                 title: 'Second Image', | ||||
|                 noteId: 'note1', | ||||
|                 index: 1, | ||||
|                 width: 1024, | ||||
|                 height: 768 | ||||
|             }, | ||||
|             { | ||||
|                 src: '/api/images/note1/image3.jpg', | ||||
|                 alt: 'Image 3', | ||||
|                 title: 'Third Image', | ||||
|                 noteId: 'note1', | ||||
|                 index: 2, | ||||
|                 width: 1920, | ||||
|                 height: 1080 | ||||
|             } | ||||
|         ]; | ||||
|          | ||||
|         // Setup DOM | ||||
|         document.body.innerHTML = ''; | ||||
|     }); | ||||
|      | ||||
|     afterEach(() => { | ||||
|         // Cleanup | ||||
|         galleryManager.cleanup(); | ||||
|         document.body.innerHTML = ''; | ||||
|     }); | ||||
|      | ||||
|     describe('Gallery Creation', () => { | ||||
|         it('should create gallery from container with images', async () => { | ||||
|             // Create container with images | ||||
|             const container = document.createElement('div'); | ||||
|             container.innerHTML = ` | ||||
|                 <img src="/api/images/note1/image1.jpg" alt="Image 1" /> | ||||
|                 <img src="/api/images/note1/image2.jpg" alt="Image 2" /> | ||||
|                 <img src="/api/images/note1/image3.jpg" alt="Image 3" /> | ||||
|             `; | ||||
|             document.body.appendChild(container); | ||||
|              | ||||
|             // Create gallery from container | ||||
|             const items = await galleryManager.createGalleryFromContainer(container); | ||||
|              | ||||
|             expect(items).toHaveLength(3); | ||||
|             expect(items[0].src).toBe('/api/images/note1/image1.jpg'); | ||||
|             expect(items[0].alt).toBe('Image 1'); | ||||
|             expect(items[0].index).toBe(0); | ||||
|         }); | ||||
|          | ||||
|         it('should extract captions from figure elements', async () => { | ||||
|             const container = document.createElement('div'); | ||||
|             container.innerHTML = ` | ||||
|                 <figure> | ||||
|                     <img src="/api/images/note1/image1.jpg" alt="Image 1" /> | ||||
|                     <figcaption>This is a caption</figcaption> | ||||
|                 </figure> | ||||
|             `; | ||||
|             document.body.appendChild(container); | ||||
|              | ||||
|             const items = await galleryManager.createGalleryFromContainer(container); | ||||
|              | ||||
|             expect(items).toHaveLength(1); | ||||
|             expect(items[0].caption).toBe('This is a caption'); | ||||
|         }); | ||||
|          | ||||
|         it('should handle images without dimensions', async () => { | ||||
|             const container = document.createElement('div'); | ||||
|             container.innerHTML = `<img src="/api/images/note1/image1.jpg" alt="Image 1" />`; | ||||
|             document.body.appendChild(container); | ||||
|              | ||||
|             const items = await galleryManager.createGalleryFromContainer(container); | ||||
|              | ||||
|             expect(items).toHaveLength(1); | ||||
|             expect(items[0].width).toBe(800); // From mocked getImageDimensions | ||||
|             expect(items[0].height).toBe(600); | ||||
|             expect(mediaViewer.getImageDimensions).toHaveBeenCalledWith('/api/images/note1/image1.jpg'); | ||||
|         }); | ||||
|     }); | ||||
|      | ||||
|     describe('Gallery Opening', () => { | ||||
|         it('should open gallery with multiple items', () => { | ||||
|             const callbacks: MediaViewerCallbacks = { | ||||
|                 onOpen: vi.fn(), | ||||
|                 onClose: vi.fn(), | ||||
|                 onChange: vi.fn() | ||||
|             }; | ||||
|              | ||||
|             galleryManager.openGallery(mockItems, 0, {}, callbacks); | ||||
|              | ||||
|             expect(mediaViewer.open).toHaveBeenCalledWith( | ||||
|                 mockItems, | ||||
|                 0, | ||||
|                 expect.objectContaining({ | ||||
|                     loop: true, | ||||
|                     allowPanToNext: true, | ||||
|                     preload: [2, 2] | ||||
|                 }), | ||||
|                 expect.objectContaining({ | ||||
|                     onOpen: expect.any(Function), | ||||
|                     onClose: expect.any(Function), | ||||
|                     onChange: expect.any(Function) | ||||
|                 }) | ||||
|             ); | ||||
|         }); | ||||
|          | ||||
|         it('should handle empty items array', () => { | ||||
|             galleryManager.openGallery([], 0); | ||||
|             expect(mediaViewer.open).not.toHaveBeenCalled(); | ||||
|         }); | ||||
|          | ||||
|         it('should apply custom configuration', () => { | ||||
|             const config: GalleryConfig = { | ||||
|                 showThumbnails: false, | ||||
|                 autoPlay: true, | ||||
|                 slideInterval: 5000, | ||||
|                 loop: false | ||||
|             }; | ||||
|              | ||||
|             galleryManager.openGallery(mockItems, 0, config); | ||||
|              | ||||
|             expect(mediaViewer.open).toHaveBeenCalledWith( | ||||
|                 mockItems, | ||||
|                 0, | ||||
|                 expect.objectContaining({ | ||||
|                     loop: false | ||||
|                 }), | ||||
|                 expect.any(Object) | ||||
|             ); | ||||
|         }); | ||||
|     }); | ||||
|      | ||||
|     describe('Gallery Navigation', () => { | ||||
|         beforeEach(() => { | ||||
|             // Open a gallery first | ||||
|             galleryManager.openGallery(mockItems, 0); | ||||
|         }); | ||||
|          | ||||
|         it('should navigate to next slide', () => { | ||||
|             galleryManager.nextSlide(); | ||||
|             expect(mediaViewer.next).toHaveBeenCalled(); | ||||
|         }); | ||||
|          | ||||
|         it('should navigate to previous slide', () => { | ||||
|             galleryManager.previousSlide(); | ||||
|             expect(mediaViewer.prev).toHaveBeenCalled(); | ||||
|         }); | ||||
|          | ||||
|         it('should go to specific slide', () => { | ||||
|             galleryManager.goToSlide(2); | ||||
|             expect(mediaViewer.goTo).toHaveBeenCalledWith(2); | ||||
|         }); | ||||
|          | ||||
|         it('should not navigate to invalid slide index', () => { | ||||
|             const state = galleryManager.getGalleryState(); | ||||
|             if (state) { | ||||
|                 // Try to go to invalid index | ||||
|                 galleryManager.goToSlide(-1); | ||||
|                 expect(mediaViewer.goTo).not.toHaveBeenCalled(); | ||||
|                  | ||||
|                 galleryManager.goToSlide(10); | ||||
|                 expect(mediaViewer.goTo).not.toHaveBeenCalled(); | ||||
|             } | ||||
|         }); | ||||
|     }); | ||||
|      | ||||
|     describe('Slideshow Functionality', () => { | ||||
|         beforeEach(() => { | ||||
|             vi.useFakeTimers(); | ||||
|             galleryManager.openGallery(mockItems, 0, { autoPlay: false }); | ||||
|         }); | ||||
|          | ||||
|         afterEach(() => { | ||||
|             vi.useRealTimers(); | ||||
|         }); | ||||
|          | ||||
|         it('should start slideshow', () => { | ||||
|             const state = galleryManager.getGalleryState(); | ||||
|             expect(state?.isPlaying).toBe(false); | ||||
|              | ||||
|             galleryManager.startSlideshow(); | ||||
|              | ||||
|             const updatedState = galleryManager.getGalleryState(); | ||||
|             expect(updatedState?.isPlaying).toBe(true); | ||||
|         }); | ||||
|          | ||||
|         it('should stop slideshow', () => { | ||||
|             galleryManager.startSlideshow(); | ||||
|             galleryManager.stopSlideshow(); | ||||
|              | ||||
|             const state = galleryManager.getGalleryState(); | ||||
|             expect(state?.isPlaying).toBe(false); | ||||
|         }); | ||||
|          | ||||
|         it('should toggle slideshow', () => { | ||||
|             const initialState = galleryManager.getGalleryState(); | ||||
|             expect(initialState?.isPlaying).toBe(false); | ||||
|              | ||||
|             galleryManager.toggleSlideshow(); | ||||
|             expect(galleryManager.getGalleryState()?.isPlaying).toBe(true); | ||||
|              | ||||
|             galleryManager.toggleSlideshow(); | ||||
|             expect(galleryManager.getGalleryState()?.isPlaying).toBe(false); | ||||
|         }); | ||||
|          | ||||
|         it('should advance slides automatically in slideshow', () => { | ||||
|             galleryManager.startSlideshow(); | ||||
|              | ||||
|             // Fast-forward time | ||||
|             vi.advanceTimersByTime(4000); // Default interval | ||||
|              | ||||
|             expect(mediaViewer.goTo).toHaveBeenCalledWith(1); | ||||
|         }); | ||||
|          | ||||
|         it('should update slideshow interval', () => { | ||||
|             galleryManager.startSlideshow(); | ||||
|             galleryManager.updateSlideshowInterval(5000); | ||||
|              | ||||
|             const state = galleryManager.getGalleryState(); | ||||
|             expect(state?.config.slideInterval).toBe(5000); | ||||
|         }); | ||||
|     }); | ||||
|      | ||||
|     describe('Gallery State', () => { | ||||
|         it('should track gallery state', () => { | ||||
|             expect(galleryManager.getGalleryState()).toBeNull(); | ||||
|              | ||||
|             galleryManager.openGallery(mockItems, 1); | ||||
|              | ||||
|             const state = galleryManager.getGalleryState(); | ||||
|             expect(state).not.toBeNull(); | ||||
|             expect(state?.items).toEqual(mockItems); | ||||
|             expect(state?.currentIndex).toBe(1); | ||||
|         }); | ||||
|          | ||||
|         it('should check if gallery is open', () => { | ||||
|             expect(galleryManager.isGalleryOpen()).toBe(false); | ||||
|              | ||||
|             vi.mocked(mediaViewer.isOpen).mockReturnValue(true); | ||||
|             galleryManager.openGallery(mockItems, 0); | ||||
|              | ||||
|             expect(galleryManager.isGalleryOpen()).toBe(true); | ||||
|         }); | ||||
|     }); | ||||
|      | ||||
|     describe('Gallery Cleanup', () => { | ||||
|         it('should close gallery on cleanup', () => { | ||||
|             galleryManager.openGallery(mockItems, 0); | ||||
|             galleryManager.cleanup(); | ||||
|              | ||||
|             expect(mediaViewer.close).toHaveBeenCalled(); | ||||
|             expect(galleryManager.getGalleryState()).toBeNull(); | ||||
|         }); | ||||
|          | ||||
|         it('should stop slideshow on close', () => { | ||||
|             galleryManager.openGallery(mockItems, 0, { autoPlay: true }); | ||||
|              | ||||
|             const state = galleryManager.getGalleryState(); | ||||
|             expect(state?.isPlaying).toBe(true); | ||||
|              | ||||
|             galleryManager.closeGallery(); | ||||
|             expect(mediaViewer.close).toHaveBeenCalled(); | ||||
|         }); | ||||
|     }); | ||||
|      | ||||
|     describe('UI Enhancements', () => { | ||||
|         beforeEach(() => { | ||||
|             // Create PhotoSwipe container mock | ||||
|             const pswpElement = document.createElement('div'); | ||||
|             pswpElement.className = 'pswp'; | ||||
|             document.body.appendChild(pswpElement); | ||||
|         }); | ||||
|          | ||||
|         it('should add thumbnail strip when enabled', (done) => { | ||||
|             galleryManager.openGallery(mockItems, 0, { showThumbnails: true }); | ||||
|              | ||||
|             // Wait for UI setup | ||||
|             setTimeout(() => { | ||||
|                 const thumbnailStrip = document.querySelector('.gallery-thumbnail-strip'); | ||||
|                 expect(thumbnailStrip).toBeTruthy(); | ||||
|                  | ||||
|                 const thumbnails = document.querySelectorAll('.gallery-thumbnail'); | ||||
|                 expect(thumbnails).toHaveLength(3); | ||||
|                  | ||||
|                 done(); | ||||
|             }, 150); | ||||
|         }); | ||||
|          | ||||
|         it('should add slideshow controls', (done) => { | ||||
|             galleryManager.openGallery(mockItems, 0); | ||||
|              | ||||
|             setTimeout(() => { | ||||
|                 const controls = document.querySelector('.gallery-slideshow-controls'); | ||||
|                 expect(controls).toBeTruthy(); | ||||
|                  | ||||
|                 const playPauseBtn = document.querySelector('.slideshow-play-pause'); | ||||
|                 expect(playPauseBtn).toBeTruthy(); | ||||
|                  | ||||
|                 done(); | ||||
|             }, 150); | ||||
|         }); | ||||
|          | ||||
|         it('should add image counter when enabled', (done) => { | ||||
|             galleryManager.openGallery(mockItems, 0, { showCounter: true }); | ||||
|              | ||||
|             setTimeout(() => { | ||||
|                 const counter = document.querySelector('.gallery-counter'); | ||||
|                 expect(counter).toBeTruthy(); | ||||
|                 expect(counter?.textContent).toContain('1'); | ||||
|                 expect(counter?.textContent).toContain('3'); | ||||
|                  | ||||
|                 done(); | ||||
|             }, 150); | ||||
|         }); | ||||
|          | ||||
|         it('should add keyboard hints', (done) => { | ||||
|             galleryManager.openGallery(mockItems, 0); | ||||
|              | ||||
|             setTimeout(() => { | ||||
|                 const hints = document.querySelector('.gallery-keyboard-hints'); | ||||
|                 expect(hints).toBeTruthy(); | ||||
|                 expect(hints?.textContent).toContain('Navigate'); | ||||
|                 expect(hints?.textContent).toContain('ESC'); | ||||
|                  | ||||
|                 done(); | ||||
|             }, 150); | ||||
|         }); | ||||
|     }); | ||||
| }); | ||||
							
								
								
									
										987
									
								
								apps/client/src/services/gallery_manager.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										987
									
								
								apps/client/src/services/gallery_manager.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,987 @@ | ||||
| /** | ||||
|  * Gallery Manager for PhotoSwipe integration in Trilium Notes | ||||
|  * Handles multi-image galleries, slideshow mode, and navigation features | ||||
|  */ | ||||
|  | ||||
| import mediaViewer, { MediaItem, MediaViewerCallbacks, MediaViewerConfig } from './media_viewer.js'; | ||||
| import utils from './utils.js'; | ||||
| import froca from './froca.js'; | ||||
| import type FNote from '../entities/fnote.js'; | ||||
|  | ||||
| /** | ||||
|  * Gallery configuration options | ||||
|  */ | ||||
| export interface GalleryConfig { | ||||
|     showThumbnails?: boolean; | ||||
|     thumbnailHeight?: number; | ||||
|     autoPlay?: boolean; | ||||
|     slideInterval?: number; // in milliseconds | ||||
|     showCounter?: boolean; | ||||
|     enableKeyboardNav?: boolean; | ||||
|     enableSwipeGestures?: boolean; | ||||
|     preloadCount?: number; | ||||
|     loop?: boolean; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Gallery item with additional metadata | ||||
|  */ | ||||
| export interface GalleryItem extends MediaItem { | ||||
|     noteId?: string; | ||||
|     attachmentId?: string; | ||||
|     caption?: string; | ||||
|     description?: string; | ||||
|     index?: number; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Gallery state management | ||||
|  */ | ||||
| interface GalleryState { | ||||
|     items: GalleryItem[]; | ||||
|     currentIndex: number; | ||||
|     isPlaying: boolean; | ||||
|     slideshowTimer?: number; | ||||
|     config: Required<GalleryConfig>; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * GalleryManager handles multi-image galleries with slideshow and navigation features | ||||
|  */ | ||||
| class GalleryManager { | ||||
|     private static instance: GalleryManager; | ||||
|     private currentGallery: GalleryState | null = null; | ||||
|     private defaultConfig: Required<GalleryConfig> = { | ||||
|         showThumbnails: true, | ||||
|         thumbnailHeight: 80, | ||||
|         autoPlay: false, | ||||
|         slideInterval: 4000, | ||||
|         showCounter: true, | ||||
|         enableKeyboardNav: true, | ||||
|         enableSwipeGestures: true, | ||||
|         preloadCount: 2, | ||||
|         loop: true | ||||
|     }; | ||||
|      | ||||
|     private slideshowCallbacks: Set<() => void> = new Set(); | ||||
|     private $thumbnailStrip?: JQuery<HTMLElement>; | ||||
|     private $slideshowControls?: JQuery<HTMLElement>; | ||||
|      | ||||
|     // Track all dynamically created elements for proper cleanup | ||||
|     private createdElements: Map<string, HTMLElement | JQuery<HTMLElement>> = new Map(); | ||||
|     private setupTimeout?: number; | ||||
|  | ||||
|     private constructor() { | ||||
|         // Cleanup on window unload | ||||
|         window.addEventListener('beforeunload', () => this.cleanup()); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Get singleton instance | ||||
|      */ | ||||
|     static getInstance(): GalleryManager { | ||||
|         if (!GalleryManager.instance) { | ||||
|             GalleryManager.instance = new GalleryManager(); | ||||
|         } | ||||
|         return GalleryManager.instance; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Create gallery from images in a note's content | ||||
|      */ | ||||
|     async createGalleryFromNote(note: FNote, config?: GalleryConfig): Promise<GalleryItem[]> { | ||||
|         const items: GalleryItem[] = []; | ||||
|          | ||||
|         try { | ||||
|             // Parse note content to find images | ||||
|             const parser = new DOMParser(); | ||||
|             const content = await note.getContent(); | ||||
|             const doc = parser.parseFromString(content || '', 'text/html'); | ||||
|             const images = doc.querySelectorAll('img'); | ||||
|              | ||||
|             for (let i = 0; i < images.length; i++) { | ||||
|                 const img = images[i]; | ||||
|                 const src = img.getAttribute('src'); | ||||
|                  | ||||
|                 if (!src) continue; | ||||
|                  | ||||
|                 // Convert relative URLs to absolute | ||||
|                 const absoluteSrc = this.resolveImageSrc(src, note.noteId); | ||||
|                  | ||||
|                 const item: GalleryItem = { | ||||
|                     src: absoluteSrc, | ||||
|                     alt: img.getAttribute('alt') || `Image ${i + 1} from ${note.title}`, | ||||
|                     title: img.getAttribute('title') || img.getAttribute('alt') || undefined, | ||||
|                     caption: img.getAttribute('data-caption') || undefined, | ||||
|                     noteId: note.noteId, | ||||
|                     index: i, | ||||
|                     width: parseInt(img.getAttribute('width') || '0') || undefined, | ||||
|                     height: parseInt(img.getAttribute('height') || '0') || undefined | ||||
|                 }; | ||||
|                  | ||||
|                 // Try to get thumbnail from data attribute or create one | ||||
|                 const thumbnailSrc = img.getAttribute('data-thumbnail'); | ||||
|                 if (thumbnailSrc) { | ||||
|                     item.msrc = this.resolveImageSrc(thumbnailSrc, note.noteId); | ||||
|                 } | ||||
|                  | ||||
|                 items.push(item); | ||||
|             } | ||||
|              | ||||
|             // Also check for image attachments | ||||
|             const attachmentItems = await this.getAttachmentImages(note); | ||||
|             items.push(...attachmentItems); | ||||
|              | ||||
|         } catch (error) { | ||||
|             console.error('Failed to create gallery from note:', error); | ||||
|         } | ||||
|          | ||||
|         return items; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Get image attachments from a note | ||||
|      */ | ||||
|     private async getAttachmentImages(note: FNote): Promise<GalleryItem[]> { | ||||
|         const items: GalleryItem[] = []; | ||||
|          | ||||
|         try { | ||||
|             // Get child notes that are images | ||||
|             const childNotes = await note.getChildNotes(); | ||||
|              | ||||
|             for (const childNote of childNotes) { | ||||
|                 if (childNote.type === 'image') { | ||||
|                     const item: GalleryItem = { | ||||
|                         src: utils.createImageSrcUrl(childNote), | ||||
|                         alt: childNote.title, | ||||
|                         title: childNote.title, | ||||
|                         noteId: childNote.noteId, | ||||
|                         index: items.length | ||||
|                     }; | ||||
|                      | ||||
|                     items.push(item); | ||||
|                 } | ||||
|             } | ||||
|         } catch (error) { | ||||
|             console.error('Failed to get attachment images:', error); | ||||
|         } | ||||
|          | ||||
|         return items; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Create gallery from a container element with images | ||||
|      */ | ||||
|     async createGalleryFromContainer( | ||||
|         container: HTMLElement | JQuery<HTMLElement>,  | ||||
|         selector: string = 'img', | ||||
|         config?: GalleryConfig | ||||
|     ): Promise<GalleryItem[]> { | ||||
|         const $container = $(container); | ||||
|         const images = $container.find(selector); | ||||
|         const items: GalleryItem[] = []; | ||||
|          | ||||
|         for (let i = 0; i < images.length; i++) { | ||||
|             const img = images[i] as HTMLImageElement; | ||||
|              | ||||
|             const item: GalleryItem = { | ||||
|                 src: img.src, | ||||
|                 alt: img.alt || `Image ${i + 1}`, | ||||
|                 title: img.title || img.alt || undefined, | ||||
|                 element: img, | ||||
|                 index: i, | ||||
|                 width: img.naturalWidth || undefined, | ||||
|                 height: img.naturalHeight || undefined | ||||
|             }; | ||||
|              | ||||
|             // Try to extract caption from nearby elements | ||||
|             const $img = $(img); | ||||
|             const $figure = $img.closest('figure'); | ||||
|             if ($figure.length) { | ||||
|                 const $caption = $figure.find('figcaption'); | ||||
|                 if ($caption.length) { | ||||
|                     item.caption = $caption.text(); | ||||
|                 } | ||||
|             } | ||||
|              | ||||
|             // Check for data attributes | ||||
|             item.noteId = $img.data('note-id'); | ||||
|             item.attachmentId = $img.data('attachment-id'); | ||||
|              | ||||
|             items.push(item); | ||||
|         } | ||||
|          | ||||
|         return items; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Open gallery with specified items | ||||
|      */ | ||||
|     openGallery( | ||||
|         items: GalleryItem[],  | ||||
|         startIndex: number = 0,  | ||||
|         config?: GalleryConfig, | ||||
|         callbacks?: MediaViewerCallbacks | ||||
|     ): void { | ||||
|         if (!items || items.length === 0) { | ||||
|             console.warn('No items provided to gallery'); | ||||
|             return; | ||||
|         } | ||||
|          | ||||
|         // Close any existing gallery | ||||
|         this.closeGallery(); | ||||
|          | ||||
|         // Merge configuration | ||||
|         const finalConfig = { ...this.defaultConfig, ...config }; | ||||
|          | ||||
|         // Initialize gallery state | ||||
|         this.currentGallery = { | ||||
|             items, | ||||
|             currentIndex: startIndex, | ||||
|             isPlaying: finalConfig.autoPlay, | ||||
|             config: finalConfig | ||||
|         }; | ||||
|          | ||||
|         // Enhanced PhotoSwipe configuration for gallery | ||||
|         const photoSwipeConfig: Partial<MediaViewerConfig> = { | ||||
|             bgOpacity: 0.95, | ||||
|             showHideOpacity: true, | ||||
|             allowPanToNext: true, | ||||
|             spacing: 0.12, | ||||
|             loop: finalConfig.loop, | ||||
|             arrowKeys: finalConfig.enableKeyboardNav, | ||||
|             pinchToClose: finalConfig.enableSwipeGestures, | ||||
|             closeOnVerticalDrag: finalConfig.enableSwipeGestures, | ||||
|             preload: [finalConfig.preloadCount, finalConfig.preloadCount], | ||||
|             wheelToZoom: true, | ||||
|             // Enable mobile and accessibility enhancements | ||||
|             mobileA11y: { | ||||
|                 touch: { | ||||
|                     hapticFeedback: true, | ||||
|                     multiTouchEnabled: true | ||||
|                 }, | ||||
|                 a11y: { | ||||
|                     enableKeyboardNav: finalConfig.enableKeyboardNav, | ||||
|                     enableScreenReaderAnnouncements: true, | ||||
|                     keyboardShortcutsEnabled: true | ||||
|                 }, | ||||
|                 mobileUI: { | ||||
|                     bottomSheetEnabled: true, | ||||
|                     adaptiveToolbar: true, | ||||
|                     swipeIndicators: true, | ||||
|                     gestureHints: true | ||||
|                 }, | ||||
|                 performance: { | ||||
|                     adaptiveQuality: true, | ||||
|                     batteryOptimization: true | ||||
|                 } | ||||
|             } | ||||
|         }; | ||||
|          | ||||
|         // Enhanced callbacks | ||||
|         const enhancedCallbacks: MediaViewerCallbacks = { | ||||
|             onOpen: () => { | ||||
|                 this.onGalleryOpen(); | ||||
|                 callbacks?.onOpen?.(); | ||||
|             }, | ||||
|             onClose: () => { | ||||
|                 this.onGalleryClose(); | ||||
|                 callbacks?.onClose?.(); | ||||
|             }, | ||||
|             onChange: (index) => { | ||||
|                 this.onSlideChange(index); | ||||
|                 callbacks?.onChange?.(index); | ||||
|             }, | ||||
|             onImageLoad: callbacks?.onImageLoad, | ||||
|             onImageError: callbacks?.onImageError | ||||
|         }; | ||||
|          | ||||
|         // Open with media viewer | ||||
|         mediaViewer.open(items, startIndex, photoSwipeConfig, enhancedCallbacks); | ||||
|          | ||||
|         // Setup gallery UI enhancements | ||||
|         this.setupGalleryUI(); | ||||
|          | ||||
|         // Start slideshow if configured | ||||
|         if (finalConfig.autoPlay) { | ||||
|             this.startSlideshow(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Setup gallery UI enhancements | ||||
|      */ | ||||
|     private setupGalleryUI(): void { | ||||
|         if (!this.currentGallery) return; | ||||
|          | ||||
|         // Clear any existing timeout | ||||
|         if (this.setupTimeout) { | ||||
|             clearTimeout(this.setupTimeout); | ||||
|         } | ||||
|          | ||||
|         // Add gallery-specific UI elements to PhotoSwipe | ||||
|         this.setupTimeout = window.setTimeout(() => { | ||||
|             // Validate gallery is still open before manipulating DOM | ||||
|             if (!this.currentGallery || !this.isGalleryOpen()) { | ||||
|                 return; | ||||
|             } | ||||
|              | ||||
|             // PhotoSwipe needs a moment to initialize | ||||
|             const pswpElement = document.querySelector('.pswp'); | ||||
|             if (!pswpElement) return; | ||||
|              | ||||
|             // Add thumbnail strip if enabled | ||||
|             if (this.currentGallery.config.showThumbnails) { | ||||
|                 this.addThumbnailStrip(pswpElement); | ||||
|             } | ||||
|              | ||||
|             // Add slideshow controls | ||||
|             this.addSlideshowControls(pswpElement); | ||||
|              | ||||
|             // Add image counter if enabled | ||||
|             if (this.currentGallery.config.showCounter) { | ||||
|                 this.addImageCounter(pswpElement); | ||||
|             } | ||||
|              | ||||
|             // Add keyboard hints | ||||
|             this.addKeyboardHints(pswpElement); | ||||
|         }, 100); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Add thumbnail strip navigation | ||||
|      */ | ||||
|     private addThumbnailStrip(container: Element): void { | ||||
|         if (!this.currentGallery) return; | ||||
|          | ||||
|         // Create thumbnail strip container safely using DOM APIs | ||||
|         const stripDiv = document.createElement('div'); | ||||
|         stripDiv.className = 'gallery-thumbnail-strip'; | ||||
|         stripDiv.setAttribute('style', ` | ||||
|             position: absolute; | ||||
|             bottom: 60px; | ||||
|             left: 50%; | ||||
|             transform: translateX(-50%); | ||||
|             display: flex; | ||||
|             gap: 8px; | ||||
|             padding: 10px; | ||||
|             background: rgba(0, 0, 0, 0.7); | ||||
|             border-radius: 8px; | ||||
|             max-width: 90%; | ||||
|             overflow-x: auto; | ||||
|             z-index: 100; | ||||
|         `); | ||||
|          | ||||
|         // Create thumbnails safely | ||||
|         this.currentGallery.items.forEach((item, index) => { | ||||
|             const thumbDiv = document.createElement('div'); | ||||
|             thumbDiv.className = 'gallery-thumbnail'; | ||||
|             thumbDiv.dataset.index = index.toString(); | ||||
|             thumbDiv.setAttribute('style', ` | ||||
|                 width: ${this.currentGallery!.config.thumbnailHeight}px; | ||||
|                 height: ${this.currentGallery!.config.thumbnailHeight}px; | ||||
|                 cursor: pointer; | ||||
|                 border: 2px solid ${index === this.currentGallery!.currentIndex ? '#fff' : 'transparent'}; | ||||
|                 border-radius: 4px; | ||||
|                 overflow: hidden; | ||||
|                 flex-shrink: 0; | ||||
|                 opacity: ${index === this.currentGallery!.currentIndex ? '1' : '0.6'}; | ||||
|                 transition: all 0.2s; | ||||
|             `); | ||||
|              | ||||
|             const img = document.createElement('img'); | ||||
|             // Sanitize src URLs | ||||
|             const src = this.sanitizeUrl(item.msrc || item.src); | ||||
|             img.src = src; | ||||
|             // Use textContent for safe text insertion | ||||
|             img.alt = this.sanitizeText(item.alt || ''); | ||||
|             img.setAttribute('style', ` | ||||
|                 width: 100%; | ||||
|                 height: 100%; | ||||
|                 object-fit: cover; | ||||
|             `); | ||||
|              | ||||
|             thumbDiv.appendChild(img); | ||||
|             stripDiv.appendChild(thumbDiv); | ||||
|         }); | ||||
|          | ||||
|         this.$thumbnailStrip = $(stripDiv); | ||||
|         $(container).append(this.$thumbnailStrip); | ||||
|         this.createdElements.set('thumbnailStrip', this.$thumbnailStrip); | ||||
|          | ||||
|         // Handle thumbnail clicks | ||||
|         this.$thumbnailStrip.on('click', '.gallery-thumbnail', (e) => { | ||||
|             const index = parseInt($(e.currentTarget).data('index')); | ||||
|             this.goToSlide(index); | ||||
|         }); | ||||
|          | ||||
|         // Handle hover effect | ||||
|         this.$thumbnailStrip.on('mouseenter', '.gallery-thumbnail', (e) => { | ||||
|             if (!$(e.currentTarget).hasClass('active')) { | ||||
|                 $(e.currentTarget).css('opacity', '0.8'); | ||||
|             } | ||||
|         }); | ||||
|          | ||||
|         this.$thumbnailStrip.on('mouseleave', '.gallery-thumbnail', (e) => { | ||||
|             if (!$(e.currentTarget).hasClass('active')) { | ||||
|                 $(e.currentTarget).css('opacity', '0.6'); | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Sanitize text content to prevent XSS | ||||
|      */ | ||||
|     private sanitizeText(text: string): string { | ||||
|         // Remove any HTML tags and entities | ||||
|         const div = document.createElement('div'); | ||||
|         div.textContent = text; | ||||
|         return div.innerHTML; | ||||
|     } | ||||
|      | ||||
|     /** | ||||
|      * Sanitize URL to prevent XSS | ||||
|      */ | ||||
|     private sanitizeUrl(url: string): string { | ||||
|         // Only allow safe protocols | ||||
|         const allowedProtocols = ['http:', 'https:', 'data:']; | ||||
|         try { | ||||
|             const urlObj = new URL(url, window.location.href); | ||||
|              | ||||
|             // Special validation for data URLs | ||||
|             if (urlObj.protocol === 'data:') { | ||||
|                 // Only allow image MIME types for data URLs | ||||
|                 const allowedImageTypes = [ | ||||
|                     'data:image/jpeg', | ||||
|                     'data:image/jpg', | ||||
|                     'data:image/png', | ||||
|                     'data:image/gif', | ||||
|                     'data:image/webp', | ||||
|                     'data:image/svg+xml', | ||||
|                     'data:image/bmp' | ||||
|                 ]; | ||||
|                  | ||||
|                 // Check if data URL starts with an allowed image type | ||||
|                 const isAllowedImage = allowedImageTypes.some(type =>  | ||||
|                     url.toLowerCase().startsWith(type) | ||||
|                 ); | ||||
|                  | ||||
|                 if (!isAllowedImage) { | ||||
|                     console.warn('Rejected non-image data URL:', url.substring(0, 50)); | ||||
|                     return ''; | ||||
|                 } | ||||
|                  | ||||
|                 // Additional check for base64 encoding | ||||
|                 if (!url.includes(';base64,') && !url.includes(';charset=')) { | ||||
|                     console.warn('Rejected data URL with invalid encoding'); | ||||
|                     return ''; | ||||
|                 } | ||||
|             } else if (!allowedProtocols.includes(urlObj.protocol)) { | ||||
|                 return ''; | ||||
|             } | ||||
|              | ||||
|             return urlObj.href; | ||||
|         } catch { | ||||
|             // If URL parsing fails, check if it's a relative path | ||||
|             if (url.startsWith('/') || url.startsWith('api/')) { | ||||
|                 return url; | ||||
|             } | ||||
|             return ''; | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     /** | ||||
|      * Add slideshow controls | ||||
|      */ | ||||
|     private addSlideshowControls(container: Element): void { | ||||
|         if (!this.currentGallery) return; | ||||
|          | ||||
|         const controlsHtml = ` | ||||
|             <div class="gallery-slideshow-controls" style=" | ||||
|                 position: absolute; | ||||
|                 top: 20px; | ||||
|                 right: 20px; | ||||
|                 display: flex; | ||||
|                 gap: 10px; | ||||
|                 z-index: 100; | ||||
|             "> | ||||
|                 <button class="slideshow-play-pause" style=" | ||||
|                     background: rgba(255, 255, 255, 0.9); | ||||
|                     border: none; | ||||
|                     border-radius: 4px; | ||||
|                     width: 44px; | ||||
|                     height: 44px; | ||||
|                     cursor: pointer; | ||||
|                     display: flex; | ||||
|                     align-items: center; | ||||
|                     justify-content: center; | ||||
|                     font-size: 20px; | ||||
|                 " aria-label="${this.currentGallery.isPlaying ? 'Pause slideshow' : 'Play slideshow'}"> | ||||
|                     <i class="bx ${this.currentGallery.isPlaying ? 'bx-pause' : 'bx-play'}"></i> | ||||
|                 </button> | ||||
|                  | ||||
|                 <button class="slideshow-settings" style=" | ||||
|                     background: rgba(255, 255, 255, 0.9); | ||||
|                     border: none; | ||||
|                     border-radius: 4px; | ||||
|                     width: 44px; | ||||
|                     height: 44px; | ||||
|                     cursor: pointer; | ||||
|                     display: flex; | ||||
|                     align-items: center; | ||||
|                     justify-content: center; | ||||
|                     font-size: 20px; | ||||
|                 " aria-label="Slideshow settings"> | ||||
|                     <i class="bx bx-cog"></i> | ||||
|                 </button> | ||||
|             </div> | ||||
|              | ||||
|             <div class="slideshow-interval-selector" style=" | ||||
|                 position: absolute; | ||||
|                 top: 80px; | ||||
|                 right: 20px; | ||||
|                 background: rgba(0, 0, 0, 0.8); | ||||
|                 color: white; | ||||
|                 padding: 10px; | ||||
|                 border-radius: 4px; | ||||
|                 display: none; | ||||
|                 z-index: 101; | ||||
|             "> | ||||
|                 <label style="display: block; margin-bottom: 5px;">Slide interval:</label> | ||||
|                 <select class="interval-select" style=" | ||||
|                     background: rgba(255, 255, 255, 0.1); | ||||
|                     color: white; | ||||
|                     border: 1px solid rgba(255, 255, 255, 0.3); | ||||
|                     padding: 4px; | ||||
|                     border-radius: 3px; | ||||
|                 "> | ||||
|                     <option value="3000">3 seconds</option> | ||||
|                     <option value="4000" selected>4 seconds</option> | ||||
|                     <option value="5000">5 seconds</option> | ||||
|                     <option value="7000">7 seconds</option> | ||||
|                     <option value="10000">10 seconds</option> | ||||
|                 </select> | ||||
|             </div> | ||||
|         `; | ||||
|          | ||||
|         this.$slideshowControls = $(controlsHtml); | ||||
|         $(container).append(this.$slideshowControls); | ||||
|         this.createdElements.set('slideshowControls', this.$slideshowControls); | ||||
|          | ||||
|         // Handle play/pause button | ||||
|         this.$slideshowControls.find('.slideshow-play-pause').on('click', () => { | ||||
|             this.toggleSlideshow(); | ||||
|         }); | ||||
|          | ||||
|         // Handle settings button | ||||
|         this.$slideshowControls.find('.slideshow-settings').on('click', () => { | ||||
|             const $selector = this.$slideshowControls?.find('.slideshow-interval-selector'); | ||||
|             $selector?.toggle(); | ||||
|         }); | ||||
|          | ||||
|         // Handle interval change | ||||
|         this.$slideshowControls.find('.interval-select').on('change', (e) => { | ||||
|             const interval = parseInt($(e.target).val() as string); | ||||
|             this.updateSlideshowInterval(interval); | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Add image counter | ||||
|      */ | ||||
|     private addImageCounter(container: Element): void { | ||||
|         if (!this.currentGallery) return; | ||||
|          | ||||
|         // Create counter element safely | ||||
|         const counterDiv = document.createElement('div'); | ||||
|         counterDiv.className = 'gallery-counter'; | ||||
|         counterDiv.setAttribute('style', ` | ||||
|             position: absolute; | ||||
|             top: 20px; | ||||
|             left: 20px; | ||||
|             background: rgba(0, 0, 0, 0.7); | ||||
|             color: white; | ||||
|             padding: 8px 12px; | ||||
|             border-radius: 4px; | ||||
|             font-size: 14px; | ||||
|             z-index: 100; | ||||
|         `); | ||||
|          | ||||
|         const currentSpan = document.createElement('span'); | ||||
|         currentSpan.className = 'current-index'; | ||||
|         currentSpan.textContent = String(this.currentGallery.currentIndex + 1); | ||||
|          | ||||
|         const separatorSpan = document.createElement('span'); | ||||
|         separatorSpan.textContent = ' / '; | ||||
|          | ||||
|         const totalSpan = document.createElement('span'); | ||||
|         totalSpan.className = 'total-count'; | ||||
|         totalSpan.textContent = String(this.currentGallery.items.length); | ||||
|          | ||||
|         counterDiv.appendChild(currentSpan); | ||||
|         counterDiv.appendChild(separatorSpan); | ||||
|         counterDiv.appendChild(totalSpan); | ||||
|          | ||||
|         container.appendChild(counterDiv); | ||||
|         this.createdElements.set('counter', counterDiv); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Add keyboard hints overlay | ||||
|      */ | ||||
|     private addKeyboardHints(container: Element): void { | ||||
|         // Create hints element safely | ||||
|         const hintsDiv = document.createElement('div'); | ||||
|         hintsDiv.className = 'gallery-keyboard-hints'; | ||||
|         hintsDiv.setAttribute('style', ` | ||||
|             position: absolute; | ||||
|             bottom: 20px; | ||||
|             left: 20px; | ||||
|             background: rgba(0, 0, 0, 0.7); | ||||
|             color: white; | ||||
|             padding: 8px 12px; | ||||
|             border-radius: 4px; | ||||
|             font-size: 12px; | ||||
|             opacity: 0; | ||||
|             transition: opacity 0.3s; | ||||
|             z-index: 100; | ||||
|         `); | ||||
|          | ||||
|         // Create hint items | ||||
|         const hints = [ | ||||
|             { key: '←/→', action: 'Navigate' }, | ||||
|             { key: 'Space', action: 'Play/Pause' }, | ||||
|             { key: 'ESC', action: 'Close' } | ||||
|         ]; | ||||
|          | ||||
|         hints.forEach(hint => { | ||||
|             const hintItem = document.createElement('div'); | ||||
|             const kbd = document.createElement('kbd'); | ||||
|             kbd.style.cssText = 'background: rgba(255,255,255,0.2); padding: 2px 4px; border-radius: 2px;'; | ||||
|             kbd.textContent = hint.key; | ||||
|             hintItem.appendChild(kbd); | ||||
|             hintItem.appendChild(document.createTextNode(' ' + hint.action)); | ||||
|             hintsDiv.appendChild(hintItem); | ||||
|         }); | ||||
|          | ||||
|         container.appendChild(hintsDiv); | ||||
|         this.createdElements.set('keyboardHints', hintsDiv); | ||||
|          | ||||
|         const $hints = $(hintsDiv); | ||||
|          | ||||
|         // Show hints on hover with scoped selector | ||||
|         const handleMouseEnter = () => { | ||||
|             if (this.currentGallery) { | ||||
|                 $hints.css('opacity', '0.6'); | ||||
|             } | ||||
|         }; | ||||
|          | ||||
|         const handleMouseLeave = () => { | ||||
|             $hints.css('opacity', '0'); | ||||
|         }; | ||||
|          | ||||
|         $(container).on('mouseenter.galleryHints', handleMouseEnter); | ||||
|         $(container).on('mouseleave.galleryHints', handleMouseLeave); | ||||
|          | ||||
|         // Track cleanup callback | ||||
|         this.slideshowCallbacks.add(() => { | ||||
|             $(container).off('.galleryHints'); | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Handle gallery open event | ||||
|      */ | ||||
|     private onGalleryOpen(): void { | ||||
|         // Add keyboard listener for slideshow control | ||||
|         const handleKeyDown = (e: KeyboardEvent) => { | ||||
|             if (e.key === ' ') { | ||||
|                 e.preventDefault(); | ||||
|                 this.toggleSlideshow(); | ||||
|             } | ||||
|         }; | ||||
|          | ||||
|         document.addEventListener('keydown', handleKeyDown); | ||||
|         this.slideshowCallbacks.add(() => { | ||||
|             document.removeEventListener('keydown', handleKeyDown); | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Handle gallery close event | ||||
|      */ | ||||
|     private onGalleryClose(): void { | ||||
|         this.stopSlideshow(); | ||||
|          | ||||
|         // Clear setup timeout if exists | ||||
|         if (this.setupTimeout) { | ||||
|             clearTimeout(this.setupTimeout); | ||||
|             this.setupTimeout = undefined; | ||||
|         } | ||||
|          | ||||
|         // Cleanup event listeners | ||||
|         this.slideshowCallbacks.forEach(callback => callback()); | ||||
|         this.slideshowCallbacks.clear(); | ||||
|          | ||||
|         // Remove all tracked UI elements | ||||
|         this.createdElements.forEach((element, key) => { | ||||
|             if (element instanceof HTMLElement) { | ||||
|                 element.remove(); | ||||
|             } else if (element instanceof $) { | ||||
|                 element.remove(); | ||||
|             } | ||||
|         }); | ||||
|         this.createdElements.clear(); | ||||
|          | ||||
|         // Clear jQuery references | ||||
|         this.$thumbnailStrip = undefined; | ||||
|         this.$slideshowControls = undefined; | ||||
|          | ||||
|         // Clear state | ||||
|         this.currentGallery = null; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Handle slide change event | ||||
|      */ | ||||
|     private onSlideChange(index: number): void { | ||||
|         if (!this.currentGallery) return; | ||||
|          | ||||
|         this.currentGallery.currentIndex = index; | ||||
|          | ||||
|         // Update thumbnail highlighting | ||||
|         if (this.$thumbnailStrip) { | ||||
|             this.$thumbnailStrip.find('.gallery-thumbnail').each((i, el) => { | ||||
|                 const $thumb = $(el); | ||||
|                 if (i === index) { | ||||
|                     $thumb.css({ | ||||
|                         'border-color': '#fff', | ||||
|                         'opacity': '1' | ||||
|                     }); | ||||
|                      | ||||
|                     // Scroll thumbnail into view | ||||
|                     const thumbLeft = $thumb.position().left; | ||||
|                     const thumbWidth = $thumb.outerWidth() || 0; | ||||
|                     const stripWidth = this.$thumbnailStrip!.width() || 0; | ||||
|                     const scrollLeft = this.$thumbnailStrip!.scrollLeft() || 0; | ||||
|                      | ||||
|                     if (thumbLeft < 0) { | ||||
|                         this.$thumbnailStrip!.scrollLeft(scrollLeft + thumbLeft - 10); | ||||
|                     } else if (thumbLeft + thumbWidth > stripWidth) { | ||||
|                         this.$thumbnailStrip!.scrollLeft(scrollLeft + (thumbLeft + thumbWidth - stripWidth) + 10); | ||||
|                     } | ||||
|                 } else { | ||||
|                     $thumb.css({ | ||||
|                         'border-color': 'transparent', | ||||
|                         'opacity': '0.6' | ||||
|                     }); | ||||
|                 } | ||||
|             }); | ||||
|         } | ||||
|          | ||||
|         // Update counter using tracked element | ||||
|         const counterElement = this.createdElements.get('counter'); | ||||
|         if (counterElement instanceof HTMLElement) { | ||||
|             const currentIndexElement = counterElement.querySelector('.current-index'); | ||||
|             if (currentIndexElement) { | ||||
|                 currentIndexElement.textContent = String(index + 1); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Start slideshow | ||||
|      */ | ||||
|     startSlideshow(): void { | ||||
|         if (!this.currentGallery || this.currentGallery.isPlaying) return; | ||||
|          | ||||
|         // Validate gallery state before starting slideshow | ||||
|         if (!this.isGalleryOpen() || this.currentGallery.items.length === 0) { | ||||
|             console.warn('Cannot start slideshow: gallery not ready'); | ||||
|             return; | ||||
|         } | ||||
|          | ||||
|         // Ensure PhotoSwipe is ready | ||||
|         if (!mediaViewer.isOpen()) { | ||||
|             console.warn('Cannot start slideshow: PhotoSwipe not ready'); | ||||
|             return; | ||||
|         } | ||||
|          | ||||
|         this.currentGallery.isPlaying = true; | ||||
|          | ||||
|         // Update button icon | ||||
|         this.$slideshowControls?.find('.slideshow-play-pause i') | ||||
|             .removeClass('bx-play') | ||||
|             .addClass('bx-pause'); | ||||
|          | ||||
|         // Start timer | ||||
|         this.scheduleNextSlide(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Stop slideshow | ||||
|      */ | ||||
|     stopSlideshow(): void { | ||||
|         if (!this.currentGallery) return; | ||||
|          | ||||
|         this.currentGallery.isPlaying = false; | ||||
|          | ||||
|         // Clear timer | ||||
|         if (this.currentGallery.slideshowTimer) { | ||||
|             clearTimeout(this.currentGallery.slideshowTimer); | ||||
|             this.currentGallery.slideshowTimer = undefined; | ||||
|         } | ||||
|          | ||||
|         // Update button icon | ||||
|         this.$slideshowControls?.find('.slideshow-play-pause i') | ||||
|             .removeClass('bx-pause') | ||||
|             .addClass('bx-play'); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Toggle slideshow play/pause | ||||
|      */ | ||||
|     toggleSlideshow(): void { | ||||
|         if (!this.currentGallery) return; | ||||
|          | ||||
|         if (this.currentGallery.isPlaying) { | ||||
|             this.stopSlideshow(); | ||||
|         } else { | ||||
|             this.startSlideshow(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Schedule next slide in slideshow | ||||
|      */ | ||||
|     private scheduleNextSlide(): void { | ||||
|         if (!this.currentGallery || !this.currentGallery.isPlaying) return; | ||||
|          | ||||
|         // Clear any existing timer | ||||
|         if (this.currentGallery.slideshowTimer) { | ||||
|             clearTimeout(this.currentGallery.slideshowTimer); | ||||
|         } | ||||
|          | ||||
|         this.currentGallery.slideshowTimer = window.setTimeout(() => { | ||||
|             if (!this.currentGallery || !this.currentGallery.isPlaying) return; | ||||
|              | ||||
|             // Go to next slide | ||||
|             const nextIndex = (this.currentGallery.currentIndex + 1) % this.currentGallery.items.length; | ||||
|             this.goToSlide(nextIndex); | ||||
|              | ||||
|             // Schedule next transition | ||||
|             this.scheduleNextSlide(); | ||||
|         }, this.currentGallery.config.slideInterval); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Update slideshow interval | ||||
|      */ | ||||
|     updateSlideshowInterval(interval: number): void { | ||||
|         if (!this.currentGallery) return; | ||||
|          | ||||
|         this.currentGallery.config.slideInterval = interval; | ||||
|          | ||||
|         // Restart slideshow with new interval if playing | ||||
|         if (this.currentGallery.isPlaying) { | ||||
|             this.stopSlideshow(); | ||||
|             this.startSlideshow(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Go to specific slide | ||||
|      */ | ||||
|     goToSlide(index: number): void { | ||||
|         if (!this.currentGallery) return; | ||||
|          | ||||
|         if (index >= 0 && index < this.currentGallery.items.length) { | ||||
|             mediaViewer.goTo(index); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Navigate to next slide | ||||
|      */ | ||||
|     nextSlide(): void { | ||||
|         mediaViewer.next(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Navigate to previous slide | ||||
|      */ | ||||
|     previousSlide(): void { | ||||
|         mediaViewer.prev(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Close gallery | ||||
|      */ | ||||
|     closeGallery(): void { | ||||
|         mediaViewer.close(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Check if gallery is open | ||||
|      */ | ||||
|     isGalleryOpen(): boolean { | ||||
|         return this.currentGallery !== null && mediaViewer.isOpen(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Get current gallery state | ||||
|      */ | ||||
|     getGalleryState(): GalleryState | null { | ||||
|         return this.currentGallery; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Resolve image source URL | ||||
|      */ | ||||
|     private resolveImageSrc(src: string, noteId: string): string { | ||||
|         // Handle different image source formats | ||||
|         if (src.startsWith('http://') || src.startsWith('https://')) { | ||||
|             return src; | ||||
|         } | ||||
|          | ||||
|         if (src.startsWith('api/images/')) { | ||||
|             return `/${src}`; | ||||
|         } | ||||
|          | ||||
|         if (src.startsWith('/')) { | ||||
|             return src; | ||||
|         } | ||||
|          | ||||
|         // Assume it's a note ID or attachment reference | ||||
|         return `/api/images/${noteId}/${src}`; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Cleanup resources | ||||
|      */ | ||||
|     cleanup(): void { | ||||
|         // Clear any pending timeouts | ||||
|         if (this.setupTimeout) { | ||||
|             clearTimeout(this.setupTimeout); | ||||
|             this.setupTimeout = undefined; | ||||
|         } | ||||
|          | ||||
|         this.closeGallery(); | ||||
|          | ||||
|         // Ensure all elements are removed | ||||
|         this.createdElements.forEach((element) => { | ||||
|             if (element instanceof HTMLElement) { | ||||
|                 element.remove(); | ||||
|             } else if (element instanceof $) { | ||||
|                 element.remove(); | ||||
|             } | ||||
|         }); | ||||
|         this.createdElements.clear(); | ||||
|          | ||||
|         this.slideshowCallbacks.clear(); | ||||
|         this.currentGallery = null; | ||||
|     } | ||||
| } | ||||
|  | ||||
| // Export singleton instance | ||||
| export default GalleryManager.getInstance(); | ||||
							
								
								
									
										597
									
								
								apps/client/src/services/image_annotations.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										597
									
								
								apps/client/src/services/image_annotations.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,597 @@ | ||||
| /** | ||||
|  * Image Annotations Module for PhotoSwipe | ||||
|  * Provides ability to add, display, and manage annotations on images | ||||
|  */ | ||||
|  | ||||
| import froca from './froca.js'; | ||||
| import server from './server.js'; | ||||
| import type FNote from '../entities/fnote.js'; | ||||
| import type FAttribute from '../entities/fattribute.js'; | ||||
| import { ImageValidator, withErrorBoundary, ImageError, ImageErrorType } from './image_error_handler.js'; | ||||
|  | ||||
| /** | ||||
|  * Annotation position and data | ||||
|  */ | ||||
| export interface ImageAnnotation { | ||||
|     id: string; | ||||
|     noteId: string; | ||||
|     x: number; // Percentage from left (0-100) | ||||
|     y: number; // Percentage from top (0-100) | ||||
|     text: string; | ||||
|     author?: string; | ||||
|     created: Date; | ||||
|     modified?: Date; | ||||
|     color?: string; | ||||
|     icon?: string; | ||||
|     type?: 'comment' | 'marker' | 'region'; | ||||
|     width?: number; // For region type | ||||
|     height?: number; // For region type | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Annotation configuration | ||||
|  */ | ||||
| export interface AnnotationConfig { | ||||
|     enableAnnotations: boolean; | ||||
|     showByDefault: boolean; | ||||
|     allowEditing: boolean; | ||||
|     defaultColor: string; | ||||
|     defaultIcon: string; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * ImageAnnotationsService manages image annotations using Trilium's attribute system | ||||
|  */ | ||||
| class ImageAnnotationsService { | ||||
|     private static instance: ImageAnnotationsService; | ||||
|     private activeAnnotations: Map<string, ImageAnnotation[]> = new Map(); | ||||
|     private annotationElements: Map<string, HTMLElement> = new Map(); | ||||
|     private isEditMode: boolean = false; | ||||
|     private selectedAnnotation: ImageAnnotation | null = null; | ||||
|      | ||||
|     private config: AnnotationConfig = { | ||||
|         enableAnnotations: true, | ||||
|         showByDefault: true, | ||||
|         allowEditing: true, | ||||
|         defaultColor: '#ffeb3b', | ||||
|         defaultIcon: 'bx-comment' | ||||
|     }; | ||||
|      | ||||
|     // Annotation attribute prefix in Trilium | ||||
|     private readonly ANNOTATION_PREFIX = 'imageAnnotation'; | ||||
|  | ||||
|     private constructor() {} | ||||
|  | ||||
|     static getInstance(): ImageAnnotationsService { | ||||
|         if (!ImageAnnotationsService.instance) { | ||||
|             ImageAnnotationsService.instance = new ImageAnnotationsService(); | ||||
|         } | ||||
|         return ImageAnnotationsService.instance; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Load annotations for an image note | ||||
|      */ | ||||
|     async loadAnnotations(noteId: string): Promise<ImageAnnotation[]> { | ||||
|         return await withErrorBoundary(async () => { | ||||
|             // Validate note ID | ||||
|             if (!noteId || typeof noteId !== 'string') { | ||||
|                 throw new ImageError( | ||||
|                     ImageErrorType.INVALID_INPUT, | ||||
|                     'Invalid note ID provided' | ||||
|                 ); | ||||
|             } | ||||
|             const note = await froca.getNote(noteId); | ||||
|             if (!note) return []; | ||||
|  | ||||
|             const attributes = note.getAttributes(); | ||||
|             const annotations: ImageAnnotation[] = []; | ||||
|  | ||||
|             // Parse annotation attributes | ||||
|             for (const attr of attributes) { | ||||
|                 if (attr.name.startsWith(this.ANNOTATION_PREFIX)) { | ||||
|                     try { | ||||
|                         const annotationData = JSON.parse(attr.value); | ||||
|                         annotations.push({ | ||||
|                             ...annotationData, | ||||
|                             id: attr.attributeId, | ||||
|                             noteId: noteId, | ||||
|                             created: new Date(annotationData.created), | ||||
|                             modified: annotationData.modified ? new Date(annotationData.modified) : undefined | ||||
|                         }); | ||||
|                     } catch (error) { | ||||
|                         console.error('Failed to parse annotation:', error); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             // Sort by creation date | ||||
|             annotations.sort((a, b) => a.created.getTime() - b.created.getTime()); | ||||
|              | ||||
|             this.activeAnnotations.set(noteId, annotations); | ||||
|             return annotations; | ||||
|         }) || []; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Save a new annotation | ||||
|      */ | ||||
|     async saveAnnotation(annotation: Omit<ImageAnnotation, 'id' | 'created'>): Promise<ImageAnnotation> { | ||||
|         return await withErrorBoundary(async () => { | ||||
|             // Validate annotation data | ||||
|             if (!annotation.text || !annotation.noteId) { | ||||
|                 throw new ImageError( | ||||
|                     ImageErrorType.INVALID_INPUT, | ||||
|                     'Invalid annotation data' | ||||
|                 ); | ||||
|             } | ||||
|              | ||||
|             // Sanitize text | ||||
|             annotation.text = this.sanitizeText(annotation.text); | ||||
|             const note = await froca.getNote(annotation.noteId); | ||||
|             if (!note) { | ||||
|                 throw new Error('Note not found'); | ||||
|             } | ||||
|  | ||||
|             const newAnnotation: ImageAnnotation = { | ||||
|                 ...annotation, | ||||
|                 id: this.generateId(), | ||||
|                 created: new Date() | ||||
|             }; | ||||
|  | ||||
|             // Save as note attribute | ||||
|             const attributeName = `${this.ANNOTATION_PREFIX}_${newAnnotation.id}`; | ||||
|             const attributeValue = JSON.stringify({ | ||||
|                 x: newAnnotation.x, | ||||
|                 y: newAnnotation.y, | ||||
|                 text: newAnnotation.text, | ||||
|                 author: newAnnotation.author, | ||||
|                 created: newAnnotation.created.toISOString(), | ||||
|                 color: newAnnotation.color, | ||||
|                 icon: newAnnotation.icon, | ||||
|                 type: newAnnotation.type, | ||||
|                 width: newAnnotation.width, | ||||
|                 height: newAnnotation.height | ||||
|             }); | ||||
|  | ||||
|             await server.put(`notes/${annotation.noteId}/attributes`, { | ||||
|                 attributes: [{ | ||||
|                     type: 'label', | ||||
|                     name: attributeName, | ||||
|                     value: attributeValue | ||||
|                 }] | ||||
|             }); | ||||
|  | ||||
|             // Update cache | ||||
|             const annotations = this.activeAnnotations.get(annotation.noteId) || []; | ||||
|             annotations.push(newAnnotation); | ||||
|             this.activeAnnotations.set(annotation.noteId, annotations); | ||||
|  | ||||
|             return newAnnotation; | ||||
|         }) as Promise<ImageAnnotation>; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Update an existing annotation | ||||
|      */ | ||||
|     async updateAnnotation(annotation: ImageAnnotation): Promise<void> { | ||||
|         try { | ||||
|             const note = await froca.getNote(annotation.noteId); | ||||
|             if (!note) { | ||||
|                 throw new Error('Note not found'); | ||||
|             } | ||||
|  | ||||
|             annotation.modified = new Date(); | ||||
|  | ||||
|             // Update attribute | ||||
|             const attributeName = `${this.ANNOTATION_PREFIX}_${annotation.id}`; | ||||
|             const attributeValue = JSON.stringify({ | ||||
|                 x: annotation.x, | ||||
|                 y: annotation.y, | ||||
|                 text: annotation.text, | ||||
|                 author: annotation.author, | ||||
|                 created: annotation.created.toISOString(), | ||||
|                 modified: annotation.modified.toISOString(), | ||||
|                 color: annotation.color, | ||||
|                 icon: annotation.icon, | ||||
|                 type: annotation.type, | ||||
|                 width: annotation.width, | ||||
|                 height: annotation.height | ||||
|             }); | ||||
|  | ||||
|             // Find and update the attribute | ||||
|             const attributes = note.getAttributes(); | ||||
|             const attr = attributes.find(a => a.name === attributeName); | ||||
|              | ||||
|             if (attr) { | ||||
|                 await server.put(`notes/${annotation.noteId}/attributes/${attr.attributeId}`, { | ||||
|                     value: attributeValue | ||||
|                 }); | ||||
|             } | ||||
|  | ||||
|             // Update cache | ||||
|             const annotations = this.activeAnnotations.get(annotation.noteId) || []; | ||||
|             const index = annotations.findIndex(a => a.id === annotation.id); | ||||
|             if (index !== -1) { | ||||
|                 annotations[index] = annotation; | ||||
|                 this.activeAnnotations.set(annotation.noteId, annotations); | ||||
|             } | ||||
|         } catch (error) { | ||||
|             console.error('Failed to update annotation:', error); | ||||
|             throw error; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Delete an annotation | ||||
|      */ | ||||
|     async deleteAnnotation(noteId: string, annotationId: string): Promise<void> { | ||||
|         try { | ||||
|             const note = await froca.getNote(noteId); | ||||
|             if (!note) return; | ||||
|  | ||||
|             const attributeName = `${this.ANNOTATION_PREFIX}_${annotationId}`; | ||||
|             const attributes = note.getAttributes(); | ||||
|             const attr = attributes.find(a => a.name === attributeName); | ||||
|              | ||||
|             if (attr) { | ||||
|                 await server.remove(`notes/${noteId}/attributes/${attr.attributeId}`); | ||||
|             } | ||||
|  | ||||
|             // Update cache | ||||
|             const annotations = this.activeAnnotations.get(noteId) || []; | ||||
|             const filtered = annotations.filter(a => a.id !== annotationId); | ||||
|             this.activeAnnotations.set(noteId, filtered); | ||||
|  | ||||
|             // Remove element if exists | ||||
|             const element = this.annotationElements.get(annotationId); | ||||
|             if (element) { | ||||
|                 element.remove(); | ||||
|                 this.annotationElements.delete(annotationId); | ||||
|             } | ||||
|         } catch (error) { | ||||
|             console.error('Failed to delete annotation:', error); | ||||
|             throw error; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Render annotations on an image container | ||||
|      */ | ||||
|     renderAnnotations(container: HTMLElement, noteId: string, imageElement: HTMLImageElement): void { | ||||
|         const annotations = this.activeAnnotations.get(noteId) || []; | ||||
|          | ||||
|         // Clear existing annotation elements | ||||
|         this.clearAnnotationElements(); | ||||
|  | ||||
|         // Create annotation overlay container | ||||
|         const overlay = this.createOverlayContainer(container, imageElement); | ||||
|  | ||||
|         // Render each annotation | ||||
|         annotations.forEach(annotation => { | ||||
|             const element = this.createAnnotationElement(annotation, overlay); | ||||
|             this.annotationElements.set(annotation.id, element); | ||||
|         }); | ||||
|  | ||||
|         // Add click handler for creating new annotations | ||||
|         if (this.config.allowEditing && this.isEditMode) { | ||||
|             this.setupAnnotationCreation(overlay, noteId); | ||||
|         } | ||||
|          | ||||
|         // Add ARIA attributes for accessibility | ||||
|         overlay.setAttribute('role', 'img'); | ||||
|         overlay.setAttribute('aria-label', 'Image with annotations'); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Create overlay container for annotations | ||||
|      */ | ||||
|     private createOverlayContainer(container: HTMLElement, imageElement: HTMLImageElement): HTMLElement { | ||||
|         let overlay = container.querySelector('.annotation-overlay') as HTMLElement; | ||||
|          | ||||
|         if (!overlay) { | ||||
|             overlay = document.createElement('div'); | ||||
|             overlay.className = 'annotation-overlay'; | ||||
|             overlay.style.cssText = ` | ||||
|                 position: absolute; | ||||
|                 top: 0; | ||||
|                 left: 0; | ||||
|                 width: 100%; | ||||
|                 height: 100%; | ||||
|                 pointer-events: ${this.isEditMode ? 'auto' : 'none'}; | ||||
|                 z-index: 10; | ||||
|             `; | ||||
|              | ||||
|             // Position overlay over the image | ||||
|             const rect = imageElement.getBoundingClientRect(); | ||||
|             const containerRect = container.getBoundingClientRect(); | ||||
|             overlay.style.top = `${rect.top - containerRect.top}px`; | ||||
|             overlay.style.left = `${rect.left - containerRect.left}px`; | ||||
|             overlay.style.width = `${rect.width}px`; | ||||
|             overlay.style.height = `${rect.height}px`; | ||||
|              | ||||
|             container.appendChild(overlay); | ||||
|         } | ||||
|  | ||||
|         return overlay; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Create annotation element | ||||
|      */ | ||||
|     private createAnnotationElement(annotation: ImageAnnotation, container: HTMLElement): HTMLElement { | ||||
|         const element = document.createElement('div'); | ||||
|         element.className = `annotation-marker annotation-${annotation.type || 'comment'}`; | ||||
|         element.dataset.annotationId = annotation.id; | ||||
|          | ||||
|         // Position based on percentage | ||||
|         element.style.cssText = ` | ||||
|             position: absolute; | ||||
|             left: ${annotation.x}%; | ||||
|             top: ${annotation.y}%; | ||||
|             transform: translate(-50%, -50%); | ||||
|             cursor: pointer; | ||||
|             z-index: 20; | ||||
|             pointer-events: auto; | ||||
|         `; | ||||
|  | ||||
|         // Create marker based on type | ||||
|         if (annotation.type === 'region') { | ||||
|             // Region annotation | ||||
|             element.style.cssText += ` | ||||
|                 width: ${annotation.width || 20}%; | ||||
|                 height: ${annotation.height || 20}%; | ||||
|                 border: 2px solid ${annotation.color || this.config.defaultColor}; | ||||
|                 background: ${annotation.color || this.config.defaultColor}33; | ||||
|                 border-radius: 4px; | ||||
|             `; | ||||
|         } else { | ||||
|             // Point annotation | ||||
|             const marker = document.createElement('div'); | ||||
|             marker.style.cssText = ` | ||||
|                 width: 24px; | ||||
|                 height: 24px; | ||||
|                 background: ${annotation.color || this.config.defaultColor}; | ||||
|                 border-radius: 50%; | ||||
|                 display: flex; | ||||
|                 align-items: center; | ||||
|                 justify-content: center; | ||||
|                 box-shadow: 0 2px 4px rgba(0,0,0,0.2); | ||||
|             `; | ||||
|              | ||||
|             const icon = document.createElement('i'); | ||||
|             icon.className = `bx ${annotation.icon || this.config.defaultIcon}`; | ||||
|             icon.style.cssText = ` | ||||
|                 color: #333; | ||||
|                 font-size: 14px; | ||||
|             `; | ||||
|              | ||||
|             marker.appendChild(icon); | ||||
|             element.appendChild(marker); | ||||
|              | ||||
|             // Add ARIA attributes for accessibility | ||||
|             element.setAttribute('role', 'button'); | ||||
|             element.setAttribute('aria-label', `Annotation: ${this.sanitizeText(annotation.text)}`); | ||||
|             element.setAttribute('tabindex', '0'); | ||||
|         } | ||||
|  | ||||
|         // Add tooltip | ||||
|         const tooltip = document.createElement('div'); | ||||
|         tooltip.className = 'annotation-tooltip'; | ||||
|         tooltip.style.cssText = ` | ||||
|             position: absolute; | ||||
|             bottom: 100%; | ||||
|             left: 50%; | ||||
|             transform: translateX(-50%); | ||||
|             background: rgba(0,0,0,0.9); | ||||
|             color: white; | ||||
|             padding: 8px 12px; | ||||
|             border-radius: 4px; | ||||
|             font-size: 12px; | ||||
|             white-space: nowrap; | ||||
|             max-width: 200px; | ||||
|             pointer-events: none; | ||||
|             opacity: 0; | ||||
|             transition: opacity 0.2s; | ||||
|             margin-bottom: 8px; | ||||
|         `; | ||||
|         // Use textContent to prevent XSS | ||||
|         tooltip.textContent = this.sanitizeText(annotation.text); | ||||
|         element.appendChild(tooltip); | ||||
|  | ||||
|         // Show tooltip on hover | ||||
|         element.addEventListener('mouseenter', () => { | ||||
|             tooltip.style.opacity = '1'; | ||||
|         }); | ||||
|          | ||||
|         element.addEventListener('mouseleave', () => { | ||||
|             tooltip.style.opacity = '0'; | ||||
|         }); | ||||
|  | ||||
|         // Handle click for editing | ||||
|         element.addEventListener('click', (e) => { | ||||
|             e.stopPropagation(); | ||||
|             this.selectAnnotation(annotation); | ||||
|         }); | ||||
|  | ||||
|         container.appendChild(element); | ||||
|         return element; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Setup annotation creation on click | ||||
|      */ | ||||
|     private setupAnnotationCreation(overlay: HTMLElement, noteId: string): void { | ||||
|         overlay.addEventListener('click', async (e) => { | ||||
|             if (!this.isEditMode) return; | ||||
|              | ||||
|             const rect = overlay.getBoundingClientRect(); | ||||
|             const x = ((e.clientX - rect.left) / rect.width) * 100; | ||||
|             const y = ((e.clientY - rect.top) / rect.height) * 100; | ||||
|              | ||||
|             // Show annotation creation dialog | ||||
|             const text = prompt('Enter annotation text:'); | ||||
|             if (text) { | ||||
|                 await this.saveAnnotation({ | ||||
|                     noteId, | ||||
|                     x, | ||||
|                     y, | ||||
|                     text, | ||||
|                     author: 'current_user', // TODO: Get from session | ||||
|                     type: 'comment' | ||||
|                 }); | ||||
|                  | ||||
|                 // Reload annotations | ||||
|                 await this.loadAnnotations(noteId); | ||||
|                  | ||||
|                 // Re-render | ||||
|                 const imageElement = overlay.parentElement?.querySelector('img') as HTMLImageElement; | ||||
|                 if (imageElement && overlay.parentElement) { | ||||
|                     this.renderAnnotations(overlay.parentElement, noteId, imageElement); | ||||
|                 } | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Select an annotation for editing | ||||
|      */ | ||||
|     private selectAnnotation(annotation: ImageAnnotation): void { | ||||
|         this.selectedAnnotation = annotation; | ||||
|          | ||||
|         // Highlight selected annotation | ||||
|         this.annotationElements.forEach((element, id) => { | ||||
|             if (id === annotation.id) { | ||||
|                 element.classList.add('selected'); | ||||
|                 element.style.outline = '2px solid #2196F3'; | ||||
|             } else { | ||||
|                 element.classList.remove('selected'); | ||||
|                 element.style.outline = 'none'; | ||||
|             } | ||||
|         }); | ||||
|          | ||||
|         // Show edit options | ||||
|         if (this.isEditMode) { | ||||
|             this.showEditDialog(annotation); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Show edit dialog for annotation | ||||
|      */ | ||||
|     private showEditDialog(annotation: ImageAnnotation): void { | ||||
|         // Simple implementation - could be replaced with a proper modal | ||||
|         const newText = prompt('Edit annotation:', annotation.text); | ||||
|         if (newText !== null) { | ||||
|             annotation.text = newText; | ||||
|             this.updateAnnotation(annotation); | ||||
|              | ||||
|             // Update tooltip with sanitized text | ||||
|             const element = this.annotationElements.get(annotation.id); | ||||
|             if (element) { | ||||
|                 const tooltip = element.querySelector('.annotation-tooltip'); | ||||
|                 if (tooltip) { | ||||
|                     // Use textContent to prevent XSS | ||||
|                     tooltip.textContent = this.sanitizeText(newText); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Toggle edit mode | ||||
|      */ | ||||
|     toggleEditMode(): void { | ||||
|         this.isEditMode = !this.isEditMode; | ||||
|          | ||||
|         // Update overlay pointer events | ||||
|         document.querySelectorAll('.annotation-overlay').forEach(overlay => { | ||||
|             (overlay as HTMLElement).style.pointerEvents = this.isEditMode ? 'auto' : 'none'; | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Clear all annotation elements | ||||
|      */ | ||||
|     private clearAnnotationElements(): void { | ||||
|         this.annotationElements.forEach(element => element.remove()); | ||||
|         this.annotationElements.clear(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Generate unique ID | ||||
|      */ | ||||
|     private generateId(): string { | ||||
|         return `ann_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Export annotations as JSON | ||||
|      */ | ||||
|     exportAnnotations(noteId: string): string { | ||||
|         const annotations = this.activeAnnotations.get(noteId) || []; | ||||
|         return JSON.stringify(annotations, null, 2); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Import annotations from JSON | ||||
|      */ | ||||
|     async importAnnotations(noteId: string, json: string): Promise<void> { | ||||
|         try { | ||||
|             const annotations = JSON.parse(json) as ImageAnnotation[]; | ||||
|              | ||||
|             for (const annotation of annotations) { | ||||
|                 await this.saveAnnotation({ | ||||
|                     noteId, | ||||
|                     x: annotation.x, | ||||
|                     y: annotation.y, | ||||
|                     text: annotation.text, | ||||
|                     author: annotation.author, | ||||
|                     color: annotation.color, | ||||
|                     icon: annotation.icon, | ||||
|                     type: annotation.type, | ||||
|                     width: annotation.width, | ||||
|                     height: annotation.height | ||||
|                 }); | ||||
|             } | ||||
|              | ||||
|             await this.loadAnnotations(noteId); | ||||
|         } catch (error) { | ||||
|             console.error('Failed to import annotations:', error); | ||||
|             throw error; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Sanitize text to prevent XSS | ||||
|      */ | ||||
|     private sanitizeText(text: string): string { | ||||
|         if (!text) return ''; | ||||
|          | ||||
|         // Remove any HTML tags and dangerous characters | ||||
|         const div = document.createElement('div'); | ||||
|         div.textContent = text; | ||||
|          | ||||
|         // Additional validation | ||||
|         const sanitized = div.textContent || ''; | ||||
|          | ||||
|         // Remove any remaining special characters that could be dangerous | ||||
|         return sanitized | ||||
|             .replace(/<script[^>]*>.*?<\/script>/gi, '') | ||||
|             .replace(/<iframe[^>]*>.*?<\/iframe>/gi, '') | ||||
|             .replace(/javascript:/gi, '') | ||||
|             .replace(/on\w+\s*=/gi, ''); | ||||
|     } | ||||
|      | ||||
|     /** | ||||
|      * Cleanup resources | ||||
|      */ | ||||
|     cleanup(): void { | ||||
|         this.clearAnnotationElements(); | ||||
|         this.activeAnnotations.clear(); | ||||
|         this.selectedAnnotation = null; | ||||
|         this.isEditMode = false; | ||||
|     } | ||||
| } | ||||
|  | ||||
| export default ImageAnnotationsService.getInstance(); | ||||
							
								
								
									
										877
									
								
								apps/client/src/services/image_comparison.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										877
									
								
								apps/client/src/services/image_comparison.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,877 @@ | ||||
| /** | ||||
|  * Image Comparison Module for Trilium Notes | ||||
|  * Provides side-by-side and overlay comparison modes for images | ||||
|  */ | ||||
|  | ||||
| import mediaViewer from './media_viewer.js'; | ||||
| import utils from './utils.js'; | ||||
|  | ||||
| /** | ||||
|  * Comparison mode types | ||||
|  */ | ||||
| export type ComparisonMode = 'side-by-side' | 'overlay' | 'swipe' | 'difference'; | ||||
|  | ||||
| /** | ||||
|  * Image comparison configuration | ||||
|  */ | ||||
| export interface ComparisonConfig { | ||||
|     mode: ComparisonMode; | ||||
|     syncZoom: boolean; | ||||
|     syncPan: boolean; | ||||
|     showLabels: boolean; | ||||
|     swipePosition?: number; // For swipe mode (0-100) | ||||
|     opacity?: number; // For overlay mode (0-1) | ||||
|     highlightDifferences?: boolean; // For difference mode | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Comparison state | ||||
|  */ | ||||
| interface ComparisonState { | ||||
|     leftImage: ComparisonImage; | ||||
|     rightImage: ComparisonImage; | ||||
|     config: ComparisonConfig; | ||||
|     container?: HTMLElement; | ||||
|     isActive: boolean; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Image data for comparison | ||||
|  */ | ||||
| export interface ComparisonImage { | ||||
|     src: string; | ||||
|     title?: string; | ||||
|     noteId?: string; | ||||
|     width?: number; | ||||
|     height?: number; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * ImageComparisonService provides various comparison modes for images | ||||
|  */ | ||||
| class ImageComparisonService { | ||||
|     private static instance: ImageComparisonService; | ||||
|     private currentComparison: ComparisonState | null = null; | ||||
|     private comparisonContainer?: HTMLElement; | ||||
|     private leftCanvas?: HTMLCanvasElement; | ||||
|     private rightCanvas?: HTMLCanvasElement; | ||||
|     private leftContext?: CanvasRenderingContext2D; | ||||
|     private rightContext?: CanvasRenderingContext2D; | ||||
|     private swipeHandle?: HTMLElement; | ||||
|     private isDraggingSwipe: boolean = false; | ||||
|     private currentZoom: number = 1; | ||||
|     private panX: number = 0; | ||||
|     private panY: number = 0; | ||||
|      | ||||
|     private defaultConfig: ComparisonConfig = { | ||||
|         mode: 'side-by-side', | ||||
|         syncZoom: true, | ||||
|         syncPan: true, | ||||
|         showLabels: true, | ||||
|         swipePosition: 50, | ||||
|         opacity: 0.5, | ||||
|         highlightDifferences: false | ||||
|     }; | ||||
|  | ||||
|     private constructor() {} | ||||
|  | ||||
|     static getInstance(): ImageComparisonService { | ||||
|         if (!ImageComparisonService.instance) { | ||||
|             ImageComparisonService.instance = new ImageComparisonService(); | ||||
|         } | ||||
|         return ImageComparisonService.instance; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Start image comparison | ||||
|      */ | ||||
|     async startComparison( | ||||
|         leftImage: ComparisonImage, | ||||
|         rightImage: ComparisonImage, | ||||
|         container: HTMLElement, | ||||
|         config?: Partial<ComparisonConfig> | ||||
|     ): Promise<void> { | ||||
|         try { | ||||
|             // Close any existing comparison | ||||
|             this.closeComparison(); | ||||
|  | ||||
|             // Merge configuration | ||||
|             const finalConfig = { ...this.defaultConfig, ...config }; | ||||
|  | ||||
|             // Initialize state | ||||
|             this.currentComparison = { | ||||
|                 leftImage, | ||||
|                 rightImage, | ||||
|                 config: finalConfig, | ||||
|                 container, | ||||
|                 isActive: true | ||||
|             }; | ||||
|  | ||||
|             // Load images | ||||
|             await this.loadImages(leftImage, rightImage); | ||||
|  | ||||
|             // Create comparison UI based on mode | ||||
|             switch (finalConfig.mode) { | ||||
|                 case 'side-by-side': | ||||
|                     await this.createSideBySideComparison(container); | ||||
|                     break; | ||||
|                 case 'overlay': | ||||
|                     await this.createOverlayComparison(container); | ||||
|                     break; | ||||
|                 case 'swipe': | ||||
|                     await this.createSwipeComparison(container); | ||||
|                     break; | ||||
|                 case 'difference': | ||||
|                     await this.createDifferenceComparison(container); | ||||
|                     break; | ||||
|             } | ||||
|  | ||||
|             // Add controls | ||||
|             this.addComparisonControls(container); | ||||
|         } catch (error) { | ||||
|             console.error('Failed to start image comparison:', error); | ||||
|             this.closeComparison(); | ||||
|             throw error; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Load images and get dimensions | ||||
|      */ | ||||
|     private async loadImages(leftImage: ComparisonImage, rightImage: ComparisonImage): Promise<void> { | ||||
|         const loadImage = (src: string): Promise<HTMLImageElement> => { | ||||
|             return new Promise((resolve, reject) => { | ||||
|                 const img = new Image(); | ||||
|                 img.onload = () => resolve(img); | ||||
|                 img.onerror = () => reject(new Error(`Failed to load image: ${src}`)); | ||||
|                 img.src = src; | ||||
|             }); | ||||
|         }; | ||||
|  | ||||
|         const [leftImg, rightImg] = await Promise.all([ | ||||
|             loadImage(leftImage.src), | ||||
|             loadImage(rightImage.src) | ||||
|         ]); | ||||
|  | ||||
|         // Update dimensions | ||||
|         leftImage.width = leftImg.naturalWidth; | ||||
|         leftImage.height = leftImg.naturalHeight; | ||||
|         rightImage.width = rightImg.naturalWidth; | ||||
|         rightImage.height = rightImg.naturalHeight; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Create side-by-side comparison | ||||
|      */ | ||||
|     private async createSideBySideComparison(container: HTMLElement): Promise<void> { | ||||
|         if (!this.currentComparison) return; | ||||
|  | ||||
|         // Clear container | ||||
|         container.innerHTML = ''; | ||||
|         container.style.cssText = ` | ||||
|             display: flex; | ||||
|             width: 100%; | ||||
|             height: 100%; | ||||
|             position: relative; | ||||
|             background: #1a1a1a; | ||||
|         `; | ||||
|  | ||||
|         // Create left panel | ||||
|         const leftPanel = document.createElement('div'); | ||||
|         leftPanel.className = 'comparison-panel comparison-left'; | ||||
|         leftPanel.style.cssText = ` | ||||
|             flex: 1; | ||||
|             position: relative; | ||||
|             overflow: hidden; | ||||
|             border-right: 2px solid #333; | ||||
|         `; | ||||
|  | ||||
|         // Create right panel | ||||
|         const rightPanel = document.createElement('div'); | ||||
|         rightPanel.className = 'comparison-panel comparison-right'; | ||||
|         rightPanel.style.cssText = ` | ||||
|             flex: 1; | ||||
|             position: relative; | ||||
|             overflow: hidden; | ||||
|         `; | ||||
|  | ||||
|         // Add images | ||||
|         const leftImg = await this.createImageElement(this.currentComparison.leftImage); | ||||
|         const rightImg = await this.createImageElement(this.currentComparison.rightImage); | ||||
|          | ||||
|         leftPanel.appendChild(leftImg); | ||||
|         rightPanel.appendChild(rightImg); | ||||
|  | ||||
|         // Add labels if enabled | ||||
|         if (this.currentComparison.config.showLabels) { | ||||
|             this.addImageLabel(leftPanel, this.currentComparison.leftImage.title || 'Image 1'); | ||||
|             this.addImageLabel(rightPanel, this.currentComparison.rightImage.title || 'Image 2'); | ||||
|         } | ||||
|  | ||||
|         container.appendChild(leftPanel); | ||||
|         container.appendChild(rightPanel); | ||||
|  | ||||
|         // Setup synchronized zoom and pan if enabled | ||||
|         if (this.currentComparison.config.syncZoom || this.currentComparison.config.syncPan) { | ||||
|             this.setupSynchronizedControls(leftPanel, rightPanel); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Create overlay comparison | ||||
|      */ | ||||
|     private async createOverlayComparison(container: HTMLElement): Promise<void> { | ||||
|         if (!this.currentComparison) return; | ||||
|  | ||||
|         container.innerHTML = ''; | ||||
|         container.style.cssText = ` | ||||
|             position: relative; | ||||
|             width: 100%; | ||||
|             height: 100%; | ||||
|             background: #1a1a1a; | ||||
|             overflow: hidden; | ||||
|         `; | ||||
|  | ||||
|         // Create base image | ||||
|         const baseImg = await this.createImageElement(this.currentComparison.leftImage); | ||||
|         baseImg.style.position = 'absolute'; | ||||
|         baseImg.style.zIndex = '1'; | ||||
|          | ||||
|         // Create overlay image | ||||
|         const overlayImg = await this.createImageElement(this.currentComparison.rightImage); | ||||
|         overlayImg.style.position = 'absolute'; | ||||
|         overlayImg.style.zIndex = '2'; | ||||
|         overlayImg.style.opacity = String(this.currentComparison.config.opacity || 0.5); | ||||
|          | ||||
|         container.appendChild(baseImg); | ||||
|         container.appendChild(overlayImg); | ||||
|  | ||||
|         // Add opacity slider | ||||
|         this.addOpacityControl(container, overlayImg); | ||||
|  | ||||
|         // Add labels | ||||
|         if (this.currentComparison.config.showLabels) { | ||||
|             const labelContainer = document.createElement('div'); | ||||
|             labelContainer.style.cssText = ` | ||||
|                 position: absolute; | ||||
|                 top: 10px; | ||||
|                 left: 10px; | ||||
|                 z-index: 10; | ||||
|                 display: flex; | ||||
|                 gap: 10px; | ||||
|             `; | ||||
|              | ||||
|             const baseLabel = this.createLabel(this.currentComparison.leftImage.title || 'Base', '#4CAF50'); | ||||
|             const overlayLabel = this.createLabel(this.currentComparison.rightImage.title || 'Overlay', '#2196F3'); | ||||
|              | ||||
|             labelContainer.appendChild(baseLabel); | ||||
|             labelContainer.appendChild(overlayLabel); | ||||
|             container.appendChild(labelContainer); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Create swipe comparison | ||||
|      */ | ||||
|     private async createSwipeComparison(container: HTMLElement): Promise<void> { | ||||
|         if (!this.currentComparison) return; | ||||
|  | ||||
|         container.innerHTML = ''; | ||||
|         container.style.cssText = ` | ||||
|             position: relative; | ||||
|             width: 100%; | ||||
|             height: 100%; | ||||
|             background: #1a1a1a; | ||||
|             overflow: hidden; | ||||
|             cursor: ew-resize; | ||||
|         `; | ||||
|  | ||||
|         // Create images | ||||
|         const leftImg = await this.createImageElement(this.currentComparison.leftImage); | ||||
|         const rightImg = await this.createImageElement(this.currentComparison.rightImage); | ||||
|          | ||||
|         leftImg.style.position = 'absolute'; | ||||
|         leftImg.style.zIndex = '1'; | ||||
|          | ||||
|         // Create clipping container for right image | ||||
|         const clipContainer = document.createElement('div'); | ||||
|         clipContainer.className = 'swipe-clip-container'; | ||||
|         clipContainer.style.cssText = ` | ||||
|             position: absolute; | ||||
|             top: 0; | ||||
|             left: 0; | ||||
|             width: ${this.currentComparison.config.swipePosition}%; | ||||
|             height: 100%; | ||||
|             overflow: hidden; | ||||
|             z-index: 2; | ||||
|         `; | ||||
|          | ||||
|         rightImg.style.position = 'absolute'; | ||||
|         clipContainer.appendChild(rightImg); | ||||
|          | ||||
|         // Create swipe handle | ||||
|         this.swipeHandle = document.createElement('div'); | ||||
|         this.swipeHandle.className = 'swipe-handle'; | ||||
|         this.swipeHandle.style.cssText = ` | ||||
|             position: absolute; | ||||
|             top: 0; | ||||
|             left: ${this.currentComparison.config.swipePosition}%; | ||||
|             width: 4px; | ||||
|             height: 100%; | ||||
|             background: white; | ||||
|             cursor: ew-resize; | ||||
|             z-index: 3; | ||||
|             transform: translateX(-50%); | ||||
|             box-shadow: 0 0 10px rgba(0,0,0,0.5); | ||||
|         `; | ||||
|  | ||||
|         // Add handle icon | ||||
|         const handleIcon = document.createElement('div'); | ||||
|         handleIcon.style.cssText = ` | ||||
|             position: absolute; | ||||
|             top: 50%; | ||||
|             left: 50%; | ||||
|             transform: translate(-50%, -50%); | ||||
|             width: 40px; | ||||
|             height: 40px; | ||||
|             background: white; | ||||
|             border-radius: 50%; | ||||
|             display: flex; | ||||
|             align-items: center; | ||||
|             justify-content: center; | ||||
|             box-shadow: 0 2px 10px rgba(0,0,0,0.3); | ||||
|         `; | ||||
|         handleIcon.innerHTML = '<i class="bx bx-move-horizontal" style="font-size: 24px; color: #333;"></i>'; | ||||
|         this.swipeHandle.appendChild(handleIcon); | ||||
|  | ||||
|         container.appendChild(leftImg); | ||||
|         container.appendChild(clipContainer); | ||||
|         container.appendChild(this.swipeHandle); | ||||
|  | ||||
|         // Setup swipe interaction | ||||
|         this.setupSwipeInteraction(container, clipContainer); | ||||
|  | ||||
|         // Add labels | ||||
|         if (this.currentComparison.config.showLabels) { | ||||
|             this.addSwipeLabels(container); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Create difference comparison using canvas | ||||
|      */ | ||||
|     private async createDifferenceComparison(container: HTMLElement): Promise<void> { | ||||
|         if (!this.currentComparison) return; | ||||
|  | ||||
|         container.innerHTML = ''; | ||||
|         container.style.cssText = ` | ||||
|             position: relative; | ||||
|             width: 100%; | ||||
|             height: 100%; | ||||
|             background: #1a1a1a; | ||||
|             overflow: hidden; | ||||
|         `; | ||||
|  | ||||
|         // Create canvas for difference visualization | ||||
|         const canvas = document.createElement('canvas'); | ||||
|         canvas.className = 'difference-canvas'; | ||||
|         const ctx = canvas.getContext('2d'); | ||||
|          | ||||
|         if (!ctx) { | ||||
|             throw new Error('Failed to get canvas context'); | ||||
|         } | ||||
|  | ||||
|         // Load images | ||||
|         const leftImg = new Image(); | ||||
|         const rightImg = new Image(); | ||||
|          | ||||
|         await Promise.all([ | ||||
|             new Promise((resolve) => { | ||||
|                 leftImg.onload = resolve; | ||||
|                 leftImg.src = this.currentComparison!.leftImage.src; | ||||
|             }), | ||||
|             new Promise((resolve) => { | ||||
|                 rightImg.onload = resolve; | ||||
|                 rightImg.src = this.currentComparison!.rightImage.src; | ||||
|             }) | ||||
|         ]); | ||||
|  | ||||
|         // Set canvas size | ||||
|         const maxWidth = Math.max(leftImg.width, rightImg.width); | ||||
|         const maxHeight = Math.max(leftImg.height, rightImg.height); | ||||
|         canvas.width = maxWidth; | ||||
|         canvas.height = maxHeight; | ||||
|  | ||||
|         // Calculate difference | ||||
|         this.calculateImageDifference(ctx, leftImg, rightImg, maxWidth, maxHeight); | ||||
|  | ||||
|         // Style canvas | ||||
|         canvas.style.cssText = ` | ||||
|             max-width: 100%; | ||||
|             max-height: 100%; | ||||
|             object-fit: contain; | ||||
|         `; | ||||
|  | ||||
|         container.appendChild(canvas); | ||||
|  | ||||
|         // Add difference statistics | ||||
|         this.addDifferenceStatistics(container, ctx, maxWidth, maxHeight); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Calculate and visualize image difference | ||||
|      */ | ||||
|     private calculateImageDifference( | ||||
|         ctx: CanvasRenderingContext2D, | ||||
|         leftImg: HTMLImageElement, | ||||
|         rightImg: HTMLImageElement, | ||||
|         width: number, | ||||
|         height: number | ||||
|     ): void { | ||||
|         // Draw left image | ||||
|         ctx.drawImage(leftImg, 0, 0, width, height); | ||||
|         const leftData = ctx.getImageData(0, 0, width, height); | ||||
|  | ||||
|         // Draw right image | ||||
|         ctx.clearRect(0, 0, width, height); | ||||
|         ctx.drawImage(rightImg, 0, 0, width, height); | ||||
|         const rightData = ctx.getImageData(0, 0, width, height); | ||||
|  | ||||
|         // Calculate difference | ||||
|         const diffData = ctx.createImageData(width, height); | ||||
|         let totalDiff = 0; | ||||
|  | ||||
|         for (let i = 0; i < leftData.data.length; i += 4) { | ||||
|             const rDiff = Math.abs(leftData.data[i] - rightData.data[i]); | ||||
|             const gDiff = Math.abs(leftData.data[i + 1] - rightData.data[i + 1]); | ||||
|             const bDiff = Math.abs(leftData.data[i + 2] - rightData.data[i + 2]); | ||||
|              | ||||
|             const avgDiff = (rDiff + gDiff + bDiff) / 3; | ||||
|             totalDiff += avgDiff; | ||||
|  | ||||
|             if (this.currentComparison?.config.highlightDifferences && avgDiff > 30) { | ||||
|                 // Highlight differences in red | ||||
|                 diffData.data[i] = 255; // Red | ||||
|                 diffData.data[i + 1] = 0; // Green | ||||
|                 diffData.data[i + 2] = 0; // Blue | ||||
|                 diffData.data[i + 3] = Math.min(255, avgDiff * 2); // Alpha based on difference | ||||
|             } else { | ||||
|                 // Show original image with reduced opacity for non-different areas | ||||
|                 diffData.data[i] = leftData.data[i]; | ||||
|                 diffData.data[i + 1] = leftData.data[i + 1]; | ||||
|                 diffData.data[i + 2] = leftData.data[i + 2]; | ||||
|                 diffData.data[i + 3] = avgDiff > 10 ? 255 : 128; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         ctx.putImageData(diffData, 0, 0); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Add difference statistics overlay | ||||
|      */ | ||||
|     private addDifferenceStatistics( | ||||
|         container: HTMLElement, | ||||
|         ctx: CanvasRenderingContext2D, | ||||
|         width: number, | ||||
|         height: number | ||||
|     ): void { | ||||
|         const imageData = ctx.getImageData(0, 0, width, height); | ||||
|         let changedPixels = 0; | ||||
|         const threshold = 30; | ||||
|  | ||||
|         for (let i = 0; i < imageData.data.length; i += 4) { | ||||
|             const r = imageData.data[i]; | ||||
|             const g = imageData.data[i + 1]; | ||||
|             const b = imageData.data[i + 2]; | ||||
|              | ||||
|             if (r > threshold || g > threshold || b > threshold) { | ||||
|                 changedPixels++; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         const totalPixels = width * height; | ||||
|         const changePercentage = ((changedPixels / totalPixels) * 100).toFixed(2); | ||||
|  | ||||
|         const statsDiv = document.createElement('div'); | ||||
|         statsDiv.className = 'difference-stats'; | ||||
|         statsDiv.style.cssText = ` | ||||
|             position: absolute; | ||||
|             top: 10px; | ||||
|             right: 10px; | ||||
|             background: rgba(0, 0, 0, 0.8); | ||||
|             color: white; | ||||
|             padding: 10px; | ||||
|             border-radius: 4px; | ||||
|             font-size: 12px; | ||||
|             z-index: 10; | ||||
|         `; | ||||
|          | ||||
|         statsDiv.innerHTML = ` | ||||
|             <div><strong>Difference Analysis</strong></div> | ||||
|             <div>Changed pixels: ${changedPixels.toLocaleString()}</div> | ||||
|             <div>Total pixels: ${totalPixels.toLocaleString()}</div> | ||||
|             <div>Difference: ${changePercentage}%</div> | ||||
|         `; | ||||
|          | ||||
|         container.appendChild(statsDiv); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Create image element | ||||
|      */ | ||||
|     private async createImageElement(image: ComparisonImage): Promise<HTMLImageElement> { | ||||
|         const img = document.createElement('img'); | ||||
|         img.src = image.src; | ||||
|         img.alt = image.title || 'Comparison image'; | ||||
|         img.style.cssText = ` | ||||
|             width: 100%; | ||||
|             height: 100%; | ||||
|             object-fit: contain; | ||||
|         `; | ||||
|          | ||||
|         await new Promise((resolve, reject) => { | ||||
|             img.onload = resolve; | ||||
|             img.onerror = reject; | ||||
|         }); | ||||
|          | ||||
|         return img; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Add image label | ||||
|      */ | ||||
|     private addImageLabel(container: HTMLElement, text: string): void { | ||||
|         const label = document.createElement('div'); | ||||
|         label.className = 'image-label'; | ||||
|         label.style.cssText = ` | ||||
|             position: absolute; | ||||
|             top: 10px; | ||||
|             left: 10px; | ||||
|             background: rgba(0, 0, 0, 0.7); | ||||
|             color: white; | ||||
|             padding: 6px 10px; | ||||
|             border-radius: 4px; | ||||
|             font-size: 12px; | ||||
|             z-index: 10; | ||||
|         `; | ||||
|         label.textContent = text; | ||||
|         container.appendChild(label); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Create label element | ||||
|      */ | ||||
|     private createLabel(text: string, color: string): HTMLElement { | ||||
|         const label = document.createElement('div'); | ||||
|         label.style.cssText = ` | ||||
|             background: ${color}; | ||||
|             color: white; | ||||
|             padding: 4px 8px; | ||||
|             border-radius: 3px; | ||||
|             font-size: 12px; | ||||
|         `; | ||||
|         label.textContent = text; | ||||
|         return label; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Add swipe labels | ||||
|      */ | ||||
|     private addSwipeLabels(container: HTMLElement): void { | ||||
|         if (!this.currentComparison) return; | ||||
|  | ||||
|         const leftLabel = document.createElement('div'); | ||||
|         leftLabel.style.cssText = ` | ||||
|             position: absolute; | ||||
|             top: 10px; | ||||
|             left: 10px; | ||||
|             background: rgba(76, 175, 80, 0.9); | ||||
|             color: white; | ||||
|             padding: 6px 10px; | ||||
|             border-radius: 4px; | ||||
|             font-size: 12px; | ||||
|             z-index: 10; | ||||
|         `; | ||||
|         leftLabel.textContent = this.currentComparison.leftImage.title || 'Left'; | ||||
|  | ||||
|         const rightLabel = document.createElement('div'); | ||||
|         rightLabel.style.cssText = ` | ||||
|             position: absolute; | ||||
|             top: 10px; | ||||
|             right: 10px; | ||||
|             background: rgba(33, 150, 243, 0.9); | ||||
|             color: white; | ||||
|             padding: 6px 10px; | ||||
|             border-radius: 4px; | ||||
|             font-size: 12px; | ||||
|             z-index: 10; | ||||
|         `; | ||||
|         rightLabel.textContent = this.currentComparison.rightImage.title || 'Right'; | ||||
|  | ||||
|         container.appendChild(leftLabel); | ||||
|         container.appendChild(rightLabel); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Setup swipe interaction | ||||
|      */ | ||||
|     private setupSwipeInteraction(container: HTMLElement, clipContainer: HTMLElement): void { | ||||
|         if (!this.swipeHandle) return; | ||||
|  | ||||
|         let startX = 0; | ||||
|         let startPosition = this.currentComparison?.config.swipePosition || 50; | ||||
|  | ||||
|         const handleMouseMove = (e: MouseEvent) => { | ||||
|             if (!this.isDraggingSwipe) return; | ||||
|              | ||||
|             const rect = container.getBoundingClientRect(); | ||||
|             const x = e.clientX - rect.left; | ||||
|             const percentage = Math.max(0, Math.min(100, (x / rect.width) * 100)); | ||||
|              | ||||
|             clipContainer.style.width = `${percentage}%`; | ||||
|             if (this.swipeHandle) { | ||||
|                 this.swipeHandle.style.left = `${percentage}%`; | ||||
|             } | ||||
|              | ||||
|             if (this.currentComparison) { | ||||
|                 this.currentComparison.config.swipePosition = percentage; | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|         const handleMouseUp = () => { | ||||
|             this.isDraggingSwipe = false; | ||||
|             document.removeEventListener('mousemove', handleMouseMove); | ||||
|             document.removeEventListener('mouseup', handleMouseUp); | ||||
|             container.style.cursor = 'default'; | ||||
|         }; | ||||
|  | ||||
|         this.swipeHandle.addEventListener('mousedown', (e) => { | ||||
|             this.isDraggingSwipe = true; | ||||
|             startX = e.clientX; | ||||
|             startPosition = this.currentComparison?.config.swipePosition || 50; | ||||
|             container.style.cursor = 'ew-resize'; | ||||
|              | ||||
|             document.addEventListener('mousemove', handleMouseMove); | ||||
|             document.addEventListener('mouseup', handleMouseUp); | ||||
|         }); | ||||
|  | ||||
|         // Also allow dragging anywhere in the container | ||||
|         container.addEventListener('mousedown', (e) => { | ||||
|             if (e.target === this.swipeHandle || (e.target as HTMLElement).parentElement === this.swipeHandle) { | ||||
|                 return; | ||||
|             } | ||||
|              | ||||
|             const rect = container.getBoundingClientRect(); | ||||
|             const x = e.clientX - rect.left; | ||||
|             const percentage = (x / rect.width) * 100; | ||||
|              | ||||
|             clipContainer.style.width = `${percentage}%`; | ||||
|             if (this.swipeHandle) { | ||||
|                 this.swipeHandle.style.left = `${percentage}%`; | ||||
|             } | ||||
|              | ||||
|             if (this.currentComparison) { | ||||
|                 this.currentComparison.config.swipePosition = percentage; | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Add opacity control for overlay mode | ||||
|      */ | ||||
|     private addOpacityControl(container: HTMLElement, overlayImg: HTMLImageElement): void { | ||||
|         const control = document.createElement('div'); | ||||
|         control.className = 'opacity-control'; | ||||
|         control.style.cssText = ` | ||||
|             position: absolute; | ||||
|             bottom: 20px; | ||||
|             left: 50%; | ||||
|             transform: translateX(-50%); | ||||
|             background: rgba(0, 0, 0, 0.8); | ||||
|             padding: 10px 20px; | ||||
|             border-radius: 4px; | ||||
|             z-index: 10; | ||||
|             display: flex; | ||||
|             align-items: center; | ||||
|             gap: 10px; | ||||
|         `; | ||||
|  | ||||
|         const label = document.createElement('label'); | ||||
|         label.textContent = 'Opacity:'; | ||||
|         label.style.color = 'white'; | ||||
|         label.style.fontSize = '12px'; | ||||
|  | ||||
|         const slider = document.createElement('input'); | ||||
|         slider.type = 'range'; | ||||
|         slider.min = '0'; | ||||
|         slider.max = '100'; | ||||
|         slider.value = String((this.currentComparison?.config.opacity || 0.5) * 100); | ||||
|         slider.style.width = '150px'; | ||||
|  | ||||
|         const value = document.createElement('span'); | ||||
|         value.textContent = `${slider.value}%`; | ||||
|         value.style.color = 'white'; | ||||
|         value.style.fontSize = '12px'; | ||||
|         value.style.minWidth = '35px'; | ||||
|  | ||||
|         slider.addEventListener('input', () => { | ||||
|             const opacity = parseInt(slider.value) / 100; | ||||
|             overlayImg.style.opacity = String(opacity); | ||||
|             value.textContent = `${slider.value}%`; | ||||
|              | ||||
|             if (this.currentComparison) { | ||||
|                 this.currentComparison.config.opacity = opacity; | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|         control.appendChild(label); | ||||
|         control.appendChild(slider); | ||||
|         control.appendChild(value); | ||||
|         container.appendChild(control); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Setup synchronized controls for side-by-side mode | ||||
|      */ | ||||
|     private setupSynchronizedControls(leftPanel: HTMLElement, rightPanel: HTMLElement): void { | ||||
|         const leftImg = leftPanel.querySelector('img') as HTMLImageElement; | ||||
|         const rightImg = rightPanel.querySelector('img') as HTMLImageElement; | ||||
|          | ||||
|         if (!leftImg || !rightImg) return; | ||||
|  | ||||
|         // Synchronize scroll | ||||
|         if (this.currentComparison?.config.syncPan) { | ||||
|             leftPanel.addEventListener('scroll', () => { | ||||
|                 rightPanel.scrollLeft = leftPanel.scrollLeft; | ||||
|                 rightPanel.scrollTop = leftPanel.scrollTop; | ||||
|             }); | ||||
|              | ||||
|             rightPanel.addEventListener('scroll', () => { | ||||
|                 leftPanel.scrollLeft = rightPanel.scrollLeft; | ||||
|                 leftPanel.scrollTop = rightPanel.scrollTop; | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         // Synchronize zoom with wheel events | ||||
|         if (this.currentComparison?.config.syncZoom) { | ||||
|             const handleWheel = (e: WheelEvent) => { | ||||
|                 e.preventDefault(); | ||||
|                  | ||||
|                 const delta = e.deltaY < 0 ? 1.1 : 0.9; | ||||
|                 this.currentZoom = Math.max(0.5, Math.min(5, this.currentZoom * delta)); | ||||
|                  | ||||
|                 leftImg.style.transform = `scale(${this.currentZoom})`; | ||||
|                 rightImg.style.transform = `scale(${this.currentZoom})`; | ||||
|             }; | ||||
|              | ||||
|             leftPanel.addEventListener('wheel', handleWheel); | ||||
|             rightPanel.addEventListener('wheel', handleWheel); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Add comparison controls toolbar | ||||
|      */ | ||||
|     private addComparisonControls(container: HTMLElement): void { | ||||
|         const toolbar = document.createElement('div'); | ||||
|         toolbar.className = 'comparison-toolbar'; | ||||
|         toolbar.style.cssText = ` | ||||
|             position: absolute; | ||||
|             top: 10px; | ||||
|             right: 10px; | ||||
|             background: rgba(0, 0, 0, 0.8); | ||||
|             border-radius: 4px; | ||||
|             padding: 8px; | ||||
|             display: flex; | ||||
|             gap: 8px; | ||||
|             z-index: 100; | ||||
|         `; | ||||
|  | ||||
|         // Mode switcher | ||||
|         const modes: ComparisonMode[] = ['side-by-side', 'overlay', 'swipe', 'difference']; | ||||
|         modes.forEach(mode => { | ||||
|             const btn = document.createElement('button'); | ||||
|             btn.className = `mode-btn mode-${mode}`; | ||||
|             btn.style.cssText = ` | ||||
|                 background: ${this.currentComparison?.config.mode === mode ? '#2196F3' : 'rgba(255,255,255,0.1)'}; | ||||
|                 color: white; | ||||
|                 border: none; | ||||
|                 padding: 6px 10px; | ||||
|                 border-radius: 3px; | ||||
|                 cursor: pointer; | ||||
|                 font-size: 12px; | ||||
|             `; | ||||
|             btn.textContent = mode.replace('-', ' ').replace(/\b\w/g, l => l.toUpperCase()); | ||||
|              | ||||
|             btn.addEventListener('click', async () => { | ||||
|                 if (this.currentComparison && this.currentComparison.container) { | ||||
|                     this.currentComparison.config.mode = mode; | ||||
|                     await this.startComparison( | ||||
|                         this.currentComparison.leftImage, | ||||
|                         this.currentComparison.rightImage, | ||||
|                         this.currentComparison.container, | ||||
|                         this.currentComparison.config | ||||
|                     ); | ||||
|                 } | ||||
|             }); | ||||
|              | ||||
|             toolbar.appendChild(btn); | ||||
|         }); | ||||
|  | ||||
|         // Close button | ||||
|         const closeBtn = document.createElement('button'); | ||||
|         closeBtn.style.cssText = ` | ||||
|             background: rgba(255,0,0,0.5); | ||||
|             color: white; | ||||
|             border: none; | ||||
|             padding: 6px 10px; | ||||
|             border-radius: 3px; | ||||
|             cursor: pointer; | ||||
|             font-size: 12px; | ||||
|             margin-left: 10px; | ||||
|         `; | ||||
|         closeBtn.textContent = 'Close'; | ||||
|         closeBtn.addEventListener('click', () => this.closeComparison()); | ||||
|          | ||||
|         toolbar.appendChild(closeBtn); | ||||
|         container.appendChild(toolbar); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Close comparison | ||||
|      */ | ||||
|     closeComparison(): void { | ||||
|         if (this.currentComparison?.container) { | ||||
|             this.currentComparison.container.innerHTML = ''; | ||||
|         } | ||||
|          | ||||
|         this.currentComparison = null; | ||||
|         this.comparisonContainer = undefined; | ||||
|         this.leftCanvas = undefined; | ||||
|         this.rightCanvas = undefined; | ||||
|         this.leftContext = undefined; | ||||
|         this.rightContext = undefined; | ||||
|         this.swipeHandle = undefined; | ||||
|         this.isDraggingSwipe = false; | ||||
|         this.currentZoom = 1; | ||||
|         this.panX = 0; | ||||
|         this.panY = 0; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Check if comparison is active | ||||
|      */ | ||||
|     isComparisonActive(): boolean { | ||||
|         return this.currentComparison?.isActive || false; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Get current comparison state | ||||
|      */ | ||||
|     getComparisonState(): ComparisonState | null { | ||||
|         return this.currentComparison; | ||||
|     } | ||||
| } | ||||
|  | ||||
| export default ImageComparisonService.getInstance(); | ||||
							
								
								
									
										874
									
								
								apps/client/src/services/image_editor.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										874
									
								
								apps/client/src/services/image_editor.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,874 @@ | ||||
| /** | ||||
|  * Basic Image Editor Module for Trilium Notes | ||||
|  * Provides non-destructive image editing capabilities | ||||
|  */ | ||||
|  | ||||
| import server from './server.js'; | ||||
| import toastService from './toast.js'; | ||||
| import { ImageValidator, withErrorBoundary, MemoryMonitor, ImageError, ImageErrorType } from './image_error_handler.js'; | ||||
|  | ||||
| /** | ||||
|  * Edit operation types | ||||
|  */ | ||||
| export type EditOperation =  | ||||
|     | 'rotate' | ||||
|     | 'crop' | ||||
|     | 'brightness' | ||||
|     | 'contrast' | ||||
|     | 'saturation' | ||||
|     | 'blur' | ||||
|     | 'sharpen'; | ||||
|  | ||||
| /** | ||||
|  * Edit history entry | ||||
|  */ | ||||
| export interface EditHistoryEntry { | ||||
|     operation: EditOperation; | ||||
|     params: any; | ||||
|     timestamp: Date; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Crop area definition | ||||
|  */ | ||||
| export interface CropArea { | ||||
|     x: number; | ||||
|     y: number; | ||||
|     width: number; | ||||
|     height: number; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Editor state | ||||
|  */ | ||||
| interface EditorState { | ||||
|     originalImage: HTMLImageElement | null; | ||||
|     currentImage: HTMLImageElement | null; | ||||
|     canvas: HTMLCanvasElement; | ||||
|     context: CanvasRenderingContext2D; | ||||
|     history: EditHistoryEntry[]; | ||||
|     historyIndex: number; | ||||
|     isEditing: boolean; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Filter parameters | ||||
|  */ | ||||
| export interface FilterParams { | ||||
|     brightness?: number; // -100 to 100 | ||||
|     contrast?: number; // -100 to 100 | ||||
|     saturation?: number; // -100 to 100 | ||||
|     blur?: number; // 0 to 20 | ||||
|     sharpen?: number; // 0 to 100 | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * ImageEditorService provides basic image editing capabilities | ||||
|  */ | ||||
| class ImageEditorService { | ||||
|     private static instance: ImageEditorService; | ||||
|     private editorState: EditorState; | ||||
|     private tempCanvas: HTMLCanvasElement; | ||||
|     private tempContext: CanvasRenderingContext2D; | ||||
|     private cropOverlay?: HTMLElement; | ||||
|     private cropHandles?: HTMLElement[]; | ||||
|     private cropArea: CropArea | null = null; | ||||
|     private isDraggingCrop: boolean = false; | ||||
|     private dragStartX: number = 0; | ||||
|     private dragStartY: number = 0; | ||||
|     private currentFilters: FilterParams = {}; | ||||
|      | ||||
|     // Canvas size limits for security and memory management | ||||
|     private readonly MAX_CANVAS_SIZE = 8192; // Maximum width/height | ||||
|     private readonly MAX_CANVAS_AREA = 50000000; // 50 megapixels | ||||
|  | ||||
|     private constructor() { | ||||
|         // Initialize canvases | ||||
|         this.editorState = { | ||||
|             originalImage: null, | ||||
|             currentImage: null, | ||||
|             canvas: document.createElement('canvas'), | ||||
|             context: null as any, | ||||
|             history: [], | ||||
|             historyIndex: -1, | ||||
|             isEditing: false | ||||
|         }; | ||||
|          | ||||
|         const ctx = this.editorState.canvas.getContext('2d'); | ||||
|         if (!ctx) { | ||||
|             throw new Error('Failed to get canvas context'); | ||||
|         } | ||||
|         this.editorState.context = ctx; | ||||
|          | ||||
|         this.tempCanvas = document.createElement('canvas'); | ||||
|         const tempCtx = this.tempCanvas.getContext('2d'); | ||||
|         if (!tempCtx) { | ||||
|             throw new Error('Failed to get temp canvas context'); | ||||
|         } | ||||
|         this.tempContext = tempCtx; | ||||
|     } | ||||
|  | ||||
|     static getInstance(): ImageEditorService { | ||||
|         if (!ImageEditorService.instance) { | ||||
|             ImageEditorService.instance = new ImageEditorService(); | ||||
|         } | ||||
|         return ImageEditorService.instance; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Start editing an image | ||||
|      */ | ||||
|     async startEditing(src: string | HTMLImageElement): Promise<HTMLCanvasElement> { | ||||
|         return await withErrorBoundary(async () => { | ||||
|             // Validate input | ||||
|             if (typeof src === 'string') { | ||||
|                 ImageValidator.validateUrl(src); | ||||
|             } | ||||
|             // Load image | ||||
|             let img: HTMLImageElement; | ||||
|             if (typeof src === 'string') { | ||||
|                 img = await this.loadImage(src); | ||||
|             } else { | ||||
|                 img = src; | ||||
|             } | ||||
|              | ||||
|             // Validate image dimensions | ||||
|             ImageValidator.validateDimensions(img.naturalWidth, img.naturalHeight); | ||||
|              | ||||
|             // Check memory availability | ||||
|             const estimatedMemory = MemoryMonitor.estimateImageMemory(img.naturalWidth, img.naturalHeight); | ||||
|             if (!MemoryMonitor.checkMemoryAvailable(estimatedMemory)) { | ||||
|                 throw new ImageError( | ||||
|                     ImageErrorType.MEMORY_ERROR, | ||||
|                     'Insufficient memory to process image', | ||||
|                     { estimatedMemory } | ||||
|                 ); | ||||
|             } | ||||
|              | ||||
|             if (img.naturalWidth > this.MAX_CANVAS_SIZE ||  | ||||
|                 img.naturalHeight > this.MAX_CANVAS_SIZE || | ||||
|                 img.naturalWidth * img.naturalHeight > this.MAX_CANVAS_AREA) { | ||||
|                  | ||||
|                 // Scale down if too large | ||||
|                 const scale = Math.min( | ||||
|                     this.MAX_CANVAS_SIZE / Math.max(img.naturalWidth, img.naturalHeight), | ||||
|                     Math.sqrt(this.MAX_CANVAS_AREA / (img.naturalWidth * img.naturalHeight)) | ||||
|                 ); | ||||
|                  | ||||
|                 const scaledWidth = Math.floor(img.naturalWidth * scale); | ||||
|                 const scaledHeight = Math.floor(img.naturalHeight * scale); | ||||
|                  | ||||
|                 console.warn(`Image too large (${img.naturalWidth}x${img.naturalHeight}), scaling to ${scaledWidth}x${scaledHeight}`); | ||||
|                  | ||||
|                 // Create scaled image | ||||
|                 const scaledCanvas = document.createElement('canvas'); | ||||
|                 scaledCanvas.width = scaledWidth; | ||||
|                 scaledCanvas.height = scaledHeight; | ||||
|                 const scaledCtx = scaledCanvas.getContext('2d'); | ||||
|                 if (!scaledCtx) throw new Error('Failed to get scaled canvas context'); | ||||
|                  | ||||
|                 scaledCtx.drawImage(img, 0, 0, scaledWidth, scaledHeight); | ||||
|                  | ||||
|                 // Create new image from scaled canvas | ||||
|                 const scaledImg = new Image(); | ||||
|                 scaledImg.src = scaledCanvas.toDataURL(); | ||||
|                 await new Promise(resolve => scaledImg.onload = resolve); | ||||
|                 img = scaledImg; | ||||
|                  | ||||
|                 // Clean up scaled canvas | ||||
|                 scaledCanvas.width = 0; | ||||
|                 scaledCanvas.height = 0; | ||||
|             } | ||||
|              | ||||
|             // Store original | ||||
|             this.editorState.originalImage = img; | ||||
|             this.editorState.currentImage = img; | ||||
|             this.editorState.isEditing = true; | ||||
|             this.editorState.history = []; | ||||
|             this.editorState.historyIndex = -1; | ||||
|             this.currentFilters = {}; | ||||
|              | ||||
|             // Setup canvas with validated dimensions | ||||
|             this.editorState.canvas.width = img.naturalWidth; | ||||
|             this.editorState.canvas.height = img.naturalHeight; | ||||
|             this.editorState.context.drawImage(img, 0, 0); | ||||
|              | ||||
|             return this.editorState.canvas; | ||||
|         }, (error) => { | ||||
|             this.stopEditing(); | ||||
|             throw error; | ||||
|         }) || this.editorState.canvas; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Rotate image by degrees (90, 180, 270) | ||||
|      */ | ||||
|     rotate(degrees: 90 | 180 | 270 | -90): void { | ||||
|         if (!this.editorState.isEditing) return; | ||||
|          | ||||
|         const { canvas, context } = this.editorState; | ||||
|         const { width, height } = canvas; | ||||
|          | ||||
|         // Setup temp canvas | ||||
|         if (degrees === 90 || degrees === -90 || degrees === 270) { | ||||
|             this.tempCanvas.width = height; | ||||
|             this.tempCanvas.height = width; | ||||
|         } else { | ||||
|             this.tempCanvas.width = width; | ||||
|             this.tempCanvas.height = height; | ||||
|         } | ||||
|          | ||||
|         // Clear temp canvas | ||||
|         this.tempContext.clearRect(0, 0, this.tempCanvas.width, this.tempCanvas.height); | ||||
|          | ||||
|         // Rotate | ||||
|         this.tempContext.save(); | ||||
|          | ||||
|         if (degrees === 90) { | ||||
|             this.tempContext.translate(height, 0); | ||||
|             this.tempContext.rotate(Math.PI / 2); | ||||
|         } else if (degrees === 180) { | ||||
|             this.tempContext.translate(width, height); | ||||
|             this.tempContext.rotate(Math.PI); | ||||
|         } else if (degrees === 270 || degrees === -90) { | ||||
|             this.tempContext.translate(0, width); | ||||
|             this.tempContext.rotate(-Math.PI / 2); | ||||
|         } | ||||
|          | ||||
|         this.tempContext.drawImage(canvas, 0, 0); | ||||
|         this.tempContext.restore(); | ||||
|          | ||||
|         // Copy back to main canvas | ||||
|         canvas.width = this.tempCanvas.width; | ||||
|         canvas.height = this.tempCanvas.height; | ||||
|         context.drawImage(this.tempCanvas, 0, 0); | ||||
|          | ||||
|         // Add to history | ||||
|         this.addToHistory('rotate', { degrees }); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Start crop selection | ||||
|      */ | ||||
|     startCrop(container: HTMLElement): void { | ||||
|         if (!this.editorState.isEditing) return; | ||||
|          | ||||
|         // Create crop overlay | ||||
|         this.cropOverlay = document.createElement('div'); | ||||
|         this.cropOverlay.className = 'crop-overlay'; | ||||
|         this.cropOverlay.style.cssText = ` | ||||
|             position: absolute; | ||||
|             border: 2px dashed #fff; | ||||
|             background: rgba(0, 0, 0, 0.3); | ||||
|             cursor: move; | ||||
|             z-index: 1000; | ||||
|         `; | ||||
|          | ||||
|         // Create resize handles | ||||
|         this.cropHandles = []; | ||||
|         const handlePositions = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w']; | ||||
|          | ||||
|         handlePositions.forEach(pos => { | ||||
|             const handle = document.createElement('div'); | ||||
|             handle.className = `crop-handle crop-handle-${pos}`; | ||||
|             handle.dataset.position = pos; | ||||
|             handle.style.cssText = ` | ||||
|                 position: absolute; | ||||
|                 width: 10px; | ||||
|                 height: 10px; | ||||
|                 background: white; | ||||
|                 border: 1px solid #333; | ||||
|                 z-index: 1001; | ||||
|             `; | ||||
|              | ||||
|             // Position handles | ||||
|             switch (pos) { | ||||
|                 case 'nw': | ||||
|                     handle.style.top = '-5px'; | ||||
|                     handle.style.left = '-5px'; | ||||
|                     handle.style.cursor = 'nw-resize'; | ||||
|                     break; | ||||
|                 case 'n': | ||||
|                     handle.style.top = '-5px'; | ||||
|                     handle.style.left = '50%'; | ||||
|                     handle.style.transform = 'translateX(-50%)'; | ||||
|                     handle.style.cursor = 'n-resize'; | ||||
|                     break; | ||||
|                 case 'ne': | ||||
|                     handle.style.top = '-5px'; | ||||
|                     handle.style.right = '-5px'; | ||||
|                     handle.style.cursor = 'ne-resize'; | ||||
|                     break; | ||||
|                 case 'e': | ||||
|                     handle.style.top = '50%'; | ||||
|                     handle.style.right = '-5px'; | ||||
|                     handle.style.transform = 'translateY(-50%)'; | ||||
|                     handle.style.cursor = 'e-resize'; | ||||
|                     break; | ||||
|                 case 'se': | ||||
|                     handle.style.bottom = '-5px'; | ||||
|                     handle.style.right = '-5px'; | ||||
|                     handle.style.cursor = 'se-resize'; | ||||
|                     break; | ||||
|                 case 's': | ||||
|                     handle.style.bottom = '-5px'; | ||||
|                     handle.style.left = '50%'; | ||||
|                     handle.style.transform = 'translateX(-50%)'; | ||||
|                     handle.style.cursor = 's-resize'; | ||||
|                     break; | ||||
|                 case 'sw': | ||||
|                     handle.style.bottom = '-5px'; | ||||
|                     handle.style.left = '-5px'; | ||||
|                     handle.style.cursor = 'sw-resize'; | ||||
|                     break; | ||||
|                 case 'w': | ||||
|                     handle.style.top = '50%'; | ||||
|                     handle.style.left = '-5px'; | ||||
|                     handle.style.transform = 'translateY(-50%)'; | ||||
|                     handle.style.cursor = 'w-resize'; | ||||
|                     break; | ||||
|             } | ||||
|              | ||||
|             this.cropOverlay.appendChild(handle); | ||||
|             this.cropHandles!.push(handle); | ||||
|         }); | ||||
|          | ||||
|         // Set initial crop area (80% of image) | ||||
|         const canvasRect = this.editorState.canvas.getBoundingClientRect(); | ||||
|         const initialSize = Math.min(canvasRect.width, canvasRect.height) * 0.8; | ||||
|         const initialX = (canvasRect.width - initialSize) / 2; | ||||
|         const initialY = (canvasRect.height - initialSize) / 2; | ||||
|          | ||||
|         this.cropArea = { | ||||
|             x: initialX, | ||||
|             y: initialY, | ||||
|             width: initialSize, | ||||
|             height: initialSize | ||||
|         }; | ||||
|          | ||||
|         this.updateCropOverlay(); | ||||
|         container.appendChild(this.cropOverlay); | ||||
|          | ||||
|         // Setup drag handlers | ||||
|         this.setupCropHandlers(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Setup crop interaction handlers | ||||
|      */ | ||||
|     private setupCropHandlers(): void { | ||||
|         if (!this.cropOverlay) return; | ||||
|          | ||||
|         // Drag to move | ||||
|         this.cropOverlay.addEventListener('mousedown', (e) => { | ||||
|             if ((e.target as HTMLElement).classList.contains('crop-handle')) return; | ||||
|              | ||||
|             this.isDraggingCrop = true; | ||||
|             this.dragStartX = e.clientX; | ||||
|             this.dragStartY = e.clientY; | ||||
|              | ||||
|             const handleMove = (e: MouseEvent) => { | ||||
|                 if (!this.isDraggingCrop || !this.cropArea) return; | ||||
|                  | ||||
|                 const deltaX = e.clientX - this.dragStartX; | ||||
|                 const deltaY = e.clientY - this.dragStartY; | ||||
|                  | ||||
|                 this.cropArea.x += deltaX; | ||||
|                 this.cropArea.y += deltaY; | ||||
|                  | ||||
|                 this.dragStartX = e.clientX; | ||||
|                 this.dragStartY = e.clientY; | ||||
|                  | ||||
|                 this.updateCropOverlay(); | ||||
|             }; | ||||
|              | ||||
|             const handleUp = () => { | ||||
|                 this.isDraggingCrop = false; | ||||
|                 document.removeEventListener('mousemove', handleMove); | ||||
|                 document.removeEventListener('mouseup', handleUp); | ||||
|             }; | ||||
|              | ||||
|             document.addEventListener('mousemove', handleMove); | ||||
|             document.addEventListener('mouseup', handleUp); | ||||
|         }); | ||||
|          | ||||
|         // Resize handles | ||||
|         this.cropHandles?.forEach(handle => { | ||||
|             handle.addEventListener('mousedown', (e) => { | ||||
|                 e.stopPropagation(); | ||||
|                  | ||||
|                 const position = handle.dataset.position!; | ||||
|                 const startX = e.clientX; | ||||
|                 const startY = e.clientY; | ||||
|                 const startCrop = { ...this.cropArea! }; | ||||
|                  | ||||
|                 const handleResize = (e: MouseEvent) => { | ||||
|                     if (!this.cropArea) return; | ||||
|                      | ||||
|                     const deltaX = e.clientX - startX; | ||||
|                     const deltaY = e.clientY - startY; | ||||
|                      | ||||
|                     switch (position) { | ||||
|                         case 'nw': | ||||
|                             this.cropArea.x = startCrop.x + deltaX; | ||||
|                             this.cropArea.y = startCrop.y + deltaY; | ||||
|                             this.cropArea.width = startCrop.width - deltaX; | ||||
|                             this.cropArea.height = startCrop.height - deltaY; | ||||
|                             break; | ||||
|                         case 'n': | ||||
|                             this.cropArea.y = startCrop.y + deltaY; | ||||
|                             this.cropArea.height = startCrop.height - deltaY; | ||||
|                             break; | ||||
|                         case 'ne': | ||||
|                             this.cropArea.y = startCrop.y + deltaY; | ||||
|                             this.cropArea.width = startCrop.width + deltaX; | ||||
|                             this.cropArea.height = startCrop.height - deltaY; | ||||
|                             break; | ||||
|                         case 'e': | ||||
|                             this.cropArea.width = startCrop.width + deltaX; | ||||
|                             break; | ||||
|                         case 'se': | ||||
|                             this.cropArea.width = startCrop.width + deltaX; | ||||
|                             this.cropArea.height = startCrop.height + deltaY; | ||||
|                             break; | ||||
|                         case 's': | ||||
|                             this.cropArea.height = startCrop.height + deltaY; | ||||
|                             break; | ||||
|                         case 'sw': | ||||
|                             this.cropArea.x = startCrop.x + deltaX; | ||||
|                             this.cropArea.width = startCrop.width - deltaX; | ||||
|                             this.cropArea.height = startCrop.height + deltaY; | ||||
|                             break; | ||||
|                         case 'w': | ||||
|                             this.cropArea.x = startCrop.x + deltaX; | ||||
|                             this.cropArea.width = startCrop.width - deltaX; | ||||
|                             break; | ||||
|                     } | ||||
|                      | ||||
|                     // Ensure minimum size | ||||
|                     this.cropArea.width = Math.max(50, this.cropArea.width); | ||||
|                     this.cropArea.height = Math.max(50, this.cropArea.height); | ||||
|                      | ||||
|                     this.updateCropOverlay(); | ||||
|                 }; | ||||
|                  | ||||
|                 const handleUp = () => { | ||||
|                     document.removeEventListener('mousemove', handleResize); | ||||
|                     document.removeEventListener('mouseup', handleUp); | ||||
|                 }; | ||||
|                  | ||||
|                 document.addEventListener('mousemove', handleResize); | ||||
|                 document.addEventListener('mouseup', handleUp); | ||||
|             }); | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Update crop overlay position | ||||
|      */ | ||||
|     private updateCropOverlay(): void { | ||||
|         if (!this.cropOverlay || !this.cropArea) return; | ||||
|          | ||||
|         this.cropOverlay.style.left = `${this.cropArea.x}px`; | ||||
|         this.cropOverlay.style.top = `${this.cropArea.y}px`; | ||||
|         this.cropOverlay.style.width = `${this.cropArea.width}px`; | ||||
|         this.cropOverlay.style.height = `${this.cropArea.height}px`; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Apply crop | ||||
|      */ | ||||
|     applyCrop(): void { | ||||
|         if (!this.editorState.isEditing || !this.cropArea) return; | ||||
|          | ||||
|         const { canvas, context } = this.editorState; | ||||
|         const canvasRect = canvas.getBoundingClientRect(); | ||||
|          | ||||
|         // Convert crop area from screen to canvas coordinates | ||||
|         const scaleX = canvas.width / canvasRect.width; | ||||
|         const scaleY = canvas.height / canvasRect.height; | ||||
|          | ||||
|         const cropX = this.cropArea.x * scaleX; | ||||
|         const cropY = this.cropArea.y * scaleY; | ||||
|         const cropWidth = this.cropArea.width * scaleX; | ||||
|         const cropHeight = this.cropArea.height * scaleY; | ||||
|          | ||||
|         // Get cropped image data | ||||
|         const imageData = context.getImageData(cropX, cropY, cropWidth, cropHeight); | ||||
|          | ||||
|         // Resize canvas and put cropped image | ||||
|         canvas.width = cropWidth; | ||||
|         canvas.height = cropHeight; | ||||
|         context.putImageData(imageData, 0, 0); | ||||
|          | ||||
|         // Clean up crop overlay | ||||
|         this.cancelCrop(); | ||||
|          | ||||
|         // Add to history | ||||
|         this.addToHistory('crop', { | ||||
|             x: cropX, | ||||
|             y: cropY, | ||||
|             width: cropWidth, | ||||
|             height: cropHeight | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Cancel crop | ||||
|      */ | ||||
|     cancelCrop(): void { | ||||
|         if (this.cropOverlay) { | ||||
|             this.cropOverlay.remove(); | ||||
|             this.cropOverlay = undefined; | ||||
|         } | ||||
|         this.cropHandles = undefined; | ||||
|         this.cropArea = null; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Apply brightness adjustment | ||||
|      */ | ||||
|     applyBrightness(value: number): void { | ||||
|         if (!this.editorState.isEditing) return; | ||||
|          | ||||
|         this.currentFilters.brightness = value; | ||||
|         this.applyFilters(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Apply contrast adjustment | ||||
|      */ | ||||
|     applyContrast(value: number): void { | ||||
|         if (!this.editorState.isEditing) return; | ||||
|          | ||||
|         this.currentFilters.contrast = value; | ||||
|         this.applyFilters(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Apply saturation adjustment | ||||
|      */ | ||||
|     applySaturation(value: number): void { | ||||
|         if (!this.editorState.isEditing) return; | ||||
|          | ||||
|         this.currentFilters.saturation = value; | ||||
|         this.applyFilters(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Apply all filters | ||||
|      */ | ||||
|     private applyFilters(): void { | ||||
|         const { canvas, context, originalImage } = this.editorState; | ||||
|          | ||||
|         if (!originalImage) return; | ||||
|          | ||||
|         // Clear canvas and redraw original | ||||
|         context.clearRect(0, 0, canvas.width, canvas.height); | ||||
|         context.drawImage(originalImage, 0, 0, canvas.width, canvas.height); | ||||
|          | ||||
|         // Get image data | ||||
|         const imageData = context.getImageData(0, 0, canvas.width, canvas.height); | ||||
|         const data = imageData.data; | ||||
|          | ||||
|         // Apply brightness | ||||
|         if (this.currentFilters.brightness) { | ||||
|             const brightness = this.currentFilters.brightness * 2.55; // Convert to 0-255 range | ||||
|             for (let i = 0; i < data.length; i += 4) { | ||||
|                 data[i] = Math.min(255, Math.max(0, data[i] + brightness)); | ||||
|                 data[i + 1] = Math.min(255, Math.max(0, data[i + 1] + brightness)); | ||||
|                 data[i + 2] = Math.min(255, Math.max(0, data[i + 2] + brightness)); | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         // Apply contrast | ||||
|         if (this.currentFilters.contrast) { | ||||
|             const factor = (259 * (this.currentFilters.contrast + 255)) / (255 * (259 - this.currentFilters.contrast)); | ||||
|             for (let i = 0; i < data.length; i += 4) { | ||||
|                 data[i] = Math.min(255, Math.max(0, factor * (data[i] - 128) + 128)); | ||||
|                 data[i + 1] = Math.min(255, Math.max(0, factor * (data[i + 1] - 128) + 128)); | ||||
|                 data[i + 2] = Math.min(255, Math.max(0, factor * (data[i + 2] - 128) + 128)); | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         // Apply saturation | ||||
|         if (this.currentFilters.saturation) { | ||||
|             const saturation = this.currentFilters.saturation / 100; | ||||
|             for (let i = 0; i < data.length; i += 4) { | ||||
|                 const gray = 0.2989 * data[i] + 0.5870 * data[i + 1] + 0.1140 * data[i + 2]; | ||||
|                 data[i] = Math.min(255, Math.max(0, gray + saturation * (data[i] - gray))); | ||||
|                 data[i + 1] = Math.min(255, Math.max(0, gray + saturation * (data[i + 1] - gray))); | ||||
|                 data[i + 2] = Math.min(255, Math.max(0, gray + saturation * (data[i + 2] - gray))); | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         // Put modified image data back | ||||
|         context.putImageData(imageData, 0, 0); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Apply blur effect | ||||
|      */ | ||||
|     applyBlur(radius: number): void { | ||||
|         if (!this.editorState.isEditing) return; | ||||
|          | ||||
|         const { canvas, context } = this.editorState; | ||||
|          | ||||
|         // Use CSS filter for performance | ||||
|         context.filter = `blur(${radius}px)`; | ||||
|         context.drawImage(canvas, 0, 0); | ||||
|         context.filter = 'none'; | ||||
|          | ||||
|         this.addToHistory('blur', { radius }); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Apply sharpen effect | ||||
|      */ | ||||
|     applySharpen(amount: number): void { | ||||
|         if (!this.editorState.isEditing) return; | ||||
|          | ||||
|         const { canvas, context } = this.editorState; | ||||
|         const imageData = context.getImageData(0, 0, canvas.width, canvas.height); | ||||
|         const data = imageData.data; | ||||
|         const width = canvas.width; | ||||
|         const height = canvas.height; | ||||
|          | ||||
|         // Create copy of original data | ||||
|         const original = new Uint8ClampedArray(data); | ||||
|          | ||||
|         // Sharpen kernel | ||||
|         const kernel = [ | ||||
|             0, -1, 0, | ||||
|             -1, 5 + amount / 25, -1, | ||||
|             0, -1, 0 | ||||
|         ]; | ||||
|          | ||||
|         // Apply convolution | ||||
|         for (let y = 1; y < height - 1; y++) { | ||||
|             for (let x = 1; x < width - 1; x++) { | ||||
|                 const idx = (y * width + x) * 4; | ||||
|                  | ||||
|                 for (let c = 0; c < 3; c++) { | ||||
|                     let sum = 0; | ||||
|                     for (let ky = -1; ky <= 1; ky++) { | ||||
|                         for (let kx = -1; kx <= 1; kx++) { | ||||
|                             const kidx = ((y + ky) * width + (x + kx)) * 4; | ||||
|                             sum += original[kidx + c] * kernel[(ky + 1) * 3 + (kx + 1)]; | ||||
|                         } | ||||
|                     } | ||||
|                     data[idx + c] = Math.min(255, Math.max(0, sum)); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         context.putImageData(imageData, 0, 0); | ||||
|         this.addToHistory('sharpen', { amount }); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Undo last operation | ||||
|      */ | ||||
|     undo(): void { | ||||
|         if (!this.editorState.isEditing || this.editorState.historyIndex <= 0) return; | ||||
|          | ||||
|         this.editorState.historyIndex--; | ||||
|         this.replayHistory(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Redo operation | ||||
|      */ | ||||
|     redo(): void { | ||||
|         if (!this.editorState.isEditing ||  | ||||
|             this.editorState.historyIndex >= this.editorState.history.length - 1) return; | ||||
|          | ||||
|         this.editorState.historyIndex++; | ||||
|         this.replayHistory(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Replay history up to current index | ||||
|      */ | ||||
|     private replayHistory(): void { | ||||
|         const { canvas, context, originalImage, history, historyIndex } = this.editorState; | ||||
|          | ||||
|         if (!originalImage) return; | ||||
|          | ||||
|         // Reset to original | ||||
|         canvas.width = originalImage.naturalWidth; | ||||
|         canvas.height = originalImage.naturalHeight; | ||||
|         context.drawImage(originalImage, 0, 0); | ||||
|          | ||||
|         // Replay operations | ||||
|         for (let i = 0; i <= historyIndex; i++) { | ||||
|             const entry = history[i]; | ||||
|             // Apply operation based on entry | ||||
|             // Note: This is simplified - actual implementation would need to store and replay exact operations | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Add operation to history | ||||
|      */ | ||||
|     private addToHistory(operation: EditOperation, params: any): void { | ||||
|         // Remove any operations after current index | ||||
|         this.editorState.history = this.editorState.history.slice(0, this.editorState.historyIndex + 1); | ||||
|          | ||||
|         // Add new operation | ||||
|         this.editorState.history.push({ | ||||
|             operation, | ||||
|             params, | ||||
|             timestamp: new Date() | ||||
|         }); | ||||
|          | ||||
|         this.editorState.historyIndex++; | ||||
|          | ||||
|         // Limit history size | ||||
|         if (this.editorState.history.length > 50) { | ||||
|             this.editorState.history.shift(); | ||||
|             this.editorState.historyIndex--; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Save edited image | ||||
|      */ | ||||
|     async saveImage(noteId?: string): Promise<Blob> { | ||||
|         if (!this.editorState.isEditing) { | ||||
|             throw new ImageError( | ||||
|                 ImageErrorType.INVALID_INPUT, | ||||
|                 'No image being edited' | ||||
|             ); | ||||
|         } | ||||
|          | ||||
|         return new Promise((resolve, reject) => { | ||||
|             this.editorState.canvas.toBlob((blob) => { | ||||
|                 if (blob) { | ||||
|                     resolve(blob); | ||||
|                      | ||||
|                     if (noteId) { | ||||
|                         // Optionally save to server | ||||
|                         this.saveToServer(noteId, blob); | ||||
|                     } | ||||
|                 } else { | ||||
|                     reject(new Error('Failed to create blob')); | ||||
|                 } | ||||
|             }, 'image/png'); | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Save edited image to server | ||||
|      */ | ||||
|     private async saveToServer(noteId: string, blob: Blob): Promise<void> { | ||||
|         try { | ||||
|             const formData = new FormData(); | ||||
|             formData.append('image', blob, 'edited.png'); | ||||
|              | ||||
|             await server.upload(`notes/${noteId}/image`, formData); | ||||
|             toastService.showMessage('Image saved successfully'); | ||||
|         } catch (error) { | ||||
|             console.error('Failed to save image:', error); | ||||
|             toastService.showError('Failed to save image'); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Reset to original image | ||||
|      */ | ||||
|     reset(): void { | ||||
|         if (!this.editorState.isEditing || !this.editorState.originalImage) return; | ||||
|          | ||||
|         const { canvas, context, originalImage } = this.editorState; | ||||
|          | ||||
|         canvas.width = originalImage.naturalWidth; | ||||
|         canvas.height = originalImage.naturalHeight; | ||||
|         context.drawImage(originalImage, 0, 0); | ||||
|          | ||||
|         this.currentFilters = {}; | ||||
|         this.editorState.history = []; | ||||
|         this.editorState.historyIndex = -1; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Stop editing and clean up resources | ||||
|      */ | ||||
|     stopEditing(): void { | ||||
|         this.cancelCrop(); | ||||
|          | ||||
|         // Request garbage collection after cleanup | ||||
|         MemoryMonitor.requestGarbageCollection(); | ||||
|          | ||||
|         // Clean up canvas memory | ||||
|         if (this.editorState.canvas) { | ||||
|             this.editorState.context.clearRect(0, 0, this.editorState.canvas.width, this.editorState.canvas.height); | ||||
|             this.editorState.canvas.width = 0; | ||||
|             this.editorState.canvas.height = 0; | ||||
|         } | ||||
|          | ||||
|         if (this.tempCanvas) { | ||||
|             this.tempContext.clearRect(0, 0, this.tempCanvas.width, this.tempCanvas.height); | ||||
|             this.tempCanvas.width = 0; | ||||
|             this.tempCanvas.height = 0; | ||||
|         } | ||||
|          | ||||
|         // Release image references | ||||
|         if (this.editorState.originalImage) { | ||||
|             this.editorState.originalImage.src = ''; | ||||
|         } | ||||
|         if (this.editorState.currentImage) { | ||||
|             this.editorState.currentImage.src = ''; | ||||
|         } | ||||
|          | ||||
|         this.editorState.isEditing = false; | ||||
|         this.editorState.originalImage = null; | ||||
|         this.editorState.currentImage = null; | ||||
|         this.editorState.history = []; | ||||
|         this.editorState.historyIndex = -1; | ||||
|         this.currentFilters = {}; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Load image from URL | ||||
|      */ | ||||
|     private loadImage(src: string): Promise<HTMLImageElement> { | ||||
|         return new Promise((resolve, reject) => { | ||||
|             const img = new Image(); | ||||
|             img.crossOrigin = 'anonymous'; | ||||
|             img.onload = () => resolve(img); | ||||
|             img.onerror = () => reject(new Error(`Failed to load image: ${src}`)); | ||||
|             img.src = src; | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Check if can undo | ||||
|      */ | ||||
|     canUndo(): boolean { | ||||
|         return this.editorState.historyIndex > 0; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Check if can redo | ||||
|      */ | ||||
|     canRedo(): boolean { | ||||
|         return this.editorState.historyIndex < this.editorState.history.length - 1; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Get current canvas | ||||
|      */ | ||||
|     getCanvas(): HTMLCanvasElement { | ||||
|         return this.editorState.canvas; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Check if editing | ||||
|      */ | ||||
|     isEditing(): boolean { | ||||
|         return this.editorState.isEditing; | ||||
|     } | ||||
| } | ||||
|  | ||||
| export default ImageEditorService.getInstance(); | ||||
							
								
								
									
										369
									
								
								apps/client/src/services/image_error_handler.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										369
									
								
								apps/client/src/services/image_error_handler.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,369 @@ | ||||
| /** | ||||
|  * Error Handler for Image Processing Operations | ||||
|  * Provides error boundaries and validation for image-related operations | ||||
|  */ | ||||
|  | ||||
| import toastService from './toast.js'; | ||||
|  | ||||
| /** | ||||
|  * Error types for image operations | ||||
|  */ | ||||
| export enum ImageErrorType { | ||||
|     INVALID_INPUT = 'INVALID_INPUT', | ||||
|     SIZE_LIMIT_EXCEEDED = 'SIZE_LIMIT_EXCEEDED', | ||||
|     MEMORY_ERROR = 'MEMORY_ERROR', | ||||
|     PROCESSING_ERROR = 'PROCESSING_ERROR', | ||||
|     NETWORK_ERROR = 'NETWORK_ERROR', | ||||
|     SECURITY_ERROR = 'SECURITY_ERROR' | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Custom error class for image operations | ||||
|  */ | ||||
| export class ImageError extends Error { | ||||
|     constructor( | ||||
|         public type: ImageErrorType, | ||||
|         message: string, | ||||
|         public details?: any | ||||
|     ) { | ||||
|         super(message); | ||||
|         this.name = 'ImageError'; | ||||
|     } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Input validation utilities | ||||
|  */ | ||||
| export class ImageValidator { | ||||
|     private static readonly MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB | ||||
|     private static readonly ALLOWED_MIME_TYPES = [ | ||||
|         'image/jpeg', | ||||
|         'image/jpg', | ||||
|         'image/png', | ||||
|         'image/gif', | ||||
|         'image/webp', | ||||
|         'image/svg+xml', | ||||
|         'image/bmp' | ||||
|     ]; | ||||
|     private static readonly MAX_DIMENSION = 16384; | ||||
|     private static readonly MAX_AREA = 100000000; // 100 megapixels | ||||
|  | ||||
|     /** | ||||
|      * Validate file input | ||||
|      */ | ||||
|     static validateFile(file: File): void { | ||||
|         // Check file size | ||||
|         if (file.size > this.MAX_FILE_SIZE) { | ||||
|             throw new ImageError( | ||||
|                 ImageErrorType.SIZE_LIMIT_EXCEEDED, | ||||
|                 `File size exceeds maximum allowed size of ${this.MAX_FILE_SIZE / 1024 / 1024}MB`, | ||||
|                 { fileSize: file.size, maxSize: this.MAX_FILE_SIZE } | ||||
|             ); | ||||
|         } | ||||
|  | ||||
|         // Check MIME type | ||||
|         if (!this.ALLOWED_MIME_TYPES.includes(file.type)) { | ||||
|             throw new ImageError( | ||||
|                 ImageErrorType.INVALID_INPUT, | ||||
|                 `File type ${file.type} is not supported`, | ||||
|                 { fileType: file.type, allowedTypes: this.ALLOWED_MIME_TYPES } | ||||
|             ); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Validate image dimensions | ||||
|      */ | ||||
|     static validateDimensions(width: number, height: number): void { | ||||
|         if (width <= 0 || height <= 0) { | ||||
|             throw new ImageError( | ||||
|                 ImageErrorType.INVALID_INPUT, | ||||
|                 'Invalid image dimensions', | ||||
|                 { width, height } | ||||
|             ); | ||||
|         } | ||||
|  | ||||
|         if (width > this.MAX_DIMENSION || height > this.MAX_DIMENSION) { | ||||
|             throw new ImageError( | ||||
|                 ImageErrorType.SIZE_LIMIT_EXCEEDED, | ||||
|                 `Image dimensions exceed maximum allowed size of ${this.MAX_DIMENSION}px`, | ||||
|                 { width, height, maxDimension: this.MAX_DIMENSION } | ||||
|             ); | ||||
|         } | ||||
|  | ||||
|         if (width * height > this.MAX_AREA) { | ||||
|             throw new ImageError( | ||||
|                 ImageErrorType.SIZE_LIMIT_EXCEEDED, | ||||
|                 `Image area exceeds maximum allowed area of ${this.MAX_AREA / 1000000} megapixels`, | ||||
|                 { area: width * height, maxArea: this.MAX_AREA } | ||||
|             ); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Validate URL | ||||
|      */ | ||||
|     static validateUrl(url: string): void { | ||||
|         try { | ||||
|             const parsedUrl = new URL(url); | ||||
|              | ||||
|             // Check protocol | ||||
|             if (!['http:', 'https:', 'data:', 'blob:'].includes(parsedUrl.protocol)) { | ||||
|                 throw new ImageError( | ||||
|                     ImageErrorType.SECURITY_ERROR, | ||||
|                     `Unsupported protocol: ${parsedUrl.protocol}`, | ||||
|                     { url, protocol: parsedUrl.protocol } | ||||
|                 ); | ||||
|             } | ||||
|  | ||||
|             // Additional security checks for data URLs | ||||
|             if (parsedUrl.protocol === 'data:') { | ||||
|                 const [header] = url.split(','); | ||||
|                 if (!header.includes('image/')) { | ||||
|                     throw new ImageError( | ||||
|                         ImageErrorType.INVALID_INPUT, | ||||
|                         'Data URL does not contain image data', | ||||
|                         { url: url.substring(0, 100) } | ||||
|                     ); | ||||
|                 } | ||||
|             } | ||||
|         } catch (error) { | ||||
|             if (error instanceof ImageError) { | ||||
|                 throw error; | ||||
|             } | ||||
|             throw new ImageError( | ||||
|                 ImageErrorType.INVALID_INPUT, | ||||
|                 'Invalid URL format', | ||||
|                 { url, originalError: error } | ||||
|             ); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Sanitize filename | ||||
|      */ | ||||
|     static sanitizeFilename(filename: string): string { | ||||
|         // Remove path traversal attempts | ||||
|         filename = filename.replace(/\.\./g, ''); | ||||
|         filename = filename.replace(/[\/\\]/g, '_'); | ||||
|          | ||||
|         // Remove special characters except dots and dashes | ||||
|         filename = filename.replace(/[^a-zA-Z0-9._-]/g, '_'); | ||||
|          | ||||
|         // Limit length | ||||
|         if (filename.length > 255) { | ||||
|             const ext = filename.split('.').pop(); | ||||
|             filename = filename.substring(0, 250) + '.' + ext; | ||||
|         } | ||||
|          | ||||
|         return filename; | ||||
|     } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Error boundary wrapper for async operations | ||||
|  */ | ||||
| export async function withErrorBoundary<T>( | ||||
|     operation: () => Promise<T>, | ||||
|     errorHandler?: (error: Error) => void | ||||
| ): Promise<T | null> { | ||||
|     try { | ||||
|         return await operation(); | ||||
|     } catch (error) { | ||||
|         const imageError = error instanceof ImageError  | ||||
|             ? error  | ||||
|             : new ImageError( | ||||
|                 ImageErrorType.PROCESSING_ERROR, | ||||
|                 error instanceof Error ? error.message : 'Unknown error occurred', | ||||
|                 { originalError: error } | ||||
|             ); | ||||
|  | ||||
|         // Log error | ||||
|         console.error('[Image Error]', imageError.type, imageError.message, imageError.details); | ||||
|  | ||||
|         // Show user-friendly message | ||||
|         switch (imageError.type) { | ||||
|             case ImageErrorType.SIZE_LIMIT_EXCEEDED: | ||||
|                 toastService.showError('Image is too large to process'); | ||||
|                 break; | ||||
|             case ImageErrorType.INVALID_INPUT: | ||||
|                 toastService.showError('Invalid image or input provided'); | ||||
|                 break; | ||||
|             case ImageErrorType.MEMORY_ERROR: | ||||
|                 toastService.showError('Not enough memory to process image'); | ||||
|                 break; | ||||
|             case ImageErrorType.SECURITY_ERROR: | ||||
|                 toastService.showError('Security violation detected'); | ||||
|                 break; | ||||
|             case ImageErrorType.NETWORK_ERROR: | ||||
|                 toastService.showError('Network error occurred'); | ||||
|                 break; | ||||
|             default: | ||||
|                 toastService.showError('Failed to process image'); | ||||
|         } | ||||
|  | ||||
|         // Call custom error handler if provided | ||||
|         if (errorHandler) { | ||||
|             errorHandler(imageError); | ||||
|         } | ||||
|  | ||||
|         return null; | ||||
|     } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Memory monitoring utilities | ||||
|  */ | ||||
| export class MemoryMonitor { | ||||
|     private static readonly WARNING_THRESHOLD = 0.8; // 80% of available memory | ||||
|      | ||||
|     /** | ||||
|      * Check if memory is available for operation | ||||
|      */ | ||||
|     static checkMemoryAvailable(estimatedBytes: number): boolean { | ||||
|         if ('memory' in performance && (performance as any).memory) { | ||||
|             const memory = (performance as any).memory; | ||||
|             const used = memory.usedJSHeapSize; | ||||
|             const limit = memory.jsHeapSizeLimit; | ||||
|             const available = limit - used; | ||||
|              | ||||
|             if (estimatedBytes > available * this.WARNING_THRESHOLD) { | ||||
|                 console.warn(`Memory warning: Estimated ${estimatedBytes} bytes needed, ${available} bytes available`); | ||||
|                 return false; | ||||
|             } | ||||
|         } | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Estimate memory needed for image | ||||
|      */ | ||||
|     static estimateImageMemory(width: number, height: number, channels: number = 4): number { | ||||
|         // Each pixel uses 4 bytes (RGBA) or specified channels | ||||
|         return width * height * channels; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Force garbage collection if available | ||||
|      */ | ||||
|     static requestGarbageCollection(): void { | ||||
|         if (typeof (globalThis as any).gc === 'function') { | ||||
|             (globalThis as any).gc(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Web Worker support for heavy operations | ||||
|  */ | ||||
| export class ImageWorkerPool { | ||||
|     private workers: Worker[] = []; | ||||
|     private taskQueue: Array<{ | ||||
|         data: any; | ||||
|         resolve: (value: any) => void; | ||||
|         reject: (error: any) => void; | ||||
|     }> = []; | ||||
|     private busyWorkers = new Set<Worker>(); | ||||
|  | ||||
|     constructor( | ||||
|         private workerScript: string, | ||||
|         private poolSize: number = navigator.hardwareConcurrency || 4 | ||||
|     ) { | ||||
|         this.initializeWorkers(); | ||||
|     } | ||||
|  | ||||
|     private initializeWorkers(): void { | ||||
|         for (let i = 0; i < this.poolSize; i++) { | ||||
|             try { | ||||
|                 const worker = new Worker(this.workerScript); | ||||
|                 worker.addEventListener('message', (e) => this.handleWorkerMessage(worker, e)); | ||||
|                 worker.addEventListener('error', (e) => this.handleWorkerError(worker, e)); | ||||
|                 this.workers.push(worker); | ||||
|             } catch (error) { | ||||
|                 console.error('Failed to create worker:', error); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private handleWorkerMessage(worker: Worker, event: MessageEvent): void { | ||||
|         this.busyWorkers.delete(worker); | ||||
|          | ||||
|         // Process next task if available | ||||
|         if (this.taskQueue.length > 0) { | ||||
|             const task = this.taskQueue.shift()!; | ||||
|             this.executeTask(worker, task); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private handleWorkerError(worker: Worker, event: ErrorEvent): void { | ||||
|         this.busyWorkers.delete(worker); | ||||
|         console.error('Worker error:', event); | ||||
|     } | ||||
|  | ||||
|     private executeTask( | ||||
|         worker: Worker, | ||||
|         task: { data: any; resolve: (value: any) => void; reject: (error: any) => void } | ||||
|     ): void { | ||||
|         this.busyWorkers.add(worker); | ||||
|          | ||||
|         const messageHandler = (e: MessageEvent) => { | ||||
|             worker.removeEventListener('message', messageHandler); | ||||
|             worker.removeEventListener('error', errorHandler); | ||||
|             this.busyWorkers.delete(worker); | ||||
|             task.resolve(e.data); | ||||
|              | ||||
|             // Process next task | ||||
|             if (this.taskQueue.length > 0) { | ||||
|                 const nextTask = this.taskQueue.shift()!; | ||||
|                 this.executeTask(worker, nextTask); | ||||
|             } | ||||
|         }; | ||||
|          | ||||
|         const errorHandler = (e: ErrorEvent) => { | ||||
|             worker.removeEventListener('message', messageHandler); | ||||
|             worker.removeEventListener('error', errorHandler); | ||||
|             this.busyWorkers.delete(worker); | ||||
|             task.reject(e); | ||||
|              | ||||
|             // Process next task | ||||
|             if (this.taskQueue.length > 0) { | ||||
|                 const nextTask = this.taskQueue.shift()!; | ||||
|                 this.executeTask(worker, nextTask); | ||||
|             } | ||||
|         }; | ||||
|          | ||||
|         worker.addEventListener('message', messageHandler); | ||||
|         worker.addEventListener('error', errorHandler); | ||||
|         worker.postMessage(task.data); | ||||
|     } | ||||
|  | ||||
|     async process(data: any): Promise<any> { | ||||
|         return new Promise((resolve, reject) => { | ||||
|             // Find available worker | ||||
|             const availableWorker = this.workers.find(w => !this.busyWorkers.has(w)); | ||||
|              | ||||
|             if (availableWorker) { | ||||
|                 this.executeTask(availableWorker, { data, resolve, reject }); | ||||
|             } else { | ||||
|                 // Queue task | ||||
|                 this.taskQueue.push({ data, resolve, reject }); | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     terminate(): void { | ||||
|         this.workers.forEach(worker => worker.terminate()); | ||||
|         this.workers = []; | ||||
|         this.taskQueue = []; | ||||
|         this.busyWorkers.clear(); | ||||
|     } | ||||
| } | ||||
|  | ||||
| export default { | ||||
|     ImageError, | ||||
|     ImageErrorType, | ||||
|     ImageValidator, | ||||
|     MemoryMonitor, | ||||
|     ImageWorkerPool, | ||||
|     withErrorBoundary | ||||
| }; | ||||
							
								
								
									
										839
									
								
								apps/client/src/services/image_exif.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										839
									
								
								apps/client/src/services/image_exif.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,839 @@ | ||||
| /** | ||||
|  * EXIF Data Viewer Module for Trilium Notes | ||||
|  * Extracts and displays EXIF metadata from images | ||||
|  */ | ||||
|  | ||||
| /** | ||||
|  * EXIF data structure | ||||
|  */ | ||||
| export interface ExifData { | ||||
|     // Image information | ||||
|     make?: string; | ||||
|     model?: string; | ||||
|     software?: string; | ||||
|     dateTime?: Date; | ||||
|     dateTimeOriginal?: Date; | ||||
|     dateTimeDigitized?: Date; | ||||
|      | ||||
|     // Camera settings | ||||
|     exposureTime?: string; | ||||
|     fNumber?: number; | ||||
|     exposureProgram?: string; | ||||
|     iso?: number; | ||||
|     shutterSpeedValue?: string; | ||||
|     apertureValue?: number; | ||||
|     brightnessValue?: number; | ||||
|     exposureBiasValue?: number; | ||||
|     maxApertureValue?: number; | ||||
|     meteringMode?: string; | ||||
|     flash?: string; | ||||
|     focalLength?: number; | ||||
|     focalLengthIn35mm?: number; | ||||
|      | ||||
|     // Image properties | ||||
|     imageWidth?: number; | ||||
|     imageHeight?: number; | ||||
|     orientation?: number; | ||||
|     xResolution?: number; | ||||
|     yResolution?: number; | ||||
|     resolutionUnit?: string; | ||||
|     colorSpace?: string; | ||||
|     whiteBalance?: string; | ||||
|      | ||||
|     // GPS information | ||||
|     gpsLatitude?: number; | ||||
|     gpsLongitude?: number; | ||||
|     gpsAltitude?: number; | ||||
|     gpsTimestamp?: Date; | ||||
|     gpsSpeed?: number; | ||||
|     gpsDirection?: number; | ||||
|      | ||||
|     // Other metadata | ||||
|     artist?: string; | ||||
|     copyright?: string; | ||||
|     userComment?: string; | ||||
|     imageDescription?: string; | ||||
|     lensModel?: string; | ||||
|     lensMake?: string; | ||||
|      | ||||
|     // Raw data | ||||
|     raw?: Record<string, any>; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * EXIF tag definitions | ||||
|  */ | ||||
| const EXIF_TAGS: Record<number, string> = { | ||||
|     0x010F: 'make', | ||||
|     0x0110: 'model', | ||||
|     0x0131: 'software', | ||||
|     0x0132: 'dateTime', | ||||
|     0x829A: 'exposureTime', | ||||
|     0x829D: 'fNumber', | ||||
|     0x8822: 'exposureProgram', | ||||
|     0x8827: 'iso', | ||||
|     0x9003: 'dateTimeOriginal', | ||||
|     0x9004: 'dateTimeDigitized', | ||||
|     0x9201: 'shutterSpeedValue', | ||||
|     0x9202: 'apertureValue', | ||||
|     0x9203: 'brightnessValue', | ||||
|     0x9204: 'exposureBiasValue', | ||||
|     0x9205: 'maxApertureValue', | ||||
|     0x9207: 'meteringMode', | ||||
|     0x9209: 'flash', | ||||
|     0x920A: 'focalLength', | ||||
|     0xA002: 'imageWidth', | ||||
|     0xA003: 'imageHeight', | ||||
|     0x0112: 'orientation', | ||||
|     0x011A: 'xResolution', | ||||
|     0x011B: 'yResolution', | ||||
|     0x0128: 'resolutionUnit', | ||||
|     0xA001: 'colorSpace', | ||||
|     0xA403: 'whiteBalance', | ||||
|     0x8298: 'copyright', | ||||
|     0x013B: 'artist', | ||||
|     0x9286: 'userComment', | ||||
|     0x010E: 'imageDescription', | ||||
|     0xA434: 'lensModel', | ||||
|     0xA433: 'lensMake', | ||||
|     0xA432: 'focalLengthIn35mm' | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * GPS tag definitions | ||||
|  */ | ||||
| const GPS_TAGS: Record<number, string> = { | ||||
|     0x0001: 'gpsLatitudeRef', | ||||
|     0x0002: 'gpsLatitude', | ||||
|     0x0003: 'gpsLongitudeRef', | ||||
|     0x0004: 'gpsLongitude', | ||||
|     0x0005: 'gpsAltitudeRef', | ||||
|     0x0006: 'gpsAltitude', | ||||
|     0x0007: 'gpsTimestamp', | ||||
|     0x000D: 'gpsSpeed', | ||||
|     0x0010: 'gpsDirection' | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * ImageExifService extracts and manages EXIF metadata from images | ||||
|  */ | ||||
| class ImageExifService { | ||||
|     private static instance: ImageExifService; | ||||
|     private exifCache: Map<string, ExifData> = new Map(); | ||||
|     private cacheOrder: string[] = []; // Track cache insertion order for LRU | ||||
|     private readonly MAX_CACHE_SIZE = 50; // Maximum number of cached entries | ||||
|     private readonly MAX_BUFFER_SIZE = 100 * 1024 * 1024; // 100MB max buffer size | ||||
|  | ||||
|     private constructor() {} | ||||
|  | ||||
|     static getInstance(): ImageExifService { | ||||
|         if (!ImageExifService.instance) { | ||||
|             ImageExifService.instance = new ImageExifService(); | ||||
|         } | ||||
|         return ImageExifService.instance; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Extract EXIF data from image URL or file | ||||
|      */ | ||||
|     async extractExifData(source: string | File | Blob): Promise<ExifData | null> { | ||||
|         try { | ||||
|             // Check cache if URL | ||||
|             if (typeof source === 'string' && this.exifCache.has(source)) { | ||||
|                 // Move to end for LRU | ||||
|                 this.updateCacheOrder(source); | ||||
|                 return this.exifCache.get(source)!; | ||||
|             } | ||||
|  | ||||
|             // Get array buffer with size validation | ||||
|             const buffer = await this.getArrayBuffer(source); | ||||
|              | ||||
|             // Validate buffer size | ||||
|             if (buffer.byteLength > this.MAX_BUFFER_SIZE) { | ||||
|                 console.error('Buffer size exceeds maximum allowed size'); | ||||
|                 return null; | ||||
|             } | ||||
|              | ||||
|             // Parse EXIF data | ||||
|             const exifData = this.parseExifData(buffer); | ||||
|              | ||||
|             // Cache if URL with LRU eviction | ||||
|             if (typeof source === 'string' && exifData) { | ||||
|                 this.addToCache(source, exifData); | ||||
|             } | ||||
|              | ||||
|             return exifData; | ||||
|         } catch (error) { | ||||
|             console.error('Failed to extract EXIF data:', error); | ||||
|             return null; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Get array buffer from various sources | ||||
|      */ | ||||
|     private async getArrayBuffer(source: string | File | Blob): Promise<ArrayBuffer> { | ||||
|         if (source instanceof File || source instanceof Blob) { | ||||
|             return source.arrayBuffer(); | ||||
|         } else { | ||||
|             const response = await fetch(source); | ||||
|             return response.arrayBuffer(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Parse EXIF data from array buffer | ||||
|      */ | ||||
|     private parseExifData(buffer: ArrayBuffer): ExifData | null { | ||||
|         const dataView = new DataView(buffer); | ||||
|          | ||||
|         // Check for JPEG SOI marker | ||||
|         if (dataView.getUint16(0) !== 0xFFD8) { | ||||
|             return null; // Not a JPEG | ||||
|         } | ||||
|  | ||||
|         // Find APP1 marker (EXIF) | ||||
|         let offset = 2; | ||||
|         let marker; | ||||
|          | ||||
|         while (offset < dataView.byteLength) { | ||||
|             marker = dataView.getUint16(offset); | ||||
|              | ||||
|             if (marker === 0xFFE1) { | ||||
|                 // Found EXIF marker | ||||
|                 return this.parseExifSegment(dataView, offset + 2); | ||||
|             } | ||||
|              | ||||
|             if ((marker & 0xFF00) !== 0xFF00) { | ||||
|                 break; // Invalid marker | ||||
|             } | ||||
|              | ||||
|             offset += 2 + dataView.getUint16(offset + 2); | ||||
|         } | ||||
|          | ||||
|         return null; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Parse EXIF segment with bounds checking | ||||
|      */ | ||||
|     private parseExifSegment(dataView: DataView, offset: number): ExifData | null { | ||||
|         // Bounds check | ||||
|         if (offset + 2 > dataView.byteLength) { | ||||
|             console.error('Invalid offset for EXIF segment'); | ||||
|             return null; | ||||
|         } | ||||
|          | ||||
|         const length = dataView.getUint16(offset); | ||||
|          | ||||
|         // Validate segment length | ||||
|         if (offset + length > dataView.byteLength) { | ||||
|             console.error('EXIF segment length exceeds buffer size'); | ||||
|             return null; | ||||
|         } | ||||
|          | ||||
|         // Check for "Exif\0\0" identifier with bounds check | ||||
|         if (offset + 6 > dataView.byteLength) { | ||||
|             console.error('Invalid EXIF header offset'); | ||||
|             return null; | ||||
|         } | ||||
|          | ||||
|         const exifHeader = String.fromCharCode( | ||||
|             dataView.getUint8(offset + 2), | ||||
|             dataView.getUint8(offset + 3), | ||||
|             dataView.getUint8(offset + 4), | ||||
|             dataView.getUint8(offset + 5) | ||||
|         ); | ||||
|          | ||||
|         if (exifHeader !== 'Exif') { | ||||
|             return null; | ||||
|         } | ||||
|          | ||||
|         // TIFF header offset | ||||
|         const tiffOffset = offset + 8; | ||||
|          | ||||
|         // Check byte order | ||||
|         const byteOrder = dataView.getUint16(tiffOffset); | ||||
|         const littleEndian = byteOrder === 0x4949; // 'II' for Intel | ||||
|          | ||||
|         if (byteOrder !== 0x4949 && byteOrder !== 0x4D4D) { | ||||
|             return null; // Invalid byte order | ||||
|         } | ||||
|          | ||||
|         // Parse IFD | ||||
|         const ifdOffset = this.getUint32(dataView, tiffOffset + 4, littleEndian); | ||||
|         const exifData = this.parseIFD(dataView, tiffOffset, tiffOffset + ifdOffset, littleEndian); | ||||
|          | ||||
|         // Parse GPS data if available | ||||
|         if (exifData.raw?.gpsIFDPointer) { | ||||
|             const gpsData = this.parseGPSIFD( | ||||
|                 dataView, | ||||
|                 tiffOffset, | ||||
|                 tiffOffset + exifData.raw.gpsIFDPointer, | ||||
|                 littleEndian | ||||
|             ); | ||||
|             Object.assign(exifData, gpsData); | ||||
|         } | ||||
|          | ||||
|         return this.formatExifData(exifData); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Parse IFD (Image File Directory) with bounds checking | ||||
|      */ | ||||
|     private parseIFD( | ||||
|         dataView: DataView, | ||||
|         tiffOffset: number, | ||||
|         ifdOffset: number, | ||||
|         littleEndian: boolean | ||||
|     ): ExifData { | ||||
|         // Bounds check for IFD offset | ||||
|         if (ifdOffset + 2 > dataView.byteLength) { | ||||
|             console.error('Invalid IFD offset'); | ||||
|             return { raw: {} }; | ||||
|         } | ||||
|          | ||||
|         const numEntries = this.getUint16(dataView, ifdOffset, littleEndian); | ||||
|          | ||||
|         // Validate number of entries | ||||
|         if (numEntries > 1000) { // Reasonable limit | ||||
|             console.error('Too many IFD entries'); | ||||
|             return { raw: {} }; | ||||
|         } | ||||
|          | ||||
|         const data: ExifData = { raw: {} }; | ||||
|          | ||||
|         for (let i = 0; i < numEntries; i++) { | ||||
|             const entryOffset = ifdOffset + 2 + (i * 12); | ||||
|              | ||||
|             // Bounds check for entry | ||||
|             if (entryOffset + 12 > dataView.byteLength) { | ||||
|                 console.warn('IFD entry exceeds buffer bounds'); | ||||
|                 break; | ||||
|             } | ||||
|              | ||||
|             const tag = this.getUint16(dataView, entryOffset, littleEndian); | ||||
|             const type = this.getUint16(dataView, entryOffset + 2, littleEndian); | ||||
|             const count = this.getUint32(dataView, entryOffset + 4, littleEndian); | ||||
|             const valueOffset = entryOffset + 8; | ||||
|              | ||||
|             const value = this.getTagValue( | ||||
|                 dataView, | ||||
|                 tiffOffset, | ||||
|                 type, | ||||
|                 count, | ||||
|                 valueOffset, | ||||
|                 littleEndian | ||||
|             ); | ||||
|              | ||||
|             const tagName = EXIF_TAGS[tag]; | ||||
|             if (tagName) { | ||||
|                 (data as any)[tagName] = value; | ||||
|             } | ||||
|              | ||||
|             // Store raw value | ||||
|             data.raw![tag] = value; | ||||
|              | ||||
|             // Check for EXIF IFD pointer | ||||
|             if (tag === 0x8769) { | ||||
|                 const exifIFDOffset = tiffOffset + value; | ||||
|                 const exifData = this.parseIFD(dataView, tiffOffset, exifIFDOffset, littleEndian); | ||||
|                 Object.assign(data, exifData); | ||||
|             } | ||||
|              | ||||
|             // Store GPS IFD pointer | ||||
|             if (tag === 0x8825) { | ||||
|                 data.raw!.gpsIFDPointer = value; | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         return data; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Parse GPS IFD | ||||
|      */ | ||||
|     private parseGPSIFD( | ||||
|         dataView: DataView, | ||||
|         tiffOffset: number, | ||||
|         ifdOffset: number, | ||||
|         littleEndian: boolean | ||||
|     ): Partial<ExifData> { | ||||
|         const numEntries = this.getUint16(dataView, ifdOffset, littleEndian); | ||||
|         const gpsData: any = {}; | ||||
|          | ||||
|         for (let i = 0; i < numEntries; i++) { | ||||
|             const entryOffset = ifdOffset + 2 + (i * 12); | ||||
|              | ||||
|             // Bounds check for entry | ||||
|             if (entryOffset + 12 > dataView.byteLength) { | ||||
|                 console.warn('IFD entry exceeds buffer bounds'); | ||||
|                 break; | ||||
|             } | ||||
|              | ||||
|             const tag = this.getUint16(dataView, entryOffset, littleEndian); | ||||
|             const type = this.getUint16(dataView, entryOffset + 2, littleEndian); | ||||
|             const count = this.getUint32(dataView, entryOffset + 4, littleEndian); | ||||
|             const valueOffset = entryOffset + 8; | ||||
|              | ||||
|             const value = this.getTagValue( | ||||
|                 dataView, | ||||
|                 tiffOffset, | ||||
|                 type, | ||||
|                 count, | ||||
|                 valueOffset, | ||||
|                 littleEndian | ||||
|             ); | ||||
|              | ||||
|             const tagName = GPS_TAGS[tag]; | ||||
|             if (tagName) { | ||||
|                 gpsData[tagName] = value; | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         // Convert GPS coordinates | ||||
|         const result: Partial<ExifData> = {}; | ||||
|          | ||||
|         if (gpsData.gpsLatitude && gpsData.gpsLatitudeRef) { | ||||
|             result.gpsLatitude = this.convertGPSCoordinate( | ||||
|                 gpsData.gpsLatitude, | ||||
|                 gpsData.gpsLatitudeRef | ||||
|             ); | ||||
|         } | ||||
|          | ||||
|         if (gpsData.gpsLongitude && gpsData.gpsLongitudeRef) { | ||||
|             result.gpsLongitude = this.convertGPSCoordinate( | ||||
|                 gpsData.gpsLongitude, | ||||
|                 gpsData.gpsLongitudeRef | ||||
|             ); | ||||
|         } | ||||
|          | ||||
|         if (gpsData.gpsAltitude) { | ||||
|             result.gpsAltitude = gpsData.gpsAltitude; | ||||
|         } | ||||
|          | ||||
|         return result; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Get tag value based on type | ||||
|      */ | ||||
|     private getTagValue( | ||||
|         dataView: DataView, | ||||
|         tiffOffset: number, | ||||
|         type: number, | ||||
|         count: number, | ||||
|         offset: number, | ||||
|         littleEndian: boolean | ||||
|     ): any { | ||||
|         switch (type) { | ||||
|             case 1: // BYTE | ||||
|             case 7: // UNDEFINED | ||||
|                 if (count === 1) { | ||||
|                     return dataView.getUint8(offset); | ||||
|                 } | ||||
|                 const bytes = []; | ||||
|                 for (let i = 0; i < count; i++) { | ||||
|                     bytes.push(dataView.getUint8(offset + i)); | ||||
|                 } | ||||
|                 return bytes; | ||||
|                  | ||||
|             case 2: // ASCII | ||||
|                 const stringOffset = count > 4  | ||||
|                     ? tiffOffset + this.getUint32(dataView, offset, littleEndian) | ||||
|                     : offset; | ||||
|                 let str = ''; | ||||
|                 for (let i = 0; i < count - 1; i++) { | ||||
|                     const char = dataView.getUint8(stringOffset + i); | ||||
|                     if (char === 0) break; | ||||
|                     str += String.fromCharCode(char); | ||||
|                 } | ||||
|                 return str; | ||||
|                  | ||||
|             case 3: // SHORT | ||||
|                 if (count === 1) { | ||||
|                     return this.getUint16(dataView, offset, littleEndian); | ||||
|                 } | ||||
|                 const shorts = []; | ||||
|                 const shortOffset = count > 2 | ||||
|                     ? tiffOffset + this.getUint32(dataView, offset, littleEndian) | ||||
|                     : offset; | ||||
|                 for (let i = 0; i < count; i++) { | ||||
|                     shorts.push(this.getUint16(dataView, shortOffset + i * 2, littleEndian)); | ||||
|                 } | ||||
|                 return shorts; | ||||
|                  | ||||
|             case 4: // LONG | ||||
|                 if (count === 1) { | ||||
|                     return this.getUint32(dataView, offset, littleEndian); | ||||
|                 } | ||||
|                 const longs = []; | ||||
|                 const longOffset = tiffOffset + this.getUint32(dataView, offset, littleEndian); | ||||
|                 for (let i = 0; i < count; i++) { | ||||
|                     longs.push(this.getUint32(dataView, longOffset + i * 4, littleEndian)); | ||||
|                 } | ||||
|                 return longs; | ||||
|                  | ||||
|             case 5: // RATIONAL | ||||
|                 const ratOffset = tiffOffset + this.getUint32(dataView, offset, littleEndian); | ||||
|                 if (count === 1) { | ||||
|                     const num = this.getUint32(dataView, ratOffset, littleEndian); | ||||
|                     const den = this.getUint32(dataView, ratOffset + 4, littleEndian); | ||||
|                     return den === 0 ? 0 : num / den; | ||||
|                 } | ||||
|                 const rationals = []; | ||||
|                 for (let i = 0; i < count; i++) { | ||||
|                     const num = this.getUint32(dataView, ratOffset + i * 8, littleEndian); | ||||
|                     const den = this.getUint32(dataView, ratOffset + i * 8 + 4, littleEndian); | ||||
|                     rationals.push(den === 0 ? 0 : num / den); | ||||
|                 } | ||||
|                 return rationals; | ||||
|                  | ||||
|             default: | ||||
|                 return null; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Convert GPS coordinate to decimal degrees | ||||
|      */ | ||||
|     private convertGPSCoordinate(coord: number[], ref: string): number { | ||||
|         if (!coord || coord.length !== 3) return 0; | ||||
|          | ||||
|         const degrees = coord[0]; | ||||
|         const minutes = coord[1]; | ||||
|         const seconds = coord[2]; | ||||
|          | ||||
|         let decimal = degrees + minutes / 60 + seconds / 3600; | ||||
|          | ||||
|         if (ref === 'S' || ref === 'W') { | ||||
|             decimal = -decimal; | ||||
|         } | ||||
|          | ||||
|         return decimal; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Format EXIF data for display | ||||
|      */ | ||||
|     private formatExifData(data: ExifData): ExifData { | ||||
|         const formatted: ExifData = { ...data }; | ||||
|          | ||||
|         // Format dates | ||||
|         if (formatted.dateTime) { | ||||
|             formatted.dateTime = this.parseExifDate(formatted.dateTime as any); | ||||
|         } | ||||
|         if (formatted.dateTimeOriginal) { | ||||
|             formatted.dateTimeOriginal = this.parseExifDate(formatted.dateTimeOriginal as any); | ||||
|         } | ||||
|         if (formatted.dateTimeDigitized) { | ||||
|             formatted.dateTimeDigitized = this.parseExifDate(formatted.dateTimeDigitized as any); | ||||
|         } | ||||
|          | ||||
|         // Format exposure time | ||||
|         if (formatted.exposureTime) { | ||||
|             const time = formatted.exposureTime as any; | ||||
|             if (typeof time === 'number') { | ||||
|                 if (time < 1) { | ||||
|                     formatted.exposureTime = `1/${Math.round(1 / time)}`; | ||||
|                 } else { | ||||
|                     formatted.exposureTime = `${time}s`; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         // Format exposure program | ||||
|         if (formatted.exposureProgram) { | ||||
|             const programs = [ | ||||
|                 'Not defined', | ||||
|                 'Manual', | ||||
|                 'Normal program', | ||||
|                 'Aperture priority', | ||||
|                 'Shutter priority', | ||||
|                 'Creative program', | ||||
|                 'Action program', | ||||
|                 'Portrait mode', | ||||
|                 'Landscape mode' | ||||
|             ]; | ||||
|             const index = formatted.exposureProgram as any; | ||||
|             formatted.exposureProgram = programs[index] || 'Unknown'; | ||||
|         } | ||||
|          | ||||
|         // Format metering mode | ||||
|         if (formatted.meteringMode) { | ||||
|             const modes = [ | ||||
|                 'Unknown', | ||||
|                 'Average', | ||||
|                 'Center-weighted average', | ||||
|                 'Spot', | ||||
|                 'Multi-spot', | ||||
|                 'Pattern', | ||||
|                 'Partial' | ||||
|             ]; | ||||
|             const index = formatted.meteringMode as any; | ||||
|             formatted.meteringMode = modes[index] || 'Unknown'; | ||||
|         } | ||||
|          | ||||
|         // Format flash | ||||
|         if (formatted.flash !== undefined) { | ||||
|             const flash = formatted.flash as any; | ||||
|             formatted.flash = (flash & 1) ? 'Flash fired' : 'Flash did not fire'; | ||||
|         } | ||||
|          | ||||
|         return formatted; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Parse EXIF date string | ||||
|      */ | ||||
|     private parseExifDate(dateStr: string): Date { | ||||
|         // EXIF date format: "YYYY:MM:DD HH:MM:SS" | ||||
|         const parts = dateStr.split(' '); | ||||
|         if (parts.length !== 2) return new Date(dateStr); | ||||
|          | ||||
|         const dateParts = parts[0].split(':'); | ||||
|         const timeParts = parts[1].split(':'); | ||||
|          | ||||
|         if (dateParts.length !== 3 || timeParts.length !== 3) { | ||||
|             return new Date(dateStr); | ||||
|         } | ||||
|          | ||||
|         return new Date( | ||||
|             parseInt(dateParts[0]), | ||||
|             parseInt(dateParts[1]) - 1, | ||||
|             parseInt(dateParts[2]), | ||||
|             parseInt(timeParts[0]), | ||||
|             parseInt(timeParts[1]), | ||||
|             parseInt(timeParts[2]) | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Get uint16 with endianness and bounds checking | ||||
|      */ | ||||
|     private getUint16(dataView: DataView, offset: number, littleEndian: boolean): number { | ||||
|         if (offset + 2 > dataView.byteLength) { | ||||
|             console.error('Uint16 read exceeds buffer bounds'); | ||||
|             return 0; | ||||
|         } | ||||
|         return dataView.getUint16(offset, littleEndian); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Get uint32 with endianness and bounds checking | ||||
|      */ | ||||
|     private getUint32(dataView: DataView, offset: number, littleEndian: boolean): number { | ||||
|         if (offset + 4 > dataView.byteLength) { | ||||
|             console.error('Uint32 read exceeds buffer bounds'); | ||||
|             return 0; | ||||
|         } | ||||
|         return dataView.getUint32(offset, littleEndian); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Create EXIF display panel | ||||
|      */ | ||||
|     createExifPanel(exifData: ExifData): HTMLElement { | ||||
|         const panel = document.createElement('div'); | ||||
|         panel.className = 'exif-panel'; | ||||
|         panel.style.cssText = ` | ||||
|             background: rgba(0, 0, 0, 0.9); | ||||
|             color: white; | ||||
|             padding: 15px; | ||||
|             border-radius: 8px; | ||||
|             max-width: 400px; | ||||
|             max-height: 500px; | ||||
|             overflow-y: auto; | ||||
|             font-size: 12px; | ||||
|         `; | ||||
|  | ||||
|         const sections = [ | ||||
|             { | ||||
|                 title: 'Camera', | ||||
|                 fields: ['make', 'model', 'lensModel'] | ||||
|             }, | ||||
|             { | ||||
|                 title: 'Settings', | ||||
|                 fields: ['exposureTime', 'fNumber', 'iso', 'focalLength', 'exposureProgram', 'meteringMode', 'flash'] | ||||
|             }, | ||||
|             { | ||||
|                 title: 'Image', | ||||
|                 fields: ['imageWidth', 'imageHeight', 'orientation', 'colorSpace', 'whiteBalance'] | ||||
|             }, | ||||
|             { | ||||
|                 title: 'Date/Time', | ||||
|                 fields: ['dateTimeOriginal', 'dateTime'] | ||||
|             }, | ||||
|             { | ||||
|                 title: 'Location', | ||||
|                 fields: ['gpsLatitude', 'gpsLongitude', 'gpsAltitude'] | ||||
|             }, | ||||
|             { | ||||
|                 title: 'Other', | ||||
|                 fields: ['software', 'artist', 'copyright', 'imageDescription'] | ||||
|             } | ||||
|         ]; | ||||
|  | ||||
|         sections.forEach(section => { | ||||
|             const hasData = section.fields.some(field => (exifData as any)[field]); | ||||
|             if (!hasData) return; | ||||
|  | ||||
|             const sectionDiv = document.createElement('div'); | ||||
|             sectionDiv.style.marginBottom = '15px'; | ||||
|              | ||||
|             const title = document.createElement('h4'); | ||||
|             // Use textContent for safe title insertion | ||||
|             title.textContent = section.title; | ||||
|             title.style.cssText = 'margin: 0 0 8px 0; color: #4CAF50;'; | ||||
|             title.setAttribute('aria-label', `Section: ${section.title}`); | ||||
|             sectionDiv.appendChild(title); | ||||
|  | ||||
|             section.fields.forEach(field => { | ||||
|                 const value = (exifData as any)[field]; | ||||
|                 if (!value) return; | ||||
|  | ||||
|                 const row = document.createElement('div'); | ||||
|                 row.style.cssText = 'display: flex; justify-content: space-between; margin: 4px 0;'; | ||||
|                  | ||||
|                 const label = document.createElement('span'); | ||||
|                 // Use textContent for safe text insertion | ||||
|                 label.textContent = this.formatFieldName(field) + ':'; | ||||
|                 label.style.color = '#aaa'; | ||||
|                  | ||||
|                 const val = document.createElement('span'); | ||||
|                 // Use textContent for safe value insertion   | ||||
|                 val.textContent = this.formatFieldValue(field, value); | ||||
|                 val.style.textAlign = 'right'; | ||||
|                  | ||||
|                 row.appendChild(label); | ||||
|                 row.appendChild(val); | ||||
|                 sectionDiv.appendChild(row); | ||||
|             }); | ||||
|  | ||||
|             panel.appendChild(sectionDiv); | ||||
|         }); | ||||
|  | ||||
|         // Add GPS map link if coordinates available | ||||
|         if (exifData.gpsLatitude && exifData.gpsLongitude) { | ||||
|             const mapLink = document.createElement('a'); | ||||
|             mapLink.href = `https://www.google.com/maps?q=${exifData.gpsLatitude},${exifData.gpsLongitude}`; | ||||
|             mapLink.target = '_blank'; | ||||
|             mapLink.textContent = 'View on Map'; | ||||
|             mapLink.style.cssText = ` | ||||
|                 display: inline-block; | ||||
|                 margin-top: 10px; | ||||
|                 padding: 8px 12px; | ||||
|                 background: #4CAF50; | ||||
|                 color: white; | ||||
|                 text-decoration: none; | ||||
|                 border-radius: 4px; | ||||
|             `; | ||||
|             panel.appendChild(mapLink); | ||||
|         } | ||||
|  | ||||
|         return panel; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Format field name for display | ||||
|      */ | ||||
|     private formatFieldName(field: string): string { | ||||
|         const names: Record<string, string> = { | ||||
|             make: 'Camera Make', | ||||
|             model: 'Camera Model', | ||||
|             lensModel: 'Lens', | ||||
|             exposureTime: 'Shutter Speed', | ||||
|             fNumber: 'Aperture', | ||||
|             iso: 'ISO', | ||||
|             focalLength: 'Focal Length', | ||||
|             exposureProgram: 'Mode', | ||||
|             meteringMode: 'Metering', | ||||
|             flash: 'Flash', | ||||
|             imageWidth: 'Width', | ||||
|             imageHeight: 'Height', | ||||
|             orientation: 'Orientation', | ||||
|             colorSpace: 'Color Space', | ||||
|             whiteBalance: 'White Balance', | ||||
|             dateTimeOriginal: 'Date Taken', | ||||
|             dateTime: 'Date Modified', | ||||
|             gpsLatitude: 'Latitude', | ||||
|             gpsLongitude: 'Longitude', | ||||
|             gpsAltitude: 'Altitude', | ||||
|             software: 'Software', | ||||
|             artist: 'Artist', | ||||
|             copyright: 'Copyright', | ||||
|             imageDescription: 'Description' | ||||
|         }; | ||||
|         return names[field] || field; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Format field value for display | ||||
|      */ | ||||
|     private formatFieldValue(field: string, value: any): string { | ||||
|         if (value instanceof Date) { | ||||
|             return value.toLocaleString(); | ||||
|         } | ||||
|          | ||||
|         switch (field) { | ||||
|             case 'fNumber': | ||||
|                 return `f/${value}`; | ||||
|             case 'focalLength': | ||||
|                 return `${value}mm`; | ||||
|             case 'gpsLatitude': | ||||
|             case 'gpsLongitude': | ||||
|                 return value.toFixed(6) + '°'; | ||||
|             case 'gpsAltitude': | ||||
|                 return `${value.toFixed(1)}m`; | ||||
|             case 'imageWidth': | ||||
|             case 'imageHeight': | ||||
|                 return `${value}px`; | ||||
|             default: | ||||
|                 return String(value); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Add to cache with LRU eviction | ||||
|      */ | ||||
|     private addToCache(key: string, data: ExifData): void { | ||||
|         // Remove from order if exists | ||||
|         const existingIndex = this.cacheOrder.indexOf(key); | ||||
|         if (existingIndex !== -1) { | ||||
|             this.cacheOrder.splice(existingIndex, 1); | ||||
|         } | ||||
|          | ||||
|         // Add to end | ||||
|         this.cacheOrder.push(key); | ||||
|         this.exifCache.set(key, data); | ||||
|          | ||||
|         // Evict oldest if over limit | ||||
|         while (this.cacheOrder.length > this.MAX_CACHE_SIZE) { | ||||
|             const oldestKey = this.cacheOrder.shift(); | ||||
|             if (oldestKey) { | ||||
|                 this.exifCache.delete(oldestKey); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     /** | ||||
|      * Update cache order for LRU | ||||
|      */ | ||||
|     private updateCacheOrder(key: string): void { | ||||
|         const index = this.cacheOrder.indexOf(key); | ||||
|         if (index !== -1) { | ||||
|             this.cacheOrder.splice(index, 1); | ||||
|             this.cacheOrder.push(key); | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     /** | ||||
|      * Clear EXIF cache | ||||
|      */ | ||||
|     clearCache(): void { | ||||
|         this.exifCache.clear(); | ||||
|         this.cacheOrder = []; | ||||
|     } | ||||
| } | ||||
|  | ||||
| export default ImageExifService.getInstance(); | ||||
							
								
								
									
										681
									
								
								apps/client/src/services/image_sharing.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										681
									
								
								apps/client/src/services/image_sharing.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,681 @@ | ||||
| /** | ||||
|  * Image Sharing and Export Module for Trilium Notes | ||||
|  * Provides functionality for sharing, downloading, and exporting images | ||||
|  */ | ||||
|  | ||||
| import server from './server.js'; | ||||
| import utils from './utils.js'; | ||||
| import toastService from './toast.js'; | ||||
| import type FNote from '../entities/fnote.js'; | ||||
| import { ImageValidator, withErrorBoundary, MemoryMonitor, ImageError, ImageErrorType } from './image_error_handler.js'; | ||||
|  | ||||
| /** | ||||
|  * Export format options | ||||
|  */ | ||||
| export type ExportFormat = 'original' | 'jpeg' | 'png' | 'webp'; | ||||
|  | ||||
| /** | ||||
|  * Export size presets | ||||
|  */ | ||||
| export type SizePreset = 'original' | 'thumbnail' | 'small' | 'medium' | 'large' | 'custom'; | ||||
|  | ||||
| /** | ||||
|  * Export configuration | ||||
|  */ | ||||
| export interface ExportConfig { | ||||
|     format: ExportFormat; | ||||
|     quality: number; // 0-100 for JPEG/WebP | ||||
|     size: SizePreset; | ||||
|     customWidth?: number; | ||||
|     customHeight?: number; | ||||
|     maintainAspectRatio: boolean; | ||||
|     addWatermark: boolean; | ||||
|     watermarkText?: string; | ||||
|     watermarkPosition?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'center'; | ||||
|     watermarkOpacity?: number; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Share options | ||||
|  */ | ||||
| export interface ShareOptions { | ||||
|     method: 'link' | 'email' | 'social'; | ||||
|     expiresIn?: number; // Hours | ||||
|     password?: string; | ||||
|     allowDownload: boolean; | ||||
|     trackViews: boolean; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Share link data | ||||
|  */ | ||||
| export interface ShareLink { | ||||
|     url: string; | ||||
|     shortUrl?: string; | ||||
|     expiresAt?: Date; | ||||
|     password?: string; | ||||
|     views: number; | ||||
|     maxViews?: number; | ||||
|     created: Date; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Size presets in pixels | ||||
|  */ | ||||
| const SIZE_PRESETS = { | ||||
|     thumbnail: { width: 150, height: 150 }, | ||||
|     small: { width: 400, height: 400 }, | ||||
|     medium: { width: 800, height: 800 }, | ||||
|     large: { width: 1600, height: 1600 } | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * ImageSharingService handles image sharing, downloading, and exporting | ||||
|  */ | ||||
| class ImageSharingService { | ||||
|     private static instance: ImageSharingService; | ||||
|     private activeShares: Map<string, ShareLink> = new Map(); | ||||
|     private downloadCanvas?: HTMLCanvasElement; | ||||
|     private downloadContext?: CanvasRenderingContext2D; | ||||
|      | ||||
|     // Canvas size limits for security and memory management | ||||
|     private readonly MAX_CANVAS_SIZE = 8192; // Maximum width/height | ||||
|     private readonly MAX_CANVAS_AREA = 50000000; // 50 megapixels | ||||
|  | ||||
|     private defaultExportConfig: ExportConfig = { | ||||
|         format: 'original', | ||||
|         quality: 90, | ||||
|         size: 'original', | ||||
|         maintainAspectRatio: true, | ||||
|         addWatermark: false, | ||||
|         watermarkPosition: 'bottom-right', | ||||
|         watermarkOpacity: 0.5 | ||||
|     }; | ||||
|  | ||||
|     private constructor() { | ||||
|         // Initialize download canvas | ||||
|         this.downloadCanvas = document.createElement('canvas'); | ||||
|         this.downloadContext = this.downloadCanvas.getContext('2d') || undefined; | ||||
|     } | ||||
|  | ||||
|     static getInstance(): ImageSharingService { | ||||
|         if (!ImageSharingService.instance) { | ||||
|             ImageSharingService.instance = new ImageSharingService(); | ||||
|         } | ||||
|         return ImageSharingService.instance; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Download image with options | ||||
|      */ | ||||
|     async downloadImage( | ||||
|         src: string, | ||||
|         filename: string, | ||||
|         config?: Partial<ExportConfig> | ||||
|     ): Promise<void> { | ||||
|         await withErrorBoundary(async () => { | ||||
|             // Validate inputs | ||||
|             ImageValidator.validateUrl(src); | ||||
|             const sanitizedFilename = ImageValidator.sanitizeFilename(filename); | ||||
|             const finalConfig = { ...this.defaultExportConfig, ...config }; | ||||
|              | ||||
|             // Load image | ||||
|             const img = await this.loadImage(src); | ||||
|              | ||||
|             // Process image based on config | ||||
|             const processedBlob = await this.processImage(img, finalConfig); | ||||
|              | ||||
|             // Create download link | ||||
|             const url = URL.createObjectURL(processedBlob); | ||||
|             const link = document.createElement('a'); | ||||
|             link.href = url; | ||||
|              | ||||
|             // Determine filename with extension | ||||
|             const extension = finalConfig.format === 'original'  | ||||
|                 ? this.getOriginalExtension(sanitizedFilename)  | ||||
|                 : finalConfig.format; | ||||
|             const finalFilename = this.ensureExtension(sanitizedFilename, extension); | ||||
|              | ||||
|             link.download = finalFilename; | ||||
|             document.body.appendChild(link); | ||||
|             link.click(); | ||||
|             document.body.removeChild(link); | ||||
|              | ||||
|             // Cleanup | ||||
|             URL.revokeObjectURL(url); | ||||
|              | ||||
|             toastService.showMessage(`Downloaded ${finalFilename}`); | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Process image according to export configuration | ||||
|      */ | ||||
|     private async processImage(img: HTMLImageElement, config: ExportConfig): Promise<Blob> { | ||||
|         if (!this.downloadCanvas || !this.downloadContext) { | ||||
|             throw new Error('Canvas not initialized'); | ||||
|         } | ||||
|  | ||||
|         // Calculate dimensions | ||||
|         const { width, height } = this.calculateDimensions( | ||||
|             img.naturalWidth, | ||||
|             img.naturalHeight, | ||||
|             config | ||||
|         ); | ||||
|  | ||||
|         // Validate canvas dimensions | ||||
|         ImageValidator.validateDimensions(width, height); | ||||
|          | ||||
|         // Check memory availability | ||||
|         const estimatedMemory = MemoryMonitor.estimateImageMemory(width, height); | ||||
|         if (!MemoryMonitor.checkMemoryAvailable(estimatedMemory)) { | ||||
|             throw new ImageError( | ||||
|                 ImageErrorType.MEMORY_ERROR, | ||||
|                 'Insufficient memory to process image', | ||||
|                 { width, height, estimatedMemory } | ||||
|             ); | ||||
|         } | ||||
|          | ||||
|         // Set canvas size | ||||
|         this.downloadCanvas.width = width; | ||||
|         this.downloadCanvas.height = height; | ||||
|  | ||||
|         // Clear canvas | ||||
|         this.downloadContext.fillStyle = 'white'; | ||||
|         this.downloadContext.fillRect(0, 0, width, height); | ||||
|  | ||||
|         // Draw image | ||||
|         this.downloadContext.drawImage(img, 0, 0, width, height); | ||||
|  | ||||
|         // Add watermark if enabled | ||||
|         if (config.addWatermark && config.watermarkText) { | ||||
|             this.addWatermark(this.downloadContext, width, height, config); | ||||
|         } | ||||
|  | ||||
|         // Convert to blob | ||||
|         return new Promise((resolve, reject) => { | ||||
|             const mimeType = this.getMimeType(config.format); | ||||
|             const quality = config.quality / 100; | ||||
|              | ||||
|             this.downloadCanvas!.toBlob( | ||||
|                 (blob) => { | ||||
|                     if (blob) { | ||||
|                         resolve(blob); | ||||
|                     } else { | ||||
|                         reject(new Error('Failed to create blob')); | ||||
|                     } | ||||
|                 }, | ||||
|                 mimeType, | ||||
|                 quality | ||||
|             ); | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Calculate dimensions based on size preset | ||||
|      */ | ||||
|     private calculateDimensions( | ||||
|         originalWidth: number, | ||||
|         originalHeight: number, | ||||
|         config: ExportConfig | ||||
|     ): { width: number; height: number } { | ||||
|         if (config.size === 'original') { | ||||
|             return { width: originalWidth, height: originalHeight }; | ||||
|         } | ||||
|  | ||||
|         if (config.size === 'custom' && config.customWidth && config.customHeight) { | ||||
|             if (config.maintainAspectRatio) { | ||||
|                 const aspectRatio = originalWidth / originalHeight; | ||||
|                 const targetRatio = config.customWidth / config.customHeight; | ||||
|                  | ||||
|                 if (aspectRatio > targetRatio) { | ||||
|                     return { | ||||
|                         width: config.customWidth, | ||||
|                         height: Math.round(config.customWidth / aspectRatio) | ||||
|                     }; | ||||
|                 } else { | ||||
|                     return { | ||||
|                         width: Math.round(config.customHeight * aspectRatio), | ||||
|                         height: config.customHeight | ||||
|                     }; | ||||
|                 } | ||||
|             } | ||||
|             return { width: config.customWidth, height: config.customHeight }; | ||||
|         } | ||||
|  | ||||
|         // Use preset | ||||
|         const preset = SIZE_PRESETS[config.size as keyof typeof SIZE_PRESETS]; | ||||
|         if (!preset) { | ||||
|             return { width: originalWidth, height: originalHeight }; | ||||
|         } | ||||
|  | ||||
|         if (config.maintainAspectRatio) { | ||||
|             const aspectRatio = originalWidth / originalHeight; | ||||
|             const maxWidth = preset.width; | ||||
|             const maxHeight = preset.height; | ||||
|              | ||||
|             let width = originalWidth; | ||||
|             let height = originalHeight; | ||||
|              | ||||
|             if (width > maxWidth) { | ||||
|                 width = maxWidth; | ||||
|                 height = Math.round(width / aspectRatio); | ||||
|             } | ||||
|              | ||||
|             if (height > maxHeight) { | ||||
|                 height = maxHeight; | ||||
|                 width = Math.round(height * aspectRatio); | ||||
|             } | ||||
|              | ||||
|             return { width, height }; | ||||
|         } | ||||
|  | ||||
|         return preset; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Add watermark to canvas | ||||
|      */ | ||||
|     private addWatermark( | ||||
|         ctx: CanvasRenderingContext2D, | ||||
|         width: number, | ||||
|         height: number, | ||||
|         config: ExportConfig | ||||
|     ): void { | ||||
|         if (!config.watermarkText) return; | ||||
|  | ||||
|         ctx.save(); | ||||
|          | ||||
|         // Set watermark style | ||||
|         ctx.globalAlpha = config.watermarkOpacity || 0.5; | ||||
|         ctx.fillStyle = 'white'; | ||||
|         ctx.strokeStyle = 'black'; | ||||
|         ctx.lineWidth = 2; | ||||
|         ctx.font = `${Math.min(width, height) * 0.05}px Arial`; | ||||
|         ctx.textAlign = 'center'; | ||||
|         ctx.textBaseline = 'middle'; | ||||
|  | ||||
|         // Calculate position | ||||
|         let x = width / 2; | ||||
|         let y = height / 2; | ||||
|          | ||||
|         switch (config.watermarkPosition) { | ||||
|             case 'top-left': | ||||
|                 x = width * 0.1; | ||||
|                 y = height * 0.1; | ||||
|                 ctx.textAlign = 'left'; | ||||
|                 break; | ||||
|             case 'top-right': | ||||
|                 x = width * 0.9; | ||||
|                 y = height * 0.1; | ||||
|                 ctx.textAlign = 'right'; | ||||
|                 break; | ||||
|             case 'bottom-left': | ||||
|                 x = width * 0.1; | ||||
|                 y = height * 0.9; | ||||
|                 ctx.textAlign = 'left'; | ||||
|                 break; | ||||
|             case 'bottom-right': | ||||
|                 x = width * 0.9; | ||||
|                 y = height * 0.9; | ||||
|                 ctx.textAlign = 'right'; | ||||
|                 break; | ||||
|         } | ||||
|  | ||||
|         // Draw watermark with outline | ||||
|         ctx.strokeText(config.watermarkText, x, y); | ||||
|         ctx.fillText(config.watermarkText, x, y); | ||||
|          | ||||
|         ctx.restore(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Generate shareable link for image | ||||
|      */ | ||||
|     async generateShareLink( | ||||
|         noteId: string, | ||||
|         options?: Partial<ShareOptions> | ||||
|     ): Promise<ShareLink> { | ||||
|         try { | ||||
|             const finalOptions = { | ||||
|                 method: 'link' as const, | ||||
|                 allowDownload: true, | ||||
|                 trackViews: false, | ||||
|                 ...options | ||||
|             }; | ||||
|  | ||||
|             // Create share token on server | ||||
|             const response = await server.post(`notes/${noteId}/share`, { | ||||
|                 type: 'image', | ||||
|                 expiresIn: finalOptions.expiresIn, | ||||
|                 password: finalOptions.password, | ||||
|                 allowDownload: finalOptions.allowDownload, | ||||
|                 trackViews: finalOptions.trackViews | ||||
|             }); | ||||
|  | ||||
|             const shareLink: ShareLink = { | ||||
|                 url: `${window.location.origin}/share/${response.token}`, | ||||
|                 shortUrl: response.shortUrl, | ||||
|                 expiresAt: response.expiresAt ? new Date(response.expiresAt) : undefined, | ||||
|                 password: finalOptions.password, | ||||
|                 views: 0, | ||||
|                 maxViews: response.maxViews, | ||||
|                 created: new Date() | ||||
|             }; | ||||
|  | ||||
|             // Store in active shares | ||||
|             this.activeShares.set(response.token, shareLink); | ||||
|  | ||||
|             return shareLink; | ||||
|         } catch (error) { | ||||
|             console.error('Failed to generate share link:', error); | ||||
|             throw error; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Copy image or link to clipboard | ||||
|      */ | ||||
|     async copyToClipboard( | ||||
|         src: string, | ||||
|         type: 'image' | 'link' = 'link' | ||||
|     ): Promise<void> { | ||||
|         await withErrorBoundary(async () => { | ||||
|             // Validate URL | ||||
|             ImageValidator.validateUrl(src); | ||||
|             if (type === 'link') { | ||||
|                 // Copy URL to clipboard | ||||
|                 await navigator.clipboard.writeText(src); | ||||
|                 toastService.showMessage('Link copied to clipboard'); | ||||
|             } else { | ||||
|                 // Copy image data to clipboard | ||||
|                 const img = await this.loadImage(src); | ||||
|                  | ||||
|                 if (!this.downloadCanvas || !this.downloadContext) { | ||||
|                     throw new Error('Canvas not initialized'); | ||||
|                 } | ||||
|                  | ||||
|                 // Validate dimensions before setting | ||||
|                 ImageValidator.validateDimensions(img.naturalWidth, img.naturalHeight); | ||||
|                  | ||||
|                 this.downloadCanvas.width = img.naturalWidth; | ||||
|                 this.downloadCanvas.height = img.naturalHeight; | ||||
|                 this.downloadContext.drawImage(img, 0, 0); | ||||
|                  | ||||
|                 this.downloadCanvas.toBlob(async (blob) => { | ||||
|                     if (blob) { | ||||
|                         try { | ||||
|                             const item = new ClipboardItem({ 'image/png': blob }); | ||||
|                             await navigator.clipboard.write([item]); | ||||
|                             toastService.showMessage('Image copied to clipboard'); | ||||
|                         } catch (error) { | ||||
|                             console.error('Failed to copy image to clipboard:', error); | ||||
|                             // Fallback to copying link | ||||
|                             await navigator.clipboard.writeText(src); | ||||
|                             toastService.showMessage('Image link copied to clipboard'); | ||||
|                         } | ||||
|                     } | ||||
|                 }); | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Share via native share API (mobile) | ||||
|      */ | ||||
|     async shareNative( | ||||
|         src: string, | ||||
|         title: string, | ||||
|         text?: string | ||||
|     ): Promise<void> { | ||||
|         if (!navigator.share) { | ||||
|             throw new Error('Native share not supported'); | ||||
|         } | ||||
|  | ||||
|         try { | ||||
|             // Try to share with file | ||||
|             const img = await this.loadImage(src); | ||||
|             const blob = await this.processImage(img, this.defaultExportConfig); | ||||
|             const file = new File([blob], `${title}.${this.defaultExportConfig.format}`, { | ||||
|                 type: this.getMimeType(this.defaultExportConfig.format) | ||||
|             }); | ||||
|  | ||||
|             await navigator.share({ | ||||
|                 title, | ||||
|                 text: text || `Check out this image: ${title}`, | ||||
|                 files: [file] | ||||
|             }); | ||||
|         } catch (error) { | ||||
|             // Fallback to sharing URL | ||||
|             try { | ||||
|                 await navigator.share({ | ||||
|                     title, | ||||
|                     text: text || `Check out this image: ${title}`, | ||||
|                     url: src | ||||
|                 }); | ||||
|             } catch (shareError) { | ||||
|                 console.error('Failed to share:', shareError); | ||||
|                 throw shareError; | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Export multiple images as ZIP | ||||
|      */ | ||||
|     async exportBatch( | ||||
|         images: Array<{ src: string; filename: string }>, | ||||
|         config?: Partial<ExportConfig> | ||||
|     ): Promise<void> { | ||||
|         try { | ||||
|             // Dynamic import of JSZip | ||||
|             const JSZip = (await import('jszip')).default; | ||||
|             const zip = new JSZip(); | ||||
|              | ||||
|             const finalConfig = { ...this.defaultExportConfig, ...config }; | ||||
|              | ||||
|             // Process each image | ||||
|             for (const { src, filename } of images) { | ||||
|                 try { | ||||
|                     const img = await this.loadImage(src); | ||||
|                     const blob = await this.processImage(img, finalConfig); | ||||
|                     const extension = finalConfig.format === 'original'  | ||||
|                         ? this.getOriginalExtension(filename)  | ||||
|                         : finalConfig.format; | ||||
|                     const finalFilename = this.ensureExtension(filename, extension); | ||||
|                      | ||||
|                     zip.file(finalFilename, blob); | ||||
|                 } catch (error) { | ||||
|                     console.error(`Failed to process image ${filename}:`, error); | ||||
|                 } | ||||
|             } | ||||
|              | ||||
|             // Generate and download ZIP | ||||
|             const zipBlob = await zip.generateAsync({ type: 'blob' }); | ||||
|             const url = URL.createObjectURL(zipBlob); | ||||
|             const link = document.createElement('a'); | ||||
|             link.href = url; | ||||
|             link.download = `images_${Date.now()}.zip`; | ||||
|             document.body.appendChild(link); | ||||
|             link.click(); | ||||
|             document.body.removeChild(link); | ||||
|             URL.revokeObjectURL(url); | ||||
|              | ||||
|             toastService.showMessage(`Exported ${images.length} images`); | ||||
|         } catch (error) { | ||||
|             console.error('Failed to export images:', error); | ||||
|             toastService.showError('Failed to export images'); | ||||
|             throw error; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Open share dialog | ||||
|      */ | ||||
|     openShareDialog( | ||||
|         src: string, | ||||
|         title: string, | ||||
|         noteId?: string | ||||
|     ): void { | ||||
|         // Create modal dialog | ||||
|         const dialog = document.createElement('div'); | ||||
|         dialog.className = 'share-dialog-overlay'; | ||||
|         dialog.style.cssText = ` | ||||
|             position: fixed; | ||||
|             top: 0; | ||||
|             left: 0; | ||||
|             right: 0; | ||||
|             bottom: 0; | ||||
|             background: rgba(0, 0, 0, 0.5); | ||||
|             display: flex; | ||||
|             align-items: center; | ||||
|             justify-content: center; | ||||
|             z-index: 10000; | ||||
|         `; | ||||
|  | ||||
|         const content = document.createElement('div'); | ||||
|         content.className = 'share-dialog'; | ||||
|         content.style.cssText = ` | ||||
|             background: white; | ||||
|             border-radius: 8px; | ||||
|             padding: 20px; | ||||
|             width: 400px; | ||||
|             max-width: 90%; | ||||
|         `; | ||||
|  | ||||
|         content.innerHTML = ` | ||||
|             <h3 style="margin: 0 0 15px 0;">Share Image</h3> | ||||
|             <div class="share-options" style="display: flex; flex-direction: column; gap: 10px;"> | ||||
|                 <button class="share-copy-link" style="padding: 10px; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;"> | ||||
|                     <i class="bx bx-link"></i> Copy Link | ||||
|                 </button> | ||||
|                 <button class="share-copy-image" style="padding: 10px; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;"> | ||||
|                     <i class="bx bx-copy"></i> Copy Image | ||||
|                 </button> | ||||
|                 <button class="share-download" style="padding: 10px; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;"> | ||||
|                     <i class="bx bx-download"></i> Download | ||||
|                 </button> | ||||
|                 ${navigator.share ? ` | ||||
|                     <button class="share-native" style="padding: 10px; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;"> | ||||
|                         <i class="bx bx-share"></i> Share... | ||||
|                     </button> | ||||
|                 ` : ''} | ||||
|                 ${noteId ? ` | ||||
|                     <button class="share-generate-link" style="padding: 10px; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;"> | ||||
|                         <i class="bx bx-link-external"></i> Generate Share Link | ||||
|                     </button> | ||||
|                 ` : ''} | ||||
|             </div> | ||||
|             <button class="close-dialog" style="margin-top: 15px; padding: 8px 16px; background: #f0f0f0; border: none; border-radius: 4px; cursor: pointer;"> | ||||
|                 Close | ||||
|             </button> | ||||
|         `; | ||||
|  | ||||
|         // Add event handlers | ||||
|         content.querySelector('.share-copy-link')?.addEventListener('click', () => { | ||||
|             this.copyToClipboard(src, 'link'); | ||||
|             dialog.remove(); | ||||
|         }); | ||||
|  | ||||
|         content.querySelector('.share-copy-image')?.addEventListener('click', () => { | ||||
|             this.copyToClipboard(src, 'image'); | ||||
|             dialog.remove(); | ||||
|         }); | ||||
|  | ||||
|         content.querySelector('.share-download')?.addEventListener('click', () => { | ||||
|             this.downloadImage(src, title); | ||||
|             dialog.remove(); | ||||
|         }); | ||||
|  | ||||
|         content.querySelector('.share-native')?.addEventListener('click', () => { | ||||
|             this.shareNative(src, title); | ||||
|             dialog.remove(); | ||||
|         }); | ||||
|  | ||||
|         content.querySelector('.share-generate-link')?.addEventListener('click', async () => { | ||||
|             if (noteId) { | ||||
|                 const link = await this.generateShareLink(noteId); | ||||
|                 await this.copyToClipboard(link.url, 'link'); | ||||
|                 dialog.remove(); | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|         content.querySelector('.close-dialog')?.addEventListener('click', () => { | ||||
|             dialog.remove(); | ||||
|         }); | ||||
|  | ||||
|         dialog.appendChild(content); | ||||
|         document.body.appendChild(dialog); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Load image from URL | ||||
|      */ | ||||
|     private loadImage(src: string): Promise<HTMLImageElement> { | ||||
|         return new Promise((resolve, reject) => { | ||||
|             const img = new Image(); | ||||
|             img.crossOrigin = 'anonymous'; | ||||
|             img.onload = () => resolve(img); | ||||
|             img.onerror = () => reject(new Error(`Failed to load image: ${src}`)); | ||||
|             img.src = src; | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Get MIME type for format | ||||
|      */ | ||||
|     private getMimeType(format: ExportFormat): string { | ||||
|         switch (format) { | ||||
|             case 'jpeg': | ||||
|                 return 'image/jpeg'; | ||||
|             case 'png': | ||||
|                 return 'image/png'; | ||||
|             case 'webp': | ||||
|                 return 'image/webp'; | ||||
|             default: | ||||
|                 return 'image/png'; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Get original extension from filename | ||||
|      */ | ||||
|     private getOriginalExtension(filename: string): string { | ||||
|         const parts = filename.split('.'); | ||||
|         if (parts.length > 1) { | ||||
|             return parts[parts.length - 1].toLowerCase(); | ||||
|         } | ||||
|         return 'png'; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Ensure filename has correct extension | ||||
|      */ | ||||
|     private ensureExtension(filename: string, extension: string): string { | ||||
|         const parts = filename.split('.'); | ||||
|         if (parts.length > 1) { | ||||
|             parts[parts.length - 1] = extension; | ||||
|             return parts.join('.'); | ||||
|         } | ||||
|         return `${filename}.${extension}`; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Cleanup resources | ||||
|      */ | ||||
|     cleanup(): void { | ||||
|         this.activeShares.clear(); | ||||
|          | ||||
|         // Clean up canvas memory | ||||
|         if (this.downloadCanvas && this.downloadContext) { | ||||
|             this.downloadContext.clearRect(0, 0, this.downloadCanvas.width, this.downloadCanvas.height); | ||||
|             this.downloadCanvas.width = 0; | ||||
|             this.downloadCanvas.height = 0; | ||||
|         } | ||||
|          | ||||
|         this.downloadCanvas = undefined; | ||||
|         this.downloadContext = undefined; | ||||
|     } | ||||
| } | ||||
|  | ||||
| export default ImageSharingService.getInstance(); | ||||
							
								
								
									
										552
									
								
								apps/client/src/services/media_viewer.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										552
									
								
								apps/client/src/services/media_viewer.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,552 @@ | ||||
| import PhotoSwipe from 'photoswipe'; | ||||
| import type PhotoSwipeOptions from 'photoswipe'; | ||||
| import type { DataSource, SlideData } from 'photoswipe'; | ||||
| import 'photoswipe/style.css'; | ||||
| import '../styles/photoswipe-mobile-a11y.css'; | ||||
| import mobileA11yService, { type MobileA11yConfig } from './photoswipe_mobile_a11y.js'; | ||||
|  | ||||
| // Define Content type locally since it's not exported by PhotoSwipe | ||||
| interface Content { | ||||
|     width?: number; | ||||
|     height?: number; | ||||
|     [key: string]: any; | ||||
| } | ||||
|  | ||||
| // Define AugmentedEvent type locally | ||||
| interface AugmentedEvent<T extends string> { | ||||
|     content: Content; | ||||
|     slide?: any; | ||||
|     preventDefault?: () => void; | ||||
|     [key: string]: any; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Media item interface for PhotoSwipe gallery | ||||
|  */ | ||||
| export interface MediaItem { | ||||
|     src: string; | ||||
|     width?: number; | ||||
|     height?: number; | ||||
|     alt?: string; | ||||
|     title?: string; | ||||
|     noteId?: string; | ||||
|     element?: HTMLElement; | ||||
|     msrc?: string; // Thumbnail source | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Configuration options for the media viewer | ||||
|  */ | ||||
| export interface MediaViewerConfig { | ||||
|     bgOpacity?: number; | ||||
|     showHideOpacity?: boolean; | ||||
|     showAnimationDuration?: number; | ||||
|     hideAnimationDuration?: number; | ||||
|     allowPanToNext?: boolean; | ||||
|     spacing?: number; | ||||
|     maxSpreadZoom?: number; | ||||
|     getThumbBoundsFn?: (index: number) => { x: number; y: number; w: number } | undefined; | ||||
|     pinchToClose?: boolean; | ||||
|     closeOnScroll?: boolean; | ||||
|     closeOnVerticalDrag?: boolean; | ||||
|     mouseMovePan?: boolean; | ||||
|     arrowKeys?: boolean; | ||||
|     returnFocus?: boolean; | ||||
|     escKey?: boolean; | ||||
|     errorMsg?: string; | ||||
|     preloadFirstSlide?: boolean; | ||||
|     preload?: [number, number]; | ||||
|     loop?: boolean; | ||||
|     wheelToZoom?: boolean; | ||||
|     mobileA11y?: MobileA11yConfig; // Mobile and accessibility configuration | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Event callbacks for media viewer | ||||
|  */ | ||||
| export interface MediaViewerCallbacks { | ||||
|     onOpen?: () => void; | ||||
|     onClose?: () => void; | ||||
|     onChange?: (index: number) => void; | ||||
|     onImageLoad?: (index: number, item: MediaItem) => void; | ||||
|     onImageError?: (index: number, item: MediaItem, error?: Error) => void; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * PhotoSwipe data item with original item reference | ||||
|  */ | ||||
| interface PhotoSwipeDataItem extends SlideData { | ||||
|     _originalItem?: MediaItem; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Error handler for media viewer operations | ||||
|  */ | ||||
| class MediaViewerError extends Error { | ||||
|     constructor(message: string, public readonly cause?: unknown) { | ||||
|         super(message); | ||||
|         this.name = 'MediaViewerError'; | ||||
|     } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * MediaViewerService manages the PhotoSwipe lightbox for viewing images and media | ||||
|  * in Trilium Notes. Implements singleton pattern for global access. | ||||
|  */ | ||||
| class MediaViewerService { | ||||
|     private static instance: MediaViewerService; | ||||
|     private photoSwipe: PhotoSwipe | null = null; | ||||
|     private defaultConfig: MediaViewerConfig; | ||||
|     private currentItems: MediaItem[] = []; | ||||
|     private callbacks: MediaViewerCallbacks = {}; | ||||
|     private cleanupHandlers: Array<() => void> = []; | ||||
|  | ||||
|     private constructor() { | ||||
|         // Default configuration optimized for Trilium | ||||
|         this.defaultConfig = { | ||||
|             bgOpacity: 0.95, | ||||
|             showHideOpacity: true, | ||||
|             showAnimationDuration: 250, | ||||
|             hideAnimationDuration: 250, | ||||
|             allowPanToNext: true, | ||||
|             spacing: 0.12, | ||||
|             maxSpreadZoom: 4, | ||||
|             pinchToClose: true, | ||||
|             closeOnScroll: false, | ||||
|             closeOnVerticalDrag: true, | ||||
|             mouseMovePan: true, | ||||
|             arrowKeys: true, | ||||
|             returnFocus: true, | ||||
|             escKey: true, | ||||
|             errorMsg: 'The image could not be loaded', | ||||
|             preloadFirstSlide: true, | ||||
|             preload: [1, 2], | ||||
|             loop: true, | ||||
|             wheelToZoom: true | ||||
|         }; | ||||
|  | ||||
|         // Setup global cleanup on window unload | ||||
|         window.addEventListener('beforeunload', () => this.destroy()); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Get singleton instance of MediaViewerService | ||||
|      */ | ||||
|     static getInstance(): MediaViewerService { | ||||
|         if (!MediaViewerService.instance) { | ||||
|             MediaViewerService.instance = new MediaViewerService(); | ||||
|         } | ||||
|         return MediaViewerService.instance; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Open the media viewer with specified items | ||||
|      */ | ||||
|     open(items: MediaItem[], startIndex: number = 0, config?: Partial<MediaViewerConfig>, callbacks?: MediaViewerCallbacks): void { | ||||
|         try { | ||||
|             // Validate inputs | ||||
|             if (!items || items.length === 0) { | ||||
|                 throw new MediaViewerError('No items provided to media viewer'); | ||||
|             } | ||||
|              | ||||
|             if (startIndex < 0 || startIndex >= items.length) { | ||||
|                 console.warn(`Invalid start index ${startIndex}, using 0`); | ||||
|                 startIndex = 0; | ||||
|             } | ||||
|  | ||||
|             // Close any existing viewer | ||||
|             this.close(); | ||||
|  | ||||
|             this.currentItems = items; | ||||
|             this.callbacks = callbacks || {}; | ||||
|  | ||||
|             // Prepare data source for PhotoSwipe with error handling | ||||
|             const dataSource: DataSource = items.map((item, index) => { | ||||
|                 try { | ||||
|                     return this.prepareItem(item); | ||||
|                 } catch (error) { | ||||
|                     console.error(`Failed to prepare item at index ${index}:`, error); | ||||
|                     // Return a minimal valid item as fallback | ||||
|                     return { | ||||
|                         src: item.src, | ||||
|                         width: 800, | ||||
|                         height: 600, | ||||
|                         alt: item.alt || 'Error loading image' | ||||
|                     } as PhotoSwipeDataItem; | ||||
|                 } | ||||
|             }); | ||||
|  | ||||
|             // Merge configurations | ||||
|             const finalConfig = { | ||||
|                 ...this.defaultConfig, | ||||
|                 ...config, | ||||
|                 dataSource, | ||||
|                 index: startIndex, | ||||
|                 errorMsg: config?.errorMsg || 'The image could not be loaded. Please try again.' | ||||
|             }; | ||||
|  | ||||
|             // Create and initialize PhotoSwipe | ||||
|             this.photoSwipe = new PhotoSwipe(finalConfig); | ||||
|  | ||||
|             // Setup event handlers | ||||
|             this.setupEventHandlers(); | ||||
|              | ||||
|             // Apply mobile and accessibility enhancements | ||||
|             if (config?.mobileA11y || this.shouldAutoEnhance()) { | ||||
|                 mobileA11yService.enhancePhotoSwipe(this.photoSwipe, config?.mobileA11y); | ||||
|             } | ||||
|  | ||||
|             // Initialize the viewer | ||||
|             this.photoSwipe.init(); | ||||
|         } catch (error) { | ||||
|             console.error('Failed to open media viewer:', error); | ||||
|             // Cleanup on error | ||||
|             this.close(); | ||||
|             // Re-throw as MediaViewerError | ||||
|             throw error instanceof MediaViewerError ? error : new MediaViewerError('Failed to open media viewer', error); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Open a single image in the viewer | ||||
|      */ | ||||
|     openSingle(item: MediaItem, config?: Partial<MediaViewerConfig>, callbacks?: MediaViewerCallbacks): void { | ||||
|         this.open([item], 0, config, callbacks); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Close the media viewer | ||||
|      */ | ||||
|     close(): void { | ||||
|         if (this.photoSwipe) { | ||||
|             this.photoSwipe.destroy(); | ||||
|             this.photoSwipe = null; | ||||
|             this.cleanupEventHandlers(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Navigate to next item | ||||
|      */ | ||||
|     next(): void { | ||||
|         if (this.photoSwipe) { | ||||
|             this.photoSwipe.next(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Navigate to previous item | ||||
|      */ | ||||
|     prev(): void { | ||||
|         if (this.photoSwipe) { | ||||
|             this.photoSwipe.prev(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Go to specific slide by index | ||||
|      */ | ||||
|     goTo(index: number): void { | ||||
|         if (this.photoSwipe && index >= 0 && index < this.currentItems.length) { | ||||
|             this.photoSwipe.goTo(index); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Get current slide index | ||||
|      */ | ||||
|     getCurrentIndex(): number { | ||||
|         return this.photoSwipe ? this.photoSwipe.currIndex : -1; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Check if viewer is open | ||||
|      */ | ||||
|     isOpen(): boolean { | ||||
|         return this.photoSwipe !== null; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Update configuration dynamically | ||||
|      */ | ||||
|     updateConfig(config: Partial<MediaViewerConfig>): void { | ||||
|         this.defaultConfig = { | ||||
|             ...this.defaultConfig, | ||||
|             ...config | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Prepare item for PhotoSwipe | ||||
|      */ | ||||
|     private prepareItem(item: MediaItem): PhotoSwipeDataItem { | ||||
|         const prepared: PhotoSwipeDataItem = { | ||||
|             src: item.src, | ||||
|             alt: item.alt || '', | ||||
|             title: item.title | ||||
|         }; | ||||
|  | ||||
|         // If dimensions are provided, use them | ||||
|         if (item.width && item.height) { | ||||
|             prepared.width = item.width; | ||||
|             prepared.height = item.height; | ||||
|         } else { | ||||
|             // Default dimensions - will be updated when image loads | ||||
|             prepared.width = 0; | ||||
|             prepared.height = 0; | ||||
|         } | ||||
|  | ||||
|         // Add thumbnail if provided | ||||
|         if (item.msrc) { | ||||
|             prepared.msrc = item.msrc; | ||||
|         } | ||||
|  | ||||
|         // Store original item reference | ||||
|         prepared._originalItem = item; | ||||
|  | ||||
|         return prepared; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Setup event handlers for PhotoSwipe | ||||
|      */ | ||||
|     private setupEventHandlers(): void { | ||||
|         if (!this.photoSwipe) return; | ||||
|  | ||||
|         // Opening event | ||||
|         const openHandler = () => { | ||||
|             if (this.callbacks.onOpen) { | ||||
|                 this.callbacks.onOpen(); | ||||
|             } | ||||
|         }; | ||||
|         this.photoSwipe.on('openingAnimationEnd', openHandler); | ||||
|         this.cleanupHandlers.push(() => this.photoSwipe?.off('openingAnimationEnd', openHandler)); | ||||
|  | ||||
|         // Closing event | ||||
|         const closeHandler = () => { | ||||
|             if (this.callbacks.onClose) { | ||||
|                 this.callbacks.onClose(); | ||||
|             } | ||||
|         }; | ||||
|         this.photoSwipe.on('close', closeHandler); | ||||
|         this.cleanupHandlers.push(() => this.photoSwipe?.off('close', closeHandler)); | ||||
|  | ||||
|         // Change event | ||||
|         const changeHandler = () => { | ||||
|             if (this.callbacks.onChange && this.photoSwipe) { | ||||
|                 this.callbacks.onChange(this.photoSwipe.currIndex); | ||||
|             } | ||||
|         }; | ||||
|         this.photoSwipe.on('change', changeHandler); | ||||
|         this.cleanupHandlers.push(() => this.photoSwipe?.off('change', changeHandler)); | ||||
|  | ||||
|         // Image load event - also update dimensions if needed | ||||
|         const loadCompleteHandler = (e: any) => { | ||||
|             try { | ||||
|                 const { content } = e; | ||||
|                 const extContent = content as Content & { type?: string; data?: HTMLImageElement; index?: number; _originalItem?: MediaItem }; | ||||
|                  | ||||
|                 if (extContent.type === 'image' && extContent.data) { | ||||
|                     // Update dimensions if they were not provided | ||||
|                     if (content.width === 0 || content.height === 0) { | ||||
|                         const img = extContent.data; | ||||
|                         content.width = img.naturalWidth; | ||||
|                         content.height = img.naturalHeight; | ||||
|                         if (typeof extContent.index === 'number') { | ||||
|                             this.photoSwipe?.refreshSlideContent(extContent.index); | ||||
|                         } | ||||
|                     } | ||||
|  | ||||
|                     if (this.callbacks.onImageLoad && typeof extContent.index === 'number' && extContent._originalItem) { | ||||
|                         this.callbacks.onImageLoad(extContent.index, extContent._originalItem); | ||||
|                     } | ||||
|                 } | ||||
|             } catch (error) { | ||||
|                 console.error('Error in loadComplete handler:', error); | ||||
|             } | ||||
|         }; | ||||
|         this.photoSwipe.on('loadComplete', loadCompleteHandler); | ||||
|         this.cleanupHandlers.push(() => this.photoSwipe?.off('loadComplete', loadCompleteHandler)); | ||||
|  | ||||
|         // Image error event | ||||
|         const errorHandler = (e: any) => { | ||||
|             try { | ||||
|                 const { content } = e; | ||||
|                 const extContent = content as Content & { index?: number; _originalItem?: MediaItem }; | ||||
|                  | ||||
|                 if (this.callbacks.onImageError && typeof extContent.index === 'number' && extContent._originalItem) { | ||||
|                     const error = new MediaViewerError(`Failed to load image at index ${extContent.index}`); | ||||
|                     this.callbacks.onImageError(extContent.index, extContent._originalItem, error); | ||||
|                 } | ||||
|             } catch (error) { | ||||
|                 console.error('Error in errorHandler:', error); | ||||
|             } | ||||
|         }; | ||||
|         this.photoSwipe.on('loadError', errorHandler); | ||||
|         this.cleanupHandlers.push(() => this.photoSwipe?.off('loadError', errorHandler)); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Cleanup event handlers | ||||
|      */ | ||||
|     private cleanupEventHandlers(): void { | ||||
|         this.cleanupHandlers.forEach(handler => handler()); | ||||
|         this.cleanupHandlers = []; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Destroy the service and cleanup resources | ||||
|      */ | ||||
|     destroy(): void { | ||||
|         this.close(); | ||||
|         this.currentItems = []; | ||||
|         this.callbacks = {}; | ||||
|          | ||||
|         // Cleanup mobile and accessibility enhancements | ||||
|         mobileA11yService.cleanup(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Get dimensions from image element or URL with proper resource cleanup | ||||
|      */ | ||||
|     async getImageDimensions(src: string): Promise<{ width: number; height: number }> { | ||||
|         return new Promise((resolve, reject) => { | ||||
|             const img = new Image(); | ||||
|             let resolved = false; | ||||
|              | ||||
|             const cleanup = () => { | ||||
|                 img.onload = null; | ||||
|                 img.onerror = null; | ||||
|                 // Clear the src to help with garbage collection | ||||
|                 if (!resolved) { | ||||
|                     img.src = ''; | ||||
|                 } | ||||
|             }; | ||||
|              | ||||
|             img.onload = () => { | ||||
|                 resolved = true; | ||||
|                 const dimensions = { | ||||
|                     width: img.naturalWidth, | ||||
|                     height: img.naturalHeight | ||||
|                 }; | ||||
|                 cleanup(); | ||||
|                 resolve(dimensions); | ||||
|             }; | ||||
|              | ||||
|             img.onerror = () => { | ||||
|                 const error = new MediaViewerError(`Failed to load image: ${src}`); | ||||
|                 cleanup(); | ||||
|                 reject(error); | ||||
|             }; | ||||
|              | ||||
|             // Set a timeout for image loading | ||||
|             const timeoutId = setTimeout(() => { | ||||
|                 if (!resolved) { | ||||
|                     cleanup(); | ||||
|                     reject(new MediaViewerError(`Image loading timeout: ${src}`)); | ||||
|                 } | ||||
|             }, 30000); // 30 second timeout | ||||
|              | ||||
|             img.src = src; | ||||
|              | ||||
|             // Clear timeout on success or error | ||||
|             // Store the original handlers with timeout cleanup | ||||
|             const originalOnload = img.onload; | ||||
|             const originalOnerror = img.onerror; | ||||
|              | ||||
|             img.onload = function(ev: Event) { | ||||
|                 clearTimeout(timeoutId); | ||||
|                 if (originalOnload) { | ||||
|                     originalOnload.call(img, ev); | ||||
|                 } | ||||
|             }; | ||||
|              | ||||
|             img.onerror = function(ev: Event | string) { | ||||
|                 clearTimeout(timeoutId); | ||||
|                 if (originalOnerror) { | ||||
|                     originalOnerror.call(img, ev); | ||||
|                 } | ||||
|             }; | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Create items from image elements in a container with error isolation | ||||
|      */ | ||||
|     async createItemsFromContainer(container: HTMLElement, selector: string = 'img'): Promise<MediaItem[]> { | ||||
|         const images = container.querySelectorAll<HTMLImageElement>(selector); | ||||
|         const items: MediaItem[] = []; | ||||
|  | ||||
|         // Process each image with isolated error handling | ||||
|         const promises = Array.from(images).map(async (img) => { | ||||
|             try { | ||||
|                 const item: MediaItem = { | ||||
|                     src: img.src, | ||||
|                     alt: img.alt || `Image ${items.length + 1}`, | ||||
|                     title: img.title || img.alt || `Image ${items.length + 1}`, | ||||
|                     element: img, | ||||
|                     width: img.naturalWidth || undefined, | ||||
|                     height: img.naturalHeight || undefined | ||||
|                 }; | ||||
|  | ||||
|                 // Try to get dimensions if not available | ||||
|                 if (!item.width || !item.height) { | ||||
|                     try { | ||||
|                         const dimensions = await this.getImageDimensions(img.src); | ||||
|                         item.width = dimensions.width; | ||||
|                         item.height = dimensions.height; | ||||
|                     } catch (error) { | ||||
|                         // Log but don't fail - image will still be viewable | ||||
|                         console.warn(`Failed to get dimensions for image: ${img.src}`, error); | ||||
|                         // Set default dimensions as fallback | ||||
|                         item.width = 800; | ||||
|                         item.height = 600; | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 return item; | ||||
|             } catch (error) { | ||||
|                 // Log error but continue processing other images | ||||
|                 console.error(`Failed to process image: ${img.src}`, error); | ||||
|                 return null; | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|         // Wait for all promises and filter out nulls | ||||
|         const results = await Promise.allSettled(promises); | ||||
|         for (const result of results) { | ||||
|             if (result.status === 'fulfilled' && result.value !== null) { | ||||
|                 items.push(result.value); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return items; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Apply theme-specific styles | ||||
|      */ | ||||
|     applyTheme(isDarkTheme: boolean): void { | ||||
|         // This will be expanded to modify PhotoSwipe's appearance based on Trilium's theme | ||||
|         const opacity = isDarkTheme ? 0.95 : 0.9; | ||||
|         this.updateConfig({ bgOpacity: opacity }); | ||||
|     } | ||||
|      | ||||
|     /** | ||||
|      * Check if mobile/accessibility enhancements should be auto-enabled | ||||
|      */ | ||||
|     private shouldAutoEnhance(): boolean { | ||||
|         // Auto-enable for touch devices | ||||
|         const isTouchDevice = 'ontouchstart' in window ||  | ||||
|                              navigator.maxTouchPoints > 0; | ||||
|          | ||||
|         // Auto-enable if user has accessibility preferences | ||||
|         const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; | ||||
|         const prefersHighContrast = window.matchMedia('(prefers-contrast: high)').matches; | ||||
|          | ||||
|         return isTouchDevice || prefersReducedMotion || prefersHighContrast; | ||||
|     } | ||||
| } | ||||
|  | ||||
| // Export singleton instance | ||||
| export default MediaViewerService.getInstance(); | ||||
							
								
								
									
										541
									
								
								apps/client/src/services/photoswipe_mobile_a11y.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										541
									
								
								apps/client/src/services/photoswipe_mobile_a11y.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,541 @@ | ||||
| /** | ||||
|  * Tests for PhotoSwipe Mobile & Accessibility Enhancement Module | ||||
|  */ | ||||
|  | ||||
| import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'; | ||||
| import type PhotoSwipe from 'photoswipe'; | ||||
| import mobileA11yService from './photoswipe_mobile_a11y.js'; | ||||
|  | ||||
| // Mock PhotoSwipe | ||||
| const mockPhotoSwipe = { | ||||
|     template: document.createElement('div'), | ||||
|     currSlide: { | ||||
|         currZoomLevel: 1, | ||||
|         zoomTo: jest.fn(), | ||||
|         data: { | ||||
|             src: 'test.jpg', | ||||
|             alt: 'Test image', | ||||
|             title: 'Test', | ||||
|             width: 800, | ||||
|             height: 600 | ||||
|         } | ||||
|     }, | ||||
|     currIndex: 0, | ||||
|     viewportSize: { x: 800, y: 600 }, | ||||
|     ui: { toggle: jest.fn() }, | ||||
|     next: jest.fn(), | ||||
|     prev: jest.fn(), | ||||
|     goTo: jest.fn(), | ||||
|     close: jest.fn(), | ||||
|     getNumItems: () => 5, | ||||
|     on: jest.fn(), | ||||
|     off: jest.fn(), | ||||
|     options: { | ||||
|         showAnimationDuration: 250, | ||||
|         hideAnimationDuration: 250 | ||||
|     } | ||||
| } as unknown as PhotoSwipe; | ||||
|  | ||||
| describe('PhotoSwipeMobileA11yService', () => { | ||||
|     beforeEach(() => { | ||||
|         // Reset DOM | ||||
|         document.body.innerHTML = ''; | ||||
|          | ||||
|         // Reset mocks | ||||
|         jest.clearAllMocks(); | ||||
|     }); | ||||
|      | ||||
|     afterEach(() => { | ||||
|         // Cleanup | ||||
|         mobileA11yService.cleanup(); | ||||
|     }); | ||||
|      | ||||
|     describe('Device Capabilities Detection', () => { | ||||
|         it('should detect touch device capabilities', () => { | ||||
|             // Add touch support to window | ||||
|             Object.defineProperty(window, 'ontouchstart', { | ||||
|                 value: () => {}, | ||||
|                 writable: true | ||||
|             }); | ||||
|              | ||||
|             // Service should detect touch support on initialization | ||||
|             const service = mobileA11yService; | ||||
|             expect(service).toBeDefined(); | ||||
|         }); | ||||
|          | ||||
|         it('should detect accessibility preferences', () => { | ||||
|             // Mock matchMedia for reduced motion | ||||
|             const mockMatchMedia = jest.fn().mockImplementation(query => ({ | ||||
|                 matches: query === '(prefers-reduced-motion: reduce)', | ||||
|                 media: query, | ||||
|                 addListener: jest.fn(), | ||||
|                 removeListener: jest.fn() | ||||
|             })); | ||||
|              | ||||
|             Object.defineProperty(window, 'matchMedia', { | ||||
|                 value: mockMatchMedia, | ||||
|                 writable: true | ||||
|             }); | ||||
|              | ||||
|             const service = mobileA11yService; | ||||
|             expect(service).toBeDefined(); | ||||
|         }); | ||||
|     }); | ||||
|      | ||||
|     describe('ARIA Live Region', () => { | ||||
|         it('should create ARIA live region for announcements', () => { | ||||
|             mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe); | ||||
|              | ||||
|             const liveRegion = document.querySelector('[aria-live]'); | ||||
|             expect(liveRegion).toBeTruthy(); | ||||
|             expect(liveRegion?.getAttribute('aria-live')).toBe('polite'); | ||||
|             expect(liveRegion?.getAttribute('role')).toBe('status'); | ||||
|         }); | ||||
|          | ||||
|         it('should announce changes to screen readers', () => { | ||||
|             mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe, { | ||||
|                 a11y: { | ||||
|                     enableScreenReaderAnnouncements: true | ||||
|                 } | ||||
|             }); | ||||
|              | ||||
|             const liveRegion = document.querySelector('[aria-live]'); | ||||
|              | ||||
|             // Trigger navigation | ||||
|             const changeHandler = (mockPhotoSwipe.on as jest.Mock).mock.calls | ||||
|                 .find(call => call[0] === 'change')?.[1]; | ||||
|              | ||||
|             if (changeHandler) { | ||||
|                 changeHandler(); | ||||
|                  | ||||
|                 // Check if announcement was made | ||||
|                 expect(liveRegion?.textContent).toContain('Image 1 of 5'); | ||||
|             } | ||||
|         }); | ||||
|     }); | ||||
|      | ||||
|     describe('Keyboard Navigation', () => { | ||||
|         it('should handle arrow key navigation', () => { | ||||
|             mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe); | ||||
|              | ||||
|             // Simulate arrow key presses | ||||
|             const leftArrow = new KeyboardEvent('keydown', { key: 'ArrowLeft' }); | ||||
|             const rightArrow = new KeyboardEvent('keydown', { key: 'ArrowRight' }); | ||||
|              | ||||
|             document.dispatchEvent(leftArrow); | ||||
|             expect(mockPhotoSwipe.prev).toHaveBeenCalled(); | ||||
|              | ||||
|             document.dispatchEvent(rightArrow); | ||||
|             expect(mockPhotoSwipe.next).toHaveBeenCalled(); | ||||
|         }); | ||||
|          | ||||
|         it('should handle zoom with arrow keys', () => { | ||||
|             mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe); | ||||
|              | ||||
|             const upArrow = new KeyboardEvent('keydown', { key: 'ArrowUp' }); | ||||
|             const downArrow = new KeyboardEvent('keydown', { key: 'ArrowDown' }); | ||||
|              | ||||
|             document.dispatchEvent(upArrow); | ||||
|             expect(mockPhotoSwipe.currSlide?.zoomTo).toHaveBeenCalledWith( | ||||
|                 expect.any(Number), | ||||
|                 expect.any(Object), | ||||
|                 333 | ||||
|             ); | ||||
|              | ||||
|             document.dispatchEvent(downArrow); | ||||
|             expect(mockPhotoSwipe.currSlide?.zoomTo).toHaveBeenCalledTimes(2); | ||||
|         }); | ||||
|          | ||||
|         it('should show keyboard help on ? key', () => { | ||||
|             mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe); | ||||
|              | ||||
|             const helpKey = new KeyboardEvent('keydown', { key: '?' }); | ||||
|             document.dispatchEvent(helpKey); | ||||
|              | ||||
|             const helpDialog = document.querySelector('.photoswipe-keyboard-help'); | ||||
|             expect(helpDialog).toBeTruthy(); | ||||
|             expect(helpDialog?.getAttribute('role')).toBe('dialog'); | ||||
|         }); | ||||
|          | ||||
|         it('should support quick navigation with number keys', () => { | ||||
|             mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe); | ||||
|              | ||||
|             const key3 = new KeyboardEvent('keydown', { key: '3' }); | ||||
|             document.dispatchEvent(key3); | ||||
|              | ||||
|             expect(mockPhotoSwipe.goTo).toHaveBeenCalledWith(2); // 0-indexed | ||||
|         }); | ||||
|     }); | ||||
|      | ||||
|     describe('Touch Gestures', () => { | ||||
|         it('should handle pinch to zoom', () => { | ||||
|             mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe); | ||||
|              | ||||
|             const element = mockPhotoSwipe.template; | ||||
|              | ||||
|             // Simulate pinch gesture | ||||
|             const touch1 = { clientX: 100, clientY: 100, identifier: 0 }; | ||||
|             const touch2 = { clientX: 200, clientY: 200, identifier: 1 }; | ||||
|              | ||||
|             const touchStart = new TouchEvent('touchstart', { | ||||
|                 touches: [touch1, touch2] as any | ||||
|             }); | ||||
|              | ||||
|             element?.dispatchEvent(touchStart); | ||||
|              | ||||
|             // Move touches apart (zoom in) | ||||
|             const touch1Move = { clientX: 50, clientY: 50, identifier: 0 }; | ||||
|             const touch2Move = { clientX: 250, clientY: 250, identifier: 1 }; | ||||
|              | ||||
|             const touchMove = new TouchEvent('touchmove', { | ||||
|                 touches: [touch1Move, touch2Move] as any | ||||
|             }); | ||||
|              | ||||
|             element?.dispatchEvent(touchMove); | ||||
|              | ||||
|             // Zoom should be triggered | ||||
|             expect(mockPhotoSwipe.currSlide?.zoomTo).toHaveBeenCalled(); | ||||
|         }); | ||||
|          | ||||
|         it('should handle double tap to zoom', (done) => { | ||||
|             mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe); | ||||
|              | ||||
|             const element = mockPhotoSwipe.template; | ||||
|             const pos = { clientX: 400, clientY: 300 }; | ||||
|              | ||||
|             // First tap | ||||
|             const firstTap = new TouchEvent('touchend', { | ||||
|                 changedTouches: [{ ...pos, identifier: 0 }] as any | ||||
|             }); | ||||
|              | ||||
|             element?.dispatchEvent(new TouchEvent('touchstart', { | ||||
|                 touches: [{ ...pos, identifier: 0 }] as any | ||||
|             })); | ||||
|             element?.dispatchEvent(firstTap); | ||||
|              | ||||
|             // Second tap within double tap delay | ||||
|             setTimeout(() => { | ||||
|                 element?.dispatchEvent(new TouchEvent('touchstart', { | ||||
|                     touches: [{ ...pos, identifier: 0 }] as any | ||||
|                 })); | ||||
|                  | ||||
|                 const secondTap = new TouchEvent('touchend', { | ||||
|                     changedTouches: [{ ...pos, identifier: 0 }] as any | ||||
|                 }); | ||||
|                 element?.dispatchEvent(secondTap); | ||||
|                  | ||||
|                 // Check zoom was triggered | ||||
|                 expect(mockPhotoSwipe.currSlide?.zoomTo).toHaveBeenCalled(); | ||||
|                 done(); | ||||
|             }, 100); | ||||
|         }); | ||||
|          | ||||
|         it('should detect swipe gestures', () => { | ||||
|             mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe); | ||||
|              | ||||
|             const element = mockPhotoSwipe.template; | ||||
|              | ||||
|             // Simulate swipe left | ||||
|             const touchStart = new TouchEvent('touchstart', { | ||||
|                 touches: [{ clientX: 300, clientY: 300, identifier: 0 }] as any | ||||
|             }); | ||||
|              | ||||
|             const touchEnd = new TouchEvent('touchend', { | ||||
|                 changedTouches: [{ clientX: 100, clientY: 300, identifier: 0 }] as any | ||||
|             }); | ||||
|              | ||||
|             element?.dispatchEvent(touchStart); | ||||
|             element?.dispatchEvent(touchEnd); | ||||
|              | ||||
|             // Should navigate to next image | ||||
|             expect(mockPhotoSwipe.next).toHaveBeenCalled(); | ||||
|         }); | ||||
|     }); | ||||
|      | ||||
|     describe('Focus Management', () => { | ||||
|         it('should trap focus within gallery', () => { | ||||
|             mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe); | ||||
|              | ||||
|             const element = mockPhotoSwipe.template; | ||||
|              | ||||
|             // Add focusable elements | ||||
|             const button1 = document.createElement('button'); | ||||
|             const button2 = document.createElement('button'); | ||||
|             element?.appendChild(button1); | ||||
|             element?.appendChild(button2); | ||||
|              | ||||
|             // Focus first button | ||||
|             button1.focus(); | ||||
|              | ||||
|             // Simulate Tab on last focusable element | ||||
|             const tabEvent = new KeyboardEvent('keydown', { | ||||
|                 key: 'Tab', | ||||
|                 shiftKey: false | ||||
|             }); | ||||
|              | ||||
|             button2.focus(); | ||||
|             element?.dispatchEvent(tabEvent); | ||||
|              | ||||
|             // Focus should wrap to first element | ||||
|             expect(document.activeElement).toBe(button1); | ||||
|         }); | ||||
|          | ||||
|         it('should restore focus on close', () => { | ||||
|             const originalFocus = document.createElement('button'); | ||||
|             document.body.appendChild(originalFocus); | ||||
|             originalFocus.focus(); | ||||
|              | ||||
|             mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe); | ||||
|              | ||||
|             // Trigger close handler | ||||
|             const closeHandler = (mockPhotoSwipe.on as jest.Mock).mock.calls | ||||
|                 .find(call => call[0] === 'close')?.[1]; | ||||
|              | ||||
|             if (closeHandler) { | ||||
|                 closeHandler(); | ||||
|                  | ||||
|                 // Focus should be restored | ||||
|                 expect(document.activeElement).toBe(originalFocus); | ||||
|             } | ||||
|         }); | ||||
|     }); | ||||
|      | ||||
|     describe('ARIA Attributes', () => { | ||||
|         it('should add proper ARIA attributes to gallery', () => { | ||||
|             mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe); | ||||
|              | ||||
|             const element = mockPhotoSwipe.template; | ||||
|              | ||||
|             expect(element?.getAttribute('role')).toBe('dialog'); | ||||
|             expect(element?.getAttribute('aria-label')).toContain('Image gallery'); | ||||
|             expect(element?.getAttribute('aria-modal')).toBe('true'); | ||||
|         }); | ||||
|          | ||||
|         it('should label controls for screen readers', () => { | ||||
|             mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe); | ||||
|              | ||||
|             const element = mockPhotoSwipe.template; | ||||
|              | ||||
|             // Add mock controls | ||||
|             const prevBtn = document.createElement('button'); | ||||
|             prevBtn.className = 'pswp__button--arrow--prev'; | ||||
|             element?.appendChild(prevBtn); | ||||
|              | ||||
|             const nextBtn = document.createElement('button'); | ||||
|             nextBtn.className = 'pswp__button--arrow--next'; | ||||
|             element?.appendChild(nextBtn); | ||||
|              | ||||
|             // Enhance again to label the newly added controls | ||||
|             mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe); | ||||
|              | ||||
|             expect(prevBtn.getAttribute('aria-label')).toBe('Previous image'); | ||||
|             expect(nextBtn.getAttribute('aria-label')).toBe('Next image'); | ||||
|         }); | ||||
|     }); | ||||
|      | ||||
|     describe('Mobile UI Adaptations', () => { | ||||
|         it('should ensure minimum touch target size', () => { | ||||
|             mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe, { | ||||
|                 mobileUI: { | ||||
|                     minTouchTargetSize: 44 | ||||
|                 } | ||||
|             }); | ||||
|              | ||||
|             const element = mockPhotoSwipe.template; | ||||
|              | ||||
|             // Add a button | ||||
|             const button = document.createElement('button'); | ||||
|             button.className = 'pswp__button'; | ||||
|             button.style.width = '30px'; | ||||
|             button.style.height = '30px'; | ||||
|             element?.appendChild(button); | ||||
|              | ||||
|             // Enhance to apply minimum sizes | ||||
|             mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe); | ||||
|              | ||||
|             // Button should be resized to meet minimum | ||||
|             expect(button.style.minWidth).toBe('44px'); | ||||
|             expect(button.style.minHeight).toBe('44px'); | ||||
|         }); | ||||
|          | ||||
|         it('should add swipe indicators for mobile', () => { | ||||
|             // Mock as mobile device | ||||
|             Object.defineProperty(window, 'ontouchstart', { | ||||
|                 value: () => {}, | ||||
|                 writable: true | ||||
|             }); | ||||
|              | ||||
|             mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe, { | ||||
|                 mobileUI: { | ||||
|                     swipeIndicators: true | ||||
|                 } | ||||
|             }); | ||||
|              | ||||
|             const indicators = document.querySelector('.photoswipe-swipe-indicators'); | ||||
|             expect(indicators).toBeTruthy(); | ||||
|         }); | ||||
|     }); | ||||
|      | ||||
|     describe('Performance Optimizations', () => { | ||||
|         it('should adapt quality based on device capabilities', () => { | ||||
|             // Mock low memory device | ||||
|             Object.defineProperty(navigator, 'deviceMemory', { | ||||
|                 value: 1, | ||||
|                 writable: true | ||||
|             }); | ||||
|              | ||||
|             mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe, { | ||||
|                 performance: { | ||||
|                     adaptiveQuality: true | ||||
|                 } | ||||
|             }); | ||||
|              | ||||
|             // Service should detect low memory and adjust settings | ||||
|             expect(mobileA11yService).toBeDefined(); | ||||
|         }); | ||||
|          | ||||
|         it('should apply reduced motion preferences', () => { | ||||
|             // Mock reduced motion preference | ||||
|             const mockMatchMedia = jest.fn().mockImplementation(query => ({ | ||||
|                 matches: query === '(prefers-reduced-motion: reduce)', | ||||
|                 media: query, | ||||
|                 addListener: jest.fn(), | ||||
|                 removeListener: jest.fn() | ||||
|             })); | ||||
|              | ||||
|             Object.defineProperty(window, 'matchMedia', { | ||||
|                 value: mockMatchMedia, | ||||
|                 writable: true | ||||
|             }); | ||||
|              | ||||
|             mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe); | ||||
|              | ||||
|             // Animations should be disabled | ||||
|             expect(mockPhotoSwipe.options.showAnimationDuration).toBe(0); | ||||
|             expect(mockPhotoSwipe.options.hideAnimationDuration).toBe(0); | ||||
|         }); | ||||
|          | ||||
|         it('should optimize for battery saving', () => { | ||||
|             // Mock battery API | ||||
|             const mockBattery = { | ||||
|                 charging: false, | ||||
|                 level: 0.15, | ||||
|                 addEventListener: jest.fn() | ||||
|             }; | ||||
|              | ||||
|             (navigator as any).getBattery = jest.fn().mockResolvedValue(mockBattery); | ||||
|              | ||||
|             mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe, { | ||||
|                 performance: { | ||||
|                     batteryOptimization: true | ||||
|                 } | ||||
|             }); | ||||
|              | ||||
|             // Battery optimization should be enabled | ||||
|             expect((navigator as any).getBattery).toHaveBeenCalled(); | ||||
|         }); | ||||
|     }); | ||||
|      | ||||
|     describe('High Contrast Mode', () => { | ||||
|         it('should apply high contrast styles when enabled', () => { | ||||
|             // Mock high contrast preference | ||||
|             const mockMatchMedia = jest.fn().mockImplementation(query => ({ | ||||
|                 matches: query === '(prefers-contrast: high)', | ||||
|                 media: query, | ||||
|                 addListener: jest.fn(), | ||||
|                 removeListener: jest.fn() | ||||
|             })); | ||||
|              | ||||
|             Object.defineProperty(window, 'matchMedia', { | ||||
|                 value: mockMatchMedia, | ||||
|                 writable: true | ||||
|             }); | ||||
|              | ||||
|             mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe); | ||||
|              | ||||
|             const element = mockPhotoSwipe.template; | ||||
|              | ||||
|             // Should have high contrast styles | ||||
|             expect(element?.style.outline).toContain('2px solid white'); | ||||
|         }); | ||||
|     }); | ||||
|      | ||||
|     describe('Haptic Feedback', () => { | ||||
|         it('should trigger haptic feedback on supported devices', () => { | ||||
|             // Mock vibration API | ||||
|             const mockVibrate = jest.fn(); | ||||
|             Object.defineProperty(navigator, 'vibrate', { | ||||
|                 value: mockVibrate, | ||||
|                 writable: true | ||||
|             }); | ||||
|              | ||||
|             mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe, { | ||||
|                 touch: { | ||||
|                     hapticFeedback: true | ||||
|                 } | ||||
|             }); | ||||
|              | ||||
|             // Trigger a gesture that should cause haptic feedback | ||||
|             const element = mockPhotoSwipe.template; | ||||
|              | ||||
|             // Double tap | ||||
|             const tap = new TouchEvent('touchend', { | ||||
|                 changedTouches: [{ clientX: 100, clientY: 100, identifier: 0 }] as any | ||||
|             }); | ||||
|              | ||||
|             element?.dispatchEvent(new TouchEvent('touchstart', { | ||||
|                 touches: [{ clientX: 100, clientY: 100, identifier: 0 }] as any | ||||
|             })); | ||||
|             element?.dispatchEvent(tap); | ||||
|              | ||||
|             // Quick second tap | ||||
|             setTimeout(() => { | ||||
|                 element?.dispatchEvent(new TouchEvent('touchstart', { | ||||
|                     touches: [{ clientX: 100, clientY: 100, identifier: 0 }] as any | ||||
|                 })); | ||||
|                 element?.dispatchEvent(tap); | ||||
|                  | ||||
|                 // Haptic feedback should be triggered | ||||
|                 expect(mockVibrate).toHaveBeenCalled(); | ||||
|             }, 50); | ||||
|         }); | ||||
|     }); | ||||
|      | ||||
|     describe('Configuration Updates', () => { | ||||
|         it('should update configuration dynamically', () => { | ||||
|             mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe); | ||||
|              | ||||
|             // Update configuration | ||||
|             mobileA11yService.updateConfig({ | ||||
|                 a11y: { | ||||
|                     ariaLiveRegion: 'assertive' | ||||
|                 }, | ||||
|                 touch: { | ||||
|                     hapticFeedback: false | ||||
|                 } | ||||
|             }); | ||||
|              | ||||
|             const liveRegion = document.querySelector('[aria-live]'); | ||||
|             expect(liveRegion?.getAttribute('aria-live')).toBe('assertive'); | ||||
|         }); | ||||
|     }); | ||||
|      | ||||
|     describe('Cleanup', () => { | ||||
|         it('should properly cleanup resources', () => { | ||||
|             mobileA11yService.enhancePhotoSwipe(mockPhotoSwipe); | ||||
|              | ||||
|             // Create some elements | ||||
|             const liveRegion = document.querySelector('[aria-live]'); | ||||
|             const helpDialog = document.querySelector('.photoswipe-keyboard-help'); | ||||
|              | ||||
|             expect(liveRegion).toBeTruthy(); | ||||
|              | ||||
|             // Cleanup | ||||
|             mobileA11yService.cleanup(); | ||||
|              | ||||
|             // Elements should be removed | ||||
|             expect(document.querySelector('[aria-live]')).toBeFalsy(); | ||||
|             expect(document.querySelector('.photoswipe-keyboard-help')).toBeFalsy(); | ||||
|         }); | ||||
|     }); | ||||
| }); | ||||
							
								
								
									
										1756
									
								
								apps/client/src/services/photoswipe_mobile_a11y.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1756
									
								
								apps/client/src/services/photoswipe_mobile_a11y.ts
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										284
									
								
								apps/client/src/styles/gallery.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										284
									
								
								apps/client/src/styles/gallery.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,284 @@ | ||||
| /** | ||||
|  * Gallery styles for PhotoSwipe integration | ||||
|  * Provides styling for gallery UI elements | ||||
|  */ | ||||
|  | ||||
| /* Gallery thumbnail strip */ | ||||
| .gallery-thumbnail-strip { | ||||
|     scrollbar-width: thin; | ||||
|     scrollbar-color: rgba(255, 255, 255, 0.3) transparent; | ||||
| } | ||||
|  | ||||
| .gallery-thumbnail-strip::-webkit-scrollbar { | ||||
|     height: 6px; | ||||
| } | ||||
|  | ||||
| .gallery-thumbnail-strip::-webkit-scrollbar-track { | ||||
|     background: transparent; | ||||
| } | ||||
|  | ||||
| .gallery-thumbnail-strip::-webkit-scrollbar-thumb { | ||||
|     background: rgba(255, 255, 255, 0.3); | ||||
|     border-radius: 3px; | ||||
| } | ||||
|  | ||||
| .gallery-thumbnail-strip::-webkit-scrollbar-thumb:hover { | ||||
|     background: rgba(255, 255, 255, 0.5); | ||||
| } | ||||
|  | ||||
| .gallery-thumbnail { | ||||
|     position: relative; | ||||
|     overflow: hidden; | ||||
| } | ||||
|  | ||||
| .gallery-thumbnail.active::after { | ||||
|     content: ''; | ||||
|     position: absolute; | ||||
|     inset: 0; | ||||
|     background: rgba(255, 255, 255, 0.1); | ||||
|     pointer-events: none; | ||||
| } | ||||
|  | ||||
| /* Gallery controls animations */ | ||||
| .gallery-slideshow-controls button { | ||||
|     transition: transform 0.2s, background 0.2s; | ||||
| } | ||||
|  | ||||
| .gallery-slideshow-controls button:hover { | ||||
|     transform: scale(1.1); | ||||
|     background: rgba(255, 255, 255, 1) !important; | ||||
| } | ||||
|  | ||||
| .gallery-slideshow-controls button:active { | ||||
|     transform: scale(0.95); | ||||
| } | ||||
|  | ||||
| /* Slideshow progress indicator */ | ||||
| .slideshow-progress { | ||||
|     position: absolute; | ||||
|     bottom: 0; | ||||
|     left: 0; | ||||
|     right: 0; | ||||
|     height: 3px; | ||||
|     background: rgba(255, 255, 255, 0.2); | ||||
|     z-index: 101; | ||||
| } | ||||
|  | ||||
| .slideshow-progress-bar { | ||||
|     height: 100%; | ||||
|     background: rgba(255, 255, 255, 0.8); | ||||
|     width: 0; | ||||
|     transition: width linear; | ||||
| } | ||||
|  | ||||
| .slideshow-progress.active .slideshow-progress-bar { | ||||
|     animation: slideshow-progress var(--slideshow-interval) linear; | ||||
| } | ||||
|  | ||||
| @keyframes slideshow-progress { | ||||
|     from { width: 0; } | ||||
|     to { width: 100%; } | ||||
| } | ||||
|  | ||||
| /* Gallery counter styling */ | ||||
| .gallery-counter { | ||||
|     font-family: var(--font-family-monospace); | ||||
|     letter-spacing: 0.05em; | ||||
|     user-select: none; | ||||
| } | ||||
|  | ||||
| .gallery-counter .current-index { | ||||
|     font-weight: bold; | ||||
|     color: #fff; | ||||
| } | ||||
|  | ||||
| .gallery-counter .total-count { | ||||
|     opacity: 0.8; | ||||
| } | ||||
|  | ||||
| /* Enhanced image hover effects */ | ||||
| .pswp__img { | ||||
|     transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); | ||||
| } | ||||
|  | ||||
| .pswp__img.pswp__img--zoomed { | ||||
|     cursor: move; | ||||
| } | ||||
|  | ||||
| /* Gallery navigation arrows */ | ||||
| .pswp__button--arrow--left, | ||||
| .pswp__button--arrow--right { | ||||
|     background: rgba(0, 0, 0, 0.5) !important; | ||||
|     backdrop-filter: blur(10px); | ||||
|     border-radius: 50%; | ||||
|     width: 50px; | ||||
|     height: 50px; | ||||
|     margin: 20px; | ||||
| } | ||||
|  | ||||
| .pswp__button--arrow--left:hover, | ||||
| .pswp__button--arrow--right:hover { | ||||
|     background: rgba(0, 0, 0, 0.7) !important; | ||||
| } | ||||
|  | ||||
| /* Touch-friendly tap areas */ | ||||
| @media (pointer: coarse) { | ||||
|     .gallery-thumbnail { | ||||
|         min-width: 60px; | ||||
|         min-height: 60px; | ||||
|     } | ||||
|      | ||||
|     .gallery-slideshow-controls button { | ||||
|         min-width: 50px; | ||||
|         min-height: 50px; | ||||
|     } | ||||
| } | ||||
|  | ||||
| /* Smooth transitions */ | ||||
| .pswp--animate_opacity { | ||||
|     transition: opacity 250ms cubic-bezier(0.4, 0, 0.2, 1); | ||||
| } | ||||
|  | ||||
| .pswp__bg { | ||||
|     transition: opacity 250ms cubic-bezier(0.4, 0, 0.2, 1); | ||||
| } | ||||
|  | ||||
| /* Loading state */ | ||||
| .pswp__preloader { | ||||
|     position: absolute; | ||||
|     top: 50%; | ||||
|     left: 50%; | ||||
|     transform: translate(-50%, -50%); | ||||
| } | ||||
|  | ||||
| .pswp__preloader__icn { | ||||
|     width: 30px; | ||||
|     height: 30px; | ||||
|     border: 3px solid rgba(255, 255, 255, 0.3); | ||||
|     border-top-color: #fff; | ||||
|     border-radius: 50%; | ||||
|     animation: gallery-spin 1s linear infinite; | ||||
| } | ||||
|  | ||||
| @keyframes gallery-spin { | ||||
|     to { transform: rotate(360deg); } | ||||
| } | ||||
|  | ||||
| /* Error state */ | ||||
| .pswp__error-msg { | ||||
|     background: rgba(0, 0, 0, 0.8); | ||||
|     color: #ff6b6b; | ||||
|     padding: 20px; | ||||
|     border-radius: 8px; | ||||
|     max-width: 400px; | ||||
|     text-align: center; | ||||
| } | ||||
|  | ||||
| /* Accessibility improvements */ | ||||
| .pswp__button:focus-visible { | ||||
|     outline: 2px solid #4a9eff; | ||||
|     outline-offset: 2px; | ||||
| } | ||||
|  | ||||
| .gallery-thumbnail:focus-visible { | ||||
|     outline: 2px solid #4a9eff; | ||||
|     outline-offset: -2px; | ||||
| } | ||||
|  | ||||
| /* Dark theme adjustments */ | ||||
| body.theme-dark .gallery-thumbnail-strip { | ||||
|     background: rgba(0, 0, 0, 0.8); | ||||
| } | ||||
|  | ||||
| body.theme-dark .gallery-slideshow-controls button { | ||||
|     background: rgba(255, 255, 255, 0.8); | ||||
|     color: #000; | ||||
| } | ||||
|  | ||||
| body.theme-dark .gallery-slideshow-controls button:hover { | ||||
|     background: rgba(255, 255, 255, 0.95); | ||||
| } | ||||
|  | ||||
| /* Light theme adjustments */ | ||||
| body.theme-light .pswp__bg { | ||||
|     background: rgba(255, 255, 255, 0.95); | ||||
| } | ||||
|  | ||||
| body.theme-light .gallery-counter, | ||||
| body.theme-light .gallery-keyboard-hints { | ||||
|     background: rgba(0, 0, 0, 0.8); | ||||
|     color: white; | ||||
| } | ||||
|  | ||||
| /* Mobile-specific styles */ | ||||
| @media (max-width: 768px) { | ||||
|     .gallery-thumbnail-strip { | ||||
|         bottom: 40px; | ||||
|         padding: 6px; | ||||
|         gap: 4px; | ||||
|     } | ||||
|      | ||||
|     .gallery-thumbnail { | ||||
|         width: 50px !important; | ||||
|         height: 50px !important; | ||||
|     } | ||||
|      | ||||
|     .gallery-slideshow-controls { | ||||
|         top: 10px; | ||||
|         right: 10px; | ||||
|     } | ||||
|      | ||||
|     .gallery-counter { | ||||
|         font-size: 12px; | ||||
|         padding: 6px 10px; | ||||
|     } | ||||
|      | ||||
|     .pswp__button--arrow--left, | ||||
|     .pswp__button--arrow--right { | ||||
|         width: 40px; | ||||
|         height: 40px; | ||||
|         margin: 10px; | ||||
|     } | ||||
| } | ||||
|  | ||||
| /* Tablet-specific styles */ | ||||
| @media (min-width: 769px) and (max-width: 1024px) { | ||||
|     .gallery-thumbnail-strip { | ||||
|         max-width: 80%; | ||||
|     } | ||||
|      | ||||
|     .gallery-thumbnail { | ||||
|         width: 70px !important; | ||||
|         height: 70px !important; | ||||
|     } | ||||
| } | ||||
|  | ||||
| /* High-DPI display optimizations */ | ||||
| @media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) { | ||||
|     .gallery-thumbnail img { | ||||
|         image-rendering: -webkit-optimize-contrast; | ||||
|         image-rendering: crisp-edges; | ||||
|     } | ||||
| } | ||||
|  | ||||
| /* Reduced motion support */ | ||||
| @media (prefers-reduced-motion: reduce) { | ||||
|     .gallery-thumbnail, | ||||
|     .gallery-slideshow-controls button, | ||||
|     .pswp__img, | ||||
|     .pswp--animate_opacity, | ||||
|     .pswp__bg { | ||||
|         transition: none !important; | ||||
|         animation: none !important; | ||||
|     } | ||||
| } | ||||
|  | ||||
| /* Print styles */ | ||||
| @media print { | ||||
|     .gallery-thumbnail-strip, | ||||
|     .gallery-slideshow-controls, | ||||
|     .gallery-counter, | ||||
|     .gallery-keyboard-hints { | ||||
|         display: none !important; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										528
									
								
								apps/client/src/styles/photoswipe-mobile-a11y.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										528
									
								
								apps/client/src/styles/photoswipe-mobile-a11y.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,528 @@ | ||||
| /** | ||||
|  * PhotoSwipe Mobile & Accessibility Styles | ||||
|  * Phase 6: Complete mobile optimization and WCAG 2.1 AA compliance | ||||
|  */ | ||||
|  | ||||
| /* ========================================================================== | ||||
|    Touch Target Optimization (WCAG 2.1 Success Criterion 2.5.5) | ||||
|    ========================================================================== */ | ||||
|  | ||||
| /* Ensure all interactive elements meet minimum 44x44px touch target */ | ||||
| .pswp__button, | ||||
| .pswp__button--arrow--left, | ||||
| .pswp__button--arrow--right, | ||||
| .pswp__button--close, | ||||
| .pswp__button--zoom, | ||||
| .pswp__button--fs, | ||||
| .gallery-thumbnail, | ||||
| .photoswipe-bottom-sheet button { | ||||
|     min-width: 44px !important; | ||||
|     min-height: 44px !important; | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
| } | ||||
|  | ||||
| /* Increase touch target padding on mobile */ | ||||
| @media (pointer: coarse) { | ||||
|     .pswp__button { | ||||
|         padding: 12px; | ||||
|     } | ||||
|      | ||||
|     /* Larger hit areas for navigation arrows */ | ||||
|     .pswp__button--arrow--left, | ||||
|     .pswp__button--arrow--right { | ||||
|         width: 60px !important; | ||||
|         height: 100px !important; | ||||
|     } | ||||
| } | ||||
|  | ||||
| /* ========================================================================== | ||||
|    Focus Indicators (WCAG 2.1 Success Criterion 2.4.7) | ||||
|    ========================================================================== */ | ||||
|  | ||||
| /* High visibility focus indicators */ | ||||
| .pswp__button:focus, | ||||
| .pswp__button:focus-visible, | ||||
| .gallery-thumbnail:focus, | ||||
| .photoswipe-bottom-sheet button:focus, | ||||
| .photoswipe-focused { | ||||
|     outline: 3px solid #4A90E2 !important; | ||||
|     outline-offset: 2px !important; | ||||
|     box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.3); | ||||
| } | ||||
|  | ||||
| /* Remove default browser outline */ | ||||
| .pswp__button:focus:not(:focus-visible) { | ||||
|     outline: none; | ||||
| } | ||||
|  | ||||
| /* Focus indicator for images */ | ||||
| .pswp__img:focus { | ||||
|     outline: 3px solid #4A90E2; | ||||
|     outline-offset: -3px; | ||||
| } | ||||
|  | ||||
| /* Skip link styles */ | ||||
| .photoswipe-skip-link { | ||||
|     position: absolute; | ||||
|     left: -10000px; | ||||
|     top: 0; | ||||
|     background: #000; | ||||
|     color: #fff; | ||||
|     padding: 8px 16px; | ||||
|     text-decoration: none; | ||||
|     z-index: 100000; | ||||
|     border-radius: 4px; | ||||
| } | ||||
|  | ||||
| .photoswipe-skip-link:focus { | ||||
|     left: 10px !important; | ||||
|     top: 10px !important; | ||||
|     width: auto !important; | ||||
|     height: auto !important; | ||||
| } | ||||
|  | ||||
| /* ========================================================================== | ||||
|    Mobile UI Adaptations | ||||
|    ========================================================================== */ | ||||
|  | ||||
| /* Mobile-optimized toolbar */ | ||||
| @media (max-width: 768px) { | ||||
|     .pswp__top-bar { | ||||
|         height: 60px; | ||||
|         background: rgba(0, 0, 0, 0.8); | ||||
|     } | ||||
|      | ||||
|     .pswp__button { | ||||
|         width: 50px; | ||||
|         height: 50px; | ||||
|     } | ||||
|      | ||||
|     /* Reposition counter for mobile */ | ||||
|     .pswp__counter { | ||||
|         top: auto; | ||||
|         bottom: 70px; | ||||
|         left: 50%; | ||||
|         transform: translateX(-50%); | ||||
|         background: rgba(0, 0, 0, 0.7); | ||||
|         padding: 8px 16px; | ||||
|         border-radius: 20px; | ||||
|         font-size: 14px; | ||||
|     } | ||||
| } | ||||
|  | ||||
| /* Bottom sheet for mobile controls */ | ||||
| .photoswipe-bottom-sheet { | ||||
|     position: fixed; | ||||
|     bottom: 0; | ||||
|     left: 0; | ||||
|     right: 0; | ||||
|     background: rgba(0, 0, 0, 0.95); | ||||
|     padding: env(safe-area-inset-bottom, 20px) 20px; | ||||
|     display: flex; | ||||
|     justify-content: space-around; | ||||
|     align-items: center; | ||||
|     z-index: 100; | ||||
|     transform: translateY(100%); | ||||
|     transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); | ||||
|     border-top: 1px solid rgba(255, 255, 255, 0.1); | ||||
| } | ||||
|  | ||||
| .photoswipe-bottom-sheet.active { | ||||
|     transform: translateY(0); | ||||
| } | ||||
|  | ||||
| .photoswipe-bottom-sheet button { | ||||
|     background: none; | ||||
|     border: 2px solid transparent; | ||||
|     color: white; | ||||
|     font-size: 24px; | ||||
|     padding: 10px; | ||||
|     border-radius: 50%; | ||||
|     transition: all 0.2s; | ||||
| } | ||||
|  | ||||
| .photoswipe-bottom-sheet button:active { | ||||
|     background: rgba(255, 255, 255, 0.1); | ||||
|     transform: scale(0.95); | ||||
| } | ||||
|  | ||||
| /* Swipe indicators */ | ||||
| .photoswipe-swipe-indicators { | ||||
|     position: absolute; | ||||
|     top: 50%; | ||||
|     left: 50%; | ||||
|     transform: translate(-50%, -50%); | ||||
|     pointer-events: none; | ||||
|     animation: fadeInOut 3s ease-in-out; | ||||
| } | ||||
|  | ||||
| @keyframes fadeInOut { | ||||
|     0%, 100% { opacity: 0; } | ||||
|     20%, 80% { opacity: 0.7; } | ||||
| } | ||||
|  | ||||
| /* Gesture hints */ | ||||
| .photoswipe-gesture-hints { | ||||
|     position: absolute; | ||||
|     top: 60px; | ||||
|     left: 50%; | ||||
|     transform: translateX(-50%); | ||||
|     background: rgba(0, 0, 0, 0.85); | ||||
|     color: white; | ||||
|     padding: 12px 24px; | ||||
|     border-radius: 24px; | ||||
|     font-size: 14px; | ||||
|     font-weight: 500; | ||||
|     box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); | ||||
|     pointer-events: none; | ||||
| } | ||||
|  | ||||
| /* Context menu for long press */ | ||||
| .photoswipe-context-menu { | ||||
|     background: var(--theme-background-color, white); | ||||
|     border: 1px solid var(--theme-border-color, #ccc); | ||||
|     border-radius: 8px; | ||||
|     box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); | ||||
|     overflow: hidden; | ||||
|     min-width: 200px; | ||||
| } | ||||
|  | ||||
| .photoswipe-context-menu button { | ||||
|     display: block; | ||||
|     width: 100%; | ||||
|     padding: 12px 16px; | ||||
|     border: none; | ||||
|     background: none; | ||||
|     text-align: left; | ||||
|     cursor: pointer; | ||||
|     font-size: 16px; | ||||
|     transition: background 0.2s; | ||||
| } | ||||
|  | ||||
| .photoswipe-context-menu button:hover, | ||||
| .photoswipe-context-menu button:focus { | ||||
|     background: var(--theme-hover-background, rgba(0, 0, 0, 0.05)); | ||||
| } | ||||
|  | ||||
| /* ========================================================================== | ||||
|    Responsive Breakpoints | ||||
|    ========================================================================== */ | ||||
|  | ||||
| /* Small phones (< 375px) */ | ||||
| @media (max-width: 374px) { | ||||
|     .pswp__button { | ||||
|         width: 40px; | ||||
|         height: 40px; | ||||
|     } | ||||
|      | ||||
|     .gallery-thumbnail-strip { | ||||
|         padding: 5px !important; | ||||
|     } | ||||
|      | ||||
|     .gallery-thumbnail { | ||||
|         width: 60px !important; | ||||
|         height: 60px !important; | ||||
|     } | ||||
| } | ||||
|  | ||||
| /* Tablets (768px - 1024px) */ | ||||
| @media (min-width: 768px) and (max-width: 1024px) { | ||||
|     .pswp__button { | ||||
|         width: 48px; | ||||
|         height: 48px; | ||||
|     } | ||||
|      | ||||
|     .gallery-thumbnail { | ||||
|         width: 90px !important; | ||||
|         height: 90px !important; | ||||
|     } | ||||
| } | ||||
|  | ||||
| /* Landscape orientation adjustments */ | ||||
| @media (orientation: landscape) and (max-height: 500px) { | ||||
|     .pswp__top-bar { | ||||
|         height: 44px; | ||||
|     } | ||||
|      | ||||
|     .pswp__button { | ||||
|         width: 44px; | ||||
|         height: 44px; | ||||
|     } | ||||
|      | ||||
|     .gallery-thumbnail-strip { | ||||
|         bottom: 40px !important; | ||||
|         max-height: 60px; | ||||
|     } | ||||
|      | ||||
|     .gallery-thumbnail { | ||||
|         width: 50px !important; | ||||
|         height: 50px !important; | ||||
|     } | ||||
| } | ||||
|  | ||||
| /* ========================================================================== | ||||
|    Accessibility Enhancements | ||||
|    ========================================================================== */ | ||||
|  | ||||
| /* Screen reader only content */ | ||||
| .photoswipe-sr-only, | ||||
| .photoswipe-live-region, | ||||
| .photoswipe-aria-live { | ||||
|     position: absolute !important; | ||||
|     left: -10000px !important; | ||||
|     width: 1px !important; | ||||
|     height: 1px !important; | ||||
|     overflow: hidden !important; | ||||
| } | ||||
|  | ||||
| /* Keyboard help dialog */ | ||||
| .photoswipe-keyboard-help { | ||||
|     position: fixed; | ||||
|     top: 50%; | ||||
|     left: 50%; | ||||
|     transform: translate(-50%, -50%); | ||||
|     background: var(--theme-background-color, white); | ||||
|     color: var(--theme-text-color, black); | ||||
|     padding: 30px; | ||||
|     border-radius: 12px; | ||||
|     box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3); | ||||
|     z-index: 10001; | ||||
|     max-width: 500px; | ||||
|     max-height: 80vh; | ||||
|     overflow-y: auto; | ||||
| } | ||||
|  | ||||
| .photoswipe-keyboard-help h2 { | ||||
|     margin: 0 0 20px 0; | ||||
|     font-size: 20px; | ||||
|     font-weight: 600; | ||||
| } | ||||
|  | ||||
| .photoswipe-keyboard-help dl { | ||||
|     margin: 0; | ||||
|     padding: 0; | ||||
| } | ||||
|  | ||||
| .photoswipe-keyboard-help dt { | ||||
|     float: left; | ||||
|     clear: left; | ||||
|     width: 120px; | ||||
|     margin-bottom: 10px; | ||||
| } | ||||
|  | ||||
| .photoswipe-keyboard-help dd { | ||||
|     margin-left: 140px; | ||||
|     margin-bottom: 10px; | ||||
| } | ||||
|  | ||||
| .photoswipe-keyboard-help kbd { | ||||
|     display: inline-block; | ||||
|     padding: 3px 8px; | ||||
|     background: var(--theme-kbd-background, #f0f0f0); | ||||
|     border: 1px solid var(--theme-border-color, #ccc); | ||||
|     border-radius: 4px; | ||||
|     font-family: monospace; | ||||
|     font-size: 14px; | ||||
|     box-shadow: 0 2px 0 rgba(0, 0, 0, 0.1); | ||||
| } | ||||
|  | ||||
| .photoswipe-keyboard-help .close-help { | ||||
|     margin-top: 20px; | ||||
|     padding: 10px 20px; | ||||
|     background: var(--theme-primary-color, #4A90E2); | ||||
|     color: white; | ||||
|     border: none; | ||||
|     border-radius: 6px; | ||||
|     cursor: pointer; | ||||
|     font-size: 16px; | ||||
|     transition: background 0.2s; | ||||
| } | ||||
|  | ||||
| .photoswipe-keyboard-help .close-help:hover, | ||||
| .photoswipe-keyboard-help .close-help:focus { | ||||
|     background: var(--theme-primary-hover, #357ABD); | ||||
| } | ||||
|  | ||||
| /* ========================================================================== | ||||
|    High Contrast Mode Support | ||||
|    ========================================================================== */ | ||||
|  | ||||
| @media (prefers-contrast: high) { | ||||
|     .pswp__bg { | ||||
|         background: #000 !important; | ||||
|     } | ||||
|      | ||||
|     .pswp__button { | ||||
|         background: #000 !important; | ||||
|         border: 2px solid #fff !important; | ||||
|     } | ||||
|      | ||||
|     .pswp__button svg { | ||||
|         fill: #fff !important; | ||||
|     } | ||||
|      | ||||
|     .pswp__counter { | ||||
|         background: #000 !important; | ||||
|         color: #fff !important; | ||||
|         border: 2px solid #fff !important; | ||||
|     } | ||||
|      | ||||
|     .gallery-thumbnail { | ||||
|         border-width: 3px !important; | ||||
|     } | ||||
|      | ||||
|     .photoswipe-keyboard-help { | ||||
|         background: #000 !important; | ||||
|         color: #fff !important; | ||||
|         border: 2px solid #fff !important; | ||||
|     } | ||||
|      | ||||
|     .photoswipe-keyboard-help kbd { | ||||
|         background: #fff !important; | ||||
|         color: #000 !important; | ||||
|         border-color: #fff !important; | ||||
|     } | ||||
| } | ||||
|  | ||||
| /* Windows High Contrast Mode */ | ||||
| @media (-ms-high-contrast: active) { | ||||
|     .pswp__button { | ||||
|         border: 2px solid WindowText !important; | ||||
|     } | ||||
|      | ||||
|     .pswp__counter { | ||||
|         border: 2px solid WindowText !important; | ||||
|     } | ||||
| } | ||||
|  | ||||
| /* ========================================================================== | ||||
|    Reduced Motion Support (WCAG 2.1 Success Criterion 2.3.3) | ||||
|    ========================================================================== */ | ||||
|  | ||||
| @media (prefers-reduced-motion: reduce) { | ||||
|     /* Disable all animations */ | ||||
|     .pswp *, | ||||
|     .pswp *::before, | ||||
|     .pswp *::after { | ||||
|         animation-duration: 0.01ms !important; | ||||
|         animation-iteration-count: 1 !important; | ||||
|         transition-duration: 0.01ms !important; | ||||
|     } | ||||
|      | ||||
|     /* Remove slide transitions */ | ||||
|     .pswp__container { | ||||
|         transition: none !important; | ||||
|     } | ||||
|      | ||||
|     /* Remove zoom animations */ | ||||
|     .pswp__img { | ||||
|         transition: none !important; | ||||
|     } | ||||
|      | ||||
|     /* Instant show/hide for indicators */ | ||||
|     .photoswipe-swipe-indicators, | ||||
|     .photoswipe-gesture-hints { | ||||
|         animation: none !important; | ||||
|         transition: none !important; | ||||
|     } | ||||
| } | ||||
|  | ||||
| /* ========================================================================== | ||||
|    Performance Optimizations | ||||
|    ========================================================================== */ | ||||
|  | ||||
| /* GPU acceleration for smooth animations */ | ||||
| .pswp__container, | ||||
| .pswp__img, | ||||
| .pswp__zoom-wrap { | ||||
|     will-change: transform; | ||||
|     transform: translateZ(0); | ||||
| } | ||||
|  | ||||
| /* Optimize rendering for low-end devices */ | ||||
| @media (max-width: 768px) and (max-resolution: 2dppx) { | ||||
|     .pswp__img { | ||||
|         image-rendering: -webkit-optimize-contrast; | ||||
|         image-rendering: crisp-edges; | ||||
|     } | ||||
| } | ||||
|  | ||||
| /* Reduce visual complexity on low-end devices */ | ||||
| .low-performance-mode .pswp__button { | ||||
|     box-shadow: none !important; | ||||
| } | ||||
|  | ||||
| .low-performance-mode .gallery-thumbnail { | ||||
|     box-shadow: none !important; | ||||
|     transition: none !important; | ||||
| } | ||||
|  | ||||
| /* ========================================================================== | ||||
|    Battery Optimization Mode | ||||
|    ========================================================================== */ | ||||
|  | ||||
| .battery-saver-mode .pswp__img { | ||||
|     filter: brightness(0.9); | ||||
| } | ||||
|  | ||||
| .battery-saver-mode .pswp__button { | ||||
|     opacity: 0.8; | ||||
| } | ||||
|  | ||||
| .battery-saver-mode .gallery-thumbnail-strip { | ||||
|     display: none; | ||||
| } | ||||
|  | ||||
| /* ========================================================================== | ||||
|    Print Styles | ||||
|    ========================================================================== */ | ||||
|  | ||||
| @media print { | ||||
|     .pswp { | ||||
|         display: none !important; | ||||
|     } | ||||
| } | ||||
|  | ||||
| /* ========================================================================== | ||||
|    Custom Scrollbar for Mobile | ||||
|    ========================================================================== */ | ||||
|  | ||||
| .gallery-thumbnail-strip::-webkit-scrollbar { | ||||
|     height: 4px; | ||||
| } | ||||
|  | ||||
| .gallery-thumbnail-strip::-webkit-scrollbar-track { | ||||
|     background: rgba(255, 255, 255, 0.1); | ||||
|     border-radius: 2px; | ||||
| } | ||||
|  | ||||
| .gallery-thumbnail-strip::-webkit-scrollbar-thumb { | ||||
|     background: rgba(255, 255, 255, 0.3); | ||||
|     border-radius: 2px; | ||||
| } | ||||
|  | ||||
| .gallery-thumbnail-strip::-webkit-scrollbar-thumb:hover { | ||||
|     background: rgba(255, 255, 255, 0.5); | ||||
| } | ||||
|  | ||||
| /* ========================================================================== | ||||
|    Safe Area Insets (for devices with notches) | ||||
|    ========================================================================== */ | ||||
|  | ||||
| .pswp__top-bar { | ||||
|     padding-top: env(safe-area-inset-top); | ||||
| } | ||||
|  | ||||
| .photoswipe-bottom-sheet { | ||||
|     padding-bottom: env(safe-area-inset-bottom); | ||||
| } | ||||
|  | ||||
| .pswp__button--arrow--left { | ||||
|     left: env(safe-area-inset-left, 10px); | ||||
| } | ||||
|  | ||||
| .pswp__button--arrow--right { | ||||
|     right: env(safe-area-inset-right, 10px); | ||||
| } | ||||
							
								
								
									
										253
									
								
								apps/client/src/stylesheets/media-viewer.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										253
									
								
								apps/client/src/stylesheets/media-viewer.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,253 @@ | ||||
| /** | ||||
|  * Media Viewer Styles for Trilium Notes | ||||
|  * Customizes PhotoSwipe appearance to match Trilium's theme | ||||
|  */ | ||||
|  | ||||
| /* Base PhotoSwipe container customization */ | ||||
| .pswp { | ||||
|     --pswp-bg: rgba(0, 0, 0, 0.95); | ||||
|     --pswp-placeholder-bg: rgba(30, 30, 30, 0.9); | ||||
|     --pswp-icon-color: #fff; | ||||
|     --pswp-icon-color-secondary: rgba(255, 255, 255, 0.75); | ||||
|     --pswp-icon-stroke-color: #fff; | ||||
|     --pswp-icon-stroke-width: 1px; | ||||
|     --pswp-error-text-color: #f44336; | ||||
| } | ||||
|  | ||||
| /* Dark theme adjustments */ | ||||
| body.theme-dark .pswp, | ||||
| body.theme-next-dark .pswp { | ||||
|     --pswp-bg: rgba(0, 0, 0, 0.95); | ||||
|     --pswp-placeholder-bg: rgba(30, 30, 30, 0.9); | ||||
| } | ||||
|  | ||||
| /* Light theme adjustments */ | ||||
| body.theme-light .pswp, | ||||
| body.theme-next-light .pswp { | ||||
|     --pswp-bg: rgba(0, 0, 0, 0.9); | ||||
|     --pswp-placeholder-bg: rgba(50, 50, 50, 0.8); | ||||
| } | ||||
|  | ||||
| /* Toolbar and controls styling */ | ||||
| .pswp__top-bar { | ||||
|     background-color: rgba(0, 0, 0, 0.5); | ||||
|     backdrop-filter: blur(10px); | ||||
| } | ||||
|  | ||||
| .pswp__button { | ||||
|     transition: opacity 0.2s ease-in-out; | ||||
| } | ||||
|  | ||||
| .pswp__button:hover { | ||||
|     opacity: 1; | ||||
| } | ||||
|  | ||||
| /* Counter styling */ | ||||
| .pswp__counter { | ||||
|     font-family: var(--main-font-family); | ||||
|     font-size: 14px; | ||||
|     color: rgba(255, 255, 255, 0.9); | ||||
|     text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); | ||||
| } | ||||
|  | ||||
| /* Caption styling */ | ||||
| .pswp__caption { | ||||
|     background-color: rgba(0, 0, 0, 0.7); | ||||
|     backdrop-filter: blur(10px); | ||||
| } | ||||
|  | ||||
| .pswp__caption__center { | ||||
|     text-align: center; | ||||
|     font-family: var(--main-font-family); | ||||
|     font-size: 14px; | ||||
|     color: rgba(255, 255, 255, 0.9); | ||||
|     padding: 10px 20px; | ||||
| } | ||||
|  | ||||
| /* Image styling */ | ||||
| .pswp__img { | ||||
|     cursor: zoom-in; | ||||
| } | ||||
|  | ||||
| .pswp__img--placeholder { | ||||
|     background-color: var(--pswp-placeholder-bg); | ||||
| } | ||||
|  | ||||
| .pswp--zoomed-in .pswp__img { | ||||
|     cursor: grab; | ||||
| } | ||||
|  | ||||
| .pswp--dragging .pswp__img { | ||||
|     cursor: grabbing; | ||||
| } | ||||
|  | ||||
| /* Loading indicator */ | ||||
| .pswp__preloader { | ||||
|     width: 44px; | ||||
|     height: 44px; | ||||
| } | ||||
|  | ||||
| .pswp__preloader__icn { | ||||
|     width: 20px; | ||||
|     height: 20px; | ||||
| } | ||||
|  | ||||
| /* Error message styling */ | ||||
| .pswp__error-msg { | ||||
|     font-family: var(--main-font-family); | ||||
|     font-size: 14px; | ||||
|     color: var(--pswp-error-text-color); | ||||
|     text-align: center; | ||||
|     padding: 20px; | ||||
| } | ||||
|  | ||||
| /* Thumbnails strip (for future implementation) */ | ||||
| .pswp__thumbnails { | ||||
|     position: absolute; | ||||
|     bottom: 0; | ||||
|     left: 0; | ||||
|     right: 0; | ||||
|     height: 80px; | ||||
|     background-color: rgba(0, 0, 0, 0.7); | ||||
|     backdrop-filter: blur(10px); | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     padding: 10px; | ||||
|     gap: 10px; | ||||
|     overflow-x: auto; | ||||
|     z-index: 1; | ||||
| } | ||||
|  | ||||
| .pswp__thumbnail { | ||||
|     width: 60px; | ||||
|     height: 60px; | ||||
|     cursor: pointer; | ||||
|     opacity: 0.6; | ||||
|     transition: opacity 0.2s; | ||||
|     object-fit: cover; | ||||
|     border: 2px solid transparent; | ||||
| } | ||||
|  | ||||
| .pswp__thumbnail:hover { | ||||
|     opacity: 0.9; | ||||
| } | ||||
|  | ||||
| .pswp__thumbnail--active { | ||||
|     opacity: 1; | ||||
|     border-color: var(--main-border-color); | ||||
| } | ||||
|  | ||||
| /* Animations */ | ||||
| .pswp--open { | ||||
|     animation: pswpFadeIn 0.25s ease-out; | ||||
| } | ||||
|  | ||||
| .pswp--closing { | ||||
|     animation: pswpFadeOut 0.25s ease-out; | ||||
| } | ||||
|  | ||||
| @keyframes pswpFadeIn { | ||||
|     from { | ||||
|         opacity: 0; | ||||
|     } | ||||
|     to { | ||||
|         opacity: 1; | ||||
|     } | ||||
| } | ||||
|  | ||||
| @keyframes pswpFadeOut { | ||||
|     from { | ||||
|         opacity: 1; | ||||
|     } | ||||
|     to { | ||||
|         opacity: 0; | ||||
|     } | ||||
| } | ||||
|  | ||||
| /* Zoom animation */ | ||||
| .pswp__zoom-wrap { | ||||
|     transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1); | ||||
| } | ||||
|  | ||||
| /* Mobile-specific adjustments */ | ||||
| @media (max-width: 768px) { | ||||
|     .pswp__caption__center { | ||||
|         font-size: 12px; | ||||
|         padding: 8px 16px; | ||||
|     } | ||||
|      | ||||
|     .pswp__counter { | ||||
|         font-size: 12px; | ||||
|     } | ||||
|      | ||||
|     .pswp__thumbnails { | ||||
|         height: 60px; | ||||
|     } | ||||
|      | ||||
|     .pswp__thumbnail { | ||||
|         width: 45px; | ||||
|         height: 45px; | ||||
|     } | ||||
| } | ||||
|  | ||||
| /* Integration with Trilium's note context */ | ||||
| .media-viewer-trigger { | ||||
|     cursor: zoom-in; | ||||
|     transition: opacity 0.2s; | ||||
| } | ||||
|  | ||||
| .media-viewer-trigger:hover { | ||||
|     opacity: 0.9; | ||||
| } | ||||
|  | ||||
| /* Gallery mode indicators */ | ||||
| .media-viewer-gallery-indicator { | ||||
|     position: absolute; | ||||
|     top: 10px; | ||||
|     right: 10px; | ||||
|     background-color: rgba(0, 0, 0, 0.6); | ||||
|     color: white; | ||||
|     padding: 4px 8px; | ||||
|     border-radius: 4px; | ||||
|     font-size: 12px; | ||||
|     font-family: var(--main-font-family); | ||||
|     pointer-events: none; | ||||
|     z-index: 1; | ||||
| } | ||||
|  | ||||
| /* Fullscreen mode adjustments */ | ||||
| .pswp--fs { | ||||
|     background-color: black; | ||||
| } | ||||
|  | ||||
| .pswp--fs .pswp__top-bar { | ||||
|     background-color: rgba(0, 0, 0, 0.7); | ||||
| } | ||||
|  | ||||
| /* Accessibility improvements */ | ||||
| .pswp__button:focus { | ||||
|     outline: 2px solid var(--main-border-color); | ||||
|     outline-offset: 2px; | ||||
| } | ||||
|  | ||||
| .pswp__img:focus { | ||||
|     outline: none; | ||||
| } | ||||
|  | ||||
| /* Custom toolbar buttons */ | ||||
| .pswp__button--download { | ||||
|     background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2'%3E%3Cpath d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'%3E%3C/path%3E%3Cpolyline points='7 10 12 15 17 10'%3E%3C/polyline%3E%3Cline x1='12' y1='15' x2='12' y2='3'%3E%3C/line%3E%3C/svg%3E"); | ||||
|     background-size: 24px 24px; | ||||
| } | ||||
|  | ||||
| .pswp__button--info { | ||||
|     background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='16' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='8' x2='12.01' y2='8'%3E%3C/line%3E%3C/svg%3E"); | ||||
|     background-size: 24px 24px; | ||||
| } | ||||
|  | ||||
| /* Print styles */ | ||||
| @media print { | ||||
|     .pswp { | ||||
|         display: none !important; | ||||
|     } | ||||
| } | ||||
| @@ -9,6 +9,9 @@ import contentRenderer from "../services/content_renderer.js"; | ||||
| import toastService from "../services/toast.js"; | ||||
| import type FAttachment from "../entities/fattachment.js"; | ||||
| import type { EventData } from "../components/app_context.js"; | ||||
| import appContext from "../components/app_context.js"; | ||||
| import mediaViewer from "../services/media_viewer.js"; | ||||
| import type { MediaItem } from "../services/media_viewer.js"; | ||||
|  | ||||
| const TPL = /*html*/` | ||||
| <div class="attachment-detail-widget"> | ||||
| @@ -65,6 +68,12 @@ const TPL = /*html*/` | ||||
|  | ||||
|         .attachment-content-wrapper img { | ||||
|             margin: 10px; | ||||
|             cursor: zoom-in; | ||||
|             transition: opacity 0.2s; | ||||
|         } | ||||
|          | ||||
|         .attachment-content-wrapper img:hover { | ||||
|             opacity: 0.9; | ||||
|         } | ||||
|  | ||||
|         .attachment-detail-wrapper.list-view .attachment-content-wrapper img, .attachment-detail-wrapper.list-view .attachment-content-wrapper video { | ||||
| @@ -77,6 +86,24 @@ const TPL = /*html*/` | ||||
|             max-width: 90%; | ||||
|             object-fit: contain; | ||||
|         } | ||||
|          | ||||
|         .attachment-lightbox-hint { | ||||
|             position: absolute; | ||||
|             top: 10px; | ||||
|             right: 10px; | ||||
|             background: rgba(0, 0, 0, 0.7); | ||||
|             color: white; | ||||
|             padding: 5px 10px; | ||||
|             border-radius: 4px; | ||||
|             font-size: 12px; | ||||
|             opacity: 0; | ||||
|             transition: opacity 0.3s; | ||||
|             pointer-events: none; | ||||
|         } | ||||
|          | ||||
|         .attachment-content-wrapper:hover .attachment-lightbox-hint { | ||||
|             opacity: 1; | ||||
|         } | ||||
|  | ||||
|         .attachment-detail-wrapper.scheduled-for-deletion .attachment-content-wrapper img { | ||||
|             filter: contrast(10%); | ||||
| @@ -88,7 +115,9 @@ const TPL = /*html*/` | ||||
|             <div class="attachment-actions-container"></div> | ||||
|             <h4 class="attachment-title"></h4> | ||||
|             <div class="attachment-details"></div> | ||||
|             <div style="flex: 1 1;"></div> | ||||
|             <button class="btn btn-sm back-to-note-btn" style="margin-left: auto;" title="Back to Note"> | ||||
|                 <span class="bx bx-arrow-back"></span> Back to Note | ||||
|             </button> | ||||
|         </div> | ||||
|  | ||||
|         <div class="attachment-deletion-warning alert alert-info" style="margin-top: 15px;"></div> | ||||
| @@ -124,6 +153,14 @@ export default class AttachmentDetailWidget extends BasicWidget { | ||||
|         this.$widget.find(".attachment-detail-wrapper").empty().append($(TPL).find(".attachment-detail-wrapper").html()); | ||||
|         this.$wrapper = this.$widget.find(".attachment-detail-wrapper"); | ||||
|         this.$wrapper.addClass(this.isFullDetail ? "full-detail" : "list-view"); | ||||
|          | ||||
|         // Setup back to note button (only show in full detail mode) | ||||
|         if (this.isFullDetail) { | ||||
|             const $backBtn = this.$wrapper.find('.back-to-note-btn'); | ||||
|             $backBtn.on('click', () => this.handleBackToNote()); | ||||
|         } else { | ||||
|             this.$wrapper.find('.back-to-note-btn').hide(); | ||||
|         } | ||||
|  | ||||
|         if (!this.isFullDetail) { | ||||
|             const $link = await linkService.createLink(this.attachment.ownerId, { | ||||
| @@ -170,7 +207,92 @@ export default class AttachmentDetailWidget extends BasicWidget { | ||||
|         this.$wrapper.find(".attachment-actions-container").append(this.attachmentActionsWidget.render()); | ||||
|  | ||||
|         const { $renderedContent } = await contentRenderer.getRenderedContent(this.attachment, { imageHasZoom: this.isFullDetail }); | ||||
|         this.$wrapper.find(".attachment-content-wrapper").append($renderedContent); | ||||
|         const $contentWrapper = this.$wrapper.find(".attachment-content-wrapper"); | ||||
|         $contentWrapper.append($renderedContent); | ||||
|          | ||||
|         // Add PhotoSwipe integration for image attachments | ||||
|         if (this.attachment.role === 'image') { | ||||
|             this.setupPhotoSwipeIntegration($contentWrapper); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     setupPhotoSwipeIntegration($contentWrapper: JQuery<HTMLElement>) { | ||||
|         // Add lightbox hint | ||||
|         const $hint = $('<div class="attachment-lightbox-hint">Click to view in lightbox</div>'); | ||||
|         $contentWrapper.css('position', 'relative').append($hint); | ||||
|          | ||||
|         // Find the image element | ||||
|         const $img = $contentWrapper.find('img'); | ||||
|         if (!$img.length) return; | ||||
|          | ||||
|         // Setup click handler for lightbox with namespace for proper cleanup | ||||
|         $img.off('click.photoswipe').on('click.photoswipe', (e) => { | ||||
|             e.preventDefault(); | ||||
|             e.stopPropagation(); | ||||
|              | ||||
|             const item: MediaItem = { | ||||
|                 src: $img.attr('src') || '', | ||||
|                 alt: this.attachment.title, | ||||
|                 title: this.attachment.title, | ||||
|                 noteId: this.attachment.ownerId, | ||||
|                 element: $img[0] as HTMLElement | ||||
|             }; | ||||
|              | ||||
|             // Try to get actual dimensions | ||||
|             const imgElement = $img[0] as HTMLImageElement; | ||||
|             if (imgElement.naturalWidth && imgElement.naturalHeight) { | ||||
|                 item.width = imgElement.naturalWidth; | ||||
|                 item.height = imgElement.naturalHeight; | ||||
|             } | ||||
|              | ||||
|             mediaViewer.openSingle(item, { | ||||
|                 bgOpacity: 0.95, | ||||
|                 showHideOpacity: true, | ||||
|                 pinchToClose: true, | ||||
|                 closeOnScroll: false, | ||||
|                 closeOnVerticalDrag: true, | ||||
|                 wheelToZoom: true, | ||||
|                 getThumbBoundsFn: () => { | ||||
|                     // Get position for zoom animation | ||||
|                     const rect = imgElement.getBoundingClientRect(); | ||||
|                     return { | ||||
|                         x: rect.left, | ||||
|                         y: rect.top, | ||||
|                         w: rect.width | ||||
|                     }; | ||||
|                 } | ||||
|             }, { | ||||
|                 onOpen: () => { | ||||
|                     console.log('Attachment image opened in lightbox'); | ||||
|                 }, | ||||
|                 onClose: () => { | ||||
|                     // Check if we're in attachment detail view and reset viewScope if needed | ||||
|                     const activeContext = appContext.tabManager.getActiveContext(); | ||||
|                     if (activeContext?.viewScope?.viewMode === 'attachments' &&  | ||||
|                         activeContext?.viewScope?.attachmentId === this.attachment.attachmentId) { | ||||
|                         // Reset to normal note view when closing lightbox from attachment detail | ||||
|                         activeContext.setNote(this.attachment.ownerId, {  | ||||
|                             viewScope: { viewMode: 'default' }  | ||||
|                         }); | ||||
|                     } | ||||
|                     // Restore focus to the image | ||||
|                     $img.focus(); | ||||
|                 } | ||||
|             }); | ||||
|         }); | ||||
|          | ||||
|         // Add keyboard support | ||||
|         $img.attr('tabindex', '0') | ||||
|             .attr('role', 'button') | ||||
|             .attr('aria-label', 'Click to view in lightbox'); | ||||
|          | ||||
|         // Use namespaced event for proper cleanup | ||||
|         $img.off('keydown.photoswipe').on('keydown.photoswipe', (e) => { | ||||
|             if (e.key === 'Enter' || e.key === ' ') { | ||||
|                 e.preventDefault(); | ||||
|                 $img.trigger('click'); | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     async copyAttachmentLinkToClipboard() { | ||||
| @@ -204,4 +326,43 @@ export default class AttachmentDetailWidget extends BasicWidget { | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     async handleBackToNote() { | ||||
|         try { | ||||
|             const activeContext = appContext.tabManager.getActiveContext(); | ||||
|             if (!activeContext) { | ||||
|                 console.warn('No active context available for navigation'); | ||||
|                 return; | ||||
|             } | ||||
|              | ||||
|             if (!this.attachment.ownerId) { | ||||
|                 console.error('Cannot navigate back: no owner ID available'); | ||||
|                 return; | ||||
|             } | ||||
|              | ||||
|             await activeContext.setNote(this.attachment.ownerId, {  | ||||
|                 viewScope: { viewMode: 'default' }  | ||||
|             }); | ||||
|         } catch (error) { | ||||
|             console.error('Failed to navigate back to note:', error); | ||||
|             toastService.showError('Failed to navigate back to note'); | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     cleanup() { | ||||
|         // Remove all event handlers before cleanup | ||||
|         const $contentWrapper = this.$wrapper?.find('.attachment-content-wrapper'); | ||||
|         if ($contentWrapper?.length) { | ||||
|             const $img = $contentWrapper.find('img'); | ||||
|             if ($img.length) { | ||||
|                 // Remove namespaced event handlers | ||||
|                 $img.off('.photoswipe'); | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         // Remove back button handler | ||||
|         this.$wrapper?.find('.back-to-note-btn').off('click'); | ||||
|          | ||||
|         super.cleanup(); | ||||
|     } | ||||
| } | ||||
|   | ||||
							
								
								
									
										549
									
								
								apps/client/src/widgets/embedded_image_gallery.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										549
									
								
								apps/client/src/widgets/embedded_image_gallery.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,549 @@ | ||||
| /** | ||||
|  * Embedded Image Gallery Widget | ||||
|  * Handles image galleries within text notes and other content types | ||||
|  */ | ||||
|  | ||||
| import BasicWidget from "./basic_widget.js"; | ||||
| import galleryManager from "../services/gallery_manager.js"; | ||||
| import mediaViewer from "../services/media_viewer.js"; | ||||
| import type { GalleryItem, GalleryConfig } from "../services/gallery_manager.js"; | ||||
| import type { MediaViewerCallbacks } from "../services/media_viewer.js"; | ||||
| import utils from "../services/utils.js"; | ||||
|  | ||||
| const TPL = /*html*/` | ||||
| <style> | ||||
|     .embedded-gallery-trigger { | ||||
|         position: relative; | ||||
|         display: inline-block; | ||||
|         cursor: pointer; | ||||
|         user-select: none; | ||||
|     } | ||||
|      | ||||
|     .embedded-gallery-trigger::after { | ||||
|         content: ''; | ||||
|         position: absolute; | ||||
|         top: 8px; | ||||
|         right: 8px; | ||||
|         width: 32px; | ||||
|         height: 32px; | ||||
|         background: rgba(0, 0, 0, 0.6); | ||||
|         border-radius: 4px; | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|         justify-content: center; | ||||
|         opacity: 0; | ||||
|         transition: opacity 0.2s; | ||||
|         pointer-events: none; | ||||
|     } | ||||
|      | ||||
|     .embedded-gallery-trigger:hover::after { | ||||
|         opacity: 1; | ||||
|     } | ||||
|      | ||||
|     .embedded-gallery-trigger.has-gallery::after { | ||||
|         content: '\\f0660'; /* Gallery icon from boxicons font */ | ||||
|         font-family: 'boxicons'; | ||||
|         color: white; | ||||
|         font-size: 20px; | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|         justify-content: center; | ||||
|         line-height: 32px; | ||||
|         text-align: center; | ||||
|     } | ||||
|      | ||||
|     .gallery-indicator { | ||||
|         position: absolute; | ||||
|         top: 8px; | ||||
|         left: 8px; | ||||
|         background: rgba(0, 0, 0, 0.7); | ||||
|         color: white; | ||||
|         padding: 4px 8px; | ||||
|         border-radius: 4px; | ||||
|         font-size: 12px; | ||||
|         font-weight: bold; | ||||
|         pointer-events: none; | ||||
|         z-index: 1; | ||||
|     } | ||||
|      | ||||
|     .image-grid-view { | ||||
|         display: grid; | ||||
|         grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); | ||||
|         gap: 16px; | ||||
|         padding: 16px; | ||||
|     } | ||||
|      | ||||
|     .image-grid-item { | ||||
|         position: relative; | ||||
|         aspect-ratio: 1; | ||||
|         overflow: hidden; | ||||
|         border-radius: 8px; | ||||
|         cursor: pointer; | ||||
|         transition: transform 0.2s, box-shadow 0.2s; | ||||
|         background: var(--accented-background-color); | ||||
|     } | ||||
|      | ||||
|     .image-grid-item:hover { | ||||
|         transform: scale(1.05); | ||||
|         box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); | ||||
|     } | ||||
|      | ||||
|     .image-grid-item img { | ||||
|         width: 100%; | ||||
|         height: 100%; | ||||
|         object-fit: cover; | ||||
|     } | ||||
|      | ||||
|     .image-grid-caption { | ||||
|         position: absolute; | ||||
|         bottom: 0; | ||||
|         left: 0; | ||||
|         right: 0; | ||||
|         background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent); | ||||
|         color: white; | ||||
|         padding: 8px; | ||||
|         font-size: 12px; | ||||
|         opacity: 0; | ||||
|         transition: opacity 0.2s; | ||||
|     } | ||||
|      | ||||
|     .image-grid-item:hover .image-grid-caption { | ||||
|         opacity: 1; | ||||
|     } | ||||
|      | ||||
|     /* Mobile optimizations */ | ||||
|     @media (max-width: 768px) { | ||||
|         .image-grid-view { | ||||
|             grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); | ||||
|             gap: 8px; | ||||
|             padding: 8px; | ||||
|         } | ||||
|          | ||||
|         .gallery-indicator { | ||||
|             font-size: 10px; | ||||
|             padding: 2px 4px; | ||||
|         } | ||||
|     } | ||||
| </style> | ||||
| `; | ||||
|  | ||||
| interface ImageElement { | ||||
|     element: HTMLImageElement; | ||||
|     src: string; | ||||
|     alt?: string; | ||||
|     title?: string; | ||||
|     caption?: string; | ||||
|     noteId?: string; | ||||
|     index: number; | ||||
| } | ||||
|  | ||||
| export default class EmbeddedImageGallery extends BasicWidget { | ||||
|     private galleryItems: GalleryItem[] = []; | ||||
|     private imageElements: Map<HTMLElement, ImageElement> = new Map(); | ||||
|     private observer?: MutationObserver; | ||||
|     private processingQueue: Set<HTMLElement> = new Set(); | ||||
|      | ||||
|     doRender(): JQuery<HTMLElement> { | ||||
|         this.$widget = $(TPL); | ||||
|         this.setupMutationObserver(); | ||||
|         return this.$widget; | ||||
|     } | ||||
|      | ||||
|     /** | ||||
|      * Initialize gallery for a container element | ||||
|      */ | ||||
|     async initializeGallery( | ||||
|         container: HTMLElement | JQuery<HTMLElement>, | ||||
|         options?: { | ||||
|             selector?: string; | ||||
|             autoEnhance?: boolean; | ||||
|             gridView?: boolean; | ||||
|             galleryConfig?: GalleryConfig; | ||||
|         } | ||||
|     ): Promise<void> { | ||||
|         const $container = $(container); | ||||
|         const selector = options?.selector || 'img'; | ||||
|         const autoEnhance = options?.autoEnhance !== false; | ||||
|         const gridView = options?.gridView || false; | ||||
|          | ||||
|         // Find all images in the container | ||||
|         const images = $container.find(selector).toArray() as HTMLImageElement[]; | ||||
|          | ||||
|         if (images.length === 0) { | ||||
|             return; | ||||
|         } | ||||
|          | ||||
|         // Create gallery items | ||||
|         this.galleryItems = await this.createGalleryItems(images, $container); | ||||
|          | ||||
|         if (gridView) { | ||||
|             // Create grid view | ||||
|             this.createGridView($container, this.galleryItems); | ||||
|         } else if (autoEnhance) { | ||||
|             // Enhance individual images | ||||
|             this.enhanceImages(images); | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     /** | ||||
|      * Create gallery items from image elements | ||||
|      */ | ||||
|     private async createGalleryItems( | ||||
|         images: HTMLImageElement[], | ||||
|         $container: JQuery<HTMLElement> | ||||
|     ): Promise<GalleryItem[]> { | ||||
|         const items: GalleryItem[] = []; | ||||
|          | ||||
|         for (let i = 0; i < images.length; i++) { | ||||
|             const img = images[i]; | ||||
|              | ||||
|             // Skip already processed images | ||||
|             if (img.dataset.galleryProcessed === 'true') { | ||||
|                 continue; | ||||
|             } | ||||
|              | ||||
|             const item: GalleryItem = { | ||||
|                 src: img.src, | ||||
|                 alt: img.alt || `Image ${i + 1}`, | ||||
|                 title: img.title || img.alt, | ||||
|                 element: img, | ||||
|                 index: i, | ||||
|                 width: img.naturalWidth || undefined, | ||||
|                 height: img.naturalHeight || undefined | ||||
|             }; | ||||
|              | ||||
|             // Extract caption from figure element | ||||
|             const $img = $(img); | ||||
|             const $figure = $img.closest('figure'); | ||||
|             if ($figure.length) { | ||||
|                 const $caption = $figure.find('figcaption'); | ||||
|                 if ($caption.length) { | ||||
|                     item.caption = $caption.text(); | ||||
|                 } | ||||
|             } | ||||
|              | ||||
|             // Check for note ID in data attributes or URL | ||||
|             item.noteId = this.extractNoteId(img); | ||||
|              | ||||
|             // Get dimensions if not available | ||||
|             if (!item.width || !item.height) { | ||||
|                 try { | ||||
|                     const dimensions = await mediaViewer.getImageDimensions(img.src); | ||||
|                     item.width = dimensions.width; | ||||
|                     item.height = dimensions.height; | ||||
|                 } catch (error) { | ||||
|                     console.warn('Failed to get image dimensions:', error); | ||||
|                 } | ||||
|             } | ||||
|              | ||||
|             items.push(item); | ||||
|              | ||||
|             // Store image element data | ||||
|             this.imageElements.set(img, { | ||||
|                 element: img, | ||||
|                 src: img.src, | ||||
|                 alt: item.alt, | ||||
|                 title: item.title, | ||||
|                 caption: item.caption, | ||||
|                 noteId: item.noteId, | ||||
|                 index: i | ||||
|             }); | ||||
|              | ||||
|             // Mark as processed | ||||
|             img.dataset.galleryProcessed = 'true'; | ||||
|         } | ||||
|          | ||||
|         return items; | ||||
|     } | ||||
|      | ||||
|     /** | ||||
|      * Enhance individual images with gallery functionality | ||||
|      */ | ||||
|     private enhanceImages(images: HTMLImageElement[]): void { | ||||
|         images.forEach((img, index) => { | ||||
|             const $img = $(img); | ||||
|              | ||||
|             // Wrap image in a trigger container if not already wrapped | ||||
|             if (!$img.parent().hasClass('embedded-gallery-trigger')) { | ||||
|                 $img.wrap('<span class="embedded-gallery-trigger"></span>'); | ||||
|             } | ||||
|              | ||||
|             const $trigger = $img.parent(); | ||||
|              | ||||
|             // Add gallery indicator if multiple images | ||||
|             if (this.galleryItems.length > 1) { | ||||
|                 $trigger.addClass('has-gallery'); | ||||
|                  | ||||
|                 // Add count indicator | ||||
|                 if (!$trigger.find('.gallery-indicator').length) { | ||||
|                     $trigger.prepend(` | ||||
|                         <span class="gallery-indicator" aria-label="Image ${index + 1} of ${this.galleryItems.length}"> | ||||
|                             ${index + 1}/${this.galleryItems.length} | ||||
|                         </span> | ||||
|                     `); | ||||
|                 } | ||||
|             } | ||||
|              | ||||
|             // Remove any existing click handlers | ||||
|             $img.off('click.gallery'); | ||||
|              | ||||
|             // Add click handler to open gallery | ||||
|             $img.on('click.gallery', (e) => { | ||||
|                 e.preventDefault(); | ||||
|                 e.stopPropagation(); | ||||
|                 this.openGallery(index); | ||||
|             }); | ||||
|              | ||||
|             // Add keyboard support | ||||
|             $img.attr('tabindex', '0'); | ||||
|             $img.attr('role', 'button'); | ||||
|             $img.attr('aria-label', `${img.alt || 'Image'} - Click to open in gallery`); | ||||
|              | ||||
|             $img.on('keydown.gallery', (e) => { | ||||
|                 if (e.key === 'Enter' || e.key === ' ') { | ||||
|                     e.preventDefault(); | ||||
|                     this.openGallery(index); | ||||
|                 } | ||||
|             }); | ||||
|         }); | ||||
|     } | ||||
|      | ||||
|     /** | ||||
|      * Create grid view of images | ||||
|      */ | ||||
|     private createGridView($container: JQuery<HTMLElement>, items: GalleryItem[]): void { | ||||
|         const $grid = $('<div class="image-grid-view"></div>'); | ||||
|          | ||||
|         items.forEach((item, index) => { | ||||
|             const $gridItem = $(` | ||||
|                 <div class="image-grid-item" data-index="${index}" tabindex="0" role="button"> | ||||
|                     <img src="${item.src}" alt="${item.alt}" loading="lazy" /> | ||||
|                     ${item.caption ? `<div class="image-grid-caption">${item.caption}</div>` : ''} | ||||
|                 </div> | ||||
|             `); | ||||
|              | ||||
|             $gridItem.on('click', () => this.openGallery(index)); | ||||
|             $gridItem.on('keydown', (e) => { | ||||
|                 if (e.key === 'Enter' || e.key === ' ') { | ||||
|                     e.preventDefault(); | ||||
|                     this.openGallery(index); | ||||
|                 } | ||||
|             }); | ||||
|              | ||||
|             $grid.append($gridItem); | ||||
|         }); | ||||
|          | ||||
|         // Replace container content with grid | ||||
|         $container.empty().append($grid); | ||||
|     } | ||||
|      | ||||
|     /** | ||||
|      * Open gallery at specified index | ||||
|      */ | ||||
|     private openGallery(startIndex: number = 0): void { | ||||
|         if (this.galleryItems.length === 0) { | ||||
|             return; | ||||
|         } | ||||
|          | ||||
|         const config: GalleryConfig = { | ||||
|             showThumbnails: this.galleryItems.length > 1, | ||||
|             thumbnailHeight: 80, | ||||
|             autoPlay: false, | ||||
|             slideInterval: 4000, | ||||
|             showCounter: this.galleryItems.length > 1, | ||||
|             enableKeyboardNav: true, | ||||
|             enableSwipeGestures: true, | ||||
|             preloadCount: 2, | ||||
|             loop: true | ||||
|         }; | ||||
|          | ||||
|         const callbacks: MediaViewerCallbacks = { | ||||
|             onOpen: () => { | ||||
|                 console.log('Embedded gallery opened'); | ||||
|                 this.trigger('galleryOpened', { items: this.galleryItems, startIndex }); | ||||
|             }, | ||||
|             onClose: () => { | ||||
|                 console.log('Embedded gallery closed'); | ||||
|                 this.trigger('galleryClosed'); | ||||
|                  | ||||
|                 // Restore focus to the trigger element | ||||
|                 const currentItem = this.galleryItems[galleryManager.getGalleryState()?.currentIndex || startIndex]; | ||||
|                 if (currentItem?.element) { | ||||
|                     (currentItem.element as HTMLElement).focus(); | ||||
|                 } | ||||
|             }, | ||||
|             onChange: (index) => { | ||||
|                 console.log('Gallery slide changed to:', index); | ||||
|                 this.trigger('gallerySlideChanged', { index, item: this.galleryItems[index] }); | ||||
|             }, | ||||
|             onImageLoad: (index, item) => { | ||||
|                 console.log('Gallery image loaded:', item.title); | ||||
|             }, | ||||
|             onImageError: (index, item, error) => { | ||||
|                 console.error('Failed to load gallery image:', error); | ||||
|             } | ||||
|         }; | ||||
|          | ||||
|         if (this.galleryItems.length === 1) { | ||||
|             // Open single image | ||||
|             mediaViewer.openSingle(this.galleryItems[0], { | ||||
|                 bgOpacity: 0.95, | ||||
|                 showHideOpacity: true, | ||||
|                 wheelToZoom: true, | ||||
|                 pinchToClose: true | ||||
|             }, callbacks); | ||||
|         } else { | ||||
|             // Open gallery | ||||
|             galleryManager.openGallery(this.galleryItems, startIndex, config, callbacks); | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     /** | ||||
|      * Extract note ID from image element | ||||
|      */ | ||||
|     private extractNoteId(img: HTMLImageElement): string | undefined { | ||||
|         // Check data attribute | ||||
|         if (img.dataset.noteId) { | ||||
|             return img.dataset.noteId; | ||||
|         } | ||||
|          | ||||
|         // Try to extract from URL | ||||
|         const match = img.src.match(/\/api\/images\/([a-zA-Z0-9_]+)/); | ||||
|         if (match) { | ||||
|             return match[1]; | ||||
|         } | ||||
|          | ||||
|         return undefined; | ||||
|     } | ||||
|      | ||||
|     /** | ||||
|      * Setup mutation observer to detect dynamically added images | ||||
|      */ | ||||
|     private setupMutationObserver(): void { | ||||
|         this.observer = new MutationObserver((mutations) => { | ||||
|             const imagesToProcess: HTMLImageElement[] = []; | ||||
|              | ||||
|             mutations.forEach((mutation) => { | ||||
|                 mutation.addedNodes.forEach((node) => { | ||||
|                     if (node.nodeType === Node.ELEMENT_NODE) { | ||||
|                         const element = node as HTMLElement; | ||||
|                          | ||||
|                         // Check if it's an image | ||||
|                         if (element.tagName === 'IMG') { | ||||
|                             imagesToProcess.push(element as HTMLImageElement); | ||||
|                         } | ||||
|                          | ||||
|                         // Check for images within the added element | ||||
|                         const images = element.querySelectorAll('img'); | ||||
|                         images.forEach(img => imagesToProcess.push(img as HTMLImageElement)); | ||||
|                     } | ||||
|                 }); | ||||
|             }); | ||||
|              | ||||
|             if (imagesToProcess.length > 0) { | ||||
|                 this.processNewImages(imagesToProcess); | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
|      | ||||
|     /** | ||||
|      * Process newly added images | ||||
|      */ | ||||
|     private async processNewImages(images: HTMLImageElement[]): Promise<void> { | ||||
|         // Filter out already processed images | ||||
|         const newImages = images.filter(img =>  | ||||
|             img.dataset.galleryProcessed !== 'true' &&  | ||||
|             !this.processingQueue.has(img) | ||||
|         ); | ||||
|          | ||||
|         if (newImages.length === 0) { | ||||
|             return; | ||||
|         } | ||||
|          | ||||
|         // Add to processing queue | ||||
|         newImages.forEach(img => this.processingQueue.add(img)); | ||||
|          | ||||
|         try { | ||||
|             // Create gallery items for new images | ||||
|             const newItems = await this.createGalleryItems(newImages, $(document.body)); | ||||
|              | ||||
|             // Add to existing gallery | ||||
|             this.galleryItems.push(...newItems); | ||||
|              | ||||
|             // Enhance the new images | ||||
|             this.enhanceImages(newImages); | ||||
|         } finally { | ||||
|             // Remove from processing queue | ||||
|             newImages.forEach(img => this.processingQueue.delete(img)); | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     /** | ||||
|      * Start observing a container for new images | ||||
|      */ | ||||
|     observeContainer(container: HTMLElement): void { | ||||
|         if (!this.observer) { | ||||
|             this.setupMutationObserver(); | ||||
|         } | ||||
|          | ||||
|         this.observer?.observe(container, { | ||||
|             childList: true, | ||||
|             subtree: true | ||||
|         }); | ||||
|     } | ||||
|      | ||||
|     /** | ||||
|      * Stop observing | ||||
|      */ | ||||
|     stopObserving(): void { | ||||
|         this.observer?.disconnect(); | ||||
|     } | ||||
|      | ||||
|     /** | ||||
|      * Refresh gallery items | ||||
|      */ | ||||
|     async refresh(): Promise<void> { | ||||
|         // Clear existing items | ||||
|         this.galleryItems = []; | ||||
|         this.imageElements.clear(); | ||||
|          | ||||
|         // Mark all images as unprocessed | ||||
|         $('[data-gallery-processed="true"]').removeAttr('data-gallery-processed'); | ||||
|          | ||||
|         // Re-initialize if there's a container | ||||
|         const $container = this.$widget?.parent(); | ||||
|         if ($container?.length) { | ||||
|             await this.initializeGallery($container); | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     /** | ||||
|      * Get current gallery items | ||||
|      */ | ||||
|     getGalleryItems(): GalleryItem[] { | ||||
|         return this.galleryItems; | ||||
|     } | ||||
|      | ||||
|     /** | ||||
|      * Cleanup | ||||
|      */ | ||||
|     cleanup(): void { | ||||
|         // Stop observing | ||||
|         this.stopObserving(); | ||||
|          | ||||
|         // Close gallery if open | ||||
|         if (galleryManager.isGalleryOpen()) { | ||||
|             galleryManager.closeGallery(); | ||||
|         } | ||||
|          | ||||
|         // Remove event handlers | ||||
|         $('[data-gallery-processed="true"]').off('.gallery'); | ||||
|          | ||||
|         // Clear data | ||||
|         this.galleryItems = []; | ||||
|         this.imageElements.clear(); | ||||
|         this.processingQueue.clear(); | ||||
|          | ||||
|         super.cleanup(); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										573
									
								
								apps/client/src/widgets/media_viewer_widget.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										573
									
								
								apps/client/src/widgets/media_viewer_widget.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,573 @@ | ||||
| import { TypedBasicWidget } from "./basic_widget.js"; | ||||
| import Component from "../components/component.js"; | ||||
| import mediaViewerService from "../services/media_viewer.js"; | ||||
| import type { MediaItem, MediaViewerConfig, MediaViewerCallbacks } from "../services/media_viewer.js"; | ||||
| import type FNote from "../entities/fnote.js"; | ||||
| import type { EventData } from "../components/app_context.js"; | ||||
| import froca from "../services/froca.js"; | ||||
| import utils from "../services/utils.js"; | ||||
| import server from "../services/server.js"; | ||||
| import toastService from "../services/toast.js"; | ||||
|  | ||||
| /** | ||||
|  * MediaViewerWidget provides a modern lightbox experience for viewing images | ||||
|  * and other media in Trilium Notes using PhotoSwipe 5. | ||||
|  *  | ||||
|  * This widget can be used in two modes: | ||||
|  * 1. As a standalone viewer for a single note's media | ||||
|  * 2. As a gallery viewer for multiple media items | ||||
|  */ | ||||
| export class MediaViewerWidget extends TypedBasicWidget<Component> { | ||||
|     private currentNoteId: string | null = null; | ||||
|     private galleryItems: MediaItem[] = []; | ||||
|     private isGalleryMode: boolean = false; | ||||
|     private clickHandlers: Map<HTMLElement, () => void> = new Map(); | ||||
|     private boundKeyboardHandler: ((event: KeyboardEvent) => void) | null = null; | ||||
|  | ||||
|     constructor() { | ||||
|         super(); | ||||
|         this.setupGlobalHandlers(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Setup global event handlers for media viewing | ||||
|      */ | ||||
|     private setupGlobalHandlers(): void { | ||||
|         // Store bound handler for proper cleanup | ||||
|         this.boundKeyboardHandler = this.handleKeyboard.bind(this); | ||||
|         document.addEventListener('keydown', this.boundKeyboardHandler); | ||||
|          | ||||
|         // Cleanup will be called by parent class | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Handle keyboard shortcuts with error boundary | ||||
|      */ | ||||
|     private handleKeyboard(event: KeyboardEvent): void { | ||||
|         try { | ||||
|             // Only handle if viewer is open | ||||
|             if (!mediaViewerService.isOpen()) { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             switch (event.key) { | ||||
|                 case 'ArrowLeft': | ||||
|                     mediaViewerService.prev(); | ||||
|                     event.preventDefault(); | ||||
|                     break; | ||||
|                 case 'ArrowRight': | ||||
|                     mediaViewerService.next(); | ||||
|                     event.preventDefault(); | ||||
|                     break; | ||||
|                 case 'Escape': | ||||
|                     mediaViewerService.close(); | ||||
|                     event.preventDefault(); | ||||
|                     break; | ||||
|             } | ||||
|         } catch (error) { | ||||
|             console.error('Error handling keyboard event:', error); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Open viewer for a single image note with comprehensive error handling | ||||
|      */ | ||||
|     async openImageNote(noteId: string, config?: Partial<MediaViewerConfig>): Promise<void> { | ||||
|         try { | ||||
|             const note = await froca.getNote(noteId); | ||||
|             if (!note || note.type !== 'image') { | ||||
|                 toastService.showError('Note is not an image'); | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             const item: MediaItem = { | ||||
|                 src: utils.createImageSrcUrl(note), | ||||
|                 alt: note.title || `Image ${noteId}`, | ||||
|                 title: note.title || `Image ${noteId}`, | ||||
|                 noteId: noteId | ||||
|             }; | ||||
|  | ||||
|             // Try to get image dimensions from attributes | ||||
|             const widthAttr = note.getAttribute('label', 'imageWidth'); | ||||
|             const heightAttr = note.getAttribute('label', 'imageHeight'); | ||||
|              | ||||
|             if (widthAttr && heightAttr) { | ||||
|                 const width = parseInt(widthAttr.value); | ||||
|                 const height = parseInt(heightAttr.value); | ||||
|                 if (!isNaN(width) && !isNaN(height) && width > 0 && height > 0) { | ||||
|                     item.width = width; | ||||
|                     item.height = height; | ||||
|                 } | ||||
|             } | ||||
|              | ||||
|             // Get dimensions dynamically if not available | ||||
|             if (!item.width || !item.height) { | ||||
|                 try { | ||||
|                     const dimensions = await mediaViewerService.getImageDimensions(item.src); | ||||
|                     item.width = dimensions.width; | ||||
|                     item.height = dimensions.height; | ||||
|                 } catch (error) { | ||||
|                     console.warn('Failed to get image dimensions, using defaults:', error); | ||||
|                     // Use default dimensions as fallback | ||||
|                     item.width = 800; | ||||
|                     item.height = 600; | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             const callbacks: MediaViewerCallbacks = { | ||||
|                 onOpen: () => this.onViewerOpen(noteId), | ||||
|                 onClose: () => this.onViewerClose(noteId), | ||||
|                 onImageError: (index, errorItem, error) => this.onImageError(errorItem, error) | ||||
|             }; | ||||
|  | ||||
|             mediaViewerService.openSingle(item, config, callbacks); | ||||
|             this.currentNoteId = noteId; | ||||
|  | ||||
|         } catch (error) { | ||||
|             console.error('Failed to open image note:', error); | ||||
|             const errorMessage = error instanceof Error ? error.message : 'Failed to open image'; | ||||
|             toastService.showError(errorMessage); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Open viewer for multiple images (gallery mode) with isolated error handling | ||||
|      */ | ||||
|     async openGallery(noteIds: string[], startIndex: number = 0, config?: Partial<MediaViewerConfig>): Promise<void> { | ||||
|         try { | ||||
|             const items: MediaItem[] = []; | ||||
|             const errors: Array<{ noteId: string; error: unknown }> = []; | ||||
|  | ||||
|             // Process each note with isolated error handling | ||||
|             await Promise.all(noteIds.map(async (noteId) => { | ||||
|                 try { | ||||
|                     const note = await froca.getNote(noteId); | ||||
|                     if (!note || note.type !== 'image') { | ||||
|                         return; // Skip non-image notes silently | ||||
|                     } | ||||
|  | ||||
|                     const item: MediaItem = { | ||||
|                         src: utils.createImageSrcUrl(note), | ||||
|                         alt: note.title || `Image ${noteId}`, | ||||
|                         title: note.title || `Image ${noteId}`, | ||||
|                         noteId: noteId | ||||
|                     }; | ||||
|  | ||||
|                     // Try to get dimensions | ||||
|                     const widthAttr = note.getAttribute('label', 'imageWidth'); | ||||
|                     const heightAttr = note.getAttribute('label', 'imageHeight'); | ||||
|                      | ||||
|                     if (widthAttr && heightAttr) { | ||||
|                         const width = parseInt(widthAttr.value); | ||||
|                         const height = parseInt(heightAttr.value); | ||||
|                         if (!isNaN(width) && !isNaN(height) && width > 0 && height > 0) { | ||||
|                             item.width = width; | ||||
|                             item.height = height; | ||||
|                         } | ||||
|                     } | ||||
|  | ||||
|                     // Use default dimensions if not available | ||||
|                     if (!item.width || !item.height) { | ||||
|                         item.width = 800; | ||||
|                         item.height = 600; | ||||
|                     } | ||||
|  | ||||
|                     items.push(item); | ||||
|                 } catch (error) { | ||||
|                     console.error(`Failed to process note ${noteId}:`, error); | ||||
|                     errors.push({ noteId, error }); | ||||
|                 } | ||||
|             })); | ||||
|  | ||||
|             if (items.length === 0) { | ||||
|                 if (errors.length > 0) { | ||||
|                     toastService.showError('Failed to load any images'); | ||||
|                 } else { | ||||
|                     toastService.showMessage('No images to display'); | ||||
|                 } | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             // Show warning if some images failed | ||||
|             if (errors.length > 0) { | ||||
|                 toastService.showMessage(`Loaded ${items.length} images (${errors.length} failed)`); | ||||
|             } | ||||
|  | ||||
|             // Validate and adjust start index | ||||
|             if (startIndex < 0 || startIndex >= items.length) { | ||||
|                 console.warn(`Invalid start index ${startIndex}, using 0`); | ||||
|                 startIndex = 0; | ||||
|             } | ||||
|  | ||||
|             const callbacks: MediaViewerCallbacks = { | ||||
|                 onOpen: () => this.onGalleryOpen(), | ||||
|                 onClose: () => this.onGalleryClose(), | ||||
|                 onChange: (index) => this.onGalleryChange(index), | ||||
|                 onImageError: (index, item, error) => this.onImageError(item, error) | ||||
|             }; | ||||
|  | ||||
|             mediaViewerService.open(items, startIndex, config, callbacks); | ||||
|             this.galleryItems = items; | ||||
|             this.isGalleryMode = true; | ||||
|  | ||||
|         } catch (error) { | ||||
|             console.error('Failed to open gallery:', error); | ||||
|             const errorMessage = error instanceof Error ? error.message : 'Failed to open gallery'; | ||||
|             toastService.showError(errorMessage); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Open viewer for images in note content | ||||
|      */ | ||||
|     async openContentImages(noteId: string, container: HTMLElement, startIndex: number = 0): Promise<void> { | ||||
|         try { | ||||
|             const note = await froca.getNote(noteId); | ||||
|             if (!note) { | ||||
|                 toastService.showError('Note not found'); | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             // Find all images in the container | ||||
|             const items = await mediaViewerService.createItemsFromContainer(container, 'img:not(.note-icon)'); | ||||
|              | ||||
|             if (items.length === 0) { | ||||
|                 toastService.showMessage('No images found in content'); | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             // Add note context to items | ||||
|             items.forEach(item => { | ||||
|                 item.noteId = noteId; | ||||
|             }); | ||||
|  | ||||
|             const callbacks: MediaViewerCallbacks = { | ||||
|                 onOpen: () => this.onContentViewerOpen(noteId), | ||||
|                 onClose: () => this.onContentViewerClose(noteId), | ||||
|                 onChange: (index) => this.onContentImageChange(index, items), | ||||
|                 onImageError: (index, item, error) => this.onImageError(item, error) | ||||
|             }; | ||||
|  | ||||
|             const config: Partial<MediaViewerConfig> = { | ||||
|                 getThumbBoundsFn: (index) => { | ||||
|                     // Get thumbnail bounds for zoom animation | ||||
|                     const item = items[index]; | ||||
|                     if (item.element) { | ||||
|                         const rect = item.element.getBoundingClientRect(); | ||||
|                         return { x: rect.left, y: rect.top, w: rect.width }; | ||||
|                     } | ||||
|                     return undefined; | ||||
|                 } | ||||
|             }; | ||||
|  | ||||
|             mediaViewerService.open(items, startIndex, config, callbacks); | ||||
|             this.currentNoteId = noteId; | ||||
|  | ||||
|         } catch (error) { | ||||
|             console.error('Failed to open content images:', error); | ||||
|             toastService.showError('Failed to open images'); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Attach click handlers to images in a container with accessibility | ||||
|      */ | ||||
|     attachToContainer(container: HTMLElement, noteId: string): void { | ||||
|         try { | ||||
|             const images = container.querySelectorAll<HTMLImageElement>('img:not(.note-icon)'); | ||||
|              | ||||
|             images.forEach((img, index) => { | ||||
|                 // Skip if already has handler | ||||
|                 if (this.clickHandlers.has(img)) { | ||||
|                     return; | ||||
|                 } | ||||
|  | ||||
|                 const handler = () => { | ||||
|                     this.openContentImages(noteId, container, index).catch(error => { | ||||
|                         console.error('Failed to open content images:', error); | ||||
|                         toastService.showError('Failed to open image viewer'); | ||||
|                     }); | ||||
|                 }; | ||||
|  | ||||
|                 img.addEventListener('click', handler); | ||||
|                 img.classList.add('media-viewer-trigger'); | ||||
|                 img.style.cursor = 'zoom-in'; | ||||
|                  | ||||
|                 // Add accessibility attributes | ||||
|                 img.setAttribute('role', 'button'); | ||||
|                 img.setAttribute('tabindex', '0'); | ||||
|                 img.setAttribute('aria-label', img.alt || 'Click to view image in fullscreen'); | ||||
|                  | ||||
|                 // Add keyboard support for accessibility | ||||
|                 const keyHandler = (event: KeyboardEvent) => { | ||||
|                     if (event.key === 'Enter' || event.key === ' ') { | ||||
|                         event.preventDefault(); | ||||
|                         handler(); | ||||
|                     } | ||||
|                 }; | ||||
|                 img.addEventListener('keydown', keyHandler); | ||||
|                  | ||||
|                 // Store both handlers | ||||
|                 this.clickHandlers.set(img, handler); | ||||
|             }); | ||||
|         } catch (error) { | ||||
|             console.error('Failed to attach container handlers:', error); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Detach click handlers from a container | ||||
|      */ | ||||
|     detachFromContainer(container: HTMLElement): void { | ||||
|         const images = container.querySelectorAll<HTMLImageElement>('img.media-viewer-trigger'); | ||||
|          | ||||
|         images.forEach(img => { | ||||
|             const handler = this.clickHandlers.get(img); | ||||
|             if (handler) { | ||||
|                 img.removeEventListener('click', handler); | ||||
|                 img.classList.remove('media-viewer-trigger'); | ||||
|                 img.style.cursor = ''; | ||||
|                 this.clickHandlers.delete(img); | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when viewer opens for a single image | ||||
|      */ | ||||
|     private onViewerOpen(noteId: string): void { | ||||
|         // Log for debugging purposes | ||||
|         console.debug('Media viewer opened for note:', noteId); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when viewer closes for a single image | ||||
|      */ | ||||
|     private onViewerClose(noteId: string): void { | ||||
|         this.currentNoteId = null; | ||||
|         console.debug('Media viewer closed for note:', noteId); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when gallery opens | ||||
|      */ | ||||
|     private onGalleryOpen(): void { | ||||
|         console.debug('Gallery opened with', this.galleryItems.length, 'items'); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when gallery closes | ||||
|      */ | ||||
|     private onGalleryClose(): void { | ||||
|         this.isGalleryMode = false; | ||||
|         this.galleryItems = []; | ||||
|         console.debug('Gallery closed'); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when gallery slide changes | ||||
|      */ | ||||
|     private onGalleryChange(index: number): void { | ||||
|         const item = this.galleryItems[index]; | ||||
|         if (item && item.noteId) { | ||||
|             console.debug('Gallery slide changed to index:', index, 'noteId:', item.noteId); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when content viewer opens | ||||
|      */ | ||||
|     private onContentViewerOpen(noteId: string): void { | ||||
|         console.debug('Content viewer opened for note:', noteId); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when content viewer closes | ||||
|      */ | ||||
|     private onContentViewerClose(noteId: string): void { | ||||
|         this.currentNoteId = null; | ||||
|         console.debug('Content viewer closed for note:', noteId); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when content image changes | ||||
|      */ | ||||
|     private onContentImageChange(index: number, items: MediaItem[]): void { | ||||
|         console.debug('Content image changed to index:', index, 'of', items.length); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Handle image loading errors with graceful degradation | ||||
|      */ | ||||
|     private onImageError(item: MediaItem, error?: Error): void { | ||||
|         const errorMessage = `Failed to load image: ${item.title || 'Unknown'}`; | ||||
|         console.error(errorMessage, { src: item.src, error }); | ||||
|          | ||||
|         // Show user-friendly error message | ||||
|         toastService.showError(errorMessage); | ||||
|          | ||||
|         // Log the error for debugging | ||||
|         console.debug('Image load error:', {  | ||||
|             item,  | ||||
|             error: error?.message || 'Unknown error'  | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Download current image | ||||
|      */ | ||||
|     async downloadCurrent(): Promise<void> { | ||||
|         if (!mediaViewerService.isOpen()) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         const index = mediaViewerService.getCurrentIndex(); | ||||
|         const item = this.isGalleryMode ? this.galleryItems[index] : null; | ||||
|  | ||||
|         if (item && item.noteId) { | ||||
|             try { | ||||
|                 const note = await froca.getNote(item.noteId); | ||||
|                 if (note) { | ||||
|                     const url = `api/notes/${note.noteId}/download`; | ||||
|                     window.open(url); | ||||
|                 } | ||||
|             } catch (error) { | ||||
|                 console.error('Failed to download image:', error); | ||||
|                 toastService.showError('Failed to download image'); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Copy image reference to clipboard | ||||
|      */ | ||||
|     async copyImageReference(): Promise<void> { | ||||
|         if (!mediaViewerService.isOpen()) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         const index = mediaViewerService.getCurrentIndex(); | ||||
|         const item = this.isGalleryMode ? this.galleryItems[index] : null; | ||||
|  | ||||
|         if (item && item.noteId) { | ||||
|             try { | ||||
|                 const reference = ``; | ||||
|                 await navigator.clipboard.writeText(reference); | ||||
|                 toastService.showMessage('Image reference copied to clipboard'); | ||||
|             } catch (error) { | ||||
|                 console.error('Failed to copy image reference:', error); | ||||
|                 toastService.showError('Failed to copy image reference'); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Get metadata for current image with type safety | ||||
|      */ | ||||
|     async getCurrentMetadata(): Promise<{ | ||||
|         noteId: string; | ||||
|         title: string; | ||||
|         mime?: string; | ||||
|         fileSize?: string; | ||||
|         width?: number; | ||||
|         height?: number; | ||||
|         dateCreated?: string; | ||||
|         dateModified?: string; | ||||
|     } | null> { | ||||
|         try { | ||||
|             if (!mediaViewerService.isOpen()) { | ||||
|                 return null; | ||||
|             } | ||||
|  | ||||
|             const index = mediaViewerService.getCurrentIndex(); | ||||
|             const item = this.isGalleryMode ? this.galleryItems[index] : null; | ||||
|  | ||||
|             if (item && item.noteId) { | ||||
|                 const note = await froca.getNote(item.noteId); | ||||
|                 if (note) { | ||||
|                     const metadata = await note.getMetadata(); | ||||
|                     return { | ||||
|                         noteId: note.noteId, | ||||
|                         title: note.title || 'Untitled', | ||||
|                         mime: note.mime, | ||||
|                         fileSize: note.getAttribute('label', 'fileSize')?.value, | ||||
|                         width: item.width, | ||||
|                         height: item.height, | ||||
|                         dateCreated: metadata.dateCreated, | ||||
|                         dateModified: metadata.dateModified | ||||
|                     }; | ||||
|                 } | ||||
|             } | ||||
|         } catch (error) { | ||||
|             console.error('Failed to get image metadata:', error); | ||||
|         } | ||||
|  | ||||
|         return null; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Cleanup handlers and resources | ||||
|      */ | ||||
|     cleanup(): void { | ||||
|         try { | ||||
|             // Close viewer if open | ||||
|             mediaViewerService.close(); | ||||
|  | ||||
|             // Remove all click handlers | ||||
|             this.clickHandlers.forEach((handler, element) => { | ||||
|                 element.removeEventListener('click', handler); | ||||
|                 element.classList.remove('media-viewer-trigger'); | ||||
|                 element.style.cursor = ''; | ||||
|             }); | ||||
|             this.clickHandlers.clear(); | ||||
|  | ||||
|             // Remove keyboard handler with proper reference | ||||
|             if (this.boundKeyboardHandler) { | ||||
|                 document.removeEventListener('keydown', this.boundKeyboardHandler); | ||||
|                 this.boundKeyboardHandler = null; | ||||
|             } | ||||
|  | ||||
|             // Clear references | ||||
|             this.currentNoteId = null; | ||||
|             this.galleryItems = []; | ||||
|             this.isGalleryMode = false; | ||||
|         } catch (error) { | ||||
|             console.error('Error during MediaViewerWidget cleanup:', error); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Handle note changes | ||||
|      */ | ||||
|     async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">): Promise<void> { | ||||
|         // Refresh viewer if current note was reloaded | ||||
|         if (this.currentNoteId && loadResults.isNoteReloaded(this.currentNoteId)) { | ||||
|             // Close and reopen with updated data | ||||
|             if (mediaViewerService.isOpen()) { | ||||
|                 const index = mediaViewerService.getCurrentIndex(); | ||||
|                 mediaViewerService.close(); | ||||
|                  | ||||
|                 if (this.isGalleryMode) { | ||||
|                     const noteIds = this.galleryItems.map(item => item.noteId).filter(Boolean) as string[]; | ||||
|                     await this.openGallery(noteIds, index); | ||||
|                 } else { | ||||
|                     await this.openImageNote(this.currentNoteId); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Apply theme changes | ||||
|      */ | ||||
|     themeChangedEvent(): void { | ||||
|         const isDarkTheme = document.body.classList.contains('theme-dark') ||  | ||||
|                            document.body.classList.contains('theme-next-dark'); | ||||
|         mediaViewerService.applyTheme(isDarkTheme); | ||||
|     } | ||||
| } | ||||
|  | ||||
| // Create global instance for easy access | ||||
| const mediaViewerWidget = new MediaViewerWidget(); | ||||
|  | ||||
| export default mediaViewerWidget; | ||||
| @@ -6,6 +6,7 @@ import contentRenderer from "../../services/content_renderer.js"; | ||||
| import utils from "../../services/utils.js"; | ||||
| import options from "../../services/options.js"; | ||||
| import attributes from "../../services/attributes.js"; | ||||
| import ckeditorPhotoswipeIntegration from "../../services/ckeditor_photoswipe_integration.js"; | ||||
|  | ||||
| export default class AbstractTextTypeWidget extends TypeWidget { | ||||
|     doRender() { | ||||
| @@ -35,7 +36,29 @@ export default class AbstractTextTypeWidget extends TypeWidget { | ||||
|         const parsedImage  = await this.parseFromImage($img); | ||||
|  | ||||
|         if (parsedImage) { | ||||
|             appContext.tabManager.getActiveContext()?.setNote(parsedImage.noteId, { viewScope: parsedImage.viewScope }); | ||||
|             // Check if this is an attachment image and PhotoSwipe is available | ||||
|             if (parsedImage.viewScope?.attachmentId) { | ||||
|                 // Instead of navigating to attachment detail, trigger PhotoSwipe | ||||
|                 // Check if the image is already processed by PhotoSwipe | ||||
|                 const imgElement = $img[0] as HTMLImageElement; | ||||
|                  | ||||
|                 // Check if PhotoSwipe is integrated with this image using multiple reliable indicators | ||||
|                 const hasPhotoSwipe = imgElement.classList.contains('photoswipe-enabled') ||  | ||||
|                                       imgElement.hasAttribute('data-photoswipe') || | ||||
|                                       imgElement.style.cursor === 'zoom-in'; | ||||
|                  | ||||
|                 if (hasPhotoSwipe) { | ||||
|                     // Image has PhotoSwipe integration, trigger click to open lightbox | ||||
|                     $img.trigger('click'); | ||||
|                     return; | ||||
|                 } | ||||
|                  | ||||
|                 // Otherwise, fall back to opening attachment detail (but with improved navigation) | ||||
|                 appContext.tabManager.getActiveContext()?.setNote(parsedImage.noteId, { viewScope: parsedImage.viewScope }); | ||||
|             } else { | ||||
|                 // Regular note image, navigate normally | ||||
|                 appContext.tabManager.getActiveContext()?.setNote(parsedImage.noteId, { viewScope: parsedImage.viewScope }); | ||||
|             } | ||||
|         } else { | ||||
|             window.open($img.prop("src"), "_blank"); | ||||
|         } | ||||
|   | ||||
| @@ -4,6 +4,8 @@ import linkService from "../../services/link.js"; | ||||
| import utils from "../../services/utils.js"; | ||||
| import { t } from "../../services/i18n.js"; | ||||
| import type { EventData } from "../../components/app_context.js"; | ||||
| import galleryManager from "../../services/gallery_manager.js"; | ||||
| import type { GalleryItem } from "../../services/gallery_manager.js"; | ||||
|  | ||||
| const TPL = /*html*/` | ||||
| <div class="attachment-list note-detail-printable"> | ||||
| @@ -20,17 +22,81 @@ const TPL = /*html*/` | ||||
|             justify-content: space-between; | ||||
|             align-items: baseline; | ||||
|         } | ||||
|          | ||||
|         .attachment-list .gallery-toolbar { | ||||
|             display: flex; | ||||
|             gap: 5px; | ||||
|             margin-bottom: 10px; | ||||
|         } | ||||
|          | ||||
|         .attachment-list .gallery-toolbar button { | ||||
|             padding: 5px 10px; | ||||
|             font-size: 12px; | ||||
|         } | ||||
|          | ||||
|         .attachment-list .image-grid { | ||||
|             display: grid; | ||||
|             grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); | ||||
|             gap: 10px; | ||||
|             margin-bottom: 20px; | ||||
|         } | ||||
|          | ||||
|         .attachment-list .image-grid .image-thumbnail { | ||||
|             position: relative; | ||||
|             width: 100%; | ||||
|             padding-bottom: 100%; /* 1:1 aspect ratio */ | ||||
|             overflow: hidden; | ||||
|             border-radius: 4px; | ||||
|             cursor: pointer; | ||||
|             background: var(--accented-background-color); | ||||
|         } | ||||
|          | ||||
|         .attachment-list .image-grid .image-thumbnail img { | ||||
|             position: absolute; | ||||
|             top: 0; | ||||
|             left: 0; | ||||
|             width: 100%; | ||||
|             height: 100%; | ||||
|             object-fit: cover; | ||||
|             transition: transform 0.2s; | ||||
|         } | ||||
|          | ||||
|         .attachment-list .image-grid .image-thumbnail:hover img { | ||||
|             transform: scale(1.05); | ||||
|         } | ||||
|          | ||||
|         .attachment-list .image-grid .image-thumbnail .overlay { | ||||
|             position: absolute; | ||||
|             bottom: 0; | ||||
|             left: 0; | ||||
|             right: 0; | ||||
|             background: linear-gradient(to top, rgba(0,0,0,0.7), transparent); | ||||
|             color: white; | ||||
|             padding: 5px; | ||||
|             font-size: 11px; | ||||
|             opacity: 0; | ||||
|             transition: opacity 0.2s; | ||||
|         } | ||||
|          | ||||
|         .attachment-list .image-grid .image-thumbnail:hover .overlay { | ||||
|             opacity: 1; | ||||
|         } | ||||
|     </style> | ||||
|  | ||||
|     <div class="links-wrapper"></div> | ||||
|  | ||||
|     <div class="gallery-toolbar" style="display: none;"></div> | ||||
|     <div class="image-grid" style="display: none;"></div> | ||||
|     <div class="attachment-list-wrapper"></div> | ||||
| </div>`; | ||||
|  | ||||
| export default class AttachmentListTypeWidget extends TypeWidget { | ||||
|     $list!: JQuery<HTMLElement>; | ||||
|     $linksWrapper!: JQuery<HTMLElement>; | ||||
|     $galleryToolbar!: JQuery<HTMLElement>; | ||||
|     $imageGrid!: JQuery<HTMLElement>; | ||||
|     renderedAttachmentIds!: Set<string>; | ||||
|     imageAttachments: GalleryItem[] = []; | ||||
|     otherAttachments: any[] = []; | ||||
|  | ||||
|     static getType() { | ||||
|         return "attachmentList"; | ||||
| @@ -40,6 +106,8 @@ export default class AttachmentListTypeWidget extends TypeWidget { | ||||
|         this.$widget = $(TPL); | ||||
|         this.$list = this.$widget.find(".attachment-list-wrapper"); | ||||
|         this.$linksWrapper = this.$widget.find(".links-wrapper"); | ||||
|         this.$galleryToolbar = this.$widget.find(".gallery-toolbar"); | ||||
|         this.$imageGrid = this.$widget.find(".image-grid"); | ||||
|  | ||||
|         super.doRender(); | ||||
|     } | ||||
| @@ -75,8 +143,12 @@ export default class AttachmentListTypeWidget extends TypeWidget { | ||||
|         ); | ||||
|  | ||||
|         this.$list.empty(); | ||||
|         this.$imageGrid.empty().hide(); | ||||
|         this.$galleryToolbar.empty().hide(); | ||||
|         this.children = []; | ||||
|         this.renderedAttachmentIds = new Set(); | ||||
|         this.imageAttachments = []; | ||||
|         this.otherAttachments = []; | ||||
|  | ||||
|         const attachments = await note.getAttachments(); | ||||
|  | ||||
| @@ -85,17 +157,122 @@ export default class AttachmentListTypeWidget extends TypeWidget { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         // Separate image and non-image attachments | ||||
|         for (const attachment of attachments) { | ||||
|             if (attachment.role === 'image') { | ||||
|                 const galleryItem: GalleryItem = { | ||||
|                     src: `/api/attachments/${attachment.attachmentId}/image`, | ||||
|                     alt: attachment.title, | ||||
|                     title: attachment.title, | ||||
|                     attachmentId: attachment.attachmentId, | ||||
|                     noteId: attachment.ownerId, | ||||
|                     index: this.imageAttachments.length | ||||
|                 }; | ||||
|                 this.imageAttachments.push(galleryItem); | ||||
|             } else { | ||||
|                 this.otherAttachments.push(attachment); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // If we have image attachments, show gallery view | ||||
|         if (this.imageAttachments.length > 0) { | ||||
|             this.setupGalleryView(); | ||||
|         } | ||||
|  | ||||
|         // Render non-image attachments in the traditional list | ||||
|         for (const attachment of this.otherAttachments) { | ||||
|             const attachmentDetailWidget = new AttachmentDetailWidget(attachment, false); | ||||
|  | ||||
|             this.child(attachmentDetailWidget); | ||||
|  | ||||
|             this.renderedAttachmentIds.add(attachment.attachmentId); | ||||
|  | ||||
|             this.$list.append(attachmentDetailWidget.render()); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     setupGalleryView() { | ||||
|         // Show gallery toolbar | ||||
|         this.$galleryToolbar.show(); | ||||
|          | ||||
|         // Add gallery action buttons | ||||
|         const $viewAllButton = $(` | ||||
|             <button class="btn btn-sm view-gallery-btn"> | ||||
|                 <span class="bx bx-images"></span> | ||||
|                 View as Gallery (${this.imageAttachments.length} images) | ||||
|             </button> | ||||
|         `); | ||||
|          | ||||
|         const $slideshowButton = $(` | ||||
|             <button class="btn btn-sm slideshow-btn"> | ||||
|                 <span class="bx bx-play-circle"></span> | ||||
|                 Start Slideshow | ||||
|             </button> | ||||
|         `); | ||||
|          | ||||
|         this.$galleryToolbar.append($viewAllButton, $slideshowButton); | ||||
|          | ||||
|         // Handle gallery view button | ||||
|         $viewAllButton.on('click', () => { | ||||
|             galleryManager.openGallery(this.imageAttachments, 0, { | ||||
|                 showThumbnails: true, | ||||
|                 showCounter: true, | ||||
|                 enableKeyboardNav: true, | ||||
|                 loop: true | ||||
|             }); | ||||
|         }); | ||||
|          | ||||
|         // Handle slideshow button | ||||
|         $slideshowButton.on('click', () => { | ||||
|             galleryManager.openGallery(this.imageAttachments, 0, { | ||||
|                 showThumbnails: false, | ||||
|                 autoPlay: true, | ||||
|                 slideInterval: 4000, | ||||
|                 showCounter: true, | ||||
|                 loop: true | ||||
|             }); | ||||
|         }); | ||||
|          | ||||
|         // Create image grid | ||||
|         this.$imageGrid.show(); | ||||
|          | ||||
|         this.imageAttachments.forEach((item, index) => { | ||||
|             const $thumbnail = $(` | ||||
|                 <div class="image-thumbnail"  | ||||
|                      data-index="${index}" | ||||
|                      role="button" | ||||
|                      tabindex="0" | ||||
|                      aria-label="View ${item.alt || item.title || 'image'} in gallery"> | ||||
|                     <img src="${item.src}"  | ||||
|                          alt="${item.alt || item.title || `Image ${index + 1}`}"  | ||||
|                          loading="lazy" | ||||
|                          aria-describedby="thumb-desc-${index}"> | ||||
|                     <div class="overlay" id="thumb-desc-${index}">${item.title || ''}</div> | ||||
|                 </div> | ||||
|             `); | ||||
|              | ||||
|             // Add click handler | ||||
|             $thumbnail.on('click', () => { | ||||
|                 galleryManager.openGallery(this.imageAttachments, index, { | ||||
|                     showThumbnails: true, | ||||
|                     showCounter: true, | ||||
|                     enableKeyboardNav: true | ||||
|                 }); | ||||
|             }); | ||||
|              | ||||
|             // Add keyboard support for accessibility | ||||
|             $thumbnail.on('keydown', (e) => { | ||||
|                 if (e.key === 'Enter' || e.key === ' ') { | ||||
|                     e.preventDefault(); | ||||
|                     galleryManager.openGallery(this.imageAttachments, index, { | ||||
|                         showThumbnails: true, | ||||
|                         showCounter: true, | ||||
|                         enableKeyboardNav: true | ||||
|                     }); | ||||
|                 } | ||||
|             }); | ||||
|              | ||||
|             this.$imageGrid.append($thumbnail); | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) { | ||||
|         // updates and deletions are handled by the detail, for new attachments the whole list has to be refreshed | ||||
|         const attachmentsAdded = loadResults.getAttachmentRows().some((att) => att.attachmentId && !this.renderedAttachmentIds.has(att.attachmentId)); | ||||
| @@ -104,4 +281,16 @@ export default class AttachmentListTypeWidget extends TypeWidget { | ||||
|             this.refresh(); | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     cleanup() { | ||||
|         // Clean up event handlers | ||||
|         if (this.$galleryToolbar) { | ||||
|             this.$galleryToolbar.find('button').off(); | ||||
|         } | ||||
|         if (this.$imageGrid) { | ||||
|             this.$imageGrid.find('.image-thumbnail').off(); | ||||
|         } | ||||
|          | ||||
|         super.cleanup(); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -14,6 +14,7 @@ import type FNote from "../../entities/fnote.js"; | ||||
| import { PopupEditor, ClassicEditor, EditorWatchdog, type CKTextEditor, type MentionFeed, type WatchdogConfig, EditorConfig } from "@triliumnext/ckeditor5"; | ||||
| import "@triliumnext/ckeditor5/index.css"; | ||||
| import { updateTemplateCache } from "./ckeditor/snippets.js"; | ||||
| import ckeditorPhotoSwipe from "../../services/ckeditor_photoswipe_integration.js"; | ||||
|  | ||||
| const TPL = /*html*/` | ||||
| <div class="note-detail-editable-text note-detail-printable"> | ||||
| @@ -162,6 +163,19 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget { | ||||
|                 isClassicEditor | ||||
|             }; | ||||
|             const editor = await buildEditor(this.$editor[0], isClassicEditor, opts); | ||||
|              | ||||
|             // Setup PhotoSwipe integration for images in the editor | ||||
|             setTimeout(() => { | ||||
|                 const editorElement = this.$editor[0]; | ||||
|                 if (editorElement) { | ||||
|                     ckeditorPhotoSwipe.setupContainer(editorElement, { | ||||
|                         enableGalleryMode: true, | ||||
|                         showHints: true, | ||||
|                         hintDelay: 2000, | ||||
|                         excludeSelector: '.cke_widget_element, .ck-widget' | ||||
|                     }); | ||||
|                 } | ||||
|             }, 100); | ||||
|  | ||||
|             const notificationsPlugin = editor.plugins.get("Notification"); | ||||
|             notificationsPlugin.on("show:warning", (evt, data) => { | ||||
| @@ -291,11 +305,25 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget { | ||||
|     } | ||||
|  | ||||
|     cleanup() { | ||||
|         // Cleanup PhotoSwipe integration | ||||
|         if (this.$editor?.[0]) { | ||||
|             ckeditorPhotoSwipe.cleanupContainer(this.$editor[0]); | ||||
|         } | ||||
|          | ||||
|         if (this.watchdog?.editor) { | ||||
|             this.spacedUpdate.allowUpdateWithoutChange(() => { | ||||
|                 this.watchdog.editor?.setData(""); | ||||
|             }); | ||||
|         } | ||||
|          | ||||
|         // Destroy the watchdog to clean up all CKEditor resources | ||||
|         if (this.watchdog) { | ||||
|             this.watchdog.destroy().catch((error: any) => { | ||||
|                 console.error('Error destroying CKEditor watchdog:', error); | ||||
|             }); | ||||
|         } | ||||
|          | ||||
|         super.cleanup(); | ||||
|     } | ||||
|  | ||||
|     insertDateTimeToTextCommand() { | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import openService from "../../services/open.js"; | ||||
| import TypeWidget from "./type_widget.js"; | ||||
| import { ImageViewerBase } from "./image_viewer_base.js"; | ||||
| import { t } from "../../services/i18n.js"; | ||||
| import type { EventData } from "../../components/app_context.js"; | ||||
| import type FNote from "../../entities/fnote.js"; | ||||
| @@ -23,7 +23,8 @@ const TPL = /*html*/` | ||||
|         } | ||||
|  | ||||
|         .note-detail.full-height .note-detail-file[data-preview-type="pdf"], | ||||
|         .note-detail.full-height .note-detail-file[data-preview-type="video"] { | ||||
|         .note-detail.full-height .note-detail-file[data-preview-type="video"], | ||||
|         .note-detail.full-height .note-detail-file[data-preview-type="image"] { | ||||
|             overflow: hidden; | ||||
|         } | ||||
|  | ||||
| @@ -39,6 +40,133 @@ const TPL = /*html*/` | ||||
|             width: 100%; | ||||
|             height: 100%; | ||||
|         } | ||||
|  | ||||
|         .image-file-preview { | ||||
|             display: flex; | ||||
|             flex-direction: column; | ||||
|             align-items: center; | ||||
|             justify-content: center; | ||||
|             height: 100%; | ||||
|             position: relative; | ||||
|             overflow: hidden; | ||||
|         } | ||||
|  | ||||
|         .image-file-view { | ||||
|             max-width: 100%; | ||||
|             max-height: 90%; | ||||
|             cursor: zoom-in; | ||||
|             transition: opacity 0.2s ease; | ||||
|         } | ||||
|  | ||||
|         .image-file-view:hover { | ||||
|             opacity: 0.95; | ||||
|         } | ||||
|  | ||||
|         .image-file-controls { | ||||
|             position: absolute; | ||||
|             bottom: 20px; | ||||
|             right: 20px; | ||||
|             display: flex; | ||||
|             gap: 10px; | ||||
|             background: rgba(0, 0, 0, 0.6); | ||||
|             border-radius: 8px; | ||||
|             padding: 8px; | ||||
|             z-index: 10; | ||||
|         } | ||||
|  | ||||
|         .image-file-control-btn { | ||||
|             background: rgba(255, 255, 255, 0.9); | ||||
|             border: none; | ||||
|             border-radius: 4px; | ||||
|             min-width: 44px; | ||||
|             min-height: 44px; | ||||
|             width: 44px; | ||||
|             height: 44px; | ||||
|             display: flex; | ||||
|             align-items: center; | ||||
|             justify-content: center; | ||||
|             cursor: pointer; | ||||
|             transition: background 0.2s; | ||||
|         } | ||||
|  | ||||
|         .image-file-control-btn:hover:not(:disabled) { | ||||
|             background: rgba(255, 255, 255, 1); | ||||
|         } | ||||
|  | ||||
|         .image-file-control-btn:disabled { | ||||
|             opacity: 0.5; | ||||
|             cursor: not-allowed; | ||||
|         } | ||||
|  | ||||
|         .image-file-control-btn i { | ||||
|             font-size: 20px; | ||||
|             color: #333; | ||||
|         } | ||||
|  | ||||
|         .image-file-info { | ||||
|             position: absolute; | ||||
|             top: 10px; | ||||
|             left: 10px; | ||||
|             background: rgba(0, 0, 0, 0.7); | ||||
|             color: white; | ||||
|             padding: 8px 12px; | ||||
|             border-radius: 4px; | ||||
|             font-size: 12px; | ||||
|             z-index: 10; | ||||
|         } | ||||
|  | ||||
|         /* Loading indicator */ | ||||
|         .image-loading-indicator { | ||||
|             position: absolute; | ||||
|             top: 50%; | ||||
|             left: 50%; | ||||
|             transform: translate(-50%, -50%); | ||||
|             z-index: 100; | ||||
|         } | ||||
|  | ||||
|         /* Zoom indicator */ | ||||
|         .zoom-indicator { | ||||
|             position: absolute; | ||||
|             bottom: 80px; | ||||
|             right: 20px; | ||||
|             background: rgba(0, 0, 0, 0.7); | ||||
|             color: white; | ||||
|             padding: 4px 8px; | ||||
|             border-radius: 4px; | ||||
|             font-size: 12px; | ||||
|             z-index: 10; | ||||
|             pointer-events: none; | ||||
|         } | ||||
|  | ||||
|         /* Mobile optimizations */ | ||||
|         @media (max-width: 768px) { | ||||
|             .image-file-controls { | ||||
|                 bottom: 10px; | ||||
|                 right: 10px; | ||||
|                 padding: 6px; | ||||
|                 gap: 8px; | ||||
|             } | ||||
|  | ||||
|             .image-file-info { | ||||
|                 font-size: 11px; | ||||
|                 padding: 6px 10px; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         /* High contrast mode support */ | ||||
|         @media (prefers-contrast: high) { | ||||
|             .image-file-control-btn { | ||||
|                 border: 2px solid currentColor; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         /* Reduced motion support */ | ||||
|         @media (prefers-reduced-motion: reduce) { | ||||
|             .image-file-view, | ||||
|             .image-file-control-btn { | ||||
|                 transition: none; | ||||
|             } | ||||
|         } | ||||
|     </style> | ||||
|  | ||||
|     <div class="file-preview-too-big alert alert-info hidden-ext"> | ||||
| @@ -56,21 +184,66 @@ const TPL = /*html*/` | ||||
|     <video class="video-preview" controls></video> | ||||
|  | ||||
|     <audio class="audio-preview" controls></audio> | ||||
|  | ||||
|     <div class="image-file-preview" style="display: none;"> | ||||
|         <div class="image-file-info"> | ||||
|             <span class="image-dimensions"></span> | ||||
|         </div> | ||||
|         <img class="image-file-view" /> | ||||
|         <div class="image-file-controls"> | ||||
|             <button class="image-file-control-btn zoom-in" type="button" aria-label="Zoom In" title="Zoom In (+ key)"> | ||||
|                 <i class="bx bx-zoom-in" aria-hidden="true"></i> | ||||
|             </button> | ||||
|             <button class="image-file-control-btn zoom-out" type="button" aria-label="Zoom Out" title="Zoom Out (- key)"> | ||||
|                 <i class="bx bx-zoom-out" aria-hidden="true"></i> | ||||
|             </button> | ||||
|             <button class="image-file-control-btn reset-zoom" type="button" aria-label="Reset Zoom" title="Reset Zoom (0 key or double-click)"> | ||||
|                 <i class="bx bx-reset" aria-hidden="true"></i> | ||||
|             </button> | ||||
|             <button class="image-file-control-btn fullscreen" type="button" aria-label="Open in Lightbox" title="Open in Lightbox (Enter or Space key)"> | ||||
|                 <i class="bx bx-fullscreen" aria-hidden="true"></i> | ||||
|             </button> | ||||
|             <button class="image-file-control-btn download" type="button" aria-label="Download" title="Download File"> | ||||
|                 <i class="bx bx-download" aria-hidden="true"></i> | ||||
|             </button> | ||||
|         </div> | ||||
|     </div> | ||||
| </div>`; | ||||
|  | ||||
| export default class FileTypeWidget extends TypeWidget { | ||||
|  | ||||
| export default class FileTypeWidget extends ImageViewerBase { | ||||
|     private $previewContent!: JQuery<HTMLElement>; | ||||
|     private $previewNotAvailable!: JQuery<HTMLElement>; | ||||
|     private $previewTooBig!: JQuery<HTMLElement>; | ||||
|     private $pdfPreview!: JQuery<HTMLElement>; | ||||
|     private $videoPreview!: JQuery<HTMLElement>; | ||||
|     private $audioPreview!: JQuery<HTMLElement>; | ||||
|     private $imageFilePreview!: JQuery<HTMLElement>; | ||||
|     private $imageFileView!: JQuery<HTMLElement>; | ||||
|     private $imageDimensions!: JQuery<HTMLElement>; | ||||
|     private $fullscreenBtn!: JQuery<HTMLElement>; | ||||
|     private $downloadBtn!: JQuery<HTMLElement>; | ||||
|     private $zoomInBtn!: JQuery<HTMLElement>; | ||||
|     private $zoomOutBtn!: JQuery<HTMLElement>; | ||||
|     private $resetZoomBtn!: JQuery<HTMLElement>; | ||||
|     private wheelHandler?: (e: JQuery.TriggeredEvent) => void; | ||||
|     private currentPreviewType?: string; | ||||
|  | ||||
|     static getType() { | ||||
|         return "file"; | ||||
|     } | ||||
|  | ||||
|     constructor() { | ||||
|         super(); | ||||
|         // Apply custom configuration for file viewer | ||||
|         this.applyConfig({ | ||||
|             minZoom: 0.5, | ||||
|             maxZoom: 5, | ||||
|             zoomStep: 0.25, | ||||
|             debounceDelay: 16, | ||||
|             touchTargetSize: 44 | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     doRender() { | ||||
|         this.$widget = $(TPL); | ||||
|         this.$previewContent = this.$widget.find(".file-preview-content"); | ||||
| @@ -79,60 +252,204 @@ export default class FileTypeWidget extends TypeWidget { | ||||
|         this.$pdfPreview = this.$widget.find(".pdf-preview"); | ||||
|         this.$videoPreview = this.$widget.find(".video-preview"); | ||||
|         this.$audioPreview = this.$widget.find(".audio-preview"); | ||||
|         this.$imageFilePreview = this.$widget.find(".image-file-preview"); | ||||
|         this.$imageFileView = this.$widget.find(".image-file-view"); | ||||
|         this.$imageDimensions = this.$widget.find(".image-dimensions"); | ||||
|          | ||||
|         // Image controls | ||||
|         this.$zoomInBtn = this.$widget.find(".zoom-in"); | ||||
|         this.$zoomOutBtn = this.$widget.find(".zoom-out"); | ||||
|         this.$resetZoomBtn = this.$widget.find(".reset-zoom"); | ||||
|         this.$fullscreenBtn = this.$widget.find(".fullscreen"); | ||||
|         this.$downloadBtn = this.$widget.find(".download"); | ||||
|  | ||||
|         // Set image wrapper and view for base class | ||||
|         this.$imageWrapper = this.$imageFilePreview; | ||||
|         this.$imageView = this.$imageFileView; | ||||
|  | ||||
|         this.setupImageControls(); | ||||
|  | ||||
|         super.doRender(); | ||||
|     } | ||||
|  | ||||
|     private setupImageControls(): void { | ||||
|         // Image click to open lightbox | ||||
|         this.$imageFileView?.on("click", (e) => { | ||||
|             e.preventDefault(); | ||||
|             this.openImageInLightbox(); | ||||
|         }); | ||||
|  | ||||
|         // Control button handlers | ||||
|         this.$zoomInBtn?.on("click", () => this.zoomIn()); | ||||
|         this.$zoomOutBtn?.on("click", () => this.zoomOut()); | ||||
|         this.$resetZoomBtn?.on("click", () => this.resetZoom()); | ||||
|         this.$fullscreenBtn?.on("click", () => this.openImageInLightbox()); | ||||
|         this.$downloadBtn?.on("click", () => this.downloadFile()); | ||||
|  | ||||
|         // Mouse wheel zoom with focus check | ||||
|         this.wheelHandler = (e: JQuery.TriggeredEvent) => { | ||||
|             // Only handle if image preview is visible and has focus | ||||
|             if (!this.$imageFilePreview?.is(':visible') || !this.$widget?.is(':focus-within')) { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             e.preventDefault(); | ||||
|             const originalEvent = e.originalEvent as WheelEvent | undefined; | ||||
|             const delta = originalEvent?.deltaY; | ||||
|              | ||||
|             if (delta) { | ||||
|                 if (delta < 0) { | ||||
|                     this.zoomIn(); | ||||
|                 } else { | ||||
|                     this.zoomOut(); | ||||
|                 } | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|         this.$imageFilePreview?.on("wheel", this.wheelHandler); | ||||
|     } | ||||
|  | ||||
|     async doRefresh(note: FNote) { | ||||
|         this.$widget.show(); | ||||
|         this.$widget?.show(); | ||||
|  | ||||
|         const blob = await this.note?.getBlob(); | ||||
|  | ||||
|         this.$previewContent.empty().hide(); | ||||
|         this.$pdfPreview.attr("src", "").empty().hide(); | ||||
|         this.$previewNotAvailable.hide(); | ||||
|         this.$previewTooBig.addClass("hidden-ext"); | ||||
|         this.$videoPreview.hide(); | ||||
|         this.$audioPreview.hide(); | ||||
|         // Hide all preview types | ||||
|         this.$previewContent?.empty().hide(); | ||||
|         this.$pdfPreview?.attr("src", "").empty().hide(); | ||||
|         this.$previewNotAvailable?.hide(); | ||||
|         this.$previewTooBig?.addClass("hidden-ext"); | ||||
|         this.$videoPreview?.hide(); | ||||
|         this.$audioPreview?.hide(); | ||||
|         this.$imageFilePreview?.hide(); | ||||
|  | ||||
|         let previewType: string; | ||||
|  | ||||
|         if (blob?.content) { | ||||
|             this.$previewContent.show().scrollTop(0); | ||||
|         // Check if this is an image file | ||||
|         if (note.mime.startsWith("image/")) { | ||||
|             this.$imageFilePreview?.show(); | ||||
|             const src = openService.getUrlForDownload(`api/notes/${this.noteId}/open`); | ||||
|              | ||||
|             // Reset zoom for new image | ||||
|             this.resetZoom(); | ||||
|              | ||||
|             // Setup pan, keyboard navigation, and other features | ||||
|             this.setupPanFunctionality(); | ||||
|             this.setupKeyboardNavigation(); | ||||
|             this.setupDoubleClickReset(); | ||||
|             this.setupContextMenu(); | ||||
|             this.addAccessibilityLabels(); | ||||
|              | ||||
|             // Load image with loading state and error handling | ||||
|             try { | ||||
|                 await this.setupImage(src, this.$imageFileView!); | ||||
|                 await this.loadImageDimensions(src); | ||||
|             } catch (error) { | ||||
|                 console.error("Failed to load image file:", error); | ||||
|             } | ||||
|              | ||||
|             previewType = "image"; | ||||
|         } else if (blob?.content) { | ||||
|             this.$previewContent?.show().scrollTop(0); | ||||
|             const trimmedContent = blob.content.substring(0, TEXT_MAX_NUM_CHARS); | ||||
|             if (trimmedContent.length !== blob.content.length) { | ||||
|                 this.$previewTooBig.removeClass("hidden-ext"); | ||||
|                 this.$previewTooBig?.removeClass("hidden-ext"); | ||||
|             } | ||||
|             this.$previewContent.text(trimmedContent); | ||||
|             this.$previewContent?.text(trimmedContent); | ||||
|             previewType = "text"; | ||||
|         } else if (note.mime === "application/pdf") { | ||||
|             this.$pdfPreview.show().attr("src", openService.getUrlForDownload(`api/notes/${this.noteId}/open`)); | ||||
|             this.$pdfPreview?.show().attr("src", openService.getUrlForDownload(`api/notes/${this.noteId}/open`)); | ||||
|             previewType = "pdf"; | ||||
|         } else if (note.mime.startsWith("video/")) { | ||||
|             this.$videoPreview | ||||
|                 .show() | ||||
|                 ?.show() | ||||
|                 .attr("src", openService.getUrlForDownload(`api/notes/${this.noteId}/open-partial`)) | ||||
|                 .attr("type", this.note?.mime ?? "") | ||||
|                 .css("width", this.$widget.width() ?? 0); | ||||
|                 .css("width", this.$widget?.width() ?? 0); | ||||
|             previewType = "video"; | ||||
|         } else if (note.mime.startsWith("audio/")) { | ||||
|             this.$audioPreview | ||||
|                 .show() | ||||
|                 ?.show() | ||||
|                 .attr("src", openService.getUrlForDownload(`api/notes/${this.noteId}/open-partial`)) | ||||
|                 .attr("type", this.note?.mime ?? "") | ||||
|                 .css("width", this.$widget.width() ?? 0); | ||||
|                 .css("width", this.$widget?.width() ?? 0); | ||||
|             previewType = "audio"; | ||||
|         } else { | ||||
|             this.$previewNotAvailable.show(); | ||||
|             this.$previewNotAvailable?.show(); | ||||
|             previewType = "not-available"; | ||||
|         } | ||||
|  | ||||
|         this.$widget.attr("data-preview-type", previewType ?? ""); | ||||
|         this.currentPreviewType = previewType; | ||||
|         this.$widget?.attr("data-preview-type", previewType ?? ""); | ||||
|     } | ||||
|  | ||||
|     private async loadImageDimensions(src: string): Promise<void> { | ||||
|         try { | ||||
|             // Use a new Image object to get dimensions | ||||
|             const img = new Image(); | ||||
|              | ||||
|             await new Promise<void>((resolve, reject) => { | ||||
|                 img.onload = () => { | ||||
|                     this.$imageDimensions?.text(`${img.width} × ${img.height}px`); | ||||
|                     resolve(); | ||||
|                 }; | ||||
|                 img.onerror = () => { | ||||
|                     this.$imageDimensions?.text("Image"); | ||||
|                     reject(new Error("Failed to load image dimensions")); | ||||
|                 }; | ||||
|                 img.src = src; | ||||
|             }); | ||||
|         } catch (error) { | ||||
|             console.warn("Failed to get image dimensions:", error); | ||||
|             this.$imageDimensions?.text("Image"); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private openImageInLightbox(): void { | ||||
|         if (!this.note || !this.$imageFileView?.length) return; | ||||
|          | ||||
|         const src = this.$imageFileView.attr("src") || this.$imageFileView.prop("src"); | ||||
|         if (!src) return; | ||||
|  | ||||
|         this.openInLightbox( | ||||
|             src, | ||||
|             this.note.title || "Image File", | ||||
|             this.noteId, | ||||
|             this.$imageFileView.get(0) | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     private downloadFile(): void { | ||||
|         if (!this.note) return; | ||||
|          | ||||
|         try { | ||||
|             const link = document.createElement('a'); | ||||
|             link.href = openService.getUrlForDownload(`api/notes/${this.noteId}/open`); | ||||
|             link.download = this.note.title || 'file'; | ||||
|              | ||||
|             // Add to document, click, and remove | ||||
|             document.body.appendChild(link); | ||||
|             link.click(); | ||||
|             document.body.removeChild(link); | ||||
|         } catch (error) { | ||||
|             console.error("Failed to download file:", error); | ||||
|             alert("Failed to download file. Please try again."); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) { | ||||
|         if (loadResults.isNoteReloaded(this.noteId)) { | ||||
|             this.refresh(); | ||||
|             await this.refresh(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
|     cleanup() { | ||||
|         // Remove wheel handler if it exists | ||||
|         if (this.wheelHandler && this.$imageFilePreview?.length) { | ||||
|             this.$imageFilePreview.off("wheel", this.wheelHandler); | ||||
|         } | ||||
|          | ||||
|         // Call parent cleanup | ||||
|         super.cleanup(); | ||||
|     } | ||||
| } | ||||
| @@ -1,10 +1,8 @@ | ||||
| import utils from "../../services/utils.js"; | ||||
| import TypeWidget from "./type_widget.js"; | ||||
| import imageContextMenuService from "../../menus/image_context_menu.js"; | ||||
| import { ImageViewerBase } from "./image_viewer_base.js"; | ||||
| import imageService from "../../services/image.js"; | ||||
| import type FNote from "../../entities/fnote.js"; | ||||
| import type { EventData } from "../../components/app_context.js"; | ||||
| import WheelZoom from 'vanilla-js-wheel-zoom'; | ||||
|  | ||||
| const TPL = /*html*/` | ||||
| <div class="note-detail-image note-detail-printable"> | ||||
| @@ -15,6 +13,7 @@ const TPL = /*html*/` | ||||
|  | ||||
|         .note-detail-image { | ||||
|             height: 100%; | ||||
|             position: relative; | ||||
|         } | ||||
|  | ||||
|         .note-detail-image-wrapper { | ||||
| @@ -28,53 +27,314 @@ const TPL = /*html*/` | ||||
|  | ||||
|         .note-detail-image-view { | ||||
|             display: block; | ||||
|             max-width: 100%; | ||||
|             max-height: 100%; | ||||
|             width: auto; | ||||
|             height: auto; | ||||
|             align-self: center; | ||||
|             flex-shrink: 0; | ||||
|             cursor: zoom-in; | ||||
|             transition: opacity 0.2s ease; | ||||
|         } | ||||
|  | ||||
|         .note-detail-image-view:hover { | ||||
|             opacity: 0.95; | ||||
|         } | ||||
|  | ||||
|         .image-controls { | ||||
|             position: absolute; | ||||
|             bottom: 20px; | ||||
|             right: 20px; | ||||
|             display: flex; | ||||
|             gap: 10px; | ||||
|             z-index: 10; | ||||
|             background: rgba(0, 0, 0, 0.6); | ||||
|             border-radius: 8px; | ||||
|             padding: 8px; | ||||
|         } | ||||
|  | ||||
|         .image-control-btn { | ||||
|             background: rgba(255, 255, 255, 0.9); | ||||
|             border: none; | ||||
|             border-radius: 4px; | ||||
|             min-width: 44px; | ||||
|             min-height: 44px; | ||||
|             width: 44px; | ||||
|             height: 44px; | ||||
|             display: flex; | ||||
|             align-items: center; | ||||
|             justify-content: center; | ||||
|             cursor: pointer; | ||||
|             transition: background 0.2s; | ||||
|         } | ||||
|  | ||||
|         .image-control-btn:hover:not(:disabled) { | ||||
|             background: rgba(255, 255, 255, 1); | ||||
|         } | ||||
|  | ||||
|         .image-control-btn:disabled { | ||||
|             opacity: 0.5; | ||||
|             cursor: not-allowed; | ||||
|         } | ||||
|  | ||||
|         .image-control-btn i { | ||||
|             font-size: 20px; | ||||
|             color: #333; | ||||
|         } | ||||
|  | ||||
|         /* Keyboard hints overlay */ | ||||
|         .keyboard-hints { | ||||
|             position: absolute; | ||||
|             top: 10px; | ||||
|             right: 10px; | ||||
|             background: rgba(0, 0, 0, 0.7); | ||||
|             color: white; | ||||
|             padding: 8px 12px; | ||||
|             border-radius: 4px; | ||||
|             font-size: 12px; | ||||
|             opacity: 0; | ||||
|             transition: opacity 0.3s; | ||||
|             pointer-events: none; | ||||
|             z-index: 10; | ||||
|         } | ||||
|  | ||||
|         .note-detail-image:hover .keyboard-hints { | ||||
|             opacity: 0.8; | ||||
|         } | ||||
|  | ||||
|         .keyboard-hints .hint { | ||||
|             margin: 2px 0; | ||||
|         } | ||||
|  | ||||
|         .keyboard-hints .key { | ||||
|             background: rgba(255, 255, 255, 0.2); | ||||
|             padding: 2px 6px; | ||||
|             border-radius: 3px; | ||||
|             margin-right: 4px; | ||||
|             font-family: monospace; | ||||
|         } | ||||
|  | ||||
|         /* Loading indicator */ | ||||
|         .image-loading-indicator { | ||||
|             position: absolute; | ||||
|             top: 50%; | ||||
|             left: 50%; | ||||
|             transform: translate(-50%, -50%); | ||||
|             z-index: 100; | ||||
|         } | ||||
|  | ||||
|         /* Zoom indicator */ | ||||
|         .zoom-indicator { | ||||
|             position: absolute; | ||||
|             bottom: 80px; | ||||
|             right: 20px; | ||||
|             background: rgba(0, 0, 0, 0.7); | ||||
|             color: white; | ||||
|             padding: 4px 8px; | ||||
|             border-radius: 4px; | ||||
|             font-size: 12px; | ||||
|             z-index: 10; | ||||
|             pointer-events: none; | ||||
|         } | ||||
|  | ||||
|         /* Mobile optimizations */ | ||||
|         @media (max-width: 768px) { | ||||
|             .image-controls { | ||||
|                 bottom: 10px; | ||||
|                 right: 10px; | ||||
|                 padding: 6px; | ||||
|                 gap: 8px; | ||||
|             } | ||||
|  | ||||
|             .keyboard-hints { | ||||
|                 display: none; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         /* High contrast mode support */ | ||||
|         @media (prefers-contrast: high) { | ||||
|             .image-control-btn { | ||||
|                 border: 2px solid currentColor; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         /* Reduced motion support */ | ||||
|         @media (prefers-reduced-motion: reduce) { | ||||
|             .note-detail-image-view, | ||||
|             .image-control-btn { | ||||
|                 transition: none; | ||||
|             } | ||||
|         } | ||||
|     </style> | ||||
|  | ||||
|     <div class="note-detail-image-wrapper"> | ||||
|         <img class="note-detail-image-view" /> | ||||
|     </div> | ||||
|      | ||||
|     <div class="image-controls"> | ||||
|         <button class="image-control-btn zoom-in" type="button" aria-label="Zoom In" title="Zoom In (+ key)"> | ||||
|             <i class="bx bx-zoom-in" aria-hidden="true"></i> | ||||
|         </button> | ||||
|         <button class="image-control-btn zoom-out" type="button" aria-label="Zoom Out" title="Zoom Out (- key)"> | ||||
|             <i class="bx bx-zoom-out" aria-hidden="true"></i> | ||||
|         </button> | ||||
|         <button class="image-control-btn reset-zoom" type="button" aria-label="Reset Zoom" title="Reset Zoom (0 key or double-click)"> | ||||
|             <i class="bx bx-reset" aria-hidden="true"></i> | ||||
|         </button> | ||||
|         <button class="image-control-btn fullscreen" type="button" aria-label="Fullscreen" title="Fullscreen (Enter or Space key)"> | ||||
|             <i class="bx bx-fullscreen" aria-hidden="true"></i> | ||||
|         </button> | ||||
|         <button class="image-control-btn download" type="button" aria-label="Download" title="Download Image"> | ||||
|             <i class="bx bx-download" aria-hidden="true"></i> | ||||
|         </button> | ||||
|     </div> | ||||
|      | ||||
|     <div class="keyboard-hints" aria-hidden="true"> | ||||
|         <div class="hint"><span class="key">Click</span> Open lightbox</div> | ||||
|         <div class="hint"><span class="key">Double-click</span> Reset zoom</div> | ||||
|         <div class="hint"><span class="key">Scroll</span> Zoom</div> | ||||
|         <div class="hint"><span class="key">+/-</span> Zoom in/out</div> | ||||
|         <div class="hint"><span class="key">0</span> Reset zoom</div> | ||||
|         <div class="hint"><span class="key">ESC</span> Close lightbox</div> | ||||
|         <div class="hint"><span class="key">Arrow keys</span> Pan (when zoomed)</div> | ||||
|     </div> | ||||
| </div>`; | ||||
|  | ||||
| class ImageTypeWidget extends TypeWidget { | ||||
|  | ||||
|     private $imageWrapper!: JQuery<HTMLElement>; | ||||
|     private $imageView!: JQuery<HTMLElement>; | ||||
| class ImageTypeWidget extends ImageViewerBase { | ||||
|     private $zoomInBtn!: JQuery<HTMLElement>; | ||||
|     private $zoomOutBtn!: JQuery<HTMLElement>; | ||||
|     private $resetZoomBtn!: JQuery<HTMLElement>; | ||||
|     private $fullscreenBtn!: JQuery<HTMLElement>; | ||||
|     private $downloadBtn!: JQuery<HTMLElement>; | ||||
|     private wheelHandler?: (e: JQuery.TriggeredEvent) => void; | ||||
|  | ||||
|     static getType() { | ||||
|         return "image"; | ||||
|     } | ||||
|  | ||||
|     constructor() { | ||||
|         super(); | ||||
|         // Apply custom configuration if needed | ||||
|         this.applyConfig({ | ||||
|             minZoom: 0.5, | ||||
|             maxZoom: 5, | ||||
|             zoomStep: 0.25, | ||||
|             debounceDelay: 16, | ||||
|             touchTargetSize: 44 | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     doRender() { | ||||
|         this.$widget = $(TPL); | ||||
|         this.$imageWrapper = this.$widget.find(".note-detail-image-wrapper"); | ||||
|         this.$imageView = this.$widget.find(".note-detail-image-view").attr("id", `image-view-${utils.randomString(10)}`); | ||||
|         this.$imageView = this.$widget.find(".note-detail-image-view"); | ||||
|          | ||||
|         // Generate unique ID for image element | ||||
|         const imageId = `image-view-${utils.randomString(10)}`; | ||||
|         this.$imageView.attr("id", imageId); | ||||
|          | ||||
|         // Get control buttons | ||||
|         this.$zoomInBtn = this.$widget.find(".zoom-in"); | ||||
|         this.$zoomOutBtn = this.$widget.find(".zoom-out"); | ||||
|         this.$resetZoomBtn = this.$widget.find(".reset-zoom"); | ||||
|         this.$fullscreenBtn = this.$widget.find(".fullscreen"); | ||||
|         this.$downloadBtn = this.$widget.find(".download"); | ||||
|  | ||||
|         const initZoom = async () => { | ||||
|             const element = document.querySelector(`#${this.$imageView.attr("id")}`); | ||||
|             if (element) { | ||||
|                 WheelZoom.create(`#${this.$imageView.attr("id")}`, { | ||||
|                     maxScale: 50, | ||||
|                     speed: 1.3, | ||||
|                     zoomOnClick: false | ||||
|                 }); | ||||
|             } else { | ||||
|                 requestAnimationFrame(initZoom); | ||||
|             } | ||||
|         }; | ||||
|         initZoom(); | ||||
|  | ||||
|         imageContextMenuService.setupContextMenu(this.$imageView); | ||||
|         this.setupEventHandlers(); | ||||
|         this.setupPanFunctionality(); | ||||
|         this.setupKeyboardNavigation(); | ||||
|         this.setupDoubleClickReset(); | ||||
|         this.setupContextMenu(); | ||||
|         this.addAccessibilityLabels(); | ||||
|  | ||||
|         super.doRender(); | ||||
|     } | ||||
|  | ||||
|     private setupEventHandlers(): void { | ||||
|         // Image click to open lightbox | ||||
|         this.$imageView?.on("click", async (e) => { | ||||
|             e.preventDefault(); | ||||
|             await this.handleOpenLightbox(); | ||||
|         }); | ||||
|  | ||||
|         // Control button handlers | ||||
|         this.$zoomInBtn?.on("click", () => this.zoomIn()); | ||||
|         this.$zoomOutBtn?.on("click", () => this.zoomOut()); | ||||
|         this.$resetZoomBtn?.on("click", () => this.resetZoom()); | ||||
|         this.$fullscreenBtn?.on("click", async () => await this.handleOpenLightbox()); | ||||
|         this.$downloadBtn?.on("click", () => this.downloadImage()); | ||||
|  | ||||
|         // Mouse wheel zoom with debouncing | ||||
|         this.wheelHandler = (e: JQuery.TriggeredEvent) => { | ||||
|             // Only handle if widget has focus | ||||
|             if (!this.$widget?.is(':focus-within')) { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             e.preventDefault(); | ||||
|             const originalEvent = e.originalEvent as WheelEvent | undefined; | ||||
|             const delta = originalEvent?.deltaY; | ||||
|              | ||||
|             if (delta) { | ||||
|                 if (delta < 0) { | ||||
|                     this.zoomIn(); | ||||
|                 } else { | ||||
|                     this.zoomOut(); | ||||
|                 } | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|         this.$imageWrapper?.on("wheel", this.wheelHandler); | ||||
|     } | ||||
|  | ||||
|     private async handleOpenLightbox(): Promise<void> { | ||||
|         if (!this.$imageView?.length) return; | ||||
|          | ||||
|         const src = this.$imageView.attr('src') || this.$imageView.prop('src'); | ||||
|         if (!src) return; | ||||
|  | ||||
|         await this.openInLightbox( | ||||
|             src, | ||||
|             this.note?.title, | ||||
|             this.noteId, | ||||
|             this.$imageView.get(0) | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     async doRefresh(note: FNote) { | ||||
|         this.$imageView.prop("src", utils.createImageSrcUrl(note)); | ||||
|         const src = utils.createImageSrcUrl(note); | ||||
|          | ||||
|         // Reset zoom when image changes | ||||
|         this.resetZoom(); | ||||
|          | ||||
|         // Refresh gallery items when note changes | ||||
|         await this.refreshGalleryItems(); | ||||
|          | ||||
|         // Setup image with loading state and error handling | ||||
|         try { | ||||
|             await this.setupImage(src, this.$imageView!); | ||||
|         } catch (error) { | ||||
|             console.error("Failed to load image:", error); | ||||
|             // Error message is already shown by setupImage | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private downloadImage(): void { | ||||
|         if (!this.note) return; | ||||
|          | ||||
|         try { | ||||
|             const link = document.createElement('a'); | ||||
|             link.href = utils.createImageSrcUrl(this.note); | ||||
|             link.download = this.note.title || 'image'; | ||||
|              | ||||
|             // Add to document, click, and remove | ||||
|             document.body.appendChild(link); | ||||
|             link.click(); | ||||
|             document.body.removeChild(link); | ||||
|         } catch (error) { | ||||
|             console.error("Failed to download image:", error); | ||||
|             alert("Failed to download image. Please try again."); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     copyImageReferenceToClipboardEvent({ ntxId }: EventData<"copyImageReferenceToClipboard">) { | ||||
| @@ -82,14 +342,26 @@ class ImageTypeWidget extends TypeWidget { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         imageService.copyImageReferenceToClipboard(this.$imageWrapper); | ||||
|         if (this.$imageWrapper?.length) { | ||||
|             imageService.copyImageReferenceToClipboard(this.$imageWrapper); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) { | ||||
|         if (loadResults.isNoteReloaded(this.noteId)) { | ||||
|             this.refresh(); | ||||
|             await this.refresh(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     cleanup() { | ||||
|         // Remove wheel handler if it exists | ||||
|         if (this.wheelHandler && this.$imageWrapper?.length) { | ||||
|             this.$imageWrapper.off("wheel", this.wheelHandler); | ||||
|         } | ||||
|          | ||||
|         // Call parent cleanup | ||||
|         super.cleanup(); | ||||
|     } | ||||
| } | ||||
|  | ||||
| export default ImageTypeWidget; | ||||
| export default ImageTypeWidget; | ||||
							
								
								
									
										352
									
								
								apps/client/src/widgets/type_widgets/image_viewer_base.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										352
									
								
								apps/client/src/widgets/type_widgets/image_viewer_base.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,352 @@ | ||||
| import { ImageViewerBase } from './image_viewer_base.js'; | ||||
| import mediaViewer from '../../services/media_viewer.js'; | ||||
|  | ||||
| // Mock mediaViewer | ||||
| jest.mock('../../services/media_viewer.js', () => ({ | ||||
|     default: { | ||||
|         isOpen: jest.fn().mockReturnValue(false), | ||||
|         close: jest.fn(), | ||||
|         openSingle: jest.fn(), | ||||
|         getImageDimensions: jest.fn().mockResolvedValue({ width: 1920, height: 1080 }) | ||||
|     } | ||||
| })); | ||||
|  | ||||
| // Create a concrete test class | ||||
| class TestImageViewer extends ImageViewerBase { | ||||
|     static getType() { | ||||
|         return 'test'; | ||||
|     } | ||||
|  | ||||
|     doRender() { | ||||
|         this.$widget = $('<div class="test-widget" tabindex="0"></div>'); | ||||
|         this.$imageWrapper = $('<div class="image-wrapper"></div>'); | ||||
|         this.$imageView = $('<img class="image-view" />'); | ||||
|         this.$widget.append(this.$imageWrapper.append(this.$imageView)); | ||||
|         super.doRender(); | ||||
|     } | ||||
|  | ||||
|     async doRefresh() { | ||||
|         // Test implementation | ||||
|     } | ||||
| } | ||||
|  | ||||
| describe('ImageViewerBase', () => { | ||||
|     let widget: TestImageViewer; | ||||
|     let $container: JQuery<HTMLElement>; | ||||
|  | ||||
|     beforeEach(() => { | ||||
|         // Setup DOM container | ||||
|         $container = $('<div id="test-container"></div>'); | ||||
|         $('body').append($container); | ||||
|          | ||||
|         widget = new TestImageViewer(); | ||||
|         widget.doRender(); | ||||
|         $container.append(widget.$widget!); | ||||
|     }); | ||||
|  | ||||
|     afterEach(() => { | ||||
|         widget.cleanup(); | ||||
|         $container.remove(); | ||||
|         jest.clearAllMocks(); | ||||
|     }); | ||||
|  | ||||
|     describe('PhotoSwipe Verification', () => { | ||||
|         it('should verify PhotoSwipe availability on initialization', () => { | ||||
|             expect(widget['isPhotoSwipeAvailable']).toBe(true); | ||||
|         }); | ||||
|  | ||||
|         it('should handle PhotoSwipe not being available gracefully', () => { | ||||
|             const originalMediaViewer = mediaViewer; | ||||
|             // @ts-ignore - Temporarily set to undefined for testing | ||||
|             window.mediaViewer = undefined; | ||||
|              | ||||
|             const newWidget = new TestImageViewer(); | ||||
|             expect(newWidget['isPhotoSwipeAvailable']).toBe(false); | ||||
|              | ||||
|             // @ts-ignore - Restore | ||||
|             window.mediaViewer = originalMediaViewer; | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     describe('Configuration', () => { | ||||
|         it('should have default configuration values', () => { | ||||
|             expect(widget['config'].minZoom).toBe(0.5); | ||||
|             expect(widget['config'].maxZoom).toBe(5); | ||||
|             expect(widget['config'].zoomStep).toBe(0.25); | ||||
|             expect(widget['config'].debounceDelay).toBe(16); | ||||
|             expect(widget['config'].touchTargetSize).toBe(44); | ||||
|         }); | ||||
|  | ||||
|         it('should allow configuration overrides', () => { | ||||
|             widget['applyConfig']({ | ||||
|                 minZoom: 0.2, | ||||
|                 maxZoom: 10, | ||||
|                 zoomStep: 0.5 | ||||
|             }); | ||||
|              | ||||
|             expect(widget['config'].minZoom).toBe(0.2); | ||||
|             expect(widget['config'].maxZoom).toBe(10); | ||||
|             expect(widget['config'].zoomStep).toBe(0.5); | ||||
|             expect(widget['config'].debounceDelay).toBe(16); // Unchanged | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     describe('Loading States', () => { | ||||
|         it('should show loading indicator when loading image', () => { | ||||
|             widget['showLoadingIndicator'](); | ||||
|             expect(widget.$imageWrapper?.find('.image-loading-indicator').length).toBe(1); | ||||
|         }); | ||||
|  | ||||
|         it('should hide loading indicator after loading', () => { | ||||
|             widget['showLoadingIndicator'](); | ||||
|             widget['hideLoadingIndicator'](); | ||||
|             expect(widget.$imageWrapper?.find('.image-loading-indicator').length).toBe(0); | ||||
|         }); | ||||
|  | ||||
|         it('should handle image load errors gracefully', async () => { | ||||
|             const mockImage = { | ||||
|                 onload: null as any, | ||||
|                 onerror: null as any, | ||||
|                 src: '' | ||||
|             }; | ||||
|              | ||||
|             // @ts-ignore | ||||
|             global.Image = jest.fn(() => mockImage); | ||||
|              | ||||
|             const setupPromise = widget['setupImage']('test.jpg', widget.$imageView!); | ||||
|              | ||||
|             // Trigger error | ||||
|             mockImage.onerror(new Error('Failed to load')); | ||||
|              | ||||
|             await expect(setupPromise).rejects.toThrow('Failed to load image'); | ||||
|             expect(widget.$imageWrapper?.find('.alert-danger').length).toBe(1); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     describe('Zoom Functionality', () => { | ||||
|         it('should zoom in correctly', () => { | ||||
|             const initialZoom = widget['currentZoom']; | ||||
|             widget['zoomIn'](); | ||||
|              | ||||
|             // Wait for debounce | ||||
|             jest.advanceTimersByTime(20); | ||||
|              | ||||
|             expect(widget['currentZoom']).toBeGreaterThan(initialZoom); | ||||
|         }); | ||||
|  | ||||
|         it('should zoom out correctly', () => { | ||||
|             widget['currentZoom'] = 2; | ||||
|             widget['zoomOut'](); | ||||
|              | ||||
|             // Wait for debounce | ||||
|             jest.advanceTimersByTime(20); | ||||
|              | ||||
|             expect(widget['currentZoom']).toBeLessThan(2); | ||||
|         }); | ||||
|  | ||||
|         it('should respect zoom limits', () => { | ||||
|             // Test max zoom | ||||
|             widget['currentZoom'] = widget['config'].maxZoom; | ||||
|             widget['zoomIn'](); | ||||
|             jest.advanceTimersByTime(20); | ||||
|             expect(widget['currentZoom']).toBe(widget['config'].maxZoom); | ||||
|              | ||||
|             // Test min zoom | ||||
|             widget['currentZoom'] = widget['config'].minZoom; | ||||
|             widget['zoomOut'](); | ||||
|             jest.advanceTimersByTime(20); | ||||
|             expect(widget['currentZoom']).toBe(widget['config'].minZoom); | ||||
|         }); | ||||
|  | ||||
|         it('should reset zoom to 100%', () => { | ||||
|             widget['currentZoom'] = 3; | ||||
|             widget['resetZoom'](); | ||||
|             expect(widget['currentZoom']).toBe(1); | ||||
|         }); | ||||
|  | ||||
|         it('should show zoom indicator when zooming', () => { | ||||
|             widget['updateZoomIndicator'](); | ||||
|             expect(widget.$widget?.find('.zoom-indicator').length).toBe(1); | ||||
|             expect(widget.$widget?.find('.zoom-indicator').text()).toBe('100%'); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     describe('Keyboard Navigation', () => { | ||||
|         it('should only handle keyboard events when widget has focus', () => { | ||||
|             const preventDefaultSpy = jest.fn(); | ||||
|             const stopPropagationSpy = jest.fn(); | ||||
|              | ||||
|             // Simulate widget not having focus | ||||
|             widget.$widget?.blur(); | ||||
|              | ||||
|             const event = $.Event('keydown', { | ||||
|                 key: '+', | ||||
|                 preventDefault: preventDefaultSpy, | ||||
|                 stopPropagation: stopPropagationSpy | ||||
|             }); | ||||
|              | ||||
|             widget.$widget?.trigger(event); | ||||
|              | ||||
|             expect(preventDefaultSpy).not.toHaveBeenCalled(); | ||||
|             expect(stopPropagationSpy).not.toHaveBeenCalled(); | ||||
|         }); | ||||
|  | ||||
|         it('should handle zoom keyboard shortcuts when focused', () => { | ||||
|             // Focus the widget | ||||
|             widget.$widget?.focus(); | ||||
|             jest.spyOn(widget.$widget!, 'is').mockImplementation((selector) => { | ||||
|                 if (selector === ':focus-within') return true; | ||||
|                 return false; | ||||
|             }); | ||||
|              | ||||
|             const zoomInSpy = jest.spyOn(widget as any, 'zoomIn'); | ||||
|              | ||||
|             const event = $.Event('keydown', { key: '+' }); | ||||
|             widget.$widget?.trigger(event); | ||||
|              | ||||
|             expect(zoomInSpy).toHaveBeenCalled(); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     describe('Pan Functionality', () => { | ||||
|         it('should setup pan event handlers', () => { | ||||
|             widget['setupPanFunctionality'](); | ||||
|             expect(widget['boundHandlers'].size).toBeGreaterThan(0); | ||||
|         }); | ||||
|  | ||||
|         it('should only allow panning when zoomed in', () => { | ||||
|             widget['currentZoom'] = 1; // Not zoomed | ||||
|             const mouseDownEvent = $.Event('mousedown', { pageX: 100, pageY: 100 }); | ||||
|             widget.$imageWrapper?.trigger(mouseDownEvent); | ||||
|              | ||||
|             expect(widget['isDragging']).toBe(false); | ||||
|              | ||||
|             // Now zoom in and try again | ||||
|             widget['currentZoom'] = 2; | ||||
|             widget.$imageWrapper?.trigger(mouseDownEvent); | ||||
|              | ||||
|             expect(widget['isDragging']).toBe(true); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     describe('Accessibility', () => { | ||||
|         it('should add ARIA labels to buttons', () => { | ||||
|             const $button = $('<button class="zoom-in"></button>'); | ||||
|             widget.$widget?.append($button); | ||||
|              | ||||
|             widget['addAccessibilityLabels'](); | ||||
|              | ||||
|             expect($button.attr('aria-label')).toBe('Zoom in'); | ||||
|             expect($button.attr('role')).toBe('button'); | ||||
|         }); | ||||
|  | ||||
|         it('should make widget focusable with proper ARIA attributes', () => { | ||||
|             widget['setupKeyboardNavigation'](); | ||||
|              | ||||
|             expect(widget.$widget?.attr('tabindex')).toBe('0'); | ||||
|             expect(widget.$widget?.attr('role')).toBe('application'); | ||||
|             expect(widget.$widget?.attr('aria-label')).toBeTruthy(); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     describe('Lightbox Integration', () => { | ||||
|         it('should open lightbox when PhotoSwipe is available', () => { | ||||
|             const openSingleSpy = jest.spyOn(mediaViewer, 'openSingle'); | ||||
|              | ||||
|             widget['openInLightbox']('test.jpg', 'Test Image', 'note123'); | ||||
|              | ||||
|             expect(openSingleSpy).toHaveBeenCalledWith( | ||||
|                 expect.objectContaining({ | ||||
|                     src: 'test.jpg', | ||||
|                     alt: 'Test Image', | ||||
|                     title: 'Test Image', | ||||
|                     noteId: 'note123' | ||||
|                 }), | ||||
|                 expect.any(Object), | ||||
|                 expect.any(Object) | ||||
|             ); | ||||
|         }); | ||||
|  | ||||
|         it('should fallback to opening in new tab when PhotoSwipe is not available', () => { | ||||
|             widget['isPhotoSwipeAvailable'] = false; | ||||
|             const windowOpenSpy = jest.spyOn(window, 'open').mockImplementation(); | ||||
|              | ||||
|             widget['openInLightbox']('test.jpg', 'Test Image'); | ||||
|              | ||||
|             expect(windowOpenSpy).toHaveBeenCalledWith('test.jpg', '_blank'); | ||||
|             expect(mediaViewer.openSingle).not.toHaveBeenCalled(); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     describe('Memory Leak Prevention', () => { | ||||
|         it('should cleanup all event handlers on cleanup', () => { | ||||
|             widget['setupPanFunctionality'](); | ||||
|             widget['setupKeyboardNavigation'](); | ||||
|              | ||||
|             const initialHandlerCount = widget['boundHandlers'].size; | ||||
|             expect(initialHandlerCount).toBeGreaterThan(0); | ||||
|              | ||||
|             widget.cleanup(); | ||||
|              | ||||
|             expect(widget['boundHandlers'].size).toBe(0); | ||||
|         }); | ||||
|  | ||||
|         it('should cancel animation frames on cleanup', () => { | ||||
|             const cancelAnimationFrameSpy = jest.spyOn(window, 'cancelAnimationFrame'); | ||||
|             widget['rafId'] = 123; | ||||
|              | ||||
|             widget.cleanup(); | ||||
|              | ||||
|             expect(cancelAnimationFrameSpy).toHaveBeenCalledWith(123); | ||||
|             expect(widget['rafId']).toBeNull(); | ||||
|         }); | ||||
|  | ||||
|         it('should clear timers on cleanup', () => { | ||||
|             const clearTimeoutSpy = jest.spyOn(window, 'clearTimeout'); | ||||
|             widget['zoomDebounceTimer'] = 456; | ||||
|              | ||||
|             widget.cleanup(); | ||||
|              | ||||
|             expect(clearTimeoutSpy).toHaveBeenCalledWith(456); | ||||
|             expect(widget['zoomDebounceTimer']).toBeNull(); | ||||
|         }); | ||||
|  | ||||
|         it('should close lightbox if open on cleanup', () => { | ||||
|             jest.spyOn(mediaViewer, 'isOpen').mockReturnValue(true); | ||||
|             const closeSpy = jest.spyOn(mediaViewer, 'close'); | ||||
|              | ||||
|             widget.cleanup(); | ||||
|              | ||||
|             expect(closeSpy).toHaveBeenCalled(); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     describe('Double-click Reset', () => { | ||||
|         it('should reset zoom on double-click', () => { | ||||
|             widget['currentZoom'] = 3; | ||||
|             widget['setupDoubleClickReset'](); | ||||
|              | ||||
|             const dblClickEvent = $.Event('dblclick'); | ||||
|             widget.$imageView?.trigger(dblClickEvent); | ||||
|              | ||||
|             expect(widget['currentZoom']).toBe(1); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     describe('Error Handling', () => { | ||||
|         it('should show error message to user on failure', () => { | ||||
|             widget['showErrorMessage']('Test error message'); | ||||
|              | ||||
|             const $error = widget.$imageWrapper?.find('.alert-danger'); | ||||
|             expect($error?.length).toBe(1); | ||||
|             expect($error?.text()).toBe('Test error message'); | ||||
|         }); | ||||
|  | ||||
|         it('should handle null/undefined elements safely', () => { | ||||
|             widget.$imageView = undefined; | ||||
|              | ||||
|             // Should not throw | ||||
|             expect(() => widget['setupImage']('test.jpg', widget.$imageView!)).not.toThrow(); | ||||
|         }); | ||||
|     }); | ||||
| }); | ||||
							
								
								
									
										787
									
								
								apps/client/src/widgets/type_widgets/image_viewer_base.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										787
									
								
								apps/client/src/widgets/type_widgets/image_viewer_base.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,787 @@ | ||||
| /** | ||||
|  * Base class for widgets that display images with zoom, pan, and lightbox functionality. | ||||
|  * Provides shared image viewing logic to avoid code duplication. | ||||
|  */ | ||||
| import TypeWidget from "./type_widget.js"; | ||||
| import mediaViewer from "../../services/media_viewer.js"; | ||||
| import type { MediaItem, MediaViewerCallbacks } from "../../services/media_viewer.js"; | ||||
| import imageContextMenuService from "../../menus/image_context_menu.js"; | ||||
| import galleryManager from "../../services/gallery_manager.js"; | ||||
| import type { GalleryItem, GalleryConfig } from "../../services/gallery_manager.js"; | ||||
|  | ||||
| export interface ImageViewerConfig { | ||||
|     minZoom?: number; | ||||
|     maxZoom?: number; | ||||
|     zoomStep?: number; | ||||
|     debounceDelay?: number; | ||||
|     touchTargetSize?: number; | ||||
| } | ||||
|  | ||||
| export abstract class ImageViewerBase extends TypeWidget { | ||||
|     // Configuration | ||||
|     protected config: Required<ImageViewerConfig> = { | ||||
|         minZoom: 0.5, | ||||
|         maxZoom: 5, | ||||
|         zoomStep: 0.25, | ||||
|         debounceDelay: 16, // ~60fps | ||||
|         touchTargetSize: 44 // WCAG recommended minimum | ||||
|     }; | ||||
|  | ||||
|     // State | ||||
|     protected currentZoom: number = 1; | ||||
|     protected isDragging: boolean = false; | ||||
|     protected startX: number = 0; | ||||
|     protected startY: number = 0; | ||||
|     protected scrollLeft: number = 0; | ||||
|     protected scrollTop: number = 0; | ||||
|     protected isPhotoSwipeAvailable: boolean = false; | ||||
|     protected isLoadingImage: boolean = false; | ||||
|     protected galleryItems: GalleryItem[] = []; | ||||
|     protected currentImageIndex: number = 0; | ||||
|  | ||||
|     // Elements | ||||
|     protected $imageWrapper?: JQuery<HTMLElement>; | ||||
|     protected $imageView?: JQuery<HTMLElement>; | ||||
|     protected $zoomIndicator?: JQuery<HTMLElement>; | ||||
|     protected $loadingIndicator?: JQuery<HTMLElement>; | ||||
|  | ||||
|     // Event handler references for cleanup | ||||
|     private boundHandlers: Map<string, Function> = new Map(); | ||||
|     private rafId: number | null = null; | ||||
|     private zoomDebounceTimer: number | null = null; | ||||
|  | ||||
|     constructor() { | ||||
|         super(); | ||||
|         this.verifyPhotoSwipe(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Verify PhotoSwipe is available | ||||
|      */ | ||||
|     protected verifyPhotoSwipe(): void { | ||||
|         try { | ||||
|             // Check if PhotoSwipe is loaded | ||||
|             if (typeof mediaViewer !== 'undefined' && mediaViewer) { | ||||
|                 this.isPhotoSwipeAvailable = true; | ||||
|             } else { | ||||
|                 console.warn("PhotoSwipe/mediaViewer not available, lightbox features disabled"); | ||||
|                 this.isPhotoSwipeAvailable = false; | ||||
|             } | ||||
|         } catch (error) { | ||||
|             console.error("Error checking PhotoSwipe availability:", error); | ||||
|             this.isPhotoSwipeAvailable = false; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Apply configuration overrides | ||||
|      */ | ||||
|     protected applyConfig(overrides?: ImageViewerConfig): void { | ||||
|         if (overrides) { | ||||
|             this.config = { ...this.config, ...overrides }; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Show loading indicator | ||||
|      */ | ||||
|     protected showLoadingIndicator(): void { | ||||
|         if (!this.$loadingIndicator) { | ||||
|             this.$loadingIndicator = $('<div class="image-loading-indicator">') | ||||
|                 .html('<div class="spinner-border spinner-border-sm" role="status"><span class="sr-only">Loading...</span></div>') | ||||
|                 .css({ | ||||
|                     position: 'absolute', | ||||
|                     top: '50%', | ||||
|                     left: '50%', | ||||
|                     transform: 'translate(-50%, -50%)', | ||||
|                     zIndex: 100 | ||||
|                 }); | ||||
|         } | ||||
|         this.$imageWrapper?.append(this.$loadingIndicator); | ||||
|         this.isLoadingImage = true; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Hide loading indicator | ||||
|      */ | ||||
|     protected hideLoadingIndicator(): void { | ||||
|         this.$loadingIndicator?.remove(); | ||||
|         this.isLoadingImage = false; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Setup image with loading state and error handling | ||||
|      */ | ||||
|     protected async setupImage(src: string, $image: JQuery<HTMLElement>): Promise<void> { | ||||
|         if (!$image || !$image.length) { | ||||
|             console.error("Image element not provided"); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         this.showLoadingIndicator(); | ||||
|  | ||||
|         return new Promise((resolve, reject) => { | ||||
|             const img = new Image(); | ||||
|              | ||||
|             img.onload = () => { | ||||
|                 this.hideLoadingIndicator(); | ||||
|                 $image.attr('src', src); | ||||
|                  | ||||
|                 // Preload dimensions for PhotoSwipe if available | ||||
|                 if (this.isPhotoSwipeAvailable) { | ||||
|                     this.preloadImageDimensions(src).catch(console.warn); | ||||
|                 } | ||||
|                  | ||||
|                 resolve(); | ||||
|             }; | ||||
|              | ||||
|             img.onerror = (error) => { | ||||
|                 this.hideLoadingIndicator(); | ||||
|                 console.error("Failed to load image:", error); | ||||
|                 this.showErrorMessage("Failed to load image"); | ||||
|                 reject(new Error("Failed to load image")); | ||||
|             }; | ||||
|              | ||||
|             img.src = src; | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Show error message to user | ||||
|      */ | ||||
|     protected showErrorMessage(message: string): void { | ||||
|         const $error = $('<div class="alert alert-danger">') | ||||
|             .text(message) | ||||
|             .css({ | ||||
|                 position: 'absolute', | ||||
|                 top: '50%', | ||||
|                 left: '50%', | ||||
|                 transform: 'translate(-50%, -50%)', | ||||
|                 maxWidth: '80%' | ||||
|             }); | ||||
|          | ||||
|         this.$imageWrapper?.empty().append($error); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Preload image dimensions for PhotoSwipe | ||||
|      */ | ||||
|     protected async preloadImageDimensions(src: string): Promise<void> { | ||||
|         if (!this.isPhotoSwipeAvailable) return; | ||||
|          | ||||
|         try { | ||||
|             await mediaViewer.getImageDimensions(src); | ||||
|         } catch (error) { | ||||
|             console.warn("Failed to preload image dimensions:", error); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Detect and collect gallery items from the current context | ||||
|      */ | ||||
|     protected async detectGalleryItems(): Promise<GalleryItem[]> { | ||||
|         // Default implementation - can be overridden by subclasses | ||||
|         if (this.note && this.note.type === 'text') { | ||||
|             // For text notes, scan for all images | ||||
|             return await galleryManager.createGalleryFromNote(this.note); | ||||
|         } | ||||
|          | ||||
|         // For single image notes, return just the current image | ||||
|         const src = this.$imageView?.attr('src') || this.$imageView?.prop('src'); | ||||
|         if (src) { | ||||
|             return [{ | ||||
|                 src: src, | ||||
|                 alt: this.note?.title || 'Image', | ||||
|                 title: this.note?.title, | ||||
|                 noteId: this.noteId, | ||||
|                 index: 0 | ||||
|             }]; | ||||
|         } | ||||
|          | ||||
|         return []; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Open image in lightbox with gallery support | ||||
|      */ | ||||
|     protected async openInLightbox(src: string, title?: string, noteId?: string, element?: HTMLElement): Promise<void> { | ||||
|         if (!this.isPhotoSwipeAvailable) { | ||||
|             console.warn("PhotoSwipe not available, cannot open lightbox"); | ||||
|             // Fallback: open image in new tab | ||||
|             window.open(src, '_blank'); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         if (!src) { | ||||
|             console.error("No image source provided for lightbox"); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         try { | ||||
|             // Detect if we should open as a gallery | ||||
|             if (this.galleryItems.length === 0) { | ||||
|                 this.galleryItems = await this.detectGalleryItems(); | ||||
|             } | ||||
|              | ||||
|             // Find the index of the current image in the gallery | ||||
|             let startIndex = 0; | ||||
|             if (this.galleryItems.length > 1) { | ||||
|                 startIndex = this.galleryItems.findIndex(item => item.src === src); | ||||
|                 if (startIndex === -1) startIndex = 0; | ||||
|             } | ||||
|              | ||||
|             // Open as gallery if multiple items, otherwise single image | ||||
|             if (this.galleryItems.length > 1) { | ||||
|                 // Open gallery with all images | ||||
|                 const galleryConfig: GalleryConfig = { | ||||
|                     showThumbnails: true, | ||||
|                     thumbnailHeight: 80, | ||||
|                     autoPlay: false, | ||||
|                     slideInterval: 4000, | ||||
|                     showCounter: true, | ||||
|                     enableKeyboardNav: true, | ||||
|                     enableSwipeGestures: true, | ||||
|                     preloadCount: 2, | ||||
|                     loop: true | ||||
|                 }; | ||||
|                  | ||||
|                 const callbacks: MediaViewerCallbacks = { | ||||
|                     onOpen: () => { | ||||
|                         console.log("Gallery opened with", this.galleryItems.length, "images"); | ||||
|                     }, | ||||
|                     onClose: () => { | ||||
|                         console.log("Gallery closed"); | ||||
|                         // Restore focus to the image element | ||||
|                         element?.focus(); | ||||
|                     }, | ||||
|                     onChange: (index) => { | ||||
|                         console.log("Gallery slide changed to:", index); | ||||
|                         this.currentImageIndex = index; | ||||
|                     }, | ||||
|                     onImageLoad: (index, mediaItem) => { | ||||
|                         console.log("Gallery image loaded:", mediaItem.title); | ||||
|                     }, | ||||
|                     onImageError: (index, mediaItem, error) => { | ||||
|                         console.error("Failed to load gallery image:", error); | ||||
|                     } | ||||
|                 }; | ||||
|                  | ||||
|                 galleryManager.openGallery(this.galleryItems, startIndex, galleryConfig, callbacks); | ||||
|             } else { | ||||
|                 // Open single image | ||||
|                 const item: MediaItem = { | ||||
|                     src: src, | ||||
|                     alt: title || "Image", | ||||
|                     title: title, | ||||
|                     noteId: noteId, | ||||
|                     element: element | ||||
|                 }; | ||||
|  | ||||
|             const callbacks: MediaViewerCallbacks = { | ||||
|                 onOpen: () => { | ||||
|                     console.log("Image lightbox opened"); | ||||
|                 }, | ||||
|                 onClose: () => { | ||||
|                     console.log("Image lightbox closed"); | ||||
|                     // Restore focus to the image element | ||||
|                     element?.focus(); | ||||
|                 }, | ||||
|                 onImageLoad: (index, mediaItem) => { | ||||
|                     console.log("Image loaded in lightbox:", mediaItem.title); | ||||
|                 }, | ||||
|                 onImageError: (index, mediaItem, error) => { | ||||
|                     console.error("Failed to load image in lightbox:", error); | ||||
|                 } | ||||
|             }; | ||||
|  | ||||
|                 // Open with enhanced configuration | ||||
|                 mediaViewer.openSingle(item, { | ||||
|                 bgOpacity: 0.95, | ||||
|                 showHideOpacity: true, | ||||
|                 pinchToClose: true, | ||||
|                 closeOnScroll: false, | ||||
|                 closeOnVerticalDrag: true, | ||||
|                 wheelToZoom: true, | ||||
|                 arrowKeys: false, | ||||
|                 loop: false, | ||||
|                 maxSpreadZoom: 10, | ||||
|                 getThumbBoundsFn: (index: number) => { | ||||
|                     // Get position of thumbnail for zoom animation | ||||
|                     if (element) { | ||||
|                         const rect = element.getBoundingClientRect(); | ||||
|                         return { | ||||
|                             x: rect.left, | ||||
|                             y: rect.top, | ||||
|                             w: rect.width | ||||
|                         }; | ||||
|                     } | ||||
|                     return undefined; | ||||
|                 } | ||||
|                 }, callbacks); | ||||
|             } | ||||
|         } catch (error) { | ||||
|             console.error("Failed to open lightbox:", error); | ||||
|             // Fallback: open image in new tab | ||||
|             window.open(src, '_blank'); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Zoom in with debouncing | ||||
|      */ | ||||
|     protected zoomIn(): void { | ||||
|         if (this.zoomDebounceTimer) { | ||||
|             clearTimeout(this.zoomDebounceTimer); | ||||
|         } | ||||
|          | ||||
|         this.zoomDebounceTimer = window.setTimeout(() => { | ||||
|             this.currentZoom = Math.min(this.currentZoom + this.config.zoomStep, this.config.maxZoom); | ||||
|             this.applyZoom(); | ||||
|         }, this.config.debounceDelay); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Zoom out with debouncing | ||||
|      */ | ||||
|     protected zoomOut(): void { | ||||
|         if (this.zoomDebounceTimer) { | ||||
|             clearTimeout(this.zoomDebounceTimer); | ||||
|         } | ||||
|          | ||||
|         this.zoomDebounceTimer = window.setTimeout(() => { | ||||
|             this.currentZoom = Math.max(this.currentZoom - this.config.zoomStep, this.config.minZoom); | ||||
|             this.applyZoom(); | ||||
|         }, this.config.debounceDelay); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Reset zoom to 100% | ||||
|      */ | ||||
|     protected resetZoom(): void { | ||||
|         this.currentZoom = 1; | ||||
|         this.applyZoom(); | ||||
|          | ||||
|         if (this.$imageWrapper?.length) { | ||||
|             this.$imageWrapper.scrollLeft(0).scrollTop(0); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Apply zoom with requestAnimationFrame for smooth performance | ||||
|      */ | ||||
|     protected applyZoom(): void { | ||||
|         if (this.rafId) { | ||||
|             cancelAnimationFrame(this.rafId); | ||||
|         } | ||||
|          | ||||
|         this.rafId = requestAnimationFrame(() => { | ||||
|             if (!this.$imageView?.length) return; | ||||
|              | ||||
|             this.$imageView.css({ | ||||
|                 transform: `scale(${this.currentZoom})`, | ||||
|                 transformOrigin: 'center center' | ||||
|             }); | ||||
|              | ||||
|             // Update zoom indicator | ||||
|             this.updateZoomIndicator(); | ||||
|              | ||||
|             // Update button states | ||||
|             this.updateZoomButtonStates(); | ||||
|              | ||||
|             // Update cursor based on zoom level | ||||
|             if (this.currentZoom > 1) { | ||||
|                 this.$imageView.css('cursor', 'move'); | ||||
|             } else { | ||||
|                 this.$imageView.css('cursor', 'zoom-in'); | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Update zoom percentage indicator | ||||
|      */ | ||||
|     protected updateZoomIndicator(): void { | ||||
|         const percentage = Math.round(this.currentZoom * 100); | ||||
|          | ||||
|         if (!this.$zoomIndicator) { | ||||
|             this.$zoomIndicator = $('<div class="zoom-indicator">') | ||||
|                 .css({ | ||||
|                     position: 'absolute', | ||||
|                     bottom: '60px', | ||||
|                     right: '20px', | ||||
|                     background: 'rgba(0, 0, 0, 0.7)', | ||||
|                     color: 'white', | ||||
|                     padding: '4px 8px', | ||||
|                     borderRadius: '4px', | ||||
|                     fontSize: '12px', | ||||
|                     zIndex: 10 | ||||
|                 }) | ||||
|                 .attr('aria-live', 'polite') | ||||
|                 .attr('aria-label', 'Zoom level'); | ||||
|              | ||||
|             this.$widget?.append(this.$zoomIndicator); | ||||
|         } | ||||
|          | ||||
|         this.$zoomIndicator.text(`${percentage}%`); | ||||
|          | ||||
|         // Hide indicator after 2 seconds | ||||
|         if (this.$zoomIndicator.data('hideTimer')) { | ||||
|             clearTimeout(this.$zoomIndicator.data('hideTimer')); | ||||
|         } | ||||
|          | ||||
|         this.$zoomIndicator.show(); | ||||
|         const hideTimer = setTimeout(() => { | ||||
|             this.$zoomIndicator?.fadeOut(); | ||||
|         }, 2000); | ||||
|          | ||||
|         this.$zoomIndicator.data('hideTimer', hideTimer); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Update zoom button states | ||||
|      */ | ||||
|     protected updateZoomButtonStates(): void { | ||||
|         const $zoomInBtn = this.$widget?.find('.zoom-in, .image-control-btn.zoom-in'); | ||||
|         const $zoomOutBtn = this.$widget?.find('.zoom-out, .image-control-btn.zoom-out'); | ||||
|          | ||||
|         if ($zoomInBtn?.length) { | ||||
|             $zoomInBtn.prop('disabled', this.currentZoom >= this.config.maxZoom); | ||||
|             $zoomInBtn.attr('aria-disabled', (this.currentZoom >= this.config.maxZoom).toString()); | ||||
|         } | ||||
|          | ||||
|         if ($zoomOutBtn?.length) { | ||||
|             $zoomOutBtn.prop('disabled', this.currentZoom <= this.config.minZoom); | ||||
|             $zoomOutBtn.attr('aria-disabled', (this.currentZoom <= this.config.minZoom).toString()); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Setup pan functionality with proper event cleanup | ||||
|      */ | ||||
|     protected setupPanFunctionality(): void { | ||||
|         if (!this.$imageWrapper?.length) return; | ||||
|  | ||||
|         // Create bound handlers for cleanup | ||||
|         const handleMouseDown = this.handleMouseDown.bind(this); | ||||
|         const handleMouseMove = this.handleMouseMove.bind(this); | ||||
|         const handleMouseUp = this.handleMouseUp.bind(this); | ||||
|         const handleTouchStart = this.handleTouchStart.bind(this); | ||||
|         const handleTouchMove = this.handleTouchMove.bind(this); | ||||
|         const handlePinchZoom = this.handlePinchZoom.bind(this); | ||||
|  | ||||
|         // Store references for cleanup | ||||
|         this.boundHandlers.set('mousedown', handleMouseDown); | ||||
|         this.boundHandlers.set('mousemove', handleMouseMove); | ||||
|         this.boundHandlers.set('mouseup', handleMouseUp); | ||||
|         this.boundHandlers.set('touchstart', handleTouchStart); | ||||
|         this.boundHandlers.set('touchmove', handleTouchMove); | ||||
|         this.boundHandlers.set('pinchzoom', handlePinchZoom); | ||||
|  | ||||
|         // Mouse events | ||||
|         this.$imageWrapper.on('mousedown', handleMouseDown); | ||||
|          | ||||
|         // Document-level mouse events (for dragging outside wrapper) | ||||
|         $(document).on('mousemove', handleMouseMove); | ||||
|         $(document).on('mouseup', handleMouseUp); | ||||
|  | ||||
|         // Touch events | ||||
|         this.$imageWrapper.on('touchstart', handleTouchStart); | ||||
|         this.$imageWrapper.on('touchmove', handleTouchMove); | ||||
|          | ||||
|         // Pinch zoom | ||||
|         this.$imageWrapper.on('touchstart', handlePinchZoom); | ||||
|         this.$imageWrapper.on('touchmove', handlePinchZoom); | ||||
|     } | ||||
|  | ||||
|     private handleMouseDown(e: JQuery.MouseDownEvent): void { | ||||
|         if (this.currentZoom <= 1 || !this.$imageWrapper) return; | ||||
|          | ||||
|         this.isDragging = true; | ||||
|          | ||||
|         const offset = this.$imageWrapper.offset(); | ||||
|         if (offset) { | ||||
|             this.startX = e.pageX - offset.left; | ||||
|             this.startY = e.pageY - offset.top; | ||||
|         } | ||||
|          | ||||
|         this.scrollLeft = this.$imageWrapper.scrollLeft() ?? 0; | ||||
|         this.scrollTop = this.$imageWrapper.scrollTop() ?? 0; | ||||
|          | ||||
|         this.$imageWrapper.css('cursor', 'grabbing'); | ||||
|         e.preventDefault(); | ||||
|     } | ||||
|  | ||||
|     private handleMouseMove(e: JQuery.MouseMoveEvent): void { | ||||
|         if (!this.isDragging || !this.$imageWrapper) return; | ||||
|          | ||||
|         e.preventDefault(); | ||||
|          | ||||
|         const offset = this.$imageWrapper.offset(); | ||||
|         if (offset) { | ||||
|             const x = e.pageX - offset.left; | ||||
|             const y = e.pageY - offset.top; | ||||
|             const walkX = (x - this.startX) * 2; | ||||
|             const walkY = (y - this.startY) * 2; | ||||
|              | ||||
|             this.$imageWrapper.scrollLeft(this.scrollLeft - walkX); | ||||
|             this.$imageWrapper.scrollTop(this.scrollTop - walkY); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private handleMouseUp(): void { | ||||
|         if (this.isDragging) { | ||||
|             this.isDragging = false; | ||||
|             if (this.currentZoom > 1 && this.$imageWrapper) { | ||||
|                 this.$imageWrapper.css('cursor', 'move'); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private handleTouchStart(e: JQuery.TouchStartEvent): void { | ||||
|         if (this.currentZoom <= 1 || !this.$imageWrapper) return; | ||||
|          | ||||
|         const touch = e.originalEvent?.touches[0]; | ||||
|         if (touch) { | ||||
|             this.startX = touch.clientX; | ||||
|             this.startY = touch.clientY; | ||||
|             this.scrollLeft = this.$imageWrapper.scrollLeft() ?? 0; | ||||
|             this.scrollTop = this.$imageWrapper.scrollTop() ?? 0; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private handleTouchMove(e: JQuery.TouchMoveEvent): void { | ||||
|         if (this.currentZoom <= 1 || !this.$imageWrapper) return; | ||||
|          | ||||
|         const touches = e.originalEvent?.touches; | ||||
|         if (touches && touches.length === 1) { | ||||
|             e.preventDefault(); | ||||
|             const touch = touches[0]; | ||||
|             const deltaX = this.startX - touch.clientX; | ||||
|             const deltaY = this.startY - touch.clientY; | ||||
|              | ||||
|             this.$imageWrapper.scrollLeft(this.scrollLeft + deltaX); | ||||
|             this.$imageWrapper.scrollTop(this.scrollTop + deltaY); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private initialDistance: number = 0; | ||||
|     private initialZoom: number = 1; | ||||
|  | ||||
|     private handlePinchZoom(e: JQuery.TriggeredEvent): void { | ||||
|         const touches = e.originalEvent?.touches; | ||||
|         if (!touches || touches.length !== 2) return; | ||||
|  | ||||
|         if (e.type === 'touchstart') { | ||||
|             this.initialDistance = Math.hypot( | ||||
|                 touches[0].clientX - touches[1].clientX, | ||||
|                 touches[0].clientY - touches[1].clientY | ||||
|             ); | ||||
|             this.initialZoom = this.currentZoom; | ||||
|         } else if (e.type === 'touchmove') { | ||||
|             e.preventDefault(); | ||||
|              | ||||
|             const distance = Math.hypot( | ||||
|                 touches[0].clientX - touches[1].clientX, | ||||
|                 touches[0].clientY - touches[1].clientY | ||||
|             ); | ||||
|              | ||||
|             const scale = distance / this.initialDistance; | ||||
|             this.currentZoom = Math.min(Math.max(this.initialZoom * scale, this.config.minZoom), this.config.maxZoom); | ||||
|             this.applyZoom(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Setup keyboard navigation with focus check | ||||
|      */ | ||||
|     protected setupKeyboardNavigation(): void { | ||||
|         if (!this.$widget?.length) return; | ||||
|  | ||||
|         // Make widget focusable | ||||
|         this.$widget.attr('tabindex', '0'); | ||||
|         this.$widget.attr('role', 'application'); | ||||
|         this.$widget.attr('aria-label', 'Image viewer with zoom controls'); | ||||
|          | ||||
|         const handleKeyDown = (e: JQuery.KeyDownEvent) => { | ||||
|             // Only handle keyboard events when widget has focus | ||||
|             if (!this.$widget?.is(':focus-within')) { | ||||
|                 return; | ||||
|             } | ||||
|              | ||||
|             switch(e.key) { | ||||
|                 case '+': | ||||
|                 case '=': | ||||
|                     e.preventDefault(); | ||||
|                     e.stopPropagation(); | ||||
|                     this.zoomIn(); | ||||
|                     break; | ||||
|                 case '-': | ||||
|                 case '_': | ||||
|                     e.preventDefault(); | ||||
|                     e.stopPropagation(); | ||||
|                     this.zoomOut(); | ||||
|                     break; | ||||
|                 case '0': | ||||
|                     e.preventDefault(); | ||||
|                     e.stopPropagation(); | ||||
|                     this.resetZoom(); | ||||
|                     break; | ||||
|                 case 'Enter': | ||||
|                 case ' ': | ||||
|                     if (this.isPhotoSwipeAvailable && this.$imageView?.length) { | ||||
|                         e.preventDefault(); | ||||
|                         e.stopPropagation(); | ||||
|                         const src = this.$imageView.attr('src') || this.$imageView.prop('src'); | ||||
|                         if (src) { | ||||
|                             this.openInLightbox(src, this.note?.title, this.noteId, this.$imageView.get(0)); | ||||
|                         } | ||||
|                     } | ||||
|                     break; | ||||
|                 case 'Escape': | ||||
|                     if (this.isPhotoSwipeAvailable && mediaViewer.isOpen()) { | ||||
|                         e.preventDefault(); | ||||
|                         e.stopPropagation(); | ||||
|                         mediaViewer.close(); | ||||
|                     } | ||||
|                     break; | ||||
|                 case 'ArrowLeft': | ||||
|                     if (this.currentZoom > 1 && this.$imageWrapper) { | ||||
|                         e.preventDefault(); | ||||
|                         e.stopPropagation(); | ||||
|                         this.$imageWrapper.scrollLeft((this.$imageWrapper.scrollLeft() ?? 0) - 50); | ||||
|                     } | ||||
|                     break; | ||||
|                 case 'ArrowRight': | ||||
|                     if (this.currentZoom > 1 && this.$imageWrapper) { | ||||
|                         e.preventDefault(); | ||||
|                         e.stopPropagation(); | ||||
|                         this.$imageWrapper.scrollLeft((this.$imageWrapper.scrollLeft() ?? 0) + 50); | ||||
|                     } | ||||
|                     break; | ||||
|                 case 'ArrowUp': | ||||
|                     if (this.currentZoom > 1 && this.$imageWrapper) { | ||||
|                         e.preventDefault(); | ||||
|                         e.stopPropagation(); | ||||
|                         this.$imageWrapper.scrollTop((this.$imageWrapper.scrollTop() ?? 0) - 50); | ||||
|                     } | ||||
|                     break; | ||||
|                 case 'ArrowDown': | ||||
|                     if (this.currentZoom > 1 && this.$imageWrapper) { | ||||
|                         e.preventDefault(); | ||||
|                         e.stopPropagation(); | ||||
|                         this.$imageWrapper.scrollTop((this.$imageWrapper.scrollTop() ?? 0) + 50); | ||||
|                     } | ||||
|                     break; | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|         this.boundHandlers.set('keydown', handleKeyDown); | ||||
|         this.$widget.on('keydown', handleKeyDown); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Refresh gallery items when content changes | ||||
|      */ | ||||
|     protected async refreshGalleryItems(): Promise<void> { | ||||
|         this.galleryItems = await this.detectGalleryItems(); | ||||
|         this.currentImageIndex = 0; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Setup double-click to reset zoom | ||||
|      */ | ||||
|     protected setupDoubleClickReset(): void { | ||||
|         if (!this.$imageView?.length) return; | ||||
|  | ||||
|         this.$imageView.on('dblclick', (e) => { | ||||
|             e.preventDefault(); | ||||
|             this.resetZoom(); | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Setup context menu for image | ||||
|      */ | ||||
|     protected setupContextMenu(): void { | ||||
|         if (this.$imageView?.length) { | ||||
|             imageContextMenuService.setupContextMenu(this.$imageView); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Add ARIA labels for accessibility | ||||
|      */ | ||||
|     protected addAccessibilityLabels(): void { | ||||
|         // Add ARIA labels to control buttons | ||||
|         this.$widget?.find('.zoom-in, .image-control-btn.zoom-in') | ||||
|             .attr('aria-label', 'Zoom in') | ||||
|             .attr('role', 'button'); | ||||
|          | ||||
|         this.$widget?.find('.zoom-out, .image-control-btn.zoom-out') | ||||
|             .attr('aria-label', 'Zoom out') | ||||
|             .attr('role', 'button'); | ||||
|          | ||||
|         this.$widget?.find('.fullscreen, .image-control-btn.fullscreen') | ||||
|             .attr('aria-label', 'Open in fullscreen lightbox') | ||||
|             .attr('role', 'button'); | ||||
|          | ||||
|         this.$widget?.find('.download, .image-control-btn.download') | ||||
|             .attr('aria-label', 'Download image') | ||||
|             .attr('role', 'button'); | ||||
|          | ||||
|         // Add alt text to image | ||||
|         if (this.$imageView?.length && this.note?.title) { | ||||
|             this.$imageView.attr('alt', this.note.title); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Cleanup all event handlers and resources | ||||
|      */ | ||||
|     cleanup() { | ||||
|         // Close gallery or lightbox if open | ||||
|         if (this.isPhotoSwipeAvailable) { | ||||
|             if (galleryManager.isGalleryOpen()) { | ||||
|                 galleryManager.closeGallery(); | ||||
|             } else if (mediaViewer.isOpen()) { | ||||
|                 mediaViewer.close(); | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         // Clear gallery items | ||||
|         this.galleryItems = []; | ||||
|         this.currentImageIndex = 0; | ||||
|          | ||||
|         // Remove document-level event listeners | ||||
|         if (this.boundHandlers.has('mousemove')) { | ||||
|             $(document).off('mousemove', this.boundHandlers.get('mousemove') as any); | ||||
|         } | ||||
|         if (this.boundHandlers.has('mouseup')) { | ||||
|             $(document).off('mouseup', this.boundHandlers.get('mouseup') as any); | ||||
|         } | ||||
|          | ||||
|         // Clear all bound handlers | ||||
|         this.boundHandlers.clear(); | ||||
|          | ||||
|         // Cancel any pending animations | ||||
|         if (this.rafId) { | ||||
|             cancelAnimationFrame(this.rafId); | ||||
|             this.rafId = null; | ||||
|         } | ||||
|          | ||||
|         // Clear zoom debounce timer | ||||
|         if (this.zoomDebounceTimer) { | ||||
|             clearTimeout(this.zoomDebounceTimer); | ||||
|             this.zoomDebounceTimer = null; | ||||
|         } | ||||
|          | ||||
|         // Clear zoom indicator timer | ||||
|         if (this.$zoomIndicator?.data('hideTimer')) { | ||||
|             clearTimeout(this.$zoomIndicator.data('hideTimer')); | ||||
|         } | ||||
|          | ||||
|         super.cleanup(); | ||||
|     } | ||||
| } | ||||
|  | ||||
| export default ImageViewerBase; | ||||
| @@ -6,6 +6,7 @@ import { getLocaleById } from "../../services/i18n.js"; | ||||
| import appContext from "../../components/app_context.js"; | ||||
| import { getMermaidConfig } from "../../services/mermaid.js"; | ||||
| import { renderMathInElement } from "../../services/math.js"; | ||||
| import ckeditorPhotoSwipe from "../../services/ckeditor_photoswipe_integration.js"; | ||||
|  | ||||
| const TPL = /*html*/` | ||||
| <div class="note-detail-readonly-text note-detail-printable" tabindex="100"> | ||||
| @@ -93,7 +94,19 @@ export default class ReadOnlyTextTypeWidget extends AbstractTextTypeWidget { | ||||
|     } | ||||
|  | ||||
|     cleanup() { | ||||
|         this.$content.html(""); | ||||
|         // Cleanup PhotoSwipe integration | ||||
|         if (this.$content?.[0]) { | ||||
|             ckeditorPhotoSwipe.cleanupContainer(this.$content[0]); | ||||
|         } | ||||
|          | ||||
|         // Remove all event handlers from content | ||||
|         if (this.$content) { | ||||
|             this.$content.off(); | ||||
|             this.$content.find('*').off(); | ||||
|             this.$content.html(""); | ||||
|         } | ||||
|          | ||||
|         super.cleanup(); | ||||
|     } | ||||
|  | ||||
|     async doRefresh(note: FNote) { | ||||
| @@ -107,6 +120,18 @@ export default class ReadOnlyTextTypeWidget extends AbstractTextTypeWidget { | ||||
|         const blob = await note.getBlob(); | ||||
|  | ||||
|         this.$content.html(blob?.content ?? ""); | ||||
|          | ||||
|         // Setup PhotoSwipe integration for images in read-only content | ||||
|         setTimeout(() => { | ||||
|             if (this.$content[0]) { | ||||
|                 ckeditorPhotoSwipe.setupContainer(this.$content[0], { | ||||
|                     enableGalleryMode: true, | ||||
|                     showHints: true, | ||||
|                     hintDelay: 2000, | ||||
|                     excludeSelector: '.no-lightbox' | ||||
|                 }); | ||||
|             } | ||||
|         }, 100); | ||||
|  | ||||
|         this.$content.find("a.reference-link").each((_, el) => { | ||||
|             this.loadReferenceLinkTitle($(el)); | ||||
|   | ||||
							
								
								
									
										55
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										55
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							| @@ -282,6 +282,9 @@ importers: | ||||
|       panzoom: | ||||
|         specifier: 9.4.3 | ||||
|         version: 9.4.3 | ||||
|       photoswipe: | ||||
|         specifier: ^5.4.4 | ||||
|         version: 5.4.4 | ||||
|       preact: | ||||
|         specifier: 10.27.0 | ||||
|         version: 10.27.0 | ||||
| @@ -11730,6 +11733,10 @@ packages: | ||||
|   perfect-freehand@1.2.0: | ||||
|     resolution: {integrity: sha512-h/0ikF1M3phW7CwpZ5MMvKnfpHficWoOEyr//KVNTxV4F6deRK1eYMtHyBKEAKFK0aXIEUK9oBvlF6PNXMDsAw==} | ||||
| 
 | ||||
|   photoswipe@5.4.4: | ||||
|     resolution: {integrity: sha512-WNFHoKrkZNnvFFhbHL93WDkW3ifwVOXSW3w1UuZZelSmgXpIGiZSNlZJq37rR8YejqME2rHs9EhH9ZvlvFH2NA==} | ||||
|     engines: {node: '>= 0.12.0'} | ||||
| 
 | ||||
|   pica@7.1.1: | ||||
|     resolution: {integrity: sha512-WY73tMvNzXWEld2LicT9Y260L43isrZ85tPuqRyvtkljSDLmnNFQmZICt4xUJMVulmcc6L9O7jbBrtx3DOz/YQ==} | ||||
| 
 | ||||
| @@ -16850,6 +16857,8 @@ snapshots: | ||||
|       '@ckeditor/ckeditor5-utils': 46.0.1 | ||||
|       '@ckeditor/ckeditor5-widget': 46.0.1 | ||||
|       es-toolkit: 1.39.5 | ||||
|     transitivePeerDependencies: | ||||
|       - supports-color | ||||
| 
 | ||||
|   '@ckeditor/ckeditor5-cloud-services@46.0.1': | ||||
|     dependencies: | ||||
| @@ -16869,6 +16878,8 @@ snapshots: | ||||
|       '@ckeditor/ckeditor5-ui': 46.0.1 | ||||
|       '@ckeditor/ckeditor5-utils': 46.0.1 | ||||
|       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) | ||||
|     transitivePeerDependencies: | ||||
|       - supports-color | ||||
| 
 | ||||
|   '@ckeditor/ckeditor5-collaboration-core@46.0.1': | ||||
|     dependencies: | ||||
| @@ -17138,8 +17149,6 @@ snapshots: | ||||
|       '@ckeditor/ckeditor5-table': 46.0.1 | ||||
|       '@ckeditor/ckeditor5-utils': 46.0.1 | ||||
|       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) | ||||
|     transitivePeerDependencies: | ||||
|       - supports-color | ||||
| 
 | ||||
|   '@ckeditor/ckeditor5-emoji@46.0.1': | ||||
|     dependencies: | ||||
| @@ -17196,8 +17205,6 @@ snapshots: | ||||
|       '@ckeditor/ckeditor5-ui': 46.0.1 | ||||
|       '@ckeditor/ckeditor5-utils': 46.0.1 | ||||
|       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) | ||||
|     transitivePeerDependencies: | ||||
|       - supports-color | ||||
| 
 | ||||
|   '@ckeditor/ckeditor5-export-word@46.0.1': | ||||
|     dependencies: | ||||
| @@ -17222,6 +17229,8 @@ snapshots: | ||||
|       '@ckeditor/ckeditor5-utils': 46.0.1 | ||||
|       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) | ||||
|       es-toolkit: 1.39.5 | ||||
|     transitivePeerDependencies: | ||||
|       - supports-color | ||||
| 
 | ||||
|   '@ckeditor/ckeditor5-font@46.0.1': | ||||
|     dependencies: | ||||
| @@ -17346,8 +17355,6 @@ snapshots: | ||||
|       '@ckeditor/ckeditor5-ui': 46.0.1 | ||||
|       '@ckeditor/ckeditor5-utils': 46.0.1 | ||||
|       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) | ||||
|     transitivePeerDependencies: | ||||
|       - supports-color | ||||
| 
 | ||||
|   '@ckeditor/ckeditor5-indent@46.0.1': | ||||
|     dependencies: | ||||
| @@ -17420,8 +17427,6 @@ snapshots: | ||||
|       '@ckeditor/ckeditor5-ui': 46.0.1 | ||||
|       '@ckeditor/ckeditor5-utils': 46.0.1 | ||||
|       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) | ||||
|     transitivePeerDependencies: | ||||
|       - supports-color | ||||
| 
 | ||||
|   '@ckeditor/ckeditor5-markdown-gfm@46.0.1': | ||||
|     dependencies: | ||||
| @@ -17459,8 +17464,6 @@ snapshots: | ||||
|       '@ckeditor/ckeditor5-utils': 46.0.1 | ||||
|       '@ckeditor/ckeditor5-widget': 46.0.1 | ||||
|       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) | ||||
|     transitivePeerDependencies: | ||||
|       - supports-color | ||||
| 
 | ||||
|   '@ckeditor/ckeditor5-mention@46.0.1(patch_hash=5981fb59ba35829e4dff1d39cf771000f8a8fdfa7a34b51d8af9549541f2d62d)': | ||||
|     dependencies: | ||||
| @@ -17484,8 +17487,6 @@ snapshots: | ||||
|       '@ckeditor/ckeditor5-widget': 46.0.1 | ||||
|       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) | ||||
|       es-toolkit: 1.39.5 | ||||
|     transitivePeerDependencies: | ||||
|       - supports-color | ||||
| 
 | ||||
|   '@ckeditor/ckeditor5-minimap@46.0.1': | ||||
|     dependencies: | ||||
| @@ -17494,8 +17495,6 @@ snapshots: | ||||
|       '@ckeditor/ckeditor5-ui': 46.0.1 | ||||
|       '@ckeditor/ckeditor5-utils': 46.0.1 | ||||
|       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) | ||||
|     transitivePeerDependencies: | ||||
|       - supports-color | ||||
| 
 | ||||
|   '@ckeditor/ckeditor5-operations-compressor@46.0.1': | ||||
|     dependencies: | ||||
| @@ -17548,8 +17547,6 @@ snapshots: | ||||
|       '@ckeditor/ckeditor5-utils': 46.0.1 | ||||
|       '@ckeditor/ckeditor5-widget': 46.0.1 | ||||
|       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) | ||||
|     transitivePeerDependencies: | ||||
|       - supports-color | ||||
| 
 | ||||
|   '@ckeditor/ckeditor5-pagination@46.0.1': | ||||
|     dependencies: | ||||
| @@ -17576,8 +17573,6 @@ snapshots: | ||||
|       '@ckeditor/ckeditor5-paste-from-office': 46.0.1 | ||||
|       '@ckeditor/ckeditor5-utils': 46.0.1 | ||||
|       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) | ||||
|     transitivePeerDependencies: | ||||
|       - supports-color | ||||
| 
 | ||||
|   '@ckeditor/ckeditor5-paste-from-office@46.0.1': | ||||
|     dependencies: | ||||
| @@ -17585,8 +17580,6 @@ snapshots: | ||||
|       '@ckeditor/ckeditor5-core': 46.0.1 | ||||
|       '@ckeditor/ckeditor5-engine': 46.0.1 | ||||
|       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) | ||||
|     transitivePeerDependencies: | ||||
|       - supports-color | ||||
| 
 | ||||
|   '@ckeditor/ckeditor5-real-time-collaboration@46.0.1(bufferutil@4.0.9)(utf-8-validate@6.0.5)': | ||||
|     dependencies: | ||||
| @@ -17617,8 +17610,6 @@ snapshots: | ||||
|       '@ckeditor/ckeditor5-ui': 46.0.1 | ||||
|       '@ckeditor/ckeditor5-utils': 46.0.1 | ||||
|       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) | ||||
|     transitivePeerDependencies: | ||||
|       - supports-color | ||||
| 
 | ||||
|   '@ckeditor/ckeditor5-restricted-editing@46.0.1': | ||||
|     dependencies: | ||||
| @@ -17628,8 +17619,6 @@ snapshots: | ||||
|       '@ckeditor/ckeditor5-ui': 46.0.1 | ||||
|       '@ckeditor/ckeditor5-utils': 46.0.1 | ||||
|       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) | ||||
|     transitivePeerDependencies: | ||||
|       - supports-color | ||||
| 
 | ||||
|   '@ckeditor/ckeditor5-revision-history@46.0.1': | ||||
|     dependencies: | ||||
| @@ -17664,8 +17653,6 @@ snapshots: | ||||
|       '@ckeditor/ckeditor5-ui': 46.0.1 | ||||
|       '@ckeditor/ckeditor5-utils': 46.0.1 | ||||
|       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) | ||||
|     transitivePeerDependencies: | ||||
|       - supports-color | ||||
| 
 | ||||
|   '@ckeditor/ckeditor5-slash-command@46.0.1': | ||||
|     dependencies: | ||||
| @@ -17678,8 +17665,6 @@ snapshots: | ||||
|       '@ckeditor/ckeditor5-ui': 46.0.1 | ||||
|       '@ckeditor/ckeditor5-utils': 46.0.1 | ||||
|       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) | ||||
|     transitivePeerDependencies: | ||||
|       - supports-color | ||||
| 
 | ||||
|   '@ckeditor/ckeditor5-source-editing-enhanced@46.0.1': | ||||
|     dependencies: | ||||
| @@ -17706,8 +17691,6 @@ snapshots: | ||||
|       '@ckeditor/ckeditor5-ui': 46.0.1 | ||||
|       '@ckeditor/ckeditor5-utils': 46.0.1 | ||||
|       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) | ||||
|     transitivePeerDependencies: | ||||
|       - supports-color | ||||
| 
 | ||||
|   '@ckeditor/ckeditor5-special-characters@46.0.1': | ||||
|     dependencies: | ||||
| @@ -17717,8 +17700,6 @@ snapshots: | ||||
|       '@ckeditor/ckeditor5-ui': 46.0.1 | ||||
|       '@ckeditor/ckeditor5-utils': 46.0.1 | ||||
|       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) | ||||
|     transitivePeerDependencies: | ||||
|       - supports-color | ||||
| 
 | ||||
|   '@ckeditor/ckeditor5-style@46.0.1': | ||||
|     dependencies: | ||||
| @@ -17731,8 +17712,6 @@ snapshots: | ||||
|       '@ckeditor/ckeditor5-utils': 46.0.1 | ||||
|       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) | ||||
|       es-toolkit: 1.39.5 | ||||
|     transitivePeerDependencies: | ||||
|       - supports-color | ||||
| 
 | ||||
|   '@ckeditor/ckeditor5-table@46.0.1': | ||||
|     dependencies: | ||||
| @@ -17745,8 +17724,6 @@ snapshots: | ||||
|       '@ckeditor/ckeditor5-widget': 46.0.1 | ||||
|       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) | ||||
|       es-toolkit: 1.39.5 | ||||
|     transitivePeerDependencies: | ||||
|       - supports-color | ||||
| 
 | ||||
|   '@ckeditor/ckeditor5-template@46.0.1': | ||||
|     dependencies: | ||||
| @@ -17857,8 +17834,6 @@ snapshots: | ||||
|       '@ckeditor/ckeditor5-engine': 46.0.1 | ||||
|       '@ckeditor/ckeditor5-utils': 46.0.1 | ||||
|       es-toolkit: 1.39.5 | ||||
|     transitivePeerDependencies: | ||||
|       - supports-color | ||||
| 
 | ||||
|   '@ckeditor/ckeditor5-widget@46.0.1': | ||||
|     dependencies: | ||||
| @@ -17878,8 +17853,6 @@ snapshots: | ||||
|       '@ckeditor/ckeditor5-utils': 46.0.1 | ||||
|       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) | ||||
|       es-toolkit: 1.39.5 | ||||
|     transitivePeerDependencies: | ||||
|       - supports-color | ||||
| 
 | ||||
|   '@codemirror/autocomplete@6.18.6': | ||||
|     dependencies: | ||||
| @@ -29514,6 +29487,8 @@ snapshots: | ||||
| 
 | ||||
|   perfect-freehand@1.2.0: {} | ||||
| 
 | ||||
|   photoswipe@5.4.4: {} | ||||
| 
 | ||||
|   pica@7.1.1: | ||||
|     dependencies: | ||||
|       glur: 1.1.2 | ||||
|   | ||||
		Reference in New Issue
	
	Block a user