2207 lines
68 KiB
JavaScript
2207 lines
68 KiB
JavaScript
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 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 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;
|
||
}
|
||
|
||
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 fetchScenes(currentRefinementKey(node));
|
||
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 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 === "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"]]) {
|
||
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 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-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);
|
||
};
|
||
},
|
||
});
|