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;