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

@@ -1346,3 +1346,466 @@ audio.comfy-audio.empty-audio-widget {
border-radius: 0;
}
/* END LOD specific styles */
/* ===================== Mask Editor Styles ===================== */
/* To be migrated to Tailwind later */
#maskEditor_brush {
position: absolute;
backgroundColor: transparent;
z-index: 8889;
pointer-events: none;
border-radius: 50%;
overflow: visible;
outline: 1px dashed black;
box-shadow: 0 0 0 1px white;
}
#maskEditor_brushPreviewGradient {
position: absolute;
width: 100%;
height: 100%;
border-radius: 50%;
display: none;
}
.maskEditor_sidePanelTitle {
text-align: center;
font-size: 15px;
font-family: sans-serif;
color: var(--descrip-text);
margin-top: 10px;
}
.maskEditor_sidePanelBrushShapeCircle {
width: 35px;
height: 35px;
border-radius: 50%;
border: 1px solid var(--border-color);
pointer-events: auto;
transition: background 0.1s;
margin-left: 7.5px;
}
.maskEditor_sidePanelBrushRange {
width: 180px;
appearance: none;
background: transparent;
cursor: pointer;
}
.maskEditor_sidePanelBrushRange::-webkit-slider-thumb {
height: 20px;
width: 20px;
border-radius: 50%;
cursor: grab;
margin-top: -8px;
background: var(--p-surface-700);
border: 1px solid var(--border-color);
}
.maskEditor_sidePanelBrushRange::-moz-range-thumb {
height: 20px;
width: 20px;
border-radius: 50%;
cursor: grab;
background: var(--p-surface-800);
border: 1px solid var(--border-color);
}
.maskEditor_sidePanelBrushRange::-webkit-slider-runnable-track {
background: var(--p-surface-700);
height: 3px;
}
.maskEditor_sidePanelBrushRange::-moz-range-track {
background: var(--p-surface-700);
height: 3px;
}
.maskEditor_sidePanelBrushShapeSquare {
width: 35px;
height: 35px;
margin: 5px;
border: 1px solid var(--border-color);
pointer-events: auto;
transition: background 0.1s;
}
.maskEditor_brushShape_dark {
background: transparent;
}
.maskEditor_brushShape_dark:hover {
background: var(--p-surface-900);
}
.maskEditor_brushShape_light {
background: transparent;
}
.maskEditor_brushShape_light:hover {
background: var(--comfy-menu-bg);
}
.maskEditor_sidePanelLayer {
display: flex;
width: 100%;
height: 50px;
}
.maskEditor_sidePanelLayerVisibilityContainer {
width: 50px;
height: 50px;
border-radius: 8px;
display: flex;
justify-content: center;
align-items: center;
}
.maskEditor_sidePanelVisibilityToggle {
width: 12px;
height: 12px;
border-radius: 50%;
pointer-events: auto;
}
.maskEditor_sidePanelLayerIconContainer {
width: 60px;
height: 50px;
border-radius: 8px;
display: flex;
justify-content: center;
align-items: center;
fill: var(--input-text);
}
.maskEditor_sidePanelLayerIconContainer svg {
width: 30px;
height: 30px;
}
.maskEditor_sidePanelBigButton {
width: 85px;
height: 30px;
background: rgb(0 0 0 / 0.2);
border: 1px solid var(--border-color);
color: var(--input-text);
font-family: sans-serif;
font-size: 15px;
pointer-events: auto;
transition: background-color 0.1s;
}
.maskEditor_sidePanelBigButton:hover {
background-color: var(--p-overlaybadge-outline-color);
border: none;
}
.maskEditor_toolPanelContainer {
width: 4rem;
height: 4rem;
display: flex;
justify-content: center;
align-items: center;
position: relative;
transition: background-color 0.2s;
}
.maskEditor_toolPanelContainerSelected svg {
fill: var(--p-button-text-primary-color) !important;
}
.maskEditor_toolPanelContainerSelected .maskEditor_toolPanelIndicator {
display: block;
}
.maskEditor_toolPanelContainer svg {
width: 75%;
aspect-ratio: 1/1;
fill: var(--p-button-text-secondary-color);
}
.maskEditor_toolPanelContainerDark:hover {
background-color: var(--p-surface-800);
}
.maskEditor_toolPanelContainerLight:hover {
background-color: var(--p-surface-300);
}
.maskEditor_toolPanelIndicator {
display: none;
height: 100%;
width: 4px;
position: absolute;
left: 0;
background: var(--p-button-text-primary-color);
}
.maskEditor_sidePanelSeparator {
width: 100%;
height: 2px;
background: var(--border-color);
margin-top: 1.5em;
margin-bottom: 5px;
}
#maskEditorCanvasContainer {
position: absolute;
width: 1000px;
height: 667px;
left: 359px;
top: 280px;
}
.maskEditor_topPanelIconButton_dark {
width: 50px;
height: 30px;
pointer-events: auto;
display: flex;
justify-content: center;
align-items: center;
transition: background-color 0.1s;
background: var(--p-surface-800);
border: 1px solid var(--p-form-field-border-color);
border-radius: 10px;
}
.maskEditor_topPanelIconButton_dark:hover {
background-color: var(--p-surface-900);
}
.maskEditor_topPanelIconButton_dark svg {
width: 25px;
height: 25px;
pointer-events: none;
fill: var(--input-text);
}
.maskEditor_topPanelIconButton_light {
width: 50px;
height: 30px;
pointer-events: auto;
display: flex;
justify-content: center;
align-items: center;
transition: background-color 0.1s;
background: var(--comfy-menu-bg);
border: 1px solid var(--p-form-field-border-color);
border-radius: 10px;
}
.maskEditor_topPanelIconButton_light:hover {
background-color: var(--p-surface-300);
}
.maskEditor_topPanelIconButton_light svg {
width: 25px;
height: 25px;
pointer-events: none;
fill: var(--input-text);
}
.maskEditor_topPanelButton_dark {
height: 30px;
background: var(--p-surface-800);
border: 1px solid var(--p-form-field-border-color);
border-radius: 10px;
color: var(--input-text);
font-family: sans-serif;
pointer-events: auto;
transition: 0.1s;
width: 60px;
}
.maskEditor_topPanelButton_dark:hover {
background-color: var(--p-surface-900);
}
.maskEditor_topPanelButton_light {
height: 30px;
background: var(--comfy-menu-bg);
border: 1px solid var(--p-form-field-border-color);
border-radius: 10px;
color: var(--input-text);
font-family: sans-serif;
pointer-events: auto;
transition: 0.1s;
width: 60px;
}
.maskEditor_topPanelButton_light:hover {
background-color: var(--p-surface-300);
}
.maskEditor_sidePanel_paintBucket_Container {
width: 180px;
display: flex;
flex-direction: column;
position: relative;
}
.maskEditor_sidePanel_colorSelect_Container {
display: flex;
width: 180px;
align-items: center;
gap: 5px;
height: 30px;
}
.maskEditor_sidePanel_colorSelect_tolerance_container {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 10px;
}
.maskEditor_sidePanelContainerColumn {
display: flex;
flex-direction: column;
gap: 12px;
padding-bottom: 12px;
}
.maskEditor_sidePanelContainerRow {
display: flex;
flex-direction: row;
gap: 10px;
align-items: center;
min-height: 24px;
position: relative;
}
.maskEditor_accent_bg_dark {
background: var(--p-surface-800);
}
.maskEditor_accent_bg_very_dark {
background: var(--p-surface-900);
}
.maskEditor_accent_bg_light {
background: var(--p-surface-300);
}
.maskEditor_accent_bg_very_light {
background: var(--comfy-menu-bg);
}
.maskEditor_sidePanelToggleContainer {
cursor: pointer;
display: inline-block;
position: absolute;
right: 0;
}
.maskEditor_sidePanelToggleSwitch {
display: inline-block;
border-radius: 16px;
width: 40px;
height: 24px;
position: relative;
vertical-align: middle;
transition: background 0.25s;
background: var(--p-surface-300);
}
.dark-theme .maskEditor_sidePanelToggleSwitch {
background: var(--p-surface-700);
}
.maskEditor_sidePanelToggleSwitch::before, .maskEditor_sidePanelToggleSwitch::after {
content: "";
}
.maskEditor_sidePanelToggleSwitch::before {
display: block;
background: linear-gradient(to bottom, #fff 0%, #eee 100%);
border-radius: 50%;
width: 16px;
height: 16px;
position: absolute;
top: 4px;
left: 4px;
transition: ease 0.2s;
}
.maskEditor_sidePanelToggleContainer:hover .maskEditor_sidePanelToggleSwitch::before {
background: linear-gradient(to bottom, #fff 0%, #fff 100%);
}
.maskEditor_sidePanelToggleCheckbox:checked + .maskEditor_sidePanelToggleSwitch {
background: var(--p-button-text-primary-color);
}
.maskEditor_sidePanelToggleCheckbox:checked + .maskEditor_sidePanelToggleSwitch::before {
background: var(--comfy-menu-bg);
left: 20px;
}
.dark-theme .maskEditor_sidePanelToggleCheckbox:checked + .maskEditor_sidePanelToggleSwitch::before {
background: var(--p-surface-900);
}
.maskEditor_sidePanelToggleCheckbox {
position: absolute;
visibility: hidden;
}
.maskEditor_sidePanelDropdown {
border: 1px solid var(--p-form-field-border-color);
background: var(--comfy-menu-bg);
height: 24px;
padding-left: 5px;
padding-right: 5px;
border-radius: 6px;
transition: background 0.1s;
}
.maskEditor_sidePanelDropdown option {
background: var(--comfy-menu-bg);
}
.maskEditor_sidePanelDropdown:focus {
outline: 1px solid var(--p-surface-300);
}
.maskEditor_sidePanelDropdown option:hover {
background: white;
}
.maskEditor_sidePanelDropdown option:active {
background: var(--p-surface-300);
}
.dark-theme .maskEditor_sidePanelDropdown {
background: var(--p-surface-900);
}
.dark-theme .maskEditor_sidePanelDropdown option {
background: var(--p-surface-900);
}
.dark-theme .maskEditor_sidePanelDropdown:focus {
outline: 1px solid var(--p-button-text-primary-color);
}
.dark-theme .maskEditor_sidePanelDropdown option:active {
background: var(--p-highlight-background);
}
.maskEditor_layerRow {
height: 50px;
width: 100%;
border-radius: 10px;
}
.maskEditor_sidePanelLayerPreviewContainer {
width: 40px;
height: 30px;
}
.maskEditor_sidePanelLayerPreviewContainer > svg{
width: 100%;
height: 100%;
object-fit: contain;
fill: var(--p-surface-100);
}
.maskEditor_sidePanelImageLayerImage {
width: 100%;
height: 100%;
object-fit: contain;
}
.maskEditor_sidePanelSubTitle {
text-align: left;
font-size: 12px;
font-family: sans-serif;
color: var(--descrip-text);
}
.maskEditor_containerDropdown {
position: absolute;
right: 0;
}
.maskEditor_sidePanelLayerCheckbox {
margin-left: 15px;
}
/* ===================== End of Mask Editor Styles ===================== */

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,671 @@
import { ref } from 'vue'
import QuickLRU from '@alloc/quick-lru'
import { debounce } from 'es-toolkit/compat'
import { hexToRgb, parseToRgb } from '@/utils/colorUtil'
import { getStorageValue, setStorageValue } from '@/scripts/utils'
import {
Tools,
BrushShape,
CompositionOperation
} from '@/extensions/core/maskeditor/types'
import type { Brush, Point } from '@/extensions/core/maskeditor/types'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { useCoordinateTransform } from './useCoordinateTransform'
const saveBrushToCache = debounce(function (key: string, brush: Brush): void {
try {
const brushString = JSON.stringify(brush)
setStorageValue(key, brushString)
} catch (error) {
console.error('Failed to save brush to cache:', error)
}
}, 300)
function loadBrushFromCache(key: string): Brush | null {
try {
const brushString = getStorageValue(key)
if (brushString) {
return JSON.parse(brushString) as Brush
} else {
return null
}
} catch (error) {
console.error('Failed to load brush from cache:', error)
return null
}
}
export function useBrushDrawing(initialSettings?: {
useDominantAxis?: boolean
brushAdjustmentSpeed?: number
}) {
const store = useMaskEditorStore()
const coordinateTransform = useCoordinateTransform()
const brushTextureCache = new QuickLRU<string, HTMLCanvasElement>({
maxSize: 8
})
const SMOOTHING_MAX_STEPS = 30
const SMOOTHING_MIN_STEPS = 2
const isDrawing = ref(false)
const isDrawingLine = ref(false)
const lineStartPoint = ref<Point | null>(null)
const smoothingCordsArray = ref<Point[]>([])
const smoothingLastDrawTime = ref(new Date())
const initialDraw = ref(true)
const brushStrokeCanvas = ref<HTMLCanvasElement | null>(null)
const brushStrokeCtx = ref<CanvasRenderingContext2D | null>(null)
const initialPoint = ref<Point | null>(null)
const useDominantAxis = ref(initialSettings?.useDominantAxis ?? false)
const brushAdjustmentSpeed = ref(initialSettings?.brushAdjustmentSpeed ?? 1.0)
const cachedBrushSettings = loadBrushFromCache('maskeditor_brush_settings')
if (cachedBrushSettings) {
store.setBrushSize(cachedBrushSettings.size)
store.setBrushOpacity(cachedBrushSettings.opacity)
store.setBrushHardness(cachedBrushSettings.hardness)
store.brushSettings.type = cachedBrushSettings.type
store.setBrushSmoothingPrecision(cachedBrushSettings.smoothingPrecision)
}
const createBrushStrokeCanvas = async (): Promise<void> => {
if (brushStrokeCanvas.value !== null) {
return
}
const maskCanvas = store.maskCanvas
if (!maskCanvas) {
throw new Error('Mask canvas not initialized')
}
const canvas = document.createElement('canvas')
canvas.width = maskCanvas.width
canvas.height = maskCanvas.height
brushStrokeCanvas.value = canvas
brushStrokeCtx.value = canvas.getContext('2d')!
}
const initShape = (compositionOperation: CompositionOperation) => {
const blendMode = store.maskBlendMode
const mask_ctx = store.maskCtx
const rgb_ctx = store.rgbCtx
if (!mask_ctx || !rgb_ctx) {
throw new Error('Canvas contexts are required')
}
mask_ctx.beginPath()
rgb_ctx.beginPath()
if (compositionOperation === CompositionOperation.SourceOver) {
mask_ctx.fillStyle = blendMode
mask_ctx.globalCompositeOperation = CompositionOperation.SourceOver
rgb_ctx.globalCompositeOperation = CompositionOperation.SourceOver
} else if (compositionOperation === CompositionOperation.DestinationOut) {
mask_ctx.globalCompositeOperation = CompositionOperation.DestinationOut
rgb_ctx.globalCompositeOperation = CompositionOperation.DestinationOut
}
}
const formatRgba = (hex: string, alpha: number): string => {
const { r, g, b } = hexToRgb(hex)
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
const getCachedBrushTexture = (
radius: number,
hardness: number,
color: string,
opacity: number
): HTMLCanvasElement => {
const cacheKey = `${radius}_${hardness}_${color}_${opacity}`
if (brushTextureCache.has(cacheKey)) {
return brushTextureCache.get(cacheKey)!
}
const tempCanvas = document.createElement('canvas')
const tempCtx = tempCanvas.getContext('2d')!
const size = radius * 2
tempCanvas.width = size
tempCanvas.height = size
const centerX = size / 2
const centerY = size / 2
const hardRadius = radius * hardness
const imageData = tempCtx.createImageData(size, size)
const data = imageData.data
const { r, g, b } = parseToRgb(color)
const fadeRange = radius - hardRadius
for (let y = 0; y < size; y++) {
const dy = y - centerY
for (let x = 0; x < size; x++) {
const dx = x - centerX
const index = (y * size + x) * 4
// Calculate square distance (Chebyshev distance)
const distFromEdge = Math.max(Math.abs(dx), Math.abs(dy))
let pixelOpacity = 0
if (distFromEdge <= hardRadius) {
pixelOpacity = opacity
} else if (distFromEdge <= radius) {
const fadeProgress = (distFromEdge - hardRadius) / fadeRange
pixelOpacity = opacity * (1 - fadeProgress)
}
data[index] = r
data[index + 1] = g
data[index + 2] = b
data[index + 3] = pixelOpacity * 255
}
}
tempCtx.putImageData(imageData, 0, 0)
brushTextureCache.set(cacheKey, tempCanvas)
return tempCanvas
}
const createBrushGradient = (
ctx: CanvasRenderingContext2D,
x: number,
y: number,
radius: number,
hardness: number,
color: string,
opacity: number,
isErasing: boolean
): CanvasGradient => {
const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius)
if (isErasing) {
gradient.addColorStop(0, `rgba(255, 255, 255, ${opacity})`)
gradient.addColorStop(hardness, `rgba(255, 255, 255, ${opacity * 0.5})`)
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`)
} else {
const { r, g, b } = parseToRgb(color)
gradient.addColorStop(0, `rgba(${r}, ${g}, ${b}, ${opacity})`)
gradient.addColorStop(
hardness,
`rgba(${r}, ${g}, ${b}, ${opacity * 0.5})`
)
gradient.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0)`)
}
return gradient
}
const drawShapeOnContext = (
ctx: CanvasRenderingContext2D,
brushType: BrushShape,
x: number,
y: number,
radius: number
): void => {
ctx.beginPath()
if (brushType === BrushShape.Rect) {
ctx.rect(x - radius, y - radius, radius * 2, radius * 2)
} else {
ctx.arc(x, y, radius, 0, Math.PI * 2, false)
}
ctx.fill()
}
const drawRgbShape = (
ctx: CanvasRenderingContext2D,
point: Point,
brushType: BrushShape,
brushRadius: number,
hardness: number,
opacity: number
): void => {
const { x, y } = point
const rgbColor = store.rgbColor
if (brushType === BrushShape.Rect && hardness < 1) {
const rgbaColor = formatRgba(rgbColor, opacity)
const brushTexture = getCachedBrushTexture(
brushRadius,
hardness,
rgbaColor,
opacity
)
ctx.drawImage(brushTexture, x - brushRadius, y - brushRadius)
return
}
if (hardness === 1) {
const rgbaColor = formatRgba(rgbColor, opacity)
ctx.fillStyle = rgbaColor
drawShapeOnContext(ctx, brushType, x, y, brushRadius)
return
}
const gradient = createBrushGradient(
ctx,
x,
y,
brushRadius,
hardness,
rgbColor,
opacity,
false
)
ctx.fillStyle = gradient
drawShapeOnContext(ctx, brushType, x, y, brushRadius)
}
const drawMaskShape = (
ctx: CanvasRenderingContext2D,
point: Point,
brushType: BrushShape,
brushRadius: number,
hardness: number,
opacity: number,
isErasing: boolean
): void => {
const { x, y } = point
const maskColor = store.maskColor
if (brushType === BrushShape.Rect && hardness < 1) {
const baseColor = isErasing
? `rgba(255, 255, 255, ${opacity})`
: `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
const brushTexture = getCachedBrushTexture(
brushRadius,
hardness,
baseColor,
opacity
)
ctx.drawImage(brushTexture, x - brushRadius, y - brushRadius)
return
}
if (hardness === 1) {
ctx.fillStyle = isErasing
? `rgba(255, 255, 255, ${opacity})`
: `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
drawShapeOnContext(ctx, brushType, x, y, brushRadius)
return
}
const maskColorHex = `rgb(${maskColor.r}, ${maskColor.g}, ${maskColor.b})`
const gradient = createBrushGradient(
ctx,
x,
y,
brushRadius,
hardness,
maskColorHex,
opacity,
isErasing
)
ctx.fillStyle = gradient
drawShapeOnContext(ctx, brushType, x, y, brushRadius)
}
const drawShape = (point: Point, overrideOpacity?: number) => {
const brush = store.brushSettings
const mask_ctx = store.maskCtx
const rgb_ctx = store.rgbCtx
if (!mask_ctx || !rgb_ctx) {
throw new Error('Canvas contexts are required')
}
const brushType = brush.type
const brushRadius = brush.size
const hardness = brush.hardness
const opacity = overrideOpacity ?? brush.opacity
const isErasing = mask_ctx.globalCompositeOperation === 'destination-out'
const currentTool = store.currentTool
const isRgbLayer = store.activeLayer === 'rgb'
if (
isRgbLayer &&
currentTool &&
(currentTool === Tools.Eraser || currentTool === Tools.PaintPen)
) {
drawRgbShape(rgb_ctx, point, brushType, brushRadius, hardness, opacity)
return
}
drawMaskShape(
mask_ctx,
point,
brushType,
brushRadius,
hardness,
opacity,
isErasing
)
}
const clampSmoothingPrecision = (value: number): number => {
return Math.min(Math.max(value, 1), 100)
}
const generateEquidistantPoints = (
points: Point[],
distance: number
): Point[] => {
const result: Point[] = []
const cumulativeDistances: number[] = [0]
for (let i = 1; i < points.length; i++) {
const dx = points[i].x - points[i - 1].x
const dy = points[i].y - points[i - 1].y
const dist = Math.hypot(dx, dy)
cumulativeDistances[i] = cumulativeDistances[i - 1] + dist
}
const totalLength = cumulativeDistances[cumulativeDistances.length - 1]
const numPoints = Math.floor(totalLength / distance)
for (let i = 0; i <= numPoints; i++) {
const targetDistance = i * distance
let idx = 0
while (
idx < cumulativeDistances.length - 1 &&
cumulativeDistances[idx + 1] < targetDistance
) {
idx++
}
if (idx >= points.length - 1) {
result.push(points[points.length - 1])
continue
}
const d0 = cumulativeDistances[idx]
const d1 = cumulativeDistances[idx + 1]
const t = (targetDistance - d0) / (d1 - d0)
const x = points[idx].x + t * (points[idx + 1].x - points[idx].x)
const y = points[idx].y + t * (points[idx + 1].y - points[idx].y)
result.push({ x, y })
}
return result
}
const drawWithBetterSmoothing = (point: Point): void => {
if (!smoothingCordsArray.value) {
smoothingCordsArray.value = []
}
const opacityConstant = 1 / (1 + Math.exp(3))
const interpolatedOpacity =
1 / (1 + Math.exp(-6 * (store.brushSettings.opacity - 0.5))) -
opacityConstant
smoothingCordsArray.value.push(point)
const POINTS_NR = 5
if (smoothingCordsArray.value.length < POINTS_NR) {
return
}
let totalLength = 0
const points = smoothingCordsArray.value
const len = points.length - 1
let dx, dy
for (let i = 0; i < len; i++) {
dx = points[i + 1].x - points[i].x
dy = points[i + 1].y - points[i].y
totalLength += Math.sqrt(dx * dx + dy * dy)
}
const maxSteps = SMOOTHING_MAX_STEPS
const minSteps = SMOOTHING_MIN_STEPS
const smoothing = clampSmoothingPrecision(
store.brushSettings.smoothingPrecision
)
const normalizedSmoothing = (smoothing - 1) / 99
const stepNr = Math.round(
Math.round(minSteps + (maxSteps - minSteps) * normalizedSmoothing)
)
const distanceBetweenPoints = totalLength / stepNr
let interpolatedPoints = points
if (stepNr > 0) {
interpolatedPoints = generateEquidistantPoints(
smoothingCordsArray.value,
distanceBetweenPoints
)
}
if (!initialDraw.value) {
const spliceIndex = interpolatedPoints.findIndex(
(p) =>
p.x === smoothingCordsArray.value[2].x &&
p.y === smoothingCordsArray.value[2].y
)
if (spliceIndex !== -1) {
interpolatedPoints = interpolatedPoints.slice(spliceIndex + 1)
}
}
for (const p of interpolatedPoints) {
drawShape(p, interpolatedOpacity)
}
if (!initialDraw.value) {
smoothingCordsArray.value = smoothingCordsArray.value.slice(2)
} else {
initialDraw.value = false
}
}
const drawLine = async (
p1: Point,
p2: Point,
compositionOp: CompositionOperation
): Promise<void> => {
try {
const brush_size = store.brushSettings.size
const distance = Math.sqrt((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2)
const steps = Math.ceil(
distance / ((brush_size / store.brushSettings.smoothingPrecision) * 4)
)
const interpolatedOpacity =
1 / (1 + Math.exp(-6 * (store.brushSettings.opacity - 0.5))) -
1 / (1 + Math.exp(3))
initShape(compositionOp)
for (let i = 0; i <= steps; i++) {
const t = i / steps
const x = p1.x + (p2.x - p1.x) * t
const y = p1.y + (p2.y - p1.y) * t
const point = { x, y }
drawShape(point, interpolatedOpacity)
}
} catch (error) {
console.error('[useBrushDrawing] Failed to draw line:', error)
throw error
}
}
const startDrawing = async (event: PointerEvent): Promise<void> => {
isDrawing.value = true
try {
let compositionOp: CompositionOperation
const currentTool = store.currentTool
const coords = { x: event.offsetX, y: event.offsetY }
const coords_canvas = coordinateTransform.screenToCanvas(coords)
await createBrushStrokeCanvas()
if (currentTool === 'eraser' || event.buttons === 2) {
compositionOp = CompositionOperation.DestinationOut
} else {
compositionOp = CompositionOperation.SourceOver
}
if (event.shiftKey && lineStartPoint.value) {
isDrawingLine.value = true
await drawLine(lineStartPoint.value, coords_canvas, compositionOp)
} else {
isDrawingLine.value = false
initShape(compositionOp)
drawShape(coords_canvas)
}
lineStartPoint.value = coords_canvas
smoothingCordsArray.value = [coords_canvas]
smoothingLastDrawTime.value = new Date()
} catch (error) {
console.error('[useBrushDrawing] Failed to start drawing:', error)
isDrawing.value = false
isDrawingLine.value = false
}
}
const handleDrawing = async (event: PointerEvent): Promise<void> => {
const diff = performance.now() - smoothingLastDrawTime.value.getTime()
const coords = { x: event.offsetX, y: event.offsetY }
const coords_canvas = coordinateTransform.screenToCanvas(coords)
const currentTool = store.currentTool
if (diff > 20 && !isDrawing.value) {
requestAnimationFrame(() => {
try {
initShape(CompositionOperation.SourceOver)
drawShape(coords_canvas)
smoothingCordsArray.value.push(coords_canvas)
} catch (error) {
console.error('[useBrushDrawing] Drawing error:', error)
}
})
} else {
requestAnimationFrame(() => {
try {
if (currentTool === 'eraser' || event.buttons === 2) {
initShape(CompositionOperation.DestinationOut)
} else {
initShape(CompositionOperation.SourceOver)
}
drawWithBetterSmoothing(coords_canvas)
} catch (error) {
console.error('[useBrushDrawing] Drawing error:', error)
}
})
}
smoothingLastDrawTime.value = new Date()
}
const drawEnd = async (event: PointerEvent): Promise<void> => {
const coords = { x: event.offsetX, y: event.offsetY }
const coords_canvas = coordinateTransform.screenToCanvas(coords)
if (isDrawing.value) {
isDrawing.value = false
store.canvasHistory.saveState()
lineStartPoint.value = coords_canvas
initialDraw.value = true
}
}
const startBrushAdjustment = async (event: PointerEvent): Promise<void> => {
event.preventDefault()
const coords = { x: event.offsetX, y: event.offsetY }
const coords_canvas = coordinateTransform.screenToCanvas(coords)
store.brushPreviewGradientVisible = true
initialPoint.value = coords_canvas
}
const handleBrushAdjustment = async (event: PointerEvent): Promise<void> => {
if (!initialPoint.value) {
return
}
const coords = { x: event.offsetX, y: event.offsetY }
const brushDeadZone = 5
const coords_canvas = coordinateTransform.screenToCanvas(coords)
const delta_x = coords_canvas.x - initialPoint.value.x
const delta_y = coords_canvas.y - initialPoint.value.y
const effectiveDeltaX = Math.abs(delta_x) < brushDeadZone ? 0 : delta_x
const effectiveDeltaY = Math.abs(delta_y) < brushDeadZone ? 0 : delta_y
let finalDeltaX = effectiveDeltaX
let finalDeltaY = effectiveDeltaY
if (useDominantAxis.value) {
const ratio = Math.abs(effectiveDeltaX) / Math.abs(effectiveDeltaY)
const threshold = 2.0
if (ratio > threshold) {
finalDeltaY = 0
} else if (ratio < 1 / threshold) {
finalDeltaX = 0
}
}
const cappedDeltaX = Math.max(-100, Math.min(100, finalDeltaX))
const cappedDeltaY = Math.max(-100, Math.min(100, finalDeltaY))
const newSize = Math.max(
1,
Math.min(
100,
store.brushSettings.size +
(cappedDeltaX / 35) * brushAdjustmentSpeed.value
)
)
const newHardness = Math.max(
0,
Math.min(
1,
store.brushSettings.hardness -
(cappedDeltaY / 4000) * brushAdjustmentSpeed.value
)
)
store.setBrushSize(newSize)
store.setBrushHardness(newHardness)
}
const saveBrushSettings = (): void => {
saveBrushToCache('maskeditor_brush_settings', store.brushSettings)
}
return {
startDrawing,
handleDrawing,
drawEnd,
startBrushAdjustment,
handleBrushAdjustment,
saveBrushSettings
}
}

View File

@@ -0,0 +1,136 @@
import { ref, computed } from 'vue'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
export function useCanvasHistory(maxStates = 20) {
const store = useMaskEditorStore()
const states = ref<{ mask: ImageData; rgb: ImageData }[]>([])
const currentStateIndex = ref(-1)
const initialized = ref(false)
const canUndo = computed(
() => states.value.length > 1 && currentStateIndex.value > 0
)
const canRedo = computed(() => {
return (
states.value.length > 1 &&
currentStateIndex.value < states.value.length - 1
)
})
const saveInitialState = () => {
const maskCtx = store.maskCtx
const rgbCtx = store.rgbCtx
const maskCanvas = store.maskCanvas
const rgbCanvas = store.rgbCanvas
if (!maskCtx || !rgbCtx || !maskCanvas || !rgbCanvas) {
requestAnimationFrame(saveInitialState)
return
}
if (!maskCanvas.width || !rgbCanvas.width) {
requestAnimationFrame(saveInitialState)
return
}
states.value = []
const maskState = maskCtx.getImageData(
0,
0,
maskCanvas.width,
maskCanvas.height
)
const rgbState = rgbCtx.getImageData(
0,
0,
rgbCanvas.width,
rgbCanvas.height
)
states.value.push({ mask: maskState, rgb: rgbState })
currentStateIndex.value = 0
initialized.value = true
}
const saveState = () => {
const maskCtx = store.maskCtx
const rgbCtx = store.rgbCtx
const maskCanvas = store.maskCanvas
const rgbCanvas = store.rgbCanvas
if (!maskCtx || !rgbCtx || !maskCanvas || !rgbCanvas) return
if (!initialized.value || currentStateIndex.value === -1) {
saveInitialState()
return
}
states.value = states.value.slice(0, currentStateIndex.value + 1)
const maskState = maskCtx.getImageData(
0,
0,
maskCanvas.width,
maskCanvas.height
)
const rgbState = rgbCtx.getImageData(
0,
0,
rgbCanvas.width,
rgbCanvas.height
)
states.value.push({ mask: maskState, rgb: rgbState })
currentStateIndex.value++
if (states.value.length > maxStates) {
states.value.shift()
currentStateIndex.value--
}
}
const undo = () => {
if (!canUndo.value) {
alert('No more undo states available')
return
}
currentStateIndex.value--
restoreState(states.value[currentStateIndex.value])
}
const redo = () => {
if (!canRedo.value) {
alert('No more redo states available')
return
}
currentStateIndex.value++
restoreState(states.value[currentStateIndex.value])
}
const restoreState = (state: { mask: ImageData; rgb: ImageData }) => {
const maskCtx = store.maskCtx
const rgbCtx = store.rgbCtx
if (!maskCtx || !rgbCtx) return
maskCtx.putImageData(state.mask, 0, 0)
rgbCtx.putImageData(state.rgb, 0, 0)
}
const clearStates = () => {
states.value = []
currentStateIndex.value = -1
initialized.value = false
}
return {
canUndo,
canRedo,
saveInitialState,
saveState,
undo,
redo,
clearStates
}
}

View File

@@ -0,0 +1,121 @@
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { MaskBlendMode } from '@/extensions/core/maskeditor/types'
export function useCanvasManager() {
const store = useMaskEditorStore()
const prepareMask = async (
image: HTMLImageElement,
maskCanvasEl: HTMLCanvasElement,
maskContext: CanvasRenderingContext2D
): Promise<void> => {
const maskColor = store.maskColor
maskContext.drawImage(image, 0, 0, maskCanvasEl.width, maskCanvasEl.height)
const maskData = maskContext.getImageData(
0,
0,
maskCanvasEl.width,
maskCanvasEl.height
)
for (let i = 0; i < maskData.data.length; i += 4) {
const alpha = maskData.data[i + 3]
maskData.data[i] = maskColor.r
maskData.data[i + 1] = maskColor.g
maskData.data[i + 2] = maskColor.b
maskData.data[i + 3] = 255 - alpha
}
maskContext.globalCompositeOperation = 'source-over'
maskContext.putImageData(maskData, 0, 0)
}
const invalidateCanvas = async (
origImage: HTMLImageElement,
maskImage: HTMLImageElement,
paintImage: HTMLImageElement | null
): Promise<void> => {
const { imgCanvas, maskCanvas, rgbCanvas, imgCtx, maskCtx, rgbCtx } = store
if (
!imgCanvas ||
!maskCanvas ||
!rgbCanvas ||
!imgCtx ||
!maskCtx ||
!rgbCtx
) {
throw new Error('Canvas elements or contexts not available')
}
imgCanvas.width = origImage.width
imgCanvas.height = origImage.height
maskCanvas.width = origImage.width
maskCanvas.height = origImage.height
rgbCanvas.width = origImage.width
rgbCanvas.height = origImage.height
imgCtx.drawImage(origImage, 0, 0, origImage.width, origImage.height)
if (paintImage) {
rgbCtx.drawImage(paintImage, 0, 0, paintImage.width, paintImage.height)
}
await prepareMask(maskImage, maskCanvas, maskCtx)
}
const setCanvasBackground = (): void => {
const canvasBackground = store.canvasBackground
if (!canvasBackground) return
if (store.maskBlendMode === MaskBlendMode.Black) {
canvasBackground.style.backgroundColor = 'rgba(0,0,0,1)'
} else if (store.maskBlendMode === MaskBlendMode.White) {
canvasBackground.style.backgroundColor = 'rgba(255,255,255,1)'
} else if (store.maskBlendMode === MaskBlendMode.Negative) {
canvasBackground.style.backgroundColor = 'rgba(255,255,255,1)'
}
}
const updateMaskColor = async (): Promise<void> => {
const { maskCanvas, maskCtx, maskColor, maskBlendMode, maskOpacity } = store
if (!maskCanvas || !maskCtx) return
if (maskBlendMode === MaskBlendMode.Negative) {
maskCanvas.style.mixBlendMode = 'difference'
maskCanvas.style.opacity = '1'
} else {
maskCanvas.style.mixBlendMode = 'initial'
maskCanvas.style.opacity = String(maskOpacity)
}
maskCtx.fillStyle = `rgb(${maskColor.r}, ${maskColor.g}, ${maskColor.b})`
setCanvasBackground()
const maskData = maskCtx.getImageData(
0,
0,
maskCanvas.width,
maskCanvas.height
)
for (let i = 0; i < maskData.data.length; i += 4) {
maskData.data[i] = maskColor.r
maskData.data[i + 1] = maskColor.g
maskData.data[i + 2] = maskColor.b
}
maskCtx.putImageData(maskData, 0, 0)
}
return {
invalidateCanvas,
updateMaskColor
}
}

View File

@@ -0,0 +1,486 @@
import { ref, watch } from 'vue'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { ColorComparisonMethod } from '@/extensions/core/maskeditor/types'
import type { Point } from '@/extensions/core/maskeditor/types'
const getPixelAlpha = (
data: Uint8ClampedArray,
x: number,
y: number,
width: number
): number => {
return data[(y * width + x) * 4 + 3]
}
const getPixelColor = (
data: Uint8ClampedArray,
x: number,
y: number,
width: number
): { r: number; g: number; b: number } => {
const index = (y * width + x) * 4
return {
r: data[index],
g: data[index + 1],
b: data[index + 2]
}
}
const setPixel = (
data: Uint8ClampedArray,
x: number,
y: number,
width: number,
alpha: number,
color: { r: number; g: number; b: number }
): void => {
const index = (y * width + x) * 4
data[index] = color.r
data[index + 1] = color.g
data[index + 2] = color.b
data[index + 3] = alpha
}
// Color comparison utilities
const rgbToHSL = (
r: number,
g: number,
b: number
): { h: number; s: number; l: number } => {
r /= 255
g /= 255
b /= 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
let h = 0
let s = 0
const l = (max + min) / 2
if (max !== min) {
const d = max - min
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0)
break
case g:
h = (b - r) / d + 2
break
case b:
h = (r - g) / d + 4
break
}
h /= 6
}
return {
h: h * 360,
s: s * 100,
l: l * 100
}
}
const rgbToLab = (rgb: {
r: number
g: number
b: number
}): {
l: number
a: number
b: number
} => {
let r = rgb.r / 255
let g = rgb.g / 255
let b = rgb.b / 255
r = r > 0.04045 ? Math.pow((r + 0.055) / 1.055, 2.4) : r / 12.92
g = g > 0.04045 ? Math.pow((g + 0.055) / 1.055, 2.4) : g / 12.92
b = b > 0.04045 ? Math.pow((b + 0.055) / 1.055, 2.4) : b / 12.92
r *= 100
g *= 100
b *= 100
const x = r * 0.4124 + g * 0.3576 + b * 0.1805
const y = r * 0.2126 + g * 0.7152 + b * 0.0722
const z = r * 0.0193 + g * 0.1192 + b * 0.9505
const xn = 95.047
const yn = 100.0
const zn = 108.883
const xyz = [x / xn, y / yn, z / zn]
for (let i = 0; i < xyz.length; i++) {
xyz[i] =
xyz[i] > 0.008856 ? Math.pow(xyz[i], 1 / 3) : 7.787 * xyz[i] + 16 / 116
}
return {
l: 116 * xyz[1] - 16,
a: 500 * (xyz[0] - xyz[1]),
b: 200 * (xyz[1] - xyz[2])
}
}
const isPixelInRangeSimple = (
pixel: { r: number; g: number; b: number },
target: { r: number; g: number; b: number },
tolerance: number
): boolean => {
const distance = Math.sqrt(
Math.pow(pixel.r - target.r, 2) +
Math.pow(pixel.g - target.g, 2) +
Math.pow(pixel.b - target.b, 2)
)
return distance <= tolerance
}
const isPixelInRangeHSL = (
pixel: { r: number; g: number; b: number },
target: { r: number; g: number; b: number },
tolerance: number
): boolean => {
const pixelHSL = rgbToHSL(pixel.r, pixel.g, pixel.b)
const targetHSL = rgbToHSL(target.r, target.g, target.b)
const hueDiff = Math.abs(pixelHSL.h - targetHSL.h)
const satDiff = Math.abs(pixelHSL.s - targetHSL.s)
const lightDiff = Math.abs(pixelHSL.l - targetHSL.l)
const distance = Math.sqrt(
Math.pow((hueDiff / 360) * 255, 2) +
Math.pow((satDiff / 100) * 255, 2) +
Math.pow((lightDiff / 100) * 255, 2)
)
return distance <= tolerance
}
const isPixelInRangeLab = (
pixel: { r: number; g: number; b: number },
target: { r: number; g: number; b: number },
tolerance: number
): boolean => {
const pixelLab = rgbToLab(pixel)
const targetLab = rgbToLab(target)
const deltaE = Math.sqrt(
Math.pow(pixelLab.l - targetLab.l, 2) +
Math.pow(pixelLab.a - targetLab.a, 2) +
Math.pow(pixelLab.b - targetLab.b, 2)
)
const normalizedDeltaE = (deltaE / 100) * 255
return normalizedDeltaE <= tolerance
}
const isPixelInRange = (
pixel: { r: number; g: number; b: number },
target: { r: number; g: number; b: number },
tolerance: number,
method: ColorComparisonMethod
): boolean => {
switch (method) {
case ColorComparisonMethod.Simple:
return isPixelInRangeSimple(pixel, target, tolerance)
case ColorComparisonMethod.HSL:
return isPixelInRangeHSL(pixel, target, tolerance)
case ColorComparisonMethod.LAB:
return isPixelInRangeLab(pixel, target, tolerance)
default:
return isPixelInRangeSimple(pixel, target, tolerance)
}
}
export function useCanvasTools() {
const store = useMaskEditorStore()
const lastColorSelectPoint = ref<Point | null>(null)
const paintBucketFill = (point: Point): void => {
const ctx = store.maskCtx
const canvas = store.maskCanvas
if (!ctx || !canvas) return
const startX = Math.floor(point.x)
const startY = Math.floor(point.y)
const width = canvas.width
const height = canvas.height
if (startX < 0 || startX >= width || startY < 0 || startY >= height) {
return
}
const imageData = ctx.getImageData(0, 0, width, height)
const data = imageData.data
const targetAlpha = getPixelAlpha(data, startX, startY, width)
const isFillMode = targetAlpha !== 255
if (targetAlpha === -1) return
const maskColor = store.maskColor
const tolerance = store.paintBucketTolerance
const fillOpacity = Math.floor((store.fillOpacity / 100) * 255)
const stack: Array<[number, number]> = []
const visited = new Uint8Array(width * height)
const shouldProcessPixel = (
currentAlpha: number,
targetAlpha: number,
tolerance: number,
isFillMode: boolean
): boolean => {
if (currentAlpha === -1) return false
if (isFillMode) {
return (
currentAlpha !== 255 &&
Math.abs(currentAlpha - targetAlpha) <= tolerance
)
} else {
return (
currentAlpha === 255 ||
Math.abs(currentAlpha - targetAlpha) <= tolerance
)
}
}
if (shouldProcessPixel(targetAlpha, targetAlpha, tolerance, isFillMode)) {
stack.push([startX, startY])
}
while (stack.length > 0) {
const [x, y] = stack.pop()!
const visitedIndex = y * width + x
if (visited[visitedIndex]) continue
const currentAlpha = getPixelAlpha(data, x, y, width)
if (
!shouldProcessPixel(currentAlpha, targetAlpha, tolerance, isFillMode)
) {
continue
}
visited[visitedIndex] = 1
setPixel(data, x, y, width, isFillMode ? fillOpacity : 0, maskColor)
const checkNeighbor = (nx: number, ny: number) => {
if (nx < 0 || nx >= width || ny < 0 || ny >= height) return
if (!visited[ny * width + nx]) {
const alpha = getPixelAlpha(data, nx, ny, width)
if (shouldProcessPixel(alpha, targetAlpha, tolerance, isFillMode)) {
stack.push([nx, ny])
}
}
}
checkNeighbor(x - 1, y)
checkNeighbor(x + 1, y)
checkNeighbor(x, y - 1)
checkNeighbor(x, y + 1)
}
ctx.putImageData(imageData, 0, 0)
store.canvasHistory.saveState()
}
const colorSelectFill = async (point: Point): Promise<void> => {
const maskCtx = store.maskCtx
const imgCtx = store.imgCtx
const imgCanvas = store.imgCanvas
if (!maskCtx || !imgCtx || !imgCanvas) return
const width = imgCanvas.width
const height = imgCanvas.height
lastColorSelectPoint.value = point
const maskData = maskCtx.getImageData(0, 0, width, height)
const maskDataArray = maskData.data
const imageDataArray = imgCtx.getImageData(0, 0, width, height).data
const tolerance = store.colorSelectTolerance
const method = store.colorComparisonMethod
const maskColor = store.maskColor
const selectOpacity = Math.floor((store.selectionOpacity / 100) * 255)
const applyWholeImage = store.applyWholeImage
const maskBoundary = store.maskBoundary
const maskTolerance = store.maskTolerance
if (applyWholeImage) {
const targetPixel = getPixelColor(
imageDataArray,
Math.floor(point.x),
Math.floor(point.y),
width
)
const CHUNK_SIZE = 10000
for (let i = 0; i < width * height; i += CHUNK_SIZE) {
const endIndex = Math.min(i + CHUNK_SIZE, width * height)
for (let pixelIndex = i; pixelIndex < endIndex; pixelIndex++) {
const x = pixelIndex % width
const y = Math.floor(pixelIndex / width)
if (
isPixelInRange(
getPixelColor(imageDataArray, x, y, width),
targetPixel,
tolerance,
method
)
) {
setPixel(maskDataArray, x, y, width, selectOpacity, maskColor)
}
}
await new Promise((resolve) => setTimeout(resolve, 0))
}
} else {
const startX = Math.floor(point.x)
const startY = Math.floor(point.y)
if (startX < 0 || startX >= width || startY < 0 || startY >= height) {
return
}
const targetPixel = getPixelColor(imageDataArray, startX, startY, width)
const stack: Array<[number, number]> = []
const visited = new Uint8Array(width * height)
stack.push([startX, startY])
while (stack.length > 0) {
const [x, y] = stack.pop()!
const visitedIndex = y * width + x
if (
visited[visitedIndex] ||
!isPixelInRange(
getPixelColor(imageDataArray, x, y, width),
targetPixel,
tolerance,
method
)
) {
continue
}
visited[visitedIndex] = 1
setPixel(maskDataArray, x, y, width, selectOpacity, maskColor)
const checkNeighbor = (nx: number, ny: number) => {
if (nx < 0 || nx >= width || ny < 0 || ny >= height) return
if (visited[ny * width + nx]) return
if (
!isPixelInRange(
getPixelColor(imageDataArray, nx, ny, width),
targetPixel,
tolerance,
method
)
)
return
if (
maskBoundary &&
255 - getPixelAlpha(maskDataArray, nx, ny, width) <= maskTolerance
)
return
stack.push([nx, ny])
}
checkNeighbor(x - 1, y)
checkNeighbor(x + 1, y)
checkNeighbor(x, y - 1)
checkNeighbor(x, y + 1)
}
}
maskCtx.putImageData(maskData, 0, 0)
store.canvasHistory.saveState()
}
const invertMask = (): void => {
const ctx = store.maskCtx
const canvas = store.maskCanvas
if (!ctx || !canvas) return
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
const data = imageData.data
let maskR = 0,
maskG = 0,
maskB = 0
for (let i = 0; i < data.length; i += 4) {
if (data[i + 3] > 0) {
maskR = data[i]
maskG = data[i + 1]
maskB = data[i + 2]
break
}
}
for (let i = 0; i < data.length; i += 4) {
const alpha = data[i + 3]
data[i + 3] = 255 - alpha
if (alpha === 0) {
data[i] = maskR
data[i + 1] = maskG
data[i + 2] = maskB
}
}
ctx.putImageData(imageData, 0, 0)
store.canvasHistory.saveState()
}
const clearMask = (): void => {
const maskCtx = store.maskCtx
const maskCanvas = store.maskCanvas
const rgbCtx = store.rgbCtx
const rgbCanvas = store.rgbCanvas
if (maskCtx && maskCanvas) {
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height)
}
if (rgbCtx && rgbCanvas) {
rgbCtx.clearRect(0, 0, rgbCanvas.width, rgbCanvas.height)
}
store.canvasHistory.saveState()
}
const clearLastColorSelectPoint = () => {
lastColorSelectPoint.value = null
}
watch(
[
() => store.colorSelectTolerance,
() => store.colorComparisonMethod,
() => store.selectionOpacity
],
async () => {
if (
lastColorSelectPoint.value &&
store.colorSelectLivePreview &&
store.canUndo
) {
store.canvasHistory.undo()
await colorSelectFill(lastColorSelectPoint.value)
}
}
)
return {
paintBucketFill,
colorSelectFill,
clearLastColorSelectPoint,
invertMask,
clearMask
}
}

