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.
1036 lines
46 KiB
JavaScript
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;
|
|
});
|
|
},
|
|
});
|