Files
ausboss 1051bee665 Workflow Note: wrap the hint under a model file
The line under a file name was cut off with an ellipsis. It now wraps,
and the note grows to fit as it already does for other content.
2026-10-24 10:13:10 -05:00

1036 lines
46 KiB
JavaScript

// Workflow Note 🆎 — the card that makes a shared workflow self-explanatory.
//
// One hidden STRING widget holds the card as JSON (title, subtitle, author,
// Markdown body, model rows, node-pack rows, links). This file renders it
// on the node face, checks every model file and node pack against this
// install, and opens the editor dialog. All parsing and the model match
// live in js/shared/workflow_note.mjs under node:test.
//
// Safety: a downloaded workflow is someone else's text. Nothing here goes
// through innerHTML - every string becomes a text node - and only http(s)
// URLs become buttons or links (the parser already filtered them).
import { api } from "/scripts/api.js";
import { app } from "/scripts/app.js";
import { BRAND, chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "../shared/index.mjs";
import { collectGraphNodes } from "../shared/appearance.mjs";
import { copyToClipboard } from "../shared/clipboard.mjs";
import { confirmDiscard } from "../shared/discard_prompt.mjs";
import { WIDGET_FRAME, fillNodeHeight, holdNodeMinHeight } from "../shared/panel_layout.mjs";
import { hideInputsInDef, hideWidget } from "../shared/widget_visibility.mjs";
import {
COMMON_FOLDERS,
INPUT_FOLDER,
LAYOUTS,
MODEL_COLUMNS,
groupModels,
hostLabel,
listedSpelling,
loadersNeeding,
loraRowsNeeding,
matchInstalled,
noteFit,
noteIsEmpty,
normalizeNote,
packsFromGraph,
parseMarkdown,
rowFromCells,
serializeNote,
subfolderOf,
} from "../shared/workflow_note.mjs";
import { comboValues, commitWidgetValue } from "../shared/widget_card_math.mjs";
import { parseRows, serializeRows } from "../shared/lora_stack.mjs";
const NODE_CLASS = "AUSBOSS_NODES_WorkflowNote";
const CSS_ID = "ausboss-workflow-note-css";
const WIDGET_NAME = "ausboss_workflow_note_panel";
const PANEL_MIN_WIDTH = 300;
const CARD_MIN_HEIGHT = 160;
const BANNER_MIN_HEIGHT = 64;
const DEFAULT_SIZE = [520, 620];
const FOLDER_CACHE_MS = 30000;
// The classic canvas lays a node's first widget 2 px below its title. Only
// used before the node has been laid out once; after that the widget's own
// position is read.
const PANEL_TOP = 2;
// ---------------------------------------------------------------------------
// Server lookups, cached across every note on the canvas
// ---------------------------------------------------------------------------
const folderLists = new Map(); // dir -> { at, promise }
let modelFolders = null; // Promise<string[]> from GET /models
function listFolder(dir, fresh = false) {
const cached = folderLists.get(dir);
if (!fresh && cached && performance.now() - cached.at < FOLDER_CACHE_MS) return cached.promise;
const promise = api
.fetchApi(`/models/${encodeURIComponent(dir)}`)
.then((response) => (response.ok ? response.json() : null))
.then((files) => (Array.isArray(files) ? files : null))
.catch(() => null);
folderLists.set(dir, { at: performance.now(), promise });
return promise;
}
function inputExists(name) {
const query = new URLSearchParams({ filename: name, type: "input" });
return api
.fetchApi(`/view?${query}`, { method: "HEAD" })
.then((response) => response.ok)
.catch(() => false);
}
function knownFolders() {
modelFolders ??= api
.fetchApi("/models")
.then((response) => (response.ok ? response.json() : []))
.then((list) => (Array.isArray(list) ? list.filter((item) => typeof item === "string") : []))
.catch(() => []);
return modelFolders;
}
function nodeTypeRegistered(type) {
return Boolean(type && globalThis.LiteGraph?.registered_node_types?.[type]);
}
function moduleOfType(type) {
return globalThis.LiteGraph?.registered_node_types?.[type]?.nodeData?.python_module;
}
// ---------------------------------------------------------------------------
// CSS
// ---------------------------------------------------------------------------
function ensureCss() {
if (document.getElementById(CSS_ID)) return;
const style = document.createElement("style");
style.id = CSS_ID;
style.textContent = `
.ausboss-note{box-sizing:border-box;width:100%;height:100%;padding:2px 6px 6px;pointer-events:none;overflow:hidden;--accent:${BRAND};}
.ausboss-note *{box-sizing:border-box;}
.ausboss-note-card{position:relative;display:flex;flex-direction:column;width:100%;height:100%;overflow:hidden;border:1px solid rgba(0,180,170,.27);border-radius:6px;background:rgba(0,0,0,.28);color:#c8dddd;font:12px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;}
.ausboss-note-scroll{flex:1 1 auto;min-height:0;overflow-y:auto;overflow-x:hidden;pointer-events:auto;padding:0 0 8px;}
.ausboss-note-head{position:relative;padding:12px 40px 10px 16px;border-left:4px solid var(--accent);}
.ausboss-note-title{margin:0;color:#f2f8f8;font-size:22px;font-weight:700;line-height:1.15;letter-spacing:-.01em;overflow-wrap:anywhere;}
.ausboss-note-subtitle{margin:5px 0 0;color:#a9c4c2;font-size:12.5px;line-height:1.35;overflow-wrap:anywhere;}
.ausboss-note-author{margin:6px 0 0;color:#78908e;font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;}
.ausboss-note.is-banner .ausboss-note-head{padding:14px 40px 14px 18px;}
.ausboss-note.is-banner .ausboss-note-title{font-size:28px;}
.ausboss-note-edit{position:absolute;top:8px;right:8px;width:24px;height:24px;border:1px solid transparent;border-radius:5px;background:transparent;color:#78908e;font-size:13px;line-height:1;cursor:pointer;pointer-events:auto;}
.ausboss-note-edit:hover{border-color:#3a4047;background:#23272c;color:#fff;}
.ausboss-note-empty{padding:14px 16px 4px;color:#78908e;font-size:11.5px;line-height:1.4;}
.ausboss-note-section{padding:8px 16px 0;}
.ausboss-note-section + .ausboss-note-section{margin-top:6px;border-top:1px solid rgba(255,255,255,.06);padding-top:10px;}
.ausboss-note-h{display:flex;align-items:center;gap:8px;margin:0 0 6px;color:#78908e;font-size:10px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;}
.ausboss-note-h .spacer{flex:1 1 auto;}
.ausboss-note-mini{height:18px;padding:0 7px;border:1px solid #3a4047;border-radius:4px;background:#23272c;color:#9ba2aa;font:10px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;letter-spacing:.04em;text-transform:none;cursor:pointer;pointer-events:auto;}
.ausboss-note-mini:hover{border-color:var(--accent);color:#fff;}
.ausboss-note-body{user-select:text;-webkit-user-select:text;cursor:text;}
.ausboss-note-body p{margin:0 0 7px;overflow-wrap:anywhere;}
.ausboss-note-body h1,.ausboss-note-body h2,.ausboss-note-body h3{margin:8px 0 5px;color:#eaf4f3;font-weight:700;line-height:1.25;}
.ausboss-note-body h1{font-size:16px;}
.ausboss-note-body h2{font-size:14px;}
.ausboss-note-body h3{font-size:12.5px;text-transform:uppercase;letter-spacing:.06em;color:#a9c4c2;}
.ausboss-note-body ul,.ausboss-note-body ol{margin:0 0 7px;padding-left:20px;}
.ausboss-note-body li{margin:2px 0;overflow-wrap:anywhere;}
.ausboss-note-body hr{margin:8px 0;border:0;border-top:1px solid rgba(255,255,255,.08);}
.ausboss-note-body code{padding:1px 4px;border-radius:3px;background:rgba(255,255,255,.08);font:11px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:#e6f4f3;}
.ausboss-note-body pre{margin:0 0 7px;padding:7px 9px;overflow-x:auto;border-radius:5px;background:rgba(0,0,0,.35);font:11px/1.4 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:#e6f4f3;white-space:pre;}
.ausboss-note-body a,.ausboss-note-link{color:var(--accent);text-decoration:none;pointer-events:auto;}
.ausboss-note-body a:hover,.ausboss-note-link:hover{text-decoration:underline;}
.ausboss-note-folder{margin:8px 0 4px;color:#a9c4c2;font:11px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;}
.ausboss-note-folder:first-child{margin-top:0;}
.ausboss-note-row{display:flex;align-items:center;gap:8px;min-height:30px;padding:3px 0;}
.ausboss-note-dot{flex:none;width:8px;height:8px;border-radius:50%;background:#4b5a5a;}
.ausboss-note-dot.checking{background:#4b5a5a;animation:ausboss-note-pulse 1s ease-in-out infinite;}
.ausboss-note-dot.ok{background:var(--accent);box-shadow:0 0 5px rgba(0,180,170,.55);}
.ausboss-note-dot.missing{background:#e0564b;box-shadow:0 0 5px rgba(224,86,75,.5);}
.ausboss-note-dot.unknown{background:#4b5a5a;}
@keyframes ausboss-note-pulse{0%,100%{opacity:.35;}50%{opacity:1;}}
.ausboss-note-file{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:1px;}
.ausboss-note-name{overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:#e6f4f3;font:11.5px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;pointer-events:auto;cursor:copy;}
.ausboss-note-meta{overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:#78908e;font-size:10.5px;}
.ausboss-note-file .ausboss-note-meta{white-space:normal;overflow-wrap:anywhere;line-height:1.35;}
.ausboss-note-dl{flex:none;display:inline-flex;align-items:center;gap:6px;height:24px;padding:0 9px;border:1px solid var(--accent);border-radius:5px;background:rgba(0,180,170,.14);color:#e6f4f3;font-size:11px;font-weight:600;text-decoration:none;white-space:nowrap;pointer-events:auto;cursor:pointer;}
.ausboss-note-dl:hover{background:var(--accent);color:#08211f;}
.ausboss-note-dl .size{font-weight:400;opacity:.8;}
.ausboss-note-pill{flex:none;display:inline-flex;align-items:center;gap:5px;height:22px;padding:0 8px;border-radius:11px;background:rgba(0,180,170,.16);color:#9fe3dc;font-size:10.5px;white-space:nowrap;}
.ausboss-note-pill.missing{background:rgba(224,86,75,.16);color:#f0a59e;}
.ausboss-note-pill.elsewhere{background:rgba(255,196,107,.14);color:#ffd79a;}
.ausboss-note-action{flex:none;display:inline-flex;align-items:center;gap:6px;}
.ausboss-note-use{flex:none;height:22px;padding:0 9px;border:1px solid #ffc46b;border-radius:5px;background:rgba(255,196,107,.12);color:#ffe3b0;font:600 11px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;cursor:pointer;pointer-events:auto;}
.ausboss-note-use:hover{background:#ffc46b;color:#2a1d05;}
.ausboss-note-pill a{color:inherit;text-decoration:none;pointer-events:auto;}
.ausboss-note-pill a:hover{text-decoration:underline;}
.ausboss-note-chips{display:flex;flex-wrap:wrap;gap:6px;}
.ausboss-note-chip{display:inline-flex;align-items:center;height:24px;padding:0 10px;border:1px solid #3a4047;border-radius:12px;background:#23272c;color:#d7dde2;font-size:11px;text-decoration:none;white-space:nowrap;pointer-events:auto;}
.ausboss-note-chip:hover{border-color:var(--accent);color:#fff;}
/* editor dialog */
.ausboss-note-overlay{position:fixed;inset:0;z-index:10000;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.55);}
.ausboss-note-dialog{display:flex;flex-direction:column;width:min(860px,94vw);max-height:92vh;border:1px solid #3a4047;border-radius:9px;background:#1c1f23;box-shadow:0 12px 40px rgba(0,0,0,.6);color:#d7dde2;font:12px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;}
.ausboss-note-dialog *{box-sizing:border-box;}
.ausboss-note-dhead{display:flex;align-items:center;gap:8px;padding:11px 14px;border-bottom:1px solid #2c3238;color:${BRAND};font-weight:600;}
.ausboss-note-dhead .spacer{flex:1 1 auto;}
.ausboss-note-dbody{flex:1 1 auto;min-height:0;overflow-y:auto;padding:12px 14px;display:flex;flex-direction:column;gap:12px;}
.ausboss-note-dfoot{display:flex;align-items:center;gap:8px;padding:10px 14px;border-top:1px solid #2c3238;}
.ausboss-note-dfoot .spacer{flex:1 1 auto;}
.ausboss-note-field{display:flex;flex-direction:column;gap:4px;}
.ausboss-note-field > label{color:#9ba2aa;font-size:10.5px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;}
.ausboss-note-field .hint{color:#78908e;font-size:10.5px;}
.ausboss-note-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;}
.ausboss-note-dialog input,.ausboss-note-dialog textarea,.ausboss-note-dialog select{width:100%;padding:5px 8px;border:1px solid #3a4047;border-radius:5px;background:#23272c;color:#e6f4f3;font:12px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;outline:none;}
.ausboss-note-dialog textarea{min-height:120px;resize:vertical;font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;}
.ausboss-note-dialog textarea.json{min-height:320px;}
.ausboss-note-dialog input:focus,.ausboss-note-dialog textarea:focus,.ausboss-note-dialog select:focus{border-color:${BRAND};}
.ausboss-note-table{display:flex;flex-direction:column;gap:4px;}
.ausboss-note-trow{display:grid;gap:6px;align-items:center;}
.ausboss-note-trow.models{grid-template-columns:minmax(120px,1.5fr) 130px 70px minmax(100px,1fr) minmax(100px,1fr) 24px;}
.ausboss-note-trow.packs{grid-template-columns:minmax(120px,1fr) minmax(120px,1.4fr) minmax(120px,1fr) 24px;}
.ausboss-note-trow.links{grid-template-columns:minmax(120px,1fr) minmax(120px,2fr) 24px;}
.ausboss-note-trow.head{color:#78908e;font-size:10px;letter-spacing:.06em;text-transform:uppercase;}
.ausboss-note-x{width:24px;height:24px;padding:0;border:1px solid transparent;border-radius:5px;background:transparent;color:#9ba2aa;cursor:pointer;}
.ausboss-note-x:hover{border-color:#3a4047;color:#fff;}
.ausboss-note-btn{height:26px;padding:0 10px;border:1px solid #3a4047;border-radius:5px;background:#23272c;color:#d7dde2;font:12px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;cursor:pointer;}
.ausboss-note-btn:hover{border-color:${BRAND};color:#fff;}
.ausboss-note-btn.primary{border-color:${BRAND};background:${BRAND};color:#08211f;font-weight:600;}
.ausboss-note-btn.primary:hover{background:#19c9be;}
.ausboss-note-seg{display:inline-flex;border:1px solid #3a4047;border-radius:5px;overflow:hidden;}
.ausboss-note-seg button{height:26px;padding:0 10px;border:none;background:#23272c;color:#9ba2aa;font:12px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;cursor:pointer;}
.ausboss-note-seg button + button{border-left:1px solid #3a4047;}
.ausboss-note-seg button.on{background:${BRAND};color:#08211f;font-weight:600;}
.ausboss-note-error{color:#f0a59e;font-size:11px;}
`;
document.head.appendChild(style);
}
// ---------------------------------------------------------------------------
// DOM helpers
// ---------------------------------------------------------------------------
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function externalLink(url, className, text) {
const link = el("a", className, text);
link.href = url;
link.target = "_blank";
link.rel = "noopener noreferrer";
// A click on a link must not become a node drag.
link.addEventListener("pointerdown", (event) => event.stopPropagation());
return link;
}
function spansInto(parent, spans) {
for (const span of spans) {
if (span.type === "br") parent.append(document.createElement("br"));
else if (span.type === "strong") parent.append(el("strong", "", span.text));
else if (span.type === "em") parent.append(el("em", "", span.text));
else if (span.type === "code") parent.append(el("code", "", span.text));
else if (span.type === "link") parent.append(externalLink(span.url, "", span.text));
else parent.append(document.createTextNode(span.text));
}
}
function markdownInto(parent, text) {
for (const block of parseMarkdown(text)) {
if (block.type === "heading") {
const heading = el(`h${block.level}`);
spansInto(heading, block.spans);
parent.append(heading);
} else if (block.type === "list") {
const list = el(block.ordered ? "ol" : "ul");
for (const item of block.items) {
const li = el("li");
spansInto(li, item);
list.append(li);
}
parent.append(list);
} else if (block.type === "hr") parent.append(el("hr"));
else if (block.type === "code") parent.append(el("pre", "", block.text));
else {
const paragraph = el("p");
spansInto(paragraph, block.spans);
parent.append(paragraph);
}
}
}
async function copyText(text, element) {
const copied = await copyToClipboard(text);
// A blocked clipboard says so; the name is still on screen to copy by hand.
const note = copied ? "copied" : "copy blocked";
const before = element.textContent;
element.textContent = note;
setTimeout(() => { if (element.textContent === note) element.textContent = before; }, copied ? 700 : 1400);
}
// ---------------------------------------------------------------------------
// The card
// ---------------------------------------------------------------------------
function setDot(dot, status, title) {
dot.className = `ausboss-note-dot ${status}`;
dot.title = title ?? "";
}
function modelRow(state, row) {
const line = el("div", "ausboss-note-row");
const dot = el("span", "ausboss-note-dot checking");
const file = el("div", "ausboss-note-file");
const name = el("div", "ausboss-note-name", row.name);
name.title = `${row.name} - click to copy the file name`;
name.addEventListener("pointerdown", (event) => event.stopPropagation());
name.addEventListener("click", () => copyText(row.name, name));
file.append(name);
const metaBits = [];
if (row.note) metaBits.push(row.note);
const meta = el("div", "ausboss-note-meta", metaBits.join(" · "));
if (metaBits.length) file.append(meta);
const action = el("span", "ausboss-note-action");
line.append(dot, file, action);
const showDownload = () => {
action.textContent = "";
if (row.url) {
const button = externalLink(row.url, "ausboss-note-dl", "");
button.append(document.createTextNode("Download"));
if (row.size) button.append(el("span", "size", row.size));
button.title = `Opens ${hostLabel(row.url) || row.url} in a new tab`;
action.append(button);
} else if (row.size) {
action.append(el("span", "ausboss-note-meta", row.size));
}
};
const showInstalled = (path) => {
action.textContent = "";
const pill = el("span", "ausboss-note-pill");
pill.append(document.createTextNode("✓ installed"));
if (row.url) {
const link = externalLink(row.url, "", "↗");
link.title = `Source: ${hostLabel(row.url) || row.url}`;
pill.append(link);
}
pill.title = path ? `Found: ${path}` : "Found";
action.append(pill);
};
// The file is here, but in a subfolder, and a loader still asks for the
// bare name: ComfyUI marks that loader red. Use it points the loaders at
// the copy that was found. LoRA Loader rows get the same button: that
// loader finds the file by its name when it runs, and the row then holds
// the real path.
const showElsewhere = (path, loaders, loraRows = [], files = []) => {
action.textContent = "";
const folder = subfolderOf(path);
const pill = el("span", "ausboss-note-pill elsewhere", `in ${folder}/`);
const asking = [...loaders, ...loraRows];
const names = [...new Set(asking.map((entry) => entry.node.title || entry.node.type))].join(", ");
pill.title = loaders.length
? `Found as ${path}, but ${names} ${asking.length === 1 ? "asks" : "ask"} for ${row.name}, which ComfyUI cannot find.`
: `Found as ${path}. ${names} still ${asking.length === 1 ? "has" : "have"} it as ${row.name} and will find it by name.`;
const use = el("button", "ausboss-note-use", "Use it");
use.type = "button";
use.title = `Point ${names} at ${path}.`;
use.addEventListener("pointerdown", (event) => event.stopPropagation());
use.addEventListener("click", () => {
for (const entry of loaders) {
commitWidgetValue(entry.node, entry.widget, listedSpelling(path, entry.options), app.canvas);
entry.node.setDirtyCanvas?.(true, true);
}
const stacks = new Map();
for (const entry of loraRows) stacks.set(entry.widget, [...(stacks.get(entry.widget) ?? []), entry]);
for (const [widget, entries] of stacks) {
const rows = parseRows(widget.value);
for (const entry of entries) {
if (rows[entry.index]) rows[entry.index] = { ...rows[entry.index], name: listedSpelling(path, files) };
}
commitWidgetValue(entries[0].node, widget, serializeRows(rows), app.canvas);
entries[0].node.setDirtyCanvas?.(true, true);
}
app.graph?.setDirtyCanvas?.(true, true);
notifyAusbossChange();
checkModels(state, false);
});
action.append(pill, use);
};
showDownload();
state.rows.push({ row, dot, showDownload, showInstalled, showElsewhere });
return line;
}
function packRow(state, row) {
const line = el("div", "ausboss-note-row");
const dot = el("span", "ausboss-note-dot unknown");
const file = el("div", "ausboss-note-file");
const name = el("div", "ausboss-note-name", row.name);
name.style.cursor = "default";
file.append(name);
const action = el("span");
line.append(dot, file, action);
const installed = row.node ? nodeTypeRegistered(row.node) : null;
if (installed === true) {
setDot(dot, "ok", `${row.node} is registered on this install`);
const pill = el("span", "ausboss-note-pill", "✓ installed");
if (row.url) {
const link = externalLink(row.url, "", "↗");
link.title = row.url;
pill.append(link);
}
action.append(pill);
} else {
setDot(dot, installed === false ? "missing" : "unknown",
installed === false ? `${row.node} is not registered here - install the pack` : "No probe node set, so this row is not checked");
if (row.url) {
const button = externalLink(row.url, "ausboss-note-dl", installed === false ? "Get it" : "Open");
button.title = row.url;
action.append(button);
} else if (installed === false) {
action.append(el("span", "ausboss-note-pill missing", "missing"));
}
}
return line;
}
function sectionHeading(text) {
const heading = el("div", "ausboss-note-h");
heading.append(el("span", "", text), el("span", "spacer"));
return heading;
}
// The frontend hangs a "which pack" badge above every custom node. On a
// banner - a title label for a column or stage - it floats in space, so
// the banner layout hides it and the card layout puts it back.
function setBadgesHidden(node, hidden) {
if (hidden === Boolean(node.__ausbossBadgesHidden)) return;
try {
if (hidden) {
node.__ausbossBadgeStash = node.badges;
Object.defineProperty(node, "badges", { configurable: true, get: () => [], set: () => {} });
} else {
delete node.badges;
node.badges = node.__ausbossBadgeStash ?? [];
}
node.__ausbossBadgesHidden = hidden;
} catch {
// A frozen node keeps its badge; nothing else depends on this.
}
}
function render(state) {
const { note } = state;
const root = state.root;
root.classList.toggle("is-banner", note.layout === "banner");
setBadgesHidden(state.node, note.layout === "banner" && !noteIsEmpty(note));
root.style.setProperty("--accent", note.accent || BRAND);
const scroll = state.scroll;
scroll.textContent = "";
state.rows = [];
const head = el("div", "ausboss-note-head");
head.append(el("h1", "ausboss-note-title", note.title || "Untitled workflow"));
if (note.subtitle) head.append(el("p", "ausboss-note-subtitle", note.subtitle));
if (note.author) head.append(el("p", "ausboss-note-author", `by ${note.author}`));
const edit = el("button", "ausboss-note-edit", "✎");
edit.type = "button";
edit.title = "Edit this note";
edit.addEventListener("pointerdown", (event) => event.stopPropagation());
edit.addEventListener("click", () => openEditor(state));
head.append(edit);
scroll.append(head);
if (noteIsEmpty(note)) {
scroll.append(el("div", "ausboss-note-empty",
"Click ✎ to give this workflow a title, a how-to, the models it needs with download links, and your own links."));
state.domWidget.options.getMinHeight = () => CARD_MIN_HEIGHT + WIDGET_FRAME;
return;
}
if (note.layout === "banner") {
state.domWidget.options.getMinHeight = () => BANNER_MIN_HEIGHT + WIDGET_FRAME;
return;
}
state.domWidget.options.getMinHeight = () => CARD_MIN_HEIGHT + WIDGET_FRAME;
if (note.body.trim()) {
const section = el("div", "ausboss-note-section");
const body = el("div", "ausboss-note-body");
body.addEventListener("pointerdown", (event) => event.stopPropagation());
markdownInto(body, note.body);
section.append(body);
scroll.append(section);
}
if (note.models.length) {
const section = el("div", "ausboss-note-section");
const heading = sectionHeading("Models");
const recheck = el("button", "ausboss-note-mini", "↻ check files");
recheck.type = "button";
recheck.title = "Look for each file again (after moving files in, or pressing R)";
recheck.addEventListener("pointerdown", (event) => event.stopPropagation());
recheck.addEventListener("click", () => checkModels(state, true));
heading.append(recheck);
section.append(heading);
for (const group of groupModels(note.models)) {
section.append(el("div", "ausboss-note-folder", `📂 ${group.path}`));
for (const row of group.rows) section.append(modelRow(state, row));
}
scroll.append(section);
}
if (note.packs.length) {
const section = el("div", "ausboss-note-section");
section.append(sectionHeading("Node packs"));
for (const row of note.packs) section.append(packRow(state, row));
scroll.append(section);
}
if (note.links.length) {
const section = el("div", "ausboss-note-section");
section.append(sectionHeading("Links"));
const chips = el("div", "ausboss-note-chips");
for (const row of note.links) {
if (row.url) chips.append(externalLink(row.url, "ausboss-note-chip", row.label));
else chips.append(el("span", "ausboss-note-chip", row.label));
}
section.append(chips);
scroll.append(section);
}
checkModels(state, false);
}
async function checkModels(state, fresh) {
const generation = (state.checkGeneration = (state.checkGeneration || 0) + 1);
const rows = state.rows;
for (const entry of rows) setDot(entry.dot, "checking", "checking…");
const byDir = new Map();
for (const entry of rows) {
const dir = entry.row.dir;
if (!byDir.has(dir)) byDir.set(dir, []);
byDir.get(dir).push(entry);
}
await Promise.all([...byDir.entries()].map(async ([dir, entries]) => {
if (!dir) {
for (const entry of entries) setDot(entry.dot, "unknown", "No folder set, so this file is not checked");
return;
}
if (dir === INPUT_FOLDER) {
await Promise.all(entries.map(async (entry) => {
const found = await inputExists(entry.row.name);
if (generation !== state.checkGeneration) return;
if (found) {
setDot(entry.dot, "ok", "Found in ComfyUI/input");
entry.showInstalled(`input/${entry.row.name}`);
} else {
setDot(entry.dot, "missing", "Not in ComfyUI/input yet");
entry.showDownload();
}
}));
return;
}
const files = await listFolder(dir, fresh);
if (generation !== state.checkGeneration) return;
for (const entry of entries) {
if (files === null) {
setDot(entry.dot, "unknown", `This install has no "${dir}" model folder, so the file is not checked`);
entry.showDownload();
continue;
}
const match = matchInstalled(entry.row.name, files);
const elsewhere = match.found && subfolderOf(match.path);
const loaders = elsewhere ? loadersNeeding(entry.row.name, match.path, graphListWidgets()) : [];
const loraRows = elsewhere ? loraRowsNeeding(entry.row.name, match.path, graphLoraLoaders(), files) : [];
if (match.found && (loaders.length || loraRows.length)) {
setDot(entry.dot, "ok", `Found in ${subfolderOf(match.path)}/: ${match.path}`);
entry.showElsewhere(match.path, loaders, loraRows, files);
} else if (match.found) {
setDot(entry.dot, "ok", `Found: ${match.path}`);
entry.showInstalled(match.path);
} else {
setDot(entry.dot, "missing", `Not found in ComfyUI/models/${dir}`);
entry.showDownload();
}
}
}));
if (generation === state.checkGeneration) fitNote(state);
}
// ---------------------------------------------------------------------------
// Fitting the node to the card (classic canvas)
// ---------------------------------------------------------------------------
// Nodes 2.0 sizes the node to the card by itself; the classic canvas keeps
// whatever height the workflow saved.
function classicCanvas() {
return globalThis.LiteGraph?.vueNodesMode !== true;
}
// The card's natural height at a given width. For one read the card takes
// that width and drops its 100% heights, so the content lays out at full
// size with no scrollbar narrowing it; everything goes back before anything
// is painted. The width is the node's, not the frame's: the frame can still
// be a few pixels wide in the moment after it first appears.
function naturalHeight(state, width) {
const { root, card } = state;
root.style.width = `${width}px`;
root.style.height = "auto";
card.style.height = "auto";
const height = parseFloat(getComputedStyle(root).height);
root.style.width = "";
root.style.height = "";
card.style.height = "";
return Number.isFinite(height) && height > 0 ? height : null;
}
// Grow the node until the card shows everything, and remember that height
// as the node's floor. It never shrinks the node: a taller note keeps its
// empty space until someone drags it shorter.
function fitNote(state) {
if (!classicCanvas()) return;
const { node, root, domWidget } = state;
const width = node.size[0] - WIDGET_FRAME;
if (!(width > 0)) return;
let natural;
if (root.isConnected) natural = naturalHeight(state, width);
else {
// Not on the canvas yet - a workflow is loading. Lay the card out off
// screen instead.
const host = el("div");
host.style.cssText = "position:fixed;left:-100000px;top:0;visibility:hidden;pointer-events:none;";
host.append(root);
document.body.append(host);
natural = naturalHeight(state, width);
root.remove();
host.remove();
}
const top = Number.isFinite(domWidget.computedHeight) && Number.isFinite(domWidget.y) ? domWidget.y : PANEL_TOP;
const { fit, grow } = noteFit({ natural, nodeHeight: node.size[1], chrome: top + WIDGET_FRAME });
if (fit === null) return;
state.fitHeight = fit;
if (grow) {
node.setSize([node.size[0], fit]);
node.setDirtyCanvas?.(true, true);
}
}
// Every list widget in the graph, subgraphs included: the loaders a found
// file could be handed to. Local only - the graph already holds each
// loader's choices, as ComfyUI's own server listed them.
function graphListWidgets() {
const out = [];
const seen = new Set();
const walk = (graph) => {
if (!graph || seen.has(graph)) return;
seen.add(graph);
for (const node of graph._nodes ?? graph.nodes ?? []) {
for (const widget of node.widgets ?? []) {
if (widget?.type !== "combo" || typeof widget.value !== "string") continue;
out.push({ node, widget, value: widget.value, options: comboValues(widget) });
}
if (node.subgraph) walk(node.subgraph);
}
};
walk(app.rootGraph ?? app.graph);
return out;
}
// Every LoRA Loader in the graph, subgraphs included, with its rows. Its
// file names sit in the rows' text, not in a list widget.
function graphLoraLoaders() {
const out = [];
const seen = new Set();
const walk = (graph) => {
if (!graph || seen.has(graph)) return;
seen.add(graph);
for (const node of graph._nodes ?? graph.nodes ?? []) {
if (node.type === "AUSBOSS_NODES_LoraLoader") {
const widget = node.widgets?.find((item) => item.name === "loras");
if (widget && typeof widget.value === "string") out.push({ node, widget, rows: parseRows(widget.value) });
}
if (node.subgraph) walk(node.subgraph);
}
};
walk(app.rootGraph ?? app.graph);
return out;
}
// ---------------------------------------------------------------------------
// The editor dialog
// ---------------------------------------------------------------------------
let openDialog = null;
function closeEditor() {
if (!openDialog) return;
openDialog.abort.abort();
openDialog.element.remove();
openDialog = null;
}
function field(labelText, control, hint) {
const wrap = el("div", "ausboss-note-field");
wrap.append(el("label", "", labelText), control);
if (hint) wrap.append(el("span", "hint", hint));
return wrap;
}
function input(value, placeholder) {
const box = el("input");
box.type = "text";
box.value = value ?? "";
if (placeholder) box.placeholder = placeholder;
return box;
}
function folderSelect(value, folders) {
const select = el("select");
const options = [...folders];
if (value && !options.includes(value)) options.push(value);
if (!options.includes("")) options.unshift("");
for (const folder of options) {
const option = el("option", "", folder || "(folder)");
option.value = folder;
select.append(option);
}
select.value = value ?? "";
return select;
}
// A small editable table. `columns` describes each cell; rows are plain
// objects and `read()` returns them back as edited.
function table(kind, columns, rows, makeRow) {
const box = el("div", "ausboss-note-table");
const head = el("div", `ausboss-note-trow ${kind} head`);
for (const column of columns) head.append(el("span", "", column));
head.append(el("span"));
box.append(head);
const body = el("div", "ausboss-note-table");
box.append(body);
const entries = [];
const add = (row) => {
const line = el("div", `ausboss-note-trow ${kind}`);
const controls = makeRow(row ?? {});
for (const control of controls) line.append(control);
const remove = el("button", "ausboss-note-x", "✕");
remove.type = "button";
remove.title = "Remove this row";
const entry = { line, controls };
remove.addEventListener("click", () => {
line.remove();
entries.splice(entries.indexOf(entry), 1);
});
line.append(remove);
body.append(line);
entries.push(entry);
return entry;
};
for (const row of rows) add(row);
return { box, add, entries };
}
async function openEditor(state) {
closeEditor();
ensureCss();
const folders = await knownFolders();
const folderOptions = [...new Set([...COMMON_FOLDERS, ...folders])];
const draft = normalizeNote(state.note);
const overlay = el("div", "ausboss-note-overlay");
const dialog = el("div", "ausboss-note-dialog");
const head = el("div", "ausboss-note-dhead");
head.append(el("span", "", "Workflow Note"), el("span", "spacer"));
const jsonToggle = el("button", "ausboss-note-btn", "JSON");
jsonToggle.type = "button";
jsonToggle.title = "Edit the raw card JSON instead of the form";
head.append(jsonToggle);
const body = el("div", "ausboss-note-dbody");
const foot = el("div", "ausboss-note-dfoot");
const error = el("span", "ausboss-note-error");
const cancel = el("button", "ausboss-note-btn", "Cancel");
cancel.type = "button";
const save = el("button", "ausboss-note-btn primary", "Save");
save.type = "button";
foot.append(error, el("span", "spacer"), cancel, save);
dialog.append(head, body, foot);
overlay.append(dialog);
// --- form ---------------------------------------------------------------
const form = el("div", "ausboss-note-dbody");
form.style.padding = "0";
form.style.overflow = "visible";
const title = input(draft.title, "Krea 2 Outpaint");
const subtitle = input(draft.subtitle, "One line on what this workflow does");
const author = input(draft.author, "your name");
const accent = input(draft.accent, "#00b4aa");
let layout = draft.layout;
const seg = el("div", "ausboss-note-seg");
const segButtons = LAYOUTS.map((option) => {
const button = el("button", option === layout ? "on" : "", option === "card" ? "Card" : "Banner");
button.type = "button";
button.addEventListener("click", () => {
layout = option;
segButtons.forEach((other, index) => other.classList.toggle("on", LAYOUTS[index] === option));
});
seg.append(button);
return button;
});
const grid = el("div", "ausboss-note-grid");
grid.append(
field("Title", title),
field("Author", author),
field("Subtitle", subtitle),
field("Layout", seg, "Banner shows only the title, subtitle and author - a label for a column or stage."),
field("Accent color", accent, "Hex like #00b4aa; empty keeps the pack teal."),
);
form.append(grid);
const bodyBox = el("textarea");
bodyBox.value = draft.body;
bodyBox.placeholder = "## How it works\n\n1. Load your image\n2. Drag the padding edges\n3. Queue\n\nMarkdown: # headings, **bold**, `code`, [links](https://...), - lists";
form.append(field("How-to (Markdown)", bodyBox));
const models = table("models", MODEL_COLUMNS.map((column) => column.label), draft.models, (row) =>
MODEL_COLUMNS.map(({ key, placeholder }) => (key === "dir" ? folderSelect(row.dir, folderOptions) : input(row[key], placeholder))));
const modelTools = el("div");
const addModel = el("button", "ausboss-note-btn", "+ model");
addModel.type = "button";
addModel.addEventListener("click", () => models.add({ dir: "diffusion_models" }));
modelTools.append(addModel);
form.append(field("Models", models.box, "Each row shows a Download button until the file is found in its folder (subfolders count). The note shows in grey under the file name."), modelTools);
const packs = table("packs", ["Pack", "URL", "Probe node class"], draft.packs, (row) => [
input(row.name, "ComfyUI-AusBoss"),
input(row.url, "https://github.com/ausboss/ComfyUI-AusBoss"),
input(row.node, "AUSBOSS_NODES_LoraLoader"),
]);
const packTools = el("div");
packTools.style.display = "flex";
packTools.style.gap = "6px";
const addPack = el("button", "ausboss-note-btn", "+ pack");
addPack.type = "button";
addPack.addEventListener("click", () => packs.add({}));
const detect = el("button", "ausboss-note-btn", "Detect from this workflow");
detect.type = "button";
detect.title = "List every custom node pack the open graph uses";
detect.addEventListener("click", () => {
const types = collectGraphNodes(app.rootGraph ?? app.graph).map((node) => node.type);
const have = new Set(packs.entries.map((entry) => entry.controls[0].value.trim()));
for (const pack of packsFromGraph(types, moduleOfType)) {
if (have.has(pack.name)) continue;
packs.add(pack.name === "ComfyUI-AusBoss"
? { ...pack, url: "https://github.com/ausboss/ComfyUI-AusBoss" }
: pack);
}
});
packTools.append(addPack, detect);
form.append(field("Node packs", packs.box, "The probe node class is checked against this install; a missing one marks the pack missing."), packTools);
const links = table("links", ["Label", "URL"], draft.links, (row) => [
input(row.label, "GitHub"),
input(row.url, "https://github.com/ausboss"),
]);
const linkTools = el("div");
const addLink = el("button", "ausboss-note-btn", "+ link");
addLink.type = "button";
addLink.addEventListener("click", () => links.add({}));
linkTools.append(addLink);
form.append(field("Links", links.box, "Your pages: GitHub, Civitai, YouTube, Discord…"), linkTools);
// --- raw JSON -------------------------------------------------------------
const jsonBox = el("textarea", "json");
let showingJson = false;
const readForm = () => normalizeNote({
title: title.value,
subtitle: subtitle.value,
author: author.value,
body: bodyBox.value,
layout,
accent: accent.value,
models: models.entries.map(({ controls }) => rowFromCells(MODEL_COLUMNS, controls.map((control) => control.value))),
packs: packs.entries.map(({ controls }) => ({
name: controls[0].value, url: controls[1].value, node: controls[2].value,
})),
links: links.entries.map(({ controls }) => ({ label: controls[0].value, url: controls[1].value })),
});
body.append(form);
jsonToggle.addEventListener("click", () => {
if (!showingJson) {
jsonBox.value = JSON.stringify(readForm(), null, 2);
body.textContent = "";
body.append(field("Card JSON", jsonBox, "The exact text stored in the workflow. Save applies it."));
jsonToggle.textContent = "Form";
} else {
// Going back to the form discards JSON edits that did not parse.
try {
const parsed = normalizeNote(JSON.parse(jsonBox.value));
Object.assign(draft, parsed);
closeEditor();
state.note = parsed;
openEditor(state);
return;
} catch {
error.textContent = "That JSON does not parse - fix it or Cancel.";
return;
}
}
showingJson = !showingJson;
});
const commit = () => {
let next;
if (showingJson) {
try {
next = normalizeNote(JSON.parse(jsonBox.value));
} catch {
error.textContent = "That JSON does not parse - fix it or Cancel.";
return;
}
} else next = readForm();
state.note = next;
state.valueWidget.value = serializeNote(next);
closeEditor();
render(state);
fitNote(state);
state.node.setDirtyCanvas?.(true, true);
notifyAusbossChange();
};
// Every way out that is not Save asks first when the note changed, then
// drops the draft, including one carried over by the JSON/Form toggle.
const saved = () => JSON.stringify(normalizeNote(state.valueWidget.value));
const changed = () => {
if (!showingJson) return JSON.stringify(readForm()) !== saved();
try { return JSON.stringify(normalizeNote(JSON.parse(jsonBox.value))) !== saved(); } catch { return true; }
};
let asking = false;
const cancelEdits = async () => {
if (asking) return;
if (changed()) {
asking = true;
const discard = await confirmDiscard();
asking = false;
if (!discard) return;
}
state.note = normalizeNote(state.valueWidget.value);
closeEditor();
};
save.addEventListener("click", commit);
cancel.addEventListener("click", cancelEdits);
const abort = new AbortController();
// Keys typed into the dialog are the dialog's, not the canvas's shortcuts.
overlay.addEventListener("keydown", (event) => {
event.stopPropagation();
if (event.key === "Escape") void cancelEdits();
if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) commit();
}, { signal: abort.signal });
overlay.addEventListener("pointerdown", (event) => {
event.stopPropagation();
if (event.target === overlay) void cancelEdits();
}, { signal: abort.signal });
overlay.addEventListener("wheel", (event) => event.stopPropagation(), { signal: abort.signal });
document.body.append(overlay);
openDialog = { element: overlay, abort };
title.focus();
}
// ---------------------------------------------------------------------------
// Node wiring
// ---------------------------------------------------------------------------
function buildPanel(node) {
if (node.__ausbossNote) return node.__ausbossNote;
ensureCss();
const widget = node.widgets?.find((item) => item.name === "note");
if (!widget) return null;
hideWidget(widget);
const root = el("div", "ausboss-note");
const card = el("div", "ausboss-note-card");
const scroll = el("div", "ausboss-note-scroll");
card.append(scroll);
root.append(card);
// Selecting text or scrolling the card must not read as a node drag; a
// press on empty card space still falls through to the canvas.
scroll.addEventListener("pointerdown", (event) => {
if (event.target !== scroll) event.stopPropagation();
});
const domWidget = node.addDOMWidget(WIDGET_NAME, "ausboss_workflow_note", root, {
serialize: false,
hideOnZoom: false,
getMinHeight: () => CARD_MIN_HEIGHT + WIDGET_FRAME,
});
keepDomWidgetWidthAuto(domWidget);
// Not saved with the workflow either: options.serialize only keeps it out
// of the prompt, and saved values come back by position.
domWidget.serialize = false;
fillNodeHeight(domWidget, {
minWidth: PANEL_MIN_WIDTH,
minHeight: () => (domWidget.options.getMinHeight?.() ?? CARD_MIN_HEIGHT + WIDGET_FRAME),
minNodeSize: [PANEL_MIN_WIDTH, 120],
exactMinWidth: true,
});
// valueWidget is the hidden STRING (the storage the editor writes
// through); domWidget is the panel the layout sizes.
const state = (node.__ausbossNote = {
node, domWidget, valueWidget: widget, root, card, scroll, rows: [],
note: normalizeNote(widget.value),
});
// Classic canvas: a corner drag stops where the card shows everything, and
// the card refits whenever its box changes (first shown, or the node made
// wider or narrower, which rewraps the text).
holdNodeMinHeight(node, () => (classicCanvas() ? state.fitHeight : undefined));
state.resizeObserver = new ResizeObserver(() => fitNote(state));
state.resizeObserver.observe(root);
render(state);
node.setSize?.([
Math.max(node.size?.[0] ?? 0, DEFAULT_SIZE[0]),
Math.max(node.size?.[1] ?? 0, DEFAULT_SIZE[1]),
]);
return state;
}
app.registerExtension({
name: "AusBoss.WorkflowNote",
beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData?.name !== NODE_CLASS) return;
hideInputsInDef(nodeData, ["note"]);
chainCallback(nodeType.prototype, "onNodeCreated", function () {
buildPanel(this);
});
chainCallback(nodeType.prototype, "onConfigure", function () {
// Saved widget values land after onNodeCreated: re-read them, and
// keep the saved size rather than the default the panel asked for.
// The fit runs before ComfyUI snapshots the opened workflow, so
// growing a note that was saved short does not mark it modified.
queueMicrotask(() => {
const state = buildPanel(this);
if (!state) return;
state.note = normalizeNote(state.valueWidget.value);
render(state);
fitNote(state);
});
});
chainCallback(nodeType.prototype, "getExtraMenuOptions", function (_canvas, options) {
const state = this.__ausbossNote;
if (!state || !Array.isArray(options)) return;
options.unshift({ content: "✎ Edit Workflow Note", callback: () => openEditor(state) }, null);
});
chainCallback(nodeType.prototype, "refreshComboInNode", function () {
const state = this.__ausbossNote;
if (state) checkModels(state, true);
});
chainCallback(nodeType.prototype, "onRemoved", function () {
closeEditor();
this.__ausbossNote?.resizeObserver?.disconnect();
this.__ausbossNote = null;
});
},
});