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/.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 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 new file mode 100644 index 0000000..f2c3f14 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,20 @@ +{ + "cSpell.words": [ + "apng", + "Civitai", + "ckpt", + "comfyui", + "FYUIKMNVB", + "gguf", + "gligen", + "jfif", + "locon", + "loras", + "noimage", + "onnx", + "rfilename", + "unet", + "upscaler" + ], + "editor.defaultFormatter": "esbenp.prettier-vscode" +} \ No newline at end of file diff --git a/web/downshow.js b/web/downshow.js new file mode 100644 index 0000000..8c36e2d --- /dev/null +++ b/web/downshow.js @@ -0,0 +1,231 @@ +/** + * downshow.js -- A javascript library to convert HTML to markdown. + * + * Copyright (c) 2013 Alex Cornejo. + * + * Original Markdown Copyright (c) 2004-2005 John Gruber + * + * + * Redistributable under a BSD-style open source license. + * + * downshow has no external dependencies. It has been tested in chrome and + * firefox, it probably works in internet explorer, but YMMV. + * + * Basic Usage: + * + * downshow(document.getElementById('#yourid').innerHTML); + * + * TODO: + * - Remove extra whitespace between words in headers and other places. + */ + +(function () { + var doc; + + // Use browser DOM with jsdom as a fallback (for node.js) + try { + doc = document; + } catch(e) { + var jsdom = require("jsdom").jsdom; + doc = jsdom(""); + } + + /** + * Returns every element in root in their bfs traversal order. + * + * In the process it transforms any nested lists to conform to the w3c + * standard, see: http://www.w3.org/wiki/HTML_lists#Nesting_lists + */ + function bfsOrder(root) { + var inqueue = [root], outqueue = []; + root._bfs_parent = null; + while (inqueue.length > 0) { + var elem = inqueue.shift(); + outqueue.push(elem); + var children = elem.childNodes; + var liParent = null; + for (var i=0 ; i 0) { + if (prefix && suffix) + node._bfs_text = prefix + content + suffix; + else + node._bfs_text = content; + } else + node._bfs_text = ''; + } + + /** + * Get a node's content. + */ + function getContent(node) { + var text = '', atom; + for (var i = 0; i 0) + setContent(node, '[' + text + '](' + href + (title ? ' "' + title + '"' : '') + ')'); + else + setContent(node, ''); + } else if (node.tagName === 'IMG') { + var src = node.getAttribute('src') ? nltrim(node.getAttribute('src')) : '', alt = node.alt ? nltrim(node.alt) : '', caption = node.title ? nltrim(node.title) : ''; + if (src.length > 0) + setContent(node, '![' + alt + '](' + src + (caption ? ' "' + caption + '"' : '') + ')'); + else + setContent(node, ''); + } else if (node.tagName === 'BLOCKQUOTE') { + var block_content = getContent(node); + if (block_content.length > 0) + setContent(node, prefixBlock('> ', block_content), '\n\n', '\n\n'); + else + setContent(node, ''); + } else if (node.tagName === 'CODE') { + if (node._bfs_parent.tagName === 'PRE' && node._bfs_parent._bfs_parent !== null) + setContent(node, prefixBlock(' ', getContent(node))); + else + setContent(node, nltrim(getContent(node)), '`', '`'); + } else if (node.tagName === 'LI') { + var list_content = getContent(node); + if (list_content.length > 0) + if (node._bfs_parent.tagName === 'OL') + setContent(node, trim(prefixBlock(' ', list_content, true)), '1. ', '\n\n'); + else + setContent(node, trim(prefixBlock(' ', list_content, true)), '- ', '\n\n'); + else + setContent(node, ''); + } else + setContent(node, getContent(node)); + } + + function downshow(html, options) { + var root = doc.createElement('pre'); + root.innerHTML = html; + var nodes = bfsOrder(root).reverse(), i; + + if (options && options.nodeParser) { + for (i = 0; i )+[^\n]*)\n+(\n(?:> )+)/g, "$1\n$2") + // remove empty blockquotes + .replace(/\n((?:> )+[ ]*\n)+/g, '\n\n') + // remove extra newlines + .replace(/\n[ \t]*(?:\n[ \t]*)+\n/g,'\n\n') + // remove trailing whitespace + .replace(/\s\s*$/, '') + // convert lists to inline when not using paragraphs + .replace(/^([ \t]*(?:\d+\.|\+|\-)[^\n]*)\n\n+(?=[ \t]*(?:\d+\.|\+|\-|\*)[^\n]*)/gm, "$1\n") + // remove starting newlines + .replace(/^\n\n*/, ''); + } + + // Export for use in server and client. + if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') + module.exports = downshow; + else if (typeof define === 'function' && define.amd) + define([], function () {return downshow;}); + else + window.downshow = downshow; + })(); \ 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/marked.js b/web/marked.js new file mode 100644 index 0000000..f5cea94 --- /dev/null +++ b/web/marked.js @@ -0,0 +1,2498 @@ +/** + * marked v14.1.0 - a markdown parser + * Copyright (c) 2011-2024, Christopher Jeffrey. (MIT Licensed) + * https://github.com/markedjs/marked + */ + +/** + * DO NOT EDIT THIS FILE + * The code in this file is generated from files in ./src/ + */ + +/** + * Gets the original marked default options. + */ +function _getDefaults() { + return { + async: false, + breaks: false, + extensions: null, + gfm: true, + hooks: null, + pedantic: false, + renderer: null, + silent: false, + tokenizer: null, + walkTokens: null, + }; +} +let _defaults = _getDefaults(); +function changeDefaults(newDefaults) { + _defaults = newDefaults; +} + +/** + * Helpers + */ +const escapeTest = /[&<>"']/; +const escapeReplace = new RegExp(escapeTest.source, 'g'); +const escapeTestNoEncode = /[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/; +const escapeReplaceNoEncode = new RegExp(escapeTestNoEncode.source, 'g'); +const escapeReplacements = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', +}; +const getEscapeReplacement = (ch) => escapeReplacements[ch]; +function escape$1(html, encode) { + if (encode) { + if (escapeTest.test(html)) { + return html.replace(escapeReplace, getEscapeReplacement); + } + } + else { + if (escapeTestNoEncode.test(html)) { + return html.replace(escapeReplaceNoEncode, getEscapeReplacement); + } + } + return html; +} +const caret = /(^|[^\[])\^/g; +function edit(regex, opt) { + let source = typeof regex === 'string' ? regex : regex.source; + opt = opt || ''; + const obj = { + replace: (name, val) => { + let valSource = typeof val === 'string' ? val : val.source; + valSource = valSource.replace(caret, '$1'); + source = source.replace(name, valSource); + return obj; + }, + getRegex: () => { + return new RegExp(source, opt); + }, + }; + return obj; +} +function cleanUrl(href) { + try { + href = encodeURI(href).replace(/%25/g, '%'); + } + catch { + return null; + } + return href; +} +const noopTest = { exec: () => null }; +function splitCells(tableRow, count) { + // ensure that every cell-delimiting pipe has a space + // before it to distinguish it from an escaped pipe + const row = tableRow.replace(/\|/g, (match, offset, str) => { + let escaped = false; + let curr = offset; + while (--curr >= 0 && str[curr] === '\\') + escaped = !escaped; + if (escaped) { + // odd number of slashes means | is escaped + // so we leave it alone + return '|'; + } + else { + // add space before unescaped | + return ' |'; + } + }), cells = row.split(/ \|/); + let i = 0; + // First/last cell in a row cannot be empty if it has no leading/trailing pipe + if (!cells[0].trim()) { + cells.shift(); + } + if (cells.length > 0 && !cells[cells.length - 1].trim()) { + cells.pop(); + } + if (count) { + if (cells.length > count) { + cells.splice(count); + } + else { + while (cells.length < count) + cells.push(''); + } + } + for (; i < cells.length; i++) { + // leading or trailing whitespace is ignored per the gfm spec + cells[i] = cells[i].trim().replace(/\\\|/g, '|'); + } + return cells; +} +/** + * Remove trailing 'c's. Equivalent to str.replace(/c*$/, ''). + * /c*$/ is vulnerable to REDOS. + * + * @param str + * @param c + * @param invert Remove suffix of non-c chars instead. Default falsey. + */ +function rtrim(str, c, invert) { + const l = str.length; + if (l === 0) { + return ''; + } + // Length of suffix matching the invert condition. + let suffLen = 0; + // Step left until we fail to match the invert condition. + while (suffLen < l) { + const currChar = str.charAt(l - suffLen - 1); + if (currChar === c && !invert) { + suffLen++; + } + else if (currChar !== c && invert) { + suffLen++; + } + else { + break; + } + } + return str.slice(0, l - suffLen); +} +function findClosingBracket(str, b) { + if (str.indexOf(b[1]) === -1) { + return -1; + } + let level = 0; + for (let i = 0; i < str.length; i++) { + if (str[i] === '\\') { + i++; + } + else if (str[i] === b[0]) { + level++; + } + else if (str[i] === b[1]) { + level--; + if (level < 0) { + return i; + } + } + } + return -1; +} + +function outputLink(cap, link, raw, lexer) { + const href = link.href; + const title = link.title ? escape$1(link.title) : null; + const text = cap[1].replace(/\\([\[\]])/g, '$1'); + if (cap[0].charAt(0) !== '!') { + lexer.state.inLink = true; + const token = { + type: 'link', + raw, + href, + title, + text, + tokens: lexer.inlineTokens(text), + }; + lexer.state.inLink = false; + return token; + } + return { + type: 'image', + raw, + href, + title, + text: escape$1(text), + }; +} +function indentCodeCompensation(raw, text) { + const matchIndentToCode = raw.match(/^(\s+)(?:```)/); + if (matchIndentToCode === null) { + return text; + } + const indentToCode = matchIndentToCode[1]; + return text + .split('\n') + .map(node => { + const matchIndentInNode = node.match(/^\s+/); + if (matchIndentInNode === null) { + return node; + } + const [indentInNode] = matchIndentInNode; + if (indentInNode.length >= indentToCode.length) { + return node.slice(indentToCode.length); + } + return node; + }) + .join('\n'); +} +/** + * Tokenizer + */ +class _Tokenizer { + options; + rules; // set by the lexer + lexer; // set by the lexer + constructor(options) { + this.options = options || _defaults; + } + space(src) { + const cap = this.rules.block.newline.exec(src); + if (cap && cap[0].length > 0) { + return { + type: 'space', + raw: cap[0], + }; + } + } + code(src) { + const cap = this.rules.block.code.exec(src); + if (cap) { + const text = cap[0].replace(/^ {1,4}/gm, ''); + return { + type: 'code', + raw: cap[0], + codeBlockStyle: 'indented', + text: !this.options.pedantic + ? rtrim(text, '\n') + : text, + }; + } + } + fences(src) { + const cap = this.rules.block.fences.exec(src); + if (cap) { + const raw = cap[0]; + const text = indentCodeCompensation(raw, cap[3] || ''); + return { + type: 'code', + raw, + lang: cap[2] ? cap[2].trim().replace(this.rules.inline.anyPunctuation, '$1') : cap[2], + text, + }; + } + } + heading(src) { + const cap = this.rules.block.heading.exec(src); + if (cap) { + let text = cap[2].trim(); + // remove trailing #s + if (/#$/.test(text)) { + const trimmed = rtrim(text, '#'); + if (this.options.pedantic) { + text = trimmed.trim(); + } + else if (!trimmed || / $/.test(trimmed)) { + // CommonMark requires space before trailing #s + text = trimmed.trim(); + } + } + return { + type: 'heading', + raw: cap[0], + depth: cap[1].length, + text, + tokens: this.lexer.inline(text), + }; + } + } + hr(src) { + const cap = this.rules.block.hr.exec(src); + if (cap) { + return { + type: 'hr', + raw: rtrim(cap[0], '\n'), + }; + } + } + blockquote(src) { + const cap = this.rules.block.blockquote.exec(src); + if (cap) { + let lines = rtrim(cap[0], '\n').split('\n'); + let raw = ''; + let text = ''; + const tokens = []; + while (lines.length > 0) { + let inBlockquote = false; + const currentLines = []; + let i; + for (i = 0; i < lines.length; i++) { + // get lines up to a continuation + if (/^ {0,3}>/.test(lines[i])) { + currentLines.push(lines[i]); + inBlockquote = true; + } + else if (!inBlockquote) { + currentLines.push(lines[i]); + } + else { + break; + } + } + lines = lines.slice(i); + const currentRaw = currentLines.join('\n'); + const currentText = currentRaw + // precede setext continuation with 4 spaces so it isn't a setext + .replace(/\n {0,3}((?:=+|-+) *)(?=\n|$)/g, '\n $1') + .replace(/^ {0,3}>[ \t]?/gm, ''); + raw = raw ? `${raw}\n${currentRaw}` : currentRaw; + text = text ? `${text}\n${currentText}` : currentText; + // parse blockquote lines as top level tokens + // merge paragraphs if this is a continuation + const top = this.lexer.state.top; + this.lexer.state.top = true; + this.lexer.blockTokens(currentText, tokens, true); + this.lexer.state.top = top; + // if there is no continuation then we are done + if (lines.length === 0) { + break; + } + const lastToken = tokens[tokens.length - 1]; + if (lastToken?.type === 'code') { + // blockquote continuation cannot be preceded by a code block + break; + } + else if (lastToken?.type === 'blockquote') { + // include continuation in nested blockquote + const oldToken = lastToken; + const newText = oldToken.raw + '\n' + lines.join('\n'); + const newToken = this.blockquote(newText); + tokens[tokens.length - 1] = newToken; + raw = raw.substring(0, raw.length - oldToken.raw.length) + newToken.raw; + text = text.substring(0, text.length - oldToken.text.length) + newToken.text; + break; + } + else if (lastToken?.type === 'list') { + // include continuation in nested list + const oldToken = lastToken; + const newText = oldToken.raw + '\n' + lines.join('\n'); + const newToken = this.list(newText); + tokens[tokens.length - 1] = newToken; + raw = raw.substring(0, raw.length - lastToken.raw.length) + newToken.raw; + text = text.substring(0, text.length - oldToken.raw.length) + newToken.raw; + lines = newText.substring(tokens[tokens.length - 1].raw.length).split('\n'); + continue; + } + } + return { + type: 'blockquote', + raw, + tokens, + text, + }; + } + } + list(src) { + let cap = this.rules.block.list.exec(src); + if (cap) { + let bull = cap[1].trim(); + const isordered = bull.length > 1; + const list = { + type: 'list', + raw: '', + ordered: isordered, + start: isordered ? +bull.slice(0, -1) : '', + loose: false, + items: [], + }; + bull = isordered ? `\\d{1,9}\\${bull.slice(-1)}` : `\\${bull}`; + if (this.options.pedantic) { + bull = isordered ? bull : '[*+-]'; + } + // Get next list item + const itemRegex = new RegExp(`^( {0,3}${bull})((?:[\t ][^\\n]*)?(?:\\n|$))`); + let endsWithBlankLine = false; + // Check if current bullet point can start a new List Item + while (src) { + let endEarly = false; + let raw = ''; + let itemContents = ''; + if (!(cap = itemRegex.exec(src))) { + break; + } + if (this.rules.block.hr.test(src)) { // End list if bullet was actually HR (possibly move into itemRegex?) + break; + } + raw = cap[0]; + src = src.substring(raw.length); + let line = cap[2].split('\n', 1)[0].replace(/^\t+/, (t) => ' '.repeat(3 * t.length)); + let nextLine = src.split('\n', 1)[0]; + let blankLine = !line.trim(); + let indent = 0; + if (this.options.pedantic) { + indent = 2; + itemContents = line.trimStart(); + } + else if (blankLine) { + indent = cap[1].length + 1; + } + else { + indent = cap[2].search(/[^ ]/); // Find first non-space char + indent = indent > 4 ? 1 : indent; // Treat indented code blocks (> 4 spaces) as having only 1 indent + itemContents = line.slice(indent); + indent += cap[1].length; + } + if (blankLine && /^ *$/.test(nextLine)) { // Items begin with at most one blank line + raw += nextLine + '\n'; + src = src.substring(nextLine.length + 1); + endEarly = true; + } + if (!endEarly) { + const nextBulletRegex = new RegExp(`^ {0,${Math.min(3, indent - 1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ \t][^\\n]*)?(?:\\n|$))`); + const hrRegex = new RegExp(`^ {0,${Math.min(3, indent - 1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`); + const fencesBeginRegex = new RegExp(`^ {0,${Math.min(3, indent - 1)}}(?:\`\`\`|~~~)`); + const headingBeginRegex = new RegExp(`^ {0,${Math.min(3, indent - 1)}}#`); + // Check if following lines should be included in List Item + while (src) { + const rawLine = src.split('\n', 1)[0]; + nextLine = rawLine; + // Re-align to follow commonmark nesting rules + if (this.options.pedantic) { + nextLine = nextLine.replace(/^ {1,4}(?=( {4})*[^ ])/g, ' '); + } + // End list item if found code fences + if (fencesBeginRegex.test(nextLine)) { + break; + } + // End list item if found start of new heading + if (headingBeginRegex.test(nextLine)) { + break; + } + // End list item if found start of new bullet + if (nextBulletRegex.test(nextLine)) { + break; + } + // Horizontal rule found + if (hrRegex.test(src)) { + break; + } + if (nextLine.search(/[^ ]/) >= indent || !nextLine.trim()) { // Dedent if possible + itemContents += '\n' + nextLine.slice(indent); + } + else { + // not enough indentation + if (blankLine) { + break; + } + // paragraph continuation unless last line was a different block level element + if (line.search(/[^ ]/) >= 4) { // indented code block + break; + } + if (fencesBeginRegex.test(line)) { + break; + } + if (headingBeginRegex.test(line)) { + break; + } + if (hrRegex.test(line)) { + break; + } + itemContents += '\n' + nextLine; + } + if (!blankLine && !nextLine.trim()) { // Check if current line is blank + blankLine = true; + } + raw += rawLine + '\n'; + src = src.substring(rawLine.length + 1); + line = nextLine.slice(indent); + } + } + if (!list.loose) { + // If the previous item ended with a blank line, the list is loose + if (endsWithBlankLine) { + list.loose = true; + } + else if (/\n *\n *$/.test(raw)) { + endsWithBlankLine = true; + } + } + let istask = null; + let ischecked; + // Check for task list items + if (this.options.gfm) { + istask = /^\[[ xX]\] /.exec(itemContents); + if (istask) { + ischecked = istask[0] !== '[ ] '; + itemContents = itemContents.replace(/^\[[ xX]\] +/, ''); + } + } + list.items.push({ + type: 'list_item', + raw, + task: !!istask, + checked: ischecked, + loose: false, + text: itemContents, + tokens: [], + }); + list.raw += raw; + } + // Do not consume newlines at end of final item. Alternatively, make itemRegex *start* with any newlines to simplify/speed up endsWithBlankLine logic + list.items[list.items.length - 1].raw = list.items[list.items.length - 1].raw.trimEnd(); + list.items[list.items.length - 1].text = list.items[list.items.length - 1].text.trimEnd(); + list.raw = list.raw.trimEnd(); + // Item child tokens handled here at end because we needed to have the final item to trim it first + for (let i = 0; i < list.items.length; i++) { + this.lexer.state.top = false; + list.items[i].tokens = this.lexer.blockTokens(list.items[i].text, []); + if (!list.loose) { + // Check if list should be loose + const spacers = list.items[i].tokens.filter(t => t.type === 'space'); + const hasMultipleLineBreaks = spacers.length > 0 && spacers.some(t => /\n.*\n/.test(t.raw)); + list.loose = hasMultipleLineBreaks; + } + } + // Set all items to loose if list is loose + if (list.loose) { + for (let i = 0; i < list.items.length; i++) { + list.items[i].loose = true; + } + } + return list; + } + } + html(src) { + const cap = this.rules.block.html.exec(src); + if (cap) { + const token = { + type: 'html', + block: true, + raw: cap[0], + pre: cap[1] === 'pre' || cap[1] === 'script' || cap[1] === 'style', + text: cap[0], + }; + return token; + } + } + def(src) { + const cap = this.rules.block.def.exec(src); + if (cap) { + const tag = cap[1].toLowerCase().replace(/\s+/g, ' '); + const href = cap[2] ? cap[2].replace(/^<(.*)>$/, '$1').replace(this.rules.inline.anyPunctuation, '$1') : ''; + const title = cap[3] ? cap[3].substring(1, cap[3].length - 1).replace(this.rules.inline.anyPunctuation, '$1') : cap[3]; + return { + type: 'def', + tag, + raw: cap[0], + href, + title, + }; + } + } + table(src) { + const cap = this.rules.block.table.exec(src); + if (!cap) { + return; + } + if (!/[:|]/.test(cap[2])) { + // delimiter row must have a pipe (|) or colon (:) otherwise it is a setext heading + return; + } + const headers = splitCells(cap[1]); + const aligns = cap[2].replace(/^\||\| *$/g, '').split('|'); + const rows = cap[3] && cap[3].trim() ? cap[3].replace(/\n[ \t]*$/, '').split('\n') : []; + const item = { + type: 'table', + raw: cap[0], + header: [], + align: [], + rows: [], + }; + if (headers.length !== aligns.length) { + // header and align columns must be equal, rows can be different. + return; + } + for (const align of aligns) { + if (/^ *-+: *$/.test(align)) { + item.align.push('right'); + } + else if (/^ *:-+: *$/.test(align)) { + item.align.push('center'); + } + else if (/^ *:-+ *$/.test(align)) { + item.align.push('left'); + } + else { + item.align.push(null); + } + } + for (let i = 0; i < headers.length; i++) { + item.header.push({ + text: headers[i], + tokens: this.lexer.inline(headers[i]), + header: true, + align: item.align[i], + }); + } + for (const row of rows) { + item.rows.push(splitCells(row, item.header.length).map((cell, i) => { + return { + text: cell, + tokens: this.lexer.inline(cell), + header: false, + align: item.align[i], + }; + })); + } + return item; + } + lheading(src) { + const cap = this.rules.block.lheading.exec(src); + if (cap) { + return { + type: 'heading', + raw: cap[0], + depth: cap[2].charAt(0) === '=' ? 1 : 2, + text: cap[1], + tokens: this.lexer.inline(cap[1]), + }; + } + } + paragraph(src) { + const cap = this.rules.block.paragraph.exec(src); + if (cap) { + const text = cap[1].charAt(cap[1].length - 1) === '\n' + ? cap[1].slice(0, -1) + : cap[1]; + return { + type: 'paragraph', + raw: cap[0], + text, + tokens: this.lexer.inline(text), + }; + } + } + text(src) { + const cap = this.rules.block.text.exec(src); + if (cap) { + return { + type: 'text', + raw: cap[0], + text: cap[0], + tokens: this.lexer.inline(cap[0]), + }; + } + } + escape(src) { + const cap = this.rules.inline.escape.exec(src); + if (cap) { + return { + type: 'escape', + raw: cap[0], + text: escape$1(cap[1]), + }; + } + } + tag(src) { + const cap = this.rules.inline.tag.exec(src); + if (cap) { + if (!this.lexer.state.inLink && /^/i.test(cap[0])) { + this.lexer.state.inLink = false; + } + if (!this.lexer.state.inRawBlock && /^<(pre|code|kbd|script)(\s|>)/i.test(cap[0])) { + this.lexer.state.inRawBlock = true; + } + else if (this.lexer.state.inRawBlock && /^<\/(pre|code|kbd|script)(\s|>)/i.test(cap[0])) { + this.lexer.state.inRawBlock = false; + } + return { + type: 'html', + raw: cap[0], + inLink: this.lexer.state.inLink, + inRawBlock: this.lexer.state.inRawBlock, + block: false, + text: cap[0], + }; + } + } + link(src) { + const cap = this.rules.inline.link.exec(src); + if (cap) { + const trimmedUrl = cap[2].trim(); + if (!this.options.pedantic && /^$/.test(trimmedUrl))) { + return; + } + // ending angle bracket cannot be escaped + const rtrimSlash = rtrim(trimmedUrl.slice(0, -1), '\\'); + if ((trimmedUrl.length - rtrimSlash.length) % 2 === 0) { + return; + } + } + else { + // find closing parenthesis + const lastParenIndex = findClosingBracket(cap[2], '()'); + if (lastParenIndex > -1) { + const start = cap[0].indexOf('!') === 0 ? 5 : 4; + const linkLen = start + cap[1].length + lastParenIndex; + cap[2] = cap[2].substring(0, lastParenIndex); + cap[0] = cap[0].substring(0, linkLen).trim(); + cap[3] = ''; + } + } + let href = cap[2]; + let title = ''; + if (this.options.pedantic) { + // split pedantic href and title + const link = /^([^'"]*[^\s])\s+(['"])(.*)\2/.exec(href); + if (link) { + href = link[1]; + title = link[3]; + } + } + else { + title = cap[3] ? cap[3].slice(1, -1) : ''; + } + href = href.trim(); + if (/^$/.test(trimmedUrl))) { + // pedantic allows starting angle bracket without ending angle bracket + href = href.slice(1); + } + else { + href = href.slice(1, -1); + } + } + return outputLink(cap, { + href: href ? href.replace(this.rules.inline.anyPunctuation, '$1') : href, + title: title ? title.replace(this.rules.inline.anyPunctuation, '$1') : title, + }, cap[0], this.lexer); + } + } + reflink(src, links) { + let cap; + if ((cap = this.rules.inline.reflink.exec(src)) + || (cap = this.rules.inline.nolink.exec(src))) { + const linkString = (cap[2] || cap[1]).replace(/\s+/g, ' '); + const link = links[linkString.toLowerCase()]; + if (!link) { + const text = cap[0].charAt(0); + return { + type: 'text', + raw: text, + text, + }; + } + return outputLink(cap, link, cap[0], this.lexer); + } + } + emStrong(src, maskedSrc, prevChar = '') { + let match = this.rules.inline.emStrongLDelim.exec(src); + if (!match) + return; + // _ can't be between two alphanumerics. \p{L}\p{N} includes non-english alphabet/numbers as well + if (match[3] && prevChar.match(/[\p{L}\p{N}]/u)) + return; + const nextChar = match[1] || match[2] || ''; + if (!nextChar || !prevChar || this.rules.inline.punctuation.exec(prevChar)) { + // unicode Regex counts emoji as 1 char; spread into array for proper count (used multiple times below) + const lLength = [...match[0]].length - 1; + let rDelim, rLength, delimTotal = lLength, midDelimTotal = 0; + const endReg = match[0][0] === '*' ? this.rules.inline.emStrongRDelimAst : this.rules.inline.emStrongRDelimUnd; + endReg.lastIndex = 0; + // Clip maskedSrc to same section of string as src (move to lexer?) + maskedSrc = maskedSrc.slice(-1 * src.length + lLength); + while ((match = endReg.exec(maskedSrc)) != null) { + rDelim = match[1] || match[2] || match[3] || match[4] || match[5] || match[6]; + if (!rDelim) + continue; // skip single * in __abc*abc__ + rLength = [...rDelim].length; + if (match[3] || match[4]) { // found another Left Delim + delimTotal += rLength; + continue; + } + else if (match[5] || match[6]) { // either Left or Right Delim + if (lLength % 3 && !((lLength + rLength) % 3)) { + midDelimTotal += rLength; + continue; // CommonMark Emphasis Rules 9-10 + } + } + delimTotal -= rLength; + if (delimTotal > 0) + continue; // Haven't found enough closing delimiters + // Remove extra characters. *a*** -> *a* + rLength = Math.min(rLength, rLength + delimTotal + midDelimTotal); + // char length can be >1 for unicode characters; + const lastCharLength = [...match[0]][0].length; + const raw = src.slice(0, lLength + match.index + lastCharLength + rLength); + // Create `em` if smallest delimiter has odd char count. *a*** + if (Math.min(lLength, rLength) % 2) { + const text = raw.slice(1, -1); + return { + type: 'em', + raw, + text, + tokens: this.lexer.inlineTokens(text), + }; + } + // Create 'strong' if smallest delimiter has even char count. **a*** + const text = raw.slice(2, -2); + return { + type: 'strong', + raw, + text, + tokens: this.lexer.inlineTokens(text), + }; + } + } + } + codespan(src) { + const cap = this.rules.inline.code.exec(src); + if (cap) { + let text = cap[2].replace(/\n/g, ' '); + const hasNonSpaceChars = /[^ ]/.test(text); + const hasSpaceCharsOnBothEnds = /^ /.test(text) && / $/.test(text); + if (hasNonSpaceChars && hasSpaceCharsOnBothEnds) { + text = text.substring(1, text.length - 1); + } + text = escape$1(text, true); + return { + type: 'codespan', + raw: cap[0], + text, + }; + } + } + br(src) { + const cap = this.rules.inline.br.exec(src); + if (cap) { + return { + type: 'br', + raw: cap[0], + }; + } + } + del(src) { + const cap = this.rules.inline.del.exec(src); + if (cap) { + return { + type: 'del', + raw: cap[0], + text: cap[2], + tokens: this.lexer.inlineTokens(cap[2]), + }; + } + } + autolink(src) { + const cap = this.rules.inline.autolink.exec(src); + if (cap) { + let text, href; + if (cap[2] === '@') { + text = escape$1(cap[1]); + href = 'mailto:' + text; + } + else { + text = escape$1(cap[1]); + href = text; + } + return { + type: 'link', + raw: cap[0], + text, + href, + tokens: [ + { + type: 'text', + raw: text, + text, + }, + ], + }; + } + } + url(src) { + let cap; + if (cap = this.rules.inline.url.exec(src)) { + let text, href; + if (cap[2] === '@') { + text = escape$1(cap[0]); + href = 'mailto:' + text; + } + else { + // do extended autolink path validation + let prevCapZero; + do { + prevCapZero = cap[0]; + cap[0] = this.rules.inline._backpedal.exec(cap[0])?.[0] ?? ''; + } while (prevCapZero !== cap[0]); + text = escape$1(cap[0]); + if (cap[1] === 'www.') { + href = 'http://' + cap[0]; + } + else { + href = cap[0]; + } + } + return { + type: 'link', + raw: cap[0], + text, + href, + tokens: [ + { + type: 'text', + raw: text, + text, + }, + ], + }; + } + } + inlineText(src) { + const cap = this.rules.inline.text.exec(src); + if (cap) { + let text; + if (this.lexer.state.inRawBlock) { + text = cap[0]; + } + else { + text = escape$1(cap[0]); + } + return { + type: 'text', + raw: cap[0], + text, + }; + } + } +} + +/** + * Block-Level Grammar + */ +const newline = /^(?: *(?:\n|$))+/; +const blockCode = /^( {4}[^\n]+(?:\n(?: *(?:\n|$))*)?)+/; +const fences = /^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/; +const hr = /^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/; +const heading = /^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/; +const bullet = /(?:[*+-]|\d{1,9}[.)])/; +const lheading = edit(/^(?!bull |blockCode|fences|blockquote|heading|html)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html))+?)\n {0,3}(=+|-+) *(?:\n+|$)/) + .replace(/bull/g, bullet) // lists can interrupt + .replace(/blockCode/g, / {4}/) // indented code blocks can interrupt + .replace(/fences/g, / {0,3}(?:`{3,}|~{3,})/) // fenced code blocks can interrupt + .replace(/blockquote/g, / {0,3}>/) // blockquote can interrupt + .replace(/heading/g, / {0,3}#{1,6}/) // ATX heading can interrupt + .replace(/html/g, / {0,3}<[^\n>]+>\n/) // block html can interrupt + .getRegex(); +const _paragraph = /^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/; +const blockText = /^[^\n]+/; +const _blockLabel = /(?!\s*\])(?:\\.|[^\[\]\\])+/; +const def = edit(/^ {0,3}\[(label)\]: *(?:\n *)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n *)?| *\n *)(title))? *(?:\n+|$)/) + .replace('label', _blockLabel) + .replace('title', /(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/) + .getRegex(); +const list = edit(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/) + .replace(/bull/g, bullet) + .getRegex(); +const _tag = 'address|article|aside|base|basefont|blockquote|body|caption' + + '|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption' + + '|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe' + + '|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option' + + '|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title' + + '|tr|track|ul'; +const _comment = /|$))/; +const html = edit('^ {0,3}(?:' // optional indentation + + '<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)' // (1) + + '|comment[^\\n]*(\\n+|$)' // (2) + + '|<\\?[\\s\\S]*?(?:\\?>\\n*|$)' // (3) + + '|\\n*|$)' // (4) + + '|\\n*|$)' // (5) + + '|)[\\s\\S]*?(?:(?:\\n *)+\\n|$)' // (6) + + '|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$)' // (7) open tag + + '|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$)' // (7) closing tag + + ')', 'i') + .replace('comment', _comment) + .replace('tag', _tag) + .replace('attribute', / +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/) + .getRegex(); +const paragraph = edit(_paragraph) + .replace('hr', hr) + .replace('heading', ' {0,3}#{1,6}(?:\\s|$)') + .replace('|lheading', '') // setext headings don't interrupt commonmark paragraphs + .replace('|table', '') + .replace('blockquote', ' {0,3}>') + .replace('fences', ' {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n') + .replace('list', ' {0,3}(?:[*+-]|1[.)]) ') // only lists starting from 1 can interrupt + .replace('html', ')|<(?:script|pre|style|textarea|!--)') + .replace('tag', _tag) // pars can be interrupted by type (6) html blocks + .getRegex(); +const blockquote = edit(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/) + .replace('paragraph', paragraph) + .getRegex(); +/** + * Normal Block Grammar + */ +const blockNormal = { + blockquote, + code: blockCode, + def, + fences, + heading, + hr, + html, + lheading, + list, + newline, + paragraph, + table: noopTest, + text: blockText, +}; +/** + * GFM Block Grammar + */ +const gfmTable = edit('^ *([^\\n ].*)\\n' // Header + + ' {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)' // Align + + '(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)') // Cells + .replace('hr', hr) + .replace('heading', ' {0,3}#{1,6}(?:\\s|$)') + .replace('blockquote', ' {0,3}>') + .replace('code', ' {4}[^\\n]') + .replace('fences', ' {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n') + .replace('list', ' {0,3}(?:[*+-]|1[.)]) ') // only lists starting from 1 can interrupt + .replace('html', ')|<(?:script|pre|style|textarea|!--)') + .replace('tag', _tag) // tables can be interrupted by type (6) html blocks + .getRegex(); +const blockGfm = { + ...blockNormal, + table: gfmTable, + paragraph: edit(_paragraph) + .replace('hr', hr) + .replace('heading', ' {0,3}#{1,6}(?:\\s|$)') + .replace('|lheading', '') // setext headings don't interrupt commonmark paragraphs + .replace('table', gfmTable) // interrupt paragraphs with table + .replace('blockquote', ' {0,3}>') + .replace('fences', ' {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n') + .replace('list', ' {0,3}(?:[*+-]|1[.)]) ') // only lists starting from 1 can interrupt + .replace('html', ')|<(?:script|pre|style|textarea|!--)') + .replace('tag', _tag) // pars can be interrupted by type (6) html blocks + .getRegex(), +}; +/** + * Pedantic grammar (original John Gruber's loose markdown specification) + */ +const blockPedantic = { + ...blockNormal, + html: edit('^ *(?:comment *(?:\\n|\\s*$)' + + '|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)' // closed tag + + '|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))') + .replace('comment', _comment) + .replace(/tag/g, '(?!(?:' + + 'a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub' + + '|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)' + + '\\b)\\w+(?!:|[^\\w\\s@]*@)\\b') + .getRegex(), + def: /^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/, + heading: /^(#{1,6})(.*)(?:\n+|$)/, + fences: noopTest, // fences not supported + lheading: /^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/, + paragraph: edit(_paragraph) + .replace('hr', hr) + .replace('heading', ' *#{1,6} *[^\n]') + .replace('lheading', lheading) + .replace('|table', '') + .replace('blockquote', ' {0,3}>') + .replace('|fences', '') + .replace('|list', '') + .replace('|html', '') + .replace('|tag', '') + .getRegex(), +}; +/** + * Inline-Level Grammar + */ +const escape = /^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/; +const inlineCode = /^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/; +const br = /^( {2,}|\\)\n(?!\s*$)/; +const inlineText = /^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\ +const blockSkip = /\[[^[\]]*?\]\([^\(\)]*?\)|`[^`]*?`|<[^<>]*?>/g; +const emStrongLDelim = edit(/^(?:\*+(?:((?!\*)[punct])|[^\s*]))|^_+(?:((?!_)[punct])|([^\s_]))/, 'u') + .replace(/punct/g, _punctuation) + .getRegex(); +const emStrongRDelimAst = edit('^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)' // Skip orphan inside strong + + '|[^*]+(?=[^*])' // Consume to delim + + '|(?!\\*)[punct](\\*+)(?=[\\s]|$)' // (1) #*** can only be a Right Delimiter + + '|[^punct\\s](\\*+)(?!\\*)(?=[punct\\s]|$)' // (2) a***#, a*** can only be a Right Delimiter + + '|(?!\\*)[punct\\s](\\*+)(?=[^punct\\s])' // (3) #***a, ***a can only be Left Delimiter + + '|[\\s](\\*+)(?!\\*)(?=[punct])' // (4) ***# can only be Left Delimiter + + '|(?!\\*)[punct](\\*+)(?!\\*)(?=[punct])' // (5) #***# can be either Left or Right Delimiter + + '|[^punct\\s](\\*+)(?=[^punct\\s])', 'gu') // (6) a***a can be either Left or Right Delimiter + .replace(/punct/g, _punctuation) + .getRegex(); +// (6) Not allowed for _ +const emStrongRDelimUnd = edit('^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)' // Skip orphan inside strong + + '|[^_]+(?=[^_])' // Consume to delim + + '|(?!_)[punct](_+)(?=[\\s]|$)' // (1) #___ can only be a Right Delimiter + + '|[^punct\\s](_+)(?!_)(?=[punct\\s]|$)' // (2) a___#, a___ can only be a Right Delimiter + + '|(?!_)[punct\\s](_+)(?=[^punct\\s])' // (3) #___a, ___a can only be Left Delimiter + + '|[\\s](_+)(?!_)(?=[punct])' // (4) ___# can only be Left Delimiter + + '|(?!_)[punct](_+)(?!_)(?=[punct])', 'gu') // (5) #___# can be either Left or Right Delimiter + .replace(/punct/g, _punctuation) + .getRegex(); +const anyPunctuation = edit(/\\([punct])/, 'gu') + .replace(/punct/g, _punctuation) + .getRegex(); +const autolink = edit(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/) + .replace('scheme', /[a-zA-Z][a-zA-Z0-9+.-]{1,31}/) + .replace('email', /[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/) + .getRegex(); +const _inlineComment = edit(_comment).replace('(?:-->|$)', '-->').getRegex(); +const tag = edit('^comment' + + '|^' // self-closing tag + + '|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>' // open tag + + '|^<\\?[\\s\\S]*?\\?>' // processing instruction, e.g. + + '|^' // declaration, e.g. + + '|^') // CDATA section + .replace('comment', _inlineComment) + .replace('attribute', /\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/) + .getRegex(); +const _inlineLabel = /(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/; +const link = edit(/^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/) + .replace('label', _inlineLabel) + .replace('href', /<(?:\\.|[^\n<>\\])+>|[^\s\x00-\x1f]*/) + .replace('title', /"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/) + .getRegex(); +const reflink = edit(/^!?\[(label)\]\[(ref)\]/) + .replace('label', _inlineLabel) + .replace('ref', _blockLabel) + .getRegex(); +const nolink = edit(/^!?\[(ref)\](?:\[\])?/) + .replace('ref', _blockLabel) + .getRegex(); +const reflinkSearch = edit('reflink|nolink(?!\\()', 'g') + .replace('reflink', reflink) + .replace('nolink', nolink) + .getRegex(); +/** + * Normal Inline Grammar + */ +const inlineNormal = { + _backpedal: noopTest, // only used for GFM url + anyPunctuation, + autolink, + blockSkip, + br, + code: inlineCode, + del: noopTest, + emStrongLDelim, + emStrongRDelimAst, + emStrongRDelimUnd, + escape, + link, + nolink, + punctuation, + reflink, + reflinkSearch, + tag, + text: inlineText, + url: noopTest, +}; +/** + * Pedantic Inline Grammar + */ +const inlinePedantic = { + ...inlineNormal, + link: edit(/^!?\[(label)\]\((.*?)\)/) + .replace('label', _inlineLabel) + .getRegex(), + reflink: edit(/^!?\[(label)\]\s*\[([^\]]*)\]/) + .replace('label', _inlineLabel) + .getRegex(), +}; +/** + * GFM Inline Grammar + */ +const inlineGfm = { + ...inlineNormal, + escape: edit(escape).replace('])', '~|])').getRegex(), + url: edit(/^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/, 'i') + .replace('email', /[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/) + .getRegex(), + _backpedal: /(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/, + del: /^(~~?)(?=[^\s~])([\s\S]*?[^\s~])\1(?=[^~]|$)/, + text: /^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\ { + return leading + ' '.repeat(tabs.length); + }); + } + let token; + let lastToken; + let cutSrc; + while (src) { + if (this.options.extensions + && this.options.extensions.block + && this.options.extensions.block.some((extTokenizer) => { + if (token = extTokenizer.call({ lexer: this }, src, tokens)) { + src = src.substring(token.raw.length); + tokens.push(token); + return true; + } + return false; + })) { + continue; + } + // newline + if (token = this.tokenizer.space(src)) { + src = src.substring(token.raw.length); + if (token.raw.length === 1 && tokens.length > 0) { + // if there's a single \n as a spacer, it's terminating the last line, + // so move it there so that we don't get unnecessary paragraph tags + tokens[tokens.length - 1].raw += '\n'; + } + else { + tokens.push(token); + } + continue; + } + // code + if (token = this.tokenizer.code(src)) { + src = src.substring(token.raw.length); + lastToken = tokens[tokens.length - 1]; + // An indented code block cannot interrupt a paragraph. + if (lastToken && (lastToken.type === 'paragraph' || lastToken.type === 'text')) { + lastToken.raw += '\n' + token.raw; + lastToken.text += '\n' + token.text; + this.inlineQueue[this.inlineQueue.length - 1].src = lastToken.text; + } + else { + tokens.push(token); + } + continue; + } + // fences + if (token = this.tokenizer.fences(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // heading + if (token = this.tokenizer.heading(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // hr + if (token = this.tokenizer.hr(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // blockquote + if (token = this.tokenizer.blockquote(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // list + if (token = this.tokenizer.list(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // html + if (token = this.tokenizer.html(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // def + if (token = this.tokenizer.def(src)) { + src = src.substring(token.raw.length); + lastToken = tokens[tokens.length - 1]; + if (lastToken && (lastToken.type === 'paragraph' || lastToken.type === 'text')) { + lastToken.raw += '\n' + token.raw; + lastToken.text += '\n' + token.raw; + this.inlineQueue[this.inlineQueue.length - 1].src = lastToken.text; + } + else if (!this.tokens.links[token.tag]) { + this.tokens.links[token.tag] = { + href: token.href, + title: token.title, + }; + } + continue; + } + // table (gfm) + if (token = this.tokenizer.table(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // lheading + if (token = this.tokenizer.lheading(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // top-level paragraph + // prevent paragraph consuming extensions by clipping 'src' to extension start + cutSrc = src; + if (this.options.extensions && this.options.extensions.startBlock) { + let startIndex = Infinity; + const tempSrc = src.slice(1); + let tempStart; + this.options.extensions.startBlock.forEach((getStartIndex) => { + tempStart = getStartIndex.call({ lexer: this }, tempSrc); + if (typeof tempStart === 'number' && tempStart >= 0) { + startIndex = Math.min(startIndex, tempStart); + } + }); + if (startIndex < Infinity && startIndex >= 0) { + cutSrc = src.substring(0, startIndex + 1); + } + } + if (this.state.top && (token = this.tokenizer.paragraph(cutSrc))) { + lastToken = tokens[tokens.length - 1]; + if (lastParagraphClipped && lastToken?.type === 'paragraph') { + lastToken.raw += '\n' + token.raw; + lastToken.text += '\n' + token.text; + this.inlineQueue.pop(); + this.inlineQueue[this.inlineQueue.length - 1].src = lastToken.text; + } + else { + tokens.push(token); + } + lastParagraphClipped = (cutSrc.length !== src.length); + src = src.substring(token.raw.length); + continue; + } + // text + if (token = this.tokenizer.text(src)) { + src = src.substring(token.raw.length); + lastToken = tokens[tokens.length - 1]; + if (lastToken && lastToken.type === 'text') { + lastToken.raw += '\n' + token.raw; + lastToken.text += '\n' + token.text; + this.inlineQueue.pop(); + this.inlineQueue[this.inlineQueue.length - 1].src = lastToken.text; + } + else { + tokens.push(token); + } + continue; + } + if (src) { + const errMsg = 'Infinite loop on byte: ' + src.charCodeAt(0); + if (this.options.silent) { + console.error(errMsg); + break; + } + else { + throw new Error(errMsg); + } + } + } + this.state.top = true; + return tokens; + } + inline(src, tokens = []) { + this.inlineQueue.push({ src, tokens }); + return tokens; + } + /** + * Lexing/Compiling + */ + inlineTokens(src, tokens = []) { + let token, lastToken, cutSrc; + // String with links masked to avoid interference with em and strong + let maskedSrc = src; + let match; + let keepPrevChar, prevChar; + // Mask out reflinks + if (this.tokens.links) { + const links = Object.keys(this.tokens.links); + if (links.length > 0) { + while ((match = this.tokenizer.rules.inline.reflinkSearch.exec(maskedSrc)) != null) { + if (links.includes(match[0].slice(match[0].lastIndexOf('[') + 1, -1))) { + maskedSrc = maskedSrc.slice(0, match.index) + '[' + 'a'.repeat(match[0].length - 2) + ']' + maskedSrc.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex); + } + } + } + } + // Mask out other blocks + while ((match = this.tokenizer.rules.inline.blockSkip.exec(maskedSrc)) != null) { + maskedSrc = maskedSrc.slice(0, match.index) + '[' + 'a'.repeat(match[0].length - 2) + ']' + maskedSrc.slice(this.tokenizer.rules.inline.blockSkip.lastIndex); + } + // Mask out escaped characters + while ((match = this.tokenizer.rules.inline.anyPunctuation.exec(maskedSrc)) != null) { + maskedSrc = maskedSrc.slice(0, match.index) + '++' + maskedSrc.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex); + } + while (src) { + if (!keepPrevChar) { + prevChar = ''; + } + keepPrevChar = false; + // extensions + if (this.options.extensions + && this.options.extensions.inline + && this.options.extensions.inline.some((extTokenizer) => { + if (token = extTokenizer.call({ lexer: this }, src, tokens)) { + src = src.substring(token.raw.length); + tokens.push(token); + return true; + } + return false; + })) { + continue; + } + // escape + if (token = this.tokenizer.escape(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // tag + if (token = this.tokenizer.tag(src)) { + src = src.substring(token.raw.length); + lastToken = tokens[tokens.length - 1]; + if (lastToken && token.type === 'text' && lastToken.type === 'text') { + lastToken.raw += token.raw; + lastToken.text += token.text; + } + else { + tokens.push(token); + } + continue; + } + // link + if (token = this.tokenizer.link(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // reflink, nolink + if (token = this.tokenizer.reflink(src, this.tokens.links)) { + src = src.substring(token.raw.length); + lastToken = tokens[tokens.length - 1]; + if (lastToken && token.type === 'text' && lastToken.type === 'text') { + lastToken.raw += token.raw; + lastToken.text += token.text; + } + else { + tokens.push(token); + } + continue; + } + // em & strong + if (token = this.tokenizer.emStrong(src, maskedSrc, prevChar)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // code + if (token = this.tokenizer.codespan(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // br + if (token = this.tokenizer.br(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // del (gfm) + if (token = this.tokenizer.del(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // autolink + if (token = this.tokenizer.autolink(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // url (gfm) + if (!this.state.inLink && (token = this.tokenizer.url(src))) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // text + // prevent inlineText consuming extensions by clipping 'src' to extension start + cutSrc = src; + if (this.options.extensions && this.options.extensions.startInline) { + let startIndex = Infinity; + const tempSrc = src.slice(1); + let tempStart; + this.options.extensions.startInline.forEach((getStartIndex) => { + tempStart = getStartIndex.call({ lexer: this }, tempSrc); + if (typeof tempStart === 'number' && tempStart >= 0) { + startIndex = Math.min(startIndex, tempStart); + } + }); + if (startIndex < Infinity && startIndex >= 0) { + cutSrc = src.substring(0, startIndex + 1); + } + } + if (token = this.tokenizer.inlineText(cutSrc)) { + src = src.substring(token.raw.length); + if (token.raw.slice(-1) !== '_') { // Track prevChar before string of ____ started + prevChar = token.raw.slice(-1); + } + keepPrevChar = true; + lastToken = tokens[tokens.length - 1]; + if (lastToken && lastToken.type === 'text') { + lastToken.raw += token.raw; + lastToken.text += token.text; + } + else { + tokens.push(token); + } + continue; + } + if (src) { + const errMsg = 'Infinite loop on byte: ' + src.charCodeAt(0); + if (this.options.silent) { + console.error(errMsg); + break; + } + else { + throw new Error(errMsg); + } + } + } + return tokens; + } +} + +/** + * Renderer + */ +class _Renderer { + options; + parser; // set by the parser + constructor(options) { + this.options = options || _defaults; + } + space(token) { + return ''; + } + code({ text, lang, escaped }) { + const langString = (lang || '').match(/^\S*/)?.[0]; + const code = text.replace(/\n$/, '') + '\n'; + if (!langString) { + return '
'
+                + (escaped ? code : escape$1(code, true))
+                + '
\n'; + } + return '
'
+            + (escaped ? code : escape$1(code, true))
+            + '
\n'; + } + blockquote({ tokens }) { + const body = this.parser.parse(tokens); + return `
\n${body}
\n`; + } + html({ text }) { + return text; + } + heading({ tokens, depth }) { + return `${this.parser.parseInline(tokens)}\n`; + } + hr(token) { + 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 + '\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 `
  • ${itemBody}
  • \n`; + } + checkbox({ checked }) { + return ''; + } + paragraph({ tokens }) { + return `

    ${this.parser.parseInline(tokens)}

    \n`; + } + table(token) { + let header = ''; + // header + let cell = ''; + for (let j = 0; j < token.header.length; j++) { + cell += this.tablecell(token.header[j]); + } + header += this.tablerow({ text: cell }); + let body = ''; + for (let j = 0; j < token.rows.length; j++) { + const row = token.rows[j]; + cell = ''; + for (let k = 0; k < row.length; k++) { + cell += this.tablecell(row[k]); + } + body += this.tablerow({ text: cell }); + } + if (body) + body = `${body}`; + 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 + `\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 = '
    '; + return out; + } + image({ href, title, text }) { + const cleanHref = cleanUrl(href); + if (cleanHref === null) { + return text; + } + href = cleanHref; + let out = `${text} { + 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 = '

    An error occurred:

    '
    +                    + 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.css b/web/model-manager.css index 913f286..8d66796 100644 --- a/web/model-manager.css +++ b/web/model-manager.css @@ -145,6 +145,7 @@ font-size: 1.2em; resize: vertical; width: 100%; + height: 100%; } .model-manager input[type="file"] { diff --git a/web/model-manager.js b/web/model-manager.js index 2c06bbd..2fa224d 100644 --- a/web/model-manager.js +++ b/web/model-manager.js @@ -1,10 +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 { 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); } /** @@ -13,19 +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) => { - if (!response.ok) { - reject(new Error(`HTTP error ${response.status}: ${response.statusText}`)); - } else { - response.json() - .then(resolve) - .catch(error => reject(new Error(`Failed to parse JSON: ${error.message}`))); - } - }) - .catch(error => reject(new Error(`Request error: ${error.message}`))); - }); + return new Promise((resolve, reject) => { + api + .fetchApi(url, options) + .then((response) => response.json()) + .then(resolve) + .catch(reject); + }); } /** @@ -34,110 +30,108 @@ 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 {() => Promise} */ - action; - - /** @type {Element} */ - element; - - /** @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; - - 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); - } - }); - } + /** @type {string[]} */ + #keyCodes = []; + + /** @type {() => Promise} */ + action; + + /** @type {Element} */ + element; + + /** @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; + + 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 fileNameIndex = Math.max(url.lastIndexOf("/"), url.lastIndexOf("\\")) + 1; + const fileNameIndex = Math.max(url.lastIndexOf('/'), url.lastIndexOf('\\')) + 1; const fileName = url.substring(fileNameIndex); const response = await fetch(url); const data = await response.blob(); @@ -153,7 +147,7 @@ async function tryGetModelWebUrl(modelSearchPath) { const encodedPath = encodeURIComponent(modelSearchPath); const response = await comfyRequest(`/model-manager/model/web-url?path=${encodedPath}`); const url = response.url; - return url !== undefined && url !== "" ? url : undefined; + return url !== undefined && url !== '' ? url : undefined; } /** @@ -194,40 +188,48 @@ function tryOpenUrl(url, name="Url") { } 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 /** @@ -236,10 +238,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; } /** @@ -248,44 +250,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); + } } /** @@ -296,59 +298,63 @@ class SearchPath { * @param {string | undefined} [imageFormat=undefined] * @returns {string} */ -function imageUri(imageUriSearchPath = undefined, dateImageModified = undefined, width = undefined, height = undefined, imageFormat = undefined) { - const params = []; - if (width !== undefined && width !== null) { - params.push(`width=${width}`); - } - if (height !== undefined && height !== null) { - params.push(`height=${height}`); - } - if (dateImageModified !== undefined && dateImageModified !== null) { - params.push(`v=${dateImageModified}`); - } - if (imageFormat !== undefined && imageFormat !== null) { - params.push(`image-format=${imageFormat}`); - } - - const path = imageUriSearchPath ?? "no-preview"; - const uri = `/model-manager/preview/get/${path}`; - if (params.length > 0) { - return uri + '?' + params.join('&'); - } - return uri; -} + function imageUri( + imageUriSearchPath = undefined, + dateImageModified = undefined, + width = undefined, + height = undefined, + imageFormat = undefined, + ) { + const params = []; + if (width !== undefined && width !== null) { + params.push(`width=${width}`); + } + if (height !== undefined && height !== null) { + params.push(`height=${height}`); + } + if (dateImageModified !== undefined && dateImageModified !== null) { + params.push(`v=${dateImageModified}`); + } + if (imageFormat !== undefined && imageFormat !== null) { + params.push(`image-format=${imageFormat}`); + } + + const path = imageUriSearchPath ?? 'no-preview'; + const uri = `/model-manager/preview/get/${path}`; + if (params.length > 0) { + return uri + '?' + params.join('&'); + } + return uri; + } const PREVIEW_NONE_URI = imageUri(); const PREVIEW_THUMBNAIL_WIDTH = 320; const PREVIEW_THUMBNAIL_HEIGHT = 480; /** - * + * * @param {HTMLButtonElement} element * @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]; } /** @@ -358,120 +364,148 @@ 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; } - - const [button, icon, span] = comfyButtonDisambiguate(element); - if (button === undefined) { - console.warn("Unable to find button element!"); - console.warn(element); - 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; + } + + // 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, + ); + } } /** - * + * * @param {string} modelPath * @param {string} newValue * @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; } /** @@ -479,43 +513,39 @@ function $select(x = { $: (el) => {}, textContent: "", options: [""] }) { * @returns {HTMLDivElement} */ 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() }, - [ - $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); + const { name = Date.now(), onchange, options = [], $ } = attr; - radioGroup.forEach((radio) => { - radio.addEventListener("change", (event) => { - const selectedValue = event.target.value; - element.value = selectedValue; - onchange?.(selectedValue); - }); + /** @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), + ]); + }); + + 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); } /** @@ -523,57 +553,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); - }); - - return [tabButtons, 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); + }); + + return [tabButtons, tabContents]; } /** @@ -581,19 +611,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; + }); + }; } /** @@ -601,18 +631,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; } /** @@ -620,791 +650,836 @@ 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"; - - 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; - 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; + /** @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, + + /** @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; + 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; + } } - console.warn(`Invalid preview select type: ${value}`); + 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; } - - /** @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; + console.warn(`Invalid preview select type: ${value}`); + return PREVIEW_NONE_URI; + } + + /** @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; } - 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"; + } 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; + } } - - /** - * @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" }, + 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; + 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; - }); + 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; } - 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", { + }) + .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: { - display: el_defaultPreviews.children.length > 1 ? "block" : "none", + width: '100%', + height: '100%', }, - }, [ - 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, - ]), - ]); + }, + [ + 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]), + ], + ); + } } /** - * @typedef {Object} DirectoryItem + * @typedef {Object} DirectoryItem * @property {String} name * @property {number | undefined} childCount * @property {number | undefined} childIndex */ 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} */ - systemSeparator = null; - - /** @type {Object} */ - models = {}; - - /** @type {ModelDirectories} */ - directories = null; - - constructor() { - this.directories = new ModelDirectories(); - } + /** @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(); + } } 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; - - /** @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) => { + 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) => { + 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) { DirectoryDropdown.selectionToInput( - input, - selection, - modelData.searchSeparator, + input, + selection, + modelData.searchSeparator, DROPDOWN_DIRECTORY_SELECTION_KEY_CLASS ); const path = this.#updateOptions(); @@ -1414,2963 +1489,3410 @@ 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); - } - } - } - } - }, - ); + } + 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 {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 {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; } - /** - * @param {string} path - */ - #updateDeepestPath = (path) => { - const deepestPath = this.#deepestPreviousPath; - if (path.length > deepestPath.length || !deepestPath.startsWith(path)) { - this.#deepestPreviousPath = path; + 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} 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 {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); + /** + * @param {string} nodeType + * @returns {int} + */ + static modelWidgetIndex(nodeType) { + return nodeType === undefined ? -1 : 0; + } - 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") { - // TODO: If the node has >1 textareas, the textarea element must be selected - target.value = ModelGrid.insertEmbeddingIntoText(target.value, embeddingFile, removeEmbeddingExtension); - success = true; - } - } - if (!success) { - window.alert("No selected nodes have a text area!"); - } - event.stopPropagation(); - } - comfyButtonAlert(event.target, success, "mdi-check-bold", "mdi-close-thick"); + /** + * @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)'; + } - 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 {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); - /** - * @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(); + 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; } - 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 shouldShowTryOpenModelUrl = canShowButtons && settingsElements["model-show-open-model-url-button"].checked; - const showLoadWorkflowButton = canShowButtons && settingsElements["model-show-load-workflow-button"].checked; - const showAddButton = canShowButtons && settingsElements["model-show-add-button"].checked; - const showCopyButton = canShowButtons && settingsElements["model-show-copy-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 ? encodeURIComponent(previewInfo.path) : undefined, - previewInfo?.dateModified ? encodeURIComponent(previewInfo.dateModified) : undefined, - PREVIEW_THUMBNAIL_WIDTH, - PREVIEW_THUMBNAIL_HEIGHT, - previewThumbnailFormat, - ), - draggable: false, - }); - const searchPath = item.path.slice(); // shallow copy - const path = SearchPath.systemPath(searchPath, searchSeparator, systemSeparator); - let actionButtons = []; - if (shouldShowTryOpenModelUrl) { - actionButtons.push( - new ComfyButton({ - icon: "open-in-new", - tooltip: "Attempt to open model url page in a new tab.", - classList: "comfyui-button icon-button model-button", - action: async (e) => { - const [button, icon, span] = comfyButtonDisambiguate(e.target); - button.disabled = true; - const webUrl = await tryGetModelWebUrl(searchPath); - const success = tryOpenUrl(webUrl, searchPath); - comfyButtonAlert(e.target, success, "mdi-check-bold", "mdi-close-thick"); - button.disabled = false; - }, - }).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, - ); - } - if (showAddButton) { - 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 (showCopyButton && !(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, - ); - } - const infoButtons = [ - new ComfyButton({ - icon: "information-outline", - tooltip: "View model information", - classList: "comfyui-button icon-button model-button", - action: async(e) => { - const [button, icon, span] = comfyButtonDisambiguate(e.target); - button.disabled = true; - await showModelInfo(searchPath); - button.disabled = false; - }, - }).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"])]; - } - } - - /** - * @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 + return memo && excludeTarget !== text.includes(filteredTarget); } + }, true); + }); + } - 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; + /** + * 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') { + // TODO: If the node has >1 text areas, the textarea element must be selected + target.value = ModelGrid.insertEmbeddingIntoText( + target.value, + embeddingFile, + removeEmbeddingExtension, + ); + success = true; + } + } + if (!success) { + window.alert('No selected nodes have a text area!'); + } + 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 shouldShowTryOpenModelUrl = + canShowButtons && + settingsElements['model-show-open-model-url-button'].checked; + const showLoadWorkflowButton = + canShowButtons && + settingsElements['model-show-load-workflow-button'].checked; + const showAddButton = + canShowButtons && settingsElements['model-show-add-button'].checked; + const showCopyButton = + canShowButtons && settingsElements['model-show-copy-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 ? encodeURIComponent(previewInfo.path) : undefined, + previewInfo?.dateModified ? encodeURIComponent(previewInfo.dateModified) : undefined, + PREVIEW_THUMBNAIL_WIDTH, + PREVIEW_THUMBNAIL_HEIGHT, + previewThumbnailFormat, + ), + draggable: false, + }); + const searchPath = item.path; + const path = SearchPath.systemPath( + searchPath, + searchSeparator, + systemSeparator, + ); + let actionButtons = []; + if (shouldShowTryOpenModelUrl) { + actionButtons.push( + new ComfyButton({ + icon: 'open-in-new', + tooltip: 'Attempt to open model url page in a new tab.', + classList: 'comfyui-button icon-button model-button', + action: async (e) => { + const [button, icon, span] = comfyButtonDisambiguate(e.target); + button.disabled = true; + const webUrl = await tryGetModelWebUrl(searchPath); + const success = tryOpenUrl(webUrl, searchPath); + comfyButtonAlert(e.target, success, 'mdi-check-bold', 'mdi-close-thick'); + button.disabled = false; + }, + }).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, + ); + } + if (showAddButton) { + 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 ( + showCopyButton && + !(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, + ); + } + const infoButtons = [ + new ComfyButton({ + icon: 'information-outline', + tooltip: 'View model information', + classList: 'comfyui-button icon-button model-button', + action: async(e) => { + const [button, icon, span] = comfyButtonDisambiguate(e.target); + button.disabled = true; + await showModelInfo(searchPath); + button.disabled = false; + }, + }).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 of Object.keys(models)) { - const el = $el("option", [key]); + const el = $el('option', [key]); modelTypeOptions.push(el); } - modelTypeOptions.sort((a, b) => a.innerText.localeCompare(b.innerText, undefined, {sensitivity: 'base'})); + modelTypeOptions.sort((a, b) => + a.innerText.localeCompare( + b.innerText, + undefined, + {sensitivity : 'base'}, + ) + ); 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); + 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); - } + 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; - - 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 {string} */ - #savedNotesValue = 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; - - const searchDropdown = new DirectoryDropdown( - modelData, - moveDestinationInput, - true, + /** @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, + }; + + /** @type {ImageSelect} */ + previewSelect = null; + + /** @type {string} */ + #savedNotesValue = 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; + + 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 currrent 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: "Notes", icon: "pencil-outline", tabContent: $el("div", ["Notes"]) }, - { name: "Tags", icon: "tag-outline", tabContent: $el("div", ["Tags"]) }, - { name: "Metadata", icon: "file-document-outline", tabContent: $el("div", ["Metadata"]) }, - ]); - } - - /** @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; - } - 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, url] = await comfyRequest(`/model-manager/model/info/${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"], - result["url"], - ]; + 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; + .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'; + } } - 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 = encodeURIComponent(info["Preview"]["path"]); - const imageDateModified = encodeURIComponent(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", { style: { "flex-direction": "row" } }, [ - new ComfyButton({ - icon: "arrow-bottom-left-bold-box-outline", - tooltip: "Attempt to load preview image workflow", - classList: "comfyui-button icon-button", - action: async () => { - const urlString = previewSelect.elements.defaultPreviews.children[0].src; - await loadWorkflow(urlString); - }, - }).element, - new ComfyButton({ - icon: "open-in-new", - tooltip: "Attempt to open model url page in a new tab.", - classList: "comfyui-button icon-button", - action: async (e) => { - const [button, icon, span] = comfyButtonDisambiguate(e.target); - button.disabled = true; - let webUrl; - if (url !== undefined && url !== "") { - webUrl = url; + 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); } - else { - webUrl = await tryGetModelWebUrl(searchPath); + if (deleted) { + container.innerHTML = ''; + this.element.style.display = 'none'; + updateModels(); } - const success = tryOpenUrl(webUrl, searchPath); - comfyButtonAlert(e.target, success, "mdi-check-bold", "mdi-close-thick"); - button.disabled = false; - }, - }).element, + 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.row.tab-header", [ - $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; - })(), + ], + ), + $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: 'Notes', + icon: 'pencil-outline', + tabContent: $el('div', ['Notes']), + }, + { + name: 'Tags', + icon: 'tag-outline', + tabContent: $el('div', ['Tags']), + }, + { + name: 'Metadata', + icon: 'file-document-outline', + tabContent: $el('div', ['Metadata']), + }, + ]); + } + + /** @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, url] = await comfyRequest( + `/model-manager/model/info/${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'], + result['url'], + ]; + }) + .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], ), - ])); - infoHtml.append.apply(infoHtml, innerHtml); - // TODO: set default value of dropdown and value to model type? - - // - // NOTES - // - - 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 downloadNotesButton = new ComfyButton({ - icon: "earth-arrow-down", - tooltip: "Attempt to download model info from the internet.", - classList: "comfyui-button icon-button", - action: async (e) => { - if (this.#savedNotesValue !== "") { - const overwriteNoteConfirmation = window.confirm("Overwrite note?"); - if (!overwriteNoteConfirmation) { - comfyButtonAlert(e.target, false, "mdi-check-bold", "mdi-close-thick"); - return; - } - } - - const [button, icon, span] = comfyButtonDisambiguate(e.target); - button.disabled = true; - const [success, downloadedNotesValue] = await comfyRequest( - `/model-manager/notes/download?path=${path}&overwrite=True`, - { - method: "POST", - body: {}, - } - ).then((data) => { - const success = data["success"]; - const message = data["alert"]; - if (message !== undefined) { - window.alert(message); - } - return [success, data["notes"]]; - }).catch((err) => { - return [false, ""]; - }); - if (success) { - this.#savedNotesValue = downloadedNotesValue; - this.elements.notes.value = downloadedNotesValue; - } - comfyButtonAlert(e.target, success, "mdi-check-bold", "mdi-close-thick"); - 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[1]; // TODO: remove magic value - notesElement.innerHTML = ""; - 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(); + $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 (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.#savedNotesValue = noteText; - return [ - $el("div.row", { - style: { "align-items": "center" }, - }, [ - $el("h1", ["Notes"]), - saveNotesButton, - downloadNotesButton, - ]), - $el("div", { - style: { "display": "flex", "height": "100%", "min-height": "60px" }, - }, notes), - ]; - })() - ); - - // - // Tags - // - - /** @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; - } - } - tagsElement.innerHTML = ""; - tagsElement.append.apply(tagsElement, [ - $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, + 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, ]), - $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"; - - // - // Metadata - // - - /** @type {HTMLDivElement} */ - const metadataElement = this.elements.tabContents[3]; // 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[3]; // TODO: remove magic value - metadataButton.style.display = isMetadata ? "" : "none"; + ], + ), + ); } - - 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 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 = encodeURIComponent(info['Preview']['path']); + const imageDateModified = encodeURIComponent(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', { style: { "flex-direction": "row" } }, [ + new ComfyButton({ + icon: 'arrow-bottom-left-bold-box-outline', + tooltip: 'Attempt to load preview image workflow', + classList: 'comfyui-button icon-button', + action: async () => { + const urlString = + previewSelect.elements.defaultPreviews.children[0].src; + await loadWorkflow(urlString); + }, + }).element, + new ComfyButton({ + icon: 'open-in-new', + tooltip: 'Attempt to open model url page in a new tab.', + classList: 'comfyui-button icon-button', + action: async (e) => { + const [button, icon, span] = comfyButtonDisambiguate(e.target); + button.disabled = true; + let webUrl; + if (url !== undefined && url !== "") { + webUrl = url; + } + else { + webUrl = await tryGetModelWebUrl(searchPath); + } + const success = tryOpenUrl(webUrl, searchPath); + comfyButtonAlert(e.target, success, "mdi-check-bold", "mdi-close-thick"); + button.disabled = false; + }, + }).element, + ]), + $el('div.row.tab-header', [ + $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? + +// + // NOTES + // + + 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 downloadNotesButton = new ComfyButton({ + icon: 'earth-arrow-down', + tooltip: 'Attempt to download model info from the internet.', + classList: 'comfyui-button icon-button', + action: async (e) => { + if (this.#savedNotesValue !== '') { + const overwriteNoteConfirmation = window.confirm('Overwrite note?'); + if (!overwriteNoteConfirmation) { + comfyButtonAlert(e.target, false, 'mdi-check-bold', 'mdi-close-thick'); + return; + } + } + + const [button, icon, span] = comfyButtonDisambiguate(e.target); + button.disabled = true; + const [success, downloadedNotesValue] = await comfyRequest( + `/model-manager/notes/download?path=${path}&overwrite=True`, + { + method: 'POST', + body: {}, + } + ).then((data) => { + const success = data['success']; + const message = data['alert']; + if (message !== undefined) { + window.alert(message); + } + return [success, data['notes']]; + }).catch((err) => { + return [false, '']; + }); + if (success) { + this.#savedNotesValue = downloadedNotesValue; + this.elements.notes.value = downloadedNotesValue; + this.elements.markdown.innerHTML = marked.parse(downloadedNotesValue); + } + comfyButtonAlert(e.target, success, 'mdi-check-bold', 'mdi-close-thick'); + 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[1]; // 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); } - 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; + + this.elements.notes = notes; + this.elements.markdown = markdown; + this.#savedNotesValue = noteText; + + const notesEditor = $el( + 'div', + { + style: { + display: noteText == '' ? 'flex' : 'none', + height: '100%', + 'min-height': '60px', + }, + }, + notes, + ); + const notesViewer = $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 () => { + notesEditor.style.display = + notesEditor.style.display == 'flex' ? 'none' : 'flex'; + notesViewer.style.display = + notesViewer.style.display == 'none' ? 'flex' : 'none'; + }, + }).element; + + return [ + $el( + 'div.row', + { + style: { 'align-items': 'center' }, + }, + [$el('h1', ['Notes']), downloadNotesButton, saveNotesButton, editNotesButton], + ), + notesEditor, + notesViewer, + ]; + })(), + ); + + // + // TAGS + // + + /** @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); } - const sortedSamples = samples.sort((x1, x2) => { return parseInt(x2[1]) - parseInt(x1[1]) }); - return sortedSamples.map(x => x[0]); + 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'; + + // + // METADATA + // + + /** @type {HTMLDivElement} */ + const metadataElement = this.elements.tabContents[3]; // 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[3]; // TODO: remove magic value + metadataButton.style.display = isMetadata ? '' : 'none'; + } + + 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]); + } } 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} Dictionary containing received model info. Returns an empty if fails. - */ - static async requestInfo(id, apiPath) { - const url = "https://civitai.com/api/v1/" + apiPath + "/" + id; - try { - const response = await fetch(url); - const data = await response.json(); - return data; - } - catch (error) { - console.error("Failed to get model info from Civitai!", error); - return {}; - } + /** + * 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} Dictionary containing received model info. Returns an empty if fails. + */ + static async requestInfo(id, apiPath) { + const url = 'https://civitai.com/api/v1/' + apiPath + '/' + id; + try { + const response = await fetch(url); + const data = await response.json(); + return data; + } catch (error) { + console.error('Failed to get model info from Civitai!', error); + return {}; } - - /** - * Extract file information from the given model version information. - * - * @param {Object} modelVersionInfo - Model version information. - * @param {(string|null)} [type=null] - Optional select by model type. - * @param {(string|null)} [fp=null] - Optional select by floating point quantization. - * @param {(string|null)} [size=null] - Optional select by sizing. - * @param {(string|null)} [format=null] - Optional select by file format. - * - * @returns {Object} - Extracted list of information on each file of the given model version. - */ - static getModelFilesInfo(modelVersionInfo, type = null, fp = null, size = null, format = null) { - const files = []; - 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, - "fp": fileFp, - "hashes": modelVersionFile["hashes"], - "name": modelVersionFile["name"], - "size": fileSize, - "sizeKB": modelVersionFile["sizeKB"], - "type": fileType, - }); - } - return { - "files": files, - "id": modelVersionInfo["id"], - "images": modelVersionInfo["images"].map((image) => { - // TODO: do I need to double-check image matches resource? - return image["url"]; - }), - "name": modelVersionInfo["name"], - "description": modelVersionInfo["description"], - "tags": modelVersionInfo["trainedWords"], - }; + } + + /** + * Extract file information from the given model version information. + * + * @param {Object} modelVersionInfo - Model version information. + * @param {(string|null)} [type=null] - Optional select by model type. + * @param {(string|null)} [fp=null] - Optional select by floating point quantization. + * @param {(string|null)} [size=null] - Optional select by sizing. + * @param {(string|null)} [format=null] - Optional select by file format. + * + * @returns {Object} - Extracted list of information on each file of the given model version. + */ + static getModelFilesInfo( + modelVersionInfo, + type = null, + fp = null, + size = null, + format = null, + ) { + const files = []; + 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, + fp: fileFp, + hashes: modelVersionFile['hashes'], + name: modelVersionFile['name'], + size: fileSize, + sizeKB: modelVersionFile['sizeKB'], + type: fileType, + }); } - - /** - * @param {string} stringUrl - Model url. - * - * @returns {Promise} - Download information for the given url. - */ - static async getFilteredInfo(stringUrl) { - const url = new URL(stringUrl); - if (url.hostname != "civitai.com") { return {}; } - if (url.pathname == "/") { return {} } - const urlPath = url.pathname; - if (urlPath.startsWith("/api")) { - const idEnd = urlPath.length - (urlPath.at(-1) == "/" ? 1 : 0); - const idStart = urlPath.lastIndexOf("/", idEnd - 1) + 1; - const modelVersionId = urlPath.substring(idStart, idEnd); - if (parseInt(modelVersionId, 10) == NaN) { - return {}; - } - const modelVersionInfo = await Civitai.requestInfo(modelVersionId, "model-versions"); - if (Object.keys(modelVersionInfo).length == 0) { - return {}; - } - const searchParams = url.searchParams; - const filesInfo = Civitai.getModelFilesInfo( - modelVersionInfo, - searchParams.get("type"), - searchParams.get("fp"), - searchParams.get("size"), - searchParams.get("format"), - ); - return { - "name": modelVersionInfo["model"]["name"], - "type": modelVersionInfo["model"]["type"], - "description": modelVersionInfo["description"], - "tags": modelVersionInfo["trainedWords"], - "versions": [filesInfo] - } - } - else if (urlPath.startsWith('/models')) { - const idStart = urlPath.indexOf("models/") + "models/".length; - const idEnd = (() => { - const idEnd = urlPath.indexOf("/", idStart); - return idEnd === -1 ? urlPath.length : idEnd; - })(); - const modelId = urlPath.substring(idStart, idEnd); - if (parseInt(modelId, 10) == NaN) { - return {}; - } - const modelInfo = await Civitai.requestInfo(modelId, "models"); - if (Object.keys(modelInfo).length == 0) { - return {}; - } - const modelVersionId = parseInt(url.searchParams.get("modelVersionId")); - const modelVersions = []; - const modelVersionInfos = modelInfo["modelVersions"]; - for (let i = 0; i < modelVersionInfos.length; i++) { - const versionInfo = modelVersionInfos[i]; - if (!Number.isNaN(modelVersionId)) { - if (modelVersionId != versionInfo["id"]) {continue; } - } - const filesInfo = Civitai.getModelFilesInfo(versionInfo); - modelVersions.push(filesInfo); - } - return { - "name": modelInfo["name"], - "type": modelInfo["type"], - "description": modelInfo["description"], - "versions": modelVersions, - } - } - else { - return {}; - } + return { + files: files, + id: modelVersionInfo['id'], + images: modelVersionInfo['images'].map((image) => { + // TODO: do I need to double-check image matches resource? + return image['url']; + }), + name: modelVersionInfo['name'], + description: modelVersionInfo['description'], + tags: modelVersionInfo['trainedWords'], + }; + } + + /** + * @param {string} stringUrl - Model url. + * + * @returns {Promise} - Download information for the given url. + */ + static async getFilteredInfo(stringUrl) { + const url = new URL(stringUrl); + if (url.hostname != 'civitai.com') { + return {}; } - - /** - * @returns {string} - */ - static imagePostUrlPrefix() { - return "https://civitai.com/images/"; + if (url.pathname == '/') { + return {}; } - - /** - * @returns {string} - */ - static imageUrlPrefix() { - return "https://image.civitai.com/"; + const urlPath = url.pathname; + if (urlPath.startsWith('/api')) { + const idEnd = urlPath.length - (urlPath.at(-1) == '/' ? 1 : 0); + const idStart = urlPath.lastIndexOf('/', idEnd - 1) + 1; + const modelVersionId = urlPath.substring(idStart, idEnd); + if (parseInt(modelVersionId, 10) == NaN) { + return {}; + } + const modelVersionInfo = await Civitai.requestInfo( + modelVersionId, + 'model-versions', + ); + if (Object.keys(modelVersionInfo).length == 0) { + return {}; + } + const searchParams = url.searchParams; + const filesInfo = Civitai.getModelFilesInfo( + modelVersionInfo, + searchParams.get('type'), + searchParams.get('fp'), + searchParams.get('size'), + searchParams.get('format'), + ); + return { + name: modelVersionInfo['model']['name'], + type: modelVersionInfo['model']['type'], + description: modelVersionInfo['description'], + tags: modelVersionInfo['trainedWords'], + versions: [filesInfo], + }; + } else if (urlPath.startsWith('/models')) { + const idStart = urlPath.indexOf('models/') + 'models/'.length; + const idEnd = (() => { + const idEnd = urlPath.indexOf('/', idStart); + return idEnd === -1 ? urlPath.length : idEnd; + })(); + const modelId = urlPath.substring(idStart, idEnd); + if (parseInt(modelId, 10) == NaN) { + return {}; + } + const modelInfo = await Civitai.requestInfo(modelId, 'models'); + if (Object.keys(modelInfo).length == 0) { + return {}; + } + const modelVersionId = parseInt(url.searchParams.get('modelVersionId')); + const modelVersions = []; + const modelVersionInfos = modelInfo['modelVersions']; + for (let i = 0; i < modelVersionInfos.length; i++) { + const versionInfo = modelVersionInfos[i]; + if (!Number.isNaN(modelVersionId)) { + if (modelVersionId != versionInfo['id']) { + continue; + } + } + const filesInfo = Civitai.getModelFilesInfo(versionInfo); + modelVersions.push(filesInfo); + } + return { + name: modelInfo['name'], + type: modelInfo['type'], + description: modelInfo['description'], + versions: modelVersions, + }; + } else { + return {}; } - - /** - * @param {string} stringUrl - https://civitai.com/images/{imageId}. - * - * @returns {Promise} - Image information. - */ - static async getImageInfo(stringUrl) { - const imagePostUrlPrefix = Civitai.imagePostUrlPrefix(); - if (!stringUrl.startsWith(imagePostUrlPrefix)) { - return {}; - } - const id = stringUrl.substring(imagePostUrlPrefix.length).match(/^\d+/)[0]; - const url = `https://civitai.com/api/v1/images?imageId=${id}`; - try { - const response = await fetch(url); - const data = await response.json(); - return data; - } - catch (error) { - console.error("Failed to get image info from Civitai!", error); - 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}. + * + * @returns {Promise} - Image information. + */ + static async getImageInfo(stringUrl) { + const imagePostUrlPrefix = Civitai.imagePostUrlPrefix(); + if (!stringUrl.startsWith(imagePostUrlPrefix)) { + return {}; } - - /** - * @param {string} stringUrl - https://image.civitai.com/... - * - * @returns {Promise} - */ - static async getFullSizeImageUrl(stringUrl) { - const imageUrlPrefix = Civitai.imageUrlPrefix(); - if (!stringUrl.startsWith(imageUrlPrefix)) { - return ""; - } - const i0 = stringUrl.lastIndexOf("/"); - const i1 = stringUrl.lastIndexOf("."); - if (i0 === -1 || i1 === -1) { - return ""; - } - const id = parseInt(stringUrl.substring(i0 + 1, i1)).toString(); - const url = `https://civitai.com/api/v1/images?imageId=${id}`; - try { - const response = await fetch(url); - const imageInfo = await response.json(); - const items = imageInfo["items"]; - if (items.length === 0) { - console.warn("Civitai /api/v1/images returned 0 items."); - return stringUrl; - } - return items[0]["url"]; - } - catch (error) { - console.error("Failed to get image info from Civitai!", error); - return stringUrl; - } + const id = stringUrl.substring(imagePostUrlPrefix.length).match(/^\d+/)[0]; + const url = `https://civitai.com/api/v1/images?imageId=${id}`; + try { + const response = await fetch(url); + const data = await response.json(); + return data; + } catch (error) { + console.error('Failed to get image info from Civitai!', error); + return {}; } + } + + /** + * @param {string} stringUrl - https://image.civitai.com/... + * + * @returns {Promise} + */ + static async getFullSizeImageUrl(stringUrl) { + const imageUrlPrefix = Civitai.imageUrlPrefix(); + if (!stringUrl.startsWith(imageUrlPrefix)) { + return ''; + } + const i0 = stringUrl.lastIndexOf('/'); + const i1 = stringUrl.lastIndexOf('.'); + if (i0 === -1 || i1 === -1) { + return ''; + } + const id = parseInt(stringUrl.substring(i0 + 1, i1)).toString(); + const url = `https://civitai.com/api/v1/images?imageId=${id}`; + try { + const response = await fetch(url); + const imageInfo = await response.json(); + const items = imageInfo['items']; + if (items.length === 0) { + console.warn('Civitai /api/v1/images returned 0 items.'); + return stringUrl; + } + return items[0]['url']; + } catch (error) { + console.error('Failed to get image info from Civitai!', error); + return stringUrl; + } + } } class HuggingFace { - /** - * Get model info from Huggingface. - * - * @param {string} id - Model ID. - * @param {string} apiPath - API path. - * - * @returns {Promise} Dictionary containing received model info. Returns an empty if fails. - */ - static async requestInfo(id, apiPath = "models") { - const url = "https://huggingface.co/api/" + apiPath + "/" + id; - try { - const response = await fetch(url); - const data = await response.json(); - return data; - } - catch (error) { - console.error("Failed to get model info from HuggingFace!", error); - return {}; - } + /** + * Get model info from Huggingface. + * + * @param {string} id - Model ID. + * @param {string} apiPath - API path. + * + * @returns {Promise} Dictionary containing received model info. Returns an empty if fails. + */ + static async requestInfo(id, apiPath = 'models') { + const url = 'https://huggingface.co/api/' + apiPath + '/' + id; + try { + const response = await fetch(url); + const data = await response.json(); + return data; + } catch (error) { + console.error('Failed to get model info from HuggingFace!', error); + return {}; } - - /** - * - * - * @param {string} stringUrl - Model url. - * - * @returns {Promise} - */ - static async getFilteredInfo(stringUrl) { - const url = new URL(stringUrl); - if (url.hostname != "huggingface.co") { return {}; } - if (url.pathname == "/") { return {} } - const urlPath = url.pathname; - const i0 = 1; - const i1 = urlPath.indexOf("/", i0); - if (i1 == -1 || urlPath.length - 1 == i1) { - // user-name only - return {}; - } - let i2 = urlPath.indexOf("/", i1 + 1); - if (i2 == -1) { - // model id only - i2 = urlPath.length; - } - 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) { - const i0 = branch.length; - const i1 = urlPathEnd.indexOf("/", i0 + 1); - if (i1 == -1) { - if (i0 != urlPathEnd.length) { - // ends with branch - branch = urlPathEnd.substring(i0); - } - } - else { - branch = urlPathEnd.substring(i0, i1); - if (urlPathEnd.length - 1 > i1) { - filePath = urlPathEnd.substring(i1); - } - } - } - - 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"]; - for (let i = 0; i < MODEL_EXTENSIONS.length; i++) { - if (filename.endsWith(MODEL_EXTENSIONS[i])) { - return filename.startsWith(clippedFilePath); - } - } - return false; - }).map((sib) => { - const filename = sib["rfilename"]; - return filename; - }); - 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++) { - if (filename.endsWith(IMAGE_EXTENSIONS[i])) { - return filename.startsWith(clippedFilePath); - } - } - return false; - }).map((sib) => { - return baseDownloadUrl + "/" + sib["rfilename"]; - }); - - return { - "baseDownloadUrl": baseDownloadUrl, - "modelFiles": modelFiles, - "images": images, - "name": modelId, - }; + } + + /** + * + * + * @param {string} stringUrl - Model url. + * + * @returns {Promise} + */ + static async getFilteredInfo(stringUrl) { + const url = new URL(stringUrl); + if (url.hostname != 'huggingface.co') { + return {}; } + if (url.pathname == '/') { + return {}; + } + const urlPath = url.pathname; + const i0 = 1; + const i1 = urlPath.indexOf('/', i0); + if (i1 == -1 || urlPath.length - 1 == i1) { + // user-name only + return {}; + } + let i2 = urlPath.indexOf('/', i1 + 1); + if (i2 == -1) { + // model id only + i2 = urlPath.length; + } + 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) { + const i0 = branch.length; + const i1 = urlPathEnd.indexOf('/', i0 + 1); + if (i1 == -1) { + if (i0 != urlPathEnd.length) { + // ends with branch + branch = urlPathEnd.substring(i0); + } + } else { + branch = urlPathEnd.substring(i0, i1); + if (urlPathEnd.length - 1 > i1) { + filePath = urlPathEnd.substring(i1); + } + } + } + + 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']; + for (let i = 0; i < MODEL_EXTENSIONS.length; i++) { + if (filename.endsWith(MODEL_EXTENSIONS[i])) { + return filename.startsWith(clippedFilePath); + } + } + return false; + }) + .map((sib) => { + const filename = sib['rfilename']; + return filename; + }); + 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++) { + if (filename.endsWith(IMAGE_EXTENSIONS[i])) { + return filename.startsWith(clippedFilePath); + } + } + return false; + }) + .map((sib) => { + return baseDownloadUrl + '/' + sib['rfilename']; + }); + + return { + baseDownloadUrl: baseDownloadUrl, + modelFiles: modelFiles, + images: images, + name: modelId, + }; + } } /** - * @param {string} urlText + * @param {string} urlText * @returns {Promise<[string, any[]]>} [name, modelInfos] */ async function getModelInfos(urlText) { - // TODO: class for proper return type - return await (async () => { - if (urlText.startsWith("https://civitai.com")) { - const civitaiInfo = await Civitai.getFilteredInfo(urlText); - if (Object.keys(civitaiInfo).length === 0) { - return ["", []]; - } - const name = civitaiInfo["name"]; - const infos = []; - const type = civitaiInfo["type"]; - civitaiInfo["versions"].forEach((version) => { - const images = version["images"]; - const tags = version["tags"]?.map((tag) => tag.trim().replace(/,$/, "")); - const description = [ - tags !== undefined ? "# Trigger Words" : undefined, - tags?.join(tags.some((tag) => { return tag.includes(","); }) ? "\n" : ", "), - version["description"] !== undefined ? "# About this version" : undefined, - version["description"], - civitaiInfo["description"] !== undefined ? "# " + name : undefined, - civitaiInfo["description"], - ].filter(x => x !== undefined).join("\n\n") - .replaceAll("

    ", "\n\n") - .replaceAll("", "**").replaceAll("", "**") - .replaceAll("

      ", "\n").replaceAll("
    ", "\n") // wrong - .replaceAll("
      ", "\n").replaceAll("
    ", "\n") - .replaceAll("
  • ", "- ").replaceAll("
  • ", "\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("&", "&"); - version["files"].forEach((file) => { - infos.push({ - "images": images, - "fileName": file["name"], - "modelType": type, - "downloadUrl": file["downloadUrl"], - "downloadFilePath": "", - "description": description, - "details": { - "fileSizeKB": file["sizeKB"], - "fileType": file["type"], - "fp": file["fp"], - "quant": file["size"], - "fileFormat": file["format"], - }, - }); - }); - }); - return [name, infos]; + // TODO: class for proper return type + return await (async () => { + if (urlText.startsWith('https://civitai.com')) { + const civitaiInfo = await Civitai.getFilteredInfo(urlText); + if (Object.keys(civitaiInfo).length === 0) { + return ['', []]; + } + const name = civitaiInfo['name']; + const infos = []; + const type = civitaiInfo['type']; + + civitaiInfo['versions'].forEach((version) => { + const images = version['images']; + const tags = version['tags']?.map((tag) => + tag.trim().replace(/,$/, ''), + ); + const description = [ + tags !== undefined ? '# Trigger Words' : undefined, + tags?.join( + tags.some((tag) => { + return tag.includes(','); + }) + ? '\n' + : ', ', + ), + version['description'] !== undefined + ? '# About this version ' + : undefined, + version['description'], + civitaiInfo['description'] !== undefined ? '# ' + name : undefined, + civitaiInfo['description'], + ] + .filter((x) => x !== undefined) + .join('\n\n'); + version['files'].forEach((file) => { + infos.push({ + images: images, + fileName: file['name'], + modelType: type, + downloadUrl: file['downloadUrl'], + downloadFilePath: '', + description: downshow(description), + details: { + fileSizeKB: file['sizeKB'], + fileType: file['type'], + fp: file['fp'], + quant: file['size'], + fileFormat: file['format'], + }, + }); + }); + }); + return [name, infos]; + } + if (urlText.startsWith('https://huggingface.co')) { + const hfInfo = await HuggingFace.getFilteredInfo(urlText); + if (Object.keys(hfInfo).length === 0) { + return ['', []]; + } + const files = hfInfo['modelFiles']; + if (files.length === 0) { + return ['', []]; + } + const name = hfInfo['name']; + const baseDownloadUrl = hfInfo['baseDownloadUrl']; + const infos = hfInfo['modelFiles'].map((file) => { + const indexSep = file.lastIndexOf('/'); + const filename = file.substring(indexSep + 1); + return { + images: hfInfo['images'], + fileName: filename, + modelType: '', + downloadUrl: baseDownloadUrl + '/' + file + '?download=true', + downloadFilePath: file.substring(0, indexSep + 1), + description: '', + details: { + fileSizeKB: undefined, // TODO: too hard? + }, + }; + }); + return [name, infos]; + } + if (urlText.endsWith('.json')) { + const indexInfo = await (async () => { + try { + const response = await fetch(url); + const data = await response.json(); + return data; + } catch { + return []; } - if (urlText.startsWith("https://huggingface.co")) { - const hfInfo = await HuggingFace.getFilteredInfo(urlText); - if (Object.keys(hfInfo).length === 0) { - return ["", []]; - } - const files = hfInfo["modelFiles"]; - if (files.length === 0) { - return ["", []]; - } - const name = hfInfo["name"]; - const baseDownloadUrl = hfInfo["baseDownloadUrl"]; - const infos = hfInfo["modelFiles"].map((file) => { - const indexSep = file.lastIndexOf("/"); - const filename = file.substring(indexSep + 1); - return { - "images": hfInfo["images"], - "fileName": filename, - "modelType": "", - "downloadUrl": baseDownloadUrl + "/" + file + "?download=true", - "downloadFilePath": file.substring(0, indexSep + 1), - "description": "", - "details": { - "fileSizeKB": undefined, // TODO: too hard? - }, - }; - }); - return [name, infos]; - } - if (urlText.endsWith(".json")) { - const indexInfo = await (async() => { - try { - const response = await fetch(url); - const data = await response.json(); - return data; - } - catch { - return []; - } - })(); - const name = urlText.substring(math.max(urlText.lastIndexOf("/"), 0)); - const infos = indexInfo.map((file) => { - return { - "images": [], - "fileName": file["name"], - "modelType": DownloadView.modelTypeToComfyUiDirectory(file["type"], "") ?? "", - "downloadUrl": file["download"], - "downloadFilePath": "", - "description": file["description"], - "details": {}, - }; - }); - return [name, infos]; - } - return ["", []]; - })(); + })(); + const name = urlText.substring(math.max(urlText.lastIndexOf('/'), 0)); + const infos = indexInfo.map((file) => { + return { + images: [], + fileName: file['name'], + modelType: + DownloadView.modelTypeToComfyUiDirectory(file['type'], '') ?? '', + downloadUrl: file['download'], + downloadFilePath: '', + description: file['description'], + details: {}, + }; + }); + return [name, infos]; + } + return ['', []]; + })(); } class DownloadView { - /** @type {HTMLDivElement} */ - element = null; - - elements = { - /** @type {HTMLInputElement} */ url: null, - /** @type {HTMLDivElement} */ infos: null, - /** @type {HTMLInputElement} */ overwrite: null, - /** @type {HTMLInputElement} */ downloadNotes: null, - /** @type {HTMLButtonElement} */ searchButton: null, - /** @type {HTMLButtonElement} */ clearSearchButton: null, + /** @type {HTMLDivElement} */ + element = null; + + elements = { + /** @type {HTMLInputElement} */ url: null, + /** @type {HTMLDivElement} */ infos: null, + /** @type {HTMLInputElement} */ overwrite: null, + /** @type {HTMLInputElement} */ downloadNotes: null, + /** @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 + * @param {() => Promise} updateModels + */ + constructor(modelData, settings, updateModels) { + this.#domParser = new DOMParser(); + this.#updateModels = updateModels; + const update = async () => { + await this.#update(modelData, settings); }; - - /** @type {Object.} */ - #settings = null; - - /** @type {() => Promise} */ - #updateModels = () => {}; - - /** - * @param {ModelData} modelData - * @param {Object.} settings - * @param {() => Promise} updateModels - */ - constructor(modelData, settings, updateModels) { - this.#updateModels = updateModels; - const update = async() => { await this.#update(modelData, settings); }; - const reset = () => { - this.elements.infos.innerHTML = ""; - this.elements.infos.appendChild( - $el("h1", ["Input a URL to select a model to download."]) - ); - }; - - const searchButton = new ComfyButton({ - icon: "magnify", - tooltip: "Search url", - classList: "comfyui-button icon-button", - action: async(e) => { + const reset = () => { + this.elements.infos.innerHTML = ''; + this.elements.infos.appendChild( + $el('h1', ['Input a URL to select a model to download.']), + ); + }; + + const searchButton = new ComfyButton({ + icon: 'magnify', + tooltip: 'Search url', + classList: 'comfyui-button icon-button', + action: async (e) => { + const [button, icon, span] = comfyButtonDisambiguate(e.target); + button.disabled = true; + if (this.elements.url.value === '') { + reset(); + } else { + await update(); + } + button.disabled = false; + }, + }).element; + settings['model-real-time-search'].addEventListener('change', () => { + const hideSearchButton = + settings['text-input-always-hide-search-button'].checked; + searchButton.style.display = hideSearchButton ? 'none' : ''; + }); + settings['text-input-always-hide-search-button'].addEventListener( + 'change', + () => { + const hideSearchButton = + settings['text-input-always-hide-search-button'].checked; + searchButton.style.display = hideSearchButton ? 'none' : ''; + }, + ); + this.elements.searchButton = searchButton; + + const clearSearchButton = new ComfyButton({ + icon: 'close', + tooltip: 'Clear search', + classList: 'comfyui-button icon-button', + action: async (e) => { + e.stopPropagation(); + this.elements.url.value = ''; + reset(); + }, + }).element; + settings['text-input-always-hide-clear-button'].addEventListener( + 'change', + () => { + const hideClearButton = + settings['text-input-always-hide-clear-button'].checked; + clearSearchButton.style.display = hideClearButton ? 'none' : ''; + }, + ); + this.elements.clearSearchButton = clearSearchButton; + + $el( + 'div.tab-header', + { + $: (el) => (this.element = el), + }, + [ + $el('div.row.tab-header-flex-block', [ + $el('input.search-text-area', { + $: (el) => (this.elements.url = el), + type: 'text', + name: 'model download url', + autocomplete: 'off', + placeholder: 'Search URL', + onkeydown: async (e) => { + if (e.key === 'Enter') { + e.stopPropagation(); + searchButton.click(); + e.target.blur(); + } + }, + }), + clearSearchButton, + searchButton, + ]), + $el( + 'div.download-model-infos', + { + $: (el) => (this.elements.infos = el), + }, + [$el('h1', ['Input a URL to select a model to download.'])], + ), + ], + ); + } + + /** + * Tries to return the related ComfyUI model directory if unambiguous. + * + * @param {string | undefined} modelType - Model type. + * @param {string | undefined} [fileType] - File type. Relevant for "Diffusers". + * + * @returns {(string | null)} Logical base directory name for model type. May be null if the directory is ambiguous or not a model type. + */ + static modelTypeToComfyUiDirectory(modelType, fileType) { + if (fileType !== undefined && fileType !== null) { + 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? + // TODO: allow user to choose EXISTING folder override/null? (style_models, HuggingFace) (use an object/map instead so settings can be dynamically set) + if (m == 'aestheticGradient') { + return null; + } else if (m == 'checkpoint' || m == 'checkpoints') { + return 'checkpoints'; + } + //else if (m == "") { return "clip"; } + //else if (m == "") { return "clip_vision"; } + else if (m == 'controlnet') { + return 'controlnet'; + } + //else if (m == "Controlnet") { return "style_models"; } // are these controlnets? (TI-Adapter) + //else if (m == "") { return "gligen"; } + else if (m == 'hypernetwork' || m == 'hypernetworks') { + return 'hypernetworks'; + } else if (m == 'lora' || m == 'loras') { + return 'loras'; + } else if (m == 'locon') { + return 'loras'; + } else if (m == 'motionmodule') { + return null; + } else if (m == 'other') { + return null; + } else if (m == 'pose') { + return null; + } else if ( + m == 'textualinversion' || + m == 'embedding' || + m == 'embeddings' + ) { + return 'embeddings'; + } + //else if (m == "") { return "unet"; } + else if ( + m == 'upscaler' || + m == 'upscale_model' || + m == 'upscale_models' + ) { + return 'upscale_models'; + } else if (m == 'vae') { + return 'vae'; + } else if (m == 'wildcard' || m == 'wildcards') { + return null; + } else if (m == 'workflow' || m == 'workflows') { + return null; + } + } + return null; + } + + /** + * Returns empty string on failure + * @param {float | undefined} fileSizeKB + * @returns {string} + */ + static #fileSizeToFormattedString(fileSizeKB) { + if (fileSizeKB === undefined) { + return ''; + } + const sizes = ['KB', 'MB', 'GB', 'TB', 'PB']; + let fileSizeString = fileSizeKB.toString(); + const index = fileSizeString.indexOf('.'); + const indexMove = index % 3 === 0 ? 3 : index % 3; + const sizeIndex = Math.floor((index - indexMove) / 3); + if (sizeIndex >= sizes.length || sizeIndex < 0) { + fileSizeString = fileSizeString.substring( + 0, + fileSizeString.indexOf('.') + 3, + ); + return `(${fileSizeString} ${sizes[0]})`; + } + const split = fileSizeString.split('.'); + fileSizeString = + split[0].substring(0, indexMove) + + '.' + + split[0].substring(indexMove) + + split[1]; + fileSizeString = fileSizeString.substring( + 0, + fileSizeString.indexOf('.') + 3, + ); + return `(${fileSizeString} ${sizes[sizeIndex]})`; + } + + /** + * @param {Object} info + * @param {ModelData} modelData + * @param {int} id + * @param {any} settings + * @returns {HTMLDivElement} + */ + #modelInfoHtml(info, modelData, id, settings) { + const downloadPreviewSelect = new ImageSelect( + 'model-download-info-preview-model' + '-' + id, + info['images'], + ); + + const comfyUIModelType = + DownloadView.modelTypeToComfyUiDirectory(info['details']['fileType']) ?? + DownloadView.modelTypeToComfyUiDirectory(info['modelType']) ?? + ''; + const searchSeparator = modelData.searchSeparator; + const defaultBasePath = + searchSeparator + + (comfyUIModelType === '' ? '' : comfyUIModelType + searchSeparator + '0'); + + const el_saveDirectoryPath = $el('input.search-text-area', { + type: 'text', + name: 'save directory', + autocomplete: 'off', + placeholder: defaultBasePath, + value: defaultBasePath, + }); + const searchDropdown = new DirectoryDropdown( + modelData, + el_saveDirectoryPath, + true, + ); + + const default_name = (() => { + const filename = info['fileName']; + // TODO: only remove valid model file extensions + const i = filename.lastIndexOf('.'); + return i === -1 ? filename : filename.substring(0, i); + })(); + const el_filename = $el('input.plain-text-area', { + type: 'text', + name: 'model save file name', + autocomplete: 'off', + placeholder: default_name, + value: default_name, + onkeydown: (e) => { + if (e.key === 'Enter') { + e.stopPropagation(); + e.target.blur(); + } + }, + }); + + const infoNotes = $el('textarea.comfy-multiline-input.model-info-notes', { + name: 'model info notes', + value: info['description'] ?? '', + rows: 6, + 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']]), + $el('div', [ + downloadPreviewSelect.elements.previews, + $el('div.download-settings-wrapper', [ + $el('div.download-settings', [ + new ComfyButton({ + icon: 'arrow-collapse-down', + tooltip: 'Download model', + content: + 'Download ' + + DownloadView.#fileSizeToFormattedString( + info['details']['fileSizeKB'], + ), + classList: 'comfyui-button download-button', + action: async (e) => { + const pathDirectory = el_saveDirectoryPath.value; + const modelName = (() => { + const filename = info['fileName']; + const name = el_filename.value; + if (name === '') { + return filename; + } + const ext = + MODEL_EXTENSIONS.find((ext) => { + return filename.endsWith(ext); + }) ?? ''; + return name + ext; + })(); + const formData = new FormData(); + formData.append('download', info['downloadUrl']); + formData.append('path', pathDirectory); + formData.append('name', modelName); + const image = await downloadPreviewSelect.getImage(); + formData.append( + 'image', + image === PREVIEW_NONE_URI ? '' : image, + ); + formData.append('overwrite', this.elements.overwrite.checked); const [button, icon, span] = comfyButtonDisambiguate(e.target); button.disabled = true; - if (this.elements.url.value === "") { - reset(); + const [success, resultText] = await comfyRequest( + '/model-manager/model/download', + { + method: 'POST', + body: formData, + }, + ) + .then((data) => { + const success = data['success']; + const message = data['alert']; + if (message !== undefined) { + window.alert(message); + } + return [success, success ? '✔' : '📥︎']; + }) + .catch((err) => { + return [false, '📥︎']; + }); + if (success) { + const description = infoNotes.value; + if ( + this.elements.downloadNotes.checked && + description !== '' + ) { + const modelPath = + pathDirectory + searchSeparator + modelName; + const saved = await saveNotes(modelPath, description); + if (!saved) { + console.warn('Model description was not saved!'); + } + } + this.#updateModels(); } - else { - await update(); - } - button.disabled = false; - }, - }).element; - settings["model-real-time-search"].addEventListener("change", () => { - const hideSearchButton = settings["text-input-always-hide-search-button"].checked; - searchButton.style.display = hideSearchButton ? "none" : ""; - }); - settings["text-input-always-hide-search-button"].addEventListener("change", () => { - const hideSearchButton = settings["text-input-always-hide-search-button"].checked; - searchButton.style.display = hideSearchButton ? "none" : ""; - }); - this.elements.searchButton = searchButton; - - const clearSearchButton = new ComfyButton({ - icon: "close", - tooltip: "Clear search", - classList: "comfyui-button icon-button", - action: async(e) => { - e.stopPropagation(); - this.elements.url.value = ""; - reset(); - }, - }).element; - settings["text-input-always-hide-clear-button"].addEventListener("change", () => { - const hideClearButton = settings["text-input-always-hide-clear-button"].checked; - clearSearchButton.style.display = hideClearButton ? "none" : ""; - }); - this.elements.clearSearchButton = clearSearchButton; - - $el("div.tab-header", { - $: (el) => (this.element = el), - }, [ - $el("div.row.tab-header-flex-block", [ - $el("input.search-text-area", { - $: (el) => (this.elements.url = el), - type: "text", - name: "model download url", - autocomplete: "off", - placeholder: "Search URL", - onkeydown: async (e) => { - if (e.key === "Enter") { - e.stopPropagation(); - searchButton.click(); - e.target.blur(); - } - }, - }), - clearSearchButton, - searchButton, + comfyButtonAlert( + e.target, + success, + 'mdi-check-bold', + 'mdi-close-thick', + success, + ); + button.disabled = success; + }, + }).element, + $el('div.row.tab-header-flex-block.input-dropdown-container', [ + // TODO: magic class + el_saveDirectoryPath, + searchDropdown.element, ]), - $el("div.download-model-infos", { - $: (el) => (this.elements.infos = el), - }, [ - $el("h1", ["Input a URL to select a model to download."]), - ]), - ]); - } - - /** - * Tries to return the related ComfyUI model directory if unambiguous. - * - * @param {string | undefined} modelType - Model type. - * @param {string | undefined} [fileType] - File type. Relevant for "Diffusers". - * - * @returns {(string | null)} Logical base directory name for model type. May be null if the directory is ambiguous or not a model type. - */ - static modelTypeToComfyUiDirectory(modelType, fileType) { - if (fileType !== undefined && fileType !== null) { - 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? - // TODO: allow user to choose EXISTING folder override/null? (style_models, HuggingFace) (use an object/map instead so settings can be dynamically set) - if (m == "aestheticGradient") { return null; } - else if (m == "checkpoint" || m == "checkpoints") { return "checkpoints"; } - //else if (m == "") { return "clip"; } - //else if (m == "") { return "clip_vision"; } - else if (m == "controlnet") { return "controlnet"; } - //else if (m == "Controlnet") { return "style_models"; } // are these controlnets? (TI-Adapter) - //else if (m == "") { return "gligen"; } - else if (m == "hypernetwork" || m == "hypernetworks") { return "hypernetworks"; } - else if (m == "lora" || m == "loras") { return "loras"; } - else if (m == "locon") { return "loras"; } - else if (m == "motionmodule") { return null; } - else if (m == "other") { return null; } - else if (m == "pose") { return null; } - else if (m == "textualinversion" || m == "embedding" || m == "embeddings") { return "embeddings"; } - //else if (m == "") { return "unet"; } - else if (m == "upscaler" || m == "upscale_model" || m == "upscale_models") { return "upscale_models"; } - else if (m == "vae") { return "vae"; } - else if (m == "wildcard" || m == "wildcards") { return null; } - else if (m == "workflow" || m == "workflows") { return null; } - } - return null; - } - - /** - * Returns empty string on failure - * @param {float | undefined} fileSizeKB - * @returns {string} - */ - static #fileSizeToFormattedString(fileSizeKB) { - if (fileSizeKB === undefined) { return ""; } - const sizes = ["KB", "MB", "GB", "TB", "PB"]; - let fileSizeString = fileSizeKB.toString(); - const index = fileSizeString.indexOf("."); - const indexMove = index % 3 === 0 ? 3 : index % 3; - const sizeIndex = Math.floor((index - indexMove) / 3); - if (sizeIndex >= sizes.length || sizeIndex < 0) { - fileSizeString = fileSizeString.substring(0, fileSizeString.indexOf(".") + 3); - return `(${fileSizeString} ${sizes[0]})`; - } - const split = fileSizeString.split("."); - fileSizeString = split[0].substring(0, indexMove) + "." + split[0].substring(indexMove) + split[1]; - fileSizeString = fileSizeString.substring(0, fileSizeString.indexOf(".") + 3); - return `(${fileSizeString} ${sizes[sizeIndex]})`; - } - - /** - * @param {Object} info - * @param {ModelData} modelData - * @param {int} id - * @param {any} settings - * @returns {HTMLDivElement} - */ - #modelInfoHtml(info, modelData, id, settings) { - const downloadPreviewSelect = new ImageSelect( - "model-download-info-preview-model" + "-" + id, - info["images"], + $el('div.row.tab-header-flex-block', [el_filename]), + downloadPreviewSelect.elements.radioGroup, + infoNotes, + ]), + ]), + ]), + ]); + + return modelInfo; + } + + /** + * @param {ModelData} modelData + * @param {any} settings + */ + async #update(modelData, settings) { + const [name, modelInfos] = await getModelInfos(this.elements.url.value); + const modelInfosHtml = modelInfos + .filter((modelInfo) => { + const filename = modelInfo['fileName']; + return ( + MODEL_EXTENSIONS.find((ext) => { + return filename.endsWith(ext); + }) ?? false ); - - const comfyUIModelType = ( - DownloadView.modelTypeToComfyUiDirectory(info["details"]["fileType"]) ?? - DownloadView.modelTypeToComfyUiDirectory(info["modelType"]) ?? - "" - ); - const searchSeparator = modelData.searchSeparator; - const defaultBasePath = searchSeparator + (comfyUIModelType === "" ? "" : comfyUIModelType + searchSeparator + "0"); - - const el_saveDirectoryPath = $el("input.search-text-area", { - type: "text", - name: "save directory", - autocomplete: "off", - placeholder: defaultBasePath, - value: defaultBasePath, - }); - const searchDropdown = new DirectoryDropdown( - modelData, - el_saveDirectoryPath, - true, - ); - - const default_name = (() => { - const filename = info["fileName"]; - // TODO: only remove valid model file extensions - const i = filename.lastIndexOf("."); - return i === - 1 ? filename : filename.substring(0, i); - })(); - const el_filename = $el("input.plain-text-area", { - type: "text", - name: "model save file name", - autocomplete: "off", - placeholder: default_name, - value: default_name, - onkeydown: (e) => { - if (e.key === "Enter") { - e.stopPropagation(); - e.target.blur(); - } + }) + .map((modelInfo, id) => { + return this.#modelInfoHtml(modelInfo, modelData, id, settings); + }); + if (modelInfosHtml.length === 0) { + modelInfosHtml.push($el('h1', ['No models found.'])); + } else { + if (modelInfosHtml.length === 1) { + modelInfosHtml[0].open = true; + } + + const header = $el('div', [ + $el('h1', [name]), + $el('div.model-manager-settings', [ + $checkbox({ + $: (el) => { + this.elements.overwrite = el; }, - }); - - const infoNotes = $el("textarea.comfy-multiline-input.model-info-notes", { - name: "model info notes", - value: info["description"]??"", - rows: 6, - 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"]]), - $el("div", [ - downloadPreviewSelect.elements.previews, - $el("div.download-settings-wrapper", [ - $el("div.download-settings", [ - new ComfyButton({ - icon: "arrow-collapse-down", - tooltip: "Download model", - content: "Download " + DownloadView.#fileSizeToFormattedString(info["details"]["fileSizeKB"]), - classList: "comfyui-button download-button", - action: async (e) => { - const pathDirectory = el_saveDirectoryPath.value; - const modelName = (() => { - const filename = info["fileName"]; - const name = el_filename.value; - if (name === "") { - return filename; - } - const ext = MODEL_EXTENSIONS.find((ext) => { - return filename.endsWith(ext); - }) ?? ""; - return name + ext; - })(); - const formData = new FormData(); - formData.append("download", info["downloadUrl"]); - formData.append("path", pathDirectory); - formData.append("name", modelName); - const image = await downloadPreviewSelect.getImage(); - formData.append("image", image === PREVIEW_NONE_URI ? "" : image); - formData.append("overwrite", this.elements.overwrite.checked); - const [button, icon, span] = comfyButtonDisambiguate(e.target); - button.disabled = true; - const [success, resultText] = await comfyRequest( - "/model-manager/model/download", - { - method: "POST", - body: formData, - } - ).then((data) => { - const success = data["success"]; - const message = data["alert"]; - if (message !== undefined) { - window.alert(message); - } - return [success, success ? "✔" : "📥︎"]; - }).catch((err) => { - return [false, "📥︎"]; - }); - if (success) { - const description = infoNotes.value; - if (this.elements.downloadNotes.checked && description !== "") { - const modelPath = pathDirectory + searchSeparator + modelName; - const saved = await saveNotes(modelPath, description); - if (!saved) { - console.warn("Model description was not saved!"); - } - } - this.#updateModels(); - } - comfyButtonAlert(e.target, success, "mdi-check-bold", "mdi-close-thick", success); - button.disabled = success; - }, - }).element, - $el("div.row.tab-header-flex-block.input-dropdown-container", [ // TODO: magic class - el_saveDirectoryPath, - searchDropdown.element, - ]), - $el("div.row.tab-header-flex-block", [ - el_filename, - ]), - downloadPreviewSelect.elements.radioGroup, - infoNotes, - ]), - ]), - ]), - ]); - - return modelInfo; + textContent: 'Overwrite Existing Files.', + checked: false, + }), + $checkbox({ + $: (el) => { + this.elements.downloadNotes = el; + }, + textContent: 'Save Notes.', + checked: false, + }), + ]), + ]); + modelInfosHtml.unshift(header); } - /** - * @param {ModelData} modelData - * @param {any} settings - */ - async #update(modelData, settings) { - const [name, modelInfos] = await getModelInfos(this.elements.url.value); - const modelInfosHtml = modelInfos.filter((modelInfo) => { - const filename = modelInfo["fileName"]; - return MODEL_EXTENSIONS.find((ext) => { - return filename.endsWith(ext); - }) ?? false; - }).map((modelInfo, id) => { - return this.#modelInfoHtml( - modelInfo, - modelData, - id, - settings, - ); - }); - if (modelInfosHtml.length === 0) { - modelInfosHtml.push($el("h1", ["No models found."])); - } - else { - if (modelInfosHtml.length === 1) { - modelInfosHtml[0].open = true; - } - - const header = $el("div", [ - $el("h1", [name]), - $el("div.model-manager-settings", [ - $checkbox({ - $: (el) => { this.elements.overwrite = el; }, - textContent: "Overwrite Existing Files.", - checked: false, - }), - $checkbox({ - $: (el) => { this.elements.downloadNotes = el; }, - textContent: "Save Notes.", - checked: false, - }), - ]) - ]); - modelInfosHtml.unshift(header); - } + const infosHtml = this.elements.infos; + infosHtml.innerHTML = ''; + infosHtml.append.apply(infosHtml, modelInfosHtml); - 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) => { - const modelInfoNotes = infosHtml.querySelectorAll(`textarea.model-info-notes`); - const disabled = !e.currentTarget.checked; - for (let i = 0; i < modelInfoNotes.length; i++) { - modelInfoNotes[i].disabled = disabled; - } - }); - downloadNotes.checked = settings["download-save-description-as-text-file"].checked; - downloadNotes.dispatchEvent(new Event('change')); + const downloadNotes = this.elements.downloadNotes; + if (downloadNotes !== undefined && downloadNotes !== null) { + downloadNotes.addEventListener('change', (e) => { + const modelInfoNotes = infosHtml.querySelectorAll( + `textarea.model-info-notes`, + ); + const disabled = !e.currentTarget.checked; + for (let i = 0; i < modelInfoNotes.length; i++) { + modelInfoNotes[i].disabled = disabled; } - - 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" : ""; + }); + 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' + : ''; + } } class BrowseView { + /** @type {HTMLDivElement} */ + element = null; + + elements = { + /** @type {HTMLDivElement} */ modelGrid: null, + /** @type {HTMLSelectElement} */ modelTypeSelect: null, + /** @type {HTMLSelectElement} */ modelSortSelect: null, + /** @type {HTMLInputElement} */ modelContentFilter: null, + /** @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 + * @param {(searchPath: string) => Promise} showModelInfo + * @param {() => void} updateModelGridCallback + * @param {any} settingsElements + */ + constructor( + updateModels, + modelData, + showModelInfo, + updateModelGridCallback, + settingsElements, + ) { /** @type {HTMLDivElement} */ - element = null; - - elements = { - /** @type {HTMLDivElement} */ modelGrid: null, - /** @type {HTMLSelectElement} */ modelTypeSelect: null, - /** @type {HTMLSelectElement} */ modelSortSelect: null, - /** @type {HTMLInputElement} */ modelContentFilter: null, - /** @type {HTMLButtonElement} */ searchButton: null, - /** @type {HTMLButtonElement} */ clearSearchButton: null, + 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', + name: 'model search', + autocomplete: 'off', + placeholder: '/Search', + }); + + const updatePreviousModelFilter = () => { + const modelType = this.elements.modelTypeSelect.value; + const value = this.elements.modelContentFilter.value; + this.previousModelFilters[modelType] = value; }; - - /** @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 - * @param {(searchPath: string) => Promise} showModelInfo - * @param {() => void} updateModelGridCallback - * @param {any} settingsElements - */ - constructor(updateModels, modelData, showModelInfo, updateModelGridCallback, settingsElements) { - /** @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", - name: "model search", - 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] === "-"; - const sortBy = reverseSort ? sortValue.substring(1) : sortValue; - ModelGrid.update( - this.elements.modelGrid, - this.#modelData, - this.elements.modelTypeSelect, - this.previousModelType, - this.#settingsElements, - sortBy, - reverseSort, - this.previousModelFilters, - this.elements.modelContentFilter, - 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, + + const updateModelGrid = () => { + const sortValue = this.elements.modelSortSelect.value; + const reverseSort = sortValue[0] === '-'; + const sortBy = reverseSort ? sortValue.substring(1) : sortValue; + ModelGrid.update( + this.elements.modelGrid, + this.#modelData, + this.elements.modelTypeSelect, + this.previousModelType, + this.#settingsElements, + sortBy, + reverseSort, + this.previousModelFilters, + this.elements.modelContentFilter, + 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, + false, + () => { + return this.elements.modelTypeSelect.value; + }, + updatePreviousModelFilter, + updateModelGrid, + () => { + return this.#settingsElements['model-real-time-search'].checked; + }, + ); + this.directoryDropdown = searchDropdown; + + const searchButton = new ComfyButton({ + icon: 'magnify', + tooltip: 'Search models', + classList: 'comfyui-button icon-button', + action: (e) => { + e.stopPropagation(); + const [button, icon, span] = comfyButtonDisambiguate(e.target); + button.disabled = true; + updateModelGrid(); + button.disabled = false; + }, + }).element; + settingsElements['model-real-time-search'].addEventListener( + 'change', + () => { + const hideSearchButton = + this.#settingsElements['text-input-always-hide-search-button'] + .checked || + this.#settingsElements['model-real-time-search'].checked; + searchButton.style.display = hideSearchButton ? 'none' : ''; + }, + ); + settingsElements['text-input-always-hide-search-button'].addEventListener( + 'change', + () => { + const hideSearchButton = + this.#settingsElements['text-input-always-hide-search-button'] + .checked || + this.#settingsElements['model-real-time-search'].checked; + searchButton.style.display = hideSearchButton ? 'none' : ''; + }, + ); + this.elements.searchButton = searchButton; + + const clearSearchButton = new ComfyButton({ + icon: 'close', + tooltip: 'Clear search', + classList: 'comfyui-button icon-button', + action: (e) => { + e.stopPropagation(); + const [button, icon, span] = comfyButtonDisambiguate(e.target); + button.disabled = true; + this.elements.modelContentFilter.value = ''; + updateModelGrid(); + button.disabled = false; + }, + }).element; + settingsElements['text-input-always-hide-clear-button'].addEventListener( + 'change', + () => { + const hideClearSearchButton = + this.#settingsElements['text-input-always-hide-clear-button'].checked; + 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', [ + new ComfyButton({ + icon: 'reload', + tooltip: 'Reload model grid', + classList: 'comfyui-button icon-button', + action: async (e) => { + const [button, icon, span] = comfyButtonDisambiguate(e.target); + button.disabled = true; + updateModels(); + button.disabled = false; + }, + }).element, + $el('select.model-select-dropdown', { + $: (el) => (this.elements.modelTypeSelect = el), + name: 'model-type', + onchange: (e) => { + const select = e.target; + select.disabled = true; + updateModelGrid(); + select.disabled = false; + }, + }), + $el( + 'select.model-select-dropdown', + { + $: (el) => (this.elements.modelSortSelect = el), + name: 'model select dropdown', + onchange: (e) => { + const select = e.target; + select.disabled = true; + updateModelGrid(); + select.disabled = false; + }, + }, + [ + $el('option', { value: MODEL_SORT_DATE_CREATED }, [ + 'Created (newest first)', + ]), + $el('option', { value: '-' + MODEL_SORT_DATE_CREATED }, [ + 'Created (oldest first)', + ]), + $el('option', { value: MODEL_SORT_DATE_MODIFIED }, [ + 'Modified (newest first)', + ]), + $el('option', { value: '-' + MODEL_SORT_DATE_MODIFIED }, [ + 'Modified (oldest first)', + ]), + $el('option', { value: MODEL_SORT_DATE_NAME }, ['Name (A-Z)']), + $el('option', { value: '-' + MODEL_SORT_DATE_NAME }, [ + 'Name (Z-A)', + ]), + $el('option', { value: MODEL_SORT_SIZE_BYTES }, [ + 'Size (largest first)', + ]), + $el('option', { value: '-' + MODEL_SORT_SIZE_BYTES }, [ + 'Size (smallest first)', + ]), + ], + ), + ]), + $el('div.row.tab-header-flex-block', [ + $el('div.search-models.input-dropdown-container', [ + // TODO: magic class searchInput, - false, - () => { return this.elements.modelTypeSelect.value; }, - updatePreviousModelFilter, - updateModelGrid, - () => { return this.#settingsElements["model-real-time-search"].checked; }, - ); - this.directoryDropdown = searchDropdown; - - const searchButton = new ComfyButton({ - icon: "magnify", - tooltip: "Search models", - classList: "comfyui-button icon-button", - action: (e) => { - e.stopPropagation(); - const [button, icon, span] = comfyButtonDisambiguate(e.target); - button.disabled = true; - updateModelGrid(); - button.disabled = false; - }, - }).element; - settingsElements["model-real-time-search"].addEventListener("change", () => { - const hideSearchButton = ( - this.#settingsElements["text-input-always-hide-search-button"].checked || - this.#settingsElements["model-real-time-search"].checked - ); - searchButton.style.display = hideSearchButton ? "none" : ""; - }); - settingsElements["text-input-always-hide-search-button"].addEventListener("change", () => { - const hideSearchButton = ( - this.#settingsElements["text-input-always-hide-search-button"].checked || - this.#settingsElements["model-real-time-search"].checked - ); - searchButton.style.display = hideSearchButton ? "none" : ""; - }); - this.elements.searchButton = searchButton; - - const clearSearchButton = new ComfyButton({ - icon: "close", - tooltip: "Clear search", - classList: "comfyui-button icon-button", - action: (e) => { - e.stopPropagation(); - const [button, icon, span] = comfyButtonDisambiguate(e.target); - button.disabled = true; - this.elements.modelContentFilter.value = ""; - updateModelGrid(); - button.disabled = false; - }, - }).element; - settingsElements["text-input-always-hide-clear-button"].addEventListener("change", () => { - const hideClearSearchButton = this.#settingsElements["text-input-always-hide-clear-button"].checked; - 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", [ - new ComfyButton({ - icon: "reload", - tooltip: "Reload model grid", - classList: "comfyui-button icon-button", - action: async(e) => { - const [button, icon, span] = comfyButtonDisambiguate(e.target); - button.disabled = true; - updateModels(); - button.disabled = false; - }, - }).element, - $el("select.model-select-dropdown", { - $: (el) => (this.elements.modelTypeSelect = el), - name: "model-type", - onchange: (e) => { - const select = e.target; - select.disabled = true; - updateModelGrid(); - select.disabled = false; - }, - }), - $el("select.model-select-dropdown", - { - $: (el) => (this.elements.modelSortSelect = el), - name: "model select dropdown", - onchange: (e) => { - const select = e.target; - select.disabled = true; - updateModelGrid(); - select.disabled = false; - }, - }, - [ - $el("option", { value: MODEL_SORT_DATE_CREATED }, ["Created (newest first)"]), - $el("option", { value: "-" + MODEL_SORT_DATE_CREATED }, ["Created (oldest first)"]), - $el("option", { value: MODEL_SORT_DATE_MODIFIED }, ["Modified (newest first)"]), - $el("option", { value: "-" + MODEL_SORT_DATE_MODIFIED }, ["Modified (oldest first)"]), - $el("option", { value: MODEL_SORT_DATE_NAME }, ["Name (A-Z)"]), - $el("option", { value: "-" + MODEL_SORT_DATE_NAME }, ["Name (Z-A)"]), - $el("option", { value: MODEL_SORT_SIZE_BYTES }, ["Size (largest first)"]), - $el("option", { value: "-" + MODEL_SORT_SIZE_BYTES }, ["Size (smallest first)"]), - ], - ), - ]), - $el("div.row.tab-header-flex-block", [ - $el("div.search-models.input-dropdown-container", [ // TODO: magic class - searchInput, - searchDropdown.element, - ]), - clearSearchButton, - searchButton, - ]), - ]), - modelGrid, - ]); - } + searchDropdown.element, + ]), + clearSearchButton, + searchButton, + ]), + ]), + modelGrid, + ]); + } } class SettingsView { - /** @type {HTMLDivElement} */ - element = null; - - elements = { - /** @type {HTMLButtonElement} */ reloadButton: null, - /** @type {HTMLButtonElement} */ saveButton: null, - /** @type {HTMLDivElement} */ setPreviewButton: null, - settings: { - /** @type {HTMLTextAreaElement} */ "model-search-always-append": null, - /** @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, - /** @type {HTMLInputElement} */ "model-show-add-button": null, - /** @type {HTMLInputElement} */ "model-show-copy-button": null, - /** @type {HTMLInputElement} */ "model-show-load-workflow-button": null, - /** @type {HTMLInputElement} */ "model-show-open-model-url-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 - */ - async #setSettings(settingsData, updateModels) { - const settings = this.elements.settings; - for (const [key, value] of Object.entries(settingsData)) { - const setting = settings[key]; - if (setting === undefined || setting === null) { - continue; - } - const type = setting.type; - switch (type) { - case "checkbox": setting.checked = Boolean(value); break; - case "range": setting.value = parseFloat(value); break; - case "textarea": setting.value = value; break; - case "number": setting.value = parseInt(value); break; - case "select-one": setting.value = value; break; - default: console.warn(`Unknown settings input type '${type}'!`); - } - } + /** @type {HTMLDivElement} */ + element = null; - if (updateModels) { - await this.#updateModels(); // Is this slow? - } + elements = { + /** @type {HTMLButtonElement} */ reloadButton: null, + /** @type {HTMLButtonElement} */ saveButton: null, + /** @type {HTMLDivElement} */ setPreviewButton: null, + settings: { + /** @type {HTMLTextAreaElement} */ 'model-search-always-append': null, + /** @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, + /** @type {HTMLInputElement} */ 'model-show-add-button': null, + /** @type {HTMLInputElement} */ 'model-show-copy-button': null, + /** @type {HTMLInputElement} */ 'model-show-load-workflow-button': null, + /** @type {HTMLInputElement} */ 'model-show-open-model-url-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 + */ + async #setSettings(settingsData, updateModels) { + const settings = this.elements.settings; + for (const [key, value] of Object.entries(settingsData)) { + const setting = settings[key]; + if (setting === undefined || setting === null) { + continue; + } + const type = setting.type; + switch (type) { + case 'checkbox': + setting.checked = Boolean(value); + break; + case 'range': + setting.value = parseFloat(value); + break; + case 'textarea': + setting.value = value; + break; + case 'number': + setting.value = parseInt(value); + break; + case 'select-one': + setting.value = value; + break; + default: + console.warn(`Unknown settings input type '${type}'!`); + } } - - /** - * @param {boolean} updateModels - * @returns {Promise} - */ - async reload(updateModels) { - const data = await comfyRequest("/model-manager/settings/load"); - const settingsData = data["settings"]; - await this.#setSettings(settingsData, updateModels); - comfyButtonAlert(this.elements.reloadButton, true); + + if (updateModels) { + await this.#updateModels(); // Is this slow? } - - /** @returns {Promise} */ - async save() { - let settingsData = {}; - for (const [setting, el] of Object.entries(this.elements.settings)) { - if (!el) { continue; } // hack - const type = el.type; - let value = null; - switch (type) { - case "checkbox": value = el.checked; break; - case "range": value = el.value; break; - case "textarea": value = el.value; break; - case "number": value = el.value; break; - case "select-one": value = el.value; break; - default: console.warn("Unknown settings input type!"); - } - settingsData[setting] = value; - } - + } + + /** + * @param {boolean} updateModels + * @returns {Promise} + */ + async reload(updateModels) { + const data = await comfyRequest('/model-manager/settings/load'); + const settingsData = data['settings']; + await this.#setSettings(settingsData, updateModels); + comfyButtonAlert(this.elements.reloadButton, true); + } + + /** @returns {Promise} */ + async save() { + let settingsData = {}; + for (const [setting, el] of Object.entries(this.elements.settings)) { + if (!el) { + continue; + } // hack + const type = el.type; + let value = null; + switch (type) { + case 'checkbox': + value = el.checked; + break; + case 'range': + value = el.value; + break; + case 'textarea': + value = el.value; + break; + case 'number': + value = el.value; + break; + case 'select-one': + value = el.value; + break; + default: + console.warn('Unknown settings input type!'); + } + settingsData[setting] = value; + } + + const data = await comfyRequest('/model-manager/settings/save', { + method: 'POST', + body: JSON.stringify({ settings: settingsData }), + }).catch((err) => { + return { success: false }; + }); + const success = data['success']; + if (success) { + const settingsData = data['settings']; + await this.#setSettings(settingsData, true); + } + comfyButtonAlert(this.elements.saveButton, success); + } + + /** + * @param {() => Promise} updateModels + * @param {() => void} updateSidebarButtons + */ + 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', + }); + sidebarControl + .getElementsByTagName('input')[0] + .addEventListener('change', () => { + updateSidebarButtons(); + }); + + const reloadButton = new ComfyButton({ + content: 'Reload', + tooltip: 'Reload settings and model manager files', + action: async (e) => { + const [button, icon, span] = comfyButtonDisambiguate(e.target); + button.disabled = true; + await this.reload(true); + button.disabled = false; + }, + }).element; + this.elements.reloadButton = reloadButton; + + const saveButton = new ComfyButton({ + content: 'Save', + tooltip: 'Save settings and reload model manager', + action: async (e) => { + const [button, icon, span] = comfyButtonDisambiguate(e.target); + button.disabled = true; + await this.save(); + button.disabled = false; + }, + }).element; + this.elements.saveButton = saveButton; + + const correctPreviewsButton = new ComfyButton({ + content: 'Fix Extensions', + tooltip: 'Correct image file extensions in all model directories', + action: async (e) => { + const [button, icon, span] = comfyButtonDisambiguate(e.target); + button.disabled = true; const data = await comfyRequest( - "/model-manager/settings/save", - { - method: "POST", - body: JSON.stringify({ "settings": settingsData }), - } + '/model-manager/preview/correct-extensions', ).catch((err) => { - return { "success": false }; + return { success: false }; }); - const success = data["success"]; + const success = data['success']; if (success) { - const settingsData = data["settings"]; - await this.#setSettings(settingsData, true); + const detectPlural = data['detected'] === 1 ? '' : 's'; + const correctPlural = data['corrected'] === 1 ? '' : 's'; + const message = `Detected ${data['detected']} extension${detectPlural}.\nCorrected ${data['corrected']} extension${correctPlural}.`; + window.alert(message); } - comfyButtonAlert(this.elements.saveButton, success); - } - - /** - * @param {() => Promise} updateModels - * @param {() => void} updateSidebarButtons - */ - 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", - }); - sidebarControl.getElementsByTagName('input')[0].addEventListener("change", () => { - updateSidebarButtons(); - }); - - const reloadButton = new ComfyButton({ - content: "Reload", - tooltip: "Reload settings and model manager files", - action: async(e) => { - const [button, icon, span] = comfyButtonDisambiguate(e.target); - button.disabled = true; - await this.reload(true); - button.disabled = false; - }, - }).element; - this.elements.reloadButton = reloadButton; - - const saveButton = new ComfyButton({ - content: "Save", - tooltip: "Save settings and reload model manager", - action: async(e) => { - const [button, icon, span] = comfyButtonDisambiguate(e.target); - button.disabled = true; - await this.save(); - button.disabled = false; - }, - }).element; - this.elements.saveButton = saveButton; - - const correctPreviewsButton = new ComfyButton({ - content: "Fix Extensions", - tooltip: "Correct image file extensions in all model directories", - action: async(e) => { - const [button, icon, span] = comfyButtonDisambiguate(e.target); - button.disabled = true; - const data = await comfyRequest( - "/model-manager/preview/correct-extensions") - .catch((err) => { - return { "success": false }; - }); - const success = data["success"]; - if (success) { - const detectPlural = data["detected"] === 1 ? "" : "s"; - const correctPlural = data["corrected"] === 1 ? "" : "s"; - const message = `Detected ${data["detected"]} extension${detectPlural}.\nCorrected ${data["corrected"]} extension${correctPlural}.`; - window.alert(message); - } - comfyButtonAlert(e.target, success); - if (data["corrected"] > 0) { - await this.reload(true); - } - button.disabled = false; - }, - }).element; - - $el("div.model-manager-settings", { - $: (el) => (this.element = el), - }, [ - $el("h1", ["Settings"]), - $el("div", [ - reloadButton, - saveButton, - ]), - $el("a", { - style: { color: "var(--fg-color)" }, - href: "https://github.com/hayden-fr/ComfyUI-Model-Manager/issues/" - }, [ - "File bugs and issues here." - ] - ), - $el("h2", ["Model Search"]), - $el("div", [ - $el("div.search-settings-text", [ - $el("p", ["Always include in model search:"]), - $el("textarea.comfy-multiline-input", { - $: (el) => (settings["model-search-always-append"] = el), - name: "always include in model search", - placeholder: "example: /0/sd1.5/styles \"pastel style\" -3d", - rows: "6", - }), - ]), - ]), - $select({ - $: (el) => (settings["model-default-browser-model-type"] = el), - textContent: "Default model search type (on start up)", - options: ["checkpoints", "clip", "clip_vision", "controlnet", "diffusers", "embeddings", "gligen", "hypernetworks", "loras", "photomaker", "style_models", "unet", "vae", "vae_approx"], + comfyButtonAlert(e.target, success); + if (data['corrected'] > 0) { + await this.reload(true); + } + button.disabled = false; + }, + }).element; + + $el( + 'div.model-manager-settings', + { + $: (el) => (this.element = el), + }, + [ + $el('h1', ['Settings']), + $el('div', [reloadButton, saveButton]), + $el( + 'a', + { + style: { color: 'var(--fg-color)' }, + href: 'https://github.com/hayden-fr/ComfyUI-Model-Manager/issues/', + }, + ['File bugs and issues here.'], + ), + $el('h2', ['Model Search']), + $el('div', [ + $el('div.search-settings-text', [ + $el('p', ['Always include in model search:']), + $el('textarea.comfy-multiline-input', { + $: (el) => (settings['model-search-always-append'] = el), + name: 'always include in model search', + placeholder: 'example: /0/sd1.5/styles "pastel style" -3d', + rows: '6', }), - $checkbox({ - $: (el) => (settings["model-real-time-search"] = el), - textContent: "Real-time search", - }), - $checkbox({ - $: (el) => (settings["model-persistent-search"] = el), - textContent: "Persistent search text (across model types)", - }), - $el("h2", ["Model Search Thumbnails"]), - $select({ - $: (el) => (settings["model-preview-thumbnail-type"] = el), - textContent: "Preview thumbnail type", - options: ["AUTO", "JPEG"], // should use AUTO to avoid artifacts from changing between formats; use JPEG for backward compatibility - }), - $checkbox({ - $: (el) => (settings["model-preview-fallback-search-safetensors-thumbnail"] = el), - textContent: "Fallback to embedded safetensors image (slow)", - }), - $checkbox({ - $: (el) => (settings["model-show-label-extensions"] = el), - textContent: "Show file extension", - }), - $checkbox({ - $: (el) => (settings["model-show-copy-button"] = el), - textContent: "Show \"Copy\" button", - }), - $checkbox({ - $: (el) => (settings["model-show-add-button"] = el), - textContent: "Show \"Add\" button", - }), - $checkbox({ - $: (el) => (settings["model-show-load-workflow-button"] = el), - textContent: "Show \"Load Workflow\" button", - }),$checkbox({ - $: (el) => (settings["model-show-open-model-url-button"] = el), - textContent: "Show \"Open Model Url\" button", - }), - $checkbox({ - $: (el) => (settings["model-info-button-on-left"] = el), - textContent: "\"Model Info\" button on left", - }), - $el("h2", ["Node Graph"]), - $checkbox({ - $: (el) => (settings["model-add-embedding-extension"] = el), - textContent: "Add embedding with extension", - }), - $checkbox({ - $: (el) => (settings["model-add-drag-strict-on-field"] = el), // true -> must drag on field; false -> can drag on node when unambiguous - textContent: "Must always drag thumbnail onto node's input field", - }), - $el("label", [ - "Add offset", // if a node already was added to the same spot, add the next one with an offset - $el("input", { - $: (el) => (settings["model-add-offset"] = el), - type: "number", - name: "model add offset", - step: 5, - }), - ]), - $el("h2", ["Model Info"]), - $checkbox({ - $: (el) => (settings["model-info-autosave-notes"] = el), // note history deleted on model info close - textContent: "Autosave notes", - }), - $el("h2", ["Download"]), - $checkbox({ - $: (el) => (settings["download-save-description-as-text-file"] = el), - textContent: "Save notes by default.", - }), - $el("h2", ["Window"]), - sidebarControl, - $el("label", [ - "Sidebar width (on start up)", - $el("input", { - $: (el) => (settings["sidebar-default-width"] = el), - type: "range", - name: "default sidebar width", - value: 0.5, - min: 0.0, - max: 1.0, - step: 0.05, - }), - ]), - $el("label", [ - "Sidebar height (on start up)", - $el("input", { - $: (el) => (settings["sidebar-default-height"] = el), - type: "range", - name: "default sidebar height", - value: 0.5, - min: 0.0, - max: 1.0, - step: 0.05, - }), - ]), - $checkbox({ - $: (el) => (settings["text-input-always-hide-search-button"] = el), - textContent: "Always hide \"Search\" buttons.", - }), - $checkbox({ - $: (el) => (settings["text-input-always-hide-clear-button"] = el), - textContent: "Always hide \"Clear Search\" buttons.", - }), - $el("h2", ["Model Preview Images"]), - $el("div", [ - correctPreviewsButton, - ]), - $el("h2", ["Random Tag Generator"]), - $select({ - $: (el) => (settings["tag-generator-sampler-method"] = el), - textContent: "Default sampling method", - options: ["Frequency", "Uniform"], - }), - $el("label", [ - "Default count", - $el("input", { - $: (el) => (settings["tag-generator-count"] = el), - type: "number", - name: "tag generator count", - step: 1, - min: 1, - }), - ]), - $el("label", [ - "Default minimum threshold", - $el("input", { - $: (el) => (settings["tag-generator-threshold"] = el), - type: "number", - name: "tag generator threshold", - step: 1, - min: 1, - }), - ]), - ]); - } + ]), + ]), + $select({ + $: (el) => (settings['model-default-browser-model-type'] = el), + textContent: 'Default model search type (on start up)', + options: [ + 'checkpoints', + 'clip', + 'clip_vision', + 'controlnet', + 'diffusers', + 'embeddings', + 'gligen', + 'hypernetworks', + 'loras', + 'photomaker', + 'style_models', + 'unet', + 'vae', + 'vae_approx', + ], + }), + $checkbox({ + $: (el) => (settings['model-real-time-search'] = el), + textContent: 'Real-time search', + }), + $checkbox({ + $: (el) => (settings['model-persistent-search'] = el), + textContent: 'Persistent search text (across model types)', + }), + $el('h2', ['Model Search Thumbnails']), + $select({ + $: (el) => (settings['model-preview-thumbnail-type'] = el), + textContent: 'Preview thumbnail type', + options: ['AUTO', 'JPEG'], // should use AUTO to avoid artifacts from changing between formats; use JPEG for backward compatibility + }), + $checkbox({ + $: (el) => + (settings['model-preview-fallback-search-safetensors-thumbnail'] = + el), + textContent: 'Fallback to embedded safetensors image (slow)', + }), + $checkbox({ + $: (el) => (settings['model-show-label-extensions'] = el), + textContent: 'Show file extension', + }), + $checkbox({ + $: (el) => (settings['model-show-copy-button'] = el), + textContent: 'Show "Copy" button', + }), + $checkbox({ + $: (el) => (settings['model-show-add-button'] = el), + textContent: 'Show "Add" button', + }), + $checkbox({ + $: (el) => (settings['model-show-load-workflow-button'] = el), + textContent: 'Show "Load Workflow" button', + }),$checkbox({ + $: (el) => (settings['model-show-open-model-url-button'] = el), + textContent: 'Show "Open Model Url" button', + }), + $checkbox({ + $: (el) => (settings['model-info-button-on-left'] = el), + textContent: '"Model Info" button on left', + }), + $el('h2', ['Node Graph']), + $checkbox({ + $: (el) => (settings['model-add-embedding-extension'] = el), + textContent: 'Add embedding with extension', + }), + $checkbox({ + $: (el) => (settings['model-add-drag-strict-on-field'] = el), // true -> must drag on field; false -> can drag on node when unambiguous + textContent: "Must always drag thumbnail onto node's input field", + }), + $el('label', [ + 'Add offset', // if a node already was added to the same spot, add the next one with an offset + $el('input', { + $: (el) => (settings['model-add-offset'] = el), + type: 'number', + name: 'model add offset', + step: 5, + }), + ]), + $el('h2', ['Model Info']), + $checkbox({ + $: (el) => (settings['model-info-autosave-notes'] = el), // note history deleted on model info close + textContent: 'Autosave notes', + }), + $el('h2', ['Download']), + $checkbox({ + $: (el) => (settings['download-save-description-as-text-file'] = el), + textContent: 'Save notes by default.', + }), + $el('h2', ['Window']), + sidebarControl, + $el('label', [ + 'Sidebar width (on start up)', + $el('input', { + $: (el) => (settings['sidebar-default-width'] = el), + type: 'range', + name: 'default sidebar width', + value: 0.5, + min: 0.0, + max: 1.0, + step: 0.05, + }), + ]), + $el('label', [ + 'Sidebar height (on start up)', + $el('input', { + $: (el) => (settings['sidebar-default-height'] = el), + type: 'range', + name: 'default sidebar height', + value: 0.5, + min: 0.0, + max: 1.0, + step: 0.05, + }), + ]), + $checkbox({ + $: (el) => (settings['text-input-always-hide-search-button'] = el), + textContent: 'Always hide "Search" buttons.', + }), + $checkbox({ + $: (el) => (settings['text-input-always-hide-clear-button'] = el), + textContent: 'Always hide "Clear Search" buttons.', + }), + $el('h2', ['Model Preview Images']), + $el('div', [correctPreviewsButton]), + $el('h2', ['Random Tag Generator']), + $select({ + $: (el) => (settings['tag-generator-sampler-method'] = el), + textContent: 'Default sampling method', + options: ['Frequency', 'Uniform'], + }), + $el('label', [ + 'Default count', + $el('input', { + $: (el) => (settings['tag-generator-count'] = el), + type: 'number', + name: 'tag generator count', + step: 1, + min: 1, + }), + ]), + $el('label', [ + 'Default minimum threshold', + $el('input', { + $: (el) => (settings['tag-generator-threshold'] = el), + type: 'number', + name: 'tag generator threshold', + step: 1, + min: 1, + }), + ]), + ], + ); + } } /** @@ -4379,33 +4901,35 @@ class SettingsView { * @returns {HTMLDivElement} */ function GenerateRadioButtonGroup(labels, callbacks = []) { - const RADIO_BUTTON_GROUP_ACTIVE = "radio-button-group-active"; - const radioButtonGroup = $el("div.radio-button-group", []); - const buttons = []; - for (let i = 0; i < labels.length; i++) { - const text = labels[i]; - const callback = callbacks[i] ?? (() => {}); - buttons.push( - $el("button.radio-button", { - textContent: text, - onclick: (event) => { - const targetIsActive = event.target.classList.contains(RADIO_BUTTON_GROUP_ACTIVE); - if (targetIsActive) { - return; - } - const children = radioButtonGroup.children; - for (let i = 0; i < children.length; i++) { - children[i].classList.remove(RADIO_BUTTON_GROUP_ACTIVE); - } - event.target.classList.add(RADIO_BUTTON_GROUP_ACTIVE); - callback(event); - }, - }) - ); - } - radioButtonGroup.append.apply(radioButtonGroup, buttons); - buttons[0]?.classList.add(RADIO_BUTTON_GROUP_ACTIVE); - return radioButtonGroup; + const RADIO_BUTTON_GROUP_ACTIVE = 'radio-button-group-active'; + const radioButtonGroup = $el('div.radio-button-group', []); + const buttons = []; + for (let i = 0; i < labels.length; i++) { + const text = labels[i]; + const callback = callbacks[i] ?? (() => {}); + buttons.push( + $el('button.radio-button', { + textContent: text, + onclick: (event) => { + const targetIsActive = event.target.classList.contains( + RADIO_BUTTON_GROUP_ACTIVE, + ); + if (targetIsActive) { + return; + } + const children = radioButtonGroup.children; + for (let i = 0; i < children.length; i++) { + children[i].classList.remove(RADIO_BUTTON_GROUP_ACTIVE); + } + event.target.classList.add(RADIO_BUTTON_GROUP_ACTIVE); + callback(event); + }, + }), + ); + } + radioButtonGroup.append.apply(radioButtonGroup, buttons); + buttons[0]?.classList.add(RADIO_BUTTON_GROUP_ACTIVE); + return radioButtonGroup; } /** @@ -4414,35 +4938,40 @@ function GenerateRadioButtonGroup(labels, callbacks = []) { * @param {(event: Event) => Promise} deactivationCallback * @returns {HTMLDivElement} */ -function GenerateToggleRadioButtonGroup(labels, activationCallbacks = [], deactivationCallback = () => {}) { - const RADIO_BUTTON_GROUP_ACTIVE = "radio-button-group-active"; - const radioButtonGroup = $el("div.radio-button-group", []); - const buttons = []; - for (let i = 0; i < labels.length; i++) { - const text = labels[i]; - const activationCallback = activationCallbacks[i] ?? (() => {}); - buttons.push( - $el("button.radio-button", { - textContent: text, - onclick: (event) => { - const targetIsActive = event.target.classList.contains(RADIO_BUTTON_GROUP_ACTIVE); - const children = radioButtonGroup.children; - for (let i = 0; i < children.length; i++) { - children[i].classList.remove(RADIO_BUTTON_GROUP_ACTIVE); - } - if (targetIsActive) { - deactivationCallback(event); - } - else { - event.target.classList.add(RADIO_BUTTON_GROUP_ACTIVE); - activationCallback(event); - } - }, - }) - ); - } - radioButtonGroup.append.apply(radioButtonGroup, buttons); - return radioButtonGroup; +function GenerateToggleRadioButtonGroup( + labels, + activationCallbacks = [], + deactivationCallback = () => {}, +) { + const RADIO_BUTTON_GROUP_ACTIVE = 'radio-button-group-active'; + const radioButtonGroup = $el('div.radio-button-group', []); + const buttons = []; + for (let i = 0; i < labels.length; i++) { + const text = labels[i]; + const activationCallback = activationCallbacks[i] ?? (() => {}); + buttons.push( + $el('button.radio-button', { + textContent: text, + onclick: (event) => { + const targetIsActive = event.target.classList.contains( + RADIO_BUTTON_GROUP_ACTIVE, + ); + const children = radioButtonGroup.children; + for (let i = 0; i < children.length; i++) { + children[i].classList.remove(RADIO_BUTTON_GROUP_ACTIVE); + } + if (targetIsActive) { + deactivationCallback(event); + } else { + event.target.classList.add(RADIO_BUTTON_GROUP_ACTIVE); + activationCallback(event); + } + }, + }), + ); + } + radioButtonGroup.append.apply(radioButtonGroup, buttons); + return radioButtonGroup; } /** @@ -4452,532 +4981,666 @@ function GenerateToggleRadioButtonGroup(labels, activationCallbacks = [], deacti * @returns {[HTMLDivElement, HTMLSelectElement]} */ 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) => { - const select = event.target; - const children = select.children; - let value = undefined; - for (let i = 0; i < children.length; i++) { - const child = children[i]; - if (child.selected) { - value = child.value; - } - } - for (let i = 0; i < buttons.length; i++) { - const button = buttons[i]; - if (button.textContent === value) { - for (let i = 0; i < buttons.length; i++) { - buttons[i].classList.remove(RADIO_BUTTON_GROUP_ACTIVE); - } - button.classList.add(RADIO_BUTTON_GROUP_ACTIVE); - activationCallbacks[i](button); - break; - } - } - }, - }, labels.map((option) => { - return $el("option", { - value: option, - }, option); - }) + 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) => { + const select = event.target; + const children = select.children; + let value = undefined; + for (let i = 0; i < children.length; i++) { + const child = children[i]; + if (child.selected) { + value = child.value; + } + } + for (let i = 0; i < buttons.length; i++) { + const button = buttons[i]; + if (button.textContent === value) { + for (let i = 0; i < buttons.length; i++) { + buttons[i].classList.remove(RADIO_BUTTON_GROUP_ACTIVE); + } + button.classList.add(RADIO_BUTTON_GROUP_ACTIVE); + activationCallbacks[i](button); + break; + } + } + }, + }, + labels.map((option) => { + return $el( + 'option', + { + value: option, + }, + option, + ); + }), + ); + + for (let i = 0; i < labels.length; i++) { + const text = labels[i]; + const activationCallback = activationCallbacks[i] ?? (() => {}); + buttons.push( + $el('button.radio-button', { + textContent: text, + onclick: (event) => { + const button = event.target; + let textContent = button.textContent; + const targetIsActive = button.classList.contains( + RADIO_BUTTON_GROUP_ACTIVE, + ); + if ( + button === buttons[0] && + buttons[0].classList.contains(RADIO_BUTTON_GROUP_ACTIVE) + ) { + // do not deactivate 0 + return; + } + // update button + const children = radioButtonGroup.children; + for (let i = 0; i < children.length; i++) { + children[i].classList.remove(RADIO_BUTTON_GROUP_ACTIVE); + } + if (targetIsActive) { + // return to 0 + textContent = labels[0]; + buttons[0].classList.add(RADIO_BUTTON_GROUP_ACTIVE); + activationCallbacks[0](buttons[0]); + } else { + // move to >0 + button.classList.add(RADIO_BUTTON_GROUP_ACTIVE); + activationCallback(button); + } + // update selection + for (let i = 0; i < select.children.length; i++) { + const option = select.children[i]; + option.selected = option.value === textContent; + } + }, + }), ); - - for (let i = 0; i < labels.length; i++) { - const text = labels[i]; - const activationCallback = activationCallbacks[i] ?? (() => {}); - buttons.push( - $el("button.radio-button", { - textContent: text, - onclick: (event) => { - const button = event.target; - let textContent = button.textContent; - const targetIsActive = button.classList.contains(RADIO_BUTTON_GROUP_ACTIVE); - if (button === buttons[0] && buttons[0].classList.contains(RADIO_BUTTON_GROUP_ACTIVE)) { - // do not deactivate 0 - return; - } - // update button - const children = radioButtonGroup.children; - for (let i = 0; i < children.length; i++) { - children[i].classList.remove(RADIO_BUTTON_GROUP_ACTIVE); - } - if (targetIsActive) { - // return to 0 - textContent = labels[0]; - buttons[0].classList.add(RADIO_BUTTON_GROUP_ACTIVE); - activationCallbacks[0](buttons[0]); - } - else { - // move to >0 - button.classList.add(RADIO_BUTTON_GROUP_ACTIVE); - activationCallback(button); - } - // update selection - for (let i = 0; i < select.children.length; i++) { - const option = select.children[i]; - option.selected = option.value === textContent; - } - }, - }) - ); - } - radioButtonGroup.append.apply(radioButtonGroup, buttons); - buttons[0].click(); - buttons[0].style.display = "none"; - - return [radioButtonGroup, select]; + } + 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, - this.#showModelInfo, - 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"; - } + /** @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, + this.#showModelInfo, + 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'; + } + }, + () => { + this.element.dataset['sidebarState'] = 'right'; + }, + () => { + this.element.dataset['sidebarState'] = 'top'; + }, + () => { + this.element.dataset['sidebarState'] = 'bottom'; + }, + () => { + this.element.dataset['sidebarState'] = 'left'; + }, + ], + ); + this.#sidebarButtonGroup = sidebarButtonGroup; + this.#sidebarSelect = sidebarSelect; + sidebarButtonGroup.classList.add('sidebar-buttons'); + const sidebarButtonGroupChildren = sidebarButtonGroup.children; + 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', + classList: 'comfyui-button icon-button', + action: async () => await this.#tryHideModelInfo(true), + }).element; + this.#closeModelInfoButton = closeModelInfoButton; + closeModelInfoButton.style.display = 'none'; + + const modelManager = $el( + 'div.comfy-modal.model-manager', + { + $: (el) => (this.element = el), + parent: document.body, + dataset: { + sidebarState: 'none', + sidebarLeftWidthDecimal: '', + sidebarRightWidthDecimal: '', + sidebarTopHeightDecimal: '', + sidebarBottomHeightDecimal: '', + }, + }, + [ + $el('div.comfy-modal-content', [ + // TODO: settings.top_bar_left_to_right or settings.top_bar_right_to_left + $el('div.model-manager-panel', [ + $el('div.model-manager-head', [ + $el( + 'div.topbar-right', + { + $: (el) => (this.#topbarRight = el), }, - () => { this.element.dataset["sidebarState"] = "right"; }, - () => { this.element.dataset["sidebarState"] = "top"; }, - () => { this.element.dataset["sidebarState"] = "bottom"; }, - () => { this.element.dataset["sidebarState"] = "left"; }, - ], - ); - this.#sidebarButtonGroup = sidebarButtonGroup; - this.#sidebarSelect = sidebarSelect; - sidebarButtonGroup.classList.add("sidebar-buttons"); - const sidebarButtonGroupChildren = sidebarButtonGroup.children; - 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", - classList: "comfyui-button icon-button", - action: async() => await this.#tryHideModelInfo(true), - }).element; - this.#closeModelInfoButton = closeModelInfoButton; - closeModelInfoButton.style.display = "none"; - - const modelManager = $el( - "div.comfy-modal.model-manager", - { - $: (el) => (this.element = el), - parent: document.body, - dataset: { - "sidebarState": "none", - "sidebarLeftWidthDecimal": "", - "sidebarRightWidthDecimal": "", - "sidebarTopHeightDecimal": "", - "sidebarBottomHeightDecimal": "", - }, - }, - [ - $el("div.comfy-modal-content", [ // TODO: settings.top_bar_left_to_right or settings.top_bar_right_to_left - $el("div.model-manager-panel", [ - $el("div.model-manager-head", [ - $el("div.topbar-right", { - $: (el) => (this.#topbarRight = el), - }, [ - new ComfyButton({ - icon: "window-close", - tooltip: "Close model manager", - classList: "comfyui-button icon-button", - action: async() => { - const saved = await this.#modelInfo.trySave(true); - if (saved) { - this.close(); - } - }, - }).element, - closeModelInfoButton, - sidebarSelect, - sidebarButtonGroup, - ]), - $el("div.topbar-left", [ - $el("div", [ - $el("div.model-tab-group.no-highlight", { - $: (el) => (this.#tabManagerButtons = el), - }, tabManagerButtons), - $el("div.model-tab-group.no-highlight", { - $: (el) => (this.#tabInfoButtons = el), - style: { display: "none"}, - }, tabInfoButtons), - ]), - ]), - ]), - $el("div.model-manager-body", [ - $el("div.tab-contents", { - $: (el) => (this.#tabManagerContents = el), - }, tabManagerContents), - $el("div.tab-contents", { - $: (el) => (this.#tabInfoContents = el), - style: { display: "none"}, - }, tabInfoContents), - ]), - ]), + [ + new ComfyButton({ + icon: 'window-close', + tooltip: 'Close model manager', + classList: 'comfyui-button icon-button', + action: async () => { + const saved = await this.#modelInfo.trySave(true); + if (saved) { + this.close(); + } + }, + }).element, + closeModelInfoButton, + sidebarSelect, + sidebarButtonGroup, + ], + ), + $el('div.topbar-left', [ + $el('div', [ + $el( + 'div.model-tab-group.no-highlight', + { + $: (el) => (this.#tabManagerButtons = el), + }, + tabManagerButtons, + ), + $el( + 'div.model-tab-group.no-highlight', + { + $: (el) => (this.#tabInfoButtons = el), + style: { display: 'none' }, + }, + tabInfoButtons, + ), ]), - ] + ]), + ]), + $el('div.model-manager-body', [ + $el( + 'div.tab-contents', + { + $: (el) => (this.#tabManagerContents = el), + }, + tabManagerContents, + ), + $el( + 'div.tab-contents', + { + $: (el) => (this.#tabInfoContents = el), + style: { display: 'none' }, + }, + tabInfoContents, + ), + ]), + ]), + ]), + ], + ); + + 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; + modelManager.dataset['sidebarTopHeightDecimal'] = + parseInt( + modelManager.style.getPropertyValue( + '--model-manager-sidebar-height-top', + ), + ) / window.innerHeight; + modelManager.dataset['sidebarBottomHeightDecimal'] = + parseInt( + modelManager.style.getPropertyValue( + '--model-manager-sidebar-height-bottom', + ), + ) / window.innerHeight; + }; + 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; + const width = modelManager.offsetWidth; + 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; + } else if (sidebarState === 'right' && isOnEdgeLeft) { + this.#dragSidebarState = sidebarState; + } else if (sidebarState === 'top' && isOnEdgeBottom) { + this.#dragSidebarState = sidebarState; + } else if (sidebarState === 'bottom' && isOnEdgeTop) { + this.#dragSidebarState = sidebarState; + } + + if (this.#dragSidebarState !== '') { + e.preventDefault(); + e.stopPropagation(); + } + }; + 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); + } else { + modelManager.classList.remove(className); + } + }; + + const sidebarState = this.element.dataset['sidebarState']; + updateClass(sidebarState === 'right' && isOnEdgeLeft, 'cursor-drag-left'); + updateClass(sidebarState === 'bottom' && isOnEdgeTop, 'cursor-drag-top'); + updateClass( + sidebarState === 'left' && isOnEdgeRight, + 'cursor-drag-right', + ); + updateClass( + sidebarState === 'top' && isOnEdgeBottom, + 'cursor-drag-bottom', + ); + }; + 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, ); - - 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; - modelManager.dataset["sidebarTopHeightDecimal"] = parseInt(modelManager.style.getPropertyValue("--model-manager-sidebar-height-top")) / window.innerHeight; - modelManager.dataset["sidebarBottomHeightDecimal"] = parseInt(modelManager.style.getPropertyValue("--model-manager-sidebar-height-bottom")) / window.innerHeight; - }; - 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; - const width = modelManager.offsetWidth; - 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; - } - else if (sidebarState === "right" && isOnEdgeLeft) { - this.#dragSidebarState = sidebarState; - } - else if (sidebarState === "top" && isOnEdgeBottom) { - this.#dragSidebarState = sidebarState; - } - else if (sidebarState === "bottom" && isOnEdgeTop) { - this.#dragSidebarState = sidebarState; - } - - if (this.#dragSidebarState !== "") { - e.preventDefault(); - e.stopPropagation(); - } - }; - 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); - } - else { - modelManager.classList.remove(className); - } - }; - - const sidebarState = this.element.dataset["sidebarState"]; - updateClass(sidebarState === "right" && isOnEdgeLeft, "cursor-drag-left"); - updateClass(sidebarState === "bottom" && isOnEdgeTop, "cursor-drag-top"); - updateClass(sidebarState === "left" && isOnEdgeRight, "cursor-drag-right"); - updateClass(sidebarState === "top" && isOnEdgeBottom, "cursor-drag-bottom"); - }; - 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); - } - else if (sidebarState === "right") { - const pixels = clamp(width - x, 0, width).toString() + "px"; - modelManager.style.setProperty("--model-manager-sidebar-width-right", pixels); - } - else if (sidebarState === "top") { - const pixels = clamp(y, 0, height).toString() + "px"; - modelManager.style.setProperty("--model-manager-sidebar-height-top", pixels); - } - else if (sidebarState === "bottom") { - const pixels = clamp(height - y, 0, height).toString() + "px"; - modelManager.style.setProperty("--model-manager-sidebar-height-bottom", pixels); - } - }; - 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(); + } else if (sidebarState === 'right') { + const pixels = clamp(width - x, 0, width).toString() + 'px'; + modelManager.style.setProperty( + '--model-manager-sidebar-width-right', + pixels, + ); + } else if (sidebarState === 'top') { + const pixels = clamp(y, 0, height).toString() + 'px'; + modelManager.style.setProperty( + '--model-manager-sidebar-height-top', + pixels, + ); + } else if (sidebarState === 'bottom') { + const pixels = clamp(height - y, 0, height).toString() + 'px'; + modelManager.style.setProperty( + '--model-manager-sidebar-height-bottom', + pixels, + ); + } + }; + 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; + const hideClearSearchButtons = + settings['text-input-always-hide-clear-button'].checked; + this.#downloadView.elements.searchButton.style.display = hideSearchButtons + ? 'none' + : ''; + this.#downloadView.elements.clearSearchButton.style.display = + hideClearSearchButtons ? 'none' : ''; } - - 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; - const hideClearSearchButtons = settings["text-input-always-hide-clear-button"].checked; - 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); - } + + { + // 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; + } + + #resetManagerContentsScroll = () => { + this.#tabManagerContents.scrollTop = 0; + }; + + #refreshModels = async () => { + const modelData = this.#modelData; + modelData.systemSeparator = await comfyRequest( + '/model-manager/system-separator', + ); + const newModels = await comfyRequest('/model-manager/models/list'); + 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) + .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 + * @returns {Promise} + */ + #tryHideModelInfo = async (promptSave) => { + if (this.#tabInfoContents.style.display !== 'none') { + 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 = ''; } - - #refreshModels = async() => { - const modelData = this.#modelData; - modelData.systemSeparator = await comfyRequest("/model-manager/system-separator"); - const newModels = await comfyRequest("/model-manager/models/list"); - 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, - ).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 - * @returns {Promise} - */ - #tryHideModelInfo = async(promptSave) => { - if (this.#tabInfoContents.style.display !== "none") { - 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 - const alwaysShowCompactSidebarControls = this.#settingsView.elements.settings["sidebar-control-always-compact"].checked; - if (isNarrow || alwaysShowCompactSidebarControls) { - this.#sidebarButtonGroup.style.display = "none"; - this.#sidebarSelect.style.display = ""; - } - else { - this.#sidebarButtonGroup.style.display = ""; - this.#sidebarSelect.style.display = "none"; - } + return true; + }; + + #updateSidebarButtons = () => { + const managerRect = this.element.getBoundingClientRect(); + const isNarrow = managerRect.width < 768; // TODO: `minWidth` is a magic value + const alwaysShowCompactSidebarControls = + this.#settingsView.elements.settings['sidebar-control-always-compact'] + .checked; + if (isNarrow || alwaysShowCompactSidebarControls) { + this.#sidebarButtonGroup.style.display = 'none'; + this.#sidebarSelect.style.display = ''; + } else { + this.#sidebarButtonGroup.style.display = ''; + this.#sidebarSelect.style.display = 'none'; } + }; } /** @type {ModelManager | undefined} */ @@ -5001,43 +5664,45 @@ function getInstance() { } const toggleModelManager = () => { - const modelManager = getInstance(); - const style = modelManager.element.style; - if (style.display === "" || style.display === "none") { - modelManager.show(); - } - else { - modelManager.close(); - } + const modelManager = getInstance(); + const style = modelManager.element.style; + if (style.display === '' || style.display === 'none') { + modelManager.show(); + } else { + modelManager.close(); + } }; app.registerExtension({ - name: "Comfy.ModelManager", - init() { - }, - async setup() { - $el("link", { - parent: document.head, - rel: "stylesheet", - href: "./extensions/ComfyUI-Model-Manager/model-manager.css", - }); - - app.ui?.menuContainer?.appendChild( - $el("button", { - id: "comfyui-model-manager-button", - parent: document.querySelector(".comfy-menu"), - textContent: "Models", - onclick: () => toggleModelManager(), - }) - ); - - // [Beta] mobile menu - app.menu?.settingsGroup?.append(new ComfyButton({ - icon: "folder-search", - tooltip: "Opens model manager", - action: () => toggleModelManager(), - content: "Model Manager", - popup: getInstance(), - })); - }, + name: 'Comfy.ModelManager', + init() {}, + async setup() { + const cssFileUrl = new URL(import.meta.url).pathname.replace('.js', '.css'); + + $el('link', { + parent: document.head, + rel: 'stylesheet', + href: cssFileUrl, + }); + + app.ui?.menuContainer?.appendChild( + $el('button', { + id: 'comfyui-model-manager-button', + parent: document.querySelector('.comfy-menu'), + textContent: 'Models', + onclick: () => toggleModelManager(), + }), + ); + + // [Beta] mobile menu + app.menu?.settingsGroup?.append( + new ComfyButton({ + icon: 'folder-search', + tooltip: 'Opens model manager', + action: () => toggleModelManager(), + content: 'Model Manager', + popup: getInstance(), + }), + ); + }, });