Compare commits

...
2 changed files with 319 additions and 99 deletions
+209
View File
@@ -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(/<img /g) || []).length;
instances.push(this);
}
requestFullscreen() { this.fullscreenRequested = true; }
exitFullscreen() { this.fullscreenExited = true; }
show() { this.shown = true; }
view(index) { this.index = index; }
clearEnforceFocus() { this.focusCleared = true; }
destroy() { this.destroyed = true; }
}
const context = vm.createContext({ document, window: {}, Viewer, escapeHtml,
console, setTimeout(callback) { timers.set(++timerId, callback); return timerId; },
clearTimeout(id) { timers.delete(id); },
ApiClient: { resolveUrl: value => 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(/<img /g).length <= 100);
assert.match(viewer.element.innerHTML, /data-original="\/original\/1500.png"/);
const urls = [...viewer.element.innerHTML.matchAll(/data-original="([^"]+)"/g)].map(m => 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);
});
+110 -99
View File
@@ -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 `
<div class="image-item bg-pm-surface rounded-pm-md overflow-hidden border border-pm hover:border-pm cursor-pointer group">
<div data-image-index="${index}" class="image-item bg-pm-surface rounded-pm-md overflow-hidden border border-pm hover:border-pm cursor-pointer group">
<div class="aspect-square bg-pm-primary overflow-hidden relative">
<img src="${escapeHtml(displayUrl)}"
alt="${escapeHtml(image.filename)}"
@@ -525,9 +524,11 @@
</div>
`;
}).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 `
<div class="image-item bg-pm-surface rounded-pm-md border border-pm hover:border-pm cursor-pointer group flex items-center p-4">
<div data-image-index="${index}" class="image-item bg-pm-surface rounded-pm-md border border-pm hover:border-pm cursor-pointer group flex items-center p-4">
<div class="w-16 h-16 bg-pm-primary rounded-pm-md overflow-hidden flex-shrink-0 mr-4 relative">
<img src="${escapeHtml(displayUrl)}"
alt="${escapeHtml(image.filename)}"
@@ -576,26 +578,35 @@
</div>
`;
}).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 =>
`<img src="${escapeHtml(image.thumbnail_url || image.url)}"
data-original="${escapeHtml(image.url)}"
alt="${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) {