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; }; } }, });