Compare commits

...

2 Commits

Author SHA1 Message Date
perf3ct
5024e27885 feat(client): implement photoswipe at different points 2025-08-16 17:29:25 +00:00
perfectra1n
78c27dbe04 feat(client): create a better image viewing experience 2025-08-14 12:21:58 -07:00
31 changed files with 13451 additions and 101 deletions

View File

@@ -55,7 +55,8 @@
"split.js": "1.6.5",
"svg-pan-zoom": "3.6.2",
"tabulator-tables": "6.3.1",
"vanilla-js-wheel-zoom": "9.0.4"
"vanilla-js-wheel-zoom": "9.0.4",
"photoswipe": "^5.4.4"
},
"devDependencies": {
"@ckeditor/ckeditor5-inspector": "5.0.0",

View File

@@ -13,6 +13,8 @@ import type ElectronRemote from "@electron/remote";
import type Electron from "electron";
import "./stylesheets/bootstrap.scss";
import "boxicons/css/boxicons.min.css";
import "./stylesheets/media-viewer.css";
import "./styles/gallery.css";
import "autocomplete.js/index_jquery.js";
await appContext.earlyInit();

View File

@@ -2,6 +2,8 @@ import { t } from "../services/i18n.js";
import utils from "../services/utils.js";
import contextMenu from "./context_menu.js";
import imageService from "../services/image.js";
import mediaViewer from "../services/media_viewer.js";
import type { MediaItem } from "../services/media_viewer.js";
const PROP_NAME = "imageContextMenuInstalled";
@@ -18,6 +20,12 @@ function setupContextMenu($image: JQuery<HTMLElement>) {
x: e.pageX,
y: e.pageY,
items: [
{
title: "View in Lightbox",
command: "viewInLightbox",
uiIcon: "bx bx-expand",
enabled: true
},
{
title: t("image_context_menu.copy_reference_to_clipboard"),
command: "copyImageReferenceToClipboard",
@@ -30,7 +38,48 @@ function setupContextMenu($image: JQuery<HTMLElement>) {
}
],
selectMenuItemHandler: async ({ command }) => {
if (command === "copyImageReferenceToClipboard") {
if (command === "viewInLightbox") {
const src = $image.attr("src");
const alt = $image.attr("alt");
const title = $image.attr("title");
if (!src) {
console.error("Missing image source");
return;
}
const item: MediaItem = {
src: src,
alt: alt || "Image",
title: title || alt,
element: $image[0] as HTMLElement
};
// Try to get actual dimensions
const imgElement = $image[0] as HTMLImageElement;
if (imgElement.naturalWidth && imgElement.naturalHeight) {
item.width = imgElement.naturalWidth;
item.height = imgElement.naturalHeight;
}
mediaViewer.openSingle(item, {
bgOpacity: 0.95,
showHideOpacity: true,
pinchToClose: true,
closeOnScroll: false,
closeOnVerticalDrag: true,
wheelToZoom: true,
getThumbBoundsFn: () => {
// Get position for zoom animation
const rect = imgElement.getBoundingClientRect();
return {
x: rect.left,
y: rect.top,
w: rect.width
};
}
});
} else if (command === "copyImageReferenceToClipboard") {
imageService.copyImageReferenceToClipboard($image);
} else if (command === "copyImageToClipboard") {
try {

View File

@@ -3,6 +3,7 @@ import noteAutocompleteService from "./services/note_autocomplete.js";
import glob from "./services/glob.js";
import "./stylesheets/bootstrap.scss";
import "boxicons/css/boxicons.min.css";
import "./stylesheets/media-viewer.css";
import "autocomplete.js/index_jquery.js";
glob.setupGlobs();

View 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();

View 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);
});
});
});

View 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();

View 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();

View 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();

View 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();

View 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
};

View 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();

View 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();

View 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();

View 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();
});
});
});

File diff suppressed because it is too large Load Diff

View 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;
}
}

View 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);
}

View 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;
}
}

View File

