diff --git a/canvas_node.py b/canvas_node.py index 06f2926..f94c000 100644 --- a/canvas_node.py +++ b/canvas_node.py @@ -168,6 +168,7 @@ class CanvasNode: return { "required": { "fit_on_add": ("BOOLEAN", {"default": False, "label_on": "Fit on Add/Paste", "label_off": "Default Behavior"}), + "show_preview": ("BOOLEAN", {"default": False, "label_on": "Show Preview", "label_off": "Hide Preview"}), "trigger": ("INT", {"default": 0, "min": 0, "max": 99999999, "step": 1, "hidden": True}), "node_id": ("STRING", {"default": "0", "hidden": True}), }, @@ -231,7 +232,7 @@ class CanvasNode: _processing_lock = threading.Lock() - def process_canvas_image(self, fit_on_add, trigger, node_id, prompt=None, unique_id=None): + def process_canvas_image(self, fit_on_add, show_preview, trigger, node_id, prompt=None, unique_id=None): try: @@ -470,6 +471,70 @@ class CanvasNode: 'error': str(e) }, status=500) + @PromptServer.instance.routes.post("/ycnode/load_image_from_path") + async def load_image_from_path_route(request): + try: + data = await request.json() + file_path = data.get('file_path') + + if not file_path: + return web.json_response({ + 'success': False, + 'error': 'file_path is required' + }, status=400) + + log_info(f"Attempting to load image from path: {file_path}") + + # Check if file exists and is accessible + if not os.path.exists(file_path): + log_warn(f"File not found: {file_path}") + return web.json_response({ + 'success': False, + 'error': f'File not found: {file_path}' + }, status=404) + + # Check if it's an image file + valid_extensions = ('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.tiff', '.tif', '.ico', '.avif') + if not file_path.lower().endswith(valid_extensions): + return web.json_response({ + 'success': False, + 'error': f'Invalid image file extension. Supported: {valid_extensions}' + }, status=400) + + # Try to load and convert the image + try: + with Image.open(file_path) as img: + # Convert to RGB if necessary + if img.mode != 'RGB': + img = img.convert('RGB') + + # Convert to base64 + buffered = io.BytesIO() + img.save(buffered, format="PNG") + img_str = base64.b64encode(buffered.getvalue()).decode('utf-8') + + log_info(f"Successfully loaded image from path: {file_path}") + return web.json_response({ + 'success': True, + 'image_data': f"data:image/png;base64,{img_str}", + 'width': img.width, + 'height': img.height + }) + + except Exception as img_error: + log_error(f"Error processing image file {file_path}: {str(img_error)}") + return web.json_response({ + 'success': False, + 'error': f'Error processing image file: {str(img_error)}' + }, status=500) + + except Exception as e: + log_error(f"Error in load_image_from_path_route: {str(e)}") + return web.json_response({ + 'success': False, + 'error': str(e) + }, status=500) + def store_image(self, image_data): if isinstance(image_data, str) and image_data.startswith('data:image'): diff --git a/js/Canvas.js b/js/Canvas.js index ac66003..6f5129a 100644 --- a/js/Canvas.js +++ b/js/Canvas.js @@ -1,3 +1,5 @@ +import {app, ComfyApp} from "../../scripts/app.js"; +import {api} from "../../scripts/api.js"; import {removeImage} from "./db.js"; import {MaskTool} from "./MaskTool.js"; import {CanvasState} from "./CanvasState.js"; @@ -7,15 +9,24 @@ import {CanvasRenderer} from "./CanvasRenderer.js"; import {CanvasIO} from "./CanvasIO.js"; import {ImageReferenceManager} from "./ImageReferenceManager.js"; import {createModuleLogger} from "./utils/LoggerUtils.js"; +import {mask_editor_showing, mask_editor_listen_for_cancel} from "./utils/mask_utils.js"; const log = createModuleLogger('Canvas'); +/** + * Canvas - Fasada dla systemu rysowania + * + * Klasa Canvas pełni rolę fasady, oferując uproszczony interfejs wysokiego poziomu + * dla złożonego systemu rysowania. Zamiast eksponować wszystkie metody modułów, + * udostępnia tylko kluczowe operacje i umożliwia bezpośredni dostęp do modułów + * gdy potrzebna jest bardziej szczegółowa kontrola. + */ export class Canvas { constructor(node, widget, callbacks = {}) { this.node = node; this.widget = widget; this.canvas = document.createElement('canvas'); - this.ctx = this.canvas.getContext('2d'); + this.ctx = this.canvas.getContext('2d', { willReadFrequently: true }); this.width = 512; this.height = 512; this.layers = []; @@ -38,91 +49,370 @@ export class Canvas { this.dataInitialized = false; this.pendingDataCheck = null; + this.imageCache = new Map(); + + this._initializeModules(callbacks); + + this._setupCanvas(); + + this.interaction = this.canvasInteractions.interaction; + + log.debug('Canvas widget element:', this.node); + log.info('Canvas initialized', { + nodeId: this.node.id, + dimensions: { width: this.width, height: this.height }, + viewport: this.viewport + }); + + this.setPreviewVisibility(false); + } + + + async waitForWidget(name, node, interval = 100, timeout = 20000) { + const startTime = Date.now(); + + return new Promise((resolve, reject) => { + const check = () => { + const widget = node.widgets.find(w => w.name === name); + if (widget) { + resolve(widget); + } else if (Date.now() - startTime > timeout) { + reject(new Error(`Widget "${name}" not found within timeout.`)); + } else { + setTimeout(check, interval); + } + }; + + check(); + }); + } + + + /** + * Kontroluje widoczność podglądu canvas + * @param {boolean} visible - Czy podgląd ma być widoczny + */ + async setPreviewVisibility(visible) { + this.previewVisible = visible; + log.info("Canvas preview visibility set to:", visible); + + const imagePreviewWidget = await this.waitForWidget("$$canvas-image-preview", this.node); + if (imagePreviewWidget) { + log.debug("Found $$canvas-image-preview widget, controlling visibility"); + + if (visible) { + if (imagePreviewWidget.options) { + imagePreviewWidget.options.hidden = false; + } + if ('visible' in imagePreviewWidget) { + imagePreviewWidget.visible = true; + } + if ('hidden' in imagePreviewWidget) { + imagePreviewWidget.hidden = false; + } + imagePreviewWidget.computeSize = function () { + return [0, 250]; // Szerokość 0 (auto), wysokość 250 + }; + } else { + if (imagePreviewWidget.options) { + imagePreviewWidget.options.hidden = true; + } + if ('visible' in imagePreviewWidget) { + imagePreviewWidget.visible = false; + } + if ('hidden' in imagePreviewWidget) { + imagePreviewWidget.hidden = true; + } + + imagePreviewWidget.computeSize = function () { + return [0, 0]; // Szerokość 0, wysokość 0 + }; + } + this.render() + } else { + log.warn("$$canvas-image-preview widget not found in Canvas.js"); + } + } + + /** + * Inicjalizuje moduły systemu canvas + * @private + */ + _initializeModules(callbacks) { + log.debug('Initializing Canvas modules...'); + this.maskTool = new MaskTool(this, {onStateChange: this.onStateChange}); - this.initCanvas(); this.canvasState = new CanvasState(this); this.canvasInteractions = new CanvasInteractions(this); this.canvasLayers = new CanvasLayers(this); this.canvasRenderer = new CanvasRenderer(this); this.canvasIO = new CanvasIO(this); this.imageReferenceManager = new ImageReferenceManager(this); - this.interaction = this.canvasInteractions.interaction; - this.setupEventListeners(); - this.initNodeData(); + log.debug('Canvas modules initialized successfully'); + } + + /** + * Konfiguruje podstawowe właściwości canvas + * @private + */ + _setupCanvas() { + this.initCanvas(); + this.canvasInteractions.setupEventListeners(); + this.canvasIO.initNodeData(); this.layers = this.layers.map(layer => ({ ...layer, opacity: 1 })); - - this.imageCache = new Map(); } - async loadStateFromDB() { - return this.canvasState.loadStateFromDB(); - } - - async saveStateToDB(immediate = false) { - return this.canvasState.saveStateToDB(immediate); - } + /** + * Ładuje stan canvas z bazy danych + */ async loadInitialState() { log.info("Loading initial state for node:", this.node.id); - const loaded = await this.loadStateFromDB(); + const loaded = await this.canvasState.loadStateFromDB(); if (!loaded) { log.info("No saved state found, initializing from node data."); - await this.initNodeData(); + await this.canvasIO.initNodeData(); } this.saveState(); this.render(); } - _notifyStateChange() { - if (this.onStateChange) { - this.onStateChange(); - } - } - + /** + * Zapisuje obecny stan + * @param {boolean} replaceLast - Czy zastąpić ostatni stan w historii + */ saveState(replaceLast = false) { + log.debug('Saving canvas state', { replaceLast, layersCount: this.layers.length }); this.canvasState.saveState(replaceLast); this.incrementOperationCount(); this._notifyStateChange(); } + /** + * Cofnij ostatnią operację + */ undo() { + log.info('Performing undo operation'); + const historyInfo = this.canvasState.getHistoryInfo(); + log.debug('History state before undo:', historyInfo); + this.canvasState.undo(); this.incrementOperationCount(); this._notifyStateChange(); + + log.debug('Undo completed, layers count:', this.layers.length); } + + /** + * Ponów cofniętą operację + */ redo() { + log.info('Performing redo operation'); + const historyInfo = this.canvasState.getHistoryInfo(); + log.debug('History state before redo:', historyInfo); + this.canvasState.redo(); this.incrementOperationCount(); this._notifyStateChange(); + + log.debug('Redo completed, layers count:', this.layers.length); } - updateSelectionAfterHistory() { - const newSelectedLayers = []; - if (this.selectedLayers) { - this.selectedLayers.forEach(sl => { - const found = this.layers.find(l => l.id === sl.id); - if (found) newSelectedLayers.push(found); - }); - } - this.updateSelection(newSelectedLayers); + /** + * Renderuje canvas + */ + render() { + this.canvasRenderer.render(); } - updateHistoryButtons() { - if (this.onHistoryChange) { - const historyInfo = this.canvasState.getHistoryInfo(); - this.onHistoryChange({ - canUndo: historyInfo.canUndo, - canRedo: historyInfo.canRedo + /** + * Dodaje warstwę z obrazem + * @param {Image} image - Obraz do dodania + * @param {Object} layerProps - Właściwości warstwy + * @param {string} addMode - Tryb dodawania + */ + async addLayer(image, layerProps = {}, addMode = 'default') { + return this.canvasLayers.addLayerWithImage(image, layerProps, addMode); + } + + /** + * Usuwa wybrane warstwy + */ + removeSelectedLayers() { + if (this.selectedLayers.length > 0) { + log.info('Removing selected layers', { + layersToRemove: this.selectedLayers.length, + totalLayers: this.layers.length }); + + this.saveState(); + this.layers = this.layers.filter(l => !this.selectedLayers.includes(l)); + this.updateSelection([]); + this.render(); + this.saveState(); + + log.debug('Layers removed successfully, remaining layers:', this.layers.length); + } else { + log.debug('No layers selected for removal'); } } + /** + * Aktualizuje zaznaczenie warstw + * @param {Array} newSelection - Nowa lista zaznaczonych warstw + */ + updateSelection(newSelection) { + const previousSelection = this.selectedLayers.length; + this.selectedLayers = newSelection || []; + this.selectedLayer = this.selectedLayers.length > 0 ? this.selectedLayers[this.selectedLayers.length - 1] : null; + + log.debug('Selection updated', { + previousCount: previousSelection, + newCount: this.selectedLayers.length, + selectedLayerIds: this.selectedLayers.map(l => l.id || 'unknown') + }); + + if (this.onSelectionChange) { + this.onSelectionChange(); + } + } + + /** + * Zmienia rozmiar obszaru wyjściowego + * @param {number} width - Nowa szerokość + * @param {number} height - Nowa wysokość + * @param {boolean} saveHistory - Czy zapisać w historii + */ + updateOutputAreaSize(width, height, saveHistory = true) { + return this.canvasLayers.updateOutputAreaSize(width, height, saveHistory); + } + + /** + * Eksportuje spłaszczony canvas jako blob + */ + async getFlattenedCanvasAsBlob() { + return this.canvasLayers.getFlattenedCanvasAsBlob(); + } + + /** + * Eksportuje spłaszczony canvas z maską jako kanałem alpha + */ + async getFlattenedCanvasWithMaskAsBlob() { + return this.canvasLayers.getFlattenedCanvasWithMaskAsBlob(); + } + + /** + * Importuje najnowszy obraz + */ + async importLatestImage() { + return this.canvasIO.importLatestImage(); + } + + + /** + * Uruchamia edytor masek + * @param {Image|HTMLCanvasElement|null} predefinedMask - Opcjonalna maska do nałożenia po otwarciu editora + * @param {boolean} sendCleanImage - Czy wysłać czysty obraz (bez maski) do editora + */ + async startMaskEditor(predefinedMask = null, sendCleanImage = true) { + log.info('Starting mask editor', { + hasPredefinedMask: !!predefinedMask, + sendCleanImage, + layersCount: this.layers.length + }); + + this.savedMaskState = await this.saveMaskState(); + this.maskEditorCancelled = false; + + if (!predefinedMask && this.maskTool && this.maskTool.maskCanvas) { + try { + log.debug('Creating mask from current mask tool'); + predefinedMask = await this.createMaskFromCurrentMask(); + log.debug('Mask created from current mask tool successfully'); + } catch (error) { + log.warn("Could not create mask from current mask:", error); + } + } + + this.pendingMask = predefinedMask; + + let blob; + if (sendCleanImage) { + log.debug('Getting flattened canvas as blob (clean image)'); + blob = await this.canvasLayers.getFlattenedCanvasAsBlob(); + } else { + log.debug('Getting flattened canvas for mask editor (with mask)'); + blob = await this.canvasLayers.getFlattenedCanvasForMaskEditor(); + } + + if (!blob) { + log.warn("Canvas is empty, cannot open mask editor."); + return; + } + + log.debug('Canvas blob created successfully, size:', blob.size); + + try { + const formData = new FormData(); + const filename = `layerforge-mask-edit-${+new Date()}.png`; + formData.append("image", blob, filename); + formData.append("overwrite", "true"); + formData.append("type", "temp"); + + log.debug('Uploading image to server:', filename); + + const response = await api.fetchApi("/upload/image", { + method: "POST", + body: formData, + }); + + if (!response.ok) { + throw new Error(`Failed to upload image: ${response.statusText}`); + } + const data = await response.json(); + + log.debug('Image uploaded successfully:', data); + + const img = new Image(); + img.src = api.apiURL(`/view?filename=${encodeURIComponent(data.name)}&type=${data.type}&subfolder=${data.subfolder}`); + await new Promise((res, rej) => { + img.onload = res; + img.onerror = rej; + }); + + this.node.imgs = [img]; + + log.info('Opening ComfyUI mask editor'); + ComfyApp.copyToClipspace(this.node); + ComfyApp.clipspace_return_node = this.node; + ComfyApp.open_maskeditor(); + + this.editorWasShowing = false; + this.waitWhileMaskEditing(); + + this.setupCancelListener(); + + if (predefinedMask) { + log.debug('Will apply predefined mask when editor is ready'); + this.waitForMaskEditorAndApplyMask(); + } + + } catch (error) { + log.error("Error preparing image for mask editor:", error); + alert(`Error: ${error.message}`); + } + } + + + /** + * Inicjalizuje podstawowe właściwości canvas + */ initCanvas() { this.canvas.width = this.width; this.canvas.height = this.height; @@ -131,104 +421,14 @@ export class Canvas { this.canvas.style.backgroundColor = '#606060'; this.canvas.style.width = '100%'; this.canvas.style.height = '100%'; - - this.canvas.tabIndex = 0; this.canvas.style.outline = 'none'; } - setupEventListeners() { - this.canvasInteractions.setupEventListeners(); - } - - updateSelection(newSelection) { - this.selectedLayers = newSelection || []; - this.selectedLayer = this.selectedLayers.length > 0 ? this.selectedLayers[this.selectedLayers.length - 1] : null; - if (this.onSelectionChange) { - this.onSelectionChange(); - } - } - - async copySelectedLayers() { - return this.canvasLayers.copySelectedLayers(); - } - - pasteLayers() { - return this.canvasLayers.pasteLayers(); - } - - async handlePaste(addMode) { - return this.canvasLayers.handlePaste(addMode); - } - - - handleMouseMove(e) { - this.canvasInteractions.handleMouseMove(e); - } - - - handleMouseUp(e) { - this.canvasInteractions.handleMouseUp(e); - } - - - handleMouseLeave(e) { - this.canvasInteractions.handleMouseLeave(e); - } - - - handleWheel(e) { - this.canvasInteractions.handleWheel(e); - } - - handleKeyDown(e) { - this.canvasInteractions.handleKeyDown(e); - } - - handleKeyUp(e) { - this.canvasInteractions.handleKeyUp(e); - } - - - isRotationHandle(x, y) { - return this.canvasLayers.isRotationHandle(x, y); - } - - async addLayerWithImage(image, layerProps = {}, addMode = 'default') { - return this.canvasLayers.addLayerWithImage(image, layerProps, addMode); - } - - - async addLayer(image, addMode = 'default') { - return this.addLayerWithImage(image, {}, addMode); - } - - async removeLayer(index) { - if (index >= 0 && index < this.layers.length) { - const layer = this.layers[index]; - if (layer.imageId) { - const isImageUsedElsewhere = this.layers.some((l, i) => i !== index && l.imageId === layer.imageId); - if (!isImageUsedElsewhere) { - await removeImage(layer.imageId); - this.imageCache.delete(layer.imageId); - } - } - this.layers.splice(index, 1); - this.selectedLayer = this.layers[this.layers.length - 1] || null; - this.render(); - } - } - - removeSelectedLayers() { - if (this.selectedLayers.length > 0) { - this.saveState(); - this.layers = this.layers.filter(l => !this.selectedLayers.includes(l)); - this.updateSelection([]); - this.render(); - this.saveState(); - } - } - + /** + * Pobiera współrzędne myszy w układzie świata + * @param {MouseEvent} e - Zdarzenie myszy + */ getMouseWorldCoordinates(e) { const rect = this.canvas.getBoundingClientRect(); @@ -247,6 +447,10 @@ export class Canvas { return {x: worldX, y: worldY}; } + /** + * Pobiera współrzędne myszy w układzie widoku + * @param {MouseEvent} e - Zdarzenie myszy + */ getMouseViewCoordinates(e) { const rect = this.canvas.getBoundingClientRect(); const mouseX_DOM = e.clientX - rect.left; @@ -258,160 +462,38 @@ export class Canvas { const mouseX_Canvas = mouseX_DOM * scaleX; const mouseY_Canvas = mouseY_DOM * scaleY; - return { x: mouseX_Canvas, y: mouseY_Canvas }; - } - - - moveLayer(fromIndex, toIndex) { - return this.canvasLayers.moveLayer(fromIndex, toIndex); - } - - resizeLayer(scale) { - this.selectedLayers.forEach(layer => { - layer.width *= scale; - layer.height *= scale; - }); - this.render(); - this.saveState(); - } - - rotateLayer(angle) { - this.selectedLayers.forEach(layer => { - layer.rotation += angle; - }); - this.render(); - this.saveState(); - } - - updateOutputAreaSize(width, height, saveHistory = true) { - return this.canvasLayers.updateOutputAreaSize(width, height, saveHistory); - } - - render() { - this.canvasRenderer.render(); - } - - - getHandles(layer) { - return this.canvasLayers.getHandles(layer); - } - - getHandleAtPosition(worldX, worldY) { - return this.canvasLayers.getHandleAtPosition(worldX, worldY); - } - - - async getFlattenedCanvasAsBlob() { - return this.canvasLayers.getFlattenedCanvasAsBlob(); - } - - async getFlattenedSelectionAsBlob() { - return this.canvasLayers.getFlattenedSelectionAsBlob(); - } - - moveLayerUp() { - return this.canvasLayers.moveLayerUp(); - } - - moveLayerDown() { - return this.canvasLayers.moveLayerDown(); - } - - - getLayerAtPosition(worldX, worldY) { - return this.canvasLayers.getLayerAtPosition(worldX, worldY); - } - - getResizeHandle(x, y) { - return this.canvasLayers.getResizeHandle(x, y); - } - - async mirrorHorizontal() { - return this.canvasLayers.mirrorHorizontal(); - } - - async mirrorVertical() { - return this.canvasLayers.mirrorVertical(); - } - - async getLayerImageData(layer) { - return this.canvasLayers.getLayerImageData(layer); - } - - addMattedLayer(image, mask) { - return this.canvasLayers.addMattedLayer(image, mask); - } - - async addInputToCanvas(inputImage, inputMask) { - return this.canvasIO.addInputToCanvas(inputImage, inputMask); - } - - async convertTensorToImage(tensor) { - return this.canvasIO.convertTensorToImage(tensor); - } - - async convertTensorToMask(tensor) { - return this.canvasIO.convertTensorToMask(tensor); - } - - async initNodeData() { - return this.canvasIO.initNodeData(); - } - - scheduleDataCheck() { - return this.canvasIO.scheduleDataCheck(); - } - - async processImageData(imageData) { - return this.canvasIO.processImageData(imageData); - } - - addScaledLayer(image, scale) { - return this.canvasIO.addScaledLayer(image, scale); - } - - convertTensorToImageData(tensor) { - return this.canvasIO.convertTensorToImageData(tensor); - } - - async createImageFromData(imageData) { - return this.canvasIO.createImageFromData(imageData); - } - - async retryDataLoad(maxRetries = 3, delay = 1000) { - return this.canvasIO.retryDataLoad(maxRetries, delay); - } - - async processMaskData(maskData) { - return this.canvasIO.processMaskData(maskData); - } - - async loadImageFromCache(base64Data) { - return this.canvasIO.loadImageFromCache(base64Data); - } - - async importImage(cacheData) { - return this.canvasIO.importImage(cacheData); - } - - async importLatestImage() { - return this.canvasIO.importLatestImage(); - } - - showBlendModeMenu(x, y) { - return this.canvasLayers.showBlendModeMenu(x, y); - } - - handleBlendModeSelection(mode) { - return this.canvasLayers.handleBlendModeSelection(mode); - } - - showOpacitySlider(mode) { - return this.canvasLayers.showOpacitySlider(mode); + return {x: mouseX_Canvas, y: mouseY_Canvas}; } /** - * Zwiększa licznik operacji (wywoływane przy każdej operacji na canvas) + * Aktualizuje zaznaczenie po operacji historii + */ + updateSelectionAfterHistory() { + const newSelectedLayers = []; + if (this.selectedLayers) { + this.selectedLayers.forEach(sl => { + const found = this.layers.find(l => l.id === sl.id); + if (found) newSelectedLayers.push(found); + }); + } + this.updateSelection(newSelectedLayers); + } + + /** + * Aktualizuje przyciski historii + */ + updateHistoryButtons() { + if (this.onHistoryChange) { + const historyInfo = this.canvasState.getHistoryInfo(); + this.onHistoryChange({ + canUndo: historyInfo.canUndo, + canRedo: historyInfo.canRedo + }); + } + } + + /** + * Zwiększa licznik operacji (dla garbage collection) */ incrementOperationCount() { if (this.imageReferenceManager) { @@ -420,40 +502,7 @@ export class Canvas { } /** - * Ręczne uruchomienie garbage collection - */ - async runGarbageCollection() { - if (this.imageReferenceManager) { - await this.imageReferenceManager.manualGarbageCollection(); - } - } - - /** - * Zwraca statystyki garbage collection - */ - getGarbageCollectionStats() { - if (this.imageReferenceManager) { - const stats = this.imageReferenceManager.getStats(); - return { - ...stats, - operationCount: this.imageReferenceManager.operationCount, - operationThreshold: this.imageReferenceManager.operationThreshold - }; - } - return null; - } - - /** - * Ustawia próg operacji dla automatycznego GC - */ - setGarbageCollectionThreshold(threshold) { - if (this.imageReferenceManager) { - this.imageReferenceManager.setOperationThreshold(threshold); - } - } - - /** - * Czyści zasoby canvas (wywoływane przy usuwaniu) + * Czyści zasoby canvas */ destroy() { if (this.imageReferenceManager) { @@ -461,4 +510,443 @@ export class Canvas { } log.info("Canvas destroyed"); } + + /** + * Powiadamia o zmianie stanu + * @private + */ + _notifyStateChange() { + if (this.onStateChange) { + this.onStateChange(); + } + } + + + /** + * Czeka na otwarcie mask editora i automatycznie nakłada predefiniowaną maskę + */ + waitForMaskEditorAndApplyMask() { + let attempts = 0; + const maxAttempts = 100; // Zwiększone do 10 sekund oczekiwania + + const checkEditor = () => { + attempts++; + + if (mask_editor_showing(app)) { + + const useNewEditor = app.ui.settings.getSettingValue('Comfy.MaskEditor.UseNewEditor'); + let editorReady = false; + + if (useNewEditor) { + + const MaskEditorDialog = window.MaskEditorDialog; + if (MaskEditorDialog && MaskEditorDialog.instance) { + + try { + const messageBroker = MaskEditorDialog.instance.getMessageBroker(); + if (messageBroker) { + editorReady = true; + log.info("New mask editor detected as ready via MessageBroker"); + } + } catch (e) { + + editorReady = false; + } + } + + if (!editorReady) { + const maskEditorElement = document.getElementById('maskEditor'); + if (maskEditorElement && maskEditorElement.style.display !== 'none') { + + const canvas = maskEditorElement.querySelector('canvas'); + if (canvas) { + editorReady = true; + log.info("New mask editor detected as ready via DOM element"); + } + } + } + } else { + + const maskCanvas = document.getElementById('maskCanvas'); + editorReady = maskCanvas && maskCanvas.getContext && maskCanvas.width > 0; + if (editorReady) { + log.info("Old mask editor detected as ready"); + } + } + + if (editorReady) { + + log.info("Applying mask to editor after", attempts * 100, "ms wait"); + setTimeout(() => { + this.applyMaskToEditor(this.pendingMask); + this.pendingMask = null; + }, 300); + } else if (attempts < maxAttempts) { + + if (attempts % 10 === 0) { + log.info("Waiting for mask editor to be ready... attempt", attempts, "/", maxAttempts); + } + setTimeout(checkEditor, 100); + } else { + log.warn("Mask editor timeout - editor not ready after", maxAttempts * 100, "ms"); + + log.info("Attempting to apply mask anyway..."); + setTimeout(() => { + this.applyMaskToEditor(this.pendingMask); + this.pendingMask = null; + }, 100); + } + } else if (attempts < maxAttempts) { + + setTimeout(checkEditor, 100); + } else { + log.warn("Mask editor timeout - editor not showing after", maxAttempts * 100, "ms"); + this.pendingMask = null; + } + }; + + checkEditor(); + } + + /** + * Nakłada maskę na otwarty mask editor + * @param {Image|HTMLCanvasElement} maskData - Dane maski do nałożenia + */ + async applyMaskToEditor(maskData) { + try { + + const useNewEditor = app.ui.settings.getSettingValue('Comfy.MaskEditor.UseNewEditor'); + + if (useNewEditor) { + + const MaskEditorDialog = window.MaskEditorDialog; + if (MaskEditorDialog && MaskEditorDialog.instance) { + + await this.applyMaskToNewEditor(maskData); + } else { + log.warn("New editor setting enabled but instance not found, trying old editor"); + await this.applyMaskToOldEditor(maskData); + } + } else { + + await this.applyMaskToOldEditor(maskData); + } + + log.info("Predefined mask applied to mask editor successfully"); + } catch (error) { + log.error("Failed to apply predefined mask to editor:", error); + + try { + log.info("Trying alternative mask application method..."); + await this.applyMaskToOldEditor(maskData); + log.info("Alternative method succeeded"); + } catch (fallbackError) { + log.error("Alternative method also failed:", fallbackError); + } + } + } + + /** + * Nakłada maskę na nowy mask editor (przez MessageBroker) + * @param {Image|HTMLCanvasElement} maskData - Dane maski + */ + async applyMaskToNewEditor(maskData) { + + const MaskEditorDialog = window.MaskEditorDialog; + if (!MaskEditorDialog || !MaskEditorDialog.instance) { + throw new Error("New mask editor instance not found"); + } + + const editor = MaskEditorDialog.instance; + const messageBroker = editor.getMessageBroker(); + + const maskCanvas = await messageBroker.pull('maskCanvas'); + const maskCtx = await messageBroker.pull('maskCtx'); + const maskColor = await messageBroker.pull('getMaskColor'); + + const processedMask = await this.processMaskForEditor(maskData, maskCanvas.width, maskCanvas.height, maskColor); + + maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height); + maskCtx.drawImage(processedMask, 0, 0); + + messageBroker.publish('saveState'); + } + + /** + * Nakłada maskę na stary mask editor + * @param {Image|HTMLCanvasElement} maskData - Dane maski + */ + async applyMaskToOldEditor(maskData) { + + const maskCanvas = document.getElementById('maskCanvas'); + if (!maskCanvas) { + throw new Error("Old mask editor canvas not found"); + } + + const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true }); + + const maskColor = {r: 255, g: 255, b: 255}; + const processedMask = await this.processMaskForEditor(maskData, maskCanvas.width, maskCanvas.height, maskColor); + + maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height); + maskCtx.drawImage(processedMask, 0, 0); + } + + /** + * Przetwarza maskę do odpowiedniego formatu dla editora + * @param {Image|HTMLCanvasElement} maskData - Oryginalne dane maski + * @param {number} targetWidth - Docelowa szerokość + * @param {number} targetHeight - Docelowa wysokość + * @param {Object} maskColor - Kolor maski {r, g, b} + * @returns {HTMLCanvasElement} Przetworzona maska + */ + async processMaskForEditor(maskData, targetWidth, targetHeight, maskColor) { + const originalWidth = maskData.width || maskData.naturalWidth || this.width; + const originalHeight = maskData.height || maskData.naturalHeight || this.height; + + log.info("Processing mask for editor:", { + originalSize: {width: originalWidth, height: originalHeight}, + targetSize: {width: targetWidth, height: targetHeight}, + canvasSize: {width: this.width, height: this.height} + }); + + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = targetWidth; + tempCanvas.height = targetHeight; + const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); + + tempCtx.clearRect(0, 0, targetWidth, targetHeight); + + + const scaleToOriginal = Math.min(originalWidth / this.width, originalHeight / this.height); + + const scaledWidth = this.width * scaleToOriginal; + const scaledHeight = this.height * scaleToOriginal; + + const offsetX = (targetWidth - scaledWidth) / 2; + const offsetY = (targetHeight - scaledHeight) / 2; + + tempCtx.drawImage(maskData, offsetX, offsetY, scaledWidth, scaledHeight); + + log.info("Mask drawn scaled to original image size:", { + originalSize: {width: originalWidth, height: originalHeight}, + targetSize: {width: targetWidth, height: targetHeight}, + canvasSize: {width: this.width, height: this.height}, + scaleToOriginal: scaleToOriginal, + finalSize: {width: scaledWidth, height: scaledHeight}, + offset: {x: offsetX, y: offsetY} + }); + + const imageData = tempCtx.getImageData(0, 0, targetWidth, targetHeight); + const data = imageData.data; + + for (let i = 0; i < data.length; i += 4) { + const alpha = data[i + 3]; // Oryginalny kanał alpha + + data[i] = maskColor.r; // R + data[i + 1] = maskColor.g; // G + data[i + 2] = maskColor.b; // B + data[i + 3] = alpha; // Zachowaj oryginalny alpha + } + + tempCtx.putImageData(imageData, 0, 0); + + log.info("Mask processing completed - full size scaling applied"); + return tempCanvas; + } + + /** + * Tworzy obiekt Image z obecnej maski canvas + * @returns {Promise} Promise zwracający obiekt Image z maską + */ + async createMaskFromCurrentMask() { + if (!this.maskTool || !this.maskTool.maskCanvas) { + throw new Error("No mask canvas available"); + } + + return new Promise((resolve, reject) => { + const maskImage = new Image(); + maskImage.onload = () => resolve(maskImage); + maskImage.onerror = reject; + maskImage.src = this.maskTool.maskCanvas.toDataURL(); + }); + } + + waitWhileMaskEditing() { + if (mask_editor_showing(app)) { + this.editorWasShowing = true; + } + + if (!mask_editor_showing(app) && this.editorWasShowing) { + this.editorWasShowing = false; + setTimeout(() => this.handleMaskEditorClose(), 100); + } else { + setTimeout(this.waitWhileMaskEditing.bind(this), 100); + } + } + /** + * Zapisuje obecny stan maski przed otwarciem editora + * @returns {Object} Zapisany stan maski + */ + async saveMaskState() { + if (!this.maskTool || !this.maskTool.maskCanvas) { + return null; + } + + const maskCanvas = this.maskTool.maskCanvas; + const savedCanvas = document.createElement('canvas'); + savedCanvas.width = maskCanvas.width; + savedCanvas.height = maskCanvas.height; + const savedCtx = savedCanvas.getContext('2d', { willReadFrequently: true }); + savedCtx.drawImage(maskCanvas, 0, 0); + + return { + maskData: savedCanvas, + maskPosition: { + x: this.maskTool.x, + y: this.maskTool.y + } + }; + } + + /** + * Przywraca zapisany stan maski + * @param {Object} savedState - Zapisany stan maski + */ + async restoreMaskState(savedState) { + if (!savedState || !this.maskTool) { + return; + } + + if (savedState.maskData) { + const maskCtx = this.maskTool.maskCtx; + maskCtx.clearRect(0, 0, this.maskTool.maskCanvas.width, this.maskTool.maskCanvas.height); + maskCtx.drawImage(savedState.maskData, 0, 0); + } + + if (savedState.maskPosition) { + this.maskTool.x = savedState.maskPosition.x; + this.maskTool.y = savedState.maskPosition.y; + } + + this.render(); + log.info("Mask state restored after cancel"); + } + + /** + * Konfiguruje nasłuchiwanie na przycisk Cancel w mask editorze + */ + setupCancelListener() { + mask_editor_listen_for_cancel(app, () => { + log.info("Mask editor cancel button clicked"); + this.maskEditorCancelled = true; + }); + } + + /** + * Sprawdza czy mask editor został anulowany i obsługuje to odpowiednio + */ + async handleMaskEditorClose() { + log.info("Handling mask editor close"); + log.debug("Node object after mask editor close:", this.node); + + if (this.maskEditorCancelled) { + log.info("Mask editor was cancelled - restoring original mask state"); + + if (this.savedMaskState) { + await this.restoreMaskState(this.savedMaskState); + } + + this.maskEditorCancelled = false; + this.savedMaskState = null; + + return; + } + + if (!this.node.imgs || !this.node.imgs.length === 0 || !this.node.imgs[0].src) { + log.warn("Mask editor was closed without a result."); + return; + } + + log.debug("Processing mask editor result, image source:", this.node.imgs[0].src.substring(0, 100) + '...'); + + const resultImage = new Image(); + resultImage.src = this.node.imgs[0].src; + + try { + await new Promise((resolve, reject) => { + resultImage.onload = resolve; + resultImage.onerror = reject; + }); + + log.debug("Result image loaded successfully", { + width: resultImage.width, + height: resultImage.height + }); + } catch (error) { + log.error("Failed to load image from mask editor.", error); + this.node.imgs = []; + return; + } + + log.debug("Creating temporary canvas for mask processing"); + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = this.width; + tempCanvas.height = this.height; + const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); + + tempCtx.drawImage(resultImage, 0, 0, this.width, this.height); + + log.debug("Processing image data to create mask"); + const imageData = tempCtx.getImageData(0, 0, this.width, this.height); + const data = imageData.data; + + for (let i = 0; i < data.length; i += 4) { + const originalAlpha = data[i + 3]; + data[i] = 255; + data[i + 1] = 255; + data[i + 2] = 255; + data[i + 3] = 255 - originalAlpha; + } + + tempCtx.putImageData(imageData, 0, 0); + + log.debug("Converting processed mask to image"); + const maskAsImage = new Image(); + maskAsImage.src = tempCanvas.toDataURL(); + await new Promise(resolve => maskAsImage.onload = resolve); + + const maskCtx = this.maskTool.maskCtx; + const destX = -this.maskTool.x; + const destY = -this.maskTool.y; + + log.debug("Applying mask to canvas", { destX, destY }); + + maskCtx.globalCompositeOperation = 'source-over'; + maskCtx.clearRect(destX, destY, this.width, this.height); + + maskCtx.drawImage(maskAsImage, destX, destY); + + this.render(); + this.saveState(); + + log.debug("Creating new preview image"); + const new_preview = new Image(); + + const blob = await this.canvasLayers.getFlattenedCanvasWithMaskAsBlob(); + if (blob) { + new_preview.src = URL.createObjectURL(blob); + await new Promise(r => new_preview.onload = r); + this.node.imgs = [new_preview]; + log.debug("New preview image created successfully"); + } else { + this.node.imgs = []; + log.warn("Failed to create preview blob"); + } + + this.render(); + + this.savedMaskState = null; + log.info("Mask editor result processed successfully"); + } } diff --git a/js/CanvasIO.js b/js/CanvasIO.js index d457037..7aaa828 100644 --- a/js/CanvasIO.js +++ b/js/CanvasIO.js @@ -46,7 +46,7 @@ export class CanvasIO { log.warn(`Node ${this.canvas.node.id} has no layers, creating empty canvas`); return Promise.resolve(true); } - await this.canvas.saveStateToDB(true); + await this.canvas.canvasState.saveStateToDB(true); const nodeId = this.canvas.node.id; const delay = (nodeId % 10) * 50; if (delay > 0) { @@ -102,7 +102,7 @@ export class CanvasIO { const tempMaskCanvas = document.createElement('canvas'); tempMaskCanvas.width = this.canvas.width; tempMaskCanvas.height = this.canvas.height; - const tempMaskCtx = tempMaskCanvas.getContext('2d'); + const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true }); tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height); @@ -279,7 +279,7 @@ export class CanvasIO { const tempMaskCanvas = document.createElement('canvas'); tempMaskCanvas.width = this.canvas.width; tempMaskCanvas.height = this.canvas.height; - const tempMaskCtx = tempMaskCanvas.getContext('2d'); + const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true }); tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height); @@ -374,7 +374,7 @@ export class CanvasIO { this.canvas.height / inputImage.height * 0.8 ); - const layer = await this.canvas.addLayerWithImage(image, { + const layer = await this.canvas.canvasLayers.addLayerWithImage(image, { x: (this.canvas.width - inputImage.width * scale) / 2, y: (this.canvas.height - inputImage.height * scale) / 2, width: inputImage.width * scale, @@ -403,7 +403,7 @@ export class CanvasIO { } const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d'); + const ctx = canvas.getContext('2d', { willReadFrequently: true }); canvas.width = tensor.width; canvas.height = tensor.height; @@ -611,7 +611,7 @@ export class CanvasIO { const canvas = document.createElement('canvas'); canvas.width = imageData.width; canvas.height = imageData.height; - const ctx = canvas.getContext('2d'); + const ctx = canvas.getContext('2d', { willReadFrequently: true }); ctx.putImageData(imageData, 0, 0); const img = new Image(); @@ -684,7 +684,7 @@ export class CanvasIO { const tempCanvas = document.createElement('canvas'); tempCanvas.width = img.width; tempCanvas.height = img.height; - const tempCtx = tempCanvas.getContext('2d'); + const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); tempCtx.drawImage(img, 0, 0); @@ -693,7 +693,7 @@ export class CanvasIO { const maskCanvas = document.createElement('canvas'); maskCanvas.width = img.width; maskCanvas.height = img.height; - const maskCtx = maskCanvas.getContext('2d'); + const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true }); maskCtx.drawImage(mask, 0, 0); const maskData = maskCtx.getImageData(0, 0, img.width, img.height); @@ -744,7 +744,7 @@ export class CanvasIO { img.src = result.image_data; }); - await this.canvas.addLayerWithImage(img, { + await this.canvas.canvasLayers.addLayerWithImage(img, { x: 0, y: 0, width: this.canvas.width, diff --git a/js/CanvasInteractions.js b/js/CanvasInteractions.js index 0e8066b..f45c0c4 100644 --- a/js/CanvasInteractions.js +++ b/js/CanvasInteractions.js @@ -34,6 +34,8 @@ export class CanvasInteractions { this.canvas.canvas.addEventListener('keydown', this.handleKeyDown.bind(this)); this.canvas.canvas.addEventListener('keyup', this.handleKeyUp.bind(this)); + document.addEventListener('paste', this.handlePasteEvent.bind(this)); + this.canvas.canvas.addEventListener('mouseenter', (e) => { this.canvas.isMouseOver = true; this.handleMouseEnter(e); @@ -42,6 +44,13 @@ export class CanvasInteractions { this.canvas.isMouseOver = false; this.handleMouseLeave(e); }); + + this.canvas.canvas.addEventListener('dragover', this.handleDragOver.bind(this)); + this.canvas.canvas.addEventListener('dragenter', this.handleDragEnter.bind(this)); + this.canvas.canvas.addEventListener('dragleave', this.handleDragLeave.bind(this)); + this.canvas.canvas.addEventListener('drop', this.handleDrop.bind(this)); + + this.canvas.canvas.addEventListener('contextmenu', this.handleContextMenu.bind(this)); } resetInteractionState() { @@ -86,26 +95,34 @@ export class CanvasInteractions { } this.interaction.lastClickTime = currentTime; - const transformTarget = this.canvas.getHandleAtPosition(worldCoords.x, worldCoords.y); + if (e.button === 2) { + const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y); + if (clickedLayerResult && this.canvas.selectedLayers.includes(clickedLayerResult.layer)) { + e.preventDefault(); // Prevent context menu + this.canvas.canvasLayers.showBlendModeMenu(viewCoords.x ,viewCoords.y); + return; + } + } + + if (e.shiftKey) { + this.startCanvasResize(worldCoords); + this.canvas.render(); + return; + } + + const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y); if (transformTarget) { this.startLayerTransform(transformTarget.layer, transformTarget.handle, worldCoords); return; } - const clickedLayerResult = this.canvas.getLayerAtPosition(worldCoords.x, worldCoords.y); + const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y); if (clickedLayerResult) { - if (e.shiftKey && this.canvas.selectedLayers.includes(clickedLayerResult.layer)) { - this.canvas.showBlendModeMenu(e.clientX, e.clientY); - return; - } this.startLayerDrag(clickedLayerResult.layer, worldCoords); return; } - if (e.shiftKey) { - this.startCanvasResize(worldCoords); - } else { - this.startPanning(e); - } + + this.startPanning(e); this.canvas.render(); } @@ -176,7 +193,7 @@ export class CanvasInteractions { if (interactionEnded) { this.canvas.saveState(); - this.canvas.saveStateToDB(true); + this.canvas.canvasState.saveStateToDB(true); } } @@ -194,6 +211,11 @@ export class CanvasInteractions { this.resetInteractionState(); this.canvas.render(); } + + if (this.canvas.canvasLayers.internalClipboard.length > 0) { + this.canvas.canvasLayers.internalClipboard = []; + log.info("Internal clipboard cleared - mouse left canvas"); + } } handleMouseEnter(e) { @@ -202,6 +224,11 @@ export class CanvasInteractions { } } + handleContextMenu(e) { + + e.preventDefault(); + } + handleWheel(e) { e.preventDefault(); if (this.canvas.maskTool.isActive) { @@ -297,16 +324,16 @@ export class CanvasInteractions { e.preventDefault(); e.stopPropagation(); if (e.shiftKey) { - this.canvas.redo(); + this.canvas.canvasState.redo(); } else { - this.canvas.undo(); + this.canvas.canvasState.undo(); } return; } if (e.key.toLowerCase() === 'y') { e.preventDefault(); e.stopPropagation(); - this.canvas.redo(); + this.canvas.canvasState.redo(); return; } } @@ -324,30 +351,27 @@ export class CanvasInteractions { e.preventDefault(); e.stopPropagation(); if (e.shiftKey) { - this.canvas.redo(); + this.canvas.canvasState.redo(); } else { - this.canvas.undo(); + this.canvas.canvasState.undo(); } return; } if (e.key.toLowerCase() === 'y') { e.preventDefault(); e.stopPropagation(); - this.canvas.redo(); + this.canvas.canvasState.redo(); return; } if (e.key.toLowerCase() === 'c') { if (this.canvas.selectedLayers.length > 0) { - e.preventDefault(); - e.stopPropagation(); - this.canvas.copySelectedLayers(); + this.canvas.canvasLayers.copySelectedLayers(); } return; } if (e.key.toLowerCase() === 'v') { - e.preventDefault(); - e.stopPropagation(); - this.canvas.handlePaste('mouse'); + + return; } } @@ -399,7 +423,7 @@ export class CanvasInteractions { } updateCursor(worldCoords) { - const transformTarget = this.canvas.getHandleAtPosition(worldCoords.x, worldCoords.y); + const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y); if (transformTarget) { const handleName = transformTarget.handle; @@ -409,7 +433,7 @@ export class CanvasInteractions { 'rot': 'grab' }; this.canvas.canvas.style.cursor = cursorMap[handleName]; - } else if (this.canvas.getLayerAtPosition(worldCoords.x, worldCoords.y)) { + } else if (this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y)) { this.canvas.canvas.style.cursor = 'move'; } else { this.canvas.canvas.style.cursor = 'default'; @@ -432,7 +456,7 @@ export class CanvasInteractions { } else { this.interaction.mode = 'resizing'; this.interaction.resizeHandle = handle; - const handles = this.canvas.getHandles(layer); + const handles = this.canvas.canvasLayers.getHandles(layer); const oppositeHandleKey = { 'n': 's', 's': 'n', 'e': 'w', 'w': 'e', 'nw': 'se', 'se': 'nw', 'ne': 'sw', 'sw': 'ne' @@ -712,4 +736,130 @@ export class CanvasInteractions { this.canvas.viewport.y -= rectY; } } + + handleDragOver(e) { + e.preventDefault(); + e.stopPropagation(); // Prevent ComfyUI from handling this event + e.dataTransfer.dropEffect = 'copy'; + } + + handleDragEnter(e) { + e.preventDefault(); + e.stopPropagation(); // Prevent ComfyUI from handling this event + this.canvas.canvas.style.backgroundColor = 'rgba(45, 90, 160, 0.1)'; + this.canvas.canvas.style.border = '2px dashed #2d5aa0'; + } + + handleDragLeave(e) { + e.preventDefault(); + e.stopPropagation(); // Prevent ComfyUI from handling this event + + if (!this.canvas.canvas.contains(e.relatedTarget)) { + this.canvas.canvas.style.backgroundColor = ''; + this.canvas.canvas.style.border = ''; + } + } + + async handleDrop(e) { + e.preventDefault(); + e.stopPropagation(); // CRITICAL: Prevent ComfyUI from handling this event and loading workflow + + log.info("Canvas drag & drop event intercepted - preventing ComfyUI workflow loading"); + + this.canvas.canvas.style.backgroundColor = ''; + this.canvas.canvas.style.border = ''; + + const files = Array.from(e.dataTransfer.files); + const worldCoords = this.canvas.getMouseWorldCoordinates(e); + + log.info(`Dropped ${files.length} file(s) onto canvas at position (${worldCoords.x}, ${worldCoords.y})`); + + for (const file of files) { + if (file.type.startsWith('image/')) { + try { + await this.loadDroppedImageFile(file, worldCoords); + log.info(`Successfully loaded dropped image: ${file.name}`); + } catch (error) { + log.error(`Failed to load dropped image ${file.name}:`, error); + } + } else { + log.warn(`Skipped non-image file: ${file.name} (${file.type})`); + } + } + } + + async loadDroppedImageFile(file, worldCoords) { + const reader = new FileReader(); + reader.onload = async (e) => { + const img = new Image(); + img.onload = async () => { + + const fitOnAddWidget = this.canvas.node.widgets.find(w => w.name === "fit_on_add"); + const addMode = fitOnAddWidget && fitOnAddWidget.value ? 'fit' : 'center'; + + await this.canvas.addLayer(img, {}, addMode); + }; + img.onerror = () => { + log.error(`Failed to load dropped image: ${file.name}`); + }; + img.src = e.target.result; + }; + reader.onerror = () => { + log.error(`Failed to read dropped file: ${file.name}`); + }; + reader.readAsDataURL(file); + } + + async handlePasteEvent(e) { + + const shouldHandle = this.canvas.isMouseOver || + this.canvas.canvas.contains(document.activeElement) || + document.activeElement === this.canvas.canvas || + document.activeElement === document.body; + + if (!shouldHandle) { + log.debug("Paste event ignored - not focused on canvas"); + return; + } + + log.info("Paste event detected, checking clipboard preference"); + + const preference = this.canvas.canvasLayers.clipboardPreference; + + if (preference === 'clipspace') { + + log.info("Clipboard preference is clipspace, delegating to ClipboardManager"); + e.preventDefault(); + e.stopPropagation(); + await this.canvas.canvasLayers.clipboardManager.handlePaste('mouse', preference); + return; + } + + const clipboardData = e.clipboardData; + if (clipboardData && clipboardData.items) { + for (const item of clipboardData.items) { + if (item.type.startsWith('image/')) { + e.preventDefault(); + e.stopPropagation(); + + const file = item.getAsFile(); + if (file) { + log.info("Found direct image data in paste event"); + const reader = new FileReader(); + reader.onload = async (event) => { + const img = new Image(); + img.onload = async () => { + await this.canvas.canvasLayers.addLayerWithImage(img, {}, 'mouse'); + }; + img.src = event.target.result; + }; + reader.readAsDataURL(file); + return; + } + } + } + } + + await this.canvas.canvasLayers.clipboardManager.handlePaste('mouse', preference); + } } diff --git a/js/CanvasLayers.js b/js/CanvasLayers.js index a44e662..93fa600 100644 --- a/js/CanvasLayers.js +++ b/js/CanvasLayers.js @@ -2,12 +2,15 @@ import {saveImage, removeImage} from "./db.js"; import {createModuleLogger} from "./utils/LoggerUtils.js"; import {generateUUID, generateUniqueFileName} from "./utils/CommonUtils.js"; import {withErrorHandling, createValidationError} from "./ErrorHandler.js"; +import {app, ComfyApp} from "../../scripts/app.js"; +import {ClipboardManager} from "./utils/ClipboardManager.js"; const log = createModuleLogger('CanvasLayers'); export class CanvasLayers { - constructor(canvasLayers) { - this.canvasLayers = canvasLayers; + constructor(canvas) { + this.canvas = canvas; + this.clipboardManager = new ClipboardManager(canvas); this.blendModes = [ {name: 'normal', label: 'Normal'}, {name: 'multiply', label: 'Multiply'}, @@ -26,85 +29,120 @@ export class CanvasLayers { this.blendOpacity = 100; this.isAdjustingOpacity = false; this.internalClipboard = []; + this.clipboardPreference = 'system'; // 'system', 'clipspace' } async copySelectedLayers() { - if (this.canvasLayers.selectedLayers.length === 0) return; - this.internalClipboard = this.canvasLayers.selectedLayers.map(layer => ({...layer})); + if (this.canvas.selectedLayers.length === 0) return; + + this.internalClipboard = this.canvas.selectedLayers.map(layer => ({...layer})); log.info(`Copied ${this.internalClipboard.length} layer(s) to internal clipboard.`); - try { - const blob = await this.getFlattenedSelectionAsBlob(); - if (blob) { + + const blob = await this.getFlattenedSelectionAsBlob(); + if (!blob) { + log.warn("Failed to create flattened selection blob"); + return; + } + + if (this.clipboardPreference === 'clipspace') { + try { + + const dataURL = await new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.readAsDataURL(blob); + }); + + const img = new Image(); + img.onload = () => { + + if (this.canvas.node.imgs) { + this.canvas.node.imgs = [img]; + } else { + this.canvas.node.imgs = [img]; + } + + if (ComfyApp.copyToClipspace) { + ComfyApp.copyToClipspace(this.canvas.node); + log.info("Flattened selection copied to ComfyUI Clipspace."); + } else { + log.warn("ComfyUI copyToClipspace not available"); + } + }; + img.src = dataURL; + + } catch (error) { + log.error("Failed to copy image to ComfyUI Clipspace:", error); + + try { + const item = new ClipboardItem({'image/png': blob}); + await navigator.clipboard.write([item]); + log.info("Fallback: Flattened selection copied to system clipboard."); + } catch (fallbackError) { + log.error("Failed to copy to system clipboard as fallback:", fallbackError); + } + } + } else { + + try { const item = new ClipboardItem({'image/png': blob}); await navigator.clipboard.write([item]); - log.info("Flattened selection copied to the system clipboard."); + log.info("Flattened selection copied to system clipboard."); + } catch (error) { + log.error("Failed to copy image to system clipboard:", error); } - } catch (error) { - log.error("Failed to copy image to system clipboard:", error); } } pasteLayers() { if (this.internalClipboard.length === 0) return; - this.canvasLayers.saveState(); + this.canvas.saveState(); const newLayers = []; - const pasteOffset = 20; + + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + this.internalClipboard.forEach(layer => { + minX = Math.min(minX, layer.x); + minY = Math.min(minY, layer.y); + maxX = Math.max(maxX, layer.x + layer.width); + maxY = Math.max(maxY, layer.y + layer.height); + }); + + const centerX = (minX + maxX) / 2; + const centerY = (minY + maxY) / 2; + + const mouseX = this.canvas.lastMousePosition.x; + const mouseY = this.canvas.lastMousePosition.y; + const offsetX = mouseX - centerX; + const offsetY = mouseY - centerY; this.internalClipboard.forEach(clipboardLayer => { const newLayer = { ...clipboardLayer, - x: clipboardLayer.x + pasteOffset / this.canvasLayers.viewport.zoom, - y: clipboardLayer.y + pasteOffset / this.canvasLayers.viewport.zoom, - zIndex: this.canvasLayers.layers.length + x: clipboardLayer.x + offsetX, + y: clipboardLayer.y + offsetY, + zIndex: this.canvas.layers.length }; - this.canvasLayers.layers.push(newLayer); + this.canvas.layers.push(newLayer); newLayers.push(newLayer); }); - this.canvasLayers.updateSelection(newLayers); - this.canvasLayers.render(); - log.info(`Pasted ${newLayers.length} layer(s).`); + this.canvas.updateSelection(newLayers); + this.canvas.render(); + log.info(`Pasted ${newLayers.length} layer(s) at mouse position (${mouseX}, ${mouseY}).`); } async handlePaste(addMode = 'mouse') { try { - if (!navigator.clipboard?.read) { - log.info("Browser does not support clipboard read API. Falling back to internal paste."); - this.pasteLayers(); - return; - } + log.info(`Paste operation started with preference: ${this.clipboardPreference}`); - const clipboardItems = await navigator.clipboard.read(); - let imagePasted = false; - - for (const item of clipboardItems) { - const imageType = item.types.find(type => type.startsWith('image/')); - - if (imageType) { - const blob = await item.getType(imageType); - const reader = new FileReader(); - reader.onload = (event) => { - const img = new Image(); - img.onload = async () => { - await this.addLayerWithImage(img, {}, addMode); - }; - img.src = event.target.result; - }; - reader.readAsDataURL(blob); - imagePasted = true; - break; - } - } - if (!imagePasted) { - this.pasteLayers(); - } + await this.clipboardManager.handlePaste(addMode, this.clipboardPreference); } catch (err) { - log.error("Paste operation failed, falling back to internal paste. Error:", err); - this.pasteLayers(); + log.error("Paste operation failed:", err); } } + addLayerWithImage = withErrorHandling(async (image, layerProps = {}, addMode = 'default') => { if (!image) { throw createValidationError("Image is required for layer creation"); @@ -113,24 +151,24 @@ export class CanvasLayers { log.debug("Adding layer with image:", image, "with mode:", addMode); const imageId = generateUUID(); await saveImage(imageId, image.src); - this.canvasLayers.imageCache.set(imageId, image.src); - + this.canvas.imageCache.set(imageId, image.src); + let finalWidth = image.width; let finalHeight = image.height; let finalX, finalY; if (addMode === 'fit') { - const scale = Math.min(this.canvasLayers.width / image.width, this.canvasLayers.height / image.height); + const scale = Math.min(this.canvas.width / image.width, this.canvas.height / image.height); finalWidth = image.width * scale; finalHeight = image.height * scale; - finalX = (this.canvasLayers.width - finalWidth) / 2; - finalY = (this.canvasLayers.height - finalHeight) / 2; + finalX = (this.canvas.width - finalWidth) / 2; + finalY = (this.canvas.height - finalHeight) / 2; } else if (addMode === 'mouse') { - finalX = this.canvasLayers.lastMousePosition.x - finalWidth / 2; - finalY = this.canvasLayers.lastMousePosition.y - finalHeight / 2; + finalX = this.canvas.lastMousePosition.x - finalWidth / 2; + finalY = this.canvas.lastMousePosition.y - finalHeight / 2; } else { // 'center' or 'default' - finalX = (this.canvasLayers.width - finalWidth) / 2; - finalY = (this.canvasLayers.height - finalHeight) / 2; + finalX = (this.canvas.width - finalWidth) / 2; + finalY = (this.canvas.height - finalHeight) / 2; } const layer = { @@ -143,16 +181,16 @@ export class CanvasLayers { originalWidth: image.width, originalHeight: image.height, rotation: 0, - zIndex: this.canvasLayers.layers.length, + zIndex: this.canvas.layers.length, blendMode: 'normal', opacity: 1, ...layerProps }; - this.canvasLayers.layers.push(layer); - this.canvasLayers.updateSelection([layer]); - this.canvasLayers.render(); - this.canvasLayers.saveState(); + this.canvas.layers.push(layer); + this.canvas.updateSelection([layer]); + this.canvas.render(); + this.canvas.saveState(); log.info("Layer added successfully"); return layer; @@ -162,53 +200,27 @@ export class CanvasLayers { return this.addLayerWithImage(image); } - async removeLayer(index) { - if (index >= 0 && index < this.canvasLayers.layers.length) { - const layer = this.canvasLayers.layers[index]; - if (layer.imageId) { - const isImageUsedElsewhere = this.canvasLayers.layers.some((l, i) => i !== index && l.imageId === layer.imageId); - if (!isImageUsedElsewhere) { - await removeImage(layer.imageId); - this.canvasLayers.imageCache.delete(layer.imageId); - } - } - this.canvasLayers.layers.splice(index, 1); - this.canvasLayers.selectedLayer = this.canvasLayers.layers[this.canvasLayers.layers.length - 1] || null; - this.canvasLayers.render(); - this.canvasLayers.saveState(); - } - } - - moveLayer(fromIndex, toIndex) { - if (fromIndex >= 0 && fromIndex < this.canvasLayers.layers.length && - toIndex >= 0 && toIndex < this.canvasLayers.layers.length) { - const layer = this.canvasLayers.layers.splice(fromIndex, 1)[0]; - this.canvasLayers.layers.splice(toIndex, 0, layer); - this.canvasLayers.render(); - } - } - moveLayerUp() { - if (this.canvasLayers.selectedLayers.length === 0) return; - const selectedIndicesSet = new Set(this.canvasLayers.selectedLayers.map(layer => this.canvasLayers.layers.indexOf(layer))); + if (this.canvas.selectedLayers.length === 0) return; + const selectedIndicesSet = new Set(this.canvas.selectedLayers.map(layer => this.canvas.layers.indexOf(layer))); const sortedIndices = Array.from(selectedIndicesSet).sort((a, b) => b - a); sortedIndices.forEach(index => { const targetIndex = index + 1; - if (targetIndex < this.canvasLayers.layers.length && !selectedIndicesSet.has(targetIndex)) { - [this.canvasLayers.layers[index], this.canvasLayers.layers[targetIndex]] = [this.canvasLayers.layers[targetIndex], this.canvasLayers.layers[index]]; + if (targetIndex < this.canvas.layers.length && !selectedIndicesSet.has(targetIndex)) { + [this.canvas.layers[index], this.canvas.layers[targetIndex]] = [this.canvas.layers[targetIndex], this.canvas.layers[index]]; } }); - this.canvasLayers.layers.forEach((layer, i) => layer.zIndex = i); - this.canvasLayers.render(); - this.canvasLayers.saveState(); + this.canvas.layers.forEach((layer, i) => layer.zIndex = i); + this.canvas.render(); + this.canvas.saveState(); } moveLayerDown() { - if (this.canvasLayers.selectedLayers.length === 0) return; - const selectedIndicesSet = new Set(this.canvasLayers.selectedLayers.map(layer => this.canvasLayers.layers.indexOf(layer))); + if (this.canvas.selectedLayers.length === 0) return; + const selectedIndicesSet = new Set(this.canvas.selectedLayers.map(layer => this.canvas.layers.indexOf(layer))); const sortedIndices = Array.from(selectedIndicesSet).sort((a, b) => a - b); @@ -216,17 +228,46 @@ export class CanvasLayers { const targetIndex = index - 1; if (targetIndex >= 0 && !selectedIndicesSet.has(targetIndex)) { - [this.canvasLayers.layers[index], this.canvasLayers.layers[targetIndex]] = [this.canvasLayers.layers[targetIndex], this.canvasLayers.layers[index]]; + [this.canvas.layers[index], this.canvas.layers[targetIndex]] = [this.canvas.layers[targetIndex], this.canvas.layers[index]]; } }); - this.canvasLayers.layers.forEach((layer, i) => layer.zIndex = i); - this.canvasLayers.render(); - this.canvasLayers.saveState(); + this.canvas.layers.forEach((layer, i) => layer.zIndex = i); + this.canvas.render(); + this.canvas.saveState(); + } + + /** + * Zmienia rozmiar wybranych warstw + * @param {number} scale - Skala zmiany rozmiaru + */ + resizeLayer(scale) { + if (this.canvas.selectedLayers.length === 0) return; + + this.canvas.selectedLayers.forEach(layer => { + layer.width *= scale; + layer.height *= scale; + }); + this.canvas.render(); + this.canvas.saveState(); + } + + /** + * Obraca wybrane warstwy + * @param {number} angle - Kąt obrotu w stopniach + */ + rotateLayer(angle) { + if (this.canvas.selectedLayers.length === 0) return; + + this.canvas.selectedLayers.forEach(layer => { + layer.rotation += angle; + }); + this.canvas.render(); + this.canvas.saveState(); } getLayerAtPosition(worldX, worldY) { - for (let i = this.canvasLayers.layers.length - 1; i >= 0; i--) { - const layer = this.canvasLayers.layers[i]; + for (let i = this.canvas.layers.length - 1; i >= 0; i--) { + const layer = this.canvas.layers[i]; const centerX = layer.x + layer.width / 2; const centerY = layer.y + layer.height / 2; @@ -253,12 +294,12 @@ export class CanvasLayers { } async mirrorHorizontal() { - if (this.canvasLayers.selectedLayers.length === 0) return; + if (this.canvas.selectedLayers.length === 0) return; - const promises = this.canvasLayers.selectedLayers.map(layer => { + const promises = this.canvas.selectedLayers.map(layer => { return new Promise(resolve => { const tempCanvas = document.createElement('canvas'); - const tempCtx = tempCanvas.getContext('2d'); + const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); tempCanvas.width = layer.image.width; tempCanvas.height = layer.image.height; @@ -276,17 +317,17 @@ export class CanvasLayers { }); await Promise.all(promises); - this.canvasLayers.render(); - this.canvasLayers.saveState(); + this.canvas.render(); + this.canvas.saveState(); } async mirrorVertical() { - if (this.canvasLayers.selectedLayers.length === 0) return; + if (this.canvas.selectedLayers.length === 0) return; - const promises = this.canvasLayers.selectedLayers.map(layer => { + const promises = this.canvas.selectedLayers.map(layer => { return new Promise(resolve => { const tempCanvas = document.createElement('canvas'); - const tempCtx = tempCanvas.getContext('2d'); + const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); tempCanvas.width = layer.image.width; tempCanvas.height = layer.image.height; @@ -304,14 +345,14 @@ export class CanvasLayers { }); await Promise.all(promises); - this.canvasLayers.render(); - this.canvasLayers.saveState(); + this.canvas.render(); + this.canvas.saveState(); } async getLayerImageData(layer) { try { const tempCanvas = document.createElement('canvas'); - const tempCtx = tempCanvas.getContext('2d'); + const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); tempCanvas.width = layer.width; tempCanvas.height = layer.height; @@ -342,52 +383,24 @@ export class CanvasLayers { } } - updateOutputAreaSize(width, height, saveHistory = true) { if (saveHistory) { - this.canvasLayers.saveState(); + this.canvas.saveState(); } - this.canvasLayers.width = width; - this.canvasLayers.height = height; - this.canvasLayers.maskTool.resize(width, height); + this.canvas.width = width; + this.canvas.height = height; + this.canvas.maskTool.resize(width, height); - this.canvasLayers.canvasLayers.width = width; - this.canvasLayers.canvasLayers.height = height; + this.canvas.canvas.width = width; + this.canvas.canvas.height = height; - this.canvasLayers.render(); + this.canvas.render(); if (saveHistory) { - this.canvasLayers.saveStateToDB(); + this.canvas.canvasState.saveStateToDB(); } } - addMattedLayer(image, mask) { - const layer = { - image: image, - mask: mask, - x: 0, - y: 0, - width: image.width, - height: image.height, - rotation: 0, - zIndex: this.canvasLayers.layers.length - }; - - this.canvasLayers.layers.push(layer); - this.canvasLayers.selectedLayer = layer; - this.canvasLayers.render(); - } - - isRotationHandle(x, y) { - if (!this.canvasLayers.selectedLayer) return false; - - const handleX = this.canvasLayers.selectedLayer.x + this.canvasLayers.selectedLayer.width / 2; - const handleY = this.canvasLayers.selectedLayer.y - 20; - const handleRadius = 5; - - return Math.sqrt(Math.pow(x - handleX, 2) + Math.pow(y - handleY, 2)) <= handleRadius; - } - getHandles(layer) { if (!layer) return {}; @@ -408,7 +421,7 @@ export class CanvasLayers { 'sw': {x: -halfW, y: halfH}, 'w': {x: -halfW, y: 0}, 'nw': {x: -halfW, y: -halfH}, - 'rot': {x: 0, y: -halfH - 20 / this.canvasLayers.viewport.zoom} + 'rot': {x: 0, y: -halfH - 20 / this.canvas.viewport.zoom} }; const worldHandles = {}; @@ -423,11 +436,11 @@ export class CanvasLayers { } getHandleAtPosition(worldX, worldY) { - if (this.canvasLayers.selectedLayers.length === 0) return null; + if (this.canvas.selectedLayers.length === 0) return null; - const handleRadius = 8 / this.canvasLayers.viewport.zoom; - for (let i = this.canvasLayers.selectedLayers.length - 1; i >= 0; i--) { - const layer = this.canvasLayers.selectedLayers[i]; + const handleRadius = 8 / this.canvas.viewport.zoom; + for (let i = this.canvas.selectedLayers.length - 1; i >= 0; i--) { + const layer = this.canvas.selectedLayers[i]; const handles = this.getHandles(layer); for (const key in handles) { @@ -442,34 +455,6 @@ export class CanvasLayers { return null; } - getResizeHandle(x, y) { - if (!this.canvasLayers.selectedLayer) return null; - - const handleRadius = 5; - const handles = { - 'nw': {x: this.canvasLayers.selectedLayer.x, y: this.canvasLayers.selectedLayer.y}, - 'ne': { - x: this.canvasLayers.selectedLayer.x + this.canvasLayers.selectedLayer.width, - y: this.canvasLayers.selectedLayer.y - }, - 'se': { - x: this.canvasLayers.selectedLayer.x + this.canvasLayers.selectedLayer.width, - y: this.canvasLayers.selectedLayer.y + this.canvasLayers.selectedLayer.height - }, - 'sw': { - x: this.canvasLayers.selectedLayer.x, - y: this.canvasLayers.selectedLayer.y + this.canvasLayers.selectedLayer.height - } - }; - - for (const [position, point] of Object.entries(handles)) { - if (Math.sqrt(Math.pow(x - point.x, 2) + Math.pow(y - point.y, 2)) <= handleRadius) { - return position; - } - } - return null; - } - showBlendModeMenu(x, y) { this.closeBlendModeMenu(); @@ -482,11 +467,69 @@ export class CanvasLayers { background: #2a2a2a; border: 1px solid #3a3a3a; border-radius: 4px; - padding: 5px; z-index: 10000; box-shadow: 0 2px 10px rgba(0,0,0,0.3); + min-width: 200px; `; + const titleBar = document.createElement('div'); + titleBar.style.cssText = ` + background: #3a3a3a; + color: white; + padding: 8px 10px; + cursor: move; + user-select: none; + border-radius: 3px 3px 0 0; + font-size: 12px; + font-weight: bold; + border-bottom: 1px solid #4a4a4a; + `; + titleBar.textContent = 'Blend Mode'; + + const content = document.createElement('div'); + content.style.cssText = ` + padding: 5px; + `; + + menu.appendChild(titleBar); + menu.appendChild(content); + + let isDragging = false; + let dragOffset = { x: 0, y: 0 }; + + const handleMouseMove = (e) => { + if (isDragging) { + const newX = e.clientX - dragOffset.x; + const newY = e.clientY - dragOffset.y; + + const maxX = window.innerWidth - menu.offsetWidth; + const maxY = window.innerHeight - menu.offsetHeight; + + menu.style.left = Math.max(0, Math.min(newX, maxX)) + 'px'; + menu.style.top = Math.max(0, Math.min(newY, maxY)) + 'px'; + } + }; + + const handleMouseUp = () => { + if (isDragging) { + isDragging = false; + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + } + }; + + titleBar.addEventListener('mousedown', (e) => { + isDragging = true; + + dragOffset.x = e.clientX - parseInt(menu.style.left); + dragOffset.y = e.clientY - parseInt(menu.style.top); + e.preventDefault(); + e.stopPropagation(); + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }); + this.blendModes.forEach(mode => { const container = document.createElement('div'); container.className = 'blend-mode-container'; @@ -508,58 +551,58 @@ export class CanvasLayers { slider.min = '0'; slider.max = '100'; - slider.value = this.canvasLayers.selectedLayer.opacity ? Math.round(this.canvasLayers.selectedLayer.opacity * 100) : 100; + slider.value = this.canvas.selectedLayer.opacity ? Math.round(this.canvas.selectedLayer.opacity * 100) : 100; slider.style.cssText = ` width: 100%; margin: 5px 0; display: none; `; - if (this.canvasLayers.selectedLayer.blendMode === mode.name) { + if (this.canvas.selectedLayer.blendMode === mode.name) { slider.style.display = 'block'; option.style.backgroundColor = '#3a3a3a'; } option.onclick = () => { - menu.querySelectorAll('input[type="range"]').forEach(s => { + content.querySelectorAll('input[type="range"]').forEach(s => { s.style.display = 'none'; }); - menu.querySelectorAll('.blend-mode-container div').forEach(d => { + content.querySelectorAll('.blend-mode-container div').forEach(d => { d.style.backgroundColor = ''; }); slider.style.display = 'block'; option.style.backgroundColor = '#3a3a3a'; - if (this.canvasLayers.selectedLayer) { - this.canvasLayers.selectedLayer.blendMode = mode.name; - this.canvasLayers.render(); + if (this.canvas.selectedLayer) { + this.canvas.selectedLayer.blendMode = mode.name; + this.canvas.render(); } }; slider.addEventListener('input', () => { - if (this.canvasLayers.selectedLayer) { - this.canvasLayers.selectedLayer.opacity = slider.value / 100; - this.canvasLayers.render(); + if (this.canvas.selectedLayer) { + this.canvas.selectedLayer.opacity = slider.value / 100; + this.canvas.render(); } }); slider.addEventListener('change', async () => { - if (this.canvasLayers.selectedLayer) { - this.canvasLayers.selectedLayer.opacity = slider.value / 100; - this.canvasLayers.render(); + if (this.canvas.selectedLayer) { + this.canvas.selectedLayer.opacity = slider.value / 100; + this.canvas.render(); const saveWithFallback = async (fileName) => { try { - const uniqueFileName = generateUniqueFileName(fileName, this.canvasLayers.node.id); - return await this.canvasLayers.saveToServer(uniqueFileName); + const uniqueFileName = generateUniqueFileName(fileName, this.canvas.node.id); + return await this.canvas.saveToServer(uniqueFileName); } catch (error) { console.warn(`Failed to save with unique name, falling back to original: ${fileName}`, error); - return await this.canvasLayers.saveToServer(fileName); + return await this.canvas.saveToServer(fileName); } }; - await saveWithFallback(this.canvasLayers.widget.value); - if (this.canvasLayers.node) { + await saveWithFallback(this.canvas.widget.value); + if (this.canvas.node) { app.graph.runStep(); } } @@ -567,14 +610,14 @@ export class CanvasLayers { container.appendChild(option); container.appendChild(slider); - menu.appendChild(container); + content.appendChild(container); }); - const container = this.canvasLayers.canvas.parentElement || document.body; + const container = this.canvas.canvas.parentElement || document.body; container.appendChild(menu); const closeMenu = (e) => { - if (!menu.contains(e.target)) { + if (!menu.contains(e.target) && !isDragging) { this.closeBlendModeMenu(); document.removeEventListener('mousedown', closeMenu); } @@ -591,17 +634,6 @@ export class CanvasLayers { } } - handleBlendModeSelection(mode) { - if (this.selectedBlendMode === mode && !this.isAdjustingOpacity) { - this.applyBlendMode(mode, this.blendOpacity); - this.closeBlendModeMenu(); - } else { - this.selectedBlendMode = mode; - this.isAdjustingOpacity = true; - this.showOpacitySlider(mode); - } - } - showOpacitySlider(mode) { const slider = document.createElement('input'); slider.type = 'range'; @@ -623,11 +655,11 @@ export class CanvasLayers { async getFlattenedCanvasAsBlob() { return new Promise((resolve, reject) => { const tempCanvas = document.createElement('canvas'); - tempCanvas.width = this.canvasLayers.width; - tempCanvas.height = this.canvasLayers.height; - const tempCtx = tempCanvas.getContext('2d'); + tempCanvas.width = this.canvas.width; + tempCanvas.height = this.canvas.height; + const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); - const sortedLayers = [...this.canvasLayers.layers].sort((a, b) => a.zIndex - b.zIndex); + const sortedLayers = [...this.canvas.layers].sort((a, b) => a.zIndex - b.zIndex); sortedLayers.forEach(layer => { if (!layer.image) return; @@ -660,14 +692,216 @@ export class CanvasLayers { }); } + async getFlattenedCanvasWithMaskAsBlob() { + return new Promise((resolve, reject) => { + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = this.canvas.width; + tempCanvas.height = this.canvas.height; + const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); + + const sortedLayers = [...this.canvas.layers].sort((a, b) => a.zIndex - b.zIndex); + + sortedLayers.forEach(layer => { + if (!layer.image) return; + + tempCtx.save(); + tempCtx.globalCompositeOperation = layer.blendMode || 'normal'; + tempCtx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1; + const centerX = layer.x + layer.width / 2; + const centerY = layer.y + layer.height / 2; + tempCtx.translate(centerX, centerY); + tempCtx.rotate(layer.rotation * Math.PI / 180); + tempCtx.drawImage( + layer.image, + -layer.width / 2, + -layer.height / 2, + layer.width, + layer.height + ); + + tempCtx.restore(); + }); + + const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height); + const data = imageData.data; + + const toolMaskCanvas = this.canvas.maskTool.getMask(); + if (toolMaskCanvas) { + + const tempMaskCanvas = document.createElement('canvas'); + tempMaskCanvas.width = this.canvas.width; + tempMaskCanvas.height = this.canvas.height; + const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true }); + + tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height); + + const maskX = this.canvas.maskTool.x; + const maskY = this.canvas.maskTool.y; + + const sourceX = Math.max(0, -maskX); // Where in the mask canvas to start reading + const sourceY = Math.max(0, -maskY); + const destX = Math.max(0, maskX); // Where in the output canvas to start writing + const destY = Math.max(0, maskY); + + const copyWidth = Math.min( + toolMaskCanvas.width - sourceX, // Available width in source + this.canvas.width - destX // Available width in destination + ); + const copyHeight = Math.min( + toolMaskCanvas.height - sourceY, // Available height in source + this.canvas.height - destY // Available height in destination + ); + + if (copyWidth > 0 && copyHeight > 0) { + tempMaskCtx.drawImage( + toolMaskCanvas, + sourceX, sourceY, copyWidth, copyHeight, // Source rectangle + destX, destY, copyWidth, copyHeight // Destination rectangle + ); + } + + const tempMaskData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height); + for (let i = 0; i < tempMaskData.data.length; i += 4) { + const alpha = tempMaskData.data[i + 3]; + tempMaskData.data[i] = tempMaskData.data[i + 1] = tempMaskData.data[i + 2] = 255; + tempMaskData.data[i + 3] = alpha; + } + tempMaskCtx.putImageData(tempMaskData, 0, 0); + + const maskImageData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height); + const maskData = maskImageData.data; + + for (let i = 0; i < data.length; i += 4) { + const originalAlpha = data[i + 3]; + const maskAlpha = maskData[i + 3] / 255; // Użyj kanału alpha maski + + + const invertedMaskAlpha = 1 - maskAlpha; + data[i + 3] = originalAlpha * invertedMaskAlpha; + } + + tempCtx.putImageData(imageData, 0, 0); + } + + tempCanvas.toBlob((blob) => { + if (blob) { + resolve(blob); + } else { + reject(new Error('Canvas toBlob failed.')); + } + }, 'image/png'); + }); + } + + async getFlattenedCanvasForMaskEditor() { + return new Promise((resolve, reject) => { + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = this.canvas.width; + tempCanvas.height = this.canvas.height; + const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); + + const sortedLayers = [...this.canvas.layers].sort((a, b) => a.zIndex - b.zIndex); + + sortedLayers.forEach(layer => { + if (!layer.image) return; + + tempCtx.save(); + tempCtx.globalCompositeOperation = layer.blendMode || 'normal'; + tempCtx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1; + const centerX = layer.x + layer.width / 2; + const centerY = layer.y + layer.height / 2; + tempCtx.translate(centerX, centerY); + tempCtx.rotate(layer.rotation * Math.PI / 180); + tempCtx.drawImage( + layer.image, + -layer.width / 2, + -layer.height / 2, + layer.width, + layer.height + ); + + tempCtx.restore(); + }); + + const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height); + const data = imageData.data; + + const toolMaskCanvas = this.canvas.maskTool.getMask(); + if (toolMaskCanvas) { + + const tempMaskCanvas = document.createElement('canvas'); + tempMaskCanvas.width = this.canvas.width; + tempMaskCanvas.height = this.canvas.height; + const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true }); + + tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height); + + const maskX = this.canvas.maskTool.x; + const maskY = this.canvas.maskTool.y; + + const sourceX = Math.max(0, -maskX); + const sourceY = Math.max(0, -maskY); + const destX = Math.max(0, maskX); + const destY = Math.max(0, maskY); + + const copyWidth = Math.min( + toolMaskCanvas.width - sourceX, + this.canvas.width - destX + ); + const copyHeight = Math.min( + toolMaskCanvas.height - sourceY, + this.canvas.height - destY + ); + + if (copyWidth > 0 && copyHeight > 0) { + tempMaskCtx.drawImage( + toolMaskCanvas, + sourceX, sourceY, copyWidth, copyHeight, + destX, destY, copyWidth, copyHeight + ); + } + + const tempMaskData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height); + for (let i = 0; i < tempMaskData.data.length; i += 4) { + const alpha = tempMaskData.data[i + 3]; + tempMaskData.data[i] = tempMaskData.data[i + 1] = tempMaskData.data[i + 2] = 255; + tempMaskData.data[i + 3] = alpha; + } + tempMaskCtx.putImageData(tempMaskData, 0, 0); + + const maskImageData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height); + const maskData = maskImageData.data; + + for (let i = 0; i < data.length; i += 4) { + const originalAlpha = data[i + 3]; + const maskAlpha = maskData[i + 3] / 255; + + + const invertedMaskAlpha = 1 - maskAlpha; + data[i + 3] = originalAlpha * invertedMaskAlpha; + } + + tempCtx.putImageData(imageData, 0, 0); + } + + tempCanvas.toBlob((blob) => { + if (blob) { + resolve(blob); + } else { + reject(new Error('Canvas toBlob failed.')); + } + }, 'image/png'); + }); + } + async getFlattenedSelectionAsBlob() { - if (this.canvasLayers.selectedLayers.length === 0) { + if (this.canvas.selectedLayers.length === 0) { return null; } return new Promise((resolve) => { let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; - this.canvasLayers.selectedLayers.forEach(layer => { + this.canvas.selectedLayers.forEach(layer => { const centerX = layer.x + layer.width / 2; const centerY = layer.y + layer.height / 2; const rad = layer.rotation * Math.PI / 180; @@ -705,11 +939,11 @@ export class CanvasLayers { const tempCanvas = document.createElement('canvas'); tempCanvas.width = newWidth; tempCanvas.height = newHeight; - const tempCtx = tempCanvas.getContext('2d'); + const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); tempCtx.translate(-minX, -minY); - const sortedSelection = [...this.canvasLayers.selectedLayers].sort((a, b) => a.zIndex - b.zIndex); + const sortedSelection = [...this.canvas.selectedLayers].sort((a, b) => a.zIndex - b.zIndex); sortedSelection.forEach(layer => { if (!layer.image) return; diff --git a/js/CanvasRenderer.js b/js/CanvasRenderer.js index 2fcaafe..faab8dc 100644 --- a/js/CanvasRenderer.js +++ b/js/CanvasRenderer.js @@ -301,7 +301,7 @@ export class CanvasRenderer { ctx.moveTo(0, -layer.height / 2); ctx.lineTo(0, -layer.height / 2 - 20 / this.canvas.viewport.zoom); ctx.stroke(); - const handles = this.canvas.getHandles(layer); + const handles = this.canvas.canvasLayers.getHandles(layer); ctx.fillStyle = '#ffffff'; ctx.strokeStyle = '#000000'; ctx.lineWidth = 1 / this.canvas.viewport.zoom; diff --git a/js/CanvasState.js b/js/CanvasState.js index 35c7b1f..a15dbd8 100644 --- a/js/CanvasState.js +++ b/js/CanvasState.js @@ -216,6 +216,7 @@ export class CanvasState { await setCanvasState(this.canvas.node.id, state); log.info("Canvas state saved to IndexedDB."); this.lastSavedStateSignature = currentStateSignature; + this.canvas.render(); }, 'CanvasState.saveStateToDB'); if (immediate) { @@ -292,7 +293,7 @@ export class CanvasState { const clonedCanvas = document.createElement('canvas'); clonedCanvas.width = maskCanvas.width; clonedCanvas.height = maskCanvas.height; - const clonedCtx = clonedCanvas.getContext('2d'); + const clonedCtx = clonedCanvas.getContext('2d', { willReadFrequently: true }); clonedCtx.drawImage(maskCanvas, 0, 0); this.maskUndoStack.push(clonedCanvas); @@ -352,7 +353,7 @@ export class CanvasState { if (this.maskUndoStack.length > 0) { const prevState = this.maskUndoStack[this.maskUndoStack.length - 1]; const maskCanvas = this.canvas.maskTool.getMask(); - const maskCtx = maskCanvas.getContext('2d'); + const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true }); maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height); maskCtx.drawImage(prevState, 0, 0); @@ -368,7 +369,7 @@ export class CanvasState { const nextState = this.maskRedoStack.pop(); this.maskUndoStack.push(nextState); const maskCanvas = this.canvas.maskTool.getMask(); - const maskCtx = maskCanvas.getContext('2d'); + const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true }); maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height); maskCtx.drawImage(nextState, 0, 0); diff --git a/js/CanvasView.js b/js/CanvasView.js index 47063f8..58d6914 100644 --- a/js/CanvasView.js +++ b/js/CanvasView.js @@ -96,6 +96,33 @@ async function createCanvasWidget(node, widget, app) { border-radius: 6px; } + .painter-clipboard-group { + display: flex; + align-items: center; + gap: 2px; + background-color: rgba(0,0,0,0.15); + padding: 3px; + border-radius: 6px; + border: 1px solid rgba(255,255,255,0.1); + position: relative; + } + + .painter-clipboard-group::before { + content: ""; + position: absolute; + top: -2px; + left: 50%; + transform: translateX(-50%); + width: 20px; + height: 2px; + background: linear-gradient(90deg, transparent, rgba(74, 108, 212, 0.6), transparent); + border-radius: 1px; + } + + .painter-clipboard-group .painter-button { + margin: 1px; + } + .painter-separator { width: 1px; height: 28px; @@ -214,12 +241,13 @@ async function createCanvasWidget(node, widget, app) { } .painter-tooltip table td:first-child { - width: 45%; + width: auto; white-space: nowrap; + min-width: fit-content; } .painter-tooltip table td:last-child { - width: 55%; + width: auto; } .painter-tooltip table tr:nth-child(odd) td { @@ -368,7 +396,7 @@ async function createCanvasWidget(node, widget, app) { width: 100vw; height: 100vh; background-color: rgba(0, 0, 0, 0.8); - z-index: 9998; + z-index: 111; display: flex; align-items: center; justify-content: center; @@ -385,6 +413,8 @@ async function createCanvasWidget(node, widget, app) { flex-direction: column; position: relative; } + + `; document.head.appendChild(style); @@ -483,38 +513,31 @@ async function createCanvasWidget(node, widget, app) { } else { helpTooltip.innerHTML = standardShortcuts; } - - // Najpierw wyświetlamy tooltip z visibility: hidden aby obliczyć jego wymiary + helpTooltip.style.visibility = 'hidden'; helpTooltip.style.display = 'block'; - + const buttonRect = e.target.getBoundingClientRect(); const tooltipRect = helpTooltip.getBoundingClientRect(); const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; - - // Obliczamy pozycję + let left = buttonRect.left; let top = buttonRect.bottom + 5; - - // Sprawdzamy czy tooltip wychodzi poza prawy brzeg ekranu + if (left + tooltipRect.width > viewportWidth) { left = viewportWidth - tooltipRect.width - 10; } - - // Sprawdzamy czy tooltip wychodzi poza dolny brzeg ekranu + if (top + tooltipRect.height > viewportHeight) { - // Wyświetlamy nad przyciskiem zamiast pod + top = buttonRect.top - tooltipRect.height - 5; } - - // Upewniamy się, że tooltip nie wychodzi poza lewy brzeg + if (left < 10) left = 10; - - // Upewniamy się, że tooltip nie wychodzi poza górny brzeg + if (top < 10) top = 10; - - // Ustawiamy finalną pozycję i pokazujemy tooltip + helpTooltip.style.left = `${left}px`; helpTooltip.style.top = `${top}px`; helpTooltip.style.visibility = 'visible'; @@ -539,7 +562,7 @@ async function createCanvasWidget(node, widget, app) { reader.onload = (event) => { const img = new Image(); img.onload = () => { - canvas.addLayer(img, addMode); + canvas.addLayer(img, {}, addMode); }; img.src = event.target.result; }; @@ -552,17 +575,116 @@ async function createCanvasWidget(node, widget, app) { $el("button.painter-button.primary", { textContent: "Import Input", title: "Import image from another node", - onclick: () => canvas.importLatestImage() - }), - $el("button.painter-button.primary", { - textContent: "Paste Image", - title: "Paste image from clipboard", - onclick: () => { - const fitOnAddWidget = node.widgets.find(w => w.name === "fit_on_add"); - const addMode = fitOnAddWidget && fitOnAddWidget.value ? 'fit' : 'center'; - canvas.handlePaste(addMode); - } + onclick: () => canvas.canvasIO.importLatestImage() }), + $el("div.painter-clipboard-group", {}, [ + $el("button.painter-button.primary", { + textContent: "Paste Image", + title: "Paste image from clipboard", + onclick: () => { + + const fitOnAddWidget = node.widgets.find(w => w.name === "fit_on_add"); + const addMode = fitOnAddWidget && fitOnAddWidget.value ? 'fit' : 'center'; + canvas.canvasLayers.handlePaste(addMode); + } + }), + $el("button.painter-button", { + id: `clipboard-toggle-${node.id}`, + textContent: "📋 System", + title: "Toggle clipboard source: System Clipboard", + style: { + minWidth: "100px", + fontSize: "11px", + backgroundColor: "#4a4a4a" + }, + onclick: (e) => { + const button = e.target; + if (canvas.canvasLayers.clipboardPreference === 'system') { + canvas.canvasLayers.clipboardPreference = 'clipspace'; + button.textContent = "📋 Clipspace"; + button.title = "Toggle clipboard source: ComfyUI Clipspace"; + button.style.backgroundColor = "#4a6cd4"; + } else { + canvas.canvasLayers.clipboardPreference = 'system'; + button.textContent = "📋 System"; + button.title = "Toggle clipboard source: System Clipboard"; + button.style.backgroundColor = "#4a4a4a"; + } + log.info(`Clipboard preference toggled to: ${canvas.canvasLayers.clipboardPreference}`); + }, + onmouseenter: (e) => { + const currentPreference = canvas.canvasLayers.clipboardPreference; + let tooltipContent = ''; + + if (currentPreference === 'system') { + tooltipContent = ` +

