From 32bb6f1f044daa473297fa78a3d9eaa6f7421d0e Mon Sep 17 00:00:00 2001
From: Davo KiwiCloudNinja ${this.parser.parseInline(tokens)} An error occurred:
\n';
+ }
+ return ''
+ + (escaped ? code : escape$1(code, true))
+ + '
\n';
+ }
+ blockquote({ tokens }) {
+ const body = this.parser.parse(tokens);
+ return `'
+ + (escaped ? code : escape$1(code, true))
+ + '\n${body}
\n`;
+ }
+ html({ text }) {
+ return text;
+ }
+ heading({ tokens, depth }) {
+ return `
\n';
+ }
+ list(token) {
+ const ordered = token.ordered;
+ const start = token.start;
+ let body = '';
+ for (let j = 0; j < token.items.length; j++) {
+ const item = token.items[j];
+ body += this.listitem(item);
+ }
+ const type = ordered ? 'ol' : 'ul';
+ const startAttr = (ordered && start !== 1) ? (' start="' + start + '"') : '';
+ return '<' + type + startAttr + '>\n' + body + '' + type + '>\n';
+ }
+ listitem(item) {
+ let itemBody = '';
+ if (item.task) {
+ const checkbox = this.checkbox({ checked: !!item.checked });
+ if (item.loose) {
+ if (item.tokens.length > 0 && item.tokens[0].type === 'paragraph') {
+ item.tokens[0].text = checkbox + ' ' + item.tokens[0].text;
+ if (item.tokens[0].tokens && item.tokens[0].tokens.length > 0 && item.tokens[0].tokens[0].type === 'text') {
+ item.tokens[0].tokens[0].text = checkbox + ' ' + item.tokens[0].tokens[0].text;
+ }
+ }
+ else {
+ item.tokens.unshift({
+ type: 'text',
+ raw: checkbox + ' ',
+ text: checkbox + ' ',
+ });
+ }
+ }
+ else {
+ itemBody += checkbox + ' ';
+ }
+ }
+ itemBody += this.parser.parse(item.tokens, !!item.loose);
+ return `\n'
+ + '\n'
+ + header
+ + '\n'
+ + body
+ + '
\n';
+ }
+ tablerow({ text }) {
+ return `\n${text} \n`;
+ }
+ tablecell(token) {
+ const content = this.parser.parseInline(token.tokens);
+ const type = token.header ? 'th' : 'td';
+ const tag = token.align
+ ? `<${type} align="${token.align}">`
+ : `<${type}>`;
+ return tag + content + `${type}>\n`;
+ }
+ /**
+ * span level renderer
+ */
+ strong({ tokens }) {
+ return `${this.parser.parseInline(tokens)}`;
+ }
+ em({ tokens }) {
+ return `${this.parser.parseInline(tokens)}`;
+ }
+ codespan({ text }) {
+ return `${text}`;
+ }
+ br(token) {
+ return '
';
+ }
+ del({ tokens }) {
+ return `${this.parser.parseInline(tokens)}`;
+ }
+ link({ href, title, tokens }) {
+ const text = this.parser.parseInline(tokens);
+ const cleanHref = cleanUrl(href);
+ if (cleanHref === null) {
+ return text;
+ }
+ href = cleanHref;
+ let out = '' + text + '';
+ return out;
+ }
+ image({ href, title, text }) {
+ const cleanHref = cleanUrl(href);
+ if (cleanHref === null) {
+ return text;
+ }
+ href = cleanHref;
+ let out = `';
+ return out;
+ }
+ text(token) {
+ return 'tokens' in token && token.tokens ? this.parser.parseInline(token.tokens) : token.text;
+ }
+}
+
+/**
+ * TextRenderer
+ * returns only the textual part of the token
+ */
+class _TextRenderer {
+ // no need for block level renderers
+ strong({ text }) {
+ return text;
+ }
+ em({ text }) {
+ return text;
+ }
+ codespan({ text }) {
+ return text;
+ }
+ del({ text }) {
+ return text;
+ }
+ html({ text }) {
+ return text;
+ }
+ text({ text }) {
+ return text;
+ }
+ link({ text }) {
+ return '' + text;
+ }
+ image({ text }) {
+ return '' + text;
+ }
+ br() {
+ return '';
+ }
+}
+
+/**
+ * Parsing & Compiling
+ */
+class _Parser {
+ options;
+ renderer;
+ textRenderer;
+ constructor(options) {
+ this.options = options || _defaults;
+ this.options.renderer = this.options.renderer || new _Renderer();
+ this.renderer = this.options.renderer;
+ this.renderer.options = this.options;
+ this.renderer.parser = this;
+ this.textRenderer = new _TextRenderer();
+ }
+ /**
+ * Static Parse Method
+ */
+ static parse(tokens, options) {
+ const parser = new _Parser(options);
+ return parser.parse(tokens);
+ }
+ /**
+ * Static Parse Inline Method
+ */
+ static parseInline(tokens, options) {
+ const parser = new _Parser(options);
+ return parser.parseInline(tokens);
+ }
+ /**
+ * Parse Loop
+ */
+ parse(tokens, top = true) {
+ let out = '';
+ for (let i = 0; i < tokens.length; i++) {
+ const anyToken = tokens[i];
+ // Run any renderer extensions
+ if (this.options.extensions && this.options.extensions.renderers && this.options.extensions.renderers[anyToken.type]) {
+ const genericToken = anyToken;
+ const ret = this.options.extensions.renderers[genericToken.type].call({ parser: this }, genericToken);
+ if (ret !== false || !['space', 'hr', 'heading', 'code', 'table', 'blockquote', 'list', 'html', 'paragraph', 'text'].includes(genericToken.type)) {
+ out += ret || '';
+ continue;
+ }
+ }
+ const token = anyToken;
+ switch (token.type) {
+ case 'space': {
+ out += this.renderer.space(token);
+ continue;
+ }
+ case 'hr': {
+ out += this.renderer.hr(token);
+ continue;
+ }
+ case 'heading': {
+ out += this.renderer.heading(token);
+ continue;
+ }
+ case 'code': {
+ out += this.renderer.code(token);
+ continue;
+ }
+ case 'table': {
+ out += this.renderer.table(token);
+ continue;
+ }
+ case 'blockquote': {
+ out += this.renderer.blockquote(token);
+ continue;
+ }
+ case 'list': {
+ out += this.renderer.list(token);
+ continue;
+ }
+ case 'html': {
+ out += this.renderer.html(token);
+ continue;
+ }
+ case 'paragraph': {
+ out += this.renderer.paragraph(token);
+ continue;
+ }
+ case 'text': {
+ let textToken = token;
+ let body = this.renderer.text(textToken);
+ while (i + 1 < tokens.length && tokens[i + 1].type === 'text') {
+ textToken = tokens[++i];
+ body += '\n' + this.renderer.text(textToken);
+ }
+ if (top) {
+ out += this.renderer.paragraph({
+ type: 'paragraph',
+ raw: body,
+ text: body,
+ tokens: [{ type: 'text', raw: body, text: body }],
+ });
+ }
+ else {
+ out += body;
+ }
+ continue;
+ }
+ default: {
+ const errMsg = 'Token with "' + token.type + '" type was not found.';
+ if (this.options.silent) {
+ console.error(errMsg);
+ return '';
+ }
+ else {
+ throw new Error(errMsg);
+ }
+ }
+ }
+ }
+ return out;
+ }
+ /**
+ * Parse Inline Tokens
+ */
+ parseInline(tokens, renderer) {
+ renderer = renderer || this.renderer;
+ let out = '';
+ for (let i = 0; i < tokens.length; i++) {
+ const anyToken = tokens[i];
+ // Run any renderer extensions
+ if (this.options.extensions && this.options.extensions.renderers && this.options.extensions.renderers[anyToken.type]) {
+ const ret = this.options.extensions.renderers[anyToken.type].call({ parser: this }, anyToken);
+ if (ret !== false || !['escape', 'html', 'link', 'image', 'strong', 'em', 'codespan', 'br', 'del', 'text'].includes(anyToken.type)) {
+ out += ret || '';
+ continue;
+ }
+ }
+ const token = anyToken;
+ switch (token.type) {
+ case 'escape': {
+ out += renderer.text(token);
+ break;
+ }
+ case 'html': {
+ out += renderer.html(token);
+ break;
+ }
+ case 'link': {
+ out += renderer.link(token);
+ break;
+ }
+ case 'image': {
+ out += renderer.image(token);
+ break;
+ }
+ case 'strong': {
+ out += renderer.strong(token);
+ break;
+ }
+ case 'em': {
+ out += renderer.em(token);
+ break;
+ }
+ case 'codespan': {
+ out += renderer.codespan(token);
+ break;
+ }
+ case 'br': {
+ out += renderer.br(token);
+ break;
+ }
+ case 'del': {
+ out += renderer.del(token);
+ break;
+ }
+ case 'text': {
+ out += renderer.text(token);
+ break;
+ }
+ default: {
+ const errMsg = 'Token with "' + token.type + '" type was not found.';
+ if (this.options.silent) {
+ console.error(errMsg);
+ return '';
+ }
+ else {
+ throw new Error(errMsg);
+ }
+ }
+ }
+ }
+ return out;
+ }
+}
+
+class _Hooks {
+ options;
+ block;
+ constructor(options) {
+ this.options = options || _defaults;
+ }
+ static passThroughHooks = new Set([
+ 'preprocess',
+ 'postprocess',
+ 'processAllTokens',
+ ]);
+ /**
+ * Process markdown before marked
+ */
+ preprocess(markdown) {
+ return markdown;
+ }
+ /**
+ * Process HTML after marked is finished
+ */
+ postprocess(html) {
+ return html;
+ }
+ /**
+ * Process all tokens before walk tokens
+ */
+ processAllTokens(tokens) {
+ return tokens;
+ }
+ /**
+ * Provide function to tokenize markdown
+ */
+ provideLexer() {
+ return this.block ? _Lexer.lex : _Lexer.lexInline;
+ }
+ /**
+ * Provide function to parse tokens
+ */
+ provideParser() {
+ return this.block ? _Parser.parse : _Parser.parseInline;
+ }
+}
+
+class Marked {
+ defaults = _getDefaults();
+ options = this.setOptions;
+ parse = this.parseMarkdown(true);
+ parseInline = this.parseMarkdown(false);
+ Parser = _Parser;
+ Renderer = _Renderer;
+ TextRenderer = _TextRenderer;
+ Lexer = _Lexer;
+ Tokenizer = _Tokenizer;
+ Hooks = _Hooks;
+ constructor(...args) {
+ this.use(...args);
+ }
+ /**
+ * Run callback for every token
+ */
+ walkTokens(tokens, callback) {
+ let values = [];
+ for (const token of tokens) {
+ values = values.concat(callback.call(this, token));
+ switch (token.type) {
+ case 'table': {
+ const tableToken = token;
+ for (const cell of tableToken.header) {
+ values = values.concat(this.walkTokens(cell.tokens, callback));
+ }
+ for (const row of tableToken.rows) {
+ for (const cell of row) {
+ values = values.concat(this.walkTokens(cell.tokens, callback));
+ }
+ }
+ break;
+ }
+ case 'list': {
+ const listToken = token;
+ values = values.concat(this.walkTokens(listToken.items, callback));
+ break;
+ }
+ default: {
+ const genericToken = token;
+ if (this.defaults.extensions?.childTokens?.[genericToken.type]) {
+ this.defaults.extensions.childTokens[genericToken.type].forEach((childTokens) => {
+ const tokens = genericToken[childTokens].flat(Infinity);
+ values = values.concat(this.walkTokens(tokens, callback));
+ });
+ }
+ else if (genericToken.tokens) {
+ values = values.concat(this.walkTokens(genericToken.tokens, callback));
+ }
+ }
+ }
+ }
+ return values;
+ }
+ use(...args) {
+ const extensions = this.defaults.extensions || { renderers: {}, childTokens: {} };
+ args.forEach((pack) => {
+ // copy options to new object
+ const opts = { ...pack };
+ // set async to true if it was set to true before
+ opts.async = this.defaults.async || opts.async || false;
+ // ==-- Parse "addon" extensions --== //
+ if (pack.extensions) {
+ pack.extensions.forEach((ext) => {
+ if (!ext.name) {
+ throw new Error('extension name required');
+ }
+ if ('renderer' in ext) { // Renderer extensions
+ const prevRenderer = extensions.renderers[ext.name];
+ if (prevRenderer) {
+ // Replace extension with func to run new extension but fall back if false
+ extensions.renderers[ext.name] = function (...args) {
+ let ret = ext.renderer.apply(this, args);
+ if (ret === false) {
+ ret = prevRenderer.apply(this, args);
+ }
+ return ret;
+ };
+ }
+ else {
+ extensions.renderers[ext.name] = ext.renderer;
+ }
+ }
+ if ('tokenizer' in ext) { // Tokenizer Extensions
+ if (!ext.level || (ext.level !== 'block' && ext.level !== 'inline')) {
+ throw new Error("extension level must be 'block' or 'inline'");
+ }
+ const extLevel = extensions[ext.level];
+ if (extLevel) {
+ extLevel.unshift(ext.tokenizer);
+ }
+ else {
+ extensions[ext.level] = [ext.tokenizer];
+ }
+ if (ext.start) { // Function to check for start of token
+ if (ext.level === 'block') {
+ if (extensions.startBlock) {
+ extensions.startBlock.push(ext.start);
+ }
+ else {
+ extensions.startBlock = [ext.start];
+ }
+ }
+ else if (ext.level === 'inline') {
+ if (extensions.startInline) {
+ extensions.startInline.push(ext.start);
+ }
+ else {
+ extensions.startInline = [ext.start];
+ }
+ }
+ }
+ }
+ if ('childTokens' in ext && ext.childTokens) { // Child tokens to be visited by walkTokens
+ extensions.childTokens[ext.name] = ext.childTokens;
+ }
+ });
+ opts.extensions = extensions;
+ }
+ // ==-- Parse "overwrite" extensions --== //
+ if (pack.renderer) {
+ const renderer = this.defaults.renderer || new _Renderer(this.defaults);
+ for (const prop in pack.renderer) {
+ if (!(prop in renderer)) {
+ throw new Error(`renderer '${prop}' does not exist`);
+ }
+ if (['options', 'parser'].includes(prop)) {
+ // ignore options property
+ continue;
+ }
+ const rendererProp = prop;
+ const rendererFunc = pack.renderer[rendererProp];
+ const prevRenderer = renderer[rendererProp];
+ // Replace renderer with func to run extension, but fall back if false
+ renderer[rendererProp] = (...args) => {
+ let ret = rendererFunc.apply(renderer, args);
+ if (ret === false) {
+ ret = prevRenderer.apply(renderer, args);
+ }
+ return ret || '';
+ };
+ }
+ opts.renderer = renderer;
+ }
+ if (pack.tokenizer) {
+ const tokenizer = this.defaults.tokenizer || new _Tokenizer(this.defaults);
+ for (const prop in pack.tokenizer) {
+ if (!(prop in tokenizer)) {
+ throw new Error(`tokenizer '${prop}' does not exist`);
+ }
+ if (['options', 'rules', 'lexer'].includes(prop)) {
+ // ignore options, rules, and lexer properties
+ continue;
+ }
+ const tokenizerProp = prop;
+ const tokenizerFunc = pack.tokenizer[tokenizerProp];
+ const prevTokenizer = tokenizer[tokenizerProp];
+ // Replace tokenizer with func to run extension, but fall back if false
+ // @ts-expect-error cannot type tokenizer function dynamically
+ tokenizer[tokenizerProp] = (...args) => {
+ let ret = tokenizerFunc.apply(tokenizer, args);
+ if (ret === false) {
+ ret = prevTokenizer.apply(tokenizer, args);
+ }
+ return ret;
+ };
+ }
+ opts.tokenizer = tokenizer;
+ }
+ // ==-- Parse Hooks extensions --== //
+ if (pack.hooks) {
+ const hooks = this.defaults.hooks || new _Hooks();
+ for (const prop in pack.hooks) {
+ if (!(prop in hooks)) {
+ throw new Error(`hook '${prop}' does not exist`);
+ }
+ if (['options', 'block'].includes(prop)) {
+ // ignore options and block properties
+ continue;
+ }
+ const hooksProp = prop;
+ const hooksFunc = pack.hooks[hooksProp];
+ const prevHook = hooks[hooksProp];
+ if (_Hooks.passThroughHooks.has(prop)) {
+ // @ts-expect-error cannot type hook function dynamically
+ hooks[hooksProp] = (arg) => {
+ if (this.defaults.async) {
+ return Promise.resolve(hooksFunc.call(hooks, arg)).then(ret => {
+ return prevHook.call(hooks, ret);
+ });
+ }
+ const ret = hooksFunc.call(hooks, arg);
+ return prevHook.call(hooks, ret);
+ };
+ }
+ else {
+ // @ts-expect-error cannot type hook function dynamically
+ hooks[hooksProp] = (...args) => {
+ let ret = hooksFunc.apply(hooks, args);
+ if (ret === false) {
+ ret = prevHook.apply(hooks, args);
+ }
+ return ret;
+ };
+ }
+ }
+ opts.hooks = hooks;
+ }
+ // ==-- Parse WalkTokens extensions --== //
+ if (pack.walkTokens) {
+ const walkTokens = this.defaults.walkTokens;
+ const packWalktokens = pack.walkTokens;
+ opts.walkTokens = function (token) {
+ let values = [];
+ values.push(packWalktokens.call(this, token));
+ if (walkTokens) {
+ values = values.concat(walkTokens.call(this, token));
+ }
+ return values;
+ };
+ }
+ this.defaults = { ...this.defaults, ...opts };
+ });
+ return this;
+ }
+ setOptions(opt) {
+ this.defaults = { ...this.defaults, ...opt };
+ return this;
+ }
+ lexer(src, options) {
+ return _Lexer.lex(src, options ?? this.defaults);
+ }
+ parser(tokens, options) {
+ return _Parser.parse(tokens, options ?? this.defaults);
+ }
+ parseMarkdown(blockType) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const parse = (src, options) => {
+ const origOpt = { ...options };
+ const opt = { ...this.defaults, ...origOpt };
+ const throwError = this.onError(!!opt.silent, !!opt.async);
+ // throw error if an extension set async to true but parse was called with async: false
+ if (this.defaults.async === true && origOpt.async === false) {
+ return throwError(new Error('marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise.'));
+ }
+ // throw error in case of non string input
+ if (typeof src === 'undefined' || src === null) {
+ return throwError(new Error('marked(): input parameter is undefined or null'));
+ }
+ if (typeof src !== 'string') {
+ return throwError(new Error('marked(): input parameter is of type '
+ + Object.prototype.toString.call(src) + ', string expected'));
+ }
+ if (opt.hooks) {
+ opt.hooks.options = opt;
+ opt.hooks.block = blockType;
+ }
+ const lexer = opt.hooks ? opt.hooks.provideLexer() : (blockType ? _Lexer.lex : _Lexer.lexInline);
+ const parser = opt.hooks ? opt.hooks.provideParser() : (blockType ? _Parser.parse : _Parser.parseInline);
+ if (opt.async) {
+ return Promise.resolve(opt.hooks ? opt.hooks.preprocess(src) : src)
+ .then(src => lexer(src, opt))
+ .then(tokens => opt.hooks ? opt.hooks.processAllTokens(tokens) : tokens)
+ .then(tokens => opt.walkTokens ? Promise.all(this.walkTokens(tokens, opt.walkTokens)).then(() => tokens) : tokens)
+ .then(tokens => parser(tokens, opt))
+ .then(html => opt.hooks ? opt.hooks.postprocess(html) : html)
+ .catch(throwError);
+ }
+ try {
+ if (opt.hooks) {
+ src = opt.hooks.preprocess(src);
+ }
+ let tokens = lexer(src, opt);
+ if (opt.hooks) {
+ tokens = opt.hooks.processAllTokens(tokens);
+ }
+ if (opt.walkTokens) {
+ this.walkTokens(tokens, opt.walkTokens);
+ }
+ let html = parser(tokens, opt);
+ if (opt.hooks) {
+ html = opt.hooks.postprocess(html);
+ }
+ return html;
+ }
+ catch (e) {
+ return throwError(e);
+ }
+ };
+ return parse;
+ }
+ onError(silent, async) {
+ return (e) => {
+ e.message += '\nPlease report this to https://github.com/markedjs/marked.';
+ if (silent) {
+ const msg = '
'
+ + escape$1(e.message + '', true)
+ + '
';
+ if (async) {
+ return Promise.resolve(msg);
+ }
+ return msg;
+ }
+ if (async) {
+ return Promise.reject(e);
+ }
+ throw e;
+ };
+ }
+}
+
+const markedInstance = new Marked();
+function marked(src, opt) {
+ return markedInstance.parse(src, opt);
+}
+/**
+ * Sets the default options.
+ *
+ * @param options Hash of options
+ */
+marked.options =
+ marked.setOptions = function (options) {
+ markedInstance.setOptions(options);
+ marked.defaults = markedInstance.defaults;
+ changeDefaults(marked.defaults);
+ return marked;
+ };
+/**
+ * Gets the original marked default options.
+ */
+marked.getDefaults = _getDefaults;
+marked.defaults = _defaults;
+/**
+ * Use Extension
+ */
+marked.use = function (...args) {
+ markedInstance.use(...args);
+ marked.defaults = markedInstance.defaults;
+ changeDefaults(marked.defaults);
+ return marked;
+};
+/**
+ * Run callback for every token
+ */
+marked.walkTokens = function (tokens, callback) {
+ return markedInstance.walkTokens(tokens, callback);
+};
+/**
+ * Compiles markdown to HTML without enclosing `p` tag.
+ *
+ * @param src String of markdown source to be compiled
+ * @param options Hash of options
+ * @return String of compiled HTML
+ */
+marked.parseInline = markedInstance.parseInline;
+/**
+ * Expose
+ */
+marked.Parser = _Parser;
+marked.parser = _Parser.parse;
+marked.Renderer = _Renderer;
+marked.TextRenderer = _TextRenderer;
+marked.Lexer = _Lexer;
+marked.lexer = _Lexer.lex;
+marked.Tokenizer = _Tokenizer;
+marked.Hooks = _Hooks;
+marked.parse = marked;
+const options = marked.options;
+const setOptions = marked.setOptions;
+const use = marked.use;
+const walkTokens = marked.walkTokens;
+const parseInline = marked.parseInline;
+const parse = marked;
+const parser = _Parser.parse;
+const lexer = _Lexer.lex;
+
+export { _Hooks as Hooks, _Lexer as Lexer, Marked, _Parser as Parser, _Renderer as Renderer, _TextRenderer as TextRenderer, _Tokenizer as Tokenizer, _defaults as defaults, _getDefaults as getDefaults, lexer, marked, options, parse, parseInline, parser, setOptions, use, walkTokens };
+//# sourceMappingURL=marked.esm.js.map
\ No newline at end of file
diff --git a/web/model-manager.js b/web/model-manager.js
index 6eb9e52..d771a3a 100644
--- a/web/model-manager.js
+++ b/web/model-manager.js
@@ -2,7 +2,8 @@ import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { ComfyDialog, $el } from "../../scripts/ui.js";
import { ComfyButton } from "../../scripts/ui/components/button.js";
-import { marked } from "https://cdn.jsdelivr.net/npm/marked/lib/marked.esm.js";
+import { marked } from "./marked.js";
+import("./downshow.js");
function clamp(x, min, max) {
return Math.min(Math.max(x, min), max);
@@ -2707,7 +2708,7 @@ class ModelInfo {
const notesElement = this.elements.tabContents[3]; // TODO: remove magic value
notesElement.innerHTML = "";
const markdown = $el("div", {}, "");
- markdown.innerHTML = marked.parse(noteText);
+ markdown.innerHTML = marked.parse(noteText);
notesElement.append.apply(notesElement,
(() => {
@@ -3175,31 +3176,7 @@ async function getModelInfos(urlText) {
version["description"],
civitaiInfo["description"] !== undefined ? "# " + name : undefined,
civitaiInfo["description"],
- ].filter(x => x !== undefined).join("\n\n")
- .replaceAll("
", "\n\n") - .replaceAll("", "**").replaceAll("", "**") - .replaceAll("
", "`").replaceAll("", "`")
- .replaceAll("", "\n") - .replaceAll("
", "\n\n---\n\n") - .replaceAll("", "\n") - .replaceAll("
", "\n") - .replaceAll("
", "\n") - .replaceAll("
", "\n") - .replaceAll("
", "\n") - .replaceAll("
", "\n") - .replace(/href="(\S*)">/g, 'href=""> $1 ') - .replace(/src="(\S*)">/g, 'src=""> $1
') - // - // - .replace(/<[^>]+>/g, "") // quick hack - .replaceAll("<", "<").replaceAll(">", ">") - .replaceAll("<e;", "<=").replaceAll(">e;", ">=") - .replaceAll("&", "&"); + ].filter(x => x !== undefined).join("\n\n"); version["files"].forEach((file) => { infos.push({ "images": images, @@ -3207,7 +3184,7 @@ async function getModelInfos(urlText) { "modelType": type, "downloadUrl": file["downloadUrl"], "downloadFilePath": "", - "description": description, + "description": downshow(description), "details": { "fileSizeKB": file["sizeKB"], "fileType": file["type"], From ab7c62e929c29ceefe140f5886d7b2a36fc3150e Mon Sep 17 00:00:00 2001 From: korutech-ai
Date: Wed, 28 Aug 2024 07:48:30 +1200 Subject: [PATCH 04/11] DEVCONFIG: Adding macOS ignore settings. --- .gitignore | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/.gitignore b/.gitignore index 32b09a4..542369d 100644 --- a/.gitignore +++ b/.gitignore @@ -160,3 +160,31 @@ cython_debug/ #.idea/ ui_settings.yaml server_settings.yaml + +# macOS: +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk From 1bedfaa7a25162aa01cbcbfb84d6a42bf35378ec Mon Sep 17 00:00:00 2001 From: korutech-ai Date: Wed, 28 Aug 2024 08:14:39 +1200 Subject: [PATCH 05/11] LINT: No code change, just whitespace cleanup. --- web/model-manager.js | 550 +++++++++++++++++++++---------------------- 1 file changed, 275 insertions(+), 275 deletions(-) diff --git a/web/model-manager.js b/web/model-manager.js index d771a3a..498b098 100644 --- a/web/model-manager.js +++ b/web/model-manager.js @@ -41,16 +41,16 @@ function debounce(callback, delay) { class KeyComboListener { /** @type {string[]} */ #keyCodes = []; - + /** @type {() => Promise } */ action; - + /** @type {Element} */ element; - + /** @type {string[]} */ #combo = []; - + /** * @param {string[]} keyCodes * @param {() => Promise } action @@ -60,7 +60,7 @@ class KeyComboListener { this.#keyCodes = keyCodes; this.action = action; this.element = element; - + document.addEventListener("keydown", (e) => { const code = e.code; const keyCodes = this.#keyCodes; @@ -161,21 +161,21 @@ const modelNodeType = { const MODEL_EXTENSIONS = [".bin", ".ckpt", "gguf", ".onnx", ".pt", ".pth", ".safetensors"]; // TODO: ask server for? const IMAGE_EXTENSIONS = [ - ".png", - ".webp", - ".jpeg", - ".jpg", - ".jfif", - ".gif", - ".apng", + ".png", + ".webp", + ".jpeg", + ".jpg", + ".jfif", + ".gif", + ".apng", - ".preview.png", - ".preview.webp", - ".preview.jpeg", - ".preview.jpg", - ".preview.jfif", - ".preview.gif", - ".preview.apng", + ".preview.png", + ".preview.webp", + ".preview.jpeg", + ".preview.jpg", + ".preview.jfif", + ".preview.gif", + ".preview.apng", ]; // TODO: /model-manager/image/extensions /** @@ -267,7 +267,7 @@ const PREVIEW_THUMBNAIL_WIDTH = 320; const PREVIEW_THUMBNAIL_HEIGHT = 480; /** - * + * * @param {HTMLButtonElement} element * @returns {[HTMLButtonElement | undefined, HTMLElement | undefined, HTMLSpanElement | undefined]} [button, icon, span] */ @@ -304,18 +304,18 @@ function comfyButtonDisambiguate(element) { */ function comfyButtonAlert(element, success, successClassName = undefined, failureClassName = undefined, disableCallback = false) { if (element === undefined || element === null) { return; } - + const [button, icon, span] = comfyButtonDisambiguate(element); if (button === undefined) { console.warn("Unable to find button element!"); console.warn(element); return; } - + // TODO: debounce would be nice, but needs some sort of "global" to avoid creating/destroying many objects - + const colorClassName = success ? "comfy-button-success" : "comfy-button-failure"; - + if (icon) { const iconClassName = (success ? successClassName : failureClassName) ?? ""; if (iconClassName !== "") { @@ -331,7 +331,7 @@ function comfyButtonAlert(element, success, successClassName = undefined, failur }, 1000, icon, iconClassName, colorClassName); } } - + button.classList.add(colorClassName); if (!disableCallback) { window.setTimeout((element, colorClassName) => { @@ -341,7 +341,7 @@ function comfyButtonAlert(element, success, successClassName = undefined, failur } /** - * + * * @param {string} modelPath * @param {string} newValue * @returns {Promise } @@ -424,11 +424,11 @@ function $select(x = { $: (el) => {}, textContent: "", options: [""] }) { */ function $radioGroup(attr) { const { name = Date.now(), onchange, options = [], $ } = attr; - + /** @type {HTMLDivElement[]} */ const radioGroup = options.map((item, index) => { const inputRef = { value: null }; - + return $el( "div.comfy-radio", { onclick: () => inputRef.value.click() }, @@ -444,7 +444,7 @@ function $radioGroup(attr) { ] ); }); - + const element = $el("input", { name: name + "-group", value: options[0]?.value, @@ -458,7 +458,7 @@ function $radioGroup(attr) { onchange?.(selectedValue); }); }); - + return $el("div.comfy-radio-group", radioGroup); } @@ -516,7 +516,7 @@ function GenerateTabGroup(tabData) { tabButtons.push(tab); tabContents.push(content); }); - + return [tabButtons, tabContents]; } @@ -578,28 +578,28 @@ class ImageSelect { /** @constant {string} */ #PREVIEW_UPLOAD = "Upload"; /** @constant {string} */ #PREVIEW_URL = "URL"; /** @constant {string} */ #PREVIEW_NONE = "No Preview"; - + elements = { /** @type {HTMLDivElement} */ radioGroup: null, /** @type {HTMLDivElement} */ radioButtons: null, /** @type {HTMLDivElement} */ previews: null, - + /** @type {HTMLImageElement} */ defaultPreviewNoImage: null, /** @type {HTMLDivElement} */ defaultPreviews: null, /** @type {HTMLDivElement} */ defaultUrl: null, - + /** @type {HTMLImageElement} */ customUrlPreview: null, /** @type {HTMLInputElement} */ customUrl: null, /** @type {HTMLDivElement} */ custom: null, - + /** @type {HTMLImageElement} */ uploadPreview: null, /** @type {HTMLInputElement} */ uploadFile: null, /** @type {HTMLDivElement} */ upload: null, }; - + /** @type {string} */ #name = null; - + /** @returns {Promise | Promise } */ async getImage() { const name = this.#name; @@ -651,7 +651,7 @@ class ImageSelect { } return ""; } - + /** @returns {void} */ resetModelInfoPreview() { let noimage = this.elements.defaultUrl.dataset.noimage; @@ -680,7 +680,7 @@ class ImageSelect { this.elements.upload.style.display = "none"; this.elements.custom.style.display = "none"; } - + /** @returns {boolean} */ defaultIsChecked() { const children = this.elements.radioButtons.children; @@ -693,7 +693,7 @@ class ImageSelect { }; return false; } - + /** @returns {void} */ checkDefault() { const children = this.elements.radioButtons.children; @@ -707,9 +707,9 @@ class ImageSelect { } }; } - + /** - * @param {1 | -1} step + * @param {1 | -1} step */ stepDefaultPreviews(step) { const children = this.elements.defaultPreviews.children; @@ -730,7 +730,7 @@ class ImageSelect { else if (currentIndex < 0) { currentIndex = children.length - 1; } children[currentIndex].style.display = "block"; } - + /** * @param {string} radioGroupName - Should be unique for every radio group. * @param {string[]|undefined} defaultPreviews @@ -740,20 +740,20 @@ class ImageSelect { defaultPreviews = [PREVIEW_NONE_URI]; } this.#name = radioGroupName; - + const el_defaultUri = $el("div", { $: (el) => (this.elements.defaultUrl = el), style: { display: "none" }, "data-noimage": PREVIEW_NONE_URI, }); - + const el_defaultPreviewNoImage = $el("img", { $: (el) => (this.elements.defaultPreviewNoImage = el), loading: "lazy", /* `loading` BEFORE `src`; Known bug in Firefox 124.0.2 and Safari for iOS 17.4.1 (https://stackoverflow.com/a/76252772) */ src: PREVIEW_NONE_URI, style: { display: "none" }, }); - + const el_defaultPreviews = $el("div", { $: (el) => (this.elements.defaultPreviews = el), style: { @@ -776,7 +776,7 @@ class ImageSelect { } return imgs; })()); - + const el_uploadPreview = $el("img", { $: (el) => (this.elements.uploadPreview = el), src: PREVIEW_NONE_URI, @@ -806,9 +806,9 @@ class ImageSelect { }, [ el_uploadFile, ]); - + /** - * @param {string} url + * @param {string} url * @returns {Promise } */ const getCustomPreviewUrl = async (url) => { @@ -833,7 +833,7 @@ class ImageSelect { return url; } }; - + const el_customUrlPreview = $el("img", { $: (el) => (this.elements.customUrlPreview = el), src: PREVIEW_NONE_URI, @@ -877,7 +877,7 @@ class ImageSelect { }, }).element, ]); - + const el_previewButtons = $el("div.model-preview-overlay", { style: { display: el_defaultPreviews.children.length > 1 ? "block" : "none", @@ -914,20 +914,20 @@ class ImageSelect { ), el_previewButtons, ]); - + const el_radioButtons = $radioGroup({ name: radioGroupName, onchange: (value) => { el_custom.style.display = "none"; el_upload.style.display = "none"; - + el_defaultPreviews.style.display = "none"; el_previewButtons.style.display = "none"; - + el_defaultPreviewNoImage.style.display = "none"; el_uploadPreview.style.display = "none"; el_customUrlPreview.style.display = "none"; - + switch (value) { case this.#PREVIEW_DEFAULT: el_defaultPreviews.style.display = "block"; @@ -957,7 +957,7 @@ class ImageSelect { }), }); this.elements.radioButtons = el_radioButtons; - + const children = el_radioButtons.children; for (let i = 0; i < children.length; i++) { const child = children[i]; @@ -967,7 +967,7 @@ class ImageSelect { break; } }; - + const el_radioGroup = $el("div.model-preview-select-radio-container", { $: (el) => (this.elements.radioGroup = el), }, [ @@ -981,7 +981,7 @@ class ImageSelect { } /** - * @typedef {Object} DirectoryItem + * @typedef {Object} DirectoryItem * @property {String} name * @property {number | undefined} childCount * @property {number | undefined} childIndex @@ -1084,7 +1084,7 @@ class ModelDirectories { } return index + start; } - + /** * Returns a list of matching search results and valid path. * @param {string} filter @@ -1167,16 +1167,16 @@ const DROPDOWN_DIRECTORY_SELECTION_MOUSE_CLASS = "search-directory-dropdown-mous class ModelData { /** @type {string} */ searchSeparator = "/"; // TODO: other client or server code may be assuming this to always be "/" - + /** @type {string} */ systemSeparator = null; - + /** @type {Object} */ models = {}; - + /** @type {ModelDirectories} */ directories = null; - + constructor() { this.directories = new ModelDirectories(); } @@ -1185,34 +1185,34 @@ class ModelData { class DirectoryDropdown { /** @type {HTMLDivElement} */ element = null; - + /** @type {Boolean} */ showDirectoriesOnly = false; - + /** @type {HTMLInputElement} */ #input = null; - + /** @type {() => string} */ #getModelType = null; - + /** @type {ModelData} */ #modelData = null; // READ ONLY - + /** @type {() => void} */ #updateCallback = null; - + /** @type {() => Promise } */ #submitCallback = null; - + /** @type {string} */ #deepestPreviousPath = "/"; - + /** @type {Any} */ #touchSelectionStart = null; - + /** @type {() => Boolean} */ #isDynamicSearch = () => { return false; }; - + /** * @param {ModelData} modelData * @param {HTMLInputElement} input @@ -1237,7 +1237,7 @@ class DirectoryDropdown { this.#submitCallback = submitCallback; this.showDirectoriesOnly = showDirectoriesOnly; this.#isDynamicSearch = isDynamicSearch; - + input.addEventListener("input", async(e) => { const path = this.#updateOptions(); if (path !== undefined) { @@ -1401,7 +1401,7 @@ class DirectoryDropdown { }, ); } - + /** * @param {HTMLInputElement} input * @param {HTMLParagraphElement | undefined | null} selection @@ -1457,7 +1457,7 @@ class DirectoryDropdown { if (i1 !== -1) { name = name.substring(0, i1); } - + const dropdown = this.element; const options = dropdown.children; let iSelection; @@ -1615,7 +1615,7 @@ class ModelGrid { static modelWidgetIndex(nodeType) { return nodeType === undefined ? -1 : 0; } - + /** * @param {string} text * @param {string} file @@ -1630,7 +1630,7 @@ class ModelGrid { const sep = text.length === 0 || text.slice(-1).match(/\s/) ? "" : " "; return text + sep + "(embedding:" + name + ":1.0)"; } - + /** * @param {Array} list * @param {string} searchString @@ -1666,7 +1666,7 @@ class ModelGrid { }, true); }); } - + /** * In-place sort. Returns an array alias. * @param {Array} list @@ -1696,7 +1696,7 @@ class ModelGrid { const sorted = list.sort(compareFn); return reverse ? sorted.reverse() : sorted; } - + /** * @param {Event} event * @param {string} modelType @@ -1775,7 +1775,7 @@ class ModelGrid { if (modelType !== "embeddings" && target.id === "graph-canvas") { //const pos = app.canvas.convertEventToCanvasOffset(event); const pos = app.canvas.convertEventToCanvasOffset({ clientX: clientX, clientY: clientY }); - + const node = app.graph.getNodeOnPos(pos[0], pos[1], app.canvas.visible_nodes); let widgetIndex = -1; @@ -1835,7 +1835,7 @@ class ModelGrid { } } } - + /** * @param {Event} event * @param {string} modelType @@ -1870,7 +1870,7 @@ class ModelGrid { } comfyButtonAlert(event.target, success, "mdi-check-bold", "mdi-close-thick"); } - + /** * @param {Array} models * @param {string} modelType @@ -2001,7 +2001,7 @@ class ModelGrid { return [$el("h2", ["No Models"])]; } } - + /** * @param {HTMLDivElement} modelGrid * @param {ModelData} modelData @@ -2067,7 +2067,7 @@ class ModelGrid { class ModelInfo { /** @type {HTMLDivElement} */ element = null; - + elements = { /** @type {HTMLDivElement[]} */ tabButtons: null, /** @type {HTMLDivElement[]} */ tabContents: null, @@ -2076,16 +2076,16 @@ class ModelInfo { /** @type {HTMLButtonElement} */ setPreviewButton: null, /** @type {HTMLInputElement} */ moveDestinationInput: null, }; - + /** @type {ImageSelect} */ previewSelect = null; - + /** @type {string} */ #savedNotesValue = null; - + /** @type {[HTMLElement][]} */ #settingsElements = null; - + /** * @param {ModelData} modelData * @param {() => Promise } updateModels @@ -2100,17 +2100,17 @@ class ModelInfo { value: modelData.searchSeparator, }); this.elements.moveDestinationInput = moveDestinationInput; - + const searchDropdown = new DirectoryDropdown( modelData, moveDestinationInput, true, ); - + const previewSelect = new ImageSelect("model-info-preview-model-FYUIKMNVB"); this.previewSelect = previewSelect; previewSelect.elements.previews.style.display = "flex"; - + const setPreviewButton = new ComfyButton({ tooltip: "Overwrite currrent preview with selected image", content: "Set as Preview", @@ -2182,7 +2182,7 @@ class ModelInfo { previewSelect.elements.radioButtons.addEventListener("change", (e) => { setPreviewButton.style.display = previewSelect.defaultIsChecked() ? "none" : "block"; }); - + this.element = $el("div", { style: { display: "none" }, }, [ @@ -2294,7 +2294,7 @@ class ModelInfo { "data-path": "", }), ]); - + [this.elements.tabButtons, this.elements.tabContents] = GenerateTabGroup([ { name: "Overview", icon: "information-box-outline", tabContent: this.element }, { name: "Metadata", icon: "file-document-outline", tabContent: $el("div", ["Metadata"]) }, @@ -2302,13 +2302,13 @@ class ModelInfo { { name: "Notes", icon: "pencil-outline", tabContent: $el("div", ["Notes"]) }, ]); } - + /** @returns {void} */ show() { this.element.style = ""; this.element.scrollTop = 0; } - + /** * @param {boolean} promptUser * @returns {Promise } @@ -2317,7 +2317,7 @@ class ModelInfo { if (this.element.style.display === "none") { return true; } - + const noteValue = this.elements.notes.value; const savedNotesValue = this.#savedNotesValue; if (noteValue.trim() === savedNotesValue.trim()) { @@ -2345,7 +2345,7 @@ class ModelInfo { } return true; } - + /** * @param {boolean?} promptSave * @returns {Promise } @@ -2363,7 +2363,7 @@ class ModelInfo { this.element.style.display = "none"; return true; } - + /** * @param {string} searchPath * @param {() => Promise } updateModels @@ -2469,7 +2469,7 @@ class ModelInfo { ]), ); } - + const fileDirectory = info["File Directory"]; if (fileDirectory !== undefined && fileDirectory !== null && fileDirectory !== "") { this.elements.moveDestinationInput.placeholder = fileDirectory @@ -2479,7 +2479,7 @@ class ModelInfo { this.elements.moveDestinationInput.placeholder = searchSeparator; this.elements.moveDestinationInput.value = searchSeparator; } - + const previewSelect = this.previewSelect; const defaultUrl = previewSelect.elements.defaultUrl; if (info["Preview"]) { @@ -2493,7 +2493,7 @@ class ModelInfo { previewSelect.resetModelInfoPreview(); const setPreviewButton = this.elements.setPreviewButton; setPreviewButton.style.display = previewSelect.defaultIsChecked() ? "none" : "block"; - + innerHtml.push($el("div", [ previewSelect.elements.previews, $el("div.row.tab-header", [ @@ -2522,7 +2522,7 @@ class ModelInfo { if (value === undefined || value === null) { continue; } - + if (Array.isArray(value)) { // currently only used for "Bucket Resolutions" if (value.length > 0) { @@ -2556,7 +2556,7 @@ class ModelInfo { ])); infoHtml.append.apply(infoHtml, innerHtml); // TODO: set default value of dropdown and value to model type? - + /** @type {HTMLDivElement} */ const metadataElement = this.elements.tabContents[1]; // TODO: remove magic value const isMetadata = typeof metadata === 'object' && metadata !== null && Object.keys(metadata).length > 0; @@ -2584,7 +2584,7 @@ class ModelInfo { ]); const metadataButton = this.elements.tabButtons[1]; // TODO: remove magic value metadataButton.style.display = isMetadata ? "" : "none"; - + /** @type {HTMLDivElement} */ const tagsElement = this.elements.tabContents[2]; // TODO: remove magic value const isTags = Array.isArray(tags) && tags.length > 0; @@ -2676,10 +2676,10 @@ class ModelInfo { ]); const tagButton = this.elements.tabButtons[2]; // TODO: remove magic value tagButton.style.display = isTags ? "" : "none"; - + const saveIcon = "content-save"; const savingIcon = "cloud-upload-outline"; - + const saveNotesButton = new ComfyButton({ icon: saveIcon, tooltip: "Save note", @@ -2692,7 +2692,7 @@ class ModelInfo { button.disabled = false; }, }).element; - + const saveDebounce = debounce(async() => { const saveIconClass = "mdi-" + saveIcon; const savingIconClass = "mdi-" + savingIcon; @@ -2703,7 +2703,7 @@ class ModelInfo { iconElement.classList.remove(savingIconClass); iconElement.classList.add(saveIconClass); }, 1000); - + /** @type {HTMLDivElement} */ const notesElement = this.elements.tabContents[3]; // TODO: remove magic value notesElement.innerHTML = ""; @@ -2721,7 +2721,7 @@ class ModelInfo { } }, }); - + if (navigator.userAgent.includes("Mac")) { new KeyComboListener( ["MetaLeft", "KeyS"], @@ -2746,7 +2746,7 @@ class ModelInfo { notes, ); } - + this.elements.notes = notes; this.elements.markdown = markdown; this.#savedNotesValue = noteText; @@ -2767,7 +2767,7 @@ class ModelInfo { })() ); } - + static UniformTagSampling(tagsAndCounts, sampleCount, frequencyThreshold = 0) { const data = tagsAndCounts.filter(x => x[1] >= frequencyThreshold); let count = data.length; @@ -2782,7 +2782,7 @@ class ModelInfo { const sortedSamples = samples.sort((x1, x2) => { return parseInt(x2[1]) - parseInt(x1[1]) }); return sortedSamples.map(x => x[0]); } - + static ProbabilisticTagSampling(tagsAndCounts, sampleCount, frequencyThreshold = 0) { const data = tagsAndCounts.filter(x => x[1] >= frequencyThreshold); let tagFrequenciesSum = data.reduce((accumulator, x) => accumulator + x[1], 0); @@ -2832,7 +2832,7 @@ class Civitai { return {}; } } - + /** * Extract file information from the given model version information. * @@ -2849,21 +2849,21 @@ class Civitai { const modelVersionFiles = modelVersionInfo["files"]; for (let i = 0; i < modelVersionFiles.length; i++) { const modelVersionFile = modelVersionFiles[i]; - + const fileType = modelVersionFile["type"]; if (type instanceof String && type != fileType) { continue; } - + const fileMeta = modelVersionFile["metadata"]; - + const fileFp = fileMeta["fp"]; if (fp instanceof String && fp != fileFp) { continue; } - + const fileSize = fileMeta["size"]; if (size instanceof String && size != fileSize) { continue; } - + const fileFormat = fileMeta["format"]; if (format instanceof String && format != fileFormat) { continue; } - + files.push({ "downloadUrl": modelVersionFile["downloadUrl"], "format": fileFormat, @@ -2887,7 +2887,7 @@ class Civitai { "tags": modelVersionInfo["trainedWords"], }; } - + /** * @param {string} stringUrl - Model url. * @@ -2961,21 +2961,21 @@ class Civitai { return {}; } } - + /** * @returns {string} */ static imagePostUrlPrefix() { return "https://civitai.com/images/"; } - + /** * @returns {string} */ static imageUrlPrefix() { return "https://image.civitai.com/"; } - + /** * @param {string} stringUrl - https://civitai.com/images/{imageId}. * @@ -2998,7 +2998,7 @@ class Civitai { return {}; } } - + /** * @param {string} stringUrl - https://image.civitai.com/... * @@ -3054,9 +3054,9 @@ class HuggingFace { return {}; } } - + /** - * + * * * @param {string} stringUrl - Model url. * @@ -3080,13 +3080,13 @@ class HuggingFace { } const modelId = urlPath.substring(i0, i2); const urlPathEnd = urlPath.substring(i2); - + const isValidBranch = ( urlPathEnd.startsWith("/resolve") || urlPathEnd.startsWith("/blob") || urlPathEnd.startsWith("/tree") ); - + let branch = "/main"; let filePath = ""; if (isValidBranch) { @@ -3105,11 +3105,11 @@ class HuggingFace { } } } - + const modelInfo = await HuggingFace.requestInfo(modelId); //const modelInfo = await requestInfo(modelId + "/tree" + branch); // this only gives you the files at the given branch path... // oid: SHA-1?, lfs.oid: SHA-256 - + const clippedFilePath = filePath.substring(filePath[0] === "/" ? 1 : 0); const modelFiles = modelInfo["siblings"].filter((sib) => { const filename = sib["rfilename"]; @@ -3126,9 +3126,9 @@ class HuggingFace { if (modelFiles.length === 0) { return {}; } - + const baseDownloadUrl = url.origin + urlPath.substring(0, i2) + "/resolve" + branch; - + const images = modelInfo["siblings"].filter((sib) => { const filename = sib["rfilename"]; for (let i = 0; i < IMAGE_EXTENSIONS.length; i++) { @@ -3140,7 +3140,7 @@ class HuggingFace { }).map((sib) => { return baseDownloadUrl + "/" + sib["rfilename"]; }); - + return { "baseDownloadUrl": baseDownloadUrl, "modelFiles": modelFiles, @@ -3257,7 +3257,7 @@ async function getModelInfos(urlText) { class DownloadView { /** @type {HTMLDivElement} */ element = null; - + elements = { /** @type {HTMLInputElement} */ url: null, /** @type {HTMLDivElement} */ infos: null, @@ -3266,16 +3266,16 @@ class DownloadView { /** @type {HTMLButtonElement} */ searchButton: null, /** @type {HTMLButtonElement} */ clearSearchButton: null, }; - + /** @type {DOMParser} */ #domParser = null; - + /** @type {Object. } */ #settings = null; - + /** @type {() => Promise } */ #updateModels = () => {}; - + /** * @param {ModelData} modelData * @param {Object. } settings @@ -3291,7 +3291,7 @@ class DownloadView { $el("h1", ["Input a URL to select a model to download."]) ); }; - + const searchButton = new ComfyButton({ icon: "magnify", tooltip: "Search url", @@ -3317,7 +3317,7 @@ class DownloadView { searchButton.style.display = hideSearchButton ? "none" : ""; }); this.elements.searchButton = searchButton; - + const clearSearchButton = new ComfyButton({ icon: "close", tooltip: "Clear search", @@ -3333,7 +3333,7 @@ class DownloadView { clearSearchButton.style.display = hideClearButton ? "none" : ""; }); this.elements.clearSearchButton = clearSearchButton; - + $el("div.tab-header", { $: (el) => (this.element = el), }, [ @@ -3367,7 +3367,7 @@ class DownloadView { ]), ]); } - + /** * Tries to return the related ComfyUI model directory if unambiguous. * @@ -3381,7 +3381,7 @@ class DownloadView { const f = fileType.toLowerCase(); if (f == "diffusers") { return "diffusers"; } // TODO: is this correct? } - + if (modelType !== undefined && modelType !== null) { const m = modelType.toLowerCase(); // TODO: somehow allow for SERVER to set dir? @@ -3408,10 +3408,10 @@ class DownloadView { } return null; } - + /** * Returns empty string on failure - * @param {float | undefined} fileSizeKB + * @param {float | undefined} fileSizeKB * @returns {string} */ static #fileSizeToFormattedString(fileSizeKB) { @@ -3430,7 +3430,7 @@ class DownloadView { fileSizeString = fileSizeString.substring(0, fileSizeString.indexOf(".") + 3); return `(${fileSizeString} ${sizes[sizeIndex]})`; } - + /** * @param {Object} info * @param {ModelData} modelData @@ -3443,7 +3443,7 @@ class DownloadView { "model-download-info-preview-model" + "-" + id, info["images"], ); - + const comfyUIModelType = ( DownloadView.modelTypeToComfyUiDirectory(info["details"]["fileType"]) ?? DownloadView.modelTypeToComfyUiDirectory(info["modelType"]) ?? @@ -3451,7 +3451,7 @@ class DownloadView { ); const searchSeparator = modelData.searchSeparator; const defaultBasePath = searchSeparator + (comfyUIModelType === "" ? "" : comfyUIModelType + searchSeparator + "0"); - + const el_saveDirectoryPath = $el("input.search-text-area", { type: "text", name: "save directory", @@ -3464,7 +3464,7 @@ class DownloadView { el_saveDirectoryPath, true, ); - + const default_name = (() => { const filename = info["fileName"]; // TODO: only remove valid model file extensions @@ -3484,7 +3484,7 @@ class DownloadView { } }, }); - + const infoNotes = $el("textarea.comfy-multiline-input.model-info-notes", { name: "model info notes", value: info["description"]??"", @@ -3492,7 +3492,7 @@ class DownloadView { disabled: true, style: { display: info["description"] === undefined || info["description"] === "" ? "none" : "" }, }); - + const filepath = info["downloadFilePath"]; const modelInfo = $el("details.download-details", [ $el("summary", [filepath + info["fileName"]]), @@ -3571,7 +3571,7 @@ class DownloadView { ]), ]), ]); - + return modelInfo; } @@ -3601,7 +3601,7 @@ class DownloadView { if (modelInfosHtml.length === 1) { modelInfosHtml[0].open = true; } - + const header = $el("div", [ $el("h1", [name]), $el("div.model-manager-settings", [ @@ -3623,7 +3623,7 @@ class DownloadView { const infosHtml = this.elements.infos; infosHtml.innerHTML = ""; infosHtml.append.apply(infosHtml, modelInfosHtml); - + const downloadNotes = this.elements.downloadNotes; if (downloadNotes !== undefined && downloadNotes !== null) { downloadNotes.addEventListener("change", (e) => { @@ -3636,10 +3636,10 @@ class DownloadView { downloadNotes.checked = settings["download-save-description-as-text-file"].checked; downloadNotes.dispatchEvent(new Event('change')); } - + const hideSearchButtons = settings["text-input-always-hide-search-button"].checked; this.elements.searchButton.style.display = hideSearchButtons ? "none" : ""; - + const hideClearSearchButtons = settings["text-input-always-hide-clear-button"].checked; this.elements.clearSearchButton.style.display = hideClearSearchButtons ? "none" : ""; } @@ -3648,7 +3648,7 @@ class DownloadView { class BrowseView { /** @type {HTMLDivElement} */ element = null; - + elements = { /** @type {HTMLDivElement} */ modelGrid: null, /** @type {HTMLSelectElement} */ modelTypeSelect: null, @@ -3657,28 +3657,28 @@ class BrowseView { /** @type {HTMLButtonElement} */ searchButton: null, /** @type {HTMLButtonElement} */ clearSearchButton: null, }; - + /** @type {Array} */ previousModelFilters = []; - + /** @type {Object.<{value: string}>} */ previousModelType = { value: null }; - + /** @type {DirectoryDropdown} */ directoryDropdown = null; - + /** @type {ModelData} */ #modelData = null; - + /** @type {@param {() => Promise }} */ #updateModels = null; - + /** */ #settingsElements = null; - + /** @type {() => void} */ updateModelGrid = () => {}; - + /** * @param {() => Promise } updateModels * @param {ModelData} modelData @@ -3690,11 +3690,11 @@ class BrowseView { /** @type {HTMLDivElement} */ const modelGrid = $el("div.comfy-grid"); this.elements.modelGrid = modelGrid; - + this.#updateModels = updateModels; this.#modelData = modelData; this.#settingsElements = settingsElements; - + const searchInput = $el("input.search-text-area", { $: (el) => (this.elements.modelContentFilter = el), type: "text", @@ -3702,13 +3702,13 @@ class BrowseView { autocomplete: "off", placeholder: "/Search", }); - + const updatePreviousModelFilter = () => { const modelType = this.elements.modelTypeSelect.value; const value = this.elements.modelContentFilter.value; this.previousModelFilters[modelType] = value; }; - + const updateModelGrid = () => { const sortValue = this.elements.modelSortSelect.value; const reverseSort = sortValue[0] === "-"; @@ -3726,18 +3726,18 @@ class BrowseView { showModelInfo, ); updateModelGridCallback(); - + const hideSearchButtons = ( this.#settingsElements["model-real-time-search"].checked | this.#settingsElements["text-input-always-hide-search-button"].checked ); this.elements.searchButton.style.display = hideSearchButtons ? "none" : ""; - + const hideClearSearchButtons = this.#settingsElements["text-input-always-hide-clear-button"].checked; this.elements.clearSearchButton.style.display = hideClearSearchButtons ? "none" : ""; } this.updateModelGrid = updateModelGrid; - + const searchDropdown = new DirectoryDropdown( modelData, searchInput, @@ -3748,7 +3748,7 @@ class BrowseView { () => { return this.#settingsElements["model-real-time-search"].checked; }, ); this.directoryDropdown = searchDropdown; - + const searchButton = new ComfyButton({ icon: "magnify", tooltip: "Search models", @@ -3776,7 +3776,7 @@ class BrowseView { searchButton.style.display = hideSearchButton ? "none" : ""; }); this.elements.searchButton = searchButton; - + const clearSearchButton = new ComfyButton({ icon: "close", tooltip: "Clear search", @@ -3795,7 +3795,7 @@ class BrowseView { clearSearchButton.style.display = hideClearSearchButton ? "none" : ""; }); this.elements.clearSearchButton = clearSearchButton; - + this.element = $el("div", [ $el("div.row.tab-header", [ $el("div.row.tab-header-flex-block", [ @@ -3860,7 +3860,7 @@ class BrowseView { class SettingsView { /** @type {HTMLDivElement} */ element = null; - + elements = { /** @type {HTMLButtonElement} */ reloadButton: null, /** @type {HTMLButtonElement} */ saveButton: null, @@ -3870,7 +3870,7 @@ class SettingsView { /** @type {HTMLInputElement} */ "model-default-browser-model-type": null, /** @type {HTMLInputElement} */ "model-real-time-search": null, /** @type {HTMLInputElement} */ "model-persistent-search": null, - + /** @type {HTMLInputElement} */ "model-preview-thumbnail-type": null, /** @type {HTMLInputElement} */ "model-preview-fallback-search-safetensors-thumbnail": null, /** @type {HTMLInputElement} */ "model-show-label-extensions": null, @@ -3878,33 +3878,33 @@ class SettingsView { /** @type {HTMLInputElement} */ "model-show-copy-button": null, /** @type {HTMLInputElement} */ "model-show-load-workflow-button": null, /** @type {HTMLInputElement} */ "model-info-button-on-left": null, - + /** @type {HTMLInputElement} */ "model-add-embedding-extension": null, /** @type {HTMLInputElement} */ "model-add-drag-strict-on-field": null, /** @type {HTMLInputElement} */ "model-add-offset": null, - + /** @type {HTMLInputElement} */ "model-info-autosave-notes": null, - + /** @type {HTMLInputElement} */ "download-save-description-as-text-file": null, - + /** @type {HTMLInputElement} */ "sidebar-default-width": null, /** @type {HTMLInputElement} */ "sidebar-default-height": null, /** @type {HTMLInputElement} */ "sidebar-control-always-compact": null, /** @type {HTMLInputElement} */ "text-input-always-hide-search-button": null, /** @type {HTMLInputElement} */ "text-input-always-hide-clear-button": null, - + /** @type {HTMLInputElement} */ "tag-generator-sampler-method": null, /** @type {HTMLInputElement} */ "tag-generator-count": null, /** @type {HTMLInputElement} */ "tag-generator-threshold": null, }, }; - + /** @return {() => Promise } */ #updateModels = () => {}; - + /** - * @param {Object} settingsData - * @param {boolean} updateModels + * @param {Object} settingsData + * @param {boolean} updateModels */ async #setSettings(settingsData, updateModels) { const settings = this.elements.settings; @@ -3928,7 +3928,7 @@ class SettingsView { await this.#updateModels(); // Is this slow? } } - + /** * @param {boolean} updateModels * @returns {Promise } @@ -3939,7 +3939,7 @@ class SettingsView { await this.#setSettings(settingsData, updateModels); comfyButtonAlert(this.elements.reloadButton, true); } - + /** @returns {Promise } */ async save() { let settingsData = {}; @@ -3957,7 +3957,7 @@ class SettingsView { } settingsData[setting] = value; } - + const data = await comfyRequest( "/model-manager/settings/save", { @@ -3974,7 +3974,7 @@ class SettingsView { } comfyButtonAlert(this.elements.saveButton, success); } - + /** * @param {() => Promise } updateModels * @param {() => void} updateSidebarButtons @@ -3982,7 +3982,7 @@ class SettingsView { constructor(updateModels, updateSidebarButtons) { this.#updateModels = updateModels; const settings = this.elements.settings; - + const sidebarControl = $checkbox({ $: (el) => (settings["sidebar-control-always-compact"] = el), textContent: "Sidebar controls always compact", @@ -3990,7 +3990,7 @@ class SettingsView { sidebarControl.getElementsByTagName('input')[0].addEventListener("change", () => { updateSidebarButtons(); }); - + const reloadButton = new ComfyButton({ content: "Reload", tooltip: "Reload settings and model manager files", @@ -4002,7 +4002,7 @@ class SettingsView { }, }).element; this.elements.reloadButton = reloadButton; - + const saveButton = new ComfyButton({ content: "Save", tooltip: "Save settings and reload model manager", @@ -4014,7 +4014,7 @@ class SettingsView { }, }).element; this.elements.saveButton = saveButton; - + const correctPreviewsButton = new ComfyButton({ content: "Fix Extensions", tooltip: "Correct image file extensions in all model directories", @@ -4040,7 +4040,7 @@ class SettingsView { button.disabled = false; }, }).element; - + $el("div.model-manager-settings", { $: (el) => (this.element = el), }, [ @@ -4289,7 +4289,7 @@ function GenerateSidebarToggleRadioAndSelect(labels, activationCallbacks = []) { const RADIO_BUTTON_GROUP_ACTIVE = "radio-button-group-active"; const radioButtonGroup = $el("div.radio-button-group", []); const buttons = []; - + const select = $el("select", { name: "sidebar-select", onchange: (event) => { @@ -4320,7 +4320,7 @@ function GenerateSidebarToggleRadioAndSelect(labels, activationCallbacks = []) { }, option); }) ); - + for (let i = 0; i < labels.length; i++) { const text = labels[i]; const activationCallback = activationCallbacks[i] ?? (() => {}); @@ -4363,72 +4363,72 @@ function GenerateSidebarToggleRadioAndSelect(labels, activationCallbacks = []) { radioButtonGroup.append.apply(radioButtonGroup, buttons); buttons[0].click(); buttons[0].style.display = "none"; - + return [radioButtonGroup, select]; } class ModelManager extends ComfyDialog { /** @type {HTMLDivElement} */ element = null; - + /** @type {ModelData} */ #modelData = null; - + /** @type {ModelInfo} */ #modelInfo = null; - + /** @type {DownloadView} */ #downloadView = null; - + /** @type {BrowseView} */ #browseView = null; - + /** @type {SettingsView} */ #settingsView = null; - + /** @type {HTMLDivElement} */ #topbarRight = null; - + /** @type {HTMLDivElement} */ #tabManagerButtons = null; - + /** @type {HTMLDivElement} */ #tabManagerContents = null; - + /** @type {HTMLDivElement} */ #tabInfoButtons = null; - + /** @type {HTMLDivElement} */ #tabInfoContents = null; - + /** @type {HTMLButtonElement} */ #sidebarButtonGroup = null; - + /** @type {HTMLButtonElement} */ #sidebarSelect = null; - + /** @type {HTMLButtonElement} */ #closeModelInfoButton = null; - + /** @type {String} */ #dragSidebarState = ""; - + constructor() { super(); - + this.#modelData = new ModelData(); - + this.#settingsView = new SettingsView( this.#refreshModels, () => this.#updateSidebarButtons(), ); - + this.#modelInfo = new ModelInfo( this.#modelData, this.#refreshModels, this.#settingsView.elements.settings, ); - + this.#browseView = new BrowseView( this.#refreshModels, this.#modelData, @@ -4436,27 +4436,27 @@ class ModelManager extends ComfyDialog { this.#resetManagerContentsScroll, this.#settingsView.elements.settings, // TODO: decouple settingsData from elements? ); - + this.#downloadView = new DownloadView( this.#modelData, this.#settingsView.elements.settings, this.#refreshModels, ); - + const [tabManagerButtons, tabManagerContents] = GenerateTabGroup([ { name: "Download", icon: "arrow-collapse-down", tabContent: this.#downloadView.element }, { name: "Models", icon: "folder-search-outline", tabContent: this.#browseView.element }, { name: "Settings", icon: "cog-outline", tabContent: this.#settingsView.element }, ]); tabManagerButtons[0]?.click(); - + const tabInfoButtons = this.#modelInfo.elements.tabButtons; const tabInfoContents = this.#modelInfo.elements.tabContents; - + const [sidebarButtonGroup, sidebarSelect] = GenerateSidebarToggleRadioAndSelect( ["◼", "◨", "⬒", "⬓", "◧"], [ - () => { + () => { const element = this.element; if (element) { // callback on initialization as default state element.dataset["sidebarState"] = "none"; @@ -4475,7 +4475,7 @@ class ModelManager extends ComfyDialog { for (let i = 0; i < sidebarButtonGroupChildren.length; i++) { sidebarButtonGroupChildren[i].classList.add("icon-button"); } - + const closeModelInfoButton = new ComfyButton({ icon: "arrow-u-left-bottom", tooltip: "Return to model search", @@ -4484,7 +4484,7 @@ class ModelManager extends ComfyDialog { }).element; this.#closeModelInfoButton = closeModelInfoButton; closeModelInfoButton.style.display = "none"; - + const modelManager = $el( "div.comfy-modal.model-manager", { @@ -4545,36 +4545,36 @@ class ModelManager extends ComfyDialog { ]), ] ); - + new ResizeObserver(GenerateDynamicTabTextCallback(modelManager, tabManagerButtons, 704)).observe(modelManager); new ResizeObserver(GenerateDynamicTabTextCallback(modelManager, tabInfoButtons, 704)).observe(modelManager); new ResizeObserver(() => this.#updateSidebarButtons()).observe(modelManager); window.addEventListener('resize', () => { const width = window.innerWidth; const height = window.innerHeight; - + const leftDecimal = modelManager.dataset["sidebarLeftWidthDecimal"]; const rightDecimal = modelManager.dataset["sidebarRightWidthDecimal"]; const topDecimal = modelManager.dataset["sidebarTopHeightDecimal"]; const bottomDecimal = modelManager.dataset["sidebarBottomHeightDecimal"]; - + // restore decimal after resize modelManager.style.setProperty("--model-manager-sidebar-width-left", (leftDecimal * width) + "px"); modelManager.style.setProperty("--model-manager-sidebar-width-right", (rightDecimal * width) + "px"); modelManager.style.setProperty("--model-manager-sidebar-height-top", + (topDecimal * height) + "px"); modelManager.style.setProperty("--model-manager-sidebar-height-bottom", (bottomDecimal * height) + "px"); }); - + const EDGE_DELTA = 8; - + const endDragSidebar = (e) => { this.#dragSidebarState = ""; - + modelManager.classList.remove("cursor-drag-left"); modelManager.classList.remove("cursor-drag-top"); modelManager.classList.remove("cursor-drag-right"); modelManager.classList.remove("cursor-drag-bottom"); - + // cache for window resize modelManager.dataset["sidebarLeftWidthDecimal"] = parseInt(modelManager.style.getPropertyValue("--model-manager-sidebar-width-left")) / window.innerWidth; modelManager.dataset["sidebarRightWidthDecimal"] = parseInt(modelManager.style.getPropertyValue("--model-manager-sidebar-width-right")) / window.innerWidth; @@ -4583,7 +4583,7 @@ class ModelManager extends ComfyDialog { }; document.addEventListener("mouseup", (e) => endDragSidebar(e)); document.addEventListener("touchend", (e) => endDragSidebar(e)); - + const detectDragSidebar = (e, x, y) => { const left = modelManager.offsetLeft; const top = modelManager.offsetTop; @@ -4591,17 +4591,17 @@ class ModelManager extends ComfyDialog { const height = modelManager.offsetHeight; const right = left + width; const bottom = top + height; - + if (!(x >= left && x <= right && y >= top && y <= bottom)) { // click was not in model manager return; } - + const isOnEdgeLeft = x - left <= EDGE_DELTA; const isOnEdgeRight = right - x <= EDGE_DELTA; const isOnEdgeTop = y - top <= EDGE_DELTA; const isOnEdgeBottom = bottom - y <= EDGE_DELTA; - + const sidebarState = this.element.dataset["sidebarState"]; if (sidebarState === "left" && isOnEdgeRight) { this.#dragSidebarState = sidebarState; @@ -4615,7 +4615,7 @@ class ModelManager extends ComfyDialog { else if (sidebarState === "bottom" && isOnEdgeTop) { this.#dragSidebarState = sidebarState; } - + if (this.#dragSidebarState !== "") { e.preventDefault(); e.stopPropagation(); @@ -4623,25 +4623,25 @@ class ModelManager extends ComfyDialog { }; modelManager.addEventListener("mousedown", (e) => detectDragSidebar(e, e.clientX, e.clientY)); modelManager.addEventListener("touchstart", (e) => detectDragSidebar(e, e.touches[0].clientX, e.touches[0].clientY)); - + const updateSidebarCursor = (e, x, y) => { if (this.#dragSidebarState !== "") { // do not update cursor style while dragging return; } - + const left = modelManager.offsetLeft; const top = modelManager.offsetTop; const width = modelManager.offsetWidth; const height = modelManager.offsetHeight; const right = left + width; const bottom = top + height; - + const isOnEdgeLeft = x - left <= EDGE_DELTA; const isOnEdgeRight = right - x <= EDGE_DELTA; const isOnEdgeTop = y - top <= EDGE_DELTA; const isOnEdgeBottom = bottom - y <= EDGE_DELTA; - + const updateClass = (add, className) => { if (add) { modelManager.classList.add(className); @@ -4650,7 +4650,7 @@ class ModelManager extends ComfyDialog { modelManager.classList.remove(className); } }; - + const sidebarState = this.element.dataset["sidebarState"]; updateClass(sidebarState === "right" && isOnEdgeLeft, "cursor-drag-left"); updateClass(sidebarState === "bottom" && isOnEdgeTop, "cursor-drag-top"); @@ -4659,18 +4659,18 @@ class ModelManager extends ComfyDialog { }; modelManager.addEventListener("mousemove", (e) => updateSidebarCursor(e, e.clientX, e.clientY)); modelManager.addEventListener("touchmove", (e) => updateSidebarCursor(e, e.touches[0].clientX, e.touches[0].clientY)); - + const updateDragSidebar = (e, x, y) => { const sidebarState = this.#dragSidebarState; if (sidebarState === "") { return; } - + e.preventDefault(); - + const width = window.innerWidth; const height = window.innerHeight; - + if (sidebarState === "left") { const pixels = clamp(x, 0, width).toString() + "px"; modelManager.style.setProperty("--model-manager-sidebar-width-left", pixels); @@ -4690,16 +4690,16 @@ class ModelManager extends ComfyDialog { }; document.addEventListener("mousemove", (e) => updateDragSidebar(e, e.clientX, e.clientY)); document.addEventListener("touchmove", (e) => updateDragSidebar(e, e.touches[0].clientX, e.touches[0].clientY)); - + this.#init(); } - + async #init() { await this.#settingsView.reload(false); await this.#refreshModels(); - + const settings = this.#settingsView.elements.settings; - + { // initialize buttons' visibility state const hideSearchButtons = settings["text-input-always-hide-search-button"].checked; @@ -4707,41 +4707,41 @@ class ModelManager extends ComfyDialog { this.#downloadView.elements.searchButton.style.display = hideSearchButtons ? "none" : ""; this.#downloadView.elements.clearSearchButton.style.display = hideClearSearchButtons ? "none" : ""; } - + { // set initial sidebar widths & heights const width = window.innerWidth; const height = window.innerHeight; - + const xDecimal = settings["sidebar-default-width"].value; const yDecimal = settings["sidebar-default-height"].value; - + this.element.dataset["sidebarLeftWidthDecimal"] = xDecimal; this.element.dataset["sidebarRightWidthDecimal"] = xDecimal; this.element.dataset["sidebarTopHeightDecimal"] = yDecimal; this.element.dataset["sidebarBottomHeightDecimal"] = yDecimal; - + const x = Math.floor(width * xDecimal); const y = Math.floor(height * yDecimal); - + const leftPixels = x.toString() + "px"; this.element.style.setProperty("--model-manager-sidebar-width-left", leftPixels); - + const rightPixels = x.toString() + "px"; this.element.style.setProperty("--model-manager-sidebar-width-right", rightPixels); - + const topPixels = y.toString() + "px"; this.element.style.setProperty("--model-manager-sidebar-height-top", topPixels); - + const bottomPixels = y.toString() + "px"; this.element.style.setProperty("--model-manager-sidebar-height-bottom", bottomPixels); } } - + #resetManagerContentsScroll = () => { this.#tabManagerContents.scrollTop = 0; } - + #refreshModels = async() => { const modelData = this.#modelData; modelData.systemSeparator = await comfyRequest("/model-manager/system-separator"); @@ -4749,38 +4749,38 @@ class ModelManager extends ComfyDialog { Object.assign(modelData.models, newModels); // NOTE: do NOT create a new object const newModelDirectories = await comfyRequest("/model-manager/models/directory-list"); modelData.directories.data.splice(0, Infinity, ...newModelDirectories); // NOTE: do NOT create a new array - + this.#browseView.updateModelGrid(); await this.#tryHideModelInfo(false); - + document.getElementById("comfy-refresh-button")?.click(); } - + /** * @param {searchPath: string} * @return {Promise } */ #showModelInfo = async(searchPath) => { await this.#modelInfo.update( - searchPath, - this.#refreshModels, - this.#modelData.searchSeparator, + searchPath, + this.#refreshModels, + this.#modelData.searchSeparator, ).then(() => { this.#tabManagerButtons.style.display = "none"; this.#tabManagerContents.style.display = "none"; - + this.#closeModelInfoButton.style.display = ""; this.#tabInfoButtons.style.display = ""; this.#tabInfoContents.style.display = ""; - + this.#tabInfoButtons.children[0]?.click(); this.#modelInfo.show(); this.#tabInfoContents.scrollTop = 0; }); } - + /** - * @param {boolean} promptSave + * @param {boolean} promptSave * @returns {Promise } */ #tryHideModelInfo = async(promptSave) => { @@ -4788,17 +4788,17 @@ class ModelManager extends ComfyDialog { if (!await this.#modelInfo.tryHide(promptSave)) { return false; } - + this.#closeModelInfoButton.style.display = "none"; this.#tabInfoButtons.style.display = "none"; this.#tabInfoContents.style.display = "none"; - + this.#tabManagerButtons.style.display = ""; this.#tabManagerContents.style.display = ""; } return true; } - + #updateSidebarButtons = () => { const managerRect = this.element.getBoundingClientRect(); const isNarrow = managerRect.width < 768; // TODO: `minWidth` is a magic value @@ -4848,7 +4848,7 @@ app.registerExtension({ rel: "stylesheet", href: "./extensions/ComfyUI-Model-Manager/model-manager.css", }); - + app.ui?.menuContainer?.appendChild( $el("button", { id: "comfyui-model-manager-button", @@ -4857,7 +4857,7 @@ app.registerExtension({ onclick: () => toggleModelManager(), }) ); - + // [Beta] mobile menu app.menu?.settingsGroup?.append(new ComfyButton({ icon: "folder-search", From 1a52cf50d55115a155127808e37cd60cdd0b1f75 Mon Sep 17 00:00:00 2001 From: korutech-ai Date: Wed, 28 Aug 2024 10:13:11 +1200 Subject: [PATCH 06/11] LINTING: - Added eslint. - Cleaned up lexical declarations in case clauses. --- .vscode/settings.json | 19 +++++++++++++++++++ web/eslint.config.mjs | 8 ++++++++ web/model-manager.js | 8 +++++--- 3 files changed, 32 insertions(+), 3 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 web/eslint.config.mjs diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..9fbad55 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,19 @@ +{ + "cSpell.words": [ + "apng", + "Civitai", + "ckpt", + "comfyui", + "FYUIKMNVB", + "gguf", + "gligen", + "jfif", + "locon", + "loras", + "noimage", + "onnx", + "rfilename", + "unet", + "upscaler" + ] +} \ No newline at end of file diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs new file mode 100644 index 0000000..2edbef8 --- /dev/null +++ b/web/eslint.config.mjs @@ -0,0 +1,8 @@ +import globals from "globals"; +import pluginJs from "@eslint/js"; + + +export default [ + {languageOptions: { globals: globals.browser }}, + pluginJs.configs.recommended, +]; \ No newline at end of file diff --git a/web/model-manager.js b/web/model-manager.js index 498b098..469c14a 100644 --- a/web/model-manager.js +++ b/web/model-manager.js @@ -606,7 +606,7 @@ class ImageSelect { const value = document.querySelector(`input[name="${name}"]:checked`).value; const elements = this.elements; switch (value) { - case this.#PREVIEW_DEFAULT: + case this.#PREVIEW_DEFAULT: { const children = elements.defaultPreviews.children; const noImage = PREVIEW_NONE_URI; let url = ""; @@ -626,7 +626,8 @@ class ImageSelect { }); } return url; - case this.#PREVIEW_URL: + } + case this.#PREVIEW_URL: { const value = elements.customUrl.value; if (value.startsWith(Civitai.imagePostUrlPrefix())) { try { @@ -644,6 +645,7 @@ class ImageSelect { } } return value; + } case this.#PREVIEW_UPLOAD: return elements.uploadFile.files[0] ?? ""; case this.#PREVIEW_NONE: @@ -2112,7 +2114,7 @@ class ModelInfo { previewSelect.elements.previews.style.display = "flex"; const setPreviewButton = new ComfyButton({ - tooltip: "Overwrite currrent preview with selected image", + tooltip: "Overwrite current preview with selected image", content: "Set as Preview", action: async(e) => { const [button, icon, span] = comfyButtonDisambiguate(e.target); From 3943034a18e3338d2a81ed26f6e2697ed3a052f3 Mon Sep 17 00:00:00 2001 From: korutech-ai Date: Wed, 28 Aug 2024 11:18:53 +1200 Subject: [PATCH 07/11] UPDATE: Added a notes editing toggle button to improve notes tab layout. --- web/model-manager.js | 46 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 39 insertions(+), 7 deletions(-) diff --git a/web/model-manager.js b/web/model-manager.js index 469c14a..bc7b4dd 100644 --- a/web/model-manager.js +++ b/web/model-manager.js @@ -2716,7 +2716,7 @@ class ModelInfo { (() => { const notes = $el("textarea.comfy-multiline-input", { name: "model notes", - value: noteText, + value: noteText, oninput: (e) => { if (this.#settingsElements["model-info-autosave-notes"].checked) { saveDebounce(); @@ -2752,19 +2752,51 @@ class ModelInfo { this.elements.notes = notes; this.elements.markdown = markdown; this.#savedNotesValue = noteText; + + const notes_editor = $el( + "div", + { + style: { + "display": noteText == "" ? "flex" : "none", + "height": "100%", + "min-height": "60px" + }, + }, + notes + ); + const notes_viewer = $el( + "div", + { + style: { + "display": noteText == "" ? "none" : "flex", + "height": "100%", + "min-height": "60px", + "overflow": "scroll" + }, + }, + markdown + ); + + const editNotesButton = new ComfyButton({ + icon: "pencil", + tooltip: "Change file name", + classList: "comfyui-button icon-button", + action: async () => { + notes_editor.style.display = notes_editor.style.display == "flex" ? "none" : "flex"; + notes_viewer.style.display = notes_viewer.style.display == "none" ? "flex" : "none"; + }, + }).element; + return [ $el("div.row", { style: { "align-items": "center" }, }, [ $el("h1", ["Notes"]), saveNotesButton, + editNotesButton, ]), - $el("div", { - style: { "display": "block", "height": "20%", "min-height": "120px", "z-index": "1" }, - }, notes), - $el("div", { - style: { "display": "block", "height": "70%", "min-height": "60px", "overflow": "scroll" }, - }, markdown), + notes_editor, + notes_viewer, ]; })() ); From 6a3eed6157f3066415d3ff5d738ecbb6278879c3 Mon Sep 17 00:00:00 2001 From: korutech-ai Date: Wed, 28 Aug 2024 11:52:04 +1200 Subject: [PATCH 08/11] BUGFIX: Modified layout of tags tab so elements don't overlap. --- web/model-manager.js | 83 +++++++++++++++++++++++++------------------- 1 file changed, 47 insertions(+), 36 deletions(-) diff --git a/web/model-manager.js b/web/model-manager.js index bc7b4dd..40a66bc 100644 --- a/web/model-manager.js +++ b/web/model-manager.js @@ -2635,46 +2635,57 @@ class ModelInfo { break; } } + const tagGenerator = $el( + "div", [ + $el("h1", ["Tags"]), + $el("h2", { style: { margin: "0px 0px 16px 0px" } }, ["Random Tag Generator"]), + $el("div", [ + $el("details.tag-generator-settings", { + style: { margin: "10px 0", display: "none" }, + open: false, + }, [ + $el("summary", ["Settings"]), + $el("div", [ + "Sampling Method", + samplerRadioGroup, + ]), + $el("label", [ + "Count", + tagGenerationCount, + ]), + $el("label", [ + "Threshold", + tagGenerationThreshold, + ]), + ]), + tagGeneratorRandomizedOutput, + new ComfyButton({ + content: "Randomize", + tooltip: "Randomly generate subset of tags", + action: () => { + const samplerName = document.querySelector(`input[name="${TAG_GENERATOR_SAMPLER_NAME}"]:checked`).value; + const sampler = samplerName === "Frequency" ? ModelInfo.ProbabilisticTagSampling : ModelInfo.UniformTagSampling; + const sampleCount = tagGenerationCount.value; + const frequencyThreshold = tagGenerationThreshold.value; + const tags = ParseTagParagraph(tagsParagraph.innerText); + const sampledTags = sampler(tags, sampleCount, frequencyThreshold); + tagGeneratorRandomizedOutput.value = sampledTags.join(", "); + }, + }).element, + ]), + ] + ) tagsElement.innerHTML = ""; tagsElement.append.apply(tagsElement, [ - $el("h1", ["Tags"]), - $el("h2", { style: { margin: "0px 0px 16px 0px" } }, ["Random Tag Generator"]), + tagGenerator, $el("div", [ - $el("details.tag-generator-settings", { - style: { margin: "10px 0", display: "none" }, - open: false, - }, [ - $el("summary", ["Settings"]), - $el("div", [ - "Sampling Method", - samplerRadioGroup, - ]), - $el("label", [ - "Count", - tagGenerationCount, - ]), - $el("label", [ - "Threshold", - tagGenerationThreshold, - ]), - ]), - tagGeneratorRandomizedOutput, - new ComfyButton({ - content: "Randomize", - tooltip: "Randomly generate subset of tags", - action: () => { - const samplerName = document.querySelector(`input[name="${TAG_GENERATOR_SAMPLER_NAME}"]:checked`).value; - const sampler = samplerName === "Frequency" ? ModelInfo.ProbabilisticTagSampling : ModelInfo.UniformTagSampling; - const sampleCount = tagGenerationCount.value; - const frequencyThreshold = tagGenerationThreshold.value; - const tags = ParseTagParagraph(tagsParagraph.innerText); - const sampledTags = sampler(tags, sampleCount, frequencyThreshold); - tagGeneratorRandomizedOutput.value = sampledTags.join(", "); - }, - }).element, + $el("h2", { + style: { + margin: "24px 0px 8px 0px" + } + }, ["Tags"]), + tagsParagraph, ]), - $el("h2", {style: { margin: "24px 0px 8px 0px" } }, ["Training Tags"]), - tagsParagraph, ]); const tagButton = this.elements.tabButtons[2]; // TODO: remove magic value tagButton.style.display = isTags ? "" : "none"; From 89b95405c854f5d706cba2773b18c6ddda70f646 Mon Sep 17 00:00:00 2001 From: korutech-ai Date: Thu, 29 Aug 2024 08:02:01 +1200 Subject: [PATCH 09/11] CSS: Added overflow-wrap to improve text readability. --- web/model-manager.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/model-manager.js b/web/model-manager.js index 40a66bc..81fae94 100644 --- a/web/model-manager.js +++ b/web/model-manager.js @@ -2782,7 +2782,8 @@ class ModelInfo { "display": noteText == "" ? "none" : "flex", "height": "100%", "min-height": "60px", - "overflow": "scroll" + "overflow": "scroll", + "overflow-wrap": "anywhere" }, }, markdown From 5dea0bae989849e2e7eb67c1b5e02dad80b40410 Mon Sep 17 00:00:00 2001 From: hayden Date: Wed, 18 Sep 2024 11:54:49 +0800 Subject: [PATCH 10/11] LINT: Add some lint specifications --- .editorconfig | 12 + .prettierrc.json | 7 + .vscode/extensions.json | 5 + .vscode/settings.json | 3 +- web/model-manager.js | 9489 +++++++++++++++++++++------------------ 5 files changed, 5084 insertions(+), 4432 deletions(-) create mode 100644 .editorconfig create mode 100644 .prettierrc.json create mode 100644 .vscode/extensions.json diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5d47c21 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..a253ce4 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "singleQuote": true, + "trailingComma": "all" +} \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..3433c01 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "esbenp.prettier-vscode" + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 9fbad55..f2c3f14 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -15,5 +15,6 @@ "rfilename", "unet", "upscaler" - ] + ], + "editor.defaultFormatter": "esbenp.prettier-vscode" } \ No newline at end of file diff --git a/web/model-manager.js b/web/model-manager.js index 81fae94..12b9b2c 100644 --- a/web/model-manager.js +++ b/web/model-manager.js @@ -1,12 +1,12 @@ -import { app } from "../../scripts/app.js"; -import { api } from "../../scripts/api.js"; -import { ComfyDialog, $el } from "../../scripts/ui.js"; -import { ComfyButton } from "../../scripts/ui/components/button.js"; -import { marked } from "./marked.js"; -import("./downshow.js"); +import { app } from '../../scripts/app.js'; +import { api } from '../../scripts/api.js'; +import { ComfyDialog, $el } from '../../scripts/ui.js'; +import { ComfyButton } from '../../scripts/ui/components/button.js'; +import { marked } from './marked.js'; +import('./downshow.js'); function clamp(x, min, max) { - return Math.min(Math.max(x, min), max); + return Math.min(Math.max(x, min), max); } /** @@ -15,12 +15,13 @@ function clamp(x, min, max) { * @returns {Promise} */ function comfyRequest(url, options = undefined) { - return new Promise((resolve, reject) => { - api.fetchApi(url, options) - .then((response) => response.json()) - .then(resolve) - .catch(reject); - }); + return new Promise((resolve, reject) => { + api + .fetchApi(url, options) + .then((response) => response.json()) + .then(resolve) + .catch(reject); + }); } /** @@ -29,153 +30,160 @@ function comfyRequest(url, options = undefined) { * @returns {(...args) => void} */ function debounce(callback, delay) { - let timeoutId = null; - return (...args) => { - window.clearTimeout(timeoutId); - timeoutId = window.setTimeout(() => { - callback(...args); - }, delay); - }; + let timeoutId = null; + return (...args) => { + window.clearTimeout(timeoutId); + timeoutId = window.setTimeout(() => { + callback(...args); + }, delay); + }; } class KeyComboListener { - /** @type {string[]} */ - #keyCodes = []; + /** @type {string[]} */ + #keyCodes = []; - /** @type {() => Promise } */ - action; + /** @type {() => Promise } */ + action; - /** @type {Element} */ - element; + /** @type {Element} */ + element; - /** @type {string[]} */ - #combo = []; + /** @type {string[]} */ + #combo = []; - /** - * @param {string[]} keyCodes - * @param {() => Promise } action - * @param {Element} element - */ - constructor(keyCodes, action, element) { - this.#keyCodes = keyCodes; - this.action = action; - this.element = element; + /** + * @param {string[]} keyCodes + * @param {() => Promise } action + * @param {Element} element + */ + constructor(keyCodes, action, element) { + this.#keyCodes = keyCodes; + this.action = action; + this.element = element; - document.addEventListener("keydown", (e) => { - const code = e.code; - const keyCodes = this.#keyCodes; - const combo = this.#combo; - if (keyCodes.includes(code) && !combo.includes(code)) { - combo.push(code); - } - if (combo.length === 0 || keyCodes.length !== combo.length) { - return; - } - for (let i = 0; i < combo.length; i++) { - if (keyCodes[i] !== combo[i]) { - return; - } - } - if (document.activeElement !== this.element) { - return; - } - e.preventDefault(); - e.stopPropagation(); - this.action(); - this.#combo.length = 0; - }); - document.addEventListener("keyup", (e) => { - // Mac keyup doesn't fire when meta key is held: https://stackoverflow.com/a/73419500 - const code = e.code; - if (code === "MetaLeft" || code === "MetaRight") { - this.#combo.length = 0; - } - else { - this.#combo = this.#combo.filter(x => x !== code); - } - }); - } + document.addEventListener('keydown', (e) => { + const code = e.code; + const keyCodes = this.#keyCodes; + const combo = this.#combo; + if (keyCodes.includes(code) && !combo.includes(code)) { + combo.push(code); + } + if (combo.length === 0 || keyCodes.length !== combo.length) { + return; + } + for (let i = 0; i < combo.length; i++) { + if (keyCodes[i] !== combo[i]) { + return; + } + } + if (document.activeElement !== this.element) { + return; + } + e.preventDefault(); + e.stopPropagation(); + this.action(); + this.#combo.length = 0; + }); + document.addEventListener('keyup', (e) => { + // Mac keyup doesn't fire when meta key is held: https://stackoverflow.com/a/73419500 + const code = e.code; + if (code === 'MetaLeft' || code === 'MetaRight') { + this.#combo.length = 0; + } else { + this.#combo = this.#combo.filter((x) => x !== code); + } + }); + } } /** * Handles Firefox's drag event, which returns different coordinates and then fails when calling `elementFromPoint`. - * @param {DragEvent} event + * @param {DragEvent} event * @returns {[Number, Number, HTMLElement]} [clientX, clientY, targetElement] */ function elementFromDragEvent(event) { - let clientX = null; - let clientY = null; - let target; - const userAgentString = navigator.userAgent; - if (userAgentString.indexOf("Firefox") > -1) { - clientX = event.clientX; - clientY = event.clientY; - const screenOffsetX = window.screenLeft; - if (clientX >= screenOffsetX) { - clientX = clientX - screenOffsetX; - } - const screenOffsetY = window.screenTop; - if (clientY >= screenOffsetY) { - clientY = clientY - screenOffsetY; - } - target = document.elementFromPoint(clientX, clientY); + let clientX = null; + let clientY = null; + let target; + const userAgentString = navigator.userAgent; + if (userAgentString.indexOf('Firefox') > -1) { + clientX = event.clientX; + clientY = event.clientY; + const screenOffsetX = window.screenLeft; + if (clientX >= screenOffsetX) { + clientX = clientX - screenOffsetX; } - else { - clientX = event.clientX; - clientY = event.clientY; - target = document.elementFromPoint(event.clientX, event.clientY); + const screenOffsetY = window.screenTop; + if (clientY >= screenOffsetY) { + clientY = clientY - screenOffsetY; } - return [clientX, clientY, target]; + target = document.elementFromPoint(clientX, clientY); + } else { + clientX = event.clientX; + clientY = event.clientY; + target = document.elementFromPoint(event.clientX, event.clientY); + } + return [clientX, clientY, target]; } /** * @param {string} url */ async function loadWorkflow(url) { - const uri = (new URL(url)).searchParams.get("uri"); - const fileNameIndex = Math.max(uri.lastIndexOf("/"), uri.lastIndexOf("\\")) + 1; - const fileName = uri.substring(fileNameIndex); - const response = await fetch(url); - const data = await response.blob(); - const file = new File([data], fileName, { type: data.type }); - app.handleFile(file); + const uri = new URL(url).searchParams.get('uri'); + const fileNameIndex = + Math.max(uri.lastIndexOf('/'), uri.lastIndexOf('\\')) + 1; + const fileName = uri.substring(fileNameIndex); + const response = await fetch(url); + const data = await response.blob(); + const file = new File([data], fileName, { type: data.type }); + app.handleFile(file); } const modelNodeType = { - "checkpoints": "CheckpointLoaderSimple", - "clip": "CLIPLoader", - "clip_vision": "CLIPVisionLoader", - "controlnet": "ControlNetLoader", - "diffusers": "DiffusersLoader", - "embeddings": "Embedding", - "gligen": "GLIGENLoader", - "hypernetworks": "HypernetworkLoader", - "photomaker": "PhotoMakerLoader", - "loras": "LoraLoader", - "style_models": "StyleModelLoader", - "unet": "UNETLoader", - "upscale_models": "UpscaleModelLoader", - "vae": "VAELoader", - "vae_approx": undefined, + checkpoints: 'CheckpointLoaderSimple', + clip: 'CLIPLoader', + clip_vision: 'CLIPVisionLoader', + controlnet: 'ControlNetLoader', + diffusers: 'DiffusersLoader', + embeddings: 'Embedding', + gligen: 'GLIGENLoader', + hypernetworks: 'HypernetworkLoader', + photomaker: 'PhotoMakerLoader', + loras: 'LoraLoader', + style_models: 'StyleModelLoader', + unet: 'UNETLoader', + upscale_models: 'UpscaleModelLoader', + vae: 'VAELoader', + vae_approx: undefined, }; -const MODEL_EXTENSIONS = [".bin", ".ckpt", "gguf", ".onnx", ".pt", ".pth", ".safetensors"]; // TODO: ask server for? +const MODEL_EXTENSIONS = [ + '.bin', + '.ckpt', + 'gguf', + '.onnx', + '.pt', + '.pth', + '.safetensors', +]; // TODO: ask server for? const IMAGE_EXTENSIONS = [ - ".png", - ".webp", - ".jpeg", - ".jpg", - ".jfif", - ".gif", - ".apng", + '.png', + '.webp', + '.jpeg', + '.jpg', + '.jfif', + '.gif', + '.apng', - ".preview.png", - ".preview.webp", - ".preview.jpeg", - ".preview.jpg", - ".preview.jfif", - ".preview.gif", - ".preview.apng", + '.preview.png', + '.preview.webp', + '.preview.jpeg', + '.preview.jpg', + '.preview.jfif', + '.preview.gif', + '.preview.apng', ]; // TODO: /model-manager/image/extensions /** @@ -184,10 +192,10 @@ const IMAGE_EXTENSIONS = [ * @returns {string} */ function removePrefix(s, prefix) { - if (s.length >= prefix.length && s.startsWith(prefix)){ - return s.substring(prefix.length); - } - return s; + if (s.length >= prefix.length && s.startsWith(prefix)) { + return s.substring(prefix.length); + } + return s; } /** @@ -196,44 +204,44 @@ function removePrefix(s, prefix) { * @returns {string} */ function removeSuffix(s, suffix) { - if (s.length >= suffix.length && s.endsWith(suffix)){ - return s.substring(0, s.length - suffix.length); - } - return s; + if (s.length >= suffix.length && s.endsWith(suffix)) { + return s.substring(0, s.length - suffix.length); + } + return s; } class SearchPath { - /** - * @param {string} path - * @returns {[string, string]} - */ - static split(path) { - const i = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")) + 1; - return [path.slice(0, i), path.slice(i)]; - } + /** + * @param {string} path + * @returns {[string, string]} + */ + static split(path) { + const i = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1; + return [path.slice(0, i), path.slice(i)]; + } - /** - * @param {string} path - * @param {string[]} extensions - * @returns {[string, string]} - */ - static splitExtension(path) { - const i = path.lastIndexOf("."); - if (i === -1) { - return [path, ""]; - } - return [path.slice(0, i), path.slice(i)]; + /** + * @param {string} path + * @param {string[]} extensions + * @returns {[string, string]} + */ + static splitExtension(path) { + const i = path.lastIndexOf('.'); + if (i === -1) { + return [path, '']; } + return [path.slice(0, i), path.slice(i)]; + } - /** - * @param {string} path - * @returns {string} - */ - static systemPath(path, searchSeparator, systemSeparator) { - const i1 = path.indexOf(searchSeparator, 1); - const i2 = path.indexOf(searchSeparator, i1 + 1); - return path.slice(i2 + 1).replaceAll(searchSeparator, systemSeparator); - } + /** + * @param {string} path + * @returns {string} + */ + static systemPath(path, searchSeparator, systemSeparator) { + const i1 = path.indexOf(searchSeparator, 1); + const i2 = path.indexOf(searchSeparator, i1 + 1); + return path.slice(i2 + 1).replaceAll(searchSeparator, systemSeparator); + } } /** @@ -244,23 +252,29 @@ class SearchPath { * @param {string | undefined} [imageFormat=undefined] * @returns {string} */ -function imageUri(imageSearchPath = undefined, dateImageModified = undefined, width = undefined, height = undefined, imageFormat = undefined) { - const path = imageSearchPath ?? "no-preview"; - const date = dateImageModified; - let uri = `/model-manager/preview/get?uri=${path}`; - if (width !== undefined && width !== null) { - uri += `&width=${width}`; - } - if (height !== undefined && height !== null) { - uri += `&height=${height}`; - } - if (date !== undefined && date !== null) { - uri += `&v=${date}`; - } - if (imageFormat !== undefined && imageFormat !== null) { - uri += `&image-format=${imageFormat}`; - } - return uri; +function imageUri( + imageSearchPath = undefined, + dateImageModified = undefined, + width = undefined, + height = undefined, + imageFormat = undefined, +) { + const path = imageSearchPath ?? 'no-preview'; + const date = dateImageModified; + let uri = `/model-manager/preview/get?uri=${path}`; + if (width !== undefined && width !== null) { + uri += `&width=${width}`; + } + if (height !== undefined && height !== null) { + uri += `&height=${height}`; + } + if (date !== undefined && date !== null) { + uri += `&v=${date}`; + } + if (imageFormat !== undefined && imageFormat !== null) { + uri += `&image-format=${imageFormat}`; + } + return uri; } const PREVIEW_NONE_URI = imageUri(); const PREVIEW_THUMBNAIL_WIDTH = 320; @@ -272,27 +286,25 @@ const PREVIEW_THUMBNAIL_HEIGHT = 480; * @returns {[HTMLButtonElement | undefined, HTMLElement | undefined, HTMLSpanElement | undefined]} [button, icon, span] */ function comfyButtonDisambiguate(element) { - // TODO: This likely can be removed by using a css rule that disables clicking on the inner elements of the button. - let button = undefined; - let icon = undefined; - let span = undefined; - const nodeName = element.nodeName.toLowerCase(); - if (nodeName === "button") { - button = element; - icon = button.getElementsByTagName("i")[0]; - span = button.getElementsByTagName("span")[0]; - } - else if (nodeName === "i") { - icon = element; - button = element.parentElement; - span = button.getElementsByTagName("span")[0]; - } - else if (nodeName === "span") { - button = element.parentElement; - icon = button.getElementsByTagName("i")[0]; - span = element; - } - return [button, icon, span] + // TODO: This likely can be removed by using a css rule that disables clicking on the inner elements of the button. + let button = undefined; + let icon = undefined; + let span = undefined; + const nodeName = element.nodeName.toLowerCase(); + if (nodeName === 'button') { + button = element; + icon = button.getElementsByTagName('i')[0]; + span = button.getElementsByTagName('span')[0]; + } else if (nodeName === 'i') { + icon = element; + button = element.parentElement; + span = button.getElementsByTagName('span')[0]; + } else if (nodeName === 'span') { + button = element.parentElement; + icon = button.getElementsByTagName('i')[0]; + span = element; + } + return [button, icon, span]; } /** @@ -302,42 +314,63 @@ function comfyButtonDisambiguate(element) { * @param {string?} failureClassName * @param {boolean?} [disableCallback=false] */ -function comfyButtonAlert(element, success, successClassName = undefined, failureClassName = undefined, disableCallback = false) { - if (element === undefined || element === null) { return; } +function comfyButtonAlert( + element, + success, + successClassName = undefined, + failureClassName = undefined, + disableCallback = false, +) { + if (element === undefined || element === null) { + return; + } - const [button, icon, span] = comfyButtonDisambiguate(element); - if (button === undefined) { - console.warn("Unable to find button element!"); - console.warn(element); - return; + const [button, icon, span] = comfyButtonDisambiguate(element); + if (button === undefined) { + console.warn('Unable to find button element!'); + console.warn(element); + return; + } + + // TODO: debounce would be nice, but needs some sort of "global" to avoid creating/destroying many objects + + const colorClassName = success + ? 'comfy-button-success' + : 'comfy-button-failure'; + + if (icon) { + const iconClassName = (success ? successClassName : failureClassName) ?? ''; + if (iconClassName !== '') { + icon.classList.add(iconClassName); } - - // TODO: debounce would be nice, but needs some sort of "global" to avoid creating/destroying many objects - - const colorClassName = success ? "comfy-button-success" : "comfy-button-failure"; - - if (icon) { - const iconClassName = (success ? successClassName : failureClassName) ?? ""; - if (iconClassName !== "") { - icon.classList.add(iconClassName); - } - icon.classList.add(colorClassName); - if (!disableCallback) { - window.setTimeout((element, iconClassName, colorClassName) => { - if (iconClassName !== "") { - element.classList.remove(iconClassName); - } - element.classList.remove(colorClassName); - }, 1000, icon, iconClassName, colorClassName); - } - } - - button.classList.add(colorClassName); + icon.classList.add(colorClassName); if (!disableCallback) { - window.setTimeout((element, colorClassName) => { - element.classList.remove(colorClassName); - }, 1000, button, colorClassName); + window.setTimeout( + (element, iconClassName, colorClassName) => { + if (iconClassName !== '') { + element.classList.remove(iconClassName); + } + element.classList.remove(colorClassName); + }, + 1000, + icon, + iconClassName, + colorClassName, + ); } + } + + button.classList.add(colorClassName); + if (!disableCallback) { + window.setTimeout( + (element, colorClassName) => { + element.classList.remove(colorClassName); + }, + 1000, + button, + colorClassName, + ); + } } /** @@ -347,75 +380,82 @@ function comfyButtonAlert(element, success, successClassName = undefined, failur * @returns {Promise } */ async function saveNotes(modelPath, newValue) { - const timestamp = await comfyRequest("/model-manager/timestamp") - .catch((err) => { - console.warn(err); - return false; - }); - return await comfyRequest( - "/model-manager/notes/save", - { - method: "POST", - body: JSON.stringify({ - "path": modelPath, - "notes": newValue, - }), - timestamp: timestamp, - } - ).then((result) => { - const saved = result["success"]; - const message = result["alert"]; - if (message !== undefined) { - window.alert(message); - } - return saved; + const timestamp = await comfyRequest('/model-manager/timestamp').catch( + (err) => { + console.warn(err); + return false; + }, + ); + return await comfyRequest('/model-manager/notes/save', { + method: 'POST', + body: JSON.stringify({ + path: modelPath, + notes: newValue, + }), + timestamp: timestamp, + }) + .then((result) => { + const saved = result['success']; + const message = result['alert']; + if (message !== undefined) { + window.alert(message); + } + return saved; }) .catch((err) => { - console.warn(err); - return false; + console.warn(err); + return false; }); } /** * @returns {HTMLLabelElement} */ -function $checkbox(x = { $: (el) => {}, textContent: "", checked: false }) { - const text = x.textContent; - const input = $el("input", { - type: "checkbox", - name: text ?? "checkbox", - checked: x.checked ?? false, - }); - const label = $el("label", [ - input, - text === "" || text === undefined || text === null ? "" : " " + text, - ]); - if (x.$ !== undefined){ - x.$(input); - } - return label; +function $checkbox(x = { $: (el) => {}, textContent: '', checked: false }) { + const text = x.textContent; + const input = $el('input', { + type: 'checkbox', + name: text ?? 'checkbox', + checked: x.checked ?? false, + }); + const label = $el('label', [ + input, + text === '' || text === undefined || text === null ? '' : ' ' + text, + ]); + if (x.$ !== undefined) { + x.$(input); + } + return label; } /** * @returns {HTMLLabelElement} */ -function $select(x = { $: (el) => {}, textContent: "", options: [""] }) { - const text = x.textContent; - const select = $el("select", { - name: text ?? "select", - }, x.options.map((option) => { - return $el("option", { - value: option, - }, option); - })); - const label = $el("label", [ - text === "" || text === undefined || text === null ? "" : " " + text, - select, - ]); - if (x.$ !== undefined){ - x.$(select); - } - return label; +function $select(x = { $: (el) => {}, textContent: '', options: [''] }) { + const text = x.textContent; + const select = $el( + 'select', + { + name: text ?? 'select', + }, + x.options.map((option) => { + return $el( + 'option', + { + value: option, + }, + option, + ); + }), + ); + const label = $el('label', [ + text === '' || text === undefined || text === null ? '' : ' ' + text, + select, + ]); + if (x.$ !== undefined) { + x.$(select); + } + return label; } /** @@ -423,43 +463,39 @@ function $select(x = { $: (el) => {}, textContent: "", options: [""] }) { * @returns {HTMLDivElement} */ function $radioGroup(attr) { - const { name = Date.now(), onchange, options = [], $ } = attr; + const { name = Date.now(), onchange, options = [], $ } = attr; - /** @type {HTMLDivElement[]} */ - const radioGroup = options.map((item, index) => { - const inputRef = { value: null }; + /** @type {HTMLDivElement[]} */ + const radioGroup = options.map((item, index) => { + const inputRef = { value: null }; - return $el( - "div.comfy-radio", - { onclick: () => inputRef.value.click() }, - [ - $el("input.radio-input", { - type: "radio", - name: name, - value: item.value, - checked: index === 0, - $: (el) => (inputRef.value = el), - }), - $el("label.no-highlight", item.label ?? item.value), - ] - ); + return $el('div.comfy-radio', { onclick: () => inputRef.value.click() }, [ + $el('input.radio-input', { + type: 'radio', + name: name, + value: item.value, + checked: index === 0, + $: (el) => (inputRef.value = el), + }), + $el('label.no-highlight', item.label ?? item.value), + ]); + }); + + const element = $el('input', { + name: name + '-group', + value: options[0]?.value, + }); + $?.(element); + + radioGroup.forEach((radio) => { + radio.addEventListener('change', (event) => { + const selectedValue = event.target.value; + element.value = selectedValue; + onchange?.(selectedValue); }); + }); - const element = $el("input", { - name: name + "-group", - value: options[0]?.value, - }); - $?.(element); - - radioGroup.forEach((radio) => { - radio.addEventListener("change", (event) => { - const selectedValue = event.target.value; - element.value = selectedValue; - onchange?.(selectedValue); - }); - }); - - return $el("div.comfy-radio-group", radioGroup); + return $el('div.comfy-radio-group', radioGroup); } /** @@ -467,57 +503,57 @@ function $radioGroup(attr) { * @returns {[HTMLDivElement[], HTMLDivElement[]]} */ function GenerateTabGroup(tabData) { - const ACTIVE_TAB_CLASS = "active"; + const ACTIVE_TAB_CLASS = 'active'; - /** @type {HTMLDivElement[]} */ - const tabButtons = []; + /** @type {HTMLDivElement[]} */ + const tabButtons = []; - /** @type {HTMLDivElement[]} */ - const tabContents = []; + /** @type {HTMLDivElement[]} */ + const tabContents = []; - tabData.forEach((data) => { - const name = data.name; - const icon = data.icon; - /** @type {HTMLDivElement} */ - const tab = new ComfyButton({ - icon: icon, - tooltip: "Open " + name.toLowerCase() + " tab", - classList: "comfyui-button tab-button", - content: name, - action: () => { - tabButtons.forEach((tabButton) => { - if (name === tabButton.getAttribute("data-name")) { - tabButton.classList.add(ACTIVE_TAB_CLASS); - } - else { - tabButton.classList.remove(ACTIVE_TAB_CLASS); - } - }); - tabContents.forEach((tabContent) => { - if (name === tabContent.getAttribute("data-name")) { - tabContent.scrollTop = tabContent.dataset["scrollTop"] ?? 0; - tabContent.style.display = ""; - } - else { - tabContent.dataset["scrollTop"] = tabContent.scrollTop; - tabContent.style.display = "none"; - } - }); - }, - }).element; - tab.dataset.name = name; - const content = $el("div.tab-content", { - dataset: { - name: data.name, - } - }, [ - data.tabContent - ]); - tabButtons.push(tab); - tabContents.push(content); - }); + tabData.forEach((data) => { + const name = data.name; + const icon = data.icon; + /** @type {HTMLDivElement} */ + const tab = new ComfyButton({ + icon: icon, + tooltip: 'Open ' + name.toLowerCase() + ' tab', + classList: 'comfyui-button tab-button', + content: name, + action: () => { + tabButtons.forEach((tabButton) => { + if (name === tabButton.getAttribute('data-name')) { + tabButton.classList.add(ACTIVE_TAB_CLASS); + } else { + tabButton.classList.remove(ACTIVE_TAB_CLASS); + } + }); + tabContents.forEach((tabContent) => { + if (name === tabContent.getAttribute('data-name')) { + tabContent.scrollTop = tabContent.dataset['scrollTop'] ?? 0; + tabContent.style.display = ''; + } else { + tabContent.dataset['scrollTop'] = tabContent.scrollTop; + tabContent.style.display = 'none'; + } + }); + }, + }).element; + tab.dataset.name = name; + const content = $el( + 'div.tab-content', + { + dataset: { + name: data.name, + }, + }, + [data.tabContent], + ); + tabButtons.push(tab); + tabContents.push(content); + }); - return [tabButtons, tabContents]; + return [tabButtons, tabContents]; } /** @@ -525,19 +561,19 @@ function GenerateTabGroup(tabData) { * @param {Record []} tabButtons */ function GenerateDynamicTabTextCallback(element, tabButtons, minWidth) { - return () => { - if (element.style.display === "none") { - return; - } - const managerRect = element.getBoundingClientRect(); - const isIcon = managerRect.width < minWidth; // TODO: `minWidth` is a magic value - const iconDisplay = isIcon ? "" : "none"; - const spanDisplay = isIcon ? "none" : ""; - tabButtons.forEach((tabButton) => { - tabButton.getElementsByTagName("i")[0].style.display = iconDisplay; - tabButton.getElementsByTagName("span")[0].style.display = spanDisplay; - }); - }; + return () => { + if (element.style.display === 'none') { + return; + } + const managerRect = element.getBoundingClientRect(); + const isIcon = managerRect.width < minWidth; // TODO: `minWidth` is a magic value + const iconDisplay = isIcon ? '' : 'none'; + const spanDisplay = isIcon ? 'none' : ''; + tabButtons.forEach((tabButton) => { + tabButton.getElementsByTagName('i')[0].style.display = iconDisplay; + tabButton.getElementsByTagName('span')[0].style.display = spanDisplay; + }); + }; } /** @@ -545,18 +581,18 @@ function GenerateDynamicTabTextCallback(element, tabButtons, minWidth) { * @returns {String} */ function TagCountMapToParagraph(map) { - let text = " "; - for (let i = 0; i < map.length; i++) { - const v = map[i]; - const tag = v[0]; - const count = v[1]; - text += tag + " (" + count + ")"; - if (i !== map.length - 1) { - text += ", "; - } + let text = '
'; + for (let i = 0; i < map.length; i++) { + const v = map[i]; + const tag = v[0]; + const count = v[1]; + text += tag + ' (' + count + ')'; + if (i !== map.length - 1) { + text += ', '; } - text += "
"; - return text; + } + text += ''; + return text; } /** @@ -564,422 +600,448 @@ function TagCountMapToParagraph(map) { * @returns {[String, int][]} */ function ParseTagParagraph(p) { - return p.split(",").map(x => { - const text = x.endsWith(", ") ? x.substring(0, x.length - 2) : x; - const i = text.lastIndexOf("("); - const tag = text.substring(0, i).trim(); - const frequency = parseInt(text.substring(i + 1, text.length - 1)); - return [tag, frequency]; - }); + return p.split(',').map((x) => { + const text = x.endsWith(', ') ? x.substring(0, x.length - 2) : x; + const i = text.lastIndexOf('('); + const tag = text.substring(0, i).trim(); + const frequency = parseInt(text.substring(i + 1, text.length - 1)); + return [tag, frequency]; + }); } class ImageSelect { - /** @constant {string} */ #PREVIEW_DEFAULT = "Default"; - /** @constant {string} */ #PREVIEW_UPLOAD = "Upload"; - /** @constant {string} */ #PREVIEW_URL = "URL"; - /** @constant {string} */ #PREVIEW_NONE = "No Preview"; + /** @constant {string} */ #PREVIEW_DEFAULT = 'Default'; + /** @constant {string} */ #PREVIEW_UPLOAD = 'Upload'; + /** @constant {string} */ #PREVIEW_URL = 'URL'; + /** @constant {string} */ #PREVIEW_NONE = 'No Preview'; - elements = { - /** @type {HTMLDivElement} */ radioGroup: null, - /** @type {HTMLDivElement} */ radioButtons: null, - /** @type {HTMLDivElement} */ previews: null, + elements = { + /** @type {HTMLDivElement} */ radioGroup: null, + /** @type {HTMLDivElement} */ radioButtons: null, + /** @type {HTMLDivElement} */ previews: null, - /** @type {HTMLImageElement} */ defaultPreviewNoImage: null, - /** @type {HTMLDivElement} */ defaultPreviews: null, - /** @type {HTMLDivElement} */ defaultUrl: null, + /** @type {HTMLImageElement} */ defaultPreviewNoImage: null, + /** @type {HTMLDivElement} */ defaultPreviews: null, + /** @type {HTMLDivElement} */ defaultUrl: null, - /** @type {HTMLImageElement} */ customUrlPreview: null, - /** @type {HTMLInputElement} */ customUrl: null, - /** @type {HTMLDivElement} */ custom: null, + /** @type {HTMLImageElement} */ customUrlPreview: null, + /** @type {HTMLInputElement} */ customUrl: null, + /** @type {HTMLDivElement} */ custom: null, - /** @type {HTMLImageElement} */ uploadPreview: null, - /** @type {HTMLInputElement} */ uploadFile: null, - /** @type {HTMLDivElement} */ upload: null, + /** @type {HTMLImageElement} */ uploadPreview: null, + /** @type {HTMLInputElement} */ uploadFile: null, + /** @type {HTMLDivElement} */ upload: null, + }; + + /** @type {string} */ + #name = null; + + /** @returns {Promise| Promise } */ + async getImage() { + const name = this.#name; + const value = document.querySelector(`input[name="${name}"]:checked`).value; + const elements = this.elements; + switch (value) { + case this.#PREVIEW_DEFAULT: { + const children = elements.defaultPreviews.children; + const noImage = PREVIEW_NONE_URI; + let url = ''; + for (let i = 0; i < children.length; i++) { + const child = children[i]; + if ( + child.style.display !== 'none' && + child.nodeName === 'IMG' && + !child.src.endsWith(noImage) + ) { + url = child.src; + } + } + if (url.startsWith(Civitai.imageUrlPrefix())) { + url = await Civitai.getFullSizeImageUrl(url).catch((err) => { + console.warn(err); + return url; + }); + } + return url; + } + case this.#PREVIEW_URL: { + const value = elements.customUrl.value; + if (value.startsWith(Civitai.imagePostUrlPrefix())) { + try { + const imageInfo = await Civitai.getImageInfo(value); + const items = imageInfo['items']; + if (items.length === 0) { + console.warn('Civitai /api/v1/images returned 0 items.'); + return value; + } + return items[0]['url']; + } catch (error) { + console.error('Failed to get image info from Civitai!', error); + return value; + } + } + return value; + } + case this.#PREVIEW_UPLOAD: + return elements.uploadFile.files[0] ?? ''; + case this.#PREVIEW_NONE: + return PREVIEW_NONE_URI; + } + return ''; + } + + /** @returns {void} */ + resetModelInfoPreview() { + let noimage = this.elements.defaultUrl.dataset.noimage; + [ + this.elements.defaultPreviewNoImage, + this.elements.defaultPreviews, + this.elements.customUrlPreview, + this.elements.uploadPreview, + ].forEach((el) => { + el.style.display = 'none'; + if (this.elements.defaultPreviewNoImage !== el) { + if (el.nodeName === 'IMG') { + el.src = noimage; + } else { + el.children[0].src = noimage; + } + } else { + el.src = PREVIEW_NONE_URI; + } + }); + this.checkDefault(); + this.elements.uploadFile.value = ''; + this.elements.customUrl.value = ''; + this.elements.upload.style.display = 'none'; + this.elements.custom.style.display = 'none'; + } + + /** @returns {boolean} */ + defaultIsChecked() { + const children = this.elements.radioButtons.children; + for (let i = 0; i < children.length; i++) { + const child = children[i]; + const radioButton = child.children[0]; + if (radioButton.value === this.#PREVIEW_DEFAULT) { + return radioButton.checked; + } + } + return false; + } + + /** @returns {void} */ + checkDefault() { + const children = this.elements.radioButtons.children; + for (let i = 0; i < children.length; i++) { + const child = children[i]; + const radioButton = child.children[0]; + if (radioButton.value === this.#PREVIEW_DEFAULT) { + this.elements.defaultPreviews.style.display = 'block'; + radioButton.checked = true; + break; + } + } + } + + /** + * @param {1 | -1} step + */ + stepDefaultPreviews(step) { + const children = this.elements.defaultPreviews.children; + if (children.length === 0) { + return; + } + let currentIndex = -step; + for (let i = 0; i < children.length; i++) { + const previewImage = children[i]; + const display = previewImage.style.display; + if (display !== 'none') { + currentIndex = i; + } + previewImage.style.display = 'none'; + } + currentIndex = currentIndex + step; + if (currentIndex >= children.length) { + currentIndex = 0; + } else if (currentIndex < 0) { + currentIndex = children.length - 1; + } + children[currentIndex].style.display = 'block'; + } + + /** + * @param {string} radioGroupName - Should be unique for every radio group. + * @param {string[]|undefined} defaultPreviews + */ + constructor(radioGroupName, defaultPreviews = []) { + if ( + (defaultPreviews === undefined) | + (defaultPreviews === null) | + (defaultPreviews.length === 0) + ) { + defaultPreviews = [PREVIEW_NONE_URI]; + } + this.#name = radioGroupName; + + const el_defaultUri = $el('div', { + $: (el) => (this.elements.defaultUrl = el), + style: { display: 'none' }, + 'data-noimage': PREVIEW_NONE_URI, + }); + + const el_defaultPreviewNoImage = $el('img', { + $: (el) => (this.elements.defaultPreviewNoImage = el), + loading: + 'lazy' /* `loading` BEFORE `src`; Known bug in Firefox 124.0.2 and Safari for iOS 17.4.1 (https://stackoverflow.com/a/76252772) */, + src: PREVIEW_NONE_URI, + style: { display: 'none' }, + }); + + const el_defaultPreviews = $el( + 'div', + { + $: (el) => (this.elements.defaultPreviews = el), + style: { + width: '100%', + height: '100%', + }, + }, + (() => { + const imgs = defaultPreviews.map((url) => { + return $el('img', { + loading: + 'lazy' /* `loading` BEFORE `src`; Known bug in Firefox 124.0.2 and Safari for iOS 17.4.1 (https://stackoverflow.com/a/76252772) */, + src: url, + style: { display: 'none' }, + onerror: (e) => { + e.target.src = el_defaultUri.dataset.noimage ?? PREVIEW_NONE_URI; + }, + }); + }); + if (imgs.length > 0) { + imgs[0].style.display = 'block'; + } + return imgs; + })(), + ); + + const el_uploadPreview = $el('img', { + $: (el) => (this.elements.uploadPreview = el), + src: PREVIEW_NONE_URI, + style: { display: 'none' }, + onerror: (e) => { + e.target.src = el_defaultUri.dataset.noimage ?? PREVIEW_NONE_URI; + }, + }); + const el_uploadFile = $el('input', { + $: (el) => (this.elements.uploadFile = el), + type: 'file', + name: 'upload preview image', + accept: IMAGE_EXTENSIONS.join(', '), + onchange: (e) => { + const file = e.target.files[0]; + if (file) { + el_uploadPreview.src = URL.createObjectURL(file); + } else { + el_uploadPreview.src = el_defaultUri.dataset.noimage; + } + }, + }); + const el_upload = $el( + 'div.row.tab-header-flex-block', + { + $: (el) => (this.elements.upload = el), + style: { display: 'none' }, + }, + [el_uploadFile], + ); + + /** + * @param {string} url + * @returns {Promise } + */ + const getCustomPreviewUrl = async (url) => { + if (url.startsWith(Civitai.imagePostUrlPrefix())) { + return await Civitai.getImageInfo(url) + .then((imageInfo) => { + const items = imageInfo['items']; + if (items.length > 0) { + return items[0]['url']; + } else { + console.warn('Civitai /api/v1/images returned 0 items.'); + return url; + } + }) + .catch((error) => { + console.error('Failed to get image info from Civitai!', error); + return url; + }); + } else { + return url; + } }; - /** @type {string} */ - #name = null; + const el_customUrlPreview = $el('img', { + $: (el) => (this.elements.customUrlPreview = el), + src: PREVIEW_NONE_URI, + style: { display: 'none' }, + onerror: (e) => { + e.target.src = el_defaultUri.dataset.noimage ?? PREVIEW_NONE_URI; + }, + }); + const el_customUrl = $el('input.search-text-area', { + $: (el) => (this.elements.customUrl = el), + type: 'text', + name: 'custom preview image url', + autocomplete: 'off', + placeholder: 'https://custom-image-preview.png', + onkeydown: async (e) => { + if (e.key === 'Enter') { + const value = e.target.value; + el_customUrlPreview.src = await getCustomPreviewUrl(value); + e.stopPropagation(); + e.target.blur(); + } + }, + }); + const el_custom = $el( + 'div.row.tab-header-flex-block', + { + $: (el) => (this.elements.custom = el), + style: { display: 'none' }, + }, + [ + el_customUrl, + new ComfyButton({ + icon: 'magnify', + tooltip: 'Search models', + classList: 'comfyui-button icon-button', + action: async (e) => { + const [button, icon, span] = comfyButtonDisambiguate(e.target); + button.disabled = true; + const value = el_customUrl.value; + el_customUrlPreview.src = await getCustomPreviewUrl(value); + e.stopPropagation(); + el_customUrl.blur(); + button.disabled = false; + }, + }).element, + ], + ); + + const el_previewButtons = $el( + 'div.model-preview-overlay', + { + style: { + display: el_defaultPreviews.children.length > 1 ? 'block' : 'none', + }, + }, + [ + new ComfyButton({ + icon: 'arrow-left', + tooltip: 'Previous image', + classList: 'comfyui-button icon-button model-preview-button-left', + action: () => this.stepDefaultPreviews(-1), + }).element, + new ComfyButton({ + icon: 'arrow-right', + tooltip: 'Next image', + classList: 'comfyui-button icon-button model-preview-button-right', + action: () => this.stepDefaultPreviews(1), + }).element, + ], + ); + const el_previews = $el( + 'div.item', + { + $: (el) => (this.elements.previews = el), + }, + [ + $el( + 'div', + { + style: { + width: '100%', + height: '100%', + }, + }, + [ + el_defaultPreviewNoImage, + el_defaultPreviews, + el_customUrlPreview, + el_uploadPreview, + ], + ), + el_previewButtons, + ], + ); + + const el_radioButtons = $radioGroup({ + name: radioGroupName, + onchange: (value) => { + el_custom.style.display = 'none'; + el_upload.style.display = 'none'; + + el_defaultPreviews.style.display = 'none'; + el_previewButtons.style.display = 'none'; + + el_defaultPreviewNoImage.style.display = 'none'; + el_uploadPreview.style.display = 'none'; + el_customUrlPreview.style.display = 'none'; - /** @returns {Promise | Promise } */ - async getImage() { - const name = this.#name; - const value = document.querySelector(`input[name="${name}"]:checked`).value; - const elements = this.elements; switch (value) { - case this.#PREVIEW_DEFAULT: { - const children = elements.defaultPreviews.children; - const noImage = PREVIEW_NONE_URI; - let url = ""; - for (let i = 0; i < children.length; i++) { - const child = children[i]; - if (child.style.display !== "none" && - child.nodeName === "IMG" && - !child.src.endsWith(noImage) - ) { - url = child.src; - } - } - if (url.startsWith(Civitai.imageUrlPrefix())) { - url = await Civitai.getFullSizeImageUrl(url).catch((err) => { - console.warn(err); - return url; - }); - } - return url; - } - case this.#PREVIEW_URL: { - const value = elements.customUrl.value; - if (value.startsWith(Civitai.imagePostUrlPrefix())) { - try { - const imageInfo = await Civitai.getImageInfo(value); - const items = imageInfo["items"]; - if (items.length === 0) { - console.warn("Civitai /api/v1/images returned 0 items."); - return value; - } - return items[0]["url"]; - } - catch (error) { - console.error("Failed to get image info from Civitai!", error); - return value; - } - } - return value; - } - case this.#PREVIEW_UPLOAD: - return elements.uploadFile.files[0] ?? ""; - case this.#PREVIEW_NONE: - return PREVIEW_NONE_URI; + case this.#PREVIEW_DEFAULT: + el_defaultPreviews.style.display = 'block'; + el_previewButtons.style.display = + el_defaultPreviews.children.length > 1 ? 'block' : 'none'; + break; + case this.#PREVIEW_UPLOAD: + el_upload.style.display = 'flex'; + el_uploadPreview.style.display = 'block'; + break; + case this.#PREVIEW_URL: + el_custom.style.display = 'flex'; + el_customUrlPreview.style.display = 'block'; + break; + case this.#PREVIEW_NONE: + default: + el_defaultPreviewNoImage.style.display = 'block'; + break; } - return ""; + }, + options: [ + this.#PREVIEW_DEFAULT, + this.#PREVIEW_URL, + this.#PREVIEW_UPLOAD, + this.#PREVIEW_NONE, + ].map((value) => { + return { value: value }; + }), + }); + this.elements.radioButtons = el_radioButtons; + + const children = el_radioButtons.children; + for (let i = 0; i < children.length; i++) { + const child = children[i]; + const radioButton = child.children[0]; + if (radioButton.value === this.#PREVIEW_DEFAULT) { + radioButton.checked = true; + break; + } } - /** @returns {void} */ - resetModelInfoPreview() { - let noimage = this.elements.defaultUrl.dataset.noimage; - [ - this.elements.defaultPreviewNoImage, - this.elements.defaultPreviews, - this.elements.customUrlPreview, - this.elements.uploadPreview, - ].forEach((el) => { - el.style.display = "none"; - if (this.elements.defaultPreviewNoImage !== el) { - if (el.nodeName === "IMG") { - el.src = noimage; - } - else { - el.children[0].src = noimage; - } - } - else { - el.src = PREVIEW_NONE_URI; - } - }); - this.checkDefault(); - this.elements.uploadFile.value = ""; - this.elements.customUrl.value = ""; - this.elements.upload.style.display = "none"; - this.elements.custom.style.display = "none"; - } - - /** @returns {boolean} */ - defaultIsChecked() { - const children = this.elements.radioButtons.children; - for (let i = 0; i < children.length; i++) { - const child = children[i]; - const radioButton = child.children[0]; - if (radioButton.value === this.#PREVIEW_DEFAULT) { - return radioButton.checked; - } - }; - return false; - } - - /** @returns {void} */ - checkDefault() { - const children = this.elements.radioButtons.children; - for (let i = 0; i < children.length; i++) { - const child = children[i]; - const radioButton = child.children[0]; - if (radioButton.value === this.#PREVIEW_DEFAULT) { - this.elements.defaultPreviews.style.display = "block"; - radioButton.checked = true; - break; - } - }; - } - - /** - * @param {1 | -1} step - */ - stepDefaultPreviews(step) { - const children = this.elements.defaultPreviews.children; - if (children.length === 0) { - return; - } - let currentIndex = -step; - for (let i = 0; i < children.length; i++) { - const previewImage = children[i]; - const display = previewImage.style.display; - if (display !== "none") { - currentIndex = i; - } - previewImage.style.display = "none"; - } - currentIndex = currentIndex + step; - if (currentIndex >= children.length) { currentIndex = 0; } - else if (currentIndex < 0) { currentIndex = children.length - 1; } - children[currentIndex].style.display = "block"; - } - - /** - * @param {string} radioGroupName - Should be unique for every radio group. - * @param {string[]|undefined} defaultPreviews - */ - constructor(radioGroupName, defaultPreviews = []) { - if (defaultPreviews === undefined | defaultPreviews === null | defaultPreviews.length === 0) { - defaultPreviews = [PREVIEW_NONE_URI]; - } - this.#name = radioGroupName; - - const el_defaultUri = $el("div", { - $: (el) => (this.elements.defaultUrl = el), - style: { display: "none" }, - "data-noimage": PREVIEW_NONE_URI, - }); - - const el_defaultPreviewNoImage = $el("img", { - $: (el) => (this.elements.defaultPreviewNoImage = el), - loading: "lazy", /* `loading` BEFORE `src`; Known bug in Firefox 124.0.2 and Safari for iOS 17.4.1 (https://stackoverflow.com/a/76252772) */ - src: PREVIEW_NONE_URI, - style: { display: "none" }, - }); - - const el_defaultPreviews = $el("div", { - $: (el) => (this.elements.defaultPreviews = el), - style: { - width: "100%", - height: "100%", - }, - }, (() => { - const imgs = defaultPreviews.map((url) => { - return $el("img", { - loading: "lazy", /* `loading` BEFORE `src`; Known bug in Firefox 124.0.2 and Safari for iOS 17.4.1 (https://stackoverflow.com/a/76252772) */ - src: url, - style: { display: "none" }, - onerror: (e) => { - e.target.src = el_defaultUri.dataset.noimage ?? PREVIEW_NONE_URI; - }, - }); - }); - if (imgs.length > 0) { - imgs[0].style.display = "block"; - } - return imgs; - })()); - - const el_uploadPreview = $el("img", { - $: (el) => (this.elements.uploadPreview = el), - src: PREVIEW_NONE_URI, - style: { display : "none" }, - onerror: (e) => { - e.target.src = el_defaultUri.dataset.noimage ?? PREVIEW_NONE_URI; - }, - }); - const el_uploadFile = $el("input", { - $: (el) => (this.elements.uploadFile = el), - type: "file", - name: "upload preview image", - accept: IMAGE_EXTENSIONS.join(", "), - onchange: (e) => { - const file = e.target.files[0]; - if (file) { - el_uploadPreview.src = URL.createObjectURL(file); - } - else { - el_uploadPreview.src = el_defaultUri.dataset.noimage; - } - }, - }); - const el_upload = $el("div.row.tab-header-flex-block", { - $: (el) => (this.elements.upload = el), - style: { display: "none" }, - }, [ - el_uploadFile, - ]); - - /** - * @param {string} url - * @returns {Promise } - */ - const getCustomPreviewUrl = async (url) => { - if (url.startsWith(Civitai.imagePostUrlPrefix())) { - return await Civitai.getImageInfo(url) - .then((imageInfo) => { - const items = imageInfo["items"]; - if (items.length > 0) { - return items[0]["url"]; - } - else { - console.warn("Civitai /api/v1/images returned 0 items."); - return url; - } - }) - .catch((error) => { - console.error("Failed to get image info from Civitai!", error); - return url; - }); - } - else { - return url; - } - }; - - const el_customUrlPreview = $el("img", { - $: (el) => (this.elements.customUrlPreview = el), - src: PREVIEW_NONE_URI, - style: { display: "none" }, - onerror: (e) => { - e.target.src = el_defaultUri.dataset.noimage ?? PREVIEW_NONE_URI; - }, - }); - const el_customUrl = $el("input.search-text-area", { - $: (el) => (this.elements.customUrl = el), - type: "text", - name: "custom preview image url", - autocomplete: "off", - placeholder: "https://custom-image-preview.png", - onkeydown: async (e) => { - if (e.key === "Enter") { - const value = e.target.value; - el_customUrlPreview.src = await getCustomPreviewUrl(value); - e.stopPropagation(); - e.target.blur(); - } - }, - }); - const el_custom = $el("div.row.tab-header-flex-block", { - $: (el) => (this.elements.custom = el), - style: { display: "none" }, - }, [ - el_customUrl, - new ComfyButton({ - icon: "magnify", - tooltip: "Search models", - classList: "comfyui-button icon-button", - action: async (e) => { - const [button, icon, span] = comfyButtonDisambiguate(e.target); - button.disabled = true; - const value = el_customUrl.value; - el_customUrlPreview.src = await getCustomPreviewUrl(value); - e.stopPropagation(); - el_customUrl.blur(); - button.disabled = false; - }, - }).element, - ]); - - const el_previewButtons = $el("div.model-preview-overlay", { - style: { - display: el_defaultPreviews.children.length > 1 ? "block" : "none", - }, - }, [ - new ComfyButton({ - icon: "arrow-left", - tooltip: "Previous image", - classList: "comfyui-button icon-button model-preview-button-left", - action: () => this.stepDefaultPreviews(-1), - }).element, - new ComfyButton({ - icon: "arrow-right", - tooltip: "Next image", - classList: "comfyui-button icon-button model-preview-button-right", - action: () => this.stepDefaultPreviews(1), - }).element, - ]); - const el_previews = $el("div.item", { - $: (el) => (this.elements.previews = el), - }, [ - $el("div", { - style: { - "width": "100%", - "height": "100%", - }, - }, - [ - el_defaultPreviewNoImage, - el_defaultPreviews, - el_customUrlPreview, - el_uploadPreview, - ], - ), - el_previewButtons, - ]); - - const el_radioButtons = $radioGroup({ - name: radioGroupName, - onchange: (value) => { - el_custom.style.display = "none"; - el_upload.style.display = "none"; - - el_defaultPreviews.style.display = "none"; - el_previewButtons.style.display = "none"; - - el_defaultPreviewNoImage.style.display = "none"; - el_uploadPreview.style.display = "none"; - el_customUrlPreview.style.display = "none"; - - switch (value) { - case this.#PREVIEW_DEFAULT: - el_defaultPreviews.style.display = "block"; - el_previewButtons.style.display = el_defaultPreviews.children.length > 1 ? "block" : "none"; - break; - case this.#PREVIEW_UPLOAD: - el_upload.style.display = "flex"; - el_uploadPreview.style.display = "block"; - break; - case this.#PREVIEW_URL: - el_custom.style.display = "flex"; - el_customUrlPreview.style.display = "block"; - break; - case this.#PREVIEW_NONE: - default: - el_defaultPreviewNoImage.style.display = "block"; - break; - } - }, - options: [ - this.#PREVIEW_DEFAULT, - this.#PREVIEW_URL, - this.#PREVIEW_UPLOAD, - this.#PREVIEW_NONE, - ].map((value) => { - return { value: value, }; - }), - }); - this.elements.radioButtons = el_radioButtons; - - const children = el_radioButtons.children; - for (let i = 0; i < children.length; i++) { - const child = children[i]; - const radioButton = child.children[0]; - if (radioButton.value === this.#PREVIEW_DEFAULT) { - radioButton.checked = true; - break; - } - }; - - const el_radioGroup = $el("div.model-preview-select-radio-container", { - $: (el) => (this.elements.radioGroup = el), - }, [ - $el("div.row.tab-header-flex-block", [el_radioButtons]), - $el("div.model-preview-select-radio-inputs", [ - el_custom, - el_upload, - ]), - ]); - } + const el_radioGroup = $el( + 'div.model-preview-select-radio-container', + { + $: (el) => (this.elements.radioGroup = el), + }, + [ + $el('div.row.tab-header-flex-block', [el_radioButtons]), + $el('div.model-preview-select-radio-inputs', [el_custom, el_upload]), + ], + ); + } } /** @@ -990,359 +1052,376 @@ class ImageSelect { */ class ModelDirectories { - /** @type {DirectoryItem[]} */ - data = []; + /** @type {DirectoryItem[]} */ + data = []; - /** - * @returns {number} - */ - rootIndex() { - return 0; + /** + * @returns {number} + */ + rootIndex() { + return 0; + } + + /** + * @param {any} index + * @returns {boolean} + */ + isValidIndex(index) { + return typeof index === 'number' && 0 <= index && index < this.data.length; + } + + /** + * @param {number} index + * @returns {DirectoryItem} + */ + getItem(index) { + if (!this.isValidIndex(index)) { + throw new Error(`Index '${index}' is not valid!`); } + return this.data[index]; + } - /** - * @param {any} index - * @returns {boolean} - */ - isValidIndex(index) { - return typeof index === "number" && 0 <= index && index < this.data.length; + /** + * @param {DirectoryItem | number} item + * @returns {boolean} + */ + isDirectory(item) { + if (typeof item === 'number') { + item = this.getItem(item); } + const childCount = item.childCount; + return childCount !== undefined && childCount != null; + } - /** - * @param {number} index - * @returns {DirectoryItem} - */ - getItem(index) { - if (!this.isValidIndex(index)) { - throw new Error(`Index '${index}' is not valid!`); - } - return this.data[index]; + /** + * @param {DirectoryItem | number} item + * @returns {boolean} + */ + isEmpty(item) { + if (typeof item === 'number') { + item = this.getItem(item); } - - /** - * @param {DirectoryItem | number} item - * @returns {boolean} - */ - isDirectory(item) { - if (typeof item === "number") { - item = this.getItem(item); - } - const childCount = item.childCount; - return childCount !== undefined && childCount != null; + if (!this.isDirectory(item)) { + throw new Error('Item is not a directory!'); } + return item.childCount === 0; + } - /** - * @param {DirectoryItem | number} item - * @returns {boolean} - */ - isEmpty(item) { - if (typeof item === "number") { - item = this.getItem(item); - } - if (!this.isDirectory(item)) { - throw new Error("Item is not a directory!"); - } - return item.childCount === 0; + /** + * Returns a slice of children from the directory list. + * @param {DirectoryItem | number} item + * @returns {DirectoryItem[]} + */ + getChildren(item) { + if (typeof item === 'number') { + item = this.getItem(item); + if (!this.isDirectory(item)) { + throw new Error('Item is not a directory!'); + } + } else if (!this.isDirectory(item)) { + throw new Error('Item is not a directory!'); } + const count = item.childCount; + const index = item.childIndex; + return this.data.slice(index, index + count); + } - /** - * Returns a slice of children from the directory list. - * @param {DirectoryItem | number} item - * @returns {DirectoryItem[]} - */ - getChildren(item) { - if (typeof item === "number") { - item = this.getItem(item); - if (!this.isDirectory(item)) { - throw new Error("Item is not a directory!"); - } - } - else if (!this.isDirectory(item)) { - throw new Error("Item is not a directory!"); - } - const count = item.childCount; - const index = item.childIndex; - return this.data.slice(index, index + count); + /** + * Returns index of child in parent directory. Returns -1 if DNE. + * @param {DirectoryItem | number} parent + * @param {string} name + * @returns {number} + */ + findChildIndex(parent, name) { + const item = this.getItem(parent); + if (!this.isDirectory(item)) { + throw new Error('Item is not a directory!'); } + const start = item.childIndex; + const children = this.getChildren(item); + const index = children.findIndex((item) => { + return item.name === name; + }); + if (index === -1) { + return -1; + } + return index + start; + } - /** - * Returns index of child in parent directory. Returns -1 if DNE. - * @param {DirectoryItem | number} parent - * @param {string} name - * @returns {number} - */ - findChildIndex(parent, name) { - const item = this.getItem(parent); - if (!this.isDirectory(item)) { - throw new Error("Item is not a directory!"); - } - const start = item.childIndex; - const children = this.getChildren(item); - const index = children.findIndex((item) => { - return item.name === name; + /** + * Returns a list of matching search results and valid path. + * @param {string} filter + * @param {string} searchSeparator + * @param {boolean} directoriesOnly + * @returns {[string[], string]} + */ + search(filter, searchSeparator, directoriesOnly) { + let cwd = this.rootIndex(); + let indexLastWord = 1; + while (true) { + const indexNextWord = filter.indexOf(searchSeparator, indexLastWord); + if (indexNextWord === -1) { + // end of filter + break; + } + + const item = this.getItem(cwd); + if (!this.isDirectory(item) || this.isEmpty(item)) { + break; + } + + const word = filter.substring(indexLastWord, indexNextWord); + cwd = this.findChildIndex(cwd, word); + if (!this.isValidIndex(cwd)) { + return [[], '']; + } + indexLastWord = indexNextWord + 1; + } + //const cwdPath = filter.substring(0, indexLastWord); + + const lastWord = filter.substring(indexLastWord); + const children = this.getChildren(cwd); + if (directoriesOnly) { + let indexPathEnd = indexLastWord; + const results = children + .filter((child) => { + return this.isDirectory(child) && child.name.startsWith(lastWord); + }) + .map((directory) => { + const children = this.getChildren(directory); + const hasChildren = children.some((item) => { + return this.isDirectory(item); + }); + const suffix = hasChildren ? searchSeparator : ''; + //const suffix = searchSeparator; + if (directory.name == lastWord) { + indexPathEnd += searchSeparator.length + directory.name.length + 1; + } + return directory.name + suffix; }); - if (index === -1) { - return -1; - } - return index + start; - } - - /** - * Returns a list of matching search results and valid path. - * @param {string} filter - * @param {string} searchSeparator - * @param {boolean} directoriesOnly - * @returns {[string[], string]} - */ - search(filter, searchSeparator, directoriesOnly) { - let cwd = this.rootIndex(); - let indexLastWord = 1; - while (true) { - const indexNextWord = filter.indexOf(searchSeparator, indexLastWord); - if (indexNextWord === -1) { - // end of filter - break; - } - - const item = this.getItem(cwd); - if (!this.isDirectory(item) || this.isEmpty(item)) { - break; - } - - const word = filter.substring(indexLastWord, indexNextWord); - cwd = this.findChildIndex(cwd, word); - if (!this.isValidIndex(cwd)) { - return [[], ""]; - } - indexLastWord = indexNextWord + 1; - } - //const cwdPath = filter.substring(0, indexLastWord); - - const lastWord = filter.substring(indexLastWord); - const children = this.getChildren(cwd); - if (directoriesOnly) { - let indexPathEnd = indexLastWord; - const results = children.filter((child) => { - return ( - this.isDirectory(child) && - child.name.startsWith(lastWord) - ); - }).map((directory) => { - const children = this.getChildren(directory); - const hasChildren = children.some((item) => { - return this.isDirectory(item); - }); - const suffix = hasChildren ? searchSeparator : ""; - //const suffix = searchSeparator; - if (directory.name == lastWord) { - indexPathEnd += searchSeparator.length + directory.name.length + 1; - } - return directory.name + suffix; - }); - const path = filter.substring(0, indexPathEnd); - return [results, path]; - } - else { - let indexPathEnd = indexLastWord; - const results = children.filter((child) => { - return child.name.startsWith(lastWord); - }).map((item) => { - const isDir = this.isDirectory(item); - const isNonEmptyDirectory = isDir && item.childCount > 0; - const suffix = isNonEmptyDirectory ? searchSeparator : ""; - //const suffix = isDir ? searchSeparator : ""; - if (!isDir && item.name == lastWord) { - indexPathEnd += searchSeparator.length + item.name.length + 1; - } - return item.name + suffix; - }); - const path = filter.substring(0, indexPathEnd); - return [results, path]; - } + const path = filter.substring(0, indexPathEnd); + return [results, path]; + } else { + let indexPathEnd = indexLastWord; + const results = children + .filter((child) => { + return child.name.startsWith(lastWord); + }) + .map((item) => { + const isDir = this.isDirectory(item); + const isNonEmptyDirectory = isDir && item.childCount > 0; + const suffix = isNonEmptyDirectory ? searchSeparator : ''; + //const suffix = isDir ? searchSeparator : ""; + if (!isDir && item.name == lastWord) { + indexPathEnd += searchSeparator.length + item.name.length + 1; + } + return item.name + suffix; + }); + const path = filter.substring(0, indexPathEnd); + return [results, path]; } + } } -const DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS = "search-directory-dropdown-key-selected"; -const DROPDOWN_DIRECTORY_SELECTION_MOUSE_CLASS = "search-directory-dropdown-mouse-selected"; - +const DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS = + 'search-directory-dropdown-key-selected'; +const DROPDOWN_DIRECTORY_SELECTION_MOUSE_CLASS = + 'search-directory-dropdown-mouse-selected'; class ModelData { - /** @type {string} */ - searchSeparator = "/"; // TODO: other client or server code may be assuming this to always be "/" + /** @type {string} */ + searchSeparator = '/'; // TODO: other client or server code may be assuming this to always be "/" - /** @type {string} */ - systemSeparator = null; + /** @type {string} */ + systemSeparator = null; - /** @type {Object} */ - models = {}; + /** @type {Object} */ + models = {}; - /** @type {ModelDirectories} */ - directories = null; + /** @type {ModelDirectories} */ + directories = null; - constructor() { - this.directories = new ModelDirectories(); - } + constructor() { + this.directories = new ModelDirectories(); + } } class DirectoryDropdown { + /** @type {HTMLDivElement} */ + element = null; + + /** @type {Boolean} */ + showDirectoriesOnly = false; + + /** @type {HTMLInputElement} */ + #input = null; + + /** @type {() => string} */ + #getModelType = null; + + /** @type {ModelData} */ + #modelData = null; // READ ONLY + + /** @type {() => void} */ + #updateCallback = null; + + /** @type {() => Promise } */ + #submitCallback = null; + + /** @type {string} */ + #deepestPreviousPath = '/'; + + /** @type {Any} */ + #touchSelectionStart = null; + + /** @type {() => Boolean} */ + #isDynamicSearch = () => { + return false; + }; + + /** + * @param {ModelData} modelData + * @param {HTMLInputElement} input + * @param {Boolean} [showDirectoriesOnly=false] + * @param {() => string} [getModelType= () => { return ""; }] + * @param {() => void} [updateCallback= () => {}] + * @param {() => Promise } [submitCallback= () => {}] + * @param {() => Boolean} [isDynamicSearch= () => { return false; }] + */ + constructor( + modelData, + input, + showDirectoriesOnly = false, + getModelType = () => { + return ''; + }, + updateCallback = () => {}, + submitCallback = () => {}, + isDynamicSearch = () => { + return false; + }, + ) { /** @type {HTMLDivElement} */ - element = null; + const dropdown = $el('div.search-directory-dropdown', { + style: { + display: 'none', + }, + }); + this.element = dropdown; + this.#modelData = modelData; + this.#input = input; + this.#getModelType = getModelType; + this.#updateCallback = updateCallback; + this.#submitCallback = submitCallback; + this.showDirectoriesOnly = showDirectoriesOnly; + this.#isDynamicSearch = isDynamicSearch; - /** @type {Boolean} */ - showDirectoriesOnly = false; - - /** @type {HTMLInputElement} */ - #input = null; - - /** @type {() => string} */ - #getModelType = null; - - /** @type {ModelData} */ - #modelData = null; // READ ONLY - - /** @type {() => void} */ - #updateCallback = null; - - /** @type {() => Promise } */ - #submitCallback = null; - - /** @type {string} */ - #deepestPreviousPath = "/"; - - /** @type {Any} */ - #touchSelectionStart = null; - - /** @type {() => Boolean} */ - #isDynamicSearch = () => { return false; }; - - /** - * @param {ModelData} modelData - * @param {HTMLInputElement} input - * @param {Boolean} [showDirectoriesOnly=false] - * @param {() => string} [getModelType= () => { return ""; }] - * @param {() => void} [updateCallback= () => {}] - * @param {() => Promise } [submitCallback= () => {}] - * @param {() => Boolean} [isDynamicSearch= () => { return false; }] - */ - constructor(modelData, input, showDirectoriesOnly = false, getModelType = () => { return ""; }, updateCallback = () => {}, submitCallback = () => {}, isDynamicSearch = () => { return false; }) { - /** @type {HTMLDivElement} */ - const dropdown = $el("div.search-directory-dropdown", { - style: { - display: "none", - }, - }); - this.element = dropdown; - this.#modelData = modelData; - this.#input = input; - this.#getModelType = getModelType; - this.#updateCallback = updateCallback; - this.#submitCallback = submitCallback; - this.showDirectoriesOnly = showDirectoriesOnly; - this.#isDynamicSearch = isDynamicSearch; - - input.addEventListener("input", async(e) => { + input.addEventListener('input', async (e) => { + const path = this.#updateOptions(); + if (path !== undefined) { + this.#restoreSelectedOption(path); + this.#updateDeepestPath(path); + } + updateCallback(); + if (isDynamicSearch()) { + await submitCallback(); + } + }); + input.addEventListener('focus', () => { + const path = this.#updateOptions(); + if (path !== undefined) { + this.#deepestPreviousPath = path; + this.#restoreSelectedOption(path); + } + updateCallback(); + }); + input.addEventListener('blur', () => { + dropdown.style.display = 'none'; + }); + input.addEventListener('keydown', async (e) => { + const options = dropdown.children; + let iSelection; + for (iSelection = 0; iSelection < options.length; iSelection++) { + const selection = options[iSelection]; + if ( + selection.classList.contains(DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS) + ) { + break; + } + } + if (e.key === 'Escape') { + e.stopPropagation(); + if (iSelection < options.length) { + const selection = options[iSelection]; + selection.classList.remove(DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS); + } else { + e.target.blur(); + } + } else if (e.key === 'ArrowRight' && dropdown.style.display !== 'none') { + const selection = options[iSelection]; + if (selection !== undefined && selection !== null) { + e.stopPropagation(); + e.preventDefault(); // prevent cursor move + const input = e.target; + const searchSeparator = modelData.searchSeparator; + DirectoryDropdown.selectionToInput( + input, + selection, + searchSeparator, + DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS, + ); + const path = this.#updateOptions(); + if (path !== undefined) { + this.#restoreSelectedOption(path); + this.#updateDeepestPath(path); + } + updateCallback(); + if (isDynamicSearch()) { + await submitCallback(); + } + } + } else if (e.key === 'ArrowLeft' && dropdown.style.display !== 'none') { + const input = e.target; + const oldFilterText = input.value; + const searchSeparator = modelData.searchSeparator; + const iSep = oldFilterText.lastIndexOf( + searchSeparator, + oldFilterText.length - 2, + ); + const newFilterText = oldFilterText.substring(0, iSep + 1); + if (oldFilterText !== newFilterText) { + const delta = oldFilterText.substring(iSep + 1); + let isMatch = delta[delta.length - 1] === searchSeparator; + if (!isMatch) { + const options = dropdown.children; + for (let i = 0; i < options.length; i++) { + const option = options[i]; + if (option.innerText.startsWith(delta)) { + isMatch = true; + break; + } + } + } + if (isMatch) { + e.stopPropagation(); + e.preventDefault(); // prevent cursor move + input.value = newFilterText; const path = this.#updateOptions(); if (path !== undefined) { - this.#restoreSelectedOption(path); - this.#updateDeepestPath(path); + this.#restoreSelectedOption(path); + this.#updateDeepestPath(path); } updateCallback(); if (isDynamicSearch()) { - await submitCallback(); + await submitCallback(); } - }); - input.addEventListener("focus", () => { - const path = this.#updateOptions(); - if (path !== undefined) { - this.#deepestPreviousPath = path; - this.#restoreSelectedOption(path); - } - updateCallback(); - }); - input.addEventListener("blur", () => { dropdown.style.display = "none"; }); - input.addEventListener("keydown", async(e) => { - const options = dropdown.children; - let iSelection; - for (iSelection = 0; iSelection < options.length; iSelection++) { - const selection = options[iSelection]; - if (selection.classList.contains(DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS)) { - break; - } - } - if (e.key === "Escape") { - e.stopPropagation(); - if (iSelection < options.length) { - const selection = options[iSelection]; - selection.classList.remove(DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS); - } - else { - e.target.blur(); - } - } - else if (e.key === "ArrowRight" && dropdown.style.display !== "none") { - const selection = options[iSelection]; - if (selection !== undefined && selection !== null) { - e.stopPropagation(); - e.preventDefault(); // prevent cursor move - const input = e.target; - const searchSeparator = modelData.searchSeparator; - DirectoryDropdown.selectionToInput( - input, - selection, - searchSeparator, - DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS - ); - const path = this.#updateOptions(); - if (path !== undefined) { - this.#restoreSelectedOption(path); - this.#updateDeepestPath(path); - } - updateCallback(); - if (isDynamicSearch()) { - await submitCallback(); - } - } - } - else if (e.key === "ArrowLeft" && dropdown.style.display !== "none") { - const input = e.target; - const oldFilterText = input.value; - const searchSeparator = modelData.searchSeparator; - const iSep = oldFilterText.lastIndexOf(searchSeparator, oldFilterText.length - 2); - const newFilterText = oldFilterText.substring(0, iSep + 1); - if (oldFilterText !== newFilterText) { - const delta = oldFilterText.substring(iSep + 1); - let isMatch = delta[delta.length-1] === searchSeparator; - if (!isMatch) { - const options = dropdown.children; - for (let i = 0; i < options.length; i++) { - const option = options[i]; - if (option.innerText.startsWith(delta)) { - isMatch = true; - break; - } - } - } - if (isMatch) { - e.stopPropagation(); - e.preventDefault(); // prevent cursor move - input.value = newFilterText; - const path = this.#updateOptions(); - if (path !== undefined) { - this.#restoreSelectedOption(path); - this.#updateDeepestPath(path); - } - updateCallback(); - if (isDynamicSearch()) { - await submitCallback(); - } - } - } - } - else if (e.key === "Enter") { - e.stopPropagation(); - const input = e.target; - if (dropdown.style.display !== "none") { - /* + } + } + } else if (e.key === 'Enter') { + e.stopPropagation(); + const input = e.target; + if (dropdown.style.display !== 'none') { + /* // This is WAY too confusing. const selection = options[iSelection]; if (selection !== undefined && selection !== null) { @@ -1359,2898 +1438,3305 @@ class DirectoryDropdown { updateCallback(); } */ - } - await submitCallback(); - input.blur(); - } - else if ((e.key === "ArrowDown" || e.key === "ArrowUp") && dropdown.style.display !== "none") { - e.stopPropagation(); - e.preventDefault(); // prevent cursor move - let iNext = options.length; - if (iSelection < options.length) { - const selection = options[iSelection]; - selection.classList.remove(DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS); - const delta = e.key === "ArrowDown" ? 1 : -1; - iNext = iSelection + delta; - if (iNext < 0) { - iNext = options.length - 1; - } - else if (iNext >= options.length) { - iNext = 0; - } - const selectionNext = options[iNext]; - selectionNext.classList.add(DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS); - } - else if (iSelection === options.length) { // none - iNext = e.key === "ArrowDown" ? 0 : options.length-1; - const selection = options[iNext]; - selection.classList.add(DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS); - } - if (0 <= iNext && iNext < options.length) { - DirectoryDropdown.#clampDropdownScrollTop(dropdown, options[iNext]); - } - else { - dropdown.scrollTop = 0; - const options = dropdown.children; - for (iSelection = 0; iSelection < options.length; iSelection++) { - const selection = options[iSelection]; - if (selection.classList.contains(DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS)) { - selection.classList.remove(DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS); - } - } - } - } - }, - ); - } - - /** - * @param {HTMLInputElement} input - * @param {HTMLParagraphElement | undefined | null} selection - * @param {String} searchSeparator - * @param {String} className - * @returns {boolean} changed - */ - static selectionToInput(input, selection, searchSeparator, className) { - selection.classList.remove(className); - const selectedText = selection.innerText; - const oldFilterText = input.value; - const iSep = oldFilterText.lastIndexOf(searchSeparator); - const previousPath = oldFilterText.substring(0, iSep + 1); - const newFilterText = previousPath + selectedText; - input.value = newFilterText; - return newFilterText !== oldFilterText; - } - - /** - * @param {string} path - */ - #updateDeepestPath = (path) => { - const deepestPath = this.#deepestPreviousPath; - if (path.length > deepestPath.length || !deepestPath.startsWith(path)) { - this.#deepestPreviousPath = path; } + await submitCallback(); + input.blur(); + } else if ( + (e.key === 'ArrowDown' || e.key === 'ArrowUp') && + dropdown.style.display !== 'none' + ) { + e.stopPropagation(); + e.preventDefault(); // prevent cursor move + let iNext = options.length; + if (iSelection < options.length) { + const selection = options[iSelection]; + selection.classList.remove(DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS); + const delta = e.key === 'ArrowDown' ? 1 : -1; + iNext = iSelection + delta; + if (iNext < 0) { + iNext = options.length - 1; + } else if (iNext >= options.length) { + iNext = 0; + } + const selectionNext = options[iNext]; + selectionNext.classList.add(DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS); + } else if (iSelection === options.length) { + // none + iNext = e.key === 'ArrowDown' ? 0 : options.length - 1; + const selection = options[iNext]; + selection.classList.add(DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS); + } + if (0 <= iNext && iNext < options.length) { + DirectoryDropdown.#clampDropdownScrollTop(dropdown, options[iNext]); + } else { + dropdown.scrollTop = 0; + const options = dropdown.children; + for (iSelection = 0; iSelection < options.length; iSelection++) { + const selection = options[iSelection]; + if ( + selection.classList.contains( + DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS, + ) + ) { + selection.classList.remove( + DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS, + ); + } + } + } + } + }); + } + + /** + * @param {HTMLInputElement} input + * @param {HTMLParagraphElement | undefined | null} selection + * @param {String} searchSeparator + * @param {String} className + * @returns {boolean} changed + */ + static selectionToInput(input, selection, searchSeparator, className) { + selection.classList.remove(className); + const selectedText = selection.innerText; + const oldFilterText = input.value; + const iSep = oldFilterText.lastIndexOf(searchSeparator); + const previousPath = oldFilterText.substring(0, iSep + 1); + const newFilterText = previousPath + selectedText; + input.value = newFilterText; + return newFilterText !== oldFilterText; + } + + /** + * @param {string} path + */ + #updateDeepestPath = (path) => { + const deepestPath = this.#deepestPreviousPath; + if (path.length > deepestPath.length || !deepestPath.startsWith(path)) { + this.#deepestPreviousPath = path; + } + }; + + /** + * @param {HTMLDivElement} dropdown + * @param {HTMLParagraphElement} selection + */ + static #clampDropdownScrollTop = (dropdown, selection) => { + let dropdownTop = dropdown.scrollTop; + const dropdownHeight = dropdown.offsetHeight; + const selectionHeight = selection.offsetHeight; + const selectionTop = selection.offsetTop; + dropdownTop = Math.max( + dropdownTop, + selectionTop - dropdownHeight + selectionHeight, + ); + dropdownTop = Math.min(dropdownTop, selectionTop); + dropdown.scrollTop = dropdownTop; + }; + + /** + * @param {string} path + */ + #restoreSelectedOption(path) { + const searchSeparator = this.#modelData.searchSeparator; + const deepest = this.#deepestPreviousPath; + if (deepest.length >= path.length && deepest.startsWith(path)) { + let name = deepest.substring(path.length); + name = removePrefix(name, searchSeparator); + const i1 = name.indexOf(searchSeparator); + if (i1 !== -1) { + name = name.substring(0, i1); + } + + const dropdown = this.element; + const options = dropdown.children; + let iSelection; + for (iSelection = 0; iSelection < options.length; iSelection++) { + const selection = options[iSelection]; + let text = removeSuffix(selection.innerText, searchSeparator); + if (text === name) { + selection.classList.add(DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS); + dropdown.scrollTop = dropdown.scrollHeight; // snap to top + DirectoryDropdown.#clampDropdownScrollTop(dropdown, selection); + break; + } + } + if (iSelection === options.length) { + dropdown.scrollTop = 0; + } + } + } + + /** + * Returns path if update was successful. + * @returns {string | undefined} + */ + #updateOptions() { + const dropdown = this.element; + const input = this.#input; + + const searchSeparator = this.#modelData.searchSeparator; + const filter = input.value; + if (filter[0] !== searchSeparator) { + dropdown.style.display = 'none'; + return undefined; + } + + const modelType = this.#getModelType(); + const searchPrefix = modelType !== '' ? searchSeparator + modelType : ''; + const directories = this.#modelData.directories; + const [options, path] = directories.search( + searchPrefix + filter, + searchSeparator, + this.showDirectoriesOnly, + ); + if (options.length === 0) { + dropdown.style.display = 'none'; + return undefined; + } + + const mouse_selection_select = (e) => { + const selection = e.target; + if (e.movementX === 0 && e.movementY === 0) { + return; + } + if ( + !selection.classList.contains(DROPDOWN_DIRECTORY_SELECTION_MOUSE_CLASS) + ) { + // assumes only one will ever selected at a time + e.stopPropagation(); + const children = dropdown.children; + for (let iChild = 0; iChild < children.length; iChild++) { + const child = children[iChild]; + child.classList.remove(DROPDOWN_DIRECTORY_SELECTION_MOUSE_CLASS); + } + selection.classList.add(DROPDOWN_DIRECTORY_SELECTION_MOUSE_CLASS); + } }; - - /** - * @param {HTMLDivElement} dropdown - * @param {HTMLParagraphElement} selection - */ - static #clampDropdownScrollTop = (dropdown, selection) => { - let dropdownTop = dropdown.scrollTop; - const dropdownHeight = dropdown.offsetHeight; - const selectionHeight = selection.offsetHeight; - const selectionTop = selection.offsetTop; - dropdownTop = Math.max(dropdownTop, selectionTop - dropdownHeight + selectionHeight); - dropdownTop = Math.min(dropdownTop, selectionTop); - dropdown.scrollTop = dropdownTop; + const mouse_selection_deselect = (e) => { + e.stopPropagation(); + e.target.classList.remove(DROPDOWN_DIRECTORY_SELECTION_MOUSE_CLASS); }; - - /** - * @param {string} path - */ - #restoreSelectedOption(path) { - const searchSeparator = this.#modelData.searchSeparator; - const deepest = this.#deepestPreviousPath; - if (deepest.length >= path.length && deepest.startsWith(path)) { - let name = deepest.substring(path.length); - name = removePrefix(name, searchSeparator); - const i1 = name.indexOf(searchSeparator); - if (i1 !== -1) { - name = name.substring(0, i1); - } - - const dropdown = this.element; - const options = dropdown.children; - let iSelection; - for (iSelection = 0; iSelection < options.length; iSelection++) { - const selection = options[iSelection]; - let text = removeSuffix(selection.innerText, searchSeparator); - if (text === name) { - selection.classList.add(DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS); - dropdown.scrollTop = dropdown.scrollHeight; // snap to top - DirectoryDropdown.#clampDropdownScrollTop(dropdown, selection); - break; - } - } - if (iSelection === options.length) { - dropdown.scrollTop = 0; - } + const selection_submit = async (e) => { + e.stopPropagation(); + e.preventDefault(); + const selection = e.target; + const changed = DirectoryDropdown.selectionToInput( + input, + selection, + searchSeparator, + DROPDOWN_DIRECTORY_SELECTION_MOUSE_CLASS, + ); + if (!changed) { + dropdown.style.display = 'none'; + input.blur(); + } else { + const path = this.#updateOptions(); // TODO: is this needed? + if (path !== undefined) { + this.#updateDeepestPath(path); } - } - - /** - * Returns path if update was successful. - * @returns {string | undefined} - */ - #updateOptions() { - const dropdown = this.element; - const input = this.#input; - - const searchSeparator = this.#modelData.searchSeparator; - const filter = input.value; - if (filter[0] !== searchSeparator) { - dropdown.style.display = "none"; - return undefined; + } + this.#updateCallback(); + if (this.#isDynamicSearch()) { + await this.#submitCallback(); + } + }; + const touch_selection_select = async (e) => { + const [startX, startY] = this.#touchSelectionStart; + const [endX, endY] = [ + e.changedTouches[0].clientX, + e.changedTouches[0].clientY, + ]; + if (startX === endX && startY === endY) { + const touch = e.changedTouches[0]; + const box = dropdown.getBoundingClientRect(); + if ( + touch.clientX >= box.left && + touch.clientX <= box.right && + touch.clientY >= box.top && + touch.clientY <= box.bottom + ) { + selection_submit(e); } - - const modelType = this.#getModelType(); - const searchPrefix = modelType !== "" ? searchSeparator + modelType : ""; - const directories = this.#modelData.directories; - const [options, path] = directories.search( - searchPrefix + filter, - searchSeparator, - this.showDirectoriesOnly, + } + }; + const touch_start = (e) => { + this.#touchSelectionStart = [ + e.changedTouches[0].clientX, + e.changedTouches[0].clientY, + ]; + }; + dropdown.innerHTML = ''; + dropdown.append.apply( + dropdown, + options.map((text) => { + /** @type {HTMLParagraphElement} */ + const p = $el( + 'p', + { + onmouseenter: (e) => mouse_selection_select(e), + onmousemove: (e) => mouse_selection_select(e), + onmouseleave: (e) => mouse_selection_deselect(e), + onmousedown: (e) => selection_submit(e), + ontouchstart: (e) => touch_start(e), + ontouchmove: (e) => touch_move(e), + ontouchend: (e) => touch_selection_select(e), + }, + [text], ); - if (options.length === 0) { - dropdown.style.display = "none"; - return undefined; - } + return p; + }), + ); + // TODO: handle when dropdown is near the bottom of the window + const inputRect = input.getBoundingClientRect(); + dropdown.style.width = inputRect.width + 'px'; + dropdown.style.top = input.offsetTop + inputRect.height + 'px'; + dropdown.style.left = input.offsetLeft + 'px'; + dropdown.style.display = 'block'; - const mouse_selection_select = (e) => { - const selection = e.target; - if (e.movementX === 0 && e.movementY === 0) { return; } - if (!selection.classList.contains(DROPDOWN_DIRECTORY_SELECTION_MOUSE_CLASS)) { - // assumes only one will ever selected at a time - e.stopPropagation(); - const children = dropdown.children; - for (let iChild = 0; iChild < children.length; iChild++) { - const child = children[iChild]; - child.classList.remove(DROPDOWN_DIRECTORY_SELECTION_MOUSE_CLASS); - } - selection.classList.add(DROPDOWN_DIRECTORY_SELECTION_MOUSE_CLASS); - } - }; - const mouse_selection_deselect = (e) => { - e.stopPropagation(); - e.target.classList.remove(DROPDOWN_DIRECTORY_SELECTION_MOUSE_CLASS); - }; - const selection_submit = async(e) => { - e.stopPropagation(); - e.preventDefault(); - const selection = e.target; - const changed = DirectoryDropdown.selectionToInput( - input, - selection, - searchSeparator, - DROPDOWN_DIRECTORY_SELECTION_MOUSE_CLASS - ); - if (!changed) { - dropdown.style.display = "none"; - input.blur(); - } - else { - const path = this.#updateOptions(); // TODO: is this needed? - if (path !== undefined) { - this.#updateDeepestPath(path); - } - } - this.#updateCallback(); - if (this.#isDynamicSearch()) { - await this.#submitCallback(); - } - }; - const touch_selection_select = async(e) => { - const [startX, startY] = this.#touchSelectionStart; - const [endX, endY] = [ - e.changedTouches[0].clientX, - e.changedTouches[0].clientY - ]; - if (startX === endX && startY === endY) { - const touch = e.changedTouches[0]; - const box = dropdown.getBoundingClientRect(); - if (touch.clientX >= box.left && - touch.clientX <= box.right && - touch.clientY >= box.top && - touch.clientY <= box.bottom) { - selection_submit(e); - } - } - }; - const touch_start = (e) => { - this.#touchSelectionStart = [ - e.changedTouches[0].clientX, - e.changedTouches[0].clientY - ]; - }; - dropdown.innerHTML = ""; - dropdown.append.apply(dropdown, options.map((text) => { - /** @type {HTMLParagraphElement} */ - const p = $el( - "p", - { - onmouseenter: (e) => mouse_selection_select(e), - onmousemove: (e) => mouse_selection_select(e), - onmouseleave: (e) => mouse_selection_deselect(e), - onmousedown: (e) => selection_submit(e), - ontouchstart: (e) => touch_start(e), - ontouchmove: (e) => touch_move(e), - ontouchend: (e) => touch_selection_select(e), - }, - [ - text - ] - ); - return p; - })); - // TODO: handle when dropdown is near the bottom of the window - const inputRect = input.getBoundingClientRect(); - dropdown.style.width = inputRect.width + "px"; - dropdown.style.top = (input.offsetTop + inputRect.height) + "px"; - dropdown.style.left = input.offsetLeft + "px"; - dropdown.style.display = "block"; - - return path; - } + return path; + } } -const MODEL_SORT_DATE_CREATED = "dateCreated"; -const MODEL_SORT_DATE_MODIFIED = "dateModified"; -const MODEL_SORT_SIZE_BYTES = "sizeBytes"; -const MODEL_SORT_DATE_NAME = "name"; +const MODEL_SORT_DATE_CREATED = 'dateCreated'; +const MODEL_SORT_DATE_MODIFIED = 'dateModified'; +const MODEL_SORT_SIZE_BYTES = 'sizeBytes'; +const MODEL_SORT_DATE_NAME = 'name'; class ModelGrid { - /** - * @param {string} nodeType - * @returns {int} - */ - static modelWidgetIndex(nodeType) { - return nodeType === undefined ? -1 : 0; + /** + * @param {string} nodeType + * @returns {int} + */ + static modelWidgetIndex(nodeType) { + return nodeType === undefined ? -1 : 0; + } + + /** + * @param {string} text + * @param {string} file + * @param {boolean} removeExtension + * @returns {string} + */ + static insertEmbeddingIntoText(text, file, removeExtension) { + let name = file; + if (removeExtension) { + name = SearchPath.splitExtension(name)[0]; } + const sep = text.length === 0 || text.slice(-1).match(/\s/) ? '' : ' '; + return text + sep + '(embedding:' + name + ':1.0)'; + } - /** - * @param {string} text - * @param {string} file - * @param {boolean} removeExtension - * @returns {string} - */ - static insertEmbeddingIntoText(text, file, removeExtension) { - let name = file; - if (removeExtension) { - name = SearchPath.splitExtension(name)[0]; + /** + * @param {Array} list + * @param {string} searchString + * @returns {Array} + */ + static #filter(list, searchString) { + /** @type {string[]} */ + const keywords = searchString + //.replace("*", " ") // TODO: this is wrong for wildcards + .split(/(-?".*?"|[^\s"]+)+/g) + .map((item) => + item + .trim() + .replace(/(?:")+/g, '') + .toLowerCase(), + ) + .filter(Boolean); + + const regexSHA256 = /^[a-f0-9]{64}$/gi; + const fields = ['name', 'path']; + return list.filter((element) => { + const text = fields + .reduce((memo, field) => memo + ' ' + element[field], '') + .toLowerCase(); + return keywords.reduce((memo, target) => { + const excludeTarget = target[0] === '-'; + if (excludeTarget && target.length === 1) { + return memo; } - const sep = text.length === 0 || text.slice(-1).match(/\s/) ? "" : " "; - return text + sep + "(embedding:" + name + ":1.0)"; - } - - /** - * @param {Array} list - * @param {string} searchString - * @returns {Array} - */ - static #filter(list, searchString) { - /** @type {string[]} */ - const keywords = searchString - //.replace("*", " ") // TODO: this is wrong for wildcards - .split(/(-?".*?"|[^\s"]+)+/g) - .map((item) => item - .trim() - .replace(/(?:")+/g, "") - .toLowerCase()) - .filter(Boolean); - - const regexSHA256 = /^[a-f0-9]{64}$/gi; - const fields = ["name", "path"]; - return list.filter((element) => { - const text = fields - .reduce((memo, field) => memo + " " + element[field], "") - .toLowerCase(); - return keywords.reduce((memo, target) => { - const excludeTarget = target[0] === "-"; - if (excludeTarget && target.length === 1) { return memo; } - const filteredTarget = excludeTarget ? target.slice(1) : target; - if (element["SHA256"] !== undefined && regexSHA256.test(filteredTarget)) { - return memo && excludeTarget !== (filteredTarget === element["SHA256"]); - } - else { - return memo && excludeTarget !== text.includes(filteredTarget); - } - }, true); - }); - } - - /** - * In-place sort. Returns an array alias. - * @param {Array} list - * @param {string} sortBy - * @param {bool} [reverse=false] - * @returns {Array} - */ - static #sort(list, sortBy, reverse = false) { - let compareFn = null; - switch (sortBy) { - case MODEL_SORT_DATE_NAME: - compareFn = (a, b) => { return a[MODEL_SORT_DATE_NAME].localeCompare(b[MODEL_SORT_DATE_NAME]); }; - break; - case MODEL_SORT_DATE_MODIFIED: - compareFn = (a, b) => { return b[MODEL_SORT_DATE_MODIFIED] - a[MODEL_SORT_DATE_MODIFIED]; }; - break; - case MODEL_SORT_DATE_CREATED: - compareFn = (a, b) => { return b[MODEL_SORT_DATE_CREATED] - a[MODEL_SORT_DATE_CREATED]; }; - break; - case MODEL_SORT_SIZE_BYTES: - compareFn = (a, b) => { return b[MODEL_SORT_SIZE_BYTES] - a[MODEL_SORT_SIZE_BYTES]; }; - break; - default: - console.warn("Invalid filter sort value: '" + sortBy + "'"); - return list; - } - const sorted = list.sort(compareFn); - return reverse ? sorted.reverse() : sorted; - } - - /** - * @param {Event} event - * @param {string} modelType - * @param {string} path - * @param {boolean} removeEmbeddingExtension - * @param {int} addOffset - */ - static #addModel(event, modelType, path, removeEmbeddingExtension, addOffset) { - let success = false; - if (modelType !== "embeddings") { - const nodeType = modelNodeType[modelType]; - const widgetIndex = ModelGrid.modelWidgetIndex(nodeType); - let node = LiteGraph.createNode(nodeType, null, []); - if (widgetIndex !== -1 && node) { - node.widgets[widgetIndex].value = path; - const selectedNodes = app.canvas.selected_nodes; - let isSelectedNode = false; - for (var i in selectedNodes) { - const selectedNode = selectedNodes[i]; - node.pos[0] = selectedNode.pos[0] + addOffset; - node.pos[1] = selectedNode.pos[1] + addOffset; - isSelectedNode = true; - break; - } - if (!isSelectedNode) { - const graphMouse = app.canvas.graph_mouse; - node.pos[0] = graphMouse[0]; - node.pos[1] = graphMouse[1]; - } - app.graph.add(node, {doProcessChange: true}); - app.canvas.selectNode(node); - success = true; - } - event.stopPropagation(); - } - else if (modelType === "embeddings") { - const [embeddingDirectory, embeddingFile] = SearchPath.split(path); - const selectedNodes = app.canvas.selected_nodes; - for (var i in selectedNodes) { - const selectedNode = selectedNodes[i]; - const nodeType = modelNodeType[modelType]; - const widgetIndex = ModelGrid.modelWidgetIndex(nodeType); - const target = selectedNode?.widgets[widgetIndex]?.element; - if (target && target.type === "textarea") { - target.value = ModelGrid.insertEmbeddingIntoText(target.value, embeddingFile, removeEmbeddingExtension); - success = true; - } - } - if (!success) { - console.warn("Try selecting a node before adding the embedding."); - } - event.stopPropagation(); - } - comfyButtonAlert(event.target, success, "mdi-check-bold", "mdi-close-thick"); - } - - static #getWidgetComboIndices(node, value) { - const widgetIndices = []; - node?.widgets?.forEach((widget, index) => { - if (widget.type === "combo" && widget.options.values?.includes(value)) { - widgetIndices.push(index); - } - }); - return widgetIndices; - } - - /** - * @param {DragEvent} event - * @param {string} modelType - * @param {string} path - * @param {boolean} removeEmbeddingExtension - * @param {boolean} strictlyOnWidget - */ - static #dragAddModel(event, modelType, path, removeEmbeddingExtension, strictlyOnWidget) { - const [clientX, clientY, target] = elementFromDragEvent(event); - if (modelType !== "embeddings" && target.id === "graph-canvas") { - //const pos = app.canvas.convertEventToCanvasOffset(event); - const pos = app.canvas.convertEventToCanvasOffset({ clientX: clientX, clientY: clientY }); - - const node = app.graph.getNodeOnPos(pos[0], pos[1], app.canvas.visible_nodes); - - let widgetIndex = -1; - if (widgetIndex === -1) { - const widgetIndices = this.#getWidgetComboIndices(node, path); - if (widgetIndices.length === 0) { - widgetIndex = -1; - } - else if (widgetIndices.length === 1) { - widgetIndex = widgetIndices[0]; - if (strictlyOnWidget) { - const draggedWidget = app.canvas.processNodeWidgets(node, pos, event); - const widget = node.widgets[widgetIndex]; - if (draggedWidget != widget) { // != check NOT same object - widgetIndex = -1; - } - } - } - else { - // ambiguous widget (strictlyOnWidget always true) - const draggedWidget = app.canvas.processNodeWidgets(node, pos, event); - widgetIndex = widgetIndices.findIndex((index) => { - return draggedWidget == node.widgets[index]; // == check same object - }); - } - } - - if (widgetIndex !== -1) { - node.widgets[widgetIndex].value = path; - app.canvas.selectNode(node); - } - else { - const expectedNodeType = modelNodeType[modelType]; - const newNode = LiteGraph.createNode(expectedNodeType, null, []); - let newWidgetIndex = ModelGrid.modelWidgetIndex(expectedNodeType); - if (newWidgetIndex === -1) { - newWidgetIndex = this.#getWidgetComboIndices(newNode, path)[0] ?? -1; - } - if (newNode !== undefined && newNode !== null && newWidgetIndex !== -1) { - newNode.pos[0] = pos[0]; - newNode.pos[1] = pos[1]; - newNode.widgets[newWidgetIndex].value = path; - app.graph.add(newNode, {doProcessChange: true}); - app.canvas.selectNode(newNode); - } - } - event.stopPropagation(); - } - else if (modelType === "embeddings" && target.type === "textarea") { - const pos = app.canvas.convertEventToCanvasOffset(event); - const nodeAtPos = app.graph.getNodeOnPos(pos[0], pos[1], app.canvas.visible_nodes); - if (nodeAtPos) { - app.canvas.selectNode(nodeAtPos); - const [embeddingDirectory, embeddingFile] = SearchPath.split(path); - target.value = ModelGrid.insertEmbeddingIntoText(target.value, embeddingFile, removeEmbeddingExtension); - event.stopPropagation(); - } - } - } - - /** - * @param {Event} event - * @param {string} modelType - * @param {string} path - * @param {boolean} removeEmbeddingExtension - */ - static #copyModelToClipboard(event, modelType, path, removeEmbeddingExtension) { - const nodeType = modelNodeType[modelType]; - let success = false; - if (nodeType === "Embedding") { - if (navigator.clipboard){ - const [embeddingDirectory, embeddingFile] = SearchPath.split(path); - const embeddingText = ModelGrid.insertEmbeddingIntoText("", embeddingFile, removeEmbeddingExtension); - navigator.clipboard.writeText(embeddingText); - success = true; - } - else { - console.warn("Cannot copy the embedding to the system clipboard; Try dragging it instead."); - } - } - else if (nodeType) { - const node = LiteGraph.createNode(nodeType, null, []); - const widgetIndex = ModelGrid.modelWidgetIndex(nodeType); - if (widgetIndex !== -1) { - node.widgets[widgetIndex].value = path; - app.canvas.copyToClipboard([node]); - success = true; - } - } - else { - console.warn(`Unable to copy unknown model type '${modelType}.`); - } - comfyButtonAlert(event.target, success, "mdi-check-bold", "mdi-close-thick"); - } - - /** - * @param {Array} models - * @param {string} modelType - * @param {Object. } settingsElements - * @param {String} searchSeparator - * @param {String} systemSeparator - * @param {(searchPath: string) => Promise } showModelInfo - * @returns {HTMLElement[]} - */ - static #generateInnerHtml(models, modelType, settingsElements, searchSeparator, systemSeparator, showModelInfo) { - // TODO: separate text and model logic; getting too messy - // TODO: fallback on button failure to copy text? - const canShowButtons = modelNodeType[modelType] !== undefined; - const showAddButton = canShowButtons && settingsElements["model-show-add-button"].checked; - const showCopyButton = canShowButtons && settingsElements["model-show-copy-button"].checked; - const showLoadWorkflowButton = canShowButtons && settingsElements["model-show-load-workflow-button"].checked; - const strictDragToAdd = settingsElements["model-add-drag-strict-on-field"].checked; - const addOffset = parseInt(settingsElements["model-add-offset"].value); - const showModelExtension = settingsElements["model-show-label-extensions"].checked; - const modelInfoButtonOnLeft = !settingsElements["model-info-button-on-left"].checked; - const removeEmbeddingExtension = !settingsElements["model-add-embedding-extension"].checked; - const previewThumbnailFormat = settingsElements["model-preview-thumbnail-type"].value; - if (models.length > 0) { - return models.map((item) => { - const previewInfo = item.preview; - const previewThumbnail = $el("img.model-preview", { - loading: "lazy", /* `loading` BEFORE `src`; Known bug in Firefox 124.0.2 and Safari for iOS 17.4.1 (https://stackoverflow.com/a/76252772) */ - src: imageUri( - previewInfo?.path, - previewInfo?.dateModified, - PREVIEW_THUMBNAIL_WIDTH, - PREVIEW_THUMBNAIL_HEIGHT, - previewThumbnailFormat, - ), - draggable: false, - }); - const searchPath = item.path; - const path = SearchPath.systemPath(searchPath, searchSeparator, systemSeparator); - let actionButtons = []; - if (showAddButton && !(modelType === "embeddings" && !navigator.clipboard)) { - actionButtons.push( - new ComfyButton({ - icon: "content-copy", - tooltip: "Copy model to clipboard", - classList: "comfyui-button icon-button model-button", - action: (e) => ModelGrid.#copyModelToClipboard( - e, - modelType, - path, - removeEmbeddingExtension, - ), - }).element, - ); - } - if (showCopyButton) { - actionButtons.push( - new ComfyButton({ - icon: "plus-box-outline", - tooltip: "Add model to node grid", - classList: "comfyui-button icon-button model-button", - action: (e) => ModelGrid.#addModel( - e, - modelType, - path, - removeEmbeddingExtension, - addOffset, - ), - }).element, - ); - } - if (showLoadWorkflowButton) { - actionButtons.push( - new ComfyButton({ - icon: "arrow-bottom-left-bold-box-outline", - tooltip: "Load preview workflow", - classList: "comfyui-button icon-button model-button", - action: async (e) => { - const urlString = previewThumbnail.src; - const url = new URL(urlString); - const urlSearchParams = url.searchParams; - const uri = urlSearchParams.get("uri"); - const v = urlSearchParams.get("v"); - const urlFull = urlString.substring(0, urlString.indexOf("?")) + "?uri=" + uri + "&v=" + v; - await loadWorkflow(urlFull); - }, - }).element, - ); - } - const infoButtons = [ - new ComfyButton({ - icon: "information-outline", - tooltip: "View model information", - classList: "comfyui-button icon-button model-button", - action: async() => { await showModelInfo(searchPath) }, - }).element, - ]; - const dragAdd = (e) => ModelGrid.#dragAddModel( - e, - modelType, - path, - removeEmbeddingExtension, - strictDragToAdd - ); - return $el("div.item", {}, [ - previewThumbnail, - $el("div.model-preview-overlay", { - ondragend: (e) => dragAdd(e), - draggable: true, - }), - $el("div.model-preview-top-right", { - draggable: false, - }, - modelInfoButtonOnLeft ? infoButtons : actionButtons, - ), - $el("div.model-preview-top-left", { - draggable: false, - }, - modelInfoButtonOnLeft ? actionButtons : infoButtons, - ), - $el("div.model-label", { - draggable: false, - }, [ - $el("p", [showModelExtension ? item.name : SearchPath.splitExtension(item.name)[0]]) - ]), - ]); - }); + const filteredTarget = excludeTarget ? target.slice(1) : target; + if ( + element['SHA256'] !== undefined && + regexSHA256.test(filteredTarget) + ) { + return ( + memo && excludeTarget !== (filteredTarget === element['SHA256']) + ); } else { - return [$el("h2", ["No Models"])]; + return memo && excludeTarget !== text.includes(filteredTarget); } + }, true); + }); + } + + /** + * In-place sort. Returns an array alias. + * @param {Array} list + * @param {string} sortBy + * @param {bool} [reverse=false] + * @returns {Array} + */ + static #sort(list, sortBy, reverse = false) { + let compareFn = null; + switch (sortBy) { + case MODEL_SORT_DATE_NAME: + compareFn = (a, b) => { + return a[MODEL_SORT_DATE_NAME].localeCompare(b[MODEL_SORT_DATE_NAME]); + }; + break; + case MODEL_SORT_DATE_MODIFIED: + compareFn = (a, b) => { + return b[MODEL_SORT_DATE_MODIFIED] - a[MODEL_SORT_DATE_MODIFIED]; + }; + break; + case MODEL_SORT_DATE_CREATED: + compareFn = (a, b) => { + return b[MODEL_SORT_DATE_CREATED] - a[MODEL_SORT_DATE_CREATED]; + }; + break; + case MODEL_SORT_SIZE_BYTES: + compareFn = (a, b) => { + return b[MODEL_SORT_SIZE_BYTES] - a[MODEL_SORT_SIZE_BYTES]; + }; + break; + default: + console.warn("Invalid filter sort value: '" + sortBy + "'"); + return list; } + const sorted = list.sort(compareFn); + return reverse ? sorted.reverse() : sorted; + } - /** - * @param {HTMLDivElement} modelGrid - * @param {ModelData} modelData - * @param {HTMLSelectElement} modelSelect - * @param {Object.<{value: string}>} previousModelType - * @param {Object} settings - * @param {string} sortBy - * @param {boolean} reverseSort - * @param {Array} previousModelFilters - * @param {HTMLInputElement} modelFilter - * @param {(searchPath: string) => Promise } showModelInfo - */ - static update(modelGrid, modelData, modelSelect, previousModelType, settings, sortBy, reverseSort, previousModelFilters, modelFilter, showModelInfo) { - const models = modelData.models; - let modelType = modelSelect.value; - if (models[modelType] === undefined) { - modelType = settings["model-default-browser-model-type"].value; + /** + * @param {Event} event + * @param {string} modelType + * @param {string} path + * @param {boolean} removeEmbeddingExtension + * @param {int} addOffset + */ + static #addModel( + event, + modelType, + path, + removeEmbeddingExtension, + addOffset, + ) { + let success = false; + if (modelType !== 'embeddings') { + const nodeType = modelNodeType[modelType]; + const widgetIndex = ModelGrid.modelWidgetIndex(nodeType); + let node = LiteGraph.createNode(nodeType, null, []); + if (widgetIndex !== -1 && node) { + node.widgets[widgetIndex].value = path; + const selectedNodes = app.canvas.selected_nodes; + let isSelectedNode = false; + for (var i in selectedNodes) { + const selectedNode = selectedNodes[i]; + node.pos[0] = selectedNode.pos[0] + addOffset; + node.pos[1] = selectedNode.pos[1] + addOffset; + isSelectedNode = true; + break; } - if (models[modelType] === undefined) { - modelType = "checkpoints"; // panic fallback + if (!isSelectedNode) { + const graphMouse = app.canvas.graph_mouse; + node.pos[0] = graphMouse[0]; + node.pos[1] = graphMouse[1]; } + app.graph.add(node, { doProcessChange: true }); + app.canvas.selectNode(node); + success = true; + } + event.stopPropagation(); + } else if (modelType === 'embeddings') { + const [embeddingDirectory, embeddingFile] = SearchPath.split(path); + const selectedNodes = app.canvas.selected_nodes; + for (var i in selectedNodes) { + const selectedNode = selectedNodes[i]; + const nodeType = modelNodeType[modelType]; + const widgetIndex = ModelGrid.modelWidgetIndex(nodeType); + const target = selectedNode?.widgets[widgetIndex]?.element; + if (target && target.type === 'textarea') { + target.value = ModelGrid.insertEmbeddingIntoText( + target.value, + embeddingFile, + removeEmbeddingExtension, + ); + success = true; + } + } + if (!success) { + console.warn('Try selecting a node before adding the embedding.'); + } + event.stopPropagation(); + } + comfyButtonAlert( + event.target, + success, + 'mdi-check-bold', + 'mdi-close-thick', + ); + } - if (modelType !== previousModelType.value) { - if (settings["model-persistent-search"].checked) { - previousModelFilters.splice(0, previousModelFilters.length); // TODO: make sure this actually worked! + static #getWidgetComboIndices(node, value) { + const widgetIndices = []; + node?.widgets?.forEach((widget, index) => { + if (widget.type === 'combo' && widget.options.values?.includes(value)) { + widgetIndices.push(index); + } + }); + return widgetIndices; + } + + /** + * @param {DragEvent} event + * @param {string} modelType + * @param {string} path + * @param {boolean} removeEmbeddingExtension + * @param {boolean} strictlyOnWidget + */ + static #dragAddModel( + event, + modelType, + path, + removeEmbeddingExtension, + strictlyOnWidget, + ) { + const [clientX, clientY, target] = elementFromDragEvent(event); + if (modelType !== 'embeddings' && target.id === 'graph-canvas') { + //const pos = app.canvas.convertEventToCanvasOffset(event); + const pos = app.canvas.convertEventToCanvasOffset({ + clientX: clientX, + clientY: clientY, + }); + + const node = app.graph.getNodeOnPos( + pos[0], + pos[1], + app.canvas.visible_nodes, + ); + + let widgetIndex = -1; + if (widgetIndex === -1) { + const widgetIndices = this.#getWidgetComboIndices(node, path); + if (widgetIndices.length === 0) { + widgetIndex = -1; + } else if (widgetIndices.length === 1) { + widgetIndex = widgetIndices[0]; + if (strictlyOnWidget) { + const draggedWidget = app.canvas.processNodeWidgets( + node, + pos, + event, + ); + const widget = node.widgets[widgetIndex]; + if (draggedWidget != widget) { + // != check NOT same object + widgetIndex = -1; } - else { - // cache previous filter text - previousModelFilters[previousModelType.value] = modelFilter.value; - // read cached filter text - modelFilter.value = previousModelFilters[modelType] ?? ""; - } - previousModelType.value = modelType; + } + } else { + // ambiguous widget (strictlyOnWidget always true) + const draggedWidget = app.canvas.processNodeWidgets(node, pos, event); + widgetIndex = widgetIndices.findIndex((index) => { + return draggedWidget == node.widgets[index]; // == check same object + }); } + } - let modelTypeOptions = []; - for (const [key, value] of Object.entries(models)) { - const el = $el("option", [key]); - modelTypeOptions.push(el); + if (widgetIndex !== -1) { + node.widgets[widgetIndex].value = path; + app.canvas.selectNode(node); + } else { + const expectedNodeType = modelNodeType[modelType]; + const newNode = LiteGraph.createNode(expectedNodeType, null, []); + let newWidgetIndex = ModelGrid.modelWidgetIndex(expectedNodeType); + if (newWidgetIndex === -1) { + newWidgetIndex = this.#getWidgetComboIndices(newNode, path)[0] ?? -1; } - modelSelect.innerHTML = ""; - modelTypeOptions.forEach(option => modelSelect.add(option)); - modelSelect.value = modelType; - - const searchAppend = settings["model-search-always-append"].value; - const searchText = modelFilter.value + " " + searchAppend; - const modelList = ModelGrid.#filter(models[modelType], searchText); - ModelGrid.#sort(modelList, sortBy, reverseSort); - - modelGrid.innerHTML = ""; - const modelGridModels = ModelGrid.#generateInnerHtml( - modelList, - modelType, - settings, - modelData.searchSeparator, - modelData.systemSeparator, - showModelInfo, + if ( + newNode !== undefined && + newNode !== null && + newWidgetIndex !== -1 + ) { + newNode.pos[0] = pos[0]; + newNode.pos[1] = pos[1]; + newNode.widgets[newWidgetIndex].value = path; + app.graph.add(newNode, { doProcessChange: true }); + app.canvas.selectNode(newNode); + } + } + event.stopPropagation(); + } else if (modelType === 'embeddings' && target.type === 'textarea') { + const pos = app.canvas.convertEventToCanvasOffset(event); + const nodeAtPos = app.graph.getNodeOnPos( + pos[0], + pos[1], + app.canvas.visible_nodes, + ); + if (nodeAtPos) { + app.canvas.selectNode(nodeAtPos); + const [embeddingDirectory, embeddingFile] = SearchPath.split(path); + target.value = ModelGrid.insertEmbeddingIntoText( + target.value, + embeddingFile, + removeEmbeddingExtension, ); - modelGrid.append.apply(modelGrid, modelGridModels); + event.stopPropagation(); + } } + } + + /** + * @param {Event} event + * @param {string} modelType + * @param {string} path + * @param {boolean} removeEmbeddingExtension + */ + static #copyModelToClipboard( + event, + modelType, + path, + removeEmbeddingExtension, + ) { + const nodeType = modelNodeType[modelType]; + let success = false; + if (nodeType === 'Embedding') { + if (navigator.clipboard) { + const [embeddingDirectory, embeddingFile] = SearchPath.split(path); + const embeddingText = ModelGrid.insertEmbeddingIntoText( + '', + embeddingFile, + removeEmbeddingExtension, + ); + navigator.clipboard.writeText(embeddingText); + success = true; + } else { + console.warn( + 'Cannot copy the embedding to the system clipboard; Try dragging it instead.', + ); + } + } else if (nodeType) { + const node = LiteGraph.createNode(nodeType, null, []); + const widgetIndex = ModelGrid.modelWidgetIndex(nodeType); + if (widgetIndex !== -1) { + node.widgets[widgetIndex].value = path; + app.canvas.copyToClipboard([node]); + success = true; + } + } else { + console.warn(`Unable to copy unknown model type '${modelType}.`); + } + comfyButtonAlert( + event.target, + success, + 'mdi-check-bold', + 'mdi-close-thick', + ); + } + + /** + * @param {Array} models + * @param {string} modelType + * @param {Object. } settingsElements + * @param {String} searchSeparator + * @param {String} systemSeparator + * @param {(searchPath: string) => Promise } showModelInfo + * @returns {HTMLElement[]} + */ + static #generateInnerHtml( + models, + modelType, + settingsElements, + searchSeparator, + systemSeparator, + showModelInfo, + ) { + // TODO: separate text and model logic; getting too messy + // TODO: fallback on button failure to copy text? + const canShowButtons = modelNodeType[modelType] !== undefined; + const showAddButton = + canShowButtons && settingsElements['model-show-add-button'].checked; + const showCopyButton = + canShowButtons && settingsElements['model-show-copy-button'].checked; + const showLoadWorkflowButton = + canShowButtons && + settingsElements['model-show-load-workflow-button'].checked; + const strictDragToAdd = + settingsElements['model-add-drag-strict-on-field'].checked; + const addOffset = parseInt(settingsElements['model-add-offset'].value); + const showModelExtension = + settingsElements['model-show-label-extensions'].checked; + const modelInfoButtonOnLeft = + !settingsElements['model-info-button-on-left'].checked; + const removeEmbeddingExtension = + !settingsElements['model-add-embedding-extension'].checked; + const previewThumbnailFormat = + settingsElements['model-preview-thumbnail-type'].value; + if (models.length > 0) { + return models.map((item) => { + const previewInfo = item.preview; + const previewThumbnail = $el('img.model-preview', { + loading: + 'lazy' /* `loading` BEFORE `src`; Known bug in Firefox 124.0.2 and Safari for iOS 17.4.1 (https://stackoverflow.com/a/76252772) */, + src: imageUri( + previewInfo?.path, + previewInfo?.dateModified, + PREVIEW_THUMBNAIL_WIDTH, + PREVIEW_THUMBNAIL_HEIGHT, + previewThumbnailFormat, + ), + draggable: false, + }); + const searchPath = item.path; + const path = SearchPath.systemPath( + searchPath, + searchSeparator, + systemSeparator, + ); + let actionButtons = []; + if ( + showAddButton && + !(modelType === 'embeddings' && !navigator.clipboard) + ) { + actionButtons.push( + new ComfyButton({ + icon: 'content-copy', + tooltip: 'Copy model to clipboard', + classList: 'comfyui-button icon-button model-button', + action: (e) => + ModelGrid.#copyModelToClipboard( + e, + modelType, + path, + removeEmbeddingExtension, + ), + }).element, + ); + } + if (showCopyButton) { + actionButtons.push( + new ComfyButton({ + icon: 'plus-box-outline', + tooltip: 'Add model to node grid', + classList: 'comfyui-button icon-button model-button', + action: (e) => + ModelGrid.#addModel( + e, + modelType, + path, + removeEmbeddingExtension, + addOffset, + ), + }).element, + ); + } + if (showLoadWorkflowButton) { + actionButtons.push( + new ComfyButton({ + icon: 'arrow-bottom-left-bold-box-outline', + tooltip: 'Load preview workflow', + classList: 'comfyui-button icon-button model-button', + action: async (e) => { + const urlString = previewThumbnail.src; + const url = new URL(urlString); + const urlSearchParams = url.searchParams; + const uri = urlSearchParams.get('uri'); + const v = urlSearchParams.get('v'); + const urlFull = + urlString.substring(0, urlString.indexOf('?')) + + '?uri=' + + uri + + '&v=' + + v; + await loadWorkflow(urlFull); + }, + }).element, + ); + } + const infoButtons = [ + new ComfyButton({ + icon: 'information-outline', + tooltip: 'View model information', + classList: 'comfyui-button icon-button model-button', + action: async () => { + await showModelInfo(searchPath); + }, + }).element, + ]; + const dragAdd = (e) => + ModelGrid.#dragAddModel( + e, + modelType, + path, + removeEmbeddingExtension, + strictDragToAdd, + ); + return $el('div.item', {}, [ + previewThumbnail, + $el('div.model-preview-overlay', { + ondragend: (e) => dragAdd(e), + draggable: true, + }), + $el( + 'div.model-preview-top-right', + { + draggable: false, + }, + modelInfoButtonOnLeft ? infoButtons : actionButtons, + ), + $el( + 'div.model-preview-top-left', + { + draggable: false, + }, + modelInfoButtonOnLeft ? actionButtons : infoButtons, + ), + $el( + 'div.model-label', + { + draggable: false, + }, + [ + $el('p', [ + showModelExtension + ? item.name + : SearchPath.splitExtension(item.name)[0], + ]), + ], + ), + ]); + }); + } else { + return [$el('h2', ['No Models'])]; + } + } + + /** + * @param {HTMLDivElement} modelGrid + * @param {ModelData} modelData + * @param {HTMLSelectElement} modelSelect + * @param {Object.<{value: string}>} previousModelType + * @param {Object} settings + * @param {string} sortBy + * @param {boolean} reverseSort + * @param {Array} previousModelFilters + * @param {HTMLInputElement} modelFilter + * @param {(searchPath: string) => Promise } showModelInfo + */ + static update( + modelGrid, + modelData, + modelSelect, + previousModelType, + settings, + sortBy, + reverseSort, + previousModelFilters, + modelFilter, + showModelInfo, + ) { + const models = modelData.models; + let modelType = modelSelect.value; + if (models[modelType] === undefined) { + modelType = settings['model-default-browser-model-type'].value; + } + if (models[modelType] === undefined) { + modelType = 'checkpoints'; // panic fallback + } + + if (modelType !== previousModelType.value) { + if (settings['model-persistent-search'].checked) { + previousModelFilters.splice(0, previousModelFilters.length); // TODO: make sure this actually worked! + } else { + // cache previous filter text + previousModelFilters[previousModelType.value] = modelFilter.value; + // read cached filter text + modelFilter.value = previousModelFilters[modelType] ?? ''; + } + previousModelType.value = modelType; + } + + let modelTypeOptions = []; + for (const [key, value] of Object.entries(models)) { + const el = $el('option', [key]); + modelTypeOptions.push(el); + } + modelSelect.innerHTML = ''; + modelTypeOptions.forEach((option) => modelSelect.add(option)); + modelSelect.value = modelType; + + const searchAppend = settings['model-search-always-append'].value; + const searchText = modelFilter.value + ' ' + searchAppend; + const modelList = ModelGrid.#filter(models[modelType], searchText); + ModelGrid.#sort(modelList, sortBy, reverseSort); + + modelGrid.innerHTML = ''; + const modelGridModels = ModelGrid.#generateInnerHtml( + modelList, + modelType, + settings, + modelData.searchSeparator, + modelData.systemSeparator, + showModelInfo, + ); + modelGrid.append.apply(modelGrid, modelGridModels); + } } class ModelInfo { - /** @type {HTMLDivElement} */ - element = null; + /** @type {HTMLDivElement} */ + element = null; - elements = { - /** @type {HTMLDivElement[]} */ tabButtons: null, - /** @type {HTMLDivElement[]} */ tabContents: null, - /** @type {HTMLDivElement} */ info: null, - /** @type {HTMLTextAreaElement} */ notes: null, - /** @type {HTMLButtonElement} */ setPreviewButton: null, - /** @type {HTMLInputElement} */ moveDestinationInput: null, - }; + elements = { + /** @type {HTMLDivElement[]} */ tabButtons: null, + /** @type {HTMLDivElement[]} */ tabContents: null, + /** @type {HTMLDivElement} */ info: null, + /** @type {HTMLTextAreaElement} */ notes: null, + /** @type {HTMLButtonElement} */ setPreviewButton: null, + /** @type {HTMLInputElement} */ moveDestinationInput: null, + }; - /** @type {ImageSelect} */ - previewSelect = null; + /** @type {ImageSelect} */ + previewSelect = null; - /** @type {string} */ - #savedNotesValue = null; + /** @type {string} */ + #savedNotesValue = null; - /** @type {[HTMLElement][]} */ - #settingsElements = null; + /** @type {[HTMLElement][]} */ + #settingsElements = null; - /** - * @param {ModelData} modelData - * @param {() => Promise } updateModels - * @param {any} settingsElements - */ - constructor(modelData, updateModels, settingsElements) { - this.#settingsElements = settingsElements; - const moveDestinationInput = $el("input.search-text-area", { - name: "move directory", - autocomplete: "off", - placeholder: modelData.searchSeparator, - value: modelData.searchSeparator, - }); - this.elements.moveDestinationInput = moveDestinationInput; + /** + * @param {ModelData} modelData + * @param {() => Promise } updateModels + * @param {any} settingsElements + */ + constructor(modelData, updateModels, settingsElements) { + this.#settingsElements = settingsElements; + const moveDestinationInput = $el('input.search-text-area', { + name: 'move directory', + autocomplete: 'off', + placeholder: modelData.searchSeparator, + value: modelData.searchSeparator, + }); + this.elements.moveDestinationInput = moveDestinationInput; - const searchDropdown = new DirectoryDropdown( - modelData, - moveDestinationInput, - true, + const searchDropdown = new DirectoryDropdown( + modelData, + moveDestinationInput, + true, + ); + + const previewSelect = new ImageSelect('model-info-preview-model-FYUIKMNVB'); + this.previewSelect = previewSelect; + previewSelect.elements.previews.style.display = 'flex'; + + const setPreviewButton = new ComfyButton({ + tooltip: 'Overwrite current preview with selected image', + content: 'Set as Preview', + action: async (e) => { + const [button, icon, span] = comfyButtonDisambiguate(e.target); + button.disabled = true; + const confirmation = window.confirm( + 'Change preview image(s) PERMANENTLY?', ); - - const previewSelect = new ImageSelect("model-info-preview-model-FYUIKMNVB"); - this.previewSelect = previewSelect; - previewSelect.elements.previews.style.display = "flex"; - - const setPreviewButton = new ComfyButton({ - tooltip: "Overwrite current preview with selected image", - content: "Set as Preview", - action: async(e) => { - const [button, icon, span] = comfyButtonDisambiguate(e.target); - button.disabled = true; - const confirmation = window.confirm("Change preview image(s) PERMANENTLY?"); - let updatedPreview = false; - if (confirmation) { - const container = this.elements.info; - const path = container.dataset.path; - const imageUrl = await previewSelect.getImage(); - if (imageUrl === PREVIEW_NONE_URI) { - const encodedPath = encodeURIComponent(path); - updatedPreview = await comfyRequest( - `/model-manager/preview/delete?path=${encodedPath}`, - { - method: "POST", - body: JSON.stringify({}), - } - ) - .then((result) => { - const message = result["alert"]; - if (message !== undefined) { - window.alert(message); - } - return result["success"]; - }) - .catch((err) => { - return false; - }); - } - else { - const formData = new FormData(); - formData.append("path", path); - const image = imageUrl[0] == "/" ? "" : imageUrl; - formData.append("image", image); - updatedPreview = await comfyRequest( - `/model-manager/preview/set`, - { - method: "POST", - body: formData, - } - ) - .then((result) => { - const message = result["alert"]; - if (message !== undefined) { - window.alert(message); - } - return result["success"]; - }) - .catch((err) => { - return false; - }); - } - if (updatedPreview) { - updateModels(); - const previewSelect = this.previewSelect; - previewSelect.elements.defaultUrl.dataset.noimage = PREVIEW_NONE_URI; - previewSelect.resetModelInfoPreview(); - this.element.style.display = "none"; - } - } - comfyButtonAlert(e.target, updatedPreview); - button.disabled = false; - }, - }).element; - this.elements.setPreviewButton = setPreviewButton; - previewSelect.elements.radioButtons.addEventListener("change", (e) => { - setPreviewButton.style.display = previewSelect.defaultIsChecked() ? "none" : "block"; - }); - - this.element = $el("div", { - style: { display: "none" }, - }, [ - $el("div.row.tab-header", { - display: "block", - }, [ - $el("div.row.tab-header-flex-block", [ - new ComfyButton({ - icon: "trash-can-outline", - tooltip: "Delete model FOREVER", - classList: "comfyui-button icon-button", - action: async(e) => { - const [button, icon, span] = comfyButtonDisambiguate(e.target); - button.disabled = true; - const affirmation = "delete"; - const confirmation = window.prompt("Type \"" + affirmation + "\" to delete the model PERMANENTLY.\n\nThis includes all image or text files."); - let deleted = false; - if (confirmation === affirmation) { - const container = this.elements.info; - const path = encodeURIComponent(container.dataset.path); - deleted = await comfyRequest( - `/model-manager/model/delete?path=${path}`, - { - method: "POST", - } - ) - .then((result) => { - const deleted = result["success"]; - const message = result["alert"]; - if (message !== undefined) { - window.alert(message); - } - if (deleted) - { - container.innerHTML = ""; - this.element.style.display = "none"; - updateModels(); - } - return deleted; - }) - .catch((err) => { - return false; - }); - } - if (!deleted) { - comfyButtonAlert(e.target, false); - } - button.disabled = false; - }, - }).element, - $el("div.search-models.input-dropdown-container", [ // TODO: magic class - moveDestinationInput, - searchDropdown.element, - ]), - new ComfyButton({ - icon: "file-move-outline", - tooltip: "Move file", - action: async(e) => { - const [button, icon, span] = comfyButtonDisambiguate(e.target); - button.disabled = true; - const confirmation = window.confirm("Move this file?"); - let moved = false; - if (confirmation) { - const container = this.elements.info; - const oldFile = container.dataset.path; - const [oldFilePath, oldFileName] = SearchPath.split(oldFile); - const newFile = ( - moveDestinationInput.value + - modelData.searchSeparator + - oldFileName - ); - moved = await comfyRequest( - `/model-manager/model/move`, - { - method: "POST", - body: JSON.stringify({ - "oldFile": oldFile, - "newFile": newFile, - }), - } - ) - .then((result) => { - const moved = result["success"]; - const message = result["alert"]; - if (message !== undefined) { - window.alert(message); - } - if (moved) - { - moveDestinationInput.value = ""; - container.innerHTML = ""; - this.element.style.display = "none"; - updateModels(); - } - return moved; - }) - .catch(err => { - return false; - }); - } - comfyButtonAlert(e.target, moved); - button.disabled = false; - }, - }).element, - ]), - ]), - $el("div.model-info-container", { - $: (el) => (this.elements.info = el), - "data-path": "", - }), - ]); - - [this.elements.tabButtons, this.elements.tabContents] = GenerateTabGroup([ - { name: "Overview", icon: "information-box-outline", tabContent: this.element }, - { name: "Metadata", icon: "file-document-outline", tabContent: $el("div", ["Metadata"]) }, - { name: "Tags", icon: "tag-outline", tabContent: $el("div", ["Tags"]) }, - { name: "Notes", icon: "pencil-outline", tabContent: $el("div", ["Notes"]) }, - ]); - } - - /** @returns {void} */ - show() { - this.element.style = ""; - this.element.scrollTop = 0; - } - - /** - * @param {boolean} promptUser - * @returns {Promise } - */ - async trySave(promptUser) { - if (this.element.style.display === "none") { - return true; - } - - const noteValue = this.elements.notes.value; - const savedNotesValue = this.#savedNotesValue; - if (noteValue.trim() === savedNotesValue.trim()) { - return true; - } - const saveChanges = !promptUser || window.confirm("Save notes?"); - if (saveChanges) { - const path = this.elements.info.dataset.path; - const saved = await saveNotes(path, noteValue); - if (!saved) { - window.alert("Failed to save notes!"); - return false; - } - this.#savedNotesValue = noteValue; - this.elements.markdown.innerHTML = marked.parse(noteValue); - } - else { - const discardChanges = window.confirm("Discard changes?"); - if (!discardChanges) { - return false; - } - else { - this.elements.notes.value = savedNotesValue; - } - } - return true; - } - - /** - * @param {boolean?} promptSave - * @returns {Promise } - */ - async tryHide(promptSave = true) { - const notes = this.elements.notes; - if (promptSave && notes !== undefined && notes !== null) { - const saved = await this.trySave(promptSave); - if (!saved) { - return false; - } - this.#savedNotesValue = ""; - this.elements.notes.value = ""; - } - this.element.style.display = "none"; - return true; - } - - /** - * @param {string} searchPath - * @param {() => Promise } updateModels - * @param {string} searchSeparator - */ - async update(searchPath, updateModels, searchSeparator) { - const path = encodeURIComponent(searchPath); - const [info, metadata, tags, noteText] = await comfyRequest(`/model-manager/model/info?path=${path}`) - .then((result) => { - const success = result["success"]; - const message = result["alert"]; + let updatedPreview = false; + if (confirmation) { + const container = this.elements.info; + const path = container.dataset.path; + const imageUrl = await previewSelect.getImage(); + if (imageUrl === PREVIEW_NONE_URI) { + const encodedPath = encodeURIComponent(path); + updatedPreview = await comfyRequest( + `/model-manager/preview/delete?path=${encodedPath}`, + { + method: 'POST', + body: JSON.stringify({}), + }, + ) + .then((result) => { + const message = result['alert']; if (message !== undefined) { - window.alert(message); + window.alert(message); } - if (!success) { - return undefined; - } - return [ - result["info"], - result["metadata"], - result["tags"], - result["notes"] - ]; + return result['success']; + }) + .catch((err) => { + return false; + }); + } else { + const formData = new FormData(); + formData.append('path', path); + const image = imageUrl[0] == '/' ? '' : imageUrl; + formData.append('image', image); + updatedPreview = await comfyRequest(`/model-manager/preview/set`, { + method: 'POST', + body: formData, }) - .catch((err) => { - console.log(err); - return undefined; - } - ); - if (info === undefined || info === null) { - return; - } - const infoHtml = this.elements.info; - infoHtml.innerHTML = ""; - infoHtml.dataset.path = searchPath; - const innerHtml = []; - const filename = info["File Name"]; - if (filename !== undefined && filename !== null && filename !== "") { - innerHtml.push( - $el("div.row", { - style: { margin: "8px 0 16px 0" }, - }, [ - $el("h1", { - style: { margin: "0" }, - }, [ - filename, - ]), - $el("div", [ - new ComfyButton({ - icon: "pencil", - tooltip: "Change file name", - classList: "comfyui-button icon-button", - action: async(e) => { - const [button, icon, span] = comfyButtonDisambiguate(e.target); - button.disabled = true; - const container = this.elements.info; - const oldFile = container.dataset.path; - const [oldFilePath, oldFileName] = SearchPath.split(oldFile); - const oldName = SearchPath.splitExtension(oldFileName)[0]; - const newName = window.prompt("New model name:", oldName); - let renamed = false; - if (newName !== null && newName !== "" && newName != oldName) { - const newFile = ( - oldFilePath + - searchSeparator + - newName + - SearchPath.splitExtension(oldFile)[1] - ); - renamed = await comfyRequest( - `/model-manager/model/move`, - { - method: "POST", - body: JSON.stringify({ - "oldFile": oldFile, - "newFile": newFile, - }), - } - ) - .then((result) => { - const renamed = result["success"]; - const message = result["alert"]; - if (message !== undefined) { - window.alert(message); - } - if (renamed) - { - container.innerHTML = ""; - this.element.style.display = "none"; - updateModels(); - } - return renamed; - }) - .catch(err => { - console.log(err); - return false; - }); - } - comfyButtonAlert(e.target, renamed); - button.disabled = false; - }, - }).element, - ]), - ]), - ); - } - - const fileDirectory = info["File Directory"]; - if (fileDirectory !== undefined && fileDirectory !== null && fileDirectory !== "") { - this.elements.moveDestinationInput.placeholder = fileDirectory - this.elements.moveDestinationInput.value = fileDirectory; // TODO: noise vs convenience - } - else { - this.elements.moveDestinationInput.placeholder = searchSeparator; - this.elements.moveDestinationInput.value = searchSeparator; - } - - const previewSelect = this.previewSelect; - const defaultUrl = previewSelect.elements.defaultUrl; - if (info["Preview"]) { - const imagePath = info["Preview"]["path"]; - const imageDateModified = info["Preview"]["dateModified"]; - defaultUrl.dataset.noimage = imageUri(imagePath, imageDateModified); - } - else { - defaultUrl.dataset.noimage = PREVIEW_NONE_URI; - } - previewSelect.resetModelInfoPreview(); - const setPreviewButton = this.elements.setPreviewButton; - setPreviewButton.style.display = previewSelect.defaultIsChecked() ? "none" : "block"; - - innerHtml.push($el("div", [ - previewSelect.elements.previews, - $el("div.row.tab-header", [ - $el("div", [ - new ComfyButton({ - content: "Load Workflow", - tooltip: "Attempt to load preview image workflow", - action: async () => { - const urlString = previewSelect.elements.defaultPreviews.children[0].src; - await loadWorkflow(urlString); - }, - }).element, - ]), - $el("div.row.tab-header-flex-block", [ - previewSelect.elements.radioGroup, - ]), - $el("div.row.tab-header-flex-block", [ - setPreviewButton, - ]), - ]), - $el("h2", ["File Info:"]), - $el("div", - (() => { - const elements = []; - for (const [key, value] of Object.entries(info)) { - if (value === undefined || value === null) { - continue; - } - - if (Array.isArray(value)) { - // currently only used for "Bucket Resolutions" - if (value.length > 0) { - elements.push($el("h2", [key + ":"])); - const text = TagCountMapToParagraph(value); - const div = $el("div"); - div.innerHTML = text; - elements.push(div); - } - } - else { - if (key === "Description") { - if (value !== "") { - elements.push($el("h2", [key + ":"])); - elements.push($el("p", [value])); - } - } - else if (key === "Preview") { - // - } - else { - if (value !== "") { - elements.push($el("p", [key + ": " + value])); - } - } - } - } - return elements; - })(), - ), - ])); - infoHtml.append.apply(infoHtml, innerHtml); - // TODO: set default value of dropdown and value to model type? - - /** @type {HTMLDivElement} */ - const metadataElement = this.elements.tabContents[1]; // TODO: remove magic value - const isMetadata = typeof metadata === 'object' && metadata !== null && Object.keys(metadata).length > 0; - metadataElement.innerHTML = ""; - metadataElement.append.apply(metadataElement, [ - $el("h1", ["Metadata"]), - $el("div", (() => { - const tableRows = []; - if (isMetadata) { - for (const [key, value] of Object.entries(metadata)) { - if (value === undefined || value === null) { - continue; - } - if (value !== "") { - tableRows.push($el("tr", [ - $el("th.model-metadata-key", [key]), - $el("th.model-metadata-value", [value]), - ])); - } - } - } - return $el("table.model-metadata", tableRows); - })(), - ), - ]); - const metadataButton = this.elements.tabButtons[1]; // TODO: remove magic value - metadataButton.style.display = isMetadata ? "" : "none"; - - /** @type {HTMLDivElement} */ - const tagsElement = this.elements.tabContents[2]; // TODO: remove magic value - const isTags = Array.isArray(tags) && tags.length > 0; - const tagsParagraph = $el("div", (() => { - const elements = []; - if (isTags) { - let text = TagCountMapToParagraph(tags); - const div = $el("div"); - div.innerHTML = text; - elements.push(div); + .then((result) => { + const message = result['alert']; + if (message !== undefined) { + window.alert(message); } - return elements; - })(), - ); - const tagGeneratorRandomizedOutput = $el("textarea.comfy-multiline-input", { - name: "random tag generator output", - rows: 4, - }); - const TAG_GENERATOR_SAMPLER_NAME = "model manager tag generator sampler"; - const tagGenerationCount = $el("input", { - type: "number", - name: "tag generator count", - step: 1, - min: 1, - value: this.#settingsElements["tag-generator-count"].value, - }); - const tagGenerationThreshold = $el("input", { - type: "number", - name: "tag generator threshold", - step: 1, - min: 1, - value: this.#settingsElements["tag-generator-threshold"].value, - }); - const selectedSamplerOption = this.#settingsElements["tag-generator-sampler-method"].value; - const samplerOptions = ["Frequency", "Uniform"]; - const samplerRadioGroup = $radioGroup({ - name: TAG_GENERATOR_SAMPLER_NAME, - onchange: (value) => {}, - options: samplerOptions.map(option => { return { value: option }; }), - }); - const samplerOptionInputs = samplerRadioGroup.getElementsByTagName("input"); - for (let i = 0; i < samplerOptionInputs.length; i++) { - const samplerOptionInput = samplerOptionInputs[i]; - if (samplerOptionInput.value === selectedSamplerOption) { - samplerOptionInput.click(); - break; - } + return result['success']; + }) + .catch((err) => { + return false; + }); + } + if (updatedPreview) { + updateModels(); + const previewSelect = this.previewSelect; + previewSelect.elements.defaultUrl.dataset.noimage = + PREVIEW_NONE_URI; + previewSelect.resetModelInfoPreview(); + this.element.style.display = 'none'; + } } - const tagGenerator = $el( - "div", [ - $el("h1", ["Tags"]), - $el("h2", { style: { margin: "0px 0px 16px 0px" } }, ["Random Tag Generator"]), - $el("div", [ - $el("details.tag-generator-settings", { - style: { margin: "10px 0", display: "none" }, - open: false, - }, [ - $el("summary", ["Settings"]), - $el("div", [ - "Sampling Method", - samplerRadioGroup, - ]), - $el("label", [ - "Count", - tagGenerationCount, - ]), - $el("label", [ - "Threshold", - tagGenerationThreshold, - ]), - ]), - tagGeneratorRandomizedOutput, - new ComfyButton({ - content: "Randomize", - tooltip: "Randomly generate subset of tags", - action: () => { - const samplerName = document.querySelector(`input[name="${TAG_GENERATOR_SAMPLER_NAME}"]:checked`).value; - const sampler = samplerName === "Frequency" ? ModelInfo.ProbabilisticTagSampling : ModelInfo.UniformTagSampling; - const sampleCount = tagGenerationCount.value; - const frequencyThreshold = tagGenerationThreshold.value; - const tags = ParseTagParagraph(tagsParagraph.innerText); - const sampledTags = sampler(tags, sampleCount, frequencyThreshold); - tagGeneratorRandomizedOutput.value = sampledTags.join(", "); - }, - }).element, - ]), - ] - ) - tagsElement.innerHTML = ""; - tagsElement.append.apply(tagsElement, [ - tagGenerator, - $el("div", [ - $el("h2", { - style: { - margin: "24px 0px 8px 0px" - } - }, ["Tags"]), - tagsParagraph, + comfyButtonAlert(e.target, updatedPreview); + button.disabled = false; + }, + }).element; + this.elements.setPreviewButton = setPreviewButton; + previewSelect.elements.radioButtons.addEventListener('change', (e) => { + setPreviewButton.style.display = previewSelect.defaultIsChecked() + ? 'none' + : 'block'; + }); + + this.element = $el( + 'div', + { + style: { display: 'none' }, + }, + [ + $el( + 'div.row.tab-header', + { + display: 'block', + }, + [ + $el('div.row.tab-header-flex-block', [ + new ComfyButton({ + icon: 'trash-can-outline', + tooltip: 'Delete model FOREVER', + classList: 'comfyui-button icon-button', + action: async (e) => { + const [button, icon, span] = comfyButtonDisambiguate( + e.target, + ); + button.disabled = true; + const affirmation = 'delete'; + const confirmation = window.prompt( + 'Type "' + + affirmation + + '" to delete the model PERMANENTLY.\n\nThis includes all image or text files.', + ); + let deleted = false; + if (confirmation === affirmation) { + const container = this.elements.info; + const path = encodeURIComponent(container.dataset.path); + deleted = await comfyRequest( + `/model-manager/model/delete?path=${path}`, + { + method: 'POST', + }, + ) + .then((result) => { + const deleted = result['success']; + const message = result['alert']; + if (message !== undefined) { + window.alert(message); + } + if (deleted) { + container.innerHTML = ''; + this.element.style.display = 'none'; + updateModels(); + } + return deleted; + }) + .catch((err) => { + return false; + }); + } + if (!deleted) { + comfyButtonAlert(e.target, false); + } + button.disabled = false; + }, + }).element, + $el('div.search-models.input-dropdown-container', [ + // TODO: magic class + moveDestinationInput, + searchDropdown.element, + ]), + new ComfyButton({ + icon: 'file-move-outline', + tooltip: 'Move file', + action: async (e) => { + const [button, icon, span] = comfyButtonDisambiguate( + e.target, + ); + button.disabled = true; + const confirmation = window.confirm('Move this file?'); + let moved = false; + if (confirmation) { + const container = this.elements.info; + const oldFile = container.dataset.path; + const [oldFilePath, oldFileName] = + SearchPath.split(oldFile); + const newFile = + moveDestinationInput.value + + modelData.searchSeparator + + oldFileName; + moved = await comfyRequest(`/model-manager/model/move`, { + method: 'POST', + body: JSON.stringify({ + oldFile: oldFile, + newFile: newFile, + }), + }) + .then((result) => { + const moved = result['success']; + const message = result['alert']; + if (message !== undefined) { + window.alert(message); + } + if (moved) { + moveDestinationInput.value = ''; + container.innerHTML = ''; + this.element.style.display = 'none'; + updateModels(); + } + return moved; + }) + .catch((err) => { + return false; + }); + } + comfyButtonAlert(e.target, moved); + button.disabled = false; + }, + }).element, ]), - ]); - const tagButton = this.elements.tabButtons[2]; // TODO: remove magic value - tagButton.style.display = isTags ? "" : "none"; + ], + ), + $el('div.model-info-container', { + $: (el) => (this.elements.info = el), + 'data-path': '', + }), + ], + ); - const saveIcon = "content-save"; - const savingIcon = "cloud-upload-outline"; + [this.elements.tabButtons, this.elements.tabContents] = GenerateTabGroup([ + { + name: 'Overview', + icon: 'information-box-outline', + tabContent: this.element, + }, + { + name: 'Metadata', + icon: 'file-document-outline', + tabContent: $el('div', ['Metadata']), + }, + { + name: 'Tags', + icon: 'tag-outline', + tabContent: $el('div', ['Tags']), + }, + { + name: 'Notes', + icon: 'pencil-outline', + tabContent: $el('div', ['Notes']), + }, + ]); + } - const saveNotesButton = new ComfyButton({ - icon: saveIcon, - tooltip: "Save note", - classList: "comfyui-button icon-button", - action: async (e) => { - const [button, icon, span] = comfyButtonDisambiguate(e.target); - button.disabled = true; - const saved = await this.trySave(false); - comfyButtonAlert(e.target, saved); - button.disabled = false; + /** @returns {void} */ + show() { + this.element.style = ''; + this.element.scrollTop = 0; + } + + /** + * @param {boolean} promptUser + * @returns {Promise } + */ + async trySave(promptUser) { + if (this.element.style.display === 'none') { + return true; + } + + const noteValue = this.elements.notes.value; + const savedNotesValue = this.#savedNotesValue; + if (noteValue.trim() === savedNotesValue.trim()) { + return true; + } + const saveChanges = !promptUser || window.confirm('Save notes?'); + if (saveChanges) { + const path = this.elements.info.dataset.path; + const saved = await saveNotes(path, noteValue); + if (!saved) { + window.alert('Failed to save notes!'); + return false; + } + this.#savedNotesValue = noteValue; + this.elements.markdown.innerHTML = marked.parse(noteValue); + } else { + const discardChanges = window.confirm('Discard changes?'); + if (!discardChanges) { + return false; + } else { + this.elements.notes.value = savedNotesValue; + } + } + return true; + } + + /** + * @param {boolean?} promptSave + * @returns {Promise } + */ + async tryHide(promptSave = true) { + const notes = this.elements.notes; + if (promptSave && notes !== undefined && notes !== null) { + const saved = await this.trySave(promptSave); + if (!saved) { + return false; + } + this.#savedNotesValue = ''; + this.elements.notes.value = ''; + } + this.element.style.display = 'none'; + return true; + } + + /** + * @param {string} searchPath + * @param {() => Promise } updateModels + * @param {string} searchSeparator + */ + async update(searchPath, updateModels, searchSeparator) { + const path = encodeURIComponent(searchPath); + const [info, metadata, tags, noteText] = await comfyRequest( + `/model-manager/model/info?path=${path}`, + ) + .then((result) => { + const success = result['success']; + const message = result['alert']; + if (message !== undefined) { + window.alert(message); + } + if (!success) { + return undefined; + } + return [ + result['info'], + result['metadata'], + result['tags'], + result['notes'], + ]; + }) + .catch((err) => { + console.log(err); + return undefined; + }); + if (info === undefined || info === null) { + return; + } + const infoHtml = this.elements.info; + infoHtml.innerHTML = ''; + infoHtml.dataset.path = searchPath; + const innerHtml = []; + const filename = info['File Name']; + if (filename !== undefined && filename !== null && filename !== '') { + innerHtml.push( + $el( + 'div.row', + { + style: { margin: '8px 0 16px 0' }, + }, + [ + $el( + 'h1', + { + style: { margin: '0' }, + }, + [filename], + ), + $el('div', [ + new ComfyButton({ + icon: 'pencil', + tooltip: 'Change file name', + classList: 'comfyui-button icon-button', + action: async (e) => { + const [button, icon, span] = comfyButtonDisambiguate( + e.target, + ); + button.disabled = true; + const container = this.elements.info; + const oldFile = container.dataset.path; + const [oldFilePath, oldFileName] = SearchPath.split(oldFile); + const oldName = SearchPath.splitExtension(oldFileName)[0]; + const newName = window.prompt('New model name:', oldName); + let renamed = false; + if ( + newName !== null && + newName !== '' && + newName != oldName + ) { + const newFile = + oldFilePath + + searchSeparator + + newName + + SearchPath.splitExtension(oldFile)[1]; + renamed = await comfyRequest(`/model-manager/model/move`, { + method: 'POST', + body: JSON.stringify({ + oldFile: oldFile, + newFile: newFile, + }), + }) + .then((result) => { + const renamed = result['success']; + const message = result['alert']; + if (message !== undefined) { + window.alert(message); + } + if (renamed) { + container.innerHTML = ''; + this.element.style.display = 'none'; + updateModels(); + } + return renamed; + }) + .catch((err) => { + console.log(err); + return false; + }); + } + comfyButtonAlert(e.target, renamed); + button.disabled = false; + }, + }).element, + ]), + ], + ), + ); + } + + const fileDirectory = info['File Directory']; + if ( + fileDirectory !== undefined && + fileDirectory !== null && + fileDirectory !== '' + ) { + this.elements.moveDestinationInput.placeholder = fileDirectory; + this.elements.moveDestinationInput.value = fileDirectory; // TODO: noise vs convenience + } else { + this.elements.moveDestinationInput.placeholder = searchSeparator; + this.elements.moveDestinationInput.value = searchSeparator; + } + + const previewSelect = this.previewSelect; + const defaultUrl = previewSelect.elements.defaultUrl; + if (info['Preview']) { + const imagePath = info['Preview']['path']; + const imageDateModified = info['Preview']['dateModified']; + defaultUrl.dataset.noimage = imageUri(imagePath, imageDateModified); + } else { + defaultUrl.dataset.noimage = PREVIEW_NONE_URI; + } + previewSelect.resetModelInfoPreview(); + const setPreviewButton = this.elements.setPreviewButton; + setPreviewButton.style.display = previewSelect.defaultIsChecked() + ? 'none' + : 'block'; + + innerHtml.push( + $el('div', [ + previewSelect.elements.previews, + $el('div.row.tab-header', [ + $el('div', [ + new ComfyButton({ + content: 'Load Workflow', + tooltip: 'Attempt to load preview image workflow', + action: async () => { + const urlString = + previewSelect.elements.defaultPreviews.children[0].src; + await loadWorkflow(urlString); + }, + }).element, + ]), + $el('div.row.tab-header-flex-block', [ + previewSelect.elements.radioGroup, + ]), + $el('div.row.tab-header-flex-block', [setPreviewButton]), + ]), + $el('h2', ['File Info:']), + $el( + 'div', + (() => { + const elements = []; + for (const [key, value] of Object.entries(info)) { + if (value === undefined || value === null) { + continue; + } + + if (Array.isArray(value)) { + // currently only used for "Bucket Resolutions" + if (value.length > 0) { + elements.push($el('h2', [key + ':'])); + const text = TagCountMapToParagraph(value); + const div = $el('div'); + div.innerHTML = text; + elements.push(div); + } + } else { + if (key === 'Description') { + if (value !== '') { + elements.push($el('h2', [key + ':'])); + elements.push($el('p', [value])); + } + } else if (key === 'Preview') { + // + } else { + if (value !== '') { + elements.push($el('p', [key + ': ' + value])); + } + } + } + } + return elements; + })(), + ), + ]), + ); + infoHtml.append.apply(infoHtml, innerHtml); + // TODO: set default value of dropdown and value to model type? + + /** @type {HTMLDivElement} */ + const metadataElement = this.elements.tabContents[1]; // TODO: remove magic value + const isMetadata = + typeof metadata === 'object' && + metadata !== null && + Object.keys(metadata).length > 0; + metadataElement.innerHTML = ''; + metadataElement.append.apply(metadataElement, [ + $el('h1', ['Metadata']), + $el( + 'div', + (() => { + const tableRows = []; + if (isMetadata) { + for (const [key, value] of Object.entries(metadata)) { + if (value === undefined || value === null) { + continue; + } + if (value !== '') { + tableRows.push( + $el('tr', [ + $el('th.model-metadata-key', [key]), + $el('th.model-metadata-value', [value]), + ]), + ); + } + } + } + return $el('table.model-metadata', tableRows); + })(), + ), + ]); + const metadataButton = this.elements.tabButtons[1]; // TODO: remove magic value + metadataButton.style.display = isMetadata ? '' : 'none'; + + /** @type {HTMLDivElement} */ + const tagsElement = this.elements.tabContents[2]; // TODO: remove magic value + const isTags = Array.isArray(tags) && tags.length > 0; + const tagsParagraph = $el( + 'div', + (() => { + const elements = []; + if (isTags) { + let text = TagCountMapToParagraph(tags); + const div = $el('div'); + div.innerHTML = text; + elements.push(div); + } + return elements; + })(), + ); + const tagGeneratorRandomizedOutput = $el('textarea.comfy-multiline-input', { + name: 'random tag generator output', + rows: 4, + }); + const TAG_GENERATOR_SAMPLER_NAME = 'model manager tag generator sampler'; + const tagGenerationCount = $el('input', { + type: 'number', + name: 'tag generator count', + step: 1, + min: 1, + value: this.#settingsElements['tag-generator-count'].value, + }); + const tagGenerationThreshold = $el('input', { + type: 'number', + name: 'tag generator threshold', + step: 1, + min: 1, + value: this.#settingsElements['tag-generator-threshold'].value, + }); + const selectedSamplerOption = + this.#settingsElements['tag-generator-sampler-method'].value; + const samplerOptions = ['Frequency', 'Uniform']; + const samplerRadioGroup = $radioGroup({ + name: TAG_GENERATOR_SAMPLER_NAME, + onchange: (value) => {}, + options: samplerOptions.map((option) => { + return { value: option }; + }), + }); + const samplerOptionInputs = samplerRadioGroup.getElementsByTagName('input'); + for (let i = 0; i < samplerOptionInputs.length; i++) { + const samplerOptionInput = samplerOptionInputs[i]; + if (samplerOptionInput.value === selectedSamplerOption) { + samplerOptionInput.click(); + break; + } + } + const tagGenerator = $el('div', [ + $el('h1', ['Tags']), + $el('h2', { style: { margin: '0px 0px 16px 0px' } }, [ + 'Random Tag Generator', + ]), + $el('div', [ + $el( + 'details.tag-generator-settings', + { + style: { margin: '10px 0', display: 'none' }, + open: false, + }, + [ + $el('summary', ['Settings']), + $el('div', ['Sampling Method', samplerRadioGroup]), + $el('label', ['Count', tagGenerationCount]), + $el('label', ['Threshold', tagGenerationThreshold]), + ], + ), + tagGeneratorRandomizedOutput, + new ComfyButton({ + content: 'Randomize', + tooltip: 'Randomly generate subset of tags', + action: () => { + const samplerName = document.querySelector( + `input[name="${TAG_GENERATOR_SAMPLER_NAME}"]:checked`, + ).value; + const sampler = + samplerName === 'Frequency' + ? ModelInfo.ProbabilisticTagSampling + : ModelInfo.UniformTagSampling; + const sampleCount = tagGenerationCount.value; + const frequencyThreshold = tagGenerationThreshold.value; + const tags = ParseTagParagraph(tagsParagraph.innerText); + const sampledTags = sampler(tags, sampleCount, frequencyThreshold); + tagGeneratorRandomizedOutput.value = sampledTags.join(', '); + }, + }).element, + ]), + ]); + tagsElement.innerHTML = ''; + tagsElement.append.apply(tagsElement, [ + tagGenerator, + $el('div', [ + $el( + 'h2', + { + style: { + margin: '24px 0px 8px 0px', }, + }, + ['Tags'], + ), + tagsParagraph, + ]), + ]); + const tagButton = this.elements.tabButtons[2]; // TODO: remove magic value + tagButton.style.display = isTags ? '' : 'none'; + + const saveIcon = 'content-save'; + const savingIcon = 'cloud-upload-outline'; + + const saveNotesButton = new ComfyButton({ + icon: saveIcon, + tooltip: 'Save note', + classList: 'comfyui-button icon-button', + action: async (e) => { + const [button, icon, span] = comfyButtonDisambiguate(e.target); + button.disabled = true; + const saved = await this.trySave(false); + comfyButtonAlert(e.target, saved); + button.disabled = false; + }, + }).element; + + const saveDebounce = debounce(async () => { + const saveIconClass = 'mdi-' + saveIcon; + const savingIconClass = 'mdi-' + savingIcon; + const iconElement = saveNotesButton.getElementsByTagName('i')[0]; + iconElement.classList.remove(saveIconClass); + iconElement.classList.add(savingIconClass); + const saved = await this.trySave(false); + iconElement.classList.remove(savingIconClass); + iconElement.classList.add(saveIconClass); + }, 1000); + + /** @type {HTMLDivElement} */ + const notesElement = this.elements.tabContents[3]; // TODO: remove magic value + notesElement.innerHTML = ''; + const markdown = $el('div', {}, ''); + markdown.innerHTML = marked.parse(noteText); + + notesElement.append.apply( + notesElement, + (() => { + const notes = $el('textarea.comfy-multiline-input', { + name: 'model notes', + value: noteText, + oninput: (e) => { + if (this.#settingsElements['model-info-autosave-notes'].checked) { + saveDebounce(); + } + }, + }); + + if (navigator.userAgent.includes('Mac')) { + new KeyComboListener(['MetaLeft', 'KeyS'], saveDebounce, notes); + new KeyComboListener(['MetaRight', 'KeyS'], saveDebounce, notes); + } else { + new KeyComboListener(['ControlLeft', 'KeyS'], saveDebounce, notes); + new KeyComboListener(['ControlRight', 'KeyS'], saveDebounce, notes); + } + + this.elements.notes = notes; + this.elements.markdown = markdown; + this.#savedNotesValue = noteText; + + const notes_editor = $el( + 'div', + { + style: { + display: noteText == '' ? 'flex' : 'none', + height: '100%', + 'min-height': '60px', + }, + }, + notes, + ); + const notes_viewer = $el( + 'div', + { + style: { + display: noteText == '' ? 'none' : 'flex', + height: '100%', + 'min-height': '60px', + overflow: 'scroll', + 'overflow-wrap': 'anywhere', + }, + }, + markdown, + ); + + const editNotesButton = new ComfyButton({ + icon: 'pencil', + tooltip: 'Change file name', + classList: 'comfyui-button icon-button', + action: async () => { + notes_editor.style.display = + notes_editor.style.display == 'flex' ? 'none' : 'flex'; + notes_viewer.style.display = + notes_viewer.style.display == 'none' ? 'flex' : 'none'; + }, }).element; - const saveDebounce = debounce(async() => { - const saveIconClass = "mdi-" + saveIcon; - const savingIconClass = "mdi-" + savingIcon; - const iconElement = saveNotesButton.getElementsByTagName("i")[0]; - iconElement.classList.remove(saveIconClass); - iconElement.classList.add(savingIconClass); - const saved = await this.trySave(false); - iconElement.classList.remove(savingIconClass); - iconElement.classList.add(saveIconClass); - }, 1000); + return [ + $el( + 'div.row', + { + style: { 'align-items': 'center' }, + }, + [$el('h1', ['Notes']), saveNotesButton, editNotesButton], + ), + notes_editor, + notes_viewer, + ]; + })(), + ); + } - /** @type {HTMLDivElement} */ - const notesElement = this.elements.tabContents[3]; // TODO: remove magic value - notesElement.innerHTML = ""; - const markdown = $el("div", {}, ""); - markdown.innerHTML = marked.parse(noteText); - - notesElement.append.apply(notesElement, - (() => { - const notes = $el("textarea.comfy-multiline-input", { - name: "model notes", - value: noteText, - oninput: (e) => { - if (this.#settingsElements["model-info-autosave-notes"].checked) { - saveDebounce(); - } - }, - }); - - if (navigator.userAgent.includes("Mac")) { - new KeyComboListener( - ["MetaLeft", "KeyS"], - saveDebounce, - notes, - ); - new KeyComboListener( - ["MetaRight", "KeyS"], - saveDebounce, - notes, - ); - } - else { - new KeyComboListener( - ["ControlLeft", "KeyS"], - saveDebounce, - notes, - ); - new KeyComboListener( - ["ControlRight", "KeyS"], - saveDebounce, - notes, - ); - } - - this.elements.notes = notes; - this.elements.markdown = markdown; - this.#savedNotesValue = noteText; - - const notes_editor = $el( - "div", - { - style: { - "display": noteText == "" ? "flex" : "none", - "height": "100%", - "min-height": "60px" - }, - }, - notes - ); - const notes_viewer = $el( - "div", - { - style: { - "display": noteText == "" ? "none" : "flex", - "height": "100%", - "min-height": "60px", - "overflow": "scroll", - "overflow-wrap": "anywhere" - }, - }, - markdown - ); - - const editNotesButton = new ComfyButton({ - icon: "pencil", - tooltip: "Change file name", - classList: "comfyui-button icon-button", - action: async () => { - notes_editor.style.display = notes_editor.style.display == "flex" ? "none" : "flex"; - notes_viewer.style.display = notes_viewer.style.display == "none" ? "flex" : "none"; - }, - }).element; - - return [ - $el("div.row", { - style: { "align-items": "center" }, - }, [ - $el("h1", ["Notes"]), - saveNotesButton, - editNotesButton, - ]), - notes_editor, - notes_viewer, - ]; - })() - ); + static UniformTagSampling( + tagsAndCounts, + sampleCount, + frequencyThreshold = 0, + ) { + const data = tagsAndCounts.filter((x) => x[1] >= frequencyThreshold); + let count = data.length; + const samples = []; + for (let i = 0; i < sampleCount; i++) { + if (count === 0) { + break; + } + const index = Math.floor(Math.random() * count); + const pair = data.splice(index, 1)[0]; + samples.push(pair); + count -= 1; } + const sortedSamples = samples.sort((x1, x2) => { + return parseInt(x2[1]) - parseInt(x1[1]); + }); + return sortedSamples.map((x) => x[0]); + } - static UniformTagSampling(tagsAndCounts, sampleCount, frequencyThreshold = 0) { - const data = tagsAndCounts.filter(x => x[1] >= frequencyThreshold); - let count = data.length; - const samples = []; - for (let i = 0; i < sampleCount; i++) { - if (count === 0) { break; } - const index = Math.floor(Math.random() * count); - const pair = data.splice(index, 1)[0]; - samples.push(pair); - count -= 1; - } - const sortedSamples = samples.sort((x1, x2) => { return parseInt(x2[1]) - parseInt(x1[1]) }); - return sortedSamples.map(x => x[0]); - } - - static ProbabilisticTagSampling(tagsAndCounts, sampleCount, frequencyThreshold = 0) { - const data = tagsAndCounts.filter(x => x[1] >= frequencyThreshold); - let tagFrequenciesSum = data.reduce((accumulator, x) => accumulator + x[1], 0); - let count = data.length; - const samples = []; - for (let i = 0; i < sampleCount; i++) { - if (count === 0) { break; } - const index = (() => { - let frequencyIndex = Math.floor(Math.random() * tagFrequenciesSum); - return data.findIndex(x => { - const frequency = x[1]; - if (frequency > frequencyIndex) { - return true; - } - frequencyIndex = frequencyIndex - frequency; - return false; - }); - })(); - const pair = data.splice(index, 1)[0]; - samples.push(pair); - tagFrequenciesSum -= pair[1]; - count -= 1; - } - const sortedSamples = samples.sort((x1, x2) => { return parseInt(x2[1]) - parseInt(x1[1]) }); - return sortedSamples.map(x => x[0]); + static ProbabilisticTagSampling( + tagsAndCounts, + sampleCount, + frequencyThreshold = 0, + ) { + const data = tagsAndCounts.filter((x) => x[1] >= frequencyThreshold); + let tagFrequenciesSum = data.reduce( + (accumulator, x) => accumulator + x[1], + 0, + ); + let count = data.length; + const samples = []; + for (let i = 0; i < sampleCount; i++) { + if (count === 0) { + break; + } + const index = (() => { + let frequencyIndex = Math.floor(Math.random() * tagFrequenciesSum); + return data.findIndex((x) => { + const frequency = x[1]; + if (frequency > frequencyIndex) { + return true; + } + frequencyIndex = frequencyIndex - frequency; + return false; + }); + })(); + const pair = data.splice(index, 1)[0]; + samples.push(pair); + tagFrequenciesSum -= pair[1]; + count -= 1; } + const sortedSamples = samples.sort((x1, x2) => { + return parseInt(x2[1]) - parseInt(x1[1]); + }); + return sortedSamples.map((x) => x[0]); + } } class Civitai { - /** - * Get model info from Civitai. - * - * @param {string} id - Model ID. - * @param {string} apiPath - Civitai request subdirectory. "models" for 'model' urls. "model-version" for 'api' urls. - * - * @returns {Promise