Compare commits
7 Commits
devtools/r
...
v1.32.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92968f3f9b | ||
|
|
73692464ef | ||
|
|
61b3ca046a | ||
|
|
f8912ebaf4 | ||
|
|
80b87c1277 | ||
|
|
00fa9b691b | ||
|
|
0cff8eb357 |
1
.github/workflows/release-version-bump.yaml
vendored
@@ -59,7 +59,6 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Bump version
|
||||
id: bump-version
|
||||
|
||||
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 82 KiB |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.32.5",
|
||||
"version": "1.32.6",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</div>
|
||||
|
||||
<Panel
|
||||
class="pointer-events-auto z-1000"
|
||||
class="pointer-events-auto z-1010"
|
||||
:style="style"
|
||||
:class="panelClass"
|
||||
:pt="{
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
/**
|
||||
* Composable for managing widget value synchronization between Vue and LiteGraph
|
||||
* Provides consistent pattern for immediate UI updates and LiteGraph callbacks
|
||||
*/
|
||||
import { computed, toValue, ref, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
|
||||
import type { MaybeRefOrGetter } from '@vueuse/core'
|
||||
|
||||
interface UseWidgetValueOptions<T extends WidgetValue = WidgetValue, U = T> {
|
||||
/** The widget configuration from LiteGraph */
|
||||
widget: SimplifiedWidget<T>
|
||||
/** The current value from parent component (can be a value or a getter function) */
|
||||
modelValue: MaybeRefOrGetter<T>
|
||||
/** Default value if modelValue is null/undefined */
|
||||
defaultValue: T
|
||||
/** Emit function from component setup */
|
||||
emit: (event: 'update:modelValue', value: T) => void
|
||||
/** Optional value transformer before sending to LiteGraph */
|
||||
transform?: (value: U) => T
|
||||
}
|
||||
|
||||
interface UseWidgetValueReturn<T extends WidgetValue = WidgetValue, U = T> {
|
||||
/** Local value for immediate UI updates */
|
||||
localValue: Ref<T>
|
||||
/** Handler for user interactions */
|
||||
onChange: (newValue: U) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages widget value synchronization with LiteGraph
|
||||
*
|
||||
* @example
|
||||
* ```vue
|
||||
* const { localValue, onChange } = useWidgetValue({
|
||||
* widget: props.widget,
|
||||
* modelValue: props.modelValue,
|
||||
* defaultValue: ''
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function useWidgetValue<T extends WidgetValue = WidgetValue, U = T>({
|
||||
widget,
|
||||
modelValue,
|
||||
defaultValue,
|
||||
emit,
|
||||
transform
|
||||
}: UseWidgetValueOptions<T, U>): UseWidgetValueReturn<T, U> {
|
||||
// Ref for immediate UI feedback before value flows back through modelValue
|
||||
const newProcessedValue = ref<T | null>(null)
|
||||
|
||||
// Computed that prefers the immediately processed value, then falls back to modelValue
|
||||
const localValue = computed<T>(
|
||||
() => newProcessedValue.value ?? toValue(modelValue) ?? defaultValue
|
||||
)
|
||||
|
||||
// Clear newProcessedValue when modelValue updates (allowing external changes to flow through)
|
||||
watch(
|
||||
() => toValue(modelValue),
|
||||
() => {
|
||||
newProcessedValue.value = null
|
||||
}
|
||||
)
|
||||
|
||||
// Handle user changes
|
||||
const onChange = (newValue: U) => {
|
||||
// Handle different PrimeVue component signatures
|
||||
let processedValue: T
|
||||
if (transform) {
|
||||
processedValue = transform(newValue)
|
||||
} else {
|
||||
// Ensure type safety - only cast when types are compatible
|
||||
if (
|
||||
typeof newValue === typeof defaultValue ||
|
||||
newValue === null ||
|
||||
newValue === undefined
|
||||
) {
|
||||
processedValue = (newValue ?? defaultValue) as T
|
||||
} else {
|
||||
console.warn(
|
||||
`useWidgetValue: Type mismatch for widget ${widget.name}. Expected ${typeof defaultValue}, got ${typeof newValue}`
|
||||
)
|
||||
processedValue = defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
// Set for immediate UI feedback
|
||||
newProcessedValue.value = processedValue
|
||||
|
||||
// Emit to parent component
|
||||
emit('update:modelValue', processedValue)
|
||||
}
|
||||
|
||||
return {
|
||||
localValue: localValue as Ref<T>,
|
||||
onChange
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-specific helper for string widgets
|
||||
*/
|
||||
export function useStringWidgetValue(
|
||||
widget: SimplifiedWidget<string>,
|
||||
modelValue: string | (() => string),
|
||||
emit: (event: 'update:modelValue', value: string) => void
|
||||
) {
|
||||
return useWidgetValue({
|
||||
widget,
|
||||
modelValue,
|
||||
defaultValue: '',
|
||||
emit,
|
||||
transform: (value: string | undefined) => String(value || '') // Handle undefined from PrimeVue
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-specific helper for number widgets
|
||||
*/
|
||||
export function useNumberWidgetValue(
|
||||
widget: SimplifiedWidget<number>,
|
||||
modelValue: number | (() => number),
|
||||
emit: (event: 'update:modelValue', value: number) => void
|
||||
) {
|
||||
return useWidgetValue({
|
||||
widget,
|
||||
modelValue,
|
||||
defaultValue: 0,
|
||||
emit,
|
||||
transform: (value: number | number[]) => {
|
||||
// Handle PrimeVue Slider which can emit number | number[]
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0 ? (value[0] ?? 0) : 0
|
||||
}
|
||||
return Number(value) || 0
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-specific helper for boolean widgets
|
||||
*/
|
||||
export function useBooleanWidgetValue(
|
||||
widget: SimplifiedWidget<boolean>,
|
||||
modelValue: boolean | (() => boolean),
|
||||
emit: (event: 'update:modelValue', value: boolean) => void
|
||||
) {
|
||||
return useWidgetValue({
|
||||
widget,
|
||||
modelValue,
|
||||
defaultValue: false,
|
||||
emit,
|
||||
transform: (value: boolean) => Boolean(value)
|
||||
})
|
||||
}
|
||||
@@ -1537,6 +1537,8 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
return '$0.00125/$0.01 per 1K tokens'
|
||||
} else if (model.includes('gemini-2.5-pro')) {
|
||||
return '$0.00125/$0.01 per 1K tokens'
|
||||
} else if (model.includes('gemini-3-pro-preview')) {
|
||||
return '$0.002/$0.012 per 1K tokens'
|
||||
}
|
||||
// For other Gemini models, show token-based pricing info
|
||||
return 'Token-based'
|
||||
|
||||
@@ -22,9 +22,12 @@ useExtensionService().registerExtension({
|
||||
'preview',
|
||||
['STRING', { multiline: true }],
|
||||
app
|
||||
).widget as DOMWidget<any, any>
|
||||
).widget as DOMWidget<HTMLTextAreaElement, string>
|
||||
|
||||
showValueWidget.options.read_only = true
|
||||
|
||||
showValueWidget.element.readOnly = true
|
||||
showValueWidget.element.disabled = true
|
||||
|
||||
showValueWidget.serialize = false
|
||||
}
|
||||
|
||||
@@ -155,13 +155,6 @@
|
||||
"duplicate": "تكرار",
|
||||
"enterNewName": "أدخل اسمًا جديدًا"
|
||||
},
|
||||
"chatHistory": {
|
||||
"cancelEdit": "إلغاء",
|
||||
"cancelEditTooltip": "إلغاء التعديل",
|
||||
"copiedTooltip": "تم النسخ",
|
||||
"copyTooltip": "نسخ الرسالة إلى الحافظة",
|
||||
"editTooltip": "تعديل الرسالة"
|
||||
},
|
||||
"clipboard": {
|
||||
"errorMessage": "فشل النسخ إلى الحافظة",
|
||||
"errorNotSupported": "API الحافظة غير مدعوم في متصفحك",
|
||||
@@ -965,13 +958,6 @@
|
||||
"title": "عارض ثلاثي الأبعاد (بيتا)"
|
||||
}
|
||||
},
|
||||
"loadWorkflowWarning": {
|
||||
"coreNodesFromVersion": "يتطلب ComfyUI {version}:",
|
||||
"missingNodesDescription": "عند تحميل الرسم البياني، لم يتم العثور على العقد التالية.\nقد يحدث هذا أيضًا إذا كانت إصدار التثبيت المثبت أقل وأن هذا النوع من العقد غير موجود.",
|
||||
"missingNodesTitle": "بعض العقد مفقودة",
|
||||
"outdatedVersion": "بعض العقد تتطلب إصدار أحدث من ComfyUI (الحالي: {version}). يرجى التحديث لاستخدام جميع العقد.",
|
||||
"outdatedVersionGeneric": "بعض العقد تتطلب إصدار أحدث من ComfyUI. يرجى التحديث لاستخدام جميع العقد."
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "لا شيء",
|
||||
"OK": "حسنًا",
|
||||
@@ -1101,29 +1087,6 @@
|
||||
"version": "الإصدار"
|
||||
},
|
||||
"maskEditor": {
|
||||
"Apply to Whole Image": "تطبيق على كامل الصورة",
|
||||
"Brush Settings": "إعدادات الفرشاة",
|
||||
"Brush Shape": "شكل الفرشاة",
|
||||
"Clear": "مسح",
|
||||
"Color Select Settings": "إعدادات اختيار اللون",
|
||||
"Fill Opacity": "شفافية التعبئة",
|
||||
"Hardness": "الصلابة",
|
||||
"Image Layer": "طبقة الصورة",
|
||||
"Invert": "عكس",
|
||||
"Layers": "الطبقات",
|
||||
"Live Preview": "معاينة حية",
|
||||
"Mask Layer": "طبقة القناع",
|
||||
"Mask Opacity": "شفافية القناع",
|
||||
"Mask Tolerance": "تسامح القناع",
|
||||
"Method": "الطريقة",
|
||||
"Opacity": "الشفافية",
|
||||
"Paint Bucket Settings": "إعدادات دلو الطلاء",
|
||||
"Reset to Default": "إعادة إلى الافتراضي",
|
||||
"Selection Opacity": "شفافية التحديد",
|
||||
"Smoothing Precision": "دقة التنعيم",
|
||||
"Stop at mask": "التوقف عند القناع",
|
||||
"Thickness": "السماكة",
|
||||
"Tolerance": "التسامح"
|
||||
},
|
||||
"mediaAsset": {
|
||||
"assetDeletedSuccessfully": "تم حذف الأصل بنجاح",
|
||||
@@ -1189,7 +1152,6 @@
|
||||
"Canvas Performance": "أداء اللوحة",
|
||||
"Canvas Toggle Lock": "تبديل قفل اللوحة",
|
||||
"Check for Custom Node Updates": "التحقق من تحديثات العقد المخصصة",
|
||||
"Check for Updates": "التحقق من التحديثات",
|
||||
"Clear Pending Tasks": "مسح المهام المعلقة",
|
||||
"Clear Workflow": "مسح سير العمل",
|
||||
"Clipspace": "مساحة القص",
|
||||
@@ -1206,7 +1168,6 @@
|
||||
"Custom Nodes Manager": "مدير العقد المخصصة",
|
||||
"Decrease Brush Size in MaskEditor": "تقليل حجم الفرشاة في محرر القناع",
|
||||
"Delete Selected Items": "حذف العناصر المحددة",
|
||||
"Desktop User Guide": "دليل المستخدم لسطح المكتب",
|
||||
"Duplicate Current Workflow": "نسخ سير العمل الحالي",
|
||||
"Edit": "تحرير",
|
||||
"Edit Subgraph Widgets": "تحرير عناصر واجهة المستخدم للرسم البياني الفرعي",
|
||||
@@ -1243,15 +1204,8 @@
|
||||
"Node Links": "روابط العقد",
|
||||
"Open": "فتح",
|
||||
"Open 3D Viewer (Beta) for Selected Node": "فتح عارض ثلاثي الأبعاد (بيتا) للعقدة المحددة",
|
||||
"Open Custom Nodes Folder": "فتح مجلد العقد المخصصة",
|
||||
"Open DevTools": "فتح أدوات المطور",
|
||||
"Open Inputs Folder": "فتح مجلد المدخلات",
|
||||
"Open Logs Folder": "فتح مجلد السجلات",
|
||||
"Open Mask Editor for Selected Node": "فتح محرر القناع للعقدة المحددة",
|
||||
"Open Models Folder": "فتح مجلد النماذج",
|
||||
"Open Outputs Folder": "فتح مجلد المخرجات",
|
||||
"Open Sign In Dialog": "فتح نافذة تسجيل الدخول",
|
||||
"Open extra_model_paths_yaml": "فتح ملف extra_model_paths.yaml",
|
||||
"Pin/Unpin Selected Items": "تثبيت/إلغاء تثبيت العناصر المحددة",
|
||||
"Pin/Unpin Selected Nodes": "تثبيت/إلغاء تثبيت العقد المحددة",
|
||||
"Previous Opened Workflow": "سير العمل السابق المفتوح",
|
||||
@@ -1260,13 +1214,10 @@
|
||||
"Queue Prompt": "قائمة انتظار التعليمات",
|
||||
"Queue Prompt (Front)": "قائمة انتظار التعليمات (أمامي)",
|
||||
"Queue Selected Output Nodes": "قائمة انتظار عقد المخرجات المحددة",
|
||||
"Quit": "خروج",
|
||||
"Redo": "إعادة",
|
||||
"Refresh Node Definitions": "تحديث تعريفات العقد",
|
||||
"Reinstall": "إعادة التثبيت",
|
||||
"Reset View": "إعادة تعيين العرض",
|
||||
"Resize Selected Nodes": "تغيير حجم العقد المحددة",
|
||||
"Restart": "إعادة التشغيل",
|
||||
"Save": "حفظ",
|
||||
"Save As": "حفظ باسم",
|
||||
"Show Keybindings Dialog": "عرض مربع حوار اختصارات لوحة المفاتيح",
|
||||
|
||||
@@ -1,40 +1,4 @@
|
||||
{
|
||||
"Comfy-Desktop_CheckForUpdates": {
|
||||
"label": "Check for Updates"
|
||||
},
|
||||
"Comfy-Desktop_Folders_OpenCustomNodesFolder": {
|
||||
"label": "Open Custom Nodes Folder"
|
||||
},
|
||||
"Comfy-Desktop_Folders_OpenInputsFolder": {
|
||||
"label": "Open Inputs Folder"
|
||||
},
|
||||
"Comfy-Desktop_Folders_OpenLogsFolder": {
|
||||
"label": "Open Logs Folder"
|
||||
},
|
||||
"Comfy-Desktop_Folders_OpenModelConfig": {
|
||||
"label": "Open extra_model_paths.yaml"
|
||||
},
|
||||
"Comfy-Desktop_Folders_OpenModelsFolder": {
|
||||
"label": "Open Models Folder"
|
||||
},
|
||||
"Comfy-Desktop_Folders_OpenOutputsFolder": {
|
||||
"label": "Open Outputs Folder"
|
||||
},
|
||||
"Comfy-Desktop_OpenDevTools": {
|
||||
"label": "Open DevTools"
|
||||
},
|
||||
"Comfy-Desktop_OpenUserGuide": {
|
||||
"label": "Desktop User Guide"
|
||||
},
|
||||
"Comfy-Desktop_Quit": {
|
||||
"label": "Quit"
|
||||
},
|
||||
"Comfy-Desktop_Reinstall": {
|
||||
"label": "Reinstall"
|
||||
},
|
||||
"Comfy-Desktop_Restart": {
|
||||
"label": "Restart"
|
||||
},
|
||||
"Comfy_3DViewer_Open3DViewer": {
|
||||
"label": "Open 3D Viewer (Beta) for Selected Node"
|
||||
},
|
||||
|
||||
@@ -945,18 +945,6 @@
|
||||
"Edit": "Edit",
|
||||
"View": "View",
|
||||
"Help": "Help",
|
||||
"Check for Updates": "Check for Updates",
|
||||
"Open Custom Nodes Folder": "Open Custom Nodes Folder",
|
||||
"Open Inputs Folder": "Open Inputs Folder",
|
||||
"Open Logs Folder": "Open Logs Folder",
|
||||
"Open extra_model_paths_yaml": "Open extra_model_paths.yaml",
|
||||
"Open Models Folder": "Open Models Folder",
|
||||
"Open Outputs Folder": "Open Outputs Folder",
|
||||
"Open DevTools": "Open DevTools",
|
||||
"Desktop User Guide": "Desktop User Guide",
|
||||
"Quit": "Quit",
|
||||
"Reinstall": "Reinstall",
|
||||
"Restart": "Restart",
|
||||
"Open 3D Viewer (Beta) for Selected Node": "Open 3D Viewer (Beta) for Selected Node",
|
||||
"Experimental: Browse Model Assets": "Experimental: Browse Model Assets",
|
||||
"Browse Templates": "Browse Templates",
|
||||
@@ -1335,6 +1323,7 @@
|
||||
"Tripo": "Tripo",
|
||||
"Veo": "Veo",
|
||||
"Vidu": "Vidu",
|
||||
"": "",
|
||||
"camera": "camera",
|
||||
"Wan": "Wan"
|
||||
},
|
||||
@@ -2089,4 +2078,4 @@
|
||||
"replacementInstruction": "Install these nodes to run this workflow, or replace them with installed alternatives. Missing nodes are highlighted in red on the canvas."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2570,7 +2570,7 @@
|
||||
}
|
||||
},
|
||||
"GeminiImageNode": {
|
||||
"display_name": "Google Gemini Image",
|
||||
"display_name": "Nano Banana (Google Gemini Image)",
|
||||
"description": "Edit images synchronously via Google API.",
|
||||
"inputs": {
|
||||
"prompt": {
|
||||
@@ -12770,6 +12770,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wanBlockSwap": {
|
||||
"display_name": "wanBlockSwap",
|
||||
"description": "NOP",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "model"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"WanCameraEmbedding": {
|
||||
"display_name": "WanCameraEmbedding",
|
||||
"inputs": {
|
||||
|
||||
@@ -1,30 +1,4 @@
|
||||
{
|
||||
"Comfy-Desktop_AutoUpdate": {
|
||||
"name": "Automatically check for updates"
|
||||
},
|
||||
"Comfy-Desktop_SendStatistics": {
|
||||
"name": "Send anonymous usage metrics"
|
||||
},
|
||||
"Comfy-Desktop_UV_PypiInstallMirror": {
|
||||
"name": "Pypi Install Mirror",
|
||||
"tooltip": "Default pip install mirror"
|
||||
},
|
||||
"Comfy-Desktop_UV_PythonInstallMirror": {
|
||||
"name": "Python Install Mirror",
|
||||
"tooltip": "Managed Python installations are downloaded from the Astral python-build-standalone project. This variable can be set to a mirror URL to use a different source for Python installations. The provided URL will replace https://github.com/astral-sh/python-build-standalone/releases/download in, e.g., https://github.com/astral-sh/python-build-standalone/releases/download/20240713/cpython-3.12.4%2B20240713-aarch64-apple-darwin-install_only.tar.gz. Distributions can be read from a local directory by using the file:// URL scheme."
|
||||
},
|
||||
"Comfy-Desktop_UV_TorchInstallMirror": {
|
||||
"name": "Torch Install Mirror",
|
||||
"tooltip": "Pip install mirror for pytorch"
|
||||
},
|
||||
"Comfy-Desktop_WindowStyle": {
|
||||
"name": "Window Style",
|
||||
"tooltip": "Custom: Replace the system title bar with ComfyUI's Top menu",
|
||||
"options": {
|
||||
"default": "default",
|
||||
"custom": "custom"
|
||||
}
|
||||
},
|
||||
"Comfy_Canvas_BackgroundImage": {
|
||||
"name": "Canvas background image",
|
||||
"tooltip": "Image URL for the canvas background. You can right-click an image in the outputs panel and select \"Set as Background\" to use it, or upload your own image using the upload button."
|
||||
|
||||
@@ -155,13 +155,6 @@
|
||||
"duplicate": "Duplicar",
|
||||
"enterNewName": "Ingrese un nuevo nombre"
|
||||
},
|
||||
"chatHistory": {
|
||||
"cancelEdit": "Cancelar",
|
||||
"cancelEditTooltip": "Cancelar edición",
|
||||
"copiedTooltip": "Copiado",
|
||||
"copyTooltip": "Copiar mensaje al portapapeles",
|
||||
"editTooltip": "Editar mensaje"
|
||||
},
|
||||
"clipboard": {
|
||||
"errorMessage": "Error al copiar al portapapeles",
|
||||
"errorNotSupported": "API del portapapeles no soportada en su navegador",
|
||||
@@ -965,13 +958,6 @@
|
||||
"title": "Visor 3D (Beta)"
|
||||
}
|
||||
},
|
||||
"loadWorkflowWarning": {
|
||||
"coreNodesFromVersion": "Requiere ComfyUI {version}:",
|
||||
"missingNodesDescription": "Al cargar el grafo, no se encontraron los siguientes tipos de nodos.\nEsto también puede ocurrir si tu versión instalada es anterior y ese tipo de nodo no se puede encontrar.",
|
||||
"missingNodesTitle": "Faltan Algunos Nodos",
|
||||
"outdatedVersion": "Algunos nodos requieren una versión más reciente de ComfyUI (actual: {version}). Por favor, actualiza para usar todos los nodos.",
|
||||
"outdatedVersionGeneric": "Algunos nodos requieren una versión más reciente de ComfyUI. Por favor, actualiza para usar todos los nodos."
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "Ninguno",
|
||||
"OK": "OK",
|
||||
@@ -1101,29 +1087,6 @@
|
||||
"version": "Versión"
|
||||
},
|
||||
"maskEditor": {
|
||||
"Apply to Whole Image": "Aplicar a toda la imagen",
|
||||
"Brush Settings": "Configuración de pincel",
|
||||
"Brush Shape": "Forma de pincel",
|
||||
"Clear": "Borrar",
|
||||
"Color Select Settings": "Configuración de selección de color",
|
||||
"Fill Opacity": "Opacidad de relleno",
|
||||
"Hardness": "Dureza",
|
||||
"Image Layer": "Capa de imagen",
|
||||
"Invert": "Invertir",
|
||||
"Layers": "Capas",
|
||||
"Live Preview": "Vista previa en vivo",
|
||||
"Mask Layer": "Capa de máscara",
|
||||
"Mask Opacity": "Opacidad de máscara",
|
||||
"Mask Tolerance": "Tolerancia de máscara",
|
||||
"Method": "Método",
|
||||
"Opacity": "Opacidad",
|
||||
"Paint Bucket Settings": "Configuración de cubo de pintura",
|
||||
"Reset to Default": "Restablecer a predeterminado",
|
||||
"Selection Opacity": "Opacidad de selección",
|
||||
"Smoothing Precision": "Precisión de suavizado",
|
||||
"Stop at mask": "Detener en máscara",
|
||||
"Thickness": "Grosor",
|
||||
"Tolerance": "Tolerancia"
|
||||
},
|
||||
"mediaAsset": {
|
||||
"assetDeletedSuccessfully": "Recurso eliminado exitosamente",
|
||||
@@ -1189,7 +1152,6 @@
|
||||
"Canvas Performance": "Rendimiento del Lienzo",
|
||||
"Canvas Toggle Lock": "Alternar bloqueo en lienzo",
|
||||
"Check for Custom Node Updates": "Buscar actualizaciones de nodos personalizados",
|
||||
"Check for Updates": "Buscar actualizaciones",
|
||||
"Clear Pending Tasks": "Borrar tareas pendientes",
|
||||
"Clear Workflow": "Borrar flujo de trabajo",
|
||||
"Clipspace": "Espacio de clip",
|
||||
@@ -1206,7 +1168,6 @@
|
||||
"Custom Nodes Manager": "Administrador de Nodos Personalizados",
|
||||
"Decrease Brush Size in MaskEditor": "Disminuir tamaño del pincel en MaskEditor",
|
||||
"Delete Selected Items": "Eliminar elementos seleccionados",
|
||||
"Desktop User Guide": "Guía de usuario de escritorio",
|
||||
"Duplicate Current Workflow": "Duplicar flujo de trabajo actual",
|
||||
"Edit": "Editar",
|
||||
"Edit Subgraph Widgets": "Editar widgets de subgrafo",
|
||||
@@ -1243,15 +1204,8 @@
|
||||
"Node Links": "Enlaces de nodos",
|
||||
"Open": "Abrir",
|
||||
"Open 3D Viewer (Beta) for Selected Node": "Abrir Visor 3D (Beta) para Nodo Seleccionado",
|
||||
"Open Custom Nodes Folder": "Abrir carpeta de nodos personalizados",
|
||||
"Open DevTools": "Abrir DevTools",
|
||||
"Open Inputs Folder": "Abrir carpeta de entradas",
|
||||
"Open Logs Folder": "Abrir carpeta de registros",
|
||||
"Open Mask Editor for Selected Node": "Abrir el editor de mask para el nodo seleccionado",
|
||||
"Open Models Folder": "Abrir carpeta de modelos",
|
||||
"Open Outputs Folder": "Abrir carpeta de salidas",
|
||||
"Open Sign In Dialog": "Abrir diálogo de inicio de sesión",
|
||||
"Open extra_model_paths_yaml": "Abrir extra_model_paths.yaml",
|
||||
"Pin/Unpin Selected Items": "Anclar/Desanclar elementos seleccionados",
|
||||
"Pin/Unpin Selected Nodes": "Anclar/Desanclar nodos seleccionados",
|
||||
"Previous Opened Workflow": "Flujo de trabajo abierto anterior",
|
||||
@@ -1260,13 +1214,10 @@
|
||||
"Queue Prompt": "Indicador de cola",
|
||||
"Queue Prompt (Front)": "Indicador de cola (Frente)",
|
||||
"Queue Selected Output Nodes": "Encolar nodos de salida seleccionados",
|
||||
"Quit": "Salir",
|
||||
"Redo": "Rehacer",
|
||||
"Refresh Node Definitions": "Actualizar definiciones de nodo",
|
||||
"Reinstall": "Reinstalar",
|
||||
"Reset View": "Restablecer vista",
|
||||
"Resize Selected Nodes": "Redimensionar Nodos Seleccionados",
|
||||
"Restart": "Reiniciar",
|
||||
"Save": "Guardar",
|
||||
"Save As": "Guardar como",
|
||||
"Show Keybindings Dialog": "Mostrar diálogo de combinaciones de teclas",
|
||||
|
||||
@@ -155,13 +155,6 @@
|
||||
"duplicate": "Dupliquer",
|
||||
"enterNewName": "Entrez un nouveau nom"
|
||||
},
|
||||
"chatHistory": {
|
||||
"cancelEdit": "Annuler",
|
||||
"cancelEditTooltip": "Annuler la modification",
|
||||
"copiedTooltip": "Copié",
|
||||
"copyTooltip": "Copier le message dans le presse-papiers",
|
||||
"editTooltip": "Modifier le message"
|
||||
},
|
||||
"clipboard": {
|
||||
"errorMessage": "Échec de la copie dans le presse-papiers",
|
||||
"errorNotSupported": "L'API du presse-papiers n'est pas prise en charge par votre navigateur",
|
||||
@@ -965,13 +958,6 @@
|
||||
"title": "Visualiseur 3D (Bêta)"
|
||||
}
|
||||
},
|
||||
"loadWorkflowWarning": {
|
||||
"coreNodesFromVersion": "Nécessite ComfyUI {version} :",
|
||||
"missingNodesDescription": "Lors du chargement du graphe, les types de nœuds suivants n'ont pas été trouvés.\nCela peut également se produire si votre version installée est inférieure et que ce type de nœud ne peut pas être trouvé.",
|
||||
"missingNodesTitle": "Certains nœuds sont manquants",
|
||||
"outdatedVersion": "Certains nœuds nécessitent une version plus récente de ComfyUI (actuelle : {version}). Veuillez mettre à jour pour utiliser tous les nœuds.",
|
||||
"outdatedVersionGeneric": "Certains nœuds nécessitent une version plus récente de ComfyUI. Veuillez mettre à jour pour utiliser tous les nœuds."
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "Aucun",
|
||||
"OK": "OK",
|
||||
@@ -1101,29 +1087,6 @@
|
||||
"version": "Version"
|
||||
},
|
||||
"maskEditor": {
|
||||
"Apply to Whole Image": "Appliquer à toute l'image",
|
||||
"Brush Settings": "Paramètres de brosse",
|
||||
"Brush Shape": "Forme de brosse",
|
||||
"Clear": "Effacer",
|
||||
"Color Select Settings": "Paramètres de sélection de couleur",
|
||||
"Fill Opacity": "Opacité de remplissage",
|
||||
"Hardness": "Dureté",
|
||||
"Image Layer": "Couche d'image",
|
||||
"Invert": "Inverser",
|
||||
"Layers": "Couches",
|
||||
"Live Preview": "Aperçu en direct",
|
||||
"Mask Layer": "Couche de masque",
|
||||
"Mask Opacity": "Opacité du masque",
|
||||
"Mask Tolerance": "Tolérance du masque",
|
||||
"Method": "Méthode",
|
||||
"Opacity": "Opacité",
|
||||
"Paint Bucket Settings": "Paramètres du seau de peinture",
|
||||
"Reset to Default": "Réinitialiser par défaut",
|
||||
"Selection Opacity": "Opacité de sélection",
|
||||
"Smoothing Precision": "Précision de lissage",
|
||||
"Stop at mask": "Arrêter au masque",
|
||||
"Thickness": "Épaisseur",
|
||||
"Tolerance": "Tolérance"
|
||||
},
|
||||
"mediaAsset": {
|
||||
"assetDeletedSuccessfully": "Élément supprimé avec succès",
|
||||
@@ -1189,7 +1152,6 @@
|
||||
"Canvas Performance": "Performances du canevas",
|
||||
"Canvas Toggle Lock": "Basculer le verrouillage de la toile",
|
||||
"Check for Custom Node Updates": "Vérifier les mises à jour des nœuds personnalisés",
|
||||
"Check for Updates": "Vérifier les mises à jour",
|
||||
"Clear Pending Tasks": "Effacer les tâches en attente",
|
||||
"Clear Workflow": "Effacer le flux de travail",
|
||||
"Clipspace": "Espace de clip",
|
||||
@@ -1206,7 +1168,6 @@
|
||||
"Custom Nodes Manager": "Gestionnaire de Nœuds Personnalisés",
|
||||
"Decrease Brush Size in MaskEditor": "Réduire la taille du pinceau dans MaskEditor",
|
||||
"Delete Selected Items": "Supprimer les éléments sélectionnés",
|
||||
"Desktop User Guide": "Guide de l'utilisateur de bureau",
|
||||
"Duplicate Current Workflow": "Dupliquer le flux de travail actuel",
|
||||
"Edit": "Éditer",
|
||||
"Edit Subgraph Widgets": "Modifier les widgets de sous-graphe",
|
||||
@@ -1243,15 +1204,8 @@
|
||||
"Node Links": "Liens de nœuds",
|
||||
"Open": "Ouvrir",
|
||||
"Open 3D Viewer (Beta) for Selected Node": "Ouvrir le visualiseur 3D (Bêta) pour le nœud sélectionné",
|
||||
"Open Custom Nodes Folder": "Ouvrir le dossier des nœuds personnalisés",
|
||||
"Open DevTools": "Ouvrir DevTools",
|
||||
"Open Inputs Folder": "Ouvrir le dossier des entrées",
|
||||
"Open Logs Folder": "Ouvrir le dossier des journaux",
|
||||
"Open Mask Editor for Selected Node": "Ouvrir l’éditeur de mask pour le nœud sélectionné",
|
||||
"Open Models Folder": "Ouvrir le dossier des modèles",
|
||||
"Open Outputs Folder": "Ouvrir le dossier des sorties",
|
||||
"Open Sign In Dialog": "Ouvrir la boîte de dialogue de connexion",
|
||||
"Open extra_model_paths_yaml": "Ouvrir extra_model_paths.yaml",
|
||||
"Pin/Unpin Selected Items": "Épingler/Désépingler les éléments sélectionnés",
|
||||
"Pin/Unpin Selected Nodes": "Épingler/Désépingler les nœuds sélectionnés",
|
||||
"Previous Opened Workflow": "Flux de travail ouvert précédent",
|
||||
@@ -1260,13 +1214,10 @@
|
||||
"Queue Prompt": "Invite de file d'attente",
|
||||
"Queue Prompt (Front)": "Invite de file d'attente (Front)",
|
||||
"Queue Selected Output Nodes": "Mettre en file d’attente les nœuds de sortie sélectionnés",
|
||||
"Quit": "Quitter",
|
||||
"Redo": "Refaire",
|
||||
"Refresh Node Definitions": "Actualiser les définitions de nœud",
|
||||
"Reinstall": "Réinstaller",
|
||||
"Reset View": "Réinitialiser la vue",
|
||||
"Resize Selected Nodes": "Redimensionner les nœuds sélectionnés",
|
||||
"Restart": "Redémarrer",
|
||||
"Save": "Enregistrer",
|
||||
"Save As": "Enregistrer sous",
|
||||
"Show Keybindings Dialog": "Afficher la boîte de dialogue des raccourcis clavier",
|
||||
|
||||
@@ -155,13 +155,6 @@
|
||||
"duplicate": "複製",
|
||||
"enterNewName": "新しい名前を入力"
|
||||
},
|
||||
"chatHistory": {
|
||||
"cancelEdit": "キャンセル",
|
||||
"cancelEditTooltip": "編集をキャンセル",
|
||||
"copiedTooltip": "コピーしました",
|
||||
"copyTooltip": "メッセージをクリップボードにコピー",
|
||||
"editTooltip": "メッセージを編集"
|
||||
},
|
||||
"clipboard": {
|
||||
"errorMessage": "クリップボードへのコピーに失敗しました",
|
||||
"errorNotSupported": "お使いのブラウザではクリップボードAPIがサポートされていません",
|
||||
@@ -965,13 +958,6 @@
|
||||
"title": "3Dビューア(ベータ)"
|
||||
}
|
||||
},
|
||||
"loadWorkflowWarning": {
|
||||
"coreNodesFromVersion": "ComfyUI {version} が必要です:",
|
||||
"missingNodesDescription": "グラフを読み込む際、以下のノードタイプが見つかりませんでした。\nインストールされているバージョンが古く、そのノードタイプが存在しない場合にも発生することがあります。",
|
||||
"missingNodesTitle": "ノードが見つかりません",
|
||||
"outdatedVersion": "一部のノードはより新しいバージョンのComfyUIが必要です(現在のバージョン:{version})。すべてのノードを使用するにはアップデートしてください。",
|
||||
"outdatedVersionGeneric": "一部のノードはより新しいバージョンのComfyUIが必要です。すべてのノードを使用するにはアップデートしてください。"
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "なし",
|
||||
"OK": "OK",
|
||||
@@ -1101,29 +1087,6 @@
|
||||
"version": "バージョン"
|
||||
},
|
||||
"maskEditor": {
|
||||
"Apply to Whole Image": "全画像に適用",
|
||||
"Brush Settings": "ブラシ設定",
|
||||
"Brush Shape": "ブラシ形状",
|
||||
"Clear": "クリア",
|
||||
"Color Select Settings": "色選択設定",
|
||||
"Fill Opacity": "塗りつぶしの不透明度",
|
||||
"Hardness": "硬さ",
|
||||
"Image Layer": "画像レイヤー",
|
||||
"Invert": "反転",
|
||||
"Layers": "レイヤー",
|
||||
"Live Preview": "ライブプレビュー",
|
||||
"Mask Layer": "マスクレイヤー",
|
||||
"Mask Opacity": "マスクの不透明度",
|
||||
"Mask Tolerance": "マスクの許容範囲",
|
||||
"Method": "方法",
|
||||
"Opacity": "不透明度",
|
||||
"Paint Bucket Settings": "ペイントバケツ設定",
|
||||
"Reset to Default": "デフォルトにリセット",
|
||||
"Selection Opacity": "選択範囲の不透明度",
|
||||
"Smoothing Precision": "スムージング精度",
|
||||
"Stop at mask": "マスクで停止",
|
||||
"Thickness": "厚さ",
|
||||
"Tolerance": "許容範囲"
|
||||
},
|
||||
"mediaAsset": {
|
||||
"assetDeletedSuccessfully": "アセットが正常に削除されました",
|
||||
@@ -1189,7 +1152,6 @@
|
||||
"Canvas Performance": "キャンバスのパフォーマンス",
|
||||
"Canvas Toggle Lock": "キャンバスのロックを切り替え",
|
||||
"Check for Custom Node Updates": "カスタムノードのアップデートを確認",
|
||||
"Check for Updates": "更新を確認する",
|
||||
"Clear Pending Tasks": "保留中のタスクをクリア",
|
||||
"Clear Workflow": "ワークフローをクリア",
|
||||
"Clipspace": "クリップスペース",
|
||||
@@ -1206,7 +1168,6 @@
|
||||
"Custom Nodes Manager": "カスタムノードマネージャ",
|
||||
"Decrease Brush Size in MaskEditor": "マスクエディタでブラシサイズを小さくする",
|
||||
"Delete Selected Items": "選択したアイテムを削除",
|
||||
"Desktop User Guide": "デスクトップユーザーガイド",
|
||||
"Duplicate Current Workflow": "現在のワークフローを複製",
|
||||
"Edit": "編集",
|
||||
"Edit Subgraph Widgets": "サブグラフウィジェットを編集",
|
||||
@@ -1243,15 +1204,8 @@
|
||||
"Node Links": "ノードリンク",
|
||||
"Open": "開く",
|
||||
"Open 3D Viewer (Beta) for Selected Node": "選択したノードの3Dビューア(ベータ版)を開く",
|
||||
"Open Custom Nodes Folder": "カスタムノードフォルダを開く",
|
||||
"Open DevTools": "DevToolsを開く",
|
||||
"Open Inputs Folder": "入力フォルダを開く",
|
||||
"Open Logs Folder": "ログフォルダを開く",
|
||||
"Open Mask Editor for Selected Node": "選択したノードのマスクエディタを開く",
|
||||
"Open Models Folder": "モデルフォルダを開く",
|
||||
"Open Outputs Folder": "出力フォルダを開く",
|
||||
"Open Sign In Dialog": "サインインダイアログを開く",
|
||||
"Open extra_model_paths_yaml": "extra_model_paths.yamlを開く",
|
||||
"Pin/Unpin Selected Items": "選択したアイテムのピン留め/ピン留め解除",
|
||||
"Pin/Unpin Selected Nodes": "選択したノードのピン留め/ピン留め解除",
|
||||
"Previous Opened Workflow": "前に開いたワークフロー",
|
||||
@@ -1260,13 +1214,10 @@
|
||||
"Queue Prompt": "キューのプロンプト",
|
||||
"Queue Prompt (Front)": "キューのプロンプト (前面)",
|
||||
"Queue Selected Output Nodes": "選択した出力ノードをキューに追加",
|
||||
"Quit": "終了",
|
||||
"Redo": "やり直す",
|
||||
"Refresh Node Definitions": "ノード定義を更新",
|
||||
"Reinstall": "再インストール",
|
||||
"Reset View": "ビューをリセット",
|
||||
"Resize Selected Nodes": "選択したノードのサイズ変更",
|
||||
"Restart": "再起動",
|
||||
"Save": "保存",
|
||||
"Save As": "名前を付けて保存",
|
||||
"Show Keybindings Dialog": "キーバインドダイアログを表示",
|
||||
|
||||
@@ -155,13 +155,6 @@
|
||||
"duplicate": "복제",
|
||||
"enterNewName": "새 이름 입력"
|
||||
},
|
||||
"chatHistory": {
|
||||
"cancelEdit": "취소",
|
||||
"cancelEditTooltip": "편집 취소",
|
||||
"copiedTooltip": "복사됨",
|
||||
"copyTooltip": "메시지를 클립보드에 복사",
|
||||
"editTooltip": "메시지 편집"
|
||||
},
|
||||
"clipboard": {
|
||||
"errorMessage": "클립보드에 복사하지 못했습니다",
|
||||
"errorNotSupported": "브라우저가 클립보드 API를 지원하지 않습니다.",
|
||||
@@ -965,13 +958,6 @@
|
||||
"title": "3D 뷰어 (베타)"
|
||||
}
|
||||
},
|
||||
"loadWorkflowWarning": {
|
||||
"coreNodesFromVersion": "ComfyUI {version} 이상 필요:",
|
||||
"missingNodesDescription": "그래프를 로드할 때 다음 노드 유형을 찾을 수 없습니다.\n설치된 버전이 낮아 해당 노드 유형을 찾을 수 없는 경우에도 이런 일이 발생할 수 있습니다.",
|
||||
"missingNodesTitle": "일부 노드가 누락되었습니다",
|
||||
"outdatedVersion": "일부 노드는 더 최신 버전의 ComfyUI가 필요합니다 (현재: {version}). 모든 노드를 사용하려면 업데이트해 주세요.",
|
||||
"outdatedVersionGeneric": "일부 노드는 더 최신 버전의 ComfyUI가 필요합니다. 모든 노드를 사용하려면 업데이트해 주세요."
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "없음",
|
||||
"OK": "확인",
|
||||
@@ -1101,29 +1087,6 @@
|
||||
"version": "버전"
|
||||
},
|
||||
"maskEditor": {
|
||||
"Apply to Whole Image": "전체 이미지에 적용",
|
||||
"Brush Settings": "브러시 설정",
|
||||
"Brush Shape": "브러시 모양",
|
||||
"Clear": "지우기",
|
||||
"Color Select Settings": "색상 선택 설정",
|
||||
"Fill Opacity": "채우기 투명도",
|
||||
"Hardness": "경도",
|
||||
"Image Layer": "이미지 레이어",
|
||||
"Invert": "반전",
|
||||
"Layers": "레이어",
|
||||
"Live Preview": "실시간 미리보기",
|
||||
"Mask Layer": "마스크 레이어",
|
||||
"Mask Opacity": "마스크 투명도",
|
||||
"Mask Tolerance": "마스크 허용 오차",
|
||||
"Method": "방법",
|
||||
"Opacity": "투명도",
|
||||
"Paint Bucket Settings": "페인트 버킷 설정",
|
||||
"Reset to Default": "기본값으로 재설정",
|
||||
"Selection Opacity": "선택 투명도",
|
||||
"Smoothing Precision": "스무딩 정밀도",
|
||||
"Stop at mask": "마스크에서 중지",
|
||||
"Thickness": "두께",
|
||||
"Tolerance": "허용 오차"
|
||||
},
|
||||
"mediaAsset": {
|
||||
"assetDeletedSuccessfully": "에셋이 성공적으로 삭제되었습니다",
|
||||
@@ -1189,7 +1152,6 @@
|
||||
"Canvas Performance": "캔버스 성능",
|
||||
"Canvas Toggle Lock": "캔버스 토글 잠금",
|
||||
"Check for Custom Node Updates": "커스텀 노드 업데이트 확인",
|
||||
"Check for Updates": "업데이트 확인",
|
||||
"Clear Pending Tasks": "보류 중인 작업 제거하기",
|
||||
"Clear Workflow": "워크플로 지우기",
|
||||
"Clipspace": "클립스페이스",
|
||||
@@ -1206,7 +1168,6 @@
|
||||
"Custom Nodes Manager": "커스텀 노드 관리자",
|
||||
"Decrease Brush Size in MaskEditor": "마스크 편집기에서 브러시 크기 줄이기",
|
||||
"Delete Selected Items": "선택한 항목 삭제",
|
||||
"Desktop User Guide": "데스크톱 사용자 가이드",
|
||||
"Duplicate Current Workflow": "현재 워크플로 복제",
|
||||
"Edit": "편집",
|
||||
"Edit Subgraph Widgets": "하위 그래프 위젯 편집",
|
||||
@@ -1243,15 +1204,8 @@
|
||||
"Node Links": "노드 링크",
|
||||
"Open": "열기",
|
||||
"Open 3D Viewer (Beta) for Selected Node": "선택한 노드에 대한 3D 뷰어 (베타) 열기",
|
||||
"Open Custom Nodes Folder": "커스텀 노드 폴더 열기",
|
||||
"Open DevTools": "개발자 도구 열기",
|
||||
"Open Inputs Folder": "입력 폴더 열기",
|
||||
"Open Logs Folder": "로그 폴더 열기",
|
||||
"Open Mask Editor for Selected Node": "선택한 노드의 마스크 에디터 열기",
|
||||
"Open Models Folder": "모델 폴더 열기",
|
||||
"Open Outputs Folder": "출력 폴더 열기",
|
||||
"Open Sign In Dialog": "로그인 대화 상자 열기",
|
||||
"Open extra_model_paths_yaml": "extra_model_paths.yaml 열기",
|
||||
"Pin/Unpin Selected Items": "선택한 항목 고정/고정 해제",
|
||||
"Pin/Unpin Selected Nodes": "선택한 노드 고정/고정 해제",
|
||||
"Previous Opened Workflow": "이전 열린 워크플로",
|
||||
@@ -1260,13 +1214,10 @@
|
||||
"Queue Prompt": "실행 대기열에 프롬프트 추가",
|
||||
"Queue Prompt (Front)": "실행 대기열 맨 앞에 프롬프트 추가",
|
||||
"Queue Selected Output Nodes": "선택한 출력 노드 대기열에 추가",
|
||||
"Quit": "종료",
|
||||
"Redo": "다시 실행",
|
||||
"Refresh Node Definitions": "노드 정의 새로 고침",
|
||||
"Reinstall": "재설치",
|
||||
"Reset View": "보기 초기화",
|
||||
"Resize Selected Nodes": "선택된 노드 크기 조정",
|
||||
"Restart": "재시작",
|
||||
"Save": "저장",
|
||||
"Save As": "다른 이름으로 저장",
|
||||
"Show Keybindings Dialog": "단축키 대화상자 표시",
|
||||
|
||||
@@ -155,13 +155,6 @@
|
||||
"duplicate": "Дублировать",
|
||||
"enterNewName": "Введите новое имя"
|
||||
},
|
||||
"chatHistory": {
|
||||
"cancelEdit": "Отмена",
|
||||
"cancelEditTooltip": "Отменить редактирование",
|
||||
"copiedTooltip": "Скопировано",
|
||||
"copyTooltip": "Скопировать сообщение в буфер",
|
||||
"editTooltip": "Редактировать сообщение"
|
||||
},
|
||||
"clipboard": {
|
||||
"errorMessage": "Не удалось скопировать в буфер обмена",
|
||||
"errorNotSupported": "API буфера обмена не поддерживается в вашем браузере",
|
||||
@@ -965,13 +958,6 @@
|
||||
"title": "3D Просмотрщик (Бета)"
|
||||
}
|
||||
},
|
||||
"loadWorkflowWarning": {
|
||||
"coreNodesFromVersion": "Требуется ComfyUI {version}:",
|
||||
"missingNodesDescription": "При загрузке графа следующие типы нод не были найдены.\nЭто также может произойти, если ваша установленная версия ниже и этот тип ноды не может быть найден.",
|
||||
"missingNodesTitle": "Некоторые ноды отсутствуют",
|
||||
"outdatedVersion": "Некоторые узлы требуют более новой версии ComfyUI (текущая: {version}). Пожалуйста, обновите, чтобы использовать все узлы.",
|
||||
"outdatedVersionGeneric": "Некоторые узлы требуют более новой версии ComfyUI. Пожалуйста, обновите, чтобы использовать все узлы."
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "Нет",
|
||||
"OK": "OK",
|
||||
@@ -1101,29 +1087,6 @@
|
||||
"version": "Версия"
|
||||
},
|
||||
"maskEditor": {
|
||||
"Apply to Whole Image": "Применить ко всему изображению",
|
||||
"Brush Settings": "Настройки кисти",
|
||||
"Brush Shape": "Форма кисти",
|
||||
"Clear": "Очистить",
|
||||
"Color Select Settings": "Настройки выбора цвета",
|
||||
"Fill Opacity": "Прозрачность заливки",
|
||||
"Hardness": "Жесткость",
|
||||
"Image Layer": "Слой изображения",
|
||||
"Invert": "Инвертировать",
|
||||
"Layers": "Слои",
|
||||
"Live Preview": "Предварительный просмотр",
|
||||
"Mask Layer": "Слой маски",
|
||||
"Mask Opacity": "Прозрачность маски",
|
||||
"Mask Tolerance": "Толерантность маски",
|
||||
"Method": "Метод",
|
||||
"Opacity": "Прозрачность",
|
||||
"Paint Bucket Settings": "Настройки заливки",
|
||||
"Reset to Default": "Сбросить до стандартных",
|
||||
"Selection Opacity": "Прозрачность выбора",
|
||||
"Smoothing Precision": "Точность сглаживания",
|
||||
"Stop at mask": "Остановиться на маске",
|
||||
"Thickness": "Толщина",
|
||||
"Tolerance": "Толерантность"
|
||||
},
|
||||
"mediaAsset": {
|
||||
"assetDeletedSuccessfully": "Ресурс успешно удален",
|
||||
@@ -1189,7 +1152,6 @@
|
||||
"Canvas Performance": "Производительность холста",
|
||||
"Canvas Toggle Lock": "Переключение блокировки холста",
|
||||
"Check for Custom Node Updates": "Проверить обновления пользовательских узлов",
|
||||
"Check for Updates": "Проверить наличие обновлений",
|
||||
"Clear Pending Tasks": "Очистить ожидающие задачи",
|
||||
"Clear Workflow": "Очистить рабочий процесс",
|
||||
"Clipspace": "Клиппространство",
|
||||
@@ -1206,7 +1168,6 @@
|
||||
"Custom Nodes Manager": "Менеджер Пользовательских Узлов",
|
||||
"Decrease Brush Size in MaskEditor": "Уменьшить размер кисти в MaskEditor",
|
||||
"Delete Selected Items": "Удалить выбранные элементы",
|
||||
"Desktop User Guide": "Руководство пользователя для настольных ПК",
|
||||
"Duplicate Current Workflow": "Дублировать текущий рабочий процесс",
|
||||
"Edit": "Редактировать",
|
||||
"Edit Subgraph Widgets": "Редактировать виджеты подграфа",
|
||||
@@ -1243,15 +1204,8 @@
|
||||
"Node Links": "Связи узлов",
|
||||
"Open": "Открыть",
|
||||
"Open 3D Viewer (Beta) for Selected Node": "Открыть 3D просмотрщик (Бета) для выбранного узла",
|
||||
"Open Custom Nodes Folder": "Открыть папку пользовательских нод",
|
||||
"Open DevTools": "Открыть инструменты разработчика",
|
||||
"Open Inputs Folder": "Открыть папку входных данных",
|
||||
"Open Logs Folder": "Открыть папку журналов",
|
||||
"Open Mask Editor for Selected Node": "Открыть редактор масок для выбранного узла",
|
||||
"Open Models Folder": "Открыть папку моделей",
|
||||
"Open Outputs Folder": "Открыть папку выходных данных",
|
||||
"Open Sign In Dialog": "Открыть окно входа",
|
||||
"Open extra_model_paths_yaml": "Открыть extra_model_paths.yaml",
|
||||
"Pin/Unpin Selected Items": "Закрепить/открепить выбранные элементы",
|
||||
"Pin/Unpin Selected Nodes": "Закрепить/открепить выбранные ноды",
|
||||
"Previous Opened Workflow": "Предыдущий открытый рабочий процесс",
|
||||
@@ -1260,13 +1214,10 @@
|
||||
"Queue Prompt": "Запрос в очереди",
|
||||
"Queue Prompt (Front)": "Запрос в очереди (спереди)",
|
||||
"Queue Selected Output Nodes": "Добавить выбранные выходные узлы в очередь",
|
||||
"Quit": "Выйти",
|
||||
"Redo": "Повторить",
|
||||
"Refresh Node Definitions": "Обновить определения нод",
|
||||
"Reinstall": "Переустановить",
|
||||
"Reset View": "Сбросить вид",
|
||||
"Resize Selected Nodes": "Изменить размер выбранных узлов",
|
||||
"Restart": "Перезапустить",
|
||||
"Save": "Сохранить",
|
||||
"Save As": "Сохранить как",
|
||||
"Show Keybindings Dialog": "Показать диалог клавиш быстрого доступа",
|
||||
|
||||
@@ -155,13 +155,6 @@
|
||||
"duplicate": "Çoğalt",
|
||||
"enterNewName": "Yeni isim girin"
|
||||
},
|
||||
"chatHistory": {
|
||||
"cancelEdit": "İptal",
|
||||
"cancelEditTooltip": "Düzenlemeyi iptal et",
|
||||
"copiedTooltip": "Kopyalandı",
|
||||
"copyTooltip": "Mesajı panoya kopyala",
|
||||
"editTooltip": "Mesajı düzenle"
|
||||
},
|
||||
"clipboard": {
|
||||
"errorMessage": "Panoya kopyalanamadı",
|
||||
"errorNotSupported": "Pano API'si tarayıcınızda desteklenmiyor",
|
||||
@@ -965,13 +958,6 @@
|
||||
"title": "3D Görüntüleyici (Beta)"
|
||||
}
|
||||
},
|
||||
"loadWorkflowWarning": {
|
||||
"coreNodesFromVersion": "ComfyUI {version} gerektirir:",
|
||||
"missingNodesDescription": "Grafik yüklenirken aşağıdaki düğüm türleri bulunamadı.\nBu, yüklü sürümünüz daha düşükse ve bu düğüm türü bulunamazsa da olabilir.",
|
||||
"missingNodesTitle": "Bazı Düğümler Eksik",
|
||||
"outdatedVersion": "Bazı düğümler ComfyUI'nin daha yeni bir sürümünü gerektirir (mevcut: {version}). Tüm düğümleri kullanmak için lütfen güncelleyin.",
|
||||
"outdatedVersionGeneric": "Bazı düğümler ComfyUI'nin daha yeni bir sürümünü gerektirir. Tüm düğümleri kullanmak için lütfen güncelleyin."
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "Yok",
|
||||
"OK": "Tamam",
|
||||
@@ -1101,29 +1087,6 @@
|
||||
"version": "Sürüm"
|
||||
},
|
||||
"maskEditor": {
|
||||
"Apply to Whole Image": "Tüm Görüntüye Uygula",
|
||||
"Brush Settings": "Fırça Ayarları",
|
||||
"Brush Shape": "Fırça Şekli",
|
||||
"Clear": "Temizle",
|
||||
"Color Select Settings": "Renk Seçim Ayarları",
|
||||
"Fill Opacity": "Dolgu Opaklığı",
|
||||
"Hardness": "Sertlik",
|
||||
"Image Layer": "Görüntü Katmanı",
|
||||
"Invert": "Ters Çevir",
|
||||
"Layers": "Katmanlar",
|
||||
"Live Preview": "Canlı Önizleme",
|
||||
"Mask Layer": "Maske Katmanı",
|
||||
"Mask Opacity": "Maske Opaklığı",
|
||||
"Mask Tolerance": "Maske Toleransı",
|
||||
"Method": "Yöntem",
|
||||
"Opacity": "Opaklık",
|
||||
"Paint Bucket Settings": "Boya Kovası Ayarları",
|
||||
"Reset to Default": "Varsayılana Sıfırla",
|
||||
"Selection Opacity": "Seçim Opaklığı",
|
||||
"Smoothing Precision": "Yumuşatma Hassasiyeti",
|
||||
"Stop at mask": "Maskede dur",
|
||||
"Thickness": "Kalınlık",
|
||||
"Tolerance": "Tolerans"
|
||||
},
|
||||
"mediaAsset": {
|
||||
"assetDeletedSuccessfully": "Varlık başarıyla silindi",
|
||||
@@ -1189,7 +1152,6 @@
|
||||
"Canvas Performance": "Tuval Performansı",
|
||||
"Canvas Toggle Lock": "Tuval Kilidini Aç/Kapat",
|
||||
"Check for Custom Node Updates": "Özel Düğüm Güncellemelerini Kontrol Et",
|
||||
"Check for Updates": "Güncellemeleri Kontrol Et",
|
||||
"Clear Pending Tasks": "Bekleyen Görevleri Temizle",
|
||||
"Clear Workflow": "İş Akışını Temizle",
|
||||
"Clipspace": "Clipspace",
|
||||
@@ -1206,7 +1168,6 @@
|
||||
"Custom Nodes Manager": "Özel Düğüm Yöneticisi",
|
||||
"Decrease Brush Size in MaskEditor": "MaskEditor'da Fırça Boyutunu Azalt",
|
||||
"Delete Selected Items": "Seçili Öğeleri Sil",
|
||||
"Desktop User Guide": "Masaüstü Kullanıcı Kılavuzu",
|
||||
"Duplicate Current Workflow": "Mevcut İş Akışını Çoğalt",
|
||||
"Edit": "Düzenle",
|
||||
"Edit Subgraph Widgets": "Alt Grafik Widget'larını Düzenle",
|
||||
@@ -1243,15 +1204,8 @@
|
||||
"Node Links": "Düğüm Bağlantıları",
|
||||
"Open": "Aç",
|
||||
"Open 3D Viewer (Beta) for Selected Node": "Seçili Düğüm için 3D Görüntüleyiciyi (Beta) Aç",
|
||||
"Open Custom Nodes Folder": "Özel Düğümler Klasörünü Aç",
|
||||
"Open DevTools": "Geliştirici Araçlarını Aç",
|
||||
"Open Inputs Folder": "Girişler Klasörünü Aç",
|
||||
"Open Logs Folder": "Kayıtlar Klasörünü Aç",
|
||||
"Open Mask Editor for Selected Node": "Seçili Düğüm için Maske Düzenleyiciyi Aç",
|
||||
"Open Models Folder": "Modeller Klasörünü Aç",
|
||||
"Open Outputs Folder": "Çıktılar Klasörünü Aç",
|
||||
"Open Sign In Dialog": "Giriş Yapma İletişim Kutusunu Aç",
|
||||
"Open extra_model_paths_yaml": "extra_model_paths.yaml dosyasını aç",
|
||||
"Pin/Unpin Selected Items": "Seçili Öğeleri Sabitle/Kaldır",
|
||||
"Pin/Unpin Selected Nodes": "Seçili Düğümleri Sabitle/Kaldır",
|
||||
"Previous Opened Workflow": "Önceki Açılan İş Akışı",
|
||||
@@ -1260,13 +1214,10 @@
|
||||
"Queue Prompt": "İstemi Kuyruğa Al",
|
||||
"Queue Prompt (Front)": "İstemi Kuyruğa Al (Ön)",
|
||||
"Queue Selected Output Nodes": "Seçili Çıktı Düğümlerini Kuyruğa Al",
|
||||
"Quit": "Çık",
|
||||
"Redo": "Yinele",
|
||||
"Refresh Node Definitions": "Düğüm Tanımlarını Yenile",
|
||||
"Reinstall": "Yeniden Yükle",
|
||||
"Reset View": "Görünümü Sıfırla",
|
||||
"Resize Selected Nodes": "Seçili Düğümleri Yeniden Boyutlandır",
|
||||
"Restart": "Yeniden Başlat",
|
||||
"Save": "Kaydet",
|
||||
"Save As": "Farklı Kaydet",
|
||||
"Show Keybindings Dialog": "Tuş Atamaları İletişim Kutusunu Göster",
|
||||
|
||||
@@ -155,13 +155,6 @@
|
||||
"duplicate": "複製",
|
||||
"enterNewName": "輸入新名稱"
|
||||
},
|
||||
"chatHistory": {
|
||||
"cancelEdit": "取消",
|
||||
"cancelEditTooltip": "取消編輯",
|
||||
"copiedTooltip": "已複製",
|
||||
"copyTooltip": "複製訊息到剪貼簿",
|
||||
"editTooltip": "編輯訊息"
|
||||
},
|
||||
"clipboard": {
|
||||
"errorMessage": "複製到剪貼簿失敗",
|
||||
"errorNotSupported": "您的瀏覽器不支援剪貼簿 API",
|
||||
@@ -965,13 +958,6 @@
|
||||
"title": "3D 檢視器(測試版)"
|
||||
}
|
||||
},
|
||||
"loadWorkflowWarning": {
|
||||
"coreNodesFromVersion": "需要 ComfyUI {version}:",
|
||||
"missingNodesDescription": "載入圖形時,找不到以下節點類型。\n如果您安裝的版本較舊,找不到該節點類型,也可能發生這種情況。",
|
||||
"missingNodesTitle": "部分節點缺少",
|
||||
"outdatedVersion": "部分節點需要較新版本的 ComfyUI(目前版本:{version})。請更新以使用所有節點。",
|
||||
"outdatedVersionGeneric": "部分節點需要較新版本的 ComfyUI。請更新以使用所有節點。"
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "無",
|
||||
"OK": "正常",
|
||||
@@ -1101,29 +1087,6 @@
|
||||
"version": "版本"
|
||||
},
|
||||
"maskEditor": {
|
||||
"Apply to Whole Image": "套用至整張圖片",
|
||||
"Brush Settings": "筆刷設定",
|
||||
"Brush Shape": "筆刷形狀",
|
||||
"Clear": "清除",
|
||||
"Color Select Settings": "顏色選取設定",
|
||||
"Fill Opacity": "填充不透明度",
|
||||
"Hardness": "硬度",
|
||||
"Image Layer": "圖像圖層",
|
||||
"Invert": "反轉",
|
||||
"Layers": "圖層",
|
||||
"Live Preview": "即時預覽",
|
||||
"Mask Layer": "遮罩圖層",
|
||||
"Mask Opacity": "遮罩不透明度",
|
||||
"Mask Tolerance": "遮罩容差",
|
||||
"Method": "方法",
|
||||
"Opacity": "不透明度",
|
||||
"Paint Bucket Settings": "油漆桶設定",
|
||||
"Reset to Default": "重設為預設值",
|
||||
"Selection Opacity": "選取不透明度",
|
||||
"Smoothing Precision": "平滑精度",
|
||||
"Stop at mask": "停在遮罩",
|
||||
"Thickness": "粗細",
|
||||
"Tolerance": "容差"
|
||||
},
|
||||
"mediaAsset": {
|
||||
"assetDeletedSuccessfully": "資源刪除成功",
|
||||
@@ -1189,7 +1152,6 @@
|
||||
"Canvas Performance": "畫布效能",
|
||||
"Canvas Toggle Lock": "切換畫布鎖定",
|
||||
"Check for Custom Node Updates": "檢查自訂節點更新",
|
||||
"Check for Updates": "檢查更新",
|
||||
"Clear Pending Tasks": "清除待處理任務",
|
||||
"Clear Workflow": "清除工作流程",
|
||||
"Clipspace": "Clipspace",
|
||||
@@ -1206,7 +1168,6 @@
|
||||
"Custom Nodes Manager": "自訂節點管理員",
|
||||
"Decrease Brush Size in MaskEditor": "在 MaskEditor 中減小筆刷大小",
|
||||
"Delete Selected Items": "刪除選取項目",
|
||||
"Desktop User Guide": "桌面應用程式使用指南",
|
||||
"Duplicate Current Workflow": "複製目前工作流程",
|
||||
"Edit": "編輯",
|
||||
"Edit Subgraph Widgets": "編輯子圖小工具",
|
||||
@@ -1243,15 +1204,8 @@
|
||||
"Node Links": "節點連結",
|
||||
"Open": "開啟",
|
||||
"Open 3D Viewer (Beta) for Selected Node": "為選取節點開啟 3D 檢視器(測試版)",
|
||||
"Open Custom Nodes Folder": "開啟自訂節點資料夾",
|
||||
"Open DevTools": "開啟開發者工具",
|
||||
"Open Inputs Folder": "開啟輸入資料夾",
|
||||
"Open Logs Folder": "開啟日誌資料夾",
|
||||
"Open Mask Editor for Selected Node": "為選取節點開啟遮罩編輯器",
|
||||
"Open Models Folder": "開啟模型資料夾",
|
||||
"Open Outputs Folder": "開啟輸出資料夾",
|
||||
"Open Sign In Dialog": "開啟登入對話框",
|
||||
"Open extra_model_paths_yaml": "開啟 extra_model_paths.yaml",
|
||||
"Pin/Unpin Selected Items": "釘選/取消釘選選取項目",
|
||||
"Pin/Unpin Selected Nodes": "釘選/取消釘選選取節點",
|
||||
"Previous Opened Workflow": "上一個已開啟的工作流程",
|
||||
@@ -1260,13 +1214,10 @@
|
||||
"Queue Prompt": "加入提示至佇列",
|
||||
"Queue Prompt (Front)": "將提示加入佇列前端",
|
||||
"Queue Selected Output Nodes": "將選取的輸出節點加入佇列",
|
||||
"Quit": "離開",
|
||||
"Redo": "重做",
|
||||
"Refresh Node Definitions": "重新整理節點定義",
|
||||
"Reinstall": "重新安裝",
|
||||
"Reset View": "重設視圖",
|
||||
"Resize Selected Nodes": "調整選取節點大小",
|
||||
"Restart": "重新啟動",
|
||||
"Save": "儲存",
|
||||
"Save As": "另存新檔",
|
||||
"Show Keybindings Dialog": "顯示快捷鍵對話框",
|
||||
|
||||
@@ -155,13 +155,6 @@
|
||||
"duplicate": "复制",
|
||||
"enterNewName": "输入新名称"
|
||||
},
|
||||
"chatHistory": {
|
||||
"cancelEdit": "取消",
|
||||
"cancelEditTooltip": "取消编辑",
|
||||
"copiedTooltip": "已复制",
|
||||
"copyTooltip": "复制消息到剪贴板",
|
||||
"editTooltip": "编辑消息"
|
||||
},
|
||||
"clipboard": {
|
||||
"errorMessage": "复制到剪贴板失败",
|
||||
"errorNotSupported": "您的浏览器不支持剪贴板API",
|
||||
@@ -965,13 +958,6 @@
|
||||
"title": "3D 查看器(测试版)"
|
||||
}
|
||||
},
|
||||
"loadWorkflowWarning": {
|
||||
"coreNodesFromVersion": "需要 ComfyUI {version}:",
|
||||
"missingNodesDescription": "在加载工作流时,以下节点未找到。\n这也可能是因为你的ComfyUI版本过低,无法找到新的核心节点。",
|
||||
"missingNodesTitle": "某些节点缺失",
|
||||
"outdatedVersion": "某些节点需要更高版本的 ComfyUI(当前版本:{version})。请更新以使用所有节点。",
|
||||
"outdatedVersionGeneric": "某些节点需要更高版本的 ComfyUI。请更新以使用所有节点。"
|
||||
},
|
||||
"maintenance": {
|
||||
"None": "无",
|
||||
"OK": "确定",
|
||||
@@ -1101,29 +1087,6 @@
|
||||
"version": "版本"
|
||||
},
|
||||
"maskEditor": {
|
||||
"Apply to Whole Image": "应用到整个图像",
|
||||
"Brush Settings": "笔刷设置",
|
||||
"Brush Shape": "笔刷形状",
|
||||
"Clear": "清空",
|
||||
"Color Select Settings": "颜色选择设置",
|
||||
"Fill Opacity": "填充不透明度",
|
||||
"Hardness": "硬度",
|
||||
"Image Layer": "图像图层",
|
||||
"Invert": "反转",
|
||||
"Layers": "图层",
|
||||
"Live Preview": "实时预览",
|
||||
"Mask Layer": "遮罩图层",
|
||||
"Mask Opacity": "遮罩不透明度",
|
||||
"Mask Tolerance": "遮罩容差",
|
||||
"Method": "方法",
|
||||
"Opacity": "不透明度",
|
||||
"Paint Bucket Settings": "油漆桶设置",
|
||||
"Reset to Default": "恢复默认",
|
||||
"Selection Opacity": "选区不透明度",
|
||||
"Smoothing Precision": "平滑精度",
|
||||
"Stop at mask": "在遮罩处停止",
|
||||
"Thickness": "粗细",
|
||||
"Tolerance": "容差"
|
||||
},
|
||||
"mediaAsset": {
|
||||
"assetDeletedSuccessfully": "资产删除成功",
|
||||
@@ -1189,7 +1152,6 @@
|
||||
"Canvas Performance": "画布性能",
|
||||
"Canvas Toggle Lock": "切换视图锁定",
|
||||
"Check for Custom Node Updates": "检查自定义节点更新",
|
||||
"Check for Updates": "检查更新",
|
||||
"Clear Pending Tasks": "清除待处理任务",
|
||||
"Clear Workflow": "清除工作流",
|
||||
"Clipspace": "剪贴空间",
|
||||
@@ -1206,7 +1168,6 @@
|
||||
"Custom Nodes Manager": "自定义节点管理器",
|
||||
"Decrease Brush Size in MaskEditor": "在 MaskEditor 中减小笔刷大小",
|
||||
"Delete Selected Items": "删除选定的项目",
|
||||
"Desktop User Guide": "桌面端用户指南",
|
||||
"Duplicate Current Workflow": "复制当前工作流",
|
||||
"Edit": "编辑",
|
||||
"Edit Subgraph Widgets": "编辑子图组件",
|
||||
@@ -1243,15 +1204,8 @@
|
||||
"Node Links": "节点连接",
|
||||
"Open": "打开",
|
||||
"Open 3D Viewer (Beta) for Selected Node": "为选中节点打开3D查看器(测试版)",
|
||||
"Open Custom Nodes Folder": "打开自定义节点文件夹",
|
||||
"Open DevTools": "打开开发者工具",
|
||||
"Open Inputs Folder": "打开输入文件夹",
|
||||
"Open Logs Folder": "打开日志文件夹",
|
||||
"Open Mask Editor for Selected Node": "为选中节点打开 Mask 编辑器",
|
||||
"Open Models Folder": "打开模型文件夹",
|
||||
"Open Outputs Folder": "打开输出文件夹",
|
||||
"Open Sign In Dialog": "打开登录对话框",
|
||||
"Open extra_model_paths_yaml": "打开 extra_model_paths.yaml",
|
||||
"Pin/Unpin Selected Items": "固定/取消固定选定项目",
|
||||
"Pin/Unpin Selected Nodes": "固定/取消固定选定节点",
|
||||
"Previous Opened Workflow": "上一个打开的工作流",
|
||||
@@ -1260,13 +1214,10 @@
|
||||
"Queue Prompt": "执行提示词",
|
||||
"Queue Prompt (Front)": "执行提示词 (优先执行)",
|
||||
"Queue Selected Output Nodes": "将所选输出节点加入队列",
|
||||
"Quit": "退出",
|
||||
"Redo": "重做",
|
||||
"Refresh Node Definitions": "刷新节点定义",
|
||||
"Reinstall": "重新安装",
|
||||
"Reset View": "重置视图",
|
||||
"Resize Selected Nodes": "调整选定节点的大小",
|
||||
"Restart": "重启",
|
||||
"Save": "保存",
|
||||
"Save As": "另存为",
|
||||
"Show Keybindings Dialog": "显示快捷键对话框",
|
||||
|
||||
@@ -11,12 +11,12 @@
|
||||
>
|
||||
<!-- Video Wrapper -->
|
||||
<div
|
||||
class="relative h-88 w-full grow overflow-hidden rounded-[5px] bg-node-component-surface"
|
||||
class="relative h-full w-full grow overflow-hidden rounded-[5px] bg-node-component-surface"
|
||||
>
|
||||
<!-- Error State -->
|
||||
<div
|
||||
v-if="videoError"
|
||||
class="flex size-full flex-col items-center justify-center bg-smoke-800/50 text-center text-white"
|
||||
class="flex size-full flex-col items-center justify-center bg-smoke-800/50 text-center text-white py-8"
|
||||
>
|
||||
<i class="mb-2 icon-[lucide--video-off] h-12 w-12 text-smoke-400" />
|
||||
<p class="text-sm text-smoke-300">{{ $t('g.videoFailedToLoad') }}</p>
|
||||
|
||||
@@ -11,12 +11,12 @@
|
||||
>
|
||||
<!-- Image Wrapper -->
|
||||
<div
|
||||
class="min-h-88 w-full overflow-hidden rounded-[5px] bg-node-component-surface"
|
||||
class="h-full w-full overflow-hidden rounded-[5px] bg-node-component-surface"
|
||||
>
|
||||
<!-- Error State -->
|
||||
<div
|
||||
v-if="imageError"
|
||||
class="flex size-full flex-col items-center justify-center bg-smoke-800/50 text-center text-white"
|
||||
class="flex size-full flex-col items-center justify-center bg-smoke-800/50 text-center text-white py-8"
|
||||
>
|
||||
<i class="mb-2 icon-[lucide--image-off] h-12 w-12 text-smoke-400" />
|
||||
<p class="text-sm text-smoke-300">{{ $t('g.imageFailedToLoad') }}</p>
|
||||
|
||||
@@ -10,12 +10,10 @@
|
||||
/>
|
||||
|
||||
<!-- Slot Name -->
|
||||
<div class="relative h-full flex items-center">
|
||||
<div class="relative h-full flex items-center min-w-0">
|
||||
<span
|
||||
v-if="!dotOnly"
|
||||
:class="
|
||||
cn('whitespace-nowrap text-xs font-normal lod-toggle', labelClasses)
|
||||
"
|
||||
:class="cn('truncate text-xs font-normal lod-toggle', labelClasses)"
|
||||
>
|
||||
{{ slotData.localized_name || slotData.name || `Input ${index}` }}
|
||||
</span>
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
:data-node-id="nodeData.id"
|
||||
:class="
|
||||
cn(
|
||||
'bg-component-node-background lg-node absolute',
|
||||
'h-min w-min contain-style contain-layout min-h-(--node-height) min-w-(--node-width)',
|
||||
'bg-component-node-background lg-node absolute pb-1',
|
||||
|
||||
'contain-style contain-layout min-w-[225px] min-h-(--node-height) w-(--node-width)',
|
||||
'rounded-2xl touch-none flex flex-col',
|
||||
'border-1 border-solid border-component-node-border',
|
||||
// hover (only when node should handle events)
|
||||
@@ -100,7 +101,7 @@
|
||||
|
||||
<!-- Node Body - rendered based on LOD level and collapsed state -->
|
||||
<div
|
||||
class="flex min-h-min min-w-min flex-1 flex-col gap-1 pb-2"
|
||||
class="flex flex-1 flex-col gap-1 pb-2"
|
||||
:data-testid="`node-body-${nodeData.id}`"
|
||||
>
|
||||
<!-- Slots only rendered at full detail -->
|
||||
@@ -343,12 +344,17 @@ const cornerResizeHandles: CornerResizeHandle[] = [
|
||||
}
|
||||
]
|
||||
|
||||
const MIN_NODE_WIDTH = 225
|
||||
|
||||
const { startResize } = useNodeResize(
|
||||
(result, element) => {
|
||||
if (isCollapsed.value) return
|
||||
|
||||
// Clamp width to minimum to avoid conflicts with CSS min-width
|
||||
const clampedWidth = Math.max(result.size.width, MIN_NODE_WIDTH)
|
||||
|
||||
// Apply size directly to DOM element - ResizeObserver will pick this up
|
||||
element.style.setProperty('--node-width', `${result.size.width}px`)
|
||||
element.style.setProperty('--node-width', `${clampedWidth}px`)
|
||||
element.style.setProperty('--node-height', `${result.size.height}px`)
|
||||
|
||||
const currentPosition = position.value
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
v-else
|
||||
:class="
|
||||
cn(
|
||||
'lg-node-header py-2 pl-2 pr-3 text-sm rounded-t-2xl w-full min-w-50',
|
||||
'lg-node-header py-2 pl-2 pr-3 text-sm rounded-t-2xl w-full min-w-0',
|
||||
'text-node-component-header bg-node-component-header-surface',
|
||||
collapsed && 'rounded-2xl'
|
||||
)
|
||||
@@ -15,9 +15,9 @@
|
||||
:data-testid="`node-header-${nodeData?.id || ''}`"
|
||||
@dblclick="handleDoubleClick"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2.5">
|
||||
<div class="flex items-center justify-between gap-2.5 min-w-0">
|
||||
<!-- Collapse/Expand Button -->
|
||||
<div class="relative grow-1 flex items-center gap-2.5">
|
||||
<div class="relative grow-1 flex items-center gap-2.5 min-w-0 flex-1">
|
||||
<div class="lod-toggle flex shrink-0 items-center px-0.5">
|
||||
<IconButton
|
||||
size="fit-content"
|
||||
@@ -44,16 +44,18 @@
|
||||
<!-- Node Title -->
|
||||
<div
|
||||
v-tooltip.top="tooltipConfig"
|
||||
class="lod-toggle grow-1 items-center gap-2 truncate text-sm font-bold w-15"
|
||||
class="lod-toggle flex min-w-0 flex-1 items-center gap-2 text-sm font-bold"
|
||||
data-testid="node-title"
|
||||
>
|
||||
<EditableText
|
||||
:model-value="displayTitle"
|
||||
:is-editing="isEditing"
|
||||
:input-attrs="{ 'data-testid': 'node-title-input' }"
|
||||
@edit="handleTitleEdit"
|
||||
@cancel="handleTitleCancel"
|
||||
/>
|
||||
<div class="truncate min-w-0 flex-1">
|
||||
<EditableText
|
||||
:model-value="displayTitle"
|
||||
:is-editing="isEditing"
|
||||
:input-attrs="{ 'data-testid': 'node-title-input' }"
|
||||
@edit="handleTitleEdit"
|
||||
@cancel="handleTitleCancel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<LODFallback />
|
||||
</div>
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
<div v-if="renderError" class="node-error p-2 text-sm text-red-500">
|
||||
{{ st('nodeErrors.slots', 'Node Slots Error') }}
|
||||
</div>
|
||||
<div v-else :class="cn('flex justify-between', unifiedWrapperClass)">
|
||||
<div v-else :class="cn('flex justify-between min-w-0', unifiedWrapperClass)">
|
||||
<div
|
||||
v-if="filteredInputs.length"
|
||||
:class="cn('flex flex-col', unifiedDotsClass)"
|
||||
:class="cn('flex flex-col min-w-0', unifiedDotsClass)"
|
||||
>
|
||||
<InputSlot
|
||||
v-for="(input, index) in filteredInputs"
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
<div
|
||||
v-if="nodeData?.outputs?.length"
|
||||
:class="cn('ml-auto flex flex-col', unifiedDotsClass)"
|
||||
:class="cn('ml-auto flex flex-col min-w-0', unifiedDotsClass)"
|
||||
>
|
||||
<OutputSlot
|
||||
v-for="(output, index) in nodeData.outputs"
|
||||
|
||||
@@ -59,10 +59,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TooltipOptions } from 'primevue'
|
||||
import { computed, onErrorCaptured, ref } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
import type {
|
||||
SafeWidgetData,
|
||||
VueNodeData,
|
||||
WidgetSlotMetadata
|
||||
} from '@/composables/graph/useGraphNodeManager'
|
||||
@@ -115,18 +116,18 @@ const { getWidgetTooltip, createTooltipConfig } = useNodeTooltips(
|
||||
interface ProcessedWidget {
|
||||
name: string
|
||||
type: string
|
||||
vueComponent: any
|
||||
vueComponent: Component
|
||||
simplified: SimplifiedWidget
|
||||
value: WidgetValue
|
||||
updateHandler: (value: unknown) => void
|
||||
tooltipConfig: any
|
||||
updateHandler: (value: WidgetValue) => void
|
||||
tooltipConfig: TooltipOptions
|
||||
slotMetadata?: WidgetSlotMetadata
|
||||
}
|
||||
|
||||
const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
if (!nodeData?.widgets) return []
|
||||
|
||||
const widgets = nodeData.widgets as SafeWidgetData[]
|
||||
const { widgets } = nodeData
|
||||
const result: ProcessedWidget[] = []
|
||||
|
||||
for (const widget of widgets) {
|
||||
@@ -160,14 +161,14 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
spec: widget.spec
|
||||
}
|
||||
|
||||
const updateHandler = (value: unknown) => {
|
||||
const updateHandler = (value: WidgetValue) => {
|
||||
// Update the widget value directly
|
||||
widget.value = value as WidgetValue
|
||||
widget.value = value
|
||||
|
||||
// Skip callback for asset widgets - their callback opens the modal,
|
||||
// but Vue asset mode handles selection through the dropdown
|
||||
if (widget.callback && widget.type !== 'asset') {
|
||||
widget.callback(value)
|
||||
if (widget.type !== 'asset') {
|
||||
widget.callback?.(value)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<div v-if="renderError" class="node-error p-1 text-xs text-red-500">⚠️</div>
|
||||
<div v-else v-tooltip.right="tooltipConfig" :class="slotWrapperClass">
|
||||
<div class="relative h-full flex items-center">
|
||||
<div class="relative h-full flex items-center min-w-0">
|
||||
<!-- Slot Name -->
|
||||
<span
|
||||
v-if="!dotOnly"
|
||||
class="lod-toggle text-xs font-normal whitespace-nowrap text-node-component-slot-text"
|
||||
class="lod-toggle text-xs font-normal truncate text-node-component-slot-text"
|
||||
>
|
||||
{{ slotData.localized_name || slotData.name || `Output ${index}` }}
|
||||
</span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type {
|
||||
TooltipDirectivePassThroughOptions,
|
||||
TooltipOptions,
|
||||
TooltipPassThroughMethodOptions
|
||||
} from 'primevue/tooltip'
|
||||
import { computed, ref, unref } from 'vue'
|
||||
@@ -148,7 +148,7 @@ export function useNodeTooltips(nodeType: MaybeRef<string>) {
|
||||
* Create tooltip configuration object for v-tooltip directive
|
||||
* Components wrap this in computed() for reactivity
|
||||
*/
|
||||
const createTooltipConfig = (text: string) => {
|
||||
const createTooltipConfig = (text: string): TooltipOptions => {
|
||||
const tooltipDelay = settingsStore.get('LiteGraph.Node.TooltipDelay')
|
||||
const tooltipText = text || ''
|
||||
|
||||
@@ -174,7 +174,7 @@ export function useNodeTooltips(nodeType: MaybeRef<string>) {
|
||||
context.right && 'border-r-node-component-tooltip-border'
|
||||
)
|
||||
})
|
||||
} as TooltipDirectivePassThroughOptions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { app as comfyApp } from '@/scripts/app'
|
||||
import type { SubgraphInputNode } from '@/lib/litegraph/src/subgraph/SubgraphInputNode'
|
||||
import type { SubgraphOutputNode } from '@/lib/litegraph/src/subgraph/SubgraphOutputNode'
|
||||
|
||||
const SCALE_FACTOR = 1.75
|
||||
const SCALE_FACTOR = 1.2
|
||||
|
||||
export function ensureCorrectLayoutScale(
|
||||
renderer?: rendererType,
|
||||
@@ -72,23 +72,32 @@ export function ensureCorrectLayoutScale(
|
||||
? 1 / SCALE_FACTOR
|
||||
: 1
|
||||
|
||||
//TODO: once we remove the need for LiteGraph.NODE_TITLE_HEIGHT in vue nodes we nned to remove everything here.
|
||||
for (const node of graph.nodes) {
|
||||
const lgNode = lgNodesById.get(node.id)
|
||||
if (!lgNode) continue
|
||||
|
||||
const lgBodyY = lgNode.pos[1]
|
||||
|
||||
const adjustedY = needsDownscale
|
||||
? lgBodyY - LiteGraph.NODE_TITLE_HEIGHT / 2
|
||||
: lgBodyY
|
||||
|
||||
const relativeX = lgNode.pos[0] - originX
|
||||
const relativeY = lgBodyY - originY
|
||||
const relativeY = adjustedY - originY
|
||||
const newX = originX + relativeX * scaleFactor
|
||||
const newY = originY + relativeY * scaleFactor
|
||||
const scaledY = originY + relativeY * scaleFactor
|
||||
const newWidth = lgNode.width * scaleFactor
|
||||
const newHeight = lgNode.height * scaleFactor
|
||||
|
||||
const finalY = needsUpscale
|
||||
? scaledY + LiteGraph.NODE_TITLE_HEIGHT / 2
|
||||
: scaledY
|
||||
|
||||
// Directly update LiteGraph node to ensure immediate consistency
|
||||
// Dont need to reference vue directly because the pos and dims are already in yjs
|
||||
lgNode.pos[0] = newX
|
||||
lgNode.pos[1] = newY
|
||||
lgNode.pos[1] = finalY
|
||||
lgNode.size[0] = newWidth
|
||||
lgNode.size[1] =
|
||||
newHeight - (needsDownscale ? LiteGraph.NODE_TITLE_HEIGHT : 0)
|
||||
@@ -99,7 +108,7 @@ export function ensureCorrectLayoutScale(
|
||||
nodeId: String(lgNode.id),
|
||||
bounds: {
|
||||
x: newX,
|
||||
y: newY,
|
||||
y: finalY,
|
||||
width: newWidth,
|
||||
height: newHeight - (needsDownscale ? LiteGraph.NODE_TITLE_HEIGHT : 0)
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ import WidgetSelect from './WidgetSelect.vue'
|
||||
import AudioPreviewPlayer from './audio/AudioPreviewPlayer.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<string | number | undefined>
|
||||
widget: SimplifiedWidget<string | undefined>
|
||||
readonly?: boolean
|
||||
nodeId: string
|
||||
}>()
|
||||
|
||||
@@ -16,9 +16,11 @@
|
||||
}"
|
||||
@update:model-value="onPickerUpdate"
|
||||
/>
|
||||
<span class="text-xs" data-testid="widget-color-text">{{
|
||||
toHexFromFormat(localValue, format)
|
||||
}}</span>
|
||||
<span
|
||||
class="text-xs truncate min-w-[4ch]"
|
||||
data-testid="widget-color-text"
|
||||
>{{ toHexFromFormat(localValue, format) }}</span
|
||||
>
|
||||
</label>
|
||||
</WidgetLayoutField>
|
||||
</template>
|
||||
|
||||
@@ -1,500 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Button from 'primevue/button'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Select from 'primevue/select'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import { createMockFile, createMockWidget } from '../testUtils'
|
||||
import WidgetFileUpload from './WidgetFileUpload.vue'
|
||||
|
||||
describe('WidgetFileUpload File Handling', () => {
|
||||
const mountComponent = (
|
||||
widget: SimplifiedWidget<File[] | null>,
|
||||
modelValue: File[] | null,
|
||||
readonly = false
|
||||
) => {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
...enMessages,
|
||||
widgetFileUpload: {
|
||||
dropPrompt: 'Drop your file or',
|
||||
browseFiles: 'Browse Files'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return mount(WidgetFileUpload, {
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n],
|
||||
components: { Button, Select }
|
||||
},
|
||||
props: {
|
||||
widget,
|
||||
modelValue,
|
||||
readonly
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const mockObjectURL = 'blob:mock-url'
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock URL.createObjectURL and revokeObjectURL
|
||||
global.URL.createObjectURL = vi.fn(() => mockObjectURL)
|
||||
global.URL.revokeObjectURL = vi.fn()
|
||||
})
|
||||
|
||||
describe('Initial States', () => {
|
||||
it('shows upload UI when no file is selected', () => {
|
||||
const widget = createMockWidget<File[] | null>(null, {}, undefined, {
|
||||
name: 'test_file_upload',
|
||||
type: 'file'
|
||||
})
|
||||
const wrapper = mountComponent(widget, null)
|
||||
|
||||
expect(wrapper.text()).toContain('Drop your file or')
|
||||
expect(wrapper.text()).toContain('Browse Files')
|
||||
expect(wrapper.find('button').text()).toBe('Browse Files')
|
||||
})
|
||||
|
||||
it('renders file input with correct attributes', () => {
|
||||
const widget = createMockWidget<File[] | null>(
|
||||
null,
|
||||
{ accept: 'image/*' },
|
||||
undefined,
|
||||
{
|
||||
name: 'test_file_upload',
|
||||
type: 'file'
|
||||
}
|
||||
)
|
||||
const wrapper = mountComponent(widget, null)
|
||||
|
||||
const fileInput = wrapper.find('input[type="file"]')
|
||||
expect(fileInput.exists()).toBe(true)
|
||||
expect(fileInput.attributes('accept')).toBe('image/*')
|
||||
expect(fileInput.classes()).toContain('hidden')
|
||||
})
|
||||
})
|
||||
|
||||
describe('File Selection', () => {
|
||||
it('triggers file input when browse button is clicked', async () => {
|
||||
const widget = createMockWidget<File[] | null>(null, {}, undefined, {
|
||||
name: 'test_file_upload',
|
||||
type: 'file'
|
||||
})
|
||||
const wrapper = mountComponent(widget, null)
|
||||
|
||||
const fileInput = wrapper.find('input[type="file"]')
|
||||
const inputElement = fileInput.element
|
||||
if (!(inputElement instanceof HTMLInputElement)) {
|
||||
throw new Error('Expected HTMLInputElement')
|
||||
}
|
||||
const clickSpy = vi.spyOn(inputElement, 'click')
|
||||
|
||||
const browseButton = wrapper.find('button')
|
||||
await browseButton.trigger('click')
|
||||
|
||||
expect(clickSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles file selection', async () => {
|
||||
const mockCallback = vi.fn()
|
||||
const widget = createMockWidget<File[] | null>(null, {}, mockCallback, {
|
||||
name: 'test_file_upload',
|
||||
type: 'file'
|
||||
})
|
||||
const wrapper = mountComponent(widget, null)
|
||||
|
||||
const file = createMockFile('test.jpg', 'image/jpeg')
|
||||
const fileInput = wrapper.find('input[type="file"]')
|
||||
|
||||
Object.defineProperty(fileInput.element, 'files', {
|
||||
value: [file],
|
||||
writable: false
|
||||
})
|
||||
|
||||
await fileInput.trigger('change')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toEqual([[file]])
|
||||
})
|
||||
|
||||
it('resets file input after selection', async () => {
|
||||
const widget = createMockWidget<File[] | null>(null, {}, undefined, {
|
||||
name: 'test_file_upload',
|
||||
type: 'file'
|
||||
})
|
||||
const wrapper = mountComponent(widget, null)
|
||||
|
||||
const file = createMockFile('test.jpg', 'image/jpeg')
|
||||
const fileInput = wrapper.find('input[type="file"]')
|
||||
|
||||
Object.defineProperty(fileInput.element, 'files', {
|
||||
value: [file],
|
||||
writable: false
|
||||
})
|
||||
|
||||
await fileInput.trigger('change')
|
||||
|
||||
const inputElement = fileInput.element
|
||||
if (!(inputElement instanceof HTMLInputElement)) {
|
||||
throw new Error('Expected HTMLInputElement')
|
||||
}
|
||||
expect(inputElement.value).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Image File Display', () => {
|
||||
it('shows image preview for image files', () => {
|
||||
const imageFile = createMockFile('test.jpg', 'image/jpeg')
|
||||
const widget = createMockWidget<File[] | null>(
|
||||
[imageFile],
|
||||
{},
|
||||
undefined,
|
||||
{
|
||||
name: 'test_file_upload',
|
||||
type: 'file'
|
||||
}
|
||||
)
|
||||
const wrapper = mountComponent(widget, [imageFile])
|
||||
|
||||
const img = wrapper.find('img')
|
||||
expect(img.exists()).toBe(true)
|
||||
expect(img.attributes('src')).toBe(mockObjectURL)
|
||||
expect(img.attributes('alt')).toBe('test.jpg')
|
||||
})
|
||||
|
||||
it('shows select dropdown with filename for images', () => {
|
||||
const imageFile = createMockFile('test.jpg', 'image/jpeg')
|
||||
const widget = createMockWidget<File[] | null>(
|
||||
[imageFile],
|
||||
{},
|
||||
undefined,
|
||||
{
|
||||
name: 'test_file_upload',
|
||||
type: 'file'
|
||||
}
|
||||
)
|
||||
const wrapper = mountComponent(widget, [imageFile])
|
||||
|
||||
const select = wrapper.getComponent({ name: 'Select' })
|
||||
expect(select.props('modelValue')).toBe('test.jpg')
|
||||
expect(select.props('options')).toEqual(['test.jpg'])
|
||||
expect(select.props('disabled')).toBe(true)
|
||||
})
|
||||
|
||||
it('shows edit and delete buttons on hover for images', () => {
|
||||
const imageFile = createMockFile('test.jpg', 'image/jpeg')
|
||||
const widget = createMockWidget<File[] | null>(
|
||||
[imageFile],
|
||||
{},
|
||||
undefined,
|
||||
{
|
||||
name: 'test_file_upload',
|
||||
type: 'file'
|
||||
}
|
||||
)
|
||||
const wrapper = mountComponent(widget, [imageFile])
|
||||
|
||||
// The pi-pencil and pi-times classes are on the <i> elements inside the buttons
|
||||
const editIcon = wrapper.find('i.pi-pencil')
|
||||
const deleteIcon = wrapper.find('i.pi-times')
|
||||
|
||||
expect(editIcon.exists()).toBe(true)
|
||||
expect(deleteIcon.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Audio File Display', () => {
|
||||
it('shows audio player for audio files', () => {
|
||||
const audioFile = createMockFile('test.mp3', 'audio/mpeg')
|
||||
const widget = createMockWidget<File[] | null>(
|
||||
[audioFile],
|
||||
{},
|
||||
undefined,
|
||||
{
|
||||
name: 'test_file_upload',
|
||||
type: 'file'
|
||||
}
|
||||
)
|
||||
const wrapper = mountComponent(widget, [audioFile])
|
||||
|
||||
expect(wrapper.find('.pi-volume-up').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('test.mp3')
|
||||
expect(wrapper.text()).toContain('1.0 KB')
|
||||
})
|
||||
|
||||
it('shows file size for audio files', () => {
|
||||
const audioFile = createMockFile('test.mp3', 'audio/mpeg', 2048)
|
||||
const widget = createMockWidget<File[] | null>(
|
||||
[audioFile],
|
||||
{},
|
||||
undefined,
|
||||
{
|
||||
name: 'test_file_upload',
|
||||
type: 'file'
|
||||
}
|
||||
)
|
||||
const wrapper = mountComponent(widget, [audioFile])
|
||||
|
||||
expect(wrapper.text()).toContain('2.0 KB')
|
||||
})
|
||||
|
||||
it('shows delete button for audio files', () => {
|
||||
const audioFile = createMockFile('test.mp3', 'audio/mpeg')
|
||||
const widget = createMockWidget<File[] | null>(
|
||||
[audioFile],
|
||||
{},
|
||||
undefined,
|
||||
{
|
||||
name: 'test_file_upload',
|
||||
type: 'file'
|
||||
}
|
||||
)
|
||||
const wrapper = mountComponent(widget, [audioFile])
|
||||
|
||||
const deleteIcon = wrapper.find('i.pi-times')
|
||||
expect(deleteIcon.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('File Type Detection', () => {
|
||||
const imageFiles = [
|
||||
{ name: 'image.jpg', type: 'image/jpeg' },
|
||||
{ name: 'image.png', type: 'image/png' }
|
||||
]
|
||||
|
||||
const audioFiles = [
|
||||
{ name: 'audio.mp3', type: 'audio/mpeg' },
|
||||
{ name: 'audio.wav', type: 'audio/wav' }
|
||||
]
|
||||
|
||||
const normalFiles = [
|
||||
{ name: 'video.mp4', type: 'video/mp4' },
|
||||
{ name: 'document.pdf', type: 'application/pdf' }
|
||||
]
|
||||
|
||||
it.for(imageFiles)(
|
||||
'shows image preview for $type files',
|
||||
({ name, type }) => {
|
||||
const file = createMockFile(name, type)
|
||||
const widget = createMockWidget<File[] | null>([file], {}, undefined, {
|
||||
name: 'test_file_upload',
|
||||
type: 'file'
|
||||
})
|
||||
const wrapper = mountComponent(widget, [file])
|
||||
|
||||
expect(wrapper.find('img').exists()).toBe(true)
|
||||
expect(wrapper.find('.pi-volume-up').exists()).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it.for(audioFiles)(
|
||||
'shows audio player for $type files',
|
||||
({ name, type }) => {
|
||||
const file = createMockFile(name, type)
|
||||
const widget = createMockWidget<File[] | null>([file], {}, undefined, {
|
||||
name: 'test_file_upload',
|
||||
type: 'file'
|
||||
})
|
||||
const wrapper = mountComponent(widget, [file])
|
||||
|
||||
expect(wrapper.find('.pi-volume-up').exists()).toBe(true)
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it.for(normalFiles)('shows normal UI for $type files', ({ name, type }) => {
|
||||
const file = createMockFile(name, type)
|
||||
const widget = createMockWidget<File[] | null>([file], {}, undefined, {
|
||||
name: 'test_file_upload',
|
||||
type: 'file'
|
||||
})
|
||||
const wrapper = mountComponent(widget, [file])
|
||||
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
expect(wrapper.find('.pi-volume-up').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('File Actions', () => {
|
||||
it('clears file when delete button is clicked', async () => {
|
||||
const imageFile = createMockFile('test.jpg', 'image/jpeg')
|
||||
const widget = createMockWidget<File[] | null>(
|
||||
[imageFile],
|
||||
{},
|
||||
undefined,
|
||||
{
|
||||
name: 'test_file_upload',
|
||||
type: 'file'
|
||||
}
|
||||
)
|
||||
const wrapper = mountComponent(widget, [imageFile])
|
||||
|
||||
// Find button that contains the times icon
|
||||
const buttons = wrapper.findAll('button')
|
||||
const deleteButton = buttons.find((button) =>
|
||||
button.find('i.pi-times').exists()
|
||||
)
|
||||
|
||||
if (!deleteButton) {
|
||||
throw new Error('Delete button with times icon not found')
|
||||
}
|
||||
|
||||
await deleteButton.trigger('click')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![emitted!.length - 1]).toEqual([null])
|
||||
})
|
||||
|
||||
it('handles edit button click', async () => {
|
||||
const imageFile = createMockFile('test.jpg', 'image/jpeg')
|
||||
const widget = createMockWidget<File[] | null>(
|
||||
[imageFile],
|
||||
{},
|
||||
undefined,
|
||||
{
|
||||
name: 'test_file_upload',
|
||||
type: 'file'
|
||||
}
|
||||
)
|
||||
const wrapper = mountComponent(widget, [imageFile])
|
||||
|
||||
// Find button that contains the pencil icon
|
||||
const buttons = wrapper.findAll('button')
|
||||
const editButton = buttons.find((button) =>
|
||||
button.find('i.pi-pencil').exists()
|
||||
)
|
||||
|
||||
if (!editButton) {
|
||||
throw new Error('Edit button with pencil icon not found')
|
||||
}
|
||||
|
||||
// Should not throw error when clicked (TODO: implement edit functionality)
|
||||
await expect(editButton.trigger('click')).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it('triggers file input when folder button is clicked', async () => {
|
||||
const imageFile = createMockFile('test.jpg', 'image/jpeg')
|
||||
const widget = createMockWidget<File[] | null>(
|
||||
[imageFile],
|
||||
{},
|
||||
undefined,
|
||||
{
|
||||
name: 'test_file_upload',
|
||||
type: 'file'
|
||||
}
|
||||
)
|
||||
const wrapper = mountComponent(widget, [imageFile])
|
||||
|
||||
const fileInput = wrapper.find('input[type="file"]')
|
||||
const inputElement = fileInput.element
|
||||
if (!(inputElement instanceof HTMLInputElement)) {
|
||||
throw new Error('Expected HTMLInputElement')
|
||||
}
|
||||
const clickSpy = vi.spyOn(inputElement, 'click')
|
||||
|
||||
// Find PrimeVue Button component with folder icon
|
||||
const folderButton = wrapper.getComponent(Button)
|
||||
|
||||
await folderButton.trigger('click')
|
||||
|
||||
expect(clickSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles empty file selection gracefully', async () => {
|
||||
const widget = createMockWidget<File[] | null>(null, {}, undefined, {
|
||||
name: 'test_file_upload',
|
||||
type: 'file'
|
||||
})
|
||||
const wrapper = mountComponent(widget, null)
|
||||
|
||||
const fileInput = wrapper.find('input[type="file"]')
|
||||
|
||||
Object.defineProperty(fileInput.element, 'files', {
|
||||
value: [],
|
||||
writable: false
|
||||
})
|
||||
|
||||
await fileInput.trigger('change')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeUndefined()
|
||||
})
|
||||
|
||||
it('handles missing file input gracefully', () => {
|
||||
const widget = createMockWidget<File[] | null>(null, {}, undefined, {
|
||||
name: 'test_file_upload',
|
||||
type: 'file'
|
||||
})
|
||||
const wrapper = mountComponent(widget, null)
|
||||
|
||||
// Remove file input ref to simulate missing element
|
||||
wrapper.vm.$refs.fileInputRef = null
|
||||
|
||||
// Should not throw error when method exists
|
||||
const vm = wrapper.vm as any
|
||||
expect(() => vm.triggerFileInput?.()).not.toThrow()
|
||||
})
|
||||
|
||||
it('handles clearing file when no file input exists', async () => {
|
||||
const imageFile = createMockFile('test.jpg', 'image/jpeg')
|
||||
const widget = createMockWidget<File[] | null>(
|
||||
[imageFile],
|
||||
{},
|
||||
undefined,
|
||||
{
|
||||
name: 'test_file_upload',
|
||||
type: 'file'
|
||||
}
|
||||
)
|
||||
const wrapper = mountComponent(widget, [imageFile])
|
||||
|
||||
// Remove file input ref to simulate missing element
|
||||
wrapper.vm.$refs.fileInputRef = null
|
||||
|
||||
// Find button that contains the times icon
|
||||
const buttons = wrapper.findAll('button')
|
||||
const deleteButton = buttons.find((button) =>
|
||||
button.find('i.pi-times').exists()
|
||||
)
|
||||
|
||||
if (!deleteButton) {
|
||||
throw new Error('Delete button with times icon not found')
|
||||
}
|
||||
|
||||
// Should not throw error
|
||||
await expect(deleteButton.trigger('click')).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it('cleans up object URLs on unmount', () => {
|
||||
const imageFile = createMockFile('test.jpg', 'image/jpeg')
|
||||
const widget = createMockWidget<File[] | null>(
|
||||
[imageFile],
|
||||
{},
|
||||
undefined,
|
||||
{
|
||||
name: 'test_file_upload',
|
||||
type: 'file'
|
||||
}
|
||||
)
|
||||
const wrapper = mountComponent(widget, [imageFile])
|
||||
|
||||
wrapper.unmount()
|
||||
|
||||
expect(global.URL.revokeObjectURL).toHaveBeenCalledWith(mockObjectURL)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,328 +0,0 @@
|
||||
<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="mb-2 flex items-center justify-between gap-4 px-2">
|
||||
<label
|
||||
v-if="widget.name"
|
||||
class="text-secondary min-w-[4em] truncate text-xs"
|
||||
>{{ 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"
|
||||
:aria-label="`${$t('g.selectedFile')}: ${selectedFile?.name || $t('g.none')}`"
|
||||
v-bind="transformCompatProps"
|
||||
class="max-w-[20em] min-w-[8em] text-xs"
|
||||
size="small"
|
||||
:pt="{
|
||||
option: 'text-xs',
|
||||
dropdownIcon: 'text-component-node-foreground-secondary'
|
||||
}"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-folder"
|
||||
size="small"
|
||||
class="!h-8 !w-8"
|
||||
@click="triggerFileInput"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image preview -->
|
||||
<!-- TODO: change hardcoded colors when design system incorporated -->
|
||||
<div class="group relative">
|
||||
<img :src="imageUrl" :alt="selectedFile?.name" class="h-auto w-full" />
|
||||
<!-- Darkening overlay on hover -->
|
||||
<div
|
||||
class="bg-opacity-0 group-hover:bg-opacity-20 pointer-events-none absolute inset-0 bg-black transition-all duration-200"
|
||||
/>
|
||||
<!-- Control buttons in top right on hover -->
|
||||
<div
|
||||
class="absolute top-2 right-2 flex gap-1 opacity-0 transition-opacity duration-200 group-hover:opacity-100"
|
||||
>
|
||||
<!-- Edit button -->
|
||||
<button
|
||||
:aria-label="$t('g.editImage')"
|
||||
class="flex h-6 w-6 items-center justify-center rounded border-none transition-all duration-150 focus:outline-none"
|
||||
style="background-color: #262729"
|
||||
@click="handleEdit"
|
||||
>
|
||||
<i class="pi pi-pencil text-xs text-white"></i>
|
||||
</button>
|
||||
<!-- Delete button -->
|
||||
<button
|
||||
:aria-label="$t('g.deleteImage')"
|
||||
class="flex h-6 w-6 items-center justify-center rounded border-none transition-all duration-150 focus:outline-none"
|
||||
style="background-color: #262729"
|
||||
@click="clearFile"
|
||||
>
|
||||
<i class="pi pi-times text-xs text-white"></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="mb-2 flex items-center justify-between gap-4 px-2">
|
||||
<label
|
||||
v-if="widget.name"
|
||||
class="text-secondary min-w-[4em] truncate text-xs"
|
||||
>{{ 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"
|
||||
:aria-label="`${$t('g.selectedFile')}: ${selectedFile?.name || $t('g.none')}`"
|
||||
v-bind="transformCompatProps"
|
||||
class="max-w-[20em] min-w-[8em] text-xs"
|
||||
size="small"
|
||||
:pt="{
|
||||
option: 'text-xs',
|
||||
dropdownIcon: 'text-component-node-foreground-secondary'
|
||||
}"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-folder"
|
||||
size="small"
|
||||
class="!h-8 !w-8"
|
||||
@click="triggerFileInput"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audio player -->
|
||||
<div class="group relative px-2">
|
||||
<div
|
||||
class="flex items-center gap-4 rounded-lg bg-charcoal-800 p-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="mb-1 text-sm font-medium">{{ selectedFile?.name }}</div>
|
||||
<div class="text-xs opacity-60">
|
||||
{{
|
||||
selectedFile ? (selectedFile.size / 1024).toFixed(1) + ' KB' : ''
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Control buttons -->
|
||||
<div class="flex gap-1">
|
||||
<!-- Delete button -->
|
||||
<button
|
||||
:aria-label="$t('g.deleteAudioFile')"
|
||||
class="flex h-8 w-8 items-center justify-center rounded border-none transition-all duration-150 hover:bg-charcoal-600 focus:outline-none"
|
||||
@click="clearFile"
|
||||
>
|
||||
<i class="pi pi-times text-sm text-white"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Show normal file upload UI when no image or audio is loaded -->
|
||||
<div
|
||||
v-else
|
||||
class="flex w-full flex-col gap-1 rounded-lg border border-solid p-1"
|
||||
:style="{ borderColor: '#262729' }"
|
||||
>
|
||||
<div
|
||||
class="rounded-md border border-dashed p-1 transition-colors duration-200 hover:border-slate-300"
|
||||
:style="{ borderColor: '#262729' }"
|
||||
>
|
||||
<div class="flex w-full flex-col items-center gap-2 py-4">
|
||||
<span class="text-xs opacity-60">
|
||||
{{ $t('widgetFileUpload.dropPrompt') }}
|
||||
</span>
|
||||
<div>
|
||||
<Button
|
||||
:label="$t('widgetFileUpload.browseFiles')"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
class="text-xs"
|
||||
@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"
|
||||
:aria-label="`${$t('g.upload')} ${widget.name || $t('g.file')}`"
|
||||
:multiple="false"
|
||||
@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 { useWidgetValue } from '@/composables/graph/useWidgetValue'
|
||||
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
const { widget, modelValue } = defineProps<{
|
||||
widget: SimplifiedWidget<File[] | null>
|
||||
modelValue: File[] | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: File[] | null]
|
||||
}>()
|
||||
|
||||
const { localValue, onChange } = useWidgetValue({
|
||||
widget,
|
||||
modelValue,
|
||||
defaultValue: null,
|
||||
emit
|
||||
})
|
||||
|
||||
// Transform compatibility props for overlay positioning
|
||||
const transformCompatProps = useTransformCompatOverlayProps()
|
||||
|
||||
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 (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>
|
||||
@@ -2,7 +2,6 @@
|
||||
import InputNumber from 'primevue/inputnumber'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useNumberWidgetValue } from '@/composables/graph/useWidgetValue'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import {
|
||||
@@ -15,18 +14,9 @@ import WidgetLayoutField from './layout/WidgetLayoutField.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<number>
|
||||
modelValue: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: number]
|
||||
}>()
|
||||
|
||||
const { localValue, onChange } = useNumberWidgetValue(
|
||||
props.widget,
|
||||
props.modelValue,
|
||||
emit
|
||||
)
|
||||
const modelValue = defineModel<number>({ default: 0 })
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, INPUT_EXCLUDED_PROPS)
|
||||
@@ -65,7 +55,7 @@ const useGrouping = computed(() => {
|
||||
|
||||
// Check if increment/decrement buttons should be disabled due to precision limits
|
||||
const buttonsDisabled = computed(() => {
|
||||
const currentValue = localValue.value ?? 0
|
||||
const currentValue = modelValue.value ?? 0
|
||||
return (
|
||||
!Number.isFinite(currentValue) ||
|
||||
Math.abs(currentValue) > Number.MAX_SAFE_INTEGER
|
||||
@@ -83,7 +73,7 @@ const buttonTooltip = computed(() => {
|
||||
<template>
|
||||
<WidgetLayoutField :widget>
|
||||
<InputNumber
|
||||
v-model="localValue"
|
||||
v-model="modelValue"
|
||||
v-tooltip="buttonTooltip"
|
||||
v-bind="filteredProps"
|
||||
fluid
|
||||
@@ -97,7 +87,8 @@ const buttonTooltip = computed(() => {
|
||||
:show-buttons="!buttonsDisabled"
|
||||
:pt="{
|
||||
root: {
|
||||
class: '[&>input]:bg-transparent [&>input]:border-0'
|
||||
class:
|
||||
'[&>input]:bg-transparent [&>input]:border-0 [&>input]:truncate [&>input]:min-w-[4ch]'
|
||||
},
|
||||
decrementButton: {
|
||||
class: 'w-8 border-0'
|
||||
@@ -106,7 +97,6 @@ const buttonTooltip = computed(() => {
|
||||
class: 'w-8 border-0'
|
||||
}
|
||||
}"
|
||||
@update:model-value="onChange"
|
||||
>
|
||||
<template #incrementicon>
|
||||
<span class="pi pi-plus text-sm" />
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<WidgetLayoutField :widget="widget">
|
||||
<div :class="cn(WidgetInputBaseClass, 'flex items-center gap-2 pl-3 pr-2')">
|
||||
<Slider
|
||||
:model-value="[localValue]"
|
||||
:model-value="[modelValue]"
|
||||
v-bind="filteredProps"
|
||||
class="flex-grow text-xs"
|
||||
:step="stepValue"
|
||||
@@ -11,14 +11,14 @@
|
||||
/>
|
||||
<InputNumber
|
||||
:key="timesEmptied"
|
||||
:model-value="localValue"
|
||||
:model-value="modelValue"
|
||||
v-bind="filteredProps"
|
||||
:step="stepValue"
|
||||
:min-fraction-digits="precision"
|
||||
:max-fraction-digits="precision"
|
||||
:aria-label="widget.name"
|
||||
size="small"
|
||||
pt:pc-input-text:root="min-w-full bg-transparent border-none text-center"
|
||||
pt:pc-input-text:root="min-w-[4ch] bg-transparent border-none text-center truncate"
|
||||
class="w-16"
|
||||
:pt="sliderNumberPt"
|
||||
@update:model-value="handleNumberInputUpdate"
|
||||
@@ -32,7 +32,6 @@ import InputNumber from 'primevue/inputnumber'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import { useNumberWidgetValue } from '@/composables/graph/useWidgetValue'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import {
|
||||
@@ -44,22 +43,16 @@ import { useNumberWidgetButtonPt } from '../composables/useNumberWidgetButtonPt'
|
||||
import { WidgetInputBaseClass } from './layout'
|
||||
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
|
||||
|
||||
const { widget, modelValue } = defineProps<{
|
||||
const { widget } = defineProps<{
|
||||
widget: SimplifiedWidget<number>
|
||||
modelValue: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: number]
|
||||
}>()
|
||||
|
||||
// Use the composable for consistent widget value handling
|
||||
const { localValue, onChange } = useNumberWidgetValue(widget, modelValue, emit)
|
||||
const modelValue = defineModel<number>({ default: 0 })
|
||||
|
||||
const timesEmptied = ref(0)
|
||||
|
||||
const updateLocalValue = (newValue: number[] | undefined): void => {
|
||||
onChange(newValue ?? [localValue.value])
|
||||
if (newValue?.length) modelValue.value = newValue[0]
|
||||
}
|
||||
|
||||
const handleNumberInputUpdate = (newValue: number | undefined) => {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<WidgetLayoutField :widget="widget">
|
||||
<InputText
|
||||
v-model="localValue"
|
||||
v-model="modelValue"
|
||||
v-bind="filteredProps"
|
||||
:class="cn(WidgetInputBaseClass, 'w-full text-xs py-2 px-4')"
|
||||
:aria-label="widget.name"
|
||||
size="small"
|
||||
@update:model-value="onChange"
|
||||
:pt="{ root: 'truncate min-w-[4ch]' }"
|
||||
/>
|
||||
</WidgetLayoutField>
|
||||
</template>
|
||||
@@ -15,7 +15,6 @@
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useStringWidgetValue } from '@/composables/graph/useWidgetValue'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import {
|
||||
@@ -28,19 +27,9 @@ import WidgetLayoutField from './layout/WidgetLayoutField.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<string>
|
||||
modelValue: string
|
||||
}>()
|
||||
|
||||
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 modelValue = defineModel<string>({ default: '' })
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, INPUT_EXCLUDED_PROPS)
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<Textarea
|
||||
v-show="isEditing"
|
||||
ref="textareaRef"
|
||||
v-model="localValue"
|
||||
v-model="modelValue"
|
||||
:aria-label="`${$t('g.edit')} ${widget.name || $t('g.markdown')} ${$t('g.content')}`"
|
||||
class="absolute inset-0 min-h-[60px] w-full resize-none"
|
||||
:pt="{
|
||||
@@ -24,7 +24,6 @@
|
||||
}
|
||||
}"
|
||||
data-capture-wheel="true"
|
||||
@update:model-value="onChange"
|
||||
@click.stop
|
||||
@keydown.stop
|
||||
/>
|
||||
@@ -36,35 +35,24 @@
|
||||
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'
|
||||
|
||||
import LODFallback from '../../components/LODFallback.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
const { widget } = defineProps<{
|
||||
widget: SimplifiedWidget<string>
|
||||
modelValue: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
const modelValue = defineModel<string>({ default: '' })
|
||||
|
||||
// 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 || '')
|
||||
return renderMarkdownToHtml(modelValue.value || '')
|
||||
})
|
||||
|
||||
// Methods
|
||||
|
||||
@@ -1,333 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import MultiSelect from 'primevue/multiselect'
|
||||
import type { MultiSelectProps } from 'primevue/multiselect'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
|
||||
|
||||
import WidgetMultiSelect from './WidgetMultiSelect.vue'
|
||||
|
||||
describe('WidgetMultiSelect Value Binding', () => {
|
||||
const createMockWidget = (
|
||||
value: WidgetValue[] = [],
|
||||
options: Partial<MultiSelectProps> & { values?: WidgetValue[] } = {},
|
||||
callback?: (value: WidgetValue[]) => void
|
||||
): SimplifiedWidget<WidgetValue[]> => ({
|
||||
name: 'test_multiselect',
|
||||
type: 'array',
|
||||
value,
|
||||
options,
|
||||
callback
|
||||
})
|
||||
|
||||
const mountComponent = (
|
||||
widget: SimplifiedWidget<WidgetValue[]>,
|
||||
modelValue: WidgetValue[],
|
||||
readonly = false
|
||||
) => {
|
||||
return mount(WidgetMultiSelect, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
components: { MultiSelect }
|
||||
},
|
||||
props: {
|
||||
widget,
|
||||
modelValue,
|
||||
readonly
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const setMultiSelectValueAndEmit = async (
|
||||
wrapper: ReturnType<typeof mount>,
|
||||
values: WidgetValue[]
|
||||
) => {
|
||||
const multiselect = wrapper.findComponent({ name: 'MultiSelect' })
|
||||
await multiselect.vm.$emit('update:modelValue', values)
|
||||
return multiselect
|
||||
}
|
||||
|
||||
describe('Vue Event Emission', () => {
|
||||
it('emits Vue event when selection changes', async () => {
|
||||
const widget = createMockWidget([], {
|
||||
values: ['option1', 'option2', 'option3']
|
||||
})
|
||||
const wrapper = mountComponent(widget, [])
|
||||
|
||||
await setMultiSelectValueAndEmit(wrapper, ['option1', 'option2'])
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toEqual([['option1', 'option2']])
|
||||
})
|
||||
|
||||
it('emits Vue event when selection is cleared', async () => {
|
||||
const widget = createMockWidget(['option1'], {
|
||||
values: ['option1', 'option2']
|
||||
})
|
||||
const wrapper = mountComponent(widget, ['option1'])
|
||||
|
||||
await setMultiSelectValueAndEmit(wrapper, [])
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toEqual([[]])
|
||||
})
|
||||
|
||||
it('handles single item selection', async () => {
|
||||
const widget = createMockWidget([], {
|
||||
values: ['single']
|
||||
})
|
||||
const wrapper = mountComponent(widget, [])
|
||||
|
||||
await setMultiSelectValueAndEmit(wrapper, ['single'])
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toEqual([['single']])
|
||||
})
|
||||
|
||||
it('emits update:modelValue for callback handling at parent level', async () => {
|
||||
const widget = createMockWidget([], {
|
||||
values: ['option1', 'option2']
|
||||
})
|
||||
const wrapper = mountComponent(widget, [])
|
||||
|
||||
await setMultiSelectValueAndEmit(wrapper, ['option1'])
|
||||
|
||||
// The widget should emit the change for parent (NodeWidgets) to handle callbacks
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toEqual([['option1']])
|
||||
})
|
||||
|
||||
it('handles missing callback gracefully', async () => {
|
||||
const widget = createMockWidget(
|
||||
[],
|
||||
{
|
||||
values: ['option1']
|
||||
},
|
||||
undefined
|
||||
)
|
||||
const wrapper = mountComponent(widget, [])
|
||||
|
||||
await setMultiSelectValueAndEmit(wrapper, ['option1'])
|
||||
|
||||
// Should still emit Vue event
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toEqual([['option1']])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component Rendering', () => {
|
||||
it('renders multiselect component', () => {
|
||||
const widget = createMockWidget([], {
|
||||
values: ['option1', 'option2']
|
||||
})
|
||||
const wrapper = mountComponent(widget, [])
|
||||
|
||||
const multiselect = wrapper.findComponent({ name: 'MultiSelect' })
|
||||
expect(multiselect.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('displays options from widget values', () => {
|
||||
const options = ['apple', 'banana', 'cherry']
|
||||
const widget = createMockWidget([], { values: options })
|
||||
const wrapper = mountComponent(widget, [])
|
||||
|
||||
const multiselect = wrapper.findComponent({ name: 'MultiSelect' })
|
||||
expect(multiselect.props('options')).toEqual(options)
|
||||
})
|
||||
|
||||
it('displays initial selected values', () => {
|
||||
const widget = createMockWidget(['banana'], {
|
||||
values: ['apple', 'banana', 'cherry']
|
||||
})
|
||||
const wrapper = mountComponent(widget, ['banana'])
|
||||
|
||||
const multiselect = wrapper.findComponent({ name: 'MultiSelect' })
|
||||
expect(multiselect.props('modelValue')).toEqual(['banana'])
|
||||
})
|
||||
|
||||
it('applies small size styling', () => {
|
||||
const widget = createMockWidget([], { values: ['test'] })
|
||||
const wrapper = mountComponent(widget, [])
|
||||
|
||||
const multiselect = wrapper.findComponent({ name: 'MultiSelect' })
|
||||
expect(multiselect.props('size')).toBe('small')
|
||||
})
|
||||
|
||||
it('uses chip display mode', () => {
|
||||
const widget = createMockWidget([], { values: ['test'] })
|
||||
const wrapper = mountComponent(widget, [])
|
||||
|
||||
const multiselect = wrapper.findComponent({ name: 'MultiSelect' })
|
||||
expect(multiselect.props('display')).toBe('chip')
|
||||
})
|
||||
|
||||
it('applies text-xs class', () => {
|
||||
const widget = createMockWidget([], { values: ['test'] })
|
||||
const wrapper = mountComponent(widget, [])
|
||||
|
||||
const multiselect = wrapper.findComponent({ name: 'MultiSelect' })
|
||||
expect(multiselect.classes()).toContain('text-xs')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Widget Options Handling', () => {
|
||||
it('passes through valid widget options', () => {
|
||||
const widget = createMockWidget([], {
|
||||
values: ['option1', 'option2'],
|
||||
placeholder: 'Select items...',
|
||||
filter: true,
|
||||
showClear: true
|
||||
})
|
||||
const wrapper = mountComponent(widget, [])
|
||||
|
||||
const multiselect = wrapper.findComponent({ name: 'MultiSelect' })
|
||||
expect(multiselect.props('placeholder')).toBe('Select items...')
|
||||
expect(multiselect.props('filter')).toBe(true)
|
||||
expect(multiselect.props('showClear')).toBe(true)
|
||||
})
|
||||
|
||||
it('excludes panel-related props', () => {
|
||||
const widget = createMockWidget([], {
|
||||
values: ['option1'],
|
||||
overlayStyle: { color: 'red' },
|
||||
panelClass: 'custom-panel'
|
||||
})
|
||||
const wrapper = mountComponent(widget, [])
|
||||
|
||||
const multiselect = wrapper.findComponent({ name: 'MultiSelect' })
|
||||
// These props should be filtered out by the prop filter
|
||||
expect(multiselect.props('overlayStyle')).not.toEqual({ color: 'red' })
|
||||
expect(multiselect.props('panelClass')).not.toBe('custom-panel')
|
||||
})
|
||||
|
||||
it('handles empty values array', () => {
|
||||
const widget = createMockWidget([], { values: [] })
|
||||
const wrapper = mountComponent(widget, [])
|
||||
|
||||
const multiselect = wrapper.findComponent({ name: 'MultiSelect' })
|
||||
expect(multiselect.props('options')).toEqual([])
|
||||
})
|
||||
|
||||
it('handles missing values option', () => {
|
||||
const widget = createMockWidget([])
|
||||
const wrapper = mountComponent(widget, [])
|
||||
|
||||
const multiselect = wrapper.findComponent({ name: 'MultiSelect' })
|
||||
// Should not crash, options might be undefined
|
||||
expect(multiselect.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles numeric values', async () => {
|
||||
const widget = createMockWidget([], {
|
||||
values: [1, 2, 3, 4, 5]
|
||||
})
|
||||
const wrapper = mountComponent(widget, [])
|
||||
|
||||
await setMultiSelectValueAndEmit(wrapper, [1, 3, 5])
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toEqual([[1, 3, 5]])
|
||||
})
|
||||
|
||||
it('handles mixed type values', async () => {
|
||||
const widget = createMockWidget([], {
|
||||
values: ['string', 123, true, null]
|
||||
})
|
||||
const wrapper = mountComponent(widget, [])
|
||||
|
||||
await setMultiSelectValueAndEmit(wrapper, ['string', 123])
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toEqual([['string', 123]])
|
||||
})
|
||||
|
||||
it('handles object values', async () => {
|
||||
const objectValues = [
|
||||
{ id: 1, label: 'First' },
|
||||
{ id: 2, label: 'Second' }
|
||||
]
|
||||
const widget = createMockWidget([], {
|
||||
values: objectValues,
|
||||
optionLabel: 'label',
|
||||
optionValue: 'id'
|
||||
})
|
||||
const wrapper = mountComponent(widget, [])
|
||||
|
||||
await setMultiSelectValueAndEmit(wrapper, [1, 2])
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toEqual([[1, 2]])
|
||||
})
|
||||
|
||||
it('handles duplicate selections gracefully', async () => {
|
||||
const widget = createMockWidget([], {
|
||||
values: ['option1', 'option2']
|
||||
})
|
||||
const wrapper = mountComponent(widget, [])
|
||||
|
||||
// MultiSelect should handle duplicates internally
|
||||
await setMultiSelectValueAndEmit(wrapper, ['option1', 'option1'])
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
// The actual behavior depends on PrimeVue implementation
|
||||
expect(emitted![0]).toEqual([['option1', 'option1']])
|
||||
})
|
||||
|
||||
it('handles very large option lists', () => {
|
||||
const largeOptionList = Array.from(
|
||||
{ length: 1000 },
|
||||
(_, i) => `option${i}`
|
||||
)
|
||||
const widget = createMockWidget([], { values: largeOptionList })
|
||||
const wrapper = mountComponent(widget, [])
|
||||
|
||||
const multiselect = wrapper.findComponent({ name: 'MultiSelect' })
|
||||
expect(multiselect.props('options')).toHaveLength(1000)
|
||||
})
|
||||
|
||||
it('handles empty string values', async () => {
|
||||
const widget = createMockWidget([], {
|
||||
values: ['', 'not empty', ' ', 'normal']
|
||||
})
|
||||
const wrapper = mountComponent(widget, [])
|
||||
|
||||
await setMultiSelectValueAndEmit(wrapper, ['', ' '])
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toEqual([['', ' ']])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Integration with Layout', () => {
|
||||
it('renders within WidgetLayoutField', () => {
|
||||
const widget = createMockWidget([], { values: ['test'] })
|
||||
const wrapper = mountComponent(widget, [])
|
||||
|
||||
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
|
||||
expect(layoutField.exists()).toBe(true)
|
||||
expect(layoutField.props('widget')).toEqual(widget)
|
||||
})
|
||||
|
||||
it('passes widget name to layout field', () => {
|
||||
const widget = createMockWidget([], { values: ['test'] })
|
||||
widget.name = 'custom_multiselect'
|
||||
const wrapper = mountComponent(widget, [])
|
||||
|
||||
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
|
||||
expect(layoutField.props('widget').name).toBe('custom_multiselect')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,75 +0,0 @@
|
||||
<template>
|
||||
<WidgetLayoutField :widget="widget">
|
||||
<MultiSelect
|
||||
v-model="localValue"
|
||||
:options="multiSelectOptions"
|
||||
v-bind="combinedProps"
|
||||
class="w-full text-xs"
|
||||
:aria-label="widget.name"
|
||||
size="small"
|
||||
display="chip"
|
||||
:pt="{
|
||||
option: 'text-xs',
|
||||
dropdownIcon: 'text-component-node-foreground-secondary'
|
||||
}"
|
||||
@update:model-value="onChange"
|
||||
/>
|
||||
</WidgetLayoutField>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T extends WidgetValue = WidgetValue">
|
||||
import MultiSelect from 'primevue/multiselect'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
|
||||
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
|
||||
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
|
||||
import {
|
||||
PANEL_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<T[]>
|
||||
modelValue: T[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: T[]]
|
||||
}>()
|
||||
|
||||
// Use the composable for consistent widget value handling
|
||||
const { localValue, onChange } = useWidgetValue<T[]>({
|
||||
widget: props.widget,
|
||||
modelValue: props.modelValue,
|
||||
defaultValue: [],
|
||||
emit
|
||||
})
|
||||
|
||||
// Transform compatibility props for overlay positioning
|
||||
const transformCompatProps = useTransformCompatOverlayProps()
|
||||
|
||||
// MultiSelect specific excluded props include overlay styles
|
||||
const MULTISELECT_EXCLUDED_PROPS = [
|
||||
...PANEL_EXCLUDED_PROPS,
|
||||
'overlayStyle'
|
||||
] as const
|
||||
|
||||
const combinedProps = computed(() => ({
|
||||
...filterWidgetProps(props.widget.options, MULTISELECT_EXCLUDED_PROPS),
|
||||
...transformCompatProps.value
|
||||
}))
|
||||
|
||||
// Extract multiselect options from widget options
|
||||
const multiSelectOptions = computed((): T[] => {
|
||||
const options = props.widget.options
|
||||
|
||||
if (Array.isArray(options?.values)) {
|
||||
return options.values
|
||||
}
|
||||
|
||||
return []
|
||||
})
|
||||
</script>
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="relative">
|
||||
<div class="mb-4">
|
||||
<Button
|
||||
class="text-text-secondary w-[413px] border-0 bg-secondary-background hover:bg-secondary-background-hover"
|
||||
class="text-text-secondary w-full border-0 bg-secondary-background hover:bg-secondary-background-hover"
|
||||
:disabled="isRecording || readonly"
|
||||
@click="handleStartRecording"
|
||||
>
|
||||
@@ -12,7 +12,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="isRecording || isPlaying || recordedURL"
|
||||
class="flex h-14 w-[413px] items-center gap-4 rounded-lg px-4 bg-node-component-surface text-text-secondary"
|
||||
class="flex h-14 w-full items-center gap-4 rounded-lg px-4 bg-node-component-surface text-text-secondary"
|
||||
>
|
||||
<!-- Recording Status -->
|
||||
<div class="flex min-w-30 items-center gap-2">
|
||||
@@ -87,7 +87,6 @@ import { useIntervalFn } from '@vueuse/core'
|
||||
import { Button } from 'primevue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { useStringWidgetValue } from '@/composables/graph/useWidgetValue'
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
@@ -102,14 +101,9 @@ import { useAudioRecorder } from '../composables/audio/useAudioRecorder'
|
||||
import { useAudioWaveform } from '../composables/audio/useAudioWaveform'
|
||||
import { formatTime } from '../utils/audioUtils'
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<string | number | undefined>
|
||||
readonly?: boolean
|
||||
modelValue: string
|
||||
nodeId: string
|
||||
}>()
|
||||
|
||||
@@ -161,11 +155,9 @@ const { isPlaying, audioElementKey } = playback
|
||||
|
||||
// Computed for waveform animation
|
||||
const isWaveformActive = computed(() => isRecording.value || isPlaying.value)
|
||||
const { localValue, onChange } = useStringWidgetValue(
|
||||
props.widget as SimplifiedWidget<string, Record<string, string>>,
|
||||
props.modelValue,
|
||||
emit
|
||||
)
|
||||
|
||||
const modelValue = defineModel<string>({ default: '' })
|
||||
|
||||
const litegraphNode = computed(() => {
|
||||
if (!props.nodeId || !app.rootGraph) return null
|
||||
return app.rootGraph.getNodeById(props.nodeId) as LGraphNode | null
|
||||
@@ -174,9 +166,8 @@ const litegraphNode = computed(() => {
|
||||
async function handleRecordingComplete(blob: Blob) {
|
||||
try {
|
||||
const path = await useAudioService().convertBlobToFileAndSubmit(blob)
|
||||
localValue.value = path
|
||||
modelValue.value = path
|
||||
lastUploadedPath = path
|
||||
onChange(path)
|
||||
} catch (e) {
|
||||
useToastStore().addAlert('Failed to upload recorded audio')
|
||||
}
|
||||
@@ -278,7 +269,7 @@ async function serializeValue() {
|
||||
let attempts = 0
|
||||
const maxAttempts = 50 // 5 seconds max (50 * 100ms)
|
||||
const checkRecording = () => {
|
||||
if (!isRecording.value && props.modelValue) {
|
||||
if (!isRecording.value && modelValue.value) {
|
||||
resolve(undefined)
|
||||
} else if (++attempts >= maxAttempts) {
|
||||
reject(new Error('Recording serialization timeout after 5 seconds'))
|
||||
@@ -290,7 +281,7 @@ async function serializeValue() {
|
||||
})
|
||||
}
|
||||
|
||||
return props.modelValue || lastUploadedPath || ''
|
||||
return modelValue.value || lastUploadedPath || ''
|
||||
}
|
||||
|
||||
function registerWidgetSerialization() {
|
||||
|
||||
@@ -2,19 +2,14 @@
|
||||
<WidgetSelectDropdown
|
||||
v-if="isDropdownUIWidget"
|
||||
v-bind="props"
|
||||
v-model="modelValue"
|
||||
:asset-kind="assetKind"
|
||||
:allow-upload="allowUpload"
|
||||
:upload-folder="uploadFolder"
|
||||
:is-asset-mode="isAssetMode"
|
||||
:default-layout-mode="defaultLayoutMode"
|
||||
@update:model-value="handleUpdateModelValue"
|
||||
/>
|
||||
<WidgetSelectDefault
|
||||
v-else
|
||||
:widget="widget"
|
||||
:model-value="modelValue"
|
||||
@update:model-value="handleUpdateModelValue"
|
||||
/>
|
||||
<WidgetSelectDefault v-else v-model="modelValue" :widget />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -33,18 +28,11 @@ import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import type { AssetKind } from '@/types/widgetTypes'
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<string | number | undefined>
|
||||
modelValue: string | number | undefined
|
||||
widget: SimplifiedWidget<string | undefined>
|
||||
nodeType?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string | number | undefined]
|
||||
}>()
|
||||
|
||||
function handleUpdateModelValue(value: string | number | undefined) {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
const modelValue = defineModel<string | undefined>()
|
||||
|
||||
const comboSpec = computed<ComboInputSpec | undefined>(() => {
|
||||
if (props.widget.spec && isComboInputSpec(props.widget.spec)) {
|
||||
|
||||
@@ -1,409 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import WidgetSelectButton from './WidgetSelectButton.vue'
|
||||
|
||||
function createMockWidget(
|
||||
value: string = 'option1',
|
||||
options: SimplifiedWidget['options'] = {},
|
||||
callback?: (value: string) => void
|
||||
): SimplifiedWidget<string> {
|
||||
return {
|
||||
name: 'test_selectbutton',
|
||||
type: 'string',
|
||||
value,
|
||||
options,
|
||||
callback
|
||||
}
|
||||
}
|
||||
|
||||
function mountComponent(
|
||||
widget: SimplifiedWidget<string>,
|
||||
modelValue: string,
|
||||
readonly = false
|
||||
) {
|
||||
return mount(WidgetSelectButton, {
|
||||
global: {
|
||||
plugins: [PrimeVue]
|
||||
},
|
||||
props: {
|
||||
widget,
|
||||
modelValue,
|
||||
readonly
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function clickSelectButton(
|
||||
wrapper: ReturnType<typeof mount>,
|
||||
optionText: string
|
||||
) {
|
||||
const buttons = wrapper.findAll('button')
|
||||
const targetButton = buttons.find((button) =>
|
||||
button.text().includes(optionText)
|
||||
)
|
||||
|
||||
if (!targetButton) {
|
||||
throw new Error(`Button with text "${optionText}" not found`)
|
||||
}
|
||||
|
||||
await targetButton.trigger('click')
|
||||
return targetButton
|
||||
}
|
||||
|
||||
describe('WidgetSelectButton Button Selection', () => {
|
||||
describe('Basic Rendering', () => {
|
||||
it('renders FormSelectButton component', () => {
|
||||
const widget = createMockWidget('option1', {
|
||||
values: ['option1', 'option2', 'option3']
|
||||
})
|
||||
const wrapper = mountComponent(widget, 'option1')
|
||||
|
||||
const formSelectButton = wrapper.findComponent({
|
||||
name: 'FormSelectButton'
|
||||
})
|
||||
expect(formSelectButton.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders buttons for each option', () => {
|
||||
const options = ['first', 'second', 'third']
|
||||
const widget = createMockWidget('first', { values: options })
|
||||
const wrapper = mountComponent(widget, 'first')
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(3)
|
||||
expect(buttons[0].text()).toBe('first')
|
||||
expect(buttons[1].text()).toBe('second')
|
||||
expect(buttons[2].text()).toBe('third')
|
||||
})
|
||||
|
||||
it('handles empty options array', () => {
|
||||
const widget = createMockWidget('', { values: [] })
|
||||
const wrapper = mountComponent(widget, '')
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('handles missing values option', () => {
|
||||
const widget = createMockWidget('')
|
||||
const wrapper = mountComponent(widget, '')
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Selection State', () => {
|
||||
it('highlights selected option', () => {
|
||||
const options = ['apple', 'banana', 'cherry']
|
||||
const widget = createMockWidget('banana', { values: options })
|
||||
const wrapper = mountComponent(widget, 'banana')
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
const selectedButton = buttons[1] // 'banana'
|
||||
const unselectedButton = buttons[0] // 'apple'
|
||||
|
||||
expect(selectedButton.classes()).toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
)
|
||||
expect(selectedButton.classes()).toContain('text-primary')
|
||||
expect(unselectedButton.classes()).not.toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
)
|
||||
expect(unselectedButton.classes()).toContain('text-secondary')
|
||||
})
|
||||
|
||||
it('handles no selection gracefully', () => {
|
||||
const options = ['option1', 'option2']
|
||||
const widget = createMockWidget('nonexistent', { values: options })
|
||||
const wrapper = mountComponent(widget, 'nonexistent')
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
buttons.forEach((button) => {
|
||||
expect(button.classes()).not.toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
)
|
||||
expect(button.classes()).toContain('text-secondary')
|
||||
})
|
||||
})
|
||||
|
||||
it('updates selection when modelValue changes', async (context) => {
|
||||
context.skip('Classes not updating, needs diagnosis')
|
||||
|
||||
const options = ['first', 'second', 'third']
|
||||
const widget = createMockWidget('first', { values: options })
|
||||
const wrapper = mountComponent(widget, 'first')
|
||||
|
||||
// Initially 'first' is selected
|
||||
let buttons = wrapper.findAll('button')
|
||||
expect(buttons[0].classes()).toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
)
|
||||
|
||||
// Update to 'second'
|
||||
await wrapper.setProps({ modelValue: 'second' })
|
||||
buttons = wrapper.findAll('button')
|
||||
expect(buttons[0].classes()).not.toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
)
|
||||
expect(buttons[1].classes()).toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('emits update:modelValue when button is clicked', async () => {
|
||||
const options = ['first', 'second', 'third']
|
||||
const widget = createMockWidget('first', { values: options })
|
||||
const wrapper = mountComponent(widget, 'first')
|
||||
|
||||
await clickSelectButton(wrapper, 'second')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted?.[0]).toEqual(['second'])
|
||||
})
|
||||
|
||||
it('handles callback execution when provided', async (context) => {
|
||||
context.skip('Callback is not being called, needs diagnosis')
|
||||
const mockCallback = vi.fn()
|
||||
const options = ['option1', 'option2']
|
||||
const widget = createMockWidget(
|
||||
'option1',
|
||||
{ values: options },
|
||||
mockCallback
|
||||
)
|
||||
const wrapper = mountComponent(widget, 'option1')
|
||||
|
||||
await clickSelectButton(wrapper, 'option2')
|
||||
|
||||
expect(mockCallback).toHaveBeenCalledWith('option2')
|
||||
})
|
||||
|
||||
it('handles missing callback gracefully', async () => {
|
||||
const options = ['option1', 'option2']
|
||||
const widget = createMockWidget('option1', { values: options }, undefined)
|
||||
const wrapper = mountComponent(widget, 'option1')
|
||||
|
||||
await clickSelectButton(wrapper, 'option2')
|
||||
|
||||
// Should still emit Vue event
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted?.[0]).toEqual(['option2'])
|
||||
})
|
||||
|
||||
it('allows clicking same option again', async () => {
|
||||
const options = ['option1', 'option2']
|
||||
const widget = createMockWidget('option1', { values: options })
|
||||
const wrapper = mountComponent(widget, 'option1')
|
||||
|
||||
await clickSelectButton(wrapper, 'option1')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted?.[0]).toEqual(['option1'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Option Types', () => {
|
||||
it('handles string options', () => {
|
||||
const options = ['apple', 'banana', 'cherry']
|
||||
const widget = createMockWidget('banana', { values: options })
|
||||
const wrapper = mountComponent(widget, 'banana')
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons[0].text()).toBe('apple')
|
||||
expect(buttons[1].text()).toBe('banana')
|
||||
expect(buttons[2].text()).toBe('cherry')
|
||||
})
|
||||
|
||||
it('handles number options', () => {
|
||||
const options = [1, 2, 3]
|
||||
const widget = createMockWidget('2', { values: options })
|
||||
const wrapper = mountComponent(widget, '2')
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons[0].text()).toBe('1')
|
||||
expect(buttons[1].text()).toBe('2')
|
||||
expect(buttons[2].text()).toBe('3')
|
||||
|
||||
// The selected button should be the one with '2'
|
||||
expect(buttons[1].classes()).toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
)
|
||||
})
|
||||
|
||||
it('handles object options with label and value', () => {
|
||||
const options = [
|
||||
{ label: 'First Option', value: 'first' },
|
||||
{ label: 'Second Option', value: 'second' },
|
||||
{ label: 'Third Option', value: 'third' }
|
||||
]
|
||||
const widget = createMockWidget('second', { values: options })
|
||||
const wrapper = mountComponent(widget, 'second')
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons[0].text()).toBe('First Option')
|
||||
expect(buttons[1].text()).toBe('Second Option')
|
||||
expect(buttons[2].text()).toBe('Third Option')
|
||||
|
||||
// 'second' should be selected
|
||||
expect(buttons[1].classes()).toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
)
|
||||
})
|
||||
|
||||
it('emits correct values for object options', async () => {
|
||||
const options = [
|
||||
{ label: 'First', value: 'first_val' },
|
||||
{ label: 'Second', value: 'second_val' }
|
||||
]
|
||||
const widget = createMockWidget('first_val', { values: options })
|
||||
const wrapper = mountComponent(widget, 'first_val')
|
||||
|
||||
await clickSelectButton(wrapper, 'Second')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted?.[0]).toEqual(['second_val'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles options with special characters', () => {
|
||||
const options = ['@#$%^&*()', '{}[]|\\:";\'<>?,./']
|
||||
const widget = createMockWidget(options[0], { values: options })
|
||||
const wrapper = mountComponent(widget, options[0])
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons[0].text()).toBe('@#$%^&*()')
|
||||
expect(buttons[1].text()).toBe('{}[]|\\:";\'<>?,./')
|
||||
})
|
||||
|
||||
it('handles empty string options', () => {
|
||||
const options = ['', 'not empty', ' ', 'normal']
|
||||
const widget = createMockWidget('', { values: options })
|
||||
const wrapper = mountComponent(widget, '')
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(4)
|
||||
expect(buttons[0].classes()).toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
) // Empty string is selected
|
||||
})
|
||||
|
||||
it('handles null/undefined in options', () => {
|
||||
const options: (string | null | undefined)[] = [
|
||||
'valid',
|
||||
null,
|
||||
undefined,
|
||||
'another'
|
||||
]
|
||||
const widget = createMockWidget('valid', { values: options })
|
||||
const wrapper = mountComponent(widget, 'valid')
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(4)
|
||||
expect(buttons[0].classes()).toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
)
|
||||
})
|
||||
|
||||
it('handles very long option text', () => {
|
||||
const longText =
|
||||
'This is a very long option text that might cause layout issues if not handled properly'
|
||||
const options = ['short', longText, 'normal']
|
||||
const widget = createMockWidget('short', { values: options })
|
||||
const wrapper = mountComponent(widget, 'short')
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons[1].text()).toBe(longText)
|
||||
})
|
||||
|
||||
it('handles large number of options', () => {
|
||||
const options = Array.from({ length: 20 }, (_, i) => `option${i + 1}`)
|
||||
const widget = createMockWidget('option5', { values: options })
|
||||
const wrapper = mountComponent(widget, 'option5')
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(20)
|
||||
expect(buttons[4].classes()).toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
) // option5 is at index 4
|
||||
})
|
||||
|
||||
it('handles duplicate options', () => {
|
||||
const options = ['duplicate', 'unique', 'duplicate', 'unique']
|
||||
const widget = createMockWidget('duplicate', { values: options })
|
||||
const wrapper = mountComponent(widget, 'duplicate')
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(4)
|
||||
// Both 'duplicate' buttons should be highlighted (due to value matching)
|
||||
expect(buttons[0].classes()).toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
)
|
||||
expect(buttons[2].classes()).toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styling and Layout', () => {
|
||||
it('applies proper button styling', () => {
|
||||
const options = ['option1', 'option2']
|
||||
const widget = createMockWidget('option1', { values: options })
|
||||
const wrapper = mountComponent(widget, 'option1')
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
buttons.forEach((button) => {
|
||||
expect(button.classes()).toContain('flex-1')
|
||||
expect(button.classes()).toContain('h-6')
|
||||
expect(button.classes()).toContain('px-5')
|
||||
expect(button.classes()).toContain('rounded')
|
||||
expect(button.classes()).toContain('text-center')
|
||||
expect(button.classes()).toContain('text-xs')
|
||||
})
|
||||
})
|
||||
|
||||
it('applies hover effects for non-selected options', () => {
|
||||
const options = ['option1', 'option2']
|
||||
const widget = createMockWidget('option1', { values: options })
|
||||
const wrapper = mountComponent(widget, 'option1', false)
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
const unselectedButton = buttons[1] // 'option2'
|
||||
|
||||
expect(unselectedButton.classes()).toContain(
|
||||
'hover:bg-interface-menu-component-surface-hovered'
|
||||
)
|
||||
expect(unselectedButton.classes()).toContain('cursor-pointer')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Integration with Layout', () => {
|
||||
it('renders within WidgetLayoutField', () => {
|
||||
const widget = createMockWidget('test', { values: ['test'] })
|
||||
const wrapper = mountComponent(widget, 'test')
|
||||
|
||||
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
|
||||
expect(layoutField.exists()).toBe(true)
|
||||
expect(layoutField.props('widget')).toEqual(widget)
|
||||
})
|
||||
|
||||
it('passes widget name to layout field', () => {
|
||||
const widget = createMockWidget('test', { values: ['test'] })
|
||||
widget.name = 'custom_select_button'
|
||||
const wrapper = mountComponent(widget, 'test')
|
||||
|
||||
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
|
||||
expect(layoutField.props('widget').name).toBe('custom_select_button')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,34 +0,0 @@
|
||||
<template>
|
||||
<WidgetLayoutField :widget="widget">
|
||||
<FormSelectButton
|
||||
v-model="localValue"
|
||||
:options="widget.options?.values || []"
|
||||
class="w-full"
|
||||
@update:model-value="onChange"
|
||||
/>
|
||||
</WidgetLayoutField>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useStringWidgetValue } from '@/composables/graph/useWidgetValue'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import FormSelectButton from './form/FormSelectButton.vue'
|
||||
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<string>
|
||||
modelValue: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
// Use the composable for consistent widget value handling
|
||||
const { localValue, onChange } = useStringWidgetValue(
|
||||
props.widget,
|
||||
props.modelValue,
|
||||
emit
|
||||
)
|
||||
</script>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<WidgetLayoutField :widget>
|
||||
<Select
|
||||
v-model="localValue"
|
||||
v-model="modelValue"
|
||||
:invalid
|
||||
:options="selectOptions"
|
||||
v-bind="combinedProps"
|
||||
@@ -10,10 +10,11 @@
|
||||
size="small"
|
||||
:pt="{
|
||||
option: 'text-xs',
|
||||
dropdown: 'w-8'
|
||||
dropdown: 'w-8',
|
||||
label: 'truncate min-w-[4ch]',
|
||||
overlay: 'w-fit min-w-full'
|
||||
}"
|
||||
data-capture-wheel="true"
|
||||
@update:model-value="onChange"
|
||||
/>
|
||||
</WidgetLayoutField>
|
||||
</template>
|
||||
@@ -22,7 +23,6 @@
|
||||
import Select from 'primevue/select'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
|
||||
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
@@ -34,21 +34,16 @@ import {
|
||||
import { WidgetInputBaseClass } from './layout'
|
||||
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<string | number | undefined>
|
||||
modelValue: string | number | undefined
|
||||
}>()
|
||||
interface Props {
|
||||
widget: SimplifiedWidget<string | undefined>
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string | number | undefined]
|
||||
}>()
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// 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
|
||||
const modelValue = defineModel<string | undefined>({
|
||||
default(props: Props) {
|
||||
return props.widget.options?.values?.[0] || ''
|
||||
}
|
||||
})
|
||||
|
||||
// Transform compatibility props for overlay positioning
|
||||
@@ -65,12 +60,12 @@ const selectOptions = computed(() => {
|
||||
return []
|
||||
})
|
||||
const invalid = computed(
|
||||
() => !!localValue.value && !selectOptions.value.includes(localValue.value)
|
||||
() => !!modelValue.value && !selectOptions.value.includes(modelValue.value)
|
||||
)
|
||||
|
||||
const combinedProps = computed(() => ({
|
||||
...filterWidgetProps(props.widget.options, PANEL_EXCLUDED_PROPS),
|
||||
...transformCompatProps.value,
|
||||
...(invalid.value ? { placeholder: `${localValue.value}` } : {})
|
||||
...(invalid.value ? { placeholder: `${modelValue.value}` } : {})
|
||||
}))
|
||||
</script>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { capitalize } from 'es-toolkit'
|
||||
import { computed, provide, ref, toRef, watch } from 'vue'
|
||||
|
||||
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
|
||||
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
|
||||
import { t } from '@/i18n'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
@@ -27,31 +26,27 @@ import {
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<string | number | undefined>
|
||||
modelValue: string | number | undefined
|
||||
interface Props {
|
||||
widget: SimplifiedWidget<string | undefined>
|
||||
nodeType?: string
|
||||
assetKind?: AssetKind
|
||||
allowUpload?: boolean
|
||||
uploadFolder?: ResultItemType
|
||||
isAssetMode?: boolean
|
||||
defaultLayoutMode?: LayoutMode
|
||||
}>()
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
provide(
|
||||
AssetKindKey,
|
||||
computed(() => props.assetKind)
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string | number | undefined]
|
||||
}>()
|
||||
|
||||
const { localValue, onChange } = useWidgetValue({
|
||||
widget: props.widget,
|
||||
modelValue: () => props.modelValue,
|
||||
defaultValue: props.widget.options?.values?.[0] || '',
|
||||
emit
|
||||
const modelValue = defineModel<string | undefined>({
|
||||
default(props: Props) {
|
||||
return props.widget.options?.values?.[0] || ''
|
||||
}
|
||||
})
|
||||
|
||||
const toastStore = useToastStore()
|
||||
@@ -218,7 +213,7 @@ const acceptTypes = computed(() => {
|
||||
const layoutMode = ref<LayoutMode>(props.defaultLayoutMode ?? 'grid')
|
||||
|
||||
watch(
|
||||
localValue,
|
||||
modelValue,
|
||||
(currentValue) => {
|
||||
if (currentValue !== undefined) {
|
||||
const item = dropdownItems.value.find(
|
||||
@@ -241,15 +236,15 @@ function updateSelectedItems(selectedItems: Set<SelectedKey>) {
|
||||
id = selectedItems.values().next().value!
|
||||
}
|
||||
if (id == null) {
|
||||
onChange(undefined)
|
||||
modelValue.value = undefined
|
||||
return
|
||||
}
|
||||
const name = dropdownItems.value.find((item) => item.id === id)?.name
|
||||
if (!name) {
|
||||
onChange(undefined)
|
||||
modelValue.value = undefined
|
||||
return
|
||||
}
|
||||
onChange(name)
|
||||
modelValue.value = name
|
||||
}
|
||||
|
||||
// Upload file function (copied from useNodeImageUpload.ts)
|
||||
@@ -318,7 +313,7 @@ async function handleFilesUpdate(files: File[]) {
|
||||
}
|
||||
|
||||
// 3. Update widget value to the first uploaded file
|
||||
onChange(uploadedPaths[0])
|
||||
modelValue.value = uploadedPaths[0]
|
||||
|
||||
// 4. Trigger callback to notify underlying LiteGraph widget
|
||||
if (props.widget.callback) {
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
<template>
|
||||
<div class="widget-expands relative">
|
||||
<Textarea
|
||||
v-model="localValue"
|
||||
v-model="modelValue"
|
||||
v-bind="filteredProps"
|
||||
:class="
|
||||
cn(WidgetInputBaseClass, 'size-full text-xs lod-toggle resize-none')
|
||||
"
|
||||
:placeholder="placeholder || widget.name || ''"
|
||||
:aria-label="widget.name"
|
||||
:readonly="widget.options?.read_only"
|
||||
:disabled="widget.options?.read_only"
|
||||
fluid
|
||||
data-capture-wheel="true"
|
||||
@update:model-value="onChange"
|
||||
/>
|
||||
<LODFallback />
|
||||
</div>
|
||||
@@ -20,7 +21,6 @@
|
||||
import Textarea from 'primevue/textarea'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useStringWidgetValue } from '@/composables/graph/useWidgetValue'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import {
|
||||
@@ -31,24 +31,14 @@ import {
|
||||
import LODFallback from '../../components/LODFallback.vue'
|
||||
import { WidgetInputBaseClass } from './layout'
|
||||
|
||||
const props = defineProps<{
|
||||
const { widget, placeholder = '' } = defineProps<{
|
||||
widget: SimplifiedWidget<string>
|
||||
modelValue: string
|
||||
placeholder?: string
|
||||
}>()
|
||||
|
||||
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 modelValue = defineModel<string>({ default: '' })
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, INPUT_EXCLUDED_PROPS)
|
||||
filterWidgetProps(widget.options, INPUT_EXCLUDED_PROPS)
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<template>
|
||||
<WidgetLayoutField :widget>
|
||||
<ToggleSwitch
|
||||
v-model="localValue"
|
||||
v-model="modelValue"
|
||||
v-bind="filteredProps"
|
||||
class="ml-auto block"
|
||||
:aria-label="widget.name"
|
||||
@update:model-value="onChange"
|
||||
/>
|
||||
</WidgetLayoutField>
|
||||
</template>
|
||||
@@ -14,7 +13,6 @@
|
||||
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,
|
||||
@@ -23,23 +21,13 @@ import {
|
||||
|
||||
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
const { widget } = defineProps<{
|
||||
widget: SimplifiedWidget<boolean>
|
||||
modelValue: 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 modelValue = defineModel<boolean>()
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, STANDARD_EXCLUDED_PROPS)
|
||||
filterWidgetProps(widget.options, STANDARD_EXCLUDED_PROPS)
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -1,515 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import TreeSelect from 'primevue/treeselect'
|
||||
import type { TreeSelectProps } from 'primevue/treeselect'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
|
||||
|
||||
import WidgetTreeSelect from './WidgetTreeSelect.vue'
|
||||
import type { TreeNode } from './WidgetTreeSelect.vue'
|
||||
|
||||
const createTreeData = (): TreeNode[] => [
|
||||
{
|
||||
key: '0',
|
||||
label: 'Documents',
|
||||
data: 'Documents Folder',
|
||||
children: [
|
||||
{
|
||||
key: '0-0',
|
||||
label: 'Work',
|
||||
data: 'Work Folder',
|
||||
children: [
|
||||
{
|
||||
key: '0-0-0',
|
||||
label: 'Expenses.doc',
|
||||
data: 'Expenses Document',
|
||||
leaf: true
|
||||
},
|
||||
{
|
||||
key: '0-0-1',
|
||||
label: 'Resume.doc',
|
||||
data: 'Resume Document',
|
||||
leaf: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: '0-1',
|
||||
label: 'Home',
|
||||
data: 'Home Folder',
|
||||
children: [
|
||||
{
|
||||
key: '0-1-0',
|
||||
label: 'Invoices.txt',
|
||||
data: 'Invoices for this month',
|
||||
leaf: true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: '1',
|
||||
label: 'Events',
|
||||
data: 'Events Folder',
|
||||
children: [
|
||||
{ key: '1-0', label: 'Meeting', data: 'Meeting', leaf: true },
|
||||
{
|
||||
key: '1-1',
|
||||
label: 'Product Launch',
|
||||
data: 'Product Launch',
|
||||
leaf: true
|
||||
},
|
||||
{
|
||||
key: '1-2',
|
||||
label: 'Report Review',
|
||||
data: 'Report Review',
|
||||
leaf: true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
describe('WidgetTreeSelect Tree Navigation', () => {
|
||||
const createMockWidget = (
|
||||
value: WidgetValue = null,
|
||||
options: Partial<TreeSelectProps> = {},
|
||||
callback?: (value: WidgetValue) => void
|
||||
): SimplifiedWidget<WidgetValue> => ({
|
||||
name: 'test_treeselect',
|
||||
type: 'object',
|
||||
value,
|
||||
options,
|
||||
callback
|
||||
})
|
||||
|
||||
const mountComponent = (
|
||||
widget: SimplifiedWidget<WidgetValue>,
|
||||
modelValue: WidgetValue,
|
||||
readonly = false
|
||||
) => {
|
||||
return mount(WidgetTreeSelect, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
components: { TreeSelect }
|
||||
},
|
||||
props: {
|
||||
widget,
|
||||
modelValue,
|
||||
readonly
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const setTreeSelectValueAndEmit = async (
|
||||
wrapper: ReturnType<typeof mount>,
|
||||
value: unknown
|
||||
) => {
|
||||
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
|
||||
await treeSelect.vm.$emit('update:modelValue', value)
|
||||
return treeSelect
|
||||
}
|
||||
|
||||
describe('Component Rendering', () => {
|
||||
it('renders treeselect component', () => {
|
||||
const options = createTreeData()
|
||||
const widget = createMockWidget(null, { options })
|
||||
const wrapper = mountComponent(widget, null)
|
||||
|
||||
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
|
||||
expect(treeSelect.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('displays tree options from widget options', () => {
|
||||
const options = createTreeData()
|
||||
const widget = createMockWidget(null, { options })
|
||||
const wrapper = mountComponent(widget, null)
|
||||
|
||||
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
|
||||
expect(treeSelect.props('options')).toEqual(options)
|
||||
})
|
||||
|
||||
it('displays initial selected value', () => {
|
||||
const options = createTreeData()
|
||||
const selectedValue = {
|
||||
key: '0-0-0',
|
||||
label: 'Expenses.doc',
|
||||
data: 'Expenses Document',
|
||||
leaf: true
|
||||
}
|
||||
const widget = createMockWidget(selectedValue, { options })
|
||||
const wrapper = mountComponent(widget, selectedValue)
|
||||
|
||||
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
|
||||
expect(treeSelect.props('modelValue')).toEqual(selectedValue)
|
||||
})
|
||||
|
||||
it('applies small size styling', () => {
|
||||
const widget = createMockWidget(null, { options: [] })
|
||||
const wrapper = mountComponent(widget, null)
|
||||
|
||||
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
|
||||
expect(treeSelect.props('size')).toBe('small')
|
||||
})
|
||||
|
||||
it('applies text-xs class', () => {
|
||||
const widget = createMockWidget(null, { options: [] })
|
||||
const wrapper = mountComponent(widget, null)
|
||||
|
||||
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
|
||||
expect(treeSelect.classes()).toContain('text-xs')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Vue Event Emission', () => {
|
||||
it('emits Vue event when selection changes', async () => {
|
||||
const options = createTreeData()
|
||||
const widget = createMockWidget(null, { options })
|
||||
const wrapper = mountComponent(widget, null)
|
||||
|
||||
const selectedNode = { key: '0-0-0', label: 'Expenses.doc' }
|
||||
await setTreeSelectValueAndEmit(wrapper, selectedNode)
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toEqual([selectedNode])
|
||||
})
|
||||
|
||||
it('emits Vue event when selection is cleared', async () => {
|
||||
const options = createTreeData()
|
||||
const initialValue = { key: '0-0-0', label: 'Expenses.doc' }
|
||||
const widget = createMockWidget(initialValue, { options })
|
||||
const wrapper = mountComponent(widget, initialValue)
|
||||
|
||||
await setTreeSelectValueAndEmit(wrapper, null)
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toEqual([null])
|
||||
})
|
||||
|
||||
it('handles callback when widget value changes', async () => {
|
||||
const mockCallback = vi.fn()
|
||||
const options = createTreeData()
|
||||
const widget = createMockWidget(null, { options }, mockCallback)
|
||||
const wrapper = mountComponent(widget, null)
|
||||
|
||||
// Test that the treeselect has the callback widget
|
||||
expect(widget.callback).toBe(mockCallback)
|
||||
|
||||
// Manually trigger the composable's onChange to test callback
|
||||
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
|
||||
expect(treeSelect.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('handles missing callback gracefully', async () => {
|
||||
const options = createTreeData()
|
||||
const widget = createMockWidget(null, { options }, undefined)
|
||||
const wrapper = mountComponent(widget, null)
|
||||
|
||||
const selectedNode = { key: '0-1-0', label: 'Invoices.txt' }
|
||||
await setTreeSelectValueAndEmit(wrapper, selectedNode)
|
||||
|
||||
// Should still emit Vue event
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toEqual([selectedNode])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tree Structure Handling', () => {
|
||||
it('handles flat tree structure', () => {
|
||||
const flatOptions: TreeNode[] = [
|
||||
{ key: 'item1', label: 'Item 1', leaf: true },
|
||||
{ key: 'item2', label: 'Item 2', leaf: true },
|
||||
{ key: 'item3', label: 'Item 3', leaf: true }
|
||||
]
|
||||
const widget = createMockWidget(null, { options: flatOptions })
|
||||
const wrapper = mountComponent(widget, null)
|
||||
|
||||
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
|
||||
expect(treeSelect.props('options')).toEqual(flatOptions)
|
||||
})
|
||||
|
||||
it('handles nested tree structure', () => {
|
||||
const nestedOptions = createTreeData()
|
||||
const widget = createMockWidget(null, { options: nestedOptions })
|
||||
const wrapper = mountComponent(widget, null)
|
||||
|
||||
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
|
||||
expect(treeSelect.props('options')).toEqual(nestedOptions)
|
||||
})
|
||||
|
||||
it('handles tree with mixed leaf and parent nodes', () => {
|
||||
const mixedOptions: TreeNode[] = [
|
||||
{ key: 'leaf1', label: 'Leaf Node', leaf: true },
|
||||
{
|
||||
key: 'parent1',
|
||||
label: 'Parent Node',
|
||||
children: [{ key: 'child1', label: 'Child Node', leaf: true }]
|
||||
},
|
||||
{ key: 'leaf2', label: 'Another Leaf', leaf: true }
|
||||
]
|
||||
const widget = createMockWidget(null, { options: mixedOptions })
|
||||
const wrapper = mountComponent(widget, null)
|
||||
|
||||
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
|
||||
expect(treeSelect.props('options')).toEqual(mixedOptions)
|
||||
})
|
||||
|
||||
it('handles deeply nested tree structure', () => {
|
||||
const deepOptions: TreeNode[] = [
|
||||
{
|
||||
key: 'level1',
|
||||
label: 'Level 1',
|
||||
children: [
|
||||
{
|
||||
key: 'level2',
|
||||
label: 'Level 2',
|
||||
children: [
|
||||
{
|
||||
key: 'level3',
|
||||
label: 'Level 3',
|
||||
children: [{ key: 'level4', label: 'Level 4', leaf: true }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
const widget = createMockWidget(null, { options: deepOptions })
|
||||
const wrapper = mountComponent(widget, null)
|
||||
|
||||
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
|
||||
expect(treeSelect.props('options')).toEqual(deepOptions)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Selection Modes', () => {
|
||||
it('handles single selection mode', async () => {
|
||||
const options = createTreeData()
|
||||
const widget = createMockWidget(null, {
|
||||
options,
|
||||
selectionMode: 'single'
|
||||
})
|
||||
const wrapper = mountComponent(widget, null)
|
||||
|
||||
const selectedNode = { key: '0-0-0', label: 'Expenses.doc' }
|
||||
await setTreeSelectValueAndEmit(wrapper, selectedNode)
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toEqual([selectedNode])
|
||||
})
|
||||
|
||||
it('handles multiple selection mode', async () => {
|
||||
const options = createTreeData()
|
||||
const widget = createMockWidget(null, {
|
||||
options,
|
||||
selectionMode: 'multiple'
|
||||
})
|
||||
const wrapper = mountComponent(widget, null)
|
||||
|
||||
const selectedNodes = [
|
||||
{ key: '0-0-0', label: 'Expenses.doc' },
|
||||
{ key: '1-0', label: 'Meeting' }
|
||||
]
|
||||
await setTreeSelectValueAndEmit(wrapper, selectedNodes)
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toEqual([selectedNodes])
|
||||
})
|
||||
|
||||
it('handles checkbox selection mode', async () => {
|
||||
const options = createTreeData()
|
||||
const widget = createMockWidget(null, {
|
||||
options,
|
||||
selectionMode: 'checkbox'
|
||||
})
|
||||
const wrapper = mountComponent(widget, null)
|
||||
|
||||
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
|
||||
expect(treeSelect.props('selectionMode')).toBe('checkbox')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Widget Options Handling', () => {
|
||||
it('passes through valid widget options', () => {
|
||||
const options = createTreeData()
|
||||
const widget = createMockWidget(null, {
|
||||
options,
|
||||
placeholder: 'Select a node...',
|
||||
filter: true,
|
||||
showClear: true,
|
||||
selectionMode: 'single'
|
||||
})
|
||||
const wrapper = mountComponent(widget, null)
|
||||
|
||||
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
|
||||
expect(treeSelect.props('placeholder')).toBe('Select a node...')
|
||||
expect(treeSelect.props('filter')).toBe(true)
|
||||
expect(treeSelect.props('showClear')).toBe(true)
|
||||
expect(treeSelect.props('selectionMode')).toBe('single')
|
||||
})
|
||||
|
||||
it('excludes panel-related props', () => {
|
||||
const options = createTreeData()
|
||||
const widget = createMockWidget(null, {
|
||||
options,
|
||||
inputClass: 'custom-input',
|
||||
inputStyle: { color: 'red' },
|
||||
panelClass: 'custom-panel'
|
||||
})
|
||||
const wrapper = mountComponent(widget, null)
|
||||
|
||||
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
|
||||
// These props should be filtered out by the widgetPropFilter
|
||||
const inputClass = treeSelect.props('inputClass')
|
||||
const inputStyle = treeSelect.props('inputStyle')
|
||||
|
||||
// Either undefined or null are acceptable as "excluded"
|
||||
expect(inputClass == null).toBe(true)
|
||||
expect(inputStyle == null).toBe(true)
|
||||
expect(treeSelect.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('handles empty options gracefully', () => {
|
||||
const widget = createMockWidget(null, { options: [] })
|
||||
const wrapper = mountComponent(widget, null)
|
||||
|
||||
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
|
||||
expect(treeSelect.props('options')).toEqual([])
|
||||
})
|
||||
|
||||
it('handles missing options gracefully', () => {
|
||||
const widget = createMockWidget(null)
|
||||
const wrapper = mountComponent(widget, null)
|
||||
|
||||
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
|
||||
// Should not crash, options might be undefined
|
||||
expect(treeSelect.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles malformed tree nodes', () => {
|
||||
const malformedOptions: unknown[] = [
|
||||
{ key: 'empty', label: 'Empty Object' }, // Valid object to prevent issues
|
||||
{ key: 'random', label: 'Random', randomProp: 'value' } // Object with extra properties
|
||||
]
|
||||
const widget = createMockWidget(null, {
|
||||
options: malformedOptions as TreeNode[]
|
||||
})
|
||||
const wrapper = mountComponent(widget, null)
|
||||
|
||||
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
|
||||
expect(treeSelect.props('options')).toEqual(malformedOptions)
|
||||
})
|
||||
|
||||
it('handles nodes with missing keys', () => {
|
||||
const noKeyOptions = [
|
||||
{ key: 'generated-1', label: 'No Key 1', leaf: true },
|
||||
{ key: 'generated-2', label: 'No Key 2', leaf: true }
|
||||
] as TreeNode[]
|
||||
const widget = createMockWidget(null, { options: noKeyOptions })
|
||||
const wrapper = mountComponent(widget, null)
|
||||
|
||||
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
|
||||
expect(treeSelect.props('options')).toEqual(noKeyOptions)
|
||||
})
|
||||
|
||||
it('handles nodes with missing labels', () => {
|
||||
const noLabelOptions: TreeNode[] = [
|
||||
{ key: 'key1', leaf: true },
|
||||
{ key: 'key2', leaf: true }
|
||||
]
|
||||
const widget = createMockWidget(null, { options: noLabelOptions })
|
||||
const wrapper = mountComponent(widget, null)
|
||||
|
||||
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
|
||||
expect(treeSelect.props('options')).toEqual(noLabelOptions)
|
||||
})
|
||||
|
||||
it('handles very large tree structure', () => {
|
||||
const largeTree: TreeNode[] = Array.from({ length: 100 }, (_, i) => ({
|
||||
key: `node${i}`,
|
||||
label: `Node ${i}`,
|
||||
children: Array.from({ length: 10 }, (_, j) => ({
|
||||
key: `node${i}-${j}`,
|
||||
label: `Child ${j}`,
|
||||
leaf: true
|
||||
}))
|
||||
}))
|
||||
const widget = createMockWidget(null, { options: largeTree })
|
||||
const wrapper = mountComponent(widget, null)
|
||||
|
||||
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
|
||||
expect(treeSelect.props('options')).toHaveLength(100)
|
||||
})
|
||||
|
||||
it('handles tree with circular references safely', () => {
|
||||
// Create nodes that could potentially have circular references
|
||||
const circularOptions: TreeNode[] = [
|
||||
{
|
||||
key: 'parent',
|
||||
label: 'Parent',
|
||||
children: [{ key: 'child1', label: 'Child 1', leaf: true }]
|
||||
}
|
||||
]
|
||||
const widget = createMockWidget(null, { options: circularOptions })
|
||||
|
||||
expect(() => mountComponent(widget, null)).not.toThrow()
|
||||
})
|
||||
|
||||
it('handles nodes with special characters', () => {
|
||||
const specialCharOptions: TreeNode[] = [
|
||||
{ key: '@#$%^&*()', label: 'Special Chars @#$%', leaf: true },
|
||||
{
|
||||
key: '{}[]|\\:";\'<>?,./`~',
|
||||
label: 'More Special {}[]|\\',
|
||||
leaf: true
|
||||
}
|
||||
]
|
||||
const widget = createMockWidget(null, { options: specialCharOptions })
|
||||
const wrapper = mountComponent(widget, null)
|
||||
|
||||
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
|
||||
expect(treeSelect.props('options')).toEqual(specialCharOptions)
|
||||
})
|
||||
|
||||
it('handles unicode in node labels', () => {
|
||||
const unicodeOptions: TreeNode[] = [
|
||||
{ key: 'unicode1', label: '🌟 Unicode Star', leaf: true },
|
||||
{ key: 'unicode2', label: '中文 Chinese', leaf: true },
|
||||
{ key: 'unicode3', label: 'العربية Arabic', leaf: true }
|
||||
]
|
||||
const widget = createMockWidget(null, { options: unicodeOptions })
|
||||
const wrapper = mountComponent(widget, null)
|
||||
|
||||
const treeSelect = wrapper.findComponent({ name: 'TreeSelect' })
|
||||
expect(treeSelect.props('options')).toEqual(unicodeOptions)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Integration with Layout', () => {
|
||||
it('renders within WidgetLayoutField', () => {
|
||||
const widget = createMockWidget(null, { options: [] })
|
||||
const wrapper = mountComponent(widget, null)
|
||||
|
||||
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
|
||||
expect(layoutField.exists()).toBe(true)
|
||||
expect(layoutField.props('widget')).toEqual(widget)
|
||||
})
|
||||
|
||||
it('passes widget name to layout field', () => {
|
||||
const widget = createMockWidget(null, { options: [] })
|
||||
widget.name = 'custom_treeselect'
|
||||
const wrapper = mountComponent(widget, null)
|
||||
|
||||
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
|
||||
expect(layoutField.props('widget').name).toBe('custom_treeselect')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,71 +0,0 @@
|
||||
<template>
|
||||
<WidgetLayoutField :widget="widget">
|
||||
<TreeSelect
|
||||
v-model="localValue"
|
||||
v-bind="combinedProps"
|
||||
class="w-full text-xs"
|
||||
:aria-label="widget.name"
|
||||
size="small"
|
||||
:pt="{
|
||||
dropdownIcon: 'text-component-node-foreground-secondary'
|
||||
}"
|
||||
@update:model-value="onChange"
|
||||
/>
|
||||
</WidgetLayoutField>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import TreeSelect from 'primevue/treeselect'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
|
||||
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import {
|
||||
PANEL_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
|
||||
|
||||
export type TreeNode = {
|
||||
key: string
|
||||
label?: string
|
||||
data?: unknown
|
||||
children?: TreeNode[]
|
||||
leaf?: boolean
|
||||
selectable?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<any>
|
||||
modelValue: any
|
||||
}>()
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
// Transform compatibility props for overlay positioning
|
||||
const transformCompatProps = useTransformCompatOverlayProps()
|
||||
|
||||
// TreeSelect specific excluded props
|
||||
const TREE_SELECT_EXCLUDED_PROPS = [
|
||||
...PANEL_EXCLUDED_PROPS,
|
||||
'inputClass',
|
||||
'inputStyle'
|
||||
] as const
|
||||
|
||||
const combinedProps = computed(() => ({
|
||||
...filterWidgetProps(props.widget.options, TREE_SELECT_EXCLUDED_PROPS),
|
||||
...transformCompatProps.value
|
||||
}))
|
||||
</script>
|
||||
@@ -12,7 +12,7 @@
|
||||
:key="getOptionValue(option, index)"
|
||||
:class="
|
||||
cn(
|
||||
'flex-1 h-6 px-5 py-[5px] rounded flex justify-center items-center gap-1 transition-all duration-150 ease-in-out',
|
||||
'flex-1 h-6 px-5 py-[5px] rounded flex justify-center items-center gap-1 transition-all duration-150 ease-in-out truncate min-w-[4ch]',
|
||||
'bg-transparent border-none',
|
||||
'text-center text-xs font-normal',
|
||||
{
|
||||
|
||||
@@ -65,18 +65,22 @@ const theButtonStyle = computed(() =>
|
||||
<!-- Dropdown -->
|
||||
<button
|
||||
:class="
|
||||
cn(theButtonStyle, 'flex justify-between items-center flex-1 h-8', {
|
||||
'rounded-l-lg': uploadable,
|
||||
'rounded-lg': !uploadable
|
||||
})
|
||||
cn(
|
||||
theButtonStyle,
|
||||
'flex justify-between items-center flex-1 min-w-0 h-8',
|
||||
{
|
||||
'rounded-l-lg': uploadable,
|
||||
'rounded-lg': !uploadable
|
||||
}
|
||||
)
|
||||
"
|
||||
@click="emit('select-click', $event)"
|
||||
>
|
||||
<span class="min-w-0 px-4 py-2 text-left">
|
||||
<span v-if="!selectedItems.length" class="min-w-0">
|
||||
<span class="min-w-0 flex-1 px-1 py-2 text-left truncate">
|
||||
<span v-if="!selectedItems.length">
|
||||
{{ props.placeholder }}
|
||||
</span>
|
||||
<span v-else class="line-clamp-1 min-w-0 break-all">
|
||||
<span v-else>
|
||||
{{ selectedItems.map((item) => item.label ?? item.name).join(', ') }}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -11,10 +11,8 @@ defineProps<{
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-[30px] min-w-78 items-center justify-between gap-1">
|
||||
<div
|
||||
class="relative flex h-full basis-content min-w-20 flex-1 items-center"
|
||||
>
|
||||
<div class="flex h-[30px] min-w-0 items-center justify-between gap-1">
|
||||
<div class="relative flex h-full min-w-0 w-20 items-center">
|
||||
<p
|
||||
v-if="widget.name"
|
||||
class="lod-toggle flex-1 truncate text-xs font-normal text-node-component-slot-text"
|
||||
@@ -23,9 +21,10 @@ defineProps<{
|
||||
</p>
|
||||
<LODFallback />
|
||||
</div>
|
||||
<div class="relative min-w-56 basis-full grow">
|
||||
<!-- basis-full grow -->
|
||||
<div class="relative min-w-0 flex-1">
|
||||
<div
|
||||
class="lod-toggle cursor-default"
|
||||
class="lod-toggle cursor-default min-w-0"
|
||||
@pointerdown.stop="noop"
|
||||
@pointermove.stop="noop"
|
||||
@pointerup.stop="noop"
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IFileUploadWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type {
|
||||
FileUploadInputSpec,
|
||||
InputSpec as InputSpecV2
|
||||
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
|
||||
export const useFileUploadWidget = (): ComfyWidgetConstructorV2 => {
|
||||
return (node: LGraphNode, inputSpec: InputSpecV2): IFileUploadWidget => {
|
||||
const { name, options = {} } = inputSpec as FileUploadInputSpec
|
||||
|
||||
const widget = node.addWidget('fileupload', name, '', () => {}, {
|
||||
serialize: true,
|
||||
...(options as Record<string, unknown>)
|
||||
}) as IFileUploadWidget
|
||||
|
||||
return widget
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IMultiSelectWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type {
|
||||
InputSpec as InputSpecV2,
|
||||
MultiSelectInputSpec
|
||||
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
|
||||
export const useMultiSelectWidget = (): ComfyWidgetConstructorV2 => {
|
||||
return (node: LGraphNode, inputSpec: InputSpecV2): IMultiSelectWidget => {
|
||||
const { name, options = {} } = inputSpec as MultiSelectInputSpec
|
||||
|
||||
const widget = node.addWidget('multiselect', name, [], () => {}, {
|
||||
serialize: true,
|
||||
values: options.values || [],
|
||||
...options
|
||||
}) as IMultiSelectWidget
|
||||
|
||||
return widget
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ISelectButtonWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type {
|
||||
InputSpec as InputSpecV2,
|
||||
SelectButtonInputSpec
|
||||
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
|
||||
export const useSelectButtonWidget = (): ComfyWidgetConstructorV2 => {
|
||||
return (node: LGraphNode, inputSpec: InputSpecV2): ISelectButtonWidget => {
|
||||
const { name, options = {} } = inputSpec as SelectButtonInputSpec
|
||||
const values = options.values || []
|
||||
|
||||
const widget = node.addWidget(
|
||||
'selectbutton',
|
||||
name,
|
||||
values[0] || '',
|
||||
(_value: string) => {},
|
||||
{
|
||||
serialize: true,
|
||||
values,
|
||||
...options
|
||||
}
|
||||
) as ISelectButtonWidget
|
||||
|
||||
return widget
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ITreeSelectWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type {
|
||||
InputSpec as InputSpecV2,
|
||||
TreeSelectInputSpec
|
||||
} from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
|
||||
export const useTreeSelectWidget = (): ComfyWidgetConstructorV2 => {
|
||||
return (node: LGraphNode, inputSpec: InputSpecV2): ITreeSelectWidget => {
|
||||
const { name, options = {} } = inputSpec as TreeSelectInputSpec
|
||||
const isMultiple = options.multiple || false
|
||||
const defaultValue = isMultiple ? [] : ''
|
||||
|
||||
const widget = node.addWidget('treeselect', name, defaultValue, () => {}, {
|
||||
serialize: true,
|
||||
values: options.values || [],
|
||||
multiple: isMultiple,
|
||||
...options
|
||||
}) as ITreeSelectWidget
|
||||
|
||||
return widget
|
||||
}
|
||||
}
|
||||
@@ -24,12 +24,6 @@ const WidgetSelect = defineAsyncComponent(
|
||||
const WidgetColorPicker = defineAsyncComponent(
|
||||
() => import('../components/WidgetColorPicker.vue')
|
||||
)
|
||||
const WidgetMultiSelect = defineAsyncComponent(
|
||||
() => import('../components/WidgetMultiSelect.vue')
|
||||
)
|
||||
const WidgetSelectButton = defineAsyncComponent(
|
||||
() => import('../components/WidgetSelectButton.vue')
|
||||
)
|
||||
const WidgetTextarea = defineAsyncComponent(
|
||||
() => import('../components/WidgetTextarea.vue')
|
||||
)
|
||||
@@ -42,12 +36,6 @@ const WidgetImageCompare = defineAsyncComponent(
|
||||
const WidgetGalleria = defineAsyncComponent(
|
||||
() => import('../components/WidgetGalleria.vue')
|
||||
)
|
||||
const WidgetFileUpload = defineAsyncComponent(
|
||||
() => import('../components/WidgetFileUpload.vue')
|
||||
)
|
||||
const WidgetTreeSelect = defineAsyncComponent(
|
||||
() => import('../components/WidgetTreeSelect.vue')
|
||||
)
|
||||
const WidgetMarkdown = defineAsyncComponent(
|
||||
() => import('../components/WidgetMarkdown.vue')
|
||||
)
|
||||
@@ -71,7 +59,6 @@ export const FOR_TESTING = {
|
||||
WidgetAudioUI,
|
||||
WidgetButton,
|
||||
WidgetColorPicker,
|
||||
WidgetFileUpload,
|
||||
WidgetInputNumber,
|
||||
WidgetInputText,
|
||||
WidgetMarkdown,
|
||||
@@ -124,18 +111,6 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
|
||||
'color',
|
||||
{ component: WidgetColorPicker, aliases: ['COLOR'], essential: false }
|
||||
],
|
||||
[
|
||||
'multiselect',
|
||||
{ component: WidgetMultiSelect, aliases: ['MULTISELECT'], essential: false }
|
||||
],
|
||||
[
|
||||
'selectbutton',
|
||||
{
|
||||
component: WidgetSelectButton,
|
||||
aliases: ['SELECTBUTTON'],
|
||||
essential: false
|
||||
}
|
||||
],
|
||||
[
|
||||
'textarea',
|
||||
{
|
||||
@@ -157,18 +132,6 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
|
||||
'galleria',
|
||||
{ component: WidgetGalleria, aliases: ['GALLERIA'], essential: false }
|
||||
],
|
||||
[
|
||||
'fileupload',
|
||||
{
|
||||
component: WidgetFileUpload,
|
||||
aliases: ['FILEUPLOAD', 'file'],
|
||||
essential: false
|
||||
}
|
||||
],
|
||||
[
|
||||
'treeselect',
|
||||
{ component: WidgetTreeSelect, aliases: ['TREESELECT'], essential: false }
|
||||
],
|
||||
[
|
||||
'markdown',
|
||||
{ component: WidgetMarkdown, aliases: ['MARKDOWN'], essential: false }
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
|
||||
|
||||
/**
|
||||
* Creates a mock SimplifiedWidget for testing Vue Node widgets.
|
||||
* This utility function is shared across widget component tests to ensure consistency.
|
||||
*/
|
||||
export function createMockWidget<T extends WidgetValue>(
|
||||
value: T = null as T,
|
||||
options: Record<string, any> = {},
|
||||
callback?: (value: T) => void,
|
||||
overrides: Partial<SimplifiedWidget<T>> = {}
|
||||
): SimplifiedWidget<T> {
|
||||
return {
|
||||
name: 'test_widget',
|
||||
type: 'default',
|
||||
value,
|
||||
options,
|
||||
callback,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock file for testing file upload widgets.
|
||||
*/
|
||||
export function createMockFile(name: string, type: string, size = 1024): File {
|
||||
const file = new File(['mock content'], name, { type })
|
||||
Object.defineProperty(file, 'size', {
|
||||
value: size,
|
||||
writable: false
|
||||
})
|
||||
return file
|
||||
}
|
||||
@@ -50,13 +50,6 @@ const zColorInputSpec = zBaseInputOptions.extend({
|
||||
.optional()
|
||||
})
|
||||
|
||||
const zFileUploadInputSpec = zBaseInputOptions.extend({
|
||||
type: z.literal('FILEUPLOAD'),
|
||||
name: z.string(),
|
||||
isOptional: z.boolean().optional(),
|
||||
options: z.record(z.unknown()).optional()
|
||||
})
|
||||
|
||||
const zImageInputSpec = zBaseInputOptions.extend({
|
||||
type: z.literal('IMAGE'),
|
||||
name: z.string(),
|
||||
@@ -82,29 +75,6 @@ const zMarkdownInputSpec = zBaseInputOptions.extend({
|
||||
.optional()
|
||||
})
|
||||
|
||||
const zTreeSelectInputSpec = zBaseInputOptions.extend({
|
||||
type: z.literal('TREESELECT'),
|
||||
name: z.string(),
|
||||
isOptional: z.boolean().optional(),
|
||||
options: z
|
||||
.object({
|
||||
multiple: z.boolean().optional(),
|
||||
values: z.array(z.unknown()).optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
const zMultiSelectInputSpec = zBaseInputOptions.extend({
|
||||
type: z.literal('MULTISELECT'),
|
||||
name: z.string(),
|
||||
isOptional: z.boolean().optional(),
|
||||
options: z
|
||||
.object({
|
||||
values: z.array(z.string()).optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
const zChartInputSpec = zBaseInputOptions.extend({
|
||||
type: z.literal('CHART'),
|
||||
name: z.string(),
|
||||
@@ -128,17 +98,6 @@ const zGalleriaInputSpec = zBaseInputOptions.extend({
|
||||
.optional()
|
||||
})
|
||||
|
||||
const zSelectButtonInputSpec = zBaseInputOptions.extend({
|
||||
type: z.literal('SELECTBUTTON'),
|
||||
name: z.string(),
|
||||
isOptional: z.boolean().optional(),
|
||||
options: z
|
||||
.object({
|
||||
values: z.array(z.string()).optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
const zTextareaInputSpec = zBaseInputOptions.extend({
|
||||
type: z.literal('TEXTAREA'),
|
||||
name: z.string(),
|
||||
@@ -165,15 +124,11 @@ const zInputSpec = z.union([
|
||||
zStringInputSpec,
|
||||
zComboInputSpec,
|
||||
zColorInputSpec,
|
||||
zFileUploadInputSpec,
|
||||
zImageInputSpec,
|
||||
zImageCompareInputSpec,
|
||||
zMarkdownInputSpec,
|
||||
zTreeSelectInputSpec,
|
||||
zMultiSelectInputSpec,
|
||||
zChartInputSpec,
|
||||
zGalleriaInputSpec,
|
||||
zSelectButtonInputSpec,
|
||||
zTextareaInputSpec,
|
||||
zCustomInputSpec
|
||||
])
|
||||
@@ -213,13 +168,9 @@ type BooleanInputSpec = z.infer<typeof zBooleanInputSpec>
|
||||
type StringInputSpec = z.infer<typeof zStringInputSpec>
|
||||
export type ComboInputSpec = z.infer<typeof zComboInputSpec>
|
||||
export type ColorInputSpec = z.infer<typeof zColorInputSpec>
|
||||
export type FileUploadInputSpec = z.infer<typeof zFileUploadInputSpec>
|
||||
export type ImageCompareInputSpec = z.infer<typeof zImageCompareInputSpec>
|
||||
export type TreeSelectInputSpec = z.infer<typeof zTreeSelectInputSpec>
|
||||
export type MultiSelectInputSpec = z.infer<typeof zMultiSelectInputSpec>
|
||||
export type ChartInputSpec = z.infer<typeof zChartInputSpec>
|
||||
export type GalleriaInputSpec = z.infer<typeof zGalleriaInputSpec>
|
||||
export type SelectButtonInputSpec = z.infer<typeof zSelectButtonInputSpec>
|
||||
export type TextareaInputSpec = z.infer<typeof zTextareaInputSpec>
|
||||
export type CustomInputSpec = z.infer<typeof zCustomInputSpec>
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ export async function addStylesheet(
|
||||
})
|
||||
}
|
||||
|
||||
/** @knipIgnoreUnusedButUsedByCustomNodes */
|
||||
export { downloadBlob } from '@/base/common/downloadUtil'
|
||||
|
||||
export function uploadFile(accept: string) {
|
||||
|
||||
@@ -10,18 +10,14 @@ import { useBooleanWidget } from '@/renderer/extensions/vueNodes/widgets/composa
|
||||
import { useChartWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useChartWidget'
|
||||
import { useColorWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useColorWidget'
|
||||
import { useComboWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useComboWidget'
|
||||
import { useFileUploadWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useFileUploadWidget'
|
||||
import { useFloatWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useFloatWidget'
|
||||
import { useGalleriaWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useGalleriaWidget'
|
||||
import { useImageCompareWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useImageCompareWidget'
|
||||
import { useImageUploadWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useImageUploadWidget'
|
||||
import { useIntWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useIntWidget'
|
||||
import { useMarkdownWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useMarkdownWidget'
|
||||
import { useMultiSelectWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useMultiSelectWidget'
|
||||
import { useSelectButtonWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useSelectButtonWidget'
|
||||
import { useStringWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useStringWidget'
|
||||
import { useTextareaWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useTextareaWidget'
|
||||
import { useTreeSelectWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useTreeSelectWidget'
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
@@ -296,13 +292,9 @@ export const ComfyWidgets: Record<string, ComfyWidgetConstructor> = {
|
||||
MARKDOWN: transformWidgetConstructorV2ToV1(useMarkdownWidget()),
|
||||
COMBO: transformWidgetConstructorV2ToV1(useComboWidget()),
|
||||
IMAGEUPLOAD: useImageUploadWidget(),
|
||||
FILEUPLOAD: transformWidgetConstructorV2ToV1(useFileUploadWidget()),
|
||||
COLOR: transformWidgetConstructorV2ToV1(useColorWidget()),
|
||||
IMAGECOMPARE: transformWidgetConstructorV2ToV1(useImageCompareWidget()),
|
||||
TREESELECT: transformWidgetConstructorV2ToV1(useTreeSelectWidget()),
|
||||
MULTISELECT: transformWidgetConstructorV2ToV1(useMultiSelectWidget()),
|
||||
CHART: transformWidgetConstructorV2ToV1(useChartWidget()),
|
||||
GALLERIA: transformWidgetConstructorV2ToV1(useGalleriaWidget()),
|
||||
SELECTBUTTON: transformWidgetConstructorV2ToV1(useSelectButtonWidget()),
|
||||
TEXTAREA: transformWidgetConstructorV2ToV1(useTextareaWidget())
|
||||
}
|
||||
|
||||
@@ -49,9 +49,9 @@ describe('WidgetSelect Value Binding', () => {
|
||||
options: Partial<
|
||||
SelectProps & { values?: string[]; return_index?: boolean }
|
||||
> = {},
|
||||
callback?: (value: string | number | undefined) => void,
|
||||
callback?: (value: string | undefined) => void,
|
||||
spec?: ComboInputSpec
|
||||
): SimplifiedWidget<string | number | undefined> => ({
|
||||
): SimplifiedWidget<string | undefined> => ({
|
||||
name: 'test_select',
|
||||
type: 'combo',
|
||||
value,
|
||||
@@ -64,8 +64,8 @@ describe('WidgetSelect Value Binding', () => {
|
||||
})
|
||||
|
||||
const mountComponent = (
|
||||
widget: SimplifiedWidget<string | number | undefined>,
|
||||
modelValue: string | number | undefined,
|
||||
widget: SimplifiedWidget<string | undefined>,
|
||||
modelValue: string | undefined,
|
||||
readonly = false
|
||||
) => {
|
||||
return mount(WidgetSelect, {
|
||||
|
||||
@@ -1,503 +0,0 @@
|
||||
import {
|
||||
type MockedFunction,
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import {
|
||||
useBooleanWidgetValue,
|
||||
useNumberWidgetValue,
|
||||
useStringWidgetValue,
|
||||
useWidgetValue
|
||||
} from '@/composables/graph/useWidgetValue'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
describe('useWidgetValue', () => {
|
||||
let mockWidget: SimplifiedWidget<string>
|
||||
let mockEmit: MockedFunction<(event: 'update:modelValue', value: any) => void>
|
||||
let consoleWarnSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
mockWidget = {
|
||||
name: 'testWidget',
|
||||
type: 'string',
|
||||
value: 'initial',
|
||||
callback: vi.fn()
|
||||
}
|
||||
mockEmit = vi.fn()
|
||||
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
consoleWarnSpy.mockRestore()
|
||||
})
|
||||
|
||||
describe('basic functionality', () => {
|
||||
it('should initialize with modelValue', () => {
|
||||
const { localValue } = useWidgetValue({
|
||||
widget: mockWidget,
|
||||
modelValue: 'test value',
|
||||
defaultValue: '',
|
||||
emit: mockEmit
|
||||
})
|
||||
|
||||
expect(localValue.value).toBe('test value')
|
||||
})
|
||||
|
||||
it('should use defaultValue when modelValue is null', () => {
|
||||
const { localValue } = useWidgetValue({
|
||||
widget: mockWidget,
|
||||
modelValue: null as any,
|
||||
defaultValue: 'default',
|
||||
emit: mockEmit
|
||||
})
|
||||
|
||||
expect(localValue.value).toBe('default')
|
||||
})
|
||||
|
||||
it('should use defaultValue when modelValue is undefined', () => {
|
||||
const { localValue } = useWidgetValue({
|
||||
widget: mockWidget,
|
||||
modelValue: undefined as any,
|
||||
defaultValue: 'default',
|
||||
emit: mockEmit
|
||||
})
|
||||
|
||||
expect(localValue.value).toBe('default')
|
||||
})
|
||||
})
|
||||
|
||||
describe('onChange handler', () => {
|
||||
it('should update localValue immediately', () => {
|
||||
const { localValue, onChange } = useWidgetValue({
|
||||
widget: mockWidget,
|
||||
modelValue: 'initial',
|
||||
defaultValue: '',
|
||||
emit: mockEmit
|
||||
})
|
||||
|
||||
onChange('new value')
|
||||
expect(localValue.value).toBe('new value')
|
||||
})
|
||||
|
||||
it('should emit update:modelValue event', () => {
|
||||
const { onChange } = useWidgetValue({
|
||||
widget: mockWidget,
|
||||
modelValue: 'initial',
|
||||
defaultValue: '',
|
||||
emit: mockEmit
|
||||
})
|
||||
|
||||
onChange('new value')
|
||||
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 'new value')
|
||||
})
|
||||
|
||||
// useGraphNodeMaanger's createWrappedWidgetCallback makes the callback right now instead of useWidgetValue
|
||||
// it('should call widget callback if it exists', () => {
|
||||
// const { onChange } = useWidgetValue({
|
||||
// widget: mockWidget,
|
||||
// modelValue: 'initial',
|
||||
// defaultValue: '',
|
||||
// emit: mockEmit
|
||||
// })
|
||||
|
||||
// onChange('new value')
|
||||
// expect(mockWidget.callback).toHaveBeenCalledWith('new value')
|
||||
// })
|
||||
|
||||
it('should not error if widget callback is undefined', () => {
|
||||
const widgetWithoutCallback = { ...mockWidget, callback: undefined }
|
||||
const { onChange } = useWidgetValue({
|
||||
widget: widgetWithoutCallback,
|
||||
modelValue: 'initial',
|
||||
defaultValue: '',
|
||||
emit: mockEmit
|
||||
})
|
||||
|
||||
expect(() => onChange('new value')).not.toThrow()
|
||||
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 'new value')
|
||||
})
|
||||
|
||||
it('should handle null values', () => {
|
||||
const { localValue, onChange } = useWidgetValue({
|
||||
widget: mockWidget,
|
||||
modelValue: 'initial',
|
||||
defaultValue: 'default',
|
||||
emit: mockEmit
|
||||
})
|
||||
|
||||
onChange(null as any)
|
||||
expect(localValue.value).toBe('default')
|
||||
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 'default')
|
||||
})
|
||||
|
||||
it('should handle undefined values', () => {
|
||||
const { localValue, onChange } = useWidgetValue({
|
||||
widget: mockWidget,
|
||||
modelValue: 'initial',
|
||||
defaultValue: 'default',
|
||||
emit: mockEmit
|
||||
})
|
||||
|
||||
onChange(undefined as any)
|
||||
expect(localValue.value).toBe('default')
|
||||
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 'default')
|
||||
})
|
||||
})
|
||||
|
||||
describe('type safety', () => {
|
||||
it('should handle type mismatches with warning', () => {
|
||||
const numberWidget: SimplifiedWidget<number> = {
|
||||
name: 'numberWidget',
|
||||
type: 'number',
|
||||
value: 42,
|
||||
callback: vi.fn()
|
||||
}
|
||||
|
||||
const { onChange } = useWidgetValue({
|
||||
widget: numberWidget,
|
||||
modelValue: 10,
|
||||
defaultValue: 0,
|
||||
emit: mockEmit
|
||||
})
|
||||
|
||||
// Pass string to number widget
|
||||
onChange('not a number' as any)
|
||||
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'useWidgetValue: Type mismatch for widget numberWidget. Expected number, got string'
|
||||
)
|
||||
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 0) // Uses defaultValue
|
||||
})
|
||||
|
||||
it('should accept values of matching type', () => {
|
||||
const numberWidget: SimplifiedWidget<number> = {
|
||||
name: 'numberWidget',
|
||||
type: 'number',
|
||||
value: 42,
|
||||
callback: vi.fn()
|
||||
}
|
||||
|
||||
const { onChange } = useWidgetValue({
|
||||
widget: numberWidget,
|
||||
modelValue: 10,
|
||||
defaultValue: 0,
|
||||
emit: mockEmit
|
||||
})
|
||||
|
||||
onChange(25)
|
||||
expect(consoleWarnSpy).not.toHaveBeenCalled()
|
||||
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 25)
|
||||
})
|
||||
})
|
||||
|
||||
describe('transform function', () => {
|
||||
it('should apply transform function to new values', () => {
|
||||
const transform = vi.fn((value: string) => value.toUpperCase())
|
||||
const { onChange } = useWidgetValue({
|
||||
widget: mockWidget,
|
||||
modelValue: 'initial',
|
||||
defaultValue: '',
|
||||
emit: mockEmit,
|
||||
transform
|
||||
})
|
||||
|
||||
onChange('hello')
|
||||
expect(transform).toHaveBeenCalledWith('hello')
|
||||
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 'HELLO')
|
||||
})
|
||||
|
||||
it('should skip type checking when transform is provided', () => {
|
||||
const numberWidget: SimplifiedWidget<number> = {
|
||||
name: 'numberWidget',
|
||||
type: 'number',
|
||||
value: 42,
|
||||
callback: vi.fn()
|
||||
}
|
||||
|
||||
const transform = (value: string) => parseInt(value, 10) || 0
|
||||
const { onChange } = useWidgetValue({
|
||||
widget: numberWidget,
|
||||
modelValue: 10,
|
||||
defaultValue: 0,
|
||||
emit: mockEmit,
|
||||
transform
|
||||
})
|
||||
|
||||
onChange('123')
|
||||
expect(consoleWarnSpy).not.toHaveBeenCalled()
|
||||
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 123)
|
||||
})
|
||||
})
|
||||
|
||||
describe('external updates', () => {
|
||||
it('should update localValue when modelValue changes', async () => {
|
||||
const modelValue = ref('initial')
|
||||
const { localValue } = useWidgetValue({
|
||||
widget: mockWidget,
|
||||
modelValue: modelValue.value,
|
||||
defaultValue: '',
|
||||
emit: mockEmit
|
||||
})
|
||||
|
||||
expect(localValue.value).toBe('initial')
|
||||
|
||||
// Simulate parent updating modelValue
|
||||
modelValue.value = 'updated externally'
|
||||
|
||||
// Re-create the composable with new value (simulating prop change)
|
||||
const { localValue: newLocalValue } = useWidgetValue({
|
||||
widget: mockWidget,
|
||||
modelValue: modelValue.value,
|
||||
defaultValue: '',
|
||||
emit: mockEmit
|
||||
})
|
||||
|
||||
expect(newLocalValue.value).toBe('updated externally')
|
||||
})
|
||||
|
||||
it('should handle external null values', async () => {
|
||||
const modelValue = ref<string | null>('initial')
|
||||
const { localValue } = useWidgetValue({
|
||||
widget: mockWidget,
|
||||
modelValue: modelValue.value!,
|
||||
defaultValue: 'default',
|
||||
emit: mockEmit
|
||||
})
|
||||
|
||||
expect(localValue.value).toBe('initial')
|
||||
|
||||
// Simulate external update to null
|
||||
modelValue.value = null
|
||||
const { localValue: newLocalValue } = useWidgetValue({
|
||||
widget: mockWidget,
|
||||
modelValue: modelValue.value as any,
|
||||
defaultValue: 'default',
|
||||
emit: mockEmit
|
||||
})
|
||||
|
||||
expect(newLocalValue.value).toBe('default')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useStringWidgetValue helper', () => {
|
||||
it('should handle string values correctly', () => {
|
||||
const stringWidget: SimplifiedWidget<string> = {
|
||||
name: 'textWidget',
|
||||
type: 'string',
|
||||
value: 'hello',
|
||||
callback: vi.fn()
|
||||
}
|
||||
|
||||
const { localValue, onChange } = useStringWidgetValue(
|
||||
stringWidget,
|
||||
'initial',
|
||||
mockEmit
|
||||
)
|
||||
|
||||
expect(localValue.value).toBe('initial')
|
||||
|
||||
onChange('new string')
|
||||
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 'new string')
|
||||
})
|
||||
|
||||
it('should transform undefined to empty string', () => {
|
||||
const stringWidget: SimplifiedWidget<string> = {
|
||||
name: 'textWidget',
|
||||
type: 'string',
|
||||
value: '',
|
||||
callback: vi.fn()
|
||||
}
|
||||
|
||||
const { onChange } = useStringWidgetValue(stringWidget, '', mockEmit)
|
||||
|
||||
onChange(undefined as any)
|
||||
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', '')
|
||||
})
|
||||
|
||||
it('should convert non-string values to string', () => {
|
||||
const stringWidget: SimplifiedWidget<string> = {
|
||||
name: 'textWidget',
|
||||
type: 'string',
|
||||
value: '',
|
||||
callback: vi.fn()
|
||||
}
|
||||
|
||||
const { onChange } = useStringWidgetValue(stringWidget, '', mockEmit)
|
||||
|
||||
onChange(123 as any)
|
||||
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', '123')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useNumberWidgetValue helper', () => {
|
||||
it('should handle number values correctly', () => {
|
||||
const numberWidget: SimplifiedWidget<number> = {
|
||||
name: 'sliderWidget',
|
||||
type: 'number',
|
||||
value: 50,
|
||||
callback: vi.fn()
|
||||
}
|
||||
|
||||
const { localValue, onChange } = useNumberWidgetValue(
|
||||
numberWidget,
|
||||
25,
|
||||
mockEmit
|
||||
)
|
||||
|
||||
expect(localValue.value).toBe(25)
|
||||
|
||||
onChange(75)
|
||||
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 75)
|
||||
})
|
||||
|
||||
it('should handle array values from PrimeVue Slider', () => {
|
||||
const numberWidget: SimplifiedWidget<number> = {
|
||||
name: 'sliderWidget',
|
||||
type: 'number',
|
||||
value: 50,
|
||||
callback: vi.fn()
|
||||
}
|
||||
|
||||
const { onChange } = useNumberWidgetValue(numberWidget, 25, mockEmit)
|
||||
|
||||
// PrimeVue Slider can emit number[]
|
||||
onChange([42, 100] as any)
|
||||
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 42)
|
||||
})
|
||||
|
||||
it('should handle empty array', () => {
|
||||
const numberWidget: SimplifiedWidget<number> = {
|
||||
name: 'sliderWidget',
|
||||
type: 'number',
|
||||
value: 50,
|
||||
callback: vi.fn()
|
||||
}
|
||||
|
||||
const { onChange } = useNumberWidgetValue(numberWidget, 25, mockEmit)
|
||||
|
||||
onChange([] as any)
|
||||
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 0)
|
||||
})
|
||||
|
||||
it('should convert string numbers', () => {
|
||||
const numberWidget: SimplifiedWidget<number> = {
|
||||
name: 'numberWidget',
|
||||
type: 'number',
|
||||
value: 0,
|
||||
callback: vi.fn()
|
||||
}
|
||||
|
||||
const { onChange } = useNumberWidgetValue(numberWidget, 0, mockEmit)
|
||||
|
||||
onChange('42' as any)
|
||||
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 42)
|
||||
})
|
||||
|
||||
it('should handle invalid number conversions', () => {
|
||||
const numberWidget: SimplifiedWidget<number> = {
|
||||
name: 'numberWidget',
|
||||
type: 'number',
|
||||
value: 0,
|
||||
callback: vi.fn()
|
||||
}
|
||||
|
||||
const { onChange } = useNumberWidgetValue(numberWidget, 0, mockEmit)
|
||||
|
||||
onChange('not-a-number' as any)
|
||||
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBooleanWidgetValue helper', () => {
|
||||
it('should handle boolean values correctly', () => {
|
||||
const boolWidget: SimplifiedWidget<boolean> = {
|
||||
name: 'toggleWidget',
|
||||
type: 'boolean',
|
||||
value: false,
|
||||
callback: vi.fn()
|
||||
}
|
||||
|
||||
const { localValue, onChange } = useBooleanWidgetValue(
|
||||
boolWidget,
|
||||
true,
|
||||
mockEmit
|
||||
)
|
||||
|
||||
expect(localValue.value).toBe(true)
|
||||
|
||||
onChange(false)
|
||||
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', false)
|
||||
})
|
||||
|
||||
it('should convert truthy values to true', () => {
|
||||
const boolWidget: SimplifiedWidget<boolean> = {
|
||||
name: 'toggleWidget',
|
||||
type: 'boolean',
|
||||
value: false,
|
||||
callback: vi.fn()
|
||||
}
|
||||
|
||||
const { onChange } = useBooleanWidgetValue(boolWidget, false, mockEmit)
|
||||
|
||||
onChange('truthy' as any)
|
||||
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', true)
|
||||
})
|
||||
|
||||
it('should convert falsy values to false', () => {
|
||||
const boolWidget: SimplifiedWidget<boolean> = {
|
||||
name: 'toggleWidget',
|
||||
type: 'boolean',
|
||||
value: false,
|
||||
callback: vi.fn()
|
||||
}
|
||||
|
||||
const { onChange } = useBooleanWidgetValue(boolWidget, true, mockEmit)
|
||||
|
||||
onChange(0 as any)
|
||||
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle rapid onChange calls', () => {
|
||||
const { onChange } = useWidgetValue({
|
||||
widget: mockWidget,
|
||||
modelValue: 'initial',
|
||||
defaultValue: '',
|
||||
emit: mockEmit
|
||||
})
|
||||
|
||||
onChange('value1')
|
||||
onChange('value2')
|
||||
onChange('value3')
|
||||
|
||||
expect(mockEmit).toHaveBeenCalledTimes(3)
|
||||
expect(mockEmit).toHaveBeenNthCalledWith(1, 'update:modelValue', 'value1')
|
||||
expect(mockEmit).toHaveBeenNthCalledWith(2, 'update:modelValue', 'value2')
|
||||
expect(mockEmit).toHaveBeenNthCalledWith(3, 'update:modelValue', 'value3')
|
||||
})
|
||||
|
||||
it('should handle widget with all properties undefined', () => {
|
||||
const minimalWidget = {
|
||||
name: 'minimal',
|
||||
type: 'unknown'
|
||||
} as SimplifiedWidget<any>
|
||||
|
||||
const { localValue, onChange } = useWidgetValue({
|
||||
widget: minimalWidget,
|
||||
modelValue: 'test',
|
||||
defaultValue: 'default',
|
||||
emit: mockEmit
|
||||
})
|
||||
|
||||
expect(localValue.value).toBe('test')
|
||||
expect(() => onChange('new')).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1645,6 +1645,10 @@ describe('useNodePricing', () => {
|
||||
model: 'gemini-2.5-pro',
|
||||
expected: '$0.00125/$0.01 per 1K tokens'
|
||||
},
|
||||
{
|
||||
model: 'gemini-3-pro-preview',
|
||||
expected: '$0.002/$0.012 per 1K tokens'
|
||||
},
|
||||
{
|
||||
model: 'gemini-2.5-flash-preview-04-17',
|
||||
expected: '$0.0003/$0.0025 per 1K tokens'
|
||||
|
||||
@@ -31,7 +31,7 @@ import { assetService } from '@/platform/assets/services/assetService'
|
||||
const mockAssetServiceEligible = vi.mocked(assetService.isAssetBrowserEligible)
|
||||
|
||||
describe('WidgetSelect asset mode', () => {
|
||||
const createWidget = (): SimplifiedWidget<string | number | undefined> => ({
|
||||
const createWidget = (): SimplifiedWidget<string | undefined> => ({
|
||||
name: 'ckpt_name',
|
||||
type: 'combo',
|
||||
value: undefined,
|
||||
|
||||
@@ -25,7 +25,7 @@ describe('WidgetSelectDropdown custom label mapping', () => {
|
||||
getOptionLabel?: (value: string | null) => string
|
||||
} = {},
|
||||
spec?: ComboInputSpec
|
||||
): SimplifiedWidget<string | number | undefined> => ({
|
||||
): SimplifiedWidget<string | undefined> => ({
|
||||
name: 'test_image_select',
|
||||
type: 'combo',
|
||||
value,
|
||||
@@ -37,8 +37,8 @@ describe('WidgetSelectDropdown custom label mapping', () => {
|
||||
})
|
||||
|
||||
const mountComponent = (
|
||||
widget: SimplifiedWidget<string | number | undefined>,
|
||||
modelValue: string | number | undefined,
|
||||
widget: SimplifiedWidget<string | undefined>,
|
||||
modelValue: string | undefined,
|
||||
assetKind: 'image' | 'video' | 'audio' = 'image'
|
||||
): VueWrapper<WidgetSelectDropdownInstance> => {
|
||||
return mount(WidgetSelectDropdown, {
|
||||
|
||||
@@ -11,7 +11,6 @@ const {
|
||||
WidgetAudioUI,
|
||||
WidgetButton,
|
||||
WidgetColorPicker,
|
||||
WidgetFileUpload,
|
||||
WidgetInputNumber,
|
||||
WidgetInputText,
|
||||
WidgetMarkdown,
|
||||
@@ -88,12 +87,6 @@ describe('widgetRegistry', () => {
|
||||
expect(getComponent('COLOR', 'color')).toBe(WidgetColorPicker)
|
||||
})
|
||||
|
||||
it('should map file types to file upload widget', () => {
|
||||
expect(getComponent('file', 'file')).toBe(WidgetFileUpload)
|
||||
expect(getComponent('fileupload', 'file')).toBe(WidgetFileUpload)
|
||||
expect(getComponent('FILEUPLOAD', 'file')).toBe(WidgetFileUpload)
|
||||
})
|
||||
|
||||
it('should map button types to button widget', () => {
|
||||
expect(getComponent('button', '')).toBe(WidgetButton)
|
||||
expect(getComponent('BUTTON', '')).toBe(WidgetButton)
|
||||
|
||||