diff --git a/js/BatchPreviewManager.js b/js/BatchPreviewManager.js index 2c74389..9a3ceee 100644 --- a/js/BatchPreviewManager.js +++ b/js/BatchPreviewManager.js @@ -1,7 +1,5 @@ -import {createModuleLogger} from "./utils/LoggerUtils.js"; - +import { createModuleLogger } from "./utils/LoggerUtils.js"; const log = createModuleLogger('BatchPreviewManager'); - export class BatchPreviewManager { constructor(canvas, initialPosition = { x: 0, y: 0 }, generationArea = null) { this.canvas = canvas; @@ -9,33 +7,25 @@ export class BatchPreviewManager { this.layers = []; this.currentIndex = 0; this.element = null; + this.counterElement = null; this.uiInitialized = false; this.maskWasVisible = false; - - // Position in canvas world coordinates this.worldX = initialPosition.x; this.worldY = initialPosition.y; this.isDragging = false; - this.generationArea = generationArea; // Store the generation area + this.generationArea = generationArea; } - updateScreenPosition(viewport) { - if (!this.active || !this.element) return; - - // Translate world coordinates to screen coordinates + if (!this.active || !this.element) + return; const screenX = (this.worldX - viewport.x) * viewport.zoom; const screenY = (this.worldY - viewport.y) * viewport.zoom; - - // We can also scale the menu with zoom, but let's keep it constant for now for readability - const scale = 1; // viewport.zoom; - - // Use transform for performance + const scale = 1; this.element.style.transform = `translate(${screenX}px, ${screenY}px) scale(${scale})`; } - _createUI() { - if (this.uiInitialized) return; - + if (this.uiInitialized) + return; this.element = document.createElement('div'); this.element.id = 'layerforge-batch-preview'; this.element.style.cssText = ` @@ -56,65 +46,53 @@ export class BatchPreviewManager { cursor: move; user-select: none; `; - this.element.addEventListener('mousedown', (e) => { - if (e.target.tagName === 'BUTTON') return; - + if (e.target.tagName === 'BUTTON') + return; e.preventDefault(); e.stopPropagation(); - this.isDragging = true; - const handleMouseMove = (moveEvent) => { if (this.isDragging) { - // Convert screen pixel movement to world coordinate movement const deltaX = moveEvent.movementX / this.canvas.viewport.zoom; const deltaY = moveEvent.movementY / this.canvas.viewport.zoom; - this.worldX += deltaX; this.worldY += deltaY; - // The render loop will handle updating the screen position, but we need to trigger it. this.canvas.render(); } }; - const handleMouseUp = () => { this.isDragging = false; document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; - document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); }); - const prevButton = this._createButton('◀', 'Previous'); // Left arrow const nextButton = this._createButton('▶', 'Next'); // Right arrow const confirmButton = this._createButton('✔', 'Confirm'); // Checkmark - const cancelButton = this._createButton('✖', 'Cancel All'); // X mark - const closeButton = this._createButton('➲', 'Close'); // Door icon - + const cancelButton = this._createButton('✖', 'Cancel All'); + const closeButton = this._createButton('➲', 'Close'); this.counterElement = document.createElement('span'); this.counterElement.style.minWidth = '40px'; this.counterElement.style.textAlign = 'center'; this.counterElement.style.fontWeight = 'bold'; - prevButton.onclick = () => this.navigate(-1); nextButton.onclick = () => this.navigate(1); confirmButton.onclick = () => this.confirm(); cancelButton.onclick = () => this.cancelAndRemoveAll(); closeButton.onclick = () => this.hide(); - this.element.append(prevButton, this.counterElement, nextButton, confirmButton, cancelButton, closeButton); - if (this.canvas.canvas.parentNode) { - this.canvas.canvas.parentNode.appendChild(this.element); - } else { + if (this.canvas.canvas.parentElement) { + this.canvas.canvas.parentElement.appendChild(this.element); + } + else { log.error("Could not find parent node to attach batch preview UI."); } this.uiInitialized = true; } - _createButton(innerHTML, title) { const button = document.createElement('button'); button.innerHTML = innerHTML; @@ -136,14 +114,11 @@ export class BatchPreviewManager { button.onmouseout = () => button.style.background = '#555'; return button; } - show(layers) { if (!layers || layers.length <= 1) { return; } - this._createUI(); - // Auto-hide mask logic this.maskWasVisible = this.canvas.maskTool.isOverlayVisible; if (this.maskWasVisible) { @@ -155,103 +130,83 @@ export class BatchPreviewManager { } this.canvas.render(); } - log.info(`Showing batch preview for ${layers.length} layers.`); this.layers = layers; this.currentIndex = 0; - - // Make the element visible BEFORE calculating its size - this.element.style.display = 'flex'; + if (this.element) { + this.element.style.display = 'flex'; + } this.active = true; - - // Now that it's visible, we can get its dimensions and adjust the position. - const menuWidthInWorld = this.element.offsetWidth / this.canvas.viewport.zoom; - const paddingInWorld = 20 / this.canvas.viewport.zoom; - - this.worldX -= menuWidthInWorld / 2; // Center horizontally - this.worldY += paddingInWorld; // Add padding below the output area - + if (this.element) { + const menuWidthInWorld = this.element.offsetWidth / this.canvas.viewport.zoom; + const paddingInWorld = 20 / this.canvas.viewport.zoom; + this.worldX -= menuWidthInWorld / 2; + this.worldY += paddingInWorld; + } this._update(); } - hide() { log.info('Hiding batch preview.'); if (this.element) { this.element.remove(); } this.active = false; - const index = this.canvas.batchPreviewManagers.indexOf(this); if (index > -1) { this.canvas.batchPreviewManagers.splice(index, 1); } - - // Trigger a final render to ensure the generation area outline is removed this.canvas.render(); - - // Restore mask visibility if it was hidden by this manager if (this.maskWasVisible && !this.canvas.maskTool.isOverlayVisible) { this.canvas.maskTool.toggleOverlayVisibility(); - const toggleBtn = document.getElementById(`toggle-mask-btn-${this.canvas.node.id}`); + const toggleBtn = document.getElementById(`toggle-mask-btn-${String(this.canvas.node.id)}`); if (toggleBtn) { toggleBtn.classList.add('primary'); toggleBtn.textContent = "Show Mask"; } } - this.maskWasVisible = false; // Reset state - - // Make all layers visible again upon closing - this.canvas.layers.forEach(l => l.visible = true); + this.maskWasVisible = false; + this.canvas.layers.forEach((l) => l.visible = true); this.canvas.render(); } - navigate(direction) { this.currentIndex += direction; if (this.currentIndex < 0) { this.currentIndex = this.layers.length - 1; - } else if (this.currentIndex >= this.layers.length) { + } + else if (this.currentIndex >= this.layers.length) { this.currentIndex = 0; } this._update(); } - confirm() { const layerToKeep = this.layers[this.currentIndex]; log.info(`Confirming selection: Keeping layer ${layerToKeep.id}.`); - - const layersToDelete = this.layers.filter(l => l.id !== layerToKeep.id); - const layerIdsToDelete = layersToDelete.map(l => l.id); - + const layersToDelete = this.layers.filter((l) => l.id !== layerToKeep.id); + const layerIdsToDelete = layersToDelete.map((l) => l.id); this.canvas.removeLayersByIds(layerIdsToDelete); log.info(`Deleted ${layersToDelete.length} other layers.`); - this.hide(); } - cancelAndRemoveAll() { log.info('Cancel clicked. Removing all new layers.'); - - const layerIdsToDelete = this.layers.map(l => l.id); + const layerIdsToDelete = this.layers.map((l) => l.id); this.canvas.removeLayersByIds(layerIdsToDelete); log.info(`Deleted all ${layerIdsToDelete.length} new layers.`); - this.hide(); } - _update() { - this.counterElement.textContent = `${this.currentIndex + 1} / ${this.layers.length}`; + if (this.counterElement) { + this.counterElement.textContent = `${this.currentIndex + 1} / ${this.layers.length}`; + } this._focusOnLayer(this.layers[this.currentIndex]); } - _focusOnLayer(layer) { - if (!layer) return; + if (!layer) + return; log.debug(`Focusing on layer ${layer.id}`); - // Move the selected layer to the top of the layer stack this.canvas.canvasLayers.moveLayers([layer], { toIndex: 0 }); - this.canvas.updateSelection([layer]); - // Render is called by moveLayers, but we call it again to be safe this.canvas.render(); } diff --git a/js/Canvas.js b/js/Canvas.js index 56bdd48..85042b3 100644 --- a/js/Canvas.js +++ b/js/Canvas.js @@ -1,33 +1,29 @@ -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"; -import {CanvasInteractions} from "./CanvasInteractions.js"; -import {CanvasLayers} from "./CanvasLayers.js"; -import {CanvasLayersPanel} from "./CanvasLayersPanel.js"; -import {CanvasRenderer} from "./CanvasRenderer.js"; -import {CanvasIO} from "./CanvasIO.js"; -import {ImageReferenceManager} from "./ImageReferenceManager.js"; -import {BatchPreviewManager} from "./BatchPreviewManager.js"; -import {createModuleLogger} from "./utils/LoggerUtils.js"; +// @ts-ignore +import { api } from "../../scripts/api.js"; +import { MaskTool } from "./MaskTool.js"; +import { CanvasState } from "./CanvasState.js"; +import { CanvasInteractions } from "./CanvasInteractions.js"; +import { CanvasLayers } from "./CanvasLayers.js"; +import { CanvasLayersPanel } from "./CanvasLayersPanel.js"; +import { CanvasRenderer } from "./CanvasRenderer.js"; +import { CanvasIO } from "./CanvasIO.js"; +import { ImageReferenceManager } from "./ImageReferenceManager.js"; +import { BatchPreviewManager } from "./BatchPreviewManager.js"; +import { createModuleLogger } from "./utils/LoggerUtils.js"; import { debounce } from "./utils/CommonUtils.js"; -import {CanvasMask} from "./CanvasMask.js"; -import {CanvasSelection} from "./CanvasSelection.js"; - +import { CanvasMask } from "./CanvasMask.js"; +import { CanvasSelection } from "./CanvasSelection.js"; const useChainCallback = (original, next) => { - if (original === undefined || original === null) { - return next; - } - return function(...args) { - const originalReturn = original.apply(this, args); - const nextReturn = next.apply(this, args); - return nextReturn === undefined ? originalReturn : nextReturn; - }; + if (original === undefined || original === null) { + return next; + } + return function (...args) { + const originalReturn = original.apply(this, args); + const nextReturn = next.apply(this, args); + return nextReturn === undefined ? originalReturn : nextReturn; + }; }; - const log = createModuleLogger('Canvas'); - /** * Canvas - Fasada dla systemu rysowania * @@ -41,65 +37,72 @@ export class Canvas { this.node = node; this.widget = widget; this.canvas = document.createElement('canvas'); - this.ctx = this.canvas.getContext('2d', {willReadFrequently: true}); + const ctx = this.canvas.getContext('2d', { willReadFrequently: true }); + if (!ctx) + throw new Error("Could not create canvas context"); + this.ctx = ctx; this.width = 512; this.height = 512; this.layers = []; - this.onStateChange = callbacks.onStateChange || null; - this.lastMousePosition = {x: 0, y: 0}; - + this.onStateChange = callbacks.onStateChange; + this.onHistoryChange = callbacks.onHistoryChange; + this.lastMousePosition = { x: 0, y: 0 }; this.viewport = { x: -(this.width / 4), y: -(this.height / 4), zoom: 0.8, }; - this.offscreenCanvas = document.createElement('canvas'); this.offscreenCtx = this.offscreenCanvas.getContext('2d', { alpha: false }); - this.dataInitialized = false; this.pendingDataCheck = null; this.imageCache = new Map(); - - this._initializeModules(callbacks); - - this._setupCanvas(); - + this.requestSaveState = () => { }; + this.maskTool = new MaskTool(this, { onStateChange: this.onStateChange }); + this.canvasMask = new CanvasMask(this); + this.canvasState = new CanvasState(this); + this.canvasSelection = new CanvasSelection(this); + this.canvasInteractions = new CanvasInteractions(this); + this.canvasLayers = new CanvasLayers(this); + this.canvasLayersPanel = new CanvasLayersPanel(this); + this.canvasRenderer = new CanvasRenderer(this); + this.canvasIO = new CanvasIO(this); + this.imageReferenceManager = new ImageReferenceManager(this); + this.batchPreviewManagers = []; + this.pendingBatchContext = null; this.interaction = this.canvasInteractions.interaction; - + this.previewVisible = false; + this.isMouseOver = false; + this._initializeModules(); + this._setupCanvas(); log.debug('Canvas widget element:', this.node); log.info('Canvas initialized', { nodeId: this.node.id, - dimensions: {width: this.width, height: this.height}, + 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); + const widget = node.widgets.find((w) => w.name === name); if (widget) { resolve(widget); - } else if (Date.now() - startTime > timeout) { + } + else if (Date.now() - startTime > timeout) { reject(new Error(`Widget "${name}" not found within timeout.`)); - } else { + } + else { setTimeout(check, interval); } }; - check(); }); } - - /** * Kontroluje widoczność podglądu canvas * @param {boolean} visible - Czy podgląd ma być widoczny @@ -107,11 +110,9 @@ export class Canvas { 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; @@ -125,7 +126,8 @@ export class Canvas { imagePreviewWidget.computeSize = function () { return [0, 250]; // Szerokość 0 (auto), wysokość 250 }; - } else { + } + else { if (imagePreviewWidget.options) { imagePreviewWidget.options.hidden = true; } @@ -135,44 +137,27 @@ export class Canvas { if ('hidden' in imagePreviewWidget) { imagePreviewWidget.hidden = true; } - imagePreviewWidget.computeSize = function () { return [0, 0]; // Szerokość 0, wysokość 0 }; } - this.render() - } else { + this.render(); + } + else { log.warn("$$canvas-image-preview widget not found in Canvas.js"); } } - /** * Inicjalizuje moduły systemu canvas * @private */ - _initializeModules(callbacks) { + _initializeModules() { log.debug('Initializing Canvas modules...'); - // Stwórz opóźnioną wersję funkcji zapisu stanu - this.requestSaveState = debounce(this.saveState.bind(this), 500); - + this.requestSaveState = debounce(() => this.saveState(), 500); this._addAutoRefreshToggle(); - this.maskTool = new MaskTool(this, {onStateChange: this.onStateChange}); - this.canvasMask = new CanvasMask(this); - this.canvasState = new CanvasState(this); - this.canvasSelection = new CanvasSelection(this); - this.canvasInteractions = new CanvasInteractions(this); - this.canvasLayers = new CanvasLayers(this); - this.canvasLayersPanel = new CanvasLayersPanel(this); - this.canvasRenderer = new CanvasRenderer(this); - this.canvasIO = new CanvasIO(this); - this.imageReferenceManager = new ImageReferenceManager(this); - this.batchPreviewManagers = []; - this.pendingBatchContext = null; - log.debug('Canvas modules initialized successfully'); } - /** * Konfiguruje podstawowe właściwości canvas * @private @@ -181,14 +166,11 @@ export class Canvas { this.initCanvas(); this.canvasInteractions.setupEventListeners(); this.canvasIO.initNodeData(); - - this.layers = this.layers.map(layer => ({ + this.layers = this.layers.map((layer) => ({ ...layer, opacity: 1 })); } - - /** * Ładuje stan canvas z bazy danych */ @@ -201,24 +183,21 @@ export class Canvas { } this.saveState(); this.render(); - // Dodaj to wywołanie, aby panel renderował się po załadowaniu stanu if (this.canvasLayersPanel) { this.canvasLayersPanel.onLayersChanged(); } } - /** * 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}); + log.debug('Saving canvas state', { replaceLast, layersCount: this.layers.length }); this.canvasState.saveState(replaceLast); this.incrementOperationCount(); this._notifyStateChange(); } - /** * Cofnij ostatnią operację */ @@ -226,21 +205,16 @@ export class Canvas { log.info('Performing undo operation'); const historyInfo = this.canvasState.getHistoryInfo(); log.debug('History state before undo:', historyInfo); - this.canvasState.undo(); this.incrementOperationCount(); this._notifyStateChange(); - // Powiadom panel warstw o zmianie stanu warstw if (this.canvasLayersPanel) { this.canvasLayersPanel.onLayersChanged(); this.canvasLayersPanel.onSelectionChanged(); } - log.debug('Undo completed, layers count:', this.layers.length); } - - /** * Ponów cofniętą operację */ @@ -248,27 +222,22 @@ export class Canvas { log.info('Performing redo operation'); const historyInfo = this.canvasState.getHistoryInfo(); log.debug('History state before redo:', historyInfo); - this.canvasState.redo(); this.incrementOperationCount(); this._notifyStateChange(); - // Powiadom panel warstw o zmianie stanu warstw if (this.canvasLayersPanel) { this.canvasLayersPanel.onLayersChanged(); this.canvasLayersPanel.onSelectionChanged(); } - log.debug('Redo completed, layers count:', this.layers.length); } - /** * Renderuje canvas */ render() { this.canvasRenderer.render(); } - /** * Dodaje warstwę z obrazem * @param {Image} image - Obraz do dodania @@ -277,49 +246,40 @@ export class Canvas { */ async addLayer(image, layerProps = {}, addMode = 'default') { const result = await this.canvasLayers.addLayerWithImage(image, layerProps, addMode); - // Powiadom panel warstw o dodaniu nowej warstwy if (this.canvasLayersPanel) { this.canvasLayersPanel.onLayersChanged(); } - return result; } - /** * Usuwa wybrane warstwy */ removeLayersByIds(layerIds) { - if (!layerIds || layerIds.length === 0) return; - + if (!layerIds || layerIds.length === 0) + return; const initialCount = this.layers.length; this.saveState(); - this.layers = this.layers.filter(l => !layerIds.includes(l.id)); - + this.layers = this.layers.filter((l) => !layerIds.includes(l.id)); // If the current selection was part of the removal, clear it - const newSelection = this.canvasSelection.selectedLayers.filter(l => !layerIds.includes(l.id)); + const newSelection = this.canvasSelection.selectedLayers.filter((l) => !layerIds.includes(l.id)); this.canvasSelection.updateSelection(newSelection); - this.render(); this.saveState(); - if (this.canvasLayersPanel) { this.canvasLayersPanel.onLayersChanged(); } log.info(`Removed ${initialCount - this.layers.length} layers by ID.`); } - removeSelectedLayers() { return this.canvasSelection.removeSelectedLayers(); } - /** * Duplikuje zaznaczone warstwy (w pamięci, bez zapisu stanu) */ duplicateSelectedLayers() { return this.canvasSelection.duplicateSelectedLayers(); } - /** * Aktualizuje zaznaczenie warstw i powiadamia wszystkie komponenty. * To jest "jedyne źródło prawdy" o zmianie zaznaczenia. @@ -328,14 +288,12 @@ export class Canvas { updateSelection(newSelection) { return this.canvasSelection.updateSelection(newSelection); } - /** * Logika aktualizacji zaznaczenia, wywoływana przez panel warstw. */ updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index) { return this.canvasSelection.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index); } - /** * Zmienia rozmiar obszaru wyjściowego * @param {number} width - Nowa szerokość @@ -345,32 +303,27 @@ export class Canvas { 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(); } - _addAutoRefreshToggle() { let autoRefreshEnabled = false; let lastExecutionStartTime = 0; - const handleExecutionStart = () => { if (autoRefreshEnabled) { lastExecutionStartTime = Date.now(); @@ -393,62 +346,40 @@ export class Canvas { this.render(); // Trigger render to show the pending outline immediately } }; - const handleExecutionSuccess = async () => { if (autoRefreshEnabled) { log.info('Auto-refresh triggered, importing latest images.'); - if (!this.pendingBatchContext) { log.warn("execution_start did not fire, cannot process batch. Awaiting next execution."); return; } - // Use the captured output area for image import - const newLayers = await this.canvasIO.importLatestImages( - lastExecutionStartTime, - this.pendingBatchContext.outputArea - ); - + const newLayers = await this.canvasIO.importLatestImages(lastExecutionStartTime, this.pendingBatchContext.outputArea); if (newLayers && newLayers.length > 1) { - const newManager = new BatchPreviewManager( - this, - this.pendingBatchContext.spawnPosition, - this.pendingBatchContext.outputArea - ); + const newManager = new BatchPreviewManager(this, this.pendingBatchContext.spawnPosition, this.pendingBatchContext.outputArea); this.batchPreviewManagers.push(newManager); newManager.show(newLayers); } - // Consume the context this.pendingBatchContext = null; // Final render to clear the outline if it was the last one this.render(); } }; - - this.node.addWidget( - 'toggle', - 'Auto-refresh after generation', - false, - (value) => { - autoRefreshEnabled = value; - log.debug('Auto-refresh toggled:', value); - }, { - serialize: false - } - ); - + this.node.addWidget('toggle', 'Auto-refresh after generation', false, (value) => { + autoRefreshEnabled = value; + log.debug('Auto-refresh toggled:', value); + }, { + serialize: false + }); api.addEventListener('execution_start', handleExecutionStart); api.addEventListener('execution_success', handleExecutionSuccess); - this.node.onRemoved = useChainCallback(this.node.onRemoved, () => { log.info('Node removed, cleaning up auto-refresh listeners.'); api.removeEventListener('execution_start', handleExecutionStart); api.removeEventListener('execution_success', handleExecutionSuccess); }); } - - /** * Uruchamia edytor masek * @param {Image|HTMLCanvasElement|null} predefinedMask - Opcjonalna maska do nałożenia po otwarciu editora @@ -457,8 +388,6 @@ export class Canvas { async startMaskEditor(predefinedMask = null, sendCleanImage = true) { return this.canvasMask.startMaskEditor(predefinedMask, sendCleanImage); } - - /** * Inicjalizuje podstawowe właściwości canvas */ @@ -473,29 +402,24 @@ export class Canvas { this.canvas.tabIndex = 0; this.canvas.style.outline = 'none'; } - /** * Pobiera współrzędne myszy w układzie świata * @param {MouseEvent} e - Zdarzenie myszy */ getMouseWorldCoordinates(e) { const rect = this.canvas.getBoundingClientRect(); - const mouseX_DOM = e.clientX - rect.left; const mouseY_DOM = e.clientY - rect.top; - + if (!this.offscreenCanvas) + throw new Error("Offscreen canvas not initialized"); const scaleX = this.offscreenCanvas.width / rect.width; const scaleY = this.offscreenCanvas.height / rect.height; - const mouseX_Buffer = mouseX_DOM * scaleX; const mouseY_Buffer = mouseY_DOM * scaleY; - const worldX = (mouseX_Buffer / this.viewport.zoom) + this.viewport.x; const worldY = (mouseY_Buffer / this.viewport.zoom) + this.viewport.y; - - return {x: worldX, y: worldY}; + return { x: worldX, y: worldY }; } - /** * Pobiera współrzędne myszy w układzie widoku * @param {MouseEvent} e - Zdarzenie myszy @@ -504,23 +428,18 @@ export class Canvas { const rect = this.canvas.getBoundingClientRect(); const mouseX_DOM = e.clientX - rect.left; const mouseY_DOM = e.clientY - rect.top; - const scaleX = this.canvas.width / rect.width; const scaleY = this.canvas.height / rect.height; - const mouseX_Canvas = mouseX_DOM * scaleX; const mouseY_Canvas = mouseY_DOM * scaleY; - - return {x: mouseX_Canvas, y: mouseY_Canvas}; + return { x: mouseX_Canvas, y: mouseY_Canvas }; } - /** * Aktualizuje zaznaczenie po operacji historii */ updateSelectionAfterHistory() { return this.canvasSelection.updateSelectionAfterHistory(); } - /** * Aktualizuje przyciski historii */ @@ -533,7 +452,6 @@ export class Canvas { }); } } - /** * Zwiększa licznik operacji (dla garbage collection) */ @@ -542,7 +460,6 @@ export class Canvas { this.imageReferenceManager.incrementOperationCount(); } } - /** * Czyści zasoby canvas */ @@ -552,7 +469,6 @@ export class Canvas { } log.info("Canvas destroyed"); } - /** * Powiadamia o zmianie stanu * @private diff --git a/js/CanvasIO.js b/js/CanvasIO.js index 793a153..7279a9b 100644 --- a/js/CanvasIO.js +++ b/js/CanvasIO.js @@ -1,76 +1,72 @@ -import {createCanvas} from "./utils/CommonUtils.js"; -import {createModuleLogger} from "./utils/LoggerUtils.js"; -import {webSocketManager} from "./utils/WebSocketManager.js"; - +import { createCanvas } from "./utils/CommonUtils.js"; +import { createModuleLogger } from "./utils/LoggerUtils.js"; +import { webSocketManager } from "./utils/WebSocketManager.js"; const log = createModuleLogger('CanvasIO'); - export class CanvasIO { constructor(canvas) { this.canvas = canvas; this._saveInProgress = null; } - async saveToServer(fileName, outputMode = 'disk') { if (outputMode === 'disk') { if (!window.canvasSaveStates) { window.canvasSaveStates = new Map(); } - const nodeId = this.canvas.node.id; const saveKey = `${nodeId}_${fileName}`; if (this._saveInProgress || window.canvasSaveStates.get(saveKey)) { log.warn(`Save already in progress for node ${nodeId}, waiting...`); return this._saveInProgress || window.canvasSaveStates.get(saveKey); } - log.info(`Starting saveToServer (disk) with fileName: ${fileName} for node: ${nodeId}`); this._saveInProgress = this._performSave(fileName, outputMode); window.canvasSaveStates.set(saveKey, this._saveInProgress); - try { return await this._saveInProgress; - } finally { + } + finally { this._saveInProgress = null; window.canvasSaveStates.delete(saveKey); log.debug(`Save completed for node ${nodeId}, lock released`); } - } else { - + } + else { log.info(`Starting saveToServer (RAM) for node: ${this.canvas.node.id}`); return this._performSave(fileName, outputMode); } } - async _performSave(fileName, outputMode) { if (this.canvas.layers.length === 0) { log.warn(`Node ${this.canvas.node.id} has no layers, creating empty canvas`); return Promise.resolve(true); } - await this.canvas.canvasState.saveStateToDB(true); + await this.canvas.canvasState.saveStateToDB(); const nodeId = this.canvas.node.id; const delay = (nodeId % 10) * 50; if (delay > 0) { await new Promise(resolve => setTimeout(resolve, delay)); } - return new Promise((resolve) => { - const {canvas: tempCanvas, ctx: tempCtx} = createCanvas(this.canvas.width, this.canvas.height); - const {canvas: maskCanvas, ctx: maskCtx} = createCanvas(this.canvas.width, this.canvas.height); - + const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(this.canvas.width, this.canvas.height); + const { canvas: maskCanvas, ctx: maskCtx } = createCanvas(this.canvas.width, this.canvas.height); const visibilityCanvas = document.createElement('canvas'); visibilityCanvas.width = this.canvas.width; visibilityCanvas.height = this.canvas.height; - const visibilityCtx = visibilityCanvas.getContext('2d', {alpha: true}); + const visibilityCtx = visibilityCanvas.getContext('2d', { alpha: true }); + if (!visibilityCtx) + throw new Error("Could not create visibility context"); + if (!maskCtx) + throw new Error("Could not create mask context"); + if (!tempCtx) + throw new Error("Could not create temp context"); maskCtx.fillStyle = '#ffffff'; maskCtx.fillRect(0, 0, this.canvas.width, this.canvas.height); - log.debug(`Canvas contexts created, starting layer rendering`); const sortedLayers = this.canvas.layers.sort((a, b) => a.zIndex - b.zIndex); log.debug(`Processing ${sortedLayers.length} layers in order`); sortedLayers.forEach((layer, index) => { log.debug(`Processing layer ${index}: zIndex=${layer.zIndex}, size=${layer.width}x${layer.height}, pos=(${layer.x},${layer.y})`); log.debug(`Layer ${index}: blendMode=${layer.blendMode || 'normal'}, opacity=${layer.opacity !== undefined ? layer.opacity : 1}`); - tempCtx.save(); tempCtx.globalCompositeOperation = layer.blendMode || 'normal'; tempCtx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1; @@ -78,7 +74,6 @@ export class CanvasIO { tempCtx.rotate(layer.rotation * Math.PI / 180); tempCtx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height); tempCtx.restore(); - log.debug(`Layer ${index} rendered successfully`); visibilityCtx.save(); visibilityCtx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2); @@ -94,48 +89,35 @@ export class CanvasIO { maskData.data[i] = maskData.data[i + 1] = maskData.data[i + 2] = maskValue; maskData.data[i + 3] = 255; } - maskCtx.putImageData(maskData, 0, 0); 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 }); - + if (!tempMaskCtx) + throw new Error("Could not create temp mask context"); tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height); - - const maskX = this.canvas.maskTool.x; const maskY = this.canvas.maskTool.y; - log.debug(`Extracting mask from world position (${maskX}, ${maskY}) for output area (0,0) to (${this.canvas.width}, ${this.canvas.height})`); - - const sourceX = Math.max(0, -maskX); // Where in the mask canvas to start reading + 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 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 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 + const copyHeight = Math.min(toolMaskCanvas.height - sourceY, // Available height in source + this.canvas.height - destY // Available height in destination ); - if (copyWidth > 0 && copyHeight > 0) { log.debug(`Copying mask region: source(${sourceX}, ${sourceY}) to dest(${destX}, ${destY}) size(${copyWidth}, ${copyHeight})`); - - tempMaskCtx.drawImage( - toolMaskCanvas, - sourceX, sourceY, copyWidth, copyHeight, // Source rectangle - destX, destY, copyWidth, copyHeight // Destination rectangle + 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]; @@ -143,7 +125,6 @@ export class CanvasIO { tempMaskData.data[i + 3] = alpha; } tempMaskCtx.putImageData(tempMaskData, 0, 0); - maskCtx.globalCompositeOperation = 'source-over'; maskCtx.drawImage(tempMaskCanvas, 0, 0); } @@ -151,60 +132,59 @@ export class CanvasIO { const imageData = tempCanvas.toDataURL('image/png'); const maskData = maskCanvas.toDataURL('image/png'); log.info("Returning image and mask data as base64 for RAM mode."); - resolve({image: imageData, mask: maskData}); + resolve({ image: imageData, mask: maskData }); return; } - const fileNameWithoutMask = fileName.replace('.png', '_without_mask.png'); log.info(`Saving image without mask as: ${fileNameWithoutMask}`); - tempCanvas.toBlob(async (blobWithoutMask) => { + if (!blobWithoutMask) + return; log.debug(`Created blob for image without mask, size: ${blobWithoutMask.size} bytes`); const formDataWithoutMask = new FormData(); formDataWithoutMask.append("image", blobWithoutMask, fileNameWithoutMask); formDataWithoutMask.append("overwrite", "true"); - try { const response = await fetch("/upload/image", { method: "POST", body: formDataWithoutMask, }); log.debug(`Image without mask upload response: ${response.status}`); - } catch (error) { + } + catch (error) { log.error(`Error uploading image without mask:`, error); } }, "image/png"); log.info(`Saving main image as: ${fileName}`); tempCanvas.toBlob(async (blob) => { + if (!blob) + return; log.debug(`Created blob for main image, size: ${blob.size} bytes`); const formData = new FormData(); formData.append("image", blob, fileName); formData.append("overwrite", "true"); - try { const resp = await fetch("/upload/image", { method: "POST", body: formData, }); log.debug(`Main image upload response: ${resp.status}`); - if (resp.status === 200) { const maskFileName = fileName.replace('.png', '_mask.png'); log.info(`Saving mask as: ${maskFileName}`); - maskCanvas.toBlob(async (maskBlob) => { + if (!maskBlob) + return; log.debug(`Created blob for mask, size: ${maskBlob.size} bytes`); const maskFormData = new FormData(); maskFormData.append("image", maskBlob, maskFileName); maskFormData.append("overwrite", "true"); - try { const maskResp = await fetch("/upload/image", { method: "POST", body: maskFormData, }); log.debug(`Mask upload response: ${maskResp.status}`); - if (maskResp.status === 200) { const data = await resp.json(); if (this.canvas.widget) { @@ -212,42 +192,48 @@ export class CanvasIO { } log.info(`All files saved successfully, widget value set to: ${fileName}`); resolve(true); - } else { + } + else { log.error(`Error saving mask: ${maskResp.status}`); resolve(false); } - } catch (error) { + } + catch (error) { log.error(`Error saving mask:`, error); resolve(false); } }, "image/png"); - } else { + } + else { log.error(`Main image upload failed: ${resp.status} - ${resp.statusText}`); resolve(false); } - } catch (error) { + } + catch (error) { log.error(`Error uploading main image:`, error); resolve(false); } }, "image/png"); }); } - async _renderOutputData() { return new Promise((resolve) => { - const {canvas: tempCanvas, ctx: tempCtx} = createCanvas(this.canvas.width, this.canvas.height); - const {canvas: maskCanvas, ctx: maskCtx} = createCanvas(this.canvas.width, this.canvas.height); - + const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(this.canvas.width, this.canvas.height); + const { canvas: maskCanvas, ctx: maskCtx } = createCanvas(this.canvas.width, this.canvas.height); const visibilityCanvas = document.createElement('canvas'); visibilityCanvas.width = this.canvas.width; visibilityCanvas.height = this.canvas.height; - const visibilityCtx = visibilityCanvas.getContext('2d', {alpha: true}); + const visibilityCtx = visibilityCanvas.getContext('2d', { alpha: true }); + if (!visibilityCtx) + throw new Error("Could not create visibility context"); + if (!maskCtx) + throw new Error("Could not create mask context"); + if (!tempCtx) + throw new Error("Could not create temp context"); maskCtx.fillStyle = '#ffffff'; // Start with a white mask (nothing masked) maskCtx.fillRect(0, 0, this.canvas.width, this.canvas.height); - const sortedLayers = this.canvas.layers.sort((a, b) => a.zIndex - b.zIndex); sortedLayers.forEach((layer) => { - tempCtx.save(); tempCtx.globalCompositeOperation = layer.blendMode || 'normal'; tempCtx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1; @@ -255,14 +241,12 @@ export class CanvasIO { tempCtx.rotate(layer.rotation * Math.PI / 180); tempCtx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height); tempCtx.restore(); - visibilityCtx.save(); visibilityCtx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2); visibilityCtx.rotate(layer.rotation * Math.PI / 180); visibilityCtx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height); visibilityCtx.restore(); }); - const visibilityData = visibilityCtx.getImageData(0, 0, this.canvas.width, this.canvas.height); const maskData = maskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height); for (let i = 0; i < visibilityData.data.length; i += 4) { @@ -272,64 +256,45 @@ export class CanvasIO { maskData.data[i + 3] = 255; // Solid mask } maskCtx.putImageData(maskData, 0, 0); - 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 }); - + if (!tempMaskCtx) + throw new Error("Could not create temp mask context"); tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height); - const maskX = this.canvas.maskTool.x; const maskY = this.canvas.maskTool.y; - log.debug(`[renderOutputData] Extracting mask from world position (${maskX}, ${maskY})`); - 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 - ); + 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] = alpha; tempMaskData.data[i + 3] = 255; // Solid alpha } tempMaskCtx.putImageData(tempMaskData, 0, 0); - - maskCtx.globalCompositeOperation = 'screen'; maskCtx.drawImage(tempMaskCanvas, 0, 0); } - const imageDataUrl = tempCanvas.toDataURL('image/png'); const maskDataUrl = maskCanvas.toDataURL('image/png'); - - resolve({image: imageDataUrl, mask: maskDataUrl}); + resolve({ image: imageDataUrl, mask: maskDataUrl }); }); } - async sendDataViaWebSocket(nodeId) { log.info(`Preparing to send data for node ${nodeId} via WebSocket.`); - - const {image, mask} = await this._renderOutputData(); - + const { image, mask } = await this._renderOutputData(); try { log.info(`Sending data for node ${nodeId}...`); await webSocketManager.sendMessage({ @@ -338,205 +303,167 @@ export class CanvasIO { image: image, mask: mask, }, true); // `true` requires an acknowledgment - log.info(`Data for node ${nodeId} has been sent and acknowledged by the server.`); return true; - } catch (error) { + } + catch (error) { log.error(`Failed to send data for node ${nodeId}:`, error); - - throw new Error(`Failed to get confirmation from server for node ${nodeId}. The workflow might not have the latest canvas data.`); } } - async addInputToCanvas(inputImage, inputMask) { try { - log.debug("Adding input to canvas:", {inputImage}); - - const {canvas: tempCanvas, ctx: tempCtx} = createCanvas(inputImage.width, inputImage.height); - - const imgData = new ImageData( - inputImage.data, - inputImage.width, - inputImage.height - ); + log.debug("Adding input to canvas:", { inputImage }); + const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(inputImage.width, inputImage.height); + if (!tempCtx) + throw new Error("Could not create temp context"); + const imgData = new ImageData(new Uint8ClampedArray(inputImage.data), inputImage.width, inputImage.height); tempCtx.putImageData(imgData, 0, 0); - const image = new Image(); await new Promise((resolve, reject) => { image.onload = resolve; image.onerror = reject; image.src = tempCanvas.toDataURL(); }); - - const scale = Math.min( - this.canvas.width / inputImage.width * 0.8, - this.canvas.height / inputImage.height * 0.8 - ); - + const scale = Math.min(this.canvas.width / inputImage.width * 0.8, this.canvas.height / inputImage.height * 0.8); 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, height: inputImage.height * scale, }); - - if (inputMask) { + if (inputMask && layer) { layer.mask = inputMask.data; } - log.info("Layer added successfully"); return true; - - } catch (error) { + } + catch (error) { log.error("Error in addInputToCanvas:", error); throw error; } } - async convertTensorToImage(tensor) { try { log.debug("Converting tensor to image:", tensor); - if (!tensor || !tensor.data || !tensor.width || !tensor.height) { throw new Error("Invalid tensor data"); } - const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d', { willReadFrequently: true }); + if (!ctx) + throw new Error("Could not create canvas context"); canvas.width = tensor.width; canvas.height = tensor.height; - - const imageData = new ImageData( - new Uint8ClampedArray(tensor.data), - tensor.width, - tensor.height - ); - + const imageData = new ImageData(new Uint8ClampedArray(tensor.data), tensor.width, tensor.height); ctx.putImageData(imageData, 0, 0); - return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); img.onerror = (e) => reject(new Error("Failed to load image: " + e)); img.src = canvas.toDataURL(); }); - } catch (error) { + } + catch (error) { log.error("Error converting tensor to image:", error); throw error; } } - async convertTensorToMask(tensor) { if (!tensor || !tensor.data) { throw new Error("Invalid mask tensor"); } - try { return new Float32Array(tensor.data); - } catch (error) { + } + catch (error) { throw new Error(`Mask conversion failed: ${error.message}`); } } - async initNodeData() { try { log.info("Starting node data initialization..."); - if (!this.canvas.node || !this.canvas.node.inputs) { log.debug("Node or inputs not ready"); return this.scheduleDataCheck(); } - if (this.canvas.node.inputs[0] && this.canvas.node.inputs[0].link) { const imageLinkId = this.canvas.node.inputs[0].link; - const imageData = app.nodeOutputs[imageLinkId]; - + const imageData = window.app.nodeOutputs[imageLinkId]; if (imageData) { log.debug("Found image data:", imageData); await this.processImageData(imageData); this.canvas.dataInitialized = true; - } else { + } + else { log.debug("Image data not available yet"); return this.scheduleDataCheck(); } } - if (this.canvas.node.inputs[1] && this.canvas.node.inputs[1].link) { const maskLinkId = this.canvas.node.inputs[1].link; - const maskData = app.nodeOutputs[maskLinkId]; - + const maskData = window.app.nodeOutputs[maskLinkId]; if (maskData) { log.debug("Found mask data:", maskData); await this.processMaskData(maskData); } } - - } catch (error) { + } + catch (error) { log.error("Error in initNodeData:", error); return this.scheduleDataCheck(); } } - scheduleDataCheck() { if (this.canvas.pendingDataCheck) { clearTimeout(this.canvas.pendingDataCheck); } - - this.canvas.pendingDataCheck = setTimeout(() => { + this.canvas.pendingDataCheck = window.setTimeout(() => { this.canvas.pendingDataCheck = null; if (!this.canvas.dataInitialized) { this.initNodeData(); } }, 1000); } - async processImageData(imageData) { try { - if (!imageData) return; - + if (!imageData) + return; log.debug("Processing image data:", { type: typeof imageData, isArray: Array.isArray(imageData), shape: imageData.shape, hasData: !!imageData.data }); - if (Array.isArray(imageData)) { imageData = imageData[0]; } - if (!imageData.shape || !imageData.data) { throw new Error("Invalid image data format"); } - const originalWidth = imageData.shape[2]; const originalHeight = imageData.shape[1]; - - const scale = Math.min( - this.canvas.width / originalWidth * 0.8, - this.canvas.height / originalHeight * 0.8 - ); - + const scale = Math.min(this.canvas.width / originalWidth * 0.8, this.canvas.height / originalHeight * 0.8); const convertedData = this.convertTensorToImageData(imageData); if (convertedData) { const image = await this.createImageFromData(convertedData); - this.addScaledLayer(image, scale); log.info("Image layer added successfully with scale:", scale); } - } catch (error) { + } + catch (error) { log.error("Error processing image data:", error); throw error; } } - addScaledLayer(image, scale) { try { const scaledWidth = image.width * scale; const scaledHeight = image.height * scale; - const layer = { + id: '', // This will be set in addLayerWithImage + imageId: '', // This will be set in addLayerWithImage + name: 'Layer', image: image, x: (this.canvas.width - scaledWidth) / 2, y: (this.canvas.height - scaledHeight) / 2, @@ -545,31 +472,30 @@ export class CanvasIO { rotation: 0, zIndex: this.canvas.layers.length, originalWidth: image.width, - originalHeight: image.height + originalHeight: image.height, + blendMode: 'normal', + opacity: 1 }; - this.canvas.layers.push(layer); - this.canvas.selectedLayer = layer; + this.canvas.updateSelection([layer]); this.canvas.render(); - log.debug("Scaled layer added:", { originalSize: `${image.width}x${image.height}`, scaledSize: `${scaledWidth}x${scaledHeight}`, scale: scale }); - } catch (error) { + } + catch (error) { log.error("Error adding scaled layer:", error); throw error; } } - convertTensorToImageData(tensor) { try { const shape = tensor.shape; const height = shape[1]; const width = shape[2]; const channels = shape[3]; - log.debug("Converting tensor:", { shape: shape, dataRange: { @@ -577,56 +503,50 @@ export class CanvasIO { max: tensor.max_val } }); - const imageData = new ImageData(width, height); const data = new Uint8ClampedArray(width * height * 4); - const flatData = tensor.data; const pixelCount = width * height; - for (let i = 0; i < pixelCount; i++) { const pixelIndex = i * 4; const tensorIndex = i * channels; - for (let c = 0; c < channels; c++) { const value = flatData[tensorIndex + c]; - const normalizedValue = (value - tensor.min_val) / (tensor.max_val - tensor.min_val); data[pixelIndex + c] = Math.round(normalizedValue * 255); } - data[pixelIndex + 3] = 255; } - imageData.data.set(data); return imageData; - } catch (error) { + } + catch (error) { log.error("Error converting tensor:", error); return null; } } - async createImageFromData(imageData) { return new Promise((resolve, reject) => { const canvas = document.createElement('canvas'); canvas.width = imageData.width; canvas.height = imageData.height; const ctx = canvas.getContext('2d', { willReadFrequently: true }); + if (!ctx) + throw new Error("Could not create canvas context"); ctx.putImageData(imageData, 0, 0); - const img = new Image(); img.onload = () => resolve(img); img.onerror = reject; img.src = canvas.toDataURL(); }); } - async retryDataLoad(maxRetries = 3, delay = 1000) { for (let i = 0; i < maxRetries; i++) { try { await this.initNodeData(); return; - } catch (error) { + } + catch (error) { log.warn(`Retry ${i + 1}/${maxRetries} failed:`, error); if (i < maxRetries - 1) { await new Promise(resolve => setTimeout(resolve, delay)); @@ -635,32 +555,28 @@ export class CanvasIO { } log.error("Failed to load data after", maxRetries, "retries"); } - async processMaskData(maskData) { try { - if (!maskData) return; - + if (!maskData) + return; log.debug("Processing mask data:", maskData); - if (Array.isArray(maskData)) { maskData = maskData[0]; } - if (!maskData.shape || !maskData.data) { throw new Error("Invalid mask data format"); } - - if (this.canvas.selectedLayer) { + if (this.canvas.canvasSelection.selectedLayers.length > 0) { const maskTensor = await this.convertTensorToMask(maskData); - this.canvas.selectedLayer.mask = maskTensor; + this.canvas.canvasSelection.selectedLayers[0].mask = maskTensor; this.canvas.render(); log.info("Mask applied to selected layer"); } - } catch (error) { + } + catch (error) { log.error("Error processing mask data:", error); } } - async loadImageFromCache(base64Data) { return new Promise((resolve, reject) => { const img = new Image(); @@ -669,72 +585,69 @@ export class CanvasIO { img.src = base64Data; }); } - async importImage(cacheData) { try { log.info("Starting image import with cache data"); const img = await this.loadImageFromCache(cacheData.image); const mask = cacheData.mask ? await this.loadImageFromCache(cacheData.mask) : null; - - const scale = Math.min( - this.canvas.width / img.width * 0.8, - this.canvas.height / img.height * 0.8 - ); - + const scale = Math.min(this.canvas.width / img.width * 0.8, this.canvas.height / img.height * 0.8); const tempCanvas = document.createElement('canvas'); tempCanvas.width = img.width; tempCanvas.height = img.height; const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); - + if (!tempCtx) + throw new Error("Could not create temp context"); tempCtx.drawImage(img, 0, 0); - if (mask) { const imageData = tempCtx.getImageData(0, 0, img.width, img.height); const maskCanvas = document.createElement('canvas'); maskCanvas.width = img.width; maskCanvas.height = img.height; const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true }); + if (!maskCtx) + throw new Error("Could not create mask context"); maskCtx.drawImage(mask, 0, 0); const maskData = maskCtx.getImageData(0, 0, img.width, img.height); - for (let i = 0; i < imageData.data.length; i += 4) { imageData.data[i + 3] = maskData.data[i]; } - tempCtx.putImageData(imageData, 0, 0); } - const finalImage = new Image(); await new Promise((resolve) => { finalImage.onload = resolve; finalImage.src = tempCanvas.toDataURL(); }); - const layer = { + id: '', // This will be set in addLayerWithImage + imageId: '', // This will be set in addLayerWithImage + name: 'Layer', image: finalImage, x: (this.canvas.width - img.width * scale) / 2, y: (this.canvas.height - img.height * scale) / 2, width: img.width * scale, height: img.height * scale, + originalWidth: img.width, + originalHeight: img.height, rotation: 0, - zIndex: this.canvas.layers.length + zIndex: this.canvas.layers.length, + blendMode: 'normal', + opacity: 1, }; - this.canvas.layers.push(layer); - this.canvas.selectedLayer = layer; + this.canvas.updateSelection([layer]); this.canvas.render(); this.canvas.saveState(); - } catch (error) { + } + catch (error) { log.error('Error importing image:', error); } } - async importLatestImage() { try { log.info("Fetching latest image from server..."); const response = await fetch('/ycnode/get_latest_image'); const result = await response.json(); - if (result.success && result.image_data) { log.info("Latest image received, adding to canvas."); const img = new Image(); @@ -743,30 +656,28 @@ export class CanvasIO { img.onerror = reject; img.src = result.image_data; }); - await this.canvas.canvasLayers.addLayerWithImage(img, {}, 'fit'); log.info("Latest image imported and placed on canvas successfully."); return true; - } else { + } + else { throw new Error(result.error || "Failed to fetch the latest image."); } - } catch (error) { + } + catch (error) { log.error("Error importing latest image:", error); alert(`Failed to import latest image: ${error.message}`); return false; } } - async importLatestImages(sinceTimestamp, targetArea = null) { try { log.info(`Fetching latest images since ${sinceTimestamp}...`); const response = await fetch(`/layerforge/get-latest-images/${sinceTimestamp}`); const result = await response.json(); - if (result.success && result.images && result.images.length > 0) { log.info(`Received ${result.images.length} new images, adding to canvas.`); const newLayers = []; - for (const imageData of result.images) { const img = new Image(); await new Promise((resolve, reject) => { @@ -778,16 +689,17 @@ export class CanvasIO { newLayers.push(newLayer); } log.info("All new images imported and placed on canvas successfully."); - return newLayers; - - } else if (result.success) { + return newLayers.filter(l => l !== null); + } + else if (result.success) { log.info("No new images found since last generation."); return []; } else { throw new Error(result.error || "Failed to fetch latest images."); } - } catch (error) { + } + catch (error) { log.error("Error importing latest images:", error); alert(`Failed to import latest images: ${error.message}`); return []; diff --git a/js/CanvasInteractions.js b/js/CanvasInteractions.js index cb53a27..b794d66 100644 --- a/js/CanvasInteractions.js +++ b/js/CanvasInteractions.js @@ -1,42 +1,37 @@ -import {createModuleLogger} from "./utils/LoggerUtils.js"; -import {snapToGrid, getSnapAdjustment} from "./utils/CommonUtils.js"; - +import { createModuleLogger } from "./utils/LoggerUtils.js"; +import { snapToGrid, getSnapAdjustment } from "./utils/CommonUtils.js"; const log = createModuleLogger('CanvasInteractions'); - export class CanvasInteractions { constructor(canvas) { this.canvas = canvas; this.interaction = { mode: 'none', - panStart: {x: 0, y: 0}, - dragStart: {x: 0, y: 0}, + panStart: { x: 0, y: 0 }, + dragStart: { x: 0, y: 0 }, transformOrigin: {}, resizeHandle: null, - resizeAnchor: {x: 0, y: 0}, - canvasResizeStart: {x: 0, y: 0}, + resizeAnchor: { x: 0, y: 0 }, + canvasResizeStart: { x: 0, y: 0 }, isCtrlPressed: false, isAltPressed: false, hasClonedInDrag: false, lastClickTime: 0, transformingLayer: null, - keyMovementInProgress: false, // Flaga do śledzenia ruchu klawiszami + keyMovementInProgress: false, + canvasResizeRect: null, + canvasMoveRect: null, }; this.originalLayerPositions = new Map(); - this.interaction.canvasResizeRect = null; - this.interaction.canvasMoveRect = null; } - setupEventListeners() { this.canvas.canvas.addEventListener('mousedown', this.handleMouseDown.bind(this)); this.canvas.canvas.addEventListener('mousemove', this.handleMouseMove.bind(this)); this.canvas.canvas.addEventListener('mouseup', this.handleMouseUp.bind(this)); this.canvas.canvas.addEventListener('mouseleave', this.handleMouseLeave.bind(this)); - this.canvas.canvas.addEventListener('wheel', this.handleWheel.bind(this), {passive: false}); + this.canvas.canvas.addEventListener('wheel', this.handleWheel.bind(this), { passive: false }); 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); @@ -45,15 +40,12 @@ 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() { this.interaction.mode = 'none'; this.interaction.resizeHandle = null; @@ -64,20 +56,16 @@ export class CanvasInteractions { this.interaction.transformingLayer = null; this.canvas.canvas.style.cursor = 'default'; } - handleMouseDown(e) { this.canvas.canvas.focus(); const worldCoords = this.canvas.getMouseWorldCoordinates(e); const viewCoords = this.canvas.getMouseViewCoordinates(e); - if (this.interaction.mode === 'drawingMask') { this.canvas.maskTool.handleMouseDown(worldCoords, viewCoords); this.canvas.render(); return; } - // --- Ostateczna, poprawna kolejność sprawdzania --- - // 1. Akcje globalne z modyfikatorami (mają najwyższy priorytet) if (e.shiftKey && e.ctrlKey) { this.startCanvasMove(worldCoords); @@ -87,7 +75,6 @@ export class CanvasInteractions { this.startCanvasResize(worldCoords); return; } - // 2. Inne przyciski myszy if (e.button === 2) { // Prawy przycisk myszy const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y); @@ -95,35 +82,30 @@ export class CanvasInteractions { e.preventDefault(); this.canvas.canvasLayers.showBlendModeMenu(viewCoords.x, viewCoords.y); } - return; + return; } if (e.button !== 0) { // Środkowy przycisk this.startPanning(e); return; } - // 3. Interakcje z elementami na płótnie (lewy przycisk) const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y); if (transformTarget) { this.startLayerTransform(transformTarget.layer, transformTarget.handle, worldCoords); return; } - const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y); if (clickedLayerResult) { this.prepareForDrag(clickedLayerResult.layer, worldCoords); return; } - // 4. Domyślna akcja na tle (lewy przycisk bez modyfikatorów) this.startPanningOrClearSelection(e); } - handleMouseMove(e) { const worldCoords = this.canvas.getMouseWorldCoordinates(e); const viewCoords = this.canvas.getMouseViewCoordinates(e); this.canvas.lastMousePosition = worldCoords; // Zawsze aktualizuj ostatnią pozycję myszy - // Sprawdź, czy rozpocząć przeciąganie if (this.interaction.mode === 'potential-drag') { const dx = worldCoords.x - this.interaction.dragStart.x; @@ -131,12 +113,11 @@ export class CanvasInteractions { if (Math.sqrt(dx * dx + dy * dy) > 3) { // Próg 3 pikseli this.interaction.mode = 'dragging'; this.originalLayerPositions.clear(); - this.canvas.canvasSelection.selectedLayers.forEach(l => { - this.originalLayerPositions.set(l, {x: l.x, y: l.y}); + this.canvas.canvasSelection.selectedLayers.forEach((l) => { + this.originalLayerPositions.set(l, { x: l.x, y: l.y }); }); } } - switch (this.interaction.mode) { case 'drawingMask': this.canvas.maskTool.handleMouseMove(worldCoords, viewCoords); @@ -165,7 +146,6 @@ export class CanvasInteractions { break; } } - handleMouseUp(e) { const viewCoords = this.canvas.getMouseViewCoordinates(e); if (this.interaction.mode === 'drawingMask') { @@ -173,27 +153,22 @@ export class CanvasInteractions { this.canvas.render(); return; } - if (this.interaction.mode === 'resizingCanvas') { this.finalizeCanvasResize(); } if (this.interaction.mode === 'movingCanvas') { this.finalizeCanvasMove(); } - // Zapisz stan tylko, jeśli faktycznie doszło do zmiany (przeciąganie, transformacja, duplikacja) const stateChangingInteraction = ['dragging', 'resizing', 'rotating'].includes(this.interaction.mode); const duplicatedInDrag = this.interaction.hasClonedInDrag; - if (stateChangingInteraction || duplicatedInDrag) { this.canvas.saveState(); - this.canvas.canvasState.saveStateToDB(true); + this.canvas.canvasState.saveStateToDB(); } - this.resetInteractionState(); this.canvas.render(); } - handleMouseLeave(e) { const viewCoords = this.canvas.getMouseViewCoordinates(e); if (this.canvas.maskTool.isActive) { @@ -208,24 +183,19 @@ 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) { if (this.canvas.maskTool.isActive) { this.canvas.maskTool.handleMouseEnter(); } } - handleContextMenu(e) { - e.preventDefault(); } - handleWheel(e) { e.preventDefault(); if (this.canvas.maskTool.isActive) { @@ -233,36 +203,36 @@ export class CanvasInteractions { const rect = this.canvas.canvas.getBoundingClientRect(); const mouseBufferX = (e.clientX - rect.left) * (this.canvas.offscreenCanvas.width / rect.width); const mouseBufferY = (e.clientY - rect.top) * (this.canvas.offscreenCanvas.height / rect.height); - const zoomFactor = e.deltaY < 0 ? 1.1 : 1 / 1.1; const newZoom = this.canvas.viewport.zoom * zoomFactor; - this.canvas.viewport.zoom = Math.max(0.1, Math.min(10, newZoom)); this.canvas.viewport.x = worldCoords.x - (mouseBufferX / this.canvas.viewport.zoom); this.canvas.viewport.y = worldCoords.y - (mouseBufferY / this.canvas.viewport.zoom); - } else if (this.canvas.canvasSelection.selectedLayer) { + } + else if (this.canvas.canvasSelection.selectedLayers.length > 0) { const rotationStep = 5 * (e.deltaY > 0 ? -1 : 1); const direction = e.deltaY < 0 ? 1 : -1; // 1 = up/right, -1 = down/left - - this.canvas.canvasSelection.selectedLayers.forEach(layer => { + this.canvas.canvasSelection.selectedLayers.forEach((layer) => { if (e.shiftKey) { // Nowy skrót: Shift + Ctrl + Kółko do przyciągania do absolutnych wartości if (e.ctrlKey) { const snapAngle = 5; if (direction > 0) { // Obrót w górę/prawo layer.rotation = Math.ceil((layer.rotation + 0.1) / snapAngle) * snapAngle; - } else { // Obrót w dół/lewo + } + else { // Obrót w dół/lewo layer.rotation = Math.floor((layer.rotation - 0.1) / snapAngle) * snapAngle; } - } else { + } + else { // Stara funkcjonalność: Shift + Kółko obraca o stały krok layer.rotation += rotationStep; } - } else { + } + else { const oldWidth = layer.width; const oldHeight = layer.height; let scaleFactor; - if (e.ctrlKey) { const direction = e.deltaY > 0 ? -1 : 1; const baseDimension = Math.max(layer.width, layer.height); @@ -271,26 +241,28 @@ export class CanvasInteractions { return; } scaleFactor = newBaseDimension / baseDimension; - } else { + } + else { const gridSize = 64; const direction = e.deltaY > 0 ? -1 : 1; let targetHeight; - if (direction > 0) { targetHeight = (Math.floor(oldHeight / gridSize) + 1) * gridSize; - } else { + } + else { targetHeight = (Math.ceil(oldHeight / gridSize) - 1) * gridSize; } if (targetHeight < gridSize / 2) { targetHeight = gridSize / 2; } if (Math.abs(oldHeight - targetHeight) < 1) { - if (direction > 0) targetHeight += gridSize; - else targetHeight -= gridSize; - - if (targetHeight < gridSize / 2) return; + if (direction > 0) + targetHeight += gridSize; + else + targetHeight -= gridSize; + if (targetHeight < gridSize / 2) + return; } - scaleFactor = targetHeight / oldHeight; } if (scaleFactor && isFinite(scaleFactor)) { @@ -301,32 +273,30 @@ export class CanvasInteractions { } } }); - } else { + } + else { const worldCoords = this.canvas.getMouseWorldCoordinates(e); const rect = this.canvas.canvas.getBoundingClientRect(); const mouseBufferX = (e.clientX - rect.left) * (this.canvas.offscreenCanvas.width / rect.width); const mouseBufferY = (e.clientY - rect.top) * (this.canvas.offscreenCanvas.height / rect.height); - const zoomFactor = e.deltaY < 0 ? 1.1 : 1 / 1.1; const newZoom = this.canvas.viewport.zoom * zoomFactor; - this.canvas.viewport.zoom = Math.max(0.1, Math.min(10, newZoom)); this.canvas.viewport.x = worldCoords.x - (mouseBufferX / this.canvas.viewport.zoom); this.canvas.viewport.y = worldCoords.y - (mouseBufferY / this.canvas.viewport.zoom); } this.canvas.render(); if (!this.canvas.maskTool.isActive) { - this.canvas.requestSaveState(true); // Użyj opóźnionego zapisu + this.canvas.requestSaveState(); // Użyj opóźnionego zapisu } } - handleKeyDown(e) { - if (e.key === 'Control') this.interaction.isCtrlPressed = true; + if (e.key === 'Control') + this.interaction.isCtrlPressed = true; if (e.key === 'Alt') { this.interaction.isAltPressed = true; e.preventDefault(); } - // Globalne skróty (Undo/Redo/Copy/Paste) if (e.ctrlKey || e.metaKey) { let handled = true; @@ -334,7 +304,8 @@ export class CanvasInteractions { case 'z': if (e.shiftKey) { this.canvas.redo(); - } else { + } + else { this.canvas.undo(); } break; @@ -356,56 +327,54 @@ export class CanvasInteractions { return; } } - // Skróty kontekstowe (zależne od zaznaczenia) if (this.canvas.canvasSelection.selectedLayers.length > 0) { const step = e.shiftKey ? 10 : 1; let needsRender = false; - // Używamy e.code dla spójności i niezależności od układu klawiatury const movementKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'BracketLeft', 'BracketRight']; if (movementKeys.includes(e.code)) { e.preventDefault(); e.stopPropagation(); this.interaction.keyMovementInProgress = true; - - if (e.code === 'ArrowLeft') this.canvas.canvasSelection.selectedLayers.forEach(l => l.x -= step); - if (e.code === 'ArrowRight') this.canvas.canvasSelection.selectedLayers.forEach(l => l.x += step); - if (e.code === 'ArrowUp') this.canvas.canvasSelection.selectedLayers.forEach(l => l.y -= step); - if (e.code === 'ArrowDown') this.canvas.canvasSelection.selectedLayers.forEach(l => l.y += step); - if (e.code === 'BracketLeft') this.canvas.canvasSelection.selectedLayers.forEach(l => l.rotation -= step); - if (e.code === 'BracketRight') this.canvas.canvasSelection.selectedLayers.forEach(l => l.rotation += step); - + if (e.code === 'ArrowLeft') + this.canvas.canvasSelection.selectedLayers.forEach((l) => l.x -= step); + if (e.code === 'ArrowRight') + this.canvas.canvasSelection.selectedLayers.forEach((l) => l.x += step); + if (e.code === 'ArrowUp') + this.canvas.canvasSelection.selectedLayers.forEach((l) => l.y -= step); + if (e.code === 'ArrowDown') + this.canvas.canvasSelection.selectedLayers.forEach((l) => l.y += step); + if (e.code === 'BracketLeft') + this.canvas.canvasSelection.selectedLayers.forEach((l) => l.rotation -= step); + if (e.code === 'BracketRight') + this.canvas.canvasSelection.selectedLayers.forEach((l) => l.rotation += step); needsRender = true; } - if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); e.stopPropagation(); this.canvas.canvasSelection.removeSelectedLayers(); return; } - if (needsRender) { this.canvas.render(); } } } - handleKeyUp(e) { - if (e.key === 'Control') this.interaction.isCtrlPressed = false; - if (e.key === 'Alt') this.interaction.isAltPressed = false; - + if (e.key === 'Control') + this.interaction.isCtrlPressed = false; + if (e.key === 'Alt') + this.interaction.isAltPressed = false; const movementKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'BracketLeft', 'BracketRight']; if (movementKeys.includes(e.code) && this.interaction.keyMovementInProgress) { this.canvas.requestSaveState(); // Użyj opóźnionego zapisu this.interaction.keyMovementInProgress = false; } } - updateCursor(worldCoords) { const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y); - if (transformTarget) { const handleName = transformTarget.handle; const cursorMap = { @@ -414,13 +383,14 @@ export class CanvasInteractions { 'rot': 'grab' }; this.canvas.canvas.style.cursor = cursorMap[handleName]; - } else if (this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y)) { + } + else if (this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y)) { this.canvas.canvas.style.cursor = 'move'; - } else { + } + else { this.canvas.canvas.style.cursor = 'default'; } } - startLayerTransform(layer, handle, worldCoords) { this.interaction.transformingLayer = layer; this.interaction.transformOrigin = { @@ -430,43 +400,42 @@ export class CanvasInteractions { centerX: layer.x + layer.width / 2, centerY: layer.y + layer.height / 2 }; - this.interaction.dragStart = {...worldCoords}; - + this.interaction.dragStart = { ...worldCoords }; if (handle === 'rot') { this.interaction.mode = 'rotating'; - } else { + } + else { this.interaction.mode = 'resizing'; this.interaction.resizeHandle = handle; 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' - }[handle]; - this.interaction.resizeAnchor = handles[oppositeHandleKey]; + }; + this.interaction.resizeAnchor = handles[oppositeHandleKey[handle]]; } this.canvas.render(); } - prepareForDrag(layer, worldCoords) { // Zaktualizuj zaznaczenie, ale nie zapisuj stanu if (this.interaction.isCtrlPressed) { const index = this.canvas.canvasSelection.selectedLayers.indexOf(layer); if (index === -1) { this.canvas.canvasSelection.updateSelection([...this.canvas.canvasSelection.selectedLayers, layer]); - } else { - const newSelection = this.canvas.canvasSelection.selectedLayers.filter(l => l !== layer); + } + else { + const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l) => l !== layer); this.canvas.canvasSelection.updateSelection(newSelection); } - } else { + } + else { if (!this.canvas.canvasSelection.selectedLayers.includes(layer)) { this.canvas.canvasSelection.updateSelection([layer]); } } - this.interaction.mode = 'potential-drag'; - this.interaction.dragStart = {...worldCoords}; + this.interaction.dragStart = { ...worldCoords }; } - startPanningOrClearSelection(e) { // Ta funkcja jest teraz wywoływana tylko gdy kliknięto na tło bez modyfikatorów. // Domyślna akcja: wyczyść zaznaczenie i rozpocznij panoramowanie. @@ -474,75 +443,63 @@ export class CanvasInteractions { this.canvas.canvasSelection.updateSelection([]); } this.interaction.mode = 'panning'; - this.interaction.panStart = {x: e.clientX, y: e.clientY}; + this.interaction.panStart = { x: e.clientX, y: e.clientY }; } - startCanvasResize(worldCoords) { this.interaction.mode = 'resizingCanvas'; const startX = snapToGrid(worldCoords.x); const startY = snapToGrid(worldCoords.y); - this.interaction.canvasResizeStart = {x: startX, y: startY}; - this.interaction.canvasResizeRect = {x: startX, y: startY, width: 0, height: 0}; + this.interaction.canvasResizeStart = { x: startX, y: startY }; + this.interaction.canvasResizeRect = { x: startX, y: startY, width: 0, height: 0 }; this.canvas.render(); } - startCanvasMove(worldCoords) { this.interaction.mode = 'movingCanvas'; - this.interaction.dragStart = {...worldCoords}; + this.interaction.dragStart = { ...worldCoords }; const initialX = snapToGrid(worldCoords.x - this.canvas.width / 2); const initialY = snapToGrid(worldCoords.y - this.canvas.height / 2); - this.interaction.canvasMoveRect = { x: initialX, y: initialY, width: this.canvas.width, height: this.canvas.height }; - this.canvas.canvas.style.cursor = 'grabbing'; this.canvas.render(); } - updateCanvasMove(worldCoords) { - if (!this.interaction.canvasMoveRect) return; + if (!this.interaction.canvasMoveRect) + return; const dx = worldCoords.x - this.interaction.dragStart.x; const dy = worldCoords.y - this.interaction.dragStart.y; const initialRectX = snapToGrid(this.interaction.dragStart.x - this.canvas.width / 2); const initialRectY = snapToGrid(this.interaction.dragStart.y - this.canvas.height / 2); this.interaction.canvasMoveRect.x = snapToGrid(initialRectX + dx); this.interaction.canvasMoveRect.y = snapToGrid(initialRectY + dy); - this.canvas.render(); } - finalizeCanvasMove() { const moveRect = this.interaction.canvasMoveRect; - if (moveRect && (moveRect.x !== 0 || moveRect.y !== 0)) { const finalX = moveRect.x; const finalY = moveRect.y; - - this.canvas.layers.forEach(layer => { + this.canvas.layers.forEach((layer) => { layer.x -= finalX; layer.y -= finalY; }); - this.canvas.maskTool.updatePosition(-finalX, -finalY); - // If a batch generation is in progress, update the captured context as well if (this.canvas.pendingBatchContext) { this.canvas.pendingBatchContext.outputArea.x -= finalX; this.canvas.pendingBatchContext.outputArea.y -= finalY; - // Also update the menu spawn position to keep it relative this.canvas.pendingBatchContext.spawnPosition.x -= finalX; this.canvas.pendingBatchContext.spawnPosition.y -= finalY; log.debug("Updated pending batch context during canvas move:", this.canvas.pendingBatchContext); } - // Also move any active batch preview menus if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) { - this.canvas.batchPreviewManagers.forEach(manager => { + this.canvas.batchPreviewManagers.forEach((manager) => { manager.worldX -= finalX; manager.worldY -= finalY; if (manager.generationArea) { @@ -551,62 +508,58 @@ export class CanvasInteractions { } }); } - this.canvas.viewport.x -= finalX; this.canvas.viewport.y -= finalY; } this.canvas.render(); this.canvas.saveState(); } - startPanning(e) { if (!this.interaction.isCtrlPressed) { this.canvas.canvasSelection.updateSelection([]); } this.interaction.mode = 'panning'; - this.interaction.panStart = {x: e.clientX, y: e.clientY}; + this.interaction.panStart = { x: e.clientX, y: e.clientY }; } - panViewport(e) { const dx = e.clientX - this.interaction.panStart.x; const dy = e.clientY - this.interaction.panStart.y; this.canvas.viewport.x -= dx / this.canvas.viewport.zoom; this.canvas.viewport.y -= dy / this.canvas.viewport.zoom; - this.interaction.panStart = {x: e.clientX, y: e.clientY}; + this.interaction.panStart = { x: e.clientX, y: e.clientY }; this.canvas.render(); } - dragLayers(worldCoords) { if (this.interaction.isAltPressed && !this.interaction.hasClonedInDrag && this.canvas.canvasSelection.selectedLayers.length > 0) { // Scentralizowana logika duplikowania const newLayers = this.canvas.canvasSelection.duplicateSelectedLayers(); - // Zresetuj pozycje przeciągania dla nowych, zduplikowanych warstw this.originalLayerPositions.clear(); - newLayers.forEach(l => { - this.originalLayerPositions.set(l, {x: l.x, y: l.y}); + newLayers.forEach((l) => { + this.originalLayerPositions.set(l, { x: l.x, y: l.y }); }); this.interaction.hasClonedInDrag = true; } const totalDx = worldCoords.x - this.interaction.dragStart.x; const totalDy = worldCoords.y - this.interaction.dragStart.y; let finalDx = totalDx, finalDy = totalDy; - - if (this.interaction.isCtrlPressed && this.canvas.canvasSelection.selectedLayer) { - const originalPos = this.originalLayerPositions.get(this.canvas.canvasSelection.selectedLayer); + if (this.interaction.isCtrlPressed && this.canvas.canvasSelection.selectedLayers.length > 0) { + const firstLayer = this.canvas.canvasSelection.selectedLayers[0]; + const originalPos = this.originalLayerPositions.get(firstLayer); if (originalPos) { const tempLayerForSnap = { - ...this.canvas.canvasSelection.selectedLayer, + ...firstLayer, x: originalPos.x + totalDx, y: originalPos.y + totalDy }; const snapAdjustment = getSnapAdjustment(tempLayerForSnap); - finalDx += snapAdjustment.dx; - finalDy += snapAdjustment.dy; + if (snapAdjustment) { + finalDx += snapAdjustment.x; + finalDy += snapAdjustment.y; + } } } - - this.canvas.canvasSelection.selectedLayers.forEach(layer => { + this.canvas.canvasSelection.selectedLayers.forEach((layer) => { const originalPos = this.originalLayerPositions.get(layer); if (originalPos) { layer.x = originalPos.x + finalDx; @@ -615,138 +568,121 @@ export class CanvasInteractions { }); this.canvas.render(); } - resizeLayerFromHandle(worldCoords, isShiftPressed) { const layer = this.interaction.transformingLayer; - if (!layer) return; - + if (!layer) + return; let mouseX = worldCoords.x; let mouseY = worldCoords.y; - if (this.interaction.isCtrlPressed) { const snapThreshold = 10 / this.canvas.viewport.zoom; const snappedMouseX = snapToGrid(mouseX); - if (Math.abs(mouseX - snappedMouseX) < snapThreshold) mouseX = snappedMouseX; + if (Math.abs(mouseX - snappedMouseX) < snapThreshold) + mouseX = snappedMouseX; const snappedMouseY = snapToGrid(mouseY); - if (Math.abs(mouseY - snappedMouseY) < snapThreshold) mouseY = snappedMouseY; + if (Math.abs(mouseY - snappedMouseY) < snapThreshold) + mouseY = snappedMouseY; } - const o = this.interaction.transformOrigin; + if (o.rotation === undefined || o.width === undefined || o.height === undefined || o.centerX === undefined || o.centerY === undefined) + return; const handle = this.interaction.resizeHandle; const anchor = this.interaction.resizeAnchor; - const rad = o.rotation * Math.PI / 180; const cos = Math.cos(rad); const sin = Math.sin(rad); - const vecX = mouseX - anchor.x; const vecY = mouseY - anchor.y; - let newWidth = vecX * cos + vecY * sin; let newHeight = vecY * cos - vecX * sin; - if (isShiftPressed) { const originalAspectRatio = o.width / o.height; - if (Math.abs(newWidth) > Math.abs(newHeight) * originalAspectRatio) { newHeight = (Math.sign(newHeight) || 1) * Math.abs(newWidth) / originalAspectRatio; - } else { + } + else { newWidth = (Math.sign(newWidth) || 1) * Math.abs(newHeight) * originalAspectRatio; } } - - let signX = handle.includes('e') ? 1 : (handle.includes('w') ? -1 : 0); - let signY = handle.includes('s') ? 1 : (handle.includes('n') ? -1 : 0); - + let signX = handle?.includes('e') ? 1 : (handle?.includes('w') ? -1 : 0); + let signY = handle?.includes('s') ? 1 : (handle?.includes('n') ? -1 : 0); newWidth *= signX; newHeight *= signY; - - if (signX === 0) newWidth = o.width; - if (signY === 0) newHeight = o.height; - - if (newWidth < 10) newWidth = 10; - if (newHeight < 10) newHeight = 10; - + if (signX === 0) + newWidth = o.width; + if (signY === 0) + newHeight = o.height; + if (newWidth < 10) + newWidth = 10; + if (newHeight < 10) + newHeight = 10; layer.width = newWidth; layer.height = newHeight; - const deltaW = newWidth - o.width; const deltaH = newHeight - o.height; - const shiftX = (deltaW / 2) * signX; const shiftY = (deltaH / 2) * signY; - const worldShiftX = shiftX * cos - shiftY * sin; const worldShiftY = shiftX * sin + shiftY * cos; - const newCenterX = o.centerX + worldShiftX; const newCenterY = o.centerY + worldShiftY; - layer.x = newCenterX - layer.width / 2; layer.y = newCenterY - layer.height / 2; this.canvas.render(); } - rotateLayerFromHandle(worldCoords, isShiftPressed) { const layer = this.interaction.transformingLayer; - if (!layer) return; - + if (!layer) + return; const o = this.interaction.transformOrigin; + if (o.rotation === undefined || o.centerX === undefined || o.centerY === undefined) + return; const startAngle = Math.atan2(this.interaction.dragStart.y - o.centerY, this.interaction.dragStart.x - o.centerX); const currentAngle = Math.atan2(worldCoords.y - o.centerY, worldCoords.x - o.centerX); let angleDiff = (currentAngle - startAngle) * 180 / Math.PI; let newRotation = o.rotation + angleDiff; - if (isShiftPressed) { newRotation = Math.round(newRotation / 15) * 15; } - layer.rotation = newRotation; this.canvas.render(); } - updateCanvasResize(worldCoords) { + if (!this.interaction.canvasResizeRect) + return; const snappedMouseX = snapToGrid(worldCoords.x); const snappedMouseY = snapToGrid(worldCoords.y); const start = this.interaction.canvasResizeStart; - this.interaction.canvasResizeRect.x = Math.min(snappedMouseX, start.x); this.interaction.canvasResizeRect.y = Math.min(snappedMouseY, start.y); this.interaction.canvasResizeRect.width = Math.abs(snappedMouseX - start.x); this.interaction.canvasResizeRect.height = Math.abs(snappedMouseY - start.y); this.canvas.render(); } - finalizeCanvasResize() { if (this.interaction.canvasResizeRect && this.interaction.canvasResizeRect.width > 1 && this.interaction.canvasResizeRect.height > 1) { const newWidth = Math.round(this.interaction.canvasResizeRect.width); const newHeight = Math.round(this.interaction.canvasResizeRect.height); const finalX = this.interaction.canvasResizeRect.x; const finalY = this.interaction.canvasResizeRect.y; - this.canvas.updateOutputAreaSize(newWidth, newHeight); - - this.canvas.layers.forEach(layer => { + this.canvas.layers.forEach((layer) => { layer.x -= finalX; layer.y -= finalY; }); - this.canvas.maskTool.updatePosition(-finalX, -finalY); - // If a batch generation is in progress, update the captured context as well if (this.canvas.pendingBatchContext) { this.canvas.pendingBatchContext.outputArea.x -= finalX; this.canvas.pendingBatchContext.outputArea.y -= finalY; - // Also update the menu spawn position to keep it relative this.canvas.pendingBatchContext.spawnPosition.x -= finalX; this.canvas.pendingBatchContext.spawnPosition.y -= finalY; log.debug("Updated pending batch context during canvas resize:", this.canvas.pendingBatchContext); } - // Also move any active batch preview menus if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) { - this.canvas.batchPreviewManagers.forEach(manager => { + this.canvas.batchPreviewManagers.forEach((manager) => { manager.worldX -= finalX; manager.worldY -= finalY; if (manager.generationArea) { @@ -755,117 +691,101 @@ export class CanvasInteractions { } }); } - this.canvas.viewport.x -= finalX; this.canvas.viewport.y -= finalY; } } - handleDragOver(e) { e.preventDefault(); e.stopPropagation(); // Prevent ComfyUI from handling this event - e.dataTransfer.dropEffect = 'copy'; + if (e.dataTransfer) + 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 = ''; - + if (!e.dataTransfer) + return; 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) { + } + catch (error) { log.error(`Failed to load dropped image ${file.name}:`, error); } - } else { + } + 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 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); + await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode); }; img.onerror = () => { log.error(`Failed to load dropped image: ${file.name}`); }; - img.src = e.target.result; + if (e.target?.result) { + 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; - + 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"); @@ -875,7 +795,9 @@ export class CanvasInteractions { img.onload = async () => { await this.canvas.canvasLayers.addLayerWithImage(img, {}, 'mouse'); }; - img.src = event.target.result; + if (event.target?.result) { + img.src = event.target.result; + } }; reader.readAsDataURL(file); return; @@ -883,7 +805,6 @@ export class CanvasInteractions { } } } - await this.canvas.canvasLayers.clipboardManager.handlePaste('mouse', preference); } } diff --git a/js/CanvasLayers.js b/js/CanvasLayers.js index 1e6bfed..c3fd080 100644 --- a/js/CanvasLayers.js +++ b/js/CanvasLayers.js @@ -1,121 +1,167 @@ -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"; - +import { saveImage } from "./db.js"; +import { createModuleLogger } from "./utils/LoggerUtils.js"; +import { generateUUID, generateUniqueFileName } from "./utils/CommonUtils.js"; +import { withErrorHandling, createValidationError } from "./ErrorHandler.js"; +// @ts-ignore +import { app } from "../../scripts/app.js"; +// @ts-ignore +import { ComfyApp } from "../../scripts/app.js"; +import { ClipboardManager } from "./utils/ClipboardManager.js"; const log = createModuleLogger('CanvasLayers'); - export class CanvasLayers { constructor(canvas) { + this.addLayerWithImage = withErrorHandling(async (image, layerProps = {}, addMode = 'default', targetArea = null) => { + if (!image) { + throw createValidationError("Image is required for layer creation"); + } + log.debug("Adding layer with image:", image, "with mode:", addMode, "targetArea:", targetArea); + const imageId = generateUUID(); + await saveImage(imageId, image.src); + this.canvas.imageCache.set(imageId, image.src); + let finalWidth = image.width; + let finalHeight = image.height; + let finalX, finalY; + // Use the targetArea if provided, otherwise default to the current canvas dimensions + const area = targetArea || { width: this.canvas.width, height: this.canvas.height, x: 0, y: 0 }; + if (addMode === 'fit') { + const scale = Math.min(area.width / image.width, area.height / image.height); + finalWidth = image.width * scale; + finalHeight = image.height * scale; + finalX = area.x + (area.width - finalWidth) / 2; + finalY = area.y + (area.height - finalHeight) / 2; + } + else if (addMode === 'mouse') { + finalX = this.canvas.lastMousePosition.x - finalWidth / 2; + finalY = this.canvas.lastMousePosition.y - finalHeight / 2; + } + else { + finalX = area.x + (area.width - finalWidth) / 2; + finalY = area.y + (area.height - finalHeight) / 2; + } + const layer = { + id: generateUUID(), + image: image, + imageId: imageId, + name: 'Layer', + x: finalX, + y: finalY, + width: finalWidth, + height: finalHeight, + originalWidth: image.width, + originalHeight: image.height, + rotation: 0, + zIndex: this.canvas.layers.length, + blendMode: 'normal', + opacity: 1, + ...layerProps + }; + this.canvas.layers.push(layer); + this.canvas.updateSelection([layer]); + this.canvas.render(); + this.canvas.saveState(); + if (this.canvas.canvasLayersPanel) { + this.canvas.canvasLayersPanel.onLayersChanged(); + } + log.info("Layer added successfully"); + return layer; + }, 'CanvasLayers.addLayerWithImage'); this.canvas = canvas; this.clipboardManager = new ClipboardManager(canvas); this.blendModes = [ - {name: 'normal', label: 'Normal'}, - {name: 'multiply', label: 'Multiply'}, - {name: 'screen', label: 'Screen'}, - {name: 'overlay', label: 'Overlay'}, - {name: 'darken', label: 'Darken'}, - {name: 'lighten', label: 'Lighten'}, - {name: 'color-dodge', label: 'Color Dodge'}, - {name: 'color-burn', label: 'Color Burn'}, - {name: 'hard-light', label: 'Hard Light'}, - {name: 'soft-light', label: 'Soft Light'}, - {name: 'difference', label: 'Difference'}, - {name: 'exclusion', label: 'Exclusion'} + { name: 'normal', label: 'Normal' }, + { name: 'multiply', label: 'Multiply' }, + { name: 'screen', label: 'Screen' }, + { name: 'overlay', label: 'Overlay' }, + { name: 'darken', label: 'Darken' }, + { name: 'lighten', label: 'Lighten' }, + { name: 'color-dodge', label: 'Color Dodge' }, + { name: 'color-burn', label: 'Color Burn' }, + { name: 'hard-light', label: 'Hard Light' }, + { name: 'soft-light', label: 'Soft Light' }, + { name: 'difference', label: 'Difference' }, + { name: 'exclusion', label: 'Exclusion' } ]; this.selectedBlendMode = null; this.blendOpacity = 100; this.isAdjustingOpacity = false; this.internalClipboard = []; - this.clipboardPreference = 'system'; // 'system', 'clipspace' + this.clipboardPreference = 'system'; } - async copySelectedLayers() { - if (this.canvas.canvasSelection.selectedLayers.length === 0) return; - - this.internalClipboard = this.canvas.canvasSelection.selectedLayers.map(layer => ({...layer})); + if (this.canvas.canvasSelection.selectedLayers.length === 0) + return; + this.internalClipboard = this.canvas.canvasSelection.selectedLayers.map((layer) => ({ ...layer })); log.info(`Copied ${this.internalClipboard.length} layer(s) to internal clipboard.`); - 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 dataURL = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); + reader.onerror = reject; 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 (!this.canvas.node.imgs) { + this.canvas.node.imgs = []; } - + this.canvas.node.imgs[0] = img; if (ComfyApp.copyToClipspace) { ComfyApp.copyToClipspace(this.canvas.node); log.info("Flattened selection copied to ComfyUI Clipspace."); - } else { + } + else { log.warn("ComfyUI copyToClipspace not available"); } }; img.src = dataURL; - - } catch (error) { + } + catch (error) { log.error("Failed to copy image to ComfyUI Clipspace:", error); - try { - const item = new ClipboardItem({'image/png': blob}); + const item = new ClipboardItem({ 'image/png': blob }); await navigator.clipboard.write([item]); log.info("Fallback: Flattened selection copied to system clipboard."); - } catch (fallbackError) { + } + catch (fallbackError) { log.error("Failed to copy to system clipboard as fallback:", fallbackError); } } - } else { - + } + else { try { - const item = new ClipboardItem({'image/png': blob}); + const item = new ClipboardItem({ 'image/png': blob }); await navigator.clipboard.write([item]); log.info("Flattened selection copied to system clipboard."); - } catch (error) { + } + catch (error) { log.error("Failed to copy image to system clipboard:", error); } } } - pasteLayers() { - if (this.internalClipboard.length === 0) return; + if (this.internalClipboard.length === 0) + return; this.canvas.saveState(); const newLayers = []; - let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; - this.internalClipboard.forEach(layer => { + 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 { x: mouseX, y: mouseY } = this.canvas.lastMousePosition; const offsetX = mouseX - centerX; const offsetY = mouseY - centerY; - - this.internalClipboard.forEach(clipboardLayer => { + this.internalClipboard.forEach((clipboardLayer) => { const newLayer = { ...clipboardLayer, x: clipboardLayer.x + offsetX, @@ -125,122 +171,44 @@ export class CanvasLayers { this.canvas.layers.push(newLayer); newLayers.push(newLayer); }); - this.canvas.updateSelection(newLayers); this.canvas.render(); - - // Notify the layers panel to update its view if (this.canvas.canvasLayersPanel) { this.canvas.canvasLayersPanel.onLayersChanged(); } - log.info(`Pasted ${newLayers.length} layer(s) at mouse position (${mouseX}, ${mouseY}).`); } - async handlePaste(addMode = 'mouse') { try { log.info(`Paste operation started with preference: ${this.clipboardPreference}`); - await this.clipboardManager.handlePaste(addMode, this.clipboardPreference); - - } catch (err) { + } + catch (err) { log.error("Paste operation failed:", err); } } - - - addLayerWithImage = withErrorHandling(async (image, layerProps = {}, addMode = 'default', targetArea = null) => { - if (!image) { - throw createValidationError("Image is required for layer creation"); - } - - log.debug("Adding layer with image:", image, "with mode:", addMode, "targetArea:", targetArea); - const imageId = generateUUID(); - await saveImage(imageId, image.src); - this.canvas.imageCache.set(imageId, image.src); - - let finalWidth = image.width; - let finalHeight = image.height; - let finalX, finalY; - - // Use the targetArea if provided, otherwise default to the current canvas dimensions - const area = targetArea || { width: this.canvas.width, height: this.canvas.height, x: 0, y: 0 }; - - if (addMode === 'fit') { - const scale = Math.min(area.width / image.width, area.height / image.height); - finalWidth = image.width * scale; - finalHeight = image.height * scale; - finalX = area.x + (area.width - finalWidth) / 2; - finalY = area.y + (area.height - finalHeight) / 2; - } else if (addMode === 'mouse') { - finalX = this.canvas.lastMousePosition.x - finalWidth / 2; - finalY = this.canvas.lastMousePosition.y - finalHeight / 2; - } else { // 'center' or 'default' - finalX = area.x + (area.width - finalWidth) / 2; - finalY = area.y + (area.height - finalHeight) / 2; - } - - const layer = { - id: generateUUID(), - image: image, - imageId: imageId, - x: finalX, - y: finalY, - width: finalWidth, - height: finalHeight, - originalWidth: image.width, - originalHeight: image.height, - rotation: 0, - zIndex: this.canvas.layers.length, - blendMode: 'normal', - opacity: 1, - ...layerProps - }; - - this.canvas.layers.push(layer); - this.canvas.updateSelection([layer]); - this.canvas.render(); - this.canvas.saveState(); - - // Notify the layers panel to update its view - if (this.canvas.canvasLayersPanel) { - this.canvas.canvasLayersPanel.onLayersChanged(); - } - - log.info("Layer added successfully"); - return layer; - }, 'CanvasLayers.addLayerWithImage'); - async addLayer(image) { return this.addLayerWithImage(image); } - - /** - * Centralna funkcja do przesuwania warstw. - * @param {Array} layersToMove - Tablica warstw do przesunięcia. - * @param {Object} options - Opcje przesunięcia, np. { direction: 'up' } lub { toIndex: 3 } - */ moveLayers(layersToMove, options = {}) { - if (!layersToMove || layersToMove.length === 0) return; - + if (!layersToMove || layersToMove.length === 0) + return; let finalLayers; - if (options.direction) { - // Logika dla 'up' i 'down' const allLayers = [...this.canvas.layers]; - const selectedIndices = new Set(layersToMove.map(l => allLayers.indexOf(l))); - + const selectedIndices = new Set(layersToMove.map((l) => allLayers.indexOf(l))); if (options.direction === 'up') { const sorted = Array.from(selectedIndices).sort((a, b) => b - a); - sorted.forEach(index => { + sorted.forEach((index) => { const targetIndex = index + 1; if (targetIndex < allLayers.length && !selectedIndices.has(targetIndex)) { [allLayers[index], allLayers[targetIndex]] = [allLayers[targetIndex], allLayers[index]]; } }); - } else if (options.direction === 'down') { + } + else if (options.direction === 'down') { const sorted = Array.from(selectedIndices).sort((a, b) => a - b); - sorted.forEach(index => { + sorted.forEach((index) => { const targetIndex = index - 1; if (targetIndex >= 0 && !selectedIndices.has(targetIndex)) { [allLayers[index], allLayers[targetIndex]] = [allLayers[targetIndex], allLayers[index]]; @@ -248,13 +216,11 @@ export class CanvasLayers { }); } finalLayers = allLayers; - - } else if (options.toIndex !== undefined) { - // Logika dla przeciągania i upuszczania (z panelu) + } + else if (options.toIndex !== undefined) { const displayedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex); const reorderedFinal = []; let inserted = false; - for (let i = 0; i < displayedLayers.length; i++) { if (i === options.toIndex) { reorderedFinal.push(...layersToMove); @@ -269,112 +235,88 @@ export class CanvasLayers { reorderedFinal.push(...layersToMove); } finalLayers = reorderedFinal; - } else { + } + else { log.warn("Invalid options for moveLayers", options); return; } - - // Zunifikowana końcówka: aktualizacja zIndex i stanu aplikacji const totalLayers = finalLayers.length; finalLayers.forEach((layer, index) => { - // Jeśli przyszły z panelu, zIndex jest odwrócony const zIndex = (options.toIndex !== undefined) ? (totalLayers - 1 - index) : index; layer.zIndex = zIndex; }); - this.canvas.layers = finalLayers; this.canvas.layers.sort((a, b) => a.zIndex - b.zIndex); - if (this.canvas.canvasLayersPanel) { this.canvas.canvasLayersPanel.onLayersChanged(); } - this.canvas.render(); - this.canvas.requestSaveState(); // Użyj opóźnionego zapisu + this.canvas.requestSaveState(); log.info(`Moved ${layersToMove.length} layer(s).`); } - moveLayerUp() { - if (this.canvas.canvasSelection.selectedLayers.length === 0) return; + if (this.canvas.canvasSelection.selectedLayers.length === 0) + return; this.moveLayers(this.canvas.canvasSelection.selectedLayers, { direction: 'up' }); } - moveLayerDown() { - if (this.canvas.canvasSelection.selectedLayers.length === 0) return; + if (this.canvas.canvasSelection.selectedLayers.length === 0) + return; this.moveLayers(this.canvas.canvasSelection.selectedLayers, { direction: 'down' }); } - - /** - * Zmienia rozmiar wybranych warstw - * @param {number} scale - Skala zmiany rozmiaru - */ resizeLayer(scale) { - if (this.canvas.canvasSelection.selectedLayers.length === 0) return; - - this.canvas.canvasSelection.selectedLayers.forEach(layer => { + if (this.canvas.canvasSelection.selectedLayers.length === 0) + return; + this.canvas.canvasSelection.selectedLayers.forEach((layer) => { layer.width *= scale; layer.height *= scale; }); this.canvas.render(); - this.canvas.requestSaveState(); // Użyj opóźnionego zapisu + this.canvas.requestSaveState(); } - - /** - * Obraca wybrane warstwy - * @param {number} angle - Kąt obrotu w stopniach - */ rotateLayer(angle) { - if (this.canvas.canvasSelection.selectedLayers.length === 0) return; - - this.canvas.canvasSelection.selectedLayers.forEach(layer => { + if (this.canvas.canvasSelection.selectedLayers.length === 0) + return; + this.canvas.canvasSelection.selectedLayers.forEach((layer) => { layer.rotation += angle; }); this.canvas.render(); - this.canvas.requestSaveState(); // Użyj opóźnionego zapisu + this.canvas.requestSaveState(); } - getLayerAtPosition(worldX, worldY) { 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; - const dx = worldX - centerX; const dy = worldY - centerY; - const rad = -layer.rotation * Math.PI / 180; const rotatedX = dx * Math.cos(rad) - dy * Math.sin(rad); const rotatedY = dx * Math.sin(rad) + dy * Math.cos(rad); - if (Math.abs(rotatedX) <= layer.width / 2 && Math.abs(rotatedY) <= layer.height / 2) { - const localX = rotatedX + layer.width / 2; - const localY = rotatedY + layer.height / 2; - return { layer: layer, - localX: localX, - localY: localY + localX: rotatedX + layer.width / 2, + localY: rotatedY + layer.height / 2 }; } } return null; } - async mirrorHorizontal() { - if (this.canvas.canvasSelection.selectedLayers.length === 0) return; - - const promises = this.canvas.canvasSelection.selectedLayers.map(layer => { + if (this.canvas.canvasSelection.selectedLayers.length === 0) + return; + const promises = this.canvas.canvasSelection.selectedLayers.map((layer) => { return new Promise(resolve => { const tempCanvas = document.createElement('canvas'); - const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); + const tempCtx = tempCanvas.getContext('2d'); + if (!tempCtx) + return; tempCanvas.width = layer.image.width; tempCanvas.height = layer.image.height; - tempCtx.translate(tempCanvas.width, 0); tempCtx.scale(-1, 1); tempCtx.drawImage(layer.image, 0, 0); - const newImage = new Image(); newImage.onload = () => { layer.image = newImage; @@ -383,26 +325,24 @@ export class CanvasLayers { newImage.src = tempCanvas.toDataURL(); }); }); - await Promise.all(promises); this.canvas.render(); - this.canvas.requestSaveState(); // Użyj opóźnionego zapisu + this.canvas.requestSaveState(); } - async mirrorVertical() { - if (this.canvas.canvasSelection.selectedLayers.length === 0) return; - - const promises = this.canvas.canvasSelection.selectedLayers.map(layer => { + if (this.canvas.canvasSelection.selectedLayers.length === 0) + return; + const promises = this.canvas.canvasSelection.selectedLayers.map((layer) => { return new Promise(resolve => { const tempCanvas = document.createElement('canvas'); - const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); + const tempCtx = tempCanvas.getContext('2d'); + if (!tempCtx) + return; tempCanvas.width = layer.image.width; tempCanvas.height = layer.image.height; - tempCtx.translate(0, tempCanvas.height); tempCtx.scale(1, -1); tempCtx.drawImage(layer.image, 0, 0); - const newImage = new Image(); newImage.onload = () => { layer.image = newImage; @@ -411,46 +351,35 @@ export class CanvasLayers { newImage.src = tempCanvas.toDataURL(); }); }); - await Promise.all(promises); this.canvas.render(); - this.canvas.requestSaveState(); // Użyj opóźnionego zapisu + this.canvas.requestSaveState(); } - async getLayerImageData(layer) { try { const tempCanvas = document.createElement('canvas'); - const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); - + const tempCtx = tempCanvas.getContext('2d'); + if (!tempCtx) + throw new Error("Could not create canvas context"); tempCanvas.width = layer.width; tempCanvas.height = layer.height; - tempCtx.clearRect(0, 0, tempCanvas.width, tempCanvas.height); - tempCtx.save(); tempCtx.translate(layer.width / 2, layer.height / 2); tempCtx.rotate(layer.rotation * Math.PI / 180); - tempCtx.drawImage( - layer.image, - -layer.width / 2, - -layer.height / 2, - layer.width, - layer.height - ); + tempCtx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height); tempCtx.restore(); - const dataUrl = tempCanvas.toDataURL('image/png'); if (!dataUrl.startsWith('data:image/png;base64,')) { throw new Error("Invalid image data format"); } - return dataUrl; - } catch (error) { + } + catch (error) { log.error("Error getting layer image data:", error); throw error; } } - updateOutputAreaSize(width, height, saveHistory = true) { if (saveHistory) { this.canvas.saveState(); @@ -458,40 +387,32 @@ export class CanvasLayers { this.canvas.width = width; this.canvas.height = height; this.canvas.maskTool.resize(width, height); - this.canvas.canvas.width = width; this.canvas.canvas.height = height; - this.canvas.render(); - if (saveHistory) { this.canvas.canvasState.saveStateToDB(); } } - getHandles(layer) { - if (!layer) return {}; - const centerX = layer.x + layer.width / 2; const centerY = layer.y + layer.height / 2; const rad = layer.rotation * Math.PI / 180; const cos = Math.cos(rad); const sin = Math.sin(rad); - const halfW = layer.width / 2; const halfH = layer.height / 2; const localHandles = { - 'n': {x: 0, y: -halfH}, - 'ne': {x: halfW, y: -halfH}, - 'e': {x: halfW, y: 0}, - 'se': {x: halfW, y: halfH}, - 's': {x: 0, y: halfH}, - 'sw': {x: -halfW, y: halfH}, - 'w': {x: -halfW, y: 0}, - 'nw': {x: -halfW, y: -halfH}, - 'rot': {x: 0, y: -halfH - 20 / this.canvas.viewport.zoom} + 'n': { x: 0, y: -halfH }, + 'ne': { x: halfW, y: -halfH }, + 'e': { x: halfW, y: 0 }, + 'se': { x: halfW, y: halfH }, + 's': { x: 0, y: halfH }, + 'sw': { x: -halfW, y: halfH }, + 'w': { x: -halfW, y: 0 }, + 'nw': { x: -halfW, y: -halfH }, + 'rot': { x: 0, y: -halfH - 20 / this.canvas.viewport.zoom } }; - const worldHandles = {}; for (const key in localHandles) { const p = localHandles[key]; @@ -502,30 +423,26 @@ export class CanvasLayers { } return worldHandles; } - getHandleAtPosition(worldX, worldY) { - if (this.canvas.canvasSelection.selectedLayers.length === 0) return null; - + if (this.canvas.canvasSelection.selectedLayers.length === 0) + return null; const handleRadius = 8 / this.canvas.viewport.zoom; for (let i = this.canvas.canvasSelection.selectedLayers.length - 1; i >= 0; i--) { const layer = this.canvas.canvasSelection.selectedLayers[i]; const handles = this.getHandles(layer); - for (const key in handles) { const handlePos = handles[key]; const dx = worldX - handlePos.x; const dy = worldY - handlePos.y; if (dx * dx + dy * dy <= handleRadius * handleRadius) { - return {layer: layer, handle: key}; + return { layer: layer, handle: key }; } } } return null; } - showBlendModeMenu(x, y) { this.closeBlendModeMenu(); - const menu = document.createElement('div'); menu.id = 'blend-mode-menu'; menu.style.cssText = ` @@ -539,7 +456,6 @@ export class CanvasLayers { box-shadow: 0 2px 10px rgba(0,0,0,0.3); min-width: 200px; `; - const titleBar = document.createElement('div'); titleBar.style.cssText = ` background: #3a3a3a; @@ -553,31 +469,22 @@ export class CanvasLayers { border-bottom: 1px solid #4a4a4a; `; titleBar.textContent = 'Blend Mode'; - const content = document.createElement('div'); - content.style.cssText = ` - padding: 5px; - `; - + 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; @@ -585,294 +492,120 @@ export class CanvasLayers { 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); + dragOffset.x = e.clientX - parseInt(menu.style.left, 10); + dragOffset.y = e.clientY - parseInt(menu.style.top, 10); e.preventDefault(); e.stopPropagation(); - document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); }); - - this.blendModes.forEach(mode => { + this.blendModes.forEach((mode) => { const container = document.createElement('div'); container.className = 'blend-mode-container'; - container.style.cssText = ` - margin-bottom: 5px; - `; - + container.style.cssText = `margin-bottom: 5px;`; const option = document.createElement('div'); - option.style.cssText = ` - padding: 5px 10px; - color: white; - cursor: pointer; - transition: background-color 0.2s; - `; + option.style.cssText = `padding: 5px 10px; color: white; cursor: pointer; transition: background-color 0.2s;`; option.textContent = `${mode.label} (${mode.name})`; - const slider = document.createElement('input'); slider.type = 'range'; slider.min = '0'; slider.max = '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.canvas.selectedLayer.blendMode === mode.name) { + const selectedLayer = this.canvas.canvasSelection.selectedLayers[0]; + slider.value = selectedLayer ? String(Math.round(selectedLayer.opacity * 100)) : '100'; + slider.style.cssText = `width: 100%; margin: 5px 0; display: none;`; + if (selectedLayer && selectedLayer.blendMode === mode.name) { slider.style.display = 'block'; option.style.backgroundColor = '#3a3a3a'; } - option.onclick = () => { - content.querySelectorAll('input[type="range"]').forEach(s => { - s.style.display = 'none'; - }); - content.querySelectorAll('.blend-mode-container div').forEach(d => { - d.style.backgroundColor = ''; - }); - + content.querySelectorAll('input[type="range"]').forEach(s => s.style.display = 'none'); + content.querySelectorAll('.blend-mode-container div').forEach(d => d.style.backgroundColor = ''); slider.style.display = 'block'; option.style.backgroundColor = '#3a3a3a'; - - if (this.canvas.selectedLayer) { - this.canvas.selectedLayer.blendMode = mode.name; + if (selectedLayer) { + selectedLayer.blendMode = mode.name; this.canvas.render(); } }; - slider.addEventListener('input', () => { - if (this.canvas.selectedLayer) { - this.canvas.selectedLayer.opacity = slider.value / 100; + if (selectedLayer) { + selectedLayer.opacity = parseInt(slider.value, 10) / 100; this.canvas.render(); } }); - slider.addEventListener('change', async () => { - if (this.canvas.selectedLayer) { - this.canvas.selectedLayer.opacity = slider.value / 100; + if (selectedLayer) { + selectedLayer.opacity = parseInt(slider.value, 10) / 100; this.canvas.render(); const saveWithFallback = async (fileName) => { try { const uniqueFileName = generateUniqueFileName(fileName, this.canvas.node.id); - return await this.canvas.saveToServer(uniqueFileName); - } catch (error) { + return await this.canvas.canvasIO.saveToServer(uniqueFileName); + } + catch (error) { console.warn(`Failed to save with unique name, falling back to original: ${fileName}`, error); - return await this.canvas.saveToServer(fileName); + return await this.canvas.canvasIO.saveToServer(fileName); } }; - - await saveWithFallback(this.canvas.widget.value); - if (this.canvas.node) { - app.graph.runStep(); + if (this.canvas.widget) { + await saveWithFallback(this.canvas.widget.value); + if (this.canvas.node) { + app.graph.runStep(); + } } } }); - container.appendChild(option); container.appendChild(slider); content.appendChild(container); }); - const container = this.canvas.canvas.parentElement || document.body; container.appendChild(menu); - const closeMenu = (e) => { - if (!menu.contains(e.target) && !isDragging) { + if (e.target instanceof Node && !menu.contains(e.target) && !isDragging) { this.closeBlendModeMenu(); document.removeEventListener('mousedown', closeMenu); } }; - setTimeout(() => { - document.addEventListener('mousedown', closeMenu); - }, 0); + setTimeout(() => document.addEventListener('mousedown', closeMenu), 0); } - closeBlendModeMenu() { const menu = document.getElementById('blend-mode-menu'); if (menu && menu.parentNode) { menu.parentNode.removeChild(menu); } } - showOpacitySlider(mode) { const slider = document.createElement('input'); slider.type = 'range'; slider.min = '0'; slider.max = '100'; - slider.value = this.blendOpacity; + slider.value = String(this.blendOpacity); slider.className = 'blend-opacity-slider'; - slider.addEventListener('input', (e) => { - this.blendOpacity = parseInt(e.target.value); + this.blendOpacity = parseInt(e.target.value, 10); }); - const modeElement = document.querySelector(`[data-blend-mode="${mode}"]`); if (modeElement) { modeElement.appendChild(slider); } } - - async getFlattenedCanvasAsBlob() { - 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(); - }); - - tempCanvas.toBlob((blob) => { - if (blob) { - resolve(blob); - } else { - reject(new Error('Canvas toBlob failed.')); - } - }, 'image/png'); - }); - } - 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); + const tempCtx = tempCanvas.getContext('2d'); + if (!tempCtx) { + reject(new Error("Could not create canvas context")); + return; } - - 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; - + sortedLayers.forEach((layer) => { + if (!layer.image) + return; tempCtx.save(); tempCtx.globalCompositeOperation = layer.blendMode || 'normal'; tempCtx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1; @@ -880,55 +613,33 @@ export class CanvasLayers { 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.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 }); - + const tempMaskCtx = tempMaskCanvas.getContext('2d'); + if (!tempMaskCtx) { + reject(new Error("Could not create mask canvas context")); + return; + } 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 - ); - + 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 - ); + 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]; @@ -936,70 +647,94 @@ export class CanvasLayers { 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.')); + } + else { + resolve(null); } }, 'image/png'); }); } - + async getFlattenedCanvasAsBlob() { + 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'); + if (!tempCtx) { + reject(new Error("Could not create canvas context")); + return; + } + 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(); + }); + tempCanvas.toBlob((blob) => { + if (blob) { + resolve(blob); + } + else { + resolve(null); + } + }, 'image/png'); + }); + } + async getFlattenedCanvasForMaskEditor() { + return this.getFlattenedCanvasWithMaskAsBlob(); + } async getFlattenedSelectionAsBlob() { if (this.canvas.canvasSelection.selectedLayers.length === 0) { return null; } - - return new Promise((resolve) => { + return new Promise((resolve, reject) => { let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; - this.canvas.canvasSelection.selectedLayers.forEach(layer => { + this.canvas.canvasSelection.selectedLayers.forEach((layer) => { const centerX = layer.x + layer.width / 2; const centerY = layer.y + layer.height / 2; const rad = layer.rotation * Math.PI / 180; const cos = Math.cos(rad); const sin = Math.sin(rad); - const halfW = layer.width / 2; const halfH = layer.height / 2; - const corners = [ - {x: -halfW, y: -halfH}, - {x: halfW, y: -halfH}, - {x: halfW, y: halfH}, - {x: -halfW, y: halfH} + { x: -halfW, y: -halfH }, + { x: halfW, y: -halfH }, + { x: halfW, y: halfH }, + { x: -halfW, y: halfH } ]; - corners.forEach(p => { const worldX = centerX + (p.x * cos - p.y * sin); const worldY = centerY + (p.x * sin + p.y * cos); - minX = Math.min(minX, worldX); minY = Math.min(minY, worldY); maxX = Math.max(maxX, worldX); maxY = Math.max(maxY, worldY); }); }); - const newWidth = Math.ceil(maxX - minX); const newHeight = Math.ceil(maxY - minY); - if (newWidth <= 0 || newHeight <= 0) { resolve(null); return; @@ -1007,28 +742,24 @@ export class CanvasLayers { const tempCanvas = document.createElement('canvas'); tempCanvas.width = newWidth; tempCanvas.height = newHeight; - const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); - + const tempCtx = tempCanvas.getContext('2d'); + if (!tempCtx) { + reject(new Error("Could not create canvas context")); + return; + } tempCtx.translate(-minX, -minY); - const sortedSelection = [...this.canvas.canvasSelection.selectedLayers].sort((a, b) => a.zIndex - b.zIndex); - - sortedSelection.forEach(layer => { - if (!layer.image) return; - + sortedSelection.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.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height); tempCtx.restore(); }); tempCanvas.toBlob((blob) => { @@ -1036,112 +767,81 @@ export class CanvasLayers { }, 'image/png'); }); } - - /** - * Fuses (flattens and merges) selected layers into a single layer - */ async fuseLayers() { if (this.canvas.canvasSelection.selectedLayers.length < 2) { alert("Please select at least 2 layers to fuse."); return; } - log.info(`Fusing ${this.canvas.canvasSelection.selectedLayers.length} selected layers`); - try { - // Save state for undo this.canvas.saveState(); - - // Calculate bounding box of all selected layers let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; - this.canvas.canvasSelection.selectedLayers.forEach(layer => { + this.canvas.canvasSelection.selectedLayers.forEach((layer) => { const centerX = layer.x + layer.width / 2; const centerY = layer.y + layer.height / 2; const rad = layer.rotation * Math.PI / 180; const cos = Math.cos(rad); const sin = Math.sin(rad); - const halfW = layer.width / 2; const halfH = layer.height / 2; - const corners = [ - {x: -halfW, y: -halfH}, - {x: halfW, y: -halfH}, - {x: halfW, y: halfH}, - {x: -halfW, y: halfH} + { x: -halfW, y: -halfH }, + { x: halfW, y: -halfH }, + { x: halfW, y: halfH }, + { x: -halfW, y: halfH } ]; - corners.forEach(p => { const worldX = centerX + (p.x * cos - p.y * sin); const worldY = centerY + (p.x * sin + p.y * cos); - minX = Math.min(minX, worldX); minY = Math.min(minY, worldY); maxX = Math.max(maxX, worldX); maxY = Math.max(maxY, worldY); }); }); - const fusedWidth = Math.ceil(maxX - minX); const fusedHeight = Math.ceil(maxY - minY); - if (fusedWidth <= 0 || fusedHeight <= 0) { log.warn("Calculated fused layer dimensions are invalid"); alert("Cannot fuse layers: invalid dimensions calculated."); return; } - - // Create temporary canvas for flattening const tempCanvas = document.createElement('canvas'); tempCanvas.width = fusedWidth; tempCanvas.height = fusedHeight; - const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); - - // Translate context to account for the bounding box offset + const tempCtx = tempCanvas.getContext('2d'); + if (!tempCtx) + throw new Error("Could not create canvas context"); tempCtx.translate(-minX, -minY); - - // Sort selected layers by z-index and render them const sortedSelection = [...this.canvas.canvasSelection.selectedLayers].sort((a, b) => a.zIndex - b.zIndex); - - sortedSelection.forEach(layer => { - if (!layer.image) return; - + sortedSelection.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.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height); tempCtx.restore(); }); - - // Convert flattened canvas to image const fusedImage = new Image(); fusedImage.src = tempCanvas.toDataURL(); await new Promise((resolve, reject) => { fusedImage.onload = resolve; fusedImage.onerror = reject; }); - - // Find the lowest z-index among selected layers to maintain visual order - const minZIndex = Math.min(...this.canvas.canvasSelection.selectedLayers.map(layer => layer.zIndex)); - - // Generate unique ID for the new fused layer + const minZIndex = Math.min(...this.canvas.canvasSelection.selectedLayers.map((layer) => layer.zIndex)); const imageId = generateUUID(); await saveImage(imageId, fusedImage.src); this.canvas.imageCache.set(imageId, fusedImage.src); - - // Create the new fused layer const fusedLayer = { + id: generateUUID(), image: fusedImage, imageId: imageId, + name: 'Fused Layer', x: minX, y: minY, width: fusedWidth, @@ -1153,38 +853,25 @@ export class CanvasLayers { blendMode: 'normal', opacity: 1 }; - - // Remove selected layers from canvas - this.canvas.layers = this.canvas.layers.filter(layer => !this.canvas.canvasSelection.selectedLayers.includes(layer)); - - // Insert the fused layer at the correct position + this.canvas.layers = this.canvas.layers.filter((layer) => !this.canvas.canvasSelection.selectedLayers.includes(layer)); this.canvas.layers.push(fusedLayer); - - // Re-index all layers to maintain proper z-order this.canvas.layers.sort((a, b) => a.zIndex - b.zIndex); this.canvas.layers.forEach((layer, index) => { layer.zIndex = index; }); - - // Select the new fused layer this.canvas.updateSelection([fusedLayer]); - - // Render and save state this.canvas.render(); this.canvas.saveState(); - - // Notify the layers panel to update its view if (this.canvas.canvasLayersPanel) { this.canvas.canvasLayersPanel.onLayersChanged(); } - log.info("Layers fused successfully", { originalLayerCount: sortedSelection.length, fusedDimensions: { width: fusedWidth, height: fusedHeight }, fusedPosition: { x: minX, y: minY } }); - - } catch (error) { + } + catch (error) { log.error("Error during layer fusion:", error); alert(`Error fusing layers: ${error.message}`); } diff --git a/js/CanvasLayersPanel.js b/js/CanvasLayersPanel.js index e9a89cf..08c7fe2 100644 --- a/js/CanvasLayersPanel.js +++ b/js/CanvasLayersPanel.js @@ -1,7 +1,5 @@ -import {createModuleLogger} from "./utils/LoggerUtils.js"; - +import { createModuleLogger } from "./utils/LoggerUtils.js"; const log = createModuleLogger('CanvasLayersPanel'); - export class CanvasLayersPanel { constructor(canvas) { this.canvas = canvas; @@ -11,22 +9,14 @@ export class CanvasLayersPanel { this.dragInsertionLine = null; this.isMultiSelecting = false; this.lastSelectedIndex = -1; - - // Binding metod dla event handlerów this.handleLayerClick = this.handleLayerClick.bind(this); this.handleDragStart = this.handleDragStart.bind(this); this.handleDragOver = this.handleDragOver.bind(this); this.handleDragEnd = this.handleDragEnd.bind(this); this.handleDrop = this.handleDrop.bind(this); - log.info('CanvasLayersPanel initialized'); } - - /** - * Tworzy strukturê HTML panelu warstw - */ createPanelStructure() { - // Główny kontener panelu this.container = document.createElement('div'); this.container.className = 'layers-panel'; this.container.tabIndex = 0; // Umożliwia fokus na panelu @@ -41,15 +31,10 @@ export class CanvasLayersPanel { `; - this.layersContainer = this.container.querySelector('#layers-container'); - - // Dodanie stylów CSS this.injectStyles(); - // Setup event listeners dla przycisków this.setupControlButtons(); - // Dodaj listener dla klawiatury, aby usuwanie działało z panelu this.container.addEventListener('keydown', (e) => { if (e.key === 'Delete' || e.key === 'Backspace') { @@ -58,20 +43,14 @@ export class CanvasLayersPanel { this.deleteSelectedLayers(); } }); - log.debug('Panel structure created'); return this.container; } - - /** - * Dodaje style CSS do panelu - */ injectStyles() { const styleId = 'layers-panel-styles'; if (document.getElementById(styleId)) { return; // Style już istnieją } - const style = document.createElement('style'); style.id = styleId; style.textContent = ` @@ -253,404 +232,282 @@ export class CanvasLayersPanel { background: #5a5a5a; } `; - document.head.appendChild(style); log.debug('Styles injected'); } - - /** - * Konfiguruje event listenery dla przycisków kontrolnych - */ setupControlButtons() { + if (!this.container) + return; const deleteBtn = this.container.querySelector('#delete-layer-btn'); - deleteBtn?.addEventListener('click', () => { log.info('Delete layer button clicked'); this.deleteSelectedLayers(); }); } - - /** - * Renderuje listę warstw - */ renderLayers() { if (!this.layersContainer) { log.warn('Layers container not initialized'); return; } - // Wyczyść istniejącą zawartość this.layersContainer.innerHTML = ''; - // Usuń linię wstawiania jeśli istnieje this.removeDragInsertionLine(); - // Sortuj warstwy według zIndex (od najwyższej do najniższej) const sortedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex); - sortedLayers.forEach((layer, index) => { const layerElement = this.createLayerElement(layer, index); - this.layersContainer.appendChild(layerElement); + if (this.layersContainer) + this.layersContainer.appendChild(layerElement); }); - log.debug(`Rendered ${sortedLayers.length} layers`); } - - /** - * Tworzy element HTML dla pojedynczej warstwy - */ createLayerElement(layer, index) { const layerRow = document.createElement('div'); layerRow.className = 'layer-row'; layerRow.draggable = true; - layerRow.dataset.layerIndex = index; - - // Sprawdź czy warstwa jest zaznaczona + layerRow.dataset.layerIndex = String(index); const isSelected = this.canvas.canvasSelection.selectedLayers.includes(layer); if (isSelected) { layerRow.classList.add('selected'); } - // Ustawienie domyślnych właściwości jeśli nie istnieją if (!layer.name) { layer.name = this.ensureUniqueName(`Layer ${layer.zIndex + 1}`, layer); - } else { + } + else { // Sprawdź unikalność istniejącej nazwy (np. przy duplikowaniu) layer.name = this.ensureUniqueName(layer.name, layer); } - layerRow.innerHTML = `
${layer.name} `; - - // Wygeneruj miniaturkę - this.generateThumbnail(layer, layerRow.querySelector('.layer-thumbnail')); - - // Event listenery + const thumbnailContainer = layerRow.querySelector('.layer-thumbnail'); + if (thumbnailContainer) { + this.generateThumbnail(layer, thumbnailContainer); + } this.setupLayerEventListeners(layerRow, layer, index); - return layerRow; } - - /** - * Generuje miniaturkę warstwy - */ generateThumbnail(layer, thumbnailContainer) { if (!layer.image) { thumbnailContainer.style.background = '#4a4a4a'; return; } - const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d', { willReadFrequently: true }); + if (!ctx) + return; canvas.width = 48; canvas.height = 48; - - // Oblicz skalę zachowując proporcje const scale = Math.min(48 / layer.image.width, 48 / layer.image.height); const scaledWidth = layer.image.width * scale; const scaledHeight = layer.image.height * scale; - // Wycentruj obraz const x = (48 - scaledWidth) / 2; const y = (48 - scaledHeight) / 2; - - // Narysuj obraz z wyższą jakością ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; ctx.drawImage(layer.image, x, y, scaledWidth, scaledHeight); - thumbnailContainer.appendChild(canvas); } - - /** - * Konfiguruje event listenery dla elementu warstwy - */ setupLayerEventListeners(layerRow, layer, index) { - // Mousedown handler - zaznaczanie w momencie wciśnięcia przycisku layerRow.addEventListener('mousedown', (e) => { - // Ignoruj, jeśli edytujemy nazwę const nameElement = layerRow.querySelector('.layer-name'); if (nameElement && nameElement.classList.contains('editing')) { return; } this.handleLayerClick(e, layer, index); }); - - // Double click handler - edycja nazwy layerRow.addEventListener('dblclick', (e) => { e.preventDefault(); e.stopPropagation(); const nameElement = layerRow.querySelector('.layer-name'); - this.startEditingLayerName(nameElement, layer); + if (nameElement) { + this.startEditingLayerName(nameElement, layer); + } }); - - // Drag handlers layerRow.addEventListener('dragstart', (e) => this.handleDragStart(e, layer, index)); - layerRow.addEventListener('dragover', this.handleDragOver); - layerRow.addEventListener('dragend', this.handleDragEnd); + layerRow.addEventListener('dragover', this.handleDragOver.bind(this)); + layerRow.addEventListener('dragend', this.handleDragEnd.bind(this)); layerRow.addEventListener('drop', (e) => this.handleDrop(e, index)); } - - /** - * Obsługuje kliknięcie na warstwę, aktualizując stan bez pełnego renderowania. - */ handleLayerClick(e, layer, index) { const isCtrlPressed = e.ctrlKey || e.metaKey; const isShiftPressed = e.shiftKey; - // Aktualizuj wewnętrzny stan zaznaczenia w obiekcie canvas // Ta funkcja NIE powinna już wywoływać onSelectionChanged w panelu. this.canvas.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index); - // Aktualizuj tylko wygląd (klasy CSS), bez niszczenia DOM - this.updateSelectionAppearance(); - + this.updateSelectionAppearance(); log.debug(`Layer clicked: ${layer.name}, selection count: ${this.canvas.canvasSelection.selectedLayers.length}`); } - - - /** - * Rozpoczyna edycję nazwy warstwy - */ startEditingLayerName(nameElement, layer) { const currentName = layer.name; nameElement.classList.add('editing'); - const input = document.createElement('input'); input.type = 'text'; input.value = currentName; input.style.width = '100%'; - nameElement.innerHTML = ''; nameElement.appendChild(input); - input.focus(); input.select(); - const finishEditing = () => { let newName = input.value.trim() || `Layer ${layer.zIndex + 1}`; newName = this.ensureUniqueName(newName, layer); layer.name = newName; nameElement.classList.remove('editing'); nameElement.textContent = newName; - this.canvas.saveState(); log.info(`Layer renamed to: ${newName}`); }; - input.addEventListener('blur', finishEditing); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { finishEditing(); - } else if (e.key === 'Escape') { + } + else if (e.key === 'Escape') { nameElement.classList.remove('editing'); nameElement.textContent = currentName; } }); } - - - /** - * Zapewnia unikalność nazwy warstwy - */ ensureUniqueName(proposedName, currentLayer) { const existingNames = this.canvas.layers - .filter(layer => layer !== currentLayer) - .map(layer => layer.name); - + .filter((layer) => layer !== currentLayer) + .map((layer) => layer.name); if (!existingNames.includes(proposedName)) { return proposedName; } - // Sprawdź czy nazwa już ma numerację w nawiasach const match = proposedName.match(/^(.+?)\s*\((\d+)\)$/); let baseName, startNumber; - if (match) { baseName = match[1].trim(); startNumber = parseInt(match[2]) + 1; - } else { + } + else { baseName = proposedName; startNumber = 1; } - // Znajdź pierwszą dostępną numerację let counter = startNumber; let uniqueName; - do { uniqueName = `${baseName} (${counter})`; counter++; } while (existingNames.includes(uniqueName)); - return uniqueName; } - - /** - * Usuwa zaznaczone warstwy - */ deleteSelectedLayers() { if (this.canvas.canvasSelection.selectedLayers.length === 0) { log.debug('No layers selected for deletion'); return; } - log.info(`Deleting ${this.canvas.canvasSelection.selectedLayers.length} selected layers`); this.canvas.removeSelectedLayers(); this.renderLayers(); } - - /** - * Rozpoczyna przeciąganie warstwy - */ handleDragStart(e, layer, index) { - // Sprawdź czy jakakolwiek warstwa jest w trybie edycji + if (!this.layersContainer || !e.dataTransfer) + return; const editingElement = this.layersContainer.querySelector('.layer-name.editing'); if (editingElement) { e.preventDefault(); return; } - // Jeśli przeciągana warstwa nie jest zaznaczona, zaznacz ją if (!this.canvas.canvasSelection.selectedLayers.includes(layer)) { this.canvas.updateSelection([layer]); this.renderLayers(); } - this.draggedElements = [...this.canvas.canvasSelection.selectedLayers]; e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.setData('text/plain', ''); // Wymagane przez standard - - // Dodaj klasę dragging do przeciąganych elementów + e.dataTransfer.setData('text/plain', ''); this.layersContainer.querySelectorAll('.layer-row').forEach((row, idx) => { const sortedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex); if (this.draggedElements.includes(sortedLayers[idx])) { row.classList.add('dragging'); } }); - log.debug(`Started dragging ${this.draggedElements.length} layers`); } - - /** - * Obsługuje przeciąganie nad warstwą - */ handleDragOver(e) { e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; - + if (e.dataTransfer) + e.dataTransfer.dropEffect = 'move'; const layerRow = e.currentTarget; const rect = layerRow.getBoundingClientRect(); const midpoint = rect.top + rect.height / 2; const isUpperHalf = e.clientY < midpoint; - this.showDragInsertionLine(layerRow, isUpperHalf); } - - /** - * Pokazuje linię wskaźnika wstawiania - */ showDragInsertionLine(targetRow, isUpperHalf) { this.removeDragInsertionLine(); - const line = document.createElement('div'); line.className = 'drag-insertion-line'; - if (isUpperHalf) { line.style.top = '-1px'; - } else { + } + else { line.style.bottom = '-1px'; } - targetRow.style.position = 'relative'; targetRow.appendChild(line); this.dragInsertionLine = line; } - - /** - * Usuwa linię wskaźnika wstawiania - */ removeDragInsertionLine() { if (this.dragInsertionLine) { this.dragInsertionLine.remove(); this.dragInsertionLine = null; } } - - /** - * Obsługuje upuszczenie warstwy - */ handleDrop(e, targetIndex) { e.preventDefault(); this.removeDragInsertionLine(); - - if (this.draggedElements.length === 0) return; - + if (this.draggedElements.length === 0 || !(e.currentTarget instanceof HTMLElement)) + return; const rect = e.currentTarget.getBoundingClientRect(); const midpoint = rect.top + rect.height / 2; const isUpperHalf = e.clientY < midpoint; - // Oblicz docelowy indeks let insertIndex = targetIndex; if (!isUpperHalf) { insertIndex = targetIndex + 1; } - // Użyj nowej, centralnej funkcji do przesuwania warstw this.canvas.canvasLayers.moveLayers(this.draggedElements, { toIndex: insertIndex }); - log.info(`Dropped ${this.draggedElements.length} layers at position ${insertIndex}`); } - - /** - * Kończy przeciąganie - */ handleDragEnd(e) { this.removeDragInsertionLine(); - - // Usuń klasę dragging ze wszystkich elementów - this.layersContainer.querySelectorAll('.layer-row').forEach(row => { + if (!this.layersContainer) + return; + this.layersContainer.querySelectorAll('.layer-row').forEach((row) => { row.classList.remove('dragging'); }); - this.draggedElements = []; } - - - /** - * Aktualizuje panel gdy zmienią się warstwy - */ onLayersChanged() { this.renderLayers(); } - - /** - * Aktualizuje wygląd zaznaczenia w panelu bez pełnego renderowania. - */ updateSelectionAppearance() { + if (!this.layersContainer) + return; const sortedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex); const layerRows = this.layersContainer.querySelectorAll('.layer-row'); - layerRows.forEach((row, index) => { const layer = sortedLayers[index]; if (this.canvas.canvasSelection.selectedLayers.includes(layer)) { row.classList.add('selected'); - } else { + } + else { row.classList.remove('selected'); } }); } - - /** - * Aktualizuje panel gdy zmienią się warstwy (np. dodanie, usunięcie, zmiana kolejności) - * To jest jedyne miejsce, gdzie powinniśmy w pełni renderować panel. - */ - onLayersChanged() { - this.renderLayers(); - } - /** * Aktualizuje panel gdy zmieni się zaznaczenie (wywoływane z zewnątrz). * Zamiast pełnego renderowania, tylko aktualizujemy wygląd. @@ -658,10 +515,6 @@ export class CanvasLayersPanel { onSelectionChanged() { this.updateSelectionAppearance(); } - - /** - * Niszczy panel i czyści event listenery - */ destroy() { if (this.container && this.container.parentNode) { this.container.parentNode.removeChild(this.container); @@ -670,7 +523,6 @@ export class CanvasLayersPanel { this.layersContainer = null; this.draggedElements = []; this.removeDragInsertionLine(); - log.info('CanvasLayersPanel destroyed'); } } diff --git a/js/CanvasMask.js b/js/CanvasMask.js index c85c2e4..fff8653 100644 --- a/js/CanvasMask.js +++ b/js/CanvasMask.js @@ -1,22 +1,22 @@ -import { app, ComfyApp } from "../../scripts/app.js"; +// @ts-ignore +import { app } from "../../scripts/app.js"; +// @ts-ignore +import { ComfyApp } from "../../scripts/app.js"; +// @ts-ignore import { api } from "../../scripts/api.js"; import { createModuleLogger } from "./utils/LoggerUtils.js"; import { mask_editor_showing, mask_editor_listen_for_cancel } from "./utils/mask_utils.js"; - const log = createModuleLogger('CanvasMask'); - export class CanvasMask { constructor(canvas) { this.canvas = canvas; this.node = canvas.node; this.maskTool = canvas.maskTool; - this.savedMaskState = null; this.maskEditorCancelled = false; this.pendingMask = null; this.editorWasShowing = false; } - /** * Uruchamia edytor masek * @param {Image|HTMLCanvasElement|null} predefinedMask - Opcjonalna maska do nałożenia po otwarciu editora @@ -28,126 +28,101 @@ export class CanvasMask { sendCleanImage, layersCount: this.canvas.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) { + } + 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.canvas.canvasLayers.getFlattenedCanvasAsBlob(); - } else { + } + else { log.debug('Getting flattened canvas for mask editor (with mask)'); blob = await this.canvas.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) { + } + catch (error) { log.error("Error preparing image for mask editor:", error); alert(`Error: ${error.message}`); } } - - /** * 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) { - + } + 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; @@ -155,133 +130,119 @@ export class CanvasMask { } } } - } else { - + } + else { const maskCanvas = document.getElementById('maskCanvas'); - editorReady = maskCanvas && maskCanvas.getContext && maskCanvas.width > 0; - if (editorReady) { - log.info("Old mask editor detected as ready"); + if (maskCanvas) { + editorReady = !!(maskCanvas.getContext('2d') && maskCanvas.width > 0 && maskCanvas.height > 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) { - + } + else if (attempts < maxAttempts) { if (attempts % 10 === 0) { log.info("Waiting for mask editor to be ready... attempt", attempts, "/", maxAttempts); } setTimeout(checkEditor, 100); - } else { + } + 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) { - + } + else if (attempts < maxAttempts) { setTimeout(checkEditor, 100); - } else { + } + 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 { + } + else { log.warn("New editor setting enabled but instance not found, trying old editor"); await this.applyMaskToOldEditor(maskData); } - } else { - + } + else { await this.applyMaskToOldEditor(maskData); } - log.info("Predefined mask applied to mask editor successfully"); - } catch (error) { + } + 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) { + } + 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 maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true }); + if (!maskCtx) { + throw new Error("Old mask editor context not found"); + } + 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 @@ -289,61 +250,54 @@ export class CanvasMask { * @param {number} targetHeight - Docelowa wysokość * @param {Object} maskColor - Kolor maski {r, g, b} * @returns {HTMLCanvasElement} Przetworzona maska - */async processMaskForEditor(maskData, targetWidth, targetHeight, maskColor) { + */ async processMaskForEditor(maskData, targetWidth, targetHeight, maskColor) { // Współrzędne przesunięcia (pan) widoku edytora const panX = this.maskTool.x; const panY = this.maskTool.y; - log.info("Processing mask for editor:", { - sourceSize: {width: maskData.width, height: maskData.height}, - targetSize: {width: targetWidth, height: targetHeight}, - viewportPan: {x: panX, y: panY} + sourceSize: { width: maskData.width, height: maskData.height }, + targetSize: { width: targetWidth, height: targetHeight }, + viewportPan: { x: panX, y: panY } }); - const tempCanvas = document.createElement('canvas'); tempCanvas.width = targetWidth; tempCanvas.height = targetHeight; - const tempCtx = tempCanvas.getContext('2d', {willReadFrequently: true}); - + const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); const sourceX = -panX; const sourceY = -panY; - - tempCtx.drawImage( - maskData, // Źródło: pełna maska z "output area" - sourceX, // sx: Prawdziwa współrzędna X na dużej masce (np. 1000) - sourceY, // sy: Prawdziwa współrzędna Y na dużej masce (np. 1000) - targetWidth, // sWidth: Szerokość wycinanego fragmentu - targetHeight, // sHeight: Wysokość wycinanego fragmentu - 0, // dx: Gdzie wkleić w płótnie docelowym (zawsze 0) - 0, // dy: Gdzie wkleić w płótnie docelowym (zawsze 0) - targetWidth, // dWidth: Szerokość wklejanego obrazu - targetHeight // dHeight: Wysokość wklejanego obrazu - ); - + if (tempCtx) { + tempCtx.drawImage(maskData, // Źródło: pełna maska z "output area" + sourceX, // sx: Prawdziwa współrzędna X na dużej masce (np. 1000) + sourceY, // sy: Prawdziwa współrzędna Y na dużej masce (np. 1000) + targetWidth, // sWidth: Szerokość wycinanego fragmentu + targetHeight, // sHeight: Wysokość wycinanego fragmentu + 0, // dx: Gdzie wkleić w płótnie docelowym (zawsze 0) + 0, // dy: Gdzie wkleić w płótnie docelowym (zawsze 0) + targetWidth, // dWidth: Szerokość wklejanego obrazu + targetHeight // dHeight: Wysokość wklejanego obrazu + ); + } log.info("Mask viewport cropped correctly.", { source: "maskData", - cropArea: {x: sourceX, y: sourceY, width: targetWidth, height: targetHeight} + cropArea: { x: sourceX, y: sourceY, width: targetWidth, height: targetHeight } }); - // Reszta kodu (zmiana koloru) pozostaje bez zmian - 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]; - if (alpha > 0) { - data[i] = maskColor.r; - data[i + 1] = maskColor.g; - data[i + 2] = maskColor.b; + if (tempCtx) { + 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]; + if (alpha > 0) { + data[i] = maskColor.r; + data[i + 1] = maskColor.g; + data[i + 2] = maskColor.b; + } } + tempCtx.putImageData(imageData, 0, 0); } - - tempCtx.putImageData(imageData, 0, 0); - log.info("Mask processing completed - color applied."); return tempCanvas; } - /** * Tworzy obiekt Image z obecnej maski canvas * @returns {Promise} Promise zwracający obiekt Image z maską @@ -352,7 +306,6 @@ export class CanvasMask { 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); @@ -360,20 +313,18 @@ export class CanvasMask { 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 { + } + else { setTimeout(this.waitWhileMaskEditing.bind(this), 100); } } - /** * Zapisuje obecny stan maski przed otwarciem editora * @returns {Object} Zapisany stan maski @@ -382,14 +333,14 @@ export class CanvasMask { 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); - + const savedCtx = savedCanvas.getContext('2d', { willReadFrequently: true }); + if (savedCtx) { + savedCtx.drawImage(maskCanvas, 0, 0); + } return { maskData: savedCanvas, maskPosition: { @@ -398,7 +349,6 @@ export class CanvasMask { } }; } - /** * Przywraca zapisany stan maski * @param {Object} savedState - Zapisany stan maski @@ -407,22 +357,18 @@ export class CanvasMask { 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.canvas.render(); log.info("Mask state restored after cancel"); } - /** * Konfiguruje nasłuchiwanie na przycisk Cancel w mask editorze */ @@ -432,110 +378,89 @@ export class CanvasMask { 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) { + 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) { + } + 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.canvas.width; tempCanvas.height = this.canvas.height; - const tempCtx = tempCanvas.getContext('2d', {willReadFrequently: true}); - - tempCtx.drawImage(resultImage, 0, 0, this.canvas.width, this.canvas.height); - - log.debug("Processing image data to create mask"); - const imageData = tempCtx.getImageData(0, 0, this.canvas.width, this.canvas.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; + const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); + if (tempCtx) { + tempCtx.drawImage(resultImage, 0, 0, this.canvas.width, this.canvas.height); + log.debug("Processing image data to create mask"); + const imageData = tempCtx.getImageData(0, 0, this.canvas.width, this.canvas.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); } - - 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}); - + log.debug("Applying mask to canvas", { destX, destY }); maskCtx.globalCompositeOperation = 'source-over'; maskCtx.clearRect(destX, destY, this.canvas.width, this.canvas.height); - maskCtx.drawImage(maskAsImage, destX, destY); - this.canvas.render(); this.canvas.saveState(); - log.debug("Creating new preview image"); const new_preview = new Image(); - const blob = await this.canvas.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 { + } + else { this.node.imgs = []; log.warn("Failed to create preview blob"); } - this.canvas.render(); - this.savedMaskState = null; log.info("Mask editor result processed successfully"); } diff --git a/js/CanvasRenderer.js b/js/CanvasRenderer.js index a177b6e..71332cd 100644 --- a/js/CanvasRenderer.js +++ b/js/CanvasRenderer.js @@ -1,7 +1,5 @@ -import {createModuleLogger} from "./utils/LoggerUtils.js"; - +import { createModuleLogger } from "./utils/LoggerUtils.js"; const log = createModuleLogger('CanvasRenderer'); - export class CanvasRenderer { constructor(canvas) { this.canvas = canvas; @@ -10,7 +8,6 @@ export class CanvasRenderer { this.renderInterval = 1000 / 60; this.isDirty = false; } - render() { if (this.renderAnimationFrame) { this.isDirty = true; @@ -23,16 +20,15 @@ export class CanvasRenderer { this.actualRender(); this.isDirty = false; } - if (this.isDirty) { this.renderAnimationFrame = null; this.render(); - } else { + } + else { this.renderAnimationFrame = null; } }); } - actualRender() { if (this.canvas.offscreenCanvas.width !== this.canvas.canvas.clientWidth || this.canvas.offscreenCanvas.height !== this.canvas.canvas.clientHeight) { @@ -41,21 +37,17 @@ export class CanvasRenderer { this.canvas.offscreenCanvas.width = newWidth; this.canvas.offscreenCanvas.height = newHeight; } - const ctx = this.canvas.offscreenCtx; - ctx.fillStyle = '#606060'; ctx.fillRect(0, 0, this.canvas.offscreenCanvas.width, this.canvas.offscreenCanvas.height); - ctx.save(); ctx.scale(this.canvas.viewport.zoom, this.canvas.viewport.zoom); ctx.translate(-this.canvas.viewport.x, -this.canvas.viewport.y); - this.drawGrid(ctx); - const sortedLayers = [...this.canvas.layers].sort((a, b) => a.zIndex - b.zIndex); sortedLayers.forEach(layer => { - if (!layer.image) return; + if (!layer.image) + return; ctx.save(); const currentTransform = ctx.getTransform(); ctx.setTransform(1, 0, 0, 1, 0, 0); @@ -68,11 +60,7 @@ export class CanvasRenderer { ctx.rotate(layer.rotation * Math.PI / 180); ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; - ctx.drawImage( - layer.image, -layer.width / 2, -layer.height / 2, - layer.width, - layer.height - ); + ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height); if (layer.mask) { } if (this.canvas.canvasSelection.selectedLayers.includes(layer)) { @@ -80,51 +68,41 @@ export class CanvasRenderer { } ctx.restore(); }); - this.drawCanvasOutline(ctx); this.drawPendingGenerationAreas(ctx); // Draw snapshot outlines const maskImage = this.canvas.maskTool.getMask(); if (maskImage && this.canvas.maskTool.isOverlayVisible) { - ctx.save(); - if (this.canvas.maskTool.isActive) { ctx.globalCompositeOperation = 'source-over'; ctx.globalAlpha = 0.5; - } else { + } + else { ctx.globalCompositeOperation = 'source-over'; ctx.globalAlpha = 1.0; } - ctx.drawImage(maskImage, this.canvas.maskTool.x, this.canvas.maskTool.y); - ctx.globalAlpha = 1.0; ctx.restore(); } - this.renderInteractionElements(ctx); this.renderLayerInfo(ctx); - ctx.restore(); - if (this.canvas.canvas.width !== this.canvas.offscreenCanvas.width || this.canvas.canvas.height !== this.canvas.offscreenCanvas.height) { this.canvas.canvas.width = this.canvas.offscreenCanvas.width; this.canvas.canvas.height = this.canvas.offscreenCanvas.height; } this.canvas.ctx.drawImage(this.canvas.offscreenCanvas, 0, 0); - // Update Batch Preview UI positions if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) { - this.canvas.batchPreviewManagers.forEach(manager => { + this.canvas.batchPreviewManagers.forEach((manager) => { manager.updateScreenPosition(this.canvas.viewport); }); } } - renderInteractionElements(ctx) { const interaction = this.canvas.interaction; - if (interaction.mode === 'resizingCanvas' && interaction.canvasResizeRect) { const rect = interaction.canvasResizeRect; ctx.save(); @@ -138,7 +116,6 @@ export class CanvasRenderer { const text = `${Math.round(rect.width)}x${Math.round(rect.height)}`; const textWorldX = rect.x + rect.width / 2; const textWorldY = rect.y + rect.height + (20 / this.canvas.viewport.zoom); - ctx.save(); ctx.setTransform(1, 0, 0, 1, 0, 0); const screenX = (textWorldX - this.canvas.viewport.x) * this.canvas.viewport.zoom; @@ -156,7 +133,6 @@ export class CanvasRenderer { ctx.restore(); } } - if (interaction.mode === 'movingCanvas' && interaction.canvasMoveRect) { const rect = interaction.canvasMoveRect; ctx.save(); @@ -166,11 +142,9 @@ export class CanvasRenderer { ctx.strokeRect(rect.x, rect.y, rect.width, rect.height); ctx.setLineDash([]); ctx.restore(); - const text = `(${Math.round(rect.x)}, ${Math.round(rect.y)})`; const textWorldX = rect.x + rect.width / 2; const textWorldY = rect.y - (20 / this.canvas.viewport.zoom); - ctx.save(); ctx.setTransform(1, 0, 0, 1, 0, 0); const screenX = (textWorldX - this.canvas.viewport.x) * this.canvas.viewport.zoom; @@ -188,12 +162,11 @@ export class CanvasRenderer { ctx.restore(); } } - renderLayerInfo(ctx) { if (this.canvas.canvasSelection.selectedLayer) { - this.canvas.canvasSelection.selectedLayers.forEach(layer => { - if (!layer.image) return; - + this.canvas.canvasSelection.selectedLayers.forEach((layer) => { + if (!layer.image) + return; const layerIndex = this.canvas.layers.indexOf(layer); const currentWidth = Math.round(layer.width); const currentHeight = Math.round(layer.height); @@ -207,15 +180,13 @@ export class CanvasRenderer { const rad = layer.rotation * Math.PI / 180; const cos = Math.cos(rad); const sin = Math.sin(rad); - const halfW = layer.width / 2; const halfH = layer.height / 2; - const localCorners = [ - {x: -halfW, y: -halfH}, - {x: halfW, y: -halfH}, - {x: halfW, y: halfH}, - {x: -halfW, y: halfH} + { x: -halfW, y: -halfH }, + { x: halfW, y: -halfH }, + { x: halfW, y: halfH }, + { x: -halfW, y: halfH } ]; const worldCorners = localCorners.map(p => ({ x: centerX + p.x * cos - p.y * sin, @@ -232,10 +203,8 @@ export class CanvasRenderer { const textWorldY = maxY + padding; ctx.save(); ctx.setTransform(1, 0, 0, 1, 0, 0); - const screenX = (textWorldX - this.canvas.viewport.x) * this.canvas.viewport.zoom; const screenY = (textWorldY - this.canvas.viewport.y) * this.canvas.viewport.zoom; - ctx.font = "14px sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; @@ -244,59 +213,46 @@ export class CanvasRenderer { const textBgWidth = Math.max(...textMetrics.map(m => m.width)) + 10; const lineHeight = 18; const textBgHeight = lines.length * lineHeight + 4; - ctx.fillStyle = "rgba(0, 0, 0, 0.7)"; ctx.fillRect(screenX - textBgWidth / 2, screenY - textBgHeight / 2, textBgWidth, textBgHeight); - ctx.fillStyle = "white"; lines.forEach((line, index) => { const yPos = screenY - (textBgHeight / 2) + (lineHeight / 2) + (index * lineHeight) + 2; ctx.fillText(line, screenX, yPos); }); - ctx.restore(); }); } } - drawGrid(ctx) { const gridSize = 64; const lineWidth = 0.5 / this.canvas.viewport.zoom; - const viewLeft = this.canvas.viewport.x; const viewTop = this.canvas.viewport.y; const viewRight = this.canvas.viewport.x + this.canvas.offscreenCanvas.width / this.canvas.viewport.zoom; const viewBottom = this.canvas.viewport.y + this.canvas.offscreenCanvas.height / this.canvas.viewport.zoom; - ctx.beginPath(); ctx.strokeStyle = '#707070'; ctx.lineWidth = lineWidth; - for (let x = Math.floor(viewLeft / gridSize) * gridSize; x < viewRight; x += gridSize) { ctx.moveTo(x, viewTop); ctx.lineTo(x, viewBottom); } - for (let y = Math.floor(viewTop / gridSize) * gridSize; y < viewBottom; y += gridSize) { ctx.moveTo(viewLeft, y); ctx.lineTo(viewRight, y); } - ctx.stroke(); } - drawCanvasOutline(ctx) { ctx.beginPath(); ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)'; ctx.lineWidth = 2 / this.canvas.viewport.zoom; ctx.setLineDash([10 / this.canvas.viewport.zoom, 5 / this.canvas.viewport.zoom]); - ctx.rect(0, 0, this.canvas.width, this.canvas.height); - ctx.stroke(); ctx.setLineDash([]); } - drawSelectionFrame(ctx, layer) { const lineWidth = 2 / this.canvas.viewport.zoom; const handleRadius = 5 / this.canvas.viewport.zoom; @@ -313,44 +269,36 @@ export class CanvasRenderer { ctx.fillStyle = '#ffffff'; ctx.strokeStyle = '#000000'; ctx.lineWidth = 1 / this.canvas.viewport.zoom; - for (const key in handles) { const point = handles[key]; ctx.beginPath(); const localX = point.x - (layer.x + layer.width / 2); const localY = point.y - (layer.y + layer.height / 2); - const rad = -layer.rotation * Math.PI / 180; const rotatedX = localX * Math.cos(rad) - localY * Math.sin(rad); const rotatedY = localX * Math.sin(rad) + localY * Math.cos(rad); - ctx.arc(rotatedX, rotatedY, handleRadius, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); } } - drawPendingGenerationAreas(ctx) { const areasToDraw = []; - // 1. Get areas from active managers if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) { - this.canvas.batchPreviewManagers.forEach(manager => { + this.canvas.batchPreviewManagers.forEach((manager) => { if (manager.generationArea) { areasToDraw.push(manager.generationArea); } }); } - // 2. Get the area from the pending context (if it exists) if (this.canvas.pendingBatchContext && this.canvas.pendingBatchContext.outputArea) { areasToDraw.push(this.canvas.pendingBatchContext.outputArea); } - if (areasToDraw.length === 0) { return; } - // 3. Draw all collected areas areasToDraw.forEach(area => { ctx.save(); diff --git a/js/CanvasSelection.js b/js/CanvasSelection.js index 6a030bb..f691ef5 100644 --- a/js/CanvasSelection.js +++ b/js/CanvasSelection.js @@ -1,7 +1,5 @@ import { createModuleLogger } from "./utils/LoggerUtils.js"; - const log = createModuleLogger('CanvasSelection'); - export class CanvasSelection { constructor(canvas) { this.canvas = canvas; @@ -9,16 +7,14 @@ export class CanvasSelection { this.selectedLayer = null; this.onSelectionChange = null; } - /** * Duplikuje zaznaczone warstwy (w pamięci, bez zapisu stanu) */ duplicateSelectedLayers() { - if (this.selectedLayers.length === 0) return []; - + if (this.selectedLayers.length === 0) + return []; const newLayers = []; - const sortedLayers = [...this.selectedLayers].sort((a,b) => a.zIndex - b.zIndex); - + const sortedLayers = [...this.selectedLayers].sort((a, b) => a.zIndex - b.zIndex); sortedLayers.forEach(layer => { const newLayer = { ...layer, @@ -28,19 +24,15 @@ export class CanvasSelection { this.canvas.layers.push(newLayer); newLayers.push(newLayer); }); - // Aktualizuj zaznaczenie, co powiadomi panel (ale nie renderuje go całego) this.updateSelection(newLayers); - // Powiadom panel o zmianie struktury, aby się przerysował if (this.canvas.canvasLayersPanel) { this.canvas.canvasLayersPanel.onLayersChanged(); } - log.info(`Duplicated ${newLayers.length} layers (in-memory).`); return newLayers; } - /** * Aktualizuje zaznaczenie warstw i powiadamia wszystkie komponenty. * To jest "jedyne źródło prawdy" o zmianie zaznaczenia. @@ -50,47 +42,38 @@ export class CanvasSelection { const previousSelection = this.selectedLayers.length; this.selectedLayers = newSelection || []; this.selectedLayer = this.selectedLayers.length > 0 ? this.selectedLayers[this.selectedLayers.length - 1] : null; - // Sprawdź, czy zaznaczenie faktycznie się zmieniło, aby uniknąć pętli - const hasChanged = previousSelection !== this.selectedLayers.length || - this.selectedLayers.some((layer, i) => this.selectedLayers[i] !== (newSelection || [])[i]); - + const hasChanged = previousSelection !== this.selectedLayers.length || + this.selectedLayers.some((layer, i) => this.selectedLayers[i] !== (newSelection || [])[i]); if (!hasChanged && previousSelection > 0) { - // return; // Zablokowane na razie, może powodować problemy + // return; // Zablokowane na razie, może powodować problemy } - log.debug('Selection updated', { previousCount: previousSelection, newCount: this.selectedLayers.length, - selectedLayerIds: this.selectedLayers.map(l => l.id || 'unknown') + selectedLayerIds: this.selectedLayers.map((l) => l.id || 'unknown') }); - // 1. Zrenderuj ponownie canvas, aby pokazać nowe kontrolki transformacji this.canvas.render(); - // 2. Powiadom inne części aplikacji (jeśli są) if (this.onSelectionChange) { this.onSelectionChange(); } - // 3. Powiadom panel warstw, aby zaktualizował swój wygląd if (this.canvas.canvasLayersPanel) { this.canvas.canvasLayersPanel.onSelectionChanged(); } } - /** * Logika aktualizacji zaznaczenia, wywoływana przez panel warstw. */ updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index) { let newSelection = [...this.selectedLayers]; let selectionChanged = false; - if (isShiftPressed && this.canvas.canvasLayersPanel.lastSelectedIndex !== -1) { const sortedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex); const startIndex = Math.min(this.canvas.canvasLayersPanel.lastSelectedIndex, index); const endIndex = Math.max(this.canvas.canvasLayersPanel.lastSelectedIndex, index); - newSelection = []; for (let i = startIndex; i <= endIndex; i++) { if (sortedLayers[i]) { @@ -98,16 +81,19 @@ export class CanvasSelection { } } selectionChanged = true; - } else if (isCtrlPressed) { + } + else if (isCtrlPressed) { const layerIndex = newSelection.indexOf(layer); if (layerIndex === -1) { newSelection.push(layer); - } else { + } + else { newSelection.splice(layerIndex, 1); } this.canvas.canvasLayersPanel.lastSelectedIndex = index; selectionChanged = true; - } else { + } + else { // Jeśli kliknięta warstwa nie jest częścią obecnego zaznaczenia, // wyczyść zaznaczenie i zaznacz tylko ją. if (!this.selectedLayers.includes(layer)) { @@ -118,47 +104,41 @@ export class CanvasSelection { // NIE rób nic, aby umożliwić przeciąganie całej grupy. this.canvas.canvasLayersPanel.lastSelectedIndex = index; } - // Aktualizuj zaznaczenie tylko jeśli faktycznie się zmieniło if (selectionChanged) { this.updateSelection(newSelection); } } - removeSelectedLayers() { if (this.selectedLayers.length > 0) { log.info('Removing selected layers', { layersToRemove: this.selectedLayers.length, totalLayers: this.canvas.layers.length }); - this.canvas.saveState(); - this.canvas.layers = this.canvas.layers.filter(l => !this.selectedLayers.includes(l)); - - this.updateSelection([]); - + this.canvas.layers = this.canvas.layers.filter((l) => !this.selectedLayers.includes(l)); + this.updateSelection([]); this.canvas.render(); this.canvas.saveState(); - if (this.canvas.canvasLayersPanel) { this.canvas.canvasLayersPanel.onLayersChanged(); } - log.debug('Layers removed successfully, remaining layers:', this.canvas.layers.length); - } else { + } + else { log.debug('No layers selected for removal'); } } - /** * Aktualizuje zaznaczenie po operacji historii */ updateSelectionAfterHistory() { const newSelectedLayers = []; if (this.selectedLayers) { - this.selectedLayers.forEach(sl => { - const found = this.canvas.layers.find(l => l.id === sl.id); - if (found) newSelectedLayers.push(found); + this.selectedLayers.forEach((sl) => { + const found = this.canvas.layers.find((l) => l.id === sl.id); + if (found) + newSelectedLayers.push(found); }); } this.updateSelection(newSelectedLayers); diff --git a/js/CanvasState.js b/js/CanvasState.js index 2c6c3fc..eda8532 100644 --- a/js/CanvasState.js +++ b/js/CanvasState.js @@ -1,10 +1,7 @@ -import {getCanvasState, setCanvasState, saveImage, getImage} from "./db.js"; -import {createModuleLogger} from "./utils/LoggerUtils.js"; -import {generateUUID, cloneLayers, getStateSignature, debounce} from "./utils/CommonUtils.js"; -import {withErrorHandling} from "./ErrorHandler.js"; - +import { getCanvasState, setCanvasState, saveImage, getImage } from "./db.js"; +import { createModuleLogger } from "./utils/LoggerUtils.js"; +import { generateUUID, cloneLayers, getStateSignature, debounce } from "./utils/CommonUtils.js"; const log = createModuleLogger('CanvasState'); - export class CanvasState { constructor(canvas) { this.canvas = canvas; @@ -16,289 +13,302 @@ export class CanvasState { this.saveTimeout = null; this.lastSavedStateSignature = null; this._loadInProgress = null; - - // Inicjalizacja Web Workera w sposób odporny na problemy ze ścieżkami + this._debouncedSave = null; try { - // new URL(..., import.meta.url) tworzy absolutną ścieżkę do workera + // @ts-ignore this.stateSaverWorker = new Worker(new URL('./state-saver.worker.js', import.meta.url), { type: 'module' }); log.info("State saver worker initialized successfully."); - this.stateSaverWorker.onmessage = (e) => { log.info("Message from state saver worker:", e.data); }; this.stateSaverWorker.onerror = (e) => { log.error("Error in state saver worker:", e.message, e.filename, e.lineno); - // Zapobiegaj dalszym próbom, jeśli worker nie działa - this.stateSaverWorker = null; + this.stateSaverWorker = null; }; - } catch (e) { + } + catch (e) { log.error("Failed to initialize state saver worker:", e); this.stateSaverWorker = null; } } - - async loadStateFromDB() { if (this._loadInProgress) { log.warn("Load already in progress, waiting..."); return this._loadInProgress; } - log.info("Attempting to load state from IndexedDB for node:", this.canvas.node.id); - if (!this.canvas.node.id) { - log.error("Node ID is not available for loading state from DB."); - return false; - } - - this._loadInProgress = this._performLoad(); - + const loadPromise = this._performLoad(); + this._loadInProgress = loadPromise; try { - const result = await this._loadInProgress; - return result; - } finally { + const result = await loadPromise; this._loadInProgress = null; + return result; + } + catch (error) { + this._loadInProgress = null; + throw error; } } - - _performLoad = withErrorHandling(async () => { - const savedState = await getCanvasState(this.canvas.node.id); - if (!savedState) { - log.info("No saved state found in IndexedDB for node:", this.canvas.node.id); + async _performLoad() { + try { + if (!this.canvas.node.id) { + log.error("Node ID is not available for loading state from DB."); + return false; + } + const savedState = await getCanvasState(String(this.canvas.node.id)); + if (!savedState) { + log.info("No saved state found in IndexedDB for node:", this.canvas.node.id); + return false; + } + log.info("Found saved state in IndexedDB."); + this.canvas.width = savedState.width || 512; + this.canvas.height = savedState.height || 512; + this.canvas.viewport = savedState.viewport || { + x: -(this.canvas.width / 4), + y: -(this.canvas.height / 4), + zoom: 0.8 + }; + this.canvas.canvasLayers.updateOutputAreaSize(this.canvas.width, this.canvas.height, false); + log.debug(`Output Area resized to ${this.canvas.width}x${this.canvas.height} and viewport set.`); + const loadedLayers = await this._loadLayers(savedState.layers); + this.canvas.layers = loadedLayers.filter((l) => l !== null); + log.info(`Loaded ${this.canvas.layers.length} layers.`); + if (this.canvas.layers.length === 0) { + log.warn("No valid layers loaded, state may be corrupted."); + return false; + } + this.canvas.updateSelectionAfterHistory(); + this.canvas.render(); + log.info("Canvas state loaded successfully from IndexedDB for node", this.canvas.node.id); + return true; + } + catch (error) { + log.error("Error during state load:", error); return false; } - log.info("Found saved state in IndexedDB."); - this.canvas.width = savedState.width || 512; - this.canvas.height = savedState.height || 512; - this.canvas.viewport = savedState.viewport || { - x: -(this.canvas.width / 4), - y: -(this.canvas.height / 4), - zoom: 0.8 - }; - - this.canvas.updateOutputAreaSize(this.canvas.width, this.canvas.height, false); - log.debug(`Output Area resized to ${this.canvas.width}x${this.canvas.height} and viewport set.`); - const loadedLayers = await this._loadLayers(savedState.layers); - this.canvas.layers = loadedLayers.filter(l => l !== null); - log.info(`Loaded ${this.canvas.layers.length} layers.`); - - if (this.canvas.layers.length === 0) { - log.warn("No valid layers loaded, state may be corrupted."); - return false; - } - - this.canvas.updateSelectionAfterHistory(); - this.canvas.render(); - log.info("Canvas state loaded successfully from IndexedDB for node", this.canvas.node.id); - return true; - }, 'CanvasState._performLoad'); - + } /** * Ładuje warstwy z zapisanego stanu - * @param {Array} layersData - Dane warstw do załadowania - * @returns {Promise} Załadowane warstwy + * @param {any[]} layersData - Dane warstw do załadowania + * @returns {Promise<(Layer | null)[]>} Załadowane warstwy */ async _loadLayers(layersData) { - const imagePromises = layersData.map((layerData, index) => - this._loadSingleLayer(layerData, index) - ); + const imagePromises = layersData.map((layerData, index) => this._loadSingleLayer(layerData, index)); return Promise.all(imagePromises); } - /** * Ładuje pojedynczą warstwę - * @param {Object} layerData - Dane warstwy + * @param {any} layerData - Dane warstwy * @param {number} index - Indeks warstwy - * @returns {Promise} Załadowana warstwa lub null + * @returns {Promise} Załadowana warstwa lub null */ async _loadSingleLayer(layerData, index) { return new Promise((resolve) => { if (layerData.imageId) { this._loadLayerFromImageId(layerData, index, resolve); - } else if (layerData.imageSrc) { + } + else if (layerData.imageSrc) { this._convertLegacyLayer(layerData, index, resolve); - } else { + } + else { log.error(`Layer ${index}: No imageId or imageSrc found, skipping layer.`); resolve(null); } }); } - /** * Ładuje warstwę z imageId - * @param {Object} layerData - Dane warstwy + * @param {any} layerData - Dane warstwy * @param {number} index - Indeks warstwy - * @param {Function} resolve - Funkcja resolve + * @param {(value: Layer | null) => void} resolve - Funkcja resolve */ _loadLayerFromImageId(layerData, index, resolve) { log.debug(`Layer ${index}: Loading image with id: ${layerData.imageId}`); - if (this.canvas.imageCache.has(layerData.imageId)) { log.debug(`Layer ${index}: Image found in cache.`); - const imageSrc = this.canvas.imageCache.get(layerData.imageId); - this._createLayerFromSrc(layerData, imageSrc, index, resolve); - } else { + const imageData = this.canvas.imageCache.get(layerData.imageId); + if (imageData) { + const imageSrc = URL.createObjectURL(new Blob([imageData.data])); + this._createLayerFromSrc(layerData, imageSrc, index, resolve); + } + else { + resolve(null); + } + } + else { getImage(layerData.imageId) .then(imageSrc => { - if (imageSrc) { - log.debug(`Layer ${index}: Loading image from data:URL...`); - this.canvas.imageCache.set(layerData.imageId, imageSrc); - this._createLayerFromSrc(layerData, imageSrc, index, resolve); - } else { - log.error(`Layer ${index}: Image not found in IndexedDB.`); - resolve(null); - } - }) - .catch(err => { - log.error(`Layer ${index}: Error loading image from IndexedDB:`, err); + if (imageSrc) { + log.debug(`Layer ${index}: Loading image from data:URL...`); + this._createLayerFromSrc(layerData, imageSrc, index, resolve); + } + else { + log.error(`Layer ${index}: Image not found in IndexedDB.`); resolve(null); - }); + } + }) + .catch(err => { + log.error(`Layer ${index}: Error loading image from IndexedDB:`, err); + resolve(null); + }); } } - /** * Konwertuje starą warstwę z imageSrc na nowy format - * @param {Object} layerData - Dane warstwy + * @param {any} layerData - Dane warstwy * @param {number} index - Indeks warstwy - * @param {Function} resolve - Funkcja resolve + * @param {(value: Layer | null) => void} resolve - Funkcja resolve */ _convertLegacyLayer(layerData, index, resolve) { log.info(`Layer ${index}: Found imageSrc, converting to new format with imageId.`); const imageId = generateUUID(); - saveImage(imageId, layerData.imageSrc) .then(() => { - log.info(`Layer ${index}: Image saved to IndexedDB with id: ${imageId}`); - this.canvas.imageCache.set(imageId, layerData.imageSrc); - const newLayerData = {...layerData, imageId}; - delete newLayerData.imageSrc; - this._createLayerFromSrc(newLayerData, layerData.imageSrc, index, resolve); - }) + log.info(`Layer ${index}: Image saved to IndexedDB with id: ${imageId}`); + const newLayerData = { ...layerData, imageId }; + delete newLayerData.imageSrc; + this._createLayerFromSrc(newLayerData, layerData.imageSrc, index, resolve); + }) .catch(err => { - log.error(`Layer ${index}: Error saving image to IndexedDB:`, err); - resolve(null); - }); + log.error(`Layer ${index}: Error saving image to IndexedDB:`, err); + resolve(null); + }); } - /** * Tworzy warstwę z src obrazu - * @param {Object} layerData - Dane warstwy + * @param {any} layerData - Dane warstwy * @param {string} imageSrc - Źródło obrazu * @param {number} index - Indeks warstwy - * @param {Function} resolve - Funkcja resolve + * @param {(value: Layer | null) => void} resolve - Funkcja resolve */ _createLayerFromSrc(layerData, imageSrc, index, resolve) { - const img = new Image(); - img.onload = () => { - log.debug(`Layer ${index}: Image loaded successfully.`); - const newLayer = {...layerData, image: img}; - delete newLayer.imageId; - resolve(newLayer); - }; - img.onerror = () => { - log.error(`Layer ${index}: Failed to load image from src.`); - resolve(null); - }; - img.src = imageSrc; + if (typeof imageSrc === 'string') { + const img = new Image(); + img.onload = () => { + log.debug(`Layer ${index}: Image loaded successfully.`); + const newLayer = { ...layerData, image: img }; + resolve(newLayer); + }; + img.onerror = () => { + log.error(`Layer ${index}: Failed to load image from src.`); + resolve(null); + }; + img.src = imageSrc; + } + else { + const canvas = document.createElement('canvas'); + canvas.width = imageSrc.width; + canvas.height = imageSrc.height; + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.drawImage(imageSrc, 0, 0); + const img = new Image(); + img.onload = () => { + log.debug(`Layer ${index}: Image loaded successfully from ImageBitmap.`); + const newLayer = { ...layerData, image: img }; + resolve(newLayer); + }; + img.onerror = () => { + log.error(`Layer ${index}: Failed to load image from ImageBitmap.`); + resolve(null); + }; + img.src = canvas.toDataURL(); + } + else { + log.error(`Layer ${index}: Failed to get 2d context from canvas.`); + resolve(null); + } + } } - async saveStateToDB() { if (!this.canvas.node.id) { log.error("Node ID is not available for saving state to DB."); return; } - log.info("Preparing state to be sent to worker..."); + const layers = await this._prepareLayers(); const state = { - layers: await this._prepareLayers(), + layers: layers.filter(layer => layer !== null), viewport: this.canvas.viewport, width: this.canvas.width, height: this.canvas.height, }; - - state.layers = state.layers.filter(layer => layer !== null); if (state.layers.length === 0) { log.warn("No valid layers to save, skipping."); return; } - if (this.stateSaverWorker) { log.info("Posting state to worker for background saving."); this.stateSaverWorker.postMessage({ - nodeId: this.canvas.node.id, + nodeId: String(this.canvas.node.id), state: state }); this.canvas.render(); - } else { + } + else { log.warn("State saver worker not available. Saving on main thread."); - await setCanvasState(this.canvas.node.id, state); + await setCanvasState(String(this.canvas.node.id), state); } } - /** * Przygotowuje warstwy do zapisu - * @returns {Promise} Przygotowane warstwy + * @returns {Promise<(Omit & { imageId: string })[]>} Przygotowane warstwy */ async _prepareLayers() { - return Promise.all(this.canvas.layers.map(async (layer, index) => { - const newLayer = {...layer}; + const preparedLayers = await Promise.all(this.canvas.layers.map(async (layer, index) => { + const newLayer = { ...layer, imageId: layer.imageId || '' }; + delete newLayer.image; if (layer.image instanceof HTMLImageElement) { log.debug(`Layer ${index}: Using imageId instead of serializing image.`); if (!layer.imageId) { - layer.imageId = generateUUID(); - await saveImage(layer.imageId, layer.image.src); - this.canvas.imageCache.set(layer.imageId, layer.image.src); + newLayer.imageId = generateUUID(); + const imageBitmap = await createImageBitmap(layer.image); + await saveImage(newLayer.imageId, imageBitmap); } newLayer.imageId = layer.imageId; - } else if (!layer.imageId) { + } + else if (!layer.imageId) { log.error(`Layer ${index}: No image or imageId found, skipping layer.`); return null; } - delete newLayer.image; return newLayer; })); + return preparedLayers.filter((layer) => layer !== null); } - saveState(replaceLast = false) { if (this.canvas.maskTool && this.canvas.maskTool.isActive) { this.saveMaskState(replaceLast); - } else { + } + else { this.saveLayersState(replaceLast); } } - saveLayersState(replaceLast = false) { if (replaceLast && this.layersUndoStack.length > 0) { this.layersUndoStack.pop(); } - const currentState = cloneLayers(this.canvas.layers); const currentStateSignature = getStateSignature(currentState); - if (this.layersUndoStack.length > 0) { const lastState = this.layersUndoStack[this.layersUndoStack.length - 1]; if (getStateSignature(lastState) === currentStateSignature) { - return; + return; } } - this.layersUndoStack.push(currentState); - if (this.layersUndoStack.length > this.historyLimit) { this.layersUndoStack.shift(); } this.layersRedoStack = []; this.canvas.updateHistoryButtons(); - - // Użyj debouncingu, aby zapobiec zbyt częstym zapisom if (!this._debouncedSave) { - this._debouncedSave = debounce(() => this.saveStateToDB(), 1000); + this._debouncedSave = debounce(this.saveStateToDB.bind(this), 1000); } this._debouncedSave(); } - saveMaskState(replaceLast = false) { - if (!this.canvas.maskTool) return; - + if (!this.canvas.maskTool) + return; if (replaceLast && this.maskUndoStack.length > 0) { this.maskUndoStack.pop(); } @@ -307,89 +317,92 @@ export class CanvasState { clonedCanvas.width = maskCanvas.width; clonedCanvas.height = maskCanvas.height; const clonedCtx = clonedCanvas.getContext('2d', { willReadFrequently: true }); - clonedCtx.drawImage(maskCanvas, 0, 0); - + if (clonedCtx) { + clonedCtx.drawImage(maskCanvas, 0, 0); + } this.maskUndoStack.push(clonedCanvas); - if (this.maskUndoStack.length > this.historyLimit) { this.maskUndoStack.shift(); } this.maskRedoStack = []; this.canvas.updateHistoryButtons(); } - undo() { if (this.canvas.maskTool && this.canvas.maskTool.isActive) { this.undoMaskState(); - } else { + } + else { this.undoLayersState(); } } - redo() { if (this.canvas.maskTool && this.canvas.maskTool.isActive) { this.redoMaskState(); - } else { + } + else { this.redoLayersState(); } } - undoLayersState() { - if (this.layersUndoStack.length <= 1) return; - + if (this.layersUndoStack.length <= 1) + return; const currentState = this.layersUndoStack.pop(); - this.layersRedoStack.push(currentState); + if (currentState) { + this.layersRedoStack.push(currentState); + } const prevState = this.layersUndoStack[this.layersUndoStack.length - 1]; this.canvas.layers = cloneLayers(prevState); this.canvas.updateSelectionAfterHistory(); this.canvas.render(); this.canvas.updateHistoryButtons(); } - redoLayersState() { - if (this.layersRedoStack.length === 0) return; - + if (this.layersRedoStack.length === 0) + return; const nextState = this.layersRedoStack.pop(); - this.layersUndoStack.push(nextState); - this.canvas.layers = cloneLayers(nextState); - this.canvas.updateSelectionAfterHistory(); - this.canvas.render(); - this.canvas.updateHistoryButtons(); + if (nextState) { + this.layersUndoStack.push(nextState); + this.canvas.layers = cloneLayers(nextState); + this.canvas.updateSelectionAfterHistory(); + this.canvas.render(); + this.canvas.updateHistoryButtons(); + } } - undoMaskState() { - if (!this.canvas.maskTool || this.maskUndoStack.length <= 1) return; - + if (!this.canvas.maskTool || this.maskUndoStack.length <= 1) + return; const currentState = this.maskUndoStack.pop(); - this.maskRedoStack.push(currentState); - + if (currentState) { + this.maskRedoStack.push(currentState); + } if (this.maskUndoStack.length > 0) { const prevState = this.maskUndoStack[this.maskUndoStack.length - 1]; const maskCanvas = this.canvas.maskTool.getMask(); const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true }); - maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height); - maskCtx.drawImage(prevState, 0, 0); - + if (maskCtx) { + maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height); + maskCtx.drawImage(prevState, 0, 0); + } this.canvas.render(); } - this.canvas.updateHistoryButtons(); } - redoMaskState() { - if (!this.canvas.maskTool || this.maskRedoStack.length === 0) return; - + if (!this.canvas.maskTool || this.maskRedoStack.length === 0) + return; const nextState = this.maskRedoStack.pop(); - this.maskUndoStack.push(nextState); - const maskCanvas = this.canvas.maskTool.getMask(); - const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true }); - maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height); - maskCtx.drawImage(nextState, 0, 0); - - this.canvas.render(); + if (nextState) { + this.maskUndoStack.push(nextState); + const maskCanvas = this.canvas.maskTool.getMask(); + const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true }); + if (maskCtx) { + maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height); + maskCtx.drawImage(nextState, 0, 0); + } + this.canvas.render(); + } this.canvas.updateHistoryButtons(); } - /** * Czyści historię undo/redo */ @@ -397,17 +410,17 @@ export class CanvasState { if (this.canvas.maskTool && this.canvas.maskTool.isActive) { this.maskUndoStack = []; this.maskRedoStack = []; - } else { + } + else { this.layersUndoStack = []; this.layersRedoStack = []; } this.canvas.updateHistoryButtons(); log.info("History cleared"); } - /** * Zwraca informacje o historii - * @returns {Object} Informacje o historii + * @returns {HistoryInfo} Informacje o historii */ getHistoryInfo() { if (this.canvas.maskTool && this.canvas.maskTool.isActive) { @@ -418,7 +431,8 @@ export class CanvasState { canRedo: this.maskRedoStack.length > 0, historyLimit: this.historyLimit }; - } else { + } + else { return { undoCount: this.layersUndoStack.length, redoCount: this.layersRedoStack.length, diff --git a/js/CanvasView.js b/js/CanvasView.js index 3661fcc..ad31e06 100644 --- a/js/CanvasView.js +++ b/js/CanvasView.js @@ -1,69 +1,55 @@ -import {app} from "../../scripts/app.js"; -import {api} from "../../scripts/api.js"; -import {$el} from "../../scripts/ui.js"; +// @ts-ignore +import { app } from "../../scripts/app.js"; +// @ts-ignore +import { $el } from "../../scripts/ui.js"; import { addStylesheet, getUrl, loadTemplate } from "./utils/ResourceManager.js"; - -import {Canvas} from "./Canvas.js"; -import {clearAllCanvasStates} from "./db.js"; -import {ImageCache} from "./ImageCache.js"; -import {generateUniqueFileName} from "./utils/CommonUtils.js"; -import {createModuleLogger} from "./utils/LoggerUtils.js"; - +import { Canvas } from "./Canvas.js"; +import { clearAllCanvasStates } from "./db.js"; +import { ImageCache } from "./ImageCache.js"; +import { createModuleLogger } from "./utils/LoggerUtils.js"; const log = createModuleLogger('Canvas_view'); - async function createCanvasWidget(node, widget, app) { const canvas = new Canvas(node, widget, { - onStateChange: () => updateOutput() + onStateChange: () => updateOutput(node, canvas) }); const imageCache = new ImageCache(); - const helpTooltip = $el("div.painter-tooltip", { id: `painter-help-tooltip-${node.id}`, }); - const [standardShortcuts, maskShortcuts, systemClipboardTooltip, clipspaceClipboardTooltip] = await Promise.all([ - loadTemplate('./templates/standard_shortcuts.html', import.meta.url), - loadTemplate('./templates/mask_shortcuts.html', import.meta.url), - loadTemplate('./templates/system_clipboard_tooltip.html', import.meta.url), - loadTemplate('./templates/clipspace_clipboard_tooltip.html', import.meta.url) + loadTemplate('./templates/standard_shortcuts.html'), + loadTemplate('./templates/mask_shortcuts.html'), + loadTemplate('./templates/system_clipboard_tooltip.html'), + loadTemplate('./templates/clipspace_clipboard_tooltip.html') ]); - document.body.appendChild(helpTooltip); - - // Helper function for tooltip positioning const showTooltip = (buttonElement, content) => { helpTooltip.innerHTML = content; helpTooltip.style.visibility = 'hidden'; helpTooltip.style.display = 'block'; - const buttonRect = buttonElement.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; - + if (left < 10) + left = 10; + if (top < 10) + top = 10; helpTooltip.style.left = `${left}px`; helpTooltip.style.top = `${top}px`; helpTooltip.style.visibility = 'visible'; }; - const hideTooltip = () => { helpTooltip.style.display = 'none'; }; - const controlPanel = $el("div.painterControlPanel", {}, [ $el("div.controls.painter-controls", { style: { @@ -73,18 +59,13 @@ async function createCanvasWidget(node, widget, app) { right: "0", zIndex: "10", }, - - onresize: (entries) => { - const controlsHeight = entries[0].target.offsetHeight; - canvasContainer.style.top = (controlsHeight + 10) + "px"; - } }, [ $el("div.painter-button-group", {}, [ $el("button.painter-button", { id: `open-editor-btn-${node.id}`, textContent: "⛶", title: "Open in Editor", - style: {minWidth: "40px", maxWidth: "40px", fontWeight: "bold"}, + style: { minWidth: "40px", maxWidth: "40px", fontWeight: "bold" }, }), $el("button.painter-button", { textContent: "?", @@ -104,21 +85,26 @@ async function createCanvasWidget(node, widget, app) { textContent: "Add Image", title: "Add image from file", onclick: () => { - const fitOnAddWidget = node.widgets.find(w => w.name === "fit_on_add"); + const fitOnAddWidget = node.widgets.find((w) => w.name === "fit_on_add"); const addMode = fitOnAddWidget && fitOnAddWidget.value ? 'fit' : 'center'; const input = document.createElement('input'); input.type = 'file'; input.accept = 'image/*'; input.multiple = true; input.onchange = async (e) => { - for (const file of e.target.files) { + const target = e.target; + if (!target.files) + return; + for (const file of target.files) { const reader = new FileReader(); reader.onload = (event) => { const img = new Image(); img.onload = () => { canvas.addLayer(img, {}, addMode); }; - img.src = event.target.result; + if (event.target?.result) { + img.src = event.target.result; + } }; reader.readAsDataURL(file); } @@ -136,8 +122,7 @@ async function createCanvasWidget(node, widget, app) { textContent: "Paste Image", title: "Paste image from clipboard", onclick: () => { - - const fitOnAddWidget = node.widgets.find(w => w.name === "fit_on_add"); + const fitOnAddWidget = node.widgets.find((w) => w.name === "fit_on_add"); const addMode = fitOnAddWidget && fitOnAddWidget.value ? 'fit' : 'center'; canvas.canvasLayers.handlePaste(addMode); } @@ -158,7 +143,8 @@ async function createCanvasWidget(node, widget, app) { button.textContent = "📋 Clipspace"; button.title = "Toggle clipboard source: ComfyUI Clipspace"; button.style.backgroundColor = "#4a6cd4"; - } else { + } + else { canvas.canvasLayers.clipboardPreference = 'system'; button.textContent = "📋 System"; button.title = "Toggle clipboard source: System Clipboard"; @@ -175,7 +161,6 @@ async function createCanvasWidget(node, widget, app) { }) ]), ]), - $el("div.painter-separator"), $el("div.painter-button-group", {}, [ $el("button.painter-button", { @@ -207,7 +192,7 @@ async function createCanvasWidget(node, widget, app) { $el("input", { type: "number", id: "canvas-width", - value: canvas.width, + value: String(canvas.width), min: "1", max: "4096" }) @@ -228,7 +213,7 @@ async function createCanvasWidget(node, widget, app) { $el("input", { type: "number", id: "canvas-height", - value: canvas.height, + value: String(canvas.height), min: "1", max: "4096" }) @@ -249,15 +234,14 @@ async function createCanvasWidget(node, widget, app) { ]) ]); document.body.appendChild(dialog); - document.getElementById('confirm-size').onclick = () => { - const width = parseInt(document.getElementById('canvas-width').value) || canvas.width; - const height = parseInt(document.getElementById('canvas-height').value) || canvas.height; + const widthInput = document.getElementById('canvas-width'); + const heightInput = document.getElementById('canvas-height'); + const width = parseInt(widthInput.value) || canvas.width; + const height = parseInt(heightInput.value) || canvas.height; canvas.updateOutputAreaSize(width, height); document.body.removeChild(dialog); - }; - document.getElementById('cancel-size').onclick = () => { document.body.removeChild(dialog); }; @@ -284,7 +268,6 @@ async function createCanvasWidget(node, widget, app) { onclick: () => canvas.canvasLayers.fuseLayers() }), ]), - $el("div.painter-separator"), $el("div.painter-button-group", {}, [ $el("button.painter-button.requires-selection", { @@ -313,7 +296,6 @@ async function createCanvasWidget(node, widget, app) { onclick: () => canvas.canvasLayers.mirrorVertical() }), ]), - $el("div.painter-separator"), $el("div.painter-button-group", {}, [ $el("button.painter-button.requires-selection.matting-button", { @@ -321,26 +303,23 @@ async function createCanvasWidget(node, widget, app) { title: "Perform background removal on the selected layer", onclick: async (e) => { const button = e.target.closest('.matting-button'); - if (button.classList.contains('loading')) return; - + if (button.classList.contains('loading')) + return; const spinner = $el("div.matting-spinner"); button.appendChild(spinner); button.classList.add('loading'); - try { - if (canvas.canvasSelection.selectedLayers.length !== 1) throw new Error("Please select exactly one image layer for matting."); - + if (canvas.canvasSelection.selectedLayers.length !== 1) + throw new Error("Please select exactly one image layer for matting."); const selectedLayer = canvas.canvasSelection.selectedLayers[0]; const selectedLayerIndex = canvas.layers.indexOf(selectedLayer); const imageData = await canvas.canvasLayers.getLayerImageData(selectedLayer); const response = await fetch("/matting", { method: "POST", - headers: {"Content-Type": "application/json"}, - body: JSON.stringify({image: imageData}) + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ image: imageData }) }); - const result = await response.json(); - if (!response.ok) { let errorMsg = `Server error: ${response.status} - ${response.statusText}`; if (result && result.error) { @@ -351,16 +330,18 @@ async function createCanvasWidget(node, widget, app) { const mattedImage = new Image(); mattedImage.src = result.matted_image; await mattedImage.decode(); - const newLayer = {...selectedLayer, image: mattedImage}; + const newLayer = { ...selectedLayer, image: mattedImage }; delete newLayer.imageId; canvas.layers[selectedLayerIndex] = newLayer; canvas.canvasSelection.updateSelection([newLayer]); canvas.render(); canvas.saveState(); - } catch (error) { + } + catch (error) { log.error("Matting error:", error); alert(`Matting process failed:\n\n${error.message}`); - } finally { + } + finally { button.classList.remove('loading'); button.removeChild(spinner); } @@ -382,7 +363,7 @@ async function createCanvasWidget(node, widget, app) { }), ]), $el("div.painter-separator"), - $el("div.painter-button-group", {id: "mask-controls"}, [ + $el("div.painter-button-group", { id: "mask-controls" }, [ $el("button.painter-button.primary", { id: `toggle-mask-btn-${node.id}`, textContent: "Show Mask", @@ -391,11 +372,11 @@ async function createCanvasWidget(node, widget, app) { const button = e.target; canvas.maskTool.toggleOverlayVisibility(); canvas.render(); - if (canvas.maskTool.isOverlayVisible) { button.classList.add('primary'); button.textContent = "Show Mask"; - } else { + } + else { button.classList.remove('primary'); button.textContent = "Hide Mask"; } @@ -405,7 +386,7 @@ async function createCanvasWidget(node, widget, app) { textContent: "Edit Mask", title: "Open the current canvas view in the mask editor", onclick: () => { - canvas.startMaskEditor(); + canvas.startMaskEditor(null, true); } }), $el("button.painter-button", { @@ -415,22 +396,21 @@ async function createCanvasWidget(node, widget, app) { onclick: () => { const maskBtn = controlPanel.querySelector('#mask-mode-btn'); const maskControls = controlPanel.querySelector('#mask-controls'); - if (canvas.maskTool.isActive) { canvas.maskTool.deactivate(); maskBtn.classList.remove('primary'); - maskControls.querySelectorAll('.mask-control').forEach(c => c.style.display = 'none'); - } else { + maskControls.querySelectorAll('.mask-control').forEach((c) => c.style.display = 'none'); + } + else { canvas.maskTool.activate(); maskBtn.classList.add('primary'); - maskControls.querySelectorAll('.mask-control').forEach(c => c.style.display = 'flex'); + maskControls.querySelectorAll('.mask-control').forEach((c) => c.style.display = 'flex'); } - setTimeout(() => canvas.render(), 0); } }), - $el("div.painter-slider-container.mask-control", {style: {display: 'none'}}, [ - $el("label", {for: "brush-size-slider", textContent: "Size:"}), + $el("div.painter-slider-container.mask-control", { style: { display: 'none' } }, [ + $el("label", { for: "brush-size-slider", textContent: "Size:" }), $el("input", { id: "brush-size-slider", type: "range", @@ -440,8 +420,8 @@ async function createCanvasWidget(node, widget, app) { oninput: (e) => canvas.maskTool.setBrushSize(parseInt(e.target.value)) }) ]), - $el("div.painter-slider-container.mask-control", {style: {display: 'none'}}, [ - $el("label", {for: "brush-strength-slider", textContent: "Strength:"}), + $el("div.painter-slider-container.mask-control", { style: { display: 'none' } }, [ + $el("label", { for: "brush-strength-slider", textContent: "Strength:" }), $el("input", { id: "brush-strength-slider", type: "range", @@ -452,8 +432,8 @@ async function createCanvasWidget(node, widget, app) { oninput: (e) => canvas.maskTool.setBrushStrength(parseFloat(e.target.value)) }) ]), - $el("div.painter-slider-container.mask-control", {style: {display: 'none'}}, [ - $el("label", {for: "brush-hardness-slider", textContent: "Hardness:"}), + $el("div.painter-slider-container.mask-control", { style: { display: 'none' } }, [ + $el("label", { for: "brush-hardness-slider", textContent: "Hardness:" }), $el("input", { id: "brush-hardness-slider", type: "range", @@ -467,7 +447,7 @@ async function createCanvasWidget(node, widget, app) { $el("button.painter-button.mask-control", { textContent: "Clear Mask", title: "Clear the entire mask", - style: {display: 'none'}, + style: { display: 'none' }, onclick: () => { if (confirm("Are you sure you want to clear the mask?")) { canvas.maskTool.clear(); @@ -476,25 +456,22 @@ async function createCanvasWidget(node, widget, app) { } }) ]), - $el("div.painter-separator"), $el("div.painter-button-group", {}, [ $el("button.painter-button", { textContent: "Run GC", title: "Run Garbage Collection to clean unused images", - style: {backgroundColor: "#4a7c59", borderColor: "#3a6c49"}, + style: { backgroundColor: "#4a7c59", borderColor: "#3a6c49" }, onclick: async () => { try { const stats = canvas.imageReferenceManager.getStats(); log.info("GC Stats before cleanup:", stats); - await canvas.imageReferenceManager.manualGarbageCollection(); - 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: ${canvas.imageReferenceManager.operationCount}/${canvas.imageReferenceManager.operationThreshold}`); - } catch (e) { + } + catch (e) { log.error("Failed to run garbage collection:", e); alert("Error running garbage collection. Check the console for details."); } @@ -503,13 +480,14 @@ async function createCanvasWidget(node, widget, app) { $el("button.painter-button", { textContent: "Clear Cache", title: "Clear all saved canvas states from browser storage", - style: {backgroundColor: "#c54747", borderColor: "#a53737"}, + style: { backgroundColor: "#c54747", borderColor: "#a53737" }, onclick: async () => { if (confirm("Are you sure you want to clear all saved canvas states? This action cannot be undone.")) { try { await clearAllCanvasStates(); alert("Canvas cache cleared successfully!"); - } catch (e) { + } + catch (e) { log.error("Failed to clear canvas cache:", e); alert("Error clearing canvas cache. Check the console for details."); } @@ -520,17 +498,16 @@ async function createCanvasWidget(node, widget, app) { ]), $el("div.painter-separator") ]); - - const updateButtonStates = () => { const selectionCount = canvas.canvasSelection.selectedLayers.length; const hasSelection = selectionCount > 0; - controlPanel.querySelectorAll('.requires-selection').forEach(btn => { - // Special handling for Fuse button - requires at least 2 layers - if (btn.textContent === 'Fuse') { - btn.disabled = selectionCount < 2; - } else { - btn.disabled = !hasSelection; + controlPanel.querySelectorAll('.requires-selection').forEach((btn) => { + const button = btn; + if (button.textContent === 'Fuse') { + button.disabled = selectionCount < 2; + } + else { + button.disabled = !hasSelection; } }); const mattingBtn = controlPanel.querySelector('.matting-button'); @@ -538,91 +515,78 @@ async function createCanvasWidget(node, widget, app) { mattingBtn.disabled = selectionCount !== 1; } }; - canvas.canvasSelection.onSelectionChange = updateButtonStates; - const undoButton = controlPanel.querySelector(`#undo-button-${node.id}`); const redoButton = controlPanel.querySelector(`#redo-button-${node.id}`); - - canvas.onHistoryChange = ({canUndo, canRedo}) => { - if (undoButton) undoButton.disabled = !canUndo; - if (redoButton) redoButton.disabled = !canRedo; + canvas.onHistoryChange = ({ canUndo, canRedo }) => { + if (undoButton) + undoButton.disabled = !canUndo; + if (redoButton) + redoButton.disabled = !canRedo; }; - updateButtonStates(); canvas.updateHistoryButtons(); - - - const triggerWidget = node.widgets.find(w => w.name === "trigger"); - - const updateOutput = async () => { - triggerWidget.value = (triggerWidget.value + 1) % 99999999; - + const updateOutput = async (node, canvas) => { + const triggerWidget = node.widgets.find((w) => w.name === "trigger"); + if (triggerWidget) { + triggerWidget.value = (triggerWidget.value + 1) % 99999999; + } try { const new_preview = new Image(); - const blob = await canvas.getFlattenedCanvasWithMaskAsBlob(); + const blob = await canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob(); if (blob) { new_preview.src = URL.createObjectURL(blob); await new Promise(r => new_preview.onload = r); node.imgs = [new_preview]; - } else { + } + else { node.imgs = []; } - } catch (error) { + } + catch (error) { console.error("Error updating node preview:", error); } - }; - - // Tworzenie panelu warstw const layersPanel = canvas.canvasLayersPanel.createPanelStructure(); - const canvasContainer = $el("div.painterCanvasContainer.painter-container", { style: { position: "absolute", - top: "60px", // Wartość początkowa, zostanie nadpisana przez ResizeObserver + top: "60px", left: "10px", right: "270px", bottom: "10px", overflow: "hidden" } }, [canvas.canvas]); - - // Kontener dla panelu warstw const layersPanelContainer = $el("div.painterLayersPanelContainer", { style: { position: "absolute", - top: "60px", // Wartość początkowa, zostanie nadpisana przez ResizeObserver + top: "60px", right: "10px", width: "250px", bottom: "10px", overflow: "hidden" } }, [layersPanel]); - const resizeObserver = new ResizeObserver((entries) => { const controlsHeight = entries[0].target.offsetHeight; const newTop = (controlsHeight + 10) + "px"; canvasContainer.style.top = newTop; layersPanelContainer.style.top = newTop; }); - - resizeObserver.observe(controlPanel.querySelector('.controls')); - + const controlsElement = controlPanel.querySelector('.controls'); + if (controlsElement) { + resizeObserver.observe(controlsElement); + } canvas.canvas.addEventListener('focus', () => { canvasContainer.classList.add('has-focus'); }); - canvas.canvas.addEventListener('blur', () => { canvasContainer.classList.remove('has-focus'); }); - - node.onResize = function () { canvas.render(); }; - - const mainContainer = $el("div.painterMainContainer", { style: { position: "relative", @@ -630,25 +594,19 @@ async function createCanvasWidget(node, widget, app) { height: "100%" } }, [controlPanel, canvasContainer, layersPanelContainer]); - - - - const mainWidget = node.addDOMWidget("mainContainer", "widget", mainContainer); - + node.addDOMWidget("mainContainer", "widget", mainContainer); const openEditorBtn = controlPanel.querySelector(`#open-editor-btn-${node.id}`); let backdrop = null; - let modalContent = null; let originalParent = null; let isEditorOpen = false; - const closeEditor = () => { - originalParent.appendChild(mainContainer); - document.body.removeChild(backdrop); - + if (originalParent && backdrop) { + originalParent.appendChild(mainContainer); + document.body.removeChild(backdrop); + } isEditorOpen = false; openEditorBtn.textContent = "⛶"; openEditorBtn.title = "Open in Editor"; - setTimeout(() => { canvas.render(); if (node.onResize) { @@ -656,30 +614,24 @@ async function createCanvasWidget(node, widget, app) { } }, 0); }; - openEditorBtn.onclick = () => { if (isEditorOpen) { closeEditor(); return; } - - originalParent = mainContainer.parentNode; + originalParent = mainContainer.parentElement; if (!originalParent) { log.error("Could not find original parent of the canvas container!"); return; } - backdrop = $el("div.painter-modal-backdrop"); - modalContent = $el("div.painter-modal-content"); - + const modalContent = $el("div.painter-modal-content"); modalContent.appendChild(mainContainer); backdrop.appendChild(modalContent); document.body.appendChild(backdrop); - isEditorOpen = true; openEditorBtn.textContent = "X"; openEditorBtn.title = "Close Editor"; - setTimeout(() => { canvas.render(); if (node.onResize) { @@ -691,175 +643,130 @@ async function createCanvasWidget(node, widget, app) { window.canvasExecutionStates = new Map(); } node.canvasWidget = canvas; - setTimeout(() => { canvas.loadInitialState(); - // Renderuj panel warstw po załadowaniu stanu if (canvas.canvasLayersPanel) { canvas.canvasLayersPanel.renderLayers(); } }, 100); - - const showPreviewWidget = node.widgets.find(w => w.name === "show_preview"); + 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 }; } - - const canvasNodeInstances = new Map(); - app.registerExtension({ name: "Comfy.CanvasNode", - init() { - addStylesheet(getUrl('./css/canvas_view.css', import.meta.url)); - + addStylesheet(getUrl('./css/canvas_view.css')); const originalQueuePrompt = app.queuePrompt; app.queuePrompt = async function (number, prompt) { log.info("Preparing to queue prompt..."); - if (canvasNodeInstances.size > 0) { log.info(`Found ${canvasNodeInstances.size} CanvasNode(s). Sending data via WebSocket...`); - const sendPromises = []; for (const [nodeId, canvasWidget] of canvasNodeInstances.entries()) { - if (app.graph.getNodeById(nodeId) && canvasWidget.canvas && canvasWidget.canvas.canvasIO) { log.debug(`Sending data for canvas node ${nodeId}`); - sendPromises.push(canvasWidget.canvas.canvasIO.sendDataViaWebSocket(nodeId)); - } else { - + } + else { log.warn(`Node ${nodeId} not found in graph, removing from instances map.`); canvasNodeInstances.delete(nodeId); } } - try { - await Promise.all(sendPromises); log.info("All canvas data has been sent and acknowledged by the server."); - } catch (error) { + } + catch (error) { log.error("Failed to send canvas data for one or more nodes. Aborting prompt.", error); - - alert(`CanvasNode Error: ${error.message}`); - return; // Stop execution + return; } } - log.info("All pre-prompt tasks complete. Proceeding with original queuePrompt."); - return originalQueuePrompt.apply(this, arguments); }; }, - async beforeRegisterNodeDef(nodeType, nodeData, app) { if (nodeType.comfyClass === "CanvasNode") { const onNodeCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { log.debug("CanvasNode onNodeCreated: Base widget setup."); const r = onNodeCreated?.apply(this, arguments); - this.size = [1150, 1000]; + this.size = [1150, 1000]; return r; }; - nodeType.prototype.onAdded = async function () { log.info(`CanvasNode onAdded, ID: ${this.id}`); - log.debug(`Available widgets in onAdded:`, this.widgets.map(w => w.name)); - + log.debug(`Available widgets in onAdded:`, this.widgets.map((w) => w.name)); if (this.canvasWidget) { log.warn(`CanvasNode ${this.id} already initialized. Skipping onAdded setup.`); return; } - - this.widgets.forEach(w => { + this.widgets.forEach((w) => { log.debug(`Widget name: ${w.name}, type: ${w.type}, value: ${w.value}`); }); - - const nodeIdWidget = this.widgets.find(w => w.name === "node_id"); + const nodeIdWidget = this.widgets.find((w) => w.name === "node_id"); if (nodeIdWidget) { nodeIdWidget.value = String(this.id); log.debug(`Set hidden node_id widget to: ${nodeIdWidget.value}`); - } else { + } + else { log.error("Could not find the hidden node_id widget!"); } - - const canvasWidget = await createCanvasWidget(this, null, app); canvasNodeInstances.set(this.id, canvasWidget); log.info(`Registered CanvasNode instance for ID: ${this.id}`); - - // Use a timeout to ensure the DOM has updated before we redraw. setTimeout(() => { this.setDirtyCanvas(true, true); }, 100); }; - const onRemoved = nodeType.prototype.onRemoved; nodeType.prototype.onRemoved = function () { log.info(`Cleaning up canvas node ${this.id}`); - canvasNodeInstances.delete(this.id); log.info(`Deregistered CanvasNode instance for ID: ${this.id}`); - if (window.canvasExecutionStates) { window.canvasExecutionStates.delete(this.id); } - const tooltip = document.getElementById(`painter-help-tooltip-${this.id}`); if (tooltip) { tooltip.remove(); } const backdrop = document.querySelector('.painter-modal-backdrop'); - if (backdrop && backdrop.contains(this.canvasWidget?.canvas)) { + if (backdrop && this.canvasWidget && backdrop.contains(this.canvasWidget.canvas.canvas)) { document.body.removeChild(backdrop); } - if (this.canvasWidget && this.canvasWidget.destroy) { this.canvasWidget.destroy(); } - return onRemoved?.apply(this, arguments); }; - - const originalGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions; nodeType.prototype.getExtraMenuOptions = function (_, options) { originalGetExtraMenuOptions?.apply(this, arguments); - const self = this; - - const maskEditorIndex = options.findIndex(option => - option && option.content === "Open in MaskEditor" - ); + const maskEditorIndex = options.findIndex((option) => option && option.content === "Open in MaskEditor"); if (maskEditorIndex !== -1) { options.splice(maskEditorIndex, 1); } - const newOptions = [ { content: "Open in MaskEditor", @@ -867,12 +774,14 @@ app.registerExtension({ try { log.info("Opening LayerForge canvas in MaskEditor"); if (self.canvasWidget && self.canvasWidget.startMaskEditor) { - await self.canvasWidget.startMaskEditor(); - } else { + await self.canvasWidget.startMaskEditor(null, true); + } + else { log.error("Canvas widget not available"); alert("Canvas not ready. Please try again."); } - } catch (e) { + } + catch (e) { log.error("Error opening MaskEditor:", e); alert(`Failed to open MaskEditor: ${e.message}`); } @@ -882,11 +791,16 @@ app.registerExtension({ content: "Open Image", callback: async () => { try { + if (!self.canvasWidget) + return; const blob = await self.canvasWidget.getFlattenedCanvasAsBlob(); + if (!blob) + return; const url = URL.createObjectURL(blob); window.open(url, '_blank'); setTimeout(() => URL.revokeObjectURL(url), 1000); - } catch (e) { + } + catch (e) { log.error("Error opening image:", e); } }, @@ -895,11 +809,16 @@ app.registerExtension({ content: "Open Image with Mask Alpha", callback: async () => { try { + if (!self.canvasWidget) + return; const blob = await self.canvasWidget.getFlattenedCanvasWithMaskAsBlob(); + if (!blob) + return; const url = URL.createObjectURL(blob); window.open(url, '_blank'); setTimeout(() => URL.revokeObjectURL(url), 1000); - } catch (e) { + } + catch (e) { log.error("Error opening image with mask:", e); } }, @@ -908,11 +827,16 @@ app.registerExtension({ content: "Copy Image", callback: async () => { try { + if (!self.canvasWidget) + return; const blob = await self.canvasWidget.getFlattenedCanvasAsBlob(); - const item = new ClipboardItem({'image/png': blob}); + if (!blob) + return; + const item = new ClipboardItem({ 'image/png': blob }); await navigator.clipboard.write([item]); log.info("Image copied to clipboard."); - } catch (e) { + } + catch (e) { log.error("Error copying image:", e); alert("Failed to copy image to clipboard."); } @@ -922,11 +846,16 @@ app.registerExtension({ content: "Copy Image with Mask Alpha", callback: async () => { try { + if (!self.canvasWidget) + return; const blob = await self.canvasWidget.getFlattenedCanvasWithMaskAsBlob(); - const item = new ClipboardItem({'image/png': blob}); + if (!blob) + return; + const item = new ClipboardItem({ 'image/png': blob }); await navigator.clipboard.write([item]); log.info("Image with mask alpha copied to clipboard."); - } catch (e) { + } + catch (e) { log.error("Error copying image with mask:", e); alert("Failed to copy image with mask to clipboard."); } @@ -936,7 +865,11 @@ app.registerExtension({ content: "Save Image", callback: async () => { try { + if (!self.canvasWidget) + return; const blob = await self.canvasWidget.getFlattenedCanvasAsBlob(); + if (!blob) + return; const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; @@ -945,7 +878,8 @@ app.registerExtension({ a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(url), 1000); - } catch (e) { + } + catch (e) { log.error("Error saving image:", e); } }, @@ -954,7 +888,11 @@ app.registerExtension({ content: "Save Image with Mask Alpha", callback: async () => { try { + if (!self.canvasWidget) + return; const blob = await self.canvasWidget.getFlattenedCanvasWithMaskAsBlob(); + if (!blob) + return; const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; @@ -963,24 +901,18 @@ app.registerExtension({ a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(url), 1000); - } catch (e) { + } + catch (e) { log.error("Error saving image with mask:", e); } }, }, ]; if (options.length > 0) { - options.unshift({content: "___", disabled: true}); + options.unshift({ content: "___", disabled: true }); } options.unshift(...newOptions); }; } } }); - -async function handleImportInput(data) { - if (data && data.image) { - const imageData = data.image; - await importImage(imageData); - } -} diff --git a/js/ErrorHandler.js b/js/ErrorHandler.js index e722f24..6741857 100644 --- a/js/ErrorHandler.js +++ b/js/ErrorHandler.js @@ -2,11 +2,8 @@ * ErrorHandler - Centralna obsługa błędów * Eliminuje powtarzalne wzorce obsługi błędów w całym projekcie */ - -import {createModuleLogger} from "./utils/LoggerUtils.js"; - +import { createModuleLogger } from "./utils/LoggerUtils.js"; const log = createModuleLogger('ErrorHandler'); - /** * Typy błędów w aplikacji */ @@ -20,7 +17,6 @@ export const ErrorTypes = { USER_INPUT: 'USER_INPUT_ERROR', SYSTEM: 'SYSTEM_ERROR' }; - /** * Klasa błędu aplikacji z dodatkowymi informacjami */ @@ -37,7 +33,6 @@ export class AppError extends Error { } } } - /** * Handler błędów z automatycznym logowaniem i kategoryzacją */ @@ -47,12 +42,11 @@ export class ErrorHandler { this.errorHistory = []; this.maxHistorySize = 100; } - /** * Obsługuje błąd z automatycznym logowaniem - * @param {Error|AppError} error - Błąd do obsłużenia + * @param {Error | AppError | string} error - Błąd do obsłużenia * @param {string} context - Kontekst wystąpienia błędu - * @param {Object} additionalInfo - Dodatkowe informacje + * @param {object} additionalInfo - Dodatkowe informacje * @returns {AppError} Znormalizowany błąd */ handle(error, context = 'Unknown', additionalInfo = {}) { @@ -60,52 +54,33 @@ export class ErrorHandler { this.logError(normalizedError, context); this.recordError(normalizedError); this.incrementErrorCount(normalizedError.type); - return normalizedError; } - /** * Normalizuje błąd do standardowego formatu - * @param {Error|AppError|string} error - Błąd do znormalizowania + * @param {Error | AppError | string} error - Błąd do znormalizowania * @param {string} context - Kontekst - * @param {Object} additionalInfo - Dodatkowe informacje + * @param {object} additionalInfo - Dodatkowe informacje * @returns {AppError} Znormalizowany błąd */ normalizeError(error, context, additionalInfo) { if (error instanceof AppError) { return error; } - if (error instanceof Error) { const type = this.categorizeError(error, context); - return new AppError( - error.message, - type, - {context, ...additionalInfo}, - error - ); + return new AppError(error.message, type, { context, ...additionalInfo }, error); } - if (typeof error === 'string') { - return new AppError( - error, - ErrorTypes.SYSTEM, - {context, ...additionalInfo} - ); + return new AppError(error, ErrorTypes.SYSTEM, { context, ...additionalInfo }); } - - return new AppError( - 'Unknown error occurred', - ErrorTypes.SYSTEM, - {context, originalError: error, ...additionalInfo} - ); + return new AppError('Unknown error occurred', ErrorTypes.SYSTEM, { context, originalError: error, ...additionalInfo }); } - /** * Kategoryzuje błąd na podstawie wiadomości i kontekstu * @param {Error} error - Błąd do skategoryzowania * @param {string} context - Kontekst - * @returns {string} Typ błędu + * @returns {ErrorType} Typ błędu */ categorizeError(error, context) { const message = error.message.toLowerCase(); @@ -132,10 +107,8 @@ export class ErrorHandler { if (context.toLowerCase().includes('canvas')) { return ErrorTypes.CANVAS; } - return ErrorTypes.SYSTEM; } - /** * Loguje błąd z odpowiednim poziomem * @param {AppError} error - Błąd do zalogowania @@ -161,7 +134,6 @@ export class ErrorHandler { log.error(logMessage, logDetails); } } - /** * Zapisuje błąd w historii * @param {AppError} error - Błąd do zapisania @@ -177,36 +149,37 @@ export class ErrorHandler { this.errorHistory.shift(); } } - /** * Zwiększa licznik błędów dla danego typu - * @param {string} errorType - Typ błędu + * @param {ErrorType} errorType - Typ błędu */ incrementErrorCount(errorType) { const current = this.errorCounts.get(errorType) || 0; this.errorCounts.set(errorType, current + 1); } - /** * Zwraca statystyki błędów - * @returns {Object} Statystyki błędów + * @returns {ErrorStats} Statystyki błędów */ getErrorStats() { + const errorCountsObj = {}; + for (const [key, value] of this.errorCounts.entries()) { + errorCountsObj[key] = value; + } return { totalErrors: this.errorHistory.length, - errorCounts: Object.fromEntries(this.errorCounts), + errorCounts: errorCountsObj, recentErrors: this.errorHistory.slice(-10), errorsByType: this.groupErrorsByType() }; } - /** * Grupuje błędy według typu - * @returns {Object} Błędy pogrupowane według typu + * @returns {{ [key: string]: ErrorHistoryEntry[] }} Błędy pogrupowane według typu */ groupErrorsByType() { const grouped = {}; - this.errorHistory.forEach(error => { + this.errorHistory.forEach((error) => { if (!grouped[error.type]) { grouped[error.type] = []; } @@ -214,7 +187,6 @@ export class ErrorHandler { }); return grouped; } - /** * Czyści historię błędów */ @@ -224,9 +196,7 @@ export class ErrorHandler { log.info('Error history cleared'); } } - const errorHandler = new ErrorHandler(); - /** * Wrapper funkcji z automatyczną obsługą błędów * @param {Function} fn - Funkcja do opakowania @@ -237,7 +207,8 @@ export function withErrorHandling(fn, context) { return async function (...args) { try { return await fn.apply(this, args); - } catch (error) { + } + catch (error) { const handledError = errorHandler.handle(error, context, { functionName: fn.name, arguments: args.length @@ -246,7 +217,6 @@ export function withErrorHandling(fn, context) { } }; } - /** * Decorator dla metod klasy z automatyczną obsługą błędów * @param {string} context - Kontekst wykonania @@ -254,11 +224,11 @@ export function withErrorHandling(fn, context) { export function handleErrors(context) { return function (target, propertyKey, descriptor) { const originalMethod = descriptor.value; - descriptor.value = async function (...args) { try { return await originalMethod.apply(this, args); - } catch (error) { + } + catch (error) { const handledError = errorHandler.handle(error, `${context}.${propertyKey}`, { className: target.constructor.name, methodName: propertyKey, @@ -267,86 +237,77 @@ export function handleErrors(context) { throw handledError; } }; - return descriptor; }; } - /** * Funkcja pomocnicza do tworzenia błędów walidacji * @param {string} message - Wiadomość błędu - * @param {Object} details - Szczegóły walidacji + * @param {object} details - Szczegóły walidacji * @returns {AppError} Błąd walidacji */ export function createValidationError(message, details = {}) { return new AppError(message, ErrorTypes.VALIDATION, details); } - /** * Funkcja pomocnicza do tworzenia błędów sieciowych * @param {string} message - Wiadomość błędu - * @param {Object} details - Szczegóły sieci + * @param {object} details - Szczegóły sieci * @returns {AppError} Błąd sieciowy */ export function createNetworkError(message, details = {}) { return new AppError(message, ErrorTypes.NETWORK, details); } - /** * Funkcja pomocnicza do tworzenia błędów plików * @param {string} message - Wiadomość błędu - * @param {Object} details - Szczegóły pliku + * @param {object} details - Szczegóły pliku * @returns {AppError} Błąd pliku */ export function createFileError(message, details = {}) { return new AppError(message, ErrorTypes.FILE_IO, details); } - /** * Funkcja pomocnicza do bezpiecznego wykonania operacji - * @param {Function} operation - Operacja do wykonania - * @param {*} fallbackValue - Wartość fallback w przypadku błędu + * @param {() => Promise} operation - Operacja do wykonania + * @param {T} fallbackValue - Wartość fallback w przypadku błędu * @param {string} context - Kontekst operacji - * @returns {*} Wynik operacji lub wartość fallback + * @returns {Promise} Wynik operacji lub wartość fallback */ -export async function safeExecute(operation, fallbackValue = null, context = 'SafeExecute') { +export async function safeExecute(operation, fallbackValue, context = 'SafeExecute') { try { return await operation(); - } catch (error) { + } + catch (error) { errorHandler.handle(error, context); return fallbackValue; } } - /** * Funkcja do retry operacji z exponential backoff - * @param {Function} operation - Operacja do powtórzenia + * @param {() => Promise} operation - Operacja do powtórzenia * @param {number} maxRetries - Maksymalna liczba prób * @param {number} baseDelay - Podstawowe opóźnienie w ms * @param {string} context - Kontekst operacji - * @returns {*} Wynik operacji + * @returns {Promise} Wynik operacji */ export async function retryWithBackoff(operation, maxRetries = 3, baseDelay = 1000, context = 'RetryOperation') { let lastError; - for (let attempt = 0; attempt <= maxRetries; attempt++) { try { return await operation(); - } catch (error) { + } + catch (error) { lastError = error; - if (attempt === maxRetries) { break; } - const delay = baseDelay * Math.pow(2, attempt); - log.warn(`Attempt ${attempt + 1} failed, retrying in ${delay}ms`, {error: error.message, context}); + log.warn(`Attempt ${attempt + 1} failed, retrying in ${delay}ms`, { error: lastError.message, context }); await new Promise(resolve => setTimeout(resolve, delay)); } } - - throw errorHandler.handle(lastError, context, {attempts: maxRetries + 1}); + throw errorHandler.handle(lastError, context, { attempts: maxRetries + 1 }); } - -export {errorHandler}; +export { errorHandler }; export default errorHandler; diff --git a/js/ImageCache.js b/js/ImageCache.js index b5bd830..e8eb996 100644 --- a/js/ImageCache.js +++ b/js/ImageCache.js @@ -1,27 +1,21 @@ -import {createModuleLogger} from "./utils/LoggerUtils.js"; - +import { createModuleLogger } from "./utils/LoggerUtils.js"; const log = createModuleLogger('ImageCache'); - export class ImageCache { constructor() { this.cache = new Map(); } - set(key, imageData) { log.info("Caching image data for key:", key); this.cache.set(key, imageData); } - get(key) { const data = this.cache.get(key); log.debug("Retrieved cached data for key:", key, !!data); return data; } - has(key) { return this.cache.has(key); } - clear() { log.info("Clearing image cache"); this.cache.clear(); diff --git a/js/ImageReferenceManager.js b/js/ImageReferenceManager.js index bc6a4ac..34a36ce 100644 --- a/js/ImageReferenceManager.js +++ b/js/ImageReferenceManager.js @@ -1,24 +1,18 @@ -import {removeImage, getAllImageIds} from "./db.js"; -import {createModuleLogger} from "./utils/LoggerUtils.js"; - +import { removeImage, getAllImageIds } from "./db.js"; +import { createModuleLogger } from "./utils/LoggerUtils.js"; const log = createModuleLogger('ImageReferenceManager'); - export class ImageReferenceManager { constructor(canvas) { this.canvas = canvas; this.imageReferences = new Map(); // imageId -> count - this.imageLastUsed = new Map(); // imageId -> timestamp - this.gcInterval = 5 * 60 * 1000; // 5 minut (nieużywane) - this.maxAge = 30 * 60 * 1000; // 30 minut bez użycia + this.imageLastUsed = new Map(); // imageId -> timestamp + this.gcInterval = 5 * 60 * 1000; // 5 minut (nieużywane) + this.maxAge = 30 * 60 * 1000; // 30 minut bez użycia this.gcTimer = null; this.isGcRunning = false; - this.operationCount = 0; - this.operationThreshold = 500; // Uruchom GC po 500 operacjach - - + this.operationThreshold = 500; // Uruchom GC po 500 operacjach } - /** * Uruchamia automatyczne garbage collection */ @@ -26,14 +20,11 @@ export class ImageReferenceManager { if (this.gcTimer) { clearInterval(this.gcTimer); } - - this.gcTimer = setInterval(() => { + this.gcTimer = window.setInterval(() => { this.performGarbageCollection(); }, this.gcInterval); - log.info("Garbage collection started with interval:", this.gcInterval / 1000, "seconds"); } - /** * Zatrzymuje automatyczne garbage collection */ @@ -44,38 +35,35 @@ export class ImageReferenceManager { } log.info("Garbage collection stopped"); } - /** * Dodaje referencję do obrazu * @param {string} imageId - ID obrazu */ addReference(imageId) { - if (!imageId) return; - + if (!imageId) + return; const currentCount = this.imageReferences.get(imageId) || 0; this.imageReferences.set(imageId, currentCount + 1); this.imageLastUsed.set(imageId, Date.now()); - log.debug(`Added reference to image ${imageId}, count: ${currentCount + 1}`); } - /** * Usuwa referencję do obrazu * @param {string} imageId - ID obrazu */ removeReference(imageId) { - if (!imageId) return; - + if (!imageId) + return; const currentCount = this.imageReferences.get(imageId) || 0; if (currentCount <= 1) { this.imageReferences.delete(imageId); log.debug(`Removed last reference to image ${imageId}`); - } else { + } + else { this.imageReferences.set(imageId, currentCount - 1); log.debug(`Removed reference to image ${imageId}, count: ${currentCount - 1}`); } } - /** * Aktualizuje referencje na podstawie aktualnego stanu canvas */ @@ -86,117 +74,100 @@ export class ImageReferenceManager { usedImageIds.forEach(imageId => { this.addReference(imageId); }); - log.info(`Updated references for ${usedImageIds.size} unique images`); } - /** * Zbiera wszystkie używane imageId z różnych źródeł * @returns {Set} Zbiór używanych imageId */ collectAllUsedImageIds() { const usedImageIds = new Set(); - this.canvas.layers.forEach(layer => { + this.canvas.layers.forEach((layer) => { if (layer.imageId) { usedImageIds.add(layer.imageId); } }); if (this.canvas.canvasState && this.canvas.canvasState.layersUndoStack) { - this.canvas.canvasState.layersUndoStack.forEach(layersState => { - layersState.forEach(layer => { + this.canvas.canvasState.layersUndoStack.forEach((layersState) => { + layersState.forEach((layer) => { if (layer.imageId) { usedImageIds.add(layer.imageId); } }); }); } - if (this.canvas.canvasState && this.canvas.canvasState.layersRedoStack) { - this.canvas.canvasState.layersRedoStack.forEach(layersState => { - layersState.forEach(layer => { + this.canvas.canvasState.layersRedoStack.forEach((layersState) => { + layersState.forEach((layer) => { if (layer.imageId) { usedImageIds.add(layer.imageId); } }); }); } - log.debug(`Collected ${usedImageIds.size} used image IDs`); return usedImageIds; } - /** * Znajduje nieużywane obrazy * @param {Set} usedImageIds - Zbiór używanych imageId - * @returns {Array} Lista nieużywanych imageId + * @returns {Promise} Lista nieużywanych imageId */ async findUnusedImages(usedImageIds) { try { - const allImageIds = await getAllImageIds(); const unusedImages = []; const now = Date.now(); - for (const imageId of allImageIds) { - if (!usedImageIds.has(imageId)) { const lastUsed = this.imageLastUsed.get(imageId) || 0; const age = now - lastUsed; - if (age > this.maxAge) { unusedImages.push(imageId); - } else { + } + else { log.debug(`Image ${imageId} is unused but too young (age: ${Math.round(age / 1000)}s)`); } } } - log.debug(`Found ${unusedImages.length} unused images ready for cleanup`); return unusedImages; - } catch (error) { + } + catch (error) { log.error("Error finding unused images:", error); return []; } } - /** * Czyści nieużywane obrazy - * @param {Array} unusedImages - Lista nieużywanych imageId + * @param {string[]} unusedImages - Lista nieużywanych imageId */ async cleanupUnusedImages(unusedImages) { if (unusedImages.length === 0) { log.debug("No unused images to cleanup"); return; } - log.info(`Starting cleanup of ${unusedImages.length} unused images`); let cleanedCount = 0; let errorCount = 0; - for (const imageId of unusedImages) { try { - await removeImage(imageId); - if (this.canvas.imageCache && this.canvas.imageCache.has(imageId)) { this.canvas.imageCache.delete(imageId); } - this.imageReferences.delete(imageId); this.imageLastUsed.delete(imageId); - cleanedCount++; log.debug(`Cleaned up image: ${imageId}`); - - } catch (error) { + } + catch (error) { errorCount++; log.error(`Error cleaning up image ${imageId}:`, error); } } - log.info(`Garbage collection completed: ${cleanedCount} images cleaned, ${errorCount} errors`); } - /** * Wykonuje pełne garbage collection */ @@ -205,44 +176,35 @@ export class ImageReferenceManager { log.debug("Garbage collection already running, skipping"); return; } - this.isGcRunning = true; log.info("Starting garbage collection..."); - try { - this.updateReferences(); - const usedImageIds = this.collectAllUsedImageIds(); - const unusedImages = await this.findUnusedImages(usedImageIds); - await this.cleanupUnusedImages(unusedImages); - - } catch (error) { + } + catch (error) { log.error("Error during garbage collection:", error); - } finally { + } + finally { this.isGcRunning = false; } } - /** * Zwiększa licznik operacji i sprawdza czy uruchomić GC */ incrementOperationCount() { this.operationCount++; log.debug(`Operation count: ${this.operationCount}/${this.operationThreshold}`); - if (this.operationCount >= this.operationThreshold) { log.info(`Operation threshold reached (${this.operationThreshold}), triggering garbage collection`); this.operationCount = 0; // Reset counter - setTimeout(() => { this.performGarbageCollection(); }, 100); } } - /** * Resetuje licznik operacji */ @@ -250,7 +212,6 @@ export class ImageReferenceManager { this.operationCount = 0; log.debug("Operation count reset"); } - /** * Ustawia próg operacji dla automatycznego GC * @param {number} threshold - Nowy próg operacji @@ -259,7 +220,6 @@ export class ImageReferenceManager { this.operationThreshold = Math.max(1, threshold); log.info(`Operation threshold set to: ${this.operationThreshold}`); } - /** * Ręczne uruchomienie garbage collection */ @@ -267,10 +227,9 @@ export class ImageReferenceManager { log.info("Manual garbage collection triggered"); await this.performGarbageCollection(); } - /** * Zwraca statystyki garbage collection - * @returns {Object} Statystyki + * @returns {GarbageCollectionStats} Statystyki */ getStats() { return { @@ -281,7 +240,6 @@ export class ImageReferenceManager { maxAge: this.maxAge }; } - /** * Czyści wszystkie dane (przy usuwaniu canvas) */ diff --git a/js/MaskTool.js b/js/MaskTool.js index 2343199..9bbfa56 100644 --- a/js/MaskTool.js +++ b/js/MaskTool.js @@ -1,18 +1,18 @@ -import {createModuleLogger} from "./utils/LoggerUtils.js"; - +import { createModuleLogger } from "./utils/LoggerUtils.js"; const log = createModuleLogger('Mask_tool'); - export class MaskTool { constructor(canvasInstance, callbacks = {}) { this.canvasInstance = canvasInstance; this.mainCanvas = canvasInstance.canvas; this.onStateChange = callbacks.onStateChange || null; this.maskCanvas = document.createElement('canvas'); - this.maskCtx = this.maskCanvas.getContext('2d', { willReadFrequently: true }); - + const maskCtx = this.maskCanvas.getContext('2d', { willReadFrequently: true }); + if (!maskCtx) { + throw new Error("Failed to get 2D context for mask canvas"); + } + this.maskCtx = maskCtx; this.x = 0; this.y = 0; - this.isOverlayVisible = true; this.isActive = false; this.brushSize = 20; @@ -20,15 +20,16 @@ export class MaskTool { this.brushHardness = 0.5; this.isDrawing = false; this.lastPosition = null; - this.previewCanvas = document.createElement('canvas'); - this.previewCtx = this.previewCanvas.getContext('2d', { willReadFrequently: true }); + const previewCtx = this.previewCanvas.getContext('2d', { willReadFrequently: true }); + if (!previewCtx) { + throw new Error("Failed to get 2D context for preview canvas"); + } + this.previewCtx = previewCtx; this.previewVisible = false; this.previewCanvasInitialized = false; - this.initMaskCanvas(); } - initPreviewCanvas() { if (this.previewCanvas.parentElement) { this.previewCanvas.parentElement.removeChild(this.previewCanvas); @@ -40,27 +41,22 @@ export class MaskTool { this.previewCanvas.style.top = `${this.canvasInstance.canvas.offsetTop}px`; this.previewCanvas.style.pointerEvents = 'none'; this.previewCanvas.style.zIndex = '10'; - this.canvasInstance.canvas.parentElement.appendChild(this.previewCanvas); + if (this.canvasInstance.canvas.parentElement) { + this.canvasInstance.canvas.parentElement.appendChild(this.previewCanvas); + } } - setBrushHardness(hardness) { this.brushHardness = Math.max(0, Math.min(1, hardness)); } - initMaskCanvas() { - const extraSpace = 2000; // Allow for a generous drawing area outside the output area this.maskCanvas.width = this.canvasInstance.width + extraSpace; this.maskCanvas.height = this.canvasInstance.height + extraSpace; - - this.x = -extraSpace / 2; this.y = -extraSpace / 2; - this.maskCtx.clearRect(0, 0, this.maskCanvas.width, this.maskCanvas.height); log.info(`Initialized mask canvas with extended size: ${this.maskCanvas.width}x${this.maskCanvas.height}, origin at (${this.x}, ${this.y})`); } - activate() { if (!this.previewCanvasInitialized) { this.initPreviewCanvas(); @@ -69,131 +65,108 @@ export class MaskTool { this.isActive = true; this.previewCanvas.style.display = 'block'; this.canvasInstance.interaction.mode = 'drawingMask'; - if (this.canvasInstance.canvasState && this.canvasInstance.canvasState.maskUndoStack.length === 0) { + if (this.canvasInstance.canvasState.maskUndoStack.length === 0) { this.canvasInstance.canvasState.saveMaskState(); } this.canvasInstance.updateHistoryButtons(); - log.info("Mask tool activated"); } - deactivate() { this.isActive = false; this.previewCanvas.style.display = 'none'; this.canvasInstance.interaction.mode = 'none'; this.canvasInstance.updateHistoryButtons(); - log.info("Mask tool deactivated"); } - setBrushSize(size) { this.brushSize = Math.max(1, size); } - setBrushStrength(strength) { this.brushStrength = Math.max(0, Math.min(1, strength)); } - handleMouseDown(worldCoords, viewCoords) { - if (!this.isActive) return; + if (!this.isActive) + return; this.isDrawing = true; this.lastPosition = worldCoords; this.draw(worldCoords); this.clearPreview(); } - handleMouseMove(worldCoords, viewCoords) { if (this.isActive) { this.drawBrushPreview(viewCoords); } - if (!this.isActive || !this.isDrawing) return; + if (!this.isActive || !this.isDrawing) + return; this.draw(worldCoords); this.lastPosition = worldCoords; } - handleMouseLeave() { this.previewVisible = false; this.clearPreview(); } - handleMouseEnter() { this.previewVisible = true; } - handleMouseUp(viewCoords) { - if (!this.isActive) return; + if (!this.isActive) + return; if (this.isDrawing) { this.isDrawing = false; this.lastPosition = null; - if (this.canvasInstance.canvasState) { - this.canvasInstance.canvasState.saveMaskState(); - } + this.canvasInstance.canvasState.saveMaskState(); if (this.onStateChange) { this.onStateChange(); } this.drawBrushPreview(viewCoords); } } - draw(worldCoords) { if (!this.lastPosition) { this.lastPosition = worldCoords; } - - const canvasLastX = this.lastPosition.x - this.x; const canvasLastY = this.lastPosition.y - this.y; const canvasX = worldCoords.x - this.x; const canvasY = worldCoords.y - this.y; - - const canvasWidth = this.maskCanvas.width; const canvasHeight = this.maskCanvas.height; - if (canvasX >= 0 && canvasX < canvasWidth && canvasY >= 0 && canvasY < canvasHeight && canvasLastX >= 0 && canvasLastX < canvasWidth && canvasLastY >= 0 && canvasLastY < canvasHeight) { - this.maskCtx.beginPath(); this.maskCtx.moveTo(canvasLastX, canvasLastY); this.maskCtx.lineTo(canvasX, canvasY); const gradientRadius = this.brushSize / 2; - if (this.brushHardness === 1) { this.maskCtx.strokeStyle = `rgba(255, 255, 255, ${this.brushStrength})`; - } else { - + } + else { const innerRadius = gradientRadius * this.brushHardness; - const gradient = this.maskCtx.createRadialGradient( - canvasX, canvasY, innerRadius, - canvasX, canvasY, gradientRadius - ); + const gradient = this.maskCtx.createRadialGradient(canvasX, canvasY, innerRadius, canvasX, canvasY, gradientRadius); gradient.addColorStop(0, `rgba(255, 255, 255, ${this.brushStrength})`); gradient.addColorStop(1, `rgba(255, 255, 255, 0)`); this.maskCtx.strokeStyle = gradient; } - this.maskCtx.lineWidth = this.brushSize; this.maskCtx.lineCap = 'round'; this.maskCtx.lineJoin = 'round'; this.maskCtx.globalCompositeOperation = 'source-over'; this.maskCtx.stroke(); - } else { + } + else { log.debug(`Drawing outside mask canvas bounds: (${canvasX}, ${canvasY})`); } } - drawBrushPreview(viewCoords) { if (!this.previewVisible || this.isDrawing) { this.clearPreview(); return; } - this.clearPreview(); const zoom = this.canvasInstance.viewport.zoom; const radius = (this.brushSize / 2) * zoom; - this.previewCtx.beginPath(); this.previewCtx.arc(viewCoords.x, viewCoords.y, radius, 0, 2 * Math.PI); this.previewCtx.strokeStyle = 'rgba(255, 255, 255, 0.8)'; @@ -201,27 +174,26 @@ export class MaskTool { this.previewCtx.setLineDash([2, 4]); this.previewCtx.stroke(); } - clearPreview() { this.previewCtx.clearRect(0, 0, this.previewCanvas.width, this.previewCanvas.height); } - clear() { this.maskCtx.clearRect(0, 0, this.maskCanvas.width, this.maskCanvas.height); - if (this.isActive && this.canvasInstance.canvasState) { + if (this.isActive) { this.canvasInstance.canvasState.saveMaskState(); } } - getMask() { return this.maskCanvas; } - getMaskImageWithAlpha() { const tempCanvas = document.createElement('canvas'); tempCanvas.width = this.maskCanvas.width; tempCanvas.height = this.maskCanvas.height; const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); + if (!tempCtx) { + throw new Error("Failed to get 2D context for temporary canvas"); + } tempCtx.drawImage(this.maskCanvas, 0, 0); const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height); const data = imageData.data; @@ -237,7 +209,6 @@ export class MaskTool { maskImage.src = tempCanvas.toDataURL(); return maskImage; } - resize(width, height) { this.initPreviewCanvas(); const oldMask = this.maskCanvas; @@ -245,63 +216,46 @@ export class MaskTool { const oldY = this.y; const oldWidth = oldMask.width; const oldHeight = oldMask.height; - - const isIncreasingWidth = width > (this.canvasInstance.width); - const isIncreasingHeight = height > (this.canvasInstance.height); - + const isIncreasingWidth = width > this.canvasInstance.width; + const isIncreasingHeight = height > this.canvasInstance.height; this.maskCanvas = document.createElement('canvas'); - const extraSpace = 2000; - - const newWidth = isIncreasingWidth ? width + extraSpace : Math.max(oldWidth, width + extraSpace); const newHeight = isIncreasingHeight ? height + extraSpace : Math.max(oldHeight, height + extraSpace); - this.maskCanvas.width = newWidth; this.maskCanvas.height = newHeight; - this.maskCtx = this.maskCanvas.getContext('2d', { willReadFrequently: true }); - + const newMaskCtx = this.maskCanvas.getContext('2d', { willReadFrequently: true }); + if (!newMaskCtx) { + throw new Error("Failed to get 2D context for new mask canvas"); + } + this.maskCtx = newMaskCtx; if (oldMask.width > 0 && oldMask.height > 0) { - const offsetX = this.x - oldX; const offsetY = this.y - oldY; - this.maskCtx.drawImage(oldMask, offsetX, offsetY); - log.debug(`Preserved mask content with offset (${offsetX}, ${offsetY})`); } - log.info(`Mask canvas resized to ${this.maskCanvas.width}x${this.maskCanvas.height}, position (${this.x}, ${this.y})`); log.info(`Canvas size change: width ${isIncreasingWidth ? 'increased' : 'decreased'}, height ${isIncreasingHeight ? 'increased' : 'decreased'}`); } - updatePosition(dx, dy) { this.x += dx; this.y += dy; log.info(`Mask position updated to (${this.x}, ${this.y})`); } - toggleOverlayVisibility() { this.isOverlayVisible = !this.isOverlayVisible; log.info(`Mask overlay visibility toggled to: ${this.isOverlayVisible}`); } - 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ę + this.canvasInstance.render(); log.info(`MaskTool updated with a new mask image at correct canvas position (${destX}, ${destY}).`); } } diff --git a/js/css/canvas_view.css b/js/css/canvas_view.css index 2ad41f1..ff2ab75 100644 --- a/js/css/canvas_view.css +++ b/js/css/canvas_view.css @@ -391,3 +391,15 @@ flex-direction: column; position: relative; } + +.painterMainContainer { + display: flex; + flex-direction: column; + height: 100%; + flex-grow: 1; +} + +.painterCanvasContainer { + flex-grow: 1; + position: relative; +} diff --git a/js/db.js b/js/db.js index 6fa546d..dd889c6 100644 --- a/js/db.js +++ b/js/db.js @@ -1,21 +1,17 @@ -import {createModuleLogger} from "./utils/LoggerUtils.js"; - +import { createModuleLogger } from "./utils/LoggerUtils.js"; const log = createModuleLogger('db'); - const DB_NAME = 'CanvasNodeDB'; const STATE_STORE_NAME = 'CanvasState'; const IMAGE_STORE_NAME = 'CanvasImages'; const DB_VERSION = 3; - -let db; - +let db = null; /** * Funkcja pomocnicza do tworzenia żądań IndexedDB z ujednoliconą obsługą błędów * @param {IDBObjectStore} store - Store IndexedDB - * @param {string} operation - Nazwa operacji (get, put, delete, clear) - * @param {*} data - Dane dla operacji (opcjonalne) + * @param {DBRequestOperation} operation - Nazwa operacji (get, put, delete, clear) + * @param {any} data - Dane dla operacji (opcjonalne) * @param {string} errorMessage - Wiadomość błędu - * @returns {Promise} Promise z wynikiem operacji + * @returns {Promise} Promise z wynikiem operacji */ function createDBRequest(store, operation, data, errorMessage) { return new Promise((resolve, reject) => { @@ -37,130 +33,107 @@ function createDBRequest(store, operation, data, errorMessage) { reject(new Error(`Unknown operation: ${operation}`)); return; } - request.onerror = (event) => { log.error(errorMessage, event.target.error); reject(errorMessage); }; - request.onsuccess = (event) => { resolve(event.target.result); }; }); } - function openDB() { return new Promise((resolve, reject) => { if (db) { resolve(db); return; } - log.info("Opening IndexedDB..."); const request = indexedDB.open(DB_NAME, DB_VERSION); - request.onerror = (event) => { log.error("IndexedDB error:", event.target.error); reject("Error opening IndexedDB."); }; - request.onsuccess = (event) => { db = event.target.result; log.info("IndexedDB opened successfully."); resolve(db); }; - request.onupgradeneeded = (event) => { log.info("Upgrading IndexedDB..."); - const db = event.target.result; - if (!db.objectStoreNames.contains(STATE_STORE_NAME)) { - db.createObjectStore(STATE_STORE_NAME, {keyPath: 'id'}); + const dbInstance = event.target.result; + if (!dbInstance.objectStoreNames.contains(STATE_STORE_NAME)) { + dbInstance.createObjectStore(STATE_STORE_NAME, { keyPath: 'id' }); log.info("Object store created:", STATE_STORE_NAME); } - if (!db.objectStoreNames.contains(IMAGE_STORE_NAME)) { - db.createObjectStore(IMAGE_STORE_NAME, {keyPath: 'imageId'}); + if (!dbInstance.objectStoreNames.contains(IMAGE_STORE_NAME)) { + dbInstance.createObjectStore(IMAGE_STORE_NAME, { keyPath: 'imageId' }); log.info("Object store created:", IMAGE_STORE_NAME); } }; }); } - export async function getCanvasState(id) { log.info(`Getting state for id: ${id}`); const db = await openDB(); const transaction = db.transaction([STATE_STORE_NAME], 'readonly'); const store = transaction.objectStore(STATE_STORE_NAME); - const result = await createDBRequest(store, 'get', id, "Error getting canvas state"); log.debug(`Get success for id: ${id}`, result ? 'found' : 'not found'); return result ? result.state : null; } - export async function setCanvasState(id, state) { log.info(`Setting state for id: ${id}`); const db = await openDB(); const transaction = db.transaction([STATE_STORE_NAME], 'readwrite'); const store = transaction.objectStore(STATE_STORE_NAME); - - await createDBRequest(store, 'put', {id, state}, "Error setting canvas state"); + await createDBRequest(store, 'put', { id, state }, "Error setting canvas state"); log.debug(`Set success for id: ${id}`); } - export async function removeCanvasState(id) { log.info(`Removing state for id: ${id}`); const db = await openDB(); const transaction = db.transaction([STATE_STORE_NAME], 'readwrite'); const store = transaction.objectStore(STATE_STORE_NAME); - await createDBRequest(store, 'delete', id, "Error removing canvas state"); log.debug(`Remove success for id: ${id}`); } - export async function saveImage(imageId, imageSrc) { log.info(`Saving image with id: ${imageId}`); const db = await openDB(); const transaction = db.transaction([IMAGE_STORE_NAME], 'readwrite'); const store = transaction.objectStore(IMAGE_STORE_NAME); - - await createDBRequest(store, 'put', {imageId, imageSrc}, "Error saving image"); + await createDBRequest(store, 'put', { imageId, imageSrc }, "Error saving image"); log.debug(`Image saved successfully for id: ${imageId}`); } - export async function getImage(imageId) { log.info(`Getting image with id: ${imageId}`); const db = await openDB(); const transaction = db.transaction([IMAGE_STORE_NAME], 'readonly'); const store = transaction.objectStore(IMAGE_STORE_NAME); - const result = await createDBRequest(store, 'get', imageId, "Error getting image"); log.debug(`Get image success for id: ${imageId}`, result ? 'found' : 'not found'); return result ? result.imageSrc : null; } - export async function removeImage(imageId) { log.info(`Removing image with id: ${imageId}`); const db = await openDB(); const transaction = db.transaction([IMAGE_STORE_NAME], 'readwrite'); const store = transaction.objectStore(IMAGE_STORE_NAME); - await createDBRequest(store, 'delete', imageId, "Error removing image"); log.debug(`Remove image success for id: ${imageId}`); } - export async function getAllImageIds() { log.info("Getting all image IDs..."); const db = await openDB(); const transaction = db.transaction([IMAGE_STORE_NAME], 'readonly'); const store = transaction.objectStore(IMAGE_STORE_NAME); - return new Promise((resolve, reject) => { const request = store.getAllKeys(); - request.onerror = (event) => { log.error("Error getting all image IDs:", event.target.error); reject("Error getting all image IDs"); }; - request.onsuccess = (event) => { const imageIds = event.target.result; log.debug(`Found ${imageIds.length} image IDs in database`); @@ -168,13 +141,11 @@ export async function getAllImageIds() { }; }); } - export async function clearAllCanvasStates() { log.info("Clearing all canvas states..."); const db = await openDB(); const transaction = db.transaction([STATE_STORE_NAME], 'readwrite'); const store = transaction.objectStore(STATE_STORE_NAME); - await createDBRequest(store, 'clear', null, "Error clearing canvas states"); log.info("All canvas states cleared successfully."); } diff --git a/js/logger.js b/js/logger.js index 08cbf2e..9253bd4 100644 --- a/js/logger.js +++ b/js/logger.js @@ -8,6 +8,20 @@ * - Możliwość zapisywania logów do localStorage * - Możliwość eksportu logów */ +function padStart(str, targetLength, padString) { + targetLength = targetLength >> 0; + padString = String(padString || ' '); + if (str.length > targetLength) { + return String(str); + } + else { + targetLength = targetLength - str.length; + if (targetLength > padString.length) { + padString += padString.repeat(targetLength / padString.length); + } + return padString.slice(0, targetLength) + String(str); + } +} export const LogLevel = { DEBUG: 0, INFO: 1, @@ -36,25 +50,22 @@ const LEVEL_NAMES = { [LogLevel.WARN]: 'WARN', [LogLevel.ERROR]: 'ERROR', }; - class Logger { constructor() { - this.config = {...DEFAULT_CONFIG}; + this.config = { ...DEFAULT_CONFIG }; this.logs = []; this.enabled = true; this.loadConfig(); } - /** * Konfiguracja loggera - * @param {Object} config - Obiekt konfiguracyjny + * @param {Partial} config - Obiekt konfiguracyjny */ configure(config) { - this.config = {...this.config, ...config}; + this.config = { ...this.config, ...config }; this.saveConfig(); return this; } - /** * Włącz/wyłącz logger globalnie * @param {boolean} enabled - Czy logger ma być włączony @@ -63,42 +74,39 @@ class Logger { this.enabled = enabled; return this; } - /** * Ustaw globalny poziom logowania - * @param {LogLevel} level - Poziom logowania + * @param {LogLevels} level - Poziom logowania */ setGlobalLevel(level) { this.config.globalLevel = level; this.saveConfig(); return this; } - /** * Ustaw poziom logowania dla konkretnego modułu * @param {string} module - Nazwa modułu - * @param {LogLevel} level - Poziom logowania + * @param {LogLevels} level - Poziom logowania */ setModuleLevel(module, level) { this.config.moduleSettings[module] = level; this.saveConfig(); return this; } - /** * Sprawdź, czy dany poziom logowania jest aktywny dla modułu * @param {string} module - Nazwa modułu - * @param {LogLevel} level - Poziom logowania do sprawdzenia + * @param {LogLevels} level - Poziom logowania do sprawdzenia * @returns {boolean} - Czy poziom jest aktywny */ isLevelEnabled(module, level) { - if (!this.enabled) return false; + if (!this.enabled) + return false; if (this.config.moduleSettings[module] !== undefined) { return level >= this.config.moduleSettings[module]; } return level >= this.config.globalLevel; } - /** * Formatuj znacznik czasu * @returns {string} - Sformatowany znacznik czasu @@ -107,21 +115,20 @@ class Logger { const now = new Date(); const format = this.config.timestampFormat; return format - .replace('HH', String(now.getHours()).padStart(2, '0')) - .replace('mm', String(now.getMinutes()).padStart(2, '0')) - .replace('ss', String(now.getSeconds()).padStart(2, '0')) - .replace('SSS', String(now.getMilliseconds()).padStart(3, '0')); + .replace('HH', padStart(String(now.getHours()), 2, '0')) + .replace('mm', padStart(String(now.getMinutes()), 2, '0')) + .replace('ss', padStart(String(now.getSeconds()), 2, '0')) + .replace('SSS', padStart(String(now.getMilliseconds()), 3, '0')); } - /** * Zapisz log * @param {string} module - Nazwa modułu - * @param {LogLevel} level - Poziom logowania - * @param {Array} args - Argumenty do zalogowania + * @param {LogLevels} level - Poziom logowania + * @param {any[]} args - Argumenty do zalogowania */ log(module, level, ...args) { - if (!this.isLevelEnabled(module, level)) return; - + if (!this.isLevelEnabled(module, level)) + return; const timestamp = this.formatTimestamp(); const levelName = LEVEL_NAMES[level]; const logData = { @@ -141,13 +148,12 @@ class Logger { } this.printToConsole(logData); } - /** * Wyświetl log w konsoli - * @param {Object} logData - Dane logu + * @param {LogData} logData - Dane logu */ printToConsole(logData) { - const {timestamp, module, level, levelName, args} = logData; + const { timestamp, module, level, levelName, args } = logData; const prefix = `[${timestamp}] [${module}] [${levelName}]`; if (this.config.useColors && typeof console.log === 'function') { const color = COLORS[level] || '#000000'; @@ -156,36 +162,35 @@ class Logger { } console.log(prefix, ...args); } - /** * Zapisz logi do localStorage */ saveLogs() { if (typeof localStorage !== 'undefined' && this.config.saveToStorage) { try { - const simplifiedLogs = this.logs.map(log => ({ + const simplifiedLogs = this.logs.map((log) => ({ t: log.timestamp, m: log.module, l: log.level, - a: log.args.map(arg => { + a: log.args.map((arg) => { if (typeof arg === 'object') { try { return JSON.stringify(arg); - } catch (e) { + } + catch (e) { return String(arg); } } return arg; }) })); - localStorage.setItem(this.config.storageKey, JSON.stringify(simplifiedLogs)); - } catch (e) { + } + catch (e) { console.error('Failed to save logs to localStorage:', e); } } } - /** * Załaduj logi z localStorage */ @@ -196,12 +201,12 @@ class Logger { if (storedLogs) { this.logs = JSON.parse(storedLogs); } - } catch (e) { + } + catch (e) { console.error('Failed to load logs from localStorage:', e); } } } - /** * Zapisz konfigurację do localStorage */ @@ -209,12 +214,12 @@ class Logger { if (typeof localStorage !== 'undefined') { try { localStorage.setItem('layerforge_logger_config', JSON.stringify(this.config)); - } catch (e) { + } + catch (e) { console.error('Failed to save logger config to localStorage:', e); } } } - /** * Załaduj konfigurację z localStorage */ @@ -223,14 +228,14 @@ class Logger { try { const storedConfig = localStorage.getItem('layerforge_logger_config'); if (storedConfig) { - this.config = {...this.config, ...JSON.parse(storedConfig)}; + this.config = { ...this.config, ...JSON.parse(storedConfig) }; } - } catch (e) { + } + catch (e) { console.error('Failed to load logger config from localStorage:', e); } } } - /** * Wyczyść wszystkie logi */ @@ -241,33 +246,29 @@ class Logger { } return this; } - /** * Eksportuj logi do pliku - * @param {string} format - Format eksportu ('json' lub 'txt') + * @param {'json' | 'txt'} format - Format eksportu */ exportLogs(format = 'json') { if (this.logs.length === 0) { console.warn('No logs to export'); return; } - let content; let mimeType; let extension; - if (format === 'json') { content = JSON.stringify(this.logs, null, 2); mimeType = 'application/json'; extension = 'json'; - } else { - content = this.logs.map(log => - `[${log.timestamp}] [${log.module}] [${log.levelName}] ${log.args.join(' ')}` - ).join('\n'); + } + else { + content = this.logs.map((log) => `[${log.timestamp}] [${log.module}] [${log.levelName}] ${log.args.join(' ')}`).join('\n'); mimeType = 'text/plain'; extension = 'txt'; } - const blob = new Blob([content], {type: mimeType}); + const blob = new Blob([content], { type: mimeType }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; @@ -277,44 +278,39 @@ class Logger { document.body.removeChild(a); URL.revokeObjectURL(url); } - /** * Log na poziomie DEBUG * @param {string} module - Nazwa modułu - * @param {...any} args - Argumenty do zalogowania + * @param {any[]} args - Argumenty do zalogowania */ debug(module, ...args) { this.log(module, LogLevel.DEBUG, ...args); } - /** * Log na poziomie INFO * @param {string} module - Nazwa modułu - * @param {...any} args - Argumenty do zalogowania + * @param {any[]} args - Argumenty do zalogowania */ info(module, ...args) { this.log(module, LogLevel.INFO, ...args); } - /** * Log na poziomie WARN * @param {string} module - Nazwa modułu - * @param {...any} args - Argumenty do zalogowania + * @param {any[]} args - Argumenty do zalogowania */ warn(module, ...args) { this.log(module, LogLevel.WARN, ...args); } - /** * Log na poziomie ERROR * @param {string} module - Nazwa modułu - * @param {...any} args - Argumenty do zalogowania + * @param {any[]} args - Argumenty do zalogowania */ error(module, ...args) { this.log(module, LogLevel.ERROR, ...args); } } - export const logger = new Logger(); export const debug = (module, ...args) => logger.debug(module, ...args); export const info = (module, ...args) => logger.info(module, ...args); @@ -323,5 +319,4 @@ export const error = (module, ...args) => logger.error(module, ...args); if (typeof window !== 'undefined') { window.LayerForgeLogger = logger; } - -export default logger; \ No newline at end of file +export default logger; diff --git a/js/state-saver.worker.js b/js/state-saver.worker.js index a3d20aa..3096432 100644 --- a/js/state-saver.worker.js +++ b/js/state-saver.worker.js @@ -1,19 +1,15 @@ +"use strict"; console.log('[StateWorker] Worker script loaded and running.'); - const DB_NAME = 'CanvasNodeDB'; const STATE_STORE_NAME = 'CanvasState'; const DB_VERSION = 3; - let db; - function log(...args) { console.log('[StateWorker]', ...args); } - function error(...args) { console.error('[StateWorker]', ...args); } - function createDBRequest(store, operation, data, errorMessage) { return new Promise((resolve, reject) => { let request; @@ -25,69 +21,59 @@ function createDBRequest(store, operation, data, errorMessage) { reject(new Error(`Unknown operation: ${operation}`)); return; } - request.onerror = (event) => { error(errorMessage, event.target.error); reject(errorMessage); }; - request.onsuccess = (event) => { resolve(event.target.result); }; }); } - function openDB() { return new Promise((resolve, reject) => { if (db) { resolve(db); return; } - const request = indexedDB.open(DB_NAME, DB_VERSION); - request.onerror = (event) => { error("IndexedDB error:", event.target.error); reject("Error opening IndexedDB."); }; - request.onsuccess = (event) => { db = event.target.result; log("IndexedDB opened successfully in worker."); resolve(db); }; - request.onupgradeneeded = (event) => { log("Upgrading IndexedDB in worker..."); const tempDb = event.target.result; if (!tempDb.objectStoreNames.contains(STATE_STORE_NAME)) { - tempDb.createObjectStore(STATE_STORE_NAME, {keyPath: 'id'}); + tempDb.createObjectStore(STATE_STORE_NAME, { keyPath: 'id' }); } }; }); } - async function setCanvasState(id, state) { const db = await openDB(); const transaction = db.transaction([STATE_STORE_NAME], 'readwrite'); const store = transaction.objectStore(STATE_STORE_NAME); - await createDBRequest(store, 'put', {id, state}, "Error setting canvas state"); + await createDBRequest(store, 'put', { id, state }, "Error setting canvas state"); } - -self.onmessage = async function(e) { +self.onmessage = async function (e) { log('Message received from main thread:', e.data ? 'data received' : 'no data'); const { state, nodeId } = e.data; - if (!state || !nodeId) { error('Invalid data received from main thread'); return; } - try { log(`Saving state for node: ${nodeId}`); await setCanvasState(nodeId, state); log(`State saved successfully for node: ${nodeId}`); - } catch (err) { + } + catch (err) { error(`Failed to save state for node: ${nodeId}`, err); } }; diff --git a/js/templates/clipspace_clipboard_tooltip.html b/js/templates/clipspace_clipboard_tooltip.html index c46fbbc..2401727 100644 --- a/js/templates/clipspace_clipboard_tooltip.html +++ b/js/templates/clipspace_clipboard_tooltip.html @@ -9,5 +9,5 @@ Drag & DropLoad images directly from files
- 💡 Best for: ComfyUI workflow integration and node-to-node image transfer + 💡 Bestt for: ComfyUI workflow integration and node-to-node image transfer
diff --git a/js/types.js b/js/types.js new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/js/types.js @@ -0,0 +1 @@ +export {}; diff --git a/js/utils/ClipboardManager.js b/js/utils/ClipboardManager.js index fc1d755..229db7f 100644 --- a/js/utils/ClipboardManager.js +++ b/js/utils/ClipboardManager.js @@ -1,31 +1,28 @@ -import {createModuleLogger} from "./LoggerUtils.js"; -import {api} from "../../../scripts/api.js"; -import {ComfyApp} from "../../../scripts/app.js"; - +import { createModuleLogger } from "./LoggerUtils.js"; +// @ts-ignore +import { api } from "../../../scripts/api.js"; +// @ts-ignore +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') + * @param {AddMode} addMode - The mode for adding the layer + * @param {ClipboardPreference} 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); @@ -34,26 +31,23 @@ export class ClipboardManager { } log.info("No image found in ComfyUI Clipspace"); } - log.info("Attempting paste from system clipboard"); return await this.trySystemClipboardPaste(addMode); - - } catch (err) { + } + 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 + * @param {AddMode} 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); - + 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) { @@ -67,27 +61,24 @@ export class ClipboardManager { } } return false; - } catch (clipspaceError) { + } + 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 + * @param {AddMode} 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 { @@ -99,23 +90,24 @@ export class ClipboardManager { log.info("Successfully loaded image from system clipboard"); await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode); }; - img.src = event.target.result; + if (event.target?.result) { + img.src = event.target.result; + } }; reader.readAsDataURL(blob); log.info("Found image data in system clipboard"); return true; - } catch (error) { + } + 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); @@ -123,22 +115,22 @@ export class ClipboardManager { return true; } } - } catch (error) { + } + catch (error) { log.debug(`Error reading ${textType}:`, error); } } } } - } catch (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); @@ -146,16 +138,14 @@ export class ClipboardManager { return true; } } - } catch (error) { + } + 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 @@ -165,67 +155,53 @@ export class ClipboardManager { 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) { + } + catch (e) { log.debug("Invalid URL format:", text); return false; } } - const imageExtensions = [ - '.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', + '.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg', '.tiff', '.tif', '.ico', '.avif' ]; - - const hasImageExtension = imageExtensions.some(ext => - text.toLowerCase().endsWith(ext) - ); - + 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 - + 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 { + } + 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 + * @param {AddMode} 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(); @@ -242,46 +218,44 @@ export class ClipboardManager { }; img.src = filePath; }); - } catch (error) { + } + 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) { + } + 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) { + } + 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 + * @param {AddMode} 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: { @@ -291,22 +265,17 @@ export class ClipboardManager { 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 () => { @@ -318,36 +287,31 @@ export class ClipboardManager { log.warn("Failed to load image from backend response"); resolve(false); }; - img.src = data.image_data; }); - return success; - - } catch (error) { + } + 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 + * @param {AddMode} 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]; + const target = event.target; + const file = target.files?.[0]; if (file && file.type.startsWith('image/')) { try { const reader = new FileReader(); @@ -362,38 +326,37 @@ export class ClipboardManager { log.warn("Failed to load selected image"); resolve(false); }; - img.src = e.target.result; + if (e.target?.result) { + img.src = e.target.result; + } }; reader.onerror = () => { log.warn("Failed to read selected file"); resolve(false); }; reader.readAsDataURL(file); - } catch (error) { + } + catch (error) { log.warn("Error processing selected file:", error); resolve(false); } - } else { + } + 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 @@ -404,14 +367,12 @@ export class ClipboardManager { 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 + * @param {AddMode} 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; @@ -440,7 +401,6 @@ export class ClipboardManager { 💡 Tip: You can also drag & drop files directly onto the canvas `; - notification.onmouseenter = () => { notification.style.backgroundColor = '#3d6bb0'; notification.style.borderColor = '#5a8bd8'; @@ -451,7 +411,6 @@ export class ClipboardManager { notification.style.borderColor = '#4a7bc8'; notification.style.transform = 'translateY(0)'; }; - notification.onclick = async () => { document.body.removeChild(notification); try { @@ -459,29 +418,25 @@ export class ClipboardManager { if (success) { log.info("Successfully loaded image via empty clipboard file picker"); } - } catch (error) { + } + 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; @@ -498,9 +453,7 @@ export class ClipboardManager { line-height: 1.4; `; notification.textContent = message; - document.body.appendChild(notification); - setTimeout(() => { if (notification.parentNode) { notification.parentNode.removeChild(notification); diff --git a/js/utils/CommonUtils.js b/js/utils/CommonUtils.js index 7779b91..af632b1 100644 --- a/js/utils/CommonUtils.js +++ b/js/utils/CommonUtils.js @@ -1,8 +1,3 @@ -/** - * CommonUtils - Wspólne funkcje pomocnicze - * Eliminuje duplikację funkcji używanych w różnych modułach - */ - /** * Generuje unikalny identyfikator UUID * @returns {string} UUID w formacie xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx @@ -13,7 +8,6 @@ export function generateUUID() { return v.toString(16); }); } - /** * Funkcja snap do siatki * @param {number} value - Wartość do przyciągnięcia @@ -23,58 +17,48 @@ export function generateUUID() { export function snapToGrid(value, gridSize = 64) { return Math.round(value / gridSize) * gridSize; } - /** * Oblicza dostosowanie snap dla warstwy * @param {Object} layer - Obiekt warstwy * @param {number} gridSize - Rozmiar siatki * @param {number} snapThreshold - Próg przyciągania - * @returns {Object} Obiekt z dx i dy + * @returns {Point} Obiekt z dx i dy */ export function getSnapAdjustment(layer, gridSize = 64, snapThreshold = 10) { if (!layer) { - return {dx: 0, dy: 0}; + return { x: 0, y: 0 }; } - const layerEdges = { left: layer.x, right: layer.x + layer.width, 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} - ]; - + { type: 'x', delta: snapToGrid(layerEdges.left, gridSize) - layerEdges.left }, + { type: 'x', delta: snapToGrid(layerEdges.right, gridSize) - layerEdges.right } + ].map(adj => ({ ...adj, abs: Math.abs(adj.delta) })); const y_adjustments = [ - {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)); - + { type: 'y', delta: snapToGrid(layerEdges.top, gridSize) - layerEdges.top }, + { type: 'y', delta: snapToGrid(layerEdges.bottom, gridSize) - layerEdges.bottom } + ].map(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 + x: bestXSnap ? bestXSnap.delta : 0, + y: bestYSnap ? bestYSnap.delta : 0 }; } - /** * Konwertuje współrzędne świata na lokalne * @param {number} worldX - Współrzędna X w świecie * @param {number} worldY - Współrzędna Y w świecie - * @param {Object} layerProps - Właściwości warstwy - * @returns {Object} Lokalne współrzędne {x, y} + * @param {any} layerProps - Właściwości warstwy + * @returns {Point} Lokalne współrzędne {x, y} */ export function worldToLocal(worldX, worldY, layerProps) { const dx = worldX - layerProps.centerX; @@ -82,46 +66,38 @@ export function worldToLocal(worldX, worldY, layerProps) { const rad = -layerProps.rotation * Math.PI / 180; const cos = Math.cos(rad); const sin = Math.sin(rad); - return { x: dx * cos - dy * sin, y: dx * sin + dy * cos }; } - /** * Konwertuje współrzędne lokalne na świat * @param {number} localX - Lokalna współrzędna X * @param {number} localY - Lokalna współrzędna Y - * @param {Object} layerProps - Właściwości warstwy - * @returns {Object} Współrzędne świata {x, y} + * @param {any} layerProps - Właściwości warstwy + * @returns {Point} Współrzędne świata {x, y} */ export function localToWorld(localX, localY, layerProps) { const rad = layerProps.rotation * Math.PI / 180; const cos = Math.cos(rad); const sin = Math.sin(rad); - return { x: layerProps.centerX + localX * cos - localY * sin, y: layerProps.centerY + localX * sin + localY * cos }; } - /** * Klonuje warstwy (bez klonowania obiektów Image dla oszczędności pamięci) - * @param {Array} layers - Tablica warstw do sklonowania - * @returns {Array} Sklonowane warstwy + * @param {Layer[]} layers - Tablica warstw do sklonowania + * @returns {Layer[]} Sklonowane warstwy */ export function cloneLayers(layers) { - return layers.map(layer => { - const newLayer = {...layer}; - return newLayer; - }); + return layers.map(layer => ({ ...layer })); } - /** * Tworzy sygnaturę stanu warstw (dla porównań) - * @param {Array} layers - Tablica warstw + * @param {Layer[]} layers - Tablica warstw * @returns {string} Sygnatura JSON */ export function getStateSignature(layers) { @@ -137,45 +113,43 @@ export function getStateSignature(layers) { blendMode: layer.blendMode || 'normal', opacity: layer.opacity !== undefined ? Math.round(layer.opacity * 100) / 100 : 1 }; - if (layer.imageId) { sig.imageId = layer.imageId; } - if (layer.image && layer.image.src) { sig.imageSrc = layer.image.src.substring(0, 100); // First 100 chars to avoid huge signatures } - return sig; })); } - /** * Debounce funkcja - opóźnia wykonanie funkcji * @param {Function} func - Funkcja do wykonania * @param {number} wait - Czas oczekiwania w ms * @param {boolean} immediate - Czy wykonać natychmiast - * @returns {Function} Funkcja z debounce + * @returns {(...args: any[]) => void} Funkcja z debounce */ export function debounce(func, wait, immediate) { let timeout; return function executedFunction(...args) { const later = () => { timeout = null; - if (!immediate) func(...args); + if (!immediate) + func.apply(this, args); }; const callNow = immediate && !timeout; - clearTimeout(timeout); - timeout = setTimeout(later, wait); - if (callNow) func(...args); + if (timeout) + clearTimeout(timeout); + timeout = window.setTimeout(later, wait); + if (callNow) + func.apply(this, args); }; } - /** * Throttle funkcja - ogranicza częstotliwość wykonania * @param {Function} func - Funkcja do wykonania * @param {number} limit - Limit czasu w ms - * @returns {Function} Funkcja z throttle + * @returns {(...args: any[]) => void} Funkcja z throttle */ export function throttle(func, limit) { let inThrottle; @@ -187,7 +161,6 @@ export function throttle(func, limit) { } }; } - /** * Ogranicza wartość do zakresu * @param {number} value - Wartość do ograniczenia @@ -198,7 +171,6 @@ export function throttle(func, limit) { export function clamp(value, min, max) { return Math.min(Math.max(value, min), max); } - /** * Interpolacja liniowa między dwoma wartościami * @param {number} start - Wartość początkowa @@ -209,7 +181,6 @@ export function clamp(value, min, max) { export function lerp(start, end, factor) { return start + (end - start) * factor; } - /** * Konwertuje stopnie na radiany * @param {number} degrees - Stopnie @@ -218,7 +189,6 @@ export function lerp(start, end, factor) { export function degreesToRadians(degrees) { return degrees * Math.PI / 180; } - /** * Konwertuje radiany na stopnie * @param {number} radians - Radiany @@ -227,23 +197,23 @@ export function degreesToRadians(degrees) { export function radiansToDegrees(radians) { return radians * 180 / Math.PI; } - /** * Tworzy canvas z kontekstem - eliminuje duplikaty w kodzie * @param {number} width - Szerokość canvas * @param {number} height - Wysokość canvas * @param {string} contextType - Typ kontekstu (domyślnie '2d') - * @param {Object} contextOptions - Opcje kontekstu - * @returns {Object} Obiekt z canvas i ctx + * @param {object} contextOptions - Opcje kontekstu + * @returns {{canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D | null}} Obiekt z canvas i ctx */ export function createCanvas(width, height, contextType = '2d', contextOptions = {}) { const canvas = document.createElement('canvas'); - if (width) canvas.width = width; - if (height) canvas.height = height; + if (width) + canvas.width = width; + if (height) + canvas.height = height; const ctx = canvas.getContext(contextType, contextOptions); - return {canvas, ctx}; + return { canvas, ctx }; } - /** * Normalizuje wartość do zakresu Uint8 (0-255) * @param {number} value - Wartość do znormalizowania (0-1) @@ -252,11 +222,10 @@ export function createCanvas(width, height, contextType = '2d', contextOptions = export function normalizeToUint8(value) { return Math.max(0, Math.min(255, Math.round(value * 255))); } - /** * Generuje unikalną nazwę pliku z identyfikatorem node-a * @param {string} baseName - Podstawowa nazwa pliku - * @param {string|number} nodeId - Identyfikator node-a + * @param {string | number} nodeId - Identyfikator node-a * @returns {string} Unikalna nazwa pliku */ export function generateUniqueFileName(baseName, nodeId) { @@ -271,7 +240,6 @@ export function generateUniqueFileName(baseName, nodeId) { const nameWithoutExt = baseName.replace(`.${extension}`, ''); return `${nameWithoutExt}_node_${nodeId}.${extension}`; } - /** * Sprawdza czy punkt jest w prostokącie * @param {number} pointX - X punktu diff --git a/js/utils/ImageUtils.js b/js/utils/ImageUtils.js index 1f363eb..3dc3bc9 100644 --- a/js/utils/ImageUtils.js +++ b/js/utils/ImageUtils.js @@ -1,8 +1,6 @@ -import {createModuleLogger} from "./LoggerUtils.js"; -import {withErrorHandling, createValidationError} from "../ErrorHandler.js"; - +import { createModuleLogger } from "./LoggerUtils.js"; +import { withErrorHandling, createValidationError } from "../ErrorHandler.js"; const log = createModuleLogger('ImageUtils'); - export function validateImageData(data) { log.debug("Validating data structure:", { hasData: !!data, @@ -13,306 +11,222 @@ export function validateImageData(data) { dataType: data?.data ? data.data.constructor.name : null, fullData: data }); - if (!data) { log.info("Data is null or undefined"); return false; } - if (Array.isArray(data)) { log.debug("Data is array, getting first element"); data = data[0]; } - if (!data || typeof data !== 'object') { log.info("Invalid data type"); return false; } - if (!data.data) { log.info("Missing data property"); return false; } - if (!(data.data instanceof Float32Array)) { try { data.data = new Float32Array(data.data); - } catch (e) { + } + catch (e) { log.error("Failed to convert data to Float32Array:", e); return false; } } - return true; } - export function convertImageData(data) { log.info("Converting image data:", data); - if (Array.isArray(data)) { data = data[0]; } - const shape = data.shape; const height = shape[1]; const width = shape[2]; const channels = shape[3]; const floatData = new Float32Array(data.data); - - log.debug("Processing dimensions:", {height, width, channels}); - + log.debug("Processing dimensions:", { height, width, channels }); const rgbaData = new Uint8ClampedArray(width * height * 4); - for (let h = 0; h < height; h++) { for (let w = 0; w < width; w++) { const pixelIndex = (h * width + w) * 4; const tensorIndex = (h * width + w) * channels; - for (let c = 0; c < channels; c++) { const value = floatData[tensorIndex + c]; rgbaData[pixelIndex + c] = Math.max(0, Math.min(255, Math.round(value * 255))); } - rgbaData[pixelIndex + 3] = 255; } } - return { data: rgbaData, width: width, height: height }; } - export function applyMaskToImageData(imageData, maskData) { log.info("Applying mask to image data"); - const rgbaData = new Uint8ClampedArray(imageData.data); const width = imageData.width; const height = imageData.height; - const maskShape = maskData.shape; const maskFloatData = new Float32Array(maskData.data); - log.debug(`Applying mask of shape: ${maskShape}`); - for (let h = 0; h < height; h++) { for (let w = 0; w < width; w++) { const pixelIndex = (h * width + w) * 4; const maskIndex = h * width + w; - const alpha = maskFloatData[maskIndex]; rgbaData[pixelIndex + 3] = Math.max(0, Math.min(255, Math.round(alpha * 255))); } } - log.info("Mask application completed"); - return { data: rgbaData, width: width, height: height }; } - export const prepareImageForCanvas = withErrorHandling(function (inputImage) { log.info("Preparing image for canvas:", inputImage); - if (Array.isArray(inputImage)) { inputImage = inputImage[0]; } - if (!inputImage || !inputImage.shape || !inputImage.data) { - throw createValidationError("Invalid input image format", {inputImage}); + throw createValidationError("Invalid input image format", { inputImage }); } - const shape = inputImage.shape; const height = shape[1]; const width = shape[2]; const channels = shape[3]; const floatData = new Float32Array(inputImage.data); - - log.debug("Image dimensions:", {height, width, channels}); - + log.debug("Image dimensions:", { height, width, channels }); const rgbaData = new Uint8ClampedArray(width * height * 4); - for (let h = 0; h < height; h++) { for (let w = 0; w < width; w++) { const pixelIndex = (h * width + w) * 4; const tensorIndex = (h * width + w) * channels; - for (let c = 0; c < channels; c++) { const value = floatData[tensorIndex + c]; rgbaData[pixelIndex + c] = Math.max(0, Math.min(255, Math.round(value * 255))); } - rgbaData[pixelIndex + 3] = 255; } } - return { data: rgbaData, width: width, height: height }; }, 'prepareImageForCanvas'); - -/** - * Konwertuje obraz PIL/Canvas na tensor - * @param {HTMLImageElement|HTMLCanvasElement} image - Obraz do konwersji - * @returns {Promise} Tensor z danymi obrazu - */ export const imageToTensor = withErrorHandling(async function (image) { if (!image) { throw createValidationError("Image is required"); } - const canvas = document.createElement('canvas'); 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; + canvas.width = image.width; + canvas.height = image.height; + if (ctx) { + 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], + width: canvas.width, + height: canvas.height + }; } - - return { - data: data, - shape: [1, canvas.height, canvas.width, 3], - width: canvas.width, - height: canvas.height - }; + throw new Error("Canvas context not available"); }, 'imageToTensor'); - -/** - * Konwertuje tensor na obraz HTML - * @param {Object} tensor - Tensor z danymi obrazu - * @returns {Promise} Obraz HTML - */ 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', { 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; + if (ctx) { + 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); + img.onerror = (err) => reject(err); + img.src = canvas.toDataURL(); + }); } - - ctx.putImageData(imageData, 0, 0); - - return new Promise((resolve, reject) => { - const img = new Image(); - img.onload = () => resolve(img); - img.onerror = reject; - img.src = canvas.toDataURL(); - }); + throw new Error("Canvas context not available"); }, 'tensorToImage'); - -/** - * Zmienia rozmiar obrazu z zachowaniem proporcji - * @param {HTMLImageElement} image - Obraz do przeskalowania - * @param {number} maxWidth - Maksymalna szerokość - * @param {number} maxHeight - Maksymalna wysokość - * @returns {Promise} Przeskalowany obraz - */ 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', { willReadFrequently: true }); - - const originalWidth = image.width || image.naturalWidth; - const originalHeight = image.height || image.naturalHeight; + const originalWidth = image.width; + const originalHeight = image.height; 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); - img.onerror = reject; - img.src = canvas.toDataURL(); - }); + if (ctx) { + 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); + img.onerror = (err) => reject(err); + img.src = canvas.toDataURL(); + }); + } + throw new Error("Canvas context not available"); }, 'resizeImage'); - -/** - * Tworzy miniaturę obrazu - * @param {HTMLImageElement} image - Obraz źródłowy - * @param {number} size - Rozmiar miniatury (kwadrat) - * @returns {Promise} Miniatura - */ export const createThumbnail = withErrorHandling(async function (image, size = 128) { return resizeImage(image, size, size); }, 'createThumbnail'); - -/** - * Konwertuje obraz na base64 - * @param {HTMLImageElement|HTMLCanvasElement} image - Obraz do konwersji - * @param {string} format - Format obrazu (png, jpeg, webp) - * @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) { if (!image) { throw createValidationError("Image is required"); } - const canvas = document.createElement('canvas'); 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); + canvas.width = image instanceof HTMLImageElement ? image.naturalWidth : image.width; + canvas.height = image instanceof HTMLImageElement ? image.naturalHeight : image.height; + if (ctx) { + ctx.drawImage(image, 0, 0); + const mimeType = `image/${format}`; + return canvas.toDataURL(mimeType, quality); + } + throw new Error("Canvas context not available"); }, 'imageToBase64'); - -/** - * Konwertuje base64 na obraz - * @param {string} base64 - Base64 string - * @returns {Promise} Obraz - */ export const base64ToImage = withErrorHandling(function (base64) { if (!base64) { throw createValidationError("Base64 string is required"); } - return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); @@ -320,74 +234,49 @@ export const base64ToImage = withErrorHandling(function (base64) { img.src = base64; }); }, 'base64ToImage'); - -/** - * Sprawdza czy obraz jest prawidłowy - * @param {HTMLImageElement} image - Obraz do sprawdzenia - * @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; } - -/** - * Pobiera informacje o obrazie - * @param {HTMLImageElement} image - Obraz - * @returns {Object} Informacje o obrazie - */ export function getImageInfo(image) { if (!isValidImage(image)) { return null; } - + const width = image instanceof HTMLImageElement ? image.naturalWidth : image.width; + const height = image instanceof HTMLImageElement ? image.naturalHeight : image.height; return { - width: image.width || image.naturalWidth, - height: image.height || image.naturalHeight, - aspectRatio: (image.width || image.naturalWidth) / (image.height || image.naturalHeight), - area: (image.width || image.naturalWidth) * (image.height || image.naturalHeight) + width, + height, + aspectRatio: width / height, + area: width * height }; } - -/** - * Tworzy obraz z podanego źródła - eliminuje duplikaty w kodzie - * @param {string} source - Źródło obrazu (URL, data URL, etc.) - * @returns {Promise} Promise z obrazem - */ export function createImageFromSource(source) { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); - img.onerror = reject; + img.onerror = (err) => reject(err); img.src = source; }); } - -/** - * Tworzy pusty obraz o podanych wymiarach - * @param {number} width - Szerokość - * @param {number} height - Wysokość - * @param {string} color - Kolor tła (CSS color) - * @returns {Promise} Pusty obraz - */ export const createEmptyImage = withErrorHandling(function (width, height, color = 'transparent') { const canvas = document.createElement('canvas'); 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); + if (ctx) { + 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); + img.onerror = (err) => reject(err); + img.src = canvas.toDataURL(); + }); } - - return new Promise((resolve, reject) => { - const img = new Image(); - img.onload = () => resolve(img); - img.onerror = reject; - img.src = canvas.toDataURL(); - }); + throw new Error("Canvas context not available"); }, 'createEmptyImage'); diff --git a/js/utils/LoggerUtils.js b/js/utils/LoggerUtils.js index decca1f..58b2424 100644 --- a/js/utils/LoggerUtils.js +++ b/js/utils/LoggerUtils.js @@ -2,19 +2,15 @@ * LoggerUtils - Centralizacja inicjalizacji loggerów * Eliminuje powtarzalny kod inicjalizacji loggera w każdym module */ - -import {logger, LogLevel} from "../logger.js"; +import { logger, LogLevel } from "../logger.js"; import { LOG_LEVEL } from '../config.js'; - /** * Tworzy obiekt loggera dla modułu z predefiniowanymi metodami * @param {string} moduleName - Nazwa modułu - * @param {LogLevel} level - Poziom logowania (domyślnie DEBUG) - * @returns {Object} Obiekt z metodami logowania + * @returns {Logger} Obiekt z metodami logowania */ export function createModuleLogger(moduleName) { logger.setModuleLevel(moduleName, LogLevel[LOG_LEVEL]); - return { debug: (...args) => logger.debug(moduleName, ...args), info: (...args) => logger.info(moduleName, ...args), @@ -22,24 +18,20 @@ export function createModuleLogger(moduleName) { error: (...args) => logger.error(moduleName, ...args) }; } - /** * Tworzy logger z automatycznym wykrywaniem nazwy modułu z URL - * @param {LogLevel} level - Poziom logowania - * @returns {Object} Obiekt z metodami logowania + * @returns {Logger} Obiekt z metodami logowania */ -export function createAutoLogger(level = LogLevel.DEBUG) { +export function createAutoLogger() { const stack = new Error().stack; - const match = stack.match(/\/([^\/]+)\.js/); + const match = stack?.match(/\/([^\/]+)\.js/); const moduleName = match ? match[1] : 'Unknown'; - - return createModuleLogger(moduleName, level); + return createModuleLogger(moduleName); } - /** * Wrapper dla operacji z automatycznym logowaniem błędów * @param {Function} operation - Operacja do wykonania - * @param {Object} log - Obiekt loggera + * @param {Logger} log - Obiekt loggera * @param {string} operationName - Nazwa operacji (dla logów) * @returns {Function} Opakowana funkcja */ @@ -50,34 +42,33 @@ export function withErrorLogging(operation, log, operationName) { const result = await operation.apply(this, args); log.debug(`Completed ${operationName}`); return result; - } catch (error) { + } + catch (error) { log.error(`Error in ${operationName}:`, error); throw error; } }; } - /** * Decorator dla metod klasy z automatycznym logowaniem - * @param {Object} log - Obiekt loggera + * @param {Logger} log - Obiekt loggera * @param {string} methodName - Nazwa metody */ export function logMethod(log, methodName) { return function (target, propertyKey, descriptor) { const originalMethod = descriptor.value; - descriptor.value = async function (...args) { try { log.debug(`${methodName || propertyKey} started`); const result = await originalMethod.apply(this, args); log.debug(`${methodName || propertyKey} completed`); return result; - } catch (error) { + } + catch (error) { log.error(`${methodName || propertyKey} failed:`, error); throw error; } }; - return descriptor; }; } diff --git a/js/utils/ResourceManager.js b/js/utils/ResourceManager.js index b7313cd..fc598d0 100644 --- a/js/utils/ResourceManager.js +++ b/js/utils/ResourceManager.js @@ -1,5 +1,5 @@ -import {$el} from "../../../scripts/ui.js"; - +// @ts-ignore +import { $el } from "../../../scripts/ui.js"; export function addStylesheet(url) { if (url.endsWith(".js")) { url = url.substr(0, url.length - 2) + "css"; @@ -11,15 +11,15 @@ export function addStylesheet(url) { href: url.startsWith("http") ? url : getUrl(url), }); } - export function getUrl(path, baseUrl) { if (baseUrl) { return new URL(path, baseUrl).toString(); - } else { + } + else { + // @ts-ignore return new URL("../" + path, import.meta.url).toString(); } } - export async function loadTemplate(path, baseUrl) { const url = getUrl(path, baseUrl); const response = await fetch(url); diff --git a/js/utils/WebSocketManager.js b/js/utils/WebSocketManager.js index 1d261d2..c805b69 100644 --- a/js/utils/WebSocketManager.js +++ b/js/utils/WebSocketManager.js @@ -1,7 +1,5 @@ -import {createModuleLogger} from "./LoggerUtils.js"; - +import { createModuleLogger } from "./LoggerUtils.js"; const log = createModuleLogger('WebSocketManager'); - class WebSocketManager { constructor(url) { this.url = url; @@ -11,41 +9,33 @@ class WebSocketManager { this.reconnectAttempts = 0; this.maxReconnectAttempts = 10; this.reconnectInterval = 5000; // 5 seconds - this.ackCallbacks = new Map(); // Store callbacks for messages awaiting ACK + this.ackCallbacks = new Map(); this.messageIdCounter = 0; - this.connect(); } - connect() { if (this.socket && this.socket.readyState === WebSocket.OPEN) { log.debug("WebSocket is already open."); return; } - if (this.isConnecting) { log.debug("Connection attempt already in progress."); return; } - this.isConnecting = true; log.info(`Connecting to WebSocket at ${this.url}...`); - try { this.socket = new WebSocket(this.url); - this.socket.onopen = () => { this.isConnecting = false; this.reconnectAttempts = 0; log.info("WebSocket connection established."); this.flushMessageQueue(); }; - this.socket.onmessage = (event) => { 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) { @@ -54,65 +44,59 @@ class WebSocketManager { this.ackCallbacks.delete(data.nodeId); } } - - } catch (error) { + } + catch (error) { log.error("Error parsing incoming WebSocket message:", error); } }; - this.socket.onclose = (event) => { this.isConnecting = false; if (event.wasClean) { log.info(`WebSocket closed cleanly, code=${event.code}, reason=${event.reason}`); - } else { + } + else { log.warn("WebSocket connection died. Attempting to reconnect..."); this.handleReconnect(); } }; - this.socket.onerror = (error) => { this.isConnecting = false; log.error("WebSocket error:", error); - }; - } catch (error) { + } + catch (error) { this.isConnecting = false; log.error("Failed to create WebSocket connection:", error); this.handleReconnect(); } } - handleReconnect() { if (this.reconnectAttempts < this.maxReconnectAttempts) { this.reconnectAttempts++; log.info(`Reconnect attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts}...`); setTimeout(() => this.connect(), this.reconnectInterval); - } else { + } + else { log.error("Max reconnect attempts reached. Giving up."); } } - sendMessage(data, requiresAck = false) { return new Promise((resolve, reject) => { const nodeId = data.nodeId; if (requiresAck && !nodeId) { return reject(new Error("A nodeId is required for messages that need acknowledgment.")); } - const message = JSON.stringify(data); - if (this.socket && this.socket.readyState === WebSocket.OPEN) { this.socket.send(message); log.debug("Sent message:", data); - if (requiresAck) { + if (requiresAck && nodeId) { log.debug(`Message for nodeId ${nodeId} requires ACK. Setting up callback.`); - const timeout = setTimeout(() => { this.ackCallbacks.delete(nodeId); reject(new Error(`ACK timeout for nodeId ${nodeId}`)); log.warn(`ACK timeout for nodeId ${nodeId}.`); }, 10000); // 10-second timeout - this.ackCallbacks.set(nodeId, { resolve: (responseData) => { clearTimeout(timeout); @@ -123,35 +107,35 @@ class WebSocketManager { reject(error); } }); - } else { + } + else { resolve(); // Resolve immediately if no ACK is needed } - } else { + } + else { log.warn("WebSocket not open. Queuing message."); - - this.messageQueue.push(message); if (!this.isConnecting) { this.connect(); } - if (requiresAck) { reject(new Error("Cannot send message with ACK required while disconnected.")); } + else { + resolve(); + } } }); } - flushMessageQueue() { log.debug(`Flushing ${this.messageQueue.length} queued messages.`); - - while (this.messageQueue.length > 0) { const message = this.messageQueue.shift(); - this.socket.send(message); + if (this.socket && message) { + this.socket.send(message); + } } } } - const wsUrl = `ws://${window.location.host}/layerforge/canvas_ws`; export const webSocketManager = new WebSocketManager(wsUrl); diff --git a/js/utils/mask_utils.js b/js/utils/mask_utils.js index ff531bb..4983607 100644 --- a/js/utils/mask_utils.js +++ b/js/utils/mask_utils.js @@ -1,39 +1,36 @@ -import {createModuleLogger} from "./LoggerUtils.js"; - +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') + 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 + return new_editor(app) ? document.getElementById('maskEditor') : document.getElementById('maskCanvas')?.parentElement ?? null; } - export function mask_editor_showing(app) { const editor = get_mask_editor_element(app); - return editor && editor.style.display !== "none"; + return !!editor && editor.style.display !== "none"; } - -export function hide_mask_editor() { - if (mask_editor_showing()) document.getElementById('maskEditor').style.display = 'none' +export function hide_mask_editor(app) { + if (mask_editor_showing(app)) { + const editor = document.getElementById('maskEditor'); + if (editor) { + editor.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); @@ -41,11 +38,11 @@ function get_mask_editor_cancel_button(app) { log.debug("Found cancel button with selector:", selector); return button; } - } catch (e) { + } + 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 || ''; @@ -54,72 +51,78 @@ function get_mask_editor_cancel_button(app) { return button; } } - const editorElement = get_mask_editor_element(app); if (editorElement) { - return editorElement?.parentElement?.lastChild?.childNodes[2]; + const childNodes = editorElement?.parentElement?.lastChild?.childNodes; + if (childNodes && childNodes.length > 2 && childNodes[2] instanceof HTMLElement) { + return 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] + const saveButton = document.getElementById("maskEditor_topBarSaveButton"); + if (saveButton) { + return saveButton; + } + const editorElement = get_mask_editor_element(app); + if (editorElement) { + const childNodes = editorElement?.parentElement?.lastChild?.childNodes; + if (childNodes && childNodes.length > 2 && childNodes[2] instanceof HTMLElement) { + return childNodes[2]; + } + } + return null; } - 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) { + if (cancel_button instanceof HTMLElement && !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) { - + } + else if (attempts < maxAttempts) { setTimeout(findAndAttachListener, 100); - } else { + } + 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') || + if (target && (text.toLowerCase().includes('cancel') || target.id.toLowerCase().includes('cancel') || - target.className.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() + const button = get_mask_editor_save_button(app); + if (button instanceof HTMLElement) { + button.click(); + } } - export function press_maskeditor_cancel(app) { - get_mask_editor_cancel_button(app)?.click() + const button = get_mask_editor_cancel_button(app); + if (button instanceof HTMLElement) { + button.click(); + } } - /** * Uruchamia mask editor z predefiniowaną maską - * @param {Object} canvasInstance - Instancja Canvas - * @param {Image|HTMLCanvasElement} maskImage - Obraz maski do nałożenia + * @param {Canvas} canvasInstance - Instancja Canvas + * @param {HTMLImageElement | 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) { @@ -127,48 +130,42 @@ export function start_mask_editor_with_predefined_mask(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 + * @param {Canvas} canvasInstance - Instancja Canvas */ export function start_mask_editor_auto(canvasInstance) { if (!canvasInstance) { log.error('Canvas instance is required'); return; } - - - canvasInstance.startMaskEditor(); + canvasInstance.startMaskEditor(null, true); } - /** * 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 + * @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.onerror = (err) => reject(err); 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 + * @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.onerror = (err) => reject(err); img.src = canvas.toDataURL(); }); } diff --git a/src/BatchPreviewManager.ts b/src/BatchPreviewManager.ts new file mode 100644 index 0000000..e1ff0d8 --- /dev/null +++ b/src/BatchPreviewManager.ts @@ -0,0 +1,277 @@ +import {createModuleLogger} from "./utils/LoggerUtils.js"; +import type { Canvas } from './Canvas'; +import type { Layer, Point } from './types'; + +const log = createModuleLogger('BatchPreviewManager'); + +interface GenerationArea { + x: number; + y: number; + width: number; + height: number; +} + +export class BatchPreviewManager { + public active: boolean; + private canvas: Canvas; + private counterElement: HTMLSpanElement | null; + private currentIndex: number; + private element: HTMLDivElement | null; + public generationArea: GenerationArea | null; + private isDragging: boolean; + private layers: Layer[]; + private maskWasVisible: boolean; + private uiInitialized: boolean; + private worldX: number; + private worldY: number; + + constructor(canvas: Canvas, initialPosition: Point = { x: 0, y: 0 }, generationArea: GenerationArea | null = null) { + this.canvas = canvas; + this.active = false; + this.layers = []; + this.currentIndex = 0; + this.element = null; + this.counterElement = null; + this.uiInitialized = false; + this.maskWasVisible = false; + + this.worldX = initialPosition.x; + this.worldY = initialPosition.y; + this.isDragging = false; + this.generationArea = generationArea; + } + + updateScreenPosition(viewport: { x: number, y: number, zoom: number }): void { + if (!this.active || !this.element) return; + + const screenX = (this.worldX - viewport.x) * viewport.zoom; + const screenY = (this.worldY - viewport.y) * viewport.zoom; + + const scale = 1; + + this.element.style.transform = `translate(${screenX}px, ${screenY}px) scale(${scale})`; + } + + private _createUI(): void { + if (this.uiInitialized) return; + + this.element = document.createElement('div'); + this.element.id = 'layerforge-batch-preview'; + this.element.style.cssText = ` + position: absolute; + top: 0; + left: 0; + background-color: #333; + color: white; + padding: 8px 15px; + border-radius: 10px; + box-shadow: 0 4px 15px rgba(0,0,0,0.5); + display: none; + align-items: center; + gap: 15px; + font-family: sans-serif; + z-index: 1001; + border: 1px solid #555; + cursor: move; + user-select: none; + `; + + this.element.addEventListener('mousedown', (e: MouseEvent) => { + if ((e.target as HTMLElement).tagName === 'BUTTON') return; + + e.preventDefault(); + e.stopPropagation(); + + this.isDragging = true; + + const handleMouseMove = (moveEvent: MouseEvent) => { + if (this.isDragging) { + const deltaX = moveEvent.movementX / this.canvas.viewport.zoom; + const deltaY = moveEvent.movementY / this.canvas.viewport.zoom; + + this.worldX += deltaX; + this.worldY += deltaY; + + // The render loop will handle updating the screen position, but we need to trigger it. + this.canvas.render(); + } + }; + + const handleMouseUp = () => { + this.isDragging = false; + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }); + + const prevButton = this._createButton('◀', 'Previous'); // Left arrow + const nextButton = this._createButton('▶', 'Next'); // Right arrow + const confirmButton = this._createButton('✔', 'Confirm'); // Checkmark + const cancelButton = this._createButton('✖', 'Cancel All'); + const closeButton = this._createButton('➲', 'Close'); + + this.counterElement = document.createElement('span'); + this.counterElement.style.minWidth = '40px'; + this.counterElement.style.textAlign = 'center'; + this.counterElement.style.fontWeight = 'bold'; + + prevButton.onclick = () => this.navigate(-1); + nextButton.onclick = () => this.navigate(1); + confirmButton.onclick = () => this.confirm(); + cancelButton.onclick = () => this.cancelAndRemoveAll(); + closeButton.onclick = () => this.hide(); + + this.element.append(prevButton, this.counterElement, nextButton, confirmButton, cancelButton, closeButton); + if (this.canvas.canvas.parentElement) { + this.canvas.canvas.parentElement.appendChild(this.element); + } else { + log.error("Could not find parent node to attach batch preview UI."); + } + this.uiInitialized = true; + } + + private _createButton(innerHTML: string, title: string): HTMLButtonElement { + const button = document.createElement('button'); + button.innerHTML = innerHTML; + button.title = title; + button.style.cssText = ` + background: #555; + color: white; + border: 1px solid #777; + border-radius: 5px; + cursor: pointer; + font-size: 16px; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + `; + button.onmouseover = () => button.style.background = '#666'; + button.onmouseout = () => button.style.background = '#555'; + return button; + } + + show(layers: Layer[]): void { + if (!layers || layers.length <= 1) { + return; + } + + this._createUI(); + + // Auto-hide mask logic + this.maskWasVisible = this.canvas.maskTool.isOverlayVisible; + if (this.maskWasVisible) { + this.canvas.maskTool.toggleOverlayVisibility(); + const toggleBtn = document.getElementById(`toggle-mask-btn-${this.canvas.node.id}`); + if (toggleBtn) { + toggleBtn.classList.remove('primary'); + toggleBtn.textContent = "Hide Mask"; + } + this.canvas.render(); + } + + log.info(`Showing batch preview for ${layers.length} layers.`); + this.layers = layers; + this.currentIndex = 0; + + if (this.element) { + this.element.style.display = 'flex'; + } + this.active = true; + + if (this.element) { + const menuWidthInWorld = this.element.offsetWidth / this.canvas.viewport.zoom; + const paddingInWorld = 20 / this.canvas.viewport.zoom; + + this.worldX -= menuWidthInWorld / 2; + this.worldY += paddingInWorld; + } + + this._update(); + } + + hide(): void { + log.info('Hiding batch preview.'); + if (this.element) { + this.element.remove(); + } + this.active = false; + + const index = this.canvas.batchPreviewManagers.indexOf(this); + if (index > -1) { + this.canvas.batchPreviewManagers.splice(index, 1); + } + + this.canvas.render(); + + if (this.maskWasVisible && !this.canvas.maskTool.isOverlayVisible) { + this.canvas.maskTool.toggleOverlayVisibility(); + const toggleBtn = document.getElementById(`toggle-mask-btn-${String(this.canvas.node.id)}`); + if (toggleBtn) { + toggleBtn.classList.add('primary'); + toggleBtn.textContent = "Show Mask"; + } + } + this.maskWasVisible = false; + + this.canvas.layers.forEach((l: Layer) => (l as any).visible = true); + this.canvas.render(); + } + + navigate(direction: number): void { + this.currentIndex += direction; + if (this.currentIndex < 0) { + this.currentIndex = this.layers.length - 1; + } else if (this.currentIndex >= this.layers.length) { + this.currentIndex = 0; + } + this._update(); + } + + confirm(): void { + const layerToKeep = this.layers[this.currentIndex]; + log.info(`Confirming selection: Keeping layer ${layerToKeep.id}.`); + + const layersToDelete = this.layers.filter((l: Layer) => l.id !== layerToKeep.id); + const layerIdsToDelete = layersToDelete.map((l: Layer) => l.id); + + this.canvas.removeLayersByIds(layerIdsToDelete); + log.info(`Deleted ${layersToDelete.length} other layers.`); + + this.hide(); + } + + cancelAndRemoveAll(): void { + log.info('Cancel clicked. Removing all new layers.'); + + const layerIdsToDelete = this.layers.map((l: Layer) => l.id); + this.canvas.removeLayersByIds(layerIdsToDelete); + log.info(`Deleted all ${layerIdsToDelete.length} new layers.`); + + this.hide(); + } + + private _update(): void { + if (this.counterElement) { + this.counterElement.textContent = `${this.currentIndex + 1} / ${this.layers.length}`; + } + this._focusOnLayer(this.layers[this.currentIndex]); + } + + private _focusOnLayer(layer: Layer): void { + if (!layer) return; + log.debug(`Focusing on layer ${layer.id}`); + + // Move the selected layer to the top of the layer stack + this.canvas.canvasLayers.moveLayers([layer], { toIndex: 0 }); + + this.canvas.updateSelection([layer]); + + // Render is called by moveLayers, but we call it again to be safe + this.canvas.render(); + } +} diff --git a/src/Canvas.ts b/src/Canvas.ts new file mode 100644 index 0000000..2e9a13a --- /dev/null +++ b/src/Canvas.ts @@ -0,0 +1,610 @@ +// @ts-ignore +import {api} from "../../scripts/api.js"; +// @ts-ignore +import {app} from "../../scripts/app.js"; +// @ts-ignore +import {ComfyApp} from "../../scripts/app.js"; + +import {removeImage} from "./db.js"; +import {MaskTool} from "./MaskTool.js"; +import {CanvasState} from "./CanvasState.js"; +import {CanvasInteractions} from "./CanvasInteractions.js"; +import {CanvasLayers} from "./CanvasLayers.js"; +import {CanvasLayersPanel} from "./CanvasLayersPanel.js"; +import {CanvasRenderer} from "./CanvasRenderer.js"; +import {CanvasIO} from "./CanvasIO.js"; +import {ImageReferenceManager} from "./ImageReferenceManager.js"; +import {BatchPreviewManager} from "./BatchPreviewManager.js"; +import {createModuleLogger} from "./utils/LoggerUtils.js"; +import { debounce } from "./utils/CommonUtils.js"; +import {CanvasMask} from "./CanvasMask.js"; +import {CanvasSelection} from "./CanvasSelection.js"; +import type { ComfyNode, Layer, Viewport, Point, AddMode } from './types'; + +const useChainCallback = (original: any, next: any) => { + if (original === undefined || original === null) { + return next; + } + return function(this: any, ...args: any[]) { + const originalReturn = original.apply(this, args); + const nextReturn = next.apply(this, args); + return nextReturn === undefined ? originalReturn : nextReturn; + }; +}; + +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 { + batchPreviewManagers: BatchPreviewManager[]; + canvas: HTMLCanvasElement; + canvasIO: CanvasIO; + canvasInteractions: CanvasInteractions; + canvasLayers: CanvasLayers; + canvasLayersPanel: CanvasLayersPanel; + canvasMask: CanvasMask; + canvasRenderer: CanvasRenderer; + canvasSelection: CanvasSelection; + canvasState: CanvasState; + ctx: CanvasRenderingContext2D; + dataInitialized: boolean; + height: number; + imageCache: Map; + imageReferenceManager: ImageReferenceManager; + interaction: any; + isMouseOver: boolean; + lastMousePosition: Point; + layers: Layer[]; + maskTool: MaskTool; + node: ComfyNode; + offscreenCanvas: HTMLCanvasElement; + offscreenCtx: CanvasRenderingContext2D | null; + onHistoryChange: ((historyInfo: { canUndo: boolean; canRedo: boolean; }) => void) | undefined; + onStateChange: (() => void) | undefined; + pendingBatchContext: any; + pendingDataCheck: number | null; + previewVisible: boolean; + requestSaveState: () => void; + viewport: Viewport; + widget: any; + width: number; + + constructor(node: ComfyNode, widget: any, callbacks: { onStateChange?: () => void, onHistoryChange?: (historyInfo: { canUndo: boolean; canRedo: boolean; }) => void } = {}) { + this.node = node; + this.widget = widget; + this.canvas = document.createElement('canvas'); + const ctx = this.canvas.getContext('2d', {willReadFrequently: true}); + if (!ctx) throw new Error("Could not create canvas context"); + this.ctx = ctx; + this.width = 512; + this.height = 512; + this.layers = []; + this.onStateChange = callbacks.onStateChange; + this.onHistoryChange = callbacks.onHistoryChange; + this.lastMousePosition = {x: 0, y: 0}; + + this.viewport = { + x: -(this.width / 4), + y: -(this.height / 4), + zoom: 0.8, + }; + + this.offscreenCanvas = document.createElement('canvas'); + this.offscreenCtx = this.offscreenCanvas.getContext('2d', { + alpha: false + }); + + this.dataInitialized = false; + this.pendingDataCheck = null; + this.imageCache = new Map(); + + this.requestSaveState = () => {}; + this.maskTool = new MaskTool(this, {onStateChange: this.onStateChange}); + this.canvasMask = new CanvasMask(this); + this.canvasState = new CanvasState(this); + this.canvasSelection = new CanvasSelection(this); + this.canvasInteractions = new CanvasInteractions(this); + this.canvasLayers = new CanvasLayers(this); + this.canvasLayersPanel = new CanvasLayersPanel(this); + this.canvasRenderer = new CanvasRenderer(this); + this.canvasIO = new CanvasIO(this); + this.imageReferenceManager = new ImageReferenceManager(this); + this.batchPreviewManagers = []; + this.pendingBatchContext = null; + this.interaction = this.canvasInteractions.interaction; + this.previewVisible = false; + this.isMouseOver = false; + + this._initializeModules(); + this._setupCanvas(); + + 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: any, node: any, interval = 100, timeout = 20000) { + const startTime = Date.now(); + + return new Promise((resolve, reject) => { + const check = () => { + const widget = node.widgets.find((w: any) => 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: boolean) { + this.previewVisible = visible; + log.info("Canvas preview visibility set to:", visible); + + const imagePreviewWidget = await this.waitForWidget("$$canvas-image-preview", this.node) as any; + 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() { + log.debug('Initializing Canvas modules...'); + + // Stwórz opóźnioną wersję funkcji zapisu stanu + this.requestSaveState = debounce(() => this.saveState(), 500); + + this._addAutoRefreshToggle(); + + 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) => ({ + ...layer, + opacity: 1 + })); + } + + + /** + * Ładuje stan canvas z bazy danych + */ + async loadInitialState() { + log.info("Loading initial state for node:", this.node.id); + const loaded = await this.canvasState.loadStateFromDB(); + if (!loaded) { + log.info("No saved state found, initializing from node data."); + await this.canvasIO.initNodeData(); + } + this.saveState(); + this.render(); + + // Dodaj to wywołanie, aby panel renderował się po załadowaniu stanu + if (this.canvasLayersPanel) { + this.canvasLayersPanel.onLayersChanged(); + } + } + + /** + * 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(); + + // Powiadom panel warstw o zmianie stanu warstw + if (this.canvasLayersPanel) { + this.canvasLayersPanel.onLayersChanged(); + this.canvasLayersPanel.onSelectionChanged(); + } + + 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(); + + // Powiadom panel warstw o zmianie stanu warstw + if (this.canvasLayersPanel) { + this.canvasLayersPanel.onLayersChanged(); + this.canvasLayersPanel.onSelectionChanged(); + } + + log.debug('Redo completed, layers count:', this.layers.length); + } + + /** + * Renderuje canvas + */ + render() { + this.canvasRenderer.render(); + } + + /** + * 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: HTMLImageElement, layerProps = {}, addMode: AddMode = 'default') { + const result = await this.canvasLayers.addLayerWithImage(image, layerProps, addMode); + + // Powiadom panel warstw o dodaniu nowej warstwy + if (this.canvasLayersPanel) { + this.canvasLayersPanel.onLayersChanged(); + } + + return result; + } + + /** + * Usuwa wybrane warstwy + */ + removeLayersByIds(layerIds: string[]) { + if (!layerIds || layerIds.length === 0) return; + + const initialCount = this.layers.length; + this.saveState(); + this.layers = this.layers.filter((l: Layer) => !layerIds.includes(l.id)); + + // If the current selection was part of the removal, clear it + const newSelection = this.canvasSelection.selectedLayers.filter((l: Layer) => !layerIds.includes(l.id)); + this.canvasSelection.updateSelection(newSelection); + + this.render(); + this.saveState(); + + if (this.canvasLayersPanel) { + this.canvasLayersPanel.onLayersChanged(); + } + log.info(`Removed ${initialCount - this.layers.length} layers by ID.`); + } + + removeSelectedLayers() { + return this.canvasSelection.removeSelectedLayers(); + } + + /** + * Duplikuje zaznaczone warstwy (w pamięci, bez zapisu stanu) + */ + duplicateSelectedLayers() { + return this.canvasSelection.duplicateSelectedLayers(); + } + + /** + * Aktualizuje zaznaczenie warstw i powiadamia wszystkie komponenty. + * To jest "jedyne źródło prawdy" o zmianie zaznaczenia. + * @param {Array} newSelection - Nowa lista zaznaczonych warstw + */ + updateSelection(newSelection: any) { + return this.canvasSelection.updateSelection(newSelection); + } + + /** + * Logika aktualizacji zaznaczenia, wywoływana przez panel warstw. + */ + updateSelectionLogic(layer: Layer, isCtrlPressed: boolean, isShiftPressed: boolean, index: number) { + return this.canvasSelection.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index); + } + + /** + * Zmienia rozmiar obszaru wyjściowego + * @param {number} width - Nowa szerokość + * @param {number} height - Nowa wysokość + * @param {boolean} saveHistory - Czy zapisać w historii + */ + updateOutputAreaSize(width: number, height: number, 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(); + } + + _addAutoRefreshToggle() { + let autoRefreshEnabled = false; + let lastExecutionStartTime = 0; + + const handleExecutionStart = () => { + if (autoRefreshEnabled) { + lastExecutionStartTime = Date.now(); + // Store a snapshot of the context for the upcoming batch + this.pendingBatchContext = { + // For the menu position + spawnPosition: { + x: this.width / 2, + y: this.height + }, + // For the image placement + outputArea: { + x: 0, + y: 0, + width: this.width, + height: this.height + } + }; + log.debug(`Execution started, pending batch context captured:`, this.pendingBatchContext); + this.render(); // Trigger render to show the pending outline immediately + } + }; + + const handleExecutionSuccess = async () => { + if (autoRefreshEnabled) { + log.info('Auto-refresh triggered, importing latest images.'); + + if (!this.pendingBatchContext) { + log.warn("execution_start did not fire, cannot process batch. Awaiting next execution."); + return; + } + + // Use the captured output area for image import + const newLayers = await this.canvasIO.importLatestImages( + lastExecutionStartTime, + this.pendingBatchContext.outputArea + ); + + if (newLayers && newLayers.length > 1) { + const newManager = new BatchPreviewManager( + this, + this.pendingBatchContext.spawnPosition, + this.pendingBatchContext.outputArea + ); + this.batchPreviewManagers.push(newManager); + newManager.show(newLayers); + } + + // Consume the context + this.pendingBatchContext = null; + // Final render to clear the outline if it was the last one + this.render(); + } + }; + + this.node.addWidget( + 'toggle', + 'Auto-refresh after generation', + false, + (value: boolean) => { + autoRefreshEnabled = value; + log.debug('Auto-refresh toggled:', value); + }, { + serialize: false + } + ); + + api.addEventListener('execution_start', handleExecutionStart); + api.addEventListener('execution_success', handleExecutionSuccess); + + (this.node as any).onRemoved = useChainCallback((this.node as any).onRemoved, () => { + log.info('Node removed, cleaning up auto-refresh listeners.'); + api.removeEventListener('execution_start', handleExecutionStart); + api.removeEventListener('execution_success', handleExecutionSuccess); + }); + } + + + /** + * 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: HTMLImageElement | HTMLCanvasElement | null = null, sendCleanImage: boolean = true) { + return this.canvasMask.startMaskEditor(predefinedMask as any, sendCleanImage); + } + + + /** + * Inicjalizuje podstawowe właściwości canvas + */ + initCanvas() { + this.canvas.width = this.width; + this.canvas.height = this.height; + this.canvas.style.border = '1px solid black'; + this.canvas.style.maxWidth = '100%'; + this.canvas.style.backgroundColor = '#606060'; + this.canvas.style.width = '100%'; + this.canvas.style.height = '100%'; + this.canvas.tabIndex = 0; + this.canvas.style.outline = 'none'; + } + + /** + * Pobiera współrzędne myszy w układzie świata + * @param {MouseEvent} e - Zdarzenie myszy + */ + getMouseWorldCoordinates(e: any) { + const rect = this.canvas.getBoundingClientRect(); + + const mouseX_DOM = e.clientX - rect.left; + const mouseY_DOM = e.clientY - rect.top; + + if (!this.offscreenCanvas) throw new Error("Offscreen canvas not initialized"); + const scaleX = this.offscreenCanvas.width / rect.width; + const scaleY = this.offscreenCanvas.height / rect.height; + + const mouseX_Buffer = mouseX_DOM * scaleX; + const mouseY_Buffer = mouseY_DOM * scaleY; + + const worldX = (mouseX_Buffer / this.viewport.zoom) + this.viewport.x; + const worldY = (mouseY_Buffer / this.viewport.zoom) + this.viewport.y; + + return {x: worldX, y: worldY}; + } + + /** + * Pobiera współrzędne myszy w układzie widoku + * @param {MouseEvent} e - Zdarzenie myszy + */ + getMouseViewCoordinates(e: any) { + const rect = this.canvas.getBoundingClientRect(); + const mouseX_DOM = e.clientX - rect.left; + const mouseY_DOM = e.clientY - rect.top; + + const scaleX = this.canvas.width / rect.width; + const scaleY = this.canvas.height / rect.height; + + const mouseX_Canvas = mouseX_DOM * scaleX; + const mouseY_Canvas = mouseY_DOM * scaleY; + + return {x: mouseX_Canvas, y: mouseY_Canvas}; + } + + /** + * Aktualizuje zaznaczenie po operacji historii + */ + updateSelectionAfterHistory() { + return this.canvasSelection.updateSelectionAfterHistory(); + } + + /** + * 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) { + this.imageReferenceManager.incrementOperationCount(); + } + } + + /** + * Czyści zasoby canvas + */ + destroy() { + if (this.imageReferenceManager) { + this.imageReferenceManager.destroy(); + } + log.info("Canvas destroyed"); + } + + /** + * Powiadamia o zmianie stanu + * @private + */ + _notifyStateChange() { + if (this.onStateChange) { + this.onStateChange(); + } + } +} diff --git a/src/CanvasIO.ts b/src/CanvasIO.ts new file mode 100644 index 0000000..db48acf --- /dev/null +++ b/src/CanvasIO.ts @@ -0,0 +1,827 @@ +import { createCanvas } from "./utils/CommonUtils.js"; +import { createModuleLogger } from "./utils/LoggerUtils.js"; +import { webSocketManager } from "./utils/WebSocketManager.js"; +import type { Canvas } from './Canvas'; +import type { Layer } from './types'; + +const log = createModuleLogger('CanvasIO'); + +export class CanvasIO { + private _saveInProgress: Promise | null; + private canvas: Canvas; + + constructor(canvas: Canvas) { + this.canvas = canvas; + this._saveInProgress = null; + } + + async saveToServer(fileName: string, outputMode = 'disk'): Promise { + if (outputMode === 'disk') { + if (!(window as any).canvasSaveStates) { + (window as any).canvasSaveStates = new Map(); + } + + const nodeId = this.canvas.node.id; + const saveKey = `${nodeId}_${fileName}`; + if (this._saveInProgress || (window as any).canvasSaveStates.get(saveKey)) { + log.warn(`Save already in progress for node ${nodeId}, waiting...`); + return this._saveInProgress || (window as any).canvasSaveStates.get(saveKey); + } + + log.info(`Starting saveToServer (disk) with fileName: ${fileName} for node: ${nodeId}`); + this._saveInProgress = this._performSave(fileName, outputMode); + (window as any).canvasSaveStates.set(saveKey, this._saveInProgress); + + try { + return await this._saveInProgress; + } finally { + this._saveInProgress = null; + (window as any).canvasSaveStates.delete(saveKey); + log.debug(`Save completed for node ${nodeId}, lock released`); + } + } else { + log.info(`Starting saveToServer (RAM) for node: ${this.canvas.node.id}`); + return this._performSave(fileName, outputMode); + } + } + + async _performSave(fileName: string, outputMode: string): Promise { + if (this.canvas.layers.length === 0) { + log.warn(`Node ${this.canvas.node.id} has no layers, creating empty canvas`); + return Promise.resolve(true); + } + await this.canvas.canvasState.saveStateToDB(); + const nodeId = this.canvas.node.id; + const delay = (nodeId % 10) * 50; + if (delay > 0) { + await new Promise(resolve => setTimeout(resolve, delay)); + } + + return new Promise((resolve) => { + const {canvas: tempCanvas, ctx: tempCtx} = createCanvas(this.canvas.width, this.canvas.height); + const {canvas: maskCanvas, ctx: maskCtx} = createCanvas(this.canvas.width, this.canvas.height); + + const visibilityCanvas = document.createElement('canvas'); + visibilityCanvas.width = this.canvas.width; + visibilityCanvas.height = this.canvas.height; + const visibilityCtx = visibilityCanvas.getContext('2d', { alpha: true }); + if (!visibilityCtx) throw new Error("Could not create visibility context"); + if (!maskCtx) throw new Error("Could not create mask context"); + if (!tempCtx) throw new Error("Could not create temp context"); + maskCtx.fillStyle = '#ffffff'; + maskCtx.fillRect(0, 0, this.canvas.width, this.canvas.height); + + log.debug(`Canvas contexts created, starting layer rendering`); + const sortedLayers = this.canvas.layers.sort((a: Layer, b: Layer) => a.zIndex - b.zIndex); + log.debug(`Processing ${sortedLayers.length} layers in order`); + sortedLayers.forEach((layer: Layer, index: number) => { + log.debug(`Processing layer ${index}: zIndex=${layer.zIndex}, size=${layer.width}x${layer.height}, pos=(${layer.x},${layer.y})`); + log.debug(`Layer ${index}: blendMode=${layer.blendMode || 'normal'}, opacity=${layer.opacity !== undefined ? layer.opacity : 1}`); + + tempCtx.save(); + tempCtx.globalCompositeOperation = layer.blendMode as any || 'normal'; + tempCtx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1; + tempCtx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2); + tempCtx.rotate(layer.rotation * Math.PI / 180); + tempCtx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height); + tempCtx.restore(); + + log.debug(`Layer ${index} rendered successfully`); + visibilityCtx.save(); + visibilityCtx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2); + visibilityCtx.rotate(layer.rotation * Math.PI / 180); + visibilityCtx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height); + visibilityCtx.restore(); + }); + const visibilityData = visibilityCtx.getImageData(0, 0, this.canvas.width, this.canvas.height); + const maskData = maskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height); + for (let i = 0; i < visibilityData.data.length; i += 4) { + const alpha = visibilityData.data[i + 3]; + const maskValue = 255 - alpha; + maskData.data[i] = maskData.data[i + 1] = maskData.data[i + 2] = maskValue; + maskData.data[i + 3] = 255; + } + + maskCtx.putImageData(maskData, 0, 0); + 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 }); + if (!tempMaskCtx) throw new Error("Could not create temp mask context"); + + tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height); + + + const maskX = this.canvas.maskTool.x; + const maskY = this.canvas.maskTool.y; + + log.debug(`Extracting mask from world position (${maskX}, ${maskY}) for output area (0,0) to (${this.canvas.width}, ${this.canvas.height})`); + + 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) { + log.debug(`Copying mask region: source(${sourceX}, ${sourceY}) to dest(${destX}, ${destY}) size(${copyWidth}, ${copyHeight})`); + + 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); + + maskCtx.globalCompositeOperation = 'source-over'; + maskCtx.drawImage(tempMaskCanvas, 0, 0); + } + if (outputMode === 'ram') { + const imageData = tempCanvas.toDataURL('image/png'); + const maskData = maskCanvas.toDataURL('image/png'); + log.info("Returning image and mask data as base64 for RAM mode."); + resolve({image: imageData, mask: maskData}); + return; + } + + const fileNameWithoutMask = fileName.replace('.png', '_without_mask.png'); + log.info(`Saving image without mask as: ${fileNameWithoutMask}`); + + tempCanvas.toBlob(async (blobWithoutMask) => { + if (!blobWithoutMask) return; + log.debug(`Created blob for image without mask, size: ${blobWithoutMask.size} bytes`); + const formDataWithoutMask = new FormData(); + formDataWithoutMask.append("image", blobWithoutMask, fileNameWithoutMask); + formDataWithoutMask.append("overwrite", "true"); + + try { + const response = await fetch("/upload/image", { + method: "POST", + body: formDataWithoutMask, + }); + log.debug(`Image without mask upload response: ${response.status}`); + } catch (error) { + log.error(`Error uploading image without mask:`, error); + } + }, "image/png"); + log.info(`Saving main image as: ${fileName}`); + tempCanvas.toBlob(async (blob) => { + if (!blob) return; + log.debug(`Created blob for main image, size: ${blob.size} bytes`); + const formData = new FormData(); + formData.append("image", blob, fileName); + formData.append("overwrite", "true"); + + try { + const resp = await fetch("/upload/image", { + method: "POST", + body: formData, + }); + log.debug(`Main image upload response: ${resp.status}`); + + if (resp.status === 200) { + const maskFileName = fileName.replace('.png', '_mask.png'); + log.info(`Saving mask as: ${maskFileName}`); + + maskCanvas.toBlob(async (maskBlob) => { + if (!maskBlob) return; + log.debug(`Created blob for mask, size: ${maskBlob.size} bytes`); + const maskFormData = new FormData(); + maskFormData.append("image", maskBlob, maskFileName); + maskFormData.append("overwrite", "true"); + + try { + const maskResp = await fetch("/upload/image", { + method: "POST", + body: maskFormData, + }); + log.debug(`Mask upload response: ${maskResp.status}`); + + if (maskResp.status === 200) { + const data = await resp.json(); + if (this.canvas.widget) { + this.canvas.widget.value = fileName; + } + log.info(`All files saved successfully, widget value set to: ${fileName}`); + resolve(true); + } else { + log.error(`Error saving mask: ${maskResp.status}`); + resolve(false); + } + } catch (error) { + log.error(`Error saving mask:`, error); + resolve(false); + } + }, "image/png"); + } else { + log.error(`Main image upload failed: ${resp.status} - ${resp.statusText}`); + resolve(false); + } + } catch (error) { + log.error(`Error uploading main image:`, error); + resolve(false); + } + }, "image/png"); + }); + } + + async _renderOutputData(): Promise<{ image: string, mask: string }> { + return new Promise((resolve) => { + const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(this.canvas.width, this.canvas.height); + const { canvas: maskCanvas, ctx: maskCtx } = createCanvas(this.canvas.width, this.canvas.height); + + const visibilityCanvas = document.createElement('canvas'); + visibilityCanvas.width = this.canvas.width; + visibilityCanvas.height = this.canvas.height; + const visibilityCtx = visibilityCanvas.getContext('2d', { alpha: true }); + if (!visibilityCtx) throw new Error("Could not create visibility context"); + if (!maskCtx) throw new Error("Could not create mask context"); + if (!tempCtx) throw new Error("Could not create temp context"); + maskCtx.fillStyle = '#ffffff'; // Start with a white mask (nothing masked) + maskCtx.fillRect(0, 0, this.canvas.width, this.canvas.height); + + const sortedLayers = this.canvas.layers.sort((a: Layer, b: Layer) => a.zIndex - b.zIndex); + sortedLayers.forEach((layer: Layer) => { + + tempCtx.save(); + tempCtx.globalCompositeOperation = layer.blendMode as any || 'normal'; + tempCtx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1; + tempCtx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2); + tempCtx.rotate(layer.rotation * Math.PI / 180); + tempCtx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height); + tempCtx.restore(); + + visibilityCtx.save(); + visibilityCtx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2); + visibilityCtx.rotate(layer.rotation * Math.PI / 180); + visibilityCtx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height); + visibilityCtx.restore(); + }); + + const visibilityData = visibilityCtx.getImageData(0, 0, this.canvas.width, this.canvas.height); + const maskData = maskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height); + for (let i = 0; i < visibilityData.data.length; i += 4) { + const alpha = visibilityData.data[i + 3]; + const maskValue = 255 - alpha; // Invert alpha to create the mask + maskData.data[i] = maskData.data[i + 1] = maskData.data[i + 2] = maskValue; + maskData.data[i + 3] = 255; // Solid mask + } + maskCtx.putImageData(maskData, 0, 0); + + 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 }); + if (!tempMaskCtx) throw new Error("Could not create temp mask context"); + + tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height); + + const maskX = this.canvas.maskTool.x; + const maskY = this.canvas.maskTool.y; + + log.debug(`[renderOutputData] Extracting mask from world position (${maskX}, ${maskY})`); + + 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] = alpha; + tempMaskData.data[i + 3] = 255; // Solid alpha + } + tempMaskCtx.putImageData(tempMaskData, 0, 0); + + + maskCtx.globalCompositeOperation = 'screen'; + maskCtx.drawImage(tempMaskCanvas, 0, 0); + } + + const imageDataUrl = tempCanvas.toDataURL('image/png'); + const maskDataUrl = maskCanvas.toDataURL('image/png'); + + resolve({image: imageDataUrl, mask: maskDataUrl}); + }); + } + + async sendDataViaWebSocket(nodeId: number): Promise { + log.info(`Preparing to send data for node ${nodeId} via WebSocket.`); + + const { image, mask } = await this._renderOutputData(); + + try { + log.info(`Sending data for node ${nodeId}...`); + await webSocketManager.sendMessage({ + type: 'canvas_data', + nodeId: String(nodeId), + image: image, + mask: mask, + }, true); // `true` requires an acknowledgment + + log.info(`Data for node ${nodeId} has been sent and acknowledged by the server.`); + return true; + } catch (error) { + log.error(`Failed to send data for node ${nodeId}:`, error); + + + throw new Error(`Failed to get confirmation from server for node ${nodeId}. The workflow might not have the latest canvas data.`); + } + } + + async addInputToCanvas(inputImage: any, inputMask: any): Promise { + try { + log.debug("Adding input to canvas:", { inputImage }); + + const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(inputImage.width, inputImage.height); + if (!tempCtx) throw new Error("Could not create temp context"); + + const imgData = new ImageData( + new Uint8ClampedArray(inputImage.data), + inputImage.width, + inputImage.height + ); + tempCtx.putImageData(imgData, 0, 0); + + const image = new Image(); + await new Promise((resolve, reject) => { + image.onload = resolve; + image.onerror = reject; + image.src = tempCanvas.toDataURL(); + }); + + const scale = Math.min( + this.canvas.width / inputImage.width * 0.8, + this.canvas.height / inputImage.height * 0.8 + ); + + 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, + height: inputImage.height * scale, + }); + + if (inputMask && layer) { + (layer as any).mask = inputMask.data; + } + + log.info("Layer added successfully"); + return true; + + } catch (error) { + log.error("Error in addInputToCanvas:", error); + throw error; + } + } + + async convertTensorToImage(tensor: any): Promise { + try { + log.debug("Converting tensor to image:", tensor); + + if (!tensor || !tensor.data || !tensor.width || !tensor.height) { + throw new Error("Invalid tensor data"); + } + + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + if (!ctx) throw new Error("Could not create canvas context"); + canvas.width = tensor.width; + canvas.height = tensor.height; + + const imageData = new ImageData( + new Uint8ClampedArray(tensor.data), + tensor.width, + tensor.height + ); + + ctx.putImageData(imageData, 0, 0); + + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = (e) => reject(new Error("Failed to load image: " + e)); + img.src = canvas.toDataURL(); + }); + } catch (error) { + log.error("Error converting tensor to image:", error); + throw error; + } + } + + async convertTensorToMask(tensor: any): Promise { + if (!tensor || !tensor.data) { + throw new Error("Invalid mask tensor"); + } + + try { + return new Float32Array(tensor.data); + } catch (error: any) { + throw new Error(`Mask conversion failed: ${error.message}`); + } + } + + async initNodeData(): Promise { + try { + log.info("Starting node data initialization..."); + + if (!this.canvas.node || !(this.canvas.node as any).inputs) { + log.debug("Node or inputs not ready"); + return this.scheduleDataCheck(); + } + + if ((this.canvas.node as any).inputs[0] && (this.canvas.node as any).inputs[0].link) { + const imageLinkId = (this.canvas.node as any).inputs[0].link; + const imageData = (window as any).app.nodeOutputs[imageLinkId]; + + if (imageData) { + log.debug("Found image data:", imageData); + await this.processImageData(imageData); + this.canvas.dataInitialized = true; + } else { + log.debug("Image data not available yet"); + return this.scheduleDataCheck(); + } + } + + if ((this.canvas.node as any).inputs[1] && (this.canvas.node as any).inputs[1].link) { + const maskLinkId = (this.canvas.node as any).inputs[1].link; + const maskData = (window as any).app.nodeOutputs[maskLinkId]; + + if (maskData) { + log.debug("Found mask data:", maskData); + await this.processMaskData(maskData); + } + } + + } catch (error) { + log.error("Error in initNodeData:", error); + return this.scheduleDataCheck(); + } + } + + scheduleDataCheck(): void { + if (this.canvas.pendingDataCheck) { + clearTimeout(this.canvas.pendingDataCheck); + } + + this.canvas.pendingDataCheck = window.setTimeout(() => { + this.canvas.pendingDataCheck = null; + if (!this.canvas.dataInitialized) { + this.initNodeData(); + } + }, 1000); + } + + async processImageData(imageData: any): Promise { + try { + if (!imageData) return; + + log.debug("Processing image data:", { + type: typeof imageData, + isArray: Array.isArray(imageData), + shape: imageData.shape, + hasData: !!imageData.data + }); + + if (Array.isArray(imageData)) { + imageData = imageData[0]; + } + + if (!imageData.shape || !imageData.data) { + throw new Error("Invalid image data format"); + } + + const originalWidth = imageData.shape[2]; + const originalHeight = imageData.shape[1]; + + const scale = Math.min( + this.canvas.width / originalWidth * 0.8, + this.canvas.height / originalHeight * 0.8 + ); + + const convertedData = this.convertTensorToImageData(imageData); + if (convertedData) { + const image = await this.createImageFromData(convertedData); + + this.addScaledLayer(image, scale); + log.info("Image layer added successfully with scale:", scale); + } + } catch (error) { + log.error("Error processing image data:", error); + throw error; + } + } + + addScaledLayer(image: HTMLImageElement, scale: number): void { + try { + const scaledWidth = image.width * scale; + const scaledHeight = image.height * scale; + + const layer: Layer = { + id: '', // This will be set in addLayerWithImage + imageId: '', // This will be set in addLayerWithImage + name: 'Layer', + image: image, + x: (this.canvas.width - scaledWidth) / 2, + y: (this.canvas.height - scaledHeight) / 2, + width: scaledWidth, + height: scaledHeight, + rotation: 0, + zIndex: this.canvas.layers.length, + originalWidth: image.width, + originalHeight: image.height, + blendMode: 'normal', + opacity: 1 + }; + + this.canvas.layers.push(layer); + this.canvas.updateSelection([layer]); + this.canvas.render(); + + log.debug("Scaled layer added:", { + originalSize: `${image.width}x${image.height}`, + scaledSize: `${scaledWidth}x${scaledHeight}`, + scale: scale + }); + } catch (error) { + log.error("Error adding scaled layer:", error); + throw error; + } + } + + convertTensorToImageData(tensor: any): ImageData | null { + try { + const shape = tensor.shape; + const height = shape[1]; + const width = shape[2]; + const channels = shape[3]; + + log.debug("Converting tensor:", { + shape: shape, + dataRange: { + min: tensor.min_val, + max: tensor.max_val + } + }); + + const imageData = new ImageData(width, height); + const data = new Uint8ClampedArray(width * height * 4); + + const flatData = tensor.data; + const pixelCount = width * height; + + for (let i = 0; i < pixelCount; i++) { + const pixelIndex = i * 4; + const tensorIndex = i * channels; + + for (let c = 0; c < channels; c++) { + const value = flatData[tensorIndex + c]; + + const normalizedValue = (value - tensor.min_val) / (tensor.max_val - tensor.min_val); + data[pixelIndex + c] = Math.round(normalizedValue * 255); + } + + data[pixelIndex + 3] = 255; + } + + imageData.data.set(data); + return imageData; + } catch (error) { + log.error("Error converting tensor:", error); + return null; + } + } + + async createImageFromData(imageData: ImageData): Promise { + return new Promise((resolve, reject) => { + const canvas = document.createElement('canvas'); + canvas.width = imageData.width; + canvas.height = imageData.height; + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + if (!ctx) throw new Error("Could not create canvas context"); + ctx.putImageData(imageData, 0, 0); + + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = reject; + img.src = canvas.toDataURL(); + }); + } + + async retryDataLoad(maxRetries = 3, delay = 1000): Promise { + for (let i = 0; i < maxRetries; i++) { + try { + await this.initNodeData(); + return; + } catch (error) { + log.warn(`Retry ${i + 1}/${maxRetries} failed:`, error); + if (i < maxRetries - 1) { + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + } + log.error("Failed to load data after", maxRetries, "retries"); + } + + async processMaskData(maskData: any): Promise { + try { + if (!maskData) return; + + log.debug("Processing mask data:", maskData); + + if (Array.isArray(maskData)) { + maskData = maskData[0]; + } + + if (!maskData.shape || !maskData.data) { + throw new Error("Invalid mask data format"); + } + + if (this.canvas.canvasSelection.selectedLayers.length > 0) { + const maskTensor = await this.convertTensorToMask(maskData); + (this.canvas.canvasSelection.selectedLayers[0] as any).mask = maskTensor; + this.canvas.render(); + log.info("Mask applied to selected layer"); + } + } catch (error) { + log.error("Error processing mask data:", error); + } + } + + async loadImageFromCache(base64Data: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = reject; + img.src = base64Data; + }); + } + + async importImage(cacheData: { image: string, mask?: string }): Promise { + try { + log.info("Starting image import with cache data"); + const img = await this.loadImageFromCache(cacheData.image); + const mask = cacheData.mask ? await this.loadImageFromCache(cacheData.mask) : null; + + const scale = Math.min( + this.canvas.width / img.width * 0.8, + this.canvas.height / img.height * 0.8 + ); + + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = img.width; + tempCanvas.height = img.height; + const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); + if (!tempCtx) throw new Error("Could not create temp context"); + + tempCtx.drawImage(img, 0, 0); + + if (mask) { + const imageData = tempCtx.getImageData(0, 0, img.width, img.height); + const maskCanvas = document.createElement('canvas'); + maskCanvas.width = img.width; + maskCanvas.height = img.height; + const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true }); + if (!maskCtx) throw new Error("Could not create mask context"); + maskCtx.drawImage(mask, 0, 0); + const maskData = maskCtx.getImageData(0, 0, img.width, img.height); + + for (let i = 0; i < imageData.data.length; i += 4) { + imageData.data[i + 3] = maskData.data[i]; + } + + tempCtx.putImageData(imageData, 0, 0); + } + + const finalImage = new Image(); + await new Promise((resolve) => { + finalImage.onload = resolve; + finalImage.src = tempCanvas.toDataURL(); + }); + + const layer: Layer = { + id: '', // This will be set in addLayerWithImage + imageId: '', // This will be set in addLayerWithImage + name: 'Layer', + image: finalImage, + x: (this.canvas.width - img.width * scale) / 2, + y: (this.canvas.height - img.height * scale) / 2, + width: img.width * scale, + height: img.height * scale, + originalWidth: img.width, + originalHeight: img.height, + rotation: 0, + zIndex: this.canvas.layers.length, + blendMode: 'normal', + opacity: 1, + }; + + this.canvas.layers.push(layer); + this.canvas.updateSelection([layer]); + this.canvas.render(); + this.canvas.saveState(); + } catch (error) { + log.error('Error importing image:', error); + } + } + + async importLatestImage(): Promise { + try { + log.info("Fetching latest image from server..."); + const response = await fetch('/ycnode/get_latest_image'); + const result = await response.json(); + + if (result.success && result.image_data) { + log.info("Latest image received, adding to canvas."); + const img = new Image(); + await new Promise((resolve, reject) => { + img.onload = resolve; + img.onerror = reject; + img.src = result.image_data; + }); + + await this.canvas.canvasLayers.addLayerWithImage(img, {}, 'fit'); + log.info("Latest image imported and placed on canvas successfully."); + return true; + } else { + throw new Error(result.error || "Failed to fetch the latest image."); + } + } catch (error: any) { + log.error("Error importing latest image:", error); + alert(`Failed to import latest image: ${error.message}`); + return false; + } + } + + async importLatestImages(sinceTimestamp: number, targetArea: { x: number, y: number, width: number, height: number } | null = null): Promise { + try { + log.info(`Fetching latest images since ${sinceTimestamp}...`); + const response = await fetch(`/layerforge/get-latest-images/${sinceTimestamp}`); + const result = await response.json(); + + if (result.success && result.images && result.images.length > 0) { + log.info(`Received ${result.images.length} new images, adding to canvas.`); + const newLayers: (Layer | null)[] = []; + + for (const imageData of result.images) { + const img = new Image(); + await new Promise((resolve, reject) => { + img.onload = resolve; + img.onerror = reject; + img.src = imageData; + }); + const newLayer = await this.canvas.canvasLayers.addLayerWithImage(img, {}, 'fit', targetArea); + newLayers.push(newLayer); + } + log.info("All new images imported and placed on canvas successfully."); + return newLayers.filter(l => l !== null) as Layer[]; + + } else if (result.success) { + log.info("No new images found since last generation."); + return []; + } else { + throw new Error(result.error || "Failed to fetch latest images."); + } + } catch (error: any) { + log.error("Error importing latest images:", error); + alert(`Failed to import latest images: ${error.message}`); + return []; + } + } +} diff --git a/src/CanvasInteractions.ts b/src/CanvasInteractions.ts new file mode 100644 index 0000000..832ce6f --- /dev/null +++ b/src/CanvasInteractions.ts @@ -0,0 +1,923 @@ +import { createModuleLogger } from "./utils/LoggerUtils.js"; +import { snapToGrid, getSnapAdjustment } from "./utils/CommonUtils.js"; +import type { Canvas } from './Canvas'; +import type { Layer, Point } from './types'; + +const log = createModuleLogger('CanvasInteractions'); + +interface InteractionState { + mode: 'none' | 'panning' | 'dragging' | 'resizing' | 'rotating' | 'drawingMask' | 'resizingCanvas' | 'movingCanvas' | 'potential-drag'; + panStart: Point; + dragStart: Point; + transformOrigin: Partial & { centerX?: number, centerY?: number }; + resizeHandle: string | null; + resizeAnchor: Point; + canvasResizeStart: Point; + isCtrlPressed: boolean; + isAltPressed: boolean; + hasClonedInDrag: boolean; + lastClickTime: number; + transformingLayer: Layer | null; + keyMovementInProgress: boolean; + canvasResizeRect: { x: number, y: number, width: number, height: number } | null; + canvasMoveRect: { x: number, y: number, width: number, height: number } | null; +} + +export class CanvasInteractions { + private canvas: Canvas; + public interaction: InteractionState; + private originalLayerPositions: Map; + + constructor(canvas: Canvas) { + this.canvas = canvas; + this.interaction = { + mode: 'none', + panStart: { x: 0, y: 0 }, + dragStart: { x: 0, y: 0 }, + transformOrigin: {}, + resizeHandle: null, + resizeAnchor: { x: 0, y: 0 }, + canvasResizeStart: { x: 0, y: 0 }, + isCtrlPressed: false, + isAltPressed: false, + hasClonedInDrag: false, + lastClickTime: 0, + transformingLayer: null, + keyMovementInProgress: false, + canvasResizeRect: null, + canvasMoveRect: null, + }; + this.originalLayerPositions = new Map(); + } + + setupEventListeners(): void { + this.canvas.canvas.addEventListener('mousedown', this.handleMouseDown.bind(this) as EventListener); + this.canvas.canvas.addEventListener('mousemove', this.handleMouseMove.bind(this) as EventListener); + this.canvas.canvas.addEventListener('mouseup', this.handleMouseUp.bind(this) as EventListener); + this.canvas.canvas.addEventListener('mouseleave', this.handleMouseLeave.bind(this) as EventListener); + this.canvas.canvas.addEventListener('wheel', this.handleWheel.bind(this) as EventListener, { passive: false }); + this.canvas.canvas.addEventListener('keydown', this.handleKeyDown.bind(this) as EventListener); + this.canvas.canvas.addEventListener('keyup', this.handleKeyUp.bind(this) as EventListener); + + document.addEventListener('paste', this.handlePasteEvent.bind(this)); + + this.canvas.canvas.addEventListener('mouseenter', (e: MouseEvent) => { + this.canvas.isMouseOver = true; + this.handleMouseEnter(e); + }); + this.canvas.canvas.addEventListener('mouseleave', (e: MouseEvent) => { + this.canvas.isMouseOver = false; + this.handleMouseLeave(e); + }); + + this.canvas.canvas.addEventListener('dragover', this.handleDragOver.bind(this) as EventListener); + this.canvas.canvas.addEventListener('dragenter', this.handleDragEnter.bind(this) as EventListener); + this.canvas.canvas.addEventListener('dragleave', this.handleDragLeave.bind(this) as EventListener); + this.canvas.canvas.addEventListener('drop', this.handleDrop.bind(this) as unknown as EventListener); + + this.canvas.canvas.addEventListener('contextmenu', this.handleContextMenu.bind(this) as EventListener); + } + + resetInteractionState(): void { + this.interaction.mode = 'none'; + this.interaction.resizeHandle = null; + this.originalLayerPositions.clear(); + this.interaction.canvasResizeRect = null; + this.interaction.canvasMoveRect = null; + this.interaction.hasClonedInDrag = false; + this.interaction.transformingLayer = null; + this.canvas.canvas.style.cursor = 'default'; + } + + handleMouseDown(e: MouseEvent): void { + this.canvas.canvas.focus(); + const worldCoords = this.canvas.getMouseWorldCoordinates(e); + const viewCoords = this.canvas.getMouseViewCoordinates(e); + + if (this.interaction.mode === 'drawingMask') { + this.canvas.maskTool.handleMouseDown(worldCoords, viewCoords); + this.canvas.render(); + return; + } + + // --- Ostateczna, poprawna kolejność sprawdzania --- + + // 1. Akcje globalne z modyfikatorami (mają najwyższy priorytet) + if (e.shiftKey && e.ctrlKey) { + this.startCanvasMove(worldCoords); + return; + } + if (e.shiftKey) { + this.startCanvasResize(worldCoords); + return; + } + + // 2. Inne przyciski myszy + if (e.button === 2) { // Prawy przycisk myszy + const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y); + if (clickedLayerResult && this.canvas.canvasSelection.selectedLayers.includes(clickedLayerResult.layer)) { + e.preventDefault(); + this.canvas.canvasLayers.showBlendModeMenu(viewCoords.x, viewCoords.y); + } + return; + } + if (e.button !== 0) { // Środkowy przycisk + this.startPanning(e); + return; + } + + // 3. Interakcje z elementami na płótnie (lewy przycisk) + const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y); + if (transformTarget) { + this.startLayerTransform(transformTarget.layer, transformTarget.handle, worldCoords); + return; + } + + const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y); + if (clickedLayerResult) { + this.prepareForDrag(clickedLayerResult.layer, worldCoords); + return; + } + + // 4. Domyślna akcja na tle (lewy przycisk bez modyfikatorów) + this.startPanningOrClearSelection(e); + } + + handleMouseMove(e: MouseEvent): void { + const worldCoords = this.canvas.getMouseWorldCoordinates(e); + const viewCoords = this.canvas.getMouseViewCoordinates(e); + this.canvas.lastMousePosition = worldCoords; // Zawsze aktualizuj ostatnią pozycję myszy + + // Sprawdź, czy rozpocząć przeciąganie + if (this.interaction.mode === 'potential-drag') { + const dx = worldCoords.x - this.interaction.dragStart.x; + const dy = worldCoords.y - this.interaction.dragStart.y; + if (Math.sqrt(dx * dx + dy * dy) > 3) { // Próg 3 pikseli + this.interaction.mode = 'dragging'; + this.originalLayerPositions.clear(); + this.canvas.canvasSelection.selectedLayers.forEach((l: Layer) => { + this.originalLayerPositions.set(l, { x: l.x, y: l.y }); + }); + } + } + + switch (this.interaction.mode) { + case 'drawingMask': + this.canvas.maskTool.handleMouseMove(worldCoords, viewCoords); + this.canvas.render(); + break; + case 'panning': + this.panViewport(e); + break; + case 'dragging': + this.dragLayers(worldCoords); + break; + case 'resizing': + this.resizeLayerFromHandle(worldCoords, e.shiftKey); + break; + case 'rotating': + this.rotateLayerFromHandle(worldCoords, e.shiftKey); + break; + case 'resizingCanvas': + this.updateCanvasResize(worldCoords); + break; + case 'movingCanvas': + this.updateCanvasMove(worldCoords); + break; + default: + this.updateCursor(worldCoords); + break; + } + } + + handleMouseUp(e: MouseEvent): void { + const viewCoords = this.canvas.getMouseViewCoordinates(e); + if (this.interaction.mode === 'drawingMask') { + this.canvas.maskTool.handleMouseUp(viewCoords); + this.canvas.render(); + return; + } + + if (this.interaction.mode === 'resizingCanvas') { + this.finalizeCanvasResize(); + } + if (this.interaction.mode === 'movingCanvas') { + this.finalizeCanvasMove(); + } + + // Zapisz stan tylko, jeśli faktycznie doszło do zmiany (przeciąganie, transformacja, duplikacja) + const stateChangingInteraction = ['dragging', 'resizing', 'rotating'].includes(this.interaction.mode); + const duplicatedInDrag = this.interaction.hasClonedInDrag; + + if (stateChangingInteraction || duplicatedInDrag) { + this.canvas.saveState(); + this.canvas.canvasState.saveStateToDB(); + } + + this.resetInteractionState(); + this.canvas.render(); + } + + handleMouseLeave(e: MouseEvent): void { + const viewCoords = this.canvas.getMouseViewCoordinates(e); + if (this.canvas.maskTool.isActive) { + this.canvas.maskTool.handleMouseLeave(); + if (this.canvas.maskTool.isDrawing) { + this.canvas.maskTool.handleMouseUp(viewCoords); + } + this.canvas.render(); + return; + } + if (this.interaction.mode !== 'none') { + 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: MouseEvent): void { + if (this.canvas.maskTool.isActive) { + this.canvas.maskTool.handleMouseEnter(); + } + } + + handleContextMenu(e: MouseEvent): void { + e.preventDefault(); + } + + handleWheel(e: WheelEvent): void { + e.preventDefault(); + if (this.canvas.maskTool.isActive) { + const worldCoords = this.canvas.getMouseWorldCoordinates(e); + const rect = this.canvas.canvas.getBoundingClientRect(); + const mouseBufferX = (e.clientX - rect.left) * (this.canvas.offscreenCanvas.width / rect.width); + const mouseBufferY = (e.clientY - rect.top) * (this.canvas.offscreenCanvas.height / rect.height); + + const zoomFactor = e.deltaY < 0 ? 1.1 : 1 / 1.1; + const newZoom = this.canvas.viewport.zoom * zoomFactor; + + this.canvas.viewport.zoom = Math.max(0.1, Math.min(10, newZoom)); + this.canvas.viewport.x = worldCoords.x - (mouseBufferX / this.canvas.viewport.zoom); + this.canvas.viewport.y = worldCoords.y - (mouseBufferY / this.canvas.viewport.zoom); + } else if (this.canvas.canvasSelection.selectedLayers.length > 0) { + const rotationStep = 5 * (e.deltaY > 0 ? -1 : 1); + const direction = e.deltaY < 0 ? 1 : -1; // 1 = up/right, -1 = down/left + + this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => { + if (e.shiftKey) { + // Nowy skrót: Shift + Ctrl + Kółko do przyciągania do absolutnych wartości + if (e.ctrlKey) { + const snapAngle = 5; + if (direction > 0) { // Obrót w górę/prawo + layer.rotation = Math.ceil((layer.rotation + 0.1) / snapAngle) * snapAngle; + } else { // Obrót w dół/lewo + layer.rotation = Math.floor((layer.rotation - 0.1) / snapAngle) * snapAngle; + } + } else { + // Stara funkcjonalność: Shift + Kółko obraca o stały krok + layer.rotation += rotationStep; + } + } else { + const oldWidth = layer.width; + const oldHeight = layer.height; + let scaleFactor; + + if (e.ctrlKey) { + const direction = e.deltaY > 0 ? -1 : 1; + const baseDimension = Math.max(layer.width, layer.height); + const newBaseDimension = baseDimension + direction; + if (newBaseDimension < 10) { + return; + } + scaleFactor = newBaseDimension / baseDimension; + } else { + const gridSize = 64; + const direction = e.deltaY > 0 ? -1 : 1; + let targetHeight; + + if (direction > 0) { + targetHeight = (Math.floor(oldHeight / gridSize) + 1) * gridSize; + } else { + targetHeight = (Math.ceil(oldHeight / gridSize) - 1) * gridSize; + } + if (targetHeight < gridSize / 2) { + targetHeight = gridSize / 2; + } + if (Math.abs(oldHeight - targetHeight) < 1) { + if (direction > 0) targetHeight += gridSize; + else targetHeight -= gridSize; + + if (targetHeight < gridSize / 2) return; + } + + scaleFactor = targetHeight / oldHeight; + } + if (scaleFactor && isFinite(scaleFactor)) { + layer.width *= scaleFactor; + layer.height *= scaleFactor; + layer.x += (oldWidth - layer.width) / 2; + layer.y += (oldHeight - layer.height) / 2; + } + } + }); + } else { + const worldCoords = this.canvas.getMouseWorldCoordinates(e); + const rect = this.canvas.canvas.getBoundingClientRect(); + const mouseBufferX = (e.clientX - rect.left) * (this.canvas.offscreenCanvas.width / rect.width); + const mouseBufferY = (e.clientY - rect.top) * (this.canvas.offscreenCanvas.height / rect.height); + + const zoomFactor = e.deltaY < 0 ? 1.1 : 1 / 1.1; + const newZoom = this.canvas.viewport.zoom * zoomFactor; + + this.canvas.viewport.zoom = Math.max(0.1, Math.min(10, newZoom)); + this.canvas.viewport.x = worldCoords.x - (mouseBufferX / this.canvas.viewport.zoom); + this.canvas.viewport.y = worldCoords.y - (mouseBufferY / this.canvas.viewport.zoom); + } + this.canvas.render(); + if (!this.canvas.maskTool.isActive) { + this.canvas.requestSaveState(); // Użyj opóźnionego zapisu + } + } + + handleKeyDown(e: KeyboardEvent): void { + if (e.key === 'Control') this.interaction.isCtrlPressed = true; + if (e.key === 'Alt') { + this.interaction.isAltPressed = true; + e.preventDefault(); + } + + // Globalne skróty (Undo/Redo/Copy/Paste) + if (e.ctrlKey || e.metaKey) { + let handled = true; + switch (e.key.toLowerCase()) { + case 'z': + if (e.shiftKey) { + this.canvas.redo(); + } else { + this.canvas.undo(); + } + break; + case 'y': + this.canvas.redo(); + break; + case 'c': + if (this.canvas.canvasSelection.selectedLayers.length > 0) { + this.canvas.canvasLayers.copySelectedLayers(); + } + break; + default: + handled = false; + break; + } + if (handled) { + e.preventDefault(); + e.stopPropagation(); + return; + } + } + + // Skróty kontekstowe (zależne od zaznaczenia) + if (this.canvas.canvasSelection.selectedLayers.length > 0) { + const step = e.shiftKey ? 10 : 1; + let needsRender = false; + + // Używamy e.code dla spójności i niezależności od układu klawiatury + const movementKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'BracketLeft', 'BracketRight']; + if (movementKeys.includes(e.code)) { + e.preventDefault(); + e.stopPropagation(); + this.interaction.keyMovementInProgress = true; + + if (e.code === 'ArrowLeft') this.canvas.canvasSelection.selectedLayers.forEach((l: Layer) => l.x -= step); + if (e.code === 'ArrowRight') this.canvas.canvasSelection.selectedLayers.forEach((l: Layer) => l.x += step); + if (e.code === 'ArrowUp') this.canvas.canvasSelection.selectedLayers.forEach((l: Layer) => l.y -= step); + if (e.code === 'ArrowDown') this.canvas.canvasSelection.selectedLayers.forEach((l: Layer) => l.y += step); + if (e.code === 'BracketLeft') this.canvas.canvasSelection.selectedLayers.forEach((l: Layer) => l.rotation -= step); + if (e.code === 'BracketRight') this.canvas.canvasSelection.selectedLayers.forEach((l: Layer) => l.rotation += step); + + needsRender = true; + } + + if (e.key === 'Delete' || e.key === 'Backspace') { + e.preventDefault(); + e.stopPropagation(); + this.canvas.canvasSelection.removeSelectedLayers(); + return; + } + + if (needsRender) { + this.canvas.render(); + } + } + } + + handleKeyUp(e: KeyboardEvent): void { + if (e.key === 'Control') this.interaction.isCtrlPressed = false; + if (e.key === 'Alt') this.interaction.isAltPressed = false; + + const movementKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'BracketLeft', 'BracketRight']; + if (movementKeys.includes(e.code) && this.interaction.keyMovementInProgress) { + this.canvas.requestSaveState(); // Użyj opóźnionego zapisu + this.interaction.keyMovementInProgress = false; + } + } + + updateCursor(worldCoords: Point): void { + const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y); + + if (transformTarget) { + const handleName = transformTarget.handle; + const cursorMap: { [key: string]: string } = { + 'n': 'ns-resize', 's': 'ns-resize', 'e': 'ew-resize', 'w': 'ew-resize', + 'nw': 'nwse-resize', 'se': 'nwse-resize', 'ne': 'nesw-resize', 'sw': 'nesw-resize', + 'rot': 'grab' + }; + this.canvas.canvas.style.cursor = cursorMap[handleName]; + } else if (this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y)) { + this.canvas.canvas.style.cursor = 'move'; + } else { + this.canvas.canvas.style.cursor = 'default'; + } + } + + startLayerTransform(layer: Layer, handle: string, worldCoords: Point): void { + this.interaction.transformingLayer = layer; + this.interaction.transformOrigin = { + x: layer.x, y: layer.y, + width: layer.width, height: layer.height, + rotation: layer.rotation, + centerX: layer.x + layer.width / 2, + centerY: layer.y + layer.height / 2 + }; + this.interaction.dragStart = {...worldCoords}; + + if (handle === 'rot') { + this.interaction.mode = 'rotating'; + } else { + this.interaction.mode = 'resizing'; + this.interaction.resizeHandle = handle; + const handles = this.canvas.canvasLayers.getHandles(layer); + const oppositeHandleKey: { [key: string]: string } = { + 'n': 's', 's': 'n', 'e': 'w', 'w': 'e', + 'nw': 'se', 'se': 'nw', 'ne': 'sw', 'sw': 'ne' + }; + this.interaction.resizeAnchor = handles[oppositeHandleKey[handle]]; + } + this.canvas.render(); + } + + prepareForDrag(layer: Layer, worldCoords: Point): void { + // Zaktualizuj zaznaczenie, ale nie zapisuj stanu + if (this.interaction.isCtrlPressed) { + const index = this.canvas.canvasSelection.selectedLayers.indexOf(layer); + if (index === -1) { + this.canvas.canvasSelection.updateSelection([...this.canvas.canvasSelection.selectedLayers, layer]); + } else { + const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l: Layer) => l !== layer); + this.canvas.canvasSelection.updateSelection(newSelection); + } + } else { + if (!this.canvas.canvasSelection.selectedLayers.includes(layer)) { + this.canvas.canvasSelection.updateSelection([layer]); + } + } + + this.interaction.mode = 'potential-drag'; + this.interaction.dragStart = {...worldCoords}; + } + + startPanningOrClearSelection(e: MouseEvent): void { + // Ta funkcja jest teraz wywoływana tylko gdy kliknięto na tło bez modyfikatorów. + // Domyślna akcja: wyczyść zaznaczenie i rozpocznij panoramowanie. + if (!this.interaction.isCtrlPressed) { + this.canvas.canvasSelection.updateSelection([]); + } + this.interaction.mode = 'panning'; + this.interaction.panStart = {x: e.clientX, y: e.clientY}; + } + + startCanvasResize(worldCoords: Point): void { + this.interaction.mode = 'resizingCanvas'; + const startX = snapToGrid(worldCoords.x); + const startY = snapToGrid(worldCoords.y); + this.interaction.canvasResizeStart = {x: startX, y: startY}; + this.interaction.canvasResizeRect = {x: startX, y: startY, width: 0, height: 0}; + this.canvas.render(); + } + + startCanvasMove(worldCoords: Point): void { + this.interaction.mode = 'movingCanvas'; + this.interaction.dragStart = { ...worldCoords }; + const initialX = snapToGrid(worldCoords.x - this.canvas.width / 2); + const initialY = snapToGrid(worldCoords.y - this.canvas.height / 2); + + this.interaction.canvasMoveRect = { + x: initialX, + y: initialY, + width: this.canvas.width, + height: this.canvas.height + }; + + this.canvas.canvas.style.cursor = 'grabbing'; + this.canvas.render(); + } + + updateCanvasMove(worldCoords: Point): void { + if (!this.interaction.canvasMoveRect) return; + const dx = worldCoords.x - this.interaction.dragStart.x; + const dy = worldCoords.y - this.interaction.dragStart.y; + const initialRectX = snapToGrid(this.interaction.dragStart.x - this.canvas.width / 2); + const initialRectY = snapToGrid(this.interaction.dragStart.y - this.canvas.height / 2); + this.interaction.canvasMoveRect.x = snapToGrid(initialRectX + dx); + this.interaction.canvasMoveRect.y = snapToGrid(initialRectY + dy); + + this.canvas.render(); + } + + finalizeCanvasMove(): void { + const moveRect = this.interaction.canvasMoveRect; + + if (moveRect && (moveRect.x !== 0 || moveRect.y !== 0)) { + const finalX = moveRect.x; + const finalY = moveRect.y; + + this.canvas.layers.forEach((layer: Layer) => { + layer.x -= finalX; + layer.y -= finalY; + }); + + this.canvas.maskTool.updatePosition(-finalX, -finalY); + + // If a batch generation is in progress, update the captured context as well + if (this.canvas.pendingBatchContext) { + this.canvas.pendingBatchContext.outputArea.x -= finalX; + this.canvas.pendingBatchContext.outputArea.y -= finalY; + + // Also update the menu spawn position to keep it relative + this.canvas.pendingBatchContext.spawnPosition.x -= finalX; + this.canvas.pendingBatchContext.spawnPosition.y -= finalY; + log.debug("Updated pending batch context during canvas move:", this.canvas.pendingBatchContext); + } + + // Also move any active batch preview menus + if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) { + this.canvas.batchPreviewManagers.forEach((manager: any) => { // TODO: Type for manager + manager.worldX -= finalX; + manager.worldY -= finalY; + if (manager.generationArea) { + manager.generationArea.x -= finalX; + manager.generationArea.y -= finalY; + } + }); + } + + this.canvas.viewport.x -= finalX; + this.canvas.viewport.y -= finalY; + } + this.canvas.render(); + this.canvas.saveState(); + } + + startPanning(e: MouseEvent): void { + if (!this.interaction.isCtrlPressed) { + this.canvas.canvasSelection.updateSelection([]); + } + this.interaction.mode = 'panning'; + this.interaction.panStart = { x: e.clientX, y: e.clientY }; + } + + panViewport(e: MouseEvent): void { + const dx = e.clientX - this.interaction.panStart.x; + const dy = e.clientY - this.interaction.panStart.y; + this.canvas.viewport.x -= dx / this.canvas.viewport.zoom; + this.canvas.viewport.y -= dy / this.canvas.viewport.zoom; + this.interaction.panStart = {x: e.clientX, y: e.clientY}; + this.canvas.render(); + } + + dragLayers(worldCoords: Point): void { + if (this.interaction.isAltPressed && !this.interaction.hasClonedInDrag && this.canvas.canvasSelection.selectedLayers.length > 0) { + // Scentralizowana logika duplikowania + const newLayers = this.canvas.canvasSelection.duplicateSelectedLayers(); + + // Zresetuj pozycje przeciągania dla nowych, zduplikowanych warstw + this.originalLayerPositions.clear(); + newLayers.forEach((l: Layer) => { + this.originalLayerPositions.set(l, { x: l.x, y: l.y }); + }); + this.interaction.hasClonedInDrag = true; + } + const totalDx = worldCoords.x - this.interaction.dragStart.x; + const totalDy = worldCoords.y - this.interaction.dragStart.y; + let finalDx = totalDx, finalDy = totalDy; + + if (this.interaction.isCtrlPressed && this.canvas.canvasSelection.selectedLayers.length > 0) { + const firstLayer = this.canvas.canvasSelection.selectedLayers[0]; + const originalPos = this.originalLayerPositions.get(firstLayer); + if (originalPos) { + const tempLayerForSnap = { + ...firstLayer, + x: originalPos.x + totalDx, + y: originalPos.y + totalDy + }; + const snapAdjustment = getSnapAdjustment(tempLayerForSnap); + if (snapAdjustment) { + finalDx += snapAdjustment.x; + finalDy += snapAdjustment.y; + } + } + } + + this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => { + const originalPos = this.originalLayerPositions.get(layer); + if (originalPos) { + layer.x = originalPos.x + finalDx; + layer.y = originalPos.y + finalDy; + } + }); + this.canvas.render(); + } + + resizeLayerFromHandle(worldCoords: Point, isShiftPressed: boolean): void { + const layer = this.interaction.transformingLayer; + if (!layer) return; + + let mouseX = worldCoords.x; + let mouseY = worldCoords.y; + + if (this.interaction.isCtrlPressed) { + const snapThreshold = 10 / this.canvas.viewport.zoom; + const snappedMouseX = snapToGrid(mouseX); + if (Math.abs(mouseX - snappedMouseX) < snapThreshold) mouseX = snappedMouseX; + const snappedMouseY = snapToGrid(mouseY); + if (Math.abs(mouseY - snappedMouseY) < snapThreshold) mouseY = snappedMouseY; + } + + const o = this.interaction.transformOrigin; + if (o.rotation === undefined || o.width === undefined || o.height === undefined || o.centerX === undefined || o.centerY === undefined) return; + const handle = this.interaction.resizeHandle; + const anchor = this.interaction.resizeAnchor; + + const rad = o.rotation * Math.PI / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + + const vecX = mouseX - anchor.x; + const vecY = mouseY - anchor.y; + + let newWidth = vecX * cos + vecY * sin; + let newHeight = vecY * cos - vecX * sin; + + if (isShiftPressed) { + const originalAspectRatio = o.width / o.height; + + if (Math.abs(newWidth) > Math.abs(newHeight) * originalAspectRatio) { + newHeight = (Math.sign(newHeight) || 1) * Math.abs(newWidth) / originalAspectRatio; + } else { + newWidth = (Math.sign(newWidth) || 1) * Math.abs(newHeight) * originalAspectRatio; + } + } + + let signX = handle?.includes('e') ? 1 : (handle?.includes('w') ? -1 : 0); + let signY = handle?.includes('s') ? 1 : (handle?.includes('n') ? -1 : 0); + + newWidth *= signX; + newHeight *= signY; + + if (signX === 0) newWidth = o.width; + if (signY === 0) newHeight = o.height; + + if (newWidth < 10) newWidth = 10; + if (newHeight < 10) newHeight = 10; + + layer.width = newWidth; + layer.height = newHeight; + + const deltaW = newWidth - o.width; + const deltaH = newHeight - o.height; + + const shiftX = (deltaW / 2) * signX; + const shiftY = (deltaH / 2) * signY; + + const worldShiftX = shiftX * cos - shiftY * sin; + const worldShiftY = shiftX * sin + shiftY * cos; + + const newCenterX = o.centerX + worldShiftX; + const newCenterY = o.centerY + worldShiftY; + + layer.x = newCenterX - layer.width / 2; + layer.y = newCenterY - layer.height / 2; + this.canvas.render(); + } + + rotateLayerFromHandle(worldCoords: Point, isShiftPressed: boolean): void { + const layer = this.interaction.transformingLayer; + if (!layer) return; + + const o = this.interaction.transformOrigin; + if (o.rotation === undefined || o.centerX === undefined || o.centerY === undefined) return; + const startAngle = Math.atan2(this.interaction.dragStart.y - o.centerY, this.interaction.dragStart.x - o.centerX); + const currentAngle = Math.atan2(worldCoords.y - o.centerY, worldCoords.x - o.centerX); + let angleDiff = (currentAngle - startAngle) * 180 / Math.PI; + let newRotation = o.rotation + angleDiff; + + if (isShiftPressed) { + newRotation = Math.round(newRotation / 15) * 15; + } + + layer.rotation = newRotation; + this.canvas.render(); + } + + updateCanvasResize(worldCoords: Point): void { + if (!this.interaction.canvasResizeRect) return; + const snappedMouseX = snapToGrid(worldCoords.x); + const snappedMouseY = snapToGrid(worldCoords.y); + const start = this.interaction.canvasResizeStart; + + this.interaction.canvasResizeRect.x = Math.min(snappedMouseX, start.x); + this.interaction.canvasResizeRect.y = Math.min(snappedMouseY, start.y); + this.interaction.canvasResizeRect.width = Math.abs(snappedMouseX - start.x); + this.interaction.canvasResizeRect.height = Math.abs(snappedMouseY - start.y); + this.canvas.render(); + } + + finalizeCanvasResize(): void { + if (this.interaction.canvasResizeRect && this.interaction.canvasResizeRect.width > 1 && this.interaction.canvasResizeRect.height > 1) { + const newWidth = Math.round(this.interaction.canvasResizeRect.width); + const newHeight = Math.round(this.interaction.canvasResizeRect.height); + const finalX = this.interaction.canvasResizeRect.x; + const finalY = this.interaction.canvasResizeRect.y; + + this.canvas.updateOutputAreaSize(newWidth, newHeight); + + this.canvas.layers.forEach((layer: Layer) => { + layer.x -= finalX; + layer.y -= finalY; + }); + + this.canvas.maskTool.updatePosition(-finalX, -finalY); + + // If a batch generation is in progress, update the captured context as well + if (this.canvas.pendingBatchContext) { + this.canvas.pendingBatchContext.outputArea.x -= finalX; + this.canvas.pendingBatchContext.outputArea.y -= finalY; + + // Also update the menu spawn position to keep it relative + this.canvas.pendingBatchContext.spawnPosition.x -= finalX; + this.canvas.pendingBatchContext.spawnPosition.y -= finalY; + log.debug("Updated pending batch context during canvas resize:", this.canvas.pendingBatchContext); + } + + // Also move any active batch preview menus + if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) { + this.canvas.batchPreviewManagers.forEach((manager: any) => { // TODO: Type for manager + manager.worldX -= finalX; + manager.worldY -= finalY; + if (manager.generationArea) { + manager.generationArea.x -= finalX; + manager.generationArea.y -= finalY; + } + }); + } + + this.canvas.viewport.x -= finalX; + this.canvas.viewport.y -= finalY; + } + } + + handleDragOver(e: DragEvent): void { + e.preventDefault(); + e.stopPropagation(); // Prevent ComfyUI from handling this event + if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; + } + + handleDragEnter(e: DragEvent): void { + 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: DragEvent): void { + e.preventDefault(); + e.stopPropagation(); // Prevent ComfyUI from handling this event + + if (!this.canvas.canvas.contains(e.relatedTarget as Node)) { + this.canvas.canvas.style.backgroundColor = ''; + this.canvas.canvas.style.border = ''; + } + } + + async handleDrop(e: DragEvent): Promise { + 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 = ''; + + if (!e.dataTransfer) return; + 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: File, worldCoords: Point): Promise { + const reader = new FileReader(); + reader.onload = async (e) => { + const img = new Image(); + img.onload = async () => { + + const fitOnAddWidget = this.canvas.node.widgets.find((w: any) => w.name === "fit_on_add"); + const addMode = fitOnAddWidget && fitOnAddWidget.value ? 'fit' : 'center'; + + await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode); + }; + img.onerror = () => { + log.error(`Failed to load dropped image: ${file.name}`); + }; + if (e.target?.result) { + img.src = e.target.result as string; + } + }; + reader.onerror = () => { + log.error(`Failed to read dropped file: ${file.name}`); + }; + reader.readAsDataURL(file); + } + + async handlePasteEvent(e: ClipboardEvent): Promise { + + 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'); + }; + if (event.target?.result) { + img.src = event.target.result as string; + } + }; + reader.readAsDataURL(file); + return; + } + } + } + } + + await this.canvas.canvasLayers.clipboardManager.handlePaste('mouse', preference); + } +} diff --git a/src/CanvasLayers.ts b/src/CanvasLayers.ts new file mode 100644 index 0000000..873d163 --- /dev/null +++ b/src/CanvasLayers.ts @@ -0,0 +1,1045 @@ +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"; +// @ts-ignore +import {app} from "../../scripts/app.js"; +// @ts-ignore +import {ComfyApp} from "../../scripts/app.js"; +import { ClipboardManager } from "./utils/ClipboardManager.js"; +import type { Canvas } from './Canvas'; +import type { Layer, Point, AddMode, ClipboardPreference } from './types'; + +const log = createModuleLogger('CanvasLayers'); + +interface BlendMode { + name: string; + label: string; +} + +export class CanvasLayers { + private canvas: Canvas; + public clipboardManager: ClipboardManager; + private blendModes: BlendMode[]; + private selectedBlendMode: string | null; + private blendOpacity: number; + private isAdjustingOpacity: boolean; + public internalClipboard: Layer[]; + public clipboardPreference: ClipboardPreference; + + constructor(canvas: Canvas) { + this.canvas = canvas; + this.clipboardManager = new ClipboardManager(canvas as any); + this.blendModes = [ + { name: 'normal', label: 'Normal' }, + {name: 'multiply', label: 'Multiply'}, + {name: 'screen', label: 'Screen'}, + {name: 'overlay', label: 'Overlay'}, + {name: 'darken', label: 'Darken'}, + {name: 'lighten', label: 'Lighten'}, + {name: 'color-dodge', label: 'Color Dodge'}, + {name: 'color-burn', label: 'Color Burn'}, + {name: 'hard-light', label: 'Hard Light'}, + {name: 'soft-light', label: 'Soft Light'}, + {name: 'difference', label: 'Difference'}, + { name: 'exclusion', label: 'Exclusion' } + ]; + this.selectedBlendMode = null; + this.blendOpacity = 100; + this.isAdjustingOpacity = false; + this.internalClipboard = []; + this.clipboardPreference = 'system'; + } + + async copySelectedLayers(): Promise { + if (this.canvas.canvasSelection.selectedLayers.length === 0) return; + + this.internalClipboard = this.canvas.canvasSelection.selectedLayers.map((layer: Layer) => ({ ...layer })); + log.info(`Copied ${this.internalClipboard.length} layer(s) to internal clipboard.`); + + 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, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + + const img = new Image(); + img.onload = () => { + if (!this.canvas.node.imgs) { + this.canvas.node.imgs = []; + } + this.canvas.node.imgs[0] = 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 system clipboard."); + } catch (error) { + log.error("Failed to copy image to system clipboard:", error); + } + } + } + + pasteLayers(): void { + if (this.internalClipboard.length === 0) return; + this.canvas.saveState(); + const newLayers: Layer[] = []; + + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + this.internalClipboard.forEach((layer: 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 { x: mouseX, y: mouseY } = this.canvas.lastMousePosition; + const offsetX = mouseX - centerX; + const offsetY = mouseY - centerY; + + this.internalClipboard.forEach((clipboardLayer: Layer) => { + const newLayer: Layer = { + ...clipboardLayer, + x: clipboardLayer.x + offsetX, + y: clipboardLayer.y + offsetY, + zIndex: this.canvas.layers.length + }; + this.canvas.layers.push(newLayer); + newLayers.push(newLayer); + }); + + this.canvas.updateSelection(newLayers); + this.canvas.render(); + + if (this.canvas.canvasLayersPanel) { + this.canvas.canvasLayersPanel.onLayersChanged(); + } + + log.info(`Pasted ${newLayers.length} layer(s) at mouse position (${mouseX}, ${mouseY}).`); + } + + async handlePaste(addMode: AddMode = 'mouse'): Promise { + try { + log.info(`Paste operation started with preference: ${this.clipboardPreference}`); + await this.clipboardManager.handlePaste(addMode, this.clipboardPreference); + } catch (err) { + log.error("Paste operation failed:", err); + } + } + + addLayerWithImage = withErrorHandling(async (image: HTMLImageElement, layerProps: Partial = {}, addMode: AddMode = 'default', targetArea: { x: number, y: number, width: number, height: number } | null = null): Promise => { + if (!image) { + throw createValidationError("Image is required for layer creation"); + } + + log.debug("Adding layer with image:", image, "with mode:", addMode, "targetArea:", targetArea); + const imageId = generateUUID(); + await saveImage(imageId, image.src); + this.canvas.imageCache.set(imageId, image.src); + + let finalWidth = image.width; + let finalHeight = image.height; + let finalX, finalY; + + // Use the targetArea if provided, otherwise default to the current canvas dimensions + const area = targetArea || { width: this.canvas.width, height: this.canvas.height, x: 0, y: 0 }; + + if (addMode === 'fit') { + const scale = Math.min(area.width / image.width, area.height / image.height); + finalWidth = image.width * scale; + finalHeight = image.height * scale; + finalX = area.x + (area.width - finalWidth) / 2; + finalY = area.y + (area.height - finalHeight) / 2; + } else if (addMode === 'mouse') { + finalX = this.canvas.lastMousePosition.x - finalWidth / 2; + finalY = this.canvas.lastMousePosition.y - finalHeight / 2; + } else { + finalX = area.x + (area.width - finalWidth) / 2; + finalY = area.y + (area.height - finalHeight) / 2; + } + + const layer: Layer = { + id: generateUUID(), + image: image, + imageId: imageId, + name: 'Layer', + x: finalX, + y: finalY, + width: finalWidth, + height: finalHeight, + originalWidth: image.width, + originalHeight: image.height, + rotation: 0, + zIndex: this.canvas.layers.length, + blendMode: 'normal', + opacity: 1, + ...layerProps + }; + + this.canvas.layers.push(layer); + this.canvas.updateSelection([layer]); + this.canvas.render(); + this.canvas.saveState(); + + if (this.canvas.canvasLayersPanel) { + this.canvas.canvasLayersPanel.onLayersChanged(); + } + + log.info("Layer added successfully"); + return layer; + }, 'CanvasLayers.addLayerWithImage'); + + async addLayer(image: HTMLImageElement): Promise { + return this.addLayerWithImage(image); + } + + moveLayers(layersToMove: Layer[], options: { direction?: 'up' | 'down', toIndex?: number } = {}): void { + if (!layersToMove || layersToMove.length === 0) return; + + let finalLayers: Layer[]; + + if (options.direction) { + const allLayers = [...this.canvas.layers]; + const selectedIndices = new Set(layersToMove.map((l: Layer) => allLayers.indexOf(l))); + + if (options.direction === 'up') { + const sorted = Array.from(selectedIndices).sort((a, b) => b - a); + sorted.forEach((index: number) => { + const targetIndex = index + 1; + if (targetIndex < allLayers.length && !selectedIndices.has(targetIndex)) { + [allLayers[index], allLayers[targetIndex]] = [allLayers[targetIndex], allLayers[index]]; + } + }); + } else if (options.direction === 'down') { + const sorted = Array.from(selectedIndices).sort((a, b) => a - b); + sorted.forEach((index: number) => { + const targetIndex = index - 1; + if (targetIndex >= 0 && !selectedIndices.has(targetIndex)) { + [allLayers[index], allLayers[targetIndex]] = [allLayers[targetIndex], allLayers[index]]; + } + }); + } + finalLayers = allLayers; + } else if (options.toIndex !== undefined) { + const displayedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex); + const reorderedFinal: Layer[] = []; + let inserted = false; + + for (let i = 0; i < displayedLayers.length; i++) { + if (i === options.toIndex) { + reorderedFinal.push(...layersToMove); + inserted = true; + } + const currentLayer = displayedLayers[i]; + if (!layersToMove.includes(currentLayer)) { + reorderedFinal.push(currentLayer); + } + } + if (!inserted) { + reorderedFinal.push(...layersToMove); + } + finalLayers = reorderedFinal; + } else { + log.warn("Invalid options for moveLayers", options); + return; + } + + const totalLayers = finalLayers.length; + finalLayers.forEach((layer, index) => { + const zIndex = (options.toIndex !== undefined) ? (totalLayers - 1 - index) : index; + layer.zIndex = zIndex; + }); + + this.canvas.layers = finalLayers; + this.canvas.layers.sort((a: Layer, b: Layer) => a.zIndex - b.zIndex); + + if (this.canvas.canvasLayersPanel) { + this.canvas.canvasLayersPanel.onLayersChanged(); + } + + this.canvas.render(); + this.canvas.requestSaveState(); + log.info(`Moved ${layersToMove.length} layer(s).`); + } + + moveLayerUp(): void { + if (this.canvas.canvasSelection.selectedLayers.length === 0) return; + this.moveLayers(this.canvas.canvasSelection.selectedLayers, { direction: 'up' }); + } + + moveLayerDown(): void { + if (this.canvas.canvasSelection.selectedLayers.length === 0) return; + this.moveLayers(this.canvas.canvasSelection.selectedLayers, { direction: 'down' }); + } + + resizeLayer(scale: number): void { + if (this.canvas.canvasSelection.selectedLayers.length === 0) return; + + this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => { + layer.width *= scale; + layer.height *= scale; + }); + this.canvas.render(); + this.canvas.requestSaveState(); + } + + rotateLayer(angle: number): void { + if (this.canvas.canvasSelection.selectedLayers.length === 0) return; + + this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => { + layer.rotation += angle; + }); + this.canvas.render(); + this.canvas.requestSaveState(); + } + + getLayerAtPosition(worldX: number, worldY: number): { layer: Layer, localX: number, localY: number } | null { + 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; + + const dx = worldX - centerX; + const dy = worldY - centerY; + + const rad = -layer.rotation * Math.PI / 180; + const rotatedX = dx * Math.cos(rad) - dy * Math.sin(rad); + const rotatedY = dx * Math.sin(rad) + dy * Math.cos(rad); + + if (Math.abs(rotatedX) <= layer.width / 2 && Math.abs(rotatedY) <= layer.height / 2) { + return { + layer: layer, + localX: rotatedX + layer.width / 2, + localY: rotatedY + layer.height / 2 + }; + } + } + return null; + } + + async mirrorHorizontal(): Promise { + if (this.canvas.canvasSelection.selectedLayers.length === 0) return; + + const promises = this.canvas.canvasSelection.selectedLayers.map((layer: Layer) => { + return new Promise(resolve => { + const tempCanvas = document.createElement('canvas'); + const tempCtx = tempCanvas.getContext('2d'); + if (!tempCtx) return; + tempCanvas.width = layer.image.width; + tempCanvas.height = layer.image.height; + + tempCtx.translate(tempCanvas.width, 0); + tempCtx.scale(-1, 1); + tempCtx.drawImage(layer.image, 0, 0); + + const newImage = new Image(); + newImage.onload = () => { + layer.image = newImage; + resolve(); + }; + newImage.src = tempCanvas.toDataURL(); + }); + }); + + await Promise.all(promises); + this.canvas.render(); + this.canvas.requestSaveState(); + } + + async mirrorVertical(): Promise { + if (this.canvas.canvasSelection.selectedLayers.length === 0) return; + + const promises = this.canvas.canvasSelection.selectedLayers.map((layer: Layer) => { + return new Promise(resolve => { + const tempCanvas = document.createElement('canvas'); + const tempCtx = tempCanvas.getContext('2d'); + if (!tempCtx) return; + tempCanvas.width = layer.image.width; + tempCanvas.height = layer.image.height; + + tempCtx.translate(0, tempCanvas.height); + tempCtx.scale(1, -1); + tempCtx.drawImage(layer.image, 0, 0); + + const newImage = new Image(); + newImage.onload = () => { + layer.image = newImage; + resolve(); + }; + newImage.src = tempCanvas.toDataURL(); + }); + }); + + await Promise.all(promises); + this.canvas.render(); + this.canvas.requestSaveState(); + } + + async getLayerImageData(layer: Layer): Promise { + try { + const tempCanvas = document.createElement('canvas'); + const tempCtx = tempCanvas.getContext('2d'); + if (!tempCtx) throw new Error("Could not create canvas context"); + + tempCanvas.width = layer.width; + tempCanvas.height = layer.height; + + tempCtx.clearRect(0, 0, tempCanvas.width, tempCanvas.height); + + tempCtx.save(); + tempCtx.translate(layer.width / 2, layer.height / 2); + tempCtx.rotate(layer.rotation * Math.PI / 180); + tempCtx.drawImage( + layer.image, + -layer.width / 2, + -layer.height / 2, + layer.width, + layer.height + ); + tempCtx.restore(); + + const dataUrl = tempCanvas.toDataURL('image/png'); + if (!dataUrl.startsWith('data:image/png;base64,')) { + throw new Error("Invalid image data format"); + } + return dataUrl; + } catch (error) { + log.error("Error getting layer image data:", error); + throw error; + } + } + + updateOutputAreaSize(width: number, height: number, saveHistory = true): void { + if (saveHistory) { + this.canvas.saveState(); + } + this.canvas.width = width; + this.canvas.height = height; + this.canvas.maskTool.resize(width, height); + + this.canvas.canvas.width = width; + this.canvas.canvas.height = height; + + this.canvas.render(); + + if (saveHistory) { + this.canvas.canvasState.saveStateToDB(); + } + } + + getHandles(layer: Layer): Record { + const centerX = layer.x + layer.width / 2; + const centerY = layer.y + layer.height / 2; + const rad = layer.rotation * Math.PI / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + + const halfW = layer.width / 2; + const halfH = layer.height / 2; + const localHandles: Record = { + 'n': { x: 0, y: -halfH }, + 'ne': { x: halfW, y: -halfH }, + 'e': { x: halfW, y: 0 }, + 'se': { x: halfW, y: halfH }, + 's': { x: 0, y: halfH }, + 'sw': { x: -halfW, y: halfH }, + 'w': { x: -halfW, y: 0 }, + 'nw': { x: -halfW, y: -halfH }, + 'rot': { x: 0, y: -halfH - 20 / this.canvas.viewport.zoom } + }; + + const worldHandles: Record = {}; + for (const key in localHandles) { + const p = localHandles[key]; + worldHandles[key] = { + x: centerX + (p.x * cos - p.y * sin), + y: centerY + (p.x * sin + p.y * cos) + }; + } + return worldHandles; + } + + getHandleAtPosition(worldX: number, worldY: number): { layer: Layer, handle: string } | null { + if (this.canvas.canvasSelection.selectedLayers.length === 0) return null; + + const handleRadius = 8 / this.canvas.viewport.zoom; + for (let i = this.canvas.canvasSelection.selectedLayers.length - 1; i >= 0; i--) { + const layer = this.canvas.canvasSelection.selectedLayers[i]; + const handles = this.getHandles(layer); + + for (const key in handles) { + const handlePos = handles[key]; + const dx = worldX - handlePos.x; + const dy = worldY - handlePos.y; + if (dx * dx + dy * dy <= handleRadius * handleRadius) { + return { layer: layer, handle: key }; + } + } + } + return null; + } + + showBlendModeMenu(x: number, y: number): void { + this.closeBlendModeMenu(); + + const menu = document.createElement('div'); + menu.id = 'blend-mode-menu'; + menu.style.cssText = ` + position: fixed; + left: ${x}px; + top: ${y}px; + background: #2a2a2a; + border: 1px solid #3a3a3a; + border-radius: 4px; + 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: MouseEvent) => { + 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: MouseEvent) => { + isDragging = true; + dragOffset.x = e.clientX - parseInt(menu.style.left, 10); + dragOffset.y = e.clientY - parseInt(menu.style.top, 10); + e.preventDefault(); + e.stopPropagation(); + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }); + + this.blendModes.forEach((mode: BlendMode) => { + const container = document.createElement('div'); + container.className = 'blend-mode-container'; + container.style.cssText = `margin-bottom: 5px;`; + + const option = document.createElement('div'); + option.style.cssText = `padding: 5px 10px; color: white; cursor: pointer; transition: background-color 0.2s;`; + option.textContent = `${mode.label} (${mode.name})`; + + const slider = document.createElement('input'); + slider.type = 'range'; + slider.min = '0'; + slider.max = '100'; + const selectedLayer = this.canvas.canvasSelection.selectedLayers[0]; + slider.value = selectedLayer ? String(Math.round(selectedLayer.opacity * 100)) : '100'; + slider.style.cssText = `width: 100%; margin: 5px 0; display: none;`; + + if (selectedLayer && selectedLayer.blendMode === mode.name) { + slider.style.display = 'block'; + option.style.backgroundColor = '#3a3a3a'; + } + + option.onclick = () => { + content.querySelectorAll('input[type="range"]').forEach(s => s.style.display = 'none'); + content.querySelectorAll('.blend-mode-container div').forEach(d => d.style.backgroundColor = ''); + + slider.style.display = 'block'; + option.style.backgroundColor = '#3a3a3a'; + + if (selectedLayer) { + selectedLayer.blendMode = mode.name; + this.canvas.render(); + } + }; + + slider.addEventListener('input', () => { + if (selectedLayer) { + selectedLayer.opacity = parseInt(slider.value, 10) / 100; + this.canvas.render(); + } + }); + + slider.addEventListener('change', async () => { + if (selectedLayer) { + selectedLayer.opacity = parseInt(slider.value, 10) / 100; + this.canvas.render(); + const saveWithFallback = async (fileName: string) => { + try { + const uniqueFileName = generateUniqueFileName(fileName, this.canvas.node.id); + return await this.canvas.canvasIO.saveToServer(uniqueFileName); + } catch (error) { + console.warn(`Failed to save with unique name, falling back to original: ${fileName}`, error); + return await this.canvas.canvasIO.saveToServer(fileName); + } + }; + if (this.canvas.widget) { + await saveWithFallback(this.canvas.widget.value); + if (this.canvas.node) { + app.graph.runStep(); + } + } + } + }); + + container.appendChild(option); + container.appendChild(slider); + content.appendChild(container); + }); + + const container = this.canvas.canvas.parentElement || document.body; + container.appendChild(menu); + + const closeMenu = (e: MouseEvent) => { + if (e.target instanceof Node && !menu.contains(e.target) && !isDragging) { + this.closeBlendModeMenu(); + document.removeEventListener('mousedown', closeMenu); + } + }; + setTimeout(() => document.addEventListener('mousedown', closeMenu), 0); + } + + closeBlendModeMenu(): void { + const menu = document.getElementById('blend-mode-menu'); + if (menu && menu.parentNode) { + menu.parentNode.removeChild(menu); + } + } + + showOpacitySlider(mode: string): void { + const slider = document.createElement('input'); + slider.type = 'range'; + slider.min = '0'; + slider.max = '100'; + slider.value = String(this.blendOpacity); + slider.className = 'blend-opacity-slider'; + + slider.addEventListener('input', (e) => { + this.blendOpacity = parseInt((e.target as HTMLInputElement).value, 10); + }); + + const modeElement = document.querySelector(`[data-blend-mode="${mode}"]`); + if (modeElement) { + modeElement.appendChild(slider); + } + } + + async getFlattenedCanvasWithMaskAsBlob(): Promise { + 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'); + if (!tempCtx) { + reject(new Error("Could not create canvas context")); + return; + } + + const sortedLayers = [...this.canvas.layers].sort((a: Layer, b: Layer) => a.zIndex - b.zIndex); + + sortedLayers.forEach((layer: Layer) => { + if (!layer.image) return; + + tempCtx.save(); + tempCtx.globalCompositeOperation = layer.blendMode as any || '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'); + if (!tempMaskCtx) { + reject(new Error("Could not create mask canvas context")); + return; + } + + 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 { + resolve(null); + } + }, 'image/png'); + }); + } + + async getFlattenedCanvasAsBlob(): Promise { + 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'); + if (!tempCtx) { + reject(new Error("Could not create canvas context")); + return; + } + + const sortedLayers = [...this.canvas.layers].sort((a: Layer, b: Layer) => a.zIndex - b.zIndex); + + sortedLayers.forEach((layer: Layer) => { + if (!layer.image) return; + + tempCtx.save(); + tempCtx.globalCompositeOperation = layer.blendMode as any || '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(); + }); + + tempCanvas.toBlob((blob) => { + if (blob) { + resolve(blob); + } else { + resolve(null); + } + }, 'image/png'); + }); + } + + async getFlattenedCanvasForMaskEditor(): Promise { + return this.getFlattenedCanvasWithMaskAsBlob(); + } + + async getFlattenedSelectionAsBlob(): Promise { + if (this.canvas.canvasSelection.selectedLayers.length === 0) { + return null; + } + + return new Promise((resolve, reject) => { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => { + const centerX = layer.x + layer.width / 2; + const centerY = layer.y + layer.height / 2; + const rad = layer.rotation * Math.PI / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + + const halfW = layer.width / 2; + const halfH = layer.height / 2; + + const corners = [ + { x: -halfW, y: -halfH }, + { x: halfW, y: -halfH }, + { x: halfW, y: halfH }, + { x: -halfW, y: halfH } + ]; + + corners.forEach(p => { + const worldX = centerX + (p.x * cos - p.y * sin); + const worldY = centerY + (p.x * sin + p.y * cos); + + minX = Math.min(minX, worldX); + minY = Math.min(minY, worldY); + maxX = Math.max(maxX, worldX); + maxY = Math.max(maxY, worldY); + }); + }); + + const newWidth = Math.ceil(maxX - minX); + const newHeight = Math.ceil(maxY - minY); + + if (newWidth <= 0 || newHeight <= 0) { + resolve(null); + return; + } + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = newWidth; + tempCanvas.height = newHeight; + const tempCtx = tempCanvas.getContext('2d'); + if (!tempCtx) { + reject(new Error("Could not create canvas context")); + return; + } + + tempCtx.translate(-minX, -minY); + + const sortedSelection = [...this.canvas.canvasSelection.selectedLayers].sort((a: Layer, b: Layer) => a.zIndex - b.zIndex); + + sortedSelection.forEach((layer: Layer) => { + if (!layer.image) return; + + tempCtx.save(); + tempCtx.globalCompositeOperation = layer.blendMode as any || '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(); + }); + + tempCanvas.toBlob((blob) => { + resolve(blob); + }, 'image/png'); + }); + } + + async fuseLayers(): Promise { + if (this.canvas.canvasSelection.selectedLayers.length < 2) { + alert("Please select at least 2 layers to fuse."); + return; + } + + log.info(`Fusing ${this.canvas.canvasSelection.selectedLayers.length} selected layers`); + + try { + this.canvas.saveState(); + + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => { + const centerX = layer.x + layer.width / 2; + const centerY = layer.y + layer.height / 2; + const rad = layer.rotation * Math.PI / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + + const halfW = layer.width / 2; + const halfH = layer.height / 2; + + const corners = [ + { x: -halfW, y: -halfH }, + { x: halfW, y: -halfH }, + { x: halfW, y: halfH }, + { x: -halfW, y: halfH } + ]; + + corners.forEach(p => { + const worldX = centerX + (p.x * cos - p.y * sin); + const worldY = centerY + (p.x * sin + p.y * cos); + minX = Math.min(minX, worldX); + minY = Math.min(minY, worldY); + maxX = Math.max(maxX, worldX); + maxY = Math.max(maxY, worldY); + }); + }); + + const fusedWidth = Math.ceil(maxX - minX); + const fusedHeight = Math.ceil(maxY - minY); + + if (fusedWidth <= 0 || fusedHeight <= 0) { + log.warn("Calculated fused layer dimensions are invalid"); + alert("Cannot fuse layers: invalid dimensions calculated."); + return; + } + + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = fusedWidth; + tempCanvas.height = fusedHeight; + const tempCtx = tempCanvas.getContext('2d'); + if (!tempCtx) throw new Error("Could not create canvas context"); + + tempCtx.translate(-minX, -minY); + + const sortedSelection = [...this.canvas.canvasSelection.selectedLayers].sort((a: Layer, b: Layer) => a.zIndex - b.zIndex); + + sortedSelection.forEach((layer: Layer) => { + if (!layer.image) return; + + tempCtx.save(); + tempCtx.globalCompositeOperation = layer.blendMode as any || '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 fusedImage = new Image(); + fusedImage.src = tempCanvas.toDataURL(); + await new Promise((resolve, reject) => { + fusedImage.onload = resolve; + fusedImage.onerror = reject; + }); + + const minZIndex = Math.min(...this.canvas.canvasSelection.selectedLayers.map((layer: Layer) => layer.zIndex)); + const imageId = generateUUID(); + await saveImage(imageId, fusedImage.src); + this.canvas.imageCache.set(imageId, fusedImage.src); + + const fusedLayer: Layer = { + id: generateUUID(), + image: fusedImage, + imageId: imageId, + name: 'Fused Layer', + x: minX, + y: minY, + width: fusedWidth, + height: fusedHeight, + originalWidth: fusedWidth, + originalHeight: fusedHeight, + rotation: 0, + zIndex: minZIndex, + blendMode: 'normal', + opacity: 1 + }; + + this.canvas.layers = this.canvas.layers.filter((layer: Layer) => !this.canvas.canvasSelection.selectedLayers.includes(layer)); + this.canvas.layers.push(fusedLayer); + this.canvas.layers.sort((a: Layer, b: Layer) => a.zIndex - b.zIndex); + this.canvas.layers.forEach((layer: Layer, index: number) => { + layer.zIndex = index; + }); + + this.canvas.updateSelection([fusedLayer]); + this.canvas.render(); + this.canvas.saveState(); + + if (this.canvas.canvasLayersPanel) { + this.canvas.canvasLayersPanel.onLayersChanged(); + } + + log.info("Layers fused successfully", { + originalLayerCount: sortedSelection.length, + fusedDimensions: { width: fusedWidth, height: fusedHeight }, + fusedPosition: { x: minX, y: minY } + }); + + } catch (error: any) { + log.error("Error during layer fusion:", error); + alert(`Error fusing layers: ${error.message}`); + } + } +} diff --git a/src/CanvasLayersPanel.ts b/src/CanvasLayersPanel.ts new file mode 100644 index 0000000..464bdb5 --- /dev/null +++ b/src/CanvasLayersPanel.ts @@ -0,0 +1,613 @@ +import { createModuleLogger } from "./utils/LoggerUtils.js"; +import type { Canvas } from './Canvas'; +import type { Layer } from './types'; + +const log = createModuleLogger('CanvasLayersPanel'); + +export class CanvasLayersPanel { + private canvas: Canvas; + private container: HTMLElement | null; + private layersContainer: HTMLElement | null; + private draggedElements: Layer[]; + private dragInsertionLine: HTMLElement | null; + private isMultiSelecting: boolean; + private lastSelectedIndex: number; + + constructor(canvas: Canvas) { + this.canvas = canvas; + this.container = null; + this.layersContainer = null; + this.draggedElements = []; + this.dragInsertionLine = null; + this.isMultiSelecting = false; + this.lastSelectedIndex = -1; + + this.handleLayerClick = this.handleLayerClick.bind(this); + this.handleDragStart = this.handleDragStart.bind(this); + this.handleDragOver = this.handleDragOver.bind(this); + this.handleDragEnd = this.handleDragEnd.bind(this); + this.handleDrop = this.handleDrop.bind(this); + + log.info('CanvasLayersPanel initialized'); + } + + createPanelStructure(): HTMLElement { + this.container = document.createElement('div'); + this.container.className = 'layers-panel'; + this.container.tabIndex = 0; // Umożliwia fokus na panelu + this.container.innerHTML = ` +
+ Layers +
+ +
+
+
+ +
+ `; + + this.layersContainer = this.container.querySelector('#layers-container'); + + this.injectStyles(); + + // Setup event listeners dla przycisków + this.setupControlButtons(); + + // Dodaj listener dla klawiatury, aby usuwanie działało z panelu + this.container.addEventListener('keydown', (e: KeyboardEvent) => { + if (e.key === 'Delete' || e.key === 'Backspace') { + e.preventDefault(); + e.stopPropagation(); + this.deleteSelectedLayers(); + } + }); + + log.debug('Panel structure created'); + return this.container; + } + + injectStyles(): void { + const styleId = 'layers-panel-styles'; + if (document.getElementById(styleId)) { + return; // Style już istnieją + } + + const style = document.createElement('style'); + style.id = styleId; + style.textContent = ` + .layers-panel { + background: #2a2a2a; + border: 1px solid #3a3a3a; + border-radius: 4px; + padding: 8px; + height: 100%; + overflow: hidden; + font-family: Arial, sans-serif; + font-size: 12px; + color: #ffffff; + user-select: none; + display: flex; + flex-direction: column; + } + + .layers-panel-header { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 8px; + border-bottom: 1px solid #3a3a3a; + margin-bottom: 8px; + } + + .layers-panel-title { + font-weight: bold; + color: #ffffff; + } + + .layers-panel-controls { + display: flex; + gap: 4px; + } + + .layers-btn { + background: #3a3a3a; + border: 1px solid #4a4a4a; + color: #ffffff; + padding: 4px 8px; + border-radius: 3px; + cursor: pointer; + font-size: 11px; + } + + .layers-btn:hover { + background: #4a4a4a; + } + + .layers-btn:active { + background: #5a5a5a; + } + + .layers-container { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + } + + .layer-row { + display: flex; + align-items: center; + padding: 6px 4px; + margin-bottom: 2px; + border-radius: 3px; + cursor: pointer; + transition: background-color 0.15s ease; + position: relative; + gap: 6px; + } + + .layer-row:hover { + background: rgba(255, 255, 255, 0.05); + } + + .layer-row.selected { + background: #2d5aa0 !important; + box-shadow: inset 0 0 0 1px #4a7bc8; + } + + .layer-row.dragging { + opacity: 0.6; + } + + + .layer-thumbnail { + width: 48px; + height: 48px; + border: 1px solid #4a4a4a; + border-radius: 2px; + background: transparent; + position: relative; + flex-shrink: 0; + overflow: hidden; + } + + .layer-thumbnail canvas { + width: 100%; + height: 100%; + display: block; + } + + .layer-thumbnail::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-image: + linear-gradient(45deg, #555 25%, transparent 25%), + linear-gradient(-45deg, #555 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, #555 75%), + linear-gradient(-45deg, transparent 75%, #555 75%); + background-size: 8px 8px; + background-position: 0 0, 0 4px, 4px -4px, -4px 0px; + z-index: 1; + } + + .layer-thumbnail canvas { + position: relative; + z-index: 2; + } + + .layer-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding: 2px 4px; + border-radius: 2px; + color: #ffffff; + } + + .layer-name.editing { + background: #4a4a4a; + border: 1px solid #6a6a6a; + outline: none; + color: #ffffff; + } + + .layer-name input { + background: transparent; + border: none; + color: #ffffff; + font-size: 12px; + width: 100%; + outline: none; + } + + .drag-insertion-line { + position: absolute; + left: 0; + right: 0; + height: 2px; + background: #4a7bc8; + border-radius: 1px; + z-index: 1000; + box-shadow: 0 0 4px rgba(74, 123, 200, 0.6); + } + + .layers-container::-webkit-scrollbar { + width: 6px; + } + + .layers-container::-webkit-scrollbar-track { + background: #2a2a2a; + } + + .layers-container::-webkit-scrollbar-thumb { + background: #4a4a4a; + border-radius: 3px; + } + + .layers-container::-webkit-scrollbar-thumb:hover { + background: #5a5a5a; + } + `; + + document.head.appendChild(style); + log.debug('Styles injected'); + } + + setupControlButtons(): void { + if (!this.container) return; + const deleteBtn = this.container.querySelector('#delete-layer-btn'); + + deleteBtn?.addEventListener('click', () => { + log.info('Delete layer button clicked'); + this.deleteSelectedLayers(); + }); + } + + renderLayers(): void { + if (!this.layersContainer) { + log.warn('Layers container not initialized'); + return; + } + + // Wyczyść istniejącą zawartość + this.layersContainer.innerHTML = ''; + + // Usuń linię wstawiania jeśli istnieje + this.removeDragInsertionLine(); + + // Sortuj warstwy według zIndex (od najwyższej do najniższej) + const sortedLayers = [...this.canvas.layers].sort((a: Layer, b: Layer) => b.zIndex - a.zIndex); + + sortedLayers.forEach((layer: Layer, index: number) => { + const layerElement = this.createLayerElement(layer, index); + if(this.layersContainer) + this.layersContainer.appendChild(layerElement); + }); + + log.debug(`Rendered ${sortedLayers.length} layers`); + } + + createLayerElement(layer: Layer, index: number): HTMLElement { + const layerRow = document.createElement('div'); + layerRow.className = 'layer-row'; + layerRow.draggable = true; + layerRow.dataset.layerIndex = String(index); + + const isSelected = this.canvas.canvasSelection.selectedLayers.includes(layer); + if (isSelected) { + layerRow.classList.add('selected'); + } + + // Ustawienie domyślnych właściwości jeśli nie istnieją + if (!layer.name) { + layer.name = this.ensureUniqueName(`Layer ${layer.zIndex + 1}`, layer); + } else { + // Sprawdź unikalność istniejącej nazwy (np. przy duplikowaniu) + layer.name = this.ensureUniqueName(layer.name, layer); + } + + layerRow.innerHTML = ` +
+ ${layer.name} + `; + + const thumbnailContainer = layerRow.querySelector('.layer-thumbnail'); + if (thumbnailContainer) { + this.generateThumbnail(layer, thumbnailContainer); + } + + this.setupLayerEventListeners(layerRow, layer, index); + + return layerRow; + } + + generateThumbnail(layer: Layer, thumbnailContainer: HTMLElement): void { + if (!layer.image) { + thumbnailContainer.style.background = '#4a4a4a'; + return; + } + + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + if (!ctx) return; + canvas.width = 48; + canvas.height = 48; + + const scale = Math.min(48 / layer.image.width, 48 / layer.image.height); + const scaledWidth = layer.image.width * scale; + const scaledHeight = layer.image.height * scale; + + // Wycentruj obraz + const x = (48 - scaledWidth) / 2; + const y = (48 - scaledHeight) / 2; + + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(layer.image, x, y, scaledWidth, scaledHeight); + + thumbnailContainer.appendChild(canvas); + } + + setupLayerEventListeners(layerRow: HTMLElement, layer: Layer, index: number): void { + layerRow.addEventListener('mousedown', (e: MouseEvent) => { + const nameElement = layerRow.querySelector('.layer-name'); + if (nameElement && nameElement.classList.contains('editing')) { + return; + } + this.handleLayerClick(e, layer, index); + }); + + layerRow.addEventListener('dblclick', (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + const nameElement = layerRow.querySelector('.layer-name'); + if (nameElement) { + this.startEditingLayerName(nameElement, layer); + } + }); + + layerRow.addEventListener('dragstart', (e: DragEvent) => this.handleDragStart(e, layer, index)); + layerRow.addEventListener('dragover', this.handleDragOver.bind(this)); + layerRow.addEventListener('dragend', this.handleDragEnd.bind(this)); + layerRow.addEventListener('drop', (e: DragEvent) => this.handleDrop(e, index)); + } + + handleLayerClick(e: MouseEvent, layer: Layer, index: number): void { + const isCtrlPressed = e.ctrlKey || e.metaKey; + const isShiftPressed = e.shiftKey; + + // Aktualizuj wewnętrzny stan zaznaczenia w obiekcie canvas + // Ta funkcja NIE powinna już wywoływać onSelectionChanged w panelu. + this.canvas.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index); + + // Aktualizuj tylko wygląd (klasy CSS), bez niszczenia DOM + this.updateSelectionAppearance(); + + log.debug(`Layer clicked: ${layer.name}, selection count: ${this.canvas.canvasSelection.selectedLayers.length}`); + } + + + startEditingLayerName(nameElement: HTMLElement, layer: Layer): void { + const currentName = layer.name; + nameElement.classList.add('editing'); + + const input = document.createElement('input'); + input.type = 'text'; + input.value = currentName; + input.style.width = '100%'; + + nameElement.innerHTML = ''; + nameElement.appendChild(input); + + input.focus(); + input.select(); + + const finishEditing = () => { + let newName = input.value.trim() || `Layer ${layer.zIndex + 1}`; + newName = this.ensureUniqueName(newName, layer); + layer.name = newName; + nameElement.classList.remove('editing'); + nameElement.textContent = newName; + + this.canvas.saveState(); + log.info(`Layer renamed to: ${newName}`); + }; + + input.addEventListener('blur', finishEditing); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + finishEditing(); + } else if (e.key === 'Escape') { + nameElement.classList.remove('editing'); + nameElement.textContent = currentName; + } + }); + } + + + ensureUniqueName(proposedName: string, currentLayer: Layer): string { + const existingNames = this.canvas.layers + .filter((layer: Layer) => layer !== currentLayer) + .map((layer: Layer) => layer.name); + + if (!existingNames.includes(proposedName)) { + return proposedName; + } + + // Sprawdź czy nazwa już ma numerację w nawiasach + const match = proposedName.match(/^(.+?)\s*\((\d+)\)$/); + let baseName, startNumber; + + if (match) { + baseName = match[1].trim(); + startNumber = parseInt(match[2]) + 1; + } else { + baseName = proposedName; + startNumber = 1; + } + + // Znajdź pierwszą dostępną numerację + let counter = startNumber; + let uniqueName; + + do { + uniqueName = `${baseName} (${counter})`; + counter++; + } while (existingNames.includes(uniqueName)); + + return uniqueName; + } + + deleteSelectedLayers(): void { + if (this.canvas.canvasSelection.selectedLayers.length === 0) { + log.debug('No layers selected for deletion'); + return; + } + + log.info(`Deleting ${this.canvas.canvasSelection.selectedLayers.length} selected layers`); + this.canvas.removeSelectedLayers(); + this.renderLayers(); + } + + handleDragStart(e: DragEvent, layer: Layer, index: number): void { + if (!this.layersContainer || !e.dataTransfer) return; + const editingElement = this.layersContainer.querySelector('.layer-name.editing'); + if (editingElement) { + e.preventDefault(); + return; + } + + // Jeśli przeciągana warstwa nie jest zaznaczona, zaznacz ją + if (!this.canvas.canvasSelection.selectedLayers.includes(layer)) { + this.canvas.updateSelection([layer]); + this.renderLayers(); + } + + this.draggedElements = [...this.canvas.canvasSelection.selectedLayers]; + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', ''); + + this.layersContainer.querySelectorAll('.layer-row').forEach((row: Element, idx: number) => { + const sortedLayers = [...this.canvas.layers].sort((a: Layer, b: Layer) => b.zIndex - a.zIndex); + if (this.draggedElements.includes(sortedLayers[idx])) { + row.classList.add('dragging'); + } + }); + + log.debug(`Started dragging ${this.draggedElements.length} layers`); + } + + handleDragOver(e: DragEvent): void { + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'; + + const layerRow = e.currentTarget as HTMLElement; + const rect = layerRow.getBoundingClientRect(); + const midpoint = rect.top + rect.height / 2; + const isUpperHalf = e.clientY < midpoint; + + this.showDragInsertionLine(layerRow, isUpperHalf); + } + + showDragInsertionLine(targetRow: HTMLElement, isUpperHalf: boolean): void { + this.removeDragInsertionLine(); + + const line = document.createElement('div'); + line.className = 'drag-insertion-line'; + + if (isUpperHalf) { + line.style.top = '-1px'; + } else { + line.style.bottom = '-1px'; + } + + targetRow.style.position = 'relative'; + targetRow.appendChild(line); + this.dragInsertionLine = line; + } + + removeDragInsertionLine(): void { + if (this.dragInsertionLine) { + this.dragInsertionLine.remove(); + this.dragInsertionLine = null; + } + } + + handleDrop(e: DragEvent, targetIndex: number): void { + e.preventDefault(); + this.removeDragInsertionLine(); + + if (this.draggedElements.length === 0 || !(e.currentTarget instanceof HTMLElement)) return; + + const rect = e.currentTarget.getBoundingClientRect(); + const midpoint = rect.top + rect.height / 2; + const isUpperHalf = e.clientY < midpoint; + + // Oblicz docelowy indeks + let insertIndex = targetIndex; + if (!isUpperHalf) { + insertIndex = targetIndex + 1; + } + + // Użyj nowej, centralnej funkcji do przesuwania warstw + this.canvas.canvasLayers.moveLayers(this.draggedElements, { toIndex: insertIndex }); + + log.info(`Dropped ${this.draggedElements.length} layers at position ${insertIndex}`); + } + + handleDragEnd(e: DragEvent): void { + this.removeDragInsertionLine(); + if (!this.layersContainer) return; + this.layersContainer.querySelectorAll('.layer-row').forEach((row: Element) => { + row.classList.remove('dragging'); + }); + + this.draggedElements = []; + } + + + onLayersChanged(): void { + this.renderLayers(); + } + + updateSelectionAppearance(): void { + if (!this.layersContainer) return; + const sortedLayers = [...this.canvas.layers].sort((a: Layer, b: Layer) => b.zIndex - a.zIndex); + const layerRows = this.layersContainer.querySelectorAll('.layer-row'); + + layerRows.forEach((row: Element, index: number) => { + const layer = sortedLayers[index]; + if (this.canvas.canvasSelection.selectedLayers.includes(layer)) { + row.classList.add('selected'); + } else { + row.classList.remove('selected'); + } + }); + } + + /** + * Aktualizuje panel gdy zmieni się zaznaczenie (wywoływane z zewnątrz). + * Zamiast pełnego renderowania, tylko aktualizujemy wygląd. + */ + onSelectionChanged(): void { + this.updateSelectionAppearance(); + } + + destroy(): void { + if (this.container && this.container.parentNode) { + this.container.parentNode.removeChild(this.container); + } + this.container = null; + this.layersContainer = null; + this.draggedElements = []; + this.removeDragInsertionLine(); + + log.info('CanvasLayersPanel destroyed'); + } +} diff --git a/src/CanvasMask.ts b/src/CanvasMask.ts new file mode 100644 index 0000000..ff7df6c --- /dev/null +++ b/src/CanvasMask.ts @@ -0,0 +1,564 @@ +// @ts-ignore +import {app} from "../../scripts/app.js"; +// @ts-ignore +import {ComfyApp} from "../../scripts/app.js"; +// @ts-ignore +import {api} from "../../scripts/api.js"; +import { createModuleLogger } from "./utils/LoggerUtils.js"; +import { mask_editor_showing, mask_editor_listen_for_cancel } from "./utils/mask_utils.js"; + +const log = createModuleLogger('CanvasMask'); + +export class CanvasMask { + canvas: any; + editorWasShowing: any; + maskEditorCancelled: any; + maskTool: any; + node: any; + pendingMask: any; + savedMaskState: any; + constructor(canvas: any) { + this.canvas = canvas; + this.node = canvas.node; + this.maskTool = canvas.maskTool; + + this.savedMaskState = null; + this.maskEditorCancelled = false; + this.pendingMask = null; + this.editorWasShowing = false; + } + + /** + * 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: any = null, sendCleanImage = true) { + log.info('Starting mask editor', { + hasPredefinedMask: !!predefinedMask, + sendCleanImage, + layersCount: this.canvas.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.canvas.canvasLayers.getFlattenedCanvasAsBlob(); + } else { + log.debug('Getting flattened canvas for mask editor (with mask)'); + blob = await this.canvas.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 as Error).message}`); + } + } + + + /** + * 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') as HTMLCanvasElement; + if (maskCanvas) { + editorReady = !!(maskCanvas.getContext('2d') && maskCanvas.width > 0 && maskCanvas.height > 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: any) { + 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: any) { + + 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: any) { + + const maskCanvas = document.getElementById('maskCanvas') as HTMLCanvasElement; + if (!maskCanvas) { + throw new Error("Old mask editor canvas not found"); + } + + const maskCtx = maskCanvas.getContext('2d', {willReadFrequently: true}); + if (!maskCtx) { + throw new Error("Old mask editor context not found"); + } + + 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: any, targetWidth: any, targetHeight: any, maskColor: any) { + // Współrzędne przesunięcia (pan) widoku edytora + const panX = this.maskTool.x; + const panY = this.maskTool.y; + + log.info("Processing mask for editor:", { + sourceSize: {width: maskData.width, height: maskData.height}, + targetSize: {width: targetWidth, height: targetHeight}, + viewportPan: {x: panX, y: panY} + }); + + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = targetWidth; + tempCanvas.height = targetHeight; + const tempCtx = tempCanvas.getContext('2d', {willReadFrequently: true}); + + const sourceX = -panX; + const sourceY = -panY; + + if (tempCtx) { + tempCtx.drawImage( + maskData, // Źródło: pełna maska z "output area" + sourceX, // sx: Prawdziwa współrzędna X na dużej masce (np. 1000) + sourceY, // sy: Prawdziwa współrzędna Y na dużej masce (np. 1000) + targetWidth, // sWidth: Szerokość wycinanego fragmentu + targetHeight, // sHeight: Wysokość wycinanego fragmentu + 0, // dx: Gdzie wkleić w płótnie docelowym (zawsze 0) + 0, // dy: Gdzie wkleić w płótnie docelowym (zawsze 0) + targetWidth, // dWidth: Szerokość wklejanego obrazu + targetHeight // dHeight: Wysokość wklejanego obrazu + ); + } + + log.info("Mask viewport cropped correctly.", { + source: "maskData", + cropArea: {x: sourceX, y: sourceY, width: targetWidth, height: targetHeight} + }); + + // Reszta kodu (zmiana koloru) pozostaje bez zmian + if (tempCtx) { + 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]; + if (alpha > 0) { + data[i] = maskColor.r; + data[i + 1] = maskColor.g; + data[i + 2] = maskColor.b; + } + } + tempCtx.putImageData(imageData, 0, 0); + } + log.info("Mask processing completed - color 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}); + if (savedCtx) { + 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: any) { + 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.canvas.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.canvas.width; + tempCanvas.height = this.canvas.height; + const tempCtx = tempCanvas.getContext('2d', {willReadFrequently: true}); + + if (tempCtx) { + tempCtx.drawImage(resultImage, 0, 0, this.canvas.width, this.canvas.height); + + log.debug("Processing image data to create mask"); + const imageData = tempCtx.getImageData(0, 0, this.canvas.width, this.canvas.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.canvas.width, this.canvas.height); + + maskCtx.drawImage(maskAsImage, destX, destY); + + this.canvas.render(); + this.canvas.saveState(); + + log.debug("Creating new preview image"); + const new_preview = new Image(); + + const blob = await this.canvas.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.canvas.render(); + + this.savedMaskState = null; + log.info("Mask editor result processed successfully"); + } +} diff --git a/src/CanvasRenderer.ts b/src/CanvasRenderer.ts new file mode 100644 index 0000000..0d78cf2 --- /dev/null +++ b/src/CanvasRenderer.ts @@ -0,0 +1,369 @@ +import {createModuleLogger} from "./utils/LoggerUtils.js"; + +const log = createModuleLogger('CanvasRenderer'); + +export class CanvasRenderer { + canvas: any; + isDirty: any; + lastRenderTime: any; + renderAnimationFrame: any; + renderInterval: any; + constructor(canvas: any) { + this.canvas = canvas; + this.renderAnimationFrame = null; + this.lastRenderTime = 0; + this.renderInterval = 1000 / 60; + this.isDirty = false; + } + + render() { + if (this.renderAnimationFrame) { + this.isDirty = true; + return; + } + this.renderAnimationFrame = requestAnimationFrame(() => { + const now = performance.now(); + if (now - this.lastRenderTime >= this.renderInterval) { + this.lastRenderTime = now; + this.actualRender(); + this.isDirty = false; + } + + if (this.isDirty) { + this.renderAnimationFrame = null; + this.render(); + } else { + this.renderAnimationFrame = null; + } + }); + } + + actualRender() { + if (this.canvas.offscreenCanvas.width !== this.canvas.canvas.clientWidth || + this.canvas.offscreenCanvas.height !== this.canvas.canvas.clientHeight) { + const newWidth = Math.max(1, this.canvas.canvas.clientWidth); + const newHeight = Math.max(1, this.canvas.canvas.clientHeight); + this.canvas.offscreenCanvas.width = newWidth; + this.canvas.offscreenCanvas.height = newHeight; + } + + const ctx = this.canvas.offscreenCtx; + + ctx.fillStyle = '#606060'; + ctx.fillRect(0, 0, this.canvas.offscreenCanvas.width, this.canvas.offscreenCanvas.height); + + ctx.save(); + ctx.scale(this.canvas.viewport.zoom, this.canvas.viewport.zoom); + ctx.translate(-this.canvas.viewport.x, -this.canvas.viewport.y); + + this.drawGrid(ctx); + + const sortedLayers = [...this.canvas.layers].sort((a, b) => a.zIndex - b.zIndex); + sortedLayers.forEach(layer => { + if (!layer.image) return; + ctx.save(); + const currentTransform = ctx.getTransform(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.globalCompositeOperation = layer.blendMode || 'normal'; + ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1; + ctx.setTransform(currentTransform); + const centerX = layer.x + layer.width / 2; + const centerY = layer.y + layer.height / 2; + ctx.translate(centerX, centerY); + ctx.rotate(layer.rotation * Math.PI / 180); + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage( + layer.image, -layer.width / 2, -layer.height / 2, + layer.width, + layer.height + ); + if (layer.mask) { + } + if (this.canvas.canvasSelection.selectedLayers.includes(layer)) { + this.drawSelectionFrame(ctx, layer); + } + ctx.restore(); + }); + + this.drawCanvasOutline(ctx); + this.drawPendingGenerationAreas(ctx); // Draw snapshot outlines + const maskImage = this.canvas.maskTool.getMask(); + if (maskImage && this.canvas.maskTool.isOverlayVisible) { + + ctx.save(); + + if (this.canvas.maskTool.isActive) { + ctx.globalCompositeOperation = 'source-over'; + ctx.globalAlpha = 0.5; + } else { + ctx.globalCompositeOperation = 'source-over'; + ctx.globalAlpha = 1.0; + } + + ctx.drawImage(maskImage, this.canvas.maskTool.x, this.canvas.maskTool.y); + + ctx.globalAlpha = 1.0; + ctx.restore(); + } + + this.renderInteractionElements(ctx); + this.renderLayerInfo(ctx); + + ctx.restore(); + + if (this.canvas.canvas.width !== this.canvas.offscreenCanvas.width || + this.canvas.canvas.height !== this.canvas.offscreenCanvas.height) { + this.canvas.canvas.width = this.canvas.offscreenCanvas.width; + this.canvas.canvas.height = this.canvas.offscreenCanvas.height; + } + this.canvas.ctx.drawImage(this.canvas.offscreenCanvas, 0, 0); + + // Update Batch Preview UI positions + if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) { + this.canvas.batchPreviewManagers.forEach((manager: any) => { + manager.updateScreenPosition(this.canvas.viewport); + }); + } + } + + renderInteractionElements(ctx: any) { + const interaction = this.canvas.interaction; + + if (interaction.mode === 'resizingCanvas' && interaction.canvasResizeRect) { + const rect = interaction.canvasResizeRect; + ctx.save(); + ctx.strokeStyle = 'rgba(0, 255, 0, 0.8)'; + ctx.lineWidth = 2 / this.canvas.viewport.zoom; + ctx.setLineDash([8 / this.canvas.viewport.zoom, 4 / this.canvas.viewport.zoom]); + ctx.strokeRect(rect.x, rect.y, rect.width, rect.height); + ctx.setLineDash([]); + ctx.restore(); + if (rect.width > 0 && rect.height > 0) { + const text = `${Math.round(rect.width)}x${Math.round(rect.height)}`; + const textWorldX = rect.x + rect.width / 2; + const textWorldY = rect.y + rect.height + (20 / this.canvas.viewport.zoom); + + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + const screenX = (textWorldX - this.canvas.viewport.x) * this.canvas.viewport.zoom; + const screenY = (textWorldY - this.canvas.viewport.y) * this.canvas.viewport.zoom; + ctx.font = "14px sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + const textMetrics = ctx.measureText(text); + const bgWidth = textMetrics.width + 10; + const bgHeight = 22; + ctx.fillStyle = "rgba(0, 128, 0, 0.7)"; + ctx.fillRect(screenX - bgWidth / 2, screenY - bgHeight / 2, bgWidth, bgHeight); + ctx.fillStyle = "white"; + ctx.fillText(text, screenX, screenY); + ctx.restore(); + } + } + + if (interaction.mode === 'movingCanvas' && interaction.canvasMoveRect) { + const rect = interaction.canvasMoveRect; + ctx.save(); + ctx.strokeStyle = 'rgba(0, 150, 255, 0.8)'; + ctx.lineWidth = 2 / this.canvas.viewport.zoom; + ctx.setLineDash([10 / this.canvas.viewport.zoom, 5 / this.canvas.viewport.zoom]); + ctx.strokeRect(rect.x, rect.y, rect.width, rect.height); + ctx.setLineDash([]); + ctx.restore(); + + const text = `(${Math.round(rect.x)}, ${Math.round(rect.y)})`; + const textWorldX = rect.x + rect.width / 2; + const textWorldY = rect.y - (20 / this.canvas.viewport.zoom); + + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + const screenX = (textWorldX - this.canvas.viewport.x) * this.canvas.viewport.zoom; + const screenY = (textWorldY - this.canvas.viewport.y) * this.canvas.viewport.zoom; + ctx.font = "14px sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + const textMetrics = ctx.measureText(text); + const bgWidth = textMetrics.width + 10; + const bgHeight = 22; + ctx.fillStyle = "rgba(0, 100, 170, 0.7)"; + ctx.fillRect(screenX - bgWidth / 2, screenY - bgHeight / 2, bgWidth, bgHeight); + ctx.fillStyle = "white"; + ctx.fillText(text, screenX, screenY); + ctx.restore(); + } + } + + renderLayerInfo(ctx: any) { + if (this.canvas.canvasSelection.selectedLayer) { + this.canvas.canvasSelection.selectedLayers.forEach((layer: any) => { + if (!layer.image) return; + + const layerIndex = this.canvas.layers.indexOf(layer); + const currentWidth = Math.round(layer.width); + const currentHeight = Math.round(layer.height); + const rotation = Math.round(layer.rotation % 360); + let text = `${currentWidth}x${currentHeight} | ${rotation}° | Layer #${layerIndex + 1}`; + if (layer.originalWidth && layer.originalHeight) { + text += `\nOriginal: ${layer.originalWidth}x${layer.originalHeight}`; + } + const centerX = layer.x + layer.width / 2; + const centerY = layer.y + layer.height / 2; + const rad = layer.rotation * Math.PI / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + + const halfW = layer.width / 2; + const halfH = layer.height / 2; + + const localCorners = [ + {x: -halfW, y: -halfH}, + {x: halfW, y: -halfH}, + {x: halfW, y: halfH}, + {x: -halfW, y: halfH} + ]; + const worldCorners = localCorners.map(p => ({ + x: centerX + p.x * cos - p.y * sin, + y: centerY + p.x * sin + p.y * cos + })); + let minX = Infinity, maxX = -Infinity, maxY = -Infinity; + worldCorners.forEach(p => { + minX = Math.min(minX, p.x); + maxX = Math.max(maxX, p.x); + maxY = Math.max(maxY, p.y); + }); + const padding = 20 / this.canvas.viewport.zoom; + const textWorldX = (minX + maxX) / 2; + const textWorldY = maxY + padding; + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + + const screenX = (textWorldX - this.canvas.viewport.x) * this.canvas.viewport.zoom; + const screenY = (textWorldY - this.canvas.viewport.y) * this.canvas.viewport.zoom; + + ctx.font = "14px sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + const lines = text.split('\n'); + const textMetrics = lines.map(line => ctx.measureText(line)); + const textBgWidth = Math.max(...textMetrics.map(m => m.width)) + 10; + const lineHeight = 18; + const textBgHeight = lines.length * lineHeight + 4; + + ctx.fillStyle = "rgba(0, 0, 0, 0.7)"; + ctx.fillRect(screenX - textBgWidth / 2, screenY - textBgHeight / 2, textBgWidth, textBgHeight); + + ctx.fillStyle = "white"; + lines.forEach((line, index) => { + const yPos = screenY - (textBgHeight / 2) + (lineHeight / 2) + (index * lineHeight) + 2; + ctx.fillText(line, screenX, yPos); + }); + + ctx.restore(); + }); + } + } + + drawGrid(ctx: any) { + const gridSize = 64; + const lineWidth = 0.5 / this.canvas.viewport.zoom; + + const viewLeft = this.canvas.viewport.x; + const viewTop = this.canvas.viewport.y; + const viewRight = this.canvas.viewport.x + this.canvas.offscreenCanvas.width / this.canvas.viewport.zoom; + const viewBottom = this.canvas.viewport.y + this.canvas.offscreenCanvas.height / this.canvas.viewport.zoom; + + ctx.beginPath(); + ctx.strokeStyle = '#707070'; + ctx.lineWidth = lineWidth; + + for (let x = Math.floor(viewLeft / gridSize) * gridSize; x < viewRight; x += gridSize) { + ctx.moveTo(x, viewTop); + ctx.lineTo(x, viewBottom); + } + + for (let y = Math.floor(viewTop / gridSize) * gridSize; y < viewBottom; y += gridSize) { + ctx.moveTo(viewLeft, y); + ctx.lineTo(viewRight, y); + } + + ctx.stroke(); + } + + drawCanvasOutline(ctx: any) { + ctx.beginPath(); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)'; + ctx.lineWidth = 2 / this.canvas.viewport.zoom; + ctx.setLineDash([10 / this.canvas.viewport.zoom, 5 / this.canvas.viewport.zoom]); + + ctx.rect(0, 0, this.canvas.width, this.canvas.height); + + ctx.stroke(); + ctx.setLineDash([]); + } + + drawSelectionFrame(ctx: any, layer: any) { + const lineWidth = 2 / this.canvas.viewport.zoom; + const handleRadius = 5 / this.canvas.viewport.zoom; + ctx.strokeStyle = '#00ff00'; + ctx.lineWidth = lineWidth; + ctx.beginPath(); + ctx.rect(-layer.width / 2, -layer.height / 2, layer.width, layer.height); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(0, -layer.height / 2); + ctx.lineTo(0, -layer.height / 2 - 20 / this.canvas.viewport.zoom); + ctx.stroke(); + const handles = this.canvas.canvasLayers.getHandles(layer); + ctx.fillStyle = '#ffffff'; + ctx.strokeStyle = '#000000'; + ctx.lineWidth = 1 / this.canvas.viewport.zoom; + + for (const key in handles) { + const point = handles[key]; + ctx.beginPath(); + const localX = point.x - (layer.x + layer.width / 2); + const localY = point.y - (layer.y + layer.height / 2); + + const rad = -layer.rotation * Math.PI / 180; + const rotatedX = localX * Math.cos(rad) - localY * Math.sin(rad); + const rotatedY = localX * Math.sin(rad) + localY * Math.cos(rad); + + ctx.arc(rotatedX, rotatedY, handleRadius, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + } + } + + drawPendingGenerationAreas(ctx: any) { + const areasToDraw = []; + + // 1. Get areas from active managers + if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) { + this.canvas.batchPreviewManagers.forEach((manager: any) => { + if (manager.generationArea) { + areasToDraw.push(manager.generationArea); + } + }); + } + + // 2. Get the area from the pending context (if it exists) + if (this.canvas.pendingBatchContext && this.canvas.pendingBatchContext.outputArea) { + areasToDraw.push(this.canvas.pendingBatchContext.outputArea); + } + + if (areasToDraw.length === 0) { + return; + } + + // 3. Draw all collected areas + areasToDraw.forEach(area => { + ctx.save(); + ctx.strokeStyle = 'rgba(0, 150, 255, 0.9)'; // Blue color + ctx.lineWidth = 3 / this.canvas.viewport.zoom; + ctx.setLineDash([12 / this.canvas.viewport.zoom, 6 / this.canvas.viewport.zoom]); + ctx.strokeRect(area.x, area.y, area.width, area.height); + ctx.restore(); + }); + } +} diff --git a/src/CanvasSelection.ts b/src/CanvasSelection.ts new file mode 100644 index 0000000..c0151f1 --- /dev/null +++ b/src/CanvasSelection.ts @@ -0,0 +1,170 @@ +import { createModuleLogger } from "./utils/LoggerUtils.js"; + +const log = createModuleLogger('CanvasSelection'); + +export class CanvasSelection { + canvas: any; + onSelectionChange: any; + selectedLayer: any; + selectedLayers: any; + constructor(canvas: any) { + this.canvas = canvas; + this.selectedLayers = []; + this.selectedLayer = null; + this.onSelectionChange = null; + } + + /** + * Duplikuje zaznaczone warstwy (w pamięci, bez zapisu stanu) + */ + duplicateSelectedLayers() { + if (this.selectedLayers.length === 0) return []; + + const newLayers: any = []; + const sortedLayers = [...this.selectedLayers].sort((a,b) => a.zIndex - b.zIndex); + + sortedLayers.forEach(layer => { + const newLayer = { + ...layer, + id: `layer_${+new Date()}_${Math.random().toString(36).substr(2, 9)}`, + zIndex: this.canvas.layers.length, // Nowa warstwa zawsze na wierzchu + }; + this.canvas.layers.push(newLayer); + newLayers.push(newLayer); + }); + + // Aktualizuj zaznaczenie, co powiadomi panel (ale nie renderuje go całego) + this.updateSelection(newLayers); + + // Powiadom panel o zmianie struktury, aby się przerysował + if (this.canvas.canvasLayersPanel) { + this.canvas.canvasLayersPanel.onLayersChanged(); + } + + log.info(`Duplicated ${newLayers.length} layers (in-memory).`); + return newLayers; + } + + /** + * Aktualizuje zaznaczenie warstw i powiadamia wszystkie komponenty. + * To jest "jedyne źródło prawdy" o zmianie zaznaczenia. + * @param {Array} newSelection - Nowa lista zaznaczonych warstw + */ + updateSelection(newSelection: any) { + const previousSelection = this.selectedLayers.length; + this.selectedLayers = newSelection || []; + this.selectedLayer = this.selectedLayers.length > 0 ? this.selectedLayers[this.selectedLayers.length - 1] : null; + + // Sprawdź, czy zaznaczenie faktycznie się zmieniło, aby uniknąć pętli + const hasChanged = previousSelection !== this.selectedLayers.length || + this.selectedLayers.some((layer: any, i: any) => this.selectedLayers[i] !== (newSelection || [])[i]); + + if (!hasChanged && previousSelection > 0) { + // return; // Zablokowane na razie, może powodować problemy + } + + log.debug('Selection updated', { + previousCount: previousSelection, + newCount: this.selectedLayers.length, + selectedLayerIds: this.selectedLayers.map((l: any) => l.id || 'unknown') + }); + + // 1. Zrenderuj ponownie canvas, aby pokazać nowe kontrolki transformacji + this.canvas.render(); + + // 2. Powiadom inne części aplikacji (jeśli są) + if (this.onSelectionChange) { + this.onSelectionChange(); + } + + // 3. Powiadom panel warstw, aby zaktualizował swój wygląd + if (this.canvas.canvasLayersPanel) { + this.canvas.canvasLayersPanel.onSelectionChanged(); + } + } + + /** + * Logika aktualizacji zaznaczenia, wywoływana przez panel warstw. + */ + updateSelectionLogic(layer: any, isCtrlPressed: any, isShiftPressed: any, index: any) { + let newSelection = [...this.selectedLayers]; + let selectionChanged = false; + + if (isShiftPressed && this.canvas.canvasLayersPanel.lastSelectedIndex !== -1) { + const sortedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex); + const startIndex = Math.min(this.canvas.canvasLayersPanel.lastSelectedIndex, index); + const endIndex = Math.max(this.canvas.canvasLayersPanel.lastSelectedIndex, index); + + newSelection = []; + for (let i = startIndex; i <= endIndex; i++) { + if (sortedLayers[i]) { + newSelection.push(sortedLayers[i]); + } + } + selectionChanged = true; + } else if (isCtrlPressed) { + const layerIndex = newSelection.indexOf(layer); + if (layerIndex === -1) { + newSelection.push(layer); + } else { + newSelection.splice(layerIndex, 1); + } + this.canvas.canvasLayersPanel.lastSelectedIndex = index; + selectionChanged = true; + } else { + // Jeśli kliknięta warstwa nie jest częścią obecnego zaznaczenia, + // wyczyść zaznaczenie i zaznacz tylko ją. + if (!this.selectedLayers.includes(layer)) { + newSelection = [layer]; + selectionChanged = true; + } + // Jeśli kliknięta warstwa JEST już zaznaczona (potencjalnie z innymi), + // NIE rób nic, aby umożliwić przeciąganie całej grupy. + this.canvas.canvasLayersPanel.lastSelectedIndex = index; + } + + // Aktualizuj zaznaczenie tylko jeśli faktycznie się zmieniło + if (selectionChanged) { + this.updateSelection(newSelection); + } + } + + removeSelectedLayers() { + if (this.selectedLayers.length > 0) { + log.info('Removing selected layers', { + layersToRemove: this.selectedLayers.length, + totalLayers: this.canvas.layers.length + }); + + this.canvas.saveState(); + this.canvas.layers = this.canvas.layers.filter((l: any) => !this.selectedLayers.includes(l)); + + this.updateSelection([]); + + this.canvas.render(); + this.canvas.saveState(); + + if (this.canvas.canvasLayersPanel) { + this.canvas.canvasLayersPanel.onLayersChanged(); + } + + log.debug('Layers removed successfully, remaining layers:', this.canvas.layers.length); + } else { + log.debug('No layers selected for removal'); + } + } + + /** + * Aktualizuje zaznaczenie po operacji historii + */ + updateSelectionAfterHistory() { + const newSelectedLayers: any = []; + if (this.selectedLayers) { + this.selectedLayers.forEach((sl: any) => { + const found = this.canvas.layers.find((l: any) => l.id === sl.id); + if (found) newSelectedLayers.push(found); + }); + } + this.updateSelection(newSelectedLayers); + } +} diff --git a/src/CanvasState.ts b/src/CanvasState.ts new file mode 100644 index 0000000..f1ec070 --- /dev/null +++ b/src/CanvasState.ts @@ -0,0 +1,497 @@ +import {getCanvasState, setCanvasState, saveImage, getImage} from "./db.js"; +import {createModuleLogger} from "./utils/LoggerUtils.js"; +import {generateUUID, cloneLayers, getStateSignature, debounce} from "./utils/CommonUtils.js"; +import {withErrorHandling} from "./ErrorHandler.js"; +import type { Canvas } from './Canvas'; +import type { Layer, ComfyNode } from './types'; + +const log = createModuleLogger('CanvasState'); + +interface HistoryInfo { + undoCount: number; + redoCount: number; + canUndo: boolean; + canRedo: boolean; + historyLimit: number; +} + +export class CanvasState { + private _debouncedSave: (() => void) | null; + private _loadInProgress: Promise | null; + private canvas: Canvas & { node: ComfyNode, layers: Layer[] }; + private historyLimit: number; + private lastSavedStateSignature: string | null; + public layersRedoStack: Layer[][]; + public layersUndoStack: Layer[][]; + public maskRedoStack: HTMLCanvasElement[]; + public maskUndoStack: HTMLCanvasElement[]; + private saveTimeout: number | null; + private stateSaverWorker: Worker | null; + + constructor(canvas: Canvas & { node: ComfyNode, layers: Layer[] }) { + this.canvas = canvas; + this.layersUndoStack = []; + this.layersRedoStack = []; + this.maskUndoStack = []; + this.maskRedoStack = []; + this.historyLimit = 100; + this.saveTimeout = null; + this.lastSavedStateSignature = null; + this._loadInProgress = null; + this._debouncedSave = null; + + try { + // @ts-ignore + this.stateSaverWorker = new Worker(new URL('./state-saver.worker.js', import.meta.url), { type: 'module' }); + log.info("State saver worker initialized successfully."); + + this.stateSaverWorker.onmessage = (e: MessageEvent) => { + log.info("Message from state saver worker:", e.data); + }; + this.stateSaverWorker.onerror = (e: ErrorEvent) => { + log.error("Error in state saver worker:", e.message, e.filename, e.lineno); + this.stateSaverWorker = null; + }; + } catch (e) { + log.error("Failed to initialize state saver worker:", e); + this.stateSaverWorker = null; + } + } + + async loadStateFromDB(): Promise { + if (this._loadInProgress) { + log.warn("Load already in progress, waiting..."); + return this._loadInProgress; + } + + log.info("Attempting to load state from IndexedDB for node:", this.canvas.node.id); + const loadPromise = this._performLoad(); + this._loadInProgress = loadPromise; + + try { + const result = await loadPromise; + this._loadInProgress = null; + return result; + } catch (error) { + this._loadInProgress = null; + throw error; + } + } + + async _performLoad(): Promise { + try { + if (!this.canvas.node.id) { + log.error("Node ID is not available for loading state from DB."); + return false; + } + const savedState = await getCanvasState(String(this.canvas.node.id)); + if (!savedState) { + log.info("No saved state found in IndexedDB for node:", this.canvas.node.id); + return false; + } + log.info("Found saved state in IndexedDB."); + this.canvas.width = savedState.width || 512; + this.canvas.height = savedState.height || 512; + this.canvas.viewport = savedState.viewport || { + x: -(this.canvas.width / 4), + y: -(this.canvas.height / 4), + zoom: 0.8 + }; + + this.canvas.canvasLayers.updateOutputAreaSize(this.canvas.width, this.canvas.height, false); + log.debug(`Output Area resized to ${this.canvas.width}x${this.canvas.height} and viewport set.`); + const loadedLayers = await this._loadLayers(savedState.layers); + this.canvas.layers = loadedLayers.filter((l): l is Layer => l !== null); + log.info(`Loaded ${this.canvas.layers.length} layers.`); + + if (this.canvas.layers.length === 0) { + log.warn("No valid layers loaded, state may be corrupted."); + return false; + } + + this.canvas.updateSelectionAfterHistory(); + this.canvas.render(); + log.info("Canvas state loaded successfully from IndexedDB for node", this.canvas.node.id); + return true; + } catch (error) { + log.error("Error during state load:", error); + return false; + } + } + + /** + * Ładuje warstwy z zapisanego stanu + * @param {any[]} layersData - Dane warstw do załadowania + * @returns {Promise<(Layer | null)[]>} Załadowane warstwy + */ + async _loadLayers(layersData: any[]): Promise<(Layer | null)[]> { + const imagePromises = layersData.map((layerData: any, index: number) => + this._loadSingleLayer(layerData, index) + ); + return Promise.all(imagePromises); + } + + /** + * Ładuje pojedynczą warstwę + * @param {any} layerData - Dane warstwy + * @param {number} index - Indeks warstwy + * @returns {Promise} Załadowana warstwa lub null + */ + async _loadSingleLayer(layerData: Layer, index: number): Promise { + return new Promise((resolve) => { + if (layerData.imageId) { + this._loadLayerFromImageId(layerData, index, resolve); + } else if ((layerData as any).imageSrc) { + this._convertLegacyLayer(layerData, index, resolve); + } else { + log.error(`Layer ${index}: No imageId or imageSrc found, skipping layer.`); + resolve(null); + } + }); + } + + /** + * Ładuje warstwę z imageId + * @param {any} layerData - Dane warstwy + * @param {number} index - Indeks warstwy + * @param {(value: Layer | null) => void} resolve - Funkcja resolve + */ + _loadLayerFromImageId(layerData: Layer, index: number, resolve: (value: Layer | null) => void): void { + log.debug(`Layer ${index}: Loading image with id: ${layerData.imageId}`); + + if (this.canvas.imageCache.has(layerData.imageId)) { + log.debug(`Layer ${index}: Image found in cache.`); + const imageData = this.canvas.imageCache.get(layerData.imageId); + if (imageData) { + const imageSrc = URL.createObjectURL(new Blob([imageData.data])); + this._createLayerFromSrc(layerData, imageSrc, index, resolve); + } else { + resolve(null); + } + } else { + getImage(layerData.imageId) + .then(imageSrc => { + if (imageSrc) { + log.debug(`Layer ${index}: Loading image from data:URL...`); + this._createLayerFromSrc(layerData, imageSrc, index, resolve); + } else { + log.error(`Layer ${index}: Image not found in IndexedDB.`); + resolve(null); + } + }) + .catch(err => { + log.error(`Layer ${index}: Error loading image from IndexedDB:`, err); + resolve(null); + }); + } + } + + /** + * Konwertuje starą warstwę z imageSrc na nowy format + * @param {any} layerData - Dane warstwy + * @param {number} index - Indeks warstwy + * @param {(value: Layer | null) => void} resolve - Funkcja resolve + */ + _convertLegacyLayer(layerData: Layer, index: number, resolve: (value: Layer | null) => void): void { + log.info(`Layer ${index}: Found imageSrc, converting to new format with imageId.`); + const imageId = generateUUID(); + + saveImage(imageId, (layerData as any).imageSrc) + .then(() => { + log.info(`Layer ${index}: Image saved to IndexedDB with id: ${imageId}`); + const newLayerData = {...layerData, imageId}; + delete (newLayerData as any).imageSrc; + this._createLayerFromSrc(newLayerData, (layerData as any).imageSrc, index, resolve); + }) + .catch(err => { + log.error(`Layer ${index}: Error saving image to IndexedDB:`, err); + resolve(null); + }); + } + + /** + * Tworzy warstwę z src obrazu + * @param {any} layerData - Dane warstwy + * @param {string} imageSrc - Źródło obrazu + * @param {number} index - Indeks warstwy + * @param {(value: Layer | null) => void} resolve - Funkcja resolve + */ + _createLayerFromSrc(layerData: Layer, imageSrc: string | ImageBitmap, index: number, resolve: (value: Layer | null) => void): void { + if (typeof imageSrc === 'string') { + const img = new Image(); + img.onload = () => { + log.debug(`Layer ${index}: Image loaded successfully.`); + const newLayer: Layer = {...layerData, image: img}; + resolve(newLayer); + }; + img.onerror = () => { + log.error(`Layer ${index}: Failed to load image from src.`); + resolve(null); + }; + img.src = imageSrc; + } else { + const canvas = document.createElement('canvas'); + canvas.width = imageSrc.width; + canvas.height = imageSrc.height; + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.drawImage(imageSrc, 0, 0); + const img = new Image(); + img.onload = () => { + log.debug(`Layer ${index}: Image loaded successfully from ImageBitmap.`); + const newLayer: Layer = {...layerData, image: img}; + resolve(newLayer); + }; + img.onerror = () => { + log.error(`Layer ${index}: Failed to load image from ImageBitmap.`); + resolve(null); + }; + img.src = canvas.toDataURL(); + } else { + log.error(`Layer ${index}: Failed to get 2d context from canvas.`); + resolve(null); + } + } + } + + async saveStateToDB(): Promise { + if (!this.canvas.node.id) { + log.error("Node ID is not available for saving state to DB."); + return; + } + + log.info("Preparing state to be sent to worker..."); + const layers = await this._prepareLayers(); + const state = { + layers: layers.filter(layer => layer !== null), + viewport: this.canvas.viewport, + width: this.canvas.width, + height: this.canvas.height, + }; + + if (state.layers.length === 0) { + log.warn("No valid layers to save, skipping."); + return; + } + + if (this.stateSaverWorker) { + log.info("Posting state to worker for background saving."); + this.stateSaverWorker.postMessage({ + nodeId: String(this.canvas.node.id), + state: state + }); + this.canvas.render(); + } else { + log.warn("State saver worker not available. Saving on main thread."); + await setCanvasState(String(this.canvas.node.id), state); + } + } + + /** + * Przygotowuje warstwy do zapisu + * @returns {Promise<(Omit & { imageId: string })[]>} Przygotowane warstwy + */ + async _prepareLayers(): Promise<(Omit & { imageId: string })[]> { + const preparedLayers = await Promise.all(this.canvas.layers.map(async (layer: Layer, index: number) => { + const newLayer: Omit & { imageId: string } = { ...layer, imageId: layer.imageId || '' }; + delete (newLayer as any).image; + + if (layer.image instanceof HTMLImageElement) { + log.debug(`Layer ${index}: Using imageId instead of serializing image.`); + if (!layer.imageId) { + newLayer.imageId = generateUUID(); + const imageBitmap = await createImageBitmap(layer.image); + await saveImage(newLayer.imageId, imageBitmap); + } + newLayer.imageId = layer.imageId; + } else if (!layer.imageId) { + log.error(`Layer ${index}: No image or imageId found, skipping layer.`); + return null; + } + return newLayer; + })); + return preparedLayers.filter((layer): layer is Omit & { imageId: string } => layer !== null); + } + + saveState(replaceLast = false): void { + if (this.canvas.maskTool && this.canvas.maskTool.isActive) { + this.saveMaskState(replaceLast); + } else { + this.saveLayersState(replaceLast); + } + } + + saveLayersState(replaceLast = false): void { + if (replaceLast && this.layersUndoStack.length > 0) { + this.layersUndoStack.pop(); + } + + const currentState = cloneLayers(this.canvas.layers); + const currentStateSignature = getStateSignature(currentState); + + if (this.layersUndoStack.length > 0) { + const lastState = this.layersUndoStack[this.layersUndoStack.length - 1]; + if (getStateSignature(lastState) === currentStateSignature) { + return; + } + } + + this.layersUndoStack.push(currentState); + + if (this.layersUndoStack.length > this.historyLimit) { + this.layersUndoStack.shift(); + } + this.layersRedoStack = []; + this.canvas.updateHistoryButtons(); + + if (!this._debouncedSave) { + this._debouncedSave = debounce(this.saveStateToDB.bind(this), 1000); + } + this._debouncedSave(); + } + + saveMaskState(replaceLast = false): void { + if (!this.canvas.maskTool) return; + + if (replaceLast && this.maskUndoStack.length > 0) { + this.maskUndoStack.pop(); + } + const maskCanvas = this.canvas.maskTool.getMask(); + const clonedCanvas = document.createElement('canvas'); + clonedCanvas.width = maskCanvas.width; + clonedCanvas.height = maskCanvas.height; + const clonedCtx = clonedCanvas.getContext('2d', { willReadFrequently: true }); + if (clonedCtx) { + clonedCtx.drawImage(maskCanvas, 0, 0); + } + + this.maskUndoStack.push(clonedCanvas); + + if (this.maskUndoStack.length > this.historyLimit) { + this.maskUndoStack.shift(); + } + this.maskRedoStack = []; + this.canvas.updateHistoryButtons(); + } + + undo(): void { + if (this.canvas.maskTool && this.canvas.maskTool.isActive) { + this.undoMaskState(); + } else { + this.undoLayersState(); + } + } + + redo(): void { + if (this.canvas.maskTool && this.canvas.maskTool.isActive) { + this.redoMaskState(); + } else { + this.redoLayersState(); + } + } + + undoLayersState(): void { + if (this.layersUndoStack.length <= 1) return; + + const currentState = this.layersUndoStack.pop(); + if (currentState) { + this.layersRedoStack.push(currentState); + } + const prevState = this.layersUndoStack[this.layersUndoStack.length - 1]; + this.canvas.layers = cloneLayers(prevState); + this.canvas.updateSelectionAfterHistory(); + this.canvas.render(); + this.canvas.updateHistoryButtons(); + } + + redoLayersState(): void { + if (this.layersRedoStack.length === 0) return; + + const nextState = this.layersRedoStack.pop(); + if (nextState) { + this.layersUndoStack.push(nextState); + this.canvas.layers = cloneLayers(nextState); + this.canvas.updateSelectionAfterHistory(); + this.canvas.render(); + this.canvas.updateHistoryButtons(); + } + } + + undoMaskState(): void { + if (!this.canvas.maskTool || this.maskUndoStack.length <= 1) return; + + const currentState = this.maskUndoStack.pop(); + if (currentState) { + this.maskRedoStack.push(currentState); + } + + if (this.maskUndoStack.length > 0) { + const prevState = this.maskUndoStack[this.maskUndoStack.length - 1]; + const maskCanvas = this.canvas.maskTool.getMask(); + const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true }); + if (maskCtx) { + maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height); + maskCtx.drawImage(prevState, 0, 0); + } + this.canvas.render(); + } + + this.canvas.updateHistoryButtons(); + } + + redoMaskState(): void { + if (!this.canvas.maskTool || this.maskRedoStack.length === 0) return; + + const nextState = this.maskRedoStack.pop(); + if (nextState) { + this.maskUndoStack.push(nextState); + const maskCanvas = this.canvas.maskTool.getMask(); + const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true }); + if (maskCtx) { + maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height); + maskCtx.drawImage(nextState, 0, 0); + } + this.canvas.render(); + } + this.canvas.updateHistoryButtons(); + } + + /** + * Czyści historię undo/redo + */ + clearHistory(): void { + if (this.canvas.maskTool && this.canvas.maskTool.isActive) { + this.maskUndoStack = []; + this.maskRedoStack = []; + } else { + this.layersUndoStack = []; + this.layersRedoStack = []; + } + this.canvas.updateHistoryButtons(); + log.info("History cleared"); + } + + /** + * Zwraca informacje o historii + * @returns {HistoryInfo} Informacje o historii + */ + getHistoryInfo(): HistoryInfo { + if (this.canvas.maskTool && this.canvas.maskTool.isActive) { + return { + undoCount: this.maskUndoStack.length, + redoCount: this.maskRedoStack.length, + canUndo: this.maskUndoStack.length > 1, + canRedo: this.maskRedoStack.length > 0, + historyLimit: this.historyLimit + }; + } else { + return { + undoCount: this.layersUndoStack.length, + redoCount: this.layersRedoStack.length, + canUndo: this.layersUndoStack.length > 1, + canRedo: this.layersRedoStack.length > 0, + historyLimit: this.historyLimit + }; + } + } +} diff --git a/src/CanvasView.ts b/src/CanvasView.ts new file mode 100644 index 0000000..f76e905 --- /dev/null +++ b/src/CanvasView.ts @@ -0,0 +1,984 @@ +// @ts-ignore +import {app} from "../../scripts/app.js"; +// @ts-ignore +import {api} from "../../scripts/api.js"; +// @ts-ignore +import {ComfyApp} from "../../scripts/app.js"; +// @ts-ignore +import {$el} from "../../scripts/ui.js"; + +import { addStylesheet, getUrl, loadTemplate } from "./utils/ResourceManager.js"; + +import {Canvas} from "./Canvas.js"; +import {clearAllCanvasStates} from "./db.js"; +import {ImageCache} from "./ImageCache.js"; +import {generateUniqueFileName} from "./utils/CommonUtils.js"; +import {createModuleLogger} from "./utils/LoggerUtils.js"; +import type { ComfyNode, Layer, AddMode } from './types'; + +const log = createModuleLogger('Canvas_view'); + +interface CanvasWidget { + canvas: Canvas; + panel: HTMLDivElement; + destroy?: () => void; +} + +async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp): Promise { + const canvas = new Canvas(node, widget, { + onStateChange: () => updateOutput(node, canvas) + }); + const imageCache = new ImageCache(); + + const helpTooltip = $el("div.painter-tooltip", { + id: `painter-help-tooltip-${node.id}`, + }) as HTMLDivElement; + + const [standardShortcuts, maskShortcuts, systemClipboardTooltip, clipspaceClipboardTooltip] = await Promise.all([ + loadTemplate('./templates/standard_shortcuts.html'), + loadTemplate('./templates/mask_shortcuts.html'), + loadTemplate('./templates/system_clipboard_tooltip.html'), + loadTemplate('./templates/clipspace_clipboard_tooltip.html') + ]); + + document.body.appendChild(helpTooltip); + + const showTooltip = (buttonElement: HTMLElement, content: string) => { + helpTooltip.innerHTML = content; + helpTooltip.style.visibility = 'hidden'; + helpTooltip.style.display = 'block'; + + const buttonRect = buttonElement.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'; + }; + + const hideTooltip = () => { + helpTooltip.style.display = 'none'; + }; + + const controlPanel = $el("div.painterControlPanel", {}, [ + $el("div.controls.painter-controls", { + style: { + position: "absolute", + top: "0", + left: "0", + right: "0", + zIndex: "10", + }, + }, [ + $el("div.painter-button-group", {}, [ + $el("button.painter-button", { + id: `open-editor-btn-${node.id}`, + textContent: "⛶", + title: "Open in Editor", + style: {minWidth: "40px", maxWidth: "40px", fontWeight: "bold"}, + }), + $el("button.painter-button", { + textContent: "?", + title: "Show shortcuts", + style: { + minWidth: "30px", + maxWidth: "30px", + fontWeight: "bold", + }, + onmouseenter: (e: MouseEvent) => { + const content = canvas.maskTool.isActive ? maskShortcuts : standardShortcuts; + showTooltip(e.target as HTMLElement, content); + }, + onmouseleave: hideTooltip + }), + $el("button.painter-button.primary", { + textContent: "Add Image", + title: "Add image from file", + onclick: () => { + const fitOnAddWidget = node.widgets.find((w) => w.name === "fit_on_add"); + const addMode: AddMode = fitOnAddWidget && fitOnAddWidget.value ? 'fit' : 'center'; + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + input.multiple = true; + input.onchange = async (e) => { + const target = e.target as HTMLInputElement; + if (!target.files) return; + for (const file of target.files) { + const reader = new FileReader(); + reader.onload = (event) => { + const img = new Image(); + img.onload = () => { + canvas.addLayer(img, {}, addMode); + }; + if (event.target?.result) { + img.src = event.target.result as string; + } + }; + reader.readAsDataURL(file); + } + }; + input.click(); + } + }), + $el("button.painter-button.primary", { + textContent: "Import Input", + title: "Import image from another node", + 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: 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: MouseEvent) => { + const button = e.target as HTMLButtonElement; + 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: MouseEvent) => { + const currentPreference = canvas.canvasLayers.clipboardPreference; + const tooltipContent = currentPreference === 'system' ? systemClipboardTooltip : clipspaceClipboardTooltip; + showTooltip(e.target as HTMLElement, tooltipContent); + }, + onmouseleave: hideTooltip + }) + ]), + ]), + + $el("div.painter-separator"), + $el("div.painter-button-group", {}, [ + $el("button.painter-button", { + textContent: "Output Area Size", + title: "Set the size of the output area", + onclick: () => { + const dialog = $el("div.painter-dialog", { + style: { + position: 'fixed', + left: '50%', + top: '50%', + transform: 'translate(-50%, -50%)', + zIndex: '9999' + } + }, [ + $el("div", { + style: { + color: "white", + marginBottom: "10px" + } + }, [ + $el("label", { + style: { + marginRight: "5px" + } + }, [ + $el("span", {}, ["Width: "]) + ]), + $el("input", { + type: "number", + id: "canvas-width", + value: String(canvas.width), + min: "1", + max: "4096" + }) + ]), + $el("div", { + style: { + color: "white", + marginBottom: "10px" + } + }, [ + $el("label", { + style: { + marginRight: "5px" + } + }, [ + $el("span", {}, ["Height: "]) + ]), + $el("input", { + type: "number", + id: "canvas-height", + value: String(canvas.height), + min: "1", + max: "4096" + }) + ]), + $el("div", { + style: { + textAlign: "right" + } + }, [ + $el("button", { + id: "cancel-size", + textContent: "Cancel" + }), + $el("button", { + id: "confirm-size", + textContent: "OK" + }) + ]) + ]); + document.body.appendChild(dialog); + + (document.getElementById('confirm-size') as HTMLButtonElement).onclick = () => { + const widthInput = document.getElementById('canvas-width') as HTMLInputElement; + const heightInput = document.getElementById('canvas-height') as HTMLInputElement; + const width = parseInt(widthInput.value) || canvas.width; + const height = parseInt(heightInput.value) || canvas.height; + canvas.updateOutputAreaSize(width, height); + document.body.removeChild(dialog); + + }; + + (document.getElementById('cancel-size') as HTMLButtonElement).onclick = () => { + document.body.removeChild(dialog); + }; + } + }), + $el("button.painter-button.requires-selection", { + textContent: "Remove Layer", + title: "Remove selected layer(s)", + onclick: () => canvas.removeSelectedLayers() + }), + $el("button.painter-button.requires-selection", { + textContent: "Layer Up", + title: "Move selected layer(s) up", + onclick: () => canvas.canvasLayers.moveLayerUp() + }), + $el("button.painter-button.requires-selection", { + textContent: "Layer Down", + title: "Move selected layer(s) down", + onclick: () => canvas.canvasLayers.moveLayerDown() + }), + $el("button.painter-button.requires-selection", { + textContent: "Fuse", + title: "Flatten and merge selected layers into a single layer", + onclick: () => canvas.canvasLayers.fuseLayers() + }), + ]), + + $el("div.painter-separator"), + $el("div.painter-button-group", {}, [ + $el("button.painter-button.requires-selection", { + textContent: "Rotate +90°", + title: "Rotate selected layer(s) by +90 degrees", + 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.canvasLayers.resizeLayer(1.05) + }), + $el("button.painter-button.requires-selection", { + textContent: "Scale -5%", + title: "Decrease size of selected layer(s) by 5%", + onclick: () => canvas.canvasLayers.resizeLayer(0.95) + }), + $el("button.painter-button.requires-selection", { + textContent: "Mirror H", + title: "Mirror selected layer(s) horizontally", + onclick: () => canvas.canvasLayers.mirrorHorizontal() + }), + $el("button.painter-button.requires-selection", { + textContent: "Mirror V", + title: "Mirror selected layer(s) vertically", + onclick: () => canvas.canvasLayers.mirrorVertical() + }), + ]), + + $el("div.painter-separator"), + $el("div.painter-button-group", {}, [ + $el("button.painter-button.requires-selection.matting-button", { + textContent: "Matting", + title: "Perform background removal on the selected layer", + onclick: async (e: MouseEvent) => { + const button = (e.target as HTMLElement).closest('.matting-button') as HTMLButtonElement; + if (button.classList.contains('loading')) return; + + const spinner = $el("div.matting-spinner") as HTMLDivElement; + button.appendChild(spinner); + button.classList.add('loading'); + + try { + if (canvas.canvasSelection.selectedLayers.length !== 1) throw new Error("Please select exactly one image layer for matting."); + + const selectedLayer = canvas.canvasSelection.selectedLayers[0]; + const selectedLayerIndex = canvas.layers.indexOf(selectedLayer); + const imageData = await canvas.canvasLayers.getLayerImageData(selectedLayer); + const response = await fetch("/matting", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({image: imageData}) + }); + + const result = await response.json(); + + if (!response.ok) { + let errorMsg = `Server error: ${response.status} - ${response.statusText}`; + if (result && result.error) { + errorMsg = `Error: ${result.error}\n\nDetails: ${result.details}`; + } + throw new Error(errorMsg); + } + const mattedImage = new Image(); + mattedImage.src = result.matted_image; + await mattedImage.decode(); + const newLayer = {...selectedLayer, image: mattedImage} as Layer; + delete (newLayer as any).imageId; + canvas.layers[selectedLayerIndex] = newLayer; + canvas.canvasSelection.updateSelection([newLayer]); + canvas.render(); + canvas.saveState(); + } catch (error: any) { + log.error("Matting error:", error); + alert(`Matting process failed:\n\n${error.message}`); + } finally { + button.classList.remove('loading'); + button.removeChild(spinner); + } + } + }), + $el("button.painter-button", { + id: `undo-button-${node.id}`, + textContent: "Undo", + title: "Undo last action", + disabled: true, + onclick: () => canvas.undo() + }), + $el("button.painter-button", { + id: `redo-button-${node.id}`, + textContent: "Redo", + title: "Redo last undone action", + disabled: true, + onclick: () => canvas.redo() + }), + ]), + $el("div.painter-separator"), + $el("div.painter-button-group", {id: "mask-controls"}, [ + $el("button.painter-button.primary", { + id: `toggle-mask-btn-${node.id}`, + textContent: "Show Mask", + title: "Toggle mask overlay visibility", + onclick: (e: MouseEvent) => { + const button = e.target as HTMLButtonElement; + canvas.maskTool.toggleOverlayVisibility(); + canvas.render(); + + if (canvas.maskTool.isOverlayVisible) { + button.classList.add('primary'); + button.textContent = "Show Mask"; + } else { + button.classList.remove('primary'); + button.textContent = "Hide Mask"; + } + } + }), + $el("button.painter-button", { + textContent: "Edit Mask", + title: "Open the current canvas view in the mask editor", + onclick: () => { + canvas.startMaskEditor(null, true); + } + }), + $el("button.painter-button", { + id: "mask-mode-btn", + textContent: "Draw Mask", + title: "Toggle mask drawing mode", + onclick: () => { + const maskBtn = controlPanel.querySelector('#mask-mode-btn') as HTMLButtonElement; + const maskControls = controlPanel.querySelector('#mask-controls') as HTMLDivElement; + + if (canvas.maskTool.isActive) { + canvas.maskTool.deactivate(); + maskBtn.classList.remove('primary'); + maskControls.querySelectorAll('.mask-control').forEach((c) => (c as HTMLElement).style.display = 'none'); + } else { + canvas.maskTool.activate(); + maskBtn.classList.add('primary'); + maskControls.querySelectorAll('.mask-control').forEach((c) => (c as HTMLElement).style.display = 'flex'); + } + + setTimeout(() => canvas.render(), 0); + } + }), + $el("div.painter-slider-container.mask-control", {style: {display: 'none'}}, [ + $el("label", {for: "brush-size-slider", textContent: "Size:"}), + $el("input", { + id: "brush-size-slider", + type: "range", + min: "1", + max: "200", + value: "20", + oninput: (e: Event) => canvas.maskTool.setBrushSize(parseInt((e.target as HTMLInputElement).value)) + }) + ]), + $el("div.painter-slider-container.mask-control", {style: {display: 'none'}}, [ + $el("label", {for: "brush-strength-slider", textContent: "Strength:"}), + $el("input", { + id: "brush-strength-slider", + type: "range", + min: "0", + max: "1", + step: "0.05", + value: "0.5", + oninput: (e: Event) => canvas.maskTool.setBrushStrength(parseFloat((e.target as HTMLInputElement).value)) + }) + ]), + $el("div.painter-slider-container.mask-control", {style: {display: 'none'}}, [ + $el("label", {for: "brush-hardness-slider", textContent: "Hardness:"}), + $el("input", { + id: "brush-hardness-slider", + type: "range", + min: "0", + max: "1", + step: "0.05", + value: "0.5", + oninput: (e: Event) => canvas.maskTool.setBrushHardness(parseFloat((e.target as HTMLInputElement).value)) + }) + ]), + $el("button.painter-button.mask-control", { + textContent: "Clear Mask", + title: "Clear the entire mask", + style: {display: 'none'}, + onclick: () => { + if (confirm("Are you sure you want to clear the mask?")) { + canvas.maskTool.clear(); + canvas.render(); + } + } + }) + ]), + + $el("div.painter-separator"), + $el("div.painter-button-group", {}, [ + $el("button.painter-button", { + textContent: "Run GC", + title: "Run Garbage Collection to clean unused images", + style: {backgroundColor: "#4a7c59", borderColor: "#3a6c49"}, + onclick: async () => { + try { + const stats = canvas.imageReferenceManager.getStats(); + log.info("GC Stats before cleanup:", stats); + + await canvas.imageReferenceManager.manualGarbageCollection(); + + 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: ${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."); + } + } + }), + $el("button.painter-button", { + textContent: "Clear Cache", + title: "Clear all saved canvas states from browser storage", + style: {backgroundColor: "#c54747", borderColor: "#a53737"}, + onclick: async () => { + if (confirm("Are you sure you want to clear all saved canvas states? This action cannot be undone.")) { + try { + await clearAllCanvasStates(); + alert("Canvas cache cleared successfully!"); + } catch (e) { + log.error("Failed to clear canvas cache:", e); + alert("Error clearing canvas cache. Check the console for details."); + } + } + } + }) + ]) + ]), + $el("div.painter-separator") + ]); + + + const updateButtonStates = () => { + const selectionCount = canvas.canvasSelection.selectedLayers.length; + const hasSelection = selectionCount > 0; + controlPanel.querySelectorAll('.requires-selection').forEach((btn: any) => { + const button = btn as HTMLButtonElement; + if (button.textContent === 'Fuse') { + button.disabled = selectionCount < 2; + } else { + button.disabled = !hasSelection; + } + }); + const mattingBtn = controlPanel.querySelector('.matting-button') as HTMLButtonElement; + if (mattingBtn && !mattingBtn.classList.contains('loading')) { + mattingBtn.disabled = selectionCount !== 1; + } + }; + + canvas.canvasSelection.onSelectionChange = updateButtonStates; + + const undoButton = controlPanel.querySelector(`#undo-button-${node.id}`) as HTMLButtonElement; + const redoButton = controlPanel.querySelector(`#redo-button-${node.id}`) as HTMLButtonElement; + + canvas.onHistoryChange = ({ canUndo, canRedo }: { canUndo: boolean, canRedo: boolean }) => { + if (undoButton) undoButton.disabled = !canUndo; + if (redoButton) redoButton.disabled = !canRedo; + }; + + updateButtonStates(); + canvas.updateHistoryButtons(); + + const updateOutput = async (node: ComfyNode, canvas: Canvas) => { + const triggerWidget = node.widgets.find((w) => w.name === "trigger"); + if (triggerWidget) { + triggerWidget.value = (triggerWidget.value + 1) % 99999999; + } + + try { + const new_preview = new Image(); + const blob = await canvas.canvasLayers.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 layersPanel = canvas.canvasLayersPanel.createPanelStructure(); + + const canvasContainer = $el("div.painterCanvasContainer.painter-container", { + style: { + position: "absolute", + top: "60px", + left: "10px", + right: "270px", + bottom: "10px", + overflow: "hidden" + } + }, [canvas.canvas]) as HTMLDivElement; + + const layersPanelContainer = $el("div.painterLayersPanelContainer", { + style: { + position: "absolute", + top: "60px", + right: "10px", + width: "250px", + bottom: "10px", + overflow: "hidden" + } + }, [layersPanel]) as HTMLDivElement; + + const resizeObserver = new ResizeObserver((entries) => { + const controlsHeight = (entries[0].target as HTMLElement).offsetHeight; + const newTop = (controlsHeight + 10) + "px"; + canvasContainer.style.top = newTop; + layersPanelContainer.style.top = newTop; + }); + + const controlsElement = controlPanel.querySelector('.controls'); + if (controlsElement) { + resizeObserver.observe(controlsElement); + } + + canvas.canvas.addEventListener('focus', () => { + canvasContainer.classList.add('has-focus'); + }); + + canvas.canvas.addEventListener('blur', () => { + canvasContainer.classList.remove('has-focus'); + }); + + node.onResize = function () { + canvas.render(); + }; + + const mainContainer = $el("div.painterMainContainer", { + style: { + position: "relative", + width: "100%", + height: "100%" + } + }, [controlPanel, canvasContainer, layersPanelContainer]) as HTMLDivElement; + + node.addDOMWidget("mainContainer", "widget", mainContainer); + + const openEditorBtn = controlPanel.querySelector(`#open-editor-btn-${node.id}`) as HTMLButtonElement; + let backdrop: HTMLDivElement | null = null; + let originalParent: HTMLElement | null = null; + let isEditorOpen = false; + + const closeEditor = () => { + if (originalParent && backdrop) { + originalParent.appendChild(mainContainer); + document.body.removeChild(backdrop); + } + + isEditorOpen = false; + openEditorBtn.textContent = "⛶"; + openEditorBtn.title = "Open in Editor"; + + setTimeout(() => { + canvas.render(); + if (node.onResize) { + node.onResize(); + } + }, 0); + }; + + openEditorBtn.onclick = () => { + if (isEditorOpen) { + closeEditor(); + return; + } + + originalParent = mainContainer.parentElement; + if (!originalParent) { + log.error("Could not find original parent of the canvas container!"); + return; + } + + backdrop = $el("div.painter-modal-backdrop") as HTMLDivElement; + const modalContent = $el("div.painter-modal-content") as HTMLDivElement; + + modalContent.appendChild(mainContainer); + backdrop.appendChild(modalContent); + document.body.appendChild(backdrop); + + isEditorOpen = true; + openEditorBtn.textContent = "X"; + openEditorBtn.title = "Close Editor"; + + setTimeout(() => { + canvas.render(); + if (node.onResize) { + node.onResize(); + } + }, 0); + }; + + if (!(window as any).canvasExecutionStates) { + (window as any).canvasExecutionStates = new Map(); + } + (node as any).canvasWidget = canvas; + + setTimeout(() => { + canvas.loadInitialState(); + if (canvas.canvasLayersPanel) { + canvas.canvasLayersPanel.renderLayers(); + } + }, 100); + + const showPreviewWidget = node.widgets.find((w) => w.name === "show_preview"); + if (showPreviewWidget) { + const originalCallback = showPreviewWidget.callback; + + showPreviewWidget.callback = function (value: boolean) { + if (originalCallback) { + originalCallback.call(this, value); + } + + if (canvas && canvas.setPreviewVisibility) { + canvas.setPreviewVisibility(value); + } + + if ((node as any).graph && (node as any).graph.canvas) { + node.setDirtyCanvas(true, true); + } + }; + } + + return { + canvas: canvas, + panel: controlPanel + }; +} + +const canvasNodeInstances = new Map(); + +app.registerExtension({ + name: "Comfy.CanvasNode", + + init() { + addStylesheet(getUrl('./css/canvas_view.css')); + + const originalQueuePrompt = app.queuePrompt; + app.queuePrompt = async function (this: ComfyApp, number: number, prompt: any) { + log.info("Preparing to queue prompt..."); + + if (canvasNodeInstances.size > 0) { + log.info(`Found ${canvasNodeInstances.size} CanvasNode(s). Sending data via WebSocket...`); + + const sendPromises: Promise[] = []; + for (const [nodeId, canvasWidget] of canvasNodeInstances.entries()) { + if (app.graph.getNodeById(nodeId) && canvasWidget.canvas && canvasWidget.canvas.canvasIO) { + log.debug(`Sending data for canvas node ${nodeId}`); + sendPromises.push(canvasWidget.canvas.canvasIO.sendDataViaWebSocket(nodeId)); + } else { + log.warn(`Node ${nodeId} not found in graph, removing from instances map.`); + canvasNodeInstances.delete(nodeId); + } + } + + try { + await Promise.all(sendPromises); + log.info("All canvas data has been sent and acknowledged by the server."); + } catch (error: any) { + log.error("Failed to send canvas data for one or more nodes. Aborting prompt.", error); + alert(`CanvasNode Error: ${error.message}`); + return; + } + } + + log.info("All pre-prompt tasks complete. Proceeding with original queuePrompt."); + return originalQueuePrompt.apply(this, arguments as any); + }; + }, + + async beforeRegisterNodeDef(nodeType: any, nodeData: any, app: ComfyApp) { + if (nodeType.comfyClass === "CanvasNode") { + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function (this: ComfyNode) { + log.debug("CanvasNode onNodeCreated: Base widget setup."); + const r = onNodeCreated?.apply(this, arguments as any); + this.size = [1150, 1000]; + return r; + }; + + nodeType.prototype.onAdded = async function (this: ComfyNode) { + log.info(`CanvasNode onAdded, ID: ${this.id}`); + log.debug(`Available widgets in onAdded:`, this.widgets.map((w) => w.name)); + + if ((this as any).canvasWidget) { + log.warn(`CanvasNode ${this.id} already initialized. Skipping onAdded setup.`); + return; + } + + this.widgets.forEach((w) => { + log.debug(`Widget name: ${w.name}, type: ${w.type}, value: ${w.value}`); + }); + + const nodeIdWidget = this.widgets.find((w) => w.name === "node_id"); + if (nodeIdWidget) { + nodeIdWidget.value = String(this.id); + log.debug(`Set hidden node_id widget to: ${nodeIdWidget.value}`); + } else { + log.error("Could not find the hidden node_id widget!"); + } + + const canvasWidget = await createCanvasWidget(this, null, app); + canvasNodeInstances.set(this.id, canvasWidget); + log.info(`Registered CanvasNode instance for ID: ${this.id}`); + + setTimeout(() => { + this.setDirtyCanvas(true, true); + }, 100); + }; + + const onRemoved = nodeType.prototype.onRemoved; + nodeType.prototype.onRemoved = function (this: ComfyNode) { + log.info(`Cleaning up canvas node ${this.id}`); + + canvasNodeInstances.delete(this.id); + log.info(`Deregistered CanvasNode instance for ID: ${this.id}`); + + if ((window as any).canvasExecutionStates) { + (window as any).canvasExecutionStates.delete(this.id); + } + + const tooltip = document.getElementById(`painter-help-tooltip-${this.id}`); + if (tooltip) { + tooltip.remove(); + } + const backdrop = document.querySelector('.painter-modal-backdrop'); + if (backdrop && (this as any).canvasWidget && backdrop.contains((this as any).canvasWidget.canvas.canvas)) { + document.body.removeChild(backdrop); + } + + if ((this as any).canvasWidget && (this as any).canvasWidget.destroy) { + (this as any).canvasWidget.destroy(); + } + + return onRemoved?.apply(this, arguments as any); + }; + + const originalGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions; + nodeType.prototype.getExtraMenuOptions = function (this: ComfyNode, _: any, options: any[]) { + originalGetExtraMenuOptions?.apply(this, arguments as any); + + 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 as any).canvasWidget && (self as any).canvasWidget.startMaskEditor) { + await (self as any).canvasWidget.startMaskEditor(null, true); + } else { + log.error("Canvas widget not available"); + alert("Canvas not ready. Please try again."); + } + } catch (e: any) { + log.error("Error opening MaskEditor:", e); + alert(`Failed to open MaskEditor: ${e.message}`); + } + }, + }, + { + content: "Open Image", + callback: async () => { + try { + if (!(self as any).canvasWidget) return; + const blob = await (self as any).canvasWidget.getFlattenedCanvasAsBlob(); + if (!blob) return; + const url = URL.createObjectURL(blob); + window.open(url, '_blank'); + setTimeout(() => URL.revokeObjectURL(url), 1000); + } catch (e) { + log.error("Error opening image:", e); + } + }, + }, + { + content: "Open Image with Mask Alpha", + callback: async () => { + try { + if (!(self as any).canvasWidget) return; + const blob = await (self as any).canvasWidget.getFlattenedCanvasWithMaskAsBlob(); + if (!blob) return; + 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 () => { + try { + if (!(self as any).canvasWidget) return; + const blob = await (self as any).canvasWidget.getFlattenedCanvasAsBlob(); + if (!blob) return; + const item = new ClipboardItem({'image/png': blob}); + await navigator.clipboard.write([item]); + log.info("Image copied to clipboard."); + } catch (e) { + log.error("Error copying image:", e); + alert("Failed to copy image to clipboard."); + } + }, + }, + { + content: "Copy Image with Mask Alpha", + callback: async () => { + try { + if (!(self as any).canvasWidget) return; + const blob = await (self as any).canvasWidget.getFlattenedCanvasWithMaskAsBlob(); + if (!blob) return; + 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 () => { + try { + if (!(self as any).canvasWidget) return; + const blob = await (self as any).canvasWidget.getFlattenedCanvasAsBlob(); + if (!blob) return; + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'canvas_output.png'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 1000); + } catch (e) { + log.error("Error saving image:", e); + } + }, + }, + { + content: "Save Image with Mask Alpha", + callback: async () => { + try { + if (!(self as any).canvasWidget) return; + const blob = await (self as any).canvasWidget.getFlattenedCanvasWithMaskAsBlob(); + if (!blob) return; + 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}); + } + options.unshift(...newOptions); + }; + } + } +}); diff --git a/src/ErrorHandler.ts b/src/ErrorHandler.ts new file mode 100644 index 0000000..69192c0 --- /dev/null +++ b/src/ErrorHandler.ts @@ -0,0 +1,383 @@ +/** + * ErrorHandler - Centralna obsługa błędów + * Eliminuje powtarzalne wzorce obsługi błędów w całym projekcie + */ + +import {createModuleLogger} from "./utils/LoggerUtils.js"; + +const log = createModuleLogger('ErrorHandler'); + +/** + * Typy błędów w aplikacji + */ +export const ErrorTypes = { + VALIDATION: 'VALIDATION_ERROR', + NETWORK: 'NETWORK_ERROR', + FILE_IO: 'FILE_IO_ERROR', + CANVAS: 'CANVAS_ERROR', + IMAGE_PROCESSING: 'IMAGE_PROCESSING_ERROR', + STATE_MANAGEMENT: 'STATE_MANAGEMENT_ERROR', + USER_INPUT: 'USER_INPUT_ERROR', + SYSTEM: 'SYSTEM_ERROR' +} as const; + +export type ErrorType = typeof ErrorTypes[keyof typeof ErrorTypes]; + +interface ErrorHistoryEntry { + timestamp: string; + type: ErrorType; + message: string; + context?: string; +} + +interface ErrorStats { + totalErrors: number; + errorCounts: { [key: string]: number }; + recentErrors: ErrorHistoryEntry[]; + errorsByType: { [key: string]: ErrorHistoryEntry[] }; +} + +/** + * Klasa błędu aplikacji z dodatkowymi informacjami + */ +export class AppError extends Error { + details: any; + originalError: Error | null; + timestamp: string; + type: ErrorType; + constructor(message: string, type: ErrorType = ErrorTypes.SYSTEM, details: any = null, originalError: Error | null = null) { + super(message); + this.name = 'AppError'; + this.type = type; + this.details = details; + this.originalError = originalError; + this.timestamp = new Date().toISOString(); + if ((Error as any).captureStackTrace) { + (Error as any).captureStackTrace(this, AppError); + } + } +} + +/** + * Handler błędów z automatycznym logowaniem i kategoryzacją + */ +export class ErrorHandler { + private errorCounts: Map; + private errorHistory: ErrorHistoryEntry[]; + private maxHistorySize: number; + + constructor() { + this.errorCounts = new Map(); + this.errorHistory = []; + this.maxHistorySize = 100; + } + + /** + * Obsługuje błąd z automatycznym logowaniem + * @param {Error | AppError | string} error - Błąd do obsłużenia + * @param {string} context - Kontekst wystąpienia błędu + * @param {object} additionalInfo - Dodatkowe informacje + * @returns {AppError} Znormalizowany błąd + */ + handle(error: Error | AppError | string, context = 'Unknown', additionalInfo: object = {}): AppError { + const normalizedError = this.normalizeError(error, context, additionalInfo); + this.logError(normalizedError, context); + this.recordError(normalizedError); + this.incrementErrorCount(normalizedError.type); + + return normalizedError; + } + + /** + * Normalizuje błąd do standardowego formatu + * @param {Error | AppError | string} error - Błąd do znormalizowania + * @param {string} context - Kontekst + * @param {object} additionalInfo - Dodatkowe informacje + * @returns {AppError} Znormalizowany błąd + */ + normalizeError(error: Error | AppError | string, context: string, additionalInfo: object): AppError { + if (error instanceof AppError) { + return error; + } + + if (error instanceof Error) { + const type = this.categorizeError(error, context); + return new AppError( + error.message, + type, + {context, ...additionalInfo}, + error + ); + } + + if (typeof error === 'string') { + return new AppError( + error, + ErrorTypes.SYSTEM, + {context, ...additionalInfo} + ); + } + + return new AppError( + 'Unknown error occurred', + ErrorTypes.SYSTEM, + {context, originalError: error, ...additionalInfo} + ); + } + + /** + * Kategoryzuje błąd na podstawie wiadomości i kontekstu + * @param {Error} error - Błąd do skategoryzowania + * @param {string} context - Kontekst + * @returns {ErrorType} Typ błędu + */ + categorizeError(error: Error, context: string): ErrorType { + const message = error.message.toLowerCase(); + if (message.includes('fetch') || message.includes('network') || + message.includes('connection') || message.includes('timeout')) { + return ErrorTypes.NETWORK; + } + if (message.includes('file') || message.includes('read') || + message.includes('write') || message.includes('path')) { + return ErrorTypes.FILE_IO; + } + if (message.includes('invalid') || message.includes('required') || + message.includes('validation') || message.includes('format')) { + return ErrorTypes.VALIDATION; + } + if (message.includes('image') || message.includes('canvas') || + message.includes('blob') || message.includes('tensor')) { + return ErrorTypes.IMAGE_PROCESSING; + } + if (message.includes('state') || message.includes('cache') || + message.includes('storage')) { + return ErrorTypes.STATE_MANAGEMENT; + } + if (context.toLowerCase().includes('canvas')) { + return ErrorTypes.CANVAS; + } + + return ErrorTypes.SYSTEM; + } + + /** + * Loguje błąd z odpowiednim poziomem + * @param {AppError} error - Błąd do zalogowania + * @param {string} context - Kontekst + */ + logError(error: AppError, context: string): void { + const logMessage = `[${error.type}] ${error.message}`; + const logDetails = { + context, + timestamp: error.timestamp, + details: error.details, + stack: error.stack + }; + switch (error.type) { + case ErrorTypes.VALIDATION: + case ErrorTypes.USER_INPUT: + log.warn(logMessage, logDetails); + break; + case ErrorTypes.NETWORK: + log.error(logMessage, logDetails); + break; + default: + log.error(logMessage, logDetails); + } + } + + /** + * Zapisuje błąd w historii + * @param {AppError} error - Błąd do zapisania + */ + recordError(error: AppError): void { + this.errorHistory.push({ + timestamp: error.timestamp, + type: error.type, + message: error.message, + context: error.details?.context + }); + if (this.errorHistory.length > this.maxHistorySize) { + this.errorHistory.shift(); + } + } + + /** + * Zwiększa licznik błędów dla danego typu + * @param {ErrorType} errorType - Typ błędu + */ + incrementErrorCount(errorType: ErrorType): void { + const current = this.errorCounts.get(errorType) || 0; + this.errorCounts.set(errorType, current + 1); + } + + /** + * Zwraca statystyki błędów + * @returns {ErrorStats} Statystyki błędów + */ + getErrorStats(): ErrorStats { + const errorCountsObj: { [key: string]: number } = {}; + for (const [key, value] of this.errorCounts.entries()) { + errorCountsObj[key] = value; + } + return { + totalErrors: this.errorHistory.length, + errorCounts: errorCountsObj, + recentErrors: this.errorHistory.slice(-10), + errorsByType: this.groupErrorsByType() + }; + } + + /** + * Grupuje błędy według typu + * @returns {{ [key: string]: ErrorHistoryEntry[] }} Błędy pogrupowane według typu + */ + groupErrorsByType(): { [key: string]: ErrorHistoryEntry[] } { + const grouped: { [key: string]: ErrorHistoryEntry[] } = {}; + this.errorHistory.forEach((error) => { + if (!grouped[error.type]) { + grouped[error.type] = []; + } + grouped[error.type].push(error); + }); + return grouped; + } + + /** + * Czyści historię błędów + */ + clearHistory(): void { + this.errorHistory = []; + this.errorCounts.clear(); + log.info('Error history cleared'); + } +} + +const errorHandler = new ErrorHandler(); + +/** + * Wrapper funkcji z automatyczną obsługą błędów + * @param {Function} fn - Funkcja do opakowania + * @param {string} context - Kontekst wykonania + * @returns {Function} Opakowana funkcja + */ +export function withErrorHandling any>( + fn: T, + context: string +): (...args: Parameters) => Promise> { + return async function(this: any, ...args: Parameters): Promise> { + try { + return await fn.apply(this, args); + } catch (error) { + const handledError = errorHandler.handle(error as Error, context, { + functionName: fn.name, + arguments: args.length + }); + throw handledError; + } + }; +} + +/** + * Decorator dla metod klasy z automatyczną obsługą błędów + * @param {string} context - Kontekst wykonania + */ +export function handleErrors(context: string) { + return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) { + const originalMethod = descriptor.value; + + descriptor.value = async function (...args: any[]) { + try { + return await originalMethod.apply(this, args); + } catch (error) { + const handledError = errorHandler.handle(error as Error, `${context}.${propertyKey}`, { + className: target.constructor.name, + methodName: propertyKey, + arguments: args.length + }); + throw handledError; + } + }; + + return descriptor; + }; +} + +/** + * Funkcja pomocnicza do tworzenia błędów walidacji + * @param {string} message - Wiadomość błędu + * @param {object} details - Szczegóły walidacji + * @returns {AppError} Błąd walidacji + */ +export function createValidationError(message: string, details: object = {}): AppError { + return new AppError(message, ErrorTypes.VALIDATION, details); +} + +/** + * Funkcja pomocnicza do tworzenia błędów sieciowych + * @param {string} message - Wiadomość błędu + * @param {object} details - Szczegóły sieci + * @returns {AppError} Błąd sieciowy + */ +export function createNetworkError(message: string, details: object = {}): AppError { + return new AppError(message, ErrorTypes.NETWORK, details); +} + +/** + * Funkcja pomocnicza do tworzenia błędów plików + * @param {string} message - Wiadomość błędu + * @param {object} details - Szczegóły pliku + * @returns {AppError} Błąd pliku + */ +export function createFileError(message: string, details: object = {}): AppError { + return new AppError(message, ErrorTypes.FILE_IO, details); +} + +/** + * Funkcja pomocnicza do bezpiecznego wykonania operacji + * @param {() => Promise} operation - Operacja do wykonania + * @param {T} fallbackValue - Wartość fallback w przypadku błędu + * @param {string} context - Kontekst operacji + * @returns {Promise} Wynik operacji lub wartość fallback + */ +export async function safeExecute(operation: () => Promise, fallbackValue: T, context = 'SafeExecute'): Promise { + try { + return await operation(); + } catch (error) { + errorHandler.handle(error as Error, context); + return fallbackValue; + } +} + +/** + * Funkcja do retry operacji z exponential backoff + * @param {() => Promise} operation - Operacja do powtórzenia + * @param {number} maxRetries - Maksymalna liczba prób + * @param {number} baseDelay - Podstawowe opóźnienie w ms + * @param {string} context - Kontekst operacji + * @returns {Promise} Wynik operacji + */ +export async function retryWithBackoff(operation: () => Promise, maxRetries = 3, baseDelay = 1000, context = 'RetryOperation'): Promise { + let lastError: Error | undefined; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await operation(); + } catch (error) { + lastError = error as Error; + + if (attempt === maxRetries) { + break; + } + + const delay = baseDelay * Math.pow(2, attempt); + log.warn(`Attempt ${attempt + 1} failed, retrying in ${delay}ms`, {error: lastError.message, context}); + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + + throw errorHandler.handle(lastError!, context, {attempts: maxRetries + 1}); +} + +export {errorHandler}; +export default errorHandler; diff --git a/src/ImageCache.ts b/src/ImageCache.ts new file mode 100644 index 0000000..4e6337c --- /dev/null +++ b/src/ImageCache.ts @@ -0,0 +1,32 @@ +import {createModuleLogger} from "./utils/LoggerUtils.js"; +import type { ImageDataPixel } from './types'; + +const log = createModuleLogger('ImageCache'); + +export class ImageCache { + private cache: Map; + + constructor() { + this.cache = new Map(); + } + + set(key: string, imageData: ImageDataPixel): void { + log.info("Caching image data for key:", key); + this.cache.set(key, imageData); + } + + get(key: string): ImageDataPixel | undefined { + const data = this.cache.get(key); + log.debug("Retrieved cached data for key:", key, !!data); + return data; + } + + has(key: string): boolean { + return this.cache.has(key); + } + + clear(): void { + log.info("Clearing image cache"); + this.cache.clear(); + } +} diff --git a/src/ImageReferenceManager.ts b/src/ImageReferenceManager.ts new file mode 100644 index 0000000..ca28180 --- /dev/null +++ b/src/ImageReferenceManager.ts @@ -0,0 +1,309 @@ +import {removeImage, getAllImageIds} from "./db.js"; +import {createModuleLogger} from "./utils/LoggerUtils.js"; +import type { Canvas } from './Canvas'; +import type { Layer, CanvasState } from './types'; + +const log = createModuleLogger('ImageReferenceManager'); + +interface GarbageCollectionStats { + trackedImages: number; + totalReferences: number; + isRunning: boolean; + gcInterval: number; + maxAge: number; +} + +export class ImageReferenceManager { + private canvas: Canvas & { canvasState: CanvasState }; + private gcInterval: number; + private gcTimer: number | null; + private imageLastUsed: Map; + private imageReferences: Map; + private isGcRunning: boolean; + private maxAge: number; + public operationCount: number; + public operationThreshold: number; + + constructor(canvas: Canvas & { canvasState: CanvasState }) { + this.canvas = canvas; + this.imageReferences = new Map(); // imageId -> count + this.imageLastUsed = new Map(); // imageId -> timestamp + this.gcInterval = 5 * 60 * 1000; // 5 minut (nieużywane) + this.maxAge = 30 * 60 * 1000; // 30 minut bez użycia + this.gcTimer = null; + this.isGcRunning = false; + this.operationCount = 0; + this.operationThreshold = 500; // Uruchom GC po 500 operacjach + } + + /** + * Uruchamia automatyczne garbage collection + */ + startGarbageCollection(): void { + if (this.gcTimer) { + clearInterval(this.gcTimer); + } + + this.gcTimer = window.setInterval(() => { + this.performGarbageCollection(); + }, this.gcInterval); + + log.info("Garbage collection started with interval:", this.gcInterval / 1000, "seconds"); + } + + /** + * Zatrzymuje automatyczne garbage collection + */ + stopGarbageCollection(): void { + if (this.gcTimer) { + clearInterval(this.gcTimer); + this.gcTimer = null; + } + log.info("Garbage collection stopped"); + } + + /** + * Dodaje referencję do obrazu + * @param {string} imageId - ID obrazu + */ + addReference(imageId: string): void { + if (!imageId) return; + + const currentCount = this.imageReferences.get(imageId) || 0; + this.imageReferences.set(imageId, currentCount + 1); + this.imageLastUsed.set(imageId, Date.now()); + + log.debug(`Added reference to image ${imageId}, count: ${currentCount + 1}`); + } + + /** + * Usuwa referencję do obrazu + * @param {string} imageId - ID obrazu + */ + removeReference(imageId: string): void { + if (!imageId) return; + + const currentCount = this.imageReferences.get(imageId) || 0; + if (currentCount <= 1) { + this.imageReferences.delete(imageId); + log.debug(`Removed last reference to image ${imageId}`); + } else { + this.imageReferences.set(imageId, currentCount - 1); + log.debug(`Removed reference to image ${imageId}, count: ${currentCount - 1}`); + } + } + + /** + * Aktualizuje referencje na podstawie aktualnego stanu canvas + */ + updateReferences(): void { + log.debug("Updating image references..."); + this.imageReferences.clear(); + const usedImageIds = this.collectAllUsedImageIds(); + usedImageIds.forEach(imageId => { + this.addReference(imageId); + }); + + log.info(`Updated references for ${usedImageIds.size} unique images`); + } + + /** + * Zbiera wszystkie używane imageId z różnych źródeł + * @returns {Set} Zbiór używanych imageId + */ + collectAllUsedImageIds(): Set { + const usedImageIds = new Set(); + this.canvas.layers.forEach((layer: Layer) => { + if (layer.imageId) { + usedImageIds.add(layer.imageId); + } + }); + if (this.canvas.canvasState && this.canvas.canvasState.layersUndoStack) { + this.canvas.canvasState.layersUndoStack.forEach((layersState: Layer[]) => { + layersState.forEach((layer: Layer) => { + if (layer.imageId) { + usedImageIds.add(layer.imageId); + } + }); + }); + } + + if (this.canvas.canvasState && this.canvas.canvasState.layersRedoStack) { + this.canvas.canvasState.layersRedoStack.forEach((layersState: Layer[]) => { + layersState.forEach((layer: Layer) => { + if (layer.imageId) { + usedImageIds.add(layer.imageId); + } + }); + }); + } + + log.debug(`Collected ${usedImageIds.size} used image IDs`); + return usedImageIds; + } + + /** + * Znajduje nieużywane obrazy + * @param {Set} usedImageIds - Zbiór używanych imageId + * @returns {Promise} Lista nieużywanych imageId + */ + async findUnusedImages(usedImageIds: Set): Promise { + try { + const allImageIds = await getAllImageIds(); + const unusedImages: string[] = []; + const now = Date.now(); + + for (const imageId of allImageIds) { + if (!usedImageIds.has(imageId)) { + const lastUsed = this.imageLastUsed.get(imageId) || 0; + const age = now - lastUsed; + + if (age > this.maxAge) { + unusedImages.push(imageId); + } else { + log.debug(`Image ${imageId} is unused but too young (age: ${Math.round(age / 1000)}s)`); + } + } + } + + log.debug(`Found ${unusedImages.length} unused images ready for cleanup`); + return unusedImages; + } catch (error) { + log.error("Error finding unused images:", error); + return []; + } + } + + /** + * Czyści nieużywane obrazy + * @param {string[]} unusedImages - Lista nieużywanych imageId + */ + async cleanupUnusedImages(unusedImages: string[]): Promise { + if (unusedImages.length === 0) { + log.debug("No unused images to cleanup"); + return; + } + + log.info(`Starting cleanup of ${unusedImages.length} unused images`); + let cleanedCount = 0; + let errorCount = 0; + + for (const imageId of unusedImages) { + try { + + await removeImage(imageId); + + if (this.canvas.imageCache && this.canvas.imageCache.has(imageId)) { + this.canvas.imageCache.delete(imageId); + } + + this.imageReferences.delete(imageId); + this.imageLastUsed.delete(imageId); + + cleanedCount++; + log.debug(`Cleaned up image: ${imageId}`); + + } catch (error) { + errorCount++; + log.error(`Error cleaning up image ${imageId}:`, error); + } + } + + log.info(`Garbage collection completed: ${cleanedCount} images cleaned, ${errorCount} errors`); + } + + /** + * Wykonuje pełne garbage collection + */ + async performGarbageCollection(): Promise { + if (this.isGcRunning) { + log.debug("Garbage collection already running, skipping"); + return; + } + + this.isGcRunning = true; + log.info("Starting garbage collection..."); + + try { + + this.updateReferences(); + + const usedImageIds = this.collectAllUsedImageIds(); + + const unusedImages = await this.findUnusedImages(usedImageIds); + + await this.cleanupUnusedImages(unusedImages); + + } catch (error) { + log.error("Error during garbage collection:", error); + } finally { + this.isGcRunning = false; + } + } + + /** + * Zwiększa licznik operacji i sprawdza czy uruchomić GC + */ + incrementOperationCount(): void { + this.operationCount++; + log.debug(`Operation count: ${this.operationCount}/${this.operationThreshold}`); + + if (this.operationCount >= this.operationThreshold) { + log.info(`Operation threshold reached (${this.operationThreshold}), triggering garbage collection`); + this.operationCount = 0; // Reset counter + + setTimeout(() => { + this.performGarbageCollection(); + }, 100); + } + } + + /** + * Resetuje licznik operacji + */ + resetOperationCount(): void { + this.operationCount = 0; + log.debug("Operation count reset"); + } + + /** + * Ustawia próg operacji dla automatycznego GC + * @param {number} threshold - Nowy próg operacji + */ + setOperationThreshold(threshold: number): void { + this.operationThreshold = Math.max(1, threshold); + log.info(`Operation threshold set to: ${this.operationThreshold}`); + } + + /** + * Ręczne uruchomienie garbage collection + */ + async manualGarbageCollection(): Promise { + log.info("Manual garbage collection triggered"); + await this.performGarbageCollection(); + } + + /** + * Zwraca statystyki garbage collection + * @returns {GarbageCollectionStats} Statystyki + */ + getStats(): GarbageCollectionStats { + return { + trackedImages: this.imageReferences.size, + totalReferences: Array.from(this.imageReferences.values()).reduce((sum, count) => sum + count, 0), + isRunning: this.isGcRunning, + gcInterval: this.gcInterval, + maxAge: this.maxAge + }; + } + + /** + * Czyści wszystkie dane (przy usuwaniu canvas) + */ + destroy(): void { + this.stopGarbageCollection(); + this.imageReferences.clear(); + this.imageLastUsed.clear(); + log.info("ImageReferenceManager destroyed"); + } +} diff --git a/src/MaskTool.ts b/src/MaskTool.ts new file mode 100644 index 0000000..0cf44db --- /dev/null +++ b/src/MaskTool.ts @@ -0,0 +1,339 @@ +import {createModuleLogger} from "./utils/LoggerUtils.js"; +import type { Canvas } from './Canvas'; +import type { Point, CanvasState } from './types'; + +const log = createModuleLogger('Mask_tool'); + +interface MaskToolCallbacks { + onStateChange?: () => void; +} + +export class MaskTool { + private brushHardness: number; + private brushSize: number; + private brushStrength: number; + private canvasInstance: Canvas & { canvasState: CanvasState, width: number, height: number }; + public isActive: boolean; + public isDrawing: boolean; + public isOverlayVisible: boolean; + private lastPosition: Point | null; + private mainCanvas: HTMLCanvasElement; + private maskCanvas: HTMLCanvasElement; + private maskCtx: CanvasRenderingContext2D; + private onStateChange: (() => void) | null; + private previewCanvas: HTMLCanvasElement; + private previewCanvasInitialized: boolean; + private previewCtx: CanvasRenderingContext2D; + private previewVisible: boolean; + public x: number; + public y: number; + + constructor(canvasInstance: Canvas & { canvasState: CanvasState, width: number, height: number }, callbacks: MaskToolCallbacks = {}) { + this.canvasInstance = canvasInstance; + this.mainCanvas = canvasInstance.canvas; + this.onStateChange = callbacks.onStateChange || null; + this.maskCanvas = document.createElement('canvas'); + const maskCtx = this.maskCanvas.getContext('2d', { willReadFrequently: true }); + if (!maskCtx) { + throw new Error("Failed to get 2D context for mask canvas"); + } + this.maskCtx = maskCtx; + + this.x = 0; + this.y = 0; + + this.isOverlayVisible = true; + this.isActive = false; + this.brushSize = 20; + this.brushStrength = 0.5; + this.brushHardness = 0.5; + this.isDrawing = false; + this.lastPosition = null; + + this.previewCanvas = document.createElement('canvas'); + const previewCtx = this.previewCanvas.getContext('2d', { willReadFrequently: true }); + if (!previewCtx) { + throw new Error("Failed to get 2D context for preview canvas"); + } + this.previewCtx = previewCtx; + this.previewVisible = false; + this.previewCanvasInitialized = false; + + this.initMaskCanvas(); + } + + initPreviewCanvas(): void { + if (this.previewCanvas.parentElement) { + this.previewCanvas.parentElement.removeChild(this.previewCanvas); + } + this.previewCanvas.width = this.canvasInstance.canvas.width; + this.previewCanvas.height = this.canvasInstance.canvas.height; + this.previewCanvas.style.position = 'absolute'; + this.previewCanvas.style.left = `${this.canvasInstance.canvas.offsetLeft}px`; + this.previewCanvas.style.top = `${this.canvasInstance.canvas.offsetTop}px`; + this.previewCanvas.style.pointerEvents = 'none'; + this.previewCanvas.style.zIndex = '10'; + if (this.canvasInstance.canvas.parentElement) { + this.canvasInstance.canvas.parentElement.appendChild(this.previewCanvas); + } + } + + setBrushHardness(hardness: number): void { + this.brushHardness = Math.max(0, Math.min(1, hardness)); + } + + initMaskCanvas(): void { + const extraSpace = 2000; // Allow for a generous drawing area outside the output area + this.maskCanvas.width = this.canvasInstance.width + extraSpace; + this.maskCanvas.height = this.canvasInstance.height + extraSpace; + + + this.x = -extraSpace / 2; + this.y = -extraSpace / 2; + + this.maskCtx.clearRect(0, 0, this.maskCanvas.width, this.maskCanvas.height); + log.info(`Initialized mask canvas with extended size: ${this.maskCanvas.width}x${this.maskCanvas.height}, origin at (${this.x}, ${this.y})`); + } + + activate(): void { + if (!this.previewCanvasInitialized) { + this.initPreviewCanvas(); + this.previewCanvasInitialized = true; + } + this.isActive = true; + this.previewCanvas.style.display = 'block'; + this.canvasInstance.interaction.mode = 'drawingMask'; + if (this.canvasInstance.canvasState.maskUndoStack.length === 0) { + this.canvasInstance.canvasState.saveMaskState(); + } + this.canvasInstance.updateHistoryButtons(); + + log.info("Mask tool activated"); + } + + deactivate(): void { + this.isActive = false; + this.previewCanvas.style.display = 'none'; + this.canvasInstance.interaction.mode = 'none'; + this.canvasInstance.updateHistoryButtons(); + + log.info("Mask tool deactivated"); + } + + setBrushSize(size: number): void { + this.brushSize = Math.max(1, size); + } + + setBrushStrength(strength: number): void { + this.brushStrength = Math.max(0, Math.min(1, strength)); + } + + handleMouseDown(worldCoords: Point, viewCoords: Point): void { + if (!this.isActive) return; + this.isDrawing = true; + this.lastPosition = worldCoords; + this.draw(worldCoords); + this.clearPreview(); + } + + handleMouseMove(worldCoords: Point, viewCoords: Point): void { + if (this.isActive) { + this.drawBrushPreview(viewCoords); + } + if (!this.isActive || !this.isDrawing) return; + this.draw(worldCoords); + this.lastPosition = worldCoords; + } + + handleMouseLeave(): void { + this.previewVisible = false; + this.clearPreview(); + } + + handleMouseEnter(): void { + this.previewVisible = true; + } + + handleMouseUp(viewCoords: Point): void { + if (!this.isActive) return; + if (this.isDrawing) { + this.isDrawing = false; + this.lastPosition = null; + this.canvasInstance.canvasState.saveMaskState(); + if (this.onStateChange) { + this.onStateChange(); + } + this.drawBrushPreview(viewCoords); + } + } + + draw(worldCoords: Point): void { + if (!this.lastPosition) { + this.lastPosition = worldCoords; + } + + + const canvasLastX = this.lastPosition.x - this.x; + const canvasLastY = this.lastPosition.y - this.y; + const canvasX = worldCoords.x - this.x; + const canvasY = worldCoords.y - this.y; + + + const canvasWidth = this.maskCanvas.width; + const canvasHeight = this.maskCanvas.height; + + if (canvasX >= 0 && canvasX < canvasWidth && + canvasY >= 0 && canvasY < canvasHeight && + canvasLastX >= 0 && canvasLastX < canvasWidth && + canvasLastY >= 0 && canvasLastY < canvasHeight) { + + this.maskCtx.beginPath(); + this.maskCtx.moveTo(canvasLastX, canvasLastY); + this.maskCtx.lineTo(canvasX, canvasY); + const gradientRadius = this.brushSize / 2; + + if (this.brushHardness === 1) { + this.maskCtx.strokeStyle = `rgba(255, 255, 255, ${this.brushStrength})`; + } else { + const innerRadius = gradientRadius * this.brushHardness; + const gradient = this.maskCtx.createRadialGradient( + canvasX, canvasY, innerRadius, + canvasX, canvasY, gradientRadius + ); + gradient.addColorStop(0, `rgba(255, 255, 255, ${this.brushStrength})`); + gradient.addColorStop(1, `rgba(255, 255, 255, 0)`); + this.maskCtx.strokeStyle = gradient; + } + + this.maskCtx.lineWidth = this.brushSize; + this.maskCtx.lineCap = 'round'; + this.maskCtx.lineJoin = 'round'; + this.maskCtx.globalCompositeOperation = 'source-over'; + this.maskCtx.stroke(); + } else { + log.debug(`Drawing outside mask canvas bounds: (${canvasX}, ${canvasY})`); + } + } + + drawBrushPreview(viewCoords: Point): void { + if (!this.previewVisible || this.isDrawing) { + this.clearPreview(); + return; + } + + this.clearPreview(); + const zoom = this.canvasInstance.viewport.zoom; + const radius = (this.brushSize / 2) * zoom; + + this.previewCtx.beginPath(); + this.previewCtx.arc(viewCoords.x, viewCoords.y, radius, 0, 2 * Math.PI); + this.previewCtx.strokeStyle = 'rgba(255, 255, 255, 0.8)'; + this.previewCtx.lineWidth = 1; + this.previewCtx.setLineDash([2, 4]); + this.previewCtx.stroke(); + } + + clearPreview(): void { + this.previewCtx.clearRect(0, 0, this.previewCanvas.width, this.previewCanvas.height); + } + + clear(): void { + this.maskCtx.clearRect(0, 0, this.maskCanvas.width, this.maskCanvas.height); + if (this.isActive) { + this.canvasInstance.canvasState.saveMaskState(); + } + } + + getMask(): HTMLCanvasElement { + return this.maskCanvas; + } + + getMaskImageWithAlpha(): HTMLImageElement { + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = this.maskCanvas.width; + tempCanvas.height = this.maskCanvas.height; + const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); + if (!tempCtx) { + throw new Error("Failed to get 2D context for temporary canvas"); + } + tempCtx.drawImage(this.maskCanvas, 0, 0); + const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height); + const data = imageData.data; + for (let i = 0; i < data.length; i += 4) { + const alpha = data[i]; + data[i] = 255; + data[i + 1] = 255; + data[i + 2] = 255; + data[i + 3] = alpha; + } + tempCtx.putImageData(imageData, 0, 0); + const maskImage = new Image(); + maskImage.src = tempCanvas.toDataURL(); + return maskImage; + } + + resize(width: number, height: number): void { + this.initPreviewCanvas(); + const oldMask = this.maskCanvas; + const oldX = this.x; + const oldY = this.y; + const oldWidth = oldMask.width; + const oldHeight = oldMask.height; + + const isIncreasingWidth = width > this.canvasInstance.width; + const isIncreasingHeight = height > this.canvasInstance.height; + + this.maskCanvas = document.createElement('canvas'); + + const extraSpace = 2000; + + const newWidth = isIncreasingWidth ? width + extraSpace : Math.max(oldWidth, width + extraSpace); + const newHeight = isIncreasingHeight ? height + extraSpace : Math.max(oldHeight, height + extraSpace); + + this.maskCanvas.width = newWidth; + this.maskCanvas.height = newHeight; + const newMaskCtx = this.maskCanvas.getContext('2d', { willReadFrequently: true }); + if (!newMaskCtx) { + throw new Error("Failed to get 2D context for new mask canvas"); + } + this.maskCtx = newMaskCtx; + + if (oldMask.width > 0 && oldMask.height > 0) { + const offsetX = this.x - oldX; + const offsetY = this.y - oldY; + + this.maskCtx.drawImage(oldMask, offsetX, offsetY); + + log.debug(`Preserved mask content with offset (${offsetX}, ${offsetY})`); + } + + log.info(`Mask canvas resized to ${this.maskCanvas.width}x${this.maskCanvas.height}, position (${this.x}, ${this.y})`); + log.info(`Canvas size change: width ${isIncreasingWidth ? 'increased' : 'decreased'}, height ${isIncreasingHeight ? 'increased' : 'decreased'}`); + } + + updatePosition(dx: number, dy: number): void { + this.x += dx; + this.y += dy; + log.info(`Mask position updated to (${this.x}, ${this.y})`); + } + + toggleOverlayVisibility(): void { + this.isOverlayVisible = !this.isOverlayVisible; + log.info(`Mask overlay visibility toggled to: ${this.isOverlayVisible}`); + } + + setMask(image: HTMLImageElement): void { + 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(); + log.info(`MaskTool updated with a new mask image at correct canvas position (${destX}, ${destY}).`); + } +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..2855995 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,5 @@ +import { LogLevel } from "./logger"; + +// Log level for development. +// Possible values: 'DEBUG', 'INFO', 'WARN', 'ERROR', 'NONE' +export const LOG_LEVEL: keyof typeof LogLevel = 'DEBUG'; diff --git a/src/css/canvas_view.css b/src/css/canvas_view.css new file mode 100644 index 0000000..ff2ab75 --- /dev/null +++ b/src/css/canvas_view.css @@ -0,0 +1,405 @@ +.painter-button { + background: linear-gradient(to bottom, #4a4a4a, #3a3a3a); + border: 1px solid #2a2a2a; + border-radius: 4px; + color: #ffffff; + padding: 6px 12px; + font-size: 12px; + cursor: pointer; + transition: all 0.2s ease; + min-width: 80px; + text-align: center; + margin: 2px; + text-shadow: 0 1px 1px rgba(0,0,0,0.2); +} + +.painter-button:hover { + background: linear-gradient(to bottom, #5a5a5a, #4a4a4a); + box-shadow: 0 1px 3px rgba(0,0,0,0.2); +} + +.painter-button:active { + background: linear-gradient(to bottom, #3a3a3a, #4a4a4a); + transform: translateY(1px); +} + +.painter-button:disabled, +.painter-button:disabled:hover { + background: #555; + color: #888; + cursor: not-allowed; + transform: none; + box-shadow: none; + border-color: #444; +} + +.painter-button.primary { + background: linear-gradient(to bottom, #4a6cd4, #3a5cc4); + border-color: #2a4cb4; +} + +.painter-button.primary:hover { + background: linear-gradient(to bottom, #5a7ce4, #4a6cd4); +} + +.painter-controls { + background: linear-gradient(to bottom, #404040, #383838); + border-bottom: 1px solid #2a2a2a; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + padding: 8px; + display: flex; + gap: 6px; + flex-wrap: wrap; + align-items: center; + justify-content: flex-start; +} + +.painter-slider-container { + display: flex; + align-items: center; + gap: 8px; + color: #fff; + font-size: 12px; +} + +.painter-slider-container input[type="range"] { + width: 80px; +} + + +.painter-button-group { + display: flex; + align-items: center; + gap: 6px; + background-color: rgba(0,0,0,0.2); + padding: 4px; + 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; + background-color: #2a2a2a; + margin: 0 8px; +} + +.painter-container { + background: #607080; /* 带蓝色的灰色背景 */ + border: 1px solid #4a5a6a; + border-radius: 6px; + box-shadow: inset 0 0 10px rgba(0,0,0,0.1); + transition: border-color 0.3s ease; /* Dodano dla płynnej zmiany ramki */ +} + +.painter-container.drag-over { + border-color: #00ff00; /* Zielona ramka podczas przeciągania */ + border-style: dashed; +} + +.painter-dialog { + background: #404040; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + padding: 20px; + color: #ffffff; +} + +.painter-dialog input { + background: #303030; + border: 1px solid #505050; + border-radius: 4px; + color: #ffffff; + padding: 4px 8px; + margin: 4px; + width: 80px; +} + +.painter-dialog button { + background: #505050; + border: 1px solid #606060; + border-radius: 4px; + color: #ffffff; + padding: 4px 12px; + margin: 4px; + cursor: pointer; +} + +.painter-dialog button:hover { + background: #606060; +} + +.blend-opacity-slider { + width: 100%; + margin: 5px 0; + display: none; +} + +.blend-mode-active .blend-opacity-slider { + display: block; +} + +.blend-mode-item { + padding: 5px; + cursor: pointer; + position: relative; +} + +.blend-mode-item.active { + background-color: rgba(0,0,0,0.1); +} + +.blend-mode-item.active { + background-color: rgba(0,0,0,0.1); +} + +.painter-tooltip { + position: fixed; + display: none; + background: #3a3a3a; + color: #f0f0f0; + border: 1px solid #555; + border-radius: 8px; + padding: 12px 18px; + z-index: 9999; + font-size: 13px; + line-height: 1.7; + width: auto; + max-width: min(500px, calc(100vw - 40px)); + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + pointer-events: none; + transform-origin: top left; + transition: transform 0.2s ease; + will-change: transform; +} + +.painter-tooltip.scale-down { + transform: scale(0.9); + transform-origin: top; +} + +.painter-tooltip.scale-down-more { + transform: scale(0.8); + transform-origin: top; +} + +.painter-tooltip table { + width: 100%; + border-collapse: collapse; + margin: 8px 0; +} + +.painter-tooltip table td { + padding: 2px 8px; + vertical-align: middle; +} + +.painter-tooltip table td:first-child { + width: auto; + white-space: nowrap; + min-width: fit-content; +} + +.painter-tooltip table td:last-child { + width: auto; +} + +.painter-tooltip table tr:nth-child(odd) td { + background-color: rgba(0,0,0,0.1); +} + +@media (max-width: 600px) { + .painter-tooltip { + font-size: 11px; + padding: 8px 12px; + } + .painter-tooltip table td { + padding: 2px 4px; + } + .painter-tooltip kbd { + padding: 1px 4px; + font-size: 10px; + } + .painter-tooltip table td:first-child { + width: 40%; + } + .painter-tooltip table td:last-child { + width: 60%; + } + .painter-tooltip h4 { + font-size: 12px; + margin-top: 8px; + margin-bottom: 4px; + } +} + +@media (max-width: 400px) { + .painter-tooltip { + font-size: 10px; + padding: 6px 8px; + } + .painter-tooltip table td { + padding: 1px 3px; + } + .painter-tooltip kbd { + padding: 0px 3px; + font-size: 9px; + } + .painter-tooltip table td:first-child { + width: 35%; + } + .painter-tooltip table td:last-child { + width: 65%; + } + .painter-tooltip h4 { + font-size: 11px; + margin-top: 6px; + margin-bottom: 3px; + } +} + +.painter-tooltip::-webkit-scrollbar { + width: 8px; +} + +.painter-tooltip::-webkit-scrollbar-track { + background: #2a2a2a; + border-radius: 4px; +} + +.painter-tooltip::-webkit-scrollbar-thumb { + background: #555; + border-radius: 4px; +} + +.painter-tooltip::-webkit-scrollbar-thumb:hover { + background: #666; +} + +.painter-tooltip h4 { + margin-top: 10px; + margin-bottom: 5px; + color: #4a90e2; /* Jasnoniebieski akcent */ + border-bottom: 1px solid #555; + padding-bottom: 4px; +} + +.painter-tooltip ul { + list-style: none; + padding-left: 10px; + margin: 0; +} + +.painter-tooltip kbd { + background-color: #2a2a2a; + border: 1px solid #1a1a1a; + border-radius: 3px; + padding: 2px 6px; + font-family: monospace; + font-size: 12px; + color: #d0d0d0; +} + +.painter-container.has-focus { + /* Używamy box-shadow, aby stworzyć efekt zewnętrznej ramki, + która nie wpłynie na rozmiar ani pozycję elementu. */ + box-shadow: 0 0 0 2px white; + /* Możesz też zmienić kolor istniejącej ramki, ale box-shadow jest bardziej wyrazisty */ + /* border-color: white; */ +} + +.painter-button.matting-button { + position: relative; + transition: all 0.3s ease; +} + +.painter-button.matting-button.loading { + padding-right: 36px; /* Make space for spinner */ + cursor: wait; +} + +.painter-button.matting-button .matting-spinner { + display: none; + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + width: 16px; + height: 16px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-radius: 50%; + border-top-color: #fff; + animation: matting-spin 1s linear infinite; +} + +.painter-button.matting-button.loading .matting-spinner { + display: block; +} + +@keyframes matting-spin { + to { + transform: translateY(-50%) rotate(360deg); + } +} +.painter-modal-backdrop { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: rgba(0, 0, 0, 0.8); + z-index: 111; + display: flex; + align-items: center; + justify-content: center; +} + +.painter-modal-content { + width: 90vw; + height: 90vh; + background-color: #353535; + border: 1px solid #222; + border-radius: 8px; + box-shadow: 0 5px 25px rgba(0,0,0,0.5); + display: flex; + flex-direction: column; + position: relative; +} + +.painterMainContainer { + display: flex; + flex-direction: column; + height: 100%; + flex-grow: 1; +} + +.painterCanvasContainer { + flex-grow: 1; + position: relative; +} diff --git a/src/db.ts b/src/db.ts new file mode 100644 index 0000000..2d73aaa --- /dev/null +++ b/src/db.ts @@ -0,0 +1,192 @@ +import {createModuleLogger} from "./utils/LoggerUtils.js"; + +const log = createModuleLogger('db'); + +const DB_NAME = 'CanvasNodeDB'; +const STATE_STORE_NAME = 'CanvasState'; +const IMAGE_STORE_NAME = 'CanvasImages'; +const DB_VERSION = 3; + +let db: IDBDatabase | null = null; + +type DBRequestOperation = 'get' | 'put' | 'delete' | 'clear'; + +interface CanvasStateDB { + id: string; + state: any; +} + +interface CanvasImageDB { + imageId: string; + imageSrc: string; +} + +/** + * Funkcja pomocnicza do tworzenia żądań IndexedDB z ujednoliconą obsługą błędów + * @param {IDBObjectStore} store - Store IndexedDB + * @param {DBRequestOperation} operation - Nazwa operacji (get, put, delete, clear) + * @param {any} data - Dane dla operacji (opcjonalne) + * @param {string} errorMessage - Wiadomość błędu + * @returns {Promise} Promise z wynikiem operacji + */ +function createDBRequest(store: IDBObjectStore, operation: DBRequestOperation, data: any, errorMessage: string): Promise { + return new Promise((resolve, reject) => { + let request: IDBRequest; + switch (operation) { + case 'get': + request = store.get(data); + break; + case 'put': + request = store.put(data); + break; + case 'delete': + request = store.delete(data); + break; + case 'clear': + request = store.clear(); + break; + default: + reject(new Error(`Unknown operation: ${operation}`)); + return; + } + + request.onerror = (event) => { + log.error(errorMessage, (event.target as IDBRequest).error); + reject(errorMessage); + }; + + request.onsuccess = (event) => { + resolve((event.target as IDBRequest).result); + }; + }); +} + +function openDB(): Promise { + return new Promise((resolve, reject) => { + if (db) { + resolve(db); + return; + } + + log.info("Opening IndexedDB..."); + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onerror = (event) => { + log.error("IndexedDB error:", (event.target as IDBOpenDBRequest).error); + reject("Error opening IndexedDB."); + }; + + request.onsuccess = (event) => { + db = (event.target as IDBOpenDBRequest).result; + log.info("IndexedDB opened successfully."); + resolve(db); + }; + + request.onupgradeneeded = (event) => { + log.info("Upgrading IndexedDB..."); + const dbInstance = (event.target as IDBOpenDBRequest).result; + if (!dbInstance.objectStoreNames.contains(STATE_STORE_NAME)) { + dbInstance.createObjectStore(STATE_STORE_NAME, {keyPath: 'id'}); + log.info("Object store created:", STATE_STORE_NAME); + } + if (!dbInstance.objectStoreNames.contains(IMAGE_STORE_NAME)) { + dbInstance.createObjectStore(IMAGE_STORE_NAME, {keyPath: 'imageId'}); + log.info("Object store created:", IMAGE_STORE_NAME); + } + }; + }); +} + +export async function getCanvasState(id: string): Promise { + log.info(`Getting state for id: ${id}`); + const db = await openDB(); + const transaction = db.transaction([STATE_STORE_NAME], 'readonly'); + const store = transaction.objectStore(STATE_STORE_NAME); + + const result = await createDBRequest(store, 'get', id, "Error getting canvas state") as CanvasStateDB; + log.debug(`Get success for id: ${id}`, result ? 'found' : 'not found'); + return result ? result.state : null; +} + +export async function setCanvasState(id: string, state: any): Promise { + log.info(`Setting state for id: ${id}`); + const db = await openDB(); + const transaction = db.transaction([STATE_STORE_NAME], 'readwrite'); + const store = transaction.objectStore(STATE_STORE_NAME); + + await createDBRequest(store, 'put', {id, state}, "Error setting canvas state"); + log.debug(`Set success for id: ${id}`); +} + +export async function removeCanvasState(id: string): Promise { + log.info(`Removing state for id: ${id}`); + const db = await openDB(); + const transaction = db.transaction([STATE_STORE_NAME], 'readwrite'); + const store = transaction.objectStore(STATE_STORE_NAME); + + await createDBRequest(store, 'delete', id, "Error removing canvas state"); + log.debug(`Remove success for id: ${id}`); +} + +export async function saveImage(imageId: string, imageSrc: string | ImageBitmap): Promise { + log.info(`Saving image with id: ${imageId}`); + const db = await openDB(); + const transaction = db.transaction([IMAGE_STORE_NAME], 'readwrite'); + const store = transaction.objectStore(IMAGE_STORE_NAME); + + await createDBRequest(store, 'put', {imageId, imageSrc}, "Error saving image"); + log.debug(`Image saved successfully for id: ${imageId}`); +} + +export async function getImage(imageId: string): Promise { + log.info(`Getting image with id: ${imageId}`); + const db = await openDB(); + const transaction = db.transaction([IMAGE_STORE_NAME], 'readonly'); + const store = transaction.objectStore(IMAGE_STORE_NAME); + + const result = await createDBRequest(store, 'get', imageId, "Error getting image") as CanvasImageDB; + log.debug(`Get image success for id: ${imageId}`, result ? 'found' : 'not found'); + return result ? result.imageSrc : null; +} + +export async function removeImage(imageId: string): Promise { + log.info(`Removing image with id: ${imageId}`); + const db = await openDB(); + const transaction = db.transaction([IMAGE_STORE_NAME], 'readwrite'); + const store = transaction.objectStore(IMAGE_STORE_NAME); + + await createDBRequest(store, 'delete', imageId, "Error removing image"); + log.debug(`Remove image success for id: ${imageId}`); +} + +export async function getAllImageIds(): Promise { + log.info("Getting all image IDs..."); + const db = await openDB(); + const transaction = db.transaction([IMAGE_STORE_NAME], 'readonly'); + const store = transaction.objectStore(IMAGE_STORE_NAME); + + return new Promise((resolve, reject) => { + const request = store.getAllKeys(); + + request.onerror = (event) => { + log.error("Error getting all image IDs:", (event.target as IDBRequest).error); + reject("Error getting all image IDs"); + }; + + request.onsuccess = (event) => { + const imageIds = (event.target as IDBRequest).result; + log.debug(`Found ${imageIds.length} image IDs in database`); + resolve(imageIds); + }; + }); +} + +export async function clearAllCanvasStates(): Promise { + log.info("Clearing all canvas states..."); + const db = await openDB(); + const transaction = db.transaction([STATE_STORE_NAME], 'readwrite'); + const store = transaction.objectStore(STATE_STORE_NAME); + + await createDBRequest(store, 'clear', null, "Error clearing canvas states"); + log.info("All canvas states cleared successfully."); +} diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..5d682f2 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,374 @@ +/** + * Logger - Centralny system logowania dla ComfyUI-LayerForge + * + * Funkcje: + * - Różne poziomy logowania (DEBUG, INFO, WARN, ERROR) + * - Możliwość włączania/wyłączania logów globalnie lub per moduł + * - Kolorowe logi w konsoli + * - Możliwość zapisywania logów do localStorage + * - Możliwość eksportu logów + */ + +function padStart(str: string, targetLength: number, padString: string): string { + targetLength = targetLength >> 0; + padString = String(padString || ' '); + if (str.length > targetLength) { + return String(str); + } else { + targetLength = targetLength - str.length; + if (targetLength > padString.length) { + padString += padString.repeat(targetLength / padString.length); + } + return padString.slice(0, targetLength) + String(str); + } +} + +export const LogLevel = { + DEBUG: 0, + INFO: 1, + WARN: 2, + ERROR: 3, + NONE: 4 +} as const; + +export type LogLevels = typeof LogLevel[keyof typeof LogLevel]; + +interface LoggerConfig { + globalLevel: LogLevels; + moduleSettings: { [key: string]: LogLevels }; + useColors: boolean; + saveToStorage: boolean; + maxStoredLogs: number; + timestampFormat: string; + storageKey: string; +} + +interface LogData { + timestamp: string; + module: string; + level: LogLevels; + levelName: string; + args: any[]; + time: Date; +} + +const DEFAULT_CONFIG: LoggerConfig = { + globalLevel: LogLevel.INFO, + moduleSettings: {}, + useColors: true, + saveToStorage: false, + maxStoredLogs: 1000, + timestampFormat: 'HH:mm:ss', + storageKey: 'layerforge_logs' +}; + +const COLORS: { [key: number]: string } = { + [LogLevel.DEBUG]: '#9e9e9e', + [LogLevel.INFO]: '#2196f3', + [LogLevel.WARN]: '#ff9800', + [LogLevel.ERROR]: '#f44336', +}; + +const LEVEL_NAMES: { [key: number]: string } = { + [LogLevel.DEBUG]: 'DEBUG', + [LogLevel.INFO]: 'INFO', + [LogLevel.WARN]: 'WARN', + [LogLevel.ERROR]: 'ERROR', +}; + +class Logger { + private config: LoggerConfig; + private enabled: boolean; + private logs: LogData[]; + constructor() { + this.config = {...DEFAULT_CONFIG}; + this.logs = []; + this.enabled = true; + this.loadConfig(); + } + + /** + * Konfiguracja loggera + * @param {Partial} config - Obiekt konfiguracyjny + */ + configure(config: Partial): this { + this.config = {...this.config, ...config}; + this.saveConfig(); + return this; + } + + /** + * Włącz/wyłącz logger globalnie + * @param {boolean} enabled - Czy logger ma być włączony + */ + setEnabled(enabled: boolean): this { + this.enabled = enabled; + return this; + } + + /** + * Ustaw globalny poziom logowania + * @param {LogLevels} level - Poziom logowania + */ + setGlobalLevel(level: LogLevels): this { + this.config.globalLevel = level; + this.saveConfig(); + return this; + } + + /** + * Ustaw poziom logowania dla konkretnego modułu + * @param {string} module - Nazwa modułu + * @param {LogLevels} level - Poziom logowania + */ + setModuleLevel(module: string, level: LogLevels): this { + this.config.moduleSettings[module] = level; + this.saveConfig(); + return this; + } + + /** + * Sprawdź, czy dany poziom logowania jest aktywny dla modułu + * @param {string} module - Nazwa modułu + * @param {LogLevels} level - Poziom logowania do sprawdzenia + * @returns {boolean} - Czy poziom jest aktywny + */ + isLevelEnabled(module: string, level: LogLevels): boolean { + if (!this.enabled) return false; + if (this.config.moduleSettings[module] !== undefined) { + return level >= this.config.moduleSettings[module]; + } + return level >= this.config.globalLevel; + } + + /** + * Formatuj znacznik czasu + * @returns {string} - Sformatowany znacznik czasu + */ + formatTimestamp(): string { + const now = new Date(); + const format = this.config.timestampFormat; + return format + .replace('HH', padStart(String(now.getHours()), 2, '0')) + .replace('mm', padStart(String(now.getMinutes()), 2, '0')) + .replace('ss', padStart(String(now.getSeconds()), 2, '0')) + .replace('SSS', padStart(String(now.getMilliseconds()), 3, '0')); + } + + /** + * Zapisz log + * @param {string} module - Nazwa modułu + * @param {LogLevels} level - Poziom logowania + * @param {any[]} args - Argumenty do zalogowania + */ + log(module: string, level: LogLevels, ...args: any[]): void { + if (!this.isLevelEnabled(module, level)) return; + + const timestamp = this.formatTimestamp(); + const levelName = LEVEL_NAMES[level]; + const logData: LogData = { + timestamp, + module, + level, + levelName, + args, + time: new Date() + }; + if (this.config.saveToStorage) { + this.logs.push(logData); + if (this.logs.length > this.config.maxStoredLogs) { + this.logs.shift(); + } + this.saveLogs(); + } + this.printToConsole(logData); + } + + /** + * Wyświetl log w konsoli + * @param {LogData} logData - Dane logu + */ + printToConsole(logData: LogData): void { + const {timestamp, module, level, levelName, args} = logData; + const prefix = `[${timestamp}] [${module}] [${levelName}]`; + if (this.config.useColors && typeof console.log === 'function') { + const color = COLORS[level] || '#000000'; + console.log(`%c${prefix}`, `color: ${color}; font-weight: bold;`, ...args); + return; + } + console.log(prefix, ...args); + } + + /** + * Zapisz logi do localStorage + */ + saveLogs(): void { + if (typeof localStorage !== 'undefined' && this.config.saveToStorage) { + try { + const simplifiedLogs = this.logs.map((log) => ({ + t: log.timestamp, + m: log.module, + l: log.level, + a: log.args.map((arg: any) => { + if (typeof arg === 'object') { + try { + return JSON.stringify(arg); + } catch (e) { + return String(arg); + } + } + return arg; + }) + })); + + localStorage.setItem(this.config.storageKey, JSON.stringify(simplifiedLogs)); + } catch (e) { + console.error('Failed to save logs to localStorage:', e); + } + } + } + + /** + * Załaduj logi z localStorage + */ + loadLogs(): void { + if (typeof localStorage !== 'undefined' && this.config.saveToStorage) { + try { + const storedLogs = localStorage.getItem(this.config.storageKey); + if (storedLogs) { + this.logs = JSON.parse(storedLogs); + } + } catch (e) { + console.error('Failed to load logs from localStorage:', e); + } + } + } + + /** + * Zapisz konfigurację do localStorage + */ + saveConfig(): void { + if (typeof localStorage !== 'undefined') { + try { + localStorage.setItem('layerforge_logger_config', JSON.stringify(this.config)); + } catch (e) { + console.error('Failed to save logger config to localStorage:', e); + } + } + } + + /** + * Załaduj konfigurację z localStorage + */ + loadConfig(): void { + if (typeof localStorage !== 'undefined') { + try { + const storedConfig = localStorage.getItem('layerforge_logger_config'); + if (storedConfig) { + this.config = {...this.config, ...JSON.parse(storedConfig)}; + } + } catch (e) { + console.error('Failed to load logger config from localStorage:', e); + } + } + } + + /** + * Wyczyść wszystkie logi + */ + clearLogs(): this { + this.logs = []; + if (typeof localStorage !== 'undefined') { + localStorage.removeItem(this.config.storageKey); + } + return this; + } + + /** + * Eksportuj logi do pliku + * @param {'json' | 'txt'} format - Format eksportu + */ + exportLogs(format: 'json' | 'txt' = 'json'): void { + if (this.logs.length === 0) { + console.warn('No logs to export'); + return; + } + + let content: string; + let mimeType: string; + let extension: string; + + if (format === 'json') { + content = JSON.stringify(this.logs, null, 2); + mimeType = 'application/json'; + extension = 'json'; + } else { + content = this.logs.map((log) => `[${log.timestamp}] [${log.module}] [${log.levelName}] ${log.args.join(' ')}`).join('\n'); + mimeType = 'text/plain'; + extension = 'txt'; + } + const blob = new Blob([content], {type: mimeType}); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `layerforge_logs_${new Date().toISOString().replace(/[:.]/g, '-')}.${extension}`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + + /** + * Log na poziomie DEBUG + * @param {string} module - Nazwa modułu + * @param {any[]} args - Argumenty do zalogowania + */ + debug(module: string, ...args: any[]): void { + this.log(module, LogLevel.DEBUG, ...args); + } + + /** + * Log na poziomie INFO + * @param {string} module - Nazwa modułu + * @param {any[]} args - Argumenty do zalogowania + */ + info(module: string, ...args: any[]): void { + this.log(module, LogLevel.INFO, ...args); + } + + /** + * Log na poziomie WARN + * @param {string} module - Nazwa modułu + * @param {any[]} args - Argumenty do zalogowania + */ + warn(module: string, ...args: any[]): void { + this.log(module, LogLevel.WARN, ...args); + } + + /** + * Log na poziomie ERROR + * @param {string} module - Nazwa modułu + * @param {any[]} args - Argumenty do zalogowania + */ + error(module: string, ...args: any[]): void { + this.log(module, LogLevel.ERROR, ...args); + } +} + +export const logger = new Logger(); +export const debug = (module: string, ...args: any[]) => logger.debug(module, ...args); +export const info = (module: string, ...args: any[]) => logger.info(module, ...args); +export const warn = (module: string, ...args: any[]) => logger.warn(module, ...args); +export const error = (module: string, ...args: any[]) => logger.error(module, ...args); + +declare global { + interface Window { + LayerForgeLogger: Logger; + } +} + +if (typeof window !== 'undefined') { + window.LayerForgeLogger = logger; +} + +export default logger; diff --git a/src/state-saver.worker.ts b/src/state-saver.worker.ts new file mode 100644 index 0000000..c39b9ed --- /dev/null +++ b/src/state-saver.worker.ts @@ -0,0 +1,93 @@ +console.log('[StateWorker] Worker script loaded and running.'); + +const DB_NAME = 'CanvasNodeDB'; +const STATE_STORE_NAME = 'CanvasState'; +const DB_VERSION = 3; + +let db: IDBDatabase | null; + +function log(...args: any[]): void { + console.log('[StateWorker]', ...args); +} + +function error(...args: any[]): void { + console.error('[StateWorker]', ...args); +} + +function createDBRequest(store: IDBObjectStore, operation: 'put', data: any, errorMessage: string): Promise { + return new Promise((resolve, reject) => { + let request: IDBRequest; + switch (operation) { + case 'put': + request = store.put(data); + break; + default: + reject(new Error(`Unknown operation: ${operation}`)); + return; + } + + request.onerror = (event) => { + error(errorMessage, (event.target as IDBRequest).error); + reject(errorMessage); + }; + + request.onsuccess = (event) => { + resolve((event.target as IDBRequest).result); + }; + }); +} + +function openDB(): Promise { + return new Promise((resolve, reject) => { + if (db) { + resolve(db); + return; + } + + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onerror = (event) => { + error("IndexedDB error:", (event.target as IDBOpenDBRequest).error); + reject("Error opening IndexedDB."); + }; + + request.onsuccess = (event) => { + db = (event.target as IDBOpenDBRequest).result; + log("IndexedDB opened successfully in worker."); + resolve(db); + }; + + request.onupgradeneeded = (event) => { + log("Upgrading IndexedDB in worker..."); + const tempDb = (event.target as IDBOpenDBRequest).result; + if (!tempDb.objectStoreNames.contains(STATE_STORE_NAME)) { + tempDb.createObjectStore(STATE_STORE_NAME, {keyPath: 'id'}); + } + }; + }); +} + +async function setCanvasState(id: string, state: any): Promise { + const db = await openDB(); + const transaction = db.transaction([STATE_STORE_NAME], 'readwrite'); + const store = transaction.objectStore(STATE_STORE_NAME); + await createDBRequest(store, 'put', {id, state}, "Error setting canvas state"); +} + +self.onmessage = async function(e: MessageEvent<{ state: any, nodeId: string }>): Promise { + log('Message received from main thread:', e.data ? 'data received' : 'no data'); + const { state, nodeId } = e.data; + + if (!state || !nodeId) { + error('Invalid data received from main thread'); + return; + } + + try { + log(`Saving state for node: ${nodeId}`); + await setCanvasState(nodeId, state); + log(`State saved successfully for node: ${nodeId}`); + } catch (err) { + error(`Failed to save state for node: ${nodeId}`, err); + } +}; diff --git a/src/templates/clipspace_clipboard_tooltip.html b/src/templates/clipspace_clipboard_tooltip.html new file mode 100644 index 0000000..2401727 --- /dev/null +++ b/src/templates/clipspace_clipboard_tooltip.html @@ -0,0 +1,13 @@ +

📋 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
+
+ 💡 Bestt for: ComfyUI workflow integration and node-to-node image transfer +
diff --git a/src/templates/mask_shortcuts.html b/src/templates/mask_shortcuts.html new file mode 100644 index 0000000..69721cc --- /dev/null +++ b/src/templates/mask_shortcuts.html @@ -0,0 +1,9 @@ +

Mask Mode

+ + + + + + + +
Click + DragPaint on the mask
Middle Mouse Button + DragPan canvas view
Mouse WheelZoom view in/out
Brush ControlsUse sliders to control brush Size, Strength, and Hardness
Clear MaskRemove the entire mask
Exit ModeClick the "Draw Mask" button again
diff --git a/src/templates/standard_shortcuts.html b/src/templates/standard_shortcuts.html new file mode 100644 index 0000000..ded155e --- /dev/null +++ b/src/templates/standard_shortcuts.html @@ -0,0 +1,40 @@ +

Canvas Control

+ + + + + + +
Click + DragPan canvas view
Mouse WheelZoom view in/out
Shift + Click (background)Start resizing canvas area
Shift + Ctrl + ClickStart moving entire canvas
Single Click (background)Deselect all layers
+ +

Clipboard & I/O

+ + + + +
Ctrl + CCopy selected layer(s)
Ctrl + VPaste from clipboard (image or internal layers)
Drag & Drop Image FileAdd image as a new layer
+ +

Layer Interaction

+ + + + + + + + + + + + + + +
Click + DragMove selected layer(s)
Ctrl + ClickAdd/Remove layer from selection
Alt + DragClone selected layer(s)
Right ClickShow blend mode & opacity menu
Mouse WheelScale layer (snaps to grid)
Ctrl + Mouse WheelFine-scale layer
Shift + Mouse WheelRotate layer by 5° steps
Shift + Ctrl + Mouse WheelSnap rotation to 5° increments
Arrow KeysNudge layer by 1px
Shift + Arrow KeysNudge layer by 10px
[ or ]Rotate by 1°
Shift + [ or ]Rotate by 10°
DeleteDelete selected layer(s)
+ +

Transform Handles (on selected layer)

+ + + + + +
Drag Corner/SideResize layer
Drag Rotation HandleRotate layer
Hold ShiftKeep aspect ratio / Snap rotation to 15°
Hold CtrlSnap to grid
diff --git a/src/templates/system_clipboard_tooltip.html b/src/templates/system_clipboard_tooltip.html new file mode 100644 index 0000000..b3be8eb --- /dev/null +++ b/src/templates/system_clipboard_tooltip.html @@ -0,0 +1,16 @@ +

📋 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. +
diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..8561258 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,147 @@ +import type { Canvas as CanvasClass } from './Canvas'; +import type { CanvasLayers } from './CanvasLayers'; + +export interface Layer { + id: string; + image: HTMLImageElement; + imageId: string; + name: string; + x: number; + y: number; + width: number; + height: number; + originalWidth: number; + originalHeight: number; + rotation: number; + zIndex: number; + blendMode: string; + opacity: number; + mask?: Float32Array; +} + +export interface ComfyNode { + id: number; + imgs?: HTMLImageElement[]; + widgets: any[]; + size: [number, number]; + graph: any; + canvasWidget?: any; + onResize?: () => void; + addDOMWidget: (name: string, type: string, element: HTMLElement, options?: any) => any; + addWidget: (type: string, name: string, value: any, callback?: (value: any) => void, options?: any) => any; + setDirtyCanvas: (force: boolean, dirty: boolean) => void; +} + +declare global { + interface Window { + MaskEditorDialog?: { + instance?: { + getMessageBroker: () => any; + }; + }; + } + + interface HTMLElement { + getContext?(contextId: '2d', options?: any): CanvasRenderingContext2D | null; + width: number; + height: number; + } +} + +export interface Canvas { + layers: Layer[]; + selectedLayer: Layer | null; + canvasSelection: any; + lastMousePosition: Point; + width: number; + height: number; + node: ComfyNode; + viewport: { x: number, y: number, zoom: number }; + canvas: HTMLCanvasElement; + offscreenCanvas: HTMLCanvasElement; + isMouseOver: boolean; + maskTool: any; + canvasLayersPanel: any; + canvasState: any; + widget?: { value: string }; + imageReferenceManager: any; + imageCache: any; + dataInitialized: boolean; + pendingDataCheck: number | null; + pendingBatchContext: any; + canvasLayers: any; + saveState: () => void; + render: () => void; + updateSelection: (layers: Layer[]) => void; + requestSaveState: (immediate?: boolean) => void; + saveToServer: (fileName: string) => Promise; + removeLayersByIds: (ids: string[]) => void; + batchPreviewManagers: any[]; + getMouseWorldCoordinates: (e: MouseEvent) => Point; + getMouseViewCoordinates: (e: MouseEvent) => Point; + updateOutputAreaSize: (width: number, height: number) => void; + undo: () => void; + redo: () => void; +} + +// A simplified interface for the Canvas class, containing only what ClipboardManager needs. +export interface CanvasForClipboard { + canvasLayers: CanvasLayersForClipboard; + node: ComfyNode; +} + +// A simplified interface for the CanvasLayers class. +export interface CanvasLayersForClipboard { + internalClipboard: Layer[]; + pasteLayers(): void; + addLayerWithImage(image: HTMLImageElement, layerProps: Partial, addMode: string): Promise; +} + +export type AddMode = 'mouse' | 'fit' | 'center' | 'default'; + +export type ClipboardPreference = 'system' | 'clipspace'; + +export interface WebSocketMessage { + type: string; + nodeId?: string; + [key: string]: any; +} + +export interface AckCallback { + resolve: (value: WebSocketMessage | PromiseLike) => void; + reject: (reason?: any) => void; +} + +export type AckCallbacks = Map; + +export interface CanvasState { + layersUndoStack: Layer[][]; + layersRedoStack: Layer[][]; + maskUndoStack: HTMLCanvasElement[]; + maskRedoStack: HTMLCanvasElement[]; + saveMaskState(): void; +} + +export interface Point { + x: number; + y: number; +} + +export interface Viewport { + x: number; + y: number; + zoom: number; +} + +export interface Tensor { + data: Float32Array; + shape: number[]; + width: number; + height: number; +} + +export interface ImageDataPixel { + data: Uint8ClampedArray; + width: number; + height: number; +} diff --git a/src/utils/ClipboardManager.ts b/src/utils/ClipboardManager.ts new file mode 100644 index 0000000..cf3b2f9 --- /dev/null +++ b/src/utils/ClipboardManager.ts @@ -0,0 +1,524 @@ +import {createModuleLogger} from "./LoggerUtils.js"; + +// @ts-ignore +import {api} from "../../../scripts/api.js"; +// @ts-ignore +import {app} from "../../../scripts/app.js"; +// @ts-ignore +import {ComfyApp} from "../../../scripts/app.js"; + +import type { AddMode, CanvasForClipboard, ClipboardPreference } from "../types.js"; + +const log = createModuleLogger('ClipboardManager'); + +export class ClipboardManager { + canvas: CanvasForClipboard; + clipboardPreference: ClipboardPreference; + constructor(canvas: CanvasForClipboard) { + this.canvas = canvas; + this.clipboardPreference = 'system'; // 'system', 'clipspace' + } + + /** + * Main paste handler that delegates to appropriate methods + * @param {AddMode} addMode - The mode for adding the layer + * @param {ClipboardPreference} preference - Clipboard preference ('system' or 'clipspace') + * @returns {Promise} - True if successful, false otherwise + */ + async handlePaste(addMode: AddMode = 'mouse', preference: ClipboardPreference = 'system'): Promise { + 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 {AddMode} addMode - The mode for adding the layer + * @returns {Promise} - True if successful, false otherwise + */ + async tryClipspacePaste(addMode: AddMode): Promise { + try { + log.info("Attempting to paste from ComfyUI Clipspace"); + 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 {AddMode} addMode - The mode for adding the layer + * @returns {Promise} - True if successful, false otherwise + */ + async trySystemClipboardPaste(addMode: AddMode): Promise { + 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); + }; + if (event.target?.result) { + img.src = event.target.result as string; + } + }; + 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: string): boolean { + 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 {AddMode} addMode - The mode for adding the layer + * @returns {Promise} - True if successful, false otherwise + */ + async loadImageFromPath(filePath: string, addMode: AddMode): Promise { + + 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 {AddMode} addMode - The mode for adding the layer + * @returns {Promise} - True if successful, false otherwise + */ + async loadFileViaBackend(filePath: string, addMode: AddMode): Promise { + 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: boolean = 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 {AddMode} addMode - The mode for adding the layer + * @returns {Promise} - True if successful, false otherwise + */ + async promptUserForFile(originalPath: string, addMode: AddMode): Promise { + 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 target = event.target as HTMLInputElement; + const file = 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); + }; + if (e.target?.result) { + img.src = e.target.result as string; + } + }; + 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: string): void { + 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 {AddMode} addMode - The mode for adding the layer + */ + showEmptyClipboardMessage(addMode: AddMode): void { + 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: string, duration = 3000): void { + + 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/src/utils/CommonUtils.ts b/src/utils/CommonUtils.ts new file mode 100644 index 0000000..8d2cff8 --- /dev/null +++ b/src/utils/CommonUtils.ts @@ -0,0 +1,289 @@ +import type { Layer } from '../types'; + +/** + * CommonUtils - Wspólne funkcje pomocnicze + * Eliminuje duplikację funkcji używanych w różnych modułach + */ + +export interface Point { + x: number; + y: number; +} + +/** + * Generuje unikalny identyfikator UUID + * @returns {string} UUID w formacie xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx + */ +export function generateUUID(): string { + 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); + }); +} + +/** + * Funkcja snap do siatki + * @param {number} value - Wartość do przyciągnięcia + * @param {number} gridSize - Rozmiar siatki (domyślnie 64) + * @returns {number} Wartość przyciągnięta do siatki + */ +export function snapToGrid(value: number, gridSize = 64): number { + return Math.round(value / gridSize) * gridSize; +} + +/** + * Oblicza dostosowanie snap dla warstwy + * @param {Object} layer - Obiekt warstwy + * @param {number} gridSize - Rozmiar siatki + * @param {number} snapThreshold - Próg przyciągania + * @returns {Point} Obiekt z dx i dy + */ +export function getSnapAdjustment(layer: Layer, gridSize = 64, snapThreshold = 10): Point { + if (!layer) { + return {x: 0, y: 0}; + } + + const layerEdges = { + left: layer.x, + right: layer.x + layer.width, + 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} + ].map(adj => ({ ...adj, abs: Math.abs(adj.delta) })); + + const y_adjustments = [ + {type: 'y', delta: snapToGrid(layerEdges.top, gridSize) - layerEdges.top}, + {type: 'y', delta: snapToGrid(layerEdges.bottom, gridSize) - layerEdges.bottom} + ].map(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 { + x: bestXSnap ? bestXSnap.delta : 0, + y: bestYSnap ? bestYSnap.delta : 0 + }; +} + +/** + * Konwertuje współrzędne świata na lokalne + * @param {number} worldX - Współrzędna X w świecie + * @param {number} worldY - Współrzędna Y w świecie + * @param {any} layerProps - Właściwości warstwy + * @returns {Point} Lokalne współrzędne {x, y} + */ +export function worldToLocal(worldX: number, worldY: number, layerProps: { centerX: number, centerY: number, rotation: number }): Point { + const dx = worldX - layerProps.centerX; + const dy = worldY - layerProps.centerY; + const rad = -layerProps.rotation * Math.PI / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + + return { + x: dx * cos - dy * sin, + y: dx * sin + dy * cos + }; +} + +/** + * Konwertuje współrzędne lokalne na świat + * @param {number} localX - Lokalna współrzędna X + * @param {number} localY - Lokalna współrzędna Y + * @param {any} layerProps - Właściwości warstwy + * @returns {Point} Współrzędne świata {x, y} + */ +export function localToWorld(localX: number, localY: number, layerProps: { centerX: number, centerY: number, rotation: number }): Point { + const rad = layerProps.rotation * Math.PI / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + + return { + x: layerProps.centerX + localX * cos - localY * sin, + y: layerProps.centerY + localX * sin + localY * cos + }; +} + +/** + * Klonuje warstwy (bez klonowania obiektów Image dla oszczędności pamięci) + * @param {Layer[]} layers - Tablica warstw do sklonowania + * @returns {Layer[]} Sklonowane warstwy + */ +export function cloneLayers(layers: Layer[]): Layer[] { + return layers.map(layer => ({ ...layer })); +} + +/** + * Tworzy sygnaturę stanu warstw (dla porównań) + * @param {Layer[]} layers - Tablica warstw + * @returns {string} Sygnatura JSON + */ +export function getStateSignature(layers: Layer[]): string { + return JSON.stringify(layers.map((layer, index) => { + const sig: any = { + index: index, + x: Math.round(layer.x * 100) / 100, // Round to avoid floating point precision issues + y: Math.round(layer.y * 100) / 100, + width: Math.round(layer.width * 100) / 100, + height: Math.round(layer.height * 100) / 100, + rotation: Math.round((layer.rotation || 0) * 100) / 100, + zIndex: layer.zIndex, + blendMode: layer.blendMode || 'normal', + opacity: layer.opacity !== undefined ? Math.round(layer.opacity * 100) / 100 : 1 + }; + + if (layer.imageId) { + sig.imageId = layer.imageId; + } + + if (layer.image && layer.image.src) { + sig.imageSrc = layer.image.src.substring(0, 100); // First 100 chars to avoid huge signatures + } + + return sig; + })); +} + +/** + * Debounce funkcja - opóźnia wykonanie funkcji + * @param {Function} func - Funkcja do wykonania + * @param {number} wait - Czas oczekiwania w ms + * @param {boolean} immediate - Czy wykonać natychmiast + * @returns {(...args: any[]) => void} Funkcja z debounce + */ +export function debounce(func: (...args: any[]) => void, wait: number, immediate?: boolean): (...args: any[]) => void { + let timeout: number | null; + return function executedFunction(this: any, ...args: any[]) { + const later = () => { + timeout = null; + if (!immediate) func.apply(this, args); + }; + const callNow = immediate && !timeout; + if (timeout) clearTimeout(timeout); + timeout = window.setTimeout(later, wait); + if (callNow) func.apply(this, args); + }; +} + +/** + * Throttle funkcja - ogranicza częstotliwość wykonania + * @param {Function} func - Funkcja do wykonania + * @param {number} limit - Limit czasu w ms + * @returns {(...args: any[]) => void} Funkcja z throttle + */ +export function throttle(func: (...args: any[]) => void, limit: number): (...args: any[]) => void { + let inThrottle: boolean; + return function(this: any, ...args: any[]) { + if (!inThrottle) { + func.apply(this, args); + inThrottle = true; + setTimeout(() => inThrottle = false, limit); + } + }; +} + +/** + * Ogranicza wartość do zakresu + * @param {number} value - Wartość do ograniczenia + * @param {number} min - Minimalna wartość + * @param {number} max - Maksymalna wartość + * @returns {number} Ograniczona wartość + */ +export function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +/** + * Interpolacja liniowa między dwoma wartościami + * @param {number} start - Wartość początkowa + * @param {number} end - Wartość końcowa + * @param {number} factor - Współczynnik interpolacji (0-1) + * @returns {number} Interpolowana wartość + */ +export function lerp(start: number, end: number, factor: number): number { + return start + (end - start) * factor; +} + +/** + * Konwertuje stopnie na radiany + * @param {number} degrees - Stopnie + * @returns {number} Radiany + */ +export function degreesToRadians(degrees: number): number { + return degrees * Math.PI / 180; +} + +/** + * Konwertuje radiany na stopnie + * @param {number} radians - Radiany + * @returns {number} Stopnie + */ +export function radiansToDegrees(radians: number): number { + return radians * 180 / Math.PI; +} + +/** + * Tworzy canvas z kontekstem - eliminuje duplikaty w kodzie + * @param {number} width - Szerokość canvas + * @param {number} height - Wysokość canvas + * @param {string} contextType - Typ kontekstu (domyślnie '2d') + * @param {object} contextOptions - Opcje kontekstu + * @returns {{canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D | null}} Obiekt z canvas i ctx + */ +export function createCanvas(width: number, height: number, contextType = '2d', contextOptions: any = {}): { canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D | null } { + const canvas = document.createElement('canvas'); + if (width) canvas.width = width; + if (height) canvas.height = height; + const ctx = canvas.getContext(contextType, contextOptions) as CanvasRenderingContext2D | null; + return { canvas, ctx }; +} + +/** + * Normalizuje wartość do zakresu Uint8 (0-255) + * @param {number} value - Wartość do znormalizowania (0-1) + * @returns {number} Wartość w zakresie 0-255 + */ +export function normalizeToUint8(value: number): number { + return Math.max(0, Math.min(255, Math.round(value * 255))); +} + +/** + * Generuje unikalną nazwę pliku z identyfikatorem node-a + * @param {string} baseName - Podstawowa nazwa pliku + * @param {string | number} nodeId - Identyfikator node-a + * @returns {string} Unikalna nazwa pliku + */ +export function generateUniqueFileName(baseName: string, nodeId: string | number): string { + const nodePattern = new RegExp(`_node_${nodeId}(?:_node_\\d+)*`); + if (nodePattern.test(baseName)) { + const cleanName = baseName.replace(/_node_\d+/g, ''); + const extension = cleanName.split('.').pop(); + const nameWithoutExt = cleanName.replace(`.${extension}`, ''); + return `${nameWithoutExt}_node_${nodeId}.${extension}`; + } + const extension = baseName.split('.').pop(); + const nameWithoutExt = baseName.replace(`.${extension}`, ''); + return `${nameWithoutExt}_node_${nodeId}.${extension}`; +} + +/** + * Sprawdza czy punkt jest w prostokącie + * @param {number} pointX - X punktu + * @param {number} pointY - Y punktu + * @param {number} rectX - X prostokąta + * @param {number} rectY - Y prostokąta + * @param {number} rectWidth - Szerokość prostokąta + * @param {number} rectHeight - Wysokość prostokąta + * @returns {boolean} Czy punkt jest w prostokącie + */ +export function isPointInRect(pointX: number, pointY: number, rectX: number, rectY: number, rectWidth: number, rectHeight: number): boolean { + return pointX >= rectX && pointX <= rectX + rectWidth && + pointY >= rectY && pointY <= rectY + rectHeight; +} diff --git a/src/utils/ImageUtils.ts b/src/utils/ImageUtils.ts new file mode 100644 index 0000000..7b074a0 --- /dev/null +++ b/src/utils/ImageUtils.ts @@ -0,0 +1,353 @@ +import {createModuleLogger} from "./LoggerUtils.js"; +import {withErrorHandling, createValidationError} from "../ErrorHandler.js"; +import type { Tensor, ImageDataPixel } from '../types'; + +const log = createModuleLogger('ImageUtils'); + +export function validateImageData(data: any): boolean { + log.debug("Validating data structure:", { + hasData: !!data, + type: typeof data, + isArray: Array.isArray(data), + keys: data ? Object.keys(data) : null, + shape: data?.shape, + dataType: data?.data ? data.data.constructor.name : null, + fullData: data + }); + + if (!data) { + log.info("Data is null or undefined"); + return false; + } + + if (Array.isArray(data)) { + log.debug("Data is array, getting first element"); + data = data[0]; + } + + if (!data || typeof data !== 'object') { + log.info("Invalid data type"); + return false; + } + + if (!data.data) { + log.info("Missing data property"); + return false; + } + + if (!(data.data instanceof Float32Array)) { + try { + data.data = new Float32Array(data.data); + } catch (e) { + log.error("Failed to convert data to Float32Array:", e); + return false; + } + } + + return true; +} + +export function convertImageData(data: any): ImageDataPixel { + log.info("Converting image data:", data); + + if (Array.isArray(data)) { + data = data[0]; + } + + const shape = data.shape; + const height = shape[1]; + const width = shape[2]; + const channels = shape[3]; + const floatData = new Float32Array(data.data); + + log.debug("Processing dimensions:", {height, width, channels}); + + const rgbaData = new Uint8ClampedArray(width * height * 4); + + for (let h = 0; h < height; h++) { + for (let w = 0; w < width; w++) { + const pixelIndex = (h * width + w) * 4; + const tensorIndex = (h * width + w) * channels; + + for (let c = 0; c < channels; c++) { + const value = floatData[tensorIndex + c]; + rgbaData[pixelIndex + c] = Math.max(0, Math.min(255, Math.round(value * 255))); + } + + rgbaData[pixelIndex + 3] = 255; + } + } + + return { + data: rgbaData, + width: width, + height: height + }; +} + +export function applyMaskToImageData(imageData: ImageDataPixel, maskData: Tensor): ImageDataPixel { + log.info("Applying mask to image data"); + + const rgbaData = new Uint8ClampedArray(imageData.data); + const width = imageData.width; + const height = imageData.height; + + const maskShape = maskData.shape; + const maskFloatData = new Float32Array(maskData.data); + + log.debug(`Applying mask of shape: ${maskShape}`); + + for (let h = 0; h < height; h++) { + for (let w = 0; w < width; w++) { + const pixelIndex = (h * width + w) * 4; + const maskIndex = h * width + w; + + const alpha = maskFloatData[maskIndex]; + rgbaData[pixelIndex + 3] = Math.max(0, Math.min(255, Math.round(alpha * 255))); + } + } + + log.info("Mask application completed"); + + return { + data: rgbaData, + width: width, + height: height + }; +} + +export const prepareImageForCanvas = withErrorHandling(function (inputImage: any): ImageDataPixel { + log.info("Preparing image for canvas:", inputImage); + + if (Array.isArray(inputImage)) { + inputImage = inputImage[0]; + } + + if (!inputImage || !inputImage.shape || !inputImage.data) { + throw createValidationError("Invalid input image format", {inputImage}); + } + + const shape = inputImage.shape; + const height = shape[1]; + const width = shape[2]; + const channels = shape[3]; + const floatData = new Float32Array(inputImage.data); + + log.debug("Image dimensions:", {height, width, channels}); + + const rgbaData = new Uint8ClampedArray(width * height * 4); + + for (let h = 0; h < height; h++) { + for (let w = 0; w < width; w++) { + const pixelIndex = (h * width + w) * 4; + const tensorIndex = (h * width + w) * channels; + + for (let c = 0; c < channels; c++) { + const value = floatData[tensorIndex + c]; + rgbaData[pixelIndex + c] = Math.max(0, Math.min(255, Math.round(value * 255))); + } + + rgbaData[pixelIndex + 3] = 255; + } + } + + return { + data: rgbaData, + width: width, + height: height + }; +}, 'prepareImageForCanvas'); + +export const imageToTensor = withErrorHandling(async function (image: HTMLImageElement | HTMLCanvasElement): Promise { + if (!image) { + throw createValidationError("Image is required"); + } + + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + + canvas.width = image.width; + canvas.height = image.height; + + if (ctx) { + 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], + width: canvas.width, + height: canvas.height + }; + } + throw new Error("Canvas context not available"); +}, 'imageToTensor'); + +export const tensorToImage = withErrorHandling(async function (tensor: Tensor): Promise { + if (!tensor || !tensor.data || !tensor.shape) { + throw createValidationError("Invalid tensor format", {tensor}); + } + + const [, height, width, channels] = tensor.shape; + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + + canvas.width = width; + canvas.height = height; + + if (ctx) { + 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); + img.onerror = (err) => reject(err); + img.src = canvas.toDataURL(); + }); + } + throw new Error("Canvas context not available"); +}, 'tensorToImage'); + +export const resizeImage = withErrorHandling(async function (image: HTMLImageElement, maxWidth: number, maxHeight: number): Promise { + if (!image) { + throw createValidationError("Image is required"); + } + + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + + const originalWidth = image.width; + const originalHeight = image.height; + 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; + + if (ctx) { + 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); + img.onerror = (err) => reject(err); + img.src = canvas.toDataURL(); + }); + } + throw new Error("Canvas context not available"); +}, 'resizeImage'); + +export const createThumbnail = withErrorHandling(async function (image: HTMLImageElement, size = 128): Promise { + return resizeImage(image, size, size); +}, 'createThumbnail'); + +export const imageToBase64 = withErrorHandling(function (image: HTMLImageElement | HTMLCanvasElement, format = 'png', quality = 0.9): string { + if (!image) { + throw createValidationError("Image is required"); + } + + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + + canvas.width = image instanceof HTMLImageElement ? image.naturalWidth : image.width; + canvas.height = image instanceof HTMLImageElement ? image.naturalHeight : image.height; + + if (ctx) { + ctx.drawImage(image, 0, 0); + const mimeType = `image/${format}`; + return canvas.toDataURL(mimeType, quality); + } + throw new Error("Canvas context not available"); +}, 'imageToBase64'); + +export const base64ToImage = withErrorHandling(function (base64: string): Promise { + if (!base64) { + throw createValidationError("Base64 string is required"); + } + + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error("Failed to load image from base64")); + img.src = base64; + }); +}, 'base64ToImage'); + +export function isValidImage(image: any): image is HTMLImageElement | HTMLCanvasElement { + return image && + (image instanceof HTMLImageElement || image instanceof HTMLCanvasElement) && + image.width > 0 && + image.height > 0; +} + +export function getImageInfo(image: HTMLImageElement | HTMLCanvasElement): {width: number, height: number, aspectRatio: number, area: number} | null { + if (!isValidImage(image)) { + return null; + } + + const width = image instanceof HTMLImageElement ? image.naturalWidth : image.width; + const height = image instanceof HTMLImageElement ? image.naturalHeight : image.height; + + return { + width, + height, + aspectRatio: width / height, + area: width * height + }; +} + +export function createImageFromSource(source: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = (err) => reject(err); + img.src = source; + }); +} + +export const createEmptyImage = withErrorHandling(function (width: number, height: number, color = 'transparent'): Promise { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + + canvas.width = width; + canvas.height = height; + + if (ctx) { + 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); + img.onerror = (err) => reject(err); + img.src = canvas.toDataURL(); + }); + } + throw new Error("Canvas context not available"); +}, 'createEmptyImage'); diff --git a/src/utils/LoggerUtils.ts b/src/utils/LoggerUtils.ts new file mode 100644 index 0000000..c00bba2 --- /dev/null +++ b/src/utils/LoggerUtils.ts @@ -0,0 +1,92 @@ +/** + * LoggerUtils - Centralizacja inicjalizacji loggerów + * Eliminuje powtarzalny kod inicjalizacji loggera w każdym module + */ + +import {logger, LogLevel} from "../logger.js"; +import { LOG_LEVEL } from '../config.js'; + +export interface Logger { + debug: (...args: any[]) => void; + info: (...args: any[]) => void; + warn: (...args: any[]) => void; + error: (...args: any[]) => void; +} + +/** + * Tworzy obiekt loggera dla modułu z predefiniowanymi metodami + * @param {string} moduleName - Nazwa modułu + * @returns {Logger} Obiekt z metodami logowania + */ +export function createModuleLogger(moduleName: string): Logger { + logger.setModuleLevel(moduleName, LogLevel[LOG_LEVEL as keyof typeof LogLevel]); + + return { + debug: (...args: any[]) => logger.debug(moduleName, ...args), + info: (...args: any[]) => logger.info(moduleName, ...args), + warn: (...args: any[]) => logger.warn(moduleName, ...args), + error: (...args: any[]) => logger.error(moduleName, ...args) + }; +} + +/** + * Tworzy logger z automatycznym wykrywaniem nazwy modułu z URL + * @returns {Logger} Obiekt z metodami logowania + */ +export function createAutoLogger(): Logger { + const stack = new Error().stack; + const match = stack?.match(/\/([^\/]+)\.js/); + const moduleName = match ? match[1] : 'Unknown'; + + return createModuleLogger(moduleName); +} + +/** + * Wrapper dla operacji z automatycznym logowaniem błędów + * @param {Function} operation - Operacja do wykonania + * @param {Logger} log - Obiekt loggera + * @param {string} operationName - Nazwa operacji (dla logów) + * @returns {Function} Opakowana funkcja + */ +export function withErrorLogging any>( + operation: T, + log: Logger, + operationName: string +): (...args: Parameters) => Promise> { + return async function(this: any, ...args: Parameters): Promise> { + try { + log.debug(`Starting ${operationName}`); + const result = await operation.apply(this, args); + log.debug(`Completed ${operationName}`); + return result; + } catch (error) { + log.error(`Error in ${operationName}:`, error); + throw error; + } + }; +} + +/** + * Decorator dla metod klasy z automatycznym logowaniem + * @param {Logger} log - Obiekt loggera + * @param {string} methodName - Nazwa metody + */ +export function logMethod(log: Logger, methodName?: string) { + return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) { + const originalMethod = descriptor.value; + + descriptor.value = async function (...args: any[]) { + try { + log.debug(`${methodName || propertyKey} started`); + const result = await originalMethod.apply(this, args); + log.debug(`${methodName || propertyKey} completed`); + return result; + } catch (error) { + log.error(`${methodName || propertyKey} failed:`, error); + throw error; + } + }; + + return descriptor; + }; +} diff --git a/src/utils/ResourceManager.ts b/src/utils/ResourceManager.ts new file mode 100644 index 0000000..42fd859 --- /dev/null +++ b/src/utils/ResourceManager.ts @@ -0,0 +1,32 @@ +// @ts-ignore +import { $el } from "../../../scripts/ui.js"; + +export function addStylesheet(url: string): void { + if (url.endsWith(".js")) { + url = url.substr(0, url.length - 2) + "css"; + } + $el("link", { + parent: document.head, + rel: "stylesheet", + type: "text/css", + href: url.startsWith("http") ? url : getUrl(url), + }); +} + +export function getUrl(path: string, baseUrl?: string | URL): string { + if (baseUrl) { + return new URL(path, baseUrl).toString(); + } else { + // @ts-ignore + return new URL("../" + path, import.meta.url).toString(); + } +} + +export async function loadTemplate(path: string, baseUrl?: string | URL): Promise { + const url = getUrl(path, baseUrl); + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to load template: ${url}`); + } + return await response.text(); +} diff --git a/src/utils/WebSocketManager.ts b/src/utils/WebSocketManager.ts new file mode 100644 index 0000000..993780b --- /dev/null +++ b/src/utils/WebSocketManager.ts @@ -0,0 +1,166 @@ +import {createModuleLogger} from "./LoggerUtils.js"; +import type { WebSocketMessage, AckCallbacks } from "../types.js"; + +const log = createModuleLogger('WebSocketManager'); + +class WebSocketManager { + private socket: WebSocket | null; + private messageQueue: string[]; + private isConnecting: boolean; + private reconnectAttempts: number; + private readonly maxReconnectAttempts: number; + private readonly reconnectInterval: number; + private ackCallbacks: AckCallbacks; + private messageIdCounter: number; + + constructor(private url: string) { + this.socket = null; + this.messageQueue = []; + this.isConnecting = false; + this.reconnectAttempts = 0; + this.maxReconnectAttempts = 10; + this.reconnectInterval = 5000; // 5 seconds + this.ackCallbacks = new Map(); + this.messageIdCounter = 0; + + this.connect(); + } + + connect() { + if (this.socket && this.socket.readyState === WebSocket.OPEN) { + log.debug("WebSocket is already open."); + return; + } + + if (this.isConnecting) { + log.debug("Connection attempt already in progress."); + return; + } + + this.isConnecting = true; + log.info(`Connecting to WebSocket at ${this.url}...`); + + try { + this.socket = new WebSocket(this.url); + + this.socket.onopen = () => { + this.isConnecting = false; + this.reconnectAttempts = 0; + log.info("WebSocket connection established."); + this.flushMessageQueue(); + }; + + this.socket.onmessage = (event: MessageEvent) => { + try { + const data: WebSocketMessage = JSON.parse(event.data); + log.debug("Received message:", data); + + if (data.type === 'ack' && data.nodeId) { + const callback = this.ackCallbacks.get(data.nodeId); + if (callback) { + log.debug(`ACK received for nodeId: ${data.nodeId}, resolving promise.`); + callback.resolve(data); + this.ackCallbacks.delete(data.nodeId); + } + } + + } catch (error) { + log.error("Error parsing incoming WebSocket message:", error); + } + }; + + this.socket.onclose = (event: CloseEvent) => { + this.isConnecting = false; + if (event.wasClean) { + log.info(`WebSocket closed cleanly, code=${event.code}, reason=${event.reason}`); + } else { + log.warn("WebSocket connection died. Attempting to reconnect..."); + this.handleReconnect(); + } + }; + + this.socket.onerror = (error: Event) => { + this.isConnecting = false; + log.error("WebSocket error:", error); + }; + } catch (error) { + this.isConnecting = false; + log.error("Failed to create WebSocket connection:", error); + this.handleReconnect(); + } + } + + handleReconnect() { + if (this.reconnectAttempts < this.maxReconnectAttempts) { + this.reconnectAttempts++; + log.info(`Reconnect attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts}...`); + setTimeout(() => this.connect(), this.reconnectInterval); + } else { + log.error("Max reconnect attempts reached. Giving up."); + } + } + + sendMessage(data: WebSocketMessage, requiresAck = false): Promise { + return new Promise((resolve, reject) => { + const nodeId = data.nodeId; + if (requiresAck && !nodeId) { + return reject(new Error("A nodeId is required for messages that need acknowledgment.")); + } + + const message = JSON.stringify(data); + + if (this.socket && this.socket.readyState === WebSocket.OPEN) { + this.socket.send(message); + log.debug("Sent message:", data); + if (requiresAck && nodeId) { + log.debug(`Message for nodeId ${nodeId} requires ACK. Setting up callback.`); + + const timeout = setTimeout(() => { + this.ackCallbacks.delete(nodeId); + reject(new Error(`ACK timeout for nodeId ${nodeId}`)); + log.warn(`ACK timeout for nodeId ${nodeId}.`); + }, 10000); // 10-second timeout + + this.ackCallbacks.set(nodeId, { + resolve: (responseData: WebSocketMessage | PromiseLike) => { + clearTimeout(timeout); + resolve(responseData); + }, + reject: (error: any) => { + clearTimeout(timeout); + reject(error); + } + }); + } else { + resolve(); // Resolve immediately if no ACK is needed + } + } else { + log.warn("WebSocket not open. Queuing message."); + this.messageQueue.push(message); + if (!this.isConnecting) { + this.connect(); + } + + if (requiresAck) { + reject(new Error("Cannot send message with ACK required while disconnected.")); + } else { + resolve(); + } + } + }); + } + + flushMessageQueue() { + log.debug(`Flushing ${this.messageQueue.length} queued messages.`); + + while (this.messageQueue.length > 0) { + const message = this.messageQueue.shift(); + if (this.socket && message) { + this.socket.send(message); + } + } + } +} + +const wsUrl = `ws://${window.location.host}/layerforge/canvas_ws`; +export const webSocketManager = new WebSocketManager(wsUrl); diff --git a/src/utils/mask_utils.ts b/src/utils/mask_utils.ts new file mode 100644 index 0000000..5be6cc9 --- /dev/null +++ b/src/utils/mask_utils.ts @@ -0,0 +1,196 @@ +import {createModuleLogger} from "./LoggerUtils.js"; +import type { Canvas } from '../Canvas.js'; +// @ts-ignore +import {ComfyApp} from "../../../scripts/app.js"; + +const log = createModuleLogger('MaskUtils'); + +export function new_editor(app: ComfyApp): boolean { + if (!app) return false; + return !!app.ui.settings.getSettingValue('Comfy.MaskEditor.UseNewEditor'); +} + +function get_mask_editor_element(app: ComfyApp): HTMLElement | null { + return new_editor(app) ? document.getElementById('maskEditor') : document.getElementById('maskCanvas')?.parentElement ?? null; +} + +export function mask_editor_showing(app: ComfyApp): boolean { + const editor = get_mask_editor_element(app); + return !!editor && editor.style.display !== "none"; +} + +export function hide_mask_editor(app: ComfyApp): void { + if (mask_editor_showing(app)) { + const editor = document.getElementById('maskEditor'); + if (editor) { + editor.style.display = 'none'; + } + } +} + +function get_mask_editor_cancel_button(app: ComfyApp): HTMLElement | null { + 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 as HTMLElement).textContent || (button as HTMLInputElement).value || ''; + if (text.toLowerCase().includes('cancel')) { + log.debug("Found cancel button by text content:", text); + return button as HTMLElement; + } + } + + const editorElement = get_mask_editor_element(app); + if (editorElement) { + const childNodes = editorElement?.parentElement?.lastChild?.childNodes; + if (childNodes && childNodes.length > 2 && childNodes[2] instanceof HTMLElement) { + return childNodes[2]; + } + } + + return null; +} + +function get_mask_editor_save_button(app: ComfyApp): HTMLElement | null { + const saveButton = document.getElementById("maskEditor_topBarSaveButton"); + if (saveButton) { + return saveButton; + } + const editorElement = get_mask_editor_element(app); + if (editorElement) { + const childNodes = editorElement?.parentElement?.lastChild?.childNodes; + if (childNodes && childNodes.length > 2 && childNodes[2] instanceof HTMLElement) { + return childNodes[2]; + } + } + return null; +} + +export function mask_editor_listen_for_cancel(app: ComfyApp, callback: () => void): void { + let attempts = 0; + const maxAttempts = 50; // 5 sekund + + const findAndAttachListener = () => { + attempts++; + const cancel_button = get_mask_editor_cancel_button(app); + + if (cancel_button instanceof HTMLElement && !(cancel_button as any).filter_listener_added) { + log.info("Cancel button found, attaching listener"); + cancel_button.addEventListener('click', callback); + (cancel_button as any).filter_listener_added = true; + } else if (attempts < maxAttempts) { + + setTimeout(findAndAttachListener, 100); + } else { + log.warn("Could not find cancel button after", maxAttempts, "attempts"); + + const globalClickHandler = (event: MouseEvent) => { + const target = event.target as HTMLElement; + const text = target.textContent || (target as HTMLInputElement).value || ''; + if (target && (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: ComfyApp): void { + const button = get_mask_editor_save_button(app); + if (button instanceof HTMLElement) { + button.click(); + } +} + +export function press_maskeditor_cancel(app: ComfyApp): void { + const button = get_mask_editor_cancel_button(app); + if (button instanceof HTMLElement) { + button.click(); + } +} + +/** + * Uruchamia mask editor z predefiniowaną maską + * @param {Canvas} canvasInstance - Instancja Canvas + * @param {HTMLImageElement | 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: Canvas, maskImage: HTMLImageElement | HTMLCanvasElement, sendCleanImage = true): void { + 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 {Canvas} canvasInstance - Instancja Canvas + */ +export function start_mask_editor_auto(canvasInstance: Canvas): void { + if (!canvasInstance) { + log.error('Canvas instance is required'); + return; + } + canvasInstance.startMaskEditor(null, true); +} + +/** + * 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: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = (err) => reject(err); + 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: HTMLCanvasElement): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = (err) => reject(err); + img.src = canvas.toDataURL(); + }); +}