From 1a6913c46673bad215a642a28763658f88ee7d9d Mon Sep 17 00:00:00 2001 From: Terry Jia Date: Thu, 13 Nov 2025 23:57:03 -0500 Subject: [PATCH] fully refactor mask editor into vue-based (#6629) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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) --- packages/design-system/src/css/style.css | 463 +++++ src/components/maskeditor/BrushCursor.vue | 97 + .../maskeditor/BrushSettingsPanel.vue | 129 ++ .../maskeditor/ColorSelectSettingsPanel.vue | 103 + .../maskeditor/ImageLayerSettingsPanel.vue | 227 +++ .../maskeditor/MaskEditorContent.vue | 209 ++ .../maskeditor/PaintBucketSettingsPanel.vue | 44 + src/components/maskeditor/PointerZone.vue | 95 + .../maskeditor/SettingsPanelContainer.vue | 31 + src/components/maskeditor/SidePanel.vue | 24 + src/components/maskeditor/ToolPanel.vue | 69 + .../maskeditor/controls/DropdownControl.vue | 55 + .../maskeditor/controls/SliderControl.vue | 39 + .../maskeditor/controls/ToggleControl.vue | 34 + .../maskeditor/dialog/TopBarHeader.vue | 126 ++ src/composables/maskeditor/useBrushDrawing.ts | 671 +++++++ .../maskeditor/useCanvasHistory.ts | 136 ++ .../maskeditor/useCanvasManager.ts | 121 ++ src/composables/maskeditor/useCanvasTools.ts | 486 +++++ .../maskeditor/useCoordinateTransform.ts | 79 + src/composables/maskeditor/useImageLoader.ts | 54 + src/composables/maskeditor/useKeyboard.ts | 62 + .../maskeditor/useMaskEditorLoader.ts | 310 +++ .../maskeditor/useMaskEditorSaver.ts | 401 ++++ src/composables/maskeditor/usePanAndZoom.ts | 416 ++++ src/composables/maskeditor/useToolManager.ts | 228 +++ src/extensions/core/maskeditor.ts | 116 +- .../core/maskeditor/CanvasHistory.ts | 131 -- .../core/maskeditor/MaskEditorDialog.ts | 404 ---- .../maskeditor/managers/KeyboardManager.ts | 67 - .../core/maskeditor/managers/MessageBroker.ts | 183 -- .../maskeditor/managers/PanAndZoomManager.ts | 496 ----- .../core/maskeditor/managers/ToolManager.ts | 182 -- .../core/maskeditor/managers/UIManager.ts | 1737 ----------------- .../core/maskeditor/managers/index.ts | 5 - src/extensions/core/maskeditor/styles.ts | 738 ------- .../core/maskeditor/tools/BrushTool.ts | 779 -------- .../core/maskeditor/tools/ColorSelectTool.ts | 464 ----- .../core/maskeditor/tools/PaintBucketTool.ts | 265 --- src/extensions/core/maskeditor/tools/index.ts | 3 - src/extensions/core/maskeditor/types.ts | 14 +- .../core/maskeditor/utils/brushCache.ts | 32 - .../core/maskeditor/utils/canvas.ts | 39 - .../core/maskeditor/utils/clipspace.ts | 32 - src/extensions/core/maskeditor/utils/image.ts | 62 - src/extensions/core/maskeditor/utils/index.ts | 14 - .../utils/maskEditorLayerFilenames.ts | 29 - src/locales/en/main.json | 59 +- src/stores/maskEditorDataStore.ts | 80 + src/stores/maskEditorStore.ts | 263 +++ .../maskeditor/useCanvasHistory.test.ts | 495 +++++ .../maskeditor/useCanvasManager.test.ts | 336 ++++ .../maskeditor/useCanvasTools.test.ts | 480 +++++ .../maskeditor/useImageLoader.test.ts | 197 ++ tests-ui/tests/maskeditor.test.ts | 19 - 55 files changed, 6674 insertions(+), 5756 deletions(-) create mode 100644 src/components/maskeditor/BrushCursor.vue create mode 100644 src/components/maskeditor/BrushSettingsPanel.vue create mode 100644 src/components/maskeditor/ColorSelectSettingsPanel.vue create mode 100644 src/components/maskeditor/ImageLayerSettingsPanel.vue create mode 100644 src/components/maskeditor/MaskEditorContent.vue create mode 100644 src/components/maskeditor/PaintBucketSettingsPanel.vue create mode 100644 src/components/maskeditor/PointerZone.vue create mode 100644 src/components/maskeditor/SettingsPanelContainer.vue create mode 100644 src/components/maskeditor/SidePanel.vue create mode 100644 src/components/maskeditor/ToolPanel.vue create mode 100644 src/components/maskeditor/controls/DropdownControl.vue create mode 100644 src/components/maskeditor/controls/SliderControl.vue create mode 100644 src/components/maskeditor/controls/ToggleControl.vue create mode 100644 src/components/maskeditor/dialog/TopBarHeader.vue create mode 100644 src/composables/maskeditor/useBrushDrawing.ts create mode 100644 src/composables/maskeditor/useCanvasHistory.ts create mode 100644 src/composables/maskeditor/useCanvasManager.ts create mode 100644 src/composables/maskeditor/useCanvasTools.ts create mode 100644 src/composables/maskeditor/useCoordinateTransform.ts create mode 100644 src/composables/maskeditor/useImageLoader.ts create mode 100644 src/composables/maskeditor/useKeyboard.ts create mode 100644 src/composables/maskeditor/useMaskEditorLoader.ts create mode 100644 src/composables/maskeditor/useMaskEditorSaver.ts create mode 100644 src/composables/maskeditor/usePanAndZoom.ts create mode 100644 src/composables/maskeditor/useToolManager.ts delete mode 100644 src/extensions/core/maskeditor/CanvasHistory.ts delete mode 100644 src/extensions/core/maskeditor/MaskEditorDialog.ts delete mode 100644 src/extensions/core/maskeditor/managers/KeyboardManager.ts delete mode 100644 src/extensions/core/maskeditor/managers/MessageBroker.ts delete mode 100644 src/extensions/core/maskeditor/managers/PanAndZoomManager.ts delete mode 100644 src/extensions/core/maskeditor/managers/ToolManager.ts delete mode 100644 src/extensions/core/maskeditor/managers/UIManager.ts delete mode 100644 src/extensions/core/maskeditor/managers/index.ts delete mode 100644 src/extensions/core/maskeditor/styles.ts delete mode 100644 src/extensions/core/maskeditor/tools/BrushTool.ts delete mode 100644 src/extensions/core/maskeditor/tools/ColorSelectTool.ts delete mode 100644 src/extensions/core/maskeditor/tools/PaintBucketTool.ts delete mode 100644 src/extensions/core/maskeditor/tools/index.ts delete mode 100644 src/extensions/core/maskeditor/utils/brushCache.ts delete mode 100644 src/extensions/core/maskeditor/utils/canvas.ts delete mode 100644 src/extensions/core/maskeditor/utils/clipspace.ts delete mode 100644 src/extensions/core/maskeditor/utils/image.ts delete mode 100644 src/extensions/core/maskeditor/utils/index.ts delete mode 100644 src/extensions/core/maskeditor/utils/maskEditorLayerFilenames.ts create mode 100644 src/stores/maskEditorDataStore.ts create mode 100644 src/stores/maskEditorStore.ts create mode 100644 tests-ui/tests/composables/maskeditor/useCanvasHistory.test.ts create mode 100644 tests-ui/tests/composables/maskeditor/useCanvasManager.test.ts create mode 100644 tests-ui/tests/composables/maskeditor/useCanvasTools.test.ts create mode 100644 tests-ui/tests/composables/maskeditor/useImageLoader.test.ts delete mode 100644 tests-ui/tests/maskeditor.test.ts diff --git a/packages/design-system/src/css/style.css b/packages/design-system/src/css/style.css index 503e065e4..ead8ff59f 100644 --- a/packages/design-system/src/css/style.css +++ b/packages/design-system/src/css/style.css @@ -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 ===================== */ \ No newline at end of file diff --git a/src/components/maskeditor/BrushCursor.vue b/src/components/maskeditor/BrushCursor.vue new file mode 100644 index 000000000..0f9c3463c --- /dev/null +++ b/src/components/maskeditor/BrushCursor.vue @@ -0,0 +1,97 @@ + + + diff --git a/src/components/maskeditor/BrushSettingsPanel.vue b/src/components/maskeditor/BrushSettingsPanel.vue new file mode 100644 index 000000000..5dd8f6249 --- /dev/null +++ b/src/components/maskeditor/BrushSettingsPanel.vue @@ -0,0 +1,129 @@ + + + diff --git a/src/components/maskeditor/ColorSelectSettingsPanel.vue b/src/components/maskeditor/ColorSelectSettingsPanel.vue new file mode 100644 index 000000000..5a643c3d4 --- /dev/null +++ b/src/components/maskeditor/ColorSelectSettingsPanel.vue @@ -0,0 +1,103 @@ + + + diff --git a/src/components/maskeditor/ImageLayerSettingsPanel.vue b/src/components/maskeditor/ImageLayerSettingsPanel.vue new file mode 100644 index 000000000..8abace9b3 --- /dev/null +++ b/src/components/maskeditor/ImageLayerSettingsPanel.vue @@ -0,0 +1,227 @@ + + + diff --git a/src/components/maskeditor/MaskEditorContent.vue b/src/components/maskeditor/MaskEditorContent.vue new file mode 100644 index 000000000..421ef8a54 --- /dev/null +++ b/src/components/maskeditor/MaskEditorContent.vue @@ -0,0 +1,209 @@ + + + + + diff --git a/src/components/maskeditor/PaintBucketSettingsPanel.vue b/src/components/maskeditor/PaintBucketSettingsPanel.vue new file mode 100644 index 000000000..c5d674b77 --- /dev/null +++ b/src/components/maskeditor/PaintBucketSettingsPanel.vue @@ -0,0 +1,44 @@ + + + diff --git a/src/components/maskeditor/PointerZone.vue b/src/components/maskeditor/PointerZone.vue new file mode 100644 index 000000000..082b16ca7 --- /dev/null +++ b/src/components/maskeditor/PointerZone.vue @@ -0,0 +1,95 @@ + + + diff --git a/src/components/maskeditor/SettingsPanelContainer.vue b/src/components/maskeditor/SettingsPanelContainer.vue new file mode 100644 index 000000000..a213a8000 --- /dev/null +++ b/src/components/maskeditor/SettingsPanelContainer.vue @@ -0,0 +1,31 @@ + + + diff --git a/src/components/maskeditor/SidePanel.vue b/src/components/maskeditor/SidePanel.vue new file mode 100644 index 000000000..7d1fea82d --- /dev/null +++ b/src/components/maskeditor/SidePanel.vue @@ -0,0 +1,24 @@ + + + diff --git a/src/components/maskeditor/ToolPanel.vue b/src/components/maskeditor/ToolPanel.vue new file mode 100644 index 000000000..7c04d16a0 --- /dev/null +++ b/src/components/maskeditor/ToolPanel.vue @@ -0,0 +1,69 @@ + + + diff --git a/src/components/maskeditor/controls/DropdownControl.vue b/src/components/maskeditor/controls/DropdownControl.vue new file mode 100644 index 000000000..26a924f78 --- /dev/null +++ b/src/components/maskeditor/controls/DropdownControl.vue @@ -0,0 +1,55 @@ + + + diff --git a/src/components/maskeditor/controls/SliderControl.vue b/src/components/maskeditor/controls/SliderControl.vue new file mode 100644 index 000000000..83969db3b --- /dev/null +++ b/src/components/maskeditor/controls/SliderControl.vue @@ -0,0 +1,39 @@ + + + diff --git a/src/components/maskeditor/controls/ToggleControl.vue b/src/components/maskeditor/controls/ToggleControl.vue new file mode 100644 index 000000000..dd1a98c96 --- /dev/null +++ b/src/components/maskeditor/controls/ToggleControl.vue @@ -0,0 +1,34 @@ + + + diff --git a/src/components/maskeditor/dialog/TopBarHeader.vue b/src/components/maskeditor/dialog/TopBarHeader.vue new file mode 100644 index 000000000..800cedca7 --- /dev/null +++ b/src/components/maskeditor/dialog/TopBarHeader.vue @@ -0,0 +1,126 @@ + + + diff --git a/src/composables/maskeditor/useBrushDrawing.ts b/src/composables/maskeditor/useBrushDrawing.ts new file mode 100644 index 000000000..c55d9b784 --- /dev/null +++ b/src/composables/maskeditor/useBrushDrawing.ts @@ -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({ + maxSize: 8 + }) + + const SMOOTHING_MAX_STEPS = 30 + const SMOOTHING_MIN_STEPS = 2 + + const isDrawing = ref(false) + const isDrawingLine = ref(false) + const lineStartPoint = ref(null) + const smoothingCordsArray = ref([]) + const smoothingLastDrawTime = ref(new Date()) + const initialDraw = ref(true) + + const brushStrokeCanvas = ref(null) + const brushStrokeCtx = ref(null) + + const initialPoint = ref(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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 + } +} diff --git a/src/composables/maskeditor/useCanvasHistory.ts b/src/composables/maskeditor/useCanvasHistory.ts new file mode 100644 index 000000000..fcb8185b6 --- /dev/null +++ b/src/composables/maskeditor/useCanvasHistory.ts @@ -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 + } +} diff --git a/src/composables/maskeditor/useCanvasManager.ts b/src/composables/maskeditor/useCanvasManager.ts new file mode 100644 index 000000000..d64bf4e1f --- /dev/null +++ b/src/composables/maskeditor/useCanvasManager.ts @@ -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 => { + 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 => { + 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 => { + 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 + } +} diff --git a/src/composables/maskeditor/useCanvasTools.ts b/src/composables/maskeditor/useCanvasTools.ts new file mode 100644 index 000000000..59a45247f --- /dev/null +++ b/src/composables/maskeditor/useCanvasTools.ts @@ -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(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 => { + 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 + } +} diff --git a/src/composables/maskeditor/useCoordinateTransform.ts b/src/composables/maskeditor/useCoordinateTransform.ts new file mode 100644 index 000000000..ace9f0208 --- /dev/null +++ b/src/composables/maskeditor/useCoordinateTransform.ts @@ -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 +) diff --git a/src/composables/maskeditor/useImageLoader.ts b/src/composables/maskeditor/useImageLoader.ts new file mode 100644 index 000000000..00a256b8f --- /dev/null +++ b/src/composables/maskeditor/useImageLoader.ts @@ -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 => { + 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) diff --git a/src/composables/maskeditor/useKeyboard.ts b/src/composables/maskeditor/useKeyboard.ts new file mode 100644 index 000000000..3c3575939 --- /dev/null +++ b/src/composables/maskeditor/useKeyboard.ts @@ -0,0 +1,62 @@ +import { ref } from 'vue' +import { useMaskEditorStore } from '@/stores/maskEditorStore' + +export function useKeyboard() { + const store = useMaskEditorStore() + + const keysDown = ref([]) + + 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 + } +} diff --git a/src/composables/maskeditor/useMaskEditorLoader.ts b/src/composables/maskeditor/useMaskEditorLoader.ts new file mode 100644 index 000000000..d87795d16 --- /dev/null +++ b/src/composables/maskeditor/useMaskEditorLoader.ts @@ -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 => { + 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 { + 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 { + 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 { + 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 + } +} diff --git a/src/composables/maskeditor/useMaskEditorSaver.ts b/src/composables/maskeditor/useMaskEditorSaver.ts new file mode 100644 index 000000000..4af771399 --- /dev/null +++ b/src/composables/maskeditor/useMaskEditorSaver.ts @@ -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 => { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 + } +} diff --git a/src/composables/maskeditor/usePanAndZoom.ts b/src/composables/maskeditor/usePanAndZoom.ts new file mode 100644 index 000000000..2aa833647 --- /dev/null +++ b/src/composables/maskeditor/usePanAndZoom.ts @@ -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({ x: 0, y: 0 }) + const lastTouchPoint = ref({ x: 0, y: 0 }) + + const zoom_ratio = ref(1) + const interpolatedZoomRatio = ref(1) + const pan_offset = ref({ x: 0, y: 0 }) + + const mouseDownPoint = ref(null) + const initialPan = ref({ x: 0, y: 0 }) + + const canvasContainer = ref(null) + const maskCanvas = ref(null) + const rgbCanvas = ref(null) + const rootElement = ref(null) + + const toolPanelElement = ref(null) + const sidePanelElement = ref(null) + + const image = ref(null) + const imageRootWidth = ref(0) + const imageRootHeight = ref(0) + + const cursorPoint = ref({ x: 0, y: 0 }) + const penPointerIdList = ref([]) + + 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 => { + // 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 + } +} diff --git a/src/composables/maskeditor/useToolManager.ts b/src/composables/maskeditor/useToolManager.ts new file mode 100644 index 000000000..e306f3dfb --- /dev/null +++ b/src/composables/maskeditor/useToolManager.ts @@ -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, + panZoom: ReturnType +) { + 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(null) + + const toolSettings: Record> = { + [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 => { + 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 => { + 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 => { + 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 + } +} diff --git a/src/extensions/core/maskeditor.ts b/src/extensions/core/maskeditor.ts index dd51be475..0a3018bf8 100644 --- a/src/extensions/core/maskeditor.ts +++ b/src/extensions/core/maskeditor.ts @@ -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) } diff --git a/src/extensions/core/maskeditor/CanvasHistory.ts b/src/extensions/core/maskeditor/CanvasHistory.ts deleted file mode 100644 index 11a3b7894..000000000 --- a/src/extensions/core/maskeditor/CanvasHistory.ts +++ /dev/null @@ -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) - } - } -} diff --git a/src/extensions/core/maskeditor/MaskEditorDialog.ts b/src/extensions/core/maskeditor/MaskEditorDialog.ts deleted file mode 100644 index 1362069aa..000000000 --- a/src/extensions/core/maskeditor/MaskEditorDialog.ts +++ /dev/null @@ -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 = { - filename: originalImageFilename, - subfolder: originalImageUrl.searchParams.get('subfolder') ?? undefined, - type: originalImageUrl.searchParams.get('type') ?? undefined - } - - const mkFormData = ( - blob: Blob, - filename: string, - originalImageRefOverride?: Partial - ) => { - 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 - ) => { - 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: - // 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() - } -} diff --git a/src/extensions/core/maskeditor/managers/KeyboardManager.ts b/src/extensions/core/maskeditor/managers/KeyboardManager.ts deleted file mode 100644 index 2e95767aa..000000000 --- a/src/extensions/core/maskeditor/managers/KeyboardManager.ts +++ /dev/null @@ -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) - } -} diff --git a/src/extensions/core/maskeditor/managers/MessageBroker.ts b/src/extensions/core/maskeditor/managers/MessageBroker.ts deleted file mode 100644 index 5841243b1..000000000 --- a/src/extensions/core/maskeditor/managers/MessageBroker.ts +++ /dev/null @@ -1,183 +0,0 @@ -import type { Callback } from '../types' - -export class MessageBroker { - private pushTopics: Record = {} - private pullTopics: Record Promise> = {} - - 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} 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) { - 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} - The data from the pull topic. - * @throws {Error} If the specified topic does not exist. - */ - async pull(topicName: string, data?: any): Promise { - 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} 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, topicName: string): boolean { - return topics.hasOwnProperty(topicName) - } -} diff --git a/src/extensions/core/maskeditor/managers/PanAndZoomManager.ts b/src/extensions/core/maskeditor/managers/PanAndZoomManager.ts deleted file mode 100644 index c37c3edf7..000000000 --- a/src/extensions/core/maskeditor/managers/PanAndZoomManager.ts +++ /dev/null @@ -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() - } -} diff --git a/src/extensions/core/maskeditor/managers/ToolManager.ts b/src/extensions/core/maskeditor/managers/ToolManager.ts deleted file mode 100644 index 37933c758..000000000 --- a/src/extensions/core/maskeditor/managers/ToolManager.ts +++ /dev/null @@ -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) - } -} diff --git a/src/extensions/core/maskeditor/managers/UIManager.ts b/src/extensions/core/maskeditor/managers/UIManager.ts deleted file mode 100644 index 2ecdfbafd..000000000 --- a/src/extensions/core/maskeditor/managers/UIManager.ts +++ /dev/null @@ -1,1737 +0,0 @@ -import { t } from '@/i18n' -import { ComfyApp } from '@/scripts/app' -import type { - MaskEditorDialog, - ImageLayer, - Point, - ToolInternalSettings -} from '../types' -import { MessageBroker } from './MessageBroker' -import { - BrushShape, - ColorComparisonMethod, - MaskBlendMode, - Tools, - allImageLayers, - allTools -} from '../types' -import { iconsHtml } from '../constants' -import { imageLayerFilenamesIfApplicable, mkFileUrl, toRef } from '../utils' - -export class UIManager { - private rootElement: HTMLElement - private brush!: HTMLDivElement - private brushPreviewGradient!: HTMLDivElement - private maskCtx!: CanvasRenderingContext2D - private rgbCtx!: CanvasRenderingContext2D - private imageCtx!: CanvasRenderingContext2D - private maskCanvas!: HTMLCanvasElement - private rgbCanvas!: HTMLCanvasElement - private imgCanvas!: HTMLCanvasElement - private brushSettingsHTML!: HTMLDivElement - private paintBucketSettingsHTML!: HTMLDivElement - private colorSelectSettingsHTML!: HTMLDivElement - // @ts-expect-error unused variable - private maskOpacitySlider!: HTMLInputElement - private brushHardnessSlider!: HTMLInputElement - private brushSizeSlider!: HTMLInputElement - // @ts-expect-error unused variable - private brushOpacitySlider!: HTMLInputElement - private sidebarImage!: HTMLImageElement - private saveButton!: HTMLButtonElement - private toolPanel!: HTMLDivElement - // @ts-expect-error unused variable - private sidePanel!: HTMLDivElement - private pointerZone!: HTMLDivElement - private canvasBackground!: HTMLDivElement - private canvasContainer!: HTMLDivElement - private image!: HTMLImageElement - private paint_image!: HTMLImageElement - private imageURL!: URL - private darkMode: boolean = true - private maskLayerContainer: HTMLElement | null = null - private paintLayerContainer: HTMLElement | null = null - - private createColorPicker(): HTMLInputElement { - const colorPicker = document.createElement('input') - colorPicker.type = 'color' - colorPicker.id = 'maskEditor_colorPicker' - colorPicker.value = '#FF0000' // Default color - colorPicker.addEventListener('input', (event) => { - const color = (event.target as HTMLInputElement).value - this.messageBroker.publish('setRGBColor', color) - }) - return colorPicker - } - - private maskEditor: MaskEditorDialog - private messageBroker: MessageBroker - - private mask_opacity: number = 0.8 - private maskBlendMode: MaskBlendMode = MaskBlendMode.Black - - private zoomTextHTML!: HTMLSpanElement - private dimensionsTextHTML!: HTMLSpanElement - - constructor(rootElement: HTMLElement, maskEditor: MaskEditorDialog) { - this.rootElement = rootElement - this.maskEditor = maskEditor - this.messageBroker = maskEditor.getMessageBroker() - this.addListeners() - this.addPullTopics() - } - - addListeners() { - this.messageBroker.subscribe('updateBrushPreview', async () => - this.updateBrushPreview() - ) - - this.messageBroker.subscribe( - 'paintBucketCursor', - (isPaintBucket: boolean) => this.handlePaintBucketCursor(isPaintBucket) - ) - - this.messageBroker.subscribe('panCursor', (isPan: boolean) => - this.handlePanCursor(isPan) - ) - - this.messageBroker.subscribe('setBrushVisibility', (isVisible: boolean) => - this.setBrushVisibility(isVisible) - ) - - this.messageBroker.subscribe( - 'setBrushPreviewGradientVisibility', - (isVisible: boolean) => this.setBrushPreviewGradientVisibility(isVisible) - ) - - this.messageBroker.subscribe('updateCursor', () => this.updateCursor()) - - this.messageBroker.subscribe('setZoomText', (text: string) => - this.setZoomText(text) - ) - } - - addPullTopics() { - this.messageBroker.createPullTopic( - 'maskCanvas', - async () => this.maskCanvas - ) - this.messageBroker.createPullTopic('maskCtx', async () => this.maskCtx) - this.messageBroker.createPullTopic('imageCtx', async () => this.imageCtx) - this.messageBroker.createPullTopic('imgCanvas', async () => this.imgCanvas) - this.messageBroker.createPullTopic('rgbCtx', async () => this.rgbCtx) - this.messageBroker.createPullTopic('rgbCanvas', async () => this.rgbCanvas) - this.messageBroker.createPullTopic( - 'screenToCanvas', - async (coords: Point) => this.screenToCanvas(coords) - ) - this.messageBroker.createPullTopic( - 'getCanvasContainer', - async () => this.canvasContainer - ) - this.messageBroker.createPullTopic('getMaskColor', async () => - this.getMaskColor() - ) - } - - async setlayout() { - this.detectLightMode() - var user_ui = await this.createUI() - var canvasContainer = this.createBackgroundUI() - - var brush = await this.createBrush() - await this.setBrushBorderRadius() - this.setBrushOpacity(1) - this.rootElement.appendChild(canvasContainer) - this.rootElement.appendChild(user_ui) - document.body.appendChild(brush) - } - - private async createUI() { - var ui_container = document.createElement('div') - ui_container.id = 'maskEditor_uiContainer' - - var top_bar = await this.createTopBar() - - var ui_horizontal_container = document.createElement('div') - ui_horizontal_container.id = 'maskEditor_uiHorizontalContainer' - - var side_panel_container = await this.createSidePanel() - - var pointer_zone = this.createPointerZone() - - var tool_panel = this.createToolPanel() - - ui_horizontal_container.appendChild(tool_panel) - ui_horizontal_container.appendChild(pointer_zone) - ui_horizontal_container.appendChild(side_panel_container) - - ui_container.appendChild(top_bar) - ui_container.appendChild(ui_horizontal_container) - - return ui_container - } - - private createBackgroundUI() { - const canvasContainer = document.createElement('div') - canvasContainer.id = 'maskEditorCanvasContainer' - - const imgCanvas = document.createElement('canvas') - imgCanvas.id = 'imageCanvas' - - const maskCanvas = document.createElement('canvas') - maskCanvas.id = 'maskCanvas' - - const rgbCanvas = document.createElement('canvas') - rgbCanvas.id = 'rgbCanvas' - - const canvas_background = document.createElement('div') - canvas_background.id = 'canvasBackground' - - canvasContainer.appendChild(imgCanvas) - canvasContainer.appendChild(rgbCanvas) - canvasContainer.appendChild(maskCanvas) - canvasContainer.appendChild(canvas_background) - - // prepare content - this.imgCanvas = imgCanvas! - this.rgbCanvas = rgbCanvas! - this.maskCanvas = maskCanvas! - this.canvasContainer = canvasContainer! - this.canvasBackground = canvas_background! - - let maskCtx = maskCanvas!.getContext('2d', { willReadFrequently: true }) - if (maskCtx) { - this.maskCtx = maskCtx - } - let rgbCtx = rgbCanvas.getContext('2d', { willReadFrequently: true }) - if (rgbCtx) { - this.rgbCtx = rgbCtx - } - let imgCtx = imgCanvas!.getContext('2d', { willReadFrequently: true }) - if (imgCtx) { - this.imageCtx = imgCtx - } - this.setEventHandler() - - //remove styling and move to css file - - this.imgCanvas.style.position = 'absolute' - this.rgbCanvas.style.position = 'absolute' - this.maskCanvas.style.position = 'absolute' - - this.imgCanvas.style.top = '200' - this.imgCanvas.style.left = '0' - - this.rgbCanvas.style.top = this.imgCanvas.style.top - this.rgbCanvas.style.left = this.imgCanvas.style.left - - this.maskCanvas.style.top = this.imgCanvas.style.top - this.maskCanvas.style.left = this.imgCanvas.style.left - - const maskCanvasStyle = this.getMaskCanvasStyle() - this.maskCanvas.style.mixBlendMode = maskCanvasStyle.mixBlendMode - this.maskCanvas.style.opacity = maskCanvasStyle.opacity.toString() - - return canvasContainer - } - - async setBrushBorderRadius() { - const brushSettings = await this.messageBroker.pull('brushSettings') - - if (brushSettings.type === BrushShape.Rect) { - this.brush.style.borderRadius = '0%' - // @ts-expect-error - this.brush.style.MozBorderRadius = '0%' - // @ts-expect-error - this.brush.style.WebkitBorderRadius = '0%' - } else { - this.brush.style.borderRadius = '50%' - // @ts-expect-error - this.brush.style.MozBorderRadius = '50%' - // @ts-expect-error - this.brush.style.WebkitBorderRadius = '50%' - } - } - - async initUI() { - this.saveButton.innerText = t('g.save') - this.saveButton.disabled = false - - await this.setImages(this.imgCanvas) //probably change method to initImageCanvas - } - - private async createSidePanel() { - const sidePanelWrapper = this.createContainer(true) - const side_panel = document.createElement('div') - sidePanelWrapper.id = 'maskEditor_sidePanel' - side_panel.id = 'maskEditor_sidePanelContent' - - const brush_settings = await this.createBrushSettings() - brush_settings.id = 'maskEditor_brushSettings' - this.brushSettingsHTML = brush_settings - - const paint_bucket_settings = await this.createPaintBucketSettings() - paint_bucket_settings.id = 'maskEditor_paintBucketSettings' - this.paintBucketSettingsHTML = paint_bucket_settings - - const color_select_settings = await this.createColorSelectSettings() - color_select_settings.id = 'maskEditor_colorSelectSettings' - this.colorSelectSettingsHTML = color_select_settings - - const image_layer_settings = await this.createImageLayerSettings() - - const separator = this.createSeparator() - - side_panel.appendChild(brush_settings) - side_panel.appendChild(paint_bucket_settings) - side_panel.appendChild(color_select_settings) - side_panel.appendChild(separator) - side_panel.appendChild(image_layer_settings) - sidePanelWrapper.appendChild(side_panel) - - return sidePanelWrapper - } - - private async createBrushSettings() { - const shapeColor = this.darkMode - ? 'maskEditor_brushShape_dark' - : 'maskEditor_brushShape_light' - const brush_settings_container = this.createContainer(true) - - const brush_settings_title = this.createHeadline( - t('maskEditor.Brush Settings') - ) - - const brush_shape_outer_container = this.createContainer(true) - - const brush_shape_title = this.createContainerTitle( - t('maskEditor.Brush Shape') - ) - - const brush_shape_container = this.createContainer(false) - - const accentColor = this.darkMode - ? 'maskEditor_accent_bg_dark' - : 'maskEditor_accent_bg_light' - - brush_shape_container.classList.add(accentColor) - brush_shape_container.classList.add('maskEditor_layerRow') - - const circle_shape = document.createElement('div') - circle_shape.id = 'maskEditor_sidePanelBrushShapeCircle' - circle_shape.classList.add(shapeColor) - circle_shape.addEventListener('click', () => { - this.messageBroker.publish('setBrushShape', BrushShape.Arc) - this.setBrushBorderRadius() - circle_shape.style.background = 'var(--p-button-text-primary-color)' - square_shape.style.background = '' - }) - - const square_shape = document.createElement('div') - square_shape.id = 'maskEditor_sidePanelBrushShapeSquare' - square_shape.classList.add(shapeColor) - square_shape.addEventListener('click', () => { - this.messageBroker.publish('setBrushShape', BrushShape.Rect) - this.setBrushBorderRadius() - square_shape.style.background = 'var(--p-button-text-primary-color)' - circle_shape.style.background = '' - }) - - if ( - (await this.messageBroker.pull('brushSettings')).type === BrushShape.Arc - ) { - circle_shape.style.background = 'var(--p-button-text-primary-color)' - square_shape.style.background = '' - } else { - circle_shape.style.background = '' - square_shape.style.background = 'var(--p-button-text-primary-color)' - } - - brush_shape_container.appendChild(circle_shape) - brush_shape_container.appendChild(square_shape) - - brush_shape_outer_container.appendChild(brush_shape_title) - brush_shape_outer_container.appendChild(brush_shape_container) - - const thicknesSliderObj = this.createSlider( - t('maskEditor.Thickness'), - 1, - 100, - 1, - (await this.messageBroker.pull('brushSettings')).size, - (_, value) => { - this.messageBroker.publish('setBrushSize', parseInt(value)) - this.updateBrushPreview() - } - ) - this.brushSizeSlider = thicknesSliderObj.slider - - const opacitySliderObj = this.createSlider( - t('maskEditor.Opacity'), - 0, - 1, - 0.01, - (await this.messageBroker.pull('brushSettings')).opacity, - (_, value) => { - this.messageBroker.publish('setBrushOpacity', parseFloat(value)) - this.updateBrushPreview() - } - ) - this.brushOpacitySlider = opacitySliderObj.slider - - const hardnessSliderObj = this.createSlider( - t('maskEditor.Hardness'), - 0, - 1, - 0.01, - (await this.messageBroker.pull('brushSettings')).hardness, - (_, value) => { - this.messageBroker.publish('setBrushHardness', parseFloat(value)) - this.updateBrushPreview() - } - ) - this.brushHardnessSlider = hardnessSliderObj.slider - - const brushSmoothingPrecisionSliderObj = this.createSlider( - t('maskEditor.Smoothing Precision'), - 1, - 100, - 1, - (await this.messageBroker.pull('brushSettings')).smoothingPrecision, - (_, value) => { - this.messageBroker.publish( - 'setBrushSmoothingPrecision', - parseInt(value) - ) - } - ) - - const resetBrushSettingsButton = document.createElement('button') - resetBrushSettingsButton.id = 'resetBrushSettingsButton' - resetBrushSettingsButton.innerText = t('maskEditor.Reset to Default') - - resetBrushSettingsButton.addEventListener('click', () => { - this.messageBroker.publish('setBrushShape', BrushShape.Arc) - this.messageBroker.publish('setBrushSize', 20) - this.messageBroker.publish('setBrushOpacity', 1) - this.messageBroker.publish('setBrushHardness', 1) - this.messageBroker.publish('setBrushSmoothingPrecision', 60) - - circle_shape.style.background = 'var(--p-button-text-primary-color)' - square_shape.style.background = '' - - thicknesSliderObj.slider.value = '20' - opacitySliderObj.slider.value = '1' - hardnessSliderObj.slider.value = '1' - brushSmoothingPrecisionSliderObj.slider.value = '60' - - this.setBrushBorderRadius() - this.updateBrushPreview() - }) - - brush_settings_container.appendChild(brush_settings_title) - brush_settings_container.appendChild(resetBrushSettingsButton) - brush_settings_container.appendChild(brush_shape_outer_container) - - // Create a new container for the color picker and its title - const color_picker_container = this.createContainer(true) - - // Add the color picker title - const colorPickerTitle = document.createElement('span') - colorPickerTitle.innerText = 'Color Selector' - colorPickerTitle.classList.add('maskEditor_sidePanelSubTitle') // Mimic brush shape title style - color_picker_container.appendChild(colorPickerTitle) - - // Add the color picker - const colorPicker = this.createColorPicker() - color_picker_container.appendChild(colorPicker) - - // Add the color picker container to the main settings container - brush_settings_container.appendChild(color_picker_container) - - brush_settings_container.appendChild(thicknesSliderObj.container) - brush_settings_container.appendChild(opacitySliderObj.container) - brush_settings_container.appendChild(hardnessSliderObj.container) - brush_settings_container.appendChild( - brushSmoothingPrecisionSliderObj.container - ) - - return brush_settings_container - } - - private async createPaintBucketSettings() { - const paint_bucket_settings_container = this.createContainer(true) - - const paint_bucket_settings_title = this.createHeadline( - t('maskEditor.Paint Bucket Settings') - ) - - const tolerance = await this.messageBroker.pull('getTolerance') - const paintBucketToleranceSliderObj = this.createSlider( - t('maskEditor.Tolerance'), - 0, - 255, - 1, - tolerance, - (_, value) => { - this.messageBroker.publish('setPaintBucketTolerance', parseInt(value)) - } - ) - - // Add new slider for fill opacity - const fillOpacity = (await this.messageBroker.pull('getFillOpacity')) || 100 - const fillOpacitySliderObj = this.createSlider( - t('maskEditor.Fill Opacity'), - 0, - 100, - 1, - fillOpacity, - (_, value) => { - this.messageBroker.publish('setFillOpacity', parseInt(value)) - } - ) - - paint_bucket_settings_container.appendChild(paint_bucket_settings_title) - paint_bucket_settings_container.appendChild( - paintBucketToleranceSliderObj.container - ) - // Add the new opacity slider to the UI - paint_bucket_settings_container.appendChild(fillOpacitySliderObj.container) - - return paint_bucket_settings_container - } - - private async createColorSelectSettings() { - const color_select_settings_container = this.createContainer(true) - - const color_select_settings_title = this.createHeadline( - t('maskEditor.Color Select Settings') - ) - - var tolerance = await this.messageBroker.pull('getTolerance') - const colorSelectToleranceSliderObj = this.createSlider( - t('maskEditor.Tolerance'), - 0, - 255, - 1, - tolerance, - (_, value) => { - this.messageBroker.publish('setColorSelectTolerance', parseInt(value)) - } - ) - - // Add new slider for selection opacity - const selectionOpacitySliderObj = this.createSlider( - t('maskEditor.Selection Opacity'), - 0, - 100, - 1, - 100, // Default to 100% - (_, value) => { - this.messageBroker.publish('setSelectionOpacity', parseInt(value)) - } - ) - - const livePreviewToggle = this.createToggle( - t('maskEditor.Live Preview'), - (_, value) => { - this.messageBroker.publish('setLivePreview', value) - } - ) - - const wholeImageToggle = this.createToggle( - t('maskEditor.Apply to Whole Image'), - (_, value) => { - this.messageBroker.publish('setWholeImage', value) - } - ) - - const methodOptions = Object.values(ColorComparisonMethod) - const methodSelect = this.createDropdown( - t('maskEditor.Method'), - methodOptions, - (_, value) => { - this.messageBroker.publish('setColorComparisonMethod', value) - } - ) - - const maskBoundaryToggle = this.createToggle( - t('maskEditor.Stop at mask'), - (_, value) => { - this.messageBroker.publish('setMaskBoundary', value) - } - ) - - const maskToleranceSliderObj = this.createSlider( - t('maskEditor.Mask Tolerance'), - 0, - 255, - 1, - 0, - (_, value) => { - this.messageBroker.publish('setMaskTolerance', parseInt(value)) - } - ) - - color_select_settings_container.appendChild(color_select_settings_title) - color_select_settings_container.appendChild( - colorSelectToleranceSliderObj.container - ) - // Add the new opacity slider to the UI - color_select_settings_container.appendChild( - selectionOpacitySliderObj.container - ) - color_select_settings_container.appendChild(livePreviewToggle) - color_select_settings_container.appendChild(wholeImageToggle) - color_select_settings_container.appendChild(methodSelect) - color_select_settings_container.appendChild(maskBoundaryToggle) - color_select_settings_container.appendChild( - maskToleranceSliderObj.container - ) - - return color_select_settings_container - } - - activeLayer: 'mask' | 'rgb' = 'mask' - layerButtons: Record = { - mask: (() => { - const btn = document.createElement('button') - btn.style.fontSize = '12px' - return btn - })(), - rgb: (() => { - const btn = document.createElement('button') - btn.style.fontSize = '12px' - return btn - })() - } - updateButtonsVisibility() { - allImageLayers.forEach((layer) => { - const button = this.layerButtons[layer] - if (layer === this.activeLayer) { - button.style.opacity = '0.5' - button.disabled = true - } else { - button.style.opacity = '1' - button.disabled = false - } - }) - } - - async updateLayerButtonsForTool() { - const currentTool = await this.messageBroker.pull('currentTool') - const isEraserTool = currentTool === Tools.Eraser - - // Show/hide buttons based on whether eraser tool is active - Object.values(this.layerButtons).forEach((button) => { - if (isEraserTool) { - button.style.display = 'block' - } else { - button.style.display = 'none' - } - }) - } - - async setActiveLayer(layer: 'mask' | 'rgb') { - this.messageBroker.publish('setActiveLayer', layer) - this.activeLayer = layer - this.updateButtonsVisibility() - const currentTool = await this.messageBroker.pull('currentTool') - const maskOnlyTools = [Tools.MaskPen, Tools.MaskBucket, Tools.MaskColorFill] - if (maskOnlyTools.includes(currentTool) && layer === 'rgb') { - this.setToolTo(Tools.PaintPen) - } - if (currentTool === Tools.PaintPen && layer === 'mask') { - this.setToolTo(Tools.MaskPen) - } - this.updateActiveLayerHighlight() - } - - updateActiveLayerHighlight() { - // Remove blue border from all containers - if (this.maskLayerContainer) { - this.maskLayerContainer.style.border = 'none' - } - if (this.paintLayerContainer) { - this.paintLayerContainer.style.border = 'none' - } - - // Add blue border to active layer container - if (this.activeLayer === 'mask' && this.maskLayerContainer) { - this.maskLayerContainer.style.border = '2px solid #007acc' - } else if (this.activeLayer === 'rgb' && this.paintLayerContainer) { - this.paintLayerContainer.style.border = '2px solid #007acc' - } - } - - private async createImageLayerSettings() { - const accentColor = this.darkMode - ? 'maskEditor_accent_bg_dark' - : 'maskEditor_accent_bg_light' - - const image_layer_settings_container = this.createContainer(true) - - const image_layer_settings_title = this.createHeadline( - t('maskEditor.Layers') - ) - - // Add a new container for layer selection - const layer_selection_container = this.createContainer(false) - layer_selection_container.classList.add(accentColor) - layer_selection_container.classList.add('maskEditor_layerRow') - - this.layerButtons.mask.innerText = 'Activate Layer' - this.layerButtons.mask.addEventListener('click', async () => { - this.setActiveLayer('mask') - }) - - this.layerButtons.rgb.innerText = 'Activate Layer' - this.layerButtons.rgb.addEventListener('click', async () => { - this.setActiveLayer('rgb') - }) - - // Initially hide the buttons (they'll be shown when eraser tool is selected) - this.layerButtons.mask.style.display = 'none' - this.layerButtons.rgb.style.display = 'none' - - this.setActiveLayer('mask') - - // 1. MASK LAYER CONTAINER - const mask_layer_title = this.createContainerTitle('Mask Layer') - const mask_layer_container = this.createContainer(false) - mask_layer_container.classList.add(accentColor) - mask_layer_container.classList.add('maskEditor_layerRow') - - const mask_layer_visibility_checkbox = document.createElement('input') - mask_layer_visibility_checkbox.setAttribute('type', 'checkbox') - mask_layer_visibility_checkbox.checked = true - mask_layer_visibility_checkbox.classList.add( - 'maskEditor_sidePanelLayerCheckbox' - ) - mask_layer_visibility_checkbox.addEventListener('change', (event) => { - if (!(event.target as HTMLInputElement)!.checked) { - this.maskCanvas.style.opacity = '0' - } else { - this.maskCanvas.style.opacity = String(this.mask_opacity) - } - }) - - var mask_layer_image_container = document.createElement('div') - mask_layer_image_container.classList.add( - 'maskEditor_sidePanelLayerPreviewContainer' - ) - mask_layer_image_container.innerHTML = - ' ' - - // Add checkbox, image container, and activate button to mask layer container - mask_layer_container.appendChild(mask_layer_visibility_checkbox) - mask_layer_container.appendChild(mask_layer_image_container) - mask_layer_container.appendChild(this.layerButtons.mask) - - // Store reference to container for highlighting - this.maskLayerContainer = mask_layer_container - - // 2. MASK BLENDING OPTIONS CONTAINER - const mask_blending_options_title = this.createContainerTitle( - 'Mask Blending Options' - ) - const mask_blending_options_container = this.createContainer(false) - // mask_blending_options_container.classList.add(accentColor) - mask_blending_options_container.classList.add('maskEditor_layerRow') - mask_blending_options_container.style.marginTop = '-9px' - mask_blending_options_container.style.marginBottom = '-6px' - var blending_options = ['black', 'white', 'negative'] - const sidePanelDropdownAccent = this.darkMode - ? 'maskEditor_sidePanelDropdown_dark' - : 'maskEditor_sidePanelDropdown_light' - - var mask_layer_dropdown = document.createElement('select') - mask_layer_dropdown.classList.add(sidePanelDropdownAccent) - blending_options.forEach((option) => { - var option_element = document.createElement('option') - option_element.value = option - option_element.innerText = option - mask_layer_dropdown.appendChild(option_element) - - if (option == this.maskBlendMode) { - option_element.selected = true - } - }) - - mask_layer_dropdown.addEventListener('change', (event) => { - const selectedValue = (event.target as HTMLSelectElement) - .value as MaskBlendMode - this.maskBlendMode = selectedValue - this.updateMaskColor() - }) - - // Center the dropdown in its container - // mask_blending_options_container.style.display = 'flex' - // mask_blending_options_container.style.justifyContent = 'center' - mask_blending_options_container.appendChild(mask_layer_dropdown) - - // 3. MASK OPACITY SLIDER - const mask_layer_opacity_sliderObj = this.createSlider( - t('maskEditor.Mask Opacity'), - 0.0, - 1.0, - 0.01, - this.mask_opacity, - (_, value) => { - this.mask_opacity = parseFloat(value) - this.maskCanvas.style.opacity = String(this.mask_opacity) - - if (this.mask_opacity == 0) { - mask_layer_visibility_checkbox.checked = false - } else { - mask_layer_visibility_checkbox.checked = true - } - } - ) - this.maskOpacitySlider = mask_layer_opacity_sliderObj.slider - - // 4. PAINT LAYER CONTAINER - const paint_layer_title = this.createContainerTitle('Paint Layer') - const paint_layer_container = this.createContainer(false) - paint_layer_container.classList.add(accentColor) - paint_layer_container.classList.add('maskEditor_layerRow') - - const paint_layer_checkbox = document.createElement('input') - paint_layer_checkbox.setAttribute('type', 'checkbox') - paint_layer_checkbox.classList.add('maskEditor_sidePanelLayerCheckbox') - paint_layer_checkbox.checked = true - paint_layer_checkbox.addEventListener('change', (event) => { - if (!(event.target as HTMLInputElement)!.checked) { - this.rgbCanvas.style.opacity = '0' - } else { - this.rgbCanvas.style.opacity = '1' - } - }) - - const paint_layer_image_container = document.createElement('div') - paint_layer_image_container.classList.add( - 'maskEditor_sidePanelLayerPreviewContainer' - ) - paint_layer_image_container.innerHTML = ` - - - - - ` - - paint_layer_container.appendChild(paint_layer_checkbox) - paint_layer_container.appendChild(paint_layer_image_container) - paint_layer_container.appendChild(this.layerButtons.rgb) - - // Store reference to container for highlighting - this.paintLayerContainer = paint_layer_container - - // 5. BASE IMAGE LAYER CONTAINER - const base_image_layer_title = this.createContainerTitle('Base Image Layer') - const base_image_layer_container = this.createContainer(false) - base_image_layer_container.classList.add(accentColor) - base_image_layer_container.classList.add('maskEditor_layerRow') - - const base_image_layer_visibility_checkbox = document.createElement('input') - base_image_layer_visibility_checkbox.setAttribute('type', 'checkbox') - base_image_layer_visibility_checkbox.classList.add( - 'maskEditor_sidePanelLayerCheckbox' - ) - base_image_layer_visibility_checkbox.checked = true - base_image_layer_visibility_checkbox.addEventListener('change', (event) => { - if (!(event.target as HTMLInputElement)!.checked) { - this.imgCanvas.style.opacity = '0' - } else { - this.imgCanvas.style.opacity = '1' - } - }) - - const base_image_layer_image_container = document.createElement('div') - base_image_layer_image_container.classList.add( - 'maskEditor_sidePanelLayerPreviewContainer' - ) - - const base_image_layer_image = document.createElement('img') - base_image_layer_image.id = 'maskEditor_sidePanelImageLayerImage' - base_image_layer_image.src = - ComfyApp.clipspace?.imgs?.[ComfyApp.clipspace?.selectedIndex ?? 0]?.src ?? - '' - this.sidebarImage = base_image_layer_image - - base_image_layer_image_container.appendChild(base_image_layer_image) - - base_image_layer_container.appendChild(base_image_layer_visibility_checkbox) - base_image_layer_container.appendChild(base_image_layer_image_container) - - // APPEND ALL CONTAINERS IN ORDER - image_layer_settings_container.appendChild(image_layer_settings_title) - image_layer_settings_container.appendChild( - mask_layer_opacity_sliderObj.container - ) - image_layer_settings_container.appendChild(mask_blending_options_title) - image_layer_settings_container.appendChild(mask_blending_options_container) - image_layer_settings_container.appendChild(mask_layer_title) - image_layer_settings_container.appendChild(mask_layer_container) - image_layer_settings_container.appendChild(paint_layer_title) - image_layer_settings_container.appendChild(paint_layer_container) - image_layer_settings_container.appendChild(base_image_layer_title) - image_layer_settings_container.appendChild(base_image_layer_container) - - // Initialize the active layer highlighting - this.updateActiveLayerHighlight() - - // Initialize button visibility based on current tool - this.updateLayerButtonsForTool() - - return image_layer_settings_container - } - - // Method to be called when tool changes - async onToolChange() { - await this.updateLayerButtonsForTool() - } - - private createHeadline(title: string) { - var headline = document.createElement('h3') - headline.classList.add('maskEditor_sidePanelTitle') - headline.innerText = title - - return headline - } - - private createContainer(flexDirection: boolean) { - var container = document.createElement('div') - if (flexDirection) { - container.classList.add('maskEditor_sidePanelContainerColumn') - } else { - container.classList.add('maskEditor_sidePanelContainerRow') - } - - return container - } - - private createContainerTitle(title: string) { - var container_title = document.createElement('span') - container_title.classList.add('maskEditor_sidePanelSubTitle') - container_title.innerText = title - - return container_title - } - - private createSlider( - title: string, - min: number, - max: number, - step: number, - value: number, - callback: (event: Event, value: string) => void - ) { - var slider_container = this.createContainer(true) - var slider_title = this.createContainerTitle(title) - var slider = document.createElement('input') - slider.classList.add('maskEditor_sidePanelBrushRange') - slider.setAttribute('type', 'range') - slider.setAttribute('min', String(min)) - slider.setAttribute('max', String(max)) - slider.setAttribute('step', String(step)) - slider.setAttribute('value', String(value)) - slider.addEventListener('input', (event) => { - callback(event, (event.target as HTMLInputElement).value) - }) - slider_container.appendChild(slider_title) - slider_container.appendChild(slider) - - return { container: slider_container, slider: slider } - } - - private createToggle( - title: string, - callback: (event: Event, value: boolean) => void - ) { - var outer_Container = this.createContainer(false) - var toggle_title = this.createContainerTitle(title) - - var toggle_container = document.createElement('label') - toggle_container.classList.add('maskEditor_sidePanelToggleContainer') - - var toggle_checkbox = document.createElement('input') - toggle_checkbox.setAttribute('type', 'checkbox') - toggle_checkbox.classList.add('maskEditor_sidePanelToggleCheckbox') - toggle_checkbox.addEventListener('change', (event) => { - callback(event, (event.target as HTMLInputElement).checked) - }) - - var toggleAccentColor = this.darkMode - ? 'maskEditor_toggle_bg_dark' - : 'maskEditor_toggle_bg_light' - - var toggle_switch = document.createElement('div') - toggle_switch.classList.add('maskEditor_sidePanelToggleSwitch') - toggle_switch.classList.add(toggleAccentColor) - - toggle_container.appendChild(toggle_checkbox) - toggle_container.appendChild(toggle_switch) - - outer_Container.appendChild(toggle_title) - outer_Container.appendChild(toggle_container) - - return outer_Container - } - - private createDropdown( - title: string, - options: string[], - callback: (event: Event, value: string) => void - ) { - const sidePanelDropdownAccent = this.darkMode - ? 'maskEditor_sidePanelDropdown_dark' - : 'maskEditor_sidePanelDropdown_light' - var dropdown_container = this.createContainer(false) - var dropdown_title = this.createContainerTitle(title) - - var dropdown = document.createElement('select') - dropdown.classList.add(sidePanelDropdownAccent) - dropdown.classList.add('maskEditor_containerDropdown') - - options.forEach((option) => { - var option_element = document.createElement('option') - option_element.value = option - option_element.innerText = option - dropdown.appendChild(option_element) - }) - - dropdown.addEventListener('change', (event) => { - callback(event, (event.target as HTMLSelectElement).value) - }) - - dropdown_container.appendChild(dropdown_title) - dropdown_container.appendChild(dropdown) - - return dropdown_container - } - - private createSeparator() { - var separator = document.createElement('div') - separator.classList.add('maskEditor_sidePanelSeparator') - - return separator - } - - //---------------- - - private async createTopBar() { - const buttonAccentColor = this.darkMode - ? 'maskEditor_topPanelButton_dark' - : 'maskEditor_topPanelButton_light' - - const iconButtonAccentColor = this.darkMode - ? 'maskEditor_topPanelIconButton_dark' - : 'maskEditor_topPanelIconButton_light' - - var top_bar = document.createElement('div') - top_bar.id = 'maskEditor_topBar' - - var top_bar_title_container = document.createElement('div') - top_bar_title_container.id = 'maskEditor_topBarTitleContainer' - - var top_bar_title = document.createElement('h1') - top_bar_title.id = 'maskEditor_topBarTitle' - top_bar_title.innerText = 'ComfyUI' - - top_bar_title_container.appendChild(top_bar_title) - - var top_bar_shortcuts_container = document.createElement('div') - top_bar_shortcuts_container.id = 'maskEditor_topBarShortcutsContainer' - - var top_bar_undo_button = document.createElement('div') - top_bar_undo_button.id = 'maskEditor_topBarUndoButton' - top_bar_undo_button.classList.add(iconButtonAccentColor) - top_bar_undo_button.innerHTML = - ' ' - - top_bar_undo_button.addEventListener('click', () => { - this.messageBroker.publish('undo') - }) - - var top_bar_redo_button = document.createElement('div') - top_bar_redo_button.id = 'maskEditor_topBarRedoButton' - top_bar_redo_button.classList.add(iconButtonAccentColor) - top_bar_redo_button.innerHTML = - ' ' - - top_bar_redo_button.addEventListener('click', () => { - this.messageBroker.publish('redo') - }) - - var top_bar_invert_button = document.createElement('button') - top_bar_invert_button.id = 'maskEditor_topBarInvertButton' - top_bar_invert_button.classList.add(buttonAccentColor) - top_bar_invert_button.innerText = t('maskEditor.Invert') - top_bar_invert_button.addEventListener('click', () => { - this.messageBroker.publish('invert') - }) - - var top_bar_clear_button = document.createElement('button') - top_bar_clear_button.id = 'maskEditor_topBarClearButton' - top_bar_clear_button.classList.add(buttonAccentColor) - top_bar_clear_button.innerText = t('maskEditor.Clear') - - top_bar_clear_button.addEventListener('click', () => { - this.maskCtx.clearRect( - 0, - 0, - this.maskCanvas.width, - this.maskCanvas.height - ) - this.rgbCtx.clearRect(0, 0, this.rgbCanvas.width, this.rgbCanvas.height) - this.messageBroker.publish('saveState') - }) - - var top_bar_save_button = document.createElement('button') - top_bar_save_button.id = 'maskEditor_topBarSaveButton' - top_bar_save_button.classList.add(buttonAccentColor) - top_bar_save_button.innerText = t('g.save') - this.saveButton = top_bar_save_button - - top_bar_save_button.addEventListener('click', () => { - this.maskEditor.save() - }) - - var top_bar_cancel_button = document.createElement('button') - top_bar_cancel_button.id = 'maskEditor_topBarCancelButton' - top_bar_cancel_button.classList.add(buttonAccentColor) - top_bar_cancel_button.innerText = t('g.cancel') - - top_bar_cancel_button.addEventListener('click', () => { - this.maskEditor.destroy() - }) - - top_bar_shortcuts_container.appendChild(top_bar_undo_button) - top_bar_shortcuts_container.appendChild(top_bar_redo_button) - top_bar_shortcuts_container.appendChild(top_bar_invert_button) - top_bar_shortcuts_container.appendChild(top_bar_clear_button) - top_bar_shortcuts_container.appendChild(top_bar_save_button) - top_bar_shortcuts_container.appendChild(top_bar_cancel_button) - - top_bar.appendChild(top_bar_title_container) - top_bar.appendChild(top_bar_shortcuts_container) - - return top_bar - } - - toolElements: HTMLElement[] = [] - toolSettings: Record = { - [Tools.MaskPen]: { - container: document.createElement('div'), - newActiveLayerOnSet: 'mask' - }, - [Tools.Eraser]: { - container: document.createElement('div') - }, - [Tools.PaintPen]: { - container: document.createElement('div'), - newActiveLayerOnSet: 'rgb' - }, - [Tools.MaskBucket]: { - container: document.createElement('div'), - cursor: "url('/cursor/paintBucket.png') 30 25, auto", - newActiveLayerOnSet: 'mask' - }, - [Tools.MaskColorFill]: { - container: document.createElement('div'), - cursor: "url('/cursor/colorSelect.png') 15 25, auto", - newActiveLayerOnSet: 'mask' - } - } - - setToolTo(tool: Tools) { - this.messageBroker.publish('setTool', tool) - for (let toolElement of this.toolElements) { - if (toolElement != this.toolSettings[tool].container) { - toolElement.classList.remove('maskEditor_toolPanelContainerSelected') - } else { - toolElement.classList.add('maskEditor_toolPanelContainerSelected') - this.brushSettingsHTML.style.display = 'flex' - this.colorSelectSettingsHTML.style.display = 'none' - this.paintBucketSettingsHTML.style.display = 'none' - } - } - if (tool === Tools.MaskColorFill) { - this.brushSettingsHTML.style.display = 'none' - this.colorSelectSettingsHTML.style.display = 'flex' - this.paintBucketSettingsHTML.style.display = 'none' - } else if (tool === Tools.MaskBucket) { - this.brushSettingsHTML.style.display = 'none' - this.colorSelectSettingsHTML.style.display = 'none' - this.paintBucketSettingsHTML.style.display = 'flex' - } else { - this.brushSettingsHTML.style.display = 'flex' - this.colorSelectSettingsHTML.style.display = 'none' - this.paintBucketSettingsHTML.style.display = 'none' - } - this.messageBroker.publish('setTool', tool) - this.onToolChange() - const newActiveLayer = this.toolSettings[tool].newActiveLayerOnSet - if (newActiveLayer) { - this.setActiveLayer(newActiveLayer) - } - const cursor = this.toolSettings[tool].cursor - this.pointerZone.style.cursor = cursor ?? 'none' - if (cursor) { - this.brush.style.opacity = '0' - } - } - - private createToolPanel() { - var tool_panel = document.createElement('div') - tool_panel.id = 'maskEditor_toolPanel' - this.toolPanel = tool_panel - var toolPanelHoverAccent = this.darkMode - ? 'maskEditor_toolPanelContainerDark' - : 'maskEditor_toolPanelContainerLight' - - this.toolElements = [] - // mask pen tool - const setupToolContainer = (tool: Tools) => { - this.toolSettings[tool].container = document.createElement('div') - this.toolSettings[tool].container.classList.add( - 'maskEditor_toolPanelContainer' - ) - if (tool == Tools.MaskPen) - this.toolSettings[tool].container.classList.add( - 'maskEditor_toolPanelContainerSelected' - ) - this.toolSettings[tool].container.classList.add(toolPanelHoverAccent) - this.toolSettings[tool].container.innerHTML = iconsHtml[tool] - this.toolElements.push(this.toolSettings[tool].container) - this.toolSettings[tool].container.addEventListener('click', () => { - this.setToolTo(tool) - }) - const activeIndicator = document.createElement('div') - activeIndicator.classList.add('maskEditor_toolPanelIndicator') - this.toolSettings[tool].container.appendChild(activeIndicator) - tool_panel.appendChild(this.toolSettings[tool].container) - } - allTools.forEach(setupToolContainer) - - const setupZoomIndicatorContainer = () => { - var toolPanel_zoomIndicator = document.createElement('div') - toolPanel_zoomIndicator.classList.add('maskEditor_toolPanelZoomIndicator') - toolPanel_zoomIndicator.classList.add(toolPanelHoverAccent) - - var toolPanel_zoomText = document.createElement('span') - toolPanel_zoomText.id = 'maskEditor_toolPanelZoomText' - toolPanel_zoomText.innerText = '100%' - this.zoomTextHTML = toolPanel_zoomText - - var toolPanel_DimensionsText = document.createElement('span') - toolPanel_DimensionsText.id = 'maskEditor_toolPanelDimensionsText' - toolPanel_DimensionsText.innerText = ' ' - this.dimensionsTextHTML = toolPanel_DimensionsText - - toolPanel_zoomIndicator.appendChild(toolPanel_zoomText) - toolPanel_zoomIndicator.appendChild(toolPanel_DimensionsText) - - toolPanel_zoomIndicator.addEventListener('click', () => { - this.messageBroker.publish('resetZoom') - }) - tool_panel.appendChild(toolPanel_zoomIndicator) - } - setupZoomIndicatorContainer() - - return tool_panel - } - - private createPointerZone() { - const pointer_zone = document.createElement('div') - pointer_zone.id = 'maskEditor_pointerZone' - - this.pointerZone = pointer_zone - - pointer_zone.addEventListener('pointerdown', (event: PointerEvent) => { - this.messageBroker.publish('pointerDown', event) - }) - - pointer_zone.addEventListener('pointermove', (event: PointerEvent) => { - this.messageBroker.publish('pointerMove', event) - }) - - pointer_zone.addEventListener('pointerup', (event: PointerEvent) => { - this.messageBroker.publish('pointerUp', event) - }) - - pointer_zone.addEventListener('pointerleave', () => { - this.brush.style.opacity = '0' - this.pointerZone.style.cursor = '' - }) - - pointer_zone.addEventListener('touchstart', (event: TouchEvent) => { - this.messageBroker.publish('handleTouchStart', event) - }) - - pointer_zone.addEventListener('touchmove', (event: TouchEvent) => { - this.messageBroker.publish('handleTouchMove', event) - }) - - pointer_zone.addEventListener('touchend', (event: TouchEvent) => { - this.messageBroker.publish('handleTouchEnd', event) - }) - - pointer_zone.addEventListener('wheel', (event) => - this.messageBroker.publish('wheel', event) - ) - - pointer_zone.addEventListener('pointerenter', async () => { - this.updateCursor() - }) - - return pointer_zone - } - - async screenToCanvas(clientPoint: Point): Promise { - // Get the zoom ratio - const zoomRatio = await this.messageBroker.pull('zoomRatio') - - // Get the bounding rectangles for both canvases - const maskCanvasRect = this.maskCanvas.getBoundingClientRect() - const rgbCanvasRect = this.rgbCanvas.getBoundingClientRect() - - // Check which canvas is currently being used for drawing - const currentTool = await this.messageBroker.pull('currentTool') - const isUsingRGBCanvas = currentTool === Tools.PaintPen - - // Use the appropriate canvas rect based on the current tool - const canvasRect = isUsingRGBCanvas ? rgbCanvasRect : maskCanvasRect - - // Calculate the offset between pointer zone and canvas - const offsetX = clientPoint.x - canvasRect.left + this.toolPanel.clientWidth - const offsetY = clientPoint.y - canvasRect.top + 44 // 44 is the height of the top menu - - // Adjust for zoom ratio - const x = offsetX / zoomRatio - const y = offsetY / zoomRatio - - return { x: x, y: y } - } - - private setEventHandler() { - this.maskCanvas.addEventListener('contextmenu', (event: Event) => { - event.preventDefault() - }) - - this.rgbCanvas.addEventListener('contextmenu', (event: Event) => { - event.preventDefault() - }) - - this.rootElement.addEventListener('contextmenu', (event: Event) => { - event.preventDefault() - }) - - this.rootElement.addEventListener('dragstart', (event) => { - if (event.ctrlKey) { - event.preventDefault() - } - }) - } - - private async createBrush() { - var brush = document.createElement('div') - await this.messageBroker.pull('brushSettings') - brush.id = 'maskEditor_brush' - - var brush_preview_gradient = document.createElement('div') - brush_preview_gradient.id = 'maskEditor_brushPreviewGradient' - - brush.appendChild(brush_preview_gradient) - - this.brush = brush - this.brushPreviewGradient = brush_preview_gradient - - return brush - } - - async setImages(imgCanvas: HTMLCanvasElement) { - const imgCtx = imgCanvas.getContext('2d', { willReadFrequently: true }) - const maskCtx = this.maskCtx - const maskCanvas = this.maskCanvas - - const rgbCanvas = this.rgbCanvas - - imgCtx!.clearRect(0, 0, this.imgCanvas.width, this.imgCanvas.height) - maskCtx.clearRect(0, 0, this.maskCanvas.width, this.maskCanvas.height) - - const mainImageUrl = - ComfyApp.clipspace?.imgs?.[ComfyApp.clipspace?.selectedIndex ?? 0]?.src - - // original image load - if (!mainImageUrl) { - throw new Error( - 'Unable to access image source - clipspace or image is null' - ) - } - - const mainImageFilename = - new URL(mainImageUrl).searchParams.get('filename') ?? undefined - - let combinedImageFilename: string | null | undefined - if ( - ComfyApp.clipspace?.combinedIndex !== undefined && - ComfyApp.clipspace?.imgs && - ComfyApp.clipspace.combinedIndex < ComfyApp.clipspace.imgs.length && - ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex]?.src - ) { - combinedImageFilename = new URL( - ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex].src - ).searchParams.get('filename') - } else { - combinedImageFilename = undefined - } - - const imageLayerFilenames = - mainImageFilename !== undefined - ? imageLayerFilenamesIfApplicable( - combinedImageFilename ?? mainImageFilename - ) - : undefined - - const inputUrls = { - baseImagePlusMask: imageLayerFilenames?.maskedImage - ? mkFileUrl({ ref: toRef(imageLayerFilenames.maskedImage) }) - : mainImageUrl, - paintLayer: imageLayerFilenames?.paint - ? mkFileUrl({ ref: toRef(imageLayerFilenames.paint) }) - : undefined - } - - const alpha_url = new URL(inputUrls.baseImagePlusMask) - alpha_url.searchParams.delete('channel') - alpha_url.searchParams.delete('preview') - alpha_url.searchParams.set('channel', 'a') - let mask_image: HTMLImageElement = await this.loadImage(alpha_url) - - const rgb_url = new URL(inputUrls.baseImagePlusMask) - this.imageURL = rgb_url - rgb_url.searchParams.delete('channel') - rgb_url.searchParams.set('channel', 'rgb') - this.image = new Image() - - this.image = await new Promise((resolve, reject) => { - const img = new Image() - img.crossOrigin = 'anonymous' - img.onload = () => resolve(img) - img.onerror = reject - img.src = rgb_url.toString() - }) - - if (inputUrls.paintLayer) { - const paintURL = new URL(inputUrls.paintLayer) - this.paint_image = new Image() - this.paint_image = await new Promise( - (resolve, reject) => { - const img = new Image() - img.crossOrigin = 'anonymous' - img.onload = () => resolve(img) - img.onerror = reject - img.src = paintURL.toString() - } - ) - } - - maskCanvas.width = this.image.width - maskCanvas.height = this.image.height - - rgbCanvas.width = this.image.width - rgbCanvas.height = this.image.height - - this.dimensionsTextHTML.innerText = `${this.image.width}x${this.image.height}` - - await this.invalidateCanvas(this.image, mask_image, this.paint_image) - this.messageBroker.publish('initZoomPan', [this.image, this.rootElement]) - } - - async invalidateCanvas( - orig_image: HTMLImageElement, - mask_image: HTMLImageElement, - paint_image: HTMLImageElement - ) { - this.imgCanvas.width = orig_image.width - this.imgCanvas.height = orig_image.height - - this.maskCanvas.width = orig_image.width - this.maskCanvas.height = orig_image.height - - this.rgbCanvas.width = orig_image.width - this.rgbCanvas.height = orig_image.height - - let imgCtx = this.imgCanvas.getContext('2d', { willReadFrequently: true }) - let maskCtx = this.maskCanvas.getContext('2d', { - willReadFrequently: true - }) - let rgbCtx = this.rgbCanvas.getContext('2d', { - willReadFrequently: true - }) - - imgCtx!.drawImage(orig_image, 0, 0, orig_image.width, orig_image.height) - if (paint_image) { - rgbCtx!.drawImage( - paint_image, - 0, - 0, - paint_image.width, - paint_image.height - ) - } - await this.prepare_mask( - mask_image, - this.maskCanvas, - maskCtx!, - await this.getMaskColor() - ) - } - - private async prepare_mask( - image: HTMLImageElement, - maskCanvas: HTMLCanvasElement, - maskCtx: CanvasRenderingContext2D, - maskColor: { r: number; g: number; b: number } - ) { - // paste mask data into alpha channel - maskCtx.drawImage(image, 0, 0, maskCanvas.width, maskCanvas.height) - const maskData = maskCtx.getImageData( - 0, - 0, - maskCanvas.width, - maskCanvas.height - ) - - // invert mask - 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 - } - - maskCtx.globalCompositeOperation = 'source-over' - maskCtx.putImageData(maskData, 0, 0) - } - - private async updateMaskColor() { - // update mask canvas css styles - const maskCanvasStyle = this.getMaskCanvasStyle() - this.maskCanvas.style.mixBlendMode = maskCanvasStyle.mixBlendMode - this.maskCanvas.style.opacity = maskCanvasStyle.opacity.toString() - - // update mask canvas rgb colors - const maskColor = await this.getMaskColor() - this.maskCtx.fillStyle = `rgb(${maskColor.r}, ${maskColor.g}, ${maskColor.b})` - - //set canvas background color - this.setCanvasBackground() - - const maskData = this.maskCtx.getImageData( - 0, - 0, - this.maskCanvas.width, - this.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 - } - this.maskCtx.putImageData(maskData, 0, 0) - } - - getMaskCanvasStyle() { - if (this.maskBlendMode === MaskBlendMode.Negative) { - return { - mixBlendMode: 'difference', - opacity: '1' - } - } else { - return { - mixBlendMode: 'initial', - opacity: this.mask_opacity - } - } - } - - private detectLightMode() { - this.darkMode = document.body.classList.contains('dark-theme') - } - - private loadImage(imagePath: URL): Promise { - return new Promise((resolve, reject) => { - const image = new Image() as HTMLImageElement - image.crossOrigin = 'anonymous' - image.onload = function () { - resolve(image) - } - image.onerror = function (error) { - reject(error) - } - image.src = imagePath.href - }) - } - - async updateBrushPreview() { - const cursorPoint = await this.messageBroker.pull('cursorPoint') - const pan_offset = await this.messageBroker.pull('panOffset') - const brushSettings = await this.messageBroker.pull('brushSettings') - const zoom_ratio = await this.messageBroker.pull('zoomRatio') - const centerX = cursorPoint.x + pan_offset.x - const centerY = cursorPoint.y + pan_offset.y - const brush = this.brush - const hardness = brushSettings.hardness - - // Now that brush size is constant, preview is simple - const brushRadius = brushSettings.size * zoom_ratio - const previewSize = brushRadius * 2 - - this.brushSizeSlider.value = String(brushSettings.size) - this.brushHardnessSlider.value = String(hardness) - - brush.style.width = previewSize + 'px' - brush.style.height = previewSize + 'px' - brush.style.left = centerX - brushRadius + 'px' - brush.style.top = centerY - brushRadius + 'px' - - if (hardness === 1) { - this.brushPreviewGradient.style.background = 'rgba(255, 0, 0, 0.5)' - return - } - - // Simplified gradient - hardness controls where the fade starts - const midStop = hardness * 100 - const outerStop = 100 - - this.brushPreviewGradient.style.background = ` - radial-gradient( - circle, - rgba(255, 0, 0, 0.5) 0%, - rgba(255, 0, 0, 0.25) ${midStop}%, - rgba(255, 0, 0, 0) ${outerStop}% - ) - ` - } - - getMaskBlendMode() { - return this.maskBlendMode - } - - setSidebarImage() { - this.sidebarImage.src = this.imageURL.href - } - - async getMaskColor() { - if (this.maskBlendMode === MaskBlendMode.Black) { - return { r: 0, g: 0, b: 0 } - } - if (this.maskBlendMode === MaskBlendMode.White) { - return { r: 255, g: 255, b: 255 } - } - if (this.maskBlendMode === MaskBlendMode.Negative) { - // negative effect only works with white color - return { r: 255, g: 255, b: 255 } - } - - return { r: 0, g: 0, b: 0 } - } - - async getMaskFillStyle() { - const maskColor = await this.getMaskColor() - - return 'rgb(' + maskColor.r + ',' + maskColor.g + ',' + maskColor.b + ')' - } - - async setCanvasBackground() { - if (this.maskBlendMode === MaskBlendMode.White) { - this.canvasBackground.style.background = 'black' - } else { - this.canvasBackground.style.background = 'white' - } - } - - getMaskCanvas() { - return this.maskCanvas - } - - getImgCanvas() { - return this.imgCanvas - } - - getRgbCanvas() { - return this.rgbCanvas - } - - getImage() { - return this.image - } - - setBrushOpacity(opacity: number) { - this.brush.style.opacity = String(opacity) - } - - setSaveButtonEnabled(enabled: boolean) { - this.saveButton.disabled = !enabled - } - - setSaveButtonText(text: string) { - this.saveButton.innerText = text - } - - handlePaintBucketCursor(isPaintBucket: boolean) { - if (isPaintBucket) { - this.pointerZone.style.cursor = - "url('/cursor/paintBucket.png') 30 25, auto" - } else { - this.pointerZone.style.cursor = 'none' - } - } - - handlePanCursor(isPanning: boolean) { - if (isPanning) { - this.pointerZone.style.cursor = 'grabbing' - } else { - this.pointerZone.style.cursor = 'none' - } - } - - setBrushVisibility(visible: boolean) { - this.brush.style.opacity = visible ? '1' : '0' - } - - setBrushPreviewGradientVisibility(visible: boolean) { - this.brushPreviewGradient.style.display = visible ? 'block' : 'none' - } - - async updateCursor() { - const currentTool = await this.messageBroker.pull('currentTool') - if (currentTool === Tools.MaskBucket) { - this.pointerZone.style.cursor = - "url('/cursor/paintBucket.png') 30 25, auto" - this.setBrushOpacity(0) - } else if (currentTool === Tools.MaskColorFill) { - this.pointerZone.style.cursor = - "url('/cursor/colorSelect.png') 15 25, auto" - this.setBrushOpacity(0) - } else { - this.pointerZone.style.cursor = 'none' - this.setBrushOpacity(1) - } - - this.updateBrushPreview() - this.setBrushPreviewGradientVisibility(false) - } - - setZoomText(zoomText: string) { - this.zoomTextHTML.innerText = zoomText - } - - setDimensionsText(dimensionsText: string) { - this.dimensionsTextHTML.innerText = dimensionsText - } -} diff --git a/src/extensions/core/maskeditor/managers/index.ts b/src/extensions/core/maskeditor/managers/index.ts deleted file mode 100644 index dc04c0bb0..000000000 --- a/src/extensions/core/maskeditor/managers/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { UIManager } from './UIManager' -export { ToolManager } from './ToolManager' -export { PanAndZoomManager } from './PanAndZoomManager' -export { KeyboardManager } from './KeyboardManager' -export { MessageBroker } from './MessageBroker' diff --git a/src/extensions/core/maskeditor/styles.ts b/src/extensions/core/maskeditor/styles.ts deleted file mode 100644 index b212280ea..000000000 --- a/src/extensions/core/maskeditor/styles.ts +++ /dev/null @@ -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) diff --git a/src/extensions/core/maskeditor/tools/BrushTool.ts b/src/extensions/core/maskeditor/tools/BrushTool.ts deleted file mode 100644 index c2ba8139d..000000000 --- a/src/extensions/core/maskeditor/tools/BrushTool.ts +++ /dev/null @@ -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(topic: string, data?: any): Promise - createPullTopic(topic: string, callback: (data?: any) => Promise): 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({ - 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('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( - '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( - '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( - '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('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( - '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( - '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('maskCtx')) - const rgbCtx = - this.rgbCtx || - (await this.messageBroker.pull('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') - const maskCtx = - this.maskCtx || - (await this.messageBroker.pull('maskCtx')) - const rgbCtx = - this.rgbCtx || - (await this.messageBroker.pull('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 } diff --git a/src/extensions/core/maskeditor/tools/ColorSelectTool.ts b/src/extensions/core/maskeditor/tools/ColorSelectTool.ts deleted file mode 100644 index 81706cd67..000000000 --- a/src/extensions/core/maskeditor/tools/ColorSelectTool.ts +++ /dev/null @@ -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(topic: string, data?: any): Promise - createPullTopic(topic: string, callback: (data?: any) => Promise): 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 } diff --git a/src/extensions/core/maskeditor/tools/PaintBucketTool.ts b/src/extensions/core/maskeditor/tools/PaintBucketTool.ts deleted file mode 100644 index abff4e6b8..000000000 --- a/src/extensions/core/maskeditor/tools/PaintBucketTool.ts +++ /dev/null @@ -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(topic: string, data?: any): Promise - createPullTopic(topic: string, callback: (data?: any) => Promise): 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 { - 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 } diff --git a/src/extensions/core/maskeditor/tools/index.ts b/src/extensions/core/maskeditor/tools/index.ts deleted file mode 100644 index c1d38cd8d..000000000 --- a/src/extensions/core/maskeditor/tools/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { PaintBucketTool } from './PaintBucketTool' -export { ColorSelectTool } from './ColorSelectTool' -export { BrushTool } from './BrushTool' diff --git a/src/extensions/core/maskeditor/types.ts b/src/extensions/core/maskeditor/types.ts index 35ca1e251..78f1d4dc5 100644 --- a/src/extensions/core/maskeditor/types.ts +++ b/src/extensions/core/maskeditor/types.ts @@ -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 -} diff --git a/src/extensions/core/maskeditor/utils/brushCache.ts b/src/extensions/core/maskeditor/utils/brushCache.ts deleted file mode 100644 index d29e8eb2d..000000000 --- a/src/extensions/core/maskeditor/utils/brushCache.ts +++ /dev/null @@ -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 - } -} diff --git a/src/extensions/core/maskeditor/utils/canvas.ts b/src/extensions/core/maskeditor/utils/canvas.ts deleted file mode 100644 index 099f83953..000000000 --- a/src/extensions/core/maskeditor/utils/canvas.ts +++ /dev/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] -} diff --git a/src/extensions/core/maskeditor/utils/clipspace.ts b/src/extensions/core/maskeditor/utils/clipspace.ts deleted file mode 100644 index 78684e3dd..000000000 --- a/src/extensions/core/maskeditor/utils/clipspace.ts +++ /dev/null @@ -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) - } -} diff --git a/src/extensions/core/maskeditor/utils/image.ts b/src/extensions/core/maskeditor/utils/image.ts deleted file mode 100644 index 5f6a02b40..000000000 --- a/src/extensions/core/maskeditor/utils/image.ts +++ /dev/null @@ -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((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, - 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 } -} diff --git a/src/extensions/core/maskeditor/utils/index.ts b/src/extensions/core/maskeditor/utils/index.ts deleted file mode 100644 index ca443922b..000000000 --- a/src/extensions/core/maskeditor/utils/index.ts +++ /dev/null @@ -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' diff --git a/src/extensions/core/maskeditor/utils/maskEditorLayerFilenames.ts b/src/extensions/core/maskeditor/utils/maskEditorLayerFilenames.ts deleted file mode 100644 index 6b6b5ca27..000000000 --- a/src/extensions/core/maskeditor/utils/maskEditorLayerFilenames.ts +++ /dev/null @@ -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) -} diff --git a/src/locales/en/main.json b/src/locales/en/main.json index a3bedc20b..28b56bb2c 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -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", diff --git a/src/stores/maskEditorDataStore.ts b/src/stores/maskEditorDataStore.ts new file mode 100644 index 000000000..adeaff188 --- /dev/null +++ b/src/stores/maskEditorDataStore.ts @@ -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(null) + const outputData = ref(null) + const sourceNode = ref(null) + + const isLoading = ref(false) + const loadError = ref(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 + } +}) diff --git a/src/stores/maskEditorStore.ts b/src/stores/maskEditorStore.ts new file mode 100644 index 000000000..e6546ee96 --- /dev/null +++ b/src/stores/maskEditorStore.ts @@ -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({ + type: BrushShape.Arc, + size: 10, + opacity: 0.7, + hardness: 1, + smoothingPrecision: 10 + }) + + const maskBlendMode = ref(MaskBlendMode.Black) + const activeLayer = ref('mask') + const rgbColor = ref('#FF0000') + + const currentTool = ref(Tools.MaskPen) + const isAdjustingBrush = ref(false) + + const paintBucketTolerance = ref(5) + const fillOpacity = ref(100) + + const colorSelectTolerance = ref(20) + const colorSelectLivePreview = ref(false) + const colorComparisonMethod = ref( + ColorComparisonMethod.Simple + ) + const applyWholeImage = ref(false) + const maskBoundary = ref(false) + const maskTolerance = ref(0) + const selectionOpacity = ref(100) + + const zoomRatio = ref(1) + const displayZoomRatio = ref(1) + const panOffset = ref({ x: 0, y: 0 }) + const cursorPoint = ref({ x: 0, y: 0 }) + const resetZoomTrigger = ref(0) + + const maskCanvas = ref(null) + const maskCtx = ref(null) + const rgbCanvas = ref(null) + const rgbCtx = ref(null) + const imgCanvas = ref(null) + const imgCtx = ref(null) + const canvasContainer = ref(null) + const canvasBackground = ref(null) + const pointerZone = ref(null) + const image = ref(null) + + const maskOpacity = ref(0.8) + + const brushVisible = ref(true) + const isPanning = ref(false) + const brushPreviewGradientVisible = ref(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 + } +}) diff --git a/tests-ui/tests/composables/maskeditor/useCanvasHistory.test.ts b/tests-ui/tests/composables/maskeditor/useCanvasHistory.test.ts new file mode 100644 index 000000000..c70546f82 --- /dev/null +++ b/tests-ui/tests/composables/maskeditor/useCanvasHistory.test.ts @@ -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() + }) + }) +}) diff --git a/tests-ui/tests/composables/maskeditor/useCanvasManager.test.ts b/tests-ui/tests/composables/maskeditor/useCanvasManager.test.ts new file mode 100644 index 000000000..4fe40df6e --- /dev/null +++ b/tests-ui/tests/composables/maskeditor/useCanvasManager.test.ts @@ -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') + }) + }) +}) diff --git a/tests-ui/tests/composables/maskeditor/useCanvasTools.test.ts b/tests-ui/tests/composables/maskeditor/useCanvasTools.test.ts new file mode 100644 index 000000000..991d19c00 --- /dev/null +++ b/tests-ui/tests/composables/maskeditor/useCanvasTools.test.ts @@ -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) + }) + }) +}) diff --git a/tests-ui/tests/composables/maskeditor/useImageLoader.test.ts b/tests-ui/tests/composables/maskeditor/useImageLoader.test.ts new file mode 100644 index 000000000..ae4938366 --- /dev/null +++ b/tests-ui/tests/composables/maskeditor/useImageLoader.test.ts @@ -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) + }) + }) +}) diff --git a/tests-ui/tests/maskeditor.test.ts b/tests-ui/tests/maskeditor.test.ts deleted file mode 100644 index 448c2cb42..000000000 --- a/tests-ui/tests/maskeditor.test.ts +++ /dev/null @@ -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) - }) -})