Files
Comfyui-LayerForge/js/CanvasLayers.js
Dariusz L 3b1a69041c Add layer visibility toggle and icon support
Introduces a 'visible' property to layers and updates all relevant logic to support toggling layer visibility. Adds visibility toggle icons to the layers panel using a new IconLoader utility, with SVG and fallback canvas icons. Updates rendering, selection, and batch preview logic to respect layer visibility. Also improves blend mode menu UI and ensures new/pasted layers are always added on top with correct z-index.
2025-07-24 19:10:17 +02:00

907 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { saveImage } from "./db.js";
import { createModuleLogger } from "./utils/LoggerUtils.js";
import { generateUUID, generateUniqueFileName } from "./utils/CommonUtils.js";
import { withErrorHandling, createValidationError } from "./ErrorHandler.js";
// @ts-ignore
import { app } from "../../scripts/app.js";
// @ts-ignore
import { ComfyApp } from "../../scripts/app.js";
import { ClipboardManager } from "./utils/ClipboardManager.js";
const log = createModuleLogger('CanvasLayers');
export class CanvasLayers {
constructor(canvas) {
this.addLayerWithImage = withErrorHandling(async (image, layerProps = {}, addMode = 'default', targetArea = null) => {
if (!image) {
throw createValidationError("Image is required for layer creation");
}
log.debug("Adding layer with image:", image, "with mode:", addMode, "targetArea:", targetArea);
const imageId = generateUUID();
await saveImage(imageId, image.src);
this.canvas.imageCache.set(imageId, image.src);
let finalWidth = image.width;
let finalHeight = image.height;
let finalX, finalY;
// Use the targetArea if provided, otherwise default to the current canvas dimensions
const area = targetArea || { width: this.canvas.width, height: this.canvas.height, x: 0, y: 0 };
if (addMode === 'fit') {
const scale = Math.min(area.width / image.width, area.height / image.height);
finalWidth = image.width * scale;
finalHeight = image.height * scale;
finalX = area.x + (area.width - finalWidth) / 2;
finalY = area.y + (area.height - finalHeight) / 2;
}
else if (addMode === 'mouse') {
finalX = this.canvas.lastMousePosition.x - finalWidth / 2;
finalY = this.canvas.lastMousePosition.y - finalHeight / 2;
}
else {
finalX = area.x + (area.width - finalWidth) / 2;
finalY = area.y + (area.height - finalHeight) / 2;
}
// Find the highest zIndex among existing layers
const maxZIndex = this.canvas.layers.length > 0
? Math.max(...this.canvas.layers.map(l => l.zIndex))
: -1;
const layer = {
id: generateUUID(),
image: image,
imageId: imageId,
name: 'Layer',
x: finalX,
y: finalY,
width: finalWidth,
height: finalHeight,
originalWidth: image.width,
originalHeight: image.height,
rotation: 0,
zIndex: maxZIndex + 1, // Always add new layer on top
blendMode: 'normal',
opacity: 1,
visible: true,
...layerProps
};
if (layer.mask) {
const tempCanvas = document.createElement('canvas');
const tempCtx = tempCanvas.getContext('2d');
if (tempCtx) {
tempCanvas.width = layer.width;
tempCanvas.height = layer.height;
tempCtx.drawImage(layer.image, 0, 0, layer.width, layer.height);
const maskCanvas = document.createElement('canvas');
const maskCtx = maskCanvas.getContext('2d');
if (maskCtx) {
maskCanvas.width = layer.width;
maskCanvas.height = layer.height;
const maskImageData = maskCtx.createImageData(layer.width, layer.height);
for (let i = 0; i < layer.mask.length; i++) {
maskImageData.data[i * 4] = 255;
maskImageData.data[i * 4 + 1] = 255;
maskImageData.data[i * 4 + 2] = 255;
maskImageData.data[i * 4 + 3] = layer.mask[i] * 255;
}
maskCtx.putImageData(maskImageData, 0, 0);
tempCtx.globalCompositeOperation = 'destination-in';
tempCtx.drawImage(maskCanvas, 0, 0);
const newImage = new Image();
newImage.src = tempCanvas.toDataURL();
layer.image = newImage;
}
}
}
this.canvas.layers.push(layer);
this.canvas.updateSelection([layer]);
this.canvas.render();
this.canvas.saveState();
if (this.canvas.canvasLayersPanel) {
this.canvas.canvasLayersPanel.onLayersChanged();
}
log.info("Layer added successfully");
return layer;
}, 'CanvasLayers.addLayerWithImage');
this.canvas = canvas;
this.clipboardManager = new ClipboardManager(canvas);
this.blendModes = [
{ name: 'normal', label: 'Normal' },
{ name: 'multiply', label: 'Multiply' },
{ name: 'screen', label: 'Screen' },
{ name: 'overlay', label: 'Overlay' },
{ name: 'darken', label: 'Darken' },
{ name: 'lighten', label: 'Lighten' },
{ name: 'color-dodge', label: 'Color Dodge' },
{ name: 'color-burn', label: 'Color Burn' },
{ name: 'hard-light', label: 'Hard Light' },
{ name: 'soft-light', label: 'Soft Light' },
{ name: 'difference', label: 'Difference' },
{ name: 'exclusion', label: 'Exclusion' }
];
this.selectedBlendMode = null;
this.blendOpacity = 100;
this.isAdjustingOpacity = false;
this.internalClipboard = [];
this.clipboardPreference = 'system';
}
async copySelectedLayers() {
if (this.canvas.canvasSelection.selectedLayers.length === 0)
return;
this.internalClipboard = this.canvas.canvasSelection.selectedLayers.map((layer) => ({ ...layer }));
log.info(`Copied ${this.internalClipboard.length} layer(s) to internal clipboard.`);
const blob = await this.getFlattenedSelectionAsBlob();
if (!blob) {
log.warn("Failed to create flattened selection blob");
return;
}
if (this.clipboardPreference === 'clipspace') {
try {
const dataURL = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
const img = new Image();
img.onload = () => {
if (!this.canvas.node.imgs) {
this.canvas.node.imgs = [];
}
this.canvas.node.imgs[0] = img;
if (ComfyApp.copyToClipspace) {
ComfyApp.copyToClipspace(this.canvas.node);
log.info("Flattened selection copied to ComfyUI Clipspace.");
}
else {
log.warn("ComfyUI copyToClipspace not available");
}
};
img.src = dataURL;
}
catch (error) {
log.error("Failed to copy image to ComfyUI Clipspace:", error);
try {
const item = new ClipboardItem({ 'image/png': blob });
await navigator.clipboard.write([item]);
log.info("Fallback: Flattened selection copied to system clipboard.");
}
catch (fallbackError) {
log.error("Failed to copy to system clipboard as fallback:", fallbackError);
}
}
}
else {
try {
const item = new ClipboardItem({ 'image/png': blob });
await navigator.clipboard.write([item]);
log.info("Flattened selection copied to system clipboard.");
}
catch (error) {
log.error("Failed to copy image to system clipboard:", error);
}
}
}
pasteLayers() {
if (this.internalClipboard.length === 0)
return;
this.canvas.saveState();
const newLayers = [];
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
this.internalClipboard.forEach((layer) => {
minX = Math.min(minX, layer.x);
minY = Math.min(minY, layer.y);
maxX = Math.max(maxX, layer.x + layer.width);
maxY = Math.max(maxY, layer.y + layer.height);
});
const centerX = (minX + maxX) / 2;
const centerY = (minY + maxY) / 2;
const { x: mouseX, y: mouseY } = this.canvas.lastMousePosition;
const offsetX = mouseX - centerX;
const offsetY = mouseY - centerY;
// Find the highest zIndex among existing layers
const maxZIndex = this.canvas.layers.length > 0
? Math.max(...this.canvas.layers.map(l => l.zIndex))
: -1;
this.internalClipboard.forEach((clipboardLayer, index) => {
const newLayer = {
...clipboardLayer,
x: clipboardLayer.x + offsetX,
y: clipboardLayer.y + offsetY,
zIndex: maxZIndex + 1 + index // Ensure pasted layers maintain their relative order
};
this.canvas.layers.push(newLayer);
newLayers.push(newLayer);
});
this.canvas.updateSelection(newLayers);
this.canvas.render();
if (this.canvas.canvasLayersPanel) {
this.canvas.canvasLayersPanel.onLayersChanged();
}
log.info(`Pasted ${newLayers.length} layer(s) at mouse position (${mouseX}, ${mouseY}).`);
}
async handlePaste(addMode = 'mouse') {
try {
log.info(`Paste operation started with preference: ${this.clipboardPreference}`);
await this.clipboardManager.handlePaste(addMode, this.clipboardPreference);
}
catch (err) {
log.error("Paste operation failed:", err);
}
}
async addLayer(image) {
return this.addLayerWithImage(image);
}
moveLayers(layersToMove, options = {}) {
if (!layersToMove || layersToMove.length === 0)
return;
let finalLayers;
if (options.direction) {
const allLayers = [...this.canvas.layers];
const selectedIndices = new Set(layersToMove.map((l) => allLayers.indexOf(l)));
if (options.direction === 'up') {
const sorted = Array.from(selectedIndices).sort((a, b) => b - a);
sorted.forEach((index) => {
const targetIndex = index + 1;
if (targetIndex < allLayers.length && !selectedIndices.has(targetIndex)) {
[allLayers[index], allLayers[targetIndex]] = [allLayers[targetIndex], allLayers[index]];
}
});
}
else if (options.direction === 'down') {
const sorted = Array.from(selectedIndices).sort((a, b) => a - b);
sorted.forEach((index) => {
const targetIndex = index - 1;
if (targetIndex >= 0 && !selectedIndices.has(targetIndex)) {
[allLayers[index], allLayers[targetIndex]] = [allLayers[targetIndex], allLayers[index]];
}
});
}
finalLayers = allLayers;
}
else if (options.toIndex !== undefined) {
const displayedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex);
const reorderedFinal = [];
let inserted = false;
for (let i = 0; i < displayedLayers.length; i++) {
if (i === options.toIndex) {
reorderedFinal.push(...layersToMove);
inserted = true;
}
const currentLayer = displayedLayers[i];
if (!layersToMove.includes(currentLayer)) {
reorderedFinal.push(currentLayer);
}
}
if (!inserted) {
reorderedFinal.push(...layersToMove);
}
finalLayers = reorderedFinal;
}
else {
log.warn("Invalid options for moveLayers", options);
return;
}
const totalLayers = finalLayers.length;
finalLayers.forEach((layer, index) => {
const zIndex = (options.toIndex !== undefined) ? (totalLayers - 1 - index) : index;
layer.zIndex = zIndex;
});
this.canvas.layers = finalLayers;
this.canvas.layers.sort((a, b) => a.zIndex - b.zIndex);
if (this.canvas.canvasLayersPanel) {
this.canvas.canvasLayersPanel.onLayersChanged();
}
this.canvas.render();
this.canvas.requestSaveState();
log.info(`Moved ${layersToMove.length} layer(s).`);
}
moveLayerUp() {
if (this.canvas.canvasSelection.selectedLayers.length === 0)
return;
this.moveLayers(this.canvas.canvasSelection.selectedLayers, { direction: 'up' });
}
moveLayerDown() {
if (this.canvas.canvasSelection.selectedLayers.length === 0)
return;
this.moveLayers(this.canvas.canvasSelection.selectedLayers, { direction: 'down' });
}
resizeLayer(scale) {
if (this.canvas.canvasSelection.selectedLayers.length === 0)
return;
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
layer.width *= scale;
layer.height *= scale;
});
this.canvas.render();
this.canvas.requestSaveState();
}
rotateLayer(angle) {
if (this.canvas.canvasSelection.selectedLayers.length === 0)
return;
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
layer.rotation += angle;
});
this.canvas.render();
this.canvas.requestSaveState();
}
getLayerAtPosition(worldX, worldY) {
for (let i = this.canvas.layers.length - 1; i >= 0; i--) {
const layer = this.canvas.layers[i];
// Skip invisible layers
if (!layer.visible)
continue;
const centerX = layer.x + layer.width / 2;
const centerY = layer.y + layer.height / 2;
const dx = worldX - centerX;
const dy = worldY - centerY;
const rad = -layer.rotation * Math.PI / 180;
const rotatedX = dx * Math.cos(rad) - dy * Math.sin(rad);
const rotatedY = dx * Math.sin(rad) + dy * Math.cos(rad);
if (Math.abs(rotatedX) <= layer.width / 2 && Math.abs(rotatedY) <= layer.height / 2) {
return {
layer: layer,
localX: rotatedX + layer.width / 2,
localY: rotatedY + layer.height / 2
};
}
}
return null;
}
_drawLayer(ctx, layer, options = {}) {
if (!layer.image)
return;
const { offsetX = 0, offsetY = 0 } = options;
ctx.save();
ctx.globalCompositeOperation = layer.blendMode || 'normal';
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
const centerX = layer.x + layer.width / 2 - offsetX;
const centerY = layer.y + layer.height / 2 - offsetY;
ctx.translate(centerX, centerY);
ctx.rotate(layer.rotation * Math.PI / 180);
const scaleH = layer.flipH ? -1 : 1;
const scaleV = layer.flipV ? -1 : 1;
if (layer.flipH || layer.flipV) {
ctx.scale(scaleH, scaleV);
}
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
ctx.restore();
}
_drawLayers(ctx, layers, options = {}) {
const sortedLayers = [...layers].sort((a, b) => a.zIndex - b.zIndex);
sortedLayers.forEach(layer => {
if (layer.visible) {
this._drawLayer(ctx, layer, options);
}
});
}
drawLayersToContext(ctx, layers, options = {}) {
this._drawLayers(ctx, layers, options);
}
async mirrorHorizontal() {
if (this.canvas.canvasSelection.selectedLayers.length === 0)
return;
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
layer.flipH = !layer.flipH;
});
this.canvas.render();
this.canvas.requestSaveState();
}
async mirrorVertical() {
if (this.canvas.canvasSelection.selectedLayers.length === 0)
return;
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
layer.flipV = !layer.flipV;
});
this.canvas.render();
this.canvas.requestSaveState();
}
async getLayerImageData(layer) {
try {
const tempCanvas = document.createElement('canvas');
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
if (!tempCtx)
throw new Error("Could not create canvas context");
tempCanvas.width = layer.width;
tempCanvas.height = layer.height;
// We need to draw the layer relative to the new canvas, so we "move" it to 0,0
// by creating a temporary layer object for drawing.
const layerToDraw = {
...layer,
x: 0,
y: 0,
};
this._drawLayer(tempCtx, layerToDraw);
const dataUrl = tempCanvas.toDataURL('image/png');
if (!dataUrl.startsWith('data:image/png;base64,')) {
throw new Error("Invalid image data format");
}
return dataUrl;
}
catch (error) {
log.error("Error getting layer image data:", error);
throw error;
}
}
updateOutputAreaSize(width, height, saveHistory = true) {
if (saveHistory) {
this.canvas.saveState();
}
this.canvas.width = width;
this.canvas.height = height;
this.canvas.maskTool.resize(width, height);
this.canvas.canvas.width = width;
this.canvas.canvas.height = height;
this.canvas.render();
if (saveHistory) {
this.canvas.canvasState.saveStateToDB();
}
}
getHandles(layer) {
const centerX = layer.x + layer.width / 2;
const centerY = 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;
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 },
'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)
};
}
return worldHandles;
}
getHandleAtPosition(worldX, worldY) {
if (this.canvas.canvasSelection.selectedLayers.length === 0)
return null;
const handleRadius = 8 / this.canvas.viewport.zoom;
for (let i = this.canvas.canvasSelection.selectedLayers.length - 1; i >= 0; i--) {
const layer = this.canvas.canvasSelection.selectedLayers[i];
const handles = this.getHandles(layer);
for (const key in handles) {
const handlePos = handles[key];
const dx = worldX - handlePos.x;
const dy = worldY - handlePos.y;
if (dx * dx + dy * dy <= handleRadius * handleRadius) {
return { layer: layer, handle: key };
}
}
}
return null;
}
showBlendModeMenu(x, y) {
this.closeBlendModeMenu();
const menu = document.createElement('div');
menu.id = 'blend-mode-menu';
menu.style.cssText = `
position: fixed;
left: ${x}px;
top: ${y}px;
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 4px;
z-index: 10000;
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
min-width: 200px;
`;
const titleBar = document.createElement('div');
titleBar.style.cssText = `
background: #3a3a3a;
color: white;
padding: 8px 10px;
cursor: move;
user-select: none;
border-radius: 3px 3px 0 0;
font-size: 12px;
font-weight: bold;
border-bottom: 1px solid #4a4a4a;
display: flex;
justify-content: space-between;
align-items: center;
`;
const titleText = document.createElement('span');
titleText.textContent = 'Blend Mode';
titleText.style.cssText = `
flex: 1;
cursor: move;
`;
const closeButton = document.createElement('button');
closeButton.textContent = '×';
closeButton.style.cssText = `
background: none;
border: none;
color: white;
font-size: 18px;
cursor: pointer;
padding: 0;
margin: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 3px;
transition: background-color 0.2s;
`;
closeButton.onmouseover = () => {
closeButton.style.backgroundColor = '#4a4a4a';
};
closeButton.onmouseout = () => {
closeButton.style.backgroundColor = 'transparent';
};
closeButton.onclick = (e) => {
e.stopPropagation();
this.closeBlendModeMenu();
};
titleBar.appendChild(titleText);
titleBar.appendChild(closeButton);
const content = document.createElement('div');
content.style.cssText = `padding: 5px;`;
menu.appendChild(titleBar);
menu.appendChild(content);
let isDragging = false;
let dragOffset = { x: 0, y: 0 };
const handleMouseMove = (e) => {
if (isDragging) {
const newX = e.clientX - dragOffset.x;
const newY = e.clientY - dragOffset.y;
const maxX = window.innerWidth - menu.offsetWidth;
const maxY = window.innerHeight - menu.offsetHeight;
menu.style.left = Math.max(0, Math.min(newX, maxX)) + 'px';
menu.style.top = Math.max(0, Math.min(newY, maxY)) + 'px';
}
};
const handleMouseUp = () => {
if (isDragging) {
isDragging = false;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
}
};
titleBar.addEventListener('mousedown', (e) => {
isDragging = true;
dragOffset.x = e.clientX - parseInt(menu.style.left, 10);
dragOffset.y = e.clientY - parseInt(menu.style.top, 10);
e.preventDefault();
e.stopPropagation();
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
});
this.blendModes.forEach((mode) => {
const container = document.createElement('div');
container.className = 'blend-mode-container';
container.style.cssText = `margin-bottom: 5px;`;
const option = document.createElement('div');
option.style.cssText = `padding: 5px 10px; color: white; cursor: pointer; transition: background-color 0.2s;`;
option.textContent = `${mode.label} (${mode.name})`;
const slider = document.createElement('input');
slider.type = 'range';
slider.min = '0';
slider.max = '100';
const selectedLayer = this.canvas.canvasSelection.selectedLayers[0];
slider.value = selectedLayer ? String(Math.round(selectedLayer.opacity * 100)) : '100';
slider.style.cssText = `width: 100%; margin: 5px 0; display: none;`;
if (selectedLayer && selectedLayer.blendMode === mode.name) {
slider.style.display = 'block';
option.style.backgroundColor = '#3a3a3a';
}
option.onclick = () => {
content.querySelectorAll('input[type="range"]').forEach(s => s.style.display = 'none');
content.querySelectorAll('.blend-mode-container div').forEach(d => d.style.backgroundColor = '');
slider.style.display = 'block';
option.style.backgroundColor = '#3a3a3a';
if (selectedLayer) {
selectedLayer.blendMode = mode.name;
this.canvas.render();
}
};
slider.addEventListener('input', () => {
if (selectedLayer) {
selectedLayer.opacity = parseInt(slider.value, 10) / 100;
this.canvas.render();
}
});
slider.addEventListener('change', async () => {
if (selectedLayer) {
selectedLayer.opacity = parseInt(slider.value, 10) / 100;
this.canvas.render();
const saveWithFallback = async (fileName) => {
try {
const uniqueFileName = generateUniqueFileName(fileName, this.canvas.node.id);
return await this.canvas.canvasIO.saveToServer(uniqueFileName);
}
catch (error) {
console.warn(`Failed to save with unique name, falling back to original: ${fileName}`, error);
return await this.canvas.canvasIO.saveToServer(fileName);
}
};
if (this.canvas.widget) {
await saveWithFallback(this.canvas.widget.value);
if (this.canvas.node) {
app.graph.runStep();
}
}
}
});
container.appendChild(option);
container.appendChild(slider);
content.appendChild(container);
});
const container = this.canvas.canvas.parentElement || document.body;
container.appendChild(menu);
const closeMenu = (e) => {
if (e.target instanceof Node && !menu.contains(e.target) && !isDragging) {
this.closeBlendModeMenu();
document.removeEventListener('mousedown', closeMenu);
}
};
setTimeout(() => document.addEventListener('mousedown', closeMenu), 0);
}
closeBlendModeMenu() {
const menu = document.getElementById('blend-mode-menu');
if (menu && menu.parentNode) {
menu.parentNode.removeChild(menu);
}
}
showOpacitySlider(mode) {
const slider = document.createElement('input');
slider.type = 'range';
slider.min = '0';
slider.max = '100';
slider.value = String(this.blendOpacity);
slider.className = 'blend-opacity-slider';
slider.addEventListener('input', (e) => {
this.blendOpacity = parseInt(e.target.value, 10);
});
const modeElement = document.querySelector(`[data-blend-mode="${mode}"]`);
if (modeElement) {
modeElement.appendChild(slider);
}
}
async getFlattenedCanvasWithMaskAsBlob() {
return new Promise((resolve, reject) => {
const tempCanvas = document.createElement('canvas');
tempCanvas.width = this.canvas.width;
tempCanvas.height = this.canvas.height;
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
if (!tempCtx) {
reject(new Error("Could not create canvas context"));
return;
}
this._drawLayers(tempCtx, this.canvas.layers);
const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
const data = imageData.data;
const toolMaskCanvas = this.canvas.maskTool.getMask();
if (toolMaskCanvas) {
const tempMaskCanvas = document.createElement('canvas');
tempMaskCanvas.width = this.canvas.width;
tempMaskCanvas.height = this.canvas.height;
const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true });
if (!tempMaskCtx) {
reject(new Error("Could not create mask canvas context"));
return;
}
tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height);
const maskX = this.canvas.maskTool.x;
const maskY = this.canvas.maskTool.y;
const sourceX = Math.max(0, -maskX);
const sourceY = Math.max(0, -maskY);
const destX = Math.max(0, maskX);
const destY = Math.max(0, maskY);
const copyWidth = Math.min(toolMaskCanvas.width - sourceX, this.canvas.width - destX);
const copyHeight = Math.min(toolMaskCanvas.height - sourceY, this.canvas.height - destY);
if (copyWidth > 0 && copyHeight > 0) {
tempMaskCtx.drawImage(toolMaskCanvas, sourceX, sourceY, copyWidth, copyHeight, destX, destY, copyWidth, copyHeight);
}
const tempMaskData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
for (let i = 0; i < tempMaskData.data.length; i += 4) {
const alpha = tempMaskData.data[i + 3];
tempMaskData.data[i] = tempMaskData.data[i + 1] = tempMaskData.data[i + 2] = 255;
tempMaskData.data[i + 3] = alpha;
}
tempMaskCtx.putImageData(tempMaskData, 0, 0);
const maskImageData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
const maskData = maskImageData.data;
for (let i = 0; i < data.length; i += 4) {
const originalAlpha = data[i + 3];
const maskAlpha = maskData[i + 3] / 255;
const invertedMaskAlpha = 1 - maskAlpha;
data[i + 3] = originalAlpha * invertedMaskAlpha;
}
tempCtx.putImageData(imageData, 0, 0);
}
tempCanvas.toBlob((blob) => {
if (blob) {
resolve(blob);
}
else {
resolve(null);
}
}, 'image/png');
});
}
async getFlattenedCanvasAsBlob() {
return new Promise((resolve, reject) => {
const tempCanvas = document.createElement('canvas');
tempCanvas.width = this.canvas.width;
tempCanvas.height = this.canvas.height;
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
if (!tempCtx) {
reject(new Error("Could not create canvas context"));
return;
}
this._drawLayers(tempCtx, this.canvas.layers);
tempCanvas.toBlob((blob) => {
if (blob) {
resolve(blob);
}
else {
resolve(null);
}
}, 'image/png');
});
}
async getFlattenedCanvasForMaskEditor() {
return this.getFlattenedCanvasWithMaskAsBlob();
}
async getFlattenedSelectionAsBlob() {
if (this.canvas.canvasSelection.selectedLayers.length === 0) {
return null;
}
return new Promise((resolve, reject) => {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
const centerX = layer.x + layer.width / 2;
const centerY = 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;
const corners = [
{ x: -halfW, y: -halfH },
{ x: halfW, y: -halfH },
{ x: halfW, y: halfH },
{ x: -halfW, y: halfH }
];
corners.forEach(p => {
const worldX = centerX + (p.x * cos - p.y * sin);
const worldY = centerY + (p.x * sin + p.y * cos);
minX = Math.min(minX, worldX);
minY = Math.min(minY, worldY);
maxX = Math.max(maxX, worldX);
maxY = Math.max(maxY, worldY);
});
});
const newWidth = Math.ceil(maxX - minX);
const newHeight = Math.ceil(maxY - minY);
if (newWidth <= 0 || newHeight <= 0) {
resolve(null);
return;
}
const tempCanvas = document.createElement('canvas');
tempCanvas.width = newWidth;
tempCanvas.height = newHeight;
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
if (!tempCtx) {
reject(new Error("Could not create canvas context"));
return;
}
tempCtx.translate(-minX, -minY);
this._drawLayers(tempCtx, this.canvas.canvasSelection.selectedLayers);
tempCanvas.toBlob((blob) => {
resolve(blob);
}, 'image/png');
});
}
async fuseLayers() {
if (this.canvas.canvasSelection.selectedLayers.length < 2) {
alert("Please select at least 2 layers to fuse.");
return;
}
log.info(`Fusing ${this.canvas.canvasSelection.selectedLayers.length} selected layers`);
try {
this.canvas.saveState();
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
const centerX = layer.x + layer.width / 2;
const centerY = 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;
const corners = [
{ x: -halfW, y: -halfH },
{ x: halfW, y: -halfH },
{ x: halfW, y: halfH },
{ x: -halfW, y: halfH }
];
corners.forEach(p => {
const worldX = centerX + (p.x * cos - p.y * sin);
const worldY = centerY + (p.x * sin + p.y * cos);
minX = Math.min(minX, worldX);
minY = Math.min(minY, worldY);
maxX = Math.max(maxX, worldX);
maxY = Math.max(maxY, worldY);
});
});
const fusedWidth = Math.ceil(maxX - minX);
const fusedHeight = Math.ceil(maxY - minY);
if (fusedWidth <= 0 || fusedHeight <= 0) {
log.warn("Calculated fused layer dimensions are invalid");
alert("Cannot fuse layers: invalid dimensions calculated.");
return;
}
const tempCanvas = document.createElement('canvas');
tempCanvas.width = fusedWidth;
tempCanvas.height = fusedHeight;
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
if (!tempCtx)
throw new Error("Could not create canvas context");
tempCtx.translate(-minX, -minY);
this._drawLayers(tempCtx, this.canvas.canvasSelection.selectedLayers);
const fusedImage = new Image();
fusedImage.src = tempCanvas.toDataURL();
await new Promise((resolve, reject) => {
fusedImage.onload = resolve;
fusedImage.onerror = reject;
});
const minZIndex = Math.min(...this.canvas.canvasSelection.selectedLayers.map((layer) => layer.zIndex));
const imageId = generateUUID();
await saveImage(imageId, fusedImage.src);
this.canvas.imageCache.set(imageId, fusedImage.src);
const fusedLayer = {
id: generateUUID(),
image: fusedImage,
imageId: imageId,
name: 'Fused Layer',
x: minX,
y: minY,
width: fusedWidth,
height: fusedHeight,
originalWidth: fusedWidth,
originalHeight: fusedHeight,
rotation: 0,
zIndex: minZIndex,
blendMode: 'normal',
opacity: 1,
visible: true
};
this.canvas.layers = this.canvas.layers.filter((layer) => !this.canvas.canvasSelection.selectedLayers.includes(layer));
this.canvas.layers.push(fusedLayer);
this.canvas.layers.sort((a, b) => a.zIndex - b.zIndex);
this.canvas.layers.forEach((layer, index) => {
layer.zIndex = index;
});
this.canvas.updateSelection([fusedLayer]);
this.canvas.render();
this.canvas.saveState();
if (this.canvas.canvasLayersPanel) {
this.canvas.canvasLayersPanel.onLayersChanged();
}
log.info("Layers fused successfully", {
originalLayerCount: this.canvas.canvasSelection.selectedLayers.length,
fusedDimensions: { width: fusedWidth, height: fusedHeight },
fusedPosition: { x: minX, y: minY }
});
}
catch (error) {
log.error("Error during layer fusion:", error);
alert(`Error fusing layers: ${error.message}`);
}
}
}