diff --git a/tests/js/gallery-performance.test.js b/tests/js/gallery-performance.test.js new file mode 100644 index 0000000..43d0e96 --- /dev/null +++ b/tests/js/gallery-performance.test.js @@ -0,0 +1,209 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const path = require('node:path'); +const { escapeHtml } = require('../../web/js/html-escape.js'); + +function fixture(count = 2000) { + const elements = new Map(); + function element() { + return { + dataset: {}, children: [], listeners: {}, classList: { add() {}, remove() {} }, + set innerHTML(html) { this.html = html; this.children = [{ html }]; }, + get innerHTML() { return this.html || ''; }, + insertAdjacentHTML(position, html) { this.children.push({ html }); }, + addEventListener(type, handler) { (this.listeners[type] ||= []).push(handler); }, + querySelectorAll() { return []; }, + remove() {}, + }; + } + const document = { + addEventListener() {}, + getElementById(id) { if (!elements.has(id)) elements.set(id, element()); return elements.get(id); }, + createElement: element, + }; + const instances = []; + const timers = new Map(); + let timerId = 0; + class Viewer { + constructor(holder, options) { + this.element = holder; this.options = { fullscreen: true, ...options }; + this.index = options.initialViewIndex; this.isShown = true; + this.length = (holder.innerHTML.match(/ value, STALE: Symbol() }, + ListState: { nextPageState: (state, next) => ({ ...state, ...next }) }, + }); + const source = fs.readFileSync(path.join(__dirname, '../../web/js/gallery.js'), 'utf8'); + const Manager = vm.runInContext(source + '\nGalleryManager;', context); + const manager = Object.create(Manager.prototype); + Object.assign(manager, { images: Array.from({ length: count }, (_, i) => ({ + filename: `${i}.png`, url: `/original/${i}.png`, thumbnail_url: `/thumb/${i}.jpg`, + relative_path: `${i}.png`, size: 100, modified_time: 1, + })), viewMode: 'grid', viewer: null, currentPage: 1, limit: 100, total: count, + getSettings: () => ({ showImageInfo: true }), }); + return { manager, document, instances, timers }; +} + +for (const mode of ['grid', 'list']) { + test(`${mode} append retains existing cards and adds only the new page`, () => { + const { manager, document, timers } = fixture(100); + manager.viewMode = mode; + manager.renderGallery(); + const container = document.getElementById(mode === 'grid' ? 'galleryGrid' : 'galleryList'); + assert.equal(timers.size, 0); + const first = container.children[0]; + manager.images.push({ ...manager.images[0], filename: 'new.png' }); + manager.renderGallery({ from: 100 }); + assert.equal(container.children[0], first); + assert.equal(container.children.length, 2); + assert.match(container.children[1].html, /new.png/); + assert.doesNotMatch(container.children[1].html, /alt="0.png"/); + assert.match(container.children[1].html, /data-image-index="100"/); + }); +} + +test('loadImages passes the previous length for append, and zero for replacement', async () => { + const { manager } = fixture(200); + const offsets = []; + Object.assign(manager, { imagesRequest: async () => ({ total: 500, images: [manager.images[0]] }), + renderGallery: options => offsets.push(options.from), updateStats() {}, + updatePagination() {}, maybeLoadMore() {}, showLoading() {}, }); + await manager.loadImages(3, { append: true }); + await manager.loadImages(1); + assert.deepEqual(offsets, [200, 0]); + assert.equal(manager.images.length, 1); +}); + +test('viewer opening is bounded and selects the clicked original', () => { + const { manager, instances } = fixture(); + manager.openViewer(1500); + const viewer = instances[0]; + assert.ok(viewer.element.innerHTML.match(/ m[1]); + assert.equal(urls[viewer.options.initialViewIndex], '/original/1500.png'); + assert.equal(viewer.shown, true); +}); + +test('previous/next cross window edges and wrap only at gallery edges', () => { + const { manager, instances } = fixture(); + manager.openViewer(1500); + const first = manager.viewer; + first.index = 99; + const lastUrl = [...first.element.innerHTML.matchAll(/data-original="\/original\/(\d+).png"/g)].at(-1); + first.next(true); + const next = manager.viewer; + const urls = [...next.element.innerHTML.matchAll(/data-original="\/original\/(\d+).png"/g)]; + assert.equal(Number(urls[next.index][1]), Number(lastUrl[1]) + 1); + manager.openViewer(0); + manager.viewer.prev(true); + const wrapped = manager.viewer; + const wrappedUrls = [...wrapped.element.innerHTML.matchAll(/data-original="\/original\/(\d+).png"/g)]; + assert.equal(Number(wrappedUrls[wrapped.index][1]), 1999); + assert.ok(instances.length >= 3); +}); + +test('delegated card clicks bind once, open appended indices, and preserve video dispatch', () => { + const { manager, document } = fixture(); + const opened = []; + manager.openViewer = index => opened.push(index); + manager.showVideoModal = (...args) => opened.push(args); + manager.addFallbackClickHandlers(); + manager.addFallbackClickHandlers(); + const grid = document.getElementById('galleryGrid'); + assert.equal(grid.listeners.click.length, 1); + const item = { dataset: { imageIndex: '1500' }, querySelector: () => ({ dataset: { + original: '/original/1500.png', caption: 'caption', isVideo: 'false', + } }) }; + const click = () => grid.listeners.click[0]({ target: { closest: () => item }, preventDefault() {}, stopPropagation() {} }); + click(); + item.querySelector = () => ({ dataset: { original: '/video.mp4', caption: 'video', isVideo: 'true' } }); + click(); + assert.deepEqual(opened, [1500, ['/video.mp4', 'video', 1500]]); +}); + +test('navigation inside the window reuses the viewer and honors non-looping edges', () => { + const { manager, instances } = fixture(); + manager.openViewer(1500); + const viewer = manager.viewer; + viewer.next(true); + assert.equal(manager.viewer, viewer); + assert.equal(viewer.index, 51); + viewer.prev(true); + assert.equal(viewer.index, 50); + manager.openViewer(0); + const edge = manager.viewer; + edge.prev(false); + assert.equal(manager.viewer, edge); + assert.equal(edge.index, 0); + assert.equal(instances.length, 2); +}); + +test('append leaves an open viewer intact; a full render destroys it', () => { + const { manager } = fixture(); + manager.openViewer(1500); + const viewer = manager.viewer; + manager.renderGallery({ from: 1900 }); + assert.equal(manager.viewer, viewer); + manager.renderGallery(); + assert.equal(viewer.destroyed, true); + assert.equal(manager.viewer, null); +}); + +test('each viewed image updates sidebar metadata without waiting for DOM mutations', () => { + const { manager, document } = fixture(); + const urls = []; + document.querySelector = () => ({}); + const sidebar = document.getElementById('metadata-sidebar'); + sidebar.querySelector = () => ({}); + manager.loadImageMetadata = url => urls.push(url); + manager.openViewer(1500); + manager.viewer.options.viewed({ detail: { originalImage: { dataset: { original: '/original/1500.png' } } } }); + manager.viewer.options.viewed({ detail: { originalImage: { dataset: { original: '/original/1501.png' } } } }); + assert.deepEqual(urls, ['/original/1500.png', '/original/1501.png']); +}); + +test('slideshow advances across windows without preloading every original and stops on hide', () => { + const { manager, timers } = fixture(); + manager.openViewer(1500); + let prevented = false; + manager.viewer.options.play({ preventDefault() { prevented = true; } }); + assert.equal(prevented, true); + assert.equal(manager.viewer.fullscreenRequested, true); + assert.equal(timers.size, 1); + manager.viewer.index = 99; + const tick = timers.values().next().value; + timers.clear(); + tick(); + const viewer = manager.viewer; + const urls = [...viewer.element.innerHTML.matchAll(/data-original="([^"]+)"/g)]; + assert.equal(urls[viewer.index][1], '/original/1550.png'); + assert.equal(timers.size, 1); + viewer.options.hide(); + assert.equal(viewer.fullscreenExited, true); + assert.equal(timers.size, 0); +}); + +test('destroying a viewer removes its focus trap and slideshow timer', () => { + const { manager, timers } = fixture(); + manager.openViewer(1500); + const viewer = manager.viewer; + viewer.options.play({ preventDefault() {} }); + manager.destroyViewer(); + assert.equal(viewer.focusCleared, true); + assert.equal(viewer.destroyed, true); + assert.equal(timers.size, 0); +}); diff --git a/web/js/gallery.js b/web/js/gallery.js index 5dcea0e..f02c675 100644 --- a/web/js/gallery.js +++ b/web/js/gallery.js @@ -357,9 +357,10 @@ url: ApiClient.resolveUrl(image.url), thumbnail_url: ApiClient.resolveUrl(image.thumbnail_url), })); + const from = append ? this.images.length : 0; this.images = append ? this.images.concat(images) : images; this.updateStats(); - this.renderGallery(); + this.renderGallery({ from }); this.updatePagination(); document.getElementById('loadingStatus').textContent = 'Loaded'; this.maybeLoadMore(); // a short page may not fill the viewport @@ -445,7 +446,8 @@ this.renderGallery(); } - renderGallery() { + renderGallery({ from = 0 } = {}) { + if (from === 0) this.destroyViewer(); if (this.images.length === 0) { this.showError('No images found in the output folder'); return; @@ -455,19 +457,15 @@ document.getElementById('errorState').classList.add('hidden'); if (this.viewMode === 'grid') { - this.renderGridView(); + this.renderGridView({ from }); } else { - this.renderListView(); + this.renderListView({ from }); } - // Initialize ViewerJS after rendering - setTimeout(() => this.initializeViewer(), 100); - - // Add simple fallback click handlers this.addFallbackClickHandlers(); } - renderGridView() { + renderGridView({ from = 0 } = {}) { const grid = document.getElementById('galleryGrid'); const list = document.getElementById('galleryList'); const showInfo = this.getSettings().showImageInfo; @@ -475,7 +473,8 @@ list.classList.add('hidden'); grid.classList.remove('hidden'); - grid.innerHTML = this.images.map((image, index) => { + const html = this.images.slice(from).map((image, offset) => { + const index = from + offset; // Use thumbnail for display if available, fallback to original const displayUrl = image.thumbnail_url || image.url; const hasThumb = !!image.thumbnail_url; @@ -483,7 +482,7 @@ const mediaType = image.media_type || 'image'; return ` -
+
${escapeHtml(image.filename)} `; }).join(''); + if (from === 0) grid.innerHTML = html; + else grid.insertAdjacentHTML('beforeend', html); } - renderListView() { + renderListView({ from = 0 } = {}) { const grid = document.getElementById('galleryGrid'); const list = document.getElementById('galleryList'); const showInfo = this.getSettings().showImageInfo; @@ -535,7 +536,8 @@ grid.classList.add('hidden'); list.classList.remove('hidden'); - list.innerHTML = this.images.map((image, index) => { + const html = this.images.slice(from).map((image, offset) => { + const index = from + offset; // Use thumbnail for display if available, fallback to original const displayUrl = image.thumbnail_url || image.url; const hasThumb = !!image.thumbnail_url; @@ -543,7 +545,7 @@ const mediaType = image.media_type || 'image'; return ` -
+
${escapeHtml(image.filename)} `; }).join(''); + if (from === 0) list.innerHTML = html; + else list.insertAdjacentHTML('beforeend', html); } - initializeViewer() { - // Destroy existing viewer if it exists + destroyViewer({ keepSlideshow = false } = {}) { + if (!keepSlideshow) this.stopViewerSlideshow(); if (this.viewer) { + this.viewer.clearEnforceFocus(); this.viewer.destroy(); } + this.viewer = null; + document.getElementById('metadata-sidebar')?.remove(); + } - const container = this.viewMode === 'grid' ? document.getElementById('galleryGrid') : document.getElementById('galleryList'); - - // Find all images in the container - const images = container.querySelectorAll('img[data-original]'); - - if (images.length === 0) { - console.warn('No images found for viewer initialization'); - return; - } + openViewer(index, { keepSlideshow = false } = {}) { + if (!Number.isInteger(index) || index < 0 || index >= this.images.length) return; + this.destroyViewer({ keepSlideshow }); + const size = 100; + const start = Math.max(0, Math.min(index - size / 2, this.images.length - size)); + const container = document.createElement('div'); + container.innerHTML = this.images.slice(start, start + size).map(image => + `${escapeHtml(image.filename)}` + ).join(''); this.viewer = new Viewer(container, { inline: false, + initialViewIndex: index - start, button: true, backdrop: true, navbar: true, @@ -624,14 +635,20 @@ // Add metadata sidebar when ViewerJS opens // Use the original image URL from data-original attribute const originalImg = event.detail.originalImage; - setTimeout(() => this.addMetadataSidebar(originalImg), 100); + this.addMetadataSidebar(originalImg); }, - show: function() { + play: (event) => { + event.preventDefault(); + if (this.slideshowPlaying) this.stopViewerSlideshow(); + else { + this.slideshowPlaying = true; + if (this.viewer.options.fullscreen) this.viewer.requestFullscreen(); + this.scheduleViewerSlide(); + } }, - shown: function() { - }, - hide: function() { - // Remove sidebar when viewer closes + hide: () => { + this.viewer?.clearEnforceFocus(); + this.stopViewerSlideshow(); const sidebar = document.getElementById('metadata-sidebar'); if (sidebar) { sidebar.remove(); @@ -639,12 +656,50 @@ } }); - // Don't add click handlers here - let ViewerJS handle them + const viewer = this.viewer; + const navigate = (direction, loop = false) => { + if (viewer !== this.viewer || viewer.hiding || viewer.played) return viewer; + let target = start + viewer.index + direction; + if (loop) target = (target + this.images.length) % this.images.length; + else target = Math.max(0, Math.min(target, this.images.length - 1)); + if (target >= start && target < start + viewer.length) { + viewer.view(target - start); + } else { + this.openViewer(target, { keepSlideshow: true }); + } + return this.viewer; + }; + viewer.prev = (loop) => navigate(-1, loop); + viewer.next = (loop) => navigate(1, loop); + viewer.show(); + } + + stopViewerSlideshow() { + clearTimeout(this.slideshowTimer); + this.slideshowTimer = null; + if (this.slideshowPlaying) this.viewer?.exitFullscreen(); + this.slideshowPlaying = false; + } + + scheduleViewerSlide() { + // Native Viewer.js slideshows preload every original and loop within the filmstrip. + this.slideshowTimer = setTimeout(() => { + this.slideshowTimer = null; + if (!this.slideshowPlaying || !this.viewer) return; + this.viewer.next(true); + if (this.slideshowPlaying) this.scheduleViewerSlide(); + }, 5000); } addMetadataSidebar(originalImage) { const viewerContainer = document.querySelector('.viewer-container'); - if (!viewerContainer || document.getElementById('metadata-sidebar')) return; + if (!viewerContainer) return; + const existing = document.getElementById('metadata-sidebar'); + if (existing) { + this.loadImageMetadata(originalImage.dataset.original || originalImage.src, + existing.querySelector('#metadata-content')); + return; + } const sidebar = document.createElement('div'); sidebar.id = 'metadata-sidebar'; @@ -690,79 +745,35 @@ // Load metadata for the current image (ALWAYS use original for metadata) const originalImageUrl = originalImage.dataset.original || originalImage.src; this.loadImageMetadata(originalImageUrl, sidebar.querySelector('#metadata-content')); - - // Listen for ViewerJS view changes to update metadata - this.setupViewerMetadataUpdates(sidebar); - } - - setupViewerMetadataUpdates(sidebar) { - // Listen for ViewerJS navigation events - const viewerContainer = document.querySelector('.viewer-container'); - if (!viewerContainer) return; - - // Override ViewerJS navigation to update metadata - const observer = new MutationObserver((mutations) => { - mutations.forEach((mutation) => { - if (mutation.type === 'attributes' && mutation.attributeName === 'src') { - const img = mutation.target; - if (img.tagName === 'IMG' && img.closest('.viewer-canvas')) { - // Find the corresponding original image - const imageUrl = img.src; - this.loadImageMetadata(imageUrl, sidebar.querySelector('#metadata-content')); - } - } - }); - }); - - const canvas = viewerContainer.querySelector('.viewer-canvas'); - if (canvas) { - observer.observe(canvas, { - childList: true, - subtree: true, - attributes: true, - attributeFilter: ['src'] - }); - } } addFallbackClickHandlers() { - // Add simple click handlers that work even if ViewerJS fails - const container = this.viewMode === 'grid' ? document.getElementById('galleryGrid') : document.getElementById('galleryList'); - const imageItems = container.querySelectorAll('.image-item'); - - imageItems.forEach((item, index) => { - item.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - + for (const id of ['galleryGrid', 'galleryList']) { + const container = document.getElementById(id); + if (container.dataset.mediaClicksBound) continue; + container.dataset.mediaClicksBound = '1'; + container.addEventListener('click', (event) => { + const item = event.target.closest('.image-item'); + if (!item) return; const img = item.querySelector('img[data-original]'); - if (img) { - // ALWAYS use the original media URL for viewing - const originalMediaUrl = img.dataset.original; - const caption = img.dataset.caption; - const isVideo = img.dataset.isVideo === 'true'; - const mediaType = img.dataset.mediaType || 'image'; - - - if (isVideo) { - // For videos, use our custom video modal - this.showVideoModal(originalMediaUrl, caption, index); - } else { - // For images, try ViewerJS first, fallback to simple modal - if (this.viewer) { - try { - this.viewer.view(index); - } catch (e) { - console.warn('ViewerJS failed, using fallback:', e); - this.showSimpleImageModal(originalMediaUrl, caption); - } - } else { - this.showSimpleImageModal(originalMediaUrl, caption); - } + if (!img) return; + event.preventDefault(); + event.stopPropagation(); + const index = Number(item.dataset.imageIndex); + const { original, caption, isVideo } = img.dataset; + if (isVideo === 'true') { + this.showVideoModal(original, caption, index); + } else { + try { + this.openViewer(index); + } catch (error) { + console.warn('ViewerJS failed, using fallback:', error); + this.destroyViewer(); + this.showSimpleImageModal(original, caption); } } }); - }); + } } async showSimpleImageModal(imageUrl, caption) {