Files
chrmc40 8e3dceae99 ComfyUI-PromptChain
A prompt-authoring and image-iteration suite for ComfyUI.
2026-06-17 19:35:25 -07:00

357 lines
11 KiB
JavaScript

// Documents — multi-prompt storage per node.
// Each node can have multiple named prompts saved to node.properties.
//
// Stored in node.properties:
// pcrDocuments: [{ id, name, content, contentHash, lastOpened, lastModified }]
// pcrActiveDocId: ID of the currently loaded document
//
// The dropdown appears in the footer left side. Auto-saves on edit (500ms debounce).
import { app } from "../../../scripts/app.js";
import { setEditorContent } from "./editor.js";
function hashContent(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash) + str.charCodeAt(i);
hash |= 0;
}
return hash;
}
function formatRelativeTime(timestamp) {
if (!timestamp) return null;
const diff = Date.now() - timestamp;
const mins = Math.floor(diff / 60000);
const hours = Math.floor(mins / 60);
const days = Math.floor(hours / 24);
if (mins < 1) return "now";
if (mins < 60) return mins + "m";
if (hours < 24) return hours + "h";
if (days < 365) return days + "d";
return Math.floor(days / 365) + "y";
}
// simple global: pointerdown outside closes any open doc menu.
// capture phase fires before isolation.js stopPropagation on the container.
let activeCloseCallback = null;
document.addEventListener("pointerdown", (e) => {
if (!activeCloseCallback) return;
if (e.target.closest(".pcr-doc-menu, .pcr-doc-dropdown")) return;
activeCloseCallback();
activeCloseCallback = null;
}, true);
export function createDocumentDropdown(node) {
// -- storage helpers --
const getDocs = () => {
if (!node.properties) node.properties = {};
if (!node.properties.pcrDocuments) node.properties.pcrDocuments = [];
return node.properties.pcrDocuments;
};
const saveDocs = (docs) => {
if (!node.properties) node.properties = {};
node.properties.pcrDocuments = docs;
app.graph?.setDirtyCanvas?.(true, true);
};
const getActiveId = () => node.properties?.pcrActiveDocId || null;
const setActiveId = (id) => {
if (!node.properties) node.properties = {};
node.properties.pcrActiveDocId = id;
};
const nextId = () => {
const docs = getDocs();
return docs.length === 0 ? 1 : docs.reduce((max, d) => d.id > max ? d.id : max, 0) + 1;
};
// Resolve a desired name to one not already used by any doc (excluding excludeId).
// Collisions are suffixed " (n)" with the lowest free n ≥ 2; an existing
// " (n)" tail on `desired` is stripped first so "foo (2)" + collision → "foo (3)",
// not "foo (2) (2)".
const uniqueName = (desired, excludeId = null) => {
const used = new Set(getDocs().filter(d => d.id !== excludeId).map(d => d.name));
if (!used.has(desired)) return desired;
const m = desired.match(/^(.*) \((\d+)\)$/);
const base = m ? m[1] : desired;
let n = 2;
while (used.has(`${base} (${n})`)) n++;
return `${base} (${n})`;
};
const getEditorText = () => node._pcrEditor?.state.doc.toString() || "";
const setEditorText = (text) => {
if (!node._pcrEditor) return;
setEditorContent(node._pcrEditor, text);
// sync to hidden prompt widget
const promptWidget = node.widgets?.find(w => w.name === "prompt");
if (promptWidget) promptWidget.value = text;
};
// -- DOM --
const dropdown = document.createElement("div");
dropdown.className = "pcr-doc-dropdown";
const label = document.createElement("span");
label.className = "pcr-doc-label";
label.textContent = "Untitled";
dropdown.appendChild(label);
const arrow = document.createElement("span");
arrow.className = "pcr-doc-arrow";
arrow.textContent = "▾";
dropdown.appendChild(arrow);
const menu = document.createElement("div");
menu.className = "pcr-doc-menu";
// -- state --
let currentDocId = null;
let menuOpen = false;
let autoSaveTimeout = null;
let menuAppended = false;
// -- menu items --
function createMenuItem(doc, isActive, allDocs) {
const item = document.createElement("div");
item.className = "pcr-doc-item" + (isActive ? " pcr-doc-item-active" : "");
const nameSpan = document.createElement("span");
nameSpan.className = "pcr-doc-item-name";
nameSpan.textContent = doc.name;
item.appendChild(nameSpan);
const right = document.createElement("div");
right.className = "pcr-doc-item-right";
const time = formatRelativeTime(doc.lastModified || doc.lastOpened);
if (time) {
const timeSpan = document.createElement("span");
timeSpan.className = "pcr-doc-item-time";
timeSpan.textContent = time;
right.appendChild(timeSpan);
}
if (isActive) {
const editBtn = document.createElement("button");
editBtn.className = "pcr-doc-item-btn";
editBtn.textContent = "✎";
editBtn.title = "Rename";
editBtn.addEventListener("click", (e) => {
e.stopPropagation();
startRename(nameSpan, doc);
});
right.appendChild(editBtn);
} else if (allDocs.length > 1) {
const delBtn = document.createElement("button");
delBtn.className = "pcr-doc-item-btn pcr-doc-item-delete";
delBtn.textContent = "✕";
delBtn.title = "Delete";
delBtn.addEventListener("click", (e) => {
e.stopPropagation();
deleteDoc(doc.id, doc.name);
});
right.appendChild(delBtn);
}
item.appendChild(right);
if (!isActive) {
item.addEventListener("click", () => { switchDoc(doc.id); closeMenu(); });
}
return item;
}
function populateMenu() {
menu.innerHTML = "";
const docs = getDocs();
const list = document.createElement("div");
list.className = "pcr-doc-list";
const active = docs.find(d => d.id === currentDocId);
if (active) list.appendChild(createMenuItem(active, true, docs));
const others = docs.filter(d => d.id !== currentDocId)
.sort((a, b) => (b.lastOpened || 0) - (a.lastOpened || 0));
for (const doc of others) list.appendChild(createMenuItem(doc, false, docs));
menu.appendChild(list);
const newItem = document.createElement("div");
newItem.className = "pcr-doc-item pcr-doc-item-new";
newItem.innerHTML = `<span style="color: #4fc3f7;">+ New</span>`;
newItem.addEventListener("click", (e) => { e.stopPropagation(); createDoc(); });
menu.appendChild(newItem);
}
// -- open/close --
function closeMenu() {
if (!menuOpen) return;
menu.style.display = "none";
menuOpen = false;
if (activeCloseCallback === closeMenu) activeCloseCallback = null;
}
function openMenu() {
if (activeCloseCallback && activeCloseCallback !== closeMenu) activeCloseCallback();
if (!menuAppended) { document.body.appendChild(menu); menuAppended = true; }
populateMenu();
const rect = dropdown.getBoundingClientRect();
menu.style.position = "fixed";
menu.style.left = `${rect.left}px`;
menu.style.top = `${rect.bottom + 4}px`;
menu.style.bottom = "auto";
menu.style.display = "block";
menuOpen = true;
activeCloseCallback = closeMenu;
}
dropdown.addEventListener("click", (e) => {
e.stopPropagation();
menuOpen ? closeMenu() : openMenu();
});
// -- rename --
function startRename(nameSpan, doc) {
const original = doc.name;
const input = document.createElement("input");
input.type = "text";
input.className = "pcr-doc-rename-input";
input.value = doc.name;
nameSpan.textContent = "";
nameSpan.appendChild(input);
requestAnimationFrame(() => { input.focus(); input.select(); });
const finish = () => {
renameDoc(doc.id, input.value.trim() || original);
closeMenu();
};
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); finish(); }
else if (e.key === "Escape") { e.preventDefault(); nameSpan.textContent = original; closeMenu(); }
e.stopPropagation();
});
input.addEventListener("blur", finish);
input.addEventListener("click", (e) => e.stopPropagation());
}
// -- operations --
function saveCurrent() {
if (!currentDocId) return;
const docs = getDocs();
const doc = docs.find(d => d.id === currentDocId);
if (!doc) return;
const content = getEditorText();
const newHash = hashContent(content);
if (doc.contentHash !== newHash) {
doc.content = content;
doc.contentHash = newHash;
doc.lastModified = Date.now();
saveDocs(docs);
}
}
function createDoc() {
saveCurrent();
const content = getEditorText();
const docs = getDocs();
const baseName = docs.find(d => d.id === currentDocId)?.name || "Untitled";
const newDoc = {
id: nextId(), name: uniqueName(baseName), content,
contentHash: hashContent(content),
lastOpened: Date.now(), lastModified: Date.now(),
};
docs.push(newDoc);
saveDocs(docs);
currentDocId = newDoc.id;
setActiveId(newDoc.id);
label.textContent = newDoc.name;
populateMenu();
const activeItem = menu.querySelector(".pcr-doc-item-active");
const nameSpan = activeItem?.querySelector(".pcr-doc-item-name");
if (nameSpan) startRename(nameSpan, newDoc);
}
function switchDoc(docId) {
saveCurrent();
const docs = getDocs();
const doc = docs.find(d => d.id === docId);
if (!doc) return;
doc.lastOpened = Date.now();
saveDocs(docs);
currentDocId = docId;
setActiveId(docId);
label.textContent = doc.name;
setEditorText(doc.content);
node._pcrEditor?.focus();
}
function renameDoc(docId, name) {
const docs = getDocs();
const doc = docs.find(d => d.id === docId);
if (!doc) return;
const resolved = uniqueName(name, docId);
doc.name = resolved;
saveDocs(docs);
if (docId === currentDocId) label.textContent = resolved;
}
function deleteDoc(docId, name) {
if (!confirm(`Delete "${name}"?`)) return;
const docs = getDocs();
if (docs.length <= 1) return;
const idx = docs.findIndex(d => d.id === docId);
if (idx === -1) return;
docs.splice(idx, 1);
saveDocs(docs);
if (docId === currentDocId && docs[0]) switchDoc(docs[0].id);
populateMenu();
}
// -- init --
function init() {
const docs = getDocs();
const content = getEditorText();
let activeId = getActiveId();
if (docs.length === 0) {
const newDoc = {
id: nextId(), name: "Untitled", content,
contentHash: hashContent(content),
lastOpened: Date.now(), lastModified: Date.now(),
};
docs.push(newDoc);
saveDocs(docs);
activeId = newDoc.id;
setActiveId(activeId);
}
let activeDoc = docs.find(d => d.id === activeId) || docs[0];
if (activeDoc) {
currentDocId = activeDoc.id;
setActiveId(activeDoc.id);
label.textContent = activeDoc.name;
if (activeDoc.content !== content) {
activeDoc.content = content;
activeDoc.contentHash = hashContent(content);
saveDocs(docs);
}
}
}
// defer init until editor is ready
requestAnimationFrame(() => requestAnimationFrame(init));
return {
element: dropdown,
saveCurrent,
scheduleAutoSave() {
clearTimeout(autoSaveTimeout);
autoSaveTimeout = setTimeout(saveCurrent, 500);
},
cleanup() {
clearTimeout(autoSaveTimeout);
closeMenu();
if (menuAppended) { menu.remove(); menuAppended = false; }
},
};
}