27 Commits

Author SHA1 Message Date
Dariusz L
abbc78c1d0 Update pyproject.toml 2026-05-01 20:31:47 +02:00
Dariusz L
539be6fbdf Merge pull request #29 from VladiCz/main
replace hardcoded ws protocol with wss protocol based on SSL
2026-05-01 20:26:07 +02:00
VladiCz
c7239ae9d9 use offline downloaded model first 2026-04-26 10:14:33 +02:00
VladiCz
65f3dcc5d5 replace hardcoded ws protocol with wss protocol based on SSL 2026-04-25 10:41:28 +02:00
Dariusz L
4bce819918 Update pyproject.toml 2026-04-13 17:29:03 +02:00
Dariusz L
5cf10e5e5c Add Undo/Redo shortcuts to templates
Insert an "Undo & Redo" section into standard_shortcuts.html in both js/templates and src/templates. Documents Ctrl+Z for undo and Ctrl+Y or Ctrl+Shift+Z for redo so the UI shortcut list includes undo/redo actions.
2026-04-13 17:25:50 +02:00
Dariusz L
72ef62e642 Merge pull request #28 from diodiogod/pr/comfyui-shortcut-leaks-clean
Fix ComfyUI shortcut leaks while LayerForge widget is active
2026-04-13 17:18:04 +02:00
diodiogod
1edde25d75 Fix ComfyUI shortcut leaks in LayerForge
Technical details:
- skip LayerForge canvas and panel shortcuts when focus is inside editable controls
- patch ComfyUI ChangeTracker undoRedo so Ctrl/Cmd+Z does not leak to graph history while LayerForge owns the shortcut context
- stop clipboard copy/cut/paste events from editable LayerForge UI from bubbling into ComfyUI node clipboard handlers
- route widget-root Ctrl/Cmd+Z, Ctrl/Cmd+Y, and Ctrl/Cmd+Shift+Z through LayerForge canvas undo and redo when the widget is active
2026-03-18 00:34:16 -03:00
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
20 changed files with 1036 additions and 262 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

