diff --git a/tests/js/auto-formatter.test.js b/tests/js/auto-formatter.test.js new file mode 100644 index 0000000..a4beff4 --- /dev/null +++ b/tests/js/auto-formatter.test.js @@ -0,0 +1,90 @@ +import { + formatPromptText, + __test__ +} from "../../web/js/auto-formatter.js"; + +const { + shouldAutoFormat +} = __test__; + +describe('AutoFormatter Functions', () => { + + describe('shouldAutoFormat', () => { + const mockNodeInfo = (nodeType, inputName) => ({ + nodeType, + inputName + }); + + test('should return false for empty text', () => { + expect(shouldAutoFormat('', mockNodeInfo('CLIPTextEncode', 'text'))).toBe(false); + expect(shouldAutoFormat(' ', mockNodeInfo('CLIPTextEncode', 'text'))).toBe(false); + }); + + test('should return false for blocklisted nodes', () => { + expect(shouldAutoFormat('some text,', mockNodeInfo('Power Puter (rgthree)', 'code'))).toBe(false); + expect(shouldAutoFormat('some text,', mockNodeInfo('LoraLoaderBlockWeight //Inspire', 'block_vector'))).toBe(false); + }); + + test('should return false for numeric data or single-letter placeholders', () => { + expect(shouldAutoFormat('0,0,0,1,1,1', mockNodeInfo('CLIPTextEncode', 'text'))).toBe(false); + expect(shouldAutoFormat('0.5, -1.2, 0.8', mockNodeInfo('CLIPTextEncode', 'text'))).toBe(false); + expect(shouldAutoFormat('A,B,R', mockNodeInfo('CLIPTextEncode', 'text'))).toBe(false); + expect(shouldAutoFormat('X, 1.5, Y', mockNodeInfo('CLIPTextEncode', 'text'))).toBe(false); + }); + + test('should return true for text with "word + comma" pattern', () => { + expect(shouldAutoFormat('1girl, blue hair,', mockNodeInfo('CLIPTextEncode', 'text'))).toBe(true); + expect(shouldAutoFormat('tag1, tag2', mockNodeInfo('CLIPTextEncode', 'text'))).toBe(true); + }); + + test('should return false if "word + comma" pattern is not found', () => { + expect(shouldAutoFormat('hello world', mockNodeInfo('CLIPTextEncode', 'text'))).toBe(false); + expect(shouldAutoFormat('tag1 tag2', mockNodeInfo('CLIPTextEncode', 'text'))).toBe(false); + }); + }); + + describe('formatPromptText', () => { + test('should format text by adding comma and space after tags', () => { + const input = 'tag1,tag2,tag3'; + const expected = 'tag1, tag2, tag3, '; + expect(formatPromptText(input)).toBe(expected); + }); + + test('should remove extra spaces around tags', () => { + const input = ' tag1 , tag2 '; + const expected = 'tag1, tag2, '; + expect(formatPromptText(input)).toBe(expected); + }); + + test('should preserve special syntax like weights', () => { + const input = '(tag1:1.2), [tag2]'; + // Note: The current implementation splits by comma. + // If the input is "(tag1:1.2), [tag2]", it splits into "(tag1:1.2)" and "[tag2]". + // Then joins with ", ". + const expected = '(tag1:1.2), [tag2], '; + expect(formatPromptText(input)).toBe(expected); + }); + + test('should handle multiple lines', () => { + const input = 'tag1, tag2\ntag3, tag4'; + const expected = 'tag1, tag2, \ntag3, tag4, '; + expect(formatPromptText(input)).toBe(expected); + }); + + test('should keep empty lines unchanged', () => { + const input = 'tag1, tag2\n\ntag3, tag4'; + const expected = 'tag1, tag2, \n\ntag3, tag4, '; + expect(formatPromptText(input)).toBe(expected); + }); + + test('should handle empty input', () => { + expect(formatPromptText('')).toBe(''); + expect(formatPromptText(null)).toBe(null); + expect(formatPromptText(undefined)).toBe(undefined); + }); + + test('should handle input with only spaces', () => { + expect(formatPromptText(' ')).toBe(' '); + }); + }); +}); diff --git a/web/js/auto-formatter.js b/web/js/auto-formatter.js index f48b3d1..a47fc01 100644 --- a/web/js/auto-formatter.js +++ b/web/js/auto-formatter.js @@ -1,4 +1,65 @@ import { settingValues } from './settings.js'; +import { NodeInfo } from './node-info.js'; + + +/** + * Determines if the text content should be auto-formatted. + * + * Format conditions: + * 1. Skip formatting if node is in blocklist + * 2. Skip formatting if text contains only numbers or single letters (separated by commas) + * 3. Format if text contains "word + comma" pattern at least twice + * 4. Otherwise, don't format + * + * @param {NodeInfo} nodeInfo - The node information. + * @returns {boolean} - True if the text should be formatted, false otherwise. + */ +function shouldAutoFormat(text, nodeInfo) { + if (!text || text.trim().length === 0) return false; + + // 1. Check if the node name is in the blocklist + const blocklist = [ + ["Power Puter (rgthree)", "code"], + ["LoraLoaderBlockWeight //Inspire", "block_vector"] + ]; + + const isBlocklisted = blocklist.some(([type, input]) => + type === nodeInfo.nodeType && input === nodeInfo.inputName + ); + + if (isBlocklisted) { + // console.debug(`[Autocomplete-Plus] auto-formatter on blur => nodeType: ${nodeInfo.nodeType}, inputName: ${nodeInfo.inputName} => blocklisted`); + return false; + } else { + // console.debug(`[Autocomplete-Plus] auto-formatter on blur => nodeType: ${nodeInfo.nodeType}, inputName: ${nodeInfo.inputName}`); + } + + + const trimmedText = text.trim(); + + // 2. Check if the text is purely numeric data or single-letter placeholders with commas + // (e.g., "0,0,0,1,1,1" or "0.5, -1.2, 0.8" or "A,B,R" for LoRA Block Weight) + const elements = trimmedText.split(',').map(el => el.trim()); + const isSingleLetterOrNumeric = elements.every(el => { + if (/^[A-Za-z]$/.test(el)) return true; + if (/^-?\d+(\.\d+)?$/.test(el)) return true; + return false; + }); + + if (isSingleLetterOrNumeric && elements.length > 0) { + return false; // Don't format numeric data or single-letter template patterns + } + + // 3. Check if the text contains the pattern "word + comma" + const wordCommaPattern = /\w+\s*,/g; + const matches = trimmedText.match(wordCommaPattern); + + if (matches == null) { + return false; + } + + return true; // Text should be formatted +} /** * Format the prompt text: add a comma and space after each tag, and remove extra spaces. @@ -79,12 +140,19 @@ export class AutoFormatterEventHandler { * Handle blur event to trigger auto-formatting * @param {Event} event - The blur event */ - handleBlur(event) { + handleBlur(event, nodeInfo) { if ( settingValues.enableAutoFormat && + settingValues.autoFormatTrigger === 'auto' && event.target.tagName === 'TEXTAREA' ) { - formatTextareaOnBlur(event.target); + const textarea = event.target; + const text = textarea.value; + + // Check if the content should be auto-formatted + if (shouldAutoFormat(text, nodeInfo)) { + formatTextareaOnBlur(textarea); + } } } @@ -95,4 +163,37 @@ export class AutoFormatterEventHandler { handleKeyUp(event) { } handleMouseMove(event) { } handleClick(event) { } + + /** + * Format textarea content via manual trigger (e.g., keyboard shortcut) + * @param {HTMLTextAreaElement} textarea - The textarea element to format + * @param {NodeInfo} nodeInfo - The node information + * @returns {boolean} - True if formatting was performed, false otherwise + */ + applyFormatTextarea(textarea, nodeInfo) { + if (!textarea || textarea.tagName !== 'TEXTAREA') { + return false; + } + + if (!settingValues.enableAutoFormat) { + return false; + } + + const text = textarea.value; + + if (shouldAutoFormat(text, nodeInfo)) { + formatTextareaOnBlur(textarea); + return true; + } + + return false; + } } + +// Export functions for testing +const isTestEnvironment = typeof process !== 'undefined' && process.env.NODE_ENV === 'test'; +export const __test__ = isTestEnvironment + ? { + shouldAutoFormat + } + : undefined; diff --git a/web/js/main.js b/web/js/main.js index e6e3836..ad444a6 100644 --- a/web/js/main.js +++ b/web/js/main.js @@ -7,24 +7,26 @@ 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() { - const autocompleteEventHandler = new AutocompleteEventHandler(); - const relatedTagsEventHandler = new RelatedTagsEventHandler(); - const autoFormatterEventHandler = new AutoFormatterEventHandler(); - const attachedElements = new WeakSet(); // Keep track of elements that have listeners attached - // Function to attach listeners - function attachListeners(element) { - if (attachedElements.has(element)) return; // Prevent double attachment + function attachListeners(element, nodeInfo) { + if (attachedElementNodeInfoMap.has(element)) return; // Prevent double attachment element.addEventListener('input', handleInput); element.addEventListener('focus', handleFocus); @@ -37,7 +39,7 @@ function initializeEventHandlers() { element.addEventListener('mousemove', handleMouseMove); element.addEventListener('click', handleClick); - attachedElements.add(element); // Mark as attached + attachedElementNodeInfoMap.set(element, nodeInfo); // Mark as attached and store node info } // Attempt Widget Override as the primary method @@ -50,12 +52,14 @@ function initializeEventHandlers() { // Check if the widget has an inputEl 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 && result.widget.inputEl && result.widget.inputEl.tagName === 'TEXTAREA') { + if (result && result.widget + && result.widget.inputEl && result.widget.inputEl.tagName === 'TEXTAREA' && !result.widget.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; - attachListeners(result.widget.inputEl); + const nodeInfo = new NodeInfo(node.comfyClass || node.constructor.name, inputName); + attachListeners(result.widget.inputEl, nodeInfo); } return result; }; @@ -75,9 +79,11 @@ function initializeEventHandlers() { targetSelectors.forEach(selector => { // Check if the added node itself matches or contains matching elements if (node.matches(selector)) { - attachListeners(node); + attachListeners(node, new NodeInfo('Fallback', 'unknown')); } else { - node.querySelectorAll(selector).forEach(attachListeners); + node.querySelectorAll(selector).forEach(el => { + attachListeners(el, new NodeInfo('Fallback', 'unknown')); + }); } }); } @@ -87,49 +93,72 @@ function initializeEventHandlers() { // Initial scan for existing elements targetSelectors.forEach(selector => { - document.querySelectorAll(selector).forEach(attachListeners); + 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); + 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); } - // New event handler for mousemove to show related tags on hover function handleMouseMove(event) { autocompleteEventHandler.handleMouseMove(event); relatedTagsEventHandler.handleMouseMove(event); + autoFormatterEventHandler.handleMouseMove(event); } - // New event handler for click to show related tags function handleClick(event) { autocompleteEventHandler.handleClick(event); relatedTagsEventHandler.handleClick(event); + autoFormatterEventHandler.handleClick(event); } } @@ -247,6 +276,46 @@ app.registerExtension({ 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 --- @@ -421,6 +490,18 @@ app.registerExtension({ }, // --- Auto format settings --- + { + 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', diff --git a/web/js/node-info.js b/web/js/node-info.js new file mode 100644 index 0000000..f112d38 --- /dev/null +++ b/web/js/node-info.js @@ -0,0 +1,14 @@ +/** + * Class to hold information about the node attached to an input element. + * Used to control behavior based on node information. + */ +export class NodeInfo { + /** + * @param {string} nodeType - The type/class name of the node + * @param {string} inputName - The name of the input widget + */ + constructor(nodeType, inputName) { + this.nodeType = nodeType; + this.inputName = inputName; + } +} diff --git a/web/js/settings.js b/web/js/settings.js index 177b620..0cbc7b3 100644 --- a/web/js/settings.js +++ b/web/js/settings.js @@ -23,6 +23,7 @@ export const settingValues = { // Auto format settings enableAutoFormat: true, + autoFormatTrigger: 'auto', // Options: 'auto' (format on blur + shortcut), 'manual' (shortcut only) // Internal logic settings