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

    ${this.parser.parseInline(tokens)}

    \n`; + } + table(token) { + let header = ''; + // header + let cell = ''; + for (let j = 0; j < token.header.length; j++) { + cell += this.tablecell(token.header[j]); + } + header += this.tablerow({ text: cell }); + let body = ''; + for (let j = 0; j < token.rows.length; j++) { + const row = token.rows[j]; + cell = ''; + for (let k = 0; k < row.length; k++) { + cell += this.tablecell(row[k]); + } + body += this.tablerow({ text: cell }); + } + if (body) + body = `${body}`; + return '\n' + + '\n' + + header + + '\n' + + body + + '
    \n'; + } + tablerow({ text }) { + return `\n${text}\n`; + } + tablecell(token) { + const content = this.parser.parseInline(token.tokens); + const type = token.header ? 'th' : 'td'; + const tag = token.align + ? `<${type} align="${token.align}">` + : `<${type}>`; + return tag + content + `\n`; + } + /** + * span level renderer + */ + strong({ tokens }) { + return `${this.parser.parseInline(tokens)}`; + } + em({ tokens }) { + return `${this.parser.parseInline(tokens)}`; + } + codespan({ text }) { + return `${text}`; + } + br(token) { + return '
    '; + } + del({ tokens }) { + return `${this.parser.parseInline(tokens)}`; + } + link({ href, title, tokens }) { + const text = this.parser.parseInline(tokens); + const cleanHref = cleanUrl(href); + if (cleanHref === null) { + return text; + } + href = cleanHref; + let out = '
    '; + return out; + } + image({ href, title, text }) { + const cleanHref = cleanUrl(href); + if (cleanHref === null) { + return text; + } + href = cleanHref; + let out = `${text} { + const tokens = genericToken[childTokens].flat(Infinity); + values = values.concat(this.walkTokens(tokens, callback)); + }); + } + else if (genericToken.tokens) { + values = values.concat(this.walkTokens(genericToken.tokens, callback)); + } + } + } + } + return values; + } + use(...args) { + const extensions = this.defaults.extensions || { renderers: {}, childTokens: {} }; + args.forEach((pack) => { + // copy options to new object + const opts = { ...pack }; + // set async to true if it was set to true before + opts.async = this.defaults.async || opts.async || false; + // ==-- Parse "addon" extensions --== // + if (pack.extensions) { + pack.extensions.forEach((ext) => { + if (!ext.name) { + throw new Error('extension name required'); + } + if ('renderer' in ext) { // Renderer extensions + const prevRenderer = extensions.renderers[ext.name]; + if (prevRenderer) { + // Replace extension with func to run new extension but fall back if false + extensions.renderers[ext.name] = function (...args) { + let ret = ext.renderer.apply(this, args); + if (ret === false) { + ret = prevRenderer.apply(this, args); + } + return ret; + }; + } + else { + extensions.renderers[ext.name] = ext.renderer; + } + } + if ('tokenizer' in ext) { // Tokenizer Extensions + if (!ext.level || (ext.level !== 'block' && ext.level !== 'inline')) { + throw new Error("extension level must be 'block' or 'inline'"); + } + const extLevel = extensions[ext.level]; + if (extLevel) { + extLevel.unshift(ext.tokenizer); + } + else { + extensions[ext.level] = [ext.tokenizer]; + } + if (ext.start) { // Function to check for start of token + if (ext.level === 'block') { + if (extensions.startBlock) { + extensions.startBlock.push(ext.start); + } + else { + extensions.startBlock = [ext.start]; + } + } + else if (ext.level === 'inline') { + if (extensions.startInline) { + extensions.startInline.push(ext.start); + } + else { + extensions.startInline = [ext.start]; + } + } + } + } + if ('childTokens' in ext && ext.childTokens) { // Child tokens to be visited by walkTokens + extensions.childTokens[ext.name] = ext.childTokens; + } + }); + opts.extensions = extensions; + } + // ==-- Parse "overwrite" extensions --== // + if (pack.renderer) { + const renderer = this.defaults.renderer || new _Renderer(this.defaults); + for (const prop in pack.renderer) { + if (!(prop in renderer)) { + throw new Error(`renderer '${prop}' does not exist`); + } + if (['options', 'parser'].includes(prop)) { + // ignore options property + continue; + } + const rendererProp = prop; + const rendererFunc = pack.renderer[rendererProp]; + const prevRenderer = renderer[rendererProp]; + // Replace renderer with func to run extension, but fall back if false + renderer[rendererProp] = (...args) => { + let ret = rendererFunc.apply(renderer, args); + if (ret === false) { + ret = prevRenderer.apply(renderer, args); + } + return ret || ''; + }; + } + opts.renderer = renderer; + } + if (pack.tokenizer) { + const tokenizer = this.defaults.tokenizer || new _Tokenizer(this.defaults); + for (const prop in pack.tokenizer) { + if (!(prop in tokenizer)) { + throw new Error(`tokenizer '${prop}' does not exist`); + } + if (['options', 'rules', 'lexer'].includes(prop)) { + // ignore options, rules, and lexer properties + continue; + } + const tokenizerProp = prop; + const tokenizerFunc = pack.tokenizer[tokenizerProp]; + const prevTokenizer = tokenizer[tokenizerProp]; + // Replace tokenizer with func to run extension, but fall back if false + // @ts-expect-error cannot type tokenizer function dynamically + tokenizer[tokenizerProp] = (...args) => { + let ret = tokenizerFunc.apply(tokenizer, args); + if (ret === false) { + ret = prevTokenizer.apply(tokenizer, args); + } + return ret; + }; + } + opts.tokenizer = tokenizer; + } + // ==-- Parse Hooks extensions --== // + if (pack.hooks) { + const hooks = this.defaults.hooks || new _Hooks(); + for (const prop in pack.hooks) { + if (!(prop in hooks)) { + throw new Error(`hook '${prop}' does not exist`); + } + if (['options', 'block'].includes(prop)) { + // ignore options and block properties + continue; + } + const hooksProp = prop; + const hooksFunc = pack.hooks[hooksProp]; + const prevHook = hooks[hooksProp]; + if (_Hooks.passThroughHooks.has(prop)) { + // @ts-expect-error cannot type hook function dynamically + hooks[hooksProp] = (arg) => { + if (this.defaults.async) { + return Promise.resolve(hooksFunc.call(hooks, arg)).then(ret => { + return prevHook.call(hooks, ret); + }); + } + const ret = hooksFunc.call(hooks, arg); + return prevHook.call(hooks, ret); + }; + } + else { + // @ts-expect-error cannot type hook function dynamically + hooks[hooksProp] = (...args) => { + let ret = hooksFunc.apply(hooks, args); + if (ret === false) { + ret = prevHook.apply(hooks, args); + } + return ret; + }; + } + } + opts.hooks = hooks; + } + // ==-- Parse WalkTokens extensions --== // + if (pack.walkTokens) { + const walkTokens = this.defaults.walkTokens; + const packWalktokens = pack.walkTokens; + opts.walkTokens = function (token) { + let values = []; + values.push(packWalktokens.call(this, token)); + if (walkTokens) { + values = values.concat(walkTokens.call(this, token)); + } + return values; + }; + } + this.defaults = { ...this.defaults, ...opts }; + }); + return this; + } + setOptions(opt) { + this.defaults = { ...this.defaults, ...opt }; + return this; + } + lexer(src, options) { + return _Lexer.lex(src, options ?? this.defaults); + } + parser(tokens, options) { + return _Parser.parse(tokens, options ?? this.defaults); + } + parseMarkdown(blockType) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const parse = (src, options) => { + const origOpt = { ...options }; + const opt = { ...this.defaults, ...origOpt }; + const throwError = this.onError(!!opt.silent, !!opt.async); + // throw error if an extension set async to true but parse was called with async: false + if (this.defaults.async === true && origOpt.async === false) { + return throwError(new Error('marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise.')); + } + // throw error in case of non string input + if (typeof src === 'undefined' || src === null) { + return throwError(new Error('marked(): input parameter is undefined or null')); + } + if (typeof src !== 'string') { + return throwError(new Error('marked(): input parameter is of type ' + + Object.prototype.toString.call(src) + ', string expected')); + } + if (opt.hooks) { + opt.hooks.options = opt; + opt.hooks.block = blockType; + } + const lexer = opt.hooks ? opt.hooks.provideLexer() : (blockType ? _Lexer.lex : _Lexer.lexInline); + const parser = opt.hooks ? opt.hooks.provideParser() : (blockType ? _Parser.parse : _Parser.parseInline); + if (opt.async) { + return Promise.resolve(opt.hooks ? opt.hooks.preprocess(src) : src) + .then(src => lexer(src, opt)) + .then(tokens => opt.hooks ? opt.hooks.processAllTokens(tokens) : tokens) + .then(tokens => opt.walkTokens ? Promise.all(this.walkTokens(tokens, opt.walkTokens)).then(() => tokens) : tokens) + .then(tokens => parser(tokens, opt)) + .then(html => opt.hooks ? opt.hooks.postprocess(html) : html) + .catch(throwError); + } + try { + if (opt.hooks) { + src = opt.hooks.preprocess(src); + } + let tokens = lexer(src, opt); + if (opt.hooks) { + tokens = opt.hooks.processAllTokens(tokens); + } + if (opt.walkTokens) { + this.walkTokens(tokens, opt.walkTokens); + } + let html = parser(tokens, opt); + if (opt.hooks) { + html = opt.hooks.postprocess(html); + } + return html; + } + catch (e) { + return throwError(e); + } + }; + return parse; + } + onError(silent, async) { + return (e) => { + e.message += '\nPlease report this to https://github.com/markedjs/marked.'; + if (silent) { + const msg = '

    An error occurred:

    '
    +                    + escape$1(e.message + '', true)
    +                    + '
    '; + if (async) { + return Promise.resolve(msg); + } + return msg; + } + if (async) { + return Promise.reject(e); + } + throw e; + }; + } +} + +const markedInstance = new Marked(); +function marked(src, opt) { + return markedInstance.parse(src, opt); +} +/** + * Sets the default options. + * + * @param options Hash of options + */ +marked.options = + marked.setOptions = function (options) { + markedInstance.setOptions(options); + marked.defaults = markedInstance.defaults; + changeDefaults(marked.defaults); + return marked; + }; +/** + * Gets the original marked default options. + */ +marked.getDefaults = _getDefaults; +marked.defaults = _defaults; +/** + * Use Extension + */ +marked.use = function (...args) { + markedInstance.use(...args); + marked.defaults = markedInstance.defaults; + changeDefaults(marked.defaults); + return marked; +}; +/** + * Run callback for every token + */ +marked.walkTokens = function (tokens, callback) { + return markedInstance.walkTokens(tokens, callback); +}; +/** + * Compiles markdown to HTML without enclosing `p` tag. + * + * @param src String of markdown source to be compiled + * @param options Hash of options + * @return String of compiled HTML + */ +marked.parseInline = markedInstance.parseInline; +/** + * Expose + */ +marked.Parser = _Parser; +marked.parser = _Parser.parse; +marked.Renderer = _Renderer; +marked.TextRenderer = _TextRenderer; +marked.Lexer = _Lexer; +marked.lexer = _Lexer.lex; +marked.Tokenizer = _Tokenizer; +marked.Hooks = _Hooks; +marked.parse = marked; +const options = marked.options; +const setOptions = marked.setOptions; +const use = marked.use; +const walkTokens = marked.walkTokens; +const parseInline = marked.parseInline; +const parse = marked; +const parser = _Parser.parse; +const lexer = _Lexer.lex; + +export { _Hooks as Hooks, _Lexer as Lexer, Marked, _Parser as Parser, _Renderer as Renderer, _TextRenderer as TextRenderer, _Tokenizer as Tokenizer, _defaults as defaults, _getDefaults as getDefaults, lexer, marked, options, parse, parseInline, parser, setOptions, use, walkTokens }; +//# sourceMappingURL=marked.esm.js.map \ No newline at end of file diff --git a/web/model-manager.js b/web/model-manager.js index 6eb9e52..d771a3a 100644 --- a/web/model-manager.js +++ b/web/model-manager.js @@ -2,7 +2,8 @@ import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; import { ComfyDialog, $el } from "../../scripts/ui.js"; import { ComfyButton } from "../../scripts/ui/components/button.js"; -import { marked } from "https://cdn.jsdelivr.net/npm/marked/lib/marked.esm.js"; +import { marked } from "./marked.js"; +import("./downshow.js"); function clamp(x, min, max) { return Math.min(Math.max(x, min), max); @@ -2707,7 +2708,7 @@ class ModelInfo { const notesElement = this.elements.tabContents[3]; // TODO: remove magic value notesElement.innerHTML = ""; const markdown = $el("div", {}, ""); - markdown.innerHTML = marked.parse(noteText); + markdown.innerHTML = marked.parse(noteText); notesElement.append.apply(notesElement, (() => { @@ -3175,31 +3176,7 @@ async function getModelInfos(urlText) { version["description"], civitaiInfo["description"] !== undefined ? "# " + name : undefined, civitaiInfo["description"], - ].filter(x => x !== undefined).join("\n\n") - .replaceAll("

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

      ", "\n").replaceAll("
    ", "\n") // wrong - .replaceAll("
      ", "\n").replaceAll("
    ", "\n") - .replaceAll("
  • ", "- ").replaceAll("
  • ", "\n") - .replaceAll("", "*").replaceAll("", "*") - .replaceAll("", "`").replaceAll("", "`") - .replaceAll("", "\n") - .replaceAll("", "\n\n---\n\n") - .replaceAll("", "\n") - .replaceAll("", "\n") - .replaceAll("", "\n") - .replaceAll("", "\n") - .replaceAll("", "\n") - .replaceAll("", "\n") - .replace(/href="(\S*)">/g, 'href=""> $1
    ') - .replace(/src="(\S*)">/g, 'src=""> $1 ') - // - // - .replace(/<[^>]+>/g, "") // quick hack - .replaceAll("<", "<").replaceAll(">", ">") - .replaceAll("<e;", "<=").replaceAll(">e;", ">=") - .replaceAll("&", "&"); + ].filter(x => x !== undefined).join("\n\n"); version["files"].forEach((file) => { infos.push({ "images": images, @@ -3207,7 +3184,7 @@ async function getModelInfos(urlText) { "modelType": type, "downloadUrl": file["downloadUrl"], "downloadFilePath": "", - "description": description, + "description": downshow(description), "details": { "fileSizeKB": file["sizeKB"], "fileType": file["type"], From ab7c62e929c29ceefe140f5886d7b2a36fc3150e Mon Sep 17 00:00:00 2001 From: korutech-ai Date: Wed, 28 Aug 2024 07:48:30 +1200 Subject: [PATCH 4/9] DEVCONFIG: Adding macOS ignore settings. --- .gitignore | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/.gitignore b/.gitignore index 32b09a4..542369d 100644 --- a/.gitignore +++ b/.gitignore @@ -160,3 +160,31 @@ cython_debug/ #.idea/ ui_settings.yaml server_settings.yaml + +# macOS: +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk From 1bedfaa7a25162aa01cbcbfb84d6a42bf35378ec Mon Sep 17 00:00:00 2001 From: korutech-ai Date: Wed, 28 Aug 2024 08:14:39 +1200 Subject: [PATCH 5/9] LINT: No code change, just whitespace cleanup. --- web/model-manager.js | 550 +++++++++++++++++++++---------------------- 1 file changed, 275 insertions(+), 275 deletions(-) diff --git a/web/model-manager.js b/web/model-manager.js index d771a3a..498b098 100644 --- a/web/model-manager.js +++ b/web/model-manager.js @@ -41,16 +41,16 @@ function debounce(callback, delay) { class KeyComboListener { /** @type {string[]} */ #keyCodes = []; - + /** @type {() => Promise} */ action; - + /** @type {Element} */ element; - + /** @type {string[]} */ #combo = []; - + /** * @param {string[]} keyCodes * @param {() => Promise} action @@ -60,7 +60,7 @@ class KeyComboListener { this.#keyCodes = keyCodes; this.action = action; this.element = element; - + document.addEventListener("keydown", (e) => { const code = e.code; const keyCodes = this.#keyCodes; @@ -161,21 +161,21 @@ const modelNodeType = { const MODEL_EXTENSIONS = [".bin", ".ckpt", "gguf", ".onnx", ".pt", ".pth", ".safetensors"]; // TODO: ask server for? const IMAGE_EXTENSIONS = [ - ".png", - ".webp", - ".jpeg", - ".jpg", - ".jfif", - ".gif", - ".apng", + ".png", + ".webp", + ".jpeg", + ".jpg", + ".jfif", + ".gif", + ".apng", - ".preview.png", - ".preview.webp", - ".preview.jpeg", - ".preview.jpg", - ".preview.jfif", - ".preview.gif", - ".preview.apng", + ".preview.png", + ".preview.webp", + ".preview.jpeg", + ".preview.jpg", + ".preview.jfif", + ".preview.gif", + ".preview.apng", ]; // TODO: /model-manager/image/extensions /** @@ -267,7 +267,7 @@ const PREVIEW_THUMBNAIL_WIDTH = 320; const PREVIEW_THUMBNAIL_HEIGHT = 480; /** - * + * * @param {HTMLButtonElement} element * @returns {[HTMLButtonElement | undefined, HTMLElement | undefined, HTMLSpanElement | undefined]} [button, icon, span] */ @@ -304,18 +304,18 @@ function comfyButtonDisambiguate(element) { */ function comfyButtonAlert(element, success, successClassName = undefined, failureClassName = undefined, disableCallback = false) { if (element === undefined || element === null) { return; } - + const [button, icon, span] = comfyButtonDisambiguate(element); if (button === undefined) { console.warn("Unable to find button element!"); console.warn(element); return; } - + // TODO: debounce would be nice, but needs some sort of "global" to avoid creating/destroying many objects - + const colorClassName = success ? "comfy-button-success" : "comfy-button-failure"; - + if (icon) { const iconClassName = (success ? successClassName : failureClassName) ?? ""; if (iconClassName !== "") { @@ -331,7 +331,7 @@ function comfyButtonAlert(element, success, successClassName = undefined, failur }, 1000, icon, iconClassName, colorClassName); } } - + button.classList.add(colorClassName); if (!disableCallback) { window.setTimeout((element, colorClassName) => { @@ -341,7 +341,7 @@ function comfyButtonAlert(element, success, successClassName = undefined, failur } /** - * + * * @param {string} modelPath * @param {string} newValue * @returns {Promise} @@ -424,11 +424,11 @@ function $select(x = { $: (el) => {}, textContent: "", options: [""] }) { */ function $radioGroup(attr) { const { name = Date.now(), onchange, options = [], $ } = attr; - + /** @type {HTMLDivElement[]} */ const radioGroup = options.map((item, index) => { const inputRef = { value: null }; - + return $el( "div.comfy-radio", { onclick: () => inputRef.value.click() }, @@ -444,7 +444,7 @@ function $radioGroup(attr) { ] ); }); - + const element = $el("input", { name: name + "-group", value: options[0]?.value, @@ -458,7 +458,7 @@ function $radioGroup(attr) { onchange?.(selectedValue); }); }); - + return $el("div.comfy-radio-group", radioGroup); } @@ -516,7 +516,7 @@ function GenerateTabGroup(tabData) { tabButtons.push(tab); tabContents.push(content); }); - + return [tabButtons, tabContents]; } @@ -578,28 +578,28 @@ class ImageSelect { /** @constant {string} */ #PREVIEW_UPLOAD = "Upload"; /** @constant {string} */ #PREVIEW_URL = "URL"; /** @constant {string} */ #PREVIEW_NONE = "No Preview"; - + elements = { /** @type {HTMLDivElement} */ radioGroup: null, /** @type {HTMLDivElement} */ radioButtons: null, /** @type {HTMLDivElement} */ previews: null, - + /** @type {HTMLImageElement} */ defaultPreviewNoImage: null, /** @type {HTMLDivElement} */ defaultPreviews: null, /** @type {HTMLDivElement} */ defaultUrl: null, - + /** @type {HTMLImageElement} */ customUrlPreview: null, /** @type {HTMLInputElement} */ customUrl: null, /** @type {HTMLDivElement} */ custom: null, - + /** @type {HTMLImageElement} */ uploadPreview: null, /** @type {HTMLInputElement} */ uploadFile: null, /** @type {HTMLDivElement} */ upload: null, }; - + /** @type {string} */ #name = null; - + /** @returns {Promise | Promise} */ async getImage() { const name = this.#name; @@ -651,7 +651,7 @@ class ImageSelect { } return ""; } - + /** @returns {void} */ resetModelInfoPreview() { let noimage = this.elements.defaultUrl.dataset.noimage; @@ -680,7 +680,7 @@ class ImageSelect { this.elements.upload.style.display = "none"; this.elements.custom.style.display = "none"; } - + /** @returns {boolean} */ defaultIsChecked() { const children = this.elements.radioButtons.children; @@ -693,7 +693,7 @@ class ImageSelect { }; return false; } - + /** @returns {void} */ checkDefault() { const children = this.elements.radioButtons.children; @@ -707,9 +707,9 @@ class ImageSelect { } }; } - + /** - * @param {1 | -1} step + * @param {1 | -1} step */ stepDefaultPreviews(step) { const children = this.elements.defaultPreviews.children; @@ -730,7 +730,7 @@ class ImageSelect { else if (currentIndex < 0) { currentIndex = children.length - 1; } children[currentIndex].style.display = "block"; } - + /** * @param {string} radioGroupName - Should be unique for every radio group. * @param {string[]|undefined} defaultPreviews @@ -740,20 +740,20 @@ class ImageSelect { defaultPreviews = [PREVIEW_NONE_URI]; } this.#name = radioGroupName; - + const el_defaultUri = $el("div", { $: (el) => (this.elements.defaultUrl = el), style: { display: "none" }, "data-noimage": PREVIEW_NONE_URI, }); - + const el_defaultPreviewNoImage = $el("img", { $: (el) => (this.elements.defaultPreviewNoImage = el), loading: "lazy", /* `loading` BEFORE `src`; Known bug in Firefox 124.0.2 and Safari for iOS 17.4.1 (https://stackoverflow.com/a/76252772) */ src: PREVIEW_NONE_URI, style: { display: "none" }, }); - + const el_defaultPreviews = $el("div", { $: (el) => (this.elements.defaultPreviews = el), style: { @@ -776,7 +776,7 @@ class ImageSelect { } return imgs; })()); - + const el_uploadPreview = $el("img", { $: (el) => (this.elements.uploadPreview = el), src: PREVIEW_NONE_URI, @@ -806,9 +806,9 @@ class ImageSelect { }, [ el_uploadFile, ]); - + /** - * @param {string} url + * @param {string} url * @returns {Promise} */ const getCustomPreviewUrl = async (url) => { @@ -833,7 +833,7 @@ class ImageSelect { return url; } }; - + const el_customUrlPreview = $el("img", { $: (el) => (this.elements.customUrlPreview = el), src: PREVIEW_NONE_URI, @@ -877,7 +877,7 @@ class ImageSelect { }, }).element, ]); - + const el_previewButtons = $el("div.model-preview-overlay", { style: { display: el_defaultPreviews.children.length > 1 ? "block" : "none", @@ -914,20 +914,20 @@ class ImageSelect { ), el_previewButtons, ]); - + const el_radioButtons = $radioGroup({ name: radioGroupName, onchange: (value) => { el_custom.style.display = "none"; el_upload.style.display = "none"; - + el_defaultPreviews.style.display = "none"; el_previewButtons.style.display = "none"; - + el_defaultPreviewNoImage.style.display = "none"; el_uploadPreview.style.display = "none"; el_customUrlPreview.style.display = "none"; - + switch (value) { case this.#PREVIEW_DEFAULT: el_defaultPreviews.style.display = "block"; @@ -957,7 +957,7 @@ class ImageSelect { }), }); this.elements.radioButtons = el_radioButtons; - + const children = el_radioButtons.children; for (let i = 0; i < children.length; i++) { const child = children[i]; @@ -967,7 +967,7 @@ class ImageSelect { break; } }; - + const el_radioGroup = $el("div.model-preview-select-radio-container", { $: (el) => (this.elements.radioGroup = el), }, [ @@ -981,7 +981,7 @@ class ImageSelect { } /** - * @typedef {Object} DirectoryItem + * @typedef {Object} DirectoryItem * @property {String} name * @property {number | undefined} childCount * @property {number | undefined} childIndex @@ -1084,7 +1084,7 @@ class ModelDirectories { } return index + start; } - + /** * Returns a list of matching search results and valid path. * @param {string} filter @@ -1167,16 +1167,16 @@ const DROPDOWN_DIRECTORY_SELECTION_MOUSE_CLASS = "search-directory-dropdown-mous class ModelData { /** @type {string} */ searchSeparator = "/"; // TODO: other client or server code may be assuming this to always be "/" - + /** @type {string} */ systemSeparator = null; - + /** @type {Object} */ models = {}; - + /** @type {ModelDirectories} */ directories = null; - + constructor() { this.directories = new ModelDirectories(); } @@ -1185,34 +1185,34 @@ class ModelData { class DirectoryDropdown { /** @type {HTMLDivElement} */ element = null; - + /** @type {Boolean} */ showDirectoriesOnly = false; - + /** @type {HTMLInputElement} */ #input = null; - + /** @type {() => string} */ #getModelType = null; - + /** @type {ModelData} */ #modelData = null; // READ ONLY - + /** @type {() => void} */ #updateCallback = null; - + /** @type {() => Promise} */ #submitCallback = null; - + /** @type {string} */ #deepestPreviousPath = "/"; - + /** @type {Any} */ #touchSelectionStart = null; - + /** @type {() => Boolean} */ #isDynamicSearch = () => { return false; }; - + /** * @param {ModelData} modelData * @param {HTMLInputElement} input @@ -1237,7 +1237,7 @@ class DirectoryDropdown { this.#submitCallback = submitCallback; this.showDirectoriesOnly = showDirectoriesOnly; this.#isDynamicSearch = isDynamicSearch; - + input.addEventListener("input", async(e) => { const path = this.#updateOptions(); if (path !== undefined) { @@ -1401,7 +1401,7 @@ class DirectoryDropdown { }, ); } - + /** * @param {HTMLInputElement} input * @param {HTMLParagraphElement | undefined | null} selection @@ -1457,7 +1457,7 @@ class DirectoryDropdown { if (i1 !== -1) { name = name.substring(0, i1); } - + const dropdown = this.element; const options = dropdown.children; let iSelection; @@ -1615,7 +1615,7 @@ class ModelGrid { static modelWidgetIndex(nodeType) { return nodeType === undefined ? -1 : 0; } - + /** * @param {string} text * @param {string} file @@ -1630,7 +1630,7 @@ class ModelGrid { const sep = text.length === 0 || text.slice(-1).match(/\s/) ? "" : " "; return text + sep + "(embedding:" + name + ":1.0)"; } - + /** * @param {Array} list * @param {string} searchString @@ -1666,7 +1666,7 @@ class ModelGrid { }, true); }); } - + /** * In-place sort. Returns an array alias. * @param {Array} list @@ -1696,7 +1696,7 @@ class ModelGrid { const sorted = list.sort(compareFn); return reverse ? sorted.reverse() : sorted; } - + /** * @param {Event} event * @param {string} modelType @@ -1775,7 +1775,7 @@ class ModelGrid { if (modelType !== "embeddings" && target.id === "graph-canvas") { //const pos = app.canvas.convertEventToCanvasOffset(event); const pos = app.canvas.convertEventToCanvasOffset({ clientX: clientX, clientY: clientY }); - + const node = app.graph.getNodeOnPos(pos[0], pos[1], app.canvas.visible_nodes); let widgetIndex = -1; @@ -1835,7 +1835,7 @@ class ModelGrid { } } } - + /** * @param {Event} event * @param {string} modelType @@ -1870,7 +1870,7 @@ class ModelGrid { } comfyButtonAlert(event.target, success, "mdi-check-bold", "mdi-close-thick"); } - + /** * @param {Array} models * @param {string} modelType @@ -2001,7 +2001,7 @@ class ModelGrid { return [$el("h2", ["No Models"])]; } } - + /** * @param {HTMLDivElement} modelGrid * @param {ModelData} modelData @@ -2067,7 +2067,7 @@ class ModelGrid { class ModelInfo { /** @type {HTMLDivElement} */ element = null; - + elements = { /** @type {HTMLDivElement[]} */ tabButtons: null, /** @type {HTMLDivElement[]} */ tabContents: null, @@ -2076,16 +2076,16 @@ class ModelInfo { /** @type {HTMLButtonElement} */ setPreviewButton: null, /** @type {HTMLInputElement} */ moveDestinationInput: null, }; - + /** @type {ImageSelect} */ previewSelect = null; - + /** @type {string} */ #savedNotesValue = null; - + /** @type {[HTMLElement][]} */ #settingsElements = null; - + /** * @param {ModelData} modelData * @param {() => Promise} updateModels @@ -2100,17 +2100,17 @@ class ModelInfo { value: modelData.searchSeparator, }); this.elements.moveDestinationInput = moveDestinationInput; - + const searchDropdown = new DirectoryDropdown( modelData, moveDestinationInput, true, ); - + const previewSelect = new ImageSelect("model-info-preview-model-FYUIKMNVB"); this.previewSelect = previewSelect; previewSelect.elements.previews.style.display = "flex"; - + const setPreviewButton = new ComfyButton({ tooltip: "Overwrite currrent preview with selected image", content: "Set as Preview", @@ -2182,7 +2182,7 @@ class ModelInfo { previewSelect.elements.radioButtons.addEventListener("change", (e) => { setPreviewButton.style.display = previewSelect.defaultIsChecked() ? "none" : "block"; }); - + this.element = $el("div", { style: { display: "none" }, }, [ @@ -2294,7 +2294,7 @@ class ModelInfo { "data-path": "", }), ]); - + [this.elements.tabButtons, this.elements.tabContents] = GenerateTabGroup([ { name: "Overview", icon: "information-box-outline", tabContent: this.element }, { name: "Metadata", icon: "file-document-outline", tabContent: $el("div", ["Metadata"]) }, @@ -2302,13 +2302,13 @@ class ModelInfo { { name: "Notes", icon: "pencil-outline", tabContent: $el("div", ["Notes"]) }, ]); } - + /** @returns {void} */ show() { this.element.style = ""; this.element.scrollTop = 0; } - + /** * @param {boolean} promptUser * @returns {Promise} @@ -2317,7 +2317,7 @@ class ModelInfo { if (this.element.style.display === "none") { return true; } - + const noteValue = this.elements.notes.value; const savedNotesValue = this.#savedNotesValue; if (noteValue.trim() === savedNotesValue.trim()) { @@ -2345,7 +2345,7 @@ class ModelInfo { } return true; } - + /** * @param {boolean?} promptSave * @returns {Promise} @@ -2363,7 +2363,7 @@ class ModelInfo { this.element.style.display = "none"; return true; } - + /** * @param {string} searchPath * @param {() => Promise} updateModels @@ -2469,7 +2469,7 @@ class ModelInfo { ]), ); } - + const fileDirectory = info["File Directory"]; if (fileDirectory !== undefined && fileDirectory !== null && fileDirectory !== "") { this.elements.moveDestinationInput.placeholder = fileDirectory @@ -2479,7 +2479,7 @@ class ModelInfo { this.elements.moveDestinationInput.placeholder = searchSeparator; this.elements.moveDestinationInput.value = searchSeparator; } - + const previewSelect = this.previewSelect; const defaultUrl = previewSelect.elements.defaultUrl; if (info["Preview"]) { @@ -2493,7 +2493,7 @@ class ModelInfo { previewSelect.resetModelInfoPreview(); const setPreviewButton = this.elements.setPreviewButton; setPreviewButton.style.display = previewSelect.defaultIsChecked() ? "none" : "block"; - + innerHtml.push($el("div", [ previewSelect.elements.previews, $el("div.row.tab-header", [ @@ -2522,7 +2522,7 @@ class ModelInfo { if (value === undefined || value === null) { continue; } - + if (Array.isArray(value)) { // currently only used for "Bucket Resolutions" if (value.length > 0) { @@ -2556,7 +2556,7 @@ class ModelInfo { ])); infoHtml.append.apply(infoHtml, innerHtml); // TODO: set default value of dropdown and value to model type? - + /** @type {HTMLDivElement} */ const metadataElement = this.elements.tabContents[1]; // TODO: remove magic value const isMetadata = typeof metadata === 'object' && metadata !== null && Object.keys(metadata).length > 0; @@ -2584,7 +2584,7 @@ class ModelInfo { ]); const metadataButton = this.elements.tabButtons[1]; // TODO: remove magic value metadataButton.style.display = isMetadata ? "" : "none"; - + /** @type {HTMLDivElement} */ const tagsElement = this.elements.tabContents[2]; // TODO: remove magic value const isTags = Array.isArray(tags) && tags.length > 0; @@ -2676,10 +2676,10 @@ class ModelInfo { ]); const tagButton = this.elements.tabButtons[2]; // TODO: remove magic value tagButton.style.display = isTags ? "" : "none"; - + const saveIcon = "content-save"; const savingIcon = "cloud-upload-outline"; - + const saveNotesButton = new ComfyButton({ icon: saveIcon, tooltip: "Save note", @@ -2692,7 +2692,7 @@ class ModelInfo { button.disabled = false; }, }).element; - + const saveDebounce = debounce(async() => { const saveIconClass = "mdi-" + saveIcon; const savingIconClass = "mdi-" + savingIcon; @@ -2703,7 +2703,7 @@ class ModelInfo { iconElement.classList.remove(savingIconClass); iconElement.classList.add(saveIconClass); }, 1000); - + /** @type {HTMLDivElement} */ const notesElement = this.elements.tabContents[3]; // TODO: remove magic value notesElement.innerHTML = ""; @@ -2721,7 +2721,7 @@ class ModelInfo { } }, }); - + if (navigator.userAgent.includes("Mac")) { new KeyComboListener( ["MetaLeft", "KeyS"], @@ -2746,7 +2746,7 @@ class ModelInfo { notes, ); } - + this.elements.notes = notes; this.elements.markdown = markdown; this.#savedNotesValue = noteText; @@ -2767,7 +2767,7 @@ class ModelInfo { })() ); } - + static UniformTagSampling(tagsAndCounts, sampleCount, frequencyThreshold = 0) { const data = tagsAndCounts.filter(x => x[1] >= frequencyThreshold); let count = data.length; @@ -2782,7 +2782,7 @@ class ModelInfo { const sortedSamples = samples.sort((x1, x2) => { return parseInt(x2[1]) - parseInt(x1[1]) }); return sortedSamples.map(x => x[0]); } - + static ProbabilisticTagSampling(tagsAndCounts, sampleCount, frequencyThreshold = 0) { const data = tagsAndCounts.filter(x => x[1] >= frequencyThreshold); let tagFrequenciesSum = data.reduce((accumulator, x) => accumulator + x[1], 0); @@ -2832,7 +2832,7 @@ class Civitai { return {}; } } - + /** * Extract file information from the given model version information. * @@ -2849,21 +2849,21 @@ class Civitai { const modelVersionFiles = modelVersionInfo["files"]; for (let i = 0; i < modelVersionFiles.length; i++) { const modelVersionFile = modelVersionFiles[i]; - + const fileType = modelVersionFile["type"]; if (type instanceof String && type != fileType) { continue; } - + const fileMeta = modelVersionFile["metadata"]; - + const fileFp = fileMeta["fp"]; if (fp instanceof String && fp != fileFp) { continue; } - + const fileSize = fileMeta["size"]; if (size instanceof String && size != fileSize) { continue; } - + const fileFormat = fileMeta["format"]; if (format instanceof String && format != fileFormat) { continue; } - + files.push({ "downloadUrl": modelVersionFile["downloadUrl"], "format": fileFormat, @@ -2887,7 +2887,7 @@ class Civitai { "tags": modelVersionInfo["trainedWords"], }; } - + /** * @param {string} stringUrl - Model url. * @@ -2961,21 +2961,21 @@ class Civitai { return {}; } } - + /** * @returns {string} */ static imagePostUrlPrefix() { return "https://civitai.com/images/"; } - + /** * @returns {string} */ static imageUrlPrefix() { return "https://image.civitai.com/"; } - + /** * @param {string} stringUrl - https://civitai.com/images/{imageId}. * @@ -2998,7 +2998,7 @@ class Civitai { return {}; } } - + /** * @param {string} stringUrl - https://image.civitai.com/... * @@ -3054,9 +3054,9 @@ class HuggingFace { return {}; } } - + /** - * + * * * @param {string} stringUrl - Model url. * @@ -3080,13 +3080,13 @@ class HuggingFace { } const modelId = urlPath.substring(i0, i2); const urlPathEnd = urlPath.substring(i2); - + const isValidBranch = ( urlPathEnd.startsWith("/resolve") || urlPathEnd.startsWith("/blob") || urlPathEnd.startsWith("/tree") ); - + let branch = "/main"; let filePath = ""; if (isValidBranch) { @@ -3105,11 +3105,11 @@ class HuggingFace { } } } - + const modelInfo = await HuggingFace.requestInfo(modelId); //const modelInfo = await requestInfo(modelId + "/tree" + branch); // this only gives you the files at the given branch path... // oid: SHA-1?, lfs.oid: SHA-256 - + const clippedFilePath = filePath.substring(filePath[0] === "/" ? 1 : 0); const modelFiles = modelInfo["siblings"].filter((sib) => { const filename = sib["rfilename"]; @@ -3126,9 +3126,9 @@ class HuggingFace { if (modelFiles.length === 0) { return {}; } - + const baseDownloadUrl = url.origin + urlPath.substring(0, i2) + "/resolve" + branch; - + const images = modelInfo["siblings"].filter((sib) => { const filename = sib["rfilename"]; for (let i = 0; i < IMAGE_EXTENSIONS.length; i++) { @@ -3140,7 +3140,7 @@ class HuggingFace { }).map((sib) => { return baseDownloadUrl + "/" + sib["rfilename"]; }); - + return { "baseDownloadUrl": baseDownloadUrl, "modelFiles": modelFiles, @@ -3257,7 +3257,7 @@ async function getModelInfos(urlText) { class DownloadView { /** @type {HTMLDivElement} */ element = null; - + elements = { /** @type {HTMLInputElement} */ url: null, /** @type {HTMLDivElement} */ infos: null, @@ -3266,16 +3266,16 @@ class DownloadView { /** @type {HTMLButtonElement} */ searchButton: null, /** @type {HTMLButtonElement} */ clearSearchButton: null, }; - + /** @type {DOMParser} */ #domParser = null; - + /** @type {Object.} */ #settings = null; - + /** @type {() => Promise} */ #updateModels = () => {}; - + /** * @param {ModelData} modelData * @param {Object.} settings @@ -3291,7 +3291,7 @@ class DownloadView { $el("h1", ["Input a URL to select a model to download."]) ); }; - + const searchButton = new ComfyButton({ icon: "magnify", tooltip: "Search url", @@ -3317,7 +3317,7 @@ class DownloadView { searchButton.style.display = hideSearchButton ? "none" : ""; }); this.elements.searchButton = searchButton; - + const clearSearchButton = new ComfyButton({ icon: "close", tooltip: "Clear search", @@ -3333,7 +3333,7 @@ class DownloadView { clearSearchButton.style.display = hideClearButton ? "none" : ""; }); this.elements.clearSearchButton = clearSearchButton; - + $el("div.tab-header", { $: (el) => (this.element = el), }, [ @@ -3367,7 +3367,7 @@ class DownloadView { ]), ]); } - + /** * Tries to return the related ComfyUI model directory if unambiguous. * @@ -3381,7 +3381,7 @@ class DownloadView { const f = fileType.toLowerCase(); if (f == "diffusers") { return "diffusers"; } // TODO: is this correct? } - + if (modelType !== undefined && modelType !== null) { const m = modelType.toLowerCase(); // TODO: somehow allow for SERVER to set dir? @@ -3408,10 +3408,10 @@ class DownloadView { } return null; } - + /** * Returns empty string on failure - * @param {float | undefined} fileSizeKB + * @param {float | undefined} fileSizeKB * @returns {string} */ static #fileSizeToFormattedString(fileSizeKB) { @@ -3430,7 +3430,7 @@ class DownloadView { fileSizeString = fileSizeString.substring(0, fileSizeString.indexOf(".") + 3); return `(${fileSizeString} ${sizes[sizeIndex]})`; } - + /** * @param {Object} info * @param {ModelData} modelData @@ -3443,7 +3443,7 @@ class DownloadView { "model-download-info-preview-model" + "-" + id, info["images"], ); - + const comfyUIModelType = ( DownloadView.modelTypeToComfyUiDirectory(info["details"]["fileType"]) ?? DownloadView.modelTypeToComfyUiDirectory(info["modelType"]) ?? @@ -3451,7 +3451,7 @@ class DownloadView { ); const searchSeparator = modelData.searchSeparator; const defaultBasePath = searchSeparator + (comfyUIModelType === "" ? "" : comfyUIModelType + searchSeparator + "0"); - + const el_saveDirectoryPath = $el("input.search-text-area", { type: "text", name: "save directory", @@ -3464,7 +3464,7 @@ class DownloadView { el_saveDirectoryPath, true, ); - + const default_name = (() => { const filename = info["fileName"]; // TODO: only remove valid model file extensions @@ -3484,7 +3484,7 @@ class DownloadView { } }, }); - + const infoNotes = $el("textarea.comfy-multiline-input.model-info-notes", { name: "model info notes", value: info["description"]??"", @@ -3492,7 +3492,7 @@ class DownloadView { disabled: true, style: { display: info["description"] === undefined || info["description"] === "" ? "none" : "" }, }); - + const filepath = info["downloadFilePath"]; const modelInfo = $el("details.download-details", [ $el("summary", [filepath + info["fileName"]]), @@ -3571,7 +3571,7 @@ class DownloadView { ]), ]), ]); - + return modelInfo; } @@ -3601,7 +3601,7 @@ class DownloadView { if (modelInfosHtml.length === 1) { modelInfosHtml[0].open = true; } - + const header = $el("div", [ $el("h1", [name]), $el("div.model-manager-settings", [ @@ -3623,7 +3623,7 @@ class DownloadView { const infosHtml = this.elements.infos; infosHtml.innerHTML = ""; infosHtml.append.apply(infosHtml, modelInfosHtml); - + const downloadNotes = this.elements.downloadNotes; if (downloadNotes !== undefined && downloadNotes !== null) { downloadNotes.addEventListener("change", (e) => { @@ -3636,10 +3636,10 @@ class DownloadView { downloadNotes.checked = settings["download-save-description-as-text-file"].checked; downloadNotes.dispatchEvent(new Event('change')); } - + const hideSearchButtons = settings["text-input-always-hide-search-button"].checked; this.elements.searchButton.style.display = hideSearchButtons ? "none" : ""; - + const hideClearSearchButtons = settings["text-input-always-hide-clear-button"].checked; this.elements.clearSearchButton.style.display = hideClearSearchButtons ? "none" : ""; } @@ -3648,7 +3648,7 @@ class DownloadView { class BrowseView { /** @type {HTMLDivElement} */ element = null; - + elements = { /** @type {HTMLDivElement} */ modelGrid: null, /** @type {HTMLSelectElement} */ modelTypeSelect: null, @@ -3657,28 +3657,28 @@ class BrowseView { /** @type {HTMLButtonElement} */ searchButton: null, /** @type {HTMLButtonElement} */ clearSearchButton: null, }; - + /** @type {Array} */ previousModelFilters = []; - + /** @type {Object.<{value: string}>} */ previousModelType = { value: null }; - + /** @type {DirectoryDropdown} */ directoryDropdown = null; - + /** @type {ModelData} */ #modelData = null; - + /** @type {@param {() => Promise}} */ #updateModels = null; - + /** */ #settingsElements = null; - + /** @type {() => void} */ updateModelGrid = () => {}; - + /** * @param {() => Promise} updateModels * @param {ModelData} modelData @@ -3690,11 +3690,11 @@ class BrowseView { /** @type {HTMLDivElement} */ const modelGrid = $el("div.comfy-grid"); this.elements.modelGrid = modelGrid; - + this.#updateModels = updateModels; this.#modelData = modelData; this.#settingsElements = settingsElements; - + const searchInput = $el("input.search-text-area", { $: (el) => (this.elements.modelContentFilter = el), type: "text", @@ -3702,13 +3702,13 @@ class BrowseView { autocomplete: "off", placeholder: "/Search", }); - + const updatePreviousModelFilter = () => { const modelType = this.elements.modelTypeSelect.value; const value = this.elements.modelContentFilter.value; this.previousModelFilters[modelType] = value; }; - + const updateModelGrid = () => { const sortValue = this.elements.modelSortSelect.value; const reverseSort = sortValue[0] === "-"; @@ -3726,18 +3726,18 @@ class BrowseView { showModelInfo, ); updateModelGridCallback(); - + const hideSearchButtons = ( this.#settingsElements["model-real-time-search"].checked | this.#settingsElements["text-input-always-hide-search-button"].checked ); this.elements.searchButton.style.display = hideSearchButtons ? "none" : ""; - + const hideClearSearchButtons = this.#settingsElements["text-input-always-hide-clear-button"].checked; this.elements.clearSearchButton.style.display = hideClearSearchButtons ? "none" : ""; } this.updateModelGrid = updateModelGrid; - + const searchDropdown = new DirectoryDropdown( modelData, searchInput, @@ -3748,7 +3748,7 @@ class BrowseView { () => { return this.#settingsElements["model-real-time-search"].checked; }, ); this.directoryDropdown = searchDropdown; - + const searchButton = new ComfyButton({ icon: "magnify", tooltip: "Search models", @@ -3776,7 +3776,7 @@ class BrowseView { searchButton.style.display = hideSearchButton ? "none" : ""; }); this.elements.searchButton = searchButton; - + const clearSearchButton = new ComfyButton({ icon: "close", tooltip: "Clear search", @@ -3795,7 +3795,7 @@ class BrowseView { clearSearchButton.style.display = hideClearSearchButton ? "none" : ""; }); this.elements.clearSearchButton = clearSearchButton; - + this.element = $el("div", [ $el("div.row.tab-header", [ $el("div.row.tab-header-flex-block", [ @@ -3860,7 +3860,7 @@ class BrowseView { class SettingsView { /** @type {HTMLDivElement} */ element = null; - + elements = { /** @type {HTMLButtonElement} */ reloadButton: null, /** @type {HTMLButtonElement} */ saveButton: null, @@ -3870,7 +3870,7 @@ class SettingsView { /** @type {HTMLInputElement} */ "model-default-browser-model-type": null, /** @type {HTMLInputElement} */ "model-real-time-search": null, /** @type {HTMLInputElement} */ "model-persistent-search": null, - + /** @type {HTMLInputElement} */ "model-preview-thumbnail-type": null, /** @type {HTMLInputElement} */ "model-preview-fallback-search-safetensors-thumbnail": null, /** @type {HTMLInputElement} */ "model-show-label-extensions": null, @@ -3878,33 +3878,33 @@ class SettingsView { /** @type {HTMLInputElement} */ "model-show-copy-button": null, /** @type {HTMLInputElement} */ "model-show-load-workflow-button": null, /** @type {HTMLInputElement} */ "model-info-button-on-left": null, - + /** @type {HTMLInputElement} */ "model-add-embedding-extension": null, /** @type {HTMLInputElement} */ "model-add-drag-strict-on-field": null, /** @type {HTMLInputElement} */ "model-add-offset": null, - + /** @type {HTMLInputElement} */ "model-info-autosave-notes": null, - + /** @type {HTMLInputElement} */ "download-save-description-as-text-file": null, - + /** @type {HTMLInputElement} */ "sidebar-default-width": null, /** @type {HTMLInputElement} */ "sidebar-default-height": null, /** @type {HTMLInputElement} */ "sidebar-control-always-compact": null, /** @type {HTMLInputElement} */ "text-input-always-hide-search-button": null, /** @type {HTMLInputElement} */ "text-input-always-hide-clear-button": null, - + /** @type {HTMLInputElement} */ "tag-generator-sampler-method": null, /** @type {HTMLInputElement} */ "tag-generator-count": null, /** @type {HTMLInputElement} */ "tag-generator-threshold": null, }, }; - + /** @return {() => Promise} */ #updateModels = () => {}; - + /** - * @param {Object} settingsData - * @param {boolean} updateModels + * @param {Object} settingsData + * @param {boolean} updateModels */ async #setSettings(settingsData, updateModels) { const settings = this.elements.settings; @@ -3928,7 +3928,7 @@ class SettingsView { await this.#updateModels(); // Is this slow? } } - + /** * @param {boolean} updateModels * @returns {Promise} @@ -3939,7 +3939,7 @@ class SettingsView { await this.#setSettings(settingsData, updateModels); comfyButtonAlert(this.elements.reloadButton, true); } - + /** @returns {Promise} */ async save() { let settingsData = {}; @@ -3957,7 +3957,7 @@ class SettingsView { } settingsData[setting] = value; } - + const data = await comfyRequest( "/model-manager/settings/save", { @@ -3974,7 +3974,7 @@ class SettingsView { } comfyButtonAlert(this.elements.saveButton, success); } - + /** * @param {() => Promise} updateModels * @param {() => void} updateSidebarButtons @@ -3982,7 +3982,7 @@ class SettingsView { constructor(updateModels, updateSidebarButtons) { this.#updateModels = updateModels; const settings = this.elements.settings; - + const sidebarControl = $checkbox({ $: (el) => (settings["sidebar-control-always-compact"] = el), textContent: "Sidebar controls always compact", @@ -3990,7 +3990,7 @@ class SettingsView { sidebarControl.getElementsByTagName('input')[0].addEventListener("change", () => { updateSidebarButtons(); }); - + const reloadButton = new ComfyButton({ content: "Reload", tooltip: "Reload settings and model manager files", @@ -4002,7 +4002,7 @@ class SettingsView { }, }).element; this.elements.reloadButton = reloadButton; - + const saveButton = new ComfyButton({ content: "Save", tooltip: "Save settings and reload model manager", @@ -4014,7 +4014,7 @@ class SettingsView { }, }).element; this.elements.saveButton = saveButton; - + const correctPreviewsButton = new ComfyButton({ content: "Fix Extensions", tooltip: "Correct image file extensions in all model directories", @@ -4040,7 +4040,7 @@ class SettingsView { button.disabled = false; }, }).element; - + $el("div.model-manager-settings", { $: (el) => (this.element = el), }, [ @@ -4289,7 +4289,7 @@ function GenerateSidebarToggleRadioAndSelect(labels, activationCallbacks = []) { const RADIO_BUTTON_GROUP_ACTIVE = "radio-button-group-active"; const radioButtonGroup = $el("div.radio-button-group", []); const buttons = []; - + const select = $el("select", { name: "sidebar-select", onchange: (event) => { @@ -4320,7 +4320,7 @@ function GenerateSidebarToggleRadioAndSelect(labels, activationCallbacks = []) { }, option); }) ); - + for (let i = 0; i < labels.length; i++) { const text = labels[i]; const activationCallback = activationCallbacks[i] ?? (() => {}); @@ -4363,72 +4363,72 @@ function GenerateSidebarToggleRadioAndSelect(labels, activationCallbacks = []) { radioButtonGroup.append.apply(radioButtonGroup, buttons); buttons[0].click(); buttons[0].style.display = "none"; - + return [radioButtonGroup, select]; } class ModelManager extends ComfyDialog { /** @type {HTMLDivElement} */ element = null; - + /** @type {ModelData} */ #modelData = null; - + /** @type {ModelInfo} */ #modelInfo = null; - + /** @type {DownloadView} */ #downloadView = null; - + /** @type {BrowseView} */ #browseView = null; - + /** @type {SettingsView} */ #settingsView = null; - + /** @type {HTMLDivElement} */ #topbarRight = null; - + /** @type {HTMLDivElement} */ #tabManagerButtons = null; - + /** @type {HTMLDivElement} */ #tabManagerContents = null; - + /** @type {HTMLDivElement} */ #tabInfoButtons = null; - + /** @type {HTMLDivElement} */ #tabInfoContents = null; - + /** @type {HTMLButtonElement} */ #sidebarButtonGroup = null; - + /** @type {HTMLButtonElement} */ #sidebarSelect = null; - + /** @type {HTMLButtonElement} */ #closeModelInfoButton = null; - + /** @type {String} */ #dragSidebarState = ""; - + constructor() { super(); - + this.#modelData = new ModelData(); - + this.#settingsView = new SettingsView( this.#refreshModels, () => this.#updateSidebarButtons(), ); - + this.#modelInfo = new ModelInfo( this.#modelData, this.#refreshModels, this.#settingsView.elements.settings, ); - + this.#browseView = new BrowseView( this.#refreshModels, this.#modelData, @@ -4436,27 +4436,27 @@ class ModelManager extends ComfyDialog { this.#resetManagerContentsScroll, this.#settingsView.elements.settings, // TODO: decouple settingsData from elements? ); - + this.#downloadView = new DownloadView( this.#modelData, this.#settingsView.elements.settings, this.#refreshModels, ); - + const [tabManagerButtons, tabManagerContents] = GenerateTabGroup([ { name: "Download", icon: "arrow-collapse-down", tabContent: this.#downloadView.element }, { name: "Models", icon: "folder-search-outline", tabContent: this.#browseView.element }, { name: "Settings", icon: "cog-outline", tabContent: this.#settingsView.element }, ]); tabManagerButtons[0]?.click(); - + const tabInfoButtons = this.#modelInfo.elements.tabButtons; const tabInfoContents = this.#modelInfo.elements.tabContents; - + const [sidebarButtonGroup, sidebarSelect] = GenerateSidebarToggleRadioAndSelect( ["◼", "◨", "⬒", "⬓", "◧"], [ - () => { + () => { const element = this.element; if (element) { // callback on initialization as default state element.dataset["sidebarState"] = "none"; @@ -4475,7 +4475,7 @@ class ModelManager extends ComfyDialog { for (let i = 0; i < sidebarButtonGroupChildren.length; i++) { sidebarButtonGroupChildren[i].classList.add("icon-button"); } - + const closeModelInfoButton = new ComfyButton({ icon: "arrow-u-left-bottom", tooltip: "Return to model search", @@ -4484,7 +4484,7 @@ class ModelManager extends ComfyDialog { }).element; this.#closeModelInfoButton = closeModelInfoButton; closeModelInfoButton.style.display = "none"; - + const modelManager = $el( "div.comfy-modal.model-manager", { @@ -4545,36 +4545,36 @@ class ModelManager extends ComfyDialog { ]), ] ); - + new ResizeObserver(GenerateDynamicTabTextCallback(modelManager, tabManagerButtons, 704)).observe(modelManager); new ResizeObserver(GenerateDynamicTabTextCallback(modelManager, tabInfoButtons, 704)).observe(modelManager); new ResizeObserver(() => this.#updateSidebarButtons()).observe(modelManager); window.addEventListener('resize', () => { const width = window.innerWidth; const height = window.innerHeight; - + const leftDecimal = modelManager.dataset["sidebarLeftWidthDecimal"]; const rightDecimal = modelManager.dataset["sidebarRightWidthDecimal"]; const topDecimal = modelManager.dataset["sidebarTopHeightDecimal"]; const bottomDecimal = modelManager.dataset["sidebarBottomHeightDecimal"]; - + // restore decimal after resize modelManager.style.setProperty("--model-manager-sidebar-width-left", (leftDecimal * width) + "px"); modelManager.style.setProperty("--model-manager-sidebar-width-right", (rightDecimal * width) + "px"); modelManager.style.setProperty("--model-manager-sidebar-height-top", + (topDecimal * height) + "px"); modelManager.style.setProperty("--model-manager-sidebar-height-bottom", (bottomDecimal * height) + "px"); }); - + const EDGE_DELTA = 8; - + const endDragSidebar = (e) => { this.#dragSidebarState = ""; - + modelManager.classList.remove("cursor-drag-left"); modelManager.classList.remove("cursor-drag-top"); modelManager.classList.remove("cursor-drag-right"); modelManager.classList.remove("cursor-drag-bottom"); - + // cache for window resize modelManager.dataset["sidebarLeftWidthDecimal"] = parseInt(modelManager.style.getPropertyValue("--model-manager-sidebar-width-left")) / window.innerWidth; modelManager.dataset["sidebarRightWidthDecimal"] = parseInt(modelManager.style.getPropertyValue("--model-manager-sidebar-width-right")) / window.innerWidth; @@ -4583,7 +4583,7 @@ class ModelManager extends ComfyDialog { }; document.addEventListener("mouseup", (e) => endDragSidebar(e)); document.addEventListener("touchend", (e) => endDragSidebar(e)); - + const detectDragSidebar = (e, x, y) => { const left = modelManager.offsetLeft; const top = modelManager.offsetTop; @@ -4591,17 +4591,17 @@ class ModelManager extends ComfyDialog { const height = modelManager.offsetHeight; const right = left + width; const bottom = top + height; - + if (!(x >= left && x <= right && y >= top && y <= bottom)) { // click was not in model manager return; } - + const isOnEdgeLeft = x - left <= EDGE_DELTA; const isOnEdgeRight = right - x <= EDGE_DELTA; const isOnEdgeTop = y - top <= EDGE_DELTA; const isOnEdgeBottom = bottom - y <= EDGE_DELTA; - + const sidebarState = this.element.dataset["sidebarState"]; if (sidebarState === "left" && isOnEdgeRight) { this.#dragSidebarState = sidebarState; @@ -4615,7 +4615,7 @@ class ModelManager extends ComfyDialog { else if (sidebarState === "bottom" && isOnEdgeTop) { this.#dragSidebarState = sidebarState; } - + if (this.#dragSidebarState !== "") { e.preventDefault(); e.stopPropagation(); @@ -4623,25 +4623,25 @@ class ModelManager extends ComfyDialog { }; modelManager.addEventListener("mousedown", (e) => detectDragSidebar(e, e.clientX, e.clientY)); modelManager.addEventListener("touchstart", (e) => detectDragSidebar(e, e.touches[0].clientX, e.touches[0].clientY)); - + const updateSidebarCursor = (e, x, y) => { if (this.#dragSidebarState !== "") { // do not update cursor style while dragging return; } - + const left = modelManager.offsetLeft; const top = modelManager.offsetTop; const width = modelManager.offsetWidth; const height = modelManager.offsetHeight; const right = left + width; const bottom = top + height; - + const isOnEdgeLeft = x - left <= EDGE_DELTA; const isOnEdgeRight = right - x <= EDGE_DELTA; const isOnEdgeTop = y - top <= EDGE_DELTA; const isOnEdgeBottom = bottom - y <= EDGE_DELTA; - + const updateClass = (add, className) => { if (add) { modelManager.classList.add(className); @@ -4650,7 +4650,7 @@ class ModelManager extends ComfyDialog { modelManager.classList.remove(className); } }; - + const sidebarState = this.element.dataset["sidebarState"]; updateClass(sidebarState === "right" && isOnEdgeLeft, "cursor-drag-left"); updateClass(sidebarState === "bottom" && isOnEdgeTop, "cursor-drag-top"); @@ -4659,18 +4659,18 @@ class ModelManager extends ComfyDialog { }; modelManager.addEventListener("mousemove", (e) => updateSidebarCursor(e, e.clientX, e.clientY)); modelManager.addEventListener("touchmove", (e) => updateSidebarCursor(e, e.touches[0].clientX, e.touches[0].clientY)); - + const updateDragSidebar = (e, x, y) => { const sidebarState = this.#dragSidebarState; if (sidebarState === "") { return; } - + e.preventDefault(); - + const width = window.innerWidth; const height = window.innerHeight; - + if (sidebarState === "left") { const pixels = clamp(x, 0, width).toString() + "px"; modelManager.style.setProperty("--model-manager-sidebar-width-left", pixels); @@ -4690,16 +4690,16 @@ class ModelManager extends ComfyDialog { }; document.addEventListener("mousemove", (e) => updateDragSidebar(e, e.clientX, e.clientY)); document.addEventListener("touchmove", (e) => updateDragSidebar(e, e.touches[0].clientX, e.touches[0].clientY)); - + this.#init(); } - + async #init() { await this.#settingsView.reload(false); await this.#refreshModels(); - + const settings = this.#settingsView.elements.settings; - + { // initialize buttons' visibility state const hideSearchButtons = settings["text-input-always-hide-search-button"].checked; @@ -4707,41 +4707,41 @@ class ModelManager extends ComfyDialog { this.#downloadView.elements.searchButton.style.display = hideSearchButtons ? "none" : ""; this.#downloadView.elements.clearSearchButton.style.display = hideClearSearchButtons ? "none" : ""; } - + { // set initial sidebar widths & heights const width = window.innerWidth; const height = window.innerHeight; - + const xDecimal = settings["sidebar-default-width"].value; const yDecimal = settings["sidebar-default-height"].value; - + this.element.dataset["sidebarLeftWidthDecimal"] = xDecimal; this.element.dataset["sidebarRightWidthDecimal"] = xDecimal; this.element.dataset["sidebarTopHeightDecimal"] = yDecimal; this.element.dataset["sidebarBottomHeightDecimal"] = yDecimal; - + const x = Math.floor(width * xDecimal); const y = Math.floor(height * yDecimal); - + const leftPixels = x.toString() + "px"; this.element.style.setProperty("--model-manager-sidebar-width-left", leftPixels); - + const rightPixels = x.toString() + "px"; this.element.style.setProperty("--model-manager-sidebar-width-right", rightPixels); - + const topPixels = y.toString() + "px"; this.element.style.setProperty("--model-manager-sidebar-height-top", topPixels); - + const bottomPixels = y.toString() + "px"; this.element.style.setProperty("--model-manager-sidebar-height-bottom", bottomPixels); } } - + #resetManagerContentsScroll = () => { this.#tabManagerContents.scrollTop = 0; } - + #refreshModels = async() => { const modelData = this.#modelData; modelData.systemSeparator = await comfyRequest("/model-manager/system-separator"); @@ -4749,38 +4749,38 @@ class ModelManager extends ComfyDialog { Object.assign(modelData.models, newModels); // NOTE: do NOT create a new object const newModelDirectories = await comfyRequest("/model-manager/models/directory-list"); modelData.directories.data.splice(0, Infinity, ...newModelDirectories); // NOTE: do NOT create a new array - + this.#browseView.updateModelGrid(); await this.#tryHideModelInfo(false); - + document.getElementById("comfy-refresh-button")?.click(); } - + /** * @param {searchPath: string} * @return {Promise} */ #showModelInfo = async(searchPath) => { await this.#modelInfo.update( - searchPath, - this.#refreshModels, - this.#modelData.searchSeparator, + searchPath, + this.#refreshModels, + this.#modelData.searchSeparator, ).then(() => { this.#tabManagerButtons.style.display = "none"; this.#tabManagerContents.style.display = "none"; - + this.#closeModelInfoButton.style.display = ""; this.#tabInfoButtons.style.display = ""; this.#tabInfoContents.style.display = ""; - + this.#tabInfoButtons.children[0]?.click(); this.#modelInfo.show(); this.#tabInfoContents.scrollTop = 0; }); } - + /** - * @param {boolean} promptSave + * @param {boolean} promptSave * @returns {Promise} */ #tryHideModelInfo = async(promptSave) => { @@ -4788,17 +4788,17 @@ class ModelManager extends ComfyDialog { if (!await this.#modelInfo.tryHide(promptSave)) { return false; } - + this.#closeModelInfoButton.style.display = "none"; this.#tabInfoButtons.style.display = "none"; this.#tabInfoContents.style.display = "none"; - + this.#tabManagerButtons.style.display = ""; this.#tabManagerContents.style.display = ""; } return true; } - + #updateSidebarButtons = () => { const managerRect = this.element.getBoundingClientRect(); const isNarrow = managerRect.width < 768; // TODO: `minWidth` is a magic value @@ -4848,7 +4848,7 @@ app.registerExtension({ rel: "stylesheet", href: "./extensions/ComfyUI-Model-Manager/model-manager.css", }); - + app.ui?.menuContainer?.appendChild( $el("button", { id: "comfyui-model-manager-button", @@ -4857,7 +4857,7 @@ app.registerExtension({ onclick: () => toggleModelManager(), }) ); - + // [Beta] mobile menu app.menu?.settingsGroup?.append(new ComfyButton({ icon: "folder-search", From 1a52cf50d55115a155127808e37cd60cdd0b1f75 Mon Sep 17 00:00:00 2001 From: korutech-ai Date: Wed, 28 Aug 2024 10:13:11 +1200 Subject: [PATCH 6/9] LINTING: - Added eslint. - Cleaned up lexical declarations in case clauses. --- .vscode/settings.json | 19 +++++++++++++++++++ web/eslint.config.mjs | 8 ++++++++ web/model-manager.js | 8 +++++--- 3 files changed, 32 insertions(+), 3 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 web/eslint.config.mjs diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..9fbad55 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,19 @@ +{ + "cSpell.words": [ + "apng", + "Civitai", + "ckpt", + "comfyui", + "FYUIKMNVB", + "gguf", + "gligen", + "jfif", + "locon", + "loras", + "noimage", + "onnx", + "rfilename", + "unet", + "upscaler" + ] +} \ No newline at end of file diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs new file mode 100644 index 0000000..2edbef8 --- /dev/null +++ b/web/eslint.config.mjs @@ -0,0 +1,8 @@ +import globals from "globals"; +import pluginJs from "@eslint/js"; + + +export default [ + {languageOptions: { globals: globals.browser }}, + pluginJs.configs.recommended, +]; \ No newline at end of file diff --git a/web/model-manager.js b/web/model-manager.js index 498b098..469c14a 100644 --- a/web/model-manager.js +++ b/web/model-manager.js @@ -606,7 +606,7 @@ class ImageSelect { const value = document.querySelector(`input[name="${name}"]:checked`).value; const elements = this.elements; switch (value) { - case this.#PREVIEW_DEFAULT: + case this.#PREVIEW_DEFAULT: { const children = elements.defaultPreviews.children; const noImage = PREVIEW_NONE_URI; let url = ""; @@ -626,7 +626,8 @@ class ImageSelect { }); } return url; - case this.#PREVIEW_URL: + } + case this.#PREVIEW_URL: { const value = elements.customUrl.value; if (value.startsWith(Civitai.imagePostUrlPrefix())) { try { @@ -644,6 +645,7 @@ class ImageSelect { } } return value; + } case this.#PREVIEW_UPLOAD: return elements.uploadFile.files[0] ?? ""; case this.#PREVIEW_NONE: @@ -2112,7 +2114,7 @@ class ModelInfo { previewSelect.elements.previews.style.display = "flex"; const setPreviewButton = new ComfyButton({ - tooltip: "Overwrite currrent preview with selected image", + tooltip: "Overwrite current preview with selected image", content: "Set as Preview", action: async(e) => { const [button, icon, span] = comfyButtonDisambiguate(e.target); From 3943034a18e3338d2a81ed26f6e2697ed3a052f3 Mon Sep 17 00:00:00 2001 From: korutech-ai Date: Wed, 28 Aug 2024 11:18:53 +1200 Subject: [PATCH 7/9] UPDATE: Added a notes editing toggle button to improve notes tab layout. --- web/model-manager.js | 46 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 39 insertions(+), 7 deletions(-) diff --git a/web/model-manager.js b/web/model-manager.js index 469c14a..bc7b4dd 100644 --- a/web/model-manager.js +++ b/web/model-manager.js @@ -2716,7 +2716,7 @@ class ModelInfo { (() => { const notes = $el("textarea.comfy-multiline-input", { name: "model notes", - value: noteText, + value: noteText, oninput: (e) => { if (this.#settingsElements["model-info-autosave-notes"].checked) { saveDebounce(); @@ -2752,19 +2752,51 @@ class ModelInfo { this.elements.notes = notes; this.elements.markdown = markdown; this.#savedNotesValue = noteText; + + const notes_editor = $el( + "div", + { + style: { + "display": noteText == "" ? "flex" : "none", + "height": "100%", + "min-height": "60px" + }, + }, + notes + ); + const notes_viewer = $el( + "div", + { + style: { + "display": noteText == "" ? "none" : "flex", + "height": "100%", + "min-height": "60px", + "overflow": "scroll" + }, + }, + markdown + ); + + const editNotesButton = new ComfyButton({ + icon: "pencil", + tooltip: "Change file name", + classList: "comfyui-button icon-button", + action: async () => { + notes_editor.style.display = notes_editor.style.display == "flex" ? "none" : "flex"; + notes_viewer.style.display = notes_viewer.style.display == "none" ? "flex" : "none"; + }, + }).element; + return [ $el("div.row", { style: { "align-items": "center" }, }, [ $el("h1", ["Notes"]), saveNotesButton, + editNotesButton, ]), - $el("div", { - style: { "display": "block", "height": "20%", "min-height": "120px", "z-index": "1" }, - }, notes), - $el("div", { - style: { "display": "block", "height": "70%", "min-height": "60px", "overflow": "scroll" }, - }, markdown), + notes_editor, + notes_viewer, ]; })() ); From 6a3eed6157f3066415d3ff5d738ecbb6278879c3 Mon Sep 17 00:00:00 2001 From: korutech-ai Date: Wed, 28 Aug 2024 11:52:04 +1200 Subject: [PATCH 8/9] BUGFIX: Modified layout of tags tab so elements don't overlap. --- web/model-manager.js | 83 +++++++++++++++++++++++++------------------- 1 file changed, 47 insertions(+), 36 deletions(-) diff --git a/web/model-manager.js b/web/model-manager.js index bc7b4dd..40a66bc 100644 --- a/web/model-manager.js +++ b/web/model-manager.js @@ -2635,46 +2635,57 @@ class ModelInfo { break; } } + const tagGenerator = $el( + "div", [ + $el("h1", ["Tags"]), + $el("h2", { style: { margin: "0px 0px 16px 0px" } }, ["Random Tag Generator"]), + $el("div", [ + $el("details.tag-generator-settings", { + style: { margin: "10px 0", display: "none" }, + open: false, + }, [ + $el("summary", ["Settings"]), + $el("div", [ + "Sampling Method", + samplerRadioGroup, + ]), + $el("label", [ + "Count", + tagGenerationCount, + ]), + $el("label", [ + "Threshold", + tagGenerationThreshold, + ]), + ]), + tagGeneratorRandomizedOutput, + new ComfyButton({ + content: "Randomize", + tooltip: "Randomly generate subset of tags", + action: () => { + const samplerName = document.querySelector(`input[name="${TAG_GENERATOR_SAMPLER_NAME}"]:checked`).value; + const sampler = samplerName === "Frequency" ? ModelInfo.ProbabilisticTagSampling : ModelInfo.UniformTagSampling; + const sampleCount = tagGenerationCount.value; + const frequencyThreshold = tagGenerationThreshold.value; + const tags = ParseTagParagraph(tagsParagraph.innerText); + const sampledTags = sampler(tags, sampleCount, frequencyThreshold); + tagGeneratorRandomizedOutput.value = sampledTags.join(", "); + }, + }).element, + ]), + ] + ) tagsElement.innerHTML = ""; tagsElement.append.apply(tagsElement, [ - $el("h1", ["Tags"]), - $el("h2", { style: { margin: "0px 0px 16px 0px" } }, ["Random Tag Generator"]), + tagGenerator, $el("div", [ - $el("details.tag-generator-settings", { - style: { margin: "10px 0", display: "none" }, - open: false, - }, [ - $el("summary", ["Settings"]), - $el("div", [ - "Sampling Method", - samplerRadioGroup, - ]), - $el("label", [ - "Count", - tagGenerationCount, - ]), - $el("label", [ - "Threshold", - tagGenerationThreshold, - ]), - ]), - tagGeneratorRandomizedOutput, - new ComfyButton({ - content: "Randomize", - tooltip: "Randomly generate subset of tags", - action: () => { - const samplerName = document.querySelector(`input[name="${TAG_GENERATOR_SAMPLER_NAME}"]:checked`).value; - const sampler = samplerName === "Frequency" ? ModelInfo.ProbabilisticTagSampling : ModelInfo.UniformTagSampling; - const sampleCount = tagGenerationCount.value; - const frequencyThreshold = tagGenerationThreshold.value; - const tags = ParseTagParagraph(tagsParagraph.innerText); - const sampledTags = sampler(tags, sampleCount, frequencyThreshold); - tagGeneratorRandomizedOutput.value = sampledTags.join(", "); - }, - }).element, + $el("h2", { + style: { + margin: "24px 0px 8px 0px" + } + }, ["Tags"]), + tagsParagraph, ]), - $el("h2", {style: { margin: "24px 0px 8px 0px" } }, ["Training Tags"]), - tagsParagraph, ]); const tagButton = this.elements.tabButtons[2]; // TODO: remove magic value tagButton.style.display = isTags ? "" : "none"; From 89b95405c854f5d706cba2773b18c6ddda70f646 Mon Sep 17 00:00:00 2001 From: korutech-ai Date: Thu, 29 Aug 2024 08:02:01 +1200 Subject: [PATCH 9/9] CSS: Added overflow-wrap to improve text readability. --- web/model-manager.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/model-manager.js b/web/model-manager.js index 40a66bc..81fae94 100644 --- a/web/model-manager.js +++ b/web/model-manager.js @@ -2782,7 +2782,8 @@ class ModelInfo { "display": noteText == "" ? "none" : "flex", "height": "100%", "min-height": "60px", - "overflow": "scroll" + "overflow": "scroll", + "overflow-wrap": "anywhere" }, }, markdown