Compare commits

...

2 Commits

Author SHA1 Message Date
PabloWiedemann
e8a0a9808a fix: render all canvas background settings; adapt e2e to modal picker
- Give BackgroundPattern and BackgroundColor distinct category leaf
  segments. Settings sharing an identical category path collide in the
  settings tree (buildTree overwrites node.data), so only the last one
  rendered in the dialog. This restored the previously-hidden background
  image and pattern rows.
- Rewrite backgroundImageUpload.spec.ts for the new ImageUpload component
  (thumbnail + base name + remove button) instead of the old URL/upload/
  clear layout.
- Dismiss the now-modal color picker popover before interacting with the
  rest of the Customize Folder dialog in the bookmark-color e2e test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:16:12 -07:00
PabloWiedemann
6450db97b4 feat: native canvas background patterns and color
Add opinionated canvas background customization without an extension:
a Background selector (Dots / Grid / None / Image) plus a color picker,
surfaced in the right-side panel CANVAS section and the settings dialog.

- Generate dots/grid pattern tiles natively, replacing per-palette
  BACKGROUND_IMAGE; pattern marks auto-contrast from the background's
  luminance. Custom uploaded images still take precedence.
- New settings Comfy.Canvas.BackgroundPattern and BackgroundColor;
  empty color follows the active theme.
- New reusable ui/image-upload/ImageUpload component (thumbnail,
  click-to-browse, clear) used by BackgroundImageUpload.
- Fix ColorPicker stacking below dialogs, model echo on external
  change, and marquee-on-dismiss by making the popover modal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:52:51 -07:00
21 changed files with 998 additions and 283 deletions

View File

@@ -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('')
})
})

View File

@@ -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()

View File

@@ -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",

View File

@@ -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>

View File

@@ -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)

View File

@@ -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="

View 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()
})
})

View File

@@ -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"

View 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 />'
})
}

View 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([['']])
})
})

View 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>

View File

@@ -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",

View File

@@ -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": {

View File

@@ -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',

View File

@@ -308,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(),

View 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()
})
})

View File

@@ -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

View 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)
})
})

View 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}`
)

View File

@@ -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)
})
})
})

View File

@@ -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