23 Commits

Author SHA1 Message Date
Dariusz L
d8d33089d2 Update pyproject.toml 2025-10-27 17:21:34 +01:00
Dariusz L
de67252a87 Add grab icon for layer movement
Implemented grab icon feature in transform mode to move selected layers without changing selection, even when behind other layers. Added hover detection, cursor updates, and visual rendering in CanvasInteractions.ts and CanvasRenderer.ts.
2025-10-27 17:20:53 +01:00
Dariusz L
4acece1602 Update bug_report.yml 2025-09-11 19:08:52 +02:00
Dariusz L
ffa5b136bf Update pyproject.toml 2025-09-04 23:14:15 +02:00
Dariusz L
7a5ecb3919 Fix matting model check and frontend flow
Added proper backend validation for both config.json and model.safetensors to confirm model availability. Updated frontend logic to use /matting/check-model response, preventing unnecessary download notifications.
2025-09-04 23:10:22 +02:00
Dariusz L
20ab861315 Update feature-request.yml 2025-08-27 15:20:33 +02:00
Dariusz L
6750141bcc Update bug_report.yml 2025-08-27 15:04:03 +02:00
Dariusz L
5ea2562b32 added // @ts-ignore to compile to ts 2025-08-22 19:11:15 +02:00
Dariusz L
079fb7b362 Update bug_report.yml 2025-08-22 16:44:36 +02:00
Dariusz L
e05e2d8d8a Update feature-request.yml 2025-08-22 16:40:46 +02:00
Dariusz L
ae55c8a827 Update ComfyUIdownloads.yml 2025-08-21 18:51:38 +02:00
Dariusz L
e21fab0061 Update README.md 2025-08-20 23:29:00 +02:00
Dariusz L
36a80bbb7e Update README.md 2025-08-20 23:26:22 +02:00
Dariusz L
492e06068a Update README.md 2025-08-19 03:07:50 +02:00
Dariusz L
9af1491c68 Update pyproject.toml 2025-08-14 15:04:32 +02:00
Dariusz L
b04795d6e8 Fix CORS for images loaded from IndexedDB
Add crossOrigin='anonymous' to image elements in CanvasState._createLayerFromSrc() method. This prevents canvas tainting when images are restored from IndexedDB after page refresh, ensuring export functions work correctly.
2025-08-14 15:04:08 +02:00
Dariusz L
8d1545bb7e Fix context menu canvas access issues
ix context menu canvas access paths to properly reference canvasWidget.canvas methods instead of canvasWidget directly.
2025-08-14 14:59:28 +02:00
Dariusz L
f6a240c535 Fix CORS issue for Send to Clipspace function
Add crossOrigin='anonymous' attribute to image elements in CanvasLayers.ts to prevent canvas tainting. This resolves the "Tainted canvases may not be exported" error when using the Send to Clipspace feature.
2025-08-14 14:49:18 +02:00
Dariusz L
d1ceb6291b feat: add base64 image paste
Implemented data URI (base64) support for paste operations.
2025-08-14 14:39:01 +02:00
Dariusz L
868221b285 feat: add notification system with deduplication
Implemented a comprehensive notification system with smart deduplication for LayerForge's "Paste Image" operations. The system prevents duplicate error/warning notifications while providing clear feedback for all clipboard operations including success, failure, and edge cases.
2025-08-14 14:30:51 +02:00
Dariusz L
0f4f2cb1b0 feat: add interactive output area transform handles
Implemented drag-to-resize functionality for the output area with visual transform handles on corners and edges. Users can now interactively resize the output area by dragging handles instead of using dialogs, with support for grid snapping and aspect ratio preservation.
2025-08-14 13:54:10 +02:00
Dariusz L
7ce7194cbf feat: add auto adjust output area for selected layers
Implements one-click auto adjustment of output area to fit selected layers with intelligent bounding box calculation. Supports rotation, crop mode, flips, and includes automatic padding with complete canvas state updates.
2025-08-14 12:23:29 +02:00
Dariusz L
990853f8c7 Update Issue_template 2025-08-11 18:16:50 +02:00
24 changed files with 1759 additions and 346 deletions

View File

