Added MaskEditor Rotate and Mirror Functions (#7841)

# Canvas Rotation and Mirroring

## Overview
Adds rotation (90° left/right) and mirroring (horizontal/vertical)
capabilities to the mask editor canvas. All three layers (image, mask,
RGB) transform together. Redo and Undo respect transformations as new
states. Keyboard shortcuts also added for all four functions in
Keybinding settings.

Additionally, fixed the issue of ctrl+z and ctrl+y keyboard commands not
restricting to the mask editor canvas while opened.


https://github.com/user-attachments/assets/fb8d5347-b357-4a3a-840a-721cdf8a6125

## What Changed

### New Files
- **`src/composables/maskeditor/useCanvasTransform.ts`**
  - Core transformation logic for rotation and mirroring
  - GPU texture recreation after transformations

### Modified Files
#### **`src/composables/useCoreCommands.ts`**
- Added check to see if Mask Editor is opened for undo and redo commands

#### **`src/stores/maskEditorStore.ts`**
- Added GPU texture recreation signals

#### **`src/composables/maskeditor/useBrushDrawing.ts`**
- Added watcher for `gpuTexturesNeedRecreation` signal
- Handles GPU texture recreation when canvas dimensions change
- Recreates textures with new dimensions after rotation
- Updates preview canvas and readback buffers accordingly
- Ensures proper ArrayBuffer backing for WebGPU compatibility

#### **`src/components/maskeditor/TopBarHeader.vue`**
- Added 4 new transform buttons with icons:
  - Rotate Left (counter-clockwise)
  - Rotate Right (clockwise)
  - Mirror Horizontal
  - Mirror Vertical
- Added visual separators between button groups

#### **`src/extensions/core/maskEditor.ts`**
- Added keyboard shortcut settings for rotate and mirror

#### **Translation Files** (e.g., `src/locales/en.json`)
- Added i18n keys:

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7841-Added-MaskEditor-Rotate-and-Mirror-Functions-2de6d73d365081bc9b84ea4919a3c6a1)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
brucew4yn3rp
2026-01-10 15:45:08 -05:00
committed by GitHub
parent 8086f977c9
commit 7bc6334065
11 changed files with 1648 additions and 128 deletions

View File

@@ -4,6 +4,7 @@
class="maskEditor-dialog-root flex h-full w-full flex-col"
@contextmenu.prevent
@dragstart="handleDragStart"
@keydown.stop
>
<div
id="maskEditorCanvasContainer"

View File

@@ -11,7 +11,7 @@
>
<svg
viewBox="0 0 15 15"
class="h-6.25 w-6.25 pointer-events-none fill-[var(--input-text)]"
class="h-6.25 w-6.25 pointer-events-none fill-current"
>
<path
d="M8.77,12.18c-.25,0-.46-.2-.46-.46s.2-.46.46-.46c1.47,0,2.67-1.2,2.67-2.67,0-1.57-1.34-2.67-3.26-2.67h-3.98l1.43,1.43c.18.18.18.47,0,.64-.18.18-.47.18-.64,0l-2.21-2.21c-.18-.18-.18-.47,0-.64l2.21-2.21c.18-.18.47-.18.64,0,.18.18.18.47,0,.64l-1.43,1.43h3.98c2.45,0,4.17,1.47,4.17,3.58,0,1.97-1.61,3.58-3.58,3.58Z"
@@ -35,6 +35,74 @@
</svg>
</button>
<div class="h-5 border-l border-border" />
<button
:class="iconButtonClass"
:title="t('maskEditor.rotateLeft')"
@click="onRotateLeft"
>
<svg
viewBox="-6 -7 15 15"
class="h-6.25 w-6.25 pointer-events-none fill-[var(--input-text)]"
>
<path
d="m2.25-2.625c.3452 0 .625.2798.625.625v5c0 .3452-.2798.625-.625.625h-5c-.3452 0-.625-.2798-.625-.625v-5c0-.3452.2798-.625.625-.625h5zm1.25.625v5c0 .6904-.5596 1.25-1.25 1.25h-5c-.6904 0-1.25-.5596-1.25-1.25v-5c0-.6904.5596-1.25 1.25-1.25h5c.6904 0 1.25.5596 1.25 1.25zm-.1673-2.3757-.4419.4419-1.5246-1.5246 1.5416-1.5417.442.4419-.7871.7872h.9373c1.3807 0 2.5 1.1193 2.5 2.5h-.625c0-1.0355-.8395-1.875-1.875-1.875h-.9375l.7702.7702z"
/>
</svg>
</button>
<button
:class="iconButtonClass"
:title="t('maskEditor.rotateRight')"
@click="onRotateRight"
>
<svg
viewBox="-9 -7 15 15"
class="h-6.25 w-6.25 pointer-events-none fill-[var(--input-text)]"
>
<g transform="scale(-1, 1)">
<path
d="m2.25-2.625c.3452 0 .625.2798.625.625v5c0 .3452-.2798.625-.625.625h-5c-.3452 0-.625-.2798-.625-.625v-5c0-.3452.2798-.625.625-.625h5zm1.25.625v5c0 .6904-.5596 1.25-1.25 1.25h-5c-.6904 0-1.25-.5596-1.25-1.25v-5c0-.6904.5596-1.25 1.25-1.25h5c.6904 0 1.25.5596 1.25 1.25zm-.1673-2.3757-.4419.4419-1.5246-1.5246 1.5416-1.5417.442.4419-.7871.7872h.9373c1.3807 0 2.5 1.1193 2.5 2.5h-.625c0-1.0355-.8395-1.875-1.875-1.875h-.9375l.7702.7702z"
/>
</g>
</svg>
</button>
<button
:class="iconButtonClass"
:title="t('maskEditor.mirrorHorizontal')"
@click="onMirrorHorizontal"
>
<svg
viewBox="0 0 15 15"
class="h-6.25 w-6.25 pointer-events-none fill-[var(--input-text)]"
>
<path
d="M7.5,1.5c-.28,0-.5.22-.5.5v11c0,.28.22.5.5.5s.5-.22.5-.5v-11c0-.28-.22-.5-.5-.5Z"
/>
<path d="M3.5,4.5l-2,3,2,3v-6ZM11.5,4.5v6l2-3-2-3Z" />
</svg>
</button>
<button
:class="iconButtonClass"
:title="t('maskEditor.mirrorVertical')"
@click="onMirrorVertical"
>
<svg
viewBox="0 0 15 15"
class="h-6.25 w-6.25 pointer-events-none fill-[var(--input-text)]"
>
<path
d="M2,7.5c0-.28.22-.5.5-.5h11c.28,0,.5.22.5.5s-.22.5-.5.5h-11c-.28,0-.5-.22-.5-.5Z"
/>
<path d="M4.5,4.5l3-2,3,2h-6ZM4.5,10.5h6l-3,2-3-2Z" />
</svg>
</button>
<div class="h-5 w-px bg-[var(--p-form-field-border-color)]" />
<button :class="textButtonClass" @click="onInvert">
{{ t('maskEditor.invert') }}
</button>
@@ -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()
}

