Files
2026-09-06 20:59:27 +03:00

553 lines
17 KiB
JavaScript

import { app } from "../../../scripts/app.js";
app.registerExtension({
name: "iTools.PromptBuilder",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "iToolsPromptBuilder") {
const originalOnExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (message) {
originalOnExecuted?.apply(this, arguments);
if (message.prompt !== undefined) {
// This allows the UI to reflect the merged state if it was done on server
const widget = this.widgets.find(
(w) => w.name === "PromptBuilderWidget",
);
if (widget && widget.set_value) {
widget.set_value(message);
}
}
if (message.style === "none") {
// Reset style dropdown if it was merged
const widget = this.widgets.find(
(w) => w.name === "PromptBuilderWidget",
);
if (widget && widget.reset_style) {
widget.reset_style();
}
}
};
}
},
async nodeCreated(node) {
if (node.comfyClass !== "iToolsPromptBuilder") return;
let pendingValue = null;
const container = document.createElement("div");
container.style.cssText = `
box-sizing: border-box;
display: flex;
flex-direction: column;
padding: 5px;
height: 100%;
background: #1c1c1c;
border-radius: 8px;
color: white;
font-family: sans-serif;
`;
// Prompt Area Wrapper for Absolute Positioning
const promptWrapper = document.createElement("div");
promptWrapper.style.cssText = `
position: relative;
flex: 2;
width: 100%;
`;
// Textarea
const promptArea = document.createElement("textarea");
promptArea.placeholder = "Base prompt...";
promptArea.style.cssText = `
width: 100%;
height: 100%;
background: #333;
border: 1px solid #444;
border-radius: 6px;
color: #ddd;
padding: 5px;
padding-right: 20px;
resize: none;
font-size: 15px;
outline: none;
transition: border-color 0.2s;
box-sizing: border-box;
`;
promptArea.onfocus = () => (promptArea.style.borderColor = "#555");
const updateIconPositions = () => {
const hasScrollBar = promptArea.scrollHeight > promptArea.clientHeight;
const rightOffset = hasScrollBar ? "20px" : "0px";
resetBtn.style.right = rightOffset;
copyBtn.style.right = rightOffset;
};
promptArea.oninput = updateIconPositions;
let promptHistory = [];
let negHistory = [];
// Negative Prompt Wrapper
const negativeWrapper = document.createElement("div");
negativeWrapper.style.cssText = `
position: relative;
flex: 1;
width: 100%;
display: none;
margin-top: 5px;
`;
const negativeArea = document.createElement("textarea");
negativeArea.placeholder = "Negative prompt...";
negativeArea.style.cssText = promptArea.style.cssText;
negativeArea.oninput = updateIconPositions;
// Reset Button (moved here)
const resetBtn = document.createElement("button");
resetBtn.innerHTML = `↻`;
resetBtn.title = "Undo | Reset";
const actionBtnStyle = `
position: absolute;
right: 5px;
background: transparent;
border: none;
color: #888;
width: 20px;
height: 20px;
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
padding: 0;
z-index: 10;
`;
resetBtn.style.cssText = actionBtnStyle + "top: 0px;";
resetBtn.onmouseover = () => (resetBtn.style.color = "#fff");
resetBtn.onmouseout = () => (resetBtn.style.color = "#888");
// // Copy Button
const copyBtn = document.createElement("button");
copyBtn.innerHTML = `🗒`; // Clipboard icon
copyBtn.title = "Copy to clipboard";
copyBtn.style.cssText = actionBtnStyle + "top: 20px; display: none;";
copyBtn.onmouseover = () => (copyBtn.style.color = "#fff");
copyBtn.onmouseout = () => (copyBtn.style.color = "#888");
copyBtn.onclick = () => {
navigator.clipboard.writeText(promptArea.value);
app.extensionManager.toast.add({
severity: "success",
summary: "Copied!",
detail: "Prompt copied to clipboard",
life: 2000,
});
};
promptWrapper.appendChild(promptArea);
promptWrapper.appendChild(resetBtn);
promptWrapper.appendChild(copyBtn);
negativeWrapper.appendChild(negativeArea);
// Selectors Section
const selectorContainer = document.createElement("div");
selectorContainer.style.cssText = `
display: flex;
width: 100%;
gap: 5px;
`;
const createDropdown = (labelStr, placeholder) => {
const col = document.createElement("div");
col.style.cssText = `
display: flex;
flex-direction: column;
padding: 0px;
flex: 1;
min-width: 0;
`;
const label = document.createElement("label");
label.innerText = labelStr;
label.style.cssText = `
font-size: 10px;
padding-top: 5px;
padding-bottom: 5px;
color: #555;
letter-spacing: 0.5px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;`;
const select = document.createElement("select");
select.style.cssText = `
width: 100%;
background: #333;
border: 1px solid #444;
border-radius: 6px;
color: #ddd;
font-size: 12px;
padding: 2.5px 10px;
outline: none;
cursor: pointer;
`;
col.appendChild(label);
col.appendChild(select);
return { col, select, label };
};
const categoryRes = createDropdown("CATEGORY", "Basic");
const styleRes = createDropdown("STYLE", "none");
categoryRes.col.style.flex = "2";
styleRes.col.style.flex = "3";
selectorContainer.appendChild(categoryRes.col);
selectorContainer.appendChild(styleRes.col);
// Buttons Section
const btnContainer = document.createElement("div");
btnContainer.style.cssText = `
display: flex;
gap: 5px;
margin-top: 5px;
align-items: center;
`;
// Function to apply styles and states to buttons
const applyButtonStyle = (btn) => {
// Base colors using a modern dark palette
const colors = {
base: "#222",
border: "#444",
hover: "#333",
press: "#444",
};
btn.style.cssText = `
background: ${colors.base};
border: 1px solid ${colors.border};
border-radius: 6px;
color: #ddd;
padding: 5px 8px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
letter-spacing: 0.5px;
height: 28px;
outline: none;
user-select: none;
`;
// Hover State
btn.onmouseenter = () => {
btn.style.background = colors.hover;
btn.style.borderColor = "#555";
};
// Normal State
btn.onmouseleave = () => {
btn.style.background = colors.base;
btn.style.borderColor = colors.border;
};
// Pressed State
btn.onmousedown = () => {
btn.style.background = colors.press;
btn.style.borderColor = "#666";
btn.style.transform = "translateY(1px)"; // Subtle shrink on click
};
// Release State
btn.onmouseup = () => {
btn.style.background = colors.hover;
btn.style.transform = "translateY(0)";
};
};
// Merge Button
const mergeBtn = document.createElement("button");
mergeBtn.innerHTML = `<span style="margin-right: 8px; font-size: 12px;">✨</span> MERGE STYLE`;
applyButtonStyle(mergeBtn);
mergeBtn.style.flex = "3";
// Append Button
const appendBtn = document.createElement("button");
appendBtn.innerHTML = `<span style="margin-right: 8px; font-size: 12px; opacity: 0.7;">☰</span> APPEND`;
applyButtonStyle(appendBtn);
appendBtn.style.flex = "1";
// NEG Toggle Switch
const negToggle = document.createElement("div");
applyButtonStyle(negToggle);
const switchText = document.createElement("span");
switchText.innerText = "🌕";
switchText.style.cssText =
"font-size: 9px; color: #888; font-weight: bold; letter-spacing: 0.5px;";
negToggle.appendChild(switchText);
let negState = 0; // 0: Off, 1: Output only (🌔), 2: Output + Textarea (🌓)
const updateNegState = (val, initial = false) => {
negState = typeof val === "boolean" ? (val ? 2 : 0) : val;
if (negState === 1 || negState === 2) {
switchText.innerText = negState === 1 ? "🌔" : "🌓";
switchText.style.color = "#fff";
negToggle.style.borderColor = "#444";
negativeWrapper.style.display = negState === 2 ? "block" : "none";
if (node.outputs.length < 2) node.addOutput("negative", "STRING");
} else {
negState = 0;
switchText.innerText = "🌕";
switchText.style.color = "#888";
negToggle.style.borderColor = "#444";
negativeWrapper.style.display = "none";
if (node.outputs.length > 1) node.removeOutput(1);
}
};
updateNegState(0); // Ensure no negative output on init
negToggle.onclick = () => {
updateNegState((negState + 1) % 3);
updateIconPositions();
app.graph.setDirtyCanvas(true);
};
btnContainer.appendChild(negToggle);
// btnContainer.appendChild(appendBtn); // disable for now, keep for future
btnContainer.appendChild(mergeBtn);
container.appendChild(promptWrapper);
container.appendChild(negativeWrapper);
container.appendChild(selectorContainer);
container.appendChild(btnContainer);
// Initial load
const loadStyles = async () => {
try {
const resp = await fetch("/itools/get_styler_data");
const data = await resp.json();
categoryRes.select.innerHTML = "";
data.styles.forEach((s) => {
const opt = document.createElement("option");
opt.value = opt.text = s;
categoryRes.select.appendChild(opt);
});
const defaultStyle =
pendingValue?.category ||
(data.styles.includes("basic.yaml") ? "basic.yaml" : data.styles[0]);
if (defaultStyle) {
categoryRes.select.value = defaultStyle;
await updateTemplates(defaultStyle);
if (pendingValue?.style) {
styleRes.select.value = pendingValue.style;
updateStyleLabel();
}
pendingValue = null;
}
} catch (e) {
console.error("Failed to load styles", e);
}
};
const updateTemplates = async (fileName) => {
styleRes.select.innerHTML = '<option value="none">Loading...</option>';
try {
const formData = new FormData();
formData.append("file_name", fileName);
const resp = await fetch("/itools/get_style_templates", {
method: "POST",
body: formData,
});
const data = await resp.json();
styleRes.select.innerHTML = "";
// Add "none" as default
const noneOpt = document.createElement("option");
noneOpt.value = noneOpt.text = "none";
styleRes.select.appendChild(noneOpt);
data.templates.forEach((t) => {
if (t === "none") return;
const opt = document.createElement("option");
opt.value = opt.text = t;
styleRes.select.appendChild(opt);
});
if (pendingValue?.style) {
styleRes.select.value = pendingValue.style;
updateStyleLabel();
}
} catch (e) {
styleRes.select.innerHTML =
'<option value="none">Error loading</option>';
console.error("Failed to load templates", e);
}
};
categoryRes.select.onchange = (e) => updateTemplates(e.target.value);
const updateStyleLabel = () => {
styleRes.label.innerText =
styleRes.select.value === "none"
? "STYLE"
: "STYLE - will be merged on run ⓘ";
};
styleRes.select.onchange = updateStyleLabel;
mergeBtn.onclick = async () => {
const prompt = promptArea.value;
const negative = negativeArea.value;
const style_file = categoryRes.select.value;
const template_name = styleRes.select.value;
if (template_name === "none") return;
promptHistory.push(promptArea.value);
negHistory.push(negativeArea.value);
try {
const formData = new FormData();
formData.append("prompt", prompt);
formData.append("negative", negative);
formData.append("style_file", style_file);
formData.append("template_name", template_name);
const resp = await fetch("/itools/merge_style", {
method: "POST",
body: formData,
});
const data = await resp.json();
promptArea.value = data.prompt;
negativeArea.value = data.negative || "";
styleRes.select.value = "none";
updateStyleLabel();
updateIconPositions();
} catch (e) {
console.error("Merge failed", e);
}
};
appendBtn.onclick = async () => {
const style_file = categoryRes.select.value;
const template_name = styleRes.select.value;
if (template_name === "none") return;
promptHistory.push(promptArea.value);
negHistory.push(negativeArea.value);
try {
const formData = new FormData();
formData.append("prompt", ""); // Get style only
formData.append("negative", "");
formData.append("style_file", style_file);
formData.append("template_name", template_name);
const resp = await fetch("/itools/merge_style", {
method: "POST",
body: formData,
});
const data = await resp.json();
if (data.prompt) {
const separator = promptArea.value.trim() ? "\n\n" : "";
promptArea.value = promptArea.value + separator + data.prompt;
updateIconPositions();
}
if (data.negative) {
const separator = negativeArea.value.trim() ? "\n\n" : "";
negativeArea.value = negativeArea.value + separator + data.negative;
updateIconPositions();
}
styleRes.select.value = "none";
updateStyleLabel();
} catch (e) {
console.error("Append failed", e);
}
};
resetBtn.onclick = () => {
if (promptHistory.length > 0) {
promptArea.value = promptHistory.pop();
negativeArea.value = negHistory.pop();
} else {
promptArea.value = "";
negativeArea.value = "";
styleRes.select.value = "none";
}
updateStyleLabel();
updateIconPositions();
};
updateIconPositions();
loadStyles();
// Node setup
node.size = [320, 260];
const widget = node.addDOMWidget("PromptBuilderWidget", "custom", container, {
tooltip: "Prompt Builder",
getValue: () => {
return {
prompt: promptArea.value,
negative: negativeArea.value,
category: categoryRes.select.value,
style: styleRes.select.value,
negState: negState,
};
},
setValue: (v) => {
if (v) {
promptArea.value = v.prompt || "";
negativeArea.value = v.negative || "";
pendingValue = v;
updateNegState(
v.negState !== undefined ? v.negState : v.showNegative ? 2 : 0,
true,
);
updateIconPositions();
}
},
set_value: (v) => {
if (v && typeof v === "object") {
promptArea.value = v.prompt || "";
negativeArea.value = v.negative || "";
} else {
promptArea.value = v || "";
}
updateIconPositions();
},
reset_style: () => {
styleRes.select.value = "none";
updateStyleLabel();
},
});
widget.tooltip = "Prompt Builder";
// init size
node.size = [400, 300];
node.onResize = () => {
updateIconPositions();
if (node.size[0] < 400) node.size[0] = 400;
if (node.size[1] < 300) node.size[1] = 300;
};
// add delay to updateIconPositions on init
setTimeout(() => {
updateIconPositions();
}, 500);
// force redraw
setTimeout(() => {
node.setDirtyCanvas(true, true);
}, 100);
},
});