22 Commits

Author SHA1 Message Date
Dariusz L
835d94a11d Merge pull request #24 from diodiogod/fix/keyboard-shortcuts-focus-check
Fix keyboard shortcuts capturing events when node is unfocused
2026-02-20 16:28:04 +01:00
Dariusz L
061e2b7a9a Cleanup: Remove accidentally committed python\__pycache__folder 2026-02-05 16:23:10 +01:00
diodiogod
b1f29eefdb Cleanup: Remove accidentally committed __pycache__ and .vscode folders 2026-02-03 15:12:51 -03:00
diodiogod
b8fbcee67a Fix focus/modifier issues and improve multi-layer selection UX 2026-02-03 02:00:24 -03:00
diodiogod
d44d944f2d Fix: Restore drag-and-drop in layers panel 2026-02-02 23:15:08 -03:00
diodiogod
ab5d71597a Fix: Allow paste event to bubble for system clipboard access 2026-02-02 20:56:56 -03:00
diodiogod
ce4d332987 Fix Ctrl+V to paste from system clipboard when internal clipboard is empty 2026-02-02 18:22:54 -03:00
diodiogod
9b04729561 Enable cross-workflow node duplication with layers
Store canvas state in IndexedDB clipboard on copy, allowing nodes to be
duplicated with their layers preserved across different workflows.

When copying a node, the canvas state is stored in a special
'__clipboard__' entry that persists across workflow switches. On paste,
if the source node doesn't exist (indicating cross-workflow paste), the
system falls back to loading from the clipboard entry.
2026-01-19 18:24:42 -03:00
diodiogod
27ad139cd5 Add layer copy/paste and node duplication with layers
Implements two new features:
- Layer copy/paste within canvas using Ctrl+C/V
- Node duplication that preserves all layers

Layer Copy/Paste:
- Added Ctrl+V keyboard shortcut handler for pasting layers
- Intercept keydown events during capture phase to handle before ComfyUI
- Focus canvas when layer is clicked to ensure shortcuts work
- Prevent layers panel from stealing focus on mousedown

Node Duplication:
- Store source node ID during serialize for copy operations
- Track pending copy sources across node ID changes (-1 to real ID)
- Copy canvas state from source to destination in onAdded hook
- Use Map to persist copy metadata through node lifecycle
2026-01-19 17:57:14 -03:00
diodiogod
66cbcb641b Add retry logic for image loading validation
Increase robustness of image loading check before sending canvas data.
Now retries up to 5 times with 200ms delays (1 second total) instead
of a single 100ms wait.

This fixes the 'Failed to get confirmation from server' error that
appeared when executing workflows immediately after ComfyUI restart,
before images finished loading from IndexedDB.

Prevents workflow execution failures due to timing issues during
canvas initialization.
2026-01-17 20:30:36 -03:00
diodiogod
986e0a23a2 Fix canvas sizing bug by separating display and output dimensions
The canvas was getting corrupted to a small strip because of confusion
between two different dimension types:
- Output area dimensions (logical working area, e.g. 512x512)
- Display canvas dimensions (actual pixels shown on screen)

Root cause: Setting canvas.width/height attributes to match output area
while also using CSS width:100%/height:100% created conflicts. When
zooming or reloading, wrong dimensions would be read and saved.

Fix: Remove canvas element width/height attribute assignments. Let the
render loop control display size based on clientWidth/clientHeight.
Keep output area dimensions separate.

This prevents the canvas from being saved with corrupted tiny dimensions
and fixes the issue where canvas would only show in a small strip after
zooming or reloading workflows.
2026-01-17 15:03:00 -03:00
diodiogod
068ed9ee59 Skip sending canvas data for bypassed nodes
Fix critical issue where LayerForge was trying to send canvas data
even when the node was bypassed (mode === 4). This caused unnecessary
errors and blocked workflow execution.

Now properly checks node.mode before attempting to send data via
WebSocket, skipping bypassed nodes entirely.
2026-01-15 09:40:33 -03:00
diodiogod
4e5ef18d93 Fix canvas initialization and sizing bugs
- Add image loading validation before sending canvas data to server
  Prevents 'Failed to get confirmation' error when images haven't
  finished loading after workflow reload. Waits 100ms and checks
  if all layer images are complete before rendering output.

- Improve layer loading error handling in CanvasState
  Better logging when layers fail to load from IndexedDB.
  Allows empty canvas as valid state instead of failing.

- Add ResizeObserver for canvas container
  Fixes bug where canvas only shows in top half of node.
  Watches container size changes and triggers re-render to ensure
  canvas dimensions are correctly calculated after DOM layout.
2026-01-15 09:38:59 -03:00
diodiogod
be37966b45 Add DOM connection check to prevent capturing events in subgraphs
Ensure the canvas element is actually connected to the DOM before
handling paste events. This prevents LayerForge from capturing paste
events when navigating in subgraphs where the canvas is not visible.

Adds check for canvas.isConnected and document.body.contains() to
verify the canvas is part of the active DOM tree.
2026-01-14 16:11:22 -03:00
diodiogod
dd5fc5470f Fix keyboard shortcuts capturing events when node is unfocused
Prevent LayerForge from intercepting Ctrl+C, Ctrl+V, and other keyboard
shortcuts when the canvas is not focused. This was causing unwanted
popups and interfering with other nodes in ComfyUI.

Changes:
- Remove document.body focus check from handlePasteEvent
- Add focus validation to handleKeyDown before processing shortcuts
- Modifier keys (Ctrl, Shift, Alt, Meta) are still tracked globally
- All other shortcuts only trigger when canvas is focused

Fixes issue where paste events were captured globally regardless of focus.
2026-01-10 11:12:31 -03:00
Dariusz L
1f1d0aeb7d Update README.md 2025-11-13 17:10:29 +01:00
Dariusz L
da55d741d6 Update README.md 2025-11-13 16:37:25 +01:00
Dariusz L
959c47c29b Update README with quick links and compatibility info
Added quick start and workflow example links for easier navigation. Improved installation instructions and clarified manual install steps. Documented known incompatibility with Vue Nodes and provided guidance for reverting settings. Enhanced support section with actionable items.
2025-11-13 16:21:47 +01:00
Dariusz L
ab7ab9d1a8 Update README.md 2025-10-27 18:52:33 +01:00
Dariusz L
d8d33089d2 Update pyproject.toml 2025-10-27 17:21:34 +01:00
Dariusz L
de67252a87 Add grab icon for layer movement
Implemented grab icon feature in transform mode to move selected layers without changing selection, even when behind other layers. Added hover detection, cursor updates, and visual rendering in CanvasInteractions.ts and CanvasRenderer.ts.
2025-10-27 17:20:53 +01:00
Dariusz L
4acece1602 Update bug_report.yml 2025-09-11 19:08:52 +02:00
19 changed files with 749 additions and 152 deletions

View File

@@ -139,7 +139,7 @@ body:
- type: textarea - type: textarea
id: additional id: additional
attributes: attributes:
label: Additional Context, Environment (OS, ComfyUI versions, ResolutionMaster version) label: Additional Context, Environment (OS, ComfyUI versions, Comfyui-LayerForge version)
description: Any other information that might help (OS, GPU, specific nodes involved, etc.) description: Any other information that might help (OS, GPU, specific nodes involved, etc.)

View File

