mirror of
https://github.com/Azornes/Comfyui-LayerForge.git
synced 2026-03-22 05:02:11 -03:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b54ab28cb | ||
|
|
503ec126a5 | ||
|
|
3d6e3901d0 | ||
|
|
4df89a793e | ||
|
|
e42e08e35d | ||
|
|
7ed6f7ee93 |
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
125
js/CanvasView.js
125
js/CanvasView.js
@@ -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}`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user