@@ -9,6 +9,9 @@ import contentRenderer from "../services/content_renderer.js";
import toastService from "../services/toast.js";
import type FAttachment from "../entities/fattachment.js";
import type { EventData } from "../components/app_context.js";
import appContext from "../components/app_context.js";
import mediaViewer from "../services/media_viewer.js";
import type { MediaItem } from "../services/media_viewer.js";
const TPL = /*html*/`
<div class="attachment-detail-widget">
@@ -65,6 +68,12 @@ const TPL = /*html*/`
.attachment-content-wrapper img {
margin: 10px;
cursor: zoom-in;
transition: opacity 0.2s;
}
.attachment-content-wrapper img:hover {
opacity: 0.9;
}
.attachment-detail-wrapper.list-view .attachment-content-wrapper img, .attachment-detail-wrapper.list-view .attachment-content-wrapper video {
@@ -77,6 +86,24 @@ const TPL = /*html*/`
max-width: 90%;
object-fit: contain;
}
.attachment-lightbox-hint {
position: absolute;
top: 10px;
right: 10px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 5px 10px;
border-radius: 4px;
font-size: 12px;
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
}
.attachment-content-wrapper:hover .attachment-lightbox-hint {
opacity: 1;
}
.attachment-detail-wrapper.scheduled-for-deletion .attachment-content-wrapper img {
filter: contrast(10%);
@@ -88,7 +115,9 @@ const TPL = /*html*/`
<div class="attachment-actions-container"></div>
<h4 class="attachment-title"></h4>
<div class="attachment-details"></div>
<div style="flex: 1 1;"></div>
<button class="btn btn-sm back-to-note-btn" style="margin-left: auto;" title="Back to Note">
<span class="bx bx-arrow-back"></span> Back to Note
</button>
</div>
<div class="attachment-deletion-warning alert alert-info" style="margin-top: 15px;"></div>
@@ -124,6 +153,14 @@ export default class AttachmentDetailWidget extends BasicWidget {
this.$widget.find(".attachment-detail-wrapper").empty().append($(TPL).find(".attachment-detail-wrapper").html());
this.$wrapper = this.$widget.find(".attachment-detail-wrapper");
this.$wrapper.addClass(this.isFullDetail ? "full-detail" : "list-view");
// Setup back to note button (only show in full detail mode)
if (this.isFullDetail) {
const $backBtn = this.$wrapper.find('.back-to-note-btn');
$backBtn.on('click', () => this.handleBackToNote());
} else {
this.$wrapper.find('.back-to-note-btn').hide();
}
if (!this.isFullDetail) {
const $link = await linkService.createLink(this.attachment.ownerId, {
@@ -170,7 +207,92 @@ export default class AttachmentDetailWidget extends BasicWidget {
this.$wrapper.find(".attachment-actions-container").append(this.attachmentActionsWidget.render());
const { $renderedContent } = await contentRenderer.getRenderedContent(this.attachment, { imageHasZoom: this.isFullDetail });
this.$wrapper.find(".attachment-content-wrapper").append($renderedContent);
const $contentWrapper = this.$wrapper.find(".attachment-content-wrapper");
$contentWrapper.append($renderedContent);
// Add PhotoSwipe integration for image attachments
if (this.attachment.role === 'image') {
this.setupPhotoSwipeIntegration($contentWrapper);
}
}
setupPhotoSwipeIntegration($contentWrapper: JQuery<HTMLElement>) {
// Add lightbox hint
const $hint = $('<div class="attachment-lightbox-hint">Click to view in lightbox</div>');
$contentWrapper.css('position', 'relative').append($hint);
// Find the image element
const $img = $contentWrapper.find('img');
if (!$img.length) return;
// Setup click handler for lightbox with namespace for proper cleanup
$img.off('click.photoswipe').on('click.photoswipe', (e) => {
e.preventDefault();
e.stopPropagation();
const item: MediaItem = {
src: $img.attr('src') || '',
alt: this.attachment.title,
title: this.attachment.title,
noteId: this.attachment.ownerId,
element: $img[0] as HTMLElement
};
// Try to get actual dimensions
const imgElement = $img[0] as HTMLImageElement;
if (imgElement.naturalWidth && imgElement.naturalHeight) {
item.width = imgElement.naturalWidth;
item.height = imgElement.naturalHeight;
}
mediaViewer.openSingle(item, {
bgOpacity: 0.95,
showHideOpacity: true,
pinchToClose: true,
closeOnScroll: false,
closeOnVerticalDrag: true,
wheelToZoom: true,
getThumbBoundsFn: () => {
// Get position for zoom animation
const rect = imgElement.getBoundingClientRect();
return {
x: rect.left,
y: rect.top,
w: rect.width
};
}
}, {
onOpen: () => {
console.log('Attachment image opened in lightbox');
},
onClose: () => {
// Check if we're in attachment detail view and reset viewScope if needed
const activeContext = appContext.tabManager.getActiveContext();
if (activeContext?.viewScope?.viewMode === 'attachments' &&
activeContext?.viewScope?.attachmentId === this.attachment.attachmentId) {
// Reset to normal note view when closing lightbox from attachment detail
activeContext.setNote(this.attachment.ownerId, {
viewScope: { viewMode: 'default' }
});
}
// Restore focus to the image
$img.focus();
}
});
});
// Add keyboard support
$img.attr('tabindex', '0')
.attr('role', 'button')
.attr('aria-label', 'Click to view in lightbox');
// Use namespaced event for proper cleanup
$img.off('keydown.photoswipe').on('keydown.photoswipe', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
$img.trigger('click');
}
});
}
async copyAttachmentLinkToClipboard() {
@@ -204,4 +326,43 @@ export default class AttachmentDetailWidget extends BasicWidget {
}
}
}
async handleBackToNote() {
try {
const activeContext = appContext.tabManager.getActiveContext();
if (!activeContext) {
console.warn('No active context available for navigation');
return;
}
if (!this.attachment.ownerId) {
console.error('Cannot navigate back: no owner ID available');
return;
}
await activeContext.setNote(this.attachment.ownerId, {
viewScope: { viewMode: 'default' }
});
} catch (error) {
console.error('Failed to navigate back to note:', error);
toastService.showError('Failed to navigate back to note');
}
}
cleanup() {
// Remove all event handlers before cleanup
const $contentWrapper = this.$wrapper?.find('.attachment-content-wrapper');
if ($contentWrapper?.length) {
const $img = $contentWrapper.find('img');
if ($img.length) {
// Remove namespaced event handlers
$img.off('.photoswipe');
}
}
// Remove back button handler
this.$wrapper?.find('.back-to-note-btn').off('click');
super.cleanup();
}
}

View 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();
}
}

View 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 = `![](api/images/${item.noteId}/view)`;
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;

View File

