Compare commits

..

1 Commits

Author SHA1 Message Date
CodeRabbit Fixer
eda3929fed fix: a11y: localize aria-valuetext in ColorPickerSaturationValue (#9798)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 17:55:58 +01:00
15 changed files with 259 additions and 30 deletions

View File

@@ -55,7 +55,10 @@ const config: KnipConfig = {
// Agent review check config, not part of the build
'.agents/checks/eslint.strict.config.js',
// Loaded via @plugin directive in CSS, not detected by knip
'packages/design-system/src/css/lucideStrokePlugin.js'
'packages/design-system/src/css/lucideStrokePlugin.js',
// Pending integration in stacked PR (feat/storybook-color-picker)
'src/components/ui/color-picker/ColorPickerSaturationValue.vue',
'src/components/ui/color-picker/ColorPickerSlider.vue'
],
compilers: {
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199

View File

@@ -0,0 +1,99 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { hue } = defineProps<{
hue: number
}>()
const saturation = defineModel<number>('saturation', { required: true })
const value = defineModel<number>('value', { required: true })
const { t } = useI18n()
const containerRef = ref<HTMLElement | null>(null)
const hueBackground = computed(() => `hsl(${hue}, 100%, 50%)`)
const handleStyle = computed(() => ({
left: `${saturation.value}%`,
top: `${100 - value.value}%`
}))
function clamp(v: number, min: number, max: number) {
return Math.max(min, Math.min(max, v))
}
function updateFromPointer(e: PointerEvent) {
const el = containerRef.value
if (!el) return
const rect = el.getBoundingClientRect()
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height))
saturation.value = Math.round(x * 100)
value.value = Math.round((1 - y) * 100)
}
function handlePointerDown(e: PointerEvent) {
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
updateFromPointer(e)
}
function handlePointerMove(e: PointerEvent) {
if (!(e.currentTarget as HTMLElement).hasPointerCapture(e.pointerId)) return
updateFromPointer(e)
}
function handleKeydown(e: KeyboardEvent) {
const step = e.shiftKey ? 10 : 1
switch (e.key) {
case 'ArrowLeft':
e.preventDefault()
saturation.value = clamp(saturation.value - step, 0, 100)
break
case 'ArrowRight':
e.preventDefault()
saturation.value = clamp(saturation.value + step, 0, 100)
break
case 'ArrowUp':
e.preventDefault()
value.value = clamp(value.value + step, 0, 100)
break
case 'ArrowDown':
e.preventDefault()
value.value = clamp(value.value - step, 0, 100)
break
}
}
</script>
<template>
<div
ref="containerRef"
role="slider"
tabindex="0"
:aria-label="t('colorPicker.saturationValue')"
:aria-valuetext="
t('colorPicker.saturationBrightnessValue', {
saturation: saturation,
value: value
})
"
class="relative aspect-square w-full cursor-crosshair rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-highlight"
:style="{ backgroundColor: hueBackground, touchAction: 'none' }"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@keydown="handleKeydown"
>
<div
class="absolute inset-0 rounded-sm bg-linear-to-r from-white to-transparent"
/>
<div
class="absolute inset-0 rounded-sm bg-linear-to-b from-transparent to-black"
/>
<div
class="pointer-events-none absolute size-3.5 -translate-1/2 rounded-full border-2 border-white shadow-[0_0_2px_rgba(0,0,0,0.6)]"
:style="handleStyle"
/>
</div>
</template>

View File

