Masonry layout

- Fix masonry layout. Fill row wise
- Resizeable thumbnails
- Remove /single-image endpoint. Use Javascript instead to fill the page
- Dispatch an event when a single image page gets loaded, so that plugins can be used on those pages
- Load a gallery when opening the tab. No initial click on the refresh button required any more
- "Use as input" and "use settings" buttons work in both the gallery and the single image view
- showToast takes a "doc" argument to allow toasts to be shown on the single image page
This commit is contained in:
JeLuF 2023-08-14 01:00:20 +02:00
commit bf6e4a6a00
12 changed files with 2823 additions and 57 deletions

View File

@ -740,3 +740,30 @@ croppr.js is licensed under the MIT license:
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Masonry
=======
https://masonry.desandro.com/
Masonry is licensed under the MIT license:
The MIT License (MIT)
Copyright © 2023 David DeSandro
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the “Software”), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@ -100,12 +100,16 @@ def init():
raise HTTPException(status_code=404, detail="Image not found")
@server_api.get("/all_images")
def get_all_images(db: Session = Depends(get_db)):
def get_all_images(prompt: str = "", model: str = "", page: int = 0, images_per_page: int = 50, db: Session = Depends(get_db)):
from easydiffusion.easydb.mappings import GalleryImage
images = db.query(GalleryImage).all()
return images
images = db.query(GalleryImage).order_by(GalleryImage.time_created.desc())
if prompt != "":
images = images.filter(GalleryImage.path.like("%"+prompt+"%"))
if model != "":
images = images.filter(GalleryImage.use_stable_diffusion_model.like("%"+model+"%"))
images = images.offset(page*images_per_page).limit(images_per_page)
return images.all()
def get_filename_from_url(url):
path = urlparse(url).path
name = path[path.rfind('/')+1:]

View File

@ -25,10 +25,57 @@ class GalleryImage(Base):
prompt = Column(String)
negative_prompt = Column(String)
time_created = Column(DateTime(timezone=True), server_default=func.now())
nsfw = Column(Boolean, server_default=None)
def __repr__(self):
return "<GalleryImage(path='%s', seed='%s', use_stable_diffusion_model='%s', clip_skip='%s', use_vae_model='%s', sampler_name='%s', width='%s', height='%s', num_inference_steps='%s', guidance_scale='%s', lora='%s', use_hypernetwork_model='%s', tiling='%s', use_face_correction='%s', use_upscale='%s', prompt='%s', negative_prompt='%s')>" % (
self.path, self.seed, self.use_stable_diffusion_model, self.clip_skip, self.use_vae_model, self.sampler_name, self.width, self.height, self.num_inference_steps, self.guidance_scale, self.lora, self.use_hypernetwork_model, self.tiling, self.use_face_correction, self.use_upscale, self.prompt, self.negative_prompt)
def htmlForm(self) -> str:
return "<div class='panel-box'><p>Path: " + str(self.path) + "</p>" + \
"<p>Seed: " + str(self.seed) + "</p>" + \
"<p>Stable Diffusion Model: " + str(self.use_stable_diffusion_model) + "</p>" + \
"<p>Prompt: " + str(self.prompt) + "</p>" + \
"<p>Negative Prompt: " + str(self.negative_prompt) + "</p>" + \
"<p>Clip Skip: " + str(self.clip_skip) + "</p>" + \
"<p>VAE Model: " + str(self.use_vae_model) + "</p>" + \
"<p>Sampler: " + str(self.sampler_name) + "</p>" + \
"<p>Size: " + str(self.height) + "x" + str(self.width) + "</p>" + \
"<p>Inference Steps: " + str(self.num_inference_steps) + "</p>" + \
"<p>Guidance Scale: " + str(self.guidance_scale) + "</p>" + \
"<p>LoRA: " + str(self.lora) + "</p>" + \
"<p>Hypernetwork: " + str(self.use_hypernetwork_model) + "</p>" + \
"<p>Tiling: " + str(self.tiling) + "</p>" + \
"<p>Face Correction: " + str(self.use_face_correction) + "</p>" + \
"<p>Upscale: " + str(self.use_upscale) + "</p>" + \
"<p>Time Created: " + str(self.time_created) + "</p>" + \
"<p>NSFW: " + str(self.nsfw) + "</p></div>"
def settingsJSON(self) -> str:
# some are still missing: prompt strength, lora
json = {
"numOutputsTotal": 1,
"seed": self.seed,
"reqBody": {
"prompt": self.prompt,
"negative_prompt": self.negative_prompt,
"width": self.width,
"height": self.height,
"seed": self.seed,
"num_inference_steps": self.num_inference_steps,
"guidance_scale": self.guidance_scale,
"use_face_correction": self.use_face_correction,
"use_upscale": self.use_upscale,
"sampler_name": self.sampler_name,
"use_stable_diffusion_model": self.use_stable_diffusion_model,
"clip_skip": self.clip_skip,
"tiling": self.tiling,
"use_vae_model": self.use_vae_model,
"use_hypernetwork_model": self.use_hypernetwork_model
}}
from json import dumps
return dumps(json)
from easydiffusion.easydb.database import engine
GalleryImage.metadata.create_all(engine)