View File

@@ -0,0 +1,79 @@
import { createSharedComposable } from '@vueuse/core'
import { unref } from 'vue'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
interface Point {
x: number
y: number
}
function useCoordinateTransformInternal() {
const store = useMaskEditorStore()
const screenToCanvas = (clientPoint: Point): Point => {
const pointerZoneEl = unref(store.pointerZone)
const canvasContainerEl = unref(store.canvasContainer)
const canvasEl = unref(store.maskCanvas)
if (!pointerZoneEl || !canvasContainerEl || !canvasEl) {
console.warn('screenToCanvas called before elements are available')
return { x: 0, y: 0 }
}
const pointerZoneRect = pointerZoneEl.getBoundingClientRect()
const canvasContainerRect = canvasContainerEl.getBoundingClientRect()
const canvasRect = canvasEl.getBoundingClientRect()
const absoluteX = pointerZoneRect.left + clientPoint.x
const absoluteY = pointerZoneRect.top + clientPoint.y
const canvasX = absoluteX - canvasContainerRect.left
const canvasY = absoluteY - canvasContainerRect.top
const scaleX = canvasEl.width / canvasRect.width
const scaleY = canvasEl.height / canvasRect.height
const x = canvasX * scaleX
const y = canvasY * scaleY
return { x, y }
}
const canvasToScreen = (canvasPoint: Point): Point => {
const pointerZoneEl = unref(store.pointerZone)
const canvasContainerEl = unref(store.canvasContainer)
const canvasEl = unref(store.maskCanvas)
if (!pointerZoneEl || !canvasContainerEl || !canvasEl) {
console.warn('canvasToScreen called before elements are available')
return { x: 0, y: 0 }
}
const pointerZoneRect = pointerZoneEl.getBoundingClientRect()
const canvasContainerRect = canvasContainerEl.getBoundingClientRect()
const canvasRect = canvasEl.getBoundingClientRect()
const scaleX = canvasRect.width / canvasEl.width
const scaleY = canvasRect.height / canvasEl.height
const displayX = canvasPoint.x * scaleX
const displayY = canvasPoint.y * scaleY
const absoluteX = canvasContainerRect.left + displayX
const absoluteY = canvasContainerRect.top + displayY
const x = absoluteX - pointerZoneRect.left
const y = absoluteY - pointerZoneRect.top
return { x, y }
}
return {
screenToCanvas,
canvasToScreen
}
}
export const useCoordinateTransform = createSharedComposable(
useCoordinateTransformInternal
)

View File

@@ -0,0 +1,54 @@
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { useMaskEditorDataStore } from '@/stores/maskEditorDataStore'
import { createSharedComposable } from '@vueuse/core'
import { useCanvasManager } from '@/composables/maskeditor/useCanvasManager'
function useImageLoaderInternal() {
const store = useMaskEditorStore()
const dataStore = useMaskEditorDataStore()
const canvasManager = useCanvasManager()
const loadImages = async (): Promise<HTMLImageElement> => {
const inputData = dataStore.inputData
if (!inputData) {
throw new Error('No input data available in dataStore')
}
const { imgCanvas, maskCanvas, rgbCanvas, imgCtx, maskCtx } = store
if (!imgCanvas || !maskCanvas || !rgbCanvas || !imgCtx || !maskCtx) {
throw new Error('Canvas elements or contexts not available')
}
imgCtx.clearRect(0, 0, imgCanvas.width, imgCanvas.height)
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height)
const baseImage = inputData.baseLayer.image
const maskImage = inputData.maskLayer.image
const paintImage = inputData.paintLayer?.image
maskCanvas.width = baseImage.width
maskCanvas.height = baseImage.height
rgbCanvas.width = baseImage.width
rgbCanvas.height = baseImage.height
store.image = baseImage
await canvasManager.invalidateCanvas(
baseImage,
maskImage,
paintImage || null
)
await canvasManager.updateMaskColor()
return baseImage
}
return {
loadImages
}
}
export const useImageLoader = createSharedComposable(useImageLoaderInternal)

View File

@@ -0,0 +1,62 @@
import { ref } from 'vue'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
export function useKeyboard() {
const store = useMaskEditorStore()
const keysDown = ref<string[]>([])
const isKeyDown = (key: string): boolean => {
return keysDown.value.includes(key)
}
const clearKeys = (): void => {
keysDown.value = []
}
const handleKeyDown = (event: KeyboardEvent): void => {
if (!keysDown.value.includes(event.key)) {
keysDown.value.push(event.key)
}
if (event.key === ' ') {
event.preventDefault()
const activeElement = document.activeElement as HTMLElement
if (activeElement && activeElement.blur) {
activeElement.blur()
}
}
if ((event.ctrlKey || event.metaKey) && !event.altKey) {
const key = event.key.toUpperCase()
if ((key === 'Y' && !event.shiftKey) || (key === 'Z' && event.shiftKey)) {
store.canvasHistory.redo()
} else if (key === 'Z' && !event.shiftKey) {
store.canvasHistory.undo()
}
}
}
const handleKeyUp = (event: KeyboardEvent): void => {
keysDown.value = keysDown.value.filter((key) => key !== event.key)
}
const addListeners = (): void => {
document.addEventListener('keydown', handleKeyDown)
document.addEventListener('keyup', handleKeyUp)
window.addEventListener('blur', clearKeys)
}
const removeListeners = (): void => {
document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('keyup', handleKeyUp)
window.removeEventListener('blur', clearKeys)
}
return {
isKeyDown,
addListeners,
removeListeners
}
}

View File

@@ -0,0 +1,310 @@
import { useMaskEditorDataStore } from '@/stores/maskEditorDataStore'
import type { ImageRef, ImageLayer } from '@/stores/maskEditorDataStore'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { isCloud } from '@/platform/distribution/types'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
// Private image utility functions
interface ImageLayerFilenames {
maskedImage: string
paint: string
paintedImage: string
paintedMaskedImage: string
}
interface MaskLayersResponse {
painted_masked?: string
painted?: string
paint?: string
mask?: string
}
const paintedMaskedImagePrefix = 'clipspace-painted-masked-'
function imageLayerFilenamesIfApplicable(
inputImageFilename: string
): ImageLayerFilenames | undefined {
const isPaintedMaskedImageFilename = inputImageFilename.startsWith(
paintedMaskedImagePrefix
)
if (!isPaintedMaskedImageFilename) return undefined
const suffix = inputImageFilename.slice(paintedMaskedImagePrefix.length)
const timestamp = parseInt(suffix.split('.')[0], 10)
return {
maskedImage: `clipspace-mask-${timestamp}.png`,
paint: `clipspace-paint-${timestamp}.png`,
paintedImage: `clipspace-painted-${timestamp}.png`,
paintedMaskedImage: `${paintedMaskedImagePrefix}${timestamp}.png`
}
}
function toRef(filename: string): ImageRef {
return {
filename,
subfolder: 'clipspace',
type: 'input'
}
}
function mkFileUrl(props: { ref: ImageRef; preview?: boolean }): string {
const params = new URLSearchParams()
params.set('filename', props.ref.filename)
if (props.ref.subfolder) {
params.set('subfolder', props.ref.subfolder)
}
if (props.ref.type) {
params.set('type', props.ref.type)
}
const pathPlusQueryParams = api.apiURL(
'/view?' +
params.toString() +
app.getPreviewFormatParam() +
app.getRandParam()
)
const imageElement = new Image()
imageElement.crossOrigin = 'anonymous'
imageElement.src = pathPlusQueryParams
return imageElement.src
}
export function useMaskEditorLoader() {
const dataStore = useMaskEditorDataStore()
const nodeOutputStore = useNodeOutputStore()
const loadFromNode = async (node: LGraphNode): Promise<void> => {
dataStore.setLoading(true)
try {
validateNode(node)
const nodeImageUrl = getNodeImageUrl(node)
const nodeImageRef = parseImageRef(nodeImageUrl)
let widgetFilename: string | undefined
if (node.widgets) {
const imageWidget = node.widgets.find((w) => w.name === 'image')
if (
imageWidget &&
typeof imageWidget.value === 'object' &&
imageWidget.value &&
'filename' in imageWidget.value &&
typeof imageWidget.value.filename === 'string'
) {
widgetFilename = imageWidget.value.filename
}
}
const fileToQuery = widgetFilename || nodeImageRef.filename
let maskLayersFromApi: MaskLayersResponse | undefined
if (isCloud) {
try {
const response = await api.fetchApi(
`/files/mask-layers?filename=${fileToQuery}`
)
if (response.ok) {
maskLayersFromApi = await response.json()
}
} catch (error) {
// Fallback to pattern matching if API call fails
}
}
let imageLayerFilenames = imageLayerFilenamesIfApplicable(
nodeImageRef.filename
)
if (maskLayersFromApi) {
const baseFile =
maskLayersFromApi.painted_masked || maskLayersFromApi.painted
if (baseFile) {
imageLayerFilenames = {
maskedImage: baseFile,
paint: maskLayersFromApi.paint || '',
paintedImage: maskLayersFromApi.painted || '',
paintedMaskedImage: maskLayersFromApi.painted_masked || baseFile
}
}
}
const baseImageUrl = imageLayerFilenames?.maskedImage
? mkFileUrl({ ref: toRef(imageLayerFilenames.maskedImage) })
: nodeImageUrl
const sourceRef = imageLayerFilenames?.maskedImage
? parseImageRef(baseImageUrl)
: nodeImageRef
let paintLayerUrl: string | null = null
if (maskLayersFromApi?.paint) {
paintLayerUrl = mkFileUrl({ ref: toRef(maskLayersFromApi.paint) })
} else if (imageLayerFilenames?.paint) {
paintLayerUrl = mkFileUrl({ ref: toRef(imageLayerFilenames.paint) })
}
const [baseLayer, maskLayer, paintLayer] = await Promise.all([
loadImageLayer(baseImageUrl, 'rgb'),
loadImageLayer(baseImageUrl, 'a'),
paintLayerUrl
? loadPaintLayer(paintLayerUrl)
: Promise.resolve(undefined)
])
dataStore.inputData = {
baseLayer,
maskLayer,
paintLayer,
sourceRef,
nodeId: node.id
}
dataStore.sourceNode = node
dataStore.setLoading(false)
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Failed to load from node'
console.error('[MaskEditorLoader]', errorMessage, error)
dataStore.setLoading(false, errorMessage)
throw error
}
}
function validateNode(node: LGraphNode): void {
if (!node) {
throw new Error('Node is null or undefined')
}
const hasImages = node.imgs?.length || node.previewMediaType === 'image'
if (!hasImages) {
throw new Error('Node has no images')
}
}
function getNodeImageUrl(node: LGraphNode): string {
if (node.images?.[0]) {
const img = node.images[0]
const params = new URLSearchParams({
filename: img.filename,
type: img.type || 'output',
subfolder: img.subfolder || ''
})
return api.apiURL(`/view?${params.toString()}`)
}
const outputs = nodeOutputStore.getNodeOutputs(node)
if (outputs?.images?.[0]) {
const img = outputs.images[0]
if (!img.filename) {
throw new Error('nodeOutputStore image missing filename')
}
const params = new URLSearchParams()
params.set('filename', img.filename)
params.set('type', img.type || 'output')
params.set('subfolder', img.subfolder || '')
return api.apiURL(`/view?${params.toString()}`)
}
if (node.imgs?.length) {
const index = node.imageIndex ?? 0
const imgSrc = node.imgs[index].src
if (imgSrc && !imgSrc.startsWith('data:')) {
return imgSrc
}
}
throw new Error('Unable to get image URL from node')
}
function parseImageRef(url: string): ImageRef {
try {
const urlObj = new URL(url)
const filename = urlObj.searchParams.get('filename')
if (!filename) {
throw new Error('Image URL missing filename parameter')
}
return {
filename,
subfolder: urlObj.searchParams.get('subfolder') || undefined,
type: urlObj.searchParams.get('type') || undefined
}
} catch (error) {
try {
const urlObj = new URL(url, window.location.origin)
const filename = urlObj.searchParams.get('filename')
if (!filename) {
throw new Error('Image URL missing filename parameter')
}
return {
filename,
subfolder: urlObj.searchParams.get('subfolder') || undefined,
type: urlObj.searchParams.get('type') || undefined
}
} catch (e) {
throw new Error(`Invalid image URL: ${url}`)
}
}
}
async function loadImageLayer(
url: string,
channel?: 'rgb' | 'a'
): Promise<ImageLayer> {
let urlObj: URL
try {
urlObj = new URL(url)
} catch {
urlObj = new URL(url, window.location.origin)
}
if (channel) {
urlObj.searchParams.delete('channel')
urlObj.searchParams.set('channel', channel)
}
const finalUrl = urlObj.toString()
const image = await loadImage(finalUrl)
return { image, url: finalUrl }
}
function loadImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => resolve(img)
img.onerror = () => reject(new Error(`Failed to load image: ${url}`))
img.src = url
})
}
async function loadPaintLayer(url: string): Promise<ImageLayer> {
let urlObj: URL
try {
urlObj = new URL(url)
} catch {
urlObj = new URL(url, window.location.origin)
}
const finalUrl = urlObj.toString()
const image = await loadImage(finalUrl)
return { image, url: finalUrl }
}
return {
loadFromNode
}
}

