Refactor output area and mask handling for flexible canvas bounds

This update introduces a unified output area bounds system, allowing the output area to be extended in all directions independently of the custom shape. All mask and layer operations now reference outputAreaBounds, ensuring correct alignment and rendering. The mask tool, mask editor, and export logic have been refactored to use these bounds, and a new UI for output area extension with live preview and tooltips has been added. The code also improves logging and visualization of mask and output area boundaries.
This commit is contained in:
Dariusz L
2025-07-26 18:27:14 +02:00
parent 1fc06f65a2
commit 14c5f291a6
17 changed files with 1637 additions and 597 deletions

View File

@@ -63,15 +63,20 @@ export class Canvas {
this.pendingDataCheck = null;
this.imageCache = new Map();
this.requestSaveState = () => { };
this.maskTool = new MaskTool(this, { onStateChange: this.onStateChange });
this.shapeTool = new ShapeTool(this);
this.customShapeMenu = new CustomShapeMenu(this);
this.outputAreaShape = null;
this.autoApplyShapeMask = false;
this.shapeMaskExpansion = false;
this.shapeMaskExpansionValue = 0;
this.shapeMaskFeather = false;
this.shapeMaskFeatherValue = 0;
this.outputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 };
this.outputAreaExtensionEnabled = false;
this.outputAreaExtensionPreview = null;
this.originalCanvasSize = { width: this.width, height: this.height };
this.outputAreaBounds = { x: 0, y: 0, width: this.width, height: this.height };
this.maskTool = new MaskTool(this, { onStateChange: this.onStateChange });
this.shapeTool = new ShapeTool(this);
this.customShapeMenu = new CustomShapeMenu(this);
this.canvasMask = new CanvasMask(this);
this.canvasState = new CanvasState(this);
this.canvasSelection = new CanvasSelection(this);
@@ -359,7 +364,10 @@ export class Canvas {
* @param {boolean} saveHistory - Czy zapisać w historii
*/
updateOutputAreaSize(width, height, saveHistory = true) {
return this.canvasLayers.updateOutputAreaSize(width, height, saveHistory);
const result = this.canvasLayers.updateOutputAreaSize(width, height, saveHistory);
// Update mask canvas to ensure it covers the new output area
this.maskTool.updateMaskCanvasForOutputArea();
return result;
}
/**
* Eksportuje spłaszczony canvas jako blob

View File

@@ -204,70 +204,32 @@ export class CanvasIO {
});
}
async _renderOutputData() {
return new Promise((resolve) => {
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(this.canvas.width, this.canvas.height);
const { canvas: maskCanvas, ctx: maskCtx } = createCanvas(this.canvas.width, this.canvas.height);
const originalShape = this.canvas.outputAreaShape;
this.canvas.outputAreaShape = null;
const visibilityCanvas = document.createElement('canvas');
visibilityCanvas.width = this.canvas.width;
visibilityCanvas.height = this.canvas.height;
const visibilityCtx = visibilityCanvas.getContext('2d', { alpha: true });
if (!visibilityCtx)
throw new Error("Could not create visibility context");
if (!maskCtx)
throw new Error("Could not create mask context");
if (!tempCtx)
throw new Error("Could not create temp context");
maskCtx.fillStyle = '#ffffff'; // Start with a white mask (nothing masked)
maskCtx.fillRect(0, 0, this.canvas.width, this.canvas.height);
this.canvas.canvasLayers.drawLayersToContext(tempCtx, this.canvas.layers);
this.canvas.canvasLayers.drawLayersToContext(visibilityCtx, this.canvas.layers);
const visibilityData = visibilityCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
const maskData = maskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
for (let i = 0; i < visibilityData.data.length; i += 4) {
const alpha = visibilityData.data[i + 3];
const maskValue = 255 - alpha; // Invert alpha to create the mask
maskData.data[i] = maskData.data[i + 1] = maskData.data[i + 2] = maskValue;
maskData.data[i + 3] = 255; // Solid mask
}
maskCtx.putImageData(maskData, 0, 0);
const toolMaskCanvas = this.canvas.maskTool.getMask();
if (toolMaskCanvas) {
const tempMaskCanvas = document.createElement('canvas');
tempMaskCanvas.width = this.canvas.width;
tempMaskCanvas.height = this.canvas.height;
const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true });
if (!tempMaskCtx)
throw new Error("Could not create temp mask context");
tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height);
const maskX = this.canvas.maskTool.x;
const maskY = this.canvas.maskTool.y;
log.debug(`[renderOutputData] Extracting mask from world position (${maskX}, ${maskY})`);
const sourceX = Math.max(0, -maskX);
const sourceY = Math.max(0, -maskY);
const destX = Math.max(0, maskX);
const destY = Math.max(0, maskY);
const copyWidth = Math.min(toolMaskCanvas.width - sourceX, this.canvas.width - destX);
const copyHeight = Math.min(toolMaskCanvas.height - sourceY, this.canvas.height - destY);
if (copyWidth > 0 && copyHeight > 0) {
tempMaskCtx.drawImage(toolMaskCanvas, sourceX, sourceY, copyWidth, copyHeight, destX, destY, copyWidth, copyHeight);
}
const tempMaskData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
for (let i = 0; i < tempMaskData.data.length; i += 4) {
const alpha = tempMaskData.data[i + 3];
tempMaskData.data[i] = tempMaskData.data[i + 1] = tempMaskData.data[i + 2] = alpha;
tempMaskData.data[i + 3] = 255; // Solid alpha
}
tempMaskCtx.putImageData(tempMaskData, 0, 0);
maskCtx.globalCompositeOperation = 'screen';
maskCtx.drawImage(tempMaskCanvas, 0, 0);
}
const imageDataUrl = tempCanvas.toDataURL('image/png');
const maskDataUrl = maskCanvas.toDataURL('image/png');
this.canvas.outputAreaShape = originalShape;
resolve({ image: imageDataUrl, mask: maskDataUrl });
log.info("=== RENDERING OUTPUT DATA FOR COMFYUI ===");
// Użyj zunifikowanych funkcji z CanvasLayers
const imageBlob = await this.canvas.canvasLayers.getFlattenedCanvasAsBlob();
const maskBlob = await this.canvas.canvasLayers.getFlattenedMaskAsBlob();
if (!imageBlob || !maskBlob) {
throw new Error("Failed to generate canvas or mask blobs");
}
// Konwertuj blob na data URL
const imageDataUrl = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(imageBlob);
});
const maskDataUrl = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(maskBlob);
});
const bounds = this.canvas.outputAreaBounds;
log.info(`=== OUTPUT DATA GENERATED ===`);
log.info(`Image size: ${bounds.width}x${bounds.height}`);
log.info(`Image data URL length: ${imageDataUrl.length}`);
log.info(`Mask data URL length: ${maskDataUrl.length}`);
return { image: imageDataUrl, mask: maskDataUrl };
}
async sendDataViaWebSocket(nodeId) {
log.info(`Preparing to send data for node ${nodeId} via WebSocket.`);
@@ -302,10 +264,11 @@ export class CanvasIO {
image.onerror = reject;
image.src = tempCanvas.toDataURL();
});
const scale = Math.min(this.canvas.width / inputImage.width * 0.8, this.canvas.height / inputImage.height * 0.8);
const bounds = this.canvas.outputAreaBounds;
const scale = Math.min(bounds.width / inputImage.width * 0.8, bounds.height / inputImage.height * 0.8);
const layer = await this.canvas.canvasLayers.addLayerWithImage(image, {
x: (this.canvas.width - inputImage.width * scale) / 2,
y: (this.canvas.height - inputImage.height * scale) / 2,
x: bounds.x + (bounds.width - inputImage.width * scale) / 2,
y: bounds.y + (bounds.height - inputImage.height * scale) / 2,
width: inputImage.width * scale,
height: inputImage.height * scale,
});

View File

@@ -162,6 +162,7 @@ export class CanvasInteractions {
}
handleMouseUp(e) {
const viewCoords = this.canvas.getMouseViewCoordinates(e);
const worldCoords = this.canvas.getMouseWorldCoordinates(e);
if (this.interaction.mode === 'drawingMask') {
this.canvas.maskTool.handleMouseUp(viewCoords);
this.canvas.render();
@@ -173,6 +174,22 @@ export class CanvasInteractions {
if (this.interaction.mode === 'movingCanvas') {
this.finalizeCanvasMove();
}
// Log layer positions when dragging ends
if (this.interaction.mode === 'dragging' && this.canvas.canvasSelection.selectedLayers.length > 0) {
const bounds = this.canvas.outputAreaBounds;
log.info("=== LAYER DRAG COMPLETED ===");
log.info(`Mouse position: world(${worldCoords.x.toFixed(1)}, ${worldCoords.y.toFixed(1)}) view(${viewCoords.x.toFixed(1)}, ${viewCoords.y.toFixed(1)})`);
log.info(`Output Area Bounds: x=${bounds.x}, y=${bounds.y}, w=${bounds.width}, h=${bounds.height}`);
log.info(`Viewport: x=${this.canvas.viewport.x.toFixed(1)}, y=${this.canvas.viewport.y.toFixed(1)}, zoom=${this.canvas.viewport.zoom.toFixed(2)}`);
this.canvas.canvasSelection.selectedLayers.forEach((layer, index) => {
const relativeToOutput = {
x: layer.x - bounds.x,
y: layer.y - bounds.y
};
log.info(`Layer ${index + 1} "${layer.name}": world(${layer.x.toFixed(1)}, ${layer.y.toFixed(1)}) relative_to_output(${relativeToOutput.x.toFixed(1)}, ${relativeToOutput.y.toFixed(1)}) size(${layer.width.toFixed(1)}x${layer.height.toFixed(1)})`);
});
log.info("=== END LAYER DRAG ===");
}
// Zapisz stan tylko, jeśli faktycznie doszło do zmiany (przeciąganie, transformacja, duplikacja)
const stateChangingInteraction = ['dragging', 'resizing', 'rotating'].includes(this.interaction.mode);
const duplicatedInDrag = this.interaction.hasClonedInDrag;
@@ -515,60 +532,34 @@ export class CanvasInteractions {
startCanvasMove(worldCoords) {
this.interaction.mode = 'movingCanvas';
this.interaction.dragStart = { ...worldCoords };
const initialX = snapToGrid(worldCoords.x - this.canvas.width / 2);
const initialY = snapToGrid(worldCoords.y - this.canvas.height / 2);
this.interaction.canvasMoveRect = {
x: initialX,
y: initialY,
width: this.canvas.width,
height: this.canvas.height
};
this.canvas.canvas.style.cursor = 'grabbing';
this.canvas.render();
}
updateCanvasMove(worldCoords) {
if (!this.interaction.canvasMoveRect)
return;
const dx = worldCoords.x - this.interaction.dragStart.x;
const dy = worldCoords.y - this.interaction.dragStart.y;
const initialRectX = snapToGrid(this.interaction.dragStart.x - this.canvas.width / 2);
const initialRectY = snapToGrid(this.interaction.dragStart.y - this.canvas.height / 2);
this.interaction.canvasMoveRect.x = snapToGrid(initialRectX + dx);
this.interaction.canvasMoveRect.y = snapToGrid(initialRectY + dy);
// Po prostu przesuwamy outputAreaBounds
const bounds = this.canvas.outputAreaBounds;
this.interaction.canvasMoveRect = {
x: snapToGrid(bounds.x + dx),
y: snapToGrid(bounds.y + dy),
width: bounds.width,
height: bounds.height
};
this.canvas.render();
}
finalizeCanvasMove() {
const moveRect = this.interaction.canvasMoveRect;
if (moveRect && (moveRect.x !== 0 || moveRect.y !== 0)) {
const finalX = moveRect.x;
const finalY = moveRect.y;
this.canvas.layers.forEach((layer) => {
layer.x -= finalX;
layer.y -= finalY;
});
this.canvas.maskTool.updatePosition(-finalX, -finalY);
// If a batch generation is in progress, update the captured context as well
if (this.canvas.pendingBatchContext) {
this.canvas.pendingBatchContext.outputArea.x -= finalX;
this.canvas.pendingBatchContext.outputArea.y -= finalY;
// Also update the menu spawn position to keep it relative
this.canvas.pendingBatchContext.spawnPosition.x -= finalX;
this.canvas.pendingBatchContext.spawnPosition.y -= finalY;
log.debug("Updated pending batch context during canvas move:", this.canvas.pendingBatchContext);
}
// Also move any active batch preview menus
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
this.canvas.batchPreviewManagers.forEach((manager) => {
manager.worldX -= finalX;
manager.worldY -= finalY;
if (manager.generationArea) {
manager.generationArea.x -= finalX;
manager.generationArea.y -= finalY;
}
});
}
this.canvas.viewport.x -= finalX;
this.canvas.viewport.y -= finalY;
if (moveRect) {
// Po prostu aktualizujemy outputAreaBounds na nową pozycję
this.canvas.outputAreaBounds = {
x: moveRect.x,
y: moveRect.y,
width: moveRect.width,
height: moveRect.height
};
// Update mask canvas to ensure it covers the new output area position
this.canvas.maskTool.updateMaskCanvasForOutputArea();
}
this.canvas.render();
this.canvas.saveState();
@@ -724,35 +715,17 @@ export class CanvasInteractions {
const newHeight = Math.round(this.interaction.canvasResizeRect.height);
const finalX = this.interaction.canvasResizeRect.x;
const finalY = this.interaction.canvasResizeRect.y;
// Po prostu aktualizujemy outputAreaBounds na nowy obszar
this.canvas.outputAreaBounds = {
x: finalX,
y: finalY,
width: newWidth,
height: newHeight
};
this.canvas.updateOutputAreaSize(newWidth, newHeight);
this.canvas.layers.forEach((layer) => {
layer.x -= finalX;
layer.y -= finalY;
});
this.canvas.maskTool.updatePosition(-finalX, -finalY);
// If a batch generation is in progress, update the captured context as well
if (this.canvas.pendingBatchContext) {
this.canvas.pendingBatchContext.outputArea.x -= finalX;
this.canvas.pendingBatchContext.outputArea.y -= finalY;
// Also update the menu spawn position to keep it relative
this.canvas.pendingBatchContext.spawnPosition.x -= finalX;
this.canvas.pendingBatchContext.spawnPosition.y -= finalY;
log.debug("Updated pending batch context during canvas resize:", this.canvas.pendingBatchContext);
}
// Also move any active batch preview menus
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
this.canvas.batchPreviewManagers.forEach((manager) => {
manager.worldX -= finalX;
manager.worldY -= finalY;
if (manager.generationArea) {
manager.generationArea.x -= finalX;
manager.generationArea.y -= finalY;
}
});
}
this.canvas.viewport.x -= finalX;
this.canvas.viewport.y -= finalY;
}
this.canvas.render();
this.canvas.saveState();
}
handleDragOver(e) {
e.preventDefault();

View File

@@ -22,8 +22,9 @@ export class CanvasLayers {
let finalWidth = image.width;
let finalHeight = image.height;
let finalX, finalY;
// Use the targetArea if provided, otherwise default to the current canvas dimensions
const area = targetArea || { width: this.canvas.width, height: this.canvas.height, x: 0, y: 0 };
// Use the targetArea if provided, otherwise default to the current output area bounds
const bounds = this.canvas.outputAreaBounds;
const area = targetArea || { width: bounds.width, height: bounds.height, x: bounds.x, y: bounds.y };
if (addMode === 'fit') {
const scale = Math.min(area.width / image.width, area.height / image.height);
finalWidth = image.width * scale;
@@ -778,57 +779,129 @@ export class CanvasLayers {
modeElement.appendChild(slider);
}
}
async getFlattenedCanvasWithMaskAsBlob() {
/**
* Zunifikowana funkcja do generowania blob z canvas
* @param options Opcje renderowania
*/
async _generateCanvasBlob(options = {}) {
const { layers = this.canvas.layers, useOutputBounds = true, applyMask = false, enableLogging = false, customBounds } = options;
return new Promise((resolve, reject) => {
let bounds;
if (customBounds) {
bounds = customBounds;
}
else if (useOutputBounds) {
bounds = this.canvas.outputAreaBounds;
}
else {
// Oblicz bounding box dla wybranych warstw
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
layers.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;
}
bounds = { x: minX, y: minY, width: newWidth, height: newHeight };
}
const tempCanvas = document.createElement('canvas');
tempCanvas.width = this.canvas.width;
tempCanvas.height = this.canvas.height;
tempCanvas.width = bounds.width;
tempCanvas.height = bounds.height;
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
if (!tempCtx) {
reject(new Error("Could not create canvas context"));
return;
}
this._drawLayers(tempCtx, this.canvas.layers);
const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
const data = imageData.data;
const toolMaskCanvas = this.canvas.maskTool.getMask();
if (toolMaskCanvas) {
const tempMaskCanvas = document.createElement('canvas');
tempMaskCanvas.width = this.canvas.width;
tempMaskCanvas.height = this.canvas.height;
const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true });
if (!tempMaskCtx) {
reject(new Error("Could not create mask canvas context"));
return;
if (enableLogging) {
log.info("=== GENERATING OUTPUT CANVAS ===");
log.info(`Bounds: x=${bounds.x}, y=${bounds.y}, w=${bounds.width}, h=${bounds.height}`);
log.info(`Canvas Size: ${tempCanvas.width}x${tempCanvas.height}`);
log.info(`Context Translation: translate(${-bounds.x}, ${-bounds.y})`);
log.info(`Apply Mask: ${applyMask}`);
// Log layer positions before rendering
layers.forEach((layer, index) => {
if (layer.visible) {
const relativeToOutput = {
x: layer.x - bounds.x,
y: layer.y - bounds.y
};
log.info(`Layer ${index + 1} "${layer.name}": world(${layer.x.toFixed(1)}, ${layer.y.toFixed(1)}) relative_to_bounds(${relativeToOutput.x.toFixed(1)}, ${relativeToOutput.y.toFixed(1)}) size(${layer.width.toFixed(1)}x${layer.height.toFixed(1)})`);
}
});
}
// Renderuj fragment świata zdefiniowany przez bounds
tempCtx.translate(-bounds.x, -bounds.y);
this._drawLayers(tempCtx, layers);
// Aplikuj maskę jeśli wymagana
if (applyMask) {
const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
const data = imageData.data;
const toolMaskCanvas = this.canvas.maskTool.getMask();
if (toolMaskCanvas) {
const tempMaskCanvas = document.createElement('canvas');
tempMaskCanvas.width = bounds.width;
tempMaskCanvas.height = bounds.height;
const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true });
if (!tempMaskCtx) {
reject(new Error("Could not create mask canvas context"));
return;
}
tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height);
// Pozycja maski w świecie (bez przesunięcia względem bounds)
const maskWorldX = this.canvas.maskTool.x;
const maskWorldY = this.canvas.maskTool.y;
// Pozycja maski względem output bounds (gdzie ma być narysowana w output canvas)
const maskX = maskWorldX - bounds.x;
const maskY = maskWorldY - bounds.y;
const sourceX = Math.max(0, -maskX);
const sourceY = Math.max(0, -maskY);
const destX = Math.max(0, maskX);
const destY = Math.max(0, maskY);
const copyWidth = Math.min(toolMaskCanvas.width - sourceX, bounds.width - destX);
const copyHeight = Math.min(toolMaskCanvas.height - sourceY, bounds.height - destY);
if (copyWidth > 0 && copyHeight > 0) {
tempMaskCtx.drawImage(toolMaskCanvas, sourceX, sourceY, copyWidth, copyHeight, destX, destY, copyWidth, copyHeight);
}
const tempMaskData = tempMaskCtx.getImageData(0, 0, bounds.width, bounds.height);
for (let i = 0; i < tempMaskData.data.length; i += 4) {
const alpha = tempMaskData.data[i + 3];
tempMaskData.data[i] = tempMaskData.data[i + 1] = tempMaskData.data[i + 2] = 255;
tempMaskData.data[i + 3] = alpha;
}
tempMaskCtx.putImageData(tempMaskData, 0, 0);
const maskImageData = tempMaskCtx.getImageData(0, 0, bounds.width, bounds.height);
const maskData = maskImageData.data;
for (let i = 0; i < data.length; i += 4) {
const originalAlpha = data[i + 3];
const maskAlpha = maskData[i + 3] / 255;
const invertedMaskAlpha = 1 - maskAlpha;
data[i + 3] = originalAlpha * invertedMaskAlpha;
}
tempCtx.putImageData(imageData, 0, 0);
}
tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height);
const maskX = this.canvas.maskTool.x;
const maskY = this.canvas.maskTool.y;
const sourceX = Math.max(0, -maskX);
const sourceY = Math.max(0, -maskY);
const destX = Math.max(0, maskX);
const destY = Math.max(0, maskY);
const copyWidth = Math.min(toolMaskCanvas.width - sourceX, this.canvas.width - destX);
const copyHeight = Math.min(toolMaskCanvas.height - sourceY, this.canvas.height - destY);
if (copyWidth > 0 && copyHeight > 0) {
tempMaskCtx.drawImage(toolMaskCanvas, sourceX, sourceY, copyWidth, copyHeight, destX, destY, copyWidth, copyHeight);
}
const tempMaskData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
for (let i = 0; i < tempMaskData.data.length; i += 4) {
const alpha = tempMaskData.data[i + 3];
tempMaskData.data[i] = tempMaskData.data[i + 1] = tempMaskData.data[i + 2] = 255;
tempMaskData.data[i + 3] = alpha;
}
tempMaskCtx.putImageData(tempMaskData, 0, 0);
const maskImageData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
const maskData = maskImageData.data;
for (let i = 0; i < data.length; i += 4) {
const originalAlpha = data[i + 3];
const maskAlpha = maskData[i + 3] / 255;
const invertedMaskAlpha = 1 - maskAlpha;
data[i + 3] = originalAlpha * invertedMaskAlpha;
}
tempCtx.putImageData(imageData, 0, 0);
}
tempCanvas.toBlob((blob) => {
if (blob) {
@@ -840,77 +913,118 @@ export class CanvasLayers {
}, 'image/png');
});
}
// Publiczne metody używające zunifikowanej funkcji
async getFlattenedCanvasWithMaskAsBlob() {
return this._generateCanvasBlob({
layers: this.canvas.layers,
useOutputBounds: true,
applyMask: true,
enableLogging: true
});
}
async getFlattenedCanvasAsBlob() {
return new Promise((resolve, reject) => {
const tempCanvas = document.createElement('canvas');
tempCanvas.width = this.canvas.width;
tempCanvas.height = this.canvas.height;
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
if (!tempCtx) {
reject(new Error("Could not create canvas context"));
return;
}
this._drawLayers(tempCtx, this.canvas.layers);
tempCanvas.toBlob((blob) => {
if (blob) {
resolve(blob);
}
else {
resolve(null);
}
}, 'image/png');
return this._generateCanvasBlob({
layers: this.canvas.layers,
useOutputBounds: true,
applyMask: false,
enableLogging: true
});
}
async getFlattenedCanvasForMaskEditor() {
return this.getFlattenedCanvasWithMaskAsBlob();
}
async getFlattenedSelectionAsBlob() {
if (this.canvas.canvasSelection.selectedLayers.length === 0) {
return null;
}
return this._generateCanvasBlob({
layers: this.canvas.canvasSelection.selectedLayers,
useOutputBounds: false,
applyMask: false,
enableLogging: false
});
}
async getFlattenedMaskAsBlob() {
return new Promise((resolve, reject) => {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
this.canvas.canvasSelection.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);
const bounds = this.canvas.outputAreaBounds;
const maskCanvas = document.createElement('canvas');
maskCanvas.width = bounds.width;
maskCanvas.height = bounds.height;
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
if (!maskCtx) {
reject(new Error("Could not create mask context"));
return;
}
const tempCanvas = document.createElement('canvas');
tempCanvas.width = newWidth;
tempCanvas.height = newHeight;
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
if (!tempCtx) {
reject(new Error("Could not create canvas context"));
log.info("=== GENERATING MASK BLOB ===");
log.info(`Mask Canvas Size: ${maskCanvas.width}x${maskCanvas.height}`);
// Rozpocznij z białą maską (nic nie zamaskowane)
maskCtx.fillStyle = '#ffffff';
maskCtx.fillRect(0, 0, bounds.width, bounds.height);
// Stwórz canvas do sprawdzenia przezroczystości warstw
const visibilityCanvas = document.createElement('canvas');
visibilityCanvas.width = bounds.width;
visibilityCanvas.height = bounds.height;
const visibilityCtx = visibilityCanvas.getContext('2d', { alpha: true });
if (!visibilityCtx) {
reject(new Error("Could not create visibility context"));
return;
}
tempCtx.translate(-minX, -minY);
this._drawLayers(tempCtx, this.canvas.canvasSelection.selectedLayers);
tempCanvas.toBlob((blob) => {
resolve(blob);
// Renderuj warstwy z przesunięciem dla output bounds
visibilityCtx.translate(-bounds.x, -bounds.y);
this._drawLayers(visibilityCtx, this.canvas.layers);
// Konwertuj przezroczystość warstw na maskę
const visibilityData = visibilityCtx.getImageData(0, 0, bounds.width, bounds.height);
const maskData = maskCtx.getImageData(0, 0, bounds.width, bounds.height);
for (let i = 0; i < visibilityData.data.length; i += 4) {
const alpha = visibilityData.data[i + 3];
const maskValue = 255 - alpha; // Odwróć alpha żeby stworzyć maskę
maskData.data[i] = maskData.data[i + 1] = maskData.data[i + 2] = maskValue;
maskData.data[i + 3] = 255; // Solidna maska
}
maskCtx.putImageData(maskData, 0, 0);
// Aplikuj maskę narzędzia jeśli istnieje
const toolMaskCanvas = this.canvas.maskTool.getMask();
if (toolMaskCanvas) {
const tempMaskCanvas = document.createElement('canvas');
tempMaskCanvas.width = bounds.width;
tempMaskCanvas.height = bounds.height;
const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true });
if (!tempMaskCtx) {
reject(new Error("Could not create temp mask context"));
return;
}
tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height);
// Pozycja maski w świecie (bez przesunięcia względem bounds)
const maskWorldX = this.canvas.maskTool.x;
const maskWorldY = this.canvas.maskTool.y;
// Pozycja maski względem output bounds (gdzie ma być narysowana w output canvas)
const maskX = maskWorldX - bounds.x;
const maskY = maskWorldY - bounds.y;
log.debug(`[getFlattenedMaskAsBlob] Mask world position (${maskWorldX}, ${maskWorldY}) relative to bounds (${maskX}, ${maskY})`);
const sourceX = Math.max(0, -maskX);
const sourceY = Math.max(0, -maskY);
const destX = Math.max(0, maskX);
const destY = Math.max(0, maskY);
const copyWidth = Math.min(toolMaskCanvas.width - sourceX, bounds.width - destX);
const copyHeight = Math.min(toolMaskCanvas.height - sourceY, bounds.height - destY);
if (copyWidth > 0 && copyHeight > 0) {
tempMaskCtx.drawImage(toolMaskCanvas, sourceX, sourceY, copyWidth, copyHeight, destX, destY, copyWidth, copyHeight);
}
const tempMaskData = tempMaskCtx.getImageData(0, 0, bounds.width, bounds.height);
for (let i = 0; i < tempMaskData.data.length; i += 4) {
const alpha = tempMaskData.data[i + 3];
tempMaskData.data[i] = tempMaskData.data[i + 1] = tempMaskData.data[i + 2] = alpha;
tempMaskData.data[i + 3] = 255; // Solidna alpha
}
tempMaskCtx.putImageData(tempMaskData, 0, 0);
maskCtx.globalCompositeOperation = 'screen';
maskCtx.drawImage(tempMaskCanvas, 0, 0);
}
log.info("=== MASK BLOB GENERATED ===");
maskCanvas.toBlob((blob) => {
if (blob) {
resolve(blob);
}
else {
resolve(null);
}
}, 'image/png');
});
}

View File

@@ -48,7 +48,7 @@ export class CanvasMask {
}
else {
log.debug('Getting flattened canvas for mask editor (with mask)');
blob = await this.canvas.canvasLayers.getFlattenedCanvasForMaskEditor();
blob = await this.canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
}
if (!blob) {
log.warn("Canvas is empty, cannot open mask editor.");
@@ -251,9 +251,12 @@ export class CanvasMask {
* @param {Object} maskColor - Kolor maski {r, g, b}
* @returns {HTMLCanvasElement} Przetworzona maska
*/ async processMaskForEditor(maskData, targetWidth, targetHeight, maskColor) {
// Współrzędne przesunięcia (pan) widoku edytora
const panX = this.maskTool.x;
const panY = this.maskTool.y;
// Pozycja maski w świecie względem output bounds
const bounds = this.canvas.outputAreaBounds;
const maskWorldX = this.maskTool.x;
const maskWorldY = this.maskTool.y;
const panX = maskWorldX - bounds.x;
const panY = maskWorldY - bounds.y;
log.info("Processing mask for editor:", {
sourceSize: { width: maskData.width, height: maskData.height },
targetSize: { width: targetWidth, height: targetHeight },
@@ -416,14 +419,15 @@ export class CanvasMask {
return;
}
log.debug("Creating temporary canvas for mask processing");
const bounds = this.canvas.outputAreaBounds;
const tempCanvas = document.createElement('canvas');
tempCanvas.width = this.canvas.width;
tempCanvas.height = this.canvas.height;
tempCanvas.width = bounds.width;
tempCanvas.height = bounds.height;
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
if (tempCtx) {
tempCtx.drawImage(resultImage, 0, 0, this.canvas.width, this.canvas.height);
tempCtx.drawImage(resultImage, 0, 0, bounds.width, bounds.height);
log.debug("Processing image data to create mask");
const imageData = tempCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
const imageData = tempCtx.getImageData(0, 0, bounds.width, bounds.height);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const originalAlpha = data[i + 3];
@@ -439,11 +443,20 @@ export class CanvasMask {
maskAsImage.src = tempCanvas.toDataURL();
await new Promise(resolve => maskAsImage.onload = resolve);
const maskCtx = this.maskTool.maskCtx;
const destX = -this.maskTool.x;
const destY = -this.maskTool.y;
log.debug("Applying mask to canvas", { destX, destY });
// Pozycja gdzie ma być aplikowana maska na canvas MaskTool
// MaskTool canvas ma pozycję (maskTool.x, maskTool.y) w świecie
// Maska z edytora reprezentuje output bounds, więc musimy ją umieścić
// w pozycji bounds względem pozycji MaskTool
const destX = bounds.x - this.maskTool.x;
const destY = bounds.y - this.maskTool.y;
log.debug("Applying mask to canvas", {
maskToolPos: { x: this.maskTool.x, y: this.maskTool.y },
boundsPos: { x: bounds.x, y: bounds.y },
destPos: { x: destX, y: destY },
maskSize: { width: bounds.width, height: bounds.height }
});
maskCtx.globalCompositeOperation = 'source-over';
maskCtx.clearRect(destX, destY, this.canvas.width, this.canvas.height);
maskCtx.clearRect(destX, destY, bounds.width, bounds.height);
maskCtx.drawImage(maskAsImage, destX, destY);
this.canvas.render();
this.canvas.saveState();

View File

@@ -67,6 +67,7 @@ export class CanvasRenderer {
}
});
this.drawCanvasOutline(ctx);
this.drawOutputAreaExtensionPreview(ctx); // Draw extension preview
this.drawPendingGenerationAreas(ctx); // Draw snapshot outlines
const maskImage = this.canvas.maskTool.getMask();
if (maskImage && this.canvas.maskTool.isOverlayVisible) {
@@ -79,12 +80,16 @@ export class CanvasRenderer {
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1.0;
}
ctx.drawImage(maskImage, this.canvas.maskTool.x, this.canvas.maskTool.y);
// 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();
}
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) {
@@ -256,7 +261,9 @@ export class CanvasRenderer {
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]);
ctx.rect(0, 0, this.canvas.width, this.canvas.height);
// 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) {
@@ -304,6 +311,29 @@ export class CanvasRenderer {
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;
// Podgląd pokazuje jak będą wyglądać nowe outputAreaBounds
const previewBounds = {
x: -ext.left, // Może być ujemne - wycinamy fragment świata
y: -ext.top, // Może być ujemne - wycinamy fragment świata
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
@@ -331,4 +361,42 @@ export class CanvasRenderer {
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();
}
}

View File

@@ -7,6 +7,7 @@ export class CustomShapeMenu {
this.worldX = 0;
this.worldY = 0;
this.uiInitialized = false;
this.tooltip = null;
}
show() {
if (!this.canvas.outputAreaShape) {
@@ -29,6 +30,7 @@ export class CustomShapeMenu {
this.element = null;
this.uiInitialized = false;
}
this.hideTooltip();
}
updateScreenPosition() {
if (!this.element)
@@ -99,7 +101,7 @@ export class CustomShapeMenu {
}
this._updateUI();
this.canvas.render();
});
}, "Automatically applies a mask based on the custom output area shape. When enabled, the mask will be applied to all layers within the shape boundary.");
featureContainer.appendChild(checkboxContainer);
// Add expansion checkbox
const expansionContainer = this._createCheckbox(() => `${this.canvas.shapeMaskExpansion ? "☑" : "☐"} Expand/Contract mask`, () => {
@@ -109,7 +111,7 @@ export class CustomShapeMenu {
this.canvas.maskTool.applyShapeMask();
this.canvas.render();
}
});
}, "Dilate (expand) or erode (contract) the shape mask. Positive values expand the mask outward, negative values shrink it inward.");
expansionContainer.id = 'expansion-checkbox';
featureContainer.appendChild(expansionContainer);
// Add expansion slider container
@@ -188,7 +190,7 @@ export class CustomShapeMenu {
this.canvas.maskTool.applyShapeMask();
this.canvas.render();
}
});
}, "Softens the edges of the shape mask by creating a gradual transition from opaque to transparent.");
featherContainer.id = 'feather-checkbox';
featureContainer.appendChild(featherContainer);
// Add feather slider container
@@ -260,6 +262,135 @@ export class CustomShapeMenu {
featherSliderContainer.appendChild(featherValueDisplay);
featureContainer.appendChild(featherSliderContainer);
this.element.appendChild(featureContainer);
// Create output area extension container
const extensionContainer = document.createElement('div');
extensionContainer.id = 'output-area-extension-container';
extensionContainer.style.cssText = `
background-color: #282828;
border-radius: 6px;
margin-top: 6px;
padding: 4px 0;
border: 1px solid #444;
`;
// Add main extension checkbox
const extensionCheckboxContainer = this._createCheckbox(() => `${this.canvas.outputAreaExtensionEnabled ? "☑" : "☐"} Extend output area`, () => {
this.canvas.outputAreaExtensionEnabled = !this.canvas.outputAreaExtensionEnabled;
if (this.canvas.outputAreaExtensionEnabled) {
// When enabling, capture current canvas size as the baseline
this.canvas.originalCanvasSize = {
width: this.canvas.width,
height: this.canvas.height
};
log.info(`Captured current canvas size as baseline: ${this.canvas.width}x${this.canvas.height}`);
}
else {
// Reset all extensions when disabled
this.canvas.outputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 };
}
this._updateExtensionUI();
this._updateCanvasSize(); // Update canvas size when toggling
this.canvas.render();
log.info(`Output area extension ${this.canvas.outputAreaExtensionEnabled ? 'enabled' : 'disabled'}`);
}, "Allows extending the output area boundaries in all directions without changing the custom shape.");
extensionContainer.appendChild(extensionCheckboxContainer);
// Create sliders container
const slidersContainer = document.createElement('div');
slidersContainer.id = 'extension-sliders-container';
slidersContainer.style.cssText = `
margin: 0 8px 6px 8px;
padding: 4px 8px;
display: none;
`;
// Helper function to create a slider with preview system
const createExtensionSlider = (label, direction) => {
const sliderContainer = document.createElement('div');
sliderContainer.style.cssText = `
margin: 6px 0;
`;
const sliderLabel = document.createElement('div');
sliderLabel.textContent = label;
sliderLabel.style.cssText = `
font-size: 11px;
margin-bottom: 4px;
color: #ccc;
`;
const slider = document.createElement('input');
slider.type = 'range';
slider.min = '0';
slider.max = '500';
slider.value = String(this.canvas.outputAreaExtensions[direction]);
slider.style.cssText = `
width: 100%;
height: 4px;
background: #555;
outline: none;
border-radius: 2px;
`;
const valueDisplay = document.createElement('div');
valueDisplay.style.cssText = `
font-size: 10px;
text-align: center;
margin-top: 2px;
color: #aaa;
`;
const updateDisplay = () => {
const value = parseInt(slider.value);
valueDisplay.textContent = `${value}px`;
};
let isDragging = false;
slider.onmousedown = () => {
isDragging = true;
};
slider.oninput = () => {
updateDisplay();
if (isDragging) {
// During dragging, show preview
const previewExtensions = { ...this.canvas.outputAreaExtensions };
previewExtensions[direction] = parseInt(slider.value);
this.canvas.outputAreaExtensionPreview = previewExtensions;
this.canvas.render();
}
else {
// Not dragging, apply immediately (for keyboard navigation)
this.canvas.outputAreaExtensions[direction] = parseInt(slider.value);
this._updateCanvasSize();
this.canvas.render();
}
};
slider.onmouseup = () => {
if (isDragging) {
isDragging = false;
// Apply the final value and clear preview
this.canvas.outputAreaExtensions[direction] = parseInt(slider.value);
this.canvas.outputAreaExtensionPreview = null;
this._updateCanvasSize();
this.canvas.render();
}
};
// Handle mouse leave (in case user drags outside)
slider.onmouseleave = () => {
if (isDragging) {
isDragging = false;
// Apply the final value and clear preview
this.canvas.outputAreaExtensions[direction] = parseInt(slider.value);
this.canvas.outputAreaExtensionPreview = null;
this._updateCanvasSize();
this.canvas.render();
}
};
updateDisplay();
sliderContainer.appendChild(sliderLabel);
sliderContainer.appendChild(slider);
sliderContainer.appendChild(valueDisplay);
return sliderContainer;
};
// Add all four sliders
slidersContainer.appendChild(createExtensionSlider('Top extension:', 'top'));
slidersContainer.appendChild(createExtensionSlider('Bottom extension:', 'bottom'));
slidersContainer.appendChild(createExtensionSlider('Left extension:', 'left'));
slidersContainer.appendChild(createExtensionSlider('Right extension:', 'right'));
extensionContainer.appendChild(slidersContainer);
this.element.appendChild(extensionContainer);
// Add to DOM
if (this.canvas.canvas.parentElement) {
this.canvas.canvas.parentElement.appendChild(this.element);
@@ -272,7 +403,7 @@ export class CustomShapeMenu {
// Add viewport change listener to update shape preview when zooming/panning
this._addViewportChangeListener();
}
_createCheckbox(textFn, clickHandler) {
_createCheckbox(textFn, clickHandler, tooltipText) {
const container = document.createElement('div');
container.style.cssText = `
margin: 6px 0 2px 0;
@@ -298,6 +429,10 @@ export class CustomShapeMenu {
clickHandler();
updateText();
};
// Add tooltip if provided
if (tooltipText) {
this._addTooltip(container, tooltipText);
}
return container;
}
_updateUI() {
@@ -333,8 +468,36 @@ export class CustomShapeMenu {
else if (index === 2) { // Feather checkbox
checkbox.textContent = `${this.canvas.shapeMaskFeather ? "☑" : "☐"} Feather edges`;
}
else if (index === 3) { // Extension checkbox
checkbox.textContent = `${this.canvas.outputAreaExtensionEnabled ? "☑" : "☐"} Extend output area`;
}
});
}
_updateExtensionUI() {
if (!this.element)
return;
// Toggle visibility of extension sliders based on the extension checkbox state
const extensionSlidersContainer = this.element.querySelector('#extension-sliders-container');
if (extensionSlidersContainer) {
extensionSlidersContainer.style.display = this.canvas.outputAreaExtensionEnabled ? 'block' : 'none';
}
// Update slider values if they exist
if (this.canvas.outputAreaExtensionEnabled) {
const sliders = extensionSlidersContainer?.querySelectorAll('input[type="range"]');
const directions = ['top', 'bottom', 'left', 'right'];
sliders?.forEach((slider, index) => {
const direction = directions[index];
if (direction) {
slider.value = String(this.canvas.outputAreaExtensions[direction]);
// Update the corresponding value display
const valueDisplay = slider.parentElement?.querySelector('div:last-child');
if (valueDisplay) {
valueDisplay.textContent = `${this.canvas.outputAreaExtensions[direction]}px`;
}
}
});
}
}
/**
* Add viewport change listener to update shape preview when zooming/panning
*/
@@ -373,4 +536,103 @@ export class CustomShapeMenu {
// Start the viewport change detection
requestAnimationFrame(checkViewportChange);
}
_addTooltip(element, text) {
element.addEventListener('mouseenter', (e) => {
this.showTooltip(text, e);
});
element.addEventListener('mouseleave', () => {
this.hideTooltip();
});
element.addEventListener('mousemove', (e) => {
if (this.tooltip && this.tooltip.style.display === 'block') {
this.updateTooltipPosition(e);
}
});
}
showTooltip(text, event) {
this.hideTooltip(); // Hide any existing tooltip
this.tooltip = document.createElement('div');
this.tooltip.textContent = text;
this.tooltip.style.cssText = `
position: fixed;
background-color: #1a1a1a;
color: #ffffff;
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
font-family: sans-serif;
line-height: 1.4;
max-width: 250px;
word-wrap: break-word;
box-shadow: 0 4px 12px rgba(0,0,0,0.6);
border: 1px solid #444;
z-index: 10000;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease-in-out;
`;
document.body.appendChild(this.tooltip);
this.updateTooltipPosition(event);
// Fade in the tooltip
requestAnimationFrame(() => {
if (this.tooltip) {
this.tooltip.style.opacity = '1';
}
});
}
updateTooltipPosition(event) {
if (!this.tooltip)
return;
const tooltipRect = this.tooltip.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let x = event.clientX + 10;
let y = event.clientY - 10;
// Adjust if tooltip would go off the right edge
if (x + tooltipRect.width > viewportWidth) {
x = event.clientX - tooltipRect.width - 10;
}
// Adjust if tooltip would go off the bottom edge
if (y + tooltipRect.height > viewportHeight) {
y = event.clientY - tooltipRect.height - 10;
}
// Ensure tooltip doesn't go off the left or top edges
x = Math.max(5, x);
y = Math.max(5, y);
this.tooltip.style.left = `${x}px`;
this.tooltip.style.top = `${y}px`;
}
hideTooltip() {
if (this.tooltip) {
this.tooltip.remove();
this.tooltip = null;
}
}
_updateCanvasSize() {
if (!this.canvas.outputAreaExtensionEnabled) {
// Reset to original bounds when disabled
this.canvas.outputAreaBounds = {
x: 0,
y: 0,
width: this.canvas.originalCanvasSize.width,
height: this.canvas.originalCanvasSize.height
};
this.canvas.updateOutputAreaSize(this.canvas.originalCanvasSize.width, this.canvas.originalCanvasSize.height, false);
return;
}
const ext = this.canvas.outputAreaExtensions;
const newWidth = this.canvas.originalCanvasSize.width + ext.left + ext.right;
const newHeight = this.canvas.originalCanvasSize.height + ext.top + ext.bottom;
// Aktualizuj outputAreaBounds - "okno" w świecie które zostanie wyrenderowane
this.canvas.outputAreaBounds = {
x: -ext.left, // Może być ujemne - wycinamy fragment świata
y: -ext.top, // Może być ujemne - wycinamy fragment świata
width: newWidth,
height: newHeight
};
// Zmień rozmiar canvas (fizyczny rozmiar dla renderowania)
this.canvas.updateOutputAreaSize(newWidth, newHeight, false);
log.info(`Output area bounds updated: x=${this.canvas.outputAreaBounds.x}, y=${this.canvas.outputAreaBounds.y}, w=${newWidth}, h=${newHeight}`);
log.info(`Extensions: top=${ext.top}, bottom=${ext.bottom}, left=${ext.left}, right=${ext.right}`);
}
}

View File

@@ -59,12 +59,18 @@ export class MaskTool {
}
initMaskCanvas() {
const extraSpace = 2000; // Allow for a generous drawing area outside the output area
this.maskCanvas.width = this.canvasInstance.width + extraSpace;
this.maskCanvas.height = this.canvasInstance.height + extraSpace;
this.x = -extraSpace / 2;
this.y = -extraSpace / 2;
const bounds = this.canvasInstance.outputAreaBounds;
// Mask canvas should cover output area + extra space around it
const maskLeft = bounds.x - extraSpace / 2;
const maskTop = bounds.y - extraSpace / 2;
const maskWidth = bounds.width + extraSpace;
const maskHeight = bounds.height + extraSpace;
this.maskCanvas.width = maskWidth;
this.maskCanvas.height = maskHeight;
this.x = maskLeft;
this.y = maskTop;
this.maskCtx.clearRect(0, 0, this.maskCanvas.width, this.maskCanvas.height);
log.info(`Initialized mask canvas with extended size: ${this.maskCanvas.width}x${this.maskCanvas.height}, origin at (${this.x}, ${this.y})`);
log.info(`Initialized mask canvas with size: ${this.maskCanvas.width}x${this.maskCanvas.height}, positioned at (${this.x}, ${this.y}) to cover output area at (${bounds.x}, ${bounds.y})`);
}
activate() {
if (!this.previewCanvasInitialized) {
@@ -582,24 +588,82 @@ export class MaskTool {
this.y += dy;
log.info(`Mask position updated to (${this.x}, ${this.y})`);
}
/**
* Updates mask canvas to ensure it covers the current output area
* This should be called when output area position or size changes
*/
updateMaskCanvasForOutputArea() {
const extraSpace = 2000;
const bounds = this.canvasInstance.outputAreaBounds;
// Calculate required mask canvas bounds
const requiredLeft = bounds.x - extraSpace / 2;
const requiredTop = bounds.y - extraSpace / 2;
const requiredWidth = bounds.width + extraSpace;
const requiredHeight = bounds.height + extraSpace;
// Check if current mask canvas covers the required area
const currentRight = this.x + this.maskCanvas.width;
const currentBottom = this.y + this.maskCanvas.height;
const requiredRight = requiredLeft + requiredWidth;
const requiredBottom = requiredTop + requiredHeight;
const needsResize = requiredLeft < this.x ||
requiredTop < this.y ||
requiredRight > currentRight ||
requiredBottom > currentBottom;
if (needsResize) {
log.info(`Updating mask canvas to cover output area at (${bounds.x}, ${bounds.y})`);
// Save current mask content
const oldMask = this.maskCanvas;
const oldX = this.x;
const oldY = this.y;
// Create new mask canvas with proper size and position
this.maskCanvas = document.createElement('canvas');
this.maskCanvas.width = requiredWidth;
this.maskCanvas.height = requiredHeight;
this.x = requiredLeft;
this.y = requiredTop;
const newMaskCtx = this.maskCanvas.getContext('2d', { willReadFrequently: true });
if (!newMaskCtx) {
throw new Error("Failed to get 2D context for new mask canvas");
}
this.maskCtx = newMaskCtx;
// Copy old mask content to new position
if (oldMask.width > 0 && oldMask.height > 0) {
const offsetX = oldX - this.x;
const offsetY = oldY - this.y;
this.maskCtx.drawImage(oldMask, offsetX, offsetY);
log.debug(`Preserved mask content with offset (${offsetX}, ${offsetY})`);
}
log.info(`Mask canvas updated to ${this.maskCanvas.width}x${this.maskCanvas.height} at (${this.x}, ${this.y})`);
}
}
toggleOverlayVisibility() {
this.isOverlayVisible = !this.isOverlayVisible;
log.info(`Mask overlay visibility toggled to: ${this.isOverlayVisible}`);
}
setMask(image) {
const destX = -this.x;
const destY = -this.y;
// Pozycja gdzie ma być aplikowana maska na canvas MaskTool
// MaskTool canvas ma pozycję (this.x, this.y) w świecie
// Maska reprezentuje output bounds, więc musimy ją umieścić
// w pozycji bounds względem pozycji MaskTool
const bounds = this.canvasInstance.outputAreaBounds;
const destX = bounds.x - this.x;
const destY = bounds.y - this.y;
this.maskCtx.clearRect(destX, destY, this.canvasInstance.width, this.canvasInstance.height);
this.maskCtx.drawImage(image, destX, destY);
if (this.onStateChange) {
this.onStateChange();
}
this.canvasInstance.render();
log.info(`MaskTool updated with a new mask image at correct canvas position (${destX}, ${destY}).`);
log.info(`MaskTool updated with a new mask image at position (${destX}, ${destY}) relative to bounds (${bounds.x}, ${bounds.y}).`);
}
addMask(image) {
const destX = -this.x;
const destY = -this.y;
// Pozycja gdzie ma być aplikowana maska na canvas MaskTool
// MaskTool canvas ma pozycję (this.x, this.y) w świecie
// Maska z SAM reprezentuje output bounds, więc musimy ją umieścić
// w pozycji bounds względem pozycji MaskTool
const bounds = this.canvasInstance.outputAreaBounds;
const destX = bounds.x - this.x;
const destY = bounds.y - this.y;
// Don't clear existing mask - just add to it
this.maskCtx.globalCompositeOperation = 'source-over';
this.maskCtx.drawImage(image, destX, destY);
@@ -607,7 +671,7 @@ export class MaskTool {
this.onStateChange();
}
this.canvasInstance.render();
log.info(`MaskTool added mask overlay at correct canvas position (${destX}, ${destY}) without clearing existing mask.`);
log.info(`MaskTool added SAM mask overlay at position (${destX}, ${destY}) relative to bounds (${bounds.x}, ${bounds.y}) without clearing existing mask.`);
}
applyShapeMask(saveState = true) {
if (!this.canvasInstance.outputAreaShape?.points || this.canvasInstance.outputAreaShape.points.length < 3) {

View File

@@ -21,7 +21,7 @@ import {createModuleLogger} from "./utils/LoggerUtils.js";
import { debounce } from "./utils/CommonUtils.js";
import {CanvasMask} from "./CanvasMask.js";
import {CanvasSelection} from "./CanvasSelection.js";
import type { ComfyNode, Layer, Viewport, Point, AddMode, Shape } from './types';
import type { ComfyNode, Layer, Viewport, Point, AddMode, Shape, OutputAreaBounds } from './types';
const useChainCallback = (original: any, next: any) => {
if (original === undefined || original === null) {
@@ -73,6 +73,11 @@ export class Canvas {
shapeMaskExpansionValue: number;
shapeMaskFeather: boolean;
shapeMaskFeatherValue: number;
outputAreaExtensions: { top: number, bottom: number, left: number, right: number };
outputAreaExtensionEnabled: boolean;
outputAreaExtensionPreview: { top: number, bottom: number, left: number, right: number } | null;
originalCanvasSize: { width: number, height: number };
outputAreaBounds: OutputAreaBounds;
node: ComfyNode;
offscreenCanvas: HTMLCanvasElement;
offscreenCtx: CanvasRenderingContext2D | null;
@@ -117,15 +122,20 @@ export class Canvas {
this.imageCache = new Map();
this.requestSaveState = () => {};
this.maskTool = new MaskTool(this, {onStateChange: this.onStateChange});
this.shapeTool = new ShapeTool(this);
this.customShapeMenu = new CustomShapeMenu(this);
this.outputAreaShape = null;
this.autoApplyShapeMask = false;
this.shapeMaskExpansion = false;
this.shapeMaskExpansionValue = 0;
this.shapeMaskFeather = false;
this.shapeMaskFeatherValue = 0;
this.outputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 };
this.outputAreaExtensionEnabled = false;
this.outputAreaExtensionPreview = null;
this.originalCanvasSize = { width: this.width, height: this.height };
this.outputAreaBounds = { x: 0, y: 0, width: this.width, height: this.height };
this.maskTool = new MaskTool(this, {onStateChange: this.onStateChange});
this.shapeTool = new ShapeTool(this);
this.customShapeMenu = new CustomShapeMenu(this);
this.canvasMask = new CanvasMask(this);
this.canvasState = new CanvasState(this);
this.canvasSelection = new CanvasSelection(this);
@@ -464,7 +474,12 @@ export class Canvas {
* @param {boolean} saveHistory - Czy zapisać w historii
*/
updateOutputAreaSize(width: number, height: number, saveHistory = true) {
return this.canvasLayers.updateOutputAreaSize(width, height, saveHistory);
const result = this.canvasLayers.updateOutputAreaSize(width, height, saveHistory);
// Update mask canvas to ensure it covers the new output area
this.maskTool.updateMaskCanvasForOutputArea();
return result;
}
/**

View File

@@ -234,89 +234,38 @@ export class CanvasIO {
}
async _renderOutputData(): Promise<{ image: string, mask: string }> {
return new Promise((resolve) => {
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(this.canvas.width, this.canvas.height);
const { canvas: maskCanvas, ctx: maskCtx } = createCanvas(this.canvas.width, this.canvas.height);
const originalShape = this.canvas.outputAreaShape;
this.canvas.outputAreaShape = null;
const visibilityCanvas = document.createElement('canvas');
visibilityCanvas.width = this.canvas.width;
visibilityCanvas.height = this.canvas.height;
const visibilityCtx = visibilityCanvas.getContext('2d', { alpha: true });
if (!visibilityCtx) throw new Error("Could not create visibility context");
if (!maskCtx) throw new Error("Could not create mask context");
if (!tempCtx) throw new Error("Could not create temp context");
maskCtx.fillStyle = '#ffffff'; // Start with a white mask (nothing masked)
maskCtx.fillRect(0, 0, this.canvas.width, this.canvas.height);
this.canvas.canvasLayers.drawLayersToContext(tempCtx, this.canvas.layers);
this.canvas.canvasLayers.drawLayersToContext(visibilityCtx, this.canvas.layers);
const visibilityData = visibilityCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
const maskData = maskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
for (let i = 0; i < visibilityData.data.length; i += 4) {
const alpha = visibilityData.data[i + 3];
const maskValue = 255 - alpha; // Invert alpha to create the mask
maskData.data[i] = maskData.data[i + 1] = maskData.data[i + 2] = maskValue;
maskData.data[i + 3] = 255; // Solid mask
}
maskCtx.putImageData(maskData, 0, 0);
const toolMaskCanvas = this.canvas.maskTool.getMask();
if (toolMaskCanvas) {
const tempMaskCanvas = document.createElement('canvas');
tempMaskCanvas.width = this.canvas.width;
tempMaskCanvas.height = this.canvas.height;
const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true });
if (!tempMaskCtx) throw new Error("Could not create temp mask context");
tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height);
const maskX = this.canvas.maskTool.x;
const maskY = this.canvas.maskTool.y;
log.debug(`[renderOutputData] Extracting mask from world position (${maskX}, ${maskY})`);
const sourceX = Math.max(0, -maskX);
const sourceY = Math.max(0, -maskY);
const destX = Math.max(0, maskX);
const destY = Math.max(0, maskY);
const copyWidth = Math.min(toolMaskCanvas.width - sourceX, this.canvas.width - destX);
const copyHeight = Math.min(toolMaskCanvas.height - sourceY, this.canvas.height - destY);
if (copyWidth > 0 && copyHeight > 0) {
tempMaskCtx.drawImage(
toolMaskCanvas,
sourceX, sourceY, copyWidth, copyHeight,
destX, destY, copyWidth, copyHeight
);
}
const tempMaskData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
for (let i = 0; i < tempMaskData.data.length; i += 4) {
const alpha = tempMaskData.data[i + 3];
tempMaskData.data[i] = tempMaskData.data[i + 1] = tempMaskData.data[i + 2] = alpha;
tempMaskData.data[i + 3] = 255; // Solid alpha
}
tempMaskCtx.putImageData(tempMaskData, 0, 0);
maskCtx.globalCompositeOperation = 'screen';
maskCtx.drawImage(tempMaskCanvas, 0, 0);
}
const imageDataUrl = tempCanvas.toDataURL('image/png');
const maskDataUrl = maskCanvas.toDataURL('image/png');
this.canvas.outputAreaShape = originalShape;
resolve({image: imageDataUrl, mask: maskDataUrl});
log.info("=== RENDERING OUTPUT DATA FOR COMFYUI ===");
// Użyj zunifikowanych funkcji z CanvasLayers
const imageBlob = await this.canvas.canvasLayers.getFlattenedCanvasAsBlob();
const maskBlob = await this.canvas.canvasLayers.getFlattenedMaskAsBlob();
if (!imageBlob || !maskBlob) {
throw new Error("Failed to generate canvas or mask blobs");
}
// Konwertuj blob na data URL
const imageDataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(imageBlob);
});
const maskDataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(maskBlob);
});
const bounds = this.canvas.outputAreaBounds;
log.info(`=== OUTPUT DATA GENERATED ===`);
log.info(`Image size: ${bounds.width}x${bounds.height}`);
log.info(`Image data URL length: ${imageDataUrl.length}`);
log.info(`Mask data URL length: ${maskDataUrl.length}`);
return { image: imageDataUrl, mask: maskDataUrl };
}
async sendDataViaWebSocket(nodeId: number): Promise<boolean> {
@@ -364,14 +313,15 @@ export class CanvasIO {
image.src = tempCanvas.toDataURL();
});
const bounds = this.canvas.outputAreaBounds;
const scale = Math.min(
this.canvas.width / inputImage.width * 0.8,
this.canvas.height / inputImage.height * 0.8
bounds.width / inputImage.width * 0.8,
bounds.height / inputImage.height * 0.8
);
const layer = await this.canvas.canvasLayers.addLayerWithImage(image, {
x: (this.canvas.width - inputImage.width * scale) / 2,
y: (this.canvas.height - inputImage.height * scale) / 2,
x: bounds.x + (bounds.width - inputImage.width * scale) / 2,
y: bounds.y + (bounds.height - inputImage.height * scale) / 2,
width: inputImage.width * scale,
height: inputImage.height * scale,
});

View File

@@ -212,6 +212,8 @@ export class CanvasInteractions {
handleMouseUp(e: MouseEvent): void {
const viewCoords = this.canvas.getMouseViewCoordinates(e);
const worldCoords = this.canvas.getMouseWorldCoordinates(e);
if (this.interaction.mode === 'drawingMask') {
this.canvas.maskTool.handleMouseUp(viewCoords);
this.canvas.render();
@@ -225,6 +227,24 @@ export class CanvasInteractions {
this.finalizeCanvasMove();
}
// Log layer positions when dragging ends
if (this.interaction.mode === 'dragging' && this.canvas.canvasSelection.selectedLayers.length > 0) {
const bounds = this.canvas.outputAreaBounds;
log.info("=== LAYER DRAG COMPLETED ===");
log.info(`Mouse position: world(${worldCoords.x.toFixed(1)}, ${worldCoords.y.toFixed(1)}) view(${viewCoords.x.toFixed(1)}, ${viewCoords.y.toFixed(1)})`);
log.info(`Output Area Bounds: x=${bounds.x}, y=${bounds.y}, w=${bounds.width}, h=${bounds.height}`);
log.info(`Viewport: x=${this.canvas.viewport.x.toFixed(1)}, y=${this.canvas.viewport.y.toFixed(1)}, zoom=${this.canvas.viewport.zoom.toFixed(2)}`);
this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer, index: number) => {
const relativeToOutput = {
x: layer.x - bounds.x,
y: layer.y - bounds.y
};
log.info(`Layer ${index + 1} "${layer.name}": world(${layer.x.toFixed(1)}, ${layer.y.toFixed(1)}) relative_to_output(${relativeToOutput.x.toFixed(1)}, ${relativeToOutput.y.toFixed(1)}) size(${layer.width.toFixed(1)}x${layer.height.toFixed(1)})`);
});
log.info("=== END LAYER DRAG ===");
}
// Zapisz stan tylko, jeśli faktycznie doszło do zmiany (przeciąganie, transformacja, duplikacja)
const stateChangingInteraction = ['dragging', 'resizing', 'rotating'].includes(this.interaction.mode);
const duplicatedInDrag = this.interaction.hasClonedInDrag;
@@ -580,28 +600,22 @@ export class CanvasInteractions {
startCanvasMove(worldCoords: Point): void {
this.interaction.mode = 'movingCanvas';
this.interaction.dragStart = { ...worldCoords };
const initialX = snapToGrid(worldCoords.x - this.canvas.width / 2);
const initialY = snapToGrid(worldCoords.y - this.canvas.height / 2);
this.interaction.canvasMoveRect = {
x: initialX,
y: initialY,
width: this.canvas.width,
height: this.canvas.height
};
this.canvas.canvas.style.cursor = 'grabbing';
this.canvas.render();
}
updateCanvasMove(worldCoords: Point): void {
if (!this.interaction.canvasMoveRect) return;
const dx = worldCoords.x - this.interaction.dragStart.x;
const dy = worldCoords.y - this.interaction.dragStart.y;
const initialRectX = snapToGrid(this.interaction.dragStart.x - this.canvas.width / 2);
const initialRectY = snapToGrid(this.interaction.dragStart.y - this.canvas.height / 2);
this.interaction.canvasMoveRect.x = snapToGrid(initialRectX + dx);
this.interaction.canvasMoveRect.y = snapToGrid(initialRectY + dy);
// Po prostu przesuwamy outputAreaBounds
const bounds = this.canvas.outputAreaBounds;
this.interaction.canvasMoveRect = {
x: snapToGrid(bounds.x + dx),
y: snapToGrid(bounds.y + dy),
width: bounds.width,
height: bounds.height
};
this.canvas.render();
}
@@ -609,43 +623,19 @@ export class CanvasInteractions {
finalizeCanvasMove(): void {
const moveRect = this.interaction.canvasMoveRect;
if (moveRect && (moveRect.x !== 0 || moveRect.y !== 0)) {
const finalX = moveRect.x;
const finalY = moveRect.y;
this.canvas.layers.forEach((layer: Layer) => {
layer.x -= finalX;
layer.y -= finalY;
});
this.canvas.maskTool.updatePosition(-finalX, -finalY);
// If a batch generation is in progress, update the captured context as well
if (this.canvas.pendingBatchContext) {
this.canvas.pendingBatchContext.outputArea.x -= finalX;
this.canvas.pendingBatchContext.outputArea.y -= finalY;
// Also update the menu spawn position to keep it relative
this.canvas.pendingBatchContext.spawnPosition.x -= finalX;
this.canvas.pendingBatchContext.spawnPosition.y -= finalY;
log.debug("Updated pending batch context during canvas move:", this.canvas.pendingBatchContext);
}
// Also move any active batch preview menus
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
this.canvas.batchPreviewManagers.forEach((manager: any) => { // TODO: Type for manager
manager.worldX -= finalX;
manager.worldY -= finalY;
if (manager.generationArea) {
manager.generationArea.x -= finalX;
manager.generationArea.y -= finalY;
}
});
}
this.canvas.viewport.x -= finalX;
this.canvas.viewport.y -= finalY;
if (moveRect) {
// Po prostu aktualizujemy outputAreaBounds na nową pozycję
this.canvas.outputAreaBounds = {
x: moveRect.x,
y: moveRect.y,
width: moveRect.width,
height: moveRect.height
};
// Update mask canvas to ensure it covers the new output area position
this.canvas.maskTool.updateMaskCanvasForOutputArea();
}
this.canvas.render();
this.canvas.saveState();
}
@@ -821,41 +811,19 @@ export class CanvasInteractions {
const finalX = this.interaction.canvasResizeRect.x;
const finalY = this.interaction.canvasResizeRect.y;
// Po prostu aktualizujemy outputAreaBounds na nowy obszar
this.canvas.outputAreaBounds = {
x: finalX,
y: finalY,
width: newWidth,
height: newHeight
};
this.canvas.updateOutputAreaSize(newWidth, newHeight);
this.canvas.layers.forEach((layer: Layer) => {
layer.x -= finalX;
layer.y -= finalY;
});
this.canvas.maskTool.updatePosition(-finalX, -finalY);
// If a batch generation is in progress, update the captured context as well
if (this.canvas.pendingBatchContext) {
this.canvas.pendingBatchContext.outputArea.x -= finalX;
this.canvas.pendingBatchContext.outputArea.y -= finalY;
// Also update the menu spawn position to keep it relative
this.canvas.pendingBatchContext.spawnPosition.x -= finalX;
this.canvas.pendingBatchContext.spawnPosition.y -= finalY;
log.debug("Updated pending batch context during canvas resize:", this.canvas.pendingBatchContext);
}
// Also move any active batch preview menus
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
this.canvas.batchPreviewManagers.forEach((manager: any) => { // TODO: Type for manager
manager.worldX -= finalX;
manager.worldY -= finalY;
if (manager.generationArea) {
manager.generationArea.x -= finalX;
manager.generationArea.y -= finalY;
}
});
}
this.canvas.viewport.x -= finalX;
this.canvas.viewport.y -= finalY;
}
this.canvas.render();
this.canvas.saveState();
}
handleDragOver(e: DragEvent): void {

View File

@@ -180,8 +180,9 @@ export class CanvasLayers {
let finalHeight = image.height;
let finalX, finalY;
// Use the targetArea if provided, otherwise default to the current canvas dimensions
const area = targetArea || { width: this.canvas.width, height: this.canvas.height, x: 0, y: 0 };
// Use the targetArea if provided, otherwise default to the current output area bounds
const bounds = this.canvas.outputAreaBounds;
const area = targetArea || { width: bounds.width, height: bounds.height, x: bounds.x, y: bounds.y };
if (addMode === 'fit') {
const scale = Math.min(area.width / image.width, area.height / image.height);
@@ -563,6 +564,7 @@ export class CanvasLayers {
if (saveHistory) {
this.canvas.saveState();
}
this.canvas.width = width;
this.canvas.height = height;
this.canvas.maskTool.resize(width, height);
@@ -895,44 +897,286 @@ export class CanvasLayers {
}
}
async getFlattenedCanvasWithMaskAsBlob(): Promise<Blob | null> {
/**
* Zunifikowana funkcja do generowania blob z canvas
* @param options Opcje renderowania
*/
private async _generateCanvasBlob(options: {
layers?: Layer[]; // Które warstwy renderować (domyślnie wszystkie)
useOutputBounds?: boolean; // Czy używać output area bounds (domyślnie true)
applyMask?: boolean; // Czy aplikować maskę (domyślnie false)
enableLogging?: boolean; // Czy włączyć szczegółowe logi (domyślnie false)
customBounds?: { x: number, y: number, width: number, height: number }; // Niestandardowe bounds
} = {}): Promise<Blob | null> {
const {
layers = this.canvas.layers,
useOutputBounds = true,
applyMask = false,
enableLogging = false,
customBounds
} = options;
return new Promise((resolve, reject) => {
let bounds: { x: number, y: number, width: number, height: number };
if (customBounds) {
bounds = customBounds;
} else if (useOutputBounds) {
bounds = this.canvas.outputAreaBounds;
} else {
// Oblicz bounding box dla wybranych warstw
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
layers.forEach((layer: 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;
}
bounds = { x: minX, y: minY, width: newWidth, height: newHeight };
}
const tempCanvas = document.createElement('canvas');
tempCanvas.width = this.canvas.width;
tempCanvas.height = this.canvas.height;
tempCanvas.width = bounds.width;
tempCanvas.height = bounds.height;
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
if (!tempCtx) {
reject(new Error("Could not create canvas context"));
return;
}
this._drawLayers(tempCtx, this.canvas.layers);
if (enableLogging) {
log.info("=== GENERATING OUTPUT CANVAS ===");
log.info(`Bounds: x=${bounds.x}, y=${bounds.y}, w=${bounds.width}, h=${bounds.height}`);
log.info(`Canvas Size: ${tempCanvas.width}x${tempCanvas.height}`);
log.info(`Context Translation: translate(${-bounds.x}, ${-bounds.y})`);
log.info(`Apply Mask: ${applyMask}`);
// Log layer positions before rendering
layers.forEach((layer: Layer, index: number) => {
if (layer.visible) {
const relativeToOutput = {
x: layer.x - bounds.x,
y: layer.y - bounds.y
};
log.info(`Layer ${index + 1} "${layer.name}": world(${layer.x.toFixed(1)}, ${layer.y.toFixed(1)}) relative_to_bounds(${relativeToOutput.x.toFixed(1)}, ${relativeToOutput.y.toFixed(1)}) size(${layer.width.toFixed(1)}x${layer.height.toFixed(1)})`);
}
});
}
const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
const data = imageData.data;
// Renderuj fragment świata zdefiniowany przez bounds
tempCtx.translate(-bounds.x, -bounds.y);
this._drawLayers(tempCtx, layers);
// Aplikuj maskę jeśli wymagana
if (applyMask) {
const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
const data = imageData.data;
const toolMaskCanvas = this.canvas.maskTool.getMask();
if (toolMaskCanvas) {
const tempMaskCanvas = document.createElement('canvas');
tempMaskCanvas.width = bounds.width;
tempMaskCanvas.height = bounds.height;
const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true });
if (!tempMaskCtx) {
reject(new Error("Could not create mask canvas context"));
return;
}
tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height);
// Pozycja maski w świecie (bez przesunięcia względem bounds)
const maskWorldX = this.canvas.maskTool.x;
const maskWorldY = this.canvas.maskTool.y;
// Pozycja maski względem output bounds (gdzie ma być narysowana w output canvas)
const maskX = maskWorldX - bounds.x;
const maskY = maskWorldY - bounds.y;
const sourceX = Math.max(0, -maskX);
const sourceY = Math.max(0, -maskY);
const destX = Math.max(0, maskX);
const destY = Math.max(0, maskY);
const copyWidth = Math.min(toolMaskCanvas.width - sourceX, bounds.width - destX);
const copyHeight = Math.min(toolMaskCanvas.height - sourceY, bounds.height - destY);
if (copyWidth > 0 && copyHeight > 0) {
tempMaskCtx.drawImage(
toolMaskCanvas,
sourceX, sourceY, copyWidth, copyHeight,
destX, destY, copyWidth, copyHeight
);
}
const tempMaskData = tempMaskCtx.getImageData(0, 0, bounds.width, bounds.height);
for (let i = 0; i < tempMaskData.data.length; i += 4) {
const alpha = tempMaskData.data[i + 3];
tempMaskData.data[i] = tempMaskData.data[i + 1] = tempMaskData.data[i + 2] = 255;
tempMaskData.data[i + 3] = alpha;
}
tempMaskCtx.putImageData(tempMaskData, 0, 0);
const maskImageData = tempMaskCtx.getImageData(0, 0, bounds.width, bounds.height);
const maskData = maskImageData.data;
for (let i = 0; i < data.length; i += 4) {
const originalAlpha = data[i + 3];
const maskAlpha = maskData[i + 3] / 255;
const invertedMaskAlpha = 1 - maskAlpha;
data[i + 3] = originalAlpha * invertedMaskAlpha;
}
tempCtx.putImageData(imageData, 0, 0);
}
}
tempCanvas.toBlob((blob) => {
if (blob) {
resolve(blob);
} else {
resolve(null);
}
}, 'image/png');
});
}
// Publiczne metody używające zunifikowanej funkcji
async getFlattenedCanvasWithMaskAsBlob(): Promise<Blob | null> {
return this._generateCanvasBlob({
layers: this.canvas.layers,
useOutputBounds: true,
applyMask: true,
enableLogging: true
});
}
async getFlattenedCanvasAsBlob(): Promise<Blob | null> {
return this._generateCanvasBlob({
layers: this.canvas.layers,
useOutputBounds: true,
applyMask: false,
enableLogging: true
});
}
async getFlattenedSelectionAsBlob(): Promise<Blob | null> {
if (this.canvas.canvasSelection.selectedLayers.length === 0) {
return null;
}
return this._generateCanvasBlob({
layers: this.canvas.canvasSelection.selectedLayers,
useOutputBounds: false,
applyMask: false,
enableLogging: false
});
}
async getFlattenedMaskAsBlob(): Promise<Blob | null> {
return new Promise((resolve, reject) => {
const bounds = this.canvas.outputAreaBounds;
const maskCanvas = document.createElement('canvas');
maskCanvas.width = bounds.width;
maskCanvas.height = bounds.height;
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
if (!maskCtx) {
reject(new Error("Could not create mask context"));
return;
}
log.info("=== GENERATING MASK BLOB ===");
log.info(`Mask Canvas Size: ${maskCanvas.width}x${maskCanvas.height}`);
// Rozpocznij z białą maską (nic nie zamaskowane)
maskCtx.fillStyle = '#ffffff';
maskCtx.fillRect(0, 0, bounds.width, bounds.height);
// Stwórz canvas do sprawdzenia przezroczystości warstw
const visibilityCanvas = document.createElement('canvas');
visibilityCanvas.width = bounds.width;
visibilityCanvas.height = bounds.height;
const visibilityCtx = visibilityCanvas.getContext('2d', { alpha: true });
if (!visibilityCtx) {
reject(new Error("Could not create visibility context"));
return;
}
// Renderuj warstwy z przesunięciem dla output bounds
visibilityCtx.translate(-bounds.x, -bounds.y);
this._drawLayers(visibilityCtx, this.canvas.layers);
// Konwertuj przezroczystość warstw na maskę
const visibilityData = visibilityCtx.getImageData(0, 0, bounds.width, bounds.height);
const maskData = maskCtx.getImageData(0, 0, bounds.width, bounds.height);
for (let i = 0; i < visibilityData.data.length; i += 4) {
const alpha = visibilityData.data[i + 3];
const maskValue = 255 - alpha; // Odwróć alpha żeby stworzyć maskę
maskData.data[i] = maskData.data[i + 1] = maskData.data[i + 2] = maskValue;
maskData.data[i + 3] = 255; // Solidna maska
}
maskCtx.putImageData(maskData, 0, 0);
// Aplikuj maskę narzędzia jeśli istnieje
const toolMaskCanvas = this.canvas.maskTool.getMask();
if (toolMaskCanvas) {
const tempMaskCanvas = document.createElement('canvas');
tempMaskCanvas.width = this.canvas.width;
tempMaskCanvas.height = this.canvas.height;
tempMaskCanvas.width = bounds.width;
tempMaskCanvas.height = bounds.height;
const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true });
if (!tempMaskCtx) {
reject(new Error("Could not create mask canvas context"));
reject(new Error("Could not create temp mask context"));
return;
}
tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height);
const maskX = this.canvas.maskTool.x;
const maskY = this.canvas.maskTool.y;
// Pozycja maski w świecie (bez przesunięcia względem bounds)
const maskWorldX = this.canvas.maskTool.x;
const maskWorldY = this.canvas.maskTool.y;
// Pozycja maski względem output bounds (gdzie ma być narysowana w output canvas)
const maskX = maskWorldX - bounds.x;
const maskY = maskWorldY - bounds.y;
log.debug(`[getFlattenedMaskAsBlob] Mask world position (${maskWorldX}, ${maskWorldY}) relative to bounds (${maskX}, ${maskY})`);
const sourceX = Math.max(0, -maskX);
const sourceY = Math.max(0, -maskY);
const destX = Math.max(0, maskX);
const destY = Math.max(0, maskY);
const copyWidth = Math.min(toolMaskCanvas.width - sourceX, this.canvas.width - destX);
const copyHeight = Math.min(toolMaskCanvas.height - sourceY, this.canvas.height - destY);
const copyWidth = Math.min(toolMaskCanvas.width - sourceX, bounds.width - destX);
const copyHeight = Math.min(toolMaskCanvas.height - sourceY, bounds.height - destY);
if (copyWidth > 0 && copyHeight > 0) {
tempMaskCtx.drawImage(
@@ -942,27 +1186,21 @@ export class CanvasLayers {
);
}
const tempMaskData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
const tempMaskData = tempMaskCtx.getImageData(0, 0, bounds.width, bounds.height);
for (let i = 0; i < tempMaskData.data.length; i += 4) {
const alpha = tempMaskData.data[i + 3];
tempMaskData.data[i] = tempMaskData.data[i + 1] = tempMaskData.data[i + 2] = 255;
tempMaskData.data[i + 3] = alpha;
tempMaskData.data[i] = tempMaskData.data[i + 1] = tempMaskData.data[i + 2] = alpha;
tempMaskData.data[i + 3] = 255; // Solidna alpha
}
tempMaskCtx.putImageData(tempMaskData, 0, 0);
const maskImageData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
const maskData = maskImageData.data;
for (let i = 0; i < data.length; i += 4) {
const originalAlpha = data[i + 3];
const maskAlpha = maskData[i + 3] / 255;
const invertedMaskAlpha = 1 - maskAlpha;
data[i + 3] = originalAlpha * invertedMaskAlpha;
}
tempCtx.putImageData(imageData, 0, 0);
maskCtx.globalCompositeOperation = 'screen';
maskCtx.drawImage(tempMaskCanvas, 0, 0);
}
tempCanvas.toBlob((blob) => {
log.info("=== MASK BLOB GENERATED ===");
maskCanvas.toBlob((blob) => {
if (blob) {
resolve(blob);
} else {
@@ -971,94 +1209,6 @@ export class CanvasLayers {
}, 'image/png');
});
}
async getFlattenedCanvasAsBlob(): Promise<Blob | null> {
return new Promise((resolve, reject) => {
const tempCanvas = document.createElement('canvas');
tempCanvas.width = this.canvas.width;
tempCanvas.height = this.canvas.height;
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
if (!tempCtx) {
reject(new Error("Could not create canvas context"));
return;
}
this._drawLayers(tempCtx, this.canvas.layers);
tempCanvas.toBlob((blob) => {
if (blob) {
resolve(blob);
} else {
resolve(null);
}
}, 'image/png');
});
}
async getFlattenedCanvasForMaskEditor(): Promise<Blob | null> {
return this.getFlattenedCanvasWithMaskAsBlob();
}
async getFlattenedSelectionAsBlob(): Promise<Blob | null> {
if (this.canvas.canvasSelection.selectedLayers.length === 0) {
return null;
}
return new Promise((resolve, reject) => {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
this.canvas.canvasSelection.selectedLayers.forEach((layer: 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', { willReadFrequently: true });
if (!tempCtx) {
reject(new Error("Could not create canvas context"));
return;
}
tempCtx.translate(-minX, -minY);
this._drawLayers(tempCtx, this.canvas.canvasSelection.selectedLayers);
tempCanvas.toBlob((blob) => {
resolve(blob);
}, 'image/png');
});
}
async fuseLayers(): Promise<void> {
if (this.canvas.canvasSelection.selectedLayers.length < 2) {

View File

@@ -61,7 +61,7 @@ export class CanvasMask {
blob = await this.canvas.canvasLayers.getFlattenedCanvasAsBlob();
} else {
log.debug('Getting flattened canvas for mask editor (with mask)');
blob = await this.canvas.canvasLayers.getFlattenedCanvasForMaskEditor();
blob = await this.canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
}
if (!blob) {
@@ -306,9 +306,12 @@ export class CanvasMask {
* @param {Object} maskColor - Kolor maski {r, g, b}
* @returns {HTMLCanvasElement} Przetworzona maska
*/async processMaskForEditor(maskData: any, targetWidth: any, targetHeight: any, maskColor: any) {
// Współrzędne przesunięcia (pan) widoku edytora
const panX = this.maskTool.x;
const panY = this.maskTool.y;
// Pozycja maski w świecie względem output bounds
const bounds = this.canvas.outputAreaBounds;
const maskWorldX = this.maskTool.x;
const maskWorldY = this.maskTool.y;
const panX = maskWorldX - bounds.x;
const panY = maskWorldY - bounds.y;
log.info("Processing mask for editor:", {
sourceSize: {width: maskData.width, height: maskData.height},
@@ -500,16 +503,17 @@ export class CanvasMask {
}
log.debug("Creating temporary canvas for mask processing");
const bounds = this.canvas.outputAreaBounds;
const tempCanvas = document.createElement('canvas');
tempCanvas.width = this.canvas.width;
tempCanvas.height = this.canvas.height;
tempCanvas.width = bounds.width;
tempCanvas.height = bounds.height;
const tempCtx = tempCanvas.getContext('2d', {willReadFrequently: true});
if (tempCtx) {
tempCtx.drawImage(resultImage, 0, 0, this.canvas.width, this.canvas.height);
tempCtx.drawImage(resultImage, 0, 0, bounds.width, bounds.height);
log.debug("Processing image data to create mask");
const imageData = tempCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
const imageData = tempCtx.getImageData(0, 0, bounds.width, bounds.height);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
@@ -529,13 +533,23 @@ export class CanvasMask {
await new Promise(resolve => maskAsImage.onload = resolve);
const maskCtx = this.maskTool.maskCtx;
const destX = -this.maskTool.x;
const destY = -this.maskTool.y;
// Pozycja gdzie ma być aplikowana maska na canvas MaskTool
// MaskTool canvas ma pozycję (maskTool.x, maskTool.y) w świecie
// Maska z edytora reprezentuje output bounds, więc musimy ją umieścić
// w pozycji bounds względem pozycji MaskTool
const destX = bounds.x - this.maskTool.x;
const destY = bounds.y - this.maskTool.y;
log.debug("Applying mask to canvas", {destX, destY});
log.debug("Applying mask to canvas", {
maskToolPos: {x: this.maskTool.x, y: this.maskTool.y},
boundsPos: {x: bounds.x, y: bounds.y},
destPos: {x: destX, y: destY},
maskSize: {width: bounds.width, height: bounds.height}
});
maskCtx.globalCompositeOperation = 'source-over';
maskCtx.clearRect(destX, destY, this.canvas.width, this.canvas.height);
maskCtx.clearRect(destX, destY, bounds.width, bounds.height);
maskCtx.drawImage(maskAsImage, destX, destY);

View File

@@ -84,6 +84,7 @@ export class CanvasRenderer {
});
this.drawCanvasOutline(ctx);
this.drawOutputAreaExtensionPreview(ctx); // Draw extension preview
this.drawPendingGenerationAreas(ctx); // Draw snapshot outlines
const maskImage = this.canvas.maskTool.getMask();
if (maskImage && this.canvas.maskTool.isOverlayVisible) {
@@ -98,7 +99,10 @@ export class CanvasRenderer {
ctx.globalAlpha = 1.0;
}
ctx.drawImage(maskImage, this.canvas.maskTool.x, this.canvas.maskTool.y);
// 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();
@@ -106,6 +110,7 @@ export class CanvasRenderer {
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
@@ -303,7 +308,9 @@ export class CanvasRenderer {
ctx.lineWidth = 2 / this.canvas.viewport.zoom;
ctx.setLineDash([10 / this.canvas.viewport.zoom, 5 / this.canvas.viewport.zoom]);
ctx.rect(0, 0, this.canvas.width, this.canvas.height);
// Rysuj outline w pozycji outputAreaBounds
const bounds = this.canvas.outputAreaBounds;
ctx.rect(bounds.x, bounds.y, bounds.width, bounds.height);
ctx.stroke();
ctx.setLineDash([]);
@@ -358,6 +365,34 @@ export class CanvasRenderer {
}
}
drawOutputAreaExtensionPreview(ctx: any) {
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;
// Podgląd pokazuje jak będą wyglądać nowe outputAreaBounds
const previewBounds = {
x: -ext.left, // Może być ujemne - wycinamy fragment świata
y: -ext.top, // Może być ujemne - wycinamy fragment świata
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: any) {
const areasToDraw = [];
@@ -389,4 +424,51 @@ export class CanvasRenderer {
ctx.restore();
});
}
drawMaskAreaBounds(ctx: any) {
// 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();
}
}

View File

@@ -9,6 +9,7 @@ export class CustomShapeMenu {
private worldX: number;
private worldY: number;
private uiInitialized: boolean;
private tooltip: HTMLDivElement | null;
constructor(canvas: Canvas) {
this.canvas = canvas;
@@ -16,6 +17,7 @@ export class CustomShapeMenu {
this.worldX = 0;
this.worldY = 0;
this.uiInitialized = false;
this.tooltip = null;
}
show(): void {
@@ -44,6 +46,7 @@ export class CustomShapeMenu {
this.element = null;
this.uiInitialized = false;
}
this.hideTooltip();
}
updateScreenPosition(): void {
@@ -124,7 +127,8 @@ export class CustomShapeMenu {
this._updateUI();
this.canvas.render();
}
},
"Automatically applies a mask based on the custom output area shape. When enabled, the mask will be applied to all layers within the shape boundary."
);
featureContainer.appendChild(checkboxContainer);
@@ -139,7 +143,8 @@ export class CustomShapeMenu {
this.canvas.maskTool.applyShapeMask();
this.canvas.render();
}
}
},
"Dilate (expand) or erode (contract) the shape mask. Positive values expand the mask outward, negative values shrink it inward."
);
expansionContainer.id = 'expansion-checkbox';
featureContainer.appendChild(expansionContainer);
@@ -233,7 +238,8 @@ export class CustomShapeMenu {
this.canvas.maskTool.applyShapeMask();
this.canvas.render();
}
}
},
"Softens the edges of the shape mask by creating a gradual transition from opaque to transparent."
);
featherContainer.id = 'feather-checkbox';
featureContainer.appendChild(featherContainer);
@@ -317,6 +323,157 @@ export class CustomShapeMenu {
featureContainer.appendChild(featherSliderContainer);
this.element.appendChild(featureContainer);
// Create output area extension container
const extensionContainer = document.createElement('div');
extensionContainer.id = 'output-area-extension-container';
extensionContainer.style.cssText = `
background-color: #282828;
border-radius: 6px;
margin-top: 6px;
padding: 4px 0;
border: 1px solid #444;
`;
// Add main extension checkbox
const extensionCheckboxContainer = this._createCheckbox(
() => `${this.canvas.outputAreaExtensionEnabled ? "☑" : "☐"} Extend output area`,
() => {
this.canvas.outputAreaExtensionEnabled = !this.canvas.outputAreaExtensionEnabled;
if (this.canvas.outputAreaExtensionEnabled) {
// When enabling, capture current canvas size as the baseline
this.canvas.originalCanvasSize = {
width: this.canvas.width,
height: this.canvas.height
};
log.info(`Captured current canvas size as baseline: ${this.canvas.width}x${this.canvas.height}`);
} else {
// Reset all extensions when disabled
this.canvas.outputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 };
}
this._updateExtensionUI();
this._updateCanvasSize(); // Update canvas size when toggling
this.canvas.render();
log.info(`Output area extension ${this.canvas.outputAreaExtensionEnabled ? 'enabled' : 'disabled'}`);
},
"Allows extending the output area boundaries in all directions without changing the custom shape."
);
extensionContainer.appendChild(extensionCheckboxContainer);
// Create sliders container
const slidersContainer = document.createElement('div');
slidersContainer.id = 'extension-sliders-container';
slidersContainer.style.cssText = `
margin: 0 8px 6px 8px;
padding: 4px 8px;
display: none;
`;
// Helper function to create a slider with preview system
const createExtensionSlider = (label: string, direction: 'top' | 'bottom' | 'left' | 'right') => {
const sliderContainer = document.createElement('div');
sliderContainer.style.cssText = `
margin: 6px 0;
`;
const sliderLabel = document.createElement('div');
sliderLabel.textContent = label;
sliderLabel.style.cssText = `
font-size: 11px;
margin-bottom: 4px;
color: #ccc;
`;
const slider = document.createElement('input');
slider.type = 'range';
slider.min = '0';
slider.max = '500';
slider.value = String(this.canvas.outputAreaExtensions[direction]);
slider.style.cssText = `
width: 100%;
height: 4px;
background: #555;
outline: none;
border-radius: 2px;
`;
const valueDisplay = document.createElement('div');
valueDisplay.style.cssText = `
font-size: 10px;
text-align: center;
margin-top: 2px;
color: #aaa;
`;
const updateDisplay = () => {
const value = parseInt(slider.value);
valueDisplay.textContent = `${value}px`;
};
let isDragging = false;
slider.onmousedown = () => {
isDragging = true;
};
slider.oninput = () => {
updateDisplay();
if (isDragging) {
// During dragging, show preview
const previewExtensions = { ...this.canvas.outputAreaExtensions };
previewExtensions[direction] = parseInt(slider.value);
this.canvas.outputAreaExtensionPreview = previewExtensions;
this.canvas.render();
} else {
// Not dragging, apply immediately (for keyboard navigation)
this.canvas.outputAreaExtensions[direction] = parseInt(slider.value);
this._updateCanvasSize();
this.canvas.render();
}
};
slider.onmouseup = () => {
if (isDragging) {
isDragging = false;
// Apply the final value and clear preview
this.canvas.outputAreaExtensions[direction] = parseInt(slider.value);
this.canvas.outputAreaExtensionPreview = null;
this._updateCanvasSize();
this.canvas.render();
}
};
// Handle mouse leave (in case user drags outside)
slider.onmouseleave = () => {
if (isDragging) {
isDragging = false;
// Apply the final value and clear preview
this.canvas.outputAreaExtensions[direction] = parseInt(slider.value);
this.canvas.outputAreaExtensionPreview = null;
this._updateCanvasSize();
this.canvas.render();
}
};
updateDisplay();
sliderContainer.appendChild(sliderLabel);
sliderContainer.appendChild(slider);
sliderContainer.appendChild(valueDisplay);
return sliderContainer;
};
// Add all four sliders
slidersContainer.appendChild(createExtensionSlider('Top extension:', 'top'));
slidersContainer.appendChild(createExtensionSlider('Bottom extension:', 'bottom'));
slidersContainer.appendChild(createExtensionSlider('Left extension:', 'left'));
slidersContainer.appendChild(createExtensionSlider('Right extension:', 'right'));
extensionContainer.appendChild(slidersContainer);
this.element.appendChild(extensionContainer);
// Add to DOM
if (this.canvas.canvas.parentElement) {
@@ -332,7 +489,7 @@ export class CustomShapeMenu {
this._addViewportChangeListener();
}
private _createCheckbox(textFn: () => string, clickHandler: () => void): HTMLDivElement {
private _createCheckbox(textFn: () => string, clickHandler: () => void, tooltipText?: string): HTMLDivElement {
const container = document.createElement('div');
container.style.cssText = `
margin: 6px 0 2px 0;
@@ -363,6 +520,11 @@ export class CustomShapeMenu {
updateText();
};
// Add tooltip if provided
if (tooltipText) {
this._addTooltip(container, tooltipText);
}
return container;
}
@@ -400,10 +562,40 @@ export class CustomShapeMenu {
checkbox.textContent = `${this.canvas.shapeMaskExpansion ? "☑" : "☐"} Dilate/Erode mask`;
} else if (index === 2) { // Feather checkbox
checkbox.textContent = `${this.canvas.shapeMaskFeather ? "☑" : "☐"} Feather edges`;
} else if (index === 3) { // Extension checkbox
checkbox.textContent = `${this.canvas.outputAreaExtensionEnabled ? "☑" : "☐"} Extend output area`;
}
});
}
private _updateExtensionUI(): void {
if (!this.element) return;
// Toggle visibility of extension sliders based on the extension checkbox state
const extensionSlidersContainer = this.element.querySelector('#extension-sliders-container') as HTMLElement;
if (extensionSlidersContainer) {
extensionSlidersContainer.style.display = this.canvas.outputAreaExtensionEnabled ? 'block' : 'none';
}
// Update slider values if they exist
if (this.canvas.outputAreaExtensionEnabled) {
const sliders = extensionSlidersContainer?.querySelectorAll('input[type="range"]');
const directions: ('top' | 'bottom' | 'left' | 'right')[] = ['top', 'bottom', 'left', 'right'];
sliders?.forEach((slider, index) => {
const direction = directions[index];
if (direction) {
(slider as HTMLInputElement).value = String(this.canvas.outputAreaExtensions[direction]);
// Update the corresponding value display
const valueDisplay = slider.parentElement?.querySelector('div:last-child');
if (valueDisplay) {
valueDisplay.textContent = `${this.canvas.outputAreaExtensions[direction]}px`;
}
}
});
}
}
/**
* Add viewport change listener to update shape preview when zooming/panning
*/
@@ -448,4 +640,126 @@ export class CustomShapeMenu {
// Start the viewport change detection
requestAnimationFrame(checkViewportChange);
}
private _addTooltip(element: HTMLElement, text: string): void {
element.addEventListener('mouseenter', (e) => {
this.showTooltip(text, e);
});
element.addEventListener('mouseleave', () => {
this.hideTooltip();
});
element.addEventListener('mousemove', (e) => {
if (this.tooltip && this.tooltip.style.display === 'block') {
this.updateTooltipPosition(e);
}
});
}
private showTooltip(text: string, event: MouseEvent): void {
this.hideTooltip(); // Hide any existing tooltip
this.tooltip = document.createElement('div');
this.tooltip.textContent = text;
this.tooltip.style.cssText = `
position: fixed;
background-color: #1a1a1a;
color: #ffffff;
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
font-family: sans-serif;
line-height: 1.4;
max-width: 250px;
word-wrap: break-word;
box-shadow: 0 4px 12px rgba(0,0,0,0.6);
border: 1px solid #444;
z-index: 10000;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease-in-out;
`;
document.body.appendChild(this.tooltip);
this.updateTooltipPosition(event);
// Fade in the tooltip
requestAnimationFrame(() => {
if (this.tooltip) {
this.tooltip.style.opacity = '1';
}
});
}
private updateTooltipPosition(event: MouseEvent): void {
if (!this.tooltip) return;
const tooltipRect = this.tooltip.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let x = event.clientX + 10;
let y = event.clientY - 10;
// Adjust if tooltip would go off the right edge
if (x + tooltipRect.width > viewportWidth) {
x = event.clientX - tooltipRect.width - 10;
}
// Adjust if tooltip would go off the bottom edge
if (y + tooltipRect.height > viewportHeight) {
y = event.clientY - tooltipRect.height - 10;
}
// Ensure tooltip doesn't go off the left or top edges
x = Math.max(5, x);
y = Math.max(5, y);
this.tooltip.style.left = `${x}px`;
this.tooltip.style.top = `${y}px`;
}
private hideTooltip(): void {
if (this.tooltip) {
this.tooltip.remove();
this.tooltip = null;
}
}
private _updateCanvasSize(): void {
if (!this.canvas.outputAreaExtensionEnabled) {
// Reset to original bounds when disabled
this.canvas.outputAreaBounds = {
x: 0,
y: 0,
width: this.canvas.originalCanvasSize.width,
height: this.canvas.originalCanvasSize.height
};
this.canvas.updateOutputAreaSize(
this.canvas.originalCanvasSize.width,
this.canvas.originalCanvasSize.height,
false
);
return;
}
const ext = this.canvas.outputAreaExtensions;
const newWidth = this.canvas.originalCanvasSize.width + ext.left + ext.right;
const newHeight = this.canvas.originalCanvasSize.height + ext.top + ext.bottom;
// Aktualizuj outputAreaBounds - "okno" w świecie które zostanie wyrenderowane
this.canvas.outputAreaBounds = {
x: -ext.left, // Może być ujemne - wycinamy fragment świata
y: -ext.top, // Może być ujemne - wycinamy fragment świata
width: newWidth,
height: newHeight
};
// Zmień rozmiar canvas (fizyczny rozmiar dla renderowania)
this.canvas.updateOutputAreaSize(newWidth, newHeight, false);
log.info(`Output area bounds updated: x=${this.canvas.outputAreaBounds.x}, y=${this.canvas.outputAreaBounds.y}, w=${newWidth}, h=${newHeight}`);
log.info(`Extensions: top=${ext.top}, bottom=${ext.bottom}, left=${ext.left}, right=${ext.right}`);
}
}