View File

@ -143,6 +143,10 @@ def init():
def read_root():
return FileResponse(os.path.join(app.SD_UI_DIR, "index.html"), headers=NOCACHE_HEADERS)
@server_api.get("/gallery-image.html")
def read_gallery_image():
return FileResponse(os.path.join(app.SD_UI_DIR, "gallery-image.html"), headers=NOCACHE_HEADERS)
@server_api.on_event("shutdown")
def shutdown_event(): # Signal render thread to close on shutdown
task_manager.current_state_error = SystemExit("Application shutting down.")

14
ui/gallery-image.html Normal file
View File

@ -0,0 +1,14 @@
<html>
<head>
<link rel="stylesheet" href="/media/css/single-gallery.css">
</head>
<body>
<div>
<button id="use_these_settings" class="primaryButton">Use these settings</button>
<button id="use_as_input" class="primaryButton">Use as Input</button>
</div>
<img id="focusimg">
<div id="focusbox" class="panel-box">
</div>
</body></html>

View File

@ -26,6 +26,7 @@
<script src="/media/js/FileSaver.min.js"></script>
<script src="/media/js/marked.min.js"></script>
<script src="/media/js/croppr.js"></script>
<script src="/media/js/masonry.pkgd.js"></script>
</head>
<body>
<div id="container">
@ -517,7 +518,16 @@
</div>
</div>
<div id="tab-content-gallery" class="tab-content">
<button class="primaryButton" onclick="refreshGallery()">Refresh</button>
<div id="gallery-search">
<button class="primaryButton" onclick="decrementGalleryPage()"><i class="fa-solid fa-arrow-left"></i></button>
<textarea id="gallery-prompt-search" onkeydown="gallery_keyDown_handler(event)" placeholder="Search for a prompt..."></textarea>
<textarea id="gallery-model-search" onkeydown="gallery_keyDown_handler(event)" placeholder="Search for a model..."></textarea>
<label for="gallery-page">Page:</label>
<input id="gallery-page" name="Page" value="0" size="1" onkeypress="gallery_keyDown_handler(event)">
<button class="primaryButton" id="gallery-refresh" onclick="refreshGallery()">Load</button>
<input id="gallery-thumbnail-size" name="gallery-thumbnail-size" class="editor-slider" type="range" value="300" min="50" max="800">
<button class="primaryButton" onclick="incrementGalleryPage()"><i class="fa-solid fa-arrow-right"></i></button>
</div>
<div class="gallery">
<div class="gallery-container" id="imagecontainer"></div>
</div>

View File

