4 Commits

Author SHA1 Message Date
Dariusz L
3c85b99167 Update pyproject.toml 2025-07-04 07:32:14 +02:00
Dariusz L
4e55bb25bc Improve matting error handling and user feedback
Adds specific handling for JSONDecodeError during model loading in Python, returning a clear error message if the model config is corrupted. Updates the JS/TS frontends to show a custom error dialog with details and copy-to-clipboard functionality instead of a simple alert, and ensures spinner removal is safe. This improves user experience and troubleshooting for matting model errors.
2025-07-04 07:31:33 +02:00
Dariusz L
5adc77471f project migration to typescript
Project migration to typescript
2025-07-04 04:22:51 +02:00
Dariusz L
3e4cdf10bc Refactor CanvasView: externalize styles and tooltips
Moved inline CSS from CanvasView.js to a dedicated canvas_view.css file and added dynamic stylesheet loading. Extracted tooltip and shortcut HTML into separate template files and implemented a ResourceManager utility for loading stylesheets and templates. Updated CanvasInteractions.js and CanvasView.js to use the new resource management and template loading approach, improving maintainability and modularity.
2025-07-03 17:27:00 +02:00
65 changed files with 13192 additions and 3510 deletions

View File

@@ -612,11 +612,11 @@ class BiRefNetMatting:
"models") "models")
def load_model(self, model_path): def load_model(self, model_path):
from json.decoder import JSONDecodeError
try: try:
if model_path not in self.model_cache: if model_path not in self.model_cache:
full_model_path = os.path.join(self.base_path, "BiRefNet") full_model_path = os.path.join(self.base_path, "BiRefNet")
log_info(f"Loading BiRefNet model from {full_model_path}...") log_info(f"Loading BiRefNet model from {full_model_path}...")
try: try:
self.model = AutoModelForImageSegmentation.from_pretrained( self.model = AutoModelForImageSegmentation.from_pretrained(
"ZhengPeng7/BiRefNet", "ZhengPeng7/BiRefNet",
@@ -628,6 +628,13 @@ class BiRefNetMatting:
self.model = self.model.cuda() self.model = self.model.cuda()
self.model_cache[model_path] = self.model self.model_cache[model_path] = self.model
log_info("Model loaded successfully from Hugging Face") log_info("Model loaded successfully from Hugging Face")
except JSONDecodeError as e:
log_error(f"JSONDecodeError: Failed to load model from {full_model_path}. The model's config.json may be corrupted.")
raise RuntimeError(
"The matting model's configuration file (config.json) appears to be corrupted. "
f"Please manually delete the directory '{full_model_path}' and try again. "
"This will force a fresh download of the model."
) from e
except Exception as e: except Exception as e:
log_error(f"Failed to load model from Hugging Face: {str(e)}") log_error(f"Failed to load model from Hugging Face: {str(e)}")
# Re-raise with a more informative message # Re-raise with a more informative message
@@ -799,6 +806,12 @@ async def matting(request):
"error": "Network Connection Error", "error": "Network Connection Error",
"details": "Failed to download the matting model from Hugging Face. Please check your internet connection." "details": "Failed to download the matting model from Hugging Face. Please check your internet connection."
}, status=400) }, status=400)
except RuntimeError as e:
log_error(f"Runtime error during matting: {e}")
return web.json_response({
"error": "Matting Model Error",
"details": str(e)
}, status=500)
except Exception as e: except Exception as e:
log_exception(f"Error in matting endpoint: {e}") log_exception(f"Error in matting endpoint: {e}")
# Check for offline error message from Hugging Face # Check for offline error message from Hugging Face

View File

@@ -1,7 +1,5 @@
import {createModuleLogger} from "./utils/LoggerUtils.js"; import { createModuleLogger } from "./utils/LoggerUtils.js";
const log = createModuleLogger('BatchPreviewManager'); const log = createModuleLogger('BatchPreviewManager');
export class BatchPreviewManager { export class BatchPreviewManager {
constructor(canvas, initialPosition = { x: 0, y: 0 }, generationArea = null) { constructor(canvas, initialPosition = { x: 0, y: 0 }, generationArea = null) {
this.canvas = canvas; this.canvas = canvas;
@@ -9,33 +7,25 @@ export class BatchPreviewManager {
this.layers = []; this.layers = [];
this.currentIndex = 0; this.currentIndex = 0;
this.element = null; this.element = null;
this.counterElement = null;
this.uiInitialized = false; this.uiInitialized = false;
this.maskWasVisible = false; this.maskWasVisible = false;
// Position in canvas world coordinates
this.worldX = initialPosition.x; this.worldX = initialPosition.x;
this.worldY = initialPosition.y; this.worldY = initialPosition.y;
this.isDragging = false; this.isDragging = false;
this.generationArea = generationArea; // Store the generation area this.generationArea = generationArea;
} }
updateScreenPosition(viewport) { updateScreenPosition(viewport) {
if (!this.active || !this.element) return; if (!this.active || !this.element)
return;
// Translate world coordinates to screen coordinates
const screenX = (this.worldX - viewport.x) * viewport.zoom; const screenX = (this.worldX - viewport.x) * viewport.zoom;
const screenY = (this.worldY - viewport.y) * viewport.zoom; const screenY = (this.worldY - viewport.y) * viewport.zoom;
const scale = 1;
// 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
this.element.style.transform = `translate(${screenX}px, ${screenY}px) scale(${scale})`; this.element.style.transform = `translate(${screenX}px, ${screenY}px) scale(${scale})`;
} }
_createUI() { _createUI() {
if (this.uiInitialized) return; if (this.uiInitialized)
return;
this.element = document.createElement('div'); this.element = document.createElement('div');
this.element.id = 'layerforge-batch-preview'; this.element.id = 'layerforge-batch-preview';
this.element.style.cssText = ` this.element.style.cssText = `
@@ -56,65 +46,53 @@ export class BatchPreviewManager {
cursor: move; cursor: move;
user-select: none; user-select: none;
`; `;
this.element.addEventListener('mousedown', (e) => { this.element.addEventListener('mousedown', (e) => {
if (e.target.tagName === 'BUTTON') return; if (e.target.tagName === 'BUTTON')
return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
this.isDragging = true; this.isDragging = true;
const handleMouseMove = (moveEvent) => { const handleMouseMove = (moveEvent) => {
if (this.isDragging) { if (this.isDragging) {
// Convert screen pixel movement to world coordinate movement
const deltaX = moveEvent.movementX / this.canvas.viewport.zoom; const deltaX = moveEvent.movementX / this.canvas.viewport.zoom;
const deltaY = moveEvent.movementY / this.canvas.viewport.zoom; const deltaY = moveEvent.movementY / this.canvas.viewport.zoom;
this.worldX += deltaX; this.worldX += deltaX;
this.worldY += deltaY; this.worldY += deltaY;
// The render loop will handle updating the screen position, but we need to trigger it. // The render loop will handle updating the screen position, but we need to trigger it.
this.canvas.render(); this.canvas.render();
} }
}; };
const handleMouseUp = () => { const handleMouseUp = () => {
this.isDragging = false; this.isDragging = false;
document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp); document.removeEventListener('mouseup', handleMouseUp);
}; };
document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp); document.addEventListener('mouseup', handleMouseUp);
}); });
const prevButton = this._createButton('◀', 'Previous'); // Left arrow const prevButton = this._createButton('◀', 'Previous'); // Left arrow
const nextButton = this._createButton('▶', 'Next'); // Right arrow const nextButton = this._createButton('▶', 'Next'); // Right arrow
const confirmButton = this._createButton('✔', 'Confirm'); // Checkmark const confirmButton = this._createButton('✔', 'Confirm'); // Checkmark
const cancelButton = this._createButton('✖', 'Cancel All'); // X mark const cancelButton = this._createButton('✖', 'Cancel All');
const closeButton = this._createButton('➲', 'Close'); // Door icon const closeButton = this._createButton('➲', 'Close');
this.counterElement = document.createElement('span'); this.counterElement = document.createElement('span');
this.counterElement.style.minWidth = '40px'; this.counterElement.style.minWidth = '40px';
this.counterElement.style.textAlign = 'center'; this.counterElement.style.textAlign = 'center';
this.counterElement.style.fontWeight = 'bold'; this.counterElement.style.fontWeight = 'bold';
prevButton.onclick = () => this.navigate(-1); prevButton.onclick = () => this.navigate(-1);
nextButton.onclick = () => this.navigate(1); nextButton.onclick = () => this.navigate(1);
confirmButton.onclick = () => this.confirm(); confirmButton.onclick = () => this.confirm();
cancelButton.onclick = () => this.cancelAndRemoveAll(); cancelButton.onclick = () => this.cancelAndRemoveAll();
closeButton.onclick = () => this.hide(); closeButton.onclick = () => this.hide();
this.element.append(prevButton, this.counterElement, nextButton, confirmButton, cancelButton, closeButton); this.element.append(prevButton, this.counterElement, nextButton, confirmButton, cancelButton, closeButton);
if (this.canvas.canvas.parentNode) { if (this.canvas.canvas.parentElement) {
this.canvas.canvas.parentNode.appendChild(this.element); this.canvas.canvas.parentElement.appendChild(this.element);
} else { }
else {
log.error("Could not find parent node to attach batch preview UI."); log.error("Could not find parent node to attach batch preview UI.");
} }
this.uiInitialized = true; this.uiInitialized = true;
} }
_createButton(innerHTML, title) { _createButton(innerHTML, title) {
const button = document.createElement('button'); const button = document.createElement('button');
button.innerHTML = innerHTML; button.innerHTML = innerHTML;
@@ -136,14 +114,11 @@ export class BatchPreviewManager {
button.onmouseout = () => button.style.background = '#555'; button.onmouseout = () => button.style.background = '#555';
return button; return button;
} }
show(layers) { show(layers) {
if (!layers || layers.length <= 1) { if (!layers || layers.length <= 1) {
return; return;
} }
this._createUI(); this._createUI();
// Auto-hide mask logic // Auto-hide mask logic
this.maskWasVisible = this.canvas.maskTool.isOverlayVisible; this.maskWasVisible = this.canvas.maskTool.isOverlayVisible;
if (this.maskWasVisible) { if (this.maskWasVisible) {
@@ -155,103 +130,83 @@ export class BatchPreviewManager {
} }
this.canvas.render(); this.canvas.render();
} }
log.info(`Showing batch preview for ${layers.length} layers.`); log.info(`Showing batch preview for ${layers.length} layers.`);
this.layers = layers; this.layers = layers;
this.currentIndex = 0; this.currentIndex = 0;
if (this.element) {
// Make the element visible BEFORE calculating its size this.element.style.display = 'flex';
this.element.style.display = 'flex'; }
this.active = true; this.active = true;
if (this.element) {
// Now that it's visible, we can get its dimensions and adjust the position. const menuWidthInWorld = this.element.offsetWidth / this.canvas.viewport.zoom;
const menuWidthInWorld = this.element.offsetWidth / this.canvas.viewport.zoom; const paddingInWorld = 20 / this.canvas.viewport.zoom;
const paddingInWorld = 20 / this.canvas.viewport.zoom; this.worldX -= menuWidthInWorld / 2;
this.worldY += paddingInWorld;
this.worldX -= menuWidthInWorld / 2; // Center horizontally }
this.worldY += paddingInWorld; // Add padding below the output area
this._update(); this._update();
} }
hide() { hide() {
log.info('Hiding batch preview.'); log.info('Hiding batch preview.');
if (this.element) { if (this.element) {
this.element.remove(); this.element.remove();
} }
this.active = false; this.active = false;
const index = this.canvas.batchPreviewManagers.indexOf(this); const index = this.canvas.batchPreviewManagers.indexOf(this);
if (index > -1) { if (index > -1) {
this.canvas.batchPreviewManagers.splice(index, 1); this.canvas.batchPreviewManagers.splice(index, 1);
} }
// Trigger a final render to ensure the generation area outline is removed
this.canvas.render(); this.canvas.render();
// Restore mask visibility if it was hidden by this manager
if (this.maskWasVisible && !this.canvas.maskTool.isOverlayVisible) { if (this.maskWasVisible && !this.canvas.maskTool.isOverlayVisible) {
this.canvas.maskTool.toggleOverlayVisibility(); 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) { if (toggleBtn) {
toggleBtn.classList.add('primary'); toggleBtn.classList.add('primary');
toggleBtn.textContent = "Show Mask"; toggleBtn.textContent = "Show Mask";
} }
} }
this.maskWasVisible = false; // Reset state this.maskWasVisible = false;
this.canvas.layers.forEach((l) => l.visible = true);
// Make all layers visible again upon closing
this.canvas.layers.forEach(l => l.visible = true);
this.canvas.render(); this.canvas.render();
} }
navigate(direction) { navigate(direction) {
this.currentIndex += direction; this.currentIndex += direction;
if (this.currentIndex < 0) { if (this.currentIndex < 0) {
this.currentIndex = this.layers.length - 1; this.currentIndex = this.layers.length - 1;
} else if (this.currentIndex >= this.layers.length) { }
else if (this.currentIndex >= this.layers.length) {
this.currentIndex = 0; this.currentIndex = 0;
} }
this._update(); this._update();
} }
confirm() { confirm() {
const layerToKeep = this.layers[this.currentIndex]; const layerToKeep = this.layers[this.currentIndex];
log.info(`Confirming selection: Keeping layer ${layerToKeep.id}.`); log.info(`Confirming selection: Keeping layer ${layerToKeep.id}.`);
const layersToDelete = this.layers.filter((l) => l.id !== layerToKeep.id);
const layersToDelete = this.layers.filter(l => l.id !== layerToKeep.id); const layerIdsToDelete = layersToDelete.map((l) => l.id);
const layerIdsToDelete = layersToDelete.map(l => l.id);
this.canvas.removeLayersByIds(layerIdsToDelete); this.canvas.removeLayersByIds(layerIdsToDelete);
log.info(`Deleted ${layersToDelete.length} other layers.`); log.info(`Deleted ${layersToDelete.length} other layers.`);
this.hide(); this.hide();
} }
cancelAndRemoveAll() { cancelAndRemoveAll() {
log.info('Cancel clicked. Removing all new layers.'); 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); this.canvas.removeLayersByIds(layerIdsToDelete);
log.info(`Deleted all ${layerIdsToDelete.length} new layers.`); log.info(`Deleted all ${layerIdsToDelete.length} new layers.`);
this.hide(); this.hide();
} }
_update() { _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]); this._focusOnLayer(this.layers[this.currentIndex]);
} }
_focusOnLayer(layer) { _focusOnLayer(layer) {
if (!layer) return; if (!layer)
return;
log.debug(`Focusing on layer ${layer.id}`); log.debug(`Focusing on layer ${layer.id}`);
// Move the selected layer to the top of the layer stack // Move the selected layer to the top of the layer stack
this.canvas.canvasLayers.moveLayers([layer], { toIndex: 0 }); this.canvas.canvasLayers.moveLayers([layer], { toIndex: 0 });
this.canvas.updateSelection([layer]); this.canvas.updateSelection([layer]);
// Render is called by moveLayers, but we call it again to be safe // Render is called by moveLayers, but we call it again to be safe
this.canvas.render(); this.canvas.render();
} }

View File

@@ -1,33 +1,29 @@
import {app, ComfyApp} from "../../scripts/app.js"; // @ts-ignore
import {api} from "../../scripts/api.js"; import { api } from "../../scripts/api.js";
import {removeImage} from "./db.js"; import { MaskTool } from "./MaskTool.js";
import {MaskTool} from "./MaskTool.js"; import { CanvasState } from "./CanvasState.js";
import {CanvasState} from "./CanvasState.js"; import { CanvasInteractions } from "./CanvasInteractions.js";
import {CanvasInteractions} from "./CanvasInteractions.js"; import { CanvasLayers } from "./CanvasLayers.js";
import {CanvasLayers} from "./CanvasLayers.js"; import { CanvasLayersPanel } from "./CanvasLayersPanel.js";
import {CanvasLayersPanel} from "./CanvasLayersPanel.js"; import { CanvasRenderer } from "./CanvasRenderer.js";
import {CanvasRenderer} from "./CanvasRenderer.js"; import { CanvasIO } from "./CanvasIO.js";
import {CanvasIO} from "./CanvasIO.js"; import { ImageReferenceManager } from "./ImageReferenceManager.js";
import {ImageReferenceManager} from "./ImageReferenceManager.js"; import { BatchPreviewManager } from "./BatchPreviewManager.js";
import {BatchPreviewManager} from "./BatchPreviewManager.js"; import { createModuleLogger } from "./utils/LoggerUtils.js";
import {createModuleLogger} from "./utils/LoggerUtils.js";
import { debounce } from "./utils/CommonUtils.js"; import { debounce } from "./utils/CommonUtils.js";
import {CanvasMask} from "./CanvasMask.js"; import { CanvasMask } from "./CanvasMask.js";
import {CanvasSelection} from "./CanvasSelection.js"; import { CanvasSelection } from "./CanvasSelection.js";
const useChainCallback = (original, next) => { const useChainCallback = (original, next) => {
if (original === undefined || original === null) { if (original === undefined || original === null) {
return next; return next;
} }
return function(...args) { return function (...args) {
const originalReturn = original.apply(this, args); const originalReturn = original.apply(this, args);
const nextReturn = next.apply(this, args); const nextReturn = next.apply(this, args);
return nextReturn === undefined ? originalReturn : nextReturn; return nextReturn === undefined ? originalReturn : nextReturn;
}; };
}; };
const log = createModuleLogger('Canvas'); const log = createModuleLogger('Canvas');
/** /**
* Canvas - Fasada dla systemu rysowania * Canvas - Fasada dla systemu rysowania
* *
@@ -41,65 +37,72 @@ export class Canvas {
this.node = node; this.node = node;
this.widget = widget; this.widget = widget;
this.canvas = document.createElement('canvas'); 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.width = 512;
this.height = 512; this.height = 512;
this.layers = []; this.layers = [];
this.onStateChange = callbacks.onStateChange || null; this.onStateChange = callbacks.onStateChange;
this.lastMousePosition = {x: 0, y: 0}; this.onHistoryChange = callbacks.onHistoryChange;
this.lastMousePosition = { x: 0, y: 0 };
this.viewport = { this.viewport = {
x: -(this.width / 4), x: -(this.width / 4),
y: -(this.height / 4), y: -(this.height / 4),
zoom: 0.8, zoom: 0.8,
}; };
this.offscreenCanvas = document.createElement('canvas'); this.offscreenCanvas = document.createElement('canvas');
this.offscreenCtx = this.offscreenCanvas.getContext('2d', { this.offscreenCtx = this.offscreenCanvas.getContext('2d', {
alpha: false alpha: false
}); });
this.dataInitialized = false; this.dataInitialized = false;
this.pendingDataCheck = null; this.pendingDataCheck = null;
this.imageCache = new Map(); this.imageCache = new Map();
this.requestSaveState = () => { };
this._initializeModules(callbacks); this.maskTool = new MaskTool(this, { onStateChange: this.onStateChange });
this.canvasMask = new CanvasMask(this);
this._setupCanvas(); 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.interaction = this.canvasInteractions.interaction;
this.previewVisible = false;
this.isMouseOver = false;
this._initializeModules();
this._setupCanvas();
log.debug('Canvas widget element:', this.node); log.debug('Canvas widget element:', this.node);
log.info('Canvas initialized', { log.info('Canvas initialized', {
nodeId: this.node.id, nodeId: this.node.id,
dimensions: {width: this.width, height: this.height}, dimensions: { width: this.width, height: this.height },
viewport: this.viewport viewport: this.viewport
}); });
this.setPreviewVisibility(false); this.setPreviewVisibility(false);
} }
async waitForWidget(name, node, interval = 100, timeout = 20000) { async waitForWidget(name, node, interval = 100, timeout = 20000) {
const startTime = Date.now(); const startTime = Date.now();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const check = () => { const check = () => {
const widget = node.widgets.find(w => w.name === name); const widget = node.widgets.find((w) => w.name === name);
if (widget) { if (widget) {
resolve(widget); resolve(widget);
} else if (Date.now() - startTime > timeout) { }
else if (Date.now() - startTime > timeout) {
reject(new Error(`Widget "${name}" not found within timeout.`)); reject(new Error(`Widget "${name}" not found within timeout.`));
} else { }
else {
setTimeout(check, interval); setTimeout(check, interval);
} }
}; };
check(); check();
}); });
} }
/** /**
* Kontroluje widoczność podglądu canvas * Kontroluje widoczność podglądu canvas
* @param {boolean} visible - Czy podgląd ma być widoczny * @param {boolean} visible - Czy podgląd ma być widoczny
@@ -107,11 +110,9 @@ export class Canvas {
async setPreviewVisibility(visible) { async setPreviewVisibility(visible) {
this.previewVisible = visible; this.previewVisible = visible;
log.info("Canvas preview visibility set to:", visible); log.info("Canvas preview visibility set to:", visible);
const imagePreviewWidget = await this.waitForWidget("$$canvas-image-preview", this.node); const imagePreviewWidget = await this.waitForWidget("$$canvas-image-preview", this.node);
if (imagePreviewWidget) { if (imagePreviewWidget) {
log.debug("Found $$canvas-image-preview widget, controlling visibility"); log.debug("Found $$canvas-image-preview widget, controlling visibility");
if (visible) { if (visible) {
if (imagePreviewWidget.options) { if (imagePreviewWidget.options) {
imagePreviewWidget.options.hidden = false; imagePreviewWidget.options.hidden = false;
@@ -125,7 +126,8 @@ export class Canvas {
imagePreviewWidget.computeSize = function () { imagePreviewWidget.computeSize = function () {
return [0, 250]; // Szerokość 0 (auto), wysokość 250 return [0, 250]; // Szerokość 0 (auto), wysokość 250
}; };
} else { }
else {
if (imagePreviewWidget.options) { if (imagePreviewWidget.options) {
imagePreviewWidget.options.hidden = true; imagePreviewWidget.options.hidden = true;
} }
@@ -135,44 +137,27 @@ export class Canvas {
if ('hidden' in imagePreviewWidget) { if ('hidden' in imagePreviewWidget) {
imagePreviewWidget.hidden = true; imagePreviewWidget.hidden = true;
} }
imagePreviewWidget.computeSize = function () { imagePreviewWidget.computeSize = function () {
return [0, 0]; // Szerokość 0, wysokość 0 return [0, 0]; // Szerokość 0, wysokość 0
}; };
} }
this.render() this.render();
} else { }
else {
log.warn("$$canvas-image-preview widget not found in Canvas.js"); log.warn("$$canvas-image-preview widget not found in Canvas.js");
} }
} }
/** /**
* Inicjalizuje moduły systemu canvas * Inicjalizuje moduły systemu canvas
* @private * @private
*/ */
_initializeModules(callbacks) { _initializeModules() {
log.debug('Initializing Canvas modules...'); log.debug('Initializing Canvas modules...');
// Stwórz opóźnioną wersję funkcji zapisu stanu // 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._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'); log.debug('Canvas modules initialized successfully');
} }
/** /**
* Konfiguruje podstawowe właściwości canvas * Konfiguruje podstawowe właściwości canvas
* @private * @private
@@ -181,14 +166,11 @@ export class Canvas {
this.initCanvas(); this.initCanvas();
this.canvasInteractions.setupEventListeners(); this.canvasInteractions.setupEventListeners();
this.canvasIO.initNodeData(); this.canvasIO.initNodeData();
this.layers = this.layers.map((layer) => ({
this.layers = this.layers.map(layer => ({
...layer, ...layer,
opacity: 1 opacity: 1
})); }));
} }
/** /**
* Ładuje stan canvas z bazy danych * Ładuje stan canvas z bazy danych
*/ */
@@ -201,24 +183,21 @@ export class Canvas {
} }
this.saveState(); this.saveState();
this.render(); this.render();
// Dodaj to wywołanie, aby panel renderował się po załadowaniu stanu // Dodaj to wywołanie, aby panel renderował się po załadowaniu stanu
if (this.canvasLayersPanel) { if (this.canvasLayersPanel) {
this.canvasLayersPanel.onLayersChanged(); this.canvasLayersPanel.onLayersChanged();
} }
} }
/** /**
* Zapisuje obecny stan * Zapisuje obecny stan
* @param {boolean} replaceLast - Czy zastąpić ostatni stan w historii * @param {boolean} replaceLast - Czy zastąpić ostatni stan w historii
*/ */
saveState(replaceLast = false) { 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.canvasState.saveState(replaceLast);
this.incrementOperationCount(); this.incrementOperationCount();
this._notifyStateChange(); this._notifyStateChange();
} }
/** /**
* Cofnij ostatnią operację * Cofnij ostatnią operację
*/ */
@@ -226,21 +205,16 @@ export class Canvas {
log.info('Performing undo operation'); log.info('Performing undo operation');
const historyInfo = this.canvasState.getHistoryInfo(); const historyInfo = this.canvasState.getHistoryInfo();
log.debug('History state before undo:', historyInfo); log.debug('History state before undo:', historyInfo);
this.canvasState.undo(); this.canvasState.undo();
this.incrementOperationCount(); this.incrementOperationCount();
this._notifyStateChange(); this._notifyStateChange();
// Powiadom panel warstw o zmianie stanu warstw // Powiadom panel warstw o zmianie stanu warstw
if (this.canvasLayersPanel) { if (this.canvasLayersPanel) {
this.canvasLayersPanel.onLayersChanged(); this.canvasLayersPanel.onLayersChanged();
this.canvasLayersPanel.onSelectionChanged(); this.canvasLayersPanel.onSelectionChanged();
} }
log.debug('Undo completed, layers count:', this.layers.length); log.debug('Undo completed, layers count:', this.layers.length);
} }
/** /**
* Ponów cofniętą operację * Ponów cofniętą operację
*/ */
@@ -248,27 +222,22 @@ export class Canvas {
log.info('Performing redo operation'); log.info('Performing redo operation');
const historyInfo = this.canvasState.getHistoryInfo(); const historyInfo = this.canvasState.getHistoryInfo();
log.debug('History state before redo:', historyInfo); log.debug('History state before redo:', historyInfo);
this.canvasState.redo(); this.canvasState.redo();
this.incrementOperationCount(); this.incrementOperationCount();
this._notifyStateChange(); this._notifyStateChange();
// Powiadom panel warstw o zmianie stanu warstw // Powiadom panel warstw o zmianie stanu warstw
if (this.canvasLayersPanel) { if (this.canvasLayersPanel) {
this.canvasLayersPanel.onLayersChanged(); this.canvasLayersPanel.onLayersChanged();
this.canvasLayersPanel.onSelectionChanged(); this.canvasLayersPanel.onSelectionChanged();
} }
log.debug('Redo completed, layers count:', this.layers.length); log.debug('Redo completed, layers count:', this.layers.length);
} }
/** /**
* Renderuje canvas * Renderuje canvas
*/ */
render() { render() {
this.canvasRenderer.render(); this.canvasRenderer.render();
} }
/** /**
* Dodaje warstwę z obrazem * Dodaje warstwę z obrazem
* @param {Image} image - Obraz do dodania * @param {Image} image - Obraz do dodania
@@ -277,49 +246,40 @@ export class Canvas {
*/ */
async addLayer(image, layerProps = {}, addMode = 'default') { async addLayer(image, layerProps = {}, addMode = 'default') {
const result = await this.canvasLayers.addLayerWithImage(image, layerProps, addMode); const result = await this.canvasLayers.addLayerWithImage(image, layerProps, addMode);
// Powiadom panel warstw o dodaniu nowej warstwy // Powiadom panel warstw o dodaniu nowej warstwy
if (this.canvasLayersPanel) { if (this.canvasLayersPanel) {
this.canvasLayersPanel.onLayersChanged(); this.canvasLayersPanel.onLayersChanged();
} }
return result; return result;
} }
/** /**
* Usuwa wybrane warstwy * Usuwa wybrane warstwy
*/ */
removeLayersByIds(layerIds) { removeLayersByIds(layerIds) {
if (!layerIds || layerIds.length === 0) return; if (!layerIds || layerIds.length === 0)
return;
const initialCount = this.layers.length; const initialCount = this.layers.length;
this.saveState(); 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 // 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.canvasSelection.updateSelection(newSelection);
this.render(); this.render();
this.saveState(); this.saveState();
if (this.canvasLayersPanel) { if (this.canvasLayersPanel) {
this.canvasLayersPanel.onLayersChanged(); this.canvasLayersPanel.onLayersChanged();
} }
log.info(`Removed ${initialCount - this.layers.length} layers by ID.`); log.info(`Removed ${initialCount - this.layers.length} layers by ID.`);
} }
removeSelectedLayers() { removeSelectedLayers() {
return this.canvasSelection.removeSelectedLayers(); return this.canvasSelection.removeSelectedLayers();
} }
/** /**
* Duplikuje zaznaczone warstwy (w pamięci, bez zapisu stanu) * Duplikuje zaznaczone warstwy (w pamięci, bez zapisu stanu)
*/ */
duplicateSelectedLayers() { duplicateSelectedLayers() {
return this.canvasSelection.duplicateSelectedLayers(); return this.canvasSelection.duplicateSelectedLayers();
} }
/** /**
* Aktualizuje zaznaczenie warstw i powiadamia wszystkie komponenty. * Aktualizuje zaznaczenie warstw i powiadamia wszystkie komponenty.
* To jest "jedyne źródło prawdy" o zmianie zaznaczenia. * To jest "jedyne źródło prawdy" o zmianie zaznaczenia.
@@ -328,14 +288,12 @@ export class Canvas {
updateSelection(newSelection) { updateSelection(newSelection) {
return this.canvasSelection.updateSelection(newSelection); return this.canvasSelection.updateSelection(newSelection);
} }
/** /**
* Logika aktualizacji zaznaczenia, wywoływana przez panel warstw. * Logika aktualizacji zaznaczenia, wywoływana przez panel warstw.
*/ */
updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index) { updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index) {
return this.canvasSelection.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index); return this.canvasSelection.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index);
} }
/** /**
* Zmienia rozmiar obszaru wyjściowego * Zmienia rozmiar obszaru wyjściowego
* @param {number} width - Nowa szerokość * @param {number} width - Nowa szerokość
@@ -345,32 +303,27 @@ export class Canvas {
updateOutputAreaSize(width, height, saveHistory = true) { updateOutputAreaSize(width, height, saveHistory = true) {
return this.canvasLayers.updateOutputAreaSize(width, height, saveHistory); return this.canvasLayers.updateOutputAreaSize(width, height, saveHistory);
} }
/** /**
* Eksportuje spłaszczony canvas jako blob * Eksportuje spłaszczony canvas jako blob
*/ */
async getFlattenedCanvasAsBlob() { async getFlattenedCanvasAsBlob() {
return this.canvasLayers.getFlattenedCanvasAsBlob(); return this.canvasLayers.getFlattenedCanvasAsBlob();
} }
/** /**
* Eksportuje spłaszczony canvas z maską jako kanałem alpha * Eksportuje spłaszczony canvas z maską jako kanałem alpha
*/ */
async getFlattenedCanvasWithMaskAsBlob() { async getFlattenedCanvasWithMaskAsBlob() {
return this.canvasLayers.getFlattenedCanvasWithMaskAsBlob(); return this.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
} }
/** /**
* Importuje najnowszy obraz * Importuje najnowszy obraz
*/ */
async importLatestImage() { async importLatestImage() {
return this.canvasIO.importLatestImage(); return this.canvasIO.importLatestImage();
} }
_addAutoRefreshToggle() { _addAutoRefreshToggle() {
let autoRefreshEnabled = false; let autoRefreshEnabled = false;
let lastExecutionStartTime = 0; let lastExecutionStartTime = 0;
const handleExecutionStart = () => { const handleExecutionStart = () => {
if (autoRefreshEnabled) { if (autoRefreshEnabled) {
lastExecutionStartTime = Date.now(); lastExecutionStartTime = Date.now();
@@ -393,62 +346,40 @@ export class Canvas {
this.render(); // Trigger render to show the pending outline immediately this.render(); // Trigger render to show the pending outline immediately
} }
}; };
const handleExecutionSuccess = async () => { const handleExecutionSuccess = async () => {
if (autoRefreshEnabled) { if (autoRefreshEnabled) {
log.info('Auto-refresh triggered, importing latest images.'); log.info('Auto-refresh triggered, importing latest images.');
if (!this.pendingBatchContext) { if (!this.pendingBatchContext) {
log.warn("execution_start did not fire, cannot process batch. Awaiting next execution."); log.warn("execution_start did not fire, cannot process batch. Awaiting next execution.");
return; return;
} }
// Use the captured output area for image import // Use the captured output area for image import
const newLayers = await this.canvasIO.importLatestImages( const newLayers = await this.canvasIO.importLatestImages(lastExecutionStartTime, this.pendingBatchContext.outputArea);
lastExecutionStartTime,
this.pendingBatchContext.outputArea
);
if (newLayers && newLayers.length > 1) { if (newLayers && newLayers.length > 1) {
const newManager = new BatchPreviewManager( const newManager = new BatchPreviewManager(this, this.pendingBatchContext.spawnPosition, this.pendingBatchContext.outputArea);
this,
this.pendingBatchContext.spawnPosition,
this.pendingBatchContext.outputArea
);
this.batchPreviewManagers.push(newManager); this.batchPreviewManagers.push(newManager);
newManager.show(newLayers); newManager.show(newLayers);
} }
// Consume the context // Consume the context
this.pendingBatchContext = null; this.pendingBatchContext = null;
// Final render to clear the outline if it was the last one // Final render to clear the outline if it was the last one
this.render(); this.render();
} }
}; };
this.node.addWidget('toggle', 'Auto-refresh after generation', false, (value) => {
this.node.addWidget( autoRefreshEnabled = value;
'toggle', log.debug('Auto-refresh toggled:', value);
'Auto-refresh after generation', }, {
false, serialize: false
(value) => { });
autoRefreshEnabled = value;
log.debug('Auto-refresh toggled:', value);
}, {
serialize: false
}
);
api.addEventListener('execution_start', handleExecutionStart); api.addEventListener('execution_start', handleExecutionStart);
api.addEventListener('execution_success', handleExecutionSuccess); api.addEventListener('execution_success', handleExecutionSuccess);
this.node.onRemoved = useChainCallback(this.node.onRemoved, () => { this.node.onRemoved = useChainCallback(this.node.onRemoved, () => {
log.info('Node removed, cleaning up auto-refresh listeners.'); log.info('Node removed, cleaning up auto-refresh listeners.');
api.removeEventListener('execution_start', handleExecutionStart); api.removeEventListener('execution_start', handleExecutionStart);
api.removeEventListener('execution_success', handleExecutionSuccess); api.removeEventListener('execution_success', handleExecutionSuccess);
}); });
} }
/** /**
* Uruchamia edytor masek * Uruchamia edytor masek
* @param {Image|HTMLCanvasElement|null} predefinedMask - Opcjonalna maska do nałożenia po otwarciu editora * @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) { async startMaskEditor(predefinedMask = null, sendCleanImage = true) {
return this.canvasMask.startMaskEditor(predefinedMask, sendCleanImage); return this.canvasMask.startMaskEditor(predefinedMask, sendCleanImage);
} }
/** /**
* Inicjalizuje podstawowe właściwości canvas * Inicjalizuje podstawowe właściwości canvas
*/ */
@@ -473,29 +402,24 @@ export class Canvas {
this.canvas.tabIndex = 0; this.canvas.tabIndex = 0;
this.canvas.style.outline = 'none'; this.canvas.style.outline = 'none';
} }
/** /**
* Pobiera współrzędne myszy w układzie świata * Pobiera współrzędne myszy w układzie świata
* @param {MouseEvent} e - Zdarzenie myszy * @param {MouseEvent} e - Zdarzenie myszy
*/ */
getMouseWorldCoordinates(e) { getMouseWorldCoordinates(e) {
const rect = this.canvas.getBoundingClientRect(); const rect = this.canvas.getBoundingClientRect();
const mouseX_DOM = e.clientX - rect.left; const mouseX_DOM = e.clientX - rect.left;
const mouseY_DOM = e.clientY - rect.top; 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 scaleX = this.offscreenCanvas.width / rect.width;
const scaleY = this.offscreenCanvas.height / rect.height; const scaleY = this.offscreenCanvas.height / rect.height;
const mouseX_Buffer = mouseX_DOM * scaleX; const mouseX_Buffer = mouseX_DOM * scaleX;
const mouseY_Buffer = mouseY_DOM * scaleY; const mouseY_Buffer = mouseY_DOM * scaleY;
const worldX = (mouseX_Buffer / this.viewport.zoom) + this.viewport.x; const worldX = (mouseX_Buffer / this.viewport.zoom) + this.viewport.x;
const worldY = (mouseY_Buffer / this.viewport.zoom) + this.viewport.y; 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 * Pobiera współrzędne myszy w układzie widoku
* @param {MouseEvent} e - Zdarzenie myszy * @param {MouseEvent} e - Zdarzenie myszy
@@ -504,23 +428,18 @@ export class Canvas {
const rect = this.canvas.getBoundingClientRect(); const rect = this.canvas.getBoundingClientRect();
const mouseX_DOM = e.clientX - rect.left; const mouseX_DOM = e.clientX - rect.left;
const mouseY_DOM = e.clientY - rect.top; const mouseY_DOM = e.clientY - rect.top;
const scaleX = this.canvas.width / rect.width; const scaleX = this.canvas.width / rect.width;
const scaleY = this.canvas.height / rect.height; const scaleY = this.canvas.height / rect.height;
const mouseX_Canvas = mouseX_DOM * scaleX; const mouseX_Canvas = mouseX_DOM * scaleX;
const mouseY_Canvas = mouseY_DOM * scaleY; 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 * Aktualizuje zaznaczenie po operacji historii
*/ */
updateSelectionAfterHistory() { updateSelectionAfterHistory() {
return this.canvasSelection.updateSelectionAfterHistory(); return this.canvasSelection.updateSelectionAfterHistory();
} }
/** /**
* Aktualizuje przyciski historii * Aktualizuje przyciski historii
*/ */
@@ -533,7 +452,6 @@ export class Canvas {
}); });
} }
} }
/** /**
* Zwiększa licznik operacji (dla garbage collection) * Zwiększa licznik operacji (dla garbage collection)
*/ */
@@ -542,7 +460,6 @@ export class Canvas {
this.imageReferenceManager.incrementOperationCount(); this.imageReferenceManager.incrementOperationCount();
} }
} }
/** /**
* Czyści zasoby canvas * Czyści zasoby canvas
*/ */
@@ -552,7 +469,6 @@ export class Canvas {
} }
log.info("Canvas destroyed"); log.info("Canvas destroyed");
} }
/** /**
* Powiadamia o zmianie stanu * Powiadamia o zmianie stanu
* @private * @private

View File

@@ -1,76 +1,72 @@
import {createCanvas} from "./utils/CommonUtils.js"; import { createCanvas } from "./utils/CommonUtils.js";
import {createModuleLogger} from "./utils/LoggerUtils.js"; import { createModuleLogger } from "./utils/LoggerUtils.js";
import {webSocketManager} from "./utils/WebSocketManager.js"; import { webSocketManager } from "./utils/WebSocketManager.js";
const log = createModuleLogger('CanvasIO'); const log = createModuleLogger('CanvasIO');
export class CanvasIO { export class CanvasIO {
constructor(canvas) { constructor(canvas) {
this.canvas = canvas; this.canvas = canvas;
this._saveInProgress = null; this._saveInProgress = null;
} }
async saveToServer(fileName, outputMode = 'disk') { async saveToServer(fileName, outputMode = 'disk') {
if (outputMode === 'disk') { if (outputMode === 'disk') {
if (!window.canvasSaveStates) { if (!window.canvasSaveStates) {
window.canvasSaveStates = new Map(); window.canvasSaveStates = new Map();
} }
const nodeId = this.canvas.node.id; const nodeId = this.canvas.node.id;
const saveKey = `${nodeId}_${fileName}`; const saveKey = `${nodeId}_${fileName}`;
if (this._saveInProgress || window.canvasSaveStates.get(saveKey)) { if (this._saveInProgress || window.canvasSaveStates.get(saveKey)) {
log.warn(`Save already in progress for node ${nodeId}, waiting...`); log.warn(`Save already in progress for node ${nodeId}, waiting...`);
return this._saveInProgress || window.canvasSaveStates.get(saveKey); return this._saveInProgress || window.canvasSaveStates.get(saveKey);
} }
log.info(`Starting saveToServer (disk) with fileName: ${fileName} for node: ${nodeId}`); log.info(`Starting saveToServer (disk) with fileName: ${fileName} for node: ${nodeId}`);
this._saveInProgress = this._performSave(fileName, outputMode); this._saveInProgress = this._performSave(fileName, outputMode);
window.canvasSaveStates.set(saveKey, this._saveInProgress); window.canvasSaveStates.set(saveKey, this._saveInProgress);
try { try {
return await this._saveInProgress; return await this._saveInProgress;
} finally { }
finally {
this._saveInProgress = null; this._saveInProgress = null;
window.canvasSaveStates.delete(saveKey); window.canvasSaveStates.delete(saveKey);
log.debug(`Save completed for node ${nodeId}, lock released`); log.debug(`Save completed for node ${nodeId}, lock released`);
} }
} else { }
else {
log.info(`Starting saveToServer (RAM) for node: ${this.canvas.node.id}`); log.info(`Starting saveToServer (RAM) for node: ${this.canvas.node.id}`);
return this._performSave(fileName, outputMode); return this._performSave(fileName, outputMode);
} }
} }
async _performSave(fileName, outputMode) { async _performSave(fileName, outputMode) {
if (this.canvas.layers.length === 0) { if (this.canvas.layers.length === 0) {
log.warn(`Node ${this.canvas.node.id} has no layers, creating empty canvas`); log.warn(`Node ${this.canvas.node.id} has no layers, creating empty canvas`);
return Promise.resolve(true); return Promise.resolve(true);
} }
await this.canvas.canvasState.saveStateToDB(true); await this.canvas.canvasState.saveStateToDB();
const nodeId = this.canvas.node.id; const nodeId = this.canvas.node.id;
const delay = (nodeId % 10) * 50; const delay = (nodeId % 10) * 50;
if (delay > 0) { if (delay > 0) {
await new Promise(resolve => setTimeout(resolve, delay)); await new Promise(resolve => setTimeout(resolve, delay));
} }
return new Promise((resolve) => { return new Promise((resolve) => {
const {canvas: tempCanvas, ctx: tempCtx} = 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 { canvas: maskCanvas, ctx: maskCtx } = createCanvas(this.canvas.width, this.canvas.height);
const visibilityCanvas = document.createElement('canvas'); const visibilityCanvas = document.createElement('canvas');
visibilityCanvas.width = this.canvas.width; visibilityCanvas.width = this.canvas.width;
visibilityCanvas.height = this.canvas.height; 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.fillStyle = '#ffffff';
maskCtx.fillRect(0, 0, this.canvas.width, this.canvas.height); maskCtx.fillRect(0, 0, this.canvas.width, this.canvas.height);
log.debug(`Canvas contexts created, starting layer rendering`); log.debug(`Canvas contexts created, starting layer rendering`);
const sortedLayers = this.canvas.layers.sort((a, b) => a.zIndex - b.zIndex); const sortedLayers = this.canvas.layers.sort((a, b) => a.zIndex - b.zIndex);
log.debug(`Processing ${sortedLayers.length} layers in order`); log.debug(`Processing ${sortedLayers.length} layers in order`);
sortedLayers.forEach((layer, index) => { 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(`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}`); log.debug(`Layer ${index}: blendMode=${layer.blendMode || 'normal'}, opacity=${layer.opacity !== undefined ? layer.opacity : 1}`);
tempCtx.save(); tempCtx.save();
tempCtx.globalCompositeOperation = layer.blendMode || 'normal'; tempCtx.globalCompositeOperation = layer.blendMode || 'normal';
tempCtx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1; tempCtx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
@@ -78,7 +74,6 @@ export class CanvasIO {
tempCtx.rotate(layer.rotation * Math.PI / 180); 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(); tempCtx.restore();
log.debug(`Layer ${index} rendered successfully`); log.debug(`Layer ${index} rendered successfully`);
visibilityCtx.save(); visibilityCtx.save();
visibilityCtx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2); 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] = maskData.data[i + 1] = maskData.data[i + 2] = maskValue;
maskData.data[i + 3] = 255; maskData.data[i + 3] = 255;
} }
maskCtx.putImageData(maskData, 0, 0); maskCtx.putImageData(maskData, 0, 0);
const toolMaskCanvas = this.canvas.maskTool.getMask(); const toolMaskCanvas = this.canvas.maskTool.getMask();
if (toolMaskCanvas) { if (toolMaskCanvas) {
const tempMaskCanvas = document.createElement('canvas'); const tempMaskCanvas = document.createElement('canvas');
tempMaskCanvas.width = this.canvas.width; tempMaskCanvas.width = this.canvas.width;
tempMaskCanvas.height = this.canvas.height; tempMaskCanvas.height = this.canvas.height;
const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true }); 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); tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height);
const maskX = this.canvas.maskTool.x; const maskX = this.canvas.maskTool.x;
const maskY = this.canvas.maskTool.y; 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})`); 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 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 destY = Math.max(0, maskY);
const copyWidth = Math.min(toolMaskCanvas.width - sourceX, // Available width in source
const copyWidth = Math.min( this.canvas.width - destX // Available width in destination
toolMaskCanvas.width - sourceX, // Available width in source
this.canvas.width - destX // Available width in destination
); );
const copyHeight = Math.min( const copyHeight = Math.min(toolMaskCanvas.height - sourceY, // Available height in source
toolMaskCanvas.height - sourceY, // Available height in source this.canvas.height - destY // Available height in destination
this.canvas.height - destY // Available height in destination
); );
if (copyWidth > 0 && copyHeight > 0) { if (copyWidth > 0 && copyHeight > 0) {
log.debug(`Copying mask region: source(${sourceX}, ${sourceY}) to dest(${destX}, ${destY}) size(${copyWidth}, ${copyHeight})`); log.debug(`Copying mask region: source(${sourceX}, ${sourceY}) to dest(${destX}, ${destY}) size(${copyWidth}, ${copyHeight})`);
tempMaskCtx.drawImage(toolMaskCanvas, sourceX, sourceY, copyWidth, copyHeight, // Source rectangle
tempMaskCtx.drawImage( destX, destY, copyWidth, copyHeight // Destination rectangle
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); const tempMaskData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
for (let i = 0; i < tempMaskData.data.length; i += 4) { for (let i = 0; i < tempMaskData.data.length; i += 4) {
const alpha = tempMaskData.data[i + 3]; const alpha = tempMaskData.data[i + 3];
@@ -143,7 +125,6 @@ export class CanvasIO {
tempMaskData.data[i + 3] = alpha; tempMaskData.data[i + 3] = alpha;
} }
tempMaskCtx.putImageData(tempMaskData, 0, 0); tempMaskCtx.putImageData(tempMaskData, 0, 0);
maskCtx.globalCompositeOperation = 'source-over'; maskCtx.globalCompositeOperation = 'source-over';
maskCtx.drawImage(tempMaskCanvas, 0, 0); maskCtx.drawImage(tempMaskCanvas, 0, 0);
} }
@@ -151,60 +132,59 @@ export class CanvasIO {
const imageData = tempCanvas.toDataURL('image/png'); const imageData = tempCanvas.toDataURL('image/png');
const maskData = maskCanvas.toDataURL('image/png'); const maskData = maskCanvas.toDataURL('image/png');
log.info("Returning image and mask data as base64 for RAM mode."); log.info("Returning image and mask data as base64 for RAM mode.");
resolve({image: imageData, mask: maskData}); resolve({ image: imageData, mask: maskData });
return; return;
} }
const fileNameWithoutMask = fileName.replace('.png', '_without_mask.png'); const fileNameWithoutMask = fileName.replace('.png', '_without_mask.png');
log.info(`Saving image without mask as: ${fileNameWithoutMask}`); log.info(`Saving image without mask as: ${fileNameWithoutMask}`);
tempCanvas.toBlob(async (blobWithoutMask) => { tempCanvas.toBlob(async (blobWithoutMask) => {
if (!blobWithoutMask)
return;
log.debug(`Created blob for image without mask, size: ${blobWithoutMask.size} bytes`); log.debug(`Created blob for image without mask, size: ${blobWithoutMask.size} bytes`);
const formDataWithoutMask = new FormData(); const formDataWithoutMask = new FormData();
formDataWithoutMask.append("image", blobWithoutMask, fileNameWithoutMask); formDataWithoutMask.append("image", blobWithoutMask, fileNameWithoutMask);
formDataWithoutMask.append("overwrite", "true"); formDataWithoutMask.append("overwrite", "true");
try { try {
const response = await fetch("/upload/image", { const response = await fetch("/upload/image", {
method: "POST", method: "POST",
body: formDataWithoutMask, body: formDataWithoutMask,
}); });
log.debug(`Image without mask upload response: ${response.status}`); log.debug(`Image without mask upload response: ${response.status}`);
} catch (error) { }
catch (error) {
log.error(`Error uploading image without mask:`, error); log.error(`Error uploading image without mask:`, error);
} }
}, "image/png"); }, "image/png");
log.info(`Saving main image as: ${fileName}`); log.info(`Saving main image as: ${fileName}`);
tempCanvas.toBlob(async (blob) => { tempCanvas.toBlob(async (blob) => {
if (!blob)
return;
log.debug(`Created blob for main image, size: ${blob.size} bytes`); log.debug(`Created blob for main image, size: ${blob.size} bytes`);
const formData = new FormData(); const formData = new FormData();
formData.append("image", blob, fileName); formData.append("image", blob, fileName);
formData.append("overwrite", "true"); formData.append("overwrite", "true");
try { try {
const resp = await fetch("/upload/image", { const resp = await fetch("/upload/image", {
method: "POST", method: "POST",
body: formData, body: formData,
}); });
log.debug(`Main image upload response: ${resp.status}`); log.debug(`Main image upload response: ${resp.status}`);
if (resp.status === 200) { if (resp.status === 200) {
const maskFileName = fileName.replace('.png', '_mask.png'); const maskFileName = fileName.replace('.png', '_mask.png');
log.info(`Saving mask as: ${maskFileName}`); log.info(`Saving mask as: ${maskFileName}`);
maskCanvas.toBlob(async (maskBlob) => { maskCanvas.toBlob(async (maskBlob) => {
if (!maskBlob)
return;
log.debug(`Created blob for mask, size: ${maskBlob.size} bytes`); log.debug(`Created blob for mask, size: ${maskBlob.size} bytes`);
const maskFormData = new FormData(); const maskFormData = new FormData();
maskFormData.append("image", maskBlob, maskFileName); maskFormData.append("image", maskBlob, maskFileName);
maskFormData.append("overwrite", "true"); maskFormData.append("overwrite", "true");
try { try {
const maskResp = await fetch("/upload/image", { const maskResp = await fetch("/upload/image", {
method: "POST", method: "POST",
body: maskFormData, body: maskFormData,
}); });
log.debug(`Mask upload response: ${maskResp.status}`); log.debug(`Mask upload response: ${maskResp.status}`);
if (maskResp.status === 200) { if (maskResp.status === 200) {
const data = await resp.json(); const data = await resp.json();
if (this.canvas.widget) { if (this.canvas.widget) {
@@ -212,42 +192,48 @@ export class CanvasIO {
} }
log.info(`All files saved successfully, widget value set to: ${fileName}`); log.info(`All files saved successfully, widget value set to: ${fileName}`);
resolve(true); resolve(true);
} else { }
else {
log.error(`Error saving mask: ${maskResp.status}`); log.error(`Error saving mask: ${maskResp.status}`);
resolve(false); resolve(false);
} }
} catch (error) { }
catch (error) {
log.error(`Error saving mask:`, error); log.error(`Error saving mask:`, error);
resolve(false); resolve(false);
} }
}, "image/png"); }, "image/png");
} else { }
else {
log.error(`Main image upload failed: ${resp.status} - ${resp.statusText}`); log.error(`Main image upload failed: ${resp.status} - ${resp.statusText}`);
resolve(false); resolve(false);
} }
} catch (error) { }
catch (error) {
log.error(`Error uploading main image:`, error); log.error(`Error uploading main image:`, error);
resolve(false); resolve(false);
} }
}, "image/png"); }, "image/png");
}); });
} }
async _renderOutputData() { async _renderOutputData() {
return new Promise((resolve) => { return new Promise((resolve) => {
const {canvas: tempCanvas, ctx: tempCtx} = 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 { canvas: maskCanvas, ctx: maskCtx } = createCanvas(this.canvas.width, this.canvas.height);
const visibilityCanvas = document.createElement('canvas'); const visibilityCanvas = document.createElement('canvas');
visibilityCanvas.width = this.canvas.width; visibilityCanvas.width = this.canvas.width;
visibilityCanvas.height = this.canvas.height; 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.fillStyle = '#ffffff'; // Start with a white mask (nothing masked)
maskCtx.fillRect(0, 0, this.canvas.width, this.canvas.height); maskCtx.fillRect(0, 0, this.canvas.width, this.canvas.height);
const sortedLayers = this.canvas.layers.sort((a, b) => a.zIndex - b.zIndex); const sortedLayers = this.canvas.layers.sort((a, b) => a.zIndex - b.zIndex);
sortedLayers.forEach((layer) => { sortedLayers.forEach((layer) => {
tempCtx.save(); tempCtx.save();
tempCtx.globalCompositeOperation = layer.blendMode || 'normal'; tempCtx.globalCompositeOperation = layer.blendMode || 'normal';
tempCtx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1; tempCtx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
@@ -255,14 +241,12 @@ export class CanvasIO {
tempCtx.rotate(layer.rotation * Math.PI / 180); 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(); tempCtx.restore();
visibilityCtx.save(); visibilityCtx.save();
visibilityCtx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2); visibilityCtx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2);
visibilityCtx.rotate(layer.rotation * Math.PI / 180); visibilityCtx.rotate(layer.rotation * Math.PI / 180);
visibilityCtx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height); visibilityCtx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
visibilityCtx.restore(); visibilityCtx.restore();
}); });
const visibilityData = visibilityCtx.getImageData(0, 0, this.canvas.width, this.canvas.height); const visibilityData = visibilityCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
const maskData = maskCtx.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) { for (let i = 0; i < visibilityData.data.length; i += 4) {
@@ -272,64 +256,45 @@ export class CanvasIO {
maskData.data[i + 3] = 255; // Solid mask maskData.data[i + 3] = 255; // Solid mask
} }
maskCtx.putImageData(maskData, 0, 0); maskCtx.putImageData(maskData, 0, 0);
const toolMaskCanvas = this.canvas.maskTool.getMask(); const toolMaskCanvas = this.canvas.maskTool.getMask();
if (toolMaskCanvas) { if (toolMaskCanvas) {
const tempMaskCanvas = document.createElement('canvas'); const tempMaskCanvas = document.createElement('canvas');
tempMaskCanvas.width = this.canvas.width; tempMaskCanvas.width = this.canvas.width;
tempMaskCanvas.height = this.canvas.height; tempMaskCanvas.height = this.canvas.height;
const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true }); 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); tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height);
const maskX = this.canvas.maskTool.x; const maskX = this.canvas.maskTool.x;
const maskY = this.canvas.maskTool.y; const maskY = this.canvas.maskTool.y;
log.debug(`[renderOutputData] Extracting mask from world position (${maskX}, ${maskY})`); log.debug(`[renderOutputData] Extracting mask from world position (${maskX}, ${maskY})`);
const sourceX = Math.max(0, -maskX); const sourceX = Math.max(0, -maskX);
const sourceY = Math.max(0, -maskY); const sourceY = Math.max(0, -maskY);
const destX = Math.max(0, maskX); const destX = Math.max(0, maskX);
const destY = Math.max(0, maskY); const destY = Math.max(0, maskY);
const copyWidth = Math.min(toolMaskCanvas.width - sourceX, this.canvas.width - destX); const copyWidth = Math.min(toolMaskCanvas.width - sourceX, this.canvas.width - destX);
const copyHeight = Math.min(toolMaskCanvas.height - sourceY, this.canvas.height - destY); const copyHeight = Math.min(toolMaskCanvas.height - sourceY, this.canvas.height - destY);
if (copyWidth > 0 && copyHeight > 0) { if (copyWidth > 0 && copyHeight > 0) {
tempMaskCtx.drawImage( tempMaskCtx.drawImage(toolMaskCanvas, sourceX, sourceY, copyWidth, copyHeight, destX, destY, copyWidth, copyHeight);
toolMaskCanvas,
sourceX, sourceY, copyWidth, copyHeight,
destX, destY, copyWidth, copyHeight
);
} }
const tempMaskData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height); const tempMaskData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
for (let i = 0; i < tempMaskData.data.length; i += 4) { for (let i = 0; i < tempMaskData.data.length; i += 4) {
const alpha = tempMaskData.data[i + 3]; const alpha = tempMaskData.data[i + 3];
tempMaskData.data[i] = tempMaskData.data[i + 1] = tempMaskData.data[i + 2] = alpha; tempMaskData.data[i] = tempMaskData.data[i + 1] = tempMaskData.data[i + 2] = alpha;
tempMaskData.data[i + 3] = 255; // Solid alpha tempMaskData.data[i + 3] = 255; // Solid alpha
} }
tempMaskCtx.putImageData(tempMaskData, 0, 0); tempMaskCtx.putImageData(tempMaskData, 0, 0);
maskCtx.globalCompositeOperation = 'screen'; maskCtx.globalCompositeOperation = 'screen';
maskCtx.drawImage(tempMaskCanvas, 0, 0); maskCtx.drawImage(tempMaskCanvas, 0, 0);
} }
const imageDataUrl = tempCanvas.toDataURL('image/png'); const imageDataUrl = tempCanvas.toDataURL('image/png');
const maskDataUrl = maskCanvas.toDataURL('image/png'); const maskDataUrl = maskCanvas.toDataURL('image/png');
resolve({ image: imageDataUrl, mask: maskDataUrl });
resolve({image: imageDataUrl, mask: maskDataUrl});
}); });
} }
async sendDataViaWebSocket(nodeId) { async sendDataViaWebSocket(nodeId) {
log.info(`Preparing to send data for node ${nodeId} via WebSocket.`); log.info(`Preparing to send data for node ${nodeId} via WebSocket.`);
const { image, mask } = await this._renderOutputData();
const {image, mask} = await this._renderOutputData();
try { try {
log.info(`Sending data for node ${nodeId}...`); log.info(`Sending data for node ${nodeId}...`);
await webSocketManager.sendMessage({ await webSocketManager.sendMessage({
@@ -338,205 +303,167 @@ export class CanvasIO {
image: image, image: image,
mask: mask, mask: mask,
}, true); // `true` requires an acknowledgment }, true); // `true` requires an acknowledgment
log.info(`Data for node ${nodeId} has been sent and acknowledged by the server.`); log.info(`Data for node ${nodeId} has been sent and acknowledged by the server.`);
return true; return true;
} catch (error) { }
catch (error) {
log.error(`Failed to send data for node ${nodeId}:`, 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.`); 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) { async addInputToCanvas(inputImage, inputMask) {
try { try {
log.debug("Adding input to canvas:", {inputImage}); log.debug("Adding input to canvas:", { inputImage });
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(inputImage.width, inputImage.height);
const {canvas: tempCanvas, ctx: tempCtx} = createCanvas(inputImage.width, inputImage.height); if (!tempCtx)
throw new Error("Could not create temp context");
const imgData = new ImageData( const imgData = new ImageData(new Uint8ClampedArray(inputImage.data), inputImage.width, inputImage.height);
inputImage.data,
inputImage.width,
inputImage.height
);
tempCtx.putImageData(imgData, 0, 0); tempCtx.putImageData(imgData, 0, 0);
const image = new Image(); const image = new Image();
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
image.onload = resolve; image.onload = resolve;
image.onerror = reject; image.onerror = reject;
image.src = tempCanvas.toDataURL(); 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, { const layer = await this.canvas.canvasLayers.addLayerWithImage(image, {
x: (this.canvas.width - inputImage.width * scale) / 2, x: (this.canvas.width - inputImage.width * scale) / 2,
y: (this.canvas.height - inputImage.height * scale) / 2, y: (this.canvas.height - inputImage.height * scale) / 2,
width: inputImage.width * scale, width: inputImage.width * scale,
height: inputImage.height * scale, height: inputImage.height * scale,
}); });
if (inputMask && layer) {
if (inputMask) {
layer.mask = inputMask.data; layer.mask = inputMask.data;
} }
log.info("Layer added successfully"); log.info("Layer added successfully");
return true; return true;
}
} catch (error) { catch (error) {
log.error("Error in addInputToCanvas:", error); log.error("Error in addInputToCanvas:", error);
throw error; throw error;
} }
} }
async convertTensorToImage(tensor) { async convertTensorToImage(tensor) {
try { try {
log.debug("Converting tensor to image:", tensor); log.debug("Converting tensor to image:", tensor);
if (!tensor || !tensor.data || !tensor.width || !tensor.height) { if (!tensor || !tensor.data || !tensor.width || !tensor.height) {
throw new Error("Invalid tensor data"); throw new Error("Invalid tensor data");
} }
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true }); const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx)
throw new Error("Could not create canvas context");
canvas.width = tensor.width; canvas.width = tensor.width;
canvas.height = tensor.height; 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); ctx.putImageData(imageData, 0, 0);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const img = new Image(); const img = new Image();
img.onload = () => resolve(img); img.onload = () => resolve(img);
img.onerror = (e) => reject(new Error("Failed to load image: " + e)); img.onerror = (e) => reject(new Error("Failed to load image: " + e));
img.src = canvas.toDataURL(); img.src = canvas.toDataURL();
}); });
} catch (error) { }
catch (error) {
log.error("Error converting tensor to image:", error); log.error("Error converting tensor to image:", error);
throw error; throw error;
} }
} }
async convertTensorToMask(tensor) { async convertTensorToMask(tensor) {
if (!tensor || !tensor.data) { if (!tensor || !tensor.data) {
throw new Error("Invalid mask tensor"); throw new Error("Invalid mask tensor");
} }
try { try {
return new Float32Array(tensor.data); return new Float32Array(tensor.data);
} catch (error) { }
catch (error) {
throw new Error(`Mask conversion failed: ${error.message}`); throw new Error(`Mask conversion failed: ${error.message}`);
} }
} }
async initNodeData() { async initNodeData() {
try { try {
log.info("Starting node data initialization..."); log.info("Starting node data initialization...");
if (!this.canvas.node || !this.canvas.node.inputs) { if (!this.canvas.node || !this.canvas.node.inputs) {
log.debug("Node or inputs not ready"); log.debug("Node or inputs not ready");
return this.scheduleDataCheck(); return this.scheduleDataCheck();
} }
if (this.canvas.node.inputs[0] && this.canvas.node.inputs[0].link) { if (this.canvas.node.inputs[0] && this.canvas.node.inputs[0].link) {
const imageLinkId = 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) { if (imageData) {
log.debug("Found image data:", imageData); log.debug("Found image data:", imageData);
await this.processImageData(imageData); await this.processImageData(imageData);
this.canvas.dataInitialized = true; this.canvas.dataInitialized = true;
} else { }
else {
log.debug("Image data not available yet"); log.debug("Image data not available yet");
return this.scheduleDataCheck(); return this.scheduleDataCheck();
} }
} }
if (this.canvas.node.inputs[1] && this.canvas.node.inputs[1].link) { if (this.canvas.node.inputs[1] && this.canvas.node.inputs[1].link) {
const maskLinkId = 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) { if (maskData) {
log.debug("Found mask data:", maskData); log.debug("Found mask data:", maskData);
await this.processMaskData(maskData); await this.processMaskData(maskData);
} }
} }
}
} catch (error) { catch (error) {
log.error("Error in initNodeData:", error); log.error("Error in initNodeData:", error);
return this.scheduleDataCheck(); return this.scheduleDataCheck();
} }
} }
scheduleDataCheck() { scheduleDataCheck() {
if (this.canvas.pendingDataCheck) { if (this.canvas.pendingDataCheck) {
clearTimeout(this.canvas.pendingDataCheck); clearTimeout(this.canvas.pendingDataCheck);
} }
this.canvas.pendingDataCheck = window.setTimeout(() => {
this.canvas.pendingDataCheck = setTimeout(() => {
this.canvas.pendingDataCheck = null; this.canvas.pendingDataCheck = null;
if (!this.canvas.dataInitialized) { if (!this.canvas.dataInitialized) {
this.initNodeData(); this.initNodeData();
} }
}, 1000); }, 1000);
} }
async processImageData(imageData) { async processImageData(imageData) {
try { try {
if (!imageData) return; if (!imageData)
return;
log.debug("Processing image data:", { log.debug("Processing image data:", {
type: typeof imageData, type: typeof imageData,
isArray: Array.isArray(imageData), isArray: Array.isArray(imageData),
shape: imageData.shape, shape: imageData.shape,
hasData: !!imageData.data hasData: !!imageData.data
}); });
if (Array.isArray(imageData)) { if (Array.isArray(imageData)) {
imageData = imageData[0]; imageData = imageData[0];
} }
if (!imageData.shape || !imageData.data) { if (!imageData.shape || !imageData.data) {
throw new Error("Invalid image data format"); throw new Error("Invalid image data format");
} }
const originalWidth = imageData.shape[2]; const originalWidth = imageData.shape[2];
const originalHeight = imageData.shape[1]; 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); const convertedData = this.convertTensorToImageData(imageData);
if (convertedData) { if (convertedData) {
const image = await this.createImageFromData(convertedData); const image = await this.createImageFromData(convertedData);
this.addScaledLayer(image, scale); this.addScaledLayer(image, scale);
log.info("Image layer added successfully with scale:", scale); log.info("Image layer added successfully with scale:", scale);
} }
} catch (error) { }
catch (error) {
log.error("Error processing image data:", error); log.error("Error processing image data:", error);
throw error; throw error;
} }
} }
addScaledLayer(image, scale) { addScaledLayer(image, scale) {
try { try {
const scaledWidth = image.width * scale; const scaledWidth = image.width * scale;
const scaledHeight = image.height * scale; const scaledHeight = image.height * scale;
const layer = { const layer = {
id: '', // This will be set in addLayerWithImage
imageId: '', // This will be set in addLayerWithImage
name: 'Layer',
image: image, image: image,
x: (this.canvas.width - scaledWidth) / 2, x: (this.canvas.width - scaledWidth) / 2,
y: (this.canvas.height - scaledHeight) / 2, y: (this.canvas.height - scaledHeight) / 2,
@@ -545,31 +472,30 @@ export class CanvasIO {
rotation: 0, rotation: 0,
zIndex: this.canvas.layers.length, zIndex: this.canvas.layers.length,
originalWidth: image.width, originalWidth: image.width,
originalHeight: image.height originalHeight: image.height,
blendMode: 'normal',
opacity: 1
}; };
this.canvas.layers.push(layer); this.canvas.layers.push(layer);
this.canvas.selectedLayer = layer; this.canvas.updateSelection([layer]);
this.canvas.render(); this.canvas.render();
log.debug("Scaled layer added:", { log.debug("Scaled layer added:", {
originalSize: `${image.width}x${image.height}`, originalSize: `${image.width}x${image.height}`,
scaledSize: `${scaledWidth}x${scaledHeight}`, scaledSize: `${scaledWidth}x${scaledHeight}`,
scale: scale scale: scale
}); });
} catch (error) { }
catch (error) {
log.error("Error adding scaled layer:", error); log.error("Error adding scaled layer:", error);
throw error; throw error;
} }
} }
convertTensorToImageData(tensor) { convertTensorToImageData(tensor) {
try { try {
const shape = tensor.shape; const shape = tensor.shape;
const height = shape[1]; const height = shape[1];
const width = shape[2]; const width = shape[2];
const channels = shape[3]; const channels = shape[3];
log.debug("Converting tensor:", { log.debug("Converting tensor:", {
shape: shape, shape: shape,
dataRange: { dataRange: {
@@ -577,56 +503,50 @@ export class CanvasIO {
max: tensor.max_val max: tensor.max_val
} }
}); });
const imageData = new ImageData(width, height); const imageData = new ImageData(width, height);
const data = new Uint8ClampedArray(width * height * 4); const data = new Uint8ClampedArray(width * height * 4);
const flatData = tensor.data; const flatData = tensor.data;
const pixelCount = width * height; const pixelCount = width * height;
for (let i = 0; i < pixelCount; i++) { for (let i = 0; i < pixelCount; i++) {
const pixelIndex = i * 4; const pixelIndex = i * 4;
const tensorIndex = i * channels; const tensorIndex = i * channels;
for (let c = 0; c < channels; c++) { for (let c = 0; c < channels; c++) {
const value = flatData[tensorIndex + c]; const value = flatData[tensorIndex + c];
const normalizedValue = (value - tensor.min_val) / (tensor.max_val - tensor.min_val); const normalizedValue = (value - tensor.min_val) / (tensor.max_val - tensor.min_val);
data[pixelIndex + c] = Math.round(normalizedValue * 255); data[pixelIndex + c] = Math.round(normalizedValue * 255);
} }
data[pixelIndex + 3] = 255; data[pixelIndex + 3] = 255;
} }
imageData.data.set(data); imageData.data.set(data);
return imageData; return imageData;
} catch (error) { }
catch (error) {
log.error("Error converting tensor:", error); log.error("Error converting tensor:", error);
return null; return null;
} }
} }
async createImageFromData(imageData) { async createImageFromData(imageData) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.width = imageData.width; canvas.width = imageData.width;
canvas.height = imageData.height; canvas.height = imageData.height;
const ctx = canvas.getContext('2d', { willReadFrequently: true }); const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx)
throw new Error("Could not create canvas context");
ctx.putImageData(imageData, 0, 0); ctx.putImageData(imageData, 0, 0);
const img = new Image(); const img = new Image();
img.onload = () => resolve(img); img.onload = () => resolve(img);
img.onerror = reject; img.onerror = reject;
img.src = canvas.toDataURL(); img.src = canvas.toDataURL();
}); });
} }
async retryDataLoad(maxRetries = 3, delay = 1000) { async retryDataLoad(maxRetries = 3, delay = 1000) {
for (let i = 0; i < maxRetries; i++) { for (let i = 0; i < maxRetries; i++) {
try { try {
await this.initNodeData(); await this.initNodeData();
return; return;
} catch (error) { }
catch (error) {
log.warn(`Retry ${i + 1}/${maxRetries} failed:`, error); log.warn(`Retry ${i + 1}/${maxRetries} failed:`, error);
if (i < maxRetries - 1) { if (i < maxRetries - 1) {
await new Promise(resolve => setTimeout(resolve, delay)); await new Promise(resolve => setTimeout(resolve, delay));
@@ -635,32 +555,28 @@ export class CanvasIO {
} }
log.error("Failed to load data after", maxRetries, "retries"); log.error("Failed to load data after", maxRetries, "retries");
} }
async processMaskData(maskData) { async processMaskData(maskData) {
try { try {
if (!maskData) return; if (!maskData)
return;
log.debug("Processing mask data:", maskData); log.debug("Processing mask data:", maskData);
if (Array.isArray(maskData)) { if (Array.isArray(maskData)) {
maskData = maskData[0]; maskData = maskData[0];
} }
if (!maskData.shape || !maskData.data) { if (!maskData.shape || !maskData.data) {
throw new Error("Invalid mask data format"); throw new Error("Invalid mask data format");
} }
if (this.canvas.canvasSelection.selectedLayers.length > 0) {
if (this.canvas.selectedLayer) {
const maskTensor = await this.convertTensorToMask(maskData); const maskTensor = await this.convertTensorToMask(maskData);
this.canvas.selectedLayer.mask = maskTensor; this.canvas.canvasSelection.selectedLayers[0].mask = maskTensor;
this.canvas.render(); this.canvas.render();
log.info("Mask applied to selected layer"); log.info("Mask applied to selected layer");
} }
} catch (error) { }
catch (error) {
log.error("Error processing mask data:", error); log.error("Error processing mask data:", error);
} }
} }
async loadImageFromCache(base64Data) { async loadImageFromCache(base64Data) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const img = new Image(); const img = new Image();
@@ -669,72 +585,69 @@ export class CanvasIO {
img.src = base64Data; img.src = base64Data;
}); });
} }
async importImage(cacheData) { async importImage(cacheData) {
try { try {
log.info("Starting image import with cache data"); log.info("Starting image import with cache data");
const img = await this.loadImageFromCache(cacheData.image); const img = await this.loadImageFromCache(cacheData.image);
const mask = cacheData.mask ? await this.loadImageFromCache(cacheData.mask) : null; 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'); const tempCanvas = document.createElement('canvas');
tempCanvas.width = img.width; tempCanvas.width = img.width;
tempCanvas.height = img.height; tempCanvas.height = img.height;
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
if (!tempCtx)
throw new Error("Could not create temp context");
tempCtx.drawImage(img, 0, 0); tempCtx.drawImage(img, 0, 0);
if (mask) { if (mask) {
const imageData = tempCtx.getImageData(0, 0, img.width, img.height); const imageData = tempCtx.getImageData(0, 0, img.width, img.height);
const maskCanvas = document.createElement('canvas'); const maskCanvas = document.createElement('canvas');
maskCanvas.width = img.width; maskCanvas.width = img.width;
maskCanvas.height = img.height; maskCanvas.height = img.height;
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true }); const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
if (!maskCtx)
throw new Error("Could not create mask context");
maskCtx.drawImage(mask, 0, 0); maskCtx.drawImage(mask, 0, 0);
const maskData = maskCtx.getImageData(0, 0, img.width, img.height); const maskData = maskCtx.getImageData(0, 0, img.width, img.height);
for (let i = 0; i < imageData.data.length; i += 4) { for (let i = 0; i < imageData.data.length; i += 4) {
imageData.data[i + 3] = maskData.data[i]; imageData.data[i + 3] = maskData.data[i];
} }
tempCtx.putImageData(imageData, 0, 0); tempCtx.putImageData(imageData, 0, 0);
} }
const finalImage = new Image(); const finalImage = new Image();
await new Promise((resolve) => { await new Promise((resolve) => {
finalImage.onload = resolve; finalImage.onload = resolve;
finalImage.src = tempCanvas.toDataURL(); finalImage.src = tempCanvas.toDataURL();
}); });
const layer = { const layer = {
id: '', // This will be set in addLayerWithImage
imageId: '', // This will be set in addLayerWithImage
name: 'Layer',
image: finalImage, image: finalImage,
x: (this.canvas.width - img.width * scale) / 2, x: (this.canvas.width - img.width * scale) / 2,
y: (this.canvas.height - img.height * scale) / 2, y: (this.canvas.height - img.height * scale) / 2,
width: img.width * scale, width: img.width * scale,
height: img.height * scale, height: img.height * scale,
originalWidth: img.width,
originalHeight: img.height,
rotation: 0, rotation: 0,
zIndex: this.canvas.layers.length zIndex: this.canvas.layers.length,
blendMode: 'normal',
opacity: 1,
}; };
this.canvas.layers.push(layer); this.canvas.layers.push(layer);
this.canvas.selectedLayer = layer; this.canvas.updateSelection([layer]);
this.canvas.render(); this.canvas.render();
this.canvas.saveState(); this.canvas.saveState();
} catch (error) { }
catch (error) {
log.error('Error importing image:', error); log.error('Error importing image:', error);
} }
} }
async importLatestImage() { async importLatestImage() {
try { try {
log.info("Fetching latest image from server..."); log.info("Fetching latest image from server...");
const response = await fetch('/ycnode/get_latest_image'); const response = await fetch('/ycnode/get_latest_image');
const result = await response.json(); const result = await response.json();
if (result.success && result.image_data) { if (result.success && result.image_data) {
log.info("Latest image received, adding to canvas."); log.info("Latest image received, adding to canvas.");
const img = new Image(); const img = new Image();
@@ -743,30 +656,28 @@ export class CanvasIO {
img.onerror = reject; img.onerror = reject;
img.src = result.image_data; img.src = result.image_data;
}); });
await this.canvas.canvasLayers.addLayerWithImage(img, {}, 'fit'); await this.canvas.canvasLayers.addLayerWithImage(img, {}, 'fit');
log.info("Latest image imported and placed on canvas successfully."); log.info("Latest image imported and placed on canvas successfully.");
return true; return true;
} else { }
else {
throw new Error(result.error || "Failed to fetch the latest image."); throw new Error(result.error || "Failed to fetch the latest image.");
} }
} catch (error) { }
catch (error) {
log.error("Error importing latest image:", error); log.error("Error importing latest image:", error);
alert(`Failed to import latest image: ${error.message}`); alert(`Failed to import latest image: ${error.message}`);
return false; return false;
} }
} }
async importLatestImages(sinceTimestamp, targetArea = null) { async importLatestImages(sinceTimestamp, targetArea = null) {
try { try {
log.info(`Fetching latest images since ${sinceTimestamp}...`); log.info(`Fetching latest images since ${sinceTimestamp}...`);
const response = await fetch(`/layerforge/get-latest-images/${sinceTimestamp}`); const response = await fetch(`/layerforge/get-latest-images/${sinceTimestamp}`);
const result = await response.json(); const result = await response.json();
if (result.success && result.images && result.images.length > 0) { if (result.success && result.images && result.images.length > 0) {
log.info(`Received ${result.images.length} new images, adding to canvas.`); log.info(`Received ${result.images.length} new images, adding to canvas.`);
const newLayers = []; const newLayers = [];
for (const imageData of result.images) { for (const imageData of result.images) {
const img = new Image(); const img = new Image();
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
@@ -778,16 +689,17 @@ export class CanvasIO {
newLayers.push(newLayer); newLayers.push(newLayer);
} }
log.info("All new images imported and placed on canvas successfully."); log.info("All new images imported and placed on canvas successfully.");
return newLayers; return newLayers.filter(l => l !== null);
}
} else if (result.success) { else if (result.success) {
log.info("No new images found since last generation."); log.info("No new images found since last generation.");
return []; return [];
} }
else { else {
throw new Error(result.error || "Failed to fetch latest images."); throw new Error(result.error || "Failed to fetch latest images.");
} }
} catch (error) { }
catch (error) {
log.error("Error importing latest images:", error); log.error("Error importing latest images:", error);
alert(`Failed to import latest images: ${error.message}`); alert(`Failed to import latest images: ${error.message}`);
return []; return [];

View File

@@ -1,42 +1,37 @@
import {createModuleLogger} from "./utils/LoggerUtils.js"; import { createModuleLogger } from "./utils/LoggerUtils.js";
import {snapToGrid, getSnapAdjustment} from "./utils/CommonUtils.js"; import { snapToGrid, getSnapAdjustment } from "./utils/CommonUtils.js";
const log = createModuleLogger('CanvasInteractions'); const log = createModuleLogger('CanvasInteractions');
export class CanvasInteractions { export class CanvasInteractions {
constructor(canvas) { constructor(canvas) {
this.canvas = canvas; this.canvas = canvas;
this.interaction = { this.interaction = {
mode: 'none', mode: 'none',
panStart: {x: 0, y: 0}, panStart: { x: 0, y: 0 },
dragStart: {x: 0, y: 0}, dragStart: { x: 0, y: 0 },
transformOrigin: {}, transformOrigin: {},
resizeHandle: null, resizeHandle: null,
resizeAnchor: {x: 0, y: 0}, resizeAnchor: { x: 0, y: 0 },
canvasResizeStart: {x: 0, y: 0}, canvasResizeStart: { x: 0, y: 0 },
isCtrlPressed: false, isCtrlPressed: false,
isAltPressed: false, isAltPressed: false,
hasClonedInDrag: false, hasClonedInDrag: false,
lastClickTime: 0, lastClickTime: 0,
transformingLayer: null, transformingLayer: null,
keyMovementInProgress: false, // Flaga do śledzenia ruchu klawiszami keyMovementInProgress: false,
canvasResizeRect: null,
canvasMoveRect: null,
}; };
this.originalLayerPositions = new Map(); this.originalLayerPositions = new Map();
this.interaction.canvasResizeRect = null;
this.interaction.canvasMoveRect = null;
} }
setupEventListeners() { setupEventListeners() {
this.canvas.canvas.addEventListener('mousedown', this.handleMouseDown.bind(this)); this.canvas.canvas.addEventListener('mousedown', this.handleMouseDown.bind(this));
this.canvas.canvas.addEventListener('mousemove', this.handleMouseMove.bind(this)); this.canvas.canvas.addEventListener('mousemove', this.handleMouseMove.bind(this));
this.canvas.canvas.addEventListener('mouseup', this.handleMouseUp.bind(this)); this.canvas.canvas.addEventListener('mouseup', this.handleMouseUp.bind(this));
this.canvas.canvas.addEventListener('mouseleave', this.handleMouseLeave.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('keydown', this.handleKeyDown.bind(this));
this.canvas.canvas.addEventListener('keyup', this.handleKeyUp.bind(this)); this.canvas.canvas.addEventListener('keyup', this.handleKeyUp.bind(this));
document.addEventListener('paste', this.handlePasteEvent.bind(this)); document.addEventListener('paste', this.handlePasteEvent.bind(this));
this.canvas.canvas.addEventListener('mouseenter', (e) => { this.canvas.canvas.addEventListener('mouseenter', (e) => {
this.canvas.isMouseOver = true; this.canvas.isMouseOver = true;
this.handleMouseEnter(e); this.handleMouseEnter(e);
@@ -45,15 +40,12 @@ export class CanvasInteractions {
this.canvas.isMouseOver = false; this.canvas.isMouseOver = false;
this.handleMouseLeave(e); this.handleMouseLeave(e);
}); });
this.canvas.canvas.addEventListener('dragover', this.handleDragOver.bind(this)); this.canvas.canvas.addEventListener('dragover', this.handleDragOver.bind(this));
this.canvas.canvas.addEventListener('dragenter', this.handleDragEnter.bind(this)); this.canvas.canvas.addEventListener('dragenter', this.handleDragEnter.bind(this));
this.canvas.canvas.addEventListener('dragleave', this.handleDragLeave.bind(this)); this.canvas.canvas.addEventListener('dragleave', this.handleDragLeave.bind(this));
this.canvas.canvas.addEventListener('drop', this.handleDrop.bind(this)); this.canvas.canvas.addEventListener('drop', this.handleDrop.bind(this));
this.canvas.canvas.addEventListener('contextmenu', this.handleContextMenu.bind(this)); this.canvas.canvas.addEventListener('contextmenu', this.handleContextMenu.bind(this));
} }
resetInteractionState() { resetInteractionState() {
this.interaction.mode = 'none'; this.interaction.mode = 'none';
this.interaction.resizeHandle = null; this.interaction.resizeHandle = null;
@@ -64,20 +56,16 @@ export class CanvasInteractions {
this.interaction.transformingLayer = null; this.interaction.transformingLayer = null;
this.canvas.canvas.style.cursor = 'default'; this.canvas.canvas.style.cursor = 'default';
} }
handleMouseDown(e) { handleMouseDown(e) {
this.canvas.canvas.focus(); this.canvas.canvas.focus();
const worldCoords = this.canvas.getMouseWorldCoordinates(e); const worldCoords = this.canvas.getMouseWorldCoordinates(e);
const viewCoords = this.canvas.getMouseViewCoordinates(e); const viewCoords = this.canvas.getMouseViewCoordinates(e);
if (this.interaction.mode === 'drawingMask') { if (this.interaction.mode === 'drawingMask') {
this.canvas.maskTool.handleMouseDown(worldCoords, viewCoords); this.canvas.maskTool.handleMouseDown(worldCoords, viewCoords);
this.canvas.render(); this.canvas.render();
return; return;
} }
// --- Ostateczna, poprawna kolejność sprawdzania --- // --- Ostateczna, poprawna kolejność sprawdzania ---
// 1. Akcje globalne z modyfikatorami (mają najwyższy priorytet) // 1. Akcje globalne z modyfikatorami (mają najwyższy priorytet)
if (e.shiftKey && e.ctrlKey) { if (e.shiftKey && e.ctrlKey) {
this.startCanvasMove(worldCoords); this.startCanvasMove(worldCoords);
@@ -87,7 +75,6 @@ export class CanvasInteractions {
this.startCanvasResize(worldCoords); this.startCanvasResize(worldCoords);
return; return;
} }
// 2. Inne przyciski myszy // 2. Inne przyciski myszy
if (e.button === 2) { // Prawy przycisk myszy if (e.button === 2) { // Prawy przycisk myszy
const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y); const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y);
@@ -95,35 +82,30 @@ export class CanvasInteractions {
e.preventDefault(); e.preventDefault();
this.canvas.canvasLayers.showBlendModeMenu(viewCoords.x, viewCoords.y); this.canvas.canvasLayers.showBlendModeMenu(viewCoords.x, viewCoords.y);
} }
return; return;
} }
if (e.button !== 0) { // Środkowy przycisk if (e.button !== 0) { // Środkowy przycisk
this.startPanning(e); this.startPanning(e);
return; return;
} }
// 3. Interakcje z elementami na płótnie (lewy przycisk) // 3. Interakcje z elementami na płótnie (lewy przycisk)
const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y); const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y);
if (transformTarget) { if (transformTarget) {
this.startLayerTransform(transformTarget.layer, transformTarget.handle, worldCoords); this.startLayerTransform(transformTarget.layer, transformTarget.handle, worldCoords);
return; return;
} }
const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y); const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y);
if (clickedLayerResult) { if (clickedLayerResult) {
this.prepareForDrag(clickedLayerResult.layer, worldCoords); this.prepareForDrag(clickedLayerResult.layer, worldCoords);
return; return;
} }
// 4. Domyślna akcja na tle (lewy przycisk bez modyfikatorów) // 4. Domyślna akcja na tle (lewy przycisk bez modyfikatorów)
this.startPanningOrClearSelection(e); this.startPanningOrClearSelection(e);
} }
handleMouseMove(e) { handleMouseMove(e) {
const worldCoords = this.canvas.getMouseWorldCoordinates(e); const worldCoords = this.canvas.getMouseWorldCoordinates(e);
const viewCoords = this.canvas.getMouseViewCoordinates(e); const viewCoords = this.canvas.getMouseViewCoordinates(e);
this.canvas.lastMousePosition = worldCoords; // Zawsze aktualizuj ostatnią pozycję myszy this.canvas.lastMousePosition = worldCoords; // Zawsze aktualizuj ostatnią pozycję myszy
// Sprawdź, czy rozpocząć przeciąganie // Sprawdź, czy rozpocząć przeciąganie
if (this.interaction.mode === 'potential-drag') { if (this.interaction.mode === 'potential-drag') {
const dx = worldCoords.x - this.interaction.dragStart.x; 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 if (Math.sqrt(dx * dx + dy * dy) > 3) { // Próg 3 pikseli
this.interaction.mode = 'dragging'; this.interaction.mode = 'dragging';
this.originalLayerPositions.clear(); this.originalLayerPositions.clear();
this.canvas.canvasSelection.selectedLayers.forEach(l => { this.canvas.canvasSelection.selectedLayers.forEach((l) => {
this.originalLayerPositions.set(l, {x: l.x, y: l.y}); this.originalLayerPositions.set(l, { x: l.x, y: l.y });
}); });
} }
} }
switch (this.interaction.mode) { switch (this.interaction.mode) {
case 'drawingMask': case 'drawingMask':
this.canvas.maskTool.handleMouseMove(worldCoords, viewCoords); this.canvas.maskTool.handleMouseMove(worldCoords, viewCoords);
@@ -165,7 +146,6 @@ export class CanvasInteractions {
break; break;
} }
} }
handleMouseUp(e) { handleMouseUp(e) {
const viewCoords = this.canvas.getMouseViewCoordinates(e); const viewCoords = this.canvas.getMouseViewCoordinates(e);
if (this.interaction.mode === 'drawingMask') { if (this.interaction.mode === 'drawingMask') {
@@ -173,27 +153,22 @@ export class CanvasInteractions {
this.canvas.render(); this.canvas.render();
return; return;
} }
if (this.interaction.mode === 'resizingCanvas') { if (this.interaction.mode === 'resizingCanvas') {
this.finalizeCanvasResize(); this.finalizeCanvasResize();
} }
if (this.interaction.mode === 'movingCanvas') { if (this.interaction.mode === 'movingCanvas') {
this.finalizeCanvasMove(); this.finalizeCanvasMove();
} }
// Zapisz stan tylko, jeśli faktycznie doszło do zmiany (przeciąganie, transformacja, duplikacja) // Zapisz stan tylko, jeśli faktycznie doszło do zmiany (przeciąganie, transformacja, duplikacja)
const stateChangingInteraction = ['dragging', 'resizing', 'rotating'].includes(this.interaction.mode); const stateChangingInteraction = ['dragging', 'resizing', 'rotating'].includes(this.interaction.mode);
const duplicatedInDrag = this.interaction.hasClonedInDrag; const duplicatedInDrag = this.interaction.hasClonedInDrag;
if (stateChangingInteraction || duplicatedInDrag) { if (stateChangingInteraction || duplicatedInDrag) {
this.canvas.saveState(); this.canvas.saveState();
this.canvas.canvasState.saveStateToDB(true); this.canvas.canvasState.saveStateToDB();
} }
this.resetInteractionState(); this.resetInteractionState();
this.canvas.render(); this.canvas.render();
} }
handleMouseLeave(e) { handleMouseLeave(e) {
const viewCoords = this.canvas.getMouseViewCoordinates(e); const viewCoords = this.canvas.getMouseViewCoordinates(e);
if (this.canvas.maskTool.isActive) { if (this.canvas.maskTool.isActive) {
@@ -208,24 +183,19 @@ export class CanvasInteractions {
this.resetInteractionState(); this.resetInteractionState();
this.canvas.render(); this.canvas.render();
} }
if (this.canvas.canvasLayers.internalClipboard.length > 0) { if (this.canvas.canvasLayers.internalClipboard.length > 0) {
this.canvas.canvasLayers.internalClipboard = []; this.canvas.canvasLayers.internalClipboard = [];
log.info("Internal clipboard cleared - mouse left canvas"); log.info("Internal clipboard cleared - mouse left canvas");
} }
} }
handleMouseEnter(e) { handleMouseEnter(e) {
if (this.canvas.maskTool.isActive) { if (this.canvas.maskTool.isActive) {
this.canvas.maskTool.handleMouseEnter(); this.canvas.maskTool.handleMouseEnter();
} }
} }
handleContextMenu(e) { handleContextMenu(e) {
e.preventDefault(); e.preventDefault();
} }
handleWheel(e) { handleWheel(e) {
e.preventDefault(); e.preventDefault();
if (this.canvas.maskTool.isActive) { if (this.canvas.maskTool.isActive) {
@@ -233,36 +203,36 @@ export class CanvasInteractions {
const rect = this.canvas.canvas.getBoundingClientRect(); const rect = this.canvas.canvas.getBoundingClientRect();
const mouseBufferX = (e.clientX - rect.left) * (this.canvas.offscreenCanvas.width / rect.width); 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 mouseBufferY = (e.clientY - rect.top) * (this.canvas.offscreenCanvas.height / rect.height);
const zoomFactor = e.deltaY < 0 ? 1.1 : 1 / 1.1; const zoomFactor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
const newZoom = this.canvas.viewport.zoom * zoomFactor; const newZoom = this.canvas.viewport.zoom * zoomFactor;
this.canvas.viewport.zoom = Math.max(0.1, Math.min(10, newZoom)); 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.x = worldCoords.x - (mouseBufferX / this.canvas.viewport.zoom);
this.canvas.viewport.y = worldCoords.y - (mouseBufferY / this.canvas.viewport.zoom); this.canvas.viewport.y = worldCoords.y - (mouseBufferY / this.canvas.viewport.zoom);
} else if (this.canvas.selectedLayer) { }
else if (this.canvas.canvasSelection.selectedLayers.length > 0) {
const rotationStep = 5 * (e.deltaY > 0 ? -1 : 1); const rotationStep = 5 * (e.deltaY > 0 ? -1 : 1);
const direction = e.deltaY < 0 ? 1 : -1; // 1 = up/right, -1 = down/left 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) { if (e.shiftKey) {
// Nowy skrót: Shift + Ctrl + Kółko do przyciągania do absolutnych wartości // Nowy skrót: Shift + Ctrl + Kółko do przyciągania do absolutnych wartości
if (e.ctrlKey) { if (e.ctrlKey) {
const snapAngle = 5; const snapAngle = 5;
if (direction > 0) { // Obrót w górę/prawo if (direction > 0) { // Obrót w górę/prawo
layer.rotation = Math.ceil((layer.rotation + 0.1) / snapAngle) * snapAngle; 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; layer.rotation = Math.floor((layer.rotation - 0.1) / snapAngle) * snapAngle;
} }
} else { }
else {
// Stara funkcjonalność: Shift + Kółko obraca o stały krok // Stara funkcjonalność: Shift + Kółko obraca o stały krok
layer.rotation += rotationStep; layer.rotation += rotationStep;
} }
} else { }
else {
const oldWidth = layer.width; const oldWidth = layer.width;
const oldHeight = layer.height; const oldHeight = layer.height;
let scaleFactor; let scaleFactor;
if (e.ctrlKey) { if (e.ctrlKey) {
const direction = e.deltaY > 0 ? -1 : 1; const direction = e.deltaY > 0 ? -1 : 1;
const baseDimension = Math.max(layer.width, layer.height); const baseDimension = Math.max(layer.width, layer.height);
@@ -271,26 +241,28 @@ export class CanvasInteractions {
return; return;
} }
scaleFactor = newBaseDimension / baseDimension; scaleFactor = newBaseDimension / baseDimension;
} else { }
else {
const gridSize = 64; const gridSize = 64;
const direction = e.deltaY > 0 ? -1 : 1; const direction = e.deltaY > 0 ? -1 : 1;
let targetHeight; let targetHeight;
if (direction > 0) { if (direction > 0) {
targetHeight = (Math.floor(oldHeight / gridSize) + 1) * gridSize; targetHeight = (Math.floor(oldHeight / gridSize) + 1) * gridSize;
} else { }
else {
targetHeight = (Math.ceil(oldHeight / gridSize) - 1) * gridSize; targetHeight = (Math.ceil(oldHeight / gridSize) - 1) * gridSize;
} }
if (targetHeight < gridSize / 2) { if (targetHeight < gridSize / 2) {
targetHeight = gridSize / 2; targetHeight = gridSize / 2;
} }
if (Math.abs(oldHeight - targetHeight) < 1) { if (Math.abs(oldHeight - targetHeight) < 1) {
if (direction > 0) targetHeight += gridSize; if (direction > 0)
else targetHeight -= gridSize; targetHeight += gridSize;
else
if (targetHeight < gridSize / 2) return; targetHeight -= gridSize;
if (targetHeight < gridSize / 2)
return;
} }
scaleFactor = targetHeight / oldHeight; scaleFactor = targetHeight / oldHeight;
} }
if (scaleFactor && isFinite(scaleFactor)) { if (scaleFactor && isFinite(scaleFactor)) {
@@ -301,32 +273,30 @@ export class CanvasInteractions {
} }
} }
}); });
} else { }
else {
const worldCoords = this.canvas.getMouseWorldCoordinates(e); const worldCoords = this.canvas.getMouseWorldCoordinates(e);
const rect = this.canvas.canvas.getBoundingClientRect(); const rect = this.canvas.canvas.getBoundingClientRect();
const mouseBufferX = (e.clientX - rect.left) * (this.canvas.offscreenCanvas.width / rect.width); 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 mouseBufferY = (e.clientY - rect.top) * (this.canvas.offscreenCanvas.height / rect.height);
const zoomFactor = e.deltaY < 0 ? 1.1 : 1 / 1.1; const zoomFactor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
const newZoom = this.canvas.viewport.zoom * zoomFactor; const newZoom = this.canvas.viewport.zoom * zoomFactor;
this.canvas.viewport.zoom = Math.max(0.1, Math.min(10, newZoom)); 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.x = worldCoords.x - (mouseBufferX / this.canvas.viewport.zoom);
this.canvas.viewport.y = worldCoords.y - (mouseBufferY / this.canvas.viewport.zoom); this.canvas.viewport.y = worldCoords.y - (mouseBufferY / this.canvas.viewport.zoom);
} }
this.canvas.render(); this.canvas.render();
if (!this.canvas.maskTool.isActive) { if (!this.canvas.maskTool.isActive) {
this.canvas.requestSaveState(true); // Użyj opóźnionego zapisu this.canvas.requestSaveState(); // Użyj opóźnionego zapisu
} }
} }
handleKeyDown(e) { handleKeyDown(e) {
if (e.key === 'Control') this.interaction.isCtrlPressed = true; if (e.key === 'Control')
this.interaction.isCtrlPressed = true;
if (e.key === 'Alt') { if (e.key === 'Alt') {
this.interaction.isAltPressed = true; this.interaction.isAltPressed = true;
e.preventDefault(); e.preventDefault();
} }
// Globalne skróty (Undo/Redo/Copy/Paste) // Globalne skróty (Undo/Redo/Copy/Paste)
if (e.ctrlKey || e.metaKey) { if (e.ctrlKey || e.metaKey) {
let handled = true; let handled = true;
@@ -334,7 +304,8 @@ export class CanvasInteractions {
case 'z': case 'z':
if (e.shiftKey) { if (e.shiftKey) {
this.canvas.redo(); this.canvas.redo();
} else { }
else {
this.canvas.undo(); this.canvas.undo();
} }
break; break;
@@ -356,56 +327,54 @@ export class CanvasInteractions {
return; return;
} }
} }
// Skróty kontekstowe (zależne od zaznaczenia) // Skróty kontekstowe (zależne od zaznaczenia)
if (this.canvas.canvasSelection.selectedLayers.length > 0) { if (this.canvas.canvasSelection.selectedLayers.length > 0) {
const step = e.shiftKey ? 10 : 1; const step = e.shiftKey ? 10 : 1;
let needsRender = false; let needsRender = false;
// Używamy e.code dla spójności i niezależności od układu klawiatury // Używamy e.code dla spójności i niezależności od układu klawiatury
const movementKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'BracketLeft', 'BracketRight']; const movementKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'BracketLeft', 'BracketRight'];
if (movementKeys.includes(e.code)) { if (movementKeys.includes(e.code)) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
this.interaction.keyMovementInProgress = true; this.interaction.keyMovementInProgress = true;
if (e.code === 'ArrowLeft')
if (e.code === 'ArrowLeft') this.canvas.canvasSelection.selectedLayers.forEach(l => l.x -= step); 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 === 'ArrowRight')
if (e.code === 'ArrowUp') this.canvas.canvasSelection.selectedLayers.forEach(l => l.y -= step); this.canvas.canvasSelection.selectedLayers.forEach((l) => l.x += step);
if (e.code === 'ArrowDown') this.canvas.canvasSelection.selectedLayers.forEach(l => l.y += step); if (e.code === 'ArrowUp')
if (e.code === 'BracketLeft') this.canvas.canvasSelection.selectedLayers.forEach(l => l.rotation -= step); this.canvas.canvasSelection.selectedLayers.forEach((l) => l.y -= step);
if (e.code === 'BracketRight') this.canvas.canvasSelection.selectedLayers.forEach(l => l.rotation += 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; needsRender = true;
} }
if (e.key === 'Delete' || e.key === 'Backspace') { if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
this.canvas.canvasSelection.removeSelectedLayers(); this.canvas.canvasSelection.removeSelectedLayers();
return; return;
} }
if (needsRender) { if (needsRender) {
this.canvas.render(); this.canvas.render();
} }
} }
} }
handleKeyUp(e) { handleKeyUp(e) {
if (e.key === 'Control') this.interaction.isCtrlPressed = false; if (e.key === 'Control')
if (e.key === 'Alt') this.interaction.isAltPressed = false; this.interaction.isCtrlPressed = false;
if (e.key === 'Alt')
this.interaction.isAltPressed = false;
const movementKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'BracketLeft', 'BracketRight']; const movementKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'BracketLeft', 'BracketRight'];
if (movementKeys.includes(e.code) && this.interaction.keyMovementInProgress) { if (movementKeys.includes(e.code) && this.interaction.keyMovementInProgress) {
this.canvas.requestSaveState(); // Użyj opóźnionego zapisu this.canvas.requestSaveState(); // Użyj opóźnionego zapisu
this.interaction.keyMovementInProgress = false; this.interaction.keyMovementInProgress = false;
} }
} }
updateCursor(worldCoords) { updateCursor(worldCoords) {
const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y); const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y);
if (transformTarget) { if (transformTarget) {
const handleName = transformTarget.handle; const handleName = transformTarget.handle;
const cursorMap = { const cursorMap = {
@@ -414,13 +383,14 @@ export class CanvasInteractions {
'rot': 'grab' 'rot': 'grab'
}; };
this.canvas.canvas.style.cursor = cursorMap[handleName]; 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'; this.canvas.canvas.style.cursor = 'move';
} else { }
else {
this.canvas.canvas.style.cursor = 'default'; this.canvas.canvas.style.cursor = 'default';
} }
} }
startLayerTransform(layer, handle, worldCoords) { startLayerTransform(layer, handle, worldCoords) {
this.interaction.transformingLayer = layer; this.interaction.transformingLayer = layer;
this.interaction.transformOrigin = { this.interaction.transformOrigin = {
@@ -430,43 +400,42 @@ export class CanvasInteractions {
centerX: layer.x + layer.width / 2, centerX: layer.x + layer.width / 2,
centerY: layer.y + layer.height / 2 centerY: layer.y + layer.height / 2
}; };
this.interaction.dragStart = {...worldCoords}; this.interaction.dragStart = { ...worldCoords };
if (handle === 'rot') { if (handle === 'rot') {
this.interaction.mode = 'rotating'; this.interaction.mode = 'rotating';
} else { }
else {
this.interaction.mode = 'resizing'; this.interaction.mode = 'resizing';
this.interaction.resizeHandle = handle; this.interaction.resizeHandle = handle;
const handles = this.canvas.canvasLayers.getHandles(layer); const handles = this.canvas.canvasLayers.getHandles(layer);
const oppositeHandleKey = { const oppositeHandleKey = {
'n': 's', 's': 'n', 'e': 'w', 'w': 'e', 'n': 's', 's': 'n', 'e': 'w', 'w': 'e',
'nw': 'se', 'se': 'nw', 'ne': 'sw', 'sw': 'ne' 'nw': 'se', 'se': 'nw', 'ne': 'sw', 'sw': 'ne'
}[handle]; };
this.interaction.resizeAnchor = handles[oppositeHandleKey]; this.interaction.resizeAnchor = handles[oppositeHandleKey[handle]];
} }
this.canvas.render(); this.canvas.render();
} }
prepareForDrag(layer, worldCoords) { prepareForDrag(layer, worldCoords) {
// Zaktualizuj zaznaczenie, ale nie zapisuj stanu // Zaktualizuj zaznaczenie, ale nie zapisuj stanu
if (this.interaction.isCtrlPressed) { if (this.interaction.isCtrlPressed) {
const index = this.canvas.canvasSelection.selectedLayers.indexOf(layer); const index = this.canvas.canvasSelection.selectedLayers.indexOf(layer);
if (index === -1) { if (index === -1) {
this.canvas.canvasSelection.updateSelection([...this.canvas.canvasSelection.selectedLayers, layer]); 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); this.canvas.canvasSelection.updateSelection(newSelection);
} }
} else { }
else {
if (!this.canvas.canvasSelection.selectedLayers.includes(layer)) { if (!this.canvas.canvasSelection.selectedLayers.includes(layer)) {
this.canvas.canvasSelection.updateSelection([layer]); this.canvas.canvasSelection.updateSelection([layer]);
} }
} }
this.interaction.mode = 'potential-drag'; this.interaction.mode = 'potential-drag';
this.interaction.dragStart = {...worldCoords}; this.interaction.dragStart = { ...worldCoords };
} }
startPanningOrClearSelection(e) { startPanningOrClearSelection(e) {
// Ta funkcja jest teraz wywoływana tylko gdy kliknięto na tło bez modyfikatorów. // Ta funkcja jest teraz wywoływana tylko gdy kliknięto na tło bez modyfikatorów.
// Domyślna akcja: wyczyść zaznaczenie i rozpocznij panoramowanie. // Domyślna akcja: wyczyść zaznaczenie i rozpocznij panoramowanie.
@@ -474,75 +443,63 @@ export class CanvasInteractions {
this.canvas.canvasSelection.updateSelection([]); this.canvas.canvasSelection.updateSelection([]);
} }
this.interaction.mode = 'panning'; this.interaction.mode = 'panning';
this.interaction.panStart = {x: e.clientX, y: e.clientY}; this.interaction.panStart = { x: e.clientX, y: e.clientY };
} }
startCanvasResize(worldCoords) { startCanvasResize(worldCoords) {
this.interaction.mode = 'resizingCanvas'; this.interaction.mode = 'resizingCanvas';
const startX = snapToGrid(worldCoords.x); const startX = snapToGrid(worldCoords.x);
const startY = snapToGrid(worldCoords.y); const startY = snapToGrid(worldCoords.y);
this.interaction.canvasResizeStart = {x: startX, y: startY}; this.interaction.canvasResizeStart = { x: startX, y: startY };
this.interaction.canvasResizeRect = {x: startX, y: startY, width: 0, height: 0}; this.interaction.canvasResizeRect = { x: startX, y: startY, width: 0, height: 0 };
this.canvas.render(); this.canvas.render();
} }
startCanvasMove(worldCoords) { startCanvasMove(worldCoords) {
this.interaction.mode = 'movingCanvas'; this.interaction.mode = 'movingCanvas';
this.interaction.dragStart = {...worldCoords}; this.interaction.dragStart = { ...worldCoords };
const initialX = snapToGrid(worldCoords.x - this.canvas.width / 2); const initialX = snapToGrid(worldCoords.x - this.canvas.width / 2);
const initialY = snapToGrid(worldCoords.y - this.canvas.height / 2); const initialY = snapToGrid(worldCoords.y - this.canvas.height / 2);
this.interaction.canvasMoveRect = { this.interaction.canvasMoveRect = {
x: initialX, x: initialX,
y: initialY, y: initialY,
width: this.canvas.width, width: this.canvas.width,
height: this.canvas.height height: this.canvas.height
}; };
this.canvas.canvas.style.cursor = 'grabbing'; this.canvas.canvas.style.cursor = 'grabbing';
this.canvas.render(); this.canvas.render();
} }
updateCanvasMove(worldCoords) { updateCanvasMove(worldCoords) {
if (!this.interaction.canvasMoveRect) return; if (!this.interaction.canvasMoveRect)
return;
const dx = worldCoords.x - this.interaction.dragStart.x; const dx = worldCoords.x - this.interaction.dragStart.x;
const dy = worldCoords.y - this.interaction.dragStart.y; const dy = worldCoords.y - this.interaction.dragStart.y;
const initialRectX = snapToGrid(this.interaction.dragStart.x - this.canvas.width / 2); const initialRectX = snapToGrid(this.interaction.dragStart.x - this.canvas.width / 2);
const initialRectY = snapToGrid(this.interaction.dragStart.y - this.canvas.height / 2); const initialRectY = snapToGrid(this.interaction.dragStart.y - this.canvas.height / 2);
this.interaction.canvasMoveRect.x = snapToGrid(initialRectX + dx); this.interaction.canvasMoveRect.x = snapToGrid(initialRectX + dx);
this.interaction.canvasMoveRect.y = snapToGrid(initialRectY + dy); this.interaction.canvasMoveRect.y = snapToGrid(initialRectY + dy);
this.canvas.render(); this.canvas.render();
} }
finalizeCanvasMove() { finalizeCanvasMove() {
const moveRect = this.interaction.canvasMoveRect; const moveRect = this.interaction.canvasMoveRect;
if (moveRect && (moveRect.x !== 0 || moveRect.y !== 0)) { if (moveRect && (moveRect.x !== 0 || moveRect.y !== 0)) {
const finalX = moveRect.x; const finalX = moveRect.x;
const finalY = moveRect.y; const finalY = moveRect.y;
this.canvas.layers.forEach((layer) => {
this.canvas.layers.forEach(layer => {
layer.x -= finalX; layer.x -= finalX;
layer.y -= finalY; layer.y -= finalY;
}); });
this.canvas.maskTool.updatePosition(-finalX, -finalY); this.canvas.maskTool.updatePosition(-finalX, -finalY);
// If a batch generation is in progress, update the captured context as well // If a batch generation is in progress, update the captured context as well
if (this.canvas.pendingBatchContext) { if (this.canvas.pendingBatchContext) {
this.canvas.pendingBatchContext.outputArea.x -= finalX; this.canvas.pendingBatchContext.outputArea.x -= finalX;
this.canvas.pendingBatchContext.outputArea.y -= finalY; this.canvas.pendingBatchContext.outputArea.y -= finalY;
// Also update the menu spawn position to keep it relative // Also update the menu spawn position to keep it relative
this.canvas.pendingBatchContext.spawnPosition.x -= finalX; this.canvas.pendingBatchContext.spawnPosition.x -= finalX;
this.canvas.pendingBatchContext.spawnPosition.y -= finalY; this.canvas.pendingBatchContext.spawnPosition.y -= finalY;
log.debug("Updated pending batch context during canvas move:", this.canvas.pendingBatchContext); log.debug("Updated pending batch context during canvas move:", this.canvas.pendingBatchContext);
} }
// Also move any active batch preview menus // Also move any active batch preview menus
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) { if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
this.canvas.batchPreviewManagers.forEach(manager => { this.canvas.batchPreviewManagers.forEach((manager) => {
manager.worldX -= finalX; manager.worldX -= finalX;
manager.worldY -= finalY; manager.worldY -= finalY;
if (manager.generationArea) { if (manager.generationArea) {
@@ -551,62 +508,58 @@ export class CanvasInteractions {
} }
}); });
} }
this.canvas.viewport.x -= finalX; this.canvas.viewport.x -= finalX;
this.canvas.viewport.y -= finalY; this.canvas.viewport.y -= finalY;
} }
this.canvas.render(); this.canvas.render();
this.canvas.saveState(); this.canvas.saveState();
} }
startPanning(e) { startPanning(e) {
if (!this.interaction.isCtrlPressed) { if (!this.interaction.isCtrlPressed) {
this.canvas.canvasSelection.updateSelection([]); this.canvas.canvasSelection.updateSelection([]);
} }
this.interaction.mode = 'panning'; this.interaction.mode = 'panning';
this.interaction.panStart = {x: e.clientX, y: e.clientY}; this.interaction.panStart = { x: e.clientX, y: e.clientY };
} }
panViewport(e) { panViewport(e) {
const dx = e.clientX - this.interaction.panStart.x; const dx = e.clientX - this.interaction.panStart.x;
const dy = e.clientY - this.interaction.panStart.y; const dy = e.clientY - this.interaction.panStart.y;
this.canvas.viewport.x -= dx / this.canvas.viewport.zoom; this.canvas.viewport.x -= dx / this.canvas.viewport.zoom;
this.canvas.viewport.y -= dy / 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(); this.canvas.render();
} }
dragLayers(worldCoords) { dragLayers(worldCoords) {
if (this.interaction.isAltPressed && !this.interaction.hasClonedInDrag && this.canvas.canvasSelection.selectedLayers.length > 0) { if (this.interaction.isAltPressed && !this.interaction.hasClonedInDrag && this.canvas.canvasSelection.selectedLayers.length > 0) {
// Scentralizowana logika duplikowania // Scentralizowana logika duplikowania
const newLayers = this.canvas.canvasSelection.duplicateSelectedLayers(); const newLayers = this.canvas.canvasSelection.duplicateSelectedLayers();
// Zresetuj pozycje przeciągania dla nowych, zduplikowanych warstw // Zresetuj pozycje przeciągania dla nowych, zduplikowanych warstw
this.originalLayerPositions.clear(); this.originalLayerPositions.clear();
newLayers.forEach(l => { newLayers.forEach((l) => {
this.originalLayerPositions.set(l, {x: l.x, y: l.y}); this.originalLayerPositions.set(l, { x: l.x, y: l.y });
}); });
this.interaction.hasClonedInDrag = true; this.interaction.hasClonedInDrag = true;
} }
const totalDx = worldCoords.x - this.interaction.dragStart.x; const totalDx = worldCoords.x - this.interaction.dragStart.x;
const totalDy = worldCoords.y - this.interaction.dragStart.y; const totalDy = worldCoords.y - this.interaction.dragStart.y;
let finalDx = totalDx, finalDy = totalDy; let finalDx = totalDx, finalDy = totalDy;
if (this.interaction.isCtrlPressed && this.canvas.canvasSelection.selectedLayers.length > 0) {
if (this.interaction.isCtrlPressed && this.canvas.canvasSelection.selectedLayer) { const firstLayer = this.canvas.canvasSelection.selectedLayers[0];
const originalPos = this.originalLayerPositions.get(this.canvas.canvasSelection.selectedLayer); const originalPos = this.originalLayerPositions.get(firstLayer);
if (originalPos) { if (originalPos) {
const tempLayerForSnap = { const tempLayerForSnap = {
...this.canvas.canvasSelection.selectedLayer, ...firstLayer,
x: originalPos.x + totalDx, x: originalPos.x + totalDx,
y: originalPos.y + totalDy y: originalPos.y + totalDy
}; };
const snapAdjustment = getSnapAdjustment(tempLayerForSnap); const snapAdjustment = getSnapAdjustment(tempLayerForSnap);
finalDx += snapAdjustment.dx; if (snapAdjustment) {
finalDy += snapAdjustment.dy; finalDx += snapAdjustment.x;
finalDy += snapAdjustment.y;
}
} }
} }
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
this.canvas.canvasSelection.selectedLayers.forEach(layer => {
const originalPos = this.originalLayerPositions.get(layer); const originalPos = this.originalLayerPositions.get(layer);
if (originalPos) { if (originalPos) {
layer.x = originalPos.x + finalDx; layer.x = originalPos.x + finalDx;
@@ -615,138 +568,121 @@ export class CanvasInteractions {
}); });
this.canvas.render(); this.canvas.render();
} }
resizeLayerFromHandle(worldCoords, isShiftPressed) { resizeLayerFromHandle(worldCoords, isShiftPressed) {
const layer = this.interaction.transformingLayer; const layer = this.interaction.transformingLayer;
if (!layer) return; if (!layer)
return;
let mouseX = worldCoords.x; let mouseX = worldCoords.x;
let mouseY = worldCoords.y; let mouseY = worldCoords.y;
if (this.interaction.isCtrlPressed) { if (this.interaction.isCtrlPressed) {
const snapThreshold = 10 / this.canvas.viewport.zoom; const snapThreshold = 10 / this.canvas.viewport.zoom;
const snappedMouseX = snapToGrid(mouseX); const snappedMouseX = snapToGrid(mouseX);
if (Math.abs(mouseX - snappedMouseX) < snapThreshold) mouseX = snappedMouseX; if (Math.abs(mouseX - snappedMouseX) < snapThreshold)
mouseX = snappedMouseX;
const snappedMouseY = snapToGrid(mouseY); 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; 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 handle = this.interaction.resizeHandle;
const anchor = this.interaction.resizeAnchor; const anchor = this.interaction.resizeAnchor;
const rad = o.rotation * Math.PI / 180; const rad = o.rotation * Math.PI / 180;
const cos = Math.cos(rad); const cos = Math.cos(rad);
const sin = Math.sin(rad); const sin = Math.sin(rad);
const vecX = mouseX - anchor.x; const vecX = mouseX - anchor.x;
const vecY = mouseY - anchor.y; const vecY = mouseY - anchor.y;
let newWidth = vecX * cos + vecY * sin; let newWidth = vecX * cos + vecY * sin;
let newHeight = vecY * cos - vecX * sin; let newHeight = vecY * cos - vecX * sin;
if (isShiftPressed) { if (isShiftPressed) {
const originalAspectRatio = o.width / o.height; const originalAspectRatio = o.width / o.height;
if (Math.abs(newWidth) > Math.abs(newHeight) * originalAspectRatio) { if (Math.abs(newWidth) > Math.abs(newHeight) * originalAspectRatio) {
newHeight = (Math.sign(newHeight) || 1) * Math.abs(newWidth) / originalAspectRatio; newHeight = (Math.sign(newHeight) || 1) * Math.abs(newWidth) / originalAspectRatio;
} else { }
else {
newWidth = (Math.sign(newWidth) || 1) * Math.abs(newHeight) * originalAspectRatio; newWidth = (Math.sign(newWidth) || 1) * Math.abs(newHeight) * originalAspectRatio;
} }
} }
let signX = handle?.includes('e') ? 1 : (handle?.includes('w') ? -1 : 0);
let signX = handle.includes('e') ? 1 : (handle.includes('w') ? -1 : 0); let signY = handle?.includes('s') ? 1 : (handle?.includes('n') ? -1 : 0);
let signY = handle.includes('s') ? 1 : (handle.includes('n') ? -1 : 0);
newWidth *= signX; newWidth *= signX;
newHeight *= signY; newHeight *= signY;
if (signX === 0)
if (signX === 0) newWidth = o.width; newWidth = o.width;
if (signY === 0) newHeight = o.height; if (signY === 0)
newHeight = o.height;
if (newWidth < 10) newWidth = 10; if (newWidth < 10)
if (newHeight < 10) newHeight = 10; newWidth = 10;
if (newHeight < 10)
newHeight = 10;
layer.width = newWidth; layer.width = newWidth;
layer.height = newHeight; layer.height = newHeight;
const deltaW = newWidth - o.width; const deltaW = newWidth - o.width;
const deltaH = newHeight - o.height; const deltaH = newHeight - o.height;
const shiftX = (deltaW / 2) * signX; const shiftX = (deltaW / 2) * signX;
const shiftY = (deltaH / 2) * signY; const shiftY = (deltaH / 2) * signY;
const worldShiftX = shiftX * cos - shiftY * sin; const worldShiftX = shiftX * cos - shiftY * sin;
const worldShiftY = shiftX * sin + shiftY * cos; const worldShiftY = shiftX * sin + shiftY * cos;
const newCenterX = o.centerX + worldShiftX; const newCenterX = o.centerX + worldShiftX;
const newCenterY = o.centerY + worldShiftY; const newCenterY = o.centerY + worldShiftY;
layer.x = newCenterX - layer.width / 2; layer.x = newCenterX - layer.width / 2;
layer.y = newCenterY - layer.height / 2; layer.y = newCenterY - layer.height / 2;
this.canvas.render(); this.canvas.render();
} }
rotateLayerFromHandle(worldCoords, isShiftPressed) { rotateLayerFromHandle(worldCoords, isShiftPressed) {
const layer = this.interaction.transformingLayer; const layer = this.interaction.transformingLayer;
if (!layer) return; if (!layer)
return;
const o = this.interaction.transformOrigin; 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 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); const currentAngle = Math.atan2(worldCoords.y - o.centerY, worldCoords.x - o.centerX);
let angleDiff = (currentAngle - startAngle) * 180 / Math.PI; let angleDiff = (currentAngle - startAngle) * 180 / Math.PI;
let newRotation = o.rotation + angleDiff; let newRotation = o.rotation + angleDiff;
if (isShiftPressed) { if (isShiftPressed) {
newRotation = Math.round(newRotation / 15) * 15; newRotation = Math.round(newRotation / 15) * 15;
} }
layer.rotation = newRotation; layer.rotation = newRotation;
this.canvas.render(); this.canvas.render();
} }
updateCanvasResize(worldCoords) { updateCanvasResize(worldCoords) {
if (!this.interaction.canvasResizeRect)
return;
const snappedMouseX = snapToGrid(worldCoords.x); const snappedMouseX = snapToGrid(worldCoords.x);
const snappedMouseY = snapToGrid(worldCoords.y); const snappedMouseY = snapToGrid(worldCoords.y);
const start = this.interaction.canvasResizeStart; const start = this.interaction.canvasResizeStart;
this.interaction.canvasResizeRect.x = Math.min(snappedMouseX, start.x); this.interaction.canvasResizeRect.x = Math.min(snappedMouseX, start.x);
this.interaction.canvasResizeRect.y = Math.min(snappedMouseY, start.y); this.interaction.canvasResizeRect.y = Math.min(snappedMouseY, start.y);
this.interaction.canvasResizeRect.width = Math.abs(snappedMouseX - start.x); this.interaction.canvasResizeRect.width = Math.abs(snappedMouseX - start.x);
this.interaction.canvasResizeRect.height = Math.abs(snappedMouseY - start.y); this.interaction.canvasResizeRect.height = Math.abs(snappedMouseY - start.y);
this.canvas.render(); this.canvas.render();
} }
finalizeCanvasResize() { finalizeCanvasResize() {
if (this.interaction.canvasResizeRect && this.interaction.canvasResizeRect.width > 1 && this.interaction.canvasResizeRect.height > 1) { if (this.interaction.canvasResizeRect && this.interaction.canvasResizeRect.width > 1 && this.interaction.canvasResizeRect.height > 1) {
const newWidth = Math.round(this.interaction.canvasResizeRect.width); const newWidth = Math.round(this.interaction.canvasResizeRect.width);
const newHeight = Math.round(this.interaction.canvasResizeRect.height); const newHeight = Math.round(this.interaction.canvasResizeRect.height);
const finalX = this.interaction.canvasResizeRect.x; const finalX = this.interaction.canvasResizeRect.x;
const finalY = this.interaction.canvasResizeRect.y; const finalY = this.interaction.canvasResizeRect.y;
this.canvas.updateOutputAreaSize(newWidth, newHeight); this.canvas.updateOutputAreaSize(newWidth, newHeight);
this.canvas.layers.forEach((layer) => {
this.canvas.layers.forEach(layer => {
layer.x -= finalX; layer.x -= finalX;
layer.y -= finalY; layer.y -= finalY;
}); });
this.canvas.maskTool.updatePosition(-finalX, -finalY); this.canvas.maskTool.updatePosition(-finalX, -finalY);
// If a batch generation is in progress, update the captured context as well // If a batch generation is in progress, update the captured context as well
if (this.canvas.pendingBatchContext) { if (this.canvas.pendingBatchContext) {
this.canvas.pendingBatchContext.outputArea.x -= finalX; this.canvas.pendingBatchContext.outputArea.x -= finalX;
this.canvas.pendingBatchContext.outputArea.y -= finalY; this.canvas.pendingBatchContext.outputArea.y -= finalY;
// Also update the menu spawn position to keep it relative // Also update the menu spawn position to keep it relative
this.canvas.pendingBatchContext.spawnPosition.x -= finalX; this.canvas.pendingBatchContext.spawnPosition.x -= finalX;
this.canvas.pendingBatchContext.spawnPosition.y -= finalY; this.canvas.pendingBatchContext.spawnPosition.y -= finalY;
log.debug("Updated pending batch context during canvas resize:", this.canvas.pendingBatchContext); log.debug("Updated pending batch context during canvas resize:", this.canvas.pendingBatchContext);
} }
// Also move any active batch preview menus // Also move any active batch preview menus
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) { if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
this.canvas.batchPreviewManagers.forEach(manager => { this.canvas.batchPreviewManagers.forEach((manager) => {
manager.worldX -= finalX; manager.worldX -= finalX;
manager.worldY -= finalY; manager.worldY -= finalY;
if (manager.generationArea) { if (manager.generationArea) {
@@ -755,117 +691,101 @@ export class CanvasInteractions {
} }
}); });
} }
this.canvas.viewport.x -= finalX; this.canvas.viewport.x -= finalX;
this.canvas.viewport.y -= finalY; this.canvas.viewport.y -= finalY;
} }
} }
handleDragOver(e) { handleDragOver(e) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); // Prevent ComfyUI from handling this event e.stopPropagation(); // Prevent ComfyUI from handling this event
e.dataTransfer.dropEffect = 'copy'; if (e.dataTransfer)
e.dataTransfer.dropEffect = 'copy';
} }
handleDragEnter(e) { handleDragEnter(e) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); // Prevent ComfyUI from handling this event e.stopPropagation(); // Prevent ComfyUI from handling this event
this.canvas.canvas.style.backgroundColor = 'rgba(45, 90, 160, 0.1)'; this.canvas.canvas.style.backgroundColor = 'rgba(45, 90, 160, 0.1)';
this.canvas.canvas.style.border = '2px dashed #2d5aa0'; this.canvas.canvas.style.border = '2px dashed #2d5aa0';
} }
handleDragLeave(e) { handleDragLeave(e) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); // Prevent ComfyUI from handling this event e.stopPropagation(); // Prevent ComfyUI from handling this event
if (!this.canvas.canvas.contains(e.relatedTarget)) { if (!this.canvas.canvas.contains(e.relatedTarget)) {
this.canvas.canvas.style.backgroundColor = ''; this.canvas.canvas.style.backgroundColor = '';
this.canvas.canvas.style.border = ''; this.canvas.canvas.style.border = '';
} }
} }
async handleDrop(e) { async handleDrop(e) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); // CRITICAL: Prevent ComfyUI from handling this event and loading workflow e.stopPropagation(); // CRITICAL: Prevent ComfyUI from handling this event and loading workflow
log.info("Canvas drag & drop event intercepted - preventing ComfyUI workflow loading"); log.info("Canvas drag & drop event intercepted - preventing ComfyUI workflow loading");
this.canvas.canvas.style.backgroundColor = ''; this.canvas.canvas.style.backgroundColor = '';
this.canvas.canvas.style.border = ''; this.canvas.canvas.style.border = '';
if (!e.dataTransfer)
return;
const files = Array.from(e.dataTransfer.files); const files = Array.from(e.dataTransfer.files);
const worldCoords = this.canvas.getMouseWorldCoordinates(e); const worldCoords = this.canvas.getMouseWorldCoordinates(e);
log.info(`Dropped ${files.length} file(s) onto canvas at position (${worldCoords.x}, ${worldCoords.y})`); log.info(`Dropped ${files.length} file(s) onto canvas at position (${worldCoords.x}, ${worldCoords.y})`);
for (const file of files) { for (const file of files) {
if (file.type.startsWith('image/')) { if (file.type.startsWith('image/')) {
try { try {
await this.loadDroppedImageFile(file, worldCoords); await this.loadDroppedImageFile(file, worldCoords);
log.info(`Successfully loaded dropped image: ${file.name}`); log.info(`Successfully loaded dropped image: ${file.name}`);
} catch (error) { }
catch (error) {
log.error(`Failed to load dropped image ${file.name}:`, error); log.error(`Failed to load dropped image ${file.name}:`, error);
} }
} else { }
else {
log.warn(`Skipped non-image file: ${file.name} (${file.type})`); log.warn(`Skipped non-image file: ${file.name} (${file.type})`);
} }
} }
} }
async loadDroppedImageFile(file, worldCoords) { async loadDroppedImageFile(file, worldCoords) {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = async (e) => { reader.onload = async (e) => {
const img = new Image(); const img = new Image();
img.onload = async () => { 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'; const addMode = fitOnAddWidget && fitOnAddWidget.value ? 'fit' : 'center';
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
await this.canvas.addLayer(img, {}, addMode);
}; };
img.onerror = () => { img.onerror = () => {
log.error(`Failed to load dropped image: ${file.name}`); 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 = () => { reader.onerror = () => {
log.error(`Failed to read dropped file: ${file.name}`); log.error(`Failed to read dropped file: ${file.name}`);
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
} }
async handlePasteEvent(e) { async handlePasteEvent(e) {
const shouldHandle = this.canvas.isMouseOver ||
const shouldHandle = this.canvas.isMouseOver || this.canvas.canvas.contains(document.activeElement) ||
this.canvas.canvas.contains(document.activeElement) || document.activeElement === this.canvas.canvas ||
document.activeElement === this.canvas.canvas || document.activeElement === document.body;
document.activeElement === document.body;
if (!shouldHandle) { if (!shouldHandle) {
log.debug("Paste event ignored - not focused on canvas"); log.debug("Paste event ignored - not focused on canvas");
return; return;
} }
log.info("Paste event detected, checking clipboard preference"); log.info("Paste event detected, checking clipboard preference");
const preference = this.canvas.canvasLayers.clipboardPreference; const preference = this.canvas.canvasLayers.clipboardPreference;
if (preference === 'clipspace') { if (preference === 'clipspace') {
log.info("Clipboard preference is clipspace, delegating to ClipboardManager"); log.info("Clipboard preference is clipspace, delegating to ClipboardManager");
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
await this.canvas.canvasLayers.clipboardManager.handlePaste('mouse', preference); await this.canvas.canvasLayers.clipboardManager.handlePaste('mouse', preference);
return; return;
} }
const clipboardData = e.clipboardData; const clipboardData = e.clipboardData;
if (clipboardData && clipboardData.items) { if (clipboardData && clipboardData.items) {
for (const item of clipboardData.items) { for (const item of clipboardData.items) {
if (item.type.startsWith('image/')) { if (item.type.startsWith('image/')) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const file = item.getAsFile(); const file = item.getAsFile();
if (file) { if (file) {
log.info("Found direct image data in paste event"); log.info("Found direct image data in paste event");
@@ -875,7 +795,9 @@ export class CanvasInteractions {
img.onload = async () => { img.onload = async () => {
await this.canvas.canvasLayers.addLayerWithImage(img, {}, 'mouse'); await this.canvas.canvasLayers.addLayerWithImage(img, {}, 'mouse');
}; };
img.src = event.target.result; if (event.target?.result) {
img.src = event.target.result;
}
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
return; return;
@@ -883,7 +805,6 @@ export class CanvasInteractions {
} }
} }
} }
await this.canvas.canvasLayers.clipboardManager.handlePaste('mouse', preference); await this.canvas.canvasLayers.clipboardManager.handlePaste('mouse', preference);
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,5 @@
import {createModuleLogger} from "./utils/LoggerUtils.js"; import { createModuleLogger } from "./utils/LoggerUtils.js";
const log = createModuleLogger('CanvasLayersPanel'); const log = createModuleLogger('CanvasLayersPanel');
export class CanvasLayersPanel { export class CanvasLayersPanel {
constructor(canvas) { constructor(canvas) {
this.canvas = canvas; this.canvas = canvas;
@@ -11,22 +9,14 @@ export class CanvasLayersPanel {
this.dragInsertionLine = null; this.dragInsertionLine = null;
this.isMultiSelecting = false; this.isMultiSelecting = false;
this.lastSelectedIndex = -1; this.lastSelectedIndex = -1;
// Binding metod dla event handlerów
this.handleLayerClick = this.handleLayerClick.bind(this); this.handleLayerClick = this.handleLayerClick.bind(this);
this.handleDragStart = this.handleDragStart.bind(this); this.handleDragStart = this.handleDragStart.bind(this);
this.handleDragOver = this.handleDragOver.bind(this); this.handleDragOver = this.handleDragOver.bind(this);
this.handleDragEnd = this.handleDragEnd.bind(this); this.handleDragEnd = this.handleDragEnd.bind(this);
this.handleDrop = this.handleDrop.bind(this); this.handleDrop = this.handleDrop.bind(this);
log.info('CanvasLayersPanel initialized'); log.info('CanvasLayersPanel initialized');
} }
/**
* Tworzy struktur&ecirc; HTML panelu warstw
*/
createPanelStructure() { createPanelStructure() {
// Główny kontener panelu
this.container = document.createElement('div'); this.container = document.createElement('div');
this.container.className = 'layers-panel'; this.container.className = 'layers-panel';
this.container.tabIndex = 0; // Umożliwia fokus na panelu this.container.tabIndex = 0; // Umożliwia fokus na panelu
@@ -41,15 +31,10 @@ export class CanvasLayersPanel {
<!-- Lista warstw będzie renderowana tutaj --> <!-- Lista warstw będzie renderowana tutaj -->
</div> </div>
`; `;
this.layersContainer = this.container.querySelector('#layers-container'); this.layersContainer = this.container.querySelector('#layers-container');
// Dodanie stylów CSS
this.injectStyles(); this.injectStyles();
// Setup event listeners dla przycisków // Setup event listeners dla przycisków
this.setupControlButtons(); this.setupControlButtons();
// Dodaj listener dla klawiatury, aby usuwanie działało z panelu // Dodaj listener dla klawiatury, aby usuwanie działało z panelu
this.container.addEventListener('keydown', (e) => { this.container.addEventListener('keydown', (e) => {
if (e.key === 'Delete' || e.key === 'Backspace') { if (e.key === 'Delete' || e.key === 'Backspace') {
@@ -58,20 +43,14 @@ export class CanvasLayersPanel {
this.deleteSelectedLayers(); this.deleteSelectedLayers();
} }
}); });
log.debug('Panel structure created'); log.debug('Panel structure created');
return this.container; return this.container;
} }
/**
* Dodaje style CSS do panelu
*/
injectStyles() { injectStyles() {
const styleId = 'layers-panel-styles'; const styleId = 'layers-panel-styles';
if (document.getElementById(styleId)) { if (document.getElementById(styleId)) {
return; // Style już istnieją return; // Style już istnieją
} }
const style = document.createElement('style'); const style = document.createElement('style');
style.id = styleId; style.id = styleId;
style.textContent = ` style.textContent = `
@@ -253,404 +232,282 @@ export class CanvasLayersPanel {
background: #5a5a5a; background: #5a5a5a;
} }
`; `;
document.head.appendChild(style); document.head.appendChild(style);
log.debug('Styles injected'); log.debug('Styles injected');
} }
/**
* Konfiguruje event listenery dla przycisków kontrolnych
*/
setupControlButtons() { setupControlButtons() {
if (!this.container)
return;
const deleteBtn = this.container.querySelector('#delete-layer-btn'); const deleteBtn = this.container.querySelector('#delete-layer-btn');
deleteBtn?.addEventListener('click', () => { deleteBtn?.addEventListener('click', () => {
log.info('Delete layer button clicked'); log.info('Delete layer button clicked');
this.deleteSelectedLayers(); this.deleteSelectedLayers();
}); });
} }
/**
* Renderuje listę warstw
*/
renderLayers() { renderLayers() {
if (!this.layersContainer) { if (!this.layersContainer) {
log.warn('Layers container not initialized'); log.warn('Layers container not initialized');
return; return;
} }
// Wyczyść istniejącą zawartość // Wyczyść istniejącą zawartość
this.layersContainer.innerHTML = ''; this.layersContainer.innerHTML = '';
// Usuń linię wstawiania jeśli istnieje // Usuń linię wstawiania jeśli istnieje
this.removeDragInsertionLine(); this.removeDragInsertionLine();
// Sortuj warstwy według zIndex (od najwyższej do najniższej) // Sortuj warstwy według zIndex (od najwyższej do najniższej)
const sortedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex); const sortedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex);
sortedLayers.forEach((layer, index) => { sortedLayers.forEach((layer, index) => {
const layerElement = this.createLayerElement(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`); log.debug(`Rendered ${sortedLayers.length} layers`);
} }
/**
* Tworzy element HTML dla pojedynczej warstwy
*/
createLayerElement(layer, index) { createLayerElement(layer, index) {
const layerRow = document.createElement('div'); const layerRow = document.createElement('div');
layerRow.className = 'layer-row'; layerRow.className = 'layer-row';
layerRow.draggable = true; layerRow.draggable = true;
layerRow.dataset.layerIndex = index; layerRow.dataset.layerIndex = String(index);
// Sprawdź czy warstwa jest zaznaczona
const isSelected = this.canvas.canvasSelection.selectedLayers.includes(layer); const isSelected = this.canvas.canvasSelection.selectedLayers.includes(layer);
if (isSelected) { if (isSelected) {
layerRow.classList.add('selected'); layerRow.classList.add('selected');
} }
// Ustawienie domyślnych właściwości jeśli nie istnieją // Ustawienie domyślnych właściwości jeśli nie istnieją
if (!layer.name) { if (!layer.name) {
layer.name = this.ensureUniqueName(`Layer ${layer.zIndex + 1}`, layer); layer.name = this.ensureUniqueName(`Layer ${layer.zIndex + 1}`, layer);
} else { }
else {
// Sprawdź unikalność istniejącej nazwy (np. przy duplikowaniu) // Sprawdź unikalność istniejącej nazwy (np. przy duplikowaniu)
layer.name = this.ensureUniqueName(layer.name, layer); layer.name = this.ensureUniqueName(layer.name, layer);
} }
layerRow.innerHTML = ` layerRow.innerHTML = `
<div class="layer-thumbnail" data-layer-index="${index}"></div> <div class="layer-thumbnail" data-layer-index="${index}"></div>
<span class="layer-name" data-layer-index="${index}">${layer.name}</span> <span class="layer-name" data-layer-index="${index}">${layer.name}</span>
`; `;
const thumbnailContainer = layerRow.querySelector('.layer-thumbnail');
// Wygeneruj miniaturkę if (thumbnailContainer) {
this.generateThumbnail(layer, layerRow.querySelector('.layer-thumbnail')); this.generateThumbnail(layer, thumbnailContainer);
}
// Event listenery
this.setupLayerEventListeners(layerRow, layer, index); this.setupLayerEventListeners(layerRow, layer, index);
return layerRow; return layerRow;
} }
/**
* Generuje miniaturkę warstwy
*/
generateThumbnail(layer, thumbnailContainer) { generateThumbnail(layer, thumbnailContainer) {
if (!layer.image) { if (!layer.image) {
thumbnailContainer.style.background = '#4a4a4a'; thumbnailContainer.style.background = '#4a4a4a';
return; return;
} }
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true }); const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx)
return;
canvas.width = 48; canvas.width = 48;
canvas.height = 48; canvas.height = 48;
// Oblicz skalę zachowując proporcje
const scale = Math.min(48 / layer.image.width, 48 / layer.image.height); const scale = Math.min(48 / layer.image.width, 48 / layer.image.height);
const scaledWidth = layer.image.width * scale; const scaledWidth = layer.image.width * scale;
const scaledHeight = layer.image.height * scale; const scaledHeight = layer.image.height * scale;
// Wycentruj obraz // Wycentruj obraz
const x = (48 - scaledWidth) / 2; const x = (48 - scaledWidth) / 2;
const y = (48 - scaledHeight) / 2; const y = (48 - scaledHeight) / 2;
// Narysuj obraz z wyższą jakością
ctx.imageSmoothingEnabled = true; ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high'; ctx.imageSmoothingQuality = 'high';
ctx.drawImage(layer.image, x, y, scaledWidth, scaledHeight); ctx.drawImage(layer.image, x, y, scaledWidth, scaledHeight);
thumbnailContainer.appendChild(canvas); thumbnailContainer.appendChild(canvas);
} }
/**
* Konfiguruje event listenery dla elementu warstwy
*/
setupLayerEventListeners(layerRow, layer, index) { setupLayerEventListeners(layerRow, layer, index) {
// Mousedown handler - zaznaczanie w momencie wciśnięcia przycisku
layerRow.addEventListener('mousedown', (e) => { layerRow.addEventListener('mousedown', (e) => {
// Ignoruj, jeśli edytujemy nazwę
const nameElement = layerRow.querySelector('.layer-name'); const nameElement = layerRow.querySelector('.layer-name');
if (nameElement && nameElement.classList.contains('editing')) { if (nameElement && nameElement.classList.contains('editing')) {
return; return;
} }
this.handleLayerClick(e, layer, index); this.handleLayerClick(e, layer, index);
}); });
// Double click handler - edycja nazwy
layerRow.addEventListener('dblclick', (e) => { layerRow.addEventListener('dblclick', (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const nameElement = layerRow.querySelector('.layer-name'); 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('dragstart', (e) => this.handleDragStart(e, layer, index));
layerRow.addEventListener('dragover', this.handleDragOver); layerRow.addEventListener('dragover', this.handleDragOver.bind(this));
layerRow.addEventListener('dragend', this.handleDragEnd); layerRow.addEventListener('dragend', this.handleDragEnd.bind(this));
layerRow.addEventListener('drop', (e) => this.handleDrop(e, index)); 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) { handleLayerClick(e, layer, index) {
const isCtrlPressed = e.ctrlKey || e.metaKey; const isCtrlPressed = e.ctrlKey || e.metaKey;
const isShiftPressed = e.shiftKey; const isShiftPressed = e.shiftKey;
// Aktualizuj wewnętrzny stan zaznaczenia w obiekcie canvas // Aktualizuj wewnętrzny stan zaznaczenia w obiekcie canvas
// Ta funkcja NIE powinna już wywoływać onSelectionChanged w panelu. // Ta funkcja NIE powinna już wywoływać onSelectionChanged w panelu.
this.canvas.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index); this.canvas.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index);
// Aktualizuj tylko wygląd (klasy CSS), bez niszczenia DOM // 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}`); log.debug(`Layer clicked: ${layer.name}, selection count: ${this.canvas.canvasSelection.selectedLayers.length}`);
} }
/**
* Rozpoczyna edycję nazwy warstwy
*/
startEditingLayerName(nameElement, layer) { startEditingLayerName(nameElement, layer) {
const currentName = layer.name; const currentName = layer.name;
nameElement.classList.add('editing'); nameElement.classList.add('editing');
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'text'; input.type = 'text';
input.value = currentName; input.value = currentName;
input.style.width = '100%'; input.style.width = '100%';
nameElement.innerHTML = ''; nameElement.innerHTML = '';
nameElement.appendChild(input); nameElement.appendChild(input);
input.focus(); input.focus();
input.select(); input.select();
const finishEditing = () => { const finishEditing = () => {
let newName = input.value.trim() || `Layer ${layer.zIndex + 1}`; let newName = input.value.trim() || `Layer ${layer.zIndex + 1}`;
newName = this.ensureUniqueName(newName, layer); newName = this.ensureUniqueName(newName, layer);
layer.name = newName; layer.name = newName;
nameElement.classList.remove('editing'); nameElement.classList.remove('editing');
nameElement.textContent = newName; nameElement.textContent = newName;
this.canvas.saveState(); this.canvas.saveState();
log.info(`Layer renamed to: ${newName}`); log.info(`Layer renamed to: ${newName}`);
}; };
input.addEventListener('blur', finishEditing); input.addEventListener('blur', finishEditing);
input.addEventListener('keydown', (e) => { input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
finishEditing(); finishEditing();
} else if (e.key === 'Escape') { }
else if (e.key === 'Escape') {
nameElement.classList.remove('editing'); nameElement.classList.remove('editing');
nameElement.textContent = currentName; nameElement.textContent = currentName;
} }
}); });
} }
/**
* Zapewnia unikalność nazwy warstwy
*/
ensureUniqueName(proposedName, currentLayer) { ensureUniqueName(proposedName, currentLayer) {
const existingNames = this.canvas.layers const existingNames = this.canvas.layers
.filter(layer => layer !== currentLayer) .filter((layer) => layer !== currentLayer)
.map(layer => layer.name); .map((layer) => layer.name);
if (!existingNames.includes(proposedName)) { if (!existingNames.includes(proposedName)) {
return proposedName; return proposedName;
} }
// Sprawdź czy nazwa już ma numerację w nawiasach // Sprawdź czy nazwa już ma numerację w nawiasach
const match = proposedName.match(/^(.+?)\s*\((\d+)\)$/); const match = proposedName.match(/^(.+?)\s*\((\d+)\)$/);
let baseName, startNumber; let baseName, startNumber;
if (match) { if (match) {
baseName = match[1].trim(); baseName = match[1].trim();
startNumber = parseInt(match[2]) + 1; startNumber = parseInt(match[2]) + 1;
} else { }
else {
baseName = proposedName; baseName = proposedName;
startNumber = 1; startNumber = 1;
} }
// Znajdź pierwszą dostępną numerację // Znajdź pierwszą dostępną numerację
let counter = startNumber; let counter = startNumber;
let uniqueName; let uniqueName;
do { do {
uniqueName = `${baseName} (${counter})`; uniqueName = `${baseName} (${counter})`;
counter++; counter++;
} while (existingNames.includes(uniqueName)); } while (existingNames.includes(uniqueName));
return uniqueName; return uniqueName;
} }
/**
* Usuwa zaznaczone warstwy
*/
deleteSelectedLayers() { deleteSelectedLayers() {
if (this.canvas.canvasSelection.selectedLayers.length === 0) { if (this.canvas.canvasSelection.selectedLayers.length === 0) {
log.debug('No layers selected for deletion'); log.debug('No layers selected for deletion');
return; return;
} }
log.info(`Deleting ${this.canvas.canvasSelection.selectedLayers.length} selected layers`); log.info(`Deleting ${this.canvas.canvasSelection.selectedLayers.length} selected layers`);
this.canvas.removeSelectedLayers(); this.canvas.removeSelectedLayers();
this.renderLayers(); this.renderLayers();
} }
/**
* Rozpoczyna przeciąganie warstwy
*/
handleDragStart(e, layer, index) { 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'); const editingElement = this.layersContainer.querySelector('.layer-name.editing');
if (editingElement) { if (editingElement) {
e.preventDefault(); e.preventDefault();
return; return;
} }
// Jeśli przeciągana warstwa nie jest zaznaczona, zaznacz ją // Jeśli przeciągana warstwa nie jest zaznaczona, zaznacz ją
if (!this.canvas.canvasSelection.selectedLayers.includes(layer)) { if (!this.canvas.canvasSelection.selectedLayers.includes(layer)) {
this.canvas.updateSelection([layer]); this.canvas.updateSelection([layer]);
this.renderLayers(); this.renderLayers();
} }
this.draggedElements = [...this.canvas.canvasSelection.selectedLayers]; this.draggedElements = [...this.canvas.canvasSelection.selectedLayers];
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', ''); // Wymagane przez standard e.dataTransfer.setData('text/plain', '');
// Dodaj klasę dragging do przeciąganych elementów
this.layersContainer.querySelectorAll('.layer-row').forEach((row, idx) => { this.layersContainer.querySelectorAll('.layer-row').forEach((row, idx) => {
const sortedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex); const sortedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex);
if (this.draggedElements.includes(sortedLayers[idx])) { if (this.draggedElements.includes(sortedLayers[idx])) {
row.classList.add('dragging'); row.classList.add('dragging');
} }
}); });
log.debug(`Started dragging ${this.draggedElements.length} layers`); log.debug(`Started dragging ${this.draggedElements.length} layers`);
} }
/**
* Obsługuje przeciąganie nad warstwą
*/
handleDragOver(e) { handleDragOver(e) {
e.preventDefault(); e.preventDefault();
e.dataTransfer.dropEffect = 'move'; if (e.dataTransfer)
e.dataTransfer.dropEffect = 'move';
const layerRow = e.currentTarget; const layerRow = e.currentTarget;
const rect = layerRow.getBoundingClientRect(); const rect = layerRow.getBoundingClientRect();
const midpoint = rect.top + rect.height / 2; const midpoint = rect.top + rect.height / 2;
const isUpperHalf = e.clientY < midpoint; const isUpperHalf = e.clientY < midpoint;
this.showDragInsertionLine(layerRow, isUpperHalf); this.showDragInsertionLine(layerRow, isUpperHalf);
} }
/**
* Pokazuje linię wskaźnika wstawiania
*/
showDragInsertionLine(targetRow, isUpperHalf) { showDragInsertionLine(targetRow, isUpperHalf) {
this.removeDragInsertionLine(); this.removeDragInsertionLine();
const line = document.createElement('div'); const line = document.createElement('div');
line.className = 'drag-insertion-line'; line.className = 'drag-insertion-line';
if (isUpperHalf) { if (isUpperHalf) {
line.style.top = '-1px'; line.style.top = '-1px';
} else { }
else {
line.style.bottom = '-1px'; line.style.bottom = '-1px';
} }
targetRow.style.position = 'relative'; targetRow.style.position = 'relative';
targetRow.appendChild(line); targetRow.appendChild(line);
this.dragInsertionLine = line; this.dragInsertionLine = line;
} }
/**
* Usuwa linię wskaźnika wstawiania
*/
removeDragInsertionLine() { removeDragInsertionLine() {
if (this.dragInsertionLine) { if (this.dragInsertionLine) {
this.dragInsertionLine.remove(); this.dragInsertionLine.remove();
this.dragInsertionLine = null; this.dragInsertionLine = null;
} }
} }
/**
* Obsługuje upuszczenie warstwy
*/
handleDrop(e, targetIndex) { handleDrop(e, targetIndex) {
e.preventDefault(); e.preventDefault();
this.removeDragInsertionLine(); this.removeDragInsertionLine();
if (this.draggedElements.length === 0 || !(e.currentTarget instanceof HTMLElement))
if (this.draggedElements.length === 0) return; return;
const rect = e.currentTarget.getBoundingClientRect(); const rect = e.currentTarget.getBoundingClientRect();
const midpoint = rect.top + rect.height / 2; const midpoint = rect.top + rect.height / 2;
const isUpperHalf = e.clientY < midpoint; const isUpperHalf = e.clientY < midpoint;
// Oblicz docelowy indeks // Oblicz docelowy indeks
let insertIndex = targetIndex; let insertIndex = targetIndex;
if (!isUpperHalf) { if (!isUpperHalf) {
insertIndex = targetIndex + 1; insertIndex = targetIndex + 1;
} }
// Użyj nowej, centralnej funkcji do przesuwania warstw // Użyj nowej, centralnej funkcji do przesuwania warstw
this.canvas.canvasLayers.moveLayers(this.draggedElements, { toIndex: insertIndex }); this.canvas.canvasLayers.moveLayers(this.draggedElements, { toIndex: insertIndex });
log.info(`Dropped ${this.draggedElements.length} layers at position ${insertIndex}`); log.info(`Dropped ${this.draggedElements.length} layers at position ${insertIndex}`);
} }
/**
* Kończy przeciąganie
*/
handleDragEnd(e) { handleDragEnd(e) {
this.removeDragInsertionLine(); this.removeDragInsertionLine();
if (!this.layersContainer)
// Usuń klasę dragging ze wszystkich elementów return;
this.layersContainer.querySelectorAll('.layer-row').forEach(row => { this.layersContainer.querySelectorAll('.layer-row').forEach((row) => {
row.classList.remove('dragging'); row.classList.remove('dragging');
}); });
this.draggedElements = []; this.draggedElements = [];
} }
/**
* Aktualizuje panel gdy zmienią się warstwy
*/
onLayersChanged() { onLayersChanged() {
this.renderLayers(); this.renderLayers();
} }
/**
* Aktualizuje wygląd zaznaczenia w panelu bez pełnego renderowania.
*/
updateSelectionAppearance() { updateSelectionAppearance() {
if (!this.layersContainer)
return;
const sortedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex); const sortedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex);
const layerRows = this.layersContainer.querySelectorAll('.layer-row'); const layerRows = this.layersContainer.querySelectorAll('.layer-row');
layerRows.forEach((row, index) => { layerRows.forEach((row, index) => {
const layer = sortedLayers[index]; const layer = sortedLayers[index];
if (this.canvas.canvasSelection.selectedLayers.includes(layer)) { if (this.canvas.canvasSelection.selectedLayers.includes(layer)) {
row.classList.add('selected'); row.classList.add('selected');
} else { }
else {
row.classList.remove('selected'); 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). * Aktualizuje panel gdy zmieni się zaznaczenie (wywoływane z zewnątrz).
* Zamiast pełnego renderowania, tylko aktualizujemy wygląd. * Zamiast pełnego renderowania, tylko aktualizujemy wygląd.
@@ -658,10 +515,6 @@ export class CanvasLayersPanel {
onSelectionChanged() { onSelectionChanged() {
this.updateSelectionAppearance(); this.updateSelectionAppearance();
} }
/**
* Niszczy panel i czyści event listenery
*/
destroy() { destroy() {
if (this.container && this.container.parentNode) { if (this.container && this.container.parentNode) {
this.container.parentNode.removeChild(this.container); this.container.parentNode.removeChild(this.container);
@@ -670,7 +523,6 @@ export class CanvasLayersPanel {
this.layersContainer = null; this.layersContainer = null;
this.draggedElements = []; this.draggedElements = [];
this.removeDragInsertionLine(); this.removeDragInsertionLine();
log.info('CanvasLayersPanel destroyed'); log.info('CanvasLayersPanel destroyed');
} }
} }

View File

@@ -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 { api } from "../../scripts/api.js";
import { createModuleLogger } from "./utils/LoggerUtils.js"; import { createModuleLogger } from "./utils/LoggerUtils.js";
import { mask_editor_showing, mask_editor_listen_for_cancel } from "./utils/mask_utils.js"; import { mask_editor_showing, mask_editor_listen_for_cancel } from "./utils/mask_utils.js";
const log = createModuleLogger('CanvasMask'); const log = createModuleLogger('CanvasMask');
export class CanvasMask { export class CanvasMask {
constructor(canvas) { constructor(canvas) {
this.canvas = canvas; this.canvas = canvas;
this.node = canvas.node; this.node = canvas.node;
this.maskTool = canvas.maskTool; this.maskTool = canvas.maskTool;
this.savedMaskState = null; this.savedMaskState = null;
this.maskEditorCancelled = false; this.maskEditorCancelled = false;
this.pendingMask = null; this.pendingMask = null;
this.editorWasShowing = false; this.editorWasShowing = false;
} }
/** /**
* Uruchamia edytor masek * Uruchamia edytor masek
* @param {Image|HTMLCanvasElement|null} predefinedMask - Opcjonalna maska do nałożenia po otwarciu editora * @param {Image|HTMLCanvasElement|null} predefinedMask - Opcjonalna maska do nałożenia po otwarciu editora
@@ -28,126 +28,101 @@ export class CanvasMask {
sendCleanImage, sendCleanImage,
layersCount: this.canvas.layers.length layersCount: this.canvas.layers.length
}); });
this.savedMaskState = await this.saveMaskState(); this.savedMaskState = await this.saveMaskState();
this.maskEditorCancelled = false; this.maskEditorCancelled = false;
if (!predefinedMask && this.maskTool && this.maskTool.maskCanvas) { if (!predefinedMask && this.maskTool && this.maskTool.maskCanvas) {
try { try {
log.debug('Creating mask from current mask tool'); log.debug('Creating mask from current mask tool');
predefinedMask = await this.createMaskFromCurrentMask(); predefinedMask = await this.createMaskFromCurrentMask();
log.debug('Mask created from current mask tool successfully'); log.debug('Mask created from current mask tool successfully');
} catch (error) { }
catch (error) {
log.warn("Could not create mask from current mask:", error); log.warn("Could not create mask from current mask:", error);
} }
} }
this.pendingMask = predefinedMask; this.pendingMask = predefinedMask;
let blob; let blob;
if (sendCleanImage) { if (sendCleanImage) {
log.debug('Getting flattened canvas as blob (clean image)'); log.debug('Getting flattened canvas as blob (clean image)');
blob = await this.canvas.canvasLayers.getFlattenedCanvasAsBlob(); blob = await this.canvas.canvasLayers.getFlattenedCanvasAsBlob();
} else { }
else {
log.debug('Getting flattened canvas for mask editor (with mask)'); log.debug('Getting flattened canvas for mask editor (with mask)');
blob = await this.canvas.canvasLayers.getFlattenedCanvasForMaskEditor(); blob = await this.canvas.canvasLayers.getFlattenedCanvasForMaskEditor();
} }
if (!blob) { if (!blob) {
log.warn("Canvas is empty, cannot open mask editor."); log.warn("Canvas is empty, cannot open mask editor.");
return; return;
} }
log.debug('Canvas blob created successfully, size:', blob.size); log.debug('Canvas blob created successfully, size:', blob.size);
try { try {
const formData = new FormData(); const formData = new FormData();
const filename = `layerforge-mask-edit-${+new Date()}.png`; const filename = `layerforge-mask-edit-${+new Date()}.png`;
formData.append("image", blob, filename); formData.append("image", blob, filename);
formData.append("overwrite", "true"); formData.append("overwrite", "true");
formData.append("type", "temp"); formData.append("type", "temp");
log.debug('Uploading image to server:', filename); log.debug('Uploading image to server:', filename);
const response = await api.fetchApi("/upload/image", { const response = await api.fetchApi("/upload/image", {
method: "POST", method: "POST",
body: formData, body: formData,
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to upload image: ${response.statusText}`); throw new Error(`Failed to upload image: ${response.statusText}`);
} }
const data = await response.json(); const data = await response.json();
log.debug('Image uploaded successfully:', data); log.debug('Image uploaded successfully:', data);
const img = new Image(); const img = new Image();
img.src = api.apiURL(`/view?filename=${encodeURIComponent(data.name)}&type=${data.type}&subfolder=${data.subfolder}`); img.src = api.apiURL(`/view?filename=${encodeURIComponent(data.name)}&type=${data.type}&subfolder=${data.subfolder}`);
await new Promise((res, rej) => { await new Promise((res, rej) => {
img.onload = res; img.onload = res;
img.onerror = rej; img.onerror = rej;
}); });
this.node.imgs = [img]; this.node.imgs = [img];
log.info('Opening ComfyUI mask editor'); log.info('Opening ComfyUI mask editor');
ComfyApp.copyToClipspace(this.node); ComfyApp.copyToClipspace(this.node);
ComfyApp.clipspace_return_node = this.node; ComfyApp.clipspace_return_node = this.node;
ComfyApp.open_maskeditor(); ComfyApp.open_maskeditor();
this.editorWasShowing = false; this.editorWasShowing = false;
this.waitWhileMaskEditing(); this.waitWhileMaskEditing();
this.setupCancelListener(); this.setupCancelListener();
if (predefinedMask) { if (predefinedMask) {
log.debug('Will apply predefined mask when editor is ready'); log.debug('Will apply predefined mask when editor is ready');
this.waitForMaskEditorAndApplyMask(); this.waitForMaskEditorAndApplyMask();
} }
}
} catch (error) { catch (error) {
log.error("Error preparing image for mask editor:", error); log.error("Error preparing image for mask editor:", error);
alert(`Error: ${error.message}`); alert(`Error: ${error.message}`);
} }
} }
/** /**
* Czeka na otwarcie mask editora i automatycznie nakłada predefiniowaną maskę * Czeka na otwarcie mask editora i automatycznie nakłada predefiniowaną maskę
*/ */
waitForMaskEditorAndApplyMask() { waitForMaskEditorAndApplyMask() {
let attempts = 0; let attempts = 0;
const maxAttempts = 100; // Zwiększone do 10 sekund oczekiwania const maxAttempts = 100; // Zwiększone do 10 sekund oczekiwania
const checkEditor = () => { const checkEditor = () => {
attempts++; attempts++;
if (mask_editor_showing(app)) { if (mask_editor_showing(app)) {
const useNewEditor = app.ui.settings.getSettingValue('Comfy.MaskEditor.UseNewEditor'); const useNewEditor = app.ui.settings.getSettingValue('Comfy.MaskEditor.UseNewEditor');
let editorReady = false; let editorReady = false;
if (useNewEditor) { if (useNewEditor) {
const MaskEditorDialog = window.MaskEditorDialog; const MaskEditorDialog = window.MaskEditorDialog;
if (MaskEditorDialog && MaskEditorDialog.instance) { if (MaskEditorDialog && MaskEditorDialog.instance) {
try { try {
const messageBroker = MaskEditorDialog.instance.getMessageBroker(); const messageBroker = MaskEditorDialog.instance.getMessageBroker();
if (messageBroker) { if (messageBroker) {
editorReady = true; editorReady = true;
log.info("New mask editor detected as ready via MessageBroker"); log.info("New mask editor detected as ready via MessageBroker");
} }
} catch (e) { }
catch (e) {
editorReady = false; editorReady = false;
} }
} }
if (!editorReady) { if (!editorReady) {
const maskEditorElement = document.getElementById('maskEditor'); const maskEditorElement = document.getElementById('maskEditor');
if (maskEditorElement && maskEditorElement.style.display !== 'none') { if (maskEditorElement && maskEditorElement.style.display !== 'none') {
const canvas = maskEditorElement.querySelector('canvas'); const canvas = maskEditorElement.querySelector('canvas');
if (canvas) { if (canvas) {
editorReady = true; editorReady = true;
@@ -155,133 +130,119 @@ export class CanvasMask {
} }
} }
} }
} else { }
else {
const maskCanvas = document.getElementById('maskCanvas'); const maskCanvas = document.getElementById('maskCanvas');
editorReady = maskCanvas && maskCanvas.getContext && maskCanvas.width > 0; if (maskCanvas) {
if (editorReady) { editorReady = !!(maskCanvas.getContext('2d') && maskCanvas.width > 0 && maskCanvas.height > 0);
log.info("Old mask editor detected as ready"); if (editorReady) {
log.info("Old mask editor detected as ready");
}
} }
} }
if (editorReady) { if (editorReady) {
log.info("Applying mask to editor after", attempts * 100, "ms wait"); log.info("Applying mask to editor after", attempts * 100, "ms wait");
setTimeout(() => { setTimeout(() => {
this.applyMaskToEditor(this.pendingMask); this.applyMaskToEditor(this.pendingMask);
this.pendingMask = null; this.pendingMask = null;
}, 300); }, 300);
} else if (attempts < maxAttempts) { }
else if (attempts < maxAttempts) {
if (attempts % 10 === 0) { if (attempts % 10 === 0) {
log.info("Waiting for mask editor to be ready... attempt", attempts, "/", maxAttempts); log.info("Waiting for mask editor to be ready... attempt", attempts, "/", maxAttempts);
} }
setTimeout(checkEditor, 100); setTimeout(checkEditor, 100);
} else { }
else {
log.warn("Mask editor timeout - editor not ready after", maxAttempts * 100, "ms"); log.warn("Mask editor timeout - editor not ready after", maxAttempts * 100, "ms");
log.info("Attempting to apply mask anyway..."); log.info("Attempting to apply mask anyway...");
setTimeout(() => { setTimeout(() => {
this.applyMaskToEditor(this.pendingMask); this.applyMaskToEditor(this.pendingMask);
this.pendingMask = null; this.pendingMask = null;
}, 100); }, 100);
} }
} else if (attempts < maxAttempts) { }
else if (attempts < maxAttempts) {
setTimeout(checkEditor, 100); setTimeout(checkEditor, 100);
} else { }
else {
log.warn("Mask editor timeout - editor not showing after", maxAttempts * 100, "ms"); log.warn("Mask editor timeout - editor not showing after", maxAttempts * 100, "ms");
this.pendingMask = null; this.pendingMask = null;
} }
}; };
checkEditor(); checkEditor();
} }
/** /**
* Nakłada maskę na otwarty mask editor * Nakłada maskę na otwarty mask editor
* @param {Image|HTMLCanvasElement} maskData - Dane maski do nałożenia * @param {Image|HTMLCanvasElement} maskData - Dane maski do nałożenia
*/ */
async applyMaskToEditor(maskData) { async applyMaskToEditor(maskData) {
try { try {
const useNewEditor = app.ui.settings.getSettingValue('Comfy.MaskEditor.UseNewEditor'); const useNewEditor = app.ui.settings.getSettingValue('Comfy.MaskEditor.UseNewEditor');
if (useNewEditor) { if (useNewEditor) {
const MaskEditorDialog = window.MaskEditorDialog; const MaskEditorDialog = window.MaskEditorDialog;
if (MaskEditorDialog && MaskEditorDialog.instance) { if (MaskEditorDialog && MaskEditorDialog.instance) {
await this.applyMaskToNewEditor(maskData); await this.applyMaskToNewEditor(maskData);
} else { }
else {
log.warn("New editor setting enabled but instance not found, trying old editor"); log.warn("New editor setting enabled but instance not found, trying old editor");
await this.applyMaskToOldEditor(maskData); await this.applyMaskToOldEditor(maskData);
} }
} else { }
else {
await this.applyMaskToOldEditor(maskData); await this.applyMaskToOldEditor(maskData);
} }
log.info("Predefined mask applied to mask editor successfully"); log.info("Predefined mask applied to mask editor successfully");
} catch (error) { }
catch (error) {
log.error("Failed to apply predefined mask to editor:", error); log.error("Failed to apply predefined mask to editor:", error);
try { try {
log.info("Trying alternative mask application method..."); log.info("Trying alternative mask application method...");
await this.applyMaskToOldEditor(maskData); await this.applyMaskToOldEditor(maskData);
log.info("Alternative method succeeded"); log.info("Alternative method succeeded");
} catch (fallbackError) { }
catch (fallbackError) {
log.error("Alternative method also failed:", fallbackError); log.error("Alternative method also failed:", fallbackError);
} }
} }
} }
/** /**
* Nakłada maskę na nowy mask editor (przez MessageBroker) * Nakłada maskę na nowy mask editor (przez MessageBroker)
* @param {Image|HTMLCanvasElement} maskData - Dane maski * @param {Image|HTMLCanvasElement} maskData - Dane maski
*/ */
async applyMaskToNewEditor(maskData) { async applyMaskToNewEditor(maskData) {
const MaskEditorDialog = window.MaskEditorDialog; const MaskEditorDialog = window.MaskEditorDialog;
if (!MaskEditorDialog || !MaskEditorDialog.instance) { if (!MaskEditorDialog || !MaskEditorDialog.instance) {
throw new Error("New mask editor instance not found"); throw new Error("New mask editor instance not found");
} }
const editor = MaskEditorDialog.instance; const editor = MaskEditorDialog.instance;
const messageBroker = editor.getMessageBroker(); const messageBroker = editor.getMessageBroker();
const maskCanvas = await messageBroker.pull('maskCanvas'); const maskCanvas = await messageBroker.pull('maskCanvas');
const maskCtx = await messageBroker.pull('maskCtx'); const maskCtx = await messageBroker.pull('maskCtx');
const maskColor = await messageBroker.pull('getMaskColor'); const maskColor = await messageBroker.pull('getMaskColor');
const processedMask = await this.processMaskForEditor(maskData, maskCanvas.width, maskCanvas.height, maskColor); const processedMask = await this.processMaskForEditor(maskData, maskCanvas.width, maskCanvas.height, maskColor);
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height); maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
maskCtx.drawImage(processedMask, 0, 0); maskCtx.drawImage(processedMask, 0, 0);
messageBroker.publish('saveState'); messageBroker.publish('saveState');
} }
/** /**
* Nakłada maskę na stary mask editor * Nakłada maskę na stary mask editor
* @param {Image|HTMLCanvasElement} maskData - Dane maski * @param {Image|HTMLCanvasElement} maskData - Dane maski
*/ */
async applyMaskToOldEditor(maskData) { async applyMaskToOldEditor(maskData) {
const maskCanvas = document.getElementById('maskCanvas'); const maskCanvas = document.getElementById('maskCanvas');
if (!maskCanvas) { if (!maskCanvas) {
throw new Error("Old mask editor canvas not found"); throw new Error("Old mask editor canvas not found");
} }
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
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 maskColor = { r: 255, g: 255, b: 255 };
const processedMask = await this.processMaskForEditor(maskData, maskCanvas.width, maskCanvas.height, maskColor); const processedMask = await this.processMaskForEditor(maskData, maskCanvas.width, maskCanvas.height, maskColor);
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height); maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
maskCtx.drawImage(processedMask, 0, 0); maskCtx.drawImage(processedMask, 0, 0);
} }
/** /**
* Przetwarza maskę do odpowiedniego formatu dla editora * Przetwarza maskę do odpowiedniego formatu dla editora
* @param {Image|HTMLCanvasElement} maskData - Oryginalne dane maski * @param {Image|HTMLCanvasElement} maskData - Oryginalne dane maski
@@ -289,61 +250,54 @@ export class CanvasMask {
* @param {number} targetHeight - Docelowa wysokość * @param {number} targetHeight - Docelowa wysokość
* @param {Object} maskColor - Kolor maski {r, g, b} * @param {Object} maskColor - Kolor maski {r, g, b}
* @returns {HTMLCanvasElement} Przetworzona maska * @returns {HTMLCanvasElement} Przetworzona maska
*/async processMaskForEditor(maskData, targetWidth, targetHeight, maskColor) { */ async processMaskForEditor(maskData, targetWidth, targetHeight, maskColor) {
// Współrzędne przesunięcia (pan) widoku edytora // Współrzędne przesunięcia (pan) widoku edytora
const panX = this.maskTool.x; const panX = this.maskTool.x;
const panY = this.maskTool.y; const panY = this.maskTool.y;
log.info("Processing mask for editor:", { log.info("Processing mask for editor:", {
sourceSize: {width: maskData.width, height: maskData.height}, sourceSize: { width: maskData.width, height: maskData.height },
targetSize: {width: targetWidth, height: targetHeight}, targetSize: { width: targetWidth, height: targetHeight },
viewportPan: {x: panX, y: panY} viewportPan: { x: panX, y: panY }
}); });
const tempCanvas = document.createElement('canvas'); const tempCanvas = document.createElement('canvas');
tempCanvas.width = targetWidth; tempCanvas.width = targetWidth;
tempCanvas.height = targetHeight; tempCanvas.height = targetHeight;
const tempCtx = tempCanvas.getContext('2d', {willReadFrequently: true}); const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
const sourceX = -panX; const sourceX = -panX;
const sourceY = -panY; const sourceY = -panY;
if (tempCtx) {
tempCtx.drawImage( tempCtx.drawImage(maskData, // Źródło: pełna maska z "output area"
maskData, // Źródło: pełna maska z "output area" sourceX, // sx: Prawdziwa współrzędna X na dużej masce (np. 1000)
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)
sourceY, // sy: Prawdziwa współrzędna Y na dużej masce (np. 1000) targetWidth, // sWidth: Szerokość wycinanego fragmentu
targetWidth, // sWidth: Szerokość wycinanego fragmentu targetHeight, // sHeight: Wysokość wycinanego fragmentu
targetHeight, // sHeight: Wysokość wycinanego fragmentu 0, // dx: Gdzie wkleić w płótnie docelowym (zawsze 0)
0, // dx: Gdzie wkleić w płótnie docelowym (zawsze 0) 0, // dy: Gdzie wkleić w płótnie docelowym (zawsze 0)
0, // dy: Gdzie wkleić w płótnie docelowym (zawsze 0) targetWidth, // dWidth: Szerokość wklejanego obrazu
targetWidth, // dWidth: Szerokość wklejanego obrazu targetHeight // dHeight: Wysokość wklejanego obrazu
targetHeight // dHeight: Wysokość wklejanego obrazu );
); }
log.info("Mask viewport cropped correctly.", { log.info("Mask viewport cropped correctly.", {
source: "maskData", 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 // Reszta kodu (zmiana koloru) pozostaje bez zmian
const imageData = tempCtx.getImageData(0, 0, targetWidth, targetHeight); if (tempCtx) {
const data = imageData.data; const imageData = tempCtx.getImageData(0, 0, targetWidth, targetHeight);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) { for (let i = 0; i < data.length; i += 4) {
const alpha = data[i + 3]; const alpha = data[i + 3];
if (alpha > 0) { if (alpha > 0) {
data[i] = maskColor.r; data[i] = maskColor.r;
data[i + 1] = maskColor.g; data[i + 1] = maskColor.g;
data[i + 2] = maskColor.b; data[i + 2] = maskColor.b;
}
} }
tempCtx.putImageData(imageData, 0, 0);
} }
tempCtx.putImageData(imageData, 0, 0);
log.info("Mask processing completed - color applied."); log.info("Mask processing completed - color applied.");
return tempCanvas; return tempCanvas;
} }
/** /**
* Tworzy obiekt Image z obecnej maski canvas * Tworzy obiekt Image z obecnej maski canvas
* @returns {Promise<Image>} Promise zwracający obiekt Image z maską * @returns {Promise<Image>} Promise zwracający obiekt Image z maską
@@ -352,7 +306,6 @@ export class CanvasMask {
if (!this.maskTool || !this.maskTool.maskCanvas) { if (!this.maskTool || !this.maskTool.maskCanvas) {
throw new Error("No mask canvas available"); throw new Error("No mask canvas available");
} }
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const maskImage = new Image(); const maskImage = new Image();
maskImage.onload = () => resolve(maskImage); maskImage.onload = () => resolve(maskImage);
@@ -360,20 +313,18 @@ export class CanvasMask {
maskImage.src = this.maskTool.maskCanvas.toDataURL(); maskImage.src = this.maskTool.maskCanvas.toDataURL();
}); });
} }
waitWhileMaskEditing() { waitWhileMaskEditing() {
if (mask_editor_showing(app)) { if (mask_editor_showing(app)) {
this.editorWasShowing = true; this.editorWasShowing = true;
} }
if (!mask_editor_showing(app) && this.editorWasShowing) { if (!mask_editor_showing(app) && this.editorWasShowing) {
this.editorWasShowing = false; this.editorWasShowing = false;
setTimeout(() => this.handleMaskEditorClose(), 100); setTimeout(() => this.handleMaskEditorClose(), 100);
} else { }
else {
setTimeout(this.waitWhileMaskEditing.bind(this), 100); setTimeout(this.waitWhileMaskEditing.bind(this), 100);
} }
} }
/** /**
* Zapisuje obecny stan maski przed otwarciem editora * Zapisuje obecny stan maski przed otwarciem editora
* @returns {Object} Zapisany stan maski * @returns {Object} Zapisany stan maski
@@ -382,14 +333,14 @@ export class CanvasMask {
if (!this.maskTool || !this.maskTool.maskCanvas) { if (!this.maskTool || !this.maskTool.maskCanvas) {
return null; return null;
} }
const maskCanvas = this.maskTool.maskCanvas; const maskCanvas = this.maskTool.maskCanvas;
const savedCanvas = document.createElement('canvas'); const savedCanvas = document.createElement('canvas');
savedCanvas.width = maskCanvas.width; savedCanvas.width = maskCanvas.width;
savedCanvas.height = maskCanvas.height; savedCanvas.height = maskCanvas.height;
const savedCtx = savedCanvas.getContext('2d', {willReadFrequently: true}); const savedCtx = savedCanvas.getContext('2d', { willReadFrequently: true });
savedCtx.drawImage(maskCanvas, 0, 0); if (savedCtx) {
savedCtx.drawImage(maskCanvas, 0, 0);
}
return { return {
maskData: savedCanvas, maskData: savedCanvas,
maskPosition: { maskPosition: {
@@ -398,7 +349,6 @@ export class CanvasMask {
} }
}; };
} }
/** /**
* Przywraca zapisany stan maski * Przywraca zapisany stan maski
* @param {Object} savedState - Zapisany stan maski * @param {Object} savedState - Zapisany stan maski
@@ -407,22 +357,18 @@ export class CanvasMask {
if (!savedState || !this.maskTool) { if (!savedState || !this.maskTool) {
return; return;
} }
if (savedState.maskData) { if (savedState.maskData) {
const maskCtx = this.maskTool.maskCtx; const maskCtx = this.maskTool.maskCtx;
maskCtx.clearRect(0, 0, this.maskTool.maskCanvas.width, this.maskTool.maskCanvas.height); maskCtx.clearRect(0, 0, this.maskTool.maskCanvas.width, this.maskTool.maskCanvas.height);
maskCtx.drawImage(savedState.maskData, 0, 0); maskCtx.drawImage(savedState.maskData, 0, 0);
} }
if (savedState.maskPosition) { if (savedState.maskPosition) {
this.maskTool.x = savedState.maskPosition.x; this.maskTool.x = savedState.maskPosition.x;
this.maskTool.y = savedState.maskPosition.y; this.maskTool.y = savedState.maskPosition.y;
} }
this.canvas.render(); this.canvas.render();
log.info("Mask state restored after cancel"); log.info("Mask state restored after cancel");
} }
/** /**
* Konfiguruje nasłuchiwanie na przycisk Cancel w mask editorze * Konfiguruje nasłuchiwanie na przycisk Cancel w mask editorze
*/ */
@@ -432,110 +378,89 @@ export class CanvasMask {
this.maskEditorCancelled = true; this.maskEditorCancelled = true;
}); });
} }
/** /**
* Sprawdza czy mask editor został anulowany i obsługuje to odpowiednio * Sprawdza czy mask editor został anulowany i obsługuje to odpowiednio
*/ */
async handleMaskEditorClose() { async handleMaskEditorClose() {
log.info("Handling mask editor close"); log.info("Handling mask editor close");
log.debug("Node object after mask editor close:", this.node); log.debug("Node object after mask editor close:", this.node);
if (this.maskEditorCancelled) { if (this.maskEditorCancelled) {
log.info("Mask editor was cancelled - restoring original mask state"); log.info("Mask editor was cancelled - restoring original mask state");
if (this.savedMaskState) { if (this.savedMaskState) {
await this.restoreMaskState(this.savedMaskState); await this.restoreMaskState(this.savedMaskState);
} }
this.maskEditorCancelled = false; this.maskEditorCancelled = false;
this.savedMaskState = null; this.savedMaskState = null;
return; 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."); log.warn("Mask editor was closed without a result.");
return; return;
} }
log.debug("Processing mask editor result, image source:", this.node.imgs[0].src.substring(0, 100) + '...'); log.debug("Processing mask editor result, image source:", this.node.imgs[0].src.substring(0, 100) + '...');
const resultImage = new Image(); const resultImage = new Image();
resultImage.src = this.node.imgs[0].src; resultImage.src = this.node.imgs[0].src;
try { try {
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
resultImage.onload = resolve; resultImage.onload = resolve;
resultImage.onerror = reject; resultImage.onerror = reject;
}); });
log.debug("Result image loaded successfully", { log.debug("Result image loaded successfully", {
width: resultImage.width, width: resultImage.width,
height: resultImage.height height: resultImage.height
}); });
} catch (error) { }
catch (error) {
log.error("Failed to load image from mask editor.", error); log.error("Failed to load image from mask editor.", error);
this.node.imgs = []; this.node.imgs = [];
return; return;
} }
log.debug("Creating temporary canvas for mask processing"); log.debug("Creating temporary canvas for mask processing");
const tempCanvas = document.createElement('canvas'); const tempCanvas = document.createElement('canvas');
tempCanvas.width = this.canvas.width; tempCanvas.width = this.canvas.width;
tempCanvas.height = this.canvas.height; tempCanvas.height = this.canvas.height;
const tempCtx = tempCanvas.getContext('2d', {willReadFrequently: true}); const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
if (tempCtx) {
tempCtx.drawImage(resultImage, 0, 0, this.canvas.width, this.canvas.height); tempCtx.drawImage(resultImage, 0, 0, this.canvas.width, this.canvas.height);
log.debug("Processing image data to create mask");
log.debug("Processing image data to create mask"); const imageData = tempCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
const imageData = tempCtx.getImageData(0, 0, this.canvas.width, this.canvas.height); const data = imageData.data;
const data = imageData.data; for (let i = 0; i < data.length; i += 4) {
const originalAlpha = data[i + 3];
for (let i = 0; i < data.length; i += 4) { data[i] = 255;
const originalAlpha = data[i + 3]; data[i + 1] = 255;
data[i] = 255; data[i + 2] = 255;
data[i + 1] = 255; data[i + 3] = 255 - originalAlpha;
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"); log.debug("Converting processed mask to image");
const maskAsImage = new Image(); const maskAsImage = new Image();
maskAsImage.src = tempCanvas.toDataURL(); maskAsImage.src = tempCanvas.toDataURL();
await new Promise(resolve => maskAsImage.onload = resolve); await new Promise(resolve => maskAsImage.onload = resolve);
const maskCtx = this.maskTool.maskCtx; const maskCtx = this.maskTool.maskCtx;
const destX = -this.maskTool.x; const destX = -this.maskTool.x;
const destY = -this.maskTool.y; 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.globalCompositeOperation = 'source-over';
maskCtx.clearRect(destX, destY, this.canvas.width, this.canvas.height); maskCtx.clearRect(destX, destY, this.canvas.width, this.canvas.height);
maskCtx.drawImage(maskAsImage, destX, destY); maskCtx.drawImage(maskAsImage, destX, destY);
this.canvas.render(); this.canvas.render();
this.canvas.saveState(); this.canvas.saveState();
log.debug("Creating new preview image"); log.debug("Creating new preview image");
const new_preview = new Image(); const new_preview = new Image();
const blob = await this.canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob(); const blob = await this.canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
if (blob) { if (blob) {
new_preview.src = URL.createObjectURL(blob); new_preview.src = URL.createObjectURL(blob);
await new Promise(r => new_preview.onload = r); await new Promise(r => new_preview.onload = r);
this.node.imgs = [new_preview]; this.node.imgs = [new_preview];
log.debug("New preview image created successfully"); log.debug("New preview image created successfully");
} else { }
else {
this.node.imgs = []; this.node.imgs = [];
log.warn("Failed to create preview blob"); log.warn("Failed to create preview blob");
} }
this.canvas.render(); this.canvas.render();
this.savedMaskState = null; this.savedMaskState = null;
log.info("Mask editor result processed successfully"); log.info("Mask editor result processed successfully");
} }

View File

@@ -1,7 +1,5 @@
import {createModuleLogger} from "./utils/LoggerUtils.js"; import { createModuleLogger } from "./utils/LoggerUtils.js";
const log = createModuleLogger('CanvasRenderer'); const log = createModuleLogger('CanvasRenderer');
export class CanvasRenderer { export class CanvasRenderer {
constructor(canvas) { constructor(canvas) {
this.canvas = canvas; this.canvas = canvas;
@@ -10,7 +8,6 @@ export class CanvasRenderer {
this.renderInterval = 1000 / 60; this.renderInterval = 1000 / 60;
this.isDirty = false; this.isDirty = false;
} }
render() { render() {
if (this.renderAnimationFrame) { if (this.renderAnimationFrame) {
this.isDirty = true; this.isDirty = true;
@@ -23,16 +20,15 @@ export class CanvasRenderer {
this.actualRender(); this.actualRender();
this.isDirty = false; this.isDirty = false;
} }
if (this.isDirty) { if (this.isDirty) {
this.renderAnimationFrame = null; this.renderAnimationFrame = null;
this.render(); this.render();
} else { }
else {
this.renderAnimationFrame = null; this.renderAnimationFrame = null;
} }
}); });
} }
actualRender() { actualRender() {
if (this.canvas.offscreenCanvas.width !== this.canvas.canvas.clientWidth || if (this.canvas.offscreenCanvas.width !== this.canvas.canvas.clientWidth ||
this.canvas.offscreenCanvas.height !== this.canvas.canvas.clientHeight) { this.canvas.offscreenCanvas.height !== this.canvas.canvas.clientHeight) {
@@ -41,21 +37,17 @@ export class CanvasRenderer {
this.canvas.offscreenCanvas.width = newWidth; this.canvas.offscreenCanvas.width = newWidth;
this.canvas.offscreenCanvas.height = newHeight; this.canvas.offscreenCanvas.height = newHeight;
} }
const ctx = this.canvas.offscreenCtx; const ctx = this.canvas.offscreenCtx;
ctx.fillStyle = '#606060'; ctx.fillStyle = '#606060';
ctx.fillRect(0, 0, this.canvas.offscreenCanvas.width, this.canvas.offscreenCanvas.height); ctx.fillRect(0, 0, this.canvas.offscreenCanvas.width, this.canvas.offscreenCanvas.height);
ctx.save(); ctx.save();
ctx.scale(this.canvas.viewport.zoom, this.canvas.viewport.zoom); ctx.scale(this.canvas.viewport.zoom, this.canvas.viewport.zoom);
ctx.translate(-this.canvas.viewport.x, -this.canvas.viewport.y); ctx.translate(-this.canvas.viewport.x, -this.canvas.viewport.y);
this.drawGrid(ctx); this.drawGrid(ctx);
const sortedLayers = [...this.canvas.layers].sort((a, b) => a.zIndex - b.zIndex); const sortedLayers = [...this.canvas.layers].sort((a, b) => a.zIndex - b.zIndex);
sortedLayers.forEach(layer => { sortedLayers.forEach(layer => {
if (!layer.image) return; if (!layer.image)
return;
ctx.save(); ctx.save();
const currentTransform = ctx.getTransform(); const currentTransform = ctx.getTransform();
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.setTransform(1, 0, 0, 1, 0, 0);
@@ -68,11 +60,7 @@ export class CanvasRenderer {
ctx.rotate(layer.rotation * Math.PI / 180); ctx.rotate(layer.rotation * Math.PI / 180);
ctx.imageSmoothingEnabled = true; ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high'; ctx.imageSmoothingQuality = 'high';
ctx.drawImage( ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
layer.image, -layer.width / 2, -layer.height / 2,
layer.width,
layer.height
);
if (layer.mask) { if (layer.mask) {
} }
if (this.canvas.canvasSelection.selectedLayers.includes(layer)) { if (this.canvas.canvasSelection.selectedLayers.includes(layer)) {
@@ -80,51 +68,41 @@ export class CanvasRenderer {
} }
ctx.restore(); ctx.restore();
}); });
this.drawCanvasOutline(ctx); this.drawCanvasOutline(ctx);
this.drawPendingGenerationAreas(ctx); // Draw snapshot outlines this.drawPendingGenerationAreas(ctx); // Draw snapshot outlines
const maskImage = this.canvas.maskTool.getMask(); const maskImage = this.canvas.maskTool.getMask();
if (maskImage && this.canvas.maskTool.isOverlayVisible) { if (maskImage && this.canvas.maskTool.isOverlayVisible) {
ctx.save(); ctx.save();
if (this.canvas.maskTool.isActive) { if (this.canvas.maskTool.isActive) {
ctx.globalCompositeOperation = 'source-over'; ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 0.5; ctx.globalAlpha = 0.5;
} else { }
else {
ctx.globalCompositeOperation = 'source-over'; ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1.0; ctx.globalAlpha = 1.0;
} }
ctx.drawImage(maskImage, this.canvas.maskTool.x, this.canvas.maskTool.y); ctx.drawImage(maskImage, this.canvas.maskTool.x, this.canvas.maskTool.y);
ctx.globalAlpha = 1.0; ctx.globalAlpha = 1.0;
ctx.restore(); ctx.restore();
} }
this.renderInteractionElements(ctx); this.renderInteractionElements(ctx);
this.renderLayerInfo(ctx); this.renderLayerInfo(ctx);
ctx.restore(); ctx.restore();
if (this.canvas.canvas.width !== this.canvas.offscreenCanvas.width || if (this.canvas.canvas.width !== this.canvas.offscreenCanvas.width ||
this.canvas.canvas.height !== this.canvas.offscreenCanvas.height) { this.canvas.canvas.height !== this.canvas.offscreenCanvas.height) {
this.canvas.canvas.width = this.canvas.offscreenCanvas.width; this.canvas.canvas.width = this.canvas.offscreenCanvas.width;
this.canvas.canvas.height = this.canvas.offscreenCanvas.height; this.canvas.canvas.height = this.canvas.offscreenCanvas.height;
} }
this.canvas.ctx.drawImage(this.canvas.offscreenCanvas, 0, 0); this.canvas.ctx.drawImage(this.canvas.offscreenCanvas, 0, 0);
// Update Batch Preview UI positions // Update Batch Preview UI positions
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) { if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
this.canvas.batchPreviewManagers.forEach(manager => { this.canvas.batchPreviewManagers.forEach((manager) => {
manager.updateScreenPosition(this.canvas.viewport); manager.updateScreenPosition(this.canvas.viewport);
}); });
} }
} }
renderInteractionElements(ctx) { renderInteractionElements(ctx) {
const interaction = this.canvas.interaction; const interaction = this.canvas.interaction;
if (interaction.mode === 'resizingCanvas' && interaction.canvasResizeRect) { if (interaction.mode === 'resizingCanvas' && interaction.canvasResizeRect) {
const rect = interaction.canvasResizeRect; const rect = interaction.canvasResizeRect;
ctx.save(); ctx.save();
@@ -138,7 +116,6 @@ export class CanvasRenderer {
const text = `${Math.round(rect.width)}x${Math.round(rect.height)}`; const text = `${Math.round(rect.width)}x${Math.round(rect.height)}`;
const textWorldX = rect.x + rect.width / 2; const textWorldX = rect.x + rect.width / 2;
const textWorldY = rect.y + rect.height + (20 / this.canvas.viewport.zoom); const textWorldY = rect.y + rect.height + (20 / this.canvas.viewport.zoom);
ctx.save(); ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.setTransform(1, 0, 0, 1, 0, 0);
const screenX = (textWorldX - this.canvas.viewport.x) * this.canvas.viewport.zoom; const screenX = (textWorldX - this.canvas.viewport.x) * this.canvas.viewport.zoom;
@@ -156,7 +133,6 @@ export class CanvasRenderer {
ctx.restore(); ctx.restore();
} }
} }
if (interaction.mode === 'movingCanvas' && interaction.canvasMoveRect) { if (interaction.mode === 'movingCanvas' && interaction.canvasMoveRect) {
const rect = interaction.canvasMoveRect; const rect = interaction.canvasMoveRect;
ctx.save(); ctx.save();
@@ -166,11 +142,9 @@ export class CanvasRenderer {
ctx.strokeRect(rect.x, rect.y, rect.width, rect.height); ctx.strokeRect(rect.x, rect.y, rect.width, rect.height);
ctx.setLineDash([]); ctx.setLineDash([]);
ctx.restore(); ctx.restore();
const text = `(${Math.round(rect.x)}, ${Math.round(rect.y)})`; const text = `(${Math.round(rect.x)}, ${Math.round(rect.y)})`;
const textWorldX = rect.x + rect.width / 2; const textWorldX = rect.x + rect.width / 2;
const textWorldY = rect.y - (20 / this.canvas.viewport.zoom); const textWorldY = rect.y - (20 / this.canvas.viewport.zoom);
ctx.save(); ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.setTransform(1, 0, 0, 1, 0, 0);
const screenX = (textWorldX - this.canvas.viewport.x) * this.canvas.viewport.zoom; const screenX = (textWorldX - this.canvas.viewport.x) * this.canvas.viewport.zoom;
@@ -188,12 +162,11 @@ export class CanvasRenderer {
ctx.restore(); ctx.restore();
} }
} }
renderLayerInfo(ctx) { renderLayerInfo(ctx) {
if (this.canvas.canvasSelection.selectedLayer) { if (this.canvas.canvasSelection.selectedLayer) {
this.canvas.canvasSelection.selectedLayers.forEach(layer => { this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
if (!layer.image) return; if (!layer.image)
return;
const layerIndex = this.canvas.layers.indexOf(layer); const layerIndex = this.canvas.layers.indexOf(layer);
const currentWidth = Math.round(layer.width); const currentWidth = Math.round(layer.width);
const currentHeight = Math.round(layer.height); const currentHeight = Math.round(layer.height);
@@ -207,15 +180,13 @@ export class CanvasRenderer {
const rad = layer.rotation * Math.PI / 180; const rad = layer.rotation * Math.PI / 180;
const cos = Math.cos(rad); const cos = Math.cos(rad);
const sin = Math.sin(rad); const sin = Math.sin(rad);
const halfW = layer.width / 2; const halfW = layer.width / 2;
const halfH = layer.height / 2; const halfH = layer.height / 2;
const localCorners = [ 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 => ({ const worldCorners = localCorners.map(p => ({
x: centerX + p.x * cos - p.y * sin, x: centerX + p.x * cos - p.y * sin,
@@ -232,10 +203,8 @@ export class CanvasRenderer {
const textWorldY = maxY + padding; const textWorldY = maxY + padding;
ctx.save(); ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.setTransform(1, 0, 0, 1, 0, 0);
const screenX = (textWorldX - this.canvas.viewport.x) * this.canvas.viewport.zoom; const screenX = (textWorldX - this.canvas.viewport.x) * this.canvas.viewport.zoom;
const screenY = (textWorldY - this.canvas.viewport.y) * this.canvas.viewport.zoom; const screenY = (textWorldY - this.canvas.viewport.y) * this.canvas.viewport.zoom;
ctx.font = "14px sans-serif"; ctx.font = "14px sans-serif";
ctx.textAlign = "center"; ctx.textAlign = "center";
ctx.textBaseline = "middle"; ctx.textBaseline = "middle";
@@ -244,59 +213,46 @@ export class CanvasRenderer {
const textBgWidth = Math.max(...textMetrics.map(m => m.width)) + 10; const textBgWidth = Math.max(...textMetrics.map(m => m.width)) + 10;
const lineHeight = 18; const lineHeight = 18;
const textBgHeight = lines.length * lineHeight + 4; const textBgHeight = lines.length * lineHeight + 4;
ctx.fillStyle = "rgba(0, 0, 0, 0.7)"; ctx.fillStyle = "rgba(0, 0, 0, 0.7)";
ctx.fillRect(screenX - textBgWidth / 2, screenY - textBgHeight / 2, textBgWidth, textBgHeight); ctx.fillRect(screenX - textBgWidth / 2, screenY - textBgHeight / 2, textBgWidth, textBgHeight);
ctx.fillStyle = "white"; ctx.fillStyle = "white";
lines.forEach((line, index) => { lines.forEach((line, index) => {
const yPos = screenY - (textBgHeight / 2) + (lineHeight / 2) + (index * lineHeight) + 2; const yPos = screenY - (textBgHeight / 2) + (lineHeight / 2) + (index * lineHeight) + 2;
ctx.fillText(line, screenX, yPos); ctx.fillText(line, screenX, yPos);
}); });
ctx.restore(); ctx.restore();
}); });
} }
} }
drawGrid(ctx) { drawGrid(ctx) {
const gridSize = 64; const gridSize = 64;
const lineWidth = 0.5 / this.canvas.viewport.zoom; const lineWidth = 0.5 / this.canvas.viewport.zoom;
const viewLeft = this.canvas.viewport.x; const viewLeft = this.canvas.viewport.x;
const viewTop = this.canvas.viewport.y; const viewTop = this.canvas.viewport.y;
const viewRight = this.canvas.viewport.x + this.canvas.offscreenCanvas.width / this.canvas.viewport.zoom; 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; const viewBottom = this.canvas.viewport.y + this.canvas.offscreenCanvas.height / this.canvas.viewport.zoom;
ctx.beginPath(); ctx.beginPath();
ctx.strokeStyle = '#707070'; ctx.strokeStyle = '#707070';
ctx.lineWidth = lineWidth; ctx.lineWidth = lineWidth;
for (let x = Math.floor(viewLeft / gridSize) * gridSize; x < viewRight; x += gridSize) { for (let x = Math.floor(viewLeft / gridSize) * gridSize; x < viewRight; x += gridSize) {
ctx.moveTo(x, viewTop); ctx.moveTo(x, viewTop);
ctx.lineTo(x, viewBottom); ctx.lineTo(x, viewBottom);
} }
for (let y = Math.floor(viewTop / gridSize) * gridSize; y < viewBottom; y += gridSize) { for (let y = Math.floor(viewTop / gridSize) * gridSize; y < viewBottom; y += gridSize) {
ctx.moveTo(viewLeft, y); ctx.moveTo(viewLeft, y);
ctx.lineTo(viewRight, y); ctx.lineTo(viewRight, y);
} }
ctx.stroke(); ctx.stroke();
} }
drawCanvasOutline(ctx) { drawCanvasOutline(ctx) {
ctx.beginPath(); ctx.beginPath();
ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)'; ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
ctx.lineWidth = 2 / this.canvas.viewport.zoom; ctx.lineWidth = 2 / this.canvas.viewport.zoom;
ctx.setLineDash([10 / this.canvas.viewport.zoom, 5 / 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.rect(0, 0, this.canvas.width, this.canvas.height);
ctx.stroke(); ctx.stroke();
ctx.setLineDash([]); ctx.setLineDash([]);
} }
drawSelectionFrame(ctx, layer) { drawSelectionFrame(ctx, layer) {
const lineWidth = 2 / this.canvas.viewport.zoom; const lineWidth = 2 / this.canvas.viewport.zoom;
const handleRadius = 5 / this.canvas.viewport.zoom; const handleRadius = 5 / this.canvas.viewport.zoom;
@@ -313,44 +269,36 @@ export class CanvasRenderer {
ctx.fillStyle = '#ffffff'; ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#000000'; ctx.strokeStyle = '#000000';
ctx.lineWidth = 1 / this.canvas.viewport.zoom; ctx.lineWidth = 1 / this.canvas.viewport.zoom;
for (const key in handles) { for (const key in handles) {
const point = handles[key]; const point = handles[key];
ctx.beginPath(); ctx.beginPath();
const localX = point.x - (layer.x + layer.width / 2); const localX = point.x - (layer.x + layer.width / 2);
const localY = point.y - (layer.y + layer.height / 2); const localY = point.y - (layer.y + layer.height / 2);
const rad = -layer.rotation * Math.PI / 180; const rad = -layer.rotation * Math.PI / 180;
const rotatedX = localX * Math.cos(rad) - localY * Math.sin(rad); const rotatedX = localX * Math.cos(rad) - localY * Math.sin(rad);
const rotatedY = localX * Math.sin(rad) + localY * Math.cos(rad); const rotatedY = localX * Math.sin(rad) + localY * Math.cos(rad);
ctx.arc(rotatedX, rotatedY, handleRadius, 0, Math.PI * 2); ctx.arc(rotatedX, rotatedY, handleRadius, 0, Math.PI * 2);
ctx.fill(); ctx.fill();
ctx.stroke(); ctx.stroke();
} }
} }
drawPendingGenerationAreas(ctx) { drawPendingGenerationAreas(ctx) {
const areasToDraw = []; const areasToDraw = [];
// 1. Get areas from active managers // 1. Get areas from active managers
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) { if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
this.canvas.batchPreviewManagers.forEach(manager => { this.canvas.batchPreviewManagers.forEach((manager) => {
if (manager.generationArea) { if (manager.generationArea) {
areasToDraw.push(manager.generationArea); areasToDraw.push(manager.generationArea);
} }
}); });
} }
// 2. Get the area from the pending context (if it exists) // 2. Get the area from the pending context (if it exists)
if (this.canvas.pendingBatchContext && this.canvas.pendingBatchContext.outputArea) { if (this.canvas.pendingBatchContext && this.canvas.pendingBatchContext.outputArea) {
areasToDraw.push(this.canvas.pendingBatchContext.outputArea); areasToDraw.push(this.canvas.pendingBatchContext.outputArea);
} }
if (areasToDraw.length === 0) { if (areasToDraw.length === 0) {
return; return;
} }
// 3. Draw all collected areas // 3. Draw all collected areas
areasToDraw.forEach(area => { areasToDraw.forEach(area => {
ctx.save(); ctx.save();

View File

@@ -1,7 +1,5 @@
import { createModuleLogger } from "./utils/LoggerUtils.js"; import { createModuleLogger } from "./utils/LoggerUtils.js";
const log = createModuleLogger('CanvasSelection'); const log = createModuleLogger('CanvasSelection');
export class CanvasSelection { export class CanvasSelection {
constructor(canvas) { constructor(canvas) {
this.canvas = canvas; this.canvas = canvas;
@@ -9,16 +7,14 @@ export class CanvasSelection {
this.selectedLayer = null; this.selectedLayer = null;
this.onSelectionChange = null; this.onSelectionChange = null;
} }
/** /**
* Duplikuje zaznaczone warstwy (w pamięci, bez zapisu stanu) * Duplikuje zaznaczone warstwy (w pamięci, bez zapisu stanu)
*/ */
duplicateSelectedLayers() { duplicateSelectedLayers() {
if (this.selectedLayers.length === 0) return []; if (this.selectedLayers.length === 0)
return [];
const newLayers = []; 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 => { sortedLayers.forEach(layer => {
const newLayer = { const newLayer = {
...layer, ...layer,
@@ -28,19 +24,15 @@ export class CanvasSelection {
this.canvas.layers.push(newLayer); this.canvas.layers.push(newLayer);
newLayers.push(newLayer); newLayers.push(newLayer);
}); });
// Aktualizuj zaznaczenie, co powiadomi panel (ale nie renderuje go całego) // Aktualizuj zaznaczenie, co powiadomi panel (ale nie renderuje go całego)
this.updateSelection(newLayers); this.updateSelection(newLayers);
// Powiadom panel o zmianie struktury, aby się przerysował // Powiadom panel o zmianie struktury, aby się przerysował
if (this.canvas.canvasLayersPanel) { if (this.canvas.canvasLayersPanel) {
this.canvas.canvasLayersPanel.onLayersChanged(); this.canvas.canvasLayersPanel.onLayersChanged();
} }
log.info(`Duplicated ${newLayers.length} layers (in-memory).`); log.info(`Duplicated ${newLayers.length} layers (in-memory).`);
return newLayers; return newLayers;
} }
/** /**
* Aktualizuje zaznaczenie warstw i powiadamia wszystkie komponenty. * Aktualizuje zaznaczenie warstw i powiadamia wszystkie komponenty.
* To jest "jedyne źródło prawdy" o zmianie zaznaczenia. * To jest "jedyne źródło prawdy" o zmianie zaznaczenia.
@@ -50,47 +42,38 @@ export class CanvasSelection {
const previousSelection = this.selectedLayers.length; const previousSelection = this.selectedLayers.length;
this.selectedLayers = newSelection || []; this.selectedLayers = newSelection || [];
this.selectedLayer = this.selectedLayers.length > 0 ? this.selectedLayers[this.selectedLayers.length - 1] : null; this.selectedLayer = this.selectedLayers.length > 0 ? this.selectedLayers[this.selectedLayers.length - 1] : null;
// Sprawdź, czy zaznaczenie faktycznie się zmieniło, aby uniknąć pętli // Sprawdź, czy zaznaczenie faktycznie się zmieniło, aby uniknąć pętli
const hasChanged = previousSelection !== this.selectedLayers.length || const hasChanged = previousSelection !== this.selectedLayers.length ||
this.selectedLayers.some((layer, i) => this.selectedLayers[i] !== (newSelection || [])[i]); this.selectedLayers.some((layer, i) => this.selectedLayers[i] !== (newSelection || [])[i]);
if (!hasChanged && previousSelection > 0) { if (!hasChanged && previousSelection > 0) {
// return; // Zablokowane na razie, może powodować problemy // return; // Zablokowane na razie, może powodować problemy
} }
log.debug('Selection updated', { log.debug('Selection updated', {
previousCount: previousSelection, previousCount: previousSelection,
newCount: this.selectedLayers.length, 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 // 1. Zrenderuj ponownie canvas, aby pokazać nowe kontrolki transformacji
this.canvas.render(); this.canvas.render();
// 2. Powiadom inne części aplikacji (jeśli są) // 2. Powiadom inne części aplikacji (jeśli są)
if (this.onSelectionChange) { if (this.onSelectionChange) {
this.onSelectionChange(); this.onSelectionChange();
} }
// 3. Powiadom panel warstw, aby zaktualizował swój wygląd // 3. Powiadom panel warstw, aby zaktualizował swój wygląd
if (this.canvas.canvasLayersPanel) { if (this.canvas.canvasLayersPanel) {
this.canvas.canvasLayersPanel.onSelectionChanged(); this.canvas.canvasLayersPanel.onSelectionChanged();
} }
} }
/** /**
* Logika aktualizacji zaznaczenia, wywoływana przez panel warstw. * Logika aktualizacji zaznaczenia, wywoływana przez panel warstw.
*/ */
updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index) { updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index) {
let newSelection = [...this.selectedLayers]; let newSelection = [...this.selectedLayers];
let selectionChanged = false; let selectionChanged = false;
if (isShiftPressed && this.canvas.canvasLayersPanel.lastSelectedIndex !== -1) { if (isShiftPressed && this.canvas.canvasLayersPanel.lastSelectedIndex !== -1) {
const sortedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex); const sortedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex);
const startIndex = Math.min(this.canvas.canvasLayersPanel.lastSelectedIndex, index); const startIndex = Math.min(this.canvas.canvasLayersPanel.lastSelectedIndex, index);
const endIndex = Math.max(this.canvas.canvasLayersPanel.lastSelectedIndex, index); const endIndex = Math.max(this.canvas.canvasLayersPanel.lastSelectedIndex, index);
newSelection = []; newSelection = [];
for (let i = startIndex; i <= endIndex; i++) { for (let i = startIndex; i <= endIndex; i++) {
if (sortedLayers[i]) { if (sortedLayers[i]) {
@@ -98,16 +81,19 @@ export class CanvasSelection {
} }
} }
selectionChanged = true; selectionChanged = true;
} else if (isCtrlPressed) { }
else if (isCtrlPressed) {
const layerIndex = newSelection.indexOf(layer); const layerIndex = newSelection.indexOf(layer);
if (layerIndex === -1) { if (layerIndex === -1) {
newSelection.push(layer); newSelection.push(layer);
} else { }
else {
newSelection.splice(layerIndex, 1); newSelection.splice(layerIndex, 1);
} }
this.canvas.canvasLayersPanel.lastSelectedIndex = index; this.canvas.canvasLayersPanel.lastSelectedIndex = index;
selectionChanged = true; selectionChanged = true;
} else { }
else {
// Jeśli kliknięta warstwa nie jest częścią obecnego zaznaczenia, // Jeśli kliknięta warstwa nie jest częścią obecnego zaznaczenia,
// wyczyść zaznaczenie i zaznacz tylko ją. // wyczyść zaznaczenie i zaznacz tylko ją.
if (!this.selectedLayers.includes(layer)) { if (!this.selectedLayers.includes(layer)) {
@@ -118,47 +104,41 @@ export class CanvasSelection {
// NIE rób nic, aby umożliwić przeciąganie całej grupy. // NIE rób nic, aby umożliwić przeciąganie całej grupy.
this.canvas.canvasLayersPanel.lastSelectedIndex = index; this.canvas.canvasLayersPanel.lastSelectedIndex = index;
} }
// Aktualizuj zaznaczenie tylko jeśli faktycznie się zmieniło // Aktualizuj zaznaczenie tylko jeśli faktycznie się zmieniło
if (selectionChanged) { if (selectionChanged) {
this.updateSelection(newSelection); this.updateSelection(newSelection);
} }
} }
removeSelectedLayers() { removeSelectedLayers() {
if (this.selectedLayers.length > 0) { if (this.selectedLayers.length > 0) {
log.info('Removing selected layers', { log.info('Removing selected layers', {
layersToRemove: this.selectedLayers.length, layersToRemove: this.selectedLayers.length,
totalLayers: this.canvas.layers.length totalLayers: this.canvas.layers.length
}); });
this.canvas.saveState(); this.canvas.saveState();
this.canvas.layers = this.canvas.layers.filter(l => !this.selectedLayers.includes(l)); this.canvas.layers = this.canvas.layers.filter((l) => !this.selectedLayers.includes(l));
this.updateSelection([]);
this.updateSelection([]);
this.canvas.render(); this.canvas.render();
this.canvas.saveState(); this.canvas.saveState();
if (this.canvas.canvasLayersPanel) { if (this.canvas.canvasLayersPanel) {
this.canvas.canvasLayersPanel.onLayersChanged(); this.canvas.canvasLayersPanel.onLayersChanged();
} }
log.debug('Layers removed successfully, remaining layers:', this.canvas.layers.length); log.debug('Layers removed successfully, remaining layers:', this.canvas.layers.length);
} else { }
else {
log.debug('No layers selected for removal'); log.debug('No layers selected for removal');
} }
} }
/** /**
* Aktualizuje zaznaczenie po operacji historii * Aktualizuje zaznaczenie po operacji historii
*/ */
updateSelectionAfterHistory() { updateSelectionAfterHistory() {
const newSelectedLayers = []; const newSelectedLayers = [];
if (this.selectedLayers) { if (this.selectedLayers) {
this.selectedLayers.forEach(sl => { this.selectedLayers.forEach((sl) => {
const found = this.canvas.layers.find(l => l.id === sl.id); const found = this.canvas.layers.find((l) => l.id === sl.id);
if (found) newSelectedLayers.push(found); if (found)
newSelectedLayers.push(found);
}); });
} }
this.updateSelection(newSelectedLayers); this.updateSelection(newSelectedLayers);

View File

@@ -1,10 +1,7 @@
import {getCanvasState, setCanvasState, saveImage, getImage} from "./db.js"; import { getCanvasState, setCanvasState, saveImage, getImage } from "./db.js";
import {createModuleLogger} from "./utils/LoggerUtils.js"; import { createModuleLogger } from "./utils/LoggerUtils.js";
import {generateUUID, cloneLayers, getStateSignature, debounce} from "./utils/CommonUtils.js"; import { generateUUID, cloneLayers, getStateSignature, debounce } from "./utils/CommonUtils.js";
import {withErrorHandling} from "./ErrorHandler.js";
const log = createModuleLogger('CanvasState'); const log = createModuleLogger('CanvasState');
export class CanvasState { export class CanvasState {
constructor(canvas) { constructor(canvas) {
this.canvas = canvas; this.canvas = canvas;
@@ -16,289 +13,302 @@ export class CanvasState {
this.saveTimeout = null; this.saveTimeout = null;
this.lastSavedStateSignature = null; this.lastSavedStateSignature = null;
this._loadInProgress = null; this._loadInProgress = null;
this._debouncedSave = null;
// Inicjalizacja Web Workera w sposób odporny na problemy ze ścieżkami
try { 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' }); this.stateSaverWorker = new Worker(new URL('./state-saver.worker.js', import.meta.url), { type: 'module' });
log.info("State saver worker initialized successfully."); log.info("State saver worker initialized successfully.");
this.stateSaverWorker.onmessage = (e) => { this.stateSaverWorker.onmessage = (e) => {
log.info("Message from state saver worker:", e.data); log.info("Message from state saver worker:", e.data);
}; };
this.stateSaverWorker.onerror = (e) => { this.stateSaverWorker.onerror = (e) => {
log.error("Error in state saver worker:", e.message, e.filename, e.lineno); 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); log.error("Failed to initialize state saver worker:", e);
this.stateSaverWorker = null; this.stateSaverWorker = null;
} }
} }
async loadStateFromDB() { async loadStateFromDB() {
if (this._loadInProgress) { if (this._loadInProgress) {
log.warn("Load already in progress, waiting..."); log.warn("Load already in progress, waiting...");
return this._loadInProgress; return this._loadInProgress;
} }
log.info("Attempting to load state from IndexedDB for node:", this.canvas.node.id); log.info("Attempting to load state from IndexedDB for node:", this.canvas.node.id);
if (!this.canvas.node.id) { const loadPromise = this._performLoad();
log.error("Node ID is not available for loading state from DB."); this._loadInProgress = loadPromise;
return false;
}
this._loadInProgress = this._performLoad();
try { try {
const result = await this._loadInProgress; const result = await loadPromise;
return result;
} finally {
this._loadInProgress = null; this._loadInProgress = null;
return result;
}
catch (error) {
this._loadInProgress = null;
throw error;
} }
} }
async _performLoad() {
_performLoad = withErrorHandling(async () => { try {
const savedState = await getCanvasState(this.canvas.node.id); if (!this.canvas.node.id) {
if (!savedState) { log.error("Node ID is not available for loading state from DB.");
log.info("No saved state found in IndexedDB for node:", this.canvas.node.id); 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; 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 * Ładuje warstwy z zapisanego stanu
* @param {Array} layersData - Dane warstw do załadowania * @param {any[]} layersData - Dane warstw do załadowania
* @returns {Promise<Array>} Załadowane warstwy * @returns {Promise<(Layer | null)[]>} Załadowane warstwy
*/ */
async _loadLayers(layersData) { async _loadLayers(layersData) {
const imagePromises = layersData.map((layerData, index) => const imagePromises = layersData.map((layerData, index) => this._loadSingleLayer(layerData, index));
this._loadSingleLayer(layerData, index)
);
return Promise.all(imagePromises); return Promise.all(imagePromises);
} }
/** /**
* Ładuje pojedynczą warstwę * Ładuje pojedynczą warstwę
* @param {Object} layerData - Dane warstwy * @param {any} layerData - Dane warstwy
* @param {number} index - Indeks warstwy * @param {number} index - Indeks warstwy
* @returns {Promise<Object|null>} Załadowana warstwa lub null * @returns {Promise<Layer | null>} Załadowana warstwa lub null
*/ */
async _loadSingleLayer(layerData, index) { async _loadSingleLayer(layerData, index) {
return new Promise((resolve) => { return new Promise((resolve) => {
if (layerData.imageId) { if (layerData.imageId) {
this._loadLayerFromImageId(layerData, index, resolve); this._loadLayerFromImageId(layerData, index, resolve);
} else if (layerData.imageSrc) { }
else if (layerData.imageSrc) {
this._convertLegacyLayer(layerData, index, resolve); this._convertLegacyLayer(layerData, index, resolve);
} else { }
else {
log.error(`Layer ${index}: No imageId or imageSrc found, skipping layer.`); log.error(`Layer ${index}: No imageId or imageSrc found, skipping layer.`);
resolve(null); resolve(null);
} }
}); });
} }
/** /**
* Ładuje warstwę z imageId * Ładuje warstwę z imageId
* @param {Object} layerData - Dane warstwy * @param {any} layerData - Dane warstwy
* @param {number} index - Indeks warstwy * @param {number} index - Indeks warstwy
* @param {Function} resolve - Funkcja resolve * @param {(value: Layer | null) => void} resolve - Funkcja resolve
*/ */
_loadLayerFromImageId(layerData, index, resolve) { _loadLayerFromImageId(layerData, index, resolve) {
log.debug(`Layer ${index}: Loading image with id: ${layerData.imageId}`); log.debug(`Layer ${index}: Loading image with id: ${layerData.imageId}`);
if (this.canvas.imageCache.has(layerData.imageId)) { if (this.canvas.imageCache.has(layerData.imageId)) {
log.debug(`Layer ${index}: Image found in cache.`); log.debug(`Layer ${index}: Image found in cache.`);
const imageSrc = this.canvas.imageCache.get(layerData.imageId); const imageData = this.canvas.imageCache.get(layerData.imageId);
this._createLayerFromSrc(layerData, imageSrc, index, resolve); if (imageData) {
} else { const imageSrc = URL.createObjectURL(new Blob([imageData.data]));
this._createLayerFromSrc(layerData, imageSrc, index, resolve);
}
else {
resolve(null);
}
}
else {
getImage(layerData.imageId) getImage(layerData.imageId)
.then(imageSrc => { .then(imageSrc => {
if (imageSrc) { if (imageSrc) {
log.debug(`Layer ${index}: Loading image from data:URL...`); log.debug(`Layer ${index}: Loading image from data:URL...`);
this.canvas.imageCache.set(layerData.imageId, imageSrc); this._createLayerFromSrc(layerData, imageSrc, index, resolve);
this._createLayerFromSrc(layerData, imageSrc, index, resolve); }
} else { else {
log.error(`Layer ${index}: Image not found in IndexedDB.`); 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); resolve(null);
}); }
})
.catch(err => {
log.error(`Layer ${index}: Error loading image from IndexedDB:`, err);
resolve(null);
});
} }
} }
/** /**
* Konwertuje starą warstwę z imageSrc na nowy format * Konwertuje starą warstwę z imageSrc na nowy format
* @param {Object} layerData - Dane warstwy * @param {any} layerData - Dane warstwy
* @param {number} index - Indeks warstwy * @param {number} index - Indeks warstwy
* @param {Function} resolve - Funkcja resolve * @param {(value: Layer | null) => void} resolve - Funkcja resolve
*/ */
_convertLegacyLayer(layerData, index, resolve) { _convertLegacyLayer(layerData, index, resolve) {
log.info(`Layer ${index}: Found imageSrc, converting to new format with imageId.`); log.info(`Layer ${index}: Found imageSrc, converting to new format with imageId.`);
const imageId = generateUUID(); const imageId = generateUUID();
saveImage(imageId, layerData.imageSrc) saveImage(imageId, layerData.imageSrc)
.then(() => { .then(() => {
log.info(`Layer ${index}: Image saved to IndexedDB with id: ${imageId}`); log.info(`Layer ${index}: Image saved to IndexedDB with id: ${imageId}`);
this.canvas.imageCache.set(imageId, layerData.imageSrc); const newLayerData = { ...layerData, imageId };
const newLayerData = {...layerData, imageId}; delete newLayerData.imageSrc;
delete newLayerData.imageSrc; this._createLayerFromSrc(newLayerData, layerData.imageSrc, index, resolve);
this._createLayerFromSrc(newLayerData, layerData.imageSrc, index, resolve); })
})
.catch(err => { .catch(err => {
log.error(`Layer ${index}: Error saving image to IndexedDB:`, err); log.error(`Layer ${index}: Error saving image to IndexedDB:`, err);
resolve(null); resolve(null);
}); });
} }
/** /**
* Tworzy warstwę z src obrazu * Tworzy warstwę z src obrazu
* @param {Object} layerData - Dane warstwy * @param {any} layerData - Dane warstwy
* @param {string} imageSrc - Źródło obrazu * @param {string} imageSrc - Źródło obrazu
* @param {number} index - Indeks warstwy * @param {number} index - Indeks warstwy
* @param {Function} resolve - Funkcja resolve * @param {(value: Layer | null) => void} resolve - Funkcja resolve
*/ */
_createLayerFromSrc(layerData, imageSrc, index, resolve) { _createLayerFromSrc(layerData, imageSrc, index, resolve) {
const img = new Image(); if (typeof imageSrc === 'string') {
img.onload = () => { const img = new Image();
log.debug(`Layer ${index}: Image loaded successfully.`); img.onload = () => {
const newLayer = {...layerData, image: img}; log.debug(`Layer ${index}: Image loaded successfully.`);
delete newLayer.imageId; const newLayer = { ...layerData, image: img };
resolve(newLayer); resolve(newLayer);
}; };
img.onerror = () => { img.onerror = () => {
log.error(`Layer ${index}: Failed to load image from src.`); log.error(`Layer ${index}: Failed to load image from src.`);
resolve(null); resolve(null);
}; };
img.src = imageSrc; 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() { async saveStateToDB() {
if (!this.canvas.node.id) { if (!this.canvas.node.id) {
log.error("Node ID is not available for saving state to DB."); log.error("Node ID is not available for saving state to DB.");
return; return;
} }
log.info("Preparing state to be sent to worker..."); log.info("Preparing state to be sent to worker...");
const layers = await this._prepareLayers();
const state = { const state = {
layers: await this._prepareLayers(), layers: layers.filter(layer => layer !== null),
viewport: this.canvas.viewport, viewport: this.canvas.viewport,
width: this.canvas.width, width: this.canvas.width,
height: this.canvas.height, height: this.canvas.height,
}; };
state.layers = state.layers.filter(layer => layer !== null);
if (state.layers.length === 0) { if (state.layers.length === 0) {
log.warn("No valid layers to save, skipping."); log.warn("No valid layers to save, skipping.");
return; return;
} }
if (this.stateSaverWorker) { if (this.stateSaverWorker) {
log.info("Posting state to worker for background saving."); log.info("Posting state to worker for background saving.");
this.stateSaverWorker.postMessage({ this.stateSaverWorker.postMessage({
nodeId: this.canvas.node.id, nodeId: String(this.canvas.node.id),
state: state state: state
}); });
this.canvas.render(); this.canvas.render();
} else { }
else {
log.warn("State saver worker not available. Saving on main thread."); 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 * Przygotowuje warstwy do zapisu
* @returns {Promise<Array>} Przygotowane warstwy * @returns {Promise<(Omit<Layer, 'image'> & { imageId: string })[]>} Przygotowane warstwy
*/ */
async _prepareLayers() { async _prepareLayers() {
return Promise.all(this.canvas.layers.map(async (layer, index) => { const preparedLayers = await Promise.all(this.canvas.layers.map(async (layer, index) => {
const newLayer = {...layer}; const newLayer = { ...layer, imageId: layer.imageId || '' };
delete newLayer.image;
if (layer.image instanceof HTMLImageElement) { if (layer.image instanceof HTMLImageElement) {
log.debug(`Layer ${index}: Using imageId instead of serializing image.`); log.debug(`Layer ${index}: Using imageId instead of serializing image.`);
if (!layer.imageId) { if (!layer.imageId) {
layer.imageId = generateUUID(); newLayer.imageId = generateUUID();
await saveImage(layer.imageId, layer.image.src); const imageBitmap = await createImageBitmap(layer.image);
this.canvas.imageCache.set(layer.imageId, layer.image.src); await saveImage(newLayer.imageId, imageBitmap);
} }
newLayer.imageId = layer.imageId; newLayer.imageId = layer.imageId;
} else if (!layer.imageId) { }
else if (!layer.imageId) {
log.error(`Layer ${index}: No image or imageId found, skipping layer.`); log.error(`Layer ${index}: No image or imageId found, skipping layer.`);
return null; return null;
} }
delete newLayer.image;
return newLayer; return newLayer;
})); }));
return preparedLayers.filter((layer) => layer !== null);
} }
saveState(replaceLast = false) { saveState(replaceLast = false) {
if (this.canvas.maskTool && this.canvas.maskTool.isActive) { if (this.canvas.maskTool && this.canvas.maskTool.isActive) {
this.saveMaskState(replaceLast); this.saveMaskState(replaceLast);
} else { }
else {
this.saveLayersState(replaceLast); this.saveLayersState(replaceLast);
} }
} }
saveLayersState(replaceLast = false) { saveLayersState(replaceLast = false) {
if (replaceLast && this.layersUndoStack.length > 0) { if (replaceLast && this.layersUndoStack.length > 0) {
this.layersUndoStack.pop(); this.layersUndoStack.pop();
} }
const currentState = cloneLayers(this.canvas.layers); const currentState = cloneLayers(this.canvas.layers);
const currentStateSignature = getStateSignature(currentState); const currentStateSignature = getStateSignature(currentState);
if (this.layersUndoStack.length > 0) { if (this.layersUndoStack.length > 0) {
const lastState = this.layersUndoStack[this.layersUndoStack.length - 1]; const lastState = this.layersUndoStack[this.layersUndoStack.length - 1];
if (getStateSignature(lastState) === currentStateSignature) { if (getStateSignature(lastState) === currentStateSignature) {
return; return;
} }
} }
this.layersUndoStack.push(currentState); this.layersUndoStack.push(currentState);
if (this.layersUndoStack.length > this.historyLimit) { if (this.layersUndoStack.length > this.historyLimit) {
this.layersUndoStack.shift(); this.layersUndoStack.shift();
} }
this.layersRedoStack = []; this.layersRedoStack = [];
this.canvas.updateHistoryButtons(); this.canvas.updateHistoryButtons();
// Użyj debouncingu, aby zapobiec zbyt częstym zapisom
if (!this._debouncedSave) { if (!this._debouncedSave) {
this._debouncedSave = debounce(() => this.saveStateToDB(), 1000); this._debouncedSave = debounce(this.saveStateToDB.bind(this), 1000);
} }
this._debouncedSave(); this._debouncedSave();
} }
saveMaskState(replaceLast = false) { saveMaskState(replaceLast = false) {
if (!this.canvas.maskTool) return; if (!this.canvas.maskTool)
return;
if (replaceLast && this.maskUndoStack.length > 0) { if (replaceLast && this.maskUndoStack.length > 0) {
this.maskUndoStack.pop(); this.maskUndoStack.pop();
} }
@@ -307,89 +317,92 @@ export class CanvasState {
clonedCanvas.width = maskCanvas.width; clonedCanvas.width = maskCanvas.width;
clonedCanvas.height = maskCanvas.height; clonedCanvas.height = maskCanvas.height;
const clonedCtx = clonedCanvas.getContext('2d', { willReadFrequently: true }); const clonedCtx = clonedCanvas.getContext('2d', { willReadFrequently: true });
clonedCtx.drawImage(maskCanvas, 0, 0); if (clonedCtx) {
clonedCtx.drawImage(maskCanvas, 0, 0);
}
this.maskUndoStack.push(clonedCanvas); this.maskUndoStack.push(clonedCanvas);
if (this.maskUndoStack.length > this.historyLimit) { if (this.maskUndoStack.length > this.historyLimit) {
this.maskUndoStack.shift(); this.maskUndoStack.shift();
} }
this.maskRedoStack = []; this.maskRedoStack = [];
this.canvas.updateHistoryButtons(); this.canvas.updateHistoryButtons();
} }
undo() { undo() {
if (this.canvas.maskTool && this.canvas.maskTool.isActive) { if (this.canvas.maskTool && this.canvas.maskTool.isActive) {
this.undoMaskState(); this.undoMaskState();
} else { }
else {
this.undoLayersState(); this.undoLayersState();
} }
} }
redo() { redo() {
if (this.canvas.maskTool && this.canvas.maskTool.isActive) { if (this.canvas.maskTool && this.canvas.maskTool.isActive) {
this.redoMaskState(); this.redoMaskState();
} else { }
else {
this.redoLayersState(); this.redoLayersState();
} }
} }
undoLayersState() { undoLayersState() {
if (this.layersUndoStack.length <= 1) return; if (this.layersUndoStack.length <= 1)
return;
const currentState = this.layersUndoStack.pop(); const currentState = this.layersUndoStack.pop();
this.layersRedoStack.push(currentState); if (currentState) {
this.layersRedoStack.push(currentState);
}
const prevState = this.layersUndoStack[this.layersUndoStack.length - 1]; const prevState = this.layersUndoStack[this.layersUndoStack.length - 1];
this.canvas.layers = cloneLayers(prevState); this.canvas.layers = cloneLayers(prevState);
this.canvas.updateSelectionAfterHistory(); this.canvas.updateSelectionAfterHistory();
this.canvas.render(); this.canvas.render();
this.canvas.updateHistoryButtons(); this.canvas.updateHistoryButtons();
} }
redoLayersState() { redoLayersState() {
if (this.layersRedoStack.length === 0) return; if (this.layersRedoStack.length === 0)
return;
const nextState = this.layersRedoStack.pop(); const nextState = this.layersRedoStack.pop();
this.layersUndoStack.push(nextState); if (nextState) {
this.canvas.layers = cloneLayers(nextState); this.layersUndoStack.push(nextState);
this.canvas.updateSelectionAfterHistory(); this.canvas.layers = cloneLayers(nextState);
this.canvas.render(); this.canvas.updateSelectionAfterHistory();
this.canvas.updateHistoryButtons(); this.canvas.render();
this.canvas.updateHistoryButtons();
}
} }
undoMaskState() { undoMaskState() {
if (!this.canvas.maskTool || this.maskUndoStack.length <= 1) return; if (!this.canvas.maskTool || this.maskUndoStack.length <= 1)
return;
const currentState = this.maskUndoStack.pop(); const currentState = this.maskUndoStack.pop();
this.maskRedoStack.push(currentState); if (currentState) {
this.maskRedoStack.push(currentState);
}
if (this.maskUndoStack.length > 0) { if (this.maskUndoStack.length > 0) {
const prevState = this.maskUndoStack[this.maskUndoStack.length - 1]; const prevState = this.maskUndoStack[this.maskUndoStack.length - 1];
const maskCanvas = this.canvas.maskTool.getMask(); const maskCanvas = this.canvas.maskTool.getMask();
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true }); const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height); if (maskCtx) {
maskCtx.drawImage(prevState, 0, 0); maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
maskCtx.drawImage(prevState, 0, 0);
}
this.canvas.render(); this.canvas.render();
} }
this.canvas.updateHistoryButtons(); this.canvas.updateHistoryButtons();
} }
redoMaskState() { redoMaskState() {
if (!this.canvas.maskTool || this.maskRedoStack.length === 0) return; if (!this.canvas.maskTool || this.maskRedoStack.length === 0)
return;
const nextState = this.maskRedoStack.pop(); const nextState = this.maskRedoStack.pop();
this.maskUndoStack.push(nextState); if (nextState) {
const maskCanvas = this.canvas.maskTool.getMask(); this.maskUndoStack.push(nextState);
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true }); const maskCanvas = this.canvas.maskTool.getMask();
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height); const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
maskCtx.drawImage(nextState, 0, 0); if (maskCtx) {
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
this.canvas.render(); maskCtx.drawImage(nextState, 0, 0);
}
this.canvas.render();
}
this.canvas.updateHistoryButtons(); this.canvas.updateHistoryButtons();
} }
/** /**
* Czyści historię undo/redo * Czyści historię undo/redo
*/ */
@@ -397,17 +410,17 @@ export class CanvasState {
if (this.canvas.maskTool && this.canvas.maskTool.isActive) { if (this.canvas.maskTool && this.canvas.maskTool.isActive) {
this.maskUndoStack = []; this.maskUndoStack = [];
this.maskRedoStack = []; this.maskRedoStack = [];
} else { }
else {
this.layersUndoStack = []; this.layersUndoStack = [];
this.layersRedoStack = []; this.layersRedoStack = [];
} }
this.canvas.updateHistoryButtons(); this.canvas.updateHistoryButtons();
log.info("History cleared"); log.info("History cleared");
} }
/** /**
* Zwraca informacje o historii * Zwraca informacje o historii
* @returns {Object} Informacje o historii * @returns {HistoryInfo} Informacje o historii
*/ */
getHistoryInfo() { getHistoryInfo() {
if (this.canvas.maskTool && this.canvas.maskTool.isActive) { if (this.canvas.maskTool && this.canvas.maskTool.isActive) {
@@ -418,7 +431,8 @@ export class CanvasState {
canRedo: this.maskRedoStack.length > 0, canRedo: this.maskRedoStack.length > 0,
historyLimit: this.historyLimit historyLimit: this.historyLimit
}; };
} else { }
else {
return { return {
undoCount: this.layersUndoStack.length, undoCount: this.layersUndoStack.length,
redoCount: this.layersRedoStack.length, redoCount: this.layersRedoStack.length,

File diff suppressed because it is too large Load Diff

View File

@@ -2,11 +2,8 @@
* ErrorHandler - Centralna obsługa błędów * ErrorHandler - Centralna obsługa błędów
* Eliminuje powtarzalne wzorce obsługi błędów w całym projekcie * 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'); const log = createModuleLogger('ErrorHandler');
/** /**
* Typy błędów w aplikacji * Typy błędów w aplikacji
*/ */
@@ -20,7 +17,6 @@ export const ErrorTypes = {
USER_INPUT: 'USER_INPUT_ERROR', USER_INPUT: 'USER_INPUT_ERROR',
SYSTEM: 'SYSTEM_ERROR' SYSTEM: 'SYSTEM_ERROR'
}; };
/** /**
* Klasa błędu aplikacji z dodatkowymi informacjami * 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ą * Handler błędów z automatycznym logowaniem i kategoryzacją
*/ */
@@ -47,12 +42,11 @@ export class ErrorHandler {
this.errorHistory = []; this.errorHistory = [];
this.maxHistorySize = 100; this.maxHistorySize = 100;
} }
/** /**
* Obsługuje błąd z automatycznym logowaniem * 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 {string} context - Kontekst wystąpienia błędu
* @param {Object} additionalInfo - Dodatkowe informacje * @param {object} additionalInfo - Dodatkowe informacje
* @returns {AppError} Znormalizowany błąd * @returns {AppError} Znormalizowany błąd
*/ */
handle(error, context = 'Unknown', additionalInfo = {}) { handle(error, context = 'Unknown', additionalInfo = {}) {
@@ -60,52 +54,33 @@ export class ErrorHandler {
this.logError(normalizedError, context); this.logError(normalizedError, context);
this.recordError(normalizedError); this.recordError(normalizedError);
this.incrementErrorCount(normalizedError.type); this.incrementErrorCount(normalizedError.type);
return normalizedError; return normalizedError;
} }
/** /**
* Normalizuje błąd do standardowego formatu * 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 {string} context - Kontekst
* @param {Object} additionalInfo - Dodatkowe informacje * @param {object} additionalInfo - Dodatkowe informacje
* @returns {AppError} Znormalizowany błąd * @returns {AppError} Znormalizowany błąd
*/ */
normalizeError(error, context, additionalInfo) { normalizeError(error, context, additionalInfo) {
if (error instanceof AppError) { if (error instanceof AppError) {
return error; return error;
} }
if (error instanceof Error) { if (error instanceof Error) {
const type = this.categorizeError(error, context); const type = this.categorizeError(error, context);
return new AppError( return new AppError(error.message, type, { context, ...additionalInfo }, error);
error.message,
type,
{context, ...additionalInfo},
error
);
} }
if (typeof error === 'string') { if (typeof error === 'string') {
return new AppError( return new AppError(error, ErrorTypes.SYSTEM, { context, ...additionalInfo });
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 * Kategoryzuje błąd na podstawie wiadomości i kontekstu
* @param {Error} error - Błąd do skategoryzowania * @param {Error} error - Błąd do skategoryzowania
* @param {string} context - Kontekst * @param {string} context - Kontekst
* @returns {string} Typ błędu * @returns {ErrorType} Typ błędu
*/ */
categorizeError(error, context) { categorizeError(error, context) {
const message = error.message.toLowerCase(); const message = error.message.toLowerCase();
@@ -132,10 +107,8 @@ export class ErrorHandler {
if (context.toLowerCase().includes('canvas')) { if (context.toLowerCase().includes('canvas')) {
return ErrorTypes.CANVAS; return ErrorTypes.CANVAS;
} }
return ErrorTypes.SYSTEM; return ErrorTypes.SYSTEM;
} }
/** /**
* Loguje błąd z odpowiednim poziomem * Loguje błąd z odpowiednim poziomem
* @param {AppError} error - Błąd do zalogowania * @param {AppError} error - Błąd do zalogowania
@@ -161,7 +134,6 @@ export class ErrorHandler {
log.error(logMessage, logDetails); log.error(logMessage, logDetails);
} }
} }
/** /**
* Zapisuje błąd w historii * Zapisuje błąd w historii
* @param {AppError} error - Błąd do zapisania * @param {AppError} error - Błąd do zapisania
@@ -177,36 +149,37 @@ export class ErrorHandler {
this.errorHistory.shift(); this.errorHistory.shift();
} }
} }
/** /**
* Zwiększa licznik błędów dla danego typu * Zwiększa licznik błędów dla danego typu
* @param {string} errorType - Typ błędu * @param {ErrorType} errorType - Typ błędu
*/ */
incrementErrorCount(errorType) { incrementErrorCount(errorType) {
const current = this.errorCounts.get(errorType) || 0; const current = this.errorCounts.get(errorType) || 0;
this.errorCounts.set(errorType, current + 1); this.errorCounts.set(errorType, current + 1);
} }
/** /**
* Zwraca statystyki błędów * Zwraca statystyki błędów
* @returns {Object} Statystyki błędów * @returns {ErrorStats} Statystyki błędów
*/ */
getErrorStats() { getErrorStats() {
const errorCountsObj = {};
for (const [key, value] of this.errorCounts.entries()) {
errorCountsObj[key] = value;
}
return { return {
totalErrors: this.errorHistory.length, totalErrors: this.errorHistory.length,
errorCounts: Object.fromEntries(this.errorCounts), errorCounts: errorCountsObj,
recentErrors: this.errorHistory.slice(-10), recentErrors: this.errorHistory.slice(-10),
errorsByType: this.groupErrorsByType() errorsByType: this.groupErrorsByType()
}; };
} }
/** /**
* Grupuje błędy według typu * 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() { groupErrorsByType() {
const grouped = {}; const grouped = {};
this.errorHistory.forEach(error => { this.errorHistory.forEach((error) => {
if (!grouped[error.type]) { if (!grouped[error.type]) {
grouped[error.type] = []; grouped[error.type] = [];
} }
@@ -214,7 +187,6 @@ export class ErrorHandler {
}); });
return grouped; return grouped;
} }
/** /**
* Czyści historię błędów * Czyści historię błędów
*/ */
@@ -224,9 +196,7 @@ export class ErrorHandler {
log.info('Error history cleared'); log.info('Error history cleared');
} }
} }
const errorHandler = new ErrorHandler(); const errorHandler = new ErrorHandler();
/** /**
* Wrapper funkcji z automatyczną obsługą błędów * Wrapper funkcji z automatyczną obsługą błędów
* @param {Function} fn - Funkcja do opakowania * @param {Function} fn - Funkcja do opakowania
@@ -237,7 +207,8 @@ export function withErrorHandling(fn, context) {
return async function (...args) { return async function (...args) {
try { try {
return await fn.apply(this, args); return await fn.apply(this, args);
} catch (error) { }
catch (error) {
const handledError = errorHandler.handle(error, context, { const handledError = errorHandler.handle(error, context, {
functionName: fn.name, functionName: fn.name,
arguments: args.length arguments: args.length
@@ -246,7 +217,6 @@ export function withErrorHandling(fn, context) {
} }
}; };
} }
/** /**
* Decorator dla metod klasy z automatyczną obsługą błędów * Decorator dla metod klasy z automatyczną obsługą błędów
* @param {string} context - Kontekst wykonania * @param {string} context - Kontekst wykonania
@@ -254,11 +224,11 @@ export function withErrorHandling(fn, context) {
export function handleErrors(context) { export function handleErrors(context) {
return function (target, propertyKey, descriptor) { return function (target, propertyKey, descriptor) {
const originalMethod = descriptor.value; const originalMethod = descriptor.value;
descriptor.value = async function (...args) { descriptor.value = async function (...args) {
try { try {
return await originalMethod.apply(this, args); return await originalMethod.apply(this, args);
} catch (error) { }
catch (error) {
const handledError = errorHandler.handle(error, `${context}.${propertyKey}`, { const handledError = errorHandler.handle(error, `${context}.${propertyKey}`, {
className: target.constructor.name, className: target.constructor.name,
methodName: propertyKey, methodName: propertyKey,
@@ -267,86 +237,77 @@ export function handleErrors(context) {
throw handledError; throw handledError;
} }
}; };
return descriptor; return descriptor;
}; };
} }
/** /**
* Funkcja pomocnicza do tworzenia błędów walidacji * Funkcja pomocnicza do tworzenia błędów walidacji
* @param {string} message - Wiadomość błędu * @param {string} message - Wiadomość błędu
* @param {Object} details - Szczegóły walidacji * @param {object} details - Szczegóły walidacji
* @returns {AppError} Błąd walidacji * @returns {AppError} Błąd walidacji
*/ */
export function createValidationError(message, details = {}) { export function createValidationError(message, details = {}) {
return new AppError(message, ErrorTypes.VALIDATION, details); return new AppError(message, ErrorTypes.VALIDATION, details);
} }
/** /**
* Funkcja pomocnicza do tworzenia błędów sieciowych * Funkcja pomocnicza do tworzenia błędów sieciowych
* @param {string} message - Wiadomość błędu * @param {string} message - Wiadomość błędu
* @param {Object} details - Szczegóły sieci * @param {object} details - Szczegóły sieci
* @returns {AppError} Błąd sieciowy * @returns {AppError} Błąd sieciowy
*/ */
export function createNetworkError(message, details = {}) { export function createNetworkError(message, details = {}) {
return new AppError(message, ErrorTypes.NETWORK, details); return new AppError(message, ErrorTypes.NETWORK, details);
} }
/** /**
* Funkcja pomocnicza do tworzenia błędów plików * Funkcja pomocnicza do tworzenia błędów plików
* @param {string} message - Wiadomość błędu * @param {string} message - Wiadomość błędu
* @param {Object} details - Szczegóły pliku * @param {object} details - Szczegóły pliku
* @returns {AppError} Błąd pliku * @returns {AppError} Błąd pliku
*/ */
export function createFileError(message, details = {}) { export function createFileError(message, details = {}) {
return new AppError(message, ErrorTypes.FILE_IO, details); return new AppError(message, ErrorTypes.FILE_IO, details);
} }
/** /**
* Funkcja pomocnicza do bezpiecznego wykonania operacji * Funkcja pomocnicza do bezpiecznego wykonania operacji
* @param {Function} operation - Operacja do wykonania * @param {() => Promise<T>} operation - Operacja do wykonania
* @param {*} fallbackValue - Wartość fallback w przypadku błędu * @param {T} fallbackValue - Wartość fallback w przypadku błędu
* @param {string} context - Kontekst operacji * @param {string} context - Kontekst operacji
* @returns {*} Wynik operacji lub wartość fallback * @returns {Promise<T>} Wynik operacji lub wartość fallback
*/ */
export async function safeExecute(operation, fallbackValue = null, context = 'SafeExecute') { export async function safeExecute(operation, fallbackValue, context = 'SafeExecute') {
try { try {
return await operation(); return await operation();
} catch (error) { }
catch (error) {
errorHandler.handle(error, context); errorHandler.handle(error, context);
return fallbackValue; return fallbackValue;
} }
} }
/** /**
* Funkcja do retry operacji z exponential backoff * Funkcja do retry operacji z exponential backoff
* @param {Function} operation - Operacja do powtórzenia * @param {() => Promise<T>} operation - Operacja do powtórzenia
* @param {number} maxRetries - Maksymalna liczba prób * @param {number} maxRetries - Maksymalna liczba prób
* @param {number} baseDelay - Podstawowe opóźnienie w ms * @param {number} baseDelay - Podstawowe opóźnienie w ms
* @param {string} context - Kontekst operacji * @param {string} context - Kontekst operacji
* @returns {*} Wynik operacji * @returns {Promise<T>} Wynik operacji
*/ */
export async function retryWithBackoff(operation, maxRetries = 3, baseDelay = 1000, context = 'RetryOperation') { export async function retryWithBackoff(operation, maxRetries = 3, baseDelay = 1000, context = 'RetryOperation') {
let lastError; let lastError;
for (let attempt = 0; attempt <= maxRetries; attempt++) { for (let attempt = 0; attempt <= maxRetries; attempt++) {
try { try {
return await operation(); return await operation();
} catch (error) { }
catch (error) {
lastError = error; lastError = error;
if (attempt === maxRetries) { if (attempt === maxRetries) {
break; break;
} }
const delay = baseDelay * Math.pow(2, attempt); 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)); 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; export default errorHandler;

View File

@@ -1,27 +1,21 @@
import {createModuleLogger} from "./utils/LoggerUtils.js"; import { createModuleLogger } from "./utils/LoggerUtils.js";
const log = createModuleLogger('ImageCache'); const log = createModuleLogger('ImageCache');
export class ImageCache { export class ImageCache {
constructor() { constructor() {
this.cache = new Map(); this.cache = new Map();
} }
set(key, imageData) { set(key, imageData) {
log.info("Caching image data for key:", key); log.info("Caching image data for key:", key);
this.cache.set(key, imageData); this.cache.set(key, imageData);
} }
get(key) { get(key) {
const data = this.cache.get(key); const data = this.cache.get(key);
log.debug("Retrieved cached data for key:", key, !!data); log.debug("Retrieved cached data for key:", key, !!data);
return data; return data;
} }
has(key) { has(key) {
return this.cache.has(key); return this.cache.has(key);
} }
clear() { clear() {
log.info("Clearing image cache"); log.info("Clearing image cache");
this.cache.clear(); this.cache.clear();

View File

@@ -1,24 +1,18 @@
import {removeImage, getAllImageIds} from "./db.js"; import { removeImage, getAllImageIds } from "./db.js";
import {createModuleLogger} from "./utils/LoggerUtils.js"; import { createModuleLogger } from "./utils/LoggerUtils.js";
const log = createModuleLogger('ImageReferenceManager'); const log = createModuleLogger('ImageReferenceManager');
export class ImageReferenceManager { export class ImageReferenceManager {
constructor(canvas) { constructor(canvas) {
this.canvas = canvas; this.canvas = canvas;
this.imageReferences = new Map(); // imageId -> count this.imageReferences = new Map(); // imageId -> count
this.imageLastUsed = new Map(); // imageId -> timestamp this.imageLastUsed = new Map(); // imageId -> timestamp
this.gcInterval = 5 * 60 * 1000; // 5 minut (nieużywane) this.gcInterval = 5 * 60 * 1000; // 5 minut (nieużywane)
this.maxAge = 30 * 60 * 1000; // 30 minut bez użycia this.maxAge = 30 * 60 * 1000; // 30 minut bez użycia
this.gcTimer = null; this.gcTimer = null;
this.isGcRunning = false; this.isGcRunning = false;
this.operationCount = 0; this.operationCount = 0;
this.operationThreshold = 500; // Uruchom GC po 500 operacjach this.operationThreshold = 500; // Uruchom GC po 500 operacjach
} }
/** /**
* Uruchamia automatyczne garbage collection * Uruchamia automatyczne garbage collection
*/ */
@@ -26,14 +20,11 @@ export class ImageReferenceManager {
if (this.gcTimer) { if (this.gcTimer) {
clearInterval(this.gcTimer); clearInterval(this.gcTimer);
} }
this.gcTimer = window.setInterval(() => {
this.gcTimer = setInterval(() => {
this.performGarbageCollection(); this.performGarbageCollection();
}, this.gcInterval); }, this.gcInterval);
log.info("Garbage collection started with interval:", this.gcInterval / 1000, "seconds"); log.info("Garbage collection started with interval:", this.gcInterval / 1000, "seconds");
} }
/** /**
* Zatrzymuje automatyczne garbage collection * Zatrzymuje automatyczne garbage collection
*/ */
@@ -44,38 +35,35 @@ export class ImageReferenceManager {
} }
log.info("Garbage collection stopped"); log.info("Garbage collection stopped");
} }
/** /**
* Dodaje referencję do obrazu * Dodaje referencję do obrazu
* @param {string} imageId - ID obrazu * @param {string} imageId - ID obrazu
*/ */
addReference(imageId) { addReference(imageId) {
if (!imageId) return; if (!imageId)
return;
const currentCount = this.imageReferences.get(imageId) || 0; const currentCount = this.imageReferences.get(imageId) || 0;
this.imageReferences.set(imageId, currentCount + 1); this.imageReferences.set(imageId, currentCount + 1);
this.imageLastUsed.set(imageId, Date.now()); this.imageLastUsed.set(imageId, Date.now());
log.debug(`Added reference to image ${imageId}, count: ${currentCount + 1}`); log.debug(`Added reference to image ${imageId}, count: ${currentCount + 1}`);
} }
/** /**
* Usuwa referencję do obrazu * Usuwa referencję do obrazu
* @param {string} imageId - ID obrazu * @param {string} imageId - ID obrazu
*/ */
removeReference(imageId) { removeReference(imageId) {
if (!imageId) return; if (!imageId)
return;
const currentCount = this.imageReferences.get(imageId) || 0; const currentCount = this.imageReferences.get(imageId) || 0;
if (currentCount <= 1) { if (currentCount <= 1) {
this.imageReferences.delete(imageId); this.imageReferences.delete(imageId);
log.debug(`Removed last reference to image ${imageId}`); log.debug(`Removed last reference to image ${imageId}`);
} else { }
else {
this.imageReferences.set(imageId, currentCount - 1); this.imageReferences.set(imageId, currentCount - 1);
log.debug(`Removed reference to image ${imageId}, count: ${currentCount - 1}`); log.debug(`Removed reference to image ${imageId}, count: ${currentCount - 1}`);
} }
} }
/** /**
* Aktualizuje referencje na podstawie aktualnego stanu canvas * Aktualizuje referencje na podstawie aktualnego stanu canvas
*/ */
@@ -86,117 +74,100 @@ export class ImageReferenceManager {
usedImageIds.forEach(imageId => { usedImageIds.forEach(imageId => {
this.addReference(imageId); this.addReference(imageId);
}); });
log.info(`Updated references for ${usedImageIds.size} unique images`); log.info(`Updated references for ${usedImageIds.size} unique images`);
} }
/** /**
* Zbiera wszystkie używane imageId z różnych źródeł * Zbiera wszystkie używane imageId z różnych źródeł
* @returns {Set<string>} Zbiór używanych imageId * @returns {Set<string>} Zbiór używanych imageId
*/ */
collectAllUsedImageIds() { collectAllUsedImageIds() {
const usedImageIds = new Set(); const usedImageIds = new Set();
this.canvas.layers.forEach(layer => { this.canvas.layers.forEach((layer) => {
if (layer.imageId) { if (layer.imageId) {
usedImageIds.add(layer.imageId); usedImageIds.add(layer.imageId);
} }
}); });
if (this.canvas.canvasState && this.canvas.canvasState.layersUndoStack) { if (this.canvas.canvasState && this.canvas.canvasState.layersUndoStack) {
this.canvas.canvasState.layersUndoStack.forEach(layersState => { this.canvas.canvasState.layersUndoStack.forEach((layersState) => {
layersState.forEach(layer => { layersState.forEach((layer) => {
if (layer.imageId) { if (layer.imageId) {
usedImageIds.add(layer.imageId); usedImageIds.add(layer.imageId);
} }
}); });
}); });
} }
if (this.canvas.canvasState && this.canvas.canvasState.layersRedoStack) { if (this.canvas.canvasState && this.canvas.canvasState.layersRedoStack) {
this.canvas.canvasState.layersRedoStack.forEach(layersState => { this.canvas.canvasState.layersRedoStack.forEach((layersState) => {
layersState.forEach(layer => { layersState.forEach((layer) => {
if (layer.imageId) { if (layer.imageId) {
usedImageIds.add(layer.imageId); usedImageIds.add(layer.imageId);
} }
}); });
}); });
} }
log.debug(`Collected ${usedImageIds.size} used image IDs`); log.debug(`Collected ${usedImageIds.size} used image IDs`);
return usedImageIds; return usedImageIds;
} }
/** /**
* Znajduje nieużywane obrazy * Znajduje nieużywane obrazy
* @param {Set<string>} usedImageIds - Zbiór używanych imageId * @param {Set<string>} usedImageIds - Zbiór używanych imageId
* @returns {Array<string>} Lista nieużywanych imageId * @returns {Promise<string[]>} Lista nieużywanych imageId
*/ */
async findUnusedImages(usedImageIds) { async findUnusedImages(usedImageIds) {
try { try {
const allImageIds = await getAllImageIds(); const allImageIds = await getAllImageIds();
const unusedImages = []; const unusedImages = [];
const now = Date.now(); const now = Date.now();
for (const imageId of allImageIds) { for (const imageId of allImageIds) {
if (!usedImageIds.has(imageId)) { if (!usedImageIds.has(imageId)) {
const lastUsed = this.imageLastUsed.get(imageId) || 0; const lastUsed = this.imageLastUsed.get(imageId) || 0;
const age = now - lastUsed; const age = now - lastUsed;
if (age > this.maxAge) { if (age > this.maxAge) {
unusedImages.push(imageId); unusedImages.push(imageId);
} else { }
else {
log.debug(`Image ${imageId} is unused but too young (age: ${Math.round(age / 1000)}s)`); 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`); log.debug(`Found ${unusedImages.length} unused images ready for cleanup`);
return unusedImages; return unusedImages;
} catch (error) { }
catch (error) {
log.error("Error finding unused images:", error); log.error("Error finding unused images:", error);
return []; return [];
} }
} }
/** /**
* Czyści nieużywane obrazy * Czyści nieużywane obrazy
* @param {Array<string>} unusedImages - Lista nieużywanych imageId * @param {string[]} unusedImages - Lista nieużywanych imageId
*/ */
async cleanupUnusedImages(unusedImages) { async cleanupUnusedImages(unusedImages) {
if (unusedImages.length === 0) { if (unusedImages.length === 0) {
log.debug("No unused images to cleanup"); log.debug("No unused images to cleanup");
return; return;
} }
log.info(`Starting cleanup of ${unusedImages.length} unused images`); log.info(`Starting cleanup of ${unusedImages.length} unused images`);
let cleanedCount = 0; let cleanedCount = 0;
let errorCount = 0; let errorCount = 0;
for (const imageId of unusedImages) { for (const imageId of unusedImages) {
try { try {
await removeImage(imageId); await removeImage(imageId);
if (this.canvas.imageCache && this.canvas.imageCache.has(imageId)) { if (this.canvas.imageCache && this.canvas.imageCache.has(imageId)) {
this.canvas.imageCache.delete(imageId); this.canvas.imageCache.delete(imageId);
} }
this.imageReferences.delete(imageId); this.imageReferences.delete(imageId);
this.imageLastUsed.delete(imageId); this.imageLastUsed.delete(imageId);
cleanedCount++; cleanedCount++;
log.debug(`Cleaned up image: ${imageId}`); log.debug(`Cleaned up image: ${imageId}`);
}
} catch (error) { catch (error) {
errorCount++; errorCount++;
log.error(`Error cleaning up image ${imageId}:`, error); log.error(`Error cleaning up image ${imageId}:`, error);
} }
} }
log.info(`Garbage collection completed: ${cleanedCount} images cleaned, ${errorCount} errors`); log.info(`Garbage collection completed: ${cleanedCount} images cleaned, ${errorCount} errors`);
} }
/** /**
* Wykonuje pełne garbage collection * Wykonuje pełne garbage collection
*/ */
@@ -205,44 +176,35 @@ export class ImageReferenceManager {
log.debug("Garbage collection already running, skipping"); log.debug("Garbage collection already running, skipping");
return; return;
} }
this.isGcRunning = true; this.isGcRunning = true;
log.info("Starting garbage collection..."); log.info("Starting garbage collection...");
try { try {
this.updateReferences(); this.updateReferences();
const usedImageIds = this.collectAllUsedImageIds(); const usedImageIds = this.collectAllUsedImageIds();
const unusedImages = await this.findUnusedImages(usedImageIds); const unusedImages = await this.findUnusedImages(usedImageIds);
await this.cleanupUnusedImages(unusedImages); await this.cleanupUnusedImages(unusedImages);
}
} catch (error) { catch (error) {
log.error("Error during garbage collection:", error); log.error("Error during garbage collection:", error);
} finally { }
finally {
this.isGcRunning = false; this.isGcRunning = false;
} }
} }
/** /**
* Zwiększa licznik operacji i sprawdza czy uruchomić GC * Zwiększa licznik operacji i sprawdza czy uruchomić GC
*/ */
incrementOperationCount() { incrementOperationCount() {
this.operationCount++; this.operationCount++;
log.debug(`Operation count: ${this.operationCount}/${this.operationThreshold}`); log.debug(`Operation count: ${this.operationCount}/${this.operationThreshold}`);
if (this.operationCount >= this.operationThreshold) { if (this.operationCount >= this.operationThreshold) {
log.info(`Operation threshold reached (${this.operationThreshold}), triggering garbage collection`); log.info(`Operation threshold reached (${this.operationThreshold}), triggering garbage collection`);
this.operationCount = 0; // Reset counter this.operationCount = 0; // Reset counter
setTimeout(() => { setTimeout(() => {
this.performGarbageCollection(); this.performGarbageCollection();
}, 100); }, 100);
} }
} }
/** /**
* Resetuje licznik operacji * Resetuje licznik operacji
*/ */
@@ -250,7 +212,6 @@ export class ImageReferenceManager {
this.operationCount = 0; this.operationCount = 0;
log.debug("Operation count reset"); log.debug("Operation count reset");
} }
/** /**
* Ustawia próg operacji dla automatycznego GC * Ustawia próg operacji dla automatycznego GC
* @param {number} threshold - Nowy próg operacji * @param {number} threshold - Nowy próg operacji
@@ -259,7 +220,6 @@ export class ImageReferenceManager {
this.operationThreshold = Math.max(1, threshold); this.operationThreshold = Math.max(1, threshold);
log.info(`Operation threshold set to: ${this.operationThreshold}`); log.info(`Operation threshold set to: ${this.operationThreshold}`);
} }
/** /**
* Ręczne uruchomienie garbage collection * Ręczne uruchomienie garbage collection
*/ */
@@ -267,10 +227,9 @@ export class ImageReferenceManager {
log.info("Manual garbage collection triggered"); log.info("Manual garbage collection triggered");
await this.performGarbageCollection(); await this.performGarbageCollection();
} }
/** /**
* Zwraca statystyki garbage collection * Zwraca statystyki garbage collection
* @returns {Object} Statystyki * @returns {GarbageCollectionStats} Statystyki
*/ */
getStats() { getStats() {
return { return {
@@ -281,7 +240,6 @@ export class ImageReferenceManager {
maxAge: this.maxAge maxAge: this.maxAge
}; };
} }
/** /**
* Czyści wszystkie dane (przy usuwaniu canvas) * Czyści wszystkie dane (przy usuwaniu canvas)
*/ */

View File

@@ -1,18 +1,18 @@
import {createModuleLogger} from "./utils/LoggerUtils.js"; import { createModuleLogger } from "./utils/LoggerUtils.js";
const log = createModuleLogger('Mask_tool'); const log = createModuleLogger('Mask_tool');
export class MaskTool { export class MaskTool {
constructor(canvasInstance, callbacks = {}) { constructor(canvasInstance, callbacks = {}) {
this.canvasInstance = canvasInstance; this.canvasInstance = canvasInstance;
this.mainCanvas = canvasInstance.canvas; this.mainCanvas = canvasInstance.canvas;
this.onStateChange = callbacks.onStateChange || null; this.onStateChange = callbacks.onStateChange || null;
this.maskCanvas = document.createElement('canvas'); 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.x = 0;
this.y = 0; this.y = 0;
this.isOverlayVisible = true; this.isOverlayVisible = true;
this.isActive = false; this.isActive = false;
this.brushSize = 20; this.brushSize = 20;
@@ -20,15 +20,16 @@ export class MaskTool {
this.brushHardness = 0.5; this.brushHardness = 0.5;
this.isDrawing = false; this.isDrawing = false;
this.lastPosition = null; this.lastPosition = null;
this.previewCanvas = document.createElement('canvas'); 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.previewVisible = false;
this.previewCanvasInitialized = false; this.previewCanvasInitialized = false;
this.initMaskCanvas(); this.initMaskCanvas();
} }
initPreviewCanvas() { initPreviewCanvas() {
if (this.previewCanvas.parentElement) { if (this.previewCanvas.parentElement) {
this.previewCanvas.parentElement.removeChild(this.previewCanvas); 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.top = `${this.canvasInstance.canvas.offsetTop}px`;
this.previewCanvas.style.pointerEvents = 'none'; this.previewCanvas.style.pointerEvents = 'none';
this.previewCanvas.style.zIndex = '10'; 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) { setBrushHardness(hardness) {
this.brushHardness = Math.max(0, Math.min(1, hardness)); this.brushHardness = Math.max(0, Math.min(1, hardness));
} }
initMaskCanvas() { initMaskCanvas() {
const extraSpace = 2000; // Allow for a generous drawing area outside the output area const extraSpace = 2000; // Allow for a generous drawing area outside the output area
this.maskCanvas.width = this.canvasInstance.width + extraSpace; this.maskCanvas.width = this.canvasInstance.width + extraSpace;
this.maskCanvas.height = this.canvasInstance.height + extraSpace; this.maskCanvas.height = this.canvasInstance.height + extraSpace;
this.x = -extraSpace / 2; this.x = -extraSpace / 2;
this.y = -extraSpace / 2; this.y = -extraSpace / 2;
this.maskCtx.clearRect(0, 0, this.maskCanvas.width, this.maskCanvas.height); 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})`); log.info(`Initialized mask canvas with extended size: ${this.maskCanvas.width}x${this.maskCanvas.height}, origin at (${this.x}, ${this.y})`);
} }
activate() { activate() {
if (!this.previewCanvasInitialized) { if (!this.previewCanvasInitialized) {
this.initPreviewCanvas(); this.initPreviewCanvas();
@@ -69,131 +65,108 @@ export class MaskTool {
this.isActive = true; this.isActive = true;
this.previewCanvas.style.display = 'block'; this.previewCanvas.style.display = 'block';
this.canvasInstance.interaction.mode = 'drawingMask'; 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.canvasState.saveMaskState();
} }
this.canvasInstance.updateHistoryButtons(); this.canvasInstance.updateHistoryButtons();
log.info("Mask tool activated"); log.info("Mask tool activated");
} }
deactivate() { deactivate() {
this.isActive = false; this.isActive = false;
this.previewCanvas.style.display = 'none'; this.previewCanvas.style.display = 'none';
this.canvasInstance.interaction.mode = 'none'; this.canvasInstance.interaction.mode = 'none';
this.canvasInstance.updateHistoryButtons(); this.canvasInstance.updateHistoryButtons();
log.info("Mask tool deactivated"); log.info("Mask tool deactivated");
} }
setBrushSize(size) { setBrushSize(size) {
this.brushSize = Math.max(1, size); this.brushSize = Math.max(1, size);
} }
setBrushStrength(strength) { setBrushStrength(strength) {
this.brushStrength = Math.max(0, Math.min(1, strength)); this.brushStrength = Math.max(0, Math.min(1, strength));
} }
handleMouseDown(worldCoords, viewCoords) { handleMouseDown(worldCoords, viewCoords) {
if (!this.isActive) return; if (!this.isActive)
return;
this.isDrawing = true; this.isDrawing = true;
this.lastPosition = worldCoords; this.lastPosition = worldCoords;
this.draw(worldCoords); this.draw(worldCoords);
this.clearPreview(); this.clearPreview();
} }
handleMouseMove(worldCoords, viewCoords) { handleMouseMove(worldCoords, viewCoords) {
if (this.isActive) { if (this.isActive) {
this.drawBrushPreview(viewCoords); this.drawBrushPreview(viewCoords);
} }
if (!this.isActive || !this.isDrawing) return; if (!this.isActive || !this.isDrawing)
return;
this.draw(worldCoords); this.draw(worldCoords);
this.lastPosition = worldCoords; this.lastPosition = worldCoords;
} }
handleMouseLeave() { handleMouseLeave() {
this.previewVisible = false; this.previewVisible = false;
this.clearPreview(); this.clearPreview();
} }
handleMouseEnter() { handleMouseEnter() {
this.previewVisible = true; this.previewVisible = true;
} }
handleMouseUp(viewCoords) { handleMouseUp(viewCoords) {
if (!this.isActive) return; if (!this.isActive)
return;
if (this.isDrawing) { if (this.isDrawing) {
this.isDrawing = false; this.isDrawing = false;
this.lastPosition = null; this.lastPosition = null;
if (this.canvasInstance.canvasState) { this.canvasInstance.canvasState.saveMaskState();
this.canvasInstance.canvasState.saveMaskState();
}
if (this.onStateChange) { if (this.onStateChange) {
this.onStateChange(); this.onStateChange();
} }
this.drawBrushPreview(viewCoords); this.drawBrushPreview(viewCoords);
} }
} }
draw(worldCoords) { draw(worldCoords) {
if (!this.lastPosition) { if (!this.lastPosition) {
this.lastPosition = worldCoords; this.lastPosition = worldCoords;
} }
const canvasLastX = this.lastPosition.x - this.x; const canvasLastX = this.lastPosition.x - this.x;
const canvasLastY = this.lastPosition.y - this.y; const canvasLastY = this.lastPosition.y - this.y;
const canvasX = worldCoords.x - this.x; const canvasX = worldCoords.x - this.x;
const canvasY = worldCoords.y - this.y; const canvasY = worldCoords.y - this.y;
const canvasWidth = this.maskCanvas.width; const canvasWidth = this.maskCanvas.width;
const canvasHeight = this.maskCanvas.height; const canvasHeight = this.maskCanvas.height;
if (canvasX >= 0 && canvasX < canvasWidth && if (canvasX >= 0 && canvasX < canvasWidth &&
canvasY >= 0 && canvasY < canvasHeight && canvasY >= 0 && canvasY < canvasHeight &&
canvasLastX >= 0 && canvasLastX < canvasWidth && canvasLastX >= 0 && canvasLastX < canvasWidth &&
canvasLastY >= 0 && canvasLastY < canvasHeight) { canvasLastY >= 0 && canvasLastY < canvasHeight) {
this.maskCtx.beginPath(); this.maskCtx.beginPath();
this.maskCtx.moveTo(canvasLastX, canvasLastY); this.maskCtx.moveTo(canvasLastX, canvasLastY);
this.maskCtx.lineTo(canvasX, canvasY); this.maskCtx.lineTo(canvasX, canvasY);
const gradientRadius = this.brushSize / 2; const gradientRadius = this.brushSize / 2;
if (this.brushHardness === 1) { if (this.brushHardness === 1) {
this.maskCtx.strokeStyle = `rgba(255, 255, 255, ${this.brushStrength})`; this.maskCtx.strokeStyle = `rgba(255, 255, 255, ${this.brushStrength})`;
} else { }
else {
const innerRadius = gradientRadius * this.brushHardness; const innerRadius = gradientRadius * this.brushHardness;
const gradient = this.maskCtx.createRadialGradient( const gradient = this.maskCtx.createRadialGradient(canvasX, canvasY, innerRadius, canvasX, canvasY, gradientRadius);
canvasX, canvasY, innerRadius,
canvasX, canvasY, gradientRadius
);
gradient.addColorStop(0, `rgba(255, 255, 255, ${this.brushStrength})`); gradient.addColorStop(0, `rgba(255, 255, 255, ${this.brushStrength})`);
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`); gradient.addColorStop(1, `rgba(255, 255, 255, 0)`);
this.maskCtx.strokeStyle = gradient; this.maskCtx.strokeStyle = gradient;
} }
this.maskCtx.lineWidth = this.brushSize; this.maskCtx.lineWidth = this.brushSize;
this.maskCtx.lineCap = 'round'; this.maskCtx.lineCap = 'round';
this.maskCtx.lineJoin = 'round'; this.maskCtx.lineJoin = 'round';
this.maskCtx.globalCompositeOperation = 'source-over'; this.maskCtx.globalCompositeOperation = 'source-over';
this.maskCtx.stroke(); this.maskCtx.stroke();
} else { }
else {
log.debug(`Drawing outside mask canvas bounds: (${canvasX}, ${canvasY})`); log.debug(`Drawing outside mask canvas bounds: (${canvasX}, ${canvasY})`);
} }
} }
drawBrushPreview(viewCoords) { drawBrushPreview(viewCoords) {
if (!this.previewVisible || this.isDrawing) { if (!this.previewVisible || this.isDrawing) {
this.clearPreview(); this.clearPreview();
return; return;
} }
this.clearPreview(); this.clearPreview();
const zoom = this.canvasInstance.viewport.zoom; const zoom = this.canvasInstance.viewport.zoom;
const radius = (this.brushSize / 2) * zoom; const radius = (this.brushSize / 2) * zoom;
this.previewCtx.beginPath(); this.previewCtx.beginPath();
this.previewCtx.arc(viewCoords.x, viewCoords.y, radius, 0, 2 * Math.PI); this.previewCtx.arc(viewCoords.x, viewCoords.y, radius, 0, 2 * Math.PI);
this.previewCtx.strokeStyle = 'rgba(255, 255, 255, 0.8)'; this.previewCtx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
@@ -201,27 +174,26 @@ export class MaskTool {
this.previewCtx.setLineDash([2, 4]); this.previewCtx.setLineDash([2, 4]);
this.previewCtx.stroke(); this.previewCtx.stroke();
} }
clearPreview() { clearPreview() {
this.previewCtx.clearRect(0, 0, this.previewCanvas.width, this.previewCanvas.height); this.previewCtx.clearRect(0, 0, this.previewCanvas.width, this.previewCanvas.height);
} }
clear() { clear() {
this.maskCtx.clearRect(0, 0, this.maskCanvas.width, this.maskCanvas.height); this.maskCtx.clearRect(0, 0, this.maskCanvas.width, this.maskCanvas.height);
if (this.isActive && this.canvasInstance.canvasState) { if (this.isActive) {
this.canvasInstance.canvasState.saveMaskState(); this.canvasInstance.canvasState.saveMaskState();
} }
} }
getMask() { getMask() {
return this.maskCanvas; return this.maskCanvas;
} }
getMaskImageWithAlpha() { getMaskImageWithAlpha() {
const tempCanvas = document.createElement('canvas'); const tempCanvas = document.createElement('canvas');
tempCanvas.width = this.maskCanvas.width; tempCanvas.width = this.maskCanvas.width;
tempCanvas.height = this.maskCanvas.height; tempCanvas.height = this.maskCanvas.height;
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); 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); tempCtx.drawImage(this.maskCanvas, 0, 0);
const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height); const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
const data = imageData.data; const data = imageData.data;
@@ -237,7 +209,6 @@ export class MaskTool {
maskImage.src = tempCanvas.toDataURL(); maskImage.src = tempCanvas.toDataURL();
return maskImage; return maskImage;
} }
resize(width, height) { resize(width, height) {
this.initPreviewCanvas(); this.initPreviewCanvas();
const oldMask = this.maskCanvas; const oldMask = this.maskCanvas;
@@ -245,63 +216,46 @@ export class MaskTool {
const oldY = this.y; const oldY = this.y;
const oldWidth = oldMask.width; const oldWidth = oldMask.width;
const oldHeight = oldMask.height; const oldHeight = oldMask.height;
const isIncreasingWidth = width > this.canvasInstance.width;
const isIncreasingWidth = width > (this.canvasInstance.width); const isIncreasingHeight = height > this.canvasInstance.height;
const isIncreasingHeight = height > (this.canvasInstance.height);
this.maskCanvas = document.createElement('canvas'); this.maskCanvas = document.createElement('canvas');
const extraSpace = 2000; const extraSpace = 2000;
const newWidth = isIncreasingWidth ? width + extraSpace : Math.max(oldWidth, width + extraSpace); const newWidth = isIncreasingWidth ? width + extraSpace : Math.max(oldWidth, width + extraSpace);
const newHeight = isIncreasingHeight ? height + extraSpace : Math.max(oldHeight, height + extraSpace); const newHeight = isIncreasingHeight ? height + extraSpace : Math.max(oldHeight, height + extraSpace);
this.maskCanvas.width = newWidth; this.maskCanvas.width = newWidth;
this.maskCanvas.height = newHeight; 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) { if (oldMask.width > 0 && oldMask.height > 0) {
const offsetX = this.x - oldX; const offsetX = this.x - oldX;
const offsetY = this.y - oldY; const offsetY = this.y - oldY;
this.maskCtx.drawImage(oldMask, offsetX, offsetY); this.maskCtx.drawImage(oldMask, offsetX, offsetY);
log.debug(`Preserved mask content with offset (${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(`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'}`); log.info(`Canvas size change: width ${isIncreasingWidth ? 'increased' : 'decreased'}, height ${isIncreasingHeight ? 'increased' : 'decreased'}`);
} }
updatePosition(dx, dy) { updatePosition(dx, dy) {
this.x += dx; this.x += dx;
this.y += dy; this.y += dy;
log.info(`Mask position updated to (${this.x}, ${this.y})`); log.info(`Mask position updated to (${this.x}, ${this.y})`);
} }
toggleOverlayVisibility() { toggleOverlayVisibility() {
this.isOverlayVisible = !this.isOverlayVisible; this.isOverlayVisible = !this.isOverlayVisible;
log.info(`Mask overlay visibility toggled to: ${this.isOverlayVisible}`); log.info(`Mask overlay visibility toggled to: ${this.isOverlayVisible}`);
} }
setMask(image) { setMask(image) {
const destX = -this.x; const destX = -this.x;
const destY = -this.y; const destY = -this.y;
this.maskCtx.clearRect(destX, destY, this.canvasInstance.width, this.canvasInstance.height); this.maskCtx.clearRect(destX, destY, this.canvasInstance.width, this.canvasInstance.height);
this.maskCtx.drawImage(image, destX, destY); this.maskCtx.drawImage(image, destX, destY);
if (this.onStateChange) { if (this.onStateChange) {
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}).`); log.info(`MaskTool updated with a new mask image at correct canvas position (${destX}, ${destY}).`);
} }
} }

405
js/css/canvas_view.css Normal file
View File

@@ -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;
}

View File

@@ -1,21 +1,17 @@
import {createModuleLogger} from "./utils/LoggerUtils.js"; import { createModuleLogger } from "./utils/LoggerUtils.js";
const log = createModuleLogger('db'); const log = createModuleLogger('db');
const DB_NAME = 'CanvasNodeDB'; const DB_NAME = 'CanvasNodeDB';
const STATE_STORE_NAME = 'CanvasState'; const STATE_STORE_NAME = 'CanvasState';
const IMAGE_STORE_NAME = 'CanvasImages'; const IMAGE_STORE_NAME = 'CanvasImages';
const DB_VERSION = 3; const DB_VERSION = 3;
let db = null;
let db;
/** /**
* Funkcja pomocnicza do tworzenia żądań IndexedDB z ujednoliconą obsługą błędów * Funkcja pomocnicza do tworzenia żądań IndexedDB z ujednoliconą obsługą błędów
* @param {IDBObjectStore} store - Store IndexedDB * @param {IDBObjectStore} store - Store IndexedDB
* @param {string} operation - Nazwa operacji (get, put, delete, clear) * @param {DBRequestOperation} operation - Nazwa operacji (get, put, delete, clear)
* @param {*} data - Dane dla operacji (opcjonalne) * @param {any} data - Dane dla operacji (opcjonalne)
* @param {string} errorMessage - Wiadomość błędu * @param {string} errorMessage - Wiadomość błędu
* @returns {Promise} Promise z wynikiem operacji * @returns {Promise<any>} Promise z wynikiem operacji
*/ */
function createDBRequest(store, operation, data, errorMessage) { function createDBRequest(store, operation, data, errorMessage) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -37,130 +33,107 @@ function createDBRequest(store, operation, data, errorMessage) {
reject(new Error(`Unknown operation: ${operation}`)); reject(new Error(`Unknown operation: ${operation}`));
return; return;
} }
request.onerror = (event) => { request.onerror = (event) => {
log.error(errorMessage, event.target.error); log.error(errorMessage, event.target.error);
reject(errorMessage); reject(errorMessage);
}; };
request.onsuccess = (event) => { request.onsuccess = (event) => {
resolve(event.target.result); resolve(event.target.result);
}; };
}); });
} }
function openDB() { function openDB() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (db) { if (db) {
resolve(db); resolve(db);
return; return;
} }
log.info("Opening IndexedDB..."); log.info("Opening IndexedDB...");
const request = indexedDB.open(DB_NAME, DB_VERSION); const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = (event) => { request.onerror = (event) => {
log.error("IndexedDB error:", event.target.error); log.error("IndexedDB error:", event.target.error);
reject("Error opening IndexedDB."); reject("Error opening IndexedDB.");
}; };
request.onsuccess = (event) => { request.onsuccess = (event) => {
db = event.target.result; db = event.target.result;
log.info("IndexedDB opened successfully."); log.info("IndexedDB opened successfully.");
resolve(db); resolve(db);
}; };
request.onupgradeneeded = (event) => { request.onupgradeneeded = (event) => {
log.info("Upgrading IndexedDB..."); log.info("Upgrading IndexedDB...");
const db = event.target.result; const dbInstance = event.target.result;
if (!db.objectStoreNames.contains(STATE_STORE_NAME)) { if (!dbInstance.objectStoreNames.contains(STATE_STORE_NAME)) {
db.createObjectStore(STATE_STORE_NAME, {keyPath: 'id'}); dbInstance.createObjectStore(STATE_STORE_NAME, { keyPath: 'id' });
log.info("Object store created:", STATE_STORE_NAME); log.info("Object store created:", STATE_STORE_NAME);
} }
if (!db.objectStoreNames.contains(IMAGE_STORE_NAME)) { if (!dbInstance.objectStoreNames.contains(IMAGE_STORE_NAME)) {
db.createObjectStore(IMAGE_STORE_NAME, {keyPath: 'imageId'}); dbInstance.createObjectStore(IMAGE_STORE_NAME, { keyPath: 'imageId' });
log.info("Object store created:", IMAGE_STORE_NAME); log.info("Object store created:", IMAGE_STORE_NAME);
} }
}; };
}); });
} }
export async function getCanvasState(id) { export async function getCanvasState(id) {
log.info(`Getting state for id: ${id}`); log.info(`Getting state for id: ${id}`);
const db = await openDB(); const db = await openDB();
const transaction = db.transaction([STATE_STORE_NAME], 'readonly'); const transaction = db.transaction([STATE_STORE_NAME], 'readonly');
const store = transaction.objectStore(STATE_STORE_NAME); const store = transaction.objectStore(STATE_STORE_NAME);
const result = await createDBRequest(store, 'get', id, "Error getting canvas state"); const result = await createDBRequest(store, 'get', id, "Error getting canvas state");
log.debug(`Get success for id: ${id}`, result ? 'found' : 'not found'); log.debug(`Get success for id: ${id}`, result ? 'found' : 'not found');
return result ? result.state : null; return result ? result.state : null;
} }
export async function setCanvasState(id, state) { export async function setCanvasState(id, state) {
log.info(`Setting state for id: ${id}`); log.info(`Setting state for id: ${id}`);
const db = await openDB(); const db = await openDB();
const transaction = db.transaction([STATE_STORE_NAME], 'readwrite'); const transaction = db.transaction([STATE_STORE_NAME], 'readwrite');
const store = transaction.objectStore(STATE_STORE_NAME); 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}`); log.debug(`Set success for id: ${id}`);
} }
export async function removeCanvasState(id) { export async function removeCanvasState(id) {
log.info(`Removing state for id: ${id}`); log.info(`Removing state for id: ${id}`);
const db = await openDB(); const db = await openDB();
const transaction = db.transaction([STATE_STORE_NAME], 'readwrite'); const transaction = db.transaction([STATE_STORE_NAME], 'readwrite');
const store = transaction.objectStore(STATE_STORE_NAME); const store = transaction.objectStore(STATE_STORE_NAME);
await createDBRequest(store, 'delete', id, "Error removing canvas state"); await createDBRequest(store, 'delete', id, "Error removing canvas state");
log.debug(`Remove success for id: ${id}`); log.debug(`Remove success for id: ${id}`);
} }
export async function saveImage(imageId, imageSrc) { export async function saveImage(imageId, imageSrc) {
log.info(`Saving image with id: ${imageId}`); log.info(`Saving image with id: ${imageId}`);
const db = await openDB(); const db = await openDB();
const transaction = db.transaction([IMAGE_STORE_NAME], 'readwrite'); const transaction = db.transaction([IMAGE_STORE_NAME], 'readwrite');
const store = transaction.objectStore(IMAGE_STORE_NAME); 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}`); log.debug(`Image saved successfully for id: ${imageId}`);
} }
export async function getImage(imageId) { export async function getImage(imageId) {
log.info(`Getting image with id: ${imageId}`); log.info(`Getting image with id: ${imageId}`);
const db = await openDB(); const db = await openDB();
const transaction = db.transaction([IMAGE_STORE_NAME], 'readonly'); const transaction = db.transaction([IMAGE_STORE_NAME], 'readonly');
const store = transaction.objectStore(IMAGE_STORE_NAME); const store = transaction.objectStore(IMAGE_STORE_NAME);
const result = await createDBRequest(store, 'get', imageId, "Error getting image"); const result = await createDBRequest(store, 'get', imageId, "Error getting image");
log.debug(`Get image success for id: ${imageId}`, result ? 'found' : 'not found'); log.debug(`Get image success for id: ${imageId}`, result ? 'found' : 'not found');
return result ? result.imageSrc : null; return result ? result.imageSrc : null;
} }
export async function removeImage(imageId) { export async function removeImage(imageId) {
log.info(`Removing image with id: ${imageId}`); log.info(`Removing image with id: ${imageId}`);
const db = await openDB(); const db = await openDB();
const transaction = db.transaction([IMAGE_STORE_NAME], 'readwrite'); const transaction = db.transaction([IMAGE_STORE_NAME], 'readwrite');
const store = transaction.objectStore(IMAGE_STORE_NAME); const store = transaction.objectStore(IMAGE_STORE_NAME);
await createDBRequest(store, 'delete', imageId, "Error removing image"); await createDBRequest(store, 'delete', imageId, "Error removing image");
log.debug(`Remove image success for id: ${imageId}`); log.debug(`Remove image success for id: ${imageId}`);
} }
export async function getAllImageIds() { export async function getAllImageIds() {
log.info("Getting all image IDs..."); log.info("Getting all image IDs...");
const db = await openDB(); const db = await openDB();
const transaction = db.transaction([IMAGE_STORE_NAME], 'readonly'); const transaction = db.transaction([IMAGE_STORE_NAME], 'readonly');
const store = transaction.objectStore(IMAGE_STORE_NAME); const store = transaction.objectStore(IMAGE_STORE_NAME);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const request = store.getAllKeys(); const request = store.getAllKeys();
request.onerror = (event) => { request.onerror = (event) => {
log.error("Error getting all image IDs:", event.target.error); log.error("Error getting all image IDs:", event.target.error);
reject("Error getting all image IDs"); reject("Error getting all image IDs");
}; };
request.onsuccess = (event) => { request.onsuccess = (event) => {
const imageIds = event.target.result; const imageIds = event.target.result;
log.debug(`Found ${imageIds.length} image IDs in database`); log.debug(`Found ${imageIds.length} image IDs in database`);
@@ -168,13 +141,11 @@ export async function getAllImageIds() {
}; };
}); });
} }
export async function clearAllCanvasStates() { export async function clearAllCanvasStates() {
log.info("Clearing all canvas states..."); log.info("Clearing all canvas states...");
const db = await openDB(); const db = await openDB();
const transaction = db.transaction([STATE_STORE_NAME], 'readwrite'); const transaction = db.transaction([STATE_STORE_NAME], 'readwrite');
const store = transaction.objectStore(STATE_STORE_NAME); const store = transaction.objectStore(STATE_STORE_NAME);
await createDBRequest(store, 'clear', null, "Error clearing canvas states"); await createDBRequest(store, 'clear', null, "Error clearing canvas states");
log.info("All canvas states cleared successfully."); log.info("All canvas states cleared successfully.");
} }

View File

@@ -8,6 +8,20 @@
* - Możliwość zapisywania logów do localStorage * - Możliwość zapisywania logów do localStorage
* - Możliwość eksportu logów * - 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 = { export const LogLevel = {
DEBUG: 0, DEBUG: 0,
INFO: 1, INFO: 1,
@@ -36,25 +50,22 @@ const LEVEL_NAMES = {
[LogLevel.WARN]: 'WARN', [LogLevel.WARN]: 'WARN',
[LogLevel.ERROR]: 'ERROR', [LogLevel.ERROR]: 'ERROR',
}; };
class Logger { class Logger {
constructor() { constructor() {
this.config = {...DEFAULT_CONFIG}; this.config = { ...DEFAULT_CONFIG };
this.logs = []; this.logs = [];
this.enabled = true; this.enabled = true;
this.loadConfig(); this.loadConfig();
} }
/** /**
* Konfiguracja loggera * Konfiguracja loggera
* @param {Object} config - Obiekt konfiguracyjny * @param {Partial<LoggerConfig>} config - Obiekt konfiguracyjny
*/ */
configure(config) { configure(config) {
this.config = {...this.config, ...config}; this.config = { ...this.config, ...config };
this.saveConfig(); this.saveConfig();
return this; return this;
} }
/** /**
* Włącz/wyłącz logger globalnie * Włącz/wyłącz logger globalnie
* @param {boolean} enabled - Czy logger ma być włączony * @param {boolean} enabled - Czy logger ma być włączony
@@ -63,42 +74,39 @@ class Logger {
this.enabled = enabled; this.enabled = enabled;
return this; return this;
} }
/** /**
* Ustaw globalny poziom logowania * Ustaw globalny poziom logowania
* @param {LogLevel} level - Poziom logowania * @param {LogLevels} level - Poziom logowania
*/ */
setGlobalLevel(level) { setGlobalLevel(level) {
this.config.globalLevel = level; this.config.globalLevel = level;
this.saveConfig(); this.saveConfig();
return this; return this;
} }
/** /**
* Ustaw poziom logowania dla konkretnego modułu * Ustaw poziom logowania dla konkretnego modułu
* @param {string} module - Nazwa modułu * @param {string} module - Nazwa modułu
* @param {LogLevel} level - Poziom logowania * @param {LogLevels} level - Poziom logowania
*/ */
setModuleLevel(module, level) { setModuleLevel(module, level) {
this.config.moduleSettings[module] = level; this.config.moduleSettings[module] = level;
this.saveConfig(); this.saveConfig();
return this; return this;
} }
/** /**
* Sprawdź, czy dany poziom logowania jest aktywny dla modułu * Sprawdź, czy dany poziom logowania jest aktywny dla modułu
* @param {string} module - Nazwa 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 * @returns {boolean} - Czy poziom jest aktywny
*/ */
isLevelEnabled(module, level) { isLevelEnabled(module, level) {
if (!this.enabled) return false; if (!this.enabled)
return false;
if (this.config.moduleSettings[module] !== undefined) { if (this.config.moduleSettings[module] !== undefined) {
return level >= this.config.moduleSettings[module]; return level >= this.config.moduleSettings[module];
} }
return level >= this.config.globalLevel; return level >= this.config.globalLevel;
} }
/** /**
* Formatuj znacznik czasu * Formatuj znacznik czasu
* @returns {string} - Sformatowany znacznik czasu * @returns {string} - Sformatowany znacznik czasu
@@ -107,21 +115,20 @@ class Logger {
const now = new Date(); const now = new Date();
const format = this.config.timestampFormat; const format = this.config.timestampFormat;
return format return format
.replace('HH', String(now.getHours()).padStart(2, '0')) .replace('HH', padStart(String(now.getHours()), 2, '0'))
.replace('mm', String(now.getMinutes()).padStart(2, '0')) .replace('mm', padStart(String(now.getMinutes()), 2, '0'))
.replace('ss', String(now.getSeconds()).padStart(2, '0')) .replace('ss', padStart(String(now.getSeconds()), 2, '0'))
.replace('SSS', String(now.getMilliseconds()).padStart(3, '0')); .replace('SSS', padStart(String(now.getMilliseconds()), 3, '0'));
} }
/** /**
* Zapisz log * Zapisz log
* @param {string} module - Nazwa modułu * @param {string} module - Nazwa modułu
* @param {LogLevel} level - Poziom logowania * @param {LogLevels} level - Poziom logowania
* @param {Array} args - Argumenty do zalogowania * @param {any[]} args - Argumenty do zalogowania
*/ */
log(module, level, ...args) { log(module, level, ...args) {
if (!this.isLevelEnabled(module, level)) return; if (!this.isLevelEnabled(module, level))
return;
const timestamp = this.formatTimestamp(); const timestamp = this.formatTimestamp();
const levelName = LEVEL_NAMES[level]; const levelName = LEVEL_NAMES[level];
const logData = { const logData = {
@@ -141,13 +148,12 @@ class Logger {
} }
this.printToConsole(logData); this.printToConsole(logData);
} }
/** /**
* Wyświetl log w konsoli * Wyświetl log w konsoli
* @param {Object} logData - Dane logu * @param {LogData} logData - Dane logu
*/ */
printToConsole(logData) { printToConsole(logData) {
const {timestamp, module, level, levelName, args} = logData; const { timestamp, module, level, levelName, args } = logData;
const prefix = `[${timestamp}] [${module}] [${levelName}]`; const prefix = `[${timestamp}] [${module}] [${levelName}]`;
if (this.config.useColors && typeof console.log === 'function') { if (this.config.useColors && typeof console.log === 'function') {
const color = COLORS[level] || '#000000'; const color = COLORS[level] || '#000000';
@@ -156,36 +162,35 @@ class Logger {
} }
console.log(prefix, ...args); console.log(prefix, ...args);
} }
/** /**
* Zapisz logi do localStorage * Zapisz logi do localStorage
*/ */
saveLogs() { saveLogs() {
if (typeof localStorage !== 'undefined' && this.config.saveToStorage) { if (typeof localStorage !== 'undefined' && this.config.saveToStorage) {
try { try {
const simplifiedLogs = this.logs.map(log => ({ const simplifiedLogs = this.logs.map((log) => ({
t: log.timestamp, t: log.timestamp,
m: log.module, m: log.module,
l: log.level, l: log.level,
a: log.args.map(arg => { a: log.args.map((arg) => {
if (typeof arg === 'object') { if (typeof arg === 'object') {
try { try {
return JSON.stringify(arg); return JSON.stringify(arg);
} catch (e) { }
catch (e) {
return String(arg); return String(arg);
} }
} }
return arg; return arg;
}) })
})); }));
localStorage.setItem(this.config.storageKey, JSON.stringify(simplifiedLogs)); localStorage.setItem(this.config.storageKey, JSON.stringify(simplifiedLogs));
} catch (e) { }
catch (e) {
console.error('Failed to save logs to localStorage:', e); console.error('Failed to save logs to localStorage:', e);
} }
} }
} }
/** /**
* Załaduj logi z localStorage * Załaduj logi z localStorage
*/ */
@@ -196,12 +201,12 @@ class Logger {
if (storedLogs) { if (storedLogs) {
this.logs = JSON.parse(storedLogs); this.logs = JSON.parse(storedLogs);
} }
} catch (e) { }
catch (e) {
console.error('Failed to load logs from localStorage:', e); console.error('Failed to load logs from localStorage:', e);
} }
} }
} }
/** /**
* Zapisz konfigurację do localStorage * Zapisz konfigurację do localStorage
*/ */
@@ -209,12 +214,12 @@ class Logger {
if (typeof localStorage !== 'undefined') { if (typeof localStorage !== 'undefined') {
try { try {
localStorage.setItem('layerforge_logger_config', JSON.stringify(this.config)); localStorage.setItem('layerforge_logger_config', JSON.stringify(this.config));
} catch (e) { }
catch (e) {
console.error('Failed to save logger config to localStorage:', e); console.error('Failed to save logger config to localStorage:', e);
} }
} }
} }
/** /**
* Załaduj konfigurację z localStorage * Załaduj konfigurację z localStorage
*/ */
@@ -223,14 +228,14 @@ class Logger {
try { try {
const storedConfig = localStorage.getItem('layerforge_logger_config'); const storedConfig = localStorage.getItem('layerforge_logger_config');
if (storedConfig) { 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); console.error('Failed to load logger config from localStorage:', e);
} }
} }
} }
/** /**
* Wyczyść wszystkie logi * Wyczyść wszystkie logi
*/ */
@@ -241,33 +246,29 @@ class Logger {
} }
return this; return this;
} }
/** /**
* Eksportuj logi do pliku * Eksportuj logi do pliku
* @param {string} format - Format eksportu ('json' lub 'txt') * @param {'json' | 'txt'} format - Format eksportu
*/ */
exportLogs(format = 'json') { exportLogs(format = 'json') {
if (this.logs.length === 0) { if (this.logs.length === 0) {
console.warn('No logs to export'); console.warn('No logs to export');
return; return;
} }
let content; let content;
let mimeType; let mimeType;
let extension; let extension;
if (format === 'json') { if (format === 'json') {
content = JSON.stringify(this.logs, null, 2); content = JSON.stringify(this.logs, null, 2);
mimeType = 'application/json'; mimeType = 'application/json';
extension = 'json'; extension = 'json';
} else { }
content = this.logs.map(log => else {
`[${log.timestamp}] [${log.module}] [${log.levelName}] ${log.args.join(' ')}` content = this.logs.map((log) => `[${log.timestamp}] [${log.module}] [${log.levelName}] ${log.args.join(' ')}`).join('\n');
).join('\n');
mimeType = 'text/plain'; mimeType = 'text/plain';
extension = 'txt'; extension = 'txt';
} }
const blob = new Blob([content], {type: mimeType}); const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
@@ -277,44 +278,39 @@ class Logger {
document.body.removeChild(a); document.body.removeChild(a);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
/** /**
* Log na poziomie DEBUG * Log na poziomie DEBUG
* @param {string} module - Nazwa modułu * @param {string} module - Nazwa modułu
* @param {...any} args - Argumenty do zalogowania * @param {any[]} args - Argumenty do zalogowania
*/ */
debug(module, ...args) { debug(module, ...args) {
this.log(module, LogLevel.DEBUG, ...args); this.log(module, LogLevel.DEBUG, ...args);
} }
/** /**
* Log na poziomie INFO * Log na poziomie INFO
* @param {string} module - Nazwa modułu * @param {string} module - Nazwa modułu
* @param {...any} args - Argumenty do zalogowania * @param {any[]} args - Argumenty do zalogowania
*/ */
info(module, ...args) { info(module, ...args) {
this.log(module, LogLevel.INFO, ...args); this.log(module, LogLevel.INFO, ...args);
} }
/** /**
* Log na poziomie WARN * Log na poziomie WARN
* @param {string} module - Nazwa modułu * @param {string} module - Nazwa modułu
* @param {...any} args - Argumenty do zalogowania * @param {any[]} args - Argumenty do zalogowania
*/ */
warn(module, ...args) { warn(module, ...args) {
this.log(module, LogLevel.WARN, ...args); this.log(module, LogLevel.WARN, ...args);
} }
/** /**
* Log na poziomie ERROR * Log na poziomie ERROR
* @param {string} module - Nazwa modułu * @param {string} module - Nazwa modułu
* @param {...any} args - Argumenty do zalogowania * @param {any[]} args - Argumenty do zalogowania
*/ */
error(module, ...args) { error(module, ...args) {
this.log(module, LogLevel.ERROR, ...args); this.log(module, LogLevel.ERROR, ...args);
} }
} }
export const logger = new Logger(); export const logger = new Logger();
export const debug = (module, ...args) => logger.debug(module, ...args); export const debug = (module, ...args) => logger.debug(module, ...args);
export const info = (module, ...args) => logger.info(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') { if (typeof window !== 'undefined') {
window.LayerForgeLogger = logger; window.LayerForgeLogger = logger;
} }
export default logger;
export default logger;

View File

@@ -1,19 +1,15 @@
"use strict";
console.log('[StateWorker] Worker script loaded and running.'); console.log('[StateWorker] Worker script loaded and running.');
const DB_NAME = 'CanvasNodeDB'; const DB_NAME = 'CanvasNodeDB';
const STATE_STORE_NAME = 'CanvasState'; const STATE_STORE_NAME = 'CanvasState';
const DB_VERSION = 3; const DB_VERSION = 3;
let db; let db;
function log(...args) { function log(...args) {
console.log('[StateWorker]', ...args); console.log('[StateWorker]', ...args);
} }
function error(...args) { function error(...args) {
console.error('[StateWorker]', ...args); console.error('[StateWorker]', ...args);
} }
function createDBRequest(store, operation, data, errorMessage) { function createDBRequest(store, operation, data, errorMessage) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let request; let request;
@@ -25,69 +21,59 @@ function createDBRequest(store, operation, data, errorMessage) {
reject(new Error(`Unknown operation: ${operation}`)); reject(new Error(`Unknown operation: ${operation}`));
return; return;
} }
request.onerror = (event) => { request.onerror = (event) => {
error(errorMessage, event.target.error); error(errorMessage, event.target.error);
reject(errorMessage); reject(errorMessage);
}; };
request.onsuccess = (event) => { request.onsuccess = (event) => {
resolve(event.target.result); resolve(event.target.result);
}; };
}); });
} }
function openDB() { function openDB() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (db) { if (db) {
resolve(db); resolve(db);
return; return;
} }
const request = indexedDB.open(DB_NAME, DB_VERSION); const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = (event) => { request.onerror = (event) => {
error("IndexedDB error:", event.target.error); error("IndexedDB error:", event.target.error);
reject("Error opening IndexedDB."); reject("Error opening IndexedDB.");
}; };
request.onsuccess = (event) => { request.onsuccess = (event) => {
db = event.target.result; db = event.target.result;
log("IndexedDB opened successfully in worker."); log("IndexedDB opened successfully in worker.");
resolve(db); resolve(db);
}; };
request.onupgradeneeded = (event) => { request.onupgradeneeded = (event) => {
log("Upgrading IndexedDB in worker..."); log("Upgrading IndexedDB in worker...");
const tempDb = event.target.result; const tempDb = event.target.result;
if (!tempDb.objectStoreNames.contains(STATE_STORE_NAME)) { 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) { async function setCanvasState(id, state) {
const db = await openDB(); const db = await openDB();
const transaction = db.transaction([STATE_STORE_NAME], 'readwrite'); const transaction = db.transaction([STATE_STORE_NAME], 'readwrite');
const store = transaction.objectStore(STATE_STORE_NAME); 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'); log('Message received from main thread:', e.data ? 'data received' : 'no data');
const { state, nodeId } = e.data; const { state, nodeId } = e.data;
if (!state || !nodeId) { if (!state || !nodeId) {
error('Invalid data received from main thread'); error('Invalid data received from main thread');
return; return;
} }
try { try {
log(`Saving state for node: ${nodeId}`); log(`Saving state for node: ${nodeId}`);
await setCanvasState(nodeId, state); await setCanvasState(nodeId, state);
log(`State saved successfully for node: ${nodeId}`); log(`State saved successfully for node: ${nodeId}`);
} catch (err) { }
catch (err) {
error(`Failed to save state for node: ${nodeId}`, err); error(`Failed to save state for node: ${nodeId}`, err);
} }
}; };

View File

@@ -0,0 +1,13 @@
<h4>📋 ComfyUI Clipspace Mode</h4>
<table>
<tr><td><kbd>Ctrl + C</kbd></td><td>Copy selected layers to internal clipboard + <strong>ComfyUI Clipspace</strong> as flattened image</td></tr>
<tr><td><kbd>Ctrl + V</kbd></td><td><strong>Priority:</strong></td></tr>
<tr><td></td><td>1⃣ Internal clipboard (copied layers)</td></tr>
<tr><td></td><td>2⃣ ComfyUI Clipspace (workflow images)</td></tr>
<tr><td></td><td>3⃣ System clipboard (fallback)</td></tr>
<tr><td><kbd>Paste Image</kbd></td><td>Same as Ctrl+V but respects fit_on_add setting</td></tr>
<tr><td><kbd>Drag & Drop</kbd></td><td>Load images directly from files</td></tr>
</table>
<div style="margin-top: 8px; padding: 6px; background: rgba(0,0,0,0.2); border-radius: 4px; font-size: 11px;">
💡 <strong>Bestt for:</strong> ComfyUI workflow integration and node-to-node image transfer
</div>

View File

@@ -0,0 +1,9 @@
<h4>Mask Mode</h4>
<table>
<tr><td><kbd>Click + Drag</kbd></td><td>Paint on the mask</td></tr>
<tr><td><kbd>Middle Mouse Button + Drag</kbd></td><td>Pan canvas view</td></tr>
<tr><td><kbd>Mouse Wheel</kbd></td><td>Zoom view in/out</td></tr>
<tr><td><strong>Brush Controls</strong></td><td>Use sliders to control brush <strong>Size</strong>, <strong>Strength</strong>, and <strong>Hardness</strong></td></tr>
<tr><td><strong>Clear Mask</strong></td><td>Remove the entire mask</td></tr>
<tr><td><strong>Exit Mode</strong></td><td>Click the "Draw Mask" button again</td></tr>
</table>

View File

@@ -0,0 +1,40 @@
<h4>Canvas Control</h4>
<table>
<tr><td><kbd>Click + Drag</kbd></td><td>Pan canvas view</td></tr>
<tr><td><kbd>Mouse Wheel</kbd></td><td>Zoom view in/out</td></tr>
<tr><td><kbd>Shift + Click (background)</kbd></td><td>Start resizing canvas area</td></tr>
<tr><td><kbd>Shift + Ctrl + Click</kbd></td><td>Start moving entire canvas</td></tr>
<tr><td><kbd>Single Click (background)</kbd></td><td>Deselect all layers</td></tr>
</table>
<h4>Clipboard & I/O</h4>
<table>
<tr><td><kbd>Ctrl + C</kbd></td><td>Copy selected layer(s)</td></tr>
<tr><td><kbd>Ctrl + V</kbd></td><td>Paste from clipboard (image or internal layers)</td></tr>
<tr><td><kbd>Drag & Drop Image File</kbd></td><td>Add image as a new layer</td></tr>
</table>
<h4>Layer Interaction</h4>
<table>
<tr><td><kbd>Click + Drag</kbd></td><td>Move selected layer(s)</td></tr>
<tr><td><kbd>Ctrl + Click</kbd></td><td>Add/Remove layer from selection</td></tr>
<tr><td><kbd>Alt + Drag</kbd></td><td>Clone selected layer(s)</td></tr>
<tr><td><kbd>Right Click</kbd></td><td>Show blend mode & opacity menu</td></tr>
<tr><td><kbd>Mouse Wheel</kbd></td><td>Scale layer (snaps to grid)</td></tr>
<tr><td><kbd>Ctrl + Mouse Wheel</kbd></td><td>Fine-scale layer</td></tr>
<tr><td><kbd>Shift + Mouse Wheel</kbd></td><td>Rotate layer by 5° steps</td></tr>
<tr><td><kbd>Shift + Ctrl + Mouse Wheel</kbd></td><td>Snap rotation to 5° increments</td></tr>
<tr><td><kbd>Arrow Keys</kbd></td><td>Nudge layer by 1px</td></tr>
<tr><td><kbd>Shift + Arrow Keys</kbd></td><td>Nudge layer by 10px</td></tr>
<tr><td><kbd>[</kbd> or <kbd>]</kbd></td><td>Rotate by 1°</td></tr>
<tr><td><kbd>Shift + [</kbd> or <kbd>]</kbd></td><td>Rotate by 10°</td></tr>
<tr><td><kbd>Delete</kbd></td><td>Delete selected layer(s)</td></tr>
</table>
<h4>Transform Handles (on selected layer)</h4>
<table>
<tr><td><kbd>Drag Corner/Side</kbd></td><td>Resize layer</td></tr>
<tr><td><kbd>Drag Rotation Handle</kbd></td><td>Rotate layer</td></tr>
<tr><td><kbd>Hold Shift</kbd></td><td>Keep aspect ratio / Snap rotation to 15°</td></tr>
<tr><td><kbd>Hold Ctrl</kbd></td><td>Snap to grid</td></tr>
</table>

View File

@@ -0,0 +1,16 @@
<h4>📋 System Clipboard Mode</h4>
<table>
<tr><td><kbd>Ctrl + C</kbd></td><td>Copy selected layers to internal clipboard + <strong>system clipboard</strong> as flattened image</td></tr>
<tr><td><kbd>Ctrl + V</kbd></td><td><strong>Priority:</strong></td></tr>
<tr><td></td><td>1⃣ Internal clipboard (copied layers)</td></tr>
<tr><td></td><td>2⃣ System clipboard (images, screenshots)</td></tr>
<tr><td></td><td>3⃣ System clipboard (file paths, URLs)</td></tr>
<tr><td><kbd>Paste Image</kbd></td><td>Same as Ctrl+V but respects fit_on_add setting</td></tr>
<tr><td><kbd>Drag & Drop</kbd></td><td>Load images directly from files</td></tr>
</table>
<div style="margin-top: 8px; padding: 6px; background: rgba(255,165,0,0.2); border: 1px solid rgba(255,165,0,0.4); border-radius: 4px; font-size: 11px;">
⚠️ <strong>Security Note:</strong> "Paste Image" button for external images may not work due to browser security restrictions. Use Ctrl+V instead or Drag & Drop.
</div>
<div style="margin-top: 8px; padding: 6px; background: rgba(0,0,0,0.2); border-radius: 4px; font-size: 11px;">
💡 <strong>Best for:</strong> Working with screenshots, copied images, file paths, and urls.
</div>

1
js/types.js Normal file
View File

@@ -0,0 +1 @@
export {};

View File

@@ -1,31 +1,28 @@
import {createModuleLogger} from "./LoggerUtils.js"; import { createModuleLogger } from "./LoggerUtils.js";
import {api} from "../../../scripts/api.js"; // @ts-ignore
import {ComfyApp} from "../../../scripts/app.js"; import { api } from "../../../scripts/api.js";
// @ts-ignore
import { ComfyApp } from "../../../scripts/app.js";
const log = createModuleLogger('ClipboardManager'); const log = createModuleLogger('ClipboardManager');
export class ClipboardManager { export class ClipboardManager {
constructor(canvas) { constructor(canvas) {
this.canvas = canvas; this.canvas = canvas;
this.clipboardPreference = 'system'; // 'system', 'clipspace' this.clipboardPreference = 'system'; // 'system', 'clipspace'
} }
/** /**
* Main paste handler that delegates to appropriate methods * Main paste handler that delegates to appropriate methods
* @param {string} addMode - The mode for adding the layer * @param {AddMode} addMode - The mode for adding the layer
* @param {string} preference - Clipboard preference ('system' or 'clipspace') * @param {ClipboardPreference} preference - Clipboard preference ('system' or 'clipspace')
* @returns {Promise<boolean>} - True if successful, false otherwise * @returns {Promise<boolean>} - True if successful, false otherwise
*/ */
async handlePaste(addMode = 'mouse', preference = 'system') { async handlePaste(addMode = 'mouse', preference = 'system') {
try { try {
log.info(`ClipboardManager handling paste with preference: ${preference}`); log.info(`ClipboardManager handling paste with preference: ${preference}`);
if (this.canvas.canvasLayers.internalClipboard.length > 0) { if (this.canvas.canvasLayers.internalClipboard.length > 0) {
log.info("Found layers in internal clipboard, pasting layers"); log.info("Found layers in internal clipboard, pasting layers");
this.canvas.canvasLayers.pasteLayers(); this.canvas.canvasLayers.pasteLayers();
return true; return true;
} }
if (preference === 'clipspace') { if (preference === 'clipspace') {
log.info("Attempting paste from ComfyUI Clipspace"); log.info("Attempting paste from ComfyUI Clipspace");
const success = await this.tryClipspacePaste(addMode); const success = await this.tryClipspacePaste(addMode);
@@ -34,26 +31,23 @@ export class ClipboardManager {
} }
log.info("No image found in ComfyUI Clipspace"); log.info("No image found in ComfyUI Clipspace");
} }
log.info("Attempting paste from system clipboard"); log.info("Attempting paste from system clipboard");
return await this.trySystemClipboardPaste(addMode); return await this.trySystemClipboardPaste(addMode);
}
} catch (err) { catch (err) {
log.error("ClipboardManager paste operation failed:", err); log.error("ClipboardManager paste operation failed:", err);
return false; return false;
} }
} }
/** /**
* Attempts to paste from ComfyUI Clipspace * 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<boolean>} - True if successful, false otherwise * @returns {Promise<boolean>} - True if successful, false otherwise
*/ */
async tryClipspacePaste(addMode) { async tryClipspacePaste(addMode) {
try { try {
log.info("Attempting to paste from ComfyUI Clipspace"); 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) { if (this.canvas.node.imgs && this.canvas.node.imgs.length > 0) {
const clipspaceImage = this.canvas.node.imgs[0]; const clipspaceImage = this.canvas.node.imgs[0];
if (clipspaceImage && clipspaceImage.src) { if (clipspaceImage && clipspaceImage.src) {
@@ -67,27 +61,24 @@ export class ClipboardManager {
} }
} }
return false; return false;
} catch (clipspaceError) { }
catch (clipspaceError) {
log.warn("ComfyUI Clipspace paste failed:", clipspaceError); log.warn("ComfyUI Clipspace paste failed:", clipspaceError);
return false; return false;
} }
} }
/** /**
* System clipboard paste - handles both image data and text paths * 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<boolean>} - True if successful, false otherwise * @returns {Promise<boolean>} - True if successful, false otherwise
*/ */
async trySystemClipboardPaste(addMode) { async trySystemClipboardPaste(addMode) {
log.info("ClipboardManager: Checking system clipboard for images and paths"); log.info("ClipboardManager: Checking system clipboard for images and paths");
if (navigator.clipboard?.read) { if (navigator.clipboard?.read) {
try { try {
const clipboardItems = await navigator.clipboard.read(); const clipboardItems = await navigator.clipboard.read();
for (const item of clipboardItems) { for (const item of clipboardItems) {
log.debug("Clipboard item types:", item.types); log.debug("Clipboard item types:", item.types);
const imageType = item.types.find(type => type.startsWith('image/')); const imageType = item.types.find(type => type.startsWith('image/'));
if (imageType) { if (imageType) {
try { try {
@@ -99,23 +90,24 @@ export class ClipboardManager {
log.info("Successfully loaded image from system clipboard"); log.info("Successfully loaded image from system clipboard");
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode); await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
}; };
img.src = event.target.result; if (event.target?.result) {
img.src = event.target.result;
}
}; };
reader.readAsDataURL(blob); reader.readAsDataURL(blob);
log.info("Found image data in system clipboard"); log.info("Found image data in system clipboard");
return true; return true;
} catch (error) { }
catch (error) {
log.debug("Error reading image data:", error); log.debug("Error reading image data:", error);
} }
} }
const textTypes = ['text/plain', 'text/uri-list']; const textTypes = ['text/plain', 'text/uri-list'];
for (const textType of textTypes) { for (const textType of textTypes) {
if (item.types.includes(textType)) { if (item.types.includes(textType)) {
try { try {
const textBlob = await item.getType(textType); const textBlob = await item.getType(textType);
const text = await textBlob.text(); const text = await textBlob.text();
if (this.isValidImagePath(text)) { if (this.isValidImagePath(text)) {
log.info("Found image path in clipboard:", text); log.info("Found image path in clipboard:", text);
const success = await this.loadImageFromPath(text, addMode); const success = await this.loadImageFromPath(text, addMode);
@@ -123,22 +115,22 @@ export class ClipboardManager {
return true; return true;
} }
} }
} catch (error) { }
catch (error) {
log.debug(`Error reading ${textType}:`, error); log.debug(`Error reading ${textType}:`, error);
} }
} }
} }
} }
} catch (error) { }
catch (error) {
log.debug("Modern clipboard API failed:", error); log.debug("Modern clipboard API failed:", error);
} }
} }
if (navigator.clipboard?.readText) { if (navigator.clipboard?.readText) {
try { try {
const text = await navigator.clipboard.readText(); const text = await navigator.clipboard.readText();
log.debug("Found text in clipboard:", text); log.debug("Found text in clipboard:", text);
if (text && this.isValidImagePath(text)) { if (text && this.isValidImagePath(text)) {
log.info("Found valid image path in clipboard:", text); log.info("Found valid image path in clipboard:", text);
const success = await this.loadImageFromPath(text, addMode); const success = await this.loadImageFromPath(text, addMode);
@@ -146,16 +138,14 @@ export class ClipboardManager {
return true; return true;
} }
} }
} catch (error) { }
catch (error) {
log.debug("Could not read text from clipboard:", error); log.debug("Could not read text from clipboard:", error);
} }
} }
log.debug("No images or valid image paths found in system clipboard"); log.debug("No images or valid image paths found in system clipboard");
return false; return false;
} }
/** /**
* Validates if a text string is a valid image file path or URL * Validates if a text string is a valid image file path or URL
* @param {string} text - The text to validate * @param {string} text - The text to validate
@@ -165,67 +155,53 @@ export class ClipboardManager {
if (!text || typeof text !== 'string') { if (!text || typeof text !== 'string') {
return false; return false;
} }
text = text.trim(); text = text.trim();
if (!text) { if (!text) {
return false; return false;
} }
if (text.startsWith('http://') || text.startsWith('https://') || text.startsWith('file://')) { if (text.startsWith('http://') || text.startsWith('https://') || text.startsWith('file://')) {
try { try {
new URL(text); new URL(text);
log.debug("Detected valid URL:", text); log.debug("Detected valid URL:", text);
return true; return true;
} catch (e) { }
catch (e) {
log.debug("Invalid URL format:", text); log.debug("Invalid URL format:", text);
return false; return false;
} }
} }
const imageExtensions = [ const imageExtensions = [
'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp',
'.svg', '.tiff', '.tif', '.ico', '.avif' '.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) { if (!hasImageExtension) {
log.debug("No valid image extension found in:", text); log.debug("No valid image extension found in:", text);
return false; return false;
} }
const pathPatterns = [ const pathPatterns = [
/^[a-zA-Z]:[\\\/]/, // Windows absolute path (C:\... or C:/...) /^[a-zA-Z]:[\\\/]/, // Windows absolute path (C:\... or C:/...)
/^[\\\/]/, // Unix absolute path (/...) /^[\\\/]/, // Unix absolute path (/...)
/^\.{1,2}[\\\/]/, // Relative path (./... or ../...) /^\.{1,2}[\\\/]/, // Relative path (./... or ../...)
/^[^\\\/]*[\\\/]/ // Contains path separators /^[^\\\/]*[\\\/]/ // Contains path separators
]; ];
const isValidPath = pathPatterns.some(pattern => pattern.test(text)) ||
const isValidPath = pathPatterns.some(pattern => pattern.test(text)) || (!text.includes('/') && !text.includes('\\') && text.includes('.')); // Simple filename
(!text.includes('/') && !text.includes('\\') && text.includes('.')); // Simple filename
if (isValidPath) { if (isValidPath) {
log.debug("Detected valid local file path:", text); log.debug("Detected valid local file path:", text);
} else { }
else {
log.debug("Invalid local file path format:", text); log.debug("Invalid local file path format:", text);
} }
return isValidPath; return isValidPath;
} }
/** /**
* Attempts to load an image from a file path using simplified methods * Attempts to load an image from a file path using simplified methods
* @param {string} filePath - The file path to load * @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<boolean>} - True if successful, false otherwise * @returns {Promise<boolean>} - True if successful, false otherwise
*/ */
async loadImageFromPath(filePath, addMode) { async loadImageFromPath(filePath, addMode) {
if (filePath.startsWith('http://') || filePath.startsWith('https://')) { if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
try { try {
const img = new Image(); const img = new Image();
@@ -242,46 +218,44 @@ export class ClipboardManager {
}; };
img.src = filePath; img.src = filePath;
}); });
} catch (error) { }
catch (error) {
log.warn("Error loading image from URL:", error); log.warn("Error loading image from URL:", error);
return false; return false;
} }
} }
try { try {
log.info("Attempting to load local file via backend"); log.info("Attempting to load local file via backend");
const success = await this.loadFileViaBackend(filePath, addMode); const success = await this.loadFileViaBackend(filePath, addMode);
if (success) { if (success) {
return true; return true;
} }
} catch (error) { }
catch (error) {
log.warn("Backend loading failed:", error); log.warn("Backend loading failed:", error);
} }
try { try {
log.info("Falling back to file picker"); log.info("Falling back to file picker");
const success = await this.promptUserForFile(filePath, addMode); const success = await this.promptUserForFile(filePath, addMode);
if (success) { if (success) {
return true; return true;
} }
} catch (error) { }
catch (error) {
log.warn("File picker failed:", error); log.warn("File picker failed:", error);
} }
this.showFilePathMessage(filePath); this.showFilePathMessage(filePath);
return false; return false;
} }
/** /**
* Loads a local file via the ComfyUI backend endpoint * Loads a local file via the ComfyUI backend endpoint
* @param {string} filePath - The file path to load * @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<boolean>} - True if successful, false otherwise * @returns {Promise<boolean>} - True if successful, false otherwise
*/ */
async loadFileViaBackend(filePath, addMode) { async loadFileViaBackend(filePath, addMode) {
try { try {
log.info("Loading file via ComfyUI backend:", filePath); log.info("Loading file via ComfyUI backend:", filePath);
const response = await api.fetchApi("/ycnode/load_image_from_path", { const response = await api.fetchApi("/ycnode/load_image_from_path", {
method: "POST", method: "POST",
headers: { headers: {
@@ -291,22 +265,17 @@ export class ClipboardManager {
file_path: filePath file_path: filePath
}) })
}); });
if (!response.ok) { if (!response.ok) {
const errorData = await response.json(); const errorData = await response.json();
log.debug("Backend failed to load image:", errorData.error); log.debug("Backend failed to load image:", errorData.error);
return false; return false;
} }
const data = await response.json(); const data = await response.json();
if (!data.success) { if (!data.success) {
log.debug("Backend returned error:", data.error); log.debug("Backend returned error:", data.error);
return false; return false;
} }
log.info("Successfully loaded image via ComfyUI backend:", filePath); log.info("Successfully loaded image via ComfyUI backend:", filePath);
const img = new Image(); const img = new Image();
const success = await new Promise((resolve) => { const success = await new Promise((resolve) => {
img.onload = async () => { img.onload = async () => {
@@ -318,36 +287,31 @@ export class ClipboardManager {
log.warn("Failed to load image from backend response"); log.warn("Failed to load image from backend response");
resolve(false); resolve(false);
}; };
img.src = data.image_data; img.src = data.image_data;
}); });
return success; return success;
}
} catch (error) { catch (error) {
log.debug("Error loading file via ComfyUI backend:", error); log.debug("Error loading file via ComfyUI backend:", error);
return false; return false;
} }
} }
/** /**
* Prompts the user to select a file when a local path is detected * Prompts the user to select a file when a local path is detected
* @param {string} originalPath - The original file path from clipboard * @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<boolean>} - True if successful, false otherwise * @returns {Promise<boolean>} - True if successful, false otherwise
*/ */
async promptUserForFile(originalPath, addMode) { async promptUserForFile(originalPath, addMode) {
return new Promise((resolve) => { return new Promise((resolve) => {
const fileInput = document.createElement('input'); const fileInput = document.createElement('input');
fileInput.type = 'file'; fileInput.type = 'file';
fileInput.accept = 'image/*'; fileInput.accept = 'image/*';
fileInput.style.display = 'none'; fileInput.style.display = 'none';
const fileName = originalPath.split(/[\\\/]/).pop(); const fileName = originalPath.split(/[\\\/]/).pop();
fileInput.onchange = async (event) => { 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/')) { if (file && file.type.startsWith('image/')) {
try { try {
const reader = new FileReader(); const reader = new FileReader();
@@ -362,38 +326,37 @@ export class ClipboardManager {
log.warn("Failed to load selected image"); log.warn("Failed to load selected image");
resolve(false); resolve(false);
}; };
img.src = e.target.result; if (e.target?.result) {
img.src = e.target.result;
}
}; };
reader.onerror = () => { reader.onerror = () => {
log.warn("Failed to read selected file"); log.warn("Failed to read selected file");
resolve(false); resolve(false);
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
} catch (error) { }
catch (error) {
log.warn("Error processing selected file:", error); log.warn("Error processing selected file:", error);
resolve(false); resolve(false);
} }
} else { }
else {
log.warn("Selected file is not an image"); log.warn("Selected file is not an image");
resolve(false); resolve(false);
} }
document.body.removeChild(fileInput); document.body.removeChild(fileInput);
}; };
fileInput.oncancel = () => { fileInput.oncancel = () => {
log.info("File selection cancelled by user"); log.info("File selection cancelled by user");
document.body.removeChild(fileInput); document.body.removeChild(fileInput);
resolve(false); resolve(false);
}; };
this.showNotification(`Detected image path: ${fileName}. Please select the file to load it.`, 3000); this.showNotification(`Detected image path: ${fileName}. Please select the file to load it.`, 3000);
document.body.appendChild(fileInput); document.body.appendChild(fileInput);
fileInput.click(); fileInput.click();
}); });
} }
/** /**
* Shows a message to the user about file path limitations * Shows a message to the user about file path limitations
* @param {string} filePath - The file path that couldn't be loaded * @param {string} filePath - The file path that couldn't be loaded
@@ -404,14 +367,12 @@ export class ClipboardManager {
this.showNotification(message, 5000); this.showNotification(message, 5000);
log.info("Showed file path limitation message to user"); log.info("Showed file path limitation message to user");
} }
/** /**
* Shows a helpful message when clipboard appears empty and offers file picker * 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) { showEmptyClipboardMessage(addMode) {
const message = `Copied a file? Browser can't access file paths for security. Click here to select the file manually.`; 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'); const notification = document.createElement('div');
notification.style.cssText = ` notification.style.cssText = `
position: fixed; position: fixed;
@@ -440,7 +401,6 @@ export class ClipboardManager {
💡 Tip: You can also drag & drop files directly onto the canvas 💡 Tip: You can also drag & drop files directly onto the canvas
</div> </div>
`; `;
notification.onmouseenter = () => { notification.onmouseenter = () => {
notification.style.backgroundColor = '#3d6bb0'; notification.style.backgroundColor = '#3d6bb0';
notification.style.borderColor = '#5a8bd8'; notification.style.borderColor = '#5a8bd8';
@@ -451,7 +411,6 @@ export class ClipboardManager {
notification.style.borderColor = '#4a7bc8'; notification.style.borderColor = '#4a7bc8';
notification.style.transform = 'translateY(0)'; notification.style.transform = 'translateY(0)';
}; };
notification.onclick = async () => { notification.onclick = async () => {
document.body.removeChild(notification); document.body.removeChild(notification);
try { try {
@@ -459,29 +418,25 @@ export class ClipboardManager {
if (success) { if (success) {
log.info("Successfully loaded image via empty clipboard file picker"); log.info("Successfully loaded image via empty clipboard file picker");
} }
} catch (error) { }
catch (error) {
log.warn("Error with empty clipboard file picker:", error); log.warn("Error with empty clipboard file picker:", error);
} }
}; };
document.body.appendChild(notification); document.body.appendChild(notification);
setTimeout(() => { setTimeout(() => {
if (notification.parentNode) { if (notification.parentNode) {
notification.parentNode.removeChild(notification); notification.parentNode.removeChild(notification);
} }
}, 12000); }, 12000);
log.info("Showed enhanced empty clipboard message with file picker option"); log.info("Showed enhanced empty clipboard message with file picker option");
} }
/** /**
* Shows a temporary notification to the user * Shows a temporary notification to the user
* @param {string} message - The message to show * @param {string} message - The message to show
* @param {number} duration - Duration in milliseconds * @param {number} duration - Duration in milliseconds
*/ */
showNotification(message, duration = 3000) { showNotification(message, duration = 3000) {
const notification = document.createElement('div'); const notification = document.createElement('div');
notification.style.cssText = ` notification.style.cssText = `
position: fixed; position: fixed;
@@ -498,9 +453,7 @@ export class ClipboardManager {
line-height: 1.4; line-height: 1.4;
`; `;
notification.textContent = message; notification.textContent = message;
document.body.appendChild(notification); document.body.appendChild(notification);
setTimeout(() => { setTimeout(() => {
if (notification.parentNode) { if (notification.parentNode) {
notification.parentNode.removeChild(notification); notification.parentNode.removeChild(notification);

View File

@@ -1,8 +1,3 @@
/**
* CommonUtils - Wspólne funkcje pomocnicze
* Eliminuje duplikację funkcji używanych w różnych modułach
*/
/** /**
* Generuje unikalny identyfikator UUID * Generuje unikalny identyfikator UUID
* @returns {string} UUID w formacie xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx * @returns {string} UUID w formacie xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
@@ -13,7 +8,6 @@ export function generateUUID() {
return v.toString(16); return v.toString(16);
}); });
} }
/** /**
* Funkcja snap do siatki * Funkcja snap do siatki
* @param {number} value - Wartość do przyciągnięcia * @param {number} value - Wartość do przyciągnięcia
@@ -23,58 +17,48 @@ export function generateUUID() {
export function snapToGrid(value, gridSize = 64) { export function snapToGrid(value, gridSize = 64) {
return Math.round(value / gridSize) * gridSize; return Math.round(value / gridSize) * gridSize;
} }
/** /**
* Oblicza dostosowanie snap dla warstwy * Oblicza dostosowanie snap dla warstwy
* @param {Object} layer - Obiekt warstwy * @param {Object} layer - Obiekt warstwy
* @param {number} gridSize - Rozmiar siatki * @param {number} gridSize - Rozmiar siatki
* @param {number} snapThreshold - Próg przyciągania * @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) { export function getSnapAdjustment(layer, gridSize = 64, snapThreshold = 10) {
if (!layer) { if (!layer) {
return {dx: 0, dy: 0}; return { x: 0, y: 0 };
} }
const layerEdges = { const layerEdges = {
left: layer.x, left: layer.x,
right: layer.x + layer.width, right: layer.x + layer.width,
top: layer.y, top: layer.y,
bottom: layer.y + layer.height bottom: layer.y + layer.height
}; };
const x_adjustments = [ const x_adjustments = [
{type: 'x', delta: snapToGrid(layerEdges.left, gridSize) - layerEdges.left}, { type: 'x', delta: snapToGrid(layerEdges.left, gridSize) - layerEdges.left },
{type: 'x', delta: snapToGrid(layerEdges.right, gridSize) - layerEdges.right} { type: 'x', delta: snapToGrid(layerEdges.right, gridSize) - layerEdges.right }
]; ].map(adj => ({ ...adj, abs: Math.abs(adj.delta) }));
const y_adjustments = [ const y_adjustments = [
{type: 'y', delta: snapToGrid(layerEdges.top, gridSize) - layerEdges.top}, { type: 'y', delta: snapToGrid(layerEdges.top, gridSize) - layerEdges.top },
{type: 'y', delta: snapToGrid(layerEdges.bottom, gridSize) - layerEdges.bottom} { type: 'y', delta: snapToGrid(layerEdges.bottom, gridSize) - layerEdges.bottom }
]; ].map(adj => ({ ...adj, abs: Math.abs(adj.delta) }));
x_adjustments.forEach(adj => adj.abs = Math.abs(adj.delta));
y_adjustments.forEach(adj => adj.abs = Math.abs(adj.delta));
const bestXSnap = x_adjustments const bestXSnap = x_adjustments
.filter(adj => adj.abs < snapThreshold && adj.abs > 1e-9) .filter(adj => adj.abs < snapThreshold && adj.abs > 1e-9)
.sort((a, b) => a.abs - b.abs)[0]; .sort((a, b) => a.abs - b.abs)[0];
const bestYSnap = y_adjustments const bestYSnap = y_adjustments
.filter(adj => adj.abs < snapThreshold && adj.abs > 1e-9) .filter(adj => adj.abs < snapThreshold && adj.abs > 1e-9)
.sort((a, b) => a.abs - b.abs)[0]; .sort((a, b) => a.abs - b.abs)[0];
return { return {
dx: bestXSnap ? bestXSnap.delta : 0, x: bestXSnap ? bestXSnap.delta : 0,
dy: bestYSnap ? bestYSnap.delta : 0 y: bestYSnap ? bestYSnap.delta : 0
}; };
} }
/** /**
* Konwertuje współrzędne świata na lokalne * Konwertuje współrzędne świata na lokalne
* @param {number} worldX - Współrzędna X w świecie * @param {number} worldX - Współrzędna X w świecie
* @param {number} worldY - Współrzędna Y w świecie * @param {number} worldY - Współrzędna Y w świecie
* @param {Object} layerProps - Właściwości warstwy * @param {any} layerProps - Właściwości warstwy
* @returns {Object} Lokalne współrzędne {x, y} * @returns {Point} Lokalne współrzędne {x, y}
*/ */
export function worldToLocal(worldX, worldY, layerProps) { export function worldToLocal(worldX, worldY, layerProps) {
const dx = worldX - layerProps.centerX; const dx = worldX - layerProps.centerX;
@@ -82,46 +66,38 @@ export function worldToLocal(worldX, worldY, layerProps) {
const rad = -layerProps.rotation * Math.PI / 180; const rad = -layerProps.rotation * Math.PI / 180;
const cos = Math.cos(rad); const cos = Math.cos(rad);
const sin = Math.sin(rad); const sin = Math.sin(rad);
return { return {
x: dx * cos - dy * sin, x: dx * cos - dy * sin,
y: dx * sin + dy * cos y: dx * sin + dy * cos
}; };
} }
/** /**
* Konwertuje współrzędne lokalne na świat * Konwertuje współrzędne lokalne na świat
* @param {number} localX - Lokalna współrzędna X * @param {number} localX - Lokalna współrzędna X
* @param {number} localY - Lokalna współrzędna Y * @param {number} localY - Lokalna współrzędna Y
* @param {Object} layerProps - Właściwości warstwy * @param {any} layerProps - Właściwości warstwy
* @returns {Object} Współrzędne świata {x, y} * @returns {Point} Współrzędne świata {x, y}
*/ */
export function localToWorld(localX, localY, layerProps) { export function localToWorld(localX, localY, layerProps) {
const rad = layerProps.rotation * Math.PI / 180; const rad = layerProps.rotation * Math.PI / 180;
const cos = Math.cos(rad); const cos = Math.cos(rad);
const sin = Math.sin(rad); const sin = Math.sin(rad);
return { return {
x: layerProps.centerX + localX * cos - localY * sin, x: layerProps.centerX + localX * cos - localY * sin,
y: layerProps.centerY + localX * sin + localY * cos y: layerProps.centerY + localX * sin + localY * cos
}; };
} }
/** /**
* Klonuje warstwy (bez klonowania obiektów Image dla oszczędności pamięci) * Klonuje warstwy (bez klonowania obiektów Image dla oszczędności pamięci)
* @param {Array} layers - Tablica warstw do sklonowania * @param {Layer[]} layers - Tablica warstw do sklonowania
* @returns {Array} Sklonowane warstwy * @returns {Layer[]} Sklonowane warstwy
*/ */
export function cloneLayers(layers) { export function cloneLayers(layers) {
return layers.map(layer => { return layers.map(layer => ({ ...layer }));
const newLayer = {...layer};
return newLayer;
});
} }
/** /**
* Tworzy sygnaturę stanu warstw (dla porównań) * Tworzy sygnaturę stanu warstw (dla porównań)
* @param {Array} layers - Tablica warstw * @param {Layer[]} layers - Tablica warstw
* @returns {string} Sygnatura JSON * @returns {string} Sygnatura JSON
*/ */
export function getStateSignature(layers) { export function getStateSignature(layers) {
@@ -137,45 +113,43 @@ export function getStateSignature(layers) {
blendMode: layer.blendMode || 'normal', blendMode: layer.blendMode || 'normal',
opacity: layer.opacity !== undefined ? Math.round(layer.opacity * 100) / 100 : 1 opacity: layer.opacity !== undefined ? Math.round(layer.opacity * 100) / 100 : 1
}; };
if (layer.imageId) { if (layer.imageId) {
sig.imageId = layer.imageId; sig.imageId = layer.imageId;
} }
if (layer.image && layer.image.src) { if (layer.image && layer.image.src) {
sig.imageSrc = layer.image.src.substring(0, 100); // First 100 chars to avoid huge signatures sig.imageSrc = layer.image.src.substring(0, 100); // First 100 chars to avoid huge signatures
} }
return sig; return sig;
})); }));
} }
/** /**
* Debounce funkcja - opóźnia wykonanie funkcji * Debounce funkcja - opóźnia wykonanie funkcji
* @param {Function} func - Funkcja do wykonania * @param {Function} func - Funkcja do wykonania
* @param {number} wait - Czas oczekiwania w ms * @param {number} wait - Czas oczekiwania w ms
* @param {boolean} immediate - Czy wykonać natychmiast * @param {boolean} immediate - Czy wykonać natychmiast
* @returns {Function} Funkcja z debounce * @returns {(...args: any[]) => void} Funkcja z debounce
*/ */
export function debounce(func, wait, immediate) { export function debounce(func, wait, immediate) {
let timeout; let timeout;
return function executedFunction(...args) { return function executedFunction(...args) {
const later = () => { const later = () => {
timeout = null; timeout = null;
if (!immediate) func(...args); if (!immediate)
func.apply(this, args);
}; };
const callNow = immediate && !timeout; const callNow = immediate && !timeout;
clearTimeout(timeout); if (timeout)
timeout = setTimeout(later, wait); clearTimeout(timeout);
if (callNow) func(...args); timeout = window.setTimeout(later, wait);
if (callNow)
func.apply(this, args);
}; };
} }
/** /**
* Throttle funkcja - ogranicza częstotliwość wykonania * Throttle funkcja - ogranicza częstotliwość wykonania
* @param {Function} func - Funkcja do wykonania * @param {Function} func - Funkcja do wykonania
* @param {number} limit - Limit czasu w ms * @param {number} limit - Limit czasu w ms
* @returns {Function} Funkcja z throttle * @returns {(...args: any[]) => void} Funkcja z throttle
*/ */
export function throttle(func, limit) { export function throttle(func, limit) {
let inThrottle; let inThrottle;
@@ -187,7 +161,6 @@ export function throttle(func, limit) {
} }
}; };
} }
/** /**
* Ogranicza wartość do zakresu * Ogranicza wartość do zakresu
* @param {number} value - Wartość do ograniczenia * @param {number} value - Wartość do ograniczenia
@@ -198,7 +171,6 @@ export function throttle(func, limit) {
export function clamp(value, min, max) { export function clamp(value, min, max) {
return Math.min(Math.max(value, min), max); return Math.min(Math.max(value, min), max);
} }
/** /**
* Interpolacja liniowa między dwoma wartościami * Interpolacja liniowa między dwoma wartościami
* @param {number} start - Wartość początkowa * @param {number} start - Wartość początkowa
@@ -209,7 +181,6 @@ export function clamp(value, min, max) {
export function lerp(start, end, factor) { export function lerp(start, end, factor) {
return start + (end - start) * factor; return start + (end - start) * factor;
} }
/** /**
* Konwertuje stopnie na radiany * Konwertuje stopnie na radiany
* @param {number} degrees - Stopnie * @param {number} degrees - Stopnie
@@ -218,7 +189,6 @@ export function lerp(start, end, factor) {
export function degreesToRadians(degrees) { export function degreesToRadians(degrees) {
return degrees * Math.PI / 180; return degrees * Math.PI / 180;
} }
/** /**
* Konwertuje radiany na stopnie * Konwertuje radiany na stopnie
* @param {number} radians - Radiany * @param {number} radians - Radiany
@@ -227,23 +197,23 @@ export function degreesToRadians(degrees) {
export function radiansToDegrees(radians) { export function radiansToDegrees(radians) {
return radians * 180 / Math.PI; return radians * 180 / Math.PI;
} }
/** /**
* Tworzy canvas z kontekstem - eliminuje duplikaty w kodzie * Tworzy canvas z kontekstem - eliminuje duplikaty w kodzie
* @param {number} width - Szerokość canvas * @param {number} width - Szerokość canvas
* @param {number} height - Wysokość canvas * @param {number} height - Wysokość canvas
* @param {string} contextType - Typ kontekstu (domyślnie '2d') * @param {string} contextType - Typ kontekstu (domyślnie '2d')
* @param {Object} contextOptions - Opcje kontekstu * @param {object} contextOptions - Opcje kontekstu
* @returns {Object} Obiekt z canvas i ctx * @returns {{canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D | null}} Obiekt z canvas i ctx
*/ */
export function createCanvas(width, height, contextType = '2d', contextOptions = {}) { export function createCanvas(width, height, contextType = '2d', contextOptions = {}) {
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
if (width) canvas.width = width; if (width)
if (height) canvas.height = height; canvas.width = width;
if (height)
canvas.height = height;
const ctx = canvas.getContext(contextType, contextOptions); const ctx = canvas.getContext(contextType, contextOptions);
return {canvas, ctx}; return { canvas, ctx };
} }
/** /**
* Normalizuje wartość do zakresu Uint8 (0-255) * Normalizuje wartość do zakresu Uint8 (0-255)
* @param {number} value - Wartość do znormalizowania (0-1) * @param {number} value - Wartość do znormalizowania (0-1)
@@ -252,11 +222,10 @@ export function createCanvas(width, height, contextType = '2d', contextOptions =
export function normalizeToUint8(value) { export function normalizeToUint8(value) {
return Math.max(0, Math.min(255, Math.round(value * 255))); return Math.max(0, Math.min(255, Math.round(value * 255)));
} }
/** /**
* Generuje unikalną nazwę pliku z identyfikatorem node-a * Generuje unikalną nazwę pliku z identyfikatorem node-a
* @param {string} baseName - Podstawowa nazwa pliku * @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 * @returns {string} Unikalna nazwa pliku
*/ */
export function generateUniqueFileName(baseName, nodeId) { export function generateUniqueFileName(baseName, nodeId) {
@@ -271,7 +240,6 @@ export function generateUniqueFileName(baseName, nodeId) {
const nameWithoutExt = baseName.replace(`.${extension}`, ''); const nameWithoutExt = baseName.replace(`.${extension}`, '');
return `${nameWithoutExt}_node_${nodeId}.${extension}`; return `${nameWithoutExt}_node_${nodeId}.${extension}`;
} }
/** /**
* Sprawdza czy punkt jest w prostokącie * Sprawdza czy punkt jest w prostokącie
* @param {number} pointX - X punktu * @param {number} pointX - X punktu

View File

@@ -1,8 +1,6 @@
import {createModuleLogger} from "./LoggerUtils.js"; import { createModuleLogger } from "./LoggerUtils.js";
import {withErrorHandling, createValidationError} from "../ErrorHandler.js"; import { withErrorHandling, createValidationError } from "../ErrorHandler.js";
const log = createModuleLogger('ImageUtils'); const log = createModuleLogger('ImageUtils');
export function validateImageData(data) { export function validateImageData(data) {
log.debug("Validating data structure:", { log.debug("Validating data structure:", {
hasData: !!data, hasData: !!data,
@@ -13,306 +11,222 @@ export function validateImageData(data) {
dataType: data?.data ? data.data.constructor.name : null, dataType: data?.data ? data.data.constructor.name : null,
fullData: data fullData: data
}); });
if (!data) { if (!data) {
log.info("Data is null or undefined"); log.info("Data is null or undefined");
return false; return false;
} }
if (Array.isArray(data)) { if (Array.isArray(data)) {
log.debug("Data is array, getting first element"); log.debug("Data is array, getting first element");
data = data[0]; data = data[0];
} }
if (!data || typeof data !== 'object') { if (!data || typeof data !== 'object') {
log.info("Invalid data type"); log.info("Invalid data type");
return false; return false;
} }
if (!data.data) { if (!data.data) {
log.info("Missing data property"); log.info("Missing data property");
return false; return false;
} }
if (!(data.data instanceof Float32Array)) { if (!(data.data instanceof Float32Array)) {
try { try {
data.data = new Float32Array(data.data); data.data = new Float32Array(data.data);
} catch (e) { }
catch (e) {
log.error("Failed to convert data to Float32Array:", e); log.error("Failed to convert data to Float32Array:", e);
return false; return false;
} }
} }
return true; return true;
} }
export function convertImageData(data) { export function convertImageData(data) {
log.info("Converting image data:", data); log.info("Converting image data:", data);
if (Array.isArray(data)) { if (Array.isArray(data)) {
data = data[0]; data = data[0];
} }
const shape = data.shape; const shape = data.shape;
const height = shape[1]; const height = shape[1];
const width = shape[2]; const width = shape[2];
const channels = shape[3]; const channels = shape[3];
const floatData = new Float32Array(data.data); 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); const rgbaData = new Uint8ClampedArray(width * height * 4);
for (let h = 0; h < height; h++) { for (let h = 0; h < height; h++) {
for (let w = 0; w < width; w++) { for (let w = 0; w < width; w++) {
const pixelIndex = (h * width + w) * 4; const pixelIndex = (h * width + w) * 4;
const tensorIndex = (h * width + w) * channels; const tensorIndex = (h * width + w) * channels;
for (let c = 0; c < channels; c++) { for (let c = 0; c < channels; c++) {
const value = floatData[tensorIndex + c]; const value = floatData[tensorIndex + c];
rgbaData[pixelIndex + c] = Math.max(0, Math.min(255, Math.round(value * 255))); rgbaData[pixelIndex + c] = Math.max(0, Math.min(255, Math.round(value * 255)));
} }
rgbaData[pixelIndex + 3] = 255; rgbaData[pixelIndex + 3] = 255;
} }
} }
return { return {
data: rgbaData, data: rgbaData,
width: width, width: width,
height: height height: height
}; };
} }
export function applyMaskToImageData(imageData, maskData) { export function applyMaskToImageData(imageData, maskData) {
log.info("Applying mask to image data"); log.info("Applying mask to image data");
const rgbaData = new Uint8ClampedArray(imageData.data); const rgbaData = new Uint8ClampedArray(imageData.data);
const width = imageData.width; const width = imageData.width;
const height = imageData.height; const height = imageData.height;
const maskShape = maskData.shape; const maskShape = maskData.shape;
const maskFloatData = new Float32Array(maskData.data); const maskFloatData = new Float32Array(maskData.data);
log.debug(`Applying mask of shape: ${maskShape}`); log.debug(`Applying mask of shape: ${maskShape}`);
for (let h = 0; h < height; h++) { for (let h = 0; h < height; h++) {
for (let w = 0; w < width; w++) { for (let w = 0; w < width; w++) {
const pixelIndex = (h * width + w) * 4; const pixelIndex = (h * width + w) * 4;
const maskIndex = h * width + w; const maskIndex = h * width + w;
const alpha = maskFloatData[maskIndex]; const alpha = maskFloatData[maskIndex];
rgbaData[pixelIndex + 3] = Math.max(0, Math.min(255, Math.round(alpha * 255))); rgbaData[pixelIndex + 3] = Math.max(0, Math.min(255, Math.round(alpha * 255)));
} }
} }
log.info("Mask application completed"); log.info("Mask application completed");
return { return {
data: rgbaData, data: rgbaData,
width: width, width: width,
height: height height: height
}; };
} }
export const prepareImageForCanvas = withErrorHandling(function (inputImage) { export const prepareImageForCanvas = withErrorHandling(function (inputImage) {
log.info("Preparing image for canvas:", inputImage); log.info("Preparing image for canvas:", inputImage);
if (Array.isArray(inputImage)) { if (Array.isArray(inputImage)) {
inputImage = inputImage[0]; inputImage = inputImage[0];
} }
if (!inputImage || !inputImage.shape || !inputImage.data) { 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 shape = inputImage.shape;
const height = shape[1]; const height = shape[1];
const width = shape[2]; const width = shape[2];
const channels = shape[3]; const channels = shape[3];
const floatData = new Float32Array(inputImage.data); 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); const rgbaData = new Uint8ClampedArray(width * height * 4);
for (let h = 0; h < height; h++) { for (let h = 0; h < height; h++) {
for (let w = 0; w < width; w++) { for (let w = 0; w < width; w++) {
const pixelIndex = (h * width + w) * 4; const pixelIndex = (h * width + w) * 4;
const tensorIndex = (h * width + w) * channels; const tensorIndex = (h * width + w) * channels;
for (let c = 0; c < channels; c++) { for (let c = 0; c < channels; c++) {
const value = floatData[tensorIndex + c]; const value = floatData[tensorIndex + c];
rgbaData[pixelIndex + c] = Math.max(0, Math.min(255, Math.round(value * 255))); rgbaData[pixelIndex + c] = Math.max(0, Math.min(255, Math.round(value * 255)));
} }
rgbaData[pixelIndex + 3] = 255; rgbaData[pixelIndex + 3] = 255;
} }
} }
return { return {
data: rgbaData, data: rgbaData,
width: width, width: width,
height: height height: height
}; };
}, 'prepareImageForCanvas'); }, 'prepareImageForCanvas');
/**
* Konwertuje obraz PIL/Canvas na tensor
* @param {HTMLImageElement|HTMLCanvasElement} image - Obraz do konwersji
* @returns {Promise<Object>} Tensor z danymi obrazu
*/
export const imageToTensor = withErrorHandling(async function (image) { export const imageToTensor = withErrorHandling(async function (image) {
if (!image) { if (!image) {
throw createValidationError("Image is required"); throw createValidationError("Image is required");
} }
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true }); const ctx = canvas.getContext('2d', { willReadFrequently: true });
canvas.width = image.width;
canvas.width = image.width || image.naturalWidth; canvas.height = image.height;
canvas.height = image.height || image.naturalHeight; if (ctx) {
ctx.drawImage(image, 0, 0);
ctx.drawImage(image, 0, 0); const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = new Float32Array(canvas.width * canvas.height * 3);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); for (let i = 0; i < imageData.data.length; i += 4) {
const data = new Float32Array(canvas.width * canvas.height * 3); const pixelIndex = i / 4;
data[pixelIndex * 3] = imageData.data[i] / 255;
for (let i = 0; i < imageData.data.length; i += 4) { data[pixelIndex * 3 + 1] = imageData.data[i + 1] / 255;
const pixelIndex = i / 4; data[pixelIndex * 3 + 2] = imageData.data[i + 2] / 255;
data[pixelIndex * 3] = imageData.data[i] / 255; }
data[pixelIndex * 3 + 1] = imageData.data[i + 1] / 255; return {
data[pixelIndex * 3 + 2] = imageData.data[i + 2] / 255; data: data,
shape: [1, canvas.height, canvas.width, 3],
width: canvas.width,
height: canvas.height
};
} }
throw new Error("Canvas context not available");
return {
data: data,
shape: [1, canvas.height, canvas.width, 3],
width: canvas.width,
height: canvas.height
};
}, 'imageToTensor'); }, 'imageToTensor');
/**
* Konwertuje tensor na obraz HTML
* @param {Object} tensor - Tensor z danymi obrazu
* @returns {Promise<HTMLImageElement>} Obraz HTML
*/
export const tensorToImage = withErrorHandling(async function (tensor) { export const tensorToImage = withErrorHandling(async function (tensor) {
if (!tensor || !tensor.data || !tensor.shape) { 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 [, height, width, channels] = tensor.shape;
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true }); const ctx = canvas.getContext('2d', { willReadFrequently: true });
canvas.width = width; canvas.width = width;
canvas.height = height; canvas.height = height;
if (ctx) {
const imageData = ctx.createImageData(width, height); const imageData = ctx.createImageData(width, height);
const data = tensor.data; const data = tensor.data;
for (let i = 0; i < width * height; i++) {
for (let i = 0; i < width * height; i++) { const pixelIndex = i * 4;
const pixelIndex = i * 4; const tensorIndex = i * channels;
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] = Math.round(data[tensorIndex] * 255); imageData.data[pixelIndex + 2] = Math.round(data[tensorIndex + 2] * 255);
imageData.data[pixelIndex + 1] = Math.round(data[tensorIndex + 1] * 255); imageData.data[pixelIndex + 3] = 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");
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();
});
}, 'tensorToImage'); }, '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<HTMLImageElement>} Przeskalowany obraz
*/
export const resizeImage = withErrorHandling(async function (image, maxWidth, maxHeight) { export const resizeImage = withErrorHandling(async function (image, maxWidth, maxHeight) {
if (!image) { if (!image) {
throw createValidationError("Image is required"); throw createValidationError("Image is required");
} }
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true }); const ctx = canvas.getContext('2d', { willReadFrequently: true });
const originalWidth = image.width;
const originalWidth = image.width || image.naturalWidth; const originalHeight = image.height;
const originalHeight = image.height || image.naturalHeight;
const scale = Math.min(maxWidth / originalWidth, maxHeight / originalHeight); const scale = Math.min(maxWidth / originalWidth, maxHeight / originalHeight);
const newWidth = Math.round(originalWidth * scale); const newWidth = Math.round(originalWidth * scale);
const newHeight = Math.round(originalHeight * scale); const newHeight = Math.round(originalHeight * scale);
canvas.width = newWidth; canvas.width = newWidth;
canvas.height = newHeight; canvas.height = newHeight;
ctx.imageSmoothingEnabled = true; if (ctx) {
ctx.imageSmoothingQuality = 'high'; ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(image, 0, 0, newWidth, newHeight); ctx.drawImage(image, 0, 0, newWidth, newHeight);
return new Promise((resolve, reject) => {
return new Promise((resolve, reject) => { const img = new Image();
const img = new Image(); img.onload = () => resolve(img);
img.onload = () => resolve(img); img.onerror = (err) => reject(err);
img.onerror = reject; img.src = canvas.toDataURL();
img.src = canvas.toDataURL(); });
}); }
throw new Error("Canvas context not available");
}, 'resizeImage'); }, 'resizeImage');
/**
* Tworzy miniaturę obrazu
* @param {HTMLImageElement} image - Obraz źródłowy
* @param {number} size - Rozmiar miniatury (kwadrat)
* @returns {Promise<HTMLImageElement>} Miniatura
*/
export const createThumbnail = withErrorHandling(async function (image, size = 128) { export const createThumbnail = withErrorHandling(async function (image, size = 128) {
return resizeImage(image, size, size); return resizeImage(image, size, size);
}, 'createThumbnail'); }, '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) { export const imageToBase64 = withErrorHandling(function (image, format = 'png', quality = 0.9) {
if (!image) { if (!image) {
throw createValidationError("Image is required"); throw createValidationError("Image is required");
} }
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true }); const ctx = canvas.getContext('2d', { willReadFrequently: true });
canvas.width = image instanceof HTMLImageElement ? image.naturalWidth : image.width;
canvas.width = image.width || image.naturalWidth; canvas.height = image instanceof HTMLImageElement ? image.naturalHeight : image.height;
canvas.height = image.height || image.naturalHeight; if (ctx) {
ctx.drawImage(image, 0, 0);
ctx.drawImage(image, 0, 0); const mimeType = `image/${format}`;
return canvas.toDataURL(mimeType, quality);
const mimeType = `image/${format}`; }
return canvas.toDataURL(mimeType, quality); throw new Error("Canvas context not available");
}, 'imageToBase64'); }, 'imageToBase64');
/**
* Konwertuje base64 na obraz
* @param {string} base64 - Base64 string
* @returns {Promise<HTMLImageElement>} Obraz
*/
export const base64ToImage = withErrorHandling(function (base64) { export const base64ToImage = withErrorHandling(function (base64) {
if (!base64) { if (!base64) {
throw createValidationError("Base64 string is required"); throw createValidationError("Base64 string is required");
} }
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const img = new Image(); const img = new Image();
img.onload = () => resolve(img); img.onload = () => resolve(img);
@@ -320,74 +234,49 @@ export const base64ToImage = withErrorHandling(function (base64) {
img.src = base64; img.src = base64;
}); });
}, 'base64ToImage'); }, 'base64ToImage');
/**
* Sprawdza czy obraz jest prawidłowy
* @param {HTMLImageElement} image - Obraz do sprawdzenia
* @returns {boolean} Czy obraz jest prawidłowy
*/
export function isValidImage(image) { export function isValidImage(image) {
return image && return image &&
(image instanceof HTMLImageElement || image instanceof HTMLCanvasElement) && (image instanceof HTMLImageElement || image instanceof HTMLCanvasElement) &&
image.width > 0 && image.width > 0 &&
image.height > 0; image.height > 0;
} }
/**
* Pobiera informacje o obrazie
* @param {HTMLImageElement} image - Obraz
* @returns {Object} Informacje o obrazie
*/
export function getImageInfo(image) { export function getImageInfo(image) {
if (!isValidImage(image)) { if (!isValidImage(image)) {
return null; return null;
} }
const width = image instanceof HTMLImageElement ? image.naturalWidth : image.width;
const height = image instanceof HTMLImageElement ? image.naturalHeight : image.height;
return { return {
width: image.width || image.naturalWidth, width,
height: image.height || image.naturalHeight, height,
aspectRatio: (image.width || image.naturalWidth) / (image.height || image.naturalHeight), aspectRatio: width / height,
area: (image.width || image.naturalWidth) * (image.height || image.naturalHeight) 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<HTMLImageElement>} Promise z obrazem
*/
export function createImageFromSource(source) { export function createImageFromSource(source) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const img = new Image(); const img = new Image();
img.onload = () => resolve(img); img.onload = () => resolve(img);
img.onerror = reject; img.onerror = (err) => reject(err);
img.src = source; 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<HTMLImageElement>} Pusty obraz
*/
export const createEmptyImage = withErrorHandling(function (width, height, color = 'transparent') { export const createEmptyImage = withErrorHandling(function (width, height, color = 'transparent') {
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true }); const ctx = canvas.getContext('2d', { willReadFrequently: true });
canvas.width = width; canvas.width = width;
canvas.height = height; canvas.height = height;
if (ctx) {
if (color !== 'transparent') { if (color !== 'transparent') {
ctx.fillStyle = color; ctx.fillStyle = color;
ctx.fillRect(0, 0, width, height); 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");
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = canvas.toDataURL();
});
}, 'createEmptyImage'); }, 'createEmptyImage');

View File

@@ -2,19 +2,15 @@
* LoggerUtils - Centralizacja inicjalizacji loggerów * LoggerUtils - Centralizacja inicjalizacji loggerów
* Eliminuje powtarzalny kod inicjalizacji loggera w każdym module * 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'; import { LOG_LEVEL } from '../config.js';
/** /**
* Tworzy obiekt loggera dla modułu z predefiniowanymi metodami * Tworzy obiekt loggera dla modułu z predefiniowanymi metodami
* @param {string} moduleName - Nazwa modułu * @param {string} moduleName - Nazwa modułu
* @param {LogLevel} level - Poziom logowania (domyślnie DEBUG) * @returns {Logger} Obiekt z metodami logowania
* @returns {Object} Obiekt z metodami logowania
*/ */
export function createModuleLogger(moduleName) { export function createModuleLogger(moduleName) {
logger.setModuleLevel(moduleName, LogLevel[LOG_LEVEL]); logger.setModuleLevel(moduleName, LogLevel[LOG_LEVEL]);
return { return {
debug: (...args) => logger.debug(moduleName, ...args), debug: (...args) => logger.debug(moduleName, ...args),
info: (...args) => logger.info(moduleName, ...args), info: (...args) => logger.info(moduleName, ...args),
@@ -22,24 +18,20 @@ export function createModuleLogger(moduleName) {
error: (...args) => logger.error(moduleName, ...args) error: (...args) => logger.error(moduleName, ...args)
}; };
} }
/** /**
* Tworzy logger z automatycznym wykrywaniem nazwy modułu z URL * Tworzy logger z automatycznym wykrywaniem nazwy modułu z URL
* @param {LogLevel} level - Poziom logowania * @returns {Logger} Obiekt z metodami logowania
* @returns {Object} Obiekt z metodami logowania
*/ */
export function createAutoLogger(level = LogLevel.DEBUG) { export function createAutoLogger() {
const stack = new Error().stack; const stack = new Error().stack;
const match = stack.match(/\/([^\/]+)\.js/); const match = stack?.match(/\/([^\/]+)\.js/);
const moduleName = match ? match[1] : 'Unknown'; const moduleName = match ? match[1] : 'Unknown';
return createModuleLogger(moduleName);
return createModuleLogger(moduleName, level);
} }
/** /**
* Wrapper dla operacji z automatycznym logowaniem błędów * Wrapper dla operacji z automatycznym logowaniem błędów
* @param {Function} operation - Operacja do wykonania * @param {Function} operation - Operacja do wykonania
* @param {Object} log - Obiekt loggera * @param {Logger} log - Obiekt loggera
* @param {string} operationName - Nazwa operacji (dla logów) * @param {string} operationName - Nazwa operacji (dla logów)
* @returns {Function} Opakowana funkcja * @returns {Function} Opakowana funkcja
*/ */
@@ -50,34 +42,33 @@ export function withErrorLogging(operation, log, operationName) {
const result = await operation.apply(this, args); const result = await operation.apply(this, args);
log.debug(`Completed ${operationName}`); log.debug(`Completed ${operationName}`);
return result; return result;
} catch (error) { }
catch (error) {
log.error(`Error in ${operationName}:`, error); log.error(`Error in ${operationName}:`, error);
throw error; throw error;
} }
}; };
} }
/** /**
* Decorator dla metod klasy z automatycznym logowaniem * Decorator dla metod klasy z automatycznym logowaniem
* @param {Object} log - Obiekt loggera * @param {Logger} log - Obiekt loggera
* @param {string} methodName - Nazwa metody * @param {string} methodName - Nazwa metody
*/ */
export function logMethod(log, methodName) { export function logMethod(log, methodName) {
return function (target, propertyKey, descriptor) { return function (target, propertyKey, descriptor) {
const originalMethod = descriptor.value; const originalMethod = descriptor.value;
descriptor.value = async function (...args) { descriptor.value = async function (...args) {
try { try {
log.debug(`${methodName || propertyKey} started`); log.debug(`${methodName || propertyKey} started`);
const result = await originalMethod.apply(this, args); const result = await originalMethod.apply(this, args);
log.debug(`${methodName || propertyKey} completed`); log.debug(`${methodName || propertyKey} completed`);
return result; return result;
} catch (error) { }
catch (error) {
log.error(`${methodName || propertyKey} failed:`, error); log.error(`${methodName || propertyKey} failed:`, error);
throw error; throw error;
} }
}; };
return descriptor; return descriptor;
}; };
} }

View File

@@ -0,0 +1,30 @@
// @ts-ignore
import { $el } from "../../../scripts/ui.js";
export function addStylesheet(url) {
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, baseUrl) {
if (baseUrl) {
return new URL(path, baseUrl).toString();
}
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);
if (!response.ok) {
throw new Error(`Failed to load template: ${url}`);
}
return await response.text();
}

View File

@@ -1,7 +1,5 @@
import {createModuleLogger} from "./LoggerUtils.js"; import { createModuleLogger } from "./LoggerUtils.js";
const log = createModuleLogger('WebSocketManager'); const log = createModuleLogger('WebSocketManager');
class WebSocketManager { class WebSocketManager {
constructor(url) { constructor(url) {
this.url = url; this.url = url;
@@ -11,41 +9,33 @@ class WebSocketManager {
this.reconnectAttempts = 0; this.reconnectAttempts = 0;
this.maxReconnectAttempts = 10; this.maxReconnectAttempts = 10;
this.reconnectInterval = 5000; // 5 seconds this.reconnectInterval = 5000; // 5 seconds
this.ackCallbacks = new Map(); // Store callbacks for messages awaiting ACK this.ackCallbacks = new Map();
this.messageIdCounter = 0; this.messageIdCounter = 0;
this.connect(); this.connect();
} }
connect() { connect() {
if (this.socket && this.socket.readyState === WebSocket.OPEN) { if (this.socket && this.socket.readyState === WebSocket.OPEN) {
log.debug("WebSocket is already open."); log.debug("WebSocket is already open.");
return; return;
} }
if (this.isConnecting) { if (this.isConnecting) {
log.debug("Connection attempt already in progress."); log.debug("Connection attempt already in progress.");
return; return;
} }
this.isConnecting = true; this.isConnecting = true;
log.info(`Connecting to WebSocket at ${this.url}...`); log.info(`Connecting to WebSocket at ${this.url}...`);
try { try {
this.socket = new WebSocket(this.url); this.socket = new WebSocket(this.url);
this.socket.onopen = () => { this.socket.onopen = () => {
this.isConnecting = false; this.isConnecting = false;
this.reconnectAttempts = 0; this.reconnectAttempts = 0;
log.info("WebSocket connection established."); log.info("WebSocket connection established.");
this.flushMessageQueue(); this.flushMessageQueue();
}; };
this.socket.onmessage = (event) => { this.socket.onmessage = (event) => {
try { try {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
log.debug("Received message:", data); log.debug("Received message:", data);
if (data.type === 'ack' && data.nodeId) { if (data.type === 'ack' && data.nodeId) {
const callback = this.ackCallbacks.get(data.nodeId); const callback = this.ackCallbacks.get(data.nodeId);
if (callback) { if (callback) {
@@ -54,65 +44,59 @@ class WebSocketManager {
this.ackCallbacks.delete(data.nodeId); this.ackCallbacks.delete(data.nodeId);
} }
} }
}
} catch (error) { catch (error) {
log.error("Error parsing incoming WebSocket message:", error); log.error("Error parsing incoming WebSocket message:", error);
} }
}; };
this.socket.onclose = (event) => { this.socket.onclose = (event) => {
this.isConnecting = false; this.isConnecting = false;
if (event.wasClean) { if (event.wasClean) {
log.info(`WebSocket closed cleanly, code=${event.code}, reason=${event.reason}`); log.info(`WebSocket closed cleanly, code=${event.code}, reason=${event.reason}`);
} else { }
else {
log.warn("WebSocket connection died. Attempting to reconnect..."); log.warn("WebSocket connection died. Attempting to reconnect...");
this.handleReconnect(); this.handleReconnect();
} }
}; };
this.socket.onerror = (error) => { this.socket.onerror = (error) => {
this.isConnecting = false; this.isConnecting = false;
log.error("WebSocket error:", error); log.error("WebSocket error:", error);
}; };
} catch (error) { }
catch (error) {
this.isConnecting = false; this.isConnecting = false;
log.error("Failed to create WebSocket connection:", error); log.error("Failed to create WebSocket connection:", error);
this.handleReconnect(); this.handleReconnect();
} }
} }
handleReconnect() { handleReconnect() {
if (this.reconnectAttempts < this.maxReconnectAttempts) { if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++; this.reconnectAttempts++;
log.info(`Reconnect attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts}...`); log.info(`Reconnect attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts}...`);
setTimeout(() => this.connect(), this.reconnectInterval); setTimeout(() => this.connect(), this.reconnectInterval);
} else { }
else {
log.error("Max reconnect attempts reached. Giving up."); log.error("Max reconnect attempts reached. Giving up.");
} }
} }
sendMessage(data, requiresAck = false) { sendMessage(data, requiresAck = false) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const nodeId = data.nodeId; const nodeId = data.nodeId;
if (requiresAck && !nodeId) { if (requiresAck && !nodeId) {
return reject(new Error("A nodeId is required for messages that need acknowledgment.")); return reject(new Error("A nodeId is required for messages that need acknowledgment."));
} }
const message = JSON.stringify(data); const message = JSON.stringify(data);
if (this.socket && this.socket.readyState === WebSocket.OPEN) { if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(message); this.socket.send(message);
log.debug("Sent message:", data); log.debug("Sent message:", data);
if (requiresAck) { if (requiresAck && nodeId) {
log.debug(`Message for nodeId ${nodeId} requires ACK. Setting up callback.`); log.debug(`Message for nodeId ${nodeId} requires ACK. Setting up callback.`);
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
this.ackCallbacks.delete(nodeId); this.ackCallbacks.delete(nodeId);
reject(new Error(`ACK timeout for nodeId ${nodeId}`)); reject(new Error(`ACK timeout for nodeId ${nodeId}`));
log.warn(`ACK timeout for nodeId ${nodeId}.`); log.warn(`ACK timeout for nodeId ${nodeId}.`);
}, 10000); // 10-second timeout }, 10000); // 10-second timeout
this.ackCallbacks.set(nodeId, { this.ackCallbacks.set(nodeId, {
resolve: (responseData) => { resolve: (responseData) => {
clearTimeout(timeout); clearTimeout(timeout);
@@ -123,35 +107,35 @@ class WebSocketManager {
reject(error); reject(error);
} }
}); });
} else { }
else {
resolve(); // Resolve immediately if no ACK is needed resolve(); // Resolve immediately if no ACK is needed
} }
} else { }
else {
log.warn("WebSocket not open. Queuing message."); log.warn("WebSocket not open. Queuing message.");
this.messageQueue.push(message); this.messageQueue.push(message);
if (!this.isConnecting) { if (!this.isConnecting) {
this.connect(); this.connect();
} }
if (requiresAck) { if (requiresAck) {
reject(new Error("Cannot send message with ACK required while disconnected.")); reject(new Error("Cannot send message with ACK required while disconnected."));
} }
else {
resolve();
}
} }
}); });
} }
flushMessageQueue() { flushMessageQueue() {
log.debug(`Flushing ${this.messageQueue.length} queued messages.`); log.debug(`Flushing ${this.messageQueue.length} queued messages.`);
while (this.messageQueue.length > 0) { while (this.messageQueue.length > 0) {
const message = this.messageQueue.shift(); 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`; const wsUrl = `ws://${window.location.host}/layerforge/canvas_ws`;
export const webSocketManager = new WebSocketManager(wsUrl); export const webSocketManager = new WebSocketManager(wsUrl);

View File

@@ -1,39 +1,36 @@
import {createModuleLogger} from "./LoggerUtils.js"; import { createModuleLogger } from "./LoggerUtils.js";
const log = createModuleLogger('MaskUtils'); const log = createModuleLogger('MaskUtils');
export function new_editor(app) { export function new_editor(app) {
if (!app) return false; if (!app)
return app.ui.settings.getSettingValue('Comfy.MaskEditor.UseNewEditor') return false;
return !!app.ui.settings.getSettingValue('Comfy.MaskEditor.UseNewEditor');
} }
function get_mask_editor_element(app) { 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) { export function mask_editor_showing(app) {
const editor = get_mask_editor_element(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(app) {
export function hide_mask_editor() { if (mask_editor_showing(app)) {
if (mask_editor_showing()) document.getElementById('maskEditor').style.display = 'none' const editor = document.getElementById('maskEditor');
if (editor) {
editor.style.display = 'none';
}
}
} }
function get_mask_editor_cancel_button(app) { function get_mask_editor_cancel_button(app) {
const cancelButton = document.getElementById("maskEditor_topBarCancelButton"); const cancelButton = document.getElementById("maskEditor_topBarCancelButton");
if (cancelButton) { if (cancelButton) {
log.debug("Found cancel button by ID: maskEditor_topBarCancelButton"); log.debug("Found cancel button by ID: maskEditor_topBarCancelButton");
return cancelButton; return cancelButton;
} }
const cancelSelectors = [ const cancelSelectors = [
'button[onclick*="cancel"]', 'button[onclick*="cancel"]',
'button[onclick*="Cancel"]', 'button[onclick*="Cancel"]',
'input[value="Cancel"]' 'input[value="Cancel"]'
]; ];
for (const selector of cancelSelectors) { for (const selector of cancelSelectors) {
try { try {
const button = document.querySelector(selector); const button = document.querySelector(selector);
@@ -41,11 +38,11 @@ function get_mask_editor_cancel_button(app) {
log.debug("Found cancel button with selector:", selector); log.debug("Found cancel button with selector:", selector);
return button; return button;
} }
} catch (e) { }
catch (e) {
log.warn("Invalid selector:", selector, e); log.warn("Invalid selector:", selector, e);
} }
} }
const allButtons = document.querySelectorAll('button, input[type="button"]'); const allButtons = document.querySelectorAll('button, input[type="button"]');
for (const button of allButtons) { for (const button of allButtons) {
const text = button.textContent || button.value || ''; const text = button.textContent || button.value || '';
@@ -54,72 +51,78 @@ function get_mask_editor_cancel_button(app) {
return button; return button;
} }
} }
const editorElement = get_mask_editor_element(app); const editorElement = get_mask_editor_element(app);
if (editorElement) { 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; return null;
} }
function get_mask_editor_save_button(app) { function get_mask_editor_save_button(app) {
if (document.getElementById("maskEditor_topBarSaveButton")) return document.getElementById("maskEditor_topBarSaveButton") const saveButton = document.getElementById("maskEditor_topBarSaveButton");
return get_mask_editor_element(app)?.parentElement?.lastChild?.childNodes[2] 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) { export function mask_editor_listen_for_cancel(app, callback) {
let attempts = 0; let attempts = 0;
const maxAttempts = 50; // 5 sekund const maxAttempts = 50; // 5 sekund
const findAndAttachListener = () => { const findAndAttachListener = () => {
attempts++; attempts++;
const cancel_button = get_mask_editor_cancel_button(app); const cancel_button = get_mask_editor_cancel_button(app);
if (cancel_button instanceof HTMLElement && !cancel_button.filter_listener_added) {
if (cancel_button && !cancel_button.filter_listener_added) {
log.info("Cancel button found, attaching listener"); log.info("Cancel button found, attaching listener");
cancel_button.addEventListener('click', callback); cancel_button.addEventListener('click', callback);
cancel_button.filter_listener_added = true; cancel_button.filter_listener_added = true;
return true; // Znaleziono i podłączono }
} else if (attempts < maxAttempts) { else if (attempts < maxAttempts) {
setTimeout(findAndAttachListener, 100); setTimeout(findAndAttachListener, 100);
} else { }
else {
log.warn("Could not find cancel button after", maxAttempts, "attempts"); log.warn("Could not find cancel button after", maxAttempts, "attempts");
const globalClickHandler = (event) => { const globalClickHandler = (event) => {
const target = event.target; const target = event.target;
const text = target.textContent || target.value || ''; const text = target.textContent || target.value || '';
if (text.toLowerCase().includes('cancel') || if (target && (text.toLowerCase().includes('cancel') ||
target.id.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"); log.info("Cancel detected via global click handler");
callback(); callback();
document.removeEventListener('click', globalClickHandler); document.removeEventListener('click', globalClickHandler);
} }
}; };
document.addEventListener('click', globalClickHandler); document.addEventListener('click', globalClickHandler);
log.debug("Added global click handler for cancel detection"); log.debug("Added global click handler for cancel detection");
} }
}; };
findAndAttachListener(); findAndAttachListener();
} }
export function press_maskeditor_save(app) { 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) { 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ą * Uruchamia mask editor z predefiniowaną maską
* @param {Object} canvasInstance - Instancja Canvas * @param {Canvas} canvasInstance - Instancja Canvas
* @param {Image|HTMLCanvasElement} maskImage - Obraz maski do nałożenia * @param {HTMLImageElement | HTMLCanvasElement} maskImage - Obraz maski do nałożenia
* @param {boolean} sendCleanImage - Czy wysłać czysty obraz (bez istniejącej maski) * @param {boolean} sendCleanImage - Czy wysłać czysty obraz (bez istniejącej maski)
*/ */
export function start_mask_editor_with_predefined_mask(canvasInstance, maskImage, sendCleanImage = true) { 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'); log.error('Canvas instance and mask image are required');
return; return;
} }
canvasInstance.startMaskEditor(maskImage, sendCleanImage); canvasInstance.startMaskEditor(maskImage, sendCleanImage);
} }
/** /**
* Uruchamia mask editor z automatycznym zachowaniem (czysty obraz + istniejąca maska) * 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) { export function start_mask_editor_auto(canvasInstance) {
if (!canvasInstance) { if (!canvasInstance) {
log.error('Canvas instance is required'); log.error('Canvas instance is required');
return; return;
} }
canvasInstance.startMaskEditor(null, true);
canvasInstance.startMaskEditor();
} }
/** /**
* Tworzy maskę z obrazu dla użycia w mask editorze * Tworzy maskę z obrazu dla użycia w mask editorze
* @param {string} imageSrc - Źródło obrazu (URL lub data URL) * @param {string} imageSrc - Źródło obrazu (URL lub data URL)
* @returns {Promise<Image>} Promise zwracający obiekt Image * @returns {Promise<HTMLImageElement>} Promise zwracający obiekt Image
*/ */
export function create_mask_from_image_src(imageSrc) { export function create_mask_from_image_src(imageSrc) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const img = new Image(); const img = new Image();
img.onload = () => resolve(img); img.onload = () => resolve(img);
img.onerror = reject; img.onerror = (err) => reject(err);
img.src = imageSrc; img.src = imageSrc;
}); });
} }
/** /**
* Konwertuje canvas do Image dla użycia jako maska * Konwertuje canvas do Image dla użycia jako maska
* @param {HTMLCanvasElement} canvas - Canvas do konwersji * @param {HTMLCanvasElement} canvas - Canvas do konwersji
* @returns {Promise<Image>} Promise zwracający obiekt Image * @returns {Promise<HTMLImageElement>} Promise zwracający obiekt Image
*/ */
export function canvas_to_mask_image(canvas) { export function canvas_to_mask_image(canvas) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const img = new Image(); const img = new Image();
img.onload = () => resolve(img); img.onload = () => resolve(img);
img.onerror = reject; img.onerror = (err) => reject(err);
img.src = canvas.toDataURL(); img.src = canvas.toDataURL();
}); });
} }

View File

@@ -1,7 +1,7 @@
[project] [project]
name = "layerforge" name = "layerforge"
description = "Photoshop-like layered canvas editor to your ComfyUI workflow. This node is perfect for complex compositing, inpainting, and outpainting, featuring multi-layer support, masking, blend modes, and precise transformations. Includes optional AI-powered background removal for streamlined image editing." description = "Photoshop-like layered canvas editor to your ComfyUI workflow. This node is perfect for complex compositing, inpainting, and outpainting, featuring multi-layer support, masking, blend modes, and precise transformations. Includes optional AI-powered background removal for streamlined image editing."
version = "1.3.6" version = "1.3.7"
license = {file = "LICENSE"} license = {file = "LICENSE"}
dependencies = ["torch", "torchvision", "transformers", "aiohttp", "numpy", "tqdm", "Pillow"] dependencies = ["torch", "torchvision", "transformers", "aiohttp", "numpy", "tqdm", "Pillow"]

277
src/BatchPreviewManager.ts Normal file
View File

@@ -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('&#9664;', 'Previous'); // Left arrow
const nextButton = this._createButton('&#9654;', 'Next'); // Right arrow
const confirmButton = this._createButton('&#10004;', 'Confirm'); // Checkmark
const cancelButton = this._createButton('&#10006;', 'Cancel All');
const closeButton = this._createButton('&#10162;', '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();
}
}

610
src/Canvas.ts Normal file
View File

@@ -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<string, any>;
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();
}
}
}

827
src/CanvasIO.ts Normal file
View File

@@ -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<any> | null;
private canvas: Canvas;
constructor(canvas: Canvas) {
this.canvas = canvas;
this._saveInProgress = null;
}
async saveToServer(fileName: string, outputMode = 'disk'): Promise<any> {
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<any> {
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<boolean> {
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<boolean> {
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<HTMLImageElement> {
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<Float32Array> {
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<void> {
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<void> {
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<HTMLImageElement> {
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<void> {
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<void> {
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<HTMLImageElement> {
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<void> {
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<boolean> {
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<Layer[]> {
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 [];
}
}
}

923
src/CanvasInteractions.ts Normal file
View File

@@ -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<Layer> & { 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<Layer, Point>;
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<void> {
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<void> {
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<void> {
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);
}
}

1045
src/CanvasLayers.ts Normal file

File diff suppressed because it is too large Load Diff

613
src/CanvasLayersPanel.ts Normal file
View File

@@ -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 = `
<div class="layers-panel-header">
<span class="layers-panel-title">Layers</span>
<div class="layers-panel-controls">
<button class="layers-btn" id="delete-layer-btn" title="Delete layer">🗑</button>
</div>
</div>
<div class="layers-container" id="layers-container">
<!-- Lista warstw będzie renderowana tutaj -->
</div>
`;
this.layersContainer = this.container.querySelector<HTMLElement>('#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 = `
<div class="layer-thumbnail" data-layer-index="${index}"></div>
<span class="layer-name" data-layer-index="${index}">${layer.name}</span>
`;
const thumbnailContainer = layerRow.querySelector<HTMLElement>('.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<HTMLElement>('.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<HTMLElement>('.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');
}
}

564
src/CanvasMask.ts Normal file
View File

@@ -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<Image>} 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");
}
}

369
src/CanvasRenderer.ts Normal file
View File

@@ -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();
});
}
}

170
src/CanvasSelection.ts Normal file
View File

@@ -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);
}
}

497
src/CanvasState.ts Normal file
View File

@@ -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<boolean> | 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<boolean> {
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<boolean> {
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<Layer | null>} Załadowana warstwa lub null
*/
async _loadSingleLayer(layerData: Layer, index: number): Promise<Layer | null> {
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<void> {
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<Layer, 'image'> & { imageId: string })[]>} Przygotowane warstwy
*/
async _prepareLayers(): Promise<(Omit<Layer, 'image'> & { imageId: string })[]> {
const preparedLayers = await Promise.all(this.canvas.layers.map(async (layer: Layer, index: number) => {
const newLayer: Omit<Layer, 'image'> & { 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<Layer, 'image'> & { 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
};
}
}
}

1039
src/CanvasView.ts Normal file

File diff suppressed because it is too large Load Diff

383
src/ErrorHandler.ts Normal file
View File

@@ -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<ErrorType, number>;
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<T extends (...args: any[]) => any>(
fn: T,
context: string
): (...args: Parameters<T>) => Promise<ReturnType<T>> {
return async function(this: any, ...args: Parameters<T>): Promise<ReturnType<T>> {
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<T>} operation - Operacja do wykonania
* @param {T} fallbackValue - Wartość fallback w przypadku błędu
* @param {string} context - Kontekst operacji
* @returns {Promise<T>} Wynik operacji lub wartość fallback
*/
export async function safeExecute<T>(operation: () => Promise<T>, fallbackValue: T, context = 'SafeExecute'): Promise<T> {
try {
return await operation();
} catch (error) {
errorHandler.handle(error as Error, context);
return fallbackValue;
}
}
/**
* Funkcja do retry operacji z exponential backoff
* @param {() => Promise<T>} 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<T>} Wynik operacji
*/
export async function retryWithBackoff<T>(operation: () => Promise<T>, maxRetries = 3, baseDelay = 1000, context = 'RetryOperation'): Promise<T> {
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;

32
src/ImageCache.ts Normal file
View File

@@ -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<string, ImageDataPixel>;
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();
}
}

View File

@@ -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<string, number>;
private imageReferences: Map<string, number>;
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<string>} Zbiór używanych imageId
*/
collectAllUsedImageIds(): Set<string> {
const usedImageIds = new Set<string>();
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<string>} usedImageIds - Zbiór używanych imageId
* @returns {Promise<string[]>} Lista nieużywanych imageId
*/
async findUnusedImages(usedImageIds: Set<string>): Promise<string[]> {
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<void> {
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<void> {
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<void> {
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");
}
}

339
src/MaskTool.ts Normal file
View File

@@ -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}).`);
}
}

5
src/config.ts Normal file
View File

@@ -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';

405
src/css/canvas_view.css Normal file
View File

@@ -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;
}

192
src/db.ts Normal file
View File

@@ -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<any>} Promise z wynikiem operacji
*/
function createDBRequest(store: IDBObjectStore, operation: DBRequestOperation, data: any, errorMessage: string): Promise<any> {
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<IDBDatabase> {
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<any | null> {
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<void> {
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<void> {
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<void> {
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<string | ImageBitmap | null> {
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<void> {
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<string[]> {
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<void> {
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.");
}

374
src/logger.ts Normal file
View File

@@ -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<LoggerConfig>} config - Obiekt konfiguracyjny
*/
configure(config: Partial<LoggerConfig>): 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;

93
src/state-saver.worker.ts Normal file
View File

@@ -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<any> {
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<IDBDatabase> {
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<void> {
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<void> {
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);
}
};

View File

@@ -0,0 +1,13 @@
<h4>📋 ComfyUI Clipspace Mode</h4>
<table>
<tr><td><kbd>Ctrl + C</kbd></td><td>Copy selected layers to internal clipboard + <strong>ComfyUI Clipspace</strong> as flattened image</td></tr>
<tr><td><kbd>Ctrl + V</kbd></td><td><strong>Priority:</strong></td></tr>
<tr><td></td><td>1⃣ Internal clipboard (copied layers)</td></tr>
<tr><td></td><td>2⃣ ComfyUI Clipspace (workflow images)</td></tr>
<tr><td></td><td>3⃣ System clipboard (fallback)</td></tr>
<tr><td><kbd>Paste Image</kbd></td><td>Same as Ctrl+V but respects fit_on_add setting</td></tr>
<tr><td><kbd>Drag & Drop</kbd></td><td>Load images directly from files</td></tr>
</table>
<div style="margin-top: 8px; padding: 6px; background: rgba(0,0,0,0.2); border-radius: 4px; font-size: 11px;">
💡 <strong>Bestt for:</strong> ComfyUI workflow integration and node-to-node image transfer
</div>

View File

@@ -0,0 +1,9 @@
<h4>Mask Mode</h4>
<table>
<tr><td><kbd>Click + Drag</kbd></td><td>Paint on the mask</td></tr>
<tr><td><kbd>Middle Mouse Button + Drag</kbd></td><td>Pan canvas view</td></tr>
<tr><td><kbd>Mouse Wheel</kbd></td><td>Zoom view in/out</td></tr>
<tr><td><strong>Brush Controls</strong></td><td>Use sliders to control brush <strong>Size</strong>, <strong>Strength</strong>, and <strong>Hardness</strong></td></tr>
<tr><td><strong>Clear Mask</strong></td><td>Remove the entire mask</td></tr>
<tr><td><strong>Exit Mode</strong></td><td>Click the "Draw Mask" button again</td></tr>
</table>

View File

@@ -0,0 +1,40 @@
<h4>Canvas Control</h4>
<table>
<tr><td><kbd>Click + Drag</kbd></td><td>Pan canvas view</td></tr>
<tr><td><kbd>Mouse Wheel</kbd></td><td>Zoom view in/out</td></tr>
<tr><td><kbd>Shift + Click (background)</kbd></td><td>Start resizing canvas area</td></tr>
<tr><td><kbd>Shift + Ctrl + Click</kbd></td><td>Start moving entire canvas</td></tr>
<tr><td><kbd>Single Click (background)</kbd></td><td>Deselect all layers</td></tr>
</table>
<h4>Clipboard & I/O</h4>
<table>
<tr><td><kbd>Ctrl + C</kbd></td><td>Copy selected layer(s)</td></tr>
<tr><td><kbd>Ctrl + V</kbd></td><td>Paste from clipboard (image or internal layers)</td></tr>
<tr><td><kbd>Drag & Drop Image File</kbd></td><td>Add image as a new layer</td></tr>
</table>
<h4>Layer Interaction</h4>
<table>
<tr><td><kbd>Click + Drag</kbd></td><td>Move selected layer(s)</td></tr>
<tr><td><kbd>Ctrl + Click</kbd></td><td>Add/Remove layer from selection</td></tr>
<tr><td><kbd>Alt + Drag</kbd></td><td>Clone selected layer(s)</td></tr>
<tr><td><kbd>Right Click</kbd></td><td>Show blend mode & opacity menu</td></tr>
<tr><td><kbd>Mouse Wheel</kbd></td><td>Scale layer (snaps to grid)</td></tr>
<tr><td><kbd>Ctrl + Mouse Wheel</kbd></td><td>Fine-scale layer</td></tr>
<tr><td><kbd>Shift + Mouse Wheel</kbd></td><td>Rotate layer by 5° steps</td></tr>
<tr><td><kbd>Shift + Ctrl + Mouse Wheel</kbd></td><td>Snap rotation to 5° increments</td></tr>
<tr><td><kbd>Arrow Keys</kbd></td><td>Nudge layer by 1px</td></tr>
<tr><td><kbd>Shift + Arrow Keys</kbd></td><td>Nudge layer by 10px</td></tr>
<tr><td><kbd>[</kbd> or <kbd>]</kbd></td><td>Rotate by 1°</td></tr>
<tr><td><kbd>Shift + [</kbd> or <kbd>]</kbd></td><td>Rotate by 10°</td></tr>
<tr><td><kbd>Delete</kbd></td><td>Delete selected layer(s)</td></tr>
</table>
<h4>Transform Handles (on selected layer)</h4>
<table>
<tr><td><kbd>Drag Corner/Side</kbd></td><td>Resize layer</td></tr>
<tr><td><kbd>Drag Rotation Handle</kbd></td><td>Rotate layer</td></tr>
<tr><td><kbd>Hold Shift</kbd></td><td>Keep aspect ratio / Snap rotation to 15°</td></tr>
<tr><td><kbd>Hold Ctrl</kbd></td><td>Snap to grid</td></tr>
</table>

View File

@@ -0,0 +1,16 @@
<h4>📋 System Clipboard Mode</h4>
<table>
<tr><td><kbd>Ctrl + C</kbd></td><td>Copy selected layers to internal clipboard + <strong>system clipboard</strong> as flattened image</td></tr>
<tr><td><kbd>Ctrl + V</kbd></td><td><strong>Priority:</strong></td></tr>
<tr><td></td><td>1⃣ Internal clipboard (copied layers)</td></tr>
<tr><td></td><td>2⃣ System clipboard (images, screenshots)</td></tr>
<tr><td></td><td>3⃣ System clipboard (file paths, URLs)</td></tr>
<tr><td><kbd>Paste Image</kbd></td><td>Same as Ctrl+V but respects fit_on_add setting</td></tr>
<tr><td><kbd>Drag & Drop</kbd></td><td>Load images directly from files</td></tr>
</table>
<div style="margin-top: 8px; padding: 6px; background: rgba(255,165,0,0.2); border: 1px solid rgba(255,165,0,0.4); border-radius: 4px; font-size: 11px;">
⚠️ <strong>Security Note:</strong> "Paste Image" button for external images may not work due to browser security restrictions. Use Ctrl+V instead or Drag & Drop.
</div>
<div style="margin-top: 8px; padding: 6px; background: rgba(0,0,0,0.2); border-radius: 4px; font-size: 11px;">
💡 <strong>Best for:</strong> Working with screenshots, copied images, file paths, and urls.
</div>

147
src/types.ts Normal file
View File

@@ -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<any>;
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<Layer>, addMode: string): Promise<Layer | null>;
}
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<WebSocketMessage>) => void;
reject: (reason?: any) => void;
}
export type AckCallbacks = Map<string, AckCallback>;
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;
}

View File

@@ -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<boolean>} - True if successful, false otherwise
*/
async handlePaste(addMode: AddMode = 'mouse', preference: ClipboardPreference = 'system'): Promise<boolean> {
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<boolean>} - True if successful, false otherwise
*/
async tryClipspacePaste(addMode: AddMode): Promise<boolean> {
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<boolean>} - True if successful, false otherwise
*/
async trySystemClipboardPaste(addMode: AddMode): Promise<boolean> {
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<boolean>} - True if successful, false otherwise
*/
async loadImageFromPath(filePath: string, addMode: AddMode): Promise<boolean> {
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<boolean>} - True if successful, false otherwise
*/
async loadFileViaBackend(filePath: string, addMode: AddMode): Promise<boolean> {
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<boolean>} - True if successful, false otherwise
*/
async promptUserForFile(originalPath: string, addMode: AddMode): Promise<boolean> {
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 = `
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 18px;">📁</span>
<span>${message}</span>
</div>
<div style="font-size: 12px; opacity: 0.9; margin-top: 4px;">
💡 Tip: You can also drag & drop files directly onto the canvas
</div>
`;
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);
}
}

289
src/utils/CommonUtils.ts Normal file
View File

@@ -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;
}

353
src/utils/ImageUtils.ts Normal file
View File

@@ -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<Tensor> {
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<HTMLImageElement> {
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<HTMLImageElement> {
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<HTMLImageElement> {
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<HTMLImageElement> {
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<HTMLImageElement> {
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<HTMLImageElement> {
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');

92
src/utils/LoggerUtils.ts Normal file
View File

@@ -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<T extends (...args: any[]) => any>(
operation: T,
log: Logger,
operationName: string
): (...args: Parameters<T>) => Promise<ReturnType<T>> {
return async function(this: any, ...args: Parameters<T>): Promise<ReturnType<T>> {
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;
};
}

View File

@@ -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<string> {
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();
}

View File

@@ -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<WebSocketMessage | void> {
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<WebSocketMessage>) => {
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);

196
src/utils/mask_utils.ts Normal file
View File

@@ -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<HTMLElement>(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<HTMLImageElement>} Promise zwracający obiekt Image
*/
export function create_mask_from_image_src(imageSrc: string): Promise<HTMLImageElement> {
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<HTMLImageElement>} Promise zwracający obiekt Image
*/
export function canvas_to_mask_image(canvas: HTMLCanvasElement): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = (err) => reject(err);
img.src = canvas.toDataURL();
});
}