mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
## Summary
This PR refactors the mask editor from a vanilla JavaScript
implementation to Vue 3 + Composition API, aligning it with the ComfyUI
frontend's modern architecture. This is a structural refactor without UI
changes - all visual appearances and user interactions remain identical.
Net change: +1,700 lines (mostly tests)
## Changes
- Converted from class-based managers to Vue 3 Composition API
- Migrated state management to Pinia stores (maskEditorStore,
maskEditorDataStore)
- Split monolithic managers into focused composables:
- useBrushDrawing - Brush rendering and drawing logic
- useCanvasManager - Canvas lifecycle and operations
- useCanvasTools - Tool-specific canvas operations
- usePanAndZoom - Pan and zoom functionality
- useToolManager - Tool selection and coordination
- useKeyboard - Keyboard shortcuts
- useMaskEditorLoader/Saver - Data loading and saving
- useCoordinateTransform - Coordinate system transformations
- Replaced imperative DOM manipulation with Vue components
- Added comprehensive test coverage
## What This PR Does NOT Change
Preserved Original Styling:
- Original CSS retained in packages/design-system/src/css/style.css
- Some generic controls (DropdownControl, SliderControl, ToggleControl)
preserved as-is
- Future migration to Tailwind and PrimeVue components is planned but
out of scope for this PR
Preserved Core Functionality:
- Drawing algorithms and brush rendering logic remain unchanged
- Pan/zoom calculations preserved
- Canvas operations (composite modes, image processing) unchanged
- Tool behaviors (brush, color select, paint bucket) identical
- No changes to mask generation or export logic
DO NOT Review:
- CSS styling choices (preserved from original)
- Drawing algorithm implementations (unchanged)
- Canvas rendering logic (ported as-is)
- UI/UX changes (none exist)
- Component library choices (future work)
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6629-fully-refactor-mask-editor-into-vue-based-2a46d73d36508114ab8bd2984b4b54e4)
by [Unito](https://www.unito.io)
198 lines
4.6 KiB
TypeScript
198 lines
4.6 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import { useImageLoader } from '@/composables/maskeditor/useImageLoader'
|
|
|
|
const mockCanvasManager = {
|
|
invalidateCanvas: vi.fn().mockResolvedValue(undefined),
|
|
updateMaskColor: vi.fn().mockResolvedValue(undefined)
|
|
}
|
|
|
|
const mockStore = {
|
|
imgCanvas: null as any,
|
|
maskCanvas: null as any,
|
|
rgbCanvas: null as any,
|
|
imgCtx: null as any,
|
|
maskCtx: null as any,
|
|
image: null as any
|
|
}
|
|
|
|
const mockDataStore = {
|
|
inputData: null as any
|
|
}
|
|
|
|
vi.mock('@/stores/maskEditorStore', () => ({
|
|
useMaskEditorStore: vi.fn(() => mockStore)
|
|
}))
|
|
|
|
vi.mock('@/stores/maskEditorDataStore', () => ({
|
|
useMaskEditorDataStore: vi.fn(() => mockDataStore)
|
|
}))
|
|
|
|
vi.mock('@/composables/maskeditor/useCanvasManager', () => ({
|
|
useCanvasManager: vi.fn(() => mockCanvasManager)
|
|
}))
|
|
|
|
vi.mock('@vueuse/core', () => ({
|
|
createSharedComposable: (fn: any) => fn
|
|
}))
|
|
|
|
describe('useImageLoader', () => {
|
|
let mockBaseImage: HTMLImageElement
|
|
let mockMaskImage: HTMLImageElement
|
|
let mockPaintImage: HTMLImageElement
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
|
|
mockBaseImage = {
|
|
width: 512,
|
|
height: 512
|
|
} as HTMLImageElement
|
|
|
|
mockMaskImage = {
|
|
width: 512,
|
|
height: 512
|
|
} as HTMLImageElement
|
|
|
|
mockPaintImage = {
|
|
width: 512,
|
|
height: 512
|
|
} as HTMLImageElement
|
|
|
|
mockStore.imgCtx = {
|
|
clearRect: vi.fn()
|
|
}
|
|
|
|
mockStore.maskCtx = {
|
|
clearRect: vi.fn()
|
|
}
|
|
|
|
mockStore.imgCanvas = {
|
|
width: 0,
|
|
height: 0
|
|
}
|
|
|
|
mockStore.maskCanvas = {
|
|
width: 0,
|
|
height: 0
|
|
}
|
|
|
|
mockStore.rgbCanvas = {
|
|
width: 0,
|
|
height: 0
|
|
}
|
|
|
|
mockDataStore.inputData = {
|
|
baseLayer: { image: mockBaseImage },
|
|
maskLayer: { image: mockMaskImage },
|
|
paintLayer: { image: mockPaintImage }
|
|
}
|
|
})
|
|
|
|
describe('loadImages', () => {
|
|
it('should load images successfully', async () => {
|
|
const loader = useImageLoader()
|
|
|
|
const result = await loader.loadImages()
|
|
|
|
expect(result).toBe(mockBaseImage)
|
|
expect(mockStore.image).toBe(mockBaseImage)
|
|
})
|
|
|
|
it('should set canvas dimensions', async () => {
|
|
const loader = useImageLoader()
|
|
|
|
await loader.loadImages()
|
|
|
|
expect(mockStore.maskCanvas.width).toBe(512)
|
|
expect(mockStore.maskCanvas.height).toBe(512)
|
|
expect(mockStore.rgbCanvas.width).toBe(512)
|
|
expect(mockStore.rgbCanvas.height).toBe(512)
|
|
})
|
|
|
|
it('should clear canvas contexts', async () => {
|
|
const loader = useImageLoader()
|
|
|
|
await loader.loadImages()
|
|
|
|
expect(mockStore.imgCtx.clearRect).toHaveBeenCalledWith(0, 0, 0, 0)
|
|
expect(mockStore.maskCtx.clearRect).toHaveBeenCalledWith(0, 0, 0, 0)
|
|
})
|
|
|
|
it('should call canvasManager methods', async () => {
|
|
const loader = useImageLoader()
|
|
|
|
await loader.loadImages()
|
|
|
|
expect(mockCanvasManager.invalidateCanvas).toHaveBeenCalledWith(
|
|
mockBaseImage,
|
|
mockMaskImage,
|
|
mockPaintImage
|
|
)
|
|
expect(mockCanvasManager.updateMaskColor).toHaveBeenCalled()
|
|
})
|
|
|
|
it('should handle missing paintLayer', async () => {
|
|
mockDataStore.inputData = {
|
|
baseLayer: { image: mockBaseImage },
|
|
maskLayer: { image: mockMaskImage },
|
|
paintLayer: null
|
|
}
|
|
|
|
const loader = useImageLoader()
|
|
|
|
await loader.loadImages()
|
|
|
|
expect(mockCanvasManager.invalidateCanvas).toHaveBeenCalledWith(
|
|
mockBaseImage,
|
|
mockMaskImage,
|
|
null
|
|
)
|
|
})
|
|
|
|
it('should throw error when no input data', async () => {
|
|
mockDataStore.inputData = null
|
|
|
|
const loader = useImageLoader()
|
|
|
|
await expect(loader.loadImages()).rejects.toThrow(
|
|
'No input data available in dataStore'
|
|
)
|
|
})
|
|
|
|
it('should throw error when canvas elements missing', async () => {
|
|
mockStore.imgCanvas = null
|
|
|
|
const loader = useImageLoader()
|
|
|
|
await expect(loader.loadImages()).rejects.toThrow(
|
|
'Canvas elements or contexts not available'
|
|
)
|
|
})
|
|
|
|
it('should throw error when contexts missing', async () => {
|
|
mockStore.imgCtx = null
|
|
|
|
const loader = useImageLoader()
|
|
|
|
await expect(loader.loadImages()).rejects.toThrow(
|
|
'Canvas elements or contexts not available'
|
|
)
|
|
})
|
|
|
|
it('should handle different image dimensions', async () => {
|
|
mockBaseImage.width = 1024
|
|
mockBaseImage.height = 768
|
|
|
|
const loader = useImageLoader()
|
|
|
|
await loader.loadImages()
|
|
|
|
expect(mockStore.maskCanvas.width).toBe(1024)
|
|
expect(mockStore.maskCanvas.height).toBe(768)
|
|
expect(mockStore.rgbCanvas.width).toBe(1024)
|
|
expect(mockStore.rgbCanvas.height).toBe(768)
|
|
})
|
|
})
|
|
})
|