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.
This commit is contained in:
Dariusz L
2025-07-24 15:12:53 +02:00
parent b655b68412
commit 2778b8df9f
13 changed files with 664 additions and 8 deletions

View File

@@ -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ść

View File

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

View File

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

View File

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

View File

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

125
js/ShapeTool.js Normal file
View File

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