@@ -0,0 +1,115 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { hsbToRgb, rgbToHex } from '@/utils/colorUtil'
const {
type,
hue = 0,
saturation = 100,
brightness = 100
} = defineProps<{
type: 'hue' | 'alpha'
hue?: number
saturation?: number
brightness?: number
}>()
const modelValue = defineModel<number>({ required: true })
const { t } = useI18n()
const max = computed(() => (type === 'hue' ? 360 : 100))
const fraction = computed(() => modelValue.value / max.value)
const trackBackground = computed(() => {
if (type === 'hue') {
return 'linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)'
}
const rgb = hsbToRgb({ h: hue, s: saturation, b: brightness })
const hex = rgbToHex(rgb)
return `linear-gradient(to right, transparent, ${hex})`
})
const containerStyle = computed(() => {
if (type === 'alpha') {
return {
backgroundImage:
'repeating-conic-gradient(#808080 0% 25%, transparent 0% 50%)',
backgroundSize: '8px 8px',
touchAction: 'none'
}
}
return {
background: trackBackground.value,
touchAction: 'none'
}
})
const ariaLabel = computed(() =>
type === 'hue' ? t('colorPicker.hue') : t('colorPicker.alpha')
)
function clamp(v: number, min: number, maxVal: number) {
return Math.max(min, Math.min(maxVal, v))
}
function updateFromPointer(e: PointerEvent) {
const el = e.currentTarget as HTMLElement
const rect = el.getBoundingClientRect()
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
modelValue.value = Math.round(x * max.value)
}
function handlePointerDown(e: PointerEvent) {
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
updateFromPointer(e)
}
function handlePointerMove(e: PointerEvent) {
if (!(e.currentTarget as HTMLElement).hasPointerCapture(e.pointerId)) return
updateFromPointer(e)
}
function handleKeydown(e: KeyboardEvent) {
const step = e.shiftKey ? 10 : 1
switch (e.key) {
case 'ArrowLeft':
e.preventDefault()
modelValue.value = clamp(modelValue.value - step, 0, max.value)
break
case 'ArrowRight':
e.preventDefault()
modelValue.value = clamp(modelValue.value + step, 0, max.value)
break
}
}
</script>
<template>
<div
role="slider"
tabindex="0"
:aria-label="ariaLabel"
:aria-valuenow="modelValue"
:aria-valuemin="0"
:aria-valuemax="max"
class="relative flex h-4 cursor-pointer items-center rounded-full p-px outline-none focus-visible:ring-2 focus-visible:ring-highlight"
:style="containerStyle"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@keydown="handleKeydown"
>
<div
v-if="type === 'alpha'"
class="absolute inset-0 rounded-full"
:style="{ background: trackBackground }"
/>
<div
class="pointer-events-none absolute aspect-square h-full -translate-x-1/2 rounded-full border-2 border-white shadow-[0_0_2px_rgba(0,0,0,0.6)]"
:style="{ left: `${fraction * 100}%` }"
/>
</div>
</template>

View File

@@ -59,7 +59,10 @@ function mkFileUrl(props: { ref: ImageRef; preview?: boolean }): string {
}
const pathPlusQueryParams = api.apiURL(
'/view?' + params.toString() + app.getPreviewFormatParam()
'/view?' +
params.toString() +
app.getPreviewFormatParam() +
app.getRandParam()
)
const imageElement = new Image()
imageElement.crossOrigin = 'anonymous'

View File

