mirror of
https://github.com/Azornes/Comfyui-LayerForge.git
synced 2026-03-21 20:52:12 -03:00
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:
@@ -443,8 +443,8 @@ export class Canvas {
|
||||
* Inicjalizuje podstawowe właściwości canvas
|
||||
*/
|
||||
initCanvas() {
|
||||
this.canvas.width = this.width;
|
||||
this.canvas.height = this.height;
|
||||
// Don't set canvas.width/height here - let the render loop handle it based on clientWidth/clientHeight
|
||||
// this.width and this.height are for the OUTPUT AREA, not the display canvas
|
||||
this.canvas.style.border = '1px solid black';
|
||||
this.canvas.style.maxWidth = '100%';
|
||||
this.canvas.style.backgroundColor = '#606060';
|
||||
|
||||
@@ -197,6 +197,25 @@ export class CanvasIO {
|
||||
}
|
||||
async _renderOutputData() {
|
||||
log.info("=== RENDERING OUTPUT DATA FOR COMFYUI ===");
|
||||
// Check if layers have valid images loaded, with retry logic
|
||||
const maxRetries = 5;
|
||||
const retryDelay = 200;
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
const layersWithoutImages = this.canvas.layers.filter(layer => !layer.image || !layer.image.complete);
|
||||
if (layersWithoutImages.length === 0) {
|
||||
break; // All images loaded
|
||||
}
|
||||
if (attempt === 0) {
|
||||
log.warn(`${layersWithoutImages.length} layer(s) have incomplete image data. Waiting for images to load...`);
|
||||
}
|
||||
if (attempt < maxRetries - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, retryDelay));
|
||||
}
|
||||
else {
|
||||
// Last attempt failed
|
||||
throw new Error(`Canvas not ready after ${maxRetries} attempts: ${layersWithoutImages.length} layer(s) still have incomplete image data. Try waiting a moment and running again.`);
|
||||
}
|
||||
}
|
||||
// Użyj zunifikowanych funkcji z CanvasLayers
|
||||
const imageBlob = await this.canvas.canvasLayers.getFlattenedCanvasAsBlob();
|
||||
const maskBlob = await this.canvas.canvasLayers.getFlattenedMaskAsBlob();
|
||||
|
||||
@@ -104,6 +104,8 @@ export class CanvasInteractions {
|
||||
// Add a blur event listener to the window to reset key states
|
||||
window.addEventListener('blur', this.onBlur);
|
||||
document.addEventListener('paste', this.onPaste);
|
||||
// Intercept Ctrl+V during capture phase to handle layer paste before ComfyUI
|
||||
document.addEventListener('keydown', this.onKeyDown, { capture: true });
|
||||
this.canvas.canvas.addEventListener('mouseenter', this.onMouseEnter);
|
||||
this.canvas.canvas.addEventListener('mouseleave', this.onMouseLeave);
|
||||
this.canvas.canvas.addEventListener('dragover', this.onDragOver);
|
||||
@@ -119,6 +121,8 @@ export class CanvasInteractions {
|
||||
this.canvas.canvas.removeEventListener('wheel', this.onWheel);
|
||||
this.canvas.canvas.removeEventListener('keydown', this.onKeyDown);
|
||||
this.canvas.canvas.removeEventListener('keyup', this.onKeyUp);
|
||||
// Remove document-level capture listener
|
||||
document.removeEventListener('keydown', this.onKeyDown, { capture: true });
|
||||
window.removeEventListener('blur', this.onBlur);
|
||||
document.removeEventListener('paste', this.onPaste);
|
||||
this.canvas.canvas.removeEventListener('mouseenter', this.onMouseEnter);
|
||||
@@ -188,6 +192,12 @@ export class CanvasInteractions {
|
||||
}
|
||||
handleMouseDown(e) {
|
||||
this.canvas.canvas.focus();
|
||||
// Sync modifier states with actual event state to prevent "stuck" modifiers
|
||||
// when focus moves between layers panel and canvas
|
||||
this.interaction.isCtrlPressed = e.ctrlKey;
|
||||
this.interaction.isMetaPressed = e.metaKey;
|
||||
this.interaction.isShiftPressed = e.shiftKey;
|
||||
this.interaction.isAltPressed = e.altKey;
|
||||
const coords = this.getMouseCoordinates(e);
|
||||
const mods = this.getModifierState(e);
|
||||
if (this.interaction.mode === 'drawingMask') {
|
||||
@@ -519,14 +529,24 @@ export class CanvasInteractions {
|
||||
return targetHeight / oldHeight;
|
||||
}
|
||||
handleKeyDown(e) {
|
||||
// Always track modifier keys regardless of focus
|
||||
if (e.key === 'Control')
|
||||
this.interaction.isCtrlPressed = true;
|
||||
if (e.key === 'Meta')
|
||||
this.interaction.isMetaPressed = true;
|
||||
if (e.key === 'Shift')
|
||||
this.interaction.isShiftPressed = true;
|
||||
if (e.key === 'Alt') {
|
||||
if (e.key === 'Alt')
|
||||
this.interaction.isAltPressed = true;
|
||||
// Check if canvas is focused before handling any shortcuts
|
||||
const shouldHandle = this.canvas.isMouseOver ||
|
||||
this.canvas.canvas.contains(document.activeElement) ||
|
||||
document.activeElement === this.canvas.canvas;
|
||||
if (!shouldHandle) {
|
||||
return;
|
||||
}
|
||||
// Canvas-specific key handlers (only when focused)
|
||||
if (e.key === 'Alt') {
|
||||
e.preventDefault();
|
||||
}
|
||||
if (e.key.toLowerCase() === 's') {
|
||||
@@ -560,6 +580,17 @@ export class CanvasInteractions {
|
||||
this.canvas.canvasLayers.copySelectedLayers();
|
||||
}
|
||||
break;
|
||||
case 'v':
|
||||
// Only handle internal clipboard paste here.
|
||||
// If internal clipboard is empty, let the paste event bubble
|
||||
// so handlePasteEvent can access e.clipboardData for system images.
|
||||
if (this.canvas.canvasLayers.internalClipboard.length > 0) {
|
||||
this.canvas.canvasLayers.pasteLayers();
|
||||
} else {
|
||||
// Don't preventDefault - let paste event fire for system clipboard
|
||||
handled = false;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
handled = false;
|
||||
break;
|
||||
@@ -713,12 +744,11 @@ export class CanvasInteractions {
|
||||
if (mods.ctrl || mods.meta) {
|
||||
const index = this.canvas.canvasSelection.selectedLayers.indexOf(layer);
|
||||
if (index === -1) {
|
||||
// Ctrl-clicking unselected layer: add to selection
|
||||
this.canvas.canvasSelection.updateSelection([...this.canvas.canvasSelection.selectedLayers, layer]);
|
||||
}
|
||||
else {
|
||||
const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l) => l !== layer);
|
||||
this.canvas.canvasSelection.updateSelection(newSelection);
|
||||
}
|
||||
// If already selected, do NOT deselect - allows dragging multiple layers with Ctrl held
|
||||
// User can use right-click in layers panel to deselect individual layers
|
||||
}
|
||||
else {
|
||||
if (!this.canvas.canvasSelection.selectedLayers.includes(layer)) {
|
||||
@@ -1155,10 +1185,13 @@ export class CanvasInteractions {
|
||||
}
|
||||
}
|
||||
async handlePasteEvent(e) {
|
||||
// Check if canvas is connected to DOM and visible
|
||||
if (!this.canvas.canvas.isConnected || !document.body.contains(this.canvas.canvas)) {
|
||||
return;
|
||||
}
|
||||
const shouldHandle = this.canvas.isMouseOver ||
|
||||
this.canvas.canvas.contains(document.activeElement) ||
|
||||
document.activeElement === this.canvas.canvas ||
|
||||
document.activeElement === document.body;
|
||||
document.activeElement === this.canvas.canvas;
|
||||
if (!shouldHandle) {
|
||||
log.debug("Paste event ignored - not focused on canvas");
|
||||
return;
|
||||
|
||||
@@ -1100,8 +1100,8 @@ export class CanvasLayers {
|
||||
this.canvas.width = width;
|
||||
this.canvas.height = height;
|
||||
this.canvas.maskTool.resize(width, height);
|
||||
this.canvas.canvas.width = width;
|
||||
this.canvas.canvas.height = height;
|
||||
// Don't set canvas.width/height - the render loop will handle display size
|
||||
// this.canvas.width/height are for OUTPUT AREA dimensions, not display canvas
|
||||
this.canvas.render();
|
||||
if (saveHistory) {
|
||||
this.canvas.canvasState.saveStateToDB();
|
||||
|
||||
@@ -123,6 +123,26 @@ export class CanvasLayersPanel {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.deleteSelectedLayers();
|
||||
return;
|
||||
}
|
||||
// Handle Ctrl+C/V for layer copy/paste when panel has focus
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
if (e.key.toLowerCase() === 'c') {
|
||||
if (this.canvas.canvasSelection.selectedLayers.length > 0) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.canvas.canvasLayers.copySelectedLayers();
|
||||
log.info('Layers copied from panel');
|
||||
}
|
||||
}
|
||||
else if (e.key.toLowerCase() === 'v') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (this.canvas.canvasLayers.internalClipboard.length > 0) {
|
||||
this.canvas.canvasLayers.pasteLayers();
|
||||
log.info('Layers pasted from panel');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
log.debug('Panel structure created');
|
||||
@@ -329,6 +349,8 @@ export class CanvasLayersPanel {
|
||||
// Aktualizuj tylko wygląd (klasy CSS), bez niszczenia DOM
|
||||
this.updateSelectionAppearance();
|
||||
this.updateButtonStates();
|
||||
// Focus the canvas so keyboard shortcuts (like Ctrl+C/V) work for layer operations
|
||||
this.canvas.canvas.focus();
|
||||
log.debug(`Layer clicked: ${layer.name}, selection count: ${this.canvas.canvasSelection.selectedLayers.length}`);
|
||||
}
|
||||
startEditingLayerName(nameElement, layer) {
|
||||
|
||||
@@ -88,10 +88,10 @@ export class CanvasState {
|
||||
log.debug(`Output Area resized to ${this.canvas.width}x${this.canvas.height} and viewport set.`);
|
||||
const loadedLayers = await this._loadLayers(savedState.layers);
|
||||
this.canvas.layers = loadedLayers.filter((l) => l !== null);
|
||||
log.info(`Loaded ${this.canvas.layers.length} layers.`);
|
||||
if (this.canvas.layers.length === 0) {
|
||||
log.warn("No valid layers loaded, state may be corrupted.");
|
||||
return false;
|
||||
log.info(`Loaded ${this.canvas.layers.length} layers from ${savedState.layers.length} saved layers.`);
|
||||
if (this.canvas.layers.length === 0 && savedState.layers.length > 0) {
|
||||
log.warn(`Failed to load any layers. Saved state had ${savedState.layers.length} layers but all failed to load. This may indicate corrupted IndexedDB data.`);
|
||||
// Don't return false - allow empty canvas to be valid
|
||||
}
|
||||
this.canvas.updateSelectionAfterHistory();
|
||||
this.canvas.render();
|
||||
|
||||
103
js/CanvasView.js
103
js/CanvasView.js
@@ -884,6 +884,12 @@ async function createCanvasWidget(node, widget, app) {
|
||||
if (controlsElement) {
|
||||
resizeObserver.observe(controlsElement);
|
||||
}
|
||||
// Watch the canvas container itself to detect size changes and fix canvas dimensions
|
||||
const canvasContainerResizeObserver = new ResizeObserver(() => {
|
||||
// Force re-read of canvas dimensions on next render
|
||||
canvas.render();
|
||||
});
|
||||
canvasContainerResizeObserver.observe(canvasContainer);
|
||||
canvas.canvas.addEventListener('focus', () => {
|
||||
canvasContainer.classList.add('has-focus');
|
||||
});
|
||||
@@ -1038,13 +1044,20 @@ app.registerExtension({
|
||||
log.info(`Found ${canvasNodeInstances.size} CanvasNode(s). Sending data via WebSocket...`);
|
||||
const sendPromises = [];
|
||||
for (const [nodeId, canvasWidget] of canvasNodeInstances.entries()) {
|
||||
if (app.graph.getNodeById(nodeId) && canvasWidget.canvas && canvasWidget.canvas.canvasIO) {
|
||||
log.debug(`Sending data for canvas node ${nodeId}`);
|
||||
sendPromises.push(canvasWidget.canvas.canvasIO.sendDataViaWebSocket(nodeId));
|
||||
}
|
||||
else {
|
||||
const node = app.graph.getNodeById(nodeId);
|
||||
if (!node) {
|
||||
log.warn(`Node ${nodeId} not found in graph, removing from instances map.`);
|
||||
canvasNodeInstances.delete(nodeId);
|
||||
continue;
|
||||
}
|
||||
// Skip bypassed nodes
|
||||
if (node.mode === 4) {
|
||||
log.debug(`Node ${nodeId} is bypassed, skipping data send.`);
|
||||
continue;
|
||||
}
|
||||
if (canvasWidget.canvas && canvasWidget.canvas.canvasIO) {
|
||||
log.debug(`Sending data for canvas node ${nodeId}`);
|
||||
sendPromises.push(canvasWidget.canvas.canvasIO.sendDataViaWebSocket(nodeId));
|
||||
}
|
||||
}
|
||||
try {
|
||||
@@ -1063,6 +1076,8 @@ app.registerExtension({
|
||||
},
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeType.comfyClass === "LayerForgeNode") {
|
||||
// Map to track pending copy sources across node ID changes
|
||||
const pendingCopySources = new Map();
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
log.debug("CanvasNode onNodeCreated: Base widget setup.");
|
||||
@@ -1093,6 +1108,43 @@ app.registerExtension({
|
||||
log.info(`Registered CanvasNode instance for ID: ${this.id}`);
|
||||
// Store the canvas widget on the node
|
||||
this.canvasWidget = canvasWidget;
|
||||
// Check if this node has a pending copy source (from onConfigure)
|
||||
// Check both the current ID and -1 (temporary ID during paste)
|
||||
let sourceNodeId = pendingCopySources.get(this.id);
|
||||
if (!sourceNodeId) {
|
||||
sourceNodeId = pendingCopySources.get(-1);
|
||||
if (sourceNodeId) {
|
||||
// Transfer from -1 to the real ID and clear -1
|
||||
pendingCopySources.delete(-1);
|
||||
}
|
||||
}
|
||||
if (sourceNodeId && sourceNodeId !== this.id) {
|
||||
log.info(`Node ${this.id} will copy canvas state from node ${sourceNodeId}`);
|
||||
// Clear the flag
|
||||
pendingCopySources.delete(this.id);
|
||||
// Copy the canvas state now that the widget is initialized
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const { getCanvasState, setCanvasState } = await import('./db.js');
|
||||
let sourceState = await getCanvasState(String(sourceNodeId));
|
||||
// If source node doesn't exist (cross-workflow paste), try clipboard
|
||||
if (!sourceState) {
|
||||
log.debug(`No canvas state found for source node ${sourceNodeId}, checking clipboard`);
|
||||
sourceState = await getCanvasState('__clipboard__');
|
||||
}
|
||||
if (!sourceState) {
|
||||
log.debug(`No canvas state found in clipboard either`);
|
||||
return;
|
||||
}
|
||||
await setCanvasState(String(this.id), sourceState);
|
||||
await canvasWidget.canvas.loadInitialState();
|
||||
log.info(`Canvas state copied successfully to node ${this.id}`);
|
||||
}
|
||||
catch (error) {
|
||||
log.error(`Error copying canvas state:`, error);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
// Check if there are already connected inputs
|
||||
setTimeout(() => {
|
||||
if (this.inputs && this.inputs.length > 0) {
|
||||
@@ -1258,6 +1310,47 @@ app.registerExtension({
|
||||
}
|
||||
return onRemoved?.apply(this, arguments);
|
||||
};
|
||||
// Handle copy/paste - save canvas state when copying
|
||||
const originalSerialize = nodeType.prototype.serialize;
|
||||
nodeType.prototype.serialize = function () {
|
||||
const data = originalSerialize ? originalSerialize.apply(this) : {};
|
||||
// Store a reference to the source node ID so we can copy layer data
|
||||
data.sourceNodeId = this.id;
|
||||
log.debug(`Serializing node ${this.id} for copy`);
|
||||
// Store canvas state in a clipboard entry for cross-workflow paste
|
||||
// This happens async but that's fine since paste happens later
|
||||
(async () => {
|
||||
try {
|
||||
const { getCanvasState, setCanvasState } = await import('./db.js');
|
||||
const sourceState = await getCanvasState(String(this.id));
|
||||
if (sourceState) {
|
||||
// Store in a special "clipboard" entry
|
||||
await setCanvasState('__clipboard__', sourceState);
|
||||
log.debug(`Stored canvas state in clipboard for node ${this.id}`);
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
log.error('Error storing canvas state to clipboard:', error);
|
||||
}
|
||||
})();
|
||||
return data;
|
||||
};
|
||||
// Handle copy/paste - load canvas state from source node when pasting
|
||||
const originalConfigure = nodeType.prototype.onConfigure;
|
||||
nodeType.prototype.onConfigure = async function (data) {
|
||||
if (originalConfigure) {
|
||||
originalConfigure.apply(this, [data]);
|
||||
}
|
||||
// Store the source node ID in the map (persists across node ID changes)
|
||||
// This will be picked up later in onAdded when the canvas widget is ready
|
||||
if (data.sourceNodeId && data.sourceNodeId !== this.id) {
|
||||
const existingSource = pendingCopySources.get(this.id);
|
||||
if (!existingSource) {
|
||||
pendingCopySources.set(this.id, data.sourceNodeId);
|
||||
log.debug(`Stored pending copy source: ${data.sourceNodeId} for node ${this.id}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
const originalGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
|
||||
nodeType.prototype.getExtraMenuOptions = function (_, options) {
|
||||
// FIRST: Call original to let other extensions add their options
|
||||
|
||||
@@ -578,8 +578,8 @@ export class Canvas {
|
||||
* Inicjalizuje podstawowe właściwości canvas
|
||||
*/
|
||||
initCanvas() {
|
||||
this.canvas.width = this.width;
|
||||
this.canvas.height = this.height;
|
||||
// Don't set canvas.width/height here - let the render loop handle it based on clientWidth/clientHeight
|
||||
// this.width and this.height are for the OUTPUT AREA, not the display canvas
|
||||
this.canvas.style.border = '1px solid black';
|
||||
this.canvas.style.maxWidth = '100%';
|
||||
this.canvas.style.backgroundColor = '#606060';
|
||||
|
||||
@@ -217,11 +217,34 @@ export class CanvasIO {
|
||||
|
||||
async _renderOutputData(): Promise<{ image: string, mask: string }> {
|
||||
log.info("=== RENDERING OUTPUT DATA FOR COMFYUI ===");
|
||||
|
||||
|
||||
// Check if layers have valid images loaded, with retry logic
|
||||
const maxRetries = 5;
|
||||
const retryDelay = 200;
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
const layersWithoutImages = this.canvas.layers.filter(layer => !layer.image || !layer.image.complete);
|
||||
|
||||
if (layersWithoutImages.length === 0) {
|
||||
break; // All images loaded
|
||||
}
|
||||
|
||||
if (attempt === 0) {
|
||||
log.warn(`${layersWithoutImages.length} layer(s) have incomplete image data. Waiting for images to load...`);
|
||||
}
|
||||
|
||||
if (attempt < maxRetries - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, retryDelay));
|
||||
} else {
|
||||
// Last attempt failed
|
||||
throw new Error(`Canvas not ready after ${maxRetries} attempts: ${layersWithoutImages.length} layer(s) still have incomplete image data. Try waiting a moment and running again.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Użyj zunifikowanych funkcji z CanvasLayers
|
||||
const imageBlob = await this.canvas.canvasLayers.getFlattenedCanvasAsBlob();
|
||||
const maskBlob = await this.canvas.canvasLayers.getFlattenedMaskAsBlob();
|
||||
|
||||
|
||||
if (!imageBlob || !maskBlob) {
|
||||
throw new Error("Failed to generate canvas or mask blobs");
|
||||
}
|
||||
|
||||
@@ -132,16 +132,16 @@ export class CanvasInteractions {
|
||||
const mouseBufferY = (worldCoords.y - this.canvas.viewport.y) * this.canvas.viewport.zoom;
|
||||
|
||||
const newZoom = Math.max(0.1, Math.min(10, this.canvas.viewport.zoom * zoomFactor));
|
||||
|
||||
|
||||
this.canvas.viewport.zoom = newZoom;
|
||||
this.canvas.viewport.x = worldCoords.x - (mouseBufferX / this.canvas.viewport.zoom);
|
||||
this.canvas.viewport.y = worldCoords.y - (mouseBufferY / this.canvas.viewport.zoom);
|
||||
|
||||
|
||||
// Update stroke overlay if mask tool is drawing during zoom
|
||||
if (this.canvas.maskTool.isDrawing) {
|
||||
this.canvas.maskTool.handleViewportChange();
|
||||
}
|
||||
|
||||
|
||||
this.canvas.onViewportChange?.();
|
||||
}
|
||||
|
||||
@@ -176,6 +176,9 @@ export class CanvasInteractions {
|
||||
|
||||
document.addEventListener('paste', this.onPaste as unknown as EventListener);
|
||||
|
||||
// Intercept Ctrl+V during capture phase to handle layer paste before ComfyUI
|
||||
document.addEventListener('keydown', this.onKeyDown as EventListener, { capture: true });
|
||||
|
||||
this.canvas.canvas.addEventListener('mouseenter', this.onMouseEnter as EventListener);
|
||||
this.canvas.canvas.addEventListener('mouseleave', this.onMouseLeave as EventListener);
|
||||
|
||||
@@ -195,6 +198,9 @@ export class CanvasInteractions {
|
||||
this.canvas.canvas.removeEventListener('keydown', this.onKeyDown as EventListener);
|
||||
this.canvas.canvas.removeEventListener('keyup', this.onKeyUp as EventListener);
|
||||
|
||||
// Remove document-level capture listener
|
||||
document.removeEventListener('keydown', this.onKeyDown as EventListener, { capture: true });
|
||||
|
||||
window.removeEventListener('blur', this.onBlur);
|
||||
document.removeEventListener('paste', this.onPaste as unknown as EventListener);
|
||||
|
||||
@@ -228,7 +234,7 @@ export class CanvasInteractions {
|
||||
const rotatedY = dx * Math.sin(rad) + dy * Math.cos(rad);
|
||||
|
||||
// Sprawdź czy punkt jest wewnątrz prostokąta layera
|
||||
if (Math.abs(rotatedX) <= layer.width / 2 &&
|
||||
if (Math.abs(rotatedX) <= layer.width / 2 &&
|
||||
Math.abs(rotatedY) <= layer.height / 2) {
|
||||
return true;
|
||||
}
|
||||
@@ -277,6 +283,14 @@ export class CanvasInteractions {
|
||||
|
||||
handleMouseDown(e: MouseEvent): void {
|
||||
this.canvas.canvas.focus();
|
||||
|
||||
// Sync modifier states with actual event state to prevent "stuck" modifiers
|
||||
// when focus moves between layers panel and canvas
|
||||
this.interaction.isCtrlPressed = e.ctrlKey;
|
||||
this.interaction.isMetaPressed = e.metaKey;
|
||||
this.interaction.isShiftPressed = e.shiftKey;
|
||||
this.interaction.isAltPressed = e.altKey;
|
||||
|
||||
const coords = this.getMouseCoordinates(e);
|
||||
const mods = this.getModifierState(e);
|
||||
|
||||
@@ -325,11 +339,11 @@ export class CanvasInteractions {
|
||||
this.startCanvasResize(coords.world);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// 2. Inne przyciski myszy
|
||||
if (e.button === 2) { // Prawy przycisk myszy
|
||||
this.preventEventDefaults(e);
|
||||
|
||||
|
||||
// Sprawdź czy kliknięto w obszarze któregokolwiek z zaznaczonych layerów (niezależnie od przykrycia)
|
||||
if (this.isPointInSelectedLayers(coords.world.x, coords.world.y)) {
|
||||
// Nowa logika przekazuje tylko współrzędne świata, menu pozycjonuje się samo
|
||||
@@ -354,7 +368,7 @@ export class CanvasInteractions {
|
||||
if (grabIconLayer) {
|
||||
// Start dragging the selected layer(s) without changing selection
|
||||
this.interaction.mode = 'potential-drag';
|
||||
this.interaction.dragStart = {...coords.world};
|
||||
this.interaction.dragStart = { ...coords.world };
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -363,7 +377,7 @@ export class CanvasInteractions {
|
||||
this.prepareForDrag(clickedLayerResult.layer, coords.world);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// 4. Domyślna akcja na tle (lewy przycisk bez modyfikatorów)
|
||||
this.startPanning(e, true); // clearSelection = true
|
||||
}
|
||||
@@ -371,7 +385,7 @@ export class CanvasInteractions {
|
||||
handleMouseMove(e: MouseEvent): void {
|
||||
const coords = this.getMouseCoordinates(e);
|
||||
this.canvas.lastMousePosition = coords.world; // Zawsze aktualizuj ostatnią pozycję myszy
|
||||
|
||||
|
||||
// Sprawdź, czy rozpocząć przeciąganie
|
||||
if (this.interaction.mode === 'potential-drag') {
|
||||
const dx = coords.world.x - this.interaction.dragStart.x;
|
||||
@@ -384,7 +398,7 @@ export class CanvasInteractions {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
switch (this.interaction.mode) {
|
||||
case 'drawingMask':
|
||||
this.canvas.maskTool.handleMouseMove(coords.world, coords.view);
|
||||
@@ -419,12 +433,12 @@ export class CanvasInteractions {
|
||||
// Check if hovering over grab icon
|
||||
const wasHovering = this.interaction.hoveringGrabIcon;
|
||||
this.interaction.hoveringGrabIcon = this.getGrabIconAtPosition(coords.world.x, coords.world.y) !== null;
|
||||
|
||||
|
||||
// Re-render if hover state changed to show/hide grab icon
|
||||
if (wasHovering !== this.interaction.hoveringGrabIcon) {
|
||||
this.canvas.render();
|
||||
}
|
||||
|
||||
|
||||
this.updateCursor(coords.world);
|
||||
// Update brush cursor on overlay if mask tool is active
|
||||
if (this.canvas.maskTool.isActive) {
|
||||
@@ -441,7 +455,7 @@ export class CanvasInteractions {
|
||||
|
||||
handleMouseUp(e: MouseEvent): void {
|
||||
const coords = this.getMouseCoordinates(e);
|
||||
|
||||
|
||||
if (this.interaction.mode === 'drawingMask') {
|
||||
this.canvas.maskTool.handleMouseUp(coords.view);
|
||||
// Render only once after drawing is complete
|
||||
@@ -470,7 +484,7 @@ export class CanvasInteractions {
|
||||
if (this.interaction.mode === 'resizing' && this.interaction.transformingLayer?.cropMode) {
|
||||
this.canvas.canvasLayers.handleCropBoundsTransformEnd(this.interaction.transformingLayer);
|
||||
}
|
||||
|
||||
|
||||
// Handle end of scale transformation (normal transform mode) before resetting interaction state
|
||||
if (this.interaction.mode === 'resizing' && this.interaction.transformingLayer && !this.interaction.transformingLayer.cropMode) {
|
||||
this.canvas.canvasLayers.handleScaleTransformEnd(this.interaction.transformingLayer);
|
||||
@@ -494,7 +508,7 @@ export class CanvasInteractions {
|
||||
log.info(`Mouse position: world(${coords.world.x.toFixed(1)}, ${coords.world.y.toFixed(1)}) view(${coords.view.x.toFixed(1)}, ${coords.view.y.toFixed(1)})`);
|
||||
log.info(`Output Area Bounds: x=${bounds.x}, y=${bounds.y}, w=${bounds.width}, h=${bounds.height}`);
|
||||
log.info(`Viewport: x=${this.canvas.viewport.x.toFixed(1)}, y=${this.canvas.viewport.y.toFixed(1)}, zoom=${this.canvas.viewport.zoom.toFixed(2)}`);
|
||||
|
||||
|
||||
this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer, index: number) => {
|
||||
const relativeToOutput = {
|
||||
x: layer.x - bounds.x,
|
||||
@@ -541,7 +555,7 @@ export class CanvasInteractions {
|
||||
handleWheel(e: WheelEvent): void {
|
||||
this.preventEventDefaults(e);
|
||||
const coords = this.getMouseCoordinates(e);
|
||||
|
||||
|
||||
if (this.canvas.maskTool.isActive || this.canvas.canvasSelection.selectedLayers.length === 0) {
|
||||
// Zoom operation for mask tool or when no layers selected
|
||||
const zoomFactor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
|
||||
@@ -549,7 +563,7 @@ export class CanvasInteractions {
|
||||
} else {
|
||||
// Check if mouse is over any selected layer
|
||||
const isOverSelectedLayer = this.isPointInSelectedLayers(coords.world.x, coords.world.y);
|
||||
|
||||
|
||||
if (isOverSelectedLayer) {
|
||||
// Layer transformation when layers are selected and mouse is over selected layer
|
||||
this.handleLayerWheelTransformation(e);
|
||||
@@ -559,7 +573,7 @@ export class CanvasInteractions {
|
||||
this.performZoomOperation(coords.world, zoomFactor);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
this.canvas.render();
|
||||
if (!this.canvas.maskTool.isActive) {
|
||||
this.canvas.requestSaveState();
|
||||
@@ -615,7 +629,7 @@ export class CanvasInteractions {
|
||||
layer.height *= scaleFactor;
|
||||
layer.x += (oldWidth - layer.width) / 2;
|
||||
layer.y += (oldHeight - layer.height) / 2;
|
||||
|
||||
|
||||
// Handle wheel scaling end for layers with blend area
|
||||
this.canvas.canvasLayers.handleWheelScalingEnd(layer);
|
||||
}
|
||||
@@ -631,11 +645,11 @@ export class CanvasInteractions {
|
||||
} else {
|
||||
targetHeight = (Math.ceil(oldHeight / gridSize) - 1) * gridSize;
|
||||
}
|
||||
|
||||
|
||||
if (targetHeight < gridSize / 2) {
|
||||
targetHeight = gridSize / 2;
|
||||
}
|
||||
|
||||
|
||||
if (Math.abs(oldHeight - targetHeight) < 1) {
|
||||
if (direction > 0) targetHeight += gridSize;
|
||||
else targetHeight -= gridSize;
|
||||
@@ -646,11 +660,23 @@ export class CanvasInteractions {
|
||||
}
|
||||
|
||||
handleKeyDown(e: KeyboardEvent): void {
|
||||
// Always track modifier keys regardless of focus
|
||||
if (e.key === 'Control') this.interaction.isCtrlPressed = true;
|
||||
if (e.key === 'Meta') this.interaction.isMetaPressed = true;
|
||||
if (e.key === 'Shift') this.interaction.isShiftPressed = true;
|
||||
if (e.key === 'Alt') this.interaction.isAltPressed = true;
|
||||
|
||||
// Check if canvas is focused before handling any shortcuts
|
||||
const shouldHandle = this.canvas.isMouseOver ||
|
||||
this.canvas.canvas.contains(document.activeElement) ||
|
||||
document.activeElement === this.canvas.canvas;
|
||||
|
||||
if (!shouldHandle) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Canvas-specific key handlers (only when focused)
|
||||
if (e.key === 'Alt') {
|
||||
this.interaction.isAltPressed = true;
|
||||
e.preventDefault();
|
||||
}
|
||||
if (e.key.toLowerCase() === 's') {
|
||||
@@ -664,7 +690,7 @@ export class CanvasInteractions {
|
||||
this.canvas.shapeTool.activate();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Globalne skróty (Undo/Redo/Copy/Paste)
|
||||
const mods = this.getModifierState(e);
|
||||
if (mods.ctrl || mods.meta) {
|
||||
@@ -685,6 +711,17 @@ export class CanvasInteractions {
|
||||
this.canvas.canvasLayers.copySelectedLayers();
|
||||
}
|
||||
break;
|
||||
case 'v':
|
||||
// Only handle internal clipboard paste here.
|
||||
// If internal clipboard is empty, let the paste event bubble
|
||||
// so handlePasteEvent can access e.clipboardData for system images.
|
||||
if (this.canvas.canvasLayers.internalClipboard.length > 0) {
|
||||
this.canvas.canvasLayers.pasteLayers();
|
||||
} else {
|
||||
// Don't preventDefault - let paste event fire for system clipboard
|
||||
handled = false;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
handled = false;
|
||||
break;
|
||||
@@ -700,7 +737,7 @@ export class CanvasInteractions {
|
||||
if (this.canvas.canvasSelection.selectedLayers.length > 0) {
|
||||
const step = mods.shift ? 10 : 1;
|
||||
let needsRender = false;
|
||||
|
||||
|
||||
// Używamy e.code dla spójności i niezależności od układu klawiatury
|
||||
const movementKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'BracketLeft', 'BracketRight'];
|
||||
if (movementKeys.includes(e.code)) {
|
||||
@@ -724,7 +761,7 @@ export class CanvasInteractions {
|
||||
this.canvas.canvasSelection.removeSelectedLayers();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (needsRender) {
|
||||
this.canvas.render();
|
||||
}
|
||||
@@ -770,7 +807,7 @@ export class CanvasInteractions {
|
||||
this.canvas.saveState();
|
||||
this.canvas.canvasState.saveStateToDB();
|
||||
}
|
||||
|
||||
|
||||
// Reset interaction mode if it's something that can get "stuck"
|
||||
if (this.interaction.mode !== 'none' && this.interaction.mode !== 'drawingMask') {
|
||||
this.resetInteractionState();
|
||||
@@ -820,7 +857,7 @@ export class CanvasInteractions {
|
||||
originalHeight: layer.originalHeight,
|
||||
cropBounds: layer.cropBounds ? { ...layer.cropBounds } : undefined
|
||||
};
|
||||
this.interaction.dragStart = {...worldCoords};
|
||||
this.interaction.dragStart = { ...worldCoords };
|
||||
|
||||
if (handle === 'rot') {
|
||||
this.interaction.mode = 'rotating';
|
||||
@@ -844,19 +881,19 @@ export class CanvasInteractions {
|
||||
if (mods.ctrl || mods.meta) {
|
||||
const index = this.canvas.canvasSelection.selectedLayers.indexOf(layer);
|
||||
if (index === -1) {
|
||||
// Ctrl-clicking unselected layer: add to selection
|
||||
this.canvas.canvasSelection.updateSelection([...this.canvas.canvasSelection.selectedLayers, layer]);
|
||||
} else {
|
||||
const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l: Layer) => l !== layer);
|
||||
this.canvas.canvasSelection.updateSelection(newSelection);
|
||||
}
|
||||
// If already selected, do NOT deselect - allows dragging multiple layers with Ctrl held
|
||||
// User can use right-click in layers panel to deselect individual layers
|
||||
} else {
|
||||
if (!this.canvas.canvasSelection.selectedLayers.includes(layer)) {
|
||||
this.canvas.canvasSelection.updateSelection([layer]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
this.interaction.mode = 'potential-drag';
|
||||
this.interaction.dragStart = {...worldCoords};
|
||||
this.interaction.dragStart = { ...worldCoords };
|
||||
}
|
||||
|
||||
startPanning(e: MouseEvent, clearSelection: boolean = true): void {
|
||||
@@ -872,8 +909,8 @@ export class CanvasInteractions {
|
||||
this.interaction.mode = 'resizingCanvas';
|
||||
const startX = snapToGrid(worldCoords.x);
|
||||
const startY = snapToGrid(worldCoords.y);
|
||||
this.interaction.canvasResizeStart = {x: startX, y: startY};
|
||||
this.interaction.canvasResizeRect = {x: startX, y: startY, width: 0, height: 0};
|
||||
this.interaction.canvasResizeStart = { x: startX, y: startY };
|
||||
this.interaction.canvasResizeRect = { x: startX, y: startY, width: 0, height: 0 };
|
||||
this.canvas.render();
|
||||
}
|
||||
|
||||
@@ -887,7 +924,7 @@ export class CanvasInteractions {
|
||||
updateCanvasMove(worldCoords: Point): void {
|
||||
const dx = worldCoords.x - this.interaction.dragStart.x;
|
||||
const dy = worldCoords.y - this.interaction.dragStart.y;
|
||||
|
||||
|
||||
// Po prostu przesuwamy outputAreaBounds
|
||||
const bounds = this.canvas.outputAreaBounds;
|
||||
this.interaction.canvasMoveRect = {
|
||||
@@ -911,11 +948,11 @@ export class CanvasInteractions {
|
||||
width: moveRect.width,
|
||||
height: moveRect.height
|
||||
};
|
||||
|
||||
|
||||
// Update mask canvas to ensure it covers the new output area position
|
||||
this.canvas.maskTool.updateMaskCanvasForOutputArea();
|
||||
}
|
||||
|
||||
|
||||
this.canvas.render();
|
||||
this.canvas.saveState();
|
||||
}
|
||||
@@ -925,13 +962,13 @@ export class CanvasInteractions {
|
||||
const dy = e.clientY - this.interaction.panStart.y;
|
||||
this.canvas.viewport.x -= dx / this.canvas.viewport.zoom;
|
||||
this.canvas.viewport.y -= dy / this.canvas.viewport.zoom;
|
||||
this.interaction.panStart = {x: e.clientX, y: e.clientY};
|
||||
|
||||
this.interaction.panStart = { x: e.clientX, y: e.clientY };
|
||||
|
||||
// Update stroke overlay if mask tool is drawing during pan
|
||||
if (this.canvas.maskTool.isDrawing) {
|
||||
this.canvas.maskTool.handleViewportChange();
|
||||
}
|
||||
|
||||
|
||||
this.canvas.render();
|
||||
this.canvas.onViewportChange?.();
|
||||
}
|
||||
@@ -994,7 +1031,7 @@ export class CanvasInteractions {
|
||||
|
||||
const o = this.interaction.transformOrigin;
|
||||
if (!o) return;
|
||||
|
||||
|
||||
const handle = this.interaction.resizeHandle;
|
||||
const anchor = this.interaction.resizeAnchor;
|
||||
const rad = o.rotation * Math.PI / 180;
|
||||
@@ -1012,7 +1049,7 @@ export class CanvasInteractions {
|
||||
// Determine sign based on handle
|
||||
const signX = handle?.includes('e') ? 1 : (handle?.includes('w') ? -1 : 0);
|
||||
const signY = handle?.includes('s') ? 1 : (handle?.includes('n') ? -1 : 0);
|
||||
|
||||
|
||||
localVecX *= signX;
|
||||
localVecY *= signY;
|
||||
|
||||
@@ -1022,13 +1059,13 @@ export class CanvasInteractions {
|
||||
|
||||
if (layer.cropMode && o.cropBounds && o.originalWidth && o.originalHeight) {
|
||||
// CROP MODE: Calculate delta based on mouse movement and apply to cropBounds.
|
||||
|
||||
|
||||
// Calculate mouse movement since drag start, in the layer's local coordinate system.
|
||||
const dragStartX_local = this.interaction.dragStart.x - (o.centerX ?? 0);
|
||||
const dragStartY_local = this.interaction.dragStart.y - (o.centerY ?? 0);
|
||||
const mouseX_local = mouseX - (o.centerX ?? 0);
|
||||
const mouseY_local = mouseY - (o.centerY ?? 0);
|
||||
|
||||
|
||||
// Rotate mouse delta into the layer's unrotated frame
|
||||
const deltaX_world = mouseX_local - dragStartX_local;
|
||||
const deltaY_world = mouseY_local - dragStartY_local;
|
||||
@@ -1041,20 +1078,20 @@ export class CanvasInteractions {
|
||||
if (layer.flipV) {
|
||||
mouseDeltaY_local *= -1;
|
||||
}
|
||||
|
||||
|
||||
// Convert the on-screen mouse delta to an image-space delta.
|
||||
const screenToImageScaleX = o.originalWidth / o.width;
|
||||
const screenToImageScaleY = o.originalHeight / o.height;
|
||||
|
||||
|
||||
const delta_image_x = mouseDeltaX_local * screenToImageScaleX;
|
||||
const delta_image_y = mouseDeltaY_local * screenToImageScaleY;
|
||||
|
||||
|
||||
let newCropBounds = { ...o.cropBounds }; // Start with the bounds from the beginning of the drag
|
||||
|
||||
// Apply the image-space delta to the appropriate edges of the crop bounds
|
||||
const isFlippedH = layer.flipH;
|
||||
const isFlippedV = layer.flipV;
|
||||
|
||||
|
||||
if (handle?.includes('w')) {
|
||||
if (isFlippedH) newCropBounds.width += delta_image_x;
|
||||
else {
|
||||
@@ -1081,10 +1118,10 @@ export class CanvasInteractions {
|
||||
newCropBounds.height -= delta_image_y;
|
||||
} else newCropBounds.height += delta_image_y;
|
||||
}
|
||||
|
||||
// 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 (handle?.includes('w')) newCropBounds.x = o.cropBounds.x + o.cropBounds.width -1;
|
||||
if (handle?.includes('w')) newCropBounds.x = o.cropBounds.x + o.cropBounds.width - 1;
|
||||
newCropBounds.width = 1;
|
||||
}
|
||||
if (newCropBounds.height < 1) {
|
||||
@@ -1112,7 +1149,7 @@ export class CanvasInteractions {
|
||||
// TRANSFORM MODE: Resize the layer's main transform frame
|
||||
let newWidth = localVecX;
|
||||
let newHeight = localVecY;
|
||||
|
||||
|
||||
if (isShiftPressed) {
|
||||
const originalAspectRatio = o.width / o.height;
|
||||
if (Math.abs(newWidth) > Math.abs(newHeight) * originalAspectRatio) {
|
||||
@@ -1124,10 +1161,10 @@ export class CanvasInteractions {
|
||||
|
||||
if (newWidth < 10) newWidth = 10;
|
||||
if (newHeight < 10) newHeight = 10;
|
||||
|
||||
|
||||
layer.width = newWidth;
|
||||
layer.height = newHeight;
|
||||
|
||||
|
||||
// Update position to keep anchor point fixed
|
||||
const deltaW = layer.width - o.width;
|
||||
const deltaH = layer.height - o.height;
|
||||
@@ -1193,7 +1230,7 @@ export class CanvasInteractions {
|
||||
|
||||
this.canvas.updateOutputAreaSize(newWidth, newHeight);
|
||||
}
|
||||
|
||||
|
||||
this.canvas.render();
|
||||
this.canvas.saveState();
|
||||
}
|
||||
@@ -1291,7 +1328,7 @@ export class CanvasInteractions {
|
||||
|
||||
// Store the original canvas size for extension calculations
|
||||
this.canvas.originalCanvasSize = { width: newWidth, height: newHeight };
|
||||
|
||||
|
||||
// Store the original position where custom shape was drawn for extension calculations
|
||||
this.canvas.originalOutputAreaPosition = { x: newX, y: newY };
|
||||
|
||||
@@ -1300,10 +1337,10 @@ export class CanvasInteractions {
|
||||
const ext = this.canvas.outputAreaExtensions;
|
||||
const extendedWidth = newWidth + ext.left + ext.right;
|
||||
const extendedHeight = newHeight + ext.top + ext.bottom;
|
||||
|
||||
|
||||
// Update canvas size with extensions
|
||||
this.canvas.updateOutputAreaSize(extendedWidth, extendedHeight, false);
|
||||
|
||||
|
||||
// Set outputAreaBounds accounting for extensions
|
||||
this.canvas.outputAreaBounds = {
|
||||
x: newX - ext.left, // Adjust position by left extension
|
||||
@@ -1311,19 +1348,19 @@ export class CanvasInteractions {
|
||||
width: extendedWidth,
|
||||
height: extendedHeight
|
||||
};
|
||||
|
||||
|
||||
log.info(`New custom shape with extensions: original(${newX}, ${newY}) extended(${newX - ext.left}, ${newY - ext.top}) size(${extendedWidth}x${extendedHeight})`);
|
||||
} else {
|
||||
// No extensions - use original size and position
|
||||
this.canvas.updateOutputAreaSize(newWidth, newHeight, false);
|
||||
|
||||
|
||||
this.canvas.outputAreaBounds = {
|
||||
x: newX,
|
||||
y: newY,
|
||||
width: newWidth,
|
||||
height: newHeight
|
||||
};
|
||||
|
||||
|
||||
log.info(`New custom shape without extensions: position(${newX}, ${newY}) size(${newWidth}x${newHeight})`);
|
||||
}
|
||||
|
||||
@@ -1342,12 +1379,15 @@ export class CanvasInteractions {
|
||||
}
|
||||
|
||||
async handlePasteEvent(e: ClipboardEvent): Promise<void> {
|
||||
// Check if canvas is connected to DOM and visible
|
||||
if (!this.canvas.canvas.isConnected || !document.body.contains(this.canvas.canvas)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldHandle = this.canvas.isMouseOver ||
|
||||
this.canvas.canvas.contains(document.activeElement) ||
|
||||
document.activeElement === this.canvas.canvas ||
|
||||
document.activeElement === document.body;
|
||||
|
||||
document.activeElement === this.canvas.canvas;
|
||||
|
||||
if (!shouldHandle) {
|
||||
log.debug("Paste event ignored - not focused on canvas");
|
||||
return;
|
||||
@@ -1356,7 +1396,7 @@ export class CanvasInteractions {
|
||||
log.info("Paste event detected, checking clipboard preference");
|
||||
|
||||
const preference = this.canvas.canvasLayers.clipboardPreference;
|
||||
|
||||
|
||||
if (preference === 'clipspace') {
|
||||
|
||||
log.info("Clipboard preference is clipspace, delegating to ClipboardManager");
|
||||
@@ -1400,7 +1440,7 @@ export class CanvasInteractions {
|
||||
public activateOutputAreaTransform(): void {
|
||||
// Clear any existing interaction state before starting transform
|
||||
this.resetInteractionState();
|
||||
|
||||
|
||||
// Deactivate any active tools that might conflict
|
||||
if (this.canvas.shapeTool.isActive) {
|
||||
this.canvas.shapeTool.deactivate();
|
||||
@@ -1408,10 +1448,10 @@ export class CanvasInteractions {
|
||||
if (this.canvas.maskTool.isActive) {
|
||||
this.canvas.maskTool.deactivate();
|
||||
}
|
||||
|
||||
|
||||
// Clear selection to avoid confusion
|
||||
this.canvas.canvasSelection.updateSelection([]);
|
||||
|
||||
|
||||
// Set transform mode
|
||||
this.interaction.mode = 'transformingOutputArea';
|
||||
this.canvas.render();
|
||||
@@ -1420,7 +1460,7 @@ export class CanvasInteractions {
|
||||
private getOutputAreaHandle(worldCoords: Point): string | null {
|
||||
const bounds = this.canvas.outputAreaBounds;
|
||||
const threshold = 10 / this.canvas.viewport.zoom;
|
||||
|
||||
|
||||
// Define handle positions
|
||||
const handles = {
|
||||
'nw': { x: bounds.x, y: bounds.y },
|
||||
@@ -1447,7 +1487,7 @@ export class CanvasInteractions {
|
||||
private startOutputAreaTransform(handle: string, worldCoords: Point): void {
|
||||
this.interaction.outputAreaTransformHandle = handle;
|
||||
this.interaction.dragStart = { ...worldCoords };
|
||||
|
||||
|
||||
const bounds = this.canvas.outputAreaBounds;
|
||||
this.interaction.transformOrigin = {
|
||||
x: bounds.x,
|
||||
@@ -1470,17 +1510,17 @@ export class CanvasInteractions {
|
||||
'sw': { x: bounds.x + bounds.width, y: bounds.y },
|
||||
'w': { x: bounds.x + bounds.width, y: bounds.y + bounds.height / 2 },
|
||||
};
|
||||
|
||||
|
||||
this.interaction.outputAreaTransformAnchor = anchorMap[handle];
|
||||
}
|
||||
|
||||
private resizeOutputAreaFromHandle(worldCoords: Point, isShiftPressed: boolean): void {
|
||||
const o = this.interaction.transformOrigin;
|
||||
if (!o) return;
|
||||
|
||||
|
||||
const handle = this.interaction.outputAreaTransformHandle;
|
||||
const anchor = this.interaction.outputAreaTransformAnchor;
|
||||
|
||||
|
||||
let newX = o.x;
|
||||
let newY = o.y;
|
||||
let newWidth = o.width;
|
||||
@@ -1551,7 +1591,7 @@ export class CanvasInteractions {
|
||||
|
||||
private updateOutputAreaTransformCursor(worldCoords: Point): void {
|
||||
const handle = this.getOutputAreaHandle(worldCoords);
|
||||
|
||||
|
||||
if (handle) {
|
||||
const cursorMap: { [key: string]: string } = {
|
||||
'n': 'ns-resize', 's': 'ns-resize',
|
||||
@@ -1567,16 +1607,16 @@ export class CanvasInteractions {
|
||||
|
||||
private finalizeOutputAreaTransform(): void {
|
||||
const bounds = this.canvas.outputAreaBounds;
|
||||
|
||||
|
||||
// Update canvas size and mask tool
|
||||
this.canvas.updateOutputAreaSize(bounds.width, bounds.height);
|
||||
|
||||
|
||||
// Update mask canvas for new output area
|
||||
this.canvas.maskTool.updateMaskCanvasForOutputArea();
|
||||
|
||||
|
||||
// Save state
|
||||
this.canvas.saveState();
|
||||
|
||||
|
||||
// Reset transform handle but keep transform mode active
|
||||
this.interaction.outputAreaTransformHandle = null;
|
||||
}
|
||||
|
||||
@@ -1263,8 +1263,8 @@ export class CanvasLayers {
|
||||
this.canvas.height = height;
|
||||
this.canvas.maskTool.resize(width, height);
|
||||
|
||||
this.canvas.canvas.width = width;
|
||||
this.canvas.canvas.height = height;
|
||||
// Don't set canvas.width/height - the render loop will handle display size
|
||||
// this.canvas.width/height are for OUTPUT AREA dimensions, not display canvas
|
||||
|
||||
this.canvas.render();
|
||||
|
||||
|
||||
@@ -133,7 +133,7 @@ export class CanvasLayersPanel {
|
||||
`;
|
||||
|
||||
this.layersContainer = this.container.querySelector<HTMLElement>('#layers-container');
|
||||
|
||||
|
||||
// Setup event listeners dla przycisków
|
||||
this.setupControlButtons();
|
||||
this.setupMasterVisibilityToggle();
|
||||
@@ -144,6 +144,26 @@ export class CanvasLayersPanel {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.deleteSelectedLayers();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Ctrl+C/V for layer copy/paste when panel has focus
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
if (e.key.toLowerCase() === 'c') {
|
||||
if (this.canvas.canvasSelection.selectedLayers.length > 0) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.canvas.canvasLayers.copySelectedLayers();
|
||||
log.info('Layers copied from panel');
|
||||
}
|
||||
} else if (e.key.toLowerCase() === 'v') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (this.canvas.canvasLayers.internalClipboard.length > 0) {
|
||||
this.canvas.canvasLayers.pasteLayers();
|
||||
log.info('Layers pasted from panel');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -269,7 +289,7 @@ export class CanvasLayersPanel {
|
||||
layerRow.className = 'layer-row';
|
||||
layerRow.draggable = true;
|
||||
layerRow.dataset.layerIndex = String(index);
|
||||
|
||||
|
||||
const isSelected = this.canvas.canvasSelection.selectedLayers.includes(layer);
|
||||
if (isSelected) {
|
||||
layerRow.classList.add('selected');
|
||||
@@ -318,7 +338,7 @@ export class CanvasLayersPanel {
|
||||
const scale = Math.min(48 / layer.image.width, 48 / layer.image.height);
|
||||
const scaledWidth = layer.image.width * scale;
|
||||
const scaledHeight = layer.image.height * scale;
|
||||
|
||||
|
||||
// Wycentruj obraz
|
||||
const x = (48 - scaledWidth) / 2;
|
||||
const y = (48 - scaledHeight) / 2;
|
||||
@@ -383,26 +403,29 @@ export class CanvasLayersPanel {
|
||||
// Aktualizuj wewnętrzny stan zaznaczenia w obiekcie canvas
|
||||
// Ta funkcja NIE powinna już wywoływać onSelectionChanged w panelu.
|
||||
this.canvas.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index);
|
||||
|
||||
|
||||
// Aktualizuj tylko wygląd (klasy CSS), bez niszczenia DOM
|
||||
this.updateSelectionAppearance();
|
||||
this.updateButtonStates();
|
||||
|
||||
// Focus the canvas so keyboard shortcuts (like Ctrl+C/V) work for layer operations
|
||||
this.canvas.canvas.focus();
|
||||
|
||||
log.debug(`Layer clicked: ${layer.name}, selection count: ${this.canvas.canvasSelection.selectedLayers.length}`);
|
||||
}
|
||||
|
||||
startEditingLayerName(nameElement: HTMLElement, layer: Layer): void {
|
||||
const currentName = layer.name;
|
||||
nameElement.classList.add('editing');
|
||||
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.value = currentName;
|
||||
input.style.width = '100%';
|
||||
|
||||
|
||||
nameElement.innerHTML = '';
|
||||
nameElement.appendChild(input);
|
||||
|
||||
|
||||
input.focus();
|
||||
input.select();
|
||||
|
||||
@@ -412,7 +435,7 @@ export class CanvasLayersPanel {
|
||||
layer.name = newName;
|
||||
nameElement.classList.remove('editing');
|
||||
nameElement.textContent = newName;
|
||||
|
||||
|
||||
this.canvas.saveState();
|
||||
log.info(`Layer renamed to: ${newName}`);
|
||||
};
|
||||
@@ -436,11 +459,11 @@ export class CanvasLayersPanel {
|
||||
if (!existingNames.includes(proposedName)) {
|
||||
return proposedName;
|
||||
}
|
||||
|
||||
|
||||
// Sprawdź czy nazwa już ma numerację w nawiasach
|
||||
const match = proposedName.match(/^(.+?)\s*\((\d+)\)$/);
|
||||
let baseName, startNumber;
|
||||
|
||||
|
||||
if (match) {
|
||||
baseName = match[1].trim();
|
||||
startNumber = parseInt(match[2]) + 1;
|
||||
@@ -448,34 +471,34 @@ export class CanvasLayersPanel {
|
||||
baseName = proposedName;
|
||||
startNumber = 1;
|
||||
}
|
||||
|
||||
|
||||
// Znajdź pierwszą dostępną numerację
|
||||
let counter = startNumber;
|
||||
let uniqueName;
|
||||
|
||||
|
||||
do {
|
||||
uniqueName = `${baseName} (${counter})`;
|
||||
counter++;
|
||||
} while (existingNames.includes(uniqueName));
|
||||
|
||||
|
||||
return uniqueName;
|
||||
}
|
||||
|
||||
toggleLayerVisibility(layer: Layer): void {
|
||||
layer.visible = !layer.visible;
|
||||
|
||||
|
||||
// If layer became invisible and is selected, deselect it
|
||||
if (!layer.visible && this.canvas.canvasSelection.selectedLayers.includes(layer)) {
|
||||
const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l: Layer) => l !== layer);
|
||||
this.canvas.updateSelection(newSelection);
|
||||
}
|
||||
|
||||
|
||||
this.canvas.render();
|
||||
this.canvas.requestSaveState();
|
||||
|
||||
|
||||
// Update the eye icon in the panel
|
||||
this.renderLayers();
|
||||
|
||||
|
||||
log.info(`Layer "${layer.name}" visibility toggled to: ${layer.visible}`);
|
||||
}
|
||||
|
||||
@@ -535,7 +558,7 @@ export class CanvasLayersPanel {
|
||||
|
||||
const line = document.createElement('div');
|
||||
line.className = 'drag-insertion-line';
|
||||
|
||||
|
||||
if (isUpperHalf) {
|
||||
line.style.top = '-1px';
|
||||
} else {
|
||||
@@ -563,7 +586,7 @@ export class CanvasLayersPanel {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const midpoint = rect.top + rect.height / 2;
|
||||
const isUpperHalf = e.clientY < midpoint;
|
||||
|
||||
|
||||
// Oblicz docelowy indeks
|
||||
let insertIndex = targetIndex;
|
||||
if (!isUpperHalf) {
|
||||
@@ -572,7 +595,7 @@ export class CanvasLayersPanel {
|
||||
|
||||
// Użyj nowej, centralnej funkcji do przesuwania warstw
|
||||
this.canvas.canvasLayers.moveLayers(this.draggedElements, { toIndex: insertIndex });
|
||||
|
||||
|
||||
log.info(`Dropped ${this.draggedElements.length} layers at position ${insertIndex}`);
|
||||
}
|
||||
|
||||
@@ -610,17 +633,17 @@ export class CanvasLayersPanel {
|
||||
*/
|
||||
updateButtonStates(): void {
|
||||
if (!this.container) return;
|
||||
|
||||
|
||||
const deleteBtn = this.container.querySelector('#delete-layer-btn') as HTMLButtonElement;
|
||||
const hasSelectedLayers = this.canvas.canvasSelection.selectedLayers.length > 0;
|
||||
|
||||
|
||||
if (deleteBtn) {
|
||||
deleteBtn.disabled = !hasSelectedLayers;
|
||||
deleteBtn.title = hasSelectedLayers
|
||||
deleteBtn.title = hasSelectedLayers
|
||||
? `Delete ${this.canvas.canvasSelection.selectedLayers.length} selected layer(s)`
|
||||
: 'No layers selected';
|
||||
}
|
||||
|
||||
|
||||
log.debug(`Button states updated - delete button ${hasSelectedLayers ? 'enabled' : 'disabled'}`);
|
||||
}
|
||||
|
||||
@@ -641,7 +664,7 @@ export class CanvasLayersPanel {
|
||||
this.layersContainer = null;
|
||||
this.draggedElements = [];
|
||||
this.removeDragInsertionLine();
|
||||
|
||||
|
||||
log.info('CanvasLayersPanel destroyed');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,11 +118,11 @@ export class CanvasState {
|
||||
log.debug(`Output Area resized to ${this.canvas.width}x${this.canvas.height} and viewport set.`);
|
||||
const loadedLayers = await this._loadLayers(savedState.layers);
|
||||
this.canvas.layers = loadedLayers.filter((l): l is Layer => l !== null);
|
||||
log.info(`Loaded ${this.canvas.layers.length} layers.`);
|
||||
log.info(`Loaded ${this.canvas.layers.length} layers from ${savedState.layers.length} saved layers.`);
|
||||
|
||||
if (this.canvas.layers.length === 0) {
|
||||
log.warn("No valid layers loaded, state may be corrupted.");
|
||||
return false;
|
||||
if (this.canvas.layers.length === 0 && savedState.layers.length > 0) {
|
||||
log.warn(`Failed to load any layers. Saved state had ${savedState.layers.length} layers but all failed to load. This may indicate corrupted IndexedDB data.`);
|
||||
// Don't return false - allow empty canvas to be valid
|
||||
}
|
||||
|
||||
this.canvas.updateSelectionAfterHistory();
|
||||
|
||||
@@ -1000,6 +1000,13 @@ $el("label.clipboard-switch.mask-switch", {
|
||||
resizeObserver.observe(controlsElement);
|
||||
}
|
||||
|
||||
// Watch the canvas container itself to detect size changes and fix canvas dimensions
|
||||
const canvasContainerResizeObserver = new ResizeObserver(() => {
|
||||
// Force re-read of canvas dimensions on next render
|
||||
canvas.render();
|
||||
});
|
||||
canvasContainerResizeObserver.observe(canvasContainer);
|
||||
|
||||
canvas.canvas.addEventListener('focus', () => {
|
||||
canvasContainer.classList.add('has-focus');
|
||||
});
|
||||
@@ -1195,12 +1202,23 @@ app.registerExtension({
|
||||
|
||||
const sendPromises: Promise<any>[] = [];
|
||||
for (const [nodeId, canvasWidget] of canvasNodeInstances.entries()) {
|
||||
if (app.graph.getNodeById(nodeId) && canvasWidget.canvas && canvasWidget.canvas.canvasIO) {
|
||||
log.debug(`Sending data for canvas node ${nodeId}`);
|
||||
sendPromises.push(canvasWidget.canvas.canvasIO.sendDataViaWebSocket(nodeId));
|
||||
} else {
|
||||
const node = app.graph.getNodeById(nodeId);
|
||||
|
||||
if (!node) {
|
||||
log.warn(`Node ${nodeId} not found in graph, removing from instances map.`);
|
||||
canvasNodeInstances.delete(nodeId);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip bypassed nodes
|
||||
if (node.mode === 4) {
|
||||
log.debug(`Node ${nodeId} is bypassed, skipping data send.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (canvasWidget.canvas && canvasWidget.canvas.canvasIO) {
|
||||
log.debug(`Sending data for canvas node ${nodeId}`);
|
||||
sendPromises.push(canvasWidget.canvas.canvasIO.sendDataViaWebSocket(nodeId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1221,6 +1239,9 @@ app.registerExtension({
|
||||
|
||||
async beforeRegisterNodeDef(nodeType: any, nodeData: any, app: ComfyApp) {
|
||||
if (nodeType.comfyClass === "LayerForgeNode") {
|
||||
// Map to track pending copy sources across node ID changes
|
||||
const pendingCopySources = new Map<number, number>();
|
||||
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function (this: ComfyNode) {
|
||||
log.debug("CanvasNode onNodeCreated: Base widget setup.");
|
||||
@@ -1253,10 +1274,53 @@ app.registerExtension({
|
||||
const canvasWidget = await createCanvasWidget(this, null, app);
|
||||
canvasNodeInstances.set(this.id, canvasWidget);
|
||||
log.info(`Registered CanvasNode instance for ID: ${this.id}`);
|
||||
|
||||
|
||||
// Store the canvas widget on the node
|
||||
(this as any).canvasWidget = canvasWidget;
|
||||
|
||||
// Check if this node has a pending copy source (from onConfigure)
|
||||
// Check both the current ID and -1 (temporary ID during paste)
|
||||
let sourceNodeId = pendingCopySources.get(this.id);
|
||||
if (!sourceNodeId) {
|
||||
sourceNodeId = pendingCopySources.get(-1);
|
||||
if (sourceNodeId) {
|
||||
// Transfer from -1 to the real ID and clear -1
|
||||
pendingCopySources.delete(-1);
|
||||
}
|
||||
}
|
||||
|
||||
if (sourceNodeId && sourceNodeId !== this.id) {
|
||||
log.info(`Node ${this.id} will copy canvas state from node ${sourceNodeId}`);
|
||||
|
||||
// Clear the flag
|
||||
pendingCopySources.delete(this.id);
|
||||
|
||||
// Copy the canvas state now that the widget is initialized
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const { getCanvasState, setCanvasState } = await import('./db.js');
|
||||
let sourceState = await getCanvasState(String(sourceNodeId));
|
||||
|
||||
// If source node doesn't exist (cross-workflow paste), try clipboard
|
||||
if (!sourceState) {
|
||||
log.debug(`No canvas state found for source node ${sourceNodeId}, checking clipboard`);
|
||||
sourceState = await getCanvasState('__clipboard__');
|
||||
}
|
||||
|
||||
if (!sourceState) {
|
||||
log.debug(`No canvas state found in clipboard either`);
|
||||
return;
|
||||
}
|
||||
|
||||
await setCanvasState(String(this.id), sourceState);
|
||||
await canvasWidget.canvas.loadInitialState();
|
||||
log.info(`Canvas state copied successfully to node ${this.id}`);
|
||||
} catch (error) {
|
||||
log.error(`Error copying canvas state:`, error);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Check if there are already connected inputs
|
||||
setTimeout(() => {
|
||||
if (this.inputs && this.inputs.length > 0) {
|
||||
@@ -1440,6 +1504,52 @@ app.registerExtension({
|
||||
return onRemoved?.apply(this, arguments as any);
|
||||
};
|
||||
|
||||
// Handle copy/paste - save canvas state when copying
|
||||
const originalSerialize = nodeType.prototype.serialize;
|
||||
nodeType.prototype.serialize = function (this: ComfyNode) {
|
||||
const data = originalSerialize ? originalSerialize.apply(this) : {};
|
||||
|
||||
// Store a reference to the source node ID so we can copy layer data
|
||||
data.sourceNodeId = this.id;
|
||||
log.debug(`Serializing node ${this.id} for copy`);
|
||||
|
||||
// Store canvas state in a clipboard entry for cross-workflow paste
|
||||
// This happens async but that's fine since paste happens later
|
||||
(async () => {
|
||||
try {
|
||||
const { getCanvasState, setCanvasState } = await import('./db.js');
|
||||
const sourceState = await getCanvasState(String(this.id));
|
||||
if (sourceState) {
|
||||
// Store in a special "clipboard" entry
|
||||
await setCanvasState('__clipboard__', sourceState);
|
||||
log.debug(`Stored canvas state in clipboard for node ${this.id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Error storing canvas state to clipboard:', error);
|
||||
}
|
||||
})();
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
// Handle copy/paste - load canvas state from source node when pasting
|
||||
const originalConfigure = nodeType.prototype.onConfigure;
|
||||
nodeType.prototype.onConfigure = async function (this: ComfyNode, data: any) {
|
||||
if (originalConfigure) {
|
||||
originalConfigure.apply(this, [data]);
|
||||
}
|
||||
|
||||
// Store the source node ID in the map (persists across node ID changes)
|
||||
// This will be picked up later in onAdded when the canvas widget is ready
|
||||
if (data.sourceNodeId && data.sourceNodeId !== this.id) {
|
||||
const existingSource = pendingCopySources.get(this.id);
|
||||
if (!existingSource) {
|
||||
pendingCopySources.set(this.id, data.sourceNodeId);
|
||||
log.debug(`Stored pending copy source: ${data.sourceNodeId} for node ${this.id}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const originalGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
|
||||
nodeType.prototype.getExtraMenuOptions = function (this: ComfyNode, _: any, options: any[]) {
|
||||
// FIRST: Call original to let other extensions add their options
|
||||
|
||||
Reference in New Issue
Block a user