mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-04 13:12:10 +00:00
[refactor] Reorganize Vue nodes to domain-driven design architecture (#5085)
* refactor: Reorganize Vue nodes system to domain-driven design architecture Move Vue nodes code from scattered technical layers to domain-focused structure: - Widget system → src/renderer/extensions/vueNodes/widgets/ - LOD optimization → src/renderer/extensions/vueNodes/lod/ - Layout logic → src/renderer/extensions/vueNodes/layout/ - Node components → src/renderer/extensions/vueNodes/components/ - Test structure mirrors source organization Benefits: - Clear domain boundaries instead of technical layers - Everything Vue nodes related in renderer domain (not workbench) - camelCase naming (vueNodes vs vue-nodes) - Tests co-located with source domains - All imports updated to new DDD structure * fix: Skip spatial index performance test on CI to avoid flaky timing Performance tests are inherently flaky on CI due to variable system performance. This test should only run locally like the other performance tests.
This commit is contained in:
committed by
Benjamin Lu
parent
0dd4ff2087
commit
bfcbcf4873
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label v-if="widget.name" class="text-sm opacity-80">{{
|
||||
widget.name
|
||||
}}</label>
|
||||
<Button
|
||||
v-bind="filteredProps"
|
||||
:disabled="readonly"
|
||||
size="small"
|
||||
@click="handleClick"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import {
|
||||
BADGE_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
// Button widgets don't have a v-model value, they trigger actions
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<void>
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
// Button specific excluded props
|
||||
const BUTTON_EXCLUDED_PROPS = [...BADGE_EXCLUDED_PROPS, 'iconClass'] as const
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, BUTTON_EXCLUDED_PROPS)
|
||||
)
|
||||
|
||||
const handleClick = () => {
|
||||
if (!props.readonly && props.widget.callback) {
|
||||
props.widget.callback()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div
|
||||
class="p-4 border border-gray-300 dark-theme:border-gray-600 rounded max-h-[48rem]"
|
||||
>
|
||||
<Chart :type="chartType" :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ChartData } from 'chart.js'
|
||||
import Chart from 'primevue/chart'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { ChartInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
type ChartWidgetOptions = NonNullable<ChartInputSpec['options']>
|
||||
|
||||
const value = defineModel<ChartData>({ required: true })
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<ChartData, ChartWidgetOptions>
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const chartType = computed(() => props.widget.options?.type ?? 'line')
|
||||
|
||||
const chartData = computed(() => value.value || { labels: [], datasets: [] })
|
||||
|
||||
const chartOptions = computed(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: '#FFF',
|
||||
usePointStyle: true,
|
||||
pointStyle: 'circle'
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
color: '#9FA2BD'
|
||||
},
|
||||
grid: {
|
||||
display: true,
|
||||
color: '#9FA2BD',
|
||||
drawTicks: false,
|
||||
drawOnChartArea: true,
|
||||
drawBorder: false
|
||||
},
|
||||
border: {
|
||||
display: true,
|
||||
color: '#9FA2BD'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
color: '#9FA2BD'
|
||||
},
|
||||
grid: {
|
||||
display: false,
|
||||
drawTicks: false,
|
||||
drawOnChartArea: false,
|
||||
drawBorder: false
|
||||
},
|
||||
border: {
|
||||
display: true,
|
||||
color: '#9FA2BD'
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
</script>
|
||||
@@ -0,0 +1,52 @@
|
||||
<!-- Needs custom color picker for alpha support -->
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label v-if="widget.name" class="text-sm opacity-80">{{
|
||||
widget.name
|
||||
}}</label>
|
||||
<ColorPicker
|
||||
v-model="localValue"
|
||||
v-bind="filteredProps"
|
||||
:disabled="readonly"
|
||||
inline
|
||||
@update:model-value="onChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ColorPicker from 'primevue/colorpicker'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import {
|
||||
PANEL_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<string>
|
||||
modelValue: string
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
// Use the composable for consistent widget value handling
|
||||
const { localValue, onChange } = useWidgetValue({
|
||||
widget: props.widget,
|
||||
modelValue: props.modelValue,
|
||||
defaultValue: '#000000',
|
||||
emit
|
||||
})
|
||||
|
||||
// ColorPicker specific excluded props include panel/overlay classes
|
||||
const COLOR_PICKER_EXCLUDED_PROPS = [...PANEL_EXCLUDED_PROPS] as const
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, COLOR_PICKER_EXCLUDED_PROPS)
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,324 @@
|
||||
<template>
|
||||
<!-- Replace entire widget with image preview when image is loaded -->
|
||||
<!-- Edge-to-edge: -mx-2 removes the parent's p-2 (8px) padding on each side -->
|
||||
<div
|
||||
v-if="hasImageFile"
|
||||
class="relative -mx-2"
|
||||
style="width: calc(100% + 1rem)"
|
||||
>
|
||||
<!-- Select section above image -->
|
||||
<div class="flex items-center justify-between gap-4 mb-2 px-2">
|
||||
<label
|
||||
v-if="widget.name"
|
||||
class="text-xs opacity-80 min-w-[4em] truncate"
|
||||
>{{ widget.name }}</label
|
||||
>
|
||||
<!-- Group select and folder button together on the right -->
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- TODO: finish once we finish value bindings with Litegraph -->
|
||||
<Select
|
||||
:model-value="selectedFile?.name"
|
||||
:options="[selectedFile?.name || '']"
|
||||
:disabled="true"
|
||||
class="min-w-[8em] max-w-[20em] text-xs"
|
||||
size="small"
|
||||
:pt="{
|
||||
option: 'text-xs'
|
||||
}"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-folder"
|
||||
size="small"
|
||||
class="!w-8 !h-8"
|
||||
:disabled="readonly"
|
||||
@click="triggerFileInput"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image preview -->
|
||||
<!-- TODO: change hardcoded colors when design system incorporated -->
|
||||
<div class="relative group">
|
||||
<img :src="imageUrl" :alt="selectedFile?.name" class="w-full h-auto" />
|
||||
<!-- Darkening overlay on hover -->
|
||||
<div
|
||||
class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-20 transition-all duration-200 pointer-events-none"
|
||||
/>
|
||||
<!-- Control buttons in top right on hover -->
|
||||
<div
|
||||
v-if="!readonly"
|
||||
class="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200"
|
||||
>
|
||||
<!-- Edit button -->
|
||||
<button
|
||||
class="w-6 h-6 rounded flex items-center justify-center transition-all duration-150 focus:outline-none border-none"
|
||||
style="background-color: #262729"
|
||||
@click="handleEdit"
|
||||
>
|
||||
<i class="pi pi-pencil text-white text-xs"></i>
|
||||
</button>
|
||||
<!-- Delete button -->
|
||||
<button
|
||||
class="w-6 h-6 rounded flex items-center justify-center transition-all duration-150 focus:outline-none border-none"
|
||||
style="background-color: #262729"
|
||||
@click="clearFile"
|
||||
>
|
||||
<i class="pi pi-times text-white text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audio preview when audio file is loaded -->
|
||||
<div
|
||||
v-else-if="hasAudioFile"
|
||||
class="relative -mx-2"
|
||||
style="width: calc(100% + 1rem)"
|
||||
>
|
||||
<!-- Select section above audio player -->
|
||||
<div class="flex items-center justify-between gap-4 mb-2 px-2">
|
||||
<label
|
||||
v-if="widget.name"
|
||||
class="text-xs opacity-80 min-w-[4em] truncate"
|
||||
>{{ widget.name }}</label
|
||||
>
|
||||
<!-- Group select and folder button together on the right -->
|
||||
<div class="flex items-center gap-1">
|
||||
<Select
|
||||
:model-value="selectedFile?.name"
|
||||
:options="[selectedFile?.name || '']"
|
||||
:disabled="true"
|
||||
class="min-w-[8em] max-w-[20em] text-xs"
|
||||
size="small"
|
||||
:pt="{
|
||||
option: 'text-xs'
|
||||
}"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-folder"
|
||||
size="small"
|
||||
class="!w-8 !h-8"
|
||||
:disabled="readonly"
|
||||
@click="triggerFileInput"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audio player -->
|
||||
<div class="relative group px-2">
|
||||
<div
|
||||
class="bg-[#1a1b1e] rounded-lg p-4 flex items-center gap-4"
|
||||
style="border: 1px solid #262729"
|
||||
>
|
||||
<!-- Audio icon -->
|
||||
<div class="flex-shrink-0">
|
||||
<i class="pi pi-volume-up text-2xl opacity-60"></i>
|
||||
</div>
|
||||
|
||||
<!-- File info and controls -->
|
||||
<div class="flex-1">
|
||||
<div class="text-sm font-medium mb-1">{{ selectedFile?.name }}</div>
|
||||
<div class="text-xs opacity-60">
|
||||
{{
|
||||
selectedFile ? (selectedFile.size / 1024).toFixed(1) + ' KB' : ''
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Control buttons -->
|
||||
<div v-if="!readonly" class="flex gap-1">
|
||||
<!-- Delete button -->
|
||||
<button
|
||||
class="w-8 h-8 rounded flex items-center justify-center transition-all duration-150 focus:outline-none border-none hover:bg-[#262729]"
|
||||
@click="clearFile"
|
||||
>
|
||||
<i class="pi pi-times text-white text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Show normal file upload UI when no image or audio is loaded -->
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col gap-1 w-full border border-solid p-1 rounded-lg"
|
||||
:style="{ borderColor: '#262729' }"
|
||||
>
|
||||
<div
|
||||
class="border border-dashed p-1 rounded-md transition-colors duration-200 hover:border-[#5B5E7D]"
|
||||
:style="{ borderColor: '#262729' }"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-2 w-full py-4">
|
||||
<!-- Quick and dirty file type detection for testing -->
|
||||
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
|
||||
<span class="text-xs opacity-60"> Drop your file or </span>
|
||||
<div>
|
||||
<Button
|
||||
label="Browse Files"
|
||||
size="small"
|
||||
class="text-xs"
|
||||
:disabled="readonly"
|
||||
@click="triggerFileInput"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Hidden file input always available for both states -->
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
class="hidden"
|
||||
:accept="widget.options?.accept"
|
||||
:multiple="false"
|
||||
:disabled="readonly"
|
||||
@change="handleFileChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Select from 'primevue/select'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
// import { useI18n } from 'vue-i18n' // Commented out for testing
|
||||
|
||||
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
// const { t } = useI18n() // Commented out for testing
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<File[] | null>
|
||||
modelValue: File[] | null
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: File[] | null]
|
||||
}>()
|
||||
|
||||
// Use the composable for consistent widget value handling
|
||||
const { localValue, onChange } = useWidgetValue({
|
||||
widget: props.widget,
|
||||
modelValue: props.modelValue,
|
||||
defaultValue: null,
|
||||
emit
|
||||
})
|
||||
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
// Since we only support single file, get the first file
|
||||
const selectedFile = computed(() => {
|
||||
const files = localValue.value || []
|
||||
return files.length > 0 ? files[0] : null
|
||||
})
|
||||
|
||||
// Quick file type detection for testing
|
||||
const detectFileType = (file: File) => {
|
||||
const type = file.type?.toLowerCase() || ''
|
||||
const name = file.name?.toLowerCase() || ''
|
||||
|
||||
if (
|
||||
type.startsWith('image/') ||
|
||||
name.match(/\.(jpg|jpeg|png|gif|webp|svg)$/)
|
||||
) {
|
||||
return 'image'
|
||||
}
|
||||
if (type.startsWith('video/') || name.match(/\.(mp4|webm|ogg|mov)$/)) {
|
||||
return 'video'
|
||||
}
|
||||
if (type.startsWith('audio/') || name.match(/\.(mp3|wav|ogg|flac)$/)) {
|
||||
return 'audio'
|
||||
}
|
||||
if (type === 'application/pdf' || name.endsWith('.pdf')) {
|
||||
return 'pdf'
|
||||
}
|
||||
if (type.includes('zip') || name.match(/\.(zip|rar|7z|tar|gz)$/)) {
|
||||
return 'archive'
|
||||
}
|
||||
return 'file'
|
||||
}
|
||||
|
||||
// Check if we have an image file
|
||||
const hasImageFile = computed(() => {
|
||||
return selectedFile.value && detectFileType(selectedFile.value) === 'image'
|
||||
})
|
||||
|
||||
// Check if we have an audio file
|
||||
const hasAudioFile = computed(() => {
|
||||
return selectedFile.value && detectFileType(selectedFile.value) === 'audio'
|
||||
})
|
||||
|
||||
// Get image URL for preview
|
||||
const imageUrl = computed(() => {
|
||||
if (hasImageFile.value && selectedFile.value) {
|
||||
return URL.createObjectURL(selectedFile.value)
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
// // Get audio URL for playback
|
||||
// const audioUrl = computed(() => {
|
||||
// if (hasAudioFile.value && selectedFile.value) {
|
||||
// return URL.createObjectURL(selectedFile.value)
|
||||
// }
|
||||
// return ''
|
||||
// })
|
||||
|
||||
// Clean up image URL when file changes
|
||||
watch(imageUrl, (newUrl, oldUrl) => {
|
||||
if (oldUrl && oldUrl !== newUrl) {
|
||||
URL.revokeObjectURL(oldUrl)
|
||||
}
|
||||
})
|
||||
|
||||
const triggerFileInput = () => {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
const handleFileChange = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
if (!props.readonly && target.files && target.files.length > 0) {
|
||||
// Since we only support single file, take the first one
|
||||
const file = target.files[0]
|
||||
|
||||
// Use the composable's onChange handler with an array
|
||||
onChange([file])
|
||||
|
||||
// Reset input to allow selecting same file again
|
||||
target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const clearFile = () => {
|
||||
// Clear the file
|
||||
onChange(null)
|
||||
|
||||
// Reset file input
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = () => {
|
||||
// TODO: hook up with maskeditor
|
||||
}
|
||||
|
||||
// Clear file input when value is cleared externally
|
||||
watch(localValue, (newValue) => {
|
||||
if (!newValue || newValue.length === 0) {
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Clean up image URL on unmount
|
||||
onUnmounted(() => {
|
||||
if (imageUrl.value) {
|
||||
URL.revokeObjectURL(imageUrl.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<Galleria
|
||||
v-model:activeIndex="activeIndex"
|
||||
:value="galleryImages"
|
||||
v-bind="filteredProps"
|
||||
:disabled="readonly"
|
||||
:show-thumbnails="showThumbnails"
|
||||
:show-nav-buttons="showNavButtons"
|
||||
class="max-w-full"
|
||||
:pt="{
|
||||
thumbnails: {
|
||||
class: 'overflow-hidden'
|
||||
},
|
||||
thumbnailContent: {
|
||||
class: 'py-4 px-2'
|
||||
},
|
||||
thumbnailPrevButton: {
|
||||
class: 'm-0'
|
||||
},
|
||||
thumbnailNextButton: {
|
||||
class: 'm-0'
|
||||
}
|
||||
}"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<img
|
||||
:src="item.itemImageSrc || item.src || item"
|
||||
:alt="item.alt || 'Gallery image'"
|
||||
class="w-full h-auto max-h-64 object-contain"
|
||||
/>
|
||||
</template>
|
||||
<template #thumbnail="{ item }">
|
||||
<div class="p-1 w-full h-full">
|
||||
<img
|
||||
:src="item.thumbnailImageSrc || item.src || item"
|
||||
:alt="item.alt || 'Gallery thumbnail'"
|
||||
class="w-full h-full object-cover rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Galleria>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Galleria from 'primevue/galleria'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import {
|
||||
GALLERIA_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
interface GalleryImage {
|
||||
itemImageSrc?: string
|
||||
thumbnailImageSrc?: string
|
||||
src?: string
|
||||
alt?: string
|
||||
}
|
||||
|
||||
type GalleryValue = string[] | GalleryImage[]
|
||||
|
||||
const value = defineModel<GalleryValue>({ required: true })
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<GalleryValue>
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const activeIndex = ref(0)
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, GALLERIA_EXCLUDED_PROPS)
|
||||
)
|
||||
|
||||
const galleryImages = computed(() => {
|
||||
if (!value.value || !Array.isArray(value.value)) return []
|
||||
|
||||
return value.value.map((item, index) => {
|
||||
if (typeof item === 'string') {
|
||||
return {
|
||||
itemImageSrc: item,
|
||||
thumbnailImageSrc: item,
|
||||
alt: `Image ${index + 1}`
|
||||
}
|
||||
}
|
||||
return item
|
||||
})
|
||||
})
|
||||
|
||||
const showThumbnails = computed(() => {
|
||||
return (
|
||||
props.widget.options?.showThumbnails !== false &&
|
||||
galleryImages.value.length > 1
|
||||
)
|
||||
})
|
||||
|
||||
const showNavButtons = computed(() => {
|
||||
return (
|
||||
props.widget.options?.showNavButtons !== false &&
|
||||
galleryImages.value.length > 1
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Ensure thumbnail container doesn't overflow */
|
||||
:deep(.p-galleria-thumbnails) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Constrain thumbnail items to prevent overlap */
|
||||
:deep(.p-galleria-thumbnail-item) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Ensure thumbnail wrapper maintains aspect ratio */
|
||||
:deep(.p-galleria-thumbnail) {
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label v-if="widget.name" class="text-sm opacity-80">{{
|
||||
widget.name
|
||||
}}</label>
|
||||
<Image v-bind="filteredProps" :src="widget.value" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Image from 'primevue/image'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import {
|
||||
IMAGE_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
// Image widgets typically don't have v-model, they display a source URL/path
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<string>
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, IMAGE_EXCLUDED_PROPS)
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<ImageCompare
|
||||
:tabindex="widget.options?.tabindex ?? 0"
|
||||
:aria-label="widget.options?.ariaLabel"
|
||||
:aria-labelledby="widget.options?.ariaLabelledby"
|
||||
:pt="widget.options?.pt"
|
||||
:pt-options="widget.options?.ptOptions"
|
||||
:unstyled="widget.options?.unstyled"
|
||||
>
|
||||
<template #left>
|
||||
<img
|
||||
:src="beforeImage"
|
||||
:alt="beforeAlt"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
</template>
|
||||
<template #right>
|
||||
<img
|
||||
:src="afterImage"
|
||||
:alt="afterAlt"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
</template>
|
||||
</ImageCompare>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ImageCompare from 'primevue/imagecompare'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
interface ImageCompareValue {
|
||||
before: string
|
||||
after: string
|
||||
beforeAlt?: string
|
||||
afterAlt?: string
|
||||
initialPosition?: number
|
||||
}
|
||||
|
||||
// Image compare widgets typically don't have v-model, they display comparison
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<ImageCompareValue | string>
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const beforeImage = computed(() => {
|
||||
const value = props.widget.value
|
||||
return typeof value === 'string' ? value : value?.before || ''
|
||||
})
|
||||
|
||||
const afterImage = computed(() => {
|
||||
const value = props.widget.value
|
||||
return typeof value === 'string' ? '' : value?.after || ''
|
||||
})
|
||||
|
||||
const beforeAlt = computed(() => {
|
||||
const value = props.widget.value
|
||||
return typeof value === 'object' && value?.beforeAlt
|
||||
? value.beforeAlt
|
||||
: 'Before image'
|
||||
})
|
||||
|
||||
const afterAlt = computed(() => {
|
||||
const value = props.widget.value
|
||||
return typeof value === 'object' && value?.afterAlt
|
||||
? value.afterAlt
|
||||
: 'After image'
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<label v-if="widget.name" class="text-xs opacity-80 min-w-[4em] truncate">{{
|
||||
widget.name
|
||||
}}</label>
|
||||
<InputText
|
||||
v-model="localValue"
|
||||
v-bind="filteredProps"
|
||||
:disabled="readonly"
|
||||
class="flex-grow min-w-[8em] max-w-[20em] text-xs"
|
||||
size="small"
|
||||
@update:model-value="onChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useStringWidgetValue } from '@/composables/graph/useWidgetValue'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import {
|
||||
INPUT_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<string>
|
||||
modelValue: string
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
// Use the composable for consistent widget value handling
|
||||
const { localValue, onChange } = useStringWidgetValue(
|
||||
props.widget,
|
||||
props.modelValue,
|
||||
emit
|
||||
)
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, INPUT_EXCLUDED_PROPS)
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div
|
||||
class="widget-markdown relative w-full cursor-text"
|
||||
@click="startEditing"
|
||||
>
|
||||
<!-- Display mode: Rendered markdown -->
|
||||
<div
|
||||
v-if="!isEditing"
|
||||
class="comfy-markdown-content text-xs min-h-[60px] rounded-lg px-4 py-2 overflow-y-auto"
|
||||
v-html="renderedHtml"
|
||||
/>
|
||||
|
||||
<!-- Edit mode: Textarea -->
|
||||
<Textarea
|
||||
v-else
|
||||
ref="textareaRef"
|
||||
v-model="localValue"
|
||||
:disabled="readonly"
|
||||
class="w-full text-xs"
|
||||
size="small"
|
||||
rows="6"
|
||||
:pt="{
|
||||
root: {
|
||||
onBlur: handleBlur
|
||||
}
|
||||
}"
|
||||
@update:model-value="onChange"
|
||||
@click.stop
|
||||
@keydown.stop
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Textarea from 'primevue/textarea'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import { useStringWidgetValue } from '@/composables/graph/useWidgetValue'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<string>
|
||||
modelValue: string
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
// State
|
||||
const isEditing = ref(false)
|
||||
const textareaRef = ref<InstanceType<typeof Textarea> | undefined>()
|
||||
|
||||
// Use the composable for consistent widget value handling
|
||||
const { localValue, onChange } = useStringWidgetValue(
|
||||
props.widget,
|
||||
props.modelValue,
|
||||
emit
|
||||
)
|
||||
|
||||
// Computed
|
||||
const renderedHtml = computed(() => {
|
||||
return renderMarkdownToHtml(localValue.value || '')
|
||||
})
|
||||
|
||||
// Methods
|
||||
const startEditing = async () => {
|
||||
if (props.readonly || isEditing.value) return
|
||||
|
||||
isEditing.value = true
|
||||
await nextTick()
|
||||
|
||||
// Focus the textarea
|
||||
// @ts-expect-error - $el is an internal property of the Textarea component
|
||||
textareaRef.value?.$el?.focus()
|
||||
}
|
||||
|
||||
const handleBlur = () => {
|
||||
isEditing.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.widget-markdown {
|
||||
background-color: var(--p-muted-color);
|
||||
border: 1px solid var(--p-border-color);
|
||||
border-radius: var(--p-border-radius);
|
||||
}
|
||||
|
||||
.widget-markdown:hover:not(:has(textarea)) {
|
||||
background-color: var(--p-content-hover-background);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<label v-if="widget.name" class="text-xs opacity-80 min-w-[4em] truncate">{{
|
||||
widget.name
|
||||
}}</label>
|
||||
<MultiSelect
|
||||
v-model="localValue"
|
||||
v-bind="filteredProps"
|
||||
:disabled="readonly"
|
||||
class="flex-grow min-w-[8em] max-w-[20em] text-xs"
|
||||
size="small"
|
||||
:pt="{
|
||||
option: 'text-xs'
|
||||
}"
|
||||
@update:model-value="onChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import MultiSelect from 'primevue/multiselect'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import {
|
||||
PANEL_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<any[]>
|
||||
modelValue: any[]
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: any[]]
|
||||
}>()
|
||||
|
||||
// Use the composable for consistent widget value handling
|
||||
const { localValue, onChange } = useWidgetValue({
|
||||
widget: props.widget,
|
||||
modelValue: props.modelValue,
|
||||
defaultValue: [],
|
||||
emit
|
||||
})
|
||||
|
||||
// MultiSelect specific excluded props include overlay styles
|
||||
const MULTISELECT_EXCLUDED_PROPS = [
|
||||
...PANEL_EXCLUDED_PROPS,
|
||||
'overlayStyle'
|
||||
] as const
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, MULTISELECT_EXCLUDED_PROPS)
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center justify-between gap-4"
|
||||
:style="{ height: widgetHeight + 'px' }"
|
||||
>
|
||||
<label v-if="widget.name" class="text-xs opacity-80 min-w-[4em] truncate">{{
|
||||
widget.name
|
||||
}}</label>
|
||||
<Select
|
||||
v-model="localValue"
|
||||
:options="selectOptions"
|
||||
v-bind="filteredProps"
|
||||
:disabled="readonly"
|
||||
class="flex-grow min-w-[8em] max-w-[20em] text-xs"
|
||||
size="small"
|
||||
:pt="{
|
||||
option: 'text-xs'
|
||||
}"
|
||||
@update:model-value="onChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Select from 'primevue/select'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
|
||||
import { COMFY_VUE_NODE_DIMENSIONS } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import {
|
||||
PANEL_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<string | number | undefined>
|
||||
modelValue: string | number | undefined
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string | number | undefined]
|
||||
}>()
|
||||
|
||||
// Use the composable for consistent widget value handling
|
||||
const { localValue, onChange } = useWidgetValue({
|
||||
widget: props.widget,
|
||||
modelValue: props.modelValue,
|
||||
defaultValue: props.widget.options?.values?.[0] || '',
|
||||
emit
|
||||
})
|
||||
|
||||
// Get widget height from litegraph constants
|
||||
const widgetHeight = COMFY_VUE_NODE_DIMENSIONS.components.STANDARD_WIDGET_HEIGHT
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, PANEL_EXCLUDED_PROPS)
|
||||
)
|
||||
|
||||
// Extract select options from widget options
|
||||
const selectOptions = computed(() => {
|
||||
const options = props.widget.options
|
||||
|
||||
if (options?.values && Array.isArray(options.values)) {
|
||||
return options.values
|
||||
}
|
||||
|
||||
return []
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<label v-if="widget.name" class="text-xs opacity-80 min-w-[4em] truncate">{{
|
||||
widget.name
|
||||
}}</label>
|
||||
<SelectButton
|
||||
v-model="localValue"
|
||||
v-bind="filteredProps"
|
||||
:disabled="readonly"
|
||||
class="flex-grow min-w-[8em] max-w-[20em] text-xs"
|
||||
:pt="{
|
||||
pcToggleButton: {
|
||||
label: 'text-xs'
|
||||
}
|
||||
}"
|
||||
@update:model-value="onChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import {
|
||||
STANDARD_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<any>
|
||||
modelValue: any
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: any]
|
||||
}>()
|
||||
|
||||
// Use the composable for consistent widget value handling
|
||||
const { localValue, onChange } = useWidgetValue({
|
||||
widget: props.widget,
|
||||
modelValue: props.modelValue,
|
||||
defaultValue: null,
|
||||
emit
|
||||
})
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, STANDARD_EXCLUDED_PROPS)
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.p-selectbutton) {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
:deep(.p-selectbutton:hover) {
|
||||
border-color: currentColor;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-4" :style="{ height: widgetHeight + 'px' }">
|
||||
<label v-if="widget.name" class="text-xs opacity-80 min-w-[4em] truncate">{{
|
||||
widget.name
|
||||
}}</label>
|
||||
<div class="flex items-center gap-2 flex-1 justify-end">
|
||||
<Slider
|
||||
v-model="localValue"
|
||||
v-bind="filteredProps"
|
||||
:disabled="readonly"
|
||||
class="flex-grow min-w-[8em] max-w-[20em] text-xs"
|
||||
@update:model-value="onChange"
|
||||
/>
|
||||
<InputText
|
||||
v-model="inputDisplayValue"
|
||||
:disabled="readonly"
|
||||
type="number"
|
||||
:min="widget.options?.min"
|
||||
:max="widget.options?.max"
|
||||
:step="stepValue"
|
||||
class="w-[4em] text-center text-xs px-0"
|
||||
size="small"
|
||||
@blur="handleInputBlur"
|
||||
@keydown="handleInputKeydown"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Slider from 'primevue/slider'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { useNumberWidgetValue } from '@/composables/graph/useWidgetValue'
|
||||
import { COMFY_VUE_NODE_DIMENSIONS } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import {
|
||||
STANDARD_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<number>
|
||||
modelValue: number
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: number]
|
||||
}>()
|
||||
|
||||
// Use the composable for consistent widget value handling
|
||||
const { localValue, onChange } = useNumberWidgetValue(
|
||||
props.widget,
|
||||
props.modelValue,
|
||||
emit
|
||||
)
|
||||
|
||||
// Get widget height from litegraph constants
|
||||
const widgetHeight = COMFY_VUE_NODE_DIMENSIONS.components.STANDARD_WIDGET_HEIGHT
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, STANDARD_EXCLUDED_PROPS)
|
||||
)
|
||||
|
||||
// Get the precision value for proper number formatting
|
||||
const precision = computed(() => {
|
||||
const p = props.widget.options?.precision
|
||||
// Treat negative or non-numeric precision as undefined
|
||||
return typeof p === 'number' && p >= 0 ? p : undefined
|
||||
})
|
||||
|
||||
// Calculate the step value based on precision or widget options
|
||||
const stepValue = computed(() => {
|
||||
// If step is explicitly defined in options, use it
|
||||
if (props.widget.options?.step !== undefined) {
|
||||
return String(props.widget.options.step)
|
||||
}
|
||||
// Otherwise, derive from precision
|
||||
if (precision.value !== undefined) {
|
||||
if (precision.value === 0) {
|
||||
return '1'
|
||||
}
|
||||
// For precision > 0, step = 1 / (10^precision)
|
||||
// precision 1 → 0.1, precision 2 → 0.01, etc.
|
||||
return (1 / Math.pow(10, precision.value)).toFixed(precision.value)
|
||||
}
|
||||
// Default to 'any' for unrestricted stepping
|
||||
return 'any'
|
||||
})
|
||||
|
||||
// Format a number according to the widget's precision
|
||||
const formatNumber = (value: number): string => {
|
||||
if (precision.value === undefined) {
|
||||
// No precision specified, return as-is
|
||||
return String(value)
|
||||
}
|
||||
// Use toFixed to ensure correct decimal places
|
||||
return value.toFixed(precision.value)
|
||||
}
|
||||
|
||||
// Apply precision-based rounding to a number
|
||||
const applyPrecision = (value: number): number => {
|
||||
if (precision.value === undefined) {
|
||||
// No precision specified, return as-is
|
||||
return value
|
||||
}
|
||||
if (precision.value === 0) {
|
||||
// Integer precision
|
||||
return Math.round(value)
|
||||
}
|
||||
// Round to the specified decimal places
|
||||
const multiplier = Math.pow(10, precision.value)
|
||||
return Math.round(value * multiplier) / multiplier
|
||||
}
|
||||
|
||||
// Keep a separate display value for the input field
|
||||
const inputDisplayValue = ref(formatNumber(localValue.value))
|
||||
|
||||
// Update display value when localValue changes from external sources
|
||||
watch(localValue, (newValue) => {
|
||||
inputDisplayValue.value = formatNumber(newValue)
|
||||
})
|
||||
|
||||
const handleInputBlur = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const value = target.value || '0'
|
||||
const parsed = parseFloat(value)
|
||||
|
||||
if (!isNaN(parsed)) {
|
||||
// Apply precision-based rounding
|
||||
const roundedValue = applyPrecision(parsed)
|
||||
onChange(roundedValue)
|
||||
// Update display value with proper formatting
|
||||
inputDisplayValue.value = formatNumber(roundedValue)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter') {
|
||||
const target = event.target as HTMLInputElement
|
||||
const value = target.value || '0'
|
||||
const parsed = parseFloat(value)
|
||||
|
||||
if (!isNaN(parsed)) {
|
||||
// Apply precision-based rounding
|
||||
const roundedValue = applyPrecision(parsed)
|
||||
onChange(roundedValue)
|
||||
// Update display value with proper formatting
|
||||
inputDisplayValue.value = formatNumber(roundedValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Remove number input spinners */
|
||||
:deep(input[type='number']::-webkit-inner-spin-button),
|
||||
:deep(input[type='number']::-webkit-outer-spin-button) {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:deep(input[type='number']) {
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<Textarea
|
||||
v-model="localValue"
|
||||
v-bind="filteredProps"
|
||||
:disabled="readonly"
|
||||
class="w-full text-xs"
|
||||
size="small"
|
||||
rows="3"
|
||||
@update:model-value="onChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Textarea from 'primevue/textarea'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useStringWidgetValue } from '@/composables/graph/useWidgetValue'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import {
|
||||
INPUT_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<string>
|
||||
modelValue: string
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
// Use the composable for consistent widget value handling
|
||||
const { localValue, onChange } = useStringWidgetValue(
|
||||
props.widget,
|
||||
props.modelValue,
|
||||
emit
|
||||
)
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, INPUT_EXCLUDED_PROPS)
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<label v-if="widget.name" class="text-xs opacity-80 min-w-[4em] truncate">{{
|
||||
widget.name
|
||||
}}</label>
|
||||
<ToggleSwitch
|
||||
v-model="localValue"
|
||||
v-bind="filteredProps"
|
||||
:disabled="readonly"
|
||||
@update:model-value="onChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ToggleSwitch from 'primevue/toggleswitch'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useBooleanWidgetValue } from '@/composables/graph/useWidgetValue'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import {
|
||||
STANDARD_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<boolean>
|
||||
modelValue: boolean
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
// Use the composable for consistent widget value handling
|
||||
const { localValue, onChange } = useBooleanWidgetValue(
|
||||
props.widget,
|
||||
props.modelValue,
|
||||
emit
|
||||
)
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, STANDARD_EXCLUDED_PROPS)
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.p-toggleswitch .p-toggleswitch-slider) {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
:deep(.p-toggleswitch:hover .p-toggleswitch-slider) {
|
||||
border-color: currentColor;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<label v-if="widget.name" class="text-xs opacity-80 min-w-[4em] truncate">{{
|
||||
widget.name
|
||||
}}</label>
|
||||
<TreeSelect
|
||||
v-model="localValue"
|
||||
v-bind="filteredProps"
|
||||
:disabled="readonly"
|
||||
class="flex-grow min-w-[8em] max-w-[20em] text-xs"
|
||||
size="small"
|
||||
@update:model-value="onChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import TreeSelect from 'primevue/treeselect'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import {
|
||||
PANEL_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<any>
|
||||
modelValue: any
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: any]
|
||||
}>()
|
||||
|
||||
// Use the composable for consistent widget value handling
|
||||
const { localValue, onChange } = useWidgetValue({
|
||||
widget: props.widget,
|
||||
modelValue: props.modelValue,
|
||||
defaultValue: null,
|
||||
emit
|
||||
})
|
||||
|
||||
// TreeSelect specific excluded props
|
||||
const TREE_SELECT_EXCLUDED_PROPS = [
|
||||
...PANEL_EXCLUDED_PROPS,
|
||||
'inputClass',
|
||||
'inputStyle'
|
||||
] as const
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, TREE_SELECT_EXCLUDED_PROPS)
|
||||
)
|
||||
</script>
|
||||
Reference in New Issue
Block a user