6 Commits

Author SHA1 Message Date
Dariusz L
5b54ab28cb Update pyproject.toml 2025-08-03 02:45:20 +02:00
Dariusz L
503ec126a5 Fix DataCloneError by excluding non-serializable cache from state
Excluded blendedImageCache and blendedImageDirty properties from layer serialization in CanvasState.ts to prevent DataCloneError when saving state. This ensures that only serializable data is sent to Web Workers, while runtime caches are regenerated as needed. Blend area performance optimization remains functional without serialization issues.
2025-08-03 02:43:30 +02:00
Dariusz L
3d6e3901d0 Fix button crop icon display and update functionality 2025-08-03 02:19:52 +02:00
Dariusz L
4df89a793e Fix layer selection bug by sorting hit-test by z-index 2025-08-02 19:52:08 +02:00
Dariusz L
e42e08e35d Crop mode button to switch 2025-08-02 19:43:03 +02:00
Dariusz L
7ed6f7ee93 Implement crop mode for cropping selected layer 2025-08-02 19:05:11 +02:00
16 changed files with 1154 additions and 295 deletions

View File

@@ -539,7 +539,10 @@ export class CanvasInteractions {
width: layer.width, height: layer.height,
rotation: layer.rotation,
centerX: layer.x + layer.width / 2,
centerY: layer.y + layer.height / 2
centerY: layer.y + layer.height / 2,
originalWidth: layer.originalWidth,
originalHeight: layer.originalHeight,
cropBounds: layer.cropBounds ? { ...layer.cropBounds } : undefined
};
this.interaction.dragStart = { ...worldCoords };
if (handle === 'rot') {
@@ -692,12 +695,8 @@ export class CanvasInteractions {
let mouseY = worldCoords.y;
if (this.interaction.isCtrlPressed) {
const snapThreshold = 10 / this.canvas.viewport.zoom;
const snappedMouseX = snapToGrid(mouseX);
if (Math.abs(mouseX - snappedMouseX) < snapThreshold)
mouseX = snappedMouseX;
const snappedMouseY = snapToGrid(mouseY);
if (Math.abs(mouseY - snappedMouseY) < snapThreshold)
mouseY = snappedMouseY;
mouseX = Math.abs(mouseX - snapToGrid(mouseX)) < snapThreshold ? snapToGrid(mouseX) : mouseX;
mouseY = Math.abs(mouseY - snapToGrid(mouseY)) < snapThreshold ? snapToGrid(mouseY) : mouseY;
}
const o = this.interaction.transformOrigin;
if (o.rotation === undefined || o.width === undefined || o.height === undefined || o.centerX === undefined || o.centerY === undefined)
@@ -707,43 +706,113 @@ export class CanvasInteractions {
const rad = o.rotation * Math.PI / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
// Vector from anchor to mouse
const vecX = mouseX - anchor.x;
const vecY = mouseY - anchor.y;
let newWidth = vecX * cos + vecY * sin;
let newHeight = vecY * cos - vecX * sin;
if (isShiftPressed) {
const originalAspectRatio = o.width / o.height;
if (Math.abs(newWidth) > Math.abs(newHeight) * originalAspectRatio) {
newHeight = (Math.sign(newHeight) || 1) * Math.abs(newWidth) / originalAspectRatio;
}
else {
newWidth = (Math.sign(newWidth) || 1) * Math.abs(newHeight) * originalAspectRatio;
}
}
let signX = handle?.includes('e') ? 1 : (handle?.includes('w') ? -1 : 0);
let signY = handle?.includes('s') ? 1 : (handle?.includes('n') ? -1 : 0);
newWidth *= signX;
newHeight *= signY;
// Rotate vector to align with layer's local coordinates
let localVecX = vecX * cos + vecY * sin;
let localVecY = vecY * cos - vecX * sin;
// Determine sign based on handle
const signX = handle?.includes('e') ? 1 : (handle?.includes('w') ? -1 : 0);
const signY = handle?.includes('s') ? 1 : (handle?.includes('n') ? -1 : 0);
localVecX *= signX;
localVecY *= signY;
// If not a corner handle, keep original dimension
if (signX === 0)
newWidth = o.width;
localVecX = o.width;
if (signY === 0)
newHeight = o.height;
if (newWidth < 10)
newWidth = 10;
if (newHeight < 10)
newHeight = 10;
layer.width = newWidth;
layer.height = newHeight;
const deltaW = newWidth - o.width;
const deltaH = newHeight - o.height;
const shiftX = (deltaW / 2) * signX;
const shiftY = (deltaH / 2) * signY;
const worldShiftX = shiftX * cos - shiftY * sin;
const worldShiftY = shiftX * sin + shiftY * cos;
const newCenterX = o.centerX + worldShiftX;
const newCenterY = o.centerY + worldShiftY;
layer.x = newCenterX - layer.width / 2;
layer.y = newCenterY - layer.height / 2;
localVecY = o.height;
if (layer.cropMode && o.cropBounds && o.originalWidth && o.originalHeight) {
// CROP MODE: Calculate delta based on mouse movement and apply to cropBounds.
// Calculate mouse movement since drag start, in the layer's local coordinate system.
const dragStartX_local = this.interaction.dragStart.x - (o.centerX ?? 0);
const dragStartY_local = this.interaction.dragStart.y - (o.centerY ?? 0);
const mouseX_local = mouseX - (o.centerX ?? 0);
const mouseY_local = mouseY - (o.centerY ?? 0);
// Rotate mouse delta into the layer's unrotated frame
const deltaX_world = mouseX_local - dragStartX_local;
const deltaY_world = mouseY_local - dragStartY_local;
const mouseDeltaX_local = deltaX_world * cos + deltaY_world * sin;
const mouseDeltaY_local = deltaY_world * cos - deltaX_world * sin;
// Convert the on-screen mouse delta to an image-space delta.
const screenToImageScaleX = o.originalWidth / o.width;
const screenToImageScaleY = o.originalHeight / o.height;
const delta_image_x = mouseDeltaX_local * screenToImageScaleX;
const delta_image_y = mouseDeltaY_local * screenToImageScaleY;
let newCropBounds = { ...o.cropBounds }; // Start with the bounds from the beginning of the drag
// Apply the image-space delta to the appropriate edges of the crop bounds
if (handle?.includes('w')) {
newCropBounds.x += delta_image_x;
newCropBounds.width -= delta_image_x;
}
if (handle?.includes('e')) {
newCropBounds.width += delta_image_x;
}
if (handle?.includes('n')) {
newCropBounds.y += delta_image_y;
newCropBounds.height -= delta_image_y;
}
if (handle?.includes('s')) {
newCropBounds.height += delta_image_y;
}
// Clamp crop bounds to stay within the original image and maintain minimum size
if (newCropBounds.width < 1) {
if (handle?.includes('w'))
newCropBounds.x = o.cropBounds.x + o.cropBounds.width - 1;
newCropBounds.width = 1;
}
if (newCropBounds.height < 1) {
if (handle?.includes('n'))
newCropBounds.y = o.cropBounds.y + o.cropBounds.height - 1;
newCropBounds.height = 1;
}
if (newCropBounds.x < 0) {
newCropBounds.width += newCropBounds.x;
newCropBounds.x = 0;
}
if (newCropBounds.y < 0) {
newCropBounds.height += newCropBounds.y;
newCropBounds.y = 0;
}
if (newCropBounds.x + newCropBounds.width > o.originalWidth) {
newCropBounds.width = o.originalWidth - newCropBounds.x;
}
if (newCropBounds.y + newCropBounds.height > o.originalHeight) {
newCropBounds.height = o.originalHeight - newCropBounds.y;
}
layer.cropBounds = newCropBounds;
}
else {
// TRANSFORM MODE: Resize the layer's main transform frame
let newWidth = localVecX;
let newHeight = localVecY;
if (isShiftPressed) {
const originalAspectRatio = o.width / o.height;
if (Math.abs(newWidth) > Math.abs(newHeight) * originalAspectRatio) {
newHeight = (Math.sign(newHeight) || 1) * Math.abs(newWidth) / originalAspectRatio;
}
else {
newWidth = (Math.sign(newWidth) || 1) * Math.abs(newHeight) * originalAspectRatio;
}
}
if (newWidth < 10)
newWidth = 10;
if (newHeight < 10)
newHeight = 10;
layer.width = newWidth;
layer.height = newHeight;
// Update position to keep anchor point fixed
const deltaW = layer.width - o.width;
const deltaH = layer.height - o.height;
const shiftX = (deltaW / 2) * signX;
const shiftY = (deltaH / 2) * signY;
const worldShiftX = shiftX * cos - shiftY * sin;
const worldShiftY = shiftX * sin + shiftY * cos;
const newCenterX = o.centerX + worldShiftX;
const newCenterY = o.centerY + worldShiftY;
layer.x = newCenterX - layer.width / 2;
layer.y = newCenterY - layer.height / 2;
}
this.canvas.render();
}
rotateLayerFromHandle(worldCoords, isShiftPressed) {

View File

@@ -12,6 +12,7 @@ import { createDistanceFieldMaskSync } from "./utils/ImageAnalysis.js";
const log = createModuleLogger('CanvasLayers');
export class CanvasLayers {
constructor(canvas) {
this._canvasMaskCache = new Map();
this.blendMenuElement = null;
this.blendMenuWorldX = 0;
this.blendMenuWorldY = 0;
@@ -309,6 +310,7 @@ export class CanvasLayers {
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
layer.width *= scale;
layer.height *= scale;
this.invalidateBlendCache(layer);
});
this.canvas.render();
this.canvas.requestSaveState();
@@ -318,11 +320,14 @@ export class CanvasLayers {
return;
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
layer.rotation += angle;
this.invalidateBlendCache(layer);
});
this.canvas.render();
this.canvas.requestSaveState();
}
getLayerAtPosition(worldX, worldY) {
// Always sort by zIndex so topmost is checked first
this.canvas.layers.sort((a, b) => a.zIndex - b.zIndex);
for (let i = this.canvas.layers.length - 1; i >= 0; i--) {
const layer = this.canvas.layers[i];
// Skip invisible layers
@@ -365,69 +370,197 @@ export class CanvasLayers {
const blendArea = layer.blendArea ?? 0;
const needsBlendAreaEffect = blendArea > 0;
if (needsBlendAreaEffect) {
log.debug(`Applying blend area effect for layer ${layer.id}, blendArea: ${blendArea}%`);
// Get or create distance field mask
const maskCanvas = this.getDistanceFieldMaskSync(layer.image, blendArea);
if (maskCanvas) {
// Create a temporary canvas for the masked layer
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(layer.width, layer.height);
if (tempCtx) {
// Draw the original image
tempCtx.drawImage(layer.image, 0, 0, layer.width, layer.height);
// Apply the distance field mask using destination-in for transparency effect
tempCtx.globalCompositeOperation = 'destination-in';
tempCtx.drawImage(maskCanvas, 0, 0, layer.width, layer.height);
// Draw the result
// Check if we have a valid cached blended image
if (layer.blendedImageCache && !layer.blendedImageDirty) {
// Use cached blended image for optimal performance
ctx.globalCompositeOperation = layer.blendMode || 'normal';
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
ctx.drawImage(layer.blendedImageCache, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
}
else {
// Cache is invalid or doesn't exist, update it
this.updateLayerBlendEffect(layer);
// Use the newly created cache if available, otherwise fallback
if (layer.blendedImageCache) {
ctx.globalCompositeOperation = layer.blendMode || 'normal';
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
ctx.drawImage(tempCanvas, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
ctx.drawImage(layer.blendedImageCache, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
}
else {
// Fallback to normal drawing
ctx.globalCompositeOperation = layer.blendMode || 'normal';
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
this._drawLayerImage(ctx, layer);
}
}
else {
// Fallback to normal drawing
ctx.globalCompositeOperation = layer.blendMode || 'normal';
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
}
}
else {
// Normal drawing without blend area effect
ctx.globalCompositeOperation = layer.blendMode || 'normal';
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
this._drawLayerImage(ctx, layer);
}
ctx.restore();
}
getDistanceFieldMaskSync(image, blendArea) {
// Check cache first
let imageCache = this.distanceFieldCache.get(image);
if (!imageCache) {
imageCache = new Map();
this.distanceFieldCache.set(image, imageCache);
_drawLayerImage(ctx, layer) {
ctx.globalCompositeOperation = layer.blendMode || 'normal';
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
// Use cropBounds if they exist, otherwise use the full image dimensions as the source
const s = layer.cropBounds || { x: 0, y: 0, width: layer.originalWidth, height: layer.originalHeight };
if (!layer.originalWidth || !layer.originalHeight) {
// Fallback for older layers without original dimensions or if data is missing
ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
return;
}
let maskCanvas = imageCache.get(blendArea);
if (!maskCanvas) {
// Calculate the on-screen scale of the layer's transform frame
const layerScaleX = layer.width / layer.originalWidth;
const layerScaleY = layer.height / layer.originalHeight;
// Calculate the on-screen size of the cropped portion
const dWidth = s.width * layerScaleX;
const dHeight = s.height * layerScaleY;
// Calculate the on-screen position of the top-left of the cropped portion.
// This is relative to the layer's center (the context's 0,0).
const dX = (-layer.width / 2) + (s.x * layerScaleX);
const dY = (-layer.height / 2) + (s.y * layerScaleY);
ctx.drawImage(layer.image, s.x, s.y, s.width, s.height, // source rect (from original image)
dX, dY, dWidth, dHeight // destination rect (scaled and positioned within the transform frame)
);
}
/**
* Invalidates the blended image cache for a layer
*/
invalidateBlendCache(layer) {
layer.blendedImageDirty = true;
layer.blendedImageCache = undefined;
}
/**
* Updates the blended image cache for a layer with blendArea effect
*/
updateLayerBlendEffect(layer) {
const blendArea = layer.blendArea ?? 0;
if (blendArea <= 0) {
// No blend effect needed, clear cache
layer.blendedImageCache = undefined;
layer.blendedImageDirty = false;
return;
}
try {
log.debug(`Updating blend effect cache for layer ${layer.id}, blendArea: ${blendArea}%`);
// Create the blended image using the same logic as _drawLayer
let maskCanvas = null;
let maskWidth = layer.width;
let maskHeight = layer.height;
if (layer.cropBounds && layer.originalWidth && layer.originalHeight) {
// Create a cropped canvas
const s = layer.cropBounds;
const { canvas: cropCanvas, ctx: cropCtx } = createCanvas(s.width, s.height);
if (cropCtx) {
cropCtx.drawImage(layer.image, s.x, s.y, s.width, s.height, 0, 0, s.width, s.height);
// Generate distance field mask for the cropped region
maskCanvas = this.getDistanceFieldMaskSync(cropCanvas, blendArea);
maskWidth = s.width;
maskHeight = s.height;
}
}
else {
// No crop, use full image
maskCanvas = this.getDistanceFieldMaskSync(layer.image, blendArea);
maskWidth = layer.originalWidth || layer.width;
maskHeight = layer.originalHeight || layer.height;
}
if (maskCanvas) {
// Create the final blended canvas
const { canvas: blendedCanvas, ctx: blendedCtx } = createCanvas(layer.width, layer.height);
if (blendedCtx) {
const s = layer.cropBounds || { x: 0, y: 0, width: layer.originalWidth, height: layer.originalHeight };
if (!layer.originalWidth || !layer.originalHeight) {
blendedCtx.drawImage(layer.image, 0, 0, layer.width, layer.height);
}
else {
const layerScaleX = layer.width / layer.originalWidth;
const layerScaleY = layer.height / layer.originalHeight;
const dWidth = s.width * layerScaleX;
const dHeight = s.height * layerScaleY;
const dX = s.x * layerScaleX;
const dY = s.y * layerScaleY;
blendedCtx.drawImage(layer.image, s.x, s.y, s.width, s.height, dX, dY, dWidth, dHeight);
// Apply the distance field mask only to the visible (cropped) area
blendedCtx.globalCompositeOperation = 'destination-in';
// Scale the mask to match the drawn area
blendedCtx.drawImage(maskCanvas, 0, 0, maskWidth, maskHeight, dX, dY, dWidth, dHeight);
}
// Store the blended result in cache
layer.blendedImageCache = blendedCanvas;
layer.blendedImageDirty = false;
log.debug(`Blend effect cache updated for layer ${layer.id}`);
}
else {
log.warn(`Failed to create blended canvas context for layer ${layer.id}`);
layer.blendedImageCache = undefined;
layer.blendedImageDirty = false;
}
}
else {
log.warn(`Failed to create distance field mask for layer ${layer.id}`);
layer.blendedImageCache = undefined;
layer.blendedImageDirty = false;
}
}
catch (error) {
log.error(`Error updating blend effect for layer ${layer.id}:`, error);
layer.blendedImageCache = undefined;
layer.blendedImageDirty = false;
}
}
getDistanceFieldMaskSync(imageOrCanvas, blendArea) {
// Use a WeakMap for images, and a Map for canvases (since canvases are not always stable references)
let cacheKey = imageOrCanvas;
if (imageOrCanvas instanceof HTMLCanvasElement) {
// For canvases, use a Map on this instance (not WeakMap)
if (!this._canvasMaskCache)
this._canvasMaskCache = new Map();
let canvasCache = this._canvasMaskCache.get(imageOrCanvas);
if (!canvasCache) {
canvasCache = new Map();
this._canvasMaskCache.set(imageOrCanvas, canvasCache);
}
if (canvasCache.has(blendArea)) {
log.info(`Using cached distance field mask for blendArea: ${blendArea}% (canvas)`);
return canvasCache.get(blendArea) || null;
}
try {
log.info(`Creating distance field mask for blendArea: ${blendArea}%`);
maskCanvas = createDistanceFieldMaskSync(image, blendArea);
log.info(`Creating distance field mask for blendArea: ${blendArea}% (canvas)`);
const maskCanvas = createDistanceFieldMaskSync(imageOrCanvas, blendArea);
log.info(`Distance field mask created successfully, size: ${maskCanvas.width}x${maskCanvas.height}`);
imageCache.set(blendArea, maskCanvas);
canvasCache.set(blendArea, maskCanvas);
return maskCanvas;
}
catch (error) {
log.error('Failed to create distance field mask:', error);
log.error('Failed to create distance field mask (canvas):', error);
return null;
}
}
else {
log.info(`Using cached distance field mask for blendArea: ${blendArea}%`);
// For images, use the original WeakMap cache
let imageCache = this.distanceFieldCache.get(imageOrCanvas);
if (!imageCache) {
imageCache = new Map();
this.distanceFieldCache.set(imageOrCanvas, imageCache);
}
let maskCanvas = imageCache.get(blendArea);
if (!maskCanvas) {
try {
log.info(`Creating distance field mask for blendArea: ${blendArea}%`);
maskCanvas = createDistanceFieldMaskSync(imageOrCanvas, blendArea);
log.info(`Distance field mask created successfully, size: ${maskCanvas.width}x${maskCanvas.height}`);
imageCache.set(blendArea, maskCanvas);
}
catch (error) {
log.error('Failed to create distance field mask:', error);
return null;
}
}
else {
log.info(`Using cached distance field mask for blendArea: ${blendArea}%`);
}
return maskCanvas;
}
return maskCanvas;
}
_drawLayers(ctx, layers, options = {}) {
const sortedLayers = [...layers].sort((a, b) => a.zIndex - b.zIndex);
@@ -445,6 +578,7 @@ export class CanvasLayers {
return;
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
layer.flipH = !layer.flipH;
this.invalidateBlendCache(layer);
});
this.canvas.render();
this.canvas.requestSaveState();
@@ -454,6 +588,7 @@ export class CanvasLayers {
return;
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
layer.flipV = !layer.flipV;
this.invalidateBlendCache(layer);
});
this.canvas.render();
this.canvas.requestSaveState();
@@ -527,30 +662,47 @@ export class CanvasLayers {
this.canvas.saveState();
}
getHandles(layer) {
const centerX = layer.x + layer.width / 2;
const centerY = layer.y + layer.height / 2;
const layerCenterX = layer.x + layer.width / 2;
const layerCenterY = 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;
let handleCenterX, handleCenterY, halfW, halfH;
if (layer.cropMode && layer.cropBounds && layer.originalWidth) {
// CROP MODE: Handles are relative to the cropped area
const layerScaleX = layer.width / layer.originalWidth;
const layerScaleY = layer.height / layer.originalHeight;
const cropRectW = layer.cropBounds.width * layerScaleX;
const cropRectH = layer.cropBounds.height * layerScaleY;
// Center of the CROP rectangle in the layer's local, un-rotated space
const cropCenterX_local = (-layer.width / 2) + ((layer.cropBounds.x + layer.cropBounds.width / 2) * layerScaleX);
const cropCenterY_local = (-layer.height / 2) + ((layer.cropBounds.y + layer.cropBounds.height / 2) * layerScaleY);
// Rotate this local center to find the world-space center of the crop rect
handleCenterX = layerCenterX + (cropCenterX_local * cos - cropCenterY_local * sin);
handleCenterY = layerCenterY + (cropCenterX_local * sin + cropCenterY_local * cos);
halfW = cropRectW / 2;
halfH = cropRectH / 2;
}
else {
// TRANSFORM MODE: Handles are relative to the full layer transform frame
handleCenterX = layerCenterX;
handleCenterY = layerCenterY;
halfW = layer.width / 2;
halfH = layer.height / 2;
}
const localHandles = {
'n': { x: 0, y: -halfH },
'ne': { x: halfW, y: -halfH },
'e': { x: halfW, y: 0 },
'se': { x: halfW, y: halfH },
's': { x: 0, y: halfH },
'sw': { x: -halfW, y: halfH },
'w': { x: -halfW, y: 0 },
'nw': { x: -halfW, y: -halfH },
'n': { x: 0, y: -halfH }, 'ne': { x: halfW, y: -halfH },
'e': { x: halfW, y: 0 }, 'se': { x: halfW, y: halfH },
's': { x: 0, y: halfH }, 'sw': { x: -halfW, y: halfH },
'w': { x: -halfW, y: 0 }, 'nw': { x: -halfW, y: -halfH },
'rot': { x: 0, y: -halfH - 20 / this.canvas.viewport.zoom }
};
const worldHandles = {};
for (const key in localHandles) {
const p = localHandles[key];
worldHandles[key] = {
x: centerX + (p.x * cos - p.y * sin),
y: centerY + (p.x * sin + p.y * cos)
x: handleCenterX + (p.x * cos - p.y * sin),
y: handleCenterY + (p.x * sin + p.y * cos)
};
}
return worldHandles;
@@ -716,10 +868,16 @@ export class CanvasLayers {
if (selectedLayer) {
const newValue = parseInt(blendAreaSlider.value, 10);
selectedLayer.blendArea = newValue;
// Invalidate cache when blend area changes
this.invalidateBlendCache(selectedLayer);
this.canvas.render();
}
};
blendAreaSlider.addEventListener('change', () => {
if (selectedLayer) {
// Update the blend effect cache when the slider value is finalized
this.updateLayerBlendEffect(selectedLayer);
}
this.canvas.saveState();
});
blendAreaContainer.appendChild(blendAreaLabel);

View File

@@ -431,38 +431,63 @@ export class CanvasRenderer {
drawSelectionFrame(ctx, layer) {
const lineWidth = 2 / this.canvas.viewport.zoom;
const handleRadius = 5 / this.canvas.viewport.zoom;
ctx.strokeStyle = '#00ff00';
ctx.lineWidth = lineWidth;
// Rysuj ramkę z adaptacyjnymi liniami (ciągłe/przerywane w zależności od przykrycia)
const halfW = layer.width / 2;
const halfH = layer.height / 2;
// Górna krawędź
this.drawAdaptiveLine(ctx, -halfW, -halfH, halfW, -halfH, layer);
// Prawa krawędź
this.drawAdaptiveLine(ctx, halfW, -halfH, halfW, halfH, layer);
// Dolna krawędź
this.drawAdaptiveLine(ctx, halfW, halfH, -halfW, halfH, layer);
// Lewa krawędź
this.drawAdaptiveLine(ctx, -halfW, halfH, -halfW, -halfH, layer);
// Rysuj linię do uchwytu rotacji (zawsze ciągła)
ctx.setLineDash([]);
ctx.beginPath();
ctx.moveTo(0, -layer.height / 2);
ctx.lineTo(0, -layer.height / 2 - 20 / this.canvas.viewport.zoom);
ctx.stroke();
// Rysuj uchwyty
if (layer.cropMode && layer.cropBounds && layer.originalWidth) {
// --- CROP MODE ---
ctx.lineWidth = lineWidth;
// 1. Draw dashed blue line for the full transform frame (the "original size" container)
ctx.strokeStyle = '#007bff';
ctx.setLineDash([8 / this.canvas.viewport.zoom, 8 / this.canvas.viewport.zoom]);
ctx.strokeRect(-layer.width / 2, -layer.height / 2, layer.width, layer.height);
ctx.setLineDash([]);
// 2. Draw solid blue line for the crop bounds
const layerScaleX = layer.width / layer.originalWidth;
const layerScaleY = layer.height / layer.originalHeight;
const s = layer.cropBounds;
const cropRectX = (-layer.width / 2) + (s.x * layerScaleX);
const cropRectY = (-layer.height / 2) + (s.y * layerScaleY);
const cropRectW = s.width * layerScaleX;
const cropRectH = s.height * layerScaleY;
ctx.strokeStyle = '#007bff'; // Solid blue
this.drawAdaptiveLine(ctx, cropRectX, cropRectY, cropRectX + cropRectW, cropRectY, layer); // Top
this.drawAdaptiveLine(ctx, cropRectX + cropRectW, cropRectY, cropRectX + cropRectW, cropRectY + cropRectH, layer); // Right
this.drawAdaptiveLine(ctx, cropRectX + cropRectW, cropRectY + cropRectH, cropRectX, cropRectY + cropRectH, layer); // Bottom
this.drawAdaptiveLine(ctx, cropRectX, cropRectY + cropRectH, cropRectX, cropRectY, layer); // Left
}
else {
// --- TRANSFORM MODE ---
ctx.strokeStyle = '#00ff00'; // Green
ctx.lineWidth = lineWidth;
const halfW = layer.width / 2;
const halfH = layer.height / 2;
// Draw adaptive solid green line for transform frame
this.drawAdaptiveLine(ctx, -halfW, -halfH, halfW, -halfH, layer);
this.drawAdaptiveLine(ctx, halfW, -halfH, halfW, halfH, layer);
this.drawAdaptiveLine(ctx, halfW, halfH, -halfW, halfH, layer);
this.drawAdaptiveLine(ctx, -halfW, halfH, -halfW, -halfH, layer);
// Draw line to rotation handle
ctx.setLineDash([]);
ctx.beginPath();
ctx.moveTo(0, -halfH);
ctx.lineTo(0, -halfH - 20 / this.canvas.viewport.zoom);
ctx.stroke();
}
// --- DRAW HANDLES (Unified Logic) ---
const handles = this.canvas.canvasLayers.getHandles(layer);
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#000000';
ctx.lineWidth = 1 / this.canvas.viewport.zoom;
for (const key in handles) {
// Skip rotation handle in crop mode
if (layer.cropMode && key === 'rot')
continue;
const point = handles[key];
ctx.beginPath();
// The handle position is already in world space, we need it in the layer's rotated space
const localX = point.x - (layer.x + layer.width / 2);
const localY = point.y - (layer.y + layer.height / 2);
const rad = -layer.rotation * Math.PI / 180;
const rotatedX = localX * Math.cos(rad) - localY * Math.sin(rad);
const rotatedY = localX * Math.sin(rad) + localY * Math.cos(rad);
ctx.beginPath();
ctx.arc(rotatedX, rotatedY, handleRadius, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();

View File

@@ -286,6 +286,9 @@ If you see dark images or masks in the output, make sure node_id is set to ${cor
const preparedLayers = await Promise.all(this.canvas.layers.map(async (layer, index) => {
const newLayer = { ...layer, imageId: layer.imageId || '' };
delete newLayer.image;
// Remove cache properties that cannot be serialized for the worker
delete newLayer.blendedImageCache;
delete newLayer.blendedImageDirty;
if (layer.image instanceof HTMLImageElement) {
if (layer.imageId) {
newLayer.imageId = layer.imageId;

View File

@@ -17,6 +17,32 @@ async function createCanvasWidget(node, widget, app) {
onStateChange: () => updateOutput(node, canvas)
});
const imageCache = new ImageCache();
/**
* Helper function to update the icon of a switch component.
* @param knobIconEl The HTML element for the switch's knob icon.
* @param isChecked The current state of the switch (e.g., checkbox.checked).
* @param iconToolTrue The icon tool name for the 'true' state.
* @param iconToolFalse The icon tool name for the 'false' state.
* @param fallbackTrue The text fallback for the 'true' state.
* @param fallbackFalse The text fallback for the 'false' state.
*/
const updateSwitchIcon = (knobIconEl, isChecked, iconToolTrue, iconToolFalse, fallbackTrue, fallbackFalse) => {
if (!knobIconEl)
return;
const iconTool = isChecked ? iconToolTrue : iconToolFalse;
const fallbackText = isChecked ? fallbackTrue : fallbackFalse;
const icon = iconLoader.getIcon(iconTool);
knobIconEl.innerHTML = ''; // Clear previous icon
if (icon instanceof HTMLImageElement) {
const clonedIcon = icon.cloneNode();
clonedIcon.style.width = '20px';
clonedIcon.style.height = '20px';
knobIconEl.appendChild(clonedIcon);
}
else {
knobIconEl.textContent = fallbackText;
}
};
const helpTooltip = $el("div.painter-tooltip", {
id: `painter-help-tooltip-${node.id}`,
});
@@ -158,27 +184,15 @@ async function createCanvasWidget(node, widget, app) {
showTooltip(switchEl, tooltipContent);
});
switchEl.addEventListener("mouseleave", hideTooltip);
// Dynamic icon and text update on toggle
// Dynamic icon update on toggle
const input = switchEl.querySelector('input[type="checkbox"]');
const knobIcon = switchEl.querySelector('.switch-knob .switch-icon');
const updateSwitchView = (isClipspace) => {
const iconTool = isClipspace ? LAYERFORGE_TOOLS.CLIPSPACE : LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD;
const icon = iconLoader.getIcon(iconTool);
if (icon instanceof HTMLImageElement) {
knobIcon.innerHTML = '';
const clonedIcon = icon.cloneNode();
clonedIcon.style.width = '20px';
clonedIcon.style.height = '20px';
knobIcon.appendChild(clonedIcon);
}
else {
knobIcon.textContent = isClipspace ? "🗂️" : "📋";
}
};
input.addEventListener('change', () => updateSwitchView(input.checked));
input.addEventListener('change', () => {
updateSwitchIcon(knobIcon, input.checked, LAYERFORGE_TOOLS.CLIPSPACE, LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD, "🗂️", "📋");
});
// Initial state
iconLoader.preloadToolIcons().then(() => {
updateSwitchView(isClipspace);
updateSwitchIcon(knobIcon, isClipspace, LAYERFORGE_TOOLS.CLIPSPACE, LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD, "🗂️", "📋");
});
return switchEl;
})()
@@ -293,6 +307,50 @@ async function createCanvasWidget(node, widget, app) {
]),
$el("div.painter-separator"),
$el("div.painter-button-group", {}, [
(() => {
const switchEl = $el("label.clipboard-switch.requires-selection", {
id: `crop-transform-switch-${node.id}`,
title: "Toggle between Transform and Crop mode for selected layer(s)"
}, [
$el("input", {
type: "checkbox",
checked: false,
onchange: (e) => {
const isCropMode = e.target.checked;
const selectedLayers = canvas.canvasSelection.selectedLayers;
if (selectedLayers.length === 0)
return;
selectedLayers.forEach((layer) => {
layer.cropMode = isCropMode;
if (isCropMode && !layer.cropBounds) {
layer.cropBounds = { x: 0, y: 0, width: layer.originalWidth, height: layer.originalHeight };
}
});
canvas.saveState();
canvas.render();
}
}),
$el("span.switch-track"),
$el("span.switch-labels", { style: { fontSize: "11px" } }, [
$el("span.text-clipspace", {}, ["Crop"]),
$el("span.text-system", {}, ["Transform"])
]),
$el("span.switch-knob", {}, [
$el("span.switch-icon", { id: `crop-transform-icon-${node.id}` })
])
]);
const input = switchEl.querySelector('input[type="checkbox"]');
const knobIcon = switchEl.querySelector('.switch-icon');
input.addEventListener('change', () => {
updateSwitchIcon(knobIcon, input.checked, LAYERFORGE_TOOLS.CROP, LAYERFORGE_TOOLS.TRANSFORM, "✂️", "✥");
});
// Initial state
iconLoader.preloadToolIcons().then(() => {
updateSwitchIcon(knobIcon, false, // Initial state is transform
LAYERFORGE_TOOLS.CROP, LAYERFORGE_TOOLS.TRANSFORM, "✂️", "✥");
});
return switchEl;
})(),
$el("button.painter-button.requires-selection", {
textContent: "Rotate +90°",
title: "Rotate selected layer(s) by +90 degrees",
@@ -629,19 +687,38 @@ async function createCanvasWidget(node, widget, app) {
const updateButtonStates = () => {
const selectionCount = canvas.canvasSelection.selectedLayers.length;
const hasSelection = selectionCount > 0;
controlPanel.querySelectorAll('.requires-selection').forEach((btn) => {
const button = btn;
if (button.textContent === 'Fuse') {
button.disabled = selectionCount < 2;
}
else {
button.disabled = !hasSelection;
// --- Handle Standard Buttons ---
controlPanel.querySelectorAll('.requires-selection').forEach((el) => {
if (el.tagName === 'BUTTON') {
if (el.textContent === 'Fuse') {
el.disabled = selectionCount < 2;
}
else {
el.disabled = !hasSelection;
}
}
});
const mattingBtn = controlPanel.querySelector('.matting-button');
if (mattingBtn && !mattingBtn.classList.contains('loading')) {
mattingBtn.disabled = selectionCount !== 1;
}
// --- Handle Crop/Transform Switch ---
const switchEl = controlPanel.querySelector(`#crop-transform-switch-${node.id}`);
if (switchEl) {
const input = switchEl.querySelector('input');
const knobIcon = switchEl.querySelector('.switch-icon');
const isDisabled = !hasSelection;
switchEl.classList.toggle('disabled', isDisabled);
input.disabled = isDisabled;
if (!isDisabled) {
const isCropMode = canvas.canvasSelection.selectedLayers[0].cropMode || false;
if (input.checked !== isCropMode) {
input.checked = isCropMode;
}
// Update icon view
updateSwitchIcon(knobIcon, isCropMode, LAYERFORGE_TOOLS.CROP, LAYERFORGE_TOOLS.TRANSFORM, "✂️", "✥");
}
}
};
canvas.canvasSelection.onSelectionChange = updateButtonStates;
const undoButton = controlPanel.querySelector(`#undo-button-${node.id}`);

View File

@@ -51,6 +51,32 @@
border-color: #3a76d6;
}
/* Crop mode button styling */
.painter-button#crop-mode-btn {
background-color: #444;
border-color: #555;
color: #fff;
transition: all 0.2s ease-in-out;
}
.painter-button#crop-mode-btn.primary {
background-color: #0080ff;
border-color: #0070e0;
color: #fff;
box-shadow: 0 0 8px rgba(0, 128, 255, 0.3);
}
.painter-button#crop-mode-btn.primary:hover {
background-color: #1090ff;
border-color: #0080ff;
box-shadow: 0 0 12px rgba(0, 128, 255, 0.4);
}
.painter-button#crop-mode-btn:hover {
background-color: #555;
border-color: #666;
}
.painter-button.success {
border-color: #4ae27a;
background-color: #444;
@@ -306,6 +332,20 @@
opacity: 0;
}
/* Disabled state for switch */
.clipboard-switch.disabled {
cursor: not-allowed;
opacity: 0.6;
background: #3a3a3a !important; /* Override gradient */
border-color: #4a4a4a !important;
transform: none !important;
box-shadow: none !important;
}
.clipboard-switch.disabled .switch-knob {
background-color: #4a4a4a !important;
}
.painter-separator {
width: 1px;

View File

@@ -19,13 +19,19 @@ export const LAYERFORGE_TOOLS = {
SETTINGS: 'settings',
SYSTEM_CLIPBOARD: 'system_clipboard',
CLIPSPACE: 'clipspace',
CROP: 'crop',
TRANSFORM: 'transform',
};
// SVG Icons for LayerForge tools
const SYSTEM_CLIPBOARD_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M19 2h-4.18C14.4.84 13.3 0 12 0S9.6.84 9.18 2H5c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm5 15H7v-2h10v2zm0-4H7v-2h10v2zm0-4H7V7h10v2z"/></svg>`;
const CLIPSPACE_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> <defs> <mask id="cutout"> <rect width="100%" height="100%" fill="white"/> <path d="M5.485 23.76c-.568 0-1.026-.207-1.325-.598-.307-.402-.387-.964-.22-1.54l.672-2.315a.605.605 0 00-.1-.536.622.622 0 00-.494-.243H2.085c-.568 0-1.026-.207-1.325-.598-.307-.403-.387-.964-.22-1.54l2.31-7.917.255-.87c.343-1.18 1.592-2.14 2.786-2.14h2.313c.276 0 .519-.18.595-.442l.764-2.633C9.906 1.208 11.155.249 12.35.249l4.945-.008h3.62c.568 0 1.027.206 1.325.597.307.402.387.964.22 1.54l-1.035 3.566c-.343 1.178-1.593 2.137-2.787 2.137l-4.956.01H11.37a.618.618 0 00-.594.441l-1.928 6.604a.605.605 0 00.1.537c.118.153.3.243.495.243l3.275-.006h3.61c.568 0 1.026.206 1.325.598.307.402.387.964.22 1.54l-1.036 3.565c-.342 1.179-1.592 2.138-2.786 2.138l-4.957.01h-3.61z" fill="black" transform="translate(4.8 4.8) scale(0.6)" /> </mask> </defs> <path d="M19 2h-4.18C14.4.84 13.3 0 12 0S9.6.84 9.18 2H5c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1z" fill="#ffffff" mask="url(#cutout)" /></svg>`;
const CROP_ICON_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M17 15h3V7c0-1.1-.9-2-2-2H10v3h7v7zM7 18V1H4v4H0v3h4v10c0 2 1 3 3 3h10v4h3v-4h4v-3H24z"/></svg>';
const TRANSFORM_ICON_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M11.3 17.096c.092-.044.34-.052 1.028-.044l.912.008.124.124c.184.184.184.408.004.584l-.128.132-.896.012c-.72.008-.924 0-1.036-.048-.18-.072-.284-.264-.256-.452.028-.168.092-.248.248-.316Zm-3.164 0c.096-.044.328-.052 1.036-.044l.916.008.116.132c.16.18.16.396 0 .576l-.116.132-.876.012c-.552.008-.928-.004-1.02-.032-.388-.112-.428-.62-.056-.784Zm-4.6-1.168.112-.096 1.42.004 1.424.004.116.116.116.116V17.48v1.408l-.116.116-.116.116H5.068h-1.42l-.112-.096-.112-.096L3.42 17.48V16.032l.112-.096ZM4.78 12.336c.104-.104.168-.136.284-.136s.18.032.284.136l.136.136v.964.964l-.116.128c-.1.112-.144.132-.304.132s-.204-.02-.304-.132L4.644 14.4l-.004-.964v-.964l.136-.136Zm8.868-.648c-.008-.024-.004-.048.008-.048s1.504.512 3.312 1.136c1.812.624 4.252 1.464 5.424 1.868 1.168.404 2.128.744 2.128.76 0 .012-.24.108-.528.212-.292.104-1.468.52-2.616.928l-2.08.74-.936 2.62c-.512 1.44-.944 2.616-.956 2.616-.016 0-.86-2.424-1.88-5.392-1.02-2.964-1.864-5.412-1.876-5.44ZM19.292 9.08c.216-.088.432-.02.548.168.076.124.08.188.072 1.06l-.012.928-.116.12c-.1.104-.148.124-.304.124s-.204-.02-.304-.124l-.116-.12-.012-.928c-.008-.872-.004-.936.072-1.06.044-.072.12-.148.172-.168Zm-14.516.096c.104-.104.168-.136.284-.136s.18.032.284.136l.136.136v.956c0 1.064-.004 1.088-.268 1.2-.18.072-.376.012-.492-.148-.076-.104-.08-.172-.08-1.06V9.312l.136-.136ZM19.192 6c.096-.088.168-.116.288-.116s.192.028.288.116l.132.116V7.1v.98l-.116.12c-.1.104-.148.124-.304.124s-.204-.02-.304-.124l-.116-.12V7.096 6.112l.132-.116ZM4.816 5.964c.048-.044.152-.072.256-.072.144 0 .196.02.292.124l.116.124v.98.968l-.116.116c-.092.092-.152.116-.284.116-.408 0-.44-.28-.44-1.22s.012-1.016.176-1.148Zm9.516-3.192.14-.136.968.004h.968l.112.116c.152.152.188.3.108.468-.124.252-.196.276-1.044.288-.42.008-.84.004-.936-.012-.24-.036-.38-.192-.436-.408-.02-.156-.008-.184.12-.312Zm-3.156-.268.136.136h.956c1.064 0 1.088.004 1.2.268.072.172.016.372-.136.492-.096.076-.16.08-1.06.08h-.96l-.136-.136c-.104-.104-.136-.168-.136-.284s.032-.18.136-.284Zm-3.16 0 .136.136h.96c.94 0 .964.004 1.068.088.2.176.196.508-.004.668-.1.08-.156.084-1.064.084h-.96l-.136-.136c-.188-.188-.188-.38 0-.568Zm10.04-1.14c.044-.02.712-.032 1.476-.028l1.396.008.096.112.096.112v1.424 1.5l-.116.116-.116.116L19.48 4.72H18.072l-.116-.116-.116-.116V3.072c0-1.524.004-1.544.216-1.632ZM3.62 1.456c.184-.08 2.74-.08 2.896 0 .196.104.204.164.204 1.604s-.008 1.5-.204 1.604c-.148.076-2.732.084-2.896.008-.212-.096-.22-.148-.22-1.608s.008-1.516.22-1.608Z"/></svg>';
const LAYERFORGE_TOOL_ICONS = {
[LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(SYSTEM_CLIPBOARD_ICON_SVG)}`,
[LAYERFORGE_TOOLS.CLIPSPACE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(CLIPSPACE_ICON_SVG)}`,
[LAYERFORGE_TOOLS.CROP]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(CROP_ICON_SVG)}`,
[LAYERFORGE_TOOLS.TRANSFORM]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(TRANSFORM_ICON_SVG)}`,
[LAYERFORGE_TOOLS.VISIBILITY]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>')}`,
[LAYERFORGE_TOOLS.MOVE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M13,20H11V8L5.5,13.5L4.08,12.08L12,4.16L19.92,12.08L18.5,13.5L13,8V20Z"/></svg>')}`,
[LAYERFORGE_TOOLS.ROTATE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M12,6V9L16,5L12,1V4A8,8 0 0,0 4,12C4,13.57 4.46,15.03 5.24,16.26L6.7,14.8C6.25,13.97 6,13 6,12A6,6 0 0,1 12,6M18.76,7.74L17.3,9.2C17.74,10.04 18,11 18,12A6,6 0 0,1 12,18V15L8,19L12,23V20A8,8 0 0,0 20,12C20,10.43 19.54,8.97 18.76,7.74Z"/></svg>')}`,
@@ -54,7 +60,9 @@ const LAYERFORGE_TOOL_COLORS = {
[LAYERFORGE_TOOLS.BRUSH]: '#4285F4',
[LAYERFORGE_TOOLS.ERASER]: '#FBBC05',
[LAYERFORGE_TOOLS.SHAPE]: '#FF6D01',
[LAYERFORGE_TOOLS.SETTINGS]: '#F06292'
[LAYERFORGE_TOOLS.SETTINGS]: '#F06292',
[LAYERFORGE_TOOLS.CROP]: '#EA4335',
[LAYERFORGE_TOOLS.TRANSFORM]: '#34A853',
};
export class IconLoader {
constructor() {

View File

@@ -1,7 +1,7 @@
[project]
name = "layerforge"
description = "Photoshop-like layered canvas editor to your ComfyUI workflow. This node is perfect for complex compositing, inpainting, and outpainting, featuring multi-layer support, masking, blend modes, and precise transformations. Includes optional AI-powered background removal for streamlined image editing."
version = "1.5.1"
version = "1.5.2"
license = { text = "MIT License" }
dependencies = ["torch", "torchvision", "transformers", "aiohttp", "numpy", "tqdm", "Pillow"]

View File

@@ -626,7 +626,10 @@ export class CanvasInteractions {
width: layer.width, height: layer.height,
rotation: layer.rotation,
centerX: layer.x + layer.width / 2,
centerY: layer.y + layer.height / 2
centerY: layer.y + layer.height / 2,
originalWidth: layer.originalWidth,
originalHeight: layer.originalHeight,
cropBounds: layer.cropBounds ? { ...layer.cropBounds } : undefined
};
this.interaction.dragStart = {...worldCoords};
@@ -797,66 +800,137 @@ export class CanvasInteractions {
if (this.interaction.isCtrlPressed) {
const snapThreshold = 10 / this.canvas.viewport.zoom;
const snappedMouseX = snapToGrid(mouseX);
if (Math.abs(mouseX - snappedMouseX) < snapThreshold) mouseX = snappedMouseX;
const snappedMouseY = snapToGrid(mouseY);
if (Math.abs(mouseY - snappedMouseY) < snapThreshold) mouseY = snappedMouseY;
mouseX = Math.abs(mouseX - snapToGrid(mouseX)) < snapThreshold ? snapToGrid(mouseX) : mouseX;
mouseY = Math.abs(mouseY - snapToGrid(mouseY)) < snapThreshold ? snapToGrid(mouseY) : mouseY;
}
const o = this.interaction.transformOrigin;
if (o.rotation === undefined || o.width === undefined || o.height === undefined || o.centerX === undefined || o.centerY === undefined) return;
const handle = this.interaction.resizeHandle;
const anchor = this.interaction.resizeAnchor;
const rad = o.rotation * Math.PI / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
// Vector from anchor to mouse
const vecX = mouseX - anchor.x;
const vecY = mouseY - anchor.y;
let newWidth = vecX * cos + vecY * sin;
let newHeight = vecY * cos - vecX * sin;
// Rotate vector to align with layer's local coordinates
let localVecX = vecX * cos + vecY * sin;
let localVecY = vecY * cos - vecX * sin;
if (isShiftPressed) {
const originalAspectRatio = o.width / o.height;
// Determine sign based on handle
const signX = handle?.includes('e') ? 1 : (handle?.includes('w') ? -1 : 0);
const signY = handle?.includes('s') ? 1 : (handle?.includes('n') ? -1 : 0);
localVecX *= signX;
localVecY *= signY;
if (Math.abs(newWidth) > Math.abs(newHeight) * originalAspectRatio) {
newHeight = (Math.sign(newHeight) || 1) * Math.abs(newWidth) / originalAspectRatio;
} else {
newWidth = (Math.sign(newWidth) || 1) * Math.abs(newHeight) * originalAspectRatio;
// If not a corner handle, keep original dimension
if (signX === 0) localVecX = o.width;
if (signY === 0) localVecY = o.height;
if (layer.cropMode && o.cropBounds && o.originalWidth && o.originalHeight) {
// CROP MODE: Calculate delta based on mouse movement and apply to cropBounds.
// Calculate mouse movement since drag start, in the layer's local coordinate system.
const dragStartX_local = this.interaction.dragStart.x - (o.centerX ?? 0);
const dragStartY_local = this.interaction.dragStart.y - (o.centerY ?? 0);
const mouseX_local = mouseX - (o.centerX ?? 0);
const mouseY_local = mouseY - (o.centerY ?? 0);
// Rotate mouse delta into the layer's unrotated frame
const deltaX_world = mouseX_local - dragStartX_local;
const deltaY_world = mouseY_local - dragStartY_local;
const mouseDeltaX_local = deltaX_world * cos + deltaY_world * sin;
const mouseDeltaY_local = deltaY_world * cos - deltaX_world * sin;
// Convert the on-screen mouse delta to an image-space delta.
const screenToImageScaleX = o.originalWidth / o.width;
const screenToImageScaleY = o.originalHeight / o.height;
const delta_image_x = mouseDeltaX_local * screenToImageScaleX;
const delta_image_y = mouseDeltaY_local * screenToImageScaleY;
let newCropBounds = { ...o.cropBounds }; // Start with the bounds from the beginning of the drag
// Apply the image-space delta to the appropriate edges of the crop bounds
if (handle?.includes('w')) {
newCropBounds.x += delta_image_x;
newCropBounds.width -= delta_image_x;
}
if (handle?.includes('e')) {
newCropBounds.width += delta_image_x;
}
if (handle?.includes('n')) {
newCropBounds.y += delta_image_y;
newCropBounds.height -= delta_image_y;
}
if (handle?.includes('s')) {
newCropBounds.height += delta_image_y;
}
// Clamp crop bounds to stay within the original image and maintain minimum size
if (newCropBounds.width < 1) {
if (handle?.includes('w')) newCropBounds.x = o.cropBounds.x + o.cropBounds.width -1;
newCropBounds.width = 1;
}
if (newCropBounds.height < 1) {
if (handle?.includes('n')) newCropBounds.y = o.cropBounds.y + o.cropBounds.height - 1;
newCropBounds.height = 1;
}
if (newCropBounds.x < 0) {
newCropBounds.width += newCropBounds.x;
newCropBounds.x = 0;
}
if (newCropBounds.y < 0) {
newCropBounds.height += newCropBounds.y;
newCropBounds.y = 0;
}
if (newCropBounds.x + newCropBounds.width > o.originalWidth) {
newCropBounds.width = o.originalWidth - newCropBounds.x;
}
if (newCropBounds.y + newCropBounds.height > o.originalHeight) {
newCropBounds.height = o.originalHeight - newCropBounds.y;
}
layer.cropBounds = newCropBounds;
} else {
// TRANSFORM MODE: Resize the layer's main transform frame
let newWidth = localVecX;
let newHeight = localVecY;
if (isShiftPressed) {
const originalAspectRatio = o.width / o.height;
if (Math.abs(newWidth) > Math.abs(newHeight) * originalAspectRatio) {
newHeight = (Math.sign(newHeight) || 1) * Math.abs(newWidth) / originalAspectRatio;
} else {
newWidth = (Math.sign(newWidth) || 1) * Math.abs(newHeight) * originalAspectRatio;
}
}
if (newWidth < 10) newWidth = 10;
if (newHeight < 10) newHeight = 10;
layer.width = newWidth;
layer.height = newHeight;
// Update position to keep anchor point fixed
const deltaW = layer.width - o.width;
const deltaH = layer.height - o.height;
const shiftX = (deltaW / 2) * signX;
const shiftY = (deltaH / 2) * signY;
const worldShiftX = shiftX * cos - shiftY * sin;
const worldShiftY = shiftX * sin + shiftY * cos;
const newCenterX = o.centerX + worldShiftX;
const newCenterY = o.centerY + worldShiftY;
layer.x = newCenterX - layer.width / 2;
layer.y = newCenterY - layer.height / 2;
}
let signX = handle?.includes('e') ? 1 : (handle?.includes('w') ? -1 : 0);
let signY = handle?.includes('s') ? 1 : (handle?.includes('n') ? -1 : 0);
newWidth *= signX;
newHeight *= signY;
if (signX === 0) newWidth = o.width;
if (signY === 0) newHeight = o.height;
if (newWidth < 10) newWidth = 10;
if (newHeight < 10) newHeight = 10;
layer.width = newWidth;
layer.height = newHeight;
const deltaW = newWidth - o.width;
const deltaH = newHeight - o.height;
const shiftX = (deltaW / 2) * signX;
const shiftY = (deltaH / 2) * signY;
const worldShiftX = shiftX * cos - shiftY * sin;
const worldShiftY = shiftX * sin + shiftY * cos;
const newCenterX = o.centerX + worldShiftX;
const newCenterY = o.centerY + worldShiftY;
layer.x = newCenterX - layer.width / 2;
layer.y = newCenterY - layer.height / 2;
this.canvas.render();
}

View File

@@ -21,6 +21,7 @@ interface BlendMode {
export class CanvasLayers {
private canvas: Canvas;
private _canvasMaskCache: Map<HTMLCanvasElement, Map<number, HTMLCanvasElement>> = new Map();
public clipboardManager: ClipboardManager;
private blendModes: BlendMode[];
private selectedBlendMode: string | null;
@@ -354,6 +355,7 @@ export class CanvasLayers {
this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => {
layer.width *= scale;
layer.height *= scale;
this.invalidateBlendCache(layer);
});
this.canvas.render();
this.canvas.requestSaveState();
@@ -364,12 +366,16 @@ export class CanvasLayers {
this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => {
layer.rotation += angle;
this.invalidateBlendCache(layer);
});
this.canvas.render();
this.canvas.requestSaveState();
}
getLayerAtPosition(worldX: number, worldY: number): { layer: Layer, localX: number, localY: number } | null {
// Always sort by zIndex so topmost is checked first
this.canvas.layers.sort((a, b) => a.zIndex - b.zIndex);
for (let i = this.canvas.layers.length - 1; i >= 0; i--) {
const layer = this.canvas.layers[i];
@@ -424,72 +430,222 @@ export class CanvasLayers {
const needsBlendAreaEffect = blendArea > 0;
if (needsBlendAreaEffect) {
log.debug(`Applying blend area effect for layer ${layer.id}, blendArea: ${blendArea}%`);
// Get or create distance field mask
const maskCanvas = this.getDistanceFieldMaskSync(layer.image, blendArea);
if (maskCanvas) {
// Create a temporary canvas for the masked layer
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(layer.width, layer.height);
if (tempCtx) {
// Draw the original image
tempCtx.drawImage(layer.image, 0, 0, layer.width, layer.height);
// Apply the distance field mask using destination-in for transparency effect
tempCtx.globalCompositeOperation = 'destination-in';
tempCtx.drawImage(maskCanvas, 0, 0, layer.width, layer.height);
// Draw the result
ctx.globalCompositeOperation = layer.blendMode as any || 'normal';
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
ctx.drawImage(tempCanvas, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
} else {
// Fallback to normal drawing
ctx.globalCompositeOperation = layer.blendMode as any || 'normal';
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
}
} else {
// Fallback to normal drawing
// Check if we have a valid cached blended image
if (layer.blendedImageCache && !layer.blendedImageDirty) {
// Use cached blended image for optimal performance
ctx.globalCompositeOperation = layer.blendMode as any || 'normal';
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
ctx.drawImage(layer.blendedImageCache, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
} else {
// Cache is invalid or doesn't exist, update it
this.updateLayerBlendEffect(layer);
// Use the newly created cache if available, otherwise fallback
if (layer.blendedImageCache) {
ctx.globalCompositeOperation = layer.blendMode as any || 'normal';
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
ctx.drawImage(layer.blendedImageCache, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
} else {
// Fallback to normal drawing
this._drawLayerImage(ctx, layer);
}
}
} else {
// Normal drawing without blend area effect
ctx.globalCompositeOperation = layer.blendMode as any || 'normal';
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
this._drawLayerImage(ctx, layer);
}
ctx.restore();
}
private getDistanceFieldMaskSync(image: HTMLImageElement, blendArea: number): HTMLCanvasElement | null {
// Check cache first
let imageCache = this.distanceFieldCache.get(image);
if (!imageCache) {
imageCache = new Map();
this.distanceFieldCache.set(image, imageCache);
}
private _drawLayerImage(ctx: CanvasRenderingContext2D, layer: Layer): void {
ctx.globalCompositeOperation = layer.blendMode as any || 'normal';
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
let maskCanvas = imageCache.get(blendArea);
if (!maskCanvas) {
// Use cropBounds if they exist, otherwise use the full image dimensions as the source
const s = layer.cropBounds || { x: 0, y: 0, width: layer.originalWidth, height: layer.originalHeight };
if (!layer.originalWidth || !layer.originalHeight) {
// Fallback for older layers without original dimensions or if data is missing
ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
return;
}
// Calculate the on-screen scale of the layer's transform frame
const layerScaleX = layer.width / layer.originalWidth;
const layerScaleY = layer.height / layer.originalHeight;
// Calculate the on-screen size of the cropped portion
const dWidth = s.width * layerScaleX;
const dHeight = s.height * layerScaleY;
// Calculate the on-screen position of the top-left of the cropped portion.
// This is relative to the layer's center (the context's 0,0).
const dX = (-layer.width / 2) + (s.x * layerScaleX);
const dY = (-layer.height / 2) + (s.y * layerScaleY);
ctx.drawImage(
layer.image,
s.x, s.y, s.width, s.height, // source rect (from original image)
dX, dY, dWidth, dHeight // destination rect (scaled and positioned within the transform frame)
);
}
/**
* Invalidates the blended image cache for a layer
*/
public invalidateBlendCache(layer: Layer): void {
layer.blendedImageDirty = true;
layer.blendedImageCache = undefined;
}
/**
* Updates the blended image cache for a layer with blendArea effect
*/
public updateLayerBlendEffect(layer: Layer): void {
const blendArea = layer.blendArea ?? 0;
if (blendArea <= 0) {
// No blend effect needed, clear cache
layer.blendedImageCache = undefined;
layer.blendedImageDirty = false;
return;
}
try {
log.debug(`Updating blend effect cache for layer ${layer.id}, blendArea: ${blendArea}%`);
// Create the blended image using the same logic as _drawLayer
let maskCanvas: HTMLCanvasElement | null = null;
let maskWidth = layer.width;
let maskHeight = layer.height;
if (layer.cropBounds && layer.originalWidth && layer.originalHeight) {
// Create a cropped canvas
const s = layer.cropBounds;
const { canvas: cropCanvas, ctx: cropCtx } = createCanvas(s.width, s.height);
if (cropCtx) {
cropCtx.drawImage(
layer.image,
s.x, s.y, s.width, s.height,
0, 0, s.width, s.height
);
// Generate distance field mask for the cropped region
maskCanvas = this.getDistanceFieldMaskSync(cropCanvas, blendArea);
maskWidth = s.width;
maskHeight = s.height;
}
} else {
// No crop, use full image
maskCanvas = this.getDistanceFieldMaskSync(layer.image, blendArea);
maskWidth = layer.originalWidth || layer.width;
maskHeight = layer.originalHeight || layer.height;
}
if (maskCanvas) {
// Create the final blended canvas
const { canvas: blendedCanvas, ctx: blendedCtx } = createCanvas(layer.width, layer.height);
if (blendedCtx) {
const s = layer.cropBounds || { x: 0, y: 0, width: layer.originalWidth, height: layer.originalHeight };
if (!layer.originalWidth || !layer.originalHeight) {
blendedCtx.drawImage(layer.image, 0, 0, layer.width, layer.height);
} else {
const layerScaleX = layer.width / layer.originalWidth;
const layerScaleY = layer.height / layer.originalHeight;
const dWidth = s.width * layerScaleX;
const dHeight = s.height * layerScaleY;
const dX = s.x * layerScaleX;
const dY = s.y * layerScaleY;
blendedCtx.drawImage(
layer.image,
s.x, s.y, s.width, s.height,
dX, dY, dWidth, dHeight
);
// Apply the distance field mask only to the visible (cropped) area
blendedCtx.globalCompositeOperation = 'destination-in';
// Scale the mask to match the drawn area
blendedCtx.drawImage(
maskCanvas,
0, 0, maskWidth, maskHeight,
dX, dY, dWidth, dHeight
);
}
// Store the blended result in cache
layer.blendedImageCache = blendedCanvas;
layer.blendedImageDirty = false;
log.debug(`Blend effect cache updated for layer ${layer.id}`);
} else {
log.warn(`Failed to create blended canvas context for layer ${layer.id}`);
layer.blendedImageCache = undefined;
layer.blendedImageDirty = false;
}
} else {
log.warn(`Failed to create distance field mask for layer ${layer.id}`);
layer.blendedImageCache = undefined;
layer.blendedImageDirty = false;
}
} catch (error) {
log.error(`Error updating blend effect for layer ${layer.id}:`, error);
layer.blendedImageCache = undefined;
layer.blendedImageDirty = false;
}
}
private getDistanceFieldMaskSync(imageOrCanvas: HTMLImageElement | HTMLCanvasElement, blendArea: number): HTMLCanvasElement | null {
// Use a WeakMap for images, and a Map for canvases (since canvases are not always stable references)
let cacheKey: any = imageOrCanvas;
if (imageOrCanvas instanceof HTMLCanvasElement) {
// For canvases, use a Map on this instance (not WeakMap)
if (!this._canvasMaskCache) this._canvasMaskCache = new Map();
let canvasCache = this._canvasMaskCache.get(imageOrCanvas);
if (!canvasCache) {
canvasCache = new Map();
this._canvasMaskCache.set(imageOrCanvas, canvasCache);
}
if (canvasCache.has(blendArea)) {
log.info(`Using cached distance field mask for blendArea: ${blendArea}% (canvas)`);
return canvasCache.get(blendArea) || null;
}
try {
log.info(`Creating distance field mask for blendArea: ${blendArea}%`);
maskCanvas = createDistanceFieldMaskSync(image, blendArea);
log.info(`Creating distance field mask for blendArea: ${blendArea}% (canvas)`);
const maskCanvas = createDistanceFieldMaskSync(imageOrCanvas as any, blendArea);
log.info(`Distance field mask created successfully, size: ${maskCanvas.width}x${maskCanvas.height}`);
imageCache.set(blendArea, maskCanvas);
canvasCache.set(blendArea, maskCanvas);
return maskCanvas;
} catch (error) {
log.error('Failed to create distance field mask:', error);
log.error('Failed to create distance field mask (canvas):', error);
return null;
}
} else {
log.info(`Using cached distance field mask for blendArea: ${blendArea}%`);
// For images, use the original WeakMap cache
let imageCache = this.distanceFieldCache.get(imageOrCanvas);
if (!imageCache) {
imageCache = new Map();
this.distanceFieldCache.set(imageOrCanvas, imageCache);
}
let maskCanvas = imageCache.get(blendArea);
if (!maskCanvas) {
try {
log.info(`Creating distance field mask for blendArea: ${blendArea}%`);
maskCanvas = createDistanceFieldMaskSync(imageOrCanvas, blendArea);
log.info(`Distance field mask created successfully, size: ${maskCanvas.width}x${maskCanvas.height}`);
imageCache.set(blendArea, maskCanvas);
} catch (error) {
log.error('Failed to create distance field mask:', error);
return null;
}
} else {
log.info(`Using cached distance field mask for blendArea: ${blendArea}%`);
}
return maskCanvas;
}
return maskCanvas;
}
private _drawLayers(ctx: CanvasRenderingContext2D, layers: Layer[], options: { offsetX?: number, offsetY?: number } = {}): void {
@@ -509,6 +665,7 @@ export class CanvasLayers {
if (this.canvas.canvasSelection.selectedLayers.length === 0) return;
this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => {
layer.flipH = !layer.flipH;
this.invalidateBlendCache(layer);
});
this.canvas.render();
this.canvas.requestSaveState();
@@ -518,6 +675,7 @@ export class CanvasLayers {
if (this.canvas.canvasSelection.selectedLayers.length === 0) return;
this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => {
layer.flipV = !layer.flipV;
this.invalidateBlendCache(layer);
});
this.canvas.render();
this.canvas.requestSaveState();
@@ -606,23 +764,45 @@ export class CanvasLayers {
}
getHandles(layer: Layer): Record<string, Point> {
const centerX = layer.x + layer.width / 2;
const centerY = layer.y + layer.height / 2;
const layerCenterX = layer.x + layer.width / 2;
const layerCenterY = 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;
let handleCenterX, handleCenterY, halfW, halfH;
if (layer.cropMode && layer.cropBounds && layer.originalWidth) {
// CROP MODE: Handles are relative to the cropped area
const layerScaleX = layer.width / layer.originalWidth;
const layerScaleY = layer.height / layer.originalHeight;
const cropRectW = layer.cropBounds.width * layerScaleX;
const cropRectH = layer.cropBounds.height * layerScaleY;
// Center of the CROP rectangle in the layer's local, un-rotated space
const cropCenterX_local = (-layer.width / 2) + ((layer.cropBounds.x + layer.cropBounds.width / 2) * layerScaleX);
const cropCenterY_local = (-layer.height / 2) + ((layer.cropBounds.y + layer.cropBounds.height / 2) * layerScaleY);
// Rotate this local center to find the world-space center of the crop rect
handleCenterX = layerCenterX + (cropCenterX_local * cos - cropCenterY_local * sin);
handleCenterY = layerCenterY + (cropCenterX_local * sin + cropCenterY_local * cos);
halfW = cropRectW / 2;
halfH = cropRectH / 2;
} else {
// TRANSFORM MODE: Handles are relative to the full layer transform frame
handleCenterX = layerCenterX;
handleCenterY = layerCenterY;
halfW = layer.width / 2;
halfH = layer.height / 2;
}
const localHandles: Record<string, Point> = {
'n': { x: 0, y: -halfH },
'ne': { x: halfW, y: -halfH },
'e': { x: halfW, y: 0 },
'se': { x: halfW, y: halfH },
's': { x: 0, y: halfH },
'sw': { x: -halfW, y: halfH },
'w': { x: -halfW, y: 0 },
'nw': { x: -halfW, y: -halfH },
'n': { x: 0, y: -halfH }, 'ne': { x: halfW, y: -halfH },
'e': { x: halfW, y: 0 }, 'se': { x: halfW, y: halfH },
's': { x: 0, y: halfH }, 'sw': { x: -halfW, y: halfH },
'w': { x: -halfW, y: 0 }, 'nw': { x: -halfW, y: -halfH },
'rot': { x: 0, y: -halfH - 20 / this.canvas.viewport.zoom }
};
@@ -630,8 +810,8 @@ export class CanvasLayers {
for (const key in localHandles) {
const p = localHandles[key];
worldHandles[key] = {
x: centerX + (p.x * cos - p.y * sin),
y: centerY + (p.x * sin + p.y * cos)
x: handleCenterX + (p.x * cos - p.y * sin),
y: handleCenterY + (p.x * sin + p.y * cos)
};
}
return worldHandles;
@@ -833,11 +1013,17 @@ export class CanvasLayers {
if (selectedLayer) {
const newValue = parseInt(blendAreaSlider.value, 10);
selectedLayer.blendArea = newValue;
// Invalidate cache when blend area changes
this.invalidateBlendCache(selectedLayer);
this.canvas.render();
}
};
blendAreaSlider.addEventListener('change', () => {
if (selectedLayer) {
// Update the blend effect cache when the slider value is finalized
this.updateLayerBlendEffect(selectedLayer);
}
this.canvas.saveState();
});

View File

@@ -532,38 +532,66 @@ export class CanvasRenderer {
drawSelectionFrame(ctx: any, layer: any) {
const lineWidth = 2 / this.canvas.viewport.zoom;
const handleRadius = 5 / this.canvas.viewport.zoom;
ctx.strokeStyle = '#00ff00';
ctx.lineWidth = lineWidth;
if (layer.cropMode && layer.cropBounds && layer.originalWidth) {
// --- CROP MODE ---
ctx.lineWidth = lineWidth;
// 1. Draw dashed blue line for the full transform frame (the "original size" container)
ctx.strokeStyle = '#007bff';
ctx.setLineDash([8 / this.canvas.viewport.zoom, 8 / this.canvas.viewport.zoom]);
ctx.strokeRect(-layer.width / 2, -layer.height / 2, layer.width, layer.height);
ctx.setLineDash([]);
// 2. Draw solid blue line for the crop bounds
const layerScaleX = layer.width / layer.originalWidth;
const layerScaleY = layer.height / layer.originalHeight;
const s = layer.cropBounds;
const cropRectX = (-layer.width / 2) + (s.x * layerScaleX);
const cropRectY = (-layer.height / 2) + (s.y * layerScaleY);
const cropRectW = s.width * layerScaleX;
const cropRectH = s.height * layerScaleY;
ctx.strokeStyle = '#007bff'; // Solid blue
this.drawAdaptiveLine(ctx, cropRectX, cropRectY, cropRectX + cropRectW, cropRectY, layer); // Top
this.drawAdaptiveLine(ctx, cropRectX + cropRectW, cropRectY, cropRectX + cropRectW, cropRectY + cropRectH, layer); // Right
this.drawAdaptiveLine(ctx, cropRectX + cropRectW, cropRectY + cropRectH, cropRectX, cropRectY + cropRectH, layer); // Bottom
this.drawAdaptiveLine(ctx, cropRectX, cropRectY + cropRectH, cropRectX, cropRectY, layer); // Left
} else {
// --- TRANSFORM MODE ---
ctx.strokeStyle = '#00ff00'; // Green
ctx.lineWidth = lineWidth;
const halfW = layer.width / 2;
const halfH = layer.height / 2;
// Draw adaptive solid green line for transform frame
this.drawAdaptiveLine(ctx, -halfW, -halfH, halfW, -halfH, layer);
this.drawAdaptiveLine(ctx, halfW, -halfH, halfW, halfH, layer);
this.drawAdaptiveLine(ctx, halfW, halfH, -halfW, halfH, layer);
this.drawAdaptiveLine(ctx, -halfW, halfH, -halfW, -halfH, layer);
// Draw line to rotation handle
ctx.setLineDash([]);
ctx.beginPath();
ctx.moveTo(0, -halfH);
ctx.lineTo(0, -halfH - 20 / this.canvas.viewport.zoom);
ctx.stroke();
}
// Rysuj ramkę z adaptacyjnymi liniami (ciągłe/przerywane w zależności od przykrycia)
const halfW = layer.width / 2;
const halfH = layer.height / 2;
// Górna krawędź
this.drawAdaptiveLine(ctx, -halfW, -halfH, halfW, -halfH, layer);
// Prawa krawędź
this.drawAdaptiveLine(ctx, halfW, -halfH, halfW, halfH, layer);
// Dolna krawędź
this.drawAdaptiveLine(ctx, halfW, halfH, -halfW, halfH, layer);
// Lewa krawędź
this.drawAdaptiveLine(ctx, -halfW, halfH, -halfW, -halfH, layer);
// Rysuj linię do uchwytu rotacji (zawsze ciągła)
ctx.setLineDash([]);
ctx.beginPath();
ctx.moveTo(0, -layer.height / 2);
ctx.lineTo(0, -layer.height / 2 - 20 / this.canvas.viewport.zoom);
ctx.stroke();
// Rysuj uchwyty
// --- DRAW HANDLES (Unified Logic) ---
const handles = this.canvas.canvasLayers.getHandles(layer);
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#000000';
ctx.lineWidth = 1 / this.canvas.viewport.zoom;
for (const key in handles) {
// Skip rotation handle in crop mode
if (layer.cropMode && key === 'rot') continue;
const point = handles[key];
ctx.beginPath();
// The handle position is already in world space, we need it in the layer's rotated space
const localX = point.x - (layer.x + layer.width / 2);
const localY = point.y - (layer.y + layer.height / 2);
@@ -571,6 +599,7 @@ export class CanvasRenderer {
const rotatedX = localX * Math.cos(rad) - localY * Math.sin(rad);
const rotatedY = localX * Math.sin(rad) + localY * Math.cos(rad);
ctx.beginPath();
ctx.arc(rotatedX, rotatedY, handleRadius, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();

View File

@@ -326,6 +326,9 @@ If you see dark images or masks in the output, make sure node_id is set to ${cor
const preparedLayers = await Promise.all(this.canvas.layers.map(async (layer: Layer, index: number) => {
const newLayer: Omit<Layer, 'image'> & { imageId: string } = { ...layer, imageId: layer.imageId || '' };
delete (newLayer as any).image;
// Remove cache properties that cannot be serialized for the worker
delete (newLayer as any).blendedImageCache;
delete (newLayer as any).blendedImageDirty;
if (layer.image instanceof HTMLImageElement) {
if (layer.imageId) {

View File

@@ -33,6 +33,40 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
});
const imageCache = new ImageCache();
/**
* Helper function to update the icon of a switch component.
* @param knobIconEl The HTML element for the switch's knob icon.
* @param isChecked The current state of the switch (e.g., checkbox.checked).
* @param iconToolTrue The icon tool name for the 'true' state.
* @param iconToolFalse The icon tool name for the 'false' state.
* @param fallbackTrue The text fallback for the 'true' state.
* @param fallbackFalse The text fallback for the 'false' state.
*/
const updateSwitchIcon = (
knobIconEl: HTMLElement,
isChecked: boolean,
iconToolTrue: string,
iconToolFalse: string,
fallbackTrue: string,
fallbackFalse: string
) => {
if (!knobIconEl) return;
const iconTool = isChecked ? iconToolTrue : iconToolFalse;
const fallbackText = isChecked ? fallbackTrue : fallbackFalse;
const icon = iconLoader.getIcon(iconTool);
knobIconEl.innerHTML = ''; // Clear previous icon
if (icon instanceof HTMLImageElement) {
const clonedIcon = icon.cloneNode() as HTMLImageElement;
clonedIcon.style.width = '20px';
clonedIcon.style.height = '20px';
knobIconEl.appendChild(clonedIcon);
} else {
knobIconEl.textContent = fallbackText;
}
};
const helpTooltip = $el("div.painter-tooltip", {
id: `painter-help-tooltip-${node.id}`,
}) as HTMLDivElement;
@@ -184,29 +218,31 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
});
switchEl.addEventListener("mouseleave", hideTooltip);
// Dynamic icon and text update on toggle
// Dynamic icon update on toggle
const input = switchEl.querySelector('input[type="checkbox"]') as HTMLInputElement;
const knobIcon = switchEl.querySelector('.switch-knob .switch-icon') as HTMLElement;
const updateSwitchView = (isClipspace: boolean) => {
const iconTool = isClipspace ? LAYERFORGE_TOOLS.CLIPSPACE : LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD;
const icon = iconLoader.getIcon(iconTool);
if (icon instanceof HTMLImageElement) {
knobIcon.innerHTML = '';
const clonedIcon = icon.cloneNode() as HTMLImageElement;
clonedIcon.style.width = '20px';
clonedIcon.style.height = '20px';
knobIcon.appendChild(clonedIcon);
} else {
knobIcon.textContent = isClipspace ? "🗂️" : "📋";
}
};
input.addEventListener('change', () => updateSwitchView(input.checked));
input.addEventListener('change', () => {
updateSwitchIcon(
knobIcon,
input.checked,
LAYERFORGE_TOOLS.CLIPSPACE,
LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD,
"🗂️",
"📋"
);
});
// Initial state
iconLoader.preloadToolIcons().then(() => {
updateSwitchView(isClipspace);
updateSwitchIcon(
knobIcon,
isClipspace,
LAYERFORGE_TOOLS.CLIPSPACE,
LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD,
"🗂️",
"📋"
);
});
return switchEl;
@@ -326,6 +362,68 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
$el("div.painter-separator"),
$el("div.painter-button-group", {}, [
(() => {
const switchEl = $el("label.clipboard-switch.requires-selection", {
id: `crop-transform-switch-${node.id}`,
title: "Toggle between Transform and Crop mode for selected layer(s)"
}, [
$el("input", {
type: "checkbox",
checked: false,
onchange: (e: Event) => {
const isCropMode = (e.target as HTMLInputElement).checked;
const selectedLayers = canvas.canvasSelection.selectedLayers;
if (selectedLayers.length === 0) return;
selectedLayers.forEach((layer: Layer) => {
layer.cropMode = isCropMode;
if (isCropMode && !layer.cropBounds) {
layer.cropBounds = { x: 0, y: 0, width: layer.originalWidth, height: layer.originalHeight };
}
});
canvas.saveState();
canvas.render();
}
}),
$el("span.switch-track"),
$el("span.switch-labels", { style: { fontSize: "11px" } }, [
$el("span.text-clipspace", {}, ["Crop"]),
$el("span.text-system", {}, ["Transform"])
]),
$el("span.switch-knob", {}, [
$el("span.switch-icon", { id: `crop-transform-icon-${node.id}`})
])
]);
const input = switchEl.querySelector('input[type="checkbox"]') as HTMLInputElement;
const knobIcon = switchEl.querySelector('.switch-icon') as HTMLElement;
input.addEventListener('change', () => {
updateSwitchIcon(
knobIcon,
input.checked,
LAYERFORGE_TOOLS.CROP,
LAYERFORGE_TOOLS.TRANSFORM,
"✂️",
"✥"
);
});
// Initial state
iconLoader.preloadToolIcons().then(() => {
updateSwitchIcon(
knobIcon,
false, // Initial state is transform
LAYERFORGE_TOOLS.CROP,
LAYERFORGE_TOOLS.TRANSFORM,
"✂️",
"✥"
);
});
return switchEl;
})(),
$el("button.painter-button.requires-selection", {
textContent: "Rotate +90°",
title: "Rotate selected layer(s) by +90 degrees",
@@ -672,18 +770,50 @@ $el("label.clipboard-switch.mask-switch", {
const updateButtonStates = () => {
const selectionCount = canvas.canvasSelection.selectedLayers.length;
const hasSelection = selectionCount > 0;
controlPanel.querySelectorAll('.requires-selection').forEach((btn: any) => {
const button = btn as HTMLButtonElement;
if (button.textContent === 'Fuse') {
button.disabled = selectionCount < 2;
} else {
button.disabled = !hasSelection;
// --- Handle Standard Buttons ---
controlPanel.querySelectorAll('.requires-selection').forEach((el: any) => {
if (el.tagName === 'BUTTON') {
if (el.textContent === 'Fuse') {
el.disabled = selectionCount < 2;
} else {
el.disabled = !hasSelection;
}
}
});
const mattingBtn = controlPanel.querySelector('.matting-button') as HTMLButtonElement;
if (mattingBtn && !mattingBtn.classList.contains('loading')) {
mattingBtn.disabled = selectionCount !== 1;
}
// --- Handle Crop/Transform Switch ---
const switchEl = controlPanel.querySelector(`#crop-transform-switch-${node.id}`) as HTMLLabelElement;
if (switchEl) {
const input = switchEl.querySelector('input') as HTMLInputElement;
const knobIcon = switchEl.querySelector('.switch-icon') as HTMLElement;
const isDisabled = !hasSelection;
switchEl.classList.toggle('disabled', isDisabled);
input.disabled = isDisabled;
if (!isDisabled) {
const isCropMode = canvas.canvasSelection.selectedLayers[0].cropMode || false;
if (input.checked !== isCropMode) {
input.checked = isCropMode;
}
// Update icon view
updateSwitchIcon(
knobIcon,
isCropMode,
LAYERFORGE_TOOLS.CROP,
LAYERFORGE_TOOLS.TRANSFORM,
"✂️",
"✥"
);
}
}
};
canvas.canvasSelection.onSelectionChange = updateButtonStates;

View File

@@ -51,6 +51,32 @@
border-color: #3a76d6;
}
/* Crop mode button styling */
.painter-button#crop-mode-btn {
background-color: #444;
border-color: #555;
color: #fff;
transition: all 0.2s ease-in-out;
}
.painter-button#crop-mode-btn.primary {
background-color: #0080ff;
border-color: #0070e0;
color: #fff;
box-shadow: 0 0 8px rgba(0, 128, 255, 0.3);
}
.painter-button#crop-mode-btn.primary:hover {
background-color: #1090ff;
border-color: #0080ff;
box-shadow: 0 0 12px rgba(0, 128, 255, 0.4);
}
.painter-button#crop-mode-btn:hover {
background-color: #555;
border-color: #666;
}
.painter-button.success {
border-color: #4ae27a;
background-color: #444;
@@ -306,6 +332,20 @@
opacity: 0;
}
/* Disabled state for switch */
.clipboard-switch.disabled {
cursor: not-allowed;
opacity: 0.6;
background: #3a3a3a !important; /* Override gradient */
border-color: #4a4a4a !important;
transform: none !important;
box-shadow: none !important;
}
.clipboard-switch.disabled .switch-knob {
background-color: #4a4a4a !important;
}
.painter-separator {
width: 1px;

View File

@@ -21,6 +21,15 @@ export interface Layer {
flipH?: boolean;
flipV?: boolean;
blendArea?: number;
cropMode?: boolean; // czy warstwa jest w trybie crop
cropBounds?: { // granice przycinania
x: number; // offset od lewej krawędzi obrazu
y: number; // offset od górnej krawędzi obrazu
width: number; // szerokość widocznego obszaru
height: number; // wysokość widocznego obszaru
};
blendedImageCache?: HTMLCanvasElement; // Cache for the pre-rendered blendArea effect
blendedImageDirty?: boolean; // Flag to invalidate the cache
}
export interface ComfyNode {

View File

@@ -13,7 +13,7 @@ export const LAYERFORGE_TOOLS = {
DELETE: 'delete',
DUPLICATE: 'duplicate',
BLEND_MODE: 'blend_mode',
OPACITY: 'opacity',
OPACITY: 'opacity',
MASK: 'mask',
BRUSH: 'brush',
ERASER: 'eraser',
@@ -21,16 +21,22 @@ export const LAYERFORGE_TOOLS = {
SETTINGS: 'settings',
SYSTEM_CLIPBOARD: 'system_clipboard',
CLIPSPACE: 'clipspace',
CROP: 'crop',
TRANSFORM: 'transform',
} as const;
// SVG Icons for LayerForge tools
const SYSTEM_CLIPBOARD_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M19 2h-4.18C14.4.84 13.3 0 12 0S9.6.84 9.18 2H5c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm5 15H7v-2h10v2zm0-4H7v-2h10v2zm0-4H7V7h10v2z"/></svg>`;
const CLIPSPACE_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> <defs> <mask id="cutout"> <rect width="100%" height="100%" fill="white"/> <path d="M5.485 23.76c-.568 0-1.026-.207-1.325-.598-.307-.402-.387-.964-.22-1.54l.672-2.315a.605.605 0 00-.1-.536.622.622 0 00-.494-.243H2.085c-.568 0-1.026-.207-1.325-.598-.307-.403-.387-.964-.22-1.54l2.31-7.917.255-.87c.343-1.18 1.592-2.14 2.786-2.14h2.313c.276 0 .519-.18.595-.442l.764-2.633C9.906 1.208 11.155.249 12.35.249l4.945-.008h3.62c.568 0 1.027.206 1.325.597.307.402.387.964.22 1.54l-1.035 3.566c-.343 1.178-1.593 2.137-2.787 2.137l-4.956.01H11.37a.618.618 0 00-.594.441l-1.928 6.604a.605.605 0 00.1.537c.118.153.3.243.495.243l3.275-.006h3.61c.568 0 1.026.206 1.325.598.307.402.387.964.22 1.54l-1.036 3.565c-.342 1.179-1.592 2.138-2.786 2.138l-4.957.01h-3.61z" fill="black" transform="translate(4.8 4.8) scale(0.6)" /> </mask> </defs> <path d="M19 2h-4.18C14.4.84 13.3 0 12 0S9.6.84 9.18 2H5c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1z" fill="#ffffff" mask="url(#cutout)" /></svg>`;
const CROP_ICON_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M17 15h3V7c0-1.1-.9-2-2-2H10v3h7v7zM7 18V1H4v4H0v3h4v10c0 2 1 3 3 3h10v4h3v-4h4v-3H24z"/></svg>';
const TRANSFORM_ICON_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M11.3 17.096c.092-.044.34-.052 1.028-.044l.912.008.124.124c.184.184.184.408.004.584l-.128.132-.896.012c-.72.008-.924 0-1.036-.048-.18-.072-.284-.264-.256-.452.028-.168.092-.248.248-.316Zm-3.164 0c.096-.044.328-.052 1.036-.044l.916.008.116.132c.16.18.16.396 0 .576l-.116.132-.876.012c-.552.008-.928-.004-1.02-.032-.388-.112-.428-.62-.056-.784Zm-4.6-1.168.112-.096 1.42.004 1.424.004.116.116.116.116V17.48v1.408l-.116.116-.116.116H5.068h-1.42l-.112-.096-.112-.096L3.42 17.48V16.032l.112-.096ZM4.78 12.336c.104-.104.168-.136.284-.136s.18.032.284.136l.136.136v.964.964l-.116.128c-.1.112-.144.132-.304.132s-.204-.02-.304-.132L4.644 14.4l-.004-.964v-.964l.136-.136Zm8.868-.648c-.008-.024-.004-.048.008-.048s1.504.512 3.312 1.136c1.812.624 4.252 1.464 5.424 1.868 1.168.404 2.128.744 2.128.76 0 .012-.24.108-.528.212-.292.104-1.468.52-2.616.928l-2.08.74-.936 2.62c-.512 1.44-.944 2.616-.956 2.616-.016 0-.86-2.424-1.88-5.392-1.02-2.964-1.864-5.412-1.876-5.44ZM19.292 9.08c.216-.088.432-.02.548.168.076.124.08.188.072 1.06l-.012.928-.116.12c-.1.104-.148.124-.304.124s-.204-.02-.304-.124l-.116-.12-.012-.928c-.008-.872-.004-.936.072-1.06.044-.072.12-.148.172-.168Zm-14.516.096c.104-.104.168-.136.284-.136s.18.032.284.136l.136.136v.956c0 1.064-.004 1.088-.268 1.2-.18.072-.376.012-.492-.148-.076-.104-.08-.172-.08-1.06V9.312l.136-.136ZM19.192 6c.096-.088.168-.116.288-.116s.192.028.288.116l.132.116V7.1v.98l-.116.12c-.1.104-.148.124-.304.124s-.204-.02-.304-.124l-.116-.12V7.096 6.112l.132-.116ZM4.816 5.964c.048-.044.152-.072.256-.072.144 0 .196.02.292.124l.116.124v.98.968l-.116.116c-.092.092-.152.116-.284.116-.408 0-.44-.28-.44-1.22s.012-1.016.176-1.148Zm9.516-3.192.14-.136.968.004h.968l.112.116c.152.152.188.3.108.468-.124.252-.196.276-1.044.288-.42.008-.84.004-.936-.012-.24-.036-.38-.192-.436-.408-.02-.156-.008-.184.12-.312Zm-3.156-.268.136.136h.956c1.064 0 1.088.004 1.2.268.072.172.016.372-.136.492-.096.076-.16.08-1.06.08h-.96l-.136-.136c-.104-.104-.136-.168-.136-.284s.032-.18.136-.284Zm-3.16 0 .136.136h.96c.94 0 .964.004 1.068.088.2.176.196.508-.004.668-.1.08-.156.084-1.064.084h-.96l-.136-.136c-.188-.188-.188-.38 0-.568Zm10.04-1.14c.044-.02.712-.032 1.476-.028l1.396.008.096.112.096.112v1.424 1.5l-.116.116-.116.116L19.48 4.72H18.072l-.116-.116-.116-.116V3.072c0-1.524.004-1.544.216-1.632ZM3.62 1.456c.184-.08 2.74-.08 2.896 0 .196.104.204.164.204 1.604s-.008 1.5-.204 1.604c-.148.076-2.732.084-2.896.008-.212-.096-.22-.148-.22-1.608s.008-1.516.22-1.608Z"/></svg>';
const LAYERFORGE_TOOL_ICONS = {
[LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(SYSTEM_CLIPBOARD_ICON_SVG)}`,
[LAYERFORGE_TOOLS.CLIPSPACE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(CLIPSPACE_ICON_SVG)}`,
[LAYERFORGE_TOOLS.CROP]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(CROP_ICON_SVG)}`,
[LAYERFORGE_TOOLS.TRANSFORM]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(TRANSFORM_ICON_SVG)}`,
[LAYERFORGE_TOOLS.VISIBILITY]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>')}`,
[LAYERFORGE_TOOLS.MOVE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M13,20H11V8L5.5,13.5L4.08,12.08L12,4.16L19.92,12.08L18.5,13.5L13,8V20Z"/></svg>')}`,
@@ -72,7 +78,9 @@ const LAYERFORGE_TOOL_COLORS = {
[LAYERFORGE_TOOLS.BRUSH]: '#4285F4',
[LAYERFORGE_TOOLS.ERASER]: '#FBBC05',
[LAYERFORGE_TOOLS.SHAPE]: '#FF6D01',
[LAYERFORGE_TOOLS.SETTINGS]: '#F06292'
[LAYERFORGE_TOOLS.SETTINGS]: '#F06292',
[LAYERFORGE_TOOLS.CROP]: '#EA4335',
[LAYERFORGE_TOOLS.TRANSFORM]: '#34A853',
};
export interface IconCache {