@ -1889,6 +1889,8 @@ div#enlarge-buttons {
}
/* Gallery CSS */
/*
.gallery {
display: flex;
justify-content: center;
@ -1896,29 +1898,30 @@ div#enlarge-buttons {
flex-direction: column;
font-family: sans-serif;
}
*/
#gallery-search {
display: flex;
justify-content: center;
align-items: center;
flex-direction: row;
}
#gallery-search>* {
margin: 0.5em;
}
.gallery-container {
columns: 6 380px ;
column-gap: 1.5rem;
width: 95%;
margin: 0 0 ;
}
.gallery-image img {
width: 100%;
border-radius: 5px;
transition: all .25s ease-in-out;
box-shadow: 5px 5px 5px rgba(0,0,0,0.4);
width: var(--gallery-width);
}
.gallery-image {
position: relative;
margin: 0 1.5rem 1.5rem 0;
display: inline-block;
width: 100%;
padding: 5px;
transition: all .75s ease-in-out;
width: var(--gallery-width);
margin-bottom: 6px;
}
.gallery-image-hover {
@ -1938,6 +1941,6 @@ div#enlarge-buttons {
}
#tab-content-gallery>button {
#tab-content-gallery>* {
margin: 8px;
}

View File

@ -0,0 +1,42 @@
@import url("/media/css/themes.css");
@import url("/media/css/main.css");
body {
display: flex;
flex-direction: column;
align-items: center;
background-color: var(--background-color1);
}
img {
border-radius: 5px;
}
p, h1, h2, h3, h4, h5, h6 {
color: var(--text-color);
cursor: default;
margin: 6px;
}
::-moz-selection {
/* Code for Firefox */
color: none;
background: none;
}
::selection {
color: none;
background: none;
}
button {
margin: 8px;
}
div {
margin: 16px;
}
:disabled {
color: gray;
}

View File

@ -56,6 +56,7 @@ const SETTINGS_IDS_LIST = [
"tree_toggle",
"json_toggle",
"extract_lora_from_prompt",
"gallery-thumbnail-size",
"embedding-card-size-selector",
]

View File

