mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
fully refactor mask editor into vue-based (#6629)
## Summary
This PR refactors the mask editor from a vanilla JavaScript
implementation to Vue 3 + Composition API, aligning it with the ComfyUI
frontend's modern architecture. This is a structural refactor without UI
changes - all visual appearances and user interactions remain identical.
Net change: +1,700 lines (mostly tests)
## Changes
- Converted from class-based managers to Vue 3 Composition API
- Migrated state management to Pinia stores (maskEditorStore,
maskEditorDataStore)
- Split monolithic managers into focused composables:
- useBrushDrawing - Brush rendering and drawing logic
- useCanvasManager - Canvas lifecycle and operations
- useCanvasTools - Tool-specific canvas operations
- usePanAndZoom - Pan and zoom functionality
- useToolManager - Tool selection and coordination
- useKeyboard - Keyboard shortcuts
- useMaskEditorLoader/Saver - Data loading and saving
- useCoordinateTransform - Coordinate system transformations
- Replaced imperative DOM manipulation with Vue components
- Added comprehensive test coverage
## What This PR Does NOT Change
Preserved Original Styling:
- Original CSS retained in packages/design-system/src/css/style.css
- Some generic controls (DropdownControl, SliderControl, ToggleControl)
preserved as-is
- Future migration to Tailwind and PrimeVue components is planned but
out of scope for this PR
Preserved Core Functionality:
- Drawing algorithms and brush rendering logic remain unchanged
- Pan/zoom calculations preserved
- Canvas operations (composite modes, image processing) unchanged
- Tool behaviors (brush, color select, paint bucket) identical
- No changes to mask generation or export logic
DO NOT Review:
- CSS styling choices (preserved from original)
- Drawing algorithm implementations (unchanged)
- Canvas rendering logic (ported as-is)
- UI/UX changes (none exist)
- Component library choices (future work)
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6629-fully-refactor-mask-editor-into-vue-based-2a46d73d36508114ab8bd2984b4b54e4)
by [Unito](https://www.unito.io)
This commit is contained in:
@@ -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 ===================== */
|
||||
97
src/components/maskeditor/BrushCursor.vue
Normal file
97
src/components/maskeditor/BrushCursor.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div
|
||||
id="maskEditor_brush"
|
||||
:style="{
|
||||
position: 'absolute',
|
||||
opacity: brushOpacity,
|
||||
width: `${brushSize}px`,
|
||||
height: `${brushSize}px`,
|
||||
left: `${brushLeft}px`,
|
||||
top: `${brushTop}px`,
|
||||
borderRadius: borderRadius,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1000
|
||||
}"
|
||||
>
|
||||
<div
|
||||
id="maskEditor_brushPreviewGradient"
|
||||
:style="{
|
||||
display: gradientVisible ? 'block' : 'none',
|
||||
background: gradientBackground
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { BrushShape } from '@/extensions/core/maskeditor/types'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
const { containerRef } = defineProps<{
|
||||
containerRef?: HTMLElement
|
||||
}>()
|
||||
|
||||
const store = useMaskEditorStore()
|
||||
|
||||
const brushOpacity = computed(() => {
|
||||
return store.brushVisible ? '1' : '0'
|
||||
})
|
||||
|
||||
const brushRadius = computed(() => {
|
||||
return store.brushSettings.size * store.zoomRatio
|
||||
})
|
||||
|
||||
const brushSize = computed(() => {
|
||||
return brushRadius.value * 2
|
||||
})
|
||||
|
||||
const brushLeft = computed(() => {
|
||||
const dialogRect = containerRef?.getBoundingClientRect()
|
||||
const dialogOffsetLeft = dialogRect?.left || 0
|
||||
return (
|
||||
store.cursorPoint.x +
|
||||
store.panOffset.x -
|
||||
brushRadius.value -
|
||||
dialogOffsetLeft
|
||||
)
|
||||
})
|
||||
|
||||
const brushTop = computed(() => {
|
||||
const dialogRect = containerRef?.getBoundingClientRect()
|
||||
const dialogOffsetTop = dialogRect?.top || 0
|
||||
return (
|
||||
store.cursorPoint.y +
|
||||
store.panOffset.y -
|
||||
brushRadius.value -
|
||||
dialogOffsetTop
|
||||
)
|
||||
})
|
||||
|
||||
const borderRadius = computed(() => {
|
||||
return store.brushSettings.type === BrushShape.Rect ? '0%' : '50%'
|
||||
})
|
||||
|
||||
const gradientVisible = computed(() => {
|
||||
return store.brushPreviewGradientVisible
|
||||
})
|
||||
|
||||
const gradientBackground = computed(() => {
|
||||
const hardness = store.brushSettings.hardness
|
||||
|
||||
if (hardness === 1) {
|
||||
return 'rgba(255, 0, 0, 0.5)'
|
||||
}
|
||||
|
||||
const midStop = hardness * 100
|
||||
const outerStop = 100
|
||||
|
||||
return `radial-gradient(
|
||||
circle,
|
||||
rgba(255, 0, 0, 0.5) 0%,
|
||||
rgba(255, 0, 0, 0.25) ${midStop}%,
|
||||
rgba(255, 0, 0, 0) ${outerStop}%
|
||||
)`
|
||||
})
|
||||
</script>
|
||||
129
src/components/maskeditor/BrushSettingsPanel.vue
Normal file
129
src/components/maskeditor/BrushSettingsPanel.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3 pb-3">
|
||||
<h3
|
||||
class="text-center text-[15px] font-sans text-[var(--descrip-text)] mt-2.5"
|
||||
>
|
||||
{{ t('maskEditor.brushSettings') }}
|
||||
</h3>
|
||||
|
||||
<button
|
||||
class="w-45 h-7.5 border-none bg-black/20 border border-[var(--border-color)] text-[var(--input-text)] font-sans text-[15px] pointer-events-auto transition-colors duration-100 hover:bg-[var(--p-overlaybadge-outline-color)] hover:border-none"
|
||||
@click="resetToDefault"
|
||||
>
|
||||
{{ t('maskEditor.resetToDefault') }}
|
||||
</button>
|
||||
|
||||
<div class="flex flex-col gap-3 pb-3">
|
||||
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
|
||||
t('maskEditor.brushShape')
|
||||
}}</span>
|
||||
<div
|
||||
class="flex flex-row gap-2.5 items-center min-h-6 relative h-[50px] w-full rounded-[10px] bg-[var(--p-surface-300)] dark-theme:bg-[var(--p-surface-800)]"
|
||||
>
|
||||
<div
|
||||
class="maskEditor_sidePanelBrushShapeCircle bg-transparent hover:bg-[var(--comfy-menu-bg)] dark-theme:hover:bg-[var(--p-surface-900)]"
|
||||
:class="{ active: store.brushSettings.type === BrushShape.Arc }"
|
||||
:style="{
|
||||
background:
|
||||
store.brushSettings.type === BrushShape.Arc
|
||||
? 'var(--p-button-text-primary-color)'
|
||||
: ''
|
||||
}"
|
||||
@click="setBrushShape(BrushShape.Arc)"
|
||||
></div>
|
||||
<div
|
||||
class="maskEditor_sidePanelBrushShapeSquare bg-transparent hover:bg-[var(--comfy-menu-bg)] dark-theme:hover:bg-[var(--p-surface-900)]"
|
||||
:class="{ active: store.brushSettings.type === BrushShape.Rect }"
|
||||
:style="{
|
||||
background:
|
||||
store.brushSettings.type === BrushShape.Rect
|
||||
? 'var(--p-button-text-primary-color)'
|
||||
: ''
|
||||
}"
|
||||
@click="setBrushShape(BrushShape.Rect)"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 pb-3">
|
||||
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
|
||||
t('maskEditor.colorSelector')
|
||||
}}</span>
|
||||
<input type="color" :value="store.rgbColor" @input="onColorChange" />
|
||||
</div>
|
||||
|
||||
<SliderControl
|
||||
:label="t('maskEditor.thickness')"
|
||||
:min="1"
|
||||
:max="100"
|
||||
:step="1"
|
||||
:model-value="store.brushSettings.size"
|
||||
@update:model-value="onThicknessChange"
|
||||
/>
|
||||
|
||||
<SliderControl
|
||||
:label="t('maskEditor.opacity')"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.01"
|
||||
:model-value="store.brushSettings.opacity"
|
||||
@update:model-value="onOpacityChange"
|
||||
/>
|
||||
|
||||
<SliderControl
|
||||
:label="t('maskEditor.hardness')"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.01"
|
||||
:model-value="store.brushSettings.hardness"
|
||||
@update:model-value="onHardnessChange"
|
||||
/>
|
||||
|
||||
<SliderControl
|
||||
:label="t('maskEditor.smoothingPrecision')"
|
||||
:min="1"
|
||||
:max="100"
|
||||
:step="1"
|
||||
:model-value="store.brushSettings.smoothingPrecision"
|
||||
@update:model-value="onSmoothingPrecisionChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { BrushShape } from '@/extensions/core/maskeditor/types'
|
||||
import { t } from '@/i18n'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
import SliderControl from './controls/SliderControl.vue'
|
||||
|
||||
const store = useMaskEditorStore()
|
||||
|
||||
const setBrushShape = (shape: BrushShape) => {
|
||||
store.brushSettings.type = shape
|
||||
}
|
||||
|
||||
const onColorChange = (event: Event) => {
|
||||
store.rgbColor = (event.target as HTMLInputElement).value
|
||||
}
|
||||
|
||||
const onThicknessChange = (value: number) => {
|
||||
store.setBrushSize(value)
|
||||
}
|
||||
|
||||
const onOpacityChange = (value: number) => {
|
||||
store.setBrushOpacity(value)
|
||||
}
|
||||
|
||||
const onHardnessChange = (value: number) => {
|
||||
store.setBrushHardness(value)
|
||||
}
|
||||
|
||||
const onSmoothingPrecisionChange = (value: number) => {
|
||||
store.setBrushSmoothingPrecision(value)
|
||||
}
|
||||
|
||||
const resetToDefault = () => {
|
||||
store.resetBrushToDefault()
|
||||
}
|
||||
</script>
|
||||
103
src/components/maskeditor/ColorSelectSettingsPanel.vue
Normal file
103
src/components/maskeditor/ColorSelectSettingsPanel.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3 pb-3">
|
||||
<h3
|
||||
class="text-center text-[15px] font-sans text-[var(--descrip-text)] mt-2.5"
|
||||
>
|
||||
{{ t('maskEditor.colorSelectSettings') }}
|
||||
</h3>
|
||||
|
||||
<SliderControl
|
||||
:label="t('maskEditor.tolerance')"
|
||||
:min="0"
|
||||
:max="255"
|
||||
:step="1"
|
||||
:model-value="store.colorSelectTolerance"
|
||||
@update:model-value="onToleranceChange"
|
||||
/>
|
||||
|
||||
<SliderControl
|
||||
:label="t('maskEditor.selectionOpacity')"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="1"
|
||||
:model-value="store.selectionOpacity"
|
||||
@update:model-value="onSelectionOpacityChange"
|
||||
/>
|
||||
|
||||
<ToggleControl
|
||||
:label="t('maskEditor.livePreview')"
|
||||
:model-value="store.colorSelectLivePreview"
|
||||
@update:model-value="onLivePreviewChange"
|
||||
/>
|
||||
|
||||
<ToggleControl
|
||||
:label="t('maskEditor.applyToWholeImage')"
|
||||
:model-value="store.applyWholeImage"
|
||||
@update:model-value="onWholeImageChange"
|
||||
/>
|
||||
|
||||
<DropdownControl
|
||||
:label="t('maskEditor.method')"
|
||||
:options="methodOptions"
|
||||
:model-value="store.colorComparisonMethod"
|
||||
@update:model-value="onMethodChange"
|
||||
/>
|
||||
|
||||
<ToggleControl
|
||||
:label="t('maskEditor.stopAtMask')"
|
||||
:model-value="store.maskBoundary"
|
||||
@update:model-value="onMaskBoundaryChange"
|
||||
/>
|
||||
|
||||
<SliderControl
|
||||
:label="t('maskEditor.maskTolerance')"
|
||||
:min="0"
|
||||
:max="255"
|
||||
:step="1"
|
||||
:model-value="store.maskTolerance"
|
||||
@update:model-value="onMaskToleranceChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ColorComparisonMethod } from '@/extensions/core/maskeditor/types'
|
||||
import { t } from '@/i18n'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
import DropdownControl from './controls/DropdownControl.vue'
|
||||
import SliderControl from './controls/SliderControl.vue'
|
||||
import ToggleControl from './controls/ToggleControl.vue'
|
||||
|
||||
const store = useMaskEditorStore()
|
||||
|
||||
const methodOptions = Object.values(ColorComparisonMethod)
|
||||
|
||||
const onToleranceChange = (value: number) => {
|
||||
store.setColorSelectTolerance(value)
|
||||
}
|
||||
|
||||
const onSelectionOpacityChange = (value: number) => {
|
||||
store.setSelectionOpacity(value)
|
||||
}
|
||||
|
||||
const onLivePreviewChange = (value: boolean) => {
|
||||
store.colorSelectLivePreview = value
|
||||
}
|
||||
|
||||
const onWholeImageChange = (value: boolean) => {
|
||||
store.applyWholeImage = value
|
||||
}
|
||||
|
||||
const onMethodChange = (value: string | number) => {
|
||||
store.colorComparisonMethod = value as ColorComparisonMethod
|
||||
}
|
||||
|
||||
const onMaskBoundaryChange = (value: boolean) => {
|
||||
store.maskBoundary = value
|
||||
}
|
||||
|
||||
const onMaskToleranceChange = (value: number) => {
|
||||
store.setMaskTolerance(value)
|
||||
}
|
||||
</script>
|
||||
227
src/components/maskeditor/ImageLayerSettingsPanel.vue
Normal file
227
src/components/maskeditor/ImageLayerSettingsPanel.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3 pb-3">
|
||||
<h3
|
||||
class="text-center text-[15px] font-sans text-[var(--descrip-text)] mt-2.5"
|
||||
>
|
||||
{{ t('maskEditor.layers') }}
|
||||
</h3>
|
||||
|
||||
<SliderControl
|
||||
:label="t('maskEditor.maskOpacity')"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.01"
|
||||
:model-value="store.maskOpacity"
|
||||
@update:model-value="onMaskOpacityChange"
|
||||
/>
|
||||
|
||||
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
|
||||
t('maskEditor.maskBlendingOptions')
|
||||
}}</span>
|
||||
|
||||
<div
|
||||
class="flex flex-row gap-2.5 items-center min-h-6 relative h-[50px] w-full rounded-[10px] -mt-2 -mb-1.5"
|
||||
>
|
||||
<select
|
||||
class="maskEditor_sidePanelDropdown"
|
||||
:value="store.maskBlendMode"
|
||||
@change="onBlendModeChange"
|
||||
>
|
||||
<option value="black">{{ t('maskEditor.black') }}</option>
|
||||
<option value="white">{{ t('maskEditor.white') }}</option>
|
||||
<option value="negative">{{ t('maskEditor.negative') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
|
||||
t('maskEditor.maskLayer')
|
||||
}}</span>
|
||||
<div
|
||||
class="flex flex-row gap-2.5 items-center min-h-6 relative h-[50px] w-full rounded-[10px] bg-[var(--p-surface-300)] dark-theme:bg-[var(--p-surface-800)]"
|
||||
:style="{
|
||||
border: store.activeLayer === 'mask' ? '2px solid #007acc' : 'none'
|
||||
}"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="maskEditor_sidePanelLayerCheckbox"
|
||||
:checked="maskLayerVisible"
|
||||
@change="onMaskLayerVisibilityChange"
|
||||
/>
|
||||
<div class="maskEditor_sidePanelLayerPreviewContainer">
|
||||
<svg viewBox="0 0 20 20" style="">
|
||||
<path
|
||||
class="cls-1"
|
||||
d="M1.31,5.32v9.36c0,.55.45,1,1,1h15.38c.55,0,1-.45,1-1V5.32c0-.55-.45-1-1-1H2.31c-.55,0-1,.45-1,1ZM11.19,13.44c-2.91.94-5.57-1.72-4.63-4.63.34-1.05,1.19-1.9,2.24-2.24,2.91-.94,5.57,1.72,4.63,4.63-.34,1.05-1.19-1.9-2.24,2.24Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<button
|
||||
style="font-size: 12px"
|
||||
:style="{ opacity: store.activeLayer === 'mask' ? '0.5' : '1' }"
|
||||
:disabled="store.activeLayer === 'mask'"
|
||||
@click="setActiveLayer('mask')"
|
||||
>
|
||||
{{ t('maskEditor.activateLayer') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
|
||||
t('maskEditor.paintLayer')
|
||||
}}</span>
|
||||
<div
|
||||
class="flex flex-row gap-2.5 items-center min-h-6 relative h-[50px] w-full rounded-[10px] bg-[var(--p-surface-300)] dark-theme:bg-[var(--p-surface-800)]"
|
||||
:style="{
|
||||
border: store.activeLayer === 'rgb' ? '2px solid #007acc' : 'none'
|
||||
}"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="maskEditor_sidePanelLayerCheckbox"
|
||||
:checked="paintLayerVisible"
|
||||
@change="onPaintLayerVisibilityChange"
|
||||
/>
|
||||
<div class="maskEditor_sidePanelLayerPreviewContainer">
|
||||
<svg viewBox="0 0 20 20">
|
||||
<path
|
||||
class="cls-1"
|
||||
d="M 17 6.965 c 0 0.235 -0.095 0.47 -0.275 0.655 l -6.51 6.52 c -0.045 0.035 -0.09 0.075 -0.135 0.11 c -0.035 -0.695 -0.605 -1.24 -1.305 -1.245 c 0.035 -0.06 0.08 -0.12 0.135 -0.17 l 6.52 -6.52 c 0.36 -0.36 0.945 -0.36 1.3 0 c 0.175 0.175 0.275 0.415 0.275 0.65 Z"
|
||||
/>
|
||||
<path
|
||||
class="cls-1"
|
||||
d="M 9.82 14.515 c 0 2.23 -3.23 1.59 -4.82 0 c 1.65 -0.235 2.375 -1.29 3.53 -1.29 c 0.715 0 1.29 0.58 1.29 1.29 Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<button
|
||||
style="font-size: 12px"
|
||||
:style="{
|
||||
opacity: store.activeLayer === 'rgb' ? '0.5' : '1',
|
||||
display: showLayerButtons ? 'block' : 'none'
|
||||
}"
|
||||
:disabled="store.activeLayer === 'rgb'"
|
||||
@click="setActiveLayer('rgb')"
|
||||
>
|
||||
{{ t('maskEditor.activateLayer') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
|
||||
t('maskEditor.baseImageLayer')
|
||||
}}</span>
|
||||
<div
|
||||
class="flex flex-row gap-2.5 items-center min-h-6 relative h-[50px] w-full rounded-[10px] bg-[var(--p-surface-300)] dark-theme:bg-[var(--p-surface-800)]"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="maskEditor_sidePanelLayerCheckbox"
|
||||
:checked="baseImageLayerVisible"
|
||||
@change="onBaseImageLayerVisibilityChange"
|
||||
/>
|
||||
<div class="maskEditor_sidePanelLayerPreviewContainer">
|
||||
<img
|
||||
class="maskEditor_sidePanelImageLayerImage"
|
||||
:src="baseImageSrc"
|
||||
:alt="t('maskEditor.baseLayerPreview')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useCanvasManager } from '@/composables/maskeditor/useCanvasManager'
|
||||
import type { useToolManager } from '@/composables/maskeditor/useToolManager'
|
||||
import type { ImageLayer } from '@/extensions/core/maskeditor/types'
|
||||
import { MaskBlendMode, Tools } from '@/extensions/core/maskeditor/types'
|
||||
import { t } from '@/i18n'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
import SliderControl from './controls/SliderControl.vue'
|
||||
|
||||
const { toolManager } = defineProps<{
|
||||
toolManager?: ReturnType<typeof useToolManager>
|
||||
}>()
|
||||
|
||||
const store = useMaskEditorStore()
|
||||
const canvasManager = useCanvasManager()
|
||||
|
||||
const maskLayerVisible = ref(true)
|
||||
const paintLayerVisible = ref(true)
|
||||
const baseImageLayerVisible = ref(true)
|
||||
|
||||
const baseImageSrc = computed(() => {
|
||||
return store.image?.src ?? ''
|
||||
})
|
||||
|
||||
const showLayerButtons = computed(() => {
|
||||
return store.currentTool === Tools.Eraser
|
||||
})
|
||||
|
||||
const onMaskLayerVisibilityChange = (event: Event) => {
|
||||
const checked = (event.target as HTMLInputElement).checked
|
||||
maskLayerVisible.value = checked
|
||||
|
||||
const maskCanvas = store.maskCanvas
|
||||
if (maskCanvas) {
|
||||
maskCanvas.style.opacity = checked ? String(store.maskOpacity) : '0'
|
||||
}
|
||||
}
|
||||
|
||||
const onPaintLayerVisibilityChange = (event: Event) => {
|
||||
const checked = (event.target as HTMLInputElement).checked
|
||||
paintLayerVisible.value = checked
|
||||
|
||||
const rgbCanvas = store.rgbCanvas
|
||||
if (rgbCanvas) {
|
||||
rgbCanvas.style.opacity = checked ? '1' : '0'
|
||||
}
|
||||
}
|
||||
|
||||
const onBaseImageLayerVisibilityChange = (event: Event) => {
|
||||
const checked = (event.target as HTMLInputElement).checked
|
||||
baseImageLayerVisible.value = checked
|
||||
|
||||
const imgCanvas = store.imgCanvas
|
||||
if (imgCanvas) {
|
||||
imgCanvas.style.opacity = checked ? '1' : '0'
|
||||
}
|
||||
}
|
||||
|
||||
const onMaskOpacityChange = (value: number) => {
|
||||
store.setMaskOpacity(value)
|
||||
|
||||
const maskCanvas = store.maskCanvas
|
||||
if (maskCanvas) {
|
||||
maskCanvas.style.opacity = String(value)
|
||||
}
|
||||
|
||||
maskLayerVisible.value = value !== 0
|
||||
}
|
||||
|
||||
const onBlendModeChange = async (event: Event) => {
|
||||
const value = (event.target as HTMLSelectElement).value
|
||||
let blendMode: MaskBlendMode
|
||||
|
||||
switch (value) {
|
||||
case 'white':
|
||||
blendMode = MaskBlendMode.White
|
||||
break
|
||||
case 'negative':
|
||||
blendMode = MaskBlendMode.Negative
|
||||
break
|
||||
default:
|
||||
blendMode = MaskBlendMode.Black
|
||||
}
|
||||
|
||||
store.maskBlendMode = blendMode
|
||||
|
||||
await canvasManager.updateMaskColor()
|
||||
}
|
||||
|
||||
const setActiveLayer = (layer: ImageLayer) => {
|
||||
toolManager?.setActiveLayer(layer)
|
||||
}
|
||||
</script>
|
||||
209
src/components/maskeditor/MaskEditorContent.vue
Normal file
209
src/components/maskeditor/MaskEditorContent.vue
Normal file
@@ -0,0 +1,209 @@
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="maskEditor-dialog-root flex h-full w-full flex-col"
|
||||
@contextmenu.prevent
|
||||
@dragstart="handleDragStart"
|
||||
>
|
||||
<div
|
||||
id="maskEditorCanvasContainer"
|
||||
ref="canvasContainerRef"
|
||||
@contextmenu.prevent
|
||||
>
|
||||
<canvas
|
||||
ref="imgCanvasRef"
|
||||
class="absolute top-0 left-0 w-full h-full"
|
||||
@contextmenu.prevent
|
||||
/>
|
||||
<canvas
|
||||
ref="rgbCanvasRef"
|
||||
class="absolute top-0 left-0 w-full h-full"
|
||||
@contextmenu.prevent
|
||||
/>
|
||||
<canvas
|
||||
ref="maskCanvasRef"
|
||||
class="absolute top-0 left-0 w-full h-full"
|
||||
@contextmenu.prevent
|
||||
/>
|
||||
<div ref="canvasBackgroundRef" class="bg-white w-full h-full" />
|
||||
</div>
|
||||
|
||||
<div class="maskEditor-ui-container flex min-h-0 flex-1 flex-col">
|
||||
<div class="flex min-h-0 flex-1 overflow-hidden">
|
||||
<ToolPanel
|
||||
v-if="initialized"
|
||||
ref="toolPanelRef"
|
||||
:tool-manager="toolManager!"
|
||||
/>
|
||||
|
||||
<PointerZone
|
||||
v-if="initialized"
|
||||
:tool-manager="toolManager!"
|
||||
:pan-zoom="panZoom!"
|
||||
/>
|
||||
|
||||
<SidePanel
|
||||
v-if="initialized"
|
||||
ref="sidePanelRef"
|
||||
:tool-manager="toolManager!"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BrushCursor v-if="initialized" :container-ref="containerRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import { useImageLoader } from '@/composables/maskeditor/useImageLoader'
|
||||
import { useKeyboard } from '@/composables/maskeditor/useKeyboard'
|
||||
import { useMaskEditorLoader } from '@/composables/maskeditor/useMaskEditorLoader'
|
||||
import { usePanAndZoom } from '@/composables/maskeditor/usePanAndZoom'
|
||||
import { useToolManager } from '@/composables/maskeditor/useToolManager'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useMaskEditorDataStore } from '@/stores/maskEditorDataStore'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
import BrushCursor from './BrushCursor.vue'
|
||||
import PointerZone from './PointerZone.vue'
|
||||
import SidePanel from './SidePanel.vue'
|
||||
import ToolPanel from './ToolPanel.vue'
|
||||
|
||||
const { node } = defineProps<{
|
||||
node: LGraphNode
|
||||
}>()
|
||||
|
||||
const store = useMaskEditorStore()
|
||||
const dataStore = useMaskEditorDataStore()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
const loader = useMaskEditorLoader()
|
||||
|
||||
const containerRef = ref<HTMLElement>()
|
||||
const canvasContainerRef = ref<HTMLDivElement>()
|
||||
const imgCanvasRef = ref<HTMLCanvasElement>()
|
||||
const maskCanvasRef = ref<HTMLCanvasElement>()
|
||||
const rgbCanvasRef = ref<HTMLCanvasElement>()
|
||||
const canvasBackgroundRef = ref<HTMLDivElement>()
|
||||
|
||||
const toolPanelRef = ref<InstanceType<typeof ToolPanel>>()
|
||||
const sidePanelRef = ref<InstanceType<typeof SidePanel>>()
|
||||
|
||||
const initialized = ref(false)
|
||||
|
||||
const keyboard = useKeyboard()
|
||||
const panZoom = usePanAndZoom()
|
||||
|
||||
let toolManager: ReturnType<typeof useToolManager> | null = null
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
const handleDragStart = (event: DragEvent) => {
|
||||
if (event.ctrlKey) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
const initUI = async () => {
|
||||
if (!containerRef.value) {
|
||||
console.error(
|
||||
'[MaskEditorContent] Cannot initialize - missing required refs'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
!imgCanvasRef.value ||
|
||||
!maskCanvasRef.value ||
|
||||
!rgbCanvasRef.value ||
|
||||
!canvasContainerRef.value ||
|
||||
!canvasBackgroundRef.value
|
||||
) {
|
||||
console.error('[MaskEditorContent] Cannot initialize - missing canvas refs')
|
||||
return
|
||||
}
|
||||
|
||||
store.maskCanvas = maskCanvasRef.value
|
||||
store.rgbCanvas = rgbCanvasRef.value
|
||||
store.imgCanvas = imgCanvasRef.value
|
||||
store.canvasContainer = canvasContainerRef.value
|
||||
store.canvasBackground = canvasBackgroundRef.value
|
||||
|
||||
try {
|
||||
await loader.loadFromNode(node)
|
||||
|
||||
toolManager = useToolManager(keyboard, panZoom)
|
||||
|
||||
const imageLoader = useImageLoader()
|
||||
const image = await imageLoader.loadImages()
|
||||
|
||||
await panZoom.initializeCanvasPanZoom(
|
||||
image,
|
||||
containerRef.value,
|
||||
toolPanelRef.value?.$el as HTMLElement | undefined,
|
||||
sidePanelRef.value?.$el as HTMLElement | undefined
|
||||
)
|
||||
|
||||
store.canvasHistory.saveInitialState()
|
||||
|
||||
initialized.value = true
|
||||
} catch (error) {
|
||||
console.error('[MaskEditorContent] Initialization failed:', error)
|
||||
dialogStore.closeDialog()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
keyboard.addListeners()
|
||||
|
||||
if (containerRef.value) {
|
||||
resizeObserver = new ResizeObserver(async () => {
|
||||
if (panZoom) {
|
||||
await panZoom.invalidatePanZoom()
|
||||
}
|
||||
})
|
||||
resizeObserver.observe(containerRef.value)
|
||||
}
|
||||
|
||||
void initUI()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
toolManager?.brushDrawing.saveBrushSettings()
|
||||
|
||||
keyboard?.removeListeners()
|
||||
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect()
|
||||
resizeObserver = null
|
||||
}
|
||||
|
||||
store.canvasHistory.clearStates()
|
||||
store.resetState()
|
||||
dataStore.reset()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.maskEditor-dialog-root {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.maskEditor-ui-container {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
:deep(#maskEditorCanvasContainer) {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
}
|
||||
</style>
|
||||
44
src/components/maskeditor/PaintBucketSettingsPanel.vue
Normal file
44
src/components/maskeditor/PaintBucketSettingsPanel.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3 pb-3">
|
||||
<h3
|
||||
class="text-center text-[15px] font-sans text-[var(--descrip-text)] mt-2.5"
|
||||
>
|
||||
{{ t('maskEditor.paintBucketSettings') }}
|
||||
</h3>
|
||||
|
||||
<SliderControl
|
||||
:label="t('maskEditor.tolerance')"
|
||||
:min="0"
|
||||
:max="255"
|
||||
:step="1"
|
||||
:model-value="store.paintBucketTolerance"
|
||||
@update:model-value="onToleranceChange"
|
||||
/>
|
||||
|
||||
<SliderControl
|
||||
:label="t('maskEditor.fillOpacity')"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="1"
|
||||
:model-value="store.fillOpacity"
|
||||
@update:model-value="onFillOpacityChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
import SliderControl from './controls/SliderControl.vue'
|
||||
|
||||
const store = useMaskEditorStore()
|
||||
|
||||
const onToleranceChange = (value: number) => {
|
||||
store.setPaintBucketTolerance(value)
|
||||
}
|
||||
|
||||
const onFillOpacityChange = (value: number) => {
|
||||
store.setFillOpacity(value)
|
||||
}
|
||||
</script>
|
||||
95
src/components/maskeditor/PointerZone.vue
Normal file
95
src/components/maskeditor/PointerZone.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div
|
||||
ref="pointerZoneRef"
|
||||
class="w-[calc(100%-4rem-220px)] h-full"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
@pointerup="handlePointerUp"
|
||||
@pointerleave="handlePointerLeave"
|
||||
@pointerenter="handlePointerEnter"
|
||||
@touchstart="handleTouchStart"
|
||||
@touchmove="handleTouchMove"
|
||||
@touchend="handleTouchEnd"
|
||||
@wheel="handleWheel"
|
||||
@contextmenu.prevent
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
|
||||
import type { usePanAndZoom } from '@/composables/maskeditor/usePanAndZoom'
|
||||
import type { useToolManager } from '@/composables/maskeditor/useToolManager'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
const { toolManager, panZoom } = defineProps<{
|
||||
toolManager: ReturnType<typeof useToolManager>
|
||||
panZoom: ReturnType<typeof usePanAndZoom>
|
||||
}>()
|
||||
|
||||
const store = useMaskEditorStore()
|
||||
const pointerZoneRef = ref<HTMLDivElement>()
|
||||
|
||||
onMounted(() => {
|
||||
if (!pointerZoneRef.value) {
|
||||
console.error('[PointerZone] Pointer zone ref not initialized')
|
||||
return
|
||||
}
|
||||
|
||||
store.pointerZone = pointerZoneRef.value
|
||||
})
|
||||
|
||||
watch(
|
||||
() => store.isPanning,
|
||||
(isPanning) => {
|
||||
if (!pointerZoneRef.value) return
|
||||
|
||||
if (isPanning) {
|
||||
pointerZoneRef.value.style.cursor = 'grabbing'
|
||||
} else {
|
||||
toolManager.updateCursor()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const handlePointerDown = async (event: PointerEvent) => {
|
||||
await toolManager.handlePointerDown(event)
|
||||
}
|
||||
|
||||
const handlePointerMove = async (event: PointerEvent) => {
|
||||
await toolManager.handlePointerMove(event)
|
||||
}
|
||||
|
||||
const handlePointerUp = (event: PointerEvent) => {
|
||||
void toolManager.handlePointerUp(event)
|
||||
}
|
||||
|
||||
const handlePointerLeave = () => {
|
||||
store.brushVisible = false
|
||||
if (pointerZoneRef.value) {
|
||||
pointerZoneRef.value.style.cursor = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handlePointerEnter = () => {
|
||||
toolManager.updateCursor()
|
||||
}
|
||||
|
||||
const handleTouchStart = (event: TouchEvent) => {
|
||||
panZoom.handleTouchStart(event)
|
||||
}
|
||||
|
||||
const handleTouchMove = async (event: TouchEvent) => {
|
||||
await panZoom.handleTouchMove(event)
|
||||
}
|
||||
|
||||
const handleTouchEnd = (event: TouchEvent) => {
|
||||
panZoom.handleTouchEnd(event)
|
||||
}
|
||||
|
||||
const handleWheel = async (event: WheelEvent) => {
|
||||
await panZoom.zoom(event)
|
||||
const newCursorPoint = { x: event.clientX, y: event.clientY }
|
||||
panZoom.updateCursorPosition(newCursorPoint)
|
||||
}
|
||||
</script>
|
||||
31
src/components/maskeditor/SettingsPanelContainer.vue
Normal file
31
src/components/maskeditor/SettingsPanelContainer.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div class="maskEditor_sidePanel">
|
||||
<div class="maskEditor_sidePanelContainer">
|
||||
<component :is="currentPanelComponent" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
import { Tools } from '@/extensions/core/maskeditor/types'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
import BrushSettingsPanel from './BrushSettingsPanel.vue'
|
||||
import ColorSelectSettingsPanel from './ColorSelectSettingsPanel.vue'
|
||||
import PaintBucketSettingsPanel from './PaintBucketSettingsPanel.vue'
|
||||
|
||||
const currentPanelComponent = computed<Component>(() => {
|
||||
const tool = useMaskEditorStore().currentTool
|
||||
|
||||
if (tool === Tools.MaskBucket) {
|
||||
return PaintBucketSettingsPanel
|
||||
} else if (tool === Tools.MaskColorFill) {
|
||||
return ColorSelectSettingsPanel
|
||||
} else {
|
||||
return BrushSettingsPanel
|
||||
}
|
||||
})
|
||||
</script>
|
||||
24
src/components/maskeditor/SidePanel.vue
Normal file
24
src/components/maskeditor/SidePanel.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-3 pb-3 h-full !items-stretch bg-[var(--comfy-menu-bg)] overflow-y-auto w-55 px-2.5"
|
||||
>
|
||||
<div class="w-full min-h-full">
|
||||
<SettingsPanelContainer />
|
||||
|
||||
<div class="w-full h-0.5 bg-[var(--border-color)] mt-6 mb-1.5" />
|
||||
|
||||
<ImageLayerSettingsPanel :tool-manager="toolManager" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { useToolManager } from '@/composables/maskeditor/useToolManager'
|
||||
|
||||
import ImageLayerSettingsPanel from './ImageLayerSettingsPanel.vue'
|
||||
import SettingsPanelContainer from './SettingsPanelContainer.vue'
|
||||
|
||||
const { toolManager } = defineProps<{
|
||||
toolManager?: ReturnType<typeof useToolManager>
|
||||
}>()
|
||||
</script>
|
||||
69
src/components/maskeditor/ToolPanel.vue
Normal file
69
src/components/maskeditor/ToolPanel.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div
|
||||
class="h-full z-[8888] flex flex-col justify-between bg-[var(--comfy-menu-bg)]"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
v-for="tool in allTools"
|
||||
:key="tool"
|
||||
:class="[
|
||||
'maskEditor_toolPanelContainer hover:bg-[var(--p-surface-300)] dark-theme:hover:bg-[var(--p-surface-800)]',
|
||||
{ maskEditor_toolPanelContainerSelected: currentTool === tool }
|
||||
]"
|
||||
@click="onToolSelect(tool)"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center"
|
||||
v-html="iconsHtml[tool]"
|
||||
></div>
|
||||
<div class="maskEditor_toolPanelIndicator"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col items-center cursor-pointer rounded-md mb-2 transition-colors duration-200 hover:bg-[var(--p-surface-300)] dark-theme:hover:bg-[var(--p-surface-800)]"
|
||||
:title="t('maskEditor.clickToResetZoom')"
|
||||
@click="onResetZoom"
|
||||
>
|
||||
<span class="text-sm text-[var(--p-button-text-secondary-color)]">{{
|
||||
zoomText
|
||||
}}</span>
|
||||
<span class="text-xs text-[var(--p-button-text-secondary-color)]">{{
|
||||
dimensionsText
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { useToolManager } from '@/composables/maskeditor/useToolManager'
|
||||
import { iconsHtml } from '@/extensions/core/maskeditor/constants'
|
||||
import type { Tools } from '@/extensions/core/maskeditor/types'
|
||||
import { allTools } from '@/extensions/core/maskeditor/types'
|
||||
import { t } from '@/i18n'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
const { toolManager } = defineProps<{
|
||||
toolManager: ReturnType<typeof useToolManager>
|
||||
}>()
|
||||
|
||||
const store = useMaskEditorStore()
|
||||
|
||||
const onToolSelect = (tool: Tools) => {
|
||||
toolManager.switchTool(tool)
|
||||
}
|
||||
|
||||
const currentTool = computed(() => store.currentTool)
|
||||
|
||||
const zoomText = computed(() => `${Math.round(store.displayZoomRatio * 100)}%`)
|
||||
const dimensionsText = computed(() => {
|
||||
const img = store.image
|
||||
return img ? `${img.width}x${img.height}` : ' '
|
||||
})
|
||||
|
||||
const onResetZoom = () => {
|
||||
store.resetZoom()
|
||||
}
|
||||
</script>
|
||||
55
src/components/maskeditor/controls/DropdownControl.vue
Normal file
55
src/components/maskeditor/controls/DropdownControl.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div class="flex flex-row gap-2.5 items-center min-h-6 relative">
|
||||
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
|
||||
label
|
||||
}}</span>
|
||||
<select
|
||||
class="absolute right-0 h-6 px-1.5 rounded-md border border-[var(--p-form-field-border-color)] transition-colors duration-100 bg-[var(--comfy-menu-bg)] focus:outline focus:outline-1 focus:outline-[var(--p-surface-300)] dark-theme:bg-[var(--p-surface-900)] dark-theme:focus:outline-[var(--p-button-text-primary-color)]"
|
||||
:value="modelValue"
|
||||
@change="onChange"
|
||||
>
|
||||
<option
|
||||
v-for="option in normalizedOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface DropdownOption {
|
||||
label: string
|
||||
value: string | number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
label: string
|
||||
options: string[] | DropdownOption[]
|
||||
modelValue: string | number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string | number]
|
||||
}>()
|
||||
|
||||
const normalizedOptions = computed((): DropdownOption[] => {
|
||||
return props.options.map((option) => {
|
||||
if (typeof option === 'string') {
|
||||
return { label: option, value: option }
|
||||
}
|
||||
return option
|
||||
})
|
||||
})
|
||||
|
||||
const onChange = (event: Event) => {
|
||||
const value = (event.target as HTMLSelectElement).value
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
</script>
|
||||
39
src/components/maskeditor/controls/SliderControl.vue
Normal file
39
src/components/maskeditor/controls/SliderControl.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3 pb-3">
|
||||
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
|
||||
label
|
||||
}}</span>
|
||||
<input
|
||||
type="range"
|
||||
class="maskEditor_sidePanelBrushRange"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
:value="modelValue"
|
||||
@input="onInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
label: string
|
||||
min: number
|
||||
max: number
|
||||
step?: number
|
||||
modelValue: number
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
step: 1
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: number]
|
||||
}>()
|
||||
|
||||
const onInput = (event: Event) => {
|
||||
const value = Number((event.target as HTMLInputElement).value)
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
</script>
|
||||
34
src/components/maskeditor/controls/ToggleControl.vue
Normal file
34
src/components/maskeditor/controls/ToggleControl.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div class="flex flex-row gap-2.5 items-center min-h-6 relative">
|
||||
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
|
||||
label
|
||||
}}</span>
|
||||
<label class="maskEditor_sidePanelToggleContainer">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="maskEditor_sidePanelToggleCheckbox"
|
||||
:checked="modelValue"
|
||||
@change="onChange"
|
||||
/>
|
||||
<div class="maskEditor_sidePanelToggleSwitch"></div>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
label: string
|
||||
modelValue: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
const onChange = (event: Event) => {
|
||||
const checked = (event.target as HTMLInputElement).checked
|
||||
emit('update:modelValue', checked)
|
||||
}
|
||||
</script>
|
||||
126
src/components/maskeditor/dialog/TopBarHeader.vue
Normal file
126
src/components/maskeditor/dialog/TopBarHeader.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<div class="flex w-full items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="m-0 text-lg font-semibold">{{ t('maskEditor.title') }}</h3>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
:class="iconButtonClass"
|
||||
:title="t('maskEditor.undo')"
|
||||
@click="onUndo"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 15 15"
|
||||
class="h-6.25 w-6.25 pointer-events-none fill-[var(--input-text)]"
|
||||
>
|
||||
<path
|
||||
d="M8.77,12.18c-.25,0-.46-.2-.46-.46s.2-.46.46-.46c1.47,0,2.67-1.2,2.67-2.67,0-1.57-1.34-2.67-3.26-2.67h-3.98l1.43,1.43c.18.18.18.47,0,.64-.18.18-.47.18-.64,0l-2.21-2.21c-.18-.18-.18-.47,0-.64l2.21-2.21c.18-.18.47-.18.64,0,.18.18.18.47,0,.64l-1.43,1.43h3.98c2.45,0,4.17,1.47,4.17,3.58,0,1.97-1.61,3.58-3.58,3.58Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
:class="iconButtonClass"
|
||||
:title="t('maskEditor.redo')"
|
||||
@click="onRedo"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 15 15"
|
||||
class="h-6.25 w-6.25 pointer-events-none fill-[var(--input-text)]"
|
||||
>
|
||||
<path
|
||||
class="cls-1"
|
||||
d="M6.23,12.18c-1.97,0-3.58-1.61-3.58-3.58,0-2.11,1.71-3.58,4.17-3.58h3.98l-1.43-1.43c-.18-.18-.18-.47,0-.64.18-.18.46-.18.64,0l2.21,2.21c.09.09.13.2.13.32s-.05.24-.13.32l-2.21,2.21c-.18.18-.47.18-.64,0-.18-.18-.18-.47,0-.64l1.43-1.43h-3.98c-1.92,0-3.26,1.1-3.26,2.67,0,1.47,1.2,2.67,2.67,2.67.25,0,.46.2.46.46s-.2.46-.46.46Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button :class="textButtonClass" @click="onInvert">
|
||||
{{ t('maskEditor.invert') }}
|
||||
</button>
|
||||
|
||||
<button :class="textButtonClass" @click="onClear">
|
||||
{{ t('maskEditor.clear') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<Button
|
||||
:label="saveButtonText"
|
||||
icon="pi pi-check"
|
||||
size="small"
|
||||
:disabled="!saveEnabled"
|
||||
@click="handleSave"
|
||||
/>
|
||||
<Button
|
||||
:label="t('g.cancel')"
|
||||
icon="pi pi-times"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
@click="handleCancel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useCanvasTools } from '@/composables/maskeditor/useCanvasTools'
|
||||
import { useMaskEditorSaver } from '@/composables/maskeditor/useMaskEditorSaver'
|
||||
import { t } from '@/i18n'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
|
||||
const store = useMaskEditorStore()
|
||||
const dialogStore = useDialogStore()
|
||||
const canvasTools = useCanvasTools()
|
||||
const saver = useMaskEditorSaver()
|
||||
|
||||
const saveButtonText = ref(t('g.save'))
|
||||
const saveEnabled = ref(true)
|
||||
|
||||
const iconButtonClass =
|
||||
'flex h-7.5 w-12.5 items-center justify-center rounded-[10px] border border-[var(--p-form-field-border-color)] pointer-events-auto transition-colors duration-100 bg-[var(--comfy-menu-bg)] hover:bg-[var(--p-surface-300)] dark-theme:bg-[var(--p-surface-800)] dark-theme:hover:bg-[var(--p-surface-900)]'
|
||||
|
||||
const textButtonClass =
|
||||
'h-7.5 w-15 rounded-[10px] border border-[var(--p-form-field-border-color)] text-[var(--input-text)] font-sans pointer-events-auto transition-colors duration-100 bg-[var(--comfy-menu-bg)] hover:bg-[var(--p-surface-300)] dark-theme:bg-[var(--p-surface-800)] dark-theme:hover:bg-[var(--p-surface-900)]'
|
||||
|
||||
const onUndo = () => {
|
||||
store.canvasHistory.undo()
|
||||
}
|
||||
|
||||
const onRedo = () => {
|
||||
store.canvasHistory.redo()
|
||||
}
|
||||
|
||||
const onInvert = () => {
|
||||
canvasTools.invertMask()
|
||||
}
|
||||
|
||||
const onClear = () => {
|
||||
canvasTools.clearMask()
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
saveButtonText.value = t('g.saving')
|
||||
saveEnabled.value = false
|
||||
|
||||
try {
|
||||
store.brushVisible = false
|
||||
await saver.save()
|
||||
dialogStore.closeDialog()
|
||||
} catch (error) {
|
||||
console.error('[TopBarHeader] Save failed:', error)
|
||||
store.brushVisible = true
|
||||
saveButtonText.value = t('g.save')
|
||||
saveEnabled.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
dialogStore.closeDialog({ key: 'global-mask-editor' })
|
||||
}
|
||||
</script>
|
||||
671
src/composables/maskeditor/useBrushDrawing.ts
Normal file
671
src/composables/maskeditor/useBrushDrawing.ts
Normal 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
|
||||
}
|
||||
}
|
||||
136
src/composables/maskeditor/useCanvasHistory.ts
Normal file
136
src/composables/maskeditor/useCanvasHistory.ts
Normal 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
|
||||
}
|
||||
}
|
||||
121
src/composables/maskeditor/useCanvasManager.ts
Normal file
121
src/composables/maskeditor/useCanvasManager.ts
Normal 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
|
||||
}
|
||||
}
|
||||
486
src/composables/maskeditor/useCanvasTools.ts
Normal file
486
src/composables/maskeditor/useCanvasTools.ts
Normal 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
|
||||
}
|
||||
}
|
||||
79
src/composables/maskeditor/useCoordinateTransform.ts
Normal file
79
src/composables/maskeditor/useCoordinateTransform.ts
Normal 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
|
||||
)
|
||||
54
src/composables/maskeditor/useImageLoader.ts
Normal file
54
src/composables/maskeditor/useImageLoader.ts
Normal 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)
|
||||
62
src/composables/maskeditor/useKeyboard.ts
Normal file
62
src/composables/maskeditor/useKeyboard.ts
Normal 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
|
||||
}
|
||||
}
|
||||
310
src/composables/maskeditor/useMaskEditorLoader.ts
Normal file
310
src/composables/maskeditor/useMaskEditorLoader.ts
Normal 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
|
||||
}
|
||||
}
|
||||
401
src/composables/maskeditor/useMaskEditorSaver.ts
Normal file
401
src/composables/maskeditor/useMaskEditorSaver.ts
Normal 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
|
||||
}
|
||||
}
|
||||
416
src/composables/maskeditor/usePanAndZoom.ts
Normal file
416
src/composables/maskeditor/usePanAndZoom.ts
Normal 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
|
||||
}
|
||||
}
|
||||
228
src/composables/maskeditor/useToolManager.ts
Normal file
228
src/composables/maskeditor/useToolManager.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -1,5 +0,0 @@
|
||||
export { UIManager } from './UIManager'
|
||||
export { ToolManager } from './ToolManager'
|
||||
export { PanAndZoomManager } from './PanAndZoomManager'
|
||||
export { KeyboardManager } from './KeyboardManager'
|
||||
export { MessageBroker } from './MessageBroker'
|
||||
@@ -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)
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -1,3 +0,0 @@
|
||||
export { PaintBucketTool } from './PaintBucketTool'
|
||||
export { ColorSelectTool } from './ColorSelectTool'
|
||||
export { BrushTool } from './BrushTool'
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
80
src/stores/maskEditorDataStore.ts
Normal file
80
src/stores/maskEditorDataStore.ts
Normal 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
|
||||
}
|
||||
})
|
||||
263
src/stores/maskEditorStore.ts
Normal file
263
src/stores/maskEditorStore.ts
Normal 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
|
||||
}
|
||||
})
|
||||
495
tests-ui/tests/composables/maskeditor/useCanvasHistory.test.ts
Normal file
495
tests-ui/tests/composables/maskeditor/useCanvasHistory.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
336
tests-ui/tests/composables/maskeditor/useCanvasManager.test.ts
Normal file
336
tests-ui/tests/composables/maskeditor/useCanvasManager.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
480
tests-ui/tests/composables/maskeditor/useCanvasTools.test.ts
Normal file
480
tests-ui/tests/composables/maskeditor/useCanvasTools.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
197
tests-ui/tests/composables/maskeditor/useImageLoader.test.ts
Normal file
197
tests-ui/tests/composables/maskeditor/useImageLoader.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user