@@ -19,6 +19,15 @@
<img alt="JavaScript" src="https://img.shields.io/badge/-JavaScript-000000?logo=javascript&logoColor=F7DF1E&style=for-the-badge&logoWidth=20"> <img alt="JavaScript" src="https://img.shields.io/badge/-JavaScript-000000?logo=javascript&logoColor=F7DF1E&style=for-the-badge&logoWidth=20">
</p> </p>
<p align="center">
<strong>🔹 <a href="https://github.com/Azornes/Comfyui-LayerForge?tab=readme-ov-file#-installation">Quick Start</a></strong>
&nbsp; | &nbsp;
<strong>🧩 <a href="https://github.com/Azornes/Comfyui-LayerForge?tab=readme-ov-file#-workflow-example">Workflow Example</a></strong>
&nbsp; | &nbsp;
<strong>⚠️ <a href="https://github.com/Azornes/Comfyui-LayerForge?tab=readme-ov-file#%EF%B8%8F-known-issues--compatibility">Known Issues</a></strong>
</p>
### Why LayerForge? ### Why LayerForge?
- **Full Creative Control:** Move beyond simple image inputs. Composite, mask, and blend multiple elements without - **Full Creative Control:** Move beyond simple image inputs. Composite, mask, and blend multiple elements without
@@ -66,11 +75,12 @@ https://github.com/user-attachments/assets/9c7ce1de-873b-4a3b-8579-0fc67642af3a
## 🚀 Installation ## 🚀 Installation
### Install via ComfyUI-Manager ### Install via ComfyUI-Manager
* Search `Comfyui-LayerForge` in ComfyUI-Manager and click `Install` button. 1. Search `Comfyui-LayerForge` in ComfyUI-Manager and click `Install` button.
2. Restart ComfyUI.
### Manual Install ### Manual Install
1. Install [ComfyUi](https://github.com/comfyanonymous/ComfyUI). 1. Install [ComfyUi](https://github.com/comfyanonymous/ComfyUI). I use [portable](https://docs.comfy.org/installation/comfyui_portable_windows) version.
2. Clone this repo into `custom_modules`: 2. Clone this repo into `custom_nodes`:
```bash ```bash
cd ComfyUI/custom_nodes/ cd ComfyUI/custom_nodes/
git clone https://github.com/Azornes/Comfyui-LayerForge.git git clone https://github.com/Azornes/Comfyui-LayerForge.git
@@ -230,18 +240,24 @@ optional feature and requires a model.
--- ---
## 🔧 Troubleshooting ## ⚠️ Known Issues / Compatibility
### `node_id` not auto-filled → black output #### ○ Incompatibility with Modern Node Design (Vue Nodes)
> This node is **not compatible** with the new Vue Nodes display system.
>
> 🔧 **How to fix:**
> Go to **Settings → (search) "Vue Nodes" → Disable "Modern Node Design (Vue Nodes)"**.
In some cases, **ComfyUI doesn't auto-fill the `node_id`** when adding a node. ---
As a result, the node may produce a **completely black image** or not work at all.
**Workaround:** #### ○ `node_id` not auto-filled → black output
> In some cases, **ComfyUI doesnt auto-fill the `node_id`** when adding a node.
* Search node ID in ComfyUI settings. > This may cause the node to output a **completely black image** or fail to work.
* In NodesMap check "Enable node ID display" >
* Manually enter the correct `node_id` (match the ID Node "LayerForge" shown above the node, on the right side). > 🛠️ **Workaround:**
> - Open **Settings → NodesMap → Enable "Show node IDs"**
> - Find the correct ID for your node *(match the ID Node "LayerForge" shown above the node, on the right side)*.
> - Manually enter the correct `node_id` in the LayerForge node
> [!WARNING] > [!WARNING]
> This is a known issue and not yet fixed. > This is a known issue and not yet fixed.
@@ -256,10 +272,13 @@ This project is licensed under the MIT License. Feel free to use, modify, and di
--- ---
## 💖 Support / Sponsorship ## 💖 Support / Sponsorship
• ⭐ Give a star — it means a lot to me!
If youd like to support my work: • 🐛 Report a bug or suggest a feature
• 💖 If youd like to support my work:
👉 [GitHub Sponsors](https://github.com/sponsors/Azornes) 👉 [GitHub Sponsors](https://github.com/sponsors/Azornes)
---
## 🙏 Acknowledgments ## 🙏 Acknowledgments
Based on the original [**Comfyui-Ycanvas**](https://github.com/yichengup/Comfyui-Ycanvas) by yichengup. This fork Based on the original [**Comfyui-Ycanvas**](https://github.com/yichengup/Comfyui-Ycanvas) by yichengup. This fork

View File

@@ -443,8 +443,8 @@ export class Canvas {
* Inicjalizuje podstawowe właściwości canvas * Inicjalizuje podstawowe właściwości canvas
*/ */
initCanvas() { initCanvas() {
this.canvas.width = this.width; // Don't set canvas.width/height here - let the render loop handle it based on clientWidth/clientHeight
this.canvas.height = this.height; // this.width and this.height are for the OUTPUT AREA, not the display canvas
this.canvas.style.border = '1px solid black'; this.canvas.style.border = '1px solid black';
this.canvas.style.maxWidth = '100%'; this.canvas.style.maxWidth = '100%';
this.canvas.style.backgroundColor = '#606060'; this.canvas.style.backgroundColor = '#606060';

View File

@@ -197,6 +197,25 @@ export class CanvasIO {
} }
async _renderOutputData() { async _renderOutputData() {
log.info("=== RENDERING OUTPUT DATA FOR COMFYUI ==="); 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 // Użyj zunifikowanych funkcji z CanvasLayers
const imageBlob = await this.canvas.canvasLayers.getFlattenedCanvasAsBlob(); const imageBlob = await this.canvas.canvasLayers.getFlattenedCanvasAsBlob();
const maskBlob = await this.canvas.canvasLayers.getFlattenedMaskAsBlob(); const maskBlob = await this.canvas.canvasLayers.getFlattenedMaskAsBlob();

View File

@@ -41,6 +41,7 @@ export class CanvasInteractions {
canvasMoveRect: null, canvasMoveRect: null,
outputAreaTransformHandle: null, outputAreaTransformHandle: null,
outputAreaTransformAnchor: { x: 0, y: 0 }, outputAreaTransformAnchor: { x: 0, y: 0 },
hoveringGrabIcon: false,
}; };
this.originalLayerPositions = new Map(); this.originalLayerPositions = new Map();
} }
@@ -103,6 +104,8 @@ export class CanvasInteractions {
// Add a blur event listener to the window to reset key states // Add a blur event listener to the window to reset key states
window.addEventListener('blur', this.onBlur); window.addEventListener('blur', this.onBlur);
document.addEventListener('paste', this.onPaste); 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('mouseenter', this.onMouseEnter);
this.canvas.canvas.addEventListener('mouseleave', this.onMouseLeave); this.canvas.canvas.addEventListener('mouseleave', this.onMouseLeave);
this.canvas.canvas.addEventListener('dragover', this.onDragOver); this.canvas.canvas.addEventListener('dragover', this.onDragOver);
@@ -118,6 +121,8 @@ export class CanvasInteractions {
this.canvas.canvas.removeEventListener('wheel', this.onWheel); this.canvas.canvas.removeEventListener('wheel', this.onWheel);
this.canvas.canvas.removeEventListener('keydown', this.onKeyDown); this.canvas.canvas.removeEventListener('keydown', this.onKeyDown);
this.canvas.canvas.removeEventListener('keyup', this.onKeyUp); this.canvas.canvas.removeEventListener('keyup', this.onKeyUp);
// Remove document-level capture listener
document.removeEventListener('keydown', this.onKeyDown, { capture: true });
window.removeEventListener('blur', this.onBlur); window.removeEventListener('blur', this.onBlur);
document.removeEventListener('paste', this.onPaste); document.removeEventListener('paste', this.onPaste);
this.canvas.canvas.removeEventListener('mouseenter', this.onMouseEnter); this.canvas.canvas.removeEventListener('mouseenter', this.onMouseEnter);
@@ -151,6 +156,29 @@ export class CanvasInteractions {
} }
return false; return false;
} }
/**
* Sprawdza czy punkt znajduje się w obszarze ikony "grab" (środek layera)
* Zwraca layer, jeśli kliknięto w ikonę grab
*/
getGrabIconAtPosition(worldX, worldY) {
// Rozmiar ikony grab w pikselach światowych
const grabIconRadius = 20 / this.canvas.viewport.zoom;
for (const layer of this.canvas.canvasSelection.selectedLayers) {
if (!layer.visible)
continue;
const centerX = layer.x + layer.width / 2;
const centerY = layer.y + layer.height / 2;
// Sprawdź czy punkt jest w obszarze ikony grab (okrąg wokół środka)
const dx = worldX - centerX;
const dy = worldY - centerY;
const distanceSquared = dx * dx + dy * dy;
const radiusSquared = grabIconRadius * grabIconRadius;
if (distanceSquared <= radiusSquared) {
return layer;
}
}
return null;
}
resetInteractionState() { resetInteractionState() {
this.interaction.mode = 'none'; this.interaction.mode = 'none';
this.interaction.resizeHandle = null; this.interaction.resizeHandle = null;
@@ -164,6 +192,12 @@ export class CanvasInteractions {
} }
handleMouseDown(e) { handleMouseDown(e) {
this.canvas.canvas.focus(); 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 coords = this.getMouseCoordinates(e);
const mods = this.getModifierState(e); const mods = this.getModifierState(e);
if (this.interaction.mode === 'drawingMask') { if (this.interaction.mode === 'drawingMask') {
@@ -227,6 +261,14 @@ export class CanvasInteractions {
this.startLayerTransform(transformTarget.layer, transformTarget.handle, coords.world); this.startLayerTransform(transformTarget.layer, transformTarget.handle, coords.world);
return; return;
} }
// Check if clicking on grab icon of a selected layer
const grabIconLayer = this.getGrabIconAtPosition(coords.world.x, coords.world.y);
if (grabIconLayer) {
// Start dragging the selected layer(s) without changing selection
this.interaction.mode = 'potential-drag';
this.interaction.dragStart = { ...coords.world };
return;
}
const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(coords.world.x, coords.world.y); const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(coords.world.x, coords.world.y);
if (clickedLayerResult) { if (clickedLayerResult) {
this.prepareForDrag(clickedLayerResult.layer, coords.world); this.prepareForDrag(clickedLayerResult.layer, coords.world);
@@ -282,6 +324,13 @@ export class CanvasInteractions {
} }
break; break;
default: default:
// 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); this.updateCursor(coords.world);
// Update brush cursor on overlay if mask tool is active // Update brush cursor on overlay if mask tool is active
if (this.canvas.maskTool.isActive) { if (this.canvas.maskTool.isActive) {
@@ -480,14 +529,24 @@ export class CanvasInteractions {
return targetHeight / oldHeight; return targetHeight / oldHeight;
} }
handleKeyDown(e) { handleKeyDown(e) {
// Always track modifier keys regardless of focus
if (e.key === 'Control') if (e.key === 'Control')
this.interaction.isCtrlPressed = true; this.interaction.isCtrlPressed = true;
if (e.key === 'Meta') if (e.key === 'Meta')
this.interaction.isMetaPressed = true; this.interaction.isMetaPressed = true;
if (e.key === 'Shift') if (e.key === 'Shift')
this.interaction.isShiftPressed = true; this.interaction.isShiftPressed = true;
if (e.key === 'Alt') { if (e.key === 'Alt')
this.interaction.isAltPressed = true; 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(); e.preventDefault();
} }
if (e.key.toLowerCase() === 's') { if (e.key.toLowerCase() === 's') {
@@ -521,6 +580,17 @@ export class CanvasInteractions {
this.canvas.canvasLayers.copySelectedLayers(); this.canvas.canvasLayers.copySelectedLayers();
} }
break; 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: default:
handled = false; handled = false;
break; break;
@@ -617,6 +687,11 @@ export class CanvasInteractions {
this.canvas.canvas.style.cursor = 'grabbing'; this.canvas.canvas.style.cursor = 'grabbing';
return; return;
} }
// Check if hovering over grab icon
if (this.interaction.hoveringGrabIcon) {
this.canvas.canvas.style.cursor = 'grab';
return;
}
const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y); const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y);
if (transformTarget) { if (transformTarget) {
const handleName = transformTarget.handle; const handleName = transformTarget.handle;
@@ -669,12 +744,11 @@ export class CanvasInteractions {
if (mods.ctrl || mods.meta) { if (mods.ctrl || mods.meta) {
const index = this.canvas.canvasSelection.selectedLayers.indexOf(layer); const index = this.canvas.canvasSelection.selectedLayers.indexOf(layer);
if (index === -1) { if (index === -1) {
// Ctrl-clicking unselected layer: add to selection
this.canvas.canvasSelection.updateSelection([...this.canvas.canvasSelection.selectedLayers, layer]); this.canvas.canvasSelection.updateSelection([...this.canvas.canvasSelection.selectedLayers, layer]);
} }
else { // If already selected, do NOT deselect - allows dragging multiple layers with Ctrl held
const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l) => l !== layer); // User can use right-click in layers panel to deselect individual layers
this.canvas.canvasSelection.updateSelection(newSelection);
}
} }
else { else {
if (!this.canvas.canvasSelection.selectedLayers.includes(layer)) { if (!this.canvas.canvasSelection.selectedLayers.includes(layer)) {
@@ -1111,10 +1185,13 @@ export class CanvasInteractions {
} }
} }
async handlePasteEvent(e) { 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 || const shouldHandle = this.canvas.isMouseOver ||
this.canvas.canvas.contains(document.activeElement) || this.canvas.canvas.contains(document.activeElement) ||
document.activeElement === this.canvas.canvas || document.activeElement === this.canvas.canvas;
document.activeElement === document.body;
if (!shouldHandle) { if (!shouldHandle) {
log.debug("Paste event ignored - not focused on canvas"); log.debug("Paste event ignored - not focused on canvas");
return; return;

View File

@@ -1100,8 +1100,8 @@ export class CanvasLayers {
this.canvas.width = width; this.canvas.width = width;
this.canvas.height = height; this.canvas.height = height;
this.canvas.maskTool.resize(width, height); this.canvas.maskTool.resize(width, height);
this.canvas.canvas.width = width; // Don't set canvas.width/height - the render loop will handle display size
this.canvas.canvas.height = height; // this.canvas.width/height are for OUTPUT AREA dimensions, not display canvas
this.canvas.render(); this.canvas.render();
if (saveHistory) { if (saveHistory) {
this.canvas.canvasState.saveStateToDB(); this.canvas.canvasState.saveStateToDB();

View File

@@ -123,6 +123,26 @@ export class CanvasLayersPanel {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
this.deleteSelectedLayers(); 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'); log.debug('Panel structure created');
@@ -329,6 +349,8 @@ export class CanvasLayersPanel {
// Aktualizuj tylko wygląd (klasy CSS), bez niszczenia DOM // Aktualizuj tylko wygląd (klasy CSS), bez niszczenia DOM
this.updateSelectionAppearance(); this.updateSelectionAppearance();
this.updateButtonStates(); 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}`); log.debug(`Layer clicked: ${layer.name}, selection count: ${this.canvas.canvasSelection.selectedLayers.length}`);
} }
startEditingLayerName(nameElement, layer) { startEditingLayerName(nameElement, layer) {

View File

@@ -141,6 +141,10 @@ export class CanvasRenderer {
ctx.restore(); ctx.restore();
} }
}); });
// Draw grab icons for selected layers when hovering
if (this.canvas.canvasInteractions.interaction.hoveringGrabIcon) {
this.drawGrabIcons(ctx);
}
this.drawCanvasOutline(ctx); this.drawCanvasOutline(ctx);
this.drawOutputAreaExtensionPreview(ctx); // Draw extension preview this.drawOutputAreaExtensionPreview(ctx); // Draw extension preview
this.drawPendingGenerationAreas(ctx); // Draw snapshot outlines this.drawPendingGenerationAreas(ctx); // Draw snapshot outlines
@@ -833,6 +837,55 @@ export class CanvasRenderer {
// Just ensure it's the right size // Just ensure it's the right size
this.updateOverlaySize(); this.updateOverlaySize();
} }
/**
* Draw grab icons in the center of selected layers
*/
drawGrabIcons(ctx) {
const selectedLayers = this.canvas.canvasSelection.selectedLayers;
if (selectedLayers.length === 0)
return;
const iconRadius = 20 / this.canvas.viewport.zoom;
const innerRadius = 12 / this.canvas.viewport.zoom;
selectedLayers.forEach((layer) => {
if (!layer.visible)
return;
const centerX = layer.x + layer.width / 2;
const centerY = layer.y + layer.height / 2;
ctx.save();
// Draw outer circle (background)
ctx.beginPath();
ctx.arc(centerX, centerY, iconRadius, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(0, 150, 255, 0.7)';
ctx.fill();
ctx.strokeStyle = 'rgba(255, 255, 255, 0.9)';
ctx.lineWidth = 2 / this.canvas.viewport.zoom;
ctx.stroke();
// Draw hand/grab icon (simplified)
ctx.fillStyle = 'rgba(255, 255, 255, 0.95)';
ctx.strokeStyle = 'rgba(255, 255, 255, 0.95)';
ctx.lineWidth = 1.5 / this.canvas.viewport.zoom;
// Draw four dots representing grab points
const dotRadius = 2 / this.canvas.viewport.zoom;
const dotDistance = 6 / this.canvas.viewport.zoom;
// Top-left
ctx.beginPath();
ctx.arc(centerX - dotDistance, centerY - dotDistance, dotRadius, 0, Math.PI * 2);
ctx.fill();
// Top-right
ctx.beginPath();
ctx.arc(centerX + dotDistance, centerY - dotDistance, dotRadius, 0, Math.PI * 2);
ctx.fill();
// Bottom-left
ctx.beginPath();
ctx.arc(centerX - dotDistance, centerY + dotDistance, dotRadius, 0, Math.PI * 2);
ctx.fill();
// Bottom-right
ctx.beginPath();
ctx.arc(centerX + dotDistance, centerY + dotDistance, dotRadius, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
});
}
/** /**
* Draw transform handles for output area when in transform mode * Draw transform handles for output area when in transform mode
*/ */

View File

@@ -88,10 +88,10 @@ export class CanvasState {
log.debug(`Output Area resized to ${this.canvas.width}x${this.canvas.height} and viewport set.`); log.debug(`Output Area resized to ${this.canvas.width}x${this.canvas.height} and viewport set.`);
const loadedLayers = await this._loadLayers(savedState.layers); const loadedLayers = await this._loadLayers(savedState.layers);
this.canvas.layers = loadedLayers.filter((l) => l !== null); this.canvas.layers = loadedLayers.filter((l) => 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) { if (this.canvas.layers.length === 0 && savedState.layers.length > 0) {
log.warn("No valid layers loaded, state may be corrupted."); 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.`);
return false; // Don't return false - allow empty canvas to be valid
} }
this.canvas.updateSelectionAfterHistory(); this.canvas.updateSelectionAfterHistory();
this.canvas.render(); this.canvas.render();

View File

@@ -884,6 +884,12 @@ async function createCanvasWidget(node, widget, app) {
if (controlsElement) { if (controlsElement) {
resizeObserver.observe(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', () => { canvas.canvas.addEventListener('focus', () => {
canvasContainer.classList.add('has-focus'); canvasContainer.classList.add('has-focus');
}); });
@@ -1038,13 +1044,20 @@ app.registerExtension({
log.info(`Found ${canvasNodeInstances.size} CanvasNode(s). Sending data via WebSocket...`); log.info(`Found ${canvasNodeInstances.size} CanvasNode(s). Sending data via WebSocket...`);
const sendPromises = []; const sendPromises = [];
for (const [nodeId, canvasWidget] of canvasNodeInstances.entries()) { for (const [nodeId, canvasWidget] of canvasNodeInstances.entries()) {
if (app.graph.getNodeById(nodeId) && canvasWidget.canvas && canvasWidget.canvas.canvasIO) { const node = app.graph.getNodeById(nodeId);
log.debug(`Sending data for canvas node ${nodeId}`); if (!node) {
sendPromises.push(canvasWidget.canvas.canvasIO.sendDataViaWebSocket(nodeId));
}
else {
log.warn(`Node ${nodeId} not found in graph, removing from instances map.`); log.warn(`Node ${nodeId} not found in graph, removing from instances map.`);
canvasNodeInstances.delete(nodeId); 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 { try {
@@ -1063,6 +1076,8 @@ app.registerExtension({
}, },
async beforeRegisterNodeDef(nodeType, nodeData, app) { async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeType.comfyClass === "LayerForgeNode") { if (nodeType.comfyClass === "LayerForgeNode") {
// Map to track pending copy sources across node ID changes
const pendingCopySources = new Map();
const onNodeCreated = nodeType.prototype.onNodeCreated; const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () { nodeType.prototype.onNodeCreated = function () {
log.debug("CanvasNode onNodeCreated: Base widget setup."); log.debug("CanvasNode onNodeCreated: Base widget setup.");
@@ -1093,6 +1108,43 @@ app.registerExtension({
log.info(`Registered CanvasNode instance for ID: ${this.id}`); log.info(`Registered CanvasNode instance for ID: ${this.id}`);
// Store the canvas widget on the node // Store the canvas widget on the node
this.canvasWidget = canvasWidget; 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 // Check if there are already connected inputs
setTimeout(() => { setTimeout(() => {
if (this.inputs && this.inputs.length > 0) { if (this.inputs && this.inputs.length > 0) {
@@ -1258,6 +1310,47 @@ app.registerExtension({
} }
return onRemoved?.apply(this, arguments); 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; const originalGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function (_, options) { nodeType.prototype.getExtraMenuOptions = function (_, options) {
// FIRST: Call original to let other extensions add their options // FIRST: Call original to let other extensions add their options

View File

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

View File

@@ -578,8 +578,8 @@ export class Canvas {
* Inicjalizuje podstawowe właściwości canvas * Inicjalizuje podstawowe właściwości canvas
*/ */
initCanvas() { initCanvas() {
this.canvas.width = this.width; // Don't set canvas.width/height here - let the render loop handle it based on clientWidth/clientHeight
this.canvas.height = this.height; // this.width and this.height are for the OUTPUT AREA, not the display canvas
this.canvas.style.border = '1px solid black'; this.canvas.style.border = '1px solid black';
this.canvas.style.maxWidth = '100%'; this.canvas.style.maxWidth = '100%';
this.canvas.style.backgroundColor = '#606060'; this.canvas.style.backgroundColor = '#606060';

View File

@@ -217,11 +217,34 @@ export class CanvasIO {
async _renderOutputData(): Promise<{ image: string, mask: string }> { async _renderOutputData(): Promise<{ image: string, mask: string }> {
log.info("=== RENDERING OUTPUT DATA FOR COMFYUI ==="); 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 // Użyj zunifikowanych funkcji z CanvasLayers
const imageBlob = await this.canvas.canvasLayers.getFlattenedCanvasAsBlob(); const imageBlob = await this.canvas.canvasLayers.getFlattenedCanvasAsBlob();
const maskBlob = await this.canvas.canvasLayers.getFlattenedMaskAsBlob(); const maskBlob = await this.canvas.canvasLayers.getFlattenedMaskAsBlob();
if (!imageBlob || !maskBlob) { if (!imageBlob || !maskBlob) {
throw new Error("Failed to generate canvas or mask blobs"); throw new Error("Failed to generate canvas or mask blobs");
} }

View File

@@ -51,6 +51,7 @@ interface InteractionState {
canvasMoveRect: { x: number, y: number, width: number, height: number } | null; canvasMoveRect: { x: number, y: number, width: number, height: number } | null;
outputAreaTransformHandle: string | null; outputAreaTransformHandle: string | null;
outputAreaTransformAnchor: Point; outputAreaTransformAnchor: Point;
hoveringGrabIcon: boolean;
} }
export class CanvasInteractions { export class CanvasInteractions {
@@ -98,6 +99,7 @@ export class CanvasInteractions {
canvasMoveRect: null, canvasMoveRect: null,
outputAreaTransformHandle: null, outputAreaTransformHandle: null,
outputAreaTransformAnchor: { x: 0, y: 0 }, outputAreaTransformAnchor: { x: 0, y: 0 },
hoveringGrabIcon: false,
}; };
this.originalLayerPositions = new Map(); this.originalLayerPositions = new Map();
} }
@@ -130,16 +132,16 @@ export class CanvasInteractions {
const mouseBufferY = (worldCoords.y - this.canvas.viewport.y) * this.canvas.viewport.zoom; 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)); const newZoom = Math.max(0.1, Math.min(10, this.canvas.viewport.zoom * zoomFactor));
this.canvas.viewport.zoom = newZoom; this.canvas.viewport.zoom = newZoom;
this.canvas.viewport.x = worldCoords.x - (mouseBufferX / this.canvas.viewport.zoom); this.canvas.viewport.x = worldCoords.x - (mouseBufferX / this.canvas.viewport.zoom);
this.canvas.viewport.y = worldCoords.y - (mouseBufferY / 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 // Update stroke overlay if mask tool is drawing during zoom
if (this.canvas.maskTool.isDrawing) { if (this.canvas.maskTool.isDrawing) {
this.canvas.maskTool.handleViewportChange(); this.canvas.maskTool.handleViewportChange();
} }
this.canvas.onViewportChange?.(); this.canvas.onViewportChange?.();
} }
@@ -174,6 +176,9 @@ export class CanvasInteractions {
document.addEventListener('paste', this.onPaste as unknown as EventListener); 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('mouseenter', this.onMouseEnter as EventListener);
this.canvas.canvas.addEventListener('mouseleave', this.onMouseLeave as EventListener); this.canvas.canvas.addEventListener('mouseleave', this.onMouseLeave as EventListener);
@@ -193,6 +198,9 @@ export class CanvasInteractions {
this.canvas.canvas.removeEventListener('keydown', this.onKeyDown as EventListener); this.canvas.canvas.removeEventListener('keydown', this.onKeyDown as EventListener);
this.canvas.canvas.removeEventListener('keyup', this.onKeyUp 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); window.removeEventListener('blur', this.onBlur);
document.removeEventListener('paste', this.onPaste as unknown as EventListener); document.removeEventListener('paste', this.onPaste as unknown as EventListener);
@@ -226,7 +234,7 @@ export class CanvasInteractions {
const rotatedY = dx * Math.sin(rad) + dy * Math.cos(rad); const rotatedY = dx * Math.sin(rad) + dy * Math.cos(rad);
// Sprawdź czy punkt jest wewnątrz prostokąta layera // 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) { Math.abs(rotatedY) <= layer.height / 2) {
return true; return true;
} }
@@ -234,6 +242,33 @@ export class CanvasInteractions {
return false; return false;
} }
/**
* Sprawdza czy punkt znajduje się w obszarze ikony "grab" (środek layera)
* Zwraca layer, jeśli kliknięto w ikonę grab
*/
getGrabIconAtPosition(worldX: number, worldY: number): Layer | null {
// Rozmiar ikony grab w pikselach światowych
const grabIconRadius = 20 / this.canvas.viewport.zoom;
for (const layer of this.canvas.canvasSelection.selectedLayers) {
if (!layer.visible) continue;
const centerX = layer.x + layer.width / 2;
const centerY = layer.y + layer.height / 2;
// Sprawdź czy punkt jest w obszarze ikony grab (okrąg wokół środka)
const dx = worldX - centerX;
const dy = worldY - centerY;
const distanceSquared = dx * dx + dy * dy;
const radiusSquared = grabIconRadius * grabIconRadius;
if (distanceSquared <= radiusSquared) {
return layer;
}
}
return null;
}
resetInteractionState(): void { resetInteractionState(): void {
this.interaction.mode = 'none'; this.interaction.mode = 'none';
this.interaction.resizeHandle = null; this.interaction.resizeHandle = null;
@@ -248,6 +283,14 @@ export class CanvasInteractions {
handleMouseDown(e: MouseEvent): void { handleMouseDown(e: MouseEvent): void {
this.canvas.canvas.focus(); 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 coords = this.getMouseCoordinates(e);
const mods = this.getModifierState(e); const mods = this.getModifierState(e);
@@ -296,11 +339,11 @@ export class CanvasInteractions {
this.startCanvasResize(coords.world); this.startCanvasResize(coords.world);
return; return;
} }
// 2. Inne przyciski myszy // 2. Inne przyciski myszy
if (e.button === 2) { // Prawy przycisk myszy if (e.button === 2) { // Prawy przycisk myszy
this.preventEventDefaults(e); this.preventEventDefaults(e);
// Sprawdź czy kliknięto w obszarze któregokolwiek z zaznaczonych layerów (niezależnie od przykrycia) // 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)) { if (this.isPointInSelectedLayers(coords.world.x, coords.world.y)) {
// Nowa logika przekazuje tylko współrzędne świata, menu pozycjonuje się samo // Nowa logika przekazuje tylko współrzędne świata, menu pozycjonuje się samo
@@ -320,12 +363,21 @@ export class CanvasInteractions {
return; return;
} }
// Check if clicking on grab icon of a selected layer
const grabIconLayer = this.getGrabIconAtPosition(coords.world.x, coords.world.y);
if (grabIconLayer) {
// Start dragging the selected layer(s) without changing selection
this.interaction.mode = 'potential-drag';
this.interaction.dragStart = { ...coords.world };
return;
}
const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(coords.world.x, coords.world.y); const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(coords.world.x, coords.world.y);
if (clickedLayerResult) { if (clickedLayerResult) {
this.prepareForDrag(clickedLayerResult.layer, coords.world); this.prepareForDrag(clickedLayerResult.layer, coords.world);
return; return;
} }
// 4. Domyślna akcja na tle (lewy przycisk bez modyfikatorów) // 4. Domyślna akcja na tle (lewy przycisk bez modyfikatorów)
this.startPanning(e, true); // clearSelection = true this.startPanning(e, true); // clearSelection = true
} }
@@ -333,7 +385,7 @@ export class CanvasInteractions {
handleMouseMove(e: MouseEvent): void { handleMouseMove(e: MouseEvent): void {
const coords = this.getMouseCoordinates(e); const coords = this.getMouseCoordinates(e);
this.canvas.lastMousePosition = coords.world; // Zawsze aktualizuj ostatnią pozycję myszy this.canvas.lastMousePosition = coords.world; // Zawsze aktualizuj ostatnią pozycję myszy
// Sprawdź, czy rozpocząć przeciąganie // Sprawdź, czy rozpocząć przeciąganie
if (this.interaction.mode === 'potential-drag') { if (this.interaction.mode === 'potential-drag') {
const dx = coords.world.x - this.interaction.dragStart.x; const dx = coords.world.x - this.interaction.dragStart.x;
@@ -346,7 +398,7 @@ export class CanvasInteractions {
}); });
} }
} }
switch (this.interaction.mode) { switch (this.interaction.mode) {
case 'drawingMask': case 'drawingMask':
this.canvas.maskTool.handleMouseMove(coords.world, coords.view); this.canvas.maskTool.handleMouseMove(coords.world, coords.view);
@@ -378,6 +430,15 @@ export class CanvasInteractions {
} }
break; break;
default: default:
// 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); this.updateCursor(coords.world);
// Update brush cursor on overlay if mask tool is active // Update brush cursor on overlay if mask tool is active
if (this.canvas.maskTool.isActive) { if (this.canvas.maskTool.isActive) {
@@ -394,7 +455,7 @@ export class CanvasInteractions {
handleMouseUp(e: MouseEvent): void { handleMouseUp(e: MouseEvent): void {
const coords = this.getMouseCoordinates(e); const coords = this.getMouseCoordinates(e);
if (this.interaction.mode === 'drawingMask') { if (this.interaction.mode === 'drawingMask') {
this.canvas.maskTool.handleMouseUp(coords.view); this.canvas.maskTool.handleMouseUp(coords.view);
// Render only once after drawing is complete // Render only once after drawing is complete
@@ -423,7 +484,7 @@ export class CanvasInteractions {
if (this.interaction.mode === 'resizing' && this.interaction.transformingLayer?.cropMode) { if (this.interaction.mode === 'resizing' && this.interaction.transformingLayer?.cropMode) {
this.canvas.canvasLayers.handleCropBoundsTransformEnd(this.interaction.transformingLayer); this.canvas.canvasLayers.handleCropBoundsTransformEnd(this.interaction.transformingLayer);
} }
// Handle end of scale transformation (normal transform mode) before resetting interaction state // Handle end of scale transformation (normal transform mode) before resetting interaction state
if (this.interaction.mode === 'resizing' && this.interaction.transformingLayer && !this.interaction.transformingLayer.cropMode) { if (this.interaction.mode === 'resizing' && this.interaction.transformingLayer && !this.interaction.transformingLayer.cropMode) {
this.canvas.canvasLayers.handleScaleTransformEnd(this.interaction.transformingLayer); this.canvas.canvasLayers.handleScaleTransformEnd(this.interaction.transformingLayer);
@@ -447,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(`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(`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)}`); 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) => { this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer, index: number) => {
const relativeToOutput = { const relativeToOutput = {
x: layer.x - bounds.x, x: layer.x - bounds.x,
@@ -494,7 +555,7 @@ export class CanvasInteractions {
handleWheel(e: WheelEvent): void { handleWheel(e: WheelEvent): void {
this.preventEventDefaults(e); this.preventEventDefaults(e);
const coords = this.getMouseCoordinates(e); const coords = this.getMouseCoordinates(e);
if (this.canvas.maskTool.isActive || this.canvas.canvasSelection.selectedLayers.length === 0) { if (this.canvas.maskTool.isActive || this.canvas.canvasSelection.selectedLayers.length === 0) {
// Zoom operation for mask tool or when no layers selected // Zoom operation for mask tool or when no layers selected
const zoomFactor = e.deltaY < 0 ? 1.1 : 1 / 1.1; const zoomFactor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
@@ -502,7 +563,7 @@ export class CanvasInteractions {
} else { } else {
// Check if mouse is over any selected layer // Check if mouse is over any selected layer
const isOverSelectedLayer = this.isPointInSelectedLayers(coords.world.x, coords.world.y); const isOverSelectedLayer = this.isPointInSelectedLayers(coords.world.x, coords.world.y);
if (isOverSelectedLayer) { if (isOverSelectedLayer) {
// Layer transformation when layers are selected and mouse is over selected layer // Layer transformation when layers are selected and mouse is over selected layer
this.handleLayerWheelTransformation(e); this.handleLayerWheelTransformation(e);
@@ -512,7 +573,7 @@ export class CanvasInteractions {
this.performZoomOperation(coords.world, zoomFactor); this.performZoomOperation(coords.world, zoomFactor);
} }
} }
this.canvas.render(); this.canvas.render();
if (!this.canvas.maskTool.isActive) { if (!this.canvas.maskTool.isActive) {
this.canvas.requestSaveState(); this.canvas.requestSaveState();
@@ -568,7 +629,7 @@ export class CanvasInteractions {
layer.height *= scaleFactor; layer.height *= scaleFactor;
layer.x += (oldWidth - layer.width) / 2; layer.x += (oldWidth - layer.width) / 2;
layer.y += (oldHeight - layer.height) / 2; layer.y += (oldHeight - layer.height) / 2;
// Handle wheel scaling end for layers with blend area // Handle wheel scaling end for layers with blend area
this.canvas.canvasLayers.handleWheelScalingEnd(layer); this.canvas.canvasLayers.handleWheelScalingEnd(layer);
} }
@@ -584,11 +645,11 @@ export class CanvasInteractions {
} else { } else {
targetHeight = (Math.ceil(oldHeight / gridSize) - 1) * gridSize; targetHeight = (Math.ceil(oldHeight / gridSize) - 1) * gridSize;
} }
if (targetHeight < gridSize / 2) { if (targetHeight < gridSize / 2) {
targetHeight = gridSize / 2; targetHeight = gridSize / 2;
} }
if (Math.abs(oldHeight - targetHeight) < 1) { if (Math.abs(oldHeight - targetHeight) < 1) {
if (direction > 0) targetHeight += gridSize; if (direction > 0) targetHeight += gridSize;
else targetHeight -= gridSize; else targetHeight -= gridSize;
@@ -599,11 +660,23 @@ export class CanvasInteractions {
} }
handleKeyDown(e: KeyboardEvent): void { handleKeyDown(e: KeyboardEvent): void {
// Always track modifier keys regardless of focus
if (e.key === 'Control') this.interaction.isCtrlPressed = true; if (e.key === 'Control') this.interaction.isCtrlPressed = true;
if (e.key === 'Meta') this.interaction.isMetaPressed = true; if (e.key === 'Meta') this.interaction.isMetaPressed = true;
if (e.key === 'Shift') this.interaction.isShiftPressed = 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') { if (e.key === 'Alt') {
this.interaction.isAltPressed = true;
e.preventDefault(); e.preventDefault();
} }
if (e.key.toLowerCase() === 's') { if (e.key.toLowerCase() === 's') {
@@ -617,7 +690,7 @@ export class CanvasInteractions {
this.canvas.shapeTool.activate(); this.canvas.shapeTool.activate();
return; return;
} }
// Globalne skróty (Undo/Redo/Copy/Paste) // Globalne skróty (Undo/Redo/Copy/Paste)
const mods = this.getModifierState(e); const mods = this.getModifierState(e);
if (mods.ctrl || mods.meta) { if (mods.ctrl || mods.meta) {
@@ -638,6 +711,17 @@ export class CanvasInteractions {
this.canvas.canvasLayers.copySelectedLayers(); this.canvas.canvasLayers.copySelectedLayers();
} }
break; 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: default:
handled = false; handled = false;
break; break;
@@ -653,7 +737,7 @@ export class CanvasInteractions {
if (this.canvas.canvasSelection.selectedLayers.length > 0) { if (this.canvas.canvasSelection.selectedLayers.length > 0) {
const step = mods.shift ? 10 : 1; const step = mods.shift ? 10 : 1;
let needsRender = false; let needsRender = false;
// Używamy e.code dla spójności i niezależności od układu klawiatury // Używamy e.code dla spójności i niezależności od układu klawiatury
const movementKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'BracketLeft', 'BracketRight']; const movementKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'BracketLeft', 'BracketRight'];
if (movementKeys.includes(e.code)) { if (movementKeys.includes(e.code)) {
@@ -677,7 +761,7 @@ export class CanvasInteractions {
this.canvas.canvasSelection.removeSelectedLayers(); this.canvas.canvasSelection.removeSelectedLayers();
return; return;
} }
if (needsRender) { if (needsRender) {
this.canvas.render(); this.canvas.render();
} }
@@ -723,7 +807,7 @@ export class CanvasInteractions {
this.canvas.saveState(); this.canvas.saveState();
this.canvas.canvasState.saveStateToDB(); this.canvas.canvasState.saveStateToDB();
} }
// Reset interaction mode if it's something that can get "stuck" // Reset interaction mode if it's something that can get "stuck"
if (this.interaction.mode !== 'none' && this.interaction.mode !== 'drawingMask') { if (this.interaction.mode !== 'none' && this.interaction.mode !== 'drawingMask') {
this.resetInteractionState(); this.resetInteractionState();
@@ -738,6 +822,12 @@ export class CanvasInteractions {
return; return;
} }
// Check if hovering over grab icon
if (this.interaction.hoveringGrabIcon) {
this.canvas.canvas.style.cursor = 'grab';
return;
}
const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y); const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y);
if (transformTarget) { if (transformTarget) {
@@ -767,7 +857,7 @@ export class CanvasInteractions {
originalHeight: layer.originalHeight, originalHeight: layer.originalHeight,
cropBounds: layer.cropBounds ? { ...layer.cropBounds } : undefined cropBounds: layer.cropBounds ? { ...layer.cropBounds } : undefined
}; };
this.interaction.dragStart = {...worldCoords}; this.interaction.dragStart = { ...worldCoords };
if (handle === 'rot') { if (handle === 'rot') {
this.interaction.mode = 'rotating'; this.interaction.mode = 'rotating';
@@ -791,19 +881,19 @@ export class CanvasInteractions {
if (mods.ctrl || mods.meta) { if (mods.ctrl || mods.meta) {
const index = this.canvas.canvasSelection.selectedLayers.indexOf(layer); const index = this.canvas.canvasSelection.selectedLayers.indexOf(layer);
if (index === -1) { if (index === -1) {
// Ctrl-clicking unselected layer: add to selection
this.canvas.canvasSelection.updateSelection([...this.canvas.canvasSelection.selectedLayers, layer]); 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 { } else {
if (!this.canvas.canvasSelection.selectedLayers.includes(layer)) { if (!this.canvas.canvasSelection.selectedLayers.includes(layer)) {
this.canvas.canvasSelection.updateSelection([layer]); this.canvas.canvasSelection.updateSelection([layer]);
} }
} }
this.interaction.mode = 'potential-drag'; this.interaction.mode = 'potential-drag';
this.interaction.dragStart = {...worldCoords}; this.interaction.dragStart = { ...worldCoords };
} }
startPanning(e: MouseEvent, clearSelection: boolean = true): void { startPanning(e: MouseEvent, clearSelection: boolean = true): void {
@@ -819,8 +909,8 @@ export class CanvasInteractions {
this.interaction.mode = 'resizingCanvas'; this.interaction.mode = 'resizingCanvas';
const startX = snapToGrid(worldCoords.x); const startX = snapToGrid(worldCoords.x);
const startY = snapToGrid(worldCoords.y); const startY = snapToGrid(worldCoords.y);
this.interaction.canvasResizeStart = {x: startX, y: startY}; this.interaction.canvasResizeStart = { x: startX, y: startY };
this.interaction.canvasResizeRect = {x: startX, y: startY, width: 0, height: 0}; this.interaction.canvasResizeRect = { x: startX, y: startY, width: 0, height: 0 };
this.canvas.render(); this.canvas.render();
} }
@@ -834,7 +924,7 @@ export class CanvasInteractions {
updateCanvasMove(worldCoords: Point): void { updateCanvasMove(worldCoords: Point): void {
const dx = worldCoords.x - this.interaction.dragStart.x; const dx = worldCoords.x - this.interaction.dragStart.x;
const dy = worldCoords.y - this.interaction.dragStart.y; const dy = worldCoords.y - this.interaction.dragStart.y;
// Po prostu przesuwamy outputAreaBounds // Po prostu przesuwamy outputAreaBounds
const bounds = this.canvas.outputAreaBounds; const bounds = this.canvas.outputAreaBounds;
this.interaction.canvasMoveRect = { this.interaction.canvasMoveRect = {
@@ -858,11 +948,11 @@ export class CanvasInteractions {
width: moveRect.width, width: moveRect.width,
height: moveRect.height height: moveRect.height
}; };
// Update mask canvas to ensure it covers the new output area position // Update mask canvas to ensure it covers the new output area position
this.canvas.maskTool.updateMaskCanvasForOutputArea(); this.canvas.maskTool.updateMaskCanvasForOutputArea();
} }
this.canvas.render(); this.canvas.render();
this.canvas.saveState(); this.canvas.saveState();
} }
@@ -872,13 +962,13 @@ export class CanvasInteractions {
const dy = e.clientY - this.interaction.panStart.y; const dy = e.clientY - this.interaction.panStart.y;
this.canvas.viewport.x -= dx / this.canvas.viewport.zoom; this.canvas.viewport.x -= dx / this.canvas.viewport.zoom;
this.canvas.viewport.y -= dy / 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 // Update stroke overlay if mask tool is drawing during pan
if (this.canvas.maskTool.isDrawing) { if (this.canvas.maskTool.isDrawing) {
this.canvas.maskTool.handleViewportChange(); this.canvas.maskTool.handleViewportChange();
} }
this.canvas.render(); this.canvas.render();
this.canvas.onViewportChange?.(); this.canvas.onViewportChange?.();
} }
@@ -941,7 +1031,7 @@ export class CanvasInteractions {
const o = this.interaction.transformOrigin; const o = this.interaction.transformOrigin;
if (!o) return; if (!o) return;
const handle = this.interaction.resizeHandle; const handle = this.interaction.resizeHandle;
const anchor = this.interaction.resizeAnchor; const anchor = this.interaction.resizeAnchor;
const rad = o.rotation * Math.PI / 180; const rad = o.rotation * Math.PI / 180;
@@ -959,7 +1049,7 @@ export class CanvasInteractions {
// Determine sign based on handle // Determine sign based on handle
const signX = handle?.includes('e') ? 1 : (handle?.includes('w') ? -1 : 0); const signX = handle?.includes('e') ? 1 : (handle?.includes('w') ? -1 : 0);
const signY = handle?.includes('s') ? 1 : (handle?.includes('n') ? -1 : 0); const signY = handle?.includes('s') ? 1 : (handle?.includes('n') ? -1 : 0);
localVecX *= signX; localVecX *= signX;
localVecY *= signY; localVecY *= signY;
@@ -969,13 +1059,13 @@ export class CanvasInteractions {
if (layer.cropMode && o.cropBounds && o.originalWidth && o.originalHeight) { if (layer.cropMode && o.cropBounds && o.originalWidth && o.originalHeight) {
// CROP MODE: Calculate delta based on mouse movement and apply to cropBounds. // 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. // Calculate mouse movement since drag start, in the layer's local coordinate system.
const dragStartX_local = this.interaction.dragStart.x - (o.centerX ?? 0); const dragStartX_local = this.interaction.dragStart.x - (o.centerX ?? 0);
const dragStartY_local = this.interaction.dragStart.y - (o.centerY ?? 0); const dragStartY_local = this.interaction.dragStart.y - (o.centerY ?? 0);
const mouseX_local = mouseX - (o.centerX ?? 0); const mouseX_local = mouseX - (o.centerX ?? 0);
const mouseY_local = mouseY - (o.centerY ?? 0); const mouseY_local = mouseY - (o.centerY ?? 0);
// Rotate mouse delta into the layer's unrotated frame // Rotate mouse delta into the layer's unrotated frame
const deltaX_world = mouseX_local - dragStartX_local; const deltaX_world = mouseX_local - dragStartX_local;
const deltaY_world = mouseY_local - dragStartY_local; const deltaY_world = mouseY_local - dragStartY_local;
@@ -988,20 +1078,20 @@ export class CanvasInteractions {
if (layer.flipV) { if (layer.flipV) {
mouseDeltaY_local *= -1; mouseDeltaY_local *= -1;
} }
// Convert the on-screen mouse delta to an image-space delta. // Convert the on-screen mouse delta to an image-space delta.
const screenToImageScaleX = o.originalWidth / o.width; const screenToImageScaleX = o.originalWidth / o.width;
const screenToImageScaleY = o.originalHeight / o.height; const screenToImageScaleY = o.originalHeight / o.height;
const delta_image_x = mouseDeltaX_local * screenToImageScaleX; const delta_image_x = mouseDeltaX_local * screenToImageScaleX;
const delta_image_y = mouseDeltaY_local * screenToImageScaleY; const delta_image_y = mouseDeltaY_local * screenToImageScaleY;
let newCropBounds = { ...o.cropBounds }; // Start with the bounds from the beginning of the drag 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 // Apply the image-space delta to the appropriate edges of the crop bounds
const isFlippedH = layer.flipH; const isFlippedH = layer.flipH;
const isFlippedV = layer.flipV; const isFlippedV = layer.flipV;
if (handle?.includes('w')) { if (handle?.includes('w')) {
if (isFlippedH) newCropBounds.width += delta_image_x; if (isFlippedH) newCropBounds.width += delta_image_x;
else { else {
@@ -1028,10 +1118,10 @@ export class CanvasInteractions {
newCropBounds.height -= delta_image_y; newCropBounds.height -= delta_image_y;
} else 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 (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; newCropBounds.width = 1;
} }
if (newCropBounds.height < 1) { if (newCropBounds.height < 1) {
@@ -1059,7 +1149,7 @@ export class CanvasInteractions {
// TRANSFORM MODE: Resize the layer's main transform frame // TRANSFORM MODE: Resize the layer's main transform frame
let newWidth = localVecX; let newWidth = localVecX;
let newHeight = localVecY; let newHeight = localVecY;
if (isShiftPressed) { if (isShiftPressed) {
const originalAspectRatio = o.width / o.height; const originalAspectRatio = o.width / o.height;
if (Math.abs(newWidth) > Math.abs(newHeight) * originalAspectRatio) { if (Math.abs(newWidth) > Math.abs(newHeight) * originalAspectRatio) {
@@ -1071,10 +1161,10 @@ export class CanvasInteractions {
if (newWidth < 10) newWidth = 10; if (newWidth < 10) newWidth = 10;
if (newHeight < 10) newHeight = 10; if (newHeight < 10) newHeight = 10;
layer.width = newWidth; layer.width = newWidth;
layer.height = newHeight; layer.height = newHeight;
// Update position to keep anchor point fixed // Update position to keep anchor point fixed
const deltaW = layer.width - o.width; const deltaW = layer.width - o.width;
const deltaH = layer.height - o.height; const deltaH = layer.height - o.height;
@@ -1140,7 +1230,7 @@ export class CanvasInteractions {
this.canvas.updateOutputAreaSize(newWidth, newHeight); this.canvas.updateOutputAreaSize(newWidth, newHeight);
} }
this.canvas.render(); this.canvas.render();
this.canvas.saveState(); this.canvas.saveState();
} }
@@ -1238,7 +1328,7 @@ export class CanvasInteractions {
// Store the original canvas size for extension calculations // Store the original canvas size for extension calculations
this.canvas.originalCanvasSize = { width: newWidth, height: newHeight }; this.canvas.originalCanvasSize = { width: newWidth, height: newHeight };
// Store the original position where custom shape was drawn for extension calculations // Store the original position where custom shape was drawn for extension calculations
this.canvas.originalOutputAreaPosition = { x: newX, y: newY }; this.canvas.originalOutputAreaPosition = { x: newX, y: newY };
@@ -1247,10 +1337,10 @@ export class CanvasInteractions {
const ext = this.canvas.outputAreaExtensions; const ext = this.canvas.outputAreaExtensions;
const extendedWidth = newWidth + ext.left + ext.right; const extendedWidth = newWidth + ext.left + ext.right;
const extendedHeight = newHeight + ext.top + ext.bottom; const extendedHeight = newHeight + ext.top + ext.bottom;
// Update canvas size with extensions // Update canvas size with extensions
this.canvas.updateOutputAreaSize(extendedWidth, extendedHeight, false); this.canvas.updateOutputAreaSize(extendedWidth, extendedHeight, false);
// Set outputAreaBounds accounting for extensions // Set outputAreaBounds accounting for extensions
this.canvas.outputAreaBounds = { this.canvas.outputAreaBounds = {
x: newX - ext.left, // Adjust position by left extension x: newX - ext.left, // Adjust position by left extension
@@ -1258,19 +1348,19 @@ export class CanvasInteractions {
width: extendedWidth, width: extendedWidth,
height: extendedHeight height: extendedHeight
}; };
log.info(`New custom shape with extensions: original(${newX}, ${newY}) extended(${newX - ext.left}, ${newY - ext.top}) size(${extendedWidth}x${extendedHeight})`); log.info(`New custom shape with extensions: original(${newX}, ${newY}) extended(${newX - ext.left}, ${newY - ext.top}) size(${extendedWidth}x${extendedHeight})`);
} else { } else {
// No extensions - use original size and position // No extensions - use original size and position
this.canvas.updateOutputAreaSize(newWidth, newHeight, false); this.canvas.updateOutputAreaSize(newWidth, newHeight, false);
this.canvas.outputAreaBounds = { this.canvas.outputAreaBounds = {
x: newX, x: newX,
y: newY, y: newY,
width: newWidth, width: newWidth,
height: newHeight height: newHeight
}; };
log.info(`New custom shape without extensions: position(${newX}, ${newY}) size(${newWidth}x${newHeight})`); log.info(`New custom shape without extensions: position(${newX}, ${newY}) size(${newWidth}x${newHeight})`);
} }
@@ -1289,12 +1379,15 @@ export class CanvasInteractions {
} }
async handlePasteEvent(e: ClipboardEvent): Promise<void> { async handlePasteEvent(e: ClipboardEvent): Promise<void> {
// 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 || const shouldHandle = this.canvas.isMouseOver ||
this.canvas.canvas.contains(document.activeElement) || this.canvas.canvas.contains(document.activeElement) ||
document.activeElement === this.canvas.canvas || document.activeElement === this.canvas.canvas;
document.activeElement === document.body;
if (!shouldHandle) { if (!shouldHandle) {
log.debug("Paste event ignored - not focused on canvas"); log.debug("Paste event ignored - not focused on canvas");
return; return;
@@ -1303,7 +1396,7 @@ export class CanvasInteractions {
log.info("Paste event detected, checking clipboard preference"); log.info("Paste event detected, checking clipboard preference");
const preference = this.canvas.canvasLayers.clipboardPreference; const preference = this.canvas.canvasLayers.clipboardPreference;
if (preference === 'clipspace') { if (preference === 'clipspace') {
log.info("Clipboard preference is clipspace, delegating to ClipboardManager"); log.info("Clipboard preference is clipspace, delegating to ClipboardManager");
@@ -1347,7 +1440,7 @@ export class CanvasInteractions {
public activateOutputAreaTransform(): void { public activateOutputAreaTransform(): void {
// Clear any existing interaction state before starting transform // Clear any existing interaction state before starting transform
this.resetInteractionState(); this.resetInteractionState();
// Deactivate any active tools that might conflict // Deactivate any active tools that might conflict
if (this.canvas.shapeTool.isActive) { if (this.canvas.shapeTool.isActive) {
this.canvas.shapeTool.deactivate(); this.canvas.shapeTool.deactivate();
@@ -1355,10 +1448,10 @@ export class CanvasInteractions {
if (this.canvas.maskTool.isActive) { if (this.canvas.maskTool.isActive) {
this.canvas.maskTool.deactivate(); this.canvas.maskTool.deactivate();
} }
// Clear selection to avoid confusion // Clear selection to avoid confusion
this.canvas.canvasSelection.updateSelection([]); this.canvas.canvasSelection.updateSelection([]);
// Set transform mode // Set transform mode
this.interaction.mode = 'transformingOutputArea'; this.interaction.mode = 'transformingOutputArea';
this.canvas.render(); this.canvas.render();
@@ -1367,7 +1460,7 @@ export class CanvasInteractions {
private getOutputAreaHandle(worldCoords: Point): string | null { private getOutputAreaHandle(worldCoords: Point): string | null {
const bounds = this.canvas.outputAreaBounds; const bounds = this.canvas.outputAreaBounds;
const threshold = 10 / this.canvas.viewport.zoom; const threshold = 10 / this.canvas.viewport.zoom;
// Define handle positions // Define handle positions
const handles = { const handles = {
'nw': { x: bounds.x, y: bounds.y }, 'nw': { x: bounds.x, y: bounds.y },
@@ -1394,7 +1487,7 @@ export class CanvasInteractions {
private startOutputAreaTransform(handle: string, worldCoords: Point): void { private startOutputAreaTransform(handle: string, worldCoords: Point): void {
this.interaction.outputAreaTransformHandle = handle; this.interaction.outputAreaTransformHandle = handle;
this.interaction.dragStart = { ...worldCoords }; this.interaction.dragStart = { ...worldCoords };
const bounds = this.canvas.outputAreaBounds; const bounds = this.canvas.outputAreaBounds;
this.interaction.transformOrigin = { this.interaction.transformOrigin = {
x: bounds.x, x: bounds.x,
@@ -1417,17 +1510,17 @@ export class CanvasInteractions {
'sw': { x: bounds.x + bounds.width, y: bounds.y }, 'sw': { x: bounds.x + bounds.width, y: bounds.y },
'w': { x: bounds.x + bounds.width, y: bounds.y + bounds.height / 2 }, 'w': { x: bounds.x + bounds.width, y: bounds.y + bounds.height / 2 },
}; };
this.interaction.outputAreaTransformAnchor = anchorMap[handle]; this.interaction.outputAreaTransformAnchor = anchorMap[handle];
} }
private resizeOutputAreaFromHandle(worldCoords: Point, isShiftPressed: boolean): void { private resizeOutputAreaFromHandle(worldCoords: Point, isShiftPressed: boolean): void {
const o = this.interaction.transformOrigin; const o = this.interaction.transformOrigin;
if (!o) return; if (!o) return;
const handle = this.interaction.outputAreaTransformHandle; const handle = this.interaction.outputAreaTransformHandle;
const anchor = this.interaction.outputAreaTransformAnchor; const anchor = this.interaction.outputAreaTransformAnchor;
let newX = o.x; let newX = o.x;
let newY = o.y; let newY = o.y;
let newWidth = o.width; let newWidth = o.width;
@@ -1498,7 +1591,7 @@ export class CanvasInteractions {
private updateOutputAreaTransformCursor(worldCoords: Point): void { private updateOutputAreaTransformCursor(worldCoords: Point): void {
const handle = this.getOutputAreaHandle(worldCoords); const handle = this.getOutputAreaHandle(worldCoords);
if (handle) { if (handle) {
const cursorMap: { [key: string]: string } = { const cursorMap: { [key: string]: string } = {
'n': 'ns-resize', 's': 'ns-resize', 'n': 'ns-resize', 's': 'ns-resize',
@@ -1514,16 +1607,16 @@ export class CanvasInteractions {
private finalizeOutputAreaTransform(): void { private finalizeOutputAreaTransform(): void {
const bounds = this.canvas.outputAreaBounds; const bounds = this.canvas.outputAreaBounds;
// Update canvas size and mask tool // Update canvas size and mask tool
this.canvas.updateOutputAreaSize(bounds.width, bounds.height); this.canvas.updateOutputAreaSize(bounds.width, bounds.height);
// Update mask canvas for new output area // Update mask canvas for new output area
this.canvas.maskTool.updateMaskCanvasForOutputArea(); this.canvas.maskTool.updateMaskCanvasForOutputArea();
// Save state // Save state
this.canvas.saveState(); this.canvas.saveState();
// Reset transform handle but keep transform mode active // Reset transform handle but keep transform mode active
this.interaction.outputAreaTransformHandle = null; this.interaction.outputAreaTransformHandle = null;
} }

View File

@@ -1263,8 +1263,8 @@ export class CanvasLayers {
this.canvas.height = height; this.canvas.height = height;
this.canvas.maskTool.resize(width, height); this.canvas.maskTool.resize(width, height);
this.canvas.canvas.width = width; // Don't set canvas.width/height - the render loop will handle display size
this.canvas.canvas.height = height; // this.canvas.width/height are for OUTPUT AREA dimensions, not display canvas
this.canvas.render(); this.canvas.render();

View File

@@ -133,7 +133,7 @@ export class CanvasLayersPanel {
`; `;
this.layersContainer = this.container.querySelector<HTMLElement>('#layers-container'); this.layersContainer = this.container.querySelector<HTMLElement>('#layers-container');
// Setup event listeners dla przycisków // Setup event listeners dla przycisków
this.setupControlButtons(); this.setupControlButtons();
this.setupMasterVisibilityToggle(); this.setupMasterVisibilityToggle();
@@ -144,6 +144,26 @@ export class CanvasLayersPanel {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
this.deleteSelectedLayers(); 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.className = 'layer-row';
layerRow.draggable = true; layerRow.draggable = true;
layerRow.dataset.layerIndex = String(index); layerRow.dataset.layerIndex = String(index);
const isSelected = this.canvas.canvasSelection.selectedLayers.includes(layer); const isSelected = this.canvas.canvasSelection.selectedLayers.includes(layer);
if (isSelected) { if (isSelected) {
layerRow.classList.add('selected'); layerRow.classList.add('selected');
@@ -318,7 +338,7 @@ export class CanvasLayersPanel {
const scale = Math.min(48 / layer.image.width, 48 / layer.image.height); const scale = Math.min(48 / layer.image.width, 48 / layer.image.height);
const scaledWidth = layer.image.width * scale; const scaledWidth = layer.image.width * scale;
const scaledHeight = layer.image.height * scale; const scaledHeight = layer.image.height * scale;
// Wycentruj obraz // Wycentruj obraz
const x = (48 - scaledWidth) / 2; const x = (48 - scaledWidth) / 2;
const y = (48 - scaledHeight) / 2; const y = (48 - scaledHeight) / 2;
@@ -383,26 +403,29 @@ export class CanvasLayersPanel {
// Aktualizuj wewnętrzny stan zaznaczenia w obiekcie canvas // Aktualizuj wewnętrzny stan zaznaczenia w obiekcie canvas
// Ta funkcja NIE powinna już wywoływać onSelectionChanged w panelu. // Ta funkcja NIE powinna już wywoływać onSelectionChanged w panelu.
this.canvas.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index); this.canvas.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index);
// Aktualizuj tylko wygląd (klasy CSS), bez niszczenia DOM // Aktualizuj tylko wygląd (klasy CSS), bez niszczenia DOM
this.updateSelectionAppearance(); this.updateSelectionAppearance();
this.updateButtonStates(); 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}`); log.debug(`Layer clicked: ${layer.name}, selection count: ${this.canvas.canvasSelection.selectedLayers.length}`);
} }
startEditingLayerName(nameElement: HTMLElement, layer: Layer): void { startEditingLayerName(nameElement: HTMLElement, layer: Layer): void {
const currentName = layer.name; const currentName = layer.name;
nameElement.classList.add('editing'); nameElement.classList.add('editing');
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'text'; input.type = 'text';
input.value = currentName; input.value = currentName;
input.style.width = '100%'; input.style.width = '100%';
nameElement.innerHTML = ''; nameElement.innerHTML = '';
nameElement.appendChild(input); nameElement.appendChild(input);
input.focus(); input.focus();
input.select(); input.select();
@@ -412,7 +435,7 @@ export class CanvasLayersPanel {
layer.name = newName; layer.name = newName;
nameElement.classList.remove('editing'); nameElement.classList.remove('editing');
nameElement.textContent = newName; nameElement.textContent = newName;
this.canvas.saveState(); this.canvas.saveState();
log.info(`Layer renamed to: ${newName}`); log.info(`Layer renamed to: ${newName}`);
}; };
@@ -436,11 +459,11 @@ export class CanvasLayersPanel {
if (!existingNames.includes(proposedName)) { if (!existingNames.includes(proposedName)) {
return proposedName; return proposedName;
} }
// Sprawdź czy nazwa już ma numerację w nawiasach // Sprawdź czy nazwa już ma numerację w nawiasach
const match = proposedName.match(/^(.+?)\s*\((\d+)\)$/); const match = proposedName.match(/^(.+?)\s*\((\d+)\)$/);
let baseName, startNumber; let baseName, startNumber;
if (match) { if (match) {
baseName = match[1].trim(); baseName = match[1].trim();
startNumber = parseInt(match[2]) + 1; startNumber = parseInt(match[2]) + 1;
@@ -448,34 +471,34 @@ export class CanvasLayersPanel {
baseName = proposedName; baseName = proposedName;
startNumber = 1; startNumber = 1;
} }
// Znajdź pierwszą dostępną numerację // Znajdź pierwszą dostępną numerację
let counter = startNumber; let counter = startNumber;
let uniqueName; let uniqueName;
do { do {
uniqueName = `${baseName} (${counter})`; uniqueName = `${baseName} (${counter})`;
counter++; counter++;
} while (existingNames.includes(uniqueName)); } while (existingNames.includes(uniqueName));
return uniqueName; return uniqueName;
} }
toggleLayerVisibility(layer: Layer): void { toggleLayerVisibility(layer: Layer): void {
layer.visible = !layer.visible; layer.visible = !layer.visible;
// If layer became invisible and is selected, deselect it // If layer became invisible and is selected, deselect it
if (!layer.visible && this.canvas.canvasSelection.selectedLayers.includes(layer)) { if (!layer.visible && this.canvas.canvasSelection.selectedLayers.includes(layer)) {
const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l: Layer) => l !== layer); const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l: Layer) => l !== layer);
this.canvas.updateSelection(newSelection); this.canvas.updateSelection(newSelection);
} }
this.canvas.render(); this.canvas.render();
this.canvas.requestSaveState(); this.canvas.requestSaveState();
// Update the eye icon in the panel // Update the eye icon in the panel
this.renderLayers(); this.renderLayers();
log.info(`Layer "${layer.name}" visibility toggled to: ${layer.visible}`); log.info(`Layer "${layer.name}" visibility toggled to: ${layer.visible}`);
} }
@@ -535,7 +558,7 @@ export class CanvasLayersPanel {
const line = document.createElement('div'); const line = document.createElement('div');
line.className = 'drag-insertion-line'; line.className = 'drag-insertion-line';
if (isUpperHalf) { if (isUpperHalf) {
line.style.top = '-1px'; line.style.top = '-1px';
} else { } else {
@@ -563,7 +586,7 @@ export class CanvasLayersPanel {
const rect = e.currentTarget.getBoundingClientRect(); const rect = e.currentTarget.getBoundingClientRect();
const midpoint = rect.top + rect.height / 2; const midpoint = rect.top + rect.height / 2;
const isUpperHalf = e.clientY < midpoint; const isUpperHalf = e.clientY < midpoint;
// Oblicz docelowy indeks // Oblicz docelowy indeks
let insertIndex = targetIndex; let insertIndex = targetIndex;
if (!isUpperHalf) { if (!isUpperHalf) {
@@ -572,7 +595,7 @@ export class CanvasLayersPanel {
// Użyj nowej, centralnej funkcji do przesuwania warstw // Użyj nowej, centralnej funkcji do przesuwania warstw
this.canvas.canvasLayers.moveLayers(this.draggedElements, { toIndex: insertIndex }); this.canvas.canvasLayers.moveLayers(this.draggedElements, { toIndex: insertIndex });
log.info(`Dropped ${this.draggedElements.length} layers at position ${insertIndex}`); log.info(`Dropped ${this.draggedElements.length} layers at position ${insertIndex}`);
} }
@@ -610,17 +633,17 @@ export class CanvasLayersPanel {
*/ */
updateButtonStates(): void { updateButtonStates(): void {
if (!this.container) return; if (!this.container) return;
const deleteBtn = this.container.querySelector('#delete-layer-btn') as HTMLButtonElement; const deleteBtn = this.container.querySelector('#delete-layer-btn') as HTMLButtonElement;
const hasSelectedLayers = this.canvas.canvasSelection.selectedLayers.length > 0; const hasSelectedLayers = this.canvas.canvasSelection.selectedLayers.length > 0;
if (deleteBtn) { if (deleteBtn) {
deleteBtn.disabled = !hasSelectedLayers; deleteBtn.disabled = !hasSelectedLayers;
deleteBtn.title = hasSelectedLayers deleteBtn.title = hasSelectedLayers
? `Delete ${this.canvas.canvasSelection.selectedLayers.length} selected layer(s)` ? `Delete ${this.canvas.canvasSelection.selectedLayers.length} selected layer(s)`
: 'No layers selected'; : 'No layers selected';
} }
log.debug(`Button states updated - delete button ${hasSelectedLayers ? 'enabled' : 'disabled'}`); log.debug(`Button states updated - delete button ${hasSelectedLayers ? 'enabled' : 'disabled'}`);
} }
@@ -641,7 +664,7 @@ export class CanvasLayersPanel {
this.layersContainer = null; this.layersContainer = null;
this.draggedElements = []; this.draggedElements = [];
this.removeDragInsertionLine(); this.removeDragInsertionLine();
log.info('CanvasLayersPanel destroyed'); log.info('CanvasLayersPanel destroyed');
} }
} }

View File

@@ -188,6 +188,11 @@ export class CanvasRenderer {
} }
}); });
// Draw grab icons for selected layers when hovering
if (this.canvas.canvasInteractions.interaction.hoveringGrabIcon) {
this.drawGrabIcons(ctx);
}
this.drawCanvasOutline(ctx); this.drawCanvasOutline(ctx);
this.drawOutputAreaExtensionPreview(ctx); // Draw extension preview this.drawOutputAreaExtensionPreview(ctx); // Draw extension preview
this.drawPendingGenerationAreas(ctx); // Draw snapshot outlines this.drawPendingGenerationAreas(ctx); // Draw snapshot outlines
@@ -1013,6 +1018,66 @@ export class CanvasRenderer {
this.updateOverlaySize(); this.updateOverlaySize();
} }
/**
* Draw grab icons in the center of selected layers
*/
drawGrabIcons(ctx: any): void {
const selectedLayers = this.canvas.canvasSelection.selectedLayers;
if (selectedLayers.length === 0) return;
const iconRadius = 20 / this.canvas.viewport.zoom;
const innerRadius = 12 / this.canvas.viewport.zoom;
selectedLayers.forEach((layer: any) => {
if (!layer.visible) return;
const centerX = layer.x + layer.width / 2;
const centerY = layer.y + layer.height / 2;
ctx.save();
// Draw outer circle (background)
ctx.beginPath();
ctx.arc(centerX, centerY, iconRadius, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(0, 150, 255, 0.7)';
ctx.fill();
ctx.strokeStyle = 'rgba(255, 255, 255, 0.9)';
ctx.lineWidth = 2 / this.canvas.viewport.zoom;
ctx.stroke();
// Draw hand/grab icon (simplified)
ctx.fillStyle = 'rgba(255, 255, 255, 0.95)';
ctx.strokeStyle = 'rgba(255, 255, 255, 0.95)';
ctx.lineWidth = 1.5 / this.canvas.viewport.zoom;
// Draw four dots representing grab points
const dotRadius = 2 / this.canvas.viewport.zoom;
const dotDistance = 6 / this.canvas.viewport.zoom;
// Top-left
ctx.beginPath();
ctx.arc(centerX - dotDistance, centerY - dotDistance, dotRadius, 0, Math.PI * 2);
ctx.fill();
// Top-right
ctx.beginPath();
ctx.arc(centerX + dotDistance, centerY - dotDistance, dotRadius, 0, Math.PI * 2);
ctx.fill();
// Bottom-left
ctx.beginPath();
ctx.arc(centerX - dotDistance, centerY + dotDistance, dotRadius, 0, Math.PI * 2);
ctx.fill();
// Bottom-right
ctx.beginPath();
ctx.arc(centerX + dotDistance, centerY + dotDistance, dotRadius, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
});
}
/** /**
* Draw transform handles for output area when in transform mode * Draw transform handles for output area when in transform mode
*/ */

View File

@@ -118,11 +118,11 @@ export class CanvasState {
log.debug(`Output Area resized to ${this.canvas.width}x${this.canvas.height} and viewport set.`); log.debug(`Output Area resized to ${this.canvas.width}x${this.canvas.height} and viewport set.`);
const loadedLayers = await this._loadLayers(savedState.layers); const loadedLayers = await this._loadLayers(savedState.layers);
this.canvas.layers = loadedLayers.filter((l): l is Layer => l !== null); 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) { if (this.canvas.layers.length === 0 && savedState.layers.length > 0) {
log.warn("No valid layers loaded, state may be corrupted."); 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.`);
return false; // Don't return false - allow empty canvas to be valid
} }
this.canvas.updateSelectionAfterHistory(); this.canvas.updateSelectionAfterHistory();

View File

@@ -1000,6 +1000,13 @@ $el("label.clipboard-switch.mask-switch", {
resizeObserver.observe(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', () => { canvas.canvas.addEventListener('focus', () => {
canvasContainer.classList.add('has-focus'); canvasContainer.classList.add('has-focus');
}); });
@@ -1195,12 +1202,23 @@ app.registerExtension({
const sendPromises: Promise<any>[] = []; const sendPromises: Promise<any>[] = [];
for (const [nodeId, canvasWidget] of canvasNodeInstances.entries()) { for (const [nodeId, canvasWidget] of canvasNodeInstances.entries()) {
if (app.graph.getNodeById(nodeId) && canvasWidget.canvas && canvasWidget.canvas.canvasIO) { const node = app.graph.getNodeById(nodeId);
log.debug(`Sending data for canvas node ${nodeId}`);
sendPromises.push(canvasWidget.canvas.canvasIO.sendDataViaWebSocket(nodeId)); if (!node) {
} else {
log.warn(`Node ${nodeId} not found in graph, removing from instances map.`); log.warn(`Node ${nodeId} not found in graph, removing from instances map.`);
canvasNodeInstances.delete(nodeId); 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) { async beforeRegisterNodeDef(nodeType: any, nodeData: any, app: ComfyApp) {
if (nodeType.comfyClass === "LayerForgeNode") { if (nodeType.comfyClass === "LayerForgeNode") {
// Map to track pending copy sources across node ID changes
const pendingCopySources = new Map<number, number>();
const onNodeCreated = nodeType.prototype.onNodeCreated; const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function (this: ComfyNode) { nodeType.prototype.onNodeCreated = function (this: ComfyNode) {
log.debug("CanvasNode onNodeCreated: Base widget setup."); log.debug("CanvasNode onNodeCreated: Base widget setup.");
@@ -1253,10 +1274,53 @@ app.registerExtension({
const canvasWidget = await createCanvasWidget(this, null, app); const canvasWidget = await createCanvasWidget(this, null, app);
canvasNodeInstances.set(this.id, canvasWidget); canvasNodeInstances.set(this.id, canvasWidget);
log.info(`Registered CanvasNode instance for ID: ${this.id}`); log.info(`Registered CanvasNode instance for ID: ${this.id}`);
// Store the canvas widget on the node // Store the canvas widget on the node
(this as any).canvasWidget = canvasWidget; (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 // Check if there are already connected inputs
setTimeout(() => { setTimeout(() => {
if (this.inputs && this.inputs.length > 0) { if (this.inputs && this.inputs.length > 0) {
@@ -1440,6 +1504,52 @@ app.registerExtension({
return onRemoved?.apply(this, arguments as any); 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; const originalGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function (this: ComfyNode, _: any, options: any[]) { nodeType.prototype.getExtraMenuOptions = function (this: ComfyNode, _: any, options: any[]) {
// FIRST: Call original to let other extensions add their options // FIRST: Call original to let other extensions add their options