mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-03 22:59:14 +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)
337 lines
9.1 KiB
TypeScript
337 lines
9.1 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import { MaskBlendMode } from '@/extensions/core/maskeditor/types'
|
|
import { useCanvasManager } from '@/composables/maskeditor/useCanvasManager'
|
|
const mockStore = {
|
|
imgCanvas: null as any,
|
|
maskCanvas: null as any,
|
|
rgbCanvas: null as any,
|
|
imgCtx: null as any,
|
|
maskCtx: null as any,
|
|
rgbCtx: null as any,
|
|
canvasBackground: null as any,
|
|
maskColor: { r: 0, g: 0, b: 0 },
|
|
maskBlendMode: MaskBlendMode.Black,
|
|
maskOpacity: 0.8
|
|
}
|
|
|
|
vi.mock('@/stores/maskEditorStore', () => ({
|
|
useMaskEditorStore: vi.fn(() => mockStore)
|
|
}))
|
|
|
|
function createMockImage(width: number, height: number): HTMLImageElement {
|
|
return {
|
|
width,
|
|
height
|
|
} as HTMLImageElement
|
|
}
|
|
|
|
describe('useCanvasManager', () => {
|
|
let mockImageData: ImageData
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
|
|
mockImageData = {
|
|
data: new Uint8ClampedArray(100 * 100 * 4),
|
|
width: 100,
|
|
height: 100
|
|
} as ImageData
|
|
|
|
mockStore.imgCtx = {
|
|
drawImage: vi.fn()
|
|
}
|
|
|
|
mockStore.maskCtx = {
|
|
drawImage: vi.fn(),
|
|
getImageData: vi.fn(() => mockImageData),
|
|
putImageData: vi.fn(),
|
|
globalCompositeOperation: 'source-over',
|
|
fillStyle: ''
|
|
}
|
|
|
|
mockStore.rgbCtx = {
|
|
drawImage: vi.fn()
|
|
}
|
|
|
|
mockStore.imgCanvas = {
|
|
width: 0,
|
|
height: 0
|
|
}
|
|
|
|
mockStore.maskCanvas = {
|
|
width: 0,
|
|
height: 0,
|
|
style: {
|
|
mixBlendMode: '',
|
|
opacity: ''
|
|
}
|
|
}
|
|
|
|
mockStore.rgbCanvas = {
|
|
width: 0,
|
|
height: 0
|
|
}
|
|
|
|
mockStore.canvasBackground = {
|
|
style: {
|
|
backgroundColor: ''
|
|
}
|
|
}
|
|
|
|
mockStore.maskColor = { r: 0, g: 0, b: 0 }
|
|
mockStore.maskBlendMode = MaskBlendMode.Black
|
|
mockStore.maskOpacity = 0.8
|
|
})
|
|
|
|
describe('invalidateCanvas', () => {
|
|
it('should set canvas dimensions', async () => {
|
|
const manager = useCanvasManager()
|
|
|
|
const origImage = createMockImage(512, 512)
|
|
const maskImage = createMockImage(512, 512)
|
|
|
|
await manager.invalidateCanvas(origImage, maskImage, null)
|
|
|
|
expect(mockStore.imgCanvas.width).toBe(512)
|
|
expect(mockStore.imgCanvas.height).toBe(512)
|
|
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 draw original image', async () => {
|
|
const manager = useCanvasManager()
|
|
|
|
const origImage = createMockImage(512, 512)
|
|
const maskImage = createMockImage(512, 512)
|
|
|
|
await manager.invalidateCanvas(origImage, maskImage, null)
|
|
|
|
expect(mockStore.imgCtx.drawImage).toHaveBeenCalledWith(
|
|
origImage,
|
|
0,
|
|
0,
|
|
512,
|
|
512
|
|
)
|
|
})
|
|
|
|
it('should draw paint image when provided', async () => {
|
|
const manager = useCanvasManager()
|
|
|
|
const origImage = createMockImage(512, 512)
|
|
const maskImage = createMockImage(512, 512)
|
|
const paintImage = createMockImage(512, 512)
|
|
|
|
await manager.invalidateCanvas(origImage, maskImage, paintImage)
|
|
|
|
expect(mockStore.rgbCtx.drawImage).toHaveBeenCalledWith(
|
|
paintImage,
|
|
0,
|
|
0,
|
|
512,
|
|
512
|
|
)
|
|
})
|
|
|
|
it('should not draw paint image when null', async () => {
|
|
const manager = useCanvasManager()
|
|
|
|
const origImage = createMockImage(512, 512)
|
|
const maskImage = createMockImage(512, 512)
|
|
|
|
await manager.invalidateCanvas(origImage, maskImage, null)
|
|
|
|
expect(mockStore.rgbCtx.drawImage).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('should prepare mask', async () => {
|
|
const manager = useCanvasManager()
|
|
|
|
const origImage = createMockImage(512, 512)
|
|
const maskImage = createMockImage(512, 512)
|
|
|
|
await manager.invalidateCanvas(origImage, maskImage, null)
|
|
|
|
expect(mockStore.maskCtx.drawImage).toHaveBeenCalled()
|
|
expect(mockStore.maskCtx.getImageData).toHaveBeenCalled()
|
|
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
|
|
})
|
|
|
|
it('should throw error when canvas missing', async () => {
|
|
const manager = useCanvasManager()
|
|
|
|
mockStore.imgCanvas = null
|
|
|
|
const origImage = createMockImage(512, 512)
|
|
const maskImage = createMockImage(512, 512)
|
|
|
|
await expect(
|
|
manager.invalidateCanvas(origImage, maskImage, null)
|
|
).rejects.toThrow('Canvas elements or contexts not available')
|
|
})
|
|
|
|
it('should throw error when context missing', async () => {
|
|
const manager = useCanvasManager()
|
|
|
|
mockStore.imgCtx = null
|
|
|
|
const origImage = createMockImage(512, 512)
|
|
const maskImage = createMockImage(512, 512)
|
|
|
|
await expect(
|
|
manager.invalidateCanvas(origImage, maskImage, null)
|
|
).rejects.toThrow('Canvas elements or contexts not available')
|
|
})
|
|
})
|
|
|
|
describe('updateMaskColor', () => {
|
|
it('should update mask color for black blend mode', async () => {
|
|
const manager = useCanvasManager()
|
|
|
|
mockStore.maskBlendMode = MaskBlendMode.Black
|
|
mockStore.maskColor = { r: 0, g: 0, b: 0 }
|
|
|
|
await manager.updateMaskColor()
|
|
|
|
expect(mockStore.maskCtx.fillStyle).toBe('rgb(0, 0, 0)')
|
|
expect(mockStore.maskCanvas.style.mixBlendMode).toBe('initial')
|
|
expect(mockStore.maskCanvas.style.opacity).toBe('0.8')
|
|
expect(mockStore.canvasBackground.style.backgroundColor).toBe(
|
|
'rgba(0,0,0,1)'
|
|
)
|
|
})
|
|
|
|
it('should update mask color for white blend mode', async () => {
|
|
const manager = useCanvasManager()
|
|
|
|
mockStore.maskBlendMode = MaskBlendMode.White
|
|
mockStore.maskColor = { r: 255, g: 255, b: 255 }
|
|
|
|
await manager.updateMaskColor()
|
|
|
|
expect(mockStore.maskCtx.fillStyle).toBe('rgb(255, 255, 255)')
|
|
expect(mockStore.maskCanvas.style.mixBlendMode).toBe('initial')
|
|
expect(mockStore.canvasBackground.style.backgroundColor).toBe(
|
|
'rgba(255,255,255,1)'
|
|
)
|
|
})
|
|
|
|
it('should update mask color for negative blend mode', async () => {
|
|
const manager = useCanvasManager()
|
|
|
|
mockStore.maskBlendMode = MaskBlendMode.Negative
|
|
mockStore.maskColor = { r: 255, g: 255, b: 255 }
|
|
|
|
await manager.updateMaskColor()
|
|
|
|
expect(mockStore.maskCanvas.style.mixBlendMode).toBe('difference')
|
|
expect(mockStore.maskCanvas.style.opacity).toBe('1')
|
|
expect(mockStore.canvasBackground.style.backgroundColor).toBe(
|
|
'rgba(255,255,255,1)'
|
|
)
|
|
})
|
|
|
|
it('should update all pixels with mask color', async () => {
|
|
const manager = useCanvasManager()
|
|
|
|
mockStore.maskColor = { r: 128, g: 64, b: 32 }
|
|
mockStore.maskCanvas.width = 100
|
|
mockStore.maskCanvas.height = 100
|
|
|
|
await manager.updateMaskColor()
|
|
|
|
for (let i = 0; i < mockImageData.data.length; i += 4) {
|
|
expect(mockImageData.data[i]).toBe(128)
|
|
expect(mockImageData.data[i + 1]).toBe(64)
|
|
expect(mockImageData.data[i + 2]).toBe(32)
|
|
}
|
|
|
|
expect(mockStore.maskCtx.putImageData).toHaveBeenCalledWith(
|
|
mockImageData,
|
|
0,
|
|
0
|
|
)
|
|
})
|
|
|
|
it('should return early when canvas missing', async () => {
|
|
const manager = useCanvasManager()
|
|
|
|
mockStore.maskCanvas = null
|
|
|
|
await manager.updateMaskColor()
|
|
|
|
expect(mockStore.maskCtx.getImageData).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('should return early when context missing', async () => {
|
|
const manager = useCanvasManager()
|
|
|
|
mockStore.maskCtx = null
|
|
|
|
await manager.updateMaskColor()
|
|
|
|
expect(mockStore.canvasBackground.style.backgroundColor).toBe('')
|
|
})
|
|
|
|
it('should handle different opacity values', async () => {
|
|
const manager = useCanvasManager()
|
|
|
|
mockStore.maskOpacity = 0.5
|
|
|
|
await manager.updateMaskColor()
|
|
|
|
expect(mockStore.maskCanvas.style.opacity).toBe('0.5')
|
|
})
|
|
})
|
|
|
|
describe('prepareMask', () => {
|
|
it('should invert mask alpha', async () => {
|
|
const manager = useCanvasManager()
|
|
|
|
for (let i = 0; i < mockImageData.data.length; i += 4) {
|
|
mockImageData.data[i + 3] = 128
|
|
}
|
|
|
|
const origImage = createMockImage(100, 100)
|
|
const maskImage = createMockImage(100, 100)
|
|
|
|
await manager.invalidateCanvas(origImage, maskImage, null)
|
|
|
|
for (let i = 0; i < mockImageData.data.length; i += 4) {
|
|
expect(mockImageData.data[i + 3]).toBe(127)
|
|
}
|
|
})
|
|
|
|
it('should apply mask color to all pixels', async () => {
|
|
const manager = useCanvasManager()
|
|
|
|
mockStore.maskColor = { r: 100, g: 150, b: 200 }
|
|
|
|
const origImage = createMockImage(100, 100)
|
|
const maskImage = createMockImage(100, 100)
|
|
|
|
await manager.invalidateCanvas(origImage, maskImage, null)
|
|
|
|
for (let i = 0; i < mockImageData.data.length; i += 4) {
|
|
expect(mockImageData.data[i]).toBe(100)
|
|
expect(mockImageData.data[i + 1]).toBe(150)
|
|
expect(mockImageData.data[i + 2]).toBe(200)
|
|
}
|
|
})
|
|
|
|
it('should set composite operation', async () => {
|
|
const manager = useCanvasManager()
|
|
|
|
const origImage = createMockImage(100, 100)
|
|
const maskImage = createMockImage(100, 100)
|
|
|
|
await manager.invalidateCanvas(origImage, maskImage, null)
|
|
|
|
expect(mockStore.maskCtx.globalCompositeOperation).toBe('source-over')
|
|
})
|
|
})
|
|
})
|