Compare commits

..

2 Commits

Author SHA1 Message Date
CodeRabbit Fixer
5679af17b1 fix: add new color picker components to knip ignore list
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 10:55:02 +01:00
CodeRabbit Fixer
f3af3c90a7 fix: keyboard accessibility for ColorPickerSaturationValue and ColorPickerSlider (#9651)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 10:51:44 +01:00
14 changed files with 423 additions and 428 deletions

View File

@@ -46,6 +46,9 @@ const config: KnipConfig = {
'.github/workflows/ci-oss-assets-validation.yaml',
// Pending integration in stacked PR
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue',
// Pending integration - accessible color picker components
'src/components/ui/color-picker/ColorPickerSaturationValue.vue',
'src/components/ui/color-picker/ColorPickerSlider.vue',
// Agent review check config, not part of the build
'.agents/checks/eslint.strict.config.js'
],

View File

@@ -34,7 +34,17 @@
</Button>
</div>
<div ref="actionbarContainerRef" :class="actionbarContainerClass">
<div
ref="actionbarContainerRef"
:class="
cn(
'actionbar-container pointer-events-auto relative flex h-12 items-center gap-2 rounded-lg border bg-comfy-menu-bg px-2 shadow-interface',
hasAnyError
? 'border-destructive-background-hover'
: 'border-interface-stroke'
)
"
>
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
@@ -45,7 +55,6 @@
<ComfyActionbar
:top-menu-container="actionbarContainerRef"
:queue-overlay-expanded="isQueueOverlayExpanded"
:has-any-error="hasAnyError"
@update:progress-target="updateProgressTarget"
/>
<CurrentUserButton
@@ -114,7 +123,7 @@
</template>
<script setup lang="ts">
import { useLocalStorage, useMutationObserver } from '@vueuse/core'
import { useLocalStorage } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -136,7 +145,6 @@ import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useActionBarButtonStore } from '@/stores/actionBarButtonStore'
import { useQueueUIStore } from '@/stores/queueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
@@ -160,7 +168,6 @@ const { isLoggedIn } = useCurrentUser()
const { t } = useI18n()
const { toastErrorHandler } = useErrorHandling()
const executionErrorStore = useExecutionErrorStore()
const actionBarButtonStore = useActionBarButtonStore()
const queueUIStore = useQueueUIStore()
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
const { shouldShowRedDot: shouldShowConflictRedDot } =
@@ -175,43 +182,6 @@ const isActionbarEnabled = computed(
const isActionbarFloating = computed(
() => isActionbarEnabled.value && !isActionbarDocked.value
)
/**
* Whether the actionbar container has any visible docked buttons
* (excluding ComfyActionbar, which uses position:fixed when floating
* and does not contribute to the container's visual layout).
*/
const hasDockedButtons = computed(() => {
if (actionBarButtonStore.buttons.length > 0) return true
if (hasLegacyContent.value) return true
if (isLoggedIn.value && !isIntegratedTabBar.value) return true
if (isDesktop && !isIntegratedTabBar.value) return true
if (isCloud && flags.workflowSharingEnabled) return true
if (!isRightSidePanelOpen.value) return true
return false
})
const isActionbarContainerEmpty = computed(
() => isActionbarFloating.value && !hasDockedButtons.value
)
const actionbarContainerClass = computed(() => {
const base =
'actionbar-container pointer-events-auto relative flex h-12 items-center gap-2 rounded-lg border bg-comfy-menu-bg shadow-interface'
if (isActionbarContainerEmpty.value) {
return cn(
base,
'-ml-2 w-0 min-w-0 border-transparent shadow-none',
'has-[.border-dashed]:ml-0 has-[.border-dashed]:w-auto has-[.border-dashed]:min-w-auto',
'has-[.border-dashed]:border-interface-stroke has-[.border-dashed]:pl-2 has-[.border-dashed]:shadow-interface'
)
}
const borderClass =
!isActionbarFloating.value && hasAnyError.value
? 'border-destructive-background-hover'
: 'border-interface-stroke'
return cn(base, 'px-2', borderClass)
})
const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') !== 'Legacy'
)
@@ -263,25 +233,6 @@ const rightSidePanelTooltipConfig = computed(() =>
// Maintain support for legacy topbar elements attached by custom scripts
const legacyCommandsContainerRef = ref<HTMLElement>()
const hasLegacyContent = ref(false)
function checkLegacyContent() {
const el = legacyCommandsContainerRef.value
if (!el) {
hasLegacyContent.value = false
return
}
// Mirror the CSS: [&:not(:has(*>*:not(:empty)))]:hidden
hasLegacyContent.value =
el.querySelector(':scope > * > *:not(:empty)') !== null
}
useMutationObserver(legacyCommandsContainerRef, checkLegacyContent, {
childList: true,
subtree: true,
characterData: true
})
onMounted(() => {
if (legacyCommandsContainerRef.value) {
app.menu.element.style.width = 'fit-content'

View File

@@ -119,14 +119,9 @@ import { cn } from '@/utils/tailwindUtil'
import ComfyRunButton from './ComfyRunButton'
const {
topMenuContainer,
queueOverlayExpanded = false,
hasAnyError = false
} = defineProps<{
const { topMenuContainer, queueOverlayExpanded = false } = defineProps<{
topMenuContainer?: HTMLElement | null
queueOverlayExpanded?: boolean
hasAnyError?: boolean
}>()
const emit = defineEmits<{
@@ -440,12 +435,7 @@ const panelClass = computed(() =>
isDragging.value && 'pointer-events-none select-none',
isDocked.value
? 'static border-none bg-transparent p-0'
: [
'fixed shadow-interface',
hasAnyError
? 'border-destructive-background-hover'
: 'border-interface-stroke'
]
: 'fixed shadow-interface'
)
)
</script>

View File

@@ -170,9 +170,9 @@
</div>
</template>
</SidebarTabTemplate>
<MediaViewer
<ResultGallery
v-model:active-index="galleryActiveIndex"
:items="previewableVisibleAssets"
:all-gallery-items="galleryItems"
/>
<MediaAssetContextMenu
v-if="contextMenuAsset"
@@ -221,12 +221,12 @@ import AssetsSidebarGridView from '@/components/sidebar/tabs/AssetsSidebarGridVi
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
import Button from '@/components/ui/button/Button.vue'
import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
import MediaViewer from '@/platform/assets/components/MediaViewer.vue'
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
@@ -240,6 +240,7 @@ import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
import { isCloud } from '@/platform/distribution/types'
import { useDialogStore } from '@/stores/dialogStore'
import { ResultItemImpl } from '@/stores/queueStore'
import {
formatDuration,
getMediaTypeFromFilename,
@@ -453,6 +454,28 @@ watch(galleryActiveIndex, (index) => {
}
})
const galleryItems = computed(() => {
return previewableVisibleAssets.value.map((asset) => {
const mediaType = getMediaTypeFromFilename(asset.name)
const resultItem = new ResultItemImpl({
filename: asset.name,
subfolder: '',
type: 'output',
nodeId: '0',
mediaType: mediaType === 'image' ? 'images' : mediaType
})
Object.defineProperty(resultItem, 'url', {
get() {
return asset.preview_url || ''
},
configurable: true
})
return resultItem
})
})
const refreshAssets = async () => {
await currentAssets.value.fetchMediaList()
if (error.value) {

View File

@@ -0,0 +1,94 @@
<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.saturation')}: ${saturation}%, ${t('colorPicker.brightness')}: ${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

@@ -483,6 +483,13 @@
"black": "Black",
"custom": "Custom"
},
"colorPicker": {
"saturationValue": "Saturation and brightness",
"saturation": "Saturation",
"brightness": "Brightness",
"hue": "Hue",
"alpha": "Alpha"
},
"contextMenu": {
"Inputs": "Inputs",
"Outputs": "Outputs",

View File

@@ -1,16 +1,17 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import { useMediaAssetGalleryStore } from '../composables/useMediaAssetGalleryStore'
import type { AssetItem } from '../schemas/assetSchema'
import MediaAssetCard from './MediaAssetCard.vue'
import MediaViewer from './MediaViewer.vue'
const meta: Meta<typeof MediaAssetCard> = {
title: 'Platform/Assets/MediaAssetCard',
component: MediaAssetCard,
decorators: [
() => ({
components: { MediaViewer },
components: { ResultGallery },
setup() {
const galleryStore = useMediaAssetGalleryStore()
return { galleryStore }
@@ -18,9 +19,9 @@ const meta: Meta<typeof MediaAssetCard> = {
template: `
<div>
<story />
<MediaViewer
<ResultGallery
v-model:active-index="galleryStore.activeIndex"
:items="galleryStore.items"
:all-gallery-items="galleryStore.items"
/>
</div>
`

View File

@@ -1,135 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { AssetItem } from '../schemas/assetSchema'
import MediaViewer from './MediaViewer.vue'
const SAMPLE_MEDIA = {
image1: 'https://i.imgur.com/OB0y6MR.jpg',
image2: 'https://i.imgur.com/CzXTtJV.jpg',
image3: 'https://farm9.staticflickr.com/8505/8441256181_4e98d8bff5_z_d.jpg',
video:
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
audio: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3'
}
function makeAsset(
overrides: Partial<AssetItem> & { id: string; name: string }
): AssetItem {
return {
size: 0,
created_at: new Date().toISOString(),
tags: ['output'],
preview_url: '',
...overrides
}
}
const singleImage: AssetItem[] = [
makeAsset({
id: 'img-1',
name: 'landscape.jpg',
preview_url: SAMPLE_MEDIA.image1
})
]
const multipleImages: AssetItem[] = [
makeAsset({
id: 'img-1',
name: 'landscape.jpg',
preview_url: SAMPLE_MEDIA.image1
}),
makeAsset({
id: 'img-2',
name: 'portrait.jpg',
preview_url: SAMPLE_MEDIA.image2
}),
makeAsset({
id: 'img-3',
name: 'nature.jpg',
preview_url: SAMPLE_MEDIA.image3
})
]
const mixedMedia: AssetItem[] = [
makeAsset({
id: 'img-1',
name: 'photo.jpg',
preview_url: SAMPLE_MEDIA.image1
}),
makeAsset({
id: 'vid-1',
name: 'clip.mp4',
preview_url: SAMPLE_MEDIA.video
}),
makeAsset({
id: 'aud-1',
name: 'song.mp3',
preview_url: SAMPLE_MEDIA.audio
})
]
const meta: Meta<typeof MediaViewer> = {
title: 'Platform/Assets/MediaViewer',
component: MediaViewer,
parameters: { layout: 'fullscreen' },
argTypes: {
activeIndex: { control: { type: 'number', min: -1, max: 10 } }
}
}
export default meta
type Story = StoryObj<typeof meta>
export const SingleImage: Story = {
args: {
items: singleImage,
activeIndex: 0
}
}
export const MultipleImages: Story = {
args: {
items: multipleImages,
activeIndex: 0
}
}
export const VideoPlayer: Story = {
args: {
items: [
makeAsset({
id: 'vid-1',
name: 'big-buck-bunny.mp4',
preview_url: SAMPLE_MEDIA.video
})
],
activeIndex: 0
}
}
export const AudioPlayer: Story = {
args: {
items: [
makeAsset({
id: 'aud-1',
name: 'soundtrack.mp3',
preview_url: SAMPLE_MEDIA.audio
})
],
activeIndex: 0
}
}
export const MixedMedia: Story = {
args: {
items: mixedMedia,
activeIndex: 0
}
}
export const Closed: Story = {
args: {
items: singleImage,
activeIndex: -1
}
}

View File

@@ -1,122 +0,0 @@
<template>
<DialogRoot :open="isOpen" @update:open="handleOpenChange">
<DialogPortal>
<DialogOverlay class="fixed inset-0 z-50 bg-black/80" />
<DialogContent
class="fixed inset-0 z-50 flex items-center justify-center outline-none"
:aria-describedby="undefined"
>
<VisuallyHidden as-child>
<DialogTitle>{{ currentItem?.name ?? '' }}</DialogTitle>
</VisuallyHidden>
<!-- Close button -->
<DialogClose
class="absolute top-4 right-4 z-10 cursor-pointer rounded-full bg-black/50 p-2 text-white hover:bg-black/70"
:aria-label="$t('g.close')"
>
<i class="icon-[lucide--x] size-5" />
</DialogClose>
<!-- Previous button -->
<button
v-if="hasMultiple"
class="absolute left-4 z-10 cursor-pointer rounded-full bg-black/50 p-2 text-white hover:bg-black/70"
@click="navigate(-1)"
>
<i class="icon-[lucide--chevron-left] size-6" />
</button>
<!-- Media content -->
<ComfyImage
v-if="currentMediaType === 'image'"
:key="currentItem?.id"
:src="currentItem?.preview_url ?? ''"
:contain="false"
:alt="currentItem?.name ?? ''"
class="max-h-screen max-w-full object-contain"
/>
<video
v-else-if="currentMediaType === 'video'"
:key="currentItem?.id"
:src="currentItem?.preview_url ?? ''"
controls
class="max-h-screen max-w-full"
/>
<audio
v-else-if="currentMediaType === 'audio'"
:key="currentItem?.id"
:src="currentItem?.preview_url ?? ''"
controls
/>
<!-- Next button -->
<button
v-if="hasMultiple"
class="absolute right-4 z-10 cursor-pointer rounded-full bg-black/50 p-2 text-white hover:bg-black/70"
@click="navigate(1)"
>
<i class="icon-[lucide--chevron-right] size-6" />
</button>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>
<script setup lang="ts">
import {
DialogClose,
DialogContent,
DialogOverlay,
DialogPortal,
DialogRoot,
DialogTitle,
VisuallyHidden
} from 'reka-ui'
import { computed, onMounted, onUnmounted } from 'vue'
import ComfyImage from '@/components/common/ComfyImage.vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
const { items = [], activeIndex = -1 } = defineProps<{
items?: AssetItem[]
activeIndex?: number
}>()
const emit = defineEmits<{
'update:activeIndex': [value: number]
}>()
const isOpen = computed(() => activeIndex >= 0 && activeIndex < items.length)
const currentItem = computed(() =>
isOpen.value ? items[activeIndex] : undefined
)
const currentMediaType = computed(() =>
currentItem.value ? getMediaTypeFromFilename(currentItem.value.name) : ''
)
const hasMultiple = computed(() => items.length > 1)
function handleOpenChange(open: boolean) {
if (!open) {
emit('update:activeIndex', -1)
}
}
function navigate(direction: number) {
const newIndex = (activeIndex + direction + items.length) % items.length
emit('update:activeIndex', newIndex)
}
function handleKeyDown(event: KeyboardEvent) {
if (!isOpen.value) return
if (event.key === 'ArrowLeft') navigate(-1)
else if (event.key === 'ArrowRight') navigate(1)
}
onMounted(() => window.addEventListener('keydown', handleKeyDown))
onUnmounted(() => window.removeEventListener('keydown', handleKeyDown))
</script>

View File

@@ -1,76 +1,161 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AssetItem } from '../schemas/assetSchema'
import { ResultItemImpl } from '@/stores/queueStore'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
import { useMediaAssetGalleryStore } from './useMediaAssetGalleryStore'
vi.mock('@/stores/queueStore', () => ({
ResultItemImpl: vi
.fn<typeof ResultItemImpl>()
.mockImplementation(function (data) {
Object.assign(this, {
...data,
url: ''
})
})
}))
describe('useMediaAssetGalleryStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
describe('openSingle', () => {
it('should set items and activeIndex', () => {
it('should convert AssetMeta to ResultItemImpl format', () => {
const store = useMediaAssetGalleryStore()
const asset: AssetItem = {
const mockAsset: AssetMeta = {
id: 'test-1',
name: 'test-image.png',
kind: 'image',
src: 'https://example.com/image.png',
size: 1024,
preview_url: 'https://example.com/image.png',
tags: ['output'],
created_at: '2025-01-01'
}
store.openSingle(asset)
expect(store.items).toHaveLength(1)
expect(store.items[0]).toBe(asset)
expect(store.activeIndex).toBe(0)
})
it('should preserve asset properties', () => {
const store = useMediaAssetGalleryStore()
const asset: AssetItem = {
id: 'test-2',
name: 'test-video.mp4',
size: 2048,
preview_url: 'https://example.com/video.mp4',
tags: ['output'],
created_at: '2025-01-01'
}
store.openSingle(asset)
expect(store.items[0].name).toBe('test-video.mp4')
expect(store.items[0].preview_url).toBe('https://example.com/video.mp4')
})
it('should replace previous items when called multiple times', () => {
const store = useMediaAssetGalleryStore()
const asset1: AssetItem = {
id: '1',
name: 'first.png',
size: 100,
preview_url: 'url1',
tags: [],
created_at: '2025-01-01'
}
const asset2: AssetItem = {
store.openSingle(mockAsset)
expect(ResultItemImpl).toHaveBeenCalledWith({
filename: 'test-image.png',
subfolder: '',
type: 'output',
nodeId: '0',
mediaType: 'images'
})
expect(store.items).toHaveLength(1)
expect(store.activeIndex).toBe(0)
})
it('should set correct mediaType for video assets', () => {
const store = useMediaAssetGalleryStore()
const mockVideoAsset: AssetMeta = {
id: 'test-2',
name: 'test-video.mp4',
kind: 'video',
src: 'https://example.com/video.mp4',
size: 2048,
tags: [],
created_at: '2025-01-01'
}
store.openSingle(mockVideoAsset)
expect(ResultItemImpl).toHaveBeenCalledWith(
expect.objectContaining({
filename: 'test-video.mp4',
mediaType: 'video'
})
)
})
it('should set correct mediaType for audio assets', () => {
const store = useMediaAssetGalleryStore()
const mockAudioAsset: AssetMeta = {
id: 'test-3',
name: 'test-audio.mp3',
kind: 'audio',
src: 'https://example.com/audio.mp3',
size: 512,
tags: [],
created_at: '2025-01-01'
}
store.openSingle(mockAudioAsset)
expect(ResultItemImpl).toHaveBeenCalledWith(
expect.objectContaining({
filename: 'test-audio.mp3',
mediaType: 'audio'
})
)
})
it('should override url getter with asset.src', () => {
const store = useMediaAssetGalleryStore()
const mockAsset: AssetMeta = {
id: 'test-4',
name: 'test.png',
kind: 'image',
src: 'https://example.com/custom-url.png',
size: 1024,
tags: [],
created_at: '2025-01-01'
}
store.openSingle(mockAsset)
const resultItem = store.items[0]
expect(resultItem.url).toBe('https://example.com/custom-url.png')
})
it('should handle assets without src gracefully', () => {
const store = useMediaAssetGalleryStore()
const mockAsset: AssetMeta = {
id: 'test-5',
name: 'no-src.png',
kind: 'image',
src: '',
size: 1024,
tags: [],
created_at: '2025-01-01'
}
store.openSingle(mockAsset)
const resultItem = store.items[0]
expect(resultItem.url).toBe('')
})
it('should update activeIndex and items when called multiple times', () => {
const store = useMediaAssetGalleryStore()
const asset1: AssetMeta = {
id: '1',
name: 'first.png',
kind: 'image',
src: 'url1',
size: 100,
tags: [],
created_at: '2025-01-01'
}
const asset2: AssetMeta = {
id: '2',
name: 'second.png',
kind: 'image',
src: 'url2',
size: 200,
preview_url: 'url2',
tags: [],
created_at: '2025-01-01'
}
store.openSingle(asset1)
expect(store.items).toHaveLength(1)
expect(store.items[0].name).toBe('first.png')
expect(store.items[0].filename).toBe('first.png')
store.openSingle(asset2)
expect(store.items).toHaveLength(1)
expect(store.items[0].name).toBe('second.png')
expect(store.items[0].filename).toBe('second.png')
expect(store.activeIndex).toBe(0)
})
})
@@ -78,16 +163,17 @@ describe('useMediaAssetGalleryStore', () => {
describe('close', () => {
it('should reset activeIndex to -1', () => {
const store = useMediaAssetGalleryStore()
const asset: AssetItem = {
const mockAsset: AssetMeta = {
id: 'test',
name: 'test.png',
kind: 'image',
src: 'test-url',
size: 1024,
preview_url: 'test-url',
tags: [],
created_at: '2025-01-01'
}
store.openSingle(asset)
store.openSingle(mockAsset)
expect(store.activeIndex).toBe(0)
store.close()

View File

@@ -1,20 +1,39 @@
import { defineStore } from 'pinia'
import { ref, shallowRef } from 'vue'
import type { AssetItem } from '../schemas/assetSchema'
import { ResultItemImpl } from '@/stores/queueStore'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
export const useMediaAssetGalleryStore = defineStore(
'mediaAssetGallery',
() => {
const activeIndex = ref(-1)
const items = shallowRef<AssetItem[]>([])
const items = shallowRef<ResultItemImpl[]>([])
const close = () => {
activeIndex.value = -1
}
const openSingle = (asset: AssetItem) => {
items.value = [asset]
const openSingle = (asset: AssetMeta) => {
// Convert AssetMeta to ResultItemImpl format
const resultItem = new ResultItemImpl({
filename: asset.name,
subfolder: '',
type: 'output',
nodeId: '0',
mediaType: asset.kind === 'image' ? 'images' : asset.kind
})
// Override the url getter to use asset.src
Object.defineProperty(resultItem, 'url', {
get() {
return asset.src || ''
},
configurable: true
})
items.value = [resultItem]
activeIndex.value = 0
}

View File

@@ -16,8 +16,7 @@ const zAsset = z.object({
is_immutable: z.boolean().optional(),
last_access_time: z.string().optional(),
metadata: z.record(z.unknown()).optional(), // API allows arbitrary key-value pairs
user_metadata: z.record(z.unknown()).optional(), // API allows arbitrary key-value pairs
prompt_id: z.string().nullish()
user_metadata: z.record(z.unknown()).optional() // API allows arbitrary key-value pairs
})
const zAssetResponse = z.object({

View File

@@ -31,9 +31,6 @@ export interface PaginationOptions {
interface AssetRequestOptions extends PaginationOptions {
includeTags: string[]
includePublic?: boolean
jobIds?: string[]
sort?: string
order?: 'asc' | 'desc'
}
interface AssetExportOptions {
@@ -205,10 +202,7 @@ function createAssetService() {
includeTags,
limit = DEFAULT_LIMIT,
offset,
includePublic,
jobIds,
sort,
order
includePublic
} = options
const queryParams = new URLSearchParams({
include_tags: includeTags.join(','),
@@ -220,15 +214,6 @@ function createAssetService() {
if (includePublic !== undefined) {
queryParams.set('include_public', includePublic ? 'true' : 'false')
}
if (jobIds?.length) {
queryParams.set('job_ids', jobIds.join(','))
}
if (sort) {
queryParams.set('sort', sort)
}
if (order) {
queryParams.set('order', order)
}
const url = `${ASSETS_ENDPOINT}?${queryParams.toString()}`
const res = await api.fetchApi(url)
@@ -769,25 +754,6 @@ function createAssetService() {
return await res.json()
}
async function getOutputAssets(
options?: PaginationOptions & { sort?: string; order?: 'asc' | 'desc' }
): Promise<AssetResponse> {
return handleAssetRequest(
{ includeTags: ['output'], ...options },
'output assets'
)
}
async function getAssetsByJobIds(
jobIds: string[],
options?: PaginationOptions
): Promise<AssetResponse> {
return handleAssetRequest(
{ includeTags: ['output'], jobIds, ...options },
'job assets'
)
}
return {
getAssetModelFolders,
getAssetModels,
@@ -806,9 +772,7 @@ function createAssetService() {
uploadAssetFromBase64,
uploadAssetAsync,
createAssetExport,
getExportDownloadUrl,
getOutputAssets,
getAssetsByJobIds
getExportDownloadUrl
}
}