mirror of
https://github.com/Azornes/Comfyui-LayerForge.git
synced 2026-03-22 05:02:11 -03:00
The mask is now drawn after layers but before all preview outlines, ensuring correct visual stacking. The redundant mask rendering code after the preview outlines has been removed.
412 lines
20 KiB
JavaScript
412 lines
20 KiB
JavaScript
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
|
const log = createModuleLogger('CanvasRenderer');
|
|
export class CanvasRenderer {
|
|
constructor(canvas) {
|
|
this.canvas = canvas;
|
|
this.renderAnimationFrame = null;
|
|
this.lastRenderTime = 0;
|
|
this.renderInterval = 1000 / 60;
|
|
this.isDirty = false;
|
|
}
|
|
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.canvas.offscreenCanvas.width !== this.canvas.canvas.clientWidth ||
|
|
this.canvas.offscreenCanvas.height !== this.canvas.canvas.clientHeight) {
|
|
const newWidth = Math.max(1, this.canvas.canvas.clientWidth);
|
|
const newHeight = Math.max(1, this.canvas.canvas.clientHeight);
|
|
this.canvas.offscreenCanvas.width = newWidth;
|
|
this.canvas.offscreenCanvas.height = newHeight;
|
|
}
|
|
const ctx = this.canvas.offscreenCtx;
|
|
ctx.fillStyle = '#606060';
|
|
ctx.fillRect(0, 0, this.canvas.offscreenCanvas.width, this.canvas.offscreenCanvas.height);
|
|
ctx.save();
|
|
ctx.scale(this.canvas.viewport.zoom, this.canvas.viewport.zoom);
|
|
ctx.translate(-this.canvas.viewport.x, -this.canvas.viewport.y);
|
|
this.drawGrid(ctx);
|
|
// Use CanvasLayers to draw layers with proper blend area support
|
|
this.canvas.canvasLayers.drawLayersToContext(ctx, this.canvas.layers);
|
|
// Draw mask AFTER layers but BEFORE all preview outlines
|
|
const maskImage = this.canvas.maskTool.getMask();
|
|
if (maskImage && this.canvas.maskTool.isOverlayVisible) {
|
|
ctx.save();
|
|
if (this.canvas.maskTool.isActive) {
|
|
ctx.globalCompositeOperation = 'source-over';
|
|
ctx.globalAlpha = 0.5;
|
|
}
|
|
else {
|
|
ctx.globalCompositeOperation = 'source-over';
|
|
ctx.globalAlpha = 1.0;
|
|
}
|
|
// Renderuj maskę w jej pozycji światowej (bez przesunięcia względem bounds)
|
|
const maskWorldX = this.canvas.maskTool.x;
|
|
const maskWorldY = this.canvas.maskTool.y;
|
|
ctx.drawImage(maskImage, maskWorldX, maskWorldY);
|
|
ctx.globalAlpha = 1.0;
|
|
ctx.restore();
|
|
}
|
|
// Draw selection frames for selected layers
|
|
const sortedLayers = [...this.canvas.layers].sort((a, b) => a.zIndex - b.zIndex);
|
|
sortedLayers.forEach(layer => {
|
|
if (!layer.image || !layer.visible)
|
|
return;
|
|
if (this.canvas.canvasSelection.selectedLayers.includes(layer)) {
|
|
ctx.save();
|
|
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);
|
|
const scaleH = layer.flipH ? -1 : 1;
|
|
const scaleV = layer.flipV ? -1 : 1;
|
|
if (layer.flipH || layer.flipV) {
|
|
ctx.scale(scaleH, scaleV);
|
|
}
|
|
this.drawSelectionFrame(ctx, layer);
|
|
ctx.restore();
|
|
}
|
|
});
|
|
this.drawCanvasOutline(ctx);
|
|
this.drawOutputAreaExtensionPreview(ctx); // Draw extension preview
|
|
this.drawPendingGenerationAreas(ctx); // Draw snapshot outlines
|
|
this.renderInteractionElements(ctx);
|
|
this.canvas.shapeTool.render(ctx);
|
|
this.drawMaskAreaBounds(ctx); // Draw mask area bounds when mask tool is active
|
|
this.renderLayerInfo(ctx);
|
|
// Update custom shape menu position and visibility
|
|
if (this.canvas.outputAreaShape) {
|
|
this.canvas.customShapeMenu.show();
|
|
this.canvas.customShapeMenu.updateScreenPosition();
|
|
}
|
|
else {
|
|
this.canvas.customShapeMenu.hide();
|
|
}
|
|
ctx.restore();
|
|
if (this.canvas.canvas.width !== this.canvas.offscreenCanvas.width ||
|
|
this.canvas.canvas.height !== this.canvas.offscreenCanvas.height) {
|
|
this.canvas.canvas.width = this.canvas.offscreenCanvas.width;
|
|
this.canvas.canvas.height = this.canvas.offscreenCanvas.height;
|
|
}
|
|
this.canvas.ctx.drawImage(this.canvas.offscreenCanvas, 0, 0);
|
|
// Update Batch Preview UI positions
|
|
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
|
|
this.canvas.batchPreviewManagers.forEach((manager) => {
|
|
manager.updateScreenPosition(this.canvas.viewport);
|
|
});
|
|
}
|
|
}
|
|
renderInteractionElements(ctx) {
|
|
const interaction = this.canvas.interaction;
|
|
if (interaction.mode === 'resizingCanvas' && interaction.canvasResizeRect) {
|
|
const rect = interaction.canvasResizeRect;
|
|
ctx.save();
|
|
ctx.strokeStyle = 'rgba(0, 255, 0, 0.8)';
|
|
ctx.lineWidth = 2 / this.canvas.viewport.zoom;
|
|
ctx.setLineDash([8 / this.canvas.viewport.zoom, 4 / this.canvas.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.canvas.viewport.zoom);
|
|
ctx.save();
|
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
const screenX = (textWorldX - this.canvas.viewport.x) * this.canvas.viewport.zoom;
|
|
const screenY = (textWorldY - this.canvas.viewport.y) * this.canvas.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 (interaction.mode === 'movingCanvas' && interaction.canvasMoveRect) {
|
|
const rect = interaction.canvasMoveRect;
|
|
ctx.save();
|
|
ctx.strokeStyle = 'rgba(0, 150, 255, 0.8)';
|
|
ctx.lineWidth = 2 / this.canvas.viewport.zoom;
|
|
ctx.setLineDash([10 / this.canvas.viewport.zoom, 5 / this.canvas.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.canvas.viewport.zoom);
|
|
ctx.save();
|
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
const screenX = (textWorldX - this.canvas.viewport.x) * this.canvas.viewport.zoom;
|
|
const screenY = (textWorldY - this.canvas.viewport.y) * this.canvas.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();
|
|
}
|
|
}
|
|
renderLayerInfo(ctx) {
|
|
if (this.canvas.canvasSelection.selectedLayer) {
|
|
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
|
|
if (!layer.image || !layer.visible)
|
|
return;
|
|
const layerIndex = this.canvas.layers.indexOf(layer);
|
|
const currentWidth = Math.round(layer.width);
|
|
const currentHeight = Math.round(layer.height);
|
|
const rotation = Math.round(layer.rotation % 360);
|
|
let text = `${currentWidth}x${currentHeight} | ${rotation}° | Layer #${layerIndex + 1}`;
|
|
if (layer.originalWidth && layer.originalHeight) {
|
|
text += `\nOriginal: ${layer.originalWidth}x${layer.originalHeight}`;
|
|
}
|
|
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.canvas.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.canvas.viewport.x) * this.canvas.viewport.zoom;
|
|
const screenY = (textWorldY - this.canvas.viewport.y) * this.canvas.viewport.zoom;
|
|
ctx.font = "14px sans-serif";
|
|
ctx.textAlign = "center";
|
|
ctx.textBaseline = "middle";
|
|
const lines = text.split('\n');
|
|
const textMetrics = lines.map(line => ctx.measureText(line));
|
|
const textBgWidth = Math.max(...textMetrics.map(m => m.width)) + 10;
|
|
const lineHeight = 18;
|
|
const textBgHeight = lines.length * lineHeight + 4;
|
|
ctx.fillStyle = "rgba(0, 0, 0, 0.7)";
|
|
ctx.fillRect(screenX - textBgWidth / 2, screenY - textBgHeight / 2, textBgWidth, textBgHeight);
|
|
ctx.fillStyle = "white";
|
|
lines.forEach((line, index) => {
|
|
const yPos = screenY - (textBgHeight / 2) + (lineHeight / 2) + (index * lineHeight) + 2;
|
|
ctx.fillText(line, screenX, yPos);
|
|
});
|
|
ctx.restore();
|
|
});
|
|
}
|
|
}
|
|
drawGrid(ctx) {
|
|
const gridSize = 64;
|
|
const lineWidth = 0.5 / this.canvas.viewport.zoom;
|
|
const viewLeft = this.canvas.viewport.x;
|
|
const viewTop = this.canvas.viewport.y;
|
|
const viewRight = this.canvas.viewport.x + this.canvas.offscreenCanvas.width / this.canvas.viewport.zoom;
|
|
const viewBottom = this.canvas.viewport.y + this.canvas.offscreenCanvas.height / this.canvas.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.canvas.viewport.zoom;
|
|
ctx.setLineDash([10 / this.canvas.viewport.zoom, 5 / this.canvas.viewport.zoom]);
|
|
// Rysuj outline w pozycji outputAreaBounds
|
|
const bounds = this.canvas.outputAreaBounds;
|
|
ctx.rect(bounds.x, bounds.y, bounds.width, bounds.height);
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
if (this.canvas.outputAreaShape) {
|
|
ctx.save();
|
|
ctx.strokeStyle = 'rgba(0, 255, 255, 0.9)';
|
|
ctx.lineWidth = 2 / this.canvas.viewport.zoom;
|
|
ctx.setLineDash([]);
|
|
const shape = this.canvas.outputAreaShape;
|
|
const bounds = this.canvas.outputAreaBounds;
|
|
// Calculate custom shape position accounting for extensions
|
|
// Custom shape should maintain its relative position within the original canvas area
|
|
const ext = this.canvas.outputAreaExtensionEnabled ? this.canvas.outputAreaExtensions : { top: 0, bottom: 0, left: 0, right: 0 };
|
|
const shapeOffsetX = bounds.x + ext.left; // Add left extension to maintain relative position
|
|
const shapeOffsetY = bounds.y + ext.top; // Add top extension to maintain relative position
|
|
ctx.beginPath();
|
|
// Render custom shape with extension offset to maintain relative position
|
|
ctx.moveTo(shapeOffsetX + shape.points[0].x, shapeOffsetY + shape.points[0].y);
|
|
for (let i = 1; i < shape.points.length; i++) {
|
|
ctx.lineTo(shapeOffsetX + shape.points[i].x, shapeOffsetY + shape.points[i].y);
|
|
}
|
|
ctx.closePath();
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
}
|
|
drawSelectionFrame(ctx, layer) {
|
|
const lineWidth = 2 / this.canvas.viewport.zoom;
|
|
const handleRadius = 5 / this.canvas.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.canvas.viewport.zoom);
|
|
ctx.stroke();
|
|
const handles = this.canvas.canvasLayers.getHandles(layer);
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.strokeStyle = '#000000';
|
|
ctx.lineWidth = 1 / this.canvas.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();
|
|
}
|
|
}
|
|
drawOutputAreaExtensionPreview(ctx) {
|
|
if (!this.canvas.outputAreaExtensionPreview) {
|
|
return;
|
|
}
|
|
// Calculate preview bounds based on original canvas size + preview extensions
|
|
const baseWidth = this.canvas.originalCanvasSize ? this.canvas.originalCanvasSize.width : this.canvas.width;
|
|
const baseHeight = this.canvas.originalCanvasSize ? this.canvas.originalCanvasSize.height : this.canvas.height;
|
|
const ext = this.canvas.outputAreaExtensionPreview;
|
|
// Calculate preview bounds relative to original custom shape position, not (0,0)
|
|
const originalPos = this.canvas.originalOutputAreaPosition;
|
|
const previewBounds = {
|
|
x: originalPos.x - ext.left, // ✅ Względem oryginalnej pozycji custom shape
|
|
y: originalPos.y - ext.top, // ✅ Względem oryginalnej pozycji custom shape
|
|
width: baseWidth + ext.left + ext.right,
|
|
height: baseHeight + ext.top + ext.bottom
|
|
};
|
|
ctx.save();
|
|
ctx.strokeStyle = 'rgba(255, 255, 0, 0.8)'; // Yellow color for preview
|
|
ctx.lineWidth = 3 / this.canvas.viewport.zoom;
|
|
ctx.setLineDash([8 / this.canvas.viewport.zoom, 4 / this.canvas.viewport.zoom]);
|
|
ctx.strokeRect(previewBounds.x, previewBounds.y, previewBounds.width, previewBounds.height);
|
|
ctx.setLineDash([]);
|
|
ctx.restore();
|
|
}
|
|
drawPendingGenerationAreas(ctx) {
|
|
const areasToDraw = [];
|
|
// 1. Get areas from active managers
|
|
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
|
|
this.canvas.batchPreviewManagers.forEach((manager) => {
|
|
if (manager.generationArea) {
|
|
areasToDraw.push(manager.generationArea);
|
|
}
|
|
});
|
|
}
|
|
// 2. Get the area from the pending context (if it exists)
|
|
if (this.canvas.pendingBatchContext && this.canvas.pendingBatchContext.outputArea) {
|
|
areasToDraw.push(this.canvas.pendingBatchContext.outputArea);
|
|
}
|
|
if (areasToDraw.length === 0) {
|
|
return;
|
|
}
|
|
// 3. Draw all collected areas
|
|
areasToDraw.forEach(area => {
|
|
ctx.save();
|
|
ctx.strokeStyle = 'rgba(0, 150, 255, 0.9)'; // Blue color
|
|
ctx.lineWidth = 3 / this.canvas.viewport.zoom;
|
|
ctx.setLineDash([12 / this.canvas.viewport.zoom, 6 / this.canvas.viewport.zoom]);
|
|
ctx.strokeRect(area.x, area.y, area.width, area.height);
|
|
ctx.restore();
|
|
});
|
|
}
|
|
drawMaskAreaBounds(ctx) {
|
|
// Only show mask area bounds when mask tool is active
|
|
if (!this.canvas.maskTool.isActive) {
|
|
return;
|
|
}
|
|
const maskTool = this.canvas.maskTool;
|
|
// Get mask canvas bounds in world coordinates
|
|
const maskBounds = {
|
|
x: maskTool.x,
|
|
y: maskTool.y,
|
|
width: maskTool.getMask().width,
|
|
height: maskTool.getMask().height
|
|
};
|
|
ctx.save();
|
|
ctx.strokeStyle = 'rgba(255, 100, 100, 0.7)'; // Red color for mask area bounds
|
|
ctx.lineWidth = 2 / this.canvas.viewport.zoom;
|
|
ctx.setLineDash([6 / this.canvas.viewport.zoom, 6 / this.canvas.viewport.zoom]);
|
|
ctx.strokeRect(maskBounds.x, maskBounds.y, maskBounds.width, maskBounds.height);
|
|
ctx.setLineDash([]);
|
|
// Add text label to show this is the mask drawing area
|
|
const textWorldX = maskBounds.x + maskBounds.width / 2;
|
|
const textWorldY = maskBounds.y - (10 / this.canvas.viewport.zoom);
|
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
const screenX = (textWorldX - this.canvas.viewport.x) * this.canvas.viewport.zoom;
|
|
const screenY = (textWorldY - this.canvas.viewport.y) * this.canvas.viewport.zoom;
|
|
ctx.font = "12px sans-serif";
|
|
ctx.textAlign = "center";
|
|
ctx.textBaseline = "middle";
|
|
const text = "Mask Drawing Area";
|
|
const textMetrics = ctx.measureText(text);
|
|
const bgWidth = textMetrics.width + 8;
|
|
const bgHeight = 18;
|
|
ctx.fillStyle = "rgba(255, 100, 100, 0.8)";
|
|
ctx.fillRect(screenX - bgWidth / 2, screenY - bgHeight / 2, bgWidth, bgHeight);
|
|
ctx.fillStyle = "white";
|
|
ctx.fillText(text, screenX, screenY);
|
|
ctx.restore();
|
|
}
|
|
}
|