The style browser surfaced the REST 401 verbatim, which told people to send an `Authorization: Bearer` header and pointed at styleref.io/mcp — neither of which anyone can act on from inside ComfyUI. - A signed-out lane now renders one actionable row: a Sign in button running the same browser OAuth flow as the Login node, plus a link to the setup guide. Headless boxes still get the full instructions in the overlay, since they cannot just click. - /styleref/saved and /styleref/my-styles map an API 401 to `signedIn: false` rather than echoing the message. Expired or revoked stored credentials are the signed-out state, so the dialog should offer sign-in, not an error. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
870 lines
36 KiB
JavaScript
870 lines
36 KiB
JavaScript
/**
|
||
* Progressive enhancement for the StyleRef Load node.
|
||
*
|
||
* Deliberately additive. This file adds:
|
||
* - a "Search styles…" button opening a picker dialog that owns its own
|
||
* query input (debounced live search), category filter, Recent/Popular/Saved
|
||
* source toggle, and thumbnails. Results are split into two independently
|
||
* paged sections — the user's own library, then the gallery (or their saved
|
||
* styles) — each 8 rows deep with its own ‹ Prev / Next ›. The query filters
|
||
* both sections, so the search box narrows the library as well as the
|
||
* gallery;
|
||
* - a read-only "loaded style" line under `style_ref` naming the style that
|
||
* slug resolves to, so a graph says which style it uses without being
|
||
* queued. It is marked non-serializing and sits mid-list, the one place
|
||
* this file touches widget order;
|
||
* - a "↻ Refresh style" button that POSTs /styleref/refresh, which drops the
|
||
* server-side cache and dirties the node so the next queue re-downloads;
|
||
* - status + Sign in / Sign out buttons on StyleRef Login, backed by the
|
||
* /styleref/auth/* routes (the node has no widgets; queueing it reports
|
||
* status, and `npx styleref login` covers frontend-less installs);
|
||
* - a warning badge on StyleRef Apply when `target` is a copy-out format
|
||
* (ai_tools, style_md, midjourney, json) but the `positive` output feeds
|
||
* a CLIP Text Encode — those are for copying out, not sampler text.
|
||
*
|
||
* It creates no custom widget type and overrides no drawing beyond the badge.
|
||
* The one widget it inserts rather than appends is flagged out of serialization
|
||
* both ways frontends read that flag, because `widgets_values` is positional
|
||
* and a serialising widget mid-list would shift every value after it.
|
||
*
|
||
* If a ComfyUI frontend release changes the extension API and this stops
|
||
* loading, the nodes degrade to exactly what they
|
||
* are without it: text fields you type into. That is the plan's
|
||
* "degraded-but-unbreakable fallback", and it is why the node's own `search` +
|
||
* `category` inputs exist in Python too.
|
||
*
|
||
* Tested against both the LiteGraph frontend and Nodes 2.0.
|
||
*/
|
||
|
||
import { app } from "../../scripts/app.js";
|
||
|
||
const LOAD_NODE = "StyleRefLoad";
|
||
const APPLY_NODE = "StyleRefApply";
|
||
const LOGIN_NODE = "StyleRefLogin";
|
||
|
||
// Targets that are copy-out formats, not sampler text (mirrors Python's
|
||
// COPY_OUT_TARGETS — only flux and diffusion belong in a CLIP encoder).
|
||
const COPY_OUT_TARGETS = ["ai_tools", "style_md", "midjourney", "json"];
|
||
|
||
// Mirrors the gallery's controlled vocabulary (kept static like Python's list —
|
||
// the server tolerates unknown values).
|
||
const CATEGORIES = [
|
||
"Photography", "Graphic design", "Illustration", "Cinematography", "UI/UX",
|
||
"Brand identity", "Motion design", "Concept art", "Game art", "Product design",
|
||
"Architecture", "Interior design", "Fine art", "Character design",
|
||
"Environmental art", "High-end fashion editorial", "Book/editorial design",
|
||
"Packaging design", "3D visualization", "Data visualization", "Copywriting",
|
||
"Content writing", "Social copy", "UX writing",
|
||
];
|
||
|
||
// Rows per section page. Both sections show one page at a time with their own
|
||
// ‹ Prev / Next ›, so the dialog stays a fixed height instead of growing.
|
||
const PAGE_SIZE = 8;
|
||
|
||
// Resolved by the availability probe. `null` = probe still running.
|
||
let routesAvailable = null;
|
||
|
||
async function probeRoutes() {
|
||
// One request at extension setup: if server_routes failed to import, the
|
||
// endpoint 404s and the buttons are simply never added — matching the
|
||
// Python-side degradation instead of shipping a button that alerts.
|
||
try {
|
||
const res = await fetch("/styleref/search?query=&limit=1");
|
||
routesAvailable = res.status !== 404;
|
||
} catch {
|
||
routesAvailable = false;
|
||
}
|
||
}
|
||
|
||
async function fetchJson(url) {
|
||
try {
|
||
const res = await fetch(url);
|
||
if (!res.ok) return { styles: [], error: `Request failed (${res.status})` };
|
||
return await res.json();
|
||
} catch (err) {
|
||
return { styles: [], error: `Request failed: ${err?.message ?? err}` };
|
||
}
|
||
}
|
||
|
||
async function postJson(url, body) {
|
||
try {
|
||
const res = await fetch(url, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(body ?? {}),
|
||
});
|
||
if (!res.ok) return { ok: false, error: `Request failed (${res.status})` };
|
||
return await res.json();
|
||
} catch (err) {
|
||
return { ok: false, error: `Request failed: ${err?.message ?? err}` };
|
||
}
|
||
}
|
||
|
||
/** DOM helper — textContent only: gallery names/tags are user-authored. */
|
||
function el(tag, css, text) {
|
||
const node = document.createElement(tag);
|
||
if (css) node.style.cssText = css;
|
||
if (text != null) node.textContent = text;
|
||
return node;
|
||
}
|
||
|
||
/**
|
||
* The picker dialog: owns its query, filters, and result
|
||
* list. Resolves with the chosen slug/ref, or null when dismissed.
|
||
*/
|
||
function openPicker(initialQuery) {
|
||
return new Promise((resolve) => {
|
||
const overlay = el(
|
||
"div",
|
||
"position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:10000;" +
|
||
"display:flex;align-items:center;justify-content:center;",
|
||
);
|
||
const panel = el(
|
||
"div",
|
||
"background:#1c1c1f;color:#eee;border-radius:12px;padding:16px;width:560px;" +
|
||
"max-width:92vw;max-height:76vh;overflow:auto;font-family:system-ui,sans-serif;" +
|
||
"box-shadow:0 20px 60px rgba(0,0,0,.5);",
|
||
);
|
||
|
||
const close = (value) => {
|
||
document.removeEventListener("keydown", onKey, true);
|
||
overlay.remove();
|
||
resolve(value);
|
||
};
|
||
const onKey = (e) => {
|
||
// Esc-to-close, capturing so the canvas never sees it.
|
||
if (e.key === "Escape") {
|
||
e.stopPropagation();
|
||
close(null);
|
||
}
|
||
};
|
||
document.addEventListener("keydown", onKey, true);
|
||
|
||
panel.append(el("div", "font-weight:600;margin-bottom:12px;font-size:15px;", "Pick a style"));
|
||
|
||
// ── controls row: query, category, sort ─────────────────────────────
|
||
const controls = el("div", "display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;");
|
||
const input = el(
|
||
"input",
|
||
"flex:1;min-width:180px;background:#26262a;border:1px solid #3a3a42;color:#eee;" +
|
||
"border-radius:8px;padding:8px 10px;font-size:13px;outline:none;",
|
||
);
|
||
input.placeholder = "Search the gallery… e.g. warm editorial";
|
||
input.value = initialQuery ?? "";
|
||
|
||
const category = el(
|
||
"select",
|
||
"background:#26262a;border:1px solid #3a3a42;color:#eee;border-radius:8px;" +
|
||
"padding:8px;font-size:13px;",
|
||
);
|
||
const anyOption = el("option", "", "All categories");
|
||
anyOption.value = "";
|
||
category.append(anyOption);
|
||
for (const name of CATEGORIES) {
|
||
const option = el("option", "", name);
|
||
option.value = name;
|
||
category.append(option);
|
||
}
|
||
|
||
const sort = el(
|
||
"select",
|
||
"background:#26262a;border:1px solid #3a3a42;color:#eee;border-radius:8px;" +
|
||
"padding:8px;font-size:13px;",
|
||
);
|
||
// "Saved" is a source, not an ordering: it swaps the lower section from
|
||
// the public gallery to the styles this user saved. It sits in the same
|
||
// dropdown because from the user's side all three answer one question —
|
||
// which styles am I looking at?
|
||
for (const [value, label] of [
|
||
["recent", "Recent"],
|
||
["popular", "Popular"],
|
||
["saved", "Saved"],
|
||
]) {
|
||
const option = el("option", "", label);
|
||
option.value = value;
|
||
sort.append(option);
|
||
}
|
||
|
||
controls.append(input, category, sort);
|
||
panel.append(controls);
|
||
|
||
// Two independently paged sections: the user's own library on top, then
|
||
// the gallery (or their saved styles). Separate containers so paging one
|
||
// never re-fetches or scroll-jumps the other.
|
||
const mineBox = el("div", "");
|
||
const galleryBox = el("div", "");
|
||
panel.append(mineBox, galleryBox);
|
||
|
||
const cancel = el(
|
||
"button",
|
||
"margin-top:8px;background:transparent;border:1px solid #444;color:#bbb;" +
|
||
"border-radius:8px;padding:8px 14px;cursor:pointer;",
|
||
"Cancel",
|
||
);
|
||
cancel.onclick = () => close(null);
|
||
panel.append(cancel);
|
||
|
||
// ── rendering ───────────────────────────────────────────────────────
|
||
const sectionHeader = (text) =>
|
||
el("div", "opacity:.6;font-size:11px;text-transform:uppercase;letter-spacing:.06em;margin:10px 2px 6px;", text);
|
||
|
||
const message = (text) =>
|
||
el("div", "background:#26262a;border-radius:8px;padding:10px 12px;margin-bottom:6px;opacity:.75;font-size:13px;", text);
|
||
|
||
/**
|
||
* Signed-out state, as one actionable row rather than a wall of text.
|
||
* The old copy pasted the server's raw 401 — which told people to send an
|
||
* `Authorization: Bearer` header and linked the MCP install page, neither
|
||
* of which anyone can do from inside ComfyUI. Signing in is one click here.
|
||
*/
|
||
const signInPrompt = (out, what) => {
|
||
const box = el(
|
||
"div",
|
||
"background:#26262a;border-radius:8px;padding:12px;margin-bottom:6px;font-size:13px;",
|
||
);
|
||
box.append(
|
||
el("div", "opacity:.8;margin-bottom:8px;", `Sign in to StyleRef to see ${what}.`),
|
||
);
|
||
|
||
const actions = el("div", "display:flex;gap:8px;align-items:center;");
|
||
const signIn = el(
|
||
"button",
|
||
"background:#4f46e5;border:0;color:#fff;border-radius:6px;padding:6px 12px;cursor:pointer;font-size:13px;",
|
||
"Sign in",
|
||
);
|
||
signIn.onclick = async () => {
|
||
signIn.disabled = true;
|
||
signIn.textContent = "Waiting for browser…";
|
||
const res = await postJson("/styleref/auth/signin");
|
||
if (res && res.ok === false && res.message) {
|
||
// Headless boxes get the full instructions — they can't just click.
|
||
showMessage(res.message);
|
||
signIn.disabled = false;
|
||
signIn.textContent = "Sign in";
|
||
return;
|
||
}
|
||
// Both sections were rendered against the signed-out state.
|
||
renderMine();
|
||
renderGallery();
|
||
};
|
||
actions.append(signIn);
|
||
|
||
const help = el(
|
||
"a",
|
||
"color:#9b9bff;text-decoration:none;font-size:12px;opacity:.9;",
|
||
"Setup guide",
|
||
);
|
||
help.href = "https://docs.styleref.io/connectors/comfyui/login-and-credits";
|
||
help.target = "_blank";
|
||
help.rel = "noopener";
|
||
actions.append(help);
|
||
|
||
box.append(actions);
|
||
out.append(box);
|
||
};
|
||
|
||
function row(style) {
|
||
const button = el(
|
||
"button",
|
||
"display:flex;gap:10px;align-items:center;width:100%;text-align:left;" +
|
||
"background:#26262a;border:0;color:#eee;border-radius:8px;padding:8px 10px;" +
|
||
"margin-bottom:6px;cursor:pointer;",
|
||
);
|
||
button.onmouseenter = () => (button.style.background = "#32323a");
|
||
button.onmouseleave = () => (button.style.background = "#26262a");
|
||
|
||
// Thumbnail — a fixed box so rows align with or without one.
|
||
const thumb = el(
|
||
"div",
|
||
"width:44px;height:44px;border-radius:6px;background:#1a1a1d;flex:none;overflow:hidden;",
|
||
);
|
||
if (style.heroImage) {
|
||
const img = el("img", "width:100%;height:100%;object-fit:cover;display:block;");
|
||
img.src = style.heroImage;
|
||
img.loading = "lazy";
|
||
img.alt = "";
|
||
thumb.append(img);
|
||
}
|
||
|
||
const text = el("div", "flex:1;min-width:0;");
|
||
const meta = [style.author, style.category].filter(Boolean).join(" · ");
|
||
const nameRow = el("div", "display:flex;align-items:center;gap:6px;min-width:0;");
|
||
nameRow.append(
|
||
el(
|
||
"div",
|
||
"font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;",
|
||
style.name ?? style.slug,
|
||
),
|
||
);
|
||
|
||
// `lastGeneratedAt: null` means the style has never been generated on
|
||
// styleref.io, and loading it answers 409 styleref_not_generated. Only
|
||
// the user's own styles carry the field, so gallery rows never badge.
|
||
// Still selectable: they may have generated it in another tab since
|
||
// this list was fetched, and refusing the click would strand them.
|
||
if (style.lastGeneratedAt === null) {
|
||
const badge = el(
|
||
"span",
|
||
"flex:none;font-size:10px;padding:1px 6px;border-radius:999px;" +
|
||
"background:rgba(180,120,0,.25);color:#f0c060;white-space:nowrap;",
|
||
"not generated",
|
||
);
|
||
badge.title =
|
||
"Never generated on styleref.io — generate it once on the web, then load it here.";
|
||
nameRow.append(badge);
|
||
}
|
||
text.append(nameRow);
|
||
|
||
if (meta) text.append(el("div", "opacity:.65;font-size:12px;margin-top:2px;", meta));
|
||
const tags = (style.tags || []).join(", ");
|
||
if (tags) text.append(el("div", "opacity:.5;font-size:11px;margin-top:2px;", tags));
|
||
|
||
button.append(thumb, text);
|
||
|
||
if (style.url) {
|
||
const link = el(
|
||
"a",
|
||
"flex:none;opacity:.55;font-size:12px;color:#9db4ff;text-decoration:none;padding:4px;",
|
||
"view ↗",
|
||
);
|
||
link.href = style.url;
|
||
link.target = "_blank";
|
||
link.rel = "noopener";
|
||
link.onclick = (e) => e.stopPropagation();
|
||
button.append(link);
|
||
}
|
||
|
||
button.onclick = () => close(style.slug);
|
||
return button;
|
||
}
|
||
|
||
/** A ‹ Prev / Page N / Next › bar. Omitted entirely on a lone page. */
|
||
function pager({ page, hasPrev, hasNext, onPrev, onNext }) {
|
||
if (!hasPrev && !hasNext) return null;
|
||
const bar = el("div", "display:flex;align-items:center;gap:8px;margin:2px 0 12px;");
|
||
|
||
const step = (label, enabled, onClick) => {
|
||
const b = el(
|
||
"button",
|
||
"background:transparent;border:1px solid #3a3a42;border-radius:8px;" +
|
||
"padding:5px 12px;font-size:12px;" +
|
||
(enabled
|
||
? "color:#ddd;cursor:pointer;"
|
||
: "color:#666;cursor:default;opacity:.5;"),
|
||
label,
|
||
);
|
||
// Disabled by not binding a handler — `disabled` on a plain button
|
||
// inside the canvas overlay still swallows the click in LiteGraph.
|
||
if (enabled) b.onclick = onClick;
|
||
return b;
|
||
};
|
||
|
||
bar.append(
|
||
step("‹ Prev", hasPrev, onPrev),
|
||
el("div", "opacity:.55;font-size:12px;", `Page ${page + 1}`),
|
||
step("Next ›", hasNext, onNext),
|
||
);
|
||
return bar;
|
||
}
|
||
|
||
/** Replace a section's contents with a header plus whatever `body` adds. */
|
||
function section(box, title, build) {
|
||
const next = document.createDocumentFragment();
|
||
next.append(sectionHeader(title));
|
||
build(next);
|
||
box.replaceChildren(next);
|
||
}
|
||
|
||
// ── My styles: keyset paging ────────────────────────────────────────
|
||
// The API pages this lane by opaque cursor, which only walks forward, so
|
||
// Prev works off a stack: index N holds the cursor that produced page N.
|
||
let minePage = 0;
|
||
let mineCursors = [null];
|
||
let mineRequest = 0;
|
||
|
||
async function renderMine() {
|
||
const id = ++mineRequest;
|
||
const query = input.value.trim();
|
||
section(mineBox, "My styles", (out) => out.append(message("Loading…")));
|
||
|
||
const cursor = mineCursors[minePage] ?? null;
|
||
const url =
|
||
`/styleref/my-styles?limit=${PAGE_SIZE}` +
|
||
(query ? `&query=${encodeURIComponent(query)}` : "") +
|
||
(cursor ? `&cursor=${encodeURIComponent(cursor)}` : "");
|
||
const mine = await fetchJson(url);
|
||
if (id !== mineRequest) return; // a newer keystroke superseded this
|
||
|
||
// Remember the cursor for the page after this one, so Next knows it
|
||
// exists and can move there without refetching.
|
||
if (mine.nextCursor) mineCursors[minePage + 1] = mine.nextCursor;
|
||
else mineCursors.length = minePage + 1;
|
||
|
||
section(mineBox, "My styles", (out) => {
|
||
if (mine.signedIn === false) {
|
||
signInPrompt(out, "your style library");
|
||
return;
|
||
}
|
||
if (mine.error) {
|
||
out.append(message(mine.error));
|
||
return;
|
||
}
|
||
const styles = mine.styles || [];
|
||
if (!styles.length) {
|
||
out.append(
|
||
message(
|
||
query
|
||
? `None of your styles match "${query}".`
|
||
: "No styles in your library yet.",
|
||
),
|
||
);
|
||
// A filtered-to-nothing page 2+ still needs a way back.
|
||
if (minePage === 0) return;
|
||
}
|
||
for (const style of styles) out.append(row(style));
|
||
|
||
const bar = pager({
|
||
page: minePage,
|
||
hasPrev: minePage > 0,
|
||
hasNext: Boolean(mineCursors[minePage + 1]),
|
||
onPrev: () => {
|
||
minePage -= 1;
|
||
renderMine();
|
||
},
|
||
onNext: () => {
|
||
minePage += 1;
|
||
renderMine();
|
||
},
|
||
});
|
||
if (bar) out.append(bar);
|
||
});
|
||
}
|
||
|
||
// ── Gallery / Saved: offset paging ──────────────────────────────────
|
||
let galleryPage = 0;
|
||
let galleryRequest = 0;
|
||
|
||
async function renderGallery() {
|
||
const id = ++galleryRequest;
|
||
const query = input.value.trim();
|
||
const saved = sort.value === "saved";
|
||
const title = saved ? "Saved" : "Gallery";
|
||
section(galleryBox, title, (out) => out.append(message("Searching…")));
|
||
|
||
const offset = galleryPage * PAGE_SIZE;
|
||
const params =
|
||
`?query=${encodeURIComponent(query)}&limit=${PAGE_SIZE}&offset=${offset}` +
|
||
(category.value ? `&category=${encodeURIComponent(category.value)}` : "");
|
||
const url = saved
|
||
? `/styleref/saved${params}`
|
||
: `/styleref/search${params}&sort=${encodeURIComponent(sort.value)}`;
|
||
|
||
const gallery = await fetchJson(url);
|
||
if (id !== galleryRequest) return;
|
||
|
||
section(galleryBox, title, (out) => {
|
||
if (gallery.signedIn === false) {
|
||
signInPrompt(out, "the styles you saved");
|
||
return;
|
||
}
|
||
if (gallery.error) {
|
||
// Inline message row, never alert().
|
||
out.append(message(gallery.error));
|
||
return;
|
||
}
|
||
const styles = gallery.styles || [];
|
||
if (!styles.length) {
|
||
out.append(
|
||
message(
|
||
saved
|
||
? query
|
||
? `None of your saved styles match "${query}".`
|
||
: "You haven't saved any gallery styles yet."
|
||
: query
|
||
? `No styles matched "${query}". Try a broader query.`
|
||
: "No styles found. Browse styleref.io/gallery",
|
||
),
|
||
);
|
||
if (galleryPage === 0) return;
|
||
}
|
||
for (const style of styles) out.append(row(style));
|
||
|
||
const bar = pager({
|
||
page: galleryPage,
|
||
hasPrev: galleryPage > 0,
|
||
hasNext: Boolean(gallery.hasMore),
|
||
onPrev: () => {
|
||
galleryPage -= 1;
|
||
renderGallery();
|
||
},
|
||
onNext: () => {
|
||
galleryPage += 1;
|
||
renderGallery();
|
||
},
|
||
});
|
||
if (bar) out.append(bar);
|
||
});
|
||
}
|
||
|
||
/** A changed filter invalidates both lanes' paging — restart at page 1. */
|
||
function renderAll() {
|
||
minePage = 0;
|
||
mineCursors = [null];
|
||
galleryPage = 0;
|
||
renderMine();
|
||
renderGallery();
|
||
}
|
||
|
||
let debounce = null;
|
||
input.oninput = () => {
|
||
clearTimeout(debounce);
|
||
debounce = setTimeout(() => renderAll(), 250);
|
||
};
|
||
// Category only narrows the gallery lane, but resetting both keeps "page
|
||
// N" honest across the dialog rather than leaving one lane deep in a
|
||
// result set the user can no longer see the start of.
|
||
category.onchange = () => renderAll();
|
||
sort.onchange = () => renderAll();
|
||
|
||
overlay.onclick = (e) => {
|
||
if (e.target === overlay) close(null);
|
||
};
|
||
overlay.append(panel);
|
||
document.body.append(overlay);
|
||
input.focus();
|
||
renderAll();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* The Login node's status + buttons, backed by /styleref/auth/*. The status
|
||
* line doubles as the refresh button. Button labels are widget names, which
|
||
* both frontends render, so no custom widget type is involved.
|
||
*/
|
||
function enhanceLoginNode(node) {
|
||
const statusWidget = node.addWidget("button", "Status: checking…", null, () =>
|
||
refreshLoginStatus(node, statusWidget),
|
||
);
|
||
|
||
node.addWidget("button", "Sign in (browser)", null, async () => {
|
||
setStatus(node, statusWidget, "Status: waiting for browser approval…");
|
||
const res = await postJson("/styleref/auth/signin");
|
||
setStatus(node, statusWidget, loginStatusLabel(res));
|
||
// A failed sign-in carries instructions (e.g. the headless/token
|
||
// path) that don't fit a status label — show them in full.
|
||
if (res && res.ok === false && res.message) showMessage(res.message);
|
||
});
|
||
|
||
node.addWidget("button", "Sign out", null, async () => {
|
||
const res = await postJson("/styleref/auth/signout");
|
||
setStatus(node, statusWidget, loginStatusLabel(res));
|
||
});
|
||
|
||
// Keep the status honest without a manual click: re-check when the node's
|
||
// own queue-driven action ran, when the tab regains focus (the browser
|
||
// sign-in happens in another tab), and on a slow heartbeat.
|
||
const onExecuted = node.onExecuted;
|
||
node.onExecuted = function (...args) {
|
||
refreshLoginStatus(node, statusWidget);
|
||
return onExecuted?.apply(this, args);
|
||
};
|
||
const onFocus = () => refreshLoginStatus(node, statusWidget);
|
||
window.addEventListener("focus", onFocus);
|
||
const heartbeat = setInterval(onFocus, 30_000);
|
||
|
||
const onRemoved = node.onRemoved;
|
||
node.onRemoved = function (...args) {
|
||
window.removeEventListener("focus", onFocus);
|
||
clearInterval(heartbeat);
|
||
return onRemoved?.apply(this, args);
|
||
};
|
||
|
||
refreshLoginStatus(node, statusWidget);
|
||
}
|
||
|
||
/** A small dismissable overlay for multi-line messages (no alert()). */
|
||
function showMessage(text) {
|
||
const overlay = el(
|
||
"div",
|
||
"position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:10000;" +
|
||
"display:flex;align-items:center;justify-content:center;",
|
||
);
|
||
const panel = el(
|
||
"div",
|
||
"background:#1c1c1f;color:#eee;border-radius:12px;padding:16px;max-width:560px;" +
|
||
"max-height:70vh;overflow:auto;font-family:system-ui,sans-serif;font-size:13px;" +
|
||
"white-space:pre-wrap;box-shadow:0 20px 60px rgba(0,0,0,.5);",
|
||
text,
|
||
);
|
||
const close = el(
|
||
"button",
|
||
"display:block;margin-top:12px;background:transparent;border:1px solid #444;" +
|
||
"color:#bbb;border-radius:8px;padding:8px 14px;cursor:pointer;",
|
||
"Close",
|
||
);
|
||
close.onclick = () => overlay.remove();
|
||
panel.append(close);
|
||
overlay.onclick = (e) => {
|
||
if (e.target === overlay) overlay.remove();
|
||
};
|
||
overlay.append(panel);
|
||
document.body.append(overlay);
|
||
}
|
||
|
||
function loginStatusLabel(payload) {
|
||
if (payload?.error) return `Status: unavailable (${payload.error})`;
|
||
return payload?.signedIn ? "Status: ✔ signed in" : "Status: signed out";
|
||
}
|
||
|
||
/**
|
||
* Write a button widget's visible text, on both frontends.
|
||
*
|
||
* This is why the status used to be stuck on "checking…" forever. Writing only
|
||
* `widget.name` is enough for LiteGraph, which draws `label || name` straight
|
||
* from the widget each frame. The Vue frontend (Nodes 2.0) renders
|
||
* `label ?? name` once into a DOM button and repaints from its own reactive
|
||
* copy, so a bare `name` assignment changed a field nothing was watching and the
|
||
* first label ever set stayed on screen — no failed request involved, which is
|
||
* why it looked like a hang rather than an error.
|
||
*
|
||
* Setting `label` is what actually fixes it; `name` is kept in step so the
|
||
* LiteGraph path and anything reading the widget by name still agree.
|
||
*/
|
||
function setStatus(node, widget, text) {
|
||
widget.label = text;
|
||
widget.name = text;
|
||
node.setDirtyCanvas(true, true);
|
||
// Nodes 2.0 draws from the canvas object, not the node, and ignores the
|
||
// per-node dirty flag above.
|
||
app.canvas?.setDirty?.(true, true);
|
||
}
|
||
|
||
const NAME_LABEL = "loaded style";
|
||
const NAME_IDLE = "—";
|
||
|
||
/**
|
||
* A read-only line naming the loaded style, placed immediately under
|
||
* `style_ref` — the field it describes.
|
||
*
|
||
* Two things make this safe to insert mid-list rather than append. It is marked
|
||
* non-serializing on both the widget and its options (frontends have read one
|
||
* or the other), and `computeSize` is left alone so layout stays the frontend's
|
||
* business. That matters because `widgets_values` is a positional array: a
|
||
* widget that serialises from the middle of the list shifts every value after
|
||
* it, and a saved workflow reloads with `search` holding the category, and so
|
||
* on. The existing buttons all append precisely to avoid that question.
|
||
*
|
||
* If a frontend ignores the flags, the failure is visible immediately — reload
|
||
* a saved graph and the fields are wrong — which is what the smoke test checks.
|
||
* Nothing here is load-bearing: drop this widget and the node is unchanged.
|
||
*/
|
||
function addStyleNameWidget(node) {
|
||
const widget = node.addWidget("text", NAME_LABEL, NAME_IDLE, () => {}, {
|
||
serialize: false,
|
||
});
|
||
widget.serialize = false;
|
||
widget.serializeValue = () => undefined;
|
||
// Read-only: it reports, it is not an input. `disabled` covers the canvas
|
||
// widget; `inputEl` exists only once a DOM-backed frontend has built it.
|
||
widget.disabled = true;
|
||
if (widget.inputEl) widget.inputEl.readOnly = true;
|
||
|
||
// Move it from the end (where addWidget put it) to just after `style_ref`.
|
||
const widgets = node.widgets ?? [];
|
||
const from = widgets.indexOf(widget);
|
||
const anchor = widgets.findIndex((w) => w.name === "style_ref");
|
||
if (from > -1 && anchor > -1 && from !== anchor + 1) {
|
||
widgets.splice(from, 1);
|
||
widgets.splice(anchor + 1, 0, widget);
|
||
}
|
||
return widget;
|
||
}
|
||
|
||
/** The widget's value. Truncated so a long name cannot widen the node. */
|
||
function styleNameValue(res) {
|
||
if (!res || res.ok !== true) {
|
||
// A half-typed slug is the common case here, not a failure worth
|
||
// shouting about — the node still queues and reports properly.
|
||
return res?.error ? res.error : "not found";
|
||
}
|
||
const name = String(res.name ?? "").trim() || "Untitled style";
|
||
return name.length > 34 ? `${name.slice(0, 33)}…` : name;
|
||
}
|
||
|
||
/**
|
||
* Sets the widget's *value*, not its name — unlike the Login status line,
|
||
* which is a button whose name is its label. Here the name is the static
|
||
* "loaded style" caption and the value is the answer.
|
||
*/
|
||
function setStyleName(node, widget, text) {
|
||
widget.value = text;
|
||
node.setDirtyCanvas(true, true);
|
||
app.canvas?.setDirty?.(true, true);
|
||
}
|
||
|
||
async function refreshStyleName(node, nameWidget) {
|
||
if (!nameWidget) return;
|
||
const ref = (node.widgets?.find((w) => w.name === "style_ref")?.value ?? "")
|
||
.toString()
|
||
.trim();
|
||
if (!ref) {
|
||
setStyleName(node, nameWidget, NAME_IDLE);
|
||
return;
|
||
}
|
||
setStyleName(node, nameWidget, "resolving…");
|
||
const res = await fetchJson(`/styleref/resolve?ref=${encodeURIComponent(ref)}`);
|
||
// The field may have moved on while the request was in flight (typing is
|
||
// faster than a round trip); a stale answer must not overwrite a newer one.
|
||
const current = (node.widgets?.find((w) => w.name === "style_ref")?.value ?? "")
|
||
.toString()
|
||
.trim();
|
||
if (current !== ref) return;
|
||
setStyleName(node, nameWidget, styleNameValue(res));
|
||
}
|
||
|
||
/**
|
||
* Resolve on a settled edit, not on every keystroke — a slug is pasted or typed
|
||
* in bursts, and one request per character would hammer the API for prefixes
|
||
* that cannot resolve anyway.
|
||
*/
|
||
function watchStyleRef(node, nameWidget) {
|
||
const refWidget = node.widgets?.find((w) => w.name === "style_ref");
|
||
if (!refWidget) return;
|
||
|
||
let timer = null;
|
||
const schedule = () => {
|
||
clearTimeout(timer);
|
||
timer = setTimeout(() => refreshStyleName(node, nameWidget), 400);
|
||
};
|
||
|
||
const previous = refWidget.callback;
|
||
refWidget.callback = function (...args) {
|
||
const out = previous?.apply(this, args);
|
||
schedule();
|
||
return out;
|
||
};
|
||
|
||
// Templates and reloaded graphs arrive with the field already filled, so
|
||
// the name must appear without the user touching anything.
|
||
schedule();
|
||
}
|
||
|
||
async function refreshLoginStatus(node, statusWidget) {
|
||
try {
|
||
const res = await fetchJson("/styleref/auth/status");
|
||
setStatus(node, statusWidget, loginStatusLabel(res));
|
||
} catch (err) {
|
||
// fetchJson swallows transport errors, but a frontend API change could
|
||
// still throw here — and silently leaving "checking…" on screen is the
|
||
// exact failure this function exists to prevent.
|
||
setStatus(node, statusWidget, `Status: unavailable (${err?.message ?? err})`);
|
||
}
|
||
}
|
||
|
||
/** True when Apply's `positive` output feeds a CLIPTextEncode. */
|
||
function positiveFeedsClipEncode(node) {
|
||
const graph = node.graph;
|
||
const output = node.outputs?.find((o) => o.name === "positive");
|
||
if (!graph || !output?.links?.length) return false;
|
||
return output.links.some((linkId) => {
|
||
const link = graph.links?.[linkId];
|
||
const target = link ? graph.getNodeById(link.target_id) : null;
|
||
return target?.type === "CLIPTextEncode";
|
||
});
|
||
}
|
||
|
||
app.registerExtension({
|
||
name: "styleref.load.search",
|
||
|
||
async setup() {
|
||
await probeRoutes();
|
||
},
|
||
|
||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||
if (nodeData?.name === LOAD_NODE) {
|
||
const onCreated = nodeType.prototype.onNodeCreated;
|
||
nodeType.prototype.onNodeCreated = function () {
|
||
const result = onCreated?.apply(this, arguments);
|
||
|
||
// The name line, directly under `style_ref`. A slug says
|
||
// nothing about which style is wired in, and before the first
|
||
// queue the node cannot say either — so resolve it as soon as
|
||
// the field settles.
|
||
let nameWidget = null;
|
||
if (routesAvailable !== false) {
|
||
nameWidget = addStyleNameWidget(this);
|
||
watchStyleRef(this, nameWidget);
|
||
}
|
||
|
||
// Only add the search button when the backend routes exist
|
||
// — otherwise it could only 404.
|
||
if (routesAvailable !== false) {
|
||
this.addWidget("button", "Search styles…", null, async () => {
|
||
const refWidget = this.widgets?.find((w) => w.name === "style_ref");
|
||
const searchWidget = this.widgets?.find((w) => w.name === "search");
|
||
if (!refWidget) return;
|
||
|
||
const chosen = await openPicker((searchWidget?.value ?? "").trim());
|
||
if (!chosen) return;
|
||
|
||
refWidget.value = chosen;
|
||
refWidget.callback?.(chosen);
|
||
this.setDirtyCanvas(true, true);
|
||
refreshStyleName(this, nameWidget);
|
||
});
|
||
}
|
||
|
||
// Refresh affordance: the backend drops its cached
|
||
// copy and dirties the node, so the next queue re-downloads —
|
||
// no need to toggle use_cache off and back on.
|
||
if (routesAvailable !== false) {
|
||
this.addWidget("button", "↻ Refresh style", null, async () => {
|
||
const refWidget = this.widgets?.find((w) => w.name === "style_ref");
|
||
const ref = (refWidget?.value ?? "").trim();
|
||
if (!ref) return;
|
||
await postJson("/styleref/refresh", { ref });
|
||
this.setDirtyCanvas(true, true);
|
||
});
|
||
}
|
||
|
||
return result;
|
||
};
|
||
}
|
||
|
||
if (nodeData?.name === LOGIN_NODE && routesAvailable !== false) {
|
||
const onCreated = nodeType.prototype.onNodeCreated;
|
||
nodeType.prototype.onNodeCreated = function () {
|
||
const result = onCreated?.apply(this, arguments);
|
||
enhanceLoginNode(this);
|
||
return result;
|
||
};
|
||
}
|
||
|
||
if (nodeData?.name === APPLY_NODE) {
|
||
// Warning badge: `natural`/`style_md` are copy-out
|
||
// documents; wiring one into a CLIP encoder is almost certainly a
|
||
// mistake. Additive drawing only — nothing else is touched.
|
||
const onDrawForeground = nodeType.prototype.onDrawForeground;
|
||
nodeType.prototype.onDrawForeground = function (ctx) {
|
||
onDrawForeground?.apply(this, arguments);
|
||
if (this.flags?.collapsed) return;
|
||
const target = this.widgets?.find((w) => w.name === "target");
|
||
if (!COPY_OUT_TARGETS.includes(target?.value) || !positiveFeedsClipEncode(this))
|
||
return;
|
||
|
||
const label = `⚠ ${target.value} target → CLIP encoder`;
|
||
ctx.save();
|
||
ctx.font = "11px system-ui, sans-serif";
|
||
const width = ctx.measureText(label).width + 14;
|
||
ctx.fillStyle = "rgba(180,120,0,.85)";
|
||
ctx.beginPath();
|
||
ctx.roundRect(this.size[0] - width - 8, -46, width, 20, 5);
|
||
ctx.fill();
|
||
ctx.fillStyle = "#fff";
|
||
ctx.fillText(label, this.size[0] - width - 1, -32);
|
||
ctx.restore();
|
||
};
|
||
}
|
||
},
|
||
});
|