538 lines
19 KiB
JavaScript
538 lines
19 KiB
JavaScript
import { app } from "../../../scripts/app.js";
|
|
|
|
// Helper to get settings for both ComfyUI V1 and V2
|
|
function getSettingValue(id, defaultValue) {
|
|
if (app.extensionManager && app.extensionManager.setting) {
|
|
const val = app.extensionManager.setting.get(id);
|
|
return val !== undefined ? val : defaultValue;
|
|
} else if (app.ui && app.ui.settings) {
|
|
return app.ui.settings.getSettingValue(id, defaultValue);
|
|
}
|
|
return defaultValue;
|
|
}
|
|
|
|
function fuzzyMatch(text, query) {
|
|
const textLower = text.toLowerCase();
|
|
const queryLower = query.toLowerCase();
|
|
if (!queryLower) return { match: true, score: 0 };
|
|
|
|
let queryIdx = 0;
|
|
let score = 0;
|
|
let lastMatchIdx = -1;
|
|
|
|
for (let i = 0; i < textLower.length; i++) {
|
|
if (textLower[i] === queryLower[queryIdx]) {
|
|
if (lastMatchIdx !== -1) {
|
|
score += (i - lastMatchIdx - 1);
|
|
}
|
|
lastMatchIdx = i;
|
|
queryIdx++;
|
|
if (queryIdx === queryLower.length) {
|
|
score += (textLower.length - i - 1) * 0.1;
|
|
return { match: true, score: score };
|
|
}
|
|
}
|
|
}
|
|
return { match: false, score: Infinity };
|
|
}
|
|
|
|
// Register extension
|
|
app.registerExtension({
|
|
name: "SATA_UtilityNode.PromptAutocomplete",
|
|
|
|
settings: [
|
|
{
|
|
id: "SATA_UtilityNode.PromptAutocomplete.Trigger",
|
|
name: "Prompt Autocomplete Trigger Character",
|
|
type: "text",
|
|
defaultValue: "#",
|
|
},
|
|
{
|
|
id: "SATA_UtilityNode.PromptAutocomplete.Global",
|
|
name: "Prompt Autocomplete Global Mode (All Text Widgets)",
|
|
type: "boolean",
|
|
defaultValue: false,
|
|
}
|
|
],
|
|
|
|
async setup() {
|
|
const self = this;
|
|
document.addEventListener("focusin", (e) => {
|
|
if (e.target && (e.target.tagName === "TEXTAREA" || e.target.tagName === "INPUT")) {
|
|
if (e.target.dataset.hasAutocomplete) return;
|
|
if (!self.snippetsCache) {
|
|
self.loadSnippets();
|
|
}
|
|
e.target.dataset.hasAutocomplete = "true";
|
|
e.target.addEventListener("keydown", (evt) => self.handleKeydown(evt, e.target, null));
|
|
e.target.addEventListener("input", (evt) => self.handleInput(evt, e.target, null));
|
|
}
|
|
});
|
|
},
|
|
|
|
async initAutocompleteForNode(node) {
|
|
const isTargetNode = node.comfyClass === "PromptAutocomplete" || node.comfyClass === "Prompt_Machine";
|
|
const isGlobal = getSettingValue("SATA_UtilityNode.PromptAutocomplete.Global", false);
|
|
|
|
if (!isTargetNode && !isGlobal) return;
|
|
|
|
// Find text widgets
|
|
const textWidgets = node.widgets?.filter(w => w.type === "customtext" || w.type === "text" || w.type === "STRING");
|
|
if (!textWidgets) return;
|
|
|
|
// Load snippets (cache them)
|
|
if (!this.snippetsCache) {
|
|
await this.loadSnippets();
|
|
}
|
|
|
|
textWidgets.forEach(w => {
|
|
this.attachAutocomplete(w);
|
|
});
|
|
},
|
|
|
|
async nodeCreated(node) {
|
|
this.initAutocompleteForNode(node);
|
|
},
|
|
|
|
async loadedGraphNode(node) {
|
|
this.initAutocompleteForNode(node);
|
|
},
|
|
|
|
snippetsCache: null,
|
|
|
|
async loadSnippets() {
|
|
try {
|
|
// 1. List files
|
|
const listResp = await fetch("/sata/autocomplete/list");
|
|
const listData = await listResp.json();
|
|
const files = listData.files || [];
|
|
|
|
this.snippetsCache = {};
|
|
|
|
// 2. Load content for each file
|
|
for (const file of files) {
|
|
const contentResp = await fetch(`/sata/autocomplete/get?file=${encodeURIComponent(file)}`);
|
|
const contentData = await contentResp.json();
|
|
this.snippetsCache[file] = contentData.items || [];
|
|
}
|
|
|
|
} catch (err) {
|
|
console.error("[PromptAutocomplete] Failed to load snippets:", err);
|
|
}
|
|
},
|
|
|
|
attachAutocomplete(widget) {
|
|
// We need to hook into the DOM element of the widget
|
|
// ComfyUI widgets usually expose their input element via `inputEl` or we might need to find it.
|
|
// For standard multiline text widgets, it's often a textarea.
|
|
|
|
// Wait for the widget to have an element
|
|
const originalDraw = widget.draw;
|
|
const self = this;
|
|
|
|
// Hook into the input element creation/usage
|
|
// NOTE: ComfyUI's customtext widget often creates a textarea.
|
|
// We can try to find the textarea in the DOM if it's active, or hook the input callback.
|
|
|
|
// A more robust way for ComfyUI widgets:
|
|
// The widget has an `inputEl` property if it's a DOM widget.
|
|
|
|
// Let's try to hook the `inputEl` if it exists, or wait for it.
|
|
|
|
// Helper to setup the listener
|
|
function setupListener(input) {
|
|
if (input.dataset.hasAutocomplete) return;
|
|
input.dataset.hasAutocomplete = "true";
|
|
|
|
input.addEventListener("keydown", (e) => self.handleKeydown(e, input, widget));
|
|
input.addEventListener("input", (e) => self.handleInput(e, input, widget));
|
|
}
|
|
|
|
// Check if inputEl already exists
|
|
if (widget.inputEl) {
|
|
setupListener(widget.inputEl);
|
|
} else {
|
|
// If checking widget.draw:
|
|
// Standard LiteGraph widgets might not have an instance-level draw method, relying on the class prototype or default.
|
|
// If we blindly assign widget.draw = ..., we obscure the default behavior (disappearing widget).
|
|
// So we only wrap if originalDraw exists (or is on the prototype? widget points to instance).
|
|
|
|
// Safer approach: check if draw is defined on the instance or try to get it from prototype?
|
|
// Actually, if we just want to catch inputEl creation, maybe polling is safer for standard widgets
|
|
// if we suspect they don't have a direct 'draw' method we can wrap nicely.
|
|
|
|
if (originalDraw) {
|
|
widget.draw = function (ctx, node, widgetWidth, y, widgetHeight) {
|
|
const result = originalDraw.apply(this, arguments);
|
|
if (this.inputEl) {
|
|
setupListener(this.inputEl);
|
|
}
|
|
return result;
|
|
}
|
|
} else {
|
|
// Fallback: poll for inputEl a few times
|
|
const poll = setInterval(() => {
|
|
if (widget.inputEl) {
|
|
setupListener(widget.inputEl);
|
|
clearInterval(poll);
|
|
}
|
|
}, 200);
|
|
// Stop polling after 5 seconds to save resources
|
|
setTimeout(() => clearInterval(poll), 5000);
|
|
}
|
|
}
|
|
},
|
|
|
|
// --- Autocomplete Logic ---
|
|
|
|
popup: null,
|
|
active: false,
|
|
currentCategory: null, // null = showing categories, string = showing items
|
|
selectedIndex: 0,
|
|
filteredItems: [],
|
|
triggerChar: "#",
|
|
|
|
handleInput(e, input, widget) {
|
|
const trigger = getSettingValue("SATA_UtilityNode.PromptAutocomplete.Trigger", "#");
|
|
this.triggerChar = trigger;
|
|
|
|
const val = input.value;
|
|
const cursor = input.selectionStart;
|
|
|
|
// Simple check: is the character before cursor the trigger?
|
|
// Or are we in a "trigger session"?
|
|
|
|
// If we just typed the trigger
|
|
if (e.data === trigger) {
|
|
this.showPopup(input, cursor);
|
|
return;
|
|
}
|
|
|
|
// If active, update filter
|
|
if (this.active) {
|
|
// Find the text after the last trigger
|
|
const lastTriggerIndex = val.lastIndexOf(trigger, cursor - 1);
|
|
if (lastTriggerIndex === -1) {
|
|
this.closePopup();
|
|
return;
|
|
}
|
|
|
|
const query = val.substring(lastTriggerIndex + 1, cursor);
|
|
|
|
// Check if we have a category selected (format: #category:query)
|
|
// But wait, the plan said: Select Category -> Then Items.
|
|
// So maybe we just filter categories first.
|
|
|
|
if (this.currentCategory) {
|
|
// We are inside a category
|
|
// Check if the query contains the category prefix?
|
|
// Actually, let's keep it simple:
|
|
// 1. Type # -> Show list of files (categories)
|
|
// 2. Select file -> Insert "filename:" ? Or just switch mode?
|
|
// Let's do: Type # -> Show categories.
|
|
// If user types more, filter categories.
|
|
// If user selects category, we switch to showing items of that category.
|
|
// But how do we represent that in text?
|
|
// Maybe we don't change text, just internal state?
|
|
// Standard way: #category:item
|
|
|
|
// Let's parse the text to see where we are.
|
|
// If text is "#cat", we filter categories.
|
|
// If text is "#cat:ite", we filter items in "cat".
|
|
|
|
const parts = query.split(":");
|
|
if (parts.length > 1) {
|
|
// We have a category selected
|
|
const catName = parts[0];
|
|
const itemQuery = parts.slice(1).join(":"); // rest is item query
|
|
|
|
// Check if catName is valid
|
|
if (this.snippetsCache[catName + ".csv"] || this.snippetsCache[catName + ".json"] || this.snippetsCache[catName]) {
|
|
// It's a valid category (roughly).
|
|
// Let's find the exact filename key
|
|
let key = Object.keys(this.snippetsCache).find(k => k.startsWith(catName));
|
|
if (key) {
|
|
this.currentCategory = key;
|
|
this.updateItems(itemQuery);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// If no colon, or invalid category, we are filtering categories
|
|
this.currentCategory = null;
|
|
this.updateCategories(query);
|
|
|
|
} else {
|
|
// No category selected yet
|
|
// Check for colon
|
|
const parts = query.split(":");
|
|
if (parts.length > 1) {
|
|
// Maybe user typed "style:" manually?
|
|
const catName = parts[0];
|
|
let key = Object.keys(this.snippetsCache).find(k => k.replace(/\.(csv|json)$/, "") === catName);
|
|
if (key) {
|
|
this.currentCategory = key;
|
|
this.updateItems(parts.slice(1).join(":"));
|
|
return;
|
|
}
|
|
}
|
|
|
|
this.updateCategories(query);
|
|
}
|
|
}
|
|
},
|
|
|
|
handleKeydown(e, input, widget) {
|
|
if (!this.active) return;
|
|
|
|
if (e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
|
this.renderPopup();
|
|
} else if (e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
this.selectedIndex = Math.min(this.filteredItems.length - 1, this.selectedIndex + 1);
|
|
this.renderPopup();
|
|
} else if (e.key === "Enter" || e.key === "Tab") {
|
|
e.preventDefault();
|
|
this.selectItem(input);
|
|
} else if (e.key === "Escape") {
|
|
e.preventDefault();
|
|
this.closePopup();
|
|
}
|
|
},
|
|
|
|
showPopup(input, cursor) {
|
|
if (this.popup) document.body.removeChild(this.popup);
|
|
|
|
this.popup = document.createElement("div");
|
|
this.popup.className = "sata-autocomplete-popup";
|
|
Object.assign(this.popup.style, {
|
|
position: "absolute",
|
|
zIndex: "9999",
|
|
backgroundColor: "#222",
|
|
border: "1px solid #555",
|
|
maxHeight: "300px",
|
|
overflowY: "auto",
|
|
width: "200px",
|
|
color: "#ddd",
|
|
fontFamily: "monospace",
|
|
fontSize: "12px",
|
|
boxShadow: "0 4px 6px rgba(0,0,0,0.3)"
|
|
});
|
|
|
|
// Position popup near cursor (approximate)
|
|
const rect = input.getBoundingClientRect();
|
|
// This is tricky for textarea cursor position.
|
|
// For now, just place it under the textarea or at bottom left of it.
|
|
// To do it properly requires a library or complex calculation.
|
|
// Let's put it at the bottom-left of the input for now.
|
|
this.popup.style.left = `${rect.left}px`;
|
|
this.popup.style.top = `${rect.bottom}px`;
|
|
|
|
document.body.appendChild(this.popup);
|
|
this.active = true;
|
|
this.currentCategory = null;
|
|
this.updateCategories("");
|
|
},
|
|
|
|
closePopup() {
|
|
if (this.popup) {
|
|
document.body.removeChild(this.popup);
|
|
this.popup = null;
|
|
}
|
|
this.active = false;
|
|
this.currentCategory = null;
|
|
},
|
|
|
|
updateCategories(query) {
|
|
const files = Object.keys(this.snippetsCache || {});
|
|
const matches = [];
|
|
files.forEach(f => {
|
|
const display = f.replace(/\.(csv|json)$/, "");
|
|
const res = fuzzyMatch(display, query);
|
|
if (res.match) {
|
|
matches.push({
|
|
type: "category",
|
|
value: f,
|
|
display: display,
|
|
score: res.score
|
|
});
|
|
}
|
|
});
|
|
matches.sort((a, b) => a.score - b.score);
|
|
|
|
this.filteredItems = matches;
|
|
this.selectedIndex = 0;
|
|
this.renderPopup();
|
|
},
|
|
|
|
updateItems(query) {
|
|
if (!this.currentCategory) return;
|
|
const items = this.snippetsCache[this.currentCategory] || [];
|
|
|
|
const matches = [];
|
|
items.forEach(i => {
|
|
const res = fuzzyMatch(i, query);
|
|
if (res.match) {
|
|
matches.push({ value: i, score: res.score });
|
|
}
|
|
});
|
|
matches.sort((a, b) => a.score - b.score);
|
|
const sortedItems = matches.map(m => m.value).slice(0, 100);
|
|
|
|
// Add "Random" option at top
|
|
this.filteredItems = [
|
|
{ type: "random", value: "RANDOM", display: "🎲 Random" },
|
|
...sortedItems.map(i => ({ type: "item", value: i, display: i }))
|
|
];
|
|
|
|
this.selectedIndex = 0;
|
|
this.renderPopup();
|
|
},
|
|
|
|
renderPopup() {
|
|
if (!this.popup) return;
|
|
this.popup.innerHTML = "";
|
|
|
|
if (this.filteredItems.length === 0) {
|
|
const div = document.createElement("div");
|
|
div.textContent = "No matches";
|
|
div.style.padding = "4px";
|
|
div.style.color = "#888";
|
|
this.popup.appendChild(div);
|
|
return;
|
|
}
|
|
|
|
this.filteredItems.forEach((item, idx) => {
|
|
const div = document.createElement("div");
|
|
div.textContent = item.display;
|
|
div.style.padding = "4px 8px";
|
|
div.style.cursor = "pointer";
|
|
div.style.whiteSpace = "nowrap";
|
|
div.style.overflow = "hidden";
|
|
div.style.textOverflow = "ellipsis";
|
|
|
|
if (idx === this.selectedIndex) {
|
|
div.style.backgroundColor = "#444";
|
|
div.style.color = "#fff";
|
|
|
|
// Show preview if it's a long item
|
|
if (item.type === "item" && item.value.length > 30) {
|
|
this.showPreview(item.value);
|
|
} else {
|
|
this.hidePreview();
|
|
}
|
|
}
|
|
|
|
div.onclick = () => {
|
|
this.selectedIndex = idx;
|
|
this.selectItem(this.activeInput); // We need reference to input
|
|
};
|
|
|
|
this.popup.appendChild(div);
|
|
});
|
|
|
|
// Keep active input ref
|
|
const activeElement = document.activeElement;
|
|
if (activeElement && activeElement.tagName === "TEXTAREA") {
|
|
this.activeInput = activeElement;
|
|
}
|
|
},
|
|
|
|
previewBox: null,
|
|
|
|
showPreview(text) {
|
|
if (!this.previewBox) {
|
|
this.previewBox = document.createElement("div");
|
|
Object.assign(this.previewBox.style, {
|
|
position: "absolute",
|
|
zIndex: "10000",
|
|
backgroundColor: "#333",
|
|
border: "1px solid #666",
|
|
padding: "8px",
|
|
maxWidth: "300px",
|
|
color: "#eee",
|
|
fontSize: "11px",
|
|
pointerEvents: "none"
|
|
});
|
|
document.body.appendChild(this.previewBox);
|
|
}
|
|
|
|
this.previewBox.textContent = text;
|
|
const rect = this.popup.getBoundingClientRect();
|
|
this.previewBox.style.left = `${rect.right + 5}px`;
|
|
this.previewBox.style.top = `${rect.top}px`;
|
|
this.previewBox.style.display = "block";
|
|
},
|
|
|
|
hidePreview() {
|
|
if (this.previewBox) {
|
|
this.previewBox.style.display = "none";
|
|
}
|
|
},
|
|
|
|
selectItem(input) {
|
|
if (!input) return;
|
|
const item = this.filteredItems[this.selectedIndex];
|
|
if (!item) return;
|
|
|
|
const val = input.value;
|
|
const cursor = input.selectionStart;
|
|
const trigger = this.triggerChar;
|
|
const lastTriggerIndex = val.lastIndexOf(trigger, cursor - 1);
|
|
|
|
let textToInsert = "";
|
|
|
|
if (item.type === "category") {
|
|
// Insert category name + colon
|
|
// e.g. #style:
|
|
textToInsert = item.display + ":";
|
|
this.currentCategory = item.value;
|
|
// Don't close popup, just update to items
|
|
// We need to update the input value first
|
|
|
|
// Replace everything from trigger to cursor with trigger + textToInsert
|
|
const before = val.substring(0, lastTriggerIndex + 1); // includes trigger
|
|
const after = val.substring(cursor);
|
|
input.value = before + textToInsert + after;
|
|
|
|
// Move cursor
|
|
const newCursor = lastTriggerIndex + 1 + textToInsert.length;
|
|
input.setSelectionRange(newCursor, newCursor);
|
|
|
|
// Trigger input event manually to update state
|
|
this.handleInput({ data: null }, input, null);
|
|
return;
|
|
|
|
} else if (item.type === "random") {
|
|
// Pick random item from current category
|
|
const items = this.snippetsCache[this.currentCategory] || [];
|
|
if (items.length > 0) {
|
|
textToInsert = items[Math.floor(Math.random() * items.length)];
|
|
}
|
|
} else {
|
|
// Normal item
|
|
textToInsert = item.value;
|
|
}
|
|
|
|
// Replace
|
|
const before = val.substring(0, lastTriggerIndex); // remove trigger
|
|
const after = val.substring(cursor);
|
|
|
|
// If we are replacing a category query like #style:something, we need to find the start of #
|
|
// Actually, if we are in item mode, the text is "#style:query".
|
|
// We want to replace the whole "#style:query" with "textToInsert".
|
|
|
|
input.value = before + textToInsert + after;
|
|
|
|
// Move cursor
|
|
const newCursor = before.length + textToInsert.length;
|
|
input.setSelectionRange(newCursor, newCursor);
|
|
|
|
this.closePopup();
|
|
this.hidePreview();
|
|
}
|
|
});
|