Add custom nodes, Civitai loras (LFS), and vast.ai setup script
Some checks failed
Python Linting / Run Ruff (push) Has been cancelled
Python Linting / Run Pylint (push) Has been cancelled
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.10, [self-hosted Linux], stable) (push) Has been cancelled
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.11, [self-hosted Linux], stable) (push) Has been cancelled
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.12, [self-hosted Linux], stable) (push) Has been cancelled
Full Comfy CI Workflow Runs / test-unix-nightly (12.1, , linux, 3.11, [self-hosted Linux], nightly) (push) Has been cancelled
Execution Tests / test (macos-latest) (push) Has been cancelled
Execution Tests / test (ubuntu-latest) (push) Has been cancelled
Execution Tests / test (windows-latest) (push) Has been cancelled
Test server launches without errors / test (push) Has been cancelled
Unit Tests / test (macos-latest) (push) Has been cancelled
Unit Tests / test (ubuntu-latest) (push) Has been cancelled
Unit Tests / test (windows-2022) (push) Has been cancelled
Some checks failed
Python Linting / Run Ruff (push) Has been cancelled
Python Linting / Run Pylint (push) Has been cancelled
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.10, [self-hosted Linux], stable) (push) Has been cancelled
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.11, [self-hosted Linux], stable) (push) Has been cancelled
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.12, [self-hosted Linux], stable) (push) Has been cancelled
Full Comfy CI Workflow Runs / test-unix-nightly (12.1, , linux, 3.11, [self-hosted Linux], nightly) (push) Has been cancelled
Execution Tests / test (macos-latest) (push) Has been cancelled
Execution Tests / test (ubuntu-latest) (push) Has been cancelled
Execution Tests / test (windows-latest) (push) Has been cancelled
Test server launches without errors / test (push) Has been cancelled
Unit Tests / test (macos-latest) (push) Has been cancelled
Unit Tests / test (ubuntu-latest) (push) Has been cancelled
Unit Tests / test (windows-2022) (push) Has been cancelled
Includes 30 custom nodes committed directly, 7 Civitai-exclusive loras stored via Git LFS, and a setup script that installs all dependencies and downloads HuggingFace-hosted models on vast.ai. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
.pysssss-autocomplete {
|
||||
color: var(--descrip-text);
|
||||
background-color: var(--comfy-menu-bg);
|
||||
position: absolute;
|
||||
font-family: sans-serif;
|
||||
box-shadow: 3px 3px 8px rgba(0, 0, 0, 0.4);
|
||||
z-index: 9999;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.pysssss-autocomplete-item {
|
||||
cursor: pointer;
|
||||
padding: 3px 7px;
|
||||
display: flex;
|
||||
border-left: 3px solid transparent;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pysssss-autocomplete-item--selected {
|
||||
border-left-color: dodgerblue;
|
||||
}
|
||||
|
||||
.pysssss-autocomplete-highlight {
|
||||
font-weight: bold;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: dodgerblue;
|
||||
}
|
||||
|
||||
.pysssss-autocomplete-pill {
|
||||
margin-left: auto;
|
||||
font-size: 10px;
|
||||
color: #fff;
|
||||
padding: 2px 4px 2px 14px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pysssss-autocomplete-pill::after {
|
||||
content: "";
|
||||
display: block;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
width: calc(100% - 10px);
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 0;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.pysssss-autocomplete-pill + .pysssss-autocomplete-pill {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.pysssss-autocomplete-item-info {
|
||||
margin-left: auto;
|
||||
transition: filter 0.2s;
|
||||
will-change: filter;
|
||||
text-decoration: none;
|
||||
padding-left: 10px;
|
||||
}
|
||||
.pysssss-autocomplete-item-info:hover {
|
||||
filter: invert(1);
|
||||
}
|
||||
@@ -0,0 +1,692 @@
|
||||
import { $el } from "../../../../scripts/ui.js";
|
||||
import { addStylesheet } from "./utils.js";
|
||||
|
||||
addStylesheet(import.meta.url);
|
||||
|
||||
/*
|
||||
https://github.com/component/textarea-caret-position
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015 Jonathan Ong me@jongleberry.com
|
||||
|
||||
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.
|
||||
*/
|
||||
const getCaretCoordinates = (function () {
|
||||
// We'll copy the properties below into the mirror div.
|
||||
// Note that some browsers, such as Firefox, do not concatenate properties
|
||||
// into their shorthand (e.g. padding-top, padding-bottom etc. -> padding),
|
||||
// so we have to list every single property explicitly.
|
||||
var properties = [
|
||||
"direction", // RTL support
|
||||
"boxSizing",
|
||||
"width", // on Chrome and IE, exclude the scrollbar, so the mirror div wraps exactly as the textarea does
|
||||
"height",
|
||||
"overflowX",
|
||||
"overflowY", // copy the scrollbar for IE
|
||||
|
||||
"borderTopWidth",
|
||||
"borderRightWidth",
|
||||
"borderBottomWidth",
|
||||
"borderLeftWidth",
|
||||
"borderStyle",
|
||||
|
||||
"paddingTop",
|
||||
"paddingRight",
|
||||
"paddingBottom",
|
||||
"paddingLeft",
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/CSS/font
|
||||
"fontStyle",
|
||||
"fontVariant",
|
||||
"fontWeight",
|
||||
"fontStretch",
|
||||
"fontSize",
|
||||
"fontSizeAdjust",
|
||||
"lineHeight",
|
||||
"fontFamily",
|
||||
|
||||
"textAlign",
|
||||
"textTransform",
|
||||
"textIndent",
|
||||
"textDecoration", // might not make a difference, but better be safe
|
||||
|
||||
"letterSpacing",
|
||||
"wordSpacing",
|
||||
|
||||
"tabSize",
|
||||
"MozTabSize",
|
||||
];
|
||||
|
||||
var isBrowser = typeof window !== "undefined";
|
||||
var isFirefox = isBrowser && window.mozInnerScreenX != null;
|
||||
|
||||
return function getCaretCoordinates(element, position, options) {
|
||||
if (!isBrowser) {
|
||||
throw new Error("textarea-caret-position#getCaretCoordinates should only be called in a browser");
|
||||
}
|
||||
|
||||
var debug = (options && options.debug) || false;
|
||||
if (debug) {
|
||||
var el = document.querySelector("#input-textarea-caret-position-mirror-div");
|
||||
if (el) el.parentNode.removeChild(el);
|
||||
}
|
||||
|
||||
// The mirror div will replicate the textarea's style
|
||||
var div = document.createElement("div");
|
||||
div.id = "input-textarea-caret-position-mirror-div";
|
||||
document.body.appendChild(div);
|
||||
|
||||
var style = div.style;
|
||||
var computed = window.getComputedStyle ? window.getComputedStyle(element) : element.currentStyle; // currentStyle for IE < 9
|
||||
var isInput = element.nodeName === "INPUT";
|
||||
|
||||
// Default textarea styles
|
||||
style.whiteSpace = "pre-wrap";
|
||||
if (!isInput) style.wordWrap = "break-word"; // only for textarea-s
|
||||
|
||||
// Position off-screen
|
||||
style.position = "absolute"; // required to return coordinates properly
|
||||
if (!debug) style.visibility = "hidden"; // not 'display: none' because we want rendering
|
||||
|
||||
// Transfer the element's properties to the div
|
||||
properties.forEach(function (prop) {
|
||||
if (isInput && prop === "lineHeight") {
|
||||
// Special case for <input>s because text is rendered centered and line height may be != height
|
||||
if (computed.boxSizing === "border-box") {
|
||||
var height = parseInt(computed.height);
|
||||
var outerHeight =
|
||||
parseInt(computed.paddingTop) +
|
||||
parseInt(computed.paddingBottom) +
|
||||
parseInt(computed.borderTopWidth) +
|
||||
parseInt(computed.borderBottomWidth);
|
||||
var targetHeight = outerHeight + parseInt(computed.lineHeight);
|
||||
if (height > targetHeight) {
|
||||
style.lineHeight = height - outerHeight + "px";
|
||||
} else if (height === targetHeight) {
|
||||
style.lineHeight = computed.lineHeight;
|
||||
} else {
|
||||
style.lineHeight = 0;
|
||||
}
|
||||
} else {
|
||||
style.lineHeight = computed.height;
|
||||
}
|
||||
} else {
|
||||
style[prop] = computed[prop];
|
||||
}
|
||||
});
|
||||
|
||||
if (isFirefox) {
|
||||
// Firefox lies about the overflow property for textareas: https://bugzilla.mozilla.org/show_bug.cgi?id=984275
|
||||
if (element.scrollHeight > parseInt(computed.height)) style.overflowY = "scroll";
|
||||
} else {
|
||||
style.overflow = "hidden"; // for Chrome to not render a scrollbar; IE keeps overflowY = 'scroll'
|
||||
}
|
||||
|
||||
div.textContent = element.value.substring(0, position);
|
||||
// The second special handling for input type="text" vs textarea:
|
||||
// spaces need to be replaced with non-breaking spaces - http://stackoverflow.com/a/13402035/1269037
|
||||
if (isInput) div.textContent = div.textContent.replace(/\s/g, "\u00a0");
|
||||
|
||||
var span = document.createElement("span");
|
||||
// Wrapping must be replicated *exactly*, including when a long word gets
|
||||
// onto the next line, with whitespace at the end of the line before (#7).
|
||||
// The *only* reliable way to do that is to copy the *entire* rest of the
|
||||
// textarea's content into the <span> created at the caret position.
|
||||
// For inputs, just '.' would be enough, but no need to bother.
|
||||
span.textContent = element.value.substring(position) || "."; // || because a completely empty faux span doesn't render at all
|
||||
div.appendChild(span);
|
||||
|
||||
var coordinates = {
|
||||
top: span.offsetTop + parseInt(computed["borderTopWidth"]),
|
||||
left: span.offsetLeft + parseInt(computed["borderLeftWidth"]),
|
||||
height: parseInt(computed["lineHeight"]),
|
||||
};
|
||||
|
||||
if (debug) {
|
||||
span.style.backgroundColor = "#aaa";
|
||||
} else {
|
||||
document.body.removeChild(div);
|
||||
}
|
||||
|
||||
return coordinates;
|
||||
};
|
||||
})();
|
||||
|
||||
/*
|
||||
Key functions from:
|
||||
https://github.com/yuku/textcomplete
|
||||
© Yuku Takahashi - This software is licensed under the MIT license.
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015 Jonathan Ong me@jongleberry.com
|
||||
|
||||
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.
|
||||
*/
|
||||
const CHAR_CODE_ZERO = "0".charCodeAt(0);
|
||||
const CHAR_CODE_NINE = "9".charCodeAt(0);
|
||||
|
||||
class TextAreaCaretHelper {
|
||||
constructor(el, getScale) {
|
||||
this.el = el;
|
||||
this.getScale = getScale;
|
||||
}
|
||||
|
||||
#calculateElementOffset() {
|
||||
const rect = this.el.getBoundingClientRect();
|
||||
const owner = this.el.ownerDocument;
|
||||
if (owner == null) {
|
||||
throw new Error("Given element does not belong to document");
|
||||
}
|
||||
const { defaultView, documentElement } = owner;
|
||||
if (defaultView == null) {
|
||||
throw new Error("Given element does not belong to window");
|
||||
}
|
||||
const offset = {
|
||||
top: rect.top + defaultView.pageYOffset,
|
||||
left: rect.left + defaultView.pageXOffset,
|
||||
};
|
||||
if (documentElement) {
|
||||
offset.top -= documentElement.clientTop;
|
||||
offset.left -= documentElement.clientLeft;
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
||||
#isDigit(charCode) {
|
||||
return CHAR_CODE_ZERO <= charCode && charCode <= CHAR_CODE_NINE;
|
||||
}
|
||||
|
||||
#getLineHeightPx() {
|
||||
const computedStyle = getComputedStyle(this.el);
|
||||
const lineHeight = computedStyle.lineHeight;
|
||||
// If the char code starts with a digit, it is either a value in pixels,
|
||||
// or unitless, as per:
|
||||
// https://drafts.csswg.org/css2/visudet.html#propdef-line-height
|
||||
// https://drafts.csswg.org/css2/cascade.html#computed-value
|
||||
if (this.#isDigit(lineHeight.charCodeAt(0))) {
|
||||
const floatLineHeight = parseFloat(lineHeight);
|
||||
// In real browsers the value is *always* in pixels, even for unit-less
|
||||
// line-heights. However, we still check as per the spec.
|
||||
return this.#isDigit(lineHeight.charCodeAt(lineHeight.length - 1))
|
||||
? floatLineHeight * parseFloat(computedStyle.fontSize)
|
||||
: floatLineHeight;
|
||||
}
|
||||
// Otherwise, the value is "normal".
|
||||
// If the line-height is "normal", calculate by font-size
|
||||
return this.#calculateLineHeightPx(this.el.nodeName, computedStyle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns calculated line-height of the given node in pixels.
|
||||
*/
|
||||
#calculateLineHeightPx(nodeName, computedStyle) {
|
||||
const body = document.body;
|
||||
if (!body) return 0;
|
||||
|
||||
const tempNode = document.createElement(nodeName);
|
||||
tempNode.innerHTML = " ";
|
||||
Object.assign(tempNode.style, {
|
||||
fontSize: computedStyle.fontSize,
|
||||
fontFamily: computedStyle.fontFamily,
|
||||
padding: "0",
|
||||
position: "absolute",
|
||||
});
|
||||
body.appendChild(tempNode);
|
||||
|
||||
// Make sure textarea has only 1 row
|
||||
if (tempNode instanceof HTMLTextAreaElement) {
|
||||
tempNode.rows = 1;
|
||||
}
|
||||
|
||||
// Assume the height of the element is the line-height
|
||||
const height = tempNode.offsetHeight;
|
||||
body.removeChild(tempNode);
|
||||
|
||||
return height;
|
||||
}
|
||||
|
||||
getCursorOffset() {
|
||||
const scale = this.getScale();
|
||||
const elOffset = this.#calculateElementOffset();
|
||||
const elScroll = this.#getElScroll();
|
||||
const cursorPosition = this.#getCursorPosition();
|
||||
const lineHeight = this.#getLineHeightPx();
|
||||
const top = elOffset.top - (elScroll.top * scale) + (cursorPosition.top + lineHeight) * scale;
|
||||
const left = elOffset.left - elScroll.left + cursorPosition.left;
|
||||
const clientTop = this.el.getBoundingClientRect().top;
|
||||
if (this.el.dir !== "rtl") {
|
||||
return { top, left, lineHeight, clientTop };
|
||||
} else {
|
||||
const right = document.documentElement ? document.documentElement.clientWidth - left : 0;
|
||||
return { top, right, lineHeight, clientTop };
|
||||
}
|
||||
}
|
||||
|
||||
#getElScroll() {
|
||||
return { top: this.el.scrollTop, left: this.el.scrollLeft };
|
||||
}
|
||||
|
||||
#getCursorPosition() {
|
||||
return getCaretCoordinates(this.el, this.el.selectionEnd);
|
||||
}
|
||||
|
||||
getBeforeCursor() {
|
||||
return this.el.selectionStart !== this.el.selectionEnd ? null : this.el.value.substring(0, this.el.selectionEnd);
|
||||
}
|
||||
|
||||
getAfterCursor() {
|
||||
return this.el.value.substring(this.el.selectionEnd);
|
||||
}
|
||||
|
||||
insertAtCursor(value, offset, finalOffset) {
|
||||
if (this.el.selectionStart != null) {
|
||||
const startPos = this.el.selectionStart;
|
||||
const endPos = this.el.selectionEnd;
|
||||
|
||||
// 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 {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*********************/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* text: string,
|
||||
* priority?: number,
|
||||
* info?: Function,
|
||||
* hint?: string,
|
||||
* showValue?: boolean,
|
||||
* caretOffset?: number
|
||||
* }} AutoCompleteEntry
|
||||
*/
|
||||
export class TextAreaAutoComplete {
|
||||
static globalSeparator = "";
|
||||
static enabled = true;
|
||||
static insertOnTab = true;
|
||||
static insertOnEnter = true;
|
||||
static replacer = undefined;
|
||||
static lorasEnabled = false;
|
||||
static suggestionCount = 20;
|
||||
|
||||
/** @type {Record<string, Record<string, AutoCompleteEntry>>} */
|
||||
static groups = {};
|
||||
/** @type {Set<string>} */
|
||||
static globalGroups = new Set();
|
||||
/** @type {Record<string, AutoCompleteEntry>} */
|
||||
static globalWords = {};
|
||||
/** @type {Record<string, AutoCompleteEntry>} */
|
||||
static globalWordsExclLoras = {};
|
||||
|
||||
/** @type {HTMLTextAreaElement} */
|
||||
el;
|
||||
|
||||
/** @type {Record<string, AutoCompleteEntry>} */
|
||||
overrideWords;
|
||||
overrideSeparator = "";
|
||||
|
||||
get words() {
|
||||
return this.overrideWords ?? TextAreaAutoComplete.globalWords;
|
||||
}
|
||||
|
||||
get separator() {
|
||||
return this.overrideSeparator ?? TextAreaAutoComplete.globalSeparator;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLTextAreaElement} el
|
||||
*/
|
||||
constructor(el, words = null, separator = null) {
|
||||
this.el = el;
|
||||
this.helper = new TextAreaCaretHelper(el, () => app.canvas.ds.scale);
|
||||
this.dropdown = $el("div.pysssss-autocomplete");
|
||||
this.overrideWords = words;
|
||||
this.overrideSeparator = separator;
|
||||
|
||||
this.#setup();
|
||||
}
|
||||
|
||||
#setup() {
|
||||
this.el.addEventListener("keydown", this.#keyDown.bind(this));
|
||||
this.el.addEventListener("keypress", this.#keyPress.bind(this));
|
||||
this.el.addEventListener("keyup", this.#keyUp.bind(this));
|
||||
this.el.addEventListener("click", this.#hide.bind(this));
|
||||
this.el.addEventListener("blur", () => setTimeout(() => this.#hide(), 150));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} e
|
||||
*/
|
||||
#keyDown(e) {
|
||||
if (!TextAreaAutoComplete.enabled) return;
|
||||
|
||||
if (this.dropdown.parentElement) {
|
||||
// We are visible
|
||||
switch (e.key) {
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
if (this.selected.index) {
|
||||
this.#setSelected(this.currentWords[this.selected.index - 1].wordInfo);
|
||||
} else {
|
||||
this.#setSelected(this.currentWords[this.currentWords.length - 1].wordInfo);
|
||||
}
|
||||
break;
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
if (this.selected.index === this.currentWords.length - 1) {
|
||||
this.#setSelected(this.currentWords[0].wordInfo);
|
||||
} else {
|
||||
this.#setSelected(this.currentWords[this.selected.index + 1].wordInfo);
|
||||
}
|
||||
break;
|
||||
case "Tab":
|
||||
if (TextAreaAutoComplete.insertOnTab) {
|
||||
this.#insertItem();
|
||||
e.preventDefault();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} e
|
||||
*/
|
||||
#keyPress(e) {
|
||||
if (!TextAreaAutoComplete.enabled) return;
|
||||
if (this.dropdown.parentElement) {
|
||||
// We are visible
|
||||
switch (e.key) {
|
||||
case "Enter":
|
||||
if (!e.ctrlKey) {
|
||||
if (TextAreaAutoComplete.insertOnEnter) {
|
||||
this.#insertItem();
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!e.defaultPrevented) {
|
||||
this.#update();
|
||||
}
|
||||
}
|
||||
|
||||
#keyUp(e) {
|
||||
if (!TextAreaAutoComplete.enabled) return;
|
||||
if (this.dropdown.parentElement) {
|
||||
// We are visible
|
||||
switch (e.key) {
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
this.#hide();
|
||||
break;
|
||||
}
|
||||
} else if (e.key.length > 1 && e.key != "Delete" && e.key != "Backspace") {
|
||||
return;
|
||||
}
|
||||
if (!e.defaultPrevented) {
|
||||
this.#update();
|
||||
}
|
||||
}
|
||||
|
||||
#setSelected(item) {
|
||||
if (this.selected) {
|
||||
this.selected.el.classList.remove("pysssss-autocomplete-item--selected");
|
||||
}
|
||||
|
||||
this.selected = item;
|
||||
this.selected.el.classList.add("pysssss-autocomplete-item--selected");
|
||||
}
|
||||
|
||||
#insertItem() {
|
||||
if (!this.selected) return;
|
||||
this.selected.el.click();
|
||||
}
|
||||
|
||||
#getFilteredWords(term) {
|
||||
term = term.toLocaleLowerCase();
|
||||
|
||||
const priorityMatches = [];
|
||||
const prefixMatches = [];
|
||||
const includesMatches = [];
|
||||
for (const word of Object.keys(this.words)) {
|
||||
const lowerWord = word.toLocaleLowerCase();
|
||||
if (lowerWord === term) {
|
||||
// Dont include exact matches
|
||||
continue;
|
||||
}
|
||||
|
||||
const pos = lowerWord.indexOf(term);
|
||||
if (pos === -1) {
|
||||
// No match
|
||||
continue;
|
||||
}
|
||||
|
||||
const wordInfo = this.words[word];
|
||||
if (wordInfo.priority) {
|
||||
priorityMatches.push({ pos, wordInfo });
|
||||
} else if (pos) {
|
||||
includesMatches.push({ pos, wordInfo });
|
||||
} else {
|
||||
prefixMatches.push({ pos, wordInfo });
|
||||
}
|
||||
}
|
||||
|
||||
priorityMatches.sort(
|
||||
(a, b) =>
|
||||
b.wordInfo.priority - a.wordInfo.priority ||
|
||||
a.wordInfo.text.length - b.wordInfo.text.length ||
|
||||
a.wordInfo.text.localeCompare(b.wordInfo.text)
|
||||
);
|
||||
|
||||
const top = priorityMatches.length * 0.2;
|
||||
return priorityMatches.slice(0, top).concat(prefixMatches, priorityMatches.slice(top), includesMatches).slice(0, TextAreaAutoComplete.suggestionCount);
|
||||
}
|
||||
|
||||
#update() {
|
||||
let before = this.helper.getBeforeCursor();
|
||||
if (before?.length) {
|
||||
const m = before.match(/([^,;"|{}()\n]+)$/);
|
||||
if (m) {
|
||||
before = m[0]
|
||||
.replace(/^\s+/, "")
|
||||
.replace(/\s/g, "_") || null;
|
||||
} else {
|
||||
before = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!before) {
|
||||
this.#hide();
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentWords = this.#getFilteredWords(before);
|
||||
if (!this.currentWords.length) {
|
||||
this.#hide();
|
||||
return;
|
||||
}
|
||||
|
||||
this.dropdown.style.display = "";
|
||||
|
||||
let hasSelected = false;
|
||||
const items = this.currentWords.map(({ wordInfo, pos }, i) => {
|
||||
const parts = [
|
||||
$el("span", {
|
||||
textContent: wordInfo.text.substr(0, pos),
|
||||
}),
|
||||
$el("span.pysssss-autocomplete-highlight", {
|
||||
textContent: wordInfo.text.substr(pos, before.length),
|
||||
}),
|
||||
$el("span", {
|
||||
textContent: wordInfo.text.substr(pos + before.length),
|
||||
}),
|
||||
];
|
||||
|
||||
if (wordInfo.hint) {
|
||||
parts.push(
|
||||
$el("span.pysssss-autocomplete-pill", {
|
||||
textContent: wordInfo.hint,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (wordInfo.priority) {
|
||||
parts.push(
|
||||
$el("span.pysssss-autocomplete-pill", {
|
||||
textContent: wordInfo.priority,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (wordInfo.value && wordInfo.text !== wordInfo.value && wordInfo.showValue !== false) {
|
||||
parts.push(
|
||||
$el("span.pysssss-autocomplete-pill", {
|
||||
textContent: wordInfo.value,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (wordInfo.info) {
|
||||
parts.push(
|
||||
$el("a.pysssss-autocomplete-item-info", {
|
||||
textContent: "ℹ️",
|
||||
title: "View info...",
|
||||
onclick: (e) => {
|
||||
e.stopPropagation();
|
||||
wordInfo.info();
|
||||
e.preventDefault();
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
const item = $el(
|
||||
"div.pysssss-autocomplete-item",
|
||||
{
|
||||
onclick: () => {
|
||||
this.el.focus();
|
||||
let value = wordInfo.value ?? wordInfo.text;
|
||||
const use_replacer = wordInfo.use_replacer ?? true;
|
||||
if (TextAreaAutoComplete.replacer && use_replacer) {
|
||||
value = TextAreaAutoComplete.replacer(value);
|
||||
}
|
||||
value = this.#escapeParentheses(value);
|
||||
|
||||
const afterCursor = this.helper.getAfterCursor();
|
||||
const shouldAddSeparator = !afterCursor.trim().startsWith(this.separator.trim());
|
||||
this.helper.insertAtCursor(
|
||||
value + (shouldAddSeparator ? this.separator : ''),
|
||||
-before.length,
|
||||
wordInfo.caretOffset
|
||||
);
|
||||
setTimeout(() => {
|
||||
this.#update();
|
||||
}, 150);
|
||||
},
|
||||
},
|
||||
parts
|
||||
);
|
||||
|
||||
if (wordInfo === this.selected) {
|
||||
hasSelected = true;
|
||||
}
|
||||
|
||||
wordInfo.index = i;
|
||||
wordInfo.el = item;
|
||||
|
||||
return item;
|
||||
});
|
||||
|
||||
this.#setSelected(hasSelected ? this.selected : this.currentWords[0].wordInfo);
|
||||
this.dropdown.replaceChildren(...items);
|
||||
|
||||
if (!this.dropdown.parentElement) {
|
||||
document.body.append(this.dropdown);
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
#escapeParentheses(text) {
|
||||
return text.replace(/\(/g, '\\(').replace(/\)/g, '\\)');
|
||||
}
|
||||
|
||||
#hide() {
|
||||
this.selected = null;
|
||||
this.dropdown.remove();
|
||||
}
|
||||
|
||||
static updateWords(id, words, addGlobal = true) {
|
||||
const isUpdate = id in TextAreaAutoComplete.groups;
|
||||
TextAreaAutoComplete.groups[id] = words;
|
||||
if (addGlobal) {
|
||||
TextAreaAutoComplete.globalGroups.add(id);
|
||||
}
|
||||
|
||||
if (isUpdate) {
|
||||
// Remerge all words
|
||||
TextAreaAutoComplete.globalWords = Object.assign(
|
||||
{},
|
||||
...Object.keys(TextAreaAutoComplete.groups)
|
||||
.filter((k) => TextAreaAutoComplete.globalGroups.has(k))
|
||||
.map((k) => TextAreaAutoComplete.groups[k])
|
||||
);
|
||||
} else if (addGlobal) {
|
||||
// Just insert the new words
|
||||
Object.assign(TextAreaAutoComplete.globalWords, words);
|
||||
}
|
||||
}
|
||||
}
|
||||
244
custom_nodes/comfyui-custom-scripts/web/js/common/binding.js
Normal file
244
custom_nodes/comfyui-custom-scripts/web/js/common/binding.js
Normal file
@@ -0,0 +1,244 @@
|
||||
// @ts-check
|
||||
// @ts-ignore
|
||||
import { ComfyWidgets } from "../../../../scripts/widgets.js";
|
||||
// @ts-ignore
|
||||
import { api } from "../../../../scripts/api.js";
|
||||
// @ts-ignore
|
||||
import { app } from "../../../../scripts/app.js";
|
||||
|
||||
const PathHelper = {
|
||||
get(obj, path) {
|
||||
if (typeof path !== "string") {
|
||||
// Hardcoded value
|
||||
return path;
|
||||
}
|
||||
|
||||
if (path[0] === '"' && path[path.length - 1] === '"') {
|
||||
// Hardcoded string
|
||||
return JSON.parse(path);
|
||||
}
|
||||
|
||||
// Evaluate the path
|
||||
path = path.split(".").filter(Boolean);
|
||||
for (const p of path) {
|
||||
const k = isNaN(+p) ? p : +p;
|
||||
obj = obj[k];
|
||||
}
|
||||
|
||||
return obj;
|
||||
},
|
||||
set(obj, path, value) {
|
||||
// https://stackoverflow.com/a/54733755
|
||||
if (Object(obj) !== obj) return obj; // When obj is not an object
|
||||
// If not yet an array, get the keys from the string-path
|
||||
if (!Array.isArray(path)) path = path.toString().match(/[^.[\]]+/g) || [];
|
||||
path.slice(0, -1).reduce(
|
||||
(
|
||||
a,
|
||||
c,
|
||||
i // Iterate all of them except the last one
|
||||
) =>
|
||||
Object(a[c]) === a[c] // Does the key exist and is its value an object?
|
||||
? // Yes: then follow that path
|
||||
a[c]
|
||||
: // No: create the key. Is the next key a potential array-index?
|
||||
(a[c] =
|
||||
Math.abs(path[i + 1]) >> 0 === +path[i + 1]
|
||||
? [] // Yes: assign a new array object
|
||||
: {}), // No: assign a new plain object
|
||||
obj
|
||||
)[path[path.length - 1]] = value; // Finally assign the value to the last key
|
||||
return obj; // Return the top-level object to allow chaining
|
||||
},
|
||||
};
|
||||
|
||||
/***
|
||||
@typedef { {
|
||||
left: string;
|
||||
op: "eq" | "ne",
|
||||
right: string
|
||||
} } IfCondition
|
||||
|
||||
@typedef { {
|
||||
type: "if",
|
||||
condition: Array<IfCondition>,
|
||||
true?: Array<BindingCallback>,
|
||||
false?: Array<BindingCallback>
|
||||
} } IfCallback
|
||||
|
||||
@typedef { {
|
||||
type: "fetch",
|
||||
url: string,
|
||||
then: Array<BindingCallback>
|
||||
} } FetchCallback
|
||||
|
||||
@typedef { {
|
||||
type: "set",
|
||||
target: string,
|
||||
value: string
|
||||
} } SetCallback
|
||||
|
||||
@typedef { {
|
||||
type: "validate-combo",
|
||||
} } ValidateComboCallback
|
||||
|
||||
@typedef { IfCallback | FetchCallback | SetCallback | ValidateComboCallback } BindingCallback
|
||||
|
||||
@typedef { {
|
||||
source: string,
|
||||
callback: Array<BindingCallback>
|
||||
} } Binding
|
||||
***/
|
||||
|
||||
/**
|
||||
* @param {IfCondition} condition
|
||||
*/
|
||||
function evaluateCondition(condition, state) {
|
||||
const left = PathHelper.get(state, condition.left);
|
||||
const right = PathHelper.get(state, condition.right);
|
||||
|
||||
let r;
|
||||
if (condition.op === "eq") {
|
||||
r = left === right;
|
||||
} else {
|
||||
r = left !== right;
|
||||
}
|
||||
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* @type { Record<BindingCallback["type"], (cb: any, state: Record<string, any>) => Promise<void>> }
|
||||
*/
|
||||
const callbacks = {
|
||||
/**
|
||||
* @param {IfCallback} cb
|
||||
*/
|
||||
async if(cb, state) {
|
||||
// For now only support ANDs
|
||||
let success = true;
|
||||
for (const condition of cb.condition) {
|
||||
const r = evaluateCondition(condition, state);
|
||||
if (!r) {
|
||||
success = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (const m of cb[success + ""] ?? []) {
|
||||
await invokeCallback(m, state);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @param {FetchCallback} cb
|
||||
*/
|
||||
async fetch(cb, state) {
|
||||
const url = cb.url.replace(/\{([^\}]+)\}/g, (m, v) => {
|
||||
return PathHelper.get(state, v);
|
||||
});
|
||||
const res = await (await api.fetchApi(url)).json();
|
||||
state["$result"] = res;
|
||||
for (const m of cb.then) {
|
||||
await invokeCallback(m, state);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @param {SetCallback} cb
|
||||
*/
|
||||
async set(cb, state) {
|
||||
const value = PathHelper.get(state, cb.value);
|
||||
PathHelper.set(state, cb.target, value);
|
||||
},
|
||||
async "validate-combo"(cb, state) {
|
||||
const w = state["$this"];
|
||||
const valid = w.options.values.includes(w.value);
|
||||
if (!valid) {
|
||||
w.value = w.options.values[0];
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
async function invokeCallback(callback, state) {
|
||||
if (callback.type in callbacks) {
|
||||
// @ts-ignore
|
||||
await callbacks[callback.type](callback, state);
|
||||
} else {
|
||||
console.warn(
|
||||
"%c[🐍 pysssss]",
|
||||
"color: limegreen",
|
||||
`[binding ${state.$node.comfyClass}.${state.$this.name}]`,
|
||||
"unsupported binding callback type:",
|
||||
callback.type
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "pysssss.Binding",
|
||||
beforeRegisterNodeDef(node, nodeData) {
|
||||
const hasBinding = (v) => {
|
||||
if (!v) return false;
|
||||
return Object.values(v).find((c) => c[1]?.["pysssss.binding"]);
|
||||
};
|
||||
const inputs = { ...nodeData.input?.required, ...nodeData.input?.optional };
|
||||
if (hasBinding(inputs)) {
|
||||
const onAdded = node.prototype.onAdded;
|
||||
node.prototype.onAdded = function () {
|
||||
const r = onAdded?.apply(this, arguments);
|
||||
|
||||
for (const widget of this.widgets || []) {
|
||||
const bindings = inputs[widget.name][1]?.["pysssss.binding"];
|
||||
if (!bindings) continue;
|
||||
|
||||
for (const binding of bindings) {
|
||||
/**
|
||||
* @type {import("../../../../../web/types/litegraph.d.ts").IWidget}
|
||||
*/
|
||||
const source = this.widgets.find((w) => w.name === binding.source);
|
||||
if (!source) {
|
||||
console.warn(
|
||||
"%c[🐍 pysssss]",
|
||||
"color: limegreen",
|
||||
`[binding ${node.comfyClass}.${widget.name}]`,
|
||||
"unable to find source binding widget:",
|
||||
binding.source,
|
||||
binding
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let lastValue;
|
||||
async function valueChanged() {
|
||||
const state = {
|
||||
$this: widget,
|
||||
$source: source,
|
||||
$node: node,
|
||||
};
|
||||
|
||||
for (const callback of binding.callback) {
|
||||
await invokeCallback(callback, state);
|
||||
}
|
||||
|
||||
app.graph.setDirtyCanvas(true, false);
|
||||
}
|
||||
|
||||
const cb = source.callback;
|
||||
source.callback = function () {
|
||||
const v = cb?.apply(this, arguments) ?? source.value;
|
||||
if (v !== lastValue) {
|
||||
lastValue = v;
|
||||
valueChanged();
|
||||
}
|
||||
return v;
|
||||
};
|
||||
|
||||
lastValue = source.value;
|
||||
valueChanged();
|
||||
}
|
||||
}
|
||||
|
||||
return r;
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
102
custom_nodes/comfyui-custom-scripts/web/js/common/lightbox.css
Normal file
102
custom_nodes/comfyui-custom-scripts/web/js/common/lightbox.css
Normal file
@@ -0,0 +1,102 @@
|
||||
.pysssss-lightbox {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1001;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.pysssss-lightbox-prev,
|
||||
.pysssss-lightbox-next {
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pysssss-lightbox-prev:after,
|
||||
.pysssss-lightbox-next:after {
|
||||
border-style: solid;
|
||||
border-width: 0.25em 0.25em 0 0;
|
||||
display: inline-block;
|
||||
height: 0.45em;
|
||||
left: 0.15em;
|
||||
position: relative;
|
||||
top: 0.15em;
|
||||
transform: rotate(-135deg) scale(0.75);
|
||||
vertical-align: top;
|
||||
width: 0.45em;
|
||||
padding: 10px;
|
||||
font-size: 20px;
|
||||
margin: 0 10px 0 20px;
|
||||
transition: color 0.2s;
|
||||
flex-shrink: 0;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.pysssss-lightbox-next:after {
|
||||
transform: rotate(45deg) scale(0.75);
|
||||
margin: 0 20px 0 0px;
|
||||
}
|
||||
|
||||
.pysssss-lightbox-main {
|
||||
display: grid;
|
||||
flex: auto;
|
||||
place-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pysssss-lightbox-link {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pysssss-lightbox .lds-ring {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.pysssss-lightbox-img {
|
||||
max-height: 90vh;
|
||||
max-width: calc(100vw - 130px);
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
border: 3px solid white;
|
||||
border-radius: 4px;
|
||||
transition: opacity 0.2s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.pysssss-lightbox-img:hover {
|
||||
border-color: dodgerblue;
|
||||
}
|
||||
|
||||
.pysssss-lightbox-close {
|
||||
font-size: 80px;
|
||||
line-height: 1ch;
|
||||
height: 1ch;
|
||||
width: 1ch;
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 10px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.pysssss-lightbox-close:after {
|
||||
content: "\00d7";
|
||||
}
|
||||
|
||||
.pysssss-lightbox-close:hover,
|
||||
.pysssss-lightbox-prev:hover,
|
||||
.pysssss-lightbox-next:hover {
|
||||
color: dodgerblue;
|
||||
cursor: pointer;
|
||||
}
|
||||
149
custom_nodes/comfyui-custom-scripts/web/js/common/lightbox.js
Normal file
149
custom_nodes/comfyui-custom-scripts/web/js/common/lightbox.js
Normal file
@@ -0,0 +1,149 @@
|
||||
import { $el } from "../../../../scripts/ui.js";
|
||||
import { addStylesheet, getUrl, loadImage } from "./utils.js";
|
||||
import { createSpinner } from "./spinner.js";
|
||||
|
||||
addStylesheet(getUrl("lightbox.css", import.meta.url));
|
||||
|
||||
const $$el = (tag, name, ...args) => {
|
||||
if (name) name = "-" + name;
|
||||
return $el(tag + ".pysssss-lightbox" + name, ...args);
|
||||
};
|
||||
|
||||
const ani = async (a, t, b) => {
|
||||
a();
|
||||
await new Promise((r) => setTimeout(r, t));
|
||||
b();
|
||||
};
|
||||
|
||||
export class Lightbox {
|
||||
constructor() {
|
||||
this.el = $$el("div", "", {
|
||||
parent: document.body,
|
||||
onclick: (e) => {
|
||||
e.stopImmediatePropagation();
|
||||
this.close();
|
||||
},
|
||||
style: {
|
||||
display: "none",
|
||||
opacity: 0,
|
||||
},
|
||||
});
|
||||
this.closeBtn = $$el("div", "close", {
|
||||
parent: this.el,
|
||||
});
|
||||
this.prev = $$el("div", "prev", {
|
||||
parent: this.el,
|
||||
onclick: (e) => {
|
||||
this.update(-1);
|
||||
e.stopImmediatePropagation();
|
||||
},
|
||||
});
|
||||
this.main = $$el("div", "main", {
|
||||
parent: this.el,
|
||||
});
|
||||
this.next = $$el("div", "next", {
|
||||
parent: this.el,
|
||||
onclick: (e) => {
|
||||
this.update(1);
|
||||
e.stopImmediatePropagation();
|
||||
},
|
||||
});
|
||||
this.link = $$el("a", "link", {
|
||||
parent: this.main,
|
||||
target: "_blank",
|
||||
});
|
||||
this.spinner = createSpinner();
|
||||
this.link.appendChild(this.spinner);
|
||||
this.img = $$el("img", "img", {
|
||||
style: {
|
||||
opacity: 0,
|
||||
},
|
||||
parent: this.link,
|
||||
onclick: (e) => {
|
||||
e.stopImmediatePropagation();
|
||||
},
|
||||
onwheel: (e) => {
|
||||
if (!(e instanceof WheelEvent) || e.ctrlKey) {
|
||||
return;
|
||||
}
|
||||
const direction = Math.sign(e.deltaY);
|
||||
this.update(direction);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
ani(
|
||||
() => (this.el.style.opacity = 0),
|
||||
200,
|
||||
() => (this.el.style.display = "none")
|
||||
);
|
||||
}
|
||||
|
||||
async show(images, index) {
|
||||
this.images = images;
|
||||
this.index = index || 0;
|
||||
await this.update(0);
|
||||
}
|
||||
|
||||
async update(shift) {
|
||||
if (shift < 0 && this.index <= 0) {
|
||||
return;
|
||||
}
|
||||
if (shift > 0 && this.index >= this.images.length - 1) {
|
||||
return;
|
||||
}
|
||||
this.index += shift;
|
||||
|
||||
this.prev.style.visibility = this.index ? "unset" : "hidden";
|
||||
this.next.style.visibility = this.index === this.images.length - 1 ? "hidden" : "unset";
|
||||
|
||||
const img = this.images[this.index];
|
||||
this.el.style.display = "flex";
|
||||
this.el.clientWidth; // Force a reflow
|
||||
this.el.style.opacity = 1;
|
||||
this.img.style.opacity = 0;
|
||||
this.spinner.style.display = "inline-block";
|
||||
try {
|
||||
await loadImage(img);
|
||||
} catch (err) {
|
||||
console.error('failed to load image', img, err);
|
||||
}
|
||||
this.spinner.style.display = "none";
|
||||
this.link.href = img;
|
||||
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();
|
||||
|
||||
addEventListener('keydown', (event) => {
|
||||
if (lightbox.el.style.display === 'none') {
|
||||
return;
|
||||
}
|
||||
const { key } = event;
|
||||
switch (key) {
|
||||
case 'ArrowLeft':
|
||||
case 'a':
|
||||
lightbox.update(-1);
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
case 'd':
|
||||
lightbox.update(1);
|
||||
break;
|
||||
case 'Escape':
|
||||
lightbox.close();
|
||||
break;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
.pysssss-model-info {
|
||||
color: white;
|
||||
font-family: sans-serif;
|
||||
max-width: 90vw;
|
||||
}
|
||||
.pysssss-model-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pysssss-model-info h2 {
|
||||
text-align: center;
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
.pysssss-model-info p {
|
||||
margin: 5px 0;
|
||||
}
|
||||
.pysssss-model-info a {
|
||||
color: dodgerblue;
|
||||
}
|
||||
.pysssss-model-info a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.pysssss-model-tags-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
list-style: none;
|
||||
gap: 10px;
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
margin: 10px 0;
|
||||
padding: 0;
|
||||
}
|
||||
.pysssss-model-tag {
|
||||
background-color: rgb(128, 213, 247);
|
||||
color: #000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border-radius: 5px;
|
||||
padding: 2px 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pysssss-model-tag--selected span::before {
|
||||
content: "✅";
|
||||
position: absolute;
|
||||
background-color: dodgerblue;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
text-align: center;
|
||||
}
|
||||
.pysssss-model-tag:hover {
|
||||
outline: 2px solid dodgerblue;
|
||||
}
|
||||
.pysssss-model-tag p {
|
||||
margin: 0;
|
||||
}
|
||||
.pysssss-model-tag span {
|
||||
text-align: center;
|
||||
border-radius: 5px;
|
||||
background-color: dodgerblue;
|
||||
color: #fff;
|
||||
padding: 2px;
|
||||
position: relative;
|
||||
min-width: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pysssss-model-metadata .comfy-modal-content {
|
||||
max-width: 100%;
|
||||
}
|
||||
.pysssss-model-metadata label {
|
||||
margin-right: 1ch;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.pysssss-model-metadata span {
|
||||
color: dodgerblue;
|
||||
}
|
||||
|
||||
.pysssss-preview {
|
||||
max-width: 50%;
|
||||
margin-left: 10px;
|
||||
position: relative;
|
||||
}
|
||||
.pysssss-preview img {
|
||||
max-height: 300px;
|
||||
}
|
||||
.pysssss-preview button {
|
||||
position: absolute;
|
||||
font-size: 12px;
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
}
|
||||
.pysssss-preview button+button {
|
||||
bottom: 34px;
|
||||
}
|
||||
|
||||
.pysssss-preview button.pysssss-preview-nav {
|
||||
bottom: unset;
|
||||
right: 30px;
|
||||
top: 10px;
|
||||
font-size: 14px;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
.pysssss-preview button.pysssss-preview-nav+.pysssss-preview-nav {
|
||||
right: 10px;
|
||||
}
|
||||
.pysssss-model-notes {
|
||||
background-color: rgba(0, 0, 0, 0.25);
|
||||
padding: 5px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.pysssss-model-notes:empty {
|
||||
display: none;
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import { $el, ComfyDialog } from "../../../../scripts/ui.js";
|
||||
import { api } from "../../../../scripts/api.js";
|
||||
import { addStylesheet } from "./utils.js";
|
||||
|
||||
addStylesheet(import.meta.url);
|
||||
|
||||
class MetadataDialog extends ComfyDialog {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.element.classList.add("pysssss-model-metadata");
|
||||
}
|
||||
show(metadata) {
|
||||
super.show(
|
||||
$el(
|
||||
"div",
|
||||
Object.keys(metadata).map((k) =>
|
||||
$el("div", [
|
||||
$el("label", { textContent: k }),
|
||||
$el("span", { textContent: typeof metadata[k] === "object" ? JSON.stringify(metadata[k]) : metadata[k] }),
|
||||
])
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelInfoDialog extends ComfyDialog {
|
||||
constructor(name, node) {
|
||||
super();
|
||||
this.name = name;
|
||||
this.node = node;
|
||||
this.element.classList.add("pysssss-model-info");
|
||||
}
|
||||
|
||||
get customNotes() {
|
||||
return this.metadata["pysssss.notes"];
|
||||
}
|
||||
|
||||
set customNotes(v) {
|
||||
this.metadata["pysssss.notes"] = v;
|
||||
}
|
||||
|
||||
get hash() {
|
||||
return this.metadata["pysssss.sha256"];
|
||||
}
|
||||
|
||||
async show(type, value) {
|
||||
this.type = type;
|
||||
|
||||
const req = api.fetchApi("/pysssss/metadata/" + encodeURIComponent(`${type}/${value}`));
|
||||
this.info = $el("div", { style: { flex: "auto" } });
|
||||
this.img = $el("img", { style: { display: "none" } });
|
||||
this.imgWrapper = $el("div.pysssss-preview", [this.img]);
|
||||
this.main = $el("main", { style: { display: "flex" } }, [this.info, this.imgWrapper]);
|
||||
this.content = $el("div.pysssss-model-content", [$el("h2", { textContent: this.name }), this.main]);
|
||||
|
||||
const loading = $el("div", { textContent: "ℹ️ Loading...", parent: this.content });
|
||||
|
||||
super.show(this.content);
|
||||
|
||||
this.metadata = await (await req).json();
|
||||
this.viewMetadata.style.cursor = this.viewMetadata.style.opacity = "";
|
||||
this.viewMetadata.removeAttribute("disabled");
|
||||
|
||||
loading.remove();
|
||||
this.addInfo();
|
||||
}
|
||||
|
||||
createButtons() {
|
||||
const btns = super.createButtons();
|
||||
this.viewMetadata = $el("button", {
|
||||
type: "button",
|
||||
textContent: "View raw metadata",
|
||||
disabled: "disabled",
|
||||
style: {
|
||||
opacity: 0.5,
|
||||
cursor: "not-allowed",
|
||||
},
|
||||
onclick: (e) => {
|
||||
if (this.metadata) {
|
||||
new MetadataDialog().show(this.metadata);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
btns.unshift(this.viewMetadata);
|
||||
return btns;
|
||||
}
|
||||
|
||||
getNoteInfo() {
|
||||
function parseNote() {
|
||||
if (!this.customNotes) return [];
|
||||
|
||||
let notes = [];
|
||||
// Extract links from notes
|
||||
const r = new RegExp("(\\bhttps?:\\/\\/[^\\s]+)", "g");
|
||||
let end = 0;
|
||||
let m;
|
||||
do {
|
||||
m = r.exec(this.customNotes);
|
||||
let pos;
|
||||
let fin = 0;
|
||||
if (m) {
|
||||
pos = m.index;
|
||||
fin = m.index + m[0].length;
|
||||
} else {
|
||||
pos = this.customNotes.length;
|
||||
}
|
||||
|
||||
let pre = this.customNotes.substring(end, pos);
|
||||
if (pre) {
|
||||
pre = pre.replaceAll("\n", "<br>");
|
||||
notes.push(
|
||||
$el("span", {
|
||||
innerHTML: pre,
|
||||
})
|
||||
);
|
||||
}
|
||||
if (m) {
|
||||
notes.push(
|
||||
$el("a", {
|
||||
href: m[0],
|
||||
textContent: m[0],
|
||||
target: "_blank",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
end = fin;
|
||||
} while (m);
|
||||
return notes;
|
||||
}
|
||||
|
||||
let textarea;
|
||||
let notesContainer;
|
||||
const editText = "✏️ Edit";
|
||||
const edit = $el("a", {
|
||||
textContent: editText,
|
||||
href: "#",
|
||||
style: {
|
||||
float: "right",
|
||||
color: "greenyellow",
|
||||
textDecoration: "none",
|
||||
},
|
||||
onclick: async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (textarea) {
|
||||
this.customNotes = textarea.value;
|
||||
|
||||
const resp = await api.fetchApi("/pysssss/metadata/notes/" + encodeURIComponent(`${this.type}/${this.name}`), {
|
||||
method: "POST",
|
||||
body: this.customNotes,
|
||||
});
|
||||
|
||||
if (resp.status !== 200) {
|
||||
console.error(resp);
|
||||
alert(`Error saving notes (${req.status}) ${req.statusText}`);
|
||||
return;
|
||||
}
|
||||
|
||||
e.target.textContent = editText;
|
||||
textarea.remove();
|
||||
textarea = null;
|
||||
|
||||
notesContainer.replaceChildren(...parseNote.call(this));
|
||||
this.node?.["pysssss.updateExamples"]?.();
|
||||
} else {
|
||||
e.target.textContent = "💾 Save";
|
||||
textarea = $el("textarea", {
|
||||
style: {
|
||||
width: "100%",
|
||||
minWidth: "200px",
|
||||
minHeight: "50px",
|
||||
},
|
||||
textContent: this.customNotes,
|
||||
});
|
||||
e.target.after(textarea);
|
||||
notesContainer.replaceChildren();
|
||||
textarea.style.height = Math.min(textarea.scrollHeight, 300) + "px";
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
notesContainer = $el("div.pysssss-model-notes", parseNote.call(this));
|
||||
return $el(
|
||||
"div",
|
||||
{
|
||||
style: { display: "contents" },
|
||||
},
|
||||
[edit, notesContainer]
|
||||
);
|
||||
}
|
||||
|
||||
addInfo() {
|
||||
const usageHint = this.metadata["modelspec.usage_hint"];
|
||||
if (usageHint) {
|
||||
this.addInfoEntry("Usage Hint", usageHint);
|
||||
}
|
||||
this.addInfoEntry("Notes", this.getNoteInfo());
|
||||
}
|
||||
|
||||
addInfoEntry(name, value) {
|
||||
return $el(
|
||||
"p",
|
||||
{
|
||||
parent: this.info,
|
||||
},
|
||||
[
|
||||
typeof name === "string" ? $el("label", { textContent: name + ": " }) : name,
|
||||
typeof value === "string" ? $el("span", { textContent: value }) : value,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
async getCivitaiDetails() {
|
||||
const req = await fetch("https://civitai.com/api/v1/model-versions/by-hash/" + this.hash);
|
||||
if (req.status === 200) {
|
||||
return await req.json();
|
||||
} else if (req.status === 404) {
|
||||
throw new Error("Model not found");
|
||||
} else {
|
||||
throw new Error(`Error loading info (${req.status}) ${req.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
addCivitaiInfo() {
|
||||
const promise = this.getCivitaiDetails();
|
||||
const content = $el("span", { textContent: "ℹ️ Loading..." });
|
||||
|
||||
this.addInfoEntry(
|
||||
$el("label", [
|
||||
$el("img", {
|
||||
style: {
|
||||
width: "18px",
|
||||
position: "relative",
|
||||
top: "3px",
|
||||
margin: "0 5px 0 0",
|
||||
},
|
||||
src: "https://civitai.com/favicon.ico",
|
||||
}),
|
||||
$el("span", { textContent: "Civitai: " }),
|
||||
]),
|
||||
content
|
||||
);
|
||||
|
||||
return promise
|
||||
.then((info) => {
|
||||
content.replaceChildren(
|
||||
$el("a", {
|
||||
href: "https://civitai.com/models/" + info.modelId,
|
||||
textContent: "View " + info.model.name,
|
||||
target: "_blank",
|
||||
})
|
||||
);
|
||||
|
||||
const allPreviews = info.images?.filter((i) => i.type === "image");
|
||||
const previews = allPreviews?.filter((i) => i.nsfwLevel <= ModelInfoDialog.nsfwLevel);
|
||||
if (previews?.length) {
|
||||
let previewIndex = 0;
|
||||
let preview;
|
||||
const updatePreview = () => {
|
||||
preview = previews[previewIndex];
|
||||
this.img.src = preview.url;
|
||||
};
|
||||
|
||||
updatePreview();
|
||||
this.img.style.display = "";
|
||||
|
||||
this.img.title = `${previews.length} previews.`;
|
||||
if (allPreviews.length !== previews.length) {
|
||||
this.img.title += ` ${allPreviews.length - previews.length} images hidden due to NSFW level.`;
|
||||
}
|
||||
|
||||
this.imgSave = $el("button", {
|
||||
textContent: "Use as preview",
|
||||
parent: this.imgWrapper,
|
||||
onclick: async () => {
|
||||
// Convert the preview to a blob
|
||||
const blob = await (await fetch(this.img.src)).blob();
|
||||
|
||||
// Store it in temp
|
||||
const name = "temp_preview." + new URL(this.img.src).pathname.split(".")[1];
|
||||
const body = new FormData();
|
||||
body.append("image", new File([blob], name));
|
||||
body.append("overwrite", "true");
|
||||
body.append("type", "temp");
|
||||
|
||||
const resp = await api.fetchApi("/upload/image", {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
|
||||
if (resp.status !== 200) {
|
||||
console.error(resp);
|
||||
alert(`Error saving preview (${req.status}) ${req.statusText}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Use as preview
|
||||
await api.fetchApi("/pysssss/save/" + encodeURIComponent(`${this.type}/${this.name}`), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
filename: name,
|
||||
type: "temp",
|
||||
}),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
});
|
||||
app.refreshComboInNodes();
|
||||
},
|
||||
});
|
||||
|
||||
$el("button", {
|
||||
textContent: "Show metadata",
|
||||
parent: this.imgWrapper,
|
||||
onclick: async () => {
|
||||
if (preview.meta && Object.keys(preview.meta).length) {
|
||||
new MetadataDialog().show(preview.meta);
|
||||
} else {
|
||||
alert("No image metadata found");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const addNavButton = (icon, direction) => {
|
||||
$el("button.pysssss-preview-nav", {
|
||||
textContent: icon,
|
||||
parent: this.imgWrapper,
|
||||
onclick: async () => {
|
||||
previewIndex += direction;
|
||||
if (previewIndex < 0) {
|
||||
previewIndex = previews.length - 1;
|
||||
} else if (previewIndex >= previews.length) {
|
||||
previewIndex = 0;
|
||||
}
|
||||
updatePreview();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (previews.length > 1) {
|
||||
addNavButton("‹", -1);
|
||||
addNavButton("›", 1);
|
||||
}
|
||||
} else if (info.images?.length) {
|
||||
$el("span", { style: { opacity: 0.6 }, textContent: "⚠️ All images hidden due to NSFW level setting.", parent: this.imgWrapper });
|
||||
}
|
||||
|
||||
return info;
|
||||
})
|
||||
.catch((err) => {
|
||||
content.textContent = "⚠️ " + err.message;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
.pysssss-lds-ring {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
.pysssss-lds-ring div {
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 8px;
|
||||
border: 5px solid #fff;
|
||||
border-radius: 50%;
|
||||
animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
|
||||
border-color: #fff transparent transparent transparent;
|
||||
}
|
||||
.pysssss-lds-ring div:nth-child(1) {
|
||||
animation-delay: -0.45s;
|
||||
}
|
||||
.pysssss-lds-ring div:nth-child(2) {
|
||||
animation-delay: -0.3s;
|
||||
}
|
||||
.pysssss-lds-ring div:nth-child(3) {
|
||||
animation-delay: -0.15s;
|
||||
}
|
||||
@keyframes lds-ring {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { addStylesheet } from "./utils.js";
|
||||
|
||||
addStylesheet(import.meta.url);
|
||||
|
||||
export function createSpinner() {
|
||||
const div = document.createElement("div");
|
||||
div.innerHTML = `<div class="pysssss-lds-ring"><div></div><div></div><div></div><div></div></div>`;
|
||||
return div.firstElementChild;
|
||||
}
|
||||
30
custom_nodes/comfyui-custom-scripts/web/js/common/utils.js
Normal file
30
custom_nodes/comfyui-custom-scripts/web/js/common/utils.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { $el } from "../../../../scripts/ui.js";
|
||||
|
||||
export function addStylesheet(url) {
|
||||
if (url.endsWith(".js")) {
|
||||
url = url.substr(0, url.length - 2) + "css";
|
||||
}
|
||||
$el("link", {
|
||||
parent: document.head,
|
||||
rel: "stylesheet",
|
||||
type: "text/css",
|
||||
href: url.startsWith("http") ? url : getUrl(url),
|
||||
});
|
||||
}
|
||||
|
||||
export function getUrl(path, baseUrl) {
|
||||
if (baseUrl) {
|
||||
return new URL(path, baseUrl).toString();
|
||||
} else {
|
||||
return new URL("../" + path, import.meta.url).toString();
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadImage(url) {
|
||||
return new Promise((res, rej) => {
|
||||
const img = new Image();
|
||||
img.onload = res;
|
||||
img.onerror = rej;
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user