[refactor] Split mask editor into smaller files (#6308)

## Summary

Split mask editor structure into smaller files

## Changes

This PR is a prerequisite step for [the issue - refactoring the mask
editor using
Vue](https://github.com/Comfy-Org/ComfyUI_frontend/issues/5956). It
splits the current monolithic maskeditor.ts (about 5700 lines) into
separate files; otherwise, the original single file would be very
difficult to analyze or maintain.

This PR itself does not introduce any Vue, nor should it have any
functional changes or modifications. It's purely a code-level split,
with all related files placed in the maskeditor folder.

The original maskeditor.ts has been renamed to maskeditor.ts.backup for
future reference.

## Review Focus

Since this PR is only for splitting purposes, all logic remains
consistent with the original. Therefore, for any reviewer: any code
logic improvements should happen in the subsequent Vue-based
refactoring, not in this PR.

Following this PR, I will perform a Vue-based refactoring of the mask
editor to align with the frontend's overall Vue architecture and provide
better cloud-related support.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6308-refactor-Split-mask-editor-into-smaller-files-2986d73d36508131937dd43e465a47bd)
by [Unito](https://www.unito.io)
This commit is contained in:
Terry Jia
2025-10-26 20:34:36 -04:00
committed by GitHub
parent cacd7e3251
commit 9f5245dc80
23 changed files with 5748 additions and 5563 deletions

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -0,0 +1,36 @@
import { Tools } from './types'
export const iconsHtml: Record<Tools, string> = {
[Tools.MaskPen]: `
<svg viewBox="0 0 44 44">
<path class="cls-1" d="M10.97,15.98v14.04c0,.825.675,1.5,1.5,1.5h23.07c.825,0,1.5-.675,1.5-1.5V15.98c0-.825-.675-1.5-1.5-1.5H12.47c-.825,0-1.5.675-1.5,1.5ZM25.79,28.16c-4.365,1.41-8.355-2.58-6.945-6.945.51-1.575,1.785-2.85,3.36-3.36,4.365-1.41,8.355,2.58,6.945,6.945-.51,1.575-1.785,2.85-3.36,3.36Z"/>
</svg>
`,
[Tools.Eraser]: `
<svg viewBox="0 0 44 44">
<g>
<rect class="cls-2" x="16.68" y="10" width="10.63" height="24" rx="1.16" ry="1.16" transform="translate(22 -9.11) rotate(45)"/>
<path class="cls-1" d="M17.27,34.27c-.42,0-.85-.16-1.17-.48l-5.88-5.88c-.31-.31-.48-.73-.48-1.17s.17-.86.48-1.17l15.34-15.34c.62-.62,1.72-.62,2.34,0l5.88,5.88c.65.65.65,1.7,0,2.34l-15.34,15.34c-.32.32-.75.48-1.17.48ZM26.73,10.73c-.18,0-.34.07-.46.19l-15.34,15.34c-.12.12-.19.29-.19.46s.07.34.19.46l5.88,5.88c.26.26.67.26.93,0l15.34-15.34c.26-.26.26-.67,0-.93l-5.88-5.88c-.12-.12-.29-.19-.46-.19Z"/>
</g>
<path class="cls-3" d="M20.33,11.03h8.32c.64,0,1.16.52,1.16,1.16v15.79h-10.63v-15.79c0-.64.52-1.16,1.16-1.16Z" transform="translate(20.97 -11.61) rotate(45)"/>
</svg>
`,
[Tools.MaskBucket]: `
<svg viewBox="0 0 44 44">
<path class="cls-1" d="M33.4,21.76l-11.42,11.41-.04.05c-.61.61-1.6.61-2.21,0l-8.91-8.91c-.61-.61-.61-1.6,0-2.21l.04-.05.3-.29h22.24Z"/>
<path class="cls-1" d="M20.83,34.17c-.55,0-1.07-.21-1.46-.6l-8.91-8.91c-.8-.8-.8-2.11,0-2.92l11.31-11.31c.8-.8,2.11-.8,2.92,0l8.91,8.91c.39.39.6.91.6,1.46s-.21,1.07-.6,1.46l-11.31,11.31c-.39.39-.91.6-1.46.6ZM23.24,10.83c-.27,0-.54.1-.75.31l-11.31,11.31c-.41.41-.41,1.09,0,1.5l8.91,8.91c.4.4,1.1.4,1.5,0l11.31-11.31c.2-.2.31-.47.31-.75s-.11-.55-.31-.75l-8.91-8.91c-.21-.21-.48-.31-.75-.31Z"/>
<path class="cls-1" d="M34.28,26.85c0,.84-.68,1.52-1.52,1.52s-1.52-.68-1.52-1.52,1.52-2.86,1.52-2.86c0,0,1.52,2.02,1.52,2.86Z"/>
</svg>
`,
[Tools.MaskColorFill]: `
<svg viewBox="0 0 44 44">
<path class="cls-1" d="M30.29,13.72c-1.09-1.1-2.85-1.09-3.94,0l-2.88,2.88-.75-.75c-.2-.19-.51-.19-.71,0-.19.2-.19.51,0,.71l1.4,1.4-9.59,9.59c-.35.36-.54.82-.54,1.32,0,.14,0,.28.05.41-.05.04-.1.08-.15.13-.39.39-.39,1.01,0,1.4.38.39,1.01.39,1.4,0,.04-.04.08-.09.11-.13.14.04.3.06.45.06.5,0,.97-.19,1.32-.55l9.59-9.59,1.38,1.38c.1.09.22.14.35.14s.26-.05.35-.14c.2-.2.2-.52,0-.71l-.71-.72,2.88-2.89c1.08-1.08,1.08-2.85-.01-3.94ZM19.43,25.82h-2.46l7.15-7.15,1.23,1.23-5.92,5.92Z"/>
</svg>
`,
[Tools.PaintPen]: `
<svg viewBox="0 0 44 44">
<path class="cls-1" d="M34,13.93c0,.47-.19.94-.55,1.31l-13.02,13.04c-.09.07-.18.15-.27.22-.07-1.39-1.21-2.48-2.61-2.49.07-.12.16-.24.27-.34l13.04-13.04c.72-.72,1.89-.72,2.6,0,.35.35.55.83.55,1.3Z"/>
<path class="cls-1" d="M19.64,29.03c0,4.46-6.46,3.18-9.64,0,3.3-.47,4.75-2.58,7.06-2.58,1.43,0,2.58,1.16,2.58,2.58Z"/>
</svg>
`
}

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,75 @@
export enum BrushShape {
Arc = 'arc',
Rect = 'rect'
}
export enum Tools {
MaskPen = 'pen',
PaintPen = 'rgbPaint',
Eraser = 'eraser',
MaskBucket = 'paintBucket',
MaskColorFill = 'colorSelect'
}
export const allTools = [
Tools.MaskPen,
Tools.PaintPen,
Tools.Eraser,
Tools.MaskBucket,
Tools.MaskColorFill
]
export const allImageLayers = ['mask', 'rgb'] as const
export type ImageLayer = (typeof allImageLayers)[number]
export interface ToolInternalSettings {
container: HTMLElement
cursor?: string
newActiveLayerOnSet?: ImageLayer
}
export enum CompositionOperation {
SourceOver = 'source-over',
DestinationOut = 'destination-out'
}
export enum MaskBlendMode {
Black = 'black',
White = 'white',
Negative = 'negative'
}
export enum ColorComparisonMethod {
Simple = 'simple',
HSL = 'hsl',
LAB = 'lab'
}
export interface Point {
x: number
y: number
}
export interface Offset {
x: number
y: number
}
export interface Brush {
type: BrushShape
size: number
opacity: number
hardness: number
smoothingPrecision: number
}
export type Callback = (data?: any) => void
export type Ref = { filename: string; subfolder?: string; type?: string }
// Forward declaration for MaskEditorDialog
export interface MaskEditorDialog {
getMessageBroker(): any // Will be MessageBroker, but avoiding circular dependency
save(): void
destroy(): void
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { imageLayerFilenamesIfApplicable } from '@/extensions/core/maskEditorLayerFilenames'
import { imageLayerFilenamesIfApplicable } from '@/extensions/core/maskeditor/utils/maskEditorLayerFilenames'
describe('imageLayerFilenamesIfApplicable', () => {
// In case the naming scheme changes, this test will ensure CI fails if developers forget to support the old naming scheme. (Causing MaskEditor to lose layer data for previously-saved images.)