From 14c5f291a61ec8b95fc16afc9070fe9e6d51f9f8 Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Sat, 26 Jul 2025 18:27:14 +0200 Subject: [PATCH] Refactor output area and mask handling for flexible canvas bounds This update introduces a unified output area bounds system, allowing the output area to be extended in all directions independently of the custom shape. All mask and layer operations now reference outputAreaBounds, ensuring correct alignment and rendering. The mask tool, mask editor, and export logic have been refactored to use these bounds, and a new UI for output area extension with live preview and tooltips has been added. The code also improves logging and visualization of mask and output area boundaries. --- js/Canvas.js | 16 +- js/CanvasIO.js | 95 +++------- js/CanvasInteractions.js | 115 +++++------- js/CanvasLayers.js | 322 +++++++++++++++++++++----------- js/CanvasMask.js | 37 ++-- js/CanvasRenderer.js | 72 ++++++- js/CustomShapeMenu.js | 270 ++++++++++++++++++++++++++- js/MaskTool.js | 86 +++++++-- src/Canvas.ts | 25 ++- src/CanvasIO.ts | 122 ++++-------- src/CanvasInteractions.ts | 136 ++++++-------- src/CanvasLayers.ts | 384 ++++++++++++++++++++++++++------------ src/CanvasMask.ts | 38 ++-- src/CanvasRenderer.ts | 86 ++++++++- src/CustomShapeMenu.ts | 322 +++++++++++++++++++++++++++++++- src/MaskTool.ts | 101 ++++++++-- src/types.ts | 7 + 17 files changed, 1637 insertions(+), 597 deletions(-) diff --git a/js/Canvas.js b/js/Canvas.js index 90e6089..490afcc 100644 --- a/js/Canvas.js +++ b/js/Canvas.js @@ -63,15 +63,20 @@ export class Canvas { this.pendingDataCheck = null; this.imageCache = new Map(); this.requestSaveState = () => { }; - this.maskTool = new MaskTool(this, { onStateChange: this.onStateChange }); - this.shapeTool = new ShapeTool(this); - this.customShapeMenu = new CustomShapeMenu(this); this.outputAreaShape = null; this.autoApplyShapeMask = false; this.shapeMaskExpansion = false; this.shapeMaskExpansionValue = 0; this.shapeMaskFeather = false; this.shapeMaskFeatherValue = 0; + this.outputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 }; + this.outputAreaExtensionEnabled = false; + this.outputAreaExtensionPreview = null; + this.originalCanvasSize = { width: this.width, height: this.height }; + this.outputAreaBounds = { x: 0, y: 0, width: this.width, height: this.height }; + this.maskTool = new MaskTool(this, { onStateChange: this.onStateChange }); + this.shapeTool = new ShapeTool(this); + this.customShapeMenu = new CustomShapeMenu(this); this.canvasMask = new CanvasMask(this); this.canvasState = new CanvasState(this); this.canvasSelection = new CanvasSelection(this); @@ -359,7 +364,10 @@ export class Canvas { * @param {boolean} saveHistory - Czy zapisać w historii */ updateOutputAreaSize(width, height, saveHistory = true) { - return this.canvasLayers.updateOutputAreaSize(width, height, saveHistory); + const result = this.canvasLayers.updateOutputAreaSize(width, height, saveHistory); + // Update mask canvas to ensure it covers the new output area + this.maskTool.updateMaskCanvasForOutputArea(); + return result; } /** * Eksportuje spłaszczony canvas jako blob diff --git a/js/CanvasIO.js b/js/CanvasIO.js index fd1f4f8..b2dea70 100644 --- a/js/CanvasIO.js +++ b/js/CanvasIO.js @@ -204,70 +204,32 @@ export class CanvasIO { }); } async _renderOutputData() { - return new Promise((resolve) => { - const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(this.canvas.width, this.canvas.height); - const { canvas: maskCanvas, ctx: maskCtx } = createCanvas(this.canvas.width, this.canvas.height); - const originalShape = this.canvas.outputAreaShape; - this.canvas.outputAreaShape = null; - 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); - this.canvas.canvasLayers.drawLayersToContext(tempCtx, this.canvas.layers); - this.canvas.canvasLayers.drawLayersToContext(visibilityCtx, this.canvas.layers); - 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'); - this.canvas.outputAreaShape = originalShape; - resolve({ image: imageDataUrl, mask: maskDataUrl }); + log.info("=== RENDERING OUTPUT DATA FOR COMFYUI ==="); + // Użyj zunifikowanych funkcji z CanvasLayers + const imageBlob = await this.canvas.canvasLayers.getFlattenedCanvasAsBlob(); + const maskBlob = await this.canvas.canvasLayers.getFlattenedMaskAsBlob(); + if (!imageBlob || !maskBlob) { + throw new Error("Failed to generate canvas or mask blobs"); + } + // Konwertuj blob na data URL + const imageDataUrl = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = reject; + reader.readAsDataURL(imageBlob); }); + const maskDataUrl = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = reject; + reader.readAsDataURL(maskBlob); + }); + const bounds = this.canvas.outputAreaBounds; + log.info(`=== OUTPUT DATA GENERATED ===`); + log.info(`Image size: ${bounds.width}x${bounds.height}`); + log.info(`Image data URL length: ${imageDataUrl.length}`); + log.info(`Mask data URL length: ${maskDataUrl.length}`); + return { image: imageDataUrl, mask: maskDataUrl }; } async sendDataViaWebSocket(nodeId) { log.info(`Preparing to send data for node ${nodeId} via WebSocket.`); @@ -302,10 +264,11 @@ export class CanvasIO { 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 bounds = this.canvas.outputAreaBounds; + const scale = Math.min(bounds.width / inputImage.width * 0.8, bounds.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, + x: bounds.x + (bounds.width - inputImage.width * scale) / 2, + y: bounds.y + (bounds.height - inputImage.height * scale) / 2, width: inputImage.width * scale, height: inputImage.height * scale, }); diff --git a/js/CanvasInteractions.js b/js/CanvasInteractions.js index 8e2cf52..b253a20 100644 --- a/js/CanvasInteractions.js +++ b/js/CanvasInteractions.js @@ -162,6 +162,7 @@ export class CanvasInteractions { } handleMouseUp(e) { const viewCoords = this.canvas.getMouseViewCoordinates(e); + const worldCoords = this.canvas.getMouseWorldCoordinates(e); if (this.interaction.mode === 'drawingMask') { this.canvas.maskTool.handleMouseUp(viewCoords); this.canvas.render(); @@ -173,6 +174,22 @@ export class CanvasInteractions { if (this.interaction.mode === 'movingCanvas') { this.finalizeCanvasMove(); } + // Log layer positions when dragging ends + if (this.interaction.mode === 'dragging' && this.canvas.canvasSelection.selectedLayers.length > 0) { + const bounds = this.canvas.outputAreaBounds; + log.info("=== LAYER DRAG COMPLETED ==="); + log.info(`Mouse position: world(${worldCoords.x.toFixed(1)}, ${worldCoords.y.toFixed(1)}) view(${viewCoords.x.toFixed(1)}, ${viewCoords.y.toFixed(1)})`); + log.info(`Output Area Bounds: x=${bounds.x}, y=${bounds.y}, w=${bounds.width}, h=${bounds.height}`); + log.info(`Viewport: x=${this.canvas.viewport.x.toFixed(1)}, y=${this.canvas.viewport.y.toFixed(1)}, zoom=${this.canvas.viewport.zoom.toFixed(2)}`); + this.canvas.canvasSelection.selectedLayers.forEach((layer, index) => { + const relativeToOutput = { + x: layer.x - bounds.x, + y: layer.y - bounds.y + }; + log.info(`Layer ${index + 1} "${layer.name}": world(${layer.x.toFixed(1)}, ${layer.y.toFixed(1)}) relative_to_output(${relativeToOutput.x.toFixed(1)}, ${relativeToOutput.y.toFixed(1)}) size(${layer.width.toFixed(1)}x${layer.height.toFixed(1)})`); + }); + log.info("=== END LAYER DRAG ==="); + } // 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; @@ -515,60 +532,34 @@ export class CanvasInteractions { startCanvasMove(worldCoords) { 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) { - 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); + // Po prostu przesuwamy outputAreaBounds + const bounds = this.canvas.outputAreaBounds; + this.interaction.canvasMoveRect = { + x: snapToGrid(bounds.x + dx), + y: snapToGrid(bounds.y + dy), + width: bounds.width, + height: bounds.height + }; this.canvas.render(); } finalizeCanvasMove() { const moveRect = this.interaction.canvasMoveRect; - if (moveRect && (moveRect.x !== 0 || moveRect.y !== 0)) { - const finalX = moveRect.x; - const finalY = moveRect.y; - this.canvas.layers.forEach((layer) => { - 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) => { - 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; + if (moveRect) { + // Po prostu aktualizujemy outputAreaBounds na nową pozycję + this.canvas.outputAreaBounds = { + x: moveRect.x, + y: moveRect.y, + width: moveRect.width, + height: moveRect.height + }; + // Update mask canvas to ensure it covers the new output area position + this.canvas.maskTool.updateMaskCanvasForOutputArea(); } this.canvas.render(); this.canvas.saveState(); @@ -724,35 +715,17 @@ export class CanvasInteractions { const newHeight = Math.round(this.interaction.canvasResizeRect.height); const finalX = this.interaction.canvasResizeRect.x; const finalY = this.interaction.canvasResizeRect.y; + // Po prostu aktualizujemy outputAreaBounds na nowy obszar + this.canvas.outputAreaBounds = { + x: finalX, + y: finalY, + width: newWidth, + height: newHeight + }; this.canvas.updateOutputAreaSize(newWidth, newHeight); - this.canvas.layers.forEach((layer) => { - layer.x -= finalX; - layer.y -= finalY; - }); - this.canvas.maskTool.updatePosition(-finalX, -finalY); - // If a batch generation is in progress, update the captured context as well - if (this.canvas.pendingBatchContext) { - this.canvas.pendingBatchContext.outputArea.x -= finalX; - this.canvas.pendingBatchContext.outputArea.y -= finalY; - // Also update the menu spawn position to keep it relative - this.canvas.pendingBatchContext.spawnPosition.x -= finalX; - this.canvas.pendingBatchContext.spawnPosition.y -= finalY; - log.debug("Updated pending batch context during canvas resize:", this.canvas.pendingBatchContext); - } - // Also move any active batch preview menus - if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) { - this.canvas.batchPreviewManagers.forEach((manager) => { - 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(); } handleDragOver(e) { e.preventDefault(); diff --git a/js/CanvasLayers.js b/js/CanvasLayers.js index 36abba4..7a602a1 100644 --- a/js/CanvasLayers.js +++ b/js/CanvasLayers.js @@ -22,8 +22,9 @@ export class CanvasLayers { let finalWidth = image.width; let finalHeight = image.height; let finalX, finalY; - // Use the targetArea if provided, otherwise default to the current canvas dimensions - const area = targetArea || { width: this.canvas.width, height: this.canvas.height, x: 0, y: 0 }; + // Use the targetArea if provided, otherwise default to the current output area bounds + const bounds = this.canvas.outputAreaBounds; + const area = targetArea || { width: bounds.width, height: bounds.height, x: bounds.x, y: bounds.y }; if (addMode === 'fit') { const scale = Math.min(area.width / image.width, area.height / image.height); finalWidth = image.width * scale; @@ -778,57 +779,129 @@ export class CanvasLayers { modeElement.appendChild(slider); } } - async getFlattenedCanvasWithMaskAsBlob() { + /** + * Zunifikowana funkcja do generowania blob z canvas + * @param options Opcje renderowania + */ + async _generateCanvasBlob(options = {}) { + const { layers = this.canvas.layers, useOutputBounds = true, applyMask = false, enableLogging = false, customBounds } = options; return new Promise((resolve, reject) => { + let bounds; + if (customBounds) { + bounds = customBounds; + } + else if (useOutputBounds) { + bounds = this.canvas.outputAreaBounds; + } + else { + // Oblicz bounding box dla wybranych warstw + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + layers.forEach((layer) => { + const centerX = layer.x + layer.width / 2; + const centerY = layer.y + layer.height / 2; + const rad = layer.rotation * Math.PI / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + const halfW = layer.width / 2; + const halfH = layer.height / 2; + const corners = [ + { x: -halfW, y: -halfH }, + { x: halfW, y: -halfH }, + { x: halfW, y: halfH }, + { x: -halfW, y: halfH } + ]; + corners.forEach(p => { + const worldX = centerX + (p.x * cos - p.y * sin); + const worldY = centerY + (p.x * sin + p.y * cos); + minX = Math.min(minX, worldX); + minY = Math.min(minY, worldY); + maxX = Math.max(maxX, worldX); + maxY = Math.max(maxY, worldY); + }); + }); + const newWidth = Math.ceil(maxX - minX); + const newHeight = Math.ceil(maxY - minY); + if (newWidth <= 0 || newHeight <= 0) { + resolve(null); + return; + } + bounds = { x: minX, y: minY, width: newWidth, height: newHeight }; + } const tempCanvas = document.createElement('canvas'); - tempCanvas.width = this.canvas.width; - tempCanvas.height = this.canvas.height; + tempCanvas.width = bounds.width; + tempCanvas.height = bounds.height; const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); if (!tempCtx) { reject(new Error("Could not create canvas context")); return; } - this._drawLayers(tempCtx, this.canvas.layers); - const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height); - const data = imageData.data; - const toolMaskCanvas = this.canvas.maskTool.getMask(); - if (toolMaskCanvas) { - const tempMaskCanvas = document.createElement('canvas'); - tempMaskCanvas.width = this.canvas.width; - tempMaskCanvas.height = this.canvas.height; - const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true }); - if (!tempMaskCtx) { - reject(new Error("Could not create mask canvas context")); - return; + if (enableLogging) { + log.info("=== GENERATING OUTPUT CANVAS ==="); + log.info(`Bounds: x=${bounds.x}, y=${bounds.y}, w=${bounds.width}, h=${bounds.height}`); + log.info(`Canvas Size: ${tempCanvas.width}x${tempCanvas.height}`); + log.info(`Context Translation: translate(${-bounds.x}, ${-bounds.y})`); + log.info(`Apply Mask: ${applyMask}`); + // Log layer positions before rendering + layers.forEach((layer, index) => { + if (layer.visible) { + const relativeToOutput = { + x: layer.x - bounds.x, + y: layer.y - bounds.y + }; + log.info(`Layer ${index + 1} "${layer.name}": world(${layer.x.toFixed(1)}, ${layer.y.toFixed(1)}) relative_to_bounds(${relativeToOutput.x.toFixed(1)}, ${relativeToOutput.y.toFixed(1)}) size(${layer.width.toFixed(1)}x${layer.height.toFixed(1)})`); + } + }); + } + // Renderuj fragment świata zdefiniowany przez bounds + tempCtx.translate(-bounds.x, -bounds.y); + this._drawLayers(tempCtx, layers); + // Aplikuj maskę jeśli wymagana + if (applyMask) { + const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height); + const data = imageData.data; + const toolMaskCanvas = this.canvas.maskTool.getMask(); + if (toolMaskCanvas) { + const tempMaskCanvas = document.createElement('canvas'); + tempMaskCanvas.width = bounds.width; + tempMaskCanvas.height = bounds.height; + const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true }); + if (!tempMaskCtx) { + reject(new Error("Could not create mask canvas context")); + return; + } + tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height); + // Pozycja maski w świecie (bez przesunięcia względem bounds) + const maskWorldX = this.canvas.maskTool.x; + const maskWorldY = this.canvas.maskTool.y; + // Pozycja maski względem output bounds (gdzie ma być narysowana w output canvas) + const maskX = maskWorldX - bounds.x; + const maskY = maskWorldY - bounds.y; + const sourceX = Math.max(0, -maskX); + const sourceY = Math.max(0, -maskY); + const destX = Math.max(0, maskX); + const destY = Math.max(0, maskY); + const copyWidth = Math.min(toolMaskCanvas.width - sourceX, bounds.width - destX); + const copyHeight = Math.min(toolMaskCanvas.height - sourceY, bounds.height - destY); + if (copyWidth > 0 && copyHeight > 0) { + tempMaskCtx.drawImage(toolMaskCanvas, sourceX, sourceY, copyWidth, copyHeight, destX, destY, copyWidth, copyHeight); + } + const tempMaskData = tempMaskCtx.getImageData(0, 0, bounds.width, bounds.height); + for (let i = 0; i < tempMaskData.data.length; i += 4) { + const alpha = tempMaskData.data[i + 3]; + tempMaskData.data[i] = tempMaskData.data[i + 1] = tempMaskData.data[i + 2] = 255; + tempMaskData.data[i + 3] = alpha; + } + tempMaskCtx.putImageData(tempMaskData, 0, 0); + const maskImageData = tempMaskCtx.getImageData(0, 0, bounds.width, bounds.height); + const maskData = maskImageData.data; + for (let i = 0; i < data.length; i += 4) { + const originalAlpha = data[i + 3]; + const maskAlpha = maskData[i + 3] / 255; + const invertedMaskAlpha = 1 - maskAlpha; + data[i + 3] = originalAlpha * invertedMaskAlpha; + } + tempCtx.putImageData(imageData, 0, 0); } - tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height); - const maskX = this.canvas.maskTool.x; - const maskY = this.canvas.maskTool.y; - const sourceX = Math.max(0, -maskX); - const sourceY = Math.max(0, -maskY); - const destX = Math.max(0, maskX); - const destY = Math.max(0, maskY); - const copyWidth = Math.min(toolMaskCanvas.width - sourceX, this.canvas.width - destX); - const copyHeight = Math.min(toolMaskCanvas.height - sourceY, this.canvas.height - destY); - if (copyWidth > 0 && copyHeight > 0) { - tempMaskCtx.drawImage(toolMaskCanvas, sourceX, sourceY, copyWidth, copyHeight, destX, destY, copyWidth, copyHeight); - } - const tempMaskData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height); - for (let i = 0; i < tempMaskData.data.length; i += 4) { - const alpha = tempMaskData.data[i + 3]; - tempMaskData.data[i] = tempMaskData.data[i + 1] = tempMaskData.data[i + 2] = 255; - tempMaskData.data[i + 3] = alpha; - } - tempMaskCtx.putImageData(tempMaskData, 0, 0); - const maskImageData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height); - const maskData = maskImageData.data; - for (let i = 0; i < data.length; i += 4) { - const originalAlpha = data[i + 3]; - const maskAlpha = maskData[i + 3] / 255; - const invertedMaskAlpha = 1 - maskAlpha; - data[i + 3] = originalAlpha * invertedMaskAlpha; - } - tempCtx.putImageData(imageData, 0, 0); } tempCanvas.toBlob((blob) => { if (blob) { @@ -840,77 +913,118 @@ export class CanvasLayers { }, 'image/png'); }); } + // Publiczne metody używające zunifikowanej funkcji + async getFlattenedCanvasWithMaskAsBlob() { + return this._generateCanvasBlob({ + layers: this.canvas.layers, + useOutputBounds: true, + applyMask: true, + enableLogging: true + }); + } async getFlattenedCanvasAsBlob() { - return new Promise((resolve, reject) => { - const tempCanvas = document.createElement('canvas'); - tempCanvas.width = this.canvas.width; - tempCanvas.height = this.canvas.height; - const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); - if (!tempCtx) { - reject(new Error("Could not create canvas context")); - return; - } - this._drawLayers(tempCtx, this.canvas.layers); - tempCanvas.toBlob((blob) => { - if (blob) { - resolve(blob); - } - else { - resolve(null); - } - }, 'image/png'); + return this._generateCanvasBlob({ + layers: this.canvas.layers, + useOutputBounds: true, + applyMask: false, + enableLogging: true }); } - async getFlattenedCanvasForMaskEditor() { - return this.getFlattenedCanvasWithMaskAsBlob(); - } async getFlattenedSelectionAsBlob() { if (this.canvas.canvasSelection.selectedLayers.length === 0) { return null; } + return this._generateCanvasBlob({ + layers: this.canvas.canvasSelection.selectedLayers, + useOutputBounds: false, + applyMask: false, + enableLogging: false + }); + } + async getFlattenedMaskAsBlob() { return new Promise((resolve, reject) => { - let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; - this.canvas.canvasSelection.selectedLayers.forEach((layer) => { - const centerX = layer.x + layer.width / 2; - const centerY = layer.y + layer.height / 2; - const rad = layer.rotation * Math.PI / 180; - const cos = Math.cos(rad); - const sin = Math.sin(rad); - const halfW = layer.width / 2; - const halfH = layer.height / 2; - const corners = [ - { x: -halfW, y: -halfH }, - { x: halfW, y: -halfH }, - { x: halfW, y: halfH }, - { x: -halfW, y: halfH } - ]; - corners.forEach(p => { - const worldX = centerX + (p.x * cos - p.y * sin); - const worldY = centerY + (p.x * sin + p.y * cos); - minX = Math.min(minX, worldX); - minY = Math.min(minY, worldY); - maxX = Math.max(maxX, worldX); - maxY = Math.max(maxY, worldY); - }); - }); - const newWidth = Math.ceil(maxX - minX); - const newHeight = Math.ceil(maxY - minY); - if (newWidth <= 0 || newHeight <= 0) { - resolve(null); + const bounds = this.canvas.outputAreaBounds; + const maskCanvas = document.createElement('canvas'); + maskCanvas.width = bounds.width; + maskCanvas.height = bounds.height; + const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true }); + if (!maskCtx) { + reject(new Error("Could not create mask context")); return; } - const tempCanvas = document.createElement('canvas'); - tempCanvas.width = newWidth; - tempCanvas.height = newHeight; - const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); - if (!tempCtx) { - reject(new Error("Could not create canvas context")); + log.info("=== GENERATING MASK BLOB ==="); + log.info(`Mask Canvas Size: ${maskCanvas.width}x${maskCanvas.height}`); + // Rozpocznij z białą maską (nic nie zamaskowane) + maskCtx.fillStyle = '#ffffff'; + maskCtx.fillRect(0, 0, bounds.width, bounds.height); + // Stwórz canvas do sprawdzenia przezroczystości warstw + const visibilityCanvas = document.createElement('canvas'); + visibilityCanvas.width = bounds.width; + visibilityCanvas.height = bounds.height; + const visibilityCtx = visibilityCanvas.getContext('2d', { alpha: true }); + if (!visibilityCtx) { + reject(new Error("Could not create visibility context")); return; } - tempCtx.translate(-minX, -minY); - this._drawLayers(tempCtx, this.canvas.canvasSelection.selectedLayers); - tempCanvas.toBlob((blob) => { - resolve(blob); + // Renderuj warstwy z przesunięciem dla output bounds + visibilityCtx.translate(-bounds.x, -bounds.y); + this._drawLayers(visibilityCtx, this.canvas.layers); + // Konwertuj przezroczystość warstw na maskę + const visibilityData = visibilityCtx.getImageData(0, 0, bounds.width, bounds.height); + const maskData = maskCtx.getImageData(0, 0, bounds.width, bounds.height); + for (let i = 0; i < visibilityData.data.length; i += 4) { + const alpha = visibilityData.data[i + 3]; + const maskValue = 255 - alpha; // Odwróć alpha żeby stworzyć maskę + maskData.data[i] = maskData.data[i + 1] = maskData.data[i + 2] = maskValue; + maskData.data[i + 3] = 255; // Solidna maska + } + maskCtx.putImageData(maskData, 0, 0); + // Aplikuj maskę narzędzia jeśli istnieje + const toolMaskCanvas = this.canvas.maskTool.getMask(); + if (toolMaskCanvas) { + const tempMaskCanvas = document.createElement('canvas'); + tempMaskCanvas.width = bounds.width; + tempMaskCanvas.height = bounds.height; + const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true }); + if (!tempMaskCtx) { + reject(new Error("Could not create temp mask context")); + return; + } + tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height); + // Pozycja maski w świecie (bez przesunięcia względem bounds) + const maskWorldX = this.canvas.maskTool.x; + const maskWorldY = this.canvas.maskTool.y; + // Pozycja maski względem output bounds (gdzie ma być narysowana w output canvas) + const maskX = maskWorldX - bounds.x; + const maskY = maskWorldY - bounds.y; + log.debug(`[getFlattenedMaskAsBlob] Mask world position (${maskWorldX}, ${maskWorldY}) relative to bounds (${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, bounds.width - destX); + const copyHeight = Math.min(toolMaskCanvas.height - sourceY, bounds.height - destY); + if (copyWidth > 0 && copyHeight > 0) { + tempMaskCtx.drawImage(toolMaskCanvas, sourceX, sourceY, copyWidth, copyHeight, destX, destY, copyWidth, copyHeight); + } + const tempMaskData = tempMaskCtx.getImageData(0, 0, bounds.width, bounds.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; // Solidna alpha + } + tempMaskCtx.putImageData(tempMaskData, 0, 0); + maskCtx.globalCompositeOperation = 'screen'; + maskCtx.drawImage(tempMaskCanvas, 0, 0); + } + log.info("=== MASK BLOB GENERATED ==="); + maskCanvas.toBlob((blob) => { + if (blob) { + resolve(blob); + } + else { + resolve(null); + } }, 'image/png'); }); } diff --git a/js/CanvasMask.js b/js/CanvasMask.js index fff8653..c3c23b7 100644 --- a/js/CanvasMask.js +++ b/js/CanvasMask.js @@ -48,7 +48,7 @@ export class CanvasMask { } else { log.debug('Getting flattened canvas for mask editor (with mask)'); - blob = await this.canvas.canvasLayers.getFlattenedCanvasForMaskEditor(); + blob = await this.canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob(); } if (!blob) { log.warn("Canvas is empty, cannot open mask editor."); @@ -251,9 +251,12 @@ export class CanvasMask { * @param {Object} maskColor - Kolor maski {r, g, b} * @returns {HTMLCanvasElement} Przetworzona maska */ async processMaskForEditor(maskData, targetWidth, targetHeight, maskColor) { - // Współrzędne przesunięcia (pan) widoku edytora - const panX = this.maskTool.x; - const panY = this.maskTool.y; + // Pozycja maski w świecie względem output bounds + const bounds = this.canvas.outputAreaBounds; + const maskWorldX = this.maskTool.x; + const maskWorldY = this.maskTool.y; + const panX = maskWorldX - bounds.x; + const panY = maskWorldY - bounds.y; log.info("Processing mask for editor:", { sourceSize: { width: maskData.width, height: maskData.height }, targetSize: { width: targetWidth, height: targetHeight }, @@ -416,14 +419,15 @@ export class CanvasMask { return; } log.debug("Creating temporary canvas for mask processing"); + const bounds = this.canvas.outputAreaBounds; const tempCanvas = document.createElement('canvas'); - tempCanvas.width = this.canvas.width; - tempCanvas.height = this.canvas.height; + tempCanvas.width = bounds.width; + tempCanvas.height = bounds.height; 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, bounds.width, bounds.height); 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, bounds.width, bounds.height); const data = imageData.data; for (let i = 0; i < data.length; i += 4) { const originalAlpha = data[i + 3]; @@ -439,11 +443,20 @@ export class CanvasMask { 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 }); + // Pozycja gdzie ma być aplikowana maska na canvas MaskTool + // MaskTool canvas ma pozycję (maskTool.x, maskTool.y) w świecie + // Maska z edytora reprezentuje output bounds, więc musimy ją umieścić + // w pozycji bounds względem pozycji MaskTool + const destX = bounds.x - this.maskTool.x; + const destY = bounds.y - this.maskTool.y; + log.debug("Applying mask to canvas", { + maskToolPos: { x: this.maskTool.x, y: this.maskTool.y }, + boundsPos: { x: bounds.x, y: bounds.y }, + destPos: { x: destX, y: destY }, + maskSize: { width: bounds.width, height: bounds.height } + }); maskCtx.globalCompositeOperation = 'source-over'; - maskCtx.clearRect(destX, destY, this.canvas.width, this.canvas.height); + maskCtx.clearRect(destX, destY, bounds.width, bounds.height); maskCtx.drawImage(maskAsImage, destX, destY); this.canvas.render(); this.canvas.saveState(); diff --git a/js/CanvasRenderer.js b/js/CanvasRenderer.js index 061c40a..fb71568 100644 --- a/js/CanvasRenderer.js +++ b/js/CanvasRenderer.js @@ -67,6 +67,7 @@ export class CanvasRenderer { } }); this.drawCanvasOutline(ctx); + this.drawOutputAreaExtensionPreview(ctx); // Draw extension preview this.drawPendingGenerationAreas(ctx); // Draw snapshot outlines const maskImage = this.canvas.maskTool.getMask(); if (maskImage && this.canvas.maskTool.isOverlayVisible) { @@ -79,12 +80,16 @@ export class CanvasRenderer { ctx.globalCompositeOperation = 'source-over'; ctx.globalAlpha = 1.0; } - ctx.drawImage(maskImage, this.canvas.maskTool.x, this.canvas.maskTool.y); + // Renderuj maskę w jej pozycji światowej (bez przesunięcia względem bounds) + const maskWorldX = this.canvas.maskTool.x; + const maskWorldY = this.canvas.maskTool.y; + ctx.drawImage(maskImage, maskWorldX, maskWorldY); ctx.globalAlpha = 1.0; ctx.restore(); } this.renderInteractionElements(ctx); this.canvas.shapeTool.render(ctx); + this.drawMaskAreaBounds(ctx); // Draw mask area bounds when mask tool is active this.renderLayerInfo(ctx); // Update custom shape menu position and visibility if (this.canvas.outputAreaShape) { @@ -256,7 +261,9 @@ export class CanvasRenderer { 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); + // Rysuj outline w pozycji outputAreaBounds + const bounds = this.canvas.outputAreaBounds; + ctx.rect(bounds.x, bounds.y, bounds.width, bounds.height); ctx.stroke(); ctx.setLineDash([]); if (this.canvas.outputAreaShape) { @@ -304,6 +311,29 @@ export class CanvasRenderer { ctx.stroke(); } } + drawOutputAreaExtensionPreview(ctx) { + if (!this.canvas.outputAreaExtensionPreview) { + return; + } + // Calculate preview bounds based on original canvas size + preview extensions + const baseWidth = this.canvas.originalCanvasSize ? this.canvas.originalCanvasSize.width : this.canvas.width; + const baseHeight = this.canvas.originalCanvasSize ? this.canvas.originalCanvasSize.height : this.canvas.height; + const ext = this.canvas.outputAreaExtensionPreview; + // Podgląd pokazuje jak będą wyglądać nowe outputAreaBounds + const previewBounds = { + x: -ext.left, // Może być ujemne - wycinamy fragment świata + y: -ext.top, // Może być ujemne - wycinamy fragment świata + width: baseWidth + ext.left + ext.right, + height: baseHeight + ext.top + ext.bottom + }; + ctx.save(); + ctx.strokeStyle = 'rgba(255, 255, 0, 0.8)'; // Yellow color for preview + ctx.lineWidth = 3 / this.canvas.viewport.zoom; + ctx.setLineDash([8 / this.canvas.viewport.zoom, 4 / this.canvas.viewport.zoom]); + ctx.strokeRect(previewBounds.x, previewBounds.y, previewBounds.width, previewBounds.height); + ctx.setLineDash([]); + ctx.restore(); + } drawPendingGenerationAreas(ctx) { const areasToDraw = []; // 1. Get areas from active managers @@ -331,4 +361,42 @@ export class CanvasRenderer { ctx.restore(); }); } + drawMaskAreaBounds(ctx) { + // Only show mask area bounds when mask tool is active + if (!this.canvas.maskTool.isActive) { + return; + } + const maskTool = this.canvas.maskTool; + // Get mask canvas bounds in world coordinates + const maskBounds = { + x: maskTool.x, + y: maskTool.y, + width: maskTool.getMask().width, + height: maskTool.getMask().height + }; + ctx.save(); + ctx.strokeStyle = 'rgba(255, 100, 100, 0.7)'; // Red color for mask area bounds + ctx.lineWidth = 2 / this.canvas.viewport.zoom; + ctx.setLineDash([6 / this.canvas.viewport.zoom, 6 / this.canvas.viewport.zoom]); + ctx.strokeRect(maskBounds.x, maskBounds.y, maskBounds.width, maskBounds.height); + ctx.setLineDash([]); + // Add text label to show this is the mask drawing area + const textWorldX = maskBounds.x + maskBounds.width / 2; + const textWorldY = maskBounds.y - (10 / this.canvas.viewport.zoom); + 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 = "12px sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + const text = "Mask Drawing Area"; + const textMetrics = ctx.measureText(text); + const bgWidth = textMetrics.width + 8; + const bgHeight = 18; + ctx.fillStyle = "rgba(255, 100, 100, 0.8)"; + ctx.fillRect(screenX - bgWidth / 2, screenY - bgHeight / 2, bgWidth, bgHeight); + ctx.fillStyle = "white"; + ctx.fillText(text, screenX, screenY); + ctx.restore(); + } } diff --git a/js/CustomShapeMenu.js b/js/CustomShapeMenu.js index b282803..5ffd335 100644 --- a/js/CustomShapeMenu.js +++ b/js/CustomShapeMenu.js @@ -7,6 +7,7 @@ export class CustomShapeMenu { this.worldX = 0; this.worldY = 0; this.uiInitialized = false; + this.tooltip = null; } show() { if (!this.canvas.outputAreaShape) { @@ -29,6 +30,7 @@ export class CustomShapeMenu { this.element = null; this.uiInitialized = false; } + this.hideTooltip(); } updateScreenPosition() { if (!this.element) @@ -99,7 +101,7 @@ export class CustomShapeMenu { } this._updateUI(); this.canvas.render(); - }); + }, "Automatically applies a mask based on the custom output area shape. When enabled, the mask will be applied to all layers within the shape boundary."); featureContainer.appendChild(checkboxContainer); // Add expansion checkbox const expansionContainer = this._createCheckbox(() => `${this.canvas.shapeMaskExpansion ? "☑" : "☐"} Expand/Contract mask`, () => { @@ -109,7 +111,7 @@ export class CustomShapeMenu { this.canvas.maskTool.applyShapeMask(); this.canvas.render(); } - }); + }, "Dilate (expand) or erode (contract) the shape mask. Positive values expand the mask outward, negative values shrink it inward."); expansionContainer.id = 'expansion-checkbox'; featureContainer.appendChild(expansionContainer); // Add expansion slider container @@ -188,7 +190,7 @@ export class CustomShapeMenu { this.canvas.maskTool.applyShapeMask(); this.canvas.render(); } - }); + }, "Softens the edges of the shape mask by creating a gradual transition from opaque to transparent."); featherContainer.id = 'feather-checkbox'; featureContainer.appendChild(featherContainer); // Add feather slider container @@ -260,6 +262,135 @@ export class CustomShapeMenu { featherSliderContainer.appendChild(featherValueDisplay); featureContainer.appendChild(featherSliderContainer); this.element.appendChild(featureContainer); + // Create output area extension container + const extensionContainer = document.createElement('div'); + extensionContainer.id = 'output-area-extension-container'; + extensionContainer.style.cssText = ` + background-color: #282828; + border-radius: 6px; + margin-top: 6px; + padding: 4px 0; + border: 1px solid #444; + `; + // Add main extension checkbox + const extensionCheckboxContainer = this._createCheckbox(() => `${this.canvas.outputAreaExtensionEnabled ? "☑" : "☐"} Extend output area`, () => { + this.canvas.outputAreaExtensionEnabled = !this.canvas.outputAreaExtensionEnabled; + if (this.canvas.outputAreaExtensionEnabled) { + // When enabling, capture current canvas size as the baseline + this.canvas.originalCanvasSize = { + width: this.canvas.width, + height: this.canvas.height + }; + log.info(`Captured current canvas size as baseline: ${this.canvas.width}x${this.canvas.height}`); + } + else { + // Reset all extensions when disabled + this.canvas.outputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 }; + } + this._updateExtensionUI(); + this._updateCanvasSize(); // Update canvas size when toggling + this.canvas.render(); + log.info(`Output area extension ${this.canvas.outputAreaExtensionEnabled ? 'enabled' : 'disabled'}`); + }, "Allows extending the output area boundaries in all directions without changing the custom shape."); + extensionContainer.appendChild(extensionCheckboxContainer); + // Create sliders container + const slidersContainer = document.createElement('div'); + slidersContainer.id = 'extension-sliders-container'; + slidersContainer.style.cssText = ` + margin: 0 8px 6px 8px; + padding: 4px 8px; + display: none; + `; + // Helper function to create a slider with preview system + const createExtensionSlider = (label, direction) => { + const sliderContainer = document.createElement('div'); + sliderContainer.style.cssText = ` + margin: 6px 0; + `; + const sliderLabel = document.createElement('div'); + sliderLabel.textContent = label; + sliderLabel.style.cssText = ` + font-size: 11px; + margin-bottom: 4px; + color: #ccc; + `; + const slider = document.createElement('input'); + slider.type = 'range'; + slider.min = '0'; + slider.max = '500'; + slider.value = String(this.canvas.outputAreaExtensions[direction]); + slider.style.cssText = ` + width: 100%; + height: 4px; + background: #555; + outline: none; + border-radius: 2px; + `; + const valueDisplay = document.createElement('div'); + valueDisplay.style.cssText = ` + font-size: 10px; + text-align: center; + margin-top: 2px; + color: #aaa; + `; + const updateDisplay = () => { + const value = parseInt(slider.value); + valueDisplay.textContent = `${value}px`; + }; + let isDragging = false; + slider.onmousedown = () => { + isDragging = true; + }; + slider.oninput = () => { + updateDisplay(); + if (isDragging) { + // During dragging, show preview + const previewExtensions = { ...this.canvas.outputAreaExtensions }; + previewExtensions[direction] = parseInt(slider.value); + this.canvas.outputAreaExtensionPreview = previewExtensions; + this.canvas.render(); + } + else { + // Not dragging, apply immediately (for keyboard navigation) + this.canvas.outputAreaExtensions[direction] = parseInt(slider.value); + this._updateCanvasSize(); + this.canvas.render(); + } + }; + slider.onmouseup = () => { + if (isDragging) { + isDragging = false; + // Apply the final value and clear preview + this.canvas.outputAreaExtensions[direction] = parseInt(slider.value); + this.canvas.outputAreaExtensionPreview = null; + this._updateCanvasSize(); + this.canvas.render(); + } + }; + // Handle mouse leave (in case user drags outside) + slider.onmouseleave = () => { + if (isDragging) { + isDragging = false; + // Apply the final value and clear preview + this.canvas.outputAreaExtensions[direction] = parseInt(slider.value); + this.canvas.outputAreaExtensionPreview = null; + this._updateCanvasSize(); + this.canvas.render(); + } + }; + updateDisplay(); + sliderContainer.appendChild(sliderLabel); + sliderContainer.appendChild(slider); + sliderContainer.appendChild(valueDisplay); + return sliderContainer; + }; + // Add all four sliders + slidersContainer.appendChild(createExtensionSlider('Top extension:', 'top')); + slidersContainer.appendChild(createExtensionSlider('Bottom extension:', 'bottom')); + slidersContainer.appendChild(createExtensionSlider('Left extension:', 'left')); + slidersContainer.appendChild(createExtensionSlider('Right extension:', 'right')); + extensionContainer.appendChild(slidersContainer); + this.element.appendChild(extensionContainer); // Add to DOM if (this.canvas.canvas.parentElement) { this.canvas.canvas.parentElement.appendChild(this.element); @@ -272,7 +403,7 @@ export class CustomShapeMenu { // Add viewport change listener to update shape preview when zooming/panning this._addViewportChangeListener(); } - _createCheckbox(textFn, clickHandler) { + _createCheckbox(textFn, clickHandler, tooltipText) { const container = document.createElement('div'); container.style.cssText = ` margin: 6px 0 2px 0; @@ -298,6 +429,10 @@ export class CustomShapeMenu { clickHandler(); updateText(); }; + // Add tooltip if provided + if (tooltipText) { + this._addTooltip(container, tooltipText); + } return container; } _updateUI() { @@ -333,8 +468,36 @@ export class CustomShapeMenu { else if (index === 2) { // Feather checkbox checkbox.textContent = `${this.canvas.shapeMaskFeather ? "☑" : "☐"} Feather edges`; } + else if (index === 3) { // Extension checkbox + checkbox.textContent = `${this.canvas.outputAreaExtensionEnabled ? "☑" : "☐"} Extend output area`; + } }); } + _updateExtensionUI() { + if (!this.element) + return; + // Toggle visibility of extension sliders based on the extension checkbox state + const extensionSlidersContainer = this.element.querySelector('#extension-sliders-container'); + if (extensionSlidersContainer) { + extensionSlidersContainer.style.display = this.canvas.outputAreaExtensionEnabled ? 'block' : 'none'; + } + // Update slider values if they exist + if (this.canvas.outputAreaExtensionEnabled) { + const sliders = extensionSlidersContainer?.querySelectorAll('input[type="range"]'); + const directions = ['top', 'bottom', 'left', 'right']; + sliders?.forEach((slider, index) => { + const direction = directions[index]; + if (direction) { + slider.value = String(this.canvas.outputAreaExtensions[direction]); + // Update the corresponding value display + const valueDisplay = slider.parentElement?.querySelector('div:last-child'); + if (valueDisplay) { + valueDisplay.textContent = `${this.canvas.outputAreaExtensions[direction]}px`; + } + } + }); + } + } /** * Add viewport change listener to update shape preview when zooming/panning */ @@ -373,4 +536,103 @@ export class CustomShapeMenu { // Start the viewport change detection requestAnimationFrame(checkViewportChange); } + _addTooltip(element, text) { + element.addEventListener('mouseenter', (e) => { + this.showTooltip(text, e); + }); + element.addEventListener('mouseleave', () => { + this.hideTooltip(); + }); + element.addEventListener('mousemove', (e) => { + if (this.tooltip && this.tooltip.style.display === 'block') { + this.updateTooltipPosition(e); + } + }); + } + showTooltip(text, event) { + this.hideTooltip(); // Hide any existing tooltip + this.tooltip = document.createElement('div'); + this.tooltip.textContent = text; + this.tooltip.style.cssText = ` + position: fixed; + background-color: #1a1a1a; + color: #ffffff; + padding: 8px 12px; + border-radius: 6px; + font-size: 12px; + font-family: sans-serif; + line-height: 1.4; + max-width: 250px; + word-wrap: break-word; + box-shadow: 0 4px 12px rgba(0,0,0,0.6); + border: 1px solid #444; + z-index: 10000; + pointer-events: none; + opacity: 0; + transition: opacity 0.2s ease-in-out; + `; + document.body.appendChild(this.tooltip); + this.updateTooltipPosition(event); + // Fade in the tooltip + requestAnimationFrame(() => { + if (this.tooltip) { + this.tooltip.style.opacity = '1'; + } + }); + } + updateTooltipPosition(event) { + if (!this.tooltip) + return; + const tooltipRect = this.tooltip.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + let x = event.clientX + 10; + let y = event.clientY - 10; + // Adjust if tooltip would go off the right edge + if (x + tooltipRect.width > viewportWidth) { + x = event.clientX - tooltipRect.width - 10; + } + // Adjust if tooltip would go off the bottom edge + if (y + tooltipRect.height > viewportHeight) { + y = event.clientY - tooltipRect.height - 10; + } + // Ensure tooltip doesn't go off the left or top edges + x = Math.max(5, x); + y = Math.max(5, y); + this.tooltip.style.left = `${x}px`; + this.tooltip.style.top = `${y}px`; + } + hideTooltip() { + if (this.tooltip) { + this.tooltip.remove(); + this.tooltip = null; + } + } + _updateCanvasSize() { + if (!this.canvas.outputAreaExtensionEnabled) { + // Reset to original bounds when disabled + this.canvas.outputAreaBounds = { + x: 0, + y: 0, + width: this.canvas.originalCanvasSize.width, + height: this.canvas.originalCanvasSize.height + }; + this.canvas.updateOutputAreaSize(this.canvas.originalCanvasSize.width, this.canvas.originalCanvasSize.height, false); + return; + } + const ext = this.canvas.outputAreaExtensions; + const newWidth = this.canvas.originalCanvasSize.width + ext.left + ext.right; + const newHeight = this.canvas.originalCanvasSize.height + ext.top + ext.bottom; + // Aktualizuj outputAreaBounds - "okno" w świecie które zostanie wyrenderowane + this.canvas.outputAreaBounds = { + x: -ext.left, // Może być ujemne - wycinamy fragment świata + y: -ext.top, // Może być ujemne - wycinamy fragment świata + width: newWidth, + height: newHeight + }; + // Zmień rozmiar canvas (fizyczny rozmiar dla renderowania) + this.canvas.updateOutputAreaSize(newWidth, newHeight, false); + log.info(`Output area bounds updated: x=${this.canvas.outputAreaBounds.x}, y=${this.canvas.outputAreaBounds.y}, w=${newWidth}, h=${newHeight}`); + log.info(`Extensions: top=${ext.top}, bottom=${ext.bottom}, left=${ext.left}, right=${ext.right}`); + } } diff --git a/js/MaskTool.js b/js/MaskTool.js index dd67208..57d9559 100644 --- a/js/MaskTool.js +++ b/js/MaskTool.js @@ -59,12 +59,18 @@ export class MaskTool { } initMaskCanvas() { const extraSpace = 2000; // Allow for a generous drawing area outside the output area - this.maskCanvas.width = this.canvasInstance.width + extraSpace; - this.maskCanvas.height = this.canvasInstance.height + extraSpace; - this.x = -extraSpace / 2; - this.y = -extraSpace / 2; + const bounds = this.canvasInstance.outputAreaBounds; + // Mask canvas should cover output area + extra space around it + const maskLeft = bounds.x - extraSpace / 2; + const maskTop = bounds.y - extraSpace / 2; + const maskWidth = bounds.width + extraSpace; + const maskHeight = bounds.height + extraSpace; + this.maskCanvas.width = maskWidth; + this.maskCanvas.height = maskHeight; + this.x = maskLeft; + this.y = maskTop; 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 size: ${this.maskCanvas.width}x${this.maskCanvas.height}, positioned at (${this.x}, ${this.y}) to cover output area at (${bounds.x}, ${bounds.y})`); } activate() { if (!this.previewCanvasInitialized) { @@ -582,24 +588,82 @@ export class MaskTool { this.y += dy; log.info(`Mask position updated to (${this.x}, ${this.y})`); } + /** + * Updates mask canvas to ensure it covers the current output area + * This should be called when output area position or size changes + */ + updateMaskCanvasForOutputArea() { + const extraSpace = 2000; + const bounds = this.canvasInstance.outputAreaBounds; + // Calculate required mask canvas bounds + const requiredLeft = bounds.x - extraSpace / 2; + const requiredTop = bounds.y - extraSpace / 2; + const requiredWidth = bounds.width + extraSpace; + const requiredHeight = bounds.height + extraSpace; + // Check if current mask canvas covers the required area + const currentRight = this.x + this.maskCanvas.width; + const currentBottom = this.y + this.maskCanvas.height; + const requiredRight = requiredLeft + requiredWidth; + const requiredBottom = requiredTop + requiredHeight; + const needsResize = requiredLeft < this.x || + requiredTop < this.y || + requiredRight > currentRight || + requiredBottom > currentBottom; + if (needsResize) { + log.info(`Updating mask canvas to cover output area at (${bounds.x}, ${bounds.y})`); + // Save current mask content + const oldMask = this.maskCanvas; + const oldX = this.x; + const oldY = this.y; + // Create new mask canvas with proper size and position + this.maskCanvas = document.createElement('canvas'); + this.maskCanvas.width = requiredWidth; + this.maskCanvas.height = requiredHeight; + this.x = requiredLeft; + this.y = requiredTop; + 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; + // Copy old mask content to new position + if (oldMask.width > 0 && oldMask.height > 0) { + const offsetX = oldX - this.x; + const offsetY = oldY - this.y; + this.maskCtx.drawImage(oldMask, offsetX, offsetY); + log.debug(`Preserved mask content with offset (${offsetX}, ${offsetY})`); + } + log.info(`Mask canvas updated to ${this.maskCanvas.width}x${this.maskCanvas.height} at (${this.x}, ${this.y})`); + } + } toggleOverlayVisibility() { this.isOverlayVisible = !this.isOverlayVisible; log.info(`Mask overlay visibility toggled to: ${this.isOverlayVisible}`); } setMask(image) { - const destX = -this.x; - const destY = -this.y; + // Pozycja gdzie ma być aplikowana maska na canvas MaskTool + // MaskTool canvas ma pozycję (this.x, this.y) w świecie + // Maska reprezentuje output bounds, więc musimy ją umieścić + // w pozycji bounds względem pozycji MaskTool + const bounds = this.canvasInstance.outputAreaBounds; + const destX = bounds.x - this.x; + const destY = bounds.y - 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}).`); + log.info(`MaskTool updated with a new mask image at position (${destX}, ${destY}) relative to bounds (${bounds.x}, ${bounds.y}).`); } addMask(image) { - const destX = -this.x; - const destY = -this.y; + // Pozycja gdzie ma być aplikowana maska na canvas MaskTool + // MaskTool canvas ma pozycję (this.x, this.y) w świecie + // Maska z SAM reprezentuje output bounds, więc musimy ją umieścić + // w pozycji bounds względem pozycji MaskTool + const bounds = this.canvasInstance.outputAreaBounds; + const destX = bounds.x - this.x; + const destY = bounds.y - this.y; // Don't clear existing mask - just add to it this.maskCtx.globalCompositeOperation = 'source-over'; this.maskCtx.drawImage(image, destX, destY); @@ -607,7 +671,7 @@ export class MaskTool { this.onStateChange(); } this.canvasInstance.render(); - log.info(`MaskTool added mask overlay at correct canvas position (${destX}, ${destY}) without clearing existing mask.`); + log.info(`MaskTool added SAM mask overlay at position (${destX}, ${destY}) relative to bounds (${bounds.x}, ${bounds.y}) without clearing existing mask.`); } applyShapeMask(saveState = true) { if (!this.canvasInstance.outputAreaShape?.points || this.canvasInstance.outputAreaShape.points.length < 3) { diff --git a/src/Canvas.ts b/src/Canvas.ts index de36ab0..9f9c47d 100644 --- a/src/Canvas.ts +++ b/src/Canvas.ts @@ -21,7 +21,7 @@ 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, Shape } from './types'; +import type { ComfyNode, Layer, Viewport, Point, AddMode, Shape, OutputAreaBounds } from './types'; const useChainCallback = (original: any, next: any) => { if (original === undefined || original === null) { @@ -73,6 +73,11 @@ export class Canvas { shapeMaskExpansionValue: number; shapeMaskFeather: boolean; shapeMaskFeatherValue: number; + outputAreaExtensions: { top: number, bottom: number, left: number, right: number }; + outputAreaExtensionEnabled: boolean; + outputAreaExtensionPreview: { top: number, bottom: number, left: number, right: number } | null; + originalCanvasSize: { width: number, height: number }; + outputAreaBounds: OutputAreaBounds; node: ComfyNode; offscreenCanvas: HTMLCanvasElement; offscreenCtx: CanvasRenderingContext2D | null; @@ -117,15 +122,20 @@ export class Canvas { this.imageCache = new Map(); this.requestSaveState = () => {}; - this.maskTool = new MaskTool(this, {onStateChange: this.onStateChange}); - this.shapeTool = new ShapeTool(this); - this.customShapeMenu = new CustomShapeMenu(this); this.outputAreaShape = null; this.autoApplyShapeMask = false; this.shapeMaskExpansion = false; this.shapeMaskExpansionValue = 0; this.shapeMaskFeather = false; this.shapeMaskFeatherValue = 0; + this.outputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 }; + this.outputAreaExtensionEnabled = false; + this.outputAreaExtensionPreview = null; + this.originalCanvasSize = { width: this.width, height: this.height }; + this.outputAreaBounds = { x: 0, y: 0, width: this.width, height: this.height }; + this.maskTool = new MaskTool(this, {onStateChange: this.onStateChange}); + this.shapeTool = new ShapeTool(this); + this.customShapeMenu = new CustomShapeMenu(this); this.canvasMask = new CanvasMask(this); this.canvasState = new CanvasState(this); this.canvasSelection = new CanvasSelection(this); @@ -464,7 +474,12 @@ export class Canvas { * @param {boolean} saveHistory - Czy zapisać w historii */ updateOutputAreaSize(width: number, height: number, saveHistory = true) { - return this.canvasLayers.updateOutputAreaSize(width, height, saveHistory); + const result = this.canvasLayers.updateOutputAreaSize(width, height, saveHistory); + + // Update mask canvas to ensure it covers the new output area + this.maskTool.updateMaskCanvasForOutputArea(); + + return result; } /** diff --git a/src/CanvasIO.ts b/src/CanvasIO.ts index a0b2342..8e7b339 100644 --- a/src/CanvasIO.ts +++ b/src/CanvasIO.ts @@ -234,89 +234,38 @@ export class CanvasIO { } 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 originalShape = this.canvas.outputAreaShape; - this.canvas.outputAreaShape = null; - - 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); - - this.canvas.canvasLayers.drawLayersToContext(tempCtx, this.canvas.layers); - this.canvas.canvasLayers.drawLayersToContext(visibilityCtx, this.canvas.layers); - - 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'); - - this.canvas.outputAreaShape = originalShape; - - resolve({image: imageDataUrl, mask: maskDataUrl}); + log.info("=== RENDERING OUTPUT DATA FOR COMFYUI ==="); + + // Użyj zunifikowanych funkcji z CanvasLayers + const imageBlob = await this.canvas.canvasLayers.getFlattenedCanvasAsBlob(); + const maskBlob = await this.canvas.canvasLayers.getFlattenedMaskAsBlob(); + + if (!imageBlob || !maskBlob) { + throw new Error("Failed to generate canvas or mask blobs"); + } + + // Konwertuj blob na data URL + const imageDataUrl = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(imageBlob); }); + + const maskDataUrl = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(maskBlob); + }); + + const bounds = this.canvas.outputAreaBounds; + log.info(`=== OUTPUT DATA GENERATED ===`); + log.info(`Image size: ${bounds.width}x${bounds.height}`); + log.info(`Image data URL length: ${imageDataUrl.length}`); + log.info(`Mask data URL length: ${maskDataUrl.length}`); + + return { image: imageDataUrl, mask: maskDataUrl }; } async sendDataViaWebSocket(nodeId: number): Promise { @@ -364,14 +313,15 @@ export class CanvasIO { image.src = tempCanvas.toDataURL(); }); + const bounds = this.canvas.outputAreaBounds; const scale = Math.min( - this.canvas.width / inputImage.width * 0.8, - this.canvas.height / inputImage.height * 0.8 + bounds.width / inputImage.width * 0.8, + bounds.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, + x: bounds.x + (bounds.width - inputImage.width * scale) / 2, + y: bounds.y + (bounds.height - inputImage.height * scale) / 2, width: inputImage.width * scale, height: inputImage.height * scale, }); diff --git a/src/CanvasInteractions.ts b/src/CanvasInteractions.ts index a9cbed0..6b6fd62 100644 --- a/src/CanvasInteractions.ts +++ b/src/CanvasInteractions.ts @@ -212,6 +212,8 @@ export class CanvasInteractions { handleMouseUp(e: MouseEvent): void { const viewCoords = this.canvas.getMouseViewCoordinates(e); + const worldCoords = this.canvas.getMouseWorldCoordinates(e); + if (this.interaction.mode === 'drawingMask') { this.canvas.maskTool.handleMouseUp(viewCoords); this.canvas.render(); @@ -225,6 +227,24 @@ export class CanvasInteractions { this.finalizeCanvasMove(); } + // Log layer positions when dragging ends + if (this.interaction.mode === 'dragging' && this.canvas.canvasSelection.selectedLayers.length > 0) { + const bounds = this.canvas.outputAreaBounds; + log.info("=== LAYER DRAG COMPLETED ==="); + log.info(`Mouse position: world(${worldCoords.x.toFixed(1)}, ${worldCoords.y.toFixed(1)}) view(${viewCoords.x.toFixed(1)}, ${viewCoords.y.toFixed(1)})`); + log.info(`Output Area Bounds: x=${bounds.x}, y=${bounds.y}, w=${bounds.width}, h=${bounds.height}`); + log.info(`Viewport: x=${this.canvas.viewport.x.toFixed(1)}, y=${this.canvas.viewport.y.toFixed(1)}, zoom=${this.canvas.viewport.zoom.toFixed(2)}`); + + this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer, index: number) => { + const relativeToOutput = { + x: layer.x - bounds.x, + y: layer.y - bounds.y + }; + log.info(`Layer ${index + 1} "${layer.name}": world(${layer.x.toFixed(1)}, ${layer.y.toFixed(1)}) relative_to_output(${relativeToOutput.x.toFixed(1)}, ${relativeToOutput.y.toFixed(1)}) size(${layer.width.toFixed(1)}x${layer.height.toFixed(1)})`); + }); + log.info("=== END LAYER DRAG ==="); + } + // 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; @@ -580,28 +600,22 @@ export class CanvasInteractions { 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); + + // Po prostu przesuwamy outputAreaBounds + const bounds = this.canvas.outputAreaBounds; + this.interaction.canvasMoveRect = { + x: snapToGrid(bounds.x + dx), + y: snapToGrid(bounds.y + dy), + width: bounds.width, + height: bounds.height + }; this.canvas.render(); } @@ -609,43 +623,19 @@ export class CanvasInteractions { 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; + if (moveRect) { + // Po prostu aktualizujemy outputAreaBounds na nową pozycję + this.canvas.outputAreaBounds = { + x: moveRect.x, + y: moveRect.y, + width: moveRect.width, + height: moveRect.height + }; + + // Update mask canvas to ensure it covers the new output area position + this.canvas.maskTool.updateMaskCanvasForOutputArea(); } + this.canvas.render(); this.canvas.saveState(); } @@ -821,41 +811,19 @@ export class CanvasInteractions { const finalX = this.interaction.canvasResizeRect.x; const finalY = this.interaction.canvasResizeRect.y; + // Po prostu aktualizujemy outputAreaBounds na nowy obszar + this.canvas.outputAreaBounds = { + x: finalX, + y: finalY, + width: newWidth, + height: newHeight + }; + 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; } + + this.canvas.render(); + this.canvas.saveState(); } handleDragOver(e: DragEvent): void { diff --git a/src/CanvasLayers.ts b/src/CanvasLayers.ts index fcdee9e..0e92c89 100644 --- a/src/CanvasLayers.ts +++ b/src/CanvasLayers.ts @@ -180,8 +180,9 @@ export class CanvasLayers { let finalHeight = image.height; let finalX, finalY; - // Use the targetArea if provided, otherwise default to the current canvas dimensions - const area = targetArea || { width: this.canvas.width, height: this.canvas.height, x: 0, y: 0 }; + // Use the targetArea if provided, otherwise default to the current output area bounds + const bounds = this.canvas.outputAreaBounds; + const area = targetArea || { width: bounds.width, height: bounds.height, x: bounds.x, y: bounds.y }; if (addMode === 'fit') { const scale = Math.min(area.width / image.width, area.height / image.height); @@ -563,6 +564,7 @@ export class CanvasLayers { if (saveHistory) { this.canvas.saveState(); } + this.canvas.width = width; this.canvas.height = height; this.canvas.maskTool.resize(width, height); @@ -895,44 +897,286 @@ export class CanvasLayers { } } - async getFlattenedCanvasWithMaskAsBlob(): Promise { + /** + * Zunifikowana funkcja do generowania blob z canvas + * @param options Opcje renderowania + */ + private async _generateCanvasBlob(options: { + layers?: Layer[]; // Które warstwy renderować (domyślnie wszystkie) + useOutputBounds?: boolean; // Czy używać output area bounds (domyślnie true) + applyMask?: boolean; // Czy aplikować maskę (domyślnie false) + enableLogging?: boolean; // Czy włączyć szczegółowe logi (domyślnie false) + customBounds?: { x: number, y: number, width: number, height: number }; // Niestandardowe bounds + } = {}): Promise { + const { + layers = this.canvas.layers, + useOutputBounds = true, + applyMask = false, + enableLogging = false, + customBounds + } = options; + return new Promise((resolve, reject) => { + let bounds: { x: number, y: number, width: number, height: number }; + + if (customBounds) { + bounds = customBounds; + } else if (useOutputBounds) { + bounds = this.canvas.outputAreaBounds; + } else { + // Oblicz bounding box dla wybranych warstw + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + layers.forEach((layer: Layer) => { + const centerX = layer.x + layer.width / 2; + const centerY = layer.y + layer.height / 2; + const rad = layer.rotation * Math.PI / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + + const halfW = layer.width / 2; + const halfH = layer.height / 2; + + const corners = [ + { x: -halfW, y: -halfH }, + { x: halfW, y: -halfH }, + { x: halfW, y: halfH }, + { x: -halfW, y: halfH } + ]; + + corners.forEach(p => { + const worldX = centerX + (p.x * cos - p.y * sin); + const worldY = centerY + (p.x * sin + p.y * cos); + + minX = Math.min(minX, worldX); + minY = Math.min(minY, worldY); + maxX = Math.max(maxX, worldX); + maxY = Math.max(maxY, worldY); + }); + }); + + const newWidth = Math.ceil(maxX - minX); + const newHeight = Math.ceil(maxY - minY); + + if (newWidth <= 0 || newHeight <= 0) { + resolve(null); + return; + } + + bounds = { x: minX, y: minY, width: newWidth, height: newHeight }; + } + const tempCanvas = document.createElement('canvas'); - tempCanvas.width = this.canvas.width; - tempCanvas.height = this.canvas.height; + tempCanvas.width = bounds.width; + tempCanvas.height = bounds.height; const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); if (!tempCtx) { reject(new Error("Could not create canvas context")); return; } - this._drawLayers(tempCtx, this.canvas.layers); + if (enableLogging) { + log.info("=== GENERATING OUTPUT CANVAS ==="); + log.info(`Bounds: x=${bounds.x}, y=${bounds.y}, w=${bounds.width}, h=${bounds.height}`); + log.info(`Canvas Size: ${tempCanvas.width}x${tempCanvas.height}`); + log.info(`Context Translation: translate(${-bounds.x}, ${-bounds.y})`); + log.info(`Apply Mask: ${applyMask}`); + + // Log layer positions before rendering + layers.forEach((layer: Layer, index: number) => { + if (layer.visible) { + const relativeToOutput = { + x: layer.x - bounds.x, + y: layer.y - bounds.y + }; + log.info(`Layer ${index + 1} "${layer.name}": world(${layer.x.toFixed(1)}, ${layer.y.toFixed(1)}) relative_to_bounds(${relativeToOutput.x.toFixed(1)}, ${relativeToOutput.y.toFixed(1)}) size(${layer.width.toFixed(1)}x${layer.height.toFixed(1)})`); + } + }); + } - const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height); - const data = imageData.data; + // Renderuj fragment świata zdefiniowany przez bounds + tempCtx.translate(-bounds.x, -bounds.y); + this._drawLayers(tempCtx, layers); + // Aplikuj maskę jeśli wymagana + if (applyMask) { + const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height); + const data = imageData.data; + + const toolMaskCanvas = this.canvas.maskTool.getMask(); + if (toolMaskCanvas) { + const tempMaskCanvas = document.createElement('canvas'); + tempMaskCanvas.width = bounds.width; + tempMaskCanvas.height = bounds.height; + const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true }); + if (!tempMaskCtx) { + reject(new Error("Could not create mask canvas context")); + return; + } + + tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height); + + // Pozycja maski w świecie (bez przesunięcia względem bounds) + const maskWorldX = this.canvas.maskTool.x; + const maskWorldY = this.canvas.maskTool.y; + + // Pozycja maski względem output bounds (gdzie ma być narysowana w output canvas) + const maskX = maskWorldX - bounds.x; + const maskY = maskWorldY - bounds.y; + + const sourceX = Math.max(0, -maskX); + const sourceY = Math.max(0, -maskY); + const destX = Math.max(0, maskX); + const destY = Math.max(0, maskY); + const copyWidth = Math.min(toolMaskCanvas.width - sourceX, bounds.width - destX); + const copyHeight = Math.min(toolMaskCanvas.height - sourceY, bounds.height - destY); + + if (copyWidth > 0 && copyHeight > 0) { + tempMaskCtx.drawImage( + toolMaskCanvas, + sourceX, sourceY, copyWidth, copyHeight, + destX, destY, copyWidth, copyHeight + ); + } + + const tempMaskData = tempMaskCtx.getImageData(0, 0, bounds.width, bounds.height); + for (let i = 0; i < tempMaskData.data.length; i += 4) { + const alpha = tempMaskData.data[i + 3]; + tempMaskData.data[i] = tempMaskData.data[i + 1] = tempMaskData.data[i + 2] = 255; + tempMaskData.data[i + 3] = alpha; + } + tempMaskCtx.putImageData(tempMaskData, 0, 0); + + const maskImageData = tempMaskCtx.getImageData(0, 0, bounds.width, bounds.height); + const maskData = maskImageData.data; + + for (let i = 0; i < data.length; i += 4) { + const originalAlpha = data[i + 3]; + const maskAlpha = maskData[i + 3] / 255; + const invertedMaskAlpha = 1 - maskAlpha; + data[i + 3] = originalAlpha * invertedMaskAlpha; + } + tempCtx.putImageData(imageData, 0, 0); + } + } + + tempCanvas.toBlob((blob) => { + if (blob) { + resolve(blob); + } else { + resolve(null); + } + }, 'image/png'); + }); + } + + // Publiczne metody używające zunifikowanej funkcji + async getFlattenedCanvasWithMaskAsBlob(): Promise { + return this._generateCanvasBlob({ + layers: this.canvas.layers, + useOutputBounds: true, + applyMask: true, + enableLogging: true + }); + } + + async getFlattenedCanvasAsBlob(): Promise { + return this._generateCanvasBlob({ + layers: this.canvas.layers, + useOutputBounds: true, + applyMask: false, + enableLogging: true + }); + } + + async getFlattenedSelectionAsBlob(): Promise { + if (this.canvas.canvasSelection.selectedLayers.length === 0) { + return null; + } + + return this._generateCanvasBlob({ + layers: this.canvas.canvasSelection.selectedLayers, + useOutputBounds: false, + applyMask: false, + enableLogging: false + }); + } + + async getFlattenedMaskAsBlob(): Promise { + return new Promise((resolve, reject) => { + const bounds = this.canvas.outputAreaBounds; + const maskCanvas = document.createElement('canvas'); + maskCanvas.width = bounds.width; + maskCanvas.height = bounds.height; + const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true }); + + if (!maskCtx) { + reject(new Error("Could not create mask context")); + return; + } + + log.info("=== GENERATING MASK BLOB ==="); + log.info(`Mask Canvas Size: ${maskCanvas.width}x${maskCanvas.height}`); + + // Rozpocznij z białą maską (nic nie zamaskowane) + maskCtx.fillStyle = '#ffffff'; + maskCtx.fillRect(0, 0, bounds.width, bounds.height); + + // Stwórz canvas do sprawdzenia przezroczystości warstw + const visibilityCanvas = document.createElement('canvas'); + visibilityCanvas.width = bounds.width; + visibilityCanvas.height = bounds.height; + const visibilityCtx = visibilityCanvas.getContext('2d', { alpha: true }); + if (!visibilityCtx) { + reject(new Error("Could not create visibility context")); + return; + } + + // Renderuj warstwy z przesunięciem dla output bounds + visibilityCtx.translate(-bounds.x, -bounds.y); + this._drawLayers(visibilityCtx, this.canvas.layers); + + // Konwertuj przezroczystość warstw na maskę + const visibilityData = visibilityCtx.getImageData(0, 0, bounds.width, bounds.height); + const maskData = maskCtx.getImageData(0, 0, bounds.width, bounds.height); + for (let i = 0; i < visibilityData.data.length; i += 4) { + const alpha = visibilityData.data[i + 3]; + const maskValue = 255 - alpha; // Odwróć alpha żeby stworzyć maskę + maskData.data[i] = maskData.data[i + 1] = maskData.data[i + 2] = maskValue; + maskData.data[i + 3] = 255; // Solidna maska + } + maskCtx.putImageData(maskData, 0, 0); + + // Aplikuj maskę narzędzia jeśli istnieje const toolMaskCanvas = this.canvas.maskTool.getMask(); if (toolMaskCanvas) { const tempMaskCanvas = document.createElement('canvas'); - tempMaskCanvas.width = this.canvas.width; - tempMaskCanvas.height = this.canvas.height; + tempMaskCanvas.width = bounds.width; + tempMaskCanvas.height = bounds.height; const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true }); if (!tempMaskCtx) { - reject(new Error("Could not create mask canvas context")); + reject(new Error("Could not create temp mask context")); return; } tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height); - const maskX = this.canvas.maskTool.x; - const maskY = this.canvas.maskTool.y; + // Pozycja maski w świecie (bez przesunięcia względem bounds) + const maskWorldX = this.canvas.maskTool.x; + const maskWorldY = this.canvas.maskTool.y; + + // Pozycja maski względem output bounds (gdzie ma być narysowana w output canvas) + const maskX = maskWorldX - bounds.x; + const maskY = maskWorldY - bounds.y; + + log.debug(`[getFlattenedMaskAsBlob] Mask world position (${maskWorldX}, ${maskWorldY}) relative to bounds (${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); + + const copyWidth = Math.min(toolMaskCanvas.width - sourceX, bounds.width - destX); + const copyHeight = Math.min(toolMaskCanvas.height - sourceY, bounds.height - destY); if (copyWidth > 0 && copyHeight > 0) { tempMaskCtx.drawImage( @@ -942,27 +1186,21 @@ export class CanvasLayers { ); } - const tempMaskData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height); + const tempMaskData = tempMaskCtx.getImageData(0, 0, bounds.width, bounds.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; + tempMaskData.data[i] = tempMaskData.data[i + 1] = tempMaskData.data[i + 2] = alpha; + tempMaskData.data[i + 3] = 255; // Solidna alpha } tempMaskCtx.putImageData(tempMaskData, 0, 0); - const maskImageData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height); - const maskData = maskImageData.data; - - for (let i = 0; i < data.length; i += 4) { - const originalAlpha = data[i + 3]; - const maskAlpha = maskData[i + 3] / 255; - const invertedMaskAlpha = 1 - maskAlpha; - data[i + 3] = originalAlpha * invertedMaskAlpha; - } - tempCtx.putImageData(imageData, 0, 0); + maskCtx.globalCompositeOperation = 'screen'; + maskCtx.drawImage(tempMaskCanvas, 0, 0); } - tempCanvas.toBlob((blob) => { + log.info("=== MASK BLOB GENERATED ==="); + + maskCanvas.toBlob((blob) => { if (blob) { resolve(blob); } else { @@ -971,94 +1209,6 @@ export class CanvasLayers { }, 'image/png'); }); } - - async getFlattenedCanvasAsBlob(): Promise { - return new Promise((resolve, reject) => { - const tempCanvas = document.createElement('canvas'); - tempCanvas.width = this.canvas.width; - tempCanvas.height = this.canvas.height; - const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); - if (!tempCtx) { - reject(new Error("Could not create canvas context")); - return; - } - - this._drawLayers(tempCtx, this.canvas.layers); - - tempCanvas.toBlob((blob) => { - if (blob) { - resolve(blob); - } else { - resolve(null); - } - }, 'image/png'); - }); - } - - async getFlattenedCanvasForMaskEditor(): Promise { - return this.getFlattenedCanvasWithMaskAsBlob(); - } - - async getFlattenedSelectionAsBlob(): Promise { - if (this.canvas.canvasSelection.selectedLayers.length === 0) { - return null; - } - - return new Promise((resolve, reject) => { - let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; - this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => { - const centerX = layer.x + layer.width / 2; - const centerY = layer.y + layer.height / 2; - const rad = layer.rotation * Math.PI / 180; - const cos = Math.cos(rad); - const sin = Math.sin(rad); - - const halfW = layer.width / 2; - const halfH = layer.height / 2; - - const corners = [ - { x: -halfW, y: -halfH }, - { x: halfW, y: -halfH }, - { x: halfW, y: halfH }, - { x: -halfW, y: halfH } - ]; - - corners.forEach(p => { - const worldX = centerX + (p.x * cos - p.y * sin); - const worldY = centerY + (p.x * sin + p.y * cos); - - minX = Math.min(minX, worldX); - minY = Math.min(minY, worldY); - maxX = Math.max(maxX, worldX); - maxY = Math.max(maxY, worldY); - }); - }); - - const newWidth = Math.ceil(maxX - minX); - const newHeight = Math.ceil(maxY - minY); - - if (newWidth <= 0 || newHeight <= 0) { - resolve(null); - return; - } - const tempCanvas = document.createElement('canvas'); - tempCanvas.width = newWidth; - tempCanvas.height = newHeight; - const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); - if (!tempCtx) { - reject(new Error("Could not create canvas context")); - return; - } - - tempCtx.translate(-minX, -minY); - - this._drawLayers(tempCtx, this.canvas.canvasSelection.selectedLayers); - - tempCanvas.toBlob((blob) => { - resolve(blob); - }, 'image/png'); - }); - } async fuseLayers(): Promise { if (this.canvas.canvasSelection.selectedLayers.length < 2) { diff --git a/src/CanvasMask.ts b/src/CanvasMask.ts index ff7df6c..7057a5e 100644 --- a/src/CanvasMask.ts +++ b/src/CanvasMask.ts @@ -61,7 +61,7 @@ export class CanvasMask { blob = await this.canvas.canvasLayers.getFlattenedCanvasAsBlob(); } else { log.debug('Getting flattened canvas for mask editor (with mask)'); - blob = await this.canvas.canvasLayers.getFlattenedCanvasForMaskEditor(); + blob = await this.canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob(); } if (!blob) { @@ -306,9 +306,12 @@ export class CanvasMask { * @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; + // Pozycja maski w świecie względem output bounds + const bounds = this.canvas.outputAreaBounds; + const maskWorldX = this.maskTool.x; + const maskWorldY = this.maskTool.y; + const panX = maskWorldX - bounds.x; + const panY = maskWorldY - bounds.y; log.info("Processing mask for editor:", { sourceSize: {width: maskData.width, height: maskData.height}, @@ -500,16 +503,17 @@ export class CanvasMask { } log.debug("Creating temporary canvas for mask processing"); + const bounds = this.canvas.outputAreaBounds; const tempCanvas = document.createElement('canvas'); - tempCanvas.width = this.canvas.width; - tempCanvas.height = this.canvas.height; + tempCanvas.width = bounds.width; + tempCanvas.height = bounds.height; 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, bounds.width, bounds.height); 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, bounds.width, bounds.height); const data = imageData.data; for (let i = 0; i < data.length; i += 4) { @@ -529,13 +533,23 @@ export class CanvasMask { await new Promise(resolve => maskAsImage.onload = resolve); const maskCtx = this.maskTool.maskCtx; - const destX = -this.maskTool.x; - const destY = -this.maskTool.y; + + // Pozycja gdzie ma być aplikowana maska na canvas MaskTool + // MaskTool canvas ma pozycję (maskTool.x, maskTool.y) w świecie + // Maska z edytora reprezentuje output bounds, więc musimy ją umieścić + // w pozycji bounds względem pozycji MaskTool + const destX = bounds.x - this.maskTool.x; + const destY = bounds.y - this.maskTool.y; - log.debug("Applying mask to canvas", {destX, destY}); + log.debug("Applying mask to canvas", { + maskToolPos: {x: this.maskTool.x, y: this.maskTool.y}, + boundsPos: {x: bounds.x, y: bounds.y}, + destPos: {x: destX, y: destY}, + maskSize: {width: bounds.width, height: bounds.height} + }); maskCtx.globalCompositeOperation = 'source-over'; - maskCtx.clearRect(destX, destY, this.canvas.width, this.canvas.height); + maskCtx.clearRect(destX, destY, bounds.width, bounds.height); maskCtx.drawImage(maskAsImage, destX, destY); diff --git a/src/CanvasRenderer.ts b/src/CanvasRenderer.ts index 1000aeb..e4478a8 100644 --- a/src/CanvasRenderer.ts +++ b/src/CanvasRenderer.ts @@ -84,6 +84,7 @@ export class CanvasRenderer { }); this.drawCanvasOutline(ctx); + this.drawOutputAreaExtensionPreview(ctx); // Draw extension preview this.drawPendingGenerationAreas(ctx); // Draw snapshot outlines const maskImage = this.canvas.maskTool.getMask(); if (maskImage && this.canvas.maskTool.isOverlayVisible) { @@ -98,7 +99,10 @@ export class CanvasRenderer { ctx.globalAlpha = 1.0; } - ctx.drawImage(maskImage, this.canvas.maskTool.x, this.canvas.maskTool.y); + // Renderuj maskę w jej pozycji światowej (bez przesunięcia względem bounds) + const maskWorldX = this.canvas.maskTool.x; + const maskWorldY = this.canvas.maskTool.y; + ctx.drawImage(maskImage, maskWorldX, maskWorldY); ctx.globalAlpha = 1.0; ctx.restore(); @@ -106,6 +110,7 @@ export class CanvasRenderer { this.renderInteractionElements(ctx); this.canvas.shapeTool.render(ctx); + this.drawMaskAreaBounds(ctx); // Draw mask area bounds when mask tool is active this.renderLayerInfo(ctx); // Update custom shape menu position and visibility @@ -303,7 +308,9 @@ export class CanvasRenderer { 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); + // Rysuj outline w pozycji outputAreaBounds + const bounds = this.canvas.outputAreaBounds; + ctx.rect(bounds.x, bounds.y, bounds.width, bounds.height); ctx.stroke(); ctx.setLineDash([]); @@ -358,6 +365,34 @@ export class CanvasRenderer { } } + drawOutputAreaExtensionPreview(ctx: any) { + if (!this.canvas.outputAreaExtensionPreview) { + return; + } + + // Calculate preview bounds based on original canvas size + preview extensions + const baseWidth = this.canvas.originalCanvasSize ? this.canvas.originalCanvasSize.width : this.canvas.width; + const baseHeight = this.canvas.originalCanvasSize ? this.canvas.originalCanvasSize.height : this.canvas.height; + + const ext = this.canvas.outputAreaExtensionPreview; + + // Podgląd pokazuje jak będą wyglądać nowe outputAreaBounds + const previewBounds = { + x: -ext.left, // Może być ujemne - wycinamy fragment świata + y: -ext.top, // Może być ujemne - wycinamy fragment świata + width: baseWidth + ext.left + ext.right, + height: baseHeight + ext.top + ext.bottom + }; + + ctx.save(); + ctx.strokeStyle = 'rgba(255, 255, 0, 0.8)'; // Yellow color for preview + ctx.lineWidth = 3 / this.canvas.viewport.zoom; + ctx.setLineDash([8 / this.canvas.viewport.zoom, 4 / this.canvas.viewport.zoom]); + ctx.strokeRect(previewBounds.x, previewBounds.y, previewBounds.width, previewBounds.height); + ctx.setLineDash([]); + ctx.restore(); + } + drawPendingGenerationAreas(ctx: any) { const areasToDraw = []; @@ -389,4 +424,51 @@ export class CanvasRenderer { ctx.restore(); }); } + + drawMaskAreaBounds(ctx: any) { + // Only show mask area bounds when mask tool is active + if (!this.canvas.maskTool.isActive) { + return; + } + + const maskTool = this.canvas.maskTool; + + // Get mask canvas bounds in world coordinates + const maskBounds = { + x: maskTool.x, + y: maskTool.y, + width: maskTool.getMask().width, + height: maskTool.getMask().height + }; + + ctx.save(); + ctx.strokeStyle = 'rgba(255, 100, 100, 0.7)'; // Red color for mask area bounds + ctx.lineWidth = 2 / this.canvas.viewport.zoom; + ctx.setLineDash([6 / this.canvas.viewport.zoom, 6 / this.canvas.viewport.zoom]); + ctx.strokeRect(maskBounds.x, maskBounds.y, maskBounds.width, maskBounds.height); + ctx.setLineDash([]); + + // Add text label to show this is the mask drawing area + const textWorldX = maskBounds.x + maskBounds.width / 2; + const textWorldY = maskBounds.y - (10 / this.canvas.viewport.zoom); + + 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 = "12px sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + const text = "Mask Drawing Area"; + const textMetrics = ctx.measureText(text); + const bgWidth = textMetrics.width + 8; + const bgHeight = 18; + + ctx.fillStyle = "rgba(255, 100, 100, 0.8)"; + ctx.fillRect(screenX - bgWidth / 2, screenY - bgHeight / 2, bgWidth, bgHeight); + ctx.fillStyle = "white"; + ctx.fillText(text, screenX, screenY); + + ctx.restore(); + } } diff --git a/src/CustomShapeMenu.ts b/src/CustomShapeMenu.ts index 1ca4246..9160ce7 100644 --- a/src/CustomShapeMenu.ts +++ b/src/CustomShapeMenu.ts @@ -9,6 +9,7 @@ export class CustomShapeMenu { private worldX: number; private worldY: number; private uiInitialized: boolean; + private tooltip: HTMLDivElement | null; constructor(canvas: Canvas) { this.canvas = canvas; @@ -16,6 +17,7 @@ export class CustomShapeMenu { this.worldX = 0; this.worldY = 0; this.uiInitialized = false; + this.tooltip = null; } show(): void { @@ -44,6 +46,7 @@ export class CustomShapeMenu { this.element = null; this.uiInitialized = false; } + this.hideTooltip(); } updateScreenPosition(): void { @@ -124,7 +127,8 @@ export class CustomShapeMenu { this._updateUI(); this.canvas.render(); - } + }, + "Automatically applies a mask based on the custom output area shape. When enabled, the mask will be applied to all layers within the shape boundary." ); featureContainer.appendChild(checkboxContainer); @@ -139,7 +143,8 @@ export class CustomShapeMenu { this.canvas.maskTool.applyShapeMask(); this.canvas.render(); } - } + }, + "Dilate (expand) or erode (contract) the shape mask. Positive values expand the mask outward, negative values shrink it inward." ); expansionContainer.id = 'expansion-checkbox'; featureContainer.appendChild(expansionContainer); @@ -233,7 +238,8 @@ export class CustomShapeMenu { this.canvas.maskTool.applyShapeMask(); this.canvas.render(); } - } + }, + "Softens the edges of the shape mask by creating a gradual transition from opaque to transparent." ); featherContainer.id = 'feather-checkbox'; featureContainer.appendChild(featherContainer); @@ -317,6 +323,157 @@ export class CustomShapeMenu { featureContainer.appendChild(featherSliderContainer); this.element.appendChild(featureContainer); + + // Create output area extension container + const extensionContainer = document.createElement('div'); + extensionContainer.id = 'output-area-extension-container'; + extensionContainer.style.cssText = ` + background-color: #282828; + border-radius: 6px; + margin-top: 6px; + padding: 4px 0; + border: 1px solid #444; + `; + + // Add main extension checkbox + const extensionCheckboxContainer = this._createCheckbox( + () => `${this.canvas.outputAreaExtensionEnabled ? "☑" : "☐"} Extend output area`, + () => { + this.canvas.outputAreaExtensionEnabled = !this.canvas.outputAreaExtensionEnabled; + + if (this.canvas.outputAreaExtensionEnabled) { + // When enabling, capture current canvas size as the baseline + this.canvas.originalCanvasSize = { + width: this.canvas.width, + height: this.canvas.height + }; + log.info(`Captured current canvas size as baseline: ${this.canvas.width}x${this.canvas.height}`); + } else { + // Reset all extensions when disabled + this.canvas.outputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 }; + } + + this._updateExtensionUI(); + this._updateCanvasSize(); // Update canvas size when toggling + this.canvas.render(); + log.info(`Output area extension ${this.canvas.outputAreaExtensionEnabled ? 'enabled' : 'disabled'}`); + }, + "Allows extending the output area boundaries in all directions without changing the custom shape." + ); + extensionContainer.appendChild(extensionCheckboxContainer); + + // Create sliders container + const slidersContainer = document.createElement('div'); + slidersContainer.id = 'extension-sliders-container'; + slidersContainer.style.cssText = ` + margin: 0 8px 6px 8px; + padding: 4px 8px; + display: none; + `; + + // Helper function to create a slider with preview system + const createExtensionSlider = (label: string, direction: 'top' | 'bottom' | 'left' | 'right') => { + const sliderContainer = document.createElement('div'); + sliderContainer.style.cssText = ` + margin: 6px 0; + `; + + const sliderLabel = document.createElement('div'); + sliderLabel.textContent = label; + sliderLabel.style.cssText = ` + font-size: 11px; + margin-bottom: 4px; + color: #ccc; + `; + + const slider = document.createElement('input'); + slider.type = 'range'; + slider.min = '0'; + slider.max = '500'; + slider.value = String(this.canvas.outputAreaExtensions[direction]); + slider.style.cssText = ` + width: 100%; + height: 4px; + background: #555; + outline: none; + border-radius: 2px; + `; + + const valueDisplay = document.createElement('div'); + valueDisplay.style.cssText = ` + font-size: 10px; + text-align: center; + margin-top: 2px; + color: #aaa; + `; + + const updateDisplay = () => { + const value = parseInt(slider.value); + valueDisplay.textContent = `${value}px`; + }; + + let isDragging = false; + + slider.onmousedown = () => { + isDragging = true; + }; + + slider.oninput = () => { + updateDisplay(); + + if (isDragging) { + // During dragging, show preview + const previewExtensions = { ...this.canvas.outputAreaExtensions }; + previewExtensions[direction] = parseInt(slider.value); + this.canvas.outputAreaExtensionPreview = previewExtensions; + this.canvas.render(); + } else { + // Not dragging, apply immediately (for keyboard navigation) + this.canvas.outputAreaExtensions[direction] = parseInt(slider.value); + this._updateCanvasSize(); + this.canvas.render(); + } + }; + + slider.onmouseup = () => { + if (isDragging) { + isDragging = false; + // Apply the final value and clear preview + this.canvas.outputAreaExtensions[direction] = parseInt(slider.value); + this.canvas.outputAreaExtensionPreview = null; + this._updateCanvasSize(); + this.canvas.render(); + } + }; + + // Handle mouse leave (in case user drags outside) + slider.onmouseleave = () => { + if (isDragging) { + isDragging = false; + // Apply the final value and clear preview + this.canvas.outputAreaExtensions[direction] = parseInt(slider.value); + this.canvas.outputAreaExtensionPreview = null; + this._updateCanvasSize(); + this.canvas.render(); + } + }; + + updateDisplay(); + + sliderContainer.appendChild(sliderLabel); + sliderContainer.appendChild(slider); + sliderContainer.appendChild(valueDisplay); + return sliderContainer; + }; + + // Add all four sliders + slidersContainer.appendChild(createExtensionSlider('Top extension:', 'top')); + slidersContainer.appendChild(createExtensionSlider('Bottom extension:', 'bottom')); + slidersContainer.appendChild(createExtensionSlider('Left extension:', 'left')); + slidersContainer.appendChild(createExtensionSlider('Right extension:', 'right')); + + extensionContainer.appendChild(slidersContainer); + this.element.appendChild(extensionContainer); // Add to DOM if (this.canvas.canvas.parentElement) { @@ -332,7 +489,7 @@ export class CustomShapeMenu { this._addViewportChangeListener(); } - private _createCheckbox(textFn: () => string, clickHandler: () => void): HTMLDivElement { + private _createCheckbox(textFn: () => string, clickHandler: () => void, tooltipText?: string): HTMLDivElement { const container = document.createElement('div'); container.style.cssText = ` margin: 6px 0 2px 0; @@ -363,6 +520,11 @@ export class CustomShapeMenu { updateText(); }; + // Add tooltip if provided + if (tooltipText) { + this._addTooltip(container, tooltipText); + } + return container; } @@ -400,10 +562,40 @@ export class CustomShapeMenu { checkbox.textContent = `${this.canvas.shapeMaskExpansion ? "☑" : "☐"} Dilate/Erode mask`; } else if (index === 2) { // Feather checkbox checkbox.textContent = `${this.canvas.shapeMaskFeather ? "☑" : "☐"} Feather edges`; + } else if (index === 3) { // Extension checkbox + checkbox.textContent = `${this.canvas.outputAreaExtensionEnabled ? "☑" : "☐"} Extend output area`; } }); } + private _updateExtensionUI(): void { + if (!this.element) return; + + // Toggle visibility of extension sliders based on the extension checkbox state + const extensionSlidersContainer = this.element.querySelector('#extension-sliders-container') as HTMLElement; + if (extensionSlidersContainer) { + extensionSlidersContainer.style.display = this.canvas.outputAreaExtensionEnabled ? 'block' : 'none'; + } + + // Update slider values if they exist + if (this.canvas.outputAreaExtensionEnabled) { + const sliders = extensionSlidersContainer?.querySelectorAll('input[type="range"]'); + const directions: ('top' | 'bottom' | 'left' | 'right')[] = ['top', 'bottom', 'left', 'right']; + + sliders?.forEach((slider, index) => { + const direction = directions[index]; + if (direction) { + (slider as HTMLInputElement).value = String(this.canvas.outputAreaExtensions[direction]); + // Update the corresponding value display + const valueDisplay = slider.parentElement?.querySelector('div:last-child'); + if (valueDisplay) { + valueDisplay.textContent = `${this.canvas.outputAreaExtensions[direction]}px`; + } + } + }); + } + } + /** * Add viewport change listener to update shape preview when zooming/panning */ @@ -448,4 +640,126 @@ export class CustomShapeMenu { // Start the viewport change detection requestAnimationFrame(checkViewportChange); } + + private _addTooltip(element: HTMLElement, text: string): void { + element.addEventListener('mouseenter', (e) => { + this.showTooltip(text, e); + }); + + element.addEventListener('mouseleave', () => { + this.hideTooltip(); + }); + + element.addEventListener('mousemove', (e) => { + if (this.tooltip && this.tooltip.style.display === 'block') { + this.updateTooltipPosition(e); + } + }); + } + + private showTooltip(text: string, event: MouseEvent): void { + this.hideTooltip(); // Hide any existing tooltip + + this.tooltip = document.createElement('div'); + this.tooltip.textContent = text; + this.tooltip.style.cssText = ` + position: fixed; + background-color: #1a1a1a; + color: #ffffff; + padding: 8px 12px; + border-radius: 6px; + font-size: 12px; + font-family: sans-serif; + line-height: 1.4; + max-width: 250px; + word-wrap: break-word; + box-shadow: 0 4px 12px rgba(0,0,0,0.6); + border: 1px solid #444; + z-index: 10000; + pointer-events: none; + opacity: 0; + transition: opacity 0.2s ease-in-out; + `; + + document.body.appendChild(this.tooltip); + this.updateTooltipPosition(event); + + // Fade in the tooltip + requestAnimationFrame(() => { + if (this.tooltip) { + this.tooltip.style.opacity = '1'; + } + }); + } + + private updateTooltipPosition(event: MouseEvent): void { + if (!this.tooltip) return; + + const tooltipRect = this.tooltip.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + let x = event.clientX + 10; + let y = event.clientY - 10; + + // Adjust if tooltip would go off the right edge + if (x + tooltipRect.width > viewportWidth) { + x = event.clientX - tooltipRect.width - 10; + } + + // Adjust if tooltip would go off the bottom edge + if (y + tooltipRect.height > viewportHeight) { + y = event.clientY - tooltipRect.height - 10; + } + + // Ensure tooltip doesn't go off the left or top edges + x = Math.max(5, x); + y = Math.max(5, y); + + this.tooltip.style.left = `${x}px`; + this.tooltip.style.top = `${y}px`; + } + + private hideTooltip(): void { + if (this.tooltip) { + this.tooltip.remove(); + this.tooltip = null; + } + } + + private _updateCanvasSize(): void { + if (!this.canvas.outputAreaExtensionEnabled) { + // Reset to original bounds when disabled + this.canvas.outputAreaBounds = { + x: 0, + y: 0, + width: this.canvas.originalCanvasSize.width, + height: this.canvas.originalCanvasSize.height + }; + this.canvas.updateOutputAreaSize( + this.canvas.originalCanvasSize.width, + this.canvas.originalCanvasSize.height, + false + ); + return; + } + + const ext = this.canvas.outputAreaExtensions; + const newWidth = this.canvas.originalCanvasSize.width + ext.left + ext.right; + const newHeight = this.canvas.originalCanvasSize.height + ext.top + ext.bottom; + + // Aktualizuj outputAreaBounds - "okno" w świecie które zostanie wyrenderowane + this.canvas.outputAreaBounds = { + x: -ext.left, // Może być ujemne - wycinamy fragment świata + y: -ext.top, // Może być ujemne - wycinamy fragment świata + width: newWidth, + height: newHeight + }; + + // Zmień rozmiar canvas (fizyczny rozmiar dla renderowania) + this.canvas.updateOutputAreaSize(newWidth, newHeight, false); + + log.info(`Output area bounds updated: x=${this.canvas.outputAreaBounds.x}, y=${this.canvas.outputAreaBounds.y}, w=${newWidth}, h=${newHeight}`); + log.info(`Extensions: top=${ext.top}, bottom=${ext.bottom}, left=${ext.left}, right=${ext.right}`); + } } diff --git a/src/MaskTool.ts b/src/MaskTool.ts index 57eb3b3..cf78b28 100644 --- a/src/MaskTool.ts +++ b/src/MaskTool.ts @@ -100,15 +100,21 @@ export class MaskTool { 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; + const bounds = this.canvasInstance.outputAreaBounds; + + // Mask canvas should cover output area + extra space around it + const maskLeft = bounds.x - extraSpace / 2; + const maskTop = bounds.y - extraSpace / 2; + const maskWidth = bounds.width + extraSpace; + const maskHeight = bounds.height + extraSpace; + + this.maskCanvas.width = maskWidth; + this.maskCanvas.height = maskHeight; + this.x = maskLeft; + this.y = maskTop; 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 size: ${this.maskCanvas.width}x${this.maskCanvas.height}, positioned at (${this.x}, ${this.y}) to cover output area at (${bounds.x}, ${bounds.y})`); } activate(): void { @@ -702,14 +708,78 @@ export class MaskTool { log.info(`Mask position updated to (${this.x}, ${this.y})`); } + /** + * Updates mask canvas to ensure it covers the current output area + * This should be called when output area position or size changes + */ + updateMaskCanvasForOutputArea(): void { + const extraSpace = 2000; + const bounds = this.canvasInstance.outputAreaBounds; + + // Calculate required mask canvas bounds + const requiredLeft = bounds.x - extraSpace / 2; + const requiredTop = bounds.y - extraSpace / 2; + const requiredWidth = bounds.width + extraSpace; + const requiredHeight = bounds.height + extraSpace; + + // Check if current mask canvas covers the required area + const currentRight = this.x + this.maskCanvas.width; + const currentBottom = this.y + this.maskCanvas.height; + const requiredRight = requiredLeft + requiredWidth; + const requiredBottom = requiredTop + requiredHeight; + + const needsResize = + requiredLeft < this.x || + requiredTop < this.y || + requiredRight > currentRight || + requiredBottom > currentBottom; + + if (needsResize) { + log.info(`Updating mask canvas to cover output area at (${bounds.x}, ${bounds.y})`); + + // Save current mask content + const oldMask = this.maskCanvas; + const oldX = this.x; + const oldY = this.y; + + // Create new mask canvas with proper size and position + this.maskCanvas = document.createElement('canvas'); + this.maskCanvas.width = requiredWidth; + this.maskCanvas.height = requiredHeight; + this.x = requiredLeft; + this.y = requiredTop; + + 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; + + // Copy old mask content to new position + if (oldMask.width > 0 && oldMask.height > 0) { + const offsetX = oldX - this.x; + const offsetY = oldY - this.y; + this.maskCtx.drawImage(oldMask, offsetX, offsetY); + log.debug(`Preserved mask content with offset (${offsetX}, ${offsetY})`); + } + + log.info(`Mask canvas updated to ${this.maskCanvas.width}x${this.maskCanvas.height} at (${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; + // Pozycja gdzie ma być aplikowana maska na canvas MaskTool + // MaskTool canvas ma pozycję (this.x, this.y) w świecie + // Maska reprezentuje output bounds, więc musimy ją umieścić + // w pozycji bounds względem pozycji MaskTool + const bounds = this.canvasInstance.outputAreaBounds; + const destX = bounds.x - this.x; + const destY = bounds.y - this.y; this.maskCtx.clearRect(destX, destY, this.canvasInstance.width, this.canvasInstance.height); @@ -719,12 +789,17 @@ export class MaskTool { this.onStateChange(); } 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 position (${destX}, ${destY}) relative to bounds (${bounds.x}, ${bounds.y}).`); } addMask(image: HTMLImageElement): void { - const destX = -this.x; - const destY = -this.y; + // Pozycja gdzie ma być aplikowana maska na canvas MaskTool + // MaskTool canvas ma pozycję (this.x, this.y) w świecie + // Maska z SAM reprezentuje output bounds, więc musimy ją umieścić + // w pozycji bounds względem pozycji MaskTool + const bounds = this.canvasInstance.outputAreaBounds; + const destX = bounds.x - this.x; + const destY = bounds.y - this.y; // Don't clear existing mask - just add to it this.maskCtx.globalCompositeOperation = 'source-over'; @@ -734,7 +809,7 @@ export class MaskTool { this.onStateChange(); } this.canvasInstance.render(); - log.info(`MaskTool added mask overlay at correct canvas position (${destX}, ${destY}) without clearing existing mask.`); + log.info(`MaskTool added SAM mask overlay at position (${destX}, ${destY}) relative to bounds (${bounds.x}, ${bounds.y}) without clearing existing mask.`); } applyShapeMask(saveState: boolean = true): void { diff --git a/src/types.ts b/src/types.ts index a050a14..23fa59d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -136,6 +136,13 @@ export interface Shape { isClosed: boolean; } +export interface OutputAreaBounds { + x: number; // Pozycja w świecie (może być ujemna) + y: number; // Pozycja w świecie (może być ujemna) + width: number; // Szerokość output area + height: number; // Wysokość output area +} + export interface Viewport { x: number; y: number;