@@ -1,20 +1,75 @@
name: 🐞 Bug Report
description: Report an error or unexpected behavior
description: 'Report something that is not working correctly'
title: "[BUG] "
labels: [bug]
body:
- type: checkboxes
attributes:
label: Prerequisites
options:
- label: I am running the latest version of [ComfyUI](https://github.com/comfyanonymous/ComfyUI/releases)
required: true
- label: I am running the latest version of [ComfyUI_frontend](https://github.com/Comfy-Org/ComfyUI_frontend/releases)
required: true
- label: I am running the latest version of LayerForge [Github](https://github.com/Azornes/Comfyui-LayerForge/releases) | [Manager](https://registry.comfy.org/publishers/azornes/nodes/layerforge)
required: true
- label: I have searched existing(open/closed) issues to make sure this isn't a duplicate
required: true
- type: textarea
id: description
attributes:
label: What happened?
description: A clear and concise description of the bug. Include screenshots or videos if helpful.
placeholder: |
Example: "When I connect a image to an Input, the connection line appears but the workflow fails to execute with an error message..."
validations:
required: true
- type: textarea
id: reproduce
attributes:
label: Steps to Reproduce
description: How can I reproduce this issue? Please attach your workflow (JSON or PNG) if needed.
placeholder: |
1. Connect Image to Input
2. Click Queue Prompt
3. See error
validations:
required: true
- type: dropdown
id: severity
attributes:
label: How is this affecting you?
options:
- Crashes ComfyUI completely
- Workflow won't execute
- Feature doesn't work as expected
- Visual/UI issue only
- Minor inconvenience
validations:
required: true
- type: dropdown
id: browser
attributes:
label: Browser
description: Which browser are you using?
options:
- Chrome/Chromium
- Firefox
- Safari
- Edge
- Other
validations:
required: true
- type: markdown
attributes:
value: |
**Thank you for reporting a bug!**
Please follow these steps to capture all necessary information:
### ✅ Before You Report:
1. Make sure you have the **latest versions**:
- [ComfyUI Github](https://github.com/comfyanonymous/ComfyUI/releases)
- [LayerForge Github](https://github.com/Azornes/Comfyui-LayerForge/releases) or via [ComfyUI Node Manager](https://registry.comfy.org/publishers/azornes/nodes/layerforge)
2. Gather the required logs:
## Additional Information (Optional)
*The following fields help me debug complex issues but are not required for most bug reports.*
### 🔍 Enable Debug Logs (for **full** logs):
#### 1. Edit `config.js` (Frontend Logs):
@@ -46,75 +101,13 @@ body:
```
➡️ **Restart ComfyUI** after applying these changes to activate full logging.
- type: input
id: environment
attributes:
label: Environment (OS, ComfyUI version, LayerForge version)
placeholder: e.g. Windows 11, ComfyUI v0.3.43, LayerForge v1.2.4
validations:
required: true
- type: input
id: browser
attributes:
label: Browser & Version
placeholder: e.g. Chrome 115.0.0, Firefox 120.1.0
validations:
required: true
- type: textarea
id: what_happened
id: console-errors
attributes:
label: What Happened?
placeholder: Describe the issue you encountered
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to Reproduce
placeholder: |
1. …
2. …
3. …
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behavior
placeholder: Describe what you expected to happen
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual Behavior
placeholder: Describe what happened instead
validations:
required: true
- type: textarea
id: backend_logs
attributes:
label: ComfyUI (Backend) Logs
description: |
After enabling DEBUG logs, please:
1. Restart ComfyUI.
2. Reproduce the issue.
3. Copy-paste the newest **TEXT** logs from the terminal/console here.
validations:
required: true
- type: textarea
id: console_logs
attributes:
label: Browser Console Logs
label: Console Errors
description: |
If you see red error messages in the browser console (F12), paste them here
More info:
After enabling DEBUG logs:
1. Open Developer Tools → Console.
- Chrome/Edge (Win/Linux): `Ctrl+Shift+J`
@@ -128,11 +121,25 @@ body:
- Safari: 🗑 icon or `Cmd+K`.
3. Reproduce the issue.
4. Copy-paste the **TEXT** logs here (no screenshots).
validations:
required: true
render: javascript
- type: markdown
- type: textarea
id: logs
attributes:
value: |
**Optional:** You can also **attach a screenshot or video** to demonstrate the issue visually.
Simply drag & drop or paste image/video files into this issue form. GitHub supports common image formats and MP4/GIF files.
label: Logs
description: |
If relevant, paste any terminal/server logs here
More info:
After enabling DEBUG logs, please:
1. Restart ComfyUI.
2. Reproduce the issue.
3. Copy-paste the newest **TEXT** logs from the terminal/console here.
render: shell
- type: textarea
id: additional
attributes:
label: Additional Context, Environment (OS, ComfyUI versions, Comfyui-LayerForge version)
description: Any other information that might help (OS, GPU, specific nodes involved, etc.)

View File

@@ -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

View File

@@ -3,6 +3,22 @@ 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 of the following:
1. You are using the latest version of the project
2. The functionality you want to propose does not already exist
I also recommend using an AI assistant to check whether the feature is already included.
To do this, simply:
- Copy and paste the entire **README.md** file
- Ask if your desired feature is already covered
This helps to avoid duplicate requests for features that are already available.
- type: markdown
attributes:
value: |

View File

@@ -20,7 +20,7 @@ jobs:
max_downloads=0
top_node_json="{}"
for i in {1..20}; do
for i in {1..3}; do
echo "Pobieranie danych z próby $i..."
curl -s https://api.comfy.org/nodes/layerforge > tmp_$i.json

View File

@@ -51,9 +51,16 @@ https://github.com/user-attachments/assets/9c7ce1de-873b-4a3b-8579-0fc67642af3a
- **AI-Powered Matting:** Optional background removal for any layer using the `BiRefNet` model.
- **Efficient Memory Management:** An automatic garbage collection system cleans up unused image data to keep the
browser's storage footprint low.
- **Workflow Integration:** Outputs a final composite **image** and a combined alpha **mask**, ready for any other
ComfyUI node.
- **Inputs**
- **Image Input (optional):** Accepts a single image.
- **Multiple Images:** If you need to feed in more than one image, use the **core ComfyUI Batch Image node**.
- This lets you route multiple images into LayerForge.
- You can then stack, arrange, and edit them as separate layers inside the canvas.
- **Mask Input (optional):** Accepts a single external mask.
- When provided, the mask is applied directly to the **output area** of the LayerForge canvas when `Run` is triggered in ComfyUI.
- **Outputs**
- **Composite Image:** The final flattened result of your layer stack.
- **Combined Alpha Mask:** A merged mask representing all visible layers, ready for downstream nodes.
---
## 🚀 Installation
@@ -248,6 +255,11 @@ This project is licensed under the MIT License. Feel free to use, modify, and di
---
## 💖 Support / Sponsorship
If youd like to support my work:
👉 [GitHub Sponsors](https://github.com/sponsors/Azornes)
## 🙏 Acknowledgments
Based on the original [**Comfyui-Ycanvas**](https://github.com/yichengup/Comfyui-Ycanvas) by yichengup. This fork

View File

@@ -64,6 +64,8 @@ class BiRefNetConfig(PretrainedConfig):
def __init__(self, bb_pretrained=False, **kwargs):
self.bb_pretrained = bb_pretrained
# Add the missing is_encoder_decoder attribute for compatibility with newer transformers
self.is_encoder_decoder = False
super().__init__(**kwargs)
@@ -755,16 +757,32 @@ class BiRefNetMatting:
full_model_path = os.path.join(self.base_path, "BiRefNet")
log_info(f"Loading BiRefNet model from {full_model_path}...")
try:
# Try loading with additional configuration to handle compatibility issues
self.model = AutoModelForImageSegmentation.from_pretrained(
"ZhengPeng7/BiRefNet",
trust_remote_code=True,
cache_dir=full_model_path
cache_dir=full_model_path,
# Add force_download=False to use cached version if available
force_download=False,
# Add local_files_only=False to allow downloading if needed
local_files_only=False
)
self.model.eval()
if torch.cuda.is_available():
self.model = self.model.cuda()
self.model_cache[model_path] = self.model
log_info("Model loaded successfully from Hugging Face")
except AttributeError as e:
if "'Config' object has no attribute 'is_encoder_decoder'" in str(e):
log_error("Compatibility issue detected with transformers library. This has been fixed in the code.")
log_error("If you're still seeing this error, please clear the model cache and try again.")
raise RuntimeError(
"Model configuration compatibility issue detected. "
f"Please delete the model cache directory '{full_model_path}' and restart ComfyUI. "
"This will download a fresh copy of the model with the updated configuration."
) from e
else:
raise e
except JSONDecodeError as e:
log_error(f"JSONDecodeError: Failed to load model from {full_model_path}. The model's config.json may be corrupted.")
raise RuntimeError(
@@ -894,6 +912,95 @@ class BiRefNetMatting:
_matting_lock = None
@PromptServer.instance.routes.get("/matting/check-model")
async def check_matting_model(request):
"""Check if the matting model is available and ready to use"""
try:
if not TRANSFORMERS_AVAILABLE:
return web.json_response({
"available": False,
"reason": "missing_dependency",
"message": "The 'transformers' library is required for the matting feature. Please install it by running: pip install transformers"
})
# Check if model exists in cache
base_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "models")
model_path = os.path.join(base_path, "BiRefNet")
# Look for the actual BiRefNet model structure
model_files_exist = False
if os.path.exists(model_path):
# BiRefNet model from Hugging Face has a specific structure
# Check for subdirectories that indicate the model is downloaded
existing_items = os.listdir(model_path) if os.path.isdir(model_path) else []
# Look for the model subdirectory (usually named with the model ID)
model_subdirs = [d for d in existing_items if os.path.isdir(os.path.join(model_path, d)) and
(d.startswith("models--") or d == "ZhengPeng7--BiRefNet")]
if model_subdirs:
# Found model subdirectory, check inside for actual model files
for subdir in model_subdirs:
subdir_path = os.path.join(model_path, subdir)
# Navigate through the cache structure
if os.path.exists(os.path.join(subdir_path, "snapshots")):
snapshots_path = os.path.join(subdir_path, "snapshots")
snapshot_dirs = os.listdir(snapshots_path) if os.path.isdir(snapshots_path) else []
for snapshot in snapshot_dirs:
snapshot_path = os.path.join(snapshots_path, snapshot)
snapshot_files = os.listdir(snapshot_path) if os.path.isdir(snapshot_path) else []
# Check for essential files - BiRefNet uses model.safetensors
has_config = "config.json" in snapshot_files
has_model = "model.safetensors" in snapshot_files or "pytorch_model.bin" in snapshot_files
has_backbone = "backbone_swin.pth" in snapshot_files or "swin_base_patch4_window12_384_22kto1k.pth" in snapshot_files
has_birefnet = "birefnet.pth" in snapshot_files or any(f.endswith(".pth") for f in snapshot_files)
# Model is valid if it has config and either model.safetensors or other model files
if has_config and (has_model or has_backbone or has_birefnet):
model_files_exist = True
log_info(f"Found model files in: {snapshot_path} (config: {has_config}, model: {has_model})")
break
if model_files_exist:
break
# Also check if there are .pth files directly in the model_path
if not model_files_exist:
direct_files = existing_items
has_config = "config.json" in direct_files
has_model_files = any(f.endswith((".pth", ".bin", ".safetensors")) for f in direct_files)
model_files_exist = has_config and has_model_files
if model_files_exist:
log_info(f"Found model files directly in: {model_path}")
if model_files_exist:
# Model files exist, assume it's ready
log_info("BiRefNet model files detected")
return web.json_response({
"available": True,
"reason": "ready",
"message": "Model is ready to use"
})
else:
log_info(f"BiRefNet model not found in {model_path}")
return web.json_response({
"available": False,
"reason": "not_downloaded",
"message": "The matting model needs to be downloaded. This will happen automatically when you first use the matting feature (requires internet connection).",
"model_path": model_path
})
except Exception as e:
log_error(f"Error checking matting model: {str(e)}")
return web.json_response({
"available": False,
"reason": "error",
"message": f"Error checking model status: {str(e)}"
}, status=500)
@PromptServer.instance.routes.post("/matting")
async def matting(request):
global _matting_lock

View File

@@ -39,6 +39,9 @@ export class CanvasInteractions {
keyMovementInProgress: false,
canvasResizeRect: null,
canvasMoveRect: null,
outputAreaTransformHandle: null,
outputAreaTransformAnchor: { x: 0, y: 0 },
hoveringGrabIcon: false,
};
this.originalLayerPositions = new Map();
}
@@ -149,6 +152,29 @@ export class CanvasInteractions {
}
return false;
}
/**
* Sprawdza czy punkt znajduje się w obszarze ikony "grab" (środek layera)
* Zwraca layer, jeśli kliknięto w ikonę grab
*/
getGrabIconAtPosition(worldX, worldY) {
// Rozmiar ikony grab w pikselach światowych
const grabIconRadius = 20 / this.canvas.viewport.zoom;
for (const layer of this.canvas.canvasSelection.selectedLayers) {
if (!layer.visible)
continue;
const centerX = layer.x + layer.width / 2;
const centerY = layer.y + layer.height / 2;
// Sprawdź czy punkt jest w obszarze ikony grab (okrąg wokół środka)
const dx = worldX - centerX;
const dy = worldY - centerY;
const distanceSquared = dx * dx + dy * dy;
const radiusSquared = grabIconRadius * grabIconRadius;
if (distanceSquared <= radiusSquared) {
return layer;
}
}
return null;
}
resetInteractionState() {
this.interaction.mode = 'none';
this.interaction.resizeHandle = null;
@@ -157,6 +183,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 +195,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;
@@ -212,6 +251,14 @@ export class CanvasInteractions {
this.startLayerTransform(transformTarget.layer, transformTarget.handle, coords.world);
return;
}
// Check if clicking on grab icon of a selected layer
const grabIconLayer = this.getGrabIconAtPosition(coords.world.x, coords.world.y);
if (grabIconLayer) {
// Start dragging the selected layer(s) without changing selection
this.interaction.mode = 'potential-drag';
this.interaction.dragStart = { ...coords.world };
return;
}
const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(coords.world.x, coords.world.y);
if (clickedLayerResult) {
this.prepareForDrag(clickedLayerResult.layer, coords.world);
@@ -258,7 +305,22 @@ 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:
// Check if hovering over grab icon
const wasHovering = this.interaction.hoveringGrabIcon;
this.interaction.hoveringGrabIcon = this.getGrabIconAtPosition(coords.world.x, coords.world.y) !== null;
// Re-render if hover state changed to show/hide grab icon
if (wasHovering !== this.interaction.hoveringGrabIcon) {
this.canvas.render();
}
this.updateCursor(coords.world);
// Update brush cursor on overlay if mask tool is active
if (this.canvas.maskTool.isActive) {
@@ -285,6 +347,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);
@@ -590,6 +656,11 @@ export class CanvasInteractions {
this.canvas.canvas.style.cursor = 'grabbing';
return;
}
// Check if hovering over grab icon
if (this.interaction.hoveringGrabIcon) {
this.canvas.canvas.style.cursor = 'grab';
return;
}
const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y);
if (transformTarget) {
const handleName = transformTarget.handle;
@@ -1128,4 +1199,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;
}
}

View File

@@ -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;

View File

@@ -141,12 +141,17 @@ export class CanvasRenderer {
ctx.restore();
}
});
// Draw grab icons for selected layers when hovering
if (this.canvas.canvasInteractions.interaction.hoveringGrabIcon) {
this.drawGrabIcons(ctx);
}
this.drawCanvasOutline(ctx);
this.drawOutputAreaExtensionPreview(ctx); // Draw extension preview
this.drawPendingGenerationAreas(ctx); // Draw snapshot outlines
this.renderInteractionElements(ctx);
this.canvas.shapeTool.render(ctx);
this.drawMaskAreaBounds(ctx); // Draw mask area bounds when mask tool is active
this.renderOutputAreaTransformHandles(ctx); // Draw output area transform handles
this.renderLayerInfo(ctx);
// Update custom shape menu position and visibility
if (this.canvas.outputAreaShape) {
@@ -832,4 +837,89 @@ export class CanvasRenderer {
// Just ensure it's the right size
this.updateOverlaySize();
}
/**
* Draw grab icons in the center of selected layers
*/
drawGrabIcons(ctx) {
const selectedLayers = this.canvas.canvasSelection.selectedLayers;
if (selectedLayers.length === 0)
return;
const iconRadius = 20 / this.canvas.viewport.zoom;
const innerRadius = 12 / this.canvas.viewport.zoom;
selectedLayers.forEach((layer) => {
if (!layer.visible)
return;
const centerX = layer.x + layer.width / 2;
const centerY = layer.y + layer.height / 2;
ctx.save();
// Draw outer circle (background)
ctx.beginPath();
ctx.arc(centerX, centerY, iconRadius, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(0, 150, 255, 0.7)';
ctx.fill();
ctx.strokeStyle = 'rgba(255, 255, 255, 0.9)';
ctx.lineWidth = 2 / this.canvas.viewport.zoom;
ctx.stroke();
// Draw hand/grab icon (simplified)
ctx.fillStyle = 'rgba(255, 255, 255, 0.95)';
ctx.strokeStyle = 'rgba(255, 255, 255, 0.95)';
ctx.lineWidth = 1.5 / this.canvas.viewport.zoom;
// Draw four dots representing grab points
const dotRadius = 2 / this.canvas.viewport.zoom;
const dotDistance = 6 / this.canvas.viewport.zoom;
// Top-left
ctx.beginPath();
ctx.arc(centerX - dotDistance, centerY - dotDistance, dotRadius, 0, Math.PI * 2);
ctx.fill();
// Top-right
ctx.beginPath();
ctx.arc(centerX + dotDistance, centerY - dotDistance, dotRadius, 0, Math.PI * 2);
ctx.fill();
// Bottom-left
ctx.beginPath();
ctx.arc(centerX - dotDistance, centerY + dotDistance, dotRadius, 0, Math.PI * 2);
ctx.fill();
// Bottom-right
ctx.beginPath();
ctx.arc(centerX + dotDistance, centerY + dotDistance, dotRadius, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
});
}
/**
* 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);
}
}

View File

@@ -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 };

View File

@@ -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", {
@@ -399,11 +343,38 @@ async function createCanvasWidget(node, widget, app) {
const button = e.target.closest('.matting-button');
if (button.classList.contains('loading'))
return;
const spinner = $el("div.matting-spinner");
button.appendChild(spinner);
button.classList.add('loading');
showInfoNotification("Starting background removal process...", 2000);
try {
// First check if model is available
const modelCheckResponse = await fetch("/matting/check-model");
const modelStatus = await modelCheckResponse.json();
if (!modelStatus.available) {
switch (modelStatus.reason) {
case 'missing_dependency':
showErrorNotification(modelStatus.message, 8000);
return;
case 'not_downloaded':
showWarningNotification("The matting model needs to be downloaded first. This will happen automatically when you proceed (requires internet connection).", 5000);
// Ask user if they want to proceed with download
if (!confirm("The matting model needs to be downloaded (about 1GB). This is a one-time download. Do you want to proceed?")) {
return;
}
showInfoNotification("Downloading matting model... This may take a few minutes.", 10000);
break;
case 'corrupted':
showErrorNotification(modelStatus.message, 8000);
return;
case 'error':
showErrorNotification(`Error checking model: ${modelStatus.message}`, 5000);
return;
}
}
// Proceed with matting
const spinner = $el("div.matting-spinner");
button.appendChild(spinner);
button.classList.add('loading');
if (modelStatus.available) {
showInfoNotification("Starting background removal process...", 2000);
}
if (canvas.canvasSelection.selectedLayers.length !== 1) {
throw new Error("Please select exactly one image layer for matting.");
}
@@ -419,7 +390,20 @@ async function createCanvasWidget(node, widget, app) {
if (!response.ok) {
let errorMsg = `Server error: ${response.status} - ${response.statusText}`;
if (result && result.error) {
errorMsg = `Error: ${result.error}. Details: ${result.details || 'Check console'}`;
// Handle specific error types
if (result.error === "Network Connection Error") {
showErrorNotification("Failed to download the matting model. Please check your internet connection and try again.", 8000);
return;
}
else if (result.error === "Matting Model Error") {
showErrorNotification(result.details || "Model loading error. Please check the console for details.", 8000);
return;
}
else if (result.error === "Dependency Not Found") {
showErrorNotification(result.details || "Missing required dependencies.", 8000);
return;
}
errorMsg = `${result.error}: ${result.details || 'Check console'}`;
}
throw new Error(errorMsg);
}
@@ -439,11 +423,16 @@ async function createCanvasWidget(node, widget, app) {
catch (error) {
log.error("Matting error:", error);
const errorMessage = error.message || "An unknown error occurred.";
showErrorNotification(`Matting Failed: ${errorMessage}`);
if (!errorMessage.includes("Network Connection Error") &&
!errorMessage.includes("Matting Model Error") &&
!errorMessage.includes("Dependency Not Found")) {
showErrorNotification(`Matting Failed: ${errorMessage}`);
}
}
finally {
button.classList.remove('loading');
if (button.contains(spinner)) {
const spinner = button.querySelector('.matting-spinner');
if (spinner && button.contains(spinner)) {
button.removeChild(spinner);
}
}
@@ -1352,8 +1341,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 +1359,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 +1377,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 +1395,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 +1414,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 +1433,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 +1456,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);

View File

@@ -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 = () => {

View File

@@ -1,3 +1,4 @@
// @ts-ignore
import { api } from "../../../scripts/api.js";
import { createModuleLogger } from "./LoggerUtils.js";
import { withErrorHandling, createValidationError, createNetworkError } from "../ErrorHandler.js";

View File

@@ -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
});
}

View File

@@ -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.8"
version = "1.5.11"
license = { text = "MIT License" }
dependencies = ["torch", "torchvision", "transformers", "aiohttp", "numpy", "tqdm", "Pillow"]

View File

@@ -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,9 @@ 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;
hoveringGrabIcon: boolean;
}
export class CanvasInteractions {
@@ -94,6 +97,9 @@ export class CanvasInteractions {
keyMovementInProgress: false,
canvasResizeRect: null,
canvasMoveRect: null,
outputAreaTransformHandle: null,
outputAreaTransformAnchor: { x: 0, y: 0 },
hoveringGrabIcon: false,
};
this.originalLayerPositions = new Map();
}
@@ -230,6 +236,33 @@ export class CanvasInteractions {
return false;
}
/**
* Sprawdza czy punkt znajduje się w obszarze ikony "grab" (środek layera)
* Zwraca layer, jeśli kliknięto w ikonę grab
*/
getGrabIconAtPosition(worldX: number, worldY: number): Layer | null {
// Rozmiar ikony grab w pikselach światowych
const grabIconRadius = 20 / this.canvas.viewport.zoom;
for (const layer of this.canvas.canvasSelection.selectedLayers) {
if (!layer.visible) continue;
const centerX = layer.x + layer.width / 2;
const centerY = layer.y + layer.height / 2;
// Sprawdź czy punkt jest w obszarze ikony grab (okrąg wokół środka)
const dx = worldX - centerX;
const dy = worldY - centerY;
const distanceSquared = dx * dx + dy * dy;
const radiusSquared = grabIconRadius * grabIconRadius;
if (distanceSquared <= radiusSquared) {
return layer;
}
}
return null;
}
resetInteractionState(): void {
this.interaction.mode = 'none';
this.interaction.resizeHandle = null;
@@ -238,6 +271,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 +286,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;
@@ -302,6 +349,15 @@ export class CanvasInteractions {
return;
}
// Check if clicking on grab icon of a selected layer
const grabIconLayer = this.getGrabIconAtPosition(coords.world.x, coords.world.y);
if (grabIconLayer) {
// Start dragging the selected layer(s) without changing selection
this.interaction.mode = 'potential-drag';
this.interaction.dragStart = {...coords.world};
return;
}
const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(coords.world.x, coords.world.y);
if (clickedLayerResult) {
this.prepareForDrag(clickedLayerResult.layer, coords.world);
@@ -352,7 +408,23 @@ 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:
// Check if hovering over grab icon
const wasHovering = this.interaction.hoveringGrabIcon;
this.interaction.hoveringGrabIcon = this.getGrabIconAtPosition(coords.world.x, coords.world.y) !== null;
// Re-render if hover state changed to show/hide grab icon
if (wasHovering !== this.interaction.hoveringGrabIcon) {
this.canvas.render();
}
this.updateCursor(coords.world);
// Update brush cursor on overlay if mask tool is active
if (this.canvas.maskTool.isActive) {
@@ -384,6 +456,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);
@@ -708,6 +785,12 @@ export class CanvasInteractions {
return;
}
// Check if hovering over grab icon
if (this.interaction.hoveringGrabIcon) {
this.canvas.canvas.style.cursor = 'grab';
return;
}
const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y);
if (transformTarget) {
@@ -1313,4 +1396,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;
}
}

View File

@@ -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;

View File

@@ -188,6 +188,11 @@ export class CanvasRenderer {
}
});
// Draw grab icons for selected layers when hovering
if (this.canvas.canvasInteractions.interaction.hoveringGrabIcon) {
this.drawGrabIcons(ctx);
}
this.drawCanvasOutline(ctx);
this.drawOutputAreaExtensionPreview(ctx); // Draw extension preview
this.drawPendingGenerationAreas(ctx); // Draw snapshot outlines
@@ -195,6 +200,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
@@ -1011,4 +1017,106 @@ export class CanvasRenderer {
// Just ensure it's the right size
this.updateOverlaySize();
}
/**
* Draw grab icons in the center of selected layers
*/
drawGrabIcons(ctx: any): void {
const selectedLayers = this.canvas.canvasSelection.selectedLayers;
if (selectedLayers.length === 0) return;
const iconRadius = 20 / this.canvas.viewport.zoom;
const innerRadius = 12 / this.canvas.viewport.zoom;
selectedLayers.forEach((layer: any) => {
if (!layer.visible) return;
const centerX = layer.x + layer.width / 2;
const centerY = layer.y + layer.height / 2;
ctx.save();
// Draw outer circle (background)
ctx.beginPath();
ctx.arc(centerX, centerY, iconRadius, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(0, 150, 255, 0.7)';
ctx.fill();
ctx.strokeStyle = 'rgba(255, 255, 255, 0.9)';
ctx.lineWidth = 2 / this.canvas.viewport.zoom;
ctx.stroke();
// Draw hand/grab icon (simplified)
ctx.fillStyle = 'rgba(255, 255, 255, 0.95)';
ctx.strokeStyle = 'rgba(255, 255, 255, 0.95)';
ctx.lineWidth = 1.5 / this.canvas.viewport.zoom;
// Draw four dots representing grab points
const dotRadius = 2 / this.canvas.viewport.zoom;
const dotDistance = 6 / this.canvas.viewport.zoom;
// Top-left
ctx.beginPath();
ctx.arc(centerX - dotDistance, centerY - dotDistance, dotRadius, 0, Math.PI * 2);
ctx.fill();
// Top-right
ctx.beginPath();
ctx.arc(centerX + dotDistance, centerY - dotDistance, dotRadius, 0, Math.PI * 2);
ctx.fill();
// Bottom-left
ctx.beginPath();
ctx.arc(centerX - dotDistance, centerY + dotDistance, dotRadius, 0, Math.PI * 2);
ctx.fill();
// Bottom-right
ctx.beginPath();
ctx.arc(centerX + dotDistance, centerY + dotDistance, dotRadius, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
});
}
/**
* 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);
}
}

View File

@@ -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};

View File

@@ -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", {
@@ -476,13 +418,46 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
const button = (e.target as HTMLElement).closest('.matting-button') as HTMLButtonElement;
if (button.classList.contains('loading')) return;
const spinner = $el("div.matting-spinner") as HTMLDivElement;
button.appendChild(spinner);
button.classList.add('loading');
showInfoNotification("Starting background removal process...", 2000);
try {
// First check if model is available
const modelCheckResponse = await fetch("/matting/check-model");
const modelStatus = await modelCheckResponse.json();
if (!modelStatus.available) {
switch (modelStatus.reason) {
case 'missing_dependency':
showErrorNotification(modelStatus.message, 8000);
return;
case 'not_downloaded':
showWarningNotification("The matting model needs to be downloaded first. This will happen automatically when you proceed (requires internet connection).", 5000);
// Ask user if they want to proceed with download
if (!confirm("The matting model needs to be downloaded (about 1GB). This is a one-time download. Do you want to proceed?")) {
return;
}
showInfoNotification("Downloading matting model... This may take a few minutes.", 10000);
break;
case 'corrupted':
showErrorNotification(modelStatus.message, 8000);
return;
case 'error':
showErrorNotification(`Error checking model: ${modelStatus.message}`, 5000);
return;
}
}
// Proceed with matting
const spinner = $el("div.matting-spinner") as HTMLDivElement;
button.appendChild(spinner);
button.classList.add('loading');
if (modelStatus.available) {
showInfoNotification("Starting background removal process...", 2000);
}
if (canvas.canvasSelection.selectedLayers.length !== 1) {
throw new Error("Please select exactly one image layer for matting.");
}
@@ -501,7 +476,18 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
if (!response.ok) {
let errorMsg = `Server error: ${response.status} - ${response.statusText}`;
if (result && result.error) {
errorMsg = `Error: ${result.error}. Details: ${result.details || 'Check console'}`;
// Handle specific error types
if (result.error === "Network Connection Error") {
showErrorNotification("Failed to download the matting model. Please check your internet connection and try again.", 8000);
return;
} else if (result.error === "Matting Model Error") {
showErrorNotification(result.details || "Model loading error. Please check the console for details.", 8000);
return;
} else if (result.error === "Dependency Not Found") {
showErrorNotification(result.details || "Missing required dependencies.", 8000);
return;
}
errorMsg = `${result.error}: ${result.details || 'Check console'}`;
}
throw new Error(errorMsg);
}
@@ -526,10 +512,15 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
} catch (error: any) {
log.error("Matting error:", error);
const errorMessage = error.message || "An unknown error occurred.";
showErrorNotification(`Matting Failed: ${errorMessage}`);
if (!errorMessage.includes("Network Connection Error") &&
!errorMessage.includes("Matting Model Error") &&
!errorMessage.includes("Dependency Not Found")) {
showErrorNotification(`Matting Failed: ${errorMessage}`);
}
} finally {
button.classList.remove('loading');
if (button.contains(spinner)) {
const spinner = button.querySelector('.matting-spinner');
if (spinner && button.contains(spinner)) {
button.removeChild(spinner);
}
}
@@ -1548,8 +1539,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 +1555,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 +1570,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 +1585,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 +1601,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 +1617,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 +1637,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');

View File

@@ -1,3 +1,4 @@
// @ts-ignore
import { api } from "../../scripts/api.js";
// @ts-ignore
import { ComfyApp } from "../../scripts/app.js";

View File

@@ -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 = () => {

View File

@@ -1,3 +1,4 @@
// @ts-ignore
import { api } from "../../../scripts/api.js";
import { createModuleLogger } from "./LoggerUtils.js";
import { withErrorHandling, createValidationError, createNetworkError } from "../ErrorHandler.js";

View File

@@ -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
});
}