mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
Add @wheel event listener to AutocompleteTextWidget textarea to enable canvas zoom when textarea has no scrollbar. The onWheel handler: - Forwards pinch-to-zoom (ctrl+wheel) to canvas - Passes horizontal scroll to canvas - When textarea has vertical scrollbar: lets textarea scroll - When textarea has NO scrollbar: forwards to canvas for zoom Behavior now matches ComfyUI built-in multiline widget. Fixes #850
329 lines
9.1 KiB
Vue
329 lines
9.1 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 }]"
|
|
@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>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
|
import { useAutocomplete } from '@/composables/useAutocomplete'
|
|
|
|
// 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
|
|
}
|
|
|
|
const props = defineProps<{
|
|
widget: AutocompleteTextWidgetInterface
|
|
node: { id: number }
|
|
modelType?: 'loras' | 'embeddings' | 'custom_words' | 'prompt'
|
|
placeholder?: string
|
|
showPreview?: boolean
|
|
spellcheck?: boolean
|
|
}>()
|
|
|
|
// 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)
|
|
|
|
// 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 = () => {
|
|
// 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 = (event: CustomEvent<{ value: string }>) => {
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
const clearText = () => {
|
|
if (textareaRef.value) {
|
|
textareaRef.value.value = ''
|
|
hasText.value = false
|
|
textareaRef.value.focus()
|
|
|
|
// Trigger callback with empty value
|
|
if (typeof props.widget.callback === 'function') {
|
|
props.widget.callback('')
|
|
}
|
|
|
|
// Dispatch input event to ensure autocomplete handles the change
|
|
textareaRef.value.dispatchEvent(new Event('input'))
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
// Register textarea reference with widget
|
|
if (textareaRef.value) {
|
|
props.widget.inputEl = textareaRef.value
|
|
|
|
// 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
|
|
}
|
|
|
|
// Initialize hasText state
|
|
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()
|
|
|
|
// 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) {
|
|
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)
|
|
})
|
|
</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 */
|
|
resize: none;
|
|
border: none;
|
|
border-radius: 0;
|
|
box-sizing: border-box;
|
|
font-size: var(--comfy-textarea-font-size, 10px);
|
|
font-family: monospace;
|
|
}
|
|
|
|
/* 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;
|
|
}
|
|
|
|
/* 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>
|