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;