Files
ComfyUI_frontend/src/extensions/core/maskeditor.ts
Tristan Sommer b23cebcba4 maskeditor: massive improvements to brush opacity in brush strokes and lines (#1768)
* massive improvements to brush opacity in brush strokes and lines, improved save button visibility

* prettier formatting fixed
2024-12-02 18:16:18 -08:00

4938 lines
143 KiB
TypeScript

import { app } from '../../scripts/app'
import { ComfyDialog, $el } from '../../scripts/ui'
import { ComfyApp } from '../../scripts/app'
import { api } from '../../scripts/api'
import { ClipspaceDialog } from './clipspace'
import { MaskEditorDialogOld } from './maskEditorOld'
var styles = `
#maskEditorContainer {
display: fixed;
}
#maskEditor_brush {
position: absolute;
backgroundColor: transparent;
z-index: 8889;
pointer-events: none;
border-radius: 50%;
overflow: visible;
outline: 1px dashed black;
box-shadow: 0 0 0 1px white;
}
#maskEditor_brushPreviewGradient {
position: absolute;
width: 100%;
height: 100%;
border-radius: 50%;
display: none;
}
#maskEditor {
display: block;
width: 100%;
height: 100vh;
left: 0;
z-index: 8888;
position: fixed;
background: rgba(50,50,50,0.75);
backdrop-filter: blur(10px);
overflow: hidden;
user-select: none;
}
#maskEditor_sidePanelContainer {
height: 100%;
width: 220px;
z-index: 8888;
display: flex;
flex-direction: column;
}
#maskEditor_sidePanel {
background: var(--comfy-menu-bg);
height: 100%;
display: flex;
align-items: center;
overflow-y: hidden;
width: 220px;
}
#maskEditor_sidePanelShortcuts {
display: flex;
flex-direction: row;
width: 200px;
margin-top: 10px;
gap: 10px;
justify-content: center;
}
.maskEditor_sidePanelIconButton {
width: 40px;
height: 40px;
pointer-events: auto;
display: flex;
justify-content: center;
align-items: center;
transition: background-color 0.1s;
}
.maskEditor_sidePanelIconButton:hover {
background-color: rgba(0, 0, 0, 0.2);
}
#maskEditor_sidePanelBrushSettings {
display: flex;
flex-direction: column;
gap: 10px;
width: 200px;
padding: 10px;
}
.maskEditor_sidePanelTitle {
text-align: center;
font-size: 15px;
font-family: sans-serif;
color: var(--descrip-text);
margin-top: 10px;
}
#maskEditor_sidePanelBrushShapeContainer {
display: flex;
width: 180px;
height: 50px;
border: 1px solid var(--border-color);
pointer-events: auto;
background: rgba(0, 0, 0, 0.2);
}
#maskEditor_sidePanelBrushShapeCircle {
width: 35px;
height: 35px;
border-radius: 50%;
border: 1px solid var(--border-color);
pointer-events: auto;
transition: background 0.1s;
margin-left: 7.5px;
}
.maskEditor_sidePanelBrushRange {
width: 180px;
-webkit-appearance: none;
appearance: none;
background: transparent;
cursor: pointer;
}
.maskEditor_sidePanelBrushRange::-webkit-slider-thumb {
height: 20px;
width: 20px;
border-radius: 50%;
cursor: grab;
margin-top: -8px;
background: var(--p-surface-700);
border: 1px solid var(--border-color);
}
.maskEditor_sidePanelBrushRange::-moz-range-thumb {
height: 20px;
width: 20px;
border-radius: 50%;
cursor: grab;
background: var(--p-surface-800);
border: 1px solid var(--border-color);
}
.maskEditor_sidePanelBrushRange::-webkit-slider-runnable-track {
background: var(--p-surface-700);
height: 3px;
}
.maskEditor_sidePanelBrushRange::-moz-range-track {
background: var(--p-surface-700);
height: 3px;
}
#maskEditor_sidePanelBrushShapeSquare {
width: 35px;
height: 35px;
margin: 5px;
border: 1px solid var(--border-color);
pointer-events: auto;
transition: background 0.1s;
}
.maskEditor_brushShape_dark {
background: transparent;
}
.maskEditor_brushShape_dark:hover {
background: var(--p-surface-900);
}
.maskEditor_brushShape_light {
background: transparent;
}
.maskEditor_brushShape_light:hover {
background: var(--comfy-menu-bg);
}
#maskEditor_sidePanelImageLayerSettings {
display: flex;
flex-direction: column;
gap: 10px;
width: 200px;
align-items: center;
}
.maskEditor_sidePanelLayer {
display: flex;
width: 200px;
height: 50px;
}
.maskEditor_sidePanelLayerVisibilityContainer {
width: 50px;
height: 50px;
border-radius: 8px;
display: flex;
justify-content: center;
align-items: center;
}
.maskEditor_sidePanelVisibilityToggle {
width: 12px;
height: 12px;
border-radius: 50%;
pointer-events: auto;
}
.maskEditor_sidePanelLayerIconContainer {
width: 60px;
height: 50px;
border-radius: 8px;
display: flex;
justify-content: center;
align-items: center;
fill: var(--input-text);
}
.maskEditor_sidePanelLayerIconContainer svg {
width: 30px;
height: 30px;
}
#maskEditor_sidePanelMaskLayerBlendingContainer {
width: 80px;
height: 50px;
border-radius: 8px;
display: flex;
justify-content: center;
align-items: center;
}
#maskEditor_sidePanelMaskLayerBlendingSelect {
width: 80px;
height: 30px;
border: 1px solid var(--border-color);
background-color: rgba(0, 0, 0, 0.2);
color: var(--input-text);
font-family: sans-serif;
font-size: 15px;
pointer-events: auto;
transition: background-color border 0.1s;
}
#maskEditor_sidePanelClearCanvasButton:hover {
background-color: var(--p-overlaybadge-outline-color);
border: none;
}
#maskEditor_sidePanelClearCanvasButton {
width: 180px;
height: 30px;
border: none;
background: rgba(0, 0, 0, 0.2);
border: 1px solid var(--border-color);
color: var(--input-text);
font-family: sans-serif;
font-size: 15px;
pointer-events: auto;
transition: background-color 0.1s;
}
#maskEditor_sidePanelClearCanvasButton:hover {
background-color: var(--p-overlaybadge-outline-color);
}
#maskEditor_sidePanelHorizontalButtonContainer {
display: flex;
gap: 10px;
height: 40px;
}
.maskEditor_sidePanelBigButton {
width: 85px;
height: 30px;
border: none;
background: rgba(0, 0, 0, 0.2);
border: 1px solid var(--border-color);
color: var(--input-text);
font-family: sans-serif;
font-size: 15px;
pointer-events: auto;
transition: background-color border 0.1s;
}
.maskEditor_sidePanelBigButton:hover {
background-color: var(--p-overlaybadge-outline-color);
border: none;
}
#maskEditor_toolPanel {
height: 100%;
width: var(--sidebar-width);
z-index: 8888;
background: var(--comfy-menu-bg);
display: flex;
flex-direction: column;
}
.maskEditor_toolPanelContainer {
width: var(--sidebar-width);
height: var(--sidebar-width);
display: flex;
justify-content: center;
align-items: center;
position: relative;
transition: background-color 0.2s;
}
.maskEditor_toolPanelContainerSelected svg {
fill: var(--p-button-text-primary-color) !important;
}
.maskEditor_toolPanelContainerSelected .maskEditor_toolPanelIndicator {
display: block;
}
.maskEditor_toolPanelContainer svg {
width: 75%;
aspect-ratio: 1/1;
fill: var(--p-button-text-secondary-color);
}
.maskEditor_toolPanelContainerDark:hover {
background-color: var(--p-surface-800);
}
.maskEditor_toolPanelContainerLight:hover {
background-color: var(--p-surface-300);
}
.maskEditor_toolPanelIndicator {
display: none;
height: 100%;
width: 4px;
position: absolute;
left: 0;
background: var(--p-button-text-primary-color);
}
#maskEditor_sidePanelPaintBucketSettings {
display: flex;
flex-direction: column;
gap: 10px;
width: 200px;
padding: 10px;
}
#canvasBackground {
background: white;
width: 100%;
height: 100%;
}
#maskEditor_sidePanelButtonsContainer {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 10px;
}
.maskEditor_sidePanelSeparator {
width: 200px;
height: 2px;
background: var(--border-color);
margin-top: 5px;
margin-bottom: 5px;
}
#maskEditor_pointerZone {
width: calc(100% - var(--sidebar-width) - 220px);
height: 100%;
}
#maskEditor_uiContainer {
width: 100%;
height: 100%;
position: absolute;
z-index: 8888;
display: flex;
flex-direction: column;
}
#maskEditorCanvasContainer {
position: absolute;
width: 1000px;
height: 667px;
left: 359px;
top: 280px;
}
#imageCanvas {
width: 100%;
height: 100%;
}
#maskCanvas {
width: 100%;
height: 100%;
}
#maskEditor_uiHorizontalContainer {
width: 100%;
height: 100%;
display: flex;
}
#maskEditor_topBar {
display: flex;
height: 44px;
align-items: center;
background: var(--comfy-menu-bg);
}
#maskEditor_topBarTitle {
margin: 0;
margin-left: 0.5rem;
margin-right: 0.5rem;
font-size: 1.2em;
}
#maskEditor_topBarButtonContainer {
display: flex;
gap: 10px;
margin-right: 0.5rem;
position: absolute;
right: 0;
width: 200px;
}
#maskEditor_topBarShortcutsContainer {
display: flex;
gap: 10px;
margin-left: 5px;
}
.maskEditor_topPanelIconButton_dark {
width: 50px;
height: 30px;
pointer-events: auto;
display: flex;
justify-content: center;
align-items: center;
transition: background-color 0.1s;
background: var(--p-surface-800);
border: 1px solid var(--p-form-field-border-color);
border-radius: 10px;
}
.maskEditor_topPanelIconButton_dark:hover {
background-color: var(--p-surface-900);
}
.maskEditor_topPanelIconButton_dark svg {
width: 25px;
height: 25px;
pointer-events: none;
fill: var(--input-text);
}
.maskEditor_topPanelIconButton_light {
width: 50px;
height: 30px;
pointer-events: auto;
display: flex;
justify-content: center;
align-items: center;
transition: background-color 0.1s;
background: var(--comfy-menu-bg);
border: 1px solid var(--p-form-field-border-color);
border-radius: 10px;
}
.maskEditor_topPanelIconButton_light:hover {
background-color: var(--p-surface-300);
}
.maskEditor_topPanelIconButton_light svg {
width: 25px;
height: 25px;
pointer-events: none;
fill: var(--input-text);
}
.maskEditor_topPanelButton_dark {
height: 30px;
background: var(--p-surface-800);
border: 1px solid var(--p-form-field-border-color);
border-radius: 10px;
color: var(--input-text);
font-family: sans-serif;
pointer-events: auto;
transition: 0.1s;
width: 60px;
}
.maskEditor_topPanelButton_dark:hover {
background-color: var(--p-surface-900);
}
.maskEditor_topPanelButton_light {
height: 30px;
background: var(--comfy-menu-bg);
border: 1px solid var(--p-form-field-border-color);
border-radius: 10px;
color: var(--input-text);
font-family: sans-serif;
pointer-events: auto;
transition: 0.1s;
width: 60px;
}
.maskEditor_topPanelButton_light:hover {
background-color: var(--p-surface-300);
}
#maskEditor_sidePanelColorSelectSettings {
flex-direction: column;
}
.maskEditor_sidePanel_paintBucket_Container {
width: 180px;
display: flex;
flex-direction: column;
position: relative;
}
.maskEditor_sidePanel_colorSelect_Container {
display: flex;
width: 180px;
align-items: center;
gap: 5px;
height: 30px;
}
#maskEditor_sidePanelVisibilityToggle {
position: absolute;
right: 0;
}
#maskEditor_sidePanelColorSelectMethodSelect {
position: absolute;
right: 0;
height: 30px;
border-radius: 0;
border: 1px solid var(--border-color);
background: rgba(0,0,0,0.2);
}
#maskEditor_sidePanelVisibilityToggle {
position: absolute;
right: 0;
}
.maskEditor_sidePanel_colorSelect_tolerance_container {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 10px;
}
.maskEditor_sidePanelContainerColumn {
display: flex;
flex-direction: column;
gap: 12px;
}
.maskEditor_sidePanelContainerRow {
display: flex;
flex-direction: row;
gap: 10px;
align-items: center;
min-height: 24px;
position: relative;
}
.maskEditor_accent_bg_dark {
background: var(--p-surface-800);
}
.maskEditor_accent_bg_very_dark {
background: var(--p-surface-900);
}
.maskEditor_accent_bg_light {
background: var(--p-surface-300);
}
.maskEditor_accent_bg_very_light {
background: var(--comfy-menu-bg);
}
#maskEditor_paintBucketSettings {
display: none;
}
#maskEditor_colorSelectSettings {
display: none;
}
.maskEditor_sidePanelToggleContainer {
cursor: pointer;
display: inline-block;
position: absolute;
right: 0;
}
.maskEditor_toggle_bg_dark {
background: var(--p-surface-700);
}
.maskEditor_toggle_bg_light {
background: var(--p-surface-300);
}
.maskEditor_sidePanelToggleSwitch {
display: inline-block;
border-radius: 16px;
width: 40px;
height: 24px;
position: relative;
vertical-align: middle;
transition: background 0.25s;
}
.maskEditor_sidePanelToggleSwitch:before, .maskEditor_sidePanelToggleSwitch:after {
content: "";
}
.maskEditor_sidePanelToggleSwitch:before {
display: block;
background: linear-gradient(to bottom, #fff 0%, #eee 100%);
border-radius: 50%;
width: 16px;
height: 16px;
position: absolute;
top: 4px;
left: 4px;
transition: ease 0.2s;
}
.maskEditor_sidePanelToggleContainer:hover .maskEditor_sidePanelToggleSwitch:before {
background: linear-gradient(to bottom, #fff 0%, #fff 100%);
}
.maskEditor_sidePanelToggleCheckbox:checked + .maskEditor_sidePanelToggleSwitch {
background: var(--p-button-text-primary-color);
}
.maskEditor_sidePanelToggleCheckbox:checked + .maskEditor_toggle_bg_dark:before {
background: var(--p-surface-900);
}
.maskEditor_sidePanelToggleCheckbox:checked + .maskEditor_toggle_bg_light:before {
background: var(--comfy-menu-bg);
}
.maskEditor_sidePanelToggleCheckbox:checked + .maskEditor_sidePanelToggleSwitch:before {
left: 20px;
}
.maskEditor_sidePanelToggleCheckbox {
position: absolute;
visibility: hidden;
}
.maskEditor_sidePanelDropdown_dark {
border: 1px solid var(--p-form-field-border-color);
background: var(--p-surface-900);
height: 24px;
padding-left: 5px;
padding-right: 5px;
border-radius: 6px;
transition: background 0.1s;
}
.maskEditor_sidePanelDropdown_dark option {
background: var(--p-surface-900);
}
.maskEditor_sidePanelDropdown_dark:focus {
outline: 1px solid var(--p-button-text-primary-color);
}
.maskEditor_sidePanelDropdown_dark option:hover {
background: white;
}
.maskEditor_sidePanelDropdown_dark option:active {
background: var(--p-highlight-background);
}
.maskEditor_sidePanelDropdown_light {
border: 1px solid var(--p-form-field-border-color);
background: var(--comfy-menu-bg);
height: 24px;
padding-left: 5px;
padding-right: 5px;
border-radius: 6px;
transition: background 0.1s;
}
.maskEditor_sidePanelDropdown_light option {
background: var(--comfy-menu-bg);
}
.maskEditor_sidePanelDropdown_light:focus {
outline: 1px solid var(--p-surface-300);
}
.maskEditor_sidePanelDropdown_light option:hover {
background: white;
}
.maskEditor_sidePanelDropdown_light option:active {
background: var(--p-surface-300);
}
.maskEditor_layerRow {
height: 50px;
width: 200px;
border-radius: 10px;
}
.maskEditor_sidePanelLayerPreviewContainer {
width: 40px;
height: 30px;
}
.maskEditor_sidePanelLayerPreviewContainer > svg{
width: 100%;
height: 100%;
object-fit: contain;
fill: var(--p-surface-100);
}
#maskEditor_sidePanelImageLayerImage {
width: 100%;
height: 100%;
object-fit: contain;
}
.maskEditor_sidePanelSubTitle {
text-align: left;
font-size: 12px;
font-family: sans-serif;
color: var(--descrip-text);
}
.maskEditor_containerDropdown {
position: absolute;
right: 0;
}
.maskEditor_sidePanelLayerCheckbox {
margin-left: 15px;
}
.maskEditor_toolPanelZoomIndicator {
width: var(--sidebar-width);
height: var(--sidebar-width);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 5px;
color: var(--p-button-text-secondary-color);
position: absolute;
bottom: 0;
transition: background-color 0.2s;
}
#maskEditor_toolPanelDimensionsText {
font-size: 12px;
}
#maskEditor_topBarSaveButton {
background: var(--p-primary-color) !important;
color: var(--p-button-primary-color) !important;
}
#maskEditor_topBarSaveButton:hover {
background: var(--p-primary-hover-color) !important;
}
`
var styleSheet = document.createElement('style')
styleSheet.type = 'text/css'
styleSheet.innerText = styles
document.head.appendChild(styleSheet)
enum BrushShape {
Arc = 'arc',
Rect = 'rect'
}
enum Tools {
Pen = 'pen',
Eraser = 'eraser',
PaintBucket = 'paintBucket',
ColorSelect = 'colorSelect'
}
enum CompositionOperation {
SourceOver = 'source-over',
DestinationOut = 'destination-out'
}
enum MaskBlendMode {
Black = 'black',
White = 'white',
Negative = 'negative'
}
enum ColorComparisonMethod {
Simple = 'simple',
HSL = 'hsl',
LAB = 'lab'
}
interface Point {
x: number
y: number
}
interface Offset {
x: number
y: number
}
export interface Brush {
size: number
opacity: number
hardness: number
type: BrushShape
}
type Callback = (data?: any) => void
class MaskEditorDialog extends ComfyDialog {
static instance: MaskEditorDialog | null = null
//new
private uiManager!: UIManager
private toolManager!: ToolManager
private panAndZoomManager!: PanAndZoomManager
private brushTool!: BrushTool
private paintBucketTool!: PaintBucketTool
private colorSelectTool!: ColorSelectTool
private canvasHistory!: CanvasHistory
private messageBroker!: MessageBroker
private keyboardManager!: KeyboardManager
private rootElement!: HTMLElement
private imageURL!: string
private isLayoutCreated: boolean = false
private isOpen: boolean = false
//variables needed?
last_display_style: string | null = null
constructor() {
super()
this.rootElement = $el(
'div.maskEditor_hidden',
{ parent: document.body },
[]
)
this.element = this.rootElement
}
static getInstance() {
if (!ComfyApp.clipspace || !ComfyApp.clipspace.imgs) {
throw new Error('No clipspace images found')
}
const currentSrc =
ComfyApp.clipspace.imgs[ComfyApp.clipspace['selectedIndex']].src
if (
!MaskEditorDialog.instance ||
currentSrc !== MaskEditorDialog.instance.imageURL
) {
MaskEditorDialog.instance = new MaskEditorDialog()
}
return MaskEditorDialog.instance
}
async show() {
this.cleanup()
if (!this.isLayoutCreated) {
// layout
this.messageBroker = new MessageBroker()
this.canvasHistory = new CanvasHistory(this, 20)
this.paintBucketTool = new PaintBucketTool(this)
this.brushTool = new BrushTool(this)
this.panAndZoomManager = new PanAndZoomManager(this)
this.toolManager = new ToolManager(this)
this.keyboardManager = new KeyboardManager(this)
this.uiManager = new UIManager(this.rootElement, this)
this.colorSelectTool = new ColorSelectTool(this)
// replacement of onClose hook since close is not real close
const self = this
const observer = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
if (
mutation.type === 'attributes' &&
mutation.attributeName === 'style'
) {
if (
self.last_display_style &&
self.last_display_style != 'none' &&
self.element.style.display == 'none'
) {
//self.brush.style.display = 'none'
ComfyApp.onClipspaceEditorClosed()
}
self.last_display_style = self.element.style.display
}
})
})
const config = { attributes: true }
observer.observe(this.rootElement, config)
this.isLayoutCreated = true
await this.uiManager.setlayout()
}
//this.zoomAndPanManager.reset()
this.rootElement.id = 'maskEditor'
this.rootElement.style.display = 'flex'
this.element.style.display = 'flex'
await this.uiManager.initUI()
this.paintBucketTool.initPaintBucketTool()
this.colorSelectTool.initColorSelectTool()
await this.canvasHistory.saveInitialState()
this.isOpen = true
if (ComfyApp.clipspace && ComfyApp.clipspace.imgs) {
this.uiManager.setSidebarImage()
}
this.keyboardManager.addListeners()
}
private cleanup() {
// Remove all maskEditor elements
const maskEditors = document.querySelectorAll('[id^="maskEditor"]')
maskEditors.forEach((element) => element.remove())
// Remove brush elements specifically
const brushElements = document.querySelectorAll('#maskEditor_brush')
brushElements.forEach((element) => element.remove())
}
isOpened() {
return this.isOpen
}
async save() {
const backupCanvas = document.createElement('canvas')
const imageCanvas = this.uiManager.getImgCanvas()
const maskCanvas = this.uiManager.getMaskCanvas()
const image = this.uiManager.getImage()
const backupCtx = backupCanvas.getContext('2d', {
willReadFrequently: true
})
backupCanvas.width = imageCanvas.width
backupCanvas.height = imageCanvas.height
if (!backupCtx) {
return
}
// Ensure the mask image is fully loaded
const maskImageLoaded = new Promise<void>((resolve, reject) => {
const maskImage = new Image()
maskImage.src = maskCanvas.toDataURL()
maskImage.onload = () => {
resolve()
}
maskImage.onerror = (error) => {
reject(error)
}
})
try {
await maskImageLoaded
} catch (error) {
console.error('Error loading mask image:', error)
return
}
backupCtx.clearRect(0, 0, backupCanvas.width, backupCanvas.height)
backupCtx.drawImage(
maskCanvas,
0,
0,
maskCanvas.width,
maskCanvas.height,
0,
0,
backupCanvas.width,
backupCanvas.height
)
let maskHasContent = false
const maskData = backupCtx.getImageData(
0,
0,
backupCanvas.width,
backupCanvas.height
)
for (let i = 0; i < maskData.data.length; i += 4) {
if (maskData.data[i + 3] !== 0) {
maskHasContent = true
break
}
}
// paste mask data into alpha channel
const backupData = backupCtx.getImageData(
0,
0,
backupCanvas.width,
backupCanvas.height
)
let backupHasContent = false
for (let i = 0; i < backupData.data.length; i += 4) {
if (backupData.data[i + 3] !== 0) {
backupHasContent = true
break
}
}
if (maskHasContent && !backupHasContent) {
console.error('Mask appears to be empty')
alert('Cannot save empty mask')
return
}
// refine mask image
for (let i = 0; i < backupData.data.length; i += 4) {
const alpha = backupData.data[i + 3]
backupData.data[i] = 0
backupData.data[i + 1] = 0
backupData.data[i + 2] = 0
backupData.data[i + 3] = 255 - alpha
}
backupCtx.globalCompositeOperation = CompositionOperation.SourceOver
backupCtx.putImageData(backupData, 0, 0)
const formData = new FormData()
const filename = 'clipspace-mask-' + performance.now() + '.png'
const item = {
filename: filename,
subfolder: 'clipspace',
type: 'input'
}
if (ComfyApp?.clipspace?.widgets?.length) {
const index = ComfyApp.clipspace.widgets.findIndex(
(obj) => obj?.name === 'image'
)
if (index >= 0 && item !== undefined) {
try {
ComfyApp.clipspace.widgets[index].value = item
} catch (err) {
console.warn('Failed to set widget value:', err)
}
}
}
const dataURL = backupCanvas.toDataURL()
const blob = this.dataURLToBlob(dataURL)
let original_url = new URL(image.src)
type Ref = { filename: string; subfolder?: string; type?: string }
this.uiManager.setBrushOpacity(0)
const filenameRef = original_url.searchParams.get('filename')
if (!filenameRef) {
throw new Error('filename parameter is required')
}
const original_ref: Ref = {
filename: filenameRef
}
let original_subfolder = original_url.searchParams.get('subfolder')
if (original_subfolder) original_ref.subfolder = original_subfolder
let original_type = original_url.searchParams.get('type')
if (original_type) original_ref.type = original_type
formData.append('image', blob, filename)
formData.append('original_ref', JSON.stringify(original_ref))
formData.append('type', 'input')
formData.append('subfolder', 'clipspace')
this.uiManager.setSaveButtonText('Saving')
this.uiManager.setSaveButtonEnabled(false)
this.keyboardManager.removeListeners()
// Retry mechanism
const maxRetries = 3
let attempt = 0
let success = false
while (attempt < maxRetries && !success) {
try {
await this.uploadMask(item, formData)
success = true
} catch (error) {
console.error(`Upload attempt ${attempt + 1} failed:`, error)
attempt++
if (attempt < maxRetries) {
console.log('Retrying upload...')
} else {
console.log('Max retries reached. Upload failed.')
}
}
}
if (success) {
ComfyApp.onClipspaceEditorSave()
this.close()
this.isOpen = false
} else {
this.uiManager.setSaveButtonText('Save')
this.uiManager.setSaveButtonEnabled(true)
this.keyboardManager.addListeners()
}
}
getMessageBroker() {
return this.messageBroker
}
// Helper function to convert a data URL to a Blob object
private dataURLToBlob(dataURL: string) {
const parts = dataURL.split(';base64,')
const contentType = parts[0].split(':')[1]
const byteString = atob(parts[1])
const arrayBuffer = new ArrayBuffer(byteString.length)
const uint8Array = new Uint8Array(arrayBuffer)
for (let i = 0; i < byteString.length; i++) {
uint8Array[i] = byteString.charCodeAt(i)
}
return new Blob([arrayBuffer], { type: contentType })
}
private async uploadMask(
filepath: { filename: string; subfolder: string; type: string },
formData: FormData,
retries = 3
) {
if (retries <= 0) {
throw new Error('Max retries reached')
return
}
await api
.fetchApi('/upload/mask', {
method: 'POST',
body: formData
})
.then((response) => {
if (!response.ok) {
console.log('Failed to upload mask:', response)
this.uploadMask(filepath, formData, 2)
}
})
.catch((error) => {
console.error('Error:', error)
})
try {
const selectedIndex = ComfyApp.clipspace?.selectedIndex
if (ComfyApp.clipspace?.imgs && selectedIndex !== undefined) {
// Create and set new image
const newImage = new Image()
newImage.src = api.apiURL(
'/view?' +
new URLSearchParams(filepath).toString() +
app.getPreviewFormatParam() +
app.getRandParam()
)
ComfyApp.clipspace.imgs[selectedIndex] = newImage
// Update images array if it exists
if (ComfyApp.clipspace.images) {
ComfyApp.clipspace.images[selectedIndex] = filepath
}
}
} catch (err) {
console.warn('Failed to update clipspace image:', err)
}
ClipspaceDialog.invalidatePreview()
}
}
class CanvasHistory {
private maskEditor!: MaskEditorDialog
private messageBroker!: MessageBroker
private canvas!: HTMLCanvasElement
private ctx!: CanvasRenderingContext2D
private states: ImageData[] = []
private currentStateIndex: number = -1
private maxStates: number = 20
private initialized: boolean = false
constructor(maskEditor: MaskEditorDialog, maxStates = 20) {
this.maskEditor = maskEditor
this.messageBroker = maskEditor.getMessageBroker()
this.maxStates = maxStates
this.createListeners()
}
private async pullCanvas() {
this.canvas = await this.messageBroker.pull('maskCanvas')
this.ctx = await this.messageBroker.pull('maskCtx')
}
private createListeners() {
this.messageBroker.subscribe('saveState', () => this.saveState())
this.messageBroker.subscribe('undo', () => this.undo())
this.messageBroker.subscribe('redo', () => this.redo())
}
clearStates() {
this.states = []
this.currentStateIndex = -1
this.initialized = false
}
async saveInitialState() {
await this.pullCanvas()
if (!this.canvas.width || !this.canvas.height) {
// Canvas not ready yet, defer initialization
requestAnimationFrame(() => this.saveInitialState())
return
}
this.clearStates()
const state = this.ctx.getImageData(
0,
0,
this.canvas.width,
this.canvas.height
)
this.states.push(state)
this.currentStateIndex = 0
this.initialized = true
}
saveState() {
// Ensure we have an initial state
if (!this.initialized || this.currentStateIndex === -1) {
this.saveInitialState()
return
}
this.states = this.states.slice(0, this.currentStateIndex + 1)
const state = this.ctx.getImageData(
0,
0,
this.canvas.width,
this.canvas.height
)
this.states.push(state)
this.currentStateIndex++
if (this.states.length > this.maxStates) {
this.states.shift()
this.currentStateIndex--
}
}
undo() {
if (this.states.length > 1 && this.currentStateIndex > 0) {
this.currentStateIndex--
this.restoreState(this.states[this.currentStateIndex])
} else {
alert('No more undo states available')
}
}
redo() {
if (
this.states.length > 1 &&
this.currentStateIndex < this.states.length - 1
) {
this.currentStateIndex++
this.restoreState(this.states[this.currentStateIndex])
} else {
alert('No more redo states available')
}
}
restoreState(state: ImageData) {
if (state && this.initialized) {
this.ctx.putImageData(state, 0, 0)
}
}
}
class PaintBucketTool {
maskEditor: MaskEditorDialog
messageBroker: MessageBroker
private canvas!: HTMLCanvasElement
private ctx!: CanvasRenderingContext2D
private width: number | null = null
private height: number | null = null
private imageData: ImageData | null = null
private data: Uint8ClampedArray | null = null
private tolerance: number = 5
constructor(maskEditor: MaskEditorDialog) {
this.maskEditor = maskEditor
this.messageBroker = maskEditor.getMessageBroker()
this.createListeners()
this.addPullTopics()
}
initPaintBucketTool() {
this.pullCanvas()
}
private async pullCanvas() {
this.canvas = await this.messageBroker.pull('maskCanvas')
this.ctx = await this.messageBroker.pull('maskCtx')
}
private createListeners() {
this.messageBroker.subscribe(
'setPaintBucketTolerance',
(tolerance: number) => this.setTolerance(tolerance)
)
this.messageBroker.subscribe('paintBucketFill', (point: Point) =>
this.floodFill(point)
)
this.messageBroker.subscribe('invert', () => this.invertMask())
}
private addPullTopics() {
this.messageBroker.createPullTopic(
'getTolerance',
async () => this.tolerance
)
}
private getPixel(x: number, y: number): number {
return this.data![(y * this.width! + x) * 4 + 3]
}
private setPixel(
x: number,
y: number,
alpha: number,
color: { r: number; g: number; b: number }
): void {
const index = (y * this.width! + x) * 4
this.data![index] = color.r // R
this.data![index + 1] = color.g // G
this.data![index + 2] = color.b // B
this.data![index + 3] = alpha // A
}
private shouldProcessPixel(
currentAlpha: number,
targetAlpha: number,
tolerance: number,
isFillMode: boolean
): boolean {
if (currentAlpha === -1) return false
if (isFillMode) {
// Fill mode: process pixels that are empty/similar to target
return (
currentAlpha !== 255 &&
Math.abs(currentAlpha - targetAlpha) <= tolerance
)
} else {
// Erase mode: process pixels that are filled/similar to target
return (
currentAlpha === 255 ||
Math.abs(currentAlpha - targetAlpha) <= tolerance
)
}
}
private async floodFill(point: Point): Promise<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('getMaskColor')
const stack: Array<[number, number]> = []
const visited = new Uint8Array(this.width * this.height)
if (
this.shouldProcessPixel(
targetAlpha,
targetAlpha,
this.tolerance,
isFillMode
)
) {
stack.push([startX, startY])
}
while (stack.length > 0) {
const [x, y] = stack.pop()!
const visitedIndex = y * this.width + x
if (visited[visitedIndex]) continue
const currentAlpha = this.getPixel(x, y)
if (
!this.shouldProcessPixel(
currentAlpha,
targetAlpha,
this.tolerance,
isFillMode
)
) {
continue
}
visited[visitedIndex] = 1
// Set alpha to 255 for fill mode, 0 for erase mode
this.setPixel(x, y, isFillMode ? 255 : 0, maskColor)
// Check neighbors
const checkNeighbor = (nx: number, ny: number) => {
if (nx < 0 || nx >= this.width! || ny < 0 || ny >= this.height!) return
if (!visited[ny * this.width! + nx]) {
const alpha = this.getPixel(nx, ny)
if (
this.shouldProcessPixel(
alpha,
targetAlpha,
this.tolerance,
isFillMode
)
) {
stack.push([nx, ny])
}
}
}
checkNeighbor(x - 1, y) // Left
checkNeighbor(x + 1, y) // Right
checkNeighbor(x, y - 1) // Up
checkNeighbor(x, y + 1) // Down
}
this.ctx.putImageData(this.imageData, 0, 0)
this.imageData = null
this.data = null
}
setTolerance(tolerance: number): void {
this.tolerance = tolerance
}
getTolerance(): number {
return this.tolerance
}
//invert mask
private invertMask() {
const imageData = this.ctx.getImageData(
0,
0,
this.canvas.width,
this.canvas.height
)
const data = imageData.data
// Find first non-transparent pixel to get mask color
let maskR = 0,
maskG = 0,
maskB = 0
for (let i = 0; i < data.length; i += 4) {
if (data[i + 3] > 0) {
maskR = data[i]
maskG = data[i + 1]
maskB = data[i + 2]
break
}
}
// Process each pixel
for (let i = 0; i < data.length; i += 4) {
const alpha = data[i + 3]
// Invert alpha channel (0 becomes 255, 255 becomes 0)
data[i + 3] = 255 - alpha
// If this was originally transparent (now opaque), fill with mask color
if (alpha === 0) {
data[i] = maskR
data[i + 1] = maskG
data[i + 2] = maskB
}
}
this.ctx.putImageData(imageData, 0, 0)
this.messageBroker.publish('saveState')
}
}
class ColorSelectTool {
private maskEditor!: MaskEditorDialog
private messageBroker!: MessageBroker
private width: number | null = null
private height: number | null = null
private canvas!: HTMLCanvasElement
private maskCTX!: CanvasRenderingContext2D
private imageCTX!: CanvasRenderingContext2D
private maskData: Uint8ClampedArray | null = null
private imageData: Uint8ClampedArray | null = null
private tolerance: number = 20
private livePreview: boolean = false
private lastPoint: Point | null = null
private colorComparisonMethod: ColorComparisonMethod =
ColorComparisonMethod.Simple
private applyWholeImage: boolean = false
private maskBoundry: boolean = false
private maskTolerance: number = 0
constructor(maskEditor: MaskEditorDialog) {
this.maskEditor = maskEditor
this.messageBroker = maskEditor.getMessageBroker()
this.createListeners()
this.addPullTopics()
}
async initColorSelectTool() {
await this.pullCanvas()
}
private async pullCanvas() {
this.canvas = await this.messageBroker.pull('imgCanvas')
this.maskCTX = await this.messageBroker.pull('maskCtx')
this.imageCTX = await this.messageBroker.pull('imageCtx')
}
private createListeners() {
this.messageBroker.subscribe('colorSelectFill', (point: Point) =>
this.fillColorSelection(point)
)
this.messageBroker.subscribe(
'setColorSelectTolerance',
(tolerance: number) => this.setTolerance(tolerance)
)
this.messageBroker.subscribe('setLivePreview', (livePreview: boolean) =>
this.setLivePreview(livePreview)
)
this.messageBroker.subscribe(
'setColorComparisonMethod',
(method: ColorComparisonMethod) => this.setComparisonMethod(method)
)
this.messageBroker.subscribe('clearLastPoint', () => this.clearLastPoint())
this.messageBroker.subscribe('setWholeImage', (applyWholeImage: boolean) =>
this.setApplyWholeImage(applyWholeImage)
)
this.messageBroker.subscribe('setMaskBoundary', (maskBoundry: boolean) =>
this.setMaskBoundary(maskBoundry)
)
this.messageBroker.subscribe('setMaskTolerance', (maskTolerance: number) =>
this.setMaskTolerance(maskTolerance)
)
}
private async addPullTopics() {
this.messageBroker.createPullTopic(
'getLivePreview',
async () => this.livePreview
)
}
private getPixel(x: number, y: number): { r: number; g: number; b: number } {
const index = (y * this.width! + x) * 4
return {
r: this.imageData![index],
g: this.imageData![index + 1],
b: this.imageData![index + 2]
}
}
private getMaskAlpha(x: number, y: number): number {
return this.maskData![(y * this.width! + x) * 4 + 3]
}
private isPixelInRange(
pixel: { r: number; g: number; b: number },
target: { r: number; g: number; b: number }
): boolean {
switch (this.colorComparisonMethod) {
case ColorComparisonMethod.Simple:
return this.isPixelInRangeSimple(pixel, target)
case ColorComparisonMethod.HSL:
return this.isPixelInRangeHSL(pixel, target)
case ColorComparisonMethod.LAB:
return this.isPixelInRangeLab(pixel, target)
default:
return this.isPixelInRangeSimple(pixel, target)
}
}
private isPixelInRangeSimple(
pixel: { r: number; g: number; b: number },
target: { r: number; g: number; b: number }
): boolean {
//calculate the euclidean distance between the two colors
const distance = Math.sqrt(
Math.pow(pixel.r - target.r, 2) +
Math.pow(pixel.g - target.g, 2) +
Math.pow(pixel.b - target.b, 2)
)
return distance <= this.tolerance
}
private isPixelInRangeHSL(
pixel: { r: number; g: number; b: number },
target: { r: number; g: number; b: number }
): boolean {
// Convert RGB to HSL
const pixelHSL = this.rgbToHSL(pixel.r, pixel.g, pixel.b)
const targetHSL = this.rgbToHSL(target.r, target.g, target.b)
// Compare mainly hue and saturation, be more lenient with lightness
const hueDiff = Math.abs(pixelHSL.h - targetHSL.h)
const satDiff = Math.abs(pixelHSL.s - targetHSL.s)
const lightDiff = Math.abs(pixelHSL.l - targetHSL.l)
const distance = Math.sqrt(
Math.pow((hueDiff / 360) * 255, 2) +
Math.pow((satDiff / 100) * 255, 2) +
Math.pow((lightDiff / 100) * 255, 2)
)
return distance <= this.tolerance
}
private rgbToHSL(
r: number,
g: number,
b: number
): { h: number; s: number; l: number } {
r /= 255
g /= 255
b /= 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
let h = 0,
s = 0,
l = (max + min) / 2
if (max !== min) {
const d = max - min
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0)
break
case g:
h = (b - r) / d + 2
break
case b:
h = (r - g) / d + 4
break
}
h /= 6
}
return {
h: h * 360,
s: s * 100,
l: l * 100
}
}
private isPixelInRangeLab(
pixel: { r: number; g: number; b: number },
target: { r: number; g: number; b: number }
): boolean {
const pixelLab = this.rgbToLab(pixel)
const targetLab = this.rgbToLab(target)
// Calculate Delta E (CIE76 formula)
const deltaE = Math.sqrt(
Math.pow(pixelLab.l - targetLab.l, 2) +
Math.pow(pixelLab.a - targetLab.a, 2) +
Math.pow(pixelLab.b - targetLab.b, 2)
)
const normalizedDeltaE = (deltaE / 100) * 255
return normalizedDeltaE <= this.tolerance
}
private rgbToLab(rgb: { r: number; g: number; b: number }): {
l: number
a: number
b: number
} {
// First convert RGB to XYZ
let r = rgb.r / 255
let g = rgb.g / 255
let b = rgb.b / 255
r = r > 0.04045 ? Math.pow((r + 0.055) / 1.055, 2.4) : r / 12.92
g = g > 0.04045 ? Math.pow((g + 0.055) / 1.055, 2.4) : g / 12.92
b = b > 0.04045 ? Math.pow((b + 0.055) / 1.055, 2.4) : b / 12.92
r *= 100
g *= 100
b *= 100
const x = r * 0.4124 + g * 0.3576 + b * 0.1805
const y = r * 0.2126 + g * 0.7152 + b * 0.0722
const z = r * 0.0193 + g * 0.1192 + b * 0.9505
// Then XYZ to Lab
const xn = 95.047
const yn = 100.0
const zn = 108.883
const xyz = [x / xn, y / yn, z / zn]
for (let i = 0; i < xyz.length; i++) {
xyz[i] =
xyz[i] > 0.008856 ? Math.pow(xyz[i], 1 / 3) : 7.787 * xyz[i] + 16 / 116
}
return {
l: 116 * xyz[1] - 16,
a: 500 * (xyz[0] - xyz[1]),
b: 200 * (xyz[1] - xyz[2])
}
}
private setPixel(
x: number,
y: number,
alpha: number,
color: { r: number; g: number; b: number }
): void {
const index = (y * this.width! + x) * 4
this.maskData![index] = color.r // R
this.maskData![index + 1] = color.g // G
this.maskData![index + 2] = color.b // B
this.maskData![index + 3] = alpha // A
}
async fillColorSelection(point: Point) {
this.width = this.canvas.width
this.height = this.canvas.height
this.lastPoint = point
// Get image data
const maskData = this.maskCTX.getImageData(0, 0, this.width, this.height)
this.maskData = maskData.data
this.imageData = this.imageCTX.getImageData(
0,
0,
this.width,
this.height
).data
if (this.applyWholeImage) {
// Process entire image
const targetPixel = this.getPixel(
Math.floor(point.x),
Math.floor(point.y)
)
const maskColor = await this.messageBroker.pull('getMaskColor')
// Use TypedArrays for better performance
const width = this.width!
const height = this.height!
// Process in chunks for better performance
const CHUNK_SIZE = 10000
for (let i = 0; i < width * height; i += CHUNK_SIZE) {
const endIndex = Math.min(i + CHUNK_SIZE, width * height)
for (let pixelIndex = i; pixelIndex < endIndex; pixelIndex++) {
const x = pixelIndex % width
const y = Math.floor(pixelIndex / width)
if (this.isPixelInRange(this.getPixel(x, y), targetPixel)) {
this.setPixel(x, y, 255, maskColor)
}
}
// Allow UI updates between chunks
await new Promise((resolve) => setTimeout(resolve, 0))
}
} else {
// Original flood fill logic
let startX = Math.floor(point.x)
let startY = Math.floor(point.y)
if (
startX < 0 ||
startX >= this.width ||
startY < 0 ||
startY >= this.height
) {
return
}
const pixel = this.getPixel(startX, startY)
const stack: Array<[number, number]> = []
const visited = new Uint8Array(this.width * this.height)
stack.push([startX, startY])
const maskColor = await this.messageBroker.pull('getMaskColor')
while (stack.length > 0) {
const [x, y] = stack.pop()!
const visitedIndex = y * this.width + x
if (
visited[visitedIndex] ||
!this.isPixelInRange(this.getPixel(x, y), pixel)
) {
continue
}
visited[visitedIndex] = 1
this.setPixel(x, y, 255, maskColor)
// Inline direction checks for better performance
if (
x > 0 &&
!visited[y * this.width + (x - 1)] &&
this.isPixelInRange(this.getPixel(x - 1, y), pixel)
) {
if (
!this.maskBoundry ||
255 - this.getMaskAlpha(x - 1, y) > this.maskTolerance
) {
stack.push([x - 1, y])
}
}
if (
x < this.width - 1 &&
!visited[y * this.width + (x + 1)] &&
this.isPixelInRange(this.getPixel(x + 1, y), pixel)
) {
if (
!this.maskBoundry ||
255 - this.getMaskAlpha(x + 1, y) > this.maskTolerance
) {
stack.push([x + 1, y])
}
}
if (
y > 0 &&
!visited[(y - 1) * this.width + x] &&
this.isPixelInRange(this.getPixel(x, y - 1), pixel)
) {
if (
!this.maskBoundry ||
255 - this.getMaskAlpha(x, y - 1) > this.maskTolerance
) {
stack.push([x, y - 1])
}
}
if (
y < this.height - 1 &&
!visited[(y + 1) * this.width + x] &&
this.isPixelInRange(this.getPixel(x, y + 1), pixel)
) {
if (
!this.maskBoundry ||
255 - this.getMaskAlpha(x, y + 1) > this.maskTolerance
) {
stack.push([x, y + 1])
}
}
}
}
this.maskCTX.putImageData(maskData, 0, 0)
this.messageBroker.publish('saveState')
this.maskData = null
this.imageData = null
}
setTolerance(tolerance: number): void {
this.tolerance = tolerance
if (this.lastPoint && this.livePreview) {
this.messageBroker.publish('undo')
this.fillColorSelection(this.lastPoint)
}
}
setLivePreview(livePreview: boolean): void {
this.livePreview = livePreview
}
setComparisonMethod(method: ColorComparisonMethod): void {
this.colorComparisonMethod = method
if (this.lastPoint && this.livePreview) {
this.messageBroker.publish('undo')
this.fillColorSelection(this.lastPoint)
}
}
clearLastPoint() {
this.lastPoint = null
}
setApplyWholeImage(applyWholeImage: boolean): void {
this.applyWholeImage = applyWholeImage
}
setMaskBoundary(maskBoundry: boolean): void {
this.maskBoundry = maskBoundry
}
setMaskTolerance(maskTolerance: number): void {
this.maskTolerance = maskTolerance
}
}
class BrushTool {
brushSettings: Brush //this saves the current brush settings
maskBlendMode: MaskBlendMode
isDrawing: boolean = false
isDrawingLine: boolean = false
lineStartPoint: Point | null = null
smoothingPrecision: number = 10
smoothingCordsArray: Point[] = []
smoothingLastDrawTime!: Date
maskCtx: CanvasRenderingContext2D | null = null
initialDraw: boolean = true
brushStrokeCanvas: HTMLCanvasElement | null = null
brushStrokeCtx: CanvasRenderingContext2D | null = null
//brush adjustment
isBrushAdjusting: boolean = false
brushPreviewGradient: HTMLElement | null = null
initialPoint: Point | null = null
useDominantAxis: boolean = false
brushAdjustmentSpeed: number = 1.0
maskEditor: MaskEditorDialog
messageBroker: MessageBroker
constructor(maskEditor: MaskEditorDialog) {
this.maskEditor = maskEditor
this.messageBroker = maskEditor.getMessageBroker()
this.createListeners()
this.addPullTopics()
this.useDominantAxis = app.extensionManager.setting.get(
'Comfy.MaskEditor.UseDominantAxis'
)
this.brushAdjustmentSpeed = app.extensionManager.setting.get(
'Comfy.MaskEditor.BrushAdjustmentSpeed'
)
this.brushSettings = {
size: 10,
opacity: 100,
hardness: 1,
type: BrushShape.Arc
}
this.maskBlendMode = MaskBlendMode.Black
}
private createListeners() {
//setters
this.messageBroker.subscribe('setBrushSize', (size: number) =>
this.setBrushSize(size)
)
this.messageBroker.subscribe('setBrushOpacity', (opacity: number) =>
this.setBrushOpacity(opacity)
)
this.messageBroker.subscribe('setBrushHardness', (hardness: number) =>
this.setBrushHardness(hardness)
)
this.messageBroker.subscribe('setBrushShape', (type: BrushShape) =>
this.setBrushType(type)
)
this.messageBroker.subscribe(
'setBrushSmoothingPrecision',
(precision: number) => this.setBrushSmoothingPrecision(precision)
)
//brush adjustment
this.messageBroker.subscribe(
'brushAdjustmentStart',
(event: PointerEvent) => this.startBrushAdjustment(event)
)
this.messageBroker.subscribe('brushAdjustment', (event: PointerEvent) =>
this.handleBrushAdjustment(event)
)
//drawing
this.messageBroker.subscribe('drawStart', (event: PointerEvent) =>
this.startDrawing(event)
)
this.messageBroker.subscribe('draw', (event: PointerEvent) =>
this.handleDrawing(event)
)
this.messageBroker.subscribe('drawEnd', (event: PointerEvent) =>
this.drawEnd(event)
)
}
private addPullTopics() {
this.messageBroker.createPullTopic(
'brushSize',
async () => this.brushSettings.size
)
this.messageBroker.createPullTopic(
'brushOpacity',
async () => this.brushSettings.opacity
)
this.messageBroker.createPullTopic(
'brushHardness',
async () => this.brushSettings.hardness
)
this.messageBroker.createPullTopic(
'brushType',
async () => this.brushSettings.type
)
this.messageBroker.createPullTopic(
'maskBlendMode',
async () => this.maskBlendMode
)
this.messageBroker.createPullTopic(
'brushSettings',
async () => this.brushSettings
)
}
private async createBrushStrokeCanvas() {
if (this.brushStrokeCanvas !== null) {
return
}
const maskCanvas = await this.messageBroker.pull('maskCanvas')
const canvas = document.createElement('canvas')
canvas.width = maskCanvas.width
canvas.height = maskCanvas.height
this.brushStrokeCanvas = canvas
this.brushStrokeCtx = canvas.getContext('2d')!
}
private async startDrawing(event: PointerEvent) {
this.isDrawing = true
let compositionOp: CompositionOperation
let currentTool = await this.messageBroker.pull('currentTool')
let coords = { x: event.offsetX, y: event.offsetY }
let coords_canvas = await this.messageBroker.pull('screenToCanvas', coords)
await this.createBrushStrokeCanvas()
//set drawing mode
if (currentTool === Tools.Eraser || event.buttons == 2) {
compositionOp = CompositionOperation.DestinationOut //eraser
} else {
compositionOp = CompositionOperation.SourceOver //pen
}
//check if user wants to draw line or free draw
if (event.shiftKey && this.lineStartPoint) {
this.isDrawingLine = true
this.drawLine(this.lineStartPoint, coords_canvas, compositionOp)
} else {
this.isDrawingLine = false
this.init_shape(compositionOp)
this.draw_shape(coords_canvas)
}
this.lineStartPoint = coords_canvas
this.smoothingCordsArray = [coords_canvas] //used to smooth the drawing line
this.smoothingLastDrawTime = new Date()
}
private async handleDrawing(event: PointerEvent) {
var diff = performance.now() - this.smoothingLastDrawTime.getTime()
let coords = { x: event.offsetX, y: event.offsetY }
let coords_canvas = await this.messageBroker.pull('screenToCanvas', coords)
let currentTool = await this.messageBroker.pull('currentTool')
if (diff > 20 && !this.isDrawing)
requestAnimationFrame(() => {
this.init_shape(CompositionOperation.SourceOver)
this.draw_shape(coords_canvas)
this.smoothingCordsArray.push(coords_canvas)
})
else
requestAnimationFrame(() => {
if (currentTool === Tools.Eraser || event.buttons == 2) {
this.init_shape(CompositionOperation.DestinationOut)
} else {
this.init_shape(CompositionOperation.SourceOver)
}
//use drawWithSmoothing for better performance or change step in drawWithBetterSmoothing
this.drawWithBetterSmoothing(coords_canvas)
})
this.smoothingLastDrawTime = new Date()
}
private async drawEnd(event: PointerEvent) {
const coords = { x: event.offsetX, y: event.offsetY }
const coords_canvas = await this.messageBroker.pull(
'screenToCanvas',
coords
)
if (this.isDrawing) {
this.isDrawing = false
this.messageBroker.publish('saveState')
this.lineStartPoint = coords_canvas
this.initialDraw = true
}
}
private drawWithBetterSmoothing(point: Point) {
// Add current point to the smoothing array
if (!this.smoothingCordsArray) {
this.smoothingCordsArray = []
}
const opacityConstant = 1 / (1 + Math.exp(3))
const interpolatedOpacity =
1 / (1 + Math.exp(-6 * (this.brushSettings.opacity - 0.5))) -
opacityConstant
this.smoothingCordsArray.push(point)
// Keep a moving window of points for the spline
const POINTS_NR = 5
if (this.smoothingCordsArray.length < POINTS_NR) {
return
}
// Calculate total length more efficiently
let totalLength = 0
const points = this.smoothingCordsArray
const len = points.length - 1
// Use local variables for better performance
let dx, dy
for (let i = 0; i < len; i++) {
dx = points[i + 1].x - points[i].x
dy = points[i + 1].y - points[i].y
totalLength += Math.sqrt(dx * dx + dy * dy)
}
const distanceBetweenPoints =
(this.brushSettings.size / this.smoothingPrecision) * 6
const stepNr = Math.ceil(totalLength / distanceBetweenPoints)
let interpolatedPoints = points
if (stepNr > 0) {
//this calculation needs to be improved
interpolatedPoints = this.generateEquidistantPoints(
this.smoothingCordsArray,
distanceBetweenPoints // Distance between interpolated points
)
}
if (!this.initialDraw) {
// Remove the first 3 points from the array to avoid drawing the same points twice
const spliceIndex = interpolatedPoints.findIndex(
(point) =>
point.x === this.smoothingCordsArray[2].x &&
point.y === this.smoothingCordsArray[2].y
)
if (spliceIndex !== -1) {
interpolatedPoints = interpolatedPoints.slice(spliceIndex + 1)
}
}
// Draw all interpolated points
for (const point of interpolatedPoints) {
this.draw_shape(point, interpolatedOpacity)
}
if (!this.initialDraw) {
// initially draw on all 5 points, then remove the first 3 points to go into 2 new, 3 old points cycle
this.smoothingCordsArray = this.smoothingCordsArray.slice(2)
} else {
this.initialDraw = false
}
}
private async drawLine(
p1: Point,
p2: Point,
compositionOp: CompositionOperation
) {
const brush_size = await this.messageBroker.pull('brushSize')
const distance = Math.sqrt((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2)
const steps = Math.ceil(
distance / ((brush_size / this.smoothingPrecision) * 4)
) // Adjust for smoother lines
const interpolatedOpacity =
1 / (1 + Math.exp(-6 * (this.brushSettings.opacity - 0.5))) -
1 / (1 + Math.exp(3))
this.init_shape(compositionOp)
for (let i = 0; i <= steps; i++) {
const t = i / steps
const x = p1.x + (p2.x - p1.x) * t
const y = p1.y + (p2.y - p1.y) * t
const point = { x: x, y: y }
this.draw_shape(point, interpolatedOpacity)
}
}
//brush adjustment
private async startBrushAdjustment(event: PointerEvent) {
event.preventDefault()
const coords = { x: event.offsetX, y: event.offsetY }
let coords_canvas = await this.messageBroker.pull('screenToCanvas', coords)
this.messageBroker.publish('setBrushPreviewGradientVisibility', true)
this.initialPoint = coords_canvas
this.isBrushAdjusting = true
return
}
private async handleBrushAdjustment(event: PointerEvent) {
const coords = { x: event.offsetX, y: event.offsetY }
const brushDeadZone = 5
let coords_canvas = await this.messageBroker.pull('screenToCanvas', coords)
const delta_x = coords_canvas.x - this.initialPoint!.x
const delta_y = coords_canvas.y - this.initialPoint!.y
const effectiveDeltaX = Math.abs(delta_x) < brushDeadZone ? 0 : delta_x
const effectiveDeltaY = Math.abs(delta_y) < brushDeadZone ? 0 : delta_y
// New dominant axis logic
let finalDeltaX = effectiveDeltaX
let finalDeltaY = effectiveDeltaY
console.log(this.useDominantAxis)
if (this.useDominantAxis) {
// New setting flag
const ratio = Math.abs(effectiveDeltaX) / Math.abs(effectiveDeltaY)
const threshold = 2.0 // Configurable threshold
if (ratio > threshold) {
finalDeltaY = 0 // X is dominant
} else if (ratio < 1 / threshold) {
finalDeltaX = 0 // Y is dominant
}
}
const cappedDeltaX = Math.max(-100, Math.min(100, finalDeltaX))
const cappedDeltaY = Math.max(-100, Math.min(100, finalDeltaY))
// Rest of the function remains the same
const sizeDelta = cappedDeltaX / 40
const hardnessDelta = cappedDeltaY / 800
const newSize = Math.max(
1,
Math.min(
100,
this.brushSettings.size! +
(cappedDeltaX / 35) * this.brushAdjustmentSpeed
)
)
const newHardness = Math.max(
0,
Math.min(
1,
this.brushSettings!.hardness -
(cappedDeltaY / 4000) * this.brushAdjustmentSpeed
)
)
this.brushSettings.size = newSize
this.brushSettings.hardness = newHardness
this.messageBroker.publish('updateBrushPreview')
}
//helper functions
private async draw_shape(point: Point, overrideOpacity?: number) {
const brushSettings: Brush = this.brushSettings
const maskCtx = this.maskCtx || (await this.messageBroker.pull('maskCtx'))
const brushType = await this.messageBroker.pull('brushType')
const maskColor = await this.messageBroker.pull('getMaskColor')
const size = brushSettings.size
const sliderOpacity = brushSettings.opacity
const opacity =
overrideOpacity == undefined ? sliderOpacity : overrideOpacity
const hardness = brushSettings.hardness
const x = point.x
const y = point.y
// Extend the gradient radius beyond the brush size
const extendedSize = size * (2 - hardness)
let gradient = maskCtx.createRadialGradient(x, y, 0, x, y, extendedSize)
const isErasing = maskCtx.globalCompositeOperation === 'destination-out'
if (hardness === 1) {
console.log(sliderOpacity, opacity)
gradient.addColorStop(
0,
isErasing
? `rgba(255, 255, 255, ${opacity})`
: `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
)
gradient.addColorStop(
1,
isErasing
? `rgba(255, 255, 255, ${opacity})`
: `rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
)
} else {
let softness = 1 - hardness
let innerStop = Math.max(0, hardness - softness)
let outerStop = size / extendedSize
if (isErasing) {
gradient.addColorStop(0, `rgba(255, 255, 255, ${opacity})`)
gradient.addColorStop(innerStop, `rgba(255, 255, 255, ${opacity})`)
gradient.addColorStop(outerStop, `rgba(255, 255, 255, ${opacity / 2})`)
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`)
} else {
gradient.addColorStop(
0,
`rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
)
gradient.addColorStop(
innerStop,
`rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity})`
)
gradient.addColorStop(
outerStop,
`rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, ${opacity / 2})`
)
gradient.addColorStop(
1,
`rgba(${maskColor.r}, ${maskColor.g}, ${maskColor.b}, 0)`
)
}
}
maskCtx.fillStyle = gradient
maskCtx.beginPath()
if (brushType === BrushShape.Rect) {
maskCtx.rect(
x - extendedSize,
y - extendedSize,
extendedSize * 2,
extendedSize * 2
)
} else {
maskCtx.arc(x, y, extendedSize, 0, Math.PI * 2, false)
}
maskCtx.fill()
}
private async init_shape(compositionOperation: CompositionOperation) {
const maskBlendMode = await this.messageBroker.pull('maskBlendMode')
const maskCtx = this.maskCtx || (await this.messageBroker.pull('maskCtx'))
maskCtx.beginPath()
if (compositionOperation == CompositionOperation.SourceOver) {
maskCtx.fillStyle = maskBlendMode
maskCtx.globalCompositeOperation = CompositionOperation.SourceOver
} else if (compositionOperation == CompositionOperation.DestinationOut) {
maskCtx.globalCompositeOperation = CompositionOperation.DestinationOut
}
}
private calculateCubicSplinePoints(
points: Point[],
numSegments: number = 10
): Point[] {
const result: Point[] = []
const xCoords = points.map((p) => p.x)
const yCoords = points.map((p) => p.y)
const xDerivatives = this.calculateSplineCoefficients(xCoords)
const yDerivatives = this.calculateSplineCoefficients(yCoords)
// Generate points along the spline
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i]
const p1 = points[i + 1]
const d0x = xDerivatives[i]
const d1x = xDerivatives[i + 1]
const d0y = yDerivatives[i]
const d1y = yDerivatives[i + 1]
for (let t = 0; t <= numSegments; t++) {
const t_normalized = t / numSegments
// Hermite basis functions
const h00 = 2 * t_normalized ** 3 - 3 * t_normalized ** 2 + 1
const h10 = t_normalized ** 3 - 2 * t_normalized ** 2 + t_normalized
const h01 = -2 * t_normalized ** 3 + 3 * t_normalized ** 2
const h11 = t_normalized ** 3 - t_normalized ** 2
const x = h00 * p0.x + h10 * d0x + h01 * p1.x + h11 * d1x
const y = h00 * p0.y + h10 * d0y + h01 * p1.y + h11 * d1y
result.push({ x, y })
}
}
return result
}
private generateEvenlyDistributedPoints(
splinePoints: Point[],
numPoints: number
): Point[] {
const distances: number[] = [0]
for (let i = 1; i < splinePoints.length; i++) {
const dx = splinePoints[i].x - splinePoints[i - 1].x
const dy = splinePoints[i].y - splinePoints[i - 1].y
const dist = Math.hypot(dx, dy)
distances.push(distances[i - 1] + dist)
}
const totalLength = distances[distances.length - 1]
const interval = totalLength / (numPoints - 1)
const result: Point[] = []
let currentIndex = 0
for (let i = 0; i < numPoints; i++) {
const targetDistance = i * interval
while (
currentIndex < distances.length - 1 &&
distances[currentIndex + 1] < targetDistance
) {
currentIndex++
}
const t =
(targetDistance - distances[currentIndex]) /
(distances[currentIndex + 1] - distances[currentIndex])
const x =
splinePoints[currentIndex].x +
t * (splinePoints[currentIndex + 1].x - splinePoints[currentIndex].x)
const y =
splinePoints[currentIndex].y +
t * (splinePoints[currentIndex + 1].y - splinePoints[currentIndex].y)
result.push({ x, y })
}
return result
}
private generateEquidistantPoints(
points: Point[],
distance: number
): Point[] {
const result: Point[] = []
const cumulativeDistances: number[] = [0]
// Calculate cumulative distances between points
for (let i = 1; i < points.length; i++) {
const dx = points[i].x - points[i - 1].x
const dy = points[i].y - points[i - 1].y
const dist = Math.hypot(dx, dy)
cumulativeDistances[i] = cumulativeDistances[i - 1] + dist
}
const totalLength = cumulativeDistances[cumulativeDistances.length - 1]
const numPoints = Math.floor(totalLength / distance)
for (let i = 0; i <= numPoints; i++) {
const targetDistance = i * distance
let idx = 0
// Find the segment where the target distance falls
while (
idx < cumulativeDistances.length - 1 &&
cumulativeDistances[idx + 1] < targetDistance
) {
idx++
}
if (idx >= points.length - 1) {
result.push(points[points.length - 1])
continue
}
const d0 = cumulativeDistances[idx]
const d1 = cumulativeDistances[idx + 1]
const t = (targetDistance - d0) / (d1 - d0)
const x = points[idx].x + t * (points[idx + 1].x - points[idx].x)
const y = points[idx].y + t * (points[idx + 1].y - points[idx].y)
result.push({ x, y })
}
return result
}
private calculateSplineCoefficients(values: number[]): number[] {
const n = values.length - 1
const matrix: number[][] = new Array(n + 1)
.fill(0)
.map(() => new Array(n + 1).fill(0))
const rhs: number[] = new Array(n + 1).fill(0)
// Set up tridiagonal matrix
for (let i = 1; i < n; i++) {
matrix[i][i - 1] = 1
matrix[i][i] = 4
matrix[i][i + 1] = 1
rhs[i] = 3 * (values[i + 1] - values[i - 1])
}
// Set boundary conditions (natural spline)
matrix[0][0] = 2
matrix[0][1] = 1
matrix[n][n - 1] = 1
matrix[n][n] = 2
rhs[0] = 3 * (values[1] - values[0])
rhs[n] = 3 * (values[n] - values[n - 1])
// Solve tridiagonal system using Thomas algorithm
for (let i = 1; i <= n; i++) {
const m = matrix[i][i - 1] / matrix[i - 1][i - 1]
matrix[i][i] -= m * matrix[i - 1][i]
rhs[i] -= m * rhs[i - 1]
}
const solution: number[] = new Array(n + 1)
solution[n] = rhs[n] / matrix[n][n]
for (let i = n - 1; i >= 0; i--) {
solution[i] = (rhs[i] - matrix[i][i + 1] * solution[i + 1]) / matrix[i][i]
}
return solution
}
private setBrushSize(size: number) {
this.brushSettings.size = size
}
private setBrushOpacity(opacity: number) {
this.brushSettings.opacity = opacity
}
private setBrushHardness(hardness: number) {
this.brushSettings.hardness = hardness
}
private setBrushType(type: BrushShape) {
this.brushSettings.type = type
}
private setBrushSmoothingPrecision(precision: number) {
//console.log('precision', precision)
this.smoothingPrecision = precision
}
}
class UIManager {
private rootElement: HTMLElement
private brush!: HTMLDivElement
private brushPreviewGradient!: HTMLDivElement
private maskCtx!: CanvasRenderingContext2D
private imageCtx!: CanvasRenderingContext2D
private maskCanvas!: HTMLCanvasElement
private imgCanvas!: HTMLCanvasElement
private brushSettingsHTML!: HTMLDivElement
private paintBucketSettingsHTML!: HTMLDivElement
private colorSelectSettingsHTML!: HTMLDivElement
private maskOpacitySlider!: HTMLInputElement
private brushHardnessSlider!: HTMLInputElement
private brushSizeSlider!: HTMLInputElement
private brushOpacitySlider!: HTMLInputElement
private sidebarImage!: HTMLImageElement
private saveButton!: HTMLButtonElement
private toolPanel!: HTMLDivElement
private sidePanel!: HTMLDivElement
private pointerZone!: HTMLDivElement
private canvasBackground!: HTMLDivElement
private canvasContainer!: HTMLDivElement
private image!: HTMLImageElement
private imageURL!: URL
private darkMode: boolean = true
private maskEditor: MaskEditorDialog
private messageBroker: MessageBroker
private mask_opacity: number = 1.0
private maskBlendMode: MaskBlendMode = MaskBlendMode.Black
private zoomTextHTML!: HTMLSpanElement
private dimensionsTextHTML!: HTMLSpanElement
constructor(rootElement: HTMLElement, maskEditor: MaskEditorDialog) {
this.rootElement = rootElement
this.maskEditor = maskEditor
this.messageBroker = maskEditor.getMessageBroker()
this.addListeners()
this.addPullTopics()
}
addListeners() {
this.messageBroker.subscribe('updateBrushPreview', async () =>
this.updateBrushPreview()
)
this.messageBroker.subscribe(
'paintBucketCursor',
(isPaintBucket: boolean) => this.handlePaintBucketCursor(isPaintBucket)
)
this.messageBroker.subscribe('panCursor', (isPan: boolean) =>
this.handlePanCursor(isPan)
)
this.messageBroker.subscribe('setBrushVisibility', (isVisible: boolean) =>
this.setBrushVisibility(isVisible)
)
this.messageBroker.subscribe(
'setBrushPreviewGradientVisibility',
(isVisible: boolean) => this.setBrushPreviewGradientVisibility(isVisible)
)
this.messageBroker.subscribe('updateCursor', () => this.updateCursor())
this.messageBroker.subscribe('setZoomText', (text: string) =>
this.setZoomText(text)
)
}
addPullTopics() {
this.messageBroker.createPullTopic(
'maskCanvas',
async () => this.maskCanvas
)
this.messageBroker.createPullTopic('maskCtx', async () => this.maskCtx)
this.messageBroker.createPullTopic('imageCtx', async () => this.imageCtx)
this.messageBroker.createPullTopic('imgCanvas', async () => this.imgCanvas)
this.messageBroker.createPullTopic(
'screenToCanvas',
async (coords: Point) => this.screenToCanvas(coords)
)
this.messageBroker.createPullTopic(
'getCanvasContainer',
async () => this.canvasContainer
)
this.messageBroker.createPullTopic('getMaskColor', async () =>
this.getMaskColor()
)
}
async setlayout() {
this.detectLightMode()
var user_ui = await this.createUI()
var canvasContainer = this.createBackgroundUI()
var brush = await this.createBrush()
await this.setBrushBorderRadius()
this.setBrushOpacity(1)
this.rootElement.appendChild(canvasContainer)
this.rootElement.appendChild(user_ui)
document.body.appendChild(brush)
}
private async createUI() {
var ui_container = document.createElement('div')
ui_container.id = 'maskEditor_uiContainer'
var top_bar = await this.createTopBar()
var ui_horizontal_container = document.createElement('div')
ui_horizontal_container.id = 'maskEditor_uiHorizontalContainer'
var side_panel_container = await this.createSidePanel()
var pointer_zone = this.createPointerZone()
var tool_panel = this.createToolPanel()
ui_horizontal_container.appendChild(tool_panel)
ui_horizontal_container.appendChild(pointer_zone)
ui_horizontal_container.appendChild(side_panel_container)
ui_container.appendChild(top_bar)
ui_container.appendChild(ui_horizontal_container)
return ui_container
}
private createBackgroundUI() {
const canvasContainer = document.createElement('div')
canvasContainer.id = 'maskEditorCanvasContainer'
const imgCanvas = document.createElement('canvas')
imgCanvas.id = 'imageCanvas'
const maskCanvas = document.createElement('canvas')
maskCanvas.id = 'maskCanvas'
const canvas_background = document.createElement('div')
canvas_background.id = 'canvasBackground'
canvasContainer.appendChild(imgCanvas)
canvasContainer.appendChild(maskCanvas)
canvasContainer.appendChild(canvas_background)
// prepare content
this.imgCanvas = imgCanvas!
this.maskCanvas = maskCanvas!
this.canvasContainer = canvasContainer!
this.canvasBackground = canvas_background!
let maskCtx = maskCanvas!.getContext('2d', { willReadFrequently: true })
if (maskCtx) {
this.maskCtx = maskCtx
}
let imgCtx = imgCanvas!.getContext('2d', { willReadFrequently: true })
if (imgCtx) {
this.imageCtx = imgCtx
}
this.setEventHandler()
//remove styling and move to css file
this.imgCanvas.style.position = 'absolute'
this.maskCanvas.style.position = 'absolute'
this.imgCanvas.style.top = '200'
this.imgCanvas.style.left = '0'
this.maskCanvas.style.top = this.imgCanvas.style.top
this.maskCanvas.style.left = this.imgCanvas.style.left
const maskCanvasStyle = this.getMaskCanvasStyle()
this.maskCanvas.style.mixBlendMode = maskCanvasStyle.mixBlendMode
this.maskCanvas.style.opacity = maskCanvasStyle.opacity.toString()
return canvasContainer
}
async setBrushBorderRadius() {
const brushSettings = await this.messageBroker.pull('brushSettings')
if (brushSettings.type === BrushShape.Rect) {
this.brush.style.borderRadius = '0%'
// @ts-expect-error
this.brush.style.MozBorderRadius = '0%'
// @ts-expect-error
this.brush.style.WebkitBorderRadius = '0%'
} else {
this.brush.style.borderRadius = '50%'
// @ts-expect-error
this.brush.style.MozBorderRadius = '50%'
// @ts-expect-error
this.brush.style.WebkitBorderRadius = '50%'
}
}
async initUI() {
this.saveButton.innerText = 'Save'
this.saveButton.disabled = false
await this.setImages(this.imgCanvas) //probably change method to initImageCanvas
}
private async createSidePanel() {
const side_panel = this.createContainer(true)
side_panel.id = 'maskEditor_sidePanel'
const brush_settings = await this.createBrushSettings()
brush_settings.id = 'maskEditor_brushSettings'
this.brushSettingsHTML = brush_settings
const paint_bucket_settings = await this.createPaintBucketSettings()
paint_bucket_settings.id = 'maskEditor_paintBucketSettings'
this.paintBucketSettingsHTML = paint_bucket_settings
const color_select_settings = await this.createColorSelectSettings()
color_select_settings.id = 'maskEditor_colorSelectSettings'
this.colorSelectSettingsHTML = color_select_settings
const image_layer_settings = await this.createImageLayerSettings()
const separator = this.createSeparator()
side_panel.appendChild(brush_settings)
side_panel.appendChild(paint_bucket_settings)
side_panel.appendChild(color_select_settings)
side_panel.appendChild(separator)
side_panel.appendChild(image_layer_settings)
return side_panel
}
private async createBrushSettings() {
const shapeColor = this.darkMode
? 'maskEditor_brushShape_dark'
: 'maskEditor_brushShape_light'
const brush_settings_container = this.createContainer(true)
const brush_settings_title = this.createHeadline('Brush Settings')
const brush_shape_outer_container = this.createContainer(true)
const brush_shape_title = this.createContainerTitle('Brush Shape')
const brush_shape_container = this.createContainer(false)
const accentColor = this.darkMode
? 'maskEditor_accent_bg_dark'
: 'maskEditor_accent_bg_light'
brush_shape_container.classList.add(accentColor)
brush_shape_container.classList.add('maskEditor_layerRow')
const circle_shape = document.createElement('div')
circle_shape.id = 'maskEditor_sidePanelBrushShapeCircle'
circle_shape.classList.add(shapeColor)
circle_shape.style.background = 'var(--p-button-text-primary-color)'
circle_shape.addEventListener('click', () => {
this.messageBroker.publish('setBrushShape', BrushShape.Arc)
this.setBrushBorderRadius()
circle_shape.style.background = 'var(--p-button-text-primary-color)'
square_shape.style.background = ''
})
const square_shape = document.createElement('div')
square_shape.id = 'maskEditor_sidePanelBrushShapeSquare'
square_shape.classList.add(shapeColor)
square_shape.style.background = ''
square_shape.addEventListener('click', () => {
this.messageBroker.publish('setBrushShape', BrushShape.Rect)
this.setBrushBorderRadius()
square_shape.style.background = 'var(--p-button-text-primary-color)'
circle_shape.style.background = ''
})
brush_shape_container.appendChild(circle_shape)
brush_shape_container.appendChild(square_shape)
brush_shape_outer_container.appendChild(brush_shape_title)
brush_shape_outer_container.appendChild(brush_shape_container)
const thicknesSliderObj = this.createSlider(
'Thickness',
1,
100,
1,
10,
(event, value) => {
this.messageBroker.publish('setBrushSize', parseInt(value))
this.updateBrushPreview()
}
)
this.brushSizeSlider = thicknesSliderObj.slider
const opacitySliderObj = this.createSlider(
'Opacity',
0,
1,
0.01,
0.7,
(event, value) => {
this.messageBroker.publish('setBrushOpacity', parseFloat(value))
this.updateBrushPreview()
}
)
this.brushOpacitySlider = opacitySliderObj.slider
const hardnessSliderObj = this.createSlider(
'Hardness',
0,
1,
0.01,
1,
(event, value) => {
this.messageBroker.publish('setBrushHardness', parseFloat(value))
this.updateBrushPreview()
}
)
this.brushHardnessSlider = hardnessSliderObj.slider
const brushSmoothingPrecisionSliderObj = this.createSlider(
'Smoothing Precision',
1,
100,
1,
10,
(event, value) => {
this.messageBroker.publish(
'setBrushSmoothingPrecision',
parseInt(value)
)
}
)
brush_settings_container.appendChild(brush_settings_title)
brush_settings_container.appendChild(brush_shape_outer_container)
brush_settings_container.appendChild(thicknesSliderObj.container)
brush_settings_container.appendChild(opacitySliderObj.container)
brush_settings_container.appendChild(hardnessSliderObj.container)
brush_settings_container.appendChild(
brushSmoothingPrecisionSliderObj.container
)
return brush_settings_container
}
private async createPaintBucketSettings() {
const paint_bucket_settings_container = this.createContainer(true)
const paint_bucket_settings_title = this.createHeadline(
'Paint Bucket Settings'
)
const tolerance = await this.messageBroker.pull('getTolerance')
const paintBucketToleranceSliderObj = this.createSlider(
'Tolerance',
0,
255,
1,
tolerance,
(event, value) => {
this.messageBroker.publish('setPaintBucketTolerance', parseInt(value))
}
)
paint_bucket_settings_container.appendChild(paint_bucket_settings_title)
paint_bucket_settings_container.appendChild(
paintBucketToleranceSliderObj.container
)
return paint_bucket_settings_container
}
private async createColorSelectSettings() {
const color_select_settings_container = this.createContainer(true)
const color_select_settings_title = this.createHeadline(
'Color Select Settings'
)
var tolerance = await this.messageBroker.pull('getTolerance')
const colorSelectToleranceSliderObj = this.createSlider(
'Tolerance',
0,
255,
1,
tolerance,
(event, value) => {
this.messageBroker.publish('setColorSelectTolerance', parseInt(value))
}
)
const livePreviewToggle = this.createToggle(
'Live Preview',
(event, value) => {
this.messageBroker.publish('setLivePreview', value)
}
)
const wholeImageToggle = this.createToggle(
'Apply to Whole Image',
(event, value) => {
this.messageBroker.publish('setWholeImage', value)
}
)
const methodOptions = Object.values(ColorComparisonMethod)
const methodSelect = this.createDropdown(
'Method',
methodOptions,
(event, value) => {
this.messageBroker.publish('setColorComparisonMethod', value)
}
)
const maskBoundaryToggle = this.createToggle(
'Stop at mask',
(event, value) => {
this.messageBroker.publish('setMaskBoundary', value)
}
)
const maskToleranceSliderObj = this.createSlider(
'Mask Tolerance',
0,
255,
1,
0,
(event, value) => {
this.messageBroker.publish('setMaskTolerance', parseInt(value))
}
)
color_select_settings_container.appendChild(color_select_settings_title)
color_select_settings_container.appendChild(
colorSelectToleranceSliderObj.container
)
color_select_settings_container.appendChild(livePreviewToggle)
color_select_settings_container.appendChild(wholeImageToggle)
color_select_settings_container.appendChild(methodSelect)
color_select_settings_container.appendChild(maskBoundaryToggle)
color_select_settings_container.appendChild(
maskToleranceSliderObj.container
)
return color_select_settings_container
}
private async createImageLayerSettings() {
const accentColor = this.darkMode
? 'maskEditor_accent_bg_dark'
: 'maskEditor_accent_bg_light'
const image_layer_settings_container = this.createContainer(true)
const image_layer_settings_title = this.createHeadline('Layers')
const mask_layer_title = this.createContainerTitle('Mask Layer')
const mask_layer_container = this.createContainer(false)
mask_layer_container.classList.add(accentColor)
mask_layer_container.classList.add('maskEditor_layerRow')
const mask_layer_visibility_checkbox = document.createElement('input')
mask_layer_visibility_checkbox.setAttribute('type', 'checkbox')
mask_layer_visibility_checkbox.checked = true
mask_layer_visibility_checkbox.classList.add(
'maskEditor_sidePanelLayerCheckbox'
)
mask_layer_visibility_checkbox.addEventListener('change', (event) => {
if (!(event.target as HTMLInputElement)!.checked) {
this.maskCanvas.style.opacity = '0'
} else {
this.maskCanvas.style.opacity = String(this.mask_opacity) //change name
}
})
var mask_layer_image_container = document.createElement('div')
mask_layer_image_container.classList.add(
'maskEditor_sidePanelLayerPreviewContainer'
)
mask_layer_image_container.innerHTML =
'<svg viewBox="0 0 20 20" style=""> <path class="cls-1" d="M1.31,5.32v9.36c0,.55.45,1,1,1h15.38c.55,0,1-.45,1-1V5.32c0-.55-.45-1-1-1H2.31c-.55,0-1,.45-1,1ZM11.19,13.44c-2.91.94-5.57-1.72-4.63-4.63.34-1.05,1.19-1.9,2.24-2.24,2.91-.94,5.57,1.72,4.63,4.63-.34,1.05-1.19,1.9-2.24,2.24Z"/> </svg>'
var blending_options = ['black', 'white', 'negative']
const sidePanelDropdownAccent = this.darkMode
? 'maskEditor_sidePanelDropdown_dark'
: 'maskEditor_sidePanelDropdown_light'
var mask_layer_dropdown = document.createElement('select')
mask_layer_dropdown.classList.add(sidePanelDropdownAccent)
mask_layer_dropdown.classList.add(sidePanelDropdownAccent)
blending_options.forEach((option) => {
var option_element = document.createElement('option')
option_element.value = option
option_element.innerText = option
mask_layer_dropdown.appendChild(option_element)
if (option == this.maskBlendMode) {
option_element.selected = true
}
})
mask_layer_dropdown.addEventListener('change', (event) => {
const selectedValue = (event.target as HTMLSelectElement)
.value as MaskBlendMode
this.maskBlendMode = selectedValue
this.updateMaskColor()
})
mask_layer_container.appendChild(mask_layer_visibility_checkbox)
mask_layer_container.appendChild(mask_layer_image_container)
mask_layer_container.appendChild(mask_layer_dropdown)
const mask_layer_opacity_sliderObj = this.createSlider(
'Mask Opacity',
0.0,
1.0,
0.01,
this.mask_opacity,
(event, value) => {
this.mask_opacity = parseFloat(value)
this.maskCanvas.style.opacity = String(this.mask_opacity)
if (this.mask_opacity == 0) {
mask_layer_visibility_checkbox.checked = false
} else {
mask_layer_visibility_checkbox.checked = true
}
}
)
this.maskOpacitySlider = mask_layer_opacity_sliderObj.slider
const image_layer_title = this.createContainerTitle('Image Layer')
const image_layer_container = this.createContainer(false)
image_layer_container.classList.add(accentColor)
image_layer_container.classList.add('maskEditor_layerRow')
const image_layer_visibility_checkbox = document.createElement('input')
image_layer_visibility_checkbox.setAttribute('type', 'checkbox')
image_layer_visibility_checkbox.classList.add(
'maskEditor_sidePanelLayerCheckbox'
)
image_layer_visibility_checkbox.checked = true
image_layer_visibility_checkbox.addEventListener('change', (event) => {
if (!(event.target as HTMLInputElement)!.checked) {
this.imgCanvas.style.opacity = '0'
} else {
this.imgCanvas.style.opacity = '1'
}
})
const image_layer_image_container = document.createElement('div')
image_layer_image_container.classList.add(
'maskEditor_sidePanelLayerPreviewContainer'
)
const image_layer_image = document.createElement('img')
image_layer_image.id = 'maskEditor_sidePanelImageLayerImage'
image_layer_image.src =
ComfyApp.clipspace?.imgs?.[ComfyApp.clipspace?.selectedIndex ?? 0]?.src ??
''
this.sidebarImage = image_layer_image
image_layer_image_container.appendChild(image_layer_image)
image_layer_container.appendChild(image_layer_visibility_checkbox)
image_layer_container.appendChild(image_layer_image_container)
image_layer_settings_container.appendChild(image_layer_settings_title)
image_layer_settings_container.appendChild(mask_layer_title)
image_layer_settings_container.appendChild(mask_layer_container)
image_layer_settings_container.appendChild(
mask_layer_opacity_sliderObj.container
)
image_layer_settings_container.appendChild(image_layer_title)
image_layer_settings_container.appendChild(image_layer_container)
return image_layer_settings_container
}
private createHeadline(title: string) {
var headline = document.createElement('h3')
headline.classList.add('maskEditor_sidePanelTitle')
headline.innerText = title
return headline
}
private createContainer(flexDirection: boolean) {
var container = document.createElement('div')
if (flexDirection) {
container.classList.add('maskEditor_sidePanelContainerColumn')
} else {
container.classList.add('maskEditor_sidePanelContainerRow')
}
return container
}
private createContainerTitle(title: string) {
var container_title = document.createElement('span')
container_title.classList.add('maskEditor_sidePanelSubTitle')
container_title.innerText = title
return container_title
}
private createSlider(
title: string,
min: number,
max: number,
step: number,
value: number,
callback: (event: Event, value: string) => void
) {
var slider_container = this.createContainer(true)
var slider_title = this.createContainerTitle(title)
var slider = document.createElement('input')
slider.classList.add('maskEditor_sidePanelBrushRange')
slider.setAttribute('type', 'range')
slider.setAttribute('min', String(min))
slider.setAttribute('max', String(max))
slider.setAttribute('step', String(step))
slider.setAttribute('value', String(value))
slider.addEventListener('input', (event) => {
callback(event, (event.target as HTMLInputElement).value)
})
slider_container.appendChild(slider_title)
slider_container.appendChild(slider)
return { container: slider_container, slider: slider }
}
private createToggle(
title: string,
callback: (event: Event, value: boolean) => void
) {
var outer_Container = this.createContainer(false)
var toggle_title = this.createContainerTitle(title)
var toggle_container = document.createElement('label')
toggle_container.classList.add('maskEditor_sidePanelToggleContainer')
var toggle_checkbox = document.createElement('input')
toggle_checkbox.setAttribute('type', 'checkbox')
toggle_checkbox.classList.add('maskEditor_sidePanelToggleCheckbox')
toggle_checkbox.addEventListener('change', (event) => {
callback(event, (event.target as HTMLInputElement).checked)
})
var toggleAccentColor = this.darkMode
? 'maskEditor_toggle_bg_dark'
: 'maskEditor_toggle_bg_light'
var toggle_switch = document.createElement('div')
toggle_switch.classList.add('maskEditor_sidePanelToggleSwitch')
toggle_switch.classList.add(toggleAccentColor)
toggle_container.appendChild(toggle_checkbox)
toggle_container.appendChild(toggle_switch)
outer_Container.appendChild(toggle_title)
outer_Container.appendChild(toggle_container)
return outer_Container
}
private createDropdown(
title: string,
options: string[],
callback: (event: Event, value: string) => void
) {
const sidePanelDropdownAccent = this.darkMode
? 'maskEditor_sidePanelDropdown_dark'
: 'maskEditor_sidePanelDropdown_light'
var dropdown_container = this.createContainer(false)
var dropdown_title = this.createContainerTitle(title)
var dropdown = document.createElement('select')
dropdown.classList.add(sidePanelDropdownAccent)
dropdown.classList.add('maskEditor_containerDropdown')
options.forEach((option) => {
var option_element = document.createElement('option')
option_element.value = option
option_element.innerText = option
dropdown.appendChild(option_element)
})
dropdown.addEventListener('change', (event) => {
callback(event, (event.target as HTMLSelectElement).value)
})
dropdown_container.appendChild(dropdown_title)
dropdown_container.appendChild(dropdown)
return dropdown_container
}
private createSeparator() {
var separator = document.createElement('div')
separator.classList.add('maskEditor_sidePanelSeparator')
return separator
}
//----------------
private async createTopBar() {
const buttonAccentColor = this.darkMode
? 'maskEditor_topPanelButton_dark'
: 'maskEditor_topPanelButton_light'
const iconButtonAccentColor = this.darkMode
? 'maskEditor_topPanelIconButton_dark'
: 'maskEditor_topPanelIconButton_light'
var top_bar = document.createElement('div')
top_bar.id = 'maskEditor_topBar'
var top_bar_title_container = document.createElement('div')
top_bar_title_container.id = 'maskEditor_topBarTitleContainer'
var top_bar_title = document.createElement('h1')
top_bar_title.id = 'maskEditor_topBarTitle'
top_bar_title.innerText = 'ComfyUI'
top_bar_title_container.appendChild(top_bar_title)
var top_bar_shortcuts_container = document.createElement('div')
top_bar_shortcuts_container.id = 'maskEditor_topBarShortcutsContainer'
var top_bar_undo_button = document.createElement('div')
top_bar_undo_button.id = 'maskEditor_topBarUndoButton'
top_bar_undo_button.classList.add(iconButtonAccentColor)
top_bar_undo_button.innerHTML =
'<svg viewBox="0 0 15 15"><path d="M8.77,12.18c-.25,0-.46-.2-.46-.46s.2-.46.46-.46c1.47,0,2.67-1.2,2.67-2.67,0-1.57-1.34-2.67-3.26-2.67h-3.98l1.43,1.43c.18.18.18.47,0,.64-.18.18-.47.18-.64,0l-2.21-2.21c-.18-.18-.18-.47,0-.64l2.21-2.21c.18-.18.47-.18.64,0,.18.18.18.47,0,.64l-1.43,1.43h3.98c2.45,0,4.17,1.47,4.17,3.58,0,1.97-1.61,3.58-3.58,3.58Z"></path> </svg>'
top_bar_undo_button.addEventListener('click', () => {
this.messageBroker.publish('undo')
})
var top_bar_redo_button = document.createElement('div')
top_bar_redo_button.id = 'maskEditor_topBarRedoButton'
top_bar_redo_button.classList.add(iconButtonAccentColor)
top_bar_redo_button.innerHTML =
'<svg viewBox="0 0 15 15"> <path class="cls-1" d="M6.23,12.18c-1.97,0-3.58-1.61-3.58-3.58,0-2.11,1.71-3.58,4.17-3.58h3.98l-1.43-1.43c-.18-.18-.18-.47,0-.64.18-.18.46-.18.64,0l2.21,2.21c.09.09.13.2.13.32s-.05.24-.13.32l-2.21,2.21c-.18.18-.47.18-.64,0-.18-.18-.18-.47,0-.64l1.43-1.43h-3.98c-1.92,0-3.26,1.1-3.26,2.67,0,1.47,1.2,2.67,2.67,2.67.25,0,.46.2.46.46s-.2.46-.46.46Z"/></svg>'
top_bar_redo_button.addEventListener('click', () => {
this.messageBroker.publish('redo')
})
var top_bar_invert_button = document.createElement('button')
top_bar_invert_button.id = 'maskEditor_topBarInvertButton'
top_bar_invert_button.classList.add(buttonAccentColor)
top_bar_invert_button.innerText = 'Invert'
top_bar_invert_button.addEventListener('click', () => {
this.messageBroker.publish('invert')
})
var top_bar_clear_button = document.createElement('button')
top_bar_clear_button.id = 'maskEditor_topBarClearButton'
top_bar_clear_button.classList.add(buttonAccentColor)
top_bar_clear_button.innerText = 'Clear'
top_bar_clear_button.addEventListener('click', () => {
this.maskCtx.clearRect(
0,
0,
this.maskCanvas.width,
this.maskCanvas.height
)
this.messageBroker.publish('saveState')
})
var top_bar_save_button = document.createElement('button')
top_bar_save_button.id = 'maskEditor_topBarSaveButton'
top_bar_save_button.classList.add(buttonAccentColor)
top_bar_save_button.innerText = 'Save'
this.saveButton = top_bar_save_button
top_bar_save_button.addEventListener('click', () => {
this.maskEditor.save()
})
var top_bar_cancel_button = document.createElement('button')
top_bar_cancel_button.id = 'maskEditor_topBarCancelButton'
top_bar_cancel_button.classList.add(buttonAccentColor)
top_bar_cancel_button.innerText = 'Cancel'
top_bar_cancel_button.addEventListener('click', () => {
this.maskEditor.close()
})
top_bar_shortcuts_container.appendChild(top_bar_undo_button)
top_bar_shortcuts_container.appendChild(top_bar_redo_button)
top_bar_shortcuts_container.appendChild(top_bar_invert_button)
top_bar_shortcuts_container.appendChild(top_bar_clear_button)
top_bar_shortcuts_container.appendChild(top_bar_save_button)
top_bar_shortcuts_container.appendChild(top_bar_cancel_button)
top_bar.appendChild(top_bar_title_container)
top_bar.appendChild(top_bar_shortcuts_container)
return top_bar
}
private createToolPanel() {
var tool_panel = document.createElement('div')
tool_panel.id = 'maskEditor_toolPanel'
this.toolPanel = tool_panel
var toolPanelHoverAccent = this.darkMode
? 'maskEditor_toolPanelContainerDark'
: 'maskEditor_toolPanelContainerLight'
var toolElements: HTMLElement[] = []
//brush tool
var toolPanel_brushToolContainer = document.createElement('div')
toolPanel_brushToolContainer.classList.add('maskEditor_toolPanelContainer')
toolPanel_brushToolContainer.classList.add(
'maskEditor_toolPanelContainerSelected'
)
toolPanel_brushToolContainer.classList.add(toolPanelHoverAccent)
toolPanel_brushToolContainer.innerHTML = `
<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>
`
toolElements.push(toolPanel_brushToolContainer)
toolPanel_brushToolContainer.addEventListener('click', () => {
//move logic to tool manager
this.messageBroker.publish('setTool', Tools.Pen)
for (let toolElement of toolElements) {
if (toolElement != toolPanel_brushToolContainer) {
toolElement.classList.remove('maskEditor_toolPanelContainerSelected')
} else {
toolElement.classList.add('maskEditor_toolPanelContainerSelected')
this.brushSettingsHTML.style.display = 'flex'
this.colorSelectSettingsHTML.style.display = 'none'
this.paintBucketSettingsHTML.style.display = 'none'
}
}
this.messageBroker.publish('setTool', Tools.Pen)
this.pointerZone.style.cursor = 'none'
})
var toolPanel_brushToolIndicator = document.createElement('div')
toolPanel_brushToolIndicator.classList.add('maskEditor_toolPanelIndicator')
toolPanel_brushToolContainer.appendChild(toolPanel_brushToolIndicator)
//eraser tool
var toolPanel_eraserToolContainer = document.createElement('div')
toolPanel_eraserToolContainer.classList.add('maskEditor_toolPanelContainer')
toolPanel_eraserToolContainer.classList.add(toolPanelHoverAccent)
toolPanel_eraserToolContainer.innerHTML = `
<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>
`
toolElements.push(toolPanel_eraserToolContainer)
toolPanel_eraserToolContainer.addEventListener('click', () => {
//move logic to tool manager
this.messageBroker.publish('setTool', Tools.Eraser)
for (let toolElement of toolElements) {
if (toolElement != toolPanel_eraserToolContainer) {
toolElement.classList.remove('maskEditor_toolPanelContainerSelected')
} else {
toolElement.classList.add('maskEditor_toolPanelContainerSelected')
this.brushSettingsHTML.style.display = 'flex'
this.colorSelectSettingsHTML.style.display = 'none'
this.paintBucketSettingsHTML.style.display = 'none'
}
}
this.messageBroker.publish('setTool', Tools.Eraser)
this.pointerZone.style.cursor = 'none'
})
var toolPanel_eraserToolIndicator = document.createElement('div')
toolPanel_eraserToolIndicator.classList.add('maskEditor_toolPanelIndicator')
toolPanel_eraserToolContainer.appendChild(toolPanel_eraserToolIndicator)
//paint bucket tool
var toolPanel_paintBucketToolContainer = document.createElement('div')
toolPanel_paintBucketToolContainer.classList.add(
'maskEditor_toolPanelContainer'
)
toolPanel_paintBucketToolContainer.classList.add(toolPanelHoverAccent)
toolPanel_paintBucketToolContainer.innerHTML = `
<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>
`
toolElements.push(toolPanel_paintBucketToolContainer)
toolPanel_paintBucketToolContainer.addEventListener('click', () => {
//move logic to tool manager
this.messageBroker.publish('setTool', Tools.PaintBucket)
for (let toolElement of toolElements) {
if (toolElement != toolPanel_paintBucketToolContainer) {
toolElement.classList.remove('maskEditor_toolPanelContainerSelected')
} else {
toolElement.classList.add('maskEditor_toolPanelContainerSelected')
this.brushSettingsHTML.style.display = 'none'
this.colorSelectSettingsHTML.style.display = 'none'
this.paintBucketSettingsHTML.style.display = 'flex'
}
}
this.messageBroker.publish('setTool', Tools.PaintBucket)
this.pointerZone.style.cursor =
"url('/cursor/paintBucket.png') 30 25, auto"
this.brush.style.opacity = '0'
})
var toolPanel_paintBucketToolIndicator = document.createElement('div')
toolPanel_paintBucketToolIndicator.classList.add(
'maskEditor_toolPanelIndicator'
)
toolPanel_paintBucketToolContainer.appendChild(
toolPanel_paintBucketToolIndicator
)
//color select tool
var toolPanel_colorSelectToolContainer = document.createElement('div')
toolPanel_colorSelectToolContainer.classList.add(
'maskEditor_toolPanelContainer'
)
toolPanel_colorSelectToolContainer.classList.add(toolPanelHoverAccent)
toolPanel_colorSelectToolContainer.innerHTML = `
<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>
`
toolElements.push(toolPanel_colorSelectToolContainer)
toolPanel_colorSelectToolContainer.addEventListener('click', () => {
this.messageBroker.publish('setTool', 'colorSelect')
for (let toolElement of toolElements) {
if (toolElement != toolPanel_colorSelectToolContainer) {
toolElement.classList.remove('maskEditor_toolPanelContainerSelected')
} else {
toolElement.classList.add('maskEditor_toolPanelContainerSelected')
this.brushSettingsHTML.style.display = 'none'
this.paintBucketSettingsHTML.style.display = 'none'
this.colorSelectSettingsHTML.style.display = 'flex'
}
}
this.messageBroker.publish('setTool', Tools.ColorSelect)
this.pointerZone.style.cursor =
"url('/cursor/colorSelect.png') 15 25, auto"
this.brush.style.opacity = '0'
})
var toolPanel_colorSelectToolIndicator = document.createElement('div')
toolPanel_colorSelectToolIndicator.classList.add(
'maskEditor_toolPanelIndicator'
)
toolPanel_colorSelectToolContainer.appendChild(
toolPanel_colorSelectToolIndicator
)
//zoom indicator
var toolPanel_zoomIndicator = document.createElement('div')
toolPanel_zoomIndicator.classList.add('maskEditor_toolPanelZoomIndicator')
toolPanel_zoomIndicator.classList.add(toolPanelHoverAccent)
var toolPanel_zoomText = document.createElement('span')
toolPanel_zoomText.id = 'maskEditor_toolPanelZoomText'
toolPanel_zoomText.innerText = '100%'
this.zoomTextHTML = toolPanel_zoomText
var toolPanel_DimensionsText = document.createElement('span')
toolPanel_DimensionsText.id = 'maskEditor_toolPanelDimensionsText'
toolPanel_DimensionsText.innerText = ' '
this.dimensionsTextHTML = toolPanel_DimensionsText
toolPanel_zoomIndicator.appendChild(toolPanel_zoomText)
toolPanel_zoomIndicator.appendChild(toolPanel_DimensionsText)
toolPanel_zoomIndicator.addEventListener('click', () => {
this.messageBroker.publish('resetZoom')
})
tool_panel.appendChild(toolPanel_brushToolContainer)
tool_panel.appendChild(toolPanel_eraserToolContainer)
tool_panel.appendChild(toolPanel_paintBucketToolContainer)
tool_panel.appendChild(toolPanel_colorSelectToolContainer)
tool_panel.appendChild(toolPanel_zoomIndicator)
return tool_panel
}
private createPointerZone() {
const pointer_zone = document.createElement('div')
pointer_zone.id = 'maskEditor_pointerZone'
this.pointerZone = pointer_zone
pointer_zone.addEventListener('pointerdown', (event: PointerEvent) => {
this.messageBroker.publish('pointerDown', event)
})
pointer_zone.addEventListener('pointermove', (event: PointerEvent) => {
this.messageBroker.publish('pointerMove', event)
})
pointer_zone.addEventListener('pointerup', (event: PointerEvent) => {
this.messageBroker.publish('pointerUp', event)
})
pointer_zone.addEventListener('pointerleave', (event: PointerEvent) => {
this.brush.style.opacity = '0'
this.pointerZone.style.cursor = ''
})
pointer_zone.addEventListener('touchstart', (event: TouchEvent) => {
this.messageBroker.publish('handleTouchStart', event)
})
pointer_zone.addEventListener('touchmove', (event: TouchEvent) => {
this.messageBroker.publish('handleTouchMove', event)
})
pointer_zone.addEventListener('touchend', (event: TouchEvent) => {
this.messageBroker.publish('handleTouchEnd', event)
})
pointer_zone.addEventListener('wheel', (event) =>
this.messageBroker.publish('wheel', event)
)
pointer_zone.addEventListener(
'pointerenter',
async (event: PointerEvent) => {
this.updateCursor()
}
)
return pointer_zone
}
async screenToCanvas(clientPoint: Point): Promise<Point> {
// Get the bounding rectangles for both elements
const zoomRatio = await this.messageBroker.pull('zoomRatio')
const canvasRect = this.maskCanvas.getBoundingClientRect()
// Calculate the offset between pointer zone and canvas
const offsetX = clientPoint.x - canvasRect.left + this.toolPanel.clientWidth
const offsetY = clientPoint.y - canvasRect.top + 44 // 44 is the height of the top menu
const x = offsetX / zoomRatio
const y = offsetY / zoomRatio
return { x: x, y: y }
}
private setEventHandler() {
this.maskCanvas.addEventListener('contextmenu', (event: Event) => {
event.preventDefault()
})
this.rootElement.addEventListener('contextmenu', (event: Event) => {
event.preventDefault()
})
this.rootElement.addEventListener('dragstart', (event) => {
if (event.ctrlKey) {
event.preventDefault()
}
})
}
private async createBrush() {
var brush = document.createElement('div')
const brushSettings = await this.messageBroker.pull('brushSettings')
brush.id = 'maskEditor_brush'
var brush_preview_gradient = document.createElement('div')
brush_preview_gradient.id = 'maskEditor_brushPreviewGradient'
brush.appendChild(brush_preview_gradient)
this.brush = brush
this.brushPreviewGradient = brush_preview_gradient
return brush
}
async setImages(imgCanvas: HTMLCanvasElement) {
const imgCtx = imgCanvas.getContext('2d', { willReadFrequently: true })
const maskCtx = this.maskCtx
const maskCanvas = this.maskCanvas
imgCtx!.clearRect(0, 0, this.imgCanvas.width, this.imgCanvas.height)
maskCtx.clearRect(0, 0, this.maskCanvas.width, this.maskCanvas.height)
const alpha_url = new URL(
ComfyApp.clipspace?.imgs?.[ComfyApp.clipspace?.selectedIndex ?? 0]?.src ??
''
)
alpha_url.searchParams.delete('channel')
alpha_url.searchParams.delete('preview')
alpha_url.searchParams.set('channel', 'a')
let mask_image: HTMLImageElement = await this.loadImage(alpha_url)
// original image load
if (
!ComfyApp.clipspace?.imgs?.[ComfyApp.clipspace?.selectedIndex ?? 0]?.src
) {
throw new Error(
'Unable to access image source - clipspace or image is null'
)
}
const rgb_url = new URL(
ComfyApp.clipspace.imgs[ComfyApp.clipspace.selectedIndex].src
)
this.imageURL = rgb_url
console.log(rgb_url)
rgb_url.searchParams.delete('channel')
rgb_url.searchParams.set('channel', 'rgb')
this.image = new Image()
this.image = await new Promise<HTMLImageElement>((resolve, reject) => {
const img = new Image()
img.onload = () => resolve(img)
img.onerror = reject
img.src = rgb_url.toString()
})
maskCanvas.width = this.image.width
maskCanvas.height = this.image.height
this.dimensionsTextHTML.innerText = `${this.image.width}x${this.image.height}`
await this.invalidateCanvas(this.image, mask_image)
this.messageBroker.publish('initZoomPan', [this.image, this.rootElement])
}
async invalidateCanvas(
orig_image: HTMLImageElement,
mask_image: HTMLImageElement
) {
this.imgCanvas.width = orig_image.width
this.imgCanvas.height = orig_image.height
this.maskCanvas.width = orig_image.width
this.maskCanvas.height = orig_image.height
let imgCtx = this.imgCanvas.getContext('2d', { willReadFrequently: true })
let maskCtx = this.maskCanvas.getContext('2d', {
willReadFrequently: true
})
imgCtx!.drawImage(orig_image, 0, 0, orig_image.width, orig_image.height)
await this.prepare_mask(
mask_image,
this.maskCanvas,
maskCtx!,
await this.getMaskColor()
)
}
private async prepare_mask(
image: HTMLImageElement,
maskCanvas: HTMLCanvasElement,
maskCtx: CanvasRenderingContext2D,
maskColor: { r: number; g: number; b: number }
) {
// paste mask data into alpha channel
maskCtx.drawImage(image, 0, 0, maskCanvas.width, maskCanvas.height)
const maskData = maskCtx.getImageData(
0,
0,
maskCanvas.width,
maskCanvas.height
)
// invert mask
for (let i = 0; i < maskData.data.length; i += 4) {
const alpha = maskData.data[i + 3]
maskData.data[i] = maskColor.r
maskData.data[i + 1] = maskColor.g
maskData.data[i + 2] = maskColor.b
maskData.data[i + 3] = 255 - alpha
}
maskCtx.globalCompositeOperation = 'source-over'
maskCtx.putImageData(maskData, 0, 0)
}
private async updateMaskColor() {
// update mask canvas css styles
const maskCanvasStyle = this.getMaskCanvasStyle()
this.maskCanvas.style.mixBlendMode = maskCanvasStyle.mixBlendMode
this.maskCanvas.style.opacity = maskCanvasStyle.opacity.toString()
// update mask canvas rgb colors
const maskColor = await this.getMaskColor()
this.maskCtx.fillStyle = `rgb(${maskColor.r}, ${maskColor.g}, ${maskColor.b})`
//set canvas background color
this.setCanvasBackground()
const maskData = this.maskCtx.getImageData(
0,
0,
this.maskCanvas.width,
this.maskCanvas.height
)
for (let i = 0; i < maskData.data.length; i += 4) {
maskData.data[i] = maskColor.r
maskData.data[i + 1] = maskColor.g
maskData.data[i + 2] = maskColor.b
}
this.maskCtx.putImageData(maskData, 0, 0)
}
getMaskCanvasStyle() {
if (this.maskBlendMode === MaskBlendMode.Negative) {
return {
mixBlendMode: 'difference',
opacity: '1'
}
} else {
return {
mixBlendMode: 'initial',
opacity: this.mask_opacity
}
}
}
private detectLightMode() {
this.darkMode = document.body.classList.contains('dark-theme')
}
private loadImage(imagePath: URL): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image() as HTMLImageElement
image.onload = function () {
resolve(image)
}
image.onerror = function (error) {
reject(error)
}
image.src = imagePath.href
})
}
async updateBrushPreview() {
const cursorPoint = await this.messageBroker.pull('cursorPoint')
const pan_offset = await this.messageBroker.pull('panOffset')
const brushSettings = await this.messageBroker.pull('brushSettings')
const zoom_ratio = await this.messageBroker.pull('zoomRatio')
const centerX = cursorPoint.x + pan_offset.x
const centerY = cursorPoint.y + pan_offset.y
const brush = this.brush
const hardness = brushSettings.hardness
const extendedSize = brushSettings.size * (2 - hardness) * 2 * zoom_ratio
this.brushSizeSlider.value = String(brushSettings.size)
this.brushHardnessSlider.value = String(hardness)
brush.style.width = extendedSize + 'px'
brush.style.height = extendedSize + 'px'
brush.style.left = centerX - extendedSize / 2 + 'px'
brush.style.top = centerY - extendedSize / 2 + 'px'
if (hardness === 1) {
this.brushPreviewGradient.style.background = 'rgba(255, 0, 0, 0.5)'
return
}
const opacityStop = hardness / 4 + 0.25
this.brushPreviewGradient.style.background = `
radial-gradient(
circle,
rgba(255, 0, 0, 0.5) 0%,
rgba(255, 0, 0, ${opacityStop}) ${hardness * 100}%,
rgba(255, 0, 0, 0) 100%
)
`
}
getMaskBlendMode() {
return this.maskBlendMode
}
setSidebarImage() {
this.sidebarImage.src = this.imageURL.href
}
async getMaskColor() {
if (this.maskBlendMode === MaskBlendMode.Black) {
return { r: 0, g: 0, b: 0 }
}
if (this.maskBlendMode === MaskBlendMode.White) {
return { r: 255, g: 255, b: 255 }
}
if (this.maskBlendMode === MaskBlendMode.Negative) {
// negative effect only works with white color
return { r: 255, g: 255, b: 255 }
}
return { r: 0, g: 0, b: 0 }
}
async getMaskFillStyle() {
const maskColor = await this.getMaskColor()
return 'rgb(' + maskColor.r + ',' + maskColor.g + ',' + maskColor.b + ')'
}
async setCanvasBackground() {
if (this.maskBlendMode === MaskBlendMode.White) {
this.canvasBackground.style.background = 'black'
} else {
this.canvasBackground.style.background = 'white'
}
}
getMaskCanvas() {
return this.maskCanvas
}
getImgCanvas() {
return this.imgCanvas
}
getImage() {
return this.image
}
setBrushOpacity(opacity: number) {
this.brush.style.opacity = String(opacity)
}
setSaveButtonEnabled(enabled: boolean) {
this.saveButton.disabled = !enabled
}
setSaveButtonText(text: string) {
this.saveButton.innerText = text
}
handlePaintBucketCursor(isPaintBucket: boolean) {
if (isPaintBucket) {
this.pointerZone.style.cursor =
"url('/cursor/paintBucket.png') 30 25, auto"
} else {
this.pointerZone.style.cursor = 'none'
}
}
handlePanCursor(isPanning: boolean) {
if (isPanning) {
this.pointerZone.style.cursor = 'grabbing'
} else {
this.pointerZone.style.cursor = 'none'
}
}
setBrushVisibility(visible: boolean) {
this.brush.style.opacity = visible ? '1' : '0'
}
setBrushPreviewGradientVisibility(visible: boolean) {
this.brushPreviewGradient.style.display = visible ? 'block' : 'none'
}
async updateCursor() {
const currentTool = await this.messageBroker.pull('currentTool')
if (currentTool === Tools.PaintBucket) {
this.pointerZone.style.cursor =
"url('/cursor/paintBucket.png') 30 25, auto"
this.setBrushOpacity(0)
} else if (currentTool === Tools.ColorSelect) {
this.pointerZone.style.cursor =
"url('/cursor/colorSelect.png') 15 25, auto"
this.setBrushOpacity(0)
} else {
this.pointerZone.style.cursor = 'none'
this.setBrushOpacity(1)
}
this.updateBrushPreview()
this.setBrushPreviewGradientVisibility(false)
}
setZoomText(zoomText: string) {
this.zoomTextHTML.innerText = zoomText
}
setDimensionsText(dimensionsText: string) {
this.dimensionsTextHTML.innerText = dimensionsText
}
}
class ToolManager {
maskEditor: MaskEditorDialog
messageBroker: MessageBroker
mouseDownPoint: Point | null = null
currentTool: Tools = Tools.Pen
isAdjustingBrush: boolean = false // is user adjusting brush size or hardness with alt + right mouse button
constructor(maskEditor: MaskEditorDialog) {
this.maskEditor = maskEditor
this.messageBroker = maskEditor.getMessageBroker()
this.addListeners()
this.addPullTopics()
}
private addListeners() {
this.messageBroker.subscribe('setTool', async (tool: Tools) => {
this.setTool(tool)
})
this.messageBroker.subscribe('pointerDown', async (event: PointerEvent) => {
this.handlePointerDown(event)
})
this.messageBroker.subscribe('pointerMove', async (event: PointerEvent) => {
this.handlePointerMove(event)
})
this.messageBroker.subscribe('pointerUp', async (event: PointerEvent) => {
this.handlePointerUp(event)
})
this.messageBroker.subscribe('wheel', async (event: WheelEvent) => {
this.handleWheelEvent(event)
})
}
private async addPullTopics() {
this.messageBroker.createPullTopic('currentTool', async () =>
this.getCurrentTool()
)
}
//tools
setTool(tool: Tools) {
this.currentTool = tool
if (tool != Tools.ColorSelect) {
this.messageBroker.publish('clearLastPoint')
}
}
getCurrentTool() {
return this.currentTool
}
private async handlePointerDown(event: PointerEvent) {
event.preventDefault()
if (event.pointerType == 'touch') return
var isSpacePressed = await this.messageBroker.pull('isKeyPressed', ' ')
// Pan canvas
if (event.buttons === 4 || (event.buttons === 1 && isSpacePressed)) {
this.messageBroker.publish('panStart', event)
this.messageBroker.publish('setBrushVisibility', false)
return
}
//paint bucket
if (this.currentTool === Tools.PaintBucket && event.button === 0) {
const offset = { x: event.offsetX, y: event.offsetY }
const coords_canvas = await this.messageBroker.pull(
'screenToCanvas',
offset
)
this.messageBroker.publish('paintBucketFill', coords_canvas)
this.messageBroker.publish('saveState')
return
}
if (this.currentTool === Tools.ColorSelect && event.button === 0) {
const offset = { x: event.offsetX, y: event.offsetY }
const coords_canvas = await this.messageBroker.pull(
'screenToCanvas',
offset
)
this.messageBroker.publish('colorSelectFill', coords_canvas)
return
}
// (brush resize/change hardness) Check for alt + right mouse button
if (event.altKey && event.button === 2) {
this.isAdjustingBrush = true
this.messageBroker.publish('brushAdjustmentStart', event)
return
}
var isDrawingTool = [Tools.Pen, Tools.Eraser].includes(this.currentTool)
//drawing
if ([0, 2].includes(event.button) && isDrawingTool) {
this.messageBroker.publish('drawStart', event)
return
}
}
private async handlePointerMove(event: PointerEvent) {
event.preventDefault()
if (event.pointerType == 'touch') return
const newCursorPoint = { x: event.clientX, y: event.clientY }
this.messageBroker.publish('cursorPoint', newCursorPoint)
var isSpacePressed = await this.messageBroker.pull('isKeyPressed', ' ')
this.messageBroker.publish('updateBrushPreview')
//move the canvas
if (event.buttons === 4 || (event.buttons === 1 && isSpacePressed)) {
this.messageBroker.publish('panMove', event)
return
}
//prevent drawing with other tools
var isDrawingTool = [Tools.Pen, Tools.Eraser].includes(this.currentTool)
if (!isDrawingTool) return
// alt + right mouse button hold brush adjustment
if (
this.isAdjustingBrush &&
(this.currentTool === Tools.Pen || this.currentTool === Tools.Eraser) &&
event.altKey &&
event.buttons === 2
) {
this.messageBroker.publish('brushAdjustment', event)
return
}
//draw with pen or eraser
if (event.buttons == 1 || event.buttons == 2) {
this.messageBroker.publish('draw', event)
return
}
}
private handlePointerUp(event: PointerEvent) {
this.messageBroker.publish('panCursor', false)
if (event.pointerType === 'touch') return
this.messageBroker.publish('updateCursor')
this.isAdjustingBrush = false
this.messageBroker.publish('drawEnd', event)
this.mouseDownPoint = null
}
private handleWheelEvent(event: WheelEvent) {
this.messageBroker.publish('zoom', event)
const newCursorPoint = { x: event.clientX, y: event.clientY }
this.messageBroker.publish('cursorPoint', newCursorPoint)
}
}
class PanAndZoomManager {
maskEditor: MaskEditorDialog
messageBroker: MessageBroker
DOUBLE_TAP_DELAY: number = 300
lastTwoFingerTap: number = 0
isTouchZooming: boolean = false
lastTouchZoomDistance: number = 0
lastTouchMidPoint: Point = { x: 0, y: 0 }
lastTouchPoint: Point = { x: 0, y: 0 }
zoom_ratio: number = 1
interpolatedZoomRatio: number = 1
pan_offset: Offset = { x: 0, y: 0 }
mouseDownPoint: Point | null = null
initialPan: Offset = { x: 0, y: 0 }
canvasContainer: HTMLElement | null = null
maskCanvas: HTMLCanvasElement | null = null
rootElement: HTMLElement | null = null
image: HTMLImageElement | null = null
imageRootWidth: number = 0
imageRootHeight: number = 0
cursorPoint: Point = { x: 0, y: 0 }
constructor(maskEditor: MaskEditorDialog) {
this.maskEditor = maskEditor
this.messageBroker = maskEditor.getMessageBroker()
this.addListeners()
this.addPullTopics()
}
private addListeners() {
this.messageBroker.subscribe(
'initZoomPan',
async (args: [HTMLImageElement, HTMLElement]) => {
await this.initializeCanvasPanZoom(args[0], args[1])
}
)
this.messageBroker.subscribe('panStart', async (event: PointerEvent) => {
this.handlePanStart(event)
})
this.messageBroker.subscribe('panMove', async (event: PointerEvent) => {
this.handlePanMove(event)
})
this.messageBroker.subscribe('zoom', async (event: WheelEvent) => {
this.zoom(event)
})
this.messageBroker.subscribe('cursorPoint', async (point: Point) => {
this.updateCursorPosition(point)
})
this.messageBroker.subscribe(
'handleTouchStart',
async (event: TouchEvent) => {
this.handleTouchStart(event)
}
)
this.messageBroker.subscribe(
'handleTouchMove',
async (event: TouchEvent) => {
this.handleTouchMove(event)
}
)
this.messageBroker.subscribe(
'handleTouchEnd',
async (event: TouchEvent) => {
this.handleTouchEnd(event)
}
)
this.messageBroker.subscribe('resetZoom', async () => {
if (this.interpolatedZoomRatio === 1) return
await this.smoothResetView()
})
}
private addPullTopics() {
this.messageBroker.createPullTopic(
'cursorPoint',
async () => this.cursorPoint
)
this.messageBroker.createPullTopic('zoomRatio', async () => this.zoom_ratio)
this.messageBroker.createPullTopic('panOffset', async () => this.pan_offset)
}
handleTouchStart(event: TouchEvent) {
event.preventDefault()
if ((event.touches[0] as any).touchType === 'stylus') return
this.messageBroker.publish('setBrushVisibility', false)
if (event.touches.length === 2) {
const currentTime = new Date().getTime()
const tapTimeDiff = currentTime - this.lastTwoFingerTap
if (tapTimeDiff < this.DOUBLE_TAP_DELAY) {
// Double tap detected
this.handleDoubleTap()
this.lastTwoFingerTap = 0 // Reset to prevent triple-tap
} else {
this.lastTwoFingerTap = currentTime
// Existing two-finger touch logic
this.isTouchZooming = true
this.lastTouchZoomDistance = this.getTouchDistance(event.touches)
const midpoint = this.getTouchMidpoint(event.touches)
this.lastTouchMidPoint = midpoint
}
} else if (event.touches.length === 1) {
this.lastTouchPoint = {
x: event.touches[0].clientX,
y: event.touches[0].clientY
}
}
}
async handleTouchMove(event: TouchEvent) {
event.preventDefault()
if ((event.touches[0] as any).touchType === 'stylus') return
this.lastTwoFingerTap = 0
if (this.isTouchZooming && event.touches.length === 2) {
// Handle zooming
const newDistance = this.getTouchDistance(event.touches)
const zoomFactor = newDistance / this.lastTouchZoomDistance
const oldZoom = this.zoom_ratio
this.zoom_ratio = Math.max(
0.2,
Math.min(10.0, this.zoom_ratio * zoomFactor)
)
const newZoom = this.zoom_ratio
// Calculate the midpoint of the two touches
const midpoint = this.getTouchMidpoint(event.touches)
// Handle panning - calculate the movement of the midpoint
if (this.lastTouchMidPoint) {
const deltaX = midpoint.x - this.lastTouchMidPoint.x
const deltaY = midpoint.y - this.lastTouchMidPoint.y
// Apply the pan
this.pan_offset.x += deltaX
this.pan_offset.y += deltaY
}
// Get touch position relative to the container
if (this.maskCanvas === null) {
this.maskCanvas = await this.messageBroker.pull('maskCanvas')
}
const rect = this.maskCanvas!.getBoundingClientRect()
const touchX = midpoint.x - rect.left
const touchY = midpoint.y - rect.top
// Calculate new pan position based on zoom
const scaleFactor = newZoom / oldZoom
this.pan_offset.x += touchX - touchX * scaleFactor
this.pan_offset.y += touchY - touchY * scaleFactor
this.invalidatePanZoom()
this.lastTouchZoomDistance = newDistance
this.lastTouchMidPoint = midpoint
} else if (event.touches.length === 1) {
// Handle single touch pan
this.handleSingleTouchPan(event.touches[0])
}
}
handleTouchEnd(event: TouchEvent) {
event.preventDefault()
if (
event.touches.length === 0 &&
(event.touches[0] as any).touchType === 'stylus'
) {
return
}
this.isTouchZooming = false
this.lastTouchMidPoint = { x: 0, y: 0 }
if (event.touches.length === 0) {
this.lastTouchPoint = { x: 0, y: 0 }
} else if (event.touches.length === 1) {
this.lastTouchPoint = {
x: event.touches[0].clientX,
y: event.touches[0].clientY
}
}
}
private getTouchDistance(touches: TouchList) {
const dx = touches[0].clientX - touches[1].clientX
const dy = touches[0].clientY - touches[1].clientY
return Math.sqrt(dx * dx + dy * dy)
}
private getTouchMidpoint(touches: TouchList) {
return {
x: (touches[0].clientX + touches[1].clientX) / 2,
y: (touches[0].clientY + touches[1].clientY) / 2
}
}
private async handleSingleTouchPan(touch: Touch) {
if (this.lastTouchPoint === null) {
this.lastTouchPoint = { x: touch.clientX, y: touch.clientY }
return
}
const deltaX = touch.clientX - this.lastTouchPoint.x
const deltaY = touch.clientY - this.lastTouchPoint.y
this.pan_offset.x += deltaX
this.pan_offset.y += deltaY
await this.invalidatePanZoom()
this.lastTouchPoint = { x: touch.clientX, y: touch.clientY }
}
private updateCursorPosition(clientPoint: Point) {
var cursorX = clientPoint.x - this.pan_offset.x
var cursorY = clientPoint.y - this.pan_offset.y
this.cursorPoint = { x: cursorX, y: cursorY }
}
//prob redundant
handleDoubleTap() {
this.messageBroker.publish('undo')
// Add any additional logic needed after undo
}
async zoom(event: WheelEvent) {
// Store original cursor position
const cursorPoint = { x: event.clientX, y: event.clientY }
// zoom canvas
const oldZoom = this.zoom_ratio
const zoomFactor = event.deltaY < 0 ? 1.1 : 0.9
this.zoom_ratio = Math.max(
0.2,
Math.min(10.0, this.zoom_ratio * zoomFactor)
)
const newZoom = this.zoom_ratio
const maskCanvas = await this.messageBroker.pull('maskCanvas')
// Get mouse position relative to the container
const rect = maskCanvas.getBoundingClientRect()
const mouseX = cursorPoint.x - rect.left
const mouseY = cursorPoint.y - rect.top
console.log(oldZoom, newZoom)
// Calculate new pan position
const scaleFactor = newZoom / oldZoom
this.pan_offset.x += mouseX - mouseX * scaleFactor
this.pan_offset.y += mouseY - mouseY * scaleFactor
// Update pan and zoom immediately
await this.invalidatePanZoom()
const newImageWidth = maskCanvas.clientWidth
const zoomRatio = newImageWidth / this.imageRootWidth
this.interpolatedZoomRatio = zoomRatio
this.messageBroker.publish('setZoomText', `${Math.round(zoomRatio * 100)}%`)
// Update cursor position with new pan values
this.updateCursorPosition(cursorPoint)
// Update brush preview after pan/zoom is complete
requestAnimationFrame(() => {
this.messageBroker.publish('updateBrushPreview')
})
}
private async smoothResetView(duration: number = 500) {
// Store initial state
const startZoom = this.zoom_ratio
const startPan = { ...this.pan_offset }
// Panel dimensions
const sidePanelWidth = 220
const toolPanelWidth = 64
const topBarHeight = 44
// Calculate available space
const availableWidth =
this.rootElement!.clientWidth - sidePanelWidth - toolPanelWidth
const availableHeight = this.rootElement!.clientHeight - topBarHeight
// Calculate target zoom
const zoomRatioWidth = availableWidth / this.image!.width
const zoomRatioHeight = availableHeight / this.image!.height
const targetZoom = Math.min(zoomRatioWidth, zoomRatioHeight)
// Calculate final dimensions
const aspectRatio = this.image!.width / this.image!.height
let finalWidth = 0
let finalHeight = 0
// Calculate target pan position
const targetPan = { x: toolPanelWidth, y: topBarHeight }
if (zoomRatioHeight > zoomRatioWidth) {
finalWidth = availableWidth
finalHeight = finalWidth / aspectRatio
targetPan.y = (availableHeight - finalHeight) / 2 + topBarHeight
} else {
finalHeight = availableHeight
finalWidth = finalHeight * aspectRatio
targetPan.x = (availableWidth - finalWidth) / 2 + toolPanelWidth
}
const startTime = performance.now()
const animate = (currentTime: number) => {
const elapsed = currentTime - startTime
const progress = Math.min(elapsed / duration, 1)
// Cubic easing out for smooth deceleration
const eased = 1 - Math.pow(1 - progress, 3)
// Calculate intermediate zoom and pan values
const currentZoom = startZoom + (targetZoom - startZoom) * eased
this.zoom_ratio = currentZoom
this.pan_offset.x = startPan.x + (targetPan.x - startPan.x) * eased
this.pan_offset.y = startPan.y + (targetPan.y - startPan.y) * eased
this.invalidatePanZoom()
const interpolatedZoomRatio = startZoom + (1.0 - startZoom) * eased
this.messageBroker.publish(
'setZoomText',
`${Math.round(interpolatedZoomRatio * 100)}%`
)
if (progress < 1) {
requestAnimationFrame(animate)
}
}
requestAnimationFrame(animate)
this.interpolatedZoomRatio = 1.0
}
async initializeCanvasPanZoom(
image: HTMLImageElement,
rootElement: HTMLElement
) {
// Get side panel width
let sidePanelWidth = 220
const toolPanelWidth = 64
let topBarHeight = 44
this.rootElement = rootElement
// Calculate available width accounting for both side panels
let availableWidth =
rootElement.clientWidth - sidePanelWidth - toolPanelWidth
let availableHeight = rootElement.clientHeight - topBarHeight
let zoomRatioWidth = availableWidth / image.width
let zoomRatioHeight = availableHeight / image.height
let aspectRatio = image.width / image.height
let finalWidth = 0
let finalHeight = 0
let pan_offset: Offset = { x: toolPanelWidth, y: topBarHeight }
if (zoomRatioHeight > zoomRatioWidth) {
finalWidth = availableWidth
finalHeight = finalWidth / aspectRatio
pan_offset.y = (availableHeight - finalHeight) / 2 + topBarHeight
} else {
finalHeight = availableHeight
finalWidth = finalHeight * aspectRatio
pan_offset.x = (availableWidth - finalWidth) / 2 + toolPanelWidth
}
if (this.image === null) {
this.image = image
}
this.imageRootWidth = finalWidth
this.imageRootHeight = finalHeight
this.zoom_ratio = Math.min(zoomRatioWidth, zoomRatioHeight)
this.pan_offset = pan_offset
await this.invalidatePanZoom()
}
async invalidatePanZoom() {
// Single validation check upfront
if (
!this.image?.width ||
!this.image?.height ||
!this.pan_offset ||
!this.zoom_ratio
) {
console.warn('Missing required properties for pan/zoom')
return
}
// Now TypeScript knows these are non-null
const raw_width = this.image.width * this.zoom_ratio
const raw_height = this.image.height * this.zoom_ratio
// Get canvas container
this.canvasContainer ??=
await this.messageBroker?.pull('getCanvasContainer')
if (!this.canvasContainer) return
// Apply styles
Object.assign(this.canvasContainer.style, {
width: `${raw_width}px`,
height: `${raw_height}px`,
left: `${this.pan_offset.x}px`,
top: `${this.pan_offset.y}px`
})
}
private handlePanStart(event: PointerEvent) {
let coords_canvas = this.messageBroker.pull('screenToCanvas', {
x: event.offsetX,
y: event.offsetY
})
this.mouseDownPoint = { x: event.clientX, y: event.clientY }
this.messageBroker.publish('panCursor', true)
this.initialPan = this.pan_offset
return
}
private handlePanMove(event: PointerEvent) {
if (this.mouseDownPoint === null) throw new Error('mouseDownPoint is null')
let deltaX = this.mouseDownPoint.x - event.clientX
let deltaY = this.mouseDownPoint.y - event.clientY
let pan_x = this.initialPan.x - deltaX
let pan_y = this.initialPan.y - deltaY
this.pan_offset = { x: pan_x, y: pan_y }
this.invalidatePanZoom()
}
}
class MessageBroker {
private pushTopics: Record<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('pointerDown')
this.createPushTopic('pointerMove')
this.createPushTopic('pointerUp')
this.createPushTopic('wheel')
this.createPushTopic('initPaintBucketTool')
this.createPushTopic('setBrushVisibility')
this.createPushTopic('setBrushPreviewGradientVisibility')
this.createPushTopic('handleTouchStart')
this.createPushTopic('handleTouchMove')
this.createPushTopic('handleTouchEnd')
this.createPushTopic('colorSelectFill')
this.createPushTopic('setColorSelectTolerance')
this.createPushTopic('setLivePreview')
this.createPushTopic('updateCursor')
this.createPushTopic('setColorComparisonMethod')
this.createPushTopic('clearLastPoint')
this.createPushTopic('setWholeImage')
this.createPushTopic('setMaskBoundary')
this.createPushTopic('setMaskTolerance')
this.createPushTopic('setBrushSmoothingPrecision')
this.createPushTopic('setZoomText')
this.createPushTopic('resetZoom')
this.createPushTopic('invert')
}
/**
* Creates a new push topic (listener is notified)
*
* @param {string} topicName - The name of the topic to create.
* @throws {Error} If the topic already exists.
*/
createPushTopic(topicName: string) {
if (this.topicExists(this.pushTopics, topicName)) {
throw new Error('Topic already exists')
}
this.pushTopics[topicName] = []
}
/**
* Subscribe a callback function to the given topic.
*
* @param {string} topicName - The name of the topic to subscribe to.
* @param {Callback} callback - The callback function to be subscribed.
* @throws {Error} If the topic does not exist.
*/
subscribe(topicName: string, callback: Callback) {
if (!this.topicExists(this.pushTopics, topicName)) {
throw new Error(`Topic "${topicName}" does not exist!`)
}
this.pushTopics[topicName].push(callback)
}
/**
* Removes a callback function from the list of subscribers for a given topic.
*
* @param {string} topicName - The name of the topic to unsubscribe from.
* @param {Callback} callback - The callback function to remove from the subscribers list.
* @throws {Error} If the topic does not exist in the list of topics.
*/
unsubscribe(topicName: string, callback: Callback) {
if (!this.topicExists(this.pushTopics, topicName)) {
throw new Error('Topic does not exist')
}
const index = this.pushTopics[topicName].indexOf(callback)
if (index > -1) {
this.pushTopics[topicName].splice(index, 1)
}
}
/**
* Publishes data to a specified topic with variable number of arguments.
* @param {string} topicName - The name of the topic to publish to.
* @param {...any[]} args - Variable number of arguments to pass to subscribers
* @throws {Error} If the specified topic does not exist.
*/
publish(topicName: string, ...args: any[]) {
if (!this.topicExists(this.pushTopics, topicName)) {
throw new Error(`Topic "${topicName}" does not exist!`)
}
this.pushTopics[topicName].forEach((callback) => {
callback(...args)
})
}
// Pull
/**
* Creates a new pull topic (listener must request data)
*
* @param {string} topicName - The name of the topic to create.
* @param {() => Promise<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)
}
}
class KeyboardManager {
private keysDown: string[] = []
private maskEditor: MaskEditorDialog
private messageBroker: MessageBroker
constructor(maskEditor: MaskEditorDialog) {
this.maskEditor = maskEditor
this.messageBroker = maskEditor.getMessageBroker()
this.addPullTopics()
}
private addPullTopics() {
// isKeyPressed
this.messageBroker.createPullTopic('isKeyPressed', (key: string) =>
Promise.resolve(this.isKeyDown(key))
)
}
addListeners() {
document.addEventListener('keydown', (event) => this.handleKeyDown(event))
document.addEventListener('keyup', (event) => this.handleKeyUp(event))
window.addEventListener('blur', () => this.clearKeys())
}
removeListeners() {
document.removeEventListener('keydown', (event) =>
this.handleKeyDown(event)
)
document.removeEventListener('keyup', (event) => this.handleKeyUp(event))
}
private clearKeys() {
this.keysDown = []
}
private handleKeyDown(event: KeyboardEvent) {
if (!this.keysDown.includes(event.key)) {
this.keysDown.push(event.key)
}
//if (this.redoCombinationPressed()) return
//this.undoCombinationPressed()
}
private handleKeyUp(event: KeyboardEvent) {
this.keysDown = this.keysDown.filter((key) => key !== event.key)
}
private isKeyDown(key: string) {
return this.keysDown.includes(key)
}
// combinations
private undoCombinationPressed() {
const combination = ['ctrl', 'z']
const keysDownLower = this.keysDown.map((key) => key.toLowerCase())
const result = combination.every((key) => keysDownLower.includes(key))
if (result) this.messageBroker.publish('undo')
return result
}
private redoCombinationPressed() {
const combination = ['ctrl', 'shift', 'z']
const keysDownLower = this.keysDown.map((key) => key.toLowerCase())
const result = combination.every((key) => keysDownLower.includes(key))
if (result) this.messageBroker.publish('redo')
return result
}
}
app.registerExtension({
name: 'Comfy.MaskEditor',
settings: [
{
id: 'Comfy.MaskEditor.UseNewEditor',
category: ['Mask Editor', 'NewEditor'],
name: 'Use new mask editor',
tooltip: 'Switch to the new mask editor interface',
type: 'boolean',
defaultValue: true,
experimental: true
},
{
id: 'Comfy.MaskEditor.BrushAdjustmentSpeed',
category: ['Mask Editor', 'BrushAdjustment', 'Sensitivity'],
name: 'Brush adjustment speed multiplier',
tooltip:
'Controls how quickly the brush size and hardness change when adjusting. Higher values mean faster changes.',
experimental: true,
type: 'slider',
attrs: {
min: 0.1,
max: 2.0,
step: 0.1
},
defaultValue: 1.0,
versionAdded: '1.0.0'
},
{
id: 'Comfy.MaskEditor.UseDominantAxis',
category: ['Mask Editor', 'BrushAdjustment', 'UseDominantAxis'],
name: 'Lock brush adjustment to dominant axis',
tooltip:
'When enabled, brush adjustments will only affect size OR hardness based on which direction you move more',
type: 'boolean',
defaultValue: true,
experimental: true
}
],
init(app) {
// Create function before assignment
function openMaskEditor(): void {
const useNewEditor = app.extensionManager.setting.get(
'Comfy.MaskEditor.UseNewEditor'
)
if (useNewEditor) {
const dlg = MaskEditorDialog.getInstance() as any
if (dlg?.isOpened && !dlg.isOpened()) {
dlg.show()
}
} else {
const dlg = MaskEditorDialogOld.getInstance() as any
if (dlg?.isOpened && !dlg.isOpened()) {
dlg.show()
}
}
}
// Assign the created function
;(ComfyApp as any).open_maskeditor = openMaskEditor
// Ensure boolean return type
const context_predicate = (): boolean => {
return !!(
ComfyApp.clipspace &&
ComfyApp.clipspace.imgs &&
ComfyApp.clipspace.imgs.length > 0
)
}
ClipspaceDialog.registerButton(
'MaskEditor',
context_predicate,
openMaskEditor
)
}
})