mirror of
https://github.com/Azornes/Comfyui-LayerForge.git
synced 2026-03-21 20:52:12 -03:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9af1491c68 | ||
|
|
b04795d6e8 | ||
|
|
8d1545bb7e | ||
|
|
f6a240c535 | ||
|
|
d1ceb6291b | ||
|
|
868221b285 | ||
|
|
0f4f2cb1b0 | ||
|
|
7ce7194cbf | ||
|
|
990853f8c7 | ||
|
|
5fb163cd59 | ||
|
|
19d3238680 | ||
|
|
c9860cac9e | ||
|
|
00cf74a3c2 | ||
|
|
00a39d756d |
8
.github/ISSUE_TEMPLATE/docs_request.yml
vendored
8
.github/ISSUE_TEMPLATE/docs_request.yml
vendored
@@ -3,11 +3,17 @@ description: Suggest improvements or additions to documentation
|
||||
title: "[Docs] "
|
||||
labels: [documentation]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
> This template is only for suggesting improvements or additions **to existing documentation**.
|
||||
> If you want to suggest a new feature, functionality, or enhancement for the project itself, please use the **Feature Request** template instead.
|
||||
> Thank you!
|
||||
- type: input
|
||||
id: doc_area
|
||||
attributes:
|
||||
label: Area of documentation
|
||||
placeholder: e.g. Getting started, Node API, Deployment guide
|
||||
placeholder: e.g. Key Features, Installation, Controls & Shortcuts
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
|
||||
4
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
4
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
@@ -3,6 +3,10 @@ description: Suggest an idea for this project
|
||||
title: '[Feature Request]: '
|
||||
labels: ['enhancement']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**Before suggesting a new feature, please make sure you are using the latest version of the project and that this functionality does not already exist.**
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
|
||||
@@ -39,6 +39,8 @@ export class CanvasInteractions {
|
||||
keyMovementInProgress: false,
|
||||
canvasResizeRect: null,
|
||||
canvasMoveRect: null,
|
||||
outputAreaTransformHandle: null,
|
||||
outputAreaTransformAnchor: { x: 0, y: 0 },
|
||||
};
|
||||
this.originalLayerPositions = new Map();
|
||||
}
|
||||
@@ -157,6 +159,7 @@ export class CanvasInteractions {
|
||||
this.interaction.canvasMoveRect = null;
|
||||
this.interaction.hasClonedInDrag = false;
|
||||
this.interaction.transformingLayer = null;
|
||||
this.interaction.outputAreaTransformHandle = null;
|
||||
this.canvas.canvas.style.cursor = 'default';
|
||||
}
|
||||
handleMouseDown(e) {
|
||||
@@ -168,6 +171,18 @@ export class CanvasInteractions {
|
||||
// Don't render here - mask tool will handle its own drawing
|
||||
return;
|
||||
}
|
||||
if (this.interaction.mode === 'transformingOutputArea') {
|
||||
// Check if clicking on output area transform handle
|
||||
const handle = this.getOutputAreaHandle(coords.world);
|
||||
if (handle) {
|
||||
this.startOutputAreaTransform(handle, coords.world);
|
||||
return;
|
||||
}
|
||||
// If clicking outside, exit transform mode
|
||||
this.interaction.mode = 'none';
|
||||
this.canvas.render();
|
||||
return;
|
||||
}
|
||||
if (this.canvas.shapeTool.isActive) {
|
||||
this.canvas.shapeTool.addPoint(coords.world);
|
||||
return;
|
||||
@@ -258,6 +273,14 @@ export class CanvasInteractions {
|
||||
case 'movingCanvas':
|
||||
this.updateCanvasMove(coords.world);
|
||||
break;
|
||||
case 'transformingOutputArea':
|
||||
if (this.interaction.outputAreaTransformHandle) {
|
||||
this.resizeOutputAreaFromHandle(coords.world, e.shiftKey);
|
||||
}
|
||||
else {
|
||||
this.updateOutputAreaTransformCursor(coords.world);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
this.updateCursor(coords.world);
|
||||
// Update brush cursor on overlay if mask tool is active
|
||||
@@ -285,6 +308,10 @@ export class CanvasInteractions {
|
||||
if (this.interaction.mode === 'movingCanvas') {
|
||||
this.finalizeCanvasMove();
|
||||
}
|
||||
if (this.interaction.mode === 'transformingOutputArea' && this.interaction.outputAreaTransformHandle) {
|
||||
this.finalizeOutputAreaTransform();
|
||||
return;
|
||||
}
|
||||
// Log layer positions when dragging ends
|
||||
if (this.interaction.mode === 'dragging' && this.canvas.canvasSelection.selectedLayers.length > 0) {
|
||||
this.logDragCompletion(coords);
|
||||
@@ -1128,4 +1155,168 @@ export class CanvasInteractions {
|
||||
}
|
||||
await this.canvas.canvasLayers.clipboardManager.handlePaste('mouse', preference);
|
||||
}
|
||||
// New methods for output area transformation
|
||||
activateOutputAreaTransform() {
|
||||
// Clear any existing interaction state before starting transform
|
||||
this.resetInteractionState();
|
||||
// Deactivate any active tools that might conflict
|
||||
if (this.canvas.shapeTool.isActive) {
|
||||
this.canvas.shapeTool.deactivate();
|
||||
}
|
||||
if (this.canvas.maskTool.isActive) {
|
||||
this.canvas.maskTool.deactivate();
|
||||
}
|
||||
// Clear selection to avoid confusion
|
||||
this.canvas.canvasSelection.updateSelection([]);
|
||||
// Set transform mode
|
||||
this.interaction.mode = 'transformingOutputArea';
|
||||
this.canvas.render();
|
||||
}
|
||||
getOutputAreaHandle(worldCoords) {
|
||||
const bounds = this.canvas.outputAreaBounds;
|
||||
const threshold = 10 / this.canvas.viewport.zoom;
|
||||
// Define handle positions
|
||||
const handles = {
|
||||
'nw': { x: bounds.x, y: bounds.y },
|
||||
'n': { x: bounds.x + bounds.width / 2, y: bounds.y },
|
||||
'ne': { x: bounds.x + bounds.width, y: bounds.y },
|
||||
'e': { x: bounds.x + bounds.width, y: bounds.y + bounds.height / 2 },
|
||||
'se': { x: bounds.x + bounds.width, y: bounds.y + bounds.height },
|
||||
's': { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height },
|
||||
'sw': { x: bounds.x, y: bounds.y + bounds.height },
|
||||
'w': { x: bounds.x, y: bounds.y + bounds.height / 2 },
|
||||
};
|
||||
for (const [name, pos] of Object.entries(handles)) {
|
||||
const dx = worldCoords.x - pos.x;
|
||||
const dy = worldCoords.y - pos.y;
|
||||
if (Math.sqrt(dx * dx + dy * dy) < threshold) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
startOutputAreaTransform(handle, worldCoords) {
|
||||
this.interaction.outputAreaTransformHandle = handle;
|
||||
this.interaction.dragStart = { ...worldCoords };
|
||||
const bounds = this.canvas.outputAreaBounds;
|
||||
this.interaction.transformOrigin = {
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
rotation: 0,
|
||||
centerX: bounds.x + bounds.width / 2,
|
||||
centerY: bounds.y + bounds.height / 2
|
||||
};
|
||||
// Set anchor point (opposite corner for resize)
|
||||
const anchorMap = {
|
||||
'nw': { x: bounds.x + bounds.width, y: bounds.y + bounds.height },
|
||||
'n': { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height },
|
||||
'ne': { x: bounds.x, y: bounds.y + bounds.height },
|
||||
'e': { x: bounds.x, y: bounds.y + bounds.height / 2 },
|
||||
'se': { x: bounds.x, y: bounds.y },
|
||||
's': { x: bounds.x + bounds.width / 2, y: bounds.y },
|
||||
'sw': { x: bounds.x + bounds.width, y: bounds.y },
|
||||
'w': { x: bounds.x + bounds.width, y: bounds.y + bounds.height / 2 },
|
||||
};
|
||||
this.interaction.outputAreaTransformAnchor = anchorMap[handle];
|
||||
}
|
||||
resizeOutputAreaFromHandle(worldCoords, isShiftPressed) {
|
||||
const o = this.interaction.transformOrigin;
|
||||
if (!o)
|
||||
return;
|
||||
const handle = this.interaction.outputAreaTransformHandle;
|
||||
const anchor = this.interaction.outputAreaTransformAnchor;
|
||||
let newX = o.x;
|
||||
let newY = o.y;
|
||||
let newWidth = o.width;
|
||||
let newHeight = o.height;
|
||||
// Calculate new dimensions based on handle
|
||||
if (handle?.includes('w')) {
|
||||
const deltaX = worldCoords.x - anchor.x;
|
||||
newWidth = Math.abs(deltaX);
|
||||
newX = Math.min(worldCoords.x, anchor.x);
|
||||
}
|
||||
if (handle?.includes('e')) {
|
||||
const deltaX = worldCoords.x - anchor.x;
|
||||
newWidth = Math.abs(deltaX);
|
||||
newX = Math.min(worldCoords.x, anchor.x);
|
||||
}
|
||||
if (handle?.includes('n')) {
|
||||
const deltaY = worldCoords.y - anchor.y;
|
||||
newHeight = Math.abs(deltaY);
|
||||
newY = Math.min(worldCoords.y, anchor.y);
|
||||
}
|
||||
if (handle?.includes('s')) {
|
||||
const deltaY = worldCoords.y - anchor.y;
|
||||
newHeight = Math.abs(deltaY);
|
||||
newY = Math.min(worldCoords.y, anchor.y);
|
||||
}
|
||||
// Maintain aspect ratio if shift is held
|
||||
if (isShiftPressed && o.width > 0 && o.height > 0) {
|
||||
const aspectRatio = o.width / o.height;
|
||||
if (handle === 'n' || handle === 's') {
|
||||
newWidth = newHeight * aspectRatio;
|
||||
}
|
||||
else if (handle === 'e' || handle === 'w') {
|
||||
newHeight = newWidth / aspectRatio;
|
||||
}
|
||||
else {
|
||||
// Corner handles
|
||||
const proposedRatio = newWidth / newHeight;
|
||||
if (proposedRatio > aspectRatio) {
|
||||
newHeight = newWidth / aspectRatio;
|
||||
}
|
||||
else {
|
||||
newWidth = newHeight * aspectRatio;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Snap to grid if Ctrl is held
|
||||
if (this.interaction.isCtrlPressed) {
|
||||
newX = snapToGrid(newX);
|
||||
newY = snapToGrid(newY);
|
||||
newWidth = snapToGrid(newWidth);
|
||||
newHeight = snapToGrid(newHeight);
|
||||
}
|
||||
// Apply minimum size
|
||||
if (newWidth < 10)
|
||||
newWidth = 10;
|
||||
if (newHeight < 10)
|
||||
newHeight = 10;
|
||||
// Update output area bounds temporarily for preview
|
||||
this.canvas.outputAreaBounds = {
|
||||
x: newX,
|
||||
y: newY,
|
||||
width: newWidth,
|
||||
height: newHeight
|
||||
};
|
||||
this.canvas.render();
|
||||
}
|
||||
updateOutputAreaTransformCursor(worldCoords) {
|
||||
const handle = this.getOutputAreaHandle(worldCoords);
|
||||
if (handle) {
|
||||
const cursorMap = {
|
||||
'n': 'ns-resize', 's': 'ns-resize',
|
||||
'e': 'ew-resize', 'w': 'ew-resize',
|
||||
'nw': 'nwse-resize', 'se': 'nwse-resize',
|
||||
'ne': 'nesw-resize', 'sw': 'nesw-resize',
|
||||
};
|
||||
this.canvas.canvas.style.cursor = cursorMap[handle] || 'default';
|
||||
}
|
||||
else {
|
||||
this.canvas.canvas.style.cursor = 'default';
|
||||
}
|
||||
}
|
||||
finalizeOutputAreaTransform() {
|
||||
const bounds = this.canvas.outputAreaBounds;
|
||||
// Update canvas size and mask tool
|
||||
this.canvas.updateOutputAreaSize(bounds.width, bounds.height);
|
||||
// Update mask canvas for new output area
|
||||
this.canvas.maskTool.updateMaskCanvasForOutputArea();
|
||||
// Save state
|
||||
this.canvas.saveState();
|
||||
// Reset transform handle but keep transform mode active
|
||||
this.interaction.outputAreaTransformHandle = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@ export class CanvasLayers {
|
||||
tempCtx.globalCompositeOperation = 'destination-in';
|
||||
tempCtx.drawImage(maskCanvas, 0, 0);
|
||||
const newImage = new Image();
|
||||
newImage.crossOrigin = 'anonymous';
|
||||
newImage.src = tempCanvas.toDataURL();
|
||||
layer.image = newImage;
|
||||
}
|
||||
@@ -158,6 +159,7 @@ export class CanvasLayers {
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => {
|
||||
if (!this.canvas.node.imgs) {
|
||||
this.canvas.node.imgs = [];
|
||||
@@ -196,6 +198,117 @@ export class CanvasLayers {
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Automatically adjust output area to fit selected layers
|
||||
* Calculates precise bounding box for all selected layers including rotation and crop mode support
|
||||
*/
|
||||
autoAdjustOutputToSelection() {
|
||||
const selectedLayers = this.canvas.canvasSelection.selectedLayers;
|
||||
if (selectedLayers.length === 0) {
|
||||
return false;
|
||||
}
|
||||
// Calculate bounding box of selected layers
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
selectedLayers.forEach((layer) => {
|
||||
// For crop mode layers, use the visible crop bounds
|
||||
if (layer.cropMode && layer.cropBounds && layer.originalWidth && layer.originalHeight) {
|
||||
const layerScaleX = layer.width / layer.originalWidth;
|
||||
const layerScaleY = layer.height / layer.originalHeight;
|
||||
const cropWidth = layer.cropBounds.width * layerScaleX;
|
||||
const cropHeight = layer.cropBounds.height * layerScaleY;
|
||||
const effectiveCropX = layer.flipH
|
||||
? layer.originalWidth - (layer.cropBounds.x + layer.cropBounds.width)
|
||||
: layer.cropBounds.x;
|
||||
const effectiveCropY = layer.flipV
|
||||
? layer.originalHeight - (layer.cropBounds.y + layer.cropBounds.height)
|
||||
: layer.cropBounds.y;
|
||||
const cropOffsetX = effectiveCropX * layerScaleX;
|
||||
const cropOffsetY = effectiveCropY * layerScaleY;
|
||||
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);
|
||||
// Calculate corners of the crop rectangle
|
||||
const corners = [
|
||||
{ x: cropOffsetX, y: cropOffsetY },
|
||||
{ x: cropOffsetX + cropWidth, y: cropOffsetY },
|
||||
{ x: cropOffsetX + cropWidth, y: cropOffsetY + cropHeight },
|
||||
{ x: cropOffsetX, y: cropOffsetY + cropHeight }
|
||||
];
|
||||
corners.forEach(p => {
|
||||
// Transform to layer space (centered)
|
||||
const localX = p.x - layer.width / 2;
|
||||
const localY = p.y - layer.height / 2;
|
||||
// Apply rotation
|
||||
const worldX = centerX + (localX * cos - localY * sin);
|
||||
const worldY = centerY + (localX * sin + localY * cos);
|
||||
minX = Math.min(minX, worldX);
|
||||
minY = Math.min(minY, worldY);
|
||||
maxX = Math.max(maxX, worldX);
|
||||
maxY = Math.max(maxY, worldY);
|
||||
});
|
||||
}
|
||||
else {
|
||||
// For normal layers, use the full layer bounds
|
||||
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);
|
||||
});
|
||||
}
|
||||
});
|
||||
// Calculate new dimensions without padding for precise fit
|
||||
const newWidth = Math.ceil(maxX - minX);
|
||||
const newHeight = Math.ceil(maxY - minY);
|
||||
if (newWidth <= 0 || newHeight <= 0) {
|
||||
log.error("Cannot calculate valid output area dimensions");
|
||||
return false;
|
||||
}
|
||||
// Update output area bounds
|
||||
this.canvas.outputAreaBounds = {
|
||||
x: minX,
|
||||
y: minY,
|
||||
width: newWidth,
|
||||
height: newHeight
|
||||
};
|
||||
// Update canvas dimensions
|
||||
this.canvas.width = newWidth;
|
||||
this.canvas.height = newHeight;
|
||||
this.canvas.maskTool.resize(newWidth, newHeight);
|
||||
this.canvas.canvas.width = newWidth;
|
||||
this.canvas.canvas.height = newHeight;
|
||||
// Reset extensions
|
||||
this.canvas.outputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 };
|
||||
this.canvas.outputAreaExtensionEnabled = false;
|
||||
this.canvas.lastOutputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 };
|
||||
// Update original canvas size and position
|
||||
this.canvas.originalCanvasSize = { width: newWidth, height: newHeight };
|
||||
this.canvas.originalOutputAreaPosition = { x: minX, y: minY };
|
||||
// Save state and render
|
||||
this.canvas.render();
|
||||
this.canvas.saveState();
|
||||
log.info(`Auto-adjusted output area to fit ${selectedLayers.length} selected layer(s)`, {
|
||||
bounds: { x: minX, y: minY, width: newWidth, height: newHeight }
|
||||
});
|
||||
return true;
|
||||
}
|
||||
pasteLayers() {
|
||||
if (this.internalClipboard.length === 0)
|
||||
return;
|
||||
@@ -742,6 +855,7 @@ export class CanvasLayers {
|
||||
}
|
||||
// Convert canvas to image
|
||||
const processedImage = new Image();
|
||||
processedImage.crossOrigin = 'anonymous';
|
||||
processedImage.src = processedCanvas.toDataURL();
|
||||
return processedImage;
|
||||
}
|
||||
@@ -1611,6 +1725,7 @@ export class CanvasLayers {
|
||||
tempCtx.translate(-minX, -minY);
|
||||
this._drawLayers(tempCtx, this.canvas.canvasSelection.selectedLayers);
|
||||
const fusedImage = new Image();
|
||||
fusedImage.crossOrigin = 'anonymous';
|
||||
fusedImage.src = tempCanvas.toDataURL();
|
||||
await new Promise((resolve, reject) => {
|
||||
fusedImage.onload = resolve;
|
||||
|
||||
@@ -103,6 +103,7 @@ export class CanvasLayersPanel {
|
||||
this.container.tabIndex = 0; // Umożliwia fokus na panelu
|
||||
this.container.innerHTML = `
|
||||
<div class="layers-panel-header">
|
||||
<div class="master-visibility-toggle" title="Toggle all layers visibility"></div>
|
||||
<span class="layers-panel-title">Layers</span>
|
||||
<div class="layers-panel-controls">
|
||||
<button class="layers-btn" id="delete-layer-btn" title="Delete layer"></button>
|
||||
@@ -115,6 +116,7 @@ export class CanvasLayersPanel {
|
||||
this.layersContainer = this.container.querySelector('#layers-container');
|
||||
// Setup event listeners dla przycisków
|
||||
this.setupControlButtons();
|
||||
this.setupMasterVisibilityToggle();
|
||||
// Dodaj listener dla klawiatury, aby usuwanie działało z panelu
|
||||
this.container.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
@@ -142,6 +144,67 @@ export class CanvasLayersPanel {
|
||||
// Initial button state update
|
||||
this.updateButtonStates();
|
||||
}
|
||||
setupMasterVisibilityToggle() {
|
||||
if (!this.container)
|
||||
return;
|
||||
const toggleContainer = this.container.querySelector('.master-visibility-toggle');
|
||||
if (!toggleContainer)
|
||||
return;
|
||||
const updateToggleState = () => {
|
||||
const total = this.canvas.layers.length;
|
||||
const visibleCount = this.canvas.layers.filter(l => l.visible).length;
|
||||
toggleContainer.innerHTML = '';
|
||||
const checkboxContainer = document.createElement('div');
|
||||
checkboxContainer.className = 'checkbox-container';
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.id = 'master-visibility-checkbox';
|
||||
const customCheckbox = document.createElement('span');
|
||||
customCheckbox.className = 'custom-checkbox';
|
||||
checkboxContainer.appendChild(checkbox);
|
||||
checkboxContainer.appendChild(customCheckbox);
|
||||
if (visibleCount === 0) {
|
||||
checkbox.checked = false;
|
||||
checkbox.indeterminate = false;
|
||||
customCheckbox.classList.remove('checked', 'indeterminate');
|
||||
}
|
||||
else if (visibleCount === total) {
|
||||
checkbox.checked = true;
|
||||
checkbox.indeterminate = false;
|
||||
customCheckbox.classList.add('checked');
|
||||
customCheckbox.classList.remove('indeterminate');
|
||||
}
|
||||
else {
|
||||
checkbox.checked = false;
|
||||
checkbox.indeterminate = true;
|
||||
customCheckbox.classList.add('indeterminate');
|
||||
customCheckbox.classList.remove('checked');
|
||||
}
|
||||
checkboxContainer.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
let newVisible;
|
||||
if (checkbox.indeterminate) {
|
||||
newVisible = false; // hide all when mixed
|
||||
}
|
||||
else if (checkbox.checked) {
|
||||
newVisible = false; // toggle to hide all
|
||||
}
|
||||
else {
|
||||
newVisible = true; // toggle to show all
|
||||
}
|
||||
this.canvas.layers.forEach(layer => {
|
||||
layer.visible = newVisible;
|
||||
});
|
||||
this.canvas.render();
|
||||
this.canvas.requestSaveState();
|
||||
updateToggleState();
|
||||
this.renderLayers();
|
||||
});
|
||||
toggleContainer.appendChild(checkboxContainer);
|
||||
};
|
||||
updateToggleState();
|
||||
this._updateMasterVisibilityToggle = updateToggleState;
|
||||
}
|
||||
renderLayers() {
|
||||
if (!this.layersContainer) {
|
||||
log.warn('Layers container not initialized');
|
||||
@@ -158,6 +221,8 @@ export class CanvasLayersPanel {
|
||||
if (this.layersContainer)
|
||||
this.layersContainer.appendChild(layerElement);
|
||||
});
|
||||
if (this._updateMasterVisibilityToggle)
|
||||
this._updateMasterVisibilityToggle();
|
||||
log.debug(`Rendered ${sortedLayers.length} layers`);
|
||||
}
|
||||
createLayerElement(layer, index) {
|
||||
|
||||
@@ -147,6 +147,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.renderOutputAreaTransformHandles(ctx); // Draw output area transform handles
|
||||
this.renderLayerInfo(ctx);
|
||||
// Update custom shape menu position and visibility
|
||||
if (this.canvas.outputAreaShape) {
|
||||
@@ -652,8 +653,8 @@ export class CanvasRenderer {
|
||||
this.updateStrokeOverlaySize();
|
||||
// Position above main canvas but below cursor overlay
|
||||
this.strokeOverlayCanvas.style.position = 'absolute';
|
||||
this.strokeOverlayCanvas.style.left = '0px';
|
||||
this.strokeOverlayCanvas.style.top = '0px';
|
||||
this.strokeOverlayCanvas.style.left = '1px';
|
||||
this.strokeOverlayCanvas.style.top = '1px';
|
||||
this.strokeOverlayCanvas.style.pointerEvents = 'none';
|
||||
this.strokeOverlayCanvas.style.zIndex = '19'; // Below cursor overlay (20)
|
||||
// Opacity is now controlled by MaskTool.previewOpacity
|
||||
@@ -832,4 +833,40 @@ export class CanvasRenderer {
|
||||
// Just ensure it's the right size
|
||||
this.updateOverlaySize();
|
||||
}
|
||||
/**
|
||||
* Draw transform handles for output area when in transform mode
|
||||
*/
|
||||
renderOutputAreaTransformHandles(ctx) {
|
||||
if (this.canvas.canvasInteractions.interaction.mode !== 'transformingOutputArea') {
|
||||
return;
|
||||
}
|
||||
const bounds = this.canvas.outputAreaBounds;
|
||||
const handleRadius = 5 / this.canvas.viewport.zoom;
|
||||
// Define handle positions
|
||||
const handles = {
|
||||
'nw': { x: bounds.x, y: bounds.y },
|
||||
'n': { x: bounds.x + bounds.width / 2, y: bounds.y },
|
||||
'ne': { x: bounds.x + bounds.width, y: bounds.y },
|
||||
'e': { x: bounds.x + bounds.width, y: bounds.y + bounds.height / 2 },
|
||||
'se': { x: bounds.x + bounds.width, y: bounds.y + bounds.height },
|
||||
's': { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height },
|
||||
'sw': { x: bounds.x, y: bounds.y + bounds.height },
|
||||
'w': { x: bounds.x, y: bounds.y + bounds.height / 2 },
|
||||
};
|
||||
// Draw handles
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.strokeStyle = '#000000';
|
||||
ctx.lineWidth = 1 / this.canvas.viewport.zoom;
|
||||
for (const [name, pos] of Object.entries(handles)) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(pos.x, pos.y, handleRadius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
}
|
||||
// Draw a highlight around the output area
|
||||
ctx.strokeStyle = 'rgba(0, 150, 255, 0.8)';
|
||||
ctx.lineWidth = 3 / this.canvas.viewport.zoom;
|
||||
ctx.setLineDash([]);
|
||||
ctx.strokeRect(bounds.x, bounds.y, bounds.width, bounds.height);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,6 +200,7 @@ export class CanvasState {
|
||||
_createLayerFromSrc(layerData, imageSrc, index, resolve) {
|
||||
if (typeof imageSrc === 'string') {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => {
|
||||
log.debug(`Layer ${index}: Image loaded successfully.`);
|
||||
const newLayer = { ...layerData, image: img };
|
||||
@@ -216,6 +217,7 @@ export class CanvasState {
|
||||
if (ctx) {
|
||||
ctx.drawImage(imageSrc, 0, 0);
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => {
|
||||
log.debug(`Layer ${index}: Image loaded successfully from ImageBitmap.`);
|
||||
const newLayer = { ...layerData, image: img };
|
||||
|
||||
132
js/CanvasView.js
132
js/CanvasView.js
@@ -8,7 +8,7 @@ import { clearAllCanvasStates } from "./db.js";
|
||||
import { ImageCache } from "./ImageCache.js";
|
||||
import { createCanvas } from "./utils/CommonUtils.js";
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
import { showErrorNotification, showSuccessNotification, showInfoNotification } from "./utils/NotificationUtils.js";
|
||||
import { showErrorNotification, showSuccessNotification, showInfoNotification, showWarningNotification } from "./utils/NotificationUtils.js";
|
||||
import { iconLoader, LAYERFORGE_TOOLS } from "./utils/IconLoader.js";
|
||||
import { setupSAMDetectorHook } from "./SAMDetectorIntegration.js";
|
||||
const log = createModuleLogger('Canvas_view');
|
||||
@@ -213,88 +213,32 @@ async function createCanvasWidget(node, widget, app) {
|
||||
]),
|
||||
$el("div.painter-separator"),
|
||||
$el("div.painter-button-group", {}, [
|
||||
$el("button.painter-button.requires-selection", {
|
||||
textContent: "Auto Adjust Output",
|
||||
title: "Automatically adjust output area to fit selected layers",
|
||||
onclick: () => {
|
||||
const selectedLayers = canvas.canvasSelection.selectedLayers;
|
||||
if (selectedLayers.length === 0) {
|
||||
showWarningNotification("Please select one or more layers first");
|
||||
return;
|
||||
}
|
||||
const success = canvas.canvasLayers.autoAdjustOutputToSelection();
|
||||
if (success) {
|
||||
const bounds = canvas.outputAreaBounds;
|
||||
showSuccessNotification(`Output area adjusted to ${bounds.width}x${bounds.height}px`);
|
||||
}
|
||||
else {
|
||||
showErrorNotification("Cannot calculate valid output area dimensions");
|
||||
}
|
||||
}
|
||||
}),
|
||||
$el("button.painter-button", {
|
||||
textContent: "Output Area Size",
|
||||
title: "Set the size of the output area",
|
||||
title: "Transform output area - drag handles to resize",
|
||||
onclick: () => {
|
||||
const dialog = $el("div.painter-dialog", {
|
||||
style: {
|
||||
position: 'fixed',
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
zIndex: '9999'
|
||||
}
|
||||
}, [
|
||||
$el("div", {
|
||||
style: {
|
||||
color: "white",
|
||||
marginBottom: "10px"
|
||||
}
|
||||
}, [
|
||||
$el("label", {
|
||||
style: {
|
||||
marginRight: "5px"
|
||||
}
|
||||
}, [
|
||||
$el("span", {}, ["Width: "])
|
||||
]),
|
||||
$el("input", {
|
||||
type: "number",
|
||||
id: "canvas-width",
|
||||
value: String(canvas.width),
|
||||
min: "1",
|
||||
max: "4096"
|
||||
})
|
||||
]),
|
||||
$el("div", {
|
||||
style: {
|
||||
color: "white",
|
||||
marginBottom: "10px"
|
||||
}
|
||||
}, [
|
||||
$el("label", {
|
||||
style: {
|
||||
marginRight: "5px"
|
||||
}
|
||||
}, [
|
||||
$el("span", {}, ["Height: "])
|
||||
]),
|
||||
$el("input", {
|
||||
type: "number",
|
||||
id: "canvas-height",
|
||||
value: String(canvas.height),
|
||||
min: "1",
|
||||
max: "4096"
|
||||
})
|
||||
]),
|
||||
$el("div", {
|
||||
style: {
|
||||
textAlign: "right"
|
||||
}
|
||||
}, [
|
||||
$el("button", {
|
||||
id: "cancel-size",
|
||||
textContent: "Cancel"
|
||||
}),
|
||||
$el("button", {
|
||||
id: "confirm-size",
|
||||
textContent: "OK"
|
||||
})
|
||||
])
|
||||
]);
|
||||
document.body.appendChild(dialog);
|
||||
document.getElementById('confirm-size').onclick = () => {
|
||||
const widthInput = document.getElementById('canvas-width');
|
||||
const heightInput = document.getElementById('canvas-height');
|
||||
const width = parseInt(widthInput.value) || canvas.width;
|
||||
const height = parseInt(heightInput.value) || canvas.height;
|
||||
canvas.setOutputAreaSize(width, height);
|
||||
document.body.removeChild(dialog);
|
||||
};
|
||||
document.getElementById('cancel-size').onclick = () => {
|
||||
document.body.removeChild(dialog);
|
||||
};
|
||||
// Activate output area transform mode
|
||||
canvas.canvasInteractions.activateOutputAreaTransform();
|
||||
showInfoNotification("Click and drag the handles to resize the output area. Click anywhere else to exit.", 3000);
|
||||
}
|
||||
}),
|
||||
$el("button.painter-button.requires-selection", {
|
||||
@@ -1352,8 +1296,8 @@ app.registerExtension({
|
||||
callback: async () => {
|
||||
try {
|
||||
log.info("Opening LayerForge canvas in MaskEditor");
|
||||
if (self.canvasWidget && self.canvasWidget.startMaskEditor) {
|
||||
await self.canvasWidget.startMaskEditor(null, true);
|
||||
if (self.canvasWidget && self.canvasWidget.canvas) {
|
||||
await self.canvasWidget.canvas.startMaskEditor(null, true);
|
||||
}
|
||||
else {
|
||||
log.error("Canvas widget not available");
|
||||
@@ -1370,9 +1314,9 @@ app.registerExtension({
|
||||
content: "Open Image",
|
||||
callback: async () => {
|
||||
try {
|
||||
if (!self.canvasWidget)
|
||||
if (!self.canvasWidget || !self.canvasWidget.canvas)
|
||||
return;
|
||||
const blob = await self.canvasWidget.getFlattenedCanvasAsBlob();
|
||||
const blob = await self.canvasWidget.canvas.canvasLayers.getFlattenedCanvasAsBlob();
|
||||
if (!blob)
|
||||
return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -1388,9 +1332,9 @@ app.registerExtension({
|
||||
content: "Open Image with Mask Alpha",
|
||||
callback: async () => {
|
||||
try {
|
||||
if (!self.canvasWidget)
|
||||
if (!self.canvasWidget || !self.canvasWidget.canvas)
|
||||
return;
|
||||
const blob = await self.canvasWidget.getFlattenedCanvasWithMaskAsBlob();
|
||||
const blob = await self.canvasWidget.canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
|
||||
if (!blob)
|
||||
return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -1406,9 +1350,9 @@ app.registerExtension({
|
||||
content: "Copy Image",
|
||||
callback: async () => {
|
||||
try {
|
||||
if (!self.canvasWidget)
|
||||
if (!self.canvasWidget || !self.canvasWidget.canvas)
|
||||
return;
|
||||
const blob = await self.canvasWidget.getFlattenedCanvasAsBlob();
|
||||
const blob = await self.canvasWidget.canvas.canvasLayers.getFlattenedCanvasAsBlob();
|
||||
if (!blob)
|
||||
return;
|
||||
const item = new ClipboardItem({ 'image/png': blob });
|
||||
@@ -1425,9 +1369,9 @@ app.registerExtension({
|
||||
content: "Copy Image with Mask Alpha",
|
||||
callback: async () => {
|
||||
try {
|
||||
if (!self.canvasWidget)
|
||||
if (!self.canvasWidget || !self.canvasWidget.canvas)
|
||||
return;
|
||||
const blob = await self.canvasWidget.getFlattenedCanvasWithMaskAsBlob();
|
||||
const blob = await self.canvasWidget.canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
|
||||
if (!blob)
|
||||
return;
|
||||
const item = new ClipboardItem({ 'image/png': blob });
|
||||
@@ -1444,9 +1388,9 @@ app.registerExtension({
|
||||
content: "Save Image",
|
||||
callback: async () => {
|
||||
try {
|
||||
if (!self.canvasWidget)
|
||||
if (!self.canvasWidget || !self.canvasWidget.canvas)
|
||||
return;
|
||||
const blob = await self.canvasWidget.getFlattenedCanvasAsBlob();
|
||||
const blob = await self.canvasWidget.canvas.canvasLayers.getFlattenedCanvasAsBlob();
|
||||
if (!blob)
|
||||
return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -1467,9 +1411,9 @@ app.registerExtension({
|
||||
content: "Save Image with Mask Alpha",
|
||||
callback: async () => {
|
||||
try {
|
||||
if (!self.canvasWidget)
|
||||
if (!self.canvasWidget || !self.canvasWidget.canvas)
|
||||
return;
|
||||
const blob = await self.canvasWidget.getFlattenedCanvasWithMaskAsBlob();
|
||||
const blob = await self.canvasWidget.canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
|
||||
if (!blob)
|
||||
return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
@@ -638,7 +638,7 @@
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
z-index: 111;
|
||||
z-index: 999999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -23,6 +23,85 @@
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.checkbox-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 0;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.checkbox-container:hover {
|
||||
background-color: #4a4a4a;
|
||||
}
|
||||
|
||||
.checkbox-container input[type="checkbox"] {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
height: 0;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.checkbox-container .custom-checkbox {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
background-color: #2a2a2a;
|
||||
border: 1px solid #666;
|
||||
border-radius: 3px;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.checkbox-container input:checked ~ .custom-checkbox {
|
||||
background-color: #3a76d6;
|
||||
border-color: #3a76d6;
|
||||
}
|
||||
|
||||
.checkbox-container .custom-checkbox::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
display: none;
|
||||
left: 5px;
|
||||
top: 1px;
|
||||
width: 4px;
|
||||
height: 9px;
|
||||
border: solid white;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.checkbox-container input:checked ~ .custom-checkbox::after {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.checkbox-container input:indeterminate ~ .custom-checkbox {
|
||||
background-color: #3a76d6;
|
||||
border-color: #3a76d6;
|
||||
}
|
||||
|
||||
.checkbox-container input:indeterminate ~ .custom-checkbox::after {
|
||||
display: block;
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
left: 3px;
|
||||
width: 8px;
|
||||
height: 2px;
|
||||
background-color: white;
|
||||
border: none;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.checkbox-container:hover {
|
||||
background-color: #4a4a4a;
|
||||
}
|
||||
|
||||
.layers-panel-title {
|
||||
font-weight: bold;
|
||||
color: #ffffff;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createModuleLogger } from "./LoggerUtils.js";
|
||||
import { showNotification, showInfoNotification } from "./NotificationUtils.js";
|
||||
import { showNotification, showInfoNotification, showErrorNotification, showWarningNotification } from "./NotificationUtils.js";
|
||||
import { withErrorHandling, createValidationError, createNetworkError, createFileError } from "../ErrorHandler.js";
|
||||
import { safeClipspacePaste } from "./ClipspaceUtils.js";
|
||||
// @ts-ignore
|
||||
@@ -18,6 +18,7 @@ export class ClipboardManager {
|
||||
if (this.canvas.canvasLayers.internalClipboard.length > 0) {
|
||||
log.info("Found layers in internal clipboard, pasting layers");
|
||||
this.canvas.canvasLayers.pasteLayers();
|
||||
showInfoNotification("Layers pasted from internal clipboard");
|
||||
return true;
|
||||
}
|
||||
if (preference === 'clipspace') {
|
||||
@@ -27,9 +28,20 @@ export class ClipboardManager {
|
||||
return true;
|
||||
}
|
||||
log.info("No image found in ComfyUI Clipspace");
|
||||
// Don't show error here, will try system clipboard next
|
||||
}
|
||||
log.info("Attempting paste from system clipboard");
|
||||
return await this.trySystemClipboardPaste(addMode);
|
||||
const systemSuccess = await this.trySystemClipboardPaste(addMode);
|
||||
if (!systemSuccess) {
|
||||
// No valid image found in any clipboard
|
||||
if (preference === 'clipspace') {
|
||||
showWarningNotification("No valid image found in Clipspace or system clipboard");
|
||||
}
|
||||
else {
|
||||
showWarningNotification("No valid image found in clipboard");
|
||||
}
|
||||
}
|
||||
return systemSuccess;
|
||||
}, 'ClipboardManager.handlePaste');
|
||||
/**
|
||||
* Attempts to paste from ComfyUI Clipspace
|
||||
@@ -51,6 +63,7 @@ export class ClipboardManager {
|
||||
const img = new Image();
|
||||
img.onload = async () => {
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||
showInfoNotification("Image pasted from Clipspace");
|
||||
};
|
||||
img.src = clipspaceImage.src;
|
||||
return true;
|
||||
@@ -96,6 +109,7 @@ export class ClipboardManager {
|
||||
img.onload = async () => {
|
||||
log.info("Successfully loaded image from backend response");
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||
showInfoNotification("Image loaded from file path");
|
||||
resolve(true);
|
||||
};
|
||||
img.onerror = () => {
|
||||
@@ -131,6 +145,7 @@ export class ClipboardManager {
|
||||
img.onload = async () => {
|
||||
log.info("Successfully loaded image from system clipboard");
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||
showInfoNotification("Image pasted from system clipboard");
|
||||
};
|
||||
if (event.target?.result) {
|
||||
img.src = event.target.result;
|
||||
@@ -173,11 +188,22 @@ export class ClipboardManager {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
log.debug("Found text in clipboard:", text);
|
||||
if (text && this.isValidImagePath(text)) {
|
||||
log.info("Found valid image path in clipboard:", text);
|
||||
const success = await this.loadImageFromPath(text, addMode);
|
||||
if (success) {
|
||||
return true;
|
||||
if (text) {
|
||||
// Check if it's a data URI (base64 encoded image)
|
||||
if (this.isDataURI(text)) {
|
||||
log.info("Found data URI in clipboard");
|
||||
const success = await this.loadImageFromDataURI(text, addMode);
|
||||
if (success) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Check if it's a regular file path or URL
|
||||
else if (this.isValidImagePath(text)) {
|
||||
log.info("Found valid image path in clipboard:", text);
|
||||
const success = await this.loadImageFromPath(text, addMode);
|
||||
if (success) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -188,6 +214,48 @@ export class ClipboardManager {
|
||||
log.debug("No images or valid image paths found in system clipboard");
|
||||
return false;
|
||||
}
|
||||
/**
|
||||
* Checks if a text string is a data URI (base64 encoded image)
|
||||
* @param {string} text - The text to check
|
||||
* @returns {boolean} - True if the text is a data URI
|
||||
*/
|
||||
isDataURI(text) {
|
||||
if (!text || typeof text !== 'string') {
|
||||
return false;
|
||||
}
|
||||
// Check if it starts with data:image
|
||||
return text.trim().startsWith('data:image/');
|
||||
}
|
||||
/**
|
||||
* Loads an image from a data URI (base64 encoded image)
|
||||
* @param {string} dataURI - The data URI to load
|
||||
* @param {AddMode} addMode - The mode for adding the layer
|
||||
* @returns {Promise<boolean>} - True if successful, false otherwise
|
||||
*/
|
||||
async loadImageFromDataURI(dataURI, addMode) {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const img = new Image();
|
||||
img.onload = async () => {
|
||||
log.info("Successfully loaded image from data URI");
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||
showInfoNotification("Image pasted from clipboard (base64)");
|
||||
resolve(true);
|
||||
};
|
||||
img.onerror = () => {
|
||||
log.warn("Failed to load image from data URI");
|
||||
showErrorNotification("Failed to load base64 image from clipboard", 5000, true);
|
||||
resolve(false);
|
||||
};
|
||||
img.src = dataURI;
|
||||
}
|
||||
catch (error) {
|
||||
log.error("Error loading data URI:", error);
|
||||
showErrorNotification("Error processing base64 image from clipboard", 5000, true);
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Validates if a text string is a valid image file path or URL
|
||||
* @param {string} text - The text to validate
|
||||
@@ -252,10 +320,12 @@ export class ClipboardManager {
|
||||
img.onload = async () => {
|
||||
log.info("Successfully loaded image from URL");
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||
showInfoNotification("Image loaded from URL");
|
||||
resolve(true);
|
||||
};
|
||||
img.onerror = () => {
|
||||
log.warn("Failed to load image from URL:", filePath);
|
||||
showErrorNotification(`Failed to load image from URL\nThe link might be incorrect or may not point to an image file.: ${filePath}`, 5000, true);
|
||||
resolve(false);
|
||||
};
|
||||
img.src = filePath;
|
||||
@@ -313,6 +383,7 @@ export class ClipboardManager {
|
||||
img.onload = async () => {
|
||||
log.info("Successfully loaded image from file picker");
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||
showInfoNotification("Image loaded from selected file");
|
||||
resolve(true);
|
||||
};
|
||||
img.onerror = () => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createModuleLogger } from "./LoggerUtils.js";
|
||||
const log = createModuleLogger('NotificationUtils');
|
||||
// Store active notifications for deduplication
|
||||
const activeNotifications = new Map();
|
||||
/**
|
||||
* Utility functions for showing notifications to the user
|
||||
*/
|
||||
@@ -8,10 +10,50 @@ const log = createModuleLogger('NotificationUtils');
|
||||
* @param message - The message to show
|
||||
* @param backgroundColor - Background color (default: #4a6cd4)
|
||||
* @param duration - Duration in milliseconds (default: 3000)
|
||||
* @param type - Type of notification
|
||||
* @param deduplicate - If true, will not show duplicate messages and will refresh existing ones (default: false)
|
||||
*/
|
||||
export function showNotification(message, backgroundColor = "#4a6cd4", duration = 3000, type = "info") {
|
||||
export function showNotification(message, backgroundColor = "#4a6cd4", duration = 3000, type = "info", deduplicate = false) {
|
||||
// Remove any existing prefix to avoid double prefixing
|
||||
message = message.replace(/^\[Layer Forge\]\s*/, "");
|
||||
// If deduplication is enabled, check if this message already exists
|
||||
if (deduplicate) {
|
||||
const existingNotification = activeNotifications.get(message);
|
||||
if (existingNotification) {
|
||||
log.debug(`Notification already exists, refreshing timer: ${message}`);
|
||||
// Clear existing timeout
|
||||
if (existingNotification.timeout !== null) {
|
||||
clearTimeout(existingNotification.timeout);
|
||||
}
|
||||
// Find the progress bar and restart its animation
|
||||
const progressBar = existingNotification.element.querySelector('div[style*="animation"]');
|
||||
if (progressBar) {
|
||||
// Reset animation
|
||||
progressBar.style.animation = 'none';
|
||||
// Force reflow
|
||||
void progressBar.offsetHeight;
|
||||
// Restart animation
|
||||
progressBar.style.animation = `lf-progress ${duration / 1000}s linear`;
|
||||
}
|
||||
// Set new timeout
|
||||
const newTimeout = window.setTimeout(() => {
|
||||
const notification = existingNotification.element;
|
||||
notification.style.animation = 'lf-fadeout 0.3s ease-out forwards';
|
||||
notification.addEventListener('animationend', () => {
|
||||
if (notification.parentNode) {
|
||||
notification.parentNode.removeChild(notification);
|
||||
activeNotifications.delete(message);
|
||||
const container = document.getElementById('lf-notification-container');
|
||||
if (container && container.children.length === 0) {
|
||||
container.remove();
|
||||
}
|
||||
}
|
||||
});
|
||||
}, duration);
|
||||
existingNotification.timeout = newTimeout;
|
||||
return; // Don't create a new notification
|
||||
}
|
||||
}
|
||||
// Type-specific config
|
||||
const config = {
|
||||
success: { icon: "✔️", title: "Success", bg: "#1fd18b" },
|
||||
@@ -148,6 +190,10 @@ export function showNotification(message, backgroundColor = "#4a6cd4", duration
|
||||
body.classList.add('notification-scrollbar');
|
||||
let dismissTimeout = null;
|
||||
const closeNotification = () => {
|
||||
// Remove from active notifications map if deduplicate is enabled
|
||||
if (deduplicate) {
|
||||
activeNotifications.delete(message);
|
||||
}
|
||||
notification.style.animation = 'lf-fadeout 0.3s ease-out forwards';
|
||||
notification.addEventListener('animationend', () => {
|
||||
if (notification.parentNode) {
|
||||
@@ -171,40 +217,77 @@ export function showNotification(message, backgroundColor = "#4a6cd4", duration
|
||||
progressBar.style.transform = computedStyle.transform;
|
||||
progressBar.style.animation = 'lf-progress-rewind 0.5s ease-out forwards';
|
||||
};
|
||||
notification.addEventListener('mouseenter', pauseAndRewindTimer);
|
||||
notification.addEventListener('mouseleave', startDismissTimer);
|
||||
notification.addEventListener('mouseenter', () => {
|
||||
pauseAndRewindTimer();
|
||||
// Update stored timeout if deduplicate is enabled
|
||||
if (deduplicate) {
|
||||
const stored = activeNotifications.get(message);
|
||||
if (stored) {
|
||||
stored.timeout = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
notification.addEventListener('mouseleave', () => {
|
||||
startDismissTimer();
|
||||
// Update stored timeout if deduplicate is enabled
|
||||
if (deduplicate) {
|
||||
const stored = activeNotifications.get(message);
|
||||
if (stored) {
|
||||
stored.timeout = dismissTimeout;
|
||||
}
|
||||
}
|
||||
});
|
||||
startDismissTimer();
|
||||
// Store notification if deduplicate is enabled
|
||||
if (deduplicate) {
|
||||
activeNotifications.set(message, { element: notification, timeout: dismissTimeout });
|
||||
}
|
||||
log.debug(`Notification shown: [Layer Forge] ${message}`);
|
||||
}
|
||||
/**
|
||||
* Shows a success notification
|
||||
* @param message - The message to show
|
||||
* @param duration - Duration in milliseconds (default: 3000)
|
||||
* @param deduplicate - If true, will not show duplicate messages (default: false)
|
||||
*/
|
||||
export function showSuccessNotification(message, duration = 3000) {
|
||||
showNotification(message, undefined, duration, "success");
|
||||
export function showSuccessNotification(message, duration = 3000, deduplicate = false) {
|
||||
showNotification(message, undefined, duration, "success", deduplicate);
|
||||
}
|
||||
/**
|
||||
* Shows an error notification
|
||||
* @param message - The message to show
|
||||
* @param duration - Duration in milliseconds (default: 5000)
|
||||
* @param deduplicate - If true, will not show duplicate messages (default: false)
|
||||
*/
|
||||
export function showErrorNotification(message, duration = 5000) {
|
||||
showNotification(message, undefined, duration, "error");
|
||||
export function showErrorNotification(message, duration = 5000, deduplicate = false) {
|
||||
showNotification(message, undefined, duration, "error", deduplicate);
|
||||
}
|
||||
/**
|
||||
* Shows an info notification
|
||||
* @param message - The message to show
|
||||
* @param duration - Duration in milliseconds (default: 3000)
|
||||
* @param deduplicate - If true, will not show duplicate messages (default: false)
|
||||
*/
|
||||
export function showInfoNotification(message, duration = 3000) {
|
||||
showNotification(message, undefined, duration, "info");
|
||||
export function showInfoNotification(message, duration = 3000, deduplicate = false) {
|
||||
showNotification(message, undefined, duration, "info", deduplicate);
|
||||
}
|
||||
/**
|
||||
* Shows a warning notification
|
||||
* @param message - The message to show
|
||||
* @param duration - Duration in milliseconds (default: 3000)
|
||||
* @param deduplicate - If true, will not show duplicate messages (default: false)
|
||||
*/
|
||||
export function showWarningNotification(message, duration = 3000) {
|
||||
showNotification(message, undefined, duration, "warning");
|
||||
export function showWarningNotification(message, duration = 3000, deduplicate = false) {
|
||||
showNotification(message, undefined, duration, "warning", deduplicate);
|
||||
}
|
||||
/**
|
||||
* Shows an alert notification
|
||||
* @param message - The message to show
|
||||
* @param duration - Duration in milliseconds (default: 3000)
|
||||
* @param deduplicate - If true, will not show duplicate messages (default: false)
|
||||
*/
|
||||
export function showAlertNotification(message, duration = 3000) {
|
||||
showNotification(message, undefined, duration, "alert");
|
||||
export function showAlertNotification(message, duration = 3000, deduplicate = false) {
|
||||
showNotification(message, undefined, duration, "alert", deduplicate);
|
||||
}
|
||||
/**
|
||||
* Shows a sequence of all notification types for debugging purposes.
|
||||
@@ -214,7 +297,7 @@ export function showAllNotificationTypes(message) {
|
||||
types.forEach((type, index) => {
|
||||
const notificationMessage = message || `This is a '${type}' notification.`;
|
||||
setTimeout(() => {
|
||||
showNotification(notificationMessage, undefined, 3000, type);
|
||||
showNotification(notificationMessage, undefined, 3000, type, false);
|
||||
}, index * 400); // Stagger the notifications
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "layerforge"
|
||||
description = "Photoshop-like layered canvas editor to your ComfyUI workflow. This node is perfect for complex compositing, inpainting, and outpainting, featuring multi-layer support, masking, blend modes, and precise transformations. Includes optional AI-powered background removal for streamlined image editing."
|
||||
version = "1.5.6"
|
||||
version = "1.5.9"
|
||||
license = { text = "MIT License" }
|
||||
dependencies = ["torch", "torchvision", "transformers", "aiohttp", "numpy", "tqdm", "Pillow"]
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ interface TransformOrigin {
|
||||
}
|
||||
|
||||
interface InteractionState {
|
||||
mode: 'none' | 'panning' | 'dragging' | 'resizing' | 'rotating' | 'drawingMask' | 'resizingCanvas' | 'movingCanvas' | 'potential-drag' | 'drawingShape';
|
||||
mode: 'none' | 'panning' | 'dragging' | 'resizing' | 'rotating' | 'drawingMask' | 'resizingCanvas' | 'movingCanvas' | 'potential-drag' | 'drawingShape' | 'transformingOutputArea';
|
||||
panStart: Point;
|
||||
dragStart: Point;
|
||||
transformOrigin: TransformOrigin | null;
|
||||
@@ -49,6 +49,8 @@ interface InteractionState {
|
||||
keyMovementInProgress: boolean;
|
||||
canvasResizeRect: { x: number, y: number, width: number, height: number } | null;
|
||||
canvasMoveRect: { x: number, y: number, width: number, height: number } | null;
|
||||
outputAreaTransformHandle: string | null;
|
||||
outputAreaTransformAnchor: Point;
|
||||
}
|
||||
|
||||
export class CanvasInteractions {
|
||||
@@ -94,6 +96,8 @@ export class CanvasInteractions {
|
||||
keyMovementInProgress: false,
|
||||
canvasResizeRect: null,
|
||||
canvasMoveRect: null,
|
||||
outputAreaTransformHandle: null,
|
||||
outputAreaTransformAnchor: { x: 0, y: 0 },
|
||||
};
|
||||
this.originalLayerPositions = new Map();
|
||||
}
|
||||
@@ -238,6 +242,7 @@ export class CanvasInteractions {
|
||||
this.interaction.canvasMoveRect = null;
|
||||
this.interaction.hasClonedInDrag = false;
|
||||
this.interaction.transformingLayer = null;
|
||||
this.interaction.outputAreaTransformHandle = null;
|
||||
this.canvas.canvas.style.cursor = 'default';
|
||||
}
|
||||
|
||||
@@ -252,6 +257,19 @@ export class CanvasInteractions {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.interaction.mode === 'transformingOutputArea') {
|
||||
// Check if clicking on output area transform handle
|
||||
const handle = this.getOutputAreaHandle(coords.world);
|
||||
if (handle) {
|
||||
this.startOutputAreaTransform(handle, coords.world);
|
||||
return;
|
||||
}
|
||||
// If clicking outside, exit transform mode
|
||||
this.interaction.mode = 'none';
|
||||
this.canvas.render();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.canvas.shapeTool.isActive) {
|
||||
this.canvas.shapeTool.addPoint(coords.world);
|
||||
return;
|
||||
@@ -352,6 +370,13 @@ export class CanvasInteractions {
|
||||
case 'movingCanvas':
|
||||
this.updateCanvasMove(coords.world);
|
||||
break;
|
||||
case 'transformingOutputArea':
|
||||
if (this.interaction.outputAreaTransformHandle) {
|
||||
this.resizeOutputAreaFromHandle(coords.world, e.shiftKey);
|
||||
} else {
|
||||
this.updateOutputAreaTransformCursor(coords.world);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
this.updateCursor(coords.world);
|
||||
// Update brush cursor on overlay if mask tool is active
|
||||
@@ -384,6 +409,11 @@ export class CanvasInteractions {
|
||||
this.finalizeCanvasMove();
|
||||
}
|
||||
|
||||
if (this.interaction.mode === 'transformingOutputArea' && this.interaction.outputAreaTransformHandle) {
|
||||
this.finalizeOutputAreaTransform();
|
||||
return;
|
||||
}
|
||||
|
||||
// Log layer positions when dragging ends
|
||||
if (this.interaction.mode === 'dragging' && this.canvas.canvasSelection.selectedLayers.length > 0) {
|
||||
this.logDragCompletion(coords);
|
||||
@@ -1313,4 +1343,189 @@ export class CanvasInteractions {
|
||||
await this.canvas.canvasLayers.clipboardManager.handlePaste('mouse', preference);
|
||||
}
|
||||
|
||||
// New methods for output area transformation
|
||||
public activateOutputAreaTransform(): void {
|
||||
// Clear any existing interaction state before starting transform
|
||||
this.resetInteractionState();
|
||||
|
||||
// Deactivate any active tools that might conflict
|
||||
if (this.canvas.shapeTool.isActive) {
|
||||
this.canvas.shapeTool.deactivate();
|
||||
}
|
||||
if (this.canvas.maskTool.isActive) {
|
||||
this.canvas.maskTool.deactivate();
|
||||
}
|
||||
|
||||
// Clear selection to avoid confusion
|
||||
this.canvas.canvasSelection.updateSelection([]);
|
||||
|
||||
// Set transform mode
|
||||
this.interaction.mode = 'transformingOutputArea';
|
||||
this.canvas.render();
|
||||
}
|
||||
|
||||
private getOutputAreaHandle(worldCoords: Point): string | null {
|
||||
const bounds = this.canvas.outputAreaBounds;
|
||||
const threshold = 10 / this.canvas.viewport.zoom;
|
||||
|
||||
// Define handle positions
|
||||
const handles = {
|
||||
'nw': { x: bounds.x, y: bounds.y },
|
||||
'n': { x: bounds.x + bounds.width / 2, y: bounds.y },
|
||||
'ne': { x: bounds.x + bounds.width, y: bounds.y },
|
||||
'e': { x: bounds.x + bounds.width, y: bounds.y + bounds.height / 2 },
|
||||
'se': { x: bounds.x + bounds.width, y: bounds.y + bounds.height },
|
||||
's': { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height },
|
||||
'sw': { x: bounds.x, y: bounds.y + bounds.height },
|
||||
'w': { x: bounds.x, y: bounds.y + bounds.height / 2 },
|
||||
};
|
||||
|
||||
for (const [name, pos] of Object.entries(handles)) {
|
||||
const dx = worldCoords.x - pos.x;
|
||||
const dy = worldCoords.y - pos.y;
|
||||
if (Math.sqrt(dx * dx + dy * dy) < threshold) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private startOutputAreaTransform(handle: string, worldCoords: Point): void {
|
||||
this.interaction.outputAreaTransformHandle = handle;
|
||||
this.interaction.dragStart = { ...worldCoords };
|
||||
|
||||
const bounds = this.canvas.outputAreaBounds;
|
||||
this.interaction.transformOrigin = {
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
rotation: 0,
|
||||
centerX: bounds.x + bounds.width / 2,
|
||||
centerY: bounds.y + bounds.height / 2
|
||||
};
|
||||
|
||||
// Set anchor point (opposite corner for resize)
|
||||
const anchorMap: { [key: string]: Point } = {
|
||||
'nw': { x: bounds.x + bounds.width, y: bounds.y + bounds.height },
|
||||
'n': { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height },
|
||||
'ne': { x: bounds.x, y: bounds.y + bounds.height },
|
||||
'e': { x: bounds.x, y: bounds.y + bounds.height / 2 },
|
||||
'se': { x: bounds.x, y: bounds.y },
|
||||
's': { x: bounds.x + bounds.width / 2, y: bounds.y },
|
||||
'sw': { x: bounds.x + bounds.width, y: bounds.y },
|
||||
'w': { x: bounds.x + bounds.width, y: bounds.y + bounds.height / 2 },
|
||||
};
|
||||
|
||||
this.interaction.outputAreaTransformAnchor = anchorMap[handle];
|
||||
}
|
||||
|
||||
private resizeOutputAreaFromHandle(worldCoords: Point, isShiftPressed: boolean): void {
|
||||
const o = this.interaction.transformOrigin;
|
||||
if (!o) return;
|
||||
|
||||
const handle = this.interaction.outputAreaTransformHandle;
|
||||
const anchor = this.interaction.outputAreaTransformAnchor;
|
||||
|
||||
let newX = o.x;
|
||||
let newY = o.y;
|
||||
let newWidth = o.width;
|
||||
let newHeight = o.height;
|
||||
|
||||
// Calculate new dimensions based on handle
|
||||
if (handle?.includes('w')) {
|
||||
const deltaX = worldCoords.x - anchor.x;
|
||||
newWidth = Math.abs(deltaX);
|
||||
newX = Math.min(worldCoords.x, anchor.x);
|
||||
}
|
||||
if (handle?.includes('e')) {
|
||||
const deltaX = worldCoords.x - anchor.x;
|
||||
newWidth = Math.abs(deltaX);
|
||||
newX = Math.min(worldCoords.x, anchor.x);
|
||||
}
|
||||
if (handle?.includes('n')) {
|
||||
const deltaY = worldCoords.y - anchor.y;
|
||||
newHeight = Math.abs(deltaY);
|
||||
newY = Math.min(worldCoords.y, anchor.y);
|
||||
}
|
||||
if (handle?.includes('s')) {
|
||||
const deltaY = worldCoords.y - anchor.y;
|
||||
newHeight = Math.abs(deltaY);
|
||||
newY = Math.min(worldCoords.y, anchor.y);
|
||||
}
|
||||
|
||||
// Maintain aspect ratio if shift is held
|
||||
if (isShiftPressed && o.width > 0 && o.height > 0) {
|
||||
const aspectRatio = o.width / o.height;
|
||||
if (handle === 'n' || handle === 's') {
|
||||
newWidth = newHeight * aspectRatio;
|
||||
} else if (handle === 'e' || handle === 'w') {
|
||||
newHeight = newWidth / aspectRatio;
|
||||
} else {
|
||||
// Corner handles
|
||||
const proposedRatio = newWidth / newHeight;
|
||||
if (proposedRatio > aspectRatio) {
|
||||
newHeight = newWidth / aspectRatio;
|
||||
} else {
|
||||
newWidth = newHeight * aspectRatio;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Snap to grid if Ctrl is held
|
||||
if (this.interaction.isCtrlPressed) {
|
||||
newX = snapToGrid(newX);
|
||||
newY = snapToGrid(newY);
|
||||
newWidth = snapToGrid(newWidth);
|
||||
newHeight = snapToGrid(newHeight);
|
||||
}
|
||||
|
||||
// Apply minimum size
|
||||
if (newWidth < 10) newWidth = 10;
|
||||
if (newHeight < 10) newHeight = 10;
|
||||
|
||||
// Update output area bounds temporarily for preview
|
||||
this.canvas.outputAreaBounds = {
|
||||
x: newX,
|
||||
y: newY,
|
||||
width: newWidth,
|
||||
height: newHeight
|
||||
};
|
||||
|
||||
this.canvas.render();
|
||||
}
|
||||
|
||||
private updateOutputAreaTransformCursor(worldCoords: Point): void {
|
||||
const handle = this.getOutputAreaHandle(worldCoords);
|
||||
|
||||
if (handle) {
|
||||
const cursorMap: { [key: string]: string } = {
|
||||
'n': 'ns-resize', 's': 'ns-resize',
|
||||
'e': 'ew-resize', 'w': 'ew-resize',
|
||||
'nw': 'nwse-resize', 'se': 'nwse-resize',
|
||||
'ne': 'nesw-resize', 'sw': 'nesw-resize',
|
||||
};
|
||||
this.canvas.canvas.style.cursor = cursorMap[handle] || 'default';
|
||||
} else {
|
||||
this.canvas.canvas.style.cursor = 'default';
|
||||
}
|
||||
}
|
||||
|
||||
private finalizeOutputAreaTransform(): void {
|
||||
const bounds = this.canvas.outputAreaBounds;
|
||||
|
||||
// Update canvas size and mask tool
|
||||
this.canvas.updateOutputAreaSize(bounds.width, bounds.height);
|
||||
|
||||
// Update mask canvas for new output area
|
||||
this.canvas.maskTool.updateMaskCanvasForOutputArea();
|
||||
|
||||
// Save state
|
||||
this.canvas.saveState();
|
||||
|
||||
// Reset transform handle but keep transform mode active
|
||||
this.interaction.outputAreaTransformHandle = null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@ export class CanvasLayers {
|
||||
});
|
||||
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => {
|
||||
if (!this.canvas.node.imgs) {
|
||||
this.canvas.node.imgs = [];
|
||||
@@ -135,6 +136,142 @@ export class CanvasLayers {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatically adjust output area to fit selected layers
|
||||
* Calculates precise bounding box for all selected layers including rotation and crop mode support
|
||||
*/
|
||||
autoAdjustOutputToSelection(): boolean {
|
||||
const selectedLayers = this.canvas.canvasSelection.selectedLayers;
|
||||
if (selectedLayers.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Calculate bounding box of selected layers
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
|
||||
selectedLayers.forEach((layer: Layer) => {
|
||||
// For crop mode layers, use the visible crop bounds
|
||||
if (layer.cropMode && layer.cropBounds && layer.originalWidth && layer.originalHeight) {
|
||||
const layerScaleX = layer.width / layer.originalWidth;
|
||||
const layerScaleY = layer.height / layer.originalHeight;
|
||||
|
||||
const cropWidth = layer.cropBounds.width * layerScaleX;
|
||||
const cropHeight = layer.cropBounds.height * layerScaleY;
|
||||
|
||||
const effectiveCropX = layer.flipH
|
||||
? layer.originalWidth - (layer.cropBounds.x + layer.cropBounds.width)
|
||||
: layer.cropBounds.x;
|
||||
const effectiveCropY = layer.flipV
|
||||
? layer.originalHeight - (layer.cropBounds.y + layer.cropBounds.height)
|
||||
: layer.cropBounds.y;
|
||||
|
||||
const cropOffsetX = effectiveCropX * layerScaleX;
|
||||
const cropOffsetY = effectiveCropY * layerScaleY;
|
||||
|
||||
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);
|
||||
|
||||
// Calculate corners of the crop rectangle
|
||||
const corners = [
|
||||
{ x: cropOffsetX, y: cropOffsetY },
|
||||
{ x: cropOffsetX + cropWidth, y: cropOffsetY },
|
||||
{ x: cropOffsetX + cropWidth, y: cropOffsetY + cropHeight },
|
||||
{ x: cropOffsetX, y: cropOffsetY + cropHeight }
|
||||
];
|
||||
|
||||
corners.forEach(p => {
|
||||
// Transform to layer space (centered)
|
||||
const localX = p.x - layer.width / 2;
|
||||
const localY = p.y - layer.height / 2;
|
||||
|
||||
// Apply rotation
|
||||
const worldX = centerX + (localX * cos - localY * sin);
|
||||
const worldY = centerY + (localX * sin + localY * cos);
|
||||
|
||||
minX = Math.min(minX, worldX);
|
||||
minY = Math.min(minY, worldY);
|
||||
maxX = Math.max(maxX, worldX);
|
||||
maxY = Math.max(maxY, worldY);
|
||||
});
|
||||
} else {
|
||||
// For normal layers, use the full layer bounds
|
||||
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);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate new dimensions without padding for precise fit
|
||||
const newWidth = Math.ceil(maxX - minX);
|
||||
const newHeight = Math.ceil(maxY - minY);
|
||||
|
||||
if (newWidth <= 0 || newHeight <= 0) {
|
||||
log.error("Cannot calculate valid output area dimensions");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update output area bounds
|
||||
this.canvas.outputAreaBounds = {
|
||||
x: minX,
|
||||
y: minY,
|
||||
width: newWidth,
|
||||
height: newHeight
|
||||
};
|
||||
|
||||
// Update canvas dimensions
|
||||
this.canvas.width = newWidth;
|
||||
this.canvas.height = newHeight;
|
||||
this.canvas.maskTool.resize(newWidth, newHeight);
|
||||
this.canvas.canvas.width = newWidth;
|
||||
this.canvas.canvas.height = newHeight;
|
||||
|
||||
// Reset extensions
|
||||
this.canvas.outputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 };
|
||||
this.canvas.outputAreaExtensionEnabled = false;
|
||||
this.canvas.lastOutputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 };
|
||||
|
||||
// Update original canvas size and position
|
||||
this.canvas.originalCanvasSize = { width: newWidth, height: newHeight };
|
||||
this.canvas.originalOutputAreaPosition = { x: minX, y: minY };
|
||||
|
||||
// Save state and render
|
||||
this.canvas.render();
|
||||
this.canvas.saveState();
|
||||
|
||||
log.info(`Auto-adjusted output area to fit ${selectedLayers.length} selected layer(s)`, {
|
||||
bounds: { x: minX, y: minY, width: newWidth, height: newHeight }
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
pasteLayers(): void {
|
||||
if (this.internalClipboard.length === 0) return;
|
||||
this.canvas.saveState();
|
||||
@@ -266,6 +403,7 @@ export class CanvasLayers {
|
||||
tempCtx.drawImage(maskCanvas, 0, 0);
|
||||
|
||||
const newImage = new Image();
|
||||
newImage.crossOrigin = 'anonymous';
|
||||
newImage.src = tempCanvas.toDataURL();
|
||||
layer.image = newImage;
|
||||
}
|
||||
@@ -864,6 +1002,7 @@ export class CanvasLayers {
|
||||
|
||||
// Convert canvas to image
|
||||
const processedImage = new Image();
|
||||
processedImage.crossOrigin = 'anonymous';
|
||||
processedImage.src = processedCanvas.toDataURL();
|
||||
return processedImage;
|
||||
}
|
||||
@@ -1884,6 +2023,7 @@ export class CanvasLayers {
|
||||
this._drawLayers(tempCtx, this.canvas.canvasSelection.selectedLayers);
|
||||
|
||||
const fusedImage = new Image();
|
||||
fusedImage.crossOrigin = 'anonymous';
|
||||
fusedImage.src = tempCanvas.toDataURL();
|
||||
await new Promise((resolve, reject) => {
|
||||
fusedImage.onload = resolve;
|
||||
|
||||
@@ -121,6 +121,7 @@ export class CanvasLayersPanel {
|
||||
this.container.tabIndex = 0; // Umożliwia fokus na panelu
|
||||
this.container.innerHTML = `
|
||||
<div class="layers-panel-header">
|
||||
<div class="master-visibility-toggle" title="Toggle all layers visibility"></div>
|
||||
<span class="layers-panel-title">Layers</span>
|
||||
<div class="layers-panel-controls">
|
||||
<button class="layers-btn" id="delete-layer-btn" title="Delete layer"></button>
|
||||
@@ -135,6 +136,7 @@ export class CanvasLayersPanel {
|
||||
|
||||
// Setup event listeners dla przycisków
|
||||
this.setupControlButtons();
|
||||
this.setupMasterVisibilityToggle();
|
||||
|
||||
// Dodaj listener dla klawiatury, aby usuwanie działało z panelu
|
||||
this.container.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
@@ -169,6 +171,74 @@ export class CanvasLayersPanel {
|
||||
this.updateButtonStates();
|
||||
}
|
||||
|
||||
setupMasterVisibilityToggle(): void {
|
||||
if (!this.container) return;
|
||||
const toggleContainer = this.container.querySelector('.master-visibility-toggle') as HTMLElement;
|
||||
if (!toggleContainer) return;
|
||||
|
||||
const updateToggleState = () => {
|
||||
const total = this.canvas.layers.length;
|
||||
const visibleCount = this.canvas.layers.filter(l => l.visible).length;
|
||||
toggleContainer.innerHTML = '';
|
||||
|
||||
const checkboxContainer = document.createElement('div');
|
||||
checkboxContainer.className = 'checkbox-container';
|
||||
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.id = 'master-visibility-checkbox';
|
||||
|
||||
const customCheckbox = document.createElement('span');
|
||||
customCheckbox.className = 'custom-checkbox';
|
||||
|
||||
checkboxContainer.appendChild(checkbox);
|
||||
checkboxContainer.appendChild(customCheckbox);
|
||||
|
||||
if (visibleCount === 0) {
|
||||
checkbox.checked = false;
|
||||
checkbox.indeterminate = false;
|
||||
customCheckbox.classList.remove('checked', 'indeterminate');
|
||||
} else if (visibleCount === total) {
|
||||
checkbox.checked = true;
|
||||
checkbox.indeterminate = false;
|
||||
customCheckbox.classList.add('checked');
|
||||
customCheckbox.classList.remove('indeterminate');
|
||||
} else {
|
||||
checkbox.checked = false;
|
||||
checkbox.indeterminate = true;
|
||||
customCheckbox.classList.add('indeterminate');
|
||||
customCheckbox.classList.remove('checked');
|
||||
}
|
||||
|
||||
checkboxContainer.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
let newVisible: boolean;
|
||||
if (checkbox.indeterminate) {
|
||||
newVisible = false; // hide all when mixed
|
||||
} else if (checkbox.checked) {
|
||||
newVisible = false; // toggle to hide all
|
||||
} else {
|
||||
newVisible = true; // toggle to show all
|
||||
}
|
||||
|
||||
this.canvas.layers.forEach(layer => {
|
||||
layer.visible = newVisible;
|
||||
});
|
||||
this.canvas.render();
|
||||
this.canvas.requestSaveState();
|
||||
updateToggleState();
|
||||
this.renderLayers();
|
||||
});
|
||||
|
||||
toggleContainer.appendChild(checkboxContainer);
|
||||
};
|
||||
|
||||
updateToggleState();
|
||||
this._updateMasterVisibilityToggle = updateToggleState;
|
||||
}
|
||||
|
||||
private _updateMasterVisibilityToggle?: () => void;
|
||||
|
||||
renderLayers(): void {
|
||||
if (!this.layersContainer) {
|
||||
log.warn('Layers container not initialized');
|
||||
@@ -186,10 +256,11 @@ export class CanvasLayersPanel {
|
||||
|
||||
sortedLayers.forEach((layer: Layer, index: number) => {
|
||||
const layerElement = this.createLayerElement(layer, index);
|
||||
if(this.layersContainer)
|
||||
if (this.layersContainer)
|
||||
this.layersContainer.appendChild(layerElement);
|
||||
});
|
||||
|
||||
if (this._updateMasterVisibilityToggle) this._updateMasterVisibilityToggle();
|
||||
log.debug(`Rendered ${sortedLayers.length} layers`);
|
||||
}
|
||||
|
||||
|
||||
@@ -195,6 +195,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.renderOutputAreaTransformHandles(ctx); // Draw output area transform handles
|
||||
this.renderLayerInfo(ctx);
|
||||
|
||||
// Update custom shape menu position and visibility
|
||||
@@ -796,8 +797,8 @@ export class CanvasRenderer {
|
||||
|
||||
// Position above main canvas but below cursor overlay
|
||||
this.strokeOverlayCanvas.style.position = 'absolute';
|
||||
this.strokeOverlayCanvas.style.left = '0px';
|
||||
this.strokeOverlayCanvas.style.top = '0px';
|
||||
this.strokeOverlayCanvas.style.left = '1px';
|
||||
this.strokeOverlayCanvas.style.top = '1px';
|
||||
this.strokeOverlayCanvas.style.pointerEvents = 'none';
|
||||
this.strokeOverlayCanvas.style.zIndex = '19'; // Below cursor overlay (20)
|
||||
// Opacity is now controlled by MaskTool.previewOpacity
|
||||
@@ -1011,4 +1012,46 @@ export class CanvasRenderer {
|
||||
// Just ensure it's the right size
|
||||
this.updateOverlaySize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw transform handles for output area when in transform mode
|
||||
*/
|
||||
renderOutputAreaTransformHandles(ctx: any): void {
|
||||
if (this.canvas.canvasInteractions.interaction.mode !== 'transformingOutputArea') {
|
||||
return;
|
||||
}
|
||||
|
||||
const bounds = this.canvas.outputAreaBounds;
|
||||
const handleRadius = 5 / this.canvas.viewport.zoom;
|
||||
|
||||
// Define handle positions
|
||||
const handles = {
|
||||
'nw': { x: bounds.x, y: bounds.y },
|
||||
'n': { x: bounds.x + bounds.width / 2, y: bounds.y },
|
||||
'ne': { x: bounds.x + bounds.width, y: bounds.y },
|
||||
'e': { x: bounds.x + bounds.width, y: bounds.y + bounds.height / 2 },
|
||||
'se': { x: bounds.x + bounds.width, y: bounds.y + bounds.height },
|
||||
's': { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height },
|
||||
'sw': { x: bounds.x, y: bounds.y + bounds.height },
|
||||
'w': { x: bounds.x, y: bounds.y + bounds.height / 2 },
|
||||
};
|
||||
|
||||
// Draw handles
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.strokeStyle = '#000000';
|
||||
ctx.lineWidth = 1 / this.canvas.viewport.zoom;
|
||||
|
||||
for (const [name, pos] of Object.entries(handles)) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(pos.x, pos.y, handleRadius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Draw a highlight around the output area
|
||||
ctx.strokeStyle = 'rgba(0, 150, 255, 0.8)';
|
||||
ctx.lineWidth = 3 / this.canvas.viewport.zoom;
|
||||
ctx.setLineDash([]);
|
||||
ctx.strokeRect(bounds.x, bounds.y, bounds.width, bounds.height);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,6 +235,7 @@ export class CanvasState {
|
||||
_createLayerFromSrc(layerData: Layer, imageSrc: string | ImageBitmap, index: number, resolve: (value: Layer | null) => void): void {
|
||||
if (typeof imageSrc === 'string') {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => {
|
||||
log.debug(`Layer ${index}: Image loaded successfully.`);
|
||||
const newLayer: Layer = {...layerData, image: img};
|
||||
@@ -250,6 +251,7 @@ export class CanvasState {
|
||||
if (ctx) {
|
||||
ctx.drawImage(imageSrc, 0, 0);
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => {
|
||||
log.debug(`Layer ${index}: Image loaded successfully from ImageBitmap.`);
|
||||
const newLayer: Layer = {...layerData, image: img};
|
||||
|
||||
@@ -268,90 +268,32 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
||||
|
||||
$el("div.painter-separator"),
|
||||
$el("div.painter-button-group", {}, [
|
||||
$el("button.painter-button.requires-selection", {
|
||||
textContent: "Auto Adjust Output",
|
||||
title: "Automatically adjust output area to fit selected layers",
|
||||
onclick: () => {
|
||||
const selectedLayers = canvas.canvasSelection.selectedLayers;
|
||||
if (selectedLayers.length === 0) {
|
||||
showWarningNotification("Please select one or more layers first");
|
||||
return;
|
||||
}
|
||||
|
||||
const success = canvas.canvasLayers.autoAdjustOutputToSelection();
|
||||
if (success) {
|
||||
const bounds = canvas.outputAreaBounds;
|
||||
showSuccessNotification(`Output area adjusted to ${bounds.width}x${bounds.height}px`);
|
||||
} else {
|
||||
showErrorNotification("Cannot calculate valid output area dimensions");
|
||||
}
|
||||
}
|
||||
}),
|
||||
$el("button.painter-button", {
|
||||
textContent: "Output Area Size",
|
||||
title: "Set the size of the output area",
|
||||
title: "Transform output area - drag handles to resize",
|
||||
onclick: () => {
|
||||
const dialog = $el("div.painter-dialog", {
|
||||
style: {
|
||||
position: 'fixed',
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
zIndex: '9999'
|
||||
}
|
||||
}, [
|
||||
$el("div", {
|
||||
style: {
|
||||
color: "white",
|
||||
marginBottom: "10px"
|
||||
}
|
||||
}, [
|
||||
$el("label", {
|
||||
style: {
|
||||
marginRight: "5px"
|
||||
}
|
||||
}, [
|
||||
$el("span", {}, ["Width: "])
|
||||
]),
|
||||
$el("input", {
|
||||
type: "number",
|
||||
id: "canvas-width",
|
||||
value: String(canvas.width),
|
||||
min: "1",
|
||||
max: "4096"
|
||||
})
|
||||
]),
|
||||
$el("div", {
|
||||
style: {
|
||||
color: "white",
|
||||
marginBottom: "10px"
|
||||
}
|
||||
}, [
|
||||
$el("label", {
|
||||
style: {
|
||||
marginRight: "5px"
|
||||
}
|
||||
}, [
|
||||
$el("span", {}, ["Height: "])
|
||||
]),
|
||||
$el("input", {
|
||||
type: "number",
|
||||
id: "canvas-height",
|
||||
value: String(canvas.height),
|
||||
min: "1",
|
||||
max: "4096"
|
||||
})
|
||||
]),
|
||||
$el("div", {
|
||||
style: {
|
||||
textAlign: "right"
|
||||
}
|
||||
}, [
|
||||
$el("button", {
|
||||
id: "cancel-size",
|
||||
textContent: "Cancel"
|
||||
}),
|
||||
$el("button", {
|
||||
id: "confirm-size",
|
||||
textContent: "OK"
|
||||
})
|
||||
])
|
||||
]);
|
||||
document.body.appendChild(dialog);
|
||||
|
||||
(document.getElementById('confirm-size') as HTMLButtonElement).onclick = () => {
|
||||
const widthInput = document.getElementById('canvas-width') as HTMLInputElement;
|
||||
const heightInput = document.getElementById('canvas-height') as HTMLInputElement;
|
||||
const width = parseInt(widthInput.value) || canvas.width;
|
||||
const height = parseInt(heightInput.value) || canvas.height;
|
||||
canvas.setOutputAreaSize(width, height);
|
||||
document.body.removeChild(dialog);
|
||||
};
|
||||
|
||||
(document.getElementById('cancel-size') as HTMLButtonElement).onclick = () => {
|
||||
document.body.removeChild(dialog);
|
||||
};
|
||||
// Activate output area transform mode
|
||||
canvas.canvasInteractions.activateOutputAreaTransform();
|
||||
showInfoNotification("Click and drag the handles to resize the output area. Click anywhere else to exit.", 3000);
|
||||
}
|
||||
}),
|
||||
$el("button.painter-button.requires-selection", {
|
||||
@@ -1548,8 +1490,8 @@ app.registerExtension({
|
||||
callback: async () => {
|
||||
try {
|
||||
log.info("Opening LayerForge canvas in MaskEditor");
|
||||
if ((self as any).canvasWidget && (self as any).canvasWidget.startMaskEditor) {
|
||||
await (self as any).canvasWidget.startMaskEditor(null, true);
|
||||
if ((self as any).canvasWidget && (self as any).canvasWidget.canvas) {
|
||||
await (self as any).canvasWidget.canvas.startMaskEditor(null, true);
|
||||
} else {
|
||||
log.error("Canvas widget not available");
|
||||
showErrorNotification("Canvas not ready. Please try again.");
|
||||
@@ -1564,8 +1506,8 @@ app.registerExtension({
|
||||
content: "Open Image",
|
||||
callback: async () => {
|
||||
try {
|
||||
if (!(self as any).canvasWidget) return;
|
||||
const blob = await (self as any).canvasWidget.getFlattenedCanvasAsBlob();
|
||||
if (!(self as any).canvasWidget || !(self as any).canvasWidget.canvas) return;
|
||||
const blob = await (self as any).canvasWidget.canvas.canvasLayers.getFlattenedCanvasAsBlob();
|
||||
if (!blob) return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, '_blank');
|
||||
@@ -1579,8 +1521,8 @@ app.registerExtension({
|
||||
content: "Open Image with Mask Alpha",
|
||||
callback: async () => {
|
||||
try {
|
||||
if (!(self as any).canvasWidget) return;
|
||||
const blob = await (self as any).canvasWidget.getFlattenedCanvasWithMaskAsBlob();
|
||||
if (!(self as any).canvasWidget || !(self as any).canvasWidget.canvas) return;
|
||||
const blob = await (self as any).canvasWidget.canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
|
||||
if (!blob) return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, '_blank');
|
||||
@@ -1594,8 +1536,8 @@ app.registerExtension({
|
||||
content: "Copy Image",
|
||||
callback: async () => {
|
||||
try {
|
||||
if (!(self as any).canvasWidget) return;
|
||||
const blob = await (self as any).canvasWidget.getFlattenedCanvasAsBlob();
|
||||
if (!(self as any).canvasWidget || !(self as any).canvasWidget.canvas) return;
|
||||
const blob = await (self as any).canvasWidget.canvas.canvasLayers.getFlattenedCanvasAsBlob();
|
||||
if (!blob) return;
|
||||
const item = new ClipboardItem({'image/png': blob});
|
||||
await navigator.clipboard.write([item]);
|
||||
@@ -1610,8 +1552,8 @@ app.registerExtension({
|
||||
content: "Copy Image with Mask Alpha",
|
||||
callback: async () => {
|
||||
try {
|
||||
if (!(self as any).canvasWidget) return;
|
||||
const blob = await (self as any).canvasWidget.getFlattenedCanvasWithMaskAsBlob();
|
||||
if (!(self as any).canvasWidget || !(self as any).canvasWidget.canvas) return;
|
||||
const blob = await (self as any).canvasWidget.canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
|
||||
if (!blob) return;
|
||||
const item = new ClipboardItem({'image/png': blob});
|
||||
await navigator.clipboard.write([item]);
|
||||
@@ -1626,8 +1568,8 @@ app.registerExtension({
|
||||
content: "Save Image",
|
||||
callback: async () => {
|
||||
try {
|
||||
if (!(self as any).canvasWidget) return;
|
||||
const blob = await (self as any).canvasWidget.getFlattenedCanvasAsBlob();
|
||||
if (!(self as any).canvasWidget || !(self as any).canvasWidget.canvas) return;
|
||||
const blob = await (self as any).canvasWidget.canvas.canvasLayers.getFlattenedCanvasAsBlob();
|
||||
if (!blob) return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
@@ -1646,8 +1588,8 @@ app.registerExtension({
|
||||
content: "Save Image with Mask Alpha",
|
||||
callback: async () => {
|
||||
try {
|
||||
if (!(self as any).canvasWidget) return;
|
||||
const blob = await (self as any).canvasWidget.getFlattenedCanvasWithMaskAsBlob();
|
||||
if (!(self as any).canvasWidget || !(self as any).canvasWidget.canvas) return;
|
||||
const blob = await (self as any).canvasWidget.canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
|
||||
if (!blob) return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
|
||||
@@ -638,7 +638,7 @@
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
z-index: 111;
|
||||
z-index: 999999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -23,6 +23,85 @@
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.checkbox-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 0;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.checkbox-container:hover {
|
||||
background-color: #4a4a4a;
|
||||
}
|
||||
|
||||
.checkbox-container input[type="checkbox"] {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
height: 0;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.checkbox-container .custom-checkbox {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
background-color: #2a2a2a;
|
||||
border: 1px solid #666;
|
||||
border-radius: 3px;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.checkbox-container input:checked ~ .custom-checkbox {
|
||||
background-color: #3a76d6;
|
||||
border-color: #3a76d6;
|
||||
}
|
||||
|
||||
.checkbox-container .custom-checkbox::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
display: none;
|
||||
left: 5px;
|
||||
top: 1px;
|
||||
width: 4px;
|
||||
height: 9px;
|
||||
border: solid white;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.checkbox-container input:checked ~ .custom-checkbox::after {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.checkbox-container input:indeterminate ~ .custom-checkbox {
|
||||
background-color: #3a76d6;
|
||||
border-color: #3a76d6;
|
||||
}
|
||||
|
||||
.checkbox-container input:indeterminate ~ .custom-checkbox::after {
|
||||
display: block;
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
left: 3px;
|
||||
width: 8px;
|
||||
height: 2px;
|
||||
background-color: white;
|
||||
border: none;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.checkbox-container:hover {
|
||||
background-color: #4a4a4a;
|
||||
}
|
||||
|
||||
.layers-panel-title {
|
||||
font-weight: bold;
|
||||
color: #ffffff;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {createModuleLogger} from "./LoggerUtils.js";
|
||||
import { showNotification, showInfoNotification } from "./NotificationUtils.js";
|
||||
import { showNotification, showInfoNotification, showErrorNotification, showWarningNotification } from "./NotificationUtils.js";
|
||||
import { withErrorHandling, createValidationError, createNetworkError, createFileError } from "../ErrorHandler.js";
|
||||
import { safeClipspacePaste } from "./ClipspaceUtils.js";
|
||||
|
||||
@@ -34,6 +34,7 @@ export class ClipboardManager {
|
||||
if (this.canvas.canvasLayers.internalClipboard.length > 0) {
|
||||
log.info("Found layers in internal clipboard, pasting layers");
|
||||
this.canvas.canvasLayers.pasteLayers();
|
||||
showInfoNotification("Layers pasted from internal clipboard");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -44,10 +45,22 @@ export class ClipboardManager {
|
||||
return true;
|
||||
}
|
||||
log.info("No image found in ComfyUI Clipspace");
|
||||
// Don't show error here, will try system clipboard next
|
||||
}
|
||||
|
||||
log.info("Attempting paste from system clipboard");
|
||||
return await this.trySystemClipboardPaste(addMode);
|
||||
const systemSuccess = await this.trySystemClipboardPaste(addMode);
|
||||
|
||||
if (!systemSuccess) {
|
||||
// No valid image found in any clipboard
|
||||
if (preference === 'clipspace') {
|
||||
showWarningNotification("No valid image found in Clipspace or system clipboard");
|
||||
} else {
|
||||
showWarningNotification("No valid image found in clipboard");
|
||||
}
|
||||
}
|
||||
|
||||
return systemSuccess;
|
||||
}, 'ClipboardManager.handlePaste');
|
||||
|
||||
/**
|
||||
@@ -72,6 +85,7 @@ export class ClipboardManager {
|
||||
const img = new Image();
|
||||
img.onload = async () => {
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||
showInfoNotification("Image pasted from Clipspace");
|
||||
};
|
||||
img.src = clipspaceImage.src;
|
||||
return true;
|
||||
@@ -105,6 +119,7 @@ export class ClipboardManager {
|
||||
img.onload = async () => {
|
||||
log.info("Successfully loaded image from system clipboard");
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||
showInfoNotification("Image pasted from system clipboard");
|
||||
};
|
||||
if (event.target?.result) {
|
||||
img.src = event.target.result as string;
|
||||
@@ -148,11 +163,22 @@ export class ClipboardManager {
|
||||
const text = await navigator.clipboard.readText();
|
||||
log.debug("Found text in clipboard:", text);
|
||||
|
||||
if (text && this.isValidImagePath(text)) {
|
||||
log.info("Found valid image path in clipboard:", text);
|
||||
const success = await this.loadImageFromPath(text, addMode);
|
||||
if (success) {
|
||||
return true;
|
||||
if (text) {
|
||||
// Check if it's a data URI (base64 encoded image)
|
||||
if (this.isDataURI(text)) {
|
||||
log.info("Found data URI in clipboard");
|
||||
const success = await this.loadImageFromDataURI(text, addMode);
|
||||
if (success) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Check if it's a regular file path or URL
|
||||
else if (this.isValidImagePath(text)) {
|
||||
log.info("Found valid image path in clipboard:", text);
|
||||
const success = await this.loadImageFromPath(text, addMode);
|
||||
if (success) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -165,6 +191,50 @@ export class ClipboardManager {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Checks if a text string is a data URI (base64 encoded image)
|
||||
* @param {string} text - The text to check
|
||||
* @returns {boolean} - True if the text is a data URI
|
||||
*/
|
||||
isDataURI(text: string): boolean {
|
||||
if (!text || typeof text !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if it starts with data:image
|
||||
return text.trim().startsWith('data:image/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an image from a data URI (base64 encoded image)
|
||||
* @param {string} dataURI - The data URI to load
|
||||
* @param {AddMode} addMode - The mode for adding the layer
|
||||
* @returns {Promise<boolean>} - True if successful, false otherwise
|
||||
*/
|
||||
async loadImageFromDataURI(dataURI: string, addMode: AddMode): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const img = new Image();
|
||||
img.onload = async () => {
|
||||
log.info("Successfully loaded image from data URI");
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||
showInfoNotification("Image pasted from clipboard (base64)");
|
||||
resolve(true);
|
||||
};
|
||||
img.onerror = () => {
|
||||
log.warn("Failed to load image from data URI");
|
||||
showErrorNotification("Failed to load base64 image from clipboard", 5000, true);
|
||||
resolve(false);
|
||||
};
|
||||
img.src = dataURI;
|
||||
} catch (error) {
|
||||
log.error("Error loading data URI:", error);
|
||||
showErrorNotification("Error processing base64 image from clipboard", 5000, true);
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a text string is a valid image file path or URL
|
||||
* @param {string} text - The text to validate
|
||||
@@ -240,15 +310,17 @@ export class ClipboardManager {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
return new Promise((resolve) => {
|
||||
img.onload = async () => {
|
||||
log.info("Successfully loaded image from URL");
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||
resolve(true);
|
||||
};
|
||||
img.onerror = () => {
|
||||
log.warn("Failed to load image from URL:", filePath);
|
||||
resolve(false);
|
||||
};
|
||||
img.onload = async () => {
|
||||
log.info("Successfully loaded image from URL");
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||
showInfoNotification("Image loaded from URL");
|
||||
resolve(true);
|
||||
};
|
||||
img.onerror = () => {
|
||||
log.warn("Failed to load image from URL:", filePath);
|
||||
showErrorNotification(`Failed to load image from URL\nThe link might be incorrect or may not point to an image file.: ${filePath}`, 5000, true);
|
||||
resolve(false);
|
||||
};
|
||||
img.src = filePath;
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -326,6 +398,7 @@ export class ClipboardManager {
|
||||
img.onload = async () => {
|
||||
log.info("Successfully loaded image from backend response");
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||
showInfoNotification("Image loaded from file path");
|
||||
resolve(true);
|
||||
};
|
||||
img.onerror = () => {
|
||||
@@ -366,6 +439,7 @@ export class ClipboardManager {
|
||||
img.onload = async () => {
|
||||
log.info("Successfully loaded image from file picker");
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||
showInfoNotification("Image loaded from selected file");
|
||||
resolve(true);
|
||||
};
|
||||
img.onerror = () => {
|
||||
|
||||
@@ -2,6 +2,9 @@ import { createModuleLogger } from "./LoggerUtils.js";
|
||||
|
||||
const log = createModuleLogger('NotificationUtils');
|
||||
|
||||
// Store active notifications for deduplication
|
||||
const activeNotifications = new Map<string, { element: HTMLDivElement, timeout: number | null }>();
|
||||
|
||||
/**
|
||||
* Utility functions for showing notifications to the user
|
||||
*/
|
||||
@@ -11,16 +14,62 @@ const log = createModuleLogger('NotificationUtils');
|
||||
* @param message - The message to show
|
||||
* @param backgroundColor - Background color (default: #4a6cd4)
|
||||
* @param duration - Duration in milliseconds (default: 3000)
|
||||
* @param type - Type of notification
|
||||
* @param deduplicate - If true, will not show duplicate messages and will refresh existing ones (default: false)
|
||||
*/
|
||||
export function showNotification(
|
||||
message: string,
|
||||
backgroundColor: string = "#4a6cd4",
|
||||
duration: number = 3000,
|
||||
type: "success" | "error" | "info" | "warning" | "alert" = "info"
|
||||
type: "success" | "error" | "info" | "warning" | "alert" = "info",
|
||||
deduplicate: boolean = false
|
||||
): void {
|
||||
// Remove any existing prefix to avoid double prefixing
|
||||
message = message.replace(/^\[Layer Forge\]\s*/, "");
|
||||
|
||||
// If deduplication is enabled, check if this message already exists
|
||||
if (deduplicate) {
|
||||
const existingNotification = activeNotifications.get(message);
|
||||
if (existingNotification) {
|
||||
log.debug(`Notification already exists, refreshing timer: ${message}`);
|
||||
|
||||
// Clear existing timeout
|
||||
if (existingNotification.timeout !== null) {
|
||||
clearTimeout(existingNotification.timeout);
|
||||
}
|
||||
|
||||
// Find the progress bar and restart its animation
|
||||
const progressBar = existingNotification.element.querySelector('div[style*="animation"]') as HTMLDivElement;
|
||||
if (progressBar) {
|
||||
// Reset animation
|
||||
progressBar.style.animation = 'none';
|
||||
// Force reflow
|
||||
void progressBar.offsetHeight;
|
||||
// Restart animation
|
||||
progressBar.style.animation = `lf-progress ${duration / 1000}s linear`;
|
||||
}
|
||||
|
||||
// Set new timeout
|
||||
const newTimeout = window.setTimeout(() => {
|
||||
const notification = existingNotification.element;
|
||||
notification.style.animation = 'lf-fadeout 0.3s ease-out forwards';
|
||||
notification.addEventListener('animationend', () => {
|
||||
if (notification.parentNode) {
|
||||
notification.parentNode.removeChild(notification);
|
||||
activeNotifications.delete(message);
|
||||
const container = document.getElementById('lf-notification-container');
|
||||
if (container && container.children.length === 0) {
|
||||
container.remove();
|
||||
}
|
||||
}
|
||||
});
|
||||
}, duration);
|
||||
|
||||
existingNotification.timeout = newTimeout;
|
||||
return; // Don't create a new notification
|
||||
}
|
||||
}
|
||||
|
||||
// Type-specific config
|
||||
const config = {
|
||||
success: { icon: "✔️", title: "Success", bg: "#1fd18b" },
|
||||
@@ -172,6 +221,11 @@ export function showNotification(
|
||||
|
||||
let dismissTimeout: number | null = null;
|
||||
const closeNotification = () => {
|
||||
// Remove from active notifications map if deduplicate is enabled
|
||||
if (deduplicate) {
|
||||
activeNotifications.delete(message);
|
||||
}
|
||||
|
||||
notification.style.animation = 'lf-fadeout 0.3s ease-out forwards';
|
||||
notification.addEventListener('animationend', () => {
|
||||
if (notification.parentNode) {
|
||||
@@ -198,46 +252,86 @@ export function showNotification(
|
||||
progressBar.style.animation = 'lf-progress-rewind 0.5s ease-out forwards';
|
||||
};
|
||||
|
||||
notification.addEventListener('mouseenter', pauseAndRewindTimer);
|
||||
notification.addEventListener('mouseleave', startDismissTimer);
|
||||
notification.addEventListener('mouseenter', () => {
|
||||
pauseAndRewindTimer();
|
||||
// Update stored timeout if deduplicate is enabled
|
||||
if (deduplicate) {
|
||||
const stored = activeNotifications.get(message);
|
||||
if (stored) {
|
||||
stored.timeout = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
notification.addEventListener('mouseleave', () => {
|
||||
startDismissTimer();
|
||||
// Update stored timeout if deduplicate is enabled
|
||||
if (deduplicate) {
|
||||
const stored = activeNotifications.get(message);
|
||||
if (stored) {
|
||||
stored.timeout = dismissTimeout;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
startDismissTimer();
|
||||
|
||||
// Store notification if deduplicate is enabled
|
||||
if (deduplicate) {
|
||||
activeNotifications.set(message, { element: notification, timeout: dismissTimeout });
|
||||
}
|
||||
|
||||
log.debug(`Notification shown: [Layer Forge] ${message}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a success notification
|
||||
* @param message - The message to show
|
||||
* @param duration - Duration in milliseconds (default: 3000)
|
||||
* @param deduplicate - If true, will not show duplicate messages (default: false)
|
||||
*/
|
||||
export function showSuccessNotification(message: string, duration: number = 3000): void {
|
||||
showNotification(message, undefined, duration, "success");
|
||||
export function showSuccessNotification(message: string, duration: number = 3000, deduplicate: boolean = false): void {
|
||||
showNotification(message, undefined, duration, "success", deduplicate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows an error notification
|
||||
* @param message - The message to show
|
||||
* @param duration - Duration in milliseconds (default: 5000)
|
||||
* @param deduplicate - If true, will not show duplicate messages (default: false)
|
||||
*/
|
||||
export function showErrorNotification(message: string, duration: number = 5000): void {
|
||||
showNotification(message, undefined, duration, "error");
|
||||
export function showErrorNotification(message: string, duration: number = 5000, deduplicate: boolean = false): void {
|
||||
showNotification(message, undefined, duration, "error", deduplicate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows an info notification
|
||||
* @param message - The message to show
|
||||
* @param duration - Duration in milliseconds (default: 3000)
|
||||
* @param deduplicate - If true, will not show duplicate messages (default: false)
|
||||
*/
|
||||
export function showInfoNotification(message: string, duration: number = 3000): void {
|
||||
showNotification(message, undefined, duration, "info");
|
||||
export function showInfoNotification(message: string, duration: number = 3000, deduplicate: boolean = false): void {
|
||||
showNotification(message, undefined, duration, "info", deduplicate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a warning notification
|
||||
* @param message - The message to show
|
||||
* @param duration - Duration in milliseconds (default: 3000)
|
||||
* @param deduplicate - If true, will not show duplicate messages (default: false)
|
||||
*/
|
||||
export function showWarningNotification(message: string, duration: number = 3000): void {
|
||||
showNotification(message, undefined, duration, "warning");
|
||||
export function showWarningNotification(message: string, duration: number = 3000, deduplicate: boolean = false): void {
|
||||
showNotification(message, undefined, duration, "warning", deduplicate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows an alert notification
|
||||
* @param message - The message to show
|
||||
* @param duration - Duration in milliseconds (default: 3000)
|
||||
* @param deduplicate - If true, will not show duplicate messages (default: false)
|
||||
*/
|
||||
export function showAlertNotification(message: string, duration: number = 3000): void {
|
||||
showNotification(message, undefined, duration, "alert");
|
||||
export function showAlertNotification(message: string, duration: number = 3000, deduplicate: boolean = false): void {
|
||||
showNotification(message, undefined, duration, "alert", deduplicate);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -248,7 +342,7 @@ export function showAllNotificationTypes(message?: string): void {
|
||||
types.forEach((type, index) => {
|
||||
const notificationMessage = message || `This is a '${type}' notification.`;
|
||||
setTimeout(() => {
|
||||
showNotification(notificationMessage, undefined, 3000, type);
|
||||
showNotification(notificationMessage, undefined, 3000, type, false);
|
||||
}, index * 400); // Stagger the notifications
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user