mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
feat(lora-pool): add folder filtering and preview tooltip enhancements
- Add include/exclude folder modals for advanced filtering - Implement folder tree search with auto-expand functionality - Add hover tooltip to preview header showing matching LoRA thumbnails - Format match count with locale string for better readability - Prevent event propagation on refresh button click - Improve folder tree component with expand/collapse controls
This commit is contained in:
@@ -47,6 +47,24 @@
|
||||
@close="modalState.closeModal"
|
||||
@update:selected="state.excludeTags.value = $event"
|
||||
/>
|
||||
|
||||
<FoldersModal
|
||||
:visible="modalState.isModalOpen('includeFolders')"
|
||||
:folders="state.folderTree.value"
|
||||
:selected="state.includeFolders.value"
|
||||
variant="include"
|
||||
@close="modalState.closeModal"
|
||||
@update:selected="state.includeFolders.value = $event"
|
||||
/>
|
||||
|
||||
<FoldersModal
|
||||
:visible="modalState.isModalOpen('excludeFolders')"
|
||||
:folders="state.folderTree.value"
|
||||
:selected="state.excludeFolders.value"
|
||||
variant="exclude"
|
||||
@close="modalState.closeModal"
|
||||
@update:selected="state.excludeFolders.value = $event"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -55,6 +73,7 @@ import { onMounted } from 'vue'
|
||||
import LoraPoolSummaryView from './lora-pool/LoraPoolSummaryView.vue'
|
||||
import BaseModelModal from './lora-pool/modals/BaseModelModal.vue'
|
||||
import TagsModal from './lora-pool/modals/TagsModal.vue'
|
||||
import FoldersModal from './lora-pool/modals/FoldersModal.vue'
|
||||
import { useLoraPoolState } from '../composables/useLoraPoolState'
|
||||
import { useModalState, type ModalType } from '../composables/useModalState'
|
||||
import type { ComponentWidget, LoraPoolConfig, LegacyLoraPoolConfig } from '../composables/types'
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
<template>
|
||||
<div class="preview">
|
||||
<div class="preview__header">
|
||||
<span class="preview__title">Matching LoRAs: {{ matchCount }}</span>
|
||||
<div
|
||||
class="preview__header"
|
||||
@mouseenter="showTooltip = true"
|
||||
@mouseleave="showTooltip = false"
|
||||
>
|
||||
<span class="preview__title">Matching LoRAs: {{ matchCount.toLocaleString() }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="preview__refresh"
|
||||
:class="{ 'preview__refresh--loading': isLoading }"
|
||||
@click="$emit('refresh')"
|
||||
@click.stop="$emit('refresh')"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
<svg class="preview__refresh-icon" viewBox="0 0 16 16" fill="currentColor">
|
||||
@@ -16,43 +20,44 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="items.length > 0" class="preview__list">
|
||||
<div
|
||||
v-for="item in items.slice(0, 5)"
|
||||
:key="item.file_path"
|
||||
class="preview__item"
|
||||
>
|
||||
<img
|
||||
v-if="item.preview_url"
|
||||
:src="item.preview_url"
|
||||
class="preview__thumb"
|
||||
@error="onImageError"
|
||||
/>
|
||||
<div v-else class="preview__thumb preview__thumb--placeholder">
|
||||
<svg viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M6.002 5.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z"/>
|
||||
<path d="M2.002 1a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2h-12zm12 1a1 1 0 0 1 1 1v6.5l-3.777-1.947a.5.5 0 0 0-.577.093l-3.71 3.71-2.66-1.772a.5.5 0 0 0-.63.062L1.002 12V3a1 1 0 0 1 1-1h12z"/>
|
||||
</svg>
|
||||
<!-- Hover tooltip with preview items -->
|
||||
<Transition name="tooltip">
|
||||
<div v-if="showTooltip && items.length > 0" class="preview__tooltip">
|
||||
<div class="preview__tooltip-content">
|
||||
<div
|
||||
v-for="item in items.slice(0, 5)"
|
||||
:key="item.file_path"
|
||||
class="preview__item"
|
||||
>
|
||||
<img
|
||||
v-if="item.preview_url"
|
||||
:src="item.preview_url"
|
||||
class="preview__thumb"
|
||||
@error="onImageError"
|
||||
/>
|
||||
<div v-else class="preview__thumb preview__thumb--placeholder">
|
||||
<svg viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M6.002 5.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z"/>
|
||||
<path d="M2.002 1a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2h-12zm12 1a1 1 0 0 1 1 1v6.5l-3.777-1.947a.5.5 0 0 0-.577.093l-3.71 3.71-2.66-1.772a.5.5 0 0 0-.63.062L1.002 12V3a1 1 0 0 1 1-1h12z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="preview__name">{{ item.model_name || item.file_name }}</span>
|
||||
</div>
|
||||
<div v-if="matchCount > 5" class="preview__more">
|
||||
+{{ (matchCount - 5).toLocaleString() }} more
|
||||
</div>
|
||||
</div>
|
||||
<span class="preview__name">{{ item.model_name || item.file_name }}</span>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div v-if="matchCount > 5" class="preview__more">
|
||||
+{{ matchCount - 5 }} more
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!isLoading" class="preview__empty">
|
||||
<div v-if="items.length === 0 && !isLoading" class="preview__empty">
|
||||
No matching LoRAs
|
||||
</div>
|
||||
|
||||
<div v-else class="preview__loading">
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { LoraItem } from '../../composables/types'
|
||||
|
||||
defineProps<{
|
||||
@@ -65,6 +70,8 @@ defineEmits<{
|
||||
refresh: []
|
||||
}>()
|
||||
|
||||
const showTooltip = ref(false)
|
||||
|
||||
const onImageError = (event: Event) => {
|
||||
const img = event.target as HTMLImageElement
|
||||
img.style.display = 'none'
|
||||
@@ -75,13 +82,14 @@ const onImageError = (event: Event) => {
|
||||
.preview {
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border-color, #444);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.preview__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.preview__title {
|
||||
@@ -128,7 +136,22 @@ const onImageError = (event: Event) => {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.preview__list {
|
||||
/* Tooltip styles */
|
||||
.preview__tooltip {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-bottom: 8px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.preview__tooltip-content {
|
||||
background: var(--comfy-menu-bg, #1a1a1a);
|
||||
border: 1px solid var(--border-color, #444);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
@@ -179,15 +202,26 @@ const onImageError = (event: Event) => {
|
||||
color: var(--fg-color, #fff);
|
||||
opacity: 0.5;
|
||||
text-align: center;
|
||||
padding: 6px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.preview__empty,
|
||||
.preview__loading {
|
||||
.preview__empty {
|
||||
font-size: 11px;
|
||||
color: var(--fg-color, #fff);
|
||||
opacity: 0.4;
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
padding: 8px 0 0 0;
|
||||
}
|
||||
|
||||
/* Tooltip transitions */
|
||||
.tooltip-enter-active,
|
||||
.tooltip-leave-active {
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.tooltip-enter-from,
|
||||
.tooltip-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
<template>
|
||||
<div class="summary-view">
|
||||
<!-- Header with filter count badge -->
|
||||
<div class="summary-view__header">
|
||||
<div class="summary-view__badge">
|
||||
<svg class="summary-view__badge-icon" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M1.5 1.5A.5.5 0 0 1 2 1h12a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.128.334L10 8.692V13.5a.5.5 0 0 1-.342.474l-3 1A.5.5 0 0 1 6 14.5V8.692L1.628 3.834A.5.5 0 0 1 1.5 3.5v-2z"/>
|
||||
</svg>
|
||||
<span class="summary-view__count">{{ matchCount.toLocaleString() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter sections -->
|
||||
<div class="summary-view__filters">
|
||||
<BaseModelSection
|
||||
@@ -30,6 +20,8 @@
|
||||
:exclude-folders="excludeFolders"
|
||||
@update:include-folders="$emit('update:includeFolders', $event)"
|
||||
@update:exclude-folders="$emit('update:excludeFolders', $event)"
|
||||
@edit-include="$emit('open-modal', 'includeFolders')"
|
||||
@edit-exclude="$emit('open-modal', 'excludeFolders')"
|
||||
/>
|
||||
|
||||
<LicenseSection
|
||||
@@ -95,33 +87,6 @@ defineEmits<{
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.summary-view__header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.summary-view__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
background: rgba(66, 153, 225, 0.15);
|
||||
border: 1px solid rgba(66, 153, 225, 0.4);
|
||||
border-radius: 4px;
|
||||
color: #4299e1;
|
||||
}
|
||||
|
||||
.summary-view__badge-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.summary-view__count {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.summary-view__filters {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
217
vue-widgets/src/components/lora-pool/modals/FolderTreeNode.vue
Normal file
217
vue-widgets/src/components/lora-pool/modals/FolderTreeNode.vue
Normal file
@@ -0,0 +1,217 @@
|
||||
<template>
|
||||
<div class="tree-node">
|
||||
<div
|
||||
class="tree-node__item"
|
||||
:class="[
|
||||
`tree-node__item--${variant}`,
|
||||
{ 'tree-node__item--selected': isSelected }
|
||||
]"
|
||||
:style="{ paddingLeft: `${depth * 16 + 8}px` }"
|
||||
@click="handleRowClick"
|
||||
>
|
||||
<!-- Expand/collapse toggle -->
|
||||
<button
|
||||
v-if="hasChildren"
|
||||
type="button"
|
||||
class="tree-node__toggle"
|
||||
@click.stop="$emit('toggle-expand', node.key)"
|
||||
>
|
||||
<svg
|
||||
class="tree-node__toggle-icon"
|
||||
:class="{ 'tree-node__toggle-icon--expanded': isExpanded }"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span v-else class="tree-node__toggle-spacer"></span>
|
||||
|
||||
<!-- Checkbox -->
|
||||
<label class="tree-node__checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="tree-node__checkbox"
|
||||
:checked="isSelected"
|
||||
@change="$emit('toggle-select', node.key)"
|
||||
/>
|
||||
<span class="tree-node__checkbox-visual" :class="`tree-node__checkbox-visual--${variant}`">
|
||||
<svg v-if="isSelected" class="tree-node__check-icon" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"/>
|
||||
</svg>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<!-- Folder icon -->
|
||||
<svg class="tree-node__folder-icon" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M.54 3.87.5 3a2 2 0 0 1 2-2h3.672a2 2 0 0 1 1.414.586l.828.828A2 2 0 0 0 9.828 3H14a2 2 0 0 1 2 2v1.5a.5.5 0 0 1-1 0V5a1 1 0 0 0-1-1H9.828a3 3 0 0 1-2.12-.879l-.83-.828A1 1 0 0 0 6.172 2H2.5a1 1 0 0 0-1 .981l.006.139C1.72 3.042 1.95 3 2.19 3h5.396l.707.707a1 1 0 0 0 .707.293H14.5a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0V5H9a2 2 0 0 1-1.414-.586l-.828-.828A1 1 0 0 0 6.172 3H2.19a1.5 1.5 0 0 0-1.69.87z"/>
|
||||
<path d="M1.5 4.5h13a.5.5 0 0 1 .5.5v8a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V5a.5.5 0 0 1 .5-.5z"/>
|
||||
</svg>
|
||||
|
||||
<!-- Folder name -->
|
||||
<span class="tree-node__label">{{ node.label }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Children (recursive) -->
|
||||
<div v-if="hasChildren && isExpanded" class="tree-node__children">
|
||||
<FolderTreeNode
|
||||
v-for="child in node.children"
|
||||
:key="child.key"
|
||||
:node="child"
|
||||
:selected="selected"
|
||||
:expanded="expanded"
|
||||
:variant="variant"
|
||||
:depth="depth + 1"
|
||||
@toggle-expand="$emit('toggle-expand', $event)"
|
||||
@toggle-select="$emit('toggle-select', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { FolderTreeNode as FolderTreeNodeType } from '../../../composables/types'
|
||||
|
||||
const props = defineProps<{
|
||||
node: FolderTreeNodeType
|
||||
selected: string[]
|
||||
expanded: Set<string>
|
||||
variant: 'include' | 'exclude'
|
||||
depth: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'toggle-expand': [key: string]
|
||||
'toggle-select': [key: string]
|
||||
}>()
|
||||
|
||||
const hasChildren = computed(() => props.node.children && props.node.children.length > 0)
|
||||
const isExpanded = computed(() => props.expanded.has(props.node.key))
|
||||
const isSelected = computed(() => props.selected.includes(props.node.key))
|
||||
|
||||
// Handle row click - toggle selection unless clicking checkbox directly
|
||||
const handleRowClick = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest('.tree-node__checkbox-label')) {
|
||||
return
|
||||
}
|
||||
emit('toggle-select', props.node.key)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tree-node__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.tree-node__item:hover {
|
||||
background: var(--comfy-input-bg, #333);
|
||||
}
|
||||
|
||||
.tree-node__toggle {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--fg-color, #fff);
|
||||
cursor: pointer;
|
||||
opacity: 0.5;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tree-node__toggle:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tree-node__toggle-icon {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.tree-node__toggle-icon--expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.tree-node__toggle-spacer {
|
||||
width: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tree-node__checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tree-node__checkbox {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tree-node__checkbox-visual {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: var(--comfy-input-bg, #333);
|
||||
border: 1px solid var(--border-color, #555);
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.tree-node__item:hover .tree-node__checkbox-visual {
|
||||
border-color: var(--fg-color, #fff);
|
||||
}
|
||||
|
||||
.tree-node__checkbox:checked + .tree-node__checkbox-visual--include {
|
||||
background: #4299e1;
|
||||
border-color: #4299e1;
|
||||
}
|
||||
|
||||
.tree-node__checkbox:checked + .tree-node__checkbox-visual--exclude {
|
||||
background: #ef4444;
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.tree-node__check-icon {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tree-node__folder-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--fg-color, #fff);
|
||||
opacity: 0.6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tree-node__label {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: var(--fg-color, #fff);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tree-node__children {
|
||||
/* Children already indented via padding */
|
||||
}
|
||||
</style>
|
||||
173
vue-widgets/src/components/lora-pool/modals/FoldersModal.vue
Normal file
173
vue-widgets/src/components/lora-pool/modals/FoldersModal.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<ModalWrapper
|
||||
:visible="visible"
|
||||
:title="variant === 'include' ? 'Include Folders' : 'Exclude Folders'"
|
||||
:subtitle="variant === 'include' ? 'Select folders to include in the filter' : 'Select folders to exclude from the filter'"
|
||||
@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
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="Search folders..."
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="folder-tree">
|
||||
<template v-if="filteredFolders.length > 0">
|
||||
<FolderTreeNode
|
||||
v-for="node in filteredFolders"
|
||||
:key="node.key"
|
||||
:node="node"
|
||||
:selected="selected"
|
||||
:expanded="expandedKeys"
|
||||
:variant="variant"
|
||||
:depth="0"
|
||||
@toggle-expand="toggleExpand"
|
||||
@toggle-select="toggleSelect"
|
||||
/>
|
||||
</template>
|
||||
<div v-else class="no-results">
|
||||
No folders found
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import ModalWrapper from './ModalWrapper.vue'
|
||||
import FolderTreeNode from './FolderTreeNode.vue'
|
||||
import type { FolderTreeNode as FolderTreeNodeType } from '../../../composables/types'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
folders: FolderTreeNodeType[]
|
||||
selected: string[]
|
||||
variant: 'include' | 'exclude'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
'update:selected': [value: string[]]
|
||||
}>()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const expandedKeys = ref<Set<string>>(new Set())
|
||||
|
||||
// Filter folders based on search query
|
||||
const filteredFolders = computed(() => {
|
||||
if (!searchQuery.value) {
|
||||
return props.folders
|
||||
}
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
return filterTree(props.folders, query)
|
||||
})
|
||||
|
||||
// Recursively filter the tree, keeping matching nodes and their ancestors
|
||||
const filterTree = (nodes: FolderTreeNodeType[], query: string): FolderTreeNodeType[] => {
|
||||
const result: FolderTreeNodeType[] = []
|
||||
|
||||
for (const node of nodes) {
|
||||
const matches = node.key.toLowerCase().includes(query) || node.label.toLowerCase().includes(query)
|
||||
const filteredChildren = node.children ? filterTree(node.children, query) : []
|
||||
|
||||
if (matches || filteredChildren.length > 0) {
|
||||
result.push({
|
||||
...node,
|
||||
children: filteredChildren.length > 0 ? filteredChildren : node.children
|
||||
})
|
||||
// Auto-expand nodes when searching
|
||||
if (searchQuery.value && filteredChildren.length > 0) {
|
||||
expandedKeys.value.add(node.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Toggle expanded state
|
||||
const toggleExpand = (key: string) => {
|
||||
if (expandedKeys.value.has(key)) {
|
||||
expandedKeys.value.delete(key)
|
||||
} else {
|
||||
expandedKeys.value.add(key)
|
||||
}
|
||||
// Force reactivity update
|
||||
expandedKeys.value = new Set(expandedKeys.value)
|
||||
}
|
||||
|
||||
// Toggle selection
|
||||
const toggleSelect = (key: string) => {
|
||||
const newSelected = props.selected.includes(key)
|
||||
? props.selected.filter(k => k !== key)
|
||||
: [...props.selected, key]
|
||||
emit('update:selected', newSelected)
|
||||
}
|
||||
|
||||
// Reset expanded state when modal opens
|
||||
watch(() => props.visible, (isVisible) => {
|
||||
if (isVisible) {
|
||||
searchQuery.value = ''
|
||||
// Auto-expand first level
|
||||
expandedKeys.value = new Set()
|
||||
}
|
||||
})
|
||||
</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 12px 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;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: var(--fg-color, #fff);
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: var(--fg-color, #fff);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.folder-tree {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: var(--fg-color, #fff);
|
||||
opacity: 0.5;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -8,32 +8,30 @@
|
||||
<div class="section__column">
|
||||
<div class="section__column-header">
|
||||
<span class="section__column-title section__column-title--include">INCLUDE</span>
|
||||
</div>
|
||||
<div class="section__input-row">
|
||||
<input
|
||||
v-model="includeInput"
|
||||
type="text"
|
||||
class="section__input"
|
||||
placeholder="Path..."
|
||||
@keydown.enter="addInclude"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="section__add-btn section__add-btn--include"
|
||||
@click="addInclude"
|
||||
class="section__edit-btn section__edit-btn--include"
|
||||
@click="$emit('edit-include')"
|
||||
>
|
||||
+
|
||||
<svg viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M12.854.146a.5.5 0 0 0-.707 0L10.5 1.793 14.207 5.5l1.647-1.646a.5.5 0 0 0 0-.708l-3-3zm.646 6.061L9.793 2.5 3.293 9H3.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.207l6.5-6.5zm-7.468 7.468A.5.5 0 0 1 6 13.5V13h-.5a.5.5 0 0 1-.5-.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.5-.5V10h-.5a.499.499 0 0 1-.175-.032l-.179.178a.5.5 0 0 0-.11.168l-2 5a.5.5 0 0 0 .65.65l5-2a.5.5 0 0 0 .168-.11l.178-.178z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="includeFolders.length > 0" class="section__paths">
|
||||
<FilterChip
|
||||
v-for="path in includeFolders"
|
||||
:key="path"
|
||||
:label="truncatePath(path)"
|
||||
variant="path"
|
||||
removable
|
||||
@remove="removeInclude(path)"
|
||||
/>
|
||||
<div class="section__content">
|
||||
<div v-if="includeFolders.length > 0" class="section__paths">
|
||||
<FilterChip
|
||||
v-for="path in includeFolders"
|
||||
:key="path"
|
||||
:label="truncatePath(path)"
|
||||
variant="path"
|
||||
removable
|
||||
@remove="removeInclude(path)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="section__empty">
|
||||
No folders selected
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -41,32 +39,30 @@
|
||||
<div class="section__column">
|
||||
<div class="section__column-header">
|
||||
<span class="section__column-title section__column-title--exclude">EXCLUDE</span>
|
||||
</div>
|
||||
<div class="section__input-row">
|
||||
<input
|
||||
v-model="excludeInput"
|
||||
type="text"
|
||||
class="section__input"
|
||||
placeholder="Path..."
|
||||
@keydown.enter="addExclude"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="section__add-btn section__add-btn--exclude"
|
||||
@click="addExclude"
|
||||
class="section__edit-btn section__edit-btn--exclude"
|
||||
@click="$emit('edit-exclude')"
|
||||
>
|
||||
+
|
||||
<svg viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M12.854.146a.5.5 0 0 0-.707 0L10.5 1.793 14.207 5.5l1.647-1.646a.5.5 0 0 0 0-.708l-3-3zm.646 6.061L9.793 2.5 3.293 9H3.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.207l6.5-6.5zm-7.468 7.468A.5.5 0 0 1 6 13.5V13h-.5a.5.5 0 0 1-.5-.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.5-.5V10h-.5a.499.499 0 0 1-.175-.032l-.179.178a.5.5 0 0 0-.11.168l-2 5a.5.5 0 0 0 .65.65l5-2a.5.5 0 0 0 .168-.11l.178-.178z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="excludeFolders.length > 0" class="section__paths">
|
||||
<FilterChip
|
||||
v-for="path in excludeFolders"
|
||||
:key="path"
|
||||
:label="truncatePath(path)"
|
||||
variant="path"
|
||||
removable
|
||||
@remove="removeExclude(path)"
|
||||
/>
|
||||
<div class="section__content">
|
||||
<div v-if="excludeFolders.length > 0" class="section__paths">
|
||||
<FilterChip
|
||||
v-for="path in excludeFolders"
|
||||
:key="path"
|
||||
:label="truncatePath(path)"
|
||||
variant="path"
|
||||
removable
|
||||
@remove="removeExclude(path)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="section__empty">
|
||||
No folders selected
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -74,7 +70,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import FilterChip from '../shared/FilterChip.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -85,36 +80,19 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{
|
||||
'update:includeFolders': [value: string[]]
|
||||
'update:excludeFolders': [value: string[]]
|
||||
'edit-include': []
|
||||
'edit-exclude': []
|
||||
}>()
|
||||
|
||||
const includeInput = ref('')
|
||||
const excludeInput = ref('')
|
||||
|
||||
const truncatePath = (path: string) => {
|
||||
if (path.length <= 20) return path
|
||||
return '...' + path.slice(-17)
|
||||
}
|
||||
|
||||
const addInclude = () => {
|
||||
const path = includeInput.value.trim()
|
||||
if (path && !props.includeFolders.includes(path)) {
|
||||
emit('update:includeFolders', [...props.includeFolders, path])
|
||||
includeInput.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const removeInclude = (path: string) => {
|
||||
emit('update:includeFolders', props.includeFolders.filter(p => p !== path))
|
||||
}
|
||||
|
||||
const addExclude = () => {
|
||||
const path = excludeInput.value.trim()
|
||||
if (path && !props.excludeFolders.includes(path)) {
|
||||
emit('update:excludeFolders', [...props.excludeFolders, path])
|
||||
excludeInput.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const removeExclude = (path: string) => {
|
||||
emit('update:excludeFolders', props.excludeFolders.filter(p => p !== path))
|
||||
}
|
||||
@@ -149,6 +127,9 @@ const removeExclude = (path: string) => {
|
||||
}
|
||||
|
||||
.section__column-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
@@ -167,68 +148,58 @@ const removeExclude = (path: string) => {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.section__input-row {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.section__input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 6px 8px;
|
||||
background: var(--comfy-input-bg, #333);
|
||||
border: 1px solid var(--border-color, #444);
|
||||
border-radius: 4px;
|
||||
color: var(--fg-color, #fff);
|
||||
font-size: 11px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.section__input:focus {
|
||||
border-color: var(--fg-color, #fff);
|
||||
}
|
||||
|
||||
.section__input::placeholder {
|
||||
color: var(--fg-color, #fff);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.section__add-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
.section__edit-btn {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--comfy-input-bg, #333);
|
||||
border: 1px solid var(--border-color, #444);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--fg-color, #fff);
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
opacity: 0.5;
|
||||
border-radius: 3px;
|
||||
padding: 0;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.section__add-btn:hover {
|
||||
border-color: var(--fg-color, #fff);
|
||||
.section__edit-btn svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.section__add-btn--include:hover {
|
||||
background: rgba(66, 153, 225, 0.2);
|
||||
border-color: #4299e1;
|
||||
.section__edit-btn:hover {
|
||||
opacity: 1;
|
||||
background: var(--comfy-input-bg, #333);
|
||||
}
|
||||
|
||||
.section__edit-btn--include:hover {
|
||||
color: #4299e1;
|
||||
}
|
||||
|
||||
.section__add-btn--exclude:hover {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
border-color: #ef4444;
|
||||
.section__edit-btn--exclude:hover {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.section__content {
|
||||
min-height: 22px;
|
||||
}
|
||||
|
||||
.section__paths {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
min-height: 22px;
|
||||
}
|
||||
|
||||
.section__empty {
|
||||
font-size: 10px;
|
||||
color: var(--fg-color, #fff);
|
||||
opacity: 0.3;
|
||||
font-style: italic;
|
||||
min-height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export type ModalType = 'baseModels' | 'includeTags' | 'excludeTags' | null
|
||||
export type ModalType = 'baseModels' | 'includeTags' | 'excludeTags' | 'includeFolders' | 'excludeFolders' | null
|
||||
|
||||
export function useModalState() {
|
||||
const activeModal = ref<ModalType>(null)
|
||||
|
||||
@@ -38,7 +38,7 @@ function createLoraPoolWidget(node) {
|
||||
// Per dev guide: providing getMinHeight via options allows the system to
|
||||
// skip expensive DOM measurements during rendering loop, improving performance
|
||||
getMinHeight() {
|
||||
return 700
|
||||
return 400
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -62,7 +62,7 @@ function createLoraPoolWidget(node) {
|
||||
|
||||
widget.computeLayoutSize = () => {
|
||||
const minWidth = 500
|
||||
const minHeight = 700
|
||||
const minHeight = 400
|
||||
|
||||
return { minHeight, minWidth }
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user