View File

@@ -0,0 +1,401 @@
import { useMaskEditorDataStore } from '@/stores/maskEditorDataStore'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import type {
EditorOutputData,
EditorOutputLayer,
ImageRef
} from '@/stores/maskEditorDataStore'
import { isCloud } from '@/platform/distribution/types'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
// Private layer filename functions
interface ImageLayerFilenames {
maskedImage: string
paint: string
paintedImage: string
paintedMaskedImage: string
}
function imageLayerFilenamesByTimestamp(
timestamp: number
): ImageLayerFilenames {
return {
maskedImage: `clipspace-mask-${timestamp}.png`,
paint: `clipspace-paint-${timestamp}.png`,
paintedImage: `clipspace-painted-${timestamp}.png`,
paintedMaskedImage: `clipspace-painted-masked-${timestamp}.png`
}
}
export function useMaskEditorSaver() {
const dataStore = useMaskEditorDataStore()
const editorStore = useMaskEditorStore()
const nodeOutputStore = useNodeOutputStore()
const save = async (): Promise<void> => {
const sourceNode = dataStore.sourceNode as LGraphNode
if (!sourceNode || !dataStore.inputData) {
throw new Error('No source node or input data')
}
try {
const outputData = await prepareOutputData()
dataStore.outputData = outputData
await updateNodePreview(sourceNode, outputData)
await uploadAllLayers(outputData)
updateNodeWithServerReferences(sourceNode, outputData)
app.graph.setDirtyCanvas(true)
} catch (error) {
console.error('[MaskEditorSaver] Save failed:', error)
throw error
}
}
async function prepareOutputData(): Promise<EditorOutputData> {
const maskCanvas = editorStore.maskCanvas
const paintCanvas = editorStore.rgbCanvas
const imgCanvas = editorStore.imgCanvas
if (!maskCanvas || !paintCanvas || !imgCanvas) {
throw new Error('Canvas not initialized')
}
const timestamp = Date.now()
const filenames = imageLayerFilenamesByTimestamp(timestamp)
const [maskedImage, paintLayer, paintedImage, paintedMaskedImage] =
await Promise.all([
createMaskedImage(imgCanvas, maskCanvas, filenames.maskedImage),
createPaintLayer(paintCanvas, filenames.paint),
createPaintedImage(imgCanvas, paintCanvas, filenames.paintedImage),
createPaintedMaskedImage(
imgCanvas,
paintCanvas,
maskCanvas,
filenames.paintedMaskedImage
)
])
return {
maskedImage,
paintLayer,
paintedImage,
paintedMaskedImage
}
}
async function createMaskedImage(
imgCanvas: HTMLCanvasElement,
maskCanvas: HTMLCanvasElement,
filename: string
): Promise<EditorOutputLayer> {
const canvas = document.createElement('canvas')
canvas.width = imgCanvas.width
canvas.height = imgCanvas.height
const ctx = canvas.getContext('2d')!
ctx.drawImage(imgCanvas, 0, 0)
const maskCtx = maskCanvas.getContext('2d')!
const maskData = maskCtx.getImageData(
0,
0,
maskCanvas.width,
maskCanvas.height
)
const refinedMaskData = new Uint8ClampedArray(maskData.data.length)
for (let i = 0; i < maskData.data.length; i += 4) {
refinedMaskData[i] = 0
refinedMaskData[i + 1] = 0
refinedMaskData[i + 2] = 0
refinedMaskData[i + 3] = 255 - maskData.data[i + 3]
}
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
for (let i = 0; i < imageData.data.length; i += 4) {
imageData.data[i + 3] = refinedMaskData[i + 3]
}
ctx.putImageData(imageData, 0, 0)
const blob = await canvasToBlob(canvas)
const ref = createFileRef(filename)
return { canvas, blob, ref }
}
async function createPaintLayer(
paintCanvas: HTMLCanvasElement,
filename: string
): Promise<EditorOutputLayer> {
const canvas = cloneCanvas(paintCanvas)
const blob = await canvasToBlob(canvas)
const ref = createFileRef(filename)
return { canvas, blob, ref }
}
async function createPaintedImage(
imgCanvas: HTMLCanvasElement,
paintCanvas: HTMLCanvasElement,
filename: string
): Promise<EditorOutputLayer> {
const canvas = document.createElement('canvas')
canvas.width = imgCanvas.width
canvas.height = imgCanvas.height
const ctx = canvas.getContext('2d')!
ctx.drawImage(imgCanvas, 0, 0)
ctx.globalCompositeOperation = 'source-over'
ctx.drawImage(paintCanvas, 0, 0)
const blob = await canvasToBlob(canvas)
const ref = createFileRef(filename)
return { canvas, blob, ref }
}
async function createPaintedMaskedImage(
imgCanvas: HTMLCanvasElement,
paintCanvas: HTMLCanvasElement,
maskCanvas: HTMLCanvasElement,
filename: string
): Promise<EditorOutputLayer> {
const canvas = document.createElement('canvas')
canvas.width = imgCanvas.width
canvas.height = imgCanvas.height
const ctx = canvas.getContext('2d')!
ctx.drawImage(imgCanvas, 0, 0)
ctx.globalCompositeOperation = 'source-over'
ctx.drawImage(paintCanvas, 0, 0)
const maskCtx = maskCanvas.getContext('2d')!
const maskData = maskCtx.getImageData(
0,
0,
maskCanvas.width,
maskCanvas.height
)
const refinedMaskData = new Uint8ClampedArray(maskData.data.length)
for (let i = 0; i < maskData.data.length; i += 4) {
refinedMaskData[i] = 0
refinedMaskData[i + 1] = 0
refinedMaskData[i + 2] = 0
refinedMaskData[i + 3] = 255 - maskData.data[i + 3]
}
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
for (let i = 0; i < imageData.data.length; i += 4) {
imageData.data[i + 3] = refinedMaskData[i + 3]
}
ctx.putImageData(imageData, 0, 0)
const blob = await canvasToBlob(canvas)
const ref = createFileRef(filename)
return { canvas, blob, ref }
}
async function uploadAllLayers(outputData: EditorOutputData): Promise<void> {
const sourceRef = dataStore.inputData!.sourceRef
const actualMaskedRef = await uploadMask(outputData.maskedImage, sourceRef)
const actualPaintRef = await uploadImage(outputData.paintLayer, sourceRef)
const actualPaintedRef = await uploadImage(
outputData.paintedImage,
sourceRef
)
const actualPaintedMaskedRef = await uploadMask(
outputData.paintedMaskedImage,
actualPaintedRef
)
outputData.maskedImage.ref = actualMaskedRef
outputData.paintLayer.ref = actualPaintRef
outputData.paintedImage.ref = actualPaintedRef
outputData.paintedMaskedImage.ref = actualPaintedMaskedRef
}
async function uploadMask(
layer: EditorOutputLayer,
originalRef: ImageRef
): Promise<ImageRef> {
const formData = new FormData()
formData.append('image', layer.blob, layer.ref.filename)
formData.append('original_ref', JSON.stringify(originalRef))
formData.append('type', 'input')
formData.append('subfolder', 'clipspace')
const response = await api.fetchApi('/upload/mask', {
method: 'POST',
body: formData
})
if (!response.ok) {
throw new Error(`Failed to upload mask: ${layer.ref.filename}`)
}
try {
const data = await response.json()
if (data?.name) {
return {
filename: data.name,
subfolder: data.subfolder || layer.ref.subfolder,
type: data.type || layer.ref.type
}
}
} catch (error) {
console.warn('[MaskEditorSaver] Failed to parse upload response:', error)
}
return layer.ref
}
async function uploadImage(
layer: EditorOutputLayer,
originalRef: ImageRef
): Promise<ImageRef> {
const formData = new FormData()
formData.append('image', layer.blob, layer.ref.filename)
formData.append('original_ref', JSON.stringify(originalRef))
formData.append('type', 'input')
formData.append('subfolder', 'clipspace')
const response = await api.fetchApi('/upload/image', {
method: 'POST',
body: formData
})
if (!response.ok) {
throw new Error(`Failed to upload image: ${layer.ref.filename}`)
}
try {
const data = await response.json()
if (data?.name) {
return {
filename: data.name,
subfolder: data.subfolder || layer.ref.subfolder,
type: data.type || layer.ref.type
}
}
} catch (error) {
console.warn('[MaskEditorSaver] Failed to parse upload response:', error)
}
return layer.ref
}
async function updateNodePreview(
node: LGraphNode,
outputData: EditorOutputData
): Promise<void> {
const canvas = outputData.paintedMaskedImage.canvas
const dataUrl = canvas.toDataURL('image/png')
const mainImg = await loadImageFromUrl(dataUrl)
node.imgs = [mainImg]
app.graph.setDirtyCanvas(true)
}
function updateNodeWithServerReferences(
node: LGraphNode,
outputData: EditorOutputData
): void {
const mainRef = outputData.paintedMaskedImage.ref
node.images = [mainRef]
const imageWidget = node.widgets?.find((w) => w.name === 'image')
if (imageWidget) {
// Widget value format differs between Cloud and OSS:
// - Cloud: JUST the filename (subfolder handled by backend)
// - OSS: subfolder/filename (traditional format)
let widgetValue: string
if (isCloud) {
widgetValue =
mainRef.filename + (mainRef.type ? ` [${mainRef.type}]` : '')
} else {
widgetValue =
(mainRef.subfolder ? mainRef.subfolder + '/' : '') +
mainRef.filename +
(mainRef.type ? ` [${mainRef.type}]` : '')
}
imageWidget.value = widgetValue
if (node.properties) {
node.properties['image'] = widgetValue
}
if (node.widgets_values && node.widgets) {
const widgetIndex = node.widgets.indexOf(imageWidget)
if (widgetIndex >= 0) {
node.widgets_values[widgetIndex] = widgetValue
}
}
imageWidget.callback?.(widgetValue)
}
nodeOutputStore.updateNodeImages(node)
}
function loadImageFromUrl(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => resolve(img)
img.onerror = (error) => {
console.error('[MaskEditorSaver] Failed to load image:', url, error)
reject(new Error(`Failed to load image: ${url}`))
}
img.src = url
})
}
function cloneCanvas(source: HTMLCanvasElement): HTMLCanvasElement {
const canvas = document.createElement('canvas')
canvas.width = source.width
canvas.height = source.height
const ctx = canvas.getContext('2d')!
ctx.drawImage(source, 0, 0)
return canvas
}
function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) resolve(blob)
else reject(new Error('Failed to create blob from canvas'))
}, 'image/png')
})
}
function createFileRef(filename: string): ImageRef {
return {
filename,
subfolder: 'clipspace',
type: 'input'
}
}
return {
save
}
}

View File

@@ -0,0 +1,416 @@
import { ref, watch } from 'vue'
import type { Offset, Point } from '@/extensions/core/maskeditor/types'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
export function usePanAndZoom() {
const store = useMaskEditorStore()
const DOUBLE_TAP_DELAY = 300
const lastTwoFingerTap = ref(0)
const isTouchZooming = ref(false)
const lastTouchZoomDistance = ref(0)
const lastTouchMidPoint = ref<Point>({ x: 0, y: 0 })
const lastTouchPoint = ref<Point>({ x: 0, y: 0 })
const zoom_ratio = ref(1)
const interpolatedZoomRatio = ref(1)
const pan_offset = ref<Offset>({ x: 0, y: 0 })
const mouseDownPoint = ref<Point | null>(null)
const initialPan = ref<Offset>({ x: 0, y: 0 })
const canvasContainer = ref<HTMLElement | null>(null)
const maskCanvas = ref<HTMLCanvasElement | null>(null)
const rgbCanvas = ref<HTMLCanvasElement | null>(null)
const rootElement = ref<HTMLElement | null>(null)
const toolPanelElement = ref<HTMLElement | null>(null)
const sidePanelElement = ref<HTMLElement | null>(null)
const image = ref<HTMLImageElement | null>(null)
const imageRootWidth = ref(0)
const imageRootHeight = ref(0)
const cursorPoint = ref<Point>({ x: 0, y: 0 })
const penPointerIdList = ref<number[]>([])
const getTouchDistance = (touches: TouchList): number => {
const dx = touches[0].clientX - touches[1].clientX
const dy = touches[0].clientY - touches[1].clientY
return Math.sqrt(dx * dx + dy * dy)
}
const getTouchMidpoint = (touches: TouchList): Point => {
return {
x: (touches[0].clientX + touches[1].clientX) / 2,
y: (touches[0].clientY + touches[1].clientY) / 2
}
}
const updateCursorPosition = (clientPoint: Point): void => {
const cursorX = clientPoint.x - pan_offset.value.x
const cursorY = clientPoint.y - pan_offset.value.y
cursorPoint.value = { x: cursorX, y: cursorY }
store.setCursorPoint({ x: cursorX, y: cursorY })
}
const handleDoubleTap = (): void => {
store.canvasHistory.undo()
}
const invalidatePanZoom = async (): Promise<void> => {
// Single validation check upfront
if (
!image.value?.width ||
!image.value?.height ||
!pan_offset.value ||
!zoom_ratio.value
) {
console.warn('Missing required properties for pan/zoom')
return
}
const raw_width = image.value.width * zoom_ratio.value
const raw_height = image.value.height * zoom_ratio.value
if (!canvasContainer.value) {
canvasContainer.value = store.canvasContainer
}
if (!canvasContainer.value) return
Object.assign(canvasContainer.value.style, {
width: `${raw_width}px`,
height: `${raw_height}px`,
left: `${pan_offset.value.x}px`,
top: `${pan_offset.value.y}px`
})
if (!rgbCanvas.value) {
rgbCanvas.value = store.rgbCanvas
}
if (rgbCanvas.value) {
if (
rgbCanvas.value.width !== image.value.width ||
rgbCanvas.value.height !== image.value.height
) {
rgbCanvas.value.width = image.value.width
rgbCanvas.value.height = image.value.height
}
rgbCanvas.value.style.width = `${raw_width}px`
rgbCanvas.value.style.height = `${raw_height}px`
}
store.setPanOffset(pan_offset.value)
store.setZoomRatio(zoom_ratio.value)
}
const handlePanStart = (event: PointerEvent): void => {
mouseDownPoint.value = { x: event.clientX, y: event.clientY }
store.isPanning = true
initialPan.value = { ...pan_offset.value }
}
const handlePanMove = async (event: PointerEvent): Promise<void> => {
if (mouseDownPoint.value === null) {
throw new Error('mouseDownPoint is null')
}
const deltaX = mouseDownPoint.value.x - event.clientX
const deltaY = mouseDownPoint.value.y - event.clientY
const pan_x = initialPan.value.x - deltaX
const pan_y = initialPan.value.y - deltaY
pan_offset.value = { x: pan_x, y: pan_y }
await invalidatePanZoom()
}
const handleSingleTouchPan = async (touch: Touch): Promise<void> => {
if (lastTouchPoint.value === null) {
lastTouchPoint.value = { x: touch.clientX, y: touch.clientY }
return
}
const deltaX = touch.clientX - lastTouchPoint.value.x
const deltaY = touch.clientY - lastTouchPoint.value.y
pan_offset.value.x += deltaX
pan_offset.value.y += deltaY
await invalidatePanZoom()
lastTouchPoint.value = { x: touch.clientX, y: touch.clientY }
}
const handleTouchStart = (event: TouchEvent): void => {
event.preventDefault()
if (penPointerIdList.value.length > 0) return
store.brushVisible = false
if (event.touches.length === 2) {
const currentTime = new Date().getTime()
const tapTimeDiff = currentTime - lastTwoFingerTap.value
if (tapTimeDiff < DOUBLE_TAP_DELAY) {
handleDoubleTap()
lastTwoFingerTap.value = 0
} else {
lastTwoFingerTap.value = currentTime
isTouchZooming.value = true
lastTouchZoomDistance.value = getTouchDistance(event.touches)
lastTouchMidPoint.value = getTouchMidpoint(event.touches)
}
} else if (event.touches.length === 1) {
lastTouchPoint.value = {
x: event.touches[0].clientX,
y: event.touches[0].clientY
}
}
}
const handleTouchMove = async (event: TouchEvent): Promise<void> => {
event.preventDefault()
if (penPointerIdList.value.length > 0) return
lastTwoFingerTap.value = 0
if (isTouchZooming.value && event.touches.length === 2) {
const newDistance = getTouchDistance(event.touches)
const zoomFactor = newDistance / lastTouchZoomDistance.value
const oldZoom = zoom_ratio.value
zoom_ratio.value = Math.max(
0.2,
Math.min(10.0, zoom_ratio.value * zoomFactor)
)
const newZoom = zoom_ratio.value
const midpoint = getTouchMidpoint(event.touches)
if (lastTouchMidPoint.value) {
const deltaX = midpoint.x - lastTouchMidPoint.value.x
const deltaY = midpoint.y - lastTouchMidPoint.value.y
pan_offset.value.x += deltaX
pan_offset.value.y += deltaY
}
if (maskCanvas.value === null) {
maskCanvas.value = store.maskCanvas
}
if (!maskCanvas.value) return
const rect = maskCanvas.value.getBoundingClientRect()
const touchX = midpoint.x - rect.left
const touchY = midpoint.y - rect.top
const scaleFactor = newZoom / oldZoom
pan_offset.value.x += touchX - touchX * scaleFactor
pan_offset.value.y += touchY - touchY * scaleFactor
await invalidatePanZoom()
lastTouchZoomDistance.value = newDistance
lastTouchMidPoint.value = midpoint
} else if (event.touches.length === 1) {
await handleSingleTouchPan(event.touches[0])
}
}
const handleTouchEnd = (event: TouchEvent): void => {
event.preventDefault()
const lastTouch = event.touches[0]
if (lastTouch) {
lastTouchPoint.value = {
x: lastTouch.clientX,
y: lastTouch.clientY
}
} else {
isTouchZooming.value = false
lastTouchMidPoint.value = { x: 0, y: 0 }
}
}
const zoom = async (event: WheelEvent): Promise<void> => {
const cursorPosition = { x: event.clientX, y: event.clientY }
const oldZoom = zoom_ratio.value
const zoomFactor = event.deltaY < 0 ? 1.1 : 0.9
zoom_ratio.value = Math.max(
0.2,
Math.min(10.0, zoom_ratio.value * zoomFactor)
)
const newZoom = zoom_ratio.value
if (!maskCanvas.value) {
maskCanvas.value = store.maskCanvas
}
if (!maskCanvas.value) return
const rect = maskCanvas.value.getBoundingClientRect()
const mouseX = cursorPosition.x - rect.left
const mouseY = cursorPosition.y - rect.top
const scaleFactor = newZoom / oldZoom
pan_offset.value.x += mouseX - mouseX * scaleFactor
pan_offset.value.y += mouseY - mouseY * scaleFactor
await invalidatePanZoom()
const newImageWidth = maskCanvas.value.clientWidth
const zoomRatio = newImageWidth / imageRootWidth.value
interpolatedZoomRatio.value = zoomRatio
store.displayZoomRatio = zoomRatio
updateCursorPosition(cursorPosition)
}
const getPanelDimensions = (): {
sidePanelWidth: number
toolPanelWidth: number
} => {
const toolPanelWidth =
toolPanelElement.value?.getBoundingClientRect().width || 64
const sidePanelWidth =
sidePanelElement.value?.getBoundingClientRect().width || 220
return { sidePanelWidth, toolPanelWidth }
}
const smoothResetView = async (duration: number = 500): Promise<void> => {
if (!image.value || !rootElement.value) return
const startZoom = zoom_ratio.value
const startPan = { ...pan_offset.value }
const { sidePanelWidth, toolPanelWidth } = getPanelDimensions()
const availableWidth =
rootElement.value.clientWidth - sidePanelWidth - toolPanelWidth
const availableHeight = rootElement.value.clientHeight
// Calculate target zoom
const zoomRatioWidth = availableWidth / image.value.width
const zoomRatioHeight = availableHeight / image.value.height
const targetZoom = Math.min(zoomRatioWidth, zoomRatioHeight)
const aspectRatio = image.value.width / image.value.height
let finalWidth: number
let finalHeight: number
const targetPan = { x: toolPanelWidth, y: 0 }
if (zoomRatioHeight > zoomRatioWidth) {
finalWidth = availableWidth
finalHeight = finalWidth / aspectRatio
targetPan.y = (availableHeight - finalHeight) / 2
} else {
finalHeight = availableHeight
finalWidth = finalHeight * aspectRatio
targetPan.x = (availableWidth - finalWidth) / 2 + toolPanelWidth
}
const startTime = performance.now()
const animate = async (currentTime: number) => {
const elapsed = currentTime - startTime
const progress = Math.min(elapsed / duration, 1)
const eased = 1 - Math.pow(1 - progress, 3)
zoom_ratio.value = startZoom + (targetZoom - startZoom) * eased
pan_offset.value.x = startPan.x + (targetPan.x - startPan.x) * eased
pan_offset.value.y = startPan.y + (targetPan.y - startPan.y) * eased
await invalidatePanZoom()
const interpolatedRatio = startZoom + (1.0 - startZoom) * eased
store.displayZoomRatio = interpolatedRatio
if (progress < 1) {
requestAnimationFrame(animate)
}
}
requestAnimationFrame(animate)
interpolatedZoomRatio.value = 1.0
}
const initializeCanvasPanZoom = async (
img: HTMLImageElement,
root: HTMLElement,
toolPanel?: HTMLElement | null,
sidePanel?: HTMLElement | null
): Promise<void> => {
rootElement.value = root
toolPanelElement.value = toolPanel || null
sidePanelElement.value = sidePanel || null
const { sidePanelWidth, toolPanelWidth } = getPanelDimensions()
const availableWidth = root.clientWidth - sidePanelWidth - toolPanelWidth
const availableHeight = root.clientHeight
const zoomRatioWidth = availableWidth / img.width
const zoomRatioHeight = availableHeight / img.height
const aspectRatio = img.width / img.height
let finalWidth: number
let finalHeight: number
const panOffset: Offset = { x: toolPanelWidth, y: 0 }
if (zoomRatioHeight > zoomRatioWidth) {
finalWidth = availableWidth
finalHeight = finalWidth / aspectRatio
panOffset.y = (availableHeight - finalHeight) / 2
} else {
finalHeight = availableHeight
finalWidth = finalHeight * aspectRatio
panOffset.x = (availableWidth - finalWidth) / 2 + toolPanelWidth
}
if (image.value === null) {
image.value = img
}
imageRootWidth.value = finalWidth
imageRootHeight.value = finalHeight
zoom_ratio.value = Math.min(zoomRatioWidth, zoomRatioHeight)
pan_offset.value = panOffset
penPointerIdList.value = []
await invalidatePanZoom()
}
watch(
() => store.resetZoomTrigger,
async () => {
if (interpolatedZoomRatio.value === 1) return
await smoothResetView()
}
)
return {
initializeCanvasPanZoom,
handlePanStart,
handlePanMove,
handleTouchStart,
handleTouchMove,
handleTouchEnd,
updateCursorPosition,
zoom,
invalidatePanZoom
}
}

View File

@@ -0,0 +1,228 @@
import { ref, watch } from 'vue'
import type {
Point,
ImageLayer,
ToolInternalSettings
} from '@/extensions/core/maskeditor/types'
import { Tools } from '@/extensions/core/maskeditor/types'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { useBrushDrawing } from './useBrushDrawing'
import { useCanvasTools } from './useCanvasTools'
import { useCoordinateTransform } from './useCoordinateTransform'
import type { useKeyboard } from './useKeyboard'
import type { usePanAndZoom } from './usePanAndZoom'
import { app } from '@/scripts/app'
export function useToolManager(
keyboard: ReturnType<typeof useKeyboard>,
panZoom: ReturnType<typeof usePanAndZoom>
) {
const store = useMaskEditorStore()
const coordinateTransform = useCoordinateTransform()
const brushDrawing = useBrushDrawing({
useDominantAxis: app.extensionManager.setting.get(
'Comfy.MaskEditor.UseDominantAxis'
),
brushAdjustmentSpeed: app.extensionManager.setting.get(
'Comfy.MaskEditor.BrushAdjustmentSpeed'
)
})
const canvasTools = useCanvasTools()
const mouseDownPoint = ref<Point | null>(null)
const toolSettings: Record<Tools, Partial<ToolInternalSettings>> = {
[Tools.MaskPen]: {
newActiveLayerOnSet: 'mask'
},
[Tools.Eraser]: {},
[Tools.PaintPen]: {
newActiveLayerOnSet: 'rgb'
},
[Tools.MaskBucket]: {
cursor: "url('/cursor/paintBucket.png') 30 25, auto",
newActiveLayerOnSet: 'mask'
},
[Tools.MaskColorFill]: {
cursor: "url('/cursor/colorSelect.png') 15 25, auto",
newActiveLayerOnSet: 'mask'
}
}
const setActiveLayer = (layer: ImageLayer) => {
store.activeLayer = layer
const currentTool = store.currentTool
const maskOnlyTools = [Tools.MaskPen, Tools.MaskBucket, Tools.MaskColorFill]
if (maskOnlyTools.includes(currentTool) && layer === 'rgb') {
switchTool(Tools.PaintPen)
}
if (currentTool === Tools.PaintPen && layer === 'mask') {
switchTool(Tools.MaskPen)
}
}
const switchTool = (tool: Tools) => {
store.currentTool = tool
const newActiveLayer = toolSettings[tool].newActiveLayerOnSet
if (newActiveLayer) {
store.activeLayer = newActiveLayer
}
const cursor = toolSettings[tool].cursor
const pointerZone = store.pointerZone
if (cursor && pointerZone) {
store.brushVisible = false
pointerZone.style.cursor = cursor
} else if (pointerZone) {
store.brushVisible = true
pointerZone.style.cursor = 'none'
}
}
const updateCursor = () => {
const currentTool = store.currentTool
const cursor = toolSettings[currentTool].cursor
const pointerZone = store.pointerZone
if (cursor && pointerZone) {
store.brushVisible = false
pointerZone.style.cursor = cursor
} else if (pointerZone) {
store.brushVisible = true
pointerZone.style.cursor = 'none'
}
store.brushPreviewGradientVisible = false
}
watch(
() => store.currentTool,
(newTool) => {
if (newTool !== Tools.MaskColorFill) {
canvasTools.clearLastColorSelectPoint()
}
}
)
const handlePointerDown = async (event: PointerEvent): Promise<void> => {
event.preventDefault()
if (event.pointerType === 'touch') return
const isSpacePressed = keyboard.isKeyDown(' ')
if (event.buttons === 4 || (event.buttons === 1 && isSpacePressed)) {
panZoom.handlePanStart(event)
store.brushVisible = false
return
}
if (store.currentTool === Tools.PaintPen && event.button === 0) {
await brushDrawing.startDrawing(event)
return
}
if (store.currentTool === Tools.PaintPen && event.buttons === 1) {
await brushDrawing.handleDrawing(event)
return
}
if (store.currentTool === Tools.MaskBucket && event.button === 0) {
const offset = { x: event.offsetX, y: event.offsetY }
const coords_canvas = coordinateTransform.screenToCanvas(offset)
canvasTools.paintBucketFill(coords_canvas)
return
}
if (store.currentTool === Tools.MaskColorFill && event.button === 0) {
const offset = { x: event.offsetX, y: event.offsetY }
const coords_canvas = coordinateTransform.screenToCanvas(offset)
await canvasTools.colorSelectFill(coords_canvas)
return
}
if (event.altKey && event.button === 2) {
store.isAdjustingBrush = true
await brushDrawing.startBrushAdjustment(event)
return
}
const isDrawingTool = [
Tools.MaskPen,
Tools.Eraser,
Tools.PaintPen
].includes(store.currentTool)
if ([0, 2].includes(event.button) && isDrawingTool) {
await brushDrawing.startDrawing(event)
return
}
}
const handlePointerMove = async (event: PointerEvent): Promise<void> => {
event.preventDefault()
if (event.pointerType === 'touch') return
const newCursorPoint = { x: event.clientX, y: event.clientY }
panZoom.updateCursorPosition(newCursorPoint)
const isSpacePressed = keyboard.isKeyDown(' ')
if (event.buttons === 4 || (event.buttons === 1 && isSpacePressed)) {
await panZoom.handlePanMove(event)
return
}
const isDrawingTool = [
Tools.MaskPen,
Tools.Eraser,
Tools.PaintPen
].includes(store.currentTool)
if (!isDrawingTool) return
if (
store.isAdjustingBrush &&
(store.currentTool === Tools.MaskPen ||
store.currentTool === Tools.Eraser) &&
event.altKey &&
event.buttons === 2
) {
await brushDrawing.handleBrushAdjustment(event)
return
}
if (event.buttons === 1 || event.buttons === 2) {
await brushDrawing.handleDrawing(event)
return
}
}
const handlePointerUp = async (event: PointerEvent): Promise<void> => {
store.isPanning = false
store.brushVisible = true
if (event.pointerType === 'touch') return
updateCursor()
store.isAdjustingBrush = false
await brushDrawing.drawEnd(event)
mouseDownPoint.value = null
}
return {
switchTool,
setActiveLayer,
updateCursor,
handlePointerDown,
handlePointerMove,
handlePointerUp,
brushDrawing
}
}

View File

