8 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
11 changed files with 502 additions and 110 deletions

View File

@@ -92,7 +92,7 @@ class BiRefNet(torch.nn.Module):
return [output]
class LayerForgeNode:
class LayerForgeNode:
_canvas_data_storage = {}
_storage_lock = threading.Lock()
@@ -731,42 +731,139 @@ class LayerForgeNode:
else:
self.cached_image = image_data
def get_cached_image(self):
if self.cached_image:
buffered = io.BytesIO()
self.cached_image.save(buffered, format="PNG")
img_str = base64.b64encode(buffered.getvalue()).decode()
return f"data:image/png;base64,{img_str}"
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")
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}...")
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,
# Add force_download=False to use cached version if available
force_download=False,
# Add local_files_only=False to allow downloading if needed
local_files_only=False
)
def get_cached_image(self):
if self.cached_image:
buffered = io.BytesIO()
self.cached_image.save(buffered, format="PNG")
img_str = base64.b64encode(buffered.getvalue()).decode()
return f"data:image/png;base64,{img_str}"
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_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:
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=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
local_files_only=False
)
self.model.eval()
if torch.cuda.is_available():
self.model = self.model.cuda()
@@ -923,75 +1020,27 @@ async def check_matting_model(request):
"message": "The 'transformers' library is required for the matting feature. Please install it by running: pip install transformers"
})
# 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")
# 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:
# Model files exist, assume it's ready
log_info("BiRefNet model files detected")
return web.json_response({
"available": True,
"reason": "ready",
"message": "Model is ready to use"
})
else:
log_info(f"BiRefNet model not found in {model_path}")
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
})
# Check if model exists in cache
local_model_path = _find_local_birefnet_model()
if local_model_path:
# Model files exist, assume it's ready
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",
"model_path": local_model_path
})
else:
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": searched_paths[0] if searched_paths else None
})
except Exception as e:
log_error(f"Error checking matting model: {str(e)}")

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

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

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