import {saveImage, removeImage} 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"; import type { Canvas } from './Canvas'; import type { Layer, Point, AddMode, ClipboardPreference } from './types'; const log = createModuleLogger('CanvasLayers'); interface BlendMode { name: string; label: string; } export class CanvasLayers { private canvas: Canvas; public clipboardManager: ClipboardManager; private blendModes: BlendMode[]; private selectedBlendMode: string | null; private blendOpacity: number; private isAdjustingOpacity: boolean; public internalClipboard: Layer[]; public clipboardPreference: ClipboardPreference; constructor(canvas: Canvas) { this.canvas = canvas; this.clipboardManager = new ClipboardManager(canvas as any); 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(): Promise { if (this.canvas.canvasSelection.selectedLayers.length === 0) return; this.internalClipboard = this.canvas.canvasSelection.selectedLayers.map((layer: 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 as string); 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(): void { if (this.internalClipboard.length === 0) return; this.canvas.saveState(); const newLayers: Layer[] = []; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; this.internalClipboard.forEach((layer: 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; this.internalClipboard.forEach((clipboardLayer: Layer) => { const newLayer: Layer = { ...clipboardLayer, x: clipboardLayer.x + offsetX, y: clipboardLayer.y + offsetY, zIndex: this.canvas.layers.length }; 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: AddMode = 'mouse'): Promise { 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); } } addLayerWithImage = withErrorHandling(async (image: HTMLImageElement, layerProps: Partial = {}, addMode: AddMode = 'default', targetArea: { x: number, y: number, width: number, height: number } | null = null): Promise => { 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; } const layer: 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: this.canvas.layers.length, blendMode: 'normal', opacity: 1, ...layerProps }; 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'); async addLayer(image: HTMLImageElement): Promise { return this.addLayerWithImage(image); } moveLayers(layersToMove: Layer[], options: { direction?: 'up' | 'down', toIndex?: number } = {}): void { if (!layersToMove || layersToMove.length === 0) return; let finalLayers: Layer[]; if (options.direction) { const allLayers = [...this.canvas.layers]; const selectedIndices = new Set(layersToMove.map((l: Layer) => allLayers.indexOf(l))); if (options.direction === 'up') { const sorted = Array.from(selectedIndices).sort((a, b) => b - a); sorted.forEach((index: number) => { 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: number) => { 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: Layer[] = []; 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: Layer, b: Layer) => 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(): void { if (this.canvas.canvasSelection.selectedLayers.length === 0) return; this.moveLayers(this.canvas.canvasSelection.selectedLayers, { direction: 'up' }); } moveLayerDown(): void { if (this.canvas.canvasSelection.selectedLayers.length === 0) return; this.moveLayers(this.canvas.canvasSelection.selectedLayers, { direction: 'down' }); } resizeLayer(scale: number): void { if (this.canvas.canvasSelection.selectedLayers.length === 0) return; this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => { layer.width *= scale; layer.height *= scale; }); this.canvas.render(); this.canvas.requestSaveState(); } rotateLayer(angle: number): void { if (this.canvas.canvasSelection.selectedLayers.length === 0) return; this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => { layer.rotation += angle; }); this.canvas.render(); this.canvas.requestSaveState(); } getLayerAtPosition(worldX: number, worldY: number): { layer: Layer, localX: number, localY: number } | null { for (let i = this.canvas.layers.length - 1; i >= 0; i--) { const layer = this.canvas.layers[i]; 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; } private _drawLayer(ctx: CanvasRenderingContext2D, layer: Layer, options: { offsetX?: number, offsetY?: number } = {}): void { if (!layer.image) return; const { offsetX = 0, offsetY = 0 } = options; ctx.save(); ctx.globalCompositeOperation = layer.blendMode as any || '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(); } private _drawLayers(ctx: CanvasRenderingContext2D, layers: Layer[], options: { offsetX?: number, offsetY?: number } = {}): void { const sortedLayers = [...layers].sort((a: Layer, b: Layer) => a.zIndex - b.zIndex); sortedLayers.forEach(layer => this._drawLayer(ctx, layer, options)); } public drawLayersToContext(ctx: CanvasRenderingContext2D, layers: Layer[], options: { offsetX?: number, offsetY?: number } = {}): void { this._drawLayers(ctx, layers, options); } async mirrorHorizontal(): Promise { if (this.canvas.canvasSelection.selectedLayers.length === 0) return; this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => { layer.flipH = !layer.flipH; }); this.canvas.render(); this.canvas.requestSaveState(); } async mirrorVertical(): Promise { if (this.canvas.canvasSelection.selectedLayers.length === 0) return; this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => { layer.flipV = !layer.flipV; }); this.canvas.render(); this.canvas.requestSaveState(); } async getLayerImageData(layer: Layer): Promise { 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: number, height: number, saveHistory = true): void { 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: Layer): Record { 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: Record = { '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: Record = {}; 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: number, worldY: number): { layer: Layer, handle: string } | null { 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: number, y: number): void { 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; `; titleBar.textContent = 'Blend Mode'; 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: MouseEvent) => { 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: MouseEvent) => { 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: BlendMode) => { 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: string) => { 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: MouseEvent) => { if (e.target instanceof Node && !menu.contains(e.target) && !isDragging) { this.closeBlendModeMenu(); document.removeEventListener('mousedown', closeMenu); } }; setTimeout(() => document.addEventListener('mousedown', closeMenu), 0); } closeBlendModeMenu(): void { const menu = document.getElementById('blend-mode-menu'); if (menu && menu.parentNode) { menu.parentNode.removeChild(menu); } } showOpacitySlider(mode: string): void { 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 as HTMLInputElement).value, 10); }); const modeElement = document.querySelector(`[data-blend-mode="${mode}"]`); if (modeElement) { modeElement.appendChild(slider); } } async getFlattenedCanvasWithMaskAsBlob(): Promise { 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(): Promise { 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(): Promise { return this.getFlattenedCanvasWithMaskAsBlob(); } async getFlattenedSelectionAsBlob(): Promise { 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: 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(): Promise { 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: 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) => layer.zIndex)); const imageId = generateUUID(); await saveImage(imageId, fusedImage.src); this.canvas.imageCache.set(imageId, fusedImage.src); const fusedLayer: Layer = { 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 }; this.canvas.layers = this.canvas.layers.filter((layer: Layer) => !this.canvas.canvasSelection.selectedLayers.includes(layer)); this.canvas.layers.push(fusedLayer); this.canvas.layers.sort((a: Layer, b: Layer) => a.zIndex - b.zIndex); this.canvas.layers.forEach((layer: Layer, index: number) => { 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: any) { log.error("Error during layer fusion:", error); alert(`Error fusing layers: ${error.message}`); } } }