Files
Comfyui-LayerForge/js/CanvasInteractions.js
Dariusz L a1e00ca06a Add brush preview overlay to MaskTool
Introduces a brush preview overlay using a separate preview canvas in MaskTool. Mouse event handlers in CanvasInteractions and MaskTool are updated to support passing both world and view coordinates, enabling accurate brush preview rendering. The preview is shown or hidden appropriately on mouse enter/leave and while drawing.
2025-06-28 07:37:53 +02:00

716 lines
27 KiB
JavaScript

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,
hasClonedInDrag: false,
lastClickTime: 0,
transformingLayer: null,
};
this.originalLayerPositions = new Map();
this.interaction.canvasResizeRect = null;
this.interaction.canvasMoveRect = null;
}
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));
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);
});
}
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 worldCoords = this.canvas.getMouseWorldCoordinates(e);
const viewCoords = this.canvas.getMouseViewCoordinates(e);
if (this.canvas.maskTool.isActive) {
if (e.button === 1) {
this.startPanning(e);
} else {
this.canvas.maskTool.handleMouseDown(worldCoords, viewCoords);
}
this.canvas.render();
return;
}
const currentTime = Date.now();
if (e.shiftKey && e.ctrlKey) {
this.startCanvasMove(worldCoords);
this.canvas.render();
return;
}
if (currentTime - this.interaction.lastClickTime < 300) {
this.canvas.updateSelection([]);
this.canvas.selectedLayer = null;
this.resetInteractionState();
this.canvas.render();
return;
}
this.interaction.lastClickTime = currentTime;
const transformTarget = this.canvas.getHandleAtPosition(worldCoords.x, worldCoords.y);
if (transformTarget) {
this.startLayerTransform(transformTarget.layer, transformTarget.handle, worldCoords);
return;
}
const clickedLayerResult = this.canvas.getLayerAtPosition(worldCoords.x, worldCoords.y);
if (clickedLayerResult) {
if (e.shiftKey && this.canvas.selectedLayers.includes(clickedLayerResult.layer)) {
this.canvas.showBlendModeMenu(e.clientX, e.clientY);
return;
}
this.startLayerDrag(clickedLayerResult.layer, worldCoords);
return;
}
if (e.shiftKey) {
this.startCanvasResize(worldCoords);
} else {
this.startPanning(e);
}
this.canvas.render();
}
handleMouseMove(e) {
const worldCoords = this.canvas.getMouseWorldCoordinates(e);
const viewCoords = this.canvas.getMouseViewCoordinates(e);
this.canvas.lastMousePosition = worldCoords;
if (this.canvas.maskTool.isActive) {
if (this.interaction.mode === 'panning') {
this.panViewport(e);
return;
}
this.canvas.maskTool.handleMouseMove(worldCoords, viewCoords);
if (this.canvas.maskTool.isDrawing) {
this.canvas.render();
}
return;
}
switch (this.interaction.mode) {
case 'panning':
this.panViewport(e);
break;
case 'dragging':
this.dragLayers(worldCoords);
break;
case 'resizing':
this.resizeLayerFromHandle(worldCoords, e.shiftKey);
break;
case 'rotating':
this.rotateLayerFromHandle(worldCoords, e.shiftKey);
break;
case 'resizingCanvas':
this.updateCanvasResize(worldCoords);
break;
case 'movingCanvas':
this.updateCanvasMove(worldCoords);
break;
default:
this.updateCursor(worldCoords);
break;
}
}
handleMouseUp(e) {
const viewCoords = this.canvas.getMouseViewCoordinates(e);
if (this.canvas.maskTool.isActive) {
if (this.interaction.mode === 'panning') {
this.resetInteractionState();
} else {
this.canvas.maskTool.handleMouseUp(viewCoords);
}
this.canvas.render();
return;
}
const interactionEnded = this.interaction.mode !== 'none' && this.interaction.mode !== 'panning';
if (this.interaction.mode === 'resizingCanvas') {
this.finalizeCanvasResize();
} else if (this.interaction.mode === 'movingCanvas') {
this.finalizeCanvasMove();
}
this.resetInteractionState();
this.canvas.render();
if (interactionEnded) {
this.canvas.saveState();
this.canvas.saveStateToDB(true);
}
}
handleMouseLeave(e) {
const viewCoords = this.canvas.getMouseViewCoordinates(e);
if (this.canvas.maskTool.isActive) {
this.canvas.maskTool.handleMouseLeave();
if (this.canvas.maskTool.isDrawing) {
this.canvas.maskTool.handleMouseUp(viewCoords);
}
this.canvas.render();
return;
}
if (this.interaction.mode !== 'none') {
this.resetInteractionState();
this.canvas.render();
}
}
handleMouseEnter(e) {
if (this.canvas.maskTool.isActive) {
this.canvas.maskTool.handleMouseEnter();
}
}
handleWheel(e) {
e.preventDefault();
if (this.canvas.maskTool.isActive) {
const worldCoords = this.canvas.getMouseWorldCoordinates(e);
const rect = this.canvas.canvas.getBoundingClientRect();
const mouseBufferX = (e.clientX - rect.left) * (this.canvas.offscreenCanvas.width / rect.width);
const mouseBufferY = (e.clientY - rect.top) * (this.canvas.offscreenCanvas.height / rect.height);
const zoomFactor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
const newZoom = this.canvas.viewport.zoom * zoomFactor;
this.canvas.viewport.zoom = Math.max(0.1, Math.min(10, newZoom));
this.canvas.viewport.x = worldCoords.x - (mouseBufferX / this.canvas.viewport.zoom);
this.canvas.viewport.y = worldCoords.y - (mouseBufferY / this.canvas.viewport.zoom);
} else if (this.canvas.selectedLayer) {
const rotationStep = 5 * (e.deltaY > 0 ? -1 : 1);
this.canvas.selectedLayers.forEach(layer => {
if (e.shiftKey) {
layer.rotation += rotationStep;
} else {
const oldWidth = layer.width;
const oldHeight = layer.height;
let scaleFactor;
if (e.ctrlKey) {
const direction = e.deltaY > 0 ? -1 : 1;
const baseDimension = Math.max(layer.width, layer.height);
const newBaseDimension = baseDimension + direction;
if (newBaseDimension < 10) {
return;
}
scaleFactor = newBaseDimension / baseDimension;
} else {
const gridSize = 64;
const direction = e.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;
}
scaleFactor = targetHeight / oldHeight;
}
if (scaleFactor && isFinite(scaleFactor)) {
layer.width *= scaleFactor;
layer.height *= scaleFactor;
layer.x += (oldWidth - layer.width) / 2;
layer.y += (oldHeight - layer.height) / 2;
}
}
});
} else {
const worldCoords = this.canvas.getMouseWorldCoordinates(e);
const rect = this.canvas.canvas.getBoundingClientRect();
const mouseBufferX = (e.clientX - rect.left) * (this.canvas.offscreenCanvas.width / rect.width);
const mouseBufferY = (e.clientY - rect.top) * (this.canvas.offscreenCanvas.height / rect.height);
const zoomFactor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
const newZoom = this.canvas.viewport.zoom * zoomFactor;
this.canvas.viewport.zoom = Math.max(0.1, Math.min(10, 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.render();
if (!this.canvas.maskTool.isActive) {
this.canvas.saveState(true);
}
}
handleKeyDown(e) {
if (this.canvas.maskTool.isActive) {
if (e.key === 'Control') this.interaction.isCtrlPressed = true;
if (e.key === 'Alt') {
this.interaction.isAltPressed = true;
e.preventDefault();
}
if (e.ctrlKey) {
if (e.key.toLowerCase() === 'z') {
e.preventDefault();
e.stopPropagation();
if (e.shiftKey) {
this.canvas.redo();
} else {
this.canvas.undo();
}
return;
}
if (e.key.toLowerCase() === 'y') {
e.preventDefault();
e.stopPropagation();
this.canvas.redo();
return;
}
}
return;
}
if (e.key === 'Control') this.interaction.isCtrlPressed = true;
if (e.key === 'Alt') {
this.interaction.isAltPressed = true;
e.preventDefault();
}
if (e.ctrlKey) {
if (e.key.toLowerCase() === 'z') {
e.preventDefault();
e.stopPropagation();
if (e.shiftKey) {
this.canvas.redo();
} else {
this.canvas.undo();
}
return;
}
if (e.key.toLowerCase() === 'y') {
e.preventDefault();
e.stopPropagation();
this.canvas.redo();
return;
}
if (e.key.toLowerCase() === 'c') {
if (this.canvas.selectedLayers.length > 0) {
e.preventDefault();
e.stopPropagation();
this.canvas.copySelectedLayers();
}
return;
}
if (e.key.toLowerCase() === 'v') {
e.preventDefault();
e.stopPropagation();
this.canvas.handlePaste();
return;
}
}
if (this.canvas.selectedLayer) {
if (e.key === 'Delete') {
e.preventDefault();
e.stopPropagation();
this.canvas.saveState();
this.canvas.layers = this.canvas.layers.filter(l => !this.canvas.selectedLayers.includes(l));
this.canvas.updateSelection([]);
this.canvas.render();
return;
}
const step = e.shiftKey ? 10 : 1;
let needsRender = false;
switch (e.code) {
case 'ArrowLeft':
case 'ArrowRight':
case 'ArrowUp':
case 'ArrowDown':
case 'BracketLeft':
case 'BracketRight':
e.preventDefault();
e.stopPropagation();
if (e.code === 'ArrowLeft') this.canvas.selectedLayers.forEach(l => l.x -= step);
if (e.code === 'ArrowRight') this.canvas.selectedLayers.forEach(l => l.x += step);
if (e.code === 'ArrowUp') this.canvas.selectedLayers.forEach(l => l.y -= step);
if (e.code === 'ArrowDown') this.canvas.selectedLayers.forEach(l => l.y += step);
if (e.code === 'BracketLeft') this.canvas.selectedLayers.forEach(l => l.rotation -= step);
if (e.code === 'BracketRight') this.canvas.selectedLayers.forEach(l => l.rotation += step);
needsRender = true;
break;
}
if (needsRender) {
this.canvas.render();
this.canvas.saveState();
}
}
}
handleKeyUp(e) {
if (e.key === 'Control') this.interaction.isCtrlPressed = false;
if (e.key === 'Alt') this.interaction.isAltPressed = false;
}
updateCursor(worldCoords) {
const transformTarget = this.canvas.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.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
};
this.interaction.dragStart = {...worldCoords};
if (handle === 'rot') {
this.interaction.mode = 'rotating';
} else {
this.interaction.mode = 'resizing';
this.interaction.resizeHandle = handle;
const handles = this.canvas.getHandles(layer);
const oppositeHandleKey = {
'n': 's', 's': 'n', 'e': 'w', 'w': 'e',
'nw': 'se', 'se': 'nw', 'ne': 'sw', 'sw': 'ne'
}[handle];
this.interaction.resizeAnchor = handles[oppositeHandleKey];
}
this.canvas.render();
}
startLayerDrag(layer, worldCoords) {
this.interaction.mode = 'dragging';
this.interaction.dragStart = {...worldCoords};
let currentSelection = [...this.canvas.selectedLayers];
if (this.interaction.isCtrlPressed) {
const index = currentSelection.indexOf(layer);
if (index === -1) {
currentSelection.push(layer);
} else {
currentSelection.splice(index, 1);
}
} else {
if (!currentSelection.includes(layer)) {
currentSelection = [layer];
}
}
this.canvas.updateSelection(currentSelection);
this.originalLayerPositions.clear();
this.canvas.selectedLayers.forEach(l => {
this.originalLayerPositions.set(l, {x: l.x, y: l.y});
});
}
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};
const initialX = snapToGrid(worldCoords.x - this.canvas.width / 2);
const initialY = snapToGrid(worldCoords.y - this.canvas.height / 2);
this.interaction.canvasMoveRect = {
x: initialX,
y: initialY,
width: this.canvas.width,
height: this.canvas.height
};
this.canvas.canvas.style.cursor = 'grabbing';
this.canvas.render();
}
updateCanvasMove(worldCoords) {
if (!this.interaction.canvasMoveRect) return;
const dx = worldCoords.x - this.interaction.dragStart.x;
const dy = worldCoords.y - this.interaction.dragStart.y;
const initialRectX = snapToGrid(this.interaction.dragStart.x - this.canvas.width / 2);
const initialRectY = snapToGrid(this.interaction.dragStart.y - this.canvas.height / 2);
this.interaction.canvasMoveRect.x = snapToGrid(initialRectX + dx);
this.interaction.canvasMoveRect.y = snapToGrid(initialRectY + dy);
this.canvas.render();
}
finalizeCanvasMove() {
const moveRect = this.interaction.canvasMoveRect;
if (moveRect && (moveRect.x !== 0 || moveRect.y !== 0)) {
const finalX = moveRect.x;
const finalY = moveRect.y;
this.canvas.layers.forEach(layer => {
layer.x -= finalX;
layer.y -= finalY;
});
this.canvas.maskTool.updatePosition(-finalX, -finalY);
this.canvas.viewport.x -= finalX;
this.canvas.viewport.y -= finalY;
}
this.canvas.render();
}
startPanning(e) {
if (!this.interaction.isCtrlPressed) {
this.canvas.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();
}
dragLayers(worldCoords) {
if (this.interaction.isAltPressed && !this.interaction.hasClonedInDrag && this.canvas.selectedLayers.length > 0) {
const newLayers = [];
this.canvas.selectedLayers.forEach(layer => {
const newLayer = {
...layer,
zIndex: this.canvas.layers.length,
};
this.canvas.layers.push(newLayer);
newLayers.push(newLayer);
});
this.canvas.updateSelection(newLayers);
this.canvas.selectedLayer = newLayers.length > 0 ? newLayers[newLayers.length - 1] : null;
this.originalLayerPositions.clear();
this.canvas.selectedLayers.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.selectedLayer) {
const originalPos = this.originalLayerPositions.get(this.canvas.selectedLayer);
if (originalPos) {
const tempLayerForSnap = {
...this.canvas.selectedLayer,
x: originalPos.x + totalDx,
y: originalPos.y + totalDy
};
const snapAdjustment = getSnapAdjustment(tempLayerForSnap);
finalDx += snapAdjustment.dx;
finalDy += snapAdjustment.dy;
}
}
this.canvas.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;
const snappedMouseX = snapToGrid(mouseX);
if (Math.abs(mouseX - snappedMouseX) < snapThreshold) mouseX = snappedMouseX;
const snappedMouseY = snapToGrid(mouseY);
if (Math.abs(mouseY - snappedMouseY) < snapThreshold) mouseY = snappedMouseY;
}
const o = this.interaction.transformOrigin;
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);
const vecX = mouseX - anchor.x;
const vecY = mouseY - anchor.y;
let newWidth = vecX * cos + vecY * sin;
let newHeight = vecY * cos - vecX * sin;
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;
}
}
let signX = handle.includes('e') ? 1 : (handle.includes('w') ? -1 : 0);
let signY = handle.includes('s') ? 1 : (handle.includes('n') ? -1 : 0);
newWidth *= signX;
newHeight *= signY;
if (signX === 0) newWidth = o.width;
if (signY === 0) newHeight = o.height;
if (newWidth < 10) newWidth = 10;
if (newHeight < 10) newHeight = 10;
layer.width = newWidth;
layer.height = newHeight;
const deltaW = newWidth - o.width;
const deltaH = newHeight - 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;
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) {
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 rectX = this.interaction.canvasResizeRect.x;
const rectY = this.interaction.canvasResizeRect.y;
this.canvas.updateOutputAreaSize(newWidth, newHeight);
this.canvas.layers.forEach(layer => {
layer.x -= rectX;
layer.y -= rectY;
});
this.canvas.maskTool.updatePosition(-rectX, -rectY);
this.canvas.viewport.x -= rectX;
this.canvas.viewport.y -= rectY;
}
}
}