@@ -741,27 +741,124 @@ class LayerForgeNode:
return None
def _get_birefnet_base_paths():
paths = []
comfy_models_dir = getattr(folder_paths, "models_dir", None)
if comfy_models_dir:
paths.append(os.path.join(comfy_models_dir, "BiRefNet"))
legacy_models_dir = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
"models",
"BiRefNet"
)
paths.append(legacy_models_dir)
unique_paths = []
seen = set()
for path in paths:
normalized = os.path.normpath(path)
if normalized not in seen:
seen.add(normalized)
unique_paths.append(path)
return unique_paths
def _is_valid_birefnet_model_dir(path):
if not os.path.isdir(path):
return False
try:
files = os.listdir(path)
except OSError:
return False
has_config = "config.json" in files
has_model = "model.safetensors" in files or "pytorch_model.bin" in files
has_backbone = "backbone_swin.pth" in files or "swin_base_patch4_window12_384_22kto1k.pth" in files
has_birefnet = "birefnet.pth" in files or any(f.endswith(".pth") for f in files)
return has_config and (has_model or has_backbone or has_birefnet)
def _find_local_birefnet_model():
for base_path in _get_birefnet_base_paths():
if not os.path.isdir(base_path):
continue
if _is_valid_birefnet_model_dir(base_path):
return base_path
try:
existing_items = os.listdir(base_path)
except OSError:
continue
model_subdirs = [
d for d in existing_items
if os.path.isdir(os.path.join(base_path, d)) and
(d.startswith("models--") or d == "ZhengPeng7--BiRefNet")
]
for subdir in model_subdirs:
snapshots_path = os.path.join(base_path, subdir, "snapshots")
if not os.path.isdir(snapshots_path):
continue
try:
snapshot_dirs = os.listdir(snapshots_path)
except OSError:
continue
for snapshot in snapshot_dirs:
snapshot_path = os.path.join(snapshots_path, snapshot)
if _is_valid_birefnet_model_dir(snapshot_path):
return snapshot_path
return None
class BiRefNetMatting:
def __init__(self):
self.model = None
self.model_path = None
self.model_cache = {}
self.base_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
"models")
self.base_paths = _get_birefnet_base_paths()
def load_model(self, model_path):
from json.decoder import JSONDecodeError
try:
if model_path not in self.model_cache:
full_model_path = os.path.join(self.base_path, "BiRefNet")
log_info(f"Loading BiRefNet model from {full_model_path}...")
local_model_path = _find_local_birefnet_model()
cache_dir = self.base_paths[0] if self.base_paths else None
if local_model_path:
log_info(f"Loading BiRefNet model from local path {local_model_path}...")
try:
self.model = AutoModelForImageSegmentation.from_pretrained(
local_model_path,
trust_remote_code=True
)
self.model.eval()
if torch.cuda.is_available():
self.model = self.model.cuda()
self.model_cache[model_path] = self.model
log_info("Model loaded successfully from local disk")
return
except Exception as local_error:
log_warn(f"Failed to load local BiRefNet model from {local_model_path}: {str(local_error)}")
log_info("Falling back to Hugging Face model loading")
full_model_path = cache_dir or "BiRefNet"
log_info(f"Loading BiRefNet model from Hugging Face cache {full_model_path}...")
try:
# Try loading with additional configuration to handle compatibility issues
self.model = AutoModelForImageSegmentation.from_pretrained(
"ZhengPeng7/BiRefNet",
trust_remote_code=True,
cache_dir=full_model_path,
cache_dir=cache_dir,
# Add force_download=False to use cached version if available
force_download=False,
# Add local_files_only=False to allow downloading if needed
@@ -924,73 +1021,25 @@ async def check_matting_model(request):
})
# Check if model exists in cache
base_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "models")
model_path = os.path.join(base_path, "BiRefNet")
local_model_path = _find_local_birefnet_model()
# Look for the actual BiRefNet model structure
model_files_exist = False
if os.path.exists(model_path):
# BiRefNet model from Hugging Face has a specific structure
# Check for subdirectories that indicate the model is downloaded
existing_items = os.listdir(model_path) if os.path.isdir(model_path) else []
# Look for the model subdirectory (usually named with the model ID)
model_subdirs = [d for d in existing_items if os.path.isdir(os.path.join(model_path, d)) and
(d.startswith("models--") or d == "ZhengPeng7--BiRefNet")]
if model_subdirs:
# Found model subdirectory, check inside for actual model files
for subdir in model_subdirs:
subdir_path = os.path.join(model_path, subdir)
# Navigate through the cache structure
if os.path.exists(os.path.join(subdir_path, "snapshots")):
snapshots_path = os.path.join(subdir_path, "snapshots")
snapshot_dirs = os.listdir(snapshots_path) if os.path.isdir(snapshots_path) else []
for snapshot in snapshot_dirs:
snapshot_path = os.path.join(snapshots_path, snapshot)
snapshot_files = os.listdir(snapshot_path) if os.path.isdir(snapshot_path) else []
# Check for essential files - BiRefNet uses model.safetensors
has_config = "config.json" in snapshot_files
has_model = "model.safetensors" in snapshot_files or "pytorch_model.bin" in snapshot_files
has_backbone = "backbone_swin.pth" in snapshot_files or "swin_base_patch4_window12_384_22kto1k.pth" in snapshot_files
has_birefnet = "birefnet.pth" in snapshot_files or any(f.endswith(".pth") for f in snapshot_files)
# Model is valid if it has config and either model.safetensors or other model files
if has_config and (has_model or has_backbone or has_birefnet):
model_files_exist = True
log_info(f"Found model files in: {snapshot_path} (config: {has_config}, model: {has_model})")
break
if model_files_exist:
break
# Also check if there are .pth files directly in the model_path
if not model_files_exist:
direct_files = existing_items
has_config = "config.json" in direct_files
has_model_files = any(f.endswith((".pth", ".bin", ".safetensors")) for f in direct_files)
model_files_exist = has_config and has_model_files
if model_files_exist:
log_info(f"Found model files directly in: {model_path}")
if model_files_exist:
if local_model_path:
# Model files exist, assume it's ready
log_info("BiRefNet model files detected")
log_info(f"BiRefNet model files detected at {local_model_path}")
return web.json_response({
"available": True,
"reason": "ready",
"message": "Model is ready to use"
"message": "Model is ready to use",
"model_path": local_model_path
})
else:
log_info(f"BiRefNet model not found in {model_path}")
searched_paths = _get_birefnet_base_paths()
log_info(f"BiRefNet model not found in any of: {searched_paths}")
return web.json_response({
"available": False,
"reason": "not_downloaded",
"message": "The matting model needs to be downloaded. This will happen automatically when you first use the matting feature (requires internet connection).",
"model_path": model_path
"model_path": searched_paths[0] if searched_paths else None
})
except Exception as e:

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

