diff --git a/.gitignore b/.gitignore index 5e9693e..8d303a4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ __pycache__ pysssss.json -user/autocomplete.txt \ No newline at end of file +user/autocomplete.txt +web/js/assets/favicon.user.ico +web/js/assets/favicon-active.user.ico \ No newline at end of file diff --git a/py/show_text.py b/py/show_text.py index f0fb564..5a013a5 100644 --- a/py/show_text.py +++ b/py/show_text.py @@ -19,12 +19,24 @@ class ShowText: CATEGORY = "utils" - def notify(self, text, unique_id = None, extra_pnginfo=None): - if unique_id and extra_pnginfo and "workflow" in extra_pnginfo[0]: - workflow = extra_pnginfo[0]["workflow"] - node = next((x for x in workflow["nodes"] if str(x["id"]) == unique_id[0]), None) - if node: - node["widgets_values"] = [text] + def notify(self, text, unique_id=None, extra_pnginfo=None): + if unique_id is not None and extra_pnginfo is not None: + if not isinstance(extra_pnginfo, list): + print("Error: extra_pnginfo is not a list") + elif ( + not isinstance(extra_pnginfo[0], dict) + or "workflow" not in extra_pnginfo[0] + ): + print("Error: extra_pnginfo[0] is not a dict or missing 'workflow' key") + else: + workflow = extra_pnginfo[0]["workflow"] + node = next( + (x for x in workflow["nodes"] if str(x["id"]) == str(unique_id[0])), + None, + ) + if node: + node["widgets_values"] = [text] + return {"ui": {"text": text}, "result": (text,)} diff --git a/py/string_function.py b/py/string_function.py index 532e974..cdf9a00 100644 --- a/py/string_function.py +++ b/py/string_function.py @@ -7,11 +7,11 @@ class StringFunction: "required": { "action": (["append", "replace"], {}), "tidy_tags": (["yes", "no"], {}), - "text_a": ("STRING", {"multiline": True}), - "text_b": ("STRING", {"multiline": True}), + "text_a": ("STRING", {"multiline": True, "dynamicPrompts": False}), + "text_b": ("STRING", {"multiline": True, "dynamicPrompts": False}), }, "optional": { - "text_c": ("STRING", {"multiline": True}) + "text_c": ("STRING", {"multiline": True, "dynamicPrompts": False}) } } diff --git a/web/js/autocompleter.js b/web/js/autocompleter.js index 55d0f5e..7a81c1e 100644 --- a/web/js/autocompleter.js +++ b/web/js/autocompleter.js @@ -1,7 +1,7 @@ import { app } from "../../../scripts/app.js"; import { ComfyWidgets } from "../../../scripts/widgets.js"; -import { api } from "../../../../scripts/api.js"; -import { $el, ComfyDialog } from "../../../../scripts/ui.js"; +import { api } from "../../../scripts/api.js"; +import { $el, ComfyDialog } from "../../../scripts/ui.js"; import { TextAreaAutoComplete } from "./common/autocomplete.js"; import { ModelInfoDialog } from "./common/modelInfoDialog.js"; import { LoraInfoDialog } from "./modelInfo.js"; @@ -275,57 +275,6 @@ const id = "pysssss.AutoCompleter"; app.registerExtension({ name: id, init() { - async function addEmbeddings() { - const embeddings = await api.getEmbeddings(); - const words = {}; - words["embedding:"] = { text: "embedding:" }; - - for (const emb of embeddings) { - const v = `embedding:${emb}`; - words[v] = { - text: v, - info: () => new EmbeddingInfoDialog(emb).show("embeddings", emb), - use_replacer: false, - }; - } - - TextAreaAutoComplete.updateWords("pysssss.embeddings", words); - } - - async function addLoras() { - const loras = await api - .fetchApi("/pysssss/loras", { cache: "no-store" }) - .then(res => res.json()); - const words = {}; - words["lora:"] = { text: "lora:" }; - - for (const lora of loras) { - const v = ``; - words[v] = { - text: v, - info: () => new LoraInfoDialog(lora).show("loras", lora), - use_replacer: false, - }; - } - - TextAreaAutoComplete.updateWords("pysssss.loras", words); - } - - // store global words with/without loras - Promise.all([addEmbeddings(), addCustomWords()]) - .then(() => { - TextAreaAutoComplete.globalWordsExclLoras = Object.assign( - {}, - TextAreaAutoComplete.globalWords - ); - }) - .then(addLoras) - .then(() => { - if (!TextAreaAutoComplete.lorasEnabled) { - toggleLoras(); // off by default - } - }); - const STRING = ComfyWidgets.STRING; const SKIP_WIDGETS = new Set(["ttN xyPlot.x_values", "ttN xyPlot.y_values"]); ComfyWidgets.STRING = function (node, inputName, inputData) { @@ -517,6 +466,29 @@ app.registerExtension({ ), ] ), + $el( + "label", + { + textContent: "Max suggestions: ", + style: { + display: "block", + }, + }, + [ + $el("input", { + type: "number", + value: +TextAreaAutoComplete.suggestionCount, + style: { + width: "80px" + }, + onchange: (event) => { + const value = +event.target.value; + TextAreaAutoComplete.suggestionCount = value;; + localStorage.setItem(id + ".SuggestionCount", TextAreaAutoComplete.suggestionCount); + }, + }), + ] + ), $el("button", { textContent: "Manage Custom Words", onclick: () => { @@ -539,6 +511,62 @@ app.registerExtension({ TextAreaAutoComplete.insertOnTab = localStorage.getItem(id + ".InsertOnTab") !== "false"; TextAreaAutoComplete.insertOnEnter = localStorage.getItem(id + ".InsertOnEnter") !== "false"; TextAreaAutoComplete.lorasEnabled = localStorage.getItem(id + ".ShowLoras") === "true"; + TextAreaAutoComplete.suggestionCount = +localStorage.getItem(id + ".SuggestionCount"); + }, + setup() { + async function addEmbeddings() { + const embeddings = await api.getEmbeddings(); + const words = {}; + words["embedding:"] = { text: "embedding:" }; + + for (const emb of embeddings) { + const v = `embedding:${emb}`; + words[v] = { + text: v, + info: () => new EmbeddingInfoDialog(emb).show("embeddings", emb), + use_replacer: false, + }; + } + + TextAreaAutoComplete.updateWords("pysssss.embeddings", words); + } + + async function addLoras() { + let loras; + try { + loras = LiteGraph.registered_node_types["LoraLoader"]?.nodeData.input.required.lora_name[0]; + } catch (error) {} + + if (!loras?.length) { + loras = await api.fetchApi("/pysssss/loras", { cache: "no-store" }).then((res) => res.json()); + } + + const words = {}; + words["lora:"] = { text: "lora:" }; + + for (const lora of loras) { + const v = ``; + words[v] = { + text: v, + info: () => new LoraInfoDialog(lora).show("loras", lora), + use_replacer: false, + }; + } + + TextAreaAutoComplete.updateWords("pysssss.loras", words); + } + + // store global words with/without loras + Promise.all([addEmbeddings(), addCustomWords()]) + .then(() => { + TextAreaAutoComplete.globalWordsExclLoras = Object.assign({}, TextAreaAutoComplete.globalWords); + }) + .then(addLoras) + .then(() => { + if (!TextAreaAutoComplete.lorasEnabled) { + toggleLoras(); // off by default + } + }); }, beforeRegisterNodeDef(_, def) { // Process each input to see if there is a custom word list for diff --git a/web/js/common/autocomplete.css b/web/js/common/autocomplete.css index fa22a39..1f371d6 100644 --- a/web/js/common/autocomplete.css +++ b/web/js/common/autocomplete.css @@ -5,6 +5,7 @@ font-family: sans-serif; box-shadow: 3px 3px 8px rgba(0, 0, 0, 0.4); z-index: 9999; + overflow: auto; } .pysssss-autocomplete-item { diff --git a/web/js/common/autocomplete.js b/web/js/common/autocomplete.js index 8db269e..cb25b83 100644 --- a/web/js/common/autocomplete.js +++ b/web/js/common/autocomplete.js @@ -238,6 +238,7 @@ class TextAreaCaretHelper { fontSize: computedStyle.fontSize, fontFamily: computedStyle.fontFamily, padding: "0", + position: "absolute", }); body.appendChild(tempNode); @@ -291,10 +292,46 @@ class TextAreaCaretHelper { const startPos = this.el.selectionStart; const endPos = this.el.selectionEnd; - this.el.value = this.el.value.substring(0, startPos + offset) + value + this.el.value.substring(endPos, this.el.value.length); + // Move selection to beginning of offset + this.el.selectionStart = this.el.selectionStart + offset; + + // Using execCommand to support undo, but since it's officially + // 'deprecated' we need a backup solution, but it won't support undo :( + let pasted = true; + try { + if (!document.execCommand("insertText", false, value)) { + pasted = false; + } + } catch (e) { + console.error("Error caught during execCommand:", e); + pasted = false; + } + + if (!pasted) { + console.error( + "execCommand unsuccessful; not supported. Adding text manually, no undo support."); + textarea.setRangeText(modifiedText, this.el.selectionStart, this.el.selectionEnd, 'end'); + } + this.el.selectionEnd = this.el.selectionStart = startPos + value.length + offset + (finalOffset ?? 0); } else { - this.el.value += value; + // Using execCommand to support undo, but since it's officially + // 'deprecated' we need a backup solution, but it won't support undo :( + let pasted = true; + try { + if (!document.execCommand("insertText", false, value)) { + pasted = false; + } + } catch (e) { + console.error("Error caught during execCommand:", e); + pasted = false; + } + + if (!pasted) { + console.error( + "execCommand unsuccessful; not supported. Adding text manually, no undo support."); + this.el.value += value; + } } } } @@ -318,6 +355,7 @@ export class TextAreaAutoComplete { static insertOnEnter = true; static replacer = undefined; static lorasEnabled = false; + static suggestionCount = 20; /** @type {Record>} */ static groups = {}; @@ -492,7 +530,7 @@ export class TextAreaAutoComplete { ); const top = priorityMatches.length * 0.2; - return priorityMatches.slice(0, top).concat(prefixMatches, priorityMatches.slice(top), includesMatches).slice(0, 20); + return priorityMatches.slice(0, top).concat(prefixMatches, priorityMatches.slice(top), includesMatches).slice(0, TextAreaAutoComplete.suggestionCount); } #update() { @@ -612,6 +650,7 @@ export class TextAreaAutoComplete { const position = this.helper.getCursorOffset(); this.dropdown.style.left = (position.left ?? 0) + "px"; this.dropdown.style.top = (position.top ?? 0) + "px"; + this.dropdown.style.maxHeight = (window.innerHeight - position.top) + "px"; } #hide() { diff --git a/web/js/common/lightbox.css b/web/js/common/lightbox.css index 4357d9d..41fa52d 100644 --- a/web/js/common/lightbox.css +++ b/web/js/common/lightbox.css @@ -4,7 +4,7 @@ position: fixed; top: 0; left: 0; - z-index: 999; + z-index: 1000; background: rgba(0, 0, 0, 0.6); display: flex; align-items: center; diff --git a/web/js/common/lightbox.js b/web/js/common/lightbox.js index 4034597..d351acf 100644 --- a/web/js/common/lightbox.js +++ b/web/js/common/lightbox.js @@ -107,6 +107,16 @@ export class Lightbox { this.img.src = img; this.img.style.opacity = 1; } + + async updateWithNewImage(img, feedDirection) { + // No-op if lightbox is not open + if (this.el.style.display === "none" || this.el.style.opacity === "0") return; + + // Ensure currently shown image does not change + const [method, shift] = feedDirection === "newest first" ? ["unshift", 1] : ["push", 0]; + this.images[method](img); + await this.update(shift); + } } export const lightbox = new Lightbox(); diff --git a/web/js/common/spinner.css b/web/js/common/spinner.css index 9a29451..3b47902 100644 --- a/web/js/common/spinner.css +++ b/web/js/common/spinner.css @@ -1,10 +1,10 @@ -.lds-ring { +.pysssss-lds-ring { display: inline-block; position: relative; width: 80px; height: 80px; } -.lds-ring div { +.pysssss-lds-ring div { box-sizing: border-box; display: block; position: absolute; @@ -16,13 +16,13 @@ animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite; border-color: #fff transparent transparent transparent; } -.lds-ring div:nth-child(1) { +.pysssss-lds-ring div:nth-child(1) { animation-delay: -0.45s; } -.lds-ring div:nth-child(2) { +.pysssss-lds-ring div:nth-child(2) { animation-delay: -0.3s; } -.lds-ring div:nth-child(3) { +.pysssss-lds-ring div:nth-child(3) { animation-delay: -0.15s; } @keyframes lds-ring { diff --git a/web/js/common/spinner.js b/web/js/common/spinner.js index f01315d..4075990 100644 --- a/web/js/common/spinner.js +++ b/web/js/common/spinner.js @@ -4,6 +4,6 @@ addStylesheet(import.meta.url); export function createSpinner() { const div = document.createElement("div"); - div.innerHTML = `
`; + div.innerHTML = `
`; return div.firstElementChild; } diff --git a/web/js/faviconStatus.js b/web/js/faviconStatus.js index b68eafc..3dcd196 100644 --- a/web/js/faviconStatus.js +++ b/web/js/faviconStatus.js @@ -6,7 +6,7 @@ import { app } from "../../../scripts/app.js"; app.registerExtension({ name: "pysssss.FaviconStatus", - setup() { + async setup() { let link = document.querySelector("link[rel~='icon']"); if (!link) { link = document.createElement("link"); @@ -14,8 +14,22 @@ app.registerExtension({ document.head.appendChild(link); } + const getUrl = (active, user) => new URL(`assets/favicon${active ? "-active" : ""}${user ? ".user" : ""}.ico`, import.meta.url); + const testUrl = async (active) => { + const url = getUrl(active, true); + const r = await fetch(url, { + method: "HEAD", + }); + if (r.status === 200) { + return url; + } + return getUrl(active, false); + }; + const activeUrl = await testUrl(true); + const idleUrl = await testUrl(false); + let executing = false; - const update = () => (link.href = new URL(`assets/favicon${executing ? "-active" : ""}.ico`, import.meta.url)); + const update = () => (link.href = executing ? activeUrl : idleUrl); for (const e of ["execution_start", "progress"]) { api.addEventListener(e, () => { diff --git a/web/js/imageFeed.js b/web/js/imageFeed.js index 9214d57..2bab4ed 100644 --- a/web/js/imageFeed.js +++ b/web/js/imageFeed.js @@ -15,6 +15,9 @@ $el("style", { display: flex; flex-direction: column; } + div > .pysssss-image-feed { + position: static; + } .pysssss-image-feed--top, .pysssss-image-feed--bottom { width: 100vw; min-height: 30px; @@ -206,7 +209,7 @@ $el("style", { app.registerExtension({ name: "pysssss.ImageFeed", - setup() { + async setup() { let visible = true; const seenImages = new Map(); const showButton = $el("button.comfy-settings-btn", { @@ -217,6 +220,17 @@ app.registerExtension({ display: "none", }, }); + let showMenuButton; + if (!app.menu?.element.style.display && app.menu?.settingsGroup) { + showMenuButton = new (await import("../../../scripts/ui/components/button.js")).ComfyButton({ + icon: "image-multiple", + action: () => showButton.click(), + tooltip: "Show Image Feed 🐍", + content: "Show Image Feed 🐍", + }); + showMenuButton.enabled = false; + app.menu.settingsGroup.append(showMenuButton); + } const getVal = (n, d) => { const v = localStorage.getItem("pysssss.ImageFeed." + n); @@ -230,11 +244,19 @@ app.registerExtension({ localStorage.setItem("pysssss.ImageFeed." + n, v); }; - const imageFeed = $el("div.pysssss-image-feed", { - parent: document.body, - }); + const imageFeed = $el("div.pysssss-image-feed"); const imageList = $el("div.pysssss-image-feed-list"); + function updateMenuParent(location) { + if (showMenuButton) { + document.querySelector(".comfyui-body-" + location).append(imageFeed); + } else { + if (!imageFeed.parent) { + document.body.append(imageFeed); + } + } + } + const feedLocation = app.ui.settings.addSetting({ id: "pysssss.ImageFeed.Location", name: "🐍 Image Feed Location", @@ -256,6 +278,8 @@ app.registerExtension({ oninput: (e) => { feedLocation.value = e.target.value; imageFeed.className = `pysssss-image-feed pysssss-image-feed--${feedLocation.value}`; + updateMenuParent(feedLocation.value); + window.dispatchEvent(new Event("resize")); }, }, ["left", "top", "right", "bottom"].map((m) => @@ -271,6 +295,7 @@ app.registerExtension({ }, onChange(value) { imageFeed.className = `pysssss-image-feed pysssss-image-feed--${value}`; + updateMenuParent(value); }, }); @@ -324,7 +349,10 @@ by reducing the number of images in the feed.`, const clearButton = $el("button.pysssss-image-feed-btn.clear-btn", { textContent: "Clear", - onclick: () => imageList.replaceChildren(), + onclick: () => { + imageList.replaceChildren(); + window.dispatchEvent(new Event("resize")); + }, }); const hideButton = $el("button.pysssss-image-feed-btn.hide-btn", { @@ -332,8 +360,10 @@ by reducing the number of images in the feed.`, onclick: () => { imageFeed.style.display = "none"; showButton.style.display = "unset"; + if (showMenuButton) showMenuButton.enabled = true; saveVal("Visible", 0); visible = false; + window.dispatchEvent(new Event("resize")); }, }); @@ -344,6 +374,28 @@ by reducing the number of images in the feed.`, saveVal("ImageSize", v); columnInput.max = Math.max(10, v, columnInput.max); columnInput.value = v; + window.dispatchEvent(new Event("resize")); + } + + function addImageToFeed(href) { + const method = feedDirection.value === "newest first" ? "prepend" : "append"; + imageList[method]( + $el("div", [ + $el( + "a", + { + target: "_blank", + href, + onclick: (e) => { + const imgs = [...imageList.querySelectorAll("img")].map((img) => img.getAttribute("src")); + lightbox.show(imgs, imgs.indexOf(href)); + e.preventDefault(); + }, + }, + [$el("img", { src: href })] + ), + ]) + ); } function addImageToFeed(href) { @@ -384,6 +436,7 @@ by reducing the number of images in the feed.`, e.target.parentElement.title = `Controls the maximum size of the image feed panel (${e.target.value}vh)`; imageFeed.style.setProperty("--max-size", e.target.value); saveVal("FeedSize", e.target.value); + window.dispatchEvent(new Event("resize")); }, $: (el) => { requestAnimationFrame(() => { @@ -432,10 +485,14 @@ by reducing the number of images in the feed.`, showButton.onclick = () => { imageFeed.style.display = "block"; showButton.style.display = "none"; + if (showMenuButton) showMenuButton.enabled = false; + saveVal("Visible", 1); visible = true; + window.dispatchEvent(new Event("resize")); }; document.querySelector(".comfy-settings-btn").after(showButton); + window.dispatchEvent(new Event("resize")); if (!+getVal("Visible", 1)) { hideButton.onclick(); @@ -443,10 +500,12 @@ by reducing the number of images in the feed.`, api.addEventListener("executed", ({ detail }) => { if (visible && detail?.output?.images) { + if (detail.node?.includes?.(":")) { if (detail.node?.includes?.(":")) { // Ignore group nodes const n = app.graph.getNodeById(detail.node.split(":")[0]); if (n?.getInnerNodes) return; + if (n?.getInnerNodes) return; } for (const src of detail.output.images) {