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

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

View File

@@ -132,16 +132,16 @@ export class CanvasInteractions {
const mouseBufferY = (worldCoords.y - this.canvas.viewport.y) * this.canvas.viewport.zoom;
const newZoom = Math.max(0.1, Math.min(10, this.canvas.viewport.zoom * zoomFactor));
this.canvas.viewport.zoom = newZoom;
this.canvas.viewport.x = worldCoords.x - (mouseBufferX / this.canvas.viewport.zoom);
this.canvas.viewport.y = worldCoords.y - (mouseBufferY / this.canvas.viewport.zoom);
// Update stroke overlay if mask tool is drawing during zoom
if (this.canvas.maskTool.isDrawing) {
this.canvas.maskTool.handleViewportChange();
}
this.canvas.onViewportChange?.();
}
@@ -176,6 +176,9 @@ export class CanvasInteractions {
document.addEventListener('paste', this.onPaste as unknown as EventListener);
// Intercept Ctrl+V during capture phase to handle layer paste before ComfyUI
document.addEventListener('keydown', this.onKeyDown as EventListener, { capture: true });
this.canvas.canvas.addEventListener('mouseenter', this.onMouseEnter as EventListener);
this.canvas.canvas.addEventListener('mouseleave', this.onMouseLeave as EventListener);
@@ -195,6 +198,9 @@ export class CanvasInteractions {
this.canvas.canvas.removeEventListener('keydown', this.onKeyDown as EventListener);
this.canvas.canvas.removeEventListener('keyup', this.onKeyUp as EventListener);
// Remove document-level capture listener
document.removeEventListener('keydown', this.onKeyDown as EventListener, { capture: true });
window.removeEventListener('blur', this.onBlur);
document.removeEventListener('paste', this.onPaste as unknown as EventListener);
@@ -228,7 +234,7 @@ export class CanvasInteractions {
const rotatedY = dx * Math.sin(rad) + dy * Math.cos(rad);
// Sprawdź czy punkt jest wewnątrz prostokąta layera
if (Math.abs(rotatedX) <= layer.width / 2 &&
if (Math.abs(rotatedX) <= layer.width / 2 &&
Math.abs(rotatedY) <= layer.height / 2) {
return true;
}
@@ -277,6 +283,14 @@ export class CanvasInteractions {
handleMouseDown(e: MouseEvent): void {
this.canvas.canvas.focus();
// Sync modifier states with actual event state to prevent "stuck" modifiers
// when focus moves between layers panel and canvas
this.interaction.isCtrlPressed = e.ctrlKey;
this.interaction.isMetaPressed = e.metaKey;
this.interaction.isShiftPressed = e.shiftKey;
this.interaction.isAltPressed = e.altKey;
const coords = this.getMouseCoordinates(e);
const mods = this.getModifierState(e);
@@ -325,11 +339,11 @@ export class CanvasInteractions {
this.startCanvasResize(coords.world);
return;
}
// 2. Inne przyciski myszy
if (e.button === 2) { // Prawy przycisk myszy
this.preventEventDefaults(e);
// Sprawdź czy kliknięto w obszarze któregokolwiek z zaznaczonych layerów (niezależnie od przykrycia)
if (this.isPointInSelectedLayers(coords.world.x, coords.world.y)) {
// Nowa logika przekazuje tylko współrzędne świata, menu pozycjonuje się samo
@@ -354,7 +368,7 @@ export class CanvasInteractions {
if (grabIconLayer) {
// Start dragging the selected layer(s) without changing selection
this.interaction.mode = 'potential-drag';
this.interaction.dragStart = {...coords.world};
this.interaction.dragStart = { ...coords.world };
return;
}
@@ -363,7 +377,7 @@ export class CanvasInteractions {
this.prepareForDrag(clickedLayerResult.layer, coords.world);
return;
}
// 4. Domyślna akcja na tle (lewy przycisk bez modyfikatorów)
this.startPanning(e, true); // clearSelection = true
}
@@ -371,7 +385,7 @@ export class CanvasInteractions {
handleMouseMove(e: MouseEvent): void {
const coords = this.getMouseCoordinates(e);
this.canvas.lastMousePosition = coords.world; // Zawsze aktualizuj ostatnią pozycję myszy
// Sprawdź, czy rozpocząć przeciąganie
if (this.interaction.mode === 'potential-drag') {
const dx = coords.world.x - this.interaction.dragStart.x;
@@ -384,7 +398,7 @@ export class CanvasInteractions {
});
}
}
switch (this.interaction.mode) {
case 'drawingMask':
this.canvas.maskTool.handleMouseMove(coords.world, coords.view);
@@ -419,12 +433,12 @@ export class CanvasInteractions {
// Check if hovering over grab icon
const wasHovering = this.interaction.hoveringGrabIcon;
this.interaction.hoveringGrabIcon = this.getGrabIconAtPosition(coords.world.x, coords.world.y) !== null;
// Re-render if hover state changed to show/hide grab icon
if (wasHovering !== this.interaction.hoveringGrabIcon) {
this.canvas.render();
}
this.updateCursor(coords.world);
// Update brush cursor on overlay if mask tool is active
if (this.canvas.maskTool.isActive) {
@@ -441,7 +455,7 @@ export class CanvasInteractions {
handleMouseUp(e: MouseEvent): void {
const coords = this.getMouseCoordinates(e);
if (this.interaction.mode === 'drawingMask') {
this.canvas.maskTool.handleMouseUp(coords.view);
// Render only once after drawing is complete
@@ -470,7 +484,7 @@ export class CanvasInteractions {
if (this.interaction.mode === 'resizing' && this.interaction.transformingLayer?.cropMode) {
this.canvas.canvasLayers.handleCropBoundsTransformEnd(this.interaction.transformingLayer);
}
// Handle end of scale transformation (normal transform mode) before resetting interaction state
if (this.interaction.mode === 'resizing' && this.interaction.transformingLayer && !this.interaction.transformingLayer.cropMode) {
this.canvas.canvasLayers.handleScaleTransformEnd(this.interaction.transformingLayer);
@@ -494,7 +508,7 @@ export class CanvasInteractions {
log.info(`Mouse position: world(${coords.world.x.toFixed(1)}, ${coords.world.y.toFixed(1)}) view(${coords.view.x.toFixed(1)}, ${coords.view.y.toFixed(1)})`);
log.info(`Output Area Bounds: x=${bounds.x}, y=${bounds.y}, w=${bounds.width}, h=${bounds.height}`);
log.info(`Viewport: x=${this.canvas.viewport.x.toFixed(1)}, y=${this.canvas.viewport.y.toFixed(1)}, zoom=${this.canvas.viewport.zoom.toFixed(2)}`);
this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer, index: number) => {
const relativeToOutput = {
x: layer.x - bounds.x,
@@ -541,7 +555,7 @@ export class CanvasInteractions {
handleWheel(e: WheelEvent): void {
this.preventEventDefaults(e);
const coords = this.getMouseCoordinates(e);
if (this.canvas.maskTool.isActive || this.canvas.canvasSelection.selectedLayers.length === 0) {
// Zoom operation for mask tool or when no layers selected
const zoomFactor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
@@ -549,7 +563,7 @@ export class CanvasInteractions {
} else {
// Check if mouse is over any selected layer
const isOverSelectedLayer = this.isPointInSelectedLayers(coords.world.x, coords.world.y);
if (isOverSelectedLayer) {
// Layer transformation when layers are selected and mouse is over selected layer
this.handleLayerWheelTransformation(e);
@@ -559,7 +573,7 @@ export class CanvasInteractions {
this.performZoomOperation(coords.world, zoomFactor);
}
}
this.canvas.render();
if (!this.canvas.maskTool.isActive) {
this.canvas.requestSaveState();
@@ -615,7 +629,7 @@ export class CanvasInteractions {
layer.height *= scaleFactor;
layer.x += (oldWidth - layer.width) / 2;
layer.y += (oldHeight - layer.height) / 2;
// Handle wheel scaling end for layers with blend area
this.canvas.canvasLayers.handleWheelScalingEnd(layer);
}
@@ -631,11 +645,11 @@ export class CanvasInteractions {
} else {
targetHeight = (Math.ceil(oldHeight / gridSize) - 1) * gridSize;
}
if (targetHeight < gridSize / 2) {
targetHeight = gridSize / 2;
}
if (Math.abs(oldHeight - targetHeight) < 1) {
if (direction > 0) targetHeight += gridSize;
else targetHeight -= gridSize;
@@ -646,11 +660,23 @@ export class CanvasInteractions {
}
handleKeyDown(e: KeyboardEvent): void {
// Always track modifier keys regardless of focus
if (e.key === 'Control') this.interaction.isCtrlPressed = true;
if (e.key === 'Meta') this.interaction.isMetaPressed = true;
if (e.key === 'Shift') this.interaction.isShiftPressed = true;
if (e.key === 'Alt') this.interaction.isAltPressed = true;
// Check if canvas is focused before handling any shortcuts
const shouldHandle = this.canvas.isMouseOver ||
this.canvas.canvas.contains(document.activeElement) ||
document.activeElement === this.canvas.canvas;
if (!shouldHandle) {
return;
}
// Canvas-specific key handlers (only when focused)
if (e.key === 'Alt') {
this.interaction.isAltPressed = true;
e.preventDefault();
}
if (e.key.toLowerCase() === 's') {
@@ -664,7 +690,7 @@ export class CanvasInteractions {
this.canvas.shapeTool.activate();
return;
}
// Globalne skróty (Undo/Redo/Copy/Paste)
const mods = this.getModifierState(e);
if (mods.ctrl || mods.meta) {
@@ -685,6 +711,17 @@ export class CanvasInteractions {
this.canvas.canvasLayers.copySelectedLayers();
}
break;
case 'v':
// Only handle internal clipboard paste here.
// If internal clipboard is empty, let the paste event bubble
// so handlePasteEvent can access e.clipboardData for system images.
if (this.canvas.canvasLayers.internalClipboard.length > 0) {
this.canvas.canvasLayers.pasteLayers();
} else {
// Don't preventDefault - let paste event fire for system clipboard
handled = false;
}
break;
default:
handled = false;
break;
@@ -700,7 +737,7 @@ export class CanvasInteractions {
if (this.canvas.canvasSelection.selectedLayers.length > 0) {
const step = mods.shift ? 10 : 1;
let needsRender = false;
// Używamy e.code dla spójności i niezależności od układu klawiatury
const movementKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'BracketLeft', 'BracketRight'];
if (movementKeys.includes(e.code)) {
@@ -724,7 +761,7 @@ export class CanvasInteractions {
this.canvas.canvasSelection.removeSelectedLayers();
return;
}
if (needsRender) {
this.canvas.render();
}
@@ -770,7 +807,7 @@ export class CanvasInteractions {
this.canvas.saveState();
this.canvas.canvasState.saveStateToDB();
}
// Reset interaction mode if it's something that can get "stuck"
if (this.interaction.mode !== 'none' && this.interaction.mode !== 'drawingMask') {
this.resetInteractionState();
@@ -820,7 +857,7 @@ export class CanvasInteractions {
originalHeight: layer.originalHeight,
cropBounds: layer.cropBounds ? { ...layer.cropBounds } : undefined
};
this.interaction.dragStart = {...worldCoords};
this.interaction.dragStart = { ...worldCoords };
if (handle === 'rot') {
this.interaction.mode = 'rotating';
@@ -844,19 +881,19 @@ export class CanvasInteractions {
if (mods.ctrl || mods.meta) {
const index = this.canvas.canvasSelection.selectedLayers.indexOf(layer);
if (index === -1) {
// Ctrl-clicking unselected layer: add to selection
this.canvas.canvasSelection.updateSelection([...this.canvas.canvasSelection.selectedLayers, layer]);
} else {
const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l: Layer) => l !== layer);
this.canvas.canvasSelection.updateSelection(newSelection);
}
// If already selected, do NOT deselect - allows dragging multiple layers with Ctrl held
// User can use right-click in layers panel to deselect individual layers
} else {
if (!this.canvas.canvasSelection.selectedLayers.includes(layer)) {
this.canvas.canvasSelection.updateSelection([layer]);
}
}
this.interaction.mode = 'potential-drag';
this.interaction.dragStart = {...worldCoords};
this.interaction.dragStart = { ...worldCoords };
}
startPanning(e: MouseEvent, clearSelection: boolean = true): void {
@@ -872,8 +909,8 @@ export class CanvasInteractions {
this.interaction.mode = 'resizingCanvas';
const startX = snapToGrid(worldCoords.x);
const startY = snapToGrid(worldCoords.y);
this.interaction.canvasResizeStart = {x: startX, y: startY};
this.interaction.canvasResizeRect = {x: startX, y: startY, width: 0, height: 0};
this.interaction.canvasResizeStart = { x: startX, y: startY };
this.interaction.canvasResizeRect = { x: startX, y: startY, width: 0, height: 0 };
this.canvas.render();
}
@@ -887,7 +924,7 @@ export class CanvasInteractions {
updateCanvasMove(worldCoords: Point): void {
const dx = worldCoords.x - this.interaction.dragStart.x;
const dy = worldCoords.y - this.interaction.dragStart.y;
// Po prostu przesuwamy outputAreaBounds
const bounds = this.canvas.outputAreaBounds;
this.interaction.canvasMoveRect = {
@@ -911,11 +948,11 @@ export class CanvasInteractions {
width: moveRect.width,
height: moveRect.height
};
// Update mask canvas to ensure it covers the new output area position
this.canvas.maskTool.updateMaskCanvasForOutputArea();
}
this.canvas.render();
this.canvas.saveState();
}
@@ -925,13 +962,13 @@ export class CanvasInteractions {
const dy = e.clientY - this.interaction.panStart.y;
this.canvas.viewport.x -= dx / this.canvas.viewport.zoom;
this.canvas.viewport.y -= dy / this.canvas.viewport.zoom;
this.interaction.panStart = {x: e.clientX, y: e.clientY};
this.interaction.panStart = { x: e.clientX, y: e.clientY };
// Update stroke overlay if mask tool is drawing during pan
if (this.canvas.maskTool.isDrawing) {
this.canvas.maskTool.handleViewportChange();
}
this.canvas.render();
this.canvas.onViewportChange?.();
}
@@ -994,7 +1031,7 @@ export class CanvasInteractions {
const o = this.interaction.transformOrigin;
if (!o) return;
const handle = this.interaction.resizeHandle;
const anchor = this.interaction.resizeAnchor;
const rad = o.rotation * Math.PI / 180;
@@ -1012,7 +1049,7 @@ export class CanvasInteractions {
// Determine sign based on handle
const signX = handle?.includes('e') ? 1 : (handle?.includes('w') ? -1 : 0);
const signY = handle?.includes('s') ? 1 : (handle?.includes('n') ? -1 : 0);
localVecX *= signX;
localVecY *= signY;
@@ -1022,13 +1059,13 @@ export class CanvasInteractions {
if (layer.cropMode && o.cropBounds && o.originalWidth && o.originalHeight) {
// CROP MODE: Calculate delta based on mouse movement and apply to cropBounds.
// Calculate mouse movement since drag start, in the layer's local coordinate system.
const dragStartX_local = this.interaction.dragStart.x - (o.centerX ?? 0);
const dragStartY_local = this.interaction.dragStart.y - (o.centerY ?? 0);
const mouseX_local = mouseX - (o.centerX ?? 0);
const mouseY_local = mouseY - (o.centerY ?? 0);
// Rotate mouse delta into the layer's unrotated frame
const deltaX_world = mouseX_local - dragStartX_local;
const deltaY_world = mouseY_local - dragStartY_local;
@@ -1041,20 +1078,20 @@ export class CanvasInteractions {
if (layer.flipV) {
mouseDeltaY_local *= -1;
}
// Convert the on-screen mouse delta to an image-space delta.
const screenToImageScaleX = o.originalWidth / o.width;
const screenToImageScaleY = o.originalHeight / o.height;
const delta_image_x = mouseDeltaX_local * screenToImageScaleX;
const delta_image_y = mouseDeltaY_local * screenToImageScaleY;
let newCropBounds = { ...o.cropBounds }; // Start with the bounds from the beginning of the drag
// Apply the image-space delta to the appropriate edges of the crop bounds
const isFlippedH = layer.flipH;
const isFlippedV = layer.flipV;
if (handle?.includes('w')) {
if (isFlippedH) newCropBounds.width += delta_image_x;
else {
@@ -1081,10 +1118,10 @@ export class CanvasInteractions {
newCropBounds.height -= delta_image_y;
} else newCropBounds.height += delta_image_y;
}
// Clamp crop bounds to stay within the original image and maintain minimum size
// Clamp crop bounds to stay within the original image and maintain minimum size
if (newCropBounds.width < 1) {
if (handle?.includes('w')) newCropBounds.x = o.cropBounds.x + o.cropBounds.width -1;
if (handle?.includes('w')) newCropBounds.x = o.cropBounds.x + o.cropBounds.width - 1;
newCropBounds.width = 1;
}
if (newCropBounds.height < 1) {
@@ -1112,7 +1149,7 @@ export class CanvasInteractions {
// TRANSFORM MODE: Resize the layer's main transform frame
let newWidth = localVecX;
let newHeight = localVecY;
if (isShiftPressed) {
const originalAspectRatio = o.width / o.height;
if (Math.abs(newWidth) > Math.abs(newHeight) * originalAspectRatio) {
@@ -1124,10 +1161,10 @@ export class CanvasInteractions {
if (newWidth < 10) newWidth = 10;
if (newHeight < 10) newHeight = 10;
layer.width = newWidth;
layer.height = newHeight;
// Update position to keep anchor point fixed
const deltaW = layer.width - o.width;
const deltaH = layer.height - o.height;
@@ -1193,7 +1230,7 @@ export class CanvasInteractions {
this.canvas.updateOutputAreaSize(newWidth, newHeight);
}
this.canvas.render();
this.canvas.saveState();
}
@@ -1291,7 +1328,7 @@ export class CanvasInteractions {
// Store the original canvas size for extension calculations
this.canvas.originalCanvasSize = { width: newWidth, height: newHeight };
// Store the original position where custom shape was drawn for extension calculations
this.canvas.originalOutputAreaPosition = { x: newX, y: newY };
@@ -1300,10 +1337,10 @@ export class CanvasInteractions {
const ext = this.canvas.outputAreaExtensions;
const extendedWidth = newWidth + ext.left + ext.right;
const extendedHeight = newHeight + ext.top + ext.bottom;
// Update canvas size with extensions
this.canvas.updateOutputAreaSize(extendedWidth, extendedHeight, false);
// Set outputAreaBounds accounting for extensions
this.canvas.outputAreaBounds = {
x: newX - ext.left, // Adjust position by left extension
@@ -1311,19 +1348,19 @@ export class CanvasInteractions {
width: extendedWidth,
height: extendedHeight
};
log.info(`New custom shape with extensions: original(${newX}, ${newY}) extended(${newX - ext.left}, ${newY - ext.top}) size(${extendedWidth}x${extendedHeight})`);
} else {
// No extensions - use original size and position
this.canvas.updateOutputAreaSize(newWidth, newHeight, false);
this.canvas.outputAreaBounds = {
x: newX,
y: newY,
width: newWidth,
height: newHeight
};
log.info(`New custom shape without extensions: position(${newX}, ${newY}) size(${newWidth}x${newHeight})`);
}
@@ -1342,12 +1379,15 @@ export class CanvasInteractions {
}
async handlePasteEvent(e: ClipboardEvent): Promise<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");
return;
@@ -1356,7 +1396,7 @@ export class CanvasInteractions {
log.info("Paste event detected, checking clipboard preference");
const preference = this.canvas.canvasLayers.clipboardPreference;
if (preference === 'clipspace') {
log.info("Clipboard preference is clipspace, delegating to ClipboardManager");
@@ -1400,7 +1440,7 @@ export class CanvasInteractions {
public activateOutputAreaTransform(): void {
// Clear any existing interaction state before starting transform
this.resetInteractionState();
// Deactivate any active tools that might conflict
if (this.canvas.shapeTool.isActive) {
this.canvas.shapeTool.deactivate();
@@ -1408,10 +1448,10 @@ export class CanvasInteractions {
if (this.canvas.maskTool.isActive) {
this.canvas.maskTool.deactivate();
}
// Clear selection to avoid confusion
this.canvas.canvasSelection.updateSelection([]);
// Set transform mode
this.interaction.mode = 'transformingOutputArea';
this.canvas.render();
@@ -1420,7 +1460,7 @@ export class CanvasInteractions {
private getOutputAreaHandle(worldCoords: Point): string | null {
const bounds = this.canvas.outputAreaBounds;
const threshold = 10 / this.canvas.viewport.zoom;
// Define handle positions
const handles = {
'nw': { x: bounds.x, y: bounds.y },
@@ -1447,7 +1487,7 @@ export class CanvasInteractions {
private startOutputAreaTransform(handle: string, worldCoords: Point): void {
this.interaction.outputAreaTransformHandle = handle;
this.interaction.dragStart = { ...worldCoords };
const bounds = this.canvas.outputAreaBounds;
this.interaction.transformOrigin = {
x: bounds.x,
@@ -1470,17 +1510,17 @@ export class CanvasInteractions {
'sw': { x: bounds.x + bounds.width, y: bounds.y },
'w': { x: bounds.x + bounds.width, y: bounds.y + bounds.height / 2 },
};
this.interaction.outputAreaTransformAnchor = anchorMap[handle];
}
private resizeOutputAreaFromHandle(worldCoords: Point, isShiftPressed: boolean): void {
const o = this.interaction.transformOrigin;
if (!o) return;
const handle = this.interaction.outputAreaTransformHandle;
const anchor = this.interaction.outputAreaTransformAnchor;
let newX = o.x;
let newY = o.y;
let newWidth = o.width;
@@ -1551,7 +1591,7 @@ export class CanvasInteractions {
private updateOutputAreaTransformCursor(worldCoords: Point): void {
const handle = this.getOutputAreaHandle(worldCoords);
if (handle) {
const cursorMap: { [key: string]: string } = {
'n': 'ns-resize', 's': 'ns-resize',
@@ -1567,16 +1607,16 @@ export class CanvasInteractions {
private finalizeOutputAreaTransform(): void {
const bounds = this.canvas.outputAreaBounds;
// Update canvas size and mask tool
this.canvas.updateOutputAreaSize(bounds.width, bounds.height);
// Update mask canvas for new output area
this.canvas.maskTool.updateMaskCanvasForOutputArea();
// Save state
this.canvas.saveState();
// Reset transform handle but keep transform mode active
this.interaction.outputAreaTransformHandle = null;
}

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

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

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.");
@@ -1253,10 +1274,53 @@ app.registerExtension({
const canvasWidget = await createCanvasWidget(this, null, app);
canvasNodeInstances.set(this.id, canvasWidget);
log.info(`Registered CanvasNode instance for ID: ${this.id}`);
// Store the canvas widget on the node
(this as any).canvasWidget = canvasWidget;
// Check if this node has a pending copy source (from onConfigure)
// Check both the current ID and -1 (temporary ID during paste)
let sourceNodeId = pendingCopySources.get(this.id);
if (!sourceNodeId) {
sourceNodeId = pendingCopySources.get(-1);
if (sourceNodeId) {
// Transfer from -1 to the real ID and clear -1
pendingCopySources.delete(-1);
}
}
if (sourceNodeId && sourceNodeId !== this.id) {
log.info(`Node ${this.id} will copy canvas state from node ${sourceNodeId}`);
// Clear the flag
pendingCopySources.delete(this.id);
// Copy the canvas state now that the widget is initialized
setTimeout(async () => {
try {
const { getCanvasState, setCanvasState } = await import('./db.js');
let sourceState = await getCanvasState(String(sourceNodeId));
// If source node doesn't exist (cross-workflow paste), try clipboard
if (!sourceState) {
log.debug(`No canvas state found for source node ${sourceNodeId}, checking clipboard`);
sourceState = await getCanvasState('__clipboard__');
}
if (!sourceState) {
log.debug(`No canvas state found in clipboard either`);
return;
}
await setCanvasState(String(this.id), sourceState);
await canvasWidget.canvas.loadInitialState();
log.info(`Canvas state copied successfully to node ${this.id}`);
} catch (error) {
log.error(`Error copying canvas state:`, error);
}
}, 100);
}
// Check if there are already connected inputs
setTimeout(() => {
if (this.inputs && this.inputs.length > 0) {
@@ -1440,6 +1504,52 @@ app.registerExtension({
return onRemoved?.apply(this, arguments as any);
};
// Handle copy/paste - save canvas state when copying
const originalSerialize = nodeType.prototype.serialize;
nodeType.prototype.serialize = function (this: ComfyNode) {
const data = originalSerialize ? originalSerialize.apply(this) : {};
// Store a reference to the source node ID so we can copy layer data
data.sourceNodeId = this.id;
log.debug(`Serializing node ${this.id} for copy`);
// Store canvas state in a clipboard entry for cross-workflow paste
// This happens async but that's fine since paste happens later
(async () => {
try {
const { getCanvasState, setCanvasState } = await import('./db.js');
const sourceState = await getCanvasState(String(this.id));
if (sourceState) {
// Store in a special "clipboard" entry
await setCanvasState('__clipboard__', sourceState);
log.debug(`Stored canvas state in clipboard for node ${this.id}`);
}
} catch (error) {
log.error('Error storing canvas state to clipboard:', error);
}
})();
return data;
};
// Handle copy/paste - load canvas state from source node when pasting
const originalConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = async function (this: ComfyNode, data: any) {
if (originalConfigure) {
originalConfigure.apply(this, [data]);
}
// Store the source node ID in the map (persists across node ID changes)
// This will be picked up later in onAdded when the canvas widget is ready
if (data.sourceNodeId && data.sourceNodeId !== this.id) {
const existingSource = pendingCopySources.get(this.id);
if (!existingSource) {
pendingCopySources.set(this.id, data.sourceNodeId);
log.debug(`Stored pending copy source: ${data.sourceNodeId} for node ${this.id}`);
}
}
};
const originalGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function (this: ComfyNode, _: any, options: any[]) {
// FIRST: Call original to let other extensions add their options