Files
Comfyui-LayerForge/js/CanvasInteractions.js
Dariusz L 0f4f2cb1b0 feat: add interactive output area transform handles
Implemented drag-to-resize functionality for the output area with visual transform handles on corners and edges. Users can now interactively resize the output area by dragging handles instead of using dialogs, with support for grid snapping and aspect ratio preservation.
2025-08-14 13:54:10 +02:00

1323 lines
59 KiB
JavaScript

import { createModuleLogger } from "./utils/LoggerUtils.js";
import { snapToGrid, getSnapAdjustment } from "./utils/CommonUtils.js";
const log = createModuleLogger('CanvasInteractions');
export class CanvasInteractions {
constructor(canvas) {
// Bound event handlers to enable proper removeEventListener and avoid leaks
this.onMouseDown = (e) => this.handleMouseDown(e);
this.onMouseMove = (e) => this.handleMouseMove(e);
this.onMouseUp = (e) => this.handleMouseUp(e);
this.onMouseEnter = (e) => { this.canvas.isMouseOver = true; this.handleMouseEnter(e); };
this.onMouseLeave = (e) => { this.canvas.isMouseOver = false; this.handleMouseLeave(e); };
this.onWheel = (e) => this.handleWheel(e);
this.onKeyDown = (e) => this.handleKeyDown(e);
this.onKeyUp = (e) => this.handleKeyUp(e);
this.onDragOver = (e) => this.handleDragOver(e);
this.onDragEnter = (e) => this.handleDragEnter(e);
this.onDragLeave = (e) => this.handleDragLeave(e);
this.onDrop = (e) => { this.handleDrop(e); };
this.onContextMenu = (e) => this.handleContextMenu(e);
this.onBlur = () => this.handleBlur();
this.onPaste = (e) => this.handlePasteEvent(e);
this.canvas = canvas;
this.interaction = {
mode: 'none',
panStart: { x: 0, y: 0 },
dragStart: { x: 0, y: 0 },
transformOrigin: null,
resizeHandle: null,
resizeAnchor: { x: 0, y: 0 },
canvasResizeStart: { x: 0, y: 0 },
isCtrlPressed: false,
isMetaPressed: false,
isAltPressed: false,
isShiftPressed: false,
isSPressed: false,
hasClonedInDrag: false,
lastClickTime: 0,
transformingLayer: null,
keyMovementInProgress: false,
canvasResizeRect: null,
canvasMoveRect: null,
outputAreaTransformHandle: null,
outputAreaTransformAnchor: { x: 0, y: 0 },
};
this.originalLayerPositions = new Map();
}
// Helper functions to eliminate code duplication
getMouseCoordinates(e) {
return {
world: this.canvas.getMouseWorldCoordinates(e),
view: this.canvas.getMouseViewCoordinates(e)
};
}
getModifierState(e) {
return {
ctrl: this.interaction.isCtrlPressed || e?.ctrlKey || false,
shift: this.interaction.isShiftPressed || e?.shiftKey || false,
alt: this.interaction.isAltPressed || e?.altKey || false,
meta: this.interaction.isMetaPressed || e?.metaKey || false,
};
}
preventEventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
performZoomOperation(worldCoords, zoomFactor) {
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);
// Update stroke overlay if mask tool is drawing during zoom
if (this.canvas.maskTool.isDrawing) {
this.canvas.maskTool.handleViewportChange();
}
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.onMouseDown);
this.canvas.canvas.addEventListener('mousemove', this.onMouseMove);
this.canvas.canvas.addEventListener('mouseup', this.onMouseUp);
this.canvas.canvas.addEventListener('wheel', this.onWheel, { passive: false });
this.canvas.canvas.addEventListener('keydown', this.onKeyDown);
this.canvas.canvas.addEventListener('keyup', this.onKeyUp);
// Add a blur event listener to the window to reset key states
window.addEventListener('blur', this.onBlur);
document.addEventListener('paste', this.onPaste);
this.canvas.canvas.addEventListener('mouseenter', this.onMouseEnter);
this.canvas.canvas.addEventListener('mouseleave', this.onMouseLeave);
this.canvas.canvas.addEventListener('dragover', this.onDragOver);
this.canvas.canvas.addEventListener('dragenter', this.onDragEnter);
this.canvas.canvas.addEventListener('dragleave', this.onDragLeave);
this.canvas.canvas.addEventListener('drop', this.onDrop);
this.canvas.canvas.addEventListener('contextmenu', this.onContextMenu);
}
teardownEventListeners() {
this.canvas.canvas.removeEventListener('mousedown', this.onMouseDown);
this.canvas.canvas.removeEventListener('mousemove', this.onMouseMove);
this.canvas.canvas.removeEventListener('mouseup', this.onMouseUp);
this.canvas.canvas.removeEventListener('wheel', this.onWheel);
this.canvas.canvas.removeEventListener('keydown', this.onKeyDown);
this.canvas.canvas.removeEventListener('keyup', this.onKeyUp);
window.removeEventListener('blur', this.onBlur);
document.removeEventListener('paste', this.onPaste);
this.canvas.canvas.removeEventListener('mouseenter', this.onMouseEnter);
this.canvas.canvas.removeEventListener('mouseleave', this.onMouseLeave);
this.canvas.canvas.removeEventListener('dragover', this.onDragOver);
this.canvas.canvas.removeEventListener('dragenter', this.onDragEnter);
this.canvas.canvas.removeEventListener('dragleave', this.onDragLeave);
this.canvas.canvas.removeEventListener('drop', this.onDrop);
this.canvas.canvas.removeEventListener('contextmenu', this.onContextMenu);
}
/**
* 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.interaction.outputAreaTransformHandle = null;
this.canvas.canvas.style.cursor = 'default';
}
handleMouseDown(e) {
this.canvas.canvas.focus();
const coords = this.getMouseCoordinates(e);
const mods = this.getModifierState(e);
if (this.interaction.mode === 'drawingMask') {
this.canvas.maskTool.handleMouseDown(coords.world, coords.view);
// Don't render here - mask tool will handle its own drawing
return;
}
if (this.interaction.mode === 'transformingOutputArea') {
// Check if clicking on output area transform handle
const handle = this.getOutputAreaHandle(coords.world);
if (handle) {
this.startOutputAreaTransform(handle, coords.world);
return;
}
// If clicking outside, exit transform mode
this.interaction.mode = 'none';
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 (mods.shift && mods.ctrl) {
this.startCanvasMove(coords.world);
return;
}
if (mods.shift) {
// 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 === 1) { // Ś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.startPanning(e, true); // clearSelection = true
}
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);
// Don't render during mask drawing - it's handled by mask tool internally
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;
case 'transformingOutputArea':
if (this.interaction.outputAreaTransformHandle) {
this.resizeOutputAreaFromHandle(coords.world, e.shiftKey);
}
else {
this.updateOutputAreaTransformCursor(coords.world);
}
break;
default:
this.updateCursor(coords.world);
// Update brush cursor on overlay if mask tool is active
if (this.canvas.maskTool.isActive) {
this.canvas.canvasRenderer.drawMaskBrushCursor(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);
// Render only once after drawing is complete
this.canvas.render();
return;
}
if (this.interaction.mode === 'resizingCanvas') {
this.finalizeCanvasResize();
}
if (this.interaction.mode === 'movingCanvas') {
this.finalizeCanvasMove();
}
if (this.interaction.mode === 'transformingOutputArea' && this.interaction.outputAreaTransformHandle) {
this.finalizeOutputAreaTransform();
return;
}
// Log layer positions when dragging ends
if (this.interaction.mode === 'dragging' && this.canvas.canvasSelection.selectedLayers.length > 0) {
this.logDragCompletion(coords);
}
// Handle end of crop bounds transformation before resetting interaction state
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);
}
// 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 {
// 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);
}
else {
// Zoom operation when mouse is not over selected layers
const zoomFactor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
this.performZoomOperation(coords.world, zoomFactor);
}
}
this.canvas.render();
if (!this.canvas.maskTool.isActive) {
this.canvas.requestSaveState();
}
}
handleLayerWheelTransformation(e) {
const mods = this.getModifierState(e);
const rotationStep = 5 * (e.deltaY > 0 ? -1 : 1);
const direction = e.deltaY < 0 ? 1 : -1;
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
if (mods.shift) {
this.handleLayerRotation(layer, mods.ctrl, direction, rotationStep);
}
else {
this.handleLayerScaling(layer, mods.ctrl, 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;
// Handle wheel scaling end for layers with blend area
this.canvas.canvasLayers.handleWheelScalingEnd(layer);
}
}
calculateGridBasedScaling(oldHeight, deltaY) {
const gridSize = 64; // Grid size - could be made configurable in the future
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 === 'Meta')
this.interaction.isMetaPressed = 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)
const mods = this.getModifierState(e);
if (mods.ctrl || mods.meta) {
let handled = true;
switch (e.key.toLowerCase()) {
case 'z':
if (mods.shift) {
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 = 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)) {
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 === 'Meta')
this.interaction.isMetaPressed = 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.isMetaPressed = 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) {
// If actively rotating, show grabbing cursor
if (this.interaction.mode === 'rotating') {
this.canvas.canvas.style.cursor = 'grabbing';
return;
}
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
// Support both Ctrl (Windows/Linux) and Cmd (macOS) for multi-selection
const mods = this.getModifierState();
if (mods.ctrl || mods.meta) {
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 };
}
startPanning(e, clearSelection = true) {
// Unified panning method - can optionally clear selection
if (clearSelection && !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();
}
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 };
// 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?.();
}
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)
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;
let mouseDeltaX_local = deltaX_world * cos + deltaY_world * sin;
let mouseDeltaY_local = deltaY_world * cos - deltaX_world * sin;
if (layer.flipH) {
mouseDeltaX_local *= -1;
}
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 {
newCropBounds.x += delta_image_x;
newCropBounds.width -= delta_image_x;
}
}
if (handle?.includes('e')) {
if (isFlippedH) {
newCropBounds.x += delta_image_x;
newCropBounds.width -= delta_image_x;
}
else
newCropBounds.width += delta_image_x;
}
if (handle?.includes('n')) {
if (isFlippedV)
newCropBounds.height += delta_image_y;
else {
newCropBounds.y += delta_image_y;
newCropBounds.height -= delta_image_y;
}
}
if (handle?.includes('s')) {
if (isFlippedV) {
newCropBounds.y += delta_image_y;
newCropBounds.height -= delta_image_y;
}
else
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)
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);
}
// New methods for output area transformation
activateOutputAreaTransform() {
// 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();
}
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();
}
getOutputAreaHandle(worldCoords) {
const bounds = this.canvas.outputAreaBounds;
const threshold = 10 / this.canvas.viewport.zoom;
// Define handle positions
const handles = {
'nw': { x: bounds.x, y: bounds.y },
'n': { x: bounds.x + bounds.width / 2, y: bounds.y },
'ne': { x: bounds.x + bounds.width, y: bounds.y },
'e': { x: bounds.x + bounds.width, y: bounds.y + bounds.height / 2 },
'se': { x: bounds.x + bounds.width, y: bounds.y + bounds.height },
's': { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height },
'sw': { x: bounds.x, y: bounds.y + bounds.height },
'w': { x: bounds.x, y: bounds.y + bounds.height / 2 },
};
for (const [name, pos] of Object.entries(handles)) {
const dx = worldCoords.x - pos.x;
const dy = worldCoords.y - pos.y;
if (Math.sqrt(dx * dx + dy * dy) < threshold) {
return name;
}
}
return null;
}
startOutputAreaTransform(handle, worldCoords) {
this.interaction.outputAreaTransformHandle = handle;
this.interaction.dragStart = { ...worldCoords };
const bounds = this.canvas.outputAreaBounds;
this.interaction.transformOrigin = {
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height,
rotation: 0,
centerX: bounds.x + bounds.width / 2,
centerY: bounds.y + bounds.height / 2
};
// Set anchor point (opposite corner for resize)
const anchorMap = {
'nw': { x: bounds.x + bounds.width, y: bounds.y + bounds.height },
'n': { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height },
'ne': { x: bounds.x, y: bounds.y + bounds.height },
'e': { x: bounds.x, y: bounds.y + bounds.height / 2 },
'se': { x: bounds.x, y: bounds.y },
's': { x: bounds.x + bounds.width / 2, y: bounds.y },
'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];
}
resizeOutputAreaFromHandle(worldCoords, isShiftPressed) {
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;
let newHeight = o.height;
// Calculate new dimensions based on handle
if (handle?.includes('w')) {
const deltaX = worldCoords.x - anchor.x;
newWidth = Math.abs(deltaX);
newX = Math.min(worldCoords.x, anchor.x);
}
if (handle?.includes('e')) {
const deltaX = worldCoords.x - anchor.x;
newWidth = Math.abs(deltaX);
newX = Math.min(worldCoords.x, anchor.x);
}
if (handle?.includes('n')) {
const deltaY = worldCoords.y - anchor.y;
newHeight = Math.abs(deltaY);
newY = Math.min(worldCoords.y, anchor.y);
}
if (handle?.includes('s')) {
const deltaY = worldCoords.y - anchor.y;
newHeight = Math.abs(deltaY);
newY = Math.min(worldCoords.y, anchor.y);
}
// Maintain aspect ratio if shift is held
if (isShiftPressed && o.width > 0 && o.height > 0) {
const aspectRatio = o.width / o.height;
if (handle === 'n' || handle === 's') {
newWidth = newHeight * aspectRatio;
}
else if (handle === 'e' || handle === 'w') {
newHeight = newWidth / aspectRatio;
}
else {
// Corner handles
const proposedRatio = newWidth / newHeight;
if (proposedRatio > aspectRatio) {
newHeight = newWidth / aspectRatio;
}
else {
newWidth = newHeight * aspectRatio;
}
}
}
// Snap to grid if Ctrl is held
if (this.interaction.isCtrlPressed) {
newX = snapToGrid(newX);
newY = snapToGrid(newY);
newWidth = snapToGrid(newWidth);
newHeight = snapToGrid(newHeight);
}
// Apply minimum size
if (newWidth < 10)
newWidth = 10;
if (newHeight < 10)
newHeight = 10;
// Update output area bounds temporarily for preview
this.canvas.outputAreaBounds = {
x: newX,
y: newY,
width: newWidth,
height: newHeight
};
this.canvas.render();
}
updateOutputAreaTransformCursor(worldCoords) {
const handle = this.getOutputAreaHandle(worldCoords);
if (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',
};
this.canvas.canvas.style.cursor = cursorMap[handle] || 'default';
}
else {
this.canvas.canvas.style.cursor = 'default';
}
}
finalizeOutputAreaTransform() {
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;
}
}