mirror of
https://github.com/Azornes/Comfyui-LayerForge.git
synced 2026-05-07 00:46:43 -03:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bce819918 | ||
|
|
5cf10e5e5c | ||
|
|
72ef62e642 | ||
|
|
1edde25d75 | ||
|
|
835d94a11d | ||
|
|
061e2b7a9a | ||
|
|
b1f29eefdb | ||
|
|
b8fbcee67a | ||
|
|
d44d944f2d | ||
|
|
ab5d71597a | ||
|
|
ce4d332987 | ||
|
|
9b04729561 | ||
|
|
27ad139cd5 | ||
|
|
66cbcb641b | ||
|
|
986e0a23a2 | ||
|
|
068ed9ee59 | ||
|
|
4e5ef18d93 | ||
|
|
be37966b45 | ||
|
|
dd5fc5470f | ||
|
|
1f1d0aeb7d | ||
|
|
da55d741d6 | ||
|
|
959c47c29b | ||
|
|
ab7ab9d1a8 |
47
README.md
47
README.md
@@ -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>
|
||||
|
|
||||
<strong>🧩 <a href="https://github.com/Azornes/Comfyui-LayerForge?tab=readme-ov-file#-workflow-example">Workflow Example</a></strong>
|
||||
|
|
||||
<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 doesn’t 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 you’d like to support my work:
|
||||
• ⭐ Give a star — it means a lot to me!
|
||||
• 🐛 Report a bug or suggest a feature
|
||||
• 💖 If you’d 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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
228
js/CanvasView.js
228
js/CanvasView.js
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.12"
|
||||
license = { text = "MIT License" }
|
||||
dependencies = ["torch", "torchvision", "transformers", "aiohttp", "numpy", "tqdm", "Pillow"]
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user