@@ -6,6 +6,7 @@ import contentRenderer from "../../services/content_renderer.js";
import utils from "../../services/utils.js";
import options from "../../services/options.js";
import attributes from "../../services/attributes.js";
import ckeditorPhotoswipeIntegration from "../../services/ckeditor_photoswipe_integration.js";
export default class AbstractTextTypeWidget extends TypeWidget {
doRender() {
@@ -35,7 +36,29 @@ export default class AbstractTextTypeWidget extends TypeWidget {
const parsedImage = await this.parseFromImage($img);
if (parsedImage) {
appContext.tabManager.getActiveContext()?.setNote(parsedImage.noteId, { viewScope: parsedImage.viewScope });
// Check if this is an attachment image and PhotoSwipe is available
if (parsedImage.viewScope?.attachmentId) {
// Instead of navigating to attachment detail, trigger PhotoSwipe
// Check if the image is already processed by PhotoSwipe
const imgElement = $img[0] as HTMLImageElement;
// Check if PhotoSwipe is integrated with this image using multiple reliable indicators
const hasPhotoSwipe = imgElement.classList.contains('photoswipe-enabled') ||
imgElement.hasAttribute('data-photoswipe') ||
imgElement.style.cursor === 'zoom-in';
if (hasPhotoSwipe) {
// Image has PhotoSwipe integration, trigger click to open lightbox
$img.trigger('click');
return;
}
// Otherwise, fall back to opening attachment detail (but with improved navigation)
appContext.tabManager.getActiveContext()?.setNote(parsedImage.noteId, { viewScope: parsedImage.viewScope });
} else {
// Regular note image, navigate normally
appContext.tabManager.getActiveContext()?.setNote(parsedImage.noteId, { viewScope: parsedImage.viewScope });
}
} else {
window.open($img.prop("src"), "_blank");
}

View File

@@ -4,6 +4,8 @@ import linkService from "../../services/link.js";
import utils from "../../services/utils.js";
import { t } from "../../services/i18n.js";
import type { EventData } from "../../components/app_context.js";
import galleryManager from "../../services/gallery_manager.js";
import type { GalleryItem } from "../../services/gallery_manager.js";
const TPL = /*html*/`
<div class="attachment-list note-detail-printable">
@@ -20,17 +22,81 @@ const TPL = /*html*/`
justify-content: space-between;
align-items: baseline;
}
.attachment-list .gallery-toolbar {
display: flex;
gap: 5px;
margin-bottom: 10px;
}
.attachment-list .gallery-toolbar button {
padding: 5px 10px;
font-size: 12px;
}
.attachment-list .image-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px;
margin-bottom: 20px;
}
.attachment-list .image-grid .image-thumbnail {
position: relative;
width: 100%;
padding-bottom: 100%; /* 1:1 aspect ratio */
overflow: hidden;
border-radius: 4px;
cursor: pointer;
background: var(--accented-background-color);
}
.attachment-list .image-grid .image-thumbnail img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.2s;
}
.attachment-list .image-grid .image-thumbnail:hover img {
transform: scale(1.05);
}
.attachment-list .image-grid .image-thumbnail .overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(to top, rgba(0,0,0,0.7), transparent);
color: white;
padding: 5px;
font-size: 11px;
opacity: 0;
transition: opacity 0.2s;
}
.attachment-list .image-grid .image-thumbnail:hover .overlay {
opacity: 1;
}
</style>
<div class="links-wrapper"></div>
<div class="gallery-toolbar" style="display: none;"></div>
<div class="image-grid" style="display: none;"></div>
<div class="attachment-list-wrapper"></div>
</div>`;
export default class AttachmentListTypeWidget extends TypeWidget {
$list!: JQuery<HTMLElement>;
$linksWrapper!: JQuery<HTMLElement>;
$galleryToolbar!: JQuery<HTMLElement>;
$imageGrid!: JQuery<HTMLElement>;
renderedAttachmentIds!: Set<string>;
imageAttachments: GalleryItem[] = [];
otherAttachments: any[] = [];
static getType() {
return "attachmentList";
@@ -40,6 +106,8 @@ export default class AttachmentListTypeWidget extends TypeWidget {
this.$widget = $(TPL);
this.$list = this.$widget.find(".attachment-list-wrapper");
this.$linksWrapper = this.$widget.find(".links-wrapper");
this.$galleryToolbar = this.$widget.find(".gallery-toolbar");
this.$imageGrid = this.$widget.find(".image-grid");
super.doRender();
}
@@ -75,8 +143,12 @@ export default class AttachmentListTypeWidget extends TypeWidget {
);
this.$list.empty();
this.$imageGrid.empty().hide();
this.$galleryToolbar.empty().hide();
this.children = [];
this.renderedAttachmentIds = new Set();
this.imageAttachments = [];
this.otherAttachments = [];
const attachments = await note.getAttachments();
@@ -85,17 +157,122 @@ export default class AttachmentListTypeWidget extends TypeWidget {
return;
}
// Separate image and non-image attachments
for (const attachment of attachments) {
if (attachment.role === 'image') {
const galleryItem: GalleryItem = {
src: `/api/attachments/${attachment.attachmentId}/image`,
alt: attachment.title,
title: attachment.title,
attachmentId: attachment.attachmentId,
noteId: attachment.ownerId,
index: this.imageAttachments.length
};
this.imageAttachments.push(galleryItem);
} else {
this.otherAttachments.push(attachment);
}
}
// If we have image attachments, show gallery view
if (this.imageAttachments.length > 0) {
this.setupGalleryView();
}
// Render non-image attachments in the traditional list
for (const attachment of this.otherAttachments) {
const attachmentDetailWidget = new AttachmentDetailWidget(attachment, false);
this.child(attachmentDetailWidget);
this.renderedAttachmentIds.add(attachment.attachmentId);
this.$list.append(attachmentDetailWidget.render());
}
}
setupGalleryView() {
// Show gallery toolbar
this.$galleryToolbar.show();
// Add gallery action buttons
const $viewAllButton = $(`
<button class="btn btn-sm view-gallery-btn">
<span class="bx bx-images"></span>
View as Gallery (${this.imageAttachments.length} images)
</button>
`);
const $slideshowButton = $(`
<button class="btn btn-sm slideshow-btn">
<span class="bx bx-play-circle"></span>
Start Slideshow
</button>
`);
this.$galleryToolbar.append($viewAllButton, $slideshowButton);
// Handle gallery view button
$viewAllButton.on('click', () => {
galleryManager.openGallery(this.imageAttachments, 0, {
showThumbnails: true,
showCounter: true,
enableKeyboardNav: true,
loop: true
});
});
// Handle slideshow button
$slideshowButton.on('click', () => {
galleryManager.openGallery(this.imageAttachments, 0, {
showThumbnails: false,
autoPlay: true,
slideInterval: 4000,
showCounter: true,
loop: true
});
});
// Create image grid
this.$imageGrid.show();
this.imageAttachments.forEach((item, index) => {
const $thumbnail = $(`
<div class="image-thumbnail"
data-index="${index}"
role="button"
tabindex="0"
aria-label="View ${item.alt || item.title || 'image'} in gallery">
<img src="${item.src}"
alt="${item.alt || item.title || `Image ${index + 1}`}"
loading="lazy"
aria-describedby="thumb-desc-${index}">
<div class="overlay" id="thumb-desc-${index}">${item.title || ''}</div>
</div>
`);
// Add click handler
$thumbnail.on('click', () => {
galleryManager.openGallery(this.imageAttachments, index, {
showThumbnails: true,
showCounter: true,
enableKeyboardNav: true
});
});
// Add keyboard support for accessibility
$thumbnail.on('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
galleryManager.openGallery(this.imageAttachments, index, {
showThumbnails: true,
showCounter: true,
enableKeyboardNav: true
});
}
});
this.$imageGrid.append($thumbnail);
});
}
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
// updates and deletions are handled by the detail, for new attachments the whole list has to be refreshed
const attachmentsAdded = loadResults.getAttachmentRows().some((att) => att.attachmentId && !this.renderedAttachmentIds.has(att.attachmentId));
@@ -104,4 +281,16 @@ export default class AttachmentListTypeWidget extends TypeWidget {
this.refresh();
}
}
cleanup() {
// Clean up event handlers
if (this.$galleryToolbar) {
this.$galleryToolbar.find('button').off();
}
if (this.$imageGrid) {
this.$imageGrid.find('.image-thumbnail').off();
}
super.cleanup();
}
}

View File

@@ -14,6 +14,7 @@ import type FNote from "../../entities/fnote.js";
import { PopupEditor, ClassicEditor, EditorWatchdog, type CKTextEditor, type MentionFeed, type WatchdogConfig, EditorConfig } from "@triliumnext/ckeditor5";
import "@triliumnext/ckeditor5/index.css";
import { updateTemplateCache } from "./ckeditor/snippets.js";
import ckeditorPhotoSwipe from "../../services/ckeditor_photoswipe_integration.js";
const TPL = /*html*/`
<div class="note-detail-editable-text note-detail-printable">
@@ -162,6 +163,19 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
isClassicEditor
};
const editor = await buildEditor(this.$editor[0], isClassicEditor, opts);
// Setup PhotoSwipe integration for images in the editor
setTimeout(() => {
const editorElement = this.$editor[0];
if (editorElement) {
ckeditorPhotoSwipe.setupContainer(editorElement, {
enableGalleryMode: true,
showHints: true,
hintDelay: 2000,
excludeSelector: '.cke_widget_element, .ck-widget'
});
}
}, 100);
const notificationsPlugin = editor.plugins.get("Notification");
notificationsPlugin.on("show:warning", (evt, data) => {
@@ -291,11 +305,25 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
}
cleanup() {
// Cleanup PhotoSwipe integration
if (this.$editor?.[0]) {
ckeditorPhotoSwipe.cleanupContainer(this.$editor[0]);
}
if (this.watchdog?.editor) {
this.spacedUpdate.allowUpdateWithoutChange(() => {
this.watchdog.editor?.setData("");
});
}
// Destroy the watchdog to clean up all CKEditor resources
if (this.watchdog) {
this.watchdog.destroy().catch((error: any) => {
console.error('Error destroying CKEditor watchdog:', error);
});
}
super.cleanup();
}
insertDateTimeToTextCommand() {

View File

@@ -1,5 +1,5 @@
import openService from "../../services/open.js";
import TypeWidget from "./type_widget.js";
import { ImageViewerBase } from "./image_viewer_base.js";
import { t } from "../../services/i18n.js";
import type { EventData } from "../../components/app_context.js";
import type FNote from "../../entities/fnote.js";
@@ -23,7 +23,8 @@ const TPL = /*html*/`
}
.note-detail.full-height .note-detail-file[data-preview-type="pdf"],
.note-detail.full-height .note-detail-file[data-preview-type="video"] {
.note-detail.full-height .note-detail-file[data-preview-type="video"],
.note-detail.full-height .note-detail-file[data-preview-type="image"] {
overflow: hidden;
}
@@ -39,6 +40,133 @@ const TPL = /*html*/`
width: 100%;
height: 100%;
}
.image-file-preview {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
position: relative;
overflow: hidden;
}
.image-file-view {
max-width: 100%;
max-height: 90%;
cursor: zoom-in;
transition: opacity 0.2s ease;
}
.image-file-view:hover {
opacity: 0.95;
}
.image-file-controls {
position: absolute;
bottom: 20px;
right: 20px;
display: flex;
gap: 10px;
background: rgba(0, 0, 0, 0.6);
border-radius: 8px;
padding: 8px;
z-index: 10;
}
.image-file-control-btn {
background: rgba(255, 255, 255, 0.9);
border: none;
border-radius: 4px;
min-width: 44px;
min-height: 44px;
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.2s;
}
.image-file-control-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 1);
}
.image-file-control-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.image-file-control-btn i {
font-size: 20px;
color: #333;
}
.image-file-info {
position: absolute;
top: 10px;
left: 10px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 8px 12px;
border-radius: 4px;
font-size: 12px;
z-index: 10;
}
/* Loading indicator */
.image-loading-indicator {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 100;
}
/* Zoom indicator */
.zoom-indicator {
position: absolute;
bottom: 80px;
right: 20px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
z-index: 10;
pointer-events: none;
}
/* Mobile optimizations */
@media (max-width: 768px) {
.image-file-controls {
bottom: 10px;
right: 10px;
padding: 6px;
gap: 8px;
}
.image-file-info {
font-size: 11px;
padding: 6px 10px;
}
}
/* High contrast mode support */
@media (prefers-contrast: high) {
.image-file-control-btn {
border: 2px solid currentColor;
}
}
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
.image-file-view,
.image-file-control-btn {
transition: none;
}
}
</style>
<div class="file-preview-too-big alert alert-info hidden-ext">
@@ -56,21 +184,66 @@ const TPL = /*html*/`
<video class="video-preview" controls></video>
<audio class="audio-preview" controls></audio>
<div class="image-file-preview" style="display: none;">
<div class="image-file-info">
<span class="image-dimensions"></span>
</div>
<img class="image-file-view" />
<div class="image-file-controls">
<button class="image-file-control-btn zoom-in" type="button" aria-label="Zoom In" title="Zoom In (+ key)">
<i class="bx bx-zoom-in" aria-hidden="true"></i>
</button>
<button class="image-file-control-btn zoom-out" type="button" aria-label="Zoom Out" title="Zoom Out (- key)">
<i class="bx bx-zoom-out" aria-hidden="true"></i>
</button>
<button class="image-file-control-btn reset-zoom" type="button" aria-label="Reset Zoom" title="Reset Zoom (0 key or double-click)">
<i class="bx bx-reset" aria-hidden="true"></i>
</button>
<button class="image-file-control-btn fullscreen" type="button" aria-label="Open in Lightbox" title="Open in Lightbox (Enter or Space key)">
<i class="bx bx-fullscreen" aria-hidden="true"></i>
</button>
<button class="image-file-control-btn download" type="button" aria-label="Download" title="Download File">
<i class="bx bx-download" aria-hidden="true"></i>
</button>
</div>
</div>
</div>`;
export default class FileTypeWidget extends TypeWidget {
export default class FileTypeWidget extends ImageViewerBase {
private $previewContent!: JQuery<HTMLElement>;
private $previewNotAvailable!: JQuery<HTMLElement>;
private $previewTooBig!: JQuery<HTMLElement>;
private $pdfPreview!: JQuery<HTMLElement>;
private $videoPreview!: JQuery<HTMLElement>;
private $audioPreview!: JQuery<HTMLElement>;
private $imageFilePreview!: JQuery<HTMLElement>;
private $imageFileView!: JQuery<HTMLElement>;
private $imageDimensions!: JQuery<HTMLElement>;
private $fullscreenBtn!: JQuery<HTMLElement>;
private $downloadBtn!: JQuery<HTMLElement>;
private $zoomInBtn!: JQuery<HTMLElement>;
private $zoomOutBtn!: JQuery<HTMLElement>;
private $resetZoomBtn!: JQuery<HTMLElement>;
private wheelHandler?: (e: JQuery.TriggeredEvent) => void;
private currentPreviewType?: string;
static getType() {
return "file";
}
constructor() {
super();
// Apply custom configuration for file viewer
this.applyConfig({
minZoom: 0.5,
maxZoom: 5,
zoomStep: 0.25,
debounceDelay: 16,
touchTargetSize: 44
});
}
doRender() {
this.$widget = $(TPL);
this.$previewContent = this.$widget.find(".file-preview-content");
@@ -79,60 +252,204 @@ export default class FileTypeWidget extends TypeWidget {
this.$pdfPreview = this.$widget.find(".pdf-preview");
this.$videoPreview = this.$widget.find(".video-preview");
this.$audioPreview = this.$widget.find(".audio-preview");
this.$imageFilePreview = this.$widget.find(".image-file-preview");
this.$imageFileView = this.$widget.find(".image-file-view");
this.$imageDimensions = this.$widget.find(".image-dimensions");
// Image controls
this.$zoomInBtn = this.$widget.find(".zoom-in");
this.$zoomOutBtn = this.$widget.find(".zoom-out");
this.$resetZoomBtn = this.$widget.find(".reset-zoom");
this.$fullscreenBtn = this.$widget.find(".fullscreen");
this.$downloadBtn = this.$widget.find(".download");
// Set image wrapper and view for base class
this.$imageWrapper = this.$imageFilePreview;
this.$imageView = this.$imageFileView;
this.setupImageControls();
super.doRender();
}
private setupImageControls(): void {
// Image click to open lightbox
this.$imageFileView?.on("click", (e) => {
e.preventDefault();
this.openImageInLightbox();
});
// Control button handlers
this.$zoomInBtn?.on("click", () => this.zoomIn());
this.$zoomOutBtn?.on("click", () => this.zoomOut());
this.$resetZoomBtn?.on("click", () => this.resetZoom());
this.$fullscreenBtn?.on("click", () => this.openImageInLightbox());
this.$downloadBtn?.on("click", () => this.downloadFile());
// Mouse wheel zoom with focus check
this.wheelHandler = (e: JQuery.TriggeredEvent) => {
// Only handle if image preview is visible and has focus
if (!this.$imageFilePreview?.is(':visible') || !this.$widget?.is(':focus-within')) {
return;
}
e.preventDefault();
const originalEvent = e.originalEvent as WheelEvent | undefined;
const delta = originalEvent?.deltaY;
if (delta) {
if (delta < 0) {
this.zoomIn();
} else {
this.zoomOut();
}
}
};
this.$imageFilePreview?.on("wheel", this.wheelHandler);
}
async doRefresh(note: FNote) {
this.$widget.show();
this.$widget?.show();
const blob = await this.note?.getBlob();
this.$previewContent.empty().hide();
this.$pdfPreview.attr("src", "").empty().hide();
this.$previewNotAvailable.hide();
this.$previewTooBig.addClass("hidden-ext");
this.$videoPreview.hide();
this.$audioPreview.hide();
// Hide all preview types
this.$previewContent?.empty().hide();
this.$pdfPreview?.attr("src", "").empty().hide();
this.$previewNotAvailable?.hide();
this.$previewTooBig?.addClass("hidden-ext");
this.$videoPreview?.hide();
this.$audioPreview?.hide();
this.$imageFilePreview?.hide();
let previewType: string;
if (blob?.content) {
this.$previewContent.show().scrollTop(0);
// Check if this is an image file
if (note.mime.startsWith("image/")) {
this.$imageFilePreview?.show();
const src = openService.getUrlForDownload(`api/notes/${this.noteId}/open`);
// Reset zoom for new image
this.resetZoom();
// Setup pan, keyboard navigation, and other features
this.setupPanFunctionality();
this.setupKeyboardNavigation();
this.setupDoubleClickReset();
this.setupContextMenu();
this.addAccessibilityLabels();
// Load image with loading state and error handling
try {
await this.setupImage(src, this.$imageFileView!);
await this.loadImageDimensions(src);
} catch (error) {
console.error("Failed to load image file:", error);
}
previewType = "image";
} else if (blob?.content) {
this.$previewContent?.show().scrollTop(0);
const trimmedContent = blob.content.substring(0, TEXT_MAX_NUM_CHARS);
if (trimmedContent.length !== blob.content.length) {
this.$previewTooBig.removeClass("hidden-ext");
this.$previewTooBig?.removeClass("hidden-ext");
}
this.$previewContent.text(trimmedContent);
this.$previewContent?.text(trimmedContent);
previewType = "text";
} else if (note.mime === "application/pdf") {
this.$pdfPreview.show().attr("src", openService.getUrlForDownload(`api/notes/${this.noteId}/open`));
this.$pdfPreview?.show().attr("src", openService.getUrlForDownload(`api/notes/${this.noteId}/open`));
previewType = "pdf";
} else if (note.mime.startsWith("video/")) {
this.$videoPreview
.show()
?.show()
.attr("src", openService.getUrlForDownload(`api/notes/${this.noteId}/open-partial`))
.attr("type", this.note?.mime ?? "")
.css("width", this.$widget.width() ?? 0);
.css("width", this.$widget?.width() ?? 0);
previewType = "video";
} else if (note.mime.startsWith("audio/")) {
this.$audioPreview
.show()
?.show()
.attr("src", openService.getUrlForDownload(`api/notes/${this.noteId}/open-partial`))
.attr("type", this.note?.mime ?? "")
.css("width", this.$widget.width() ?? 0);
.css("width", this.$widget?.width() ?? 0);
previewType = "audio";
} else {
this.$previewNotAvailable.show();
this.$previewNotAvailable?.show();
previewType = "not-available";
}
this.$widget.attr("data-preview-type", previewType ?? "");
this.currentPreviewType = previewType;
this.$widget?.attr("data-preview-type", previewType ?? "");
}
private async loadImageDimensions(src: string): Promise<void> {
try {
// Use a new Image object to get dimensions
const img = new Image();
await new Promise<void>((resolve, reject) => {
img.onload = () => {
this.$imageDimensions?.text(`${img.width} × ${img.height}px`);
resolve();
};
img.onerror = () => {
this.$imageDimensions?.text("Image");
reject(new Error("Failed to load image dimensions"));
};
img.src = src;
});
} catch (error) {
console.warn("Failed to get image dimensions:", error);
this.$imageDimensions?.text("Image");
}
}
private openImageInLightbox(): void {
if (!this.note || !this.$imageFileView?.length) return;
const src = this.$imageFileView.attr("src") || this.$imageFileView.prop("src");
if (!src) return;
this.openInLightbox(
src,
this.note.title || "Image File",
this.noteId,
this.$imageFileView.get(0)
);
}
private downloadFile(): void {
if (!this.note) return;
try {
const link = document.createElement('a');
link.href = openService.getUrlForDownload(`api/notes/${this.noteId}/open`);
link.download = this.note.title || 'file';
// Add to document, click, and remove
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (error) {
console.error("Failed to download file:", error);
alert("Failed to download file. Please try again.");
}
}
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
if (loadResults.isNoteReloaded(this.noteId)) {
this.refresh();
await this.refresh();
}
}
}
cleanup() {
// Remove wheel handler if it exists
if (this.wheelHandler && this.$imageFilePreview?.length) {
this.$imageFilePreview.off("wheel", this.wheelHandler);
}
// Call parent cleanup
super.cleanup();
}
}

View File

@@ -1,10 +1,8 @@
import utils from "../../services/utils.js";
import TypeWidget from "./type_widget.js";
import imageContextMenuService from "../../menus/image_context_menu.js";
import { ImageViewerBase } from "./image_viewer_base.js";
import imageService from "../../services/image.js";
import type FNote from "../../entities/fnote.js";
import type { EventData } from "../../components/app_context.js";
import WheelZoom from 'vanilla-js-wheel-zoom';
const TPL = /*html*/`
<div class="note-detail-image note-detail-printable">
@@ -15,6 +13,7 @@ const TPL = /*html*/`
.note-detail-image {
height: 100%;
position: relative;
}
.note-detail-image-wrapper {
@@ -28,53 +27,314 @@ const TPL = /*html*/`
.note-detail-image-view {
display: block;
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
align-self: center;
flex-shrink: 0;
cursor: zoom-in;
transition: opacity 0.2s ease;
}
.note-detail-image-view:hover {
opacity: 0.95;
}
.image-controls {
position: absolute;
bottom: 20px;
right: 20px;
display: flex;
gap: 10px;
z-index: 10;
background: rgba(0, 0, 0, 0.6);
border-radius: 8px;
padding: 8px;
}
.image-control-btn {
background: rgba(255, 255, 255, 0.9);
border: none;
border-radius: 4px;
min-width: 44px;
min-height: 44px;
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.2s;
}
.image-control-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 1);
}
.image-control-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.image-control-btn i {
font-size: 20px;
color: #333;
}
/* Keyboard hints overlay */
.keyboard-hints {
position: absolute;
top: 10px;
right: 10px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 8px 12px;
border-radius: 4px;
font-size: 12px;
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
z-index: 10;
}
.note-detail-image:hover .keyboard-hints {
opacity: 0.8;
}
.keyboard-hints .hint {
margin: 2px 0;
}
.keyboard-hints .key {
background: rgba(255, 255, 255, 0.2);
padding: 2px 6px;
border-radius: 3px;
margin-right: 4px;
font-family: monospace;
}
/* Loading indicator */
.image-loading-indicator {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 100;
}
/* Zoom indicator */
.zoom-indicator {
position: absolute;
bottom: 80px;
right: 20px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
z-index: 10;
pointer-events: none;
}
/* Mobile optimizations */
@media (max-width: 768px) {
.image-controls {
bottom: 10px;
right: 10px;
padding: 6px;
gap: 8px;
}
.keyboard-hints {
display: none;
}
}
/* High contrast mode support */
@media (prefers-contrast: high) {
.image-control-btn {
border: 2px solid currentColor;
}
}
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
.note-detail-image-view,
.image-control-btn {
transition: none;
}
}
</style>
<div class="note-detail-image-wrapper">
<img class="note-detail-image-view" />
</div>
<div class="image-controls">
<button class="image-control-btn zoom-in" type="button" aria-label="Zoom In" title="Zoom In (+ key)">
<i class="bx bx-zoom-in" aria-hidden="true"></i>
</button>
<button class="image-control-btn zoom-out" type="button" aria-label="Zoom Out" title="Zoom Out (- key)">
<i class="bx bx-zoom-out" aria-hidden="true"></i>
</button>
<button class="image-control-btn reset-zoom" type="button" aria-label="Reset Zoom" title="Reset Zoom (0 key or double-click)">
<i class="bx bx-reset" aria-hidden="true"></i>
</button>
<button class="image-control-btn fullscreen" type="button" aria-label="Fullscreen" title="Fullscreen (Enter or Space key)">
<i class="bx bx-fullscreen" aria-hidden="true"></i>
</button>
<button class="image-control-btn download" type="button" aria-label="Download" title="Download Image">
<i class="bx bx-download" aria-hidden="true"></i>
</button>
</div>
<div class="keyboard-hints" aria-hidden="true">
<div class="hint"><span class="key">Click</span> Open lightbox</div>
<div class="hint"><span class="key">Double-click</span> Reset zoom</div>
<div class="hint"><span class="key">Scroll</span> Zoom</div>
<div class="hint"><span class="key">+/-</span> Zoom in/out</div>
<div class="hint"><span class="key">0</span> Reset zoom</div>
<div class="hint"><span class="key">ESC</span> Close lightbox</div>
<div class="hint"><span class="key">Arrow keys</span> Pan (when zoomed)</div>
</div>
</div>`;
class ImageTypeWidget extends TypeWidget {
private $imageWrapper!: JQuery<HTMLElement>;
private $imageView!: JQuery<HTMLElement>;
class ImageTypeWidget extends ImageViewerBase {
private $zoomInBtn!: JQuery<HTMLElement>;
private $zoomOutBtn!: JQuery<HTMLElement>;
private $resetZoomBtn!: JQuery<HTMLElement>;
private $fullscreenBtn!: JQuery<HTMLElement>;
private $downloadBtn!: JQuery<HTMLElement>;
private wheelHandler?: (e: JQuery.TriggeredEvent) => void;
static getType() {
return "image";
}
constructor() {
super();
// Apply custom configuration if needed
this.applyConfig({
minZoom: 0.5,
maxZoom: 5,
zoomStep: 0.25,
debounceDelay: 16,
touchTargetSize: 44
});
}
doRender() {
this.$widget = $(TPL);
this.$imageWrapper = this.$widget.find(".note-detail-image-wrapper");
this.$imageView = this.$widget.find(".note-detail-image-view").attr("id", `image-view-${utils.randomString(10)}`);
this.$imageView = this.$widget.find(".note-detail-image-view");
// Generate unique ID for image element
const imageId = `image-view-${utils.randomString(10)}`;
this.$imageView.attr("id", imageId);
// Get control buttons
this.$zoomInBtn = this.$widget.find(".zoom-in");
this.$zoomOutBtn = this.$widget.find(".zoom-out");
this.$resetZoomBtn = this.$widget.find(".reset-zoom");
this.$fullscreenBtn = this.$widget.find(".fullscreen");
this.$downloadBtn = this.$widget.find(".download");
const initZoom = async () => {
const element = document.querySelector(`#${this.$imageView.attr("id")}`);
if (element) {
WheelZoom.create(`#${this.$imageView.attr("id")}`, {
maxScale: 50,
speed: 1.3,
zoomOnClick: false
});
} else {
requestAnimationFrame(initZoom);
}
};
initZoom();
imageContextMenuService.setupContextMenu(this.$imageView);
this.setupEventHandlers();
this.setupPanFunctionality();
this.setupKeyboardNavigation();
this.setupDoubleClickReset();
this.setupContextMenu();
this.addAccessibilityLabels();
super.doRender();
}
private setupEventHandlers(): void {
// Image click to open lightbox
this.$imageView?.on("click", async (e) => {
e.preventDefault();
await this.handleOpenLightbox();
});
// Control button handlers
this.$zoomInBtn?.on("click", () => this.zoomIn());
this.$zoomOutBtn?.on("click", () => this.zoomOut());
this.$resetZoomBtn?.on("click", () => this.resetZoom());
this.$fullscreenBtn?.on("click", async () => await this.handleOpenLightbox());
this.$downloadBtn?.on("click", () => this.downloadImage());
// Mouse wheel zoom with debouncing
this.wheelHandler = (e: JQuery.TriggeredEvent) => {
// Only handle if widget has focus
if (!this.$widget?.is(':focus-within')) {
return;
}
e.preventDefault();
const originalEvent = e.originalEvent as WheelEvent | undefined;
const delta = originalEvent?.deltaY;
if (delta) {
if (delta < 0) {
this.zoomIn();
} else {
this.zoomOut();
}
}
};
this.$imageWrapper?.on("wheel", this.wheelHandler);
}
private async handleOpenLightbox(): Promise<void> {
if (!this.$imageView?.length) return;
const src = this.$imageView.attr('src') || this.$imageView.prop('src');
if (!src) return;
await this.openInLightbox(
src,
this.note?.title,
this.noteId,
this.$imageView.get(0)
);
}
async doRefresh(note: FNote) {
this.$imageView.prop("src", utils.createImageSrcUrl(note));
const src = utils.createImageSrcUrl(note);
// Reset zoom when image changes
this.resetZoom();
// Refresh gallery items when note changes
await this.refreshGalleryItems();
// Setup image with loading state and error handling
try {
await this.setupImage(src, this.$imageView!);
} catch (error) {
console.error("Failed to load image:", error);
// Error message is already shown by setupImage
}
}
private downloadImage(): void {
if (!this.note) return;
try {
const link = document.createElement('a');
link.href = utils.createImageSrcUrl(this.note);
link.download = this.note.title || 'image';
// Add to document, click, and remove
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (error) {
console.error("Failed to download image:", error);
alert("Failed to download image. Please try again.");
}
}
copyImageReferenceToClipboardEvent({ ntxId }: EventData<"copyImageReferenceToClipboard">) {
@@ -82,14 +342,26 @@ class ImageTypeWidget extends TypeWidget {
return;
}
imageService.copyImageReferenceToClipboard(this.$imageWrapper);
if (this.$imageWrapper?.length) {
imageService.copyImageReferenceToClipboard(this.$imageWrapper);
}
}
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
if (loadResults.isNoteReloaded(this.noteId)) {
this.refresh();
await this.refresh();
}
}
cleanup() {
// Remove wheel handler if it exists
if (this.wheelHandler && this.$imageWrapper?.length) {
this.$imageWrapper.off("wheel", this.wheelHandler);
}
// Call parent cleanup
super.cleanup();
}
}
export default ImageTypeWidget;
export default ImageTypeWidget;

View 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();
});
});
});

