Files

2446 lines
90 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* Clean Edit Dialog Implementation for ComfyUI-Prompt-Companion
*
* This file provides a production-ready dialog for editing subprompts
* based on the requirements specification. No debugging code included.
*/
import { app } from "../../../scripts/app.js";
import { ComfyDialog, $el } from "../../../scripts/ui.js";
import { api } from "../../../scripts/api.js";
/**
* Main edit dialog for managing subprompts and folders
* Uses the proper ComfyUI dialog approach like ComfyUI-Manager
*/
class PromptEditDialog {
constructor() {
// Use ComfyDialog instance like ComfyUI-Manager does
this.dialog = new ComfyDialog();
// Initialize state
this.currentSubprompt = null;
this.isEditing = false;
this.unsavedChanges = false;
this.treeView = null;
this.editPanel = null;
this.pendingPreviewUpdate = null;
// Setup dialog styling like ComfyUI-Manager - optimized for more compact size
this.dialog.element.style.zIndex = "1100";
this.dialog.element.style.width = "80%";
this.dialog.element.style.maxWidth = "1000px";
this.dialog.element.style.height = "80%";
this.dialog.element.style.maxHeight = "800px";
this.dialog.element.style.minWidth = "720px";
this.dialog.element.style.minHeight = "600px";
this.dialog.element.style.padding = "0";
this.dialog.element.style.backgroundColor = "#2d2d2d";
this.dialog.element.style.border = "1px solid #404040";
this.dialog.element.style.borderRadius = "8px";
this.dialog.element.style.boxSizing = "border-box";
this.dialog.element.style.overflow = "hidden";
this.setupEventHandlers();
this.setupResizeHandling();
}
// Get element for compatibility
get element() {
return this.dialog.element;
}
// Remove unused createDialogStructure method since we use createDialogContent
/**
* Create the edit form for the right panel
*/
createEditForm() {
return $el("div", {
className: "prompt-companion-form-container"
}, [
// Positive Prompt
$el("div", {
className: "prompt-companion-form-group"
}, [
$el("label", { textContent: "Positive Prompt:" }),
$el("textarea", {
id: "subprompt-positive",
className: "prompt-companion-input prompt-companion-textarea",
placeholder: "Enter positive prompt text..."
})
]),
// Negative Prompt
$el("div", {
className: "prompt-companion-form-group"
}, [
$el("label", { textContent: "Negative Prompt:" }),
$el("textarea", {
id: "subprompt-negative",
className: "prompt-companion-input prompt-companion-textarea",
placeholder: "Enter negative prompt text..."
})
]),
// Trigger Words
$el("div", {
className: "prompt-companion-form-group"
}, [
$el("label", { textContent: "Trigger Words:" }),
$el("input", {
type: "text",
id: "subprompt-triggers",
className: "prompt-companion-input",
placeholder: "word1, word2, word3"
})
]),
// Nested Subprompts with Drag-and-Drop Reordering
$el("div", {
className: "prompt-companion-form-group"
}, [
$el("label", {
textContent: "Nested Subprompts (drag to reorder):",
style: {
display: "block",
marginBottom: "6px",
fontWeight: "bold",
color: "#ffffff"
}
}),
$el("div", {
className: "prompt-companion-form-group-controls"
}, [
$el("div", {
id: "subprompt-nested-list",
className: "prompt-companion-nested-list-container",
style: {
minHeight: "100px",
maxHeight: "150px",
border: "1px solid #404040",
borderRadius: "4px",
padding: "4px",
backgroundColor: "#1a1a1a",
overflow: "auto"
}
}),
$el("div", {
className: "prompt-companion-nested-controls"
}, [
$el("button", {
type: "button",
className: "prompt-companion-btn prompt-companion-btn-sm",
textContent: "Add Subprompt",
style: {
padding: "6px 12px",
backgroundColor: "#007acc",
border: "none",
borderRadius: "4px",
color: "#ffffff",
cursor: "pointer",
fontSize: "12px",
marginTop: "8px"
},
onclick: () => this.showAddSubpromptDialog()
})
])
])
]),
// Save/Revert buttons in edit panel
$el("div", {
className: "prompt-companion-edit-panel-buttons",
style: {
display: "flex",
gap: "10px",
padding: "15px 0",
borderTop: "1px solid #404040",
borderBottom: "1px solid #404040",
margin: "15px 0"
}
}, [
$el("button", {
className: "prompt-companion-btn prompt-companion-btn-primary",
textContent: "Save",
style: {
padding: "8px 16px",
backgroundColor: "#28a745",
border: "none",
borderRadius: "4px",
color: "#ffffff",
cursor: "pointer",
fontSize: "14px"
},
onclick: async () => { await this.saveSubprompt(); }
}),
$el("button", {
className: "prompt-companion-btn prompt-companion-btn-secondary",
textContent: "Revert",
style: {
padding: "8px 16px",
backgroundColor: "#6c757d",
border: "none",
borderRadius: "4px",
color: "#ffffff",
cursor: "pointer",
fontSize: "14px"
},
onclick: () => this.revertChanges()
})
]),
// Validation Messages
$el("div", {
id: "validation-messages",
className: "prompt-companion-validation-messages",
style: {
minHeight: "20px"
}
}),
// Preview Section
$el("div", {
className: "prompt-companion-form-group"
}, [
$el("label", {
textContent: "Preview:",
style: {
display: "block",
marginBottom: "6px",
fontWeight: "bold",
color: "#ffffff"
}
}),
$el("div", {
id: "subprompt-preview",
className: "prompt-companion-preview",
style: {
padding: "12px",
backgroundColor: "#1a1a1a",
border: "1px solid #404040",
borderRadius: "4px",
minHeight: "60px",
fontSize: "14px",
whiteSpace: "pre-wrap"
}
})
])
]);
}
/**
* Setup event handlers
*/
setupEventHandlers() {
// Keyboard shortcuts
this.element.addEventListener("keydown", (e) => {
if (e.ctrlKey && e.key === "s") {
e.preventDefault();
this.saveSubprompt();
} else if (e.key === "Escape") {
e.preventDefault();
this.hide();
}
});
// Window resize handling
window.addEventListener("resize", () => {
this.handleResize();
});
// Tree view will be initialized after dialog content is created in show()
}
/**
* Setup form event handlers (called after form is created)
*/
setupFormEventHandlers() {
const form = this.element.querySelector("#prompt-companion-edit-form");
if (form) {
form.addEventListener("input", () => {
this.markUnsavedChanges(true);
this.debounceValidation();
this.debouncePreviewUpdate();
});
form.addEventListener("change", () => {
this.markUnsavedChanges(true);
this.updatePreview().catch(console.error);
});
}
// Add direct event handlers to specific fields for more reliable preview updates
const positiveField = this.element.querySelector("#subprompt-positive");
const negativeField = this.element.querySelector("#subprompt-negative");
const triggersField = this.element.querySelector("#subprompt-triggers");
if (positiveField) {
positiveField.addEventListener("input", () => {
this.markUnsavedChanges(true);
this.debouncePreviewUpdate();
});
}
if (negativeField) {
negativeField.addEventListener("input", () => {
this.markUnsavedChanges(true);
this.debouncePreviewUpdate();
});
}
if (triggersField) {
triggersField.addEventListener("input", () => {
this.markUnsavedChanges(true);
this.debouncePreviewUpdate();
});
}
}
/**
* Initialize the tree view component
*/
initializeTreeView() {
const container = this.element.querySelector("#prompt-companion-tree-container");
if (container && window.PromptCompanion?.initializeTreeView) {
this.treeView = window.PromptCompanion.initializeTreeView("prompt-companion-tree-container");
if (this.treeView) {
this.treeView.onSelectionChange = (item) => this.handleTreeSelectionChange(item);
// Add methods for the tree view to check unsaved changes
this.treeView.onSelectionChange.checkUnsavedChanges = () => {
return this.unsavedChanges;
};
this.treeView.onSelectionChange.handleUnsavedChanges = async () => {
if (!this.unsavedChanges) return true;
const result = await this.showUnsavedChangesDialog();
if (result === 'cancel') {
return false; // Don't proceed with selection change
} else if (result === 'save') {
// Save current changes before proceeding
const saveSuccess = await this.saveSubprompt();
return saveSuccess; // Only proceed if save was successful
}
// If result === 'discard', just continue
return true;
};
}
}
}
/**
* Handle tree view selection changes with unsaved changes check
*/
async handleTreeSelectionChange(item) {
// Note: This method is no longer directly called due to the new approach
// where unsaved changes are handled in the tree view click handlers
if (item === null) {
// Deselection - hide edit panel and show placeholder
this.clearForm();
} else {
// Selection of a subprompt - load it for editing
this.loadSubpromptForEditing(item);
}
}
/**
* Show the dialog using proper ComfyUI approach
*/
show(subprompt = null) {
// Clear existing content
this.element.innerHTML = '';
// Create and add dialog content to the dialog element
const content = this.createDialogContent();
this.element.appendChild(content);
// Show using ComfyDialog.show() like ComfyUI-Manager
this.dialog.show();
if (subprompt) {
this.loadSubpromptForEditing(subprompt);
} else {
this.clearForm();
}
// Focus first input
setTimeout(() => {
const firstInput = this.element.querySelector("input, textarea");
if (firstInput) firstInput.focus();
}, 100);
// Initialize tree view AFTER dialog content is created
this.initializeTreeView();
// Setup form event handlers AFTER form is created
this.setupFormEventHandlers();
this.markUnsavedChanges(false);
}
/**
* Create the dialog content element
*/
createDialogContent() {
return $el("div", {
className: "prompt-companion-dialog-content",
style: {
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
backgroundColor: "#2d2d2d",
color: "#ffffff",
overflow: "hidden"
}
}, [
// Header
$el("div", {
className: "prompt-companion-dialog-header",
style: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "15px 20px",
borderBottom: "1px solid #404040",
flexShrink: "0"
}
}, [
$el("h3", {
textContent: "Edit Subprompts",
style: { margin: "0", fontSize: "18px" }
}),
$el("button", {
className: "prompt-companion-close-btn",
textContent: "×",
style: {
background: "none",
border: "none",
color: "#ffffff",
fontSize: "24px",
cursor: "pointer",
padding: "0",
width: "30px",
height: "30px"
},
onclick: () => this.hide()
})
]),
// Main body with split panels
$el("div", {
className: "prompt-companion-dialog-body",
style: {
flex: "1",
overflow: "visible",
display: "flex",
minHeight: "0"
}
}, [
// Left tree panel
$el("div", {
className: "prompt-companion-tree-panel",
style: {
width: "320px",
minWidth: "320px",
display: "flex",
flexDirection: "column",
borderRight: "1px solid #404040"
}
}, [
$el("div", {
className: "prompt-companion-panel-header",
style: {
padding: "15px",
borderBottom: "1px solid #404040",
flexShrink: "0"
}
}, [
$el("h4", {
textContent: "Subprompts",
style: { margin: "0 0 12px 0", fontSize: "16px" }
}),
// Compact button grid - all buttons in optimal layout
$el("div", {
className: "prompt-companion-tree-actions",
style: {
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "6px",
marginBottom: "6px"
}
}, [
$el("button", {
className: "prompt-companion-btn prompt-companion-btn-sm",
textContent: "New Folder",
style: {
padding: "6px 8px",
backgroundColor: "#404040",
border: "none",
borderRadius: "4px",
color: "#ffffff",
cursor: "pointer",
fontSize: "11px",
minHeight: "28px",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis"
},
onclick: () => this.createFolder()
}),
$el("button", {
className: "prompt-companion-btn prompt-companion-btn-sm",
textContent: "New Sub",
title: "New Subprompt",
style: {
padding: "6px 8px",
backgroundColor: "#404040",
border: "none",
borderRadius: "4px",
color: "#ffffff",
cursor: "pointer",
fontSize: "11px",
minHeight: "28px",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis"
},
onclick: () => this.createSubprompt()
}),
$el("button", {
className: "prompt-companion-btn prompt-companion-btn-sm",
textContent: "Expand",
title: "Expand All Folders",
style: {
padding: "6px 8px",
backgroundColor: "#2d3748",
border: "1px solid #4a5568",
borderRadius: "3px",
color: "#ffffff",
cursor: "pointer",
fontSize: "11px",
minHeight: "28px",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis"
},
onclick: () => {
if (this.treeView && this.treeView.expandAll) {
this.treeView.expandAll();
}
}
}),
$el("button", {
className: "prompt-companion-btn prompt-companion-btn-sm",
textContent: "Collapse",
title: "Collapse All Folders",
style: {
padding: "6px 8px",
backgroundColor: "#2d3748",
border: "1px solid #4a5568",
borderRadius: "3px",
color: "#ffffff",
cursor: "pointer",
fontSize: "11px",
minHeight: "28px",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis"
},
onclick: () => {
if (this.treeView && this.treeView.collapseAll) {
this.treeView.collapseAll();
}
}
})
])
]),
$el("div", {
className: "prompt-companion-search-container",
style: {
padding: "8px 15px 12px 15px",
flexShrink: "0"
}
}, [
$el("input", {
type: "text",
className: "prompt-companion-input prompt-companion-search",
placeholder: "Search subprompts...",
style: {
width: "100%",
padding: "10px 14px",
backgroundColor: "#1a1a1a",
border: "1px solid #404040",
borderRadius: "4px",
color: "#ffffff",
fontSize: "14px",
boxSizing: "border-box"
},
oninput: (e) => this.searchSubprompts(e.target.value)
})
]),
$el("div", {
id: "prompt-companion-tree-container",
className: "prompt-companion-tree-container",
style: {
flex: "1",
overflow: "auto",
minHeight: "0"
}
})
]),
// Right edit panel
$el("div", {
className: "prompt-companion-edit-panel",
id: "prompt-companion-edit-panel",
style: {
display: "none",
flex: "1",
flexDirection: "column",
overflow: "auto",
minHeight: "0"
}
}, [
$el("div", {
className: "prompt-companion-panel-header",
style: {
padding: "18px 20px",
borderBottom: "1px solid #404040",
flexShrink: "0"
}
}, [
$el("h4", {
textContent: "Edit Subprompt",
style: { margin: "0", fontSize: "16px" }
})
]),
$el("div", {
className: "prompt-companion-edit-form",
id: "prompt-companion-edit-form",
style: {
flex: "1",
overflow: "auto",
padding: "20px",
minHeight: "0"
}
}, [
this.createEditForm()
])
]),
// Placeholder panel when no subprompt is selected
$el("div", {
className: "prompt-companion-placeholder-panel",
id: "prompt-companion-placeholder-panel",
style: {
flex: "1",
display: "flex",
alignItems: "center",
justifyContent: "center"
}
}, [
$el("div", {
className: "prompt-companion-placeholder-content",
style: {
textAlign: "center",
padding: "40px 20px"
}
}, [
$el("h4", {
textContent: "No Subprompt Selected",
style: { margin: "0 0 16px 0", fontSize: "18px", color: "#cccccc" }
}),
$el("p", {
textContent: "Select a subprompt from the tree to edit it, or create a new one.",
style: { margin: "0", fontSize: "14px", color: "#999999", lineHeight: "1.4" }
})
])
])
]),
// Footer with action buttons
$el("div", {
className: "prompt-companion-dialog-footer",
style: {
display: "flex",
justifyContent: "flex-end",
gap: "10px",
padding: "15px 20px",
borderTop: "1px solid #404040",
flexShrink: "0",
backgroundColor: "#2d2d2d"
}
}, [
$el("button", {
className: "prompt-companion-btn prompt-companion-btn-primary",
textContent: "OK",
style: {
padding: "10px 24px",
backgroundColor: "#007acc",
border: "none",
borderRadius: "4px",
color: "#ffffff",
cursor: "pointer",
fontSize: "14px",
minHeight: "40px"
},
onclick: () => this.handleOKButton()
}),
$el("button", {
className: "prompt-companion-btn",
textContent: "Cancel",
style: {
padding: "10px 24px",
backgroundColor: "#6c757d",
border: "none",
borderRadius: "4px",
color: "#ffffff",
cursor: "pointer",
fontSize: "14px",
minHeight: "40px"
},
onclick: () => this.hide()
})
])
]);
}
/**
* Hide the dialog with unsaved changes check
*/
async hide() {
if (this.unsavedChanges) {
const shouldClose = await this.showConfirmDialog("You have unsaved changes. Are you sure you want to close?");
if (!shouldClose) {
return false;
}
}
// Clean up the mutation observer
if (this.positionObserver) {
this.positionObserver.disconnect();
this.positionObserver = null;
}
// Clear the tree view instance so a fresh one is created on next open
if (window.PromptCompanion?.clearTreeView) {
window.PromptCompanion.clearTreeView();
}
this.treeView = null;
this.dialog.close();
return true;
}
/**
* Handle OK button - save if edit panel is visible, otherwise just close
*/
async handleOKButton() {
const editPanel = this.element.querySelector("#prompt-companion-edit-panel");
const isEditPanelVisible = editPanel && editPanel.style.display !== "none";
if (isEditPanelVisible && this.currentSubprompt) {
// Save the current subprompt before closing
if (await this.saveSubprompt()) {
this.dialog.close();
}
} else {
// No subprompt selected or edit panel not visible, just close
this.dialog.close();
}
}
/**
* Load subprompt data into the edit form
*/
async loadSubpromptForEditing(subprompt) {
if (!subprompt) return;
this.currentSubprompt = subprompt;
this.isEditing = true;
// Show the edit panel and hide placeholder
this.showEditPanel();
// Calculate folder path for display using hierarchical calculation
let displayFolderPath = "";
try {
// If subprompt has folder_id, calculate the hierarchical path
if (subprompt.folder_id) {
displayFolderPath = await this.calculateFolderPath(subprompt.folder_id);
}
// Fallback to legacy folder_path if no folder_id
else if (subprompt.folder_path || subprompt.folderPath) {
displayFolderPath = subprompt.folder_path || subprompt.folderPath || "";
}
} catch (error) {
console.warn("Error calculating folder path:", error);
// Fallback to legacy folder_path
displayFolderPath = subprompt.folder_path || subprompt.folderPath || "";
}
// Update edit panel header with subprompt name and calculated folder path
this.updateEditPanelHeader(subprompt.name || subprompt.id, displayFolderPath);
// Populate form fields
this.setFormValue("subprompt-positive", subprompt.positive || "");
this.setFormValue("subprompt-negative", subprompt.negative || "");
this.setFormValue("subprompt-triggers", subprompt.trigger_words ? subprompt.trigger_words.join(", ") : "");
// Load nested subprompts - use 'order' field from storage with proper conversion
const rawOrder = subprompt.order || [];
const convertedNested = this.convertOrderToNested(rawOrder);
this.loadNestedSubprompts(convertedNested);
// Update preview asynchronously
this.updatePreview().catch(console.error);
this.markUnsavedChanges(false);
}
/**
* Clear the edit form
*/
clearForm() {
this.currentSubprompt = null;
this.isEditing = false;
// Hide the edit panel and show placeholder
this.showPlaceholderPanel();
// Clear edit panel header
this.updateEditPanelHeader();
this.setFormValue("subprompt-positive", "");
this.setFormValue("subprompt-negative", "");
this.setFormValue("subprompt-triggers", "");
this.loadNestedSubprompts([]);
this.clearValidationMessages();
// Update preview asynchronously
this.updatePreview().catch(console.error);
this.markUnsavedChanges(false);
}
/**
* Load nested subprompts list as draggable items
*/
async loadNestedSubprompts(nestedList = []) {
const container = this.element.querySelector("#subprompt-nested-list");
if (!container) return;
// Store available subprompts for the add dialog
try {
const response = await api.fetchApi('/prompt_companion/subprompts');
if (response.ok) {
this.allSubprompts = await response.json();
}
} catch (error) {
console.error("Error fetching subprompts:", error);
this.allSubprompts = [];
}
// Clear existing content
container.innerHTML = "";
// Ensure [Self] is always included if not already present, but preserve order
let finalNestedList = [...nestedList];
if (!finalNestedList.includes("[Self]")) {
// Add [Self] at the beginning only if not present
finalNestedList.unshift("[Self]");
}
// Create draggable items for each nested subprompt in the exact order specified
finalNestedList.forEach((item, index) => {
const itemElement = this.createNestedSubpromptItem(item, index);
container.appendChild(itemElement);
});
// Update preview after loading nested subprompts
this.updatePreview().catch(console.error);
}
/**
* Create a draggable nested subprompt item
*/
createNestedSubpromptItem(item, index) {
// Get better display name for nested subprompts
const displayName = this.getNestedSubpromptDisplayName(item);
const itemElement = $el("div", {
className: "prompt-companion-nested-item",
draggable: true,
dataset: { subpromptId: item, index: index.toString() },
style: {
padding: "8px 12px",
margin: "2px 0",
backgroundColor: "#2a2a2a",
border: "1px solid #404040",
borderRadius: "4px",
cursor: "move",
display: "flex",
alignItems: "center",
justifyContent: "space-between"
}
}, [
$el("div", {
className: "prompt-companion-nested-item-content",
style: { display: "flex", alignItems: "center" }
}, [
$el("span", {
className: "prompt-companion-drag-handle",
textContent: "⋮⋮",
style: {
marginRight: "8px",
color: "#888",
fontSize: "14px"
}
}),
$el("span", {
className: "prompt-companion-nested-item-name",
textContent: displayName,
style: {
color: item === "[Self]" ? "#4CAF50" : "#ffffff",
fontWeight: item === "[Self]" ? "bold" : "normal"
}
})
]),
// Only show remove button for items that are NOT [Self] - use empty div instead of null
item === "[Self]" ? $el("div", { style: { width: "24px", height: "18px" } }) : $el("button", {
type: "button",
className: "prompt-companion-btn-remove",
textContent: "×",
style: {
background: "none",
border: "none",
color: "#dc3545",
cursor: "pointer",
fontSize: "18px",
padding: "0 6px"
},
onclick: (e) => {
e.stopPropagation();
// FIX: Don't use captured index, find current index dynamically
this.removeNestedSubpromptItemByElement(itemElement);
}
})
]);
// Add drag and drop event listeners
this.setupDragAndDrop(itemElement);
return itemElement;
}
/**
* Setup drag and drop functionality for nested subprompt items
*/
setupDragAndDrop(element) {
element.addEventListener('dragstart', (e) => {
this.draggedNestedItem = element;
e.dataTransfer.effectAllowed = 'move';
element.style.opacity = '0.5';
});
element.addEventListener('dragend', (e) => {
element.style.opacity = '1';
this.draggedNestedItem = null;
// Remove drop indicators
this.element.querySelectorAll('.drop-indicator').forEach(el => el.remove());
});
element.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (this.draggedNestedItem && this.draggedNestedItem !== element) {
this.showDropIndicator(element, e);
}
});
element.addEventListener('drop', (e) => {
e.preventDefault();
if (this.draggedNestedItem && this.draggedNestedItem !== element) {
this.reorderNestedItems(this.draggedNestedItem, element, e);
}
});
}
/**
* Show drop indicator during drag operation
*/
showDropIndicator(element, event) {
// Remove existing indicators
this.element.querySelectorAll('.drop-indicator').forEach(el => el.remove());
const rect = element.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
const isAfter = event.clientY > midY;
const indicator = $el("div", {
className: "drop-indicator",
style: {
height: "2px",
backgroundColor: "#007acc",
margin: "2px 0",
borderRadius: "1px"
}
});
if (isAfter) {
element.parentNode.insertBefore(indicator, element.nextSibling);
} else {
element.parentNode.insertBefore(indicator, element);
}
}
/**
* Reorder nested items after drag and drop
*/
reorderNestedItems(draggedElement, targetElement, event) {
const container = this.element.querySelector("#subprompt-nested-list");
const rect = targetElement.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
const isAfter = event.clientY > midY;
// Remove drop indicators
this.element.querySelectorAll('.drop-indicator').forEach(el => el.remove());
// Reorder DOM elements
if (isAfter) {
container.insertBefore(draggedElement, targetElement.nextSibling);
} else {
container.insertBefore(draggedElement, targetElement);
}
// Update indices and mark as changed
this.updateNestedItemIndices();
this.markUnsavedChanges(true);
// Update preview after reordering
this.schedulePreviewUpdate('reorder');
}
/**
* Update the index data attributes after reordering
*/
updateNestedItemIndices() {
const items = this.element.querySelectorAll("#subprompt-nested-list .prompt-companion-nested-item");
items.forEach((item, index) => {
item.dataset.index = index.toString();
});
}
/**
* Get display name for nested subprompt items
*/
getNestedSubpromptDisplayName(item) {
if (item === "[Self]") {
return "[Self]";
}
// If it's already a display name (contains ": "), return as is
if (typeof item === 'string' && item.includes(': ')) {
return item;
}
// If it's a UUID, try to find the corresponding subprompt
if (this.allSubprompts && Array.isArray(this.allSubprompts)) {
const subprompt = this.allSubprompts.find(sp => sp.id === item);
if (subprompt) {
// Use the same format as combo boxes: "folder_path: name" or just "name" for root
return subprompt.folder_path ?
`${subprompt.folder_path}: ${subprompt.name}` :
subprompt.name;
}
}
// Return the original item if we can't find a better display name
return item;
}
/**
* Remove a nested subprompt item by finding its current index dynamically
* This fixes the issue where drag-and-drop changes indices but onclick handlers use stale indices
*/
removeNestedSubpromptItemByElement(itemElement) {
const container = this.element.querySelector("#subprompt-nested-list");
if (!itemElement || !container.contains(itemElement)) {
console.warn("Item element not found or not in container");
return;
}
// Prevent removal of [Self] marker
const subpromptId = itemElement.dataset.subpromptId;
if (subpromptId === "[Self]") {
console.warn("Cannot remove [Self] marker from nested subprompts");
return;
}
// Remove the element
itemElement.remove();
this.updateNestedItemIndices();
this.markUnsavedChanges(true);
// Update preview after removing item
this.schedulePreviewUpdate('remove');
// Show empty state if no items left (but always keep [Self])
if (container.children.length === 0) {
this.loadNestedSubprompts([]);
}
}
/**
* Remove a nested subprompt item (legacy method for backward compatibility)
*/
removeNestedSubpromptItem(index) {
const container = this.element.querySelector("#subprompt-nested-list");
const item = container.querySelector(`[data-index="${index}"]`);
if (item) {
this.removeNestedSubpromptItemByElement(item);
}
}
/**
* Show dialog to add nested subprompts
*/
async showAddSubpromptDialog() {
if (!this.allSubprompts) return;
// Build available subprompts from the list with folder paths using consistent format
let availableSubprompts = [];
this.allSubprompts.forEach(subprompt => {
// Use the same format as combo boxes: "folder_path: name" or just "name" for root
const displayName = subprompt.folder_path ?
`${subprompt.folder_path}: ${subprompt.name}` :
subprompt.name;
availableSubprompts.push(displayName);
});
availableSubprompts.sort();
const currentNested = this.getCurrentNestedSubprompts();
// Filter out the current subprompt to prevent circular reference
if (this.currentSubprompt && this.currentSubprompt.name) {
const currentDisplayName = this.currentSubprompt.folder_path ?
`${this.currentSubprompt.folder_path}: ${this.currentSubprompt.name}` :
this.currentSubprompt.name;
availableSubprompts = availableSubprompts.filter(name => name !== currentDisplayName);
}
// Add [Self] as first option if not already present
const options = ["[Self]", ...availableSubprompts];
const filteredOptions = options.filter(id => !currentNested.includes(id));
if (filteredOptions.length === 0) {
await this.showAlertDialog("All available subprompts are already added.");
return;
}
// Create selection dialog
const dialogContent = $el("div", {
style: {
minWidth: "300px",
padding: "20px"
}
}, [
$el("h4", {
textContent: "Add Nested Subprompt",
style: { marginTop: "0", marginBottom: "15px" }
}),
$el("select", {
id: "add-subprompt-selector",
size: Math.min(8, filteredOptions.length),
style: {
width: "100%",
minHeight: "120px",
marginBottom: "15px"
}
}, filteredOptions.map(id => $el("option", {
value: id,
textContent: id
}))),
$el("div", {
style: { textAlign: "right" }
}, [
$el("button", {
textContent: "Add",
style: {
marginRight: "10px",
padding: "6px 12px",
backgroundColor: "#007acc",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer"
},
onclick: () => {
const selector = document.getElementById("add-subprompt-selector");
const selectedOption = selector.options[selector.selectedIndex];
if (selectedOption) {
this.addNestedSubprompt(selectedOption.value);
document.body.removeChild(overlay);
}
}
}),
$el("button", {
textContent: "Cancel",
style: {
padding: "6px 12px",
backgroundColor: "#6c757d",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer"
},
onclick: () => document.body.removeChild(overlay)
})
])
]);
const overlay = $el("div", {
style: {
position: "fixed",
top: "0",
left: "0",
width: "100%",
height: "100%",
backgroundColor: "rgba(0,0,0,0.5)",
zIndex: "10003",
display: "flex",
alignItems: "center",
justifyContent: "center"
}
}, [
$el("div", {
style: {
backgroundColor: "#2d2d2d",
border: "1px solid #404040",
borderRadius: "8px",
color: "#ffffff"
}
}, [dialogContent])
]);
document.body.appendChild(overlay);
}
/**
* Get current nested subprompts from the UI
*/
getCurrentNestedSubprompts() {
const items = this.element.querySelectorAll("#subprompt-nested-list .prompt-companion-nested-item");
const result = Array.from(items).map(item => item.dataset.subpromptId);
return result;
}
/**
* Add a nested subprompt
*/
addNestedSubprompt(subpromptId) {
const container = this.element.querySelector("#subprompt-nested-list");
const currentNested = this.getCurrentNestedSubprompts();
// Clear empty state if present
if (container.querySelector(".prompt-companion-nested-empty")) {
container.innerHTML = "";
}
// Add the new item
const index = currentNested.length;
const itemElement = this.createNestedSubpromptItem(subpromptId, index);
container.appendChild(itemElement);
this.updateNestedItemIndices();
this.markUnsavedChanges(true);
// Update preview after adding item
this.schedulePreviewUpdate('add');
}
/**
* Convert storage 'order' format to frontend 'nested_subprompts' format
* Backend uses 'attached' for [Self], frontend uses '[Self]'
* This method ensures proper ordering and handles different stored formats
*/
convertOrderToNested(order) {
if (!Array.isArray(order)) {
return ["[Self]"];
}
const result = [];
// Process each item in order to maintain the exact sequence
for (const item of order) {
if (item === "attached") {
result.push("[Self]");
} else if (item && typeof item === 'string' && item.trim()) {
// Handle different formats: UUIDs, names, or "folder_path: name"
result.push(item.trim());
}
}
// Ensure [Self] is always included if not already present
if (!result.includes("[Self]")) {
result.unshift("[Self]");
}
return result;
}
/**
* Convert frontend 'nested_subprompts' format to storage 'order' format
* Frontend uses '[Self]' for current subprompt, backend uses 'attached'
*/
convertNestedToOrder(nested) {
if (!Array.isArray(nested)) {
return ["attached"];
}
return nested.map(item => {
if (item === "[Self]") {
return "attached";
} else {
return item;
}
});
}
/**
* Set form field value
*/
setFormValue(id, value) {
const element = this.element.querySelector(`#${id}`);
if (element) {
element.value = value;
}
}
/**
* Get form field value
*/
getFormValue(id) {
const element = this.element.querySelector(`#${id}`);
return element ? element.value : "";
}
/**
* Validate form content
*/
validateContent() {
const positive = this.getFormValue("subprompt-positive");
const messages = [];
// Content validation
if (!positive.trim() && !this.getFormValue("subprompt-negative").trim()) {
messages.push({ type: "warning", message: "At least one of positive or negative prompt should be specified" });
}
this.displayValidationMessages(messages);
return messages.filter(m => m.type === "error").length === 0;
}
/**
* Display validation messages
*/
displayValidationMessages(messages) {
const container = this.element.querySelector("#validation-messages");
if (!container) return;
container.innerHTML = "";
messages.forEach(({ type, message }) => {
const div = $el("div", {
className: `validation-${type}`,
textContent: message
});
container.appendChild(div);
});
}
/**
* Clear validation messages
*/
clearValidationMessages() {
const container = this.element.querySelector("#validation-messages");
if (container) {
container.innerHTML = "";
}
}
/**
* Debounced validation
*/
debounceValidation() {
if (this.validationTimer) {
clearTimeout(this.validationTimer);
}
this.validationTimer = setTimeout(() => this.validateContent(), 300);
}
/**
* Debounced preview update for real-time typing
*/
debouncePreviewUpdate() {
if (this.previewUpdateTimer) {
clearTimeout(this.previewUpdateTimer);
}
this.previewUpdateTimer = setTimeout(() => {
this.updatePreview().catch(console.error);
}, 150); // Shorter delay for more responsive preview updates
}
/**
* Update preview display with resolved nested subprompts
*/
async updatePreview() {
const preview = this.element.querySelector("#subprompt-preview");
if (!preview) {
return;
}
// Get the resolved content (combined with nested subprompts)
const resolvedContent = await this.resolveNestedSubprompts();
let previewHtml = "";
if (resolvedContent.positive.trim()) {
previewHtml += `<div class="preview-positive"><strong>Positive:</strong> ${this.escapeHtml(resolvedContent.positive)}</div>`;
}
if (resolvedContent.negative.trim()) {
previewHtml += `<div class="preview-negative"><strong>Negative:</strong> ${this.escapeHtml(resolvedContent.negative)}</div>`;
}
if (!previewHtml) {
previewHtml = '<div class="preview-empty">No content to preview</div>';
}
preview.innerHTML = previewHtml;
}
/**
* Schedule a preview update with robust DOM timing handling
* This ensures DOM changes are fully processed before reading current state
*/
schedulePreviewUpdate(source = 'unknown') {
const preview = this.element.querySelector("#subprompt-preview");
if (!preview) return;
// Cancel any pending preview update
if (this.pendingPreviewUpdate) {
cancelAnimationFrame(this.pendingPreviewUpdate);
this.pendingPreviewUpdate = null;
}
// Add visual indicator that preview is updating
const originalContent = preview.innerHTML;
preview.innerHTML = '<div class="preview-updating" style="color: #888; font-style: italic;">Updating preview...</div>';
// Capture method references before entering async context to avoid 'this' binding issues
const escapeHtml = this.escapeHtml.bind(this);
const getCurrentNestedSubprompts = this.getCurrentNestedSubprompts.bind(this);
const resolveNestedSubpromptsWithOrder = this.resolveNestedSubpromptsWithOrder.bind(this);
// Use triple requestAnimationFrame + forced reflow for maximum DOM settling
this.pendingPreviewUpdate = requestAnimationFrame(() => {
requestAnimationFrame(() => {
requestAnimationFrame(async () => {
try {
// Force multiple reflows to ensure DOM is fully processed
const container = this.element.querySelector("#subprompt-nested-list");
if (container) {
container.offsetHeight; // Force reflow
container.scrollTop; // Force additional layout calculation
}
// Small delay to ensure all DOM operations are complete
await new Promise(resolve => setTimeout(resolve, 10));
// Get fresh DOM state for nested subprompts using bound method
const currentNested = getCurrentNestedSubprompts();
// Get resolved content using fresh DOM state - pass currentNested directly
const resolvedContent = await resolveNestedSubpromptsWithOrder(currentNested);
let previewHtml = "";
if (resolvedContent.positive.trim()) {
previewHtml += `<div class="preview-positive"><strong>Positive:</strong> ${escapeHtml(resolvedContent.positive)}</div>`;
}
if (resolvedContent.negative.trim()) {
previewHtml += `<div class="preview-negative"><strong>Negative:</strong> ${escapeHtml(resolvedContent.negative)}</div>`;
}
if (!previewHtml) {
previewHtml = '<div class="preview-empty">No content to preview</div>';
}
preview.innerHTML = previewHtml;
// Add subtle flash effect to show update happened
preview.style.transition = 'background-color 0.2s ease';
preview.style.backgroundColor = '#2a4d3a';
setTimeout(() => {
preview.style.backgroundColor = '';
setTimeout(() => {
preview.style.transition = '';
}, 200);
}, 100);
this.pendingPreviewUpdate = null;
} catch (error) {
console.error(`Error in preview update (${source}):`, error);
preview.innerHTML = originalContent;
this.pendingPreviewUpdate = null;
}
});
});
});
}
/**
* Resolve nested subprompts using a specific order from the DOM
*/
async resolveNestedSubpromptsWithOrder(currentNested) {
const currentPositive = this.getFormValue("subprompt-positive").trim();
const currentNegative = this.getFormValue("subprompt-negative").trim();
// Convert frontend format to backend format for resolution
const orderForResolution = this.convertNestedToOrder(currentNested);
// Use recursive resolution with circular reference protection
const resolved = this.resolveSubpromptRecursively({
positive: currentPositive,
negative: currentNegative,
order: orderForResolution
}, new Set());
// Remove duplicate terms from the final resolved content
const finalResult = {
positive: this.removeDuplicateTerms(resolved.positive),
negative: this.removeDuplicateTerms(resolved.negative)
};
return finalResult;
}
/**
* Force preview update with immediate DOM state reading and visual feedback
* Used after DOM manipulation operations to ensure preview reflects current state
* @deprecated Use schedulePreviewUpdate() for better timing handling
*/
forcePreviewUpdate() {
this.schedulePreviewUpdate('force');
}
/**
* Resolve nested subprompts recursively and return combined content
*/
async resolveNestedSubprompts() {
const currentPositive = this.getFormValue("subprompt-positive").trim();
const currentNegative = this.getFormValue("subprompt-negative").trim();
const nestedSubprompts = this.getCurrentNestedSubprompts();
// Convert frontend format to backend format for resolution
const orderForResolution = this.convertNestedToOrder(nestedSubprompts);
// Use recursive resolution with circular reference protection
const resolved = this.resolveSubpromptRecursively({
positive: currentPositive,
negative: currentNegative,
order: orderForResolution
}, new Set());
// Remove duplicate terms from the final resolved content
const finalResult = {
positive: this.removeDuplicateTerms(resolved.positive),
negative: this.removeDuplicateTerms(resolved.negative)
};
return finalResult;
}
/**
* Remove duplicate terms from a comma-separated string, preserving order
*/
removeDuplicateTerms(text) {
if (!text || !text.trim()) return text;
// Split by comma, trim whitespace, and filter out empty terms
const terms = text.split(',').map(term => term.trim()).filter(term => term.length > 0);
// Use Set to track seen terms (case-insensitive) and array to preserve order
const seen = new Set();
const uniqueTerms = [];
for (const term of terms) {
const lowerTerm = term.toLowerCase();
if (!seen.has(lowerTerm)) {
seen.add(lowerTerm);
uniqueTerms.push(term); // Keep original case
}
}
return uniqueTerms.join(', ');
}
/**
* Recursively resolve a subprompt and all its nested subprompts
*/
resolveSubpromptRecursively(subprompt, visited = new Set()) {
// Start with EMPTY content instead of pre-adding textbox content
let resolvedPositive = '';
let resolvedNegative = '';
// Process nested subprompts in order - use 'order' field from storage
const orderList = subprompt.order || [];
for (let i = 0; i < orderList.length; i++) {
const nestedId = orderList[i];
if (nestedId === "attached") {
// Add textbox content at this specific position in the order
if (subprompt.positive && subprompt.positive.trim()) {
if (resolvedPositive) {
resolvedPositive += ", " + subprompt.positive.trim();
} else {
resolvedPositive = subprompt.positive.trim();
}
}
if (subprompt.negative && subprompt.negative.trim()) {
if (resolvedNegative) {
resolvedNegative += ", " + subprompt.negative.trim();
} else {
resolvedNegative = subprompt.negative.trim();
}
}
continue; // Continue to next item in order
}
// Prevent circular references
if (visited.has(nestedId)) {
console.warn(`Circular reference detected: ${nestedId}`);
continue;
}
// Get nested subprompt content - search through the list
let nestedSubprompt = null;
if (this.allSubprompts && Array.isArray(this.allSubprompts)) {
// Parse the nestedId to handle both new format "folder_path: name" and old formats
if (nestedId.includes(': ')) {
// New format: "folder_path: name"
const parts = nestedId.split(': ');
const folderPath = parts[0];
const name = parts[1];
nestedSubprompt = this.allSubprompts.find(sp =>
sp.name === name && sp.folder_path === folderPath);
} else if (nestedId.includes('/')) {
// Legacy format: "folder_path/name"
const parts = nestedId.split('/');
const name = parts[parts.length - 1];
const folderPath = parts.slice(0, -1).join('/');
nestedSubprompt = this.allSubprompts.find(sp =>
sp.name === name && sp.folder_path === folderPath);
} else {
// Simple name format (root-level subprompts)
nestedSubprompt = this.allSubprompts.find(sp =>
sp.name === nestedId && (!sp.folder_path || sp.folder_path === ''));
}
}
if (nestedSubprompt) {
// Create new visited set for this branch
const branchVisited = new Set(visited);
branchVisited.add(nestedId);
// Recursively resolve the nested subprompt
const resolvedNested = this.resolveSubpromptRecursively(nestedSubprompt, branchVisited);
// Add resolved positive content AT THIS POSITION in the order
if (resolvedNested.positive && resolvedNested.positive.trim()) {
if (resolvedPositive) {
resolvedPositive += ", " + resolvedNested.positive.trim();
} else {
resolvedPositive = resolvedNested.positive.trim();
}
}
// Add resolved negative content AT THIS POSITION in the order
if (resolvedNested.negative && resolvedNested.negative.trim()) {
if (resolvedNegative) {
resolvedNegative += ", " + resolvedNested.negative.trim();
} else {
resolvedNegative = resolvedNested.negative.trim();
}
}
} else {
console.warn(`Nested subprompt not found for ID: "${nestedId}" at position ${i}`);
}
}
const finalResult = {
positive: resolvedPositive,
negative: resolvedNegative
};
return finalResult;
}
/**
* Escape HTML for safe display
*/
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Save subprompt data
*/
async saveSubprompt() {
if (!this.validateContent()) {
return false;
}
// Get nested subprompts from the draggable list (in order)
const nestedSubprompts = this.getCurrentNestedSubprompts();
// Convert frontend format to backend 'order' format
const orderForStorage = this.convertNestedToOrder(nestedSubprompts);
const subpromptData = {
name: this.currentSubprompt ? this.currentSubprompt.name : "New Subprompt",
positive: this.getFormValue("subprompt-positive").trim(),
negative: this.getFormValue("subprompt-negative").trim(),
trigger_words: this.getFormValue("subprompt-triggers").split(",").map(w => w.trim()).filter(w => w),
order: orderForStorage,
// Use folder_id for UUID-based system, keep folder_path for backward compatibility
folder_id: this.currentSubprompt ? (this.currentSubprompt.folder_id || null) : null,
folder_path: this.currentSubprompt ? (this.currentSubprompt.folder_path || this.currentSubprompt.folderPath || null) : null
};
try {
const url = this.isEditing && this.currentSubprompt
? `/prompt_companion/subprompts/${encodeURIComponent(this.currentSubprompt.id)}`
: "/prompt_companion/subprompts";
const method = this.isEditing && this.currentSubprompt ? "PUT" : "POST";
// For editing, include the ID in the data
if (this.isEditing && this.currentSubprompt) {
subpromptData.id = this.currentSubprompt.id;
}
const response = await api.fetchApi(url, {
method: method,
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(subpromptData)
});
if (response.ok) {
this.markUnsavedChanges(false);
// Refresh tree view while preserving expanded folder state
if (this.treeView && this.treeView.refreshTreePreservingState) {
await this.treeView.refreshTreePreservingState();
}
// Update node dropdowns in workflow
this.updateNodeDropdowns();
this.showMessage("Subprompt saved successfully", "success");
return true;
} else {
const errorData = await response.json();
this.showMessage(`Save failed: ${errorData.error || "Unknown error"}`, "error");
return false;
}
} catch (error) {
console.error("Save error:", error);
this.showMessage(`Save failed: ${error.message}`, "error");
return false;
}
}
/**
* Revert changes to last saved state
*/
revertChanges() {
if (this.currentSubprompt) {
this.loadSubpromptForEditing(this.currentSubprompt);
} else {
this.clearForm();
}
}
/**
* Create new folder
*/
async createFolder() {
const name = await this.showInputDialog("Enter folder name:", "");
if (name && name.trim()) {
const folderName = name.trim();
if (this.treeView && this.treeView.createFolder) {
try {
const success = await this.treeView.createFolder(folderName);
if (success) {
// Tree view already refreshes itself with preserved state in createFolder()
this.showMessage(`Folder "${folderName}" created successfully`, "success");
} else {
this.showMessage(`Failed to create folder "${folderName}"`, "error");
}
} catch (error) {
console.error("Error creating folder:", error);
this.showMessage(`Error creating folder: ${error.message}`, "error");
}
} else {
this.showMessage("Tree view not available", "error");
}
}
}
/**
* Create new subprompt
*/
async createSubprompt() {
// Prompt for subprompt name
const name = await this.showInputDialog("Enter subprompt name:", "");
if (!name || !name.trim()) {
return; // User cancelled or entered empty name
}
const subpromptName = name.trim();
// Create subprompt on server first
try {
const newSubpromptData = {
name: subpromptName,
folder_id: null, // Default to root level (null for root)
folder_path: "", // Keep for backward compatibility
positive: '',
negative: '',
trigger_words: [],
order: ["attached"] // Use backend format for storage
};
const response = await api.fetchApi('/prompt_companion/subprompts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newSubpromptData)
});
if (response.ok) {
const savedSubprompt = await response.json();
// Use the tree view's built-in method to refresh while preserving state
if (this.treeView && this.treeView.refreshTreePreservingState) {
await this.treeView.refreshTreePreservingState();
}
// Note: No need to call expandParentFolders since refreshTreePreservingState
// already preserves the correct expanded state from createSubprompt()
// Load the saved subprompt for editing
this.loadSubpromptForEditing(savedSubprompt);
this.markUnsavedChanges(false); // It's saved, no unsaved changes yet
// Focus the first field for immediate editing
const firstField = this.element.querySelector("#subprompt-positive");
if (firstField) {
firstField.focus();
}
this.showMessage(`Subprompt "${subpromptName}" created successfully`, "success");
} else {
const errorData = await response.json();
this.showMessage(`Failed to create subprompt: ${errorData.error || 'Unknown error'}`, "error");
}
} catch (error) {
console.error("Error creating subprompt:", error);
this.showMessage(`Error creating subprompt: ${error.message}`, "error");
}
}
/**
* Search subprompts in tree view
*/
searchSubprompts(term) {
if (this.treeView && this.treeView.searchTree) {
this.treeView.searchTree(term);
}
}
/**
* Mark unsaved changes state
*/
markUnsavedChanges(hasChanges) {
this.unsavedChanges = hasChanges;
const header = this.element.querySelector(".prompt-companion-dialog-header h3");
if (header) {
header.textContent = hasChanges ? "Edit Subprompts *" : "Edit Subprompts";
header.style.color = hasChanges ? "#ffc107" : "";
}
}
/**
* Show the edit panel and hide placeholder
*/
showEditPanel() {
const editPanel = this.element.querySelector("#prompt-companion-edit-panel");
const placeholderPanel = this.element.querySelector("#prompt-companion-placeholder-panel");
if (editPanel) editPanel.style.display = "block";
if (placeholderPanel) placeholderPanel.style.display = "none";
}
/**
* Show the placeholder panel and hide edit panel
*/
showPlaceholderPanel() {
const editPanel = this.element.querySelector("#prompt-companion-edit-panel");
const placeholderPanel = this.element.querySelector("#prompt-companion-placeholder-panel");
if (editPanel) editPanel.style.display = "none";
if (placeholderPanel) placeholderPanel.style.display = "block";
}
/**
* Update the edit panel header with subprompt name and folder path
*/
updateEditPanelHeader(subpromptName, folderPath) {
const header = this.element.querySelector("#prompt-companion-edit-panel .prompt-companion-panel-header h4");
if (header) {
if (subpromptName) {
let headerText = "Edit Subprompt: ";
if (folderPath && folderPath.trim()) {
// Convert forward slashes to backslashes for Windows-style display
const displayPath = folderPath.replace(/\//g, '\\');
headerText += `${displayPath}\\${subpromptName}`;
} else {
headerText += subpromptName;
}
header.textContent = headerText;
} else {
header.textContent = "Edit Subprompt";
}
}
}
/**
* Calculate hierarchical folder path from folder_id
*/
async calculateFolderPath(folderId) {
if (!folderId) return "";
try {
// Get all folders to build the hierarchy
const foldersResponse = await api.fetchApi('/prompt_companion/folders');
if (!foldersResponse.ok) {
console.warn("Failed to fetch folders for path calculation");
return "";
}
const folders = await foldersResponse.json();
if (!Array.isArray(folders) || folders.length === 0) {
return "";
}
// Create folder lookup map
const folderMap = {};
folders.forEach(folder => {
folderMap[folder.id] = folder;
});
// Calculate path by walking up the parent chain
const pathParts = [];
let currentFolder = folderMap[folderId];
// Prevent infinite loops with a max depth
let depth = 0;
const maxDepth = 20;
while (currentFolder && depth < maxDepth) {
pathParts.unshift(currentFolder.name);
currentFolder = currentFolder.parent_id ? folderMap[currentFolder.parent_id] : null;
depth++;
}
return pathParts.join('/'); // Use forward slashes internally, convert to backslashes in display
} catch (error) {
console.error("Error calculating folder path:", error);
return "";
}
}
/**
* Update node dropdowns in the workflow
*/
updateNodeDropdowns() {
const promptCompanionNodes = [
"PromptCompanion_AddSubprompt",
"PromptCompanion_SubpromptToStrings",
"PromptCompanion_StringsToSubprompt",
"PromptCompanion_LoadCheckpointWithSubprompt"
];
if (app.graph && app.graph._nodes) {
app.graph._nodes.forEach(node => {
if (node.type && promptCompanionNodes.includes(node.type)) {
if (node.widgets) {
node.widgets.forEach(widget => {
if (widget.name && widget.name.includes("subprompt") && widget.options) {
this.refreshWidgetOptions(widget);
}
});
}
}
});
}
}
/**
* Refresh widget options
*/
async refreshWidgetOptions(widget) {
try {
const response = await api.fetchApi("/prompt_companion/subprompts/dropdown_options");
if (response.ok) {
const dropdownOptions = await response.json();
// Only update if we got valid options
if (dropdownOptions && dropdownOptions.length > 0 &&
!dropdownOptions.includes("[Error Loading Subprompts]")) {
widget.options.values = dropdownOptions;
}
}
} catch (error) {
console.error("Failed to refresh widget options:", error);
// Don't clear options on error - leave them unchanged
}
}
/**
* Show temporary message
*/
showMessage(text, type = "info") {
const messageEl = $el("div", {
className: `prompt-companion-message prompt-companion-message-${type}`,
textContent: text,
style: {
position: "fixed",
top: "20px",
right: "20px",
padding: "10px 20px",
borderRadius: "4px",
zIndex: "10000",
backgroundColor: type === "success" ? "#28a745" :
type === "error" ? "#dc3545" : "#17a2b8",
color: "white"
}
});
document.body.appendChild(messageEl);
setTimeout(() => {
if (messageEl.parentNode) {
messageEl.parentNode.removeChild(messageEl);
}
}, 3000);
}
/**
* Handle window resize
*/
handleResize() {
const content = this.element.querySelector(".prompt-companion-dialog-content");
if (content) {
const maxHeight = window.innerHeight * 0.9;
const maxWidth = window.innerWidth * 0.9;
content.style.maxHeight = `${maxHeight}px`;
content.style.maxWidth = `${maxWidth}px`;
// Ensure dialog stays properly centered
this.centerDialog();
}
}
/**
* Setup resize handling
*/
setupResizeHandling() {
this.resizing = false;
this.startX = 0;
this.startY = 0;
this.startWidth = 0;
this.startHeight = 0;
}
/**
* Start resize operation
*/
startResize(e) {
e.preventDefault();
e.stopPropagation();
this.resizing = true;
this.startX = e.clientX;
this.startY = e.clientY;
const content = this.element.querySelector(".prompt-companion-dialog-content");
if (content) {
const rect = content.getBoundingClientRect();
this.startWidth = rect.width;
this.startHeight = rect.height;
}
// Add global mouse event listeners
this.resizeHandler = (e) => this.doResize(e);
this.stopResizeHandler = () => this.stopResize();
document.addEventListener('mousemove', this.resizeHandler, { passive: false });
document.addEventListener('mouseup', this.stopResizeHandler, { once: true });
// Prevent text selection during resize
document.body.style.userSelect = 'none';
document.body.style.cursor = 'nw-resize';
}
/**
* Perform resize operation
*/
doResize(e) {
if (!this.resizing) return;
e.preventDefault();
const deltaX = e.clientX - this.startX;
const deltaY = e.clientY - this.startY;
const newWidth = Math.max(600, this.startWidth + deltaX);
const newHeight = Math.max(400, this.startHeight + deltaY);
const content = this.element.querySelector(".prompt-companion-dialog-content");
if (content) {
content.style.width = newWidth + 'px';
content.style.height = newHeight + 'px';
content.style.maxWidth = 'none';
content.style.maxHeight = 'none';
}
}
/**
* Stop resize operation
*/
stopResize() {
this.resizing = false;
// Remove global event listeners
if (this.resizeHandler) {
document.removeEventListener('mousemove', this.resizeHandler);
}
// Restore text selection and cursor
document.body.style.userSelect = '';
document.body.style.cursor = '';
}
/**
* Show input dialog using ComfyUI's native API
*/
async showInputDialog(message, defaultValue = "") {
// Try ComfyUI's native prompt dialog first
if (typeof app !== 'undefined' && app.extensionManager && app.extensionManager.dialog && app.extensionManager.dialog.prompt) {
try {
return await app.extensionManager.dialog.prompt({
title: "Input",
message: message,
defaultValue: defaultValue || ""
});
} catch (error) {
console.warn("ComfyUI extensionManager dialog.prompt failed, falling back to browser prompt:", error);
}
}
// Fallback to browser prompt
const result = prompt(message, defaultValue || "");
return result;
}
/**
* Show unsaved changes dialog with three options: Save, Discard, Cancel
* @returns {Promise<string>} 'save', 'discard', or 'cancel'
*/
async showUnsavedChangesDialog() {
return new Promise((resolve) => {
const dialog = $el("div", {
className: "prompt-companion-unsaved-changes-dialog",
style: {
position: "fixed",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
backgroundColor: "#2d2d2d",
border: "1px solid #404040",
borderRadius: "8px",
padding: "20px",
zIndex: "10003",
minWidth: "400px",
maxWidth: "500px",
boxShadow: "0 4px 12px rgba(0,0,0,0.5)"
}
}, [
$el("h3", {
textContent: "Unsaved Changes",
style: {
color: "#ffffff",
marginTop: "0",
marginBottom: "15px",
fontSize: "18px"
}
}),
$el("p", {
textContent: "You have unsaved changes to the current subprompt. What would you like to do?",
style: {
color: "#cccccc",
marginBottom: "25px",
lineHeight: "1.4"
}
}),
$el("div", {
className: "prompt-companion-dialog-buttons",
style: {
display: "flex",
gap: "12px",
justifyContent: "center",
flexWrap: "wrap"
}
}, [
$el("button", {
textContent: "Save Changes",
style: {
padding: "10px 20px",
backgroundColor: "#28a745",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer",
fontSize: "14px",
fontWeight: "500",
minWidth: "120px"
},
onclick: () => {
document.body.removeChild(overlay);
resolve('save');
}
}),
$el("button", {
textContent: "Discard Changes",
style: {
padding: "10px 20px",
backgroundColor: "#dc3545",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer",
fontSize: "14px",
fontWeight: "500",
minWidth: "120px"
},
onclick: () => {
document.body.removeChild(overlay);
resolve('discard');
}
}),
$el("button", {
textContent: "Cancel",
style: {
padding: "10px 20px",
backgroundColor: "#6c757d",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer",
fontSize: "14px",
fontWeight: "500",
minWidth: "120px"
},
onclick: () => {
document.body.removeChild(overlay);
resolve('cancel');
}
})
])
]);
const overlay = $el("div", {
style: {
position: "fixed",
top: "0",
left: "0",
width: "100%",
height: "100%",
backgroundColor: "rgba(0,0,0,0.5)",
zIndex: "10002"
},
onclick: (e) => {
// Only close if clicking on overlay, not dialog
if (e.target === overlay) {
document.body.removeChild(overlay);
resolve('cancel');
}
}
}, [dialog]);
document.body.appendChild(overlay);
// Focus the "Save Changes" button by default
const saveButton = dialog.querySelector('button');
if (saveButton) {
saveButton.focus();
}
});
}
/**
* Show confirmation dialog using ComfyUI's native API
*/
async showConfirmDialog(message) {
// Try ComfyUI's native confirm dialog first
if (typeof app !== 'undefined' && app.extensionManager && app.extensionManager.dialog && app.extensionManager.dialog.confirm) {
try {
return await app.extensionManager.dialog.confirm({
title: "Confirm",
message: message
});
} catch (error) {
console.warn("ComfyUI extensionManager dialog.confirm failed, falling back to browser confirm:", error);
}
}
// Fallback to browser confirm
return confirm(message);
}
/**
* Show alert dialog using ComfyUI's native API or notification
*/
async showAlertDialog(message) {
// Try ComfyUI's notification system first
if (typeof app !== 'undefined' && app.ui && app.ui.dialog && app.ui.dialog.show) {
try {
app.ui.dialog.show(message);
return;
} catch (error) {
console.warn("ComfyUI ui.dialog.show failed, falling back to alert:", error);
}
}
// Fallback to browser alert
alert(message);
}
}
// Dialog instance management
let editDialog = null;
/**
* Show the edit dialog
*/
function showPromptEditDialog(subprompt = null) {
if (!editDialog) {
editDialog = new PromptEditDialog();
// Update the global reference
window.PromptCompanion.editDialog = editDialog;
}
editDialog.show(subprompt);
}
/**
* Hide the edit dialog
*/
function hidePromptEditDialog() {
if (editDialog) {
editDialog.hide();
}
}
/**
* Get the current edit dialog instance
*/
function getEditDialog() {
return editDialog;
}
// Context menu integration is handled by extensions.js to avoid conflicts
// Export functions for use by other components
window.PromptCompanion = window.PromptCompanion || {};
window.PromptCompanion.showEditDialog = showPromptEditDialog;
window.PromptCompanion.hideEditDialog = hidePromptEditDialog;
window.PromptCompanion.getEditDialog = getEditDialog;
window.PromptCompanion.editDialog = null; // Will be set when dialog is created