Merge pull request #24 from diodiogod/fix/keyboard-shortcuts-focus-check

Fix keyboard shortcuts capturing events when node is unfocused
This commit is contained in:
Dariusz L
2026-02-20 16:28:04 +01:00
committed by GitHub
14 changed files with 501 additions and 138 deletions

View File

@@ -443,8 +443,8 @@ export class Canvas {
* Inicjalizuje podstawowe właściwości canvas * Inicjalizuje podstawowe właściwości canvas
*/ */
initCanvas() { initCanvas() {
this.canvas.width = this.width; // Don't set canvas.width/height here - let the render loop handle it based on clientWidth/clientHeight
this.canvas.height = this.height; // this.width and this.height are for the OUTPUT AREA, not the display canvas
this.canvas.style.border = '1px solid black'; this.canvas.style.border = '1px solid black';
this.canvas.style.maxWidth = '100%'; this.canvas.style.maxWidth = '100%';
this.canvas.style.backgroundColor = '#606060'; this.canvas.style.backgroundColor = '#606060';

View File

@@ -197,6 +197,25 @@ export class CanvasIO {
} }
async _renderOutputData() { async _renderOutputData() {
log.info("=== RENDERING OUTPUT DATA FOR COMFYUI ==="); 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 // Użyj zunifikowanych funkcji z CanvasLayers
const imageBlob = await this.canvas.canvasLayers.getFlattenedCanvasAsBlob(); const imageBlob = await this.canvas.canvasLayers.getFlattenedCanvasAsBlob();
const maskBlob = await this.canvas.canvasLayers.getFlattenedMaskAsBlob(); const maskBlob = await this.canvas.canvasLayers.getFlattenedMaskAsBlob();

View File

@@ -104,6 +104,8 @@ export class CanvasInteractions {
// Add a blur event listener to the window to reset key states // Add a blur event listener to the window to reset key states
window.addEventListener('blur', this.onBlur); window.addEventListener('blur', this.onBlur);
document.addEventListener('paste', this.onPaste); 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('mouseenter', this.onMouseEnter);
this.canvas.canvas.addEventListener('mouseleave', this.onMouseLeave); this.canvas.canvas.addEventListener('mouseleave', this.onMouseLeave);
this.canvas.canvas.addEventListener('dragover', this.onDragOver); this.canvas.canvas.addEventListener('dragover', this.onDragOver);
@@ -119,6 +121,8 @@ export class CanvasInteractions {
this.canvas.canvas.removeEventListener('wheel', this.onWheel); this.canvas.canvas.removeEventListener('wheel', this.onWheel);
this.canvas.canvas.removeEventListener('keydown', this.onKeyDown); this.canvas.canvas.removeEventListener('keydown', this.onKeyDown);
this.canvas.canvas.removeEventListener('keyup', this.onKeyUp); this.canvas.canvas.removeEventListener('keyup', this.onKeyUp);
// Remove document-level capture listener
document.removeEventListener('keydown', this.onKeyDown, { capture: true });
window.removeEventListener('blur', this.onBlur); window.removeEventListener('blur', this.onBlur);
document.removeEventListener('paste', this.onPaste); document.removeEventListener('paste', this.onPaste);
this.canvas.canvas.removeEventListener('mouseenter', this.onMouseEnter); this.canvas.canvas.removeEventListener('mouseenter', this.onMouseEnter);
@@ -188,6 +192,12 @@ export class CanvasInteractions {
} }
handleMouseDown(e) { handleMouseDown(e) {
this.canvas.canvas.focus(); 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 coords = this.getMouseCoordinates(e);
const mods = this.getModifierState(e); const mods = this.getModifierState(e);
if (this.interaction.mode === 'drawingMask') { if (this.interaction.mode === 'drawingMask') {
@@ -519,14 +529,24 @@ export class CanvasInteractions {
return targetHeight / oldHeight; return targetHeight / oldHeight;
} }
handleKeyDown(e) { handleKeyDown(e) {
// Always track modifier keys regardless of focus
if (e.key === 'Control') if (e.key === 'Control')
this.interaction.isCtrlPressed = true; this.interaction.isCtrlPressed = true;
if (e.key === 'Meta') if (e.key === 'Meta')
this.interaction.isMetaPressed = true; this.interaction.isMetaPressed = true;
if (e.key === 'Shift') if (e.key === 'Shift')
this.interaction.isShiftPressed = true; this.interaction.isShiftPressed = true;
if (e.key === 'Alt') { if (e.key === 'Alt')
this.interaction.isAltPressed = true; this.interaction.isAltPressed = true;
// Check if canvas is focused before handling any shortcuts
const shouldHandle = this.canvas.isMouseOver ||
this.canvas.canvas.contains(document.activeElement) ||
document.activeElement === this.canvas.canvas;
if (!shouldHandle) {
return;
}
// Canvas-specific key handlers (only when focused)
if (e.key === 'Alt') {
e.preventDefault(); e.preventDefault();
} }
if (e.key.toLowerCase() === 's') { if (e.key.toLowerCase() === 's') {
@@ -560,6 +580,17 @@ export class CanvasInteractions {
this.canvas.canvasLayers.copySelectedLayers(); this.canvas.canvasLayers.copySelectedLayers();
} }
break; 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: default:
handled = false; handled = false;
break; break;
@@ -713,12 +744,11 @@ export class CanvasInteractions {
if (mods.ctrl || mods.meta) { if (mods.ctrl || mods.meta) {
const index = this.canvas.canvasSelection.selectedLayers.indexOf(layer); const index = this.canvas.canvasSelection.selectedLayers.indexOf(layer);
if (index === -1) { if (index === -1) {
// Ctrl-clicking unselected layer: add to selection
this.canvas.canvasSelection.updateSelection([...this.canvas.canvasSelection.selectedLayers, layer]); this.canvas.canvasSelection.updateSelection([...this.canvas.canvasSelection.selectedLayers, layer]);
} }
else { // If already selected, do NOT deselect - allows dragging multiple layers with Ctrl held
const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l) => l !== layer); // User can use right-click in layers panel to deselect individual layers
this.canvas.canvasSelection.updateSelection(newSelection);
}
} }
else { else {
if (!this.canvas.canvasSelection.selectedLayers.includes(layer)) { if (!this.canvas.canvasSelection.selectedLayers.includes(layer)) {
@@ -1155,10 +1185,13 @@ export class CanvasInteractions {
} }
} }
async handlePasteEvent(e) { 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 || const shouldHandle = this.canvas.isMouseOver ||
this.canvas.canvas.contains(document.activeElement) || this.canvas.canvas.contains(document.activeElement) ||
document.activeElement === this.canvas.canvas || document.activeElement === this.canvas.canvas;
document.activeElement === document.body;
if (!shouldHandle) { if (!shouldHandle) {
log.debug("Paste event ignored - not focused on canvas"); log.debug("Paste event ignored - not focused on canvas");
return; return;

View File

@@ -1100,8 +1100,8 @@ export class CanvasLayers {
this.canvas.width = width; this.canvas.width = width;
this.canvas.height = height; this.canvas.height = height;
this.canvas.maskTool.resize(width, height); this.canvas.maskTool.resize(width, height);
this.canvas.canvas.width = width; // Don't set canvas.width/height - the render loop will handle display size
this.canvas.canvas.height = height; // this.canvas.width/height are for OUTPUT AREA dimensions, not display canvas
this.canvas.render(); this.canvas.render();
if (saveHistory) { if (saveHistory) {
this.canvas.canvasState.saveStateToDB(); this.canvas.canvasState.saveStateToDB();

View File

@@ -123,6 +123,26 @@ export class CanvasLayersPanel {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
this.deleteSelectedLayers(); 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'); log.debug('Panel structure created');
@@ -329,6 +349,8 @@ export class CanvasLayersPanel {
// Aktualizuj tylko wygląd (klasy CSS), bez niszczenia DOM // Aktualizuj tylko wygląd (klasy CSS), bez niszczenia DOM
this.updateSelectionAppearance(); this.updateSelectionAppearance();
this.updateButtonStates(); 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}`); log.debug(`Layer clicked: ${layer.name}, selection count: ${this.canvas.canvasSelection.selectedLayers.length}`);
} }
startEditingLayerName(nameElement, layer) { startEditingLayerName(nameElement, layer) {

View File

@@ -88,10 +88,10 @@ export class CanvasState {
log.debug(`Output Area resized to ${this.canvas.width}x${this.canvas.height} and viewport set.`); log.debug(`Output Area resized to ${this.canvas.width}x${this.canvas.height} and viewport set.`);
const loadedLayers = await this._loadLayers(savedState.layers); const loadedLayers = await this._loadLayers(savedState.layers);
this.canvas.layers = loadedLayers.filter((l) => l !== null); this.canvas.layers = loadedLayers.filter((l) => 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) { if (this.canvas.layers.length === 0 && savedState.layers.length > 0) {
log.warn("No valid layers loaded, state may be corrupted."); 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.`);
return false; // Don't return false - allow empty canvas to be valid
} }
this.canvas.updateSelectionAfterHistory(); this.canvas.updateSelectionAfterHistory();
this.canvas.render(); this.canvas.render();

View File

@@ -884,6 +884,12 @@ async function createCanvasWidget(node, widget, app) {
if (controlsElement) { if (controlsElement) {
resizeObserver.observe(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', () => { canvas.canvas.addEventListener('focus', () => {
canvasContainer.classList.add('has-focus'); canvasContainer.classList.add('has-focus');
}); });
@@ -1038,13 +1044,20 @@ app.registerExtension({
log.info(`Found ${canvasNodeInstances.size} CanvasNode(s). Sending data via WebSocket...`); log.info(`Found ${canvasNodeInstances.size} CanvasNode(s). Sending data via WebSocket...`);
const sendPromises = []; const sendPromises = [];
for (const [nodeId, canvasWidget] of canvasNodeInstances.entries()) { for (const [nodeId, canvasWidget] of canvasNodeInstances.entries()) {
if (app.graph.getNodeById(nodeId) && canvasWidget.canvas && canvasWidget.canvas.canvasIO) { const node = app.graph.getNodeById(nodeId);
log.debug(`Sending data for canvas node ${nodeId}`); if (!node) {
sendPromises.push(canvasWidget.canvas.canvasIO.sendDataViaWebSocket(nodeId));
}
else {
log.warn(`Node ${nodeId} not found in graph, removing from instances map.`); log.warn(`Node ${nodeId} not found in graph, removing from instances map.`);
canvasNodeInstances.delete(nodeId); 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 { try {
@@ -1063,6 +1076,8 @@ app.registerExtension({
}, },
async beforeRegisterNodeDef(nodeType, nodeData, app) { async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeType.comfyClass === "LayerForgeNode") { if (nodeType.comfyClass === "LayerForgeNode") {
// Map to track pending copy sources across node ID changes
const pendingCopySources = new Map();
const onNodeCreated = nodeType.prototype.onNodeCreated; const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () { nodeType.prototype.onNodeCreated = function () {
log.debug("CanvasNode onNodeCreated: Base widget setup."); log.debug("CanvasNode onNodeCreated: Base widget setup.");
@@ -1093,6 +1108,43 @@ app.registerExtension({
log.info(`Registered CanvasNode instance for ID: ${this.id}`); log.info(`Registered CanvasNode instance for ID: ${this.id}`);
// Store the canvas widget on the node // Store the canvas widget on the node
this.canvasWidget = canvasWidget; 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 // Check if there are already connected inputs
setTimeout(() => { setTimeout(() => {
if (this.inputs && this.inputs.length > 0) { if (this.inputs && this.inputs.length > 0) {
@@ -1258,6 +1310,47 @@ app.registerExtension({
} }
return onRemoved?.apply(this, arguments); 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; const originalGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function (_, options) { nodeType.prototype.getExtraMenuOptions = function (_, options) {
// FIRST: Call original to let other extensions add their options // FIRST: Call original to let other extensions add their options

View File

@@ -578,8 +578,8 @@ export class Canvas {
* Inicjalizuje podstawowe właściwości canvas * Inicjalizuje podstawowe właściwości canvas
*/ */
initCanvas() { initCanvas() {
this.canvas.width = this.width; // Don't set canvas.width/height here - let the render loop handle it based on clientWidth/clientHeight
this.canvas.height = this.height; // this.width and this.height are for the OUTPUT AREA, not the display canvas
this.canvas.style.border = '1px solid black'; this.canvas.style.border = '1px solid black';
this.canvas.style.maxWidth = '100%'; this.canvas.style.maxWidth = '100%';
this.canvas.style.backgroundColor = '#606060'; this.canvas.style.backgroundColor = '#606060';

View File

@@ -218,6 +218,29 @@ export class CanvasIO {
async _renderOutputData(): Promise<{ image: string, mask: string }> { async _renderOutputData(): Promise<{ image: string, mask: string }> {
log.info("=== RENDERING OUTPUT DATA FOR COMFYUI ==="); 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 // Użyj zunifikowanych funkcji z CanvasLayers
const imageBlob = await this.canvas.canvasLayers.getFlattenedCanvasAsBlob(); const imageBlob = await this.canvas.canvasLayers.getFlattenedCanvasAsBlob();
const maskBlob = await this.canvas.canvasLayers.getFlattenedMaskAsBlob(); const maskBlob = await this.canvas.canvasLayers.getFlattenedMaskAsBlob();

View File

@@ -176,6 +176,9 @@ export class CanvasInteractions {
document.addEventListener('paste', this.onPaste as unknown as EventListener); 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('mouseenter', this.onMouseEnter as EventListener);
this.canvas.canvas.addEventListener('mouseleave', this.onMouseLeave as EventListener); this.canvas.canvas.addEventListener('mouseleave', this.onMouseLeave as EventListener);
@@ -195,6 +198,9 @@ export class CanvasInteractions {
this.canvas.canvas.removeEventListener('keydown', this.onKeyDown as EventListener); this.canvas.canvas.removeEventListener('keydown', this.onKeyDown as EventListener);
this.canvas.canvas.removeEventListener('keyup', this.onKeyUp 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); window.removeEventListener('blur', this.onBlur);
document.removeEventListener('paste', this.onPaste as unknown as EventListener); document.removeEventListener('paste', this.onPaste as unknown as EventListener);
@@ -277,6 +283,14 @@ export class CanvasInteractions {
handleMouseDown(e: MouseEvent): void { handleMouseDown(e: MouseEvent): void {
this.canvas.canvas.focus(); 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 coords = this.getMouseCoordinates(e);
const mods = this.getModifierState(e); const mods = this.getModifierState(e);
@@ -354,7 +368,7 @@ export class CanvasInteractions {
if (grabIconLayer) { if (grabIconLayer) {
// Start dragging the selected layer(s) without changing selection // Start dragging the selected layer(s) without changing selection
this.interaction.mode = 'potential-drag'; this.interaction.mode = 'potential-drag';
this.interaction.dragStart = {...coords.world}; this.interaction.dragStart = { ...coords.world };
return; return;
} }
@@ -646,11 +660,23 @@ export class CanvasInteractions {
} }
handleKeyDown(e: KeyboardEvent): void { handleKeyDown(e: KeyboardEvent): void {
// Always track modifier keys regardless of focus
if (e.key === 'Control') this.interaction.isCtrlPressed = true; if (e.key === 'Control') this.interaction.isCtrlPressed = true;
if (e.key === 'Meta') this.interaction.isMetaPressed = true; if (e.key === 'Meta') this.interaction.isMetaPressed = true;
if (e.key === 'Shift') this.interaction.isShiftPressed = true; if (e.key === 'Shift') this.interaction.isShiftPressed = true;
if (e.key === 'Alt') this.interaction.isAltPressed = true;
// Check if canvas is focused before handling any shortcuts
const shouldHandle = this.canvas.isMouseOver ||
this.canvas.canvas.contains(document.activeElement) ||
document.activeElement === this.canvas.canvas;
if (!shouldHandle) {
return;
}
// Canvas-specific key handlers (only when focused)
if (e.key === 'Alt') { if (e.key === 'Alt') {
this.interaction.isAltPressed = true;
e.preventDefault(); e.preventDefault();
} }
if (e.key.toLowerCase() === 's') { if (e.key.toLowerCase() === 's') {
@@ -685,6 +711,17 @@ export class CanvasInteractions {
this.canvas.canvasLayers.copySelectedLayers(); this.canvas.canvasLayers.copySelectedLayers();
} }
break; 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: default:
handled = false; handled = false;
break; break;
@@ -820,7 +857,7 @@ export class CanvasInteractions {
originalHeight: layer.originalHeight, originalHeight: layer.originalHeight,
cropBounds: layer.cropBounds ? { ...layer.cropBounds } : undefined cropBounds: layer.cropBounds ? { ...layer.cropBounds } : undefined
}; };
this.interaction.dragStart = {...worldCoords}; this.interaction.dragStart = { ...worldCoords };
if (handle === 'rot') { if (handle === 'rot') {
this.interaction.mode = 'rotating'; this.interaction.mode = 'rotating';
@@ -844,11 +881,11 @@ export class CanvasInteractions {
if (mods.ctrl || mods.meta) { if (mods.ctrl || mods.meta) {
const index = this.canvas.canvasSelection.selectedLayers.indexOf(layer); const index = this.canvas.canvasSelection.selectedLayers.indexOf(layer);
if (index === -1) { if (index === -1) {
// Ctrl-clicking unselected layer: add to selection
this.canvas.canvasSelection.updateSelection([...this.canvas.canvasSelection.selectedLayers, layer]); 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 { } else {
if (!this.canvas.canvasSelection.selectedLayers.includes(layer)) { if (!this.canvas.canvasSelection.selectedLayers.includes(layer)) {
this.canvas.canvasSelection.updateSelection([layer]); this.canvas.canvasSelection.updateSelection([layer]);
@@ -856,7 +893,7 @@ export class CanvasInteractions {
} }
this.interaction.mode = 'potential-drag'; this.interaction.mode = 'potential-drag';
this.interaction.dragStart = {...worldCoords}; this.interaction.dragStart = { ...worldCoords };
} }
startPanning(e: MouseEvent, clearSelection: boolean = true): void { startPanning(e: MouseEvent, clearSelection: boolean = true): void {
@@ -872,8 +909,8 @@ export class CanvasInteractions {
this.interaction.mode = 'resizingCanvas'; this.interaction.mode = 'resizingCanvas';
const startX = snapToGrid(worldCoords.x); const startX = snapToGrid(worldCoords.x);
const startY = snapToGrid(worldCoords.y); const startY = snapToGrid(worldCoords.y);
this.interaction.canvasResizeStart = {x: startX, y: startY}; this.interaction.canvasResizeStart = { x: startX, y: startY };
this.interaction.canvasResizeRect = {x: startX, y: startY, width: 0, height: 0}; this.interaction.canvasResizeRect = { x: startX, y: startY, width: 0, height: 0 };
this.canvas.render(); this.canvas.render();
} }
@@ -925,7 +962,7 @@ export class CanvasInteractions {
const dy = e.clientY - this.interaction.panStart.y; const dy = e.clientY - this.interaction.panStart.y;
this.canvas.viewport.x -= dx / this.canvas.viewport.zoom; this.canvas.viewport.x -= dx / this.canvas.viewport.zoom;
this.canvas.viewport.y -= dy / 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 // Update stroke overlay if mask tool is drawing during pan
if (this.canvas.maskTool.isDrawing) { if (this.canvas.maskTool.isDrawing) {
@@ -1084,7 +1121,7 @@ export class CanvasInteractions {
// Clamp crop bounds to stay within the original image and maintain minimum size // Clamp crop bounds to stay within the original image and maintain minimum size
if (newCropBounds.width < 1) { if (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; newCropBounds.width = 1;
} }
if (newCropBounds.height < 1) { if (newCropBounds.height < 1) {
@@ -1342,11 +1379,14 @@ export class CanvasInteractions {
} }
async handlePasteEvent(e: ClipboardEvent): Promise<void> { 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 || const shouldHandle = this.canvas.isMouseOver ||
this.canvas.canvas.contains(document.activeElement) || this.canvas.canvas.contains(document.activeElement) ||
document.activeElement === this.canvas.canvas || document.activeElement === this.canvas.canvas;
document.activeElement === document.body;
if (!shouldHandle) { if (!shouldHandle) {
log.debug("Paste event ignored - not focused on canvas"); log.debug("Paste event ignored - not focused on canvas");

View File

@@ -1263,8 +1263,8 @@ export class CanvasLayers {
this.canvas.height = height; this.canvas.height = height;
this.canvas.maskTool.resize(width, height); this.canvas.maskTool.resize(width, height);
this.canvas.canvas.width = width; // Don't set canvas.width/height - the render loop will handle display size
this.canvas.canvas.height = height; // this.canvas.width/height are for OUTPUT AREA dimensions, not display canvas
this.canvas.render(); this.canvas.render();

View File

@@ -144,6 +144,26 @@ export class CanvasLayersPanel {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
this.deleteSelectedLayers(); 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 +408,9 @@ export class CanvasLayersPanel {
this.updateSelectionAppearance(); this.updateSelectionAppearance();
this.updateButtonStates(); 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}`); log.debug(`Layer clicked: ${layer.name}, selection count: ${this.canvas.canvasSelection.selectedLayers.length}`);
} }

View File

@@ -118,11 +118,11 @@ export class CanvasState {
log.debug(`Output Area resized to ${this.canvas.width}x${this.canvas.height} and viewport set.`); log.debug(`Output Area resized to ${this.canvas.width}x${this.canvas.height} and viewport set.`);
const loadedLayers = await this._loadLayers(savedState.layers); const loadedLayers = await this._loadLayers(savedState.layers);
this.canvas.layers = loadedLayers.filter((l): l is Layer => l !== null); 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) { if (this.canvas.layers.length === 0 && savedState.layers.length > 0) {
log.warn("No valid layers loaded, state may be corrupted."); 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.`);
return false; // Don't return false - allow empty canvas to be valid
} }
this.canvas.updateSelectionAfterHistory(); this.canvas.updateSelectionAfterHistory();

View File

@@ -1000,6 +1000,13 @@ $el("label.clipboard-switch.mask-switch", {
resizeObserver.observe(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', () => { canvas.canvas.addEventListener('focus', () => {
canvasContainer.classList.add('has-focus'); canvasContainer.classList.add('has-focus');
}); });
@@ -1195,12 +1202,23 @@ app.registerExtension({
const sendPromises: Promise<any>[] = []; const sendPromises: Promise<any>[] = [];
for (const [nodeId, canvasWidget] of canvasNodeInstances.entries()) { for (const [nodeId, canvasWidget] of canvasNodeInstances.entries()) {
if (app.graph.getNodeById(nodeId) && canvasWidget.canvas && canvasWidget.canvas.canvasIO) { const node = app.graph.getNodeById(nodeId);
log.debug(`Sending data for canvas node ${nodeId}`);
sendPromises.push(canvasWidget.canvas.canvasIO.sendDataViaWebSocket(nodeId)); if (!node) {
} else {
log.warn(`Node ${nodeId} not found in graph, removing from instances map.`); log.warn(`Node ${nodeId} not found in graph, removing from instances map.`);
canvasNodeInstances.delete(nodeId); canvasNodeInstances.delete(nodeId);
continue;
}
// Skip bypassed nodes
if (node.mode === 4) {
log.debug(`Node ${nodeId} is bypassed, skipping data send.`);
continue;
}
if (canvasWidget.canvas && canvasWidget.canvas.canvasIO) {
log.debug(`Sending data for canvas node ${nodeId}`);
sendPromises.push(canvasWidget.canvas.canvasIO.sendDataViaWebSocket(nodeId));
} }
} }
@@ -1221,6 +1239,9 @@ app.registerExtension({
async beforeRegisterNodeDef(nodeType: any, nodeData: any, app: ComfyApp) { async beforeRegisterNodeDef(nodeType: any, nodeData: any, app: ComfyApp) {
if (nodeType.comfyClass === "LayerForgeNode") { 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; const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function (this: ComfyNode) { nodeType.prototype.onNodeCreated = function (this: ComfyNode) {
log.debug("CanvasNode onNodeCreated: Base widget setup."); log.debug("CanvasNode onNodeCreated: Base widget setup.");
@@ -1257,6 +1278,49 @@ app.registerExtension({
// Store the canvas widget on the node // Store the canvas widget on the node
(this as any).canvasWidget = canvasWidget; (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 // Check if there are already connected inputs
setTimeout(() => { setTimeout(() => {
if (this.inputs && this.inputs.length > 0) { if (this.inputs && this.inputs.length > 0) {
@@ -1440,6 +1504,52 @@ app.registerExtension({
return onRemoved?.apply(this, arguments as any); 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; const originalGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function (this: ComfyNode, _: any, options: any[]) { nodeType.prototype.getExtraMenuOptions = function (this: ComfyNode, _: any, options: any[]) {
// FIRST: Call original to let other extensions add their options // FIRST: Call original to let other extensions add their options