fully refactor mask editor into vue-based (#6629)

## Summary

This PR refactors the mask editor from a vanilla JavaScript
implementation to Vue 3 + Composition API, aligning it with the ComfyUI
frontend's modern architecture. This is a structural refactor without UI
changes - all visual appearances and user interactions remain identical.

Net change: +1,700 lines (mostly tests)

## Changes

- Converted from class-based managers to Vue 3 Composition API
- Migrated state management to Pinia stores (maskEditorStore,
maskEditorDataStore)
- Split monolithic managers into focused composables:
    - useBrushDrawing - Brush rendering and drawing logic
    - useCanvasManager - Canvas lifecycle and operations
    - useCanvasTools - Tool-specific canvas operations
    - usePanAndZoom - Pan and zoom functionality
    - useToolManager - Tool selection and coordination
    - useKeyboard - Keyboard shortcuts
    - useMaskEditorLoader/Saver - Data loading and saving
    - useCoordinateTransform - Coordinate system transformations
- Replaced imperative DOM manipulation with Vue components
- Added comprehensive test coverage

## What This PR Does NOT Change

  Preserved Original Styling:
  - Original CSS retained in packages/design-system/src/css/style.css
- Some generic controls (DropdownControl, SliderControl, ToggleControl)
preserved as-is
- Future migration to Tailwind and PrimeVue components is planned but
out of scope for this PR

  Preserved Core Functionality:
  - Drawing algorithms and brush rendering logic remain unchanged
  - Pan/zoom calculations preserved
  - Canvas operations (composite modes, image processing) unchanged
  - Tool behaviors (brush, color select, paint bucket) identical
  - No changes to mask generation or export logic

DO NOT Review:
  -  CSS styling choices (preserved from original)
  - Drawing algorithm implementations (unchanged)
  -  Canvas rendering logic (ported as-is)
  - UI/UX changes (none exist)
  - Component library choices (future work)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6629-fully-refactor-mask-editor-into-vue-based-2a46d73d36508114ab8bd2984b4b54e4)
