import { app } from "/scripts/app.js"; import { $el } from "/scripts/ui.js"; import { ComfyWidgets } from "/scripts/widgets.js"; import { settingValues } from "./settings.js"; import { loadCSS } from "./utils.js"; import { TagSource, loadDataAsync } from "./data.js"; import { AutocompleteEventHandler } from "./autocomplete.js"; import { RelatedTagsEventHandler } from "./related-tags.js"; import { AutoFormatterEventHandler } from "./auto-formatter.js"; import { NodeInfo } from "./node-info.js"; // --- Constants --- const id = "AutocompletePlus"; const name = "Autocomplete Plus"; // --- Module-level variables --- const autocompleteEventHandler = new AutocompleteEventHandler(); const relatedTagsEventHandler = new RelatedTagsEventHandler(); const autoFormatterEventHandler = new AutoFormatterEventHandler(); const attachedElementNodeInfoMap = new WeakMap(); // Map to track attached elements and their node info // --- Functions --- /** * Initialize event handlers for the autocomplete and related tags features. */ function initializeEventHandlers() { // Function to attach listeners function attachListeners(element, nodeInfo) { if (attachedElementNodeInfoMap.has(element)) return; // Prevent double attachment element.addEventListener('input', handleInput); element.addEventListener('focus', handleFocus); element.addEventListener('blur', handleBlur); element.addEventListener('keydown', handleKeyDown); element.addEventListener('keyup', handleKeyUp); // element.addEventListener('keypress', handleKeyPress); // keypress is deprecated // Add new event listeners for related tags feature element.addEventListener('mousemove', handleMouseMove); element.addEventListener('click', handleClick); attachedElementNodeInfoMap.set(element, nodeInfo); // Mark as attached and store node info } // Attempt Widget Override as the primary method // The original ComfyWidgets.STRING arguments are (node, inputName, inputData, app) // inputData is often an array like [type, config] if (ComfyWidgets && ComfyWidgets.STRING) { const originalStringWidget = ComfyWidgets.STRING; ComfyWidgets.STRING = function (node, inputName, inputData, appInstance) { // Use appInstance to avoid conflict with global app const result = originalStringWidget.apply(this, arguments); // Check if the widget has an element and if it's a TEXTAREA // This is to ensure we are targeting multiline text inputs, related to '.comfy-multiline-input' if (result && result.widget) { // fallback for older Comfyui frontend versions const inputEl = result.widget.element ?? result.widget.inputEl; if (inputEl && inputEl.tagName === 'TEXTAREA' && !inputEl.readOnly) { const widgetConfig = inputData && inputData[1] ? inputData[1] : {}; // Future: Add checks for Autocomplete Plus specific configurations if needed // e.g., if (widgetConfig["AutocompletePlus.enabled"] === false) return result; const nodeInfo = new NodeInfo(node.comfyClass || node.constructor.name, inputName); attachListeners(inputEl, nodeInfo); } } return result; }; } if (settingValues._useFallbackAttachmentForEventListener) { // Fallback and for dynamically added elements not caught by widget override: MutationObserver const targetSelectors = [ '.comfy-multiline-input', // Add other selectors if needed ]; const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { mutation.addedNodes.forEach((node) => { if (node.nodeType === Node.ELEMENT_NODE) { targetSelectors.forEach(selector => { // Check if the added node itself matches or contains matching elements if (node.matches(selector)) { attachListeners(node, new NodeInfo('Fallback', 'unknown')); } else { node.querySelectorAll(selector).forEach(el => { attachListeners(el, new NodeInfo('Fallback', 'unknown')); }); } }); } }); }); }); // Initial scan for existing elements targetSelectors.forEach(selector => { document.querySelectorAll(selector).forEach(el => { attachListeners(el, new NodeInfo('Fallback', 'unknown')); }); }); // Start observing the document body for changes observer.observe(document.body, { childList: true, subtree: true }); } /** * Get NodeInfo for the event target element * @param {Event} event - The DOM event * @returns {Object|null} NodeInfo object or undefined if not found */ function getNodeInfo(event) { const nodeInfo = attachedElementNodeInfoMap.get(event.target); if (!nodeInfo) { console.warn('[Autocomplete-Plus] Node info not found for element in ', event.target); return null; } return nodeInfo; } function handleInput(event) { autocompleteEventHandler.handleInput(event); relatedTagsEventHandler.handleInput(event); autoFormatterEventHandler.handleInput(event); } function handleFocus(event) { autocompleteEventHandler.handleFocus(event); relatedTagsEventHandler.handleFocus(event); autoFormatterEventHandler.handleFocus(event); } function handleBlur(event) { const nodeInfo = getNodeInfo(event); // Get node info to pass to auto formatter autocompleteEventHandler.handleBlur(event); relatedTagsEventHandler.handleBlur(event); autoFormatterEventHandler.handleBlur(event, nodeInfo); } function handleKeyDown(event) { autocompleteEventHandler.handleKeyDown(event); relatedTagsEventHandler.handleKeyDown(event); autoFormatterEventHandler.handleKeyDown(event); } function handleKeyUp(event) { autocompleteEventHandler.handleKeyUp(event); relatedTagsEventHandler.handleKeyUp(event); autoFormatterEventHandler.handleKeyUp(event); } function handleMouseMove(event) { autocompleteEventHandler.handleMouseMove(event); relatedTagsEventHandler.handleMouseMove(event); autoFormatterEventHandler.handleMouseMove(event); } function handleClick(event) { autocompleteEventHandler.handleClick(event); relatedTagsEventHandler.handleClick(event); autoFormatterEventHandler.handleClick(event); } } /** * Add Miscellaneous settings to the settings screen */ async function addExtraSettings() { // Function to perform the update check async function performUpdateCheck(checkButton, lastCheckSpan) { checkButton.textContent = "Checking..."; checkButton.disabled = true; app.extensionManager.toast.add({ severity: "info", summary: "Check new CSV", detail: "Checking the CSV updates, see console for more details.", life: 5000 }); try { const response = await fetch('/autocomplete-plus/csv/force-check-updates', { method: 'POST', headers: { 'Content-Type': 'application/json' } }); const result = await response.json(); if (result.success) { // Update last check time display using the response data if (result.last_check_time) { const newLastCheckDate = new Date(result.last_check_time); lastCheckSpan.textContent = "Last checked: " + newLastCheckDate.toLocaleString(); } else { lastCheckSpan.textContent = "Last checked: Never"; } } } catch (error) { console.error("[Autocomplete-Plus] Error during force check:", error); } finally { checkButton.textContent = "Check now"; checkButton.disabled = false; } } // Fetch last check time from API let lastCheckTimeText = "Loading..."; try { const response = await fetch('/autocomplete-plus/csv/last-check-time'); const data = await response.json(); if (data.last_check_time) { const lastCheckDate = new Date(data.last_check_time); lastCheckTimeText = "Last checked: " + lastCheckDate.toLocaleString(); } else { lastCheckTimeText = "Last checked: Never"; } } catch (error) { console.error("[Autocomplete-Plus] Error fetching last check time:", error); lastCheckTimeText = "Last checked: Error loading"; } // Add extra setting for checking new CSV updates // Note: Temporarily comment out until changes to remote files can be detected // app.ui.settings.addSetting({ // id: id + ".check_new_csv", // defaultValue: null, // name: "Check CSV updates", // category: [name, "Misc", "Check new CSV"], // type: () => { // const lastCheckSpan = $el("span", { // textContent: lastCheckTimeText, // className: "text-sm text-gray-500", // style: { // marginRight: "16px" // } // }); // const checkButton = $el("button", { // textContent: "Check now", // className: "p-button p-component p-button-primary", // onclick: async () => { // await performUpdateCheck(checkButton, lastCheckSpan); // } // }); // return $el("div", { // className: "flex-row items-center gap-2", // }, [ // $el("div", { // className: "p-component", // }, [ // lastCheckSpan, // checkButton, // ]), // ]); // } // }); } /** * Registration of the extension */ app.registerExtension({ id: id, name: name, async setup() { initializeEventHandlers(); addExtraSettings(); let rootPath = import.meta.url.replace("js/main.js", ""); loadCSS(rootPath + "css/autocomplete-plus.css"); // Load CSS for autocomplete await loadDataAsync(); }, // --- Commands --- commands: [ { id: id + ".formatPrompt", label: name + ": Format Prompt", function: () => { const activeEl = document.activeElement; if (!activeEl || activeEl.tagName !== 'TEXTAREA') { // console.debug('[Autocomplete-Plus] Format command: No textarea is currently focused'); return; } const nodeInfo = attachedElementNodeInfoMap.get(activeEl); if (!nodeInfo) { console.warn('[Autocomplete-Plus] Format command: Node info not found for focused textarea'); // Use fallback NodeInfo const fallbackNodeInfo = new NodeInfo('Unknown', 'unknown'); autoFormatterEventHandler.applyFormatTextarea(activeEl, fallbackNodeInfo); return; } const formatted = autoFormatterEventHandler.applyFormatTextarea(activeEl, nodeInfo); if (formatted) { // console.debug('[Autocomplete-Plus] Format command: Formatting applied'); } else { // console.debug('[Autocomplete-Plus] Format command: Formatting skipped (blocklisted or not applicable)'); } } } ], // --- Keybindings --- keybindings: [ { combo: { key: "f", alt: true, shift: true }, commandId: id + ".formatPrompt" } ], // One the Settings Screen, displays reverse order in same category settings: [ // --- Tag source Settings --- { id: id + ".TagSource.IconPosition", name: "Tag Source Icon Position", type: "combo", options: ["left", "right", "hidden"], defaultValue: "left", category: [name, "Tag Source", "Tag Source Icon Position"], onChange: (newVal, oldVal) => { settingValues.tagSourceIconPosition = newVal; } }, { id: id + ".TagSource.PrimaryTagSource", name: "Primary source for 'all' Source", tooltip: "When 'Autocomplete Tag Source' is 'all', this determines which source's tags appear first in suggestions.", type: "combo", options: Object.values(TagSource), defaultValue: TagSource.Danbooru, category: [name, "Tag Source", "Prioritize Tag Source"], onChange: (newVal, oldVal) => { settingValues.primaryTagSource = newVal; } }, { id: id + ".TagSource", name: "Autocomplete Tag Source", tooltip: "Select the tag source for autocomplete suggestions. 'all' includes tags from all loaded sources.", type: "combo", options: [...Object.values(TagSource), "all"], defaultValue: "all", category: [name, "Tag Source", "Tag Source"], onChange: (newVal, oldVal) => { settingValues.tagSource = newVal; } }, // --- Autocomplete Settings --- { id: id + ".Autocompletion.UseFastSearch", name: "Use Fast Search", tooltip: "Tag search processing during text input operates faster, improving responsiveness.", type: "boolean", defaultValue: false, category: [name, "Autocompletion", "Use Fast Search"], onChange: (newVal, oldVal) => { settingValues.useFastSearch = newVal; } }, { id: id + ".Autocompletion.EnableModels", name: "Enable Loras and Embeddings", type: "boolean", defaultValue: true, category: [name, "Autocompletion", "Enable Loras and Embeddings"], onChange: (newVal, oldVal) => { settingValues.enableModels = newVal; } }, { id: id + ".Autocompletion.PrefixArtist", name: "String to add before artist tags", tooltip: "Text to prepend when inserting an artist tag via autocomplete.\ne.g. '@' -> '@artist_name'.", type: "text", defaultValue: '', category: [name, "Autocompletion", "String to add before artist tags"], onChange: (newVal, oldVal) => { settingValues.prefixArtist = newVal; } }, { id: id + ".Autocompletion.ReplaceUnderscoreWithSpace", name: "Replace '_' with 'Space'", tooltip: "This setting also affects related tags display.", type: "boolean", defaultValue: true, category: [name, "Autocompletion", "Replace Underscore with Space"], onChange: (newVal, oldVal) => { settingValues.replaceUnderscoreWithSpace = newVal; } }, { id: id + ".Autocompletion.AutoInsertComma", name: "Auto-Insert Comma", tooltip: "Automatically insert a comma after tags when inserting from autocomplete.", type: "boolean", defaultValue: true, category: [name, "Autocompletion", "Auto-Insert Comma"], onChange: (newVal, oldVal) => { settingValues.autoInsertComma = newVal; } }, { id: id + ".Autocompletion.MaxSuggestions", name: "Max suggestions", type: "slider", attrs: { min: 5, max: 50, step: 5, }, defaultValue: 10, category: [name, "Autocompletion", "Max suggestions"], onChange: (newVal, oldVal) => { settingValues.maxSuggestions = newVal; } }, { id: id + ".Autocompletion.Enable", name: "Enable Autocomplete", type: "boolean", defaultValue: true, category: [name, "Autocompletion", "Enable Autocomplete"], onChange: (newVal, oldVal) => { settingValues.enabled = newVal; } }, // --- Related Tags Settings --- { id: id + ".RelatedTags.RelatedTagsTriggerMode", name: "Related Tags Trigger Mode", tooltip: "Which action will trigger displaying related tags for the entered tag (click only, Ctrl+click).", type: "combo", options: ["click", "ctrl+Click"], defaultValue: "click", category: [name, "Related Tags", "Trigger Mode"], onChange: (newVal, oldVal) => { settingValues.relatedTagsTriggerMode = newVal; } }, { id: id + ".RelatedTags.DisplayPosition", name: "Default Display Position", tooltip: "Display position (relative to Textarea).", type: "combo", options: ["horizontal", "vertical"], defaultValue: "horizontal", category: [name, "Related Tags", "Display Position"], onChange: (newVal, oldVal) => { settingValues.relatedTagsDisplayPosition = newVal; } }, { id: id + ".RelatedTags.MaxRelatedTags", name: "Max related tags", type: "slider", attrs: { min: 5, max: 100, step: 5, }, defaultValue: 20, category: [name, "Related Tags", "Max related tags"], onChange: (newVal, oldVal) => { settingValues.maxRelatedTags = newVal; } }, { id: id + ".RelatedTags.Enable", name: "Enable Related Tags", type: "boolean", defaultValue: true, category: [name, "Related Tags", "Enable Related Tags"], onChange: (newVal, oldVal) => { settingValues.enableRelatedTags = newVal; } }, // --- Display settings --- { id: id + ".Display.HideAlias", name: "Hide Alias", tooltip: "Hide alias column in the autocomplete and related tags display.", type: "boolean", defaultValue: false, category: [name, "Display", "Hide Alias"], onChange: (newVal, oldVal) => { settingValues.hideAlias = newVal; } }, // --- Auto format settings --- { id: id + '.AutoFormatter.TrimSurroundingSpaces', name: 'Trim Surrounding Spaces', tooltip: 'When enabled, trim any blank lines from the beginning and end of the prompt.', type: 'boolean', defaultValue: false, category: [name, 'AutoFormatter', 'Trim Surrounding Spaces'], onChange: (newVal, oldVal) => { settingValues.trimSurroundingSpaces = newVal; }, }, { id: id + '.AutoFormatter.UseTrailingComma', name: 'Use Trailing Comma', tooltip: 'When enabled, ensures all lines end with a trailing comma.\nWhen disabled, removes trailing commas.', type: 'boolean', defaultValue: false, category: [name, 'AutoFormatter', 'Use Trailing Comma'], onChange: (newVal, oldVal) => { settingValues.useTrailingComma = newVal; }, }, { id: id + '.AutoFormatter.Trigger', name: 'Auto Format Trigger', tooltip: 'Auto: Format automatically when leaving text field.\nManual: Format only via keyboard shortcut. default keybind: (Alt+Shift+F)', type: 'combo', options: ['auto', 'manual'], defaultValue: 'auto', category: [name, 'AutoFormatter', 'Auto Format Trigger'], onChange: (newVal, oldVal) => { settingValues.autoFormatTrigger = newVal; }, }, { id: id + '.AutoFormatter.EnableAutoFormat', name: 'Enable Auto Format', type: 'boolean', defaultValue: true, category: [name, 'AutoFormatter', 'Enable Auto Format'], onChange: (newVal, oldVal) => { settingValues.enableAutoFormat = newVal; }, }, ] });