@@ -1,25 +1,62 @@
import _ from 'es-toolkit/compat'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { app } from '../../scripts/app'
import { ComfyApp } from '../../scripts/app'
import { ClipspaceDialog } from './clipspace'
import { MaskEditorDialog } from './maskeditor/MaskEditorDialog'
import { app } from '@/scripts/app'
import { ComfyApp } from '@/scripts/app'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { useDialogStore } from '@/stores/dialogStore'
import MaskEditorContent from '@/components/maskeditor/MaskEditorContent.vue'
import TopBarHeader from '@/components/maskeditor/dialog/TopBarHeader.vue'
import { MaskEditorDialogOld } from './maskEditorOld'
import { ClipspaceDialog } from './clipspace'
// Import styles to inject into document
import './maskeditor/styles'
function openMaskEditor(node: LGraphNode): void {
if (!node) {
console.error('[MaskEditor] No node provided')
return
}
if (!node.imgs?.length && node.previewMediaType !== 'image') {
console.error('[MaskEditor] Node has no images')
return
}
// Function to open the mask editor
function openMaskEditor(): void {
const useNewEditor = app.extensionManager.setting.get(
'Comfy.MaskEditor.UseNewEditor'
)
if (useNewEditor) {
const dlg = MaskEditorDialog.getInstance() as any
if (dlg?.isOpened && !dlg.isOpened()) {
dlg.show()
}
// Use new refactored editor
useDialogStore().showDialog({
key: 'global-mask-editor',
headerComponent: TopBarHeader,
component: MaskEditorContent,
props: {
node
},
dialogComponentProps: {
style: 'width: 90vw; height: 90vh;',
modal: true,
maximizable: true,
closable: true,
pt: {
root: {
class: 'mask-editor-dialog flex flex-col'
},
content: {
class: 'flex flex-col min-h-0 flex-1 !p-0'
},
header: {
class: '!p-2'
}
}
}
})
} else {
// Use old editor
ComfyApp.copyToClipspace(node)
// @ts-expect-error clipspace_return_node is an extension property added at runtime
ComfyApp.clipspace_return_node = node
const dlg = MaskEditorDialogOld.getInstance() as any
if (dlg?.isOpened && !dlg.isOpened()) {
dlg.show()
@@ -33,21 +70,12 @@ function isOpened(): boolean {
'Comfy.MaskEditor.UseNewEditor'
)
if (useNewEditor) {
return MaskEditorDialog.instance?.isOpened?.() ?? false
return useDialogStore().isDialogOpen('global-mask-editor')
} else {
return (MaskEditorDialogOld.instance as any)?.isOpened?.() ?? false
}
}
// Ensure boolean return type for context predicate
const context_predicate = (): boolean => {
return !!(
ComfyApp.clipspace &&
ComfyApp.clipspace.imgs &&
ComfyApp.clipspace.imgs.length > 0
)
}
app.registerExtension({
name: 'Comfy.MaskEditor',
settings: [
@@ -97,15 +125,7 @@ app.registerExtension({
if (!selectedNodes || Object.keys(selectedNodes).length !== 1) return
const selectedNode = selectedNodes[Object.keys(selectedNodes)[0]]
if (
!selectedNode.imgs?.length &&
selectedNode.previewMediaType !== 'image'
)
return
ComfyApp.copyToClipspace(selectedNode)
// @ts-expect-error clipspace_return_node is an extension property added at runtime
ComfyApp.clipspace_return_node = selectedNode
openMaskEditor()
openMaskEditor(selectedNode)
}
},
{
@@ -122,24 +142,40 @@ app.registerExtension({
}
],
init() {
ComfyApp.open_maskeditor = openMaskEditor
ComfyApp.maskeditor_is_opended = isOpened
// Support for old editor clipspace integration
const openMaskEditorFromClipspace = () => {
const useNewEditor = app.extensionManager.setting.get(
'Comfy.MaskEditor.UseNewEditor'
)
if (!useNewEditor) {
const dlg = MaskEditorDialogOld.getInstance() as any
if (dlg?.isOpened && !dlg.isOpened()) {
dlg.show()
}
}
}
const context_predicate = (): boolean => {
return !!(
ComfyApp.clipspace &&
ComfyApp.clipspace.imgs &&
ComfyApp.clipspace.imgs.length > 0
)
}
ClipspaceDialog.registerButton(
'MaskEditor',
context_predicate,
openMaskEditor
openMaskEditorFromClipspace
)
}
})
const changeBrushSize = async (sizeChanger: (oldSize: number) => number) => {
if (!isOpened()) return
const maskEditor = MaskEditorDialog.getInstance()
if (!maskEditor) return
const messageBroker = maskEditor.getMessageBroker()
const oldBrushSize = (await messageBroker.pull('brushSettings')).size
const store = useMaskEditorStore()
const oldBrushSize = store.brushSettings.size
const newBrushSize = sizeChanger(oldBrushSize)
messageBroker.publish('setBrushSize', newBrushSize)
messageBroker.publish('updateBrushPreview')
store.setBrushSize(newBrushSize)
}

View File

@@ -1,131 +0,0 @@
import type { MaskEditorDialog } from './MaskEditorDialog'
import type { MessageBroker } from './managers/MessageBroker'
export class CanvasHistory {
// @ts-expect-error unused variable
private maskEditor!: MaskEditorDialog
private messageBroker!: MessageBroker
private canvas!: HTMLCanvasElement
private ctx!: CanvasRenderingContext2D
private rgbCanvas!: HTMLCanvasElement
private rgbCtx!: CanvasRenderingContext2D
private states: { mask: ImageData; rgb: ImageData }[] = []
private currentStateIndex: number = -1
private maxStates: number = 20
private initialized: boolean = false
constructor(maskEditor: MaskEditorDialog, maxStates = 20) {
this.maskEditor = maskEditor
this.messageBroker = maskEditor.getMessageBroker()
this.maxStates = maxStates
this.createListeners()
}
private async pullCanvas() {
this.canvas = await this.messageBroker.pull('maskCanvas')
this.ctx = await this.messageBroker.pull('maskCtx')
this.rgbCanvas = await this.messageBroker.pull('rgbCanvas')
this.rgbCtx = await this.messageBroker.pull('rgbCtx')
}
private createListeners() {
this.messageBroker.subscribe('saveState', () => this.saveState())
this.messageBroker.subscribe('undo', () => this.undo())
this.messageBroker.subscribe('redo', () => this.redo())
}
clearStates() {
this.states = []
this.currentStateIndex = -1
this.initialized = false
}
async saveInitialState() {
await this.pullCanvas()
if (
!this.canvas.width ||
!this.canvas.height ||
!this.rgbCanvas.width ||
!this.rgbCanvas.height
) {
// Canvas not ready yet, defer initialization
requestAnimationFrame(() => this.saveInitialState())
return
}
this.clearStates()
const maskState = this.ctx.getImageData(
0,
0,
this.canvas.width,
this.canvas.height
)
const rgbState = this.rgbCtx.getImageData(
0,
0,
this.rgbCanvas.width,
this.rgbCanvas.height
)
this.states.push({ mask: maskState, rgb: rgbState })
this.currentStateIndex = 0
this.initialized = true
}
saveState() {
// Ensure we have an initial state
if (!this.initialized || this.currentStateIndex === -1) {
this.saveInitialState()
return
}
this.states = this.states.slice(0, this.currentStateIndex + 1)
const maskState = this.ctx.getImageData(
0,
0,
this.canvas.width,
this.canvas.height
)
const rgbState = this.rgbCtx.getImageData(
0,
0,
this.rgbCanvas.width,
this.rgbCanvas.height
)
this.states.push({ mask: maskState, rgb: rgbState })
this.currentStateIndex++
if (this.states.length > this.maxStates) {
this.states.shift()
this.currentStateIndex--
}
}
undo() {
if (this.states.length > 1 && this.currentStateIndex > 0) {
this.currentStateIndex--
this.restoreState(this.states[this.currentStateIndex])
} else {
alert('No more undo states available')
}
}
redo() {
if (
this.states.length > 1 &&
this.currentStateIndex < this.states.length - 1
) {
this.currentStateIndex++
this.restoreState(this.states[this.currentStateIndex])
} else {
alert('No more redo states available')
}
}
restoreState(state: { mask: ImageData; rgb: ImageData }) {
if (state && this.initialized) {
this.ctx.putImageData(state.mask, 0, 0)
this.rgbCtx.putImageData(state.rgb, 0, 0)
}
}
}

View File

@@ -1,404 +0,0 @@
import { t } from '@/i18n'
import { api } from '../../../scripts/api'
import { ComfyApp } from '../../../scripts/app'
import { $el, ComfyDialog } from '../../../scripts/ui'
import { ClipspaceDialog } from '../clipspace'
import { imageLayerFilenamesByTimestamp } from './utils/maskEditorLayerFilenames'
import { CanvasHistory } from './CanvasHistory'
import { CompositionOperation } from './types'
import type { Ref } from './types'
import {
UIManager,
ToolManager,
PanAndZoomManager,
KeyboardManager,
MessageBroker
} from './managers'
import { BrushTool, PaintBucketTool, ColorSelectTool } from './tools'
import {
ensureImageFullyLoaded,
removeImageRgbValuesAndInvertAlpha,
createCanvasCopy,
getCanvas2dContext,
combineOriginalImageAndPaint,
toRef,
mkFileUrl,
requestWithRetries,
replaceClipspaceImages
} from './utils'
export class MaskEditorDialog extends ComfyDialog {
static instance: MaskEditorDialog | null = null
//new
private uiManager!: UIManager
// @ts-expect-error unused variable
private toolManager!: ToolManager
// @ts-expect-error unused variable
private panAndZoomManager!: PanAndZoomManager
// @ts-expect-error unused variable
private brushTool!: BrushTool
private paintBucketTool!: PaintBucketTool
private colorSelectTool!: ColorSelectTool
private canvasHistory!: CanvasHistory
private messageBroker!: MessageBroker
private keyboardManager!: KeyboardManager
private rootElement!: HTMLElement
private imageURL!: string
private isLayoutCreated: boolean = false
private isOpen: boolean = false
//variables needed?
last_display_style: string | null = null
constructor() {
super()
this.rootElement = $el(
'div.maskEditor_hidden',
{ parent: document.body },
[]
)
this.element = this.rootElement
}
static getInstance() {
if (!ComfyApp.clipspace || !ComfyApp.clipspace.imgs) {
throw new Error('No clipspace images found')
}
const currentSrc =
ComfyApp.clipspace.imgs[ComfyApp.clipspace['selectedIndex']].src
if (
!MaskEditorDialog.instance ||
currentSrc !== MaskEditorDialog.instance.imageURL
) {
if (MaskEditorDialog.instance) MaskEditorDialog.instance.destroy()
MaskEditorDialog.instance = new MaskEditorDialog()
MaskEditorDialog.instance.imageURL = currentSrc
}
return MaskEditorDialog.instance
}
override async show() {
this.cleanup()
if (!this.isLayoutCreated) {
// layout
this.messageBroker = new MessageBroker()
this.canvasHistory = new CanvasHistory(this, 20)
this.paintBucketTool = new PaintBucketTool(this)
this.brushTool = new BrushTool(this)
this.panAndZoomManager = new PanAndZoomManager(this)
this.toolManager = new ToolManager(this)
this.keyboardManager = new KeyboardManager(this)
this.uiManager = new UIManager(this.rootElement, this)
this.colorSelectTool = new ColorSelectTool(this)
// replacement of onClose hook since close is not real close
const self = this
const observer = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
if (
mutation.type === 'attributes' &&
mutation.attributeName === 'style'
) {
if (
self.last_display_style &&
self.last_display_style != 'none' &&
self.element.style.display == 'none'
) {
//self.brush.style.display = 'none'
ComfyApp.onClipspaceEditorClosed()
}
self.last_display_style = self.element.style.display
}
})
})
const config = { attributes: true }
observer.observe(this.rootElement, config)
this.isLayoutCreated = true
await this.uiManager.setlayout()
}
//this.zoomAndPanManager.reset()
this.rootElement.id = 'maskEditor'
this.rootElement.style.display = 'flex'
this.element.style.display = 'flex'
await this.uiManager.initUI()
this.paintBucketTool.initPaintBucketTool()
this.colorSelectTool.initColorSelectTool()
await this.canvasHistory.saveInitialState()
this.isOpen = true
if (ComfyApp.clipspace && ComfyApp.clipspace.imgs) {
this.uiManager.setSidebarImage()
}
this.keyboardManager.addListeners()
}
private cleanup() {
// Remove all maskEditor elements
const maskEditors = document.querySelectorAll('[id^="maskEditor"]')
maskEditors.forEach((element) => element.remove())
// Remove brush elements specifically
const brushElements = document.querySelectorAll('#maskEditor_brush')
brushElements.forEach((element) => element.remove())
}
destroy() {
this.isLayoutCreated = false
this.isOpen = false
this.canvasHistory.clearStates()
this.keyboardManager.removeListeners()
this.cleanup()
this.close()
MaskEditorDialog.instance = null
}
isOpened() {
return this.isOpen
}
async save() {
const imageCanvas = this.uiManager.getImgCanvas()
const maskCanvas = this.uiManager.getMaskCanvas()
const maskCanvasCtx = getCanvas2dContext(maskCanvas)
const paintCanvas = this.uiManager.getRgbCanvas()
const image = this.uiManager.getImage()
try {
await ensureImageFullyLoaded(maskCanvas.toDataURL())
} catch (error) {
console.error('Error loading mask image:', error)
return
}
const unrefinedMaskImageData = maskCanvasCtx.getImageData(
0,
0,
maskCanvas.width,
maskCanvas.height
)
const refinedMaskOnlyData = new ImageData(
removeImageRgbValuesAndInvertAlpha(unrefinedMaskImageData.data),
unrefinedMaskImageData.width,
unrefinedMaskImageData.height
)
// We create an undisplayed copy so as not to alter the original--displayed--canvas
const [refinedMaskCanvas, refinedMaskCanvasCtx] =
createCanvasCopy(maskCanvas)
refinedMaskCanvasCtx.globalCompositeOperation =
CompositionOperation.SourceOver
refinedMaskCanvasCtx.putImageData(refinedMaskOnlyData, 0, 0)
const timestamp = Math.round(performance.now())
const filenames = imageLayerFilenamesByTimestamp(timestamp)
const refs = {
maskedImage: toRef(filenames.maskedImage),
paint: toRef(filenames.paint),
paintedImage: toRef(filenames.paintedImage),
paintedMaskedImage: toRef(filenames.paintedMaskedImage)
}
const [paintedImageCanvas] = combineOriginalImageAndPaint({
originalImage: imageCanvas,
paint: paintCanvas
})
replaceClipspaceImages(refs.paintedMaskedImage, [refs.paint])
const originalImageUrl = new URL(image.src)
this.uiManager.setBrushOpacity(0)
const originalImageFilename = originalImageUrl.searchParams.get('filename')
if (!originalImageFilename)
throw new Error(
"Expected original image URL to have a `filename` query parameter, but couldn't find it."
)
const originalImageRef: Partial<Ref> = {
filename: originalImageFilename,
subfolder: originalImageUrl.searchParams.get('subfolder') ?? undefined,
type: originalImageUrl.searchParams.get('type') ?? undefined
}
const mkFormData = (
blob: Blob,
filename: string,
originalImageRefOverride?: Partial<Ref>
) => {
const formData = new FormData()
formData.append('image', blob, filename)
formData.append(
'original_ref',
JSON.stringify(originalImageRefOverride ?? originalImageRef)
)
formData.append('type', 'input')
formData.append('subfolder', 'clipspace')
return formData
}
const canvasToFormData = (
canvas: HTMLCanvasElement,
filename: string,
originalImageRefOverride?: Partial<Ref>
) => {
const blob = this.dataURLToBlob(canvas.toDataURL())
return mkFormData(blob, filename, originalImageRefOverride)
}
const formDatas = {
// Note: this canvas only contains mask data (no image), but during the upload process, the backend combines the mask with the original_image. Refer to the backend repo's `server.py`, search for `@routes.post("/upload/mask")`
maskedImage: canvasToFormData(refinedMaskCanvas, filenames.maskedImage),
paint: canvasToFormData(paintCanvas, filenames.paint),
paintedImage: canvasToFormData(
paintedImageCanvas,
filenames.paintedImage
),
paintedMaskedImage: canvasToFormData(
refinedMaskCanvas,
filenames.paintedMaskedImage,
refs.paintedImage
)
}
this.uiManager.setSaveButtonText(t('g.saving'))
this.uiManager.setSaveButtonEnabled(false)
this.keyboardManager.removeListeners()
try {
await this.uploadMask(
refs.maskedImage,
formDatas.maskedImage,
'selectedIndex'
)
await this.uploadImage(refs.paint, formDatas.paint)
await this.uploadImage(refs.paintedImage, formDatas.paintedImage, false)
// IMPORTANT: We using `uploadMask` here, because the backend combines the mask with the painted image during the upload process. We do NOT want to combine the mask with the original image on the frontend, because the spec for CanvasRenderingContext2D does not allow for setting pixels to transparent while preserving their RGB values.
// See: <https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/putImageData#data_loss_due_to_browser_optimization>
// It is possible that WebGL contexts can achieve this, but WebGL is extremely complex, and the backend functionality is here for this purpose!
// Refer to the backend repo's `server.py`, search for `@routes.post("/upload/mask")`
await this.uploadMask(
refs.paintedMaskedImage,
formDatas.paintedMaskedImage,
'combinedIndex'
)
ComfyApp.onClipspaceEditorSave()
this.destroy()
} catch (error) {
console.error('Error during upload:', error)
this.uiManager.setSaveButtonText(t('g.save'))
this.uiManager.setSaveButtonEnabled(true)
this.keyboardManager.addListeners()
}
}
getMessageBroker() {
return this.messageBroker
}
// Helper function to convert a data URL to a Blob object
private dataURLToBlob(dataURL: string) {
const parts = dataURL.split(';base64,')
const contentType = parts[0].split(':')[1]
const byteString = atob(parts[1])
const arrayBuffer = new ArrayBuffer(byteString.length)
const uint8Array = new Uint8Array(arrayBuffer)
for (let i = 0; i < byteString.length; i++) {
uint8Array[i] = byteString.charCodeAt(i)
}
return new Blob([arrayBuffer], { type: contentType })
}
private async uploadImage(
filepath: Ref,
formData: FormData,
isPaintLayer = true
) {
const success = await requestWithRetries(() =>
api.fetchApi('/upload/image', {
method: 'POST',
body: formData
})
)
if (!success) {
throw new Error('Upload failed.')
}
if (!isPaintLayer) {
ClipspaceDialog.invalidatePreview()
return success
}
try {
const paintedIndex = ComfyApp.clipspace?.paintedIndex
if (ComfyApp.clipspace?.imgs && paintedIndex !== undefined) {
// Create and set new image
const newImage = new Image()
newImage.crossOrigin = 'anonymous'
newImage.src = mkFileUrl({ ref: filepath, preview: true })
ComfyApp.clipspace.imgs[paintedIndex] = newImage
// Update images array if it exists
if (ComfyApp.clipspace.images) {
ComfyApp.clipspace.images[paintedIndex] = filepath
}
}
} catch (err) {
console.warn('Failed to update clipspace image:', err)
}
ClipspaceDialog.invalidatePreview()
}
private async uploadMask(
filepath: Ref,
formData: FormData,
clipspaceLocation: 'selectedIndex' | 'combinedIndex'
) {
const success = await requestWithRetries(() =>
api.fetchApi('/upload/mask', {
method: 'POST',
body: formData
})
)
if (!success) {
throw new Error('Upload failed.')
}
try {
const nameOfIndexToSaveTo = (
{
selectedIndex: 'selectedIndex',
combinedIndex: 'combinedIndex'
} as const
)[clipspaceLocation]
if (!nameOfIndexToSaveTo) return
const indexToSaveTo = ComfyApp.clipspace?.[nameOfIndexToSaveTo]
if (!ComfyApp.clipspace?.imgs || indexToSaveTo === undefined) return
// Create and set new image
const newImage = new Image()
newImage.crossOrigin = 'anonymous'
newImage.src = mkFileUrl({ ref: filepath, preview: true })
ComfyApp.clipspace.imgs[indexToSaveTo] = newImage
// Update images array if it exists
if (ComfyApp.clipspace.images) {
ComfyApp.clipspace.images[indexToSaveTo] = filepath
}
} catch (err) {
console.warn('Failed to update clipspace image:', err)
}
ClipspaceDialog.invalidatePreview()
}
}

View File

@@ -1,67 +0,0 @@
import type { MaskEditorDialog } from '../MaskEditorDialog'
import type { MessageBroker } from './MessageBroker'
export class KeyboardManager {
private keysDown: string[] = []
// @ts-expect-error unused variable
private maskEditor: MaskEditorDialog
private messageBroker: MessageBroker
// Bound functions, for use in addListeners and removeListeners
private handleKeyDownBound = this.handleKeyDown.bind(this)
private handleKeyUpBound = this.handleKeyUp.bind(this)
private clearKeysBound = this.clearKeys.bind(this)
constructor(maskEditor: MaskEditorDialog) {
this.maskEditor = maskEditor
this.messageBroker = maskEditor.getMessageBroker()
this.addPullTopics()
}
private addPullTopics() {
// isKeyPressed
this.messageBroker.createPullTopic('isKeyPressed', (key: string) =>
Promise.resolve(this.isKeyDown(key))
)
}
addListeners() {
document.addEventListener('keydown', this.handleKeyDownBound)
document.addEventListener('keyup', this.handleKeyUpBound)
window.addEventListener('blur', this.clearKeysBound)
}
removeListeners() {
document.removeEventListener('keydown', this.handleKeyDownBound)
document.removeEventListener('keyup', this.handleKeyUpBound)
window.removeEventListener('blur', this.clearKeysBound)
}
private clearKeys() {
this.keysDown = []
}
private handleKeyDown(event: KeyboardEvent) {
if (!this.keysDown.includes(event.key)) {
this.keysDown.push(event.key)
}
if ((event.ctrlKey || event.metaKey) && !event.altKey) {
const key = event.key.toUpperCase()
// Redo: Ctrl + Y, or Ctrl + Shift + Z
if ((key === 'Y' && !event.shiftKey) || (key == 'Z' && event.shiftKey)) {
this.messageBroker.publish('redo')
} else if (key === 'Z' && !event.shiftKey) {
this.messageBroker.publish('undo')
}
}
}
private handleKeyUp(event: KeyboardEvent) {
this.keysDown = this.keysDown.filter((key) => key !== event.key)
}
private isKeyDown(key: string) {
return this.keysDown.includes(key)
}
}

View File

@@ -1,183 +0,0 @@
import type { Callback } from '../types'
export class MessageBroker {
private pushTopics: Record<string, Callback[]> = {}
private pullTopics: Record<string, (data?: any) => Promise<any>> = {}
constructor() {
this.registerListeners()
}
// Push
private registerListeners() {
// Register listeners
this.createPushTopic('panStart')
this.createPushTopic('paintBucketFill')
this.createPushTopic('saveState')
this.createPushTopic('brushAdjustmentStart')
this.createPushTopic('drawStart')
this.createPushTopic('panMove')
this.createPushTopic('updateBrushPreview')
this.createPushTopic('brushAdjustment')
this.createPushTopic('draw')
this.createPushTopic('paintBucketCursor')
this.createPushTopic('panCursor')
this.createPushTopic('drawEnd')
this.createPushTopic('zoom')
this.createPushTopic('undo')
this.createPushTopic('redo')
this.createPushTopic('cursorPoint')
this.createPushTopic('panOffset')
this.createPushTopic('zoomRatio')
this.createPushTopic('getMaskCanvas')
this.createPushTopic('getCanvasContainer')
this.createPushTopic('screenToCanvas')
this.createPushTopic('isKeyPressed')
this.createPushTopic('isCombinationPressed')
this.createPushTopic('setPaintBucketTolerance')
this.createPushTopic('setBrushSize')
this.createPushTopic('setBrushHardness')
this.createPushTopic('setBrushOpacity')
this.createPushTopic('setBrushShape')
this.createPushTopic('initZoomPan')
this.createPushTopic('setTool')
this.createPushTopic('setActiveLayer')
this.createPushTopic('pointerDown')
this.createPushTopic('pointerMove')
this.createPushTopic('pointerUp')
this.createPushTopic('wheel')
this.createPushTopic('initPaintBucketTool')
this.createPushTopic('setBrushVisibility')
this.createPushTopic('setBrushPreviewGradientVisibility')
this.createPushTopic('handleTouchStart')
this.createPushTopic('handleTouchMove')
this.createPushTopic('handleTouchEnd')
this.createPushTopic('colorSelectFill')
this.createPushTopic('setColorSelectTolerance')
this.createPushTopic('setLivePreview')
this.createPushTopic('updateCursor')
this.createPushTopic('setColorComparisonMethod')
this.createPushTopic('clearLastPoint')
this.createPushTopic('setWholeImage')
this.createPushTopic('setMaskBoundary')
this.createPushTopic('setMaskTolerance')
this.createPushTopic('setBrushSmoothingPrecision')
this.createPushTopic('setZoomText')
this.createPushTopic('resetZoom')
this.createPushTopic('invert')
this.createPushTopic('setRGBColor')
this.createPushTopic('paintedurl')
this.createPushTopic('setSelectionOpacity')
this.createPushTopic('setFillOpacity')
}
/**
* Creates a new push topic (listener is notified)
*
* @param {string} topicName - The name of the topic to create.
* @throws {Error} If the topic already exists.
*/
createPushTopic(topicName: string) {
if (this.topicExists(this.pushTopics, topicName)) {
throw new Error('Topic already exists')
}
this.pushTopics[topicName] = []
}
/**
* Subscribe a callback function to the given topic.
*
* @param {string} topicName - The name of the topic to subscribe to.
* @param {Callback} callback - The callback function to be subscribed.
* @throws {Error} If the topic does not exist.
*/
subscribe(topicName: string, callback: Callback) {
if (!this.topicExists(this.pushTopics, topicName)) {
throw new Error(`Topic "${topicName}" does not exist!`)
}
this.pushTopics[topicName].push(callback)
}
/**
* Removes a callback function from the list of subscribers for a given topic.
*
* @param {string} topicName - The name of the topic to unsubscribe from.
* @param {Callback} callback - The callback function to remove from the subscribers list.
* @throws {Error} If the topic does not exist in the list of topics.
*/
unsubscribe(topicName: string, callback: Callback) {
if (!this.topicExists(this.pushTopics, topicName)) {
throw new Error('Topic does not exist')
}
const index = this.pushTopics[topicName].indexOf(callback)
if (index > -1) {
this.pushTopics[topicName].splice(index, 1)
}
}
/**
* Publishes data to a specified topic with variable number of arguments.
* @param {string} topicName - The name of the topic to publish to.
* @param {...any[]} args - Variable number of arguments to pass to subscribers
* @throws {Error} If the specified topic does not exist.
*/
publish(topicName: string, ...args: any[]) {
if (!this.topicExists(this.pushTopics, topicName)) {
throw new Error(`Topic "${topicName}" does not exist!`)
}
this.pushTopics[topicName].forEach((callback) => {
callback(...args)
})
}
// Pull
/**
* Creates a new pull topic (listener must request data)
*
* @param {string} topicName - The name of the topic to create.
* @param {() => Promise<any>} callBack - The callback function to be called when data is requested.
* @throws {Error} If the topic already exists.
*/
createPullTopic(topicName: string, callBack: (data?: any) => Promise<any>) {
if (this.topicExists(this.pullTopics, topicName)) {
throw new Error('Topic already exists')
}
this.pullTopics[topicName] = callBack
}
/**
* Requests data from a specified pull topic.
* @param {string} topicName - The name of the topic to request data from.
* @returns {Promise<any>} - The data from the pull topic.
* @throws {Error} If the specified topic does not exist.
*/
async pull(topicName: string, data?: any): Promise<any> {
if (!this.topicExists(this.pullTopics, topicName)) {
throw new Error('Topic does not exist')
}
const callBack = this.pullTopics[topicName]
try {
const result = await callBack(data)
return result
} catch (error) {
console.error(`Error pulling data from topic "${topicName}":`, error)
throw error
}
}
// Helper Methods
/**
* Checks if a topic exists in the given topics object.
* @param {Record<string, any>} topics - The topics object to check.
* @param {string} topicName - The name of the topic to check.
* @returns {boolean} - True if the topic exists, false otherwise.
*/
private topicExists(topics: Record<string, any>, topicName: string): boolean {
return topics.hasOwnProperty(topicName)
}
}

View File

@@ -1,496 +0,0 @@
import type { MaskEditorDialog, Point, Offset } from '../types'
import { MessageBroker } from './MessageBroker'
export class PanAndZoomManager {
maskEditor: MaskEditorDialog
messageBroker: MessageBroker
DOUBLE_TAP_DELAY: number = 300
lastTwoFingerTap: number = 0
isTouchZooming: boolean = false
lastTouchZoomDistance: number = 0
lastTouchMidPoint: Point = { x: 0, y: 0 }
lastTouchPoint: Point = { x: 0, y: 0 }
zoom_ratio: number = 1
interpolatedZoomRatio: number = 1
pan_offset: Offset = { x: 0, y: 0 }
mouseDownPoint: Point | null = null
initialPan: Offset = { x: 0, y: 0 }
canvasContainer: HTMLElement | null = null
maskCanvas: HTMLCanvasElement | null = null
rgbCanvas: HTMLCanvasElement | null = null
rootElement: HTMLElement | null = null
image: HTMLImageElement | null = null
imageRootWidth: number = 0
imageRootHeight: number = 0
cursorPoint: Point = { x: 0, y: 0 }
penPointerIdList: number[] = []
constructor(maskEditor: MaskEditorDialog) {
this.maskEditor = maskEditor
this.messageBroker = maskEditor.getMessageBroker()
this.addListeners()
this.addPullTopics()
}
private addListeners() {
this.messageBroker.subscribe(
'initZoomPan',
async (args: [HTMLImageElement, HTMLElement]) => {
await this.initializeCanvasPanZoom(args[0], args[1])
}
)
this.messageBroker.subscribe('panStart', async (event: PointerEvent) => {
this.handlePanStart(event)
})
this.messageBroker.subscribe('panMove', async (event: PointerEvent) => {
this.handlePanMove(event)
})
this.messageBroker.subscribe('zoom', async (event: WheelEvent) => {
this.zoom(event)
})
this.messageBroker.subscribe('cursorPoint', async (point: Point) => {
this.updateCursorPosition(point)
})
this.messageBroker.subscribe('pointerDown', async (event: PointerEvent) => {
if (event.pointerType === 'pen')
this.penPointerIdList.push(event.pointerId)
})
this.messageBroker.subscribe('pointerUp', async (event: PointerEvent) => {
if (event.pointerType === 'pen') {
const index = this.penPointerIdList.indexOf(event.pointerId)
if (index > -1) this.penPointerIdList.splice(index, 1)
}
})
this.messageBroker.subscribe(
'handleTouchStart',
async (event: TouchEvent) => {
this.handleTouchStart(event)
}
)
this.messageBroker.subscribe(
'handleTouchMove',
async (event: TouchEvent) => {
this.handleTouchMove(event)
}
)
this.messageBroker.subscribe(
'handleTouchEnd',
async (event: TouchEvent) => {
this.handleTouchEnd(event)
}
)
this.messageBroker.subscribe('resetZoom', async () => {
if (this.interpolatedZoomRatio === 1) return
await this.smoothResetView()
})
}
private addPullTopics() {
this.messageBroker.createPullTopic(
'cursorPoint',
async () => this.cursorPoint
)
this.messageBroker.createPullTopic('zoomRatio', async () => this.zoom_ratio)
this.messageBroker.createPullTopic('panOffset', async () => this.pan_offset)
}
handleTouchStart(event: TouchEvent) {
event.preventDefault()
// for pen device, if drawing with pen, do not move the canvas
if (this.penPointerIdList.length > 0) return
this.messageBroker.publish('setBrushVisibility', false)
if (event.touches.length === 2) {
const currentTime = new Date().getTime()
const tapTimeDiff = currentTime - this.lastTwoFingerTap
if (tapTimeDiff < this.DOUBLE_TAP_DELAY) {
// Double tap detected
this.handleDoubleTap()
this.lastTwoFingerTap = 0 // Reset to prevent triple-tap
} else {
this.lastTwoFingerTap = currentTime
// Existing two-finger touch logic
this.isTouchZooming = true
this.lastTouchZoomDistance = this.getTouchDistance(event.touches)
const midpoint = this.getTouchMidpoint(event.touches)
this.lastTouchMidPoint = midpoint
}
} else if (event.touches.length === 1) {
this.lastTouchPoint = {
x: event.touches[0].clientX,
y: event.touches[0].clientY
}
}
}
async handleTouchMove(event: TouchEvent) {
event.preventDefault()
// for pen device, if drawing with pen, do not move the canvas
if (this.penPointerIdList.length > 0) return
this.lastTwoFingerTap = 0
if (this.isTouchZooming && event.touches.length === 2) {
// Handle zooming
const newDistance = this.getTouchDistance(event.touches)
const zoomFactor = newDistance / this.lastTouchZoomDistance
const oldZoom = this.zoom_ratio
this.zoom_ratio = Math.max(
0.2,
Math.min(10.0, this.zoom_ratio * zoomFactor)
)
const newZoom = this.zoom_ratio
// Calculate the midpoint of the two touches
const midpoint = this.getTouchMidpoint(event.touches)
// Handle panning - calculate the movement of the midpoint
if (this.lastTouchMidPoint) {
const deltaX = midpoint.x - this.lastTouchMidPoint.x
const deltaY = midpoint.y - this.lastTouchMidPoint.y
// Apply the pan
this.pan_offset.x += deltaX
this.pan_offset.y += deltaY
}
// Get touch position relative to the container
if (this.maskCanvas === null) {
this.maskCanvas = await this.messageBroker.pull('maskCanvas')
}
const rect = this.maskCanvas!.getBoundingClientRect()
const touchX = midpoint.x - rect.left
const touchY = midpoint.y - rect.top
// Calculate new pan position based on zoom
const scaleFactor = newZoom / oldZoom
this.pan_offset.x += touchX - touchX * scaleFactor
this.pan_offset.y += touchY - touchY * scaleFactor
this.invalidatePanZoom()
this.lastTouchZoomDistance = newDistance
this.lastTouchMidPoint = midpoint
} else if (event.touches.length === 1) {
// Handle single touch pan
this.handleSingleTouchPan(event.touches[0])
}
}
handleTouchEnd(event: TouchEvent) {
event.preventDefault()
const lastTouch = event.touches[0]
// if all touches are removed, lastTouch will be null
if (lastTouch) {
this.lastTouchPoint = {
x: lastTouch.clientX,
y: lastTouch.clientY
}
} else {
this.isTouchZooming = false
this.lastTouchMidPoint = { x: 0, y: 0 }
}
}
private getTouchDistance(touches: TouchList) {
const dx = touches[0].clientX - touches[1].clientX
const dy = touches[0].clientY - touches[1].clientY
return Math.sqrt(dx * dx + dy * dy)
}
private getTouchMidpoint(touches: TouchList) {
return {
x: (touches[0].clientX + touches[1].clientX) / 2,
y: (touches[0].clientY + touches[1].clientY) / 2
}
}
private async handleSingleTouchPan(touch: Touch) {
if (this.lastTouchPoint === null) {
this.lastTouchPoint = { x: touch.clientX, y: touch.clientY }
return
}
const deltaX = touch.clientX - this.lastTouchPoint.x
const deltaY = touch.clientY - this.lastTouchPoint.y
this.pan_offset.x += deltaX
this.pan_offset.y += deltaY
await this.invalidatePanZoom()
this.lastTouchPoint = { x: touch.clientX, y: touch.clientY }
}
private updateCursorPosition(clientPoint: Point) {
var cursorX = clientPoint.x - this.pan_offset.x
var cursorY = clientPoint.y - this.pan_offset.y
this.cursorPoint = { x: cursorX, y: cursorY }
}
//prob redundant
handleDoubleTap() {
this.messageBroker.publish('undo')
// Add any additional logic needed after undo
}
async zoom(event: WheelEvent) {
// Store original cursor position
const cursorPoint = { x: event.clientX, y: event.clientY }
// zoom canvas
const oldZoom = this.zoom_ratio
const zoomFactor = event.deltaY < 0 ? 1.1 : 0.9
this.zoom_ratio = Math.max(
0.2,
Math.min(10.0, this.zoom_ratio * zoomFactor)
)
const newZoom = this.zoom_ratio
const maskCanvas = await this.messageBroker.pull('maskCanvas')
// Get mouse position relative to the container
const rect = maskCanvas.getBoundingClientRect()
const mouseX = cursorPoint.x - rect.left
const mouseY = cursorPoint.y - rect.top
console.log(oldZoom, newZoom)
// Calculate new pan position
const scaleFactor = newZoom / oldZoom
this.pan_offset.x += mouseX - mouseX * scaleFactor
this.pan_offset.y += mouseY - mouseY * scaleFactor
// Update pan and zoom immediately
await this.invalidatePanZoom()
const newImageWidth = maskCanvas.clientWidth
const zoomRatio = newImageWidth / this.imageRootWidth
this.interpolatedZoomRatio = zoomRatio
this.messageBroker.publish('setZoomText', `${Math.round(zoomRatio * 100)}%`)
// Update cursor position with new pan values
this.updateCursorPosition(cursorPoint)
// Update brush preview after pan/zoom is complete
requestAnimationFrame(() => {
this.messageBroker.publish('updateBrushPreview')
})
}
private async smoothResetView(duration: number = 500) {
// Store initial state
const startZoom = this.zoom_ratio
const startPan = { ...this.pan_offset }
// Panel dimensions
const sidePanelWidth = 220
const toolPanelWidth = 64
const topBarHeight = 44
// Calculate available space
const availableWidth =
this.rootElement!.clientWidth - sidePanelWidth - toolPanelWidth
const availableHeight = this.rootElement!.clientHeight - topBarHeight
// Calculate target zoom
const zoomRatioWidth = availableWidth / this.image!.width
const zoomRatioHeight = availableHeight / this.image!.height
const targetZoom = Math.min(zoomRatioWidth, zoomRatioHeight)
// Calculate final dimensions
const aspectRatio = this.image!.width / this.image!.height
let finalWidth = 0
let finalHeight = 0
// Calculate target pan position
const targetPan = { x: toolPanelWidth, y: topBarHeight }
if (zoomRatioHeight > zoomRatioWidth) {
finalWidth = availableWidth
finalHeight = finalWidth / aspectRatio
targetPan.y = (availableHeight - finalHeight) / 2 + topBarHeight
} else {
finalHeight = availableHeight
finalWidth = finalHeight * aspectRatio
targetPan.x = (availableWidth - finalWidth) / 2 + toolPanelWidth
}
const startTime = performance.now()
const animate = (currentTime: number) => {
const elapsed = currentTime - startTime
const progress = Math.min(elapsed / duration, 1)
// Cubic easing out for smooth deceleration
const eased = 1 - Math.pow(1 - progress, 3)
// Calculate intermediate zoom and pan values
const currentZoom = startZoom + (targetZoom - startZoom) * eased
this.zoom_ratio = currentZoom
this.pan_offset.x = startPan.x + (targetPan.x - startPan.x) * eased
this.pan_offset.y = startPan.y + (targetPan.y - startPan.y) * eased
this.invalidatePanZoom()
const interpolatedZoomRatio = startZoom + (1.0 - startZoom) * eased
this.messageBroker.publish(
'setZoomText',
`${Math.round(interpolatedZoomRatio * 100)}%`
)
if (progress < 1) {
requestAnimationFrame(animate)
}
}
requestAnimationFrame(animate)
this.interpolatedZoomRatio = 1.0
}
async initializeCanvasPanZoom(
image: HTMLImageElement,
rootElement: HTMLElement
) {
// Get side panel width
let sidePanelWidth = 220
const toolPanelWidth = 64
let topBarHeight = 44
this.rootElement = rootElement
// Calculate available width accounting for both side panels
let availableWidth =
rootElement.clientWidth - sidePanelWidth - toolPanelWidth
let availableHeight = rootElement.clientHeight - topBarHeight
let zoomRatioWidth = availableWidth / image.width
let zoomRatioHeight = availableHeight / image.height
let aspectRatio = image.width / image.height
let finalWidth = 0
let finalHeight = 0
let pan_offset: Offset = { x: toolPanelWidth, y: topBarHeight }
if (zoomRatioHeight > zoomRatioWidth) {
finalWidth = availableWidth
finalHeight = finalWidth / aspectRatio
pan_offset.y = (availableHeight - finalHeight) / 2 + topBarHeight
} else {
finalHeight = availableHeight
finalWidth = finalHeight * aspectRatio
pan_offset.x = (availableWidth - finalWidth) / 2 + toolPanelWidth
}
if (this.image === null) {
this.image = image
}
this.imageRootWidth = finalWidth
this.imageRootHeight = finalHeight
this.zoom_ratio = Math.min(zoomRatioWidth, zoomRatioHeight)
this.pan_offset = pan_offset
this.penPointerIdList = []
await this.invalidatePanZoom()
}
async invalidatePanZoom() {
// Single validation check upfront
if (
!this.image?.width ||
!this.image?.height ||
!this.pan_offset ||
!this.zoom_ratio
) {
console.warn('Missing required properties for pan/zoom')
return
}
// Now TypeScript knows these are non-null
const raw_width = this.image.width * this.zoom_ratio
const raw_height = this.image.height * this.zoom_ratio
// Get canvas container
this.canvasContainer ??=
await this.messageBroker?.pull('getCanvasContainer')
if (!this.canvasContainer) return
// Apply styles
Object.assign(this.canvasContainer.style, {
width: `${raw_width}px`,
height: `${raw_height}px`,
left: `${this.pan_offset.x}px`,
top: `${this.pan_offset.y}px`
})
this.rgbCanvas = await this.messageBroker.pull('rgbCanvas')
if (this.rgbCanvas) {
// Ensure the canvas has the proper dimensions
if (
this.rgbCanvas.width !== this.image.width ||
this.rgbCanvas.height !== this.image.height
) {
this.rgbCanvas.width = this.image.width
this.rgbCanvas.height = this.image.height
}
// Make sure the style dimensions match the container
this.rgbCanvas.style.width = `${raw_width}px`
this.rgbCanvas.style.height = `${raw_height}px`
}
}
private handlePanStart(event: PointerEvent) {
this.messageBroker.pull('screenToCanvas', {
x: event.offsetX,
y: event.offsetY
})
this.mouseDownPoint = { x: event.clientX, y: event.clientY }
this.messageBroker.publish('panCursor', true)
this.initialPan = this.pan_offset
return
}
private handlePanMove(event: PointerEvent) {
if (this.mouseDownPoint === null) throw new Error('mouseDownPoint is null')
let deltaX = this.mouseDownPoint.x - event.clientX
let deltaY = this.mouseDownPoint.y - event.clientY
let pan_x = this.initialPan.x - deltaX
let pan_y = this.initialPan.y - deltaY
this.pan_offset = { x: pan_x, y: pan_y }
this.invalidatePanZoom()
}
}

View File

@@ -1,182 +0,0 @@
import type { MaskEditorDialog, Point } from '../types'
import { Tools } from '../types'
import { MessageBroker } from './MessageBroker'
export class ToolManager {
maskEditor: MaskEditorDialog
messageBroker: MessageBroker
mouseDownPoint: Point | null = null
currentTool: Tools = Tools.MaskPen
isAdjustingBrush: boolean = false // is user adjusting brush size or hardness with alt + right mouse button
constructor(maskEditor: MaskEditorDialog) {
this.maskEditor = maskEditor
this.messageBroker = maskEditor.getMessageBroker()
this.addListeners()
this.addPullTopics()
}
private addListeners() {
this.messageBroker.subscribe('setTool', async (tool: Tools) => {
this.setTool(tool)
})
this.messageBroker.subscribe('pointerDown', async (event: PointerEvent) => {
this.handlePointerDown(event)
})
this.messageBroker.subscribe('pointerMove', async (event: PointerEvent) => {
this.handlePointerMove(event)
})
this.messageBroker.subscribe('pointerUp', async (event: PointerEvent) => {
this.handlePointerUp(event)
})
this.messageBroker.subscribe('wheel', async (event: WheelEvent) => {
this.handleWheelEvent(event)
})
}
private async addPullTopics() {
this.messageBroker.createPullTopic('currentTool', async () =>
this.getCurrentTool()
)
}
//tools
setTool(tool: Tools) {
this.currentTool = tool
if (tool != Tools.MaskColorFill) {
this.messageBroker.publish('clearLastPoint')
}
}
getCurrentTool() {
return this.currentTool
}
private async handlePointerDown(event: PointerEvent) {
event.preventDefault()
if (event.pointerType == 'touch') return
var isSpacePressed = await this.messageBroker.pull('isKeyPressed', ' ')
// Pan canvas
if (event.buttons === 4 || (event.buttons === 1 && isSpacePressed)) {
this.messageBroker.publish('panStart', event)
this.messageBroker.publish('setBrushVisibility', false)
return
}
// RGB painting
if (this.currentTool === Tools.PaintPen && event.button === 0) {
this.messageBroker.publish('drawStart', event)
this.messageBroker.publish('saveState')
return
}
// RGB painting
if (this.currentTool === Tools.PaintPen && event.buttons === 1) {
this.messageBroker.publish('draw', event)
return
}
//paint bucket
if (this.currentTool === Tools.MaskBucket && event.button === 0) {
const offset = { x: event.offsetX, y: event.offsetY }
const coords_canvas = await this.messageBroker.pull(
'screenToCanvas',
offset
)
this.messageBroker.publish('paintBucketFill', coords_canvas)
this.messageBroker.publish('saveState')
return
}
if (this.currentTool === Tools.MaskColorFill && event.button === 0) {
const offset = { x: event.offsetX, y: event.offsetY }
const coords_canvas = await this.messageBroker.pull(
'screenToCanvas',
offset
)
this.messageBroker.publish('colorSelectFill', coords_canvas)
return
}
// (brush resize/change hardness) Check for alt + right mouse button
if (event.altKey && event.button === 2) {
this.isAdjustingBrush = true
this.messageBroker.publish('brushAdjustmentStart', event)
return
}
var isDrawingTool = [Tools.MaskPen, Tools.Eraser, Tools.PaintPen].includes(
this.currentTool
)
//drawing
if ([0, 2].includes(event.button) && isDrawingTool) {
this.messageBroker.publish('drawStart', event)
return
}
}
private async handlePointerMove(event: PointerEvent) {
event.preventDefault()
if (event.pointerType == 'touch') return
const newCursorPoint = { x: event.clientX, y: event.clientY }
this.messageBroker.publish('cursorPoint', newCursorPoint)
var isSpacePressed = await this.messageBroker.pull('isKeyPressed', ' ')
this.messageBroker.publish('updateBrushPreview')
//move the canvas
if (event.buttons === 4 || (event.buttons === 1 && isSpacePressed)) {
this.messageBroker.publish('panMove', event)
return
}
//prevent drawing with other tools
var isDrawingTool = [Tools.MaskPen, Tools.Eraser, Tools.PaintPen].includes(
this.currentTool
)
if (!isDrawingTool) return
// alt + right mouse button hold brush adjustment
if (
this.isAdjustingBrush &&
(this.currentTool === Tools.MaskPen ||
this.currentTool === Tools.Eraser) &&
event.altKey &&
event.buttons === 2
) {
this.messageBroker.publish('brushAdjustment', event)
return
}
//draw with pen or eraser
if (event.buttons == 1 || event.buttons == 2) {
this.messageBroker.publish('draw', event)
return
}
}
private handlePointerUp(event: PointerEvent) {
this.messageBroker.publish('panCursor', false)
if (event.pointerType === 'touch') return
this.messageBroker.publish('updateCursor')
this.isAdjustingBrush = false
this.messageBroker.publish('drawEnd', event)
this.mouseDownPoint = null
}
private handleWheelEvent(event: WheelEvent) {
this.messageBroker.publish('zoom', event)
const newCursorPoint = { x: event.clientX, y: event.clientY }
this.messageBroker.publish('cursorPoint', newCursorPoint)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +0,0 @@
export { UIManager } from './UIManager'
export { ToolManager } from './ToolManager'
export { PanAndZoomManager } from './PanAndZoomManager'
export { KeyboardManager } from './KeyboardManager'
export { MessageBroker } from './MessageBroker'

View File

@@ -1,738 +0,0 @@
const styles = `
#maskEditorContainer {
display: fixed;
}
#maskEditor_brush {
position: absolute;
backgroundColor: transparent;
z-index: 8889;
pointer-events: none;
border-radius: 50%;
overflow: visible;
outline: 1px dashed black;
box-shadow: 0 0 0 1px white;
}
#maskEditor_brushPreviewGradient {
position: absolute;
width: 100%;
height: 100%;
border-radius: 50%;
display: none;
}
#maskEditor {
display: block;
width: 100%;
height: 100vh;
left: 0;
z-index: 8888;
position: fixed;
background: rgba(50,50,50,0.75);
backdrop-filter: blur(10px);
overflow: hidden;
user-select: none;
--mask-editor-top-bar-height: 44px;
}
#maskEditor_sidePanelContainer {
height: 100%;
width: 220px;
z-index: 8888;
display: flex;
flex-direction: column;
}
#maskEditor_sidePanel {
background: var(--comfy-menu-bg);
height: 100%;
display: flex;
align-items: center;
overflow-y: auto;
width: 220px;
padding: 0 10px;
}
#maskEditor_sidePanelContent {
width: 100%;
}
#maskEditor_sidePanelShortcuts {
display: flex;
flex-direction: row;
width: 100%;
margin-top: 10px;
gap: 10px;
justify-content: center;
}
.maskEditor_sidePanelIconButton {
width: 40px;
height: 40px;
pointer-events: auto;
display: flex;
justify-content: center;
align-items: center;
transition: background-color 0.1s;
}
.maskEditor_sidePanelIconButton:hover {
background-color: rgba(0, 0, 0, 0.2);
}
#maskEditor_sidePanelBrushSettings {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
padding: 10px;
}
.maskEditor_sidePanelTitle {
text-align: center;
font-size: 15px;
font-family: sans-serif;
color: var(--descrip-text);
margin-top: 10px;
}
#maskEditor_sidePanelBrushShapeContainer {
display: flex;
width: 180px;
height: 50px;
border: 1px solid var(--border-color);
pointer-events: auto;
background: rgba(0, 0, 0, 0.2);
}
#maskEditor_sidePanelBrushShapeCircle {
width: 35px;
height: 35px;
border-radius: 50%;
border: 1px solid var(--border-color);
pointer-events: auto;
transition: background 0.1s;
margin-left: 7.5px;
}
.maskEditor_sidePanelBrushRange {
width: 180px;
-webkit-appearance: none;
appearance: none;
background: transparent;
cursor: pointer;
}
.maskEditor_sidePanelBrushRange::-webkit-slider-thumb {
height: 20px;
width: 20px;
border-radius: 50%;
cursor: grab;
margin-top: -8px;
background: var(--p-surface-700);
border: 1px solid var(--border-color);
}
.maskEditor_sidePanelBrushRange::-moz-range-thumb {
height: 20px;
width: 20px;
border-radius: 50%;
cursor: grab;
background: var(--p-surface-800);
border: 1px solid var(--border-color);
}
.maskEditor_sidePanelBrushRange::-webkit-slider-runnable-track {
background: var(--p-surface-700);
height: 3px;
}
.maskEditor_sidePanelBrushRange::-moz-range-track {
background: var(--p-surface-700);
height: 3px;
}
#maskEditor_sidePanelBrushShapeSquare {
width: 35px;
height: 35px;
margin: 5px;
border: 1px solid var(--border-color);
pointer-events: auto;
transition: background 0.1s;
}
.maskEditor_brushShape_dark {
background: transparent;
}
.maskEditor_brushShape_dark:hover {
background: var(--p-surface-900);
}
.maskEditor_brushShape_light {
background: transparent;
}
.maskEditor_brushShape_light:hover {
background: var(--comfy-menu-bg);
}
#maskEditor_sidePanelImageLayerSettings {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
align-items: center;
}
.maskEditor_sidePanelLayer {
display: flex;
width: 100%;
height: 50px;
}
.maskEditor_sidePanelLayerVisibilityContainer {
width: 50px;
height: 50px;
border-radius: 8px;
display: flex;
justify-content: center;
align-items: center;
}
.maskEditor_sidePanelVisibilityToggle {
width: 12px;
height: 12px;
border-radius: 50%;
pointer-events: auto;
}
.maskEditor_sidePanelLayerIconContainer {
width: 60px;
height: 50px;
border-radius: 8px;
display: flex;
justify-content: center;
align-items: center;
fill: var(--input-text);
}
.maskEditor_sidePanelLayerIconContainer svg {
width: 30px;
height: 30px;
}
#maskEditor_sidePanelMaskLayerBlendingContainer {
width: 80px;
height: 50px;
border-radius: 8px;
display: flex;
justify-content: center;
align-items: center;
}
#maskEditor_sidePanelMaskLayerBlendingSelect {
width: 80px;
height: 30px;
border: 1px solid var(--border-color);
background-color: rgba(0, 0, 0, 0.2);
color: var(--input-text);
font-family: sans-serif;
font-size: 15px;
pointer-events: auto;
transition: background-color border 0.1s;
}
#maskEditor_sidePanelClearCanvasButton:hover {
background-color: var(--p-overlaybadge-outline-color);
border: none;
}
#maskEditor_sidePanelClearCanvasButton {
width: 180px;
height: 30px;
border: none;
background: rgba(0, 0, 0, 0.2);
border: 1px solid var(--border-color);
color: var(--input-text);
font-family: sans-serif;
font-size: 15px;
pointer-events: auto;
transition: background-color 0.1s;
}
#maskEditor_sidePanelClearCanvasButton:hover {
background-color: var(--p-overlaybadge-outline-color);
}
#maskEditor_sidePanelHorizontalButtonContainer {
display: flex;
gap: 10px;
height: 40px;
}
.maskEditor_sidePanelBigButton {
width: 85px;
height: 30px;
border: none;
background: rgba(0, 0, 0, 0.2);
border: 1px solid var(--border-color);
color: var(--input-text);
font-family: sans-serif;
font-size: 15px;
pointer-events: auto;
transition: background-color border 0.1s;
}
.maskEditor_sidePanelBigButton:hover {
background-color: var(--p-overlaybadge-outline-color);
border: none;
}
#maskEditor_toolPanel {
height: 100%;
width: 4rem;
z-index: 8888;
background: var(--comfy-menu-bg);
display: flex;
flex-direction: column;
}
.maskEditor_toolPanelContainer {
width: 4rem;
height: 4rem;
display: flex;
justify-content: center;
align-items: center;
position: relative;
transition: background-color 0.2s;
}
.maskEditor_toolPanelContainerSelected svg {
fill: var(--p-button-text-primary-color) !important;
}
.maskEditor_toolPanelContainerSelected .maskEditor_toolPanelIndicator {
display: block;
}
.maskEditor_toolPanelContainer svg {
width: 75%;
aspect-ratio: 1/1;
fill: var(--p-button-text-secondary-color);
}
.maskEditor_toolPanelContainerDark:hover {
background-color: var(--p-surface-800);
}
.maskEditor_toolPanelContainerLight:hover {
background-color: var(--p-surface-300);
}
.maskEditor_toolPanelIndicator {
display: none;
height: 100%;
width: 4px;
position: absolute;
left: 0;
background: var(--p-button-text-primary-color);
}
#maskEditor_sidePanelPaintBucketSettings {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
padding: 10px;
}
#canvasBackground {
background: white;
width: 100%;
height: 100%;
}
#maskEditor_sidePanelButtonsContainer {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 10px;
}
.maskEditor_sidePanelSeparator {
width: 100%;
height: 2px;
background: var(--border-color);
margin-top: 1.5em;
margin-bottom: 5px;
}
#maskEditor_pointerZone {
width: calc(100% - 4rem - 220px);
height: 100%;
}
#maskEditor_uiContainer {
width: 100%;
height: 100%;
position: absolute;
z-index: 8888;
display: flex;
flex-direction: column;
}
#maskEditorCanvasContainer {
position: absolute;
width: 1000px;
height: 667px;
left: 359px;
top: 280px;
}
#imageCanvas {
width: 100%;
height: 100%;
}
#maskCanvas {
width: 100%;
height: 100%;
}
#maskEditor_uiHorizontalContainer {
width: 100%;
height: calc(100% - var(--mask-editor-top-bar-height));
display: flex;
}
#maskEditor_topBar {
display: flex;
height: var(--mask-editor-top-bar-height);
align-items: center;
background: var(--comfy-menu-bg);
shrink: 0;
}
#maskEditor_topBarTitle {
margin: 0;
margin-left: 0.5rem;
margin-right: 0.5rem;
font-size: 1.2em;
}
#maskEditor_topBarButtonContainer {
display: flex;
gap: 10px;
margin-right: 0.5rem;
position: absolute;
right: 0;
width: 100%;
}
#maskEditor_topBarShortcutsContainer {
display: flex;
gap: 10px;
margin-left: 5px;
}
.maskEditor_topPanelIconButton_dark {
width: 50px;
height: 30px;
pointer-events: auto;
display: flex;
justify-content: center;
align-items: center;
transition: background-color 0.1s;
background: var(--p-surface-800);
border: 1px solid var(--p-form-field-border-color);
border-radius: 10px;
}
.maskEditor_topPanelIconButton_dark:hover {
background-color: var(--p-surface-900);
}
.maskEditor_topPanelIconButton_dark svg {
width: 25px;
height: 25px;
pointer-events: none;
fill: var(--input-text);
}
.maskEditor_topPanelIconButton_light {
width: 50px;
height: 30px;
pointer-events: auto;
display: flex;
justify-content: center;
align-items: center;
transition: background-color 0.1s;
background: var(--comfy-menu-bg);
border: 1px solid var(--p-form-field-border-color);
border-radius: 10px;
}
.maskEditor_topPanelIconButton_light:hover {
background-color: var(--p-surface-300);
}
.maskEditor_topPanelIconButton_light svg {
width: 25px;
height: 25px;
pointer-events: none;
fill: var(--input-text);
}
.maskEditor_topPanelButton_dark {
height: 30px;
background: var(--p-surface-800);
border: 1px solid var(--p-form-field-border-color);
border-radius: 10px;
color: var(--input-text);
font-family: sans-serif;
pointer-events: auto;
transition: 0.1s;
width: 60px;
}
.maskEditor_topPanelButton_dark:hover {
background-color: var(--p-surface-900);
}
.maskEditor_topPanelButton_light {
height: 30px;
background: var(--comfy-menu-bg);
border: 1px solid var(--p-form-field-border-color);
border-radius: 10px;
color: var(--input-text);
font-family: sans-serif;
pointer-events: auto;
transition: 0.1s;
width: 60px;
}
.maskEditor_topPanelButton_light:hover {
background-color: var(--p-surface-300);
}
#maskEditor_sidePanelColorSelectSettings {
flex-direction: column;
}
.maskEditor_sidePanel_paintBucket_Container {
width: 180px;
display: flex;
flex-direction: column;
position: relative;
}
.maskEditor_sidePanel_colorSelect_Container {
display: flex;
width: 180px;
align-items: center;
gap: 5px;
height: 30px;
}
#maskEditor_sidePanelVisibilityToggle {
position: absolute;
right: 0;
}
#maskEditor_sidePanelColorSelectMethodSelect {
position: absolute;
right: 0;
height: 30px;
border-radius: 0;
border: 1px solid var(--border-color);
background: rgba(0,0,0,0.2);
}
#maskEditor_sidePanelVisibilityToggle {
position: absolute;
right: 0;
}
.maskEditor_sidePanel_colorSelect_tolerance_container {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 10px;
}
.maskEditor_sidePanelContainerColumn {
display: flex;
flex-direction: column;
gap: 12px;
padding-bottom: 12px;
}
.maskEditor_sidePanelContainerRow {
display: flex;
flex-direction: row;
gap: 10px;
align-items: center;
min-height: 24px;
position: relative;
}
.maskEditor_accent_bg_dark {
background: var(--p-surface-800);
}
.maskEditor_accent_bg_very_dark {
background: var(--p-surface-900);
}
.maskEditor_accent_bg_light {
background: var(--p-surface-300);
}
.maskEditor_accent_bg_very_light {
background: var(--comfy-menu-bg);
}
#maskEditor_paintBucketSettings {
display: none;
}
#maskEditor_colorSelectSettings {
display: none;
}
.maskEditor_sidePanelToggleContainer {
cursor: pointer;
display: inline-block;
position: absolute;
right: 0;
}
.maskEditor_toggle_bg_dark {
background: var(--p-surface-700);
}
.maskEditor_toggle_bg_light {
background: var(--p-surface-300);
}
.maskEditor_sidePanelToggleSwitch {
display: inline-block;
border-radius: 16px;
width: 40px;
height: 24px;
position: relative;
vertical-align: middle;
transition: background 0.25s;
}
.maskEditor_sidePanelToggleSwitch:before, .maskEditor_sidePanelToggleSwitch:after {
content: "";
}
.maskEditor_sidePanelToggleSwitch:before {
display: block;
background: linear-gradient(to bottom, #fff 0%, #eee 100%);
border-radius: 50%;
width: 16px;
height: 16px;
position: absolute;
top: 4px;
left: 4px;
transition: ease 0.2s;
}
.maskEditor_sidePanelToggleContainer:hover .maskEditor_sidePanelToggleSwitch:before {
background: linear-gradient(to bottom, #fff 0%, #fff 100%);
}
.maskEditor_sidePanelToggleCheckbox:checked + .maskEditor_sidePanelToggleSwitch {
background: var(--p-button-text-primary-color);
}
.maskEditor_sidePanelToggleCheckbox:checked + .maskEditor_toggle_bg_dark:before {
background: var(--p-surface-900);
}
.maskEditor_sidePanelToggleCheckbox:checked + .maskEditor_toggle_bg_light:before {
background: var(--comfy-menu-bg);
}
.maskEditor_sidePanelToggleCheckbox:checked + .maskEditor_sidePanelToggleSwitch:before {
left: 20px;
}
.maskEditor_sidePanelToggleCheckbox {
position: absolute;
visibility: hidden;
}
.maskEditor_sidePanelDropdown_dark {
border: 1px solid var(--p-form-field-border-color);
background: var(--p-surface-900);
height: 24px;
padding-left: 5px;
padding-right: 5px;
border-radius: 6px;
transition: background 0.1s;
}
.maskEditor_sidePanelDropdown_dark option {
background: var(--p-surface-900);
}
.maskEditor_sidePanelDropdown_dark:focus {
outline: 1px solid var(--p-button-text-primary-color);
}
.maskEditor_sidePanelDropdown_dark option:hover {
background: white;
}
.maskEditor_sidePanelDropdown_dark option:active {
background: var(--p-highlight-background);
}
.maskEditor_sidePanelDropdown_light {
border: 1px solid var(--p-form-field-border-color);
background: var(--comfy-menu-bg);
height: 24px;
padding-left: 5px;
padding-right: 5px;
border-radius: 6px;
transition: background 0.1s;
}
.maskEditor_sidePanelDropdown_light option {
background: var(--comfy-menu-bg);
}
.maskEditor_sidePanelDropdown_light:focus {
outline: 1px solid var(--p-surface-300);
}
.maskEditor_sidePanelDropdown_light option:hover {
background: white;
}
.maskEditor_sidePanelDropdown_light option:active {
background: var(--p-surface-300);
}
.maskEditor_layerRow {
height: 50px;
width: 100%;
border-radius: 10px;
}
.maskEditor_sidePanelLayerPreviewContainer {
width: 40px;
height: 30px;
}
.maskEditor_sidePanelLayerPreviewContainer > svg{
width: 100%;
height: 100%;
object-fit: contain;
fill: var(--p-surface-100);
}
#maskEditor_sidePanelImageLayerImage {
width: 100%;
height: 100%;
object-fit: contain;
}
.maskEditor_sidePanelSubTitle {
text-align: left;
font-size: 12px;
font-family: sans-serif;
color: var(--descrip-text);
}
.maskEditor_containerDropdown {
position: absolute;
right: 0;
}
.maskEditor_sidePanelLayerCheckbox {
margin-left: 15px;
}
.maskEditor_toolPanelZoomIndicator {
width: 4rem;
height: 4rem;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 5px;
color: var(--p-button-text-secondary-color);
position: absolute;
bottom: 0;
transition: background-color 0.2s;
}
#maskEditor_toolPanelDimensionsText {
font-size: 12px;
}
#maskEditor_topBarSaveButton {
background: var(--p-primary-color) !important;
color: var(--p-button-primary-color) !important;
}
#maskEditor_topBarSaveButton:hover {
background: var(--p-primary-hover-color) !important;
}
`
// Inject styles into document
const styleSheet = document.createElement('style')
styleSheet.type = 'text/css'
styleSheet.innerText = styles
document.head.appendChild(styleSheet)

View File

@@ -1,779 +0,0 @@
import QuickLRU from '@alloc/quick-lru'
import { hexToRgb, parseToRgb } from '@/utils/colorUtil'
import { app } from '@/scripts/app'
import {
BrushShape,
CompositionOperation,
MaskBlendMode,
Tools,
type Brush,
type ImageLayer,
type Point
} from '../types'
import { loadBrushFromCache, saveBrushToCache } from '../utils/brushCache'
// Forward declaration for MessageBroker type
interface MessageBroker {
subscribe(topic: string, callback: (data?: any) => void): void
publish(topic: string, data?: any): void
pull<T>(topic: string, data?: any): Promise<T>
createPullTopic(topic: string, callback: (data?: any) => Promise<any>): void
}
// Forward declaration for MaskEditorDialog type
interface MaskEditorDialog {
getMessageBroker(): MessageBroker
}
class BrushTool {
brushSettings: Brush //this saves the current brush settings
maskBlendMode: MaskBlendMode
isDrawing: boolean = false
isDrawingLine: boolean = false
lineStartPoint: Point | null = null
smoothingPrecision: number = 10
smoothingCordsArray: Point[] = []
smoothingLastDrawTime!: Date
maskCtx: CanvasRenderingContext2D | null = null
rgbCtx: CanvasRenderingContext2D | null = null
initialDraw: boolean = true
private static brushTextureCache = new QuickLRU<string, HTMLCanvasElement>({
maxSize: 8 // Reasonable limit for brush texture variations?
})
brushStrokeCanvas: HTMLCanvasElement | null = null
brushStrokeCtx: CanvasRenderingContext2D | null = null
private static readonly SMOOTHING_MAX_STEPS = 30
private static readonly SMOOTHING_MIN_STEPS = 2
//brush adjustment
isBrushAdjusting: boolean = false
brushPreviewGradient: HTMLElement | null = null
initialPoint: Point | null = null
useDominantAxis: boolean = false
brushAdjustmentSpeed: number = 1.0
maskEditor: MaskEditorDialog
messageBroker: MessageBroker
private rgbColor: string = '#FF0000' // Default color
private activeLayer: ImageLayer = 'mask'
constructor(maskEditor: MaskEditorDialog) {
this.maskEditor = maskEditor
this.messageBroker = maskEditor.getMessageBroker()
this.createListeners()
this.addPullTopics()
this.useDominantAxis = app.extensionManager.setting.get(
'Comfy.MaskEditor.UseDominantAxis'
)
this.brushAdjustmentSpeed = app.extensionManager.setting.get(
'Comfy.MaskEditor.BrushAdjustmentSpeed'
)
const cachedBrushSettings = loadBrushFromCache('maskeditor_brush_settings')
if (cachedBrushSettings) {
this.brushSettings = cachedBrushSettings
} else {
this.brushSettings = {
type: BrushShape.Arc,
size: 10,
opacity: 0.7,
hardness: 1,
smoothingPrecision: 10
}
}
this.maskBlendMode = MaskBlendMode.Black
}
private createListeners() {
//setters
this.messageBroker.subscribe('setBrushSize', (size: number) =>
this.setBrushSize(size)
)
this.messageBroker.subscribe('setBrushOpacity', (opacity: number) =>
this.setBrushOpacity(opacity)
)
this.messageBroker.subscribe('setBrushHardness', (hardness: number) =>
this.setBrushHardness(hardness)
)
this.messageBroker.subscribe('setBrushShape', (type: BrushShape) =>
this.setBrushType(type)
)
this.messageBroker.subscribe(
'setActiveLayer',
(layer: ImageLayer) => (this.activeLayer = layer)
)
this.messageBroker.subscribe(
'setBrushSmoothingPrecision',
(precision: number) => this.setBrushSmoothingPrecision(precision)
)
this.messageBroker.subscribe('setRGBColor', (color: string) => {
this.rgbColor = color
})
//brush adjustment
this.messageBroker.subscribe(
'brushAdjustmentStart',
(event: PointerEvent) => this.startBrushAdjustment(event)
)
this.messageBroker.subscribe('brushAdjustment', (event: PointerEvent) =>
this.handleBrushAdjustment(event)
)
//drawing
this.messageBroker.subscribe('drawStart', (event: PointerEvent) =>
this.startDrawing(event)
)
this.messageBroker.subscribe('draw', (event: PointerEvent) =>
this.handleDrawing(event)
)
this.messageBroker.subscribe('drawEnd', (event: PointerEvent) =>
this.drawEnd(event)
)
}
private addPullTopics() {
this.messageBroker.createPullTopic(
'brushSize',
async () => this.brushSettings.size
)
this.messageBroker.createPullTopic(
'brushOpacity',
async () => this.brushSettings.opacity
)
this.messageBroker.createPullTopic(
'brushHardness',
async () => this.brushSettings.hardness
)
this.messageBroker.createPullTopic(
'brushType',
async () => this.brushSettings.type
)
this.messageBroker.createPullTopic(
'brushSmoothingPrecision',
async () => this.brushSettings.smoothingPrecision
)
this.messageBroker.createPullTopic(
'maskBlendMode',
async () => this.maskBlendMode
)
this.messageBroker.createPullTopic(
'brushSettings',
async () => this.brushSettings
)
}
private async createBrushStrokeCanvas() {
if (this.brushStrokeCanvas !== null) {
return
}
const maskCanvas =
await this.messageBroker.pull<HTMLCanvasElement>('maskCanvas')
const canvas = document.createElement('canvas')
canvas.width = maskCanvas.width
canvas.height = maskCanvas.height
this.brushStrokeCanvas = canvas
this.brushStrokeCtx = canvas.getContext('2d')!
}
private async startDrawing(event: PointerEvent) {
this.isDrawing = true
let compositionOp: CompositionOperation
let currentTool = await this.messageBroker.pull('currentTool')
let coords = { x: event.offsetX, y: event.offsetY }
let coords_canvas = await this.messageBroker.pull<Point>(
'screenToCanvas',
coords
)
await this.createBrushStrokeCanvas()
//set drawing mode
if (currentTool === Tools.Eraser || event.buttons == 2) {
compositionOp = CompositionOperation.DestinationOut //eraser
} else {
compositionOp = CompositionOperation.SourceOver //pen
}
if (event.shiftKey && this.lineStartPoint) {
this.isDrawingLine = true
this.drawLine(this.lineStartPoint, coords_canvas, compositionOp)
} else {
this.isDrawingLine = false
this.init_shape(compositionOp)
this.draw_shape(coords_canvas)
}
this.lineStartPoint = coords_canvas
this.smoothingCordsArray = [coords_canvas] //used to smooth the drawing line
this.smoothingLastDrawTime = new Date()
}
private async handleDrawing(event: PointerEvent) {
var diff = performance.now() - this.smoothingLastDrawTime.getTime()
let coords = { x: event.offsetX, y: event.offsetY }
let coords_canvas = await this.messageBroker.pull<Point>(
'screenToCanvas',
coords
)
let currentTool = await this.messageBroker.pull('currentTool')
if (diff > 20 && !this.isDrawing)
requestAnimationFrame(() => {
this.init_shape(CompositionOperation.SourceOver)
this.draw_shape(coords_canvas)
this.smoothingCordsArray.push(coords_canvas)
})
else
requestAnimationFrame(() => {
if (currentTool === Tools.Eraser || event.buttons == 2) {
this.init_shape(CompositionOperation.DestinationOut)
} else {
this.init_shape(CompositionOperation.SourceOver)
}
//use drawWithSmoothing for better performance or change step in drawWithBetterSmoothing
this.drawWithBetterSmoothing(coords_canvas)
})
this.smoothingLastDrawTime = new Date()
}
private async drawEnd(event: PointerEvent) {
const coords = { x: event.offsetX, y: event.offsetY }
const coords_canvas = await this.messageBroker.pull<Point>(
'screenToCanvas',
coords
)
if (this.isDrawing) {
this.isDrawing = false
this.messageBroker.publish('saveState')
this.lineStartPoint = coords_canvas
this.initialDraw = true
}
}
private clampSmoothingPrecision(value: number): number {
return Math.min(Math.max(value, 1), 100)
}
private drawWithBetterSmoothing(point: Point) {
// Add current point to the smoothing array
if (!this.smoothingCordsArray) {
this.smoothingCordsArray = []
}
const opacityConstant = 1 / (1 + Math.exp(3))
const interpolatedOpacity =
1 / (1 + Math.exp(-6 * (this.brushSettings.opacity - 0.5))) -
opacityConstant
this.smoothingCordsArray.push(point)
// Keep a moving window of points for the spline
const POINTS_NR = 5
if (this.smoothingCordsArray.length < POINTS_NR) {
return
}
// Calculate total length more efficiently
let totalLength = 0
const points = this.smoothingCordsArray
const len = points.length - 1
// Use local variables for better performance
let dx, dy
for (let i = 0; i < len; i++) {
dx = points[i + 1].x - points[i].x
dy = points[i + 1].y - points[i].y
totalLength += Math.sqrt(dx * dx + dy * dy)
}
const maxSteps = BrushTool.SMOOTHING_MAX_STEPS
const minSteps = BrushTool.SMOOTHING_MIN_STEPS
const smoothing = this.clampSmoothingPrecision(
this.brushSettings.smoothingPrecision
)
const normalizedSmoothing = (smoothing - 1) / 99 // Convert to 0-1 range
// Optionality to use exponential curve
const stepNr = Math.round(
Math.round(minSteps + (maxSteps - minSteps) * normalizedSmoothing)
)
// Calculate step distance capped by brush size
const distanceBetweenPoints = totalLength / stepNr
let interpolatedPoints = points
if (stepNr > 0) {
//this calculation needs to be improved
interpolatedPoints = this.generateEquidistantPoints(
this.smoothingCordsArray,
distanceBetweenPoints // Distance between interpolated points
)
}
if (!this.initialDraw) {
// Remove the first 3 points from the array to avoid drawing the same points twice
const spliceIndex = interpolatedPoints.findIndex(
(point) =>
point.x === this.smoothingCordsArray[2].x &&
point.y === this.smoothingCordsArray[2].y
)
if (spliceIndex !== -1) {
interpolatedPoints = interpolatedPoints.slice(spliceIndex + 1)
}
}
// Draw all interpolated points
for (const point of interpolatedPoints) {
this.draw_shape(point, interpolatedOpacity)
}
if (!this.initialDraw) {
// initially draw on all 5 points, then remove the first 3 points to go into 2 new, 3 old points cycle
this.smoothingCordsArray = this.smoothingCordsArray.slice(2)
} else {
this.initialDraw = false
}
}
private async drawLine(
p1: Point,
p2: Point,
compositionOp: CompositionOperation
) {
const brush_size = await this.messageBroker.pull<number>('brushSize')
const distance = Math.sqrt((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2)
const steps = Math.ceil(
distance / ((brush_size / this.brushSettings.smoothingPrecision) * 4)
) // Adjust for smoother lines
const interpolatedOpacity =
1 / (1 + Math.exp(-6 * (this.brushSettings.opacity - 0.5))) -
1 / (1 + Math.exp(3))
this.init_shape(compositionOp)
for (let i = 0; i <= steps; i++) {
const t = i / steps
const x = p1.x + (p2.x - p1.x) * t
const y = p1.y + (p2.y - p1.y) * t
const point = { x: x, y: y }
this.draw_shape(point, interpolatedOpacity)
}
}
//brush adjustment
private async startBrushAdjustment(event: PointerEvent) {
event.preventDefault()
const coords = { x: event.offsetX, y: event.offsetY }
let coords_canvas = await this.messageBroker.pull<Point>(
'screenToCanvas',
coords
)
this.messageBroker.publish('setBrushPreviewGradientVisibility', true)
this.initialPoint = coords_canvas
this.isBrushAdjusting = true
return
}
private async handleBrushAdjustment(event: PointerEvent) {
const coords = { x: event.offsetX, y: event.offsetY }
const brushDeadZone = 5
let coords_canvas = await this.messageBroker.pull<Point>(
'screenToCanvas',
coords
)
const delta_x = coords_canvas.x - this.initialPoint!.x
const delta_y = coords_canvas.y - this.initialPoint!.y
const effectiveDeltaX = Math.abs(delta_x) < brushDeadZone ? 0 : delta_x
const effectiveDeltaY = Math.abs(delta_y) < brushDeadZone ? 0 : delta_y
// New dominant axis logic
let finalDeltaX = effectiveDeltaX
let finalDeltaY = effectiveDeltaY
console.log(this.useDominantAxis)
if (this.useDominantAxis) {
// New setting flag
const ratio = Math.abs(effectiveDeltaX) / Math.abs(effectiveDeltaY)
const threshold = 2.0 // Configurable threshold
if (ratio > threshold) {
finalDeltaY = 0 // X is dominant
} else if (ratio < 1 / threshold) {
finalDeltaX = 0 // Y is dominant
}
}
const cappedDeltaX = Math.max(-100, Math.min(100, finalDeltaX))
const cappedDeltaY = Math.max(-100, Math.min(100, finalDeltaY))
// Rest of the function remains the same
const newSize = Math.max(
1,
Math.min(
100,
this.brushSettings.size! +
(cappedDeltaX / 35) * this.brushAdjustmentSpeed
)
)
const newHardness = Math.max(
0,
Math.min(
1,
this.brushSettings!.hardness -
(cappedDeltaY / 4000) * this.brushAdjustmentSpeed
)
)
this.brushSettings.size = newSize
this.brushSettings.hardness = newHardness
this.messageBroker.publish('updateBrushPreview')
}
//helper functions
private async draw_shape(point: Point, overrideOpacity?: number) {
const brushSettings: Brush = this.brushSettings
const maskCtx =
this.maskCtx ||
(await this.messageBroker.pull<CanvasRenderingContext2D>('maskCtx'))
const rgbCtx =
this.rgbCtx ||
(await this.messageBroker.pull<CanvasRenderingContext2D>('rgbCtx'))
const brushType = await this.messageBroker.pull('brushType')
const maskColor = await this.messageBroker.pull<{
r: number
g: number
b: number
}>('getMaskColor')
const size = brushSettings.size
const brushSettingsSliderOpacity = brushSettings.opacity
const opacity =
overrideOpacity == undefined
? brushSettingsSliderOpacity
: overrideOpacity
const hardness = brushSettings.hardness
const x = point.x
const y = point.y
const brushRadius = size
const isErasing = maskCtx.globalCompositeOperation === 'destination-out'
const currentTool = await this.messageBroker.pull('currentTool')
// Helper function to get or create cached brush texture
const getCachedBrushTexture = (
radius: number,
hardness: number,
color: string,
opacity: number
): HTMLCanvasElement => {
const cacheKey = `${radius}_${hardness}_${color}_${opacity}`
if (BrushTool.brushTextureCache.has(cacheKey)) {
return BrushTool.brushTextureCache.get(cacheKey)!
}
const tempCanvas = document.createElement('canvas')
const tempCtx = tempCanvas.getContext('2d')!
const size = radius * 2
tempCanvas.width = size
tempCanvas.height = size
const centerX = size / 2
const centerY = size / 2
const hardRadius = radius * hardness
const imageData = tempCtx.createImageData(size, size)
const data = imageData.data
const { r, g, b } = parseToRgb(color)
// Pre-calculate values to avoid repeated computations
const fadeRange = radius - hardRadius
for (let y = 0; y < size; y++) {
const dy = y - centerY
for (let x = 0; x < size; x++) {
const dx = x - centerX
const index = (y * size + x) * 4
// Calculate square distance (Chebyshev distance)
const distFromEdge = Math.max(Math.abs(dx), Math.abs(dy))
let pixelOpacity = 0
if (distFromEdge <= hardRadius) {
pixelOpacity = opacity
} else if (distFromEdge <= radius) {
const fadeProgress = (distFromEdge - hardRadius) / fadeRange
pixelOpacity = opacity * (1 - fadeProgress)
}
data[index] = r
data[index + 1] = g
data[index + 2] = b
data[index + 3] = pixelOpacity * 255
}
}
tempCtx.putImageData(imageData, 0, 0)
// Cache the texture
BrushTool.brushTextureCache.set(cacheKey, tempCanvas)
return tempCanvas
}
// RGB brush logic
if (
this.activeLayer === 'rgb' &&
(currentTool === Tools.Eraser || currentTool === Tools.PaintPen)
) {
const rgbaColor = this.formatRgba(this.rgbColor, opacity)
if (brushType === BrushShape.Rect && hardness < 1) {
const brushTexture = getCachedBrushTexture(
brushRadius,
hardness,
rgbaColor,
opacity
)
rgbCtx.drawImage(brushTexture, x - brushRadius, y - brushRadius)
return
}
// For max hardness, use solid fill to avoid anti-aliasing
if (hardness === 1) {
rgbCtx.fillStyle = rgbaColor
rgbCtx.beginPath()
if (brushType === BrushShape.Rect) {
rgbCtx.rect(
x - brushRadius,
y - brushRadius,
brushRadius * 2,
brushRadius * 2
)
} else {
rgbCtx.arc(x, y, brushRadius, 0, Math.PI * 2, false)
}
rgbCtx.fill()
return
}
// For soft brushes, use gradient
let gradient = rgbCtx.createRadialGradient(x, y, 0, x, y, brushRadius)
gradient.addColorStop(0, rgbaColor)
gradient.addColorStop(
hardness,
this.formatRgba(this.rgbColor, opacity * 0.5)
)
gradient.addColorStop(1, this.formatRgba(this.rgbColor, 0))
rgbCtx.fillStyle = gradient
rgbCtx.beginPath()
if (brushType === BrushShape.Rect) {
rgbCtx.rect(
x - brushRadius,
y - brushRadius,
brushRadius * 2,
brushRadius * 2
)
} else {
rgbCtx.arc(x, y, brushRadius, 0, Math.PI * 2, false)
}
rgbCtx.fill()
return
}
// Mask brush logic
if (brushType === BrushShape.Rect && hardness < 1) {
const baseColor = isErasing
? `rgba(255, 255, 255, ${opacity})`
: `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
const brushTexture = getCachedBrushTexture(
brushRadius,
hardness,
baseColor,
opacity
)
maskCtx.drawImage(brushTexture, x - brushRadius, y - brushRadius)
return
}
// For max hardness, use solid fill to avoid anti-aliasing
if (hardness === 1) {
const solidColor = isErasing
? `rgba(255, 255, 255, ${opacity})`
: `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
maskCtx.fillStyle = solidColor
maskCtx.beginPath()
if (brushType === BrushShape.Rect) {
maskCtx.rect(
x - brushRadius,
y - brushRadius,
brushRadius * 2,
brushRadius * 2
)
} else {
maskCtx.arc(x, y, brushRadius, 0, Math.PI * 2, false)
}
maskCtx.fill()
return
}
// For soft brushes, use gradient
let gradient = maskCtx.createRadialGradient(x, y, 0, x, y, brushRadius)
if (isErasing) {
gradient.addColorStop(0, `rgba(255, 255, 255, ${opacity})`)
gradient.addColorStop(hardness, `rgba(255, 255, 255, ${opacity * 0.5})`)
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`)
} else {
gradient.addColorStop(
0,
`rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
)
gradient.addColorStop(
hardness,
`rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity * 0.5})`
)
gradient.addColorStop(
1,
`rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, 0)`
)
}
maskCtx.fillStyle = gradient
maskCtx.beginPath()
if (brushType === BrushShape.Rect) {
maskCtx.rect(
x - brushRadius,
y - brushRadius,
brushRadius * 2,
brushRadius * 2
)
} else {
maskCtx.arc(x, y, brushRadius, 0, Math.PI * 2, false)
}
maskCtx.fill()
}
private formatRgba(hex: string, alpha: number): string {
const { r, g, b } = hexToRgb(hex)
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
private async init_shape(compositionOperation: CompositionOperation) {
const maskBlendMode =
await this.messageBroker.pull<MaskBlendMode>('maskBlendMode')
const maskCtx =
this.maskCtx ||
(await this.messageBroker.pull<CanvasRenderingContext2D>('maskCtx'))
const rgbCtx =
this.rgbCtx ||
(await this.messageBroker.pull<CanvasRenderingContext2D>('rgbCtx'))
maskCtx.beginPath()
rgbCtx.beginPath()
// For both contexts, set the composite operation based on the passed parameter
// This ensures right-click always works for erasing
if (compositionOperation == CompositionOperation.SourceOver) {
maskCtx.fillStyle = maskBlendMode
maskCtx.globalCompositeOperation = CompositionOperation.SourceOver
rgbCtx.globalCompositeOperation = CompositionOperation.SourceOver
} else if (compositionOperation == CompositionOperation.DestinationOut) {
maskCtx.globalCompositeOperation = CompositionOperation.DestinationOut
rgbCtx.globalCompositeOperation = CompositionOperation.DestinationOut
}
}
private generateEquidistantPoints(
points: Point[],
distance: number
): Point[] {
const result: Point[] = []
const cumulativeDistances: number[] = [0]
// Calculate cumulative distances between points
for (let i = 1; i < points.length; i++) {
const dx = points[i].x - points[i - 1].x
const dy = points[i].y - points[i - 1].y
const dist = Math.hypot(dx, dy)
cumulativeDistances[i] = cumulativeDistances[i - 1] + dist
}
const totalLength = cumulativeDistances[cumulativeDistances.length - 1]
const numPoints = Math.floor(totalLength / distance)
for (let i = 0; i <= numPoints; i++) {
const targetDistance = i * distance
let idx = 0
// Find the segment where the target distance falls
while (
idx < cumulativeDistances.length - 1 &&
cumulativeDistances[idx + 1] < targetDistance
) {
idx++
}
if (idx >= points.length - 1) {
result.push(points[points.length - 1])
continue
}
const d0 = cumulativeDistances[idx]
const d1 = cumulativeDistances[idx + 1]
const t = (targetDistance - d0) / (d1 - d0)
const x = points[idx].x + t * (points[idx + 1].x - points[idx].x)
const y = points[idx].y + t * (points[idx + 1].y - points[idx].y)
result.push({ x, y })
}
return result
}
private setBrushSize(size: number) {
this.brushSettings.size = size
saveBrushToCache('maskeditor_brush_settings', this.brushSettings)
}
private setBrushOpacity(opacity: number) {
this.brushSettings.opacity = opacity
saveBrushToCache('maskeditor_brush_settings', this.brushSettings)
}
private setBrushHardness(hardness: number) {
this.brushSettings.hardness = hardness
saveBrushToCache('maskeditor_brush_settings', this.brushSettings)
}
private setBrushType(type: BrushShape) {
this.brushSettings.type = type
saveBrushToCache('maskeditor_brush_settings', this.brushSettings)
}
private setBrushSmoothingPrecision(precision: number) {
this.brushSettings.smoothingPrecision = precision
saveBrushToCache('maskeditor_brush_settings', this.brushSettings)
}
}
export { BrushTool }

View File

@@ -1,464 +0,0 @@
import { ColorComparisonMethod, type Point } from '../types'
// Forward declaration for MessageBroker type
interface MessageBroker {
subscribe(topic: string, callback: (data?: any) => void): void
publish(topic: string, data?: any): void
pull<T>(topic: string, data?: any): Promise<T>
createPullTopic(topic: string, callback: (data?: any) => Promise<any>): void
}
// Forward declaration for MaskEditorDialog type
interface MaskEditorDialog {
getMessageBroker(): MessageBroker
}
class ColorSelectTool {
// @ts-expect-error unused variable
private maskEditor!: MaskEditorDialog
private messageBroker!: MessageBroker
private width: number | null = null
private height: number | null = null
private canvas!: HTMLCanvasElement
private maskCTX!: CanvasRenderingContext2D
private imageCTX!: CanvasRenderingContext2D
private maskData: Uint8ClampedArray | null = null
private imageData: Uint8ClampedArray | null = null
private tolerance: number = 20
private livePreview: boolean = false
private lastPoint: Point | null = null
private colorComparisonMethod: ColorComparisonMethod =
ColorComparisonMethod.Simple
private applyWholeImage: boolean = false
private maskBoundry: boolean = false
private maskTolerance: number = 0
private selectOpacity: number = 255 // Add opacity property (default 100%)
constructor(maskEditor: MaskEditorDialog) {
this.maskEditor = maskEditor
this.messageBroker = maskEditor.getMessageBroker()
this.createListeners()
this.addPullTopics()
}
async initColorSelectTool() {
await this.pullCanvas()
}
private async pullCanvas() {
this.canvas = await this.messageBroker.pull('imgCanvas')
this.maskCTX = await this.messageBroker.pull('maskCtx')
this.imageCTX = await this.messageBroker.pull('imageCtx')
}
private createListeners() {
this.messageBroker.subscribe('colorSelectFill', (point: Point) =>
this.fillColorSelection(point)
)
this.messageBroker.subscribe(
'setColorSelectTolerance',
(tolerance: number) => this.setTolerance(tolerance)
)
this.messageBroker.subscribe('setLivePreview', (livePreview: boolean) =>
this.setLivePreview(livePreview)
)
this.messageBroker.subscribe(
'setColorComparisonMethod',
(method: ColorComparisonMethod) => this.setComparisonMethod(method)
)
this.messageBroker.subscribe('clearLastPoint', () => this.clearLastPoint())
this.messageBroker.subscribe('setWholeImage', (applyWholeImage: boolean) =>
this.setApplyWholeImage(applyWholeImage)
)
this.messageBroker.subscribe('setMaskBoundary', (maskBoundry: boolean) =>
this.setMaskBoundary(maskBoundry)
)
this.messageBroker.subscribe('setMaskTolerance', (maskTolerance: number) =>
this.setMaskTolerance(maskTolerance)
)
// Add new listener for opacity setting
this.messageBroker.subscribe('setSelectionOpacity', (opacity: number) =>
this.setSelectOpacity(opacity)
)
}
private async addPullTopics() {
this.messageBroker.createPullTopic(
'getLivePreview',
async () => this.livePreview
)
}
private getPixel(x: number, y: number): { r: number; g: number; b: number } {
const index = (y * this.width! + x) * 4
return {
r: this.imageData![index],
g: this.imageData![index + 1],
b: this.imageData![index + 2]
}
}
private getMaskAlpha(x: number, y: number): number {
return this.maskData![(y * this.width! + x) * 4 + 3]
}
private isPixelInRange(
pixel: { r: number; g: number; b: number },
target: { r: number; g: number; b: number }
): boolean {
switch (this.colorComparisonMethod) {
case ColorComparisonMethod.Simple:
return this.isPixelInRangeSimple(pixel, target)
case ColorComparisonMethod.HSL:
return this.isPixelInRangeHSL(pixel, target)
case ColorComparisonMethod.LAB:
return this.isPixelInRangeLab(pixel, target)
default:
return this.isPixelInRangeSimple(pixel, target)
}
}
private isPixelInRangeSimple(
pixel: { r: number; g: number; b: number },
target: { r: number; g: number; b: number }
): boolean {
//calculate the euclidean distance between the two colors
const distance = Math.sqrt(
Math.pow(pixel.r - target.r, 2) +
Math.pow(pixel.g - target.g, 2) +
Math.pow(pixel.b - target.b, 2)
)
return distance <= this.tolerance
}
private isPixelInRangeHSL(
pixel: { r: number; g: number; b: number },
target: { r: number; g: number; b: number }
): boolean {
// Convert RGB to HSL
const pixelHSL = this.rgbToHSL(pixel.r, pixel.g, pixel.b)
const targetHSL = this.rgbToHSL(target.r, target.g, target.b)
// Compare mainly hue and saturation, be more lenient with lightness
const hueDiff = Math.abs(pixelHSL.h - targetHSL.h)
const satDiff = Math.abs(pixelHSL.s - targetHSL.s)
const lightDiff = Math.abs(pixelHSL.l - targetHSL.l)
const distance = Math.sqrt(
Math.pow((hueDiff / 360) * 255, 2) +
Math.pow((satDiff / 100) * 255, 2) +
Math.pow((lightDiff / 100) * 255, 2)
)
return distance <= this.tolerance
}
private rgbToHSL(
r: number,
g: number,
b: number
): { h: number; s: number; l: number } {
r /= 255
g /= 255
b /= 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
let h = 0,
s = 0,
l = (max + min) / 2
if (max !== min) {
const d = max - min
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0)
break
case g:
h = (b - r) / d + 2
break
case b:
h = (r - g) / d + 4
break
}
h /= 6
}
return {
h: h * 360,
s: s * 100,
l: l * 100
}
}
private isPixelInRangeLab(
pixel: { r: number; g: number; b: number },
target: { r: number; g: number; b: number }
): boolean {
const pixelLab = this.rgbToLab(pixel)
const targetLab = this.rgbToLab(target)
// Calculate Delta E (CIE76 formula)
const deltaE = Math.sqrt(
Math.pow(pixelLab.l - targetLab.l, 2) +
Math.pow(pixelLab.a - targetLab.a, 2) +
Math.pow(pixelLab.b - targetLab.b, 2)
)
const normalizedDeltaE = (deltaE / 100) * 255
return normalizedDeltaE <= this.tolerance
}
private rgbToLab(rgb: { r: number; g: number; b: number }): {
l: number
a: number
b: number
} {
// First convert RGB to XYZ
let r = rgb.r / 255
let g = rgb.g / 255
let b = rgb.b / 255
r = r > 0.04045 ? Math.pow((r + 0.055) / 1.055, 2.4) : r / 12.92
g = g > 0.04045 ? Math.pow((g + 0.055) / 1.055, 2.4) : g / 12.92
b = b > 0.04045 ? Math.pow((b + 0.055) / 1.055, 2.4) : b / 12.92
r *= 100
g *= 100
b *= 100
const x = r * 0.4124 + g * 0.3576 + b * 0.1805
const y = r * 0.2126 + g * 0.7152 + b * 0.0722
const z = r * 0.0193 + g * 0.1192 + b * 0.9505
// Then XYZ to Lab
const xn = 95.047
const yn = 100.0
const zn = 108.883
const xyz = [x / xn, y / yn, z / zn]
for (let i = 0; i < xyz.length; i++) {
xyz[i] =
xyz[i] > 0.008856 ? Math.pow(xyz[i], 1 / 3) : 7.787 * xyz[i] + 16 / 116
}
return {
l: 116 * xyz[1] - 16,
a: 500 * (xyz[0] - xyz[1]),
b: 200 * (xyz[1] - xyz[2])
}
}
private setPixel(
x: number,
y: number,
alpha: number,
color: { r: number; g: number; b: number }
): void {
const index = (y * this.width! + x) * 4
this.maskData![index] = color.r // R
this.maskData![index + 1] = color.g // G
this.maskData![index + 2] = color.b // B
this.maskData![index + 3] = alpha // A
}
async fillColorSelection(point: Point) {
this.width = this.canvas.width
this.height = this.canvas.height
this.lastPoint = point
// Get image data
const maskData = this.maskCTX.getImageData(0, 0, this.width, this.height)
this.maskData = maskData.data
this.imageData = this.imageCTX.getImageData(
0,
0,
this.width,
this.height
).data
if (this.applyWholeImage) {
// Process entire image
const targetPixel = this.getPixel(
Math.floor(point.x),
Math.floor(point.y)
)
const maskColor = await this.messageBroker.pull<{
r: number
g: number
b: number
}>('getMaskColor')
// Use TypedArrays for better performance
const width = this.width!
const height = this.height!
// Process in chunks for better performance
const CHUNK_SIZE = 10000
for (let i = 0; i < width * height; i += CHUNK_SIZE) {
const endIndex = Math.min(i + CHUNK_SIZE, width * height)
for (let pixelIndex = i; pixelIndex < endIndex; pixelIndex++) {
const x = pixelIndex % width
const y = Math.floor(pixelIndex / width)
if (this.isPixelInRange(this.getPixel(x, y), targetPixel)) {
this.setPixel(x, y, this.selectOpacity, maskColor) // Use selectOpacity instead of 255
}
}
// Allow UI updates between chunks
await new Promise((resolve) => setTimeout(resolve, 0))
}
} else {
// Original flood fill logic
let startX = Math.floor(point.x)
let startY = Math.floor(point.y)
if (
startX < 0 ||
startX >= this.width ||
startY < 0 ||
startY >= this.height
) {
return
}
const pixel = this.getPixel(startX, startY)
const stack: Array<[number, number]> = []
const visited = new Uint8Array(this.width * this.height)
stack.push([startX, startY])
const maskColor = await this.messageBroker.pull<{
r: number
g: number
b: number
}>('getMaskColor')
while (stack.length > 0) {
const [x, y] = stack.pop()!
const visitedIndex = y * this.width + x
if (
visited[visitedIndex] ||
!this.isPixelInRange(this.getPixel(x, y), pixel)
) {
continue
}
visited[visitedIndex] = 1
this.setPixel(x, y, this.selectOpacity, maskColor) // Use selectOpacity instead of 255
// Inline direction checks for better performance
if (
x > 0 &&
!visited[y * this.width + (x - 1)] &&
this.isPixelInRange(this.getPixel(x - 1, y), pixel)
) {
if (
!this.maskBoundry ||
255 - this.getMaskAlpha(x - 1, y) > this.maskTolerance
) {
stack.push([x - 1, y])
}
}
if (
x < this.width - 1 &&
!visited[y * this.width + (x + 1)] &&
this.isPixelInRange(this.getPixel(x + 1, y), pixel)
) {
if (
!this.maskBoundry ||
255 - this.getMaskAlpha(x + 1, y) > this.maskTolerance
) {
stack.push([x + 1, y])
}
}
if (
y > 0 &&
!visited[(y - 1) * this.width + x] &&
this.isPixelInRange(this.getPixel(x, y - 1), pixel)
) {
if (
!this.maskBoundry ||
255 - this.getMaskAlpha(x, y - 1) > this.maskTolerance
) {
stack.push([x, y - 1])
}
}
if (
y < this.height - 1 &&
!visited[(y + 1) * this.width + x] &&
this.isPixelInRange(this.getPixel(x, y + 1), pixel)
) {
if (
!this.maskBoundry ||
255 - this.getMaskAlpha(x, y + 1) > this.maskTolerance
) {
stack.push([x, y + 1])
}
}
}
}
this.maskCTX.putImageData(maskData, 0, 0)
this.messageBroker.publish('saveState')
this.maskData = null
this.imageData = null
}
setTolerance(tolerance: number): void {
this.tolerance = tolerance
if (this.lastPoint && this.livePreview) {
this.messageBroker.publish('undo')
this.fillColorSelection(this.lastPoint)
}
}
setLivePreview(livePreview: boolean): void {
this.livePreview = livePreview
}
setComparisonMethod(method: ColorComparisonMethod): void {
this.colorComparisonMethod = method
if (this.lastPoint && this.livePreview) {
this.messageBroker.publish('undo')
this.fillColorSelection(this.lastPoint)
}
}
clearLastPoint() {
this.lastPoint = null
}
setApplyWholeImage(applyWholeImage: boolean): void {
this.applyWholeImage = applyWholeImage
}
setMaskBoundary(maskBoundry: boolean): void {
this.maskBoundry = maskBoundry
}
setMaskTolerance(maskTolerance: number): void {
this.maskTolerance = maskTolerance
}
// Add method to set opacity
setSelectOpacity(opacity: number): void {
// Convert from percentage (0-100) to alpha value (0-255)
this.selectOpacity = Math.floor((opacity / 100) * 255)
// Update preview if applicable
if (this.lastPoint && this.livePreview) {
this.messageBroker.publish('undo')
this.fillColorSelection(this.lastPoint)
}
}
}
export { ColorSelectTool }

View File

@@ -1,265 +0,0 @@
import type { Point } from '../types'
// Forward declaration for MessageBroker type
interface MessageBroker {
subscribe(topic: string, callback: (data?: any) => void): void
publish(topic: string, data?: any): void
pull<T>(topic: string, data?: any): Promise<T>
createPullTopic(topic: string, callback: (data?: any) => Promise<any>): void
}
// Forward declaration for MaskEditorDialog type
interface MaskEditorDialog {
getMessageBroker(): MessageBroker
}
class PaintBucketTool {
maskEditor: MaskEditorDialog
messageBroker: MessageBroker
private canvas!: HTMLCanvasElement
private ctx!: CanvasRenderingContext2D
private width: number | null = null
private height: number | null = null
private imageData: ImageData | null = null
private data: Uint8ClampedArray | null = null
private tolerance: number = 5
private fillOpacity: number = 255 // Add opacity property (default 100%)
constructor(maskEditor: MaskEditorDialog) {
this.maskEditor = maskEditor
this.messageBroker = maskEditor.getMessageBroker()
this.createListeners()
this.addPullTopics()
}
initPaintBucketTool() {
this.pullCanvas()
}
private async pullCanvas() {
this.canvas = await this.messageBroker.pull('maskCanvas')
this.ctx = await this.messageBroker.pull('maskCtx')
}
private createListeners() {
this.messageBroker.subscribe(
'setPaintBucketTolerance',
(tolerance: number) => this.setTolerance(tolerance)
)
this.messageBroker.subscribe('paintBucketFill', (point: Point) =>
this.floodFill(point)
)
this.messageBroker.subscribe('invert', () => this.invertMask())
// Add new listener for opacity setting
this.messageBroker.subscribe('setFillOpacity', (opacity: number) =>
this.setFillOpacity(opacity)
)
}
private addPullTopics() {
this.messageBroker.createPullTopic(
'getTolerance',
async () => this.tolerance
)
// Add pull topic for fillOpacity
this.messageBroker.createPullTopic(
'getFillOpacity',
async () => (this.fillOpacity / 255) * 100
)
}
// Add method to set opacity
setFillOpacity(opacity: number): void {
// Convert from percentage (0-100) to alpha value (0-255)
this.fillOpacity = Math.floor((opacity / 100) * 255)
}
private getPixel(x: number, y: number): number {
return this.data![(y * this.width! + x) * 4 + 3]
}
private setPixel(
x: number,
y: number,
alpha: number,
color: { r: number; g: number; b: number }
): void {
const index = (y * this.width! + x) * 4
this.data![index] = color.r // R
this.data![index + 1] = color.g // G
this.data![index + 2] = color.b // B
this.data![index + 3] = alpha // A
}
private shouldProcessPixel(
currentAlpha: number,
targetAlpha: number,
tolerance: number,
isFillMode: boolean
): boolean {
if (currentAlpha === -1) return false
if (isFillMode) {
// Fill mode: process pixels that are empty/similar to target
return (
currentAlpha !== 255 &&
Math.abs(currentAlpha - targetAlpha) <= tolerance
)
} else {
// Erase mode: process pixels that are filled/similar to target
return (
currentAlpha === 255 ||
Math.abs(currentAlpha - targetAlpha) <= tolerance
)
}
}
private async floodFill(point: Point): Promise<void> {
let startX = Math.floor(point.x)
let startY = Math.floor(point.y)
this.width = this.canvas.width
this.height = this.canvas.height
if (
startX < 0 ||
startX >= this.width ||
startY < 0 ||
startY >= this.height
) {
return
}
this.imageData = this.ctx.getImageData(0, 0, this.width, this.height)
this.data = this.imageData.data
const targetAlpha = this.getPixel(startX, startY)
const isFillMode = targetAlpha !== 255 // Determine mode based on clicked pixel
if (targetAlpha === -1) return
const maskColor = await this.messageBroker.pull<{
r: number
g: number
b: number
}>('getMaskColor')
const stack: Array<[number, number]> = []
const visited = new Uint8Array(this.width * this.height)
if (
this.shouldProcessPixel(
targetAlpha,
targetAlpha,
this.tolerance,
isFillMode
)
) {
stack.push([startX, startY])
}
while (stack.length > 0) {
const [x, y] = stack.pop()!
const visitedIndex = y * this.width + x
if (visited[visitedIndex]) continue
const currentAlpha = this.getPixel(x, y)
if (
!this.shouldProcessPixel(
currentAlpha,
targetAlpha,
this.tolerance,
isFillMode
)
) {
continue
}
visited[visitedIndex] = 1
// Set alpha to fillOpacity for fill mode, 0 for erase mode
this.setPixel(x, y, isFillMode ? this.fillOpacity : 0, maskColor)
// Check neighbors
const checkNeighbor = (nx: number, ny: number) => {
if (nx < 0 || nx >= this.width! || ny < 0 || ny >= this.height!) return
if (!visited[ny * this.width! + nx]) {
const alpha = this.getPixel(nx, ny)
if (
this.shouldProcessPixel(
alpha,
targetAlpha,
this.tolerance,
isFillMode
)
) {
stack.push([nx, ny])
}
}
}
checkNeighbor(x - 1, y) // Left
checkNeighbor(x + 1, y) // Right
checkNeighbor(x, y - 1) // Up
checkNeighbor(x, y + 1) // Down
}
this.ctx.putImageData(this.imageData, 0, 0)
this.imageData = null
this.data = null
}
setTolerance(tolerance: number): void {
this.tolerance = tolerance
}
getTolerance(): number {
return this.tolerance
}
//invert mask
private invertMask() {
const imageData = this.ctx.getImageData(
0,
0,
this.canvas.width,
this.canvas.height
)
const data = imageData.data
// Find first non-transparent pixel to get mask color
let maskR = 0,
maskG = 0,
maskB = 0
for (let i = 0; i < data.length; i += 4) {
if (data[i + 3] > 0) {
maskR = data[i]
maskG = data[i + 1]
maskB = data[i + 2]
break
}
}
// Process each pixel
for (let i = 0; i < data.length; i += 4) {
const alpha = data[i + 3]
// Invert alpha channel (0 becomes 255, 255 becomes 0)
data[i + 3] = 255 - alpha
// If this was originally transparent (now opaque), fill with mask color
if (alpha === 0) {
data[i] = maskR
data[i + 1] = maskG
data[i + 2] = maskB
}
}
this.ctx.putImageData(imageData, 0, 0)
this.messageBroker.publish('saveState')
}
}
export { PaintBucketTool }

View File

@@ -1,3 +0,0 @@
export { PaintBucketTool } from './PaintBucketTool'
export { ColorSelectTool } from './ColorSelectTool'
export { BrushTool } from './BrushTool'

View File

@@ -19,7 +19,8 @@ export const allTools = [
Tools.MaskColorFill
]
export const allImageLayers = ['mask', 'rgb'] as const
const allImageLayers = ['mask', 'rgb'] as const
export type ImageLayer = (typeof allImageLayers)[number]
export interface ToolInternalSettings {
@@ -62,14 +63,3 @@ export interface Brush {
hardness: number
smoothingPrecision: number
}
export type Callback = (data?: any) => void
export type Ref = { filename: string; subfolder?: string; type?: string }
// Forward declaration for MaskEditorDialog
export interface MaskEditorDialog {
getMessageBroker(): any // Will be MessageBroker, but avoiding circular dependency
save(): void
destroy(): void
}

View File

@@ -1,32 +0,0 @@
import { debounce } from 'es-toolkit/compat'
import { getStorageValue, setStorageValue } from '@/scripts/utils'
import type { Brush } from '../types'
export const saveBrushToCache = debounce(function (
key: string,
brush: Brush
): void {
try {
const brushString = JSON.stringify(brush)
setStorageValue(key, brushString)
} catch (error) {
console.error('Failed to save brush to cache:', error)
}
}, 300)
export function loadBrushFromCache(key: string): Brush | null {
try {
const brushString = getStorageValue(key)
if (brushString) {
const brush = JSON.parse(brushString) as Brush
console.log('Loaded brush from cache:', brush)
return brush
} else {
console.log('No brush found in cache.')
return null
}
} catch (error) {
console.error('Failed to load brush from cache:', error)
return null
}
}

View File

@@ -1,39 +0,0 @@
export const getCanvas2dContext = (
canvas: HTMLCanvasElement
): CanvasRenderingContext2D => {
const ctx = canvas.getContext('2d', { willReadFrequently: true })
// Safe with the way we use canvases
if (!ctx) throw new Error('Failed to get 2D context from canvas')
return ctx
}
export const createCanvasCopy = (
canvas: HTMLCanvasElement
): [HTMLCanvasElement, CanvasRenderingContext2D] => {
const newCanvas = document.createElement('canvas')
const newCanvasCtx = getCanvas2dContext(newCanvas)
newCanvas.width = canvas.width
newCanvas.height = canvas.height
newCanvasCtx.clearRect(0, 0, canvas.width, canvas.height)
newCanvasCtx.drawImage(
canvas,
0,
0,
canvas.width,
canvas.height,
0,
0,
canvas.width,
canvas.height
)
return [newCanvas, newCanvasCtx]
}
export const combineOriginalImageAndPaint = (
canvases: Record<'originalImage' | 'paint', HTMLCanvasElement>
): [HTMLCanvasElement, CanvasRenderingContext2D] => {
const { originalImage, paint } = canvases
const [resultCanvas, resultCanvasCtx] = createCanvasCopy(originalImage)
resultCanvasCtx.drawImage(paint, 0, 0)
return [resultCanvas, resultCanvasCtx]
}

View File

@@ -1,32 +0,0 @@
import { ComfyApp } from '@/scripts/app'
import type { Ref } from '../types'
/**
* Note: the images' positions are important here. What the positions mean is hardcoded in `src/scripts/app.ts` in the `copyToClipspace` method.
* - `newMainOutput` should be the fully composited image: base image + mask (in the alpha channel) + paint.
* - The first array element of `extraImagesShownButNotOutputted` should be JUST the paint layer, with a transparent background.
* - It is possible to add more images in the clipspace array, but is not useful currently.
* With this configuration, the MaskEditor will properly load the paint layer separately from the base image, ensuring it is editable.
* */
export const replaceClipspaceImages = (
newMainOutput: Ref,
otherImagesInClipspace?: Ref[]
) => {
try {
if (!ComfyApp?.clipspace?.widgets?.length) return
const firstImageWidgetIndex = ComfyApp.clipspace.widgets.findIndex(
(obj) => obj?.name === 'image'
)
const firstImageWidget = ComfyApp.clipspace.widgets[firstImageWidgetIndex]
if (!firstImageWidget) return
ComfyApp!.clipspace!.widgets![firstImageWidgetIndex].value = newMainOutput
otherImagesInClipspace?.forEach((extraImage, extraImageIndex) => {
const extraImageWidgetIndex = firstImageWidgetIndex + extraImageIndex + 1
ComfyApp!.clipspace!.widgets![extraImageWidgetIndex].value = extraImage
})
} catch (err) {
console.warn('Failed to set widget value:', err)
}
}

View File

@@ -1,62 +0,0 @@
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import type { Ref } from '../types'
export const ensureImageFullyLoaded = (src: string) =>
new Promise<void>((resolve, reject) => {
const maskImage = new Image()
maskImage.src = src
maskImage.onload = () => resolve()
maskImage.onerror = reject
})
const isAlphaValue = (index: number) => index % 4 === 3
export const removeImageRgbValuesAndInvertAlpha = (
imageData: Uint8ClampedArray
) => imageData.map((val, i) => (isAlphaValue(i) ? 255 - val : 0))
export const toRef = (filename: string): Ref => ({
filename,
subfolder: 'clipspace',
type: 'input'
})
export const mkFileUrl = (props: { ref: Ref; preview?: boolean }) => {
const pathPlusQueryParams = api.apiURL(
'/view?' +
new URLSearchParams(props.ref).toString() +
app.getPreviewFormatParam() +
app.getRandParam()
)
const imageElement = new Image()
imageElement.src = pathPlusQueryParams
return imageElement.src
}
export const requestWithRetries = async (
mkRequest: () => Promise<Response>,
maxRetries: number = 3
): Promise<{ success: boolean }> => {
let attempt = 0
let success = false
while (attempt < maxRetries && !success) {
try {
const response = await mkRequest()
if (response.ok) {
success = true
} else {
console.log('Failed to upload mask:', response)
}
} catch (error) {
console.error(`Upload attempt ${attempt + 1} failed:`, error)
attempt++
if (attempt < maxRetries) {
console.log('Retrying upload...')
} else {
console.log('Max retries reached. Upload failed.')
}
}
}
return { success }
}

View File

@@ -1,14 +0,0 @@
export {
toRef,
mkFileUrl,
ensureImageFullyLoaded,
removeImageRgbValuesAndInvertAlpha,
requestWithRetries
} from './image'
export { imageLayerFilenamesIfApplicable } from './maskEditorLayerFilenames'
export {
getCanvas2dContext,
createCanvasCopy,
combineOriginalImageAndPaint
} from './canvas'
export { replaceClipspaceImages } from './clipspace'

View File

@@ -1,29 +0,0 @@
interface ImageLayerFilenames {
maskedImage: string
paint: string
paintedImage: string
paintedMaskedImage: string
}
const paintedMaskedImagePrefix = 'clipspace-painted-masked-'
export const imageLayerFilenamesByTimestamp = (
timestamp: number
): ImageLayerFilenames => ({
maskedImage: `clipspace-mask-${timestamp}.png`,
paint: `clipspace-paint-${timestamp}.png`,
paintedImage: `clipspace-painted-${timestamp}.png`,
paintedMaskedImage: `${paintedMaskedImagePrefix}${timestamp}.png`
})
export const imageLayerFilenamesIfApplicable = (
inputImageFilename: string
): ImageLayerFilenames | undefined => {
const isPaintedMaskedImageFilename = inputImageFilename.startsWith(
paintedMaskedImagePrefix
)
if (!isPaintedMaskedImageFilename) return undefined
const suffix = inputImageFilename.slice(paintedMaskedImagePrefix.length)
const timestamp = parseInt(suffix.split('.')[0], 10)
return imageLayerFilenamesByTimestamp(timestamp)
}

View File

@@ -878,29 +878,42 @@
"cancelled": "Cancelled"
},
"maskEditor": {
"Invert": "Invert",
"Clear": "Clear",
"Brush Settings": "Brush Settings",
"Brush Shape": "Brush Shape",
"Thickness": "Thickness",
"Opacity": "Opacity",
"Hardness": "Hardness",
"Smoothing Precision": "Smoothing Precision",
"Reset to Default": "Reset to Default",
"Paint Bucket Settings": "Paint Bucket Settings",
"Tolerance": "Tolerance",
"Fill Opacity": "Fill Opacity",
"Color Select Settings": "Color Select Settings",
"Selection Opacity": "Selection Opacity",
"Live Preview": "Live Preview",
"Apply to Whole Image": "Apply to Whole Image",
"Method": "Method",
"Stop at mask": "Stop at mask",
"Mask Tolerance": "Mask Tolerance",
"Layers": "Layers",
"Mask Layer": "Mask Layer",
"Mask Opacity": "Mask Opacity",
"Image Layer": "Image Layer"
"title": "Mask Editor",
"invert": "Invert",
"clear": "Clear",
"undo": "Undo",
"redo": "Redo",
"clickToResetZoom": "Click to reset zoom",
"brushSettings": "Brush Settings",
"brushShape": "Brush Shape",
"colorSelector": "Color Selector",
"thickness": "Thickness",
"opacity": "Opacity",
"hardness": "Hardness",
"smoothingPrecision": "Smoothing Precision",
"resetToDefault": "Reset to Default",
"paintBucketSettings": "Paint Bucket Settings",
"tolerance": "Tolerance",
"fillOpacity": "Fill Opacity",
"colorSelectSettings": "Color Select Settings",
"selectionOpacity": "Selection Opacity",
"livePreview": "Live Preview",
"applyToWholeImage": "Apply to Whole Image",
"method": "Method",
"stopAtMask": "Stop at mask",
"maskTolerance": "Mask Tolerance",
"layers": "Layers",
"maskLayer": "Mask Layer",
"maskOpacity": "Mask Opacity",
"imageLayer": "Image Layer",
"maskBlendingOptions": "Mask Blending Options",
"paintLayer": "Paint Layer",
"baseImageLayer": "Base Image Layer",
"activateLayer": "Activate Layer",
"baseLayerPreview": "Base layer preview",
"black": "Black",
"white": "White",
"negative": "Negative"
},
"commands": {
"runWorkflow": "Run workflow",

View File

@@ -0,0 +1,80 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
export interface ImageRef {
filename: string
subfolder?: string
type?: string
}
export interface ImageLayer {
image: HTMLImageElement
url: string
}
interface EditorInputData {
baseLayer: ImageLayer
maskLayer: ImageLayer
paintLayer?: ImageLayer
sourceRef: ImageRef
nodeId: NodeId
}
export interface EditorOutputLayer {
canvas: HTMLCanvasElement
blob: Blob
ref: ImageRef
}
export interface EditorOutputData {
maskedImage: EditorOutputLayer
paintLayer: EditorOutputLayer
paintedImage: EditorOutputLayer
paintedMaskedImage: EditorOutputLayer
}
export const useMaskEditorDataStore = defineStore('maskEditorData', () => {
const inputData = ref<EditorInputData | null>(null)
const outputData = ref<EditorOutputData | null>(null)
const sourceNode = ref<LGraphNode | null>(null)
const isLoading = ref(false)
const loadError = ref<string | null>(null)
const hasValidInput = computed(() => inputData.value !== null)
const hasValidOutput = computed(() => outputData.value !== null)
const isReady = computed(() => hasValidInput.value && !isLoading.value)
const reset = () => {
inputData.value = null
outputData.value = null
sourceNode.value = null
isLoading.value = false
loadError.value = null
}
const setLoading = (loading: boolean, error?: string) => {
isLoading.value = loading
if (error) {
loadError.value = error
}
}
return {
inputData,
outputData,
sourceNode,
isLoading,
loadError,
hasValidInput,
hasValidOutput,
isReady,
reset,
setLoading
}
})

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
}
})

