Files
olivv-cs-ComfyUI-FunPack/web/funpack_scene_builder.js
T

2466 lines
77 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
const NODE_NAME = "FunPackSceneBuilder";
const NONE_SCENE = "-None-";
const CARET_SENTINEL = "\u200b";
const HIDDEN_WIDGETS = new Set([
"scene_name",
"mode",
"scene",
"aliases",
"action",
"scene_positive",
"scene_negative",
"refinement_key",
"scene_payload",
]);
const GROUP_ORDER = ["subject", "appearance", "action", "camera", "environment", "style", "quality", "details", "negative"];
let sceneData = null;
let shortcutData = null;
let activePanel = null;
const trackedNodes = new Set();
function widgetByName(node, name) {
return (node.widgets || []).find((widget) => widget.name === name);
}
function hideWidget(widget) {
if (!widget) {
return;
}
if (widget.linkedWidgets) {
for (const linked of widget.linkedWidgets) {
hideWidget(linked);
}
}
widget.__funpackHidden = true;
widget.hidden = true;
widget.options = widget.options || {};
widget.options.hidden = true;
widget.computeSize = () => [0, -4];
widget.computedHeight = 0;
widget.type = "hidden";
for (const key of ["element", "inputEl", "textElement", "parentEl"]) {
const element = widget[key];
if (element?.style) {
element.style.display = "none";
element.style.visibility = "hidden";
element.style.pointerEvents = "none";
}
if (element) {
element.hidden = true;
}
}
}
function getWidgetValue(node, name, fallback = "") {
const widget = widgetByName(node, name);
return widget ? widget.value : fallback;
}
function setWidgetValue(node, name, value) {
const widget = widgetByName(node, name);
if (widget) {
widget.value = value;
}
}
function setDirty(node) {
node.setDirtyCanvas?.(true, true);
app.graph?.setDirtyCanvas?.(true, true);
}
function normalizeRefinementKey(value) {
const clean = String(value || "").trim();
return clean && clean !== NONE_SCENE ? clean : "";
}
function fitString(ctx, text, maxWidth) {
text = String(text ?? "");
if (ctx.measureText(text).width <= maxWidth) {
return text;
}
const ellipsis = "...";
let low = 0;
let high = text.length;
while (low < high) {
const mid = Math.ceil((low + high) / 2);
if (ctx.measureText(text.slice(0, mid) + ellipsis).width <= maxWidth) {
low = mid;
} else {
high = mid - 1;
}
}
return text.slice(0, low) + ellipsis;
}
function roundRect(ctx, x, y, width, height, radius = 5) {
ctx.beginPath();
ctx.roundRect(x, y, width, height, [radius]);
}
function button(ctx, widget, key, label, x, y, width, height) {
widget.hitAreas[key] = [x, y, width, height];
roundRect(ctx, x, y, width, height, 5);
ctx.fillStyle = LiteGraph.WIDGET_BGCOLOR;
ctx.strokeStyle = LiteGraph.WIDGET_OUTLINE_COLOR;
ctx.fill();
ctx.stroke();
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(fitString(ctx, label, width - 8), x + width / 2, y + height / 2);
}
function currentRefinementKey(node) {
const linked = linkedRefinementKey(node);
if (linked) {
return linked;
}
return normalizeRefinementKey(getWidgetValue(node, "refinement_key", ""));
}
function linkedInputNode(node, inputName) {
const input = (node.inputs || []).find((item) => item.name === inputName);
const linkId = Array.isArray(input?.link) ? input.link[0] : input?.link;
if (linkId == null) {
return null;
}
const link = app.graph?.links?.[linkId];
return link ? app.graph?.getNodeById?.(link.origin_id) : null;
}
function linkedRefinementKey(node) {
const source = linkedInputNode(node, "refinement_key_input");
if (!source) {
return "";
}
const selected = normalizeRefinementKey(getWidgetValue(source, "refinement_key", ""));
const typed = normalizeRefinementKey(getWidgetValue(source, "key_name", ""));
return selected || typed;
}
function linkedTextValue(node, inputName) {
const source = linkedInputNode(node, inputName);
if (!source) {
return "";
}
for (const name of ["text", "prompt", "positive_prompt", "negative_prompt", "string", "value"]) {
const value = getWidgetValue(source, name, "");
if (String(value || "").trim()) {
return String(value);
}
}
return "";
}
function sceneNames() {
const names = sceneData?.scenes;
return Array.isArray(names) && names.length ? names : [NONE_SCENE];
}
function sceneByName(name) {
return sceneData?.data?.scenes?.[name] || null;
}
function savedSceneNames() {
return sceneNames().filter((item) => item !== NONE_SCENE);
}
function activeSceneName(node) {
const typed = String(getWidgetValue(node, "scene_name", "") || "").trim();
const selected = String(getWidgetValue(node, "scene", "") || "").trim();
if (typed) {
return typed;
}
return selected && selected !== NONE_SCENE ? selected : "";
}
function needsSceneCreation(node) {
return !activeSceneName(node) && !savedSceneNames().length;
}
function memoryItems() {
return Array.isArray(sceneData?.memory) ? sceneData.memory : [];
}
function shortcutItems() {
return Array.isArray(shortcutData?.shortcuts) ? shortcutData.shortcuts : [];
}
function normalizeMemoryMap(items) {
const output = {};
for (const item of items || []) {
const text = String(item.text || item.key || "").trim();
if (!text) {
continue;
}
const key = String(item.key || text).toLowerCase().replace(/[^\w'’]+/g, " ").replace(/\s+/g, " ").trim() || text.toLowerCase();
output[key] = {
text,
key,
source: item.source === "negative" || item.category === "negative" ? "negative" : "positive",
category: GROUP_ORDER.includes(item.category) ? item.category : "details",
tokens: Array.isArray(item.tokens) ? item.tokens : [],
count: Number.isFinite(Number(item.count)) ? Number(item.count) : 0,
created_at: item.created_at || new Date().toISOString(),
updated_at: new Date().toISOString(),
wildcard: Boolean(item.wildcard || String(item.wildcard_group || "").trim()),
};
}
return output;
}
async function fetchScenes(refinementKey = "") {
try {
const params = new URLSearchParams({ cache_bust: String(Date.now()) });
if (refinementKey) {
params.set("key", refinementKey);
}
const response = await api.fetchApi(`/funpack/scenes?${params.toString()}`, { cache: "no-store" });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
sceneData = await response.json();
} catch (error) {
console.warn("FunPack: failed to refresh scenes", error);
}
return sceneData;
}
async function fetchShortcuts() {
try {
const params = new URLSearchParams({ cache_bust: String(Date.now()) });
const response = await api.fetchApi(`/funpack/shortcuts?${params.toString()}`, { cache: "no-store" });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
shortcutData = await response.json();
} catch (error) {
console.warn("FunPack: failed to refresh shortcuts", error);
}
return shortcutData;
}
function updateSceneCombo(node) {
const widget = widgetByName(node, "scene");
if (!widget) {
return;
}
const values = sceneNames();
const current = widget.value;
widget.options.values = current && !values.includes(current) ? [...values, current] : values;
if (!widget.value || !widget.options.values.includes(widget.value)) {
widget.value = widget.options.values[0] || NONE_SCENE;
}
}
async function refreshNode(node) {
await Promise.all([fetchScenes(currentRefinementKey(node)), fetchShortcuts()]);
updateSceneCombo(node);
setDirty(node);
}
async function refreshTracked() {
for (const node of [...trackedNodes]) {
if (!node?.graph) {
trackedNodes.delete(node);
continue;
}
await refreshNode(node);
}
}
function scheduleRefresh() {
window.setTimeout(() => void refreshTracked(), 100);
}
function loadSceneIntoNode(node, name) {
const scene = sceneByName(name);
if (!scene || name === NONE_SCENE) {
return;
}
setWidgetValue(node, "scene", name);
setWidgetValue(node, "scene_name", scene.name || name);
setWidgetValue(node, "aliases", Array.isArray(scene.aliases) ? scene.aliases.join(", ") : "");
setWidgetValue(node, "refinement_key", scene.refinement_key || "");
setWidgetValue(node, "scene_positive", Object.prototype.hasOwnProperty.call(scene, "positive_text") ? (scene.positive_text || "") : (scene.positive_phrases || []).join(", "));
setWidgetValue(node, "scene_negative", Object.prototype.hasOwnProperty.call(scene, "negative_text") ? (scene.negative_text || "") : (scene.negative_phrases || []).join(", "));
setDirty(node);
}
async function saveSceneFromNode(node) {
const name = String(getWidgetValue(node, "scene_name", "") || "").trim();
if (!name) {
throw new Error("Scene name is required.");
}
const params = new URLSearchParams();
const key = currentRefinementKey(node);
if (key) {
params.set("key", key);
}
const response = await api.fetchApi(`/funpack/scenes/scene${params.toString() ? `?${params.toString()}` : ""}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "save",
name,
aliases: getWidgetValue(node, "aliases", ""),
mode: getWidgetValue(node, "mode", "Manual"),
positive_text: getWidgetValue(node, "scene_positive", ""),
negative_text: getWidgetValue(node, "scene_negative", ""),
}),
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.error || `Save failed with HTTP ${response.status}`);
}
await refreshNode(node);
setWidgetValue(node, "scene", name);
}
async function deleteSceneFromNode(node) {
const name = String(getWidgetValue(node, "scene_name", "") || "").trim();
if (!name) {
throw new Error("Scene name is required.");
}
const params = new URLSearchParams();
const key = currentRefinementKey(node);
if (key) {
params.set("key", key);
}
const response = await api.fetchApi(`/funpack/scenes/scene${params.toString() ? `?${params.toString()}` : ""}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "delete", name }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.error || `Delete failed with HTTP ${response.status}`);
}
setWidgetValue(node, "scene", NONE_SCENE);
setWidgetValue(node, "scene_name", "");
setWidgetValue(node, "scene_positive", "");
setWidgetValue(node, "scene_negative", "");
await refreshNode(node);
}
async function saveDatabase(node, items) {
const params = new URLSearchParams();
const key = currentRefinementKey(node);
if (key) {
params.set("key", key);
}
const response = await api.fetchApi(`/funpack/scenes/database${params.toString() ? `?${params.toString()}` : ""}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ universal_memory: normalizeMemoryMap(items) }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.error || `Database save failed with HTTP ${response.status}`);
}
await refreshNode(node);
}
async function saveShortcut(item) {
const response = await api.fetchApi("/funpack/shortcuts/shortcut", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "save", ...item }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.error || `Shortcut save failed with HTTP ${response.status}`);
}
shortcutData = await response.json();
return shortcutData;
}
async function deleteShortcut(name) {
const response = await api.fetchApi("/funpack/shortcuts/shortcut", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "delete", name }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.error || `Shortcut delete failed with HTTP ${response.status}`);
}
shortcutData = await response.json();
return shortcutData;
}
async function exportShortcuts() {
const params = new URLSearchParams({ cache_bust: String(Date.now()) });
const response = await api.fetchApi(`/funpack/shortcuts/export?${params.toString()}`, { cache: "no-store" });
if (!response.ok) {
throw new Error(`Shortcut export failed with HTTP ${response.status}`);
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "funpack_shortcuts.json";
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
function importShortcuts(onDone) {
const input = document.createElement("input");
input.type = "file";
input.accept = ".json,application/json";
input.onchange = async () => {
const file = input.files?.[0];
if (!file) {
return;
}
try {
const data = JSON.parse(await file.text());
const response = await api.fetchApi("/funpack/shortcuts/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.error || `Shortcut import failed with HTTP ${response.status}`);
}
shortcutData = await response.json();
onDone?.();
} catch (error) {
showPanelError(error);
}
};
input.click();
}
async function exportScenes(node) {
const params = new URLSearchParams({ cache_bust: String(Date.now()) });
const key = currentRefinementKey(node);
if (key) {
params.set("key", key);
}
const response = await api.fetchApi(`/funpack/scenes/export?${params.toString()}`, { cache: "no-store" });
if (!response.ok) {
throw new Error(`Export failed with HTTP ${response.status}`);
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "funpack_scenes.json";
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
function importScenes(node) {
const input = document.createElement("input");
input.type = "file";
input.accept = ".json,application/json";
input.onchange = async () => {
const file = input.files?.[0];
if (!file) {
return;
}
try {
const data = JSON.parse(await file.text());
const params = new URLSearchParams();
const key = currentRefinementKey(node);
if (key) {
params.set("key", key);
}
const response = await api.fetchApi(`/funpack/scenes/import${params.toString() ? `?${params.toString()}` : ""}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.error || `Import failed with HTTP ${response.status}`);
}
await refreshNode(node);
renderPanel(activePanel, node, "menu");
} catch (error) {
showPanelError(error);
}
};
input.click();
}
function closePanel() {
if (activePanel?.root) {
activePanel.root.remove();
}
activePanel = null;
}
function showPanelError(error) {
const target = activePanel?.root?.querySelector("[data-role='error']");
if (target) {
target.textContent = error.message || String(error);
} else {
console.warn("FunPack Scene Builder:", error);
}
}
function panelButton(label, className = "") {
const element = document.createElement("button");
element.type = "button";
element.textContent = label;
element.className = `funpack-scene-button ${className}`.trim();
return element;
}
function panelTextInput(value, placeholder) {
const element = document.createElement("input");
element.type = "text";
element.value = String(value || "");
element.placeholder = placeholder;
return element;
}
function panelSelect(values, selected) {
const element = document.createElement("select");
for (const value of values) {
const option = document.createElement("option");
option.value = value;
option.textContent = value;
option.selected = value === selected;
element.append(option);
}
return element;
}
function panelCheckbox(checked, label) {
const wrapper = document.createElement("label");
wrapper.className = "funpack-scene-checkbox";
const input = document.createElement("input");
input.type = "checkbox";
input.checked = Boolean(checked);
const span = document.createElement("span");
span.textContent = label;
wrapper.append(input, span);
return { wrapper, input };
}
function editableTextLabel(value, onCommit, options = {}) {
const wrapper = document.createElement("div");
wrapper.className = "funpack-scene-editable";
const label = document.createElement("button");
label.type = "button";
let currentValue = String(value || "").trim();
const updateLabel = () => {
label.textContent = currentValue || "Unnamed";
label.title = currentValue ? `${currentValue}\n\nDouble-click to edit.` : "Double-click to edit.";
label.setAttribute("aria-label", currentValue ? `${currentValue}. Double-click to edit.` : "Double-click to edit.");
};
updateLabel();
wrapper.append(label);
const beginEdit = () => {
const editor = document.createElement("div");
editor.className = "funpack-scene-inline-editor";
const multiline = Boolean(options.multiline);
if (multiline) {
editor.classList.add("multiline");
wrapper.classList.add("editing");
}
const input = document.createElement(multiline ? "textarea" : "input");
const original = currentValue || label.textContent || "";
if (!multiline) {
input.type = "text";
} else {
input.rows = 4;
input.className = "funpack-scene-edit-textarea";
}
input.value = original;
const ok = panelButton("OK", "compact primary");
const cancel = panelButton("Cancel", "compact");
const actions = document.createElement("div");
actions.className = "funpack-scene-inline-actions";
let committed = false;
const restore = () => {
wrapper.classList.remove("editing");
editor.replaceWith(label);
};
const commit = () => {
if (committed) {
return;
}
committed = true;
const next = input.value.trim();
currentValue = next || original;
onCommit(currentValue);
updateLabel();
restore();
};
const cancelEdit = () => {
if (committed) {
return;
}
committed = true;
restore();
};
input.addEventListener("keydown", (event) => {
if (!multiline && event.key === "Enter") {
event.preventDefault();
commit();
} else if (multiline && event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
commit();
} else if (event.key === "Escape") {
event.preventDefault();
cancelEdit();
}
});
ok.addEventListener("click", commit);
cancel.addEventListener("click", cancelEdit);
actions.append(ok, cancel);
editor.append(input, actions);
label.replaceWith(editor);
input.focus();
input.select();
};
label.addEventListener("dblclick", beginEdit);
label.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === "F2") {
event.preventDefault();
beginEdit();
}
});
return wrapper;
}
function phraseButton(text, onInsert) {
const element = document.createElement("button");
element.type = "button";
element.className = "funpack-scene-chip";
element.textContent = text;
element.title = text;
element.draggable = true;
element.addEventListener("click", () => onInsert(text));
element.addEventListener("dragstart", (event) => {
event.dataTransfer?.setData("text/plain", text);
});
return element;
}
function normalizePromptSpacing(value) {
return String(value || "")
.replace(/\s+([,.])/g, "$1")
.replace(/([,.])([^\s,.])/g, "$1 $2")
.replace(/[ \t\r\f\v]+/g, " ")
.trim();
}
function normalizePromptEditorValue(value) {
return String(value || "")
.replaceAll(CARET_SENTINEL, "")
.replace(/\u00a0/g, " ")
.replace(/[ \t\r\f\v]+([,.])/g, "$1");
}
function promptLookupKey(value) {
return String(value || "")
.toLowerCase()
.replace(/[^\w'’]+/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function promptContainsPhrase(prompt, phrase) {
const promptKey = promptLookupKey(prompt);
const phraseKey = promptLookupKey(phrase);
if (!promptKey || !phraseKey) {
return false;
}
return ` ${promptKey} `.includes(` ${phraseKey} `);
}
function promptBoundary(value, index) {
if (index < 0 || index >= value.length) {
return true;
}
return !/[\w'’]/.test(value[index]);
}
function promptPhraseRanges(prompt, phrases) {
const source = String(prompt || "");
const ranges = [];
const occupied = new Array(source.length).fill(false);
const candidates = [...new Set((phrases || []).map((phrase) => normalizePromptSpacing(phrase)).filter(Boolean))]
.sort((left, right) => right.length - left.length || left.localeCompare(right));
for (const phrase of candidates) {
const pattern = phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\s+/g, "\\s+");
const regex = new RegExp(pattern, "gi");
let match = regex.exec(source);
while (match) {
const index = match.index;
const end = index + match[0].length;
const overlaps = occupied.slice(index, end).some(Boolean);
if (!overlaps && promptBoundary(source, index - 1) && promptBoundary(source, end)) {
ranges.push({ start: index, end, phrase, label: source.slice(index, end) });
for (let cursor = index; cursor < end; cursor += 1) {
occupied[cursor] = true;
}
}
match = regex.exec(source);
}
}
return ranges.sort((left, right) => left.start - right.start || right.end - left.end);
}
function promptMemoryPhrases(isNegative = false) {
return memoryItems()
.filter((item) => isNegative ? item.source === "negative" || item.category === "negative" : item.source !== "negative" && item.category !== "negative")
.map((item) => item.text || item.key || "")
.filter(Boolean);
}
function richPromptText(root) {
let output = "";
const append = (node) => {
if (node.nodeType === Node.TEXT_NODE) {
output += node.nodeValue || "";
return;
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return;
}
if (node.matches?.(".funpack-scene-inline-token")) {
output += node.textContent || "";
return;
}
if (node.tagName === "BR") {
output += "\n";
return;
}
for (const child of node.childNodes) {
append(child);
}
};
for (const child of root.childNodes) {
append(child);
}
return normalizePromptEditorValue(output);
}
function richNodeTextLength(node) {
if (!node) {
return 0;
}
if (node.nodeType === Node.TEXT_NODE) {
return normalizePromptEditorValue(node.nodeValue || "").length;
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return 0;
}
if (node.matches?.(".funpack-scene-inline-token")) {
return (node.textContent || "").length;
}
if (node.tagName === "BR") {
return 1;
}
let length = 0;
for (const child of node.childNodes) {
length += richNodeTextLength(child);
}
return length;
}
function richRangeTextOffset(root, range) {
if (!root || !range || !root.contains(range.startContainer)) {
return null;
}
let offset = 0;
let found = false;
const visit = (node) => {
if (found) {
return;
}
if (node === range.startContainer) {
if (node.nodeType === Node.TEXT_NODE) {
offset += normalizePromptEditorValue((node.nodeValue || "").slice(0, range.startOffset)).length;
} else {
const children = [...node.childNodes];
for (let index = 0; index < Math.min(range.startOffset, children.length); index += 1) {
offset += richNodeTextLength(children[index]);
}
}
found = true;
return;
}
if (node.nodeType === Node.TEXT_NODE || node.matches?.(".funpack-scene-inline-token") || node.tagName === "BR") {
offset += richNodeTextLength(node);
return;
}
for (const child of node.childNodes || []) {
visit(child);
if (found) {
return;
}
}
};
visit(root);
return found ? offset : null;
}
function isCaretSentinelNode(node) {
return node?.nodeType === Node.TEXT_NODE && node.nodeValue === CARET_SENTINEL;
}
function lastPromptContentChild(root) {
const children = [...(root?.childNodes || [])];
for (let index = children.length - 1; index >= 0; index -= 1) {
const child = children[index];
if (isCaretSentinelNode(child)) {
continue;
}
if (child.nodeType === Node.TEXT_NODE && !normalizePromptEditorValue(child.nodeValue || "").length) {
continue;
}
return child;
}
return null;
}
function ensureTrailingCaretTarget(root) {
for (const child of [...(root?.childNodes || [])]) {
if (isCaretSentinelNode(child)) {
child.remove();
}
}
if (lastPromptContentChild(root)?.matches?.(".funpack-scene-inline-token")) {
root.append(document.createTextNode(CARET_SENTINEL));
}
}
function setCaretToEnd(element) {
element.focus();
const range = document.createRange();
range.selectNodeContents(element);
range.collapse(false);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
}
function setCaretAtElementEdge(node, after = false) {
const range = document.createRange();
if (after) {
range.setStartAfter(node);
} else {
range.setStartBefore(node);
}
range.collapse(true);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
}
function setCaretByTextOffset(root, targetOffset) {
const target = Math.max(0, Number(targetOffset) || 0);
let offset = 0;
let placed = false;
const place = (node) => {
if (placed) {
return;
}
if (node.nodeType === Node.TEXT_NODE) {
const textLength = (node.nodeValue || "").length;
if (target <= offset + textLength) {
setCaretInTextNode(node, target - offset);
placed = true;
} else {
offset += textLength;
}
return;
}
if (node.nodeType === Node.ELEMENT_NODE && node.matches?.(".funpack-scene-inline-token")) {
const tokenLength = (node.textContent || "").length;
if (target <= offset + tokenLength) {
setCaretAtElementEdge(node, target > offset);
placed = true;
} else {
offset += tokenLength;
}
return;
}
if (node.nodeType === Node.ELEMENT_NODE && node.tagName === "BR") {
if (target <= offset + 1) {
setCaretAtElementEdge(node, true);
placed = true;
} else {
offset += 1;
}
return;
}
for (const child of node.childNodes || []) {
place(child);
if (placed) {
return;
}
}
};
root.focus();
place(root);
if (!placed) {
setCaretToEnd(root);
}
}
function setCaretInTextNode(node, offset) {
const range = document.createRange();
range.setStart(node, Math.max(0, Math.min(offset, (node.nodeValue || "").length)));
range.collapse(true);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
}
function setCaretAfterInlineToken(token) {
if (!token) {
return;
}
const range = document.createRange();
range.setStartAfter(token);
range.collapse(true);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
}
function placeCaretAfterLastTokenFromMouse(editor, event) {
if (event.target !== editor) {
return false;
}
const last = lastPromptContentChild(editor);
if (!last?.matches?.(".funpack-scene-inline-token")) {
return false;
}
const rect = last.getBoundingClientRect();
const sameLineAfterToken = event.clientY >= rect.top - 4 && event.clientY <= rect.bottom + 8 && event.clientX >= rect.right - 4;
const belowToken = event.clientY > rect.bottom + 8;
if (!sameLineAfterToken && !belowToken) {
return false;
}
editor.focus();
setCaretAfterInlineToken(last);
return true;
}
function insertIntoRichPrompt(editor, text, fallbackRange = null) {
editor.focus();
const selection = window.getSelection();
const activeRange = selection?.rangeCount && editor.contains(selection.anchorNode)
? selection.getRangeAt(0)
: fallbackRange;
if (activeRange && editor.contains(activeRange.commonAncestorContainer)) {
const promptBeforeEdit = richPromptText(editor);
const beforeOffset = richRangeTextOffset(editor, activeRange) ?? richNodeTextLength(editor);
const endRange = activeRange.cloneRange();
endRange.collapse(false);
const afterOffset = richRangeTextOffset(editor, endRange) ?? beforeOffset;
const beforeText = promptBeforeEdit.slice(0, beforeOffset);
const afterText = promptBeforeEdit.slice(afterOffset);
const prefix = beforeText.trim() && !/[,\s.;\n]$/.test(beforeText) ? ", " : "";
const suffix = afterText.trim() && !/^[,\s.;\n]/.test(afterText) ? ", " : "";
activeRange.deleteContents();
const insertion = `${prefix}${text}${suffix}`;
const node = document.createTextNode(insertion);
activeRange.insertNode(node);
activeRange.setStartAfter(node);
activeRange.collapse(true);
selection?.removeAllRanges();
selection?.addRange(activeRange);
return beforeOffset + insertion.length;
}
const current = richPromptText(editor);
const insertion = `${current.trim() && !/[,\s.;\n]$/.test(current) ? ", " : ""}${text}`;
editor.append(document.createTextNode(insertion));
setCaretToEnd(editor);
return richNodeTextLength(editor);
}
function inlineTokenFromNode(node, root) {
if (!node || !root) {
return null;
}
const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
const token = element?.closest?.(".funpack-scene-inline-token");
return token && root.contains(token) ? token : null;
}
function nearestEditableChild(node, root) {
if (!node || node === root) {
return null;
}
let child = node;
while (child?.parentNode && child.parentNode !== root) {
child = child.parentNode;
}
return child?.parentNode === root ? child : null;
}
function adjacentInlineToken(editor, direction) {
const selection = window.getSelection();
if (!selection || !selection.rangeCount) {
return null;
}
const range = selection.getRangeAt(0);
if (!editor.contains(range.commonAncestorContainer)) {
return null;
}
if (!range.collapsed) {
for (const token of editor.querySelectorAll(".funpack-scene-inline-token")) {
if (range.intersectsNode(token)) {
return token;
}
}
return null;
}
const node = range.startContainer;
const offset = range.startOffset;
const token = inlineTokenFromNode(node, editor);
if (token) {
return token;
}
if (node.nodeType === Node.TEXT_NODE) {
const atEdge = direction === "backward" ? offset === 0 : offset === (node.nodeValue || "").length;
if (!atEdge) {
return null;
}
const sibling = direction === "backward" ? node.previousSibling : node.nextSibling;
return inlineTokenFromNode(sibling, editor);
}
if (node.nodeType === Node.ELEMENT_NODE) {
const children = node.childNodes || [];
const child = node === editor ? children[direction === "backward" ? offset - 1 : offset] : nearestEditableChild(node, editor);
if (child) {
const sibling = node === editor ? child : (direction === "backward" ? child.previousSibling : child.nextSibling);
return inlineTokenFromNode(sibling || child, editor);
}
}
return null;
}
function editInlineTokenAsText(editor, token, direction) {
if (!token) {
return false;
}
const text = token.textContent || "";
const next = direction === "backward" ? text.slice(0, -1) : text.slice(1);
const caretOffset = direction === "backward" ? next.length : 0;
const replacement = document.createTextNode(next);
token.replaceWith(replacement);
editor.focus();
setCaretInTextNode(replacement, caretOffset);
return true;
}
function promptWords(value) {
const key = promptLookupKey(value);
return key ? key.split(" ").filter((word) => word.length >= 2) : [];
}
function removePhraseFromPrompt(prompt, phrase) {
const phraseKey = promptLookupKey(phrase);
if (!phraseKey) {
return prompt;
}
const parts = String(prompt || "").split(/([,;.\n]+)/);
let changed = false;
for (let index = 0; index < parts.length; index += 2) {
const segment = parts[index];
const segmentKey = promptLookupKey(segment);
if (!segmentKey) {
continue;
}
if (segmentKey === phraseKey) {
parts[index] = "";
changed = true;
continue;
}
const padded = ` ${segmentKey} `;
if (padded.includes(` ${phraseKey} `)) {
const words = phraseKey.split(" ").map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const pattern = new RegExp(`(^|[^\\w'’])(${words.join("[^\\w'’]+")})(?=$|[^\\w'’])`, "i");
parts[index] = segment.replace(pattern, "$1").replace(/\s+/g, " ").trim();
changed = true;
}
}
if (!changed) {
return prompt;
}
const output = parts.join("");
return normalizePromptSpacing(output.replace(/\s*([,;.])\s*([,;.]\s*)+/g, "$1 "));
}
function preconstructPrompt(currentPrompt, intentText, isNegative = false) {
if (isNegative) {
return { prompt: currentPrompt, added: [] };
}
const intentWords = new Set(promptWords(intentText));
if (!intentWords.size) {
return { prompt: currentPrompt, added: [] };
}
const prompt = normalizePromptSpacing(currentPrompt);
const intentKey = promptLookupKey(intentText);
const candidates = [];
for (const item of memoryItems()) {
if (item.source === "negative" || item.category === "negative") {
continue;
}
const text = normalizePromptSpacing(item.text || item.key || "");
const key = promptLookupKey(text);
if (!key || promptContainsPhrase(prompt, text)) {
continue;
}
const words = promptWords(text);
const overlap = words.filter((word) => intentWords.has(word)).length;
const exact = intentKey && ` ${intentKey} `.includes(` ${key} `);
if (!exact && !overlap) {
continue;
}
const count = Number.isFinite(Number(item.count)) ? Number(item.count) : 0;
const score = (exact ? 4 : 0) + overlap / Math.max(1, words.length) + Math.min(1.5, count * 0.04) + Math.min(0.5, words.length * 0.04);
candidates.push({ text, score, words: words.length, count });
}
candidates.sort((left, right) => right.score - left.score || right.words - left.words || right.count - left.count || left.text.localeCompare(right.text));
const added = [];
const seen = new Set(promptWords(prompt));
for (const candidate of candidates) {
const key = promptLookupKey(candidate.text);
if (!key || added.some((text) => promptLookupKey(text) === key)) {
continue;
}
added.push(candidate.text);
for (const word of promptWords(candidate.text)) {
seen.add(word);
}
if (added.length >= 16) {
break;
}
}
if (!added.length) {
return { prompt, added };
}
return {
prompt: normalizePromptSpacing(`${prompt}${prompt ? ", " : ""}${added.join(", ")}`),
added,
};
}
function shell(root, title, view) {
root.replaceChildren();
const header = document.createElement("div");
header.className = "funpack-scene-panel-header";
const h = document.createElement("div");
h.textContent = title;
const close = panelButton("Close");
close.addEventListener("click", closePanel);
header.append(h, close);
const error = document.createElement("div");
error.dataset.role = "error";
error.className = "funpack-scene-error";
const body = document.createElement("div");
body.className = `funpack-scene-body funpack-scene-${view}`;
root.append(header, error, body);
return body;
}
function renderPanel(panel, node, view, options = {}) {
if (!panel?.root) {
return;
}
if (options.cancelTarget) {
panel.cancelTarget = options.cancelTarget;
}
panel.currentView = view;
if (view === "positive" || view === "negative") {
renderPromptEditor(panel, node, view);
} else if (view === "database") {
renderDatabaseEditor(panel, node);
} else if (view === "shortcuts") {
renderShortcutEditor(panel, node);
} else if (view === "create") {
renderCreateScene(panel, node, options.nextView || "menu");
} else {
renderMenu(panel, node);
}
}
function navigatePanel(panel, node, view, options = {}) {
if (["menu", "positive", "negative", "database"].includes(view) && needsSceneCreation(node)) {
renderPanel(panel, node, "create", { ...options, nextView: view });
return;
}
renderPanel(panel, node, view, options);
}
function cancelEditor(panel, node) {
if (panel?.cancelTarget === "close") {
closePanel();
return;
}
renderPanel(panel, node, "menu");
}
function renderCreateScene(panel, node, nextView = "menu") {
const body = shell(panel.root, "Create scene", "create");
const message = document.createElement("div");
message.className = "funpack-scene-muted";
message.textContent = "Create a scene before editing prompts or database choices.";
body.append(message);
const row = document.createElement("label");
row.className = "funpack-scene-create-row";
const label = document.createElement("span");
label.textContent = "Scene name";
const input = panelTextInput(getWidgetValue(node, "scene_name", ""), "Scene name");
row.append(label, input);
body.append(row);
const footer = document.createElement("div");
footer.className = "funpack-scene-footer";
const cancel = panelButton("Cancel");
cancel.addEventListener("click", closePanel);
const create = panelButton("Create scene", "primary");
create.addEventListener("click", () => {
const name = input.value.trim();
if (!name) {
showPanelError(new Error("Scene name is required."));
input.focus();
return;
}
setWidgetValue(node, "scene_name", name);
setWidgetValue(node, "scene", NONE_SCENE);
setWidgetValue(node, "mode", getWidgetValue(node, "mode", "Manual") || "Manual");
setDirty(node);
renderPanel(panel, node, nextView, {
cancelTarget: nextView === "menu" ? "menu" : panel.cancelTarget,
});
});
input.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
create.click();
}
});
footer.append(cancel, create);
body.append(footer);
input.focus();
}
function renderMenu(panel, node) {
const body = shell(panel.root, "FunPack Scene Builder", "menu");
const controls = document.createElement("div");
controls.className = "funpack-scene-controls";
const sceneSelect = panelSelect(sceneNames(), String(getWidgetValue(node, "scene", NONE_SCENE) || NONE_SCENE));
sceneSelect.addEventListener("change", () => {
const name = sceneSelect.value;
if (name === NONE_SCENE) {
setWidgetValue(node, "scene", NONE_SCENE);
setWidgetValue(node, "scene_name", "");
setWidgetValue(node, "aliases", "");
setWidgetValue(node, "scene_positive", "");
setWidgetValue(node, "scene_negative", "");
setDirty(node);
renderPanel(panel, node, "menu");
return;
}
loadSceneIntoNode(node, name);
renderPanel(panel, node, "menu");
});
const sceneName = panelTextInput(getWidgetValue(node, "scene_name", ""), "Scene name");
sceneName.addEventListener("input", () => {
setWidgetValue(node, "scene_name", sceneName.value);
setDirty(node);
});
const mode = panelSelect(["Manual", "Auto", "Learning"], String(getWidgetValue(node, "mode", "Manual") || "Manual"));
mode.addEventListener("change", () => {
setWidgetValue(node, "mode", mode.value);
setDirty(node);
});
const aliases = panelTextInput(getWidgetValue(node, "aliases", ""), "Aliases, comma separated");
aliases.addEventListener("input", () => {
setWidgetValue(node, "aliases", aliases.value);
setDirty(node);
});
for (const [label, element] of [
["Saved scene", sceneSelect],
["Scene name", sceneName],
["Mode", mode],
["Aliases", aliases],
]) {
const row = document.createElement("label");
const span = document.createElement("span");
span.textContent = label;
row.append(span, element);
controls.append(row);
}
body.append(controls);
const current = document.createElement("div");
current.className = "funpack-scene-current";
const positive = String(getWidgetValue(node, "scene_positive", "") || "").trim();
const negative = String(getWidgetValue(node, "scene_negative", "") || "").trim();
for (const [label, value] of [
["Positive", positive || "empty"],
["Negative", negative || "empty"],
]) {
const row = document.createElement("div");
const b = document.createElement("b");
b.textContent = label;
const span = document.createElement("span");
span.textContent = value;
row.append(b, span);
current.append(row);
}
body.append(current);
const menu = document.createElement("div");
menu.className = "funpack-scene-menu-buttons";
for (const [label, target] of [["Positive prompt", "positive"], ["Negative prompt", "negative"], ["Database", "database"], ["Shortcuts", "shortcuts"]]) {
const item = panelButton(label, "large");
item.addEventListener("click", () => navigatePanel(panel, node, target, { cancelTarget: "menu" }));
menu.append(item);
}
body.append(menu);
const savedHeader = document.createElement("div");
savedHeader.className = "funpack-scene-section-title";
savedHeader.textContent = "Saved scenes";
body.append(savedHeader);
const saved = document.createElement("div");
saved.className = "funpack-scene-saved";
for (const name of savedSceneNames()) {
const item = panelButton(name);
item.title = name;
item.addEventListener("click", () => {
loadSceneIntoNode(node, name);
renderPanel(panel, node, "menu");
});
saved.append(item);
}
if (!saved.children.length) {
const empty = document.createElement("div");
empty.className = "funpack-scene-muted";
empty.textContent = "No saved scenes yet.";
saved.append(empty);
}
body.append(saved);
const footer = document.createElement("div");
footer.className = "funpack-scene-footer";
const refresh = panelButton("Refresh");
refresh.addEventListener("click", async () => {
await refreshNode(node);
renderPanel(panel, node, "menu");
});
const save = panelButton("Save scene", "primary");
save.addEventListener("click", async () => {
try {
await saveSceneFromNode(node);
renderPanel(panel, node, "menu");
} catch (error) {
showPanelError(error);
}
});
const del = panelButton("Delete scene", "danger");
del.addEventListener("click", async () => {
try {
await deleteSceneFromNode(node);
renderPanel(panel, node, "menu");
} catch (error) {
showPanelError(error);
}
});
const imp = panelButton("Import");
imp.addEventListener("click", () => importScenes(node));
const exp = panelButton("Export");
exp.addEventListener("click", async () => {
try {
await exportScenes(node);
} catch (error) {
showPanelError(error);
}
});
footer.append(refresh, save, del, imp, exp);
body.append(footer);
}
function renderPromptEditor(panel, node, kind) {
const isNegative = kind === "negative";
const widgetName = isNegative ? "scene_negative" : "scene_positive";
const snapshotKey = `${kind}_snapshot`;
if (!(snapshotKey in panel.snapshots)) {
panel.snapshots[snapshotKey] = String(getWidgetValue(node, widgetName, "") || "");
}
const body = shell(panel.root, isNegative ? "Negative prompt" : "Positive prompt", "prompt");
const composer = document.createElement("div");
composer.className = "funpack-scene-composer";
const editor = document.createElement("div");
editor.className = "funpack-scene-rich-prompt";
editor.contentEditable = "true";
editor.spellcheck = false;
editor.role = "textbox";
editor.ariaMultiline = "true";
editor.dataset.placeholder = isNegative ? "Write negative prompt words and phrases here." : "Write positive prompt words and phrases here.";
let promptValue = normalizePromptSpacing(getWidgetValue(node, widgetName, "") || "");
let renderingPrompt = false;
let savedEditorRange = null;
const saveEditorRange = () => {
const selection = window.getSelection();
if (!selection || !selection.rangeCount || !editor.contains(selection.anchorNode)) {
return;
}
savedEditorRange = selection.getRangeAt(0).cloneRange();
};
const setPromptValue = (value, options = {}) => {
promptValue = normalizePromptSpacing(value);
setWidgetValue(node, widgetName, promptValue);
setDirty(node);
updateUsedChips();
if (options.render) {
renderRichPrompt(Boolean(options.keepFocus), options.caretOffset);
}
};
const renderRichPrompt = (keepFocus = false, caretOffset = null) => {
renderingPrompt = true;
editor.replaceChildren();
const ranges = promptPhraseRanges(promptValue, promptMemoryPhrases(isNegative));
let cursor = 0;
for (const range of ranges) {
if (range.start > cursor) {
editor.append(document.createTextNode(promptValue.slice(cursor, range.start)));
}
const token = document.createElement("button");
token.type = "button";
token.className = "funpack-scene-inline-token";
token.contentEditable = "false";
token.dataset.phrase = range.phrase;
token.textContent = range.label;
token.title = `Remove ${range.phrase}`;
token.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
setPromptValue(removePhraseFromPrompt(promptValue, range.phrase), { render: true, keepFocus: true });
});
editor.append(token);
cursor = range.end;
}
if (cursor < promptValue.length) {
editor.append(document.createTextNode(promptValue.slice(cursor)));
}
ensureTrailingCaretTarget(editor);
renderingPrompt = false;
if (keepFocus) {
if (Number.isFinite(Number(caretOffset))) {
setCaretByTextOffset(editor, Number(caretOffset));
} else {
setCaretToEnd(editor);
}
saveEditorRange();
}
};
editor.addEventListener("input", () => {
if (renderingPrompt) {
return;
}
setPromptValue(richPromptText(editor), { render: false });
saveEditorRange();
});
editor.addEventListener("keyup", saveEditorRange);
editor.addEventListener("mouseup", (event) => {
placeCaretAfterLastTokenFromMouse(editor, event);
saveEditorRange();
});
editor.addEventListener("focus", saveEditorRange);
editor.addEventListener("keydown", (event) => {
if (event.key === "ArrowRight") {
const token = adjacentInlineToken(editor, "forward");
if (token?.matches?.(".funpack-scene-inline-token")) {
event.preventDefault();
setCaretAfterInlineToken(token);
saveEditorRange();
}
return;
}
if (event.key !== "Backspace" && event.key !== "Delete") {
return;
}
const token = adjacentInlineToken(editor, event.key === "Backspace" ? "backward" : "forward");
if (!token?.dataset?.phrase) {
return;
}
event.preventDefault();
if (editInlineTokenAsText(editor, token, event.key === "Backspace" ? "backward" : "forward")) {
setPromptValue(richPromptText(editor), { render: false });
saveEditorRange();
}
});
editor.addEventListener("blur", (event) => {
saveEditorRange();
if (panel.root.contains(event.relatedTarget)) {
return;
}
renderRichPrompt(false);
});
editor.addEventListener("dragover", (event) => event.preventDefault());
editor.addEventListener("drop", (event) => {
event.preventDefault();
const text = event.dataTransfer?.getData("text/plain");
if (text) {
const caretOffset = insertIntoRichPrompt(editor, text, savedEditorRange);
setPromptValue(richPromptText(editor), { render: true, keepFocus: true, caretOffset });
}
});
renderRichPrompt(false);
composer.append(editor);
body.append(composer);
const search = document.createElement("input");
search.type = "search";
search.placeholder = "Search phrase bank";
search.className = "funpack-scene-search";
const categoryFilter = panelSelect(["All", ...GROUP_ORDER], "All");
categoryFilter.title = "Show one category or all categories.";
const filterRow = document.createElement("div");
filterRow.className = "funpack-scene-filter-row";
filterRow.append(search, categoryFilter);
body.append(filterRow);
const bank = document.createElement("div");
bank.className = "funpack-scene-bank";
body.append(bank);
const updateUsedChips = () => {
for (const chip of bank.querySelectorAll(".funpack-scene-chip[data-phrase]")) {
const used = promptContainsPhrase(promptValue, chip.dataset.phrase || "");
chip.classList.toggle("used", used);
chip.title = used ? `Click to remove: ${chip.dataset.phrase}` : (chip.dataset.phrase || chip.textContent || "");
}
};
const renderBank = () => {
const query = search.value.toLowerCase().trim();
const selectedCategory = categoryFilter.value;
bank.replaceChildren();
const items = memoryItems()
.filter((item) => isNegative ? item.source === "negative" || item.category === "negative" : item.source !== "negative" && item.category !== "negative")
.filter((item) => selectedCategory === "All" || (item.category || (item.source === "negative" ? "negative" : "details")) === selectedCategory)
.filter((item) => !query || String(item.text || "").toLowerCase().includes(query));
const byGroup = new Map();
for (const item of items) {
const group = item.category || (item.source === "negative" ? "negative" : "details");
if (!byGroup.has(group)) {
byGroup.set(group, []);
}
byGroup.get(group).push(item);
}
for (const group of GROUP_ORDER) {
const groupItems = byGroup.get(group) || [];
if (!groupItems.length) {
continue;
}
const title = document.createElement("div");
title.className = "funpack-scene-category";
title.textContent = group;
bank.append(title);
const chips = document.createElement("div");
chips.className = "funpack-scene-chip-row";
for (const item of groupItems) {
const phrase = item.text || item.key;
const chip = phraseButton(phrase, (text) => {
if (promptContainsPhrase(promptValue, text)) {
setPromptValue(removePhraseFromPrompt(promptValue, text), { render: true, keepFocus: true });
} else {
const caretOffset = insertIntoRichPrompt(editor, text, savedEditorRange);
setPromptValue(richPromptText(editor), { render: true, keepFocus: true, caretOffset });
}
});
chip.dataset.phrase = phrase;
chips.append(chip);
}
bank.append(chips);
}
updateUsedChips();
};
search.addEventListener("input", renderBank);
categoryFilter.addEventListener("change", renderBank);
renderBank();
const footer = document.createElement("div");
footer.className = "funpack-scene-footer";
const back = panelButton("Back");
back.addEventListener("click", () => renderPanel(panel, node, "menu"));
const preconstruct = panelButton("Preconstruct", "primary");
preconstruct.disabled = isNegative;
preconstruct.title = isNegative ? "Preconstruct is available for the positive prompt." : "Add database phrases that match the current intent.";
preconstruct.addEventListener("click", () => {
const intent = String(
getWidgetValue(node, "intent_prompt", "") ||
linkedTextValue(node, "intent_prompt") ||
promptValue ||
""
);
const result = preconstructPrompt(promptValue, intent, isNegative);
if (!result.added.length) {
showPanelError(new Error("No matching database phrases found for the current intent."));
return;
}
const caretOffset = insertIntoRichPrompt(editor, result.added.join(", "), savedEditorRange);
setPromptValue(richPromptText(editor), { render: true, keepFocus: true, caretOffset });
});
const cancel = panelButton("Cancel");
cancel.addEventListener("click", () => {
setWidgetValue(node, widgetName, panel.snapshots[snapshotKey]);
setDirty(node);
delete panel.snapshots[snapshotKey];
cancelEditor(panel, node);
});
const confirm = panelButton("Confirm", "primary");
confirm.addEventListener("click", async () => {
try {
if (!activeSceneName(node)) {
renderPanel(panel, node, "create", { nextView: kind, cancelTarget: panel.cancelTarget });
return;
}
setWidgetValue(node, widgetName, promptValue);
await saveSceneFromNode(node);
delete panel.snapshots[snapshotKey];
renderPanel(panel, node, "menu");
} catch (error) {
showPanelError(error);
}
});
footer.append(back, preconstruct, cancel, confirm);
body.append(footer);
editor.focus();
setCaretToEnd(editor);
}
function renderDatabaseEditor(panel, node) {
if (!("database_snapshot" in panel.snapshots)) {
panel.snapshots.database_snapshot = JSON.parse(JSON.stringify(memoryItems()));
panel.databaseItems = JSON.parse(JSON.stringify(memoryItems()));
}
const body = shell(panel.root, "Database", "database");
const tools = document.createElement("div");
tools.className = "funpack-scene-db-tools";
const addText = document.createElement("input");
addText.placeholder = "Add word or phrase";
const addCategory = document.createElement("select");
for (const category of GROUP_ORDER) {
const option = document.createElement("option");
option.value = category;
option.textContent = category;
addCategory.append(option);
}
const addWildcard = panelCheckbox(false, "Wildcard");
const add = panelButton("Add", "primary");
add.addEventListener("click", () => {
const text = addText.value.trim();
if (!text) {
return;
}
panel.databaseItems.unshift({
text,
key: text.toLowerCase(),
source: addCategory.value === "negative" ? "negative" : "positive",
category: addCategory.value,
count: 0,
wildcard: addWildcard.input.checked,
});
addText.value = "";
addWildcard.input.checked = false;
renderPanel(panel, node, "database");
});
tools.append(addText, addCategory, addWildcard.wrapper, add);
body.append(tools);
const search = document.createElement("input");
search.type = "search";
search.placeholder = "Search database";
search.className = "funpack-scene-search";
body.append(search);
const list = document.createElement("div");
list.className = "funpack-scene-db-list";
body.append(list);
const renderList = () => {
const query = search.value.toLowerCase().trim();
list.replaceChildren();
const items = panel.databaseItems.filter((item) => !query || String(item.text || "").toLowerCase().includes(query));
items.forEach((item) => {
const row = document.createElement("div");
row.className = "funpack-scene-db-row";
const text = editableTextLabel(item.text || "", (value) => {
item.text = value;
item.key = value.toLowerCase();
}, { multiline: true });
const category = document.createElement("select");
for (const categoryName of GROUP_ORDER) {
const option = document.createElement("option");
option.value = categoryName;
option.textContent = categoryName;
option.selected = categoryName === item.category;
category.append(option);
}
category.addEventListener("change", () => {
item.category = category.value;
item.source = category.value === "negative" ? "negative" : "positive";
});
const wildcard = panelCheckbox(Boolean(item.wildcard || item.wildcard_group), "Wildcard");
wildcard.input.addEventListener("change", () => {
item.wildcard = wildcard.input.checked;
});
const del = panelButton("Delete", "danger");
del.addEventListener("click", () => {
panel.databaseItems = panel.databaseItems.filter((candidate) => candidate !== item);
renderList();
});
row.append(text, category, wildcard.wrapper, del);
list.append(row);
});
};
search.addEventListener("input", renderList);
renderList();
const footer = document.createElement("div");
footer.className = "funpack-scene-footer";
const back = panelButton("Back");
back.addEventListener("click", () => renderPanel(panel, node, "menu"));
const cancel = panelButton("Cancel");
cancel.addEventListener("click", () => {
panel.databaseItems = JSON.parse(JSON.stringify(panel.snapshots.database_snapshot));
delete panel.snapshots.database_snapshot;
cancelEditor(panel, node);
});
const confirm = panelButton("Confirm", "primary");
confirm.addEventListener("click", async () => {
try {
await saveDatabase(node, panel.databaseItems);
delete panel.snapshots.database_snapshot;
renderPanel(panel, node, "menu");
} catch (error) {
showPanelError(error);
}
});
footer.append(back, cancel, confirm);
body.append(footer);
}
function splitLines(value) {
return String(value || "")
.split(/\n+/)
.map((item) => item.trim())
.filter(Boolean);
}
function renderShortcutEditor(panel, node) {
const body = shell(panel.root, "Shortcuts", "shortcuts");
const tools = document.createElement("div");
tools.className = "funpack-scene-shortcut-tools";
const add = panelButton("Add shortcut", "primary");
add.addEventListener("click", () => {
panel.shortcutDrafts = panel.shortcutDrafts || JSON.parse(JSON.stringify(shortcutItems()));
panel.shortcutDrafts.unshift({
name: "",
enabled: true,
triggers: [],
replacements: [],
});
renderPanel(panel, node, "shortcuts");
});
const refresh = panelButton("Refresh");
refresh.addEventListener("click", async () => {
await fetchShortcuts();
delete panel.shortcutDrafts;
renderPanel(panel, node, "shortcuts");
});
const imp = panelButton("Import");
imp.addEventListener("click", () => importShortcuts(() => {
delete panel.shortcutDrafts;
renderPanel(panel, node, "shortcuts");
}));
const exp = panelButton("Export");
exp.addEventListener("click", async () => {
try {
await exportShortcuts();
} catch (error) {
showPanelError(error);
}
});
tools.append(add, refresh, imp, exp);
body.append(tools);
if (!panel.shortcutDrafts) {
panel.shortcutDrafts = JSON.parse(JSON.stringify(shortcutItems()));
}
const list = document.createElement("div");
list.className = "funpack-scene-shortcut-list";
body.append(list);
const drafts = panel.shortcutDrafts;
if (!drafts.length) {
const empty = document.createElement("div");
empty.className = "funpack-scene-muted";
empty.textContent = "No shortcuts yet.";
list.append(empty);
}
drafts.forEach((item, index) => {
const row = document.createElement("div");
row.className = "funpack-scene-shortcut-row";
const top = document.createElement("div");
top.className = "funpack-scene-shortcut-top";
const name = panelTextInput(item.name || "", "Shortcut name");
name.addEventListener("input", () => { item.name = name.value; });
const enabled = panelCheckbox(item.enabled !== false, "Enabled");
enabled.input.addEventListener("change", () => { item.enabled = enabled.input.checked; });
top.append(name, enabled.wrapper);
const triggers = document.createElement("textarea");
triggers.className = "funpack-scene-edit-textarea";
triggers.placeholder = "Activation phrases, one per line";
triggers.value = (item.triggers || []).join("\n");
triggers.addEventListener("input", () => { item.triggers = splitLines(triggers.value); });
const replacements = document.createElement("textarea");
replacements.className = "funpack-scene-edit-textarea";
replacements.placeholder = "Replacement phrases, one per line";
replacements.value = (item.replacements || []).join("\n");
replacements.addEventListener("input", () => { item.replacements = splitLines(replacements.value); });
const actions = document.createElement("div");
actions.className = "funpack-scene-shortcut-actions";
const save = panelButton("Save", "primary");
save.addEventListener("click", async () => {
try {
await saveShortcut({
original_name: item.key || item.name,
name: item.name,
enabled: item.enabled !== false,
triggers: splitLines(triggers.value),
replacements: splitLines(replacements.value),
});
await fetchShortcuts();
delete panel.shortcutDrafts;
renderPanel(panel, node, "shortcuts");
} catch (error) {
showPanelError(error);
}
});
const del = panelButton("Delete", "danger");
del.addEventListener("click", async () => {
try {
if (item.key || item.name) {
await deleteShortcut(item.name || item.key);
await fetchShortcuts();
}
drafts.splice(index, 1);
delete panel.shortcutDrafts;
renderPanel(panel, node, "shortcuts");
} catch (error) {
showPanelError(error);
}
});
actions.append(save, del);
row.append(top, triggers, replacements, actions);
list.append(row);
});
const footer = document.createElement("div");
footer.className = "funpack-scene-footer";
const back = panelButton("Back");
back.addEventListener("click", () => {
delete panel.shortcutDrafts;
renderPanel(panel, node, "menu");
});
footer.append(back);
body.append(footer);
}
function openPanel(node, view = "menu") {
closePanel();
injectStyles();
const root = document.createElement("div");
root.className = "funpack-scene-panel";
document.body.append(root);
activePanel = {
root,
snapshots: {},
databaseItems: [],
cancelTarget: view === "menu" ? "menu" : "close",
};
const viewportWidth = Number(window.innerWidth);
const viewportHeight = Number(window.innerHeight);
if (Number.isFinite(viewportWidth) && Number.isFinite(viewportHeight) && viewportWidth > 0 && viewportHeight > 0) {
root.style.left = "50%";
root.style.top = "50%";
root.style.transform = "translate(-50%, -50%)";
} else {
const canvasRect = app.canvas?.canvas?.getBoundingClientRect?.();
const scale = app.canvas?.ds?.scale || 1;
const offset = app.canvas?.ds?.offset || [0, 0];
const screenX = (canvasRect?.left || 0) + (node.pos[0] + node.size[0]) * scale + offset[0];
const screenY = (canvasRect?.top || 0) + node.pos[1] * scale + offset[1];
const left = Math.min(Math.max(12, window.innerWidth - 460), Math.max(12, screenX + 16));
const top = Math.min(Math.max(12, window.innerHeight - 620), Math.max(12, screenY));
root.style.left = `${left}px`;
root.style.top = `${top}px`;
root.style.transform = "";
}
void refreshNode(node).catch((error) => {
console.warn("FunPack: failed to refresh scenes before opening Scene Builder", error);
}).finally(() => {
if (activePanel?.root === root) {
navigatePanel(activePanel, node, view, { cancelTarget: activePanel.cancelTarget });
}
});
}
let stylesInjected = false;
function injectStyles() {
if (stylesInjected) {
return;
}
stylesInjected = true;
const style = document.createElement("style");
style.textContent = `
.funpack-scene-panel {
position: fixed;
z-index: 10000;
width: min(448px, calc(100vw - 24px));
max-height: min(620px, calc(100vh - 24px));
display: flex;
flex-direction: column;
padding: 10px;
border: 1px solid rgba(180, 190, 200, 0.35);
border-radius: 8px;
background: rgba(30, 32, 36, 0.98);
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.42);
color: #ddd;
font: 12px sans-serif;
box-sizing: border-box;
}
.funpack-scene-panel * { box-sizing: border-box; }
.funpack-scene-panel-header,
.funpack-scene-footer {
display: flex;
align-items: center;
gap: 8px;
justify-content: space-between;
flex-wrap: wrap;
}
.funpack-scene-panel-header {
font-weight: 700;
font-size: 14px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.funpack-scene-body {
overflow: auto;
padding-top: 8px;
min-height: 0;
}
.funpack-scene-error {
min-height: 16px;
color: #ff9f9f;
padding-top: 5px;
}
.funpack-scene-button {
min-height: 26px;
padding: 4px 10px;
border: 1px solid rgba(180, 190, 200, 0.35);
border-radius: 5px;
background: #22252a;
color: #eee;
cursor: pointer;
white-space: nowrap;
}
.funpack-scene-button:hover { background: #2b3037; }
.funpack-scene-button.primary { border-color: rgba(100, 210, 140, 0.6); background: #244832; }
.funpack-scene-button.danger { border-color: rgba(255, 130, 130, 0.45); background: #472626; }
.funpack-scene-button.compact {
min-height: 28px;
padding: 4px 7px;
}
.funpack-scene-button.large {
width: 100%;
height: 42px;
text-align: left;
font-weight: 700;
}
.funpack-scene-current {
display: grid;
gap: 5px;
margin-bottom: 10px;
}
.funpack-scene-controls {
display: grid;
gap: 7px;
margin-bottom: 10px;
}
.funpack-scene-controls label {
display: grid;
grid-template-columns: 86px minmax(0, 1fr);
gap: 8px;
align-items: center;
}
.funpack-scene-create-row {
display: grid;
grid-template-columns: 86px minmax(0, 1fr);
gap: 8px;
align-items: center;
margin-top: 8px;
}
.funpack-scene-controls span {
color: #b8c0ca;
}
.funpack-scene-current div {
display: grid;
grid-template-columns: 78px minmax(0, 1fr);
gap: 8px;
}
.funpack-scene-current span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #b8c0ca;
}
.funpack-scene-menu-buttons,
.funpack-scene-saved {
display: grid;
gap: 7px;
}
.funpack-scene-section-title,
.funpack-scene-category {
margin: 12px 0 6px;
color: #58a6d6;
font-weight: 700;
text-transform: uppercase;
}
.funpack-scene-textarea {
width: 100%;
min-height: 156px;
resize: vertical;
padding: 8px;
border: 1px solid rgba(180, 190, 200, 0.35);
border-radius: 6px;
background: #17191d;
color: #f4f4f4;
line-height: 1.35;
outline: none;
}
.funpack-scene-search,
.funpack-scene-controls input,
.funpack-scene-controls select,
.funpack-scene-create-row input,
.funpack-scene-db-tools input,
.funpack-scene-db-tools select,
.funpack-scene-editable input,
.funpack-scene-db-row input,
.funpack-scene-db-row select {
width: 100%;
min-height: 28px;
padding: 5px 7px;
border: 1px solid rgba(180, 190, 200, 0.28);
border-radius: 5px;
background: #17191d;
color: #f2f2f2;
outline: none;
}
.funpack-scene-db-tools .funpack-scene-checkbox input,
.funpack-scene-db-row .funpack-scene-checkbox input {
width: auto;
min-height: 0;
padding: 0;
}
.funpack-scene-checkbox {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
min-height: 28px;
color: #d9dee5;
white-space: nowrap;
}
.funpack-scene-editable {
min-width: 0;
}
.funpack-scene-editable.editing {
grid-column: 1 / -1;
}
.funpack-scene-inline-editor {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 5px;
align-items: center;
}
.funpack-scene-inline-editor.multiline {
grid-template-columns: minmax(0, 1fr);
align-items: stretch;
}
.funpack-scene-inline-actions {
display: flex;
justify-content: flex-end;
gap: 6px;
}
.funpack-scene-edit-textarea {
width: 100%;
min-height: 92px;
padding: 7px 8px;
border: 1px solid rgba(180, 190, 200, 0.32);
border-radius: 5px;
background: #17191d;
color: #f2f2f2;
line-height: 1.35;
resize: vertical;
outline: none;
}
.funpack-scene-editable button {
width: 100%;
min-height: 28px;
padding: 5px 7px;
border: 1px solid transparent;
border-radius: 5px;
background: transparent;
color: #f2f2f2;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: default;
}
.funpack-scene-editable button:hover,
.funpack-scene-editable button:focus {
border-color: rgba(180, 190, 200, 0.28);
background: #17191d;
outline: none;
}
.funpack-scene-composer {
min-height: 170px;
padding: 7px;
border: 1px solid rgba(255,255,255,0.14);
border-radius: 6px;
background: #101216;
}
.funpack-scene-composer:focus-within {
border-color: rgba(100, 210, 140, 0.55);
box-shadow: 0 0 0 1px rgba(100, 210, 140, 0.12);
}
.funpack-scene-rich-prompt {
width: 100%;
min-height: 154px;
padding: 0;
border: 0;
background: transparent;
color: #f4f4f4;
line-height: 1.45;
outline: none;
white-space: pre-wrap;
overflow-wrap: anywhere;
cursor: text;
}
.funpack-scene-rich-prompt:empty::before {
content: attr(data-placeholder);
color: #7f8792;
pointer-events: none;
}
.funpack-scene-inline-token {
display: inline-flex;
align-items: center;
max-width: 100%;
margin: 0 2px 3px;
padding: 1px 6px;
border: 1px solid rgba(100, 210, 140, 0.75);
border-radius: 5px;
background: rgba(58, 132, 86, 0.42);
color: #eaffef;
font: inherit;
line-height: 1.25;
vertical-align: baseline;
cursor: pointer;
white-space: normal;
text-align: left;
}
.funpack-scene-inline-token:hover,
.funpack-scene-inline-token:focus {
background: rgba(73, 158, 102, 0.56);
outline: none;
}
.funpack-scene-inline-token::after {
content: " ×";
padding-left: 4px;
color: #bff4cd;
font-weight: 700;
}
.funpack-scene-search { margin: 8px 0; }
.funpack-scene-filter-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 135px;
gap: 8px;
align-items: center;
}
.funpack-scene-bank,
.funpack-scene-db-list {
max-height: 270px;
overflow: auto;
padding-right: 3px;
}
.funpack-scene-chip-row {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.funpack-scene-chip {
max-width: 100%;
padding: 4px 8px;
border: 1px solid rgba(255,255,255,0.14);
border-radius: 6px;
background: rgba(255,255,255,0.07);
color: #eee;
cursor: grab;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.funpack-scene-chip.used {
border-color: rgba(100, 210, 140, 0.75);
background: rgba(58, 132, 86, 0.42);
color: #eaffef;
}
.funpack-scene-chip.used::after {
content: " used";
color: #a8efbf;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
}
.funpack-scene-db-tools {
display: grid;
grid-template-columns: minmax(0, 1.6fr) 110px auto auto;
gap: 6px;
align-items: center;
}
.funpack-scene-db-row {
display: grid;
grid-template-columns: minmax(0, 1.7fr) 110px auto auto;
gap: 6px;
align-items: center;
padding: 5px 0;
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.funpack-scene-shortcut-tools,
.funpack-scene-shortcut-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
justify-content: flex-end;
margin-bottom: 8px;
}
.funpack-scene-shortcut-list {
display: grid;
gap: 10px;
max-height: 430px;
overflow: auto;
padding-right: 3px;
}
.funpack-scene-shortcut-row {
display: grid;
gap: 7px;
padding: 8px;
border: 1px solid rgba(255,255,255,0.08);
border-radius: 6px;
background: rgba(255,255,255,0.035);
}
.funpack-scene-shortcut-top {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
}
.funpack-scene-footer {
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid rgba(255,255,255,0.08);
justify-content: flex-end;
}
.funpack-scene-muted { color: #9da6b0; }
`;
document.head.append(style);
}
class SceneBuilderWidget {
constructor(node) {
this.node = node;
this.name = "funpack_scene_builder";
this.type = "custom";
this.options = { serialize: false };
this.value = "";
this.hitAreas = {};
}
computeSize(width) {
return [width, 82];
}
draw(ctx, node, width, y) {
this.hitAreas = {};
const margin = 10;
let cy = y + 4;
const innerWidth = width - margin * 2;
const mode = getWidgetValue(node, "mode", "Manual");
const scene = String(getWidgetValue(node, "scene_name", "") || "Unnamed scene").trim();
ctx.save();
ctx.beginPath();
ctx.rect(0, y, width, this.computeSize(width)[1]);
ctx.clip();
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
ctx.textBaseline = "middle";
ctx.textAlign = "left";
ctx.font = "12px sans-serif";
ctx.fillText(fitString(ctx, `Scene Builder: ${scene}`, width - 134), margin, cy + 9);
button(ctx, this, "open:menu", "Open Editor", width - 112, cy, 92, 20);
cy += 28;
const third = Math.floor((innerWidth - 12) / 3);
button(ctx, this, "open:positive", "Positive", margin, cy, third, 28);
button(ctx, this, "open:negative", "Negative", margin + third + 6, cy, third, 28);
button(ctx, this, "open:database", "Database", margin + (third + 6) * 2, cy, innerWidth - (third + 6) * 2, 28);
ctx.globalAlpha = app.canvas.editor_alpha * 0.62;
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
ctx.fillText(`${mode}. ${memoryItems().length} database phrase(s). Connected prompts learn on queue.`, margin, cy + 42);
ctx.globalAlpha = app.canvas.editor_alpha;
ctx.restore();
}
mouse(event, pos, node) {
if (event.type !== "pointerup") {
return event.type === "pointerdown" && this.hitAt(pos);
}
const hit = Object.entries(this.hitAreas).find(([, bounds]) => (
pos[0] >= bounds[0] && pos[0] <= bounds[0] + bounds[2] &&
pos[1] >= bounds[1] && pos[1] <= bounds[1] + bounds[3]
));
if (!hit) {
return false;
}
const [, view] = hit[0].split(":");
openPanel(node, view || "menu");
return true;
}
hitAt(pos) {
return Object.values(this.hitAreas).some((bounds) => (
pos[0] >= bounds[0] && pos[0] <= bounds[0] + bounds[2] &&
pos[1] >= bounds[1] && pos[1] <= bounds[1] + bounds[3]
));
}
}
function removeSceneBuilderWidget(node) {
node.widgets = (node.widgets || []).filter((widget) => widget.name !== "funpack_scene_builder");
}
function setupSceneBuilderNode(node) {
trackedNodes.add(node);
const hideInternalWidgets = () => {
for (const widget of node.widgets || []) {
if (HIDDEN_WIDGETS.has(widget.name)) {
hideWidget(widget);
}
}
setDirty(node);
};
hideInternalWidgets();
window.requestAnimationFrame?.(hideInternalWidgets);
window.setTimeout(hideInternalWidgets, 0);
window.setTimeout(hideInternalWidgets, 250);
removeSceneBuilderWidget(node);
node.addCustomWidget(new SceneBuilderWidget(node));
updateSceneCombo(node);
void fetchScenes(currentRefinementKey(node)).then(() => {
updateSceneCombo(node);
setDirty(node);
});
}
function wrapRefreshFunction(functionName) {
const original = app[functionName];
if (typeof original !== "function" || original.__funpackSceneBuilderWrapped) {
return;
}
const wrapped = function () {
const result = original.apply(this, arguments);
Promise.resolve(result).finally(() => scheduleRefresh());
return result;
};
wrapped.__funpackSceneBuilderWrapped = true;
app[functionName] = wrapped;
}
function hookComfyRefreshControls() {
for (const functionName of ["refreshComboInNodes", "refreshComboInNode", "refreshNodeDefs"]) {
wrapRefreshFunction(functionName);
}
}
app.registerExtension({
name: "funpack.sceneBuilder",
setup() {
hookComfyRefreshControls();
window.setTimeout(hookComfyRefreshControls, 0);
window.setTimeout(hookComfyRefreshControls, 1000);
},
beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== NODE_NAME) {
return;
}
hookComfyRefreshControls();
const originalOnNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
originalOnNodeCreated?.apply(this, arguments);
setupSceneBuilderNode(this);
};
const originalConfigure = nodeType.prototype.configure;
nodeType.prototype.configure = function (info) {
originalConfigure?.apply(this, arguments);
setupSceneBuilderNode(this);
};
},
});