mirror of
https://github.com/Azornes/Comfyui-LayerForge.git
synced 2026-03-21 20:52:12 -03:00
2309 lines
80 KiB
JavaScript
2309 lines
80 KiB
JavaScript
export class Canvas {
|
|
constructor(node, widget) {
|
|
this.node = node;
|
|
this.widget = widget;
|
|
this.canvas = document.createElement('canvas');
|
|
this.ctx = this.canvas.getContext('2d');
|
|
this.width = 512;
|
|
this.height = 512;
|
|
this.layers = [];
|
|
this.selectedLayer = null;
|
|
this.selectedLayers = [];
|
|
this.onSelectionChange = null;
|
|
this.lastMousePosition = {x: 0, y: 0};
|
|
|
|
this.viewport = {
|
|
x: -(this.width / 4),
|
|
y: -(this.height / 4),
|
|
zoom: 0.8,
|
|
};
|
|
this.interaction = {
|
|
mode: 'none',
|
|
panStart: {x: 0, y: 0},
|
|
dragStart: {x: 0, y: 0},
|
|
transformOrigin: {},
|
|
resizeHandle: null,
|
|
resizeAnchor: {x: 0, y: 0},
|
|
canvasResizeStart: {x: 0, y: 0},
|
|
isCtrlPressed: false,
|
|
isAltPressed: false,
|
|
hasClonedInDrag: false,
|
|
lastClickTime: 0,
|
|
};
|
|
this.originalLayerPositions = new Map();
|
|
this.interaction.canvasResizeRect = null;
|
|
this.interaction.canvasMoveRect = null;
|
|
|
|
this.offscreenCanvas = document.createElement('canvas');
|
|
this.offscreenCtx = this.offscreenCanvas.getContext('2d', {
|
|
alpha: false
|
|
});
|
|
this.renderAnimationFrame = null;
|
|
this.lastRenderTime = 0;
|
|
this.internalClipboard = [];
|
|
this.isMouseOver = false;
|
|
this.renderInterval = 1000 / 60;
|
|
this.isDirty = false;
|
|
|
|
this.dataInitialized = false;
|
|
this.pendingDataCheck = null;
|
|
this.initCanvas();
|
|
this.setupEventListeners();
|
|
this.initNodeData();
|
|
|
|
this.blendModes = [
|
|
{name: '.', label: 'Normal'},
|
|
{name: '.', label: 'Multiply'},
|
|
{name: '.', label: 'Screen'},
|
|
{name: '.', label: 'Overlay'},
|
|
{name: '.', label: 'Darken'},
|
|
{name: '.', label: 'Lighten'},
|
|
{name: '.', label: 'Color Dodge'},
|
|
{name: '.', label: 'Color Burn'},
|
|
{name: '.', label: 'Hard Light'},
|
|
{name: '.', label: 'Soft Light'},
|
|
{name: '.', label: 'Difference'},
|
|
{name: '.', label: 'Exclusion'}
|
|
];
|
|
this.selectedBlendMode = null;
|
|
this.blendOpacity = 100;
|
|
this.isAdjustingOpacity = false;
|
|
|
|
this.layers = this.layers.map(layer => ({
|
|
...layer,
|
|
opacity: 1
|
|
}));
|
|
}
|
|
|
|
initCanvas() {
|
|
this.canvas.width = this.width;
|
|
this.canvas.height = this.height;
|
|
this.canvas.style.border = '1px solid black';
|
|
this.canvas.style.maxWidth = '100%';
|
|
this.canvas.style.backgroundColor = '#606060';
|
|
this.canvas.style.width = '100%';
|
|
this.canvas.style.height = '100%';
|
|
}
|
|
|
|
setupEventListeners() {
|
|
this.canvas.addEventListener('mousedown', this.handleMouseDown.bind(this));
|
|
this.canvas.addEventListener('mousemove', this.handleMouseMove.bind(this));
|
|
this.canvas.addEventListener('mouseup', this.handleMouseUp.bind(this));
|
|
this.canvas.addEventListener('mouseleave', this.handleMouseLeave.bind(this));
|
|
this.canvas.addEventListener('wheel', this.handleWheel.bind(this), {passive: false});
|
|
|
|
document.addEventListener('keydown', this.handleKeyDown.bind(this));
|
|
document.addEventListener('keyup', this.handleKeyUp.bind(this));
|
|
|
|
this.canvas.addEventListener('mouseenter', () => {
|
|
this.isMouseOver = true;
|
|
});
|
|
this.canvas.addEventListener('mouseleave', () => {
|
|
this.isMouseOver = false;
|
|
});
|
|
}
|
|
|
|
updateSelection(newSelection) {
|
|
this.selectedLayers = newSelection || [];
|
|
this.selectedLayer = this.selectedLayers.length > 0 ? this.selectedLayers[this.selectedLayers.length - 1] : null;
|
|
if (this.onSelectionChange) {
|
|
this.onSelectionChange();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resetuje stan interakcji do wartości domyślnych.
|
|
*/
|
|
resetInteractionState() {
|
|
this.interaction.mode = 'none';
|
|
this.interaction.resizeHandle = null;
|
|
this.originalLayerPositions.clear();
|
|
this.interaction.canvasResizeRect = null;
|
|
this.interaction.canvasMoveRect = null;
|
|
this.interaction.hasClonedInDrag = false;
|
|
this.canvas.style.cursor = 'default';
|
|
}
|
|
|
|
/**
|
|
* Główna metoda obsługująca wciśnięcie przycisku myszy.
|
|
*/
|
|
handleMouseDown(e) {
|
|
const currentTime = Date.now();
|
|
const worldCoords = this.getMouseWorldCoordinates(e);
|
|
if (e.shiftKey && e.ctrlKey) {
|
|
this.startCanvasMove(worldCoords);
|
|
this.render();
|
|
return;
|
|
}
|
|
|
|
if (currentTime - this.interaction.lastClickTime < 300) {
|
|
this.updateSelection([]);
|
|
this.selectedLayer = null;
|
|
this.resetInteractionState();
|
|
this.render();
|
|
return;
|
|
}
|
|
this.interaction.lastClickTime = currentTime;
|
|
|
|
const handle = this.getHandleAtPosition(worldCoords.x, worldCoords.y);
|
|
if (this.selectedLayer && handle) {
|
|
this.startLayerTransform(handle, worldCoords);
|
|
return;
|
|
}
|
|
|
|
const clickedLayerResult = this.getLayerAtPosition(worldCoords.x, worldCoords.y);
|
|
if (clickedLayerResult) {
|
|
if (e.shiftKey && this.selectedLayers.includes(clickedLayerResult.layer)) {
|
|
this.showBlendModeMenu(e.clientX, e.clientY);
|
|
return;
|
|
}
|
|
this.startLayerDrag(clickedLayerResult.layer, worldCoords);
|
|
return;
|
|
}
|
|
if (e.shiftKey) {
|
|
this.startCanvasResize(worldCoords);
|
|
} else {
|
|
this.startPanning(e);
|
|
}
|
|
|
|
this.render();
|
|
}
|
|
|
|
/**
|
|
* Kopiuje zaznaczone warstwy do wewnętrznego schowka ORAZ
|
|
* jako spłaszczony obraz do globalnego schowka systemowego.
|
|
*/
|
|
async copySelectedLayers() {
|
|
if (this.selectedLayers.length === 0) return;
|
|
this.internalClipboard = this.selectedLayers.map(layer => ({...layer}));
|
|
console.log(`Copied ${this.internalClipboard.length} layer(s) to internal clipboard.`);
|
|
try {
|
|
const blob = await this.getFlattenedSelectionAsBlob();
|
|
if (blob) {
|
|
const item = new ClipboardItem({'image/png': blob});
|
|
await navigator.clipboard.write([item]);
|
|
console.log("Flattened selection copied to the system clipboard.");
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to copy image to system clipboard:", error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Wkleja warstwy z wewnętrznego schowka na płótno.
|
|
*/
|
|
pasteLayers() {
|
|
if (this.internalClipboard.length === 0) return;
|
|
|
|
const newLayers = [];
|
|
const pasteOffset = 20;
|
|
|
|
this.internalClipboard.forEach(clipboardLayer => {
|
|
const newLayer = {
|
|
...clipboardLayer,
|
|
x: clipboardLayer.x + pasteOffset / this.viewport.zoom,
|
|
y: clipboardLayer.y + pasteOffset / this.viewport.zoom,
|
|
zIndex: this.layers.length
|
|
};
|
|
this.layers.push(newLayer);
|
|
newLayers.push(newLayer);
|
|
});
|
|
|
|
this.updateSelection(newLayers);
|
|
this.render();
|
|
console.log(`Pasted ${newLayers.length} layer(s).`);
|
|
}
|
|
|
|
/**
|
|
* Inteligentnie obsługuje operację wklejania.
|
|
* Najpierw próbuje wkleić obraz z globalnego schowka,
|
|
* a jeśli to się nie uda, wkleja z wewnętrznego schowka.
|
|
*/
|
|
async handlePaste() {
|
|
try {
|
|
if (!navigator.clipboard?.read) {
|
|
console.log("Browser does not support clipboard read API. Falling back to internal paste.");
|
|
this.pasteLayers();
|
|
return;
|
|
}
|
|
|
|
const clipboardItems = await navigator.clipboard.read();
|
|
let imagePasted = false;
|
|
|
|
for (const item of clipboardItems) {
|
|
const imageType = item.types.find(type => type.startsWith('image/'));
|
|
|
|
if (imageType) {
|
|
const blob = await item.getType(imageType);
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
const newLayer = {
|
|
image: img,
|
|
x: this.lastMousePosition.x - img.width / 2,
|
|
y: this.lastMousePosition.y - img.height / 2,
|
|
width: img.width,
|
|
height: img.height,
|
|
rotation: 0,
|
|
zIndex: this.layers.length,
|
|
blendMode: 'normal',
|
|
opacity: 1
|
|
};
|
|
this.layers.push(newLayer);
|
|
this.updateSelection([newLayer]);
|
|
this.render();
|
|
URL.revokeObjectURL(img.src);
|
|
};
|
|
img.src = URL.createObjectURL(blob);
|
|
imagePasted = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!imagePasted) {
|
|
this.pasteLayers();
|
|
}
|
|
|
|
} catch (err) {
|
|
console.error("Paste operation failed, falling back to internal paste. Error:", err);
|
|
this.pasteLayers();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Główna metoda obsługująca ruch myszy.
|
|
*/
|
|
handleMouseMove(e) {
|
|
const worldCoords = this.getMouseWorldCoordinates(e);
|
|
this.lastMousePosition = worldCoords;
|
|
|
|
switch (this.interaction.mode) {
|
|
case 'panning':
|
|
this.panViewport(e);
|
|
break;
|
|
case 'dragging':
|
|
this.dragLayers(worldCoords);
|
|
break;
|
|
case 'resizing':
|
|
this.resizeLayerFromHandle(worldCoords, e.shiftKey);
|
|
break;
|
|
case 'rotating':
|
|
this.rotateLayerFromHandle(worldCoords, e.shiftKey);
|
|
break;
|
|
case 'resizingCanvas':
|
|
this.updateCanvasResize(worldCoords);
|
|
break;
|
|
case 'movingCanvas':
|
|
this.updateCanvasMove(worldCoords);
|
|
break;
|
|
default:
|
|
this.updateCursor(worldCoords);
|
|
break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Metoda obsługująca puszczenie przycisku myszy.
|
|
*/
|
|
handleMouseUp(e) {
|
|
if (this.interaction.mode === 'resizingCanvas') {
|
|
this.finalizeCanvasResize();
|
|
} else if (this.interaction.mode === 'movingCanvas') {
|
|
this.finalizeCanvasMove();
|
|
}
|
|
this.resetInteractionState();
|
|
this.render();
|
|
}
|
|
|
|
/**
|
|
* Metoda obsługująca opuszczenie obszaru canvas przez kursor.
|
|
*/
|
|
handleMouseLeave(e) {
|
|
if (this.interaction.mode !== 'none') {
|
|
this.resetInteractionState();
|
|
this.render();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Metoda obsługująca kółko myszy (zoom / skalowanie / rotacja warstwy).
|
|
*/
|
|
handleWheel(e) {
|
|
e.preventDefault();
|
|
if (this.selectedLayer) {
|
|
const rotationStep = 5 * (e.deltaY > 0 ? -1 : 1);
|
|
|
|
this.selectedLayers.forEach(layer => {
|
|
if (e.shiftKey) {
|
|
layer.rotation += rotationStep;
|
|
} else {
|
|
const oldWidth = layer.width;
|
|
const oldHeight = layer.height;
|
|
let scaleFactor;
|
|
|
|
if (e.ctrlKey) {
|
|
|
|
const direction = e.deltaY > 0 ? -1 : 1;
|
|
|
|
const baseDimension = Math.max(layer.width, layer.height);
|
|
const newBaseDimension = baseDimension + direction;
|
|
if (newBaseDimension < 10) {
|
|
return;
|
|
}
|
|
|
|
scaleFactor = newBaseDimension / baseDimension;
|
|
|
|
} else {
|
|
|
|
const gridSize = 64;
|
|
const direction = e.deltaY > 0 ? -1 : 1;
|
|
let targetHeight;
|
|
|
|
if (direction > 0) {
|
|
|
|
targetHeight = (Math.floor(oldHeight / gridSize) + 1) * gridSize;
|
|
} else {
|
|
|
|
targetHeight = (Math.ceil(oldHeight / gridSize) - 1) * gridSize;
|
|
}
|
|
if (targetHeight < gridSize / 2) {
|
|
targetHeight = gridSize / 2;
|
|
}
|
|
if (Math.abs(oldHeight - targetHeight) < 1) {
|
|
if (direction > 0) targetHeight += gridSize;
|
|
else targetHeight -= gridSize;
|
|
|
|
if (targetHeight < gridSize / 2) return;
|
|
}
|
|
|
|
scaleFactor = targetHeight / oldHeight;
|
|
}
|
|
if (scaleFactor && isFinite(scaleFactor)) {
|
|
layer.width *= scaleFactor;
|
|
layer.height *= scaleFactor;
|
|
layer.x += (oldWidth - layer.width) / 2;
|
|
layer.y += (oldHeight - layer.height) / 2;
|
|
}
|
|
}
|
|
});
|
|
} else {
|
|
const worldCoords = this.getMouseWorldCoordinates(e);
|
|
const rect = this.canvas.getBoundingClientRect();
|
|
const mouseBufferX = (e.clientX - rect.left) * (this.offscreenCanvas.width / rect.width);
|
|
const mouseBufferY = (e.clientY - rect.top) * (this.offscreenCanvas.height / rect.height);
|
|
|
|
const zoomFactor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
|
|
const newZoom = this.viewport.zoom * zoomFactor;
|
|
|
|
this.viewport.zoom = Math.max(0.1, Math.min(10, newZoom));
|
|
this.viewport.x = worldCoords.x - (mouseBufferX / this.viewport.zoom);
|
|
this.viewport.y = worldCoords.y - (mouseBufferY / this.viewport.zoom);
|
|
}
|
|
this.render();
|
|
}
|
|
|
|
/**
|
|
* Metoda obsługująca wciśnięcie klawisza.
|
|
*/
|
|
handleKeyDown(e) {
|
|
if (this.isMouseOver) {
|
|
if (e.ctrlKey && e.key.toLowerCase() === 'c') {
|
|
if (this.selectedLayers.length > 0) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
this.copySelectedLayers();
|
|
return;
|
|
}
|
|
}
|
|
if (e.ctrlKey && e.key.toLowerCase() === 'v') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
this.handlePaste();
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (e.key === 'Control') this.interaction.isCtrlPressed = true;
|
|
if (e.key === 'Alt') {
|
|
this.interaction.isAltPressed = true;
|
|
e.preventDefault();
|
|
}
|
|
|
|
if (this.selectedLayer) {
|
|
if (e.key === 'Delete') {
|
|
this.layers = this.layers.filter(l => !this.selectedLayers.includes(l));
|
|
this.updateSelection([]);
|
|
this.render();
|
|
return;
|
|
}
|
|
|
|
const step = e.shiftKey ? 10 : 1;
|
|
let needsRender = false;
|
|
|
|
switch (e.key) {
|
|
case 'ArrowLeft':
|
|
this.selectedLayers.forEach(l => l.x -= step);
|
|
needsRender = true;
|
|
break;
|
|
case 'ArrowRight':
|
|
this.selectedLayers.forEach(l => l.x += step);
|
|
needsRender = true;
|
|
break;
|
|
case 'ArrowUp':
|
|
this.selectedLayers.forEach(l => l.y -= step);
|
|
needsRender = true;
|
|
break;
|
|
case 'ArrowDown':
|
|
this.selectedLayers.forEach(l => l.y += step);
|
|
needsRender = true;
|
|
break;
|
|
case '[':
|
|
this.selectedLayers.forEach(l => l.rotation -= step);
|
|
needsRender = true;
|
|
break;
|
|
case ']':
|
|
this.selectedLayers.forEach(l => l.rotation += step);
|
|
needsRender = true;
|
|
break;
|
|
}
|
|
|
|
if (needsRender) {
|
|
e.preventDefault();
|
|
this.render();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Metoda obsługująca puszczenie klawisza.
|
|
*/
|
|
handleKeyUp(e) {
|
|
if (e.key === 'Control') this.interaction.isCtrlPressed = false;
|
|
if (e.key === 'Alt') this.interaction.isAltPressed = false;
|
|
}
|
|
|
|
updateCursor(worldCoords) {
|
|
const handle = this.getHandleAtPosition(worldCoords.x, worldCoords.y);
|
|
if (handle) {
|
|
const cursorMap = {
|
|
'n': 'ns-resize', 's': 'ns-resize', 'e': 'ew-resize', 'w': 'ew-resize',
|
|
'nw': 'nwse-resize', 'se': 'nwse-resize', 'ne': 'nesw-resize', 'sw': 'nesw-resize',
|
|
'rot': 'grab'
|
|
};
|
|
this.canvas.style.cursor = cursorMap[handle];
|
|
} else if (this.getLayerAtPosition(worldCoords.x, worldCoords.y)) {
|
|
this.canvas.style.cursor = 'move';
|
|
} else {
|
|
this.canvas.style.cursor = 'default';
|
|
}
|
|
}
|
|
|
|
startLayerTransform(handle, worldCoords) {
|
|
const layer = this.selectedLayer;
|
|
this.interaction.transformOrigin = {
|
|
x: layer.x, y: layer.y,
|
|
width: layer.width, height: layer.height,
|
|
rotation: layer.rotation,
|
|
centerX: layer.x + layer.width / 2,
|
|
centerY: layer.y + layer.height / 2
|
|
};
|
|
this.interaction.dragStart = {...worldCoords};
|
|
|
|
if (handle === 'rot') {
|
|
this.interaction.mode = 'rotating';
|
|
} else {
|
|
this.interaction.mode = 'resizing';
|
|
this.interaction.resizeHandle = handle;
|
|
const handles = this.getHandles(layer);
|
|
const oppositeHandleKey = {
|
|
'n': 's', 's': 'n', 'e': 'w', 'w': 'e',
|
|
'nw': 'se', 'se': 'nw', 'ne': 'sw', 'sw': 'ne'
|
|
}[handle];
|
|
this.interaction.resizeAnchor = handles[oppositeHandleKey];
|
|
}
|
|
this.render();
|
|
}
|
|
|
|
startLayerDrag(layer, worldCoords) {
|
|
this.interaction.mode = 'dragging';
|
|
this.interaction.dragStart = {...worldCoords};
|
|
|
|
let currentSelection = [...this.selectedLayers];
|
|
|
|
if (this.interaction.isCtrlPressed) {
|
|
const index = currentSelection.indexOf(layer);
|
|
if (index === -1) {
|
|
currentSelection.push(layer);
|
|
} else {
|
|
currentSelection.splice(index, 1);
|
|
}
|
|
} else {
|
|
if (!currentSelection.includes(layer)) {
|
|
currentSelection = [layer];
|
|
}
|
|
}
|
|
|
|
this.updateSelection(currentSelection);
|
|
|
|
this.originalLayerPositions.clear();
|
|
this.selectedLayers.forEach(l => {
|
|
this.originalLayerPositions.set(l, {x: l.x, y: l.y});
|
|
});
|
|
}
|
|
|
|
startCanvasResize(worldCoords) {
|
|
this.interaction.mode = 'resizingCanvas';
|
|
const startX = this.snapToGrid(worldCoords.x);
|
|
const startY = this.snapToGrid(worldCoords.y);
|
|
this.interaction.canvasResizeStart = {x: startX, y: startY};
|
|
this.interaction.canvasResizeRect = {x: startX, y: startY, width: 0, height: 0};
|
|
this.render();
|
|
}
|
|
|
|
startCanvasMove(worldCoords) {
|
|
this.interaction.mode = 'movingCanvas';
|
|
this.interaction.dragStart = { ...worldCoords };
|
|
const initialX = this.snapToGrid(worldCoords.x - this.width / 2);
|
|
const initialY = this.snapToGrid(worldCoords.y - this.height / 2);
|
|
|
|
this.interaction.canvasMoveRect = {
|
|
x: initialX,
|
|
y: initialY,
|
|
width: this.width,
|
|
height: this.height
|
|
};
|
|
|
|
this.canvas.style.cursor = 'grabbing';
|
|
this.render();
|
|
}
|
|
|
|
/**
|
|
* Aktualizuje pozycję "ducha" płótna podczas przesuwania.
|
|
*/
|
|
updateCanvasMove(worldCoords) {
|
|
if (!this.interaction.canvasMoveRect) return;
|
|
const dx = worldCoords.x - this.interaction.dragStart.x;
|
|
const dy = worldCoords.y - this.interaction.dragStart.y;
|
|
const initialRectX = this.snapToGrid(this.interaction.dragStart.x - this.width / 2);
|
|
const initialRectY = this.snapToGrid(this.interaction.dragStart.y - this.height / 2);
|
|
this.interaction.canvasMoveRect.x = this.snapToGrid(initialRectX + dx);
|
|
this.interaction.canvasMoveRect.y = this.snapToGrid(initialRectY + dy);
|
|
|
|
this.render();
|
|
}
|
|
|
|
/**
|
|
* Kończy przesuwanie płótna i zatwierdza nową pozycję.
|
|
*/
|
|
finalizeCanvasMove() {
|
|
const moveRect = this.interaction.canvasMoveRect;
|
|
|
|
if (moveRect && (moveRect.x !== 0 || moveRect.y !== 0)) {
|
|
const finalX = moveRect.x;
|
|
const finalY = moveRect.y;
|
|
|
|
this.layers.forEach(layer => {
|
|
layer.x -= finalX;
|
|
layer.y -= finalY;
|
|
});
|
|
this.viewport.x -= finalX;
|
|
this.viewport.y -= finalY;
|
|
}
|
|
this.render();
|
|
}
|
|
|
|
startPanning(e) {
|
|
if (!this.interaction.isCtrlPressed) {
|
|
this.updateSelection([]);
|
|
}
|
|
this.interaction.mode = 'panning';
|
|
this.interaction.panStart = {x: e.clientX, y: e.clientY};
|
|
}
|
|
|
|
panViewport(e) {
|
|
const dx = e.clientX - this.interaction.panStart.x;
|
|
const dy = e.clientY - this.interaction.panStart.y;
|
|
this.viewport.x -= dx / this.viewport.zoom;
|
|
this.viewport.y -= dy / this.viewport.zoom;
|
|
this.interaction.panStart = {x: e.clientX, y: e.clientY};
|
|
this.render();
|
|
}
|
|
|
|
dragLayers(worldCoords) {
|
|
if (this.interaction.isAltPressed && !this.interaction.hasClonedInDrag && this.selectedLayers.length > 0) {
|
|
const newLayers = [];
|
|
this.selectedLayers.forEach(layer => {
|
|
const newLayer = {
|
|
...layer,
|
|
zIndex: this.layers.length,
|
|
};
|
|
this.layers.push(newLayer);
|
|
newLayers.push(newLayer);
|
|
});
|
|
this.updateSelection(newLayers);
|
|
this.selectedLayer = newLayers.length > 0 ? newLayers[newLayers.length - 1] : null;
|
|
this.originalLayerPositions.clear();
|
|
this.selectedLayers.forEach(l => {
|
|
this.originalLayerPositions.set(l, {x: l.x, y: l.y});
|
|
});
|
|
this.interaction.hasClonedInDrag = true;
|
|
}
|
|
const totalDx = worldCoords.x - this.interaction.dragStart.x;
|
|
const totalDy = worldCoords.y - this.interaction.dragStart.y;
|
|
let finalDx = totalDx, finalDy = totalDy;
|
|
|
|
if (this.interaction.isCtrlPressed && this.selectedLayer) {
|
|
const originalPos = this.originalLayerPositions.get(this.selectedLayer);
|
|
if (originalPos) {
|
|
const tempLayerForSnap = {
|
|
...this.selectedLayer,
|
|
x: originalPos.x + totalDx,
|
|
y: originalPos.y + totalDy
|
|
};
|
|
const snapAdjustment = this.getSnapAdjustment(tempLayerForSnap);
|
|
finalDx += snapAdjustment.dx;
|
|
finalDy += snapAdjustment.dy;
|
|
}
|
|
}
|
|
|
|
this.selectedLayers.forEach(layer => {
|
|
const originalPos = this.originalLayerPositions.get(layer);
|
|
if (originalPos) {
|
|
layer.x = originalPos.x + finalDx;
|
|
layer.y = originalPos.y + finalDy;
|
|
}
|
|
});
|
|
this.render();
|
|
}
|
|
|
|
resizeLayerFromHandle(worldCoords, isShiftPressed) {
|
|
let mouseX = worldCoords.x;
|
|
let mouseY = worldCoords.y;
|
|
|
|
if (this.interaction.isCtrlPressed) {
|
|
const snapThreshold = 10 / this.viewport.zoom;
|
|
const snappedMouseX = this.snapToGrid(mouseX);
|
|
if (Math.abs(mouseX - snappedMouseX) < snapThreshold) mouseX = snappedMouseX;
|
|
const snappedMouseY = this.snapToGrid(mouseY);
|
|
if (Math.abs(mouseY - snappedMouseY) < snapThreshold) mouseY = snappedMouseY;
|
|
}
|
|
|
|
const layer = this.selectedLayer;
|
|
const o = this.interaction.transformOrigin;
|
|
const handle = this.interaction.resizeHandle;
|
|
const anchor = this.interaction.resizeAnchor;
|
|
|
|
const rad = o.rotation * Math.PI / 180;
|
|
const cos = Math.cos(rad);
|
|
const sin = Math.sin(rad);
|
|
|
|
const vecX = mouseX - anchor.x;
|
|
const vecY = mouseY - anchor.y;
|
|
|
|
let newWidth = vecX * cos + vecY * sin;
|
|
let newHeight = vecY * cos - vecX * sin;
|
|
|
|
let signX = handle.includes('e') ? 1 : (handle.includes('w') ? -1 : 0);
|
|
let signY = handle.includes('s') ? 1 : (handle.includes('n') ? -1 : 0);
|
|
|
|
newWidth *= signX;
|
|
newHeight *= signY;
|
|
|
|
if (signX === 0) newWidth = o.width;
|
|
if (signY === 0) newHeight = o.height;
|
|
|
|
if (newWidth < 10) newWidth = 10;
|
|
if (newHeight < 10) newHeight = 10;
|
|
|
|
layer.width = newWidth;
|
|
layer.height = newHeight;
|
|
|
|
const deltaW = newWidth - o.width;
|
|
const deltaH = newHeight - o.height;
|
|
|
|
const shiftX = (deltaW / 2) * signX;
|
|
const shiftY = (deltaH / 2) * signY;
|
|
|
|
const worldShiftX = shiftX * cos - shiftY * sin;
|
|
const worldShiftY = shiftX * sin + shiftY * cos;
|
|
|
|
const newCenterX = o.centerX + worldShiftX;
|
|
const newCenterY = o.centerY + worldShiftY;
|
|
|
|
layer.x = newCenterX - layer.width / 2;
|
|
layer.y = newCenterY - layer.height / 2;
|
|
this.render();
|
|
}
|
|
|
|
rotateLayerFromHandle(worldCoords, isShiftPressed) {
|
|
const o = this.interaction.transformOrigin;
|
|
const startAngle = Math.atan2(this.interaction.dragStart.y - o.centerY, this.interaction.dragStart.x - o.centerX);
|
|
const currentAngle = Math.atan2(worldCoords.y - o.centerY, worldCoords.x - o.centerX);
|
|
let angleDiff = (currentAngle - startAngle) * 180 / Math.PI;
|
|
let newRotation = o.rotation + angleDiff;
|
|
|
|
if (isShiftPressed) {
|
|
newRotation = Math.round(newRotation / 15) * 15;
|
|
}
|
|
|
|
this.selectedLayer.rotation = newRotation;
|
|
this.render();
|
|
}
|
|
|
|
updateCanvasResize(worldCoords) {
|
|
const snappedMouseX = this.snapToGrid(worldCoords.x);
|
|
const snappedMouseY = this.snapToGrid(worldCoords.y);
|
|
const start = this.interaction.canvasResizeStart;
|
|
|
|
this.interaction.canvasResizeRect.x = Math.min(snappedMouseX, start.x);
|
|
this.interaction.canvasResizeRect.y = Math.min(snappedMouseY, start.y);
|
|
this.interaction.canvasResizeRect.width = Math.abs(snappedMouseX - start.x);
|
|
this.interaction.canvasResizeRect.height = Math.abs(snappedMouseY - start.y);
|
|
this.render();
|
|
}
|
|
|
|
finalizeCanvasResize() {
|
|
if (this.interaction.canvasResizeRect && this.interaction.canvasResizeRect.width > 1 && this.interaction.canvasResizeRect.height > 1) {
|
|
const newWidth = Math.round(this.interaction.canvasResizeRect.width);
|
|
const newHeight = Math.round(this.interaction.canvasResizeRect.height);
|
|
const rectX = this.interaction.canvasResizeRect.x;
|
|
const rectY = this.interaction.canvasResizeRect.y;
|
|
|
|
this.updateCanvasSize(newWidth, newHeight);
|
|
|
|
this.layers.forEach(layer => {
|
|
layer.x -= rectX;
|
|
layer.y -= rectY;
|
|
});
|
|
|
|
this.viewport.x -= rectX;
|
|
this.viewport.y -= rectY;
|
|
}
|
|
}
|
|
|
|
|
|
isRotationHandle(x, y) {
|
|
if (!this.selectedLayer) return false;
|
|
|
|
const handleX = this.selectedLayer.x + this.selectedLayer.width / 2;
|
|
const handleY = this.selectedLayer.y - 20;
|
|
const handleRadius = 5;
|
|
|
|
return Math.sqrt(Math.pow(x - handleX, 2) + Math.pow(y - handleY, 2)) <= handleRadius;
|
|
}
|
|
|
|
addLayer(image) {
|
|
try {
|
|
console.log("Adding layer with image:", image);
|
|
|
|
const layer = {
|
|
image: image,
|
|
x: (this.width - image.width) / 2,
|
|
y: (this.height - image.height) / 2,
|
|
width: image.width,
|
|
height: image.height,
|
|
rotation: 0,
|
|
zIndex: this.layers.length,
|
|
blendMode: 'normal',
|
|
opacity: 1
|
|
};
|
|
|
|
this.layers.push(layer);
|
|
this.updateSelection([layer]);
|
|
this.render();
|
|
|
|
console.log("Layer added successfully");
|
|
} catch (error) {
|
|
console.error("Error adding layer:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
removeLayer(index) {
|
|
if (index >= 0 && index < this.layers.length) {
|
|
this.layers.splice(index, 1);
|
|
this.selectedLayer = this.layers[this.layers.length - 1] || null;
|
|
this.render();
|
|
}
|
|
}
|
|
|
|
getMouseWorldCoordinates(e) {
|
|
const rect = this.canvas.getBoundingClientRect();
|
|
|
|
const mouseX_DOM = e.clientX - rect.left;
|
|
const mouseY_DOM = e.clientY - rect.top;
|
|
|
|
const scaleX = this.offscreenCanvas.width / rect.width;
|
|
const scaleY = this.offscreenCanvas.height / rect.height;
|
|
|
|
const mouseX_Buffer = mouseX_DOM * scaleX;
|
|
const mouseY_Buffer = mouseY_DOM * scaleY;
|
|
|
|
const worldX = (mouseX_Buffer / this.viewport.zoom) + this.viewport.x;
|
|
const worldY = (mouseY_Buffer / this.viewport.zoom) + this.viewport.y;
|
|
|
|
return {x: worldX, y: worldY};
|
|
}
|
|
|
|
snapToGrid(value, gridSize = 64) {
|
|
return Math.round(value / gridSize) * gridSize;
|
|
}
|
|
|
|
getSnapAdjustment(layer, gridSize = 64, snapThreshold = 10) {
|
|
if (!layer) {
|
|
return {dx: 0, dy: 0};
|
|
}
|
|
|
|
const layerEdges = {
|
|
left: layer.x,
|
|
right: layer.x + layer.width,
|
|
top: layer.y,
|
|
bottom: layer.y + layer.height
|
|
};
|
|
const x_adjustments = [
|
|
{type: 'x', delta: this.snapToGrid(layerEdges.left, gridSize) - layerEdges.left},
|
|
{type: 'x', delta: this.snapToGrid(layerEdges.right, gridSize) - layerEdges.right}
|
|
];
|
|
|
|
const y_adjustments = [
|
|
{type: 'y', delta: this.snapToGrid(layerEdges.top, gridSize) - layerEdges.top},
|
|
{type: 'y', delta: this.snapToGrid(layerEdges.bottom, gridSize) - layerEdges.bottom}
|
|
];
|
|
x_adjustments.forEach(adj => adj.abs = Math.abs(adj.delta));
|
|
y_adjustments.forEach(adj => adj.abs = Math.abs(adj.delta));
|
|
const bestXSnap = x_adjustments
|
|
.filter(adj => adj.abs < snapThreshold && adj.abs > 1e-9)
|
|
.sort((a, b) => a.abs - b.abs)[0];
|
|
const bestYSnap = y_adjustments
|
|
.filter(adj => adj.abs < snapThreshold && adj.abs > 1e-9)
|
|
.sort((a, b) => a.abs - b.abs)[0];
|
|
return {
|
|
dx: bestXSnap ? bestXSnap.delta : 0,
|
|
dy: bestYSnap ? bestYSnap.delta : 0
|
|
};
|
|
}
|
|
|
|
moveLayer(fromIndex, toIndex) {
|
|
if (fromIndex >= 0 && fromIndex < this.layers.length &&
|
|
toIndex >= 0 && toIndex < this.layers.length) {
|
|
const layer = this.layers.splice(fromIndex, 1)[0];
|
|
this.layers.splice(toIndex, 0, layer);
|
|
this.render();
|
|
}
|
|
}
|
|
|
|
resizeLayer(scale) {
|
|
this.selectedLayers.forEach(layer => {
|
|
layer.width *= scale;
|
|
layer.height *= scale;
|
|
});
|
|
this.render();
|
|
}
|
|
|
|
rotateLayer(angle) {
|
|
this.selectedLayers.forEach(layer => {
|
|
layer.rotation += angle;
|
|
});
|
|
this.render();
|
|
}
|
|
|
|
updateCanvasSize(width, height) {
|
|
this.width = width;
|
|
this.height = height;
|
|
|
|
this.canvas.width = width;
|
|
this.canvas.height = height;
|
|
|
|
this.render();
|
|
}
|
|
|
|
render() {
|
|
if (this.renderAnimationFrame) {
|
|
this.isDirty = true;
|
|
return;
|
|
}
|
|
this.renderAnimationFrame = requestAnimationFrame(() => {
|
|
const now = performance.now();
|
|
if (now - this.lastRenderTime >= this.renderInterval) {
|
|
this.lastRenderTime = now;
|
|
this.actualRender();
|
|
this.isDirty = false;
|
|
}
|
|
|
|
if (this.isDirty) {
|
|
this.renderAnimationFrame = null;
|
|
this.render();
|
|
} else {
|
|
this.renderAnimationFrame = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
actualRender() {
|
|
if (this.offscreenCanvas.width !== this.canvas.clientWidth ||
|
|
this.offscreenCanvas.height !== this.canvas.clientHeight) {
|
|
const newWidth = Math.max(1, this.canvas.clientWidth);
|
|
const newHeight = Math.max(1, this.canvas.clientHeight);
|
|
this.offscreenCanvas.width = newWidth;
|
|
this.offscreenCanvas.height = newHeight;
|
|
}
|
|
|
|
const ctx = this.offscreenCtx;
|
|
|
|
ctx.fillStyle = '#606060';
|
|
ctx.fillRect(0, 0, this.offscreenCanvas.width, this.offscreenCanvas.height);
|
|
|
|
ctx.save();
|
|
ctx.scale(this.viewport.zoom, this.viewport.zoom);
|
|
ctx.translate(-this.viewport.x, -this.viewport.y);
|
|
|
|
this.drawGrid(ctx);
|
|
|
|
const sortedLayers = [...this.layers].sort((a, b) => a.zIndex - b.zIndex);
|
|
sortedLayers.forEach(layer => {
|
|
if (!layer.image) return;
|
|
ctx.save();
|
|
const currentTransform = ctx.getTransform();
|
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
ctx.globalCompositeOperation = layer.blendMode || 'normal';
|
|
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
|
ctx.setTransform(currentTransform);
|
|
const centerX = layer.x + layer.width / 2;
|
|
const centerY = layer.y + layer.height / 2;
|
|
ctx.translate(centerX, centerY);
|
|
ctx.rotate(layer.rotation * Math.PI / 180);
|
|
ctx.imageSmoothingEnabled = true;
|
|
ctx.imageSmoothingQuality = 'high';
|
|
ctx.drawImage(
|
|
layer.image, -layer.width / 2, -layer.height / 2,
|
|
layer.width,
|
|
layer.height
|
|
);
|
|
if (layer.mask) { /* Logika maski */
|
|
}
|
|
if (this.selectedLayers.includes(layer)) {
|
|
this.drawSelectionFrame(ctx, layer);
|
|
}
|
|
ctx.restore();
|
|
});
|
|
|
|
this.drawCanvasOutline(ctx);
|
|
if (this.interaction.mode === 'resizingCanvas' && this.interaction.canvasResizeRect) {
|
|
const rect = this.interaction.canvasResizeRect;
|
|
ctx.save();
|
|
ctx.strokeStyle = 'rgba(0, 255, 0, 0.8)';
|
|
ctx.lineWidth = 2 / this.viewport.zoom;
|
|
ctx.setLineDash([8 / this.viewport.zoom, 4 / this.viewport.zoom]);
|
|
ctx.strokeRect(rect.x, rect.y, rect.width, rect.height);
|
|
ctx.setLineDash([]);
|
|
ctx.restore();
|
|
if (rect.width > 0 && rect.height > 0) {
|
|
const text = `${Math.round(rect.width)}x${Math.round(rect.height)}`;
|
|
const textWorldX = rect.x + rect.width / 2;
|
|
const textWorldY = rect.y + rect.height + (20 / this.viewport.zoom);
|
|
|
|
ctx.save();
|
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
const screenX = (textWorldX - this.viewport.x) * this.viewport.zoom;
|
|
const screenY = (textWorldY - this.viewport.y) * this.viewport.zoom;
|
|
ctx.font = "14px sans-serif";
|
|
ctx.textAlign = "center";
|
|
ctx.textBaseline = "middle";
|
|
const textMetrics = ctx.measureText(text);
|
|
const bgWidth = textMetrics.width + 10;
|
|
const bgHeight = 22;
|
|
ctx.fillStyle = "rgba(0, 128, 0, 0.7)";
|
|
ctx.fillRect(screenX - bgWidth / 2, screenY - bgHeight / 2, bgWidth, bgHeight);
|
|
ctx.fillStyle = "white";
|
|
ctx.fillText(text, screenX, screenY);
|
|
ctx.restore();
|
|
}
|
|
}
|
|
if (this.interaction.mode === 'movingCanvas' && this.interaction.canvasMoveRect) {
|
|
const rect = this.interaction.canvasMoveRect;
|
|
ctx.save();
|
|
ctx.strokeStyle = 'rgba(0, 150, 255, 0.8)';
|
|
ctx.lineWidth = 2 / this.viewport.zoom;
|
|
ctx.setLineDash([10 / this.viewport.zoom, 5 / this.viewport.zoom]);
|
|
ctx.strokeRect(rect.x, rect.y, rect.width, rect.height);
|
|
ctx.setLineDash([]);
|
|
ctx.restore();
|
|
|
|
const text = `(${Math.round(rect.x)}, ${Math.round(rect.y)})`;
|
|
const textWorldX = rect.x + rect.width / 2;
|
|
const textWorldY = rect.y - (20 / this.viewport.zoom);
|
|
|
|
ctx.save();
|
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
const screenX = (textWorldX - this.viewport.x) * this.viewport.zoom;
|
|
const screenY = (textWorldY - this.viewport.y) * this.viewport.zoom;
|
|
ctx.font = "14px sans-serif";
|
|
ctx.textAlign = "center";
|
|
ctx.textBaseline = "middle";
|
|
const textMetrics = ctx.measureText(text);
|
|
const bgWidth = textMetrics.width + 10;
|
|
const bgHeight = 22;
|
|
ctx.fillStyle = "rgba(0, 100, 170, 0.7)";
|
|
ctx.fillRect(screenX - bgWidth / 2, screenY - bgHeight / 2, bgWidth, bgHeight);
|
|
ctx.fillStyle = "white";
|
|
ctx.fillText(text, screenX, screenY);
|
|
ctx.restore();
|
|
}
|
|
|
|
if (this.selectedLayer) {
|
|
this.selectedLayers.forEach(layer => {
|
|
if (!layer.image) return;
|
|
|
|
const layerIndex = this.layers.indexOf(layer);
|
|
const currentWidth = Math.round(layer.width);
|
|
const currentHeight = Math.round(layer.height);
|
|
const rotation = Math.round(layer.rotation % 360);
|
|
const text = `${currentWidth}x${currentHeight} | ${rotation}° | Layer #${layerIndex + 1}`;
|
|
|
|
|
|
const centerX = layer.x + layer.width / 2;
|
|
const centerY = layer.y + layer.height / 2;
|
|
const rad = layer.rotation * Math.PI / 180;
|
|
const cos = Math.cos(rad);
|
|
const sin = Math.sin(rad);
|
|
|
|
const halfW = layer.width / 2;
|
|
const halfH = layer.height / 2;
|
|
|
|
const localCorners = [
|
|
{x: -halfW, y: -halfH},
|
|
{x: halfW, y: -halfH},
|
|
{x: halfW, y: halfH},
|
|
{x: -halfW, y: halfH}
|
|
];
|
|
const worldCorners = localCorners.map(p => ({
|
|
x: centerX + p.x * cos - p.y * sin,
|
|
y: centerY + p.x * sin + p.y * cos
|
|
}));
|
|
let minX = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
worldCorners.forEach(p => {
|
|
minX = Math.min(minX, p.x);
|
|
maxX = Math.max(maxX, p.x);
|
|
maxY = Math.max(maxY, p.y);
|
|
});
|
|
const padding = 20 / this.viewport.zoom;
|
|
const textWorldX = (minX + maxX) / 2;
|
|
const textWorldY = maxY + padding;
|
|
ctx.save();
|
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
|
|
const screenX = (textWorldX - this.viewport.x) * this.viewport.zoom;
|
|
const screenY = (textWorldY - this.viewport.y) * this.viewport.zoom;
|
|
|
|
ctx.font = "14px sans-serif";
|
|
ctx.textAlign = "center";
|
|
ctx.textBaseline = "middle";
|
|
const textMetrics = ctx.measureText(text);
|
|
const textBgWidth = textMetrics.width + 10;
|
|
const textBgHeight = 22;
|
|
ctx.fillStyle = "rgba(0, 0, 0, 0.7)";
|
|
ctx.fillRect(screenX - textBgWidth / 2, screenY - textBgHeight / 2, textBgWidth, textBgHeight);
|
|
|
|
ctx.fillStyle = "white";
|
|
ctx.fillText(text, screenX, screenY);
|
|
|
|
ctx.restore();
|
|
});
|
|
}
|
|
|
|
ctx.restore();
|
|
|
|
if (this.canvas.width !== this.offscreenCanvas.width || this.canvas.height !== this.offscreenCanvas.height) {
|
|
this.canvas.width = this.offscreenCanvas.width;
|
|
this.canvas.height = this.offscreenCanvas.height;
|
|
}
|
|
this.ctx.drawImage(this.offscreenCanvas, 0, 0);
|
|
}
|
|
|
|
drawGrid(ctx) {
|
|
const gridSize = 64;
|
|
const lineWidth = 0.5 / this.viewport.zoom;
|
|
|
|
const viewLeft = this.viewport.x;
|
|
const viewTop = this.viewport.y;
|
|
const viewRight = this.viewport.x + this.offscreenCanvas.width / this.viewport.zoom;
|
|
const viewBottom = this.viewport.y + this.offscreenCanvas.height / this.viewport.zoom;
|
|
|
|
ctx.beginPath();
|
|
ctx.strokeStyle = '#707070';
|
|
ctx.lineWidth = lineWidth;
|
|
|
|
for (let x = Math.floor(viewLeft / gridSize) * gridSize; x < viewRight; x += gridSize) {
|
|
ctx.moveTo(x, viewTop);
|
|
ctx.lineTo(x, viewBottom);
|
|
}
|
|
|
|
for (let y = Math.floor(viewTop / gridSize) * gridSize; y < viewBottom; y += gridSize) {
|
|
ctx.moveTo(viewLeft, y);
|
|
ctx.lineTo(viewRight, y);
|
|
}
|
|
|
|
ctx.stroke();
|
|
}
|
|
|
|
drawCanvasOutline(ctx) {
|
|
ctx.beginPath();
|
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
|
|
ctx.lineWidth = 2 / this.viewport.zoom;
|
|
ctx.setLineDash([10 / this.viewport.zoom, 5 / this.viewport.zoom]);
|
|
|
|
|
|
ctx.rect(0, 0, this.width, this.height);
|
|
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
}
|
|
|
|
drawSelectionFrame(ctx, layer) {
|
|
const lineWidth = 2 / this.viewport.zoom;
|
|
const handleRadius = 5 / this.viewport.zoom;
|
|
ctx.strokeStyle = '#00ff00';
|
|
ctx.lineWidth = lineWidth;
|
|
ctx.beginPath();
|
|
ctx.rect(-layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
|
ctx.stroke();
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, -layer.height / 2);
|
|
ctx.lineTo(0, -layer.height / 2 - 20 / this.viewport.zoom);
|
|
ctx.stroke();
|
|
const handles = this.getHandles(layer);
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.strokeStyle = '#000000';
|
|
ctx.lineWidth = 1 / this.viewport.zoom;
|
|
|
|
for (const key in handles) {
|
|
const point = handles[key];
|
|
ctx.beginPath();
|
|
const localX = point.x - (layer.x + layer.width / 2);
|
|
const localY = point.y - (layer.y + layer.height / 2);
|
|
|
|
const rad = -layer.rotation * Math.PI / 180;
|
|
const rotatedX = localX * Math.cos(rad) - localY * Math.sin(rad);
|
|
const rotatedY = localX * Math.sin(rad) + localY * Math.cos(rad);
|
|
|
|
ctx.arc(rotatedX, rotatedY, handleRadius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
|
|
|
|
getHandles(layer) {
|
|
if (!layer) return {};
|
|
|
|
const centerX = layer.x + layer.width / 2;
|
|
const centerY = layer.y + layer.height / 2;
|
|
const rad = layer.rotation * Math.PI / 180;
|
|
const cos = Math.cos(rad);
|
|
const sin = Math.sin(rad);
|
|
|
|
const halfW = layer.width / 2;
|
|
const halfH = layer.height / 2;
|
|
const localHandles = {
|
|
'n': {x: 0, y: -halfH},
|
|
'ne': {x: halfW, y: -halfH},
|
|
'e': {x: halfW, y: 0},
|
|
'se': {x: halfW, y: halfH},
|
|
's': {x: 0, y: halfH},
|
|
'sw': {x: -halfW, y: halfH},
|
|
'w': {x: -halfW, y: 0},
|
|
'nw': {x: -halfW, y: -halfH},
|
|
'rot': {x: 0, y: -halfH - 20 / this.viewport.zoom}
|
|
};
|
|
|
|
const worldHandles = {};
|
|
for (const key in localHandles) {
|
|
const p = localHandles[key];
|
|
worldHandles[key] = {
|
|
x: centerX + (p.x * cos - p.y * sin),
|
|
y: centerY + (p.x * sin + p.y * cos)
|
|
};
|
|
}
|
|
return worldHandles;
|
|
}
|
|
|
|
getHandleAtPosition(worldX, worldY) {
|
|
if (!this.selectedLayer) return null;
|
|
|
|
const handles = this.getHandles(this.selectedLayer);
|
|
const handleRadius = 8 / this.viewport.zoom;
|
|
|
|
for (const key in handles) {
|
|
const handlePos = handles[key];
|
|
const dx = worldX - handlePos.x;
|
|
const dy = worldY - handlePos.y;
|
|
if (dx * dx + dy * dy <= handleRadius * handleRadius) {
|
|
return key;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
worldToLocal(worldX, worldY, layerProps) {
|
|
const dx = worldX - layerProps.centerX;
|
|
const dy = worldY - layerProps.centerY;
|
|
const rad = -layerProps.rotation * Math.PI / 180;
|
|
const cos = Math.cos(rad);
|
|
const sin = Math.sin(rad);
|
|
|
|
return {
|
|
x: dx * cos - dy * sin,
|
|
y: dx * sin + dy * cos
|
|
};
|
|
}
|
|
|
|
localToWorld(localX, localY, layerProps) {
|
|
const rad = layerProps.rotation * Math.PI / 180;
|
|
const cos = Math.cos(rad);
|
|
const sin = Math.sin(rad);
|
|
|
|
return {
|
|
x: layerProps.centerX + localX * cos - localY * sin,
|
|
y: layerProps.centerY + localX * sin + localY * cos
|
|
};
|
|
}
|
|
|
|
|
|
async saveToServer(fileName) {
|
|
return new Promise((resolve) => {
|
|
|
|
const tempCanvas = document.createElement('canvas');
|
|
const maskCanvas = document.createElement('canvas');
|
|
tempCanvas.width = this.width;
|
|
tempCanvas.height = this.height;
|
|
maskCanvas.width = this.width;
|
|
maskCanvas.height = this.height;
|
|
|
|
const tempCtx = tempCanvas.getContext('2d');
|
|
const maskCtx = maskCanvas.getContext('2d');
|
|
|
|
tempCtx.fillStyle = '#ffffff';
|
|
tempCtx.fillRect(0, 0, this.width, this.height);
|
|
|
|
maskCtx.fillStyle = '#000000';
|
|
maskCtx.fillRect(0, 0, this.width, this.height);
|
|
|
|
this.layers.sort((a, b) => a.zIndex - b.zIndex).forEach(layer => {
|
|
|
|
tempCtx.save();
|
|
|
|
tempCtx.globalCompositeOperation = layer.blendMode || 'normal';
|
|
tempCtx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
|
|
|
tempCtx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2);
|
|
tempCtx.rotate(layer.rotation * Math.PI / 180);
|
|
tempCtx.drawImage(
|
|
layer.image,
|
|
-layer.width / 2,
|
|
-layer.height / 2,
|
|
layer.width,
|
|
layer.height
|
|
);
|
|
tempCtx.restore();
|
|
|
|
maskCtx.save();
|
|
maskCtx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2);
|
|
maskCtx.rotate(layer.rotation * Math.PI / 180);
|
|
maskCtx.globalCompositeOperation = 'lighter';
|
|
|
|
if (layer.mask) {
|
|
maskCtx.drawImage(layer.mask, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
|
} else {
|
|
|
|
const layerCanvas = document.createElement('canvas');
|
|
layerCanvas.width = layer.width;
|
|
layerCanvas.height = layer.height;
|
|
const layerCtx = layerCanvas.getContext('2d');
|
|
layerCtx.drawImage(layer.image, 0, 0, layer.width, layer.height);
|
|
const imageData = layerCtx.getImageData(0, 0, layer.width, layer.height);
|
|
|
|
const alphaCanvas = document.createElement('canvas');
|
|
alphaCanvas.width = layer.width;
|
|
alphaCanvas.height = layer.height;
|
|
const alphaCtx = alphaCanvas.getContext('2d');
|
|
const alphaData = alphaCtx.createImageData(layer.width, layer.height);
|
|
|
|
for (let i = 0; i < imageData.data.length; i += 4) {
|
|
const alpha = imageData.data[i + 3] * (layer.opacity !== undefined ? layer.opacity : 1);
|
|
alphaData.data[i] = alphaData.data[i + 1] = alphaData.data[i + 2] = alpha;
|
|
alphaData.data[i + 3] = 255;
|
|
}
|
|
|
|
alphaCtx.putImageData(alphaData, 0, 0);
|
|
maskCtx.drawImage(alphaCanvas, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
|
}
|
|
maskCtx.restore();
|
|
});
|
|
|
|
const finalMaskData = maskCtx.getImageData(0, 0, this.width, this.height);
|
|
for (let i = 0; i < finalMaskData.data.length; i += 4) {
|
|
finalMaskData.data[i] =
|
|
finalMaskData.data[i + 1] =
|
|
finalMaskData.data[i + 2] = 255 - finalMaskData.data[i];
|
|
finalMaskData.data[i + 3] = 255;
|
|
}
|
|
maskCtx.putImageData(finalMaskData, 0, 0);
|
|
|
|
tempCanvas.toBlob(async (blob) => {
|
|
const formData = new FormData();
|
|
formData.append("image", blob, fileName);
|
|
formData.append("overwrite", "true");
|
|
|
|
try {
|
|
const resp = await fetch("/upload/image", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
|
|
if (resp.status === 200) {
|
|
|
|
maskCanvas.toBlob(async (maskBlob) => {
|
|
const maskFormData = new FormData();
|
|
const maskFileName = fileName.replace('.png', '_mask.png');
|
|
maskFormData.append("image", maskBlob, maskFileName);
|
|
maskFormData.append("overwrite", "true");
|
|
|
|
try {
|
|
const maskResp = await fetch("/upload/image", {
|
|
method: "POST",
|
|
body: maskFormData,
|
|
});
|
|
|
|
if (maskResp.status === 200) {
|
|
const data = await resp.json();
|
|
this.widget.value = data.name;
|
|
resolve(true);
|
|
} else {
|
|
console.error("Error saving mask: " + maskResp.status);
|
|
resolve(false);
|
|
}
|
|
} catch (error) {
|
|
console.error("Error saving mask:", error);
|
|
resolve(false);
|
|
}
|
|
}, "image/png");
|
|
} else {
|
|
console.error(resp.status + " - " + resp.statusText);
|
|
resolve(false);
|
|
}
|
|
} catch (error) {
|
|
console.error(error);
|
|
resolve(false);
|
|
}
|
|
}, "image/png");
|
|
});
|
|
}
|
|
|
|
async getFlattenedCanvasAsBlob() {
|
|
return new Promise((resolve, reject) => {
|
|
const tempCanvas = document.createElement('canvas');
|
|
tempCanvas.width = this.width;
|
|
tempCanvas.height = this.height;
|
|
const tempCtx = tempCanvas.getContext('2d');
|
|
|
|
const sortedLayers = [...this.layers].sort((a, b) => a.zIndex - b.zIndex);
|
|
|
|
sortedLayers.forEach(layer => {
|
|
if (!layer.image) return;
|
|
|
|
tempCtx.save();
|
|
tempCtx.globalCompositeOperation = layer.blendMode || 'normal';
|
|
tempCtx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
|
const centerX = layer.x + layer.width / 2;
|
|
const centerY = layer.y + layer.height / 2;
|
|
tempCtx.translate(centerX, centerY);
|
|
tempCtx.rotate(layer.rotation * Math.PI / 180);
|
|
tempCtx.drawImage(
|
|
layer.image,
|
|
-layer.width / 2,
|
|
-layer.height / 2,
|
|
layer.width,
|
|
layer.height
|
|
);
|
|
|
|
tempCtx.restore();
|
|
});
|
|
|
|
tempCanvas.toBlob((blob) => {
|
|
if (blob) {
|
|
resolve(blob);
|
|
} else {
|
|
reject(new Error('Canvas toBlob failed.'));
|
|
}
|
|
}, 'image/png');
|
|
});
|
|
}
|
|
|
|
|
|
/**
|
|
* Tworzy spłaszczony obraz z zaznaczonych warstw, przycięty do ich zawartości.
|
|
* @returns {Promise<Blob|null>} Obiekt Blob z obrazem PNG lub null, jeśli nic nie jest zaznaczone.
|
|
*/
|
|
async getFlattenedSelectionAsBlob() {
|
|
if (this.selectedLayers.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return new Promise((resolve) => {
|
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
this.selectedLayers.forEach(layer => {
|
|
const centerX = layer.x + layer.width / 2;
|
|
const centerY = layer.y + layer.height / 2;
|
|
const rad = layer.rotation * Math.PI / 180;
|
|
const cos = Math.cos(rad);
|
|
const sin = Math.sin(rad);
|
|
|
|
const halfW = layer.width / 2;
|
|
const halfH = layer.height / 2;
|
|
|
|
const corners = [
|
|
{x: -halfW, y: -halfH},
|
|
{x: halfW, y: -halfH},
|
|
{x: halfW, y: halfH},
|
|
{x: -halfW, y: halfH}
|
|
];
|
|
|
|
corners.forEach(p => {
|
|
const worldX = centerX + (p.x * cos - p.y * sin);
|
|
const worldY = centerY + (p.x * sin + p.y * cos);
|
|
|
|
minX = Math.min(minX, worldX);
|
|
minY = Math.min(minY, worldY);
|
|
maxX = Math.max(maxX, worldX);
|
|
maxY = Math.max(maxY, worldY);
|
|
});
|
|
});
|
|
|
|
const newWidth = Math.ceil(maxX - minX);
|
|
const newHeight = Math.ceil(maxY - minY);
|
|
|
|
if (newWidth <= 0 || newHeight <= 0) {
|
|
resolve(null);
|
|
return;
|
|
}
|
|
const tempCanvas = document.createElement('canvas');
|
|
tempCanvas.width = newWidth;
|
|
tempCanvas.height = newHeight;
|
|
const tempCtx = tempCanvas.getContext('2d');
|
|
|
|
tempCtx.translate(-minX, -minY);
|
|
|
|
const sortedSelection = [...this.selectedLayers].sort((a, b) => a.zIndex - b.zIndex);
|
|
|
|
sortedSelection.forEach(layer => {
|
|
if (!layer.image) return;
|
|
|
|
tempCtx.save();
|
|
tempCtx.globalCompositeOperation = layer.blendMode || 'normal';
|
|
tempCtx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
|
|
|
const centerX = layer.x + layer.width / 2;
|
|
const centerY = layer.y + layer.height / 2;
|
|
tempCtx.translate(centerX, centerY);
|
|
tempCtx.rotate(layer.rotation * Math.PI / 180);
|
|
tempCtx.drawImage(
|
|
layer.image,
|
|
-layer.width / 2, -layer.height / 2,
|
|
layer.width, layer.height
|
|
);
|
|
tempCtx.restore();
|
|
});
|
|
tempCanvas.toBlob((blob) => {
|
|
resolve(blob);
|
|
}, 'image/png');
|
|
});
|
|
}
|
|
|
|
moveLayerUp() {
|
|
if (this.selectedLayers.length === 0) return;
|
|
const selectedIndicesSet = new Set(this.selectedLayers.map(layer => this.layers.indexOf(layer)));
|
|
|
|
const sortedIndices = Array.from(selectedIndicesSet).sort((a, b) => b - a);
|
|
|
|
sortedIndices.forEach(index => {
|
|
const targetIndex = index + 1;
|
|
|
|
if (targetIndex < this.layers.length && !selectedIndicesSet.has(targetIndex)) {
|
|
[this.layers[index], this.layers[targetIndex]] = [this.layers[targetIndex], this.layers[index]];
|
|
}
|
|
});
|
|
this.layers.forEach((layer, i) => layer.zIndex = i);
|
|
this.render();
|
|
}
|
|
|
|
moveLayerDown() {
|
|
if (this.selectedLayers.length === 0) return;
|
|
const selectedIndicesSet = new Set(this.selectedLayers.map(layer => this.layers.indexOf(layer)));
|
|
|
|
const sortedIndices = Array.from(selectedIndicesSet).sort((a, b) => a - b);
|
|
|
|
sortedIndices.forEach(index => {
|
|
const targetIndex = index - 1;
|
|
|
|
if (targetIndex >= 0 && !selectedIndicesSet.has(targetIndex)) {
|
|
[this.layers[index], this.layers[targetIndex]] = [this.layers[targetIndex], this.layers[index]];
|
|
}
|
|
});
|
|
this.layers.forEach((layer, i) => layer.zIndex = i);
|
|
this.render();
|
|
}
|
|
|
|
|
|
getLayerAtPosition(worldX, worldY) {
|
|
|
|
for (let i = this.layers.length - 1; i >= 0; i--) {
|
|
const layer = this.layers[i];
|
|
|
|
const centerX = layer.x + layer.width / 2;
|
|
const centerY = layer.y + layer.height / 2;
|
|
|
|
const dx = worldX - centerX;
|
|
const dy = worldY - centerY;
|
|
|
|
const rad = -layer.rotation * Math.PI / 180;
|
|
const rotatedX = dx * Math.cos(rad) - dy * Math.sin(rad);
|
|
const rotatedY = dx * Math.sin(rad) + dy * Math.cos(rad);
|
|
|
|
if (Math.abs(rotatedX) <= layer.width / 2 && Math.abs(rotatedY) <= layer.height / 2) {
|
|
const localX = rotatedX + layer.width / 2;
|
|
const localY = rotatedY + layer.height / 2;
|
|
|
|
return {
|
|
layer: layer,
|
|
localX: localX,
|
|
localY: localY
|
|
};
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
getResizeHandle(x, y) {
|
|
if (!this.selectedLayer) return null;
|
|
|
|
const handleRadius = 5;
|
|
const handles = {
|
|
'nw': {x: this.selectedLayer.x, y: this.selectedLayer.y},
|
|
'ne': {x: this.selectedLayer.x + this.selectedLayer.width, y: this.selectedLayer.y},
|
|
'se': {
|
|
x: this.selectedLayer.x + this.selectedLayer.width,
|
|
y: this.selectedLayer.y + this.selectedLayer.height
|
|
},
|
|
'sw': {x: this.selectedLayer.x, y: this.selectedLayer.y + this.selectedLayer.height}
|
|
};
|
|
|
|
for (const [position, point] of Object.entries(handles)) {
|
|
if (Math.sqrt(Math.pow(x - point.x, 2) + Math.pow(y - point.y, 2)) <= handleRadius) {
|
|
return position;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
mirrorHorizontal() {
|
|
if (this.selectedLayers.length === 0) return;
|
|
|
|
this.selectedLayers.forEach(layer => {
|
|
const tempCanvas = document.createElement('canvas');
|
|
const tempCtx = tempCanvas.getContext('2d');
|
|
tempCanvas.width = layer.image.width;
|
|
tempCanvas.height = layer.image.height;
|
|
|
|
tempCtx.translate(tempCanvas.width, 0);
|
|
tempCtx.scale(-1, 1);
|
|
tempCtx.drawImage(layer.image, 0, 0);
|
|
|
|
const newImage = new Image();
|
|
newImage.onload = () => {
|
|
layer.image = newImage;
|
|
this.render();
|
|
};
|
|
newImage.src = tempCanvas.toDataURL();
|
|
});
|
|
}
|
|
|
|
mirrorVertical() {
|
|
if (this.selectedLayers.length === 0) return;
|
|
|
|
this.selectedLayers.forEach(layer => {
|
|
const tempCanvas = document.createElement('canvas');
|
|
const tempCtx = tempCanvas.getContext('2d');
|
|
tempCanvas.width = layer.image.width;
|
|
tempCanvas.height = layer.image.height;
|
|
|
|
tempCtx.translate(0, tempCanvas.height);
|
|
tempCtx.scale(1, -1);
|
|
tempCtx.drawImage(layer.image, 0, 0);
|
|
|
|
const newImage = new Image();
|
|
newImage.onload = () => {
|
|
layer.image = newImage;
|
|
this.render();
|
|
};
|
|
newImage.src = tempCanvas.toDataURL();
|
|
});
|
|
}
|
|
|
|
async getLayerImageData(layer) {
|
|
try {
|
|
const tempCanvas = document.createElement('canvas');
|
|
const tempCtx = tempCanvas.getContext('2d');
|
|
|
|
tempCanvas.width = layer.width;
|
|
tempCanvas.height = layer.height;
|
|
|
|
tempCtx.clearRect(0, 0, tempCanvas.width, tempCanvas.height);
|
|
|
|
tempCtx.save();
|
|
tempCtx.translate(layer.width / 2, layer.height / 2);
|
|
tempCtx.rotate(layer.rotation * Math.PI / 180);
|
|
tempCtx.drawImage(
|
|
layer.image,
|
|
-layer.width / 2,
|
|
-layer.height / 2,
|
|
layer.width,
|
|
layer.height
|
|
);
|
|
tempCtx.restore();
|
|
|
|
const dataUrl = tempCanvas.toDataURL('image/png');
|
|
if (!dataUrl.startsWith('data:image/png;base64,')) {
|
|
throw new Error("Invalid image data format");
|
|
}
|
|
|
|
return dataUrl;
|
|
} catch (error) {
|
|
console.error("Error getting layer image data:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
addMattedLayer(image, mask) {
|
|
const layer = {
|
|
image: image,
|
|
mask: mask,
|
|
x: 0,
|
|
y: 0,
|
|
width: image.width,
|
|
height: image.height,
|
|
rotation: 0,
|
|
zIndex: this.layers.length
|
|
};
|
|
|
|
this.layers.push(layer);
|
|
this.selectedLayer = layer;
|
|
this.render();
|
|
}
|
|
|
|
processInputData(nodeData) {
|
|
if (nodeData.input_image) {
|
|
this.addInputImage(nodeData.input_image);
|
|
}
|
|
if (nodeData.input_mask) {
|
|
this.addInputMask(nodeData.input_mask);
|
|
}
|
|
}
|
|
|
|
addInputImage(imageData) {
|
|
const layer = new ImageLayer(imageData);
|
|
this.layers.push(layer);
|
|
this.updateCanvas();
|
|
}
|
|
|
|
addInputMask(maskData) {
|
|
if (this.inputImage) {
|
|
const mask = new MaskLayer(maskData);
|
|
mask.linkToLayer(this.inputImage);
|
|
this.masks.push(mask);
|
|
this.updateCanvas();
|
|
}
|
|
}
|
|
|
|
async addInputToCanvas(inputImage, inputMask) {
|
|
try {
|
|
console.log("Adding input to canvas:", {inputImage});
|
|
|
|
const tempCanvas = document.createElement('canvas');
|
|
const tempCtx = tempCanvas.getContext('2d');
|
|
tempCanvas.width = inputImage.width;
|
|
tempCanvas.height = inputImage.height;
|
|
|
|
const imgData = new ImageData(
|
|
inputImage.data,
|
|
inputImage.width,
|
|
inputImage.height
|
|
);
|
|
tempCtx.putImageData(imgData, 0, 0);
|
|
|
|
const image = new Image();
|
|
await new Promise((resolve, reject) => {
|
|
image.onload = resolve;
|
|
image.onerror = reject;
|
|
image.src = tempCanvas.toDataURL();
|
|
});
|
|
|
|
const scale = Math.min(
|
|
this.width / inputImage.width * 0.8,
|
|
this.height / inputImage.height * 0.8
|
|
);
|
|
|
|
const layer = {
|
|
image: image,
|
|
x: (this.width - inputImage.width * scale) / 2,
|
|
y: (this.height - inputImage.height * scale) / 2,
|
|
width: inputImage.width * scale,
|
|
height: inputImage.height * scale,
|
|
rotation: 0,
|
|
zIndex: this.layers.length
|
|
};
|
|
|
|
if (inputMask) {
|
|
layer.mask = inputMask.data;
|
|
}
|
|
|
|
this.layers.push(layer);
|
|
this.selectedLayer = layer;
|
|
|
|
this.render();
|
|
console.log("Layer added successfully");
|
|
|
|
return true;
|
|
|
|
} catch (error) {
|
|
console.error("Error in addInputToCanvas:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async convertTensorToImage(tensor) {
|
|
try {
|
|
console.log("Converting tensor to image:", tensor);
|
|
|
|
if (!tensor || !tensor.data || !tensor.width || !tensor.height) {
|
|
throw new Error("Invalid tensor data");
|
|
}
|
|
|
|
const canvas = document.createElement('canvas');
|
|
const ctx = canvas.getContext('2d');
|
|
canvas.width = tensor.width;
|
|
canvas.height = tensor.height;
|
|
|
|
const imageData = new ImageData(
|
|
new Uint8ClampedArray(tensor.data),
|
|
tensor.width,
|
|
tensor.height
|
|
);
|
|
|
|
ctx.putImageData(imageData, 0, 0);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const img = new Image();
|
|
img.onload = () => resolve(img);
|
|
img.onerror = (e) => reject(new Error("Failed to load image: " + e));
|
|
img.src = canvas.toDataURL();
|
|
});
|
|
} catch (error) {
|
|
console.error("Error converting tensor to image:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async convertTensorToMask(tensor) {
|
|
if (!tensor || !tensor.data) {
|
|
throw new Error("Invalid mask tensor");
|
|
}
|
|
|
|
try {
|
|
|
|
return new Float32Array(tensor.data);
|
|
} catch (error) {
|
|
throw new Error(`Mask conversion failed: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
async initNodeData() {
|
|
try {
|
|
console.log("Starting node data initialization...");
|
|
|
|
if (!this.node || !this.node.inputs) {
|
|
console.log("Node or inputs not ready");
|
|
return this.scheduleDataCheck();
|
|
}
|
|
|
|
if (this.node.inputs[0] && this.node.inputs[0].link) {
|
|
const imageLinkId = this.node.inputs[0].link;
|
|
const imageData = app.nodeOutputs[imageLinkId];
|
|
|
|
if (imageData) {
|
|
console.log("Found image data:", imageData);
|
|
await this.processImageData(imageData);
|
|
this.dataInitialized = true;
|
|
} else {
|
|
console.log("Image data not available yet");
|
|
return this.scheduleDataCheck();
|
|
}
|
|
}
|
|
|
|
if (this.node.inputs[1] && this.node.inputs[1].link) {
|
|
const maskLinkId = this.node.inputs[1].link;
|
|
const maskData = app.nodeOutputs[maskLinkId];
|
|
|
|
if (maskData) {
|
|
console.log("Found mask data:", maskData);
|
|
await this.processMaskData(maskData);
|
|
}
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error("Error in initNodeData:", error);
|
|
return this.scheduleDataCheck();
|
|
}
|
|
}
|
|
|
|
scheduleDataCheck() {
|
|
if (this.pendingDataCheck) {
|
|
clearTimeout(this.pendingDataCheck);
|
|
}
|
|
|
|
this.pendingDataCheck = setTimeout(() => {
|
|
this.pendingDataCheck = null;
|
|
if (!this.dataInitialized) {
|
|
this.initNodeData();
|
|
}
|
|
}, 1000);
|
|
}
|
|
|
|
async processImageData(imageData) {
|
|
try {
|
|
if (!imageData) return;
|
|
|
|
console.log("Processing image data:", {
|
|
type: typeof imageData,
|
|
isArray: Array.isArray(imageData),
|
|
shape: imageData.shape,
|
|
hasData: !!imageData.data
|
|
});
|
|
|
|
if (Array.isArray(imageData)) {
|
|
imageData = imageData[0];
|
|
}
|
|
|
|
if (!imageData.shape || !imageData.data) {
|
|
throw new Error("Invalid image data format");
|
|
}
|
|
|
|
const originalWidth = imageData.shape[2];
|
|
const originalHeight = imageData.shape[1];
|
|
|
|
const scale = Math.min(
|
|
this.width / originalWidth * 0.8,
|
|
this.height / originalHeight * 0.8
|
|
);
|
|
|
|
const convertedData = this.convertTensorToImageData(imageData);
|
|
if (convertedData) {
|
|
const image = await this.createImageFromData(convertedData);
|
|
|
|
this.addScaledLayer(image, scale);
|
|
console.log("Image layer added successfully with scale:", scale);
|
|
}
|
|
} catch (error) {
|
|
console.error("Error processing image data:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
addScaledLayer(image, scale) {
|
|
try {
|
|
const scaledWidth = image.width * scale;
|
|
const scaledHeight = image.height * scale;
|
|
|
|
const layer = {
|
|
image: image,
|
|
x: (this.width - scaledWidth) / 2,
|
|
y: (this.height - scaledHeight) / 2,
|
|
width: scaledWidth,
|
|
height: scaledHeight,
|
|
rotation: 0,
|
|
zIndex: this.layers.length,
|
|
originalWidth: image.width,
|
|
originalHeight: image.height
|
|
};
|
|
|
|
this.layers.push(layer);
|
|
this.selectedLayer = layer;
|
|
this.render();
|
|
|
|
console.log("Scaled layer added:", {
|
|
originalSize: `${image.width}x${image.height}`,
|
|
scaledSize: `${scaledWidth}x${scaledHeight}`,
|
|
scale: scale
|
|
});
|
|
} catch (error) {
|
|
console.error("Error adding scaled layer:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
convertTensorToImageData(tensor) {
|
|
try {
|
|
const shape = tensor.shape;
|
|
const height = shape[1];
|
|
const width = shape[2];
|
|
const channels = shape[3];
|
|
|
|
console.log("Converting tensor:", {
|
|
shape: shape,
|
|
dataRange: {
|
|
min: tensor.min_val,
|
|
max: tensor.max_val
|
|
}
|
|
});
|
|
|
|
const imageData = new ImageData(width, height);
|
|
const data = new Uint8ClampedArray(width * height * 4);
|
|
|
|
const flatData = tensor.data;
|
|
const pixelCount = width * height;
|
|
|
|
for (let i = 0; i < pixelCount; i++) {
|
|
const pixelIndex = i * 4;
|
|
const tensorIndex = i * channels;
|
|
|
|
for (let c = 0; c < channels; c++) {
|
|
const value = flatData[tensorIndex + c];
|
|
|
|
const normalizedValue = (value - tensor.min_val) / (tensor.max_val - tensor.min_val);
|
|
data[pixelIndex + c] = Math.round(normalizedValue * 255);
|
|
}
|
|
|
|
data[pixelIndex + 3] = 255;
|
|
}
|
|
|
|
imageData.data.set(data);
|
|
return imageData;
|
|
} catch (error) {
|
|
console.error("Error converting tensor:", error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async createImageFromData(imageData) {
|
|
return new Promise((resolve, reject) => {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = imageData.width;
|
|
canvas.height = imageData.height;
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.putImageData(imageData, 0, 0);
|
|
|
|
const img = new Image();
|
|
img.onload = () => resolve(img);
|
|
img.onerror = reject;
|
|
img.src = canvas.toDataURL();
|
|
});
|
|
}
|
|
|
|
async retryDataLoad(maxRetries = 3, delay = 1000) {
|
|
for (let i = 0; i < maxRetries; i++) {
|
|
try {
|
|
await this.initNodeData();
|
|
return;
|
|
} catch (error) {
|
|
console.warn(`Retry ${i + 1}/${maxRetries} failed:`, error);
|
|
if (i < maxRetries - 1) {
|
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
}
|
|
}
|
|
}
|
|
console.error("Failed to load data after", maxRetries, "retries");
|
|
}
|
|
|
|
async processMaskData(maskData) {
|
|
try {
|
|
if (!maskData) return;
|
|
|
|
console.log("Processing mask data:", maskData);
|
|
|
|
if (Array.isArray(maskData)) {
|
|
maskData = maskData[0];
|
|
}
|
|
|
|
if (!maskData.shape || !maskData.data) {
|
|
throw new Error("Invalid mask data format");
|
|
}
|
|
|
|
if (this.selectedLayer) {
|
|
const maskTensor = await this.convertTensorToMask(maskData);
|
|
this.selectedLayer.mask = maskTensor;
|
|
this.render();
|
|
console.log("Mask applied to selected layer");
|
|
}
|
|
} catch (error) {
|
|
console.error("Error processing mask data:", error);
|
|
}
|
|
}
|
|
|
|
async loadImageFromCache(base64Data) {
|
|
return new Promise((resolve, reject) => {
|
|
const img = new Image();
|
|
img.onload = () => resolve(img);
|
|
img.onerror = reject;
|
|
img.src = base64Data;
|
|
});
|
|
}
|
|
|
|
async importImage(cacheData) {
|
|
try {
|
|
console.log("Starting image import with cache data");
|
|
const img = await this.loadImageFromCache(cacheData.image);
|
|
const mask = cacheData.mask ? await this.loadImageFromCache(cacheData.mask) : null;
|
|
|
|
const scale = Math.min(
|
|
this.width / img.width * 0.8,
|
|
this.height / img.height * 0.8
|
|
);
|
|
|
|
const tempCanvas = document.createElement('canvas');
|
|
tempCanvas.width = img.width;
|
|
tempCanvas.height = img.height;
|
|
const tempCtx = tempCanvas.getContext('2d');
|
|
|
|
tempCtx.drawImage(img, 0, 0);
|
|
|
|
if (mask) {
|
|
const imageData = tempCtx.getImageData(0, 0, img.width, img.height);
|
|
const maskCanvas = document.createElement('canvas');
|
|
maskCanvas.width = img.width;
|
|
maskCanvas.height = img.height;
|
|
const maskCtx = maskCanvas.getContext('2d');
|
|
maskCtx.drawImage(mask, 0, 0);
|
|
const maskData = maskCtx.getImageData(0, 0, img.width, img.height);
|
|
|
|
for (let i = 0; i < imageData.data.length; i += 4) {
|
|
imageData.data[i + 3] = maskData.data[i];
|
|
}
|
|
|
|
tempCtx.putImageData(imageData, 0, 0);
|
|
}
|
|
|
|
const finalImage = new Image();
|
|
await new Promise((resolve) => {
|
|
finalImage.onload = resolve;
|
|
finalImage.src = tempCanvas.toDataURL();
|
|
});
|
|
|
|
const layer = {
|
|
image: finalImage,
|
|
x: (this.width - img.width * scale) / 2,
|
|
y: (this.height - img.height * scale) / 2,
|
|
width: img.width * scale,
|
|
height: img.height * scale,
|
|
rotation: 0,
|
|
zIndex: this.layers.length
|
|
};
|
|
|
|
this.layers.push(layer);
|
|
this.selectedLayer = layer;
|
|
this.render();
|
|
|
|
} catch (error) {
|
|
console.error('Error importing image:', error);
|
|
}
|
|
}
|
|
|
|
async importLatestImage() {
|
|
try {
|
|
console.log("Fetching latest image from server...");
|
|
const response = await fetch('/ycnode/get_latest_image');
|
|
const result = await response.json();
|
|
|
|
if (result.success && result.image_data) {
|
|
console.log("Latest image received, adding to canvas.");
|
|
const img = new Image();
|
|
await new Promise((resolve, reject) => {
|
|
img.onload = resolve;
|
|
img.onerror = reject;
|
|
img.src = result.image_data;
|
|
});
|
|
|
|
const layer = {
|
|
image: img,
|
|
x: 0,
|
|
y: 0,
|
|
width: this.width,
|
|
height: this.height,
|
|
rotation: 0,
|
|
zIndex: this.layers.length,
|
|
blendMode: 'normal',
|
|
opacity: 1
|
|
};
|
|
|
|
this.layers.push(layer);
|
|
this.selectedLayers = [layer];
|
|
this.selectedLayer = layer;
|
|
this.render();
|
|
console.log("Latest image imported and placed on canvas successfully.");
|
|
return true;
|
|
} else {
|
|
throw new Error(result.error || "Failed to fetch the latest image.");
|
|
}
|
|
} catch (error) {
|
|
console.error("Error importing latest image:", error);
|
|
alert(`Failed to import latest image: ${error.message}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
showBlendModeMenu(x, y) {
|
|
|
|
const existingMenu = document.getElementById('blend-mode-menu');
|
|
if (existingMenu) {
|
|
document.body.removeChild(existingMenu);
|
|
}
|
|
|
|
const menu = document.createElement('div');
|
|
menu.id = 'blend-mode-menu';
|
|
menu.style.cssText = `
|
|
position: fixed;
|
|
left: ${x}px;
|
|
top: ${y}px;
|
|
background: #2a2a2a;
|
|
border: 1px solid #3a3a3a;
|
|
border-radius: 4px;
|
|
padding: 5px;
|
|
z-index: 1000;
|
|
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
|
`;
|
|
|
|
this.blendModes.forEach(mode => {
|
|
const container = document.createElement('div');
|
|
container.className = 'blend-mode-container';
|
|
container.style.cssText = `
|
|
margin-bottom: 5px;
|
|
`;
|
|
|
|
const option = document.createElement('div');
|
|
option.style.cssText = `
|
|
padding: 5px 10px;
|
|
color: white;
|
|
cursor: pointer;
|
|
transition: background-color 0.2s;
|
|
`;
|
|
option.textContent = `${mode.label} (${mode.name})`;
|
|
|
|
const slider = document.createElement('input');
|
|
slider.type = 'range';
|
|
slider.min = '0';
|
|
slider.max = '100';
|
|
|
|
slider.value = this.selectedLayer.opacity ? Math.round(this.selectedLayer.opacity * 100) : 100;
|
|
slider.style.cssText = `
|
|
width: 100%;
|
|
margin: 5px 0;
|
|
display: none;
|
|
`;
|
|
|
|
if (this.selectedLayer.blendMode === mode.name) {
|
|
slider.style.display = 'block';
|
|
option.style.backgroundColor = '#3a3a3a';
|
|
}
|
|
|
|
option.onclick = () => {
|
|
|
|
menu.querySelectorAll('input[type="range"]').forEach(s => {
|
|
s.style.display = 'none';
|
|
});
|
|
menu.querySelectorAll('.blend-mode-container div').forEach(d => {
|
|
d.style.backgroundColor = '';
|
|
});
|
|
|
|
slider.style.display = 'block';
|
|
option.style.backgroundColor = '#3a3a3a';
|
|
|
|
if (this.selectedLayer) {
|
|
this.selectedLayer.blendMode = mode.name;
|
|
this.render();
|
|
}
|
|
};
|
|
|
|
slider.addEventListener('input', () => {
|
|
if (this.selectedLayer) {
|
|
this.selectedLayer.opacity = slider.value / 100;
|
|
this.render();
|
|
}
|
|
});
|
|
|
|
slider.addEventListener('change', async () => {
|
|
if (this.selectedLayer) {
|
|
this.selectedLayer.opacity = slider.value / 100;
|
|
this.render();
|
|
|
|
await this.saveToServer(this.widget.value);
|
|
if (this.node) {
|
|
app.graph.runStep();
|
|
}
|
|
}
|
|
});
|
|
|
|
container.appendChild(option);
|
|
container.appendChild(slider);
|
|
menu.appendChild(container);
|
|
});
|
|
|
|
document.body.appendChild(menu);
|
|
|
|
const closeMenu = (e) => {
|
|
if (!menu.contains(e.target)) {
|
|
document.body.removeChild(menu);
|
|
document.removeEventListener('mousedown', closeMenu);
|
|
}
|
|
};
|
|
setTimeout(() => {
|
|
document.addEventListener('mousedown', closeMenu);
|
|
}, 0);
|
|
}
|
|
|
|
handleBlendModeSelection(mode) {
|
|
if (this.selectedBlendMode === mode && !this.isAdjustingOpacity) {
|
|
this.applyBlendMode(mode, this.blendOpacity);
|
|
this.closeBlendModeMenu();
|
|
} else {
|
|
this.selectedBlendMode = mode;
|
|
this.isAdjustingOpacity = true;
|
|
this.showOpacitySlider(mode);
|
|
}
|
|
}
|
|
|
|
showOpacitySlider(mode) {
|
|
|
|
const slider = document.createElement('input');
|
|
slider.type = 'range';
|
|
slider.min = '0';
|
|
slider.max = '100';
|
|
slider.value = this.blendOpacity;
|
|
slider.className = 'blend-opacity-slider';
|
|
|
|
slider.addEventListener('input', (e) => {
|
|
this.blendOpacity = parseInt(e.target.value);
|
|
|
|
});
|
|
|
|
const modeElement = document.querySelector(`[data-blend-mode="${mode}"]`);
|
|
if (modeElement) {
|
|
modeElement.appendChild(slider);
|
|
}
|
|
}
|
|
|
|
applyBlendMode(mode, opacity) {
|
|
|
|
this.currentLayer.style.mixBlendMode = mode;
|
|
this.currentLayer.style.opacity = opacity / 100;
|
|
|
|
this.selectedBlendMode = null;
|
|
this.isAdjustingOpacity = false;
|
|
}
|
|
}
|