mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-08 17:10:07 +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)
496 lines
12 KiB
TypeScript
496 lines
12 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { nextTick } from 'vue'
|
|
import { useCanvasHistory } from '@/composables/maskeditor/useCanvasHistory'
|
|
|
|
let mockMaskCanvas: any
|
|
let mockRgbCanvas: any
|
|
let mockMaskCtx: any
|
|
let mockRgbCtx: any
|
|
|
|
const mockStore = {
|
|
maskCanvas: null as any,
|
|
rgbCanvas: null as any,
|
|
maskCtx: null as any,
|
|
rgbCtx: null as any
|
|
}
|
|
|
|
vi.mock('@/stores/maskEditorStore', () => ({
|
|
useMaskEditorStore: vi.fn(() => mockStore)
|
|
}))
|
|
|
|
describe('useCanvasHistory', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
let rafCallCount = 0
|
|
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(
|
|
(cb: FrameRequestCallback) => {
|
|
if (rafCallCount++ < 100) {
|
|
setTimeout(() => cb(0), 0)
|
|
}
|
|
return rafCallCount
|
|
}
|
|
)
|
|
vi.spyOn(window, 'alert').mockImplementation(() => {})
|
|
|
|
const createMockImageData = () => {
|
|
return {
|
|
data: new Uint8ClampedArray(100 * 100 * 4),
|
|
width: 100,
|
|
height: 100
|
|
} as ImageData
|
|
}
|
|
|
|
mockMaskCtx = {
|
|
getImageData: vi.fn(() => createMockImageData()),
|
|
putImageData: vi.fn()
|
|
}
|
|
|
|
mockRgbCtx = {
|
|
getImageData: vi.fn(() => createMockImageData()),
|
|
putImageData: vi.fn()
|
|
}
|
|
|
|
mockMaskCanvas = {
|
|
width: 100,
|
|
height: 100
|
|
}
|
|
|
|
mockRgbCanvas = {
|
|
width: 100,
|
|
height: 100
|
|
}
|
|
|
|
mockStore.maskCanvas = mockMaskCanvas
|
|
mockStore.rgbCanvas = mockRgbCanvas
|
|
mockStore.maskCtx = mockMaskCtx
|
|
mockStore.rgbCtx = mockRgbCtx
|
|
})
|
|
|
|
describe('initialization', () => {
|
|
it('should initialize with default values', () => {
|
|
const history = useCanvasHistory()
|
|
|
|
expect(history.canUndo.value).toBe(false)
|
|
expect(history.canRedo.value).toBe(false)
|
|
})
|
|
|
|
it('should save initial state', () => {
|
|
const history = useCanvasHistory()
|
|
|
|
history.saveInitialState()
|
|
|
|
expect(mockMaskCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
|
|
expect(mockRgbCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
|
|
expect(history.canUndo.value).toBe(false)
|
|
expect(history.canRedo.value).toBe(false)
|
|
})
|
|
|
|
it('should wait for canvas to be ready', () => {
|
|
const rafSpy = vi.spyOn(window, 'requestAnimationFrame')
|
|
|
|
mockStore.maskCanvas = { ...mockMaskCanvas, width: 0, height: 0 }
|
|
|
|
const history = useCanvasHistory()
|
|
history.saveInitialState()
|
|
|
|
expect(rafSpy).toHaveBeenCalled()
|
|
|
|
mockStore.maskCanvas = mockMaskCanvas
|
|
})
|
|
|
|
it('should wait for context to be ready', () => {
|
|
const rafSpy = vi.spyOn(window, 'requestAnimationFrame')
|
|
|
|
mockStore.maskCtx = null
|
|
|
|
const history = useCanvasHistory()
|
|
history.saveInitialState()
|
|
|
|
expect(rafSpy).toHaveBeenCalled()
|
|
|
|
mockStore.maskCtx = mockMaskCtx
|
|
})
|
|
})
|
|
|
|
describe('saveState', () => {
|
|
it('should save a new state', () => {
|
|
const history = useCanvasHistory()
|
|
|
|
history.saveInitialState()
|
|
mockMaskCtx.getImageData.mockClear()
|
|
mockRgbCtx.getImageData.mockClear()
|
|
|
|
history.saveState()
|
|
|
|
expect(mockMaskCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
|
|
expect(mockRgbCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
|
|
expect(history.canUndo.value).toBe(true)
|
|
})
|
|
|
|
it('should clear redo states when saving new state', () => {
|
|
const history = useCanvasHistory()
|
|
|
|
history.saveInitialState()
|
|
history.saveState()
|
|
history.saveState()
|
|
history.undo()
|
|
|
|
expect(history.canRedo.value).toBe(true)
|
|
|
|
history.saveState()
|
|
|
|
expect(history.canRedo.value).toBe(false)
|
|
})
|
|
|
|
it('should respect maxStates limit', () => {
|
|
const history = useCanvasHistory(3)
|
|
|
|
history.saveInitialState()
|
|
history.saveState()
|
|
history.saveState()
|
|
history.saveState()
|
|
history.saveState()
|
|
|
|
expect(history.canUndo.value).toBe(true)
|
|
|
|
let undoCount = 0
|
|
while (history.canUndo.value && undoCount < 10) {
|
|
history.undo()
|
|
undoCount++
|
|
}
|
|
|
|
expect(undoCount).toBe(2)
|
|
})
|
|
|
|
it('should call saveInitialState if not initialized', () => {
|
|
const history = useCanvasHistory()
|
|
|
|
history.saveState()
|
|
|
|
expect(mockMaskCtx.getImageData).toHaveBeenCalled()
|
|
expect(mockRgbCtx.getImageData).toHaveBeenCalled()
|
|
})
|
|
|
|
it('should not save state if context is missing', () => {
|
|
const history = useCanvasHistory()
|
|
|
|
history.saveInitialState()
|
|
|
|
mockStore.maskCtx = null
|
|
mockMaskCtx.getImageData.mockClear()
|
|
mockRgbCtx.getImageData.mockClear()
|
|
|
|
history.saveState()
|
|
|
|
expect(mockMaskCtx.getImageData).not.toHaveBeenCalled()
|
|
|
|
mockStore.maskCtx = mockMaskCtx
|
|
})
|
|
})
|
|
|
|
describe('undo', () => {
|
|
it('should undo to previous state', () => {
|
|
const history = useCanvasHistory()
|
|
|
|
history.saveInitialState()
|
|
history.saveState()
|
|
|
|
history.undo()
|
|
|
|
expect(mockMaskCtx.putImageData).toHaveBeenCalled()
|
|
expect(mockRgbCtx.putImageData).toHaveBeenCalled()
|
|
expect(history.canUndo.value).toBe(false)
|
|
expect(history.canRedo.value).toBe(true)
|
|
})
|
|
|
|
it('should show alert when no undo states available', () => {
|
|
const alertSpy = vi.spyOn(window, 'alert')
|
|
const history = useCanvasHistory()
|
|
|
|
history.saveInitialState()
|
|
history.undo()
|
|
|
|
expect(alertSpy).toHaveBeenCalledWith('No more undo states available')
|
|
})
|
|
|
|
it('should undo multiple times', () => {
|
|
const history = useCanvasHistory()
|
|
|
|
history.saveInitialState()
|
|
history.saveState()
|
|
history.saveState()
|
|
history.saveState()
|
|
|
|
history.undo()
|
|
expect(history.canUndo.value).toBe(true)
|
|
|
|
history.undo()
|
|
expect(history.canUndo.value).toBe(true)
|
|
|
|
history.undo()
|
|
expect(history.canUndo.value).toBe(false)
|
|
})
|
|
|
|
it('should not undo beyond first state', () => {
|
|
const alertSpy = vi.spyOn(window, 'alert')
|
|
const history = useCanvasHistory()
|
|
|
|
history.saveInitialState()
|
|
history.saveState()
|
|
|
|
history.undo()
|
|
history.undo()
|
|
|
|
expect(alertSpy).toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('redo', () => {
|
|
it('should redo to next state', () => {
|
|
const history = useCanvasHistory()
|
|
|
|
history.saveInitialState()
|
|
history.saveState()
|
|
history.undo()
|
|
|
|
mockMaskCtx.putImageData.mockClear()
|
|
mockRgbCtx.putImageData.mockClear()
|
|
|
|
history.redo()
|
|
|
|
expect(mockMaskCtx.putImageData).toHaveBeenCalled()
|
|
expect(mockRgbCtx.putImageData).toHaveBeenCalled()
|
|
expect(history.canRedo.value).toBe(false)
|
|
expect(history.canUndo.value).toBe(true)
|
|
})
|
|
|
|
it('should show alert when no redo states available', () => {
|
|
const alertSpy = vi.spyOn(window, 'alert')
|
|
const history = useCanvasHistory()
|
|
|
|
history.saveInitialState()
|
|
history.redo()
|
|
|
|
expect(alertSpy).toHaveBeenCalledWith('No more redo states available')
|
|
})
|
|
|
|
it('should redo multiple times', () => {
|
|
const history = useCanvasHistory()
|
|
|
|
history.saveInitialState()
|
|
history.saveState()
|
|
history.saveState()
|
|
history.saveState()
|
|
|
|
history.undo()
|
|
history.undo()
|
|
history.undo()
|
|
|
|
history.redo()
|
|
expect(history.canRedo.value).toBe(true)
|
|
|
|
history.redo()
|
|
expect(history.canRedo.value).toBe(true)
|
|
|
|
history.redo()
|
|
expect(history.canRedo.value).toBe(false)
|
|
})
|
|
|
|
it('should not redo beyond last state', () => {
|
|
const alertSpy = vi.spyOn(window, 'alert')
|
|
const history = useCanvasHistory()
|
|
|
|
history.saveInitialState()
|
|
history.saveState()
|
|
history.undo()
|
|
|
|
history.redo()
|
|
history.redo()
|
|
|
|
expect(alertSpy).toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('clearStates', () => {
|
|
it('should clear all states', () => {
|
|
const history = useCanvasHistory()
|
|
|
|
history.saveInitialState()
|
|
history.saveState()
|
|
history.saveState()
|
|
|
|
history.clearStates()
|
|
|
|
expect(history.canUndo.value).toBe(false)
|
|
expect(history.canRedo.value).toBe(false)
|
|
})
|
|
|
|
it('should allow saving initial state after clear', () => {
|
|
const history = useCanvasHistory()
|
|
|
|
history.saveInitialState()
|
|
history.clearStates()
|
|
|
|
mockMaskCtx.getImageData.mockClear()
|
|
mockRgbCtx.getImageData.mockClear()
|
|
|
|
history.saveInitialState()
|
|
|
|
expect(mockMaskCtx.getImageData).toHaveBeenCalled()
|
|
expect(mockRgbCtx.getImageData).toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('canUndo computed', () => {
|
|
it('should be false with no states', () => {
|
|
const history = useCanvasHistory()
|
|
|
|
expect(history.canUndo.value).toBe(false)
|
|
})
|
|
|
|
it('should be false with only initial state', () => {
|
|
const history = useCanvasHistory()
|
|
|
|
history.saveInitialState()
|
|
|
|
expect(history.canUndo.value).toBe(false)
|
|
})
|
|
|
|
it('should be true after saving a state', () => {
|
|
const history = useCanvasHistory()
|
|
|
|
history.saveInitialState()
|
|
history.saveState()
|
|
|
|
expect(history.canUndo.value).toBe(true)
|
|
})
|
|
|
|
it('should be false after undoing to first state', () => {
|
|
const history = useCanvasHistory()
|
|
|
|
history.saveInitialState()
|
|
history.saveState()
|
|
history.undo()
|
|
|
|
expect(history.canUndo.value).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('canRedo computed', () => {
|
|
it('should be false with no undo', () => {
|
|
const history = useCanvasHistory()
|
|
|
|
history.saveInitialState()
|
|
history.saveState()
|
|
|
|
expect(history.canRedo.value).toBe(false)
|
|
})
|
|
|
|
it('should be true after undo', () => {
|
|
const history = useCanvasHistory()
|
|
|
|
history.saveInitialState()
|
|
history.saveState()
|
|
history.undo()
|
|
|
|
expect(history.canRedo.value).toBe(true)
|
|
})
|
|
|
|
it('should be false after redo to last state', () => {
|
|
const history = useCanvasHistory()
|
|
|
|
history.saveInitialState()
|
|
history.saveState()
|
|
history.undo()
|
|
history.redo()
|
|
|
|
expect(history.canRedo.value).toBe(false)
|
|
})
|
|
|
|
it('should be false after saving new state', () => {
|
|
const history = useCanvasHistory()
|
|
|
|
history.saveInitialState()
|
|
history.saveState()
|
|
history.undo()
|
|
|
|
expect(history.canRedo.value).toBe(true)
|
|
|
|
history.saveState()
|
|
|
|
expect(history.canRedo.value).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('restoreState', () => {
|
|
it('should not restore if context is missing', () => {
|
|
const history = useCanvasHistory()
|
|
|
|
history.saveInitialState()
|
|
history.saveState()
|
|
|
|
mockStore.maskCtx = null
|
|
mockMaskCtx.putImageData.mockClear()
|
|
mockRgbCtx.putImageData.mockClear()
|
|
|
|
history.undo()
|
|
|
|
expect(mockMaskCtx.putImageData).not.toHaveBeenCalled()
|
|
|
|
mockStore.maskCtx = mockMaskCtx
|
|
})
|
|
})
|
|
|
|
describe('edge cases', () => {
|
|
it('should handle rapid state saves', async () => {
|
|
const history = useCanvasHistory()
|
|
|
|
history.saveInitialState()
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
history.saveState()
|
|
await nextTick()
|
|
}
|
|
|
|
expect(history.canUndo.value).toBe(true)
|
|
})
|
|
|
|
it('should handle maxStates of 1', () => {
|
|
const history = useCanvasHistory(1)
|
|
|
|
history.saveInitialState()
|
|
history.saveState()
|
|
|
|
expect(history.canUndo.value).toBe(false)
|
|
})
|
|
|
|
it('should handle undo/redo cycling', () => {
|
|
const history = useCanvasHistory()
|
|
|
|
history.saveInitialState()
|
|
history.saveState()
|
|
history.saveState()
|
|
|
|
history.undo()
|
|
history.redo()
|
|
history.undo()
|
|
history.redo()
|
|
history.undo()
|
|
|
|
expect(history.canRedo.value).toBe(true)
|
|
expect(history.canUndo.value).toBe(true)
|
|
})
|
|
|
|
it('should handle zero-sized canvas', () => {
|
|
mockMaskCanvas.width = 0
|
|
mockMaskCanvas.height = 0
|
|
|
|
const history = useCanvasHistory()
|
|
|
|
history.saveInitialState()
|
|
|
|
expect(window.requestAnimationFrame).toHaveBeenCalled()
|
|
})
|
|
})
|
|
})
|