Files

611 lines
21 KiB
JavaScript

import { api } from "../../../scripts/api.js";
// ── Constants ──────────────────────────────────────────────────────────────────
export const NODE_NAME = "TemplateReference";
export const STORAGE_WIDGET = "template_json";
export const PROMPT_NODE_NAME = "PromptTemplate";
export const PROMPT_LORA_NODE_NAME = "PromptTemplateLoRA";
export const PROMPT_STORAGE_WIDGET = "prompt_template_json";
export const PROMPT_TEMPLATE_API = "/template_reference/prompt_templates";
export const PROMPT_TEMPLATE_LORA_API = "/template_reference/prompt_templates_lora";
export const REFERENCE_TEMPLATE_API = "/template_reference/reference_templates";
export const LORA_API = "/template_reference/loras";
export const NODE_MIN_WIDTH = 460;
export const NODE_MIN_HEIGHT = 150;
export const NODE_CHROME_HEIGHT = 92;
export const DOM_HEIGHT_PADDING = 18;
export const IMAGE_SUBFOLDER = "template_reference";
export const DEFAULT_TEXT_HEIGHT = 82;
export const MIN_TEXT_HEIGHT = 58;
export const MAX_TEXT_HEIGHT = 420;
export const DEFAULT_PREVIEW_HEIGHT = 138;
export const MIN_PREVIEW_HEIGHT = 96;
export const MAX_PREVIEW_HEIGHT = 380;
// ── ID generation (#11 — use crypto.randomUUID where available) ────────────
export function makeId() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
// ── Widget helpers ─────────────────────────────────────────────────────────────
export function findWidget(node, name) {
return node.widgets?.find((widget) => widget.name === name);
}
export function hideWidget(widget) {
if (!widget || widget.__templateReferenceHidden) {
return;
}
widget.type = "hidden";
widget.computeSize = () => [0, -12];
widget.draw = () => {};
if (widget.inputEl) {
widget.inputEl.style.display = "none";
}
widget.__templateReferenceHidden = true;
}
export function setWidgetValue(widget, value) {
if (!widget) {
return;
}
widget.value = value;
if (widget.inputEl) {
widget.inputEl.value = value;
}
}
// ── Normalize helpers ──────────────────────────────────────────────────────────
export function normalizeImage(image = {}) {
return {
name: image.name || "",
subfolder: image.subfolder || "",
type: image.type || "input",
path: image.path || "",
preview_height: Number(image.preview_height) || DEFAULT_PREVIEW_HEIGHT,
storage: image.storage || "",
template: image.template || image.template_name || "",
template_name: image.template_name || image.template || "",
cache_key: image.cache_key || "",
};
}
export function normalizeItems(value) {
let payload;
try {
payload = JSON.parse(value || "{}");
} catch {
payload = {};
}
const rawItems = Array.isArray(payload) ? payload : Array.isArray(payload.items) ? payload.items : [];
return rawItems
.filter((item) => item && (item.type === "text" || item.type === "image"))
.map((item) => {
if (item.type === "text") {
return {
id: item.id || makeId(),
type: "text",
title: item.title || "",
text: item.text || "",
text_height: Number(item.text_height) || DEFAULT_TEXT_HEIGHT,
collapsed: Boolean(item.collapsed),
};
}
return {
id: item.id || makeId(),
type: "image",
title: item.title || "",
collapsed: Boolean(item.collapsed),
image: normalizeImage(item.image),
};
});
}
export function normalizePromptItems(value) {
let payload;
try {
payload = JSON.parse(value || "{}");
} catch {
payload = {};
}
const rawItems = Array.isArray(payload) ? payload : Array.isArray(payload.items) ? payload.items : [];
return rawItems
.filter((item) => item && typeof item === "object")
.map((item) => ({
id: item.id || makeId(),
type: "text",
title: item.title || "",
text: item.text || "",
text_height: Number(item.text_height) || DEFAULT_TEXT_HEIGHT,
collapsed: Boolean(item.collapsed),
image_collapsed: item.image_collapsed !== false,
image: normalizeImage(item.image),
}));
}
export function normalizeLora(lora = {}) {
const source = lora && typeof lora === "object" ? lora : {};
const strength = Number(source.strength);
return {
name: source.name || "",
strength: Number.isFinite(strength) ? strength : 1.0,
};
}
export function normalizePromptLoraItems(value) {
let rawItems = [];
try {
const payload = JSON.parse(value || "{}");
rawItems = Array.isArray(payload) ? payload : Array.isArray(payload.items) ? payload.items : [];
} catch {
rawItems = [];
}
return normalizePromptItems(value).map((item, index) => {
const rawById = rawItems.find((entry) => entry && entry.id && entry.id === item.id);
const raw = rawById || rawItems[index] || {};
return {
...item,
lora_collapsed: raw.lora_collapsed !== false,
lora: normalizeLora(raw.lora),
};
});
}
export function normalizeTemplateList(payload) {
return Array.isArray(payload?.templates) ? payload.templates.filter((name) => typeof name === "string") : [];
}
// ── Serialization ──────────────────────────────────────────────────────────────
export function serializeItems(items) {
return JSON.stringify({ version: 1, items });
}
export function serializeReferenceState(state) {
return JSON.stringify({ version: 1, selected_id: state.selectedId || "", list_hidden: Boolean(state.listHidden), items: state.items });
}
// ── JSON field parsers ─────────────────────────────────────────────────────────
export function referenceSelectedId(value) {
try {
const payload = JSON.parse(value || "{}");
return typeof payload?.selected_id === "string" ? payload.selected_id : "";
} catch {
return "";
}
}
export function referenceListHidden(value) {
try {
const payload = JSON.parse(value || "{}");
return Boolean(payload?.list_hidden);
} catch {
return false;
}
}
export function referenceFileName(value) {
try {
const payload = JSON.parse(value || "{}");
return typeof payload?.file_name === "string" ? payload.file_name : "";
} catch {
return "";
}
}
export function promptSelectedId(value) {
try {
const payload = JSON.parse(value || "{}");
return typeof payload?.selected_id === "string" ? payload.selected_id : "";
} catch {
return "";
}
}
export function promptListHidden(value) {
try {
const payload = JSON.parse(value || "{}");
return Boolean(payload?.list_hidden);
} catch {
return false;
}
}
export function promptFileName(value) {
try {
const payload = JSON.parse(value || "{}");
return typeof payload?.file_name === "string" ? payload.file_name : "";
} catch {
return "";
}
}
export function promptPayloadFromState(state) {
return {
version: 1,
selected_id: state.selectedId || "",
list_hidden: Boolean(state.listHidden),
items: state.items,
};
}
export function referencePayloadFromState(state) {
return {
version: 1,
selected_id: state.selectedId || "",
list_hidden: Boolean(state.listHidden),
items: state.items,
};
}
// ── Image path helpers ─────────────────────────────────────────────────────────
export function imagePath(image) {
if (!image) {
return "";
}
if (image.path) {
return image.path;
}
if (!image.name) {
return "";
}
return image.subfolder ? `${image.subfolder}/${image.name}` : image.name;
}
export function imageViewUrl(image) {
if (!image?.name) {
return "";
}
const normalizedPath = image.path ? image.path.replaceAll("\\", "/") : "";
const cacheKey = image.cache_key ? `?t=${encodeURIComponent(image.cache_key)}` : "";
if (image.storage === "prompt_template_lora" || image.type === "prompt_template_lora" || normalizedPath.startsWith("prompt_templates_lora/images/")) {
let templateName = image.template || image.template_name || "";
if (!templateName && image.path) {
const parts = normalizedPath.split("/");
const imagesIndex = parts.findIndex((part) => part === "images");
if (imagesIndex >= 0 && parts[imagesIndex + 1]) {
templateName = parts[imagesIndex + 1];
}
}
if (!templateName) {
return "";
}
return api.apiURL(`${PROMPT_TEMPLATE_LORA_API}/image/${encodeURIComponent(templateName)}/${encodeURIComponent(image.name)}${cacheKey}`);
}
if (image.storage === "prompt_template" || image.type === "prompt_template") {
let templateName = image.template || image.template_name || "";
if (!templateName && image.path) {
const parts = image.path.replaceAll("\\", "/").split("/");
const imagesIndex = parts.findIndex((part) => part === "images");
if (imagesIndex >= 0 && parts[imagesIndex + 1]) {
templateName = parts[imagesIndex + 1];
}
}
if (!templateName) {
return "";
}
return api.apiURL(`${PROMPT_TEMPLATE_API}/image/${encodeURIComponent(templateName)}/${encodeURIComponent(image.name)}${cacheKey}`);
}
if (image.storage === "template_reference_file" || image.type === "template_reference_file") {
let templateName = image.template || image.template_name || "";
if (!templateName && image.path) {
const parts = image.path.replaceAll("\\", "/").split("/");
const imageIndex = parts.findIndex((part) => part === "image");
if (imageIndex >= 0 && parts[imageIndex + 1]) {
templateName = parts[imageIndex + 1];
}
}
if (!templateName) {
return "";
}
return api.apiURL(`${REFERENCE_TEMPLATE_API}/image/${encodeURIComponent(templateName)}/${encodeURIComponent(image.name)}${cacheKey}`);
}
const params = new URLSearchParams({
filename: image.name,
type: image.type || "input",
subfolder: image.subfolder || "",
});
if (image.cache_key) {
params.set("t", image.cache_key);
}
return api.apiURL(`/view?${params.toString()}`);
}
// ── Math helpers ───────────────────────────────────────────────────────────────
export function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
export function previewHeight(image) {
return clamp(Number(image?.preview_height) || DEFAULT_PREVIEW_HEIGHT, MIN_PREVIEW_HEIGHT, MAX_PREVIEW_HEIGHT);
}
export function textHeight(item) {
return clamp(Number(item?.text_height) || DEFAULT_TEXT_HEIGHT, MIN_TEXT_HEIGHT, MAX_TEXT_HEIGHT);
}
// ── DOM helpers ────────────────────────────────────────────────────────────────
export function stopCanvasEvent(event) {
event.stopPropagation();
}
export function createButton(label, className = "") {
const button = document.createElement("button");
button.type = "button";
button.textContent = label;
button.className = className;
return button;
}
export function createInput(value, placeholder) {
const input = document.createElement("input");
input.type = "text";
input.value = value || "";
input.placeholder = placeholder;
input.spellcheck = false;
return input;
}
export function makeEditableTitle(initialValue, onChange, placeholder = "Title") {
const wrapper = document.createElement("div");
wrapper.className = "tr-title-editor";
let value = initialValue || "";
let originalValue = value;
const showDisplay = () => {
wrapper.innerHTML = "";
const display = createButton(value || placeholder, "tr-title tr-title-display");
display.title = "Click to edit title";
if (!value) {
display.classList.add("tr-title-placeholder");
}
display.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
showInput();
});
wrapper.appendChild(display);
};
const showInput = () => {
wrapper.innerHTML = "";
originalValue = value;
const input = createInput(value, placeholder);
input.className = "tr-title tr-title-input";
input.addEventListener("input", () => {
value = input.value;
onChange(value);
});
input.addEventListener("keydown", (event) => {
event.stopPropagation();
if (event.key === "Enter") {
event.preventDefault();
input.blur();
}
if (event.key === "Escape") {
event.preventDefault();
value = originalValue;
onChange(value);
showDisplay();
}
});
input.addEventListener("blur", showDisplay);
wrapper.appendChild(input);
requestAnimationFrame(() => {
input.focus();
input.select();
});
};
showDisplay();
return wrapper;
}
export async function copyTextToClipboard(text) {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const input = document.createElement("textarea");
input.value = text;
input.style.position = "fixed";
input.style.left = "-9999px";
input.style.top = "0";
document.body.appendChild(input);
input.focus();
input.select();
const copied = document.execCommand("copy");
input.remove();
if (!copied) {
throw new Error("Copy failed");
}
}
export function makeResizeHandle(label, onResize) {
const handle = document.createElement("div");
handle.className = "tr-resize-handle";
handle.title = label;
handle.addEventListener("pointerdown", (event) => {
event.preventDefault();
event.stopPropagation();
const startY = event.clientY;
const startHeight = onResize();
const onMove = (moveEvent) => {
moveEvent.preventDefault();
moveEvent.stopPropagation();
onResize(startHeight + moveEvent.clientY - startY);
};
const onUp = (upEvent) => {
upEvent.preventDefault();
upEvent.stopPropagation();
document.removeEventListener("pointermove", onMove, true);
document.removeEventListener("pointerup", onUp, true);
};
document.addEventListener("pointermove", onMove, true);
document.addEventListener("pointerup", onUp, true);
});
return handle;
}
// ── Drag & drop reorder ────────────────────────────────────────────────────────
export function moveItem(items, fromIndex, toIndex) {
if (fromIndex === toIndex || fromIndex < 0 || toIndex < 0) {
return false;
}
const [item] = items.splice(fromIndex, 1);
items.splice(toIndex, 0, item);
return true;
}
export function attachBlockDrag(block, handle, state, item, refresh) {
handle.draggable = true;
handle.title = "Drag to reorder";
handle.classList.add("tr-drag-handle");
handle.addEventListener("dragstart", (event) => {
event.stopPropagation();
state.draggingId = item.id;
block.classList.add("tr-dragging");
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", item.id);
});
handle.addEventListener("dragend", (event) => {
event.stopPropagation();
state.draggingId = "";
block.classList.remove("tr-dragging");
});
block.addEventListener("dragover", (event) => {
if (!state.draggingId || state.draggingId === item.id) {
return;
}
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = "move";
block.classList.add("tr-drop-target");
});
block.addEventListener("dragleave", () => {
block.classList.remove("tr-drop-target");
});
block.addEventListener("drop", (event) => {
if (!state.draggingId || state.draggingId === item.id) {
return;
}
event.preventDefault();
event.stopPropagation();
block.classList.remove("tr-drop-target");
const fromIndex = state.items.findIndex((entry) => entry.id === state.draggingId);
const toIndex = state.items.findIndex((entry) => entry.id === item.id);
state.draggingId = "";
if (moveItem(state.items, fromIndex, toIndex)) {
refresh();
}
});
}
// ── Bulk toggle ────────────────────────────────────────────────────────────────
export function areAllItemsCollapsed(items) {
return Boolean(items?.length) && items.every((item) => item.collapsed);
}
export function makeBulkToggleButton(items, refresh, disabledLabel = "Hide All") {
const button = createButton(areAllItemsCollapsed(items) ? "Show All" : "Hide All", "tr-bulk-toggle");
button.disabled = !items?.length;
if (!items?.length) {
button.textContent = disabledLabel;
}
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const collapsed = !areAllItemsCollapsed(items);
items.forEach((item) => {
item.collapsed = collapsed;
});
refresh();
});
return button;
}
// ── Image upload ───────────────────────────────────────────────────────────────
export async function uploadImage(file) {
const body = new FormData();
body.append("image", file);
body.append("type", "input");
body.append("subfolder", IMAGE_SUBFOLDER);
const response = await api.fetchApi("/upload/image", {
method: "POST",
body,
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload?.error || `${response.status} ${response.statusText}`);
}
const image = {
name: payload.name || file.name,
subfolder: payload.subfolder || IMAGE_SUBFOLDER,
type: payload.type || "input",
cache_key: `${Date.now()}`,
};
image.path = imagePath(image);
return image;
}
// ── Toast notifications (#6 — replace alert()) ────────────────────────────────
let toastContainer = null;
function ensureToastContainer() {
if (toastContainer && document.body.contains(toastContainer)) {
return toastContainer;
}
toastContainer = document.createElement("div");
toastContainer.className = "tr-toast-container";
document.body.appendChild(toastContainer);
return toastContainer;
}
export function showToast(message, type = "error", durationMs = 4000) {
const container = ensureToastContainer();
const toast = document.createElement("div");
toast.className = `tr-toast tr-toast-${type}`;
toast.textContent = message;
container.appendChild(toast);
const dismiss = () => {
toast.style.animation = "tr-toast-out 0.3s ease-in forwards";
toast.addEventListener("animationend", () => toast.remove(), { once: true });
};
toast.addEventListener("click", dismiss);
setTimeout(dismiss, durationMs);
}