This repository has been archived on 2026-05-31. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
imagerepo/app/static/js/view-modal.js
T

824 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// /app/static/js/view-modal.js
document.addEventListener('DOMContentLoaded', () => {
const modal = document.getElementById('imageModal');
const modalPrev = document.getElementById('modalPrev');
const modalNext = document.getElementById('modalNext');
const modalClose = document.getElementById('modalClose');
const modalImageWrapper = document.querySelector('.modal-image-wrapper');
// NEW: tag editor elements
const tagEditor = document.getElementById('modalTagEditor');
const tagList = document.getElementById('modalTagList');
const tagForm = document.getElementById('modalTagForm');
const tagInput = tagForm ? tagForm.querySelector('input[name="name"]') : null;
const tagAutocomplete = document.getElementById('tagAutocomplete');
// Series info elements (when image IS in a series)
const seriesInfoEl = document.getElementById('modalSeriesInfo');
const seriesNameEl = document.getElementById('modalSeriesName');
const pageNumberInput = document.getElementById('modalPageNumber');
const updatePageBtn = document.getElementById('updatePageBtn');
const pageUpdateStatus = document.getElementById('pageUpdateStatus');
let currentSeriesPageId = null;
// Add to series elements (when image is NOT in a series)
const addToSeriesEl = document.getElementById('modalAddToSeries');
const seriesSelect = document.getElementById('seriesSelect');
const addSeriesPageNumber = document.getElementById('addSeriesPageNumber');
const addToSeriesBtn = document.getElementById('addToSeriesBtn');
const addToSeriesStatus = document.getElementById('addToSeriesStatus');
const addToSeriesToggle = document.getElementById('addToSeriesToggle');
const addToSeriesBody = document.getElementById('addToSeriesBody');
let seriesTagsLoaded = false;
// In-context feedback: show a brief message next to an element, auto-fades
function showFeedback(el, message, isError = false) {
if (!el) return;
el.textContent = message;
el.style.color = isError ? 'var(--flash-danger)' : 'var(--flash-success)';
el.classList.add('visible');
clearTimeout(el._feedbackTimeout);
el._feedbackTimeout = setTimeout(() => {
el.classList.remove('visible');
}, 1500);
}
// Autocomplete state
let autocompleteItems = [];
let autocompleteSelectedIndex = -1;
let autocompleteDebounce = null;
let images = Array.from(document.querySelectorAll('.img-clickable'));
let currentIndex = -1;
let isZoomed = false;
let isDragging = false;
let startX, startY, scrollLeft, scrollTop;
let dragStartX = 0;
let dragStartY = 0;
let dragMoved = false;
// Legacy pagination inputs (for non-infinite-scroll pages)
const hasNextPageInput = document.getElementById('hasNextPage');
const nextPageUrlInput = document.getElementById('nextPageUrl');
const hasPrevPageInput = document.getElementById('hasPrevPage');
const prevPageUrlInput = document.getElementById('prevPageUrl');
// Check if we're in infinite scroll mode
// Infinite scroll mode is when galleryState exists (set by gallery.html template)
const isInfiniteScrollMode = typeof window.galleryState !== 'undefined';
// ---------------------------
// Tag editor helpers
// ---------------------------
// Use shared TagUtils module
const { getTagIcon, getTagDisplayName, getTagDisplayNameForOverlay, escapeHtml, createTagChipHtml } = window.TagUtils || {};
function setEditorImageId(id) {
if (tagEditor) tagEditor.dataset.imageId = id || '';
}
function getEditorImageId() {
return tagEditor ? tagEditor.dataset.imageId : '';
}
function renderTags(tags) {
if (!tagList) return;
tagList.innerHTML = '';
(tags || []).forEach(t => {
const chip = document.createElement('span');
chip.className = 'tag-chip';
const icon = getTagIcon(t.kind);
const displayName = getTagDisplayName(t.name);
chip.innerHTML = `${icon} ${displayName} <button class="x" data-name="${t.name}" title="Remove">×</button>`;
tagList.appendChild(chip);
});
}
async function loadTags(imageId) {
if (!imageId) { renderTags([]); return; }
try {
const r = await fetch(`/image/${imageId}/tags`);
const j = await r.json();
if (j.ok) renderTags(j.tags);
} catch {
// ignore
}
}
// ---------------------------
// Gallery item tag refresh (fallback if bulk-select.js not loaded)
// ---------------------------
async function refreshItemTagsFallback(imageId) {
const item = document.querySelector(`.img-clickable[data-id="${imageId}"]`);
if (!item) return;
try {
const r = await fetch(`/image/${imageId}/tags`);
const data = await r.json();
if (!data.ok) return;
const visibleTags = (data.tags || []).filter(t => t.kind !== 'archive');
let overlay = item.querySelector('.tag-overlay');
if (visibleTags.length === 0) {
if (overlay) overlay.remove();
return;
}
if (!overlay) {
overlay = document.createElement('div');
overlay.className = 'tag-overlay';
item.appendChild(overlay);
}
overlay.innerHTML = visibleTags.map(t => createTagChipHtml(t)).join('');
} catch (e) {
console.error('Failed to refresh tags for image', imageId, e);
}
}
// Register fallback if bulk-select.js hasn't registered it
if (!window.refreshItemTags) {
window.refreshItemTags = refreshItemTagsFallback;
}
// ---------------------------
// Series info helpers
// ---------------------------
function hideSeriesInfo() {
if (seriesInfoEl) seriesInfoEl.style.display = 'none';
currentSeriesPageId = null;
if (pageUpdateStatus) { pageUpdateStatus.textContent = ''; pageUpdateStatus.classList.remove('visible'); }
}
function hideAddToSeries() {
if (addToSeriesEl) addToSeriesEl.style.display = 'none';
if (addToSeriesStatus) { addToSeriesStatus.textContent = ''; addToSeriesStatus.classList.remove('visible'); }
// Reset toggle state
if (addToSeriesToggle) addToSeriesToggle.classList.remove('open');
if (addToSeriesBody) addToSeriesBody.style.display = 'none';
}
async function loadSeriesTags() {
if (!seriesSelect) return;
if (seriesTagsLoaded) return Promise.resolve();
try {
// Fetch all series tags using the kind filter
const r = await fetch('/api/tags/search?kind=series&limit=100');
const j = await r.json();
const seriesTags = j.tags || [];
// Clear existing options except the first placeholder
seriesSelect.innerHTML = '<option value="">Select series...</option>';
seriesTags.forEach(tag => {
const opt = document.createElement('option');
opt.value = tag.id;
// Display name without prefix
opt.textContent = tag.name.includes(':') ? tag.name.split(':', 2)[1] : tag.name;
seriesSelect.appendChild(opt);
});
seriesTagsLoaded = true;
} catch {
// ignore
}
}
function showAddToSeries(seriesPage = null) {
if (!addToSeriesEl) return;
// Store pending pre-fill data for when the toggle is opened
addToSeriesEl._pendingSeriesPage = seriesPage || null;
if (addToSeriesStatus) { addToSeriesStatus.textContent = ''; addToSeriesStatus.classList.remove('visible'); }
// Show the outer container; inner body stays hidden until toggle click
addToSeriesEl.style.display = 'block';
}
async function handleAddToSeries() {
const imageId = getEditorImageId();
const seriesTagId = seriesSelect?.value;
const pageNumber = parseInt(addSeriesPageNumber?.value);
if (!imageId || !seriesTagId || isNaN(pageNumber) || pageNumber < 1) return;
const isUpdate = addToSeriesBtn?.textContent === 'Update';
if (addToSeriesBtn) addToSeriesBtn.disabled = true;
showFeedback(addToSeriesStatus, isUpdate ? 'Updating…' : 'Adding…');
try {
const r = await fetch(`/api/image/${imageId}/add-to-series`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ series_tag_id: parseInt(seriesTagId), page_number: pageNumber })
});
const j = await r.json();
if (j.ok) {
showFeedback(addToSeriesStatus, 'Saved');
// Fully reload series info to show updated management interface
await loadSeriesInfo(imageId);
} else {
showFeedback(addToSeriesStatus, j.error || 'Error', true);
if (addToSeriesBtn) addToSeriesBtn.disabled = false;
}
} catch (e) {
showFeedback(addToSeriesStatus, 'Error', true);
if (addToSeriesBtn) addToSeriesBtn.disabled = false;
}
}
function showSeriesInfo(seriesPage) {
if (!seriesInfoEl || !seriesPage) return;
currentSeriesPageId = seriesPage.id;
// Display series name (strip prefix)
const displayName = seriesPage.series_name.includes(':')
? seriesPage.series_name.split(':', 1)[1]
: seriesPage.series_name;
if (seriesNameEl) {
seriesNameEl.textContent = displayName;
seriesNameEl.href = `/gallery?tag=${encodeURIComponent(seriesPage.series_name)}`;
}
// Display page number
if (pageNumberInput) {
pageNumberInput.value = seriesPage.page_number;
}
if (pageUpdateStatus) { pageUpdateStatus.textContent = ''; pageUpdateStatus.classList.remove('visible'); }
seriesInfoEl.style.display = 'block';
}
async function loadSeriesInfo(imageId) {
hideSeriesInfo();
hideAddToSeries();
if (!imageId) return;
try {
const r = await fetch(`/api/image/${imageId}/series-info`);
const j = await r.json();
if (j.ok && j.in_series) {
// Show series info (with link) and pre-fill the dropdown
showSeriesInfo(j.series_page);
showAddToSeries(j.series_page);
} else {
// Show add-to-series option when not in a series
// Auto-select if image has exactly one series tag
const seriesTags = j.series_tags || [];
if (seriesTags.length === 1) {
showAddToSeries({ series_tag_id: seriesTags[0].id, page_number: 1 });
} else {
showAddToSeries();
}
}
} catch {
// ignore
}
}
async function updatePageNumber() {
if (!currentSeriesPageId || !pageNumberInput) return;
const newPageNumber = parseInt(pageNumberInput.value);
if (isNaN(newPageNumber) || newPageNumber < 1) {
showFeedback(pageUpdateStatus, 'Invalid', true);
return;
}
if (updatePageBtn) updatePageBtn.disabled = true;
showFeedback(pageUpdateStatus, 'Saving…');
try {
const fd = new FormData();
fd.append('page_number', newPageNumber);
const r = await fetch(`/api/series/page/${currentSeriesPageId}/update`, {
method: 'POST',
body: fd
});
const j = await r.json();
if (j.ok) {
showFeedback(pageUpdateStatus, 'Saved');
} else {
showFeedback(pageUpdateStatus, j.error || 'Error', true);
}
} catch (e) {
showFeedback(pageUpdateStatus, 'Error', true);
} finally {
if (updatePageBtn) updatePageBtn.disabled = false;
}
}
// Wire up the save button
if (updatePageBtn) {
updatePageBtn.addEventListener('click', updatePageNumber);
}
// Allow Enter key in page number input to save
if (pageNumberInput) {
pageNumberInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
updatePageNumber();
}
});
}
// Enable/disable add button based on series selection
if (seriesSelect) {
seriesSelect.addEventListener('change', () => {
if (addToSeriesBtn) {
addToSeriesBtn.disabled = !seriesSelect.value;
// Update button text: "Update" if image is in a series, "Add" if not
if (seriesSelect.value && currentSeriesPageId) {
addToSeriesBtn.textContent = 'Update';
} else if (seriesSelect.value) {
addToSeriesBtn.textContent = 'Add';
}
}
});
}
// Add to series button click
if (addToSeriesBtn) {
addToSeriesBtn.addEventListener('click', handleAddToSeries);
}
// Allow Enter key in add-to-series page number input
if (addSeriesPageNumber) {
addSeriesPageNumber.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && seriesSelect?.value) {
e.preventDefault();
handleAddToSeries();
}
});
}
// Toggle "Add to Series" collapse
if (addToSeriesToggle) {
addToSeriesToggle.addEventListener('click', () => {
const isOpen = addToSeriesToggle.classList.toggle('open');
addToSeriesBody.style.display = isOpen ? 'block' : 'none';
// Load series options on first open, then apply any pending pre-fill
if (isOpen) {
const seriesPage = addToSeriesEl._pendingSeriesPage;
loadSeriesTags().then(() => {
if (seriesPage && seriesSelect) {
seriesSelect.value = seriesPage.series_tag_id;
if (addSeriesPageNumber) addSeriesPageNumber.value = seriesPage.page_number;
if (addToSeriesBtn) {
addToSeriesBtn.disabled = false;
addToSeriesBtn.textContent = 'Update';
}
} else {
if (seriesSelect) seriesSelect.value = '';
if (addSeriesPageNumber) addSeriesPageNumber.value = '1';
if (addToSeriesBtn) {
addToSeriesBtn.disabled = true;
addToSeriesBtn.textContent = 'Add';
}
}
});
}
});
}
async function addTag(imageId, name) {
const fd = new FormData();
fd.append('name', name);
const r = await fetch(`/image/${imageId}/tags/add`, { method: 'POST', body: fd });
const j = await r.json();
if (j.ok) {
await loadTags(imageId);
showFeedback(document.getElementById('tagActionFeedback'), 'Tag added');
return true;
}
return false;
}
async function removeTag(imageId, name) {
const fd = new FormData();
fd.append('name', name);
const r = await fetch(`/image/${imageId}/tags/remove`, { method: 'POST', body: fd });
const j = await r.json();
if (j.ok) {
await loadTags(imageId);
showFeedback(document.getElementById('tagActionFeedback'), 'Tag removed');
return true;
}
return false;
}
// ---------------------------
// Autocomplete helpers
// ---------------------------
function hideAutocomplete() {
if (tagAutocomplete) {
tagAutocomplete.classList.remove('active');
tagAutocomplete.innerHTML = '';
}
autocompleteItems = [];
autocompleteSelectedIndex = -1;
}
function positionAutocomplete() {
if (!tagAutocomplete || !tagInput) return;
const rect = tagInput.getBoundingClientRect();
tagAutocomplete.style.top = `${rect.bottom + 4}px`;
tagAutocomplete.style.left = `${rect.left}px`;
tagAutocomplete.style.width = `${rect.width}px`;
}
function renderAutocomplete(tags) {
if (!tagAutocomplete) return;
autocompleteItems = tags;
autocompleteSelectedIndex = -1;
if (tags.length === 0) {
tagAutocomplete.innerHTML = '<div class="tag-autocomplete-empty">No matching tags</div>';
positionAutocomplete();
tagAutocomplete.classList.add('active');
return;
}
tagAutocomplete.innerHTML = tags.map((t, i) => {
const icon = getTagIcon(t.kind);
const displayName = getTagDisplayName(t.name);
return `<div class="tag-autocomplete-item" data-index="${i}" data-name="${t.name}">
<span>${icon} ${displayName}</span>
<span class="tag-kind">${t.kind || 'user'}</span>
</div>`;
}).join('');
positionAutocomplete();
tagAutocomplete.classList.add('active');
}
function selectAutocompleteItem(index) {
const items = tagAutocomplete?.querySelectorAll('.tag-autocomplete-item');
if (!items) return;
items.forEach((el, i) => {
el.classList.toggle('selected', i === index);
});
autocompleteSelectedIndex = index;
}
async function fetchAutocomplete(query) {
try {
// Exclude system-managed tag kinds from autocomplete (archive, post, source)
const excludeKinds = 'archive,post,source';
const url = query
? `/api/tags/search?q=${encodeURIComponent(query)}&limit=8&exclude_kind=${excludeKinds}`
: `/api/tags/search?limit=8&exclude_kind=${excludeKinds}`;
const r = await fetch(url);
const j = await r.json();
renderAutocomplete(j.tags || []);
} catch {
hideAutocomplete();
}
}
// Shared function to submit a tag (avoids page refresh issues with form dispatch)
async function submitTag() {
const id = getEditorImageId();
const name = (tagInput?.value || '').trim();
if (!id || !name) return;
hideAutocomplete();
await addTag(id, name);
if (tagInput) tagInput.value = '';
}
if (tagInput) {
// Show autocomplete on focus
tagInput.addEventListener('focus', () => {
const val = tagInput.value.trim();
fetchAutocomplete(val);
});
// Hide autocomplete on blur (with delay for click)
tagInput.addEventListener('blur', () => {
setTimeout(hideAutocomplete, 200);
});
// Search as user types
tagInput.addEventListener('input', () => {
clearTimeout(autocompleteDebounce);
autocompleteDebounce = setTimeout(() => {
fetchAutocomplete(tagInput.value.trim());
}, 150);
});
// Keyboard navigation
tagInput.addEventListener('keydown', (e) => {
if (!tagAutocomplete?.classList.contains('active')) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
const nextIndex = Math.min(autocompleteSelectedIndex + 1, autocompleteItems.length - 1);
selectAutocompleteItem(nextIndex);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
const prevIndex = Math.max(autocompleteSelectedIndex - 1, 0);
selectAutocompleteItem(prevIndex);
} else if (e.key === 'Enter' && autocompleteSelectedIndex >= 0) {
e.preventDefault();
const selected = autocompleteItems[autocompleteSelectedIndex];
if (selected) {
tagInput.value = selected.name;
submitTag();
}
} else if (e.key === 'Escape') {
hideAutocomplete();
}
});
}
// Click on autocomplete item - use mousedown to fire before blur
if (tagAutocomplete) {
tagAutocomplete.addEventListener('mousedown', (e) => {
e.preventDefault(); // Prevent blur from firing
e.stopPropagation();
const item = e.target.closest('.tag-autocomplete-item');
if (!item) return;
const name = item.dataset.name;
if (tagInput && name) {
tagInput.value = name;
submitTag();
}
});
}
if (tagForm) {
tagForm.addEventListener('submit', async (e) => {
e.preventDefault();
await submitTag();
});
}
if (tagList) {
tagList.addEventListener('click', async (e) => {
const btn = e.target.closest('button.x');
if (!btn) return;
const id = getEditorImageId();
const name = btn.dataset.name;
if (!id || !name) return;
await removeTag(id, name);
});
}
// ---------------------------
// Existing viewer logic
// ---------------------------
function updateImage(index) {
const img = images[index];
const fileType = img.dataset.type || "image";
const imageId = img.dataset.id || "";
// clear wrapper
modalImageWrapper.innerHTML = "";
if (fileType === "video") {
const video = document.createElement("video");
video.src = img.dataset.full;
video.controls = true;
video.autoplay = true;
video.style.maxWidth = "100%";
video.style.maxHeight = "100%";
modalImageWrapper.appendChild(video);
} else {
const image = document.createElement("img");
image.src = img.dataset.full;
image.alt = "Full View";
image.setAttribute("draggable", "false");
image.id = "modalImage";
modalImageWrapper.appendChild(image);
}
currentIndex = index;
resetZoom();
// NEW: load tags for this image into the modal editor
setEditorImageId(imageId);
loadTags(imageId);
loadSeriesInfo(imageId);
}
function openModal(index) {
modal.classList.add('active');
updateImage(index);
history.replaceState({ modalIndex: index }, '', window.location.href);
// Focus the tag input after modal opens (desktop only - avoids keyboard popup on mobile)
if (tagInput && !isTouchDevice()) {
setTimeout(() => tagInput.focus(), 100);
}
}
// Detect touch devices to avoid auto-focus keyboard popup
function isTouchDevice() {
return ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
}
function closeModal() {
// Get the image ID before clearing so we can refresh its tags
const imageId = getEditorImageId();
modal.classList.remove('active');
modalImageWrapper.innerHTML = '';
currentIndex = -1;
resetZoom();
// clear tag UI
setEditorImageId('');
renderTags([]);
hideSeriesInfo();
hideAddToSeries();
history.replaceState({}, '', window.location.pathname + window.location.search);
// Refresh the gallery item's tags display (if function available from bulk-select.js)
if (imageId && window.refreshItemTags) {
window.refreshItemTags(imageId);
}
}
function showNext() {
if (currentIndex < images.length - 1) {
updateImage(currentIndex + 1);
} else if (isInfiniteScrollMode) {
// In infinite scroll mode, just stay at the last image
// User needs to scroll the gallery to load more
return;
} else if (hasNextPageInput?.value === "true") {
// Legacy pagination mode - fetch next page
fetch(nextPageUrlInput.value)
.then(res => res.text())
.then(html => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const newGallery = doc.querySelector('.gallery-grid');
const newImages = doc.querySelectorAll('.img-clickable');
if (newGallery && newImages.length > 0) {
const gallery = document.querySelector('.gallery-grid');
gallery.innerHTML = newGallery.innerHTML;
images = Array.from(document.querySelectorAll('.img-clickable'));
images.forEach((img, i) => {
img.addEventListener('click', () => openModal(i));
});
hasNextPageInput.value = doc.getElementById('hasNextPage')?.value || "false";
nextPageUrlInput.value = doc.getElementById('nextPageUrl')?.value || "";
hasPrevPageInput.value = doc.getElementById('hasPrevPage')?.value || "false";
prevPageUrlInput.value = doc.getElementById('prevPageUrl')?.value || "";
history.pushState({}, '', nextPageUrlInput.value);
openModal(0);
}
});
}
}
function showPrev() {
if (currentIndex > 0) {
updateImage(currentIndex - 1);
} else if (isInfiniteScrollMode) {
// In infinite scroll mode, just stay at the first image
return;
} else if (hasPrevPageInput?.value === "true") {
// Legacy pagination mode - fetch previous page
fetch(prevPageUrlInput.value)
.then(res => res.text())
.then(html => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const newGallery = doc.querySelector('.gallery-grid');
const newImages = doc.querySelectorAll('.img-clickable');
if (newGallery && newImages.length > 0) {
const gallery = document.querySelector('.gallery-grid');
gallery.innerHTML = newGallery.innerHTML;
images = Array.from(document.querySelectorAll('.img-clickable'));
images.forEach((img, i) => {
img.addEventListener('click', () => openModal(i));
});
hasNextPageInput.value = doc.getElementById('hasNextPage')?.value || "false";
nextPageUrlInput.value = doc.getElementById('nextPageUrl')?.value || "";
hasPrevPageInput.value = doc.getElementById('hasPrevPage')?.value || "false";
prevPageUrlInput.value = doc.getElementById('prevPageUrl')?.value || "";
history.pushState({}, '', prevPageUrlInput.value);
openModal(images.length - 1);
}
});
}
}
function toggleZoom() {
const isImage = modalImageWrapper.querySelector('img') !== null;
if (!isImage) return;
isZoomed = !isZoomed;
modalImageWrapper.classList.toggle('zoomed', isZoomed);
modalImageWrapper.style.cursor = isZoomed ? 'grab' : '';
if (!isZoomed) {
modalImageWrapper.scrollLeft = 0;
modalImageWrapper.scrollTop = 0;
}
}
function resetZoom() {
isZoomed = false;
modalImageWrapper.classList.remove('zoomed');
modalImageWrapper.scrollLeft = 0;
modalImageWrapper.scrollTop = 0;
modalImageWrapper.style.cursor = '';
}
modalImageWrapper.addEventListener('mousedown', (e) => {
const isImage = modalImageWrapper.querySelector('img') !== null;
if (!isZoomed || !isImage) return;
isDragging = true;
dragMoved = false;
modalImageWrapper.style.cursor = 'grabbing';
startX = e.pageX - modalImageWrapper.offsetLeft;
startY = e.pageY - modalImageWrapper.offsetTop;
scrollLeft = modalImageWrapper.scrollLeft;
scrollTop = modalImageWrapper.scrollTop;
dragStartX = e.pageX;
dragStartY = e.pageY;
});
modalImageWrapper.addEventListener('mouseleave', () => {
isDragging = false;
const isImage = modalImageWrapper.querySelector('img') !== null;
if (isZoomed && isImage) modalImageWrapper.style.cursor = 'grab';
});
modalImageWrapper.addEventListener('mouseup', () => {
isDragging = false;
const isImage = modalImageWrapper.querySelector('img') !== null;
if (isZoomed && isImage) modalImageWrapper.style.cursor = 'grab';
});
modalImageWrapper.addEventListener('mousemove', (e) => {
const isImage = modalImageWrapper.querySelector('img') !== null;
if (!isDragging || !isZoomed || !isImage) return;
e.preventDefault();
dragMoved = true;
const x = e.pageX - modalImageWrapper.offsetLeft;
const y = e.pageY - modalImageWrapper.offsetTop;
const walkX = x - startX;
const walkY = y - startY;
modalImageWrapper.scrollLeft = scrollLeft - walkX;
modalImageWrapper.scrollTop = scrollTop - walkY;
});
modalImageWrapper.addEventListener('click', (e) => {
if (dragMoved) {
dragMoved = false;
return;
}
toggleZoom();
});
modalClose?.addEventListener('click', closeModal);
modalPrev?.addEventListener('click', showPrev);
modalNext?.addEventListener('click', showNext);
modal.addEventListener('click', (e) => {
if (e.target === modal) closeModal();
});
document.addEventListener('keydown', (e) => {
if (!modal.classList.contains('active')) return;
if (e.key === 'ArrowRight') showNext();
if (e.key === 'ArrowLeft') showPrev();
if (e.key === 'Escape') closeModal();
});
// Use event delegation on the document for .img-clickable clicks
// This allows dynamically added elements (e.g., from shuffle) to work
document.addEventListener('click', (e) => {
const clickable = e.target.closest('.img-clickable');
if (!clickable) return;
// Re-query images to get current state (may have changed from shuffle)
images = Array.from(document.querySelectorAll('.img-clickable'));
const index = images.indexOf(clickable);
if (index !== -1) {
openModal(index);
}
});
// Listen for showcase updates to refresh the images array
document.addEventListener('showcaseUpdated', () => {
images = Array.from(document.querySelectorAll('.img-clickable'));
});
// Listen for gallery infinite scroll updates to refresh the images array
document.addEventListener('galleryUpdated', () => {
images = Array.from(document.querySelectorAll('.img-clickable'));
});
window.addEventListener('popstate', () => {
if (modal.classList.contains('active')) {
closeModal();
} else {
location.reload();
}
});
});