824 lines
29 KiB
JavaScript
824 lines
29 KiB
JavaScript
import { app } from "../../../scripts/app.js";
|
|
import {
|
|
NODE_NAME,
|
|
STORAGE_WIDGET,
|
|
PROMPT_NODE_NAME,
|
|
PROMPT_LORA_NODE_NAME,
|
|
NODE_MIN_WIDTH,
|
|
NODE_MIN_HEIGHT,
|
|
NODE_CHROME_HEIGHT,
|
|
DOM_HEIGHT_PADDING,
|
|
DEFAULT_TEXT_HEIGHT,
|
|
DEFAULT_PREVIEW_HEIGHT,
|
|
findWidget,
|
|
hideWidget,
|
|
makeId,
|
|
normalizeItems,
|
|
normalizeTemplateList,
|
|
referenceSelectedId,
|
|
referenceListHidden,
|
|
referenceFileName,
|
|
referencePayloadFromState,
|
|
imagePath,
|
|
imageViewUrl,
|
|
clamp,
|
|
previewHeight,
|
|
textHeight,
|
|
stopCanvasEvent,
|
|
createButton,
|
|
createInput,
|
|
makeEditableTitle,
|
|
makeResizeHandle,
|
|
attachBlockDrag,
|
|
makeBulkToggleButton,
|
|
uploadImage,
|
|
copyTextToClipboard,
|
|
showToast,
|
|
} from "./common.js";
|
|
import { fetchReferenceTemplateList, openReferenceTemplateFile, saveReferenceTemplateFile } from "./api.js";
|
|
import { setupPromptNode, syncPromptState } from "./prompt_template.js";
|
|
import { setupPromptLoraNode } from "./prompt_template_lora.js";
|
|
|
|
// ── Height calculation ─────────────────────────────────────────────────────────
|
|
|
|
function calculateEditorHeight(node) {
|
|
const state = node.__templateReferenceState;
|
|
if (!state) {
|
|
return NODE_MIN_HEIGHT;
|
|
}
|
|
|
|
if (state.listHidden) {
|
|
return NODE_MIN_HEIGHT;
|
|
}
|
|
|
|
const blockHeight = state.items.reduce((height, item) => {
|
|
if (item.type === "image") {
|
|
if (item.collapsed) {
|
|
return height + 105;
|
|
}
|
|
return height + 137 + previewHeight(item.image);
|
|
}
|
|
if (item.collapsed) {
|
|
return height + 82;
|
|
}
|
|
return height + 88 + textHeight(item);
|
|
}, 0);
|
|
return Math.max(NODE_MIN_HEIGHT, 76 + blockHeight);
|
|
}
|
|
|
|
function measuredEditorHeight(node) {
|
|
const state = node.__templateReferenceState;
|
|
const renderedHeight = state?.root ? Math.ceil(state.root.scrollHeight) + DOM_HEIGHT_PADDING : 0;
|
|
return Math.max(NODE_MIN_HEIGHT, calculateEditorHeight(node), renderedHeight);
|
|
}
|
|
|
|
function updateNodeSize(node, options = {}) {
|
|
const state = node.__templateReferenceState;
|
|
if (options.defer !== false && state && !state.resizeQueued) {
|
|
state.resizeQueued = true;
|
|
requestAnimationFrame(() => {
|
|
state.resizeQueued = false;
|
|
updateNodeSize(node, { defer: false });
|
|
});
|
|
}
|
|
|
|
const targetHeight = measuredEditorHeight(node) + NODE_CHROME_HEIGHT;
|
|
const targetWidth = Math.max(node.size?.[0] || 0, NODE_MIN_WIDTH);
|
|
const currentHeight = node.size?.[1] || 0;
|
|
const currentWidth = node.size?.[0] || 0;
|
|
|
|
const sizeChanged = Math.abs(currentHeight - targetHeight) > 1 || currentWidth < targetWidth;
|
|
if (sizeChanged) {
|
|
node.setSize?.([targetWidth, targetHeight]);
|
|
}
|
|
|
|
if (sizeChanged) {
|
|
node.onResize?.(node.size);
|
|
app.graph?.setDirtyCanvas?.(true, true);
|
|
}
|
|
}
|
|
|
|
// ── State sync ─────────────────────────────────────────────────────────────────
|
|
|
|
function syncState(node) {
|
|
const state = node.__templateReferenceState;
|
|
const widget = findWidget(node, STORAGE_WIDGET);
|
|
if (!state || !widget) {
|
|
return;
|
|
}
|
|
|
|
ensureReferenceSelection(node);
|
|
const nextValue = JSON.stringify({ version: 1, selected_id: state.selectedId || "", list_hidden: Boolean(state.listHidden), file_name: state.fileName || "", items: state.items });
|
|
if (widget.value === nextValue) {
|
|
return;
|
|
}
|
|
|
|
widget.value = nextValue;
|
|
if (widget.inputEl) {
|
|
widget.inputEl.value = widget.value;
|
|
}
|
|
node.setDirtyCanvas?.(true, true);
|
|
}
|
|
|
|
function refreshNode(node) {
|
|
syncState(node);
|
|
render(node);
|
|
updateNodeSize(node);
|
|
}
|
|
|
|
// ── File selector ──────────────────────────────────────────────────────────────
|
|
|
|
function updateReferenceFileSelector(state) {
|
|
const selector = state?.fileSelector;
|
|
if (!selector) {
|
|
return;
|
|
}
|
|
const current = state.fileName || "";
|
|
selector.innerHTML = "";
|
|
const empty = document.createElement("option");
|
|
empty.value = "";
|
|
empty.textContent = "No file";
|
|
selector.appendChild(empty);
|
|
state.files.forEach((name) => {
|
|
const option = document.createElement("option");
|
|
option.value = name;
|
|
option.textContent = name;
|
|
selector.appendChild(option);
|
|
});
|
|
selector.value = state.files.includes(current) ? current : "";
|
|
}
|
|
|
|
async function loadReferenceTemplateFiles(node, selectedName = "") {
|
|
const state = node.__templateReferenceState;
|
|
if (!state) {
|
|
return;
|
|
}
|
|
try {
|
|
state.files = await fetchReferenceTemplateList();
|
|
if (selectedName) {
|
|
state.fileName = selectedName;
|
|
}
|
|
updateReferenceFileSelector(state);
|
|
} catch (error) {
|
|
console.error(error);
|
|
}
|
|
}
|
|
|
|
function makeReferenceFileControls(node) {
|
|
const state = node.__templateReferenceState;
|
|
const row = document.createElement("div");
|
|
row.className = "tr-file-controls";
|
|
|
|
const nameInput = createInput(state.fileName || "", "json file name");
|
|
nameInput.className = "tr-file-title";
|
|
nameInput.addEventListener("input", () => {
|
|
state.fileName = nameInput.value;
|
|
syncState(node);
|
|
});
|
|
|
|
const selector = document.createElement("select");
|
|
selector.className = "tr-file-selector";
|
|
selector.addEventListener("change", () => {
|
|
state.fileName = selector.value;
|
|
nameInput.value = state.fileName;
|
|
syncState(node);
|
|
});
|
|
state.fileSelector = selector;
|
|
|
|
const openButton = createButton("Open", "tr-open");
|
|
openButton.addEventListener("click", async (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
const name = selector.value;
|
|
if (!name) {
|
|
state.items = [];
|
|
state.selectedId = "";
|
|
state.listHidden = false;
|
|
state.fileName = "";
|
|
state.fileSelector = null;
|
|
refreshNode(node);
|
|
await loadReferenceTemplateFiles(node);
|
|
return;
|
|
}
|
|
|
|
openButton.disabled = true;
|
|
try {
|
|
const payload = await openReferenceTemplateFile(name);
|
|
const data = payload.data || {};
|
|
state.items = normalizeItems(JSON.stringify(data));
|
|
state.selectedId = referenceSelectedId(JSON.stringify(data));
|
|
state.listHidden = referenceListHidden(JSON.stringify(data));
|
|
state.fileName = payload.name || name;
|
|
state.fileSelector = null;
|
|
refreshNode(node);
|
|
await loadReferenceTemplateFiles(node, state.fileName);
|
|
} catch (error) {
|
|
showToast(error?.message || String(error), "error");
|
|
} finally {
|
|
openButton.disabled = false;
|
|
}
|
|
});
|
|
|
|
const saveButton = createButton("Save", "tr-save");
|
|
saveButton.addEventListener("click", async (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
state.fileName = nameInput.value.trim();
|
|
if (!state.fileName) {
|
|
showToast("Enter a Template Reference file name before saving.", "error");
|
|
return;
|
|
}
|
|
|
|
saveButton.disabled = true;
|
|
try {
|
|
syncState(node);
|
|
const payload = await saveReferenceTemplateFile(state.fileName, referencePayloadFromState(state));
|
|
const data = payload.data || {};
|
|
state.items = normalizeItems(JSON.stringify(data));
|
|
state.selectedId = referenceSelectedId(JSON.stringify(data));
|
|
state.listHidden = referenceListHidden(JSON.stringify(data));
|
|
state.fileName = payload.name || state.fileName;
|
|
nameInput.value = state.fileName;
|
|
state.files = normalizeTemplateList(payload);
|
|
updateReferenceFileSelector(state);
|
|
refreshNode(node);
|
|
} catch (error) {
|
|
showToast(error?.message || String(error), "error");
|
|
} finally {
|
|
saveButton.disabled = false;
|
|
}
|
|
});
|
|
|
|
row.append(nameInput, selector, openButton, saveButton);
|
|
updateReferenceFileSelector(state);
|
|
return row;
|
|
}
|
|
|
|
// ── Selection ──────────────────────────────────────────────────────────────────
|
|
|
|
function ensureReferenceSelection(node) {
|
|
const state = node.__templateReferenceState;
|
|
if (!state) {
|
|
return "";
|
|
}
|
|
if (state.selectedId && !state.items.some((item) => item.id === state.selectedId)) {
|
|
state.selectedId = "";
|
|
}
|
|
return state.selectedId;
|
|
}
|
|
|
|
function updateReferenceSelectorOptions(node) {
|
|
const state = node.__templateReferenceState;
|
|
const selector = state?.selector;
|
|
if (!state || !selector) {
|
|
return;
|
|
}
|
|
|
|
const selectedId = ensureReferenceSelection(node);
|
|
selector.innerHTML = "";
|
|
|
|
const noneOption = document.createElement("option");
|
|
noneOption.value = "";
|
|
noneOption.textContent = "none";
|
|
selector.appendChild(noneOption);
|
|
|
|
state.items.forEach((item, index) => {
|
|
const option = document.createElement("option");
|
|
option.value = item.id;
|
|
const fallback = item.type === "image" ? `Image ${index + 1}` : `Text ${index + 1}`;
|
|
option.textContent = item.title?.trim() || fallback;
|
|
selector.appendChild(option);
|
|
});
|
|
selector.value = selectedId;
|
|
}
|
|
|
|
function makeReferenceSelector(node) {
|
|
const row = document.createElement("div");
|
|
row.className = "tr-selector-row";
|
|
|
|
const label = document.createElement("span");
|
|
label.className = "tr-selector-label";
|
|
label.textContent = "Output";
|
|
|
|
const selector = document.createElement("select");
|
|
selector.className = "tr-selector";
|
|
selector.addEventListener("change", () => {
|
|
node.__templateReferenceState.selectedId = selector.value;
|
|
syncState(node);
|
|
});
|
|
|
|
row.append(label, selector);
|
|
node.__templateReferenceState.selector = selector;
|
|
updateReferenceSelectorOptions(node);
|
|
return row;
|
|
}
|
|
|
|
// ── Controls ───────────────────────────────────────────────────────────────────
|
|
|
|
function makeControls(node) {
|
|
const controls = document.createElement("div");
|
|
controls.className = "tr-controls";
|
|
|
|
const state = node.__templateReferenceState;
|
|
const listToggle = createButton(state.listHidden ? "List On" : "List Off", "tr-list-toggle");
|
|
listToggle.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
state.listHidden = !state.listHidden;
|
|
refreshNode(node);
|
|
});
|
|
|
|
const toggleAll = makeBulkToggleButton(node.__templateReferenceState.items, () => refreshNode(node));
|
|
toggleAll.disabled = toggleAll.disabled || state.listHidden;
|
|
const addText = createButton("Add Textbox", "tr-add");
|
|
const addImage = createButton("Add LoadImage", "tr-add");
|
|
|
|
addText.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
node.__templateReferenceState.items.push({
|
|
id: makeId(),
|
|
type: "text",
|
|
title: "Title",
|
|
text: "",
|
|
text_height: DEFAULT_TEXT_HEIGHT,
|
|
collapsed: false,
|
|
});
|
|
refreshNode(node);
|
|
});
|
|
|
|
addImage.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
node.__templateReferenceState.items.push({
|
|
id: makeId(),
|
|
type: "image",
|
|
title: "Title",
|
|
collapsed: false,
|
|
image: { name: "", subfolder: "", type: "input", path: "", preview_height: DEFAULT_PREVIEW_HEIGHT },
|
|
});
|
|
refreshNode(node);
|
|
});
|
|
|
|
controls.append(listToggle, toggleAll, addText, addImage);
|
|
return controls;
|
|
}
|
|
|
|
// ── Text block ─────────────────────────────────────────────────────────────────
|
|
|
|
function makeTextBlock(node, item, index) {
|
|
const block = document.createElement("section");
|
|
block.className = "tr-block tr-text-block";
|
|
|
|
const header = document.createElement("div");
|
|
header.className = "tr-block-header";
|
|
|
|
const label = document.createElement("span");
|
|
label.className = "tr-block-type";
|
|
label.textContent = "Text";
|
|
attachBlockDrag(block, label, node.__templateReferenceState, item, () => refreshNode(node));
|
|
|
|
const toggleButton = createButton(item.collapsed ? "Show" : "Hide", "tr-toggle");
|
|
const copyButton = createButton("Copy", "tr-copy");
|
|
const fitButton = createButton("Fit", "tr-fit-text");
|
|
const deleteButton = createButton("Delete", "tr-delete");
|
|
const headerActions = document.createElement("div");
|
|
headerActions.className = "tr-header-actions";
|
|
|
|
toggleButton.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
item.collapsed = !item.collapsed;
|
|
refreshNode(node);
|
|
});
|
|
|
|
deleteButton.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
node.__templateReferenceState.items.splice(index, 1);
|
|
refreshNode(node);
|
|
});
|
|
|
|
copyButton.addEventListener("click", async (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
const originalLabel = copyButton.textContent;
|
|
copyButton.disabled = true;
|
|
try {
|
|
await copyTextToClipboard(item.text || "");
|
|
copyButton.textContent = "Copied";
|
|
setTimeout(() => {
|
|
copyButton.textContent = originalLabel;
|
|
copyButton.disabled = false;
|
|
}, 900);
|
|
} catch (error) {
|
|
copyButton.textContent = "Failed";
|
|
setTimeout(() => {
|
|
copyButton.textContent = originalLabel;
|
|
copyButton.disabled = false;
|
|
}, 1200);
|
|
}
|
|
});
|
|
|
|
headerActions.append(toggleButton, copyButton, fitButton, deleteButton);
|
|
header.append(label, headerActions);
|
|
|
|
const title = makeEditableTitle(item.title, (value) => {
|
|
item.title = value;
|
|
syncState(node);
|
|
updateReferenceSelectorOptions(node);
|
|
});
|
|
|
|
const textarea = document.createElement("textarea");
|
|
textarea.className = "tr-text";
|
|
textarea.value = item.text || "";
|
|
textarea.placeholder = "text";
|
|
textarea.spellcheck = false;
|
|
textarea.style.height = `${textHeight(item)}px`;
|
|
textarea.style.minHeight = `${textHeight(item)}px`;
|
|
|
|
const applyTextHeight = (height) => {
|
|
item.text_height = clamp(Math.round(height), DEFAULT_TEXT_HEIGHT - 24, 420);
|
|
textarea.style.height = `${item.text_height}px`;
|
|
textarea.style.minHeight = `${item.text_height}px`;
|
|
syncState(node);
|
|
updateNodeSize(node);
|
|
return item.text_height;
|
|
};
|
|
|
|
const fitTextHeight = () => {
|
|
textarea.style.height = "auto";
|
|
applyTextHeight(textarea.scrollHeight + 2);
|
|
};
|
|
|
|
textarea.addEventListener("input", () => {
|
|
item.text = textarea.value;
|
|
syncState(node);
|
|
});
|
|
|
|
fitButton.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
fitTextHeight();
|
|
});
|
|
|
|
const resizeHandle = makeResizeHandle("Drag to resize text area", (height) => {
|
|
if (height === undefined) {
|
|
return textHeight(item);
|
|
}
|
|
return applyTextHeight(height);
|
|
});
|
|
|
|
block.append(header, title);
|
|
if (!item.collapsed) {
|
|
block.append(textarea, resizeHandle);
|
|
}
|
|
return block;
|
|
}
|
|
|
|
// ── Image block ────────────────────────────────────────────────────────────────
|
|
|
|
function makeImageBlock(node, item, index) {
|
|
const block = document.createElement("section");
|
|
block.className = "tr-block tr-image-block";
|
|
|
|
const header = document.createElement("div");
|
|
header.className = "tr-block-header";
|
|
|
|
const label = document.createElement("span");
|
|
label.className = "tr-block-type";
|
|
label.textContent = "Image";
|
|
attachBlockDrag(block, label, node.__templateReferenceState, item, () => refreshNode(node));
|
|
|
|
const toggleButton = createButton(item.collapsed ? "Show" : "Hide", "tr-toggle");
|
|
const deleteButton = createButton("Delete", "tr-delete");
|
|
const headerActions = document.createElement("div");
|
|
headerActions.className = "tr-header-actions";
|
|
|
|
toggleButton.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
item.collapsed = !item.collapsed;
|
|
refreshNode(node);
|
|
});
|
|
|
|
deleteButton.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
node.__templateReferenceState.items.splice(index, 1);
|
|
refreshNode(node);
|
|
});
|
|
|
|
headerActions.append(toggleButton, deleteButton);
|
|
header.append(label, headerActions);
|
|
|
|
const title = makeEditableTitle(item.title, (value) => {
|
|
item.title = value;
|
|
syncState(node);
|
|
updateReferenceSelectorOptions(node);
|
|
});
|
|
|
|
const imageName = createButton(imagePath(item.image) || "image", "tr-image-name");
|
|
imageName.disabled = true;
|
|
imageName.style.opacity = "1";
|
|
|
|
const uploadButton = createButton("Upload", "tr-upload");
|
|
const fitButton = createButton("Fit", "tr-fit-height");
|
|
fitButton.disabled = true;
|
|
const fileRow = document.createElement("div");
|
|
fileRow.className = "tr-file-row";
|
|
fileRow.append(imageName, uploadButton, fitButton);
|
|
|
|
const fileInput = document.createElement("input");
|
|
fileInput.type = "file";
|
|
fileInput.accept = "image/*";
|
|
fileInput.style.display = "none";
|
|
|
|
const preview = document.createElement("div");
|
|
preview.className = "tr-preview";
|
|
let currentImage = null;
|
|
|
|
const applyPreviewHeight = () => {
|
|
const height = previewHeight(item.image);
|
|
preview.style.height = `${height}px`;
|
|
preview.style.minHeight = `${height}px`;
|
|
};
|
|
|
|
const setPreviewHeight = (height) => {
|
|
item.image.preview_height = clamp(Math.round(height), 96, 380);
|
|
applyPreviewHeight();
|
|
syncState(node);
|
|
updateNodeSize(node);
|
|
return item.image.preview_height;
|
|
};
|
|
|
|
const fitPreviewHeight = () => {
|
|
if (!currentImage?.naturalWidth || !currentImage?.naturalHeight) {
|
|
return;
|
|
}
|
|
|
|
const width = Math.max(preview.clientWidth || 260, 120);
|
|
setPreviewHeight(
|
|
Math.round((width * currentImage.naturalHeight) / currentImage.naturalWidth),
|
|
);
|
|
};
|
|
|
|
const drawPreview = () => {
|
|
imageName.textContent = imagePath(item.image) || "image";
|
|
preview.innerHTML = "";
|
|
currentImage = null;
|
|
applyPreviewHeight();
|
|
|
|
const url = imageViewUrl(item.image);
|
|
if (!url) {
|
|
const empty = document.createElement("div");
|
|
empty.className = "tr-preview-empty";
|
|
empty.textContent = "Upload image";
|
|
preview.appendChild(empty);
|
|
fitButton.disabled = true;
|
|
return;
|
|
}
|
|
|
|
const img = document.createElement("img");
|
|
img.src = url;
|
|
img.alt = imagePath(item.image);
|
|
img.addEventListener("load", () => {
|
|
currentImage = img;
|
|
fitButton.disabled = false;
|
|
updateNodeSize(node);
|
|
});
|
|
preview.appendChild(img);
|
|
};
|
|
|
|
const handleFiles = async (files) => {
|
|
const file = files?.[0];
|
|
if (!file) {
|
|
return;
|
|
}
|
|
|
|
uploadButton.disabled = true;
|
|
uploadButton.textContent = "Uploading...";
|
|
try {
|
|
item.image = await uploadImage(file);
|
|
item.image.preview_height = DEFAULT_PREVIEW_HEIGHT;
|
|
syncState(node);
|
|
drawPreview();
|
|
} catch (error) {
|
|
showToast(error?.message || String(error), "error");
|
|
} finally {
|
|
uploadButton.disabled = false;
|
|
uploadButton.textContent = "Upload";
|
|
fileInput.value = "";
|
|
}
|
|
};
|
|
|
|
uploadButton.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
fileInput.click();
|
|
});
|
|
|
|
fitButton.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
fitPreviewHeight();
|
|
});
|
|
|
|
fileInput.addEventListener("change", () => handleFiles(Array.from(fileInput.files || [])));
|
|
|
|
block.addEventListener("dragover", (event) => {
|
|
if ([...(event.dataTransfer?.items || [])].some((entry) => entry.kind === "file")) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
block.classList.add("tr-dragover");
|
|
}
|
|
});
|
|
block.addEventListener("dragleave", () => block.classList.remove("tr-dragover"));
|
|
block.addEventListener("drop", (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
block.classList.remove("tr-dragover");
|
|
handleFiles(Array.from(event.dataTransfer?.files || []));
|
|
});
|
|
|
|
drawPreview();
|
|
const resizeHandle = makeResizeHandle("Drag to resize image preview", (height) => {
|
|
if (height === undefined) {
|
|
return previewHeight(item.image);
|
|
}
|
|
return setPreviewHeight(height);
|
|
});
|
|
|
|
block.append(header, title, fileRow);
|
|
if (!item.collapsed) {
|
|
block.append(preview, resizeHandle);
|
|
}
|
|
block.append(fileInput);
|
|
return block;
|
|
}
|
|
|
|
// ── Render ─────────────────────────────────────────────────────────────────────
|
|
|
|
function render(node) {
|
|
const state = node.__templateReferenceState;
|
|
if (!state?.root) {
|
|
return;
|
|
}
|
|
|
|
state.root.innerHTML = "";
|
|
state.root.appendChild(makeReferenceFileControls(node));
|
|
state.root.appendChild(makeReferenceSelector(node));
|
|
state.root.appendChild(makeControls(node));
|
|
|
|
if (state.listHidden) {
|
|
return;
|
|
}
|
|
|
|
const list = document.createElement("div");
|
|
list.className = "tr-list";
|
|
state.items.forEach((item, index) => {
|
|
list.appendChild(item.type === "image" ? makeImageBlock(node, item, index) : makeTextBlock(node, item, index));
|
|
});
|
|
state.root.appendChild(list);
|
|
}
|
|
|
|
// ── Editor creation ────────────────────────────────────────────────────────────
|
|
|
|
function createEditor(node) {
|
|
const container = document.createElement("div");
|
|
container.className = "template-reference-editor";
|
|
|
|
const root = document.createElement("div");
|
|
root.className = "tr-root";
|
|
container.appendChild(root);
|
|
|
|
for (const eventName of ["mousedown", "pointerdown", "dblclick", "wheel"]) {
|
|
container.addEventListener(eventName, stopCanvasEvent);
|
|
}
|
|
|
|
node.__templateReferenceState = {
|
|
root,
|
|
items: normalizeItems(findWidget(node, STORAGE_WIDGET)?.value),
|
|
selectedId: referenceSelectedId(findWidget(node, STORAGE_WIDGET)?.value),
|
|
listHidden: referenceListHidden(findWidget(node, STORAGE_WIDGET)?.value),
|
|
fileName: referenceFileName(findWidget(node, STORAGE_WIDGET)?.value),
|
|
files: [],
|
|
fileSelector: null,
|
|
selector: null,
|
|
draggingId: "",
|
|
};
|
|
|
|
return container;
|
|
}
|
|
|
|
function setupNode(node) {
|
|
node.serialize_widgets = true;
|
|
|
|
const storageWidget = findWidget(node, STORAGE_WIDGET);
|
|
hideWidget(storageWidget);
|
|
|
|
if (!node.__templateReferenceState) {
|
|
const editor = createEditor(node);
|
|
node.addDOMWidget("template_reference_editor", "div", editor, {
|
|
serialize: false,
|
|
hideOnZoom: false,
|
|
getMinHeight: () => measuredEditorHeight(node),
|
|
onDraw: () => {
|
|
editor.style.pointerEvents = "auto";
|
|
},
|
|
});
|
|
} else {
|
|
node.__templateReferenceState.items = normalizeItems(storageWidget?.value);
|
|
node.__templateReferenceState.selectedId = referenceSelectedId(storageWidget?.value);
|
|
node.__templateReferenceState.listHidden = referenceListHidden(storageWidget?.value);
|
|
node.__templateReferenceState.fileName = referenceFileName(storageWidget?.value) || node.__templateReferenceState.fileName || "";
|
|
}
|
|
|
|
node.syncTemplateReference = () => syncState(node);
|
|
render(node);
|
|
updateNodeSize(node);
|
|
loadReferenceTemplateFiles(node, node.__templateReferenceState.fileName);
|
|
}
|
|
|
|
// ── LGraph serialization patch (#9 — documented) ──────────────────────────────
|
|
// This patches LGraph.prototype.onBeforeSerialize to ensure widget state is
|
|
// synced before the graph is serialized. The original method is preserved and
|
|
// called via apply() to maintain compatibility with other extensions that may
|
|
// also patch this method.
|
|
const onBeforeSerialize = LGraph.prototype.onBeforeSerialize;
|
|
LGraph.prototype.onBeforeSerialize = function () {
|
|
onBeforeSerialize?.apply(this, arguments);
|
|
for (const node of app.graph?.findNodesByType?.(NODE_NAME) || []) {
|
|
node.syncTemplateReference?.();
|
|
}
|
|
for (const node of app.graph?.findNodesByType?.(PROMPT_NODE_NAME) || []) {
|
|
node.syncPromptTemplate?.();
|
|
}
|
|
for (const node of app.graph?.findNodesByType?.(PROMPT_LORA_NODE_NAME) || []) {
|
|
node.syncPromptTemplateLoRA?.();
|
|
}
|
|
};
|
|
|
|
// ── Extension registration ─────────────────────────────────────────────────────
|
|
|
|
app.registerExtension({
|
|
name: "Comfy.TemplateReference",
|
|
init() {
|
|
// Load external CSS (#10)
|
|
const link = document.createElement("link");
|
|
link.rel = "stylesheet";
|
|
link.href = new URL("../css/styles.css", import.meta.url).href;
|
|
document.head.appendChild(link);
|
|
},
|
|
async beforeRegisterNodeDef(nodeType, nodeData) {
|
|
if (nodeData.name === NODE_NAME) {
|
|
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
|
nodeType.prototype.onNodeCreated = function () {
|
|
const result = onNodeCreated?.apply(this, arguments);
|
|
setupNode(this);
|
|
return result;
|
|
};
|
|
|
|
const onConfigure = nodeType.prototype.onConfigure;
|
|
nodeType.prototype.onConfigure = function (info) {
|
|
const result = onConfigure?.apply(this, arguments);
|
|
setupNode(this);
|
|
return result;
|
|
};
|
|
}
|
|
|
|
if (nodeData.name === PROMPT_NODE_NAME) {
|
|
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
|
nodeType.prototype.onNodeCreated = function () {
|
|
const result = onNodeCreated?.apply(this, arguments);
|
|
setupPromptNode(this);
|
|
return result;
|
|
};
|
|
|
|
const onConfigure = nodeType.prototype.onConfigure;
|
|
nodeType.prototype.onConfigure = function (info) {
|
|
const result = onConfigure?.apply(this, arguments);
|
|
setupPromptNode(this);
|
|
return result;
|
|
};
|
|
}
|
|
|
|
if (nodeData.name === PROMPT_LORA_NODE_NAME) {
|
|
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
|
nodeType.prototype.onNodeCreated = function () {
|
|
const result = onNodeCreated?.apply(this, arguments);
|
|
setupPromptLoraNode(this);
|
|
return result;
|
|
};
|
|
|
|
const onConfigure = nodeType.prototype.onConfigure;
|
|
nodeType.prototype.onConfigure = function (info) {
|
|
const result = onConfigure?.apply(this, arguments);
|
|
setupPromptLoraNode(this);
|
|
return result;
|
|
};
|
|
}
|
|
},
|
|
});
|