mirror of
https://github.com/Azornes/Comfyui-LayerForge.git
synced 2026-03-21 20:52:12 -03:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf55d13f67 | ||
|
|
de83a884c2 | ||
|
|
dd2a81b6f2 | ||
|
|
176b9d03ac | ||
|
|
e4f44c10e8 |
@@ -61,6 +61,15 @@ export class Canvas {
|
|||||||
});
|
});
|
||||||
this.offscreenCanvas = offscreenCanvas;
|
this.offscreenCanvas = offscreenCanvas;
|
||||||
this.offscreenCtx = offscreenCtx;
|
this.offscreenCtx = offscreenCtx;
|
||||||
|
// Create overlay canvas for brush cursor and other lightweight overlays
|
||||||
|
const { canvas: overlayCanvas, ctx: overlayCtx } = createCanvas(0, 0, '2d', {
|
||||||
|
alpha: true,
|
||||||
|
willReadFrequently: false
|
||||||
|
});
|
||||||
|
if (!overlayCtx)
|
||||||
|
throw new Error("Could not create overlay canvas context");
|
||||||
|
this.overlayCanvas = overlayCanvas;
|
||||||
|
this.overlayCtx = overlayCtx;
|
||||||
this.canvasContainer = null;
|
this.canvasContainer = null;
|
||||||
this.dataInitialized = false;
|
this.dataInitialized = false;
|
||||||
this.pendingDataCheck = null;
|
this.pendingDataCheck = null;
|
||||||
|
|||||||
@@ -3,16 +3,33 @@ import { snapToGrid, getSnapAdjustment } from "./utils/CommonUtils.js";
|
|||||||
const log = createModuleLogger('CanvasInteractions');
|
const log = createModuleLogger('CanvasInteractions');
|
||||||
export class CanvasInteractions {
|
export class CanvasInteractions {
|
||||||
constructor(canvas) {
|
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.canvas = canvas;
|
||||||
this.interaction = {
|
this.interaction = {
|
||||||
mode: 'none',
|
mode: 'none',
|
||||||
panStart: { x: 0, y: 0 },
|
panStart: { x: 0, y: 0 },
|
||||||
dragStart: { x: 0, y: 0 },
|
dragStart: { x: 0, y: 0 },
|
||||||
transformOrigin: {},
|
transformOrigin: null,
|
||||||
resizeHandle: null,
|
resizeHandle: null,
|
||||||
resizeAnchor: { x: 0, y: 0 },
|
resizeAnchor: { x: 0, y: 0 },
|
||||||
canvasResizeStart: { x: 0, y: 0 },
|
canvasResizeStart: { x: 0, y: 0 },
|
||||||
isCtrlPressed: false,
|
isCtrlPressed: false,
|
||||||
|
isMetaPressed: false,
|
||||||
isAltPressed: false,
|
isAltPressed: false,
|
||||||
isShiftPressed: false,
|
isShiftPressed: false,
|
||||||
isSPressed: false,
|
isSPressed: false,
|
||||||
@@ -32,18 +49,29 @@ export class CanvasInteractions {
|
|||||||
view: this.canvas.getMouseViewCoordinates(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) {
|
preventEventDefaults(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}
|
}
|
||||||
performZoomOperation(worldCoords, zoomFactor) {
|
performZoomOperation(worldCoords, zoomFactor) {
|
||||||
const rect = this.canvas.canvas.getBoundingClientRect();
|
|
||||||
const mouseBufferX = (worldCoords.x - this.canvas.viewport.x) * this.canvas.viewport.zoom;
|
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 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));
|
const newZoom = Math.max(0.1, Math.min(10, this.canvas.viewport.zoom * zoomFactor));
|
||||||
this.canvas.viewport.zoom = newZoom;
|
this.canvas.viewport.zoom = newZoom;
|
||||||
this.canvas.viewport.x = worldCoords.x - (mouseBufferX / this.canvas.viewport.zoom);
|
this.canvas.viewport.x = worldCoords.x - (mouseBufferX / this.canvas.viewport.zoom);
|
||||||
this.canvas.viewport.y = worldCoords.y - (mouseBufferY / 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?.();
|
this.canvas.onViewportChange?.();
|
||||||
}
|
}
|
||||||
renderAndSave(shouldSave = false) {
|
renderAndSave(shouldSave = false) {
|
||||||
@@ -64,29 +92,39 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setupEventListeners() {
|
setupEventListeners() {
|
||||||
this.canvas.canvas.addEventListener('mousedown', this.handleMouseDown.bind(this));
|
this.canvas.canvas.addEventListener('mousedown', this.onMouseDown);
|
||||||
this.canvas.canvas.addEventListener('mousemove', this.handleMouseMove.bind(this));
|
this.canvas.canvas.addEventListener('mousemove', this.onMouseMove);
|
||||||
this.canvas.canvas.addEventListener('mouseup', this.handleMouseUp.bind(this));
|
this.canvas.canvas.addEventListener('mouseup', this.onMouseUp);
|
||||||
this.canvas.canvas.addEventListener('mouseleave', this.handleMouseLeave.bind(this));
|
this.canvas.canvas.addEventListener('wheel', this.onWheel, { passive: false });
|
||||||
this.canvas.canvas.addEventListener('wheel', this.handleWheel.bind(this), { passive: false });
|
this.canvas.canvas.addEventListener('keydown', this.onKeyDown);
|
||||||
this.canvas.canvas.addEventListener('keydown', this.handleKeyDown.bind(this));
|
this.canvas.canvas.addEventListener('keyup', this.onKeyUp);
|
||||||
this.canvas.canvas.addEventListener('keyup', this.handleKeyUp.bind(this));
|
|
||||||
// Add a blur event listener to the window to reset key states
|
// Add a blur event listener to the window to reset key states
|
||||||
window.addEventListener('blur', this.handleBlur.bind(this));
|
window.addEventListener('blur', this.onBlur);
|
||||||
document.addEventListener('paste', this.handlePasteEvent.bind(this));
|
document.addEventListener('paste', this.onPaste);
|
||||||
this.canvas.canvas.addEventListener('mouseenter', (e) => {
|
this.canvas.canvas.addEventListener('mouseenter', this.onMouseEnter);
|
||||||
this.canvas.isMouseOver = true;
|
this.canvas.canvas.addEventListener('mouseleave', this.onMouseLeave);
|
||||||
this.handleMouseEnter(e);
|
this.canvas.canvas.addEventListener('dragover', this.onDragOver);
|
||||||
});
|
this.canvas.canvas.addEventListener('dragenter', this.onDragEnter);
|
||||||
this.canvas.canvas.addEventListener('mouseleave', (e) => {
|
this.canvas.canvas.addEventListener('dragleave', this.onDragLeave);
|
||||||
this.canvas.isMouseOver = false;
|
this.canvas.canvas.addEventListener('drop', this.onDrop);
|
||||||
this.handleMouseLeave(e);
|
this.canvas.canvas.addEventListener('contextmenu', this.onContextMenu);
|
||||||
});
|
}
|
||||||
this.canvas.canvas.addEventListener('dragover', this.handleDragOver.bind(this));
|
teardownEventListeners() {
|
||||||
this.canvas.canvas.addEventListener('dragenter', this.handleDragEnter.bind(this));
|
this.canvas.canvas.removeEventListener('mousedown', this.onMouseDown);
|
||||||
this.canvas.canvas.addEventListener('dragleave', this.handleDragLeave.bind(this));
|
this.canvas.canvas.removeEventListener('mousemove', this.onMouseMove);
|
||||||
this.canvas.canvas.addEventListener('drop', this.handleDrop.bind(this));
|
this.canvas.canvas.removeEventListener('mouseup', this.onMouseUp);
|
||||||
this.canvas.canvas.addEventListener('contextmenu', this.handleContextMenu.bind(this));
|
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
|
* Sprawdza czy punkt znajduje się w obszarze któregokolwiek z zaznaczonych layerów
|
||||||
@@ -124,9 +162,10 @@ export class CanvasInteractions {
|
|||||||
handleMouseDown(e) {
|
handleMouseDown(e) {
|
||||||
this.canvas.canvas.focus();
|
this.canvas.canvas.focus();
|
||||||
const coords = this.getMouseCoordinates(e);
|
const coords = this.getMouseCoordinates(e);
|
||||||
|
const mods = this.getModifierState(e);
|
||||||
if (this.interaction.mode === 'drawingMask') {
|
if (this.interaction.mode === 'drawingMask') {
|
||||||
this.canvas.maskTool.handleMouseDown(coords.world, coords.view);
|
this.canvas.maskTool.handleMouseDown(coords.world, coords.view);
|
||||||
this.canvas.render();
|
// Don't render here - mask tool will handle its own drawing
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.canvas.shapeTool.isActive) {
|
if (this.canvas.shapeTool.isActive) {
|
||||||
@@ -135,11 +174,11 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
// --- Ostateczna, poprawna kolejność sprawdzania ---
|
// --- Ostateczna, poprawna kolejność sprawdzania ---
|
||||||
// 1. Akcje globalne z modyfikatorami (mają najwyższy priorytet)
|
// 1. Akcje globalne z modyfikatorami (mają najwyższy priorytet)
|
||||||
if (e.shiftKey && e.ctrlKey) {
|
if (mods.shift && mods.ctrl) {
|
||||||
this.startCanvasMove(coords.world);
|
this.startCanvasMove(coords.world);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.shiftKey) {
|
if (mods.shift) {
|
||||||
// Clear custom shape when starting canvas resize
|
// Clear custom shape when starting canvas resize
|
||||||
if (this.canvas.outputAreaShape) {
|
if (this.canvas.outputAreaShape) {
|
||||||
// If auto-apply shape mask is enabled, remove the mask before clearing the shape
|
// If auto-apply shape mask is enabled, remove the mask before clearing the shape
|
||||||
@@ -163,7 +202,7 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.button !== 0) { // Środkowy przycisk
|
if (e.button === 1) { // Środkowy przycisk
|
||||||
this.startPanning(e);
|
this.startPanning(e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -179,7 +218,7 @@ export class CanvasInteractions {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 4. Domyślna akcja na tle (lewy przycisk bez modyfikatorów)
|
// 4. Domyślna akcja na tle (lewy przycisk bez modyfikatorów)
|
||||||
this.startPanningOrClearSelection(e);
|
this.startPanning(e, true); // clearSelection = true
|
||||||
}
|
}
|
||||||
handleMouseMove(e) {
|
handleMouseMove(e) {
|
||||||
const coords = this.getMouseCoordinates(e);
|
const coords = this.getMouseCoordinates(e);
|
||||||
@@ -199,7 +238,7 @@ export class CanvasInteractions {
|
|||||||
switch (this.interaction.mode) {
|
switch (this.interaction.mode) {
|
||||||
case 'drawingMask':
|
case 'drawingMask':
|
||||||
this.canvas.maskTool.handleMouseMove(coords.world, coords.view);
|
this.canvas.maskTool.handleMouseMove(coords.world, coords.view);
|
||||||
this.canvas.render();
|
// Don't render during mask drawing - it's handled by mask tool internally
|
||||||
break;
|
break;
|
||||||
case 'panning':
|
case 'panning':
|
||||||
this.panViewport(e);
|
this.panViewport(e);
|
||||||
@@ -221,6 +260,10 @@ export class CanvasInteractions {
|
|||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
this.updateCursor(coords.world);
|
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;
|
break;
|
||||||
}
|
}
|
||||||
// --- DYNAMICZNY PODGLĄD LINII CUSTOM SHAPE ---
|
// --- DYNAMICZNY PODGLĄD LINII CUSTOM SHAPE ---
|
||||||
@@ -232,6 +275,7 @@ export class CanvasInteractions {
|
|||||||
const coords = this.getMouseCoordinates(e);
|
const coords = this.getMouseCoordinates(e);
|
||||||
if (this.interaction.mode === 'drawingMask') {
|
if (this.interaction.mode === 'drawingMask') {
|
||||||
this.canvas.maskTool.handleMouseUp(coords.view);
|
this.canvas.maskTool.handleMouseUp(coords.view);
|
||||||
|
// Render only once after drawing is complete
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -315,8 +359,17 @@ export class CanvasInteractions {
|
|||||||
this.performZoomOperation(coords.world, zoomFactor);
|
this.performZoomOperation(coords.world, zoomFactor);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// Layer transformation when layers are selected
|
// Check if mouse is over any selected layer
|
||||||
this.handleLayerWheelTransformation(e);
|
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();
|
this.canvas.render();
|
||||||
if (!this.canvas.maskTool.isActive) {
|
if (!this.canvas.maskTool.isActive) {
|
||||||
@@ -324,14 +377,15 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
handleLayerWheelTransformation(e) {
|
handleLayerWheelTransformation(e) {
|
||||||
|
const mods = this.getModifierState(e);
|
||||||
const rotationStep = 5 * (e.deltaY > 0 ? -1 : 1);
|
const rotationStep = 5 * (e.deltaY > 0 ? -1 : 1);
|
||||||
const direction = e.deltaY < 0 ? 1 : -1;
|
const direction = e.deltaY < 0 ? 1 : -1;
|
||||||
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
|
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
|
||||||
if (e.shiftKey) {
|
if (mods.shift) {
|
||||||
this.handleLayerRotation(layer, e.ctrlKey, direction, rotationStep);
|
this.handleLayerRotation(layer, mods.ctrl, direction, rotationStep);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
this.handleLayerScaling(layer, e.ctrlKey, e.deltaY);
|
this.handleLayerScaling(layer, mods.ctrl, e.deltaY);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -376,7 +430,7 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
calculateGridBasedScaling(oldHeight, deltaY) {
|
calculateGridBasedScaling(oldHeight, deltaY) {
|
||||||
const gridSize = 64;
|
const gridSize = 64; // Grid size - could be made configurable in the future
|
||||||
const direction = deltaY > 0 ? -1 : 1;
|
const direction = deltaY > 0 ? -1 : 1;
|
||||||
let targetHeight;
|
let targetHeight;
|
||||||
if (direction > 0) {
|
if (direction > 0) {
|
||||||
@@ -401,6 +455,8 @@ export class CanvasInteractions {
|
|||||||
handleKeyDown(e) {
|
handleKeyDown(e) {
|
||||||
if (e.key === 'Control')
|
if (e.key === 'Control')
|
||||||
this.interaction.isCtrlPressed = true;
|
this.interaction.isCtrlPressed = true;
|
||||||
|
if (e.key === 'Meta')
|
||||||
|
this.interaction.isMetaPressed = true;
|
||||||
if (e.key === 'Shift')
|
if (e.key === 'Shift')
|
||||||
this.interaction.isShiftPressed = true;
|
this.interaction.isShiftPressed = true;
|
||||||
if (e.key === 'Alt') {
|
if (e.key === 'Alt') {
|
||||||
@@ -418,11 +474,12 @@ export class CanvasInteractions {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Globalne skróty (Undo/Redo/Copy/Paste)
|
// Globalne skróty (Undo/Redo/Copy/Paste)
|
||||||
if (e.ctrlKey || e.metaKey) {
|
const mods = this.getModifierState(e);
|
||||||
|
if (mods.ctrl || mods.meta) {
|
||||||
let handled = true;
|
let handled = true;
|
||||||
switch (e.key.toLowerCase()) {
|
switch (e.key.toLowerCase()) {
|
||||||
case 'z':
|
case 'z':
|
||||||
if (e.shiftKey) {
|
if (mods.shift) {
|
||||||
this.canvas.redo();
|
this.canvas.redo();
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
@@ -449,7 +506,7 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
// Skróty kontekstowe (zależne od zaznaczenia)
|
// Skróty kontekstowe (zależne od zaznaczenia)
|
||||||
if (this.canvas.canvasSelection.selectedLayers.length > 0) {
|
if (this.canvas.canvasSelection.selectedLayers.length > 0) {
|
||||||
const step = e.shiftKey ? 10 : 1;
|
const step = mods.shift ? 10 : 1;
|
||||||
let needsRender = false;
|
let needsRender = false;
|
||||||
// Używamy e.code dla spójności i niezależności od układu klawiatury
|
// Używamy e.code dla spójności i niezależności od układu klawiatury
|
||||||
const movementKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'BracketLeft', 'BracketRight'];
|
const movementKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'BracketLeft', 'BracketRight'];
|
||||||
@@ -485,6 +542,8 @@ export class CanvasInteractions {
|
|||||||
handleKeyUp(e) {
|
handleKeyUp(e) {
|
||||||
if (e.key === 'Control')
|
if (e.key === 'Control')
|
||||||
this.interaction.isCtrlPressed = false;
|
this.interaction.isCtrlPressed = false;
|
||||||
|
if (e.key === 'Meta')
|
||||||
|
this.interaction.isMetaPressed = false;
|
||||||
if (e.key === 'Shift')
|
if (e.key === 'Shift')
|
||||||
this.interaction.isShiftPressed = false;
|
this.interaction.isShiftPressed = false;
|
||||||
if (e.key === 'Alt')
|
if (e.key === 'Alt')
|
||||||
@@ -504,6 +563,7 @@ export class CanvasInteractions {
|
|||||||
handleBlur() {
|
handleBlur() {
|
||||||
log.debug('Window lost focus, resetting key states.');
|
log.debug('Window lost focus, resetting key states.');
|
||||||
this.interaction.isCtrlPressed = false;
|
this.interaction.isCtrlPressed = false;
|
||||||
|
this.interaction.isMetaPressed = false;
|
||||||
this.interaction.isAltPressed = false;
|
this.interaction.isAltPressed = false;
|
||||||
this.interaction.isShiftPressed = false;
|
this.interaction.isShiftPressed = false;
|
||||||
this.interaction.isSPressed = false;
|
this.interaction.isSPressed = false;
|
||||||
@@ -525,6 +585,11 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
updateCursor(worldCoords) {
|
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);
|
const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y);
|
||||||
if (transformTarget) {
|
if (transformTarget) {
|
||||||
const handleName = transformTarget.handle;
|
const handleName = transformTarget.handle;
|
||||||
@@ -572,7 +637,9 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
prepareForDrag(layer, worldCoords) {
|
prepareForDrag(layer, worldCoords) {
|
||||||
// Zaktualizuj zaznaczenie, ale nie zapisuj stanu
|
// Zaktualizuj zaznaczenie, ale nie zapisuj stanu
|
||||||
if (this.interaction.isCtrlPressed) {
|
// 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);
|
const index = this.canvas.canvasSelection.selectedLayers.indexOf(layer);
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
this.canvas.canvasSelection.updateSelection([...this.canvas.canvasSelection.selectedLayers, layer]);
|
this.canvas.canvasSelection.updateSelection([...this.canvas.canvasSelection.selectedLayers, layer]);
|
||||||
@@ -590,10 +657,9 @@ export class CanvasInteractions {
|
|||||||
this.interaction.mode = 'potential-drag';
|
this.interaction.mode = 'potential-drag';
|
||||||
this.interaction.dragStart = { ...worldCoords };
|
this.interaction.dragStart = { ...worldCoords };
|
||||||
}
|
}
|
||||||
startPanningOrClearSelection(e) {
|
startPanning(e, clearSelection = true) {
|
||||||
// Ta funkcja jest teraz wywoływana tylko gdy kliknięto na tło bez modyfikatorów.
|
// Unified panning method - can optionally clear selection
|
||||||
// Domyślna akcja: wyczyść zaznaczenie i rozpocznij panoramowanie.
|
if (clearSelection && !this.interaction.isCtrlPressed) {
|
||||||
if (!this.interaction.isCtrlPressed) {
|
|
||||||
this.canvas.canvasSelection.updateSelection([]);
|
this.canvas.canvasSelection.updateSelection([]);
|
||||||
}
|
}
|
||||||
this.interaction.mode = 'panning';
|
this.interaction.mode = 'panning';
|
||||||
@@ -642,19 +708,16 @@ export class CanvasInteractions {
|
|||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
this.canvas.saveState();
|
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) {
|
panViewport(e) {
|
||||||
const dx = e.clientX - this.interaction.panStart.x;
|
const dx = e.clientX - this.interaction.panStart.x;
|
||||||
const dy = e.clientY - this.interaction.panStart.y;
|
const dy = e.clientY - this.interaction.panStart.y;
|
||||||
this.canvas.viewport.x -= dx / this.canvas.viewport.zoom;
|
this.canvas.viewport.x -= dx / this.canvas.viewport.zoom;
|
||||||
this.canvas.viewport.y -= dy / this.canvas.viewport.zoom;
|
this.canvas.viewport.y -= dy / this.canvas.viewport.zoom;
|
||||||
this.interaction.panStart = { x: e.clientX, y: e.clientY };
|
this.interaction.panStart = { x: e.clientX, y: e.clientY };
|
||||||
|
// Update stroke overlay if mask tool is drawing during pan
|
||||||
|
if (this.canvas.maskTool.isDrawing) {
|
||||||
|
this.canvas.maskTool.handleViewportChange();
|
||||||
|
}
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
this.canvas.onViewportChange?.();
|
this.canvas.onViewportChange?.();
|
||||||
}
|
}
|
||||||
@@ -709,7 +772,7 @@ export class CanvasInteractions {
|
|||||||
mouseY = Math.abs(mouseY - snapToGrid(mouseY)) < snapThreshold ? snapToGrid(mouseY) : mouseY;
|
mouseY = Math.abs(mouseY - snapToGrid(mouseY)) < snapThreshold ? snapToGrid(mouseY) : mouseY;
|
||||||
}
|
}
|
||||||
const o = this.interaction.transformOrigin;
|
const o = this.interaction.transformOrigin;
|
||||||
if (o.rotation === undefined || o.width === undefined || o.height === undefined || o.centerX === undefined || o.centerY === undefined)
|
if (!o)
|
||||||
return;
|
return;
|
||||||
const handle = this.interaction.resizeHandle;
|
const handle = this.interaction.resizeHandle;
|
||||||
const anchor = this.interaction.resizeAnchor;
|
const anchor = this.interaction.resizeAnchor;
|
||||||
@@ -856,7 +919,7 @@ export class CanvasInteractions {
|
|||||||
if (!layer)
|
if (!layer)
|
||||||
return;
|
return;
|
||||||
const o = this.interaction.transformOrigin;
|
const o = this.interaction.transformOrigin;
|
||||||
if (o.rotation === undefined || o.centerX === undefined || o.centerY === undefined)
|
if (!o)
|
||||||
return;
|
return;
|
||||||
const startAngle = Math.atan2(this.interaction.dragStart.y - o.centerY, this.interaction.dragStart.x - o.centerX);
|
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);
|
const currentAngle = Math.atan2(worldCoords.y - o.centerY, worldCoords.x - o.centerX);
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ export class CanvasRenderer {
|
|||||||
this.lastRenderTime = 0;
|
this.lastRenderTime = 0;
|
||||||
this.renderInterval = 1000 / 60;
|
this.renderInterval = 1000 / 60;
|
||||||
this.isDirty = false;
|
this.isDirty = false;
|
||||||
|
// Initialize overlay canvases
|
||||||
|
this.initOverlay();
|
||||||
|
this.initStrokeOverlay();
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Helper function to draw text with background at world coordinates
|
* Helper function to draw text with background at world coordinates
|
||||||
@@ -102,10 +105,12 @@ export class CanvasRenderer {
|
|||||||
if (maskImage && this.canvas.maskTool.isOverlayVisible) {
|
if (maskImage && this.canvas.maskTool.isOverlayVisible) {
|
||||||
ctx.save();
|
ctx.save();
|
||||||
if (this.canvas.maskTool.isActive) {
|
if (this.canvas.maskTool.isActive) {
|
||||||
|
// In draw mask mode, use the previewOpacity value from the slider
|
||||||
ctx.globalCompositeOperation = 'source-over';
|
ctx.globalCompositeOperation = 'source-over';
|
||||||
ctx.globalAlpha = 0.5;
|
ctx.globalAlpha = this.canvas.maskTool.previewOpacity;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
|
// When not in draw mask mode, show mask at full opacity
|
||||||
ctx.globalCompositeOperation = 'source-over';
|
ctx.globalCompositeOperation = 'source-over';
|
||||||
ctx.globalAlpha = 1.0;
|
ctx.globalAlpha = 1.0;
|
||||||
}
|
}
|
||||||
@@ -158,6 +163,11 @@ export class CanvasRenderer {
|
|||||||
this.canvas.canvas.height = this.canvas.offscreenCanvas.height;
|
this.canvas.canvas.height = this.canvas.offscreenCanvas.height;
|
||||||
}
|
}
|
||||||
this.canvas.ctx.drawImage(this.canvas.offscreenCanvas, 0, 0);
|
this.canvas.ctx.drawImage(this.canvas.offscreenCanvas, 0, 0);
|
||||||
|
// Ensure overlay canvases are in DOM and properly sized
|
||||||
|
this.addOverlayToDOM();
|
||||||
|
this.updateOverlaySize();
|
||||||
|
this.addStrokeOverlayToDOM();
|
||||||
|
this.updateStrokeOverlaySize();
|
||||||
// Update Batch Preview UI positions
|
// Update Batch Preview UI positions
|
||||||
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
|
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
|
||||||
this.canvas.batchPreviewManagers.forEach((manager) => {
|
this.canvas.batchPreviewManagers.forEach((manager) => {
|
||||||
@@ -583,4 +593,243 @@ export class CanvasRenderer {
|
|||||||
padding: 8
|
padding: 8
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Initialize overlay canvas for lightweight overlays like brush cursor
|
||||||
|
*/
|
||||||
|
initOverlay() {
|
||||||
|
// Setup overlay canvas to match main canvas
|
||||||
|
this.updateOverlaySize();
|
||||||
|
// Position overlay canvas on top of main canvas
|
||||||
|
this.canvas.overlayCanvas.style.position = 'absolute';
|
||||||
|
this.canvas.overlayCanvas.style.left = '0px';
|
||||||
|
this.canvas.overlayCanvas.style.top = '0px';
|
||||||
|
this.canvas.overlayCanvas.style.pointerEvents = 'none';
|
||||||
|
this.canvas.overlayCanvas.style.zIndex = '20'; // Above other overlays
|
||||||
|
// Add overlay to DOM when main canvas is added
|
||||||
|
this.addOverlayToDOM();
|
||||||
|
log.debug('Overlay canvas initialized');
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Add overlay canvas to DOM if main canvas has a parent
|
||||||
|
*/
|
||||||
|
addOverlayToDOM() {
|
||||||
|
if (this.canvas.canvas.parentElement && !this.canvas.overlayCanvas.parentElement) {
|
||||||
|
this.canvas.canvas.parentElement.appendChild(this.canvas.overlayCanvas);
|
||||||
|
log.debug('Overlay canvas added to DOM');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Update overlay canvas size to match main canvas
|
||||||
|
*/
|
||||||
|
updateOverlaySize() {
|
||||||
|
if (this.canvas.overlayCanvas.width !== this.canvas.canvas.clientWidth ||
|
||||||
|
this.canvas.overlayCanvas.height !== this.canvas.canvas.clientHeight) {
|
||||||
|
this.canvas.overlayCanvas.width = Math.max(1, this.canvas.canvas.clientWidth);
|
||||||
|
this.canvas.overlayCanvas.height = Math.max(1, this.canvas.canvas.clientHeight);
|
||||||
|
log.debug(`Overlay canvas resized to ${this.canvas.overlayCanvas.width}x${this.canvas.overlayCanvas.height}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Clear overlay canvas
|
||||||
|
*/
|
||||||
|
clearOverlay() {
|
||||||
|
this.canvas.overlayCtx.clearRect(0, 0, this.canvas.overlayCanvas.width, this.canvas.overlayCanvas.height);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Initialize a dedicated overlay for real-time mask stroke preview
|
||||||
|
*/
|
||||||
|
initStrokeOverlay() {
|
||||||
|
// Create canvas if not created yet
|
||||||
|
if (!this.strokeOverlayCanvas) {
|
||||||
|
this.strokeOverlayCanvas = document.createElement('canvas');
|
||||||
|
const ctx = this.strokeOverlayCanvas.getContext('2d');
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('Failed to get 2D context for stroke overlay canvas');
|
||||||
|
}
|
||||||
|
this.strokeOverlayCtx = ctx;
|
||||||
|
}
|
||||||
|
// Size match main canvas
|
||||||
|
this.updateStrokeOverlaySize();
|
||||||
|
// Position above main canvas but below cursor overlay
|
||||||
|
this.strokeOverlayCanvas.style.position = 'absolute';
|
||||||
|
this.strokeOverlayCanvas.style.left = '1px';
|
||||||
|
this.strokeOverlayCanvas.style.top = '1px';
|
||||||
|
this.strokeOverlayCanvas.style.pointerEvents = 'none';
|
||||||
|
this.strokeOverlayCanvas.style.zIndex = '19'; // Below cursor overlay (20)
|
||||||
|
// Opacity is now controlled by MaskTool.previewOpacity
|
||||||
|
this.strokeOverlayCanvas.style.opacity = String(this.canvas.maskTool.previewOpacity || 0.5);
|
||||||
|
// Add to DOM
|
||||||
|
this.addStrokeOverlayToDOM();
|
||||||
|
log.debug('Stroke overlay canvas initialized');
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Add stroke overlay canvas to DOM if needed
|
||||||
|
*/
|
||||||
|
addStrokeOverlayToDOM() {
|
||||||
|
if (this.canvas.canvas.parentElement && !this.strokeOverlayCanvas.parentElement) {
|
||||||
|
this.canvas.canvas.parentElement.appendChild(this.strokeOverlayCanvas);
|
||||||
|
log.debug('Stroke overlay canvas added to DOM');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Ensure stroke overlay size matches main canvas
|
||||||
|
*/
|
||||||
|
updateStrokeOverlaySize() {
|
||||||
|
const w = Math.max(1, this.canvas.canvas.clientWidth);
|
||||||
|
const h = Math.max(1, this.canvas.canvas.clientHeight);
|
||||||
|
if (this.strokeOverlayCanvas.width !== w || this.strokeOverlayCanvas.height !== h) {
|
||||||
|
this.strokeOverlayCanvas.width = w;
|
||||||
|
this.strokeOverlayCanvas.height = h;
|
||||||
|
log.debug(`Stroke overlay resized to ${w}x${h}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Clear the stroke overlay
|
||||||
|
*/
|
||||||
|
clearMaskStrokeOverlay() {
|
||||||
|
if (!this.strokeOverlayCtx)
|
||||||
|
return;
|
||||||
|
this.strokeOverlayCtx.clearRect(0, 0, this.strokeOverlayCanvas.width, this.strokeOverlayCanvas.height);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Draw a preview stroke segment onto the stroke overlay in screen space
|
||||||
|
* Uses line drawing with gradient to match MaskTool's drawLineOnChunk exactly
|
||||||
|
*/
|
||||||
|
drawMaskStrokeSegment(startWorld, endWorld) {
|
||||||
|
// Ensure overlay is present and sized
|
||||||
|
this.updateStrokeOverlaySize();
|
||||||
|
const zoom = this.canvas.viewport.zoom;
|
||||||
|
const toScreen = (p) => ({
|
||||||
|
x: (p.x - this.canvas.viewport.x) * zoom,
|
||||||
|
y: (p.y - this.canvas.viewport.y) * zoom
|
||||||
|
});
|
||||||
|
const startScreen = toScreen(startWorld);
|
||||||
|
const endScreen = toScreen(endWorld);
|
||||||
|
const brushRadius = (this.canvas.maskTool.brushSize / 2) * zoom;
|
||||||
|
const hardness = this.canvas.maskTool.brushHardness;
|
||||||
|
const strength = this.canvas.maskTool.brushStrength;
|
||||||
|
// If strength is 0, don't draw anything
|
||||||
|
if (strength <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.strokeOverlayCtx.save();
|
||||||
|
// Draw line segment exactly as MaskTool does
|
||||||
|
this.strokeOverlayCtx.beginPath();
|
||||||
|
this.strokeOverlayCtx.moveTo(startScreen.x, startScreen.y);
|
||||||
|
this.strokeOverlayCtx.lineTo(endScreen.x, endScreen.y);
|
||||||
|
// Match the gradient setup from MaskTool's drawLineOnChunk
|
||||||
|
if (hardness === 1) {
|
||||||
|
this.strokeOverlayCtx.strokeStyle = `rgba(255, 255, 255, ${strength})`;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const innerRadius = brushRadius * hardness;
|
||||||
|
const gradient = this.strokeOverlayCtx.createRadialGradient(endScreen.x, endScreen.y, innerRadius, endScreen.x, endScreen.y, brushRadius);
|
||||||
|
gradient.addColorStop(0, `rgba(255, 255, 255, ${strength})`);
|
||||||
|
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`);
|
||||||
|
this.strokeOverlayCtx.strokeStyle = gradient;
|
||||||
|
}
|
||||||
|
// Match line properties from MaskTool
|
||||||
|
this.strokeOverlayCtx.lineWidth = this.canvas.maskTool.brushSize * zoom;
|
||||||
|
this.strokeOverlayCtx.lineCap = 'round';
|
||||||
|
this.strokeOverlayCtx.lineJoin = 'round';
|
||||||
|
this.strokeOverlayCtx.globalCompositeOperation = 'source-over';
|
||||||
|
this.strokeOverlayCtx.stroke();
|
||||||
|
this.strokeOverlayCtx.restore();
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Redraws the entire stroke overlay from world coordinates
|
||||||
|
* Used when viewport changes during drawing to maintain visual consistency
|
||||||
|
*/
|
||||||
|
redrawMaskStrokeOverlay(strokePoints) {
|
||||||
|
if (strokePoints.length < 2)
|
||||||
|
return;
|
||||||
|
// Clear the overlay first
|
||||||
|
this.clearMaskStrokeOverlay();
|
||||||
|
// Redraw all segments with current viewport
|
||||||
|
for (let i = 1; i < strokePoints.length; i++) {
|
||||||
|
this.drawMaskStrokeSegment(strokePoints[i - 1], strokePoints[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Draw mask brush cursor on overlay canvas with visual feedback for size, strength and hardness
|
||||||
|
* @param worldPoint World coordinates of cursor
|
||||||
|
*/
|
||||||
|
drawMaskBrushCursor(worldPoint) {
|
||||||
|
if (!this.canvas.maskTool.isActive || !this.canvas.isMouseOver) {
|
||||||
|
this.clearOverlay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Update overlay size if needed
|
||||||
|
this.updateOverlaySize();
|
||||||
|
// Clear previous cursor
|
||||||
|
this.clearOverlay();
|
||||||
|
// Convert world coordinates to screen coordinates
|
||||||
|
const screenX = (worldPoint.x - this.canvas.viewport.x) * this.canvas.viewport.zoom;
|
||||||
|
const screenY = (worldPoint.y - this.canvas.viewport.y) * this.canvas.viewport.zoom;
|
||||||
|
// Get brush properties
|
||||||
|
const brushRadius = (this.canvas.maskTool.brushSize / 2) * this.canvas.viewport.zoom;
|
||||||
|
const brushStrength = this.canvas.maskTool.brushStrength;
|
||||||
|
const brushHardness = this.canvas.maskTool.brushHardness;
|
||||||
|
// Save context state
|
||||||
|
this.canvas.overlayCtx.save();
|
||||||
|
// If strength is 0, just draw outline
|
||||||
|
if (brushStrength > 0) {
|
||||||
|
// Draw inner fill to visualize brush effect - matches actual brush rendering
|
||||||
|
const gradient = this.canvas.overlayCtx.createRadialGradient(screenX, screenY, 0, screenX, screenY, brushRadius);
|
||||||
|
// Preview alpha - subtle to not obscure content
|
||||||
|
const previewAlpha = brushStrength * 0.15; // Very subtle preview (max 15% opacity)
|
||||||
|
if (brushHardness === 1) {
|
||||||
|
// Hard brush - uniform fill within radius
|
||||||
|
gradient.addColorStop(0, `rgba(255, 255, 255, ${previewAlpha})`);
|
||||||
|
gradient.addColorStop(1, `rgba(255, 255, 255, ${previewAlpha})`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Soft brush - gradient fade matching actual brush
|
||||||
|
gradient.addColorStop(0, `rgba(255, 255, 255, ${previewAlpha})`);
|
||||||
|
if (brushHardness > 0) {
|
||||||
|
gradient.addColorStop(brushHardness, `rgba(255, 255, 255, ${previewAlpha})`);
|
||||||
|
}
|
||||||
|
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`);
|
||||||
|
}
|
||||||
|
this.canvas.overlayCtx.beginPath();
|
||||||
|
this.canvas.overlayCtx.arc(screenX, screenY, brushRadius, 0, 2 * Math.PI);
|
||||||
|
this.canvas.overlayCtx.fillStyle = gradient;
|
||||||
|
this.canvas.overlayCtx.fill();
|
||||||
|
}
|
||||||
|
// Draw outer circle (SIZE indicator)
|
||||||
|
this.canvas.overlayCtx.beginPath();
|
||||||
|
this.canvas.overlayCtx.arc(screenX, screenY, brushRadius, 0, 2 * Math.PI);
|
||||||
|
// Stroke opacity based on strength (dimmer when strength is 0)
|
||||||
|
const strokeOpacity = brushStrength > 0 ? (0.4 + brushStrength * 0.4) : 0.3;
|
||||||
|
this.canvas.overlayCtx.strokeStyle = `rgba(255, 255, 255, ${strokeOpacity})`;
|
||||||
|
this.canvas.overlayCtx.lineWidth = 1.5;
|
||||||
|
// Visual feedback for hardness
|
||||||
|
if (brushHardness > 0.8) {
|
||||||
|
// Hard brush - solid line
|
||||||
|
this.canvas.overlayCtx.setLineDash([]);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Soft brush - dashed line
|
||||||
|
const dashLength = 2 + (1 - brushHardness) * 4;
|
||||||
|
this.canvas.overlayCtx.setLineDash([dashLength, dashLength]);
|
||||||
|
}
|
||||||
|
this.canvas.overlayCtx.stroke();
|
||||||
|
// Center dot for small brushes
|
||||||
|
if (brushRadius < 5) {
|
||||||
|
this.canvas.overlayCtx.beginPath();
|
||||||
|
this.canvas.overlayCtx.arc(screenX, screenY, 1, 0, 2 * Math.PI);
|
||||||
|
this.canvas.overlayCtx.fillStyle = `rgba(255, 255, 255, ${strokeOpacity})`;
|
||||||
|
this.canvas.overlayCtx.fill();
|
||||||
|
}
|
||||||
|
// Restore context state
|
||||||
|
this.canvas.overlayCtx.restore();
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Update overlay position when viewport changes
|
||||||
|
*/
|
||||||
|
updateOverlayPosition() {
|
||||||
|
// Overlay canvas is positioned absolutely, so it doesn't need repositioning
|
||||||
|
// Just ensure it's the right size
|
||||||
|
this.updateOverlaySize();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -554,6 +554,25 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
setTimeout(() => canvas.render(), 0);
|
setTimeout(() => canvas.render(), 0);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
$el("div.painter-slider-container.mask-control", { style: { display: 'none' } }, [
|
||||||
|
$el("label", { for: "preview-opacity-slider", textContent: "Mask Opacity:" }),
|
||||||
|
$el("input", {
|
||||||
|
id: "preview-opacity-slider",
|
||||||
|
type: "range",
|
||||||
|
min: "0",
|
||||||
|
max: "1",
|
||||||
|
step: "0.05",
|
||||||
|
value: "0.5",
|
||||||
|
oninput: (e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
canvas.maskTool.setPreviewOpacity(parseFloat(value));
|
||||||
|
const valueEl = document.getElementById('preview-opacity-value');
|
||||||
|
if (valueEl)
|
||||||
|
valueEl.textContent = `${Math.round(parseFloat(value) * 100)}%`;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
$el("div.slider-value", { id: "preview-opacity-value" }, ["50%"])
|
||||||
|
]),
|
||||||
$el("div.painter-slider-container.mask-control", { style: { display: 'none' } }, [
|
$el("div.painter-slider-container.mask-control", { style: { display: 'none' } }, [
|
||||||
$el("label", { for: "brush-size-slider", textContent: "Size:" }),
|
$el("label", { for: "brush-size-slider", textContent: "Size:" }),
|
||||||
$el("input", {
|
$el("input", {
|
||||||
|
|||||||
184
js/MaskTool.js
184
js/MaskTool.js
@@ -3,11 +3,15 @@ import { createCanvas } from "./utils/CommonUtils.js";
|
|||||||
const log = createModuleLogger('Mask_tool');
|
const log = createModuleLogger('Mask_tool');
|
||||||
export class MaskTool {
|
export class MaskTool {
|
||||||
constructor(canvasInstance, callbacks = {}) {
|
constructor(canvasInstance, callbacks = {}) {
|
||||||
|
// Track strokes during drawing for efficient overlay updates
|
||||||
|
this.currentStrokePoints = [];
|
||||||
this.ACTIVE_MASK_UPDATE_DELAY = 16; // ~60fps throttling
|
this.ACTIVE_MASK_UPDATE_DELAY = 16; // ~60fps throttling
|
||||||
this.SHAPE_PREVIEW_THROTTLE_DELAY = 16; // ~60fps throttling for preview
|
this.SHAPE_PREVIEW_THROTTLE_DELAY = 16; // ~60fps throttling for preview
|
||||||
this.canvasInstance = canvasInstance;
|
this.canvasInstance = canvasInstance;
|
||||||
this.mainCanvas = canvasInstance.canvas;
|
this.mainCanvas = canvasInstance.canvas;
|
||||||
this.onStateChange = callbacks.onStateChange || null;
|
this.onStateChange = callbacks.onStateChange || null;
|
||||||
|
// Initialize stroke tracking for overlay drawing
|
||||||
|
this.currentStrokePoints = [];
|
||||||
// Initialize chunked mask system
|
// Initialize chunked mask system
|
||||||
this.maskChunks = new Map();
|
this.maskChunks = new Map();
|
||||||
this.chunkSize = 512;
|
this.chunkSize = 512;
|
||||||
@@ -28,8 +32,9 @@ export class MaskTool {
|
|||||||
this.isOverlayVisible = true;
|
this.isOverlayVisible = true;
|
||||||
this.isActive = false;
|
this.isActive = false;
|
||||||
this.brushSize = 20;
|
this.brushSize = 20;
|
||||||
this.brushStrength = 0.5;
|
this._brushStrength = 0.5;
|
||||||
this.brushHardness = 0.5;
|
this._brushHardness = 0.5;
|
||||||
|
this._previewOpacity = 0.5; // Default 50% opacity for preview
|
||||||
this.isDrawing = false;
|
this.isDrawing = false;
|
||||||
this.lastPosition = null;
|
this.lastPosition = null;
|
||||||
const { canvas: previewCanvas, ctx: previewCtx } = createCanvas(1, 1, '2d', { willReadFrequently: true });
|
const { canvas: previewCanvas, ctx: previewCtx } = createCanvas(1, 1, '2d', { willReadFrequently: true });
|
||||||
@@ -79,8 +84,27 @@ export class MaskTool {
|
|||||||
this.canvasInstance.canvas.parentElement.appendChild(this.previewCanvas);
|
this.canvasInstance.canvas.parentElement.appendChild(this.previewCanvas);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Getters for brush properties
|
||||||
|
get brushStrength() {
|
||||||
|
return this._brushStrength;
|
||||||
|
}
|
||||||
|
get brushHardness() {
|
||||||
|
return this._brushHardness;
|
||||||
|
}
|
||||||
|
get previewOpacity() {
|
||||||
|
return this._previewOpacity;
|
||||||
|
}
|
||||||
setBrushHardness(hardness) {
|
setBrushHardness(hardness) {
|
||||||
this.brushHardness = Math.max(0, Math.min(1, hardness));
|
this._brushHardness = Math.max(0, Math.min(1, hardness));
|
||||||
|
}
|
||||||
|
setPreviewOpacity(opacity) {
|
||||||
|
this._previewOpacity = Math.max(0, Math.min(1, opacity));
|
||||||
|
// Update the stroke overlay canvas opacity when preview opacity changes
|
||||||
|
if (this.canvasInstance.canvasRenderer && this.canvasInstance.canvasRenderer.strokeOverlayCanvas) {
|
||||||
|
this.canvasInstance.canvasRenderer.strokeOverlayCanvas.style.opacity = String(this._previewOpacity);
|
||||||
|
}
|
||||||
|
// Trigger canvas render to update mask display opacity
|
||||||
|
this.canvasInstance.render();
|
||||||
}
|
}
|
||||||
initMaskCanvas() {
|
initMaskCanvas() {
|
||||||
// Initialize chunked system
|
// Initialize chunked system
|
||||||
@@ -671,16 +695,17 @@ export class MaskTool {
|
|||||||
this.brushSize = Math.max(1, size);
|
this.brushSize = Math.max(1, size);
|
||||||
}
|
}
|
||||||
setBrushStrength(strength) {
|
setBrushStrength(strength) {
|
||||||
this.brushStrength = Math.max(0, Math.min(1, strength));
|
this._brushStrength = Math.max(0, Math.min(1, strength));
|
||||||
}
|
}
|
||||||
handleMouseDown(worldCoords, viewCoords) {
|
handleMouseDown(worldCoords, viewCoords) {
|
||||||
if (!this.isActive)
|
if (!this.isActive)
|
||||||
return;
|
return;
|
||||||
this.isDrawing = true;
|
this.isDrawing = true;
|
||||||
this.lastPosition = worldCoords;
|
this.lastPosition = worldCoords;
|
||||||
// Activate chunks around the drawing position for performance
|
// Initialize stroke tracking for live preview
|
||||||
this.updateActiveChunksForDrawing(worldCoords);
|
this.currentStrokePoints = [worldCoords];
|
||||||
this.draw(worldCoords);
|
// Clear any previous stroke overlay
|
||||||
|
this.canvasInstance.canvasRenderer.clearMaskStrokeOverlay();
|
||||||
this.clearPreview();
|
this.clearPreview();
|
||||||
}
|
}
|
||||||
handleMouseMove(worldCoords, viewCoords) {
|
handleMouseMove(worldCoords, viewCoords) {
|
||||||
@@ -689,14 +714,69 @@ export class MaskTool {
|
|||||||
}
|
}
|
||||||
if (!this.isActive || !this.isDrawing)
|
if (!this.isActive || !this.isDrawing)
|
||||||
return;
|
return;
|
||||||
// Dynamically update active chunks as user moves while drawing
|
// Add point to stroke tracking
|
||||||
this.updateActiveChunksForDrawing(worldCoords);
|
this.currentStrokePoints.push(worldCoords);
|
||||||
this.draw(worldCoords);
|
// Draw interpolated segments for smooth strokes without gaps
|
||||||
|
if (this.lastPosition) {
|
||||||
|
// Calculate distance between last and current position
|
||||||
|
const dx = worldCoords.x - this.lastPosition.x;
|
||||||
|
const dy = worldCoords.y - this.lastPosition.y;
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
// If distance is small, just draw a single segment
|
||||||
|
if (distance < this.brushSize / 4) {
|
||||||
|
this.canvasInstance.canvasRenderer.drawMaskStrokeSegment(this.lastPosition, worldCoords);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Interpolate points for smooth drawing without gaps
|
||||||
|
const interpolatedPoints = this.interpolatePoints(this.lastPosition, worldCoords, distance);
|
||||||
|
// Draw all interpolated segments
|
||||||
|
for (let i = 0; i < interpolatedPoints.length - 1; i++) {
|
||||||
|
this.canvasInstance.canvasRenderer.drawMaskStrokeSegment(interpolatedPoints[i], interpolatedPoints[i + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
this.lastPosition = worldCoords;
|
this.lastPosition = worldCoords;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Interpolates points between two positions to create smooth strokes without gaps
|
||||||
|
* Based on the BrushTool's approach for eliminating dotted lines during fast drawing
|
||||||
|
*/
|
||||||
|
interpolatePoints(start, end, distance) {
|
||||||
|
const points = [];
|
||||||
|
// Calculate number of interpolated points based on brush size
|
||||||
|
// More points = smoother line
|
||||||
|
const stepSize = Math.max(1, this.brushSize / 6); // Adjust divisor for smoothness
|
||||||
|
const numSteps = Math.ceil(distance / stepSize);
|
||||||
|
// Always include start point
|
||||||
|
points.push(start);
|
||||||
|
// Interpolate intermediate points
|
||||||
|
for (let i = 1; i < numSteps; i++) {
|
||||||
|
const t = i / numSteps;
|
||||||
|
points.push({
|
||||||
|
x: start.x + (end.x - start.x) * t,
|
||||||
|
y: start.y + (end.y - start.y) * t
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Always include end point
|
||||||
|
points.push(end);
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Called when viewport changes during drawing to update stroke overlay
|
||||||
|
* This ensures the stroke preview scales correctly with zoom changes
|
||||||
|
*/
|
||||||
|
handleViewportChange() {
|
||||||
|
if (this.isDrawing && this.currentStrokePoints.length > 1) {
|
||||||
|
// Redraw the entire stroke overlay with new viewport settings
|
||||||
|
this.canvasInstance.canvasRenderer.redrawMaskStrokeOverlay(this.currentStrokePoints);
|
||||||
|
}
|
||||||
|
}
|
||||||
handleMouseLeave() {
|
handleMouseLeave() {
|
||||||
this.previewVisible = false;
|
this.previewVisible = false;
|
||||||
this.clearPreview();
|
this.clearPreview();
|
||||||
|
// Clear overlay canvases when mouse leaves
|
||||||
|
this.canvasInstance.canvasRenderer.clearOverlay();
|
||||||
|
this.canvasInstance.canvasRenderer.clearMaskStrokeOverlay();
|
||||||
}
|
}
|
||||||
handleMouseEnter() {
|
handleMouseEnter() {
|
||||||
this.previewVisible = true;
|
this.previewVisible = true;
|
||||||
@@ -706,10 +786,15 @@ export class MaskTool {
|
|||||||
return;
|
return;
|
||||||
if (this.isDrawing) {
|
if (this.isDrawing) {
|
||||||
this.isDrawing = false;
|
this.isDrawing = false;
|
||||||
|
// Commit the stroke from overlay to actual mask chunks
|
||||||
|
this.commitStrokeToChunks();
|
||||||
|
// Clear stroke overlay and reset state
|
||||||
|
this.canvasInstance.canvasRenderer.clearMaskStrokeOverlay();
|
||||||
|
this.currentStrokePoints = [];
|
||||||
this.lastPosition = null;
|
this.lastPosition = null;
|
||||||
this.currentDrawingChunk = null;
|
this.currentDrawingChunk = null;
|
||||||
// After drawing is complete, update active canvas to show all chunks
|
// After drawing is complete, update active canvas to show all chunks
|
||||||
this.updateActiveMaskCanvas(true); // forceShowAll = true
|
this.updateActiveMaskCanvas(true); // Force full update
|
||||||
this.completeMaskOperation();
|
this.completeMaskOperation();
|
||||||
this.drawBrushPreview(viewCoords);
|
this.drawBrushPreview(viewCoords);
|
||||||
}
|
}
|
||||||
@@ -724,6 +809,38 @@ export class MaskTool {
|
|||||||
// This prevents unnecessary recomposition during drawing
|
// This prevents unnecessary recomposition during drawing
|
||||||
this.updateActiveCanvasIfNeeded(this.lastPosition, worldCoords);
|
this.updateActiveCanvasIfNeeded(this.lastPosition, worldCoords);
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Commits the current stroke from overlay to actual mask chunks
|
||||||
|
* This replays the entire stroke path with interpolation to ensure pixel-perfect accuracy
|
||||||
|
*/
|
||||||
|
commitStrokeToChunks() {
|
||||||
|
if (this.currentStrokePoints.length < 2) {
|
||||||
|
return; // Need at least 2 points for a stroke
|
||||||
|
}
|
||||||
|
log.debug(`Committing stroke with ${this.currentStrokePoints.length} points to chunks`);
|
||||||
|
// Replay the entire stroke path with interpolation for smooth, accurate lines
|
||||||
|
for (let i = 1; i < this.currentStrokePoints.length; i++) {
|
||||||
|
const startPoint = this.currentStrokePoints[i - 1];
|
||||||
|
const endPoint = this.currentStrokePoints[i];
|
||||||
|
// Calculate distance between points
|
||||||
|
const dx = endPoint.x - startPoint.x;
|
||||||
|
const dy = endPoint.y - startPoint.y;
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
if (distance < this.brushSize / 4) {
|
||||||
|
// Small distance - draw single segment
|
||||||
|
this.drawOnChunks(startPoint, endPoint);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Large distance - interpolate for smooth line without gaps
|
||||||
|
const interpolatedPoints = this.interpolatePoints(startPoint, endPoint, distance);
|
||||||
|
// Draw all interpolated segments
|
||||||
|
for (let j = 0; j < interpolatedPoints.length - 1; j++) {
|
||||||
|
this.drawOnChunks(interpolatedPoints[j], interpolatedPoints[j + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.debug("Stroke committed to chunks successfully with interpolation");
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Draws a line between two world coordinates on the appropriate chunks
|
* Draws a line between two world coordinates on the appropriate chunks
|
||||||
*/
|
*/
|
||||||
@@ -767,13 +884,13 @@ export class MaskTool {
|
|||||||
chunk.ctx.moveTo(startLocal.x, startLocal.y);
|
chunk.ctx.moveTo(startLocal.x, startLocal.y);
|
||||||
chunk.ctx.lineTo(endLocal.x, endLocal.y);
|
chunk.ctx.lineTo(endLocal.x, endLocal.y);
|
||||||
const gradientRadius = this.brushSize / 2;
|
const gradientRadius = this.brushSize / 2;
|
||||||
if (this.brushHardness === 1) {
|
if (this._brushHardness === 1) {
|
||||||
chunk.ctx.strokeStyle = `rgba(255, 255, 255, ${this.brushStrength})`;
|
chunk.ctx.strokeStyle = `rgba(255, 255, 255, ${this._brushStrength})`;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
const innerRadius = gradientRadius * this.brushHardness;
|
const innerRadius = gradientRadius * this._brushHardness;
|
||||||
const gradient = chunk.ctx.createRadialGradient(endLocal.x, endLocal.y, innerRadius, endLocal.x, endLocal.y, gradientRadius);
|
const gradient = chunk.ctx.createRadialGradient(endLocal.x, endLocal.y, innerRadius, endLocal.x, endLocal.y, gradientRadius);
|
||||||
gradient.addColorStop(0, `rgba(255, 255, 255, ${this.brushStrength})`);
|
gradient.addColorStop(0, `rgba(255, 255, 255, ${this._brushStrength})`);
|
||||||
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`);
|
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`);
|
||||||
chunk.ctx.strokeStyle = gradient;
|
chunk.ctx.strokeStyle = gradient;
|
||||||
}
|
}
|
||||||
@@ -805,28 +922,17 @@ export class MaskTool {
|
|||||||
return true; // For now, always draw - more precise intersection can be added later
|
return true; // For now, always draw - more precise intersection can be added later
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Updates active canvas when drawing affects chunks with throttling to prevent lag
|
* Updates active canvas when drawing affects chunks
|
||||||
* During drawing, only updates the affected active chunks for performance
|
* Since we now use overlay during drawing, this is only called after drawing is complete
|
||||||
*/
|
*/
|
||||||
updateActiveCanvasIfNeeded(startWorld, endWorld) {
|
updateActiveCanvasIfNeeded(startWorld, endWorld) {
|
||||||
// Calculate which chunks were affected by this drawing operation
|
// This method is now simplified - we only update after drawing is complete
|
||||||
const minX = Math.min(startWorld.x, endWorld.x) - this.brushSize;
|
// The overlay handles all live preview, so we don't need complex chunk activation
|
||||||
const maxX = Math.max(startWorld.x, endWorld.x) + this.brushSize;
|
if (!this.isDrawing) {
|
||||||
const minY = Math.min(startWorld.y, endWorld.y) - this.brushSize;
|
|
||||||
const maxY = Math.max(startWorld.y, endWorld.y) + this.brushSize;
|
|
||||||
const affectedChunkMinX = Math.floor(minX / this.chunkSize);
|
|
||||||
const affectedChunkMinY = Math.floor(minY / this.chunkSize);
|
|
||||||
const affectedChunkMaxX = Math.floor(maxX / this.chunkSize);
|
|
||||||
const affectedChunkMaxY = Math.floor(maxY / this.chunkSize);
|
|
||||||
// During drawing, only update affected chunks that are active for performance
|
|
||||||
if (this.isDrawing) {
|
|
||||||
// Use throttled partial update for active chunks only
|
|
||||||
this.scheduleThrottledActiveMaskUpdate(affectedChunkMinX, affectedChunkMinY, affectedChunkMaxX, affectedChunkMaxY);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
// Not drawing - do full update to show all chunks
|
// Not drawing - do full update to show all chunks
|
||||||
this.updateActiveMaskCanvas(true);
|
this.updateActiveMaskCanvas(true);
|
||||||
}
|
}
|
||||||
|
// During drawing, we don't update chunks at all - overlay handles preview
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Schedules a throttled update of the active mask canvas to prevent excessive redraws
|
* Schedules a throttled update of the active mask canvas to prevent excessive redraws
|
||||||
@@ -903,18 +1009,12 @@ export class MaskTool {
|
|||||||
}
|
}
|
||||||
drawBrushPreview(viewCoords) {
|
drawBrushPreview(viewCoords) {
|
||||||
if (!this.previewVisible || this.isDrawing) {
|
if (!this.previewVisible || this.isDrawing) {
|
||||||
this.clearPreview();
|
this.canvasInstance.canvasRenderer.clearOverlay();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.clearPreview();
|
// Use overlay canvas instead of preview canvas for brush cursor
|
||||||
const zoom = this.canvasInstance.viewport.zoom;
|
const worldCoords = this.canvasInstance.lastMousePosition;
|
||||||
const radius = (this.brushSize / 2) * zoom;
|
this.canvasInstance.canvasRenderer.drawMaskBrushCursor(worldCoords);
|
||||||
this.previewCtx.beginPath();
|
|
||||||
this.previewCtx.arc(viewCoords.x, viewCoords.y, radius, 0, 2 * Math.PI);
|
|
||||||
this.previewCtx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
|
|
||||||
this.previewCtx.lineWidth = 1;
|
|
||||||
this.previewCtx.setLineDash([2, 4]);
|
|
||||||
this.previewCtx.stroke();
|
|
||||||
}
|
}
|
||||||
clearPreview() {
|
clearPreview() {
|
||||||
this.previewCtx.clearRect(0, 0, this.previewCanvas.width, this.previewCanvas.height);
|
this.previewCtx.clearRect(0, 0, this.previewCanvas.width, this.previewCanvas.height);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "layerforge"
|
name = "layerforge"
|
||||||
description = "Photoshop-like layered canvas editor to your ComfyUI workflow. This node is perfect for complex compositing, inpainting, and outpainting, featuring multi-layer support, masking, blend modes, and precise transformations. Includes optional AI-powered background removal for streamlined image editing."
|
description = "Photoshop-like layered canvas editor to your ComfyUI workflow. This node is perfect for complex compositing, inpainting, and outpainting, featuring multi-layer support, masking, blend modes, and precise transformations. Includes optional AI-powered background removal for streamlined image editing."
|
||||||
version = "1.5.4"
|
version = "1.5.5"
|
||||||
license = { text = "MIT License" }
|
license = { text = "MIT License" }
|
||||||
dependencies = ["torch", "torchvision", "transformers", "aiohttp", "numpy", "tqdm", "Pillow"]
|
dependencies = ["torch", "torchvision", "transformers", "aiohttp", "numpy", "tqdm", "Pillow"]
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ export class Canvas {
|
|||||||
node: ComfyNode;
|
node: ComfyNode;
|
||||||
offscreenCanvas: HTMLCanvasElement;
|
offscreenCanvas: HTMLCanvasElement;
|
||||||
offscreenCtx: CanvasRenderingContext2D | null;
|
offscreenCtx: CanvasRenderingContext2D | null;
|
||||||
|
overlayCanvas: HTMLCanvasElement;
|
||||||
|
overlayCtx: CanvasRenderingContext2D;
|
||||||
onHistoryChange: ((historyInfo: { canUndo: boolean; canRedo: boolean; }) => void) | undefined;
|
onHistoryChange: ((historyInfo: { canUndo: boolean; canRedo: boolean; }) => void) | undefined;
|
||||||
onViewportChange: (() => void) | null;
|
onViewportChange: (() => void) | null;
|
||||||
onStateChange: (() => void) | undefined;
|
onStateChange: (() => void) | undefined;
|
||||||
@@ -122,6 +124,16 @@ export class Canvas {
|
|||||||
});
|
});
|
||||||
this.offscreenCanvas = offscreenCanvas;
|
this.offscreenCanvas = offscreenCanvas;
|
||||||
this.offscreenCtx = offscreenCtx;
|
this.offscreenCtx = offscreenCtx;
|
||||||
|
|
||||||
|
// Create overlay canvas for brush cursor and other lightweight overlays
|
||||||
|
const { canvas: overlayCanvas, ctx: overlayCtx } = createCanvas(0, 0, '2d', {
|
||||||
|
alpha: true,
|
||||||
|
willReadFrequently: false
|
||||||
|
});
|
||||||
|
if (!overlayCtx) throw new Error("Could not create overlay canvas context");
|
||||||
|
this.overlayCanvas = overlayCanvas;
|
||||||
|
this.overlayCtx = overlayCtx;
|
||||||
|
|
||||||
this.canvasContainer = null;
|
this.canvasContainer = null;
|
||||||
|
|
||||||
this.dataInitialized = false;
|
this.dataInitialized = false;
|
||||||
|
|||||||
@@ -10,15 +10,36 @@ interface MouseCoordinates {
|
|||||||
view: Point;
|
view: Point;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ModifierState {
|
||||||
|
ctrl: boolean;
|
||||||
|
shift: boolean;
|
||||||
|
alt: boolean;
|
||||||
|
meta: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TransformOrigin {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
rotation: number;
|
||||||
|
centerX: number;
|
||||||
|
centerY: number;
|
||||||
|
originalWidth?: number;
|
||||||
|
originalHeight?: number;
|
||||||
|
cropBounds?: { x: number; y: number; width: number; height: number };
|
||||||
|
}
|
||||||
|
|
||||||
interface InteractionState {
|
interface InteractionState {
|
||||||
mode: 'none' | 'panning' | 'dragging' | 'resizing' | 'rotating' | 'drawingMask' | 'resizingCanvas' | 'movingCanvas' | 'potential-drag' | 'drawingShape';
|
mode: 'none' | 'panning' | 'dragging' | 'resizing' | 'rotating' | 'drawingMask' | 'resizingCanvas' | 'movingCanvas' | 'potential-drag' | 'drawingShape';
|
||||||
panStart: Point;
|
panStart: Point;
|
||||||
dragStart: Point;
|
dragStart: Point;
|
||||||
transformOrigin: Partial<Layer> & { centerX?: number, centerY?: number };
|
transformOrigin: TransformOrigin | null;
|
||||||
resizeHandle: string | null;
|
resizeHandle: string | null;
|
||||||
resizeAnchor: Point;
|
resizeAnchor: Point;
|
||||||
canvasResizeStart: Point;
|
canvasResizeStart: Point;
|
||||||
isCtrlPressed: boolean;
|
isCtrlPressed: boolean;
|
||||||
|
isMetaPressed: boolean;
|
||||||
isAltPressed: boolean;
|
isAltPressed: boolean;
|
||||||
isShiftPressed: boolean;
|
isShiftPressed: boolean;
|
||||||
isSPressed: boolean;
|
isSPressed: boolean;
|
||||||
@@ -35,17 +56,35 @@ export class CanvasInteractions {
|
|||||||
public interaction: InteractionState;
|
public interaction: InteractionState;
|
||||||
private originalLayerPositions: Map<Layer, Point>;
|
private originalLayerPositions: Map<Layer, Point>;
|
||||||
|
|
||||||
|
// Bound event handlers to enable proper removeEventListener and avoid leaks
|
||||||
|
private onMouseDown = (e: MouseEvent) => this.handleMouseDown(e);
|
||||||
|
private onMouseMove = (e: MouseEvent) => this.handleMouseMove(e);
|
||||||
|
private onMouseUp = (e: MouseEvent) => this.handleMouseUp(e);
|
||||||
|
private onMouseEnter = (e: MouseEvent) => { this.canvas.isMouseOver = true; this.handleMouseEnter(e); };
|
||||||
|
private onMouseLeave = (e: MouseEvent) => { this.canvas.isMouseOver = false; this.handleMouseLeave(e); };
|
||||||
|
private onWheel = (e: WheelEvent) => this.handleWheel(e);
|
||||||
|
private onKeyDown = (e: KeyboardEvent) => this.handleKeyDown(e);
|
||||||
|
private onKeyUp = (e: KeyboardEvent) => this.handleKeyUp(e);
|
||||||
|
private onDragOver = (e: DragEvent) => this.handleDragOver(e);
|
||||||
|
private onDragEnter = (e: DragEvent) => this.handleDragEnter(e);
|
||||||
|
private onDragLeave = (e: DragEvent) => this.handleDragLeave(e);
|
||||||
|
private onDrop = (e: DragEvent) => { this.handleDrop(e); };
|
||||||
|
private onContextMenu = (e: MouseEvent) => this.handleContextMenu(e);
|
||||||
|
private onBlur = () => this.handleBlur();
|
||||||
|
private onPaste = (e: ClipboardEvent) => this.handlePasteEvent(e);
|
||||||
|
|
||||||
constructor(canvas: Canvas) {
|
constructor(canvas: Canvas) {
|
||||||
this.canvas = canvas;
|
this.canvas = canvas;
|
||||||
this.interaction = {
|
this.interaction = {
|
||||||
mode: 'none',
|
mode: 'none',
|
||||||
panStart: { x: 0, y: 0 },
|
panStart: { x: 0, y: 0 },
|
||||||
dragStart: { x: 0, y: 0 },
|
dragStart: { x: 0, y: 0 },
|
||||||
transformOrigin: {},
|
transformOrigin: null,
|
||||||
resizeHandle: null,
|
resizeHandle: null,
|
||||||
resizeAnchor: { x: 0, y: 0 },
|
resizeAnchor: { x: 0, y: 0 },
|
||||||
canvasResizeStart: { x: 0, y: 0 },
|
canvasResizeStart: { x: 0, y: 0 },
|
||||||
isCtrlPressed: false,
|
isCtrlPressed: false,
|
||||||
|
isMetaPressed: false,
|
||||||
isAltPressed: false,
|
isAltPressed: false,
|
||||||
isShiftPressed: false,
|
isShiftPressed: false,
|
||||||
isSPressed: false,
|
isSPressed: false,
|
||||||
@@ -68,13 +107,21 @@ export class CanvasInteractions {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getModifierState(e?: MouseEvent | WheelEvent | KeyboardEvent): ModifierState {
|
||||||
|
return {
|
||||||
|
ctrl: this.interaction.isCtrlPressed || (e as any)?.ctrlKey || false,
|
||||||
|
shift: this.interaction.isShiftPressed || (e as any)?.shiftKey || false,
|
||||||
|
alt: this.interaction.isAltPressed || (e as any)?.altKey || false,
|
||||||
|
meta: this.interaction.isMetaPressed || (e as any)?.metaKey || false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private preventEventDefaults(e: Event): void {
|
private preventEventDefaults(e: Event): void {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}
|
}
|
||||||
|
|
||||||
private performZoomOperation(worldCoords: Point, zoomFactor: number): void {
|
private performZoomOperation(worldCoords: Point, zoomFactor: number): void {
|
||||||
const rect = this.canvas.canvas.getBoundingClientRect();
|
|
||||||
const mouseBufferX = (worldCoords.x - this.canvas.viewport.x) * this.canvas.viewport.zoom;
|
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 mouseBufferY = (worldCoords.y - this.canvas.viewport.y) * this.canvas.viewport.zoom;
|
||||||
|
|
||||||
@@ -84,6 +131,11 @@ export class CanvasInteractions {
|
|||||||
this.canvas.viewport.x = worldCoords.x - (mouseBufferX / this.canvas.viewport.zoom);
|
this.canvas.viewport.x = worldCoords.x - (mouseBufferX / this.canvas.viewport.zoom);
|
||||||
this.canvas.viewport.y = worldCoords.y - (mouseBufferY / 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?.();
|
this.canvas.onViewportChange?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,34 +158,49 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setupEventListeners(): void {
|
setupEventListeners(): void {
|
||||||
this.canvas.canvas.addEventListener('mousedown', this.handleMouseDown.bind(this) as EventListener);
|
this.canvas.canvas.addEventListener('mousedown', this.onMouseDown as EventListener);
|
||||||
this.canvas.canvas.addEventListener('mousemove', this.handleMouseMove.bind(this) as EventListener);
|
this.canvas.canvas.addEventListener('mousemove', this.onMouseMove as EventListener);
|
||||||
this.canvas.canvas.addEventListener('mouseup', this.handleMouseUp.bind(this) as EventListener);
|
this.canvas.canvas.addEventListener('mouseup', this.onMouseUp as EventListener);
|
||||||
this.canvas.canvas.addEventListener('mouseleave', this.handleMouseLeave.bind(this) as EventListener);
|
this.canvas.canvas.addEventListener('wheel', this.onWheel as EventListener, { passive: false });
|
||||||
this.canvas.canvas.addEventListener('wheel', this.handleWheel.bind(this) as EventListener, { passive: false });
|
this.canvas.canvas.addEventListener('keydown', this.onKeyDown as EventListener);
|
||||||
this.canvas.canvas.addEventListener('keydown', this.handleKeyDown.bind(this) as EventListener);
|
this.canvas.canvas.addEventListener('keyup', this.onKeyUp as EventListener);
|
||||||
this.canvas.canvas.addEventListener('keyup', this.handleKeyUp.bind(this) as EventListener);
|
|
||||||
|
|
||||||
// Add a blur event listener to the window to reset key states
|
// Add a blur event listener to the window to reset key states
|
||||||
window.addEventListener('blur', this.handleBlur.bind(this));
|
window.addEventListener('blur', this.onBlur);
|
||||||
|
|
||||||
document.addEventListener('paste', this.handlePasteEvent.bind(this));
|
document.addEventListener('paste', this.onPaste as unknown as EventListener);
|
||||||
|
|
||||||
this.canvas.canvas.addEventListener('mouseenter', (e: MouseEvent) => {
|
this.canvas.canvas.addEventListener('mouseenter', this.onMouseEnter as EventListener);
|
||||||
this.canvas.isMouseOver = true;
|
this.canvas.canvas.addEventListener('mouseleave', this.onMouseLeave as EventListener);
|
||||||
this.handleMouseEnter(e);
|
|
||||||
});
|
|
||||||
this.canvas.canvas.addEventListener('mouseleave', (e: MouseEvent) => {
|
|
||||||
this.canvas.isMouseOver = false;
|
|
||||||
this.handleMouseLeave(e);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.canvas.canvas.addEventListener('dragover', this.handleDragOver.bind(this) as EventListener);
|
this.canvas.canvas.addEventListener('dragover', this.onDragOver as EventListener);
|
||||||
this.canvas.canvas.addEventListener('dragenter', this.handleDragEnter.bind(this) as EventListener);
|
this.canvas.canvas.addEventListener('dragenter', this.onDragEnter as EventListener);
|
||||||
this.canvas.canvas.addEventListener('dragleave', this.handleDragLeave.bind(this) as EventListener);
|
this.canvas.canvas.addEventListener('dragleave', this.onDragLeave as EventListener);
|
||||||
this.canvas.canvas.addEventListener('drop', this.handleDrop.bind(this) as unknown as EventListener);
|
this.canvas.canvas.addEventListener('drop', this.onDrop as unknown as EventListener);
|
||||||
|
|
||||||
this.canvas.canvas.addEventListener('contextmenu', this.handleContextMenu.bind(this) as EventListener);
|
this.canvas.canvas.addEventListener('contextmenu', this.onContextMenu as EventListener);
|
||||||
|
}
|
||||||
|
|
||||||
|
teardownEventListeners(): void {
|
||||||
|
this.canvas.canvas.removeEventListener('mousedown', this.onMouseDown as EventListener);
|
||||||
|
this.canvas.canvas.removeEventListener('mousemove', this.onMouseMove as EventListener);
|
||||||
|
this.canvas.canvas.removeEventListener('mouseup', this.onMouseUp as EventListener);
|
||||||
|
this.canvas.canvas.removeEventListener('wheel', this.onWheel as EventListener);
|
||||||
|
this.canvas.canvas.removeEventListener('keydown', this.onKeyDown as EventListener);
|
||||||
|
this.canvas.canvas.removeEventListener('keyup', this.onKeyUp as EventListener);
|
||||||
|
|
||||||
|
window.removeEventListener('blur', this.onBlur);
|
||||||
|
document.removeEventListener('paste', this.onPaste as unknown as EventListener);
|
||||||
|
|
||||||
|
this.canvas.canvas.removeEventListener('mouseenter', this.onMouseEnter as EventListener);
|
||||||
|
this.canvas.canvas.removeEventListener('mouseleave', this.onMouseLeave as EventListener);
|
||||||
|
|
||||||
|
this.canvas.canvas.removeEventListener('dragover', this.onDragOver as EventListener);
|
||||||
|
this.canvas.canvas.removeEventListener('dragenter', this.onDragEnter as EventListener);
|
||||||
|
this.canvas.canvas.removeEventListener('dragleave', this.onDragLeave as EventListener);
|
||||||
|
this.canvas.canvas.removeEventListener('drop', this.onDrop as unknown as EventListener);
|
||||||
|
|
||||||
|
this.canvas.canvas.removeEventListener('contextmenu', this.onContextMenu as EventListener);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -177,10 +244,11 @@ export class CanvasInteractions {
|
|||||||
handleMouseDown(e: MouseEvent): void {
|
handleMouseDown(e: MouseEvent): void {
|
||||||
this.canvas.canvas.focus();
|
this.canvas.canvas.focus();
|
||||||
const coords = this.getMouseCoordinates(e);
|
const coords = this.getMouseCoordinates(e);
|
||||||
|
const mods = this.getModifierState(e);
|
||||||
|
|
||||||
if (this.interaction.mode === 'drawingMask') {
|
if (this.interaction.mode === 'drawingMask') {
|
||||||
this.canvas.maskTool.handleMouseDown(coords.world, coords.view);
|
this.canvas.maskTool.handleMouseDown(coords.world, coords.view);
|
||||||
this.canvas.render();
|
// Don't render here - mask tool will handle its own drawing
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,11 +260,11 @@ export class CanvasInteractions {
|
|||||||
// --- Ostateczna, poprawna kolejność sprawdzania ---
|
// --- Ostateczna, poprawna kolejność sprawdzania ---
|
||||||
|
|
||||||
// 1. Akcje globalne z modyfikatorami (mają najwyższy priorytet)
|
// 1. Akcje globalne z modyfikatorami (mają najwyższy priorytet)
|
||||||
if (e.shiftKey && e.ctrlKey) {
|
if (mods.shift && mods.ctrl) {
|
||||||
this.startCanvasMove(coords.world);
|
this.startCanvasMove(coords.world);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.shiftKey) {
|
if (mods.shift) {
|
||||||
// Clear custom shape when starting canvas resize
|
// Clear custom shape when starting canvas resize
|
||||||
if (this.canvas.outputAreaShape) {
|
if (this.canvas.outputAreaShape) {
|
||||||
// If auto-apply shape mask is enabled, remove the mask before clearing the shape
|
// If auto-apply shape mask is enabled, remove the mask before clearing the shape
|
||||||
@@ -222,7 +290,7 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.button !== 0) { // Środkowy przycisk
|
if (e.button === 1) { // Środkowy przycisk
|
||||||
this.startPanning(e);
|
this.startPanning(e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -241,7 +309,7 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. Domyślna akcja na tle (lewy przycisk bez modyfikatorów)
|
// 4. Domyślna akcja na tle (lewy przycisk bez modyfikatorów)
|
||||||
this.startPanningOrClearSelection(e);
|
this.startPanning(e, true); // clearSelection = true
|
||||||
}
|
}
|
||||||
|
|
||||||
handleMouseMove(e: MouseEvent): void {
|
handleMouseMove(e: MouseEvent): void {
|
||||||
@@ -264,7 +332,7 @@ export class CanvasInteractions {
|
|||||||
switch (this.interaction.mode) {
|
switch (this.interaction.mode) {
|
||||||
case 'drawingMask':
|
case 'drawingMask':
|
||||||
this.canvas.maskTool.handleMouseMove(coords.world, coords.view);
|
this.canvas.maskTool.handleMouseMove(coords.world, coords.view);
|
||||||
this.canvas.render();
|
// Don't render during mask drawing - it's handled by mask tool internally
|
||||||
break;
|
break;
|
||||||
case 'panning':
|
case 'panning':
|
||||||
this.panViewport(e);
|
this.panViewport(e);
|
||||||
@@ -286,6 +354,10 @@ export class CanvasInteractions {
|
|||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
this.updateCursor(coords.world);
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,6 +372,7 @@ export class CanvasInteractions {
|
|||||||
|
|
||||||
if (this.interaction.mode === 'drawingMask') {
|
if (this.interaction.mode === 'drawingMask') {
|
||||||
this.canvas.maskTool.handleMouseUp(coords.view);
|
this.canvas.maskTool.handleMouseUp(coords.view);
|
||||||
|
// Render only once after drawing is complete
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -397,8 +470,17 @@ export class CanvasInteractions {
|
|||||||
const zoomFactor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
|
const zoomFactor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
|
||||||
this.performZoomOperation(coords.world, zoomFactor);
|
this.performZoomOperation(coords.world, zoomFactor);
|
||||||
} else {
|
} else {
|
||||||
// Layer transformation when layers are selected
|
// Check if mouse is over any selected layer
|
||||||
this.handleLayerWheelTransformation(e);
|
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();
|
this.canvas.render();
|
||||||
@@ -408,14 +490,15 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleLayerWheelTransformation(e: WheelEvent): void {
|
private handleLayerWheelTransformation(e: WheelEvent): void {
|
||||||
|
const mods = this.getModifierState(e);
|
||||||
const rotationStep = 5 * (e.deltaY > 0 ? -1 : 1);
|
const rotationStep = 5 * (e.deltaY > 0 ? -1 : 1);
|
||||||
const direction = e.deltaY < 0 ? 1 : -1;
|
const direction = e.deltaY < 0 ? 1 : -1;
|
||||||
|
|
||||||
this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => {
|
this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => {
|
||||||
if (e.shiftKey) {
|
if (mods.shift) {
|
||||||
this.handleLayerRotation(layer, e.ctrlKey, direction, rotationStep);
|
this.handleLayerRotation(layer, mods.ctrl, direction, rotationStep);
|
||||||
} else {
|
} else {
|
||||||
this.handleLayerScaling(layer, e.ctrlKey, e.deltaY);
|
this.handleLayerScaling(layer, mods.ctrl, e.deltaY);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -462,7 +545,7 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private calculateGridBasedScaling(oldHeight: number, deltaY: number): number {
|
private calculateGridBasedScaling(oldHeight: number, deltaY: number): number {
|
||||||
const gridSize = 64;
|
const gridSize = 64; // Grid size - could be made configurable in the future
|
||||||
const direction = deltaY > 0 ? -1 : 1;
|
const direction = deltaY > 0 ? -1 : 1;
|
||||||
let targetHeight;
|
let targetHeight;
|
||||||
|
|
||||||
@@ -487,6 +570,7 @@ export class CanvasInteractions {
|
|||||||
|
|
||||||
handleKeyDown(e: KeyboardEvent): void {
|
handleKeyDown(e: KeyboardEvent): void {
|
||||||
if (e.key === 'Control') this.interaction.isCtrlPressed = true;
|
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 === 'Shift') this.interaction.isShiftPressed = true;
|
||||||
if (e.key === 'Alt') {
|
if (e.key === 'Alt') {
|
||||||
this.interaction.isAltPressed = true;
|
this.interaction.isAltPressed = true;
|
||||||
@@ -505,11 +589,12 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Globalne skróty (Undo/Redo/Copy/Paste)
|
// Globalne skróty (Undo/Redo/Copy/Paste)
|
||||||
if (e.ctrlKey || e.metaKey) {
|
const mods = this.getModifierState(e);
|
||||||
|
if (mods.ctrl || mods.meta) {
|
||||||
let handled = true;
|
let handled = true;
|
||||||
switch (e.key.toLowerCase()) {
|
switch (e.key.toLowerCase()) {
|
||||||
case 'z':
|
case 'z':
|
||||||
if (e.shiftKey) {
|
if (mods.shift) {
|
||||||
this.canvas.redo();
|
this.canvas.redo();
|
||||||
} else {
|
} else {
|
||||||
this.canvas.undo();
|
this.canvas.undo();
|
||||||
@@ -536,7 +621,7 @@ export class CanvasInteractions {
|
|||||||
|
|
||||||
// Skróty kontekstowe (zależne od zaznaczenia)
|
// Skróty kontekstowe (zależne od zaznaczenia)
|
||||||
if (this.canvas.canvasSelection.selectedLayers.length > 0) {
|
if (this.canvas.canvasSelection.selectedLayers.length > 0) {
|
||||||
const step = e.shiftKey ? 10 : 1;
|
const step = mods.shift ? 10 : 1;
|
||||||
let needsRender = false;
|
let needsRender = false;
|
||||||
|
|
||||||
// Używamy e.code dla spójności i niezależności od układu klawiatury
|
// Używamy e.code dla spójności i niezależności od układu klawiatury
|
||||||
@@ -571,6 +656,7 @@ export class CanvasInteractions {
|
|||||||
|
|
||||||
handleKeyUp(e: KeyboardEvent): void {
|
handleKeyUp(e: KeyboardEvent): void {
|
||||||
if (e.key === 'Control') this.interaction.isCtrlPressed = false;
|
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 === 'Shift') this.interaction.isShiftPressed = false;
|
||||||
if (e.key === 'Alt') this.interaction.isAltPressed = false;
|
if (e.key === 'Alt') this.interaction.isAltPressed = false;
|
||||||
if (e.key.toLowerCase() === 's') this.interaction.isSPressed = false;
|
if (e.key.toLowerCase() === 's') this.interaction.isSPressed = false;
|
||||||
@@ -590,6 +676,7 @@ export class CanvasInteractions {
|
|||||||
handleBlur(): void {
|
handleBlur(): void {
|
||||||
log.debug('Window lost focus, resetting key states.');
|
log.debug('Window lost focus, resetting key states.');
|
||||||
this.interaction.isCtrlPressed = false;
|
this.interaction.isCtrlPressed = false;
|
||||||
|
this.interaction.isMetaPressed = false;
|
||||||
this.interaction.isAltPressed = false;
|
this.interaction.isAltPressed = false;
|
||||||
this.interaction.isShiftPressed = false;
|
this.interaction.isShiftPressed = false;
|
||||||
this.interaction.isSPressed = false;
|
this.interaction.isSPressed = false;
|
||||||
@@ -615,6 +702,12 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateCursor(worldCoords: Point): void {
|
updateCursor(worldCoords: Point): void {
|
||||||
|
// 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);
|
const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y);
|
||||||
|
|
||||||
if (transformTarget) {
|
if (transformTarget) {
|
||||||
@@ -663,7 +756,9 @@ export class CanvasInteractions {
|
|||||||
|
|
||||||
prepareForDrag(layer: Layer, worldCoords: Point): void {
|
prepareForDrag(layer: Layer, worldCoords: Point): void {
|
||||||
// Zaktualizuj zaznaczenie, ale nie zapisuj stanu
|
// Zaktualizuj zaznaczenie, ale nie zapisuj stanu
|
||||||
if (this.interaction.isCtrlPressed) {
|
// 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);
|
const index = this.canvas.canvasSelection.selectedLayers.indexOf(layer);
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
this.canvas.canvasSelection.updateSelection([...this.canvas.canvasSelection.selectedLayers, layer]);
|
this.canvas.canvasSelection.updateSelection([...this.canvas.canvasSelection.selectedLayers, layer]);
|
||||||
@@ -681,14 +776,13 @@ export class CanvasInteractions {
|
|||||||
this.interaction.dragStart = {...worldCoords};
|
this.interaction.dragStart = {...worldCoords};
|
||||||
}
|
}
|
||||||
|
|
||||||
startPanningOrClearSelection(e: MouseEvent): void {
|
startPanning(e: MouseEvent, clearSelection: boolean = true): void {
|
||||||
// Ta funkcja jest teraz wywoływana tylko gdy kliknięto na tło bez modyfikatorów.
|
// Unified panning method - can optionally clear selection
|
||||||
// Domyślna akcja: wyczyść zaznaczenie i rozpocznij panoramowanie.
|
if (clearSelection && !this.interaction.isCtrlPressed) {
|
||||||
if (!this.interaction.isCtrlPressed) {
|
|
||||||
this.canvas.canvasSelection.updateSelection([]);
|
this.canvas.canvasSelection.updateSelection([]);
|
||||||
}
|
}
|
||||||
this.interaction.mode = 'panning';
|
this.interaction.mode = 'panning';
|
||||||
this.interaction.panStart = {x: e.clientX, y: e.clientY};
|
this.interaction.panStart = { x: e.clientX, y: e.clientY };
|
||||||
}
|
}
|
||||||
|
|
||||||
startCanvasResize(worldCoords: Point): void {
|
startCanvasResize(worldCoords: Point): void {
|
||||||
@@ -743,20 +837,18 @@ export class CanvasInteractions {
|
|||||||
this.canvas.saveState();
|
this.canvas.saveState();
|
||||||
}
|
}
|
||||||
|
|
||||||
startPanning(e: MouseEvent): void {
|
|
||||||
if (!this.interaction.isCtrlPressed) {
|
|
||||||
this.canvas.canvasSelection.updateSelection([]);
|
|
||||||
}
|
|
||||||
this.interaction.mode = 'panning';
|
|
||||||
this.interaction.panStart = { x: e.clientX, y: e.clientY };
|
|
||||||
}
|
|
||||||
|
|
||||||
panViewport(e: MouseEvent): void {
|
panViewport(e: MouseEvent): void {
|
||||||
const dx = e.clientX - this.interaction.panStart.x;
|
const dx = e.clientX - this.interaction.panStart.x;
|
||||||
const dy = e.clientY - this.interaction.panStart.y;
|
const dy = e.clientY - this.interaction.panStart.y;
|
||||||
this.canvas.viewport.x -= dx / this.canvas.viewport.zoom;
|
this.canvas.viewport.x -= dx / this.canvas.viewport.zoom;
|
||||||
this.canvas.viewport.y -= dy / this.canvas.viewport.zoom;
|
this.canvas.viewport.y -= dy / this.canvas.viewport.zoom;
|
||||||
this.interaction.panStart = {x: e.clientX, y: e.clientY};
|
this.interaction.panStart = {x: e.clientX, y: e.clientY};
|
||||||
|
|
||||||
|
// Update stroke overlay if mask tool is drawing during pan
|
||||||
|
if (this.canvas.maskTool.isDrawing) {
|
||||||
|
this.canvas.maskTool.handleViewportChange();
|
||||||
|
}
|
||||||
|
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
this.canvas.onViewportChange?.();
|
this.canvas.onViewportChange?.();
|
||||||
}
|
}
|
||||||
@@ -818,7 +910,7 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const o = this.interaction.transformOrigin;
|
const o = this.interaction.transformOrigin;
|
||||||
if (o.rotation === undefined || o.width === undefined || o.height === undefined || o.centerX === undefined || o.centerY === undefined) return;
|
if (!o) return;
|
||||||
|
|
||||||
const handle = this.interaction.resizeHandle;
|
const handle = this.interaction.resizeHandle;
|
||||||
const anchor = this.interaction.resizeAnchor;
|
const anchor = this.interaction.resizeAnchor;
|
||||||
@@ -974,7 +1066,7 @@ export class CanvasInteractions {
|
|||||||
if (!layer) return;
|
if (!layer) return;
|
||||||
|
|
||||||
const o = this.interaction.transformOrigin;
|
const o = this.interaction.transformOrigin;
|
||||||
if (o.rotation === undefined || o.centerX === undefined || o.centerY === undefined) return;
|
if (!o) return;
|
||||||
const startAngle = Math.atan2(this.interaction.dragStart.y - o.centerY, this.interaction.dragStart.x - o.centerX);
|
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);
|
const currentAngle = Math.atan2(worldCoords.y - o.centerY, worldCoords.x - o.centerX);
|
||||||
let angleDiff = (currentAngle - startAngle) * 180 / Math.PI;
|
let angleDiff = (currentAngle - startAngle) * 180 / Math.PI;
|
||||||
|
|||||||
@@ -8,12 +8,19 @@ export class CanvasRenderer {
|
|||||||
lastRenderTime: any;
|
lastRenderTime: any;
|
||||||
renderAnimationFrame: any;
|
renderAnimationFrame: any;
|
||||||
renderInterval: any;
|
renderInterval: any;
|
||||||
|
// Overlay used to preview in-progress mask strokes (separate from cursor overlay)
|
||||||
|
strokeOverlayCanvas!: HTMLCanvasElement;
|
||||||
|
strokeOverlayCtx!: CanvasRenderingContext2D;
|
||||||
constructor(canvas: any) {
|
constructor(canvas: any) {
|
||||||
this.canvas = canvas;
|
this.canvas = canvas;
|
||||||
this.renderAnimationFrame = null;
|
this.renderAnimationFrame = null;
|
||||||
this.lastRenderTime = 0;
|
this.lastRenderTime = 0;
|
||||||
this.renderInterval = 1000 / 60;
|
this.renderInterval = 1000 / 60;
|
||||||
this.isDirty = false;
|
this.isDirty = false;
|
||||||
|
|
||||||
|
// Initialize overlay canvases
|
||||||
|
this.initOverlay();
|
||||||
|
this.initStrokeOverlay();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -141,9 +148,11 @@ export class CanvasRenderer {
|
|||||||
ctx.save();
|
ctx.save();
|
||||||
|
|
||||||
if (this.canvas.maskTool.isActive) {
|
if (this.canvas.maskTool.isActive) {
|
||||||
|
// In draw mask mode, use the previewOpacity value from the slider
|
||||||
ctx.globalCompositeOperation = 'source-over';
|
ctx.globalCompositeOperation = 'source-over';
|
||||||
ctx.globalAlpha = 0.5;
|
ctx.globalAlpha = this.canvas.maskTool.previewOpacity;
|
||||||
} else {
|
} else {
|
||||||
|
// When not in draw mask mode, show mask at full opacity
|
||||||
ctx.globalCompositeOperation = 'source-over';
|
ctx.globalCompositeOperation = 'source-over';
|
||||||
ctx.globalAlpha = 1.0;
|
ctx.globalAlpha = 1.0;
|
||||||
}
|
}
|
||||||
@@ -205,6 +214,12 @@ export class CanvasRenderer {
|
|||||||
}
|
}
|
||||||
this.canvas.ctx.drawImage(this.canvas.offscreenCanvas, 0, 0);
|
this.canvas.ctx.drawImage(this.canvas.offscreenCanvas, 0, 0);
|
||||||
|
|
||||||
|
// Ensure overlay canvases are in DOM and properly sized
|
||||||
|
this.addOverlayToDOM();
|
||||||
|
this.updateOverlaySize();
|
||||||
|
this.addStrokeOverlayToDOM();
|
||||||
|
this.updateStrokeOverlaySize();
|
||||||
|
|
||||||
// Update Batch Preview UI positions
|
// Update Batch Preview UI positions
|
||||||
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
|
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
|
||||||
this.canvas.batchPreviewManagers.forEach((manager: any) => {
|
this.canvas.batchPreviewManagers.forEach((manager: any) => {
|
||||||
@@ -710,4 +725,290 @@ export class CanvasRenderer {
|
|||||||
padding: 8
|
padding: 8
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize overlay canvas for lightweight overlays like brush cursor
|
||||||
|
*/
|
||||||
|
initOverlay(): void {
|
||||||
|
// Setup overlay canvas to match main canvas
|
||||||
|
this.updateOverlaySize();
|
||||||
|
|
||||||
|
// Position overlay canvas on top of main canvas
|
||||||
|
this.canvas.overlayCanvas.style.position = 'absolute';
|
||||||
|
this.canvas.overlayCanvas.style.left = '0px';
|
||||||
|
this.canvas.overlayCanvas.style.top = '0px';
|
||||||
|
this.canvas.overlayCanvas.style.pointerEvents = 'none';
|
||||||
|
this.canvas.overlayCanvas.style.zIndex = '20'; // Above other overlays
|
||||||
|
|
||||||
|
// Add overlay to DOM when main canvas is added
|
||||||
|
this.addOverlayToDOM();
|
||||||
|
|
||||||
|
log.debug('Overlay canvas initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add overlay canvas to DOM if main canvas has a parent
|
||||||
|
*/
|
||||||
|
addOverlayToDOM(): void {
|
||||||
|
if (this.canvas.canvas.parentElement && !this.canvas.overlayCanvas.parentElement) {
|
||||||
|
this.canvas.canvas.parentElement.appendChild(this.canvas.overlayCanvas);
|
||||||
|
log.debug('Overlay canvas added to DOM');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update overlay canvas size to match main canvas
|
||||||
|
*/
|
||||||
|
updateOverlaySize(): void {
|
||||||
|
if (this.canvas.overlayCanvas.width !== this.canvas.canvas.clientWidth ||
|
||||||
|
this.canvas.overlayCanvas.height !== this.canvas.canvas.clientHeight) {
|
||||||
|
|
||||||
|
this.canvas.overlayCanvas.width = Math.max(1, this.canvas.canvas.clientWidth);
|
||||||
|
this.canvas.overlayCanvas.height = Math.max(1, this.canvas.canvas.clientHeight);
|
||||||
|
|
||||||
|
log.debug(`Overlay canvas resized to ${this.canvas.overlayCanvas.width}x${this.canvas.overlayCanvas.height}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear overlay canvas
|
||||||
|
*/
|
||||||
|
clearOverlay(): void {
|
||||||
|
this.canvas.overlayCtx.clearRect(0, 0, this.canvas.overlayCanvas.width, this.canvas.overlayCanvas.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize a dedicated overlay for real-time mask stroke preview
|
||||||
|
*/
|
||||||
|
initStrokeOverlay(): void {
|
||||||
|
// Create canvas if not created yet
|
||||||
|
if (!this.strokeOverlayCanvas) {
|
||||||
|
this.strokeOverlayCanvas = document.createElement('canvas');
|
||||||
|
const ctx = this.strokeOverlayCanvas.getContext('2d');
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('Failed to get 2D context for stroke overlay canvas');
|
||||||
|
}
|
||||||
|
this.strokeOverlayCtx = ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size match main canvas
|
||||||
|
this.updateStrokeOverlaySize();
|
||||||
|
|
||||||
|
// Position above main canvas but below cursor overlay
|
||||||
|
this.strokeOverlayCanvas.style.position = 'absolute';
|
||||||
|
this.strokeOverlayCanvas.style.left = '1px';
|
||||||
|
this.strokeOverlayCanvas.style.top = '1px';
|
||||||
|
this.strokeOverlayCanvas.style.pointerEvents = 'none';
|
||||||
|
this.strokeOverlayCanvas.style.zIndex = '19'; // Below cursor overlay (20)
|
||||||
|
// Opacity is now controlled by MaskTool.previewOpacity
|
||||||
|
this.strokeOverlayCanvas.style.opacity = String(this.canvas.maskTool.previewOpacity || 0.5);
|
||||||
|
|
||||||
|
// Add to DOM
|
||||||
|
this.addStrokeOverlayToDOM();
|
||||||
|
log.debug('Stroke overlay canvas initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add stroke overlay canvas to DOM if needed
|
||||||
|
*/
|
||||||
|
addStrokeOverlayToDOM(): void {
|
||||||
|
if (this.canvas.canvas.parentElement && !this.strokeOverlayCanvas.parentElement) {
|
||||||
|
this.canvas.canvas.parentElement.appendChild(this.strokeOverlayCanvas);
|
||||||
|
log.debug('Stroke overlay canvas added to DOM');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure stroke overlay size matches main canvas
|
||||||
|
*/
|
||||||
|
updateStrokeOverlaySize(): void {
|
||||||
|
const w = Math.max(1, this.canvas.canvas.clientWidth);
|
||||||
|
const h = Math.max(1, this.canvas.canvas.clientHeight);
|
||||||
|
if (this.strokeOverlayCanvas.width !== w || this.strokeOverlayCanvas.height !== h) {
|
||||||
|
this.strokeOverlayCanvas.width = w;
|
||||||
|
this.strokeOverlayCanvas.height = h;
|
||||||
|
log.debug(`Stroke overlay resized to ${w}x${h}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the stroke overlay
|
||||||
|
*/
|
||||||
|
clearMaskStrokeOverlay(): void {
|
||||||
|
if (!this.strokeOverlayCtx) return;
|
||||||
|
this.strokeOverlayCtx.clearRect(0, 0, this.strokeOverlayCanvas.width, this.strokeOverlayCanvas.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw a preview stroke segment onto the stroke overlay in screen space
|
||||||
|
* Uses line drawing with gradient to match MaskTool's drawLineOnChunk exactly
|
||||||
|
*/
|
||||||
|
drawMaskStrokeSegment(startWorld: { x: number; y: number }, endWorld: { x: number; y: number }): void {
|
||||||
|
// Ensure overlay is present and sized
|
||||||
|
this.updateStrokeOverlaySize();
|
||||||
|
|
||||||
|
const zoom = this.canvas.viewport.zoom;
|
||||||
|
const toScreen = (p: { x: number; y: number }) => ({
|
||||||
|
x: (p.x - this.canvas.viewport.x) * zoom,
|
||||||
|
y: (p.y - this.canvas.viewport.y) * zoom
|
||||||
|
});
|
||||||
|
|
||||||
|
const startScreen = toScreen(startWorld);
|
||||||
|
const endScreen = toScreen(endWorld);
|
||||||
|
|
||||||
|
const brushRadius = (this.canvas.maskTool.brushSize / 2) * zoom;
|
||||||
|
const hardness = this.canvas.maskTool.brushHardness;
|
||||||
|
const strength = this.canvas.maskTool.brushStrength;
|
||||||
|
|
||||||
|
// If strength is 0, don't draw anything
|
||||||
|
if (strength <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.strokeOverlayCtx.save();
|
||||||
|
|
||||||
|
// Draw line segment exactly as MaskTool does
|
||||||
|
this.strokeOverlayCtx.beginPath();
|
||||||
|
this.strokeOverlayCtx.moveTo(startScreen.x, startScreen.y);
|
||||||
|
this.strokeOverlayCtx.lineTo(endScreen.x, endScreen.y);
|
||||||
|
|
||||||
|
// Match the gradient setup from MaskTool's drawLineOnChunk
|
||||||
|
if (hardness === 1) {
|
||||||
|
this.strokeOverlayCtx.strokeStyle = `rgba(255, 255, 255, ${strength})`;
|
||||||
|
} else {
|
||||||
|
const innerRadius = brushRadius * hardness;
|
||||||
|
const gradient = this.strokeOverlayCtx.createRadialGradient(
|
||||||
|
endScreen.x, endScreen.y, innerRadius,
|
||||||
|
endScreen.x, endScreen.y, brushRadius
|
||||||
|
);
|
||||||
|
gradient.addColorStop(0, `rgba(255, 255, 255, ${strength})`);
|
||||||
|
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`);
|
||||||
|
this.strokeOverlayCtx.strokeStyle = gradient;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match line properties from MaskTool
|
||||||
|
this.strokeOverlayCtx.lineWidth = this.canvas.maskTool.brushSize * zoom;
|
||||||
|
this.strokeOverlayCtx.lineCap = 'round';
|
||||||
|
this.strokeOverlayCtx.lineJoin = 'round';
|
||||||
|
this.strokeOverlayCtx.globalCompositeOperation = 'source-over';
|
||||||
|
this.strokeOverlayCtx.stroke();
|
||||||
|
|
||||||
|
this.strokeOverlayCtx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redraws the entire stroke overlay from world coordinates
|
||||||
|
* Used when viewport changes during drawing to maintain visual consistency
|
||||||
|
*/
|
||||||
|
redrawMaskStrokeOverlay(strokePoints: { x: number; y: number }[]): void {
|
||||||
|
if (strokePoints.length < 2) return;
|
||||||
|
|
||||||
|
// Clear the overlay first
|
||||||
|
this.clearMaskStrokeOverlay();
|
||||||
|
|
||||||
|
// Redraw all segments with current viewport
|
||||||
|
for (let i = 1; i < strokePoints.length; i++) {
|
||||||
|
this.drawMaskStrokeSegment(strokePoints[i - 1], strokePoints[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw mask brush cursor on overlay canvas with visual feedback for size, strength and hardness
|
||||||
|
* @param worldPoint World coordinates of cursor
|
||||||
|
*/
|
||||||
|
drawMaskBrushCursor(worldPoint: { x: number, y: number }): void {
|
||||||
|
if (!this.canvas.maskTool.isActive || !this.canvas.isMouseOver) {
|
||||||
|
this.clearOverlay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update overlay size if needed
|
||||||
|
this.updateOverlaySize();
|
||||||
|
|
||||||
|
// Clear previous cursor
|
||||||
|
this.clearOverlay();
|
||||||
|
|
||||||
|
// Convert world coordinates to screen coordinates
|
||||||
|
const screenX = (worldPoint.x - this.canvas.viewport.x) * this.canvas.viewport.zoom;
|
||||||
|
const screenY = (worldPoint.y - this.canvas.viewport.y) * this.canvas.viewport.zoom;
|
||||||
|
|
||||||
|
// Get brush properties
|
||||||
|
const brushRadius = (this.canvas.maskTool.brushSize / 2) * this.canvas.viewport.zoom;
|
||||||
|
const brushStrength = this.canvas.maskTool.brushStrength;
|
||||||
|
const brushHardness = this.canvas.maskTool.brushHardness;
|
||||||
|
|
||||||
|
// Save context state
|
||||||
|
this.canvas.overlayCtx.save();
|
||||||
|
|
||||||
|
// If strength is 0, just draw outline
|
||||||
|
if (brushStrength > 0) {
|
||||||
|
// Draw inner fill to visualize brush effect - matches actual brush rendering
|
||||||
|
const gradient = this.canvas.overlayCtx.createRadialGradient(
|
||||||
|
screenX, screenY, 0,
|
||||||
|
screenX, screenY, brushRadius
|
||||||
|
);
|
||||||
|
|
||||||
|
// Preview alpha - subtle to not obscure content
|
||||||
|
const previewAlpha = brushStrength * 0.15; // Very subtle preview (max 15% opacity)
|
||||||
|
|
||||||
|
if (brushHardness === 1) {
|
||||||
|
// Hard brush - uniform fill within radius
|
||||||
|
gradient.addColorStop(0, `rgba(255, 255, 255, ${previewAlpha})`);
|
||||||
|
gradient.addColorStop(1, `rgba(255, 255, 255, ${previewAlpha})`);
|
||||||
|
} else {
|
||||||
|
// Soft brush - gradient fade matching actual brush
|
||||||
|
gradient.addColorStop(0, `rgba(255, 255, 255, ${previewAlpha})`);
|
||||||
|
if (brushHardness > 0) {
|
||||||
|
gradient.addColorStop(brushHardness, `rgba(255, 255, 255, ${previewAlpha})`);
|
||||||
|
}
|
||||||
|
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.canvas.overlayCtx.beginPath();
|
||||||
|
this.canvas.overlayCtx.arc(screenX, screenY, brushRadius, 0, 2 * Math.PI);
|
||||||
|
this.canvas.overlayCtx.fillStyle = gradient;
|
||||||
|
this.canvas.overlayCtx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw outer circle (SIZE indicator)
|
||||||
|
this.canvas.overlayCtx.beginPath();
|
||||||
|
this.canvas.overlayCtx.arc(screenX, screenY, brushRadius, 0, 2 * Math.PI);
|
||||||
|
|
||||||
|
// Stroke opacity based on strength (dimmer when strength is 0)
|
||||||
|
const strokeOpacity = brushStrength > 0 ? (0.4 + brushStrength * 0.4) : 0.3;
|
||||||
|
this.canvas.overlayCtx.strokeStyle = `rgba(255, 255, 255, ${strokeOpacity})`;
|
||||||
|
this.canvas.overlayCtx.lineWidth = 1.5;
|
||||||
|
|
||||||
|
// Visual feedback for hardness
|
||||||
|
if (brushHardness > 0.8) {
|
||||||
|
// Hard brush - solid line
|
||||||
|
this.canvas.overlayCtx.setLineDash([]);
|
||||||
|
} else {
|
||||||
|
// Soft brush - dashed line
|
||||||
|
const dashLength = 2 + (1 - brushHardness) * 4;
|
||||||
|
this.canvas.overlayCtx.setLineDash([dashLength, dashLength]);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.canvas.overlayCtx.stroke();
|
||||||
|
|
||||||
|
// Center dot for small brushes
|
||||||
|
if (brushRadius < 5) {
|
||||||
|
this.canvas.overlayCtx.beginPath();
|
||||||
|
this.canvas.overlayCtx.arc(screenX, screenY, 1, 0, 2 * Math.PI);
|
||||||
|
this.canvas.overlayCtx.fillStyle = `rgba(255, 255, 255, ${strokeOpacity})`;
|
||||||
|
this.canvas.overlayCtx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore context state
|
||||||
|
this.canvas.overlayCtx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update overlay position when viewport changes
|
||||||
|
*/
|
||||||
|
updateOverlayPosition(): void {
|
||||||
|
// Overlay canvas is positioned absolutely, so it doesn't need repositioning
|
||||||
|
// Just ensure it's the right size
|
||||||
|
this.updateOverlaySize();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -640,6 +640,24 @@ $el("label.clipboard-switch.mask-switch", {
|
|||||||
setTimeout(() => canvas.render(), 0);
|
setTimeout(() => canvas.render(), 0);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
$el("div.painter-slider-container.mask-control", {style: {display: 'none'}}, [
|
||||||
|
$el("label", {for: "preview-opacity-slider", textContent: "Mask Opacity:"}),
|
||||||
|
$el("input", {
|
||||||
|
id: "preview-opacity-slider",
|
||||||
|
type: "range",
|
||||||
|
min: "0",
|
||||||
|
max: "1",
|
||||||
|
step: "0.05",
|
||||||
|
value: "0.5",
|
||||||
|
oninput: (e: Event) => {
|
||||||
|
const value = (e.target as HTMLInputElement).value;
|
||||||
|
canvas.maskTool.setPreviewOpacity(parseFloat(value));
|
||||||
|
const valueEl = document.getElementById('preview-opacity-value');
|
||||||
|
if (valueEl) valueEl.textContent = `${Math.round(parseFloat(value) * 100)}%`;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
$el("div.slider-value", {id: "preview-opacity-value"}, ["50%"])
|
||||||
|
]),
|
||||||
$el("div.painter-slider-container.mask-control", {style: {display: 'none'}}, [
|
$el("div.painter-slider-container.mask-control", {style: {display: 'none'}}, [
|
||||||
$el("label", {for: "brush-size-slider", textContent: "Size:"}),
|
$el("label", {for: "brush-size-slider", textContent: "Size:"}),
|
||||||
$el("input", {
|
$el("input", {
|
||||||
|
|||||||
220
src/MaskTool.ts
220
src/MaskTool.ts
@@ -21,9 +21,10 @@ interface MaskChunk {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class MaskTool {
|
export class MaskTool {
|
||||||
private brushHardness: number;
|
private _brushHardness: number;
|
||||||
private brushSize: number;
|
public brushSize: number;
|
||||||
private brushStrength: number;
|
private _brushStrength: number;
|
||||||
|
private _previewOpacity: number;
|
||||||
private canvasInstance: Canvas & { canvasState: CanvasState, width: number, height: number };
|
private canvasInstance: Canvas & { canvasState: CanvasState, width: number, height: number };
|
||||||
public isActive: boolean;
|
public isActive: boolean;
|
||||||
public isDrawing: boolean;
|
public isDrawing: boolean;
|
||||||
@@ -31,6 +32,9 @@ export class MaskTool {
|
|||||||
private lastPosition: Point | null;
|
private lastPosition: Point | null;
|
||||||
private mainCanvas: HTMLCanvasElement;
|
private mainCanvas: HTMLCanvasElement;
|
||||||
|
|
||||||
|
// Track strokes during drawing for efficient overlay updates
|
||||||
|
private currentStrokePoints: Point[] = [];
|
||||||
|
|
||||||
// Chunked mask system
|
// Chunked mask system
|
||||||
private maskChunks: Map<string, MaskChunk>; // Key: "x,y" (chunk coordinates)
|
private maskChunks: Map<string, MaskChunk>; // Key: "x,y" (chunk coordinates)
|
||||||
private chunkSize: number;
|
private chunkSize: number;
|
||||||
@@ -72,6 +76,9 @@ export class MaskTool {
|
|||||||
this.mainCanvas = canvasInstance.canvas;
|
this.mainCanvas = canvasInstance.canvas;
|
||||||
this.onStateChange = callbacks.onStateChange || null;
|
this.onStateChange = callbacks.onStateChange || null;
|
||||||
|
|
||||||
|
// Initialize stroke tracking for overlay drawing
|
||||||
|
this.currentStrokePoints = [];
|
||||||
|
|
||||||
// Initialize chunked mask system
|
// Initialize chunked mask system
|
||||||
this.maskChunks = new Map();
|
this.maskChunks = new Map();
|
||||||
this.chunkSize = 512;
|
this.chunkSize = 512;
|
||||||
@@ -96,8 +103,9 @@ export class MaskTool {
|
|||||||
this.isOverlayVisible = true;
|
this.isOverlayVisible = true;
|
||||||
this.isActive = false;
|
this.isActive = false;
|
||||||
this.brushSize = 20;
|
this.brushSize = 20;
|
||||||
this.brushStrength = 0.5;
|
this._brushStrength = 0.5;
|
||||||
this.brushHardness = 0.5;
|
this._brushHardness = 0.5;
|
||||||
|
this._previewOpacity = 0.5; // Default 50% opacity for preview
|
||||||
this.isDrawing = false;
|
this.isDrawing = false;
|
||||||
this.lastPosition = null;
|
this.lastPosition = null;
|
||||||
|
|
||||||
@@ -156,8 +164,31 @@ export class MaskTool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Getters for brush properties
|
||||||
|
get brushStrength(): number {
|
||||||
|
return this._brushStrength;
|
||||||
|
}
|
||||||
|
|
||||||
|
get brushHardness(): number {
|
||||||
|
return this._brushHardness;
|
||||||
|
}
|
||||||
|
|
||||||
|
get previewOpacity(): number {
|
||||||
|
return this._previewOpacity;
|
||||||
|
}
|
||||||
|
|
||||||
setBrushHardness(hardness: number): void {
|
setBrushHardness(hardness: number): void {
|
||||||
this.brushHardness = Math.max(0, Math.min(1, hardness));
|
this._brushHardness = Math.max(0, Math.min(1, hardness));
|
||||||
|
}
|
||||||
|
|
||||||
|
setPreviewOpacity(opacity: number): void {
|
||||||
|
this._previewOpacity = Math.max(0, Math.min(1, opacity));
|
||||||
|
// Update the stroke overlay canvas opacity when preview opacity changes
|
||||||
|
if (this.canvasInstance.canvasRenderer && this.canvasInstance.canvasRenderer.strokeOverlayCanvas) {
|
||||||
|
this.canvasInstance.canvasRenderer.strokeOverlayCanvas.style.opacity = String(this._previewOpacity);
|
||||||
|
}
|
||||||
|
// Trigger canvas render to update mask display opacity
|
||||||
|
this.canvasInstance.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
initMaskCanvas(): void {
|
initMaskCanvas(): void {
|
||||||
@@ -867,7 +898,7 @@ export class MaskTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setBrushStrength(strength: number): void {
|
setBrushStrength(strength: number): void {
|
||||||
this.brushStrength = Math.max(0, Math.min(1, strength));
|
this._brushStrength = Math.max(0, Math.min(1, strength));
|
||||||
}
|
}
|
||||||
|
|
||||||
handleMouseDown(worldCoords: Point, viewCoords: Point): void {
|
handleMouseDown(worldCoords: Point, viewCoords: Point): void {
|
||||||
@@ -875,10 +906,12 @@ export class MaskTool {
|
|||||||
this.isDrawing = true;
|
this.isDrawing = true;
|
||||||
this.lastPosition = worldCoords;
|
this.lastPosition = worldCoords;
|
||||||
|
|
||||||
// Activate chunks around the drawing position for performance
|
// Initialize stroke tracking for live preview
|
||||||
this.updateActiveChunksForDrawing(worldCoords);
|
this.currentStrokePoints = [worldCoords];
|
||||||
|
|
||||||
|
// Clear any previous stroke overlay
|
||||||
|
this.canvasInstance.canvasRenderer.clearMaskStrokeOverlay();
|
||||||
|
|
||||||
this.draw(worldCoords);
|
|
||||||
this.clearPreview();
|
this.clearPreview();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -888,16 +921,83 @@ export class MaskTool {
|
|||||||
}
|
}
|
||||||
if (!this.isActive || !this.isDrawing) return;
|
if (!this.isActive || !this.isDrawing) return;
|
||||||
|
|
||||||
// Dynamically update active chunks as user moves while drawing
|
// Add point to stroke tracking
|
||||||
this.updateActiveChunksForDrawing(worldCoords);
|
this.currentStrokePoints.push(worldCoords);
|
||||||
|
|
||||||
|
// Draw interpolated segments for smooth strokes without gaps
|
||||||
|
if (this.lastPosition) {
|
||||||
|
// Calculate distance between last and current position
|
||||||
|
const dx = worldCoords.x - this.lastPosition.x;
|
||||||
|
const dy = worldCoords.y - this.lastPosition.y;
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
// If distance is small, just draw a single segment
|
||||||
|
if (distance < this.brushSize / 4) {
|
||||||
|
this.canvasInstance.canvasRenderer.drawMaskStrokeSegment(this.lastPosition, worldCoords);
|
||||||
|
} else {
|
||||||
|
// Interpolate points for smooth drawing without gaps
|
||||||
|
const interpolatedPoints = this.interpolatePoints(this.lastPosition, worldCoords, distance);
|
||||||
|
|
||||||
|
// Draw all interpolated segments
|
||||||
|
for (let i = 0; i < interpolatedPoints.length - 1; i++) {
|
||||||
|
this.canvasInstance.canvasRenderer.drawMaskStrokeSegment(
|
||||||
|
interpolatedPoints[i],
|
||||||
|
interpolatedPoints[i + 1]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.draw(worldCoords);
|
|
||||||
this.lastPosition = worldCoords;
|
this.lastPosition = worldCoords;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interpolates points between two positions to create smooth strokes without gaps
|
||||||
|
* Based on the BrushTool's approach for eliminating dotted lines during fast drawing
|
||||||
|
*/
|
||||||
|
private interpolatePoints(start: Point, end: Point, distance: number): Point[] {
|
||||||
|
const points: Point[] = [];
|
||||||
|
|
||||||
|
// Calculate number of interpolated points based on brush size
|
||||||
|
// More points = smoother line
|
||||||
|
const stepSize = Math.max(1, this.brushSize / 6); // Adjust divisor for smoothness
|
||||||
|
const numSteps = Math.ceil(distance / stepSize);
|
||||||
|
|
||||||
|
// Always include start point
|
||||||
|
points.push(start);
|
||||||
|
|
||||||
|
// Interpolate intermediate points
|
||||||
|
for (let i = 1; i < numSteps; i++) {
|
||||||
|
const t = i / numSteps;
|
||||||
|
points.push({
|
||||||
|
x: start.x + (end.x - start.x) * t,
|
||||||
|
y: start.y + (end.y - start.y) * t
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always include end point
|
||||||
|
points.push(end);
|
||||||
|
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when viewport changes during drawing to update stroke overlay
|
||||||
|
* This ensures the stroke preview scales correctly with zoom changes
|
||||||
|
*/
|
||||||
|
handleViewportChange(): void {
|
||||||
|
if (this.isDrawing && this.currentStrokePoints.length > 1) {
|
||||||
|
// Redraw the entire stroke overlay with new viewport settings
|
||||||
|
this.canvasInstance.canvasRenderer.redrawMaskStrokeOverlay(this.currentStrokePoints);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handleMouseLeave(): void {
|
handleMouseLeave(): void {
|
||||||
this.previewVisible = false;
|
this.previewVisible = false;
|
||||||
this.clearPreview();
|
this.clearPreview();
|
||||||
|
// Clear overlay canvases when mouse leaves
|
||||||
|
this.canvasInstance.canvasRenderer.clearOverlay();
|
||||||
|
this.canvasInstance.canvasRenderer.clearMaskStrokeOverlay();
|
||||||
}
|
}
|
||||||
|
|
||||||
handleMouseEnter(): void {
|
handleMouseEnter(): void {
|
||||||
@@ -908,11 +1008,18 @@ export class MaskTool {
|
|||||||
if (!this.isActive) return;
|
if (!this.isActive) return;
|
||||||
if (this.isDrawing) {
|
if (this.isDrawing) {
|
||||||
this.isDrawing = false;
|
this.isDrawing = false;
|
||||||
|
|
||||||
|
// Commit the stroke from overlay to actual mask chunks
|
||||||
|
this.commitStrokeToChunks();
|
||||||
|
|
||||||
|
// Clear stroke overlay and reset state
|
||||||
|
this.canvasInstance.canvasRenderer.clearMaskStrokeOverlay();
|
||||||
|
this.currentStrokePoints = [];
|
||||||
this.lastPosition = null;
|
this.lastPosition = null;
|
||||||
this.currentDrawingChunk = null;
|
this.currentDrawingChunk = null;
|
||||||
|
|
||||||
// After drawing is complete, update active canvas to show all chunks
|
// After drawing is complete, update active canvas to show all chunks
|
||||||
this.updateActiveMaskCanvas(true); // forceShowAll = true
|
this.updateActiveMaskCanvas(true); // Force full update
|
||||||
|
|
||||||
this.completeMaskOperation();
|
this.completeMaskOperation();
|
||||||
this.drawBrushPreview(viewCoords);
|
this.drawBrushPreview(viewCoords);
|
||||||
@@ -932,6 +1039,44 @@ export class MaskTool {
|
|||||||
this.updateActiveCanvasIfNeeded(this.lastPosition, worldCoords);
|
this.updateActiveCanvasIfNeeded(this.lastPosition, worldCoords);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commits the current stroke from overlay to actual mask chunks
|
||||||
|
* This replays the entire stroke path with interpolation to ensure pixel-perfect accuracy
|
||||||
|
*/
|
||||||
|
private commitStrokeToChunks(): void {
|
||||||
|
if (this.currentStrokePoints.length < 2) {
|
||||||
|
return; // Need at least 2 points for a stroke
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug(`Committing stroke with ${this.currentStrokePoints.length} points to chunks`);
|
||||||
|
|
||||||
|
// Replay the entire stroke path with interpolation for smooth, accurate lines
|
||||||
|
for (let i = 1; i < this.currentStrokePoints.length; i++) {
|
||||||
|
const startPoint = this.currentStrokePoints[i - 1];
|
||||||
|
const endPoint = this.currentStrokePoints[i];
|
||||||
|
|
||||||
|
// Calculate distance between points
|
||||||
|
const dx = endPoint.x - startPoint.x;
|
||||||
|
const dy = endPoint.y - startPoint.y;
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
if (distance < this.brushSize / 4) {
|
||||||
|
// Small distance - draw single segment
|
||||||
|
this.drawOnChunks(startPoint, endPoint);
|
||||||
|
} else {
|
||||||
|
// Large distance - interpolate for smooth line without gaps
|
||||||
|
const interpolatedPoints = this.interpolatePoints(startPoint, endPoint, distance);
|
||||||
|
|
||||||
|
// Draw all interpolated segments
|
||||||
|
for (let j = 0; j < interpolatedPoints.length - 1; j++) {
|
||||||
|
this.drawOnChunks(interpolatedPoints[j], interpolatedPoints[j + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("Stroke committed to chunks successfully with interpolation");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Draws a line between two world coordinates on the appropriate chunks
|
* Draws a line between two world coordinates on the appropriate chunks
|
||||||
*/
|
*/
|
||||||
@@ -982,15 +1127,15 @@ export class MaskTool {
|
|||||||
|
|
||||||
const gradientRadius = this.brushSize / 2;
|
const gradientRadius = this.brushSize / 2;
|
||||||
|
|
||||||
if (this.brushHardness === 1) {
|
if (this._brushHardness === 1) {
|
||||||
chunk.ctx.strokeStyle = `rgba(255, 255, 255, ${this.brushStrength})`;
|
chunk.ctx.strokeStyle = `rgba(255, 255, 255, ${this._brushStrength})`;
|
||||||
} else {
|
} else {
|
||||||
const innerRadius = gradientRadius * this.brushHardness;
|
const innerRadius = gradientRadius * this._brushHardness;
|
||||||
const gradient = chunk.ctx.createRadialGradient(
|
const gradient = chunk.ctx.createRadialGradient(
|
||||||
endLocal.x, endLocal.y, innerRadius,
|
endLocal.x, endLocal.y, innerRadius,
|
||||||
endLocal.x, endLocal.y, gradientRadius
|
endLocal.x, endLocal.y, gradientRadius
|
||||||
);
|
);
|
||||||
gradient.addColorStop(0, `rgba(255, 255, 255, ${this.brushStrength})`);
|
gradient.addColorStop(0, `rgba(255, 255, 255, ${this._brushStrength})`);
|
||||||
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`);
|
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`);
|
||||||
chunk.ctx.strokeStyle = gradient;
|
chunk.ctx.strokeStyle = gradient;
|
||||||
}
|
}
|
||||||
@@ -1029,29 +1174,17 @@ export class MaskTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates active canvas when drawing affects chunks with throttling to prevent lag
|
* Updates active canvas when drawing affects chunks
|
||||||
* During drawing, only updates the affected active chunks for performance
|
* Since we now use overlay during drawing, this is only called after drawing is complete
|
||||||
*/
|
*/
|
||||||
private updateActiveCanvasIfNeeded(startWorld: Point, endWorld: Point): void {
|
private updateActiveCanvasIfNeeded(startWorld: Point, endWorld: Point): void {
|
||||||
// Calculate which chunks were affected by this drawing operation
|
// This method is now simplified - we only update after drawing is complete
|
||||||
const minX = Math.min(startWorld.x, endWorld.x) - this.brushSize;
|
// The overlay handles all live preview, so we don't need complex chunk activation
|
||||||
const maxX = Math.max(startWorld.x, endWorld.x) + this.brushSize;
|
if (!this.isDrawing) {
|
||||||
const minY = Math.min(startWorld.y, endWorld.y) - this.brushSize;
|
|
||||||
const maxY = Math.max(startWorld.y, endWorld.y) + this.brushSize;
|
|
||||||
|
|
||||||
const affectedChunkMinX = Math.floor(minX / this.chunkSize);
|
|
||||||
const affectedChunkMinY = Math.floor(minY / this.chunkSize);
|
|
||||||
const affectedChunkMaxX = Math.floor(maxX / this.chunkSize);
|
|
||||||
const affectedChunkMaxY = Math.floor(maxY / this.chunkSize);
|
|
||||||
|
|
||||||
// During drawing, only update affected chunks that are active for performance
|
|
||||||
if (this.isDrawing) {
|
|
||||||
// Use throttled partial update for active chunks only
|
|
||||||
this.scheduleThrottledActiveMaskUpdate(affectedChunkMinX, affectedChunkMinY, affectedChunkMaxX, affectedChunkMaxY);
|
|
||||||
} else {
|
|
||||||
// Not drawing - do full update to show all chunks
|
// Not drawing - do full update to show all chunks
|
||||||
this.updateActiveMaskCanvas(true);
|
this.updateActiveMaskCanvas(true);
|
||||||
}
|
}
|
||||||
|
// During drawing, we don't update chunks at all - overlay handles preview
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1142,20 +1275,13 @@ export class MaskTool {
|
|||||||
|
|
||||||
drawBrushPreview(viewCoords: Point): void {
|
drawBrushPreview(viewCoords: Point): void {
|
||||||
if (!this.previewVisible || this.isDrawing) {
|
if (!this.previewVisible || this.isDrawing) {
|
||||||
this.clearPreview();
|
this.canvasInstance.canvasRenderer.clearOverlay();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.clearPreview();
|
// Use overlay canvas instead of preview canvas for brush cursor
|
||||||
const zoom = this.canvasInstance.viewport.zoom;
|
const worldCoords = this.canvasInstance.lastMousePosition;
|
||||||
const radius = (this.brushSize / 2) * zoom;
|
this.canvasInstance.canvasRenderer.drawMaskBrushCursor(worldCoords);
|
||||||
|
|
||||||
this.previewCtx.beginPath();
|
|
||||||
this.previewCtx.arc(viewCoords.x, viewCoords.y, radius, 0, 2 * Math.PI);
|
|
||||||
this.previewCtx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
|
|
||||||
this.previewCtx.lineWidth = 1;
|
|
||||||
this.previewCtx.setLineDash([2, 4]);
|
|
||||||
this.previewCtx.stroke();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
clearPreview(): void {
|
clearPreview(): void {
|
||||||
|
|||||||
Reference in New Issue
Block a user