From 2778b8df9fa9bff184b1494d8f1189413bb1bd3e Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Thu, 24 Jul 2025 15:12:53 +0200 Subject: [PATCH] Add ShapeTool for custom output area selection Introduces ShapeTool to allow users to define custom polygonal output areas by holding Shift+S and clicking to add points. The selected shape is used to crop and mask images and layers, and is visualized on the canvas. Updates Canvas, CanvasIO, CanvasInteractions, CanvasLayers, CanvasRenderer, and types to support shape-based output areas, including shape-aware import, export, and rendering logic. --- js/Canvas.js | 30 ++++++++ js/CanvasIO.js | 60 ++++++++++++++- js/CanvasInteractions.js | 37 +++++++++ js/CanvasLayers.js | 28 +++++++ js/CanvasRenderer.js | 16 ++++ js/ShapeTool.js | 125 ++++++++++++++++++++++++++++++ src/Canvas.ts | 42 ++++++++++- src/CanvasIO.ts | 83 ++++++++++++++++++-- src/CanvasInteractions.ts | 42 ++++++++++- src/CanvasLayers.ts | 32 ++++++++ src/CanvasRenderer.ts | 17 +++++ src/ShapeTool.ts | 155 ++++++++++++++++++++++++++++++++++++++ src/types.ts | 5 ++ 13 files changed, 664 insertions(+), 8 deletions(-) create mode 100644 js/ShapeTool.js create mode 100644 src/ShapeTool.ts diff --git a/js/Canvas.js b/js/Canvas.js index 092846e..9a536bd 100644 --- a/js/Canvas.js +++ b/js/Canvas.js @@ -1,6 +1,7 @@ // @ts-ignore import { api } from "../../scripts/api.js"; import { MaskTool } from "./MaskTool.js"; +import { ShapeTool } from "./ShapeTool.js"; import { CanvasState } from "./CanvasState.js"; import { CanvasInteractions } from "./CanvasInteractions.js"; import { CanvasLayers } from "./CanvasLayers.js"; @@ -62,6 +63,8 @@ export class Canvas { this.imageCache = new Map(); this.requestSaveState = () => { }; this.maskTool = new MaskTool(this, { onStateChange: this.onStateChange }); + this.shapeTool = new ShapeTool(this); + this.outputAreaShape = null; this.canvasMask = new CanvasMask(this); this.canvasState = new CanvasState(this); this.canvasSelection = new CanvasSelection(this); @@ -295,6 +298,33 @@ export class Canvas { updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index) { return this.canvasSelection.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index); } + defineOutputAreaWithShape(shape) { + const boundingBox = this.shapeTool.getBoundingBox(); + if (boundingBox && boundingBox.width > 1 && boundingBox.height > 1) { + this.saveState(); + this.outputAreaShape = { + ...shape, + points: shape.points.map(p => ({ + x: p.x - boundingBox.x, + y: p.y - boundingBox.y + })) + }; + const newWidth = Math.round(boundingBox.width); + const newHeight = Math.round(boundingBox.height); + const finalX = boundingBox.x; + const finalY = boundingBox.y; + this.updateOutputAreaSize(newWidth, newHeight, false); + this.layers.forEach((layer) => { + layer.x -= finalX; + layer.y -= finalY; + }); + this.maskTool.updatePosition(-finalX, -finalY); + this.viewport.x -= finalX; + this.viewport.y -= finalY; + this.saveState(); + this.render(); + } + } /** * Zmienia rozmiar obszaru wyjściowego * @param {number} width - Nowa szerokość diff --git a/js/CanvasIO.js b/js/CanvasIO.js index f20c57a..605d113 100644 --- a/js/CanvasIO.js +++ b/js/CanvasIO.js @@ -49,6 +49,8 @@ export class CanvasIO { 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; @@ -74,6 +76,7 @@ export class CanvasIO { maskData.data[i + 3] = 255; } maskCtx.putImageData(maskData, 0, 0); + this.canvas.outputAreaShape = originalShape; const toolMaskCanvas = this.canvas.maskTool.getMask(); if (toolMaskCanvas) { const tempMaskCanvas = document.createElement('canvas'); @@ -204,6 +207,8 @@ export class CanvasIO { 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; @@ -260,6 +265,7 @@ export class CanvasIO { } const imageDataUrl = tempCanvas.toDataURL('image/png'); const maskDataUrl = maskCanvas.toDataURL('image/png'); + this.canvas.outputAreaShape = originalShape; resolve({ image: imageDataUrl, mask: maskDataUrl }); }); } @@ -656,7 +662,12 @@ export class CanvasIO { img.onerror = reject; img.src = imageData; }); - const newLayer = await this.canvas.canvasLayers.addLayerWithImage(img, {}, 'fit', targetArea); + let processedImage = img; + // If there's a custom shape, clip the image to that shape + if (this.canvas.outputAreaShape && this.canvas.outputAreaShape.isClosed) { + processedImage = await this.clipImageToShape(img, this.canvas.outputAreaShape); + } + const newLayer = await this.canvas.canvasLayers.addLayerWithImage(processedImage, {}, 'fit', targetArea); newLayers.push(newLayer); } log.info("All new images imported and placed on canvas successfully."); @@ -676,4 +687,51 @@ export class CanvasIO { return []; } } + async clipImageToShape(image, shape) { + return new Promise((resolve, reject) => { + const { canvas, ctx } = createCanvas(image.width, image.height); + if (!ctx) { + reject(new Error("Could not create canvas context for clipping")); + return; + } + // Draw the image first + ctx.drawImage(image, 0, 0); + // Create a clipping mask using the shape + ctx.globalCompositeOperation = 'destination-in'; + ctx.beginPath(); + ctx.moveTo(shape.points[0].x, shape.points[0].y); + for (let i = 1; i < shape.points.length; i++) { + ctx.lineTo(shape.points[i].x, shape.points[i].y); + } + ctx.closePath(); + ctx.fill(); + // Create a new image from the clipped canvas + const clippedImage = new Image(); + clippedImage.onload = () => resolve(clippedImage); + clippedImage.onerror = () => reject(new Error("Failed to create clipped image")); + clippedImage.src = canvas.toDataURL(); + }); + } + createMaskFromShape(shape, width, height) { + const { canvas, ctx } = createCanvas(width, height); + if (!ctx) { + throw new Error("Could not create canvas context for mask"); + } + ctx.fillStyle = 'black'; + ctx.fillRect(0, 0, width, height); + ctx.fillStyle = 'white'; + ctx.beginPath(); + ctx.moveTo(shape.points[0].x, shape.points[0].y); + for (let i = 1; i < shape.points.length; i++) { + ctx.lineTo(shape.points[i].x, shape.points[i].y); + } + ctx.closePath(); + ctx.fill(); + const imageData = ctx.getImageData(0, 0, width, height); + const maskData = new Float32Array(width * height); + for (let i = 0; i < imageData.data.length; i += 4) { + maskData[i / 4] = imageData.data[i] / 255; + } + return maskData; + } } diff --git a/js/CanvasInteractions.js b/js/CanvasInteractions.js index 64a7396..87ad412 100644 --- a/js/CanvasInteractions.js +++ b/js/CanvasInteractions.js @@ -14,6 +14,8 @@ export class CanvasInteractions { canvasResizeStart: { x: 0, y: 0 }, isCtrlPressed: false, isAltPressed: false, + isShiftPressed: false, + isSPressed: false, hasClonedInDrag: false, lastClickTime: 0, transformingLayer: null, @@ -67,6 +69,10 @@ export class CanvasInteractions { this.canvas.render(); return; } + if (this.canvas.shapeTool.isActive) { + this.canvas.shapeTool.addPoint(worldCoords); + return; + } // --- Ostateczna, poprawna kolejność sprawdzania --- // 1. Akcje globalne z modyfikatorami (mają najwyższy priorytet) if (e.shiftKey && e.ctrlKey) { @@ -74,6 +80,11 @@ export class CanvasInteractions { return; } if (e.shiftKey) { + // Clear custom shape when starting canvas resize + if (this.canvas.outputAreaShape) { + this.canvas.outputAreaShape = null; + this.canvas.render(); + } this.startCanvasResize(worldCoords); return; } @@ -295,10 +306,22 @@ export class CanvasInteractions { handleKeyDown(e) { if (e.key === 'Control') this.interaction.isCtrlPressed = true; + if (e.key === 'Shift') + this.interaction.isShiftPressed = true; if (e.key === 'Alt') { this.interaction.isAltPressed = true; e.preventDefault(); } + if (e.key.toLowerCase() === 's') { + this.interaction.isSPressed = true; + e.preventDefault(); + e.stopPropagation(); + } + // Check if Shift+S is being held down + if (this.interaction.isShiftPressed && this.interaction.isSPressed && !this.interaction.isCtrlPressed && !this.canvas.shapeTool.isActive) { + this.canvas.shapeTool.activate(); + return; + } // Globalne skróty (Undo/Redo/Copy/Paste) if (e.ctrlKey || e.metaKey) { let handled = true; @@ -367,8 +390,16 @@ export class CanvasInteractions { handleKeyUp(e) { if (e.key === 'Control') this.interaction.isCtrlPressed = false; + if (e.key === 'Shift') + this.interaction.isShiftPressed = false; if (e.key === 'Alt') this.interaction.isAltPressed = false; + if (e.key.toLowerCase() === 's') + this.interaction.isSPressed = false; + // Deactivate shape tool when Shift or S is released + if (this.canvas.shapeTool.isActive && (!this.interaction.isShiftPressed || !this.interaction.isSPressed)) { + this.canvas.shapeTool.deactivate(); + } const movementKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'BracketLeft', 'BracketRight']; if (movementKeys.includes(e.code) && this.interaction.keyMovementInProgress) { this.canvas.requestSaveState(); // Użyj opóźnionego zapisu @@ -379,7 +410,13 @@ export class CanvasInteractions { log.debug('Window lost focus, resetting key states.'); this.interaction.isCtrlPressed = false; this.interaction.isAltPressed = false; + this.interaction.isShiftPressed = false; + this.interaction.isSPressed = false; this.interaction.keyMovementInProgress = false; + // Deactivate shape tool when window loses focus + if (this.canvas.shapeTool.isActive) { + this.canvas.shapeTool.deactivate(); + } // Also reset any interaction that relies on a key being held down if (this.interaction.mode === 'dragging' && this.interaction.hasClonedInDrag) { // If we were in the middle of a cloning drag, finalize it diff --git a/js/CanvasLayers.js b/js/CanvasLayers.js index 984396b..0665d31 100644 --- a/js/CanvasLayers.js +++ b/js/CanvasLayers.js @@ -55,6 +55,34 @@ export class CanvasLayers { opacity: 1, ...layerProps }; + if (layer.mask) { + const tempCanvas = document.createElement('canvas'); + const tempCtx = tempCanvas.getContext('2d'); + if (tempCtx) { + tempCanvas.width = layer.width; + tempCanvas.height = layer.height; + tempCtx.drawImage(layer.image, 0, 0, layer.width, layer.height); + const maskCanvas = document.createElement('canvas'); + const maskCtx = maskCanvas.getContext('2d'); + if (maskCtx) { + maskCanvas.width = layer.width; + maskCanvas.height = layer.height; + const maskImageData = maskCtx.createImageData(layer.width, layer.height); + for (let i = 0; i < layer.mask.length; i++) { + maskImageData.data[i * 4] = 255; + maskImageData.data[i * 4 + 1] = 255; + maskImageData.data[i * 4 + 2] = 255; + maskImageData.data[i * 4 + 3] = layer.mask[i] * 255; + } + maskCtx.putImageData(maskImageData, 0, 0); + tempCtx.globalCompositeOperation = 'destination-in'; + tempCtx.drawImage(maskCanvas, 0, 0); + const newImage = new Image(); + newImage.src = tempCanvas.toDataURL(); + layer.image = newImage; + } + } + } this.canvas.layers.push(layer); this.canvas.updateSelection([layer]); this.canvas.render(); diff --git a/js/CanvasRenderer.js b/js/CanvasRenderer.js index b5acdc7..0930373 100644 --- a/js/CanvasRenderer.js +++ b/js/CanvasRenderer.js @@ -91,6 +91,7 @@ export class CanvasRenderer { ctx.restore(); } this.renderInteractionElements(ctx); + this.canvas.shapeTool.render(ctx); this.renderLayerInfo(ctx); ctx.restore(); if (this.canvas.canvas.width !== this.canvas.offscreenCanvas.width || @@ -257,6 +258,21 @@ export class CanvasRenderer { ctx.rect(0, 0, this.canvas.width, this.canvas.height); ctx.stroke(); ctx.setLineDash([]); + if (this.canvas.outputAreaShape) { + ctx.save(); + ctx.strokeStyle = 'rgba(0, 255, 255, 0.9)'; + ctx.lineWidth = 2 / this.canvas.viewport.zoom; + ctx.setLineDash([]); + const shape = this.canvas.outputAreaShape; + ctx.beginPath(); + ctx.moveTo(shape.points[0].x, shape.points[0].y); + for (let i = 1; i < shape.points.length; i++) { + ctx.lineTo(shape.points[i].x, shape.points[i].y); + } + ctx.closePath(); + ctx.stroke(); + ctx.restore(); + } } drawSelectionFrame(ctx, layer) { const lineWidth = 2 / this.canvas.viewport.zoom; diff --git a/js/ShapeTool.js b/js/ShapeTool.js new file mode 100644 index 0000000..591ead9 --- /dev/null +++ b/js/ShapeTool.js @@ -0,0 +1,125 @@ +import { createModuleLogger } from "./utils/LoggerUtils.js"; +const log = createModuleLogger('ShapeTool'); +export class ShapeTool { + constructor(canvas) { + this.isActive = false; + this.canvas = canvas; + this.shape = { + points: [], + isClosed: false, + }; + } + toggle() { + this.isActive = !this.isActive; + if (this.isActive) { + log.info('ShapeTool activated. Press "S" to exit.'); + this.reset(); + } + else { + log.info('ShapeTool deactivated.'); + this.reset(); + } + this.canvas.render(); + } + activate() { + if (!this.isActive) { + this.isActive = true; + log.info('ShapeTool activated. Hold Shift+S to draw.'); + this.reset(); + this.canvas.render(); + } + } + deactivate() { + if (this.isActive) { + this.isActive = false; + log.info('ShapeTool deactivated.'); + this.reset(); + this.canvas.render(); + } + } + addPoint(point) { + if (this.shape.isClosed) { + this.reset(); + } + // Check if the new point is close to the start point to close the shape + if (this.shape.points.length > 2) { + const firstPoint = this.shape.points[0]; + const dx = point.x - firstPoint.x; + const dy = point.y - firstPoint.y; + if (Math.sqrt(dx * dx + dy * dy) < 10 / this.canvas.viewport.zoom) { + this.closeShape(); + return; + } + } + this.shape.points.push(point); + this.canvas.render(); + } + closeShape() { + if (this.shape.points.length > 2) { + this.shape.isClosed = true; + log.info('Shape closed with', this.shape.points.length, 'points.'); + this.canvas.defineOutputAreaWithShape(this.shape); + this.reset(); + } + this.canvas.render(); + } + getBoundingBox() { + if (this.shape.points.length === 0) { + return null; + } + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + this.shape.points.forEach(p => { + minX = Math.min(minX, p.x); + minY = Math.min(minY, p.y); + maxX = Math.max(maxX, p.x); + maxY = Math.max(maxY, p.y); + }); + return { + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY, + }; + } + reset() { + this.shape = { + points: [], + isClosed: false, + }; + log.info('ShapeTool reset.'); + this.canvas.render(); + } + render(ctx) { + if (this.shape.points.length === 0) { + return; + } + ctx.save(); + ctx.strokeStyle = 'rgba(0, 255, 255, 0.9)'; + ctx.lineWidth = 2 / this.canvas.viewport.zoom; + ctx.setLineDash([8 / this.canvas.viewport.zoom, 4 / this.canvas.viewport.zoom]); + ctx.beginPath(); + const startPoint = this.shape.points[0]; + ctx.moveTo(startPoint.x, startPoint.y); + for (let i = 1; i < this.shape.points.length; i++) { + ctx.lineTo(this.shape.points[i].x, this.shape.points[i].y); + } + if (this.shape.isClosed) { + ctx.closePath(); + ctx.fillStyle = 'rgba(0, 255, 255, 0.2)'; + ctx.fill(); + } + else if (this.isActive) { + // Draw a line to the current mouse position + ctx.lineTo(this.canvas.lastMousePosition.x, this.canvas.lastMousePosition.y); + } + ctx.stroke(); + // Draw vertices + ctx.fillStyle = 'rgba(0, 255, 255, 1)'; + this.shape.points.forEach((point, index) => { + ctx.beginPath(); + ctx.arc(point.x, point.y, 4 / this.canvas.viewport.zoom, 0, 2 * Math.PI); + ctx.fill(); + }); + ctx.restore(); + } +} diff --git a/src/Canvas.ts b/src/Canvas.ts index 3023388..576d69a 100644 --- a/src/Canvas.ts +++ b/src/Canvas.ts @@ -7,6 +7,7 @@ import {ComfyApp} from "../../scripts/app.js"; import {removeImage} from "./db.js"; import {MaskTool} from "./MaskTool.js"; +import {ShapeTool} from "./ShapeTool.js"; import {CanvasState} from "./CanvasState.js"; import {CanvasInteractions} from "./CanvasInteractions.js"; import {CanvasLayers} from "./CanvasLayers.js"; @@ -19,7 +20,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 } from './types'; +import type { ComfyNode, Layer, Viewport, Point, AddMode, Shape } from './types'; const useChainCallback = (original: any, next: any) => { if (original === undefined || original === null) { @@ -63,6 +64,8 @@ export class Canvas { lastMousePosition: Point; layers: Layer[]; maskTool: MaskTool; + shapeTool: ShapeTool; + outputAreaShape: Shape | null; node: ComfyNode; offscreenCanvas: HTMLCanvasElement; offscreenCtx: CanvasRenderingContext2D | null; @@ -108,6 +111,8 @@ export class Canvas { this.requestSaveState = () => {}; this.maskTool = new MaskTool(this, {onStateChange: this.onStateChange}); + this.shapeTool = new ShapeTool(this); + this.outputAreaShape = null; this.canvasMask = new CanvasMask(this); this.canvasState = new CanvasState(this); this.canvasSelection = new CanvasSelection(this); @@ -381,6 +386,41 @@ export class Canvas { return this.canvasSelection.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index); } + defineOutputAreaWithShape(shape: Shape): void { + const boundingBox = this.shapeTool.getBoundingBox(); + if (boundingBox && boundingBox.width > 1 && boundingBox.height > 1) { + this.saveState(); + + this.outputAreaShape = { + ...shape, + points: shape.points.map(p => ({ + x: p.x - boundingBox.x, + y: p.y - boundingBox.y + })) + }; + + const newWidth = Math.round(boundingBox.width); + const newHeight = Math.round(boundingBox.height); + const finalX = boundingBox.x; + const finalY = boundingBox.y; + + this.updateOutputAreaSize(newWidth, newHeight, false); + + this.layers.forEach((layer: Layer) => { + layer.x -= finalX; + layer.y -= finalY; + }); + + this.maskTool.updatePosition(-finalX, -finalY); + + this.viewport.x -= finalX; + this.viewport.y -= finalY; + + this.saveState(); + this.render(); + } + } + /** * Zmienia rozmiar obszaru wyjściowego * @param {number} width - Nowa szerokość diff --git a/src/CanvasIO.ts b/src/CanvasIO.ts index 37d6a6e..1fb2f2f 100644 --- a/src/CanvasIO.ts +++ b/src/CanvasIO.ts @@ -2,7 +2,7 @@ import { createCanvas } from "./utils/CommonUtils.js"; import { createModuleLogger } from "./utils/LoggerUtils.js"; import { webSocketManager } from "./utils/WebSocketManager.js"; import type { Canvas } from './Canvas'; -import type { Layer } from './types'; +import type { Layer, Shape } from './types'; const log = createModuleLogger('CanvasIO'); @@ -61,6 +61,9 @@ export class CanvasIO { 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; @@ -75,7 +78,6 @@ export class CanvasIO { this.canvas.canvasLayers.drawLayersToContext(tempCtx, this.canvas.layers); this.canvas.canvasLayers.drawLayersToContext(visibilityCtx, this.canvas.layers); - log.debug(`Finished rendering 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); @@ -87,6 +89,9 @@ export class CanvasIO { } maskCtx.putImageData(maskData, 0, 0); + + this.canvas.outputAreaShape = originalShape; + const toolMaskCanvas = this.canvas.maskTool.getMask(); if (toolMaskCanvas) { @@ -233,6 +238,9 @@ export class CanvasIO { 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; @@ -298,13 +306,15 @@ export class CanvasIO { tempMaskCtx.putImageData(tempMaskData, 0, 0); - maskCtx.globalCompositeOperation = 'screen'; - maskCtx.drawImage(tempMaskCanvas, 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}); }); } @@ -775,7 +785,15 @@ export class CanvasIO { img.onerror = reject; img.src = imageData; }); - const newLayer = await this.canvas.canvasLayers.addLayerWithImage(img, {}, 'fit', targetArea); + + let processedImage = img; + + // If there's a custom shape, clip the image to that shape + if (this.canvas.outputAreaShape && this.canvas.outputAreaShape.isClosed) { + processedImage = await this.clipImageToShape(img, this.canvas.outputAreaShape); + } + + const newLayer = await this.canvas.canvasLayers.addLayerWithImage(processedImage, {}, 'fit', targetArea); newLayers.push(newLayer); } log.info("All new images imported and placed on canvas successfully."); @@ -793,4 +811,59 @@ export class CanvasIO { return []; } } + + async clipImageToShape(image: HTMLImageElement, shape: Shape): Promise { + return new Promise((resolve, reject) => { + const { canvas, ctx } = createCanvas(image.width, image.height); + if (!ctx) { + reject(new Error("Could not create canvas context for clipping")); + return; + } + + // Draw the image first + ctx.drawImage(image, 0, 0); + + // Create a clipping mask using the shape + ctx.globalCompositeOperation = 'destination-in'; + ctx.beginPath(); + ctx.moveTo(shape.points[0].x, shape.points[0].y); + for (let i = 1; i < shape.points.length; i++) { + ctx.lineTo(shape.points[i].x, shape.points[i].y); + } + ctx.closePath(); + ctx.fill(); + + // Create a new image from the clipped canvas + const clippedImage = new Image(); + clippedImage.onload = () => resolve(clippedImage); + clippedImage.onerror = () => reject(new Error("Failed to create clipped image")); + clippedImage.src = canvas.toDataURL(); + }); + } + + createMaskFromShape(shape: Shape, width: number, height: number): Float32Array { + const { canvas, ctx } = createCanvas(width, height); + if (!ctx) { + throw new Error("Could not create canvas context for mask"); + } + + ctx.fillStyle = 'black'; + ctx.fillRect(0, 0, width, height); + + ctx.fillStyle = 'white'; + ctx.beginPath(); + ctx.moveTo(shape.points[0].x, shape.points[0].y); + for (let i = 1; i < shape.points.length; i++) { + ctx.lineTo(shape.points[i].x, shape.points[i].y); + } + ctx.closePath(); + ctx.fill(); + + const imageData = ctx.getImageData(0, 0, width, height); + const maskData = new Float32Array(width * height); + for (let i = 0; i < imageData.data.length; i += 4) { + maskData[i / 4] = imageData.data[i] / 255; + } + return maskData; + } } diff --git a/src/CanvasInteractions.ts b/src/CanvasInteractions.ts index 1262d53..1d8b31c 100644 --- a/src/CanvasInteractions.ts +++ b/src/CanvasInteractions.ts @@ -6,7 +6,7 @@ import type { Layer, Point } from './types'; const log = createModuleLogger('CanvasInteractions'); interface InteractionState { - mode: 'none' | 'panning' | 'dragging' | 'resizing' | 'rotating' | 'drawingMask' | 'resizingCanvas' | 'movingCanvas' | 'potential-drag'; + mode: 'none' | 'panning' | 'dragging' | 'resizing' | 'rotating' | 'drawingMask' | 'resizingCanvas' | 'movingCanvas' | 'potential-drag' | 'drawingShape'; panStart: Point; dragStart: Point; transformOrigin: Partial & { centerX?: number, centerY?: number }; @@ -15,6 +15,8 @@ interface InteractionState { canvasResizeStart: Point; isCtrlPressed: boolean; isAltPressed: boolean; + isShiftPressed: boolean; + isSPressed: boolean; hasClonedInDrag: boolean; lastClickTime: number; transformingLayer: Layer | null; @@ -40,6 +42,8 @@ export class CanvasInteractions { canvasResizeStart: { x: 0, y: 0 }, isCtrlPressed: false, isAltPressed: false, + isShiftPressed: false, + isSPressed: false, hasClonedInDrag: false, lastClickTime: 0, transformingLayer: null, @@ -103,6 +107,11 @@ export class CanvasInteractions { return; } + if (this.canvas.shapeTool.isActive) { + this.canvas.shapeTool.addPoint(worldCoords); + return; + } + // --- Ostateczna, poprawna kolejność sprawdzania --- // 1. Akcje globalne z modyfikatorami (mają najwyższy priorytet) @@ -111,6 +120,11 @@ export class CanvasInteractions { return; } if (e.shiftKey) { + // Clear custom shape when starting canvas resize + if (this.canvas.outputAreaShape) { + this.canvas.outputAreaShape = null; + this.canvas.render(); + } this.startCanvasResize(worldCoords); return; } @@ -348,11 +362,23 @@ export class CanvasInteractions { handleKeyDown(e: KeyboardEvent): void { if (e.key === 'Control') this.interaction.isCtrlPressed = true; + if (e.key === 'Shift') this.interaction.isShiftPressed = true; if (e.key === 'Alt') { this.interaction.isAltPressed = true; e.preventDefault(); } + if (e.key.toLowerCase() === 's') { + this.interaction.isSPressed = true; + e.preventDefault(); + e.stopPropagation(); + } + // Check if Shift+S is being held down + if (this.interaction.isShiftPressed && this.interaction.isSPressed && !this.interaction.isCtrlPressed && !this.canvas.shapeTool.isActive) { + this.canvas.shapeTool.activate(); + return; + } + // Globalne skróty (Undo/Redo/Copy/Paste) if (e.ctrlKey || e.metaKey) { let handled = true; @@ -420,7 +446,14 @@ export class CanvasInteractions { handleKeyUp(e: KeyboardEvent): void { if (e.key === 'Control') this.interaction.isCtrlPressed = false; + if (e.key === 'Shift') this.interaction.isShiftPressed = false; if (e.key === 'Alt') this.interaction.isAltPressed = false; + if (e.key.toLowerCase() === 's') this.interaction.isSPressed = false; + + // Deactivate shape tool when Shift or S is released + if (this.canvas.shapeTool.isActive && (!this.interaction.isShiftPressed || !this.interaction.isSPressed)) { + this.canvas.shapeTool.deactivate(); + } const movementKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'BracketLeft', 'BracketRight']; if (movementKeys.includes(e.code) && this.interaction.keyMovementInProgress) { @@ -433,8 +466,15 @@ export class CanvasInteractions { log.debug('Window lost focus, resetting key states.'); this.interaction.isCtrlPressed = false; this.interaction.isAltPressed = false; + this.interaction.isShiftPressed = false; + this.interaction.isSPressed = false; this.interaction.keyMovementInProgress = false; + // Deactivate shape tool when window loses focus + if (this.canvas.shapeTool.isActive) { + this.canvas.shapeTool.deactivate(); + } + // Also reset any interaction that relies on a key being held down if (this.interaction.mode === 'dragging' && this.interaction.hasClonedInDrag) { // If we were in the middle of a cloning drag, finalize it diff --git a/src/CanvasLayers.ts b/src/CanvasLayers.ts index d2307b6..2d7ecb0 100644 --- a/src/CanvasLayers.ts +++ b/src/CanvasLayers.ts @@ -207,6 +207,38 @@ export class CanvasLayers { ...layerProps }; + if (layer.mask) { + const tempCanvas = document.createElement('canvas'); + const tempCtx = tempCanvas.getContext('2d'); + if(tempCtx) { + tempCanvas.width = layer.width; + tempCanvas.height = layer.height; + tempCtx.drawImage(layer.image, 0, 0, layer.width, layer.height); + + const maskCanvas = document.createElement('canvas'); + const maskCtx = maskCanvas.getContext('2d'); + if(maskCtx) { + maskCanvas.width = layer.width; + maskCanvas.height = layer.height; + const maskImageData = maskCtx.createImageData(layer.width, layer.height); + for (let i = 0; i < layer.mask.length; i++) { + maskImageData.data[i * 4] = 255; + maskImageData.data[i * 4 + 1] = 255; + maskImageData.data[i * 4 + 2] = 255; + maskImageData.data[i * 4 + 3] = layer.mask[i] * 255; + } + maskCtx.putImageData(maskImageData, 0, 0); + + tempCtx.globalCompositeOperation = 'destination-in'; + tempCtx.drawImage(maskCanvas, 0, 0); + + const newImage = new Image(); + newImage.src = tempCanvas.toDataURL(); + layer.image = newImage; + } + } + } + this.canvas.layers.push(layer); this.canvas.updateSelection([layer]); this.canvas.render(); diff --git a/src/CanvasRenderer.ts b/src/CanvasRenderer.ts index c14fc4a..8e51a42 100644 --- a/src/CanvasRenderer.ts +++ b/src/CanvasRenderer.ts @@ -115,6 +115,7 @@ export class CanvasRenderer { } this.renderInteractionElements(ctx); + this.canvas.shapeTool.render(ctx); this.renderLayerInfo(ctx); ctx.restore(); @@ -307,6 +308,22 @@ export class CanvasRenderer { ctx.stroke(); ctx.setLineDash([]); + + if (this.canvas.outputAreaShape) { + ctx.save(); + ctx.strokeStyle = 'rgba(0, 255, 255, 0.9)'; + ctx.lineWidth = 2 / this.canvas.viewport.zoom; + ctx.setLineDash([]); + const shape = this.canvas.outputAreaShape; + ctx.beginPath(); + ctx.moveTo(shape.points[0].x, shape.points[0].y); + for (let i = 1; i < shape.points.length; i++) { + ctx.lineTo(shape.points[i].x, shape.points[i].y); + } + ctx.closePath(); + ctx.stroke(); + ctx.restore(); + } } drawSelectionFrame(ctx: any, layer: any) { diff --git a/src/ShapeTool.ts b/src/ShapeTool.ts new file mode 100644 index 0000000..18cc7e0 --- /dev/null +++ b/src/ShapeTool.ts @@ -0,0 +1,155 @@ +import { createModuleLogger } from "./utils/LoggerUtils.js"; +import type { Canvas } from './Canvas.js'; +import type { Point, Layer } from './types.js'; + +const log = createModuleLogger('ShapeTool'); + +interface Shape { + points: Point[]; + isClosed: boolean; +} + +export class ShapeTool { + private canvas: Canvas; + public shape: Shape; + public isActive: boolean = false; + + constructor(canvas: Canvas) { + this.canvas = canvas; + this.shape = { + points: [], + isClosed: false, + }; + } + + toggle() { + this.isActive = !this.isActive; + if (this.isActive) { + log.info('ShapeTool activated. Press "S" to exit.'); + this.reset(); + } else { + log.info('ShapeTool deactivated.'); + this.reset(); + } + this.canvas.render(); + } + + activate() { + if (!this.isActive) { + this.isActive = true; + log.info('ShapeTool activated. Hold Shift+S to draw.'); + this.reset(); + this.canvas.render(); + } + } + + deactivate() { + if (this.isActive) { + this.isActive = false; + log.info('ShapeTool deactivated.'); + this.reset(); + this.canvas.render(); + } + } + + addPoint(point: Point) { + if (this.shape.isClosed) { + this.reset(); + } + + // Check if the new point is close to the start point to close the shape + if (this.shape.points.length > 2) { + const firstPoint = this.shape.points[0]; + const dx = point.x - firstPoint.x; + const dy = point.y - firstPoint.y; + if (Math.sqrt(dx * dx + dy * dy) < 10 / this.canvas.viewport.zoom) { + this.closeShape(); + return; + } + } + + this.shape.points.push(point); + this.canvas.render(); + } + + closeShape() { + if (this.shape.points.length > 2) { + this.shape.isClosed = true; + log.info('Shape closed with', this.shape.points.length, 'points.'); + + this.canvas.defineOutputAreaWithShape(this.shape); + this.reset(); + } + this.canvas.render(); + } + + getBoundingBox(): { x: number, y: number, width: number, height: number } | null { + if (this.shape.points.length === 0) { + return null; + } + + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + this.shape.points.forEach(p => { + minX = Math.min(minX, p.x); + minY = Math.min(minY, p.y); + maxX = Math.max(maxX, p.x); + maxY = Math.max(maxY, p.y); + }); + + return { + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY, + }; + } + + reset() { + this.shape = { + points: [], + isClosed: false, + }; + log.info('ShapeTool reset.'); + this.canvas.render(); + } + + render(ctx: CanvasRenderingContext2D) { + if (this.shape.points.length === 0) { + return; + } + + ctx.save(); + ctx.strokeStyle = 'rgba(0, 255, 255, 0.9)'; + ctx.lineWidth = 2 / this.canvas.viewport.zoom; + ctx.setLineDash([8 / this.canvas.viewport.zoom, 4 / this.canvas.viewport.zoom]); + + ctx.beginPath(); + const startPoint = this.shape.points[0]; + ctx.moveTo(startPoint.x, startPoint.y); + + for (let i = 1; i < this.shape.points.length; i++) { + ctx.lineTo(this.shape.points[i].x, this.shape.points[i].y); + } + + if (this.shape.isClosed) { + ctx.closePath(); + ctx.fillStyle = 'rgba(0, 255, 255, 0.2)'; + ctx.fill(); + } else if (this.isActive) { + // Draw a line to the current mouse position + ctx.lineTo(this.canvas.lastMousePosition.x, this.canvas.lastMousePosition.y); + } + + ctx.stroke(); + + // Draw vertices + ctx.fillStyle = 'rgba(0, 255, 255, 1)'; + this.shape.points.forEach((point, index) => { + ctx.beginPath(); + ctx.arc(point.x, point.y, 4 / this.canvas.viewport.zoom, 0, 2 * Math.PI); + ctx.fill(); + }); + + ctx.restore(); + } +} diff --git a/src/types.ts b/src/types.ts index 3cb8a90..3be21dd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -129,6 +129,11 @@ export interface Point { y: number; } +export interface Shape { + points: Point[]; + isClosed: boolean; +} + export interface Viewport { x: number; y: number;