View File

@@ -100,15 +100,21 @@ export class MaskTool {
initMaskCanvas(): void {
const extraSpace = 2000; // Allow for a generous drawing area outside the output area
this.maskCanvas.width = this.canvasInstance.width + extraSpace;
this.maskCanvas.height = this.canvasInstance.height + extraSpace;
this.x = -extraSpace / 2;
this.y = -extraSpace / 2;
const bounds = this.canvasInstance.outputAreaBounds;
// Mask canvas should cover output area + extra space around it
const maskLeft = bounds.x - extraSpace / 2;
const maskTop = bounds.y - extraSpace / 2;
const maskWidth = bounds.width + extraSpace;
const maskHeight = bounds.height + extraSpace;
this.maskCanvas.width = maskWidth;
this.maskCanvas.height = maskHeight;
this.x = maskLeft;
this.y = maskTop;
this.maskCtx.clearRect(0, 0, this.maskCanvas.width, this.maskCanvas.height);
log.info(`Initialized mask canvas with extended size: ${this.maskCanvas.width}x${this.maskCanvas.height}, origin at (${this.x}, ${this.y})`);
log.info(`Initialized mask canvas with size: ${this.maskCanvas.width}x${this.maskCanvas.height}, positioned at (${this.x}, ${this.y}) to cover output area at (${bounds.x}, ${bounds.y})`);
}
activate(): void {
@@ -702,14 +708,78 @@ export class MaskTool {
log.info(`Mask position updated to (${this.x}, ${this.y})`);
}
/**
* Updates mask canvas to ensure it covers the current output area
* This should be called when output area position or size changes
*/
updateMaskCanvasForOutputArea(): void {
const extraSpace = 2000;
const bounds = this.canvasInstance.outputAreaBounds;
// Calculate required mask canvas bounds
const requiredLeft = bounds.x - extraSpace / 2;
const requiredTop = bounds.y - extraSpace / 2;
const requiredWidth = bounds.width + extraSpace;
const requiredHeight = bounds.height + extraSpace;
// Check if current mask canvas covers the required area
const currentRight = this.x + this.maskCanvas.width;
const currentBottom = this.y + this.maskCanvas.height;
const requiredRight = requiredLeft + requiredWidth;
const requiredBottom = requiredTop + requiredHeight;
const needsResize =
requiredLeft < this.x ||
requiredTop < this.y ||
requiredRight > currentRight ||
requiredBottom > currentBottom;
if (needsResize) {
log.info(`Updating mask canvas to cover output area at (${bounds.x}, ${bounds.y})`);
// Save current mask content
const oldMask = this.maskCanvas;
const oldX = this.x;
const oldY = this.y;
// Create new mask canvas with proper size and position
this.maskCanvas = document.createElement('canvas');
this.maskCanvas.width = requiredWidth;
this.maskCanvas.height = requiredHeight;
this.x = requiredLeft;
this.y = requiredTop;
const newMaskCtx = this.maskCanvas.getContext('2d', { willReadFrequently: true });
if (!newMaskCtx) {
throw new Error("Failed to get 2D context for new mask canvas");
}
this.maskCtx = newMaskCtx;
// Copy old mask content to new position
if (oldMask.width > 0 && oldMask.height > 0) {
const offsetX = oldX - this.x;
const offsetY = oldY - this.y;
this.maskCtx.drawImage(oldMask, offsetX, offsetY);
log.debug(`Preserved mask content with offset (${offsetX}, ${offsetY})`);
}
log.info(`Mask canvas updated to ${this.maskCanvas.width}x${this.maskCanvas.height} at (${this.x}, ${this.y})`);
}
}
toggleOverlayVisibility(): void {
this.isOverlayVisible = !this.isOverlayVisible;
log.info(`Mask overlay visibility toggled to: ${this.isOverlayVisible}`);
}
setMask(image: HTMLImageElement): void {
const destX = -this.x;
const destY = -this.y;
// Pozycja gdzie ma być aplikowana maska na canvas MaskTool
// MaskTool canvas ma pozycję (this.x, this.y) w świecie
// Maska reprezentuje output bounds, więc musimy ją umieścić
// w pozycji bounds względem pozycji MaskTool
const bounds = this.canvasInstance.outputAreaBounds;
const destX = bounds.x - this.x;
const destY = bounds.y - this.y;
this.maskCtx.clearRect(destX, destY, this.canvasInstance.width, this.canvasInstance.height);
@@ -719,12 +789,17 @@ export class MaskTool {
this.onStateChange();
}
this.canvasInstance.render();
log.info(`MaskTool updated with a new mask image at correct canvas position (${destX}, ${destY}).`);
log.info(`MaskTool updated with a new mask image at position (${destX}, ${destY}) relative to bounds (${bounds.x}, ${bounds.y}).`);
}
addMask(image: HTMLImageElement): void {
const destX = -this.x;
const destY = -this.y;
// Pozycja gdzie ma być aplikowana maska na canvas MaskTool
// MaskTool canvas ma pozycję (this.x, this.y) w świecie
// Maska z SAM reprezentuje output bounds, więc musimy ją umieścić
// w pozycji bounds względem pozycji MaskTool
const bounds = this.canvasInstance.outputAreaBounds;
const destX = bounds.x - this.x;
const destY = bounds.y - this.y;
// Don't clear existing mask - just add to it
this.maskCtx.globalCompositeOperation = 'source-over';
@@ -734,7 +809,7 @@ export class MaskTool {
this.onStateChange();
}
this.canvasInstance.render();
log.info(`MaskTool added mask overlay at correct canvas position (${destX}, ${destY}) without clearing existing mask.`);
log.info(`MaskTool added SAM mask overlay at position (${destX}, ${destY}) relative to bounds (${bounds.x}, ${bounds.y}) without clearing existing mask.`);
}
applyShapeMask(saveState: boolean = true): void {

View File

@@ -136,6 +136,13 @@ export interface Shape {
isClosed: boolean;
}
export interface OutputAreaBounds {
x: number; // Pozycja w świecie (może być ujemna)
y: number; // Pozycja w świecie (może być ujemna)
width: number; // Szerokość output area
height: number; // Wysokość output area
}
export interface Viewport {
x: number;
y: number;