diff --git a/src/components/maskeditor/MaskEditorContent.vue b/src/components/maskeditor/MaskEditorContent.vue index a524edfcb..0563dc571 100644 --- a/src/components/maskeditor/MaskEditorContent.vue +++ b/src/components/maskeditor/MaskEditorContent.vue @@ -4,6 +4,7 @@ class="maskEditor-dialog-root flex h-full w-full flex-col" @contextmenu.prevent @dragstart="handleDragStart" + @keydown.stop >
+
+ + + + + + + + + +
+ @@ -63,6 +131,7 @@ import { ref } from 'vue' import Button from '@/components/ui/button/Button.vue' import { useCanvasTools } from '@/composables/maskeditor/useCanvasTools' +import { useCanvasTransform } from '@/composables/maskeditor/useCanvasTransform' import { useMaskEditorSaver } from '@/composables/maskeditor/useMaskEditorSaver' import { t } from '@/i18n' import { useDialogStore } from '@/stores/dialogStore' @@ -71,16 +140,17 @@ import { useMaskEditorStore } from '@/stores/maskEditorStore' const store = useMaskEditorStore() const dialogStore = useDialogStore() const canvasTools = useCanvasTools() +const canvasTransform = useCanvasTransform() const saver = useMaskEditorSaver() const saveButtonText = ref(t('g.save')) const saveEnabled = ref(true) const iconButtonClass = - 'flex h-7.5 w-12.5 items-center justify-center rounded-[10px] border border-[var(--p-form-field-border-color)] pointer-events-auto transition-colors duration-100 bg-[var(--comfy-menu-bg)] hover:bg-secondary-background-hover' + 'flex h-7.5 w-12.5 items-center justify-center rounded-[10px] border border-border-default pointer-events-auto transition-colors duration-100 bg-comfy-menu-bg hover:bg-secondary-background-hover' const textButtonClass = - 'h-7.5 w-15 rounded-[10px] border border-[var(--p-form-field-border-color)] text-[var(--input-text)] font-sans pointer-events-auto transition-colors duration-100 bg-[var(--comfy-menu-bg)] hover:bg-secondary-background-hover' + 'h-7.5 w-15 rounded-[10px] border border-border-default text-current font-sans pointer-events-auto transition-colors duration-100 bg-comfy-menu-bg hover:bg-secondary-background-hover' const onUndo = () => { store.canvasHistory.undo() @@ -90,6 +160,38 @@ const onRedo = () => { store.canvasHistory.redo() } +const onRotateLeft = async () => { + try { + await canvasTransform.rotateCounterclockwise() + } catch (error) { + console.error('[TopBarHeader] Rotate left failed:', error) + } +} + +const onRotateRight = async () => { + try { + await canvasTransform.rotateClockwise() + } catch (error) { + console.error('[TopBarHeader] Rotate right failed:', error) + } +} + +const onMirrorHorizontal = async () => { + try { + await canvasTransform.mirrorHorizontal() + } catch (error) { + console.error('[TopBarHeader] Mirror horizontal failed:', error) + } +} + +const onMirrorVertical = async () => { + try { + await canvasTransform.mirrorVertical() + } catch (error) { + console.error('[TopBarHeader] Mirror vertical failed:', error) + } +} + const onInvert = () => { canvasTools.invertMask() } diff --git a/src/composables/maskeditor/useBrushDrawing.ts b/src/composables/maskeditor/useBrushDrawing.ts index c62ab1446..a86dcec85 100644 --- a/src/composables/maskeditor/useBrushDrawing.ts +++ b/src/composables/maskeditor/useBrushDrawing.ts @@ -1,3 +1,4 @@ +/// import { ref, watch, nextTick, onUnmounted } from 'vue' import QuickLRU from '@alloc/quick-lru' import { debounce } from 'es-toolkit/compat' @@ -233,6 +234,128 @@ export function useBrushDrawing(initialSettings?: { } ) + const isRecreatingTextures = ref(false) + + watch( + () => store.gpuTexturesNeedRecreation, + async (needsRecreation) => { + if ( + !needsRecreation || + !device || + !store.maskCanvas || + isRecreatingTextures.value + ) + return + + isRecreatingTextures.value = true + + const width = store.gpuTextureWidth + const height = store.gpuTextureHeight + + try { + // Destroy old textures + if (maskTexture) { + maskTexture.destroy() + maskTexture = null + } + if (rgbTexture) { + rgbTexture.destroy() + rgbTexture = null + } + + // Create new textures with updated dimensions + maskTexture = device.createTexture({ + size: [width, height], + format: 'rgba8unorm', + usage: + GPUTextureUsage.TEXTURE_BINDING | + GPUTextureUsage.STORAGE_BINDING | + GPUTextureUsage.RENDER_ATTACHMENT | + GPUTextureUsage.COPY_DST | + GPUTextureUsage.COPY_SRC + }) + + rgbTexture = device.createTexture({ + size: [width, height], + format: 'rgba8unorm', + usage: + GPUTextureUsage.TEXTURE_BINDING | + GPUTextureUsage.STORAGE_BINDING | + GPUTextureUsage.RENDER_ATTACHMENT | + GPUTextureUsage.COPY_DST | + GPUTextureUsage.COPY_SRC + }) + + // Upload pending data if available + if (store.pendingGPUMaskData && store.pendingGPURgbData) { + device.queue.writeTexture( + { texture: maskTexture }, + store.pendingGPUMaskData, + { bytesPerRow: width * 4 }, + { width, height } + ) + + device.queue.writeTexture( + { texture: rgbTexture }, + store.pendingGPURgbData, + { bytesPerRow: width * 4 }, + { width, height } + ) + } else { + // Fallback: read from canvas + await updateGPUFromCanvas() + } + + // Update preview canvas if it exists + if (previewCanvas && renderer) { + previewCanvas.width = width + previewCanvas.height = height + } + + // Recreate readback buffers with new size + const bufferSize = width * height * 4 + if (currentBufferSize !== bufferSize) { + readbackStorageMask?.destroy() + readbackStorageRgb?.destroy() + readbackStagingMask?.destroy() + readbackStagingRgb?.destroy() + + readbackStorageMask = device.createBuffer({ + size: bufferSize, + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC + }) + readbackStorageRgb = device.createBuffer({ + size: bufferSize, + usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC + }) + readbackStagingMask = device.createBuffer({ + size: bufferSize, + usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ + }) + readbackStagingRgb = device.createBuffer({ + size: bufferSize, + usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ + }) + + currentBufferSize = bufferSize + } + } catch (error) { + console.error( + '[useBrushDrawing] Failed to recreate GPU textures:', + error + ) + } finally { + // Clear the recreation flag and pending data + store.gpuTexturesNeedRecreation = false + store.gpuTextureWidth = 0 + store.gpuTextureHeight = 0 + store.pendingGPUMaskData = null + store.pendingGPURgbData = null + isRecreatingTextures.value = false + } + } + ) + // Cleanup GPU resources on unmount onUnmounted(() => { if (renderer) { diff --git a/src/composables/maskeditor/useCanvasHistory.test.ts b/src/composables/maskeditor/useCanvasHistory.test.ts index 587b0347d..6281baf39 100644 --- a/src/composables/maskeditor/useCanvasHistory.test.ts +++ b/src/composables/maskeditor/useCanvasHistory.test.ts @@ -2,23 +2,70 @@ 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 +// Define the store shape to avoid 'any' and cast to the expected type +interface MaskEditorStoreState { + maskCanvas: HTMLCanvasElement | null + rgbCanvas: HTMLCanvasElement | null + imgCanvas: HTMLCanvasElement | null + maskCtx: CanvasRenderingContext2D | null + rgbCtx: CanvasRenderingContext2D | null + imgCtx: CanvasRenderingContext2D | null +} -const mockStore = { - maskCanvas: null as any, - rgbCanvas: null as any, - maskCtx: null as any, - rgbCtx: null as any +// Use vi.hoisted to create isolated mock state container +const mockRefs = vi.hoisted(() => ({ + maskCanvas: null as HTMLCanvasElement | null, + rgbCanvas: null as HTMLCanvasElement | null, + imgCanvas: null as HTMLCanvasElement | null, + maskCtx: null as CanvasRenderingContext2D | null, + rgbCtx: null as CanvasRenderingContext2D | null, + imgCtx: null as CanvasRenderingContext2D | null +})) + +const mockStore: MaskEditorStoreState = { + get maskCanvas() { + return mockRefs.maskCanvas + }, + set maskCanvas(val) { + mockRefs.maskCanvas = val + }, + get rgbCanvas() { + return mockRefs.rgbCanvas + }, + set rgbCanvas(val) { + mockRefs.rgbCanvas = val + }, + get imgCanvas() { + return mockRefs.imgCanvas + }, + set imgCanvas(val) { + mockRefs.imgCanvas = val + }, + get maskCtx() { + return mockRefs.maskCtx + }, + set maskCtx(val) { + mockRefs.maskCtx = val + }, + get rgbCtx() { + return mockRefs.rgbCtx + }, + set rgbCtx(val) { + mockRefs.rgbCtx = val + }, + get imgCtx() { + return mockRefs.imgCtx + }, + set imgCtx(val) { + mockRefs.imgCtx = val + } } vi.mock('@/stores/maskEditorStore', () => ({ useMaskEditorStore: vi.fn(() => mockStore) })) -// Mock ImageBitmap for test environment +// Mock ImageBitmap using safe global augmentation pattern if (typeof globalThis.ImageBitmap === 'undefined') { globalThis.ImageBitmap = class ImageBitmap { width: number @@ -28,7 +75,7 @@ if (typeof globalThis.ImageBitmap === 'undefined') { this.height = height } close() {} - } as any + } as unknown as typeof globalThis.ImageBitmap } describe('useCanvasHistory', () => { @@ -43,9 +90,8 @@ describe('useCanvasHistory', () => { return rafCallCount } ) - vi.stubGlobal('alert', () => {}) - const createMockImageData = () => { + const createMockImageData = (): ImageData => { return { data: new Uint8ClampedArray(100 * 100 * 4), width: 100, @@ -53,34 +99,43 @@ describe('useCanvasHistory', () => { } as ImageData } - mockMaskCtx = { + // Mock contexts using explicit partial-cast pattern + mockRefs.maskCtx = { getImageData: vi.fn(() => createMockImageData()), putImageData: vi.fn(), clearRect: vi.fn(), drawImage: vi.fn() - } + } as Partial as CanvasRenderingContext2D - mockRgbCtx = { + mockRefs.rgbCtx = { getImageData: vi.fn(() => createMockImageData()), putImageData: vi.fn(), clearRect: vi.fn(), drawImage: vi.fn() - } + } as Partial as CanvasRenderingContext2D - mockMaskCanvas = { + mockRefs.imgCtx = { + getImageData: vi.fn(() => createMockImageData()), + putImageData: vi.fn(), + clearRect: vi.fn(), + drawImage: vi.fn() + } as Partial as CanvasRenderingContext2D + + // Mock canvases using explicit partial-cast pattern + mockRefs.maskCanvas = { width: 100, height: 100 - } + } as Partial as HTMLCanvasElement - mockRgbCanvas = { + mockRefs.rgbCanvas = { width: 100, height: 100 - } + } as Partial as HTMLCanvasElement - mockStore.maskCanvas = mockMaskCanvas - mockStore.rgbCanvas = mockRgbCanvas - mockStore.maskCtx = mockMaskCtx - mockStore.rgbCtx = mockRgbCtx + mockRefs.imgCanvas = { + width: 100, + height: 100 + } as Partial as HTMLCanvasElement }) describe('initialization', () => { @@ -96,8 +151,14 @@ describe('useCanvasHistory', () => { history.saveInitialState() - expect(mockMaskCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100) - expect(mockRgbCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100) + expect(mockRefs.maskCtx!.getImageData).toHaveBeenCalledWith( + 0, + 0, + 100, + 100 + ) + expect(mockRefs.rgbCtx!.getImageData).toHaveBeenCalledWith(0, 0, 100, 100) + expect(mockRefs.imgCtx!.getImageData).toHaveBeenCalledWith(0, 0, 100, 100) expect(history.canUndo.value).toBe(false) expect(history.canRedo.value).toBe(false) }) @@ -105,27 +166,47 @@ describe('useCanvasHistory', () => { it('should wait for canvas to be ready', () => { const rafSpy = vi.spyOn(window, 'requestAnimationFrame') - mockStore.maskCanvas = { ...mockMaskCanvas, width: 0, height: 0 } + mockRefs.maskCanvas = { + ...mockRefs.maskCanvas, + width: 0, + height: 0 + } as Partial as HTMLCanvasElement const history = useCanvasHistory() history.saveInitialState() expect(rafSpy).toHaveBeenCalled() - mockStore.maskCanvas = mockMaskCanvas + mockRefs.maskCanvas = { + width: 100, + height: 100 + } as Partial as HTMLCanvasElement }) it('should wait for context to be ready', () => { const rafSpy = vi.spyOn(window, 'requestAnimationFrame') - mockStore.maskCtx = null + mockRefs.maskCtx = null const history = useCanvasHistory() history.saveInitialState() expect(rafSpy).toHaveBeenCalled() - mockStore.maskCtx = mockMaskCtx + const createMockImageData = (): ImageData => { + return { + data: new Uint8ClampedArray(100 * 100 * 4), + width: 100, + height: 100 + } as ImageData + } + + mockRefs.maskCtx = { + getImageData: vi.fn(() => createMockImageData()), + putImageData: vi.fn(), + clearRect: vi.fn(), + drawImage: vi.fn() + } as Partial as CanvasRenderingContext2D }) }) @@ -134,13 +215,20 @@ describe('useCanvasHistory', () => { const history = useCanvasHistory() history.saveInitialState() - mockMaskCtx.getImageData.mockClear() - mockRgbCtx.getImageData.mockClear() + vi.mocked(mockRefs.maskCtx!.getImageData).mockClear() + vi.mocked(mockRefs.rgbCtx!.getImageData).mockClear() + vi.mocked(mockRefs.imgCtx!.getImageData).mockClear() history.saveState() - expect(mockMaskCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100) - expect(mockRgbCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100) + expect(mockRefs.maskCtx!.getImageData).toHaveBeenCalledWith( + 0, + 0, + 100, + 100 + ) + expect(mockRefs.rgbCtx!.getImageData).toHaveBeenCalledWith(0, 0, 100, 100) + expect(mockRefs.imgCtx!.getImageData).toHaveBeenCalledWith(0, 0, 100, 100) expect(history.canUndo.value).toBe(true) }) @@ -184,8 +272,9 @@ describe('useCanvasHistory', () => { history.saveState() - expect(mockMaskCtx.getImageData).toHaveBeenCalled() - expect(mockRgbCtx.getImageData).toHaveBeenCalled() + expect(mockRefs.maskCtx!.getImageData).toHaveBeenCalled() + expect(mockRefs.rgbCtx!.getImageData).toHaveBeenCalled() + expect(mockRefs.imgCtx!.getImageData).toHaveBeenCalled() }) it('should not save state if context is missing', () => { @@ -193,15 +282,17 @@ describe('useCanvasHistory', () => { history.saveInitialState() - mockStore.maskCtx = null - mockMaskCtx.getImageData.mockClear() - mockRgbCtx.getImageData.mockClear() + const savedMaskCtx = mockRefs.maskCtx + mockRefs.maskCtx = null + vi.mocked(savedMaskCtx!.getImageData).mockClear() + vi.mocked(mockRefs.rgbCtx!.getImageData).mockClear() + vi.mocked(mockRefs.imgCtx!.getImageData).mockClear() history.saveState() - expect(mockMaskCtx.getImageData).not.toHaveBeenCalled() + expect(savedMaskCtx!.getImageData).not.toHaveBeenCalled() - mockStore.maskCtx = mockMaskCtx + mockRefs.maskCtx = savedMaskCtx }) }) @@ -214,20 +305,27 @@ describe('useCanvasHistory', () => { history.undo() - expect(mockMaskCtx.putImageData).toHaveBeenCalled() - expect(mockRgbCtx.putImageData).toHaveBeenCalled() + expect(mockRefs.maskCtx!.putImageData).toHaveBeenCalled() + expect(mockRefs.rgbCtx!.putImageData).toHaveBeenCalled() + expect(mockRefs.imgCtx!.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') + it('should not undo when no undo states available', () => { const history = useCanvasHistory() history.saveInitialState() + + vi.mocked(mockRefs.maskCtx!.putImageData).mockClear() + vi.mocked(mockRefs.rgbCtx!.putImageData).mockClear() + vi.mocked(mockRefs.imgCtx!.putImageData).mockClear() + history.undo() - expect(alertSpy).toHaveBeenCalledWith('No more undo states available') + expect(mockRefs.maskCtx!.putImageData).not.toHaveBeenCalled() + expect(mockRefs.rgbCtx!.putImageData).not.toHaveBeenCalled() + expect(mockRefs.imgCtx!.putImageData).not.toHaveBeenCalled() }) it('should undo multiple times', () => { @@ -249,16 +347,22 @@ describe('useCanvasHistory', () => { }) it('should not undo beyond first state', () => { - const alertSpy = vi.spyOn(window, 'alert') const history = useCanvasHistory() history.saveInitialState() history.saveState() history.undo() + + vi.mocked(mockRefs.maskCtx!.putImageData).mockClear() + vi.mocked(mockRefs.rgbCtx!.putImageData).mockClear() + vi.mocked(mockRefs.imgCtx!.putImageData).mockClear() + history.undo() - expect(alertSpy).toHaveBeenCalled() + expect(mockRefs.maskCtx!.putImageData).not.toHaveBeenCalled() + expect(mockRefs.rgbCtx!.putImageData).not.toHaveBeenCalled() + expect(mockRefs.imgCtx!.putImageData).not.toHaveBeenCalled() }) }) @@ -270,25 +374,33 @@ describe('useCanvasHistory', () => { history.saveState() history.undo() - mockMaskCtx.putImageData.mockClear() - mockRgbCtx.putImageData.mockClear() + vi.mocked(mockRefs.maskCtx!.putImageData).mockClear() + vi.mocked(mockRefs.rgbCtx!.putImageData).mockClear() + vi.mocked(mockRefs.imgCtx!.putImageData).mockClear() history.redo() - expect(mockMaskCtx.putImageData).toHaveBeenCalled() - expect(mockRgbCtx.putImageData).toHaveBeenCalled() + expect(mockRefs.maskCtx!.putImageData).toHaveBeenCalled() + expect(mockRefs.rgbCtx!.putImageData).toHaveBeenCalled() + expect(mockRefs.imgCtx!.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') + it('should not redo when no redo states available', () => { const history = useCanvasHistory() history.saveInitialState() + + vi.mocked(mockRefs.maskCtx!.putImageData).mockClear() + vi.mocked(mockRefs.rgbCtx!.putImageData).mockClear() + vi.mocked(mockRefs.imgCtx!.putImageData).mockClear() + history.redo() - expect(alertSpy).toHaveBeenCalledWith('No more redo states available') + expect(mockRefs.maskCtx!.putImageData).not.toHaveBeenCalled() + expect(mockRefs.rgbCtx!.putImageData).not.toHaveBeenCalled() + expect(mockRefs.imgCtx!.putImageData).not.toHaveBeenCalled() }) it('should redo multiple times', () => { @@ -314,7 +426,6 @@ describe('useCanvasHistory', () => { }) it('should not redo beyond last state', () => { - const alertSpy = vi.spyOn(window, 'alert') const history = useCanvasHistory() history.saveInitialState() @@ -322,9 +433,16 @@ describe('useCanvasHistory', () => { history.undo() history.redo() + + vi.mocked(mockRefs.maskCtx!.putImageData).mockClear() + vi.mocked(mockRefs.rgbCtx!.putImageData).mockClear() + vi.mocked(mockRefs.imgCtx!.putImageData).mockClear() + history.redo() - expect(alertSpy).toHaveBeenCalled() + expect(mockRefs.maskCtx!.putImageData).not.toHaveBeenCalled() + expect(mockRefs.rgbCtx!.putImageData).not.toHaveBeenCalled() + expect(mockRefs.imgCtx!.putImageData).not.toHaveBeenCalled() }) }) @@ -348,13 +466,15 @@ describe('useCanvasHistory', () => { history.saveInitialState() history.clearStates() - mockMaskCtx.getImageData.mockClear() - mockRgbCtx.getImageData.mockClear() + vi.mocked(mockRefs.maskCtx!.getImageData).mockClear() + vi.mocked(mockRefs.rgbCtx!.getImageData).mockClear() + vi.mocked(mockRefs.imgCtx!.getImageData).mockClear() history.saveInitialState() - expect(mockMaskCtx.getImageData).toHaveBeenCalled() - expect(mockRgbCtx.getImageData).toHaveBeenCalled() + expect(mockRefs.maskCtx!.getImageData).toHaveBeenCalled() + expect(mockRefs.rgbCtx!.getImageData).toHaveBeenCalled() + expect(mockRefs.imgCtx!.getImageData).toHaveBeenCalled() }) }) @@ -446,15 +566,17 @@ describe('useCanvasHistory', () => { history.saveInitialState() history.saveState() - mockStore.maskCtx = null - mockMaskCtx.putImageData.mockClear() - mockRgbCtx.putImageData.mockClear() + const savedMaskCtx = mockRefs.maskCtx + mockRefs.maskCtx = null + vi.mocked(savedMaskCtx!.putImageData).mockClear() + vi.mocked(mockRefs.rgbCtx!.putImageData).mockClear() + vi.mocked(mockRefs.imgCtx!.putImageData).mockClear() history.undo() - expect(mockMaskCtx.putImageData).not.toHaveBeenCalled() + expect(savedMaskCtx!.putImageData).not.toHaveBeenCalled() - mockStore.maskCtx = mockMaskCtx + mockRefs.maskCtx = savedMaskCtx }) }) @@ -499,8 +621,12 @@ describe('useCanvasHistory', () => { }) it('should handle zero-sized canvas', () => { - mockMaskCanvas.width = 0 - mockMaskCanvas.height = 0 + if (mockRefs.maskCanvas) { + mockRefs.maskCanvas = { + width: 0, + height: 0 + } as Partial as HTMLCanvasElement + } const history = useCanvasHistory() diff --git a/src/composables/maskeditor/useCanvasHistory.ts b/src/composables/maskeditor/useCanvasHistory.ts index 122e888c7..a7c394530 100644 --- a/src/composables/maskeditor/useCanvasHistory.ts +++ b/src/composables/maskeditor/useCanvasHistory.ts @@ -1,12 +1,17 @@ import { ref, computed } from 'vue' import { useMaskEditorStore } from '@/stores/maskEditorStore' +// Define the state interface for better readability +interface CanvasState { + mask: ImageData | ImageBitmap + rgb: ImageData | ImageBitmap + img: ImageData | ImageBitmap +} + export function useCanvasHistory(maxStates = 20) { const store = useMaskEditorStore() - const states = ref< - { mask: ImageData | ImageBitmap; rgb: ImageData | ImageBitmap }[] - >([]) + const states = ref([]) const currentStateIndex = ref(-1) const initialized = ref(false) @@ -22,22 +27,29 @@ export function useCanvasHistory(maxStates = 20) { }) const saveInitialState = () => { - const maskCtx = store.maskCtx - const rgbCtx = store.rgbCtx - const maskCanvas = store.maskCanvas - const rgbCanvas = store.rgbCanvas + const { maskCtx, rgbCtx, imgCtx, maskCanvas, rgbCanvas, imgCanvas } = store - if (!maskCtx || !rgbCtx || !maskCanvas || !rgbCanvas) { + // Ensure all 3 contexts and canvases are ready + if ( + !maskCtx || + !rgbCtx || + !imgCtx || + !maskCanvas || + !rgbCanvas || + !imgCanvas + ) { requestAnimationFrame(saveInitialState) return } - if (!maskCanvas.width || !rgbCanvas.width) { + if (!maskCanvas.width || !rgbCanvas.width || !imgCanvas.width) { requestAnimationFrame(saveInitialState) return } states.value = [] + + // Capture all three layers const maskState = maskCtx.getImageData( 0, 0, @@ -50,35 +62,51 @@ export function useCanvasHistory(maxStates = 20) { rgbCanvas.width, rgbCanvas.height ) - states.value.push({ mask: maskState, rgb: rgbState }) + const imgState = imgCtx.getImageData( + 0, + 0, + imgCanvas.width, + imgCanvas.height + ) + + states.value.push({ mask: maskState, rgb: rgbState, img: imgState }) currentStateIndex.value = 0 initialized.value = true } const saveState = ( providedMaskData?: ImageData | ImageBitmap, - providedRgbData?: ImageData | ImageBitmap + providedRgbData?: ImageData | ImageBitmap, + providedImgData?: ImageData | ImageBitmap ) => { - const maskCtx = store.maskCtx - const rgbCtx = store.rgbCtx - const maskCanvas = store.maskCanvas - const rgbCanvas = store.rgbCanvas + const { maskCtx, rgbCtx, imgCtx, maskCanvas, rgbCanvas, imgCanvas } = store - if (!maskCtx || !rgbCtx || !maskCanvas || !rgbCanvas) return + if ( + !maskCtx || + !rgbCtx || + !imgCtx || + !maskCanvas || + !rgbCanvas || + !imgCanvas + ) + return if (!initialized.value || currentStateIndex.value === -1) { saveInitialState() return } + // Clear redo history states.value = states.value.slice(0, currentStateIndex.value + 1) let maskState: ImageData | ImageBitmap let rgbState: ImageData | ImageBitmap + let imgState: ImageData | ImageBitmap - if (providedMaskData && providedRgbData) { + if (providedMaskData && providedRgbData && providedImgData) { maskState = providedMaskData rgbState = providedRgbData + imgState = providedImgData } else { maskState = maskCtx.getImageData( 0, @@ -87,71 +115,84 @@ export function useCanvasHistory(maxStates = 20) { maskCanvas.height ) rgbState = rgbCtx.getImageData(0, 0, rgbCanvas.width, rgbCanvas.height) + imgState = imgCtx.getImageData(0, 0, imgCanvas.width, imgCanvas.height) } - states.value.push({ mask: maskState, rgb: rgbState }) + states.value.push({ mask: maskState, rgb: rgbState, img: imgState }) currentStateIndex.value++ + // Maintain max history size and clean up memory if (states.value.length > maxStates) { const removed = states.value.shift() - // Cleanup ImageBitmaps to avoid memory leaks if (removed) { - if (removed.mask instanceof ImageBitmap) removed.mask.close() - if (removed.rgb instanceof ImageBitmap) removed.rgb.close() + cleanupState(removed) } currentStateIndex.value-- } } const undo = () => { - if (!canUndo.value) { - alert('No more undo states available') - return - } - + if (!canUndo.value) return currentStateIndex.value-- restoreState(states.value[currentStateIndex.value]) } const redo = () => { - if (!canRedo.value) { - alert('No more redo states available') - return - } - + if (!canRedo.value) return currentStateIndex.value++ restoreState(states.value[currentStateIndex.value]) } - const restoreState = (state: { - mask: ImageData | ImageBitmap - rgb: ImageData | ImageBitmap - }) => { - const maskCtx = store.maskCtx - const rgbCtx = store.rgbCtx - if (!maskCtx || !rgbCtx) return + const restoreState = (state: CanvasState) => { + const { maskCtx, rgbCtx, imgCtx, maskCanvas, rgbCanvas, imgCanvas } = store + if ( + !maskCtx || + !rgbCtx || + !imgCtx || + !maskCanvas || + !rgbCanvas || + !imgCanvas + ) + return - if (state.mask instanceof ImageBitmap) { - maskCtx.clearRect(0, 0, state.mask.width, state.mask.height) - maskCtx.drawImage(state.mask, 0, 0) - } else { - maskCtx.putImageData(state.mask, 0, 0) + // Update canvas dimensions to match state (handles rotation undo/redo) + const refData = state.mask + const newWidth = refData.width + const newHeight = refData.height + + if (maskCanvas.width !== newWidth || maskCanvas.height !== newHeight) { + maskCanvas.width = newWidth + maskCanvas.height = newHeight + rgbCanvas.width = newWidth + rgbCanvas.height = newHeight + imgCanvas.width = newWidth + imgCanvas.height = newHeight } - if (state.rgb instanceof ImageBitmap) { - rgbCtx.clearRect(0, 0, state.rgb.width, state.rgb.height) - rgbCtx.drawImage(state.rgb, 0, 0) - } else { - rgbCtx.putImageData(state.rgb, 0, 0) - } + const layers = [ + { ctx: maskCtx, data: state.mask }, + { ctx: rgbCtx, data: state.rgb }, + { ctx: imgCtx, data: state.img } + ] + + layers.forEach(({ ctx, data }) => { + if (data instanceof ImageBitmap) { + ctx.clearRect(0, 0, data.width, data.height) + ctx.drawImage(data, 0, 0) + } else { + ctx.putImageData(data, 0, 0) + } + }) + } + + const cleanupState = (state: CanvasState) => { + if (state.mask instanceof ImageBitmap) state.mask.close() + if (state.rgb instanceof ImageBitmap) state.rgb.close() + if (state.img instanceof ImageBitmap) state.img.close() } const clearStates = () => { - // Cleanup bitmaps - states.value.forEach((state) => { - if (state.mask instanceof ImageBitmap) state.mask.close() - if (state.rgb instanceof ImageBitmap) state.rgb.close() - }) + states.value.forEach(cleanupState) states.value = [] currentStateIndex.value = -1 initialized.value = false diff --git a/src/composables/maskeditor/useCanvasTransform.test.ts b/src/composables/maskeditor/useCanvasTransform.test.ts new file mode 100644 index 000000000..95c153d29 --- /dev/null +++ b/src/composables/maskeditor/useCanvasTransform.test.ts @@ -0,0 +1,683 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useCanvasTransform } from '@/composables/maskeditor/useCanvasTransform' + +interface IMockCanvas { + width: number + height: number +} + +interface IMockContext { + getImageData: ReturnType + putImageData: ReturnType + clearRect: ReturnType + drawImage: ReturnType +} + +interface IMockCanvasHistory { + saveState: ReturnType +} + +interface IMockStore { + maskCanvas: IMockCanvas | null + rgbCanvas: IMockCanvas | null + imgCanvas: IMockCanvas | null + maskCtx: IMockContext | null + rgbCtx: IMockContext | null + imgCtx: IMockContext | null + tgpuRoot: unknown + canvasHistory: IMockCanvasHistory + gpuTexturesNeedRecreation: boolean + gpuTextureWidth: number + gpuTextureHeight: number + pendingGPUMaskData: Uint8ClampedArray | null + pendingGPURgbData: Uint8ClampedArray | null +} + +const { mockStore, mockCanvasHistory } = vi.hoisted(() => { + const mockCanvasHistory: IMockCanvasHistory = { + saveState: vi.fn() + } + + const mockStore: IMockStore = { + maskCanvas: null, + rgbCanvas: null, + imgCanvas: null, + maskCtx: null, + rgbCtx: null, + imgCtx: null, + tgpuRoot: null, + canvasHistory: mockCanvasHistory, + gpuTexturesNeedRecreation: false, + gpuTextureWidth: 0, + gpuTextureHeight: 0, + pendingGPUMaskData: null, + pendingGPURgbData: null + } + + return { mockStore, mockCanvasHistory } +}) + +vi.mock('@/stores/maskEditorStore', () => ({ + useMaskEditorStore: vi.fn(() => mockStore) +})) + +// Mock ImageData with improved type safety +if (typeof globalThis.ImageData === 'undefined') { + globalThis.ImageData = class ImageData { + data: Uint8ClampedArray + width: number + height: number + + constructor( + dataOrWidth: Uint8ClampedArray | number, + widthOrHeight?: number, + height?: number + ) { + if (dataOrWidth instanceof Uint8ClampedArray) { + // Constructor overload: new ImageData(data, width, height) + if (widthOrHeight === undefined || height === undefined) { + throw new Error( + 'ImageData constructor requires width and height when data is provided' + ) + } + this.data = dataOrWidth + this.width = widthOrHeight + this.height = height + } else { + // Constructor overload: new ImageData(width, height) + if (widthOrHeight === undefined) { + throw new Error( + 'ImageData constructor requires height when width is provided' + ) + } + this.width = dataOrWidth + this.height = widthOrHeight + this.data = new Uint8ClampedArray(dataOrWidth * widthOrHeight * 4) + } + } + } as unknown as typeof globalThis.ImageData +} + +// Mock ImageBitmap for test environment using safe type casting +if (typeof globalThis.ImageBitmap === 'undefined') { + globalThis.ImageBitmap = class ImageBitmap { + width: number + height: number + constructor(width = 100, height = 100) { + this.width = width + this.height = height + } + close() {} + } as unknown as typeof globalThis.ImageBitmap +} + +describe('useCanvasTransform', () => { + let mockMaskCanvas: IMockCanvas + let mockRgbCanvas: IMockCanvas + let mockImgCanvas: IMockCanvas + let mockMaskCtx: IMockContext + let mockRgbCtx: IMockContext + let mockImgCtx: IMockContext + + beforeEach(() => { + vi.clearAllMocks() + + const createMockImageData = (width: number, height: number) => { + const data = new Uint8ClampedArray(width * height * 4) + for (let i = 0; i < data.length; i += 4) { + data[i] = 255 // R + data[i + 1] = 0 // G + data[i + 2] = 0 // B + data[i + 3] = 255 // A + } + return { + data, + width, + height + } as ImageData + } + + mockMaskCtx = { + getImageData: vi.fn((_x, _y, w, h) => createMockImageData(w, h)), + putImageData: vi.fn(), + clearRect: vi.fn(), + drawImage: vi.fn() + } + + mockRgbCtx = { + getImageData: vi.fn((_x, _y, w, h) => createMockImageData(w, h)), + putImageData: vi.fn(), + clearRect: vi.fn(), + drawImage: vi.fn() + } + + mockImgCtx = { + getImageData: vi.fn((_x, _y, w, h) => createMockImageData(w, h)), + putImageData: vi.fn(), + clearRect: vi.fn(), + drawImage: vi.fn() + } + + mockMaskCanvas = { + width: 100, + height: 50 + } + + mockRgbCanvas = { + width: 100, + height: 50 + } + + mockImgCanvas = { + width: 100, + height: 50 + } + + mockStore.maskCanvas = mockMaskCanvas + mockStore.rgbCanvas = mockRgbCanvas + mockStore.imgCanvas = mockImgCanvas + mockStore.maskCtx = mockMaskCtx + mockStore.rgbCtx = mockRgbCtx + mockStore.imgCtx = mockImgCtx + mockStore.tgpuRoot = null + mockStore.gpuTexturesNeedRecreation = false + mockStore.gpuTextureWidth = 0 + mockStore.gpuTextureHeight = 0 + mockStore.pendingGPUMaskData = null + mockStore.pendingGPURgbData = null + }) + + describe('rotateClockwise', () => { + it('should rotate canvas 90 degrees clockwise', async () => { + const transform = useCanvasTransform() + await transform.rotateClockwise() + + expect(mockMaskCanvas.width).toBe(50) + expect(mockMaskCanvas.height).toBe(100) + expect(mockRgbCanvas.width).toBe(50) + expect(mockRgbCanvas.height).toBe(100) + expect(mockImgCanvas.width).toBe(50) + expect(mockImgCanvas.height).toBe(100) + }) + + it('should call getImageData with original dimensions', async () => { + const transform = useCanvasTransform() + await transform.rotateClockwise() + + expect(mockMaskCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 50) + expect(mockRgbCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 50) + expect(mockImgCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 50) + }) + + it('should call putImageData with rotated data', async () => { + const transform = useCanvasTransform() + await transform.rotateClockwise() + + expect(mockMaskCtx.putImageData).toHaveBeenCalled() + expect(mockRgbCtx.putImageData).toHaveBeenCalled() + expect(mockImgCtx.putImageData).toHaveBeenCalled() + + const maskCall = mockMaskCtx.putImageData.mock.calls[0][0] + expect(maskCall.width).toBe(50) + expect(maskCall.height).toBe(100) + }) + + it('should save transformed state to history', async () => { + const transform = useCanvasTransform() + await transform.rotateClockwise() + + expect(mockCanvasHistory.saveState).toHaveBeenCalled() + + const savedArgs = mockCanvasHistory.saveState.mock.calls[0] + expect(savedArgs).toHaveLength(3) + + expect(savedArgs[0].width).toBe(50) + expect(savedArgs[0].height).toBe(100) + expect(savedArgs[1].width).toBe(50) + expect(savedArgs[1].height).toBe(100) + expect(savedArgs[2].width).toBe(50) + expect(savedArgs[2].height).toBe(100) + }) + + it('should log error when canvas contexts not ready', async () => { + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) + mockStore.maskCanvas = null + + const transform = useCanvasTransform() + await transform.rotateClockwise() + + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[useCanvasTransform] Canvas contexts not ready' + ) + + consoleErrorSpy.mockRestore() + }) + + it('should handle GPU texture recreation when GPU is active', async () => { + mockStore.tgpuRoot = {} + + const transform = useCanvasTransform() + await transform.rotateClockwise() + + expect(mockStore.gpuTexturesNeedRecreation).toBe(true) + expect(mockStore.gpuTextureWidth).toBe(50) + expect(mockStore.gpuTextureHeight).toBe(100) + }) + + it('should not recreate GPU textures when GPU is inactive', async () => { + mockStore.tgpuRoot = null + + const transform = useCanvasTransform() + await transform.rotateClockwise() + + expect(mockStore.gpuTexturesNeedRecreation).toBe(false) + }) + + it('should correctly rotate pixels clockwise at pixel level', async () => { + mockMaskCanvas.width = 2 + mockMaskCanvas.height = 2 + + const createTestPattern = () => { + const data = new Uint8ClampedArray(2 * 2 * 4) + // TL (0,0): Red + data[0] = 255 + data[1] = 0 + data[2] = 0 + data[3] = 255 + // TR (1,0): Green + data[4] = 0 + data[5] = 255 + data[6] = 0 + data[7] = 255 + // BL (0,1): Blue + data[8] = 0 + data[9] = 0 + data[10] = 255 + data[11] = 255 + // BR (1,1): Yellow + data[12] = 255 + data[13] = 255 + data[14] = 0 + data[15] = 255 + return { data, width: 2, height: 2 } as ImageData + } + + mockMaskCtx.getImageData = vi.fn(() => createTestPattern()) + const transform = useCanvasTransform() + await transform.rotateClockwise() + + const result = mockMaskCtx.putImageData.mock.calls[0][0] as ImageData + + // After clockwise rotation: + // New TL should be old BL (Blue) + expect(result.data[0]).toBe(0) // R + expect(result.data[1]).toBe(0) // G + expect(result.data[2]).toBe(255) // B + expect(result.data[3]).toBe(255) // A + + // New TR should be old TL (Red) + expect(result.data[4]).toBe(255) // R + expect(result.data[5]).toBe(0) // G + expect(result.data[6]).toBe(0) // B + expect(result.data[7]).toBe(255) // A + + // New BL should be old BR (Yellow) + expect(result.data[8]).toBe(255) // R + expect(result.data[9]).toBe(255) // G + expect(result.data[10]).toBe(0) // B + expect(result.data[11]).toBe(255) // A + + // New BR should be old TR (Green) + expect(result.data[12]).toBe(0) // R + expect(result.data[13]).toBe(255) // G + expect(result.data[14]).toBe(0) // B + expect(result.data[15]).toBe(255) // A + }) + }) + + describe('rotateCounterclockwise', () => { + it('should rotate canvas 90 degrees counterclockwise', async () => { + const transform = useCanvasTransform() + await transform.rotateCounterclockwise() + + expect(mockMaskCanvas.width).toBe(50) + expect(mockMaskCanvas.height).toBe(100) + }) + + it('should call getImageData with original dimensions', async () => { + const transform = useCanvasTransform() + await transform.rotateCounterclockwise() + + expect(mockMaskCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 50) + }) + + it('should correctly rotate pixels counterclockwise at pixel level', async () => { + mockMaskCanvas.width = 2 + mockMaskCanvas.height = 2 + + const createTestPattern = () => { + const data = new Uint8ClampedArray(2 * 2 * 4) + // TL (0,0): Red + data[0] = 255 + data[1] = 0 + data[2] = 0 + data[3] = 255 + // TR (1,0): Green + data[4] = 0 + data[5] = 255 + data[6] = 0 + data[7] = 255 + // BL (0,1): Blue + data[8] = 0 + data[9] = 0 + data[10] = 255 + data[11] = 255 + // BR (1,1): Yellow + data[12] = 255 + data[13] = 255 + data[14] = 0 + data[15] = 255 + return { data, width: 2, height: 2 } as ImageData + } + + mockMaskCtx.getImageData = vi.fn(() => createTestPattern()) + const transform = useCanvasTransform() + await transform.rotateCounterclockwise() + + const result = mockMaskCtx.putImageData.mock.calls[0][0] as ImageData + + // After counterclockwise rotation: + // New TL should be old TR (Green) + expect(result.data[0]).toBe(0) // R + expect(result.data[1]).toBe(255) // G + expect(result.data[2]).toBe(0) // B + expect(result.data[3]).toBe(255) // A + + // New TR should be old BR (Yellow) + expect(result.data[4]).toBe(255) // R + expect(result.data[5]).toBe(255) // G + expect(result.data[6]).toBe(0) // B + expect(result.data[7]).toBe(255) // A + + // New BL should be old TL (Red) + expect(result.data[8]).toBe(255) // R + expect(result.data[9]).toBe(0) // G + expect(result.data[10]).toBe(0) // B + expect(result.data[11]).toBe(255) // A + + // New BR should be old BL (Blue) + expect(result.data[12]).toBe(0) // R + expect(result.data[13]).toBe(0) // G + expect(result.data[14]).toBe(255) // B + expect(result.data[15]).toBe(255) // A + }) + + it('should produce different result than clockwise rotation', async () => { + const transform = useCanvasTransform() + + const createAsymmetricImageData = (width: number, height: number) => { + const data = new Uint8ClampedArray(width * height * 4) + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const i = (y * width + x) * 4 + if (x < width / 2 && y < height / 2) { + data[i] = 255 + data[i + 3] = 255 + } else { + data[i + 3] = 255 + } + } + } + return { data, width, height } as ImageData + } + + mockMaskCtx.getImageData = vi.fn(() => createAsymmetricImageData(100, 50)) + await transform.rotateCounterclockwise() + const ccwResult = mockMaskCtx.putImageData.mock.calls[0][0] as ImageData + + mockMaskCanvas.width = 100 + mockMaskCanvas.height = 50 + mockMaskCtx.putImageData.mockClear() + + mockMaskCtx.getImageData = vi.fn(() => createAsymmetricImageData(100, 50)) + await transform.rotateClockwise() + const cwResult = mockMaskCtx.putImageData.mock.calls[0][0] as ImageData + + let pixelDifferences = 0 + for (let i = 0; i < ccwResult.data.length; i++) { + if (ccwResult.data[i] !== cwResult.data[i]) { + pixelDifferences++ + } + } + + expect(pixelDifferences).toBeGreaterThan(0) + }) + }) + + describe('mirrorHorizontal', () => { + it('should mirror canvas horizontally', async () => { + const transform = useCanvasTransform() + await transform.mirrorHorizontal() + + expect(mockMaskCanvas.width).toBe(100) + expect(mockMaskCanvas.height).toBe(50) + }) + + it('should handle GPU texture recreation when GPU is active', async () => { + mockStore.tgpuRoot = {} + + const transform = useCanvasTransform() + await transform.mirrorHorizontal() + + expect(mockStore.gpuTexturesNeedRecreation).toBe(true) + expect(mockStore.gpuTextureWidth).toBe(100) + expect(mockStore.gpuTextureHeight).toBe(50) + }) + + it('should correctly flip pixels horizontally at pixel level', async () => { + mockMaskCanvas.width = 2 + mockMaskCanvas.height = 2 + + const createTestPattern = () => { + const data = new Uint8ClampedArray(2 * 2 * 4) + // TL (0,0): Red + data[0] = 255 + data[1] = 0 + data[2] = 0 + data[3] = 255 + // TR (1,0): Green + data[4] = 0 + data[5] = 255 + data[6] = 0 + data[7] = 255 + // BL (0,1): Blue + data[8] = 0 + data[9] = 0 + data[10] = 255 + data[11] = 255 + // BR (1,1): Yellow + data[12] = 255 + data[13] = 255 + data[14] = 0 + data[15] = 255 + return { data, width: 2, height: 2 } as ImageData + } + + mockMaskCtx.getImageData = vi.fn(() => createTestPattern()) + const transform = useCanvasTransform() + await transform.mirrorHorizontal() + + const result = mockMaskCtx.putImageData.mock.calls[0][0] as ImageData + + // After horizontal flip: + // New TL should be old TR (Green) + expect(result.data[0]).toBe(0) + expect(result.data[1]).toBe(255) + // New TR should be old TL (Red) + expect(result.data[4]).toBe(255) + expect(result.data[5]).toBe(0) + }) + }) + + describe('mirrorVertical', () => { + it('should mirror canvas vertically', async () => { + const transform = useCanvasTransform() + await transform.mirrorVertical() + + expect(mockMaskCanvas.width).toBe(100) + expect(mockMaskCanvas.height).toBe(50) + }) + + it('should handle GPU texture recreation when GPU is active', async () => { + mockStore.tgpuRoot = {} + + const transform = useCanvasTransform() + await transform.mirrorVertical() + + expect(mockStore.gpuTexturesNeedRecreation).toBe(true) + expect(mockStore.gpuTextureWidth).toBe(100) + expect(mockStore.gpuTextureHeight).toBe(50) + }) + + it('should correctly flip pixels vertically at pixel level', async () => { + mockMaskCanvas.width = 2 + mockMaskCanvas.height = 2 + + const createTestPattern = () => { + const data = new Uint8ClampedArray(2 * 2 * 4) + // TL (0,0): Red + data[0] = 255 + data[1] = 0 + data[2] = 0 + data[3] = 255 + // TR (1,0): Green + data[4] = 0 + data[5] = 255 + data[6] = 0 + data[7] = 255 + // BL (0,1): Blue + data[8] = 0 + data[9] = 0 + data[10] = 255 + data[11] = 255 + // BR (1,1): Yellow + data[12] = 255 + data[13] = 255 + data[14] = 0 + data[15] = 255 + return { data, width: 2, height: 2 } as ImageData + } + + mockMaskCtx.getImageData = vi.fn(() => createTestPattern()) + const transform = useCanvasTransform() + await transform.mirrorVertical() + + const result = mockMaskCtx.putImageData.mock.calls[0][0] as ImageData + + // After vertical flip: + // New TL should be old BL (Blue) + expect(result.data[0]).toBe(0) // R + expect(result.data[1]).toBe(0) // G + expect(result.data[2]).toBe(255) // B + expect(result.data[3]).toBe(255) // A + + // New TR should be old BR (Yellow) + expect(result.data[4]).toBe(255) // R + expect(result.data[5]).toBe(255) // G + expect(result.data[6]).toBe(0) // B + expect(result.data[7]).toBe(255) // A + + // New BL should be old TL (Red) + expect(result.data[8]).toBe(255) // R + expect(result.data[9]).toBe(0) // G + expect(result.data[10]).toBe(0) // B + expect(result.data[11]).toBe(255) // A + + // New BR should be old TR (Green) + expect(result.data[12]).toBe(0) // R + expect(result.data[13]).toBe(255) // G + expect(result.data[14]).toBe(0) // B + expect(result.data[15]).toBe(255) // A + }) + + it('should log error when canvas contexts not ready', async () => { + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) + mockStore.maskCanvas = null + + const transform = useCanvasTransform() + await transform.mirrorVertical() + + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[useCanvasTransform] Canvas contexts not ready' + ) + + consoleErrorSpy.mockRestore() + }) + }) + + describe('GPU integration', () => { + it('should set GPU recreation flags for rotation', async () => { + mockStore.tgpuRoot = {} + mockMaskCanvas.width = 100 + mockMaskCanvas.height = 50 + + const transform = useCanvasTransform() + await transform.rotateClockwise() + + expect(mockStore.gpuTexturesNeedRecreation).toBe(true) + expect(mockStore.gpuTextureWidth).toBe(50) + expect(mockStore.gpuTextureHeight).toBe(100) + expect(mockStore.pendingGPUMaskData!.length).toBe(50 * 100 * 4) + expect(mockStore.pendingGPURgbData!.length).toBe(50 * 100 * 4) + }) + + it('should premultiply alpha when preparing GPU data', async () => { + mockStore.tgpuRoot = {} + mockMaskCanvas.width = 1 + mockMaskCanvas.height = 1 + + // Create 1x1 ImageData with semi-transparent pixel + const createSemiTransparentImageData = () => { + const data = new Uint8ClampedArray(1 * 1 * 4) + data[0] = 200 // R + data[1] = 100 // G + data[2] = 50 // B + data[3] = 128 // A (50% opacity) + return { data, width: 1, height: 1 } as ImageData + } + + mockMaskCtx.getImageData = vi.fn(() => createSemiTransparentImageData()) + mockRgbCtx.getImageData = vi.fn(() => createSemiTransparentImageData()) + mockImgCtx.getImageData = vi.fn(() => createSemiTransparentImageData()) + + const transform = useCanvasTransform() + await transform.rotateClockwise() + + // Verify pendingGPUMaskData contains premultiplied values + expect(mockStore.pendingGPUMaskData).not.toBeNull() + const maskData = mockStore.pendingGPUMaskData! + + // Expected premultiplied values: RGB * alpha / 255 + // R: 200 * 128 / 255 ≈ 100 + // G: 100 * 128 / 255 ≈ 50 + // B: 50 * 128 / 255 ≈ 25 + // A: 128 (preserved) + expect(maskData[0]).toBeCloseTo(100, 0) // R premultiplied + expect(maskData[1]).toBeCloseTo(50, 0) // G premultiplied + expect(maskData[2]).toBeCloseTo(25, 0) // B premultiplied + expect(maskData[3]).toBe(128) // A preserved + + // Also verify RGB canvas data + expect(mockStore.pendingGPURgbData).not.toBeNull() + const rgbData = mockStore.pendingGPURgbData! + expect(rgbData[0]).toBeCloseTo(100, 0) + expect(rgbData[1]).toBeCloseTo(50, 0) + expect(rgbData[2]).toBeCloseTo(25, 0) + expect(rgbData[3]).toBe(128) + }) + }) +}) diff --git a/src/composables/maskeditor/useCanvasTransform.ts b/src/composables/maskeditor/useCanvasTransform.ts new file mode 100644 index 000000000..1b08c4117 --- /dev/null +++ b/src/composables/maskeditor/useCanvasTransform.ts @@ -0,0 +1,359 @@ +import { useMaskEditorStore } from '@/stores/maskEditorStore' + +/** + * Composable for canvas transformation operations (rotate, mirror) + */ +export function useCanvasTransform() { + const store = useMaskEditorStore() + + /** + * Rotates a canvas 90 degrees clockwise or counter-clockwise + */ + const rotateCanvas = ( + ctx: CanvasRenderingContext2D, + canvas: HTMLCanvasElement, + clockwise: boolean + ): ImageData => { + const width = canvas.width + const height = canvas.height + + // Get current canvas data + const sourceData = ctx.getImageData(0, 0, width, height) + + // Create new ImageData with swapped dimensions + const rotatedData = new ImageData(height, width) + const src = sourceData.data + const dst = rotatedData.data + + // Rotate pixel by pixel + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const srcIdx = (y * width + x) * 4 + + // Calculate destination coordinates + let dstX: number, dstY: number + if (clockwise) { + // Rotate 90° clockwise: (x,y) -> (height-1-y, x) + dstX = height - 1 - y + dstY = x + } else { + // Rotate 90° counter-clockwise: (x,y) -> (y, width-1-x) + dstX = y + dstY = width - 1 - x + } + + const dstIdx = (dstY * height + dstX) * 4 + + // Copy RGBA values + dst[dstIdx] = src[srcIdx] + dst[dstIdx + 1] = src[srcIdx + 1] + dst[dstIdx + 2] = src[srcIdx + 2] + dst[dstIdx + 3] = src[srcIdx + 3] + } + } + + return rotatedData + } + + /** + * Mirrors a canvas horizontally or vertically + */ + const mirrorCanvas = ( + ctx: CanvasRenderingContext2D, + canvas: HTMLCanvasElement, + horizontal: boolean + ): ImageData => { + const width = canvas.width + const height = canvas.height + + // Get current canvas data + const sourceData = ctx.getImageData(0, 0, width, height) + const mirroredData = new ImageData(width, height) + const src = sourceData.data + const dst = mirroredData.data + + // Mirror pixel by pixel + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const srcIdx = (y * width + x) * 4 + + // Calculate destination coordinates + let dstX: number, dstY: number + if (horizontal) { + // Mirror horizontally: flip X axis + dstX = width - 1 - x + dstY = y + } else { + // Mirror vertically: flip Y axis + dstX = x + dstY = height - 1 - y + } + + const dstIdx = (dstY * width + dstX) * 4 + + // Copy RGBA values + dst[dstIdx] = src[srcIdx] + dst[dstIdx + 1] = src[srcIdx + 1] + dst[dstIdx + 2] = src[srcIdx + 2] + dst[dstIdx + 3] = src[srcIdx + 3] + } + } + + return mirroredData + } + + /** + * Premultiplies alpha for GPU upload + */ + const premultiplyData = (data: Uint8ClampedArray): void => { + for (let i = 0; i < data.length; i += 4) { + const a = data[i + 3] / 255 + data[i] = Math.round(data[i] * a) + data[i + 1] = Math.round(data[i + 1] * a) + data[i + 2] = Math.round(data[i + 2] * a) + } + } + + /** + * Recreates and updates GPU textures after transformation + * This is required because GPU textures have immutable dimensions + */ + const recreateGPUTextures = async ( + width: number, + height: number + ): Promise => { + if ( + !store.tgpuRoot || + !store.maskCanvas || + !store.rgbCanvas || + !store.maskCtx || + !store.rgbCtx + ) { + return + } + + // Get references to GPU resources from useBrushDrawing + // These are stored as module-level variables in useBrushDrawing + // We need to trigger a reinitialization through the store + + // Signal to useBrushDrawing that textures need recreation + store.gpuTexturesNeedRecreation = true + store.gpuTextureWidth = width + store.gpuTextureHeight = height + + // Get current canvas data + const maskImageData = store.maskCtx.getImageData(0, 0, width, height) + const rgbImageData = store.rgbCtx.getImageData(0, 0, width, height) + + // Create new Uint8ClampedArray with ArrayBuffer (not SharedArrayBuffer) + // This ensures compatibility with WebGPU writeTexture + const maskData = new Uint8ClampedArray( + new ArrayBuffer(maskImageData.data.length) + ) + const rgbData = new Uint8ClampedArray( + new ArrayBuffer(rgbImageData.data.length) + ) + + // Copy data + maskData.set(maskImageData.data) + rgbData.set(rgbImageData.data) + + // Runtime check to ensure we have ArrayBuffer backing + if ( + maskData.buffer instanceof SharedArrayBuffer || + rgbData.buffer instanceof SharedArrayBuffer + ) { + console.error( + '[useCanvasTransform] SharedArrayBuffer detected, WebGPU writeTexture will fail' + ) + return + } + + // Premultiply alpha for GPU + premultiplyData(maskData) + premultiplyData(rgbData) + + // Store the premultiplied data for useBrushDrawing to pick up + store.pendingGPUMaskData = maskData + store.pendingGPURgbData = rgbData + } + + /** + * Rotates all canvas layers 90 degrees clockwise and updates GPU + */ + const rotateClockwise = async (): Promise => { + const { maskCanvas, maskCtx, rgbCanvas, rgbCtx, imgCanvas, imgCtx } = store + + if ( + !maskCanvas || + !maskCtx || + !rgbCanvas || + !rgbCtx || + !imgCanvas || + !imgCtx + ) { + console.error('[useCanvasTransform] Canvas contexts not ready') + return + } + + // Store original dimensions + const origWidth = maskCanvas.width + const origHeight = maskCanvas.height + + // Rotate all three layers clockwise + const rotatedMask = rotateCanvas(maskCtx, maskCanvas, true) + const rotatedRgb = rotateCanvas(rgbCtx, rgbCanvas, true) + const rotatedImg = rotateCanvas(imgCtx, imgCanvas, true) + + // Update canvas dimensions (swap width/height) + maskCanvas.width = origHeight + maskCanvas.height = origWidth + rgbCanvas.width = origHeight + rgbCanvas.height = origWidth + imgCanvas.width = origHeight + imgCanvas.height = origWidth + + // Apply rotated data + maskCtx.putImageData(rotatedMask, 0, 0) + rgbCtx.putImageData(rotatedRgb, 0, 0) + imgCtx.putImageData(rotatedImg, 0, 0) + + // Recreate GPU textures with new dimensions if GPU is active + if (store.tgpuRoot) { + await recreateGPUTextures(origHeight, origWidth) + } + + // Save to history + store.canvasHistory.saveState(rotatedMask, rotatedRgb, rotatedImg) + } + + /** + * Rotates all canvas layers 90 degrees counter-clockwise and updates GPU + */ + const rotateCounterclockwise = async (): Promise => { + const { maskCanvas, maskCtx, rgbCanvas, rgbCtx, imgCanvas, imgCtx } = store + + if ( + !maskCanvas || + !maskCtx || + !rgbCanvas || + !rgbCtx || + !imgCanvas || + !imgCtx + ) { + console.error('[useCanvasTransform] Canvas contexts not ready') + return + } + + // Store original dimensions + const origWidth = maskCanvas.width + const origHeight = maskCanvas.height + + // Rotate all three layers counter-clockwise + const rotatedMask = rotateCanvas(maskCtx, maskCanvas, false) + const rotatedRgb = rotateCanvas(rgbCtx, rgbCanvas, false) + const rotatedImg = rotateCanvas(imgCtx, imgCanvas, false) + + // Update canvas dimensions (swap width/height) + maskCanvas.width = origHeight + maskCanvas.height = origWidth + rgbCanvas.width = origHeight + rgbCanvas.height = origWidth + imgCanvas.width = origHeight + imgCanvas.height = origWidth + + // Apply rotated data + maskCtx.putImageData(rotatedMask, 0, 0) + rgbCtx.putImageData(rotatedRgb, 0, 0) + imgCtx.putImageData(rotatedImg, 0, 0) + + // Recreate GPU textures with new dimensions if GPU is active + if (store.tgpuRoot) { + await recreateGPUTextures(origHeight, origWidth) + } + + // Save to history + store.canvasHistory.saveState(rotatedMask, rotatedRgb, rotatedImg) + } + + /** + * Mirrors all canvas layers horizontally and updates GPU + */ + const mirrorHorizontal = async (): Promise => { + const { maskCanvas, maskCtx, rgbCanvas, rgbCtx, imgCanvas, imgCtx } = store + + if ( + !maskCanvas || + !maskCtx || + !rgbCanvas || + !rgbCtx || + !imgCanvas || + !imgCtx + ) { + console.error('[useCanvasTransform] Canvas contexts not ready') + return + } + + // Mirror all three layers horizontally + const mirroredMask = mirrorCanvas(maskCtx, maskCanvas, true) + const mirroredRgb = mirrorCanvas(rgbCtx, rgbCanvas, true) + const mirroredImg = mirrorCanvas(imgCtx, imgCanvas, true) + + // Apply mirrored data (dimensions stay the same) + maskCtx.putImageData(mirroredMask, 0, 0) + rgbCtx.putImageData(mirroredRgb, 0, 0) + imgCtx.putImageData(mirroredImg, 0, 0) + + // Update GPU textures if GPU is active (dimensions unchanged, just data) + if (store.tgpuRoot) { + await recreateGPUTextures(maskCanvas.width, maskCanvas.height) + } + + // Save to history + store.canvasHistory.saveState(mirroredMask, mirroredRgb, mirroredImg) + } + + /** + * Mirrors all canvas layers vertically and updates GPU + */ + const mirrorVertical = async (): Promise => { + const { maskCanvas, maskCtx, rgbCanvas, rgbCtx, imgCanvas, imgCtx } = store + + if ( + !maskCanvas || + !maskCtx || + !rgbCanvas || + !rgbCtx || + !imgCanvas || + !imgCtx + ) { + console.error('[useCanvasTransform] Canvas contexts not ready') + return + } + + // Mirror all three layers vertically + const mirroredMask = mirrorCanvas(maskCtx, maskCanvas, false) + const mirroredRgb = mirrorCanvas(rgbCtx, rgbCanvas, false) + const mirroredImg = mirrorCanvas(imgCtx, imgCanvas, false) + + // Apply mirrored data (dimensions stay the same) + maskCtx.putImageData(mirroredMask, 0, 0) + rgbCtx.putImageData(mirroredRgb, 0, 0) + imgCtx.putImageData(mirroredImg, 0, 0) + + // Update GPU textures if GPU is active (dimensions unchanged, just data) + if (store.tgpuRoot) { + await recreateGPUTextures(maskCanvas.width, maskCanvas.height) + } + + // Save to history + store.canvasHistory.saveState(mirroredMask, mirroredRgb, mirroredImg) + } + + return { + rotateClockwise, + rotateCounterclockwise, + mirrorHorizontal, + mirrorVertical + } +} diff --git a/src/composables/useCoreCommands.ts b/src/composables/useCoreCommands.ts index 032f1f57b..4a5369b0c 100644 --- a/src/composables/useCoreCommands.ts +++ b/src/composables/useCoreCommands.ts @@ -64,6 +64,9 @@ import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTyp import { useWorkflowTemplateSelectorDialog } from './useWorkflowTemplateSelectorDialog' +import { useMaskEditorStore } from '@/stores/maskEditorStore' +import { useDialogStore } from '@/stores/dialogStore' + const { isActiveSubscription, showSubscriptionDialog } = useSubscription() const moveSelectedNodesVersionAdded = '1.22.2' @@ -82,6 +85,9 @@ export function useCoreCommands(): ComfyCommand[] { const bottomPanelStore = useBottomPanelStore() + const dialogStore = useDialogStore() + const maskEditorStore = useMaskEditorStore() + const { getSelectedNodes, toggleSelectedNodesMode } = useSelectedLiteGraphItems() const getTracker = () => workflowStore.activeWorkflow?.changeTracker @@ -207,7 +213,12 @@ export function useCoreCommands(): ComfyCommand[] { label: 'Undo', category: 'essentials' as const, function: async () => { - await getTracker()?.undo?.() + // If Mask Editor is open, use its history instead of the graph + if (dialogStore.isDialogOpen('global-mask-editor')) { + maskEditorStore.canvasHistory.undo() + } else { + await getTracker()?.undo?.() + } } }, { @@ -216,7 +227,11 @@ export function useCoreCommands(): ComfyCommand[] { label: 'Redo', category: 'essentials' as const, function: async () => { - await getTracker()?.redo?.() + if (dialogStore.isDialogOpen('global-mask-editor')) { + maskEditorStore.canvasHistory.redo() + } else { + await getTracker()?.redo?.() + } } }, { diff --git a/src/extensions/core/maskeditor.ts b/src/extensions/core/maskeditor.ts index 406fdcd7d..550bec00a 100644 --- a/src/extensions/core/maskeditor.ts +++ b/src/extensions/core/maskeditor.ts @@ -5,6 +5,7 @@ import { app, ComfyApp } from '@/scripts/app' import { useMaskEditorStore } from '@/stores/maskEditorStore' import { useDialogStore } from '@/stores/dialogStore' import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor' +import { useCanvasTransform } from '@/composables/maskeditor/useCanvasTransform' function openMaskEditor(node: LGraphNode): void { if (!node) { @@ -109,6 +110,42 @@ app.registerExtension({ const store = useMaskEditorStore() store.colorInput?.click() } + }, + { + id: 'Comfy.MaskEditor.Rotate.Right', + icon: 'pi pi-refresh', + label: 'Rotate Right in MaskEditor', + function: async () => { + if (!isOpened()) return + await useCanvasTransform().rotateClockwise() + } + }, + { + id: 'Comfy.MaskEditor.Rotate.Left', + icon: 'pi pi-undo', + label: 'Rotate Left in MaskEditor', + function: async () => { + if (!isOpened()) return + await useCanvasTransform().rotateCounterclockwise() + } + }, + { + id: 'Comfy.MaskEditor.Mirror.Horizontal', + icon: 'pi pi-arrows-h', + label: 'Mirror Horizontal in MaskEditor', + function: async () => { + if (!isOpened()) return + await useCanvasTransform().mirrorHorizontal() + } + }, + { + id: 'Comfy.MaskEditor.Mirror.Vertical', + icon: 'pi pi-arrows-v', + label: 'Mirror Vertical in MaskEditor', + function: async () => { + if (!isOpened()) return + await useCanvasTransform().mirrorVertical() + } } ], init() { diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 6cdd9db2e..44a6d1d3e 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -977,6 +977,10 @@ "clear": "Clear", "undo": "Undo", "redo": "Redo", + "rotateLeft": "Rotate Left", + "rotateRight": "Rotate Right", + "mirrorHorizontal": "Mirror Horizontal", + "mirrorVertical": "Mirror Vertical", "clickToResetZoom": "Click to reset zoom", "brushSettings": "Brush Settings", "brushShape": "Brush Shape", diff --git a/src/stores/maskEditorStore.ts b/src/stores/maskEditorStore.ts index d2140e845..515a3dee3 100644 --- a/src/stores/maskEditorStore.ts +++ b/src/stores/maskEditorStore.ts @@ -75,6 +75,21 @@ export const useMaskEditorStore = defineStore('maskEditor', () => { const colorInput = ref(null) + // GPU texture recreation signals + /** + * GPU texture data must use ArrayBuffer (not SharedArrayBuffer) for compatibility + * with WebGPU's device.queue.writeTexture API. SharedArrayBuffer is not accepted + * by the WebGPU specification and will cause runtime errors. + * + * @see https://gpuweb.github.io/gpuweb/#dom-gpuqueue-writetexture + */ + type GPUCompatibleArray = Uint8ClampedArray & { buffer: ArrayBuffer } + const gpuTexturesNeedRecreation = ref(false) + const gpuTextureWidth = ref(0) + const gpuTextureHeight = ref(0) + const pendingGPUMaskData = ref(null) + const pendingGPURgbData = ref(null) + watch(maskCanvas, (canvas) => { if (canvas) { maskCtx.value = canvas.getContext('2d', { willReadFrequently: true }) @@ -208,6 +223,13 @@ export const useMaskEditorStore = defineStore('maskEditor', () => { panOffset.value = { x: 0, y: 0 } cursorPoint.value = { x: 0, y: 0 } maskOpacity.value = 0.8 + + // Reset GPU recreation flags + gpuTexturesNeedRecreation.value = false + gpuTextureWidth.value = 0 + gpuTextureHeight.value = 0 + pendingGPUMaskData.value = null + pendingGPURgbData.value = null } return { @@ -254,6 +276,13 @@ export const useMaskEditorStore = defineStore('maskEditor', () => { tgpuRoot, + // GPU texture recreation signals + gpuTexturesNeedRecreation, + gpuTextureWidth, + gpuTextureHeight, + pendingGPUMaskData, + pendingGPURgbData, + colorInput, setBrushSize,