4 Commits

Author SHA1 Message Date
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
9 changed files with 344 additions and 3 deletions

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);
@@ -538,6 +548,9 @@ export class CanvasInteractions {
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) ||

View File

@@ -118,7 +118,19 @@ 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();

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)
@@ -906,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);
}
@@ -1029,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();

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

@@ -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"]

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);
@@ -666,6 +679,10 @@ export class CanvasInteractions {
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) ||

View File

@@ -139,7 +139,23 @@ 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();

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;
@@ -1027,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);
}
@@ -1181,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);
}
};
}

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>