import { createModuleLogger } from "./utils/LoggerUtils.js"; import { snapToGrid, getSnapAdjustment } from "./utils/CommonUtils.js"; const log = createModuleLogger('CanvasInteractions'); export class CanvasInteractions { constructor(canvas) { this.canvas = canvas; this.interaction = { mode: 'none', panStart: { x: 0, y: 0 }, dragStart: { x: 0, y: 0 }, transformOrigin: {}, resizeHandle: null, resizeAnchor: { x: 0, y: 0 }, canvasResizeStart: { x: 0, y: 0 }, isCtrlPressed: false, isAltPressed: false, isShiftPressed: false, isSPressed: false, hasClonedInDrag: false, lastClickTime: 0, transformingLayer: null, keyMovementInProgress: false, canvasResizeRect: null, canvasMoveRect: null, }; this.originalLayerPositions = new Map(); } // Helper functions to eliminate code duplication getMouseCoordinates(e) { return { world: this.canvas.getMouseWorldCoordinates(e), view: this.canvas.getMouseViewCoordinates(e) }; } preventEventDefaults(e) { e.preventDefault(); e.stopPropagation(); } performZoomOperation(worldCoords, zoomFactor) { const rect = this.canvas.canvas.getBoundingClientRect(); const mouseBufferX = (worldCoords.x - this.canvas.viewport.x) * this.canvas.viewport.zoom; 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); this.canvas.onViewportChange?.(); } renderAndSave(shouldSave = false) { this.canvas.render(); if (shouldSave) { this.canvas.saveState(); this.canvas.canvasState.saveStateToDB(); } } setDragDropStyling(active) { if (active) { this.canvas.canvas.style.backgroundColor = 'rgba(45, 90, 160, 0.1)'; this.canvas.canvas.style.border = '2px dashed #2d5aa0'; } else { this.canvas.canvas.style.backgroundColor = ''; this.canvas.canvas.style.border = ''; } } setupEventListeners() { this.canvas.canvas.addEventListener('mousedown', this.handleMouseDown.bind(this)); this.canvas.canvas.addEventListener('mousemove', this.handleMouseMove.bind(this)); this.canvas.canvas.addEventListener('mouseup', this.handleMouseUp.bind(this)); this.canvas.canvas.addEventListener('mouseleave', this.handleMouseLeave.bind(this)); this.canvas.canvas.addEventListener('wheel', this.handleWheel.bind(this), { passive: false }); this.canvas.canvas.addEventListener('keydown', this.handleKeyDown.bind(this)); this.canvas.canvas.addEventListener('keyup', this.handleKeyUp.bind(this)); // Add a blur event listener to the window to reset key states window.addEventListener('blur', this.handleBlur.bind(this)); document.addEventListener('paste', this.handlePasteEvent.bind(this)); this.canvas.canvas.addEventListener('mouseenter', (e) => { this.canvas.isMouseOver = true; this.handleMouseEnter(e); }); this.canvas.canvas.addEventListener('mouseleave', (e) => { this.canvas.isMouseOver = false; this.handleMouseLeave(e); }); this.canvas.canvas.addEventListener('dragover', this.handleDragOver.bind(this)); this.canvas.canvas.addEventListener('dragenter', this.handleDragEnter.bind(this)); this.canvas.canvas.addEventListener('dragleave', this.handleDragLeave.bind(this)); this.canvas.canvas.addEventListener('drop', this.handleDrop.bind(this)); this.canvas.canvas.addEventListener('contextmenu', this.handleContextMenu.bind(this)); } /** * Sprawdza czy punkt znajduje się w obszarze któregokolwiek z zaznaczonych layerów */ isPointInSelectedLayers(worldX, worldY) { for (const layer of this.canvas.canvasSelection.selectedLayers) { if (!layer.visible) continue; const centerX = layer.x + layer.width / 2; const centerY = layer.y + layer.height / 2; // Przekształć punkt do lokalnego układu współrzędnych layera const dx = worldX - centerX; const dy = worldY - centerY; const rad = -layer.rotation * Math.PI / 180; const rotatedX = dx * Math.cos(rad) - dy * Math.sin(rad); 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 && Math.abs(rotatedY) <= layer.height / 2) { return true; } } return false; } resetInteractionState() { this.interaction.mode = 'none'; this.interaction.resizeHandle = null; this.originalLayerPositions.clear(); this.interaction.canvasResizeRect = null; this.interaction.canvasMoveRect = null; this.interaction.hasClonedInDrag = false; this.interaction.transformingLayer = null; this.canvas.canvas.style.cursor = 'default'; } handleMouseDown(e) { this.canvas.canvas.focus(); const coords = this.getMouseCoordinates(e); if (this.interaction.mode === 'drawingMask') { this.canvas.maskTool.handleMouseDown(coords.world, coords.view); this.canvas.render(); return; } if (this.canvas.shapeTool.isActive) { this.canvas.shapeTool.addPoint(coords.world); return; } // --- Ostateczna, poprawna kolejność sprawdzania --- // 1. Akcje globalne z modyfikatorami (mają najwyższy priorytet) if (e.shiftKey && e.ctrlKey) { this.startCanvasMove(coords.world); return; } if (e.shiftKey) { // Clear custom shape when starting canvas resize if (this.canvas.outputAreaShape) { // If auto-apply shape mask is enabled, remove the mask before clearing the shape if (this.canvas.autoApplyShapeMask) { log.info("Removing shape mask before clearing custom shape for canvas resize"); this.canvas.maskTool.removeShapeMask(); } this.canvas.outputAreaShape = null; this.canvas.render(); } 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 this.canvas.canvasLayers.showBlendModeMenu(coords.world.x, coords.world.y); } return; } if (e.button !== 0) { // Środkowy przycisk this.startPanning(e); return; } // 3. Interakcje z elementami na płótnie (lewy przycisk) const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(coords.world.x, coords.world.y); if (transformTarget) { this.startLayerTransform(transformTarget.layer, transformTarget.handle, coords.world); return; } const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(coords.world.x, coords.world.y); if (clickedLayerResult) { this.prepareForDrag(clickedLayerResult.layer, coords.world); return; } // 4. Domyślna akcja na tle (lewy przycisk bez modyfikatorów) this.startPanningOrClearSelection(e); } handleMouseMove(e) { 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; const dy = coords.world.y - this.interaction.dragStart.y; if (Math.sqrt(dx * dx + dy * dy) > 3) { // Próg 3 pikseli this.interaction.mode = 'dragging'; this.originalLayerPositions.clear(); this.canvas.canvasSelection.selectedLayers.forEach((l) => { this.originalLayerPositions.set(l, { x: l.x, y: l.y }); }); } } switch (this.interaction.mode) { case 'drawingMask': this.canvas.maskTool.handleMouseMove(coords.world, coords.view); this.canvas.render(); break; case 'panning': this.panViewport(e); break; case 'dragging': this.dragLayers(coords.world); break; case 'resizing': this.resizeLayerFromHandle(coords.world, e.shiftKey); break; case 'rotating': this.rotateLayerFromHandle(coords.world, e.shiftKey); break; case 'resizingCanvas': this.updateCanvasResize(coords.world); break; case 'movingCanvas': this.updateCanvasMove(coords.world); break; default: this.updateCursor(coords.world); break; } // --- DYNAMICZNY PODGLĄD LINII CUSTOM SHAPE --- if (this.canvas.shapeTool.isActive && !this.canvas.shapeTool.shape.isClosed) { this.canvas.render(); } } handleMouseUp(e) { const coords = this.getMouseCoordinates(e); if (this.interaction.mode === 'drawingMask') { this.canvas.maskTool.handleMouseUp(coords.view); this.canvas.render(); return; } if (this.interaction.mode === 'resizingCanvas') { this.finalizeCanvasResize(); } if (this.interaction.mode === 'movingCanvas') { this.finalizeCanvasMove(); } // Log layer positions when dragging ends if (this.interaction.mode === 'dragging' && this.canvas.canvasSelection.selectedLayers.length > 0) { this.logDragCompletion(coords); } // Zapisz stan tylko, jeśli faktycznie doszło do zmiany (przeciąganie, transformacja, duplikacja) const stateChangingInteraction = ['dragging', 'resizing', 'rotating'].includes(this.interaction.mode); const duplicatedInDrag = this.interaction.hasClonedInDrag; if (stateChangingInteraction || duplicatedInDrag) { this.renderAndSave(true); } this.resetInteractionState(); this.canvas.render(); } logDragCompletion(coords) { const bounds = this.canvas.outputAreaBounds; log.info("=== LAYER DRAG COMPLETED ==="); 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, index) => { const relativeToOutput = { x: layer.x - bounds.x, y: layer.y - bounds.y }; log.info(`Layer ${index + 1} "${layer.name}": world(${layer.x.toFixed(1)}, ${layer.y.toFixed(1)}) relative_to_output(${relativeToOutput.x.toFixed(1)}, ${relativeToOutput.y.toFixed(1)}) size(${layer.width.toFixed(1)}x${layer.height.toFixed(1)})`); }); log.info("=== END LAYER DRAG ==="); } handleMouseLeave(e) { const coords = this.getMouseCoordinates(e); if (this.canvas.maskTool.isActive) { this.canvas.maskTool.handleMouseLeave(); if (this.canvas.maskTool.isDrawing) { this.canvas.maskTool.handleMouseUp(coords.view); } this.canvas.render(); return; } if (this.interaction.mode !== 'none') { this.resetInteractionState(); this.canvas.render(); } if (this.canvas.canvasLayers.internalClipboard.length > 0) { this.canvas.canvasLayers.internalClipboard = []; log.info("Internal clipboard cleared - mouse left canvas"); } } handleMouseEnter(e) { if (this.canvas.maskTool.isActive) { this.canvas.maskTool.handleMouseEnter(); } } handleContextMenu(e) { // Always prevent browser context menu - we handle all right-click interactions ourselves e.preventDefault(); e.stopPropagation(); } handleWheel(e) { 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; this.performZoomOperation(coords.world, zoomFactor); } else { // Layer transformation when layers are selected this.handleLayerWheelTransformation(e); } this.canvas.render(); if (!this.canvas.maskTool.isActive) { this.canvas.requestSaveState(); } } handleLayerWheelTransformation(e) { const rotationStep = 5 * (e.deltaY > 0 ? -1 : 1); const direction = e.deltaY < 0 ? 1 : -1; this.canvas.canvasSelection.selectedLayers.forEach((layer) => { if (e.shiftKey) { this.handleLayerRotation(layer, e.ctrlKey, direction, rotationStep); } else { this.handleLayerScaling(layer, e.ctrlKey, e.deltaY); } }); } handleLayerRotation(layer, isCtrlPressed, direction, rotationStep) { if (isCtrlPressed) { // Snap to absolute values const snapAngle = 5; if (direction > 0) { layer.rotation = Math.ceil((layer.rotation + 0.1) / snapAngle) * snapAngle; } else { layer.rotation = Math.floor((layer.rotation - 0.1) / snapAngle) * snapAngle; } } else { // Fixed step rotation layer.rotation += rotationStep; } } handleLayerScaling(layer, isCtrlPressed, deltaY) { const oldWidth = layer.width; const oldHeight = layer.height; let scaleFactor; if (isCtrlPressed) { const direction = deltaY > 0 ? -1 : 1; const baseDimension = Math.max(layer.width, layer.height); const newBaseDimension = baseDimension + direction; if (newBaseDimension < 10) return; scaleFactor = newBaseDimension / baseDimension; } else { scaleFactor = this.calculateGridBasedScaling(oldHeight, deltaY); } if (scaleFactor && isFinite(scaleFactor)) { layer.width *= scaleFactor; layer.height *= scaleFactor; layer.x += (oldWidth - layer.width) / 2; layer.y += (oldHeight - layer.height) / 2; } } calculateGridBasedScaling(oldHeight, deltaY) { const gridSize = 64; const direction = deltaY > 0 ? -1 : 1; let targetHeight; if (direction > 0) { targetHeight = (Math.floor(oldHeight / gridSize) + 1) * gridSize; } 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; if (targetHeight < gridSize / 2) return 0; } return targetHeight / oldHeight; } handleKeyDown(e) { if (e.key === 'Control') this.interaction.isCtrlPressed = true; if (e.key === 'Shift') this.interaction.isShiftPressed = true; if (e.key === 'Alt') { this.interaction.isAltPressed = true; e.preventDefault(); } if (e.key.toLowerCase() === 's') { this.interaction.isSPressed = true; e.preventDefault(); e.stopPropagation(); } // Check if Shift+S is being held down if (this.interaction.isShiftPressed && this.interaction.isSPressed && !this.interaction.isCtrlPressed && !this.canvas.shapeTool.isActive) { this.canvas.shapeTool.activate(); return; } // Globalne skróty (Undo/Redo/Copy/Paste) if (e.ctrlKey || e.metaKey) { let handled = true; switch (e.key.toLowerCase()) { case 'z': if (e.shiftKey) { this.canvas.redo(); } else { this.canvas.undo(); } break; case 'y': this.canvas.redo(); break; case 'c': if (this.canvas.canvasSelection.selectedLayers.length > 0) { this.canvas.canvasLayers.copySelectedLayers(); } break; default: handled = false; break; } if (handled) { e.preventDefault(); e.stopPropagation(); return; } } // Skróty kontekstowe (zależne od zaznaczenia) if (this.canvas.canvasSelection.selectedLayers.length > 0) { const step = e.shiftKey ? 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)) { e.preventDefault(); e.stopPropagation(); this.interaction.keyMovementInProgress = true; if (e.code === 'ArrowLeft') this.canvas.canvasSelection.selectedLayers.forEach((l) => l.x -= step); if (e.code === 'ArrowRight') this.canvas.canvasSelection.selectedLayers.forEach((l) => l.x += step); if (e.code === 'ArrowUp') this.canvas.canvasSelection.selectedLayers.forEach((l) => l.y -= step); if (e.code === 'ArrowDown') this.canvas.canvasSelection.selectedLayers.forEach((l) => l.y += step); if (e.code === 'BracketLeft') this.canvas.canvasSelection.selectedLayers.forEach((l) => l.rotation -= step); if (e.code === 'BracketRight') this.canvas.canvasSelection.selectedLayers.forEach((l) => l.rotation += step); needsRender = true; } if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); e.stopPropagation(); this.canvas.canvasSelection.removeSelectedLayers(); return; } if (needsRender) { this.canvas.render(); } } } handleKeyUp(e) { if (e.key === 'Control') this.interaction.isCtrlPressed = false; if (e.key === 'Shift') this.interaction.isShiftPressed = false; if (e.key === 'Alt') this.interaction.isAltPressed = false; if (e.key.toLowerCase() === 's') this.interaction.isSPressed = false; // Deactivate shape tool when Shift or S is released if (this.canvas.shapeTool.isActive && (!this.interaction.isShiftPressed || !this.interaction.isSPressed)) { this.canvas.shapeTool.deactivate(); } const movementKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'BracketLeft', 'BracketRight']; if (movementKeys.includes(e.code) && this.interaction.keyMovementInProgress) { this.canvas.requestSaveState(); // Użyj opóźnionego zapisu this.interaction.keyMovementInProgress = false; } } handleBlur() { log.debug('Window lost focus, resetting key states.'); this.interaction.isCtrlPressed = false; this.interaction.isAltPressed = false; this.interaction.isShiftPressed = false; this.interaction.isSPressed = false; this.interaction.keyMovementInProgress = false; // Deactivate shape tool when window loses focus if (this.canvas.shapeTool.isActive) { this.canvas.shapeTool.deactivate(); } // Also reset any interaction that relies on a key being held down if (this.interaction.mode === 'dragging' && this.interaction.hasClonedInDrag) { // If we were in the middle of a cloning drag, finalize it 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(); this.canvas.render(); } } updateCursor(worldCoords) { const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y); if (transformTarget) { const handleName = transformTarget.handle; const cursorMap = { 'n': 'ns-resize', 's': 'ns-resize', 'e': 'ew-resize', 'w': 'ew-resize', 'nw': 'nwse-resize', 'se': 'nwse-resize', 'ne': 'nesw-resize', 'sw': 'nesw-resize', 'rot': 'grab' }; this.canvas.canvas.style.cursor = cursorMap[handleName]; } else if (this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y)) { this.canvas.canvas.style.cursor = 'move'; } else { this.canvas.canvas.style.cursor = 'default'; } } startLayerTransform(layer, handle, worldCoords) { this.interaction.transformingLayer = layer; this.interaction.transformOrigin = { x: layer.x, y: layer.y, width: layer.width, height: layer.height, rotation: layer.rotation, centerX: layer.x + layer.width / 2, centerY: layer.y + layer.height / 2, originalWidth: layer.originalWidth, originalHeight: layer.originalHeight, cropBounds: layer.cropBounds ? { ...layer.cropBounds } : undefined }; this.interaction.dragStart = { ...worldCoords }; if (handle === 'rot') { this.interaction.mode = 'rotating'; } else { this.interaction.mode = 'resizing'; this.interaction.resizeHandle = handle; const handles = this.canvas.canvasLayers.getHandles(layer); const oppositeHandleKey = { 'n': 's', 's': 'n', 'e': 'w', 'w': 'e', 'nw': 'se', 'se': 'nw', 'ne': 'sw', 'sw': 'ne' }; this.interaction.resizeAnchor = handles[oppositeHandleKey[handle]]; } this.canvas.render(); } prepareForDrag(layer, worldCoords) { // Zaktualizuj zaznaczenie, ale nie zapisuj stanu if (this.interaction.isCtrlPressed) { const index = this.canvas.canvasSelection.selectedLayers.indexOf(layer); if (index === -1) { 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); } } else { if (!this.canvas.canvasSelection.selectedLayers.includes(layer)) { this.canvas.canvasSelection.updateSelection([layer]); } } this.interaction.mode = 'potential-drag'; this.interaction.dragStart = { ...worldCoords }; } startPanningOrClearSelection(e) { // Ta funkcja jest teraz wywoływana tylko gdy kliknięto na tło bez modyfikatorów. // Domyślna akcja: wyczyść zaznaczenie i rozpocznij panoramowanie. if (!this.interaction.isCtrlPressed) { this.canvas.canvasSelection.updateSelection([]); } this.interaction.mode = 'panning'; this.interaction.panStart = { x: e.clientX, y: e.clientY }; } startCanvasResize(worldCoords) { 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.canvas.render(); } startCanvasMove(worldCoords) { this.interaction.mode = 'movingCanvas'; this.interaction.dragStart = { ...worldCoords }; this.canvas.canvas.style.cursor = 'grabbing'; this.canvas.render(); } updateCanvasMove(worldCoords) { 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 = { x: snapToGrid(bounds.x + dx), y: snapToGrid(bounds.y + dy), width: bounds.width, height: bounds.height }; this.canvas.render(); } finalizeCanvasMove() { const moveRect = this.interaction.canvasMoveRect; if (moveRect) { // Po prostu aktualizujemy outputAreaBounds na nową pozycję this.canvas.outputAreaBounds = { x: moveRect.x, y: moveRect.y, 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(); } startPanning(e) { if (!this.interaction.isCtrlPressed) { this.canvas.canvasSelection.updateSelection([]); } this.interaction.mode = 'panning'; this.interaction.panStart = { x: e.clientX, y: e.clientY }; } panViewport(e) { const dx = e.clientX - this.interaction.panStart.x; 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.canvas.render(); this.canvas.onViewportChange?.(); } dragLayers(worldCoords) { if (this.interaction.isAltPressed && !this.interaction.hasClonedInDrag && this.canvas.canvasSelection.selectedLayers.length > 0) { // Scentralizowana logika duplikowania const newLayers = this.canvas.canvasSelection.duplicateSelectedLayers(); // Zresetuj pozycje przeciągania dla nowych, zduplikowanych warstw this.originalLayerPositions.clear(); newLayers.forEach((l) => { this.originalLayerPositions.set(l, { x: l.x, y: l.y }); }); this.interaction.hasClonedInDrag = true; } const totalDx = worldCoords.x - this.interaction.dragStart.x; const totalDy = worldCoords.y - this.interaction.dragStart.y; let finalDx = totalDx, finalDy = totalDy; if (this.interaction.isCtrlPressed && this.canvas.canvasSelection.selectedLayers.length > 0) { const firstLayer = this.canvas.canvasSelection.selectedLayers[0]; const originalPos = this.originalLayerPositions.get(firstLayer); if (originalPos) { const tempLayerForSnap = { ...firstLayer, x: originalPos.x + totalDx, y: originalPos.y + totalDy }; const snapAdjustment = getSnapAdjustment(tempLayerForSnap); if (snapAdjustment) { finalDx += snapAdjustment.x; finalDy += snapAdjustment.y; } } } this.canvas.canvasSelection.selectedLayers.forEach((layer) => { const originalPos = this.originalLayerPositions.get(layer); if (originalPos) { layer.x = originalPos.x + finalDx; layer.y = originalPos.y + finalDy; } }); this.canvas.render(); } resizeLayerFromHandle(worldCoords, isShiftPressed) { const layer = this.interaction.transformingLayer; if (!layer) return; let mouseX = worldCoords.x; let mouseY = worldCoords.y; if (this.interaction.isCtrlPressed) { const snapThreshold = 10 / this.canvas.viewport.zoom; mouseX = Math.abs(mouseX - snapToGrid(mouseX)) < snapThreshold ? snapToGrid(mouseX) : mouseX; mouseY = Math.abs(mouseY - snapToGrid(mouseY)) < snapThreshold ? snapToGrid(mouseY) : mouseY; } const o = this.interaction.transformOrigin; if (o.rotation === undefined || o.width === undefined || o.height === undefined || o.centerX === undefined || o.centerY === undefined) return; const handle = this.interaction.resizeHandle; const anchor = this.interaction.resizeAnchor; const rad = o.rotation * Math.PI / 180; const cos = Math.cos(rad); const sin = Math.sin(rad); // Vector from anchor to mouse const vecX = mouseX - anchor.x; const vecY = mouseY - anchor.y; // Rotate vector to align with layer's local coordinates let localVecX = vecX * cos + vecY * sin; let localVecY = vecY * cos - vecX * sin; // 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; // If not a corner handle, keep original dimension if (signX === 0) localVecX = o.width; if (signY === 0) localVecY = o.height; 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; const mouseDeltaX_local = deltaX_world * cos + deltaY_world * sin; const mouseDeltaY_local = deltaY_world * cos - deltaX_world * sin; // 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 if (handle?.includes('w')) { newCropBounds.x += delta_image_x; newCropBounds.width -= delta_image_x; } if (handle?.includes('e')) { newCropBounds.width += delta_image_x; } if (handle?.includes('n')) { newCropBounds.y += delta_image_y; newCropBounds.height -= delta_image_y; } if (handle?.includes('s')) { newCropBounds.height += delta_image_y; } // 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; newCropBounds.width = 1; } if (newCropBounds.height < 1) { if (handle?.includes('n')) newCropBounds.y = o.cropBounds.y + o.cropBounds.height - 1; newCropBounds.height = 1; } if (newCropBounds.x < 0) { newCropBounds.width += newCropBounds.x; newCropBounds.x = 0; } if (newCropBounds.y < 0) { newCropBounds.height += newCropBounds.y; newCropBounds.y = 0; } if (newCropBounds.x + newCropBounds.width > o.originalWidth) { newCropBounds.width = o.originalWidth - newCropBounds.x; } if (newCropBounds.y + newCropBounds.height > o.originalHeight) { newCropBounds.height = o.originalHeight - newCropBounds.y; } layer.cropBounds = newCropBounds; } else { // 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) { newHeight = (Math.sign(newHeight) || 1) * Math.abs(newWidth) / originalAspectRatio; } else { newWidth = (Math.sign(newWidth) || 1) * Math.abs(newHeight) * originalAspectRatio; } } 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; const shiftX = (deltaW / 2) * signX; const shiftY = (deltaH / 2) * signY; const worldShiftX = shiftX * cos - shiftY * sin; const worldShiftY = shiftX * sin + shiftY * cos; const newCenterX = o.centerX + worldShiftX; const newCenterY = o.centerY + worldShiftY; layer.x = newCenterX - layer.width / 2; layer.y = newCenterY - layer.height / 2; } this.canvas.render(); } rotateLayerFromHandle(worldCoords, isShiftPressed) { const layer = this.interaction.transformingLayer; if (!layer) return; const o = this.interaction.transformOrigin; if (o.rotation === undefined || o.centerX === undefined || o.centerY === undefined) return; const startAngle = Math.atan2(this.interaction.dragStart.y - o.centerY, this.interaction.dragStart.x - o.centerX); const currentAngle = Math.atan2(worldCoords.y - o.centerY, worldCoords.x - o.centerX); let angleDiff = (currentAngle - startAngle) * 180 / Math.PI; let newRotation = o.rotation + angleDiff; if (isShiftPressed) { newRotation = Math.round(newRotation / 15) * 15; } layer.rotation = newRotation; this.canvas.render(); } updateCanvasResize(worldCoords) { if (!this.interaction.canvasResizeRect) return; const snappedMouseX = snapToGrid(worldCoords.x); const snappedMouseY = snapToGrid(worldCoords.y); const start = this.interaction.canvasResizeStart; this.interaction.canvasResizeRect.x = Math.min(snappedMouseX, start.x); this.interaction.canvasResizeRect.y = Math.min(snappedMouseY, start.y); this.interaction.canvasResizeRect.width = Math.abs(snappedMouseX - start.x); this.interaction.canvasResizeRect.height = Math.abs(snappedMouseY - start.y); this.canvas.render(); } finalizeCanvasResize() { if (this.interaction.canvasResizeRect && this.interaction.canvasResizeRect.width > 1 && this.interaction.canvasResizeRect.height > 1) { const newWidth = Math.round(this.interaction.canvasResizeRect.width); const newHeight = Math.round(this.interaction.canvasResizeRect.height); const finalX = this.interaction.canvasResizeRect.x; const finalY = this.interaction.canvasResizeRect.y; // Po prostu aktualizujemy outputAreaBounds na nowy obszar this.canvas.outputAreaBounds = { x: finalX, y: finalY, width: newWidth, height: newHeight }; this.canvas.updateOutputAreaSize(newWidth, newHeight); } this.canvas.render(); this.canvas.saveState(); } handleDragOver(e) { this.preventEventDefaults(e); if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; } handleDragEnter(e) { this.preventEventDefaults(e); this.setDragDropStyling(true); } handleDragLeave(e) { this.preventEventDefaults(e); if (!this.canvas.canvas.contains(e.relatedTarget)) { this.setDragDropStyling(false); } } async handleDrop(e) { this.preventEventDefaults(e); log.info("Canvas drag & drop event intercepted - preventing ComfyUI workflow loading"); this.setDragDropStyling(false); if (!e.dataTransfer) return; const files = Array.from(e.dataTransfer.files); const coords = this.getMouseCoordinates(e); log.info(`Dropped ${files.length} file(s) onto canvas at position (${coords.world.x}, ${coords.world.y})`); for (const file of files) { if (file.type.startsWith('image/')) { try { await this.loadDroppedImageFile(file, coords.world); log.info(`Successfully loaded dropped image: ${file.name}`); } catch (error) { log.error(`Failed to load dropped image ${file.name}:`, error); } } else { log.warn(`Skipped non-image file: ${file.name} (${file.type})`); } } } async loadDroppedImageFile(file, worldCoords) { const reader = new FileReader(); reader.onload = async (e) => { const img = new Image(); img.onload = async () => { const fitOnAddWidget = this.canvas.node.widgets.find((w) => w.name === "fit_on_add"); const addMode = fitOnAddWidget && fitOnAddWidget.value ? 'fit' : 'center'; await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode); }; img.onerror = () => { log.error(`Failed to load dropped image: ${file.name}`); }; if (e.target?.result) { img.src = e.target.result; } }; reader.onerror = () => { log.error(`Failed to read dropped file: ${file.name}`); }; reader.readAsDataURL(file); } defineOutputAreaWithShape(shape) { const boundingBox = this.canvas.shapeTool.getBoundingBox(); if (boundingBox && boundingBox.width > 1 && boundingBox.height > 1) { this.canvas.saveState(); // If there's an existing custom shape and auto-apply shape mask is enabled, remove the previous mask if (this.canvas.outputAreaShape && this.canvas.autoApplyShapeMask) { log.info("Removing previous shape mask before defining new custom shape"); this.canvas.maskTool.removeShapeMask(); } this.canvas.outputAreaShape = { ...shape, points: shape.points.map((p) => ({ x: p.x - boundingBox.x, y: p.y - boundingBox.y })) }; const newWidth = Math.round(boundingBox.width); const newHeight = Math.round(boundingBox.height); const newX = Math.round(boundingBox.x); const newY = Math.round(boundingBox.y); // 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 }; // If extensions are enabled, we need to recalculate outputAreaBounds with current extensions if (this.canvas.outputAreaExtensionEnabled) { 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 y: newY - ext.top, // Adjust position by top extension 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})`); } // Update mask canvas to ensure it covers the new output area position this.canvas.maskTool.updateMaskCanvasForOutputArea(); // If auto-apply shape mask is enabled, automatically apply the mask with current settings if (this.canvas.autoApplyShapeMask) { log.info("Auto-applying shape mask to new custom shape with current settings"); this.canvas.maskTool.applyShapeMask(); } this.canvas.saveState(); this.canvas.render(); } } async handlePasteEvent(e) { const shouldHandle = this.canvas.isMouseOver || this.canvas.canvas.contains(document.activeElement) || document.activeElement === this.canvas.canvas || document.activeElement === document.body; if (!shouldHandle) { log.debug("Paste event ignored - not focused on canvas"); return; } 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"); e.preventDefault(); e.stopPropagation(); await this.canvas.canvasLayers.clipboardManager.handlePaste('mouse', preference); return; } const clipboardData = e.clipboardData; if (clipboardData && clipboardData.items) { for (const item of clipboardData.items) { if (item.type.startsWith('image/')) { e.preventDefault(); e.stopPropagation(); const file = item.getAsFile(); if (file) { log.info("Found direct image data in paste event"); const reader = new FileReader(); reader.onload = async (event) => { const img = new Image(); img.onload = async () => { await this.canvas.canvasLayers.addLayerWithImage(img, {}, 'mouse'); }; if (event.target?.result) { img.src = event.target.result; } }; reader.readAsDataURL(file); return; } } } } await this.canvas.canvasLayers.clipboardManager.handlePaste('mouse', preference); } }