View File

@@ -0,0 +1,495 @@
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
const mockStore = {
maskCanvas: null as any,
rgbCanvas: null as any,
maskCtx: null as any,
rgbCtx: null as any
}
vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: vi.fn(() => mockStore)
}))
describe('useCanvasHistory', () => {
beforeEach(() => {
vi.clearAllMocks()
let rafCallCount = 0
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(
(cb: FrameRequestCallback) => {
if (rafCallCount++ < 100) {
setTimeout(() => cb(0), 0)
}
return rafCallCount
}
)
vi.spyOn(window, 'alert').mockImplementation(() => {})
const createMockImageData = () => {
return {
data: new Uint8ClampedArray(100 * 100 * 4),
width: 100,
height: 100
} as ImageData
}
mockMaskCtx = {
getImageData: vi.fn(() => createMockImageData()),
putImageData: vi.fn()
}
mockRgbCtx = {
getImageData: vi.fn(() => createMockImageData()),
putImageData: vi.fn()
}
mockMaskCanvas = {
width: 100,
height: 100
}
mockRgbCanvas = {
width: 100,
height: 100
}
mockStore.maskCanvas = mockMaskCanvas
mockStore.rgbCanvas = mockRgbCanvas
mockStore.maskCtx = mockMaskCtx
mockStore.rgbCtx = mockRgbCtx
})
describe('initialization', () => {
it('should initialize with default values', () => {
const history = useCanvasHistory()
expect(history.canUndo.value).toBe(false)
expect(history.canRedo.value).toBe(false)
})
it('should save initial state', () => {
const history = useCanvasHistory()
history.saveInitialState()
expect(mockMaskCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
expect(mockRgbCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
expect(history.canUndo.value).toBe(false)
expect(history.canRedo.value).toBe(false)
})
it('should wait for canvas to be ready', () => {
const rafSpy = vi.spyOn(window, 'requestAnimationFrame')
mockStore.maskCanvas = { ...mockMaskCanvas, width: 0, height: 0 }
const history = useCanvasHistory()
history.saveInitialState()
expect(rafSpy).toHaveBeenCalled()
mockStore.maskCanvas = mockMaskCanvas
})
it('should wait for context to be ready', () => {
const rafSpy = vi.spyOn(window, 'requestAnimationFrame')
mockStore.maskCtx = null
const history = useCanvasHistory()
history.saveInitialState()
expect(rafSpy).toHaveBeenCalled()
mockStore.maskCtx = mockMaskCtx
})
})
describe('saveState', () => {
it('should save a new state', () => {
const history = useCanvasHistory()
history.saveInitialState()
mockMaskCtx.getImageData.mockClear()
mockRgbCtx.getImageData.mockClear()
history.saveState()
expect(mockMaskCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
expect(mockRgbCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
expect(history.canUndo.value).toBe(true)
})
it('should clear redo states when saving new state', () => {
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
history.saveState()
history.undo()
expect(history.canRedo.value).toBe(true)
history.saveState()
expect(history.canRedo.value).toBe(false)
})
it('should respect maxStates limit', () => {
const history = useCanvasHistory(3)
history.saveInitialState()
history.saveState()
history.saveState()
history.saveState()
history.saveState()
expect(history.canUndo.value).toBe(true)
let undoCount = 0
while (history.canUndo.value && undoCount < 10) {
history.undo()
undoCount++
}
expect(undoCount).toBe(2)
})
it('should call saveInitialState if not initialized', () => {
const history = useCanvasHistory()
history.saveState()
expect(mockMaskCtx.getImageData).toHaveBeenCalled()
expect(mockRgbCtx.getImageData).toHaveBeenCalled()
})
it('should not save state if context is missing', () => {
const history = useCanvasHistory()
history.saveInitialState()
mockStore.maskCtx = null
mockMaskCtx.getImageData.mockClear()
mockRgbCtx.getImageData.mockClear()
history.saveState()
expect(mockMaskCtx.getImageData).not.toHaveBeenCalled()
mockStore.maskCtx = mockMaskCtx
})
})
describe('undo', () => {
it('should undo to previous state', () => {
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
history.undo()
expect(mockMaskCtx.putImageData).toHaveBeenCalled()
expect(mockRgbCtx.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')
const history = useCanvasHistory()
history.saveInitialState()
history.undo()
expect(alertSpy).toHaveBeenCalledWith('No more undo states available')
})
it('should undo multiple times', () => {
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
history.saveState()
history.saveState()
history.undo()
expect(history.canUndo.value).toBe(true)
history.undo()
expect(history.canUndo.value).toBe(true)
history.undo()
expect(history.canUndo.value).toBe(false)
})
it('should not undo beyond first state', () => {
const alertSpy = vi.spyOn(window, 'alert')
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
history.undo()
history.undo()
expect(alertSpy).toHaveBeenCalled()
})
})
describe('redo', () => {
it('should redo to next state', () => {
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
history.undo()
mockMaskCtx.putImageData.mockClear()
mockRgbCtx.putImageData.mockClear()
history.redo()
expect(mockMaskCtx.putImageData).toHaveBeenCalled()
expect(mockRgbCtx.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')
const history = useCanvasHistory()
history.saveInitialState()
history.redo()
expect(alertSpy).toHaveBeenCalledWith('No more redo states available')
})
it('should redo multiple times', () => {
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
history.saveState()
history.saveState()
history.undo()
history.undo()
history.undo()
history.redo()
expect(history.canRedo.value).toBe(true)
history.redo()
expect(history.canRedo.value).toBe(true)
history.redo()
expect(history.canRedo.value).toBe(false)
})
it('should not redo beyond last state', () => {
const alertSpy = vi.spyOn(window, 'alert')
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
history.undo()
history.redo()
history.redo()
expect(alertSpy).toHaveBeenCalled()
})
})
describe('clearStates', () => {
it('should clear all states', () => {
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
history.saveState()
history.clearStates()
expect(history.canUndo.value).toBe(false)
expect(history.canRedo.value).toBe(false)
})
it('should allow saving initial state after clear', () => {
const history = useCanvasHistory()
history.saveInitialState()
history.clearStates()
mockMaskCtx.getImageData.mockClear()
mockRgbCtx.getImageData.mockClear()
history.saveInitialState()
expect(mockMaskCtx.getImageData).toHaveBeenCalled()
expect(mockRgbCtx.getImageData).toHaveBeenCalled()
})
})
describe('canUndo computed', () => {
it('should be false with no states', () => {
const history = useCanvasHistory()
expect(history.canUndo.value).toBe(false)
})
it('should be false with only initial state', () => {
const history = useCanvasHistory()
history.saveInitialState()
expect(history.canUndo.value).toBe(false)
})
it('should be true after saving a state', () => {
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
expect(history.canUndo.value).toBe(true)
})
it('should be false after undoing to first state', () => {
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
history.undo()
expect(history.canUndo.value).toBe(false)
})
})
describe('canRedo computed', () => {
it('should be false with no undo', () => {
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
expect(history.canRedo.value).toBe(false)
})
it('should be true after undo', () => {
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
history.undo()
expect(history.canRedo.value).toBe(true)
})
it('should be false after redo to last state', () => {
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
history.undo()
history.redo()
expect(history.canRedo.value).toBe(false)
})
it('should be false after saving new state', () => {
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
history.undo()
expect(history.canRedo.value).toBe(true)
history.saveState()
expect(history.canRedo.value).toBe(false)
})
})
describe('restoreState', () => {
it('should not restore if context is missing', () => {
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
mockStore.maskCtx = null
mockMaskCtx.putImageData.mockClear()
mockRgbCtx.putImageData.mockClear()
history.undo()
expect(mockMaskCtx.putImageData).not.toHaveBeenCalled()
mockStore.maskCtx = mockMaskCtx
})
})
describe('edge cases', () => {
it('should handle rapid state saves', async () => {
const history = useCanvasHistory()
history.saveInitialState()
for (let i = 0; i < 10; i++) {
history.saveState()
await nextTick()
}
expect(history.canUndo.value).toBe(true)
})
it('should handle maxStates of 1', () => {
const history = useCanvasHistory(1)
history.saveInitialState()
history.saveState()
expect(history.canUndo.value).toBe(false)
})
it('should handle undo/redo cycling', () => {
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
history.saveState()
history.undo()
history.redo()
history.undo()
history.redo()
history.undo()
expect(history.canRedo.value).toBe(true)
expect(history.canUndo.value).toBe(true)
})
it('should handle zero-sized canvas', () => {
mockMaskCanvas.width = 0
mockMaskCanvas.height = 0
const history = useCanvasHistory()
history.saveInitialState()
expect(window.requestAnimationFrame).toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,336 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { MaskBlendMode } from '@/extensions/core/maskeditor/types'
import { useCanvasManager } from '@/composables/maskeditor/useCanvasManager'
const mockStore = {
imgCanvas: null as any,
maskCanvas: null as any,
rgbCanvas: null as any,
imgCtx: null as any,
maskCtx: null as any,
rgbCtx: null as any,
canvasBackground: null as any,
maskColor: { r: 0, g: 0, b: 0 },
maskBlendMode: MaskBlendMode.Black,
maskOpacity: 0.8
}
vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: vi.fn(() => mockStore)
}))
function createMockImage(width: number, height: number): HTMLImageElement {
return {
width,
height
} as HTMLImageElement
}
describe('useCanvasManager', () => {
let mockImageData: ImageData
beforeEach(() => {
vi.clearAllMocks()
mockImageData = {
data: new Uint8ClampedArray(100 * 100 * 4),
width: 100,
height: 100
} as ImageData
mockStore.imgCtx = {
drawImage: vi.fn()
}
mockStore.maskCtx = {
drawImage: vi.fn(),
getImageData: vi.fn(() => mockImageData),
putImageData: vi.fn(),
globalCompositeOperation: 'source-over',
fillStyle: ''
}
mockStore.rgbCtx = {
drawImage: vi.fn()
}
mockStore.imgCanvas = {
width: 0,
height: 0
}
mockStore.maskCanvas = {
width: 0,
height: 0,
style: {
mixBlendMode: '',
opacity: ''
}
}
mockStore.rgbCanvas = {
width: 0,
height: 0
}
mockStore.canvasBackground = {
style: {
backgroundColor: ''
}
}
mockStore.maskColor = { r: 0, g: 0, b: 0 }
mockStore.maskBlendMode = MaskBlendMode.Black
mockStore.maskOpacity = 0.8
})
describe('invalidateCanvas', () => {
it('should set canvas dimensions', async () => {
const manager = useCanvasManager()
const origImage = createMockImage(512, 512)
const maskImage = createMockImage(512, 512)
await manager.invalidateCanvas(origImage, maskImage, null)
expect(mockStore.imgCanvas.width).toBe(512)
expect(mockStore.imgCanvas.height).toBe(512)
expect(mockStore.maskCanvas.width).toBe(512)
expect(mockStore.maskCanvas.height).toBe(512)
expect(mockStore.rgbCanvas.width).toBe(512)
expect(mockStore.rgbCanvas.height).toBe(512)
})
it('should draw original image', async () => {
const manager = useCanvasManager()
const origImage = createMockImage(512, 512)
const maskImage = createMockImage(512, 512)
await manager.invalidateCanvas(origImage, maskImage, null)
expect(mockStore.imgCtx.drawImage).toHaveBeenCalledWith(
origImage,
0,
0,
512,
512
)
})
it('should draw paint image when provided', async () => {
const manager = useCanvasManager()
const origImage = createMockImage(512, 512)
const maskImage = createMockImage(512, 512)
const paintImage = createMockImage(512, 512)
await manager.invalidateCanvas(origImage, maskImage, paintImage)
expect(mockStore.rgbCtx.drawImage).toHaveBeenCalledWith(
paintImage,
0,
0,
512,
512
)
})
it('should not draw paint image when null', async () => {
const manager = useCanvasManager()
const origImage = createMockImage(512, 512)
const maskImage = createMockImage(512, 512)
await manager.invalidateCanvas(origImage, maskImage, null)
expect(mockStore.rgbCtx.drawImage).not.toHaveBeenCalled()
})
it('should prepare mask', async () => {
const manager = useCanvasManager()
const origImage = createMockImage(512, 512)
const maskImage = createMockImage(512, 512)
await manager.invalidateCanvas(origImage, maskImage, null)
expect(mockStore.maskCtx.drawImage).toHaveBeenCalled()
expect(mockStore.maskCtx.getImageData).toHaveBeenCalled()
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
})
it('should throw error when canvas missing', async () => {
const manager = useCanvasManager()
mockStore.imgCanvas = null
const origImage = createMockImage(512, 512)
const maskImage = createMockImage(512, 512)
await expect(
manager.invalidateCanvas(origImage, maskImage, null)
).rejects.toThrow('Canvas elements or contexts not available')
})
it('should throw error when context missing', async () => {
const manager = useCanvasManager()
mockStore.imgCtx = null
const origImage = createMockImage(512, 512)
const maskImage = createMockImage(512, 512)
await expect(
manager.invalidateCanvas(origImage, maskImage, null)
).rejects.toThrow('Canvas elements or contexts not available')
})
})
describe('updateMaskColor', () => {
it('should update mask color for black blend mode', async () => {
const manager = useCanvasManager()
mockStore.maskBlendMode = MaskBlendMode.Black
mockStore.maskColor = { r: 0, g: 0, b: 0 }
await manager.updateMaskColor()
expect(mockStore.maskCtx.fillStyle).toBe('rgb(0, 0, 0)')
expect(mockStore.maskCanvas.style.mixBlendMode).toBe('initial')
expect(mockStore.maskCanvas.style.opacity).toBe('0.8')
expect(mockStore.canvasBackground.style.backgroundColor).toBe(
'rgba(0,0,0,1)'
)
})
it('should update mask color for white blend mode', async () => {
const manager = useCanvasManager()
mockStore.maskBlendMode = MaskBlendMode.White
mockStore.maskColor = { r: 255, g: 255, b: 255 }
await manager.updateMaskColor()
expect(mockStore.maskCtx.fillStyle).toBe('rgb(255, 255, 255)')
expect(mockStore.maskCanvas.style.mixBlendMode).toBe('initial')
expect(mockStore.canvasBackground.style.backgroundColor).toBe(
'rgba(255,255,255,1)'
)
})
it('should update mask color for negative blend mode', async () => {
const manager = useCanvasManager()
mockStore.maskBlendMode = MaskBlendMode.Negative
mockStore.maskColor = { r: 255, g: 255, b: 255 }
await manager.updateMaskColor()
expect(mockStore.maskCanvas.style.mixBlendMode).toBe('difference')
expect(mockStore.maskCanvas.style.opacity).toBe('1')
expect(mockStore.canvasBackground.style.backgroundColor).toBe(
'rgba(255,255,255,1)'
)
})
it('should update all pixels with mask color', async () => {
const manager = useCanvasManager()
mockStore.maskColor = { r: 128, g: 64, b: 32 }
mockStore.maskCanvas.width = 100
mockStore.maskCanvas.height = 100
await manager.updateMaskColor()
for (let i = 0; i < mockImageData.data.length; i += 4) {
expect(mockImageData.data[i]).toBe(128)
expect(mockImageData.data[i + 1]).toBe(64)
expect(mockImageData.data[i + 2]).toBe(32)
}
expect(mockStore.maskCtx.putImageData).toHaveBeenCalledWith(
mockImageData,
0,
0
)
})
it('should return early when canvas missing', async () => {
const manager = useCanvasManager()
mockStore.maskCanvas = null
await manager.updateMaskColor()
expect(mockStore.maskCtx.getImageData).not.toHaveBeenCalled()
})
it('should return early when context missing', async () => {
const manager = useCanvasManager()
mockStore.maskCtx = null
await manager.updateMaskColor()
expect(mockStore.canvasBackground.style.backgroundColor).toBe('')
})
it('should handle different opacity values', async () => {
const manager = useCanvasManager()
mockStore.maskOpacity = 0.5
await manager.updateMaskColor()
expect(mockStore.maskCanvas.style.opacity).toBe('0.5')
})
})
describe('prepareMask', () => {
it('should invert mask alpha', async () => {
const manager = useCanvasManager()
for (let i = 0; i < mockImageData.data.length; i += 4) {
mockImageData.data[i + 3] = 128
}
const origImage = createMockImage(100, 100)
const maskImage = createMockImage(100, 100)
await manager.invalidateCanvas(origImage, maskImage, null)
for (let i = 0; i < mockImageData.data.length; i += 4) {
expect(mockImageData.data[i + 3]).toBe(127)
}
})
it('should apply mask color to all pixels', async () => {
const manager = useCanvasManager()
mockStore.maskColor = { r: 100, g: 150, b: 200 }
const origImage = createMockImage(100, 100)
const maskImage = createMockImage(100, 100)
await manager.invalidateCanvas(origImage, maskImage, null)
for (let i = 0; i < mockImageData.data.length; i += 4) {
expect(mockImageData.data[i]).toBe(100)
expect(mockImageData.data[i + 1]).toBe(150)
expect(mockImageData.data[i + 2]).toBe(200)
}
})
it('should set composite operation', async () => {
const manager = useCanvasManager()
const origImage = createMockImage(100, 100)
const maskImage = createMockImage(100, 100)
await manager.invalidateCanvas(origImage, maskImage, null)
expect(mockStore.maskCtx.globalCompositeOperation).toBe('source-over')
})
})
})

View File

@@ -0,0 +1,480 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ColorComparisonMethod } from '@/extensions/core/maskeditor/types'
import { useCanvasTools } from '@/composables/maskeditor/useCanvasTools'
const mockCanvasHistory = {
saveState: vi.fn()
}
const mockStore = {
maskCtx: null as any,
imgCtx: null as any,
maskCanvas: null as any,
imgCanvas: null as any,
rgbCtx: null as any,
rgbCanvas: null as any,
maskColor: { r: 255, g: 255, b: 255 },
paintBucketTolerance: 10,
fillOpacity: 100,
colorSelectTolerance: 30,
colorComparisonMethod: ColorComparisonMethod.Simple,
selectionOpacity: 100,
applyWholeImage: false,
maskBoundary: false,
maskTolerance: 10,
canvasHistory: mockCanvasHistory
}
vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: vi.fn(() => mockStore)
}))
describe('useCanvasTools', () => {
let mockMaskImageData: ImageData
let mockImgImageData: ImageData
beforeEach(() => {
vi.clearAllMocks()
mockMaskImageData = {
data: new Uint8ClampedArray(100 * 100 * 4),
width: 100,
height: 100
} as ImageData
mockImgImageData = {
data: new Uint8ClampedArray(100 * 100 * 4),
width: 100,
height: 100
} as ImageData
for (let i = 0; i < mockImgImageData.data.length; i += 4) {
mockImgImageData.data[i] = 255
mockImgImageData.data[i + 1] = 0
mockImgImageData.data[i + 2] = 0
mockImgImageData.data[i + 3] = 255
}
mockStore.maskCtx = {
getImageData: vi.fn(() => mockMaskImageData),
putImageData: vi.fn(),
clearRect: vi.fn()
}
mockStore.imgCtx = {
getImageData: vi.fn(() => mockImgImageData)
}
mockStore.rgbCtx = {
clearRect: vi.fn()
}
mockStore.maskCanvas = {
width: 100,
height: 100
}
mockStore.imgCanvas = {
width: 100,
height: 100
}
mockStore.rgbCanvas = {
width: 100,
height: 100
}
mockStore.maskColor = { r: 255, g: 255, b: 255 }
mockStore.paintBucketTolerance = 10
mockStore.fillOpacity = 100
mockStore.colorSelectTolerance = 30
mockStore.colorComparisonMethod = ColorComparisonMethod.Simple
mockStore.selectionOpacity = 100
mockStore.applyWholeImage = false
mockStore.maskBoundary = false
mockStore.maskTolerance = 10
})
describe('paintBucketFill', () => {
it('should fill empty area with mask color', () => {
const tools = useCanvasTools()
tools.paintBucketFill({ x: 50, y: 50 })
expect(mockStore.maskCtx.getImageData).toHaveBeenCalledWith(
0,
0,
100,
100
)
expect(mockStore.maskCtx.putImageData).toHaveBeenCalledWith(
mockMaskImageData,
0,
0
)
expect(mockCanvasHistory.saveState).toHaveBeenCalled()
const index = (50 * 100 + 50) * 4
expect(mockMaskImageData.data[index + 3]).toBe(255)
})
it('should erase filled area', () => {
for (let i = 0; i < mockMaskImageData.data.length; i += 4) {
mockMaskImageData.data[i + 3] = 255
}
const tools = useCanvasTools()
tools.paintBucketFill({ x: 50, y: 50 })
const index = (50 * 100 + 50) * 4
expect(mockMaskImageData.data[index + 3]).toBe(0)
})
it('should respect tolerance', () => {
mockStore.paintBucketTolerance = 0
for (let i = 0; i < mockMaskImageData.data.length; i += 4) {
mockMaskImageData.data[i + 3] = 0
}
const centerIndex = (50 * 100 + 50) * 4
mockMaskImageData.data[centerIndex + 3] = 10
const tools = useCanvasTools()
tools.paintBucketFill({ x: 51, y: 50 })
expect(mockMaskImageData.data[centerIndex + 3]).toBe(10)
})
it('should return early when point out of bounds', () => {
const tools = useCanvasTools()
tools.paintBucketFill({ x: -1, y: 50 })
expect(mockStore.maskCtx.putImageData).not.toHaveBeenCalled()
})
it('should return early when canvas missing', () => {
mockStore.maskCanvas = null
const tools = useCanvasTools()
tools.paintBucketFill({ x: 50, y: 50 })
expect(mockStore.maskCtx.getImageData).not.toHaveBeenCalled()
})
it('should apply fill opacity', () => {
mockStore.fillOpacity = 50
const tools = useCanvasTools()
tools.paintBucketFill({ x: 50, y: 50 })
const index = (50 * 100 + 50) * 4
expect(mockMaskImageData.data[index + 3]).toBe(127)
})
it('should apply mask color', () => {
mockStore.maskColor = { r: 128, g: 64, b: 32 }
const tools = useCanvasTools()
tools.paintBucketFill({ x: 50, y: 50 })
const index = (50 * 100 + 50) * 4
expect(mockMaskImageData.data[index]).toBe(128)
expect(mockMaskImageData.data[index + 1]).toBe(64)
expect(mockMaskImageData.data[index + 2]).toBe(32)
})
})
describe('colorSelectFill', () => {
it('should select pixels by color with flood fill', async () => {
const tools = useCanvasTools()
await tools.colorSelectFill({ x: 50, y: 50 })
expect(mockStore.maskCtx.getImageData).toHaveBeenCalledWith(
0,
0,
100,
100
)
expect(mockStore.imgCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
expect(mockCanvasHistory.saveState).toHaveBeenCalled()
})
it('should select pixels in whole image when applyWholeImage is true', async () => {
mockStore.applyWholeImage = true
const tools = useCanvasTools()
await tools.colorSelectFill({ x: 50, y: 50 })
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
})
it('should respect color tolerance', async () => {
mockStore.colorSelectTolerance = 0
for (let i = 0; i < mockImgImageData.data.length; i += 4) {
mockImgImageData.data[i] = 200
}
const tools = useCanvasTools()
await tools.colorSelectFill({ x: 50, y: 50 })
const index = (50 * 100 + 50) * 4
expect(mockMaskImageData.data[index + 3]).toBe(255)
})
it('should return early when point out of bounds', async () => {
const tools = useCanvasTools()
await tools.colorSelectFill({ x: -1, y: 50 })
expect(mockStore.maskCtx.putImageData).not.toHaveBeenCalled()
})
it('should return early when canvas missing', async () => {
mockStore.imgCanvas = null
const tools = useCanvasTools()
await tools.colorSelectFill({ x: 50, y: 50 })
expect(mockStore.maskCtx.getImageData).not.toHaveBeenCalled()
})
it('should apply selection opacity', async () => {
mockStore.selectionOpacity = 50
const tools = useCanvasTools()
await tools.colorSelectFill({ x: 50, y: 50 })
const index = (50 * 100 + 50) * 4
expect(mockMaskImageData.data[index + 3]).toBe(127)
})
it('should use HSL color comparison method', async () => {
mockStore.colorComparisonMethod = ColorComparisonMethod.HSL
const tools = useCanvasTools()
await tools.colorSelectFill({ x: 50, y: 50 })
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
})
it('should use LAB color comparison method', async () => {
mockStore.colorComparisonMethod = ColorComparisonMethod.LAB
const tools = useCanvasTools()
await tools.colorSelectFill({ x: 50, y: 50 })
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
})
it('should respect mask boundary', async () => {
mockStore.maskBoundary = true
mockStore.maskTolerance = 0
for (let i = 0; i < mockMaskImageData.data.length; i += 4) {
mockMaskImageData.data[i + 3] = 255
}
const tools = useCanvasTools()
await tools.colorSelectFill({ x: 50, y: 50 })
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
})
it('should update last color select point', async () => {
const tools = useCanvasTools()
await tools.colorSelectFill({ x: 30, y: 40 })
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
})
})
describe('invertMask', () => {
it('should invert mask alpha values', () => {
for (let i = 0; i < mockMaskImageData.data.length; i += 4) {
mockMaskImageData.data[i] = 255
mockMaskImageData.data[i + 1] = 255
mockMaskImageData.data[i + 2] = 255
mockMaskImageData.data[i + 3] = 128
}
const tools = useCanvasTools()
tools.invertMask()
expect(mockStore.maskCtx.getImageData).toHaveBeenCalledWith(
0,
0,
100,
100
)
expect(mockStore.maskCtx.putImageData).toHaveBeenCalledWith(
mockMaskImageData,
0,
0
)
expect(mockCanvasHistory.saveState).toHaveBeenCalled()
for (let i = 0; i < mockMaskImageData.data.length; i += 4) {
expect(mockMaskImageData.data[i + 3]).toBe(127)
}
})
it('should preserve mask color for empty pixels', () => {
for (let i = 0; i < mockMaskImageData.data.length; i += 4) {
mockMaskImageData.data[i + 3] = 0
}
const firstPixelIndex = 100
mockMaskImageData.data[firstPixelIndex * 4] = 128
mockMaskImageData.data[firstPixelIndex * 4 + 1] = 64
mockMaskImageData.data[firstPixelIndex * 4 + 2] = 32
mockMaskImageData.data[firstPixelIndex * 4 + 3] = 255
const tools = useCanvasTools()
tools.invertMask()
for (let i = 0; i < mockMaskImageData.data.length; i += 4) {
if (i !== firstPixelIndex * 4) {
expect(mockMaskImageData.data[i]).toBe(128)
expect(mockMaskImageData.data[i + 1]).toBe(64)
expect(mockMaskImageData.data[i + 2]).toBe(32)
}
}
})
it('should return early when canvas missing', () => {
mockStore.maskCanvas = null
const tools = useCanvasTools()
tools.invertMask()
expect(mockStore.maskCtx.getImageData).not.toHaveBeenCalled()
})
it('should return early when context missing', () => {
mockStore.maskCtx = null
const tools = useCanvasTools()
tools.invertMask()
expect(mockCanvasHistory.saveState).not.toHaveBeenCalled()
})
})
describe('clearMask', () => {
it('should clear mask canvas', () => {
const tools = useCanvasTools()
tools.clearMask()
expect(mockStore.maskCtx.clearRect).toHaveBeenCalledWith(0, 0, 100, 100)
expect(mockStore.rgbCtx.clearRect).toHaveBeenCalledWith(0, 0, 100, 100)
expect(mockCanvasHistory.saveState).toHaveBeenCalled()
})
it('should handle missing mask canvas', () => {
mockStore.maskCanvas = null
const tools = useCanvasTools()
tools.clearMask()
expect(mockStore.maskCtx.clearRect).not.toHaveBeenCalled()
expect(mockCanvasHistory.saveState).toHaveBeenCalled()
})
it('should handle missing rgb canvas', () => {
mockStore.rgbCanvas = null
const tools = useCanvasTools()
tools.clearMask()
expect(mockStore.maskCtx.clearRect).toHaveBeenCalledWith(0, 0, 100, 100)
expect(mockStore.rgbCtx.clearRect).not.toHaveBeenCalled()
expect(mockCanvasHistory.saveState).toHaveBeenCalled()
})
})
describe('clearLastColorSelectPoint', () => {
it('should clear last color select point', async () => {
const tools = useCanvasTools()
await tools.colorSelectFill({ x: 50, y: 50 })
tools.clearLastColorSelectPoint()
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
})
})
describe('edge cases', () => {
it('should handle small canvas', () => {
mockStore.maskCanvas.width = 1
mockStore.maskCanvas.height = 1
mockMaskImageData = {
data: new Uint8ClampedArray(1 * 1 * 4),
width: 1,
height: 1
} as ImageData
mockStore.maskCtx.getImageData = vi.fn(() => mockMaskImageData)
const tools = useCanvasTools()
tools.paintBucketFill({ x: 0, y: 0 })
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
})
it('should handle fractional coordinates', () => {
const tools = useCanvasTools()
tools.paintBucketFill({ x: 50.7, y: 50.3 })
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
})
it('should handle maximum tolerance', () => {
mockStore.paintBucketTolerance = 255
const tools = useCanvasTools()
tools.paintBucketFill({ x: 50, y: 50 })
expect(mockStore.maskCtx.putImageData).toHaveBeenCalled()
})
it('should handle zero opacity', () => {
mockStore.fillOpacity = 0
const tools = useCanvasTools()
tools.paintBucketFill({ x: 50, y: 50 })
const index = (50 * 100 + 50) * 4
expect(mockMaskImageData.data[index + 3]).toBe(0)
})
})
})

View File

@@ -0,0 +1,197 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useImageLoader } from '@/composables/maskeditor/useImageLoader'
const mockCanvasManager = {
invalidateCanvas: vi.fn().mockResolvedValue(undefined),
updateMaskColor: vi.fn().mockResolvedValue(undefined)
}
const mockStore = {
imgCanvas: null as any,
maskCanvas: null as any,
rgbCanvas: null as any,
imgCtx: null as any,
maskCtx: null as any,
image: null as any
}
const mockDataStore = {
inputData: null as any
}
vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: vi.fn(() => mockStore)
}))
vi.mock('@/stores/maskEditorDataStore', () => ({
useMaskEditorDataStore: vi.fn(() => mockDataStore)
}))
vi.mock('@/composables/maskeditor/useCanvasManager', () => ({
useCanvasManager: vi.fn(() => mockCanvasManager)
}))
vi.mock('@vueuse/core', () => ({
createSharedComposable: (fn: any) => fn
}))
describe('useImageLoader', () => {
let mockBaseImage: HTMLImageElement
let mockMaskImage: HTMLImageElement
let mockPaintImage: HTMLImageElement
beforeEach(() => {
vi.clearAllMocks()
mockBaseImage = {
width: 512,
height: 512
} as HTMLImageElement
mockMaskImage = {
width: 512,
height: 512
} as HTMLImageElement
mockPaintImage = {
width: 512,
height: 512
} as HTMLImageElement
mockStore.imgCtx = {
clearRect: vi.fn()
}
mockStore.maskCtx = {
clearRect: vi.fn()
}
mockStore.imgCanvas = {
width: 0,
height: 0
}
mockStore.maskCanvas = {
width: 0,
height: 0
}
mockStore.rgbCanvas = {
width: 0,
height: 0
}
mockDataStore.inputData = {
baseLayer: { image: mockBaseImage },
maskLayer: { image: mockMaskImage },
paintLayer: { image: mockPaintImage }
}
})
describe('loadImages', () => {
it('should load images successfully', async () => {
const loader = useImageLoader()
const result = await loader.loadImages()
expect(result).toBe(mockBaseImage)
expect(mockStore.image).toBe(mockBaseImage)
})
it('should set canvas dimensions', async () => {
const loader = useImageLoader()
await loader.loadImages()
expect(mockStore.maskCanvas.width).toBe(512)
expect(mockStore.maskCanvas.height).toBe(512)
expect(mockStore.rgbCanvas.width).toBe(512)
expect(mockStore.rgbCanvas.height).toBe(512)
})
it('should clear canvas contexts', async () => {
const loader = useImageLoader()
await loader.loadImages()
expect(mockStore.imgCtx.clearRect).toHaveBeenCalledWith(0, 0, 0, 0)
expect(mockStore.maskCtx.clearRect).toHaveBeenCalledWith(0, 0, 0, 0)
})
it('should call canvasManager methods', async () => {
const loader = useImageLoader()
await loader.loadImages()
expect(mockCanvasManager.invalidateCanvas).toHaveBeenCalledWith(
mockBaseImage,
mockMaskImage,
mockPaintImage
)
expect(mockCanvasManager.updateMaskColor).toHaveBeenCalled()
})
it('should handle missing paintLayer', async () => {
mockDataStore.inputData = {
baseLayer: { image: mockBaseImage },
maskLayer: { image: mockMaskImage },
paintLayer: null
}
const loader = useImageLoader()
await loader.loadImages()
expect(mockCanvasManager.invalidateCanvas).toHaveBeenCalledWith(
mockBaseImage,
mockMaskImage,
null
)
})
it('should throw error when no input data', async () => {
mockDataStore.inputData = null
const loader = useImageLoader()
await expect(loader.loadImages()).rejects.toThrow(
'No input data available in dataStore'
)
})
it('should throw error when canvas elements missing', async () => {
mockStore.imgCanvas = null
const loader = useImageLoader()
await expect(loader.loadImages()).rejects.toThrow(
'Canvas elements or contexts not available'
)
})
it('should throw error when contexts missing', async () => {
mockStore.imgCtx = null
const loader = useImageLoader()
await expect(loader.loadImages()).rejects.toThrow(
'Canvas elements or contexts not available'
)
})
it('should handle different image dimensions', async () => {
mockBaseImage.width = 1024
mockBaseImage.height = 768
const loader = useImageLoader()
await loader.loadImages()
expect(mockStore.maskCanvas.width).toBe(1024)
expect(mockStore.maskCanvas.height).toBe(768)
expect(mockStore.rgbCanvas.width).toBe(1024)
expect(mockStore.rgbCanvas.height).toBe(768)
})
})
})

View File

@@ -1,19 +0,0 @@
import { describe, expect, it } from 'vitest'
import { imageLayerFilenamesIfApplicable } from '@/extensions/core/maskeditor/utils/maskEditorLayerFilenames'
describe('imageLayerFilenamesIfApplicable', () => {
// In case the naming scheme changes, this test will ensure CI fails if developers forget to support the old naming scheme. (Causing MaskEditor to lose layer data for previously-saved images.)
it('should support all past layer naming schemes to preserve backward compatibility', async () => {
const dummyTimestamp = 1234567890
const inputToSupport = `clipspace-painted-masked-${dummyTimestamp}.png`
const expectedOutput = {
maskedImage: `clipspace-mask-${dummyTimestamp}.png`,
paint: `clipspace-paint-${dummyTimestamp}.png`,
paintedImage: `clipspace-painted-${dummyTimestamp}.png`,
paintedMaskedImage: inputToSupport
}
const actualOutput = imageLayerFilenamesIfApplicable(inputToSupport)
expect(actualOutput).toEqual(expectedOutput)
})
})