View File

@@ -1,3 +1,4 @@
/// <reference types="@webgpu/types" />
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) {

View File

@@ -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<CanvasRenderingContext2D> as CanvasRenderingContext2D
mockRgbCtx = {
mockRefs.rgbCtx = {
getImageData: vi.fn(() => createMockImageData()),
putImageData: vi.fn(),
clearRect: vi.fn(),
drawImage: vi.fn()
}
} as Partial<CanvasRenderingContext2D> as CanvasRenderingContext2D
mockMaskCanvas = {
mockRefs.imgCtx = {
getImageData: vi.fn(() => createMockImageData()),
putImageData: vi.fn(),
clearRect: vi.fn(),
drawImage: vi.fn()
} as Partial<CanvasRenderingContext2D> as CanvasRenderingContext2D
// Mock canvases using explicit partial-cast pattern
mockRefs.maskCanvas = {
width: 100,
height: 100
}
} as Partial<HTMLCanvasElement> as HTMLCanvasElement
mockRgbCanvas = {
mockRefs.rgbCanvas = {
width: 100,
height: 100
}
} as Partial<HTMLCanvasElement> as HTMLCanvasElement
mockStore.maskCanvas = mockMaskCanvas
mockStore.rgbCanvas = mockRgbCanvas
mockStore.maskCtx = mockMaskCtx
mockStore.rgbCtx = mockRgbCtx
mockRefs.imgCanvas = {
width: 100,
height: 100
} as Partial<HTMLCanvasElement> 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<HTMLCanvasElement> as HTMLCanvasElement
const history = useCanvasHistory()
history.saveInitialState()
expect(rafSpy).toHaveBeenCalled()
mockStore.maskCanvas = mockMaskCanvas
mockRefs.maskCanvas = {
width: 100,
height: 100
} as Partial<HTMLCanvasElement> 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<CanvasRenderingContext2D> 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<HTMLCanvasElement> as HTMLCanvasElement
}
const history = useCanvasHistory()

View File

@@ -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<CanvasState[]>([])
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

View File

@@ -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<typeof vi.fn>
putImageData: ReturnType<typeof vi.fn>
clearRect: ReturnType<typeof vi.fn>
drawImage: ReturnType<typeof vi.fn>
}
interface IMockCanvasHistory {
saveState: ReturnType<typeof vi.fn>
}
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)
})
})
})

View File

@@ -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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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
}
}

View File

@@ -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?.()
}
}
},
{

View File

@@ -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() {

View File

@@ -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",

View File

@@ -75,6 +75,21 @@ export const useMaskEditorStore = defineStore('maskEditor', () => {
const colorInput = ref<HTMLInputElement | null>(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<boolean>(false)
const gpuTextureWidth = ref<number>(0)
const gpuTextureHeight = ref<number>(0)
const pendingGPUMaskData = ref<GPUCompatibleArray | null>(null)
const pendingGPURgbData = ref<GPUCompatibleArray | null>(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,