by [Unito](https://www.unito.io)
This commit is contained in:
Terry Jia
2025-11-13 23:57:03 -05:00
committed by GitHub
parent f80fc4cf9a
commit 1a6913c466
55 changed files with 6674 additions and 5756 deletions

View File

@@ -0,0 +1,263 @@
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
import _ from 'es-toolkit/compat'
import {
BrushShape,
ColorComparisonMethod,
MaskBlendMode,
Tools
} from '@/extensions/core/maskeditor/types'
import type {
Brush,
ImageLayer,
Offset,
Point
} from '@/extensions/core/maskeditor/types'
import { useCanvasHistory } from '@/composables/maskeditor/useCanvasHistory'
export const useMaskEditorStore = defineStore('maskEditor', () => {
const brushSettings = ref<Brush>({
type: BrushShape.Arc,
size: 10,
opacity: 0.7,
hardness: 1,
smoothingPrecision: 10
})
const maskBlendMode = ref<MaskBlendMode>(MaskBlendMode.Black)
const activeLayer = ref<ImageLayer>('mask')
const rgbColor = ref<string>('#FF0000')
const currentTool = ref<Tools>(Tools.MaskPen)
const isAdjustingBrush = ref<boolean>(false)
const paintBucketTolerance = ref<number>(5)
const fillOpacity = ref<number>(100)
const colorSelectTolerance = ref<number>(20)
const colorSelectLivePreview = ref<boolean>(false)
const colorComparisonMethod = ref<ColorComparisonMethod>(
ColorComparisonMethod.Simple
)
const applyWholeImage = ref<boolean>(false)
const maskBoundary = ref<boolean>(false)
const maskTolerance = ref<number>(0)
const selectionOpacity = ref<number>(100)
const zoomRatio = ref<number>(1)
const displayZoomRatio = ref<number>(1)
const panOffset = ref<Offset>({ x: 0, y: 0 })
const cursorPoint = ref<Point>({ x: 0, y: 0 })
const resetZoomTrigger = ref<number>(0)
const maskCanvas = ref<HTMLCanvasElement | null>(null)
const maskCtx = ref<CanvasRenderingContext2D | null>(null)
const rgbCanvas = ref<HTMLCanvasElement | null>(null)
const rgbCtx = ref<CanvasRenderingContext2D | null>(null)
const imgCanvas = ref<HTMLCanvasElement | null>(null)
const imgCtx = ref<CanvasRenderingContext2D | null>(null)
const canvasContainer = ref<HTMLElement | null>(null)
const canvasBackground = ref<HTMLElement | null>(null)
const pointerZone = ref<HTMLElement | null>(null)
const image = ref<HTMLImageElement | null>(null)
const maskOpacity = ref<number>(0.8)
const brushVisible = ref<boolean>(true)
const isPanning = ref<boolean>(false)
const brushPreviewGradientVisible = ref<boolean>(false)
const canvasHistory = useCanvasHistory(20)
watch(maskCanvas, (canvas) => {
if (canvas) {
maskCtx.value = canvas.getContext('2d', { willReadFrequently: true })
}
})
watch(rgbCanvas, (canvas) => {
if (canvas) {
rgbCtx.value = canvas.getContext('2d', { willReadFrequently: true })
}
})
watch(imgCanvas, (canvas) => {
if (canvas) {
imgCtx.value = canvas.getContext('2d', { willReadFrequently: true })
}
})
const canUndo = computed(() => {
return canvasHistory.canUndo.value
})
const canRedo = computed(() => {
return canvasHistory.canRedo.value
})
const maskColor = computed(() => {
switch (maskBlendMode.value) {
case MaskBlendMode.Black:
return { r: 0, g: 0, b: 0 }
case MaskBlendMode.White:
return { r: 255, g: 255, b: 255 }
case MaskBlendMode.Negative:
return { r: 255, g: 255, b: 255 }
default:
return { r: 0, g: 0, b: 0 }
}
})
function setBrushSize(size: number): void {
brushSettings.value.size = _.clamp(size, 1, 100)
}
function setBrushOpacity(opacity: number): void {
brushSettings.value.opacity = _.clamp(opacity, 0, 1)
}
function setBrushHardness(hardness: number): void {
brushSettings.value.hardness = _.clamp(hardness, 0, 1)
}
function setBrushSmoothingPrecision(precision: number): void {
brushSettings.value.smoothingPrecision = _.clamp(precision, 1, 100)
}
function resetBrushToDefault(): void {
brushSettings.value.type = BrushShape.Arc
brushSettings.value.size = 20
brushSettings.value.opacity = 1
brushSettings.value.hardness = 1
brushSettings.value.smoothingPrecision = 60
}
function setPaintBucketTolerance(tolerance: number): void {
paintBucketTolerance.value = _.clamp(tolerance, 0, 255)
}
function setFillOpacity(opacity: number): void {
fillOpacity.value = _.clamp(opacity, 0, 100)
}
function setColorSelectTolerance(tolerance: number): void {
colorSelectTolerance.value = _.clamp(tolerance, 0, 255)
}
function setMaskTolerance(tolerance: number): void {
maskTolerance.value = _.clamp(tolerance, 0, 255)
}
function setSelectionOpacity(opacity: number): void {
selectionOpacity.value = _.clamp(opacity, 0, 100)
}
function setZoomRatio(ratio: number): void {
zoomRatio.value = Math.max(0.1, Math.min(10, ratio))
}
function setPanOffset(offset: Offset): void {
panOffset.value = { ...offset }
}
function setCursorPoint(point: Point): void {
cursorPoint.value = { ...point }
}
function resetZoom(): void {
resetZoomTrigger.value++
}
function setMaskOpacity(opacity: number): void {
maskOpacity.value = _.clamp(opacity, 0, 1)
}
function resetState(): void {
brushSettings.value = {
type: BrushShape.Arc,
size: 10,
opacity: 0.7,
hardness: 1,
smoothingPrecision: 10
}
maskBlendMode.value = MaskBlendMode.Black
activeLayer.value = 'mask'
rgbColor.value = '#FF0000'
currentTool.value = Tools.MaskPen
isAdjustingBrush.value = false
paintBucketTolerance.value = 5
fillOpacity.value = 100
colorSelectTolerance.value = 20
colorSelectLivePreview.value = false
colorComparisonMethod.value = ColorComparisonMethod.Simple
applyWholeImage.value = false
maskBoundary.value = false
maskTolerance.value = 0
selectionOpacity.value = 100
zoomRatio.value = 1
panOffset.value = { x: 0, y: 0 }
cursorPoint.value = { x: 0, y: 0 }
maskOpacity.value = 0.8
}
return {
brushSettings,
maskBlendMode,
activeLayer,
rgbColor,
currentTool,
isAdjustingBrush,
paintBucketTolerance,
fillOpacity,
colorSelectTolerance,
colorSelectLivePreview,
colorComparisonMethod,
applyWholeImage,
maskBoundary,
maskTolerance,
selectionOpacity,
zoomRatio,
displayZoomRatio,
panOffset,
cursorPoint,
resetZoomTrigger,
maskCanvas,
maskCtx,
rgbCanvas,
rgbCtx,
imgCanvas,
imgCtx,
canvasContainer,
canvasBackground,
pointerZone,
image,
maskOpacity,
canUndo,
canRedo,
maskColor,
brushVisible,
isPanning,
brushPreviewGradientVisible,
canvasHistory,
setBrushSize,
setBrushOpacity,
setBrushHardness,
setBrushSmoothingPrecision,
resetBrushToDefault,
setPaintBucketTolerance,
setFillOpacity,
setColorSelectTolerance,
setMaskTolerance,
setSelectionOpacity,
setZoomRatio,
setPanOffset,
setCursorPoint,
resetZoom,
setMaskOpacity,
resetState
}
})