View 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;

View File

@@ -6,6 +6,7 @@ import { getLocaleById } from "../../services/i18n.js";
import appContext from "../../components/app_context.js";
import { getMermaidConfig } from "../../services/mermaid.js";
import { renderMathInElement } from "../../services/math.js";
import ckeditorPhotoSwipe from "../../services/ckeditor_photoswipe_integration.js";
const TPL = /*html*/`
<div class="note-detail-readonly-text note-detail-printable" tabindex="100">
@@ -93,7 +94,19 @@ export default class ReadOnlyTextTypeWidget extends AbstractTextTypeWidget {
}
cleanup() {
this.$content.html("");
// Cleanup PhotoSwipe integration
if (this.$content?.[0]) {
ckeditorPhotoSwipe.cleanupContainer(this.$content[0]);
}
// Remove all event handlers from content
if (this.$content) {
this.$content.off();
this.$content.find('*').off();
this.$content.html("");
}
super.cleanup();
}
async doRefresh(note: FNote) {
@@ -107,6 +120,18 @@ export default class ReadOnlyTextTypeWidget extends AbstractTextTypeWidget {
const blob = await note.getBlob();
this.$content.html(blob?.content ?? "");
// Setup PhotoSwipe integration for images in read-only content
setTimeout(() => {
if (this.$content[0]) {
ckeditorPhotoSwipe.setupContainer(this.$content[0], {
enableGalleryMode: true,
showHints: true,
hintDelay: 2000,
excludeSelector: '.no-lightbox'
});
}
}, 100);
this.$content.find("a.reference-link").each((_, el) => {
this.loadReferenceLinkTitle($(el));

55
pnpm-lock.yaml generated
View File

@@ -282,6 +282,9 @@ importers:
panzoom:
specifier: 9.4.3
version: 9.4.3
photoswipe:
specifier: ^5.4.4
version: 5.4.4
preact:
specifier: 10.27.0
version: 10.27.0
@@ -11730,6 +11733,10 @@ packages:
perfect-freehand@1.2.0:
resolution: {integrity: sha512-h/0ikF1M3phW7CwpZ5MMvKnfpHficWoOEyr//KVNTxV4F6deRK1eYMtHyBKEAKFK0aXIEUK9oBvlF6PNXMDsAw==}
photoswipe@5.4.4:
resolution: {integrity: sha512-WNFHoKrkZNnvFFhbHL93WDkW3ifwVOXSW3w1UuZZelSmgXpIGiZSNlZJq37rR8YejqME2rHs9EhH9ZvlvFH2NA==}
engines: {node: '>= 0.12.0'}
pica@7.1.1:
resolution: {integrity: sha512-WY73tMvNzXWEld2LicT9Y260L43isrZ85tPuqRyvtkljSDLmnNFQmZICt4xUJMVulmcc6L9O7jbBrtx3DOz/YQ==}
@@ -16850,6 +16857,8 @@ snapshots:
'@ckeditor/ckeditor5-utils': 46.0.1
'@ckeditor/ckeditor5-widget': 46.0.1
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-cloud-services@46.0.1':
dependencies:
@@ -16869,6 +16878,8 @@ snapshots:
'@ckeditor/ckeditor5-ui': 46.0.1
'@ckeditor/ckeditor5-utils': 46.0.1
ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41)
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-collaboration-core@46.0.1':
dependencies:
@@ -17138,8 +17149,6 @@ snapshots:
'@ckeditor/ckeditor5-table': 46.0.1
'@ckeditor/ckeditor5-utils': 46.0.1
ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41)
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-emoji@46.0.1':
dependencies:
@@ -17196,8 +17205,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 46.0.1
'@ckeditor/ckeditor5-utils': 46.0.1
ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41)
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-export-word@46.0.1':
dependencies:
@@ -17222,6 +17229,8 @@ snapshots:
'@ckeditor/ckeditor5-utils': 46.0.1
ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41)
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-font@46.0.1':
dependencies:
@@ -17346,8 +17355,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 46.0.1
'@ckeditor/ckeditor5-utils': 46.0.1
ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41)
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-indent@46.0.1':
dependencies:
@@ -17420,8 +17427,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 46.0.1
'@ckeditor/ckeditor5-utils': 46.0.1
ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41)
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-markdown-gfm@46.0.1':
dependencies:
@@ -17459,8 +17464,6 @@ snapshots:
'@ckeditor/ckeditor5-utils': 46.0.1
'@ckeditor/ckeditor5-widget': 46.0.1
ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41)
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-mention@46.0.1(patch_hash=5981fb59ba35829e4dff1d39cf771000f8a8fdfa7a34b51d8af9549541f2d62d)':
dependencies:
@@ -17484,8 +17487,6 @@ snapshots:
'@ckeditor/ckeditor5-widget': 46.0.1
ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41)
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-minimap@46.0.1':
dependencies:
@@ -17494,8 +17495,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 46.0.1
'@ckeditor/ckeditor5-utils': 46.0.1
ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41)
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-operations-compressor@46.0.1':
dependencies:
@@ -17548,8 +17547,6 @@ snapshots:
'@ckeditor/ckeditor5-utils': 46.0.1
'@ckeditor/ckeditor5-widget': 46.0.1
ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41)
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-pagination@46.0.1':
dependencies:
@@ -17576,8 +17573,6 @@ snapshots:
'@ckeditor/ckeditor5-paste-from-office': 46.0.1
'@ckeditor/ckeditor5-utils': 46.0.1
ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41)
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-paste-from-office@46.0.1':
dependencies:
@@ -17585,8 +17580,6 @@ snapshots:
'@ckeditor/ckeditor5-core': 46.0.1
'@ckeditor/ckeditor5-engine': 46.0.1
ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41)
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-real-time-collaboration@46.0.1(bufferutil@4.0.9)(utf-8-validate@6.0.5)':
dependencies:
@@ -17617,8 +17610,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 46.0.1
'@ckeditor/ckeditor5-utils': 46.0.1
ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41)
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-restricted-editing@46.0.1':
dependencies:
@@ -17628,8 +17619,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 46.0.1
'@ckeditor/ckeditor5-utils': 46.0.1
ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41)
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-revision-history@46.0.1':
dependencies:
@@ -17664,8 +17653,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 46.0.1
'@ckeditor/ckeditor5-utils': 46.0.1
ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41)
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-slash-command@46.0.1':
dependencies:
@@ -17678,8 +17665,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 46.0.1
'@ckeditor/ckeditor5-utils': 46.0.1
ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41)
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-source-editing-enhanced@46.0.1':
dependencies:
@@ -17706,8 +17691,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 46.0.1
'@ckeditor/ckeditor5-utils': 46.0.1
ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41)
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-special-characters@46.0.1':
dependencies:
@@ -17717,8 +17700,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 46.0.1
'@ckeditor/ckeditor5-utils': 46.0.1
ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41)
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-style@46.0.1':
dependencies:
@@ -17731,8 +17712,6 @@ snapshots:
'@ckeditor/ckeditor5-utils': 46.0.1
ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41)
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-table@46.0.1':
dependencies:
@@ -17745,8 +17724,6 @@ snapshots:
'@ckeditor/ckeditor5-widget': 46.0.1
ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41)
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-template@46.0.1':
dependencies:
@@ -17857,8 +17834,6 @@ snapshots:
'@ckeditor/ckeditor5-engine': 46.0.1
'@ckeditor/ckeditor5-utils': 46.0.1
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-widget@46.0.1':
dependencies:
@@ -17878,8 +17853,6 @@ snapshots:
'@ckeditor/ckeditor5-utils': 46.0.1
ckeditor5: 46.0.1(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41)
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@codemirror/autocomplete@6.18.6':
dependencies:
@@ -29514,6 +29487,8 @@ snapshots:
perfect-freehand@1.2.0: {}
photoswipe@5.4.4: {}
pica@7.1.1:
dependencies:
glur: 1.1.2