Files
BehzadandClaude Opus 5 26b02e86f0 feat(picker): copy prompt, actionable errors, and a library lane worth reading
Release 1.1.0. Everything here came out of using the style picker and finding
the same shape of problem in several places: the connector showed the REST
API's own words to someone who cannot act on them, or showed nothing at all.

Errors, in one place. `friendly_error`/`short_error` (styleref_api.py) rewrite
an API failure as something a node graph can act on — the Login node's Sign in
button, the Search styles… dialog, generate-it-once for a 409 — and Load and
Apply re-raise through them. The raw 401 told users to send an
`Authorization: Bearer` header into ComfyUI's red error window, which has no
buttons at all. `short_error` is the same failure at widget width; the
"loaded style" line used to render the whole paragraph uncut, because the
length guard sat on the success branch only.

The picker:
- Sign in moved to the title row. It changes what the whole dialog can show,
  so it is not a property of one list.
- "My styles" hides itself when empty, and its rows now carry the thumbnail,
  category and tags a gallery row carries — with visibility (Private /
  Link-shared / Public) where a gallery row carries its author. A name on a
  blank square sat directly above real cards.
- The gallery lane drops styles you own, so a published style of yours appears
  once rather than in both lanes.
- No matches now offers /styles/create instead of a dead end.
- ↓/↑ walk the rows, Enter takes the top hit, Esc closes; query, category and
  sort survive between opens.

Load hides `search`/`category` when the dialog is available — they are the
headless fallback, and next to a working button they are the same duplication
the Login node's `action` dropdown was removed for. Picking also clears the
query, which otherwise made every later execution run a search it did not need.

Apply gains a Copy prompt button. Four of its six targets exist to be copied
out of ComfyUI, and the only way to read one in full was to wire up a Preview
Any node. The warning badge now matches any text encoder, not the exact class
`CLIPTextEncode` — SDXL, SD3 and Flux ship their own, so it did nothing on most
real graphs.

Reference Images no longer fails the queue when a style has no inspiration
images: it feeds IPAdapter or a preview, never the sampler, so it reports and
returns an empty batch. `max_images: 0` mutes it without rewiring.

Needs the matching StyleRef-Webapp deploy: the library lane's category, tags
and heroImage come from /api/v1/me/styles, and its search now scores the same
fields the gallery search scores.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 18:22:01 +02:00

