diff --git a/javascript/gallery.js b/javascript/gallery.js index 0c75f685a..9a7bc9579 100644 --- a/javascript/gallery.js +++ b/javascript/gallery.js @@ -314,6 +314,8 @@ class SimpleFunctionQueue { // HTML Elements class GalleryFolder extends HTMLElement { + static folders = new Set(); + constructor(folder) { super(); // Support both old format (string) and new format (object with path and label) @@ -327,21 +329,175 @@ class GalleryFolder extends HTMLElement { this.style.overflowX = 'hidden'; this.shadow = this.attachShadow({ mode: 'open' }); this.shadow.adoptedStyleSheets = [folderStylesheet]; + + this.div = document.createElement('div'); } connectedCallback() { - const div = document.createElement('div'); - div.className = 'gallery-folder'; - div.innerHTML = `\uf03e ${this.label}`; - div.title = this.name; // Show full path on hover - div.addEventListener('click', () => { - for (const folder of el.folders.children) { - if (folder.name === this.name) folder.shadow.firstElementChild.classList.add('gallery-folder-selected'); - else folder.shadow.firstElementChild.classList.remove('gallery-folder-selected'); + if (GalleryFolder.folders.has(this)) return; // Element is just being moved + + this.div.className = 'gallery-folder'; + this.div.innerHTML = `\uf03e ${this.label}`; + this.div.title = this.name; // Show full path on hover + this.div.addEventListener('click', () => { this.updateSelected(); }); // Ensures 'this' isn't the div in the called method + this.div.addEventListener('click', fetchFilesWS); // eslint-disable-line no-use-before-define + this.shadow.appendChild(this.div); + GalleryFolder.folders.add(this); + } + + async disconnectedCallback() { + await Promise.resolve(); // Wait for other microtasks (such as element moving) + if (this.isConnected) return; + GalleryFolder.folders.delete(this); + } + + updateSelected() { + this.div.classList.add('gallery-folder-selected'); + for (const folder of GalleryFolder.folders) { + if (folder !== this) { + folder.div.classList.remove('gallery-folder-selected'); } - }); - div.addEventListener('click', fetchFilesWS); // eslint-disable-line no-use-before-define - this.shadow.appendChild(div); + } + } +} + +async function delayFetchThumb(fn, signal) { + await awaitForOutstanding(16, signal); + try { + outstanding++; + const ts = Date.now().toString(); + const res = await authFetch(`${window.api}/browser/thumb?file=${encodeURI(fn)}&ts=${ts}`, { priority: 'low' }); + if (!res.ok) { + error(`fetchThumb: ${res.statusText}`); + return undefined; + } + const json = await res.json(); + if (!res || !json || json.error || Object.keys(json).length === 0) { + if (json.error) error(`fetchThumb: ${json.error}`); + return undefined; + } + return json; + } finally { + outstanding--; + } +} + +class GalleryFile extends HTMLElement { + /** @type {AbortSignal} */ + #signal; + + constructor(folder, file, signal) { + super(); + this.folder = folder; + this.name = file; + this.#signal = signal; + this.size = 0; + this.mtime = 0; + this.hash = undefined; + this.exif = ''; + this.width = 0; + this.height = 0; + this.src = `${this.folder}/${this.name}`; + this.shadow = this.attachShadow({ mode: 'open' }); + this.shadow.adoptedStyleSheets = [fileStylesheet]; + + this.firstRun = true; + } + + async connectedCallback() { + if (!this.firstRun) return; // Element is just being moved + this.firstRun = false; + + // 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'; + } + } + + // Normalize path to ensure consistent hash regardless of which folder view is used + const normalizedPath = this.src.replace(/\/+/g, '/').replace(/\/$/, ''); + this.hash = await getHash(`${normalizedPath}/${this.size}/${this.mtime}`); // eslint-disable-line no-use-before-define + const cachedData = (this.hash && opts.browser_cache) ? await idbGet(this.hash).catch(() => undefined) : undefined; + const img = document.createElement('img'); + img.className = 'gallery-file'; + img.loading = 'lazy'; + img.onload = async () => { + img.title += `\nResolution: ${this.width} x ${this.height}`; + this.title = img.title; + if (!cachedData && opts.browser_cache) { + if ((this.width === 0) || (this.height === 0)) { // fetch thumb failed so we use actual image + this.width = img.naturalWidth; + this.height = img.naturalHeight; + } + } + }; + let ok = true; + if (cachedData?.img) { + img.src = cachedData.img; + this.exif = cachedData.exif; + this.width = cachedData.width; + this.height = cachedData.height; + this.size = cachedData.size; + this.mtime = new Date(cachedData.mtime); + } else { + try { + const json = await delayFetchThumb(this.src, this.#signal); + if (!json) { + ok = false; + } else { + img.src = json.data; + this.exif = json.exif; + this.width = json.width; + this.height = json.height; + this.size = json.size; + this.mtime = new Date(json.mtime); + if (opts.browser_cache) { + // Store file's actual parent directory (not browsed folder) for consistent cleanup + const fileDir = this.src.replace(/\/+/g, '/').replace(/\/[^/]+$/, ''); + await idbAdd({ + hash: this.hash, + folder: fileDir, + file: this.name, + size: this.size, + mtime: this.mtime, + width: this.width, + height: this.height, + src: this.src, + exif: this.exif, + img: img.src, + // exif: await getExif(img), // alternative client-side exif + // img: await createThumb(img), // alternative client-side thumb + }); + } + } + } catch (err) { // thumb fetch failed so assign actual image + img.src = `file=${this.src}`; + } + } + if (this.#signal.aborted) { // Do not change the operations order from here... + return; + } + galleryHashes.add(this.hash); + if (!ok) { + return; + } // ... to here unless modifications are also being made to maintenance functionality and the usage of AbortController/AbortSignal + img.onclick = () => { + setGallerySelectionByElement(this, { send: true }); + }; + img.title = `Folder: ${this.folder}\nFile: ${this.name}\nSize: ${this.size.toLocaleString()} bytes\nModified: ${this.mtime.toLocaleString()}`; + this.title = img.title; + + // 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(img); } } @@ -472,148 +628,6 @@ async function addSeparators() { } } -async function delayFetchThumb(fn, signal) { - await awaitForOutstanding(16, signal); - try { - outstanding++; - const ts = Date.now().toString(); - const res = await authFetch(`${window.api}/browser/thumb?file=${encodeURI(fn)}&ts=${ts}`, { priority: 'low' }); - if (!res.ok) { - error(`fetchThumb: ${res.statusText}`); - return undefined; - } - const json = await res.json(); - if (!res || !json || json.error || Object.keys(json).length === 0) { - if (json.error) error(`fetchThumb: ${json.error}`); - return undefined; - } - return json; - } finally { - outstanding--; - } -} - -class GalleryFile extends HTMLElement { - /** @type {AbortSignal} */ - #signal; - - constructor(folder, file, signal) { - super(); - this.folder = folder; - this.name = file; - this.#signal = signal; - this.size = 0; - this.mtime = 0; - this.hash = undefined; - this.exif = ''; - this.width = 0; - this.height = 0; - this.src = `${this.folder}/${this.name}`; - this.shadow = this.attachShadow({ mode: 'open' }); - this.shadow.adoptedStyleSheets = [fileStylesheet]; - } - - async connectedCallback() { - if (this.shadow.children.length > 0) { - 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'; - } - } - - // Normalize path to ensure consistent hash regardless of which folder view is used - const normalizedPath = this.src.replace(/\/+/g, '/').replace(/\/$/, ''); - this.hash = await getHash(`${normalizedPath}/${this.size}/${this.mtime}`); // eslint-disable-line no-use-before-define - const cachedData = (this.hash && opts.browser_cache) ? await idbGet(this.hash).catch(() => undefined) : undefined; - const img = document.createElement('img'); - img.className = 'gallery-file'; - img.loading = 'lazy'; - img.onload = async () => { - img.title += `\nResolution: ${this.width} x ${this.height}`; - this.title = img.title; - if (!cachedData && opts.browser_cache) { - if ((this.width === 0) || (this.height === 0)) { // fetch thumb failed so we use actual image - this.width = img.naturalWidth; - this.height = img.naturalHeight; - } - } - }; - let ok = true; - if (cachedData?.img) { - img.src = cachedData.img; - this.exif = cachedData.exif; - this.width = cachedData.width; - this.height = cachedData.height; - this.size = cachedData.size; - this.mtime = new Date(cachedData.mtime); - } else { - try { - const json = await delayFetchThumb(this.src, this.#signal); - if (!json) { - ok = false; - } else { - img.src = json.data; - this.exif = json.exif; - this.width = json.width; - this.height = json.height; - this.size = json.size; - this.mtime = new Date(json.mtime); - if (opts.browser_cache) { - // Store file's actual parent directory (not browsed folder) for consistent cleanup - const fileDir = this.src.replace(/\/+/g, '/').replace(/\/[^/]+$/, ''); - await idbAdd({ - hash: this.hash, - folder: fileDir, - file: this.name, - size: this.size, - mtime: this.mtime, - width: this.width, - height: this.height, - src: this.src, - exif: this.exif, - img: img.src, - // exif: await getExif(img), // alternative client-side exif - // img: await createThumb(img), // alternative client-side thumb - }); - } - } - } catch (err) { // thumb fetch failed so assign actual image - img.src = `file=${this.src}`; - } - } - if (this.#signal.aborted) { // Do not change the operations order from here... - return; - } - galleryHashes.add(this.hash); - if (!ok) { - return; - } // ... to here unless modifications are also being made to maintenance functionality and the usage of AbortController/AbortSignal - img.onclick = () => { - setGallerySelectionByElement(this, { send: true }); - }; - img.title = `Folder: ${this.folder}\nFile: ${this.name}\nSize: ${this.size.toLocaleString()} bytes\nModified: ${this.mtime.toLocaleString()}`; - if (this.shadow.children.length > 0) { - return; // avoid double-adding - } - this.title = img.title; - - // 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(img); - } -} - // methods const gallerySendImage = (_images) => [currentImage]; // invoked by gradio button