@ -143,6 +143,7 @@ let embeddingsList = document.querySelector("#embeddings-list")
let embeddingsModeField = document.querySelector("#embeddings-mode")
let embeddingsCardSizeSelector = document.querySelector("#embedding-card-size-selector")
let galleryImginfoDialog = document.querySelector("#gallery-imginfo")
let galleryThumbnailSize = document.querySelector("#gallery-thumbnail-size")
let galleryImginfoDialogContent = document.querySelector("#gallery-imginfo-content")
let positiveEmbeddingText = document.querySelector("#positive-embedding-text")
@ -3109,16 +3110,52 @@ const IMAGE_INFO = {
"Clip Skip": "clip_skip",
}
const IGNORE_TOKENS = ["None", "none", "Null", "null", "false", "False"]
const IGNORE_TOKENS = ["None", "none", "Null", "null", "false", "False", null]
function galleryImage(item) {
function galleryDetailTable(table) {
for (const [label, key] of Object.entries(IMAGE_INFO)) {
if (IGNORE_TOKENS.findIndex( k => (k == item[key])) == -1) {
let data = item[key]
if (key == "path") {
data = "&hellip;/"+data.split(/[\/\\]/).pop()
}
table.appendChild(htmlToElement(`<tr><th style="text-align: right;opacity:0.7;">${label}:</th><td>${data}</td></tr>`))
}
}
}
let div = document.createElement("div")
div.classList.add("gallery-image")
let img = document.createElement("img")
img.src = "/image/" + item.path
let img = createElement("img", { style: "cursor: zoom-in;", src: "/image/" + item.path}, undefined, undefined)
img.dataset["request"] = JSON.stringify(item)
img.addEventListener("click", (event) => {
let w = window.open("/gallery-image.html")
w.addEventListener("DOMContentLoaded", () => {
let fimg = w.document.getElementById("focusimg")
fimg.src = img.src
w.document.body.classList.add(themeField.value)
w.document.getElementById("use_these_settings").addEventListener("click", () => {
restoreTaskToUI({
batchCount: 1,
numOutputsTotal: 1,
reqBody: item
})
})
w.document.getElementById("use_as_input").addEventListener("click", () => {
onUseURLasInput(img.src)
showToast("Loaded image as EasyDiffusion input image", 5000, false, w.document)
})
let table = w.document.createElement("table")
galleryDetailTable(table)
w.document.getElementById("focusbox").replaceChildren(table)
document.dispatchEvent(new CustomEvent("newGalleryWindow", { detail: w }))
})
})
let hover = document.createElement("div")
hover.classList.add("gallery-image-hover")
@ -3127,29 +3164,17 @@ function galleryImage(item) {
infoBtn.innerHTML = '<i class="fa-regular fa-file-lines"></i>'
infoBtn.addEventListener("click", function() {
let table = document.createElement("table")
console.log(item)
for (const [label, key] of Object.entries(IMAGE_INFO)) {
console.log(label, key, item[key])
console.log(IGNORE_TOKENS.findIndex( k => (k == item[key])))
if (IGNORE_TOKENS.findIndex( k => (k == item[key])) == -1) {
let data = item[key]
if (key == "path") {
data = "&hellip;/"+data.split(/[\/\\]/).pop()
}
table.appendChild(htmlToElement(`<tr><th style="text-align: right;opacity:0.7;">${label}:</th><td>${data}</td></tr>`))
}
}
galleryDetailTable(table)
galleryImginfoDialogContent.replaceChildren(table)
galleryImginfoDialog.showModal()
})
hover.appendChild(infoBtn)
let imageExpandBtn=document.createElement("button")
imageExpandBtn.classList.add("tertiaryButton")
let imageExpandBtn=createElement("button", { style: "margin-left: 0.2em;"}, ["tertiaryButton"], undefined)
imageExpandBtn.innerHTML = '<i class="fa-solid fa-expand"></i>'
imageExpandBtn.style["margin-left"] = "0.2em"
imageExpandBtn.addEventListener("click", function() {
function previousImage(img) {
const allImages = Array.from(document.getElementById("imagecontainer").querySelectorAll(".gallery-image img"))
@ -3189,10 +3214,11 @@ function galleryImage(item) {
hover.appendChild(document.createElement("br"))
let useAsInputBtn = document.createElement("button")
useAsInputBtn.classList.add("tertiaryButton")
useAsInputBtn.innerHTML = "Use as Input"
useAsInputBtn.addEventListener("click", (e) => onUseAsInputClick(null, img))
let useAsInputBtn = createElement("button", {}, ["tertiaryButton"], "Use as Input")
useAsInputBtn.addEventListener("click", (e) => {
onUseURLasInput(img.src)
showToast("Loaded image as input image")
})
hover.appendChild(useAsInputBtn)
@ -3200,6 +3226,12 @@ function galleryImage(item) {
return div
}
function onUseURLasInput(url) {
toDataURL(url, blob => {
onUseAsInputClick(null, {src:blob})
})
}
modalDialogCloseOnBackdropClick(galleryImginfoDialog)
makeDialogDraggable(galleryImginfoDialog)
@ -3207,15 +3239,79 @@ galleryImginfoDialog.querySelector("#gallery-imginfo-close-button").addEventList
galleryImginfoDialog.close()
})
galleryThumbnailSize.addEventListener("input", layoutGallery)
window.addEventListener("resize", layoutGallery)
function layoutGallery() {
let container = document.getElementById("imagecontainer")
let thumbSize = parseInt(galleryThumbnailSize.value)
let root = document.querySelector(':root')
root.style.setProperty('--gallery-width', thumbSize + "px")
let msnry = new Masonry( container, {
gutter: 10,
itemSelector: '.gallery-image',
columnWidth: thumbSize
})
}
function refreshGallery() {
let container = document.getElementById("imagecontainer")
container.innerHTML=""
fetch('/all_images')
container.innerHTML = ""
let params = new URLSearchParams({
prompt: document.getElementById("gallery-prompt-search").value,
model: document.getElementById("gallery-model-search").value,
page: document.getElementById("gallery-page").value
})
fetch('/all_images?' + params)
.then(response => response.json())
.then(json => {
console.log(json)
json.forEach( item => {
if (document.getElementById("gallery-page").value > 0 && json.length == 0) {
decrementGalleryPage()
alert("No more images")
return
}
json.forEach(item => {
container.appendChild(galleryImage(item))
})
})
// Wait for all images to be loaded
Promise.all(Array.from(container.querySelectorAll("img")).map(img => {
if (img.complete)
return Promise.resolve(img.naturalHeight !== 0)
return new Promise(resolve => {
img.addEventListener('load', () => resolve(true))
img.addEventListener('error', () => resolve(false))
})
})).then(results => {
// then layout the images
layoutGallery()
})
})
document.getElementById("gallery-refresh").innerText = "Refresh"
}
document.addEventListener("tabClick", (e) => {
if (e.detail.name == 'gallery') {
refreshGallery()
}
})
function decrementGalleryPage() {
let page = Math.max(document.getElementById("gallery-page").value - 1, 0)
document.getElementById("gallery-page").value = page
refreshGallery()
}
function incrementGalleryPage() {
document.getElementById("gallery-page").value++
refreshGallery()
}
function gallery_keyDown_handler(event) {
if (event.key === 'Enter') {
event.preventDefault()
refreshGallery()
}
}

2504
ui/media/js/masonry.pkgd.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -872,19 +872,19 @@ function createTab(request) {
/* TOAST NOTIFICATIONS */
function showToast(message, duration = 5000, error = false) {
const toast = document.createElement("div")
function showToast(message, duration = 5000, error = false, doc=document) {
const toast = doc.createElement("div")
toast.classList.add("toast-notification")
if (error === true) {
toast.classList.add("toast-notification-error")
}
toast.innerHTML = message
document.body.appendChild(toast)
doc.body.appendChild(toast)
// Set the position of the toast on the screen
const toastCount = document.querySelectorAll(".toast-notification").length
const toastCount = doc.querySelectorAll(".toast-notification").length
const toastHeight = toast.offsetHeight
const previousToastsHeight = Array.from(document.querySelectorAll(".toast-notification"))
const previousToastsHeight = Array.from(doc.querySelectorAll(".toast-notification"))
.slice(0, -1) // exclude current toast
.reduce((totalHeight, toast) => totalHeight + toast.offsetHeight + 10, 0) // add 10 pixels for spacing
toast.style.bottom = `${10 + previousToastsHeight}px`
@ -896,7 +896,7 @@ function showToast(message, duration = 5000, error = false) {
const removeTimeoutId = setTimeout(() => {
toast.remove()
// Adjust the position of remaining toasts
const remainingToasts = document.querySelectorAll(".toast-notification")
const remainingToasts = doc.querySelectorAll(".toast-notification")
const removedToastBottom = toast.getBoundingClientRect().bottom
remainingToasts.forEach((toast) => {
@ -908,13 +908,13 @@ function showToast(message, duration = 5000, error = false) {
// Wait for the slide-down animation to complete
setTimeout(() => {
// Remove the slide-down class after the animation has completed
const slidingToasts = document.querySelectorAll(".slide-down")
const slidingToasts = doc.querySelectorAll(".slide-down")
slidingToasts.forEach((toast) => {
toast.classList.remove("slide-down")
})
// Adjust the position of remaining toasts again, in case there are multiple toasts being removed at once
const remainingToastsDown = document.querySelectorAll(".toast-notification")
const remainingToastsDown = doc.querySelectorAll(".toast-notification")
let heightSoFar = 0
remainingToastsDown.forEach((toast) => {
toast.style.bottom = `${10 + heightSoFar}px`
@ -1198,4 +1198,18 @@ function makeDialogDraggable(element) {
})() )
}
function toDataURL(url, callback){
var xhr = new XMLHttpRequest()
xhr.open('get', url)
xhr.responseType = 'blob'
xhr.onload = function(){
var fr = new FileReader()
fr.onload = function(){
callback(this.result)
}
fr.readAsDataURL(xhr.response)
}
xhr.send()
}