From dcc7bd33b53f3ad66bab119e47b3edf2a448e7cd Mon Sep 17 00:00:00 2001 From: Will Miao Date: Sat, 28 Mar 2026 20:21:23 +0800 Subject: [PATCH] fix(autocomplete): make accept key behavior configurable (#863) --- .../components/autocomplete.behavior.test.js | 154 ++++++++++++++++++ web/comfyui/autocomplete.js | 130 ++++++++++++++- web/comfyui/settings.js | 57 ++++++- 3 files changed, 333 insertions(+), 8 deletions(-) diff --git a/tests/frontend/components/autocomplete.behavior.test.js b/tests/frontend/components/autocomplete.behavior.test.js index ad87d546..51dd67ea 100644 --- a/tests/frontend/components/autocomplete.behavior.test.js +++ b/tests/frontend/components/autocomplete.behavior.test.js @@ -69,6 +69,9 @@ describe('AutoComplete widget interactions', () => { if (key === 'loramanager.autocomplete_append_comma') { return true; } + if (key === 'loramanager.autocomplete_accept_key') { + return 'both'; + } if (key === 'loramanager.prompt_tag_autocomplete') { return true; } @@ -210,6 +213,157 @@ describe('AutoComplete widget interactions', () => { expect(insertSelectionSpy).toHaveBeenCalledWith('example_completion'); }); + it('prefers the latest best match when Tab is pressed before debounced suggestions fully refresh', async () => { + caretHelperInstance.getBeforeCursor.mockReturnValue('loop'); + + const input = document.createElement('textarea'); + input.value = 'loop'; + input.selectionStart = input.value.length; + input.focus = vi.fn(); + input.setSelectionRange = vi.fn(); + document.body.append(input); + + const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); + const autoComplete = new AutoComplete(input,'prompt', { showPreview: false, minChars: 1 }); + + autoComplete.searchType = 'custom_words'; + autoComplete.items = [ + { tag_name: 'looking_to_the_side', category: 0, post_count: 1000 }, + { tag_name: 'loop', category: 0, post_count: 500 }, + ]; + autoComplete.currentSearchTerm = 'loo'; + autoComplete.selectedIndex = 0; + autoComplete.isVisible = true; + const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue(); + + const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }); + input.dispatchEvent(tabEvent); + + expect(tabEvent.defaultPrevented).toBe(true); + expect(autoComplete.selectedIndex).toBe(1); + expect(insertSelectionSpy).toHaveBeenCalledWith('loop'); + }); + + it('accepts the first available suggestion with Tab even if delayed auto-selection has not happened yet', async () => { + caretHelperInstance.getBeforeCursor.mockReturnValue('loop'); + + const input = document.createElement('textarea'); + input.value = 'loop'; + input.selectionStart = input.value.length; + input.focus = vi.fn(); + input.setSelectionRange = vi.fn(); + document.body.append(input); + + const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); + const autoComplete = new AutoComplete(input,'custom_words', { showPreview: false }); + + autoComplete.items = ['loop']; + autoComplete.selectedIndex = -1; + autoComplete.isVisible = true; + const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue(); + + const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }); + input.dispatchEvent(tabEvent); + + expect(tabEvent.defaultPrevented).toBe(true); + expect(autoComplete.selectedIndex).toBe(0); + expect(insertSelectionSpy).toHaveBeenCalledWith('loop'); + }); + + it('only accepts with Tab when autocomplete accept key is set to tab_only', async () => { + settingGetMock.mockImplementation((key) => { + if (key === 'loramanager.autocomplete_append_comma') { + return true; + } + if (key === 'loramanager.autocomplete_accept_key') { + return 'tab_only'; + } + if (key === 'loramanager.prompt_tag_autocomplete') { + return true; + } + if (key === 'loramanager.tag_space_replacement') { + return false; + } + return undefined; + }); + + caretHelperInstance.getBeforeCursor.mockReturnValue('example'); + + const input = document.createElement('textarea'); + input.value = 'example'; + input.selectionStart = input.value.length; + input.focus = vi.fn(); + input.setSelectionRange = vi.fn(); + document.body.append(input); + + const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); + const autoComplete = new AutoComplete(input,'custom_words', { showPreview: false }); + + autoComplete.items = ['example_completion']; + autoComplete.selectedIndex = 0; + autoComplete.isVisible = true; + const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue(); + + const enterEvent = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }); + input.dispatchEvent(enterEvent); + + expect(enterEvent.defaultPrevented).toBe(false); + expect(insertSelectionSpy).not.toHaveBeenCalled(); + + const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }); + input.dispatchEvent(tabEvent); + + expect(tabEvent.defaultPrevented).toBe(true); + expect(insertSelectionSpy).toHaveBeenCalledWith('example_completion'); + }); + + it('only accepts with Enter when autocomplete accept key is set to enter_only', async () => { + settingGetMock.mockImplementation((key) => { + if (key === 'loramanager.autocomplete_append_comma') { + return true; + } + if (key === 'loramanager.autocomplete_accept_key') { + return 'enter_only'; + } + if (key === 'loramanager.prompt_tag_autocomplete') { + return true; + } + if (key === 'loramanager.tag_space_replacement') { + return false; + } + return undefined; + }); + + caretHelperInstance.getBeforeCursor.mockReturnValue('example'); + + const input = document.createElement('textarea'); + input.value = 'example'; + input.selectionStart = input.value.length; + input.focus = vi.fn(); + input.setSelectionRange = vi.fn(); + document.body.append(input); + + const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); + const autoComplete = new AutoComplete(input,'custom_words', { showPreview: false }); + + autoComplete.items = ['example_completion']; + autoComplete.selectedIndex = 0; + autoComplete.isVisible = true; + const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue(); + + const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }); + input.dispatchEvent(tabEvent); + + expect(tabEvent.defaultPrevented).toBe(false); + expect(insertSelectionSpy).not.toHaveBeenCalled(); + + const enterEvent = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }); + input.dispatchEvent(enterEvent); + + expect(enterEvent.defaultPrevented).toBe(true); + expect(insertSelectionSpy).toHaveBeenCalledWith('example_completion'); + }); + it('does not intercept Tab when the dropdown is not visible', async () => { caretHelperInstance.getBeforeCursor.mockReturnValue('example'); diff --git a/web/comfyui/autocomplete.js b/web/comfyui/autocomplete.js index f8275bed..2e682988 100644 --- a/web/comfyui/autocomplete.js +++ b/web/comfyui/autocomplete.js @@ -3,6 +3,7 @@ import { app } from "../../scripts/app.js"; import { TextAreaCaretHelper } from "./textarea_caret_helper.js"; import { getAutocompleteAppendCommaPreference, + getAutocompleteAcceptKeyPreference, getPromptTagAutocompletePreference, getTagSpaceReplacementPreference, } from "./settings.js"; @@ -121,6 +122,24 @@ function formatAutocompleteInsertion(text = '') { return getAutocompleteAppendCommaPreference() ? `${trimmed},` : `${trimmed} `; } +function shouldAcceptAutocompleteKey(key) { + const mode = getAutocompleteAcceptKeyPreference(); + + if (mode === 'tab_only') { + return key === 'Tab'; + } + + if (mode === 'enter_only') { + return key === 'Enter'; + } + + return key === 'Tab' || key === 'Enter'; +} + +function normalizeAutocompleteMatchText(text = '') { + return text.toLowerCase().replace(/[-_\s']/g, ''); +} + const AUTOCOMPLETE_METADATA_VERSION = 1; function createAutocompleteMetadataBase(textWidgetName = 'text') { @@ -873,6 +892,92 @@ class AutoComplete { return { matched: false, isExactMatch: false }; } + _getLiveSearchTermForAcceptance() { + const rawSearchTerm = this.getSearchTerm(this.inputElement.value); + + if (this.modelType === 'embeddings') { + const match = rawSearchTerm.match(/^emb:(.*)$/i); + return (match?.[1] || '').trim(); + } + + if (this.modelType === 'prompt') { + const embeddingMatch = rawSearchTerm.match(/^emb:(.*)$/i); + if (embeddingMatch) { + return (embeddingMatch[1] || '').trim(); + } + + const commandResult = this._parseCommandInput(rawSearchTerm); + return commandResult.searchTerm ?? rawSearchTerm; + } + + return rawSearchTerm; + } + + _getPreferredSelectedIndex(searchTerm = '') { + if (!this.items?.length) { + return -1; + } + + if (this.showingCommands) { + if (this.selectedIndex >= 0 && this.selectedIndex < this.items.length) { + return this.selectedIndex; + } + return 0; + } + + const trimmedSearchTerm = searchTerm.trim(); + if (!trimmedSearchTerm) { + if (this.selectedIndex >= 0 && this.selectedIndex < this.items.length) { + return this.selectedIndex; + } + return 0; + } + + const searchLower = trimmedSearchTerm.toLowerCase(); + const normalizedSearch = normalizeAutocompleteMatchText(trimmedSearchTerm); + let bestIndex = -1; + let bestScore = -Infinity; + + this.items.forEach((item, index) => { + const displayText = this._getDisplayText(item); + const textLower = displayText.toLowerCase(); + const normalizedText = normalizeAutocompleteMatchText(displayText); + let score = -1; + + if (textLower === searchLower) { + score = 5000; + } else if (normalizedText === normalizedSearch) { + score = 4500; + } else if (textLower.startsWith(searchLower)) { + score = 4000; + } else if (normalizedText.startsWith(normalizedSearch)) { + score = 3500; + } else if (textLower.includes(searchLower)) { + score = 3000; + } else if (normalizedText.includes(normalizedSearch)) { + score = 2500; + } + + if (score > -1) { + score -= index; + if (score > bestScore) { + bestScore = score; + bestIndex = index; + } + } + }); + + if (bestIndex !== -1) { + return bestIndex; + } + + if (this.selectedIndex >= 0 && this.selectedIndex < this.items.length) { + return this.selectedIndex; + } + + return 0; + } + async search(term = '', endpoint = null) { try { this.currentSearchTerm = term; @@ -1135,9 +1240,9 @@ class AutoComplete { lastChild.style.borderBottom = 'none'; } - // Auto-select first item + // Auto-select immediately so accept keys remain stable. if (this.items.length > 0) { - setTimeout(() => this.selectItem(0), 100); + this.selectItem(0); } // Update virtual scroll height for virtual scrolling mode @@ -1300,11 +1405,10 @@ class AutoComplete { } } - // Auto-select the first item with a small delay + // Auto-select immediately so accept keys do not fall through + // to native focus traversal while the dropdown is visible. if (this.items.length > 0) { - setTimeout(() => { - this.selectItem(0); - }, 100); + this.selectItem(0); } } @@ -1989,8 +2093,20 @@ class AutoComplete { case 'Enter': case 'Tab': - e.preventDefault(); + if (!shouldAcceptAutocompleteKey(e.key)) { + break; + } + + { + const liveSearchTerm = this._getLiveSearchTermForAcceptance(); + const preferredIndex = this._getPreferredSelectedIndex(liveSearchTerm); + if (preferredIndex !== -1 && preferredIndex !== this.selectedIndex) { + this.selectItem(preferredIndex); + } + } + if (this.selectedIndex >= 0 && this.selectedIndex < this.items.length) { + e.preventDefault(); if (this.showingCommands) { // Insert command this._insertCommand(this.items[this.selectedIndex].command); diff --git a/web/comfyui/settings.js b/web/comfyui/settings.js index 4a8a1f13..4df11719 100644 --- a/web/comfyui/settings.js +++ b/web/comfyui/settings.js @@ -16,6 +16,12 @@ const PROMPT_TAG_AUTOCOMPLETE_DEFAULT = true; const AUTOCOMPLETE_APPEND_COMMA_SETTING_ID = "loramanager.autocomplete_append_comma"; const AUTOCOMPLETE_APPEND_COMMA_DEFAULT = true; +const AUTOCOMPLETE_ACCEPT_KEY_SETTING_ID = "loramanager.autocomplete_accept_key"; +const AUTOCOMPLETE_ACCEPT_KEY_DEFAULT = "both"; +const AUTOCOMPLETE_ACCEPT_KEY_OPTION_BOTH = "Tab or Enter"; +const AUTOCOMPLETE_ACCEPT_KEY_OPTION_TAB_ONLY = "Tab only"; +const AUTOCOMPLETE_ACCEPT_KEY_OPTION_ENTER_ONLY = "Enter only"; + const TAG_SPACE_REPLACEMENT_SETTING_ID = "loramanager.tag_space_replacement"; const TAG_SPACE_REPLACEMENT_DEFAULT = false; @@ -186,6 +192,41 @@ const getAutocompleteAppendCommaPreference = (() => { }; })(); +const getAutocompleteAcceptKeyPreference = (() => { + let settingsUnavailableLogged = false; + + return () => { + const settingManager = app?.extensionManager?.setting; + if (!settingManager || typeof settingManager.get !== "function") { + if (!settingsUnavailableLogged) { + console.warn("LoRA Manager: settings API unavailable, using default autocomplete accept key setting."); + settingsUnavailableLogged = true; + } + return AUTOCOMPLETE_ACCEPT_KEY_DEFAULT; + } + + try { + const value = settingManager.get(AUTOCOMPLETE_ACCEPT_KEY_SETTING_ID); + if (value === AUTOCOMPLETE_ACCEPT_KEY_OPTION_TAB_ONLY) { + return "tab_only"; + } + if (value === AUTOCOMPLETE_ACCEPT_KEY_OPTION_ENTER_ONLY) { + return "enter_only"; + } + if (value === AUTOCOMPLETE_ACCEPT_KEY_OPTION_BOTH || value == null) { + return AUTOCOMPLETE_ACCEPT_KEY_DEFAULT; + } + return value; + } catch (error) { + if (!settingsUnavailableLogged) { + console.warn("LoRA Manager: unable to read autocomplete accept key setting, using default.", error); + settingsUnavailableLogged = true; + } + return AUTOCOMPLETE_ACCEPT_KEY_DEFAULT; + } + }; +})(); + const getTagSpaceReplacementPreference = (() => { let settingsUnavailableLogged = false; @@ -332,7 +373,20 @@ app.registerExtension({ type: "boolean", defaultValue: AUTOCOMPLETE_APPEND_COMMA_DEFAULT, tooltip: "When enabled, accepted autocomplete suggestions append ', ' to the inserted text.", - category: ["LoRA Manager", "Autocomplete", "Behavior"], + category: ["LoRA Manager", "Autocomplete", "Append comma"], + }, + { + id: AUTOCOMPLETE_ACCEPT_KEY_SETTING_ID, + name: "Autocomplete accept key", + type: "combo", + options: [ + AUTOCOMPLETE_ACCEPT_KEY_OPTION_BOTH, + AUTOCOMPLETE_ACCEPT_KEY_OPTION_TAB_ONLY, + AUTOCOMPLETE_ACCEPT_KEY_OPTION_ENTER_ONLY, + ], + defaultValue: AUTOCOMPLETE_ACCEPT_KEY_OPTION_BOTH, + tooltip: "Choose which key accepts the selected autocomplete suggestion. Keys not selected here keep their normal textarea behavior.", + category: ["LoRA Manager", "Autocomplete", "Accept key"], }, { id: TAG_SPACE_REPLACEMENT_SETTING_ID, @@ -451,6 +505,7 @@ export { getWheelSensitivity, getAutoPathCorrectionPreference, getAutocompleteAppendCommaPreference, + getAutocompleteAcceptKeyPreference, getPromptTagAutocompletePreference, getTagSpaceReplacementPreference, getUsageStatisticsPreference,