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>
1156 lines
49 KiB
JavaScript
1156 lines
49 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 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();
|
||
};
|
||
}
|
||
},
|
||
});
|