mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-02 14:27:40 +00:00
[feat] Add Vue visual widgets
- WidgetColorPicker: Color selection with ColorPicker component - WidgetImage: Single image display with Image component - WidgetImageCompare: Before/after comparison with ImageCompare component - WidgetGalleria: Image gallery/carousel with Galleria component - WidgetChart: Data visualization with Chart component
This commit is contained in:
65
src/components/graph/vueWidgets/WidgetChart.vue
Normal file
65
src/components/graph/vueWidgets/WidgetChart.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label v-if="widget.name" class="text-sm opacity-80">{{
|
||||
widget.name
|
||||
}}</label>
|
||||
<div class="p-4 border border-gray-300 dark-theme:border-gray-600 rounded">
|
||||
<!-- Simple chart placeholder - can be enhanced with Chart.js when available -->
|
||||
<div
|
||||
v-if="!value || !Array.isArray(value.data)"
|
||||
class="text-center text-gray-500 dark-theme:text-gray-400"
|
||||
>
|
||||
No chart data available
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div v-if="value.title" class="text-center font-semibold">
|
||||
{{ value.title }}
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
v-for="(item, index) in value.data"
|
||||
:key="index"
|
||||
class="flex justify-between items-center"
|
||||
>
|
||||
<span class="text-sm">{{ item.label || `Item ${index + 1}` }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="h-3 bg-blue-500 rounded"
|
||||
:style="{
|
||||
width: `${Math.max((item.value / maxValue) * 100, 5)}px`
|
||||
}"
|
||||
></div>
|
||||
<span class="text-sm font-mono">{{ item.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
interface ChartData {
|
||||
title?: string
|
||||
data: Array<{
|
||||
label: string
|
||||
value: number
|
||||
}>
|
||||
}
|
||||
|
||||
const value = defineModel<ChartData>({ required: true })
|
||||
|
||||
defineProps<{
|
||||
widget: SimplifiedWidget<ChartData>
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const maxValue = computed(() => {
|
||||
if (!value.value?.data?.length) return 1
|
||||
return Math.max(...value.value.data.map((item) => item.value))
|
||||
})
|
||||
</script>
|
||||
33
src/components/graph/vueWidgets/WidgetColorPicker.vue
Normal file
33
src/components/graph/vueWidgets/WidgetColorPicker.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label v-if="widget.name" class="text-sm opacity-80">{{
|
||||
widget.name
|
||||
}}</label>
|
||||
<ColorPicker v-model="value" v-bind="filteredProps" :disabled="readonly" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ColorPicker from 'primevue/colorpicker'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import {
|
||||
PANEL_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
const value = defineModel<string>({ required: true })
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<string>
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
// 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>
|
||||
101
src/components/graph/vueWidgets/WidgetGalleria.vue
Normal file
101
src/components/graph/vueWidgets/WidgetGalleria.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label v-if="widget.name" class="text-sm opacity-80">{{
|
||||
widget.name
|
||||
}}</label>
|
||||
<Galleria
|
||||
v-model:activeIndex="activeIndex"
|
||||
:value="galleryImages"
|
||||
v-bind="filteredProps"
|
||||
:disabled="readonly"
|
||||
:show-thumbnails="showThumbnails"
|
||||
:show-indicators="showIndicators"
|
||||
:show-nav-buttons="showNavButtons"
|
||||
class="max-w-full"
|
||||
>
|
||||
<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 }">
|
||||
<img
|
||||
:src="item.thumbnailImageSrc || item.src || item"
|
||||
:alt="item.alt || 'Gallery thumbnail'"
|
||||
class="w-16 h-16 object-cover"
|
||||
/>
|
||||
</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 showIndicators = computed(() => {
|
||||
return (
|
||||
props.widget.options?.showIndicators !== false &&
|
||||
galleryImages.value.length > 1
|
||||
)
|
||||
})
|
||||
|
||||
const showNavButtons = computed(() => {
|
||||
return (
|
||||
props.widget.options?.showNavButtons !== false &&
|
||||
galleryImages.value.length > 1
|
||||
)
|
||||
})
|
||||
</script>
|
||||
29
src/components/graph/vueWidgets/WidgetImage.vue
Normal file
29
src/components/graph/vueWidgets/WidgetImage.vue
Normal file
@@ -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>
|
||||
172
src/components/graph/vueWidgets/WidgetImageCompare.vue
Normal file
172
src/components/graph/vueWidgets/WidgetImageCompare.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label v-if="widget.name" class="text-sm opacity-80">{{
|
||||
widget.name
|
||||
}}</label>
|
||||
<div
|
||||
class="image-compare-container relative overflow-hidden rounded border border-gray-300 dark-theme:border-gray-600"
|
||||
>
|
||||
<div
|
||||
v-if="!beforeImage || !afterImage"
|
||||
class="p-4 text-center text-gray-500 dark-theme:text-gray-400"
|
||||
>
|
||||
Before and after images required
|
||||
</div>
|
||||
<div v-else class="relative">
|
||||
<!-- After image (base layer) -->
|
||||
<Image
|
||||
v-bind="filteredProps"
|
||||
:src="afterImage"
|
||||
class="w-full h-auto"
|
||||
:alt="afterAlt"
|
||||
/>
|
||||
<!-- Before image (overlay layer) -->
|
||||
<div
|
||||
class="absolute top-0 left-0 h-full overflow-hidden transition-all duration-300 ease-in-out"
|
||||
:style="{ width: `${sliderPosition}%` }"
|
||||
>
|
||||
<Image
|
||||
v-bind="filteredProps"
|
||||
:src="beforeImage"
|
||||
class="w-full h-auto"
|
||||
:alt="beforeAlt"
|
||||
/>
|
||||
</div>
|
||||
<!-- Slider handle -->
|
||||
<div
|
||||
class="absolute top-0 h-full w-0.5 bg-white shadow-lg cursor-col-resize z-10 transition-all duration-100"
|
||||
:style="{ left: `${sliderPosition}%` }"
|
||||
@mousedown="startDrag"
|
||||
@touchstart="startDrag"
|
||||
>
|
||||
<div
|
||||
class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-8 h-8 bg-white rounded-full shadow-md flex items-center justify-center"
|
||||
>
|
||||
<div class="w-4 h-4 flex items-center justify-center">
|
||||
<div class="w-0.5 h-3 bg-gray-600 mr-0.5"></div>
|
||||
<div class="w-0.5 h-3 bg-gray-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Labels -->
|
||||
<div
|
||||
v-if="showLabels"
|
||||
class="absolute top-2 left-2 px-2 py-1 bg-black bg-opacity-50 text-white text-xs rounded"
|
||||
>
|
||||
{{ beforeLabel }}
|
||||
</div>
|
||||
<div
|
||||
v-if="showLabels"
|
||||
class="absolute top-2 right-2 px-2 py-1 bg-black bg-opacity-50 text-white text-xs rounded"
|
||||
>
|
||||
{{ afterLabel }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Image from 'primevue/image'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import {
|
||||
IMAGE_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
interface ImageCompareValue {
|
||||
before: string
|
||||
after: string
|
||||
beforeAlt?: string
|
||||
afterAlt?: string
|
||||
beforeLabel?: string
|
||||
afterLabel?: string
|
||||
showLabels?: boolean
|
||||
initialPosition?: number
|
||||
}
|
||||
|
||||
// Image compare widgets typically don't have v-model, they display comparison
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<ImageCompareValue>
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const sliderPosition = ref(50) // Default to 50% (middle)
|
||||
const isDragging = ref(false)
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, IMAGE_EXCLUDED_PROPS)
|
||||
)
|
||||
|
||||
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 : value?.after
|
||||
})
|
||||
|
||||
const beforeAlt = computed(
|
||||
() => props.widget.value?.beforeAlt || 'Before image'
|
||||
)
|
||||
const afterAlt = computed(() => props.widget.value?.afterAlt || 'After image')
|
||||
const beforeLabel = computed(() => props.widget.value?.beforeLabel || 'Before')
|
||||
const afterLabel = computed(() => props.widget.value?.afterLabel || 'After')
|
||||
const showLabels = computed(() => props.widget.value?.showLabels !== false)
|
||||
|
||||
onMounted(() => {
|
||||
if (props.widget.value?.initialPosition !== undefined) {
|
||||
sliderPosition.value = Math.max(
|
||||
0,
|
||||
Math.min(100, props.widget.value.initialPosition)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const startDrag = (event: MouseEvent | TouchEvent) => {
|
||||
if (props.readonly) return
|
||||
|
||||
isDragging.value = true
|
||||
event.preventDefault()
|
||||
|
||||
const handleMouseMove = (e: MouseEvent | TouchEvent) => {
|
||||
if (!isDragging.value) return
|
||||
updateSliderPosition(e)
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
isDragging.value = false
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
document.removeEventListener('touchmove', handleMouseMove)
|
||||
document.removeEventListener('touchend', handleMouseUp)
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
document.addEventListener('touchmove', handleMouseMove)
|
||||
document.addEventListener('touchend', handleMouseUp)
|
||||
}
|
||||
|
||||
const updateSliderPosition = (event: MouseEvent | TouchEvent) => {
|
||||
const container = (event.target as HTMLElement).closest(
|
||||
'.image-compare-container'
|
||||
)
|
||||
if (!container) return
|
||||
|
||||
const rect = container.getBoundingClientRect()
|
||||
const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX
|
||||
const x = clientX - rect.left
|
||||
const percentage = Math.max(0, Math.min(100, (x / rect.width) * 100))
|
||||
|
||||
sliderPosition.value = percentage
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
isDragging.value = false
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user