@@ -94,6 +94,16 @@ export class CanvasInteractions {
this.canvas.canvas.style.border = '';
}
}
isEditableElement(target) {
if (!(target instanceof HTMLElement)) {
return false;
}
if (target.isContentEditable) {
return true;
}
const editableSelector = 'input, textarea, select, [contenteditable="true"]';
return !!target.closest(editableSelector);
}
setupEventListeners() {
this.canvas.canvas.addEventListener('mousedown', this.onMouseDown);
this.canvas.canvas.addEventListener('mousemove', this.onMouseMove);
@@ -104,6 +114,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 +131,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 +202,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 +539,27 @@ 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;
if (this.isEditableElement(e.target) || this.isEditableElement(document.activeElement)) {
return;
}
// 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 +593,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 +757,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 +1198,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

@@ -118,11 +118,43 @@ export class CanvasLayersPanel {
this.setupControlButtons();
this.setupMasterVisibilityToggle();
// Dodaj listener dla klawiatury, aby usuwanie działało z panelu
const isEditableTarget = (target) => {
if (!(target instanceof HTMLElement)) {
return false;
}
if (target.isContentEditable) {
return true;
}
return !!target.closest('input, textarea, select, [contenteditable="true"]');
};
this.container.addEventListener('keydown', (e) => {
if (isEditableTarget(e.target)) {
return;
}
if (e.key === 'Delete' || e.key === 'Backspace') {
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 +361,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

@@ -1,6 +1,8 @@
// @ts-ignore
import { app } from "../../scripts/app.js";
// @ts-ignore
import { ChangeTracker } from "../../scripts/changeTracker.js";
// @ts-ignore
import { $el } from "../../scripts/ui.js";
import { addStylesheet, getUrl, loadTemplate } from "./utils/ResourceManager.js";
import { Canvas } from "./Canvas.js";
@@ -12,6 +14,52 @@ import { showErrorNotification, showSuccessNotification, showInfoNotification, s
import { iconLoader, LAYERFORGE_TOOLS } from "./utils/IconLoader.js";
import { setupSAMDetectorHook } from "./SAMDetectorIntegration.js";
const log = createModuleLogger('Canvas_view');
const LAYERFORGE_CHANGE_TRACKER_PATCH_FLAG = '__layerForgeUndoRedoPatched';
const LAYERFORGE_SHORTCUT_ACTIVE_ATTR = 'data-layerforge-shortcuts-active';
const isLayerForgeEditableElement = (target) => {
if (!(target instanceof HTMLElement)) {
return false;
}
if (target.isContentEditable) {
return true;
}
return !!target.closest('.painterMainContainer input, .painterMainContainer textarea, .painterMainContainer select, .painterMainContainer [contenteditable="true"]');
};
const isLayerForgeShortcutContextElement = (target) => {
return target instanceof HTMLElement && !!target.closest('.painterMainContainer');
};
const isLayerForgeShortcutContextActive = (event) => {
if (event && isLayerForgeShortcutContextElement(event.target)) {
return true;
}
if (isLayerForgeShortcutContextElement(document.activeElement)) {
return true;
}
return !!document.querySelector(`.painterMainContainer[${LAYERFORGE_SHORTCUT_ACTIVE_ATTR}="true"]`);
};
const isLayerForgeEditableFocused = () => {
return isLayerForgeEditableElement(document.activeElement);
};
const patchLayerForgeChangeTrackerUndoRedo = () => {
const prototype = ChangeTracker?.prototype;
if (!prototype || prototype[LAYERFORGE_CHANGE_TRACKER_PATCH_FLAG] || typeof prototype.undoRedo !== 'function') {
return;
}
const originalUndoRedo = prototype.undoRedo;
prototype.undoRedo = async function (event) {
if (isLayerForgeShortcutContextActive(event)) {
return false;
}
return await originalUndoRedo.call(this, event);
};
Object.defineProperty(prototype, LAYERFORGE_CHANGE_TRACKER_PATCH_FLAG, {
value: true,
configurable: false,
enumerable: false,
writable: false
});
};
patchLayerForgeChangeTrackerUndoRedo();
async function createCanvasWidget(node, widget, app) {
const canvas = new Canvas(node, widget, {
onStateChange: () => updateOutput(node, canvas)
@@ -884,6 +932,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');
});
@@ -900,6 +954,70 @@ async function createCanvasWidget(node, widget, app) {
height: "100%"
}
}, [controlPanel, canvasContainer, layersPanelContainer]);
const stopEditableClipboardLeak = (event) => {
if (isLayerForgeEditableElement(event.target) || isLayerForgeEditableFocused()) {
event.stopPropagation();
event.stopImmediatePropagation();
}
};
mainContainer.addEventListener('copy', stopEditableClipboardLeak);
mainContainer.addEventListener('cut', stopEditableClipboardLeak);
mainContainer.addEventListener('paste', stopEditableClipboardLeak);
const setShortcutContextActive = (active) => {
if (active) {
mainContainer.setAttribute(LAYERFORGE_SHORTCUT_ACTIVE_ATTR, 'true');
}
else {
mainContainer.removeAttribute(LAYERFORGE_SHORTCUT_ACTIVE_ATTR);
}
};
const handleShortcutContextFocusIn = () => {
setShortcutContextActive(true);
};
const handleShortcutContextFocusOut = () => {
requestAnimationFrame(() => {
if (!mainContainer.contains(document.activeElement)) {
setShortcutContextActive(false);
}
});
};
const handleShortcutContextPointerEnter = () => {
setShortcutContextActive(true);
};
const handleShortcutContextPointerLeave = () => {
if (!mainContainer.contains(document.activeElement)) {
setShortcutContextActive(false);
}
};
const handleRootUndoRedo = (event) => {
if (isLayerForgeEditableElement(event.target)) {
return;
}
const isPrimaryModifier = (event.ctrlKey || event.metaKey) && !event.altKey;
if (!isPrimaryModifier) {
return;
}
const key = event.key.toLowerCase();
const isUndo = key === 'z' && !event.shiftKey;
const isRedo = key === 'y' || (key === 'z' && event.shiftKey);
if (!isUndo && !isRedo) {
return;
}
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
if (isRedo) {
canvas.redo();
}
else {
canvas.undo();
}
};
mainContainer.addEventListener('focusin', handleShortcutContextFocusIn);
mainContainer.addEventListener('focusout', handleShortcutContextFocusOut);
mainContainer.addEventListener('pointerenter', handleShortcutContextPointerEnter);
mainContainer.addEventListener('pointerleave', handleShortcutContextPointerLeave);
mainContainer.addEventListener('keydown', handleRootUndoRedo, true);
if (node.addDOMWidget) {
node.addDOMWidget("mainContainer", "widget", mainContainer);
}
@@ -1023,7 +1141,18 @@ async function createCanvasWidget(node, widget, app) {
}
return {
canvas: canvas,
panel: controlPanel
panel: controlPanel,
destroy: () => {
mainContainer.removeEventListener('copy', stopEditableClipboardLeak);
mainContainer.removeEventListener('cut', stopEditableClipboardLeak);
mainContainer.removeEventListener('paste', stopEditableClipboardLeak);
mainContainer.removeEventListener('focusin', handleShortcutContextFocusIn);
mainContainer.removeEventListener('focusout', handleShortcutContextFocusOut);
mainContainer.removeEventListener('pointerenter', handleShortcutContextPointerEnter);
mainContainer.removeEventListener('pointerleave', handleShortcutContextPointerLeave);
mainContainer.removeEventListener('keydown', handleRootUndoRedo, true);
mainContainer.removeAttribute(LAYERFORGE_SHORTCUT_ACTIVE_ATTR);
}
};
}
const canvasNodeInstances = new Map();
@@ -1038,13 +1167,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 +1199,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 +1231,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 +1433,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

@@ -16,6 +16,12 @@
<tr><td><kbd>Drag & Drop Image File</kbd></td><td>Add image as a new layer</td></tr>
</table>
<h4>Undo & Redo</h4>
<table>
<tr><td><kbd>Ctrl + Z</kbd></td><td>Undo last action</td></tr>
<tr><td><kbd>Ctrl + Y</kbd> or <kbd>Ctrl + Shift + Z</kbd></td><td>Redo last action</td></tr>
</table>
<h4>Layer Interaction</h4>
<table>
<tr><td><kbd>Click + Drag</kbd></td><td>Move selected layer(s)</td></tr>

View File

@@ -140,5 +140,7 @@ class WebSocketManager {
}
}
}
const wsUrl = `ws://${window.location.host}/layerforge/canvas_ws`;
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${location.host}/layerforge/canvas_ws`;
export const webSocketManager = new WebSocketManager(wsUrl);

View File

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

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

@@ -163,6 +163,19 @@ export class CanvasInteractions {
}
}
private isEditableElement(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) {
return false;
}
if (target.isContentEditable) {
return true;
}
const editableSelector = 'input, textarea, select, [contenteditable="true"]';
return !!target.closest(editableSelector);
}
setupEventListeners(): void {
this.canvas.canvas.addEventListener('mousedown', this.onMouseDown as EventListener);
this.canvas.canvas.addEventListener('mousemove', this.onMouseMove as EventListener);
@@ -176,6 +189,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 +211,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 +296,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 +381,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 +673,27 @@ 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;
if (this.isEditableElement(e.target) || this.isEditableElement(document.activeElement)) {
return;
}
// 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 +728,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 +874,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 +898,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 +910,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 +926,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 +979,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 +1138,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 +1396,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

@@ -139,11 +139,47 @@ export class CanvasLayersPanel {
this.setupMasterVisibilityToggle();
// Dodaj listener dla klawiatury, aby usuwanie działało z panelu
const isEditableTarget = (target: EventTarget | null): boolean => {
if (!(target instanceof HTMLElement)) {
return false;
}
if (target.isContentEditable) {
return true;
}
return !!target.closest('input, textarea, select, [contenteditable="true"]');
};
this.container.addEventListener('keydown', (e: KeyboardEvent) => {
if (isEditableTarget(e.target)) {
return;
}
if (e.key === 'Delete' || e.key === 'Backspace') {
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 +424,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

@@ -5,6 +5,8 @@ import {api} from "../../scripts/api.js";
// @ts-ignore
import {ComfyApp} from "../../scripts/app.js";
// @ts-ignore
import {ChangeTracker} from "../../scripts/changeTracker.js";
// @ts-ignore
import {$el} from "../../scripts/ui.js";
import { addStylesheet, getUrl, loadTemplate } from "./utils/ResourceManager.js";
@@ -21,6 +23,66 @@ import type { ComfyNode, Layer, AddMode } from './types';
const log = createModuleLogger('Canvas_view');
const LAYERFORGE_CHANGE_TRACKER_PATCH_FLAG = '__layerForgeUndoRedoPatched';
const LAYERFORGE_SHORTCUT_ACTIVE_ATTR = 'data-layerforge-shortcuts-active';
const isLayerForgeEditableElement = (target: EventTarget | null): boolean => {
if (!(target instanceof HTMLElement)) {
return false;
}
if (target.isContentEditable) {
return true;
}
return !!target.closest('.painterMainContainer input, .painterMainContainer textarea, .painterMainContainer select, .painterMainContainer [contenteditable="true"]');
};
const isLayerForgeShortcutContextElement = (target: EventTarget | null): boolean => {
return target instanceof HTMLElement && !!target.closest('.painterMainContainer');
};
const isLayerForgeShortcutContextActive = (event?: KeyboardEvent): boolean => {
if (event && isLayerForgeShortcutContextElement(event.target)) {
return true;
}
if (isLayerForgeShortcutContextElement(document.activeElement)) {
return true;
}
return !!document.querySelector(`.painterMainContainer[${LAYERFORGE_SHORTCUT_ACTIVE_ATTR}="true"]`);
};
const isLayerForgeEditableFocused = (): boolean => {
return isLayerForgeEditableElement(document.activeElement);
};
const patchLayerForgeChangeTrackerUndoRedo = (): void => {
const prototype = ChangeTracker?.prototype as any;
if (!prototype || prototype[LAYERFORGE_CHANGE_TRACKER_PATCH_FLAG] || typeof prototype.undoRedo !== 'function') {
return;
}
const originalUndoRedo = prototype.undoRedo;
prototype.undoRedo = async function (event: KeyboardEvent) {
if (isLayerForgeShortcutContextActive(event)) {
return false;
}
return await originalUndoRedo.call(this, event);
};
Object.defineProperty(prototype, LAYERFORGE_CHANGE_TRACKER_PATCH_FLAG, {
value: true,
configurable: false,
enumerable: false,
writable: false
});
};
patchLayerForgeChangeTrackerUndoRedo();
interface CanvasWidget {
canvas: Canvas;
panel: HTMLDivElement;
@@ -1000,6 +1062,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');
});
@@ -1020,6 +1089,81 @@ $el("label.clipboard-switch.mask-switch", {
}
}, [controlPanel, canvasContainer, layersPanelContainer]) as HTMLDivElement;
const stopEditableClipboardLeak = (event: ClipboardEvent) => {
if (isLayerForgeEditableElement(event.target) || isLayerForgeEditableFocused()) {
event.stopPropagation();
event.stopImmediatePropagation();
}
};
mainContainer.addEventListener('copy', stopEditableClipboardLeak);
mainContainer.addEventListener('cut', stopEditableClipboardLeak);
mainContainer.addEventListener('paste', stopEditableClipboardLeak);
const setShortcutContextActive = (active: boolean) => {
if (active) {
mainContainer.setAttribute(LAYERFORGE_SHORTCUT_ACTIVE_ATTR, 'true');
} else {
mainContainer.removeAttribute(LAYERFORGE_SHORTCUT_ACTIVE_ATTR);
}
};
const handleShortcutContextFocusIn = () => {
setShortcutContextActive(true);
};
const handleShortcutContextFocusOut = () => {
requestAnimationFrame(() => {
if (!mainContainer.contains(document.activeElement)) {
setShortcutContextActive(false);
}
});
};
const handleShortcutContextPointerEnter = () => {
setShortcutContextActive(true);
};
const handleShortcutContextPointerLeave = () => {
if (!mainContainer.contains(document.activeElement)) {
setShortcutContextActive(false);
}
};
const handleRootUndoRedo = (event: KeyboardEvent) => {
if (isLayerForgeEditableElement(event.target)) {
return;
}
const isPrimaryModifier = (event.ctrlKey || event.metaKey) && !event.altKey;
if (!isPrimaryModifier) {
return;
}
const key = event.key.toLowerCase();
const isUndo = key === 'z' && !event.shiftKey;
const isRedo = key === 'y' || (key === 'z' && event.shiftKey);
if (!isUndo && !isRedo) {
return;
}
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
if (isRedo) {
canvas.redo();
} else {
canvas.undo();
}
};
mainContainer.addEventListener('focusin', handleShortcutContextFocusIn);
mainContainer.addEventListener('focusout', handleShortcutContextFocusOut);
mainContainer.addEventListener('pointerenter', handleShortcutContextPointerEnter);
mainContainer.addEventListener('pointerleave', handleShortcutContextPointerLeave);
mainContainer.addEventListener('keydown', handleRootUndoRedo, true);
if (node.addDOMWidget) {
node.addDOMWidget("mainContainer", "widget", mainContainer);
}
@@ -1174,7 +1318,18 @@ $el("label.clipboard-switch.mask-switch", {
return {
canvas: canvas,
panel: controlPanel
panel: controlPanel,
destroy: () => {
mainContainer.removeEventListener('copy', stopEditableClipboardLeak);
mainContainer.removeEventListener('cut', stopEditableClipboardLeak);
mainContainer.removeEventListener('paste', stopEditableClipboardLeak);
mainContainer.removeEventListener('focusin', handleShortcutContextFocusIn);
mainContainer.removeEventListener('focusout', handleShortcutContextFocusOut);
mainContainer.removeEventListener('pointerenter', handleShortcutContextPointerEnter);
mainContainer.removeEventListener('pointerleave', handleShortcutContextPointerLeave);
mainContainer.removeEventListener('keydown', handleRootUndoRedo, true);
mainContainer.removeAttribute(LAYERFORGE_SHORTCUT_ACTIVE_ATTR);
}
};
}
@@ -1195,12 +1350,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 +1387,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 +1426,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 +1652,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

View File

@@ -16,6 +16,12 @@
<tr><td><kbd>Drag & Drop Image File</kbd></td><td>Add image as a new layer</td></tr>
</table>
<h4>Undo & Redo</h4>
<table>
<tr><td><kbd>Ctrl + Z</kbd></td><td>Undo last action</td></tr>
<tr><td><kbd>Ctrl + Y</kbd> or <kbd>Ctrl + Shift + Z</kbd></td><td>Redo last action</td></tr>
</table>
<h4>Layer Interaction</h4>
<table>
<tr><td><kbd>Click + Drag</kbd></td><td>Move selected layer(s)</td></tr>