import { app } from '../../scripts/app' import { ComfyDialog, $el } from '../../scripts/ui' import { ComfyApp } from '../../scripts/app' import { api } from '../../scripts/api' import { ClipspaceDialog } from './clipspace' 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; } #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: hidden; width: 220px; } #maskEditor_sidePanelShortcuts { display: flex; flex-direction: row; width: 200px; 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: 200px; 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: 200px; align-items: center; } .maskEditor_sidePanelLayer { display: flex; width: 200px; 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: var(--sidebar-width); z-index: 8888; background: var(--comfy-menu-bg); display: flex; flex-direction: column; } .maskEditor_toolPanelContainer { width: var(--sidebar-width); height: var(--sidebar-width); 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: 200px; padding: 10px; } #canvasBackground { background: white; width: 100%; height: 100%; } #maskEditor_sidePanelButtonsContainer { display: flex; flex-direction: column; gap: 10px; margin-top: 10px; } .maskEditor_sidePanelSeparator { width: 200px; height: 2px; background: var(--border-color); margin-top: 5px; margin-bottom: 5px; } #maskEditor_pointerZone { width: calc(100% - var(--sidebar-width) - 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: 100%; display: flex; } #maskEditor_topBar { display: flex; height: 44px; align-items: center; background: var(--comfy-menu-bg); } #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: 200px; } #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; } .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: 200px; 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: var(--sidebar-width); height: var(--sidebar-width); 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 { Pen = 'pen', Eraser = 'eraser', PaintBucket = 'paintBucket', ColorSelect = 'colorSelect' } 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 } export interface Brush { size: number opacity: number hardness: number type: BrushShape } type Callback = (data?: any) => void class MaskEditorDialog extends ComfyDialog { static instance: MaskEditorDialog | null = null //new private uiManager!: UIManager private toolManager!: ToolManager private panAndZoomManager!: PanAndZoomManager 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 ) { MaskEditorDialog.instance = new MaskEditorDialog() } return MaskEditorDialog.instance } 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()) } isOpened() { return this.isOpen } async save() { const backupCanvas = document.createElement('canvas') const imageCanvas = this.uiManager.getImgCanvas() const maskCanvas = this.uiManager.getMaskCanvas() const image = this.uiManager.getImage() const backupCtx = backupCanvas.getContext('2d', { willReadFrequently: true }) backupCanvas.width = imageCanvas.width backupCanvas.height = imageCanvas.height if (!backupCtx) { return } // Ensure the mask image is fully loaded const maskImageLoaded = new Promise((resolve, reject) => { const maskImage = new Image() maskImage.src = maskCanvas.toDataURL() maskImage.onload = () => { resolve() } maskImage.onerror = (error) => { reject(error) } }) try { await maskImageLoaded } catch (error) { console.error('Error loading mask image:', error) return } backupCtx.clearRect(0, 0, backupCanvas.width, backupCanvas.height) backupCtx.drawImage( maskCanvas, 0, 0, maskCanvas.width, maskCanvas.height, 0, 0, backupCanvas.width, backupCanvas.height ) let maskHasContent = false const maskData = backupCtx.getImageData( 0, 0, backupCanvas.width, backupCanvas.height ) for (let i = 0; i < maskData.data.length; i += 4) { if (maskData.data[i + 3] !== 0) { maskHasContent = true break } } // paste mask data into alpha channel const backupData = backupCtx.getImageData( 0, 0, backupCanvas.width, backupCanvas.height ) let backupHasContent = false for (let i = 0; i < backupData.data.length; i += 4) { if (backupData.data[i + 3] !== 0) { backupHasContent = true break } } if (maskHasContent && !backupHasContent) { console.error('Mask appears to be empty') alert('Cannot save empty mask') return } // refine mask image for (let i = 0; i < backupData.data.length; i += 4) { const alpha = backupData.data[i + 3] backupData.data[i] = 0 backupData.data[i + 1] = 0 backupData.data[i + 2] = 0 backupData.data[i + 3] = 255 - alpha } backupCtx.globalCompositeOperation = CompositionOperation.SourceOver backupCtx.putImageData(backupData, 0, 0) const formData = new FormData() const filename = 'clipspace-mask-' + performance.now() + '.png' const item = { filename: filename, subfolder: 'clipspace', type: 'input' } if (ComfyApp?.clipspace?.widgets?.length) { const index = ComfyApp.clipspace.widgets.findIndex( (obj) => obj?.name === 'image' ) if (index >= 0 && item !== undefined) { try { ComfyApp.clipspace.widgets[index].value = item } catch (err) { console.warn('Failed to set widget value:', err) } } } const dataURL = backupCanvas.toDataURL() const blob = this.dataURLToBlob(dataURL) let original_url = new URL(image.src) type Ref = { filename: string; subfolder?: string; type?: string } this.uiManager.setBrushOpacity(0) const filenameRef = original_url.searchParams.get('filename') if (!filenameRef) { throw new Error('filename parameter is required') } const original_ref: Ref = { filename: filenameRef } let original_subfolder = original_url.searchParams.get('subfolder') if (original_subfolder) original_ref.subfolder = original_subfolder let original_type = original_url.searchParams.get('type') if (original_type) original_ref.type = original_type formData.append('image', blob, filename) formData.append('original_ref', JSON.stringify(original_ref)) formData.append('type', 'input') formData.append('subfolder', 'clipspace') this.uiManager.setSaveButtonText('Saving') this.uiManager.setSaveButtonEnabled(false) this.keyboardManager.removeListeners() // Retry mechanism const maxRetries = 3 let attempt = 0 let success = false while (attempt < maxRetries && !success) { try { await this.uploadMask(item, formData) success = true } 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.') } } } if (success) { ComfyApp.onClipspaceEditorSave() this.close() this.isOpen = false } else { this.uiManager.setSaveButtonText('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 uploadMask( filepath: { filename: string; subfolder: string; type: string }, formData: FormData, retries = 3 ) { if (retries <= 0) { throw new Error('Max retries reached') return } await api .fetchApi('/upload/mask', { method: 'POST', body: formData }) .then((response) => { if (!response.ok) { console.log('Failed to upload mask:', response) this.uploadMask(filepath, formData, 2) } }) .catch((error) => { console.error('Error:', error) }) try { const selectedIndex = ComfyApp.clipspace?.selectedIndex if (ComfyApp.clipspace?.imgs && selectedIndex !== undefined) { // Create and set new image const newImage = new Image() newImage.src = api.apiURL( '/view?' + new URLSearchParams(filepath).toString() + app.getPreviewFormatParam() + app.getRandParam() ) ComfyApp.clipspace.imgs[selectedIndex] = newImage // Update images array if it exists if (ComfyApp.clipspace.images) { ComfyApp.clipspace.images[selectedIndex] = filepath } } } catch (err) { console.warn('Failed to update clipspace image:', err) } ClipspaceDialog.invalidatePreview() } } class CanvasHistory { private maskEditor!: MaskEditorDialog private messageBroker!: MessageBroker private canvas!: HTMLCanvasElement private ctx!: CanvasRenderingContext2D private states: 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') } 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) { // Canvas not ready yet, defer initialization requestAnimationFrame(() => this.saveInitialState()) return } this.clearStates() const state = this.ctx.getImageData( 0, 0, this.canvas.width, this.canvas.height ) this.states.push(state) 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 state = this.ctx.getImageData( 0, 0, this.canvas.width, this.canvas.height ) this.states.push(state) 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: ImageData) { if (state && this.initialized) { this.ctx.putImageData(state, 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 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()) } private addPullTopics() { this.messageBroker.createPullTopic( 'getTolerance', async () => this.tolerance ) } 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 255 for fill mode, 0 for erase mode this.setPixel(x, y, isFillMode ? 255 : 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 { 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 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) ) } 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, 255, maskColor) } } // 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, 255, maskColor) // 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 } } 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 initialDraw: boolean = true brushStrokeCanvas: HTMLCanvasElement | null = null brushStrokeCtx: CanvasRenderingContext2D | null = null //brush adjustment isBrushAdjusting: boolean = false brushPreviewGradient: HTMLElement | null = null initialPoint: Point | null = null useDominantAxis: boolean = false brushAdjustmentSpeed: number = 1.0 maskEditor: MaskEditorDialog messageBroker: MessageBroker 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' ) this.brushSettings = { size: 10, opacity: 100, hardness: 1, type: BrushShape.Arc } 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( 'setBrushSmoothingPrecision', (precision: number) => this.setBrushSmoothingPrecision(precision) ) //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( '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 } //check if user wants to draw line or free draw 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 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 distanceBetweenPoints = (this.brushSettings.size / this.smoothingPrecision) * 6 const stepNr = Math.ceil(totalLength / distanceBetweenPoints) 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.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 sizeDelta = cappedDeltaX / 40 const hardnessDelta = cappedDeltaY / 800 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 brushType = await this.messageBroker.pull('brushType') const maskColor = await this.messageBroker.pull('getMaskColor') const size = brushSettings.size const sliderOpacity = brushSettings.opacity const opacity = overrideOpacity == undefined ? sliderOpacity : overrideOpacity const hardness = brushSettings.hardness const x = point.x const y = point.y // Extend the gradient radius beyond the brush size const extendedSize = size * (2 - hardness) let gradient = maskCtx.createRadialGradient(x, y, 0, x, y, extendedSize) const isErasing = maskCtx.globalCompositeOperation === 'destination-out' if (hardness === 1) { console.log(sliderOpacity, opacity) gradient.addColorStop( 0, isErasing ? `rgba(255, 255, 255, ${opacity})` : `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})` ) gradient.addColorStop( 1, isErasing ? `rgba(255, 255, 255, ${opacity})` : `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})` ) } else { let softness = 1 - hardness let innerStop = Math.max(0, hardness - softness) let outerStop = size / extendedSize if (isErasing) { gradient.addColorStop(0, `rgba(255, 255, 255, ${opacity})`) gradient.addColorStop(innerStop, `rgba(255, 255, 255, ${opacity})`) gradient.addColorStop(outerStop, `rgba(255, 255, 255, ${opacity / 2})`) gradient.addColorStop(1, `rgba(255, 255, 255, 0)`) } else { gradient.addColorStop( 0, `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})` ) gradient.addColorStop( innerStop, `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})` ) gradient.addColorStop( outerStop, `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity / 2})` ) gradient.addColorStop( 1, `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, 0)` ) } } maskCtx.fillStyle = gradient maskCtx.beginPath() if (brushType === BrushShape.Rect) { maskCtx.rect( x - extendedSize, y - extendedSize, extendedSize * 2, extendedSize * 2 ) } else { maskCtx.arc(x, y, extendedSize, 0, Math.PI * 2, false) } maskCtx.fill() } private async init_shape(compositionOperation: CompositionOperation) { const maskBlendMode = await this.messageBroker.pull('maskBlendMode') const maskCtx = this.maskCtx || (await this.messageBroker.pull('maskCtx')) maskCtx.beginPath() if (compositionOperation == CompositionOperation.SourceOver) { maskCtx.fillStyle = maskBlendMode maskCtx.globalCompositeOperation = CompositionOperation.SourceOver } else if (compositionOperation == CompositionOperation.DestinationOut) { maskCtx.globalCompositeOperation = CompositionOperation.DestinationOut } } private calculateCubicSplinePoints( points: Point[], numSegments: number = 10 ): Point[] { const result: Point[] = [] const xCoords = points.map((p) => p.x) const yCoords = points.map((p) => p.y) const xDerivatives = this.calculateSplineCoefficients(xCoords) const yDerivatives = this.calculateSplineCoefficients(yCoords) // Generate points along the spline for (let i = 0; i < points.length - 1; i++) { const p0 = points[i] const p1 = points[i + 1] const d0x = xDerivatives[i] const d1x = xDerivatives[i + 1] const d0y = yDerivatives[i] const d1y = yDerivatives[i + 1] for (let t = 0; t <= numSegments; t++) { const t_normalized = t / numSegments // Hermite basis functions const h00 = 2 * t_normalized ** 3 - 3 * t_normalized ** 2 + 1 const h10 = t_normalized ** 3 - 2 * t_normalized ** 2 + t_normalized const h01 = -2 * t_normalized ** 3 + 3 * t_normalized ** 2 const h11 = t_normalized ** 3 - t_normalized ** 2 const x = h00 * p0.x + h10 * d0x + h01 * p1.x + h11 * d1x const y = h00 * p0.y + h10 * d0y + h01 * p1.y + h11 * d1y result.push({ x, y }) } } return result } private generateEvenlyDistributedPoints( splinePoints: Point[], numPoints: number ): Point[] { const distances: number[] = [0] for (let i = 1; i < splinePoints.length; i++) { const dx = splinePoints[i].x - splinePoints[i - 1].x const dy = splinePoints[i].y - splinePoints[i - 1].y const dist = Math.hypot(dx, dy) distances.push(distances[i - 1] + dist) } const totalLength = distances[distances.length - 1] const interval = totalLength / (numPoints - 1) const result: Point[] = [] let currentIndex = 0 for (let i = 0; i < numPoints; i++) { const targetDistance = i * interval while ( currentIndex < distances.length - 1 && distances[currentIndex + 1] < targetDistance ) { currentIndex++ } const t = (targetDistance - distances[currentIndex]) / (distances[currentIndex + 1] - distances[currentIndex]) const x = splinePoints[currentIndex].x + t * (splinePoints[currentIndex + 1].x - splinePoints[currentIndex].x) const y = splinePoints[currentIndex].y + t * (splinePoints[currentIndex + 1].y - splinePoints[currentIndex].y) result.push({ x, y }) } return result } 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 calculateSplineCoefficients(values: number[]): number[] { const n = values.length - 1 const matrix: number[][] = new Array(n + 1) .fill(0) .map(() => new Array(n + 1).fill(0)) const rhs: number[] = new Array(n + 1).fill(0) // Set up tridiagonal matrix for (let i = 1; i < n; i++) { matrix[i][i - 1] = 1 matrix[i][i] = 4 matrix[i][i + 1] = 1 rhs[i] = 3 * (values[i + 1] - values[i - 1]) } // Set boundary conditions (natural spline) matrix[0][0] = 2 matrix[0][1] = 1 matrix[n][n - 1] = 1 matrix[n][n] = 2 rhs[0] = 3 * (values[1] - values[0]) rhs[n] = 3 * (values[n] - values[n - 1]) // Solve tridiagonal system using Thomas algorithm for (let i = 1; i <= n; i++) { const m = matrix[i][i - 1] / matrix[i - 1][i - 1] matrix[i][i] -= m * matrix[i - 1][i] rhs[i] -= m * rhs[i - 1] } const solution: number[] = new Array(n + 1) solution[n] = rhs[n] / matrix[n][n] for (let i = n - 1; i >= 0; i--) { solution[i] = (rhs[i] - matrix[i][i + 1] * solution[i + 1]) / matrix[i][i] } return solution } private setBrushSize(size: number) { this.brushSettings.size = size } private setBrushOpacity(opacity: number) { this.brushSettings.opacity = opacity } private setBrushHardness(hardness: number) { this.brushSettings.hardness = hardness } private setBrushType(type: BrushShape) { this.brushSettings.type = type } private setBrushSmoothingPrecision(precision: number) { //console.log('precision', precision) this.smoothingPrecision = precision } } class UIManager { private rootElement: HTMLElement private brush!: HTMLDivElement private brushPreviewGradient!: HTMLDivElement private maskCtx!: CanvasRenderingContext2D private imageCtx!: CanvasRenderingContext2D private maskCanvas!: HTMLCanvasElement private imgCanvas!: HTMLCanvasElement private brushSettingsHTML!: HTMLDivElement private paintBucketSettingsHTML!: HTMLDivElement private colorSelectSettingsHTML!: HTMLDivElement private maskOpacitySlider!: HTMLInputElement private brushHardnessSlider!: HTMLInputElement private brushSizeSlider!: HTMLInputElement private brushOpacitySlider!: HTMLInputElement private sidebarImage!: HTMLImageElement private saveButton!: HTMLButtonElement private toolPanel!: HTMLDivElement private sidePanel!: HTMLDivElement private pointerZone!: HTMLDivElement private canvasBackground!: HTMLDivElement private canvasContainer!: HTMLDivElement private image!: HTMLImageElement private imageURL!: URL private darkMode: boolean = true private maskEditor: MaskEditorDialog private messageBroker: MessageBroker private mask_opacity: number = 1.0 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( '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 canvas_background = document.createElement('div') canvas_background.id = 'canvasBackground' canvasContainer.appendChild(imgCanvas) canvasContainer.appendChild(maskCanvas) canvasContainer.appendChild(canvas_background) // prepare content this.imgCanvas = imgCanvas! this.maskCanvas = maskCanvas! this.canvasContainer = canvasContainer! this.canvasBackground = canvas_background! let maskCtx = maskCanvas!.getContext('2d', { willReadFrequently: true }) if (maskCtx) { this.maskCtx = maskCtx } 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.maskCanvas.style.position = 'absolute' this.imgCanvas.style.top = '200' this.imgCanvas.style.left = '0' 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 = 'Save' this.saveButton.disabled = false await this.setImages(this.imgCanvas) //probably change method to initImageCanvas } private async createSidePanel() { const side_panel = this.createContainer(true) side_panel.id = 'maskEditor_sidePanel' 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) return side_panel } 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('Brush Settings') const brush_shape_outer_container = this.createContainer(true) const brush_shape_title = this.createContainerTitle('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.style.background = 'var(--p-button-text-primary-color)' 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.style.background = '' 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 = '' }) 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( 'Thickness', 1, 100, 1, 10, (event, value) => { this.messageBroker.publish('setBrushSize', parseInt(value)) this.updateBrushPreview() } ) this.brushSizeSlider = thicknesSliderObj.slider const opacitySliderObj = this.createSlider( 'Opacity', 0, 1, 0.01, 0.7, (event, value) => { this.messageBroker.publish('setBrushOpacity', parseFloat(value)) this.updateBrushPreview() } ) this.brushOpacitySlider = opacitySliderObj.slider const hardnessSliderObj = this.createSlider( 'Hardness', 0, 1, 0.01, 1, (event, value) => { this.messageBroker.publish('setBrushHardness', parseFloat(value)) this.updateBrushPreview() } ) this.brushHardnessSlider = hardnessSliderObj.slider const brushSmoothingPrecisionSliderObj = this.createSlider( 'Smoothing Precision', 1, 100, 1, 10, (event, value) => { this.messageBroker.publish( 'setBrushSmoothingPrecision', parseInt(value) ) } ) brush_settings_container.appendChild(brush_settings_title) brush_settings_container.appendChild(brush_shape_outer_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( 'Paint Bucket Settings' ) const tolerance = await this.messageBroker.pull('getTolerance') const paintBucketToleranceSliderObj = this.createSlider( 'Tolerance', 0, 255, 1, tolerance, (event, value) => { this.messageBroker.publish('setPaintBucketTolerance', parseInt(value)) } ) paint_bucket_settings_container.appendChild(paint_bucket_settings_title) paint_bucket_settings_container.appendChild( paintBucketToleranceSliderObj.container ) return paint_bucket_settings_container } private async createColorSelectSettings() { const color_select_settings_container = this.createContainer(true) const color_select_settings_title = this.createHeadline( 'Color Select Settings' ) var tolerance = await this.messageBroker.pull('getTolerance') const colorSelectToleranceSliderObj = this.createSlider( 'Tolerance', 0, 255, 1, tolerance, (event, value) => { this.messageBroker.publish('setColorSelectTolerance', parseInt(value)) } ) const livePreviewToggle = this.createToggle( 'Live Preview', (event, value) => { this.messageBroker.publish('setLivePreview', value) } ) const wholeImageToggle = this.createToggle( 'Apply to Whole Image', (event, value) => { this.messageBroker.publish('setWholeImage', value) } ) const methodOptions = Object.values(ColorComparisonMethod) const methodSelect = this.createDropdown( 'Method', methodOptions, (event, value) => { this.messageBroker.publish('setColorComparisonMethod', value) } ) const maskBoundaryToggle = this.createToggle( 'Stop at mask', (event, value) => { this.messageBroker.publish('setMaskBoundary', value) } ) const maskToleranceSliderObj = this.createSlider( 'Mask Tolerance', 0, 255, 1, 0, (event, value) => { this.messageBroker.publish('setMaskTolerance', parseInt(value)) } ) color_select_settings_container.appendChild(color_select_settings_title) color_select_settings_container.appendChild( colorSelectToleranceSliderObj.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 } 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('Layers') 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) //change name } }) var mask_layer_image_container = document.createElement('div') mask_layer_image_container.classList.add( 'maskEditor_sidePanelLayerPreviewContainer' ) mask_layer_image_container.innerHTML = ' ' 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) 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() }) mask_layer_container.appendChild(mask_layer_visibility_checkbox) mask_layer_container.appendChild(mask_layer_image_container) mask_layer_container.appendChild(mask_layer_dropdown) const mask_layer_opacity_sliderObj = this.createSlider( 'Mask Opacity', 0.0, 1.0, 0.01, this.mask_opacity, (event, 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 const image_layer_title = this.createContainerTitle('Image Layer') const image_layer_container = this.createContainer(false) image_layer_container.classList.add(accentColor) image_layer_container.classList.add('maskEditor_layerRow') const image_layer_visibility_checkbox = document.createElement('input') image_layer_visibility_checkbox.setAttribute('type', 'checkbox') image_layer_visibility_checkbox.classList.add( 'maskEditor_sidePanelLayerCheckbox' ) image_layer_visibility_checkbox.checked = true 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 image_layer_image_container = document.createElement('div') image_layer_image_container.classList.add( 'maskEditor_sidePanelLayerPreviewContainer' ) const image_layer_image = document.createElement('img') image_layer_image.id = 'maskEditor_sidePanelImageLayerImage' image_layer_image.src = ComfyApp.clipspace?.imgs?.[ComfyApp.clipspace?.selectedIndex ?? 0]?.src ?? '' this.sidebarImage = image_layer_image image_layer_image_container.appendChild(image_layer_image) image_layer_container.appendChild(image_layer_visibility_checkbox) image_layer_container.appendChild(image_layer_image_container) image_layer_settings_container.appendChild(image_layer_settings_title) image_layer_settings_container.appendChild(mask_layer_title) image_layer_settings_container.appendChild(mask_layer_container) image_layer_settings_container.appendChild( mask_layer_opacity_sliderObj.container ) image_layer_settings_container.appendChild(image_layer_title) image_layer_settings_container.appendChild(image_layer_container) return image_layer_settings_container } 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 = '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 = 'Clear' top_bar_clear_button.addEventListener('click', () => { this.maskCtx.clearRect( 0, 0, this.maskCanvas.width, this.maskCanvas.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 = '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 = 'Cancel' top_bar_cancel_button.addEventListener('click', () => { this.maskEditor.close() }) 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 } 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' var toolElements: HTMLElement[] = [] //brush tool var toolPanel_brushToolContainer = document.createElement('div') toolPanel_brushToolContainer.classList.add('maskEditor_toolPanelContainer') toolPanel_brushToolContainer.classList.add( 'maskEditor_toolPanelContainerSelected' ) toolPanel_brushToolContainer.classList.add(toolPanelHoverAccent) toolPanel_brushToolContainer.innerHTML = ` ` toolElements.push(toolPanel_brushToolContainer) toolPanel_brushToolContainer.addEventListener('click', () => { //move logic to tool manager this.messageBroker.publish('setTool', Tools.Pen) for (let toolElement of toolElements) { if (toolElement != toolPanel_brushToolContainer) { 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' } } this.messageBroker.publish('setTool', Tools.Pen) this.pointerZone.style.cursor = 'none' }) var toolPanel_brushToolIndicator = document.createElement('div') toolPanel_brushToolIndicator.classList.add('maskEditor_toolPanelIndicator') toolPanel_brushToolContainer.appendChild(toolPanel_brushToolIndicator) //eraser tool var toolPanel_eraserToolContainer = document.createElement('div') toolPanel_eraserToolContainer.classList.add('maskEditor_toolPanelContainer') toolPanel_eraserToolContainer.classList.add(toolPanelHoverAccent) toolPanel_eraserToolContainer.innerHTML = ` ` toolElements.push(toolPanel_eraserToolContainer) toolPanel_eraserToolContainer.addEventListener('click', () => { //move logic to tool manager this.messageBroker.publish('setTool', Tools.Eraser) for (let toolElement of toolElements) { if (toolElement != toolPanel_eraserToolContainer) { 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' } } this.messageBroker.publish('setTool', Tools.Eraser) this.pointerZone.style.cursor = 'none' }) var toolPanel_eraserToolIndicator = document.createElement('div') toolPanel_eraserToolIndicator.classList.add('maskEditor_toolPanelIndicator') toolPanel_eraserToolContainer.appendChild(toolPanel_eraserToolIndicator) //paint bucket tool var toolPanel_paintBucketToolContainer = document.createElement('div') toolPanel_paintBucketToolContainer.classList.add( 'maskEditor_toolPanelContainer' ) toolPanel_paintBucketToolContainer.classList.add(toolPanelHoverAccent) toolPanel_paintBucketToolContainer.innerHTML = ` ` toolElements.push(toolPanel_paintBucketToolContainer) toolPanel_paintBucketToolContainer.addEventListener('click', () => { //move logic to tool manager this.messageBroker.publish('setTool', Tools.PaintBucket) for (let toolElement of toolElements) { if (toolElement != toolPanel_paintBucketToolContainer) { toolElement.classList.remove('maskEditor_toolPanelContainerSelected') } else { toolElement.classList.add('maskEditor_toolPanelContainerSelected') this.brushSettingsHTML.style.display = 'none' this.colorSelectSettingsHTML.style.display = 'none' this.paintBucketSettingsHTML.style.display = 'flex' } } this.messageBroker.publish('setTool', Tools.PaintBucket) this.pointerZone.style.cursor = "url('/cursor/paintBucket.png') 30 25, auto" this.brush.style.opacity = '0' }) var toolPanel_paintBucketToolIndicator = document.createElement('div') toolPanel_paintBucketToolIndicator.classList.add( 'maskEditor_toolPanelIndicator' ) toolPanel_paintBucketToolContainer.appendChild( toolPanel_paintBucketToolIndicator ) //color select tool var toolPanel_colorSelectToolContainer = document.createElement('div') toolPanel_colorSelectToolContainer.classList.add( 'maskEditor_toolPanelContainer' ) toolPanel_colorSelectToolContainer.classList.add(toolPanelHoverAccent) toolPanel_colorSelectToolContainer.innerHTML = ` ` toolElements.push(toolPanel_colorSelectToolContainer) toolPanel_colorSelectToolContainer.addEventListener('click', () => { this.messageBroker.publish('setTool', 'colorSelect') for (let toolElement of toolElements) { if (toolElement != toolPanel_colorSelectToolContainer) { toolElement.classList.remove('maskEditor_toolPanelContainerSelected') } else { toolElement.classList.add('maskEditor_toolPanelContainerSelected') this.brushSettingsHTML.style.display = 'none' this.paintBucketSettingsHTML.style.display = 'none' this.colorSelectSettingsHTML.style.display = 'flex' } } this.messageBroker.publish('setTool', Tools.ColorSelect) this.pointerZone.style.cursor = "url('/cursor/colorSelect.png') 15 25, auto" this.brush.style.opacity = '0' }) var toolPanel_colorSelectToolIndicator = document.createElement('div') toolPanel_colorSelectToolIndicator.classList.add( 'maskEditor_toolPanelIndicator' ) toolPanel_colorSelectToolContainer.appendChild( toolPanel_colorSelectToolIndicator ) //zoom indicator 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_brushToolContainer) tool_panel.appendChild(toolPanel_eraserToolContainer) tool_panel.appendChild(toolPanel_paintBucketToolContainer) tool_panel.appendChild(toolPanel_colorSelectToolContainer) tool_panel.appendChild(toolPanel_zoomIndicator) 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', (event: PointerEvent) => { 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 (event: PointerEvent) => { this.updateCursor() } ) return pointer_zone } async screenToCanvas(clientPoint: Point): Promise { // Get the bounding rectangles for both elements const zoomRatio = await this.messageBroker.pull('zoomRatio') const canvasRect = this.maskCanvas.getBoundingClientRect() // 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 const x = offsetX / zoomRatio const y = offsetY / zoomRatio return { x: x, y: y } } private setEventHandler() { this.maskCanvas.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') const brushSettings = 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 imgCtx!.clearRect(0, 0, this.imgCanvas.width, this.imgCanvas.height) maskCtx.clearRect(0, 0, this.maskCanvas.width, this.maskCanvas.height) const alpha_url = new URL( ComfyApp.clipspace?.imgs?.[ComfyApp.clipspace?.selectedIndex ?? 0]?.src ?? '' ) 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) // original image load if ( !ComfyApp.clipspace?.imgs?.[ComfyApp.clipspace?.selectedIndex ?? 0]?.src ) { throw new Error( 'Unable to access image source - clipspace or image is null' ) } const rgb_url = new URL( ComfyApp.clipspace.imgs[ComfyApp.clipspace.selectedIndex].src ) this.imageURL = rgb_url console.log(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.onload = () => resolve(img) img.onerror = reject img.src = rgb_url.toString() }) maskCanvas.width = this.image.width maskCanvas.height = this.image.height this.dimensionsTextHTML.innerText = `${this.image.width}x${this.image.height}` await this.invalidateCanvas(this.image, mask_image) this.messageBroker.publish('initZoomPan', [this.image, this.rootElement]) } async invalidateCanvas( orig_image: HTMLImageElement, mask_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 let imgCtx = this.imgCanvas.getContext('2d', { willReadFrequently: true }) let maskCtx = this.maskCanvas.getContext('2d', { willReadFrequently: true }) imgCtx!.drawImage(orig_image, 0, 0, orig_image.width, orig_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.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 const extendedSize = brushSettings.size * (2 - hardness) * 2 * zoom_ratio this.brushSizeSlider.value = String(brushSettings.size) this.brushHardnessSlider.value = String(hardness) brush.style.width = extendedSize + 'px' brush.style.height = extendedSize + 'px' brush.style.left = centerX - extendedSize / 2 + 'px' brush.style.top = centerY - extendedSize / 2 + 'px' if (hardness === 1) { this.brushPreviewGradient.style.background = 'rgba(255, 0, 0, 0.5)' return } const opacityStop = hardness / 4 + 0.25 this.brushPreviewGradient.style.background = ` radial-gradient( circle, rgba(255, 0, 0, 0.5) 0%, rgba(255, 0, 0, ${opacityStop}) ${hardness * 100}%, rgba(255, 0, 0, 0) 100% ) ` } 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 } 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.PaintBucket) { this.pointerZone.style.cursor = "url('/cursor/paintBucket.png') 30 25, auto" this.setBrushOpacity(0) } else if (currentTool === Tools.ColorSelect) { 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.Pen 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.ColorSelect) { 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 } //paint bucket if (this.currentTool === Tools.PaintBucket && 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.ColorSelect && 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.Pen, Tools.Eraser].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.Pen, Tools.Eraser].includes(this.currentTool) if (!isDrawingTool) return // alt + right mouse button hold brush adjustment if ( this.isAdjustingBrush && (this.currentTool === Tools.Pen || 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 rootElement: HTMLElement | null = null image: HTMLImageElement | null = null imageRootWidth: number = 0 imageRootHeight: number = 0 cursorPoint: Point = { x: 0, y: 0 } 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( '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() if ((event.touches[0] as any).touchType === 'stylus') 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() if ((event.touches[0] as any).touchType === 'stylus') 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() if ( event.touches.length === 0 && (event.touches[0] as any).touchType === 'stylus' ) { return } this.isTouchZooming = false this.lastTouchMidPoint = { x: 0, y: 0 } if (event.touches.length === 0) { this.lastTouchPoint = { x: 0, y: 0 } } else if (event.touches.length === 1) { this.lastTouchPoint = { x: event.touches[0].clientX, y: event.touches[0].clientY } } } 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 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` }) } private handlePanStart(event: PointerEvent) { let coords_canvas = 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('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') } /** * 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[] = [] private maskEditor: MaskEditorDialog private messageBroker: MessageBroker 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', (event) => this.handleKeyDown(event)) document.addEventListener('keyup', (event) => this.handleKeyUp(event)) window.addEventListener('blur', () => this.clearKeys()) } removeListeners() { document.removeEventListener('keydown', (event) => this.handleKeyDown(event) ) document.removeEventListener('keyup', (event) => this.handleKeyUp(event)) } private clearKeys() { this.keysDown = [] } private handleKeyDown(event: KeyboardEvent) { if (!this.keysDown.includes(event.key)) { this.keysDown.push(event.key) } //if (this.redoCombinationPressed()) return //this.undoCombinationPressed() } private handleKeyUp(event: KeyboardEvent) { this.keysDown = this.keysDown.filter((key) => key !== event.key) } private isKeyDown(key: string) { return this.keysDown.includes(key) } // combinations private undoCombinationPressed() { const combination = ['ctrl', 'z'] const keysDownLower = this.keysDown.map((key) => key.toLowerCase()) const result = combination.every((key) => keysDownLower.includes(key)) if (result) this.messageBroker.publish('undo') return result } private redoCombinationPressed() { const combination = ['ctrl', 'shift', 'z'] const keysDownLower = this.keysDown.map((key) => key.toLowerCase()) const result = combination.every((key) => keysDownLower.includes(key)) if (result) this.messageBroker.publish('redo') return result } } app.registerExtension({ name: 'Comfy.MaskEditor', settings: [ { id: 'Comfy.MaskEditor.UseNewEditor', category: ['Mask Editor', 'NewEditor'], name: 'Use new mask editor', tooltip: 'Switch to the new mask editor interface', type: 'boolean', defaultValue: true, experimental: true }, { id: 'Comfy.MaskEditor.BrushAdjustmentSpeed', category: ['Mask Editor', 'BrushAdjustment', 'Sensitivity'], name: 'Brush adjustment speed multiplier', tooltip: 'Controls how quickly the brush size and hardness change when adjusting. Higher values mean faster changes.', experimental: true, type: 'slider', attrs: { min: 0.1, max: 2.0, step: 0.1 }, defaultValue: 1.0, versionAdded: '1.0.0' }, { id: 'Comfy.MaskEditor.UseDominantAxis', category: ['Mask Editor', 'BrushAdjustment', 'UseDominantAxis'], name: 'Lock brush adjustment to dominant axis', tooltip: 'When enabled, brush adjustments will only affect size OR hardness based on which direction you move more', type: 'boolean', defaultValue: true, experimental: true } ], init(app) { // Create function before assignment function openMaskEditor(): void { const useNewEditor = app.extensionManager.setting.get( 'Comfy.MaskEditor.UseNewEditor' ) if (useNewEditor) { const dlg = MaskEditorDialog.getInstance() as any if (dlg?.isOpened && !dlg.isOpened()) { dlg.show() } } else { const dlg = MaskEditorDialogOld.getInstance() as any if (dlg?.isOpened && !dlg.isOpened()) { dlg.show() } } } // Assign the created function ;(ComfyApp as any).open_maskeditor = openMaskEditor // Ensure boolean return type const context_predicate = (): boolean => { return !!( ComfyApp.clipspace && ComfyApp.clipspace.imgs && ComfyApp.clipspace.imgs.length > 0 ) } ClipspaceDialog.registerButton( 'MaskEditor', context_predicate, openMaskEditor ) } })