19 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
15 changed files with 534 additions and 152 deletions

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">
</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?
- **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
### 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
1. Install [ComfyUi](https://github.com/comfyanonymous/ComfyUI).
2. Clone this repo into `custom_modules`:
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_nodes`:
```bash
cd ComfyUI/custom_nodes/
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:**
* Search node ID in ComfyUI settings.
* 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).
#### ○ `node_id` not auto-filled → black output
> In some cases, **ComfyUI doesnt auto-fill the `node_id`** when adding a node.
> This may cause the node to output a **completely black image** or fail to work.
>
> 🛠️ **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]
> 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
If youd like to support my work:
• ⭐ Give a star — it means a lot to me!
• 🐛 Report a bug or suggest a feature
• 💖 If youd like to support my work:
👉 [GitHub Sponsors](https://github.com/sponsors/Azornes)
---
## 🙏 Acknowledgments
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
*/
initCanvas() {
this.canvas.width = this.width;
this.canvas.height = this.height;
// Don't set canvas.width/height here - let the render loop handle it based on clientWidth/clientHeight
// this.width and this.height are for the OUTPUT AREA, not the display canvas
this.canvas.style.border = '1px solid black';
this.canvas.style.maxWidth = '100%';
this.canvas.style.backgroundColor = '#606060';

View File

@@ -197,6 +197,25 @@ export class CanvasIO {
}
async _renderOutputData() {
log.info("=== RENDERING OUTPUT DATA FOR COMFYUI ===");
// Check if layers have valid images loaded, with retry logic
const maxRetries = 5;
const retryDelay = 200;
for (let attempt = 0; attempt < maxRetries; attempt++) {
const layersWithoutImages = this.canvas.layers.filter(layer => !layer.image || !layer.image.complete);
if (layersWithoutImages.length === 0) {
break; // All images loaded
}
if (attempt === 0) {
log.warn(`${layersWithoutImages.length} layer(s) have incomplete image data. Waiting for images to load...`);
}
if (attempt < maxRetries - 1) {
await new Promise(resolve => setTimeout(resolve, retryDelay));
}
else {
// Last attempt failed
throw new Error(`Canvas not ready after ${maxRetries} attempts: ${layersWithoutImages.length} layer(s) still have incomplete image data. Try waiting a moment and running again.`);
}
}
// Użyj zunifikowanych funkcji z CanvasLayers
const imageBlob = await this.canvas.canvasLayers.getFlattenedCanvasAsBlob();
const maskBlob = await this.canvas.canvasLayers.getFlattenedMaskAsBlob();

View File

@@ -104,6 +104,8 @@ export class CanvasInteractions {
// Add a blur event listener to the window to reset key states
window.addEventListener('blur', this.onBlur);
document.addEventListener('paste', this.onPaste);
// Intercept Ctrl+V during capture phase to handle layer paste before ComfyUI
document.addEventListener('keydown', this.onKeyDown, { capture: true });
this.canvas.canvas.addEventListener('mouseenter', this.onMouseEnter);
this.canvas.canvas.addEventListener('mouseleave', this.onMouseLeave);
this.canvas.canvas.addEventListener('dragover', this.onDragOver);
@@ -119,6 +121,8 @@ export class CanvasInteractions {
this.canvas.canvas.removeEventListener('wheel', this.onWheel);
this.canvas.canvas.removeEventListener('keydown', this.onKeyDown);
this.canvas.canvas.removeEventListener('keyup', this.onKeyUp);
// Remove document-level capture listener
document.removeEventListener('keydown', this.onKeyDown, { capture: true });
window.removeEventListener('blur', this.onBlur);
document.removeEventListener('paste', this.onPaste);
this.canvas.canvas.removeEventListener('mouseenter', this.onMouseEnter);
@@ -188,6 +192,12 @@ export class CanvasInteractions {
}
handleMouseDown(e) {
this.canvas.canvas.focus();
// Sync modifier states with actual event state to prevent "stuck" modifiers
// when focus moves between layers panel and canvas
this.interaction.isCtrlPressed = e.ctrlKey;
this.interaction.isMetaPressed = e.metaKey;
this.interaction.isShiftPressed = e.shiftKey;
this.interaction.isAltPressed = e.altKey;
const coords = this.getMouseCoordinates(e);
const mods = this.getModifierState(e);
if (this.interaction.mode === 'drawingMask') {
@@ -519,14 +529,24 @@ export class CanvasInteractions {
return targetHeight / oldHeight;
}
handleKeyDown(e) {
// Always track modifier keys regardless of focus
if (e.key === 'Control')
this.interaction.isCtrlPressed = true;
if (e.key === 'Meta')
this.interaction.isMetaPressed = true;
if (e.key === 'Shift')
this.interaction.isShiftPressed = true;
if (e.key === 'Alt') {
if (e.key === 'Alt')
this.interaction.isAltPressed = true;
// Check if canvas is focused before handling any shortcuts
const shouldHandle = this.canvas.isMouseOver ||
this.canvas.canvas.contains(document.activeElement) ||
document.activeElement === this.canvas.canvas;
if (!shouldHandle) {
return;
}
// Canvas-specific key handlers (only when focused)
if (e.key === 'Alt') {
e.preventDefault();
}
if (e.key.toLowerCase() === 's') {
@@ -560,6 +580,17 @@ export class CanvasInteractions {
this.canvas.canvasLayers.copySelectedLayers();
}
break;
case 'v':
// Only handle internal clipboard paste here.
// If internal clipboard is empty, let the paste event bubble
// so handlePasteEvent can access e.clipboardData for system images.
if (this.canvas.canvasLayers.internalClipboard.length > 0) {
this.canvas.canvasLayers.pasteLayers();
} else {
// Don't preventDefault - let paste event fire for system clipboard
handled = false;
}
break;
default:
handled = false;
break;
@@ -713,12 +744,11 @@ export class CanvasInteractions {
if (mods.ctrl || mods.meta) {
const index = this.canvas.canvasSelection.selectedLayers.indexOf(layer);
if (index === -1) {
// Ctrl-clicking unselected layer: add to selection
this.canvas.canvasSelection.updateSelection([...this.canvas.canvasSelection.selectedLayers, layer]);
}
else {
const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l) => l !== layer);
this.canvas.canvasSelection.updateSelection(newSelection);
}
// If already selected, do NOT deselect - allows dragging multiple layers with Ctrl held
// User can use right-click in layers panel to deselect individual layers
}
else {
if (!this.canvas.canvasSelection.selectedLayers.includes(layer)) {
@@ -1155,10 +1185,13 @@ export class CanvasInteractions {
}
}
async handlePasteEvent(e) {
// Check if canvas is connected to DOM and visible
if (!this.canvas.canvas.isConnected || !document.body.contains(this.canvas.canvas)) {
return;
}
const shouldHandle = this.canvas.isMouseOver ||
this.canvas.canvas.contains(document.activeElement) ||
document.activeElement === this.canvas.canvas ||
document.activeElement === document.body;
document.activeElement === this.canvas.canvas;
if (!shouldHandle) {
log.debug("Paste event ignored - not focused on canvas");
return;

View File

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

View File

@@ -123,6 +123,26 @@ export class CanvasLayersPanel {
e.preventDefault();
e.stopPropagation();
this.deleteSelectedLayers();
return;
}
// Handle Ctrl+C/V for layer copy/paste when panel has focus
if (e.ctrlKey || e.metaKey) {
if (e.key.toLowerCase() === 'c') {
if (this.canvas.canvasSelection.selectedLayers.length > 0) {
e.preventDefault();
e.stopPropagation();
this.canvas.canvasLayers.copySelectedLayers();
log.info('Layers copied from panel');
}
}
else if (e.key.toLowerCase() === 'v') {
e.preventDefault();
e.stopPropagation();
if (this.canvas.canvasLayers.internalClipboard.length > 0) {
this.canvas.canvasLayers.pasteLayers();
log.info('Layers pasted from panel');
}
}
}
});
log.debug('Panel structure created');
@@ -329,6 +349,8 @@ export class CanvasLayersPanel {
// Aktualizuj tylko wygląd (klasy CSS), bez niszczenia DOM
this.updateSelectionAppearance();
this.updateButtonStates();
// Focus the canvas so keyboard shortcuts (like Ctrl+C/V) work for layer operations
this.canvas.canvas.focus();
log.debug(`Layer clicked: ${layer.name}, selection count: ${this.canvas.canvasSelection.selectedLayers.length}`);
}
startEditingLayerName(nameElement, layer) {

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.`);
const loadedLayers = await this._loadLayers(savedState.layers);
this.canvas.layers = loadedLayers.filter((l) => l !== null);
log.info(`Loaded ${this.canvas.layers.length} layers.`);
if (this.canvas.layers.length === 0) {
log.warn("No valid layers loaded, state may be corrupted.");
return false;
log.info(`Loaded ${this.canvas.layers.length} layers from ${savedState.layers.length} saved layers.`);
if (this.canvas.layers.length === 0 && savedState.layers.length > 0) {
log.warn(`Failed to load any layers. Saved state had ${savedState.layers.length} layers but all failed to load. This may indicate corrupted IndexedDB data.`);
// Don't return false - allow empty canvas to be valid
}
this.canvas.updateSelectionAfterHistory();
this.canvas.render();

View File

@@ -884,6 +884,12 @@ async function createCanvasWidget(node, widget, app) {
if (controlsElement) {
resizeObserver.observe(controlsElement);
}
// Watch the canvas container itself to detect size changes and fix canvas dimensions
const canvasContainerResizeObserver = new ResizeObserver(() => {
// Force re-read of canvas dimensions on next render
canvas.render();
});
canvasContainerResizeObserver.observe(canvasContainer);
canvas.canvas.addEventListener('focus', () => {
canvasContainer.classList.add('has-focus');
});
@@ -1038,13 +1044,20 @@ app.registerExtension({
log.info(`Found ${canvasNodeInstances.size} CanvasNode(s). Sending data via WebSocket...`);
const sendPromises = [];
for (const [nodeId, canvasWidget] of canvasNodeInstances.entries()) {
if (app.graph.getNodeById(nodeId) && canvasWidget.canvas && canvasWidget.canvas.canvasIO) {
log.debug(`Sending data for canvas node ${nodeId}`);
sendPromises.push(canvasWidget.canvas.canvasIO.sendDataViaWebSocket(nodeId));
}
else {
const node = app.graph.getNodeById(nodeId);
if (!node) {
log.warn(`Node ${nodeId} not found in graph, removing from instances map.`);
canvasNodeInstances.delete(nodeId);
continue;
}
// Skip bypassed nodes
if (node.mode === 4) {
log.debug(`Node ${nodeId} is bypassed, skipping data send.`);
continue;
}
if (canvasWidget.canvas && canvasWidget.canvas.canvasIO) {
log.debug(`Sending data for canvas node ${nodeId}`);
sendPromises.push(canvasWidget.canvas.canvasIO.sendDataViaWebSocket(nodeId));
}
}
try {
@@ -1063,6 +1076,8 @@ app.registerExtension({
},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeType.comfyClass === "LayerForgeNode") {
// Map to track pending copy sources across node ID changes
const pendingCopySources = new Map();
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
log.debug("CanvasNode onNodeCreated: Base widget setup.");
@@ -1093,6 +1108,43 @@ app.registerExtension({
log.info(`Registered CanvasNode instance for ID: ${this.id}`);
// Store the canvas widget on the node
this.canvasWidget = canvasWidget;
// Check if this node has a pending copy source (from onConfigure)
// Check both the current ID and -1 (temporary ID during paste)
let sourceNodeId = pendingCopySources.get(this.id);
if (!sourceNodeId) {
sourceNodeId = pendingCopySources.get(-1);
if (sourceNodeId) {
// Transfer from -1 to the real ID and clear -1
pendingCopySources.delete(-1);
}
}
if (sourceNodeId && sourceNodeId !== this.id) {
log.info(`Node ${this.id} will copy canvas state from node ${sourceNodeId}`);
// Clear the flag
pendingCopySources.delete(this.id);
// Copy the canvas state now that the widget is initialized
setTimeout(async () => {
try {
const { getCanvasState, setCanvasState } = await import('./db.js');
let sourceState = await getCanvasState(String(sourceNodeId));
// If source node doesn't exist (cross-workflow paste), try clipboard
if (!sourceState) {
log.debug(`No canvas state found for source node ${sourceNodeId}, checking clipboard`);
sourceState = await getCanvasState('__clipboard__');
}
if (!sourceState) {
log.debug(`No canvas state found in clipboard either`);
return;
}
await setCanvasState(String(this.id), sourceState);
await canvasWidget.canvas.loadInitialState();
log.info(`Canvas state copied successfully to node ${this.id}`);
}
catch (error) {
log.error(`Error copying canvas state:`, error);
}
}, 100);
}
// Check if there are already connected inputs
setTimeout(() => {
if (this.inputs && this.inputs.length > 0) {
@@ -1258,6 +1310,47 @@ app.registerExtension({
}
return onRemoved?.apply(this, arguments);
};
// Handle copy/paste - save canvas state when copying
const originalSerialize = nodeType.prototype.serialize;
nodeType.prototype.serialize = function () {
const data = originalSerialize ? originalSerialize.apply(this) : {};
// Store a reference to the source node ID so we can copy layer data
data.sourceNodeId = this.id;
log.debug(`Serializing node ${this.id} for copy`);
// Store canvas state in a clipboard entry for cross-workflow paste
// This happens async but that's fine since paste happens later
(async () => {
try {
const { getCanvasState, setCanvasState } = await import('./db.js');
const sourceState = await getCanvasState(String(this.id));
if (sourceState) {
// Store in a special "clipboard" entry
await setCanvasState('__clipboard__', sourceState);
log.debug(`Stored canvas state in clipboard for node ${this.id}`);
}
}
catch (error) {
log.error('Error storing canvas state to clipboard:', error);
}
})();
return data;
};
// Handle copy/paste - load canvas state from source node when pasting
const originalConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = async function (data) {
if (originalConfigure) {
originalConfigure.apply(this, [data]);
}
// Store the source node ID in the map (persists across node ID changes)
// This will be picked up later in onAdded when the canvas widget is ready
if (data.sourceNodeId && data.sourceNodeId !== this.id) {
const existingSource = pendingCopySources.get(this.id);
if (!existingSource) {
pendingCopySources.set(this.id, data.sourceNodeId);
log.debug(`Stored pending copy source: ${data.sourceNodeId} for node ${this.id}`);
}
}
};
const originalGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function (_, options) {
// FIRST: Call original to let other extensions add their options

View File

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

View File

@@ -218,6 +218,29 @@ export class CanvasIO {
async _renderOutputData(): Promise<{ image: string, mask: string }> {
log.info("=== RENDERING OUTPUT DATA FOR COMFYUI ===");
// Check if layers have valid images loaded, with retry logic
const maxRetries = 5;
const retryDelay = 200;
for (let attempt = 0; attempt < maxRetries; attempt++) {
const layersWithoutImages = this.canvas.layers.filter(layer => !layer.image || !layer.image.complete);
if (layersWithoutImages.length === 0) {
break; // All images loaded
}
if (attempt === 0) {
log.warn(`${layersWithoutImages.length} layer(s) have incomplete image data. Waiting for images to load...`);
}
if (attempt < maxRetries - 1) {
await new Promise(resolve => setTimeout(resolve, retryDelay));
} else {
// Last attempt failed
throw new Error(`Canvas not ready after ${maxRetries} attempts: ${layersWithoutImages.length} layer(s) still have incomplete image data. Try waiting a moment and running again.`);
}
}
// Użyj zunifikowanych funkcji z CanvasLayers
const imageBlob = await this.canvas.canvasLayers.getFlattenedCanvasAsBlob();
const maskBlob = await this.canvas.canvasLayers.getFlattenedMaskAsBlob();

View File

@@ -176,6 +176,9 @@ export class CanvasInteractions {
document.addEventListener('paste', this.onPaste as unknown as EventListener);
// Intercept Ctrl+V during capture phase to handle layer paste before ComfyUI
document.addEventListener('keydown', this.onKeyDown as EventListener, { capture: true });
this.canvas.canvas.addEventListener('mouseenter', this.onMouseEnter as EventListener);
this.canvas.canvas.addEventListener('mouseleave', this.onMouseLeave as EventListener);
@@ -195,6 +198,9 @@ export class CanvasInteractions {
this.canvas.canvas.removeEventListener('keydown', this.onKeyDown as EventListener);
this.canvas.canvas.removeEventListener('keyup', this.onKeyUp as EventListener);
// Remove document-level capture listener
document.removeEventListener('keydown', this.onKeyDown as EventListener, { capture: true });
window.removeEventListener('blur', this.onBlur);
document.removeEventListener('paste', this.onPaste as unknown as EventListener);
@@ -277,6 +283,14 @@ export class CanvasInteractions {
handleMouseDown(e: MouseEvent): void {
this.canvas.canvas.focus();
// Sync modifier states with actual event state to prevent "stuck" modifiers
// when focus moves between layers panel and canvas
this.interaction.isCtrlPressed = e.ctrlKey;
this.interaction.isMetaPressed = e.metaKey;
this.interaction.isShiftPressed = e.shiftKey;
this.interaction.isAltPressed = e.altKey;
const coords = this.getMouseCoordinates(e);
const mods = this.getModifierState(e);
@@ -354,7 +368,7 @@ export class CanvasInteractions {
if (grabIconLayer) {
// Start dragging the selected layer(s) without changing selection
this.interaction.mode = 'potential-drag';
this.interaction.dragStart = {...coords.world};
this.interaction.dragStart = { ...coords.world };
return;
}
@@ -646,11 +660,23 @@ export class CanvasInteractions {
}
handleKeyDown(e: KeyboardEvent): void {
// Always track modifier keys regardless of focus
if (e.key === 'Control') this.interaction.isCtrlPressed = true;
if (e.key === 'Meta') this.interaction.isMetaPressed = true;
if (e.key === 'Shift') this.interaction.isShiftPressed = true;
if (e.key === 'Alt') this.interaction.isAltPressed = true;
// Check if canvas is focused before handling any shortcuts
const shouldHandle = this.canvas.isMouseOver ||
this.canvas.canvas.contains(document.activeElement) ||
document.activeElement === this.canvas.canvas;
if (!shouldHandle) {
return;
}
// Canvas-specific key handlers (only when focused)
if (e.key === 'Alt') {
this.interaction.isAltPressed = true;
e.preventDefault();
}
if (e.key.toLowerCase() === 's') {
@@ -685,6 +711,17 @@ export class CanvasInteractions {
this.canvas.canvasLayers.copySelectedLayers();
}
break;
case 'v':
// Only handle internal clipboard paste here.
// If internal clipboard is empty, let the paste event bubble
// so handlePasteEvent can access e.clipboardData for system images.
if (this.canvas.canvasLayers.internalClipboard.length > 0) {
this.canvas.canvasLayers.pasteLayers();
} else {
// Don't preventDefault - let paste event fire for system clipboard
handled = false;
}
break;
default:
handled = false;
break;
@@ -820,7 +857,7 @@ export class CanvasInteractions {
originalHeight: layer.originalHeight,
cropBounds: layer.cropBounds ? { ...layer.cropBounds } : undefined
};
this.interaction.dragStart = {...worldCoords};
this.interaction.dragStart = { ...worldCoords };
if (handle === 'rot') {
this.interaction.mode = 'rotating';
@@ -844,11 +881,11 @@ export class CanvasInteractions {
if (mods.ctrl || mods.meta) {
const index = this.canvas.canvasSelection.selectedLayers.indexOf(layer);
if (index === -1) {
// Ctrl-clicking unselected layer: add to selection
this.canvas.canvasSelection.updateSelection([...this.canvas.canvasSelection.selectedLayers, layer]);
} else {
const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l: Layer) => l !== layer);
this.canvas.canvasSelection.updateSelection(newSelection);
}
// If already selected, do NOT deselect - allows dragging multiple layers with Ctrl held
// User can use right-click in layers panel to deselect individual layers
} else {
if (!this.canvas.canvasSelection.selectedLayers.includes(layer)) {
this.canvas.canvasSelection.updateSelection([layer]);
@@ -856,7 +893,7 @@ export class CanvasInteractions {
}
this.interaction.mode = 'potential-drag';
this.interaction.dragStart = {...worldCoords};
this.interaction.dragStart = { ...worldCoords };
}
startPanning(e: MouseEvent, clearSelection: boolean = true): void {
@@ -872,8 +909,8 @@ export class CanvasInteractions {
this.interaction.mode = 'resizingCanvas';
const startX = snapToGrid(worldCoords.x);
const startY = snapToGrid(worldCoords.y);
this.interaction.canvasResizeStart = {x: startX, y: startY};
this.interaction.canvasResizeRect = {x: startX, y: startY, width: 0, height: 0};
this.interaction.canvasResizeStart = { x: startX, y: startY };
this.interaction.canvasResizeRect = { x: startX, y: startY, width: 0, height: 0 };
this.canvas.render();
}
@@ -925,7 +962,7 @@ export class CanvasInteractions {
const dy = e.clientY - this.interaction.panStart.y;
this.canvas.viewport.x -= dx / this.canvas.viewport.zoom;
this.canvas.viewport.y -= dy / this.canvas.viewport.zoom;
this.interaction.panStart = {x: e.clientX, y: e.clientY};
this.interaction.panStart = { x: e.clientX, y: e.clientY };
// Update stroke overlay if mask tool is drawing during pan
if (this.canvas.maskTool.isDrawing) {
@@ -1084,7 +1121,7 @@ export class CanvasInteractions {
// Clamp crop bounds to stay within the original image and maintain minimum size
if (newCropBounds.width < 1) {
if (handle?.includes('w')) newCropBounds.x = o.cropBounds.x + o.cropBounds.width -1;
if (handle?.includes('w')) newCropBounds.x = o.cropBounds.x + o.cropBounds.width - 1;
newCropBounds.width = 1;
}
if (newCropBounds.height < 1) {
@@ -1342,11 +1379,14 @@ export class CanvasInteractions {
}
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 ||
this.canvas.canvas.contains(document.activeElement) ||
document.activeElement === this.canvas.canvas ||
document.activeElement === document.body;
document.activeElement === this.canvas.canvas;
if (!shouldHandle) {
log.debug("Paste event ignored - not focused on canvas");

View File

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

View File

@@ -144,6 +144,26 @@ export class CanvasLayersPanel {
e.preventDefault();
e.stopPropagation();
this.deleteSelectedLayers();
return;
}
// Handle Ctrl+C/V for layer copy/paste when panel has focus
if (e.ctrlKey || e.metaKey) {
if (e.key.toLowerCase() === 'c') {
if (this.canvas.canvasSelection.selectedLayers.length > 0) {
e.preventDefault();
e.stopPropagation();
this.canvas.canvasLayers.copySelectedLayers();
log.info('Layers copied from panel');
}
} else if (e.key.toLowerCase() === 'v') {
e.preventDefault();
e.stopPropagation();
if (this.canvas.canvasLayers.internalClipboard.length > 0) {
this.canvas.canvasLayers.pasteLayers();
log.info('Layers pasted from panel');
}
}
}
});
@@ -388,6 +408,9 @@ export class CanvasLayersPanel {
this.updateSelectionAppearance();
this.updateButtonStates();
// Focus the canvas so keyboard shortcuts (like Ctrl+C/V) work for layer operations
this.canvas.canvas.focus();
log.debug(`Layer clicked: ${layer.name}, selection count: ${this.canvas.canvasSelection.selectedLayers.length}`);
}

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.`);
const loadedLayers = await this._loadLayers(savedState.layers);
this.canvas.layers = loadedLayers.filter((l): l is Layer => l !== null);
log.info(`Loaded ${this.canvas.layers.length} layers.`);
log.info(`Loaded ${this.canvas.layers.length} layers from ${savedState.layers.length} saved layers.`);
if (this.canvas.layers.length === 0) {
log.warn("No valid layers loaded, state may be corrupted.");
return false;
if (this.canvas.layers.length === 0 && savedState.layers.length > 0) {
log.warn(`Failed to load any layers. Saved state had ${savedState.layers.length} layers but all failed to load. This may indicate corrupted IndexedDB data.`);
// Don't return false - allow empty canvas to be valid
}
this.canvas.updateSelectionAfterHistory();

View File

@@ -1000,6 +1000,13 @@ $el("label.clipboard-switch.mask-switch", {
resizeObserver.observe(controlsElement);
}
// Watch the canvas container itself to detect size changes and fix canvas dimensions
const canvasContainerResizeObserver = new ResizeObserver(() => {
// Force re-read of canvas dimensions on next render
canvas.render();
});
canvasContainerResizeObserver.observe(canvasContainer);
canvas.canvas.addEventListener('focus', () => {
canvasContainer.classList.add('has-focus');
});
@@ -1195,12 +1202,23 @@ app.registerExtension({
const sendPromises: Promise<any>[] = [];
for (const [nodeId, canvasWidget] of canvasNodeInstances.entries()) {
if (app.graph.getNodeById(nodeId) && canvasWidget.canvas && canvasWidget.canvas.canvasIO) {
log.debug(`Sending data for canvas node ${nodeId}`);
sendPromises.push(canvasWidget.canvas.canvasIO.sendDataViaWebSocket(nodeId));
} else {
const node = app.graph.getNodeById(nodeId);
if (!node) {
log.warn(`Node ${nodeId} not found in graph, removing from instances map.`);
canvasNodeInstances.delete(nodeId);
continue;
}
// Skip bypassed nodes
if (node.mode === 4) {
log.debug(`Node ${nodeId} is bypassed, skipping data send.`);
continue;
}
if (canvasWidget.canvas && canvasWidget.canvas.canvasIO) {
log.debug(`Sending data for canvas node ${nodeId}`);
sendPromises.push(canvasWidget.canvas.canvasIO.sendDataViaWebSocket(nodeId));
}
}
@@ -1221,6 +1239,9 @@ app.registerExtension({
async beforeRegisterNodeDef(nodeType: any, nodeData: any, app: ComfyApp) {
if (nodeType.comfyClass === "LayerForgeNode") {
// Map to track pending copy sources across node ID changes
const pendingCopySources = new Map<number, number>();
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function (this: ComfyNode) {
log.debug("CanvasNode onNodeCreated: Base widget setup.");
@@ -1257,6 +1278,49 @@ app.registerExtension({
// Store the canvas widget on the node
(this as any).canvasWidget = canvasWidget;
// Check if this node has a pending copy source (from onConfigure)
// Check both the current ID and -1 (temporary ID during paste)
let sourceNodeId = pendingCopySources.get(this.id);
if (!sourceNodeId) {
sourceNodeId = pendingCopySources.get(-1);
if (sourceNodeId) {
// Transfer from -1 to the real ID and clear -1
pendingCopySources.delete(-1);
}
}
if (sourceNodeId && sourceNodeId !== this.id) {
log.info(`Node ${this.id} will copy canvas state from node ${sourceNodeId}`);
// Clear the flag
pendingCopySources.delete(this.id);
// Copy the canvas state now that the widget is initialized
setTimeout(async () => {
try {
const { getCanvasState, setCanvasState } = await import('./db.js');
let sourceState = await getCanvasState(String(sourceNodeId));
// If source node doesn't exist (cross-workflow paste), try clipboard
if (!sourceState) {
log.debug(`No canvas state found for source node ${sourceNodeId}, checking clipboard`);
sourceState = await getCanvasState('__clipboard__');
}
if (!sourceState) {
log.debug(`No canvas state found in clipboard either`);
return;
}
await setCanvasState(String(this.id), sourceState);
await canvasWidget.canvas.loadInitialState();
log.info(`Canvas state copied successfully to node ${this.id}`);
} catch (error) {
log.error(`Error copying canvas state:`, error);
}
}, 100);
}
// Check if there are already connected inputs
setTimeout(() => {
if (this.inputs && this.inputs.length > 0) {
@@ -1440,6 +1504,52 @@ app.registerExtension({
return onRemoved?.apply(this, arguments as any);
};
// Handle copy/paste - save canvas state when copying
const originalSerialize = nodeType.prototype.serialize;
nodeType.prototype.serialize = function (this: ComfyNode) {
const data = originalSerialize ? originalSerialize.apply(this) : {};
// Store a reference to the source node ID so we can copy layer data
data.sourceNodeId = this.id;
log.debug(`Serializing node ${this.id} for copy`);
// Store canvas state in a clipboard entry for cross-workflow paste
// This happens async but that's fine since paste happens later
(async () => {
try {
const { getCanvasState, setCanvasState } = await import('./db.js');
const sourceState = await getCanvasState(String(this.id));
if (sourceState) {
// Store in a special "clipboard" entry
await setCanvasState('__clipboard__', sourceState);
log.debug(`Stored canvas state in clipboard for node ${this.id}`);
}
} catch (error) {
log.error('Error storing canvas state to clipboard:', error);
}
})();
return data;
};
// Handle copy/paste - load canvas state from source node when pasting
const originalConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = async function (this: ComfyNode, data: any) {
if (originalConfigure) {
originalConfigure.apply(this, [data]);
}
// Store the source node ID in the map (persists across node ID changes)
// This will be picked up later in onAdded when the canvas widget is ready
if (data.sourceNodeId && data.sourceNodeId !== this.id) {
const existingSource = pendingCopySources.get(this.id);
if (!existingSource) {
pendingCopySources.set(this.id, data.sourceNodeId);
log.debug(`Stored pending copy source: ${data.sourceNodeId} for node ${this.id}`);
}
}
};
const originalGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function (this: ComfyNode, _: any, options: any[]) {
// FIRST: Call original to let other extensions add their options