mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
feat: Add LoRA selector modal to Cycler widget
- Add LoraListModal component with search and preview tooltip - Make 'Next LoRA' name clickable to open selector modal - Integrate PreviewTooltip with custom resolver for Vue widgets - Disable selector when prompts are queued (consistent with pause button) - Fix tooltip z-index to display above modal backdrop Fixes issue: users couldn't easily identify which index corresponds to specific LoRA in large lists
This commit is contained in:
@@ -23,6 +23,15 @@
|
||||
@update:repeat-count="handleRepeatCountChange"
|
||||
@toggle-pause="handleTogglePause"
|
||||
@reset-index="handleResetIndex"
|
||||
@open-lora-selector="isModalOpen = true"
|
||||
/>
|
||||
|
||||
<LoraListModal
|
||||
:visible="isModalOpen"
|
||||
:lora-list="cachedLoraList"
|
||||
:current-index="state.currentIndex.value"
|
||||
@close="isModalOpen = false"
|
||||
@select="handleModalSelect"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -30,8 +39,9 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import LoraCyclerSettingsView from './lora-cycler/LoraCyclerSettingsView.vue'
|
||||
import LoraListModal from './lora-cycler/LoraListModal.vue'
|
||||
import { useLoraCyclerState } from '../composables/useLoraCyclerState'
|
||||
import type { ComponentWidget, CyclerConfig, LoraPoolConfig } from '../composables/types'
|
||||
import type { ComponentWidget, CyclerConfig, LoraPoolConfig, LoraItem } from '../composables/types'
|
||||
|
||||
type CyclerWidget = ComponentWidget<CyclerConfig>
|
||||
|
||||
@@ -86,6 +96,12 @@ const lastPoolConfigHash = ref('')
|
||||
// Track if component is mounted
|
||||
const isMounted = ref(false)
|
||||
|
||||
// Modal state
|
||||
const isModalOpen = ref(false)
|
||||
|
||||
// Cache for LoRA list (used by modal)
|
||||
const cachedLoraList = ref<LoraItem[]>([])
|
||||
|
||||
// Get pool config from connected node
|
||||
const getPoolConfig = (): LoraPoolConfig | null => {
|
||||
// Check if getPoolConfig method exists on node (added by main.ts)
|
||||
@@ -95,6 +111,17 @@ const getPoolConfig = (): LoraPoolConfig | null => {
|
||||
return null
|
||||
}
|
||||
|
||||
// Update display from LoRA list and index
|
||||
const updateDisplayFromLoraList = (loraList: LoraItem[], index: number) => {
|
||||
if (loraList.length > 0 && index > 0 && index <= loraList.length) {
|
||||
const currentLora = loraList[index - 1]
|
||||
if (currentLora) {
|
||||
state.currentLoraName.value = currentLora.file_name
|
||||
state.currentLoraFilename.value = currentLora.file_name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle index update from user
|
||||
const handleIndexUpdate = async (newIndex: number) => {
|
||||
// Reset execution state when user manually changes index
|
||||
@@ -113,19 +140,18 @@ const handleIndexUpdate = async (newIndex: number) => {
|
||||
try {
|
||||
const poolConfig = getPoolConfig()
|
||||
const loraList = await state.fetchCyclerList(poolConfig)
|
||||
|
||||
if (loraList.length > 0 && newIndex > 0 && newIndex <= loraList.length) {
|
||||
const currentLora = loraList[newIndex - 1]
|
||||
if (currentLora) {
|
||||
state.currentLoraName.value = currentLora.file_name
|
||||
state.currentLoraFilename.value = currentLora.file_name
|
||||
}
|
||||
}
|
||||
cachedLoraList.value = loraList
|
||||
updateDisplayFromLoraList(loraList, newIndex)
|
||||
} catch (error) {
|
||||
console.error('[LoraCyclerWidget] Error updating index:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle LoRA selection from modal
|
||||
const handleModalSelect = (index: number) => {
|
||||
handleIndexUpdate(index)
|
||||
}
|
||||
|
||||
// Handle use custom clip range toggle
|
||||
const handleUseCustomClipRangeChange = (newValue: boolean) => {
|
||||
state.useCustomClipRange.value = newValue
|
||||
@@ -166,14 +192,8 @@ const handleResetIndex = async () => {
|
||||
try {
|
||||
const poolConfig = getPoolConfig()
|
||||
const loraList = await state.fetchCyclerList(poolConfig)
|
||||
|
||||
if (loraList.length > 0) {
|
||||
const currentLora = loraList[0]
|
||||
if (currentLora) {
|
||||
state.currentLoraName.value = currentLora.file_name
|
||||
state.currentLoraFilename.value = currentLora.file_name
|
||||
}
|
||||
}
|
||||
cachedLoraList.value = loraList
|
||||
updateDisplayFromLoraList(loraList, 1)
|
||||
} catch (error) {
|
||||
console.error('[LoraCyclerWidget] Error resetting index:', error)
|
||||
}
|
||||
@@ -191,6 +211,9 @@ const checkPoolConfigChanges = async () => {
|
||||
lastPoolConfigHash.value = newHash
|
||||
try {
|
||||
await state.refreshList(poolConfig)
|
||||
// Update cached list when pool config changes
|
||||
const loraList = await state.fetchCyclerList(poolConfig)
|
||||
cachedLoraList.value = loraList
|
||||
} catch (error) {
|
||||
console.error('[LoraCyclerWidget] Error on pool config change:', error)
|
||||
}
|
||||
@@ -288,6 +311,9 @@ onMounted(async () => {
|
||||
const poolConfig = getPoolConfig()
|
||||
lastPoolConfigHash.value = state.hashPoolConfig(poolConfig)
|
||||
await state.refreshList(poolConfig)
|
||||
// Cache the initial LoRA list for modal
|
||||
const loraList = await state.fetchCyclerList(poolConfig)
|
||||
cachedLoraList.value = loraList
|
||||
} catch (error) {
|
||||
console.error('[LoraCyclerWidget] Error on initial load:', error)
|
||||
}
|
||||
|
||||
@@ -7,9 +7,18 @@
|
||||
<!-- Progress Display -->
|
||||
<div class="setting-section progress-section">
|
||||
<div class="progress-display" :class="{ executing: isWorkflowExecuting }">
|
||||
<div class="progress-info">
|
||||
<div
|
||||
class="progress-info"
|
||||
:class="{ disabled: isPauseDisabled }"
|
||||
@click="handleOpenSelector"
|
||||
>
|
||||
<span class="progress-label">{{ isWorkflowExecuting ? 'Using LoRA:' : 'Next LoRA:' }}</span>
|
||||
<span class="progress-name" :title="currentLoraFilename">{{ currentLoraName || 'None' }}</span>
|
||||
<span class="progress-name clickable" :class="{ disabled: isPauseDisabled }" :title="currentLoraFilename">
|
||||
{{ currentLoraName || 'None' }}
|
||||
<svg class="selector-icon" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M7 10l5 5 5-5z"/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<div class="progress-counter">
|
||||
<span class="progress-index">{{ currentIndex }}</span>
|
||||
@@ -183,12 +192,20 @@ const emit = defineEmits<{
|
||||
'update:repeatCount': [value: number]
|
||||
'toggle-pause': []
|
||||
'reset-index': []
|
||||
'open-lora-selector': []
|
||||
}>()
|
||||
|
||||
// Temporary value for input while typing
|
||||
const tempIndex = ref<string>('')
|
||||
const tempRepeat = ref<string>('')
|
||||
|
||||
const handleOpenSelector = () => {
|
||||
if (props.isPauseDisabled) {
|
||||
return
|
||||
}
|
||||
emit('open-lora-selector')
|
||||
}
|
||||
|
||||
const onIndexInput = (event: Event) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
tempIndex.value = input.value
|
||||
@@ -313,6 +330,42 @@ const onRepeatBlur = (event: Event) => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.progress-name.clickable {
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
margin: -2px -6px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.progress-name.clickable:hover:not(.disabled) {
|
||||
background: rgba(66, 153, 225, 0.2);
|
||||
color: rgba(191, 219, 254, 1);
|
||||
}
|
||||
|
||||
.progress-name.clickable.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.progress-info.disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.selector-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
opacity: 0.5;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.progress-name.clickable:hover .selector-icon {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.progress-counter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
313
vue-widgets/src/components/lora-cycler/LoraListModal.vue
Normal file
313
vue-widgets/src/components/lora-cycler/LoraListModal.vue
Normal file
@@ -0,0 +1,313 @@
|
||||
<template>
|
||||
<ModalWrapper
|
||||
:visible="visible"
|
||||
title="Select LoRA"
|
||||
:subtitle="subtitleText"
|
||||
@close="$emit('close')"
|
||||
>
|
||||
<template #search>
|
||||
<div class="search-container">
|
||||
<svg class="search-icon" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z"/>
|
||||
</svg>
|
||||
<input
|
||||
ref="searchInputRef"
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="Search LoRAs..."
|
||||
/>
|
||||
<button
|
||||
v-if="searchQuery"
|
||||
type="button"
|
||||
class="clear-button"
|
||||
@click="clearSearch"
|
||||
>
|
||||
<svg viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="lora-list">
|
||||
<div
|
||||
v-for="item in filteredList"
|
||||
:key="item.index"
|
||||
class="lora-item"
|
||||
:class="{ active: currentIndex === item.index }"
|
||||
@mouseenter="showPreview(item.lora.file_name, $event)"
|
||||
@mouseleave="hidePreview"
|
||||
@click="selectLora(item.index)"
|
||||
>
|
||||
<span class="lora-index">{{ item.index }}</span>
|
||||
<span class="lora-name" :title="item.lora.file_name">{{ item.lora.file_name }}</span>
|
||||
<span v-if="currentIndex === item.index" class="current-badge">Current</span>
|
||||
</div>
|
||||
<div v-if="filteredList.length === 0" class="no-results">
|
||||
No LoRAs found
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick, onUnmounted } from 'vue'
|
||||
import ModalWrapper from '../lora-pool/modals/ModalWrapper.vue'
|
||||
import type { LoraItem } from '../../composables/types'
|
||||
|
||||
interface LoraListItem {
|
||||
index: number
|
||||
lora: LoraItem
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
loraList: LoraItem[]
|
||||
currentIndex: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
select: [index: number]
|
||||
}>()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const searchInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
// Preview tooltip instance (lazy init)
|
||||
let previewTooltip: any = null
|
||||
|
||||
const subtitleText = computed(() => {
|
||||
const total = props.loraList.length
|
||||
const filtered = filteredList.value.length
|
||||
if (filtered === total) {
|
||||
return `Total: ${total} LoRA${total !== 1 ? 's' : ''}`
|
||||
}
|
||||
return `Showing ${filtered} of ${total} LoRA${total !== 1 ? 's' : ''}`
|
||||
})
|
||||
|
||||
const filteredList = computed<LoraListItem[]>(() => {
|
||||
const list = props.loraList.map((lora, idx) => ({
|
||||
index: idx + 1,
|
||||
lora
|
||||
}))
|
||||
|
||||
if (!searchQuery.value.trim()) {
|
||||
return list
|
||||
}
|
||||
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
return list.filter(item =>
|
||||
item.lora.file_name.toLowerCase().includes(query)
|
||||
)
|
||||
})
|
||||
|
||||
const clearSearch = () => {
|
||||
searchQuery.value = ''
|
||||
searchInputRef.value?.focus()
|
||||
}
|
||||
|
||||
const selectLora = (index: number) => {
|
||||
emit('select', index)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// Custom preview URL resolver for Vue widgets environment
|
||||
// The default preview_tooltip.js uses api.fetchApi which is mocked as native fetch
|
||||
// in the Vue widgets build, so we need to use the full path with /api prefix
|
||||
const customPreviewUrlResolver = async (modelName: string) => {
|
||||
const response = await fetch(
|
||||
`/api/lm/loras/preview-url?name=${encodeURIComponent(modelName)}&license_flags=true`
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch preview URL')
|
||||
}
|
||||
const data = await response.json()
|
||||
if (!data.success || !data.preview_url) {
|
||||
throw new Error('No preview available')
|
||||
}
|
||||
return {
|
||||
previewUrl: data.preview_url,
|
||||
displayName: data.display_name ?? modelName,
|
||||
licenseFlags: data.license_flags
|
||||
}
|
||||
}
|
||||
|
||||
// Lazy load PreviewTooltip to avoid loading it unnecessarily
|
||||
const getPreviewTooltip = async () => {
|
||||
if (!previewTooltip) {
|
||||
const { PreviewTooltip } = await import(/* @vite-ignore */ `${'../preview_tooltip.js'}`)
|
||||
previewTooltip = new PreviewTooltip({
|
||||
modelType: 'loras',
|
||||
displayNameFormatter: (name: string) => name,
|
||||
previewUrlResolver: customPreviewUrlResolver
|
||||
})
|
||||
}
|
||||
return previewTooltip
|
||||
}
|
||||
|
||||
const showPreview = async (loraName: string, event: MouseEvent) => {
|
||||
const tooltip = await getPreviewTooltip()
|
||||
const rect = (event.target as HTMLElement).getBoundingClientRect()
|
||||
// Position to the right of the item, centered vertically
|
||||
tooltip.show(loraName, rect.right + 10, rect.top + rect.height / 2)
|
||||
}
|
||||
|
||||
const hidePreview = async () => {
|
||||
if (previewTooltip) {
|
||||
previewTooltip.hide()
|
||||
}
|
||||
}
|
||||
|
||||
// Focus search input when modal opens
|
||||
watch(() => props.visible, (isVisible) => {
|
||||
if (isVisible) {
|
||||
searchQuery.value = ''
|
||||
nextTick(() => {
|
||||
searchInputRef.value?.focus()
|
||||
})
|
||||
} else {
|
||||
// Hide preview when modal closes
|
||||
hidePreview()
|
||||
}
|
||||
})
|
||||
|
||||
// Cleanup on unmount
|
||||
onUnmounted(() => {
|
||||
if (previewTooltip) {
|
||||
previewTooltip.cleanup()
|
||||
previewTooltip = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--fg-color, #fff);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 8px 32px;
|
||||
background: var(--comfy-input-bg, #333);
|
||||
border: 1px solid var(--border-color, #444);
|
||||
border-radius: 6px;
|
||||
color: var(--fg-color, #fff);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: rgba(66, 153, 225, 0.6);
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: var(--fg-color, #fff);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.clear-button {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.clear-button:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.clear-button svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: var(--fg-color, #fff);
|
||||
}
|
||||
|
||||
.lora-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.lora-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.lora-item:hover {
|
||||
background: rgba(66, 153, 225, 0.15);
|
||||
}
|
||||
|
||||
.lora-item.active {
|
||||
background: rgba(66, 153, 225, 0.25);
|
||||
border-left-color: rgba(66, 153, 225, 0.8);
|
||||
}
|
||||
|
||||
.lora-index {
|
||||
font-family: 'SF Mono', 'Roboto Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: rgba(226, 232, 240, 0.5);
|
||||
min-width: 3ch;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.lora-name {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: var(--fg-color, #fff);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.current-badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
background: rgba(66, 153, 225, 0.3);
|
||||
border: 1px solid rgba(66, 153, 225, 0.5);
|
||||
border-radius: 4px;
|
||||
color: rgba(191, 219, 254, 1);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
padding: 32px 20px;
|
||||
text-align: center;
|
||||
color: var(--fg-color, #fff);
|
||||
opacity: 0.5;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -81,7 +81,7 @@ watch(() => props.visible, (isVisible) => {
|
||||
.lora-pool-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10000;
|
||||
z-index: 9998;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -24,7 +24,8 @@ export default defineConfig({
|
||||
'../../../scripts/app.js',
|
||||
'../../../scripts/api.js',
|
||||
'../loras_widget.js',
|
||||
'../autocomplete.js'
|
||||
'../autocomplete.js',
|
||||
'../preview_tooltip.js'
|
||||
],
|
||||
output: {
|
||||
dir: '../web/comfyui/vue-widgets',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* Shared styling for the LoRA Manager frontend widgets */
|
||||
.lm-tooltip {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
z-index: 10001;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user