mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
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:
@@ -4,6 +4,7 @@
|
||||
class="maskEditor-dialog-root flex h-full w-full flex-col"
|
||||
@contextmenu.prevent
|
||||
@dragstart="handleDragStart"
|
||||
@keydown.stop
|
||||
>
|
||||
<div
|
||||
id="maskEditorCanvasContainer"
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
683
src/composables/maskeditor/useCanvasTransform.test.ts
Normal file
683
src/composables/maskeditor/useCanvasTransform.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
359
src/composables/maskeditor/useCanvasTransform.ts
Normal file
359
src/composables/maskeditor/useCanvasTransform.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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?.()
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user