@@ -17,7 +17,7 @@ type MockTask = {
executionEndTimestamp?: number
previewOutput?: {
isImage: boolean
url: string
urlWithTimestamp: string
}
}
@@ -94,7 +94,7 @@ describe(useQueueNotificationBanners, () => {
if (previewUrl) {
task.previewOutput = {
isImage,
url: previewUrl
urlWithTimestamp: previewUrl
}
}

View File

@@ -231,7 +231,7 @@ export const useQueueNotificationBanners = () => {
completedCount++
const preview = task.previewOutput
if (preview?.isImage) {
imagePreviews.push(preview.url)
imagePreviews.push(preview.urlWithTimestamp)
}
} else if (state === 'failed') {
failedCount++

View File

@@ -1,6 +1,7 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { NodeOutputWith } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useExtensionService } from '@/services/extensionService'
type ImageCompareOutput = NodeOutputWith<{
@@ -23,10 +24,11 @@ useExtensionService().registerExtension({
onExecuted?.call(this, output)
const { a_images: aImages, b_images: bImages } = output
const rand = app.getRandParam()
const toUrl = (record: Record<string, string>) => {
const params = new URLSearchParams(record)
return api.apiURL(`/view?${params}`)
return api.apiURL(`/view?${params}${rand}`)
}
const beforeImages =

View File

@@ -2,6 +2,7 @@ import type Load3d from '@/extensions/core/load3d/Load3d'
import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
class Load3dUtils {
static async generateThumbnailIfNeeded(
@@ -132,7 +133,8 @@ class Load3dUtils {
const params = [
'filename=' + encodeURIComponent(filename),
'type=' + type,
'subfolder=' + subfolder
'subfolder=' + subfolder,
app.getRandParam().substring(1)
].join('&')
return `/view?${params}`

View File

@@ -498,6 +498,14 @@
"black": "Black",
"custom": "Custom"
},
"colorPicker": {
"saturationValue": "Saturation and brightness",
"saturationBrightnessValue": "Saturation {saturation}%, brightness {value}%",
"saturation": "Saturation",
"brightness": "Brightness",
"hue": "Hue",
"alpha": "Alpha"
},
"contextMenu": {
"Inputs": "Inputs",
"Outputs": "Outputs",

View File

@@ -1,5 +1,5 @@
<template>
<WidgetLayoutField v-slot="{ borderStyle }" :widget :no-border="!hasLabels">
<WidgetLayoutField :widget>
<!-- Use ToggleGroup when explicit labels are provided -->
<ToggleGroup
v-if="hasLabels"
@@ -25,13 +25,7 @@
<!-- Use ToggleSwitch for implicit boolean states -->
<div
v-else
:class="
cn(
'-m-1 flex w-fit items-center gap-2 rounded-full p-1',
hideLayoutField || 'ml-auto',
borderStyle
)
"
:class="cn('flex w-fit items-center gap-2', hideLayoutField || 'ml-auto')"
>
<ToggleSwitch
v-model="modelValue"

View File

@@ -1,26 +1,17 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { useHideLayoutField } from '@/types/widgetTypes'
import { cn } from '@/utils/tailwindUtil'
const { widget, rootClass } = defineProps<{
const { rootClass } = defineProps<{
widget: Pick<
SimplifiedWidget<string | number | undefined>,
'name' | 'label' | 'borderStyle'
>
rootClass?: string
noBorder?: boolean
}>()
const hideLayoutField = useHideLayoutField()
const borderStyle = computed(() =>
cn(
'focus-within:ring focus-within:ring-component-node-widget-background-highlighted',
widget.borderStyle
)
)
</script>
<template>
@@ -42,15 +33,15 @@ const borderStyle = computed(() =>
<div
:class="
cn(
'min-w-0 cursor-default rounded-lg transition-all',
!noBorder && borderStyle
'min-w-0 cursor-default rounded-lg transition-all has-focus-visible:ring has-focus-visible:ring-component-node-widget-background-highlighted',
widget.borderStyle
)
"
@pointerdown.stop
@pointermove.stop
@pointerup.stop
>
<slot :border-style />
<slot />
</div>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import type { ResultItemType } from '@/schemas/apiSchema'
import { app } from '@/scripts/app'
/**
* Format time in MM:SS format
@@ -19,7 +20,8 @@ export function getResourceURL(
const params = [
'filename=' + encodeURIComponent(filename),
'type=' + type,
'subfolder=' + subfolder
'subfolder=' + subfolder,
app.getRandParam().substring(1)
].join('&')
return `/view?${params}`

View File

@@ -382,6 +382,11 @@ export class ComfyApp {
else return ''
}
getRandParam() {
if (isCloud) return ''
return '&rand=' + Math.random()
}
static onClipspaceEditorSave() {
if (ComfyApp.clipspace_return_node) {
ComfyApp.pasteFromClipspace(ComfyApp.clipspace_return_node)

View File

@@ -117,11 +117,12 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
const outputs = getNodeOutputs(node)
if (!outputs?.images?.length) return
const rand = app.getRandParam()
const previewParam = getPreviewParam(node, outputs)
return outputs.images.map((image) => {
const params = new URLSearchParams(image)
return api.apiURL(`/view?${params}${previewParam}`)
return api.apiURL(`/view?${params}${previewParam}${rand}`)
})
}

View File

@@ -104,6 +104,10 @@ export class ResultItemImpl {
return api.apiURL('/view?' + params)
}
get urlWithTimestamp(): string {
return `${this.url}&t=${+new Date()}`
}
get isVhsFormat(): boolean {
return !!this.format && !!this.frame_rate
}