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, ' + ')');
+ 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]*?(?:\\1>[^\\n]*\\n+|$)' // (1)
+ + '|comment[^\\n]*(\\n+|$)' // (2)
+ + '|<\\?[\\s\\S]*?(?:\\?>\\n*|$)' // (3)
+ + '|\\n*|$)' // (4)
+ + '|\\n*|$)' // (5)
+ + '|?(tag)(?: +|\\n|/?>)[\\s\\S]*?(?:(?:\\n *)+\\n|$)' // (6)
+ + '|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$)' // (7) open tag
+ + '|(?!script|pre|style|textarea)[a-z][\\w-]*\\s*>(?=[ \\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', '?(?:tag)(?: +|\\n|/?>)|<(?: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', '?(?:tag)(?: +|\\n|/?>)|<(?: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', '?(?:tag)(?: +|\\n|/?>)|<(?: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]+?\\1> *(?:\\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: /^ *\[([^\]]+)\]: *([^\s>]+)>?(?: +(["(][^\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'
+ + '|^[a-zA-Z][\\w:-]*\\s*>' // 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 + '' + type + '>\n';
+ }
+ listitem(item) {
+ let itemBody = '';
+ if (item.task) {
+ const checkbox = this.checkbox({ checked: !!item.checked });
+ if (item.loose) {
+ if (item.tokens.length > 0 && item.tokens[0].type === 'paragraph') {
+ item.tokens[0].text = checkbox + ' ' + item.tokens[0].text;
+ if (item.tokens[0].tokens && item.tokens[0].tokens.length > 0 && item.tokens[0].tokens[0].type === 'text') {
+ item.tokens[0].tokens[0].text = checkbox + ' ' + item.tokens[0].tokens[0].text;
+ }
+ }
+ else {
+ item.tokens.unshift({
+ type: 'text',
+ raw: checkbox + ' ',
+ text: checkbox + ' ',
+ });
+ }
+ }
+ else {
+ itemBody += checkbox + ' ';
+ }
+ }
+ itemBody += this.parser.parse(item.tokens, !!item.loose);
+ return `${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 + `${type}>\n`;
+ }
+ /**
+ * span level renderer
+ */
+ strong({ tokens }) {
+ return `${this.parser.parseInline(tokens)} `;
+ }
+ em({ tokens }) {
+ return `${this.parser.parseInline(tokens)} `;
+ }
+ codespan({ text }) {
+ return `${text}`;
+ }
+ br(token) {
+ return ' ';
+ }
+ del({ tokens }) {
+ return `${this.parser.parseInline(tokens)}`;
+ }
+ link({ href, title, tokens }) {
+ const text = this.parser.parseInline(tokens);
+ const cleanHref = cleanUrl(href);
+ if (cleanHref === null) {
+ return text;
+ }
+ href = cleanHref;
+ let out = '' + text + ' ';
+ return out;
+ }
+ image({ href, title, text }) {
+ const cleanHref = cleanUrl(href);
+ if (cleanHref === null) {
+ return text;
+ }
+ href = cleanHref;
+ let out = ` ';
+ return out;
+ }
+ text(token) {
+ return 'tokens' in token && token.tokens ? this.parser.parseInline(token.tokens) : token.text;
+ }
+}
+
+/**
+ * TextRenderer
+ * returns only the textual part of the token
+ */
+class _TextRenderer {
+ // no need for block level renderers
+ strong({ text }) {
+ return text;
+ }
+ em({ text }) {
+ return text;
+ }
+ codespan({ text }) {
+ return text;
+ }
+ del({ text }) {
+ return text;
+ }
+ html({ text }) {
+ return text;
+ }
+ text({ text }) {
+ return text;
+ }
+ link({ text }) {
+ return '' + text;
+ }
+ image({ text }) {
+ return '' + text;
+ }
+ br() {
+ return '';
+ }
+}
+
+/**
+ * Parsing & Compiling
+ */
+class _Parser {
+ options;
+ renderer;
+ textRenderer;
+ constructor(options) {
+ this.options = options || _defaults;
+ this.options.renderer = this.options.renderer || new _Renderer();
+ this.renderer = this.options.renderer;
+ this.renderer.options = this.options;
+ this.renderer.parser = this;
+ this.textRenderer = new _TextRenderer();
+ }
+ /**
+ * Static Parse Method
+ */
+ static parse(tokens, options) {
+ const parser = new _Parser(options);
+ return parser.parse(tokens);
+ }
+ /**
+ * Static Parse Inline Method
+ */
+ static parseInline(tokens, options) {
+ const parser = new _Parser(options);
+ return parser.parseInline(tokens);
+ }
+ /**
+ * Parse Loop
+ */
+ parse(tokens, top = true) {
+ let out = '';
+ for (let i = 0; i < tokens.length; i++) {
+ const anyToken = tokens[i];
+ // Run any renderer extensions
+ if (this.options.extensions && this.options.extensions.renderers && this.options.extensions.renderers[anyToken.type]) {
+ const genericToken = anyToken;
+ const ret = this.options.extensions.renderers[genericToken.type].call({ parser: this }, genericToken);
+ if (ret !== false || !['space', 'hr', 'heading', 'code', 'table', 'blockquote', 'list', 'html', 'paragraph', 'text'].includes(genericToken.type)) {
+ out += ret || '';
+ continue;
+ }
+ }
+ const token = anyToken;
+ switch (token.type) {
+ case 'space': {
+ out += this.renderer.space(token);
+ continue;
+ }
+ case 'hr': {
+ out += this.renderer.hr(token);
+ continue;
+ }
+ case 'heading': {
+ out += this.renderer.heading(token);
+ continue;
+ }
+ case 'code': {
+ out += this.renderer.code(token);
+ continue;
+ }
+ case 'table': {
+ out += this.renderer.table(token);
+ continue;
+ }
+ case 'blockquote': {
+ out += this.renderer.blockquote(token);
+ continue;
+ }
+ case 'list': {
+ out += this.renderer.list(token);
+ continue;
+ }
+ case 'html': {
+ out += this.renderer.html(token);
+ continue;
+ }
+ case 'paragraph': {
+ out += this.renderer.paragraph(token);
+ continue;
+ }
+ case 'text': {
+ let textToken = token;
+ let body = this.renderer.text(textToken);
+ while (i + 1 < tokens.length && tokens[i + 1].type === 'text') {
+ textToken = tokens[++i];
+ body += '\n' + this.renderer.text(textToken);
+ }
+ if (top) {
+ out += this.renderer.paragraph({
+ type: 'paragraph',
+ raw: body,
+ text: body,
+ tokens: [{ type: 'text', raw: body, text: body }],
+ });
+ }
+ else {
+ out += body;
+ }
+ continue;
+ }
+ default: {
+ const errMsg = 'Token with "' + token.type + '" type was not found.';
+ if (this.options.silent) {
+ console.error(errMsg);
+ return '';
+ }
+ else {
+ throw new Error(errMsg);
+ }
+ }
+ }
+ }
+ return out;
+ }
+ /**
+ * Parse Inline Tokens
+ */
+ parseInline(tokens, renderer) {
+ renderer = renderer || this.renderer;
+ let out = '';
+ for (let i = 0; i < tokens.length; i++) {
+ const anyToken = tokens[i];
+ // Run any renderer extensions
+ if (this.options.extensions && this.options.extensions.renderers && this.options.extensions.renderers[anyToken.type]) {
+ const ret = this.options.extensions.renderers[anyToken.type].call({ parser: this }, anyToken);
+ if (ret !== false || !['escape', 'html', 'link', 'image', 'strong', 'em', 'codespan', 'br', 'del', 'text'].includes(anyToken.type)) {
+ out += ret || '';
+ continue;
+ }
+ }
+ const token = anyToken;
+ switch (token.type) {
+ case 'escape': {
+ out += renderer.text(token);
+ break;
+ }
+ case 'html': {
+ out += renderer.html(token);
+ break;
+ }
+ case 'link': {
+ out += renderer.link(token);
+ break;
+ }
+ case 'image': {
+ out += renderer.image(token);
+ break;
+ }
+ case 'strong': {
+ out += renderer.strong(token);
+ break;
+ }
+ case 'em': {
+ out += renderer.em(token);
+ break;
+ }
+ case 'codespan': {
+ out += renderer.codespan(token);
+ break;
+ }
+ case 'br': {
+ out += renderer.br(token);
+ break;
+ }
+ case 'del': {
+ out += renderer.del(token);
+ break;
+ }
+ case 'text': {
+ out += renderer.text(token);
+ break;
+ }
+ default: {
+ const errMsg = 'Token with "' + token.type + '" type was not found.';
+ if (this.options.silent) {
+ console.error(errMsg);
+ return '';
+ }
+ else {
+ throw new Error(errMsg);
+ }
+ }
+ }
+ }
+ return out;
+ }
+}
+
+class _Hooks {
+ options;
+ block;
+ constructor(options) {
+ this.options = options || _defaults;
+ }
+ static passThroughHooks = new Set([
+ 'preprocess',
+ 'postprocess',
+ 'processAllTokens',
+ ]);
+ /**
+ * Process markdown before marked
+ */
+ preprocess(markdown) {
+ return markdown;
+ }
+ /**
+ * Process HTML after marked is finished
+ */
+ postprocess(html) {
+ return html;
+ }
+ /**
+ * Process all tokens before walk tokens
+ */
+ processAllTokens(tokens) {
+ return tokens;
+ }
+ /**
+ * Provide function to tokenize markdown
+ */
+ provideLexer() {
+ return this.block ? _Lexer.lex : _Lexer.lexInline;
+ }
+ /**
+ * Provide function to parse tokens
+ */
+ provideParser() {
+ return this.block ? _Parser.parse : _Parser.parseInline;
+ }
+}
+
+class Marked {
+ defaults = _getDefaults();
+ options = this.setOptions;
+ parse = this.parseMarkdown(true);
+ parseInline = this.parseMarkdown(false);
+ Parser = _Parser;
+ Renderer = _Renderer;
+ TextRenderer = _TextRenderer;
+ Lexer = _Lexer;
+ Tokenizer = _Tokenizer;
+ Hooks = _Hooks;
+ constructor(...args) {
+ this.use(...args);
+ }
+ /**
+ * Run callback for every token
+ */
+ walkTokens(tokens, callback) {
+ let values = [];
+ for (const token of tokens) {
+ values = values.concat(callback.call(this, token));
+ switch (token.type) {
+ case 'table': {
+ const tableToken = token;
+ for (const cell of tableToken.header) {
+ values = values.concat(this.walkTokens(cell.tokens, callback));
+ }
+ for (const row of tableToken.rows) {
+ for (const cell of row) {
+ values = values.concat(this.walkTokens(cell.tokens, callback));
+ }
+ }
+ break;
+ }
+ case 'list': {
+ const listToken = token;
+ values = values.concat(this.walkTokens(listToken.items, callback));
+ break;
+ }
+ default: {
+ const genericToken = token;
+ if (this.defaults.extensions?.childTokens?.[genericToken.type]) {
+ this.defaults.extensions.childTokens[genericToken.type].forEach((childTokens) => {
+ const tokens = genericToken[childTokens].flat(Infinity);
+ values = values.concat(this.walkTokens(tokens, callback));
+ });
+ }
+ else if (genericToken.tokens) {
+ values = values.concat(this.walkTokens(genericToken.tokens, callback));
+ }
+ }
+ }
+ }
+ return values;
+ }
+ use(...args) {
+ const extensions = this.defaults.extensions || { renderers: {}, childTokens: {} };
+ args.forEach((pack) => {
+ // copy options to new object
+ const opts = { ...pack };
+ // set async to true if it was set to true before
+ opts.async = this.defaults.async || opts.async || false;
+ // ==-- Parse "addon" extensions --== //
+ if (pack.extensions) {
+ pack.extensions.forEach((ext) => {
+ if (!ext.name) {
+ throw new Error('extension name required');
+ }
+ if ('renderer' in ext) { // Renderer extensions
+ const prevRenderer = extensions.renderers[ext.name];
+ if (prevRenderer) {
+ // Replace extension with func to run new extension but fall back if false
+ extensions.renderers[ext.name] = function (...args) {
+ let ret = ext.renderer.apply(this, args);
+ if (ret === false) {
+ ret = prevRenderer.apply(this, args);
+ }
+ return ret;
+ };
+ }
+ else {
+ extensions.renderers[ext.name] = ext.renderer;
+ }
+ }
+ if ('tokenizer' in ext) { // Tokenizer Extensions
+ if (!ext.level || (ext.level !== 'block' && ext.level !== 'inline')) {
+ throw new Error("extension level must be 'block' or 'inline'");
+ }
+ const extLevel = extensions[ext.level];
+ if (extLevel) {
+ extLevel.unshift(ext.tokenizer);
+ }
+ else {
+ extensions[ext.level] = [ext.tokenizer];
+ }
+ if (ext.start) { // Function to check for start of token
+ if (ext.level === 'block') {
+ if (extensions.startBlock) {
+ extensions.startBlock.push(ext.start);
+ }
+ else {
+ extensions.startBlock = [ext.start];
+ }
+ }
+ else if (ext.level === 'inline') {
+ if (extensions.startInline) {
+ extensions.startInline.push(ext.start);
+ }
+ else {
+ extensions.startInline = [ext.start];
+ }
+ }
+ }
+ }
+ if ('childTokens' in ext && ext.childTokens) { // Child tokens to be visited by walkTokens
+ extensions.childTokens[ext.name] = ext.childTokens;
+ }
+ });
+ opts.extensions = extensions;
+ }
+ // ==-- Parse "overwrite" extensions --== //
+ if (pack.renderer) {
+ const renderer = this.defaults.renderer || new _Renderer(this.defaults);
+ for (const prop in pack.renderer) {
+ if (!(prop in renderer)) {
+ throw new Error(`renderer '${prop}' does not exist`);
+ }
+ if (['options', 'parser'].includes(prop)) {
+ // ignore options property
+ continue;
+ }
+ const rendererProp = prop;
+ const rendererFunc = pack.renderer[rendererProp];
+ const prevRenderer = renderer[rendererProp];
+ // Replace renderer with func to run extension, but fall back if false
+ renderer[rendererProp] = (...args) => {
+ let ret = rendererFunc.apply(renderer, args);
+ if (ret === false) {
+ ret = prevRenderer.apply(renderer, args);
+ }
+ return ret || '';
+ };
+ }
+ opts.renderer = renderer;
+ }
+ if (pack.tokenizer) {
+ const tokenizer = this.defaults.tokenizer || new _Tokenizer(this.defaults);
+ for (const prop in pack.tokenizer) {
+ if (!(prop in tokenizer)) {
+ throw new Error(`tokenizer '${prop}' does not exist`);
+ }
+ if (['options', 'rules', 'lexer'].includes(prop)) {
+ // ignore options, rules, and lexer properties
+ continue;
+ }
+ const tokenizerProp = prop;
+ const tokenizerFunc = pack.tokenizer[tokenizerProp];
+ const prevTokenizer = tokenizer[tokenizerProp];
+ // Replace tokenizer with func to run extension, but fall back if false
+ // @ts-expect-error cannot type tokenizer function dynamically
+ tokenizer[tokenizerProp] = (...args) => {
+ let ret = tokenizerFunc.apply(tokenizer, args);
+ if (ret === false) {
+ ret = prevTokenizer.apply(tokenizer, args);
+ }
+ return ret;
+ };
+ }
+ opts.tokenizer = tokenizer;
+ }
+ // ==-- Parse Hooks extensions --== //
+ if (pack.hooks) {
+ const hooks = this.defaults.hooks || new _Hooks();
+ for (const prop in pack.hooks) {
+ if (!(prop in hooks)) {
+ throw new Error(`hook '${prop}' does not exist`);
+ }
+ if (['options', 'block'].includes(prop)) {
+ // ignore options and block properties
+ continue;
+ }
+ const hooksProp = prop;
+ const hooksFunc = pack.hooks[hooksProp];
+ const prevHook = hooks[hooksProp];
+ if (_Hooks.passThroughHooks.has(prop)) {
+ // @ts-expect-error cannot type hook function dynamically
+ hooks[hooksProp] = (arg) => {
+ if (this.defaults.async) {
+ return Promise.resolve(hooksFunc.call(hooks, arg)).then(ret => {
+ return prevHook.call(hooks, ret);
+ });
+ }
+ const ret = hooksFunc.call(hooks, arg);
+ return prevHook.call(hooks, ret);
+ };
+ }
+ else {
+ // @ts-expect-error cannot type hook function dynamically
+ hooks[hooksProp] = (...args) => {
+ let ret = hooksFunc.apply(hooks, args);
+ if (ret === false) {
+ ret = prevHook.apply(hooks, args);
+ }
+ return ret;
+ };
+ }
+ }
+ opts.hooks = hooks;
+ }
+ // ==-- Parse WalkTokens extensions --== //
+ if (pack.walkTokens) {
+ const walkTokens = this.defaults.walkTokens;
+ const packWalktokens = pack.walkTokens;
+ opts.walkTokens = function (token) {
+ let values = [];
+ values.push(packWalktokens.call(this, token));
+ if (walkTokens) {
+ values = values.concat(walkTokens.call(this, token));
+ }
+ return values;
+ };
+ }
+ this.defaults = { ...this.defaults, ...opts };
+ });
+ return this;
+ }
+ setOptions(opt) {
+ this.defaults = { ...this.defaults, ...opt };
+ return this;
+ }
+ lexer(src, options) {
+ return _Lexer.lex(src, options ?? this.defaults);
+ }
+ parser(tokens, options) {
+ return _Parser.parse(tokens, options ?? this.defaults);
+ }
+ parseMarkdown(blockType) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const parse = (src, options) => {
+ const origOpt = { ...options };
+ const opt = { ...this.defaults, ...origOpt };
+ const throwError = this.onError(!!opt.silent, !!opt.async);
+ // throw error if an extension set async to true but parse was called with async: false
+ if (this.defaults.async === true && origOpt.async === false) {
+ return throwError(new Error('marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise.'));
+ }
+ // throw error in case of non string input
+ if (typeof src === 'undefined' || src === null) {
+ return throwError(new Error('marked(): input parameter is undefined or null'));
+ }
+ if (typeof src !== 'string') {
+ return throwError(new Error('marked(): input parameter is of type '
+ + Object.prototype.toString.call(src) + ', string expected'));
+ }
+ if (opt.hooks) {
+ opt.hooks.options = opt;
+ opt.hooks.block = blockType;
+ }
+ const lexer = opt.hooks ? opt.hooks.provideLexer() : (blockType ? _Lexer.lex : _Lexer.lexInline);
+ const parser = opt.hooks ? opt.hooks.provideParser() : (blockType ? _Parser.parse : _Parser.parseInline);
+ if (opt.async) {
+ return Promise.resolve(opt.hooks ? opt.hooks.preprocess(src) : src)
+ .then(src => lexer(src, opt))
+ .then(tokens => opt.hooks ? opt.hooks.processAllTokens(tokens) : tokens)
+ .then(tokens => opt.walkTokens ? Promise.all(this.walkTokens(tokens, opt.walkTokens)).then(() => tokens) : tokens)
+ .then(tokens => parser(tokens, opt))
+ .then(html => opt.hooks ? opt.hooks.postprocess(html) : html)
+ .catch(throwError);
+ }
+ try {
+ if (opt.hooks) {
+ src = opt.hooks.preprocess(src);
+ }
+ let tokens = lexer(src, opt);
+ if (opt.hooks) {
+ tokens = opt.hooks.processAllTokens(tokens);
+ }
+ if (opt.walkTokens) {
+ this.walkTokens(tokens, opt.walkTokens);
+ }
+ let html = parser(tokens, opt);
+ if (opt.hooks) {
+ html = opt.hooks.postprocess(html);
+ }
+ return html;
+ }
+ catch (e) {
+ return throwError(e);
+ }
+ };
+ return parse;
+ }
+ onError(silent, async) {
+ return (e) => {
+ e.message += '\nPlease report this to https://github.com/markedjs/marked.';
+ if (silent) {
+ const msg = '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("", "- ").replaceAll(" ", "\n")
- .replaceAll("", "*").replaceAll(" ", "*")
- .replaceAll("", "`").replaceAll("", "`")
- .replaceAll("", "\n")
- .replaceAll(" ", "\n\n---\n\n")
- .replaceAll("