1156 lines
49 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 text encoder — those are for copying out, not sampler text — and a
* "Copy prompt" button, since copying out is what those four targets are
* for and the node body only previews 200 characters.
*
* 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",
];
// Own-style rows show this where a gallery row shows its author. An unknown or
// absent value contributes nothing rather than a raw enum.
const VISIBILITY_LABELS = {
private: "Private",
"link-shared": "Link-shared",
public: "Public",
};
// 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;
}
// Survives between opens. Picking a style is rarely a one-shot decision —
// people open the dialog, try one, open it again — and re-typing the query and
// re-picking the category every time is the tax for treating each open as new.
const pickerState = { query: "", category: "", sort: "recent" };
/**
* 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);
};
/** Every result row currently on screen, in visual order. */
const rows = () => Array.from(panel.querySelectorAll("[data-styleref-row]"));
const moveFocus = (delta) => {
const list = rows();
if (!list.length) return;
const at = list.indexOf(document.activeElement);
const next =
at === -1
? delta > 0
? 0
: list.length - 1
: Math.min(list.length - 1, Math.max(0, at + delta));
list[next].focus();
};
const onKey = (e) => {
// Capturing throughout, so the canvas never sees these: ComfyUI
// binds arrows and Enter globally, and a search dialog that pans the
// graph behind it while you pick a style is worse than no keyboard
// support at all.
if (e.key === "Escape") {
e.stopPropagation();
close(null);
return;
}
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
e.stopPropagation();
moveFocus(e.key === "ArrowDown" ? 1 : -1);
return;
}
// Rows are real buttons, so Enter on a focused row already picks it.
// This is the other half: type a query, press Enter, take the top hit.
if (e.key === "Enter" && document.activeElement === input) {
e.preventDefault();
e.stopPropagation();
rows()[0]?.click();
}
};
document.addEventListener("keydown", onKey, true);
// Title row. The sign-in control lives up here, next to the title,
// because signing in changes what the whole dialog can show — it is not
// a property of the "My styles" list, and burying it there meant the
// signed-out state had to explain itself in a paragraph.
const titleRow = el(
"div",
"display:flex;align-items:center;gap:10px;margin-bottom:12px;",
);
titleRow.append(el("div", "font-weight:600;font-size:15px;flex:1;", "Pick a style"));
const authSlot = el("div", "display:flex;align-items:center;gap:8px;");
titleRow.append(authSlot);
panel.append(titleRow);
// ── 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 || pickerState.query;
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 stays in this
// dropdown because from the user's side all three answer one question —
// which styles am I looking at? — but the groups say which of the two
// things each option changes, so "Saved" no longer reads as a sort.
for (const [groupLabel, options] of [
["Gallery", [["recent", "Recent"], ["popular", "Popular"]]],
["Your styles", [["saved", "Saved"]]],
]) {
const group = document.createElement("optgroup");
group.label = groupLabel;
for (const [value, label] of options) {
const option = el("option", "", label);
option.value = value;
group.append(option);
}
sort.append(group);
}
sort.value = pickerState.sort;
category.value = pickerState.category;
controls.append(input, category, sort);
panel.append(controls);
// The category filter is a gallery-search parameter; the library lane
// is filtered by the query alone. That asymmetry is invisible until a
// category is set and "My styles" keeps showing styles from others.
const scopeNote = el(
"div",
"opacity:.45;font-size:11px;margin:-6px 2px 10px;",
"Category narrows the gallery only — My styles is filtered by the search box.",
);
const syncScopeNote = () => {
scopeNote.style.display =
category.value && signedIn && sort.value !== "saved" ? "block" : "none";
};
panel.append(scopeNote);
// `null` until /styleref/auth/status answers — see loadAuth().
let signedIn = null;
// 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);
/**
* The title-row sign-in control. Signed out it is a single "Sign in"
* button — the sentence that used to explain the signed-out state was
* the API's own 401 text, written for HTTP callers who can set an
* Authorization header, which nobody can do from inside ComfyUI. Signed
* in it says so quietly, so the header never looks like a call to action
* once there is nothing to do there.
*/
function renderAuth() {
authSlot.replaceChildren();
if (signedIn === null) return; // status still in flight
if (signedIn) {
authSlot.append(el("div", "opacity:.5;font-size:12px;", "Signed in"));
return;
}
const signIn = el(
"button",
"background:#4f46e5;border:0;color:#fff;border-radius:6px;padding:6px 12px;" +
"cursor:pointer;font-size:13px;",
"Sign in",
);
signIn.title = "Sign in to load your own styles.";
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;
}
signedIn = true;
renderAuth();
// Both lanes were rendered against the signed-out state.
renderAll();
};
authSlot.append(signIn);
const hint = el("div", "opacity:.5;font-size:12px;", "to load your own styles");
authSlot.append(hint);
}
/**
* Nothing matched: point at the one thing that always works from here —
* making the style you were looking for. The link opens styleref.io
* rather than trying to drive extraction from the canvas.
*/
const createPrompt = (out, text) => {
const box = el(
"div",
"background:#26262a;border-radius:8px;padding:10px 12px;margin-bottom:6px;" +
"font-size:13px;display:flex;align-items:center;gap:10px;flex-wrap:wrap;",
);
box.append(el("div", "opacity:.75;flex:1;min-width:140px;", text));
const create = el(
"a",
"background:#4f46e5;color:#fff;border-radius:6px;padding:6px 12px;" +
"text-decoration:none;font-size:13px;white-space:nowrap;",
"Extract and create custom style",
);
create.href = "https://styleref.io/styles/create";
create.target = "_blank";
create.rel = "noopener";
box.append(create);
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;",
);
// Marked for rows(), which drives arrow-key navigation.
button.dataset.stylerefRow = "1";
const highlight = (on) => {
button.style.background = on ? "#32323a" : "#26262a";
button.style.outline = on ? "1px solid #6b6bff" : "none";
};
button.onmouseenter = () => (button.style.background = "#32323a");
button.onmouseleave = () =>
(button.style.background =
document.activeElement === button ? "#32323a" : "#26262a");
button.onfocus = () => {
highlight(true);
// Eight rows can outrun the panel's scroll box.
button.scrollIntoView({ block: "nearest" });
};
button.onblur = () => highlight(false);
// 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;");
// Own rows carry no author — they are yours — so the same line
// carries visibility instead, which is the one thing about your own
// style the name cannot tell you.
const meta = [style.author, VISIBILITY_LABELS[style.visibility], 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();
// Signed out there is no library to page, and an empty labelled
// section is just a header explaining its own emptiness — the Sign
// in button in the title row already says what to do about it.
if (signedIn === false) {
mineBox.replaceChildren();
return;
}
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;
// A 401 mid-flight means the stored session died; fall back to the
// signed-out layout rather than showing the lane's own error.
if (mine.signedIn === false) {
signedIn = false;
renderAuth();
mineBox.replaceChildren();
return;
}
// Page 1 with nothing on it: hide the lane. Whether the library is
// empty or just filtered to nothing, the gallery below is the answer,
// and it already offers "create a style" when it comes up empty too.
if (!mine.error && !(mine.styles || []).length && minePage === 0) {
mineBox.replaceChildren();
return;
}
section(mineBox, "My styles", (out) => {
if (mine.error) {
out.append(message(mine.error));
return;
}
const styles = mine.styles || [];
if (!styles.length) {
// Only reachable past page 1 — keep the pager so Prev exists.
out.append(
message(
query
? `None of your styles match "${query}".`
: "No styles in your library yet.",
),
);
}
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) {
// "Saved" is the only signed-in source down here.
signedIn = false;
renderAuth();
out.append(message("Sign in to load your own styles."));
return;
}
if (gallery.error) {
// Inline message row, never alert().
out.append(message(gallery.error));
return;
}
const styles = gallery.styles || [];
if (!styles.length) {
createPrompt(
out,
saved
? query
? `None of your saved styles match "${query}".`
: "You haven't saved any gallery styles yet."
: query
? `No styles matched "${query}".`
: "No styles found.",
);
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);
});
}
/**
* Auth state for the whole dialog: `null` until the status call lands,
* so neither the header nor the lanes flash a signed-out layout at a
* signed-in user.
*/
async function loadAuth() {
const status = await fetchJson("/styleref/auth/status");
signedIn = status.signedIn === true;
renderAuth();
syncScopeNote(); // the note only applies while a library lane exists
renderMine();
}
/** A changed filter invalidates both lanes' paging — restart at page 1. */
function renderAll() {
pickerState.query = input.value.trim();
pickerState.category = category.value;
pickerState.sort = sort.value;
syncScopeNote();
minePage = 0;
mineCursors = [null];
galleryPage = 0;
// Before the status call lands, loadAuth() owns the first render of
// this lane — calling it here too would just fetch the same page twice.
if (signedIn !== null) 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();
loadAuth();
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);
}
/**
* Hide a widget without removing it from `node.widgets`.
*
* `widgets_values` is a positional array, so splicing a widget out of the list
* would shift every value after it and reload a saved workflow with the wrong
* fields — the same trap documented on the name widget below. Hiding leaves the
* array intact, so the value still serialises and a frontend without the search
* dialog still shows the field.
*
* `hidden` is what current frontends read; the zero `computeSize` is the older
* LiteGraph path. A frontend that honours neither shows the widget, which is
* exactly today's behaviour — this degrades to a no-op, never to a broken node.
*/
function hideWidget(widget) {
if (!widget) return;
widget.hidden = true;
widget.computeSize = () => [0, -4];
}
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;
}
// Every value this widget can hold is cut to this. It is one line under
// `style_ref`, and a longer string either widens the node or is cut mid-word.
const NAME_MAX_CHARS = 34;
function clampName(text) {
const value = String(text ?? "").trim();
return value.length > NAME_MAX_CHARS ? `${value.slice(0, NAME_MAX_CHARS - 1)}…` : value;
}
/**
* The widget's value — always short, on the failure path as well as the good one.
*
* The failure path used to return the server's `message`, which is a paragraph
* of sign-in instructions written for HTTP callers. It arrived here uncut,
* because the length guard sat on the success branch only, and stretched the
* node. `short` is the server-side label for the same failure (`short_error`
* in styleref_api.py); the full text still exists, for the surfaces that have
* room for it.
*/
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 clampName(res?.short || "not found");
}
return clampName(res.name || "Untitled style");
}
/**
* 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));
// The picker marks a never-generated style on its row; queueing one is a
// 409. Carrying the same warning here means the node says so while the
// user is still looking at the field, not after the run fails.
nameWidget.tooltip = res?.notGenerated
? "Never generated on styleref.io — generate it once there, then load it here."
: res?.error || undefined;
}
/**
* 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})`);
}
}
// Every prompt encoder in circulation, not just the stock one: SDXL, SD3 and
// Flux ship their own classes, and half the custom packs wrap them. Matching
// the exact string "CLIPTextEncode" meant the warning quietly did nothing on
// most real graphs. Substring matching can over-fire; a badge that appears once
// too often is a far cheaper mistake than one that never appears.
const TEXT_ENCODER_PATTERN = /textencode|textencoder|encodetext/i;
/** True when Apply's `positive` output feeds a text encoder. */
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 TEXT_ENCODER_PATTERN.test(target?.type ?? "");
});
}
/**
* The Apply node's Copy prompt button.
*
* Four of the six targets — ai_tools, style_md, midjourney, json — exist to be
* copied *out* of ComfyUI, and until now the only way to read one in full was to
* wire up a Preview Any node: the node body shows 200 characters and cannot be
* selected. The Python side sends the whole composed text in the execution
* payload (`styleref_prompt`), which is where this reads it from.
*/
const COPY_LABEL = "📋 Copy prompt";
function enhanceApplyNode(node) {
let prompt = "";
const button = node.addWidget("button", COPY_LABEL, null, async () => {
if (!prompt) {
showMessage(
"Nothing composed yet — queue this node once, then copy.\n\n" +
"The button copies the same text the `positive` output carries.",
);
return;
}
try {
// Secure context only. ComfyUI on plain http:// over a LAN is not
// one, and a rejected promise here would leave the user with no
// text and no explanation.
await navigator.clipboard.writeText(prompt);
setStatus(node, button, "✔ Copied");
setTimeout(() => setStatus(node, button, COPY_LABEL), 1500);
} catch {
// The fallback is not a lesser copy — it is the text itself, in a
// window the user can select from.
showMessage(prompt);
}
});
const onExecuted = node.onExecuted;
node.onExecuted = function (message, ...rest) {
prompt = message?.styleref_prompt?.[0] ?? prompt;
return onExecuted?.apply(this, [message, ...rest]);
};
}
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) {
// `search` + `category` are the headless fallback: type a
// query, queue the graph, read slugs out of the node body,
// paste one into `style_ref`. With the dialog present they
// are a second, slower path to the same result sitting right
// next to the button — the same duplication the Login node's
// `action` dropdown was removed for. Hidden, not deleted:
// they still serialise, and a frontend that cannot run this
// file never hides them.
hideWidget(this.widgets?.find((w) => w.name === "search"));
hideWidget(this.widgets?.find((w) => w.name === "category"));
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);
// Clear the fallback query. A leftover value makes every
// later execution of this node run a gallery search it
// does not need, and print those results into the node
// body under the load summary — for the life of the
// saved workflow, on a field that is now hidden.
if (searchWidget && searchWidget.value) {
searchWidget.value = "";
searchWidget.callback?.("");
}
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) {
const onApplyCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const result = onApplyCreated?.apply(this, arguments);
if (routesAvailable !== false) enhanceApplyNode(this);
return result;
};
// Warning badge: `ai_tools`, `style_md`, `midjourney` and `json` are
// copy-out formats, not sampler text; wiring one into a text 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 → text 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();
};
}
},
});