From 9f5245dc805df689a5ad1ee5fe64b8a3a11ed198 Mon Sep 17 00:00:00 2001 From: Terry Jia Date: Sun, 26 Oct 2025 20:34:36 -0400 Subject: [PATCH] [refactor] Split mask editor into smaller files (#6308) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Split mask editor structure into smaller files ## Changes This PR is a prerequisite step for [the issue - refactoring the mask editor using Vue](https://github.com/Comfy-Org/ComfyUI_frontend/issues/5956). It splits the current monolithic maskeditor.ts (about 5700 lines) into separate files; otherwise, the original single file would be very difficult to analyze or maintain. This PR itself does not introduce any Vue, nor should it have any functional changes or modifications. It's purely a code-level split, with all related files placed in the maskeditor folder. The original maskeditor.ts has been renamed to maskeditor.ts.backup for future reference. ## Review Focus Since this PR is only for splitting purposes, all logic remains consistent with the original. Therefore, for any reviewer: any code logic improvements should happen in the subsequent Vue-based refactoring, not in this PR. Following this PR, I will perform a Vue-based refactoring of the mask editor to align with the frontend's overall Vue architecture and provide better cloud-related support. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-6308-refactor-Split-mask-editor-into-smaller-files-2986d73d36508131937dd43e465a47bd) by [Unito](https://www.unito.io) --- src/extensions/core/maskeditor.ts | 5565 +---------------- .../core/maskeditor/CanvasHistory.ts | 131 + .../core/maskeditor/MaskEditorDialog.ts | 404 ++ src/extensions/core/maskeditor/constants.ts | 36 + .../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 | 75 + .../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 | 0 tests-ui/tests/maskeditor.test.ts | 2 +- 23 files changed, 5748 insertions(+), 5563 deletions(-) create mode 100644 src/extensions/core/maskeditor/CanvasHistory.ts create mode 100644 src/extensions/core/maskeditor/MaskEditorDialog.ts create mode 100644 src/extensions/core/maskeditor/constants.ts create mode 100644 src/extensions/core/maskeditor/managers/KeyboardManager.ts create mode 100644 src/extensions/core/maskeditor/managers/MessageBroker.ts create mode 100644 src/extensions/core/maskeditor/managers/PanAndZoomManager.ts create mode 100644 src/extensions/core/maskeditor/managers/ToolManager.ts create mode 100644 src/extensions/core/maskeditor/managers/UIManager.ts create mode 100644 src/extensions/core/maskeditor/managers/index.ts create mode 100644 src/extensions/core/maskeditor/styles.ts create mode 100644 src/extensions/core/maskeditor/tools/BrushTool.ts create mode 100644 src/extensions/core/maskeditor/tools/ColorSelectTool.ts create mode 100644 src/extensions/core/maskeditor/tools/PaintBucketTool.ts create mode 100644 src/extensions/core/maskeditor/tools/index.ts create mode 100644 src/extensions/core/maskeditor/types.ts create mode 100644 src/extensions/core/maskeditor/utils/brushCache.ts create mode 100644 src/extensions/core/maskeditor/utils/canvas.ts create mode 100644 src/extensions/core/maskeditor/utils/clipspace.ts create mode 100644 src/extensions/core/maskeditor/utils/image.ts create mode 100644 src/extensions/core/maskeditor/utils/index.ts rename src/extensions/core/{ => maskeditor/utils}/maskEditorLayerFilenames.ts (100%) diff --git a/src/extensions/core/maskeditor.ts b/src/extensions/core/maskeditor.ts index f8dc58524..dd51be475 100644 --- a/src/extensions/core/maskeditor.ts +++ b/src/extensions/core/maskeditor.ts @@ -1,5406 +1,13 @@ -import QuickLRU from '@alloc/quick-lru' -import { debounce } from 'es-toolkit/compat' import _ from 'es-toolkit/compat' -import { t } from '@/i18n' - -import { api } from '../../scripts/api' import { app } from '../../scripts/app' import { ComfyApp } from '../../scripts/app' -import { $el, ComfyDialog } from '../../scripts/ui' -import { getStorageValue, setStorageValue } from '../../scripts/utils' -import { hexToRgb } from '../../utils/colorUtil' -import { parseToRgb } from '../../utils/colorUtil' import { ClipspaceDialog } from './clipspace' -import { - imageLayerFilenamesByTimestamp, - imageLayerFilenamesIfApplicable -} from './maskEditorLayerFilenames' +import { MaskEditorDialog } from './maskeditor/MaskEditorDialog' import { MaskEditorDialogOld } from './maskEditorOld' -var 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; - } - -` - -var styleSheet = document.createElement('style') -styleSheet.type = 'text/css' -styleSheet.innerText = styles -document.head.appendChild(styleSheet) - -enum BrushShape { - Arc = 'arc', - Rect = 'rect' -} - -enum Tools { - MaskPen = 'pen', - PaintPen = 'rgbPaint', - Eraser = 'eraser', - MaskBucket = 'paintBucket', - MaskColorFill = 'colorSelect' -} - -const allTools = [ - Tools.MaskPen, - Tools.PaintPen, - Tools.Eraser, - Tools.MaskBucket, - Tools.MaskColorFill -] - -const allImageLayers = ['mask', 'rgb'] as const -type ImageLayer = (typeof allImageLayers)[number] - -interface ToolInternalSettings { - container: HTMLElement - cursor?: string - newActiveLayerOnSet?: ImageLayer -} - -enum CompositionOperation { - SourceOver = 'source-over', - DestinationOut = 'destination-out' -} - -enum MaskBlendMode { - Black = 'black', - White = 'white', - Negative = 'negative' -} - -enum ColorComparisonMethod { - Simple = 'simple', - HSL = 'hsl', - LAB = 'lab' -} - -interface Point { - x: number - y: number -} - -interface Offset { - x: number - y: number -} - -interface Brush { - type: BrushShape - size: number - opacity: number - hardness: number - smoothingPrecision: number -} - -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) { - 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 - } -} - -type Callback = (data?: any) => void - -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() - } -} - -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) - } - } -} - -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('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') - } -} - -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('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('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) - } - } -} - -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('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) - } -} - -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 - } -} - -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) - } -} - -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() - } -} - -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) - } -} - -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) - } -} +// Import styles to inject into document +import './maskeditor/styles' // Function to open the mask editor function openMaskEditor(): void { @@ -5536,169 +143,3 @@ const changeBrushSize = async (sizeChanger: (oldSize: number) => number) => { messageBroker.publish('setBrushSize', newBrushSize) messageBroker.publish('updateBrushPreview') } - -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 } -} - -const isAlphaValue = (index: number) => index % 4 === 3 - -const removeImageRgbValuesAndInvertAlpha = (imageData: Uint8ClampedArray) => - imageData.map((val, i) => (isAlphaValue(i) ? 255 - val : 0)) - -type Ref = { filename: string; subfolder?: string; type?: string } - -/** - * 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. - * */ -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) - } -} - -const ensureImageFullyLoaded = (src: string) => - new Promise((resolve, reject) => { - const maskImage = new Image() - maskImage.src = src - maskImage.onload = () => resolve() - maskImage.onerror = reject - }) - -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] -} - -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 -} - -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] -} - -const iconsHtml: Record = { - [Tools.MaskPen]: ` - - - - `, - [Tools.Eraser]: ` - - - - - - - - `, - [Tools.MaskBucket]: ` - - - - - - `, - [Tools.MaskColorFill]: ` - - - - `, - [Tools.PaintPen]: ` - - - - - ` -} - -const toRef = (filename: string): Ref => ({ - filename, - subfolder: 'clipspace', - type: 'input' -}) - -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 - const fullyResolvedUrl = imageElement.src - return fullyResolvedUrl -} diff --git a/src/extensions/core/maskeditor/CanvasHistory.ts b/src/extensions/core/maskeditor/CanvasHistory.ts new file mode 100644 index 000000000..11a3b7894 --- /dev/null +++ b/src/extensions/core/maskeditor/CanvasHistory.ts @@ -0,0 +1,131 @@ +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 new file mode 100644 index 000000000..1362069aa --- /dev/null +++ b/src/extensions/core/maskeditor/MaskEditorDialog.ts @@ -0,0 +1,404 @@ +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/constants.ts b/src/extensions/core/maskeditor/constants.ts new file mode 100644 index 000000000..e352422f3 --- /dev/null +++ b/src/extensions/core/maskeditor/constants.ts @@ -0,0 +1,36 @@ +import { Tools } from './types' + +export const iconsHtml: Record = { + [Tools.MaskPen]: ` + + + + `, + [Tools.Eraser]: ` + + + + + + + + `, + [Tools.MaskBucket]: ` + + + + + + `, + [Tools.MaskColorFill]: ` + + + + `, + [Tools.PaintPen]: ` + + + + + ` +} diff --git a/src/extensions/core/maskeditor/managers/KeyboardManager.ts b/src/extensions/core/maskeditor/managers/KeyboardManager.ts new file mode 100644 index 000000000..2e95767aa --- /dev/null +++ b/src/extensions/core/maskeditor/managers/KeyboardManager.ts @@ -0,0 +1,67 @@ +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 new file mode 100644 index 000000000..5841243b1 --- /dev/null +++ b/src/extensions/core/maskeditor/managers/MessageBroker.ts @@ -0,0 +1,183 @@ +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 new file mode 100644 index 000000000..c37c3edf7 --- /dev/null +++ b/src/extensions/core/maskeditor/managers/PanAndZoomManager.ts @@ -0,0 +1,496 @@ +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 new file mode 100644 index 000000000..37933c758 --- /dev/null +++ b/src/extensions/core/maskeditor/managers/ToolManager.ts @@ -0,0 +1,182 @@ +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 new file mode 100644 index 000000000..2ecdfbafd --- /dev/null +++ b/src/extensions/core/maskeditor/managers/UIManager.ts @@ -0,0 +1,1737 @@ +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 new file mode 100644 index 000000000..dc04c0bb0 --- /dev/null +++ b/src/extensions/core/maskeditor/managers/index.ts @@ -0,0 +1,5 @@ +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 new file mode 100644 index 000000000..b212280ea --- /dev/null +++ b/src/extensions/core/maskeditor/styles.ts @@ -0,0 +1,738 @@ +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 new file mode 100644 index 000000000..c2ba8139d --- /dev/null +++ b/src/extensions/core/maskeditor/tools/BrushTool.ts @@ -0,0 +1,779 @@ +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 new file mode 100644 index 000000000..81706cd67 --- /dev/null +++ b/src/extensions/core/maskeditor/tools/ColorSelectTool.ts @@ -0,0 +1,464 @@ +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 new file mode 100644 index 000000000..abff4e6b8 --- /dev/null +++ b/src/extensions/core/maskeditor/tools/PaintBucketTool.ts @@ -0,0 +1,265 @@ +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 new file mode 100644 index 000000000..c1d38cd8d --- /dev/null +++ b/src/extensions/core/maskeditor/tools/index.ts @@ -0,0 +1,3 @@ +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 new file mode 100644 index 000000000..35ca1e251 --- /dev/null +++ b/src/extensions/core/maskeditor/types.ts @@ -0,0 +1,75 @@ +export enum BrushShape { + Arc = 'arc', + Rect = 'rect' +} + +export enum Tools { + MaskPen = 'pen', + PaintPen = 'rgbPaint', + Eraser = 'eraser', + MaskBucket = 'paintBucket', + MaskColorFill = 'colorSelect' +} + +export const allTools = [ + Tools.MaskPen, + Tools.PaintPen, + Tools.Eraser, + Tools.MaskBucket, + Tools.MaskColorFill +] + +export const allImageLayers = ['mask', 'rgb'] as const +export type ImageLayer = (typeof allImageLayers)[number] + +export interface ToolInternalSettings { + container: HTMLElement + cursor?: string + newActiveLayerOnSet?: ImageLayer +} + +export enum CompositionOperation { + SourceOver = 'source-over', + DestinationOut = 'destination-out' +} + +export enum MaskBlendMode { + Black = 'black', + White = 'white', + Negative = 'negative' +} + +export enum ColorComparisonMethod { + Simple = 'simple', + HSL = 'hsl', + LAB = 'lab' +} + +export interface Point { + x: number + y: number +} + +export interface Offset { + x: number + y: number +} + +export interface Brush { + type: BrushShape + size: number + opacity: number + 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 new file mode 100644 index 000000000..d29e8eb2d --- /dev/null +++ b/src/extensions/core/maskeditor/utils/brushCache.ts @@ -0,0 +1,32 @@ +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 new file mode 100644 index 000000000..099f83953 --- /dev/null +++ b/src/extensions/core/maskeditor/utils/canvas.ts @@ -0,0 +1,39 @@ +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 new file mode 100644 index 000000000..78684e3dd --- /dev/null +++ b/src/extensions/core/maskeditor/utils/clipspace.ts @@ -0,0 +1,32 @@ +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 new file mode 100644 index 000000000..5f6a02b40 --- /dev/null +++ b/src/extensions/core/maskeditor/utils/image.ts @@ -0,0 +1,62 @@ +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 new file mode 100644 index 000000000..ca443922b --- /dev/null +++ b/src/extensions/core/maskeditor/utils/index.ts @@ -0,0 +1,14 @@ +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/maskEditorLayerFilenames.ts b/src/extensions/core/maskeditor/utils/maskEditorLayerFilenames.ts similarity index 100% rename from src/extensions/core/maskEditorLayerFilenames.ts rename to src/extensions/core/maskeditor/utils/maskEditorLayerFilenames.ts diff --git a/tests-ui/tests/maskeditor.test.ts b/tests-ui/tests/maskeditor.test.ts index d3fad6e33..448c2cb42 100644 --- a/tests-ui/tests/maskeditor.test.ts +++ b/tests-ui/tests/maskeditor.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { imageLayerFilenamesIfApplicable } from '@/extensions/core/maskEditorLayerFilenames' +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.)