mirror of
				https://github.com/zadam/trilium.git
				synced 2025-10-31 02:16:05 +01:00 
			
		
		
		
	Compare commits
	
		
			2 Commits
		
	
	
		
			v0.99.2
			...
			feat/bette
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 5024e27885 | ||
|  | 78c27dbe04 | 
| @@ -55,7 +55,8 @@ | |||||||
|     "split.js": "1.6.5", |     "split.js": "1.6.5", | ||||||
|     "svg-pan-zoom": "3.6.2", |     "svg-pan-zoom": "3.6.2", | ||||||
|     "tabulator-tables": "6.3.1", |     "tabulator-tables": "6.3.1", | ||||||
|     "vanilla-js-wheel-zoom": "9.0.4" |     "vanilla-js-wheel-zoom": "9.0.4", | ||||||
|  |     "photoswipe": "^5.4.4" | ||||||
|   }, |   }, | ||||||
|   "devDependencies": { |   "devDependencies": { | ||||||
|     "@ckeditor/ckeditor5-inspector": "5.0.0", |     "@ckeditor/ckeditor5-inspector": "5.0.0", | ||||||
|   | |||||||
| @@ -13,6 +13,8 @@ import type ElectronRemote from "@electron/remote"; | |||||||
| import type Electron from "electron"; | import type Electron from "electron"; | ||||||
| import "./stylesheets/bootstrap.scss"; | import "./stylesheets/bootstrap.scss"; | ||||||
| import "boxicons/css/boxicons.min.css"; | import "boxicons/css/boxicons.min.css"; | ||||||
|  | import "./stylesheets/media-viewer.css"; | ||||||
|  | import "./styles/gallery.css"; | ||||||
| import "autocomplete.js/index_jquery.js"; | import "autocomplete.js/index_jquery.js"; | ||||||
|  |  | ||||||
| await appContext.earlyInit(); | await appContext.earlyInit(); | ||||||
|   | |||||||
| @@ -2,6 +2,8 @@ import { t } from "../services/i18n.js"; | |||||||
| import utils from "../services/utils.js"; | import utils from "../services/utils.js"; | ||||||
| import contextMenu from "./context_menu.js"; | import contextMenu from "./context_menu.js"; | ||||||
| import imageService from "../services/image.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"; | const PROP_NAME = "imageContextMenuInstalled"; | ||||||
|  |  | ||||||
| @@ -18,6 +20,12 @@ function setupContextMenu($image: JQuery<HTMLElement>) { | |||||||
|             x: e.pageX, |             x: e.pageX, | ||||||
|             y: e.pageY, |             y: e.pageY, | ||||||
|             items: [ |             items: [ | ||||||
|  |                 { | ||||||
|  |                     title: "View in Lightbox", | ||||||
|  |                     command: "viewInLightbox", | ||||||
|  |                     uiIcon: "bx bx-expand", | ||||||
|  |                     enabled: true | ||||||
|  |                 }, | ||||||
|                 { |                 { | ||||||
|                     title: t("image_context_menu.copy_reference_to_clipboard"), |                     title: t("image_context_menu.copy_reference_to_clipboard"), | ||||||
|                     command: "copyImageReferenceToClipboard", |                     command: "copyImageReferenceToClipboard", | ||||||
| @@ -30,7 +38,48 @@ function setupContextMenu($image: JQuery<HTMLElement>) { | |||||||
|                 } |                 } | ||||||
|             ], |             ], | ||||||
|             selectMenuItemHandler: async ({ command }) => { |             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); |                     imageService.copyImageReferenceToClipboard($image); | ||||||
|                 } else if (command === "copyImageToClipboard") { |                 } else if (command === "copyImageToClipboard") { | ||||||
|                     try { |                     try { | ||||||
|   | |||||||
| @@ -3,6 +3,7 @@ import noteAutocompleteService from "./services/note_autocomplete.js"; | |||||||
| import glob from "./services/glob.js"; | import glob from "./services/glob.js"; | ||||||
| import "./stylesheets/bootstrap.scss"; | import "./stylesheets/bootstrap.scss"; | ||||||
| import "boxicons/css/boxicons.min.css"; | import "boxicons/css/boxicons.min.css"; | ||||||
|  | import "./stylesheets/media-viewer.css"; | ||||||
| import "autocomplete.js/index_jquery.js"; | import "autocomplete.js/index_jquery.js"; | ||||||
|  |  | ||||||
| glob.setupGlobs(); | 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 toastService from "../services/toast.js"; | ||||||
| import type FAttachment from "../entities/fattachment.js"; | import type FAttachment from "../entities/fattachment.js"; | ||||||
| import type { EventData } from "../components/app_context.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*/` | const TPL = /*html*/` | ||||||
| <div class="attachment-detail-widget"> | <div class="attachment-detail-widget"> | ||||||
| @@ -65,6 +68,12 @@ const TPL = /*html*/` | |||||||
|  |  | ||||||
|         .attachment-content-wrapper img { |         .attachment-content-wrapper img { | ||||||
|             margin: 10px; |             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 { |         .attachment-detail-wrapper.list-view .attachment-content-wrapper img, .attachment-detail-wrapper.list-view .attachment-content-wrapper video { | ||||||
| @@ -78,6 +87,24 @@ const TPL = /*html*/` | |||||||
|             object-fit: contain; |             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 { |         .attachment-detail-wrapper.scheduled-for-deletion .attachment-content-wrapper img { | ||||||
|             filter: contrast(10%); |             filter: contrast(10%); | ||||||
|         } |         } | ||||||
| @@ -88,7 +115,9 @@ const TPL = /*html*/` | |||||||
|             <div class="attachment-actions-container"></div> |             <div class="attachment-actions-container"></div> | ||||||
|             <h4 class="attachment-title"></h4> |             <h4 class="attachment-title"></h4> | ||||||
|             <div class="attachment-details"></div> |             <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> | ||||||
|  |  | ||||||
|         <div class="attachment-deletion-warning alert alert-info" style="margin-top: 15px;"></div> |         <div class="attachment-deletion-warning alert alert-info" style="margin-top: 15px;"></div> | ||||||
| @@ -125,6 +154,14 @@ export default class AttachmentDetailWidget extends BasicWidget { | |||||||
|         this.$wrapper = this.$widget.find(".attachment-detail-wrapper"); |         this.$wrapper = this.$widget.find(".attachment-detail-wrapper"); | ||||||
|         this.$wrapper.addClass(this.isFullDetail ? "full-detail" : "list-view"); |         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) { |         if (!this.isFullDetail) { | ||||||
|             const $link = await linkService.createLink(this.attachment.ownerId, { |             const $link = await linkService.createLink(this.attachment.ownerId, { | ||||||
|                 title: this.attachment.title, |                 title: this.attachment.title, | ||||||
| @@ -170,7 +207,92 @@ export default class AttachmentDetailWidget extends BasicWidget { | |||||||
|         this.$wrapper.find(".attachment-actions-container").append(this.attachmentActionsWidget.render()); |         this.$wrapper.find(".attachment-actions-container").append(this.attachmentActionsWidget.render()); | ||||||
|  |  | ||||||
|         const { $renderedContent } = await contentRenderer.getRenderedContent(this.attachment, { imageHasZoom: this.isFullDetail }); |         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() { |     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 utils from "../../services/utils.js"; | ||||||
| import options from "../../services/options.js"; | import options from "../../services/options.js"; | ||||||
| import attributes from "../../services/attributes.js"; | import attributes from "../../services/attributes.js"; | ||||||
|  | import ckeditorPhotoswipeIntegration from "../../services/ckeditor_photoswipe_integration.js"; | ||||||
|  |  | ||||||
| export default class AbstractTextTypeWidget extends TypeWidget { | export default class AbstractTextTypeWidget extends TypeWidget { | ||||||
|     doRender() { |     doRender() { | ||||||
| @@ -35,7 +36,29 @@ export default class AbstractTextTypeWidget extends TypeWidget { | |||||||
|         const parsedImage  = await this.parseFromImage($img); |         const parsedImage  = await this.parseFromImage($img); | ||||||
|  |  | ||||||
|         if (parsedImage) { |         if (parsedImage) { | ||||||
|  |             // 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 }); |                 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 { |         } else { | ||||||
|             window.open($img.prop("src"), "_blank"); |             window.open($img.prop("src"), "_blank"); | ||||||
|         } |         } | ||||||
|   | |||||||
| @@ -4,6 +4,8 @@ import linkService from "../../services/link.js"; | |||||||
| import utils from "../../services/utils.js"; | import utils from "../../services/utils.js"; | ||||||
| import { t } from "../../services/i18n.js"; | import { t } from "../../services/i18n.js"; | ||||||
| import type { EventData } from "../../components/app_context.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*/` | const TPL = /*html*/` | ||||||
| <div class="attachment-list note-detail-printable"> | <div class="attachment-list note-detail-printable"> | ||||||
| @@ -20,17 +22,81 @@ const TPL = /*html*/` | |||||||
|             justify-content: space-between; |             justify-content: space-between; | ||||||
|             align-items: baseline; |             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> |     </style> | ||||||
|  |  | ||||||
|     <div class="links-wrapper"></div> |     <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 class="attachment-list-wrapper"></div> | ||||||
| </div>`; | </div>`; | ||||||
|  |  | ||||||
| export default class AttachmentListTypeWidget extends TypeWidget { | export default class AttachmentListTypeWidget extends TypeWidget { | ||||||
|     $list!: JQuery<HTMLElement>; |     $list!: JQuery<HTMLElement>; | ||||||
|     $linksWrapper!: JQuery<HTMLElement>; |     $linksWrapper!: JQuery<HTMLElement>; | ||||||
|  |     $galleryToolbar!: JQuery<HTMLElement>; | ||||||
|  |     $imageGrid!: JQuery<HTMLElement>; | ||||||
|     renderedAttachmentIds!: Set<string>; |     renderedAttachmentIds!: Set<string>; | ||||||
|  |     imageAttachments: GalleryItem[] = []; | ||||||
|  |     otherAttachments: any[] = []; | ||||||
|  |  | ||||||
|     static getType() { |     static getType() { | ||||||
|         return "attachmentList"; |         return "attachmentList"; | ||||||
| @@ -40,6 +106,8 @@ export default class AttachmentListTypeWidget extends TypeWidget { | |||||||
|         this.$widget = $(TPL); |         this.$widget = $(TPL); | ||||||
|         this.$list = this.$widget.find(".attachment-list-wrapper"); |         this.$list = this.$widget.find(".attachment-list-wrapper"); | ||||||
|         this.$linksWrapper = this.$widget.find(".links-wrapper"); |         this.$linksWrapper = this.$widget.find(".links-wrapper"); | ||||||
|  |         this.$galleryToolbar = this.$widget.find(".gallery-toolbar"); | ||||||
|  |         this.$imageGrid = this.$widget.find(".image-grid"); | ||||||
|  |  | ||||||
|         super.doRender(); |         super.doRender(); | ||||||
|     } |     } | ||||||
| @@ -75,8 +143,12 @@ export default class AttachmentListTypeWidget extends TypeWidget { | |||||||
|         ); |         ); | ||||||
|  |  | ||||||
|         this.$list.empty(); |         this.$list.empty(); | ||||||
|  |         this.$imageGrid.empty().hide(); | ||||||
|  |         this.$galleryToolbar.empty().hide(); | ||||||
|         this.children = []; |         this.children = []; | ||||||
|         this.renderedAttachmentIds = new Set(); |         this.renderedAttachmentIds = new Set(); | ||||||
|  |         this.imageAttachments = []; | ||||||
|  |         this.otherAttachments = []; | ||||||
|  |  | ||||||
|         const attachments = await note.getAttachments(); |         const attachments = await note.getAttachments(); | ||||||
|  |  | ||||||
| @@ -85,17 +157,122 @@ export default class AttachmentListTypeWidget extends TypeWidget { | |||||||
|             return; |             return; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         // Separate image and non-image attachments | ||||||
|         for (const attachment of 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); |             const attachmentDetailWidget = new AttachmentDetailWidget(attachment, false); | ||||||
|  |  | ||||||
|             this.child(attachmentDetailWidget); |             this.child(attachmentDetailWidget); | ||||||
|  |  | ||||||
|             this.renderedAttachmentIds.add(attachment.attachmentId); |             this.renderedAttachmentIds.add(attachment.attachmentId); | ||||||
|  |  | ||||||
|             this.$list.append(attachmentDetailWidget.render()); |             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">) { |     async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) { | ||||||
|         // updates and deletions are handled by the detail, for new attachments the whole list has to be refreshed |         // 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)); |         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(); |             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 { PopupEditor, ClassicEditor, EditorWatchdog, type CKTextEditor, type MentionFeed, type WatchdogConfig, EditorConfig } from "@triliumnext/ckeditor5"; | ||||||
| import "@triliumnext/ckeditor5/index.css"; | import "@triliumnext/ckeditor5/index.css"; | ||||||
| import { updateTemplateCache } from "./ckeditor/snippets.js"; | import { updateTemplateCache } from "./ckeditor/snippets.js"; | ||||||
|  | import ckeditorPhotoSwipe from "../../services/ckeditor_photoswipe_integration.js"; | ||||||
|  |  | ||||||
| const TPL = /*html*/` | const TPL = /*html*/` | ||||||
| <div class="note-detail-editable-text note-detail-printable"> | <div class="note-detail-editable-text note-detail-printable"> | ||||||
| @@ -163,6 +164,19 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget { | |||||||
|             }; |             }; | ||||||
|             const editor = await buildEditor(this.$editor[0], isClassicEditor, opts); |             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"); |             const notificationsPlugin = editor.plugins.get("Notification"); | ||||||
|             notificationsPlugin.on("show:warning", (evt, data) => { |             notificationsPlugin.on("show:warning", (evt, data) => { | ||||||
|                 const title = data.title; |                 const title = data.title; | ||||||
| @@ -291,11 +305,25 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     cleanup() { |     cleanup() { | ||||||
|  |         // Cleanup PhotoSwipe integration | ||||||
|  |         if (this.$editor?.[0]) { | ||||||
|  |             ckeditorPhotoSwipe.cleanupContainer(this.$editor[0]); | ||||||
|  |         } | ||||||
|  |          | ||||||
|         if (this.watchdog?.editor) { |         if (this.watchdog?.editor) { | ||||||
|             this.spacedUpdate.allowUpdateWithoutChange(() => { |             this.spacedUpdate.allowUpdateWithoutChange(() => { | ||||||
|                 this.watchdog.editor?.setData(""); |                 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() { |     insertDateTimeToTextCommand() { | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| import openService from "../../services/open.js"; | 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 { t } from "../../services/i18n.js"; | ||||||
| import type { EventData } from "../../components/app_context.js"; | import type { EventData } from "../../components/app_context.js"; | ||||||
| import type FNote from "../../entities/fnote.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="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; |             overflow: hidden; | ||||||
|         } |         } | ||||||
|  |  | ||||||
| @@ -39,6 +40,133 @@ const TPL = /*html*/` | |||||||
|             width: 100%; |             width: 100%; | ||||||
|             height: 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> |     </style> | ||||||
|  |  | ||||||
|     <div class="file-preview-too-big alert alert-info hidden-ext"> |     <div class="file-preview-too-big alert alert-info hidden-ext"> | ||||||
| @@ -56,21 +184,66 @@ const TPL = /*html*/` | |||||||
|     <video class="video-preview" controls></video> |     <video class="video-preview" controls></video> | ||||||
|  |  | ||||||
|     <audio class="audio-preview" controls></audio> |     <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>`; | </div>`; | ||||||
|  |  | ||||||
| export default class FileTypeWidget extends TypeWidget { | export default class FileTypeWidget extends ImageViewerBase { | ||||||
|  |  | ||||||
|     private $previewContent!: JQuery<HTMLElement>; |     private $previewContent!: JQuery<HTMLElement>; | ||||||
|     private $previewNotAvailable!: JQuery<HTMLElement>; |     private $previewNotAvailable!: JQuery<HTMLElement>; | ||||||
|     private $previewTooBig!: JQuery<HTMLElement>; |     private $previewTooBig!: JQuery<HTMLElement>; | ||||||
|     private $pdfPreview!: JQuery<HTMLElement>; |     private $pdfPreview!: JQuery<HTMLElement>; | ||||||
|     private $videoPreview!: JQuery<HTMLElement>; |     private $videoPreview!: JQuery<HTMLElement>; | ||||||
|     private $audioPreview!: 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() { |     static getType() { | ||||||
|         return "file"; |         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() { |     doRender() { | ||||||
|         this.$widget = $(TPL); |         this.$widget = $(TPL); | ||||||
|         this.$previewContent = this.$widget.find(".file-preview-content"); |         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.$pdfPreview = this.$widget.find(".pdf-preview"); | ||||||
|         this.$videoPreview = this.$widget.find(".video-preview"); |         this.$videoPreview = this.$widget.find(".video-preview"); | ||||||
|         this.$audioPreview = this.$widget.find(".audio-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(); |         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) { |     async doRefresh(note: FNote) { | ||||||
|         this.$widget.show(); |         this.$widget?.show(); | ||||||
|  |  | ||||||
|         const blob = await this.note?.getBlob(); |         const blob = await this.note?.getBlob(); | ||||||
|  |  | ||||||
|         this.$previewContent.empty().hide(); |         // Hide all preview types | ||||||
|         this.$pdfPreview.attr("src", "").empty().hide(); |         this.$previewContent?.empty().hide(); | ||||||
|         this.$previewNotAvailable.hide(); |         this.$pdfPreview?.attr("src", "").empty().hide(); | ||||||
|         this.$previewTooBig.addClass("hidden-ext"); |         this.$previewNotAvailable?.hide(); | ||||||
|         this.$videoPreview.hide(); |         this.$previewTooBig?.addClass("hidden-ext"); | ||||||
|         this.$audioPreview.hide(); |         this.$videoPreview?.hide(); | ||||||
|  |         this.$audioPreview?.hide(); | ||||||
|  |         this.$imageFilePreview?.hide(); | ||||||
|  |  | ||||||
|         let previewType: string; |         let previewType: string; | ||||||
|  |  | ||||||
|         if (blob?.content) { |         // Check if this is an image file | ||||||
|             this.$previewContent.show().scrollTop(0); |         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); |             const trimmedContent = blob.content.substring(0, TEXT_MAX_NUM_CHARS); | ||||||
|             if (trimmedContent.length !== blob.content.length) { |             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"; |             previewType = "text"; | ||||||
|         } else if (note.mime === "application/pdf") { |         } 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"; |             previewType = "pdf"; | ||||||
|         } else if (note.mime.startsWith("video/")) { |         } else if (note.mime.startsWith("video/")) { | ||||||
|             this.$videoPreview |             this.$videoPreview | ||||||
|                 .show() |                 ?.show() | ||||||
|                 .attr("src", openService.getUrlForDownload(`api/notes/${this.noteId}/open-partial`)) |                 .attr("src", openService.getUrlForDownload(`api/notes/${this.noteId}/open-partial`)) | ||||||
|                 .attr("type", this.note?.mime ?? "") |                 .attr("type", this.note?.mime ?? "") | ||||||
|                 .css("width", this.$widget.width() ?? 0); |                 .css("width", this.$widget?.width() ?? 0); | ||||||
|             previewType = "video"; |             previewType = "video"; | ||||||
|         } else if (note.mime.startsWith("audio/")) { |         } else if (note.mime.startsWith("audio/")) { | ||||||
|             this.$audioPreview |             this.$audioPreview | ||||||
|                 .show() |                 ?.show() | ||||||
|                 .attr("src", openService.getUrlForDownload(`api/notes/${this.noteId}/open-partial`)) |                 .attr("src", openService.getUrlForDownload(`api/notes/${this.noteId}/open-partial`)) | ||||||
|                 .attr("type", this.note?.mime ?? "") |                 .attr("type", this.note?.mime ?? "") | ||||||
|                 .css("width", this.$widget.width() ?? 0); |                 .css("width", this.$widget?.width() ?? 0); | ||||||
|             previewType = "audio"; |             previewType = "audio"; | ||||||
|         } else { |         } else { | ||||||
|             this.$previewNotAvailable.show(); |             this.$previewNotAvailable?.show(); | ||||||
|             previewType = "not-available"; |             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">) { |     async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) { | ||||||
|         if (loadResults.isNoteReloaded(this.noteId)) { |         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 utils from "../../services/utils.js"; | ||||||
| import TypeWidget from "./type_widget.js"; | import { ImageViewerBase } from "./image_viewer_base.js"; | ||||||
| import imageContextMenuService from "../../menus/image_context_menu.js"; |  | ||||||
| import imageService from "../../services/image.js"; | import imageService from "../../services/image.js"; | ||||||
| import type FNote from "../../entities/fnote.js"; | import type FNote from "../../entities/fnote.js"; | ||||||
| import type { EventData } from "../../components/app_context.js"; | import type { EventData } from "../../components/app_context.js"; | ||||||
| import WheelZoom from 'vanilla-js-wheel-zoom'; |  | ||||||
|  |  | ||||||
| const TPL = /*html*/` | const TPL = /*html*/` | ||||||
| <div class="note-detail-image note-detail-printable"> | <div class="note-detail-image note-detail-printable"> | ||||||
| @@ -15,6 +13,7 @@ const TPL = /*html*/` | |||||||
|  |  | ||||||
|         .note-detail-image { |         .note-detail-image { | ||||||
|             height: 100%; |             height: 100%; | ||||||
|  |             position: relative; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         .note-detail-image-wrapper { |         .note-detail-image-wrapper { | ||||||
| @@ -28,53 +27,314 @@ const TPL = /*html*/` | |||||||
|  |  | ||||||
|         .note-detail-image-view { |         .note-detail-image-view { | ||||||
|             display: block; |             display: block; | ||||||
|  |             max-width: 100%; | ||||||
|  |             max-height: 100%; | ||||||
|             width: auto; |             width: auto; | ||||||
|             height: auto; |             height: auto; | ||||||
|             align-self: center; |             align-self: center; | ||||||
|             flex-shrink: 0; |             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> |     </style> | ||||||
|  |  | ||||||
|     <div class="note-detail-image-wrapper"> |     <div class="note-detail-image-wrapper"> | ||||||
|         <img class="note-detail-image-view" /> |         <img class="note-detail-image-view" /> | ||||||
|     </div> |     </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>`; | </div>`; | ||||||
|  |  | ||||||
| class ImageTypeWidget extends TypeWidget { | class ImageTypeWidget extends ImageViewerBase { | ||||||
|  |     private $zoomInBtn!: JQuery<HTMLElement>; | ||||||
|     private $imageWrapper!: JQuery<HTMLElement>; |     private $zoomOutBtn!: JQuery<HTMLElement>; | ||||||
|     private $imageView!: JQuery<HTMLElement>; |     private $resetZoomBtn!: JQuery<HTMLElement>; | ||||||
|  |     private $fullscreenBtn!: JQuery<HTMLElement>; | ||||||
|  |     private $downloadBtn!: JQuery<HTMLElement>; | ||||||
|  |     private wheelHandler?: (e: JQuery.TriggeredEvent) => void; | ||||||
|  |  | ||||||
|     static getType() { |     static getType() { | ||||||
|         return "image"; |         return "image"; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     constructor() { | ||||||
|  |         super(); | ||||||
|  |         // Apply custom configuration if needed | ||||||
|  |         this.applyConfig({ | ||||||
|  |             minZoom: 0.5, | ||||||
|  |             maxZoom: 5, | ||||||
|  |             zoomStep: 0.25, | ||||||
|  |             debounceDelay: 16, | ||||||
|  |             touchTargetSize: 44 | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     doRender() { |     doRender() { | ||||||
|         this.$widget = $(TPL); |         this.$widget = $(TPL); | ||||||
|         this.$imageWrapper = this.$widget.find(".note-detail-image-wrapper"); |         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"); | ||||||
|          |          | ||||||
|         const initZoom = async () => { |         // Generate unique ID for image element | ||||||
|             const element = document.querySelector(`#${this.$imageView.attr("id")}`); |         const imageId = `image-view-${utils.randomString(10)}`; | ||||||
|             if (element) { |         this.$imageView.attr("id", imageId); | ||||||
|                 WheelZoom.create(`#${this.$imageView.attr("id")}`, { |  | ||||||
|                     maxScale: 50, |  | ||||||
|                     speed: 1.3, |  | ||||||
|                     zoomOnClick: false |  | ||||||
|                 }); |  | ||||||
|             } else { |  | ||||||
|                 requestAnimationFrame(initZoom); |  | ||||||
|             } |  | ||||||
|         }; |  | ||||||
|         initZoom(); |  | ||||||
|          |          | ||||||
|         imageContextMenuService.setupContextMenu(this.$imageView); |         // 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"); | ||||||
|  |  | ||||||
|  |         this.setupEventHandlers(); | ||||||
|  |         this.setupPanFunctionality(); | ||||||
|  |         this.setupKeyboardNavigation(); | ||||||
|  |         this.setupDoubleClickReset(); | ||||||
|  |         this.setupContextMenu(); | ||||||
|  |         this.addAccessibilityLabels(); | ||||||
|  |  | ||||||
|         super.doRender(); |         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) { |     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">) { |     copyImageReferenceToClipboardEvent({ ntxId }: EventData<"copyImageReferenceToClipboard">) { | ||||||
| @@ -82,14 +342,26 @@ class ImageTypeWidget extends TypeWidget { | |||||||
|             return; |             return; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         if (this.$imageWrapper?.length) { | ||||||
|             imageService.copyImageReferenceToClipboard(this.$imageWrapper); |             imageService.copyImageReferenceToClipboard(this.$imageWrapper); | ||||||
|         } |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|     async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) { |     async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) { | ||||||
|         if (loadResults.isNoteReloaded(this.noteId)) { |         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 appContext from "../../components/app_context.js"; | ||||||
| import { getMermaidConfig } from "../../services/mermaid.js"; | import { getMermaidConfig } from "../../services/mermaid.js"; | ||||||
| import { renderMathInElement } from "../../services/math.js"; | import { renderMathInElement } from "../../services/math.js"; | ||||||
|  | import ckeditorPhotoSwipe from "../../services/ckeditor_photoswipe_integration.js"; | ||||||
|  |  | ||||||
| const TPL = /*html*/` | const TPL = /*html*/` | ||||||
| <div class="note-detail-readonly-text note-detail-printable" tabindex="100"> | <div class="note-detail-readonly-text note-detail-printable" tabindex="100"> | ||||||
| @@ -93,9 +94,21 @@ export default class ReadOnlyTextTypeWidget extends AbstractTextTypeWidget { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     cleanup() { |     cleanup() { | ||||||
|  |         // 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(""); |             this.$content.html(""); | ||||||
|         } |         } | ||||||
|          |          | ||||||
|  |         super.cleanup(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     async doRefresh(note: FNote) { |     async doRefresh(note: FNote) { | ||||||
|         // we load CKEditor also for read only notes because they contain content styles required for correct rendering of even read only notes |         // we load CKEditor also for read only notes because they contain content styles required for correct rendering of even read only notes | ||||||
|         // we could load just ckeditor-content.css but that causes CSS conflicts when both build CSS and this content CSS is loaded at the same time |         // we could load just ckeditor-content.css but that causes CSS conflicts when both build CSS and this content CSS is loaded at the same time | ||||||
| @@ -108,6 +121,18 @@ export default class ReadOnlyTextTypeWidget extends AbstractTextTypeWidget { | |||||||
|  |  | ||||||
|         this.$content.html(blob?.content ?? ""); |         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.$content.find("a.reference-link").each((_, el) => { | ||||||
|             this.loadReferenceLinkTitle($(el)); |             this.loadReferenceLinkTitle($(el)); | ||||||
|         }); |         }); | ||||||
|   | |||||||
							
								
								
									
										55
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										55
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							| @@ -282,6 +282,9 @@ importers: | |||||||
|       panzoom: |       panzoom: | ||||||
|         specifier: 9.4.3 |         specifier: 9.4.3 | ||||||
|         version: 9.4.3 |         version: 9.4.3 | ||||||
|  |       photoswipe: | ||||||
|  |         specifier: ^5.4.4 | ||||||
|  |         version: 5.4.4 | ||||||
|       preact: |       preact: | ||||||
|         specifier: 10.27.0 |         specifier: 10.27.0 | ||||||
|         version: 10.27.0 |         version: 10.27.0 | ||||||
| @@ -11730,6 +11733,10 @@ packages: | |||||||
|   perfect-freehand@1.2.0: |   perfect-freehand@1.2.0: | ||||||
|     resolution: {integrity: sha512-h/0ikF1M3phW7CwpZ5MMvKnfpHficWoOEyr//KVNTxV4F6deRK1eYMtHyBKEAKFK0aXIEUK9oBvlF6PNXMDsAw==} |     resolution: {integrity: sha512-h/0ikF1M3phW7CwpZ5MMvKnfpHficWoOEyr//KVNTxV4F6deRK1eYMtHyBKEAKFK0aXIEUK9oBvlF6PNXMDsAw==} | ||||||
| 
 | 
 | ||||||
|  |   photoswipe@5.4.4: | ||||||
|  |     resolution: {integrity: sha512-WNFHoKrkZNnvFFhbHL93WDkW3ifwVOXSW3w1UuZZelSmgXpIGiZSNlZJq37rR8YejqME2rHs9EhH9ZvlvFH2NA==} | ||||||
|  |     engines: {node: '>= 0.12.0'} | ||||||
|  | 
 | ||||||
|   pica@7.1.1: |   pica@7.1.1: | ||||||
|     resolution: {integrity: sha512-WY73tMvNzXWEld2LicT9Y260L43isrZ85tPuqRyvtkljSDLmnNFQmZICt4xUJMVulmcc6L9O7jbBrtx3DOz/YQ==} |     resolution: {integrity: sha512-WY73tMvNzXWEld2LicT9Y260L43isrZ85tPuqRyvtkljSDLmnNFQmZICt4xUJMVulmcc6L9O7jbBrtx3DOz/YQ==} | ||||||
| 
 | 
 | ||||||
| @@ -16850,6 +16857,8 @@ snapshots: | |||||||
|       '@ckeditor/ckeditor5-utils': 46.0.1 |       '@ckeditor/ckeditor5-utils': 46.0.1 | ||||||
|       '@ckeditor/ckeditor5-widget': 46.0.1 |       '@ckeditor/ckeditor5-widget': 46.0.1 | ||||||
|       es-toolkit: 1.39.5 |       es-toolkit: 1.39.5 | ||||||
|  |     transitivePeerDependencies: | ||||||
|  |       - supports-color | ||||||
| 
 | 
 | ||||||
|   '@ckeditor/ckeditor5-cloud-services@46.0.1': |   '@ckeditor/ckeditor5-cloud-services@46.0.1': | ||||||
|     dependencies: |     dependencies: | ||||||
| @@ -16869,6 +16878,8 @@ snapshots: | |||||||
|       '@ckeditor/ckeditor5-ui': 46.0.1 |       '@ckeditor/ckeditor5-ui': 46.0.1 | ||||||
|       '@ckeditor/ckeditor5-utils': 46.0.1 |       '@ckeditor/ckeditor5-utils': 46.0.1 | ||||||
|       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) |       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) | ||||||
|  |     transitivePeerDependencies: | ||||||
|  |       - supports-color | ||||||
| 
 | 
 | ||||||
|   '@ckeditor/ckeditor5-collaboration-core@46.0.1': |   '@ckeditor/ckeditor5-collaboration-core@46.0.1': | ||||||
|     dependencies: |     dependencies: | ||||||
| @@ -17138,8 +17149,6 @@ snapshots: | |||||||
|       '@ckeditor/ckeditor5-table': 46.0.1 |       '@ckeditor/ckeditor5-table': 46.0.1 | ||||||
|       '@ckeditor/ckeditor5-utils': 46.0.1 |       '@ckeditor/ckeditor5-utils': 46.0.1 | ||||||
|       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) |       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) | ||||||
|     transitivePeerDependencies: |  | ||||||
|       - supports-color |  | ||||||
| 
 | 
 | ||||||
|   '@ckeditor/ckeditor5-emoji@46.0.1': |   '@ckeditor/ckeditor5-emoji@46.0.1': | ||||||
|     dependencies: |     dependencies: | ||||||
| @@ -17196,8 +17205,6 @@ snapshots: | |||||||
|       '@ckeditor/ckeditor5-ui': 46.0.1 |       '@ckeditor/ckeditor5-ui': 46.0.1 | ||||||
|       '@ckeditor/ckeditor5-utils': 46.0.1 |       '@ckeditor/ckeditor5-utils': 46.0.1 | ||||||
|       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) |       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) | ||||||
|     transitivePeerDependencies: |  | ||||||
|       - supports-color |  | ||||||
| 
 | 
 | ||||||
|   '@ckeditor/ckeditor5-export-word@46.0.1': |   '@ckeditor/ckeditor5-export-word@46.0.1': | ||||||
|     dependencies: |     dependencies: | ||||||
| @@ -17222,6 +17229,8 @@ snapshots: | |||||||
|       '@ckeditor/ckeditor5-utils': 46.0.1 |       '@ckeditor/ckeditor5-utils': 46.0.1 | ||||||
|       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) |       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) | ||||||
|       es-toolkit: 1.39.5 |       es-toolkit: 1.39.5 | ||||||
|  |     transitivePeerDependencies: | ||||||
|  |       - supports-color | ||||||
| 
 | 
 | ||||||
|   '@ckeditor/ckeditor5-font@46.0.1': |   '@ckeditor/ckeditor5-font@46.0.1': | ||||||
|     dependencies: |     dependencies: | ||||||
| @@ -17346,8 +17355,6 @@ snapshots: | |||||||
|       '@ckeditor/ckeditor5-ui': 46.0.1 |       '@ckeditor/ckeditor5-ui': 46.0.1 | ||||||
|       '@ckeditor/ckeditor5-utils': 46.0.1 |       '@ckeditor/ckeditor5-utils': 46.0.1 | ||||||
|       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) |       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) | ||||||
|     transitivePeerDependencies: |  | ||||||
|       - supports-color |  | ||||||
| 
 | 
 | ||||||
|   '@ckeditor/ckeditor5-indent@46.0.1': |   '@ckeditor/ckeditor5-indent@46.0.1': | ||||||
|     dependencies: |     dependencies: | ||||||
| @@ -17420,8 +17427,6 @@ snapshots: | |||||||
|       '@ckeditor/ckeditor5-ui': 46.0.1 |       '@ckeditor/ckeditor5-ui': 46.0.1 | ||||||
|       '@ckeditor/ckeditor5-utils': 46.0.1 |       '@ckeditor/ckeditor5-utils': 46.0.1 | ||||||
|       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) |       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) | ||||||
|     transitivePeerDependencies: |  | ||||||
|       - supports-color |  | ||||||
| 
 | 
 | ||||||
|   '@ckeditor/ckeditor5-markdown-gfm@46.0.1': |   '@ckeditor/ckeditor5-markdown-gfm@46.0.1': | ||||||
|     dependencies: |     dependencies: | ||||||
| @@ -17459,8 +17464,6 @@ snapshots: | |||||||
|       '@ckeditor/ckeditor5-utils': 46.0.1 |       '@ckeditor/ckeditor5-utils': 46.0.1 | ||||||
|       '@ckeditor/ckeditor5-widget': 46.0.1 |       '@ckeditor/ckeditor5-widget': 46.0.1 | ||||||
|       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) |       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) | ||||||
|     transitivePeerDependencies: |  | ||||||
|       - supports-color |  | ||||||
| 
 | 
 | ||||||
|   '@ckeditor/ckeditor5-mention@46.0.1(patch_hash=5981fb59ba35829e4dff1d39cf771000f8a8fdfa7a34b51d8af9549541f2d62d)': |   '@ckeditor/ckeditor5-mention@46.0.1(patch_hash=5981fb59ba35829e4dff1d39cf771000f8a8fdfa7a34b51d8af9549541f2d62d)': | ||||||
|     dependencies: |     dependencies: | ||||||
| @@ -17484,8 +17487,6 @@ snapshots: | |||||||
|       '@ckeditor/ckeditor5-widget': 46.0.1 |       '@ckeditor/ckeditor5-widget': 46.0.1 | ||||||
|       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) |       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) | ||||||
|       es-toolkit: 1.39.5 |       es-toolkit: 1.39.5 | ||||||
|     transitivePeerDependencies: |  | ||||||
|       - supports-color |  | ||||||
| 
 | 
 | ||||||
|   '@ckeditor/ckeditor5-minimap@46.0.1': |   '@ckeditor/ckeditor5-minimap@46.0.1': | ||||||
|     dependencies: |     dependencies: | ||||||
| @@ -17494,8 +17495,6 @@ snapshots: | |||||||
|       '@ckeditor/ckeditor5-ui': 46.0.1 |       '@ckeditor/ckeditor5-ui': 46.0.1 | ||||||
|       '@ckeditor/ckeditor5-utils': 46.0.1 |       '@ckeditor/ckeditor5-utils': 46.0.1 | ||||||
|       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) |       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) | ||||||
|     transitivePeerDependencies: |  | ||||||
|       - supports-color |  | ||||||
| 
 | 
 | ||||||
|   '@ckeditor/ckeditor5-operations-compressor@46.0.1': |   '@ckeditor/ckeditor5-operations-compressor@46.0.1': | ||||||
|     dependencies: |     dependencies: | ||||||
| @@ -17548,8 +17547,6 @@ snapshots: | |||||||
|       '@ckeditor/ckeditor5-utils': 46.0.1 |       '@ckeditor/ckeditor5-utils': 46.0.1 | ||||||
|       '@ckeditor/ckeditor5-widget': 46.0.1 |       '@ckeditor/ckeditor5-widget': 46.0.1 | ||||||
|       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) |       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) | ||||||
|     transitivePeerDependencies: |  | ||||||
|       - supports-color |  | ||||||
| 
 | 
 | ||||||
|   '@ckeditor/ckeditor5-pagination@46.0.1': |   '@ckeditor/ckeditor5-pagination@46.0.1': | ||||||
|     dependencies: |     dependencies: | ||||||
| @@ -17576,8 +17573,6 @@ snapshots: | |||||||
|       '@ckeditor/ckeditor5-paste-from-office': 46.0.1 |       '@ckeditor/ckeditor5-paste-from-office': 46.0.1 | ||||||
|       '@ckeditor/ckeditor5-utils': 46.0.1 |       '@ckeditor/ckeditor5-utils': 46.0.1 | ||||||
|       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) |       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) | ||||||
|     transitivePeerDependencies: |  | ||||||
|       - supports-color |  | ||||||
| 
 | 
 | ||||||
|   '@ckeditor/ckeditor5-paste-from-office@46.0.1': |   '@ckeditor/ckeditor5-paste-from-office@46.0.1': | ||||||
|     dependencies: |     dependencies: | ||||||
| @@ -17585,8 +17580,6 @@ snapshots: | |||||||
|       '@ckeditor/ckeditor5-core': 46.0.1 |       '@ckeditor/ckeditor5-core': 46.0.1 | ||||||
|       '@ckeditor/ckeditor5-engine': 46.0.1 |       '@ckeditor/ckeditor5-engine': 46.0.1 | ||||||
|       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) |       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)': |   '@ckeditor/ckeditor5-real-time-collaboration@46.0.1(bufferutil@4.0.9)(utf-8-validate@6.0.5)': | ||||||
|     dependencies: |     dependencies: | ||||||
| @@ -17617,8 +17610,6 @@ snapshots: | |||||||
|       '@ckeditor/ckeditor5-ui': 46.0.1 |       '@ckeditor/ckeditor5-ui': 46.0.1 | ||||||
|       '@ckeditor/ckeditor5-utils': 46.0.1 |       '@ckeditor/ckeditor5-utils': 46.0.1 | ||||||
|       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) |       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) | ||||||
|     transitivePeerDependencies: |  | ||||||
|       - supports-color |  | ||||||
| 
 | 
 | ||||||
|   '@ckeditor/ckeditor5-restricted-editing@46.0.1': |   '@ckeditor/ckeditor5-restricted-editing@46.0.1': | ||||||
|     dependencies: |     dependencies: | ||||||
| @@ -17628,8 +17619,6 @@ snapshots: | |||||||
|       '@ckeditor/ckeditor5-ui': 46.0.1 |       '@ckeditor/ckeditor5-ui': 46.0.1 | ||||||
|       '@ckeditor/ckeditor5-utils': 46.0.1 |       '@ckeditor/ckeditor5-utils': 46.0.1 | ||||||
|       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) |       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) | ||||||
|     transitivePeerDependencies: |  | ||||||
|       - supports-color |  | ||||||
| 
 | 
 | ||||||
|   '@ckeditor/ckeditor5-revision-history@46.0.1': |   '@ckeditor/ckeditor5-revision-history@46.0.1': | ||||||
|     dependencies: |     dependencies: | ||||||
| @@ -17664,8 +17653,6 @@ snapshots: | |||||||
|       '@ckeditor/ckeditor5-ui': 46.0.1 |       '@ckeditor/ckeditor5-ui': 46.0.1 | ||||||
|       '@ckeditor/ckeditor5-utils': 46.0.1 |       '@ckeditor/ckeditor5-utils': 46.0.1 | ||||||
|       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) |       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) | ||||||
|     transitivePeerDependencies: |  | ||||||
|       - supports-color |  | ||||||
| 
 | 
 | ||||||
|   '@ckeditor/ckeditor5-slash-command@46.0.1': |   '@ckeditor/ckeditor5-slash-command@46.0.1': | ||||||
|     dependencies: |     dependencies: | ||||||
| @@ -17678,8 +17665,6 @@ snapshots: | |||||||
|       '@ckeditor/ckeditor5-ui': 46.0.1 |       '@ckeditor/ckeditor5-ui': 46.0.1 | ||||||
|       '@ckeditor/ckeditor5-utils': 46.0.1 |       '@ckeditor/ckeditor5-utils': 46.0.1 | ||||||
|       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) |       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) | ||||||
|     transitivePeerDependencies: |  | ||||||
|       - supports-color |  | ||||||
| 
 | 
 | ||||||
|   '@ckeditor/ckeditor5-source-editing-enhanced@46.0.1': |   '@ckeditor/ckeditor5-source-editing-enhanced@46.0.1': | ||||||
|     dependencies: |     dependencies: | ||||||
| @@ -17706,8 +17691,6 @@ snapshots: | |||||||
|       '@ckeditor/ckeditor5-ui': 46.0.1 |       '@ckeditor/ckeditor5-ui': 46.0.1 | ||||||
|       '@ckeditor/ckeditor5-utils': 46.0.1 |       '@ckeditor/ckeditor5-utils': 46.0.1 | ||||||
|       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) |       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) | ||||||
|     transitivePeerDependencies: |  | ||||||
|       - supports-color |  | ||||||
| 
 | 
 | ||||||
|   '@ckeditor/ckeditor5-special-characters@46.0.1': |   '@ckeditor/ckeditor5-special-characters@46.0.1': | ||||||
|     dependencies: |     dependencies: | ||||||
| @@ -17717,8 +17700,6 @@ snapshots: | |||||||
|       '@ckeditor/ckeditor5-ui': 46.0.1 |       '@ckeditor/ckeditor5-ui': 46.0.1 | ||||||
|       '@ckeditor/ckeditor5-utils': 46.0.1 |       '@ckeditor/ckeditor5-utils': 46.0.1 | ||||||
|       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) |       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) | ||||||
|     transitivePeerDependencies: |  | ||||||
|       - supports-color |  | ||||||
| 
 | 
 | ||||||
|   '@ckeditor/ckeditor5-style@46.0.1': |   '@ckeditor/ckeditor5-style@46.0.1': | ||||||
|     dependencies: |     dependencies: | ||||||
| @@ -17731,8 +17712,6 @@ snapshots: | |||||||
|       '@ckeditor/ckeditor5-utils': 46.0.1 |       '@ckeditor/ckeditor5-utils': 46.0.1 | ||||||
|       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) |       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) | ||||||
|       es-toolkit: 1.39.5 |       es-toolkit: 1.39.5 | ||||||
|     transitivePeerDependencies: |  | ||||||
|       - supports-color |  | ||||||
| 
 | 
 | ||||||
|   '@ckeditor/ckeditor5-table@46.0.1': |   '@ckeditor/ckeditor5-table@46.0.1': | ||||||
|     dependencies: |     dependencies: | ||||||
| @@ -17745,8 +17724,6 @@ snapshots: | |||||||
|       '@ckeditor/ckeditor5-widget': 46.0.1 |       '@ckeditor/ckeditor5-widget': 46.0.1 | ||||||
|       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) |       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) | ||||||
|       es-toolkit: 1.39.5 |       es-toolkit: 1.39.5 | ||||||
|     transitivePeerDependencies: |  | ||||||
|       - supports-color |  | ||||||
| 
 | 
 | ||||||
|   '@ckeditor/ckeditor5-template@46.0.1': |   '@ckeditor/ckeditor5-template@46.0.1': | ||||||
|     dependencies: |     dependencies: | ||||||
| @@ -17857,8 +17834,6 @@ snapshots: | |||||||
|       '@ckeditor/ckeditor5-engine': 46.0.1 |       '@ckeditor/ckeditor5-engine': 46.0.1 | ||||||
|       '@ckeditor/ckeditor5-utils': 46.0.1 |       '@ckeditor/ckeditor5-utils': 46.0.1 | ||||||
|       es-toolkit: 1.39.5 |       es-toolkit: 1.39.5 | ||||||
|     transitivePeerDependencies: |  | ||||||
|       - supports-color |  | ||||||
| 
 | 
 | ||||||
|   '@ckeditor/ckeditor5-widget@46.0.1': |   '@ckeditor/ckeditor5-widget@46.0.1': | ||||||
|     dependencies: |     dependencies: | ||||||
| @@ -17878,8 +17853,6 @@ snapshots: | |||||||
|       '@ckeditor/ckeditor5-utils': 46.0.1 |       '@ckeditor/ckeditor5-utils': 46.0.1 | ||||||
|       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) |       ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) | ||||||
|       es-toolkit: 1.39.5 |       es-toolkit: 1.39.5 | ||||||
|     transitivePeerDependencies: |  | ||||||
|       - supports-color |  | ||||||
| 
 | 
 | ||||||
|   '@codemirror/autocomplete@6.18.6': |   '@codemirror/autocomplete@6.18.6': | ||||||
|     dependencies: |     dependencies: | ||||||
| @@ -29514,6 +29487,8 @@ snapshots: | |||||||
| 
 | 
 | ||||||
|   perfect-freehand@1.2.0: {} |   perfect-freehand@1.2.0: {} | ||||||
| 
 | 
 | ||||||
|  |   photoswipe@5.4.4: {} | ||||||
|  | 
 | ||||||
|   pica@7.1.1: |   pica@7.1.1: | ||||||
|     dependencies: |     dependencies: | ||||||
|       glur: 1.1.2 |       glur: 1.1.2 | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user