📋 System Clipboard Mode

+ + + + + + + + +
Ctrl + CCopy selected layers to internal clipboard + system clipboard as flattened image
Ctrl + VPriority:
1️⃣ Internal clipboard (copied layers)
2️⃣ System clipboard (images, screenshots)
3️⃣ System clipboard (file paths, URLs)
Paste ImageSame as Ctrl+V but respects fit_on_add setting
Drag & DropLoad images directly from files
+
+ ⚠️ Security Note: "Paste Image" button for external images may not work due to browser security restrictions. Use Ctrl+V instead or Drag & Drop. +
+
+ 💡 Best for: Working with screenshots, copied images, file paths, and urls. +
+ `; + } else { + tooltipContent = ` +

📋 ComfyUI Clipspace Mode

+ + + + + + + + +
Ctrl + CCopy selected layers to internal clipboard + ComfyUI Clipspace as flattened image
Ctrl + VPriority:
1️⃣ Internal clipboard (copied layers)
2️⃣ ComfyUI Clipspace (workflow images)
3️⃣ System clipboard (fallback)
Paste ImageSame as Ctrl+V but respects fit_on_add setting
Drag & DropLoad images directly from files
+
+ 💡 Best for: ComfyUI workflow integration and node-to-node image transfer +
+ `; + } + + helpTooltip.innerHTML = tooltipContent; + helpTooltip.style.visibility = 'hidden'; + helpTooltip.style.display = 'block'; + + const buttonRect = e.target.getBoundingClientRect(); + const tooltipRect = helpTooltip.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + let left = buttonRect.left; + let top = buttonRect.bottom + 5; + + if (left + tooltipRect.width > viewportWidth) { + left = viewportWidth - tooltipRect.width - 10; + } + + if (top + tooltipRect.height > viewportHeight) { + top = buttonRect.top - tooltipRect.height - 5; + } + + if (left < 10) left = 10; + if (top < 10) top = 10; + + helpTooltip.style.left = `${left}px`; + helpTooltip.style.top = `${top}px`; + helpTooltip.style.visibility = 'visible'; + }, + onmouseleave: () => { + helpTooltip.style.display = 'none'; + } + }) + ]), ]), $el("div.painter-separator"), @@ -644,7 +766,7 @@ async function createCanvasWidget(node, widget, app) { const height = parseInt(document.getElementById('canvas-height').value) || canvas.height; canvas.updateOutputAreaSize(width, height); document.body.removeChild(dialog); - // updateOutput is triggered by saveState in updateOutputAreaSize + }; document.getElementById('cancel-size').onclick = () => { @@ -660,12 +782,12 @@ async function createCanvasWidget(node, widget, app) { $el("button.painter-button.requires-selection", { textContent: "Layer Up", title: "Move selected layer(s) up", - onclick: () => canvas.moveLayerUp() + onclick: () => canvas.canvasLayers.moveLayerUp() }), $el("button.painter-button.requires-selection", { textContent: "Layer Down", title: "Move selected layer(s) down", - onclick: () => canvas.moveLayerDown() + onclick: () => canvas.canvasLayers.moveLayerDown() }), ]), @@ -674,27 +796,27 @@ async function createCanvasWidget(node, widget, app) { $el("button.painter-button.requires-selection", { textContent: "Rotate +90°", title: "Rotate selected layer(s) by +90 degrees", - onclick: () => canvas.rotateLayer(90) + onclick: () => canvas.canvasLayers.rotateLayer(90) }), $el("button.painter-button.requires-selection", { textContent: "Scale +5%", title: "Increase size of selected layer(s) by 5%", - onclick: () => canvas.resizeLayer(1.05) + onclick: () => canvas.canvasLayers.resizeLayer(1.05) }), $el("button.painter-button.requires-selection", { textContent: "Scale -5%", title: "Decrease size of selected layer(s) by 5%", - onclick: () => canvas.resizeLayer(0.95) + onclick: () => canvas.canvasLayers.resizeLayer(0.95) }), $el("button.painter-button.requires-selection", { textContent: "Mirror H", title: "Mirror selected layer(s) horizontally", - onclick: () => canvas.mirrorHorizontal() + onclick: () => canvas.canvasLayers.mirrorHorizontal() }), $el("button.painter-button.requires-selection", { textContent: "Mirror V", title: "Mirror selected layer(s) vertically", - onclick: () => canvas.mirrorVertical() + onclick: () => canvas.canvasLayers.mirrorVertical() }), ]), @@ -716,7 +838,7 @@ async function createCanvasWidget(node, widget, app) { const selectedLayer = canvas.selectedLayers[0]; const selectedLayerIndex = canvas.layers.indexOf(selectedLayer); - const imageData = await canvas.getLayerImageData(selectedLayer); + const imageData = await canvas.canvasLayers.getLayerImageData(selectedLayer); const response = await fetch("/matting", { method: "POST", headers: {"Content-Type": "application/json"}, @@ -749,18 +871,25 @@ async function createCanvasWidget(node, widget, app) { textContent: "Undo", title: "Undo last action", disabled: true, - onclick: () => canvas.undo() + onclick: () => canvas.canvasState.undo() }), $el("button.painter-button", { id: `redo-button-${node.id}`, textContent: "Redo", title: "Redo last undone action", disabled: true, - onclick: () => canvas.redo() + onclick: () => canvas.canvasState.redo() }), ]), $el("div.painter-separator"), $el("div.painter-button-group", {id: "mask-controls"}, [ + $el("button.painter-button", { + textContent: "Edit Mask", + title: "Open the current canvas view in the mask editor", + onclick: () => { + canvas.startMaskEditor(); + } + }), $el("button.painter-button", { id: "mask-mode-btn", textContent: "Draw Mask", @@ -838,15 +967,15 @@ async function createCanvasWidget(node, widget, app) { style: {backgroundColor: "#4a7c59", borderColor: "#3a6c49"}, onclick: async () => { try { - const stats = canvas.getGarbageCollectionStats(); + const stats = canvas.imageReferenceManager.getStats(); log.info("GC Stats before cleanup:", stats); - await canvas.runGarbageCollection(); + await canvas.imageReferenceManager.manualGarbageCollection(); - const newStats = canvas.getGarbageCollectionStats(); + const newStats = canvas.imageReferenceManager.getStats(); log.info("GC Stats after cleanup:", newStats); - alert(`Garbage collection completed!\nTracked images: ${newStats.trackedImages}\nTotal references: ${newStats.totalReferences}\nOperations: ${newStats.operationCount}/${newStats.operationThreshold}`); + alert(`Garbage collection completed!\nTracked images: ${newStats.trackedImages}\nTotal references: ${newStats.totalReferences}\nOperations: ${canvas.imageReferenceManager.operationCount}/${canvas.imageReferenceManager.operationThreshold}`); } catch (e) { log.error("Failed to run garbage collection:", e); alert("Error running garbage collection. Check the console for details."); @@ -910,9 +1039,23 @@ async function createCanvasWidget(node, widget, app) { const triggerWidget = node.widgets.find(w => w.name === "trigger"); - const updateOutput = () => { + const updateOutput = async () => { triggerWidget.value = (triggerWidget.value + 1) % 99999999; - // app.graph.runStep(); // Potentially not needed if we just want to mark dirty + + try { + const new_preview = new Image(); + const blob = await canvas.getFlattenedCanvasWithMaskAsBlob(); + if (blob) { + new_preview.src = URL.createObjectURL(blob); + await new Promise(r => new_preview.onload = r); + node.imgs = [new_preview]; + } else { + node.imgs = []; + } + } catch (error) { + console.error("Error updating node preview:", error); + } + }; const canvasContainer = $el("div.painterCanvasContainer.painter-container", { @@ -948,70 +1091,8 @@ async function createCanvasWidget(node, widget, app) { height: "100%" } }, [controlPanel, canvasContainer]); - const handleFileLoad = async (file) => { - log.info("File dropped:", file.name); - if (!file.type.startsWith('image/')) { - log.info("Dropped file is not an image."); - return; - } - const reader = new FileReader(); - reader.onload = async (event) => { - log.debug("FileReader finished loading dropped file as data:URL."); - const img = new Image(); - img.onload = async () => { - log.debug("Image object loaded from dropped data:URL."); - const scale = Math.min( - canvas.width / img.width, - canvas.height / img.height - ); - const layer = { - image: img, - x: (canvas.width - img.width * scale) / 2, - y: (canvas.height - img.height * scale) / 2, - width: img.width * scale, - height: img.height * scale, - rotation: 0, - zIndex: canvas.layers.length, - blendMode: 'normal', - opacity: 1 - }; - - canvas.layers.push(layer); - canvas.updateSelection([layer]); - canvas.render(); - canvas.saveState(); - log.info("Dropped layer added and state saved."); - }; - img.src = event.target.result; - }; - reader.readAsDataURL(file); - }; - - mainContainer.addEventListener('dragover', (e) => { - e.preventDefault(); - e.stopPropagation(); - canvasContainer.classList.add('drag-over'); - }); - - mainContainer.addEventListener('dragleave', (e) => { - e.preventDefault(); - e.stopPropagation(); - canvasContainer.classList.remove('drag-over'); - }); - - mainContainer.addEventListener('drop', async (e) => { - e.preventDefault(); - e.stopPropagation(); - canvasContainer.classList.remove('drag-over'); - - if (e.dataTransfer.files) { - for (const file of e.dataTransfer.files) { - await handleFileLoad(file); - } - } - }); const mainWidget = node.addDOMWidget("mainContainer", "widget", mainContainer); @@ -1072,14 +1153,34 @@ async function createCanvasWidget(node, widget, app) { if (!window.canvasExecutionStates) { window.canvasExecutionStates = new Map(); } - - node.canvasWidget = canvas; setTimeout(() => { canvas.loadInitialState(); }, 100); + const showPreviewWidget = node.widgets.find(w => w.name === "show_preview"); + if (showPreviewWidget) { + const originalCallback = showPreviewWidget.callback; + + showPreviewWidget.callback = function (value) { + if (originalCallback) { + originalCallback.call(this, value); + } + + if (canvas && canvas.setPreviewVisibility) { + canvas.setPreviewVisibility(value); + } + + if (node.graph && node.graph.canvas) { + node.setDirtyCanvas(true, true); + } + }; + + + } + + return { canvas: canvas, panel: controlPanel @@ -1154,7 +1255,6 @@ app.registerExtension({ return; } - // Iterate through every widget attached to this node this.widgets.forEach(w => { log.debug(`Widget name: ${w.name}, type: ${w.type}, value: ${w.value}`); }); @@ -1206,7 +1306,32 @@ app.registerExtension({ originalGetExtraMenuOptions?.apply(this, arguments); const self = this; + + const maskEditorIndex = options.findIndex(option => + option && option.content === "Open in MaskEditor" + ); + if (maskEditorIndex !== -1) { + options.splice(maskEditorIndex, 1); + } + const newOptions = [ + { + content: "Open in MaskEditor", + callback: async () => { + try { + log.info("Opening LayerForge canvas in MaskEditor"); + if (self.canvasWidget && self.canvasWidget.startMaskEditor) { + await self.canvasWidget.startMaskEditor(); + } else { + log.error("Canvas widget not available"); + alert("Canvas not ready. Please try again."); + } + } catch (e) { + log.error("Error opening MaskEditor:", e); + alert(`Failed to open MaskEditor: ${e.message}`); + } + }, + }, { content: "Open Image", callback: async () => { @@ -1220,6 +1345,19 @@ app.registerExtension({ } }, }, + { + content: "Open Image with Mask Alpha", + callback: async () => { + try { + const blob = await self.canvasWidget.getFlattenedCanvasWithMaskAsBlob(); + const url = URL.createObjectURL(blob); + window.open(url, '_blank'); + setTimeout(() => URL.revokeObjectURL(url), 1000); + } catch (e) { + log.error("Error opening image with mask:", e); + } + }, + }, { content: "Copy Image", callback: async () => { @@ -1234,6 +1372,20 @@ app.registerExtension({ } }, }, + { + content: "Copy Image with Mask Alpha", + callback: async () => { + try { + const blob = await self.canvasWidget.getFlattenedCanvasWithMaskAsBlob(); + const item = new ClipboardItem({'image/png': blob}); + await navigator.clipboard.write([item]); + log.info("Image with mask alpha copied to clipboard."); + } catch (e) { + log.error("Error copying image with mask:", e); + alert("Failed to copy image with mask to clipboard."); + } + }, + }, { content: "Save Image", callback: async () => { @@ -1252,6 +1404,24 @@ app.registerExtension({ } }, }, + { + content: "Save Image with Mask Alpha", + callback: async () => { + try { + const blob = await self.canvasWidget.getFlattenedCanvasWithMaskAsBlob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'canvas_output_with_mask.png'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 1000); + } catch (e) { + log.error("Error saving image with mask:", e); + } + }, + }, ]; if (options.length > 0) { options.unshift({content: "___", disabled: true}); diff --git a/js/MaskTool.js b/js/MaskTool.js index 456689f..4ca82a8 100644 --- a/js/MaskTool.js +++ b/js/MaskTool.js @@ -8,7 +8,7 @@ export class MaskTool { this.mainCanvas = canvasInstance.canvas; this.onStateChange = callbacks.onStateChange || null; this.maskCanvas = document.createElement('canvas'); - this.maskCtx = this.maskCanvas.getContext('2d'); + this.maskCtx = this.maskCanvas.getContext('2d', { willReadFrequently: true }); this.x = 0; this.y = 0; @@ -21,7 +21,7 @@ export class MaskTool { this.lastPosition = null; this.previewCanvas = document.createElement('canvas'); - this.previewCtx = this.previewCanvas.getContext('2d'); + this.previewCtx = this.previewCanvas.getContext('2d', { willReadFrequently: true }); this.previewVisible = false; this.previewCanvasInitialized = false; @@ -162,7 +162,7 @@ export class MaskTool { if (this.brushHardness === 1) { this.maskCtx.strokeStyle = `rgba(255, 255, 255, ${this.brushStrength})`; } else { - // hardness: 1 = hard edge, 0 = soft edge + const innerRadius = gradientRadius * this.brushHardness; const gradient = this.maskCtx.createRadialGradient( canvasX, canvasY, innerRadius, @@ -220,7 +220,7 @@ export class MaskTool { const tempCanvas = document.createElement('canvas'); tempCanvas.width = this.maskCanvas.width; tempCanvas.height = this.maskCanvas.height; - const tempCtx = tempCanvas.getContext('2d'); + const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); tempCtx.drawImage(this.maskCanvas, 0, 0); const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height); const data = imageData.data; @@ -258,7 +258,7 @@ export class MaskTool { this.maskCanvas.width = newWidth; this.maskCanvas.height = newHeight; - this.maskCtx = this.maskCanvas.getContext('2d'); + this.maskCtx = this.maskCanvas.getContext('2d', { willReadFrequently: true }); if (oldMask.width > 0 && oldMask.height > 0) { @@ -279,4 +279,23 @@ export class MaskTool { this.y += dy; log.info(`Mask position updated to (${this.x}, ${this.y})`); } + + setMask(image) { + + + const destX = -this.x; + const destY = -this.y; + + + this.maskCtx.clearRect(destX, destY, this.canvasInstance.width, this.canvasInstance.height); + + + this.maskCtx.drawImage(image, destX, destY); + + if (this.onStateChange) { + this.onStateChange(); + } + this.canvasInstance.render(); // Wymuś odświeżenie, aby zobaczyć zmianę + log.info(`MaskTool updated with a new mask image at correct canvas position (${destX}, ${destY}).`); + } } diff --git a/js/utils/ClipboardManager.js b/js/utils/ClipboardManager.js new file mode 100644 index 0000000..fc1d755 --- /dev/null +++ b/js/utils/ClipboardManager.js @@ -0,0 +1,510 @@ +import {createModuleLogger} from "./LoggerUtils.js"; +import {api} from "../../../scripts/api.js"; +import {ComfyApp} from "../../../scripts/app.js"; + +const log = createModuleLogger('ClipboardManager'); + +export class ClipboardManager { + constructor(canvas) { + this.canvas = canvas; + this.clipboardPreference = 'system'; // 'system', 'clipspace' + } + + /** + * Main paste handler that delegates to appropriate methods + * @param {string} addMode - The mode for adding the layer + * @param {string} preference - Clipboard preference ('system' or 'clipspace') + * @returns {Promise} - True if successful, false otherwise + */ + async handlePaste(addMode = 'mouse', preference = 'system') { + try { + log.info(`ClipboardManager handling paste with preference: ${preference}`); + + if (this.canvas.canvasLayers.internalClipboard.length > 0) { + log.info("Found layers in internal clipboard, pasting layers"); + this.canvas.canvasLayers.pasteLayers(); + return true; + } + + if (preference === 'clipspace') { + log.info("Attempting paste from ComfyUI Clipspace"); + const success = await this.tryClipspacePaste(addMode); + if (success) { + return true; + } + log.info("No image found in ComfyUI Clipspace"); + } + + log.info("Attempting paste from system clipboard"); + return await this.trySystemClipboardPaste(addMode); + + } catch (err) { + log.error("ClipboardManager paste operation failed:", err); + return false; + } + } + + /** + * Attempts to paste from ComfyUI Clipspace + * @param {string} addMode - The mode for adding the layer + * @returns {Promise} - True if successful, false otherwise + */ + async tryClipspacePaste(addMode) { + try { + log.info("Attempting to paste from ComfyUI Clipspace"); + const clipspaceResult = ComfyApp.pasteFromClipspace(this.canvas.node); + + if (this.canvas.node.imgs && this.canvas.node.imgs.length > 0) { + const clipspaceImage = this.canvas.node.imgs[0]; + if (clipspaceImage && clipspaceImage.src) { + log.info("Successfully got image from ComfyUI Clipspace"); + const img = new Image(); + img.onload = async () => { + await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode); + }; + img.src = clipspaceImage.src; + return true; + } + } + return false; + } catch (clipspaceError) { + log.warn("ComfyUI Clipspace paste failed:", clipspaceError); + return false; + } + } + + /** + * System clipboard paste - handles both image data and text paths + * @param {string} addMode - The mode for adding the layer + * @returns {Promise} - True if successful, false otherwise + */ + async trySystemClipboardPaste(addMode) { + log.info("ClipboardManager: Checking system clipboard for images and paths"); + + if (navigator.clipboard?.read) { + try { + const clipboardItems = await navigator.clipboard.read(); + + for (const item of clipboardItems) { + log.debug("Clipboard item types:", item.types); + + const imageType = item.types.find(type => type.startsWith('image/')); + if (imageType) { + try { + const blob = await item.getType(imageType); + const reader = new FileReader(); + reader.onload = (event) => { + const img = new Image(); + img.onload = async () => { + log.info("Successfully loaded image from system clipboard"); + await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode); + }; + img.src = event.target.result; + }; + reader.readAsDataURL(blob); + log.info("Found image data in system clipboard"); + return true; + } catch (error) { + log.debug("Error reading image data:", error); + } + } + + const textTypes = ['text/plain', 'text/uri-list']; + for (const textType of textTypes) { + if (item.types.includes(textType)) { + try { + const textBlob = await item.getType(textType); + const text = await textBlob.text(); + + if (this.isValidImagePath(text)) { + log.info("Found image path in clipboard:", text); + const success = await this.loadImageFromPath(text, addMode); + if (success) { + return true; + } + } + } catch (error) { + log.debug(`Error reading ${textType}:`, error); + } + } + } + } + } catch (error) { + log.debug("Modern clipboard API failed:", error); + } + } + + if (navigator.clipboard?.readText) { + try { + const text = await navigator.clipboard.readText(); + log.debug("Found text in clipboard:", text); + + if (text && this.isValidImagePath(text)) { + log.info("Found valid image path in clipboard:", text); + const success = await this.loadImageFromPath(text, addMode); + if (success) { + return true; + } + } + } catch (error) { + log.debug("Could not read text from clipboard:", error); + } + } + + log.debug("No images or valid image paths found in system clipboard"); + return false; + } + + + /** + * Validates if a text string is a valid image file path or URL + * @param {string} text - The text to validate + * @returns {boolean} - True if the text appears to be a valid image file path or URL + */ + isValidImagePath(text) { + if (!text || typeof text !== 'string') { + return false; + } + + text = text.trim(); + + if (!text) { + return false; + } + + if (text.startsWith('http://') || text.startsWith('https://') || text.startsWith('file://')) { + + try { + new URL(text); + log.debug("Detected valid URL:", text); + return true; + } catch (e) { + log.debug("Invalid URL format:", text); + return false; + } + } + + const imageExtensions = [ + '.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', + '.svg', '.tiff', '.tif', '.ico', '.avif' + ]; + + const hasImageExtension = imageExtensions.some(ext => + text.toLowerCase().endsWith(ext) + ); + + if (!hasImageExtension) { + log.debug("No valid image extension found in:", text); + return false; + } + + + const pathPatterns = [ + /^[a-zA-Z]:[\\\/]/, // Windows absolute path (C:\... or C:/...) + /^[\\\/]/, // Unix absolute path (/...) + /^\.{1,2}[\\\/]/, // Relative path (./... or ../...) + /^[^\\\/]*[\\\/]/ // Contains path separators + ]; + + const isValidPath = pathPatterns.some(pattern => pattern.test(text)) || + (!text.includes('/') && !text.includes('\\') && text.includes('.')); // Simple filename + + if (isValidPath) { + log.debug("Detected valid local file path:", text); + } else { + log.debug("Invalid local file path format:", text); + } + + return isValidPath; + } + + /** + * Attempts to load an image from a file path using simplified methods + * @param {string} filePath - The file path to load + * @param {string} addMode - The mode for adding the layer + * @returns {Promise} - True if successful, false otherwise + */ + async loadImageFromPath(filePath, addMode) { + + if (filePath.startsWith('http://') || filePath.startsWith('https://')) { + try { + const img = new Image(); + img.crossOrigin = 'anonymous'; + return new Promise((resolve) => { + img.onload = async () => { + log.info("Successfully loaded image from URL"); + await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode); + resolve(true); + }; + img.onerror = () => { + log.warn("Failed to load image from URL:", filePath); + resolve(false); + }; + img.src = filePath; + }); + } catch (error) { + log.warn("Error loading image from URL:", error); + return false; + } + } + + try { + log.info("Attempting to load local file via backend"); + const success = await this.loadFileViaBackend(filePath, addMode); + if (success) { + return true; + } + } catch (error) { + log.warn("Backend loading failed:", error); + } + + try { + log.info("Falling back to file picker"); + const success = await this.promptUserForFile(filePath, addMode); + if (success) { + return true; + } + } catch (error) { + log.warn("File picker failed:", error); + } + + this.showFilePathMessage(filePath); + return false; + } + + /** + * Loads a local file via the ComfyUI backend endpoint + * @param {string} filePath - The file path to load + * @param {string} addMode - The mode for adding the layer + * @returns {Promise} - True if successful, false otherwise + */ + async loadFileViaBackend(filePath, addMode) { + try { + log.info("Loading file via ComfyUI backend:", filePath); + + const response = await api.fetchApi("/ycnode/load_image_from_path", { + method: "POST", + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + file_path: filePath + }) + }); + + if (!response.ok) { + const errorData = await response.json(); + log.debug("Backend failed to load image:", errorData.error); + return false; + } + + const data = await response.json(); + + if (!data.success) { + log.debug("Backend returned error:", data.error); + return false; + } + + log.info("Successfully loaded image via ComfyUI backend:", filePath); + + const img = new Image(); + const success = await new Promise((resolve) => { + img.onload = async () => { + log.info("Successfully loaded image from backend response"); + await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode); + resolve(true); + }; + img.onerror = () => { + log.warn("Failed to load image from backend response"); + resolve(false); + }; + + img.src = data.image_data; + }); + + return success; + + } catch (error) { + log.debug("Error loading file via ComfyUI backend:", error); + return false; + } + } + + /** + * Prompts the user to select a file when a local path is detected + * @param {string} originalPath - The original file path from clipboard + * @param {string} addMode - The mode for adding the layer + * @returns {Promise} - True if successful, false otherwise + */ + async promptUserForFile(originalPath, addMode) { + return new Promise((resolve) => { + + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.accept = 'image/*'; + fileInput.style.display = 'none'; + + const fileName = originalPath.split(/[\\\/]/).pop(); + + fileInput.onchange = async (event) => { + const file = event.target.files[0]; + if (file && file.type.startsWith('image/')) { + try { + const reader = new FileReader(); + reader.onload = (e) => { + const img = new Image(); + img.onload = async () => { + log.info("Successfully loaded image from file picker"); + await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode); + resolve(true); + }; + img.onerror = () => { + log.warn("Failed to load selected image"); + resolve(false); + }; + img.src = e.target.result; + }; + reader.onerror = () => { + log.warn("Failed to read selected file"); + resolve(false); + }; + reader.readAsDataURL(file); + } catch (error) { + log.warn("Error processing selected file:", error); + resolve(false); + } + } else { + log.warn("Selected file is not an image"); + resolve(false); + } + + document.body.removeChild(fileInput); + }; + + fileInput.oncancel = () => { + log.info("File selection cancelled by user"); + document.body.removeChild(fileInput); + resolve(false); + }; + + this.showNotification(`Detected image path: ${fileName}. Please select the file to load it.`, 3000); + + document.body.appendChild(fileInput); + fileInput.click(); + }); + } + + /** + * Shows a message to the user about file path limitations + * @param {string} filePath - The file path that couldn't be loaded + */ + showFilePathMessage(filePath) { + const fileName = filePath.split(/[\\\/]/).pop(); + const message = `Cannot load local file directly due to browser security restrictions. File detected: ${fileName}`; + this.showNotification(message, 5000); + log.info("Showed file path limitation message to user"); + } + + /** + * Shows a helpful message when clipboard appears empty and offers file picker + * @param {string} addMode - The mode for adding the layer + */ + showEmptyClipboardMessage(addMode) { + const message = `Copied a file? Browser can't access file paths for security. Click here to select the file manually.`; + + const notification = document.createElement('div'); + notification.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + background: #2d5aa0; + color: white; + padding: 14px 18px; + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0,0,0,0.4); + z-index: 10001; + max-width: 320px; + font-size: 14px; + line-height: 1.4; + cursor: pointer; + border: 2px solid #4a7bc8; + transition: all 0.2s ease; + font-weight: 500; + `; + notification.innerHTML = ` +
+ 📁 + ${message} +
+
+ 💡 Tip: You can also drag & drop files directly onto the canvas +
+ `; + + notification.onmouseenter = () => { + notification.style.backgroundColor = '#3d6bb0'; + notification.style.borderColor = '#5a8bd8'; + notification.style.transform = 'translateY(-1px)'; + }; + notification.onmouseleave = () => { + notification.style.backgroundColor = '#2d5aa0'; + notification.style.borderColor = '#4a7bc8'; + notification.style.transform = 'translateY(0)'; + }; + + notification.onclick = async () => { + document.body.removeChild(notification); + try { + const success = await this.promptUserForFile('image_file.jpg', addMode); + if (success) { + log.info("Successfully loaded image via empty clipboard file picker"); + } + } catch (error) { + log.warn("Error with empty clipboard file picker:", error); + } + }; + + document.body.appendChild(notification); + + setTimeout(() => { + if (notification.parentNode) { + notification.parentNode.removeChild(notification); + } + }, 12000); + + log.info("Showed enhanced empty clipboard message with file picker option"); + } + + /** + * Shows a temporary notification to the user + * @param {string} message - The message to show + * @param {number} duration - Duration in milliseconds + */ + showNotification(message, duration = 3000) { + + const notification = document.createElement('div'); + notification.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + background: #333; + color: white; + padding: 12px 16px; + border-radius: 4px; + box-shadow: 0 2px 10px rgba(0,0,0,0.3); + z-index: 10001; + max-width: 300px; + font-size: 14px; + line-height: 1.4; + `; + notification.textContent = message; + + document.body.appendChild(notification); + + setTimeout(() => { + if (notification.parentNode) { + notification.parentNode.removeChild(notification); + } + }, duration); + } +} diff --git a/js/utils/CommonUtils.js b/js/utils/CommonUtils.js index 9b59295..7779b91 100644 --- a/js/utils/CommonUtils.js +++ b/js/utils/CommonUtils.js @@ -8,7 +8,7 @@ * @returns {string} UUID w formacie xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx */ export function generateUUID() { - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { const r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8); return v.toString(16); }); @@ -42,7 +42,7 @@ export function getSnapAdjustment(layer, gridSize = 64, snapThreshold = 10) { top: layer.y, bottom: layer.y + layer.height }; - + const x_adjustments = [ {type: 'x', delta: snapToGrid(layerEdges.left, gridSize) - layerEdges.left}, {type: 'x', delta: snapToGrid(layerEdges.right, gridSize) - layerEdges.right} @@ -52,17 +52,17 @@ export function getSnapAdjustment(layer, gridSize = 64, snapThreshold = 10) { {type: 'y', delta: snapToGrid(layerEdges.top, gridSize) - layerEdges.top}, {type: 'y', delta: snapToGrid(layerEdges.bottom, gridSize) - layerEdges.bottom} ]; - + x_adjustments.forEach(adj => adj.abs = Math.abs(adj.delta)); y_adjustments.forEach(adj => adj.abs = Math.abs(adj.delta)); - + const bestXSnap = x_adjustments .filter(adj => adj.abs < snapThreshold && adj.abs > 1e-9) .sort((a, b) => a.abs - b.abs)[0]; const bestYSnap = y_adjustments .filter(adj => adj.abs < snapThreshold && adj.abs > 1e-9) .sort((a, b) => a.abs - b.abs)[0]; - + return { dx: bestXSnap ? bestXSnap.delta : 0, dy: bestYSnap ? bestYSnap.delta : 0 @@ -145,7 +145,7 @@ export function getStateSignature(layers) { if (layer.image && layer.image.src) { sig.imageSrc = layer.image.src.substring(0, 100); // First 100 chars to avoid huge signatures } - + return sig; })); } @@ -179,7 +179,7 @@ export function debounce(func, wait, immediate) { */ export function throttle(func, limit) { let inThrottle; - return function(...args) { + return function (...args) { if (!inThrottle) { func.apply(this, args); inThrottle = true; @@ -241,7 +241,7 @@ export function createCanvas(width, height, contextType = '2d', contextOptions = if (width) canvas.width = width; if (height) canvas.height = height; const ctx = canvas.getContext(contextType, contextOptions); - return { canvas, ctx }; + return {canvas, ctx}; } /** @@ -284,5 +284,5 @@ export function generateUniqueFileName(baseName, nodeId) { */ export function isPointInRect(pointX, pointY, rectX, rectY, rectWidth, rectHeight) { return pointX >= rectX && pointX <= rectX + rectWidth && - pointY >= rectY && pointY <= rectY + rectHeight; + pointY >= rectY && pointY <= rectY + rectHeight; } diff --git a/js/utils/ImageUtils.js b/js/utils/ImageUtils.js index b5392a1..1f363eb 100644 --- a/js/utils/ImageUtils.js +++ b/js/utils/ImageUtils.js @@ -1,5 +1,6 @@ import {createModuleLogger} from "./LoggerUtils.js"; import {withErrorHandling, createValidationError} from "../ErrorHandler.js"; + const log = createModuleLogger('ImageUtils'); export function validateImageData(data) { @@ -114,7 +115,7 @@ export function applyMaskToImageData(imageData, maskData) { }; } -export const prepareImageForCanvas = withErrorHandling(function(inputImage) { +export const prepareImageForCanvas = withErrorHandling(function (inputImage) { log.info("Preparing image for canvas:", inputImage); if (Array.isArray(inputImage)) { @@ -122,7 +123,7 @@ export const prepareImageForCanvas = withErrorHandling(function(inputImage) { } if (!inputImage || !inputImage.shape || !inputImage.data) { - throw createValidationError("Invalid input image format", { inputImage }); + throw createValidationError("Invalid input image format", {inputImage}); } const shape = inputImage.shape; @@ -161,29 +162,29 @@ export const prepareImageForCanvas = withErrorHandling(function(inputImage) { * @param {HTMLImageElement|HTMLCanvasElement} image - Obraz do konwersji * @returns {Promise} Tensor z danymi obrazu */ -export const imageToTensor = withErrorHandling(async function(image) { +export const imageToTensor = withErrorHandling(async function (image) { if (!image) { throw createValidationError("Image is required"); } const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d'); - + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + canvas.width = image.width || image.naturalWidth; canvas.height = image.height || image.naturalHeight; - + ctx.drawImage(image, 0, 0); - + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); const data = new Float32Array(canvas.width * canvas.height * 3); - + for (let i = 0; i < imageData.data.length; i += 4) { const pixelIndex = i / 4; data[pixelIndex * 3] = imageData.data[i] / 255; data[pixelIndex * 3 + 1] = imageData.data[i + 1] / 255; data[pixelIndex * 3 + 2] = imageData.data[i + 2] / 255; } - + return { data: data, shape: [1, canvas.height, canvas.width, 3], @@ -197,33 +198,33 @@ export const imageToTensor = withErrorHandling(async function(image) { * @param {Object} tensor - Tensor z danymi obrazu * @returns {Promise} Obraz HTML */ -export const tensorToImage = withErrorHandling(async function(tensor) { +export const tensorToImage = withErrorHandling(async function (tensor) { if (!tensor || !tensor.data || !tensor.shape) { - throw createValidationError("Invalid tensor format", { tensor }); + throw createValidationError("Invalid tensor format", {tensor}); } const [, height, width, channels] = tensor.shape; const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d'); - + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + canvas.width = width; canvas.height = height; - + const imageData = ctx.createImageData(width, height); const data = tensor.data; - + for (let i = 0; i < width * height; i++) { const pixelIndex = i * 4; const tensorIndex = i * channels; - + imageData.data[pixelIndex] = Math.round(data[tensorIndex] * 255); imageData.data[pixelIndex + 1] = Math.round(data[tensorIndex + 1] * 255); imageData.data[pixelIndex + 2] = Math.round(data[tensorIndex + 2] * 255); imageData.data[pixelIndex + 3] = 255; } - + ctx.putImageData(imageData, 0, 0); - + return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); @@ -239,27 +240,27 @@ export const tensorToImage = withErrorHandling(async function(tensor) { * @param {number} maxHeight - Maksymalna wysokość * @returns {Promise} Przeskalowany obraz */ -export const resizeImage = withErrorHandling(async function(image, maxWidth, maxHeight) { +export const resizeImage = withErrorHandling(async function (image, maxWidth, maxHeight) { if (!image) { throw createValidationError("Image is required"); } const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d'); - + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + const originalWidth = image.width || image.naturalWidth; const originalHeight = image.height || image.naturalHeight; const scale = Math.min(maxWidth / originalWidth, maxHeight / originalHeight); const newWidth = Math.round(originalWidth * scale); const newHeight = Math.round(originalHeight * scale); - + canvas.width = newWidth; canvas.height = newHeight; ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; - + ctx.drawImage(image, 0, 0, newWidth, newHeight); - + return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); @@ -274,7 +275,7 @@ export const resizeImage = withErrorHandling(async function(image, maxWidth, max * @param {number} size - Rozmiar miniatury (kwadrat) * @returns {Promise} Miniatura */ -export const createThumbnail = withErrorHandling(async function(image, size = 128) { +export const createThumbnail = withErrorHandling(async function (image, size = 128) { return resizeImage(image, size, size); }, 'createThumbnail'); @@ -285,19 +286,19 @@ export const createThumbnail = withErrorHandling(async function(image, size = 12 * @param {number} quality - Jakość (0-1) dla formatów stratnych * @returns {string} Base64 string */ -export const imageToBase64 = withErrorHandling(function(image, format = 'png', quality = 0.9) { +export const imageToBase64 = withErrorHandling(function (image, format = 'png', quality = 0.9) { if (!image) { throw createValidationError("Image is required"); } const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d'); - + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + canvas.width = image.width || image.naturalWidth; canvas.height = image.height || image.naturalHeight; - + ctx.drawImage(image, 0, 0); - + const mimeType = `image/${format}`; return canvas.toDataURL(mimeType, quality); }, 'imageToBase64'); @@ -307,7 +308,7 @@ export const imageToBase64 = withErrorHandling(function(image, format = 'png', q * @param {string} base64 - Base64 string * @returns {Promise} Obraz */ -export const base64ToImage = withErrorHandling(function(base64) { +export const base64ToImage = withErrorHandling(function (base64) { if (!base64) { throw createValidationError("Base64 string is required"); } @@ -326,10 +327,10 @@ export const base64ToImage = withErrorHandling(function(base64) { * @returns {boolean} Czy obraz jest prawidłowy */ export function isValidImage(image) { - return image && - (image instanceof HTMLImageElement || image instanceof HTMLCanvasElement) && - image.width > 0 && - image.height > 0; + return image && + (image instanceof HTMLImageElement || image instanceof HTMLCanvasElement) && + image.width > 0 && + image.height > 0; } /** @@ -371,18 +372,18 @@ export function createImageFromSource(source) { * @param {string} color - Kolor tła (CSS color) * @returns {Promise} Pusty obraz */ -export const createEmptyImage = withErrorHandling(function(width, height, color = 'transparent') { +export const createEmptyImage = withErrorHandling(function (width, height, color = 'transparent') { const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d'); - + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + canvas.width = width; canvas.height = height; - + if (color !== 'transparent') { ctx.fillStyle = color; ctx.fillRect(0, 0, width, height); } - + return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); diff --git a/js/utils/LoggerUtils.js b/js/utils/LoggerUtils.js index d88afe6..2db0f6c 100644 --- a/js/utils/LoggerUtils.js +++ b/js/utils/LoggerUtils.js @@ -13,7 +13,7 @@ import {logger, LogLevel} from "../logger.js"; */ export function createModuleLogger(moduleName, level = LogLevel.NONE) { logger.setModuleLevel(moduleName, level); - + return { debug: (...args) => logger.debug(moduleName, ...args), info: (...args) => logger.info(moduleName, ...args), @@ -31,7 +31,7 @@ export function createAutoLogger(level = LogLevel.DEBUG) { const stack = new Error().stack; const match = stack.match(/\/([^\/]+)\.js/); const moduleName = match ? match[1] : 'Unknown'; - + return createModuleLogger(moduleName, level); } @@ -43,7 +43,7 @@ export function createAutoLogger(level = LogLevel.DEBUG) { * @returns {Function} Opakowana funkcja */ export function withErrorLogging(operation, log, operationName) { - return async function(...args) { + return async function (...args) { try { log.debug(`Starting ${operationName}`); const result = await operation.apply(this, args); @@ -62,10 +62,10 @@ export function withErrorLogging(operation, log, operationName) { * @param {string} methodName - Nazwa metody */ export function logMethod(log, methodName) { - return function(target, propertyKey, descriptor) { + return function (target, propertyKey, descriptor) { const originalMethod = descriptor.value; - - descriptor.value = async function(...args) { + + descriptor.value = async function (...args) { try { log.debug(`${methodName || propertyKey} started`); const result = await originalMethod.apply(this, args); @@ -76,7 +76,7 @@ export function logMethod(log, methodName) { throw error; } }; - + return descriptor; }; } diff --git a/js/utils/WebSocketManager.js b/js/utils/WebSocketManager.js index 6314bd2..1d261d2 100644 --- a/js/utils/WebSocketManager.js +++ b/js/utils/WebSocketManager.js @@ -45,7 +45,7 @@ class WebSocketManager { try { const data = JSON.parse(event.data); log.debug("Received message:", data); - + if (data.type === 'ack' && data.nodeId) { const callback = this.ackCallbacks.get(data.nodeId); if (callback) { @@ -130,7 +130,6 @@ class WebSocketManager { log.warn("WebSocket not open. Queuing message."); - this.messageQueue.push(message); if (!this.isConnecting) { this.connect(); @@ -147,7 +146,6 @@ class WebSocketManager { log.debug(`Flushing ${this.messageQueue.length} queued messages.`); - while (this.messageQueue.length > 0) { const message = this.messageQueue.shift(); this.socket.send(message); diff --git a/js/utils/mask_utils.js b/js/utils/mask_utils.js new file mode 100644 index 0000000..ff531bb --- /dev/null +++ b/js/utils/mask_utils.js @@ -0,0 +1,174 @@ +import {createModuleLogger} from "./LoggerUtils.js"; + +const log = createModuleLogger('MaskUtils'); + +export function new_editor(app) { + if (!app) return false; + return app.ui.settings.getSettingValue('Comfy.MaskEditor.UseNewEditor') +} + +function get_mask_editor_element(app) { + return new_editor(app) ? document.getElementById('maskEditor') : document.getElementById('maskCanvas')?.parentElement +} + +export function mask_editor_showing(app) { + const editor = get_mask_editor_element(app); + return editor && editor.style.display !== "none"; +} + +export function hide_mask_editor() { + if (mask_editor_showing()) document.getElementById('maskEditor').style.display = 'none' +} + +function get_mask_editor_cancel_button(app) { + + const cancelButton = document.getElementById("maskEditor_topBarCancelButton"); + if (cancelButton) { + log.debug("Found cancel button by ID: maskEditor_topBarCancelButton"); + return cancelButton; + } + + const cancelSelectors = [ + 'button[onclick*="cancel"]', + 'button[onclick*="Cancel"]', + 'input[value="Cancel"]' + ]; + + for (const selector of cancelSelectors) { + try { + const button = document.querySelector(selector); + if (button) { + log.debug("Found cancel button with selector:", selector); + return button; + } + } catch (e) { + log.warn("Invalid selector:", selector, e); + } + } + + const allButtons = document.querySelectorAll('button, input[type="button"]'); + for (const button of allButtons) { + const text = button.textContent || button.value || ''; + if (text.toLowerCase().includes('cancel')) { + log.debug("Found cancel button by text content:", text); + return button; + } + } + + const editorElement = get_mask_editor_element(app); + if (editorElement) { + return editorElement?.parentElement?.lastChild?.childNodes[2]; + } + + return null; +} + +function get_mask_editor_save_button(app) { + if (document.getElementById("maskEditor_topBarSaveButton")) return document.getElementById("maskEditor_topBarSaveButton") + return get_mask_editor_element(app)?.parentElement?.lastChild?.childNodes[2] +} + +export function mask_editor_listen_for_cancel(app, callback) { + + let attempts = 0; + const maxAttempts = 50; // 5 sekund + + const findAndAttachListener = () => { + attempts++; + const cancel_button = get_mask_editor_cancel_button(app); + + if (cancel_button && !cancel_button.filter_listener_added) { + log.info("Cancel button found, attaching listener"); + cancel_button.addEventListener('click', callback); + cancel_button.filter_listener_added = true; + return true; // Znaleziono i podłączono + } else if (attempts < maxAttempts) { + + setTimeout(findAndAttachListener, 100); + } else { + log.warn("Could not find cancel button after", maxAttempts, "attempts"); + + const globalClickHandler = (event) => { + const target = event.target; + const text = target.textContent || target.value || ''; + if (text.toLowerCase().includes('cancel') || + target.id.toLowerCase().includes('cancel') || + target.className.toLowerCase().includes('cancel')) { + log.info("Cancel detected via global click handler"); + callback(); + document.removeEventListener('click', globalClickHandler); + } + }; + + document.addEventListener('click', globalClickHandler); + log.debug("Added global click handler for cancel detection"); + } + }; + + findAndAttachListener(); +} + +export function press_maskeditor_save(app) { + get_mask_editor_save_button(app)?.click() +} + +export function press_maskeditor_cancel(app) { + get_mask_editor_cancel_button(app)?.click() +} + +/** + * Uruchamia mask editor z predefiniowaną maską + * @param {Object} canvasInstance - Instancja Canvas + * @param {Image|HTMLCanvasElement} maskImage - Obraz maski do nałożenia + * @param {boolean} sendCleanImage - Czy wysłać czysty obraz (bez istniejącej maski) + */ +export function start_mask_editor_with_predefined_mask(canvasInstance, maskImage, sendCleanImage = true) { + if (!canvasInstance || !maskImage) { + log.error('Canvas instance and mask image are required'); + return; + } + + canvasInstance.startMaskEditor(maskImage, sendCleanImage); +} + +/** + * Uruchamia mask editor z automatycznym zachowaniem (czysty obraz + istniejąca maska) + * @param {Object} canvasInstance - Instancja Canvas + */ +export function start_mask_editor_auto(canvasInstance) { + if (!canvasInstance) { + log.error('Canvas instance is required'); + return; + } + + + canvasInstance.startMaskEditor(); +} + +/** + * Tworzy maskę z obrazu dla użycia w mask editorze + * @param {string} imageSrc - Źródło obrazu (URL lub data URL) + * @returns {Promise} Promise zwracający obiekt Image + */ +export function create_mask_from_image_src(imageSrc) { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = reject; + img.src = imageSrc; + }); +} + +/** + * Konwertuje canvas do Image dla użycia jako maska + * @param {HTMLCanvasElement} canvas - Canvas do konwersji + * @returns {Promise} Promise zwracający obiekt Image + */ +export function canvas_to_mask_image(canvas) { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = reject; + img.src = canvas.toDataURL(); + }); +}