mirror of
https://github.com/Azornes/Comfyui-LayerForge.git
synced 2026-03-26 14:48:52 -03:00
Add layer copy/paste and node duplication with layers
Implements two new features: - Layer copy/paste within canvas using Ctrl+C/V - Node duplication that preserves all layers Layer Copy/Paste: - Added Ctrl+V keyboard shortcut handler for pasting layers - Intercept keydown events during capture phase to handle before ComfyUI - Focus canvas when layer is clicked to ensure shortcuts work - Prevent layers panel from stealing focus on mousedown Node Duplication: - Store source node ID during serialize for copy operations - Track pending copy sources across node ID changes (-1 to real ID) - Copy canvas state from source to destination in onAdded hook - Use Map to persist copy metadata through node lifecycle
This commit is contained in:
@@ -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);
|
||||||
@@ -570,6 +574,12 @@ export class CanvasInteractions {
|
|||||||
this.canvas.canvasLayers.copySelectedLayers();
|
this.canvas.canvasLayers.copySelectedLayers();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case 'v':
|
||||||
|
// Paste layers from internal clipboard
|
||||||
|
if (this.canvas.canvasLayers.internalClipboard.length > 0) {
|
||||||
|
this.canvas.canvasLayers.pasteLayers();
|
||||||
|
}
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
handled = false;
|
handled = false;
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -285,6 +285,8 @@ export class CanvasLayersPanel {
|
|||||||
if (nameElement && nameElement.classList.contains('editing')) {
|
if (nameElement && nameElement.classList.contains('editing')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Prevent the layers panel from stealing focus
|
||||||
|
e.preventDefault();
|
||||||
this.handleLayerClick(e, layer, index);
|
this.handleLayerClick(e, layer, index);
|
||||||
});
|
});
|
||||||
// --- PRAWY PRZYCISK: ODJAZNACZ LAYER ---
|
// --- PRAWY PRZYCISK: ODJAZNACZ LAYER ---
|
||||||
@@ -329,6 +331,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) {
|
||||||
|
|||||||
@@ -1076,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.");
|
||||||
@@ -1106,6 +1108,38 @@ 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');
|
||||||
|
const sourceState = await getCanvasState(String(sourceNodeId));
|
||||||
|
if (!sourceState) {
|
||||||
|
log.debug(`No canvas state found for source node ${sourceNodeId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await setCanvasState(String(this.id), sourceState);
|
||||||
|
await canvasWidget.canvas.loadInitialState();
|
||||||
|
log.info(`Canvas state copied successfully from node ${sourceNodeId} 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) {
|
||||||
@@ -1271,6 +1305,31 @@ 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`);
|
||||||
|
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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
@@ -697,6 +703,12 @@ export class CanvasInteractions {
|
|||||||
this.canvas.canvasLayers.copySelectedLayers();
|
this.canvas.canvasLayers.copySelectedLayers();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case 'v':
|
||||||
|
// Paste layers from internal clipboard
|
||||||
|
if (this.canvas.canvasLayers.internalClipboard.length > 0) {
|
||||||
|
this.canvas.canvasLayers.pasteLayers();
|
||||||
|
}
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
handled = false;
|
handled = false;
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -336,6 +336,8 @@ export class CanvasLayersPanel {
|
|||||||
if (nameElement && nameElement.classList.contains('editing')) {
|
if (nameElement && nameElement.classList.contains('editing')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Prevent the layers panel from stealing focus
|
||||||
|
e.preventDefault();
|
||||||
this.handleLayerClick(e, layer, index);
|
this.handleLayerClick(e, layer, index);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -388,6 +390,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}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1239,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.");
|
||||||
@@ -1275,6 +1278,43 @@ 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');
|
||||||
|
const sourceState = await getCanvasState(String(sourceNodeId));
|
||||||
|
|
||||||
|
if (!sourceState) {
|
||||||
|
log.debug(`No canvas state found for source node ${sourceNodeId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await setCanvasState(String(this.id), sourceState);
|
||||||
|
await canvasWidget.canvas.loadInitialState();
|
||||||
|
log.info(`Canvas state copied successfully from node ${sourceNodeId} 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) {
|
||||||
@@ -1458,6 +1498,36 @@ 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`);
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user