mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-12 01:09:36 +00:00
Compare commits
6 Commits
fix/hero-a
...
feat/nativ
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8a0a9808a | ||
|
|
6450db97b4 | ||
|
|
02adfd4b83 | ||
|
|
7c2c78b537 | ||
|
|
bd1fd0680e | ||
|
|
9617e498c9 |
@@ -8,6 +8,7 @@ import {
|
||||
useDownloadUrl
|
||||
} from '../../../composables/useDownloadUrl'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import { captureDownloadClick } from '../../../scripts/posthog'
|
||||
import BrandButton from '../../common/BrandButton.vue'
|
||||
|
||||
const { locale = 'en', class: customClass = '' } = defineProps<{
|
||||
@@ -69,6 +70,7 @@ const buttons = computed<ButtonSpec[]>(() => {
|
||||
size="lg"
|
||||
:class="customClass"
|
||||
:aria-label="btn.ariaLabel"
|
||||
@click="captureDownloadClick(btn.key)"
|
||||
>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<img
|
||||
|
||||
@@ -53,3 +53,28 @@ describe('initPostHog', () => {
|
||||
expect(result.$set_once).toHaveProperty('plan', 'free')
|
||||
})
|
||||
})
|
||||
|
||||
describe('captureDownloadClick', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('captures the download event with the platform', async () => {
|
||||
const { initPostHog, captureDownloadClick } = await import('./posthog')
|
||||
initPostHog()
|
||||
captureDownloadClick('mac')
|
||||
|
||||
expect(hoisted.mockCapture).toHaveBeenCalledWith(
|
||||
'website:download_button_clicked',
|
||||
{ platform: 'mac' }
|
||||
)
|
||||
})
|
||||
|
||||
it('does not capture before PostHog is initialized', async () => {
|
||||
const { captureDownloadClick } = await import('./posthog')
|
||||
captureDownloadClick('windows')
|
||||
|
||||
expect(hoisted.mockCapture).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -38,3 +38,12 @@ export function capturePageview() {
|
||||
console.error('PostHog pageview capture failed', error)
|
||||
}
|
||||
}
|
||||
|
||||
export function captureDownloadClick(platform: string) {
|
||||
if (!initialized) return
|
||||
try {
|
||||
posthog.capture('website:download_button_clicked', { platform })
|
||||
} catch (error) {
|
||||
console.error('PostHog download click capture failed', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -17,245 +18,87 @@ test.describe('Background Image Upload', () => {
|
||||
await comfyPage.settings.setSetting('Comfy.Canvas.BackgroundImage', '')
|
||||
})
|
||||
|
||||
const openBackgroundImageSetting = async (comfyPage: ComfyPage) => {
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
await comfyPage.page.locator('text=Appearance').click()
|
||||
return comfyPage.page.locator('#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'
|
||||
)
|
||||
const backgroundImageSetting = await openBackgroundImageSetting(comfyPage)
|
||||
await expect(backgroundImageSetting).toBeVisible()
|
||||
|
||||
// Verify the component has the expected elements using semantic selectors
|
||||
const urlInput = backgroundImageSetting.getByRole('textbox')
|
||||
await expect(urlInput).toBeVisible()
|
||||
await expect(urlInput).toHaveAttribute('placeholder')
|
||||
|
||||
const uploadButton = backgroundImageSetting.getByRole('button', {
|
||||
name: /upload/i
|
||||
})
|
||||
await expect(uploadButton).toBeVisible()
|
||||
|
||||
const clearButton = backgroundImageSetting.getByRole('button', {
|
||||
name: /clear/i
|
||||
})
|
||||
await expect(clearButton).toBeVisible()
|
||||
await expect(clearButton).toBeDisabled() // Should be disabled when no image
|
||||
// With no image set: placeholder shown, no remove button
|
||||
await expect(backgroundImageSetting.getByText('Choose image')).toBeVisible()
|
||||
await expect(
|
||||
backgroundImageSetting.getByRole('button', { name: 'Remove image' })
|
||||
).toBeHidden()
|
||||
})
|
||||
|
||||
test('should upload image file and set as background', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Open settings dialog
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const backgroundImageSetting = await openBackgroundImageSetting(comfyPage)
|
||||
|
||||
// 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.getByRole('button', {
|
||||
name: /upload/i
|
||||
})
|
||||
|
||||
// Set up file upload handler
|
||||
// Clicking the row opens the system file browser
|
||||
const fileChooserPromise = comfyPage.page.waitForEvent('filechooser')
|
||||
await uploadButton.click()
|
||||
await backgroundImageSetting
|
||||
.getByRole('button', { name: 'Choose image' })
|
||||
.click()
|
||||
const fileChooser = await fileChooserPromise
|
||||
|
||||
// Upload the test image
|
||||
await fileChooser.setFiles(comfyPage.assetPath('image32x32.webp'))
|
||||
|
||||
// Verify the URL input now has an API URL
|
||||
const urlInput = backgroundImageSetting.getByRole('textbox')
|
||||
await expect(urlInput).toHaveValue(/^\/api\/view\?.*subfolder=backgrounds/)
|
||||
// The row shows the uploaded file's base name and a remove button
|
||||
await expect(
|
||||
backgroundImageSetting.getByText('image32x32.webp')
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
backgroundImageSetting.getByRole('button', { name: 'Remove image' })
|
||||
).toBeVisible()
|
||||
|
||||
// Verify clear button is now enabled
|
||||
const clearButton = backgroundImageSetting.getByRole('button', {
|
||||
name: /clear/i
|
||||
})
|
||||
await expect(clearButton).toBeEnabled()
|
||||
|
||||
// Verify the setting value was actually set
|
||||
// The setting value points at the uploaded file
|
||||
const settingValue = await comfyPage.settings.getSetting(
|
||||
'Comfy.Canvas.BackgroundImage'
|
||||
)
|
||||
expect(settingValue).toMatch(/^\/api\/view\?.*subfolder=backgrounds/)
|
||||
})
|
||||
|
||||
test('should accept URL input for background image', async ({
|
||||
test('should show the base name of an existing 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.getByRole('textbox')
|
||||
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.getByRole('button', {
|
||||
name: /clear/i
|
||||
})
|
||||
await expect(clearButton).toBeEnabled()
|
||||
|
||||
// Verify the setting value was updated
|
||||
const settingValue = await comfyPage.settings.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.settings.setSetting(
|
||||
'Comfy.Canvas.BackgroundImage',
|
||||
testImageUrl
|
||||
'/api/view?filename=backgrounds%2Ftest-image.png&type=input&subfolder=backgrounds'
|
||||
)
|
||||
|
||||
// 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.getByRole('textbox')
|
||||
await expect(urlInput).toHaveValue(testImageUrl)
|
||||
|
||||
// Verify clear button is enabled
|
||||
const clearButton = backgroundImageSetting.getByRole('button', {
|
||||
name: /clear/i
|
||||
})
|
||||
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.settings.getSetting(
|
||||
'Comfy.Canvas.BackgroundImage'
|
||||
)
|
||||
expect(settingValue).toBe('')
|
||||
const backgroundImageSetting = await openBackgroundImageSetting(comfyPage)
|
||||
await expect(
|
||||
backgroundImageSetting.getByText('test-image.png')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show tooltip on upload and clear buttons', async ({
|
||||
test('should clear background image with the remove button', 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 comfyPage.settings.setSetting(
|
||||
'Comfy.Canvas.BackgroundImage',
|
||||
'/api/view?filename=test-image.png&type=input'
|
||||
)
|
||||
// Hover over upload button and verify tooltip appears
|
||||
const uploadButton = backgroundImageSetting.getByRole('button', {
|
||||
name: /upload/i
|
||||
})
|
||||
await uploadButton.hover()
|
||||
|
||||
const uploadTooltip = comfyPage.page.locator('.p-tooltip:visible')
|
||||
await expect(uploadTooltip).toBeVisible()
|
||||
const backgroundImageSetting = await openBackgroundImageSetting(comfyPage)
|
||||
await backgroundImageSetting
|
||||
.getByRole('button', { name: 'Remove image' })
|
||||
.click()
|
||||
|
||||
// Move away to hide tooltip
|
||||
await comfyPage.page.locator('body').hover()
|
||||
|
||||
// Set a background to enable clear button
|
||||
const urlInput = backgroundImageSetting.getByRole('textbox')
|
||||
await urlInput.fill('https://example.com/test.png')
|
||||
await urlInput.blur()
|
||||
|
||||
// Hover over clear button and verify tooltip appears
|
||||
const clearButton = backgroundImageSetting.getByRole('button', {
|
||||
name: /clear/i
|
||||
})
|
||||
await clearButton.hover()
|
||||
|
||||
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.getByRole('textbox')
|
||||
const clearButton = backgroundImageSetting.getByRole('button', {
|
||||
name: /clear/i
|
||||
})
|
||||
|
||||
// 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()
|
||||
// Placeholder returns, remove button disappears, setting cleared
|
||||
await expect(backgroundImageSetting.getByText('Choose image')).toBeVisible()
|
||||
await expect(
|
||||
backgroundImageSetting.getByRole('button', { name: 'Remove image' })
|
||||
).toBeHidden()
|
||||
await expect
|
||||
.poll(() => comfyPage.settings.getSetting('Comfy.Canvas.BackgroundImage'))
|
||||
.toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -292,6 +292,10 @@ test.describe('Node library sidebar', () => {
|
||||
const dialog = comfyPage.page.getByRole('dialog', {
|
||||
name: 'Customize Folder'
|
||||
})
|
||||
// Capture the dialog header position before opening the modal color
|
||||
// picker: while the picker is open it sets aria-hidden on the dialog,
|
||||
// so it can no longer be located by role.
|
||||
const dialogBox = await dialog.boundingBox()
|
||||
await dialog
|
||||
.locator('.color-customization-selector-container > button')
|
||||
.last()
|
||||
@@ -300,6 +304,17 @@ test.describe('Node library sidebar', () => {
|
||||
.getByLabel('Color saturation and brightness')
|
||||
.click({ position: { x: 10, y: 10 } })
|
||||
|
||||
// The color picker popover is modal: while it is open the rest of the
|
||||
// dialog is inert (pointer-events disabled), so dismiss it before
|
||||
// interacting with other controls. A coordinate click on the dialog
|
||||
// header lands on the dismiss layer and closes the popover.
|
||||
if (dialogBox) {
|
||||
await comfyPage.page.mouse.click(dialogBox.x + 40, dialogBox.y + 16)
|
||||
}
|
||||
await expect(
|
||||
comfyPage.page.getByLabel('Color saturation and brightness')
|
||||
).toBeHidden()
|
||||
|
||||
// Select Folder icon (2nd button in Icon group)
|
||||
const iconGroup = dialog.getByText('Icon').locator('..').getByRole('group')
|
||||
await iconGroup.getByRole('button').nth(1).click()
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
},
|
||||
"litegraph_base": {
|
||||
"BACKGROUND_IMAGE": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAQBJREFUeNrs1rEKwjAUhlETUkj3vP9rdmr1Ysammk2w5wdxuLgcMHyptfawuZX4pJSWZTnfnu/lnIe/jNNxHHGNn//HNbbv+4dr6V+11uF527arU7+u63qfa/bnmh8sWLBgwYJlqRf8MEptXPBXJXa37BSl3ixYsGDBMliwFLyCV/DeLIMFCxYsWLBMwSt4Be/NggXLYMGCBUvBK3iNruC9WbBgwYJlsGApeAWv4L1ZBgsWLFiwYJmCV/AK3psFC5bBggULloJX8BpdwXuzYMGCBctgwVLwCl7Be7MMFixYsGDBsu8FH1FaSmExVfAxBa/gvVmwYMGCZbBg/W4vAQYA5tRF9QYlv/QAAAAASUVORK5CYII=",
|
||||
"CLEAR_BACKGROUND_COLOR": "#141414",
|
||||
"CLEAR_BACKGROUND_COLOR": "#222222",
|
||||
"NODE_TITLE_COLOR": "#999",
|
||||
"NODE_SELECTED_TITLE_COLOR": "#FFF",
|
||||
"NODE_TEXT_COLOR": "#AAA",
|
||||
|
||||
@@ -1,58 +1,23 @@
|
||||
<template>
|
||||
<div class="flex gap-2">
|
||||
<InputText
|
||||
v-model="modelValue"
|
||||
class="flex-1"
|
||||
:placeholder="$t('g.imageUrl')"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip="$t('g.upload')"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:aria-label="$t('g.upload')"
|
||||
:disabled="isUploading"
|
||||
@click="triggerFileInput"
|
||||
>
|
||||
<i :class="isUploading ? 'pi pi-spin pi-spinner' : 'pi pi-upload'" />
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip="$t('g.clear')"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
:aria-label="$t('g.clear')"
|
||||
:disabled="!modelValue"
|
||||
@click="clearImage"
|
||||
>
|
||||
<i class="pi pi-trash" />
|
||||
</Button>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
class="hidden"
|
||||
accept="image/*"
|
||||
@change="handleFileUpload"
|
||||
/>
|
||||
</div>
|
||||
<ImageUpload
|
||||
v-model="modelValue"
|
||||
:loading="isUploading"
|
||||
@file-selected="handleFileUpload"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import ImageUpload from '@/components/ui/image-upload/ImageUpload.vue'
|
||||
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
const modelValue = defineModel<string>()
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const isUploading = ref(false)
|
||||
|
||||
const triggerFileInput = () => {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
const uploadFile = async (file: File): Promise<string | null> => {
|
||||
const body = new FormData()
|
||||
body.append('image', file)
|
||||
@@ -74,36 +39,24 @@ const uploadFile = async (file: File): Promise<string | null> => {
|
||||
return data.subfolder ? `${data.subfolder}/${data.name}` : data.name
|
||||
}
|
||||
|
||||
const handleFileUpload = async (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
if (target.files && target.files[0]) {
|
||||
const file = target.files[0]
|
||||
|
||||
isUploading.value = true
|
||||
try {
|
||||
const uploadedPath = await uploadFile(file)
|
||||
if (uploadedPath) {
|
||||
// Set the value to the API view URL with subfolder parameter
|
||||
const params = new URLSearchParams({
|
||||
filename: uploadedPath,
|
||||
type: 'input',
|
||||
subfolder: 'backgrounds'
|
||||
})
|
||||
appendCloudResParam(params, file.name)
|
||||
modelValue.value = `/api/view?${params.toString()}`
|
||||
}
|
||||
} catch (error) {
|
||||
useToastStore().addAlert(`Upload error: ${String(error)}`)
|
||||
} finally {
|
||||
isUploading.value = false
|
||||
const handleFileUpload = async (file: File) => {
|
||||
isUploading.value = true
|
||||
try {
|
||||
const uploadedPath = await uploadFile(file)
|
||||
if (uploadedPath) {
|
||||
// Set the value to the API view URL with subfolder parameter
|
||||
const params = new URLSearchParams({
|
||||
filename: uploadedPath,
|
||||
type: 'input',
|
||||
subfolder: 'backgrounds'
|
||||
})
|
||||
appendCloudResParam(params, file.name)
|
||||
modelValue.value = `/api/view?${params.toString()}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const clearImage = () => {
|
||||
modelValue.value = ''
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = ''
|
||||
} catch (error) {
|
||||
useToastStore().addAlert(`Upload error: ${String(error)}`)
|
||||
} finally {
|
||||
isUploading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -376,13 +376,17 @@ watch(
|
||||
)
|
||||
|
||||
watch(
|
||||
() => settingStore.get('Comfy.Canvas.BackgroundImage'),
|
||||
[
|
||||
() => settingStore.get('Comfy.Canvas.BackgroundImage'),
|
||||
() => settingStore.get('Comfy.Canvas.BackgroundPattern'),
|
||||
() => settingStore.get('Comfy.Canvas.BackgroundColor')
|
||||
],
|
||||
async () => {
|
||||
if (!canvasStore.canvas) return
|
||||
const currentPaletteId = colorPaletteStore.activePaletteId
|
||||
if (!currentPaletteId) return
|
||||
|
||||
// Reload color palette to apply background image
|
||||
// Reload color palette to apply background image/pattern/color
|
||||
await colorPaletteService.loadColorPalette(currentPaletteId)
|
||||
// Mark background canvas as dirty
|
||||
canvasStore.canvas.setDirty(false, true)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import InputNumber from 'primevue/inputnumber'
|
||||
import Select from 'primevue/select'
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import BackgroundImageUpload from '@/components/common/BackgroundImageUpload.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import ColorPicker from '@/components/ui/color-picker/ColorPicker.vue'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LinkRenderType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
@@ -12,6 +14,9 @@ import { LinkMarkerShape } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { WidgetInputBaseClass } from '@/renderer/extensions/vueNodes/widgets/components/layout'
|
||||
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import type { CanvasBackgroundPattern } from '@/utils/canvasPatternUtil'
|
||||
import { getEffectiveCanvasBackgroundColor } from '@/utils/canvasPatternUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
||||
@@ -40,6 +45,77 @@ const nodes2Enabled = computed({
|
||||
})
|
||||
|
||||
// CANVAS settings
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
|
||||
type CanvasBackgroundMode = CanvasBackgroundPattern | 'image'
|
||||
|
||||
// Keeps the Image option selected while no image is set, e.g. before the
|
||||
// first upload or right after removing the current one.
|
||||
const imageModeSelected = ref(false)
|
||||
|
||||
const backgroundImage = computed({
|
||||
get: () => settingStore.get('Comfy.Canvas.BackgroundImage') ?? '',
|
||||
set: (value) => {
|
||||
if (!value) imageModeSelected.value = true
|
||||
settingStore.set('Comfy.Canvas.BackgroundImage', value)
|
||||
}
|
||||
})
|
||||
|
||||
const isBackgroundImageSet = computed(() => !!backgroundImage.value)
|
||||
|
||||
const backgroundMode = computed<CanvasBackgroundMode>({
|
||||
get: () =>
|
||||
isBackgroundImageSet.value || imageModeSelected.value
|
||||
? 'image'
|
||||
: settingStore.get('Comfy.Canvas.BackgroundPattern'),
|
||||
set: (value) => {
|
||||
if (value === 'image') {
|
||||
imageModeSelected.value = true
|
||||
return
|
||||
}
|
||||
imageModeSelected.value = false
|
||||
if (isBackgroundImageSet.value) {
|
||||
settingStore.set('Comfy.Canvas.BackgroundImage', '')
|
||||
}
|
||||
settingStore.set('Comfy.Canvas.BackgroundPattern', value)
|
||||
}
|
||||
})
|
||||
|
||||
const backgroundOptions = computed(() => [
|
||||
{
|
||||
value: 'dots',
|
||||
label: t('settings.Comfy_Canvas_BackgroundPattern.options.Dots')
|
||||
},
|
||||
{
|
||||
value: 'grid',
|
||||
label: t('settings.Comfy_Canvas_BackgroundPattern.options.Grid')
|
||||
},
|
||||
{ value: 'none', label: t('g.none') },
|
||||
{ value: 'image', label: t('rightSidePanel.globalSettings.image') }
|
||||
])
|
||||
|
||||
const hasCustomBackgroundColor = computed(
|
||||
() => settingStore.get('Comfy.Canvas.BackgroundColor') !== ''
|
||||
)
|
||||
|
||||
const backgroundColor = computed({
|
||||
get: () =>
|
||||
getEffectiveCanvasBackgroundColor(
|
||||
settingStore.get('Comfy.Canvas.BackgroundColor'),
|
||||
colorPaletteStore.completedActivePalette.colors.litegraph_base
|
||||
.CLEAR_BACKGROUND_COLOR
|
||||
),
|
||||
set: (value) =>
|
||||
settingStore.set(
|
||||
'Comfy.Canvas.BackgroundColor',
|
||||
value.replace(/^#/, '').slice(0, 6)
|
||||
)
|
||||
})
|
||||
|
||||
async function resetBackgroundColor() {
|
||||
await settingStore.set('Comfy.Canvas.BackgroundColor', '')
|
||||
}
|
||||
|
||||
const gridSpacing = computed({
|
||||
get: () => settingStore.get('Comfy.SnapToGrid.GridSize'),
|
||||
set: (value) => settingStore.set('Comfy.SnapToGrid.GridSize', value)
|
||||
@@ -128,6 +204,55 @@ function openFullSettings() {
|
||||
{{ t('rightSidePanel.globalSettings.canvas') }}
|
||||
</template>
|
||||
<div class="space-y-4 px-4 py-3">
|
||||
<LayoutField
|
||||
:label="t('rightSidePanel.globalSettings.background')"
|
||||
:tooltip="t('settings.Comfy_Canvas_BackgroundPattern.tooltip')"
|
||||
>
|
||||
<Select
|
||||
v-model="backgroundMode"
|
||||
:options="backgroundOptions"
|
||||
:aria-label="t('rightSidePanel.globalSettings.background')"
|
||||
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"
|
||||
size="small"
|
||||
:pt="{
|
||||
option: 'text-xs',
|
||||
dropdown: 'w-8',
|
||||
label: cn('min-w-[4ch] truncate', $slots.default && 'mr-5'),
|
||||
overlay: 'w-fit min-w-full'
|
||||
}"
|
||||
data-capture-wheel="true"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
/>
|
||||
</LayoutField>
|
||||
<BackgroundImageUpload
|
||||
v-if="backgroundMode === 'image'"
|
||||
v-model="backgroundImage"
|
||||
/>
|
||||
<LayoutField
|
||||
v-else
|
||||
:label="t('rightSidePanel.globalSettings.backgroundColor')"
|
||||
:tooltip="t('settings.Comfy_Canvas_BackgroundColor.tooltip')"
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<ColorPicker
|
||||
v-model="backgroundColor"
|
||||
class="min-w-0 grow"
|
||||
:aria-label="t('rightSidePanel.globalSettings.backgroundColor')"
|
||||
/>
|
||||
<Button
|
||||
v-if="hasCustomBackgroundColor"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
:aria-label="
|
||||
t('rightSidePanel.globalSettings.resetBackgroundColor')
|
||||
"
|
||||
@click="resetBackgroundColor"
|
||||
>
|
||||
<i class="icon-[lucide--rotate-ccw] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</LayoutField>
|
||||
<LayoutField :label="t('rightSidePanel.globalSettings.gridSpacing')">
|
||||
<div
|
||||
:class="
|
||||
|
||||
34
src/components/ui/color-picker/ColorPicker.test.ts
Normal file
34
src/components/ui/color-picker/ColorPicker.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import ColorPicker from './ColorPicker.vue'
|
||||
|
||||
describe('ColorPicker', () => {
|
||||
it('does not echo a write back when the model is changed externally', async () => {
|
||||
const onUpdate = vi.fn()
|
||||
const { rerender } = render(ColorPicker, {
|
||||
props: { modelValue: '#823182', 'onUpdate:modelValue': onUpdate }
|
||||
})
|
||||
|
||||
await rerender({ modelValue: '' })
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(onUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the latest external color without writing back', async () => {
|
||||
const onUpdate = vi.fn()
|
||||
const { rerender } = render(ColorPicker, {
|
||||
props: { modelValue: '#823182', 'onUpdate:modelValue': onUpdate }
|
||||
})
|
||||
|
||||
await rerender({ modelValue: '#222222' })
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(onUpdate).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('#222222')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
PopoverRoot,
|
||||
PopoverTrigger
|
||||
} from 'reka-ui'
|
||||
import { ZIndex } from '@primeuix/utils/zindex'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import type { HSVA } from '@/utils/colorUtil'
|
||||
@@ -23,9 +24,15 @@ const modelValue = defineModel<string>({ default: '#000000' })
|
||||
const hsva = ref<HSVA>(hexToHsva(modelValue.value || '#000000'))
|
||||
const displayMode = ref<'hex' | 'rgba'>('hex')
|
||||
|
||||
// Guard against echoing external model changes back: hex -> hsva -> hex is
|
||||
// not an identity (rounding), so without the flag an outside write (e.g.
|
||||
// resetting a setting to '') would immediately be overwritten.
|
||||
let syncingFromModel = false
|
||||
|
||||
watch(modelValue, (newVal) => {
|
||||
const current = hsvaToHex(hsva.value)
|
||||
if (newVal !== current) {
|
||||
syncingFromModel = true
|
||||
hsva.value = hexToHsva(newVal || '#000000')
|
||||
}
|
||||
})
|
||||
@@ -33,6 +40,10 @@ watch(modelValue, (newVal) => {
|
||||
watch(
|
||||
hsva,
|
||||
(newHsva) => {
|
||||
if (syncingFromModel) {
|
||||
syncingFromModel = false
|
||||
return
|
||||
}
|
||||
const hex = hsvaToHex(newHsva)
|
||||
if (hex !== modelValue.value) {
|
||||
modelValue.value = hex
|
||||
@@ -60,10 +71,29 @@ const previewColor = computed(() => {
|
||||
const displayHex = computed(() => rgbToHex(baseRgb.value).toLowerCase())
|
||||
|
||||
const isOpen = ref(false)
|
||||
|
||||
// The popover portals to body, so a static z-index can lose to dialogs that
|
||||
// take theirs from the shared PrimeVue ZIndex counter (see vRekaZIndex.ts).
|
||||
// Reka copies the content's z-index onto its popper wrapper, so compute the
|
||||
// content z-index from the same counter on each open to stack above
|
||||
// whichever dialog opened the picker.
|
||||
const BASE_POPOVER_Z_INDEX = 1700
|
||||
const popoverZIndex = ref(BASE_POPOVER_Z_INDEX)
|
||||
|
||||
watch(isOpen, (open) => {
|
||||
if (open) {
|
||||
popoverZIndex.value = Math.max(
|
||||
BASE_POPOVER_Z_INDEX,
|
||||
ZIndex.getCurrent('modal') + 1
|
||||
)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverRoot v-model:open="isOpen">
|
||||
<!-- Modal so the click that dismisses the popover cannot fall through to
|
||||
the canvas and start a drag-select marquee. -->
|
||||
<PopoverRoot v-model:open="isOpen" modal>
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
@@ -115,7 +145,7 @@ const isOpen = ref(false)
|
||||
align="start"
|
||||
:side-offset="7"
|
||||
:collision-padding="10"
|
||||
class="z-1700"
|
||||
:style="{ zIndex: popoverZIndex }"
|
||||
>
|
||||
<ColorPickerPanel
|
||||
v-model:hsva="hsva"
|
||||
|
||||
68
src/components/ui/image-upload/ImageUpload.stories.ts
Normal file
68
src/components/ui/image-upload/ImageUpload.stories.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type {
|
||||
ComponentPropsAndSlots,
|
||||
Meta,
|
||||
StoryObj
|
||||
} from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import ImageUpload from './ImageUpload.vue'
|
||||
|
||||
const meta: Meta<ComponentPropsAndSlots<typeof ImageUpload>> = {
|
||||
title: 'Components/ImageUpload',
|
||||
component: ImageUpload,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'padded' },
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template: '<div class="w-60"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => ({
|
||||
components: { ImageUpload },
|
||||
setup() {
|
||||
const url = ref('')
|
||||
return { url }
|
||||
},
|
||||
template: '<ImageUpload v-model="url" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const WithImage: Story = {
|
||||
render: () => ({
|
||||
components: { ImageUpload },
|
||||
setup() {
|
||||
const url = ref('/api/view?filename=mountain+lake.png&type=input')
|
||||
return { url }
|
||||
},
|
||||
template: '<ImageUpload v-model="url" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const Loading: Story = {
|
||||
render: () => ({
|
||||
components: { ImageUpload },
|
||||
setup() {
|
||||
const url = ref('')
|
||||
return { url }
|
||||
},
|
||||
template: '<ImageUpload v-model="url" loading />'
|
||||
})
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: () => ({
|
||||
components: { ImageUpload },
|
||||
setup() {
|
||||
const url = ref('')
|
||||
return { url }
|
||||
},
|
||||
template: '<ImageUpload v-model="url" disabled />'
|
||||
})
|
||||
}
|
||||
72
src/components/ui/image-upload/ImageUpload.test.ts
Normal file
72
src/components/ui/image-upload/ImageUpload.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
|
||||
import ImageUpload from './ImageUpload.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
function renderImageUpload(props: Record<string, unknown> = {}) {
|
||||
return render(ImageUpload, {
|
||||
props,
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
}
|
||||
|
||||
describe('ImageUpload', () => {
|
||||
it('shows a placeholder when no image is set', () => {
|
||||
renderImageUpload({ modelValue: '' })
|
||||
expect(screen.getByText('Choose image')).toBeTruthy()
|
||||
expect(screen.queryByLabelText('Remove image')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the image base name extracted from the URL', () => {
|
||||
renderImageUpload({
|
||||
modelValue:
|
||||
'/api/view?filename=backgrounds%2Fmountain+lake.png&type=input'
|
||||
})
|
||||
expect(screen.getByText('mountain lake.png')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('opens the file browser when the row is clicked', async () => {
|
||||
const user = userEvent.setup({ applyAccept: false })
|
||||
renderImageUpload({ modelValue: '' })
|
||||
const fileInput = screen.getByTestId<HTMLInputElement>('image-upload-input')
|
||||
const clickSpy = vi.spyOn(fileInput, 'click')
|
||||
|
||||
await user.click(screen.getByText('Choose image'))
|
||||
|
||||
expect(clickSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('emits fileSelected when a file is picked', async () => {
|
||||
const user = userEvent.setup({ applyAccept: false })
|
||||
const { emitted } = renderImageUpload({ modelValue: '' })
|
||||
const file = new File(['x'], 'photo.png', { type: 'image/png' })
|
||||
|
||||
await user.upload(
|
||||
screen.getByTestId<HTMLInputElement>('image-upload-input'),
|
||||
file
|
||||
)
|
||||
|
||||
expect(emitted('fileSelected')).toEqual([[file]])
|
||||
})
|
||||
|
||||
it('clears the model when the remove button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { emitted } = renderImageUpload({
|
||||
modelValue: '/api/view?filename=bg.png'
|
||||
})
|
||||
|
||||
await user.click(screen.getByLabelText('Remove image'))
|
||||
|
||||
expect(emitted('update:modelValue')).toEqual([['']])
|
||||
})
|
||||
})
|
||||
122
src/components/ui/image-upload/ImageUpload.vue
Normal file
122
src/components/ui/image-upload/ImageUpload.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const {
|
||||
class: className,
|
||||
disabled = false,
|
||||
loading = false
|
||||
} = defineProps<{
|
||||
class?: string
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string>({ default: '' })
|
||||
|
||||
const emit = defineEmits<{
|
||||
fileSelected: [file: File]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const previewFailed = ref(false)
|
||||
watch(modelValue, () => {
|
||||
previewFailed.value = false
|
||||
})
|
||||
|
||||
const imageBaseName = computed(() => {
|
||||
if (!modelValue.value) return ''
|
||||
try {
|
||||
const url = new URL(modelValue.value, window.location.origin)
|
||||
const filename =
|
||||
url.searchParams.get('filename') ?? url.pathname.split('/').pop() ?? ''
|
||||
return filename.split('/').pop() ?? ''
|
||||
} catch {
|
||||
return modelValue.value
|
||||
}
|
||||
})
|
||||
|
||||
function openFileBrowser() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
function handleFileChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (file) emit('fileSelected', file)
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
function clearImage() {
|
||||
modelValue.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex h-8 w-full items-center overflow-clip rounded-lg bg-component-node-widget-background hover:bg-component-node-widget-background-hovered',
|
||||
(disabled || loading) && 'cursor-not-allowed opacity-50',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="disabled || loading"
|
||||
class="flex h-full min-w-0 flex-1 cursor-pointer items-center border-none bg-transparent p-0 outline-none disabled:cursor-not-allowed"
|
||||
@click="openFileBrowser"
|
||||
>
|
||||
<span class="flex size-8 shrink-0 items-center justify-center">
|
||||
<i
|
||||
v-if="loading"
|
||||
class="icon-[lucide--loader-circle] size-4 animate-spin text-muted-foreground"
|
||||
/>
|
||||
<img
|
||||
v-else-if="modelValue && !previewFailed"
|
||||
:src="modelValue"
|
||||
alt=""
|
||||
class="size-5 rounded-sm object-cover"
|
||||
@error="previewFailed = true"
|
||||
/>
|
||||
<i v-else class="icon-[lucide--image] size-4 text-muted-foreground" />
|
||||
</span>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'min-w-0 flex-1 truncate text-left text-xs',
|
||||
imageBaseName
|
||||
? 'text-component-node-foreground'
|
||||
: 'text-muted-foreground'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ imageBaseName || t('g.chooseImage') }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="modelValue && !loading"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
:aria-label="t('g.removeImage')"
|
||||
class="flex size-8 shrink-0 cursor-pointer items-center justify-center border-none bg-transparent text-component-node-foreground outline-none disabled:cursor-not-allowed"
|
||||
@click="clearImage"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</button>
|
||||
<input
|
||||
ref="fileInput"
|
||||
data-testid="image-upload-input"
|
||||
type="file"
|
||||
class="hidden"
|
||||
accept="image/*"
|
||||
@change="handleFileChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -204,6 +204,7 @@
|
||||
"control_after_generate": "control after generate",
|
||||
"control_before_generate": "control before generate",
|
||||
"choose_file_to_upload": "choose file to upload",
|
||||
"chooseImage": "Choose image",
|
||||
"uploadAlreadyInProgress": "Upload already in progress",
|
||||
"uploadTimedOut": "Upload timed out. Please try again.",
|
||||
"capture": "capture",
|
||||
@@ -3596,6 +3597,10 @@
|
||||
"showInfoBadges": "Show info badges",
|
||||
"showToolbox": "Show toolbox on selection",
|
||||
"nodes2": "Nodes 2.0",
|
||||
"background": "Background",
|
||||
"image": "Image",
|
||||
"backgroundColor": "Background color",
|
||||
"resetBackgroundColor": "Reset background color to theme default",
|
||||
"gridSpacing": "Grid spacing",
|
||||
"snapNodesToGrid": "Snap nodes to grid",
|
||||
"linkShape": "Link shape",
|
||||
|
||||
@@ -29,10 +29,23 @@
|
||||
"name": "Disable animations",
|
||||
"tooltip": "Turns off most CSS animations and transitions. Speeds up inference when the display GPU is also used for generation."
|
||||
},
|
||||
"Comfy_Canvas_BackgroundColor": {
|
||||
"name": "Canvas background color",
|
||||
"tooltip": "Custom canvas background color. Leave empty to follow the active theme."
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"Comfy_Canvas_BackgroundPattern": {
|
||||
"name": "Canvas background pattern",
|
||||
"tooltip": "Pattern drawn on the canvas background. Not shown while a custom background image is set.",
|
||||
"options": {
|
||||
"Dots": "Dots",
|
||||
"Grid": "Grid",
|
||||
"None": "None"
|
||||
}
|
||||
},
|
||||
"Comfy_Canvas_LeftMouseClickBehavior": {
|
||||
"name": "Left Mouse Click Behavior",
|
||||
"options": {
|
||||
|
||||
@@ -22,7 +22,7 @@ const zAsset = z.object({
|
||||
})
|
||||
|
||||
const zAssetResponse = zListAssetsResponse
|
||||
.pick({ total: true, has_more: true })
|
||||
.pick({ total: true, has_more: true, next_cursor: true })
|
||||
.extend({
|
||||
assets: z.array(zAsset)
|
||||
})
|
||||
|
||||
@@ -53,6 +53,7 @@ const fetchApiMock = vi.mocked(api.fetchApi)
|
||||
type AssetListResponseOptions = {
|
||||
hasMore?: AssetResponse['has_more']
|
||||
total?: AssetResponse['total']
|
||||
nextCursor?: AssetResponse['next_cursor']
|
||||
}
|
||||
|
||||
function buildResponse(
|
||||
@@ -68,9 +69,18 @@ function buildResponse(
|
||||
|
||||
function buildAssetListResponse(
|
||||
assets: AssetItem[],
|
||||
{ hasMore = false, total = assets.length }: AssetListResponseOptions = {}
|
||||
{
|
||||
hasMore = false,
|
||||
total = assets.length,
|
||||
nextCursor
|
||||
}: AssetListResponseOptions = {}
|
||||
): Response {
|
||||
return buildResponse({ assets, total, has_more: hasMore })
|
||||
return buildResponse({
|
||||
assets,
|
||||
total,
|
||||
has_more: hasMore,
|
||||
...(nextCursor === undefined ? {} : { next_cursor: nextCursor })
|
||||
})
|
||||
}
|
||||
|
||||
function validAsset(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||
@@ -512,7 +522,7 @@ describe(assetService.getAllAssetsByTag, () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('paginates tagged asset requests with include_public=true', async () => {
|
||||
it('walks pages by keyset cursor with include_public=true', async () => {
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(
|
||||
buildAssetListResponse(
|
||||
@@ -520,7 +530,7 @@ describe(assetService.getAllAssetsByTag, () => {
|
||||
validAsset({ id: 'a', tags: ['input'] }),
|
||||
validAsset({ id: 'b', tags: ['input'] })
|
||||
],
|
||||
{ hasMore: true }
|
||||
{ hasMore: true, nextCursor: 'cursor-page-2' }
|
||||
)
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
@@ -538,6 +548,8 @@ describe(assetService.getAllAssetsByTag, () => {
|
||||
expect(firstParams.get('include_public')).toBe('true')
|
||||
expect(firstParams.get('exclude_tags')).toBe(MISSING_TAG)
|
||||
expect(firstParams.get('limit')).toBe('2')
|
||||
// First page carries neither a cursor nor an offset.
|
||||
expect(firstParams.has('after')).toBe(false)
|
||||
expect(firstParams.has('offset')).toBe(false)
|
||||
|
||||
const secondUrl = fetchApiMock.mock.calls[1]?.[0] as string
|
||||
@@ -545,7 +557,9 @@ describe(assetService.getAllAssetsByTag, () => {
|
||||
expect(secondParams.get('include_public')).toBe('true')
|
||||
expect(secondParams.get('exclude_tags')).toBe(MISSING_TAG)
|
||||
expect(secondParams.get('limit')).toBe('2')
|
||||
expect(secondParams.get('offset')).toBe('2')
|
||||
// Subsequent pages resume from the prior response's next_cursor, never offset.
|
||||
expect(secondParams.get('after')).toBe('cursor-page-2')
|
||||
expect(secondParams.has('offset')).toBe(false)
|
||||
})
|
||||
|
||||
it('honors has_more when walking tagged asset pages', async () => {
|
||||
@@ -556,7 +570,7 @@ describe(assetService.getAllAssetsByTag, () => {
|
||||
validAsset({ id: 'first', tags: ['input'] }),
|
||||
validAsset({ id: 'second', tags: ['input'] })
|
||||
],
|
||||
{ hasMore: true }
|
||||
{ hasMore: true, nextCursor: 'cursor-next' }
|
||||
)
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
@@ -577,7 +591,45 @@ describe(assetService.getAllAssetsByTag, () => {
|
||||
throw new Error('Expected a second asset request URL')
|
||||
}
|
||||
const secondParams = new URL(secondUrl, 'http://localhost').searchParams
|
||||
expect(secondParams.get('offset')).toBe('2')
|
||||
expect(secondParams.get('after')).toBe('cursor-next')
|
||||
})
|
||||
|
||||
it('stops walking when next_cursor is absent even if has_more is true', async () => {
|
||||
fetchApiMock.mockResolvedValueOnce(
|
||||
buildAssetListResponse([validAsset({ id: 'only', tags: ['input'] })], {
|
||||
hasMore: true
|
||||
})
|
||||
)
|
||||
|
||||
const assets = await assetService.getAllAssetsByTag('input', true, {
|
||||
limit: 2
|
||||
})
|
||||
|
||||
expect(assets.map((a) => a.id)).toEqual(['only'])
|
||||
expect(fetchApiMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('stops walking when the server returns a non-advancing cursor', async () => {
|
||||
fetchApiMock
|
||||
.mockResolvedValueOnce(
|
||||
buildAssetListResponse([validAsset({ id: 'a', tags: ['input'] })], {
|
||||
hasMore: true,
|
||||
nextCursor: 'stuck'
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
buildAssetListResponse([validAsset({ id: 'b', tags: ['input'] })], {
|
||||
hasMore: true,
|
||||
nextCursor: 'stuck'
|
||||
})
|
||||
)
|
||||
|
||||
const assets = await assetService.getAllAssetsByTag('input', true, {
|
||||
limit: 1
|
||||
})
|
||||
|
||||
expect(assets.map((a) => a.id)).toEqual(['a', 'b'])
|
||||
expect(fetchApiMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it.for([
|
||||
@@ -636,7 +688,7 @@ describe(assetService.getAllAssetsByTag, () => {
|
||||
validAsset({ id: 'a', tags: ['input'] }),
|
||||
validAsset({ id: 'b', tags: ['input'] })
|
||||
],
|
||||
{ hasMore: true }
|
||||
{ hasMore: true, nextCursor: 'cursor-page-2' }
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -31,6 +31,11 @@ export interface PaginationOptions {
|
||||
}
|
||||
|
||||
interface AssetPaginationOptions extends PaginationOptions {
|
||||
/**
|
||||
* Opaque keyset cursor from a prior response's `next_cursor`. When set, the
|
||||
* server resumes after that cursor and `offset` is ignored.
|
||||
*/
|
||||
after?: string
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
@@ -38,6 +43,7 @@ interface AssetRequestOptions extends PaginationOptions {
|
||||
includeTags: string[]
|
||||
excludeTags?: string[]
|
||||
includePublic?: boolean
|
||||
after?: string
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
@@ -286,6 +292,7 @@ function createAssetService() {
|
||||
excludeTags = DEFAULT_EXCLUDED_ASSET_TAGS,
|
||||
limit = DEFAULT_LIMIT,
|
||||
offset,
|
||||
after,
|
||||
includePublic,
|
||||
signal
|
||||
} = options
|
||||
@@ -299,7 +306,11 @@ function createAssetService() {
|
||||
if (normalizedExcludeTags.length > 0) {
|
||||
queryParams.set('exclude_tags', normalizedExcludeTags.join(','))
|
||||
}
|
||||
if (offset !== undefined && offset > 0) {
|
||||
// `after` (keyset cursor) takes precedence over `offset`; the server ignores
|
||||
// `offset` when a cursor is supplied, so we avoid sending a redundant param.
|
||||
if (after) {
|
||||
queryParams.set('after', after)
|
||||
} else if (offset !== undefined && offset > 0) {
|
||||
queryParams.set('offset', offset.toString())
|
||||
}
|
||||
if (includePublic !== undefined) {
|
||||
@@ -481,11 +492,17 @@ function createAssetService() {
|
||||
async function getAssetsByTag(
|
||||
tag: string,
|
||||
includePublic: boolean = true,
|
||||
{ limit = DEFAULT_LIMIT, offset = 0, signal }: AssetPaginationOptions = {}
|
||||
{
|
||||
limit = DEFAULT_LIMIT,
|
||||
offset = 0,
|
||||
after,
|
||||
signal
|
||||
}: AssetPaginationOptions = {}
|
||||
): Promise<AssetItem[]> {
|
||||
const data = await getAssetsPageByTag(tag, includePublic, {
|
||||
limit,
|
||||
offset,
|
||||
after,
|
||||
signal
|
||||
})
|
||||
|
||||
@@ -498,17 +515,27 @@ function createAssetService() {
|
||||
async function getAssetsPageByTag(
|
||||
tag: string,
|
||||
includePublic: boolean = true,
|
||||
{ limit = DEFAULT_LIMIT, offset = 0, signal }: AssetPaginationOptions = {}
|
||||
{
|
||||
limit = DEFAULT_LIMIT,
|
||||
offset = 0,
|
||||
after,
|
||||
signal
|
||||
}: AssetPaginationOptions = {}
|
||||
): Promise<AssetResponse> {
|
||||
return await handleAssetRequest(
|
||||
{ includeTags: [tag], limit, offset, includePublic, signal },
|
||||
{ includeTags: [tag], limit, offset, after, includePublic, signal },
|
||||
`assets for tag ${tag}`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets every asset for a tag by walking paginated asset API responses.
|
||||
* Pagination follows the required server-provided `has_more` flag.
|
||||
*
|
||||
* Uses keyset (cursor) pagination: each page is fetched with the prior
|
||||
* response's `next_cursor`, which is stable under concurrent inserts/deletes
|
||||
* and avoids the duplicate/skip drift that offset paging exhibits when the
|
||||
* underlying set changes mid-walk. Falls back to terminating on `has_more`
|
||||
* when the server omits `next_cursor`.
|
||||
*
|
||||
* @param tag - The tag to filter by (e.g., 'models', 'input')
|
||||
* @param includePublic - Whether to include public assets (default: true)
|
||||
@@ -520,18 +547,21 @@ function createAssetService() {
|
||||
async function getAllAssetsByTag(
|
||||
tag: string,
|
||||
includePublic: boolean = true,
|
||||
{ limit = DEFAULT_LIMIT, signal }: AssetPaginationOptions = {}
|
||||
{
|
||||
limit = DEFAULT_LIMIT,
|
||||
signal
|
||||
}: Pick<AssetPaginationOptions, 'limit' | 'signal'> = {}
|
||||
): Promise<AssetItem[]> {
|
||||
const assets: AssetItem[] = []
|
||||
const pageSize = limit > 0 ? limit : DEFAULT_LIMIT
|
||||
let offset = 0
|
||||
let after: string | undefined
|
||||
|
||||
while (true) {
|
||||
if (signal?.aborted) throw createAbortError()
|
||||
|
||||
const data = await getAssetsPageByTag(tag, includePublic, {
|
||||
limit: pageSize,
|
||||
offset,
|
||||
after,
|
||||
signal
|
||||
})
|
||||
const batch = data.assets
|
||||
@@ -541,11 +571,12 @@ function createAssetService() {
|
||||
|
||||
assets.push(...batch)
|
||||
|
||||
if (!data.has_more) {
|
||||
// A server that returns a non-advancing cursor would loop forever.
|
||||
if (!data.has_more || !data.next_cursor || data.next_cursor === after) {
|
||||
return assets
|
||||
}
|
||||
|
||||
offset += batch.length
|
||||
after = data.next_cursor
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1136,6 +1136,31 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
versionAdded: '1.20.4',
|
||||
versionModified: '1.20.5'
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Canvas.BackgroundPattern',
|
||||
category: ['Appearance', 'Canvas', 'BackgroundPattern'],
|
||||
name: 'Canvas background pattern',
|
||||
type: 'combo',
|
||||
options: [
|
||||
{ value: 'dots', text: 'Dots' },
|
||||
{ value: 'grid', text: 'Grid' },
|
||||
{ value: 'none', text: 'None' }
|
||||
],
|
||||
tooltip:
|
||||
'Pattern drawn on the canvas background. Not shown while a custom background image is set.',
|
||||
defaultValue: 'dots',
|
||||
versionAdded: '1.47.1'
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Canvas.BackgroundColor',
|
||||
category: ['Appearance', 'Canvas', 'BackgroundColor'],
|
||||
name: 'Canvas background color',
|
||||
type: 'color',
|
||||
tooltip:
|
||||
'Custom canvas background color. Leave empty to follow the active theme.',
|
||||
defaultValue: '',
|
||||
versionAdded: '1.47.1'
|
||||
},
|
||||
// Release data stored in settings
|
||||
{
|
||||
id: 'Comfy.Release.Version',
|
||||
|
||||
@@ -53,7 +53,8 @@ describe('useReleaseService', () => {
|
||||
project: 'comfyui',
|
||||
current_version: '1.0.0'
|
||||
},
|
||||
signal: undefined
|
||||
signal: undefined,
|
||||
headers: undefined
|
||||
})
|
||||
|
||||
expect(result).toEqual(mockReleases)
|
||||
@@ -76,7 +77,8 @@ describe('useReleaseService', () => {
|
||||
current_version: '1.0.0',
|
||||
form_factor: 'desktop-windows'
|
||||
},
|
||||
signal: undefined
|
||||
signal: undefined,
|
||||
headers: undefined
|
||||
})
|
||||
|
||||
expect(result).toEqual(mockReleases)
|
||||
@@ -86,11 +88,30 @@ describe('useReleaseService', () => {
|
||||
const abortController = new AbortController()
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: mockReleases })
|
||||
|
||||
await service.getReleases({ project: 'comfyui' }, abortController.signal)
|
||||
await service.getReleases(
|
||||
{ project: 'comfyui' },
|
||||
{ signal: abortController.signal }
|
||||
)
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/releases', {
|
||||
params: { project: 'comfyui' },
|
||||
signal: abortController.signal
|
||||
signal: abortController.signal,
|
||||
headers: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('should send Comfy-Env header when deployEnvironment is provided', async () => {
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: mockReleases })
|
||||
|
||||
await service.getReleases(
|
||||
{ project: 'comfyui' },
|
||||
{ deployEnvironment: 'local-desktop' }
|
||||
)
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/releases', {
|
||||
params: { project: 'comfyui' },
|
||||
signal: undefined,
|
||||
headers: { 'Comfy-Env': 'local-desktop' }
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -98,8 +98,9 @@ export const useReleaseService = () => {
|
||||
// Fetch release notes from API
|
||||
const getReleases = async (
|
||||
params: GetReleasesParams,
|
||||
signal?: AbortSignal
|
||||
options: { signal?: AbortSignal; deployEnvironment?: string } = {}
|
||||
): Promise<ReleaseNote[] | null> => {
|
||||
const { signal, deployEnvironment } = options
|
||||
const endpoint = '/releases'
|
||||
const errorContext = 'Failed to get releases'
|
||||
const routeSpecificErrors = {
|
||||
@@ -110,7 +111,10 @@ export const useReleaseService = () => {
|
||||
() =>
|
||||
releaseApiClient.get<ReleaseNote[]>(endpoint, {
|
||||
params,
|
||||
signal
|
||||
signal,
|
||||
headers: deployEnvironment
|
||||
? { 'Comfy-Env': deployEnvironment }
|
||||
: undefined
|
||||
}),
|
||||
errorContext,
|
||||
routeSpecificErrors
|
||||
|
||||
@@ -228,12 +228,15 @@ describe('useReleaseStore', () => {
|
||||
|
||||
await store.initialize()
|
||||
|
||||
expect(releaseService.getReleases).toHaveBeenCalledWith({
|
||||
project: 'comfyui',
|
||||
current_version: '1.0.0',
|
||||
form_factor: 'git-windows',
|
||||
locale: 'en'
|
||||
})
|
||||
expect(releaseService.getReleases).toHaveBeenCalledWith(
|
||||
{
|
||||
project: 'comfyui',
|
||||
current_version: '1.0.0',
|
||||
form_factor: 'git-windows',
|
||||
locale: 'en'
|
||||
},
|
||||
{ deployEnvironment: undefined }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -300,12 +303,15 @@ describe('useReleaseStore', () => {
|
||||
|
||||
await store.initialize()
|
||||
|
||||
expect(releaseService.getReleases).toHaveBeenCalledWith({
|
||||
project: 'comfyui',
|
||||
current_version: '1.0.0',
|
||||
form_factor: 'git-windows',
|
||||
locale: 'en'
|
||||
})
|
||||
expect(releaseService.getReleases).toHaveBeenCalledWith(
|
||||
{
|
||||
project: 'comfyui',
|
||||
current_version: '1.0.0',
|
||||
form_factor: 'git-windows',
|
||||
locale: 'en'
|
||||
},
|
||||
{ deployEnvironment: undefined }
|
||||
)
|
||||
expect(store.releases).toEqual([mockRelease])
|
||||
})
|
||||
|
||||
@@ -318,12 +324,30 @@ describe('useReleaseStore', () => {
|
||||
|
||||
await store.initialize()
|
||||
|
||||
expect(releaseService.getReleases).toHaveBeenCalledWith({
|
||||
project: 'comfyui',
|
||||
current_version: '1.0.0',
|
||||
form_factor: 'desktop-mac',
|
||||
locale: 'en'
|
||||
})
|
||||
expect(releaseService.getReleases).toHaveBeenCalledWith(
|
||||
{
|
||||
project: 'comfyui',
|
||||
current_version: '1.0.0',
|
||||
form_factor: 'desktop-mac',
|
||||
locale: 'en'
|
||||
},
|
||||
{ deployEnvironment: undefined }
|
||||
)
|
||||
})
|
||||
|
||||
it('should pass deploy_environment from system stats', async () => {
|
||||
const store = useReleaseStore()
|
||||
const releaseService = useReleaseService()
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
systemStatsStore.systemStats!.system.deploy_environment = 'local-desktop'
|
||||
vi.mocked(releaseService.getReleases).mockResolvedValue([mockRelease])
|
||||
|
||||
await store.initialize()
|
||||
|
||||
expect(releaseService.getReleases).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ deployEnvironment: 'local-desktop' }
|
||||
)
|
||||
})
|
||||
|
||||
it('should skip fetching when --disable-api-nodes is present', async () => {
|
||||
|
||||
@@ -266,12 +266,18 @@ export const useReleaseStore = defineStore('release', () => {
|
||||
await until(systemStatsStore.isInitialized)
|
||||
}
|
||||
|
||||
const fetchedReleases = await releaseService.getReleases({
|
||||
project: isCloud ? 'cloud' : 'comfyui',
|
||||
current_version: currentVersion.value,
|
||||
form_factor: systemStatsStore.getFormFactor(),
|
||||
locale: stringToLocale(locale.value)
|
||||
})
|
||||
const fetchedReleases = await releaseService.getReleases(
|
||||
{
|
||||
project: isCloud ? 'cloud' : 'comfyui',
|
||||
current_version: currentVersion.value,
|
||||
form_factor: systemStatsStore.getFormFactor(),
|
||||
locale: stringToLocale(locale.value)
|
||||
},
|
||||
{
|
||||
deployEnvironment:
|
||||
systemStatsStore.systemStats?.system?.deploy_environment
|
||||
}
|
||||
)
|
||||
|
||||
if (fetchedReleases !== null) {
|
||||
releases.value = fetchedReleases
|
||||
|
||||
@@ -252,6 +252,7 @@ const zSystemStats = z.object({
|
||||
python_version: z.string(),
|
||||
embedded_python: z.boolean(),
|
||||
comfyui_version: z.string(),
|
||||
deploy_environment: z.string().optional(),
|
||||
pytorch_version: z.string(),
|
||||
required_frontend_version: z.string().optional(),
|
||||
argv: z.array(z.string()),
|
||||
@@ -307,6 +308,8 @@ const zSettings = z.object({
|
||||
'Comfy.ColorPalette': z.string(),
|
||||
'Comfy.CustomColorPalettes': colorPalettesSchema,
|
||||
'Comfy.Canvas.BackgroundImage': z.string().optional(),
|
||||
'Comfy.Canvas.BackgroundPattern': z.enum(['dots', 'grid', 'none']),
|
||||
'Comfy.Canvas.BackgroundColor': z.string(),
|
||||
'Comfy.ConfirmClear': z.boolean(),
|
||||
'Comfy.DevMode': z.boolean(),
|
||||
'Comfy.Appearance.DisableAnimations': z.boolean(),
|
||||
|
||||
@@ -108,6 +108,11 @@ interface QueuePromptRequestBody {
|
||||
* ```
|
||||
*/
|
||||
api_key_comfy_org?: string
|
||||
/**
|
||||
* Identifies the client submitting the prompt. Forwarded by the backend
|
||||
* to API nodes' upstream requests via the Comfy-Usage-Source header.
|
||||
*/
|
||||
comfy_usage_source?: string
|
||||
/**
|
||||
* Override the preview method for this prompt execution.
|
||||
* 'default' uses the server's CLI setting.
|
||||
@@ -867,6 +872,7 @@ export class ComfyApi extends EventTarget {
|
||||
extra_data: {
|
||||
auth_token_comfy_org: this.authToken,
|
||||
api_key_comfy_org: this.apiKey,
|
||||
comfy_usage_source: 'comfyui-frontend',
|
||||
extra_pnginfo: { workflow },
|
||||
...(options?.previewMethod &&
|
||||
options.previewMethod !== 'default' && {
|
||||
|
||||
90
src/services/colorPaletteService.test.ts
Normal file
90
src/services/colorPaletteService.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type * as canvasPatternUtil from '@/utils/canvasPatternUtil'
|
||||
|
||||
const mockSettings = vi.hoisted(() => ({
|
||||
values: {} as Record<string, unknown>
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
canvas: {
|
||||
background_image: 'initial',
|
||||
clear_background_color: 'initial',
|
||||
_pattern: 'initial',
|
||||
node_title_color: '',
|
||||
default_link_color: '',
|
||||
default_connection_color_byType: {},
|
||||
setDirty: vi.fn()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: (key: string) => mockSettings.values[key],
|
||||
set: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/canvasPatternUtil', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof canvasPatternUtil>()),
|
||||
generateCanvasPatternImage: vi.fn(
|
||||
(pattern: string, color: string) => `tile:${pattern}:${color}`
|
||||
)
|
||||
}))
|
||||
|
||||
import { app } from '@/scripts/app'
|
||||
import { useColorPaletteService } from '@/services/colorPaletteService'
|
||||
|
||||
describe('colorPaletteService canvas background', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
mockSettings.values = {
|
||||
'Comfy.Canvas.BackgroundImage': '',
|
||||
'Comfy.Canvas.BackgroundPattern': 'dots',
|
||||
'Comfy.Canvas.BackgroundColor': ''
|
||||
}
|
||||
app.canvas.background_image = 'initial'
|
||||
app.canvas.clear_background_color = 'initial'
|
||||
app.canvas._pattern = 'initial' as unknown as undefined
|
||||
})
|
||||
|
||||
it('renders the generated pattern over the palette color by default', async () => {
|
||||
await useColorPaletteService().loadColorPalette('dark')
|
||||
|
||||
expect(app.canvas.background_image).toBe('tile:dots:#222222')
|
||||
expect(app.canvas.clear_background_color).toBe('#222222')
|
||||
expect(app.canvas._pattern).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses the user background color when set', async () => {
|
||||
mockSettings.values['Comfy.Canvas.BackgroundColor'] = 'aabbcc'
|
||||
mockSettings.values['Comfy.Canvas.BackgroundPattern'] = 'grid'
|
||||
|
||||
await useColorPaletteService().loadColorPalette('dark')
|
||||
|
||||
expect(app.canvas.background_image).toBe('tile:grid:#aabbcc')
|
||||
expect(app.canvas.clear_background_color).toBe('#aabbcc')
|
||||
})
|
||||
|
||||
it('renders a solid tile when the pattern is none', async () => {
|
||||
mockSettings.values['Comfy.Canvas.BackgroundPattern'] = 'none'
|
||||
|
||||
await useColorPaletteService().loadColorPalette('dark')
|
||||
|
||||
expect(app.canvas.background_image).toBe('tile:none:#222222')
|
||||
})
|
||||
|
||||
it('lets a custom background image take precedence over patterns', async () => {
|
||||
mockSettings.values['Comfy.Canvas.BackgroundImage'] = 'https://bg.png'
|
||||
|
||||
await useColorPaletteService().loadColorPalette('dark')
|
||||
|
||||
expect(app.canvas.background_image).toBe('')
|
||||
expect(app.canvas.clear_background_color).toBe('transparent')
|
||||
expect(app.canvas._pattern).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,10 @@ import type { Colors, Palette } from '@/schemas/colorPaletteSchema'
|
||||
import { app } from '@/scripts/app'
|
||||
import { uploadFile } from '@/scripts/utils'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import {
|
||||
generateCanvasPatternImage,
|
||||
getEffectiveCanvasBackgroundColor
|
||||
} from '@/utils/canvasPatternUtil'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
|
||||
const THEME_PROPERTY_MAP = {
|
||||
@@ -153,10 +157,18 @@ export const useColorPaletteService = () => {
|
||||
app.canvas.default_link_color = palette.LINK_COLOR
|
||||
const backgroundImage = settingStore.get('Comfy.Canvas.BackgroundImage')
|
||||
if (backgroundImage) {
|
||||
app.canvas.background_image = ''
|
||||
app.canvas.clear_background_color = 'transparent'
|
||||
} else {
|
||||
app.canvas.background_image = palette.BACKGROUND_IMAGE
|
||||
app.canvas.clear_background_color = palette.CLEAR_BACKGROUND_COLOR
|
||||
const backgroundColor = getEffectiveCanvasBackgroundColor(
|
||||
settingStore.get('Comfy.Canvas.BackgroundColor'),
|
||||
palette.CLEAR_BACKGROUND_COLOR
|
||||
)
|
||||
app.canvas.background_image = generateCanvasPatternImage(
|
||||
settingStore.get('Comfy.Canvas.BackgroundPattern'),
|
||||
backgroundColor
|
||||
)
|
||||
app.canvas.clear_background_color = backgroundColor
|
||||
}
|
||||
app.canvas._pattern = undefined
|
||||
|
||||
|
||||
135
src/utils/canvasPatternUtil.test.ts
Normal file
135
src/utils/canvasPatternUtil.test.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
generateCanvasPatternImage,
|
||||
getEffectiveCanvasBackgroundColor,
|
||||
getPatternMarkColor
|
||||
} from '@/utils/canvasPatternUtil'
|
||||
|
||||
interface RecordingContext {
|
||||
fillStyle: string
|
||||
strokeStyle: string
|
||||
lineWidth: number
|
||||
groundColor: string
|
||||
fillRect: ReturnType<typeof vi.fn>
|
||||
beginPath: ReturnType<typeof vi.fn>
|
||||
arc: ReturnType<typeof vi.fn>
|
||||
fill: ReturnType<typeof vi.fn>
|
||||
moveTo: ReturnType<typeof vi.fn>
|
||||
lineTo: ReturnType<typeof vi.fn>
|
||||
stroke: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function createRecordingContext(): RecordingContext {
|
||||
const ctx: RecordingContext = {
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
lineWidth: 0,
|
||||
groundColor: '',
|
||||
fillRect: vi.fn(() => {
|
||||
ctx.groundColor = ctx.fillStyle
|
||||
}),
|
||||
beginPath: vi.fn(),
|
||||
arc: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
stroke: vi.fn()
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
let contexts: RecordingContext[]
|
||||
let getContextSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
contexts = []
|
||||
getContextSpy = vi
|
||||
.spyOn(HTMLCanvasElement.prototype, 'getContext')
|
||||
.mockImplementation(() => {
|
||||
const ctx = createRecordingContext()
|
||||
contexts.push(ctx)
|
||||
return ctx as unknown as GPUCanvasContext
|
||||
})
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'toDataURL').mockReturnValue(
|
||||
'data:image/png;base64,sentinel'
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('getPatternMarkColor', () => {
|
||||
it('uses faint light marks on dark backgrounds', () => {
|
||||
expect(getPatternMarkColor('#141414')).toBe('rgba(255, 255, 255, 0.10)')
|
||||
expect(getPatternMarkColor('#0000ff')).toBe('rgba(255, 255, 255, 0.10)')
|
||||
})
|
||||
|
||||
it('uses faint dark marks on light backgrounds', () => {
|
||||
expect(getPatternMarkColor('#f0f0f0')).toBe('rgba(0, 0, 0, 0.13)')
|
||||
expect(getPatternMarkColor('#ffffff')).toBe('rgba(0, 0, 0, 0.13)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getEffectiveCanvasBackgroundColor', () => {
|
||||
it('prefers the user setting over the palette color', () => {
|
||||
expect(getEffectiveCanvasBackgroundColor('aabbcc', '#222222')).toBe(
|
||||
'#aabbcc'
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the palette color when the setting is empty', () => {
|
||||
expect(getEffectiveCanvasBackgroundColor('', '#222222')).toBe('#222222')
|
||||
})
|
||||
|
||||
it('accepts a #-prefixed stored value', () => {
|
||||
expect(getEffectiveCanvasBackgroundColor('#aabbcc', '#222222')).toBe(
|
||||
'#aabbcc'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateCanvasPatternImage', () => {
|
||||
it('fills the ground with the background color and draws no marks for none', () => {
|
||||
generateCanvasPatternImage('none', '#101010')
|
||||
const ctx = contexts.at(-1)!
|
||||
expect(ctx.fillRect).toHaveBeenCalledExactlyOnceWith(0, 0, 100, 100)
|
||||
expect(ctx.groundColor).toBe('#101010')
|
||||
expect(ctx.arc).not.toHaveBeenCalled()
|
||||
expect(ctx.stroke).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('draws a 5x5 grid of dots for the dots pattern', () => {
|
||||
generateCanvasPatternImage('dots', '#111111')
|
||||
const ctx = contexts.at(-1)!
|
||||
expect(ctx.arc).toHaveBeenCalledTimes(25)
|
||||
expect(ctx.fill).toHaveBeenCalledTimes(25)
|
||||
expect(ctx.stroke).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('draws 5 vertical and 5 horizontal lines for the grid pattern', () => {
|
||||
generateCanvasPatternImage('grid', '#121212')
|
||||
const ctx = contexts.at(-1)!
|
||||
expect(ctx.stroke).toHaveBeenCalledTimes(10)
|
||||
expect(ctx.arc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns a data URI', () => {
|
||||
expect(generateCanvasPatternImage('dots', '#131313')).toBe(
|
||||
'data:image/png;base64,sentinel'
|
||||
)
|
||||
})
|
||||
|
||||
it('strips alpha from 8-digit hex backgrounds', () => {
|
||||
generateCanvasPatternImage('dots', '#101010ff')
|
||||
expect(contexts.at(-1)!.groundColor).toBe('#101010')
|
||||
})
|
||||
|
||||
it('memoizes tiles per pattern and color', () => {
|
||||
generateCanvasPatternImage('grid', '#151515')
|
||||
const callsAfterFirst = getContextSpy.mock.calls.length
|
||||
generateCanvasPatternImage('grid', '#151515')
|
||||
expect(getContextSpy.mock.calls.length).toBe(callsAfterFirst)
|
||||
})
|
||||
})
|
||||
120
src/utils/canvasPatternUtil.ts
Normal file
120
src/utils/canvasPatternUtil.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { memoize } from 'es-toolkit/compat'
|
||||
|
||||
import { isLightColor, parseToRgb, rgbToHex } from '@/utils/colorUtil'
|
||||
|
||||
export type CanvasBackgroundPattern = 'dots' | 'grid' | 'none'
|
||||
|
||||
const TILE_SIZE = 100
|
||||
const SPACING = 20
|
||||
const DOT_RADIUS = 1.25
|
||||
const GRID_LINE_WIDTH = 1
|
||||
|
||||
const LIGHT_MARK_COLOR = 'rgba(255, 255, 255, 0.10)'
|
||||
const DARK_MARK_COLOR = 'rgba(0, 0, 0, 0.13)'
|
||||
|
||||
let sharedContext: CanvasRenderingContext2D | null = null
|
||||
|
||||
function getSharedContext(): CanvasRenderingContext2D {
|
||||
sharedContext ??= document.createElement('canvas').getContext('2d')
|
||||
if (!sharedContext) throw new Error('2D canvas context unavailable')
|
||||
return sharedContext
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes any CSS color (hex, rgb()/hsl(), named colors like `lightgray`)
|
||||
* to opaque lowercase `#rrggbb`.
|
||||
*/
|
||||
function normalizeToHexColor(color: string): string {
|
||||
const trimmed = color.trim()
|
||||
if (trimmed.startsWith('#')) {
|
||||
return rgbToHex(parseToRgb(trimmed)).toLowerCase()
|
||||
}
|
||||
const ctx = getSharedContext()
|
||||
ctx.fillStyle = '#000000'
|
||||
ctx.fillStyle = trimmed
|
||||
const parsed = ctx.fillStyle
|
||||
return parsed.startsWith('#')
|
||||
? parsed.toLowerCase()
|
||||
: rgbToHex(parseToRgb(parsed)).toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the canvas background color: a user-set value (stored as hex
|
||||
* without `#`) wins over the active palette's color.
|
||||
*/
|
||||
export function getEffectiveCanvasBackgroundColor(
|
||||
settingValue: string,
|
||||
paletteColor: string
|
||||
): string {
|
||||
return normalizeToHexColor(
|
||||
settingValue ? `#${settingValue.replace(/^#/, '')}` : paletteColor
|
||||
)
|
||||
}
|
||||
|
||||
/** Faint mark color auto-contrasted against the background. */
|
||||
export function getPatternMarkColor(backgroundColor: string): string {
|
||||
return isLightColor(normalizeToHexColor(backgroundColor))
|
||||
? DARK_MARK_COLOR
|
||||
: LIGHT_MARK_COLOR
|
||||
}
|
||||
|
||||
function drawDots(ctx: CanvasRenderingContext2D) {
|
||||
for (let x = SPACING / 2; x < TILE_SIZE; x += SPACING) {
|
||||
for (let y = SPACING / 2; y < TILE_SIZE; y += SPACING) {
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, DOT_RADIUS, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawGrid(ctx: CanvasRenderingContext2D) {
|
||||
ctx.lineWidth = GRID_LINE_WIDTH
|
||||
for (let offset = 0; offset < TILE_SIZE; offset += SPACING) {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(offset + 0.5, 0)
|
||||
ctx.lineTo(offset + 0.5, TILE_SIZE)
|
||||
ctx.stroke()
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, offset + 0.5)
|
||||
ctx.lineTo(TILE_SIZE, offset + 0.5)
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
function renderPatternImage(
|
||||
pattern: CanvasBackgroundPattern,
|
||||
backgroundColor: string
|
||||
): string {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = TILE_SIZE
|
||||
canvas.height = TILE_SIZE
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) throw new Error('2D canvas context unavailable')
|
||||
|
||||
const ground = normalizeToHexColor(backgroundColor)
|
||||
ctx.fillStyle = ground
|
||||
ctx.fillRect(0, 0, TILE_SIZE, TILE_SIZE)
|
||||
|
||||
const markColor = getPatternMarkColor(ground)
|
||||
ctx.fillStyle = markColor
|
||||
ctx.strokeStyle = markColor
|
||||
if (pattern === 'dots') drawDots(ctx)
|
||||
if (pattern === 'grid') drawGrid(ctx)
|
||||
|
||||
return canvas.toDataURL('image/png')
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an opaque repeating background tile as a data URI. The tile is
|
||||
* always opaque (including `none`) because LGraphCanvas paints only the tile
|
||||
* at zoom levels >= 1.5.
|
||||
*/
|
||||
export const generateCanvasPatternImage: (
|
||||
pattern: CanvasBackgroundPattern,
|
||||
backgroundColor: string
|
||||
) => string = memoize(
|
||||
renderPatternImage,
|
||||
(pattern: CanvasBackgroundPattern, backgroundColor: string) =>
|
||||
`${pattern}:${backgroundColor}`
|
||||
)
|
||||
@@ -3,11 +3,13 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import type { ColorAdjustOptions } from '@/utils/colorUtil'
|
||||
import {
|
||||
adjustColor,
|
||||
getRelativeLuminance,
|
||||
hexToHsva,
|
||||
hexToInt,
|
||||
hexToRgb,
|
||||
hsbToRgb,
|
||||
hsvaToHex,
|
||||
isLightColor,
|
||||
isTransparent,
|
||||
parseToRgb,
|
||||
rgbToHex,
|
||||
@@ -410,3 +412,28 @@ describe('colorUtil - adjustColor', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('colorUtil - luminance', () => {
|
||||
describe('getRelativeLuminance', () => {
|
||||
it('returns 0 for black and 1 for white', () => {
|
||||
expect(getRelativeLuminance('#000000')).toBe(0)
|
||||
expect(getRelativeLuminance('#ffffff')).toBeCloseTo(1)
|
||||
})
|
||||
|
||||
it('returns the WCAG luminance for mid gray', () => {
|
||||
expect(getRelativeLuminance('#808080')).toBeCloseTo(0.2159, 3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isLightColor', () => {
|
||||
it('classifies dark backgrounds as dark', () => {
|
||||
expect(isLightColor('#141414')).toBe(false)
|
||||
expect(isLightColor('#0000ff')).toBe(false)
|
||||
})
|
||||
|
||||
it('classifies light backgrounds as light', () => {
|
||||
expect(isLightColor('#f0f0f0')).toBe(true)
|
||||
expect(isLightColor('#808080')).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -404,6 +404,26 @@ export function hsvaToHex(hsva: HSVA): string {
|
||||
return `${hex}${alphaHex}`.toLowerCase()
|
||||
}
|
||||
|
||||
function linearizeSrgbChannel(channel: number): number {
|
||||
const c = channel / 255
|
||||
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
|
||||
}
|
||||
|
||||
/** WCAG relative luminance (0..1) of a parseable CSS color. */
|
||||
export function getRelativeLuminance(color: string): number {
|
||||
const { r, g, b } = parseToRgb(color)
|
||||
return (
|
||||
0.2126 * linearizeSrgbChannel(r) +
|
||||
0.7152 * linearizeSrgbChannel(g) +
|
||||
0.0722 * linearizeSrgbChannel(b)
|
||||
)
|
||||
}
|
||||
|
||||
/** True when dark foreground marks should be used on this background. */
|
||||
export function isLightColor(color: string): boolean {
|
||||
return getRelativeLuminance(color) > 0.179
|
||||
}
|
||||
|
||||
const applyColorAdjustments = (
|
||||
color: string,
|
||||
options: ColorAdjustOptions
|
||||
|
||||
Reference in New Issue
Block a user