diff --git a/javascript/gallery.js b/javascript/gallery.js index 0707c2e59..04256c20b 100644 --- a/javascript/gallery.js +++ b/javascript/gallery.js @@ -1,4 +1,12 @@ /* eslint-disable max-classes-per-file */ +// Known issues +// Images flash on the screen before they get processed and separator is properly closed, especially when root/subfolder has large amount of files +// Search is a bit wonky, I tried to get the separators to hide if 0 hits in seperator are found, but no luck so +// Sorting huge amount of images is slow, might look at optimising, I don't think it's a regression. + +//TODO +// Setting to enable or disable separator state persistence + let ws; let url; @@ -6,7 +14,9 @@ let currentImage; let pruneImagesTimer; let outstanding = 0; let lastSort = 0; -let lastSortName = 'none'; +let lastSortName = 'None'; +// Store separator states for the session +const separatorStates = new Map(); const el = { folders: undefined, files: undefined, @@ -15,6 +25,8 @@ const el = { btnSend: undefined, }; +const SUPPORTED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp', 'tiff', 'jp2', 'jxl', 'gif', 'mp4', 'mkv', 'avi', 'mjpeg', 'mpg', 'avr']; + // HTML Elements class GalleryFolder extends HTMLElement { @@ -73,34 +85,159 @@ async function createThumb(img) { async function handleSeparator(separator) { separator.classList.toggle('gallery-separator-hidden'); + const nowHidden = separator.classList.contains('gallery-separator-hidden'); + + // Store the state (true = open, false = closed) + separatorStates.set(separator.title, !nowHidden); + + // Update arrow and count + const arrow = separator.querySelector('.gallery-separator-arrow'); + arrow.style.transform = nowHidden ? 'rotate(0deg)' : 'rotate(90deg)'; + const all = Array.from(el.files.children); - const hide = separator.classList.contains('gallery-separator-hidden'); - separator.innerText = `${hide ? '▶' : '▼'} ${separator.title}`; for (const f of all) { - if (separator.title.length > 0 && f.name?.startsWith(separator.title)) f.style.display = hide ? 'none' : 'unset'; + if (!f.name) continue; // Skip separators + + // Check if file belongs to this exact directory + const fileDir = f.name.match(/(.*)[\/\\]/); + const fileDirPath = fileDir ? fileDir[1] : ''; + + if (separator.title.length > 0 && fileDirPath === separator.title) { + f.style.display = nowHidden ? 'none' : 'unset'; + } } + // Note: Count is not updated here on manual toggle, as it reflects the total. + // If I end up implementing it, the search function will handle dynamic count updates. } async function addSeparators() { document.querySelectorAll('.gallery-separator').forEach((node) => el.files.removeChild(node)); const all = Array.from(el.files.children); let lastDir; + let isFirstSeparator = true; // Flag to open the first separator by default + + // First, add the style for separators if it doesn't exist + if (!document.getElementById('gallery-separator-styles')) { + const style = document.createElement('style'); + style.id = 'gallery-separator-styles'; + style.textContent = ` + .gallery-separator { + cursor: pointer; + padding: 8px; + background-color: var(--sd-secondary-color); + border-radius: var(--sd-border-radius); + margin: 4px 0; + user-select: none; + display: flex; + align-items: center; + gap: 8px; + max-width: 100%; + } + .gallery-separator:hover { + background-color: var(--sd-button-hover-color); + } + .gallery-separator:hover .gallery-separator-arrow, + .gallery-separator:hover .gallery-separator-name, + .gallery-separator:hover .gallery-separator-count { + color: var(--sd-input-hover-text-color); + } + .gallery-separator-arrow { + display: inline-block; + transition: transform 0.2s ease-in-out; + flex-shrink: 0; + color: var(--sd-input-text-color); + } + .gallery-separator-name { + flex: 1; + min-width: 0; + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-right: 8px; + color: var(--sd-input-text-color); + } + .gallery-separator-count { + color: var(--sd-input-placeholder-text-color); + font-size: 0.9em; + flex-shrink: 0; + margin-left: auto; + } + `; + document.head.appendChild(style); + } + + // First pass: create separators for (const f of all) { - let dir = f.name.match(/(.*)[\/\\]/); + let dir = f.name?.match(/(.*)[\/\\]/); if (!dir) dir = ''; else dir = dir[1]; if (dir !== lastDir) { lastDir = dir; if (dir.length > 0) { + // Count files in this directory + let fileCount = 0; + for (const file of all) { + if (!file.name) continue; + const fileDir = file.name.match(/(.*)[\/\\]/); + const fileDirPath = fileDir ? fileDir[1] : ''; + if (fileDirPath === dir) fileCount++; + } + const sep = document.createElement('div'); sep.className = 'gallery-separator'; - sep.innerText = `▼ ${dir}`; sep.title = dir; + + // Default to open for the first separator if no state is saved, otherwise closed. + const isOpen = separatorStates.has(dir) ? separatorStates.get(dir) : isFirstSeparator; + separatorStates.set(dir, isOpen); // Ensure it's in the map + if (isFirstSeparator) isFirstSeparator = false; // Subsequent separators will default to closed + + if (!isOpen) { + sep.classList.add('gallery-separator-hidden'); + } + + // Create arrow span + const arrow = document.createElement('span'); + arrow.className = 'gallery-separator-arrow'; + arrow.textContent = '▶'; + arrow.style.transform = isOpen ? 'rotate(90deg)' : 'rotate(0deg)'; + + // Create directory name span + const dirName = document.createElement('span'); + dirName.className = 'gallery-separator-name'; + dirName.textContent = dir; + dirName.title = dir; // Show full path on hover + + // Create count span + const count = document.createElement('span'); + count.className = 'gallery-separator-count'; + count.textContent = `${fileCount} files`; + sep.dataset.totalFiles = fileCount; // Store total count for search filtering + + sep.appendChild(arrow); + sep.appendChild(dirName); + sep.appendChild(count); + sep.onclick = () => handleSeparator(sep); el.files.insertBefore(sep, f); } } } + + // Second pass: hide files in closed directories + for (const f of all) { + if (!f.name) continue; // Skip separators + + const dir = f.name.match(/(.*)[\/\\]/); + if (dir && dir[1]) { + const dirPath = dir[1]; + const isOpen = separatorStates.get(dirPath); + if (isOpen === false) { + f.style.display = 'none'; + } + } + } } async function delayFetchThumb(fn) { @@ -140,11 +277,17 @@ class GalleryFile extends HTMLElement { if (this.shadow.children.length > 0) { return; } - const ext = this.name.split('.').pop().toLowerCase(); - if (!['jpg', 'jpeg', 'png', 'gif', 'webp', 'jxl', 'svg', 'mp4'].includes(ext)) { - // console.error(`gallery: type=${ext} file=${this.name} unsupported`); - return; + + // Check separator state early to hide the element immediately + const dir = this.name.match(/(.*)[\/\\]/); + if (dir && dir[1]) { + const dirPath = dir[1]; + const isOpen = separatorStates.get(dirPath); + if (isOpen === false) { + this.style.display = 'none'; + } } + this.hash = await getHash(`${this.folder}/${this.name}/${this.size}/${this.mtime}`); // eslint-disable-line no-use-before-define const style = document.createElement('style'); const width = opts.browser_fixed_width ? `${opts.extra_networks_card_size}px` : 'unset'; @@ -225,7 +368,13 @@ class GalleryFile extends HTMLElement { return; // avoid double-adding } this.title = img.title; - this.style.display = this.title.toLowerCase().includes(el.search.value.toLowerCase()) ? 'unset' : 'none'; + + // Final visibility check based on search term. + const shouldDisplayBasedOnSearch = this.title.toLowerCase().includes(el.search.value.toLowerCase()); + if (this.style.display !== 'none') { // Only proceed if not already hidden by a closed separator + this.style.display = shouldDisplayBasedOnSearch ? 'unset' : 'none'; + } + this.shadow.appendChild(style); this.shadow.appendChild(img); } @@ -262,15 +411,49 @@ async function wsConnect(socket, timeout = 5000) { return isOpened(); } -async function gallerySearch(evt) { +async function gallerySearch() { if (el.search.busy) clearTimeout(el.search.busy); el.search.busy = setTimeout(async () => { - let numFound = 0; - const all = Array.from(el.files.children); - const str = el.search.value.toLowerCase(); - const r = /^(.+)([=<>])(.*)/; const t0 = performance.now(); - for (const f of all) { + const str = el.search.value.toLowerCase(); + const allFiles = Array.from(el.files.children).filter(node => node.name); + const allSeparators = Array.from(el.files.children).filter(node => node.classList.contains('gallery-separator')); + + // If search is cleared, restore original view + if (str === '') { + allSeparators.forEach(sep => { + sep.style.display = 'flex'; + const isOpen = separatorStates.has(sep.title) ? separatorStates.get(sep.title) : false; + + const countSpan = sep.querySelector('.gallery-separator-count'); + if (countSpan && sep.dataset.totalFiles) { + countSpan.textContent = `${sep.dataset.totalFiles} files`; + } + + const arrow = sep.querySelector('.gallery-separator-arrow'); + sep.classList.toggle('gallery-separator-hidden', !isOpen); + if(arrow) arrow.style.transform = isOpen ? 'rotate(90deg)' : 'rotate(0deg)'; + }); + + allFiles.forEach(f => { + const dir = f.name.match(/(.*)[\/\\]/); + const dirPath = (dir && dir[1]) ? dir[1] : ''; + const isOpen = separatorStates.get(dirPath); + f.style.display = (!dirPath || isOpen) ? 'unset' : 'none'; + }); + + updateStatusWithSort(`Filter | Cleared | ${allFiles.length.toLocaleString()} images`); + return; + } + + // --- Search logic --- + let totalFound = 0; + const directoryMatches = new Map(); + const fileMatches = new WeakSet(); + const r = /^(.+)([=<>])(.*)/; + + for (const f of allFiles) { + let isMatch = false; if (r.test(str)) { const match = str.match(r); const key = match[1].trim(); @@ -278,23 +461,50 @@ async function gallerySearch(evt) { let val = match[3].trim(); if (key === 'mtime') val = new Date(val); if (((op === '=') && (f[key] === val)) || ((op === '>') && (f[key] > val)) || ((op === '<') && (f[key] < val))) { - f.style.display = 'unset'; - numFound++; - } else { - f.style.display = 'none'; + isMatch = true; } } else if (f.title?.toLowerCase().includes(str) || f.exif?.toLowerCase().includes(str)) { - f.style.display = 'unset'; - numFound++; - } else { - f.style.display = 'none'; + isMatch = true; + } + + if (isMatch) { + fileMatches.add(f); + totalFound++; + const dir = f.name.match(/(.*)[\/\\]/); + const dirPath = (dir && dir[1]) ? dir[1] : ''; + directoryMatches.set(dirPath, (directoryMatches.get(dirPath) || 0) + 1); } - const t1 = performance.now(); - el.status.innerText = `Filter | ${f.folder} | ${numFound.toLocaleString()}/${all.length.toLocaleString()} images | ${Math.floor(t1 - t0).toLocaleString()}ms`; } + + // Update separators based on search results + for (const sep of allSeparators) { + const dirPath = sep.title; + const foundCount = directoryMatches.get(dirPath) || 0; + + if (foundCount > 0) { + sep.style.display = 'flex'; // Show separator + sep.classList.remove('gallery-separator-hidden'); // Force open + + const arrow = sep.querySelector('.gallery-separator-arrow'); + if (arrow) arrow.style.transform = 'rotate(90deg)'; + + // Removed file count update during search as it was buggy. + } else { + sep.style.display = 'none'; // Hide separator + } + } + + // Update file visibility + for (const f of allFiles) { + f.style.display = fileMatches.has(f) ? 'unset' : 'none'; + } + + const t1 = performance.now(); + updateStatusWithSort(`Filter | ${totalFound.toLocaleString()} / ${allFiles.length.toLocaleString()} images | ${Math.floor(t1 - t0).toLocaleString()}ms`); }, 250); } + const findDuplicates = (arr, key) => { const map = new Map(); return arr.filter((item) => { @@ -305,58 +515,64 @@ const findDuplicates = (arr, key) => { }); }; +// Helper function to update status with sort mode +function updateStatusWithSort(message) { + const sortIndicator = `Sort: ${lastSortName} | `; + el.status.innerText = sortIndicator + message; +} + async function gallerySort(btn) { const t0 = performance.now(); const arr = Array.from(el.files.children).filter((node) => node.name); // filter out separators if (arr.length === 0) return; // no files to sort if (btn) lastSort = btn.charCodeAt(0); - lastSortName = 'none'; + lastSortName = 'None'; const fragment = document.createDocumentFragment(); switch (lastSort) { case 61789: // name asc - lastSortName = 'name asc'; + lastSortName = 'Name Ascending'; arr .sort((a, b) => a.name.localeCompare(b.name)) .forEach((node) => fragment.appendChild(node)); break; case 61790: // name dsc - lastSortName = 'name dsc'; + lastSortName = 'Name Descending'; arr .sort((b, a) => a.name.localeCompare(b.name)) .forEach((node) => fragment.appendChild(node)); break; case 61792: // size asc - lastSortName = 'size asc'; + lastSortName = 'Size Ascending'; arr .sort((a, b) => a.size - b.size) .forEach((node) => fragment.appendChild(node)); break; case 61793: // size dsc - lastSortName = 'size dsc'; + lastSortName = 'Size Descending'; arr .sort((b, a) => a.size - b.size) .forEach((node) => fragment.appendChild(node)); break; case 61794: // resolution asc - lastSortName = 'resolution asc'; + lastSortName = 'Resolution Ascending'; arr .sort((a, b) => a.width * a.height - b.width * b.height) .forEach((node) => fragment.appendChild(node)); break; case 61795: // resolution dsc - lastSortName = 'resolution dsc'; + lastSortName = 'Resolution Descending'; arr .sort((b, a) => a.width * a.height - b.width * b.height) .forEach((node) => fragment.appendChild(node)); break; case 61662: - lastSortName = 'modified asc'; + lastSortName = 'Modified Ascending'; arr .sort((a, b) => a.mtime - b.mtime) .forEach((node) => fragment.appendChild(node)); break; case 61661: - lastSortName = 'modified dsc'; + lastSortName = 'Modified Descending'; arr .sort((b, a) => a.mtime - b.mtime) .forEach((node) => fragment.appendChild(node)); @@ -368,35 +584,55 @@ async function gallerySort(btn) { el.files.innerHTML = ''; el.files.appendChild(fragment); addSeparators(); + + // After sorting and adding separators, ensure files respect separator states + const all = Array.from(el.files.children); + for (const f of all) { + if (!f.name) continue; // Skip separators + + const dir = f.name.match(/(.*)[\/\\]/); + if (dir && dir[1]) { + const dirPath = dir[1]; + const isOpen = separatorStates.get(dirPath); + if (isOpen === false) { + f.style.display = 'none'; + } + } + } + const t1 = performance.now(); log(`gallerySort: char=${lastSort} len=${arr.length} time=${Math.floor(t1 - t0)} sort=${lastSortName}`); - el.status.innerText = `Sort | ${lastSortName} | ${arr.length.toLocaleString()} images | ${Math.floor(t1 - t0).toLocaleString()}ms`; + updateStatusWithSort(`${arr.length.toLocaleString()} images | ${Math.floor(t1 - t0).toLocaleString()}ms`); } async function fetchFilesHT(evt) { const t0 = performance.now(); const fragment = document.createDocumentFragment(); - el.status.innerText = `Folder | ${evt.target.name} | in-progress`; + updateStatusWithSort(`Folder | ${evt.target.name} | in-progress`); let numFiles = 0; const res = await fetch(`${window.api}/browser/files?folder=${encodeURI(evt.target.name)}`); if (!res || res.status !== 200) { - el.status.innerText = `Folder | ${evt.target.name} | failed: ${res?.statusText}`; + updateStatusWithSort(`Folder | ${evt.target.name} | failed: ${res?.statusText}`); return; } const jsonData = await res.json(); for (const line of jsonData) { const data = decodeURI(line).split('##F##'); - numFiles++; - const f = new GalleryFile(data[0], data[1]); - fragment.appendChild(f); + const fileName = data[1]; + const ext = fileName.split('.').pop().toLowerCase(); + if (SUPPORTED_EXTENSIONS.includes(ext)) { + numFiles++; + const f = new GalleryFile(data[0], fileName); + fragment.appendChild(f); + } } el.files.appendChild(fragment); const t1 = performance.now(); log(`gallery: folder=${evt.target.name} num=${numFiles} time=${Math.floor(t1 - t0)}ms`); - el.status.innerText = `Folder | ${evt.target.name} | ${numFiles.toLocaleString()} images | ${Math.floor(t1 - t0).toLocaleString()}ms`; + updateStatusWithSort(`Folder | ${evt.target.name} | ${numFiles.toLocaleString()} images | ${Math.floor(t1 - t0).toLocaleString()}ms`); addSeparators(); } @@ -417,7 +653,7 @@ async function fetchFilesWS(evt) { // fetch file-by-file list over websockets await fetchFilesHT(evt); // fallback to http return; } - el.status.innerText = `Folder | ${evt.target.name}`; + updateStatusWithSort(`Folder | ${evt.target.name}`); const t0 = performance.now(); let numFiles = 0; let t1 = performance.now(); @@ -429,13 +665,17 @@ async function fetchFilesWS(evt) { // fetch file-by-file list over websockets if (data[0] === '#END#') { ws.close(); } else { - const file = new GalleryFile(data[0], data[1]); - numFiles++; - fragment.appendChild(file); - if (numFiles % 100 === 0) { - el.status.innerText = `Folder | ${evt.target.name} | ${numFiles.toLocaleString()} images | in-progress | ${Math.floor(t1 - t0).toLocaleString()}ms`; - el.files.appendChild(fragment); - fragment = document.createDocumentFragment(); + const fileName = data[1]; + const ext = fileName.split('.').pop().toLowerCase(); + if (SUPPORTED_EXTENSIONS.includes(ext)) { + const file = new GalleryFile(data[0], fileName); + numFiles++; + fragment.appendChild(file); + if (numFiles % 100 === 0) { + updateStatusWithSort(`Folder | ${evt.target.name} | ${numFiles.toLocaleString()} images | in-progress | ${Math.floor(t1 - t0).toLocaleString()}ms`); + el.files.appendChild(fragment); + fragment = document.createDocumentFragment(); + } } } }; @@ -443,7 +683,7 @@ async function fetchFilesWS(evt) { // fetch file-by-file list over websockets el.files.appendChild(fragment); // gallerySort(); log(`gallery: folder=${evt.target.name} num=${numFiles} time=${Math.floor(t1 - t0)}ms`); - el.status.innerText = `Folder | ${evt.target.name} | ${numFiles.toLocaleString()} images | ${Math.floor(t1 - t0).toLocaleString()}ms`; + updateStatusWithSort(`Folder | ${evt.target.name} | ${numFiles.toLocaleString()} images | ${Math.floor(t1 - t0).toLocaleString()}ms`); addSeparators(); }; ws.onerror = (event) => {