mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-28 10:12:11 +00:00
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:
97
src/components/maskeditor/BrushCursor.vue
Normal file
97
src/components/maskeditor/BrushCursor.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div
|
||||
id="maskEditor_brush"
|
||||
:style="{
|
||||
position: 'absolute',
|
||||
opacity: brushOpacity,
|
||||
width: `${brushSize}px`,
|
||||
height: `${brushSize}px`,
|
||||
left: `${brushLeft}px`,
|
||||
top: `${brushTop}px`,
|
||||
borderRadius: borderRadius,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1000
|
||||
}"
|
||||
>
|
||||
<div
|
||||
id="maskEditor_brushPreviewGradient"
|
||||
:style="{
|
||||
display: gradientVisible ? 'block' : 'none',
|
||||
background: gradientBackground
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { BrushShape } from '@/extensions/core/maskeditor/types'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
const { containerRef } = defineProps<{
|
||||
containerRef?: HTMLElement
|
||||
}>()
|
||||
|
||||
const store = useMaskEditorStore()
|
||||
|
||||
const brushOpacity = computed(() => {
|
||||
return store.brushVisible ? '1' : '0'
|
||||
})
|
||||
|
||||
const brushRadius = computed(() => {
|
||||
return store.brushSettings.size * store.zoomRatio
|
||||
})
|
||||
|
||||
const brushSize = computed(() => {
|
||||
return brushRadius.value * 2
|
||||
})
|
||||
|
||||
const brushLeft = computed(() => {
|
||||
const dialogRect = containerRef?.getBoundingClientRect()
|
||||
const dialogOffsetLeft = dialogRect?.left || 0
|
||||
return (
|
||||
store.cursorPoint.x +
|
||||
store.panOffset.x -
|
||||
brushRadius.value -
|
||||
dialogOffsetLeft
|
||||
)
|
||||
})
|
||||
|
||||
const brushTop = computed(() => {
|
||||
const dialogRect = containerRef?.getBoundingClientRect()
|
||||
const dialogOffsetTop = dialogRect?.top || 0
|
||||
return (
|
||||
store.cursorPoint.y +
|
||||
store.panOffset.y -
|
||||
brushRadius.value -
|
||||
dialogOffsetTop
|
||||
)
|
||||
})
|
||||
|
||||
const borderRadius = computed(() => {
|
||||
return store.brushSettings.type === BrushShape.Rect ? '0%' : '50%'
|
||||
})
|
||||
|
||||
const gradientVisible = computed(() => {
|
||||
return store.brushPreviewGradientVisible
|
||||
})
|
||||
|
||||
const gradientBackground = computed(() => {
|
||||
const hardness = store.brushSettings.hardness
|
||||
|
||||
if (hardness === 1) {
|
||||
return 'rgba(255, 0, 0, 0.5)'
|
||||
}
|
||||
|
||||
const midStop = hardness * 100
|
||||
const outerStop = 100
|
||||
|
||||
return `radial-gradient(
|
||||
circle,
|
||||
rgba(255, 0, 0, 0.5) 0%,
|
||||
rgba(255, 0, 0, 0.25) ${midStop}%,
|
||||
rgba(255, 0, 0, 0) ${outerStop}%
|
||||
)`
|
||||
})
|
||||
</script>
|
||||
129
src/components/maskeditor/BrushSettingsPanel.vue
Normal file
129
src/components/maskeditor/BrushSettingsPanel.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3 pb-3">
|
||||
<h3
|
||||
class="text-center text-[15px] font-sans text-[var(--descrip-text)] mt-2.5"
|
||||
>
|
||||
{{ t('maskEditor.brushSettings') }}
|
||||
</h3>
|
||||
|
||||
<button
|
||||
class="w-45 h-7.5 border-none bg-black/20 border border-[var(--border-color)] text-[var(--input-text)] font-sans text-[15px] pointer-events-auto transition-colors duration-100 hover:bg-[var(--p-overlaybadge-outline-color)] hover:border-none"
|
||||
@click="resetToDefault"
|
||||
>
|
||||
{{ t('maskEditor.resetToDefault') }}
|
||||
</button>
|
||||
|
||||
<div class="flex flex-col gap-3 pb-3">
|
||||
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
|
||||
t('maskEditor.brushShape')
|
||||
}}</span>
|
||||
<div
|
||||
class="flex flex-row gap-2.5 items-center min-h-6 relative h-[50px] w-full rounded-[10px] bg-[var(--p-surface-300)] dark-theme:bg-[var(--p-surface-800)]"
|
||||
>
|
||||
<div
|
||||
class="maskEditor_sidePanelBrushShapeCircle bg-transparent hover:bg-[var(--comfy-menu-bg)] dark-theme:hover:bg-[var(--p-surface-900)]"
|
||||
:class="{ active: store.brushSettings.type === BrushShape.Arc }"
|
||||
:style="{
|
||||
background:
|
||||
store.brushSettings.type === BrushShape.Arc
|
||||
? 'var(--p-button-text-primary-color)'
|
||||
: ''
|
||||
}"
|
||||
@click="setBrushShape(BrushShape.Arc)"
|
||||
></div>
|
||||
<div
|
||||
class="maskEditor_sidePanelBrushShapeSquare bg-transparent hover:bg-[var(--comfy-menu-bg)] dark-theme:hover:bg-[var(--p-surface-900)]"
|
||||
:class="{ active: store.brushSettings.type === BrushShape.Rect }"
|
||||
:style="{
|
||||
background:
|
||||
store.brushSettings.type === BrushShape.Rect
|
||||
? 'var(--p-button-text-primary-color)'
|
||||
: ''
|
||||
}"
|
||||
@click="setBrushShape(BrushShape.Rect)"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 pb-3">
|
||||
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
|
||||
t('maskEditor.colorSelector')
|
||||
}}</span>
|
||||
<input type="color" :value="store.rgbColor" @input="onColorChange" />
|
||||
</div>
|
||||
|
||||
<SliderControl
|
||||
:label="t('maskEditor.thickness')"
|
||||
:min="1"
|
||||
:max="100"
|
||||
:step="1"
|
||||
:model-value="store.brushSettings.size"
|
||||
@update:model-value="onThicknessChange"
|
||||
/>
|
||||
|
||||
<SliderControl
|
||||
:label="t('maskEditor.opacity')"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.01"
|
||||
:model-value="store.brushSettings.opacity"
|
||||
@update:model-value="onOpacityChange"
|
||||
/>
|
||||
|
||||
<SliderControl
|
||||
:label="t('maskEditor.hardness')"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.01"
|
||||
:model-value="store.brushSettings.hardness"
|
||||
@update:model-value="onHardnessChange"
|
||||
/>
|
||||
|
||||
<SliderControl
|
||||
:label="t('maskEditor.smoothingPrecision')"
|
||||
:min="1"
|
||||
:max="100"
|
||||
:step="1"
|
||||
:model-value="store.brushSettings.smoothingPrecision"
|
||||
@update:model-value="onSmoothingPrecisionChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { BrushShape } from '@/extensions/core/maskeditor/types'
|
||||
import { t } from '@/i18n'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
import SliderControl from './controls/SliderControl.vue'
|
||||
|
||||
const store = useMaskEditorStore()
|
||||
|
||||
const setBrushShape = (shape: BrushShape) => {
|
||||
store.brushSettings.type = shape
|
||||
}
|
||||
|
||||
const onColorChange = (event: Event) => {
|
||||
store.rgbColor = (event.target as HTMLInputElement).value
|
||||
}
|
||||
|
||||
const onThicknessChange = (value: number) => {
|
||||
store.setBrushSize(value)
|
||||
}
|
||||
|
||||
const onOpacityChange = (value: number) => {
|
||||
store.setBrushOpacity(value)
|
||||
}
|
||||
|
||||
const onHardnessChange = (value: number) => {
|
||||
store.setBrushHardness(value)
|
||||
}
|
||||
|
||||
const onSmoothingPrecisionChange = (value: number) => {
|
||||
store.setBrushSmoothingPrecision(value)
|
||||
}
|
||||
|
||||
const resetToDefault = () => {
|
||||
store.resetBrushToDefault()
|
||||
}
|
||||
</script>
|
||||
103
src/components/maskeditor/ColorSelectSettingsPanel.vue
Normal file
103
src/components/maskeditor/ColorSelectSettingsPanel.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3 pb-3">
|
||||
<h3
|
||||
class="text-center text-[15px] font-sans text-[var(--descrip-text)] mt-2.5"
|
||||
>
|
||||
{{ t('maskEditor.colorSelectSettings') }}
|
||||
</h3>
|
||||
|
||||
<SliderControl
|
||||
:label="t('maskEditor.tolerance')"
|
||||
:min="0"
|
||||
:max="255"
|
||||
:step="1"
|
||||
:model-value="store.colorSelectTolerance"
|
||||
@update:model-value="onToleranceChange"
|
||||
/>
|
||||
|
||||
<SliderControl
|
||||
:label="t('maskEditor.selectionOpacity')"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="1"
|
||||
:model-value="store.selectionOpacity"
|
||||
@update:model-value="onSelectionOpacityChange"
|
||||
/>
|
||||
|
||||
<ToggleControl
|
||||
:label="t('maskEditor.livePreview')"
|
||||
:model-value="store.colorSelectLivePreview"
|
||||
@update:model-value="onLivePreviewChange"
|
||||
/>
|
||||
|
||||
<ToggleControl
|
||||
:label="t('maskEditor.applyToWholeImage')"
|
||||
:model-value="store.applyWholeImage"
|
||||
@update:model-value="onWholeImageChange"
|
||||
/>
|
||||
|
||||
<DropdownControl
|
||||
:label="t('maskEditor.method')"
|
||||
:options="methodOptions"
|
||||
:model-value="store.colorComparisonMethod"
|
||||
@update:model-value="onMethodChange"
|
||||
/>
|
||||
|
||||
<ToggleControl
|
||||
:label="t('maskEditor.stopAtMask')"
|
||||
:model-value="store.maskBoundary"
|
||||
@update:model-value="onMaskBoundaryChange"
|
||||
/>
|
||||
|
||||
<SliderControl
|
||||
:label="t('maskEditor.maskTolerance')"
|
||||
:min="0"
|
||||
:max="255"
|
||||
:step="1"
|
||||
:model-value="store.maskTolerance"
|
||||
@update:model-value="onMaskToleranceChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ColorComparisonMethod } from '@/extensions/core/maskeditor/types'
|
||||
import { t } from '@/i18n'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
import DropdownControl from './controls/DropdownControl.vue'
|
||||
import SliderControl from './controls/SliderControl.vue'
|
||||
import ToggleControl from './controls/ToggleControl.vue'
|
||||
|
||||
const store = useMaskEditorStore()
|
||||
|
||||
const methodOptions = Object.values(ColorComparisonMethod)
|
||||
|
||||
const onToleranceChange = (value: number) => {
|
||||
store.setColorSelectTolerance(value)
|
||||
}
|
||||
|
||||
const onSelectionOpacityChange = (value: number) => {
|
||||
store.setSelectionOpacity(value)
|
||||
}
|
||||
|
||||
const onLivePreviewChange = (value: boolean) => {
|
||||
store.colorSelectLivePreview = value
|
||||
}
|
||||
|
||||
const onWholeImageChange = (value: boolean) => {
|
||||
store.applyWholeImage = value
|
||||
}
|
||||
|
||||
const onMethodChange = (value: string | number) => {
|
||||
store.colorComparisonMethod = value as ColorComparisonMethod
|
||||
}
|
||||
|
||||
const onMaskBoundaryChange = (value: boolean) => {
|
||||
store.maskBoundary = value
|
||||
}
|
||||
|
||||
const onMaskToleranceChange = (value: number) => {
|
||||
store.setMaskTolerance(value)
|
||||
}
|
||||
</script>
|
||||
227
src/components/maskeditor/ImageLayerSettingsPanel.vue
Normal file
227
src/components/maskeditor/ImageLayerSettingsPanel.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3 pb-3">
|
||||
<h3
|
||||
class="text-center text-[15px] font-sans text-[var(--descrip-text)] mt-2.5"
|
||||
>
|
||||
{{ t('maskEditor.layers') }}
|
||||
</h3>
|
||||
|
||||
<SliderControl
|
||||
:label="t('maskEditor.maskOpacity')"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.01"
|
||||
:model-value="store.maskOpacity"
|
||||
@update:model-value="onMaskOpacityChange"
|
||||
/>
|
||||
|
||||
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
|
||||
t('maskEditor.maskBlendingOptions')
|
||||
}}</span>
|
||||
|
||||
<div
|
||||
class="flex flex-row gap-2.5 items-center min-h-6 relative h-[50px] w-full rounded-[10px] -mt-2 -mb-1.5"
|
||||
>
|
||||
<select
|
||||
class="maskEditor_sidePanelDropdown"
|
||||
:value="store.maskBlendMode"
|
||||
@change="onBlendModeChange"
|
||||
>
|
||||
<option value="black">{{ t('maskEditor.black') }}</option>
|
||||
<option value="white">{{ t('maskEditor.white') }}</option>
|
||||
<option value="negative">{{ t('maskEditor.negative') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
|
||||
t('maskEditor.maskLayer')
|
||||
}}</span>
|
||||
<div
|
||||
class="flex flex-row gap-2.5 items-center min-h-6 relative h-[50px] w-full rounded-[10px] bg-[var(--p-surface-300)] dark-theme:bg-[var(--p-surface-800)]"
|
||||
:style="{
|
||||
border: store.activeLayer === 'mask' ? '2px solid #007acc' : 'none'
|
||||
}"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="maskEditor_sidePanelLayerCheckbox"
|
||||
:checked="maskLayerVisible"
|
||||
@change="onMaskLayerVisibilityChange"
|
||||
/>
|
||||
<div class="maskEditor_sidePanelLayerPreviewContainer">
|
||||
<svg viewBox="0 0 20 20" style="">
|
||||
<path
|
||||
class="cls-1"
|
||||
d="M1.31,5.32v9.36c0,.55.45,1,1,1h15.38c.55,0,1-.45,1-1V5.32c0-.55-.45-1-1-1H2.31c-.55,0-1,.45-1,1ZM11.19,13.44c-2.91.94-5.57-1.72-4.63-4.63.34-1.05,1.19-1.9,2.24-2.24,2.91-.94,5.57,1.72,4.63,4.63-.34,1.05-1.19-1.9-2.24,2.24Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<button
|
||||
style="font-size: 12px"
|
||||
:style="{ opacity: store.activeLayer === 'mask' ? '0.5' : '1' }"
|
||||
:disabled="store.activeLayer === 'mask'"
|
||||
@click="setActiveLayer('mask')"
|
||||
>
|
||||
{{ t('maskEditor.activateLayer') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
|
||||
t('maskEditor.paintLayer')
|
||||
}}</span>
|
||||
<div
|
||||
class="flex flex-row gap-2.5 items-center min-h-6 relative h-[50px] w-full rounded-[10px] bg-[var(--p-surface-300)] dark-theme:bg-[var(--p-surface-800)]"
|
||||
:style="{
|
||||
border: store.activeLayer === 'rgb' ? '2px solid #007acc' : 'none'
|
||||
}"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="maskEditor_sidePanelLayerCheckbox"
|
||||
:checked="paintLayerVisible"
|
||||
@change="onPaintLayerVisibilityChange"
|
||||
/>
|
||||
<div class="maskEditor_sidePanelLayerPreviewContainer">
|
||||
<svg viewBox="0 0 20 20">
|
||||
<path
|
||||
class="cls-1"
|
||||
d="M 17 6.965 c 0 0.235 -0.095 0.47 -0.275 0.655 l -6.51 6.52 c -0.045 0.035 -0.09 0.075 -0.135 0.11 c -0.035 -0.695 -0.605 -1.24 -1.305 -1.245 c 0.035 -0.06 0.08 -0.12 0.135 -0.17 l 6.52 -6.52 c 0.36 -0.36 0.945 -0.36 1.3 0 c 0.175 0.175 0.275 0.415 0.275 0.65 Z"
|
||||
/>
|
||||
<path
|
||||
class="cls-1"
|
||||
d="M 9.82 14.515 c 0 2.23 -3.23 1.59 -4.82 0 c 1.65 -0.235 2.375 -1.29 3.53 -1.29 c 0.715 0 1.29 0.58 1.29 1.29 Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<button
|
||||
style="font-size: 12px"
|
||||
:style="{
|
||||
opacity: store.activeLayer === 'rgb' ? '0.5' : '1',
|
||||
display: showLayerButtons ? 'block' : 'none'
|
||||
}"
|
||||
:disabled="store.activeLayer === 'rgb'"
|
||||
@click="setActiveLayer('rgb')"
|
||||
>
|
||||
{{ t('maskEditor.activateLayer') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
|
||||
t('maskEditor.baseImageLayer')
|
||||
}}</span>
|
||||
<div
|
||||
class="flex flex-row gap-2.5 items-center min-h-6 relative h-[50px] w-full rounded-[10px] bg-[var(--p-surface-300)] dark-theme:bg-[var(--p-surface-800)]"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="maskEditor_sidePanelLayerCheckbox"
|
||||
:checked="baseImageLayerVisible"
|
||||
@change="onBaseImageLayerVisibilityChange"
|
||||
/>
|
||||
<div class="maskEditor_sidePanelLayerPreviewContainer">
|
||||
<img
|
||||
class="maskEditor_sidePanelImageLayerImage"
|
||||
:src="baseImageSrc"
|
||||
:alt="t('maskEditor.baseLayerPreview')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useCanvasManager } from '@/composables/maskeditor/useCanvasManager'
|
||||
import type { useToolManager } from '@/composables/maskeditor/useToolManager'
|
||||
import type { ImageLayer } from '@/extensions/core/maskeditor/types'
|
||||
import { MaskBlendMode, Tools } from '@/extensions/core/maskeditor/types'
|
||||
import { t } from '@/i18n'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
import SliderControl from './controls/SliderControl.vue'
|
||||
|
||||
const { toolManager } = defineProps<{
|
||||
toolManager?: ReturnType<typeof useToolManager>
|
||||
}>()
|
||||
|
||||
const store = useMaskEditorStore()
|
||||
const canvasManager = useCanvasManager()
|
||||
|
||||
const maskLayerVisible = ref(true)
|
||||
const paintLayerVisible = ref(true)
|
||||
const baseImageLayerVisible = ref(true)
|
||||
|
||||
const baseImageSrc = computed(() => {
|
||||
return store.image?.src ?? ''
|
||||
})
|
||||
|
||||
const showLayerButtons = computed(() => {
|
||||
return store.currentTool === Tools.Eraser
|
||||
})
|
||||
|
||||
const onMaskLayerVisibilityChange = (event: Event) => {
|
||||
const checked = (event.target as HTMLInputElement).checked
|
||||
maskLayerVisible.value = checked
|
||||
|
||||
const maskCanvas = store.maskCanvas
|
||||
if (maskCanvas) {
|
||||
maskCanvas.style.opacity = checked ? String(store.maskOpacity) : '0'
|
||||
}
|
||||
}
|
||||
|
||||
const onPaintLayerVisibilityChange = (event: Event) => {
|
||||
const checked = (event.target as HTMLInputElement).checked
|
||||
paintLayerVisible.value = checked
|
||||
|
||||
const rgbCanvas = store.rgbCanvas
|
||||
if (rgbCanvas) {
|
||||
rgbCanvas.style.opacity = checked ? '1' : '0'
|
||||
}
|
||||
}
|
||||
|
||||
const onBaseImageLayerVisibilityChange = (event: Event) => {
|
||||
const checked = (event.target as HTMLInputElement).checked
|
||||
baseImageLayerVisible.value = checked
|
||||
|
||||
const imgCanvas = store.imgCanvas
|
||||
if (imgCanvas) {
|
||||
imgCanvas.style.opacity = checked ? '1' : '0'
|
||||
}
|
||||
}
|
||||
|
||||
const onMaskOpacityChange = (value: number) => {
|
||||
store.setMaskOpacity(value)
|
||||
|
||||
const maskCanvas = store.maskCanvas
|
||||
if (maskCanvas) {
|
||||
maskCanvas.style.opacity = String(value)
|
||||
}
|
||||
|
||||
maskLayerVisible.value = value !== 0
|
||||
}
|
||||
|
||||
const onBlendModeChange = async (event: Event) => {
|
||||
const value = (event.target as HTMLSelectElement).value
|
||||
let blendMode: MaskBlendMode
|
||||
|
||||
switch (value) {
|
||||
case 'white':
|
||||
blendMode = MaskBlendMode.White
|
||||
break
|
||||
case 'negative':
|
||||
blendMode = MaskBlendMode.Negative
|
||||
break
|
||||
default:
|
||||
blendMode = MaskBlendMode.Black
|
||||
}
|
||||
|
||||
store.maskBlendMode = blendMode
|
||||
|
||||
await canvasManager.updateMaskColor()
|
||||
}
|
||||
|
||||
const setActiveLayer = (layer: ImageLayer) => {
|
||||
toolManager?.setActiveLayer(layer)
|
||||
}
|
||||
</script>
|
||||
209
src/components/maskeditor/MaskEditorContent.vue
Normal file
209
src/components/maskeditor/MaskEditorContent.vue
Normal file
@@ -0,0 +1,209 @@
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="maskEditor-dialog-root flex h-full w-full flex-col"
|
||||
@contextmenu.prevent
|
||||
@dragstart="handleDragStart"
|
||||
>
|
||||
<div
|
||||
id="maskEditorCanvasContainer"
|
||||
ref="canvasContainerRef"
|
||||
@contextmenu.prevent
|
||||
>
|
||||
<canvas
|
||||
ref="imgCanvasRef"
|
||||
class="absolute top-0 left-0 w-full h-full"
|
||||
@contextmenu.prevent
|
||||
/>
|
||||
<canvas
|
||||
ref="rgbCanvasRef"
|
||||
class="absolute top-0 left-0 w-full h-full"
|
||||
@contextmenu.prevent
|
||||
/>
|
||||
<canvas
|
||||
ref="maskCanvasRef"
|
||||
class="absolute top-0 left-0 w-full h-full"
|
||||
@contextmenu.prevent
|
||||
/>
|
||||
<div ref="canvasBackgroundRef" class="bg-white w-full h-full" />
|
||||
</div>
|
||||
|
||||
<div class="maskEditor-ui-container flex min-h-0 flex-1 flex-col">
|
||||
<div class="flex min-h-0 flex-1 overflow-hidden">
|
||||
<ToolPanel
|
||||
v-if="initialized"
|
||||
ref="toolPanelRef"
|
||||
:tool-manager="toolManager!"
|
||||
/>
|
||||
|
||||
<PointerZone
|
||||
v-if="initialized"
|
||||
:tool-manager="toolManager!"
|
||||
:pan-zoom="panZoom!"
|
||||
/>
|
||||
|
||||
<SidePanel
|
||||
v-if="initialized"
|
||||
ref="sidePanelRef"
|
||||
:tool-manager="toolManager!"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BrushCursor v-if="initialized" :container-ref="containerRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import { useImageLoader } from '@/composables/maskeditor/useImageLoader'
|
||||
import { useKeyboard } from '@/composables/maskeditor/useKeyboard'
|
||||
import { useMaskEditorLoader } from '@/composables/maskeditor/useMaskEditorLoader'
|
||||
import { usePanAndZoom } from '@/composables/maskeditor/usePanAndZoom'
|
||||
import { useToolManager } from '@/composables/maskeditor/useToolManager'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useMaskEditorDataStore } from '@/stores/maskEditorDataStore'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
import BrushCursor from './BrushCursor.vue'
|
||||
import PointerZone from './PointerZone.vue'
|
||||
import SidePanel from './SidePanel.vue'
|
||||
import ToolPanel from './ToolPanel.vue'
|
||||
|
||||
const { node } = defineProps<{
|
||||
node: LGraphNode
|
||||
}>()
|
||||
|
||||
const store = useMaskEditorStore()
|
||||
const dataStore = useMaskEditorDataStore()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
const loader = useMaskEditorLoader()
|
||||
|
||||
const containerRef = ref<HTMLElement>()
|
||||
const canvasContainerRef = ref<HTMLDivElement>()
|
||||
const imgCanvasRef = ref<HTMLCanvasElement>()
|
||||
const maskCanvasRef = ref<HTMLCanvasElement>()
|
||||
const rgbCanvasRef = ref<HTMLCanvasElement>()
|
||||
const canvasBackgroundRef = ref<HTMLDivElement>()
|
||||
|
||||
const toolPanelRef = ref<InstanceType<typeof ToolPanel>>()
|
||||
const sidePanelRef = ref<InstanceType<typeof SidePanel>>()
|
||||
|
||||
const initialized = ref(false)
|
||||
|
||||
const keyboard = useKeyboard()
|
||||
const panZoom = usePanAndZoom()
|
||||
|
||||
let toolManager: ReturnType<typeof useToolManager> | null = null
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
const handleDragStart = (event: DragEvent) => {
|
||||
if (event.ctrlKey) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
const initUI = async () => {
|
||||
if (!containerRef.value) {
|
||||
console.error(
|
||||
'[MaskEditorContent] Cannot initialize - missing required refs'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
!imgCanvasRef.value ||
|
||||
!maskCanvasRef.value ||
|
||||
!rgbCanvasRef.value ||
|
||||
!canvasContainerRef.value ||
|
||||
!canvasBackgroundRef.value
|
||||
) {
|
||||
console.error('[MaskEditorContent] Cannot initialize - missing canvas refs')
|
||||
return
|
||||
}
|
||||
|
||||
store.maskCanvas = maskCanvasRef.value
|
||||
store.rgbCanvas = rgbCanvasRef.value
|
||||
store.imgCanvas = imgCanvasRef.value
|
||||
store.canvasContainer = canvasContainerRef.value
|
||||
store.canvasBackground = canvasBackgroundRef.value
|
||||
|
||||
try {
|
||||
await loader.loadFromNode(node)
|
||||
|
||||
toolManager = useToolManager(keyboard, panZoom)
|
||||
|
||||
const imageLoader = useImageLoader()
|
||||
const image = await imageLoader.loadImages()
|
||||
|
||||
await panZoom.initializeCanvasPanZoom(
|
||||
image,
|
||||
containerRef.value,
|
||||
toolPanelRef.value?.$el as HTMLElement | undefined,
|
||||
sidePanelRef.value?.$el as HTMLElement | undefined
|
||||
)
|
||||
|
||||
store.canvasHistory.saveInitialState()
|
||||
|
||||
initialized.value = true
|
||||
} catch (error) {
|
||||
console.error('[MaskEditorContent] Initialization failed:', error)
|
||||
dialogStore.closeDialog()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
keyboard.addListeners()
|
||||
|
||||
if (containerRef.value) {
|
||||
resizeObserver = new ResizeObserver(async () => {
|
||||
if (panZoom) {
|
||||
await panZoom.invalidatePanZoom()
|
||||
}
|
||||
})
|
||||
resizeObserver.observe(containerRef.value)
|
||||
}
|
||||
|
||||
void initUI()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
toolManager?.brushDrawing.saveBrushSettings()
|
||||
|
||||
keyboard?.removeListeners()
|
||||
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect()
|
||||
resizeObserver = null
|
||||
}
|
||||
|
||||
store.canvasHistory.clearStates()
|
||||
store.resetState()
|
||||
dataStore.reset()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.maskEditor-dialog-root {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.maskEditor-ui-container {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
:deep(#maskEditorCanvasContainer) {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
}
|
||||
</style>
|
||||
44
src/components/maskeditor/PaintBucketSettingsPanel.vue
Normal file
44
src/components/maskeditor/PaintBucketSettingsPanel.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3 pb-3">
|
||||
<h3
|
||||
class="text-center text-[15px] font-sans text-[var(--descrip-text)] mt-2.5"
|
||||
>
|
||||
{{ t('maskEditor.paintBucketSettings') }}
|
||||
</h3>
|
||||
|
||||
<SliderControl
|
||||
:label="t('maskEditor.tolerance')"
|
||||
:min="0"
|
||||
:max="255"
|
||||
:step="1"
|
||||
:model-value="store.paintBucketTolerance"
|
||||
@update:model-value="onToleranceChange"
|
||||
/>
|
||||
|
||||
<SliderControl
|
||||
:label="t('maskEditor.fillOpacity')"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="1"
|
||||
:model-value="store.fillOpacity"
|
||||
@update:model-value="onFillOpacityChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
import SliderControl from './controls/SliderControl.vue'
|
||||
|
||||
const store = useMaskEditorStore()
|
||||
|
||||
const onToleranceChange = (value: number) => {
|
||||
store.setPaintBucketTolerance(value)
|
||||
}
|
||||
|
||||
const onFillOpacityChange = (value: number) => {
|
||||
store.setFillOpacity(value)
|
||||
}
|
||||
</script>
|
||||
95
src/components/maskeditor/PointerZone.vue
Normal file
95
src/components/maskeditor/PointerZone.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div
|
||||
ref="pointerZoneRef"
|
||||
class="w-[calc(100%-4rem-220px)] h-full"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
@pointerup="handlePointerUp"
|
||||
@pointerleave="handlePointerLeave"
|
||||
@pointerenter="handlePointerEnter"
|
||||
@touchstart="handleTouchStart"
|
||||
@touchmove="handleTouchMove"
|
||||
@touchend="handleTouchEnd"
|
||||
@wheel="handleWheel"
|
||||
@contextmenu.prevent
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
|
||||
import type { usePanAndZoom } from '@/composables/maskeditor/usePanAndZoom'
|
||||
import type { useToolManager } from '@/composables/maskeditor/useToolManager'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
const { toolManager, panZoom } = defineProps<{
|
||||
toolManager: ReturnType<typeof useToolManager>
|
||||
panZoom: ReturnType<typeof usePanAndZoom>
|
||||
}>()
|
||||
|
||||
const store = useMaskEditorStore()
|
||||
const pointerZoneRef = ref<HTMLDivElement>()
|
||||
|
||||
onMounted(() => {
|
||||
if (!pointerZoneRef.value) {
|
||||
console.error('[PointerZone] Pointer zone ref not initialized')
|
||||
return
|
||||
}
|
||||
|
||||
store.pointerZone = pointerZoneRef.value
|
||||
})
|
||||
|
||||
watch(
|
||||
() => store.isPanning,
|
||||
(isPanning) => {
|
||||
if (!pointerZoneRef.value) return
|
||||
|
||||
if (isPanning) {
|
||||
pointerZoneRef.value.style.cursor = 'grabbing'
|
||||
} else {
|
||||
toolManager.updateCursor()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const handlePointerDown = async (event: PointerEvent) => {
|
||||
await toolManager.handlePointerDown(event)
|
||||
}
|
||||
|
||||
const handlePointerMove = async (event: PointerEvent) => {
|
||||
await toolManager.handlePointerMove(event)
|
||||
}
|
||||
|
||||
const handlePointerUp = (event: PointerEvent) => {
|
||||
void toolManager.handlePointerUp(event)
|
||||
}
|
||||
|
||||
const handlePointerLeave = () => {
|
||||
store.brushVisible = false
|
||||
if (pointerZoneRef.value) {
|
||||
pointerZoneRef.value.style.cursor = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handlePointerEnter = () => {
|
||||
toolManager.updateCursor()
|
||||
}
|
||||
|
||||
const handleTouchStart = (event: TouchEvent) => {
|
||||
panZoom.handleTouchStart(event)
|
||||
}
|
||||
|
||||
const handleTouchMove = async (event: TouchEvent) => {
|
||||
await panZoom.handleTouchMove(event)
|
||||
}
|
||||
|
||||
const handleTouchEnd = (event: TouchEvent) => {
|
||||
panZoom.handleTouchEnd(event)
|
||||
}
|
||||
|
||||
const handleWheel = async (event: WheelEvent) => {
|
||||
await panZoom.zoom(event)
|
||||
const newCursorPoint = { x: event.clientX, y: event.clientY }
|
||||
panZoom.updateCursorPosition(newCursorPoint)
|
||||
}
|
||||
</script>
|
||||
31
src/components/maskeditor/SettingsPanelContainer.vue
Normal file
31
src/components/maskeditor/SettingsPanelContainer.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div class="maskEditor_sidePanel">
|
||||
<div class="maskEditor_sidePanelContainer">
|
||||
<component :is="currentPanelComponent" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
import { Tools } from '@/extensions/core/maskeditor/types'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
import BrushSettingsPanel from './BrushSettingsPanel.vue'
|
||||
import ColorSelectSettingsPanel from './ColorSelectSettingsPanel.vue'
|
||||
import PaintBucketSettingsPanel from './PaintBucketSettingsPanel.vue'
|
||||
|
||||
const currentPanelComponent = computed<Component>(() => {
|
||||
const tool = useMaskEditorStore().currentTool
|
||||
|
||||
if (tool === Tools.MaskBucket) {
|
||||
return PaintBucketSettingsPanel
|
||||
} else if (tool === Tools.MaskColorFill) {
|
||||
return ColorSelectSettingsPanel
|
||||
} else {
|
||||
return BrushSettingsPanel
|
||||
}
|
||||
})
|
||||
</script>
|
||||
24
src/components/maskeditor/SidePanel.vue
Normal file
24
src/components/maskeditor/SidePanel.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-3 pb-3 h-full !items-stretch bg-[var(--comfy-menu-bg)] overflow-y-auto w-55 px-2.5"
|
||||
>
|
||||
<div class="w-full min-h-full">
|
||||
<SettingsPanelContainer />
|
||||
|
||||
<div class="w-full h-0.5 bg-[var(--border-color)] mt-6 mb-1.5" />
|
||||
|
||||
<ImageLayerSettingsPanel :tool-manager="toolManager" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { useToolManager } from '@/composables/maskeditor/useToolManager'
|
||||
|
||||
import ImageLayerSettingsPanel from './ImageLayerSettingsPanel.vue'
|
||||
import SettingsPanelContainer from './SettingsPanelContainer.vue'
|
||||
|
||||
const { toolManager } = defineProps<{
|
||||
toolManager?: ReturnType<typeof useToolManager>
|
||||
}>()
|
||||
</script>
|
||||
69
src/components/maskeditor/ToolPanel.vue
Normal file
69
src/components/maskeditor/ToolPanel.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div
|
||||
class="h-full z-[8888] flex flex-col justify-between bg-[var(--comfy-menu-bg)]"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
v-for="tool in allTools"
|
||||
:key="tool"
|
||||
:class="[
|
||||
'maskEditor_toolPanelContainer hover:bg-[var(--p-surface-300)] dark-theme:hover:bg-[var(--p-surface-800)]',
|
||||
{ maskEditor_toolPanelContainerSelected: currentTool === tool }
|
||||
]"
|
||||
@click="onToolSelect(tool)"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center"
|
||||
v-html="iconsHtml[tool]"
|
||||
></div>
|
||||
<div class="maskEditor_toolPanelIndicator"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col items-center cursor-pointer rounded-md mb-2 transition-colors duration-200 hover:bg-[var(--p-surface-300)] dark-theme:hover:bg-[var(--p-surface-800)]"
|
||||
:title="t('maskEditor.clickToResetZoom')"
|
||||
@click="onResetZoom"
|
||||
>
|
||||
<span class="text-sm text-[var(--p-button-text-secondary-color)]">{{
|
||||
zoomText
|
||||
}}</span>
|
||||
<span class="text-xs text-[var(--p-button-text-secondary-color)]">{{
|
||||
dimensionsText
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { useToolManager } from '@/composables/maskeditor/useToolManager'
|
||||
import { iconsHtml } from '@/extensions/core/maskeditor/constants'
|
||||
import type { Tools } from '@/extensions/core/maskeditor/types'
|
||||
import { allTools } from '@/extensions/core/maskeditor/types'
|
||||
import { t } from '@/i18n'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
const { toolManager } = defineProps<{
|
||||
toolManager: ReturnType<typeof useToolManager>
|
||||
}>()
|
||||
|
||||
const store = useMaskEditorStore()
|
||||
|
||||
const onToolSelect = (tool: Tools) => {
|
||||
toolManager.switchTool(tool)
|
||||
}
|
||||
|
||||
const currentTool = computed(() => store.currentTool)
|
||||
|
||||
const zoomText = computed(() => `${Math.round(store.displayZoomRatio * 100)}%`)
|
||||
const dimensionsText = computed(() => {
|
||||
const img = store.image
|
||||
return img ? `${img.width}x${img.height}` : ' '
|
||||
})
|
||||
|
||||
const onResetZoom = () => {
|
||||
store.resetZoom()
|
||||
}
|
||||
</script>
|
||||
55
src/components/maskeditor/controls/DropdownControl.vue
Normal file
55
src/components/maskeditor/controls/DropdownControl.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div class="flex flex-row gap-2.5 items-center min-h-6 relative">
|
||||
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
|
||||
label
|
||||
}}</span>
|
||||
<select
|
||||
class="absolute right-0 h-6 px-1.5 rounded-md border border-[var(--p-form-field-border-color)] transition-colors duration-100 bg-[var(--comfy-menu-bg)] focus:outline focus:outline-1 focus:outline-[var(--p-surface-300)] dark-theme:bg-[var(--p-surface-900)] dark-theme:focus:outline-[var(--p-button-text-primary-color)]"
|
||||
:value="modelValue"
|
||||
@change="onChange"
|
||||
>
|
||||
<option
|
||||
v-for="option in normalizedOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface DropdownOption {
|
||||
label: string
|
||||
value: string | number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
label: string
|
||||
options: string[] | DropdownOption[]
|
||||
modelValue: string | number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string | number]
|
||||
}>()
|
||||
|
||||
const normalizedOptions = computed((): DropdownOption[] => {
|
||||
return props.options.map((option) => {
|
||||
if (typeof option === 'string') {
|
||||
return { label: option, value: option }
|
||||
}
|
||||
return option
|
||||
})
|
||||
})
|
||||
|
||||
const onChange = (event: Event) => {
|
||||
const value = (event.target as HTMLSelectElement).value
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
</script>
|
||||
39
src/components/maskeditor/controls/SliderControl.vue
Normal file
39
src/components/maskeditor/controls/SliderControl.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3 pb-3">
|
||||
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
|
||||
label
|
||||
}}</span>
|
||||
<input
|
||||
type="range"
|
||||
class="maskEditor_sidePanelBrushRange"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
:value="modelValue"
|
||||
@input="onInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
label: string
|
||||
min: number
|
||||
max: number
|
||||
step?: number
|
||||
modelValue: number
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
step: 1
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: number]
|
||||
}>()
|
||||
|
||||
const onInput = (event: Event) => {
|
||||
const value = Number((event.target as HTMLInputElement).value)
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
</script>
|
||||
34
src/components/maskeditor/controls/ToggleControl.vue
Normal file
34
src/components/maskeditor/controls/ToggleControl.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div class="flex flex-row gap-2.5 items-center min-h-6 relative">
|
||||
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
|
||||
label
|
||||
}}</span>
|
||||
<label class="maskEditor_sidePanelToggleContainer">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="maskEditor_sidePanelToggleCheckbox"
|
||||
:checked="modelValue"
|
||||
@change="onChange"
|
||||
/>
|
||||
<div class="maskEditor_sidePanelToggleSwitch"></div>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
label: string
|
||||
modelValue: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
const onChange = (event: Event) => {
|
||||
const checked = (event.target as HTMLInputElement).checked
|
||||
emit('update:modelValue', checked)
|
||||
}
|
||||
</script>
|
||||
126
src/components/maskeditor/dialog/TopBarHeader.vue
Normal file
126
src/components/maskeditor/dialog/TopBarHeader.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<div class="flex w-full items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="m-0 text-lg font-semibold">{{ t('maskEditor.title') }}</h3>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
:class="iconButtonClass"
|
||||
:title="t('maskEditor.undo')"
|
||||
@click="onUndo"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 15 15"
|
||||
class="h-6.25 w-6.25 pointer-events-none fill-[var(--input-text)]"
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
:class="iconButtonClass"
|
||||
:title="t('maskEditor.redo')"
|
||||
@click="onRedo"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 15 15"
|
||||
class="h-6.25 w-6.25 pointer-events-none fill-[var(--input-text)]"
|
||||
>
|
||||
<path
|
||||
class="cls-1"
|
||||
d="M6.23,12.18c-1.97,0-3.58-1.61-3.58-3.58,0-2.11,1.71-3.58,4.17-3.58h3.98l-1.43-1.43c-.18-.18-.18-.47,0-.64.18-.18.46-.18.64,0l2.21,2.21c.09.09.13.2.13.32s-.05.24-.13.32l-2.21,2.21c-.18.18-.47.18-.64,0-.18-.18-.18-.47,0-.64l1.43-1.43h-3.98c-1.92,0-3.26,1.1-3.26,2.67,0,1.47,1.2,2.67,2.67,2.67.25,0,.46.2.46.46s-.2.46-.46.46Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button :class="textButtonClass" @click="onInvert">
|
||||
{{ t('maskEditor.invert') }}
|
||||
</button>
|
||||
|
||||
<button :class="textButtonClass" @click="onClear">
|
||||
{{ t('maskEditor.clear') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<Button
|
||||
:label="saveButtonText"
|
||||
icon="pi pi-check"
|
||||
size="small"
|
||||
:disabled="!saveEnabled"
|
||||
@click="handleSave"
|
||||
/>
|
||||
<Button
|
||||
:label="t('g.cancel')"
|
||||
icon="pi pi-times"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
@click="handleCancel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useCanvasTools } from '@/composables/maskeditor/useCanvasTools'
|
||||
import { useMaskEditorSaver } from '@/composables/maskeditor/useMaskEditorSaver'
|
||||
import { t } from '@/i18n'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
const store = useMaskEditorStore()
|
||||
const dialogStore = useDialogStore()
|
||||
const canvasTools = useCanvasTools()
|
||||
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-[var(--p-surface-300)] dark-theme:bg-[var(--p-surface-800)] dark-theme:hover:bg-[var(--p-surface-900)]'
|
||||
|
||||
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-[var(--p-surface-300)] dark-theme:bg-[var(--p-surface-800)] dark-theme:hover:bg-[var(--p-surface-900)]'
|
||||
|
||||
const onUndo = () => {
|
||||
store.canvasHistory.undo()
|
||||
}
|
||||
|
||||
const onRedo = () => {
|
||||
store.canvasHistory.redo()
|
||||
}
|
||||
|
||||
const onInvert = () => {
|
||||
canvasTools.invertMask()
|
||||
}
|
||||
|
||||
const onClear = () => {
|
||||
canvasTools.clearMask()
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
saveButtonText.value = t('g.saving')
|
||||
saveEnabled.value = false
|
||||
|
||||
try {
|
||||
store.brushVisible = false
|
||||
await saver.save()
|
||||
dialogStore.closeDialog()
|
||||
} catch (error) {
|
||||
console.error('[TopBarHeader] Save failed:', error)
|
||||
store.brushVisible = true
|
||||
saveButtonText.value = t('g.save')
|
||||
saveEnabled.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
dialogStore.closeDialog({ key: 'global-mask-editor' })
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user