diff --git a/js/Canvas.js b/js/Canvas.js index 16c0bdb..66f8692 100644 --- a/js/Canvas.js +++ b/js/Canvas.js @@ -443,8 +443,8 @@ export class Canvas { * Inicjalizuje podstawowe właściwości canvas */ initCanvas() { - this.canvas.width = this.width; - this.canvas.height = this.height; + // Don't set canvas.width/height here - let the render loop handle it based on clientWidth/clientHeight + // this.width and this.height are for the OUTPUT AREA, not the display canvas this.canvas.style.border = '1px solid black'; this.canvas.style.maxWidth = '100%'; this.canvas.style.backgroundColor = '#606060'; diff --git a/js/CanvasIO.js b/js/CanvasIO.js index 582843a..75f58f7 100644 --- a/js/CanvasIO.js +++ b/js/CanvasIO.js @@ -197,6 +197,25 @@ export class CanvasIO { } async _renderOutputData() { log.info("=== RENDERING OUTPUT DATA FOR COMFYUI ==="); + // Check if layers have valid images loaded, with retry logic + const maxRetries = 5; + const retryDelay = 200; + for (let attempt = 0; attempt < maxRetries; attempt++) { + const layersWithoutImages = this.canvas.layers.filter(layer => !layer.image || !layer.image.complete); + if (layersWithoutImages.length === 0) { + break; // All images loaded + } + if (attempt === 0) { + log.warn(`${layersWithoutImages.length} layer(s) have incomplete image data. Waiting for images to load...`); + } + if (attempt < maxRetries - 1) { + await new Promise(resolve => setTimeout(resolve, retryDelay)); + } + else { + // Last attempt failed + throw new Error(`Canvas not ready after ${maxRetries} attempts: ${layersWithoutImages.length} layer(s) still have incomplete image data. Try waiting a moment and running again.`); + } + } // Użyj zunifikowanych funkcji z CanvasLayers const imageBlob = await this.canvas.canvasLayers.getFlattenedCanvasAsBlob(); const maskBlob = await this.canvas.canvasLayers.getFlattenedMaskAsBlob(); diff --git a/js/CanvasInteractions.js b/js/CanvasInteractions.js index 7de76c9..4290a78 100644 --- a/js/CanvasInteractions.js +++ b/js/CanvasInteractions.js @@ -104,6 +104,8 @@ export class CanvasInteractions { // Add a blur event listener to the window to reset key states window.addEventListener('blur', this.onBlur); document.addEventListener('paste', this.onPaste); + // Intercept Ctrl+V during capture phase to handle layer paste before ComfyUI + document.addEventListener('keydown', this.onKeyDown, { capture: true }); this.canvas.canvas.addEventListener('mouseenter', this.onMouseEnter); this.canvas.canvas.addEventListener('mouseleave', this.onMouseLeave); this.canvas.canvas.addEventListener('dragover', this.onDragOver); @@ -119,6 +121,8 @@ export class CanvasInteractions { this.canvas.canvas.removeEventListener('wheel', this.onWheel); this.canvas.canvas.removeEventListener('keydown', this.onKeyDown); this.canvas.canvas.removeEventListener('keyup', this.onKeyUp); + // Remove document-level capture listener + document.removeEventListener('keydown', this.onKeyDown, { capture: true }); window.removeEventListener('blur', this.onBlur); document.removeEventListener('paste', this.onPaste); this.canvas.canvas.removeEventListener('mouseenter', this.onMouseEnter); @@ -188,6 +192,12 @@ export class CanvasInteractions { } handleMouseDown(e) { this.canvas.canvas.focus(); + // Sync modifier states with actual event state to prevent "stuck" modifiers + // when focus moves between layers panel and canvas + this.interaction.isCtrlPressed = e.ctrlKey; + this.interaction.isMetaPressed = e.metaKey; + this.interaction.isShiftPressed = e.shiftKey; + this.interaction.isAltPressed = e.altKey; const coords = this.getMouseCoordinates(e); const mods = this.getModifierState(e); if (this.interaction.mode === 'drawingMask') { @@ -519,14 +529,24 @@ export class CanvasInteractions { return targetHeight / oldHeight; } handleKeyDown(e) { + // Always track modifier keys regardless of focus if (e.key === 'Control') this.interaction.isCtrlPressed = true; if (e.key === 'Meta') this.interaction.isMetaPressed = true; if (e.key === 'Shift') this.interaction.isShiftPressed = true; - if (e.key === 'Alt') { + if (e.key === 'Alt') this.interaction.isAltPressed = true; + // Check if canvas is focused before handling any shortcuts + const shouldHandle = this.canvas.isMouseOver || + this.canvas.canvas.contains(document.activeElement) || + document.activeElement === this.canvas.canvas; + if (!shouldHandle) { + return; + } + // Canvas-specific key handlers (only when focused) + if (e.key === 'Alt') { e.preventDefault(); } if (e.key.toLowerCase() === 's') { @@ -560,6 +580,17 @@ export class CanvasInteractions { this.canvas.canvasLayers.copySelectedLayers(); } break; + case 'v': + // Only handle internal clipboard paste here. + // If internal clipboard is empty, let the paste event bubble + // so handlePasteEvent can access e.clipboardData for system images. + if (this.canvas.canvasLayers.internalClipboard.length > 0) { + this.canvas.canvasLayers.pasteLayers(); + } else { + // Don't preventDefault - let paste event fire for system clipboard + handled = false; + } + break; default: handled = false; break; @@ -713,12 +744,11 @@ export class CanvasInteractions { if (mods.ctrl || mods.meta) { const index = this.canvas.canvasSelection.selectedLayers.indexOf(layer); if (index === -1) { + // Ctrl-clicking unselected layer: add to selection this.canvas.canvasSelection.updateSelection([...this.canvas.canvasSelection.selectedLayers, layer]); } - else { - const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l) => l !== layer); - this.canvas.canvasSelection.updateSelection(newSelection); - } + // If already selected, do NOT deselect - allows dragging multiple layers with Ctrl held + // User can use right-click in layers panel to deselect individual layers } else { if (!this.canvas.canvasSelection.selectedLayers.includes(layer)) { @@ -1155,10 +1185,13 @@ export class CanvasInteractions { } } async handlePasteEvent(e) { + // Check if canvas is connected to DOM and visible + if (!this.canvas.canvas.isConnected || !document.body.contains(this.canvas.canvas)) { + return; + } const shouldHandle = this.canvas.isMouseOver || this.canvas.canvas.contains(document.activeElement) || - document.activeElement === this.canvas.canvas || - document.activeElement === document.body; + document.activeElement === this.canvas.canvas; if (!shouldHandle) { log.debug("Paste event ignored - not focused on canvas"); return; diff --git a/js/CanvasLayers.js b/js/CanvasLayers.js index 0e08158..a1e180f 100644 --- a/js/CanvasLayers.js +++ b/js/CanvasLayers.js @@ -1100,8 +1100,8 @@ export class CanvasLayers { this.canvas.width = width; this.canvas.height = height; this.canvas.maskTool.resize(width, height); - this.canvas.canvas.width = width; - this.canvas.canvas.height = height; + // Don't set canvas.width/height - the render loop will handle display size + // this.canvas.width/height are for OUTPUT AREA dimensions, not display canvas this.canvas.render(); if (saveHistory) { this.canvas.canvasState.saveStateToDB(); diff --git a/js/CanvasLayersPanel.js b/js/CanvasLayersPanel.js index 61c2b3e..0e2f46c 100644 --- a/js/CanvasLayersPanel.js +++ b/js/CanvasLayersPanel.js @@ -123,6 +123,26 @@ export class CanvasLayersPanel { e.preventDefault(); e.stopPropagation(); this.deleteSelectedLayers(); + return; + } + // Handle Ctrl+C/V for layer copy/paste when panel has focus + if (e.ctrlKey || e.metaKey) { + if (e.key.toLowerCase() === 'c') { + if (this.canvas.canvasSelection.selectedLayers.length > 0) { + e.preventDefault(); + e.stopPropagation(); + this.canvas.canvasLayers.copySelectedLayers(); + log.info('Layers copied from panel'); + } + } + else if (e.key.toLowerCase() === 'v') { + e.preventDefault(); + e.stopPropagation(); + if (this.canvas.canvasLayers.internalClipboard.length > 0) { + this.canvas.canvasLayers.pasteLayers(); + log.info('Layers pasted from panel'); + } + } } }); log.debug('Panel structure created'); @@ -329,6 +349,8 @@ export class CanvasLayersPanel { // Aktualizuj tylko wygląd (klasy CSS), bez niszczenia DOM this.updateSelectionAppearance(); this.updateButtonStates(); + // Focus the canvas so keyboard shortcuts (like Ctrl+C/V) work for layer operations + this.canvas.canvas.focus(); log.debug(`Layer clicked: ${layer.name}, selection count: ${this.canvas.canvasSelection.selectedLayers.length}`); } startEditingLayerName(nameElement, layer) { diff --git a/js/CanvasState.js b/js/CanvasState.js index 74e6be4..3fb28b9 100644 --- a/js/CanvasState.js +++ b/js/CanvasState.js @@ -88,10 +88,10 @@ export class CanvasState { log.debug(`Output Area resized to ${this.canvas.width}x${this.canvas.height} and viewport set.`); const loadedLayers = await this._loadLayers(savedState.layers); this.canvas.layers = loadedLayers.filter((l) => l !== null); - log.info(`Loaded ${this.canvas.layers.length} layers.`); - if (this.canvas.layers.length === 0) { - log.warn("No valid layers loaded, state may be corrupted."); - return false; + log.info(`Loaded ${this.canvas.layers.length} layers from ${savedState.layers.length} saved layers.`); + if (this.canvas.layers.length === 0 && savedState.layers.length > 0) { + log.warn(`Failed to load any layers. Saved state had ${savedState.layers.length} layers but all failed to load. This may indicate corrupted IndexedDB data.`); + // Don't return false - allow empty canvas to be valid } this.canvas.updateSelectionAfterHistory(); this.canvas.render(); diff --git a/js/CanvasView.js b/js/CanvasView.js index 4b12db2..b9ce8a4 100644 --- a/js/CanvasView.js +++ b/js/CanvasView.js @@ -884,6 +884,12 @@ async function createCanvasWidget(node, widget, app) { if (controlsElement) { resizeObserver.observe(controlsElement); } + // Watch the canvas container itself to detect size changes and fix canvas dimensions + const canvasContainerResizeObserver = new ResizeObserver(() => { + // Force re-read of canvas dimensions on next render + canvas.render(); + }); + canvasContainerResizeObserver.observe(canvasContainer); canvas.canvas.addEventListener('focus', () => { canvasContainer.classList.add('has-focus'); }); @@ -1038,13 +1044,20 @@ app.registerExtension({ log.info(`Found ${canvasNodeInstances.size} CanvasNode(s). Sending data via WebSocket...`); const sendPromises = []; for (const [nodeId, canvasWidget] of canvasNodeInstances.entries()) { - if (app.graph.getNodeById(nodeId) && canvasWidget.canvas && canvasWidget.canvas.canvasIO) { - log.debug(`Sending data for canvas node ${nodeId}`); - sendPromises.push(canvasWidget.canvas.canvasIO.sendDataViaWebSocket(nodeId)); - } - else { + const node = app.graph.getNodeById(nodeId); + if (!node) { log.warn(`Node ${nodeId} not found in graph, removing from instances map.`); canvasNodeInstances.delete(nodeId); + continue; + } + // Skip bypassed nodes + if (node.mode === 4) { + log.debug(`Node ${nodeId} is bypassed, skipping data send.`); + continue; + } + if (canvasWidget.canvas && canvasWidget.canvas.canvasIO) { + log.debug(`Sending data for canvas node ${nodeId}`); + sendPromises.push(canvasWidget.canvas.canvasIO.sendDataViaWebSocket(nodeId)); } } try { @@ -1063,6 +1076,8 @@ app.registerExtension({ }, async beforeRegisterNodeDef(nodeType, nodeData, app) { if (nodeType.comfyClass === "LayerForgeNode") { + // Map to track pending copy sources across node ID changes + const pendingCopySources = new Map(); const onNodeCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { log.debug("CanvasNode onNodeCreated: Base widget setup."); @@ -1093,6 +1108,43 @@ app.registerExtension({ log.info(`Registered CanvasNode instance for ID: ${this.id}`); // Store the canvas widget on the node this.canvasWidget = canvasWidget; + // Check if this node has a pending copy source (from onConfigure) + // Check both the current ID and -1 (temporary ID during paste) + let sourceNodeId = pendingCopySources.get(this.id); + if (!sourceNodeId) { + sourceNodeId = pendingCopySources.get(-1); + if (sourceNodeId) { + // Transfer from -1 to the real ID and clear -1 + pendingCopySources.delete(-1); + } + } + if (sourceNodeId && sourceNodeId !== this.id) { + log.info(`Node ${this.id} will copy canvas state from node ${sourceNodeId}`); + // Clear the flag + pendingCopySources.delete(this.id); + // Copy the canvas state now that the widget is initialized + setTimeout(async () => { + try { + const { getCanvasState, setCanvasState } = await import('./db.js'); + let sourceState = await getCanvasState(String(sourceNodeId)); + // If source node doesn't exist (cross-workflow paste), try clipboard + if (!sourceState) { + log.debug(`No canvas state found for source node ${sourceNodeId}, checking clipboard`); + sourceState = await getCanvasState('__clipboard__'); + } + if (!sourceState) { + log.debug(`No canvas state found in clipboard either`); + return; + } + await setCanvasState(String(this.id), sourceState); + await canvasWidget.canvas.loadInitialState(); + log.info(`Canvas state copied successfully to node ${this.id}`); + } + catch (error) { + log.error(`Error copying canvas state:`, error); + } + }, 100); + } // Check if there are already connected inputs setTimeout(() => { if (this.inputs && this.inputs.length > 0) { @@ -1258,6 +1310,47 @@ app.registerExtension({ } return onRemoved?.apply(this, arguments); }; + // Handle copy/paste - save canvas state when copying + const originalSerialize = nodeType.prototype.serialize; + nodeType.prototype.serialize = function () { + const data = originalSerialize ? originalSerialize.apply(this) : {}; + // Store a reference to the source node ID so we can copy layer data + data.sourceNodeId = this.id; + log.debug(`Serializing node ${this.id} for copy`); + // Store canvas state in a clipboard entry for cross-workflow paste + // This happens async but that's fine since paste happens later + (async () => { + try { + const { getCanvasState, setCanvasState } = await import('./db.js'); + const sourceState = await getCanvasState(String(this.id)); + if (sourceState) { + // Store in a special "clipboard" entry + await setCanvasState('__clipboard__', sourceState); + log.debug(`Stored canvas state in clipboard for node ${this.id}`); + } + } + catch (error) { + log.error('Error storing canvas state to clipboard:', error); + } + })(); + return data; + }; + // Handle copy/paste - load canvas state from source node when pasting + const originalConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = async function (data) { + if (originalConfigure) { + originalConfigure.apply(this, [data]); + } + // Store the source node ID in the map (persists across node ID changes) + // This will be picked up later in onAdded when the canvas widget is ready + if (data.sourceNodeId && data.sourceNodeId !== this.id) { + const existingSource = pendingCopySources.get(this.id); + if (!existingSource) { + pendingCopySources.set(this.id, data.sourceNodeId); + log.debug(`Stored pending copy source: ${data.sourceNodeId} for node ${this.id}`); + } + } + }; const originalGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions; nodeType.prototype.getExtraMenuOptions = function (_, options) { // FIRST: Call original to let other extensions add their options diff --git a/src/Canvas.ts b/src/Canvas.ts index f08b72d..7e29b97 100644 --- a/src/Canvas.ts +++ b/src/Canvas.ts @@ -578,8 +578,8 @@ export class Canvas { * Inicjalizuje podstawowe właściwości canvas */ initCanvas() { - this.canvas.width = this.width; - this.canvas.height = this.height; + // Don't set canvas.width/height here - let the render loop handle it based on clientWidth/clientHeight + // this.width and this.height are for the OUTPUT AREA, not the display canvas this.canvas.style.border = '1px solid black'; this.canvas.style.maxWidth = '100%'; this.canvas.style.backgroundColor = '#606060'; diff --git a/src/CanvasIO.ts b/src/CanvasIO.ts index 4072a4d..3212b0e 100644 --- a/src/CanvasIO.ts +++ b/src/CanvasIO.ts @@ -217,11 +217,34 @@ export class CanvasIO { async _renderOutputData(): Promise<{ image: string, mask: string }> { log.info("=== RENDERING OUTPUT DATA FOR COMFYUI ==="); - + + // Check if layers have valid images loaded, with retry logic + const maxRetries = 5; + const retryDelay = 200; + + for (let attempt = 0; attempt < maxRetries; attempt++) { + const layersWithoutImages = this.canvas.layers.filter(layer => !layer.image || !layer.image.complete); + + if (layersWithoutImages.length === 0) { + break; // All images loaded + } + + if (attempt === 0) { + log.warn(`${layersWithoutImages.length} layer(s) have incomplete image data. Waiting for images to load...`); + } + + if (attempt < maxRetries - 1) { + await new Promise(resolve => setTimeout(resolve, retryDelay)); + } else { + // Last attempt failed + throw new Error(`Canvas not ready after ${maxRetries} attempts: ${layersWithoutImages.length} layer(s) still have incomplete image data. Try waiting a moment and running again.`); + } + } + // Użyj zunifikowanych funkcji z CanvasLayers const imageBlob = await this.canvas.canvasLayers.getFlattenedCanvasAsBlob(); const maskBlob = await this.canvas.canvasLayers.getFlattenedMaskAsBlob(); - + if (!imageBlob || !maskBlob) { throw new Error("Failed to generate canvas or mask blobs"); } diff --git a/src/CanvasInteractions.ts b/src/CanvasInteractions.ts index 0381d46..bc1004a 100644 --- a/src/CanvasInteractions.ts +++ b/src/CanvasInteractions.ts @@ -132,16 +132,16 @@ export class CanvasInteractions { const mouseBufferY = (worldCoords.y - this.canvas.viewport.y) * this.canvas.viewport.zoom; const newZoom = Math.max(0.1, Math.min(10, this.canvas.viewport.zoom * zoomFactor)); - + this.canvas.viewport.zoom = newZoom; this.canvas.viewport.x = worldCoords.x - (mouseBufferX / this.canvas.viewport.zoom); this.canvas.viewport.y = worldCoords.y - (mouseBufferY / this.canvas.viewport.zoom); - + // Update stroke overlay if mask tool is drawing during zoom if (this.canvas.maskTool.isDrawing) { this.canvas.maskTool.handleViewportChange(); } - + this.canvas.onViewportChange?.(); } @@ -176,6 +176,9 @@ export class CanvasInteractions { document.addEventListener('paste', this.onPaste as unknown as EventListener); + // Intercept Ctrl+V during capture phase to handle layer paste before ComfyUI + document.addEventListener('keydown', this.onKeyDown as EventListener, { capture: true }); + this.canvas.canvas.addEventListener('mouseenter', this.onMouseEnter as EventListener); this.canvas.canvas.addEventListener('mouseleave', this.onMouseLeave as EventListener); @@ -195,6 +198,9 @@ export class CanvasInteractions { this.canvas.canvas.removeEventListener('keydown', this.onKeyDown as EventListener); this.canvas.canvas.removeEventListener('keyup', this.onKeyUp as EventListener); + // Remove document-level capture listener + document.removeEventListener('keydown', this.onKeyDown as EventListener, { capture: true }); + window.removeEventListener('blur', this.onBlur); document.removeEventListener('paste', this.onPaste as unknown as EventListener); @@ -228,7 +234,7 @@ export class CanvasInteractions { const rotatedY = dx * Math.sin(rad) + dy * Math.cos(rad); // Sprawdź czy punkt jest wewnątrz prostokąta layera - if (Math.abs(rotatedX) <= layer.width / 2 && + if (Math.abs(rotatedX) <= layer.width / 2 && Math.abs(rotatedY) <= layer.height / 2) { return true; } @@ -277,6 +283,14 @@ export class CanvasInteractions { handleMouseDown(e: MouseEvent): void { this.canvas.canvas.focus(); + + // Sync modifier states with actual event state to prevent "stuck" modifiers + // when focus moves between layers panel and canvas + this.interaction.isCtrlPressed = e.ctrlKey; + this.interaction.isMetaPressed = e.metaKey; + this.interaction.isShiftPressed = e.shiftKey; + this.interaction.isAltPressed = e.altKey; + const coords = this.getMouseCoordinates(e); const mods = this.getModifierState(e); @@ -325,11 +339,11 @@ export class CanvasInteractions { this.startCanvasResize(coords.world); return; } - + // 2. Inne przyciski myszy if (e.button === 2) { // Prawy przycisk myszy this.preventEventDefaults(e); - + // Sprawdź czy kliknięto w obszarze któregokolwiek z zaznaczonych layerów (niezależnie od przykrycia) if (this.isPointInSelectedLayers(coords.world.x, coords.world.y)) { // Nowa logika przekazuje tylko współrzędne świata, menu pozycjonuje się samo @@ -354,7 +368,7 @@ export class CanvasInteractions { if (grabIconLayer) { // Start dragging the selected layer(s) without changing selection this.interaction.mode = 'potential-drag'; - this.interaction.dragStart = {...coords.world}; + this.interaction.dragStart = { ...coords.world }; return; } @@ -363,7 +377,7 @@ export class CanvasInteractions { this.prepareForDrag(clickedLayerResult.layer, coords.world); return; } - + // 4. Domyślna akcja na tle (lewy przycisk bez modyfikatorów) this.startPanning(e, true); // clearSelection = true } @@ -371,7 +385,7 @@ export class CanvasInteractions { handleMouseMove(e: MouseEvent): void { const coords = this.getMouseCoordinates(e); this.canvas.lastMousePosition = coords.world; // Zawsze aktualizuj ostatnią pozycję myszy - + // Sprawdź, czy rozpocząć przeciąganie if (this.interaction.mode === 'potential-drag') { const dx = coords.world.x - this.interaction.dragStart.x; @@ -384,7 +398,7 @@ export class CanvasInteractions { }); } } - + switch (this.interaction.mode) { case 'drawingMask': this.canvas.maskTool.handleMouseMove(coords.world, coords.view); @@ -419,12 +433,12 @@ export class CanvasInteractions { // Check if hovering over grab icon const wasHovering = this.interaction.hoveringGrabIcon; this.interaction.hoveringGrabIcon = this.getGrabIconAtPosition(coords.world.x, coords.world.y) !== null; - + // Re-render if hover state changed to show/hide grab icon if (wasHovering !== this.interaction.hoveringGrabIcon) { this.canvas.render(); } - + this.updateCursor(coords.world); // Update brush cursor on overlay if mask tool is active if (this.canvas.maskTool.isActive) { @@ -441,7 +455,7 @@ export class CanvasInteractions { handleMouseUp(e: MouseEvent): void { const coords = this.getMouseCoordinates(e); - + if (this.interaction.mode === 'drawingMask') { this.canvas.maskTool.handleMouseUp(coords.view); // Render only once after drawing is complete @@ -470,7 +484,7 @@ export class CanvasInteractions { if (this.interaction.mode === 'resizing' && this.interaction.transformingLayer?.cropMode) { this.canvas.canvasLayers.handleCropBoundsTransformEnd(this.interaction.transformingLayer); } - + // Handle end of scale transformation (normal transform mode) before resetting interaction state if (this.interaction.mode === 'resizing' && this.interaction.transformingLayer && !this.interaction.transformingLayer.cropMode) { this.canvas.canvasLayers.handleScaleTransformEnd(this.interaction.transformingLayer); @@ -494,7 +508,7 @@ export class CanvasInteractions { log.info(`Mouse position: world(${coords.world.x.toFixed(1)}, ${coords.world.y.toFixed(1)}) view(${coords.view.x.toFixed(1)}, ${coords.view.y.toFixed(1)})`); log.info(`Output Area Bounds: x=${bounds.x}, y=${bounds.y}, w=${bounds.width}, h=${bounds.height}`); log.info(`Viewport: x=${this.canvas.viewport.x.toFixed(1)}, y=${this.canvas.viewport.y.toFixed(1)}, zoom=${this.canvas.viewport.zoom.toFixed(2)}`); - + this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer, index: number) => { const relativeToOutput = { x: layer.x - bounds.x, @@ -541,7 +555,7 @@ export class CanvasInteractions { handleWheel(e: WheelEvent): void { this.preventEventDefaults(e); const coords = this.getMouseCoordinates(e); - + if (this.canvas.maskTool.isActive || this.canvas.canvasSelection.selectedLayers.length === 0) { // Zoom operation for mask tool or when no layers selected const zoomFactor = e.deltaY < 0 ? 1.1 : 1 / 1.1; @@ -549,7 +563,7 @@ export class CanvasInteractions { } else { // Check if mouse is over any selected layer const isOverSelectedLayer = this.isPointInSelectedLayers(coords.world.x, coords.world.y); - + if (isOverSelectedLayer) { // Layer transformation when layers are selected and mouse is over selected layer this.handleLayerWheelTransformation(e); @@ -559,7 +573,7 @@ export class CanvasInteractions { this.performZoomOperation(coords.world, zoomFactor); } } - + this.canvas.render(); if (!this.canvas.maskTool.isActive) { this.canvas.requestSaveState(); @@ -615,7 +629,7 @@ export class CanvasInteractions { layer.height *= scaleFactor; layer.x += (oldWidth - layer.width) / 2; layer.y += (oldHeight - layer.height) / 2; - + // Handle wheel scaling end for layers with blend area this.canvas.canvasLayers.handleWheelScalingEnd(layer); } @@ -631,11 +645,11 @@ export class CanvasInteractions { } else { targetHeight = (Math.ceil(oldHeight / gridSize) - 1) * gridSize; } - + if (targetHeight < gridSize / 2) { targetHeight = gridSize / 2; } - + if (Math.abs(oldHeight - targetHeight) < 1) { if (direction > 0) targetHeight += gridSize; else targetHeight -= gridSize; @@ -646,11 +660,23 @@ export class CanvasInteractions { } handleKeyDown(e: KeyboardEvent): void { + // Always track modifier keys regardless of focus if (e.key === 'Control') this.interaction.isCtrlPressed = true; if (e.key === 'Meta') this.interaction.isMetaPressed = true; if (e.key === 'Shift') this.interaction.isShiftPressed = true; + if (e.key === 'Alt') this.interaction.isAltPressed = true; + + // Check if canvas is focused before handling any shortcuts + const shouldHandle = this.canvas.isMouseOver || + this.canvas.canvas.contains(document.activeElement) || + document.activeElement === this.canvas.canvas; + + if (!shouldHandle) { + return; + } + + // Canvas-specific key handlers (only when focused) if (e.key === 'Alt') { - this.interaction.isAltPressed = true; e.preventDefault(); } if (e.key.toLowerCase() === 's') { @@ -664,7 +690,7 @@ export class CanvasInteractions { this.canvas.shapeTool.activate(); return; } - + // Globalne skróty (Undo/Redo/Copy/Paste) const mods = this.getModifierState(e); if (mods.ctrl || mods.meta) { @@ -685,6 +711,17 @@ export class CanvasInteractions { this.canvas.canvasLayers.copySelectedLayers(); } break; + case 'v': + // Only handle internal clipboard paste here. + // If internal clipboard is empty, let the paste event bubble + // so handlePasteEvent can access e.clipboardData for system images. + if (this.canvas.canvasLayers.internalClipboard.length > 0) { + this.canvas.canvasLayers.pasteLayers(); + } else { + // Don't preventDefault - let paste event fire for system clipboard + handled = false; + } + break; default: handled = false; break; @@ -700,7 +737,7 @@ export class CanvasInteractions { if (this.canvas.canvasSelection.selectedLayers.length > 0) { const step = mods.shift ? 10 : 1; let needsRender = false; - + // Używamy e.code dla spójności i niezależności od układu klawiatury const movementKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'BracketLeft', 'BracketRight']; if (movementKeys.includes(e.code)) { @@ -724,7 +761,7 @@ export class CanvasInteractions { this.canvas.canvasSelection.removeSelectedLayers(); return; } - + if (needsRender) { this.canvas.render(); } @@ -770,7 +807,7 @@ export class CanvasInteractions { this.canvas.saveState(); this.canvas.canvasState.saveStateToDB(); } - + // Reset interaction mode if it's something that can get "stuck" if (this.interaction.mode !== 'none' && this.interaction.mode !== 'drawingMask') { this.resetInteractionState(); @@ -820,7 +857,7 @@ export class CanvasInteractions { originalHeight: layer.originalHeight, cropBounds: layer.cropBounds ? { ...layer.cropBounds } : undefined }; - this.interaction.dragStart = {...worldCoords}; + this.interaction.dragStart = { ...worldCoords }; if (handle === 'rot') { this.interaction.mode = 'rotating'; @@ -844,19 +881,19 @@ export class CanvasInteractions { if (mods.ctrl || mods.meta) { const index = this.canvas.canvasSelection.selectedLayers.indexOf(layer); if (index === -1) { + // Ctrl-clicking unselected layer: add to selection this.canvas.canvasSelection.updateSelection([...this.canvas.canvasSelection.selectedLayers, layer]); - } else { - const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l: Layer) => l !== layer); - this.canvas.canvasSelection.updateSelection(newSelection); } + // If already selected, do NOT deselect - allows dragging multiple layers with Ctrl held + // User can use right-click in layers panel to deselect individual layers } else { if (!this.canvas.canvasSelection.selectedLayers.includes(layer)) { this.canvas.canvasSelection.updateSelection([layer]); } } - + this.interaction.mode = 'potential-drag'; - this.interaction.dragStart = {...worldCoords}; + this.interaction.dragStart = { ...worldCoords }; } startPanning(e: MouseEvent, clearSelection: boolean = true): void { @@ -872,8 +909,8 @@ export class CanvasInteractions { this.interaction.mode = 'resizingCanvas'; const startX = snapToGrid(worldCoords.x); const startY = snapToGrid(worldCoords.y); - this.interaction.canvasResizeStart = {x: startX, y: startY}; - this.interaction.canvasResizeRect = {x: startX, y: startY, width: 0, height: 0}; + this.interaction.canvasResizeStart = { x: startX, y: startY }; + this.interaction.canvasResizeRect = { x: startX, y: startY, width: 0, height: 0 }; this.canvas.render(); } @@ -887,7 +924,7 @@ export class CanvasInteractions { updateCanvasMove(worldCoords: Point): void { const dx = worldCoords.x - this.interaction.dragStart.x; const dy = worldCoords.y - this.interaction.dragStart.y; - + // Po prostu przesuwamy outputAreaBounds const bounds = this.canvas.outputAreaBounds; this.interaction.canvasMoveRect = { @@ -911,11 +948,11 @@ export class CanvasInteractions { width: moveRect.width, height: moveRect.height }; - + // Update mask canvas to ensure it covers the new output area position this.canvas.maskTool.updateMaskCanvasForOutputArea(); } - + this.canvas.render(); this.canvas.saveState(); } @@ -925,13 +962,13 @@ export class CanvasInteractions { const dy = e.clientY - this.interaction.panStart.y; this.canvas.viewport.x -= dx / this.canvas.viewport.zoom; this.canvas.viewport.y -= dy / this.canvas.viewport.zoom; - this.interaction.panStart = {x: e.clientX, y: e.clientY}; - + this.interaction.panStart = { x: e.clientX, y: e.clientY }; + // Update stroke overlay if mask tool is drawing during pan if (this.canvas.maskTool.isDrawing) { this.canvas.maskTool.handleViewportChange(); } - + this.canvas.render(); this.canvas.onViewportChange?.(); } @@ -994,7 +1031,7 @@ export class CanvasInteractions { const o = this.interaction.transformOrigin; if (!o) return; - + const handle = this.interaction.resizeHandle; const anchor = this.interaction.resizeAnchor; const rad = o.rotation * Math.PI / 180; @@ -1012,7 +1049,7 @@ export class CanvasInteractions { // 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; @@ -1022,13 +1059,13 @@ export class CanvasInteractions { 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; @@ -1041,20 +1078,20 @@ export class CanvasInteractions { if (layer.flipV) { mouseDeltaY_local *= -1; } - + // 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 const isFlippedH = layer.flipH; const isFlippedV = layer.flipV; - + if (handle?.includes('w')) { if (isFlippedH) newCropBounds.width += delta_image_x; else { @@ -1081,10 +1118,10 @@ export class CanvasInteractions { newCropBounds.height -= delta_image_y; } else newCropBounds.height += delta_image_y; } - - // Clamp crop bounds to stay within the original image and maintain minimum size + + // 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; + if (handle?.includes('w')) newCropBounds.x = o.cropBounds.x + o.cropBounds.width - 1; newCropBounds.width = 1; } if (newCropBounds.height < 1) { @@ -1112,7 +1149,7 @@ export class CanvasInteractions { // 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) { @@ -1124,10 +1161,10 @@ export class CanvasInteractions { 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; @@ -1193,7 +1230,7 @@ export class CanvasInteractions { this.canvas.updateOutputAreaSize(newWidth, newHeight); } - + this.canvas.render(); this.canvas.saveState(); } @@ -1291,7 +1328,7 @@ export class CanvasInteractions { // Store the original canvas size for extension calculations this.canvas.originalCanvasSize = { width: newWidth, height: newHeight }; - + // Store the original position where custom shape was drawn for extension calculations this.canvas.originalOutputAreaPosition = { x: newX, y: newY }; @@ -1300,10 +1337,10 @@ export class CanvasInteractions { const ext = this.canvas.outputAreaExtensions; const extendedWidth = newWidth + ext.left + ext.right; const extendedHeight = newHeight + ext.top + ext.bottom; - + // Update canvas size with extensions this.canvas.updateOutputAreaSize(extendedWidth, extendedHeight, false); - + // Set outputAreaBounds accounting for extensions this.canvas.outputAreaBounds = { x: newX - ext.left, // Adjust position by left extension @@ -1311,19 +1348,19 @@ export class CanvasInteractions { width: extendedWidth, height: extendedHeight }; - + log.info(`New custom shape with extensions: original(${newX}, ${newY}) extended(${newX - ext.left}, ${newY - ext.top}) size(${extendedWidth}x${extendedHeight})`); } else { // No extensions - use original size and position this.canvas.updateOutputAreaSize(newWidth, newHeight, false); - + this.canvas.outputAreaBounds = { x: newX, y: newY, width: newWidth, height: newHeight }; - + log.info(`New custom shape without extensions: position(${newX}, ${newY}) size(${newWidth}x${newHeight})`); } @@ -1342,12 +1379,15 @@ export class CanvasInteractions { } async handlePasteEvent(e: ClipboardEvent): Promise { + // Check if canvas is connected to DOM and visible + if (!this.canvas.canvas.isConnected || !document.body.contains(this.canvas.canvas)) { + return; + } const shouldHandle = this.canvas.isMouseOver || this.canvas.canvas.contains(document.activeElement) || - document.activeElement === this.canvas.canvas || - document.activeElement === document.body; - + document.activeElement === this.canvas.canvas; + if (!shouldHandle) { log.debug("Paste event ignored - not focused on canvas"); return; @@ -1356,7 +1396,7 @@ export class CanvasInteractions { log.info("Paste event detected, checking clipboard preference"); const preference = this.canvas.canvasLayers.clipboardPreference; - + if (preference === 'clipspace') { log.info("Clipboard preference is clipspace, delegating to ClipboardManager"); @@ -1400,7 +1440,7 @@ export class CanvasInteractions { public activateOutputAreaTransform(): void { // Clear any existing interaction state before starting transform this.resetInteractionState(); - + // Deactivate any active tools that might conflict if (this.canvas.shapeTool.isActive) { this.canvas.shapeTool.deactivate(); @@ -1408,10 +1448,10 @@ export class CanvasInteractions { if (this.canvas.maskTool.isActive) { this.canvas.maskTool.deactivate(); } - + // Clear selection to avoid confusion this.canvas.canvasSelection.updateSelection([]); - + // Set transform mode this.interaction.mode = 'transformingOutputArea'; this.canvas.render(); @@ -1420,7 +1460,7 @@ export class CanvasInteractions { private getOutputAreaHandle(worldCoords: Point): string | null { const bounds = this.canvas.outputAreaBounds; const threshold = 10 / this.canvas.viewport.zoom; - + // Define handle positions const handles = { 'nw': { x: bounds.x, y: bounds.y }, @@ -1447,7 +1487,7 @@ export class CanvasInteractions { private startOutputAreaTransform(handle: string, worldCoords: Point): void { this.interaction.outputAreaTransformHandle = handle; this.interaction.dragStart = { ...worldCoords }; - + const bounds = this.canvas.outputAreaBounds; this.interaction.transformOrigin = { x: bounds.x, @@ -1470,17 +1510,17 @@ export class CanvasInteractions { 'sw': { x: bounds.x + bounds.width, y: bounds.y }, 'w': { x: bounds.x + bounds.width, y: bounds.y + bounds.height / 2 }, }; - + this.interaction.outputAreaTransformAnchor = anchorMap[handle]; } private resizeOutputAreaFromHandle(worldCoords: Point, isShiftPressed: boolean): void { const o = this.interaction.transformOrigin; if (!o) return; - + const handle = this.interaction.outputAreaTransformHandle; const anchor = this.interaction.outputAreaTransformAnchor; - + let newX = o.x; let newY = o.y; let newWidth = o.width; @@ -1551,7 +1591,7 @@ export class CanvasInteractions { private updateOutputAreaTransformCursor(worldCoords: Point): void { const handle = this.getOutputAreaHandle(worldCoords); - + if (handle) { const cursorMap: { [key: string]: string } = { 'n': 'ns-resize', 's': 'ns-resize', @@ -1567,16 +1607,16 @@ export class CanvasInteractions { private finalizeOutputAreaTransform(): void { const bounds = this.canvas.outputAreaBounds; - + // Update canvas size and mask tool this.canvas.updateOutputAreaSize(bounds.width, bounds.height); - + // Update mask canvas for new output area this.canvas.maskTool.updateMaskCanvasForOutputArea(); - + // Save state this.canvas.saveState(); - + // Reset transform handle but keep transform mode active this.interaction.outputAreaTransformHandle = null; } diff --git a/src/CanvasLayers.ts b/src/CanvasLayers.ts index d2d9343..b3f4dda 100644 --- a/src/CanvasLayers.ts +++ b/src/CanvasLayers.ts @@ -1263,8 +1263,8 @@ export class CanvasLayers { this.canvas.height = height; this.canvas.maskTool.resize(width, height); - this.canvas.canvas.width = width; - this.canvas.canvas.height = height; + // Don't set canvas.width/height - the render loop will handle display size + // this.canvas.width/height are for OUTPUT AREA dimensions, not display canvas this.canvas.render(); diff --git a/src/CanvasLayersPanel.ts b/src/CanvasLayersPanel.ts index 2ee7280..6626a5e 100644 --- a/src/CanvasLayersPanel.ts +++ b/src/CanvasLayersPanel.ts @@ -133,7 +133,7 @@ export class CanvasLayersPanel { `; this.layersContainer = this.container.querySelector('#layers-container'); - + // Setup event listeners dla przycisków this.setupControlButtons(); this.setupMasterVisibilityToggle(); @@ -144,6 +144,26 @@ export class CanvasLayersPanel { e.preventDefault(); e.stopPropagation(); this.deleteSelectedLayers(); + return; + } + + // Handle Ctrl+C/V for layer copy/paste when panel has focus + if (e.ctrlKey || e.metaKey) { + if (e.key.toLowerCase() === 'c') { + if (this.canvas.canvasSelection.selectedLayers.length > 0) { + e.preventDefault(); + e.stopPropagation(); + this.canvas.canvasLayers.copySelectedLayers(); + log.info('Layers copied from panel'); + } + } else if (e.key.toLowerCase() === 'v') { + e.preventDefault(); + e.stopPropagation(); + if (this.canvas.canvasLayers.internalClipboard.length > 0) { + this.canvas.canvasLayers.pasteLayers(); + log.info('Layers pasted from panel'); + } + } } }); @@ -269,7 +289,7 @@ export class CanvasLayersPanel { layerRow.className = 'layer-row'; layerRow.draggable = true; layerRow.dataset.layerIndex = String(index); - + const isSelected = this.canvas.canvasSelection.selectedLayers.includes(layer); if (isSelected) { layerRow.classList.add('selected'); @@ -318,7 +338,7 @@ export class CanvasLayersPanel { const scale = Math.min(48 / layer.image.width, 48 / layer.image.height); const scaledWidth = layer.image.width * scale; const scaledHeight = layer.image.height * scale; - + // Wycentruj obraz const x = (48 - scaledWidth) / 2; const y = (48 - scaledHeight) / 2; @@ -383,26 +403,29 @@ export class CanvasLayersPanel { // Aktualizuj wewnętrzny stan zaznaczenia w obiekcie canvas // Ta funkcja NIE powinna już wywoływać onSelectionChanged w panelu. this.canvas.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index); - + // Aktualizuj tylko wygląd (klasy CSS), bez niszczenia DOM this.updateSelectionAppearance(); this.updateButtonStates(); + // Focus the canvas so keyboard shortcuts (like Ctrl+C/V) work for layer operations + this.canvas.canvas.focus(); + log.debug(`Layer clicked: ${layer.name}, selection count: ${this.canvas.canvasSelection.selectedLayers.length}`); } startEditingLayerName(nameElement: HTMLElement, layer: Layer): void { const currentName = layer.name; nameElement.classList.add('editing'); - + const input = document.createElement('input'); input.type = 'text'; input.value = currentName; input.style.width = '100%'; - + nameElement.innerHTML = ''; nameElement.appendChild(input); - + input.focus(); input.select(); @@ -412,7 +435,7 @@ export class CanvasLayersPanel { layer.name = newName; nameElement.classList.remove('editing'); nameElement.textContent = newName; - + this.canvas.saveState(); log.info(`Layer renamed to: ${newName}`); }; @@ -436,11 +459,11 @@ export class CanvasLayersPanel { if (!existingNames.includes(proposedName)) { return proposedName; } - + // Sprawdź czy nazwa już ma numerację w nawiasach const match = proposedName.match(/^(.+?)\s*\((\d+)\)$/); let baseName, startNumber; - + if (match) { baseName = match[1].trim(); startNumber = parseInt(match[2]) + 1; @@ -448,34 +471,34 @@ export class CanvasLayersPanel { baseName = proposedName; startNumber = 1; } - + // Znajdź pierwszą dostępną numerację let counter = startNumber; let uniqueName; - + do { uniqueName = `${baseName} (${counter})`; counter++; } while (existingNames.includes(uniqueName)); - + return uniqueName; } toggleLayerVisibility(layer: Layer): void { layer.visible = !layer.visible; - + // If layer became invisible and is selected, deselect it if (!layer.visible && this.canvas.canvasSelection.selectedLayers.includes(layer)) { const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l: Layer) => l !== layer); this.canvas.updateSelection(newSelection); } - + this.canvas.render(); this.canvas.requestSaveState(); - + // Update the eye icon in the panel this.renderLayers(); - + log.info(`Layer "${layer.name}" visibility toggled to: ${layer.visible}`); } @@ -535,7 +558,7 @@ export class CanvasLayersPanel { const line = document.createElement('div'); line.className = 'drag-insertion-line'; - + if (isUpperHalf) { line.style.top = '-1px'; } else { @@ -563,7 +586,7 @@ export class CanvasLayersPanel { const rect = e.currentTarget.getBoundingClientRect(); const midpoint = rect.top + rect.height / 2; const isUpperHalf = e.clientY < midpoint; - + // Oblicz docelowy indeks let insertIndex = targetIndex; if (!isUpperHalf) { @@ -572,7 +595,7 @@ export class CanvasLayersPanel { // Użyj nowej, centralnej funkcji do przesuwania warstw this.canvas.canvasLayers.moveLayers(this.draggedElements, { toIndex: insertIndex }); - + log.info(`Dropped ${this.draggedElements.length} layers at position ${insertIndex}`); } @@ -610,17 +633,17 @@ export class CanvasLayersPanel { */ updateButtonStates(): void { if (!this.container) return; - + const deleteBtn = this.container.querySelector('#delete-layer-btn') as HTMLButtonElement; const hasSelectedLayers = this.canvas.canvasSelection.selectedLayers.length > 0; - + if (deleteBtn) { deleteBtn.disabled = !hasSelectedLayers; - deleteBtn.title = hasSelectedLayers + deleteBtn.title = hasSelectedLayers ? `Delete ${this.canvas.canvasSelection.selectedLayers.length} selected layer(s)` : 'No layers selected'; } - + log.debug(`Button states updated - delete button ${hasSelectedLayers ? 'enabled' : 'disabled'}`); } @@ -641,7 +664,7 @@ export class CanvasLayersPanel { this.layersContainer = null; this.draggedElements = []; this.removeDragInsertionLine(); - + log.info('CanvasLayersPanel destroyed'); } } diff --git a/src/CanvasState.ts b/src/CanvasState.ts index 4023a0d..5687662 100644 --- a/src/CanvasState.ts +++ b/src/CanvasState.ts @@ -118,11 +118,11 @@ export class CanvasState { log.debug(`Output Area resized to ${this.canvas.width}x${this.canvas.height} and viewport set.`); const loadedLayers = await this._loadLayers(savedState.layers); this.canvas.layers = loadedLayers.filter((l): l is Layer => l !== null); - log.info(`Loaded ${this.canvas.layers.length} layers.`); + log.info(`Loaded ${this.canvas.layers.length} layers from ${savedState.layers.length} saved layers.`); - if (this.canvas.layers.length === 0) { - log.warn("No valid layers loaded, state may be corrupted."); - return false; + if (this.canvas.layers.length === 0 && savedState.layers.length > 0) { + log.warn(`Failed to load any layers. Saved state had ${savedState.layers.length} layers but all failed to load. This may indicate corrupted IndexedDB data.`); + // Don't return false - allow empty canvas to be valid } this.canvas.updateSelectionAfterHistory(); diff --git a/src/CanvasView.ts b/src/CanvasView.ts index a1940c7..ec1890a 100644 --- a/src/CanvasView.ts +++ b/src/CanvasView.ts @@ -1000,6 +1000,13 @@ $el("label.clipboard-switch.mask-switch", { resizeObserver.observe(controlsElement); } + // Watch the canvas container itself to detect size changes and fix canvas dimensions + const canvasContainerResizeObserver = new ResizeObserver(() => { + // Force re-read of canvas dimensions on next render + canvas.render(); + }); + canvasContainerResizeObserver.observe(canvasContainer); + canvas.canvas.addEventListener('focus', () => { canvasContainer.classList.add('has-focus'); }); @@ -1195,12 +1202,23 @@ app.registerExtension({ const sendPromises: Promise[] = []; for (const [nodeId, canvasWidget] of canvasNodeInstances.entries()) { - if (app.graph.getNodeById(nodeId) && canvasWidget.canvas && canvasWidget.canvas.canvasIO) { - log.debug(`Sending data for canvas node ${nodeId}`); - sendPromises.push(canvasWidget.canvas.canvasIO.sendDataViaWebSocket(nodeId)); - } else { + const node = app.graph.getNodeById(nodeId); + + if (!node) { log.warn(`Node ${nodeId} not found in graph, removing from instances map.`); canvasNodeInstances.delete(nodeId); + continue; + } + + // Skip bypassed nodes + if (node.mode === 4) { + log.debug(`Node ${nodeId} is bypassed, skipping data send.`); + continue; + } + + if (canvasWidget.canvas && canvasWidget.canvas.canvasIO) { + log.debug(`Sending data for canvas node ${nodeId}`); + sendPromises.push(canvasWidget.canvas.canvasIO.sendDataViaWebSocket(nodeId)); } } @@ -1221,6 +1239,9 @@ app.registerExtension({ async beforeRegisterNodeDef(nodeType: any, nodeData: any, app: ComfyApp) { if (nodeType.comfyClass === "LayerForgeNode") { + // Map to track pending copy sources across node ID changes + const pendingCopySources = new Map(); + const onNodeCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function (this: ComfyNode) { log.debug("CanvasNode onNodeCreated: Base widget setup."); @@ -1253,10 +1274,53 @@ app.registerExtension({ const canvasWidget = await createCanvasWidget(this, null, app); canvasNodeInstances.set(this.id, canvasWidget); log.info(`Registered CanvasNode instance for ID: ${this.id}`); - + // Store the canvas widget on the node (this as any).canvasWidget = canvasWidget; + // Check if this node has a pending copy source (from onConfigure) + // Check both the current ID and -1 (temporary ID during paste) + let sourceNodeId = pendingCopySources.get(this.id); + if (!sourceNodeId) { + sourceNodeId = pendingCopySources.get(-1); + if (sourceNodeId) { + // Transfer from -1 to the real ID and clear -1 + pendingCopySources.delete(-1); + } + } + + if (sourceNodeId && sourceNodeId !== this.id) { + log.info(`Node ${this.id} will copy canvas state from node ${sourceNodeId}`); + + // Clear the flag + pendingCopySources.delete(this.id); + + // Copy the canvas state now that the widget is initialized + setTimeout(async () => { + try { + const { getCanvasState, setCanvasState } = await import('./db.js'); + let sourceState = await getCanvasState(String(sourceNodeId)); + + // If source node doesn't exist (cross-workflow paste), try clipboard + if (!sourceState) { + log.debug(`No canvas state found for source node ${sourceNodeId}, checking clipboard`); + sourceState = await getCanvasState('__clipboard__'); + } + + if (!sourceState) { + log.debug(`No canvas state found in clipboard either`); + return; + } + + await setCanvasState(String(this.id), sourceState); + await canvasWidget.canvas.loadInitialState(); + log.info(`Canvas state copied successfully to node ${this.id}`); + } catch (error) { + log.error(`Error copying canvas state:`, error); + } + }, 100); + } + // Check if there are already connected inputs setTimeout(() => { if (this.inputs && this.inputs.length > 0) { @@ -1440,6 +1504,52 @@ app.registerExtension({ return onRemoved?.apply(this, arguments as any); }; + // Handle copy/paste - save canvas state when copying + const originalSerialize = nodeType.prototype.serialize; + nodeType.prototype.serialize = function (this: ComfyNode) { + const data = originalSerialize ? originalSerialize.apply(this) : {}; + + // Store a reference to the source node ID so we can copy layer data + data.sourceNodeId = this.id; + log.debug(`Serializing node ${this.id} for copy`); + + // Store canvas state in a clipboard entry for cross-workflow paste + // This happens async but that's fine since paste happens later + (async () => { + try { + const { getCanvasState, setCanvasState } = await import('./db.js'); + const sourceState = await getCanvasState(String(this.id)); + if (sourceState) { + // Store in a special "clipboard" entry + await setCanvasState('__clipboard__', sourceState); + log.debug(`Stored canvas state in clipboard for node ${this.id}`); + } + } catch (error) { + log.error('Error storing canvas state to clipboard:', error); + } + })(); + + return data; + }; + + // Handle copy/paste - load canvas state from source node when pasting + const originalConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = async function (this: ComfyNode, data: any) { + if (originalConfigure) { + originalConfigure.apply(this, [data]); + } + + // Store the source node ID in the map (persists across node ID changes) + // This will be picked up later in onAdded when the canvas widget is ready + if (data.sourceNodeId && data.sourceNodeId !== this.id) { + const existingSource = pendingCopySources.get(this.id); + if (!existingSource) { + pendingCopySources.set(this.id, data.sourceNodeId); + log.debug(`Stored pending copy source: ${data.sourceNodeId} for node ${this.id}`); + } + } + }; + const originalGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions; nodeType.prototype.getExtraMenuOptions = function (this: ComfyNode, _: any, options: any[]) { // FIRST: Call original to let other extensions add their options