The button now clears the wizard, applies the cleared state to the builder, and closes the modal in one click. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
564 lines
34 KiB
JavaScript
564 lines
34 KiB
JavaScript
/*
|
|
* Part of ComfyUI-AI2Go-Utils.
|
|
*
|
|
* Companion to the Ideogram 4 Prompt Builder. GPL-3.0, like the rest of the pack.
|
|
* Upstream lineage: https://github.com/kijai/ComfyUI-KJNodes
|
|
*
|
|
* A "click-together" wizard for the Ideogram 4 style_description fields. A button on the node opens a
|
|
* fullscreen chip-picker (5 categories). On close the picked values are written straight into a
|
|
* connected AI2Go Ideogram 4 Prompt Builder's style widgets — the node's own data output stays empty
|
|
* so it never overwrites the builder's bounding boxes / palette / high_level_description.
|
|
*/
|
|
import { chainCallback } from "./utility.js";
|
|
const { app } = window.comfyAPI.app;
|
|
|
|
const BUILDER_TYPE = "AI2GoIdeogram4PromptBuilder";
|
|
|
|
// Built-in chip lists (values are exactly what lands in the JSON). Sourced from the research report.
|
|
// These are the fallback / "restore defaults" content; the live lists are read from an editable
|
|
// preset file (see PRESET_FILE) so users can add or replace styles without touching the code.
|
|
// Note: some Photo values contain commas — that's fine; selection uses substring containment, not a
|
|
// naive comma split, so a multi-word value is treated as one chip.
|
|
const DEFAULT_CATEGORIES = [
|
|
{ key: "aesthetics", label: "Aesthetics", chips: [
|
|
"cinematic", "moody", "dreamy", "serene", "vintage", "minimalist", "surreal", "vibrant",
|
|
"cozy", "dramatic", "ethereal", "nostalgic", "dark", "clean", "elegant",
|
|
] },
|
|
{ key: "lighting", label: "Lighting", chips: [
|
|
"golden hour lighting", "blue hour evening light", "sunrise lighting", "sunset lighting",
|
|
"soft diffused lighting", "hard directional lighting", "studio lighting (softbox)", "rim lighting",
|
|
"backlit (silhouette effect)", "volumetric lighting (haze, rays)", "neon lighting", "candlelit",
|
|
"moonlight", "overcast lighting", "high contrast lighting",
|
|
] },
|
|
{ key: "medium", label: "Medium", chips: [
|
|
"photograph", "illustration", "graphic_design", "3d_render", "painting", "pencil_sketch",
|
|
"pixel_art", "line_art", "concept_art", "infographic", "animation",
|
|
] },
|
|
{ key: "photo", label: "Photo (camera / lens)", chips: [
|
|
"35mm lens, f/1.4", "50mm lens, f/1.8", "85mm lens, shallow depth of field", "24mm wide-angle, f/2.8",
|
|
"200mm telephoto, f/2.8", "macro lens, extreme close-up", "drone aerial shot, high angle",
|
|
"instant film photography (Polaroid)", "35mm motion-picture film still", "studio flash lighting, softbox",
|
|
"black-and-white film (e.g. Kodak Tri-X)", "shallow depth of field, bokeh",
|
|
] },
|
|
{ key: "art_style", label: "Art Style", chips: [
|
|
"flat vector illustration", "oil painting", "watercolor illustration", "pencil sketch illustration",
|
|
"comic book style", "isometric illustration", "anime illustration", "low-poly 3D render",
|
|
"hand-drawn doodles style", "retro-futuristic concept art", "flat graphic design style", "art deco style",
|
|
] },
|
|
];
|
|
|
|
const BLANK = () => ({ aesthetics: "", lighting: "", medium: "", photo: "", art_style: "" });
|
|
|
|
// ── Editable style presets, stored server-side via ComfyUI's userdata API. This path maps to
|
|
// ComfyUI/user/default/ai2go/ideogram4/WizardStylesDefault.json. Users can edit the file to add or
|
|
// replace chips; if it's missing or malformed we fall back to DEFAULT_CATEGORIES and warn in the UI.
|
|
const PRESET_FILE = "ai2go/ideogram4/WizardStylesDefault.json";
|
|
const ALLOWED_KEYS = ["aesthetics", "lighting", "medium", "photo", "art_style"];
|
|
const DEFAULT_LABELS = Object.fromEntries(DEFAULT_CATEGORIES.map((c) => [c.key, c.label]));
|
|
// File body written by "Restore defaults": a documented, hand-editable shape.
|
|
const DEFAULT_PRESETS_FILE = {
|
|
_comment: "AI2Go Ideogram 4 Style Wizard presets. Edit 'chips' freely; 'key' must be one of " +
|
|
ALLOWED_KEYS.join(", ") + ". Delete this file or use the wizard's 'Restore defaults' to reset.",
|
|
version: 1,
|
|
categories: DEFAULT_CATEGORIES.map((c) => ({ key: c.key, label: c.label, chips: c.chips.slice() })),
|
|
};
|
|
|
|
// Validate + normalize a parsed preset file into the internal [{key,label,chips}] shape.
|
|
// Returns { cats, error }: cats is the category array on success (error null), or cats null + a
|
|
// human-readable error string describing why the file is unusable (→ caller treats as malformed).
|
|
function parsePresets(text) {
|
|
let obj;
|
|
try { obj = JSON.parse(text); } catch (e) { return { cats: null, error: "Invalid JSON: " + e.message }; }
|
|
const cats = Array.isArray(obj) ? obj : (obj && Array.isArray(obj.categories) ? obj.categories : null);
|
|
if (!cats) return { cats: null, error: 'Expected an array, or an object with a "categories" array.' };
|
|
if (!cats.length) return { cats: null, error: '"categories" is empty.' };
|
|
const seen = new Set();
|
|
const out = [];
|
|
for (let n = 0; n < cats.length; n++) {
|
|
const c = cats[n], where = "category " + (n + 1);
|
|
if (!c || typeof c !== "object") return { cats: null, error: where + " is not an object." };
|
|
if (!ALLOWED_KEYS.includes(c.key)) return { cats: null, error: where + ': "key" must be one of ' + ALLOWED_KEYS.join(", ") + ' (got ' + JSON.stringify(c.key) + ")." };
|
|
if (seen.has(c.key)) return { cats: null, error: where + ': duplicate key "' + c.key + '".' };
|
|
if (!Array.isArray(c.chips)) return { cats: null, error: where + ' ("' + c.key + '"): "chips" must be an array.' };
|
|
seen.add(c.key);
|
|
const chips = c.chips.filter((x) => typeof x === "string" && x.trim()).map((x) => x.trim());
|
|
const label = typeof c.label === "string" && c.label.trim() ? c.label.trim() : (DEFAULT_LABELS[c.key] || c.key);
|
|
out.push({ key: c.key, label, chips });
|
|
}
|
|
return { cats: out, error: null };
|
|
}
|
|
|
|
// Load the live categories: { cats, state, error } where state is "ok" | "missing" | "malformed".
|
|
// Always returns usable cats (the file's when valid, else the built-in defaults).
|
|
async function loadCategories() {
|
|
let res;
|
|
try { res = await app.api.getUserData(PRESET_FILE); } catch (e) { res = null; }
|
|
if (!res || res.status !== 200) return { cats: DEFAULT_CATEGORIES, state: "missing", error: null };
|
|
let text = "";
|
|
try { text = await res.text(); } catch (e) { return { cats: DEFAULT_CATEGORIES, state: "malformed", error: "Couldn't read the file." }; }
|
|
const { cats, error } = parsePresets(text);
|
|
return cats ? { cats, state: "ok", error: null } : { cats: DEFAULT_CATEGORIES, state: "malformed", error };
|
|
}
|
|
|
|
// Write the built-in defaults to the preset file (used by the "Restore defaults" button).
|
|
async function saveDefaultPresets() {
|
|
await app.api.storeUserData(PRESET_FILE, JSON.stringify(DEFAULT_PRESETS_FILE, null, 2),
|
|
{ overwrite: true, stringify: false, throwOnError: true });
|
|
}
|
|
|
|
// Trim + drop empties + de-dupe (case-insensitive) a chip list — used when saving edited presets.
|
|
function dedupeTrim(chips) {
|
|
const seen = new Set(), out = [];
|
|
for (const c of chips || []) {
|
|
const t = (c || "").trim();
|
|
if (t && !seen.has(t.toLowerCase())) { seen.add(t.toLowerCase()); out.push(t); }
|
|
}
|
|
return out;
|
|
}
|
|
// Build the editor's working copy: all five keys in canonical order. A valid file's categories win
|
|
// (a key it omits → empty chips); a missing/malformed file starts from the built-in defaults.
|
|
function toEditCats(cats, state) {
|
|
const base = state === "ok" ? cats : DEFAULT_CATEGORIES;
|
|
const byKey = Object.fromEntries(base.map((c) => [c.key, c]));
|
|
return ALLOWED_KEYS.map((k) => {
|
|
const src = byKey[k];
|
|
return { key: k, label: (src && src.label) || DEFAULT_LABELS[k] || k, chips: src ? src.chips.slice() : [] };
|
|
});
|
|
}
|
|
|
|
// Normalize a comma-joined field: collapse whitespace around commas, drop empty/duplicate items.
|
|
function normField(s) {
|
|
const seen = new Set();
|
|
return (s || "")
|
|
.split(",")
|
|
.map((t) => t.trim())
|
|
.filter((t) => t && !seen.has(t.toLowerCase()) && seen.add(t.toLowerCase()))
|
|
.join(", ");
|
|
}
|
|
// Is `term` present in `field`? Whole-item match first (handles plain terms), substring fallback
|
|
// (handles chip values that themselves contain commas, e.g. "35mm lens, f/1.4").
|
|
function hasTerm(field, term) {
|
|
const f = field || "", t = term.trim();
|
|
if (!t) return false;
|
|
if (f.split(",").some((x) => x.trim().toLowerCase() === t.toLowerCase())) return true;
|
|
return f.toLowerCase().includes(t.toLowerCase());
|
|
}
|
|
// Toggle `term` in `field` (append when absent, remove when present), returning the normalized result.
|
|
function toggleTerm(field, term) {
|
|
const t = term.trim();
|
|
if (!t) return normField(field);
|
|
if (hasTerm(field, t)) {
|
|
// Remove the term as a whole comma-item where possible, else as a substring.
|
|
const items = (field || "").split(",").map((x) => x.trim()).filter(Boolean);
|
|
const kept = items.filter((x) => x.toLowerCase() !== t.toLowerCase());
|
|
let out = kept.join(", ");
|
|
if (kept.length === items.length) out = normField(out.replace(t, "")); // comma-containing chip → substring strip
|
|
return normField(out);
|
|
}
|
|
return normField((field ? field + ", " : "") + t);
|
|
}
|
|
|
|
function injectWizStyle() {
|
|
if (document.getElementById("ai2go-wiz-style")) return;
|
|
const s = document.createElement("style");
|
|
s.id = "ai2go-wiz-style";
|
|
s.textContent = `
|
|
.ai2go-wiz-fs { position:fixed; inset:0; z-index:9200; background:rgba(0,0,0,0.72); display:flex; align-items:center; justify-content:center; }
|
|
.ai2go-wiz-panel { position:relative; width:min(860px,92vw); max-height:90vh; display:flex; flex-direction:column; background:#1a1a1a; border:1px solid #444; border-radius:8px; box-shadow:0 12px 48px rgba(0,0,0,0.6); }
|
|
.ai2go-wiz-head { display:flex; align-items:center; gap:8px; padding:10px 14px; border-bottom:1px solid #333; font:13px sans-serif; color:#ddd; }
|
|
.ai2go-wiz-title { font-weight:600; flex:0 0 auto; }
|
|
.ai2go-wiz-tabs { display:flex; gap:4px; flex:1 1 auto; }
|
|
.ai2go-wiz-tab { background:none; border:1px solid transparent; border-radius:5px; color:#aaa; font:12px sans-serif; cursor:pointer; padding:3px 12px; }
|
|
.ai2go-wiz-tab:hover { color:#fff; }
|
|
.ai2go-wiz-tab.active { color:#46b4e6; border-color:#46b4e6; background:#2a3a42; }
|
|
.ai2go-wiz-body { flex:1 1 auto; overflow-y:auto; padding:12px 14px; display:flex; flex-direction:column; gap:14px; }
|
|
.ai2go-wiz-search { width:100%; box-sizing:border-box; background:#1d1d1d; border:1px solid #444; border-radius:4px; color:#ddd; font:12px sans-serif; padding:5px 8px; }
|
|
.ai2go-wiz-search:focus { border-color:#46b4e6; outline:none; color:#fff; }
|
|
.ai2go-wiz-status { font:13px sans-serif; color:#8fbfd6; min-height:17px; }
|
|
.ai2go-wiz-status.off { color:#e05555; font-weight:600; }
|
|
.ai2go-wiz-edit { display:flex; flex-direction:column; gap:5px; }
|
|
.ai2go-wiz-editrow { display:flex; align-items:center; gap:6px; }
|
|
.ai2go-wiz-editrow .ai2go-wiz-input { font:12px sans-serif; }
|
|
.ai2go-wiz-del { background:none; border:1px solid #555; border-radius:4px; color:#c77; cursor:pointer; font:12px sans-serif; line-height:1; padding:3px 8px; flex:0 0 auto; }
|
|
.ai2go-wiz-del:hover { border-color:#a33; color:#fff; background:#a33; }
|
|
.ai2go-wiz-add { display:flex; align-items:center; gap:6px; margin-top:2px; }
|
|
.ai2go-wiz-dirty { color:#e6a23c; font:11px sans-serif; flex:1 1 auto; }
|
|
.ai2go-wiz-toast { position:absolute; left:50%; bottom:16px; transform:translateX(-50%); background:#2a3a42; color:#cfe8f5; border:1px solid #46b4e6; border-radius:6px; padding:5px 14px; font:12px sans-serif; box-shadow:0 4px 14px rgba(0,0,0,0.5); opacity:1; transition:opacity .4s ease; pointer-events:none; z-index:5; }
|
|
.ai2go-wiz-toast.fade { opacity:0; }
|
|
.ai2go-wiz-banner { display:flex; align-items:flex-start; gap:10px; padding:8px 10px; border-radius:6px; background:#3a2f1a; border:1px solid #6b5320; color:#e6c98c; font:11px/1.5 sans-serif; }
|
|
.ai2go-wiz-banner code { color:#f0d9a6; font:11px monospace; }
|
|
.ai2go-wiz-banner .ai2go-wiz-btn { flex:0 0 auto; align-self:center; }
|
|
.ai2go-wiz-banner-msg { flex:1 1 auto; }
|
|
.ai2go-wiz-cat { display:flex; flex-direction:column; gap:6px; }
|
|
.ai2go-wiz-catlbl { font:11px sans-serif; color:#8fbfd6; letter-spacing:0.03em; text-transform:uppercase; }
|
|
.ai2go-wiz-chips { display:flex; flex-wrap:wrap; gap:5px; }
|
|
.ai2go-wiz-chip { background:#333; border:1px solid #555; border-radius:12px; color:#bbb; font:11px sans-serif; cursor:pointer; padding:2px 10px; line-height:17px; white-space:nowrap; }
|
|
.ai2go-wiz-chip:hover { border-color:#46b4e6; color:#fff; }
|
|
.ai2go-wiz-chip.active { border-color:#46b4e6; color:#46b4e6; background:#2a3a42; }
|
|
.ai2go-wiz-input { width:100%; box-sizing:border-box; background:#1d1d1d; border:1px solid #444; border-radius:4px; color:#ddd; font:12px monospace; padding:4px 6px; }
|
|
.ai2go-wiz-input:focus { border-color:#46b4e6; outline:none; color:#fff; }
|
|
.ai2go-wiz-foot { flex:0 0 auto; padding:10px 14px; border-top:1px solid #333; display:flex; flex-direction:column; gap:8px; }
|
|
.ai2go-wiz-warn { color:#e6a23c; font:11px sans-serif; min-height:14px; }
|
|
.ai2go-wiz-prev { background:#141414; border:1px solid #333; border-radius:4px; color:#9fd29f; font:11px/1.4 monospace; padding:8px; margin:0; max-height:150px; overflow:auto; white-space:pre; }
|
|
.ai2go-wiz-footrow { display:flex; align-items:center; gap:8px; }
|
|
.ai2go-wiz-btn { background:#333; border:1px solid #555; border-radius:4px; color:#bbb; font:12px sans-serif; cursor:pointer; padding:4px 12px; }
|
|
.ai2go-wiz-btn:hover { border-color:#46b4e6; color:#fff; }
|
|
.ai2go-wiz-btn.primary { border-color:#46b4e6; color:#46b4e6; background:#2a3a42; }
|
|
.ai2go-wiz-x { background:none; border:none; color:#999; cursor:pointer; font:16px sans-serif; line-height:1; padding:2px 6px; }
|
|
.ai2go-wiz-x:hover { color:#fff; }
|
|
`;
|
|
document.head.appendChild(s);
|
|
}
|
|
|
|
// Assemble the style_description object in the builder's canonical key order, omitting blank fields.
|
|
function buildStyleDescription(wiz) {
|
|
const a = wiz.aesthetics.trim(), l = wiz.lighting.trim(), m = wiz.medium.trim();
|
|
const p = wiz.photo.trim(), ar = wiz.art_style.trim();
|
|
const sd = {};
|
|
if (a) sd.aesthetics = a;
|
|
if (l) sd.lighting = l;
|
|
if (p) { // photo mode: ...photo, medium...
|
|
sd.photo = p;
|
|
if (m) sd.medium = m;
|
|
} else { // art / none: ...medium, art_style...
|
|
if (m) sd.medium = m;
|
|
}
|
|
if (ar) sd.art_style = ar; // shown even alongside photo (free-pick); the warning flags it
|
|
return sd;
|
|
}
|
|
|
|
app.registerExtension({
|
|
name: "AI2Go.Ideogram4StyleWizard",
|
|
|
|
async beforeRegisterNodeDef(nodeType, nodeData) {
|
|
if (nodeData?.name !== "AI2GoIdeogram4StyleWizard") return;
|
|
injectWizStyle();
|
|
|
|
chainCallback(nodeType.prototype, "onNodeCreated", function () {
|
|
const node = this;
|
|
const wizWidget = node.widgets?.find((w) => w.name === "wizard_data");
|
|
// Hide the persistence widget but keep it serializable (the proven prompt-builder pattern).
|
|
if (wizWidget) { wizWidget.hidden = true; wizWidget.computeSize = () => [0, -4]; }
|
|
const i = node.inputs?.findIndex((inp) => inp.name === "wizard_data");
|
|
if (i != null && i !== -1) node.removeInput(i);
|
|
|
|
node._wiz = BLANK();
|
|
function hydrate() {
|
|
node._wiz = BLANK();
|
|
try {
|
|
const v = JSON.parse(wizWidget?.value || "{}");
|
|
if (v && typeof v === "object") for (const k of Object.keys(node._wiz)) if (typeof v[k] === "string") node._wiz[k] = v[k];
|
|
} catch (e) {}
|
|
}
|
|
hydrate();
|
|
|
|
function persist() {
|
|
if (wizWidget) wizWidget.value = JSON.stringify(node._wiz);
|
|
// Nudge ComfyUI to snapshot the change (same trick the prompt builder uses).
|
|
window.dispatchEvent(new MouseEvent("mouseup"));
|
|
}
|
|
|
|
// Prompt Builder nodes wired to this node's "style" output (the writeback targets / status).
|
|
function builderTargets() {
|
|
const out = [];
|
|
for (const id of (node.outputs?.[0]?.links || [])) {
|
|
const link = node.graph?.links?.get ? node.graph.links.get(id) : node.graph?.links?.[id];
|
|
if (!link) continue;
|
|
const t = node.graph.getNodeById(link.target_id);
|
|
if (t && t.type === BUILDER_TYPE) out.push(t);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Write the picked style fields into every connected Prompt Builder. `notify` controls the
|
|
// "nothing connected" alert (shown on an explicit Apply, not on Esc/backdrop close). Returns count.
|
|
function applyToBuilder(notify) {
|
|
const targets = builderTargets();
|
|
for (const target of targets) {
|
|
const set = (name, val) => {
|
|
const w = target.widgets?.find((x) => x.name === name);
|
|
if (w) { w.value = val; w.callback?.(val); }
|
|
};
|
|
// Mirror the wizard exactly, including clears: photo -> "photo", else art_style -> "art_style",
|
|
// else "none". Setting "none" rebuilds the combo without a photo/art_style sub-widget, so a
|
|
// cleared wizard actually clears the builder's photo/art_style (was the "stays filled" bug).
|
|
const p = node._wiz.photo.trim(), ar = node._wiz.art_style.trim();
|
|
const kind = p ? "photo" : (ar ? "art_style" : "none");
|
|
const styleW = target.widgets?.find((x) => x.name === "style");
|
|
if (styleW) styleW.value = kind; // DynamicCombo setter rebuilds sub-widgets
|
|
if (kind === "photo") set("style.photo", p);
|
|
else if (kind === "art_style") set("style.art_style", ar);
|
|
set("aesthetics", node._wiz.aesthetics.trim());
|
|
set("lighting", node._wiz.lighting.trim());
|
|
set("medium", node._wiz.medium.trim());
|
|
target.setDirtyCanvas?.(true, true);
|
|
}
|
|
if (!targets.length && notify) {
|
|
window.alert("Wire this node's \"style\" output into an AI2Go Ideogram 4 Prompt Builder's \"import_json\" input, then reopen the wizard.");
|
|
}
|
|
return targets.length;
|
|
}
|
|
node._wizApply = applyToBuilder;
|
|
|
|
// ── modal ──
|
|
const PRESET_PATH_DISPLAY = "user/default/ai2go/ideogram4/WizardStylesDefault.json";
|
|
|
|
async function openModal() {
|
|
if (node._wizOverlay || node._wizOpening) return;
|
|
node._wizOpening = true; // guard the async load against double-open
|
|
let loaded;
|
|
try { loaded = await loadCategories(); } finally { node._wizOpening = false; }
|
|
if (node._wizOverlay) return;
|
|
|
|
// Shared, mutable across both tabs; refreshed after a Save / Restore.
|
|
let categories = loaded.cats, loadState = loaded.state, loadError = loaded.error;
|
|
let currentTab = "pick", editDirty = false;
|
|
|
|
const ov = document.createElement("div"); ov.className = "ai2go-wiz-fs";
|
|
const panel = document.createElement("div"); panel.className = "ai2go-wiz-panel";
|
|
ov.appendChild(panel);
|
|
|
|
const head = document.createElement("div"); head.className = "ai2go-wiz-head";
|
|
const title = document.createElement("span"); title.className = "ai2go-wiz-title"; title.textContent = "Style Wizard";
|
|
const tabs = document.createElement("div"); tabs.className = "ai2go-wiz-tabs";
|
|
const pickTabBtn = document.createElement("button"); pickTabBtn.className = "ai2go-wiz-tab"; pickTabBtn.textContent = "Pick styles";
|
|
const editTabBtn = document.createElement("button"); editTabBtn.className = "ai2go-wiz-tab"; editTabBtn.textContent = "Edit presets";
|
|
tabs.append(pickTabBtn, editTabBtn);
|
|
const xBtn = document.createElement("button"); xBtn.className = "ai2go-wiz-x"; xBtn.textContent = "✕"; xBtn.title = "Close (applies your picks to the connected builder)";
|
|
head.append(title, tabs, xBtn);
|
|
|
|
const body = document.createElement("div"); body.className = "ai2go-wiz-body";
|
|
const foot = document.createElement("div"); foot.className = "ai2go-wiz-foot";
|
|
|
|
// Transient confirmation pill (Applied / Saved / …).
|
|
function toast(msg) {
|
|
const t = document.createElement("div"); t.className = "ai2go-wiz-toast"; t.textContent = msg;
|
|
panel.appendChild(t);
|
|
setTimeout(() => { t.classList.add("fade"); setTimeout(() => t.remove(), 450); }, 1300);
|
|
}
|
|
|
|
// Overwrite the preset file with the built-in defaults, then refresh both tabs.
|
|
async function doRestore() {
|
|
if (loadState !== "missing" && !window.confirm("Overwrite the preset file with the built-in defaults?")) return;
|
|
try { await saveDefaultPresets(); }
|
|
catch (e) { window.alert("Couldn't write the preset file to the server."); return; }
|
|
const r = await loadCategories();
|
|
categories = r.cats; loadState = r.state; loadError = r.error; editDirty = false;
|
|
showTab(currentTab); toast("Restored defaults ✓");
|
|
}
|
|
|
|
let closed = false;
|
|
function close(apply, notify) {
|
|
if (closed) return;
|
|
if (currentTab === "edit" && editDirty && !window.confirm("Discard unsaved preset changes?")) return;
|
|
closed = true;
|
|
if (apply) applyToBuilder(notify);
|
|
document.removeEventListener("keydown", onEsc, true);
|
|
ov.remove(); node._wizOverlay = null;
|
|
}
|
|
function onEsc(e) { if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); close(true, false); } }
|
|
|
|
// ── Pick tab ──
|
|
function renderPickTab() {
|
|
currentTab = "pick"; editDirty = false;
|
|
pickTabBtn.classList.add("active"); editTabBtn.classList.remove("active");
|
|
body.innerHTML = ""; foot.innerHTML = "";
|
|
const refreshers = []; // re-sync chip highlight + field value from node._wiz
|
|
const allChips = []; // {term, el} across all categories, for the search filter
|
|
|
|
const status = document.createElement("div"); status.className = "ai2go-wiz-status";
|
|
const warn = document.createElement("div"); warn.className = "ai2go-wiz-warn";
|
|
const prev = document.createElement("pre"); prev.className = "ai2go-wiz-prev";
|
|
const footRow = document.createElement("div"); footRow.className = "ai2go-wiz-footrow";
|
|
const spacer = document.createElement("span"); spacer.style.flex = "1";
|
|
const clearBtn = document.createElement("button"); clearBtn.className = "ai2go-wiz-btn"; clearBtn.textContent = "Clear all";
|
|
const clearApplyBtn = document.createElement("button"); clearApplyBtn.className = "ai2go-wiz-btn"; clearApplyBtn.textContent = "Clear & apply & close";
|
|
const applyBtn = document.createElement("button"); applyBtn.className = "ai2go-wiz-btn"; applyBtn.textContent = "Apply";
|
|
const doneBtn = document.createElement("button"); doneBtn.className = "ai2go-wiz-btn primary"; doneBtn.textContent = "Apply & close";
|
|
footRow.append(spacer, clearBtn, clearApplyBtn, applyBtn, doneBtn);
|
|
foot.append(status, warn, prev, footRow);
|
|
|
|
function updatePreview() {
|
|
prev.textContent = JSON.stringify({ style_description: buildStyleDescription(node._wiz) }, null, 2);
|
|
const both = node._wiz.photo.trim() && node._wiz.art_style.trim();
|
|
warn.textContent = both ? "⚠ Ideogram allows only photo OR art_style — only photo will be applied to the builder." : "";
|
|
}
|
|
function updateStatus() {
|
|
const ts = builderTargets();
|
|
status.classList.toggle("off", !ts.length);
|
|
status.textContent = ts.length
|
|
? "→ writing to: " + ts.map((t) => (t.title || t.type) + " #" + t.id).join(", ")
|
|
: "⚠ Not connected — wire the \"style\" output into a Prompt Builder's import_json.";
|
|
}
|
|
function buildCatBlock(cat) {
|
|
const block = document.createElement("div"); block.className = "ai2go-wiz-cat";
|
|
const lbl = document.createElement("div"); lbl.className = "ai2go-wiz-catlbl"; lbl.textContent = cat.label;
|
|
const chipsWrap = document.createElement("div"); chipsWrap.className = "ai2go-wiz-chips";
|
|
const input = document.createElement("input");
|
|
input.className = "ai2go-wiz-input"; input.type = "text";
|
|
input.placeholder = "click chips above, or type your own (comma-separated)";
|
|
input.value = node._wiz[cat.key] || "";
|
|
const chipEls = (cat.chips || []).map((term) => {
|
|
const c = document.createElement("button");
|
|
c.className = "ai2go-wiz-chip"; c.textContent = term; c.title = term;
|
|
c.addEventListener("click", () => {
|
|
node._wiz[cat.key] = toggleTerm(node._wiz[cat.key], term);
|
|
input.value = node._wiz[cat.key];
|
|
syncChips(); persist(); updatePreview();
|
|
});
|
|
return { term, el: c };
|
|
});
|
|
chipEls.forEach((c) => { chipsWrap.appendChild(c.el); allChips.push(c); });
|
|
input.addEventListener("input", () => {
|
|
node._wiz[cat.key] = input.value; // keep raw while typing; normalized on chip-toggle/apply
|
|
syncChips(); persist(); updatePreview();
|
|
});
|
|
function syncChips() { for (const c of chipEls) c.el.classList.toggle("active", hasTerm(node._wiz[cat.key], c.term)); }
|
|
function syncCat() { input.value = node._wiz[cat.key] || ""; syncChips(); }
|
|
syncChips();
|
|
refreshers.push(syncCat);
|
|
block.append(lbl, chipsWrap, input);
|
|
return block;
|
|
}
|
|
|
|
const search = document.createElement("input");
|
|
search.className = "ai2go-wiz-search"; search.type = "text"; search.placeholder = "Search chips…";
|
|
search.addEventListener("input", () => {
|
|
const q = search.value.trim().toLowerCase();
|
|
for (const c of allChips) c.el.style.display = (!q || c.term.toLowerCase().includes(q)) ? "" : "none";
|
|
});
|
|
body.appendChild(search);
|
|
if (loadState !== "ok") body.appendChild(buildPickBanner());
|
|
for (const cat of categories) body.appendChild(buildCatBlock(cat));
|
|
updatePreview(); updateStatus();
|
|
|
|
function clearWiz() { node._wiz = BLANK(); for (const r of refreshers) r(); persist(); updatePreview(); }
|
|
const applied = (n) => { if (n) toast("Applied to " + n + " builder" + (n > 1 ? "s" : "") + " ✓"); updateStatus(); };
|
|
clearBtn.addEventListener("click", clearWiz);
|
|
clearApplyBtn.addEventListener("click", () => { clearWiz(); close(true, true); });
|
|
applyBtn.addEventListener("click", () => applied(applyToBuilder(true)));
|
|
doneBtn.addEventListener("click", () => close(true, true));
|
|
}
|
|
|
|
// Warning banner (Pick tab) for a missing / malformed preset file.
|
|
function buildPickBanner() {
|
|
const banner = document.createElement("div"); banner.className = "ai2go-wiz-banner";
|
|
const msg = document.createElement("div"); msg.className = "ai2go-wiz-banner-msg";
|
|
if (loadState === "missing") {
|
|
msg.innerHTML = "⚠ Style preset file not found — using built-in defaults. Use <b>Edit presets → Restore defaults</b> to create it:";
|
|
} else {
|
|
msg.innerHTML = "⚠ Style preset file is malformed — using built-in defaults.";
|
|
if (loadError) { const e = document.createElement("div"); e.style.cssText = "margin-top:4px;color:#f0d9a6;font-family:monospace;"; e.textContent = loadError; msg.appendChild(e); }
|
|
const fix = document.createElement("div"); fix.style.marginTop = "4px"; fix.innerHTML = "Fix the JSON and reopen, or use <b>Edit presets → Restore defaults</b>:"; msg.appendChild(fix);
|
|
}
|
|
const pathEl = document.createElement("div"); pathEl.innerHTML = "<code>" + PRESET_PATH_DISPLAY + "</code>"; msg.appendChild(pathEl);
|
|
const btns = document.createElement("div"); btns.style.cssText = "display:flex;flex-direction:column;gap:4px;";
|
|
const copyBtn = document.createElement("button"); copyBtn.className = "ai2go-wiz-btn"; copyBtn.textContent = "Copy path";
|
|
copyBtn.addEventListener("click", () => { navigator.clipboard?.writeText?.(PRESET_PATH_DISPLAY).then(() => toast("Path copied")).catch(() => {}); });
|
|
const restoreBtn = document.createElement("button"); restoreBtn.className = "ai2go-wiz-btn"; restoreBtn.textContent = "Restore defaults";
|
|
restoreBtn.addEventListener("click", () => doRestore());
|
|
btns.append(copyBtn, restoreBtn);
|
|
banner.append(msg, btns);
|
|
return banner;
|
|
}
|
|
|
|
// ── Edit presets tab ──
|
|
function renderEditTab() {
|
|
currentTab = "edit"; editDirty = false;
|
|
editTabBtn.classList.add("active"); pickTabBtn.classList.remove("active");
|
|
body.innerHTML = ""; foot.innerHTML = "";
|
|
let editCats = toEditCats(categories, loadState);
|
|
|
|
const help = document.createElement("div"); help.className = "ai2go-wiz-status";
|
|
help.textContent = "Add, rename, or delete chips per category, then Save. New chips append to the end.";
|
|
const footRow = document.createElement("div"); footRow.className = "ai2go-wiz-footrow";
|
|
const dirtyLbl = document.createElement("div"); dirtyLbl.className = "ai2go-wiz-dirty";
|
|
const restoreBtn = document.createElement("button"); restoreBtn.className = "ai2go-wiz-btn"; restoreBtn.textContent = "Restore defaults";
|
|
const discardBtn = document.createElement("button"); discardBtn.className = "ai2go-wiz-btn"; discardBtn.textContent = "Discard";
|
|
const saveBtn = document.createElement("button"); saveBtn.className = "ai2go-wiz-btn primary"; saveBtn.textContent = "Save to file";
|
|
footRow.append(dirtyLbl, restoreBtn, discardBtn, saveBtn);
|
|
foot.append(help, footRow);
|
|
|
|
function setDirty(v) { editDirty = v; dirtyLbl.textContent = v ? "Unsaved changes" : ""; }
|
|
|
|
function buildEditCat(cat) {
|
|
const block = document.createElement("div"); block.className = "ai2go-wiz-cat";
|
|
const lbl = document.createElement("div"); lbl.className = "ai2go-wiz-catlbl"; lbl.textContent = cat.label + " (key: " + cat.key + ")";
|
|
const list = document.createElement("div"); list.className = "ai2go-wiz-edit";
|
|
cat.chips.forEach((chip, idx) => {
|
|
const row = document.createElement("div"); row.className = "ai2go-wiz-editrow";
|
|
const inp = document.createElement("input"); inp.className = "ai2go-wiz-input"; inp.type = "text"; inp.value = chip;
|
|
inp.addEventListener("input", () => { cat.chips[idx] = inp.value; setDirty(true); });
|
|
const del = document.createElement("button"); del.className = "ai2go-wiz-del"; del.textContent = "✕"; del.title = "Delete chip";
|
|
del.addEventListener("click", () => { cat.chips.splice(idx, 1); setDirty(true); renderEditor(); });
|
|
row.append(inp, del); list.appendChild(row);
|
|
});
|
|
const addWrap = document.createElement("div"); addWrap.className = "ai2go-wiz-add";
|
|
const addInput = document.createElement("input"); addInput.className = "ai2go-wiz-input"; addInput.type = "text"; addInput.placeholder = "add a chip…";
|
|
const addBtn = document.createElement("button"); addBtn.className = "ai2go-wiz-btn"; addBtn.textContent = "+ Add";
|
|
const add = () => { const v = addInput.value.trim(); if (!v) return; cat.chips.push(v); addInput.value = ""; setDirty(true); renderEditor(); };
|
|
addBtn.addEventListener("click", add);
|
|
addInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); add(); } });
|
|
addWrap.append(addInput, addBtn);
|
|
block.append(lbl, list, addWrap);
|
|
return block;
|
|
}
|
|
function renderEditor() { body.innerHTML = ""; for (const cat of editCats) body.appendChild(buildEditCat(cat)); }
|
|
renderEditor();
|
|
|
|
saveBtn.addEventListener("click", async () => {
|
|
saveBtn.disabled = true;
|
|
const fileObj = { _comment: DEFAULT_PRESETS_FILE._comment, version: 1,
|
|
categories: editCats.map((c) => ({ key: c.key, label: c.label, chips: dedupeTrim(c.chips) })) };
|
|
try {
|
|
await app.api.storeUserData(PRESET_FILE, JSON.stringify(fileObj, null, 2), { overwrite: true, stringify: false, throwOnError: true });
|
|
} catch (e) { window.alert("Couldn't save the preset file to the server."); saveBtn.disabled = false; return; }
|
|
const r = await loadCategories();
|
|
categories = r.cats; loadState = r.state; loadError = r.error;
|
|
editCats = toEditCats(categories, loadState);
|
|
setDirty(false); saveBtn.disabled = false; renderEditor(); toast("Saved ✓");
|
|
});
|
|
discardBtn.addEventListener("click", async () => {
|
|
const r = await loadCategories();
|
|
categories = r.cats; loadState = r.state; loadError = r.error;
|
|
editCats = toEditCats(categories, loadState);
|
|
setDirty(false); renderEditor(); toast("Reverted");
|
|
});
|
|
restoreBtn.addEventListener("click", () => doRestore());
|
|
}
|
|
|
|
function showTab(name) { if (name === "edit") renderEditTab(); else renderPickTab(); }
|
|
pickTabBtn.addEventListener("click", () => { if (currentTab !== "pick" && (!editDirty || window.confirm("Discard unsaved preset changes?"))) showTab("pick"); });
|
|
editTabBtn.addEventListener("click", () => { if (currentTab !== "edit") showTab("edit"); });
|
|
|
|
xBtn.addEventListener("click", () => close(true, false));
|
|
ov.addEventListener("mousedown", (e) => { if (e.target === ov) close(true, false); }); // backdrop closes + applies
|
|
document.addEventListener("keydown", onEsc, true);
|
|
|
|
panel.append(head, body, foot);
|
|
document.body.appendChild(ov);
|
|
node._wizOverlay = ov;
|
|
showTab("pick");
|
|
}
|
|
node._wizOpen = openModal;
|
|
|
|
node.addWidget("button", "🪄 Open Style Wizard", "", openModal);
|
|
});
|
|
|
|
// Re-hydrate after a saved workflow loads (widget value is restored before onConfigure fires).
|
|
chainCallback(nodeType.prototype, "onConfigure", function () {
|
|
const wizWidget = this.widgets?.find((w) => w.name === "wizard_data");
|
|
this._wiz = BLANK();
|
|
try {
|
|
const v = JSON.parse(wizWidget?.value || "{}");
|
|
if (v && typeof v === "object") for (const k of Object.keys(this._wiz)) if (typeof v[k] === "string") this._wiz[k] = v[k];
|
|
} catch (e) {}
|
|
});
|
|
|
|
// Tidy up an open modal if the node is removed.
|
|
chainCallback(nodeType.prototype, "onRemoved", function () {
|
|
this._wizOverlay?.remove(); this._wizOverlay = null;
|
|
});
|
|
},
|
|
});
|