C / explorer.js
q6's picture
Speed up explorer scrolling and add Favorites view
b4e7e0f
Raw History Blame Contribute Delete
26.2 kB
import { downloadUpscale } from "/upscale/image.js"
const $ = (selector, root = document) => root.querySelector(selector)
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)]
const p = new URLSearchParams(location.search).get("p")
const api = (path, query = {}) => `${path}?${new URLSearchParams({ p, ...query })}`
const preview = image => api("/api/images/preview", { path: image.path, v: image.version })
const original = image => api("/api/images/original", { path: image.path, v: image.version })
const MIN_ZOOM = 1
const MAX_ZOOM = 8
const ZOOM_STEP = 1.2
const INFINITE = "*"
const FAVORITES = "favorites"
const SEARCH_DELAY = 250
const LOAD_MARGIN = "640px"
const PREVIEW_MARGIN = "320px"
class ImageExplorer extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<main>
<div class="date-heading">
<button class="date-prev" type="button" aria-label="Previous date" hidden>←</button>
<h1>Image folders</h1>
<button class="date-next" type="button" aria-label="Next date" hidden>→</button>
</div>
<div class="tools">
<button class="back" type="button" hidden>Folders</button>
<input type="search" placeholder="Filter folders" aria-label="Filter folders">
<button class="favorites" type="button" aria-pressed="false" hidden>Favorites</button>
<button class="all" type="button">Select all</button>
<button class="clear" type="button">Clear</button>
<button class="download primary" type="button">Download 7z</button>
<button class="delete" type="button">Delete</button>
</div>
<div class="status">Loading</div>
<div class="content"></div>
</main>
<dialog class="lightbox">
<div class="image-actions">
<button class="upscale primary" type="button">Upscale</button>
<button class="star expanded-star" type="button" aria-label="Star image"></button>
<span class="upscale-status" role="status" aria-live="polite"></span>
</div>
<button class="close" type="button" aria-label="Close">Close</button>
<div class="zoom-tools">
<button class="zoom-out" type="button" aria-label="Zoom out">−</button>
<button class="reset" type="button">Reset</button>
<button class="zoom-in" type="button" aria-label="Zoom in">+</button>
</div>
<pre class="prompt"></pre>
<div class="viewer"><img alt="" draggable="false"></div>
</dialog>`
this.cache = new Map()
this.setupPreviews()
this.pageObserver = new IntersectionObserver(entries => {
if (entries.some(entry => entry.isIntersecting)) this.loadMore()
}, { rootMargin: LOAD_MARGIN })
this.load()
$(".back", this).addEventListener("click", () => this.load())
$(".date-prev", this).addEventListener("click", () => this.navigateDate(1))
$(".date-next", this).addEventListener("click", () => this.navigateDate(-1))
$(".all", this).addEventListener("click", () => this.select(true))
$(".clear", this).addEventListener("click", () => this.select(false))
$(".favorites", this).addEventListener("click", () => this.toggleFavorites())
$(".download", this).addEventListener("click", () => this.download())
$(".delete", this).addEventListener("click", () => this.delete())
$("input[type=search]", this).addEventListener("input", event => {
this.filter(event.target.value)
})
this.setupLightbox()
}
async load(folder, favoritesOnly = false, query = "") {
clearTimeout(this.searchTimer)
this.controller?.abort()
const controller = new AbortController()
this.controller = controller
this.folder = folder
$(".lightbox", this).close()
this.favoritesOnly = folder === FAVORITES || favoritesOnly
this.searchQuery = query
this.images = []
this.imagePaths = new Set()
this.groupGrids = new Map()
this.nextOffset = folder ? 0 : null
this.loadingPage = false
this.more = null
$(".status", this).textContent = "Loading"
this.previewObserver.disconnect()
this.pageObserver.disconnect()
$(".content", this).replaceChildren()
$("h1", this).textContent = folder === FAVORITES ? "Favorites"
: folder === INFINITE ? "Infinite" : folder || "Image folders"
$(".back", this).hidden = !folder
$(".all", this).textContent = folder ? "Select loaded" : "Select all"
const favorites = $(".favorites", this)
favorites.hidden = !folder || folder === FAVORITES
favorites.setAttribute("aria-pressed", this.favoritesOnly)
this.updateDateNav()
const search = $("input[type=search]", this)
search.value = query
search.placeholder = folder ? "Search positive prompts" : "Filter folders"
search.setAttribute("aria-label", search.placeholder)
if (folder) {
if (folder !== INFINITE && folder !== FAVORITES) this.addImages([])
this.more = document.createElement("button")
this.more.className = "load-more"
this.more.type = "button"
this.more.textContent = "Load more"
this.more.addEventListener("click", () => this.loadMore())
$(".content", this).append(this.more)
this.pageObserver.observe(this.more)
await this.loadMore()
return
}
try {
let data = this.cache.get(folder)
if (!data) {
const response = await fetch(api("/api/images"), { signal: controller.signal })
if (!response.ok) throw new Error("Unable to load folders")
data = await response.json()
}
if (controller !== this.controller) return
this.cache.set(folder, data)
this.folders = [FAVORITES, INFINITE, ...data.folders.map(item => item.name)]
this.addFolders(data.folders)
this.update()
} catch (error) {
if (controller === this.controller) $(".status", this).textContent = error.message
}
}
loadMore() {
if (this.loadingPage) return this.pageTask
if (this.nextOffset === null || !this.folder || this.pendingStars?.size) return
this.pageTask = this.loadPage()
return this.pageTask
}
async loadPage() {
const controller = this.controller
const grouped = this.folder === INFINITE || this.folder === FAVORITES
const query = {
offset: this.nextOffset,
favorites: this.favoritesOnly,
search: this.searchQuery,
...(grouped ? { infinite: true } : { folder: this.folder }),
}
this.loadingPage = true
this.more.disabled = true
this.more.textContent = "Loading"
try {
const response = await fetch(api("/api/images", query), { signal: controller.signal })
if (!response.ok) throw new Error("Unable to load images")
const data = await response.json()
if (controller !== this.controller) return
if (grouped) this.addGroups(data.groups)
else this.addImages(data.images)
this.nextOffset = data.next_offset
this.more.hidden = this.nextOffset === null
this.more.textContent = "Load more"
this.update()
this.pageObserver.unobserve(this.more)
if (this.nextOffset !== null) this.pageObserver.observe(this.more)
} catch (error) {
if (controller !== this.controller) return
this.pageObserver.unobserve(this.more)
this.more.textContent = "Retry loading images"
$(".status", this).textContent = error.message
} finally {
if (controller === this.controller) {
this.loadingPage = false
this.more.disabled = false
}
}
}
updateDateNav() {
const index = this.folders?.indexOf(this.folder) ?? -1
const previous = $(".date-prev", this)
const next = $(".date-next", this)
previous.hidden = index < 0
next.hidden = index < 0
if (index < 0) return
previous.disabled = index === this.folders.length - 1
next.disabled = index === 0
previous.title = this.folders[index + 1] || ""
next.title = this.folders[index - 1] || ""
}
navigateDate(step) {
const index = this.folders.indexOf(this.folder) + step
if (this.folders[index]) this.load(this.folders[index])
}
setupPreviews() {
this.previewObserver = new IntersectionObserver(entries => {
for (const entry of entries) {
if (!entry.isIntersecting) continue
const image = entry.target
image.src = image.dataset.src
delete image.dataset.src
this.previewObserver.unobserve(image)
}
}, { rootMargin: PREVIEW_MARGIN })
}
addFolders(folders) {
const grid = document.createElement("div")
grid.className = "grid folders"
for (const [folder, label] of [[FAVORITES, "Favorites"], [INFINITE, "Infinite"]]) {
const card = document.createElement("div")
card.className = "folder entry"
card.dataset.name = label.toLowerCase()
const open = document.createElement("button")
open.className = "folder-open"
open.type = "button"
open.addEventListener("click", () => this.load(folder))
const name = document.createElement("strong")
name.textContent = label
open.append(name)
card.append(open)
grid.append(card)
}
for (const folder of folders) {
const card = document.createElement("div")
card.className = "folder entry"
card.dataset.name = folder.name.toLowerCase()
const input = this.checkbox(folder.name)
const open = document.createElement("button")
open.className = "folder-open"
open.type = "button"
open.addEventListener("click", () => this.load(folder.name))
const name = document.createElement("strong")
name.textContent = folder.name
open.append(name)
card.append(input, open)
grid.append(card)
}
$(".content", this).append(grid)
}
appendImages(grid, images) {
const cards = document.createDocumentFragment()
for (const image of images) {
if (this.imagePaths.has(image.path)) continue
this.imagePaths.add(image.path)
this.images.push(image)
cards.append(this.card(image))
}
grid.append(cards)
}
addImages(images) {
let grid = $(".content > .grid", this)
if (!grid) {
grid = document.createElement("div")
grid.className = "grid"
$(".content", this).append(grid)
}
this.appendImages(grid, images)
}
addGroups(groups) {
const content = $(".content", this)
for (const group of groups) {
let grid = this.groupGrids.get(group.name)
if (!grid) {
const section = document.createElement("section")
section.className = "date-group"
const heading = document.createElement("h2")
heading.textContent = group.name
grid = document.createElement("div")
grid.className = "grid"
section.append(heading, grid)
content.insertBefore(section, this.more)
this.groupGrids.set(group.name, grid)
}
this.appendImages(grid, group.images)
grid.parentElement.hidden = !$(".entry:not([hidden])", grid)
}
}
checkbox(path) {
const input = document.createElement("input")
input.type = "checkbox"
input.className = "item"
input.dataset.path = path
input.setAttribute("aria-label", `Select ${path}`)
input.addEventListener("change", () => this.update())
return input
}
card(image) {
const card = document.createElement("div")
card.className = "image entry"
card.classList.toggle("favorite", image.starred)
card.dataset.name = image.name.toLowerCase()
card.dataset.prompt = image.prompt.toLowerCase()
card.dataset.path = image.path
const open = document.createElement("button")
open.className = "preview"
open.type = "button"
open.addEventListener("click", () => this.openImage(image))
const picture = document.createElement("img")
picture.alt = image.name
picture.decoding = "async"
picture.dataset.src = preview(image)
this.previewObserver.observe(picture)
open.append(picture)
const star = this.starButton(image, "card-star")
const info = document.createElement("label")
info.className = "image-info"
const input = this.checkbox(image.path)
const name = document.createElement("strong")
name.className = "name"
name.textContent = image.name
info.append(input, name)
card.append(open, star, info)
return card
}
starButton(image, className) {
const button = document.createElement("button")
button.className = `star ${className}`
button.type = "button"
button.dataset.path = image.path
button.addEventListener("click", () => this.toggleStar(image))
this.renderStar(button, image.starred)
return button
}
renderStar(button, starred) {
button.textContent = starred ? "★" : "☆"
button.setAttribute("aria-pressed", starred)
button.setAttribute("aria-label", `${starred ? "Unstar" : "Star"} image`)
}
setStar(path, starred) {
for (const image of this.images) {
if (image.path === path) image.starred = starred
}
for (const button of $$(".star", this)) {
if (button.dataset.path === path) this.renderStar(button, starred)
}
for (const card of $$(".image", this)) {
if (card.dataset.path !== path) continue
card.classList.toggle("favorite", starred)
card.hidden = this.favoritesOnly && !starred
if (card.hidden) $(".item", card).checked = false
}
for (const group of $$(".date-group", this)) {
group.hidden = !$(".entry:not([hidden])", group)
}
this.update()
}
async toggleStar(image) {
this.pendingStars ||= new Set()
if (this.pendingStars.has(image.path)) return
this.pendingStars.add(image.path)
const controller = this.controller
const buttons = $$(".star", this).filter(button => (
button.dataset.path === image.path
))
for (const button of buttons) button.disabled = true
const starred = !image.starred
try {
await this.pageTask
if (controller !== this.controller) return
this.setStar(image.path, starred)
const response = await fetch(api("/api/images/star"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: image.path, starred }),
})
if (!response.ok) throw new Error("Unable to update star")
if (controller === this.controller && this.favoritesOnly && !starred
&& this.nextOffset !== null) {
this.nextOffset = Math.max(0, this.nextOffset - 1)
}
} catch (error) {
this.setStar(image.path, !starred)
$(".status", this).textContent = error.message
} finally {
this.pendingStars.delete(image.path)
for (const button of buttons) button.disabled = false
if (this.more && this.nextOffset !== null) {
this.pageObserver.unobserve(this.more)
this.pageObserver.observe(this.more)
}
}
}
select(checked) {
for (const input of $$(".entry:not([hidden]) .item", this)) input.checked = checked
this.update()
}
update() {
const count = $$(".item:checked", this).length
$(".status", this).textContent = `${count} selected`
}
toggleFavorites() {
this.load(this.folder, !this.favoritesOnly, $("input[type=search]", this).value)
}
filter(value) {
clearTimeout(this.searchTimer)
if (this.folder) {
this.searchTimer = setTimeout(() => {
this.load(this.folder, this.favoritesOnly, value)
}, SEARCH_DELAY)
return
}
const query = value.trim().toLowerCase()
for (const entry of $$(".entry", this)) {
entry.hidden = !entry.dataset.name.includes(query)
}
}
async download() {
const items = $$(".item:checked", this).map(input => input.dataset.path)
if (!items.length) return
const button = $(".download", this)
button.disabled = true
$(".status", this).textContent = "Building archive"
try {
const response = await fetch(api("/api/images/download"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items }),
})
if (!response.ok) throw new Error("Archive failed")
const url = URL.createObjectURL(await response.blob())
const link = document.createElement("a")
link.href = url
link.download = "images.7z"
link.click()
URL.revokeObjectURL(url)
} catch (error) {
$(".status", this).textContent = error.message
return
} finally {
button.disabled = false
}
this.update()
}
async delete() {
const items = $$(".item:checked", this).map(input => input.dataset.path)
if (!items.length || !confirm(`Delete ${items.length} selected item(s)?`)) return
const button = $(".delete", this)
button.disabled = true
$(".status", this).textContent = "Deleting"
try {
const response = await fetch(api("/api/images/delete"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items }),
})
if (!response.ok) throw new Error("Delete failed")
this.cache.clear()
await this.load(this.folder, this.favoritesOnly, $("input[type=search]", this).value)
} catch (error) {
$(".status", this).textContent = error.message
} finally {
button.disabled = false
}
}
async upscale() {
const image = this.images[this.imageIndex]
const button = $(".upscale", this)
const status = $(".upscale-status", this)
button.disabled = true
status.textContent = "Upscaling…"
try {
await downloadUpscale({ path: image.path, name: image.name })
status.textContent = "Downloaded"
} catch (error) {
status.textContent = error.message
} finally {
button.disabled = false
}
}
setupLightbox() {
const dialog = $(".lightbox", this)
const viewer = $(".viewer", dialog)
const image = $("img", viewer)
$(".close", dialog).addEventListener("click", () => dialog.close())
$(".upscale", dialog).addEventListener("click", () => this.upscale())
$(".expanded-star", dialog).addEventListener("click", () => {
this.toggleStar(this.images[this.imageIndex])
})
$(".zoom-out", dialog).addEventListener("click", () => this.zoom(1 / ZOOM_STEP))
$(".reset", dialog).addEventListener("click", () => this.resetView())
$(".zoom-in", dialog).addEventListener("click", () => this.zoom(ZOOM_STEP))
dialog.addEventListener("click", event => {
if (event.target === dialog) dialog.close()
})
dialog.addEventListener("keydown", event => {
if (event.key === "ArrowLeft") this.moveImage(-1)
else if (event.key === "ArrowRight") this.moveImage(1)
else if (event.key === "+" || event.key === "=") this.zoom(ZOOM_STEP)
else if (event.key === "-") this.zoom(1 / ZOOM_STEP)
else if (event.key === "0") this.resetView()
else return
event.preventDefault()
})
dialog.addEventListener("close", () => image.removeAttribute("src"))
image.addEventListener("load", () => this.resetView())
viewer.addEventListener("wheel", event => this.zoomWheel(event), {
passive: false,
})
viewer.addEventListener("pointerdown", event => this.startPan(event))
viewer.addEventListener("pointermove", event => this.pan(event))
viewer.addEventListener("pointerup", event => this.stopPan(event))
viewer.addEventListener("pointercancel", event => this.stopPan(event))
viewer.addEventListener("dblclick", () => this.resetView())
}
openImage(image) {
this.imageIndex = this.images.indexOf(image)
this.showImage(image)
$(".lightbox", this).showModal()
}
showImage(image) {
const dialog = $(".lightbox", this)
$("img", dialog).src = original(image)
$("img", dialog).alt = image.name
$(".prompt", dialog).textContent = this.formatPrompt(image)
$(".upscale-status", dialog).textContent = ""
const star = $(".expanded-star", dialog)
star.dataset.path = image.path
this.renderStar(star, image.starred)
}
async moveImage(step) {
const controller = this.controller
const current = this.images[this.imageIndex]
if (step > 0 && this.imageIndex === this.images.length - 1
&& this.nextOffset !== null) await this.loadMore()
if (controller !== this.controller) return
const images = this.images.filter(image => !this.favoritesOnly || image.starred)
if (!images.length) {
$(".lightbox", this).close()
return
}
const index = images.indexOf(current)
const image = images[(index + step + images.length) % images.length]
this.imageIndex = this.images.indexOf(image)
this.showImage(image)
}
formatPrompt(image) {
if (!image.prompt) return "Prompt unavailable"
const lines = [`1:${image.prompt}`]
if (image.second_prompt) {
lines.push(`2:${image.second_prompt}`)
}
return lines.join("\n")
}
resetView() {
const viewer = $(".viewer", this)
const image = $("img", viewer)
this.baseZoom = Math.min(
viewer.clientWidth / image.naturalWidth,
viewer.clientHeight / image.naturalHeight,
MIN_ZOOM,
)
this.zoomLevel = this.baseZoom
this.panX = 0
this.panY = 0
this.renderView()
}
zoomWheel(event) {
event.preventDefault()
const viewer = $(".viewer", this)
const box = viewer.getBoundingClientRect()
const x = event.clientX - box.left - box.width / 2
const y = event.clientY - box.top - box.height / 2
this.zoom(event.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP, x, y)
}
zoom(step, x = 0, y = 0) {
const level = Math.min(
Math.max(this.zoomLevel * step, this.baseZoom),
MAX_ZOOM,
)
const ratio = level / this.zoomLevel
this.panX = x - (x - this.panX) * ratio
this.panY = y - (y - this.panY) * ratio
this.zoomLevel = level
this.renderView()
}
startPan(event) {
event.currentTarget.setPointerCapture(event.pointerId)
this.dragX = event.clientX
this.dragY = event.clientY
}
pan(event) {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
this.panX += event.clientX - this.dragX
this.panY += event.clientY - this.dragY
this.dragX = event.clientX
this.dragY = event.clientY
this.renderView()
}
stopPan(event) {
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId)
}
}
renderView() {
$("img", $(".viewer", this)).style.transform = `
translate(-50%, -50%)
translate(${this.panX}px, ${this.panY}px)
scale(${this.zoomLevel})
`
}
}
customElements.define("image-explorer", ImageExplorer)