mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-25 07:05:43 -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"
|
@close="modalState.closeModal"
|
||||||
@update:selected="state.excludeTags.value = $event"
|
@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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -55,6 +73,7 @@ import { onMounted } from 'vue'
|
|||||||
import LoraPoolSummaryView from './lora-pool/LoraPoolSummaryView.vue'
|
import LoraPoolSummaryView from './lora-pool/LoraPoolSummaryView.vue'
|
||||||
import BaseModelModal from './lora-pool/modals/BaseModelModal.vue'
|
import BaseModelModal from './lora-pool/modals/BaseModelModal.vue'
|
||||||
import TagsModal from './lora-pool/modals/TagsModal.vue'
|
import TagsModal from './lora-pool/modals/TagsModal.vue'
|
||||||
|
import FoldersModal from './lora-pool/modals/FoldersModal.vue'
|
||||||
import { useLoraPoolState } from '../composables/useLoraPoolState'
|
import { useLoraPoolState } from '../composables/useLoraPoolState'
|
||||||
import { useModalState, type ModalType } from '../composables/useModalState'
|
import { useModalState, type ModalType } from '../composables/useModalState'
|
||||||
import type { ComponentWidget, LoraPoolConfig, LegacyLoraPoolConfig } from '../composables/types'
|
import type { ComponentWidget, LoraPoolConfig, LegacyLoraPoolConfig } from '../composables/types'
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="preview">
|
<div class="preview">
|
||||||
<div class="preview__header">
|
<div
|
||||||
<span class="preview__title">Matching LoRAs: {{ matchCount }}</span>
|
class="preview__header"
|
||||||
|
@mouseenter="showTooltip = true"
|
||||||
|
@mouseleave="showTooltip = false"
|
||||||
|
>
|
||||||
|
<span class="preview__title">Matching LoRAs: {{ matchCount.toLocaleString() }}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="preview__refresh"
|
class="preview__refresh"
|
||||||
:class="{ 'preview__refresh--loading': isLoading }"
|
:class="{ 'preview__refresh--loading': isLoading }"
|
||||||
@click="$emit('refresh')"
|
@click.stop="$emit('refresh')"
|
||||||
:disabled="isLoading"
|
:disabled="isLoading"
|
||||||
>
|
>
|
||||||
<svg class="preview__refresh-icon" viewBox="0 0 16 16" fill="currentColor">
|
<svg class="preview__refresh-icon" viewBox="0 0 16 16" fill="currentColor">
|
||||||
@@ -16,7 +20,10 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="items.length > 0" class="preview__list">
|
<!-- Hover tooltip with preview items -->
|
||||||
|
<Transition name="tooltip">
|
||||||
|
<div v-if="showTooltip && items.length > 0" class="preview__tooltip">
|
||||||
|
<div class="preview__tooltip-content">
|
||||||
<div
|
<div
|
||||||
v-for="item in items.slice(0, 5)"
|
v-for="item in items.slice(0, 5)"
|
||||||
:key="item.file_path"
|
:key="item.file_path"
|
||||||
@@ -36,23 +43,21 @@
|
|||||||
</div>
|
</div>
|
||||||
<span class="preview__name">{{ item.model_name || item.file_name }}</span>
|
<span class="preview__name">{{ item.model_name || item.file_name }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="matchCount > 5" class="preview__more">
|
<div v-if="matchCount > 5" class="preview__more">
|
||||||
+{{ matchCount - 5 }} more
|
+{{ (matchCount - 5).toLocaleString() }} more
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
<div v-else-if="!isLoading" class="preview__empty">
|
<div v-if="items.length === 0 && !isLoading" class="preview__empty">
|
||||||
No matching LoRAs
|
No matching LoRAs
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="preview__loading">
|
|
||||||
Loading...
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
import type { LoraItem } from '../../composables/types'
|
import type { LoraItem } from '../../composables/types'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -65,6 +70,8 @@ defineEmits<{
|
|||||||
refresh: []
|
refresh: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const showTooltip = ref(false)
|
||||||
|
|
||||||
const onImageError = (event: Event) => {
|
const onImageError = (event: Event) => {
|
||||||
const img = event.target as HTMLImageElement
|
const img = event.target as HTMLImageElement
|
||||||
img.style.display = 'none'
|
img.style.display = 'none'
|
||||||
@@ -75,13 +82,14 @@ const onImageError = (event: Event) => {
|
|||||||
.preview {
|
.preview {
|
||||||
padding-top: 12px;
|
padding-top: 12px;
|
||||||
border-top: 1px solid var(--border-color, #444);
|
border-top: 1px solid var(--border-color, #444);
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview__header {
|
.preview__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 10px;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview__title {
|
.preview__title {
|
||||||
@@ -128,7 +136,22 @@ const onImageError = (event: Event) => {
|
|||||||
to { transform: rotate(360deg); }
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
@@ -179,15 +202,26 @@ const onImageError = (event: Event) => {
|
|||||||
color: var(--fg-color, #fff);
|
color: var(--fg-color, #fff);
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 6px;
|
padding: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview__empty,
|
.preview__empty {
|
||||||
.preview__loading {
|
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--fg-color, #fff);
|
color: var(--fg-color, #fff);
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
text-align: center;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,15 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="summary-view">
|
<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 -->
|
<!-- Filter sections -->
|
||||||
<div class="summary-view__filters">
|
<div class="summary-view__filters">
|
||||||
<BaseModelSection
|
<BaseModelSection
|
||||||
@@ -30,6 +20,8 @@
|
|||||||
:exclude-folders="excludeFolders"
|
:exclude-folders="excludeFolders"
|
||||||
@update:include-folders="$emit('update:includeFolders', $event)"
|
@update:include-folders="$emit('update:includeFolders', $event)"
|
||||||
@update:exclude-folders="$emit('update:excludeFolders', $event)"
|
@update:exclude-folders="$emit('update:excludeFolders', $event)"
|
||||||
|
@edit-include="$emit('open-modal', 'includeFolders')"
|
||||||
|
@edit-exclude="$emit('open-modal', 'excludeFolders')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<LicenseSection
|
<LicenseSection
|
||||||
@@ -95,33 +87,6 @@ defineEmits<{
|
|||||||
height: 100%;
|
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 {
|
.summary-view__filters {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
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,23 +8,17 @@
|
|||||||
<div class="section__column">
|
<div class="section__column">
|
||||||
<div class="section__column-header">
|
<div class="section__column-header">
|
||||||
<span class="section__column-title section__column-title--include">INCLUDE</span>
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="section__add-btn section__add-btn--include"
|
class="section__edit-btn section__edit-btn--include"
|
||||||
@click="addInclude"
|
@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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="section__content">
|
||||||
<div v-if="includeFolders.length > 0" class="section__paths">
|
<div v-if="includeFolders.length > 0" class="section__paths">
|
||||||
<FilterChip
|
<FilterChip
|
||||||
v-for="path in includeFolders"
|
v-for="path in includeFolders"
|
||||||
@@ -35,29 +29,27 @@
|
|||||||
@remove="removeInclude(path)"
|
@remove="removeInclude(path)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else class="section__empty">
|
||||||
|
No folders selected
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Exclude column -->
|
<!-- Exclude column -->
|
||||||
<div class="section__column">
|
<div class="section__column">
|
||||||
<div class="section__column-header">
|
<div class="section__column-header">
|
||||||
<span class="section__column-title section__column-title--exclude">EXCLUDE</span>
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="section__add-btn section__add-btn--exclude"
|
class="section__edit-btn section__edit-btn--exclude"
|
||||||
@click="addExclude"
|
@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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="section__content">
|
||||||
<div v-if="excludeFolders.length > 0" class="section__paths">
|
<div v-if="excludeFolders.length > 0" class="section__paths">
|
||||||
<FilterChip
|
<FilterChip
|
||||||
v-for="path in excludeFolders"
|
v-for="path in excludeFolders"
|
||||||
@@ -68,13 +60,16 @@
|
|||||||
@remove="removeExclude(path)"
|
@remove="removeExclude(path)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else class="section__empty">
|
||||||
|
No folders selected
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
|
||||||
import FilterChip from '../shared/FilterChip.vue'
|
import FilterChip from '../shared/FilterChip.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -85,36 +80,19 @@ const props = defineProps<{
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:includeFolders': [value: string[]]
|
'update:includeFolders': [value: string[]]
|
||||||
'update:excludeFolders': [value: string[]]
|
'update:excludeFolders': [value: string[]]
|
||||||
|
'edit-include': []
|
||||||
|
'edit-exclude': []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const includeInput = ref('')
|
|
||||||
const excludeInput = ref('')
|
|
||||||
|
|
||||||
const truncatePath = (path: string) => {
|
const truncatePath = (path: string) => {
|
||||||
if (path.length <= 20) return path
|
if (path.length <= 20) return path
|
||||||
return '...' + path.slice(-17)
|
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) => {
|
const removeInclude = (path: string) => {
|
||||||
emit('update:includeFolders', props.includeFolders.filter(p => p !== path))
|
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) => {
|
const removeExclude = (path: string) => {
|
||||||
emit('update:excludeFolders', props.excludeFolders.filter(p => p !== path))
|
emit('update:excludeFolders', props.excludeFolders.filter(p => p !== path))
|
||||||
}
|
}
|
||||||
@@ -149,6 +127,9 @@ const removeExclude = (path: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.section__column-header {
|
.section__column-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,68 +148,58 @@ const removeExclude = (path: string) => {
|
|||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section__input-row {
|
.section__edit-btn {
|
||||||
display: flex;
|
width: 20px;
|
||||||
gap: 4px;
|
height: 20px;
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: var(--comfy-input-bg, #333);
|
background: transparent;
|
||||||
border: 1px solid var(--border-color, #444);
|
border: none;
|
||||||
border-radius: 4px;
|
|
||||||
color: var(--fg-color, #fff);
|
color: var(--fg-color, #fff);
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
opacity: 0.5;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section__add-btn:hover {
|
.section__edit-btn svg {
|
||||||
border-color: var(--fg-color, #fff);
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section__add-btn--include:hover {
|
.section__edit-btn:hover {
|
||||||
background: rgba(66, 153, 225, 0.2);
|
opacity: 1;
|
||||||
border-color: #4299e1;
|
background: var(--comfy-input-bg, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section__edit-btn--include:hover {
|
||||||
color: #4299e1;
|
color: #4299e1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section__add-btn--exclude:hover {
|
.section__edit-btn--exclude:hover {
|
||||||
background: rgba(239, 68, 68, 0.2);
|
|
||||||
border-color: #ef4444;
|
|
||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section__content {
|
||||||
|
min-height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
.section__paths {
|
.section__paths {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 4px;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
export type ModalType = 'baseModels' | 'includeTags' | 'excludeTags' | null
|
export type ModalType = 'baseModels' | 'includeTags' | 'excludeTags' | 'includeFolders' | 'excludeFolders' | null
|
||||||
|
|
||||||
export function useModalState() {
|
export function useModalState() {
|
||||||
const activeModal = ref<ModalType>(null)
|
const activeModal = ref<ModalType>(null)
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ function createLoraPoolWidget(node) {
|
|||||||
// Per dev guide: providing getMinHeight via options allows the system to
|
// Per dev guide: providing getMinHeight via options allows the system to
|
||||||
// skip expensive DOM measurements during rendering loop, improving performance
|
// skip expensive DOM measurements during rendering loop, improving performance
|
||||||
getMinHeight() {
|
getMinHeight() {
|
||||||
return 700
|
return 400
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -62,7 +62,7 @@ function createLoraPoolWidget(node) {
|
|||||||
|
|
||||||
widget.computeLayoutSize = () => {
|
widget.computeLayoutSize = () => {
|
||||||
const minWidth = 500
|
const minWidth = 500
|
||||||
const minHeight = 700
|
const minHeight = 400
|
||||||
|
|
||||||
return { minHeight, minWidth }
|
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