From ccc1039abb0c19511d6e5353adf9dcd1f5a7e88d Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Wed, 21 May 2025 16:06:16 -0700 Subject: [PATCH] [feat] Add file upload support to canvas background image setting (#3958) Co-authored-by: github-actions --- .../tests/backgroundImageUpload.spec.ts | 251 ++++++++++++++++++ .../common/BackgroundImageUpload.vue | 103 +++++++ src/components/common/FormItem.vue | 3 + .../sidebar/tabs/QueueSidebarTab.vue | 68 ++--- src/constants/coreSettings.ts | 9 +- src/locales/en/main.json | 4 +- src/locales/en/settings.json | 2 +- src/locales/es/main.json | 2 + src/locales/fr/main.json | 2 + src/locales/ja/main.json | 2 + src/locales/ko/main.json | 2 + src/locales/ru/main.json | 2 + src/locales/zh/main.json | 2 + src/types/settingTypes.ts | 1 + 14 files changed, 414 insertions(+), 39 deletions(-) create mode 100644 browser_tests/tests/backgroundImageUpload.spec.ts create mode 100644 src/components/common/BackgroundImageUpload.vue diff --git a/browser_tests/tests/backgroundImageUpload.spec.ts b/browser_tests/tests/backgroundImageUpload.spec.ts new file mode 100644 index 0000000000..24af9e8acd --- /dev/null +++ b/browser_tests/tests/backgroundImageUpload.spec.ts @@ -0,0 +1,251 @@ +import { expect } from '@playwright/test' + +import { comfyPageFixture as test } from '../fixtures/ComfyPage' + +test.describe('Background Image Upload', () => { + test.beforeEach(async ({ comfyPage }) => { + // Reset the background image setting before each test + await comfyPage.setSetting('Comfy.Canvas.BackgroundImage', '') + }) + + test.afterEach(async ({ comfyPage }) => { + // Clean up background image setting after each test + await comfyPage.setSetting('Comfy.Canvas.BackgroundImage', '') + }) + + test('should show background image upload component in settings', async ({ + comfyPage + }) => { + // Open settings dialog + await comfyPage.page.keyboard.press('Control+,') + + // Navigate to Appearance category + const appearanceOption = comfyPage.page.locator('text=Appearance') + await appearanceOption.click() + + // Find the background image setting + const backgroundImageSetting = comfyPage.page.locator( + '#Comfy\\.Canvas\\.BackgroundImage' + ) + await expect(backgroundImageSetting).toBeVisible() + + // Verify the component has the expected elements using semantic selectors + const urlInput = backgroundImageSetting.locator('input[type="text"]') + await expect(urlInput).toBeVisible() + await expect(urlInput).toHaveAttribute('placeholder') + + const uploadButton = backgroundImageSetting.locator( + 'button:has(.pi-upload)' + ) + await expect(uploadButton).toBeVisible() + + const clearButton = backgroundImageSetting.locator('button:has(.pi-trash)') + await expect(clearButton).toBeVisible() + await expect(clearButton).toBeDisabled() // Should be disabled when no image + }) + + test('should upload image file and set as background', async ({ + comfyPage + }) => { + // Open settings dialog + await comfyPage.page.keyboard.press('Control+,') + + // Navigate to Appearance category + const appearanceOption = comfyPage.page.locator('text=Appearance') + await appearanceOption.click() + + // Find the background image setting + const backgroundImageSetting = comfyPage.page.locator( + '#Comfy\\.Canvas\\.BackgroundImage' + ) + // Click the upload button to trigger file input + const uploadButton = backgroundImageSetting.locator( + 'button:has(.pi-upload)' + ) + + // Set up file upload handler + const fileChooserPromise = comfyPage.page.waitForEvent('filechooser') + await uploadButton.click() + const fileChooser = await fileChooserPromise + + // Upload the test image + await fileChooser.setFiles(comfyPage.assetPath('image32x32.webp')) + + // Wait for upload to complete and verify the setting was updated + await comfyPage.page.waitForTimeout(500) // Give time for file reading + + // Verify the URL input now has an API URL + const urlInput = backgroundImageSetting.locator('input[type="text"]') + const inputValue = await urlInput.inputValue() + expect(inputValue).toMatch(/^\/api\/view\?.*subfolder=backgrounds/) + + // Verify clear button is now enabled + const clearButton = backgroundImageSetting.locator('button:has(.pi-trash)') + await expect(clearButton).toBeEnabled() + + // Verify the setting value was actually set + const settingValue = await comfyPage.getSetting( + 'Comfy.Canvas.BackgroundImage' + ) + expect(settingValue).toMatch(/^\/api\/view\?.*subfolder=backgrounds/) + }) + + test('should accept URL input for background image', async ({ + comfyPage + }) => { + const testImageUrl = 'https://example.com/test-image.png' + + // Open settings dialog + await comfyPage.page.keyboard.press('Control+,') + + // Navigate to Appearance category + const appearanceOption = comfyPage.page.locator('text=Appearance') + await appearanceOption.click() + + // Find the background image setting + const backgroundImageSetting = comfyPage.page.locator( + '#Comfy\\.Canvas\\.BackgroundImage' + ) + // Enter URL in the input field + const urlInput = backgroundImageSetting.locator('input[type="text"]') + await urlInput.fill(testImageUrl) + + // Trigger blur event to ensure the value is set + await urlInput.blur() + + // Verify clear button is now enabled + const clearButton = backgroundImageSetting.locator('button:has(.pi-trash)') + await expect(clearButton).toBeEnabled() + + // Verify the setting value was updated + const settingValue = await comfyPage.getSetting( + 'Comfy.Canvas.BackgroundImage' + ) + expect(settingValue).toBe(testImageUrl) + }) + + test('should clear background image when clear button is clicked', async ({ + comfyPage + }) => { + const testImageUrl = 'https://example.com/test-image.png' + + // First set a background image + await comfyPage.setSetting('Comfy.Canvas.BackgroundImage', testImageUrl) + + // Open settings dialog + await comfyPage.page.keyboard.press('Control+,') + + // Navigate to Appearance category + const appearanceOption = comfyPage.page.locator('text=Appearance') + await appearanceOption.click() + + // Find the background image setting + const backgroundImageSetting = comfyPage.page.locator( + '#Comfy\\.Canvas\\.BackgroundImage' + ) + // Verify the input has the test URL + const urlInput = backgroundImageSetting.locator('input[type="text"]') + await expect(urlInput).toHaveValue(testImageUrl) + + // Verify clear button is enabled + const clearButton = backgroundImageSetting.locator('button:has(.pi-trash)') + await expect(clearButton).toBeEnabled() + + // Click the clear button + await clearButton.click() + + // Verify the input is now empty + await expect(urlInput).toHaveValue('') + + // Verify clear button is now disabled + await expect(clearButton).toBeDisabled() + + // Verify the setting value was cleared + const settingValue = await comfyPage.getSetting( + 'Comfy.Canvas.BackgroundImage' + ) + expect(settingValue).toBe('') + }) + + test('should show tooltip on upload and clear buttons', async ({ + comfyPage + }) => { + // Open settings dialog + await comfyPage.page.keyboard.press('Control+,') + + // Navigate to Appearance category + const appearanceOption = comfyPage.page.locator('text=Appearance') + await appearanceOption.click() + + // Find the background image setting + const backgroundImageSetting = comfyPage.page.locator( + '#Comfy\\.Canvas\\.BackgroundImage' + ) + // Hover over upload button and verify tooltip appears + const uploadButton = backgroundImageSetting.locator( + 'button:has(.pi-upload)' + ) + await uploadButton.hover() + + // Wait for tooltip to appear and verify it exists + await comfyPage.page.waitForTimeout(700) // Tooltip delay + const uploadTooltip = comfyPage.page.locator('.p-tooltip:visible') + await expect(uploadTooltip).toBeVisible() + + // Move away to hide tooltip + await comfyPage.page.locator('body').hover() + await comfyPage.page.waitForTimeout(100) + + // Set a background to enable clear button + const urlInput = backgroundImageSetting.locator('input[type="text"]') + await urlInput.fill('https://example.com/test.png') + await urlInput.blur() + + // Hover over clear button and verify tooltip appears + const clearButton = backgroundImageSetting.locator('button:has(.pi-trash)') + await clearButton.hover() + + // Wait for tooltip to appear and verify it exists + await comfyPage.page.waitForTimeout(700) // Tooltip delay + const clearTooltip = comfyPage.page.locator('.p-tooltip:visible') + await expect(clearTooltip).toBeVisible() + }) + + test('should maintain reactive updates between URL input and clear button state', async ({ + comfyPage + }) => { + // Open settings dialog + await comfyPage.page.keyboard.press('Control+,') + + // Navigate to Appearance category + const appearanceOption = comfyPage.page.locator('text=Appearance') + await appearanceOption.click() + + // Find the background image setting + const backgroundImageSetting = comfyPage.page.locator( + '#Comfy\\.Canvas\\.BackgroundImage' + ) + const urlInput = backgroundImageSetting.locator('input[type="text"]') + const clearButton = backgroundImageSetting.locator('button:has(.pi-trash)') + + // Initially clear button should be disabled + await expect(clearButton).toBeDisabled() + + // Type some text - clear button should become enabled + await urlInput.fill('test') + await expect(clearButton).toBeEnabled() + + // Clear the text manually - clear button should become disabled again + await urlInput.fill('') + await expect(clearButton).toBeDisabled() + + // Add text again - clear button should become enabled + await urlInput.fill('https://example.com/image.png') + await expect(clearButton).toBeEnabled() + + // Use clear button - should clear input and disable itself + await clearButton.click() + await expect(urlInput).toHaveValue('') + await expect(clearButton).toBeDisabled() + }) +}) diff --git a/src/components/common/BackgroundImageUpload.vue b/src/components/common/BackgroundImageUpload.vue new file mode 100644 index 0000000000..7c20cbab11 --- /dev/null +++ b/src/components/common/BackgroundImageUpload.vue @@ -0,0 +1,103 @@ + + + diff --git a/src/components/common/FormItem.vue b/src/components/common/FormItem.vue index c57a2df765..4de5ddd4cc 100644 --- a/src/components/common/FormItem.vue +++ b/src/components/common/FormItem.vue @@ -36,6 +36,7 @@ import Select from 'primevue/select' import ToggleSwitch from 'primevue/toggleswitch' import { type Component, markRaw } from 'vue' +import BackgroundImageUpload from '@/components/common/BackgroundImageUpload.vue' import CustomFormValue from '@/components/common/CustomFormValue.vue' import FormColorPicker from '@/components/common/FormColorPicker.vue' import FormImageUpload from '@/components/common/FormImageUpload.vue' @@ -102,6 +103,8 @@ function getFormComponent(item: FormItem): Component { return FormColorPicker case 'url': return UrlInput + case 'backgroundImage': + return BackgroundImageUpload default: return InputText } diff --git a/src/components/sidebar/tabs/QueueSidebarTab.vue b/src/components/sidebar/tabs/QueueSidebarTab.vue index ee921fdac5..b598cd8f92 100644 --- a/src/components/sidebar/tabs/QueueSidebarTab.vue +++ b/src/components/sidebar/tabs/QueueSidebarTab.vue @@ -197,40 +197,46 @@ const confirmRemoveAll = (event: Event) => { const menu = ref | null>(null) const menuTargetTask = ref(null) const menuTargetNode = ref(null) -const menuItems = computed(() => [ - { - label: t('g.delete'), - icon: 'pi pi-trash', - command: () => menuTargetTask.value && removeTask(menuTargetTask.value), - disabled: isExpanded.value || isInFolderView.value - }, - { - label: t('g.loadWorkflow'), - icon: 'pi pi-file-export', - command: () => menuTargetTask.value?.loadWorkflow(app), - disabled: !menuTargetTask.value?.workflow - }, - { - label: t('g.goToNode'), - icon: 'pi pi-arrow-circle-right', - command: () => { - if (!menuTargetNode.value) return - - useLitegraphService().goToNode(menuTargetNode.value.id) +const menuItems = computed(() => { + const items: MenuItem[] = [ + { + label: t('g.delete'), + icon: 'pi pi-trash', + command: () => menuTargetTask.value && removeTask(menuTargetTask.value), + disabled: isExpanded.value || isInFolderView.value }, - visible: !!menuTargetNode.value - }, - { - label: t('g.setAsBackground'), - icon: 'pi pi-image', - command: () => { - const url = menuTargetTask.value?.previewOutput?.url - if (url) { - void settingStore.set('Comfy.Canvas.BackgroundImage', url) - } + { + label: t('g.loadWorkflow'), + icon: 'pi pi-file-export', + command: () => menuTargetTask.value?.loadWorkflow(app), + disabled: !menuTargetTask.value?.workflow + }, + { + label: t('g.goToNode'), + icon: 'pi pi-arrow-circle-right', + command: () => { + if (!menuTargetNode.value) return + useLitegraphService().goToNode(menuTargetNode.value.id) + }, + visible: !!menuTargetNode.value } + ] + + if (menuTargetTask.value?.previewOutput?.mediaType === 'images') { + items.push({ + label: t('g.setAsBackground'), + icon: 'pi pi-image', + command: () => { + const url = menuTargetTask.value?.previewOutput?.url + if (url) { + void settingStore.set('Comfy.Canvas.BackgroundImage', url) + } + } + }) } -]) + + return items +}) const handleContextMenu = ({ task, diff --git a/src/constants/coreSettings.ts b/src/constants/coreSettings.ts index b9ffac64da..ec554f632b 100644 --- a/src/constants/coreSettings.ts +++ b/src/constants/coreSettings.ts @@ -830,15 +830,12 @@ export const CORE_SETTINGS: SettingParams[] = [ id: 'Comfy.Canvas.BackgroundImage', category: ['Appearance', 'Canvas', 'Background'], name: 'Canvas background image', - type: 'text', + type: 'backgroundImage', 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.', + '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.', defaultValue: '', versionAdded: '1.20.4', - attrs: { - inputmode: 'url', - placeholder: 'https://example.com/image.png' - } + versionModified: '1.20.5' }, { id: 'LiteGraph.Pointer.TrackpadGestures', diff --git a/src/locales/en/main.json b/src/locales/en/main.json index d404694d68..e2fca8680b 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -118,7 +118,9 @@ "unknownError": "Unknown error", "title": "Title", "edit": "Edit", - "copy": "Copy" + "copy": "Copy", + "imageUrl": "Image URL", + "clear": "Clear" }, "manager": { "title": "Custom Nodes Manager", diff --git a/src/locales/en/settings.json b/src/locales/en/settings.json index a18f283839..ffe13f2259 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -27,7 +27,7 @@ }, "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." + "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." }, "Comfy_Canvas_SelectionToolbox": { "name": "Show selection toolbox" diff --git a/src/locales/es/main.json b/src/locales/es/main.json index d5e2a43139..71a150aa1a 100644 --- a/src/locales/es/main.json +++ b/src/locales/es/main.json @@ -253,6 +253,7 @@ "capture": "captura", "category": "Categoría", "choose_file_to_upload": "elige archivo para subir", + "clear": "Limpiar", "close": "Cerrar", "color": "Color", "comingSoon": "Próximamente", @@ -293,6 +294,7 @@ "goToNode": "Ir al nodo", "icon": "Icono", "imageFailedToLoad": "Falló la carga de la imagen", + "imageUrl": "URL de la imagen", "import": "Importar", "inProgress": "En progreso", "insert": "Insertar", diff --git a/src/locales/fr/main.json b/src/locales/fr/main.json index c88495664c..4cdc02b95b 100644 --- a/src/locales/fr/main.json +++ b/src/locales/fr/main.json @@ -253,6 +253,7 @@ "capture": "capture", "category": "Catégorie", "choose_file_to_upload": "choisissez le fichier à télécharger", + "clear": "Effacer", "close": "Fermer", "color": "Couleur", "comingSoon": "Bientôt disponible", @@ -293,6 +294,7 @@ "goToNode": "Aller au nœud", "icon": "Icône", "imageFailedToLoad": "Échec du chargement de l'image", + "imageUrl": "URL de l'image", "import": "Importer", "inProgress": "En cours", "insert": "Insérer", diff --git a/src/locales/ja/main.json b/src/locales/ja/main.json index d140b972e7..4824f5eb18 100644 --- a/src/locales/ja/main.json +++ b/src/locales/ja/main.json @@ -253,6 +253,7 @@ "capture": "キャプチャ", "category": "カテゴリ", "choose_file_to_upload": "アップロードするファイルを選択", + "clear": "クリア", "close": "閉じる", "color": "色", "comingSoon": "近日公開", @@ -293,6 +294,7 @@ "goToNode": "ノードに移動", "icon": "アイコン", "imageFailedToLoad": "画像の読み込みに失敗しました", + "imageUrl": "画像URL", "import": "インポート", "inProgress": "進行中", "insert": "挿入", diff --git a/src/locales/ko/main.json b/src/locales/ko/main.json index 58d97798f1..72db448f0d 100644 --- a/src/locales/ko/main.json +++ b/src/locales/ko/main.json @@ -253,6 +253,7 @@ "capture": "캡처", "category": "카테고리", "choose_file_to_upload": "업로드할 파일 선택", + "clear": "지우기", "close": "닫기", "color": "색상", "comingSoon": "곧 출시 예정", @@ -293,6 +294,7 @@ "goToNode": "노드로 이동", "icon": "아이콘", "imageFailedToLoad": "이미지를 로드하지 못했습니다.", + "imageUrl": "이미지 URL", "import": "가져오기", "inProgress": "진행 중", "insert": "삽입", diff --git a/src/locales/ru/main.json b/src/locales/ru/main.json index f9b08d9858..fda4a11a0d 100644 --- a/src/locales/ru/main.json +++ b/src/locales/ru/main.json @@ -253,6 +253,7 @@ "capture": "захват", "category": "Категория", "choose_file_to_upload": "выберите файл для загрузки", + "clear": "Очистить", "close": "Закрыть", "color": "Цвет", "comingSoon": "Скоро будет", @@ -293,6 +294,7 @@ "goToNode": "Перейти к ноде", "icon": "Иконка", "imageFailedToLoad": "Не удалось загрузить изображение", + "imageUrl": "URL изображения", "import": "Импорт", "inProgress": "В процессе", "insert": "Вставить", diff --git a/src/locales/zh/main.json b/src/locales/zh/main.json index ef874afc4b..53504b9bf2 100644 --- a/src/locales/zh/main.json +++ b/src/locales/zh/main.json @@ -253,6 +253,7 @@ "capture": "捕获", "category": "类别", "choose_file_to_upload": "选择要上传的文件", + "clear": "清除", "close": "关闭", "color": "颜色", "comingSoon": "即将推出", @@ -293,6 +294,7 @@ "goToNode": "转到节点", "icon": "图标", "imageFailedToLoad": "图像加载失败", + "imageUrl": "图片网址", "import": "导入", "inProgress": "进行中", "insert": "插入", diff --git a/src/types/settingTypes.ts b/src/types/settingTypes.ts index e1104943ec..4518c30eca 100644 --- a/src/types/settingTypes.ts +++ b/src/types/settingTypes.ts @@ -11,6 +11,7 @@ export type SettingInputType = | 'color' | 'url' | 'hidden' + | 'backgroundImage' export type SettingCustomRenderer = ( name: string,