Files
willmiao-ComfyUI-Lora-Manager/vue-widgets/src/components/AutocompleteTextWidget.vue
T
Will Miao 6ba64ebb3c feat(ui): improve /activefilters discoverability on loras nodes
Mirror the /noautocomplete discoverability pattern for the loras\nactive-filters search toggle:\n\n- autocomplete.js: extend the slash-command-list footer and the\n  one-time first-run hint to loras nodes, advertising\n  /activefilters and /noactivefilters\n- lora_loader.js: add an 'Active Filters Search: ON/OFF' entry to the\n  right-click menu of all loras-autocomplete node classes\n- settings.js: broadcast a 'lora-manager:setting-toggled' window event\n  on every setLoraManagerSettingValue write\n- AutocompleteTextWidget.vue: add a persistent filter indicator chip\n  (loras mode only) that reflects and toggles the setting and stays in\n  sync via the setting-toggled event\n- tests: footer/hint/event coverage, context-menu tests for all four\n  node classes, widget indicator tests; rebuild vue-widgets bundle
2026-09-03 22:42:45 +08:00

519 lines
16 KiB
Vue

<template>
<div class="autocomplete-text-widget">
<div class="input-wrapper">
<textarea
ref="textareaRef"
:placeholder="placeholder"
:spellcheck="spellcheck ?? false"
:class="['text-input', { 'vue-dom-mode': isVueDomMode, 'lm-wheel-scrollable': isVueDomMode }]"
:style="maxHeight && isVueDomMode ? { maxHeight: maxHeight + 'px' } : undefined"
data-capture-wheel="true"
@input="onInput"
@wheel="onWheel"
/>
<button
v-if="showClearButton"
type="button"
class="clear-button"
title="Clear text"
@click="clearText"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
<button
v-if="isLorasMode"
type="button"
class="active-filters-toggle"
:class="{ 'is-active': activeFiltersEnabled }"
:title="activeFiltersToggleTitle"
@click="toggleActiveFiltersSearch"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />
</svg>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useAutocomplete } from '@/composables/useAutocomplete'
// @ts-ignore - ComfyUI external module
import { LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID, SETTING_TOGGLED_EVENT_NAME, getLoraActiveFiltersAutocompletePreference, setLoraManagerSettingValue } from '../../../web/comfyui/settings.js'
// Access LiteGraph global for initial mode detection
declare const LiteGraph: { vueNodesMode?: boolean } | undefined
export interface AutocompleteTextWidgetInterface {
inputEl?: HTMLTextAreaElement
callback?: (v: string) => void
onSetValue?: (v: string) => void
metadataWidget?: { value?: unknown }
name?: string
}
const props = defineProps<{
widget: AutocompleteTextWidgetInterface
node: { id: number }
modelType?: 'loras' | 'prompt'
placeholder?: string
showPreview?: boolean
spellcheck?: boolean
maxHeight?: number
}>()
// Reactive ref for Vue DOM mode
const isVueDomMode = ref(typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode === true)
// Listen for mode change events from main.ts
const onModeChange = (event: Event) => {
const customEvent = event as CustomEvent<{ isVueDomMode: boolean }>
isVueDomMode.value = customEvent.detail.isVueDomMode
}
const textareaRef = ref<HTMLTextAreaElement | null>(null)
const hasText = ref(false)
// Show clear button when there is text
const showClearButton = computed(() => hasText.value)
// Active-filters search indicator (loras nodes only). Mirrors the
// loramanager.lora_active_filters_autocomplete setting so users can
// discover and toggle the /activefilters mode without opening the
// dropdown or the settings dialog.
const isLorasMode = (props.modelType ?? 'loras') === 'loras'
const activeFiltersEnabled = ref(false)
const refreshActiveFiltersState = () => {
if (isLorasMode) {
activeFiltersEnabled.value = getLoraActiveFiltersAutocompletePreference()
}
}
const onSettingToggled = (event: Event) => {
const detail = (event as CustomEvent<{ settingId?: string; value?: unknown }>).detail
if (detail?.settingId === LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID) {
activeFiltersEnabled.value = detail.value === true
}
}
const activeFiltersToggleTitle = computed(() =>
activeFiltersEnabled.value
? 'Active Filters Search is ON: suggestions respect the LoRA Manager page filters. Click to disable, or type /noactivefilters.'
: 'Active Filters Search is OFF: suggestions search the full library. Click to enable, or type /activefilters.'
)
const toggleActiveFiltersSearch = async () => {
const newValue = !activeFiltersEnabled.value
try {
const success = await setLoraManagerSettingValue(
LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID,
newValue
)
if (!success) {
throw new Error('settings API unavailable')
}
activeFiltersEnabled.value = newValue
} catch (error) {
console.error('[Lora Manager] Failed to toggle active filters search:', error)
}
}
// Initialize autocomplete with direct ref access
useAutocomplete(
textareaRef,
props.modelType ?? 'loras',
{ showPreview: props.showPreview ?? true }
)
const updateHasTextState = () => {
hasText.value = textareaRef.value ? textareaRef.value.value.length > 0 : false
}
const onInput = (event: Event) => {
// A clear via execCommand captures the full-text selection in the browser's
// undo entry; Ctrl+Z restores the content together with that selection.
// Collapse the caret so the restored text is not left selected.
if ((event as InputEvent).inputType === 'historyUndo') {
const ta = textareaRef.value
if (ta && ta.selectionStart === 0 && ta.selectionEnd === ta.value.length) {
ta.setSelectionRange(ta.value.length, ta.value.length)
}
}
// Update hasText state
updateHasTextState()
// Call widget callback when text changes
if (textareaRef.value && typeof props.widget.callback === 'function') {
props.widget.callback(textareaRef.value.value)
}
}
/**
* Handle mouse wheel events on the textarea.
* Forwards the event to the ComfyUI canvas for zooming when the textarea has no scrollbar,
* or handles pinch-to-zoom gestures.
*
* Logic aligns with ComfyUI's built-in multiline widget:
* src/renderer/extensions/vueNodes/widgets/composables/useStringWidget.ts
*/
const onWheel = (event: WheelEvent) => {
const textarea = textareaRef.value
if (!textarea) return
// Track if we have a vertical scrollbar
const canScrollY = textarea.scrollHeight > textarea.clientHeight
const deltaX = event.deltaX
const deltaY = event.deltaY
const isHorizontal = Math.abs(deltaX) > Math.abs(deltaY)
// Access ComfyUI app from global window
const app = (window as any).app
if (!app || !app.canvas || typeof app.canvas.processMouseWheel !== 'function') {
return
}
// 1. Handle pinch-to-zoom (ctrlKey is true for pinch-to-zoom on most browsers)
if (event.ctrlKey) {
event.preventDefault()
event.stopPropagation()
app.canvas.processMouseWheel(event)
return
}
// 2. Horizontal scroll: pass to canvas (textareas usually don't scroll horizontally)
if (isHorizontal) {
event.preventDefault()
event.stopPropagation()
app.canvas.processMouseWheel(event)
return
}
// 3. Vertical scrolling:
if (canScrollY) {
// If the textarea is scrollable, let it handle the wheel event but stop propagation
// to prevent the canvas from zooming while the user is trying to scroll the text
event.stopPropagation()
} else {
// If the textarea is NOT scrollable, forward the wheel event to the canvas
// so it can trigger zoom in/out
event.preventDefault()
app.canvas.processMouseWheel(event)
}
}
// Handle external value changes (e.g., from "send lora to workflow")
const onExternalValueChange = () => {
updateHasTextState()
}
// Setup widget.onSetValue callback for external value changes
const setupWidgetOnSetValue = () => {
if (props.widget) {
props.widget.onSetValue = (value: string) => {
// The DOM value is already set by setValue, just update our state
hasText.value = value.length > 0
}
}
}
/**
* Clear the textarea contents.
*
* Uses a trusted editing command (execCommand: select all + replace with
* empty string) so the browser records the clear as an undoable edit —
* Ctrl+Z with focus in the textarea restores the cleared text. Falls back
* to a plain programmatic clear when execCommand is unavailable (e.g. jsdom
* test environment), which is not undoable via native Ctrl+Z.
*/
const clearText = () => {
const ta = textareaRef.value
if (!ta || ta.value.length === 0) return
// Select all + replace via a trusted edit command so the browser pushes an
// undo entry that restores the full previous content.
ta.focus()
ta.setSelectionRange(0, ta.value.length)
let ok = false
try {
// Guarded for engines without execCommand (jsdom); some engines also
// throw instead of returning false for unsupported commands.
ok = typeof document.execCommand === 'function' && document.execCommand('insertText', false, '')
} catch {
ok = false
}
if (ok) {
// execCommand fired a trusted 'input' event → onInput already synced
// hasText, called the widget callback, and notified the autocomplete.
hasText.value = false
return
}
// Fallback: execCommand unavailable (jsdom / unsupported browser) — plain
// programmatic clear. The dispatched input event keeps onInput, the widget
// callback, and the autocomplete in sync.
ta.value = ''
ta.dispatchEvent(new Event('input'))
}
onMounted(() => {
// Register textarea reference with widget
if (textareaRef.value) {
props.widget.inputEl = textareaRef.value
;(textareaRef.value as any)._autocompleteHostWidget = props.widget
;(textareaRef.value as any)._autocompleteMetadataWidget = props.widget.metadataWidget
;(textareaRef.value as any)._autocompleteTextWidgetName = props.widget.name ?? 'text'
// Also store on the container element for cloned widgets (subgraph promotion)
// When widgets are promoted to subgraph nodes, the cloned widget shares the same
// DOM element but has its own inputEl property. We store the reference on the
// container so both original and cloned widgets can access it.
const container = textareaRef.value.closest('[id^="autocomplete-text-widget-"]') as HTMLElement
if (container && (container as any).__widgetInputEl) {
(container as any).__widgetInputEl.inputEl = textareaRef.value
}
// Apply pending value from setValue if exists (workflow loading before Vue mount)
const pendingValue = (props.widget as any)._pendingValue
if (pendingValue !== undefined) {
textareaRef.value.value = pendingValue
hasText.value = pendingValue.length > 0
delete (props.widget as any)._pendingValue
// Dispatch event to notify autocomplete of value change
textareaRef.value.dispatchEvent(new CustomEvent('lora-manager:autocomplete-value-changed', {
detail: { value: pendingValue }
}))
}
// Initialize hasText state (already done if pendingValue was applied, but safe to re-check)
if (pendingValue === undefined) {
hasText.value = textareaRef.value.value.length > 0
}
// Listen for external value change events from setValue
textareaRef.value.addEventListener('lora-manager:autocomplete-value-changed', onExternalValueChange as EventListener)
}
// Setup callback for input changes
if (textareaRef.value && typeof props.widget.callback === 'function') {
props.widget.callback(textareaRef.value.value)
}
// Setup widget.onSetValue callback
setupWidgetOnSetValue()
// Active-filters indicator: read initial state and stay in sync with
// slash-command / context-menu toggles dispatched via settings.js
refreshActiveFiltersState()
window.addEventListener(SETTING_TOGGLED_EVENT_NAME, onSettingToggled)
// Listen for custom event dispatched by main.ts
document.addEventListener('lora-manager:vue-mode-change', onModeChange)
})
onUnmounted(() => {
// Clean up textarea reference
if (props.widget.inputEl === textareaRef.value) {
props.widget.inputEl = undefined
}
// Remove external value change event listener
if (textareaRef.value) {
delete (textareaRef.value as any)._autocompleteHostWidget
delete (textareaRef.value as any)._autocompleteMetadataWidget
delete (textareaRef.value as any)._autocompleteTextWidgetName
textareaRef.value.removeEventListener('lora-manager:autocomplete-value-changed', onExternalValueChange as EventListener)
}
// Clean up onSetValue callback
if (props.widget) {
props.widget.onSetValue = undefined
}
// Remove event listener
document.removeEventListener('lora-manager:vue-mode-change', onModeChange)
window.removeEventListener(SETTING_TOGGLED_EVENT_NAME, onSettingToggled)
})
</script>
<style scoped>
.autocomplete-text-widget {
background: transparent;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.input-wrapper {
position: relative;
flex: 1;
display: flex;
width: 100%;
}
/* Canvas mode styles (default) - matches built-in comfy-multiline-input */
.text-input {
flex: 1;
width: 100%;
background-color: var(--comfy-input-bg, #222);
color: var(--input-text, #ddd);
overflow: hidden;
overflow-y: auto;
padding: 2px 2px 24px 2px; /* Reserve bottom space for clear button */
border: none;
border-radius: 0;
box-sizing: border-box;
font-size: var(--comfy-textarea-font-size, 10px);
font-family: monospace;
/* resize:none set here (0,2,0). Overridden to vertical in app mode
by the :global(.\[\&_textarea\]\:resize-y) .text-input rule below. */
resize: none;
}
/* Vue DOM mode styles - matches built-in p-textarea in Vue DOM mode */
.text-input.vue-dom-mode {
background-color: var(--color-charcoal-400, #313235);
color: #fff;
padding: 8px 12px 30px 12px; /* Reserve bottom space for clear button */
margin: 0 0 4px;
border-radius: 8px;
font-size: 12px;
font-family: inherit;
}
.text-input:focus {
outline: none;
}
/* Clear button styles */
.clear-button {
position: absolute;
right: 6px;
bottom: 6px; /* Changed from top to bottom */
width: 18px;
height: 18px;
padding: 0;
margin: 0;
border: none;
border-radius: 50%;
background: rgba(128, 128, 128, 0.5);
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0; /* Hidden by default */
pointer-events: none; /* Not clickable when hidden */
transition: opacity 0.2s ease, background-color 0.2s ease;
z-index: 10;
}
/* Show clear button when hovering over input wrapper */
.input-wrapper:hover .clear-button {
opacity: 0.7;
pointer-events: auto;
}
.clear-button:hover {
opacity: 1;
background: rgba(255, 100, 100, 0.8);
}
.clear-button svg {
width: 12px;
height: 12px;
}
/* Active-filters search indicator (loras nodes only) */
.active-filters-toggle {
position: absolute;
top: 3px;
right: 3px;
width: 16px;
height: 16px;
padding: 2px;
margin: 0;
border: none;
border-radius: 4px;
background: rgba(128, 128, 128, 0.25);
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.7;
transition: opacity 0.2s ease, background-color 0.2s ease, color 0.2s ease;
z-index: 10;
}
.active-filters-toggle:hover {
opacity: 1;
background: rgba(128, 128, 128, 0.45);
color: rgba(255, 255, 255, 0.85);
}
.active-filters-toggle.is-active {
background: rgba(59, 130, 246, 0.35);
color: #7db8ff;
opacity: 1;
}
.active-filters-toggle svg {
width: 11px;
height: 11px;
}
/* Vue DOM mode adjustments for the indicator */
.text-input.vue-dom-mode ~ .active-filters-toggle {
top: 8px;
right: 8px;
width: 20px;
height: 20px;
}
.text-input.vue-dom-mode ~ .active-filters-toggle svg {
width: 13px;
height: 13px;
}
/* Vue DOM mode adjustments for clear button */
.text-input.vue-dom-mode ~ .clear-button {
right: 8px;
bottom: 10px; /* Changed from top to bottom, adjusted for Vue DOM padding */
width: 20px;
height: 20px;
background: rgba(107, 114, 128, 0.6);
}
.text-input.vue-dom-mode ~ .clear-button:hover {
background: oklch(62% 0.18 25);
}
.text-input.vue-dom-mode ~ .clear-button svg {
width: 14px;
height: 14px;
}
</style>
<!--
Non-scoped !important override: scoped .text-input[data-v-xxx] (0,2,0)
beats the app-mode Tailwind rule (0,1,1), so we use !important here to
force resize:vertical only when inside the app-mode widget list.
The data-testid attribute scoping prevents it from leaking into graph
mode. This is the only !important in the widget stylesheets.
-->
<style>
[data-testid="app-mode-widget-item"] textarea,
[data-testid="builder-widget-item"] textarea {
resize: vertical !important;
}
</style>