mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-16 20:51:04 +00:00
Compare commits
3 Commits
test-cover
...
feat/clear
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3787e1fae9 | ||
|
|
d8305398ae | ||
|
|
75295960a3 |
@@ -94,6 +94,8 @@ export class AppModeHelper {
|
||||
|
||||
/** Enter builder mode via the "Workflow actions" dropdown. */
|
||||
async enterBuilder() {
|
||||
await this.page.keyboard.press('Escape')
|
||||
await this.comfyPage.nextFrame()
|
||||
await this.page
|
||||
.getByRole('button', { name: 'Workflow actions' })
|
||||
.first()
|
||||
|
||||
@@ -65,7 +65,10 @@ export class CanvasHelper {
|
||||
}
|
||||
|
||||
async doubleClick(): Promise<void> {
|
||||
await this.page.mouse.dblclick(10, 10, { delay: 5 })
|
||||
await this.canvas.dblclick({
|
||||
position: DefaultGraphPositions.emptySpaceClick,
|
||||
delay: 5
|
||||
})
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
|
||||
@@ -106,7 +106,6 @@
|
||||
<button :class="textButtonClass" @click="onInvert">
|
||||
{{ t('maskEditor.invert') }}
|
||||
</button>
|
||||
|
||||
<button :class="textButtonClass" @click="onClear">
|
||||
{{ t('maskEditor.clear') }}
|
||||
</button>
|
||||
|
||||
@@ -15,8 +15,14 @@ vi.mock('vue-i18n', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({ execute: vi.fn() })
|
||||
const mockOpenMaskEditor = vi.fn()
|
||||
|
||||
vi.mock('@/composables/maskeditor/useMaskEditor', () => ({
|
||||
useMaskEditor: () => ({
|
||||
openMaskEditor: mockOpenMaskEditor,
|
||||
clearMask: vi.fn(),
|
||||
isClearingMask: { value: false }
|
||||
})
|
||||
}))
|
||||
|
||||
function mockClipboard(clipboard: Partial<Clipboard> | undefined) {
|
||||
@@ -101,6 +107,17 @@ describe('useImageMenuOptions', () => {
|
||||
expect(copyIdx).toBeLessThan(pasteIdx)
|
||||
expect(pasteIdx).toBeLessThan(saveIdx)
|
||||
})
|
||||
|
||||
it('calls openMaskEditor with the node when Open in Mask Editor is chosen', () => {
|
||||
const node = createImageNode()
|
||||
const { getImageMenuOptions } = useImageMenuOptions()
|
||||
const options = getImageMenuOptions(node)
|
||||
const maskOption = options.find((o) => o.label === 'Open in Mask Editor')
|
||||
|
||||
maskOption!.action!()
|
||||
|
||||
expect(mockOpenMaskEditor).toHaveBeenCalledWith(node)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pasteImage action', () => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { downloadFile, openFileInNewTab } from '@/base/common/downloadUtil'
|
||||
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
import type { MenuOption } from './useMoreOptionsMenu'
|
||||
|
||||
@@ -41,10 +41,10 @@ async function pasteClipboardImageToNode(node: LGraphNode): Promise<void> {
|
||||
*/
|
||||
export function useImageMenuOptions() {
|
||||
const { t } = useI18n()
|
||||
const maskEditor = useMaskEditor()
|
||||
|
||||
const openMaskEditor = () => {
|
||||
const commandStore = useCommandStore()
|
||||
void commandStore.execute('Comfy.MaskEditor.OpenMaskEditor')
|
||||
const openMaskEditorForNode = (node: LGraphNode) => {
|
||||
maskEditor.openMaskEditor(node)
|
||||
}
|
||||
|
||||
const openImage = (node: LGraphNode) => {
|
||||
@@ -118,7 +118,7 @@ export function useImageMenuOptions() {
|
||||
options.push(
|
||||
{
|
||||
label: t('contextMenu.Open in Mask Editor'),
|
||||
action: () => openMaskEditor()
|
||||
action: () => openMaskEditorForNode(node)
|
||||
},
|
||||
{
|
||||
label: t('contextMenu.Open Image'),
|
||||
|
||||
@@ -1,9 +1,31 @@
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import TopBarHeader from '@/components/maskeditor/dialog/TopBarHeader.vue'
|
||||
import MaskEditorContent from '@/components/maskeditor/MaskEditorContent.vue'
|
||||
import { useMaskEditorDataStore } from '@/stores/maskEditorDataStore'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
import { useMaskEditorLoader } from '@/composables/maskeditor/useMaskEditorLoader'
|
||||
import { useMaskEditorSaver } from '@/composables/maskeditor/useMaskEditorSaver'
|
||||
import { useCanvasTools } from '@/composables/maskeditor/useCanvasTools'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const useSharedCanvasTools = createSharedComposable(useCanvasTools)
|
||||
|
||||
const sharedIsClearingMask = ref(false)
|
||||
|
||||
export function useMaskEditor() {
|
||||
const isClearingMask = sharedIsClearingMask
|
||||
const toastStore = useToastStore()
|
||||
const { t } = useI18n()
|
||||
const dataStore = useMaskEditorDataStore()
|
||||
const editorStore = useMaskEditorStore()
|
||||
const loader = useMaskEditorLoader()
|
||||
const saver = useMaskEditorSaver()
|
||||
const canvasTools = useSharedCanvasTools()
|
||||
const openMaskEditor = (node: LGraphNode) => {
|
||||
if (!node) {
|
||||
console.error('[MaskEditor] No node provided')
|
||||
@@ -42,7 +64,93 @@ export function useMaskEditor() {
|
||||
})
|
||||
}
|
||||
|
||||
const clearMask = async (node: LGraphNode) => {
|
||||
if (!node) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isClearingMask.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
if (dialogStore.isDialogOpen('global-mask-editor')) {
|
||||
console.warn(
|
||||
'[MaskEditor] Cannot clear mask while the mask editor is open'
|
||||
)
|
||||
toastStore.add({
|
||||
severity: 'warn',
|
||||
summary: t('maskEditor.cannotClearWhenOpenSummary'),
|
||||
detail: t('maskEditor.cannotClearWhenOpenDetail'),
|
||||
life: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
isClearingMask.value = true
|
||||
|
||||
try {
|
||||
await loader.loadFromNode(node)
|
||||
|
||||
if (!dataStore.inputData) throw new Error('Failed to load image data')
|
||||
|
||||
if (!editorStore.maskCanvas) {
|
||||
const { image } = dataStore.inputData.baseLayer
|
||||
const width = image.naturalWidth || image.width || 1
|
||||
const height = image.naturalHeight || image.height || 1
|
||||
|
||||
const imgCanvas = document.createElement('canvas')
|
||||
imgCanvas.width = width
|
||||
imgCanvas.height = height
|
||||
const imgCtx = imgCanvas.getContext('2d')
|
||||
if (!imgCtx) {
|
||||
throw new Error('Failed to get image canvas context')
|
||||
}
|
||||
imgCtx.drawImage(image, 0, 0)
|
||||
|
||||
const maskCanvas = document.createElement('canvas')
|
||||
maskCanvas.width = width
|
||||
maskCanvas.height = height
|
||||
|
||||
const rgbCanvas = document.createElement('canvas')
|
||||
rgbCanvas.width = width
|
||||
rgbCanvas.height = height
|
||||
|
||||
editorStore.imgCanvas = imgCanvas
|
||||
editorStore.maskCanvas = maskCanvas
|
||||
editorStore.rgbCanvas = rgbCanvas
|
||||
const maskCtx = maskCanvas.getContext('2d', {
|
||||
willReadFrequently: true
|
||||
})
|
||||
const rgbCtx = rgbCanvas.getContext('2d', {
|
||||
willReadFrequently: true
|
||||
})
|
||||
if (!maskCtx || !rgbCtx) {
|
||||
throw new Error('Failed to get mask or RGB canvas context')
|
||||
}
|
||||
editorStore.maskCtx = maskCtx
|
||||
editorStore.rgbCtx = rgbCtx
|
||||
editorStore.imgCtx = imgCtx
|
||||
}
|
||||
|
||||
canvasTools.clearMask()
|
||||
await saver.save()
|
||||
} finally {
|
||||
const dialogOpen = dialogStore.isDialogOpen('global-mask-editor')
|
||||
if (!dialogOpen) {
|
||||
editorStore.imgCanvas = null
|
||||
editorStore.maskCanvas = null
|
||||
editorStore.rgbCanvas = null
|
||||
dataStore.reset()
|
||||
editorStore.resetState()
|
||||
}
|
||||
isClearingMask.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
openMaskEditor
|
||||
isClearingMask,
|
||||
openMaskEditor,
|
||||
clearMask
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"downloadVideo": "Download video",
|
||||
"downloadAudio": "Download audio",
|
||||
"editOrMaskImage": "Edit or mask image",
|
||||
"clearMask": "Clear mask",
|
||||
"editImage": "Edit image",
|
||||
"decrement": "Decrement",
|
||||
"deleteImage": "Delete image",
|
||||
@@ -1194,6 +1195,8 @@
|
||||
"maskEditor": {
|
||||
"title": "Mask Editor",
|
||||
"openMaskEditor": "Open in Mask Editor",
|
||||
"cannotClearWhenOpenSummary": "Warning",
|
||||
"cannotClearWhenOpenDetail": "Please close the mask editor before clearing masks.",
|
||||
"invert": "Invert",
|
||||
"clear": "Clear",
|
||||
"undo": "Undo",
|
||||
@@ -1233,7 +1236,9 @@
|
||||
"baseLayerPreview": "Base layer preview",
|
||||
"black": "Black",
|
||||
"white": "White",
|
||||
"negative": "Negative"
|
||||
"negative": "Negative",
|
||||
"clearMaskError": "Clear Mask Error",
|
||||
"clearMaskFailed": "Failed to clear mask. Please try again."
|
||||
},
|
||||
"commands": {
|
||||
"runWorkflow": "Run workflow",
|
||||
|
||||
@@ -1,16 +1,39 @@
|
||||
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
|
||||
/* eslint-disable testing-library/prefer-user-event */
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen, fireEvent } from '@testing-library/vue'
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor
|
||||
} from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
const mockClearMask = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
|
||||
|
||||
vi.mock('@/composables/maskeditor/useMaskEditor', async () => {
|
||||
const { ref } = await import('vue')
|
||||
return {
|
||||
useMaskEditor: () => ({
|
||||
openMaskEditor: vi.fn(),
|
||||
clearMask: mockClearMask,
|
||||
isClearingMask: ref(false)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/utils/litegraphUtil', () => ({
|
||||
resolveNode: vi.fn(() => undefined)
|
||||
}))
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import ImagePreview from '@/renderer/extensions/vueNodes/components/ImagePreview.vue'
|
||||
import { resolveNode } from '@/utils/litegraphUtil'
|
||||
|
||||
// Mock downloadFile to avoid DOM errors
|
||||
vi.mock('@/base/common/downloadUtil', () => ({
|
||||
downloadFile: vi.fn()
|
||||
}))
|
||||
@@ -35,55 +58,67 @@ const i18n = createI18n({
|
||||
unknownFile: 'Unknown file',
|
||||
loading: 'Loading',
|
||||
viewGrid: 'Grid view',
|
||||
galleryThumbnail: 'Gallery thumbnail'
|
||||
galleryThumbnail: 'Gallery thumbnail',
|
||||
clearMask: 'Clear mask',
|
||||
imageGallery: 'image gallery'
|
||||
},
|
||||
maskEditor: {
|
||||
clearMaskError: 'Clear Mask Error',
|
||||
clearMaskFailed: 'Failed to clear mask. Please try again.'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('ImagePreview', () => {
|
||||
const defaultProps = {
|
||||
imageUrls: [
|
||||
'/api/view?filename=test1.png&type=output',
|
||||
'/api/view?filename=test2.png&type=output'
|
||||
]
|
||||
}
|
||||
const defaultProps = {
|
||||
imageUrls: [
|
||||
'/api/view?filename=test1.png&type=output',
|
||||
'/api/view?filename=test2.png&type=output'
|
||||
]
|
||||
}
|
||||
|
||||
function renderImagePreview(props = {}) {
|
||||
return render(ImagePreview, {
|
||||
props: { ...defaultProps, ...props },
|
||||
global: {
|
||||
plugins: [
|
||||
createTestingPinia({
|
||||
createSpy: vi.fn
|
||||
}),
|
||||
i18n
|
||||
],
|
||||
stubs: {
|
||||
'i-lucide:venetian-mask': true,
|
||||
'i-lucide:download': true,
|
||||
'i-lucide:x': true,
|
||||
'i-lucide:image-off': true,
|
||||
Skeleton: true
|
||||
}
|
||||
function renderImagePreview(props: Record<string, unknown> = {}) {
|
||||
return render(ImagePreview, {
|
||||
props: { ...defaultProps, ...props },
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn }), i18n],
|
||||
stubs: {
|
||||
'i-comfy:mask': true,
|
||||
'i-lucide:venetian-mask': true,
|
||||
'i-lucide:download': true,
|
||||
'i-lucide:x': true,
|
||||
'i-lucide:image-off': true,
|
||||
Skeleton: true
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function switchToGallery(user: ReturnType<typeof userEvent.setup>) {
|
||||
const thumbnails = screen.getAllByRole('button', { name: /^View image/ })
|
||||
await user.click(thumbnails[0])
|
||||
await nextTick()
|
||||
}
|
||||
async function switchToGallery(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(screen.getByRole('button', { name: 'View image 1 of 2' }))
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
function viewImageNavButtons() {
|
||||
return screen.getAllByRole('button', {
|
||||
name: /View image \d+ of \d+/
|
||||
})
|
||||
}
|
||||
|
||||
describe('ImagePreview', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(resolveNode).mockReturnValue(undefined)
|
||||
})
|
||||
|
||||
it('does not render when no imageUrls provided', () => {
|
||||
const { container } = renderImagePreview({ imageUrls: [] })
|
||||
renderImagePreview({ imageUrls: [] })
|
||||
|
||||
expect(container.querySelector('.image-preview')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('image-preview-root')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays calculating dimensions text in gallery mode', async () => {
|
||||
@@ -91,18 +126,15 @@ describe('ImagePreview', () => {
|
||||
imageUrls: [defaultProps.imageUrls[0]]
|
||||
})
|
||||
|
||||
screen.getByText('Calculating dimensions')
|
||||
expect(screen.getByText('Calculating dimensions')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows navigation dots for multiple images in gallery mode', async () => {
|
||||
renderImagePreview()
|
||||
const user = userEvent.setup()
|
||||
renderImagePreview()
|
||||
await switchToGallery(user)
|
||||
|
||||
const navigationDots = screen.getAllByRole('button', {
|
||||
name: /View image/
|
||||
})
|
||||
expect(navigationDots).toHaveLength(2)
|
||||
expect(viewImageNavButtons()).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('does not show navigation dots for single image', () => {
|
||||
@@ -110,85 +142,92 @@ describe('ImagePreview', () => {
|
||||
imageUrls: [defaultProps.imageUrls[0]]
|
||||
})
|
||||
|
||||
const navigationDots = screen.queryAllByRole('button', {
|
||||
name: /View image/
|
||||
})
|
||||
expect(navigationDots).toHaveLength(0)
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /View image \d+ of \d+/ })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show mask/edit button for multiple images in gallery mode', async () => {
|
||||
renderImagePreview()
|
||||
it('shows mask/edit button only for single images', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderImagePreview()
|
||||
await switchToGallery(user)
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Edit or mask image' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows mask/edit button for single images', () => {
|
||||
cleanup()
|
||||
renderImagePreview({
|
||||
imageUrls: [defaultProps.imageUrls[0]]
|
||||
})
|
||||
|
||||
screen.getByRole('button', { name: 'Edit or mask image' })
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Edit or mask image' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles download button click', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderImagePreview({
|
||||
imageUrls: [defaultProps.imageUrls[0]]
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
|
||||
const downloadButton = screen.getByRole('button', {
|
||||
name: 'Download image'
|
||||
})
|
||||
await user.click(downloadButton)
|
||||
await user.click(screen.getByRole('button', { name: 'Download image' }))
|
||||
|
||||
expect(downloadFile).toHaveBeenCalledWith(defaultProps.imageUrls[0])
|
||||
})
|
||||
|
||||
it('switches images when navigation dots are clicked', async () => {
|
||||
renderImagePreview()
|
||||
it('calls clearMask with resolved node when clear mask is clicked', async () => {
|
||||
const stubNode = { id: 99, imgs: [] } as unknown as LGraphNode
|
||||
vi.mocked(resolveNode).mockReturnValue(stubNode)
|
||||
|
||||
const user = userEvent.setup()
|
||||
renderImagePreview({
|
||||
imageUrls: [defaultProps.imageUrls[0]],
|
||||
nodeId: '99'
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Clear mask' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockClearMask).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(mockClearMask).toHaveBeenCalledWith(stubNode)
|
||||
})
|
||||
|
||||
it('switches images when navigation dots are clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderImagePreview()
|
||||
await switchToGallery(user)
|
||||
|
||||
// Initially shows first image
|
||||
expect(screen.getByRole('img')).toHaveAttribute(
|
||||
expect(screen.getByTestId('main-image')).toHaveAttribute(
|
||||
'src',
|
||||
defaultProps.imageUrls[0]
|
||||
)
|
||||
|
||||
// Click second navigation dot
|
||||
const navigationDots = screen.getAllByRole('button', {
|
||||
name: /View image/
|
||||
})
|
||||
const navigationDots = viewImageNavButtons()
|
||||
await user.click(navigationDots[1])
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByRole('img')).toHaveAttribute(
|
||||
expect(screen.getByTestId('main-image')).toHaveAttribute(
|
||||
'src',
|
||||
defaultProps.imageUrls[1]
|
||||
)
|
||||
})
|
||||
|
||||
it('marks active navigation dot with aria-current', async () => {
|
||||
renderImagePreview()
|
||||
const user = userEvent.setup()
|
||||
renderImagePreview()
|
||||
await switchToGallery(user)
|
||||
|
||||
const navigationDots = screen.getAllByRole('button', {
|
||||
name: /View image/
|
||||
})
|
||||
const navigationDots = viewImageNavButtons()
|
||||
|
||||
// First dot should be active
|
||||
expect(navigationDots[0]).toHaveAttribute('aria-current', 'true')
|
||||
expect(navigationDots[1]).not.toHaveAttribute('aria-current')
|
||||
|
||||
await user.click(navigationDots[1])
|
||||
await nextTick()
|
||||
|
||||
// Second dot should now be active
|
||||
expect(navigationDots[0]).not.toHaveAttribute('aria-current')
|
||||
expect(navigationDots[1]).toHaveAttribute('aria-current', 'true')
|
||||
})
|
||||
@@ -198,34 +237,36 @@ describe('ImagePreview', () => {
|
||||
imageUrls: [defaultProps.imageUrls[0]]
|
||||
})
|
||||
|
||||
expect(screen.getByRole('img')).toHaveAttribute('alt', 'View image 1 of 1')
|
||||
expect(screen.getByRole('img', { name: 'View image 1 of 1' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('updates alt text when switching images', async () => {
|
||||
renderImagePreview()
|
||||
const user = userEvent.setup()
|
||||
renderImagePreview()
|
||||
await switchToGallery(user)
|
||||
|
||||
expect(screen.getByRole('img')).toHaveAttribute('alt', 'View image 1 of 2')
|
||||
expect(screen.getByTestId('main-image')).toHaveAttribute(
|
||||
'alt',
|
||||
'View image 1 of 2'
|
||||
)
|
||||
|
||||
// Switch to second image
|
||||
const navigationDots = screen.getAllByRole('button', {
|
||||
name: /View image/
|
||||
})
|
||||
const navigationDots = viewImageNavButtons()
|
||||
await user.click(navigationDots[1])
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByRole('img')).toHaveAttribute('alt', 'View image 2 of 2')
|
||||
expect(screen.getByTestId('main-image')).toHaveAttribute(
|
||||
'alt',
|
||||
'View image 2 of 2'
|
||||
)
|
||||
})
|
||||
|
||||
describe('keyboard navigation', () => {
|
||||
it('navigates to next image with ArrowRight', async () => {
|
||||
const { container } = renderImagePreview()
|
||||
const user = userEvent.setup()
|
||||
renderImagePreview()
|
||||
await switchToGallery(user)
|
||||
|
||||
const preview = container.querySelector('.image-preview') as HTMLElement
|
||||
await fireEvent.keyDown(preview, { key: 'ArrowRight' })
|
||||
await user.keyboard('{ArrowRight}')
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('main-image')).toHaveAttribute(
|
||||
@@ -235,15 +276,13 @@ describe('ImagePreview', () => {
|
||||
})
|
||||
|
||||
it('navigates to previous image with ArrowLeft', async () => {
|
||||
const { container } = renderImagePreview()
|
||||
const user = userEvent.setup()
|
||||
renderImagePreview()
|
||||
await switchToGallery(user)
|
||||
|
||||
const preview = container.querySelector('.image-preview') as HTMLElement
|
||||
await fireEvent.keyDown(preview, { key: 'ArrowRight' })
|
||||
await user.keyboard('{ArrowRight}')
|
||||
await nextTick()
|
||||
|
||||
await fireEvent.keyDown(preview, { key: 'ArrowLeft' })
|
||||
await user.keyboard('{ArrowLeft}')
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('main-image')).toHaveAttribute(
|
||||
@@ -253,14 +292,13 @@ describe('ImagePreview', () => {
|
||||
})
|
||||
|
||||
it('wraps around from last to first with ArrowRight', async () => {
|
||||
const { container } = renderImagePreview()
|
||||
const user = userEvent.setup()
|
||||
renderImagePreview()
|
||||
await switchToGallery(user)
|
||||
|
||||
const preview = container.querySelector('.image-preview') as HTMLElement
|
||||
await fireEvent.keyDown(preview, { key: 'ArrowRight' })
|
||||
await user.keyboard('{ArrowRight}')
|
||||
await nextTick()
|
||||
await fireEvent.keyDown(preview, { key: 'ArrowRight' })
|
||||
await user.keyboard('{ArrowRight}')
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('main-image')).toHaveAttribute(
|
||||
@@ -270,12 +308,11 @@ describe('ImagePreview', () => {
|
||||
})
|
||||
|
||||
it('wraps around from first to last with ArrowLeft', async () => {
|
||||
const { container } = renderImagePreview()
|
||||
const user = userEvent.setup()
|
||||
renderImagePreview()
|
||||
await switchToGallery(user)
|
||||
|
||||
const preview = container.querySelector('.image-preview') as HTMLElement
|
||||
await fireEvent.keyDown(preview, { key: 'ArrowLeft' })
|
||||
await user.keyboard('{ArrowLeft}')
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('main-image')).toHaveAttribute(
|
||||
@@ -285,15 +322,13 @@ describe('ImagePreview', () => {
|
||||
})
|
||||
|
||||
it('navigates to first image with Home', async () => {
|
||||
const { container } = renderImagePreview()
|
||||
const user = userEvent.setup()
|
||||
renderImagePreview()
|
||||
await switchToGallery(user)
|
||||
|
||||
const preview = container.querySelector('.image-preview') as HTMLElement
|
||||
await fireEvent.keyDown(preview, { key: 'ArrowRight' })
|
||||
await user.keyboard('{ArrowRight}')
|
||||
await nextTick()
|
||||
|
||||
await fireEvent.keyDown(preview, { key: 'Home' })
|
||||
await user.keyboard('{Home}')
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('main-image')).toHaveAttribute(
|
||||
@@ -303,12 +338,11 @@ describe('ImagePreview', () => {
|
||||
})
|
||||
|
||||
it('navigates to last image with End', async () => {
|
||||
const { container } = renderImagePreview()
|
||||
const user = userEvent.setup()
|
||||
renderImagePreview()
|
||||
await switchToGallery(user)
|
||||
|
||||
const preview = container.querySelector('.image-preview') as HTMLElement
|
||||
await fireEvent.keyDown(preview, { key: 'End' })
|
||||
await user.keyboard('{End}')
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('main-image')).toHaveAttribute(
|
||||
@@ -318,31 +352,38 @@ describe('ImagePreview', () => {
|
||||
})
|
||||
|
||||
it('ignores arrow keys in grid mode', async () => {
|
||||
const { container } = renderImagePreview()
|
||||
renderImagePreview()
|
||||
|
||||
const gridThumbnails = screen.getAllByRole('button', {
|
||||
name: /^View image/
|
||||
})
|
||||
expect(gridThumbnails).toHaveLength(2)
|
||||
expect(
|
||||
screen.getAllByRole('button', { name: /View image \d+ of 2/ })
|
||||
).toHaveLength(2)
|
||||
|
||||
const preview = container.querySelector('.image-preview') as HTMLElement
|
||||
await fireEvent.keyDown(preview, { key: 'ArrowRight' })
|
||||
const root = screen.getByTestId('image-preview-root')
|
||||
// Thumbnail click opens gallery; grid-mode arrows are a no-op on the shell without moving focus into it.
|
||||
// eslint-disable-next-line testing-library/prefer-user-event -- need keydown on root while view stays grid
|
||||
void fireEvent.keyDown(root, { key: 'ArrowRight' })
|
||||
await nextTick()
|
||||
|
||||
expect(screen.queryByRole('region')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ignores arrow keys for single image', async () => {
|
||||
const { container } = renderImagePreview({
|
||||
const user = userEvent.setup()
|
||||
renderImagePreview({
|
||||
imageUrls: [defaultProps.imageUrls[0]]
|
||||
})
|
||||
|
||||
const initialSrc = screen.getByRole('img').getAttribute('src')
|
||||
const preview = container.querySelector('.image-preview') as HTMLElement
|
||||
await fireEvent.keyDown(preview, { key: 'ArrowRight' })
|
||||
const img = screen.getByRole('img')
|
||||
const initialSrc = img.getAttribute('src')
|
||||
await user.click(
|
||||
screen.getByRole('region', {
|
||||
name: 'Image preview - Use arrow keys to navigate between images'
|
||||
})
|
||||
)
|
||||
await user.keyboard('{ArrowRight}')
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByRole('img')).toHaveAttribute('src', initialSrc!)
|
||||
expect(screen.getByRole('img').getAttribute('src')).toBe(initialSrc)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -350,10 +391,9 @@ describe('ImagePreview', () => {
|
||||
it('defaults to grid mode for multiple images', () => {
|
||||
renderImagePreview()
|
||||
|
||||
const gridThumbnails = screen.getAllByRole('button', {
|
||||
name: /^View image/
|
||||
})
|
||||
expect(gridThumbnails).toHaveLength(2)
|
||||
expect(
|
||||
screen.getAllByRole('button', { name: /View image \d+ of 2/ })
|
||||
).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('defaults to gallery mode for single image', () => {
|
||||
@@ -361,60 +401,65 @@ describe('ImagePreview', () => {
|
||||
imageUrls: [defaultProps.imageUrls[0]]
|
||||
})
|
||||
|
||||
screen.getByRole('region')
|
||||
const gridThumbnails = screen.queryAllByRole('button', {
|
||||
name: /^View image/
|
||||
})
|
||||
expect(gridThumbnails).toHaveLength(0)
|
||||
expect(
|
||||
screen.getByRole('region', {
|
||||
name: 'Image preview - Use arrow keys to navigate between images'
|
||||
})
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /View image \d+ of 1/ })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('switches to gallery mode when grid thumbnail is clicked', async () => {
|
||||
renderImagePreview()
|
||||
const user = userEvent.setup()
|
||||
renderImagePreview()
|
||||
|
||||
const thumbnails = screen.getAllByRole('button', {
|
||||
name: /^View image/
|
||||
name: /View image \d+ of 2/
|
||||
})
|
||||
await user.click(thumbnails[1])
|
||||
await nextTick()
|
||||
|
||||
const mainImg = screen.getByTestId('main-image')
|
||||
expect(mainImg).toBeInTheDocument()
|
||||
expect(mainImg).toHaveAttribute('src', defaultProps.imageUrls[1])
|
||||
})
|
||||
|
||||
it('shows back-to-grid button next to navigation dots', async () => {
|
||||
renderImagePreview()
|
||||
const user = userEvent.setup()
|
||||
renderImagePreview()
|
||||
await switchToGallery(user)
|
||||
|
||||
const gridButtons = screen.getAllByRole('button', { name: 'Grid view' })
|
||||
expect(gridButtons.length).toBeGreaterThanOrEqual(1)
|
||||
expect(
|
||||
screen.getAllByRole('button', { name: 'Grid view' })[0]
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('switches back to grid mode via back-to-grid button', async () => {
|
||||
renderImagePreview()
|
||||
const user = userEvent.setup()
|
||||
renderImagePreview()
|
||||
await switchToGallery(user)
|
||||
|
||||
const gridButtons = screen.getAllByRole('button', { name: 'Grid view' })
|
||||
await user.click(gridButtons[0])
|
||||
await user.click(screen.getAllByRole('button', { name: 'Grid view' })[0])
|
||||
await nextTick()
|
||||
|
||||
const gridThumbnails = screen.getAllByRole('button', {
|
||||
name: /^View image/
|
||||
})
|
||||
expect(gridThumbnails).toHaveLength(2)
|
||||
expect(
|
||||
screen.getAllByRole('button', { name: /View image \d+ of 2/ })
|
||||
).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('resets to grid mode when URLs change to multiple images', async () => {
|
||||
const { rerender } = renderImagePreview()
|
||||
const user = userEvent.setup()
|
||||
const { rerender } = renderImagePreview()
|
||||
await switchToGallery(user)
|
||||
|
||||
// Verify we're in gallery mode
|
||||
screen.getByRole('region')
|
||||
expect(
|
||||
screen.getByRole('region', {
|
||||
name: 'Image preview - Use arrow keys to navigate between images'
|
||||
})
|
||||
).toBeInTheDocument()
|
||||
|
||||
// Change URLs
|
||||
await rerender({
|
||||
imageUrls: [
|
||||
'/api/view?filename=new1.png&type=output',
|
||||
@@ -424,47 +469,50 @@ describe('ImagePreview', () => {
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
// Should be back in grid mode
|
||||
const gridThumbnails = screen.getAllByRole('button', {
|
||||
name: /^View image/
|
||||
})
|
||||
expect(gridThumbnails).toHaveLength(3)
|
||||
expect(
|
||||
screen.getAllByRole('button', { name: /View image \d+ of 3/ })
|
||||
).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('batch cycling with identical URLs', () => {
|
||||
it('should not enter persistent loading state when cycling through identical images', async () => {
|
||||
vi.useFakeTimers()
|
||||
const user = userEvent.setup({
|
||||
advanceTimers: vi.advanceTimersByTime
|
||||
})
|
||||
try {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
const sameUrl = '/api/view?filename=test.png&type=output'
|
||||
const { container } = renderImagePreview({
|
||||
renderImagePreview({
|
||||
imageUrls: [sameUrl, sameUrl, sameUrl]
|
||||
})
|
||||
await switchToGallery(user)
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'View image 1 of 3' })
|
||||
)
|
||||
await nextTick()
|
||||
|
||||
// Simulate initial image load
|
||||
await fireEvent.load(screen.getByRole('img'))
|
||||
void fireEvent.load(screen.getByTestId('main-image'))
|
||||
await nextTick()
|
||||
expect(
|
||||
container.querySelector('[aria-busy="true"]')
|
||||
).not.toBeInTheDocument()
|
||||
screen
|
||||
.getByRole('region', {
|
||||
name: 'Image preview - Use arrow keys to navigate between images'
|
||||
})
|
||||
.getAttribute('aria-busy')
|
||||
).not.toBe('true')
|
||||
|
||||
// Click second navigation dot to cycle
|
||||
const dots = screen.getAllByRole('button', { name: /View image/ })
|
||||
const dots = viewImageNavButtons()
|
||||
await user.click(dots[1])
|
||||
await nextTick()
|
||||
|
||||
// Advance past the delayed loader timeout
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
await nextTick()
|
||||
|
||||
// Should NOT be in loading state since URL didn't change
|
||||
expect(
|
||||
container.querySelector('[aria-busy="true"]')
|
||||
).not.toBeInTheDocument()
|
||||
screen
|
||||
.getByRole('region', {
|
||||
name: 'Image preview - Use arrow keys to navigate between images'
|
||||
})
|
||||
.getAttribute('aria-busy')
|
||||
).not.toBe('true')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
@@ -474,37 +522,31 @@ describe('ImagePreview', () => {
|
||||
describe('URL change detection', () => {
|
||||
it('should NOT reset loading state when imageUrls prop is reassigned with identical URLs', async () => {
|
||||
vi.useFakeTimers()
|
||||
const user = userEvent.setup({
|
||||
advanceTimers: vi.advanceTimersByTime
|
||||
})
|
||||
try {
|
||||
const urls = ['/api/view?filename=test.png&type=output']
|
||||
const { container, rerender } = renderImagePreview({
|
||||
imageUrls: urls
|
||||
})
|
||||
void user
|
||||
const { rerender } = renderImagePreview({ imageUrls: urls })
|
||||
|
||||
// Simulate image load completing
|
||||
await fireEvent.load(screen.getByRole('img'))
|
||||
void fireEvent.load(screen.getByTestId('main-image'))
|
||||
await nextTick()
|
||||
|
||||
// Verify loader is hidden after load
|
||||
expect(
|
||||
container.querySelector('[aria-busy="true"]')
|
||||
).not.toBeInTheDocument()
|
||||
const region = screen.getByRole('region', {
|
||||
name: 'Image preview - Use arrow keys to navigate between images'
|
||||
})
|
||||
expect(region.getAttribute('aria-busy')).not.toBe('true')
|
||||
|
||||
// Reassign with new array reference but same content
|
||||
await rerender({ imageUrls: [...urls] })
|
||||
await nextTick()
|
||||
|
||||
// Advance past the 250ms delayed loader timeout
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
await nextTick()
|
||||
|
||||
// Loading state should NOT have been reset
|
||||
expect(
|
||||
container.querySelector('[aria-busy="true"]')
|
||||
).not.toBeInTheDocument()
|
||||
screen
|
||||
.getByRole('region', {
|
||||
name: 'Image preview - Use arrow keys to navigate between images'
|
||||
})
|
||||
.getAttribute('aria-busy')
|
||||
).not.toBe('true')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
@@ -512,55 +554,48 @@ describe('ImagePreview', () => {
|
||||
|
||||
it('should reset loading state when imageUrls prop changes to different URLs', async () => {
|
||||
vi.useFakeTimers()
|
||||
const user = userEvent.setup({
|
||||
advanceTimers: vi.advanceTimersByTime
|
||||
})
|
||||
try {
|
||||
const urls = ['/api/view?filename=test.png&type=output']
|
||||
const { container, rerender } = renderImagePreview({
|
||||
imageUrls: urls
|
||||
})
|
||||
const { rerender } = renderImagePreview({ imageUrls: urls })
|
||||
|
||||
// Simulate image load completing
|
||||
await fireEvent.load(screen.getByRole('img'))
|
||||
void fireEvent.load(screen.getByTestId('main-image'))
|
||||
await nextTick()
|
||||
|
||||
// Verify loader is hidden
|
||||
expect(
|
||||
container.querySelector('[aria-busy="true"]')
|
||||
).not.toBeInTheDocument()
|
||||
const region = screen.getByRole('region', {
|
||||
name: 'Image preview - Use arrow keys to navigate between images'
|
||||
})
|
||||
expect(region.getAttribute('aria-busy')).not.toBe('true')
|
||||
|
||||
void user
|
||||
// Change to different URL
|
||||
await rerender({
|
||||
imageUrls: ['/api/view?filename=different.png&type=output']
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
// Advance past the 250ms delayed loader timeout
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
await nextTick()
|
||||
|
||||
expect(
|
||||
container.querySelector('[aria-busy="true"]')
|
||||
).toBeInTheDocument()
|
||||
screen.getByRole('region', {
|
||||
name: 'Image preview - Use arrow keys to navigate between images'
|
||||
})
|
||||
).toHaveAttribute('aria-busy', 'true')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('should handle empty to non-empty URL transitions correctly', async () => {
|
||||
const { container, rerender } = renderImagePreview({ imageUrls: [] })
|
||||
const { rerender } = renderImagePreview({ imageUrls: [] })
|
||||
|
||||
expect(container.querySelector('.image-preview')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('image-preview-root')).not.toBeInTheDocument()
|
||||
|
||||
await rerender({
|
||||
imageUrls: ['/api/view?filename=test.png&type=output']
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
expect(container.querySelector('.image-preview')).toBeInTheDocument()
|
||||
screen.getByRole('img')
|
||||
expect(screen.getByTestId('image-preview-root')).toBeInTheDocument()
|
||||
expect(screen.getByRole('img')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="imageUrls.length > 0"
|
||||
data-testid="image-preview-root"
|
||||
class="image-preview group relative flex size-full min-h-55 min-w-16 flex-col justify-center px-2"
|
||||
@keydown="handleKeyDown"
|
||||
>
|
||||
@@ -89,6 +90,22 @@
|
||||
<i-comfy:mask class="size-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="!hasMultipleImages && nodeId"
|
||||
:class="actionButtonClass"
|
||||
:title="$t('g.clearMask')"
|
||||
:aria-label="$t('g.clearMask')"
|
||||
:disabled="isClearingMask"
|
||||
@click.stop="handleClearMask"
|
||||
>
|
||||
<i
|
||||
v-if="isClearingMask"
|
||||
class="icon-[lucide--loader-circle] size-4 animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<i v-else class="icon-[lucide--eraser] size-4" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<!-- Download Button -->
|
||||
<button
|
||||
:class="actionButtonClass"
|
||||
@@ -188,7 +205,7 @@ interface ImagePreviewProps {
|
||||
const { imageUrls, nodeId } = defineProps<ImagePreviewProps>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const maskEditor = useMaskEditor()
|
||||
const { openMaskEditor, clearMask, isClearingMask } = useMaskEditor()
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
const toastStore = useToastStore()
|
||||
|
||||
@@ -284,7 +301,22 @@ function handleEditMask() {
|
||||
if (!nodeId) return
|
||||
const node = resolveNode(Number(nodeId))
|
||||
if (!node) return
|
||||
maskEditor.openMaskEditor(node)
|
||||
openMaskEditor(node)
|
||||
}
|
||||
|
||||
async function handleClearMask() {
|
||||
if (!nodeId) return
|
||||
const node = resolveNode(Number(nodeId))
|
||||
if (!node) return
|
||||
try {
|
||||
await clearMask(node)
|
||||
} catch {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('maskEditor.clearMaskError'),
|
||||
detail: t('maskEditor.clearMaskFailed')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
|
||||
6
src/types/vue-component.d.ts
vendored
Normal file
6
src/types/vue-component.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
|
||||
const component: DefineComponent<object, object, unknown>
|
||||
export default component
|
||||
}
|
||||
Reference in New Issue
Block a user