45 Commits
v1.5.7 ... main

Author SHA1 Message Date
Dariusz L
835d94a11d Merge pull request #24 from diodiogod/fix/keyboard-shortcuts-focus-check
Fix keyboard shortcuts capturing events when node is unfocused
2026-02-20 16:28:04 +01:00
Dariusz L
061e2b7a9a Cleanup: Remove accidentally committed python\__pycache__folder 2026-02-05 16:23:10 +01:00
diodiogod
b1f29eefdb Cleanup: Remove accidentally committed __pycache__ and .vscode folders 2026-02-03 15:12:51 -03:00
diodiogod
b8fbcee67a Fix focus/modifier issues and improve multi-layer selection UX 2026-02-03 02:00:24 -03:00
diodiogod
d44d944f2d Fix: Restore drag-and-drop in layers panel 2026-02-02 23:15:08 -03:00
diodiogod
ab5d71597a Fix: Allow paste event to bubble for system clipboard access 2026-02-02 20:56:56 -03:00
diodiogod
ce4d332987 Fix Ctrl+V to paste from system clipboard when internal clipboard is empty 2026-02-02 18:22:54 -03:00
diodiogod
9b04729561 Enable cross-workflow node duplication with layers
Store canvas state in IndexedDB clipboard on copy, allowing nodes to be
duplicated with their layers preserved across different workflows.

When copying a node, the canvas state is stored in a special
'__clipboard__' entry that persists across workflow switches. On paste,
if the source node doesn't exist (indicating cross-workflow paste), the
system falls back to loading from the clipboard entry.
2026-01-19 18:24:42 -03:00
diodiogod
27ad139cd5 Add layer copy/paste and node duplication with layers
Implements two new features:
- Layer copy/paste within canvas using Ctrl+C/V
- Node duplication that preserves all layers

Layer Copy/Paste:
- Added Ctrl+V keyboard shortcut handler for pasting layers
- Intercept keydown events during capture phase to handle before ComfyUI
- Focus canvas when layer is clicked to ensure shortcuts work
- Prevent layers panel from stealing focus on mousedown

Node Duplication:
- Store source node ID during serialize for copy operations
- Track pending copy sources across node ID changes (-1 to real ID)
- Copy canvas state from source to destination in onAdded hook
- Use Map to persist copy metadata through node lifecycle
2026-01-19 17:57:14 -03:00
diodiogod
66cbcb641b Add retry logic for image loading validation
Increase robustness of image loading check before sending canvas data.
Now retries up to 5 times with 200ms delays (1 second total) instead
of a single 100ms wait.

This fixes the 'Failed to get confirmation from server' error that
appeared when executing workflows immediately after ComfyUI restart,
before images finished loading from IndexedDB.

Prevents workflow execution failures due to timing issues during
canvas initialization.
2026-01-17 20:30:36 -03:00
diodiogod
986e0a23a2 Fix canvas sizing bug by separating display and output dimensions
The canvas was getting corrupted to a small strip because of confusion
between two different dimension types:
- Output area dimensions (logical working area, e.g. 512x512)
- Display canvas dimensions (actual pixels shown on screen)

Root cause: Setting canvas.width/height attributes to match output area
while also using CSS width:100%/height:100% created conflicts. When
zooming or reloading, wrong dimensions would be read and saved.

Fix: Remove canvas element width/height attribute assignments. Let the
render loop control display size based on clientWidth/clientHeight.
Keep output area dimensions separate.

This prevents the canvas from being saved with corrupted tiny dimensions
and fixes the issue where canvas would only show in a small strip after
zooming or reloading workflows.
2026-01-17 15:03:00 -03:00
diodiogod
068ed9ee59 Skip sending canvas data for bypassed nodes
Fix critical issue where LayerForge was trying to send canvas data
even when the node was bypassed (mode === 4). This caused unnecessary
errors and blocked workflow execution.

Now properly checks node.mode before attempting to send data via
WebSocket, skipping bypassed nodes entirely.
2026-01-15 09:40:33 -03:00
diodiogod
4e5ef18d93 Fix canvas initialization and sizing bugs
- Add image loading validation before sending canvas data to server
  Prevents 'Failed to get confirmation' error when images haven't
  finished loading after workflow reload. Waits 100ms and checks
  if all layer images are complete before rendering output.

- Improve layer loading error handling in CanvasState
  Better logging when layers fail to load from IndexedDB.
  Allows empty canvas as valid state instead of failing.

- Add ResizeObserver for canvas container
  Fixes bug where canvas only shows in top half of node.
  Watches container size changes and triggers re-render to ensure
  canvas dimensions are correctly calculated after DOM layout.
2026-01-15 09:38:59 -03:00
diodiogod
be37966b45 Add DOM connection check to prevent capturing events in subgraphs
Ensure the canvas element is actually connected to the DOM before
handling paste events. This prevents LayerForge from capturing paste
events when navigating in subgraphs where the canvas is not visible.

Adds check for canvas.isConnected and document.body.contains() to
verify the canvas is part of the active DOM tree.
2026-01-14 16:11:22 -03:00
diodiogod
dd5fc5470f Fix keyboard shortcuts capturing events when node is unfocused
Prevent LayerForge from intercepting Ctrl+C, Ctrl+V, and other keyboard
shortcuts when the canvas is not focused. This was causing unwanted
popups and interfering with other nodes in ComfyUI.

Changes:
- Remove document.body focus check from handlePasteEvent
- Add focus validation to handleKeyDown before processing shortcuts
- Modifier keys (Ctrl, Shift, Alt, Meta) are still tracked globally
- All other shortcuts only trigger when canvas is focused

Fixes issue where paste events were captured globally regardless of focus.
2026-01-10 11:12:31 -03:00
Dariusz L
1f1d0aeb7d Update README.md 2025-11-13 17:10:29 +01:00
Dariusz L
da55d741d6 Update README.md 2025-11-13 16:37:25 +01:00
Dariusz L
959c47c29b Update README with quick links and compatibility info
Added quick start and workflow example links for easier navigation. Improved installation instructions and clarified manual install steps. Documented known incompatibility with Vue Nodes and provided guidance for reverting settings. Enhanced support section with actionable items.
2025-11-13 16:21:47 +01:00
Dariusz L
ab7ab9d1a8 Update README.md 2025-10-27 18:52:33 +01:00
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
Dariusz L
5fb163cd59 Update pyproject.toml 2025-08-09 17:07:24 +02:00
Dariusz L
19d3238680 Fix mismatch between preview and actual mask
Corrected the overlay alignment issue on the canvas so that the preview mask now matches the actual mask positioning. This ensures consistent visual accuracy during editing.
2025-08-09 17:07:13 +02:00
Dariusz L
c9860cac9e Add Master Visibility Toggle to Layers Panel
Introduce a three-state checkbox in CanvasLayersPanel header to control visibility of all layers at once. Supports automatic state updates and integrates with renderLayers() for seamless layer management.
2025-08-09 16:15:11 +02:00
32 changed files with 2574 additions and 485 deletions

View File

@@ -1,20 +1,75 @@
name: 🐞 Bug Report name: 🐞 Bug Report
description: Report an error or unexpected behavior description: 'Report something that is not working correctly'
title: "[BUG] " title: "[BUG] "
labels: [bug] labels: [bug]
body: 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 - type: markdown
attributes: attributes:
value: | value: |
**Thank you for reporting a bug!** ## Additional Information (Optional)
Please follow these steps to capture all necessary information: *The following fields help me debug complex issues but are not required for most bug reports.*
### ✅ 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:
### 🔍 Enable Debug Logs (for **full** logs): ### 🔍 Enable Debug Logs (for **full** logs):
#### 1. Edit `config.js` (Frontend Logs): #### 1. Edit `config.js` (Frontend Logs):
@@ -46,75 +101,13 @@ body:
``` ```
➡️ **Restart ComfyUI** after applying these changes to activate full logging. ➡️ **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 - type: textarea
id: what_happened id: console-errors
attributes: attributes:
label: What Happened? label: Console Errors
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
description: | description: |
If you see red error messages in the browser console (F12), paste them here
More info:
After enabling DEBUG logs: After enabling DEBUG logs:
1. Open Developer Tools → Console. 1. Open Developer Tools → Console.
- Chrome/Edge (Win/Linux): `Ctrl+Shift+J` - Chrome/Edge (Win/Linux): `Ctrl+Shift+J`
@@ -128,11 +121,25 @@ body:
- Safari: 🗑 icon or `Cmd+K`. - Safari: 🗑 icon or `Cmd+K`.
3. Reproduce the issue. 3. Reproduce the issue.
4. Copy-paste the **TEXT** logs here (no screenshots). 4. Copy-paste the **TEXT** logs here (no screenshots).
validations: render: javascript
required: true
- type: markdown - type: textarea
id: logs
attributes: attributes:
value: | label: Logs
**Optional:** You can also **attach a screenshot or video** to demonstrate the issue visually. description: |
Simply drag & drop or paste image/video files into this issue form. GitHub supports common image formats and MP4/GIF files. 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] " title: "[Docs] "
labels: [documentation] labels: [documentation]
body: 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 - type: input
id: doc_area id: doc_area
attributes: attributes:
label: Area of documentation label: Area of documentation
placeholder: e.g. Getting started, Node API, Deployment guide placeholder: e.g. Key Features, Installation, Controls & Shortcuts
validations: validations:
required: true required: true
- type: textarea - type: textarea

View File

@@ -3,6 +3,22 @@ description: Suggest an idea for this project
title: '[Feature Request]: ' title: '[Feature Request]: '
labels: ['enhancement'] labels: ['enhancement']
body: 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 - type: markdown
attributes: attributes:
value: | value: |

View File

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

View File

@@ -19,6 +19,15 @@
<img alt="JavaScript" src="https://img.shields.io/badge/-JavaScript-000000?logo=javascript&logoColor=F7DF1E&style=for-the-badge&logoWidth=20"> <img alt="JavaScript" src="https://img.shields.io/badge/-JavaScript-000000?logo=javascript&logoColor=F7DF1E&style=for-the-badge&logoWidth=20">
</p> </p>
<p align="center">
<strong>🔹 <a href="https://github.com/Azornes/Comfyui-LayerForge?tab=readme-ov-file#-installation">Quick Start</a></strong>
&nbsp; | &nbsp;
<strong>🧩 <a href="https://github.com/Azornes/Comfyui-LayerForge?tab=readme-ov-file#-workflow-example">Workflow Example</a></strong>
&nbsp; | &nbsp;
<strong>⚠️ <a href="https://github.com/Azornes/Comfyui-LayerForge?tab=readme-ov-file#%EF%B8%8F-known-issues--compatibility">Known Issues</a></strong>
</p>
### Why LayerForge? ### Why LayerForge?
- **Full Creative Control:** Move beyond simple image inputs. Composite, mask, and blend multiple elements without - **Full Creative Control:** Move beyond simple image inputs. Composite, mask, and blend multiple elements without
@@ -51,19 +60,27 @@ https://github.com/user-attachments/assets/9c7ce1de-873b-4a3b-8579-0fc67642af3a
- **AI-Powered Matting:** Optional background removal for any layer using the `BiRefNet` model. - **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 - **Efficient Memory Management:** An automatic garbage collection system cleans up unused image data to keep the
browser's storage footprint low. browser's storage footprint low.
- **Workflow Integration:** Outputs a final composite **image** and a combined alpha **mask**, ready for any other - **Inputs**
ComfyUI node. - **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 ## 🚀 Installation
### Install via ComfyUI-Manager ### Install via ComfyUI-Manager
* Search `Comfyui-LayerForge` in ComfyUI-Manager and click `Install` button. 1. Search `Comfyui-LayerForge` in ComfyUI-Manager and click `Install` button.
2. Restart ComfyUI.
### Manual Install ### Manual Install
1. Install [ComfyUi](https://github.com/comfyanonymous/ComfyUI). 1. Install [ComfyUi](https://github.com/comfyanonymous/ComfyUI). I use [portable](https://docs.comfy.org/installation/comfyui_portable_windows) version.
2. Clone this repo into `custom_modules`: 2. Clone this repo into `custom_nodes`:
```bash ```bash
cd ComfyUI/custom_nodes/ cd ComfyUI/custom_nodes/
git clone https://github.com/Azornes/Comfyui-LayerForge.git git clone https://github.com/Azornes/Comfyui-LayerForge.git
@@ -223,18 +240,24 @@ optional feature and requires a model.
--- ---
## 🔧 Troubleshooting ## ⚠️ Known Issues / Compatibility
### `node_id` not auto-filled → black output #### ○ Incompatibility with Modern Node Design (Vue Nodes)
> This node is **not compatible** with the new Vue Nodes display system.
>
> 🔧 **How to fix:**
> Go to **Settings → (search) "Vue Nodes" → Disable "Modern Node Design (Vue Nodes)"**.
In some cases, **ComfyUI doesn't auto-fill the `node_id`** when adding a node. ---
As a result, the node may produce a **completely black image** or not work at all.
**Workaround:** #### ○ `node_id` not auto-filled → black output
> In some cases, **ComfyUI doesnt auto-fill the `node_id`** when adding a node.
* Search node ID in ComfyUI settings. > This may cause the node to output a **completely black image** or fail to work.
* In NodesMap check "Enable node ID display" >
* Manually enter the correct `node_id` (match the ID Node "LayerForge" shown above the node, on the right side). > 🛠️ **Workaround:**
> - Open **Settings → NodesMap → Enable "Show node IDs"**
> - Find the correct ID for your node *(match the ID Node "LayerForge" shown above the node, on the right side)*.
> - Manually enter the correct `node_id` in the LayerForge node
> [!WARNING] > [!WARNING]
> This is a known issue and not yet fixed. > This is a known issue and not yet fixed.
@@ -248,6 +271,14 @@ This project is licensed under the MIT License. Feel free to use, modify, and di
--- ---
## 💖 Support / Sponsorship
• ⭐ Give a star — it means a lot to me!
• 🐛 Report a bug or suggest a feature
• 💖 If youd like to support my work:
👉 [GitHub Sponsors](https://github.com/sponsors/Azornes)
---
## 🙏 Acknowledgments ## 🙏 Acknowledgments
Based on the original [**Comfyui-Ycanvas**](https://github.com/yichengup/Comfyui-Ycanvas) by yichengup. This fork 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): def __init__(self, bb_pretrained=False, **kwargs):
self.bb_pretrained = bb_pretrained 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) super().__init__(**kwargs)
@@ -755,16 +757,32 @@ class BiRefNetMatting:
full_model_path = os.path.join(self.base_path, "BiRefNet") full_model_path = os.path.join(self.base_path, "BiRefNet")
log_info(f"Loading BiRefNet model from {full_model_path}...") log_info(f"Loading BiRefNet model from {full_model_path}...")
try: try:
# Try loading with additional configuration to handle compatibility issues
self.model = AutoModelForImageSegmentation.from_pretrained( self.model = AutoModelForImageSegmentation.from_pretrained(
"ZhengPeng7/BiRefNet", "ZhengPeng7/BiRefNet",
trust_remote_code=True, 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() self.model.eval()
if torch.cuda.is_available(): if torch.cuda.is_available():
self.model = self.model.cuda() self.model = self.model.cuda()
self.model_cache[model_path] = self.model self.model_cache[model_path] = self.model
log_info("Model loaded successfully from Hugging Face") 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: except JSONDecodeError as e:
log_error(f"JSONDecodeError: Failed to load model from {full_model_path}. The model's config.json may be corrupted.") log_error(f"JSONDecodeError: Failed to load model from {full_model_path}. The model's config.json may be corrupted.")
raise RuntimeError( raise RuntimeError(
@@ -894,6 +912,95 @@ class BiRefNetMatting:
_matting_lock = None _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") @PromptServer.instance.routes.post("/matting")
async def matting(request): async def matting(request):
global _matting_lock global _matting_lock

View File

@@ -443,8 +443,8 @@ export class Canvas {
* Inicjalizuje podstawowe właściwości canvas * Inicjalizuje podstawowe właściwości canvas
*/ */
initCanvas() { initCanvas() {
this.canvas.width = this.width; // Don't set canvas.width/height here - let the render loop handle it based on clientWidth/clientHeight
this.canvas.height = this.height; // this.width and this.height are for the OUTPUT AREA, not the display canvas
this.canvas.style.border = '1px solid black'; this.canvas.style.border = '1px solid black';
this.canvas.style.maxWidth = '100%'; this.canvas.style.maxWidth = '100%';
this.canvas.style.backgroundColor = '#606060'; this.canvas.style.backgroundColor = '#606060';

View File

@@ -197,6 +197,25 @@ export class CanvasIO {
} }
async _renderOutputData() { async _renderOutputData() {
log.info("=== RENDERING OUTPUT DATA FOR COMFYUI ==="); log.info("=== RENDERING OUTPUT DATA FOR COMFYUI ===");
// Check if layers have valid images loaded, with retry logic
const maxRetries = 5;
const retryDelay = 200;
for (let attempt = 0; attempt < maxRetries; attempt++) {
const layersWithoutImages = this.canvas.layers.filter(layer => !layer.image || !layer.image.complete);
if (layersWithoutImages.length === 0) {
break; // All images loaded
}
if (attempt === 0) {
log.warn(`${layersWithoutImages.length} layer(s) have incomplete image data. Waiting for images to load...`);
}
if (attempt < maxRetries - 1) {
await new Promise(resolve => setTimeout(resolve, retryDelay));
}
else {
// Last attempt failed
throw new Error(`Canvas not ready after ${maxRetries} attempts: ${layersWithoutImages.length} layer(s) still have incomplete image data. Try waiting a moment and running again.`);
}
}
// Użyj zunifikowanych funkcji z CanvasLayers // Użyj zunifikowanych funkcji z CanvasLayers
const imageBlob = await this.canvas.canvasLayers.getFlattenedCanvasAsBlob(); const imageBlob = await this.canvas.canvasLayers.getFlattenedCanvasAsBlob();
const maskBlob = await this.canvas.canvasLayers.getFlattenedMaskAsBlob(); const maskBlob = await this.canvas.canvasLayers.getFlattenedMaskAsBlob();

View File

@@ -39,6 +39,9 @@ export class CanvasInteractions {
keyMovementInProgress: false, keyMovementInProgress: false,
canvasResizeRect: null, canvasResizeRect: null,
canvasMoveRect: null, canvasMoveRect: null,
outputAreaTransformHandle: null,
outputAreaTransformAnchor: { x: 0, y: 0 },
hoveringGrabIcon: false,
}; };
this.originalLayerPositions = new Map(); this.originalLayerPositions = new Map();
} }
@@ -101,6 +104,8 @@ export class CanvasInteractions {
// Add a blur event listener to the window to reset key states // Add a blur event listener to the window to reset key states
window.addEventListener('blur', this.onBlur); window.addEventListener('blur', this.onBlur);
document.addEventListener('paste', this.onPaste); document.addEventListener('paste', this.onPaste);
// Intercept Ctrl+V during capture phase to handle layer paste before ComfyUI
document.addEventListener('keydown', this.onKeyDown, { capture: true });
this.canvas.canvas.addEventListener('mouseenter', this.onMouseEnter); this.canvas.canvas.addEventListener('mouseenter', this.onMouseEnter);
this.canvas.canvas.addEventListener('mouseleave', this.onMouseLeave); this.canvas.canvas.addEventListener('mouseleave', this.onMouseLeave);
this.canvas.canvas.addEventListener('dragover', this.onDragOver); this.canvas.canvas.addEventListener('dragover', this.onDragOver);
@@ -116,6 +121,8 @@ export class CanvasInteractions {
this.canvas.canvas.removeEventListener('wheel', this.onWheel); this.canvas.canvas.removeEventListener('wheel', this.onWheel);
this.canvas.canvas.removeEventListener('keydown', this.onKeyDown); this.canvas.canvas.removeEventListener('keydown', this.onKeyDown);
this.canvas.canvas.removeEventListener('keyup', this.onKeyUp); this.canvas.canvas.removeEventListener('keyup', this.onKeyUp);
// Remove document-level capture listener
document.removeEventListener('keydown', this.onKeyDown, { capture: true });
window.removeEventListener('blur', this.onBlur); window.removeEventListener('blur', this.onBlur);
document.removeEventListener('paste', this.onPaste); document.removeEventListener('paste', this.onPaste);
this.canvas.canvas.removeEventListener('mouseenter', this.onMouseEnter); this.canvas.canvas.removeEventListener('mouseenter', this.onMouseEnter);
@@ -149,6 +156,29 @@ export class CanvasInteractions {
} }
return false; 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() { resetInteractionState() {
this.interaction.mode = 'none'; this.interaction.mode = 'none';
this.interaction.resizeHandle = null; this.interaction.resizeHandle = null;
@@ -157,10 +187,17 @@ export class CanvasInteractions {
this.interaction.canvasMoveRect = null; this.interaction.canvasMoveRect = null;
this.interaction.hasClonedInDrag = false; this.interaction.hasClonedInDrag = false;
this.interaction.transformingLayer = null; this.interaction.transformingLayer = null;
this.interaction.outputAreaTransformHandle = null;
this.canvas.canvas.style.cursor = 'default'; this.canvas.canvas.style.cursor = 'default';
} }
handleMouseDown(e) { handleMouseDown(e) {
this.canvas.canvas.focus(); this.canvas.canvas.focus();
// Sync modifier states with actual event state to prevent "stuck" modifiers
// when focus moves between layers panel and canvas
this.interaction.isCtrlPressed = e.ctrlKey;
this.interaction.isMetaPressed = e.metaKey;
this.interaction.isShiftPressed = e.shiftKey;
this.interaction.isAltPressed = e.altKey;
const coords = this.getMouseCoordinates(e); const coords = this.getMouseCoordinates(e);
const mods = this.getModifierState(e); const mods = this.getModifierState(e);
if (this.interaction.mode === 'drawingMask') { if (this.interaction.mode === 'drawingMask') {
@@ -168,6 +205,18 @@ export class CanvasInteractions {
// Don't render here - mask tool will handle its own drawing // Don't render here - mask tool will handle its own drawing
return; 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) { if (this.canvas.shapeTool.isActive) {
this.canvas.shapeTool.addPoint(coords.world); this.canvas.shapeTool.addPoint(coords.world);
return; return;
@@ -212,6 +261,14 @@ export class CanvasInteractions {
this.startLayerTransform(transformTarget.layer, transformTarget.handle, coords.world); this.startLayerTransform(transformTarget.layer, transformTarget.handle, coords.world);
return; 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); const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(coords.world.x, coords.world.y);
if (clickedLayerResult) { if (clickedLayerResult) {
this.prepareForDrag(clickedLayerResult.layer, coords.world); this.prepareForDrag(clickedLayerResult.layer, coords.world);
@@ -258,7 +315,22 @@ export class CanvasInteractions {
case 'movingCanvas': case 'movingCanvas':
this.updateCanvasMove(coords.world); this.updateCanvasMove(coords.world);
break; break;
case 'transformingOutputArea':
if (this.interaction.outputAreaTransformHandle) {
this.resizeOutputAreaFromHandle(coords.world, e.shiftKey);
}
else {
this.updateOutputAreaTransformCursor(coords.world);
}
break;
default: 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); this.updateCursor(coords.world);
// Update brush cursor on overlay if mask tool is active // Update brush cursor on overlay if mask tool is active
if (this.canvas.maskTool.isActive) { if (this.canvas.maskTool.isActive) {
@@ -285,6 +357,10 @@ export class CanvasInteractions {
if (this.interaction.mode === 'movingCanvas') { if (this.interaction.mode === 'movingCanvas') {
this.finalizeCanvasMove(); this.finalizeCanvasMove();
} }
if (this.interaction.mode === 'transformingOutputArea' && this.interaction.outputAreaTransformHandle) {
this.finalizeOutputAreaTransform();
return;
}
// Log layer positions when dragging ends // Log layer positions when dragging ends
if (this.interaction.mode === 'dragging' && this.canvas.canvasSelection.selectedLayers.length > 0) { if (this.interaction.mode === 'dragging' && this.canvas.canvasSelection.selectedLayers.length > 0) {
this.logDragCompletion(coords); this.logDragCompletion(coords);
@@ -453,14 +529,24 @@ export class CanvasInteractions {
return targetHeight / oldHeight; return targetHeight / oldHeight;
} }
handleKeyDown(e) { handleKeyDown(e) {
// Always track modifier keys regardless of focus
if (e.key === 'Control') if (e.key === 'Control')
this.interaction.isCtrlPressed = true; this.interaction.isCtrlPressed = true;
if (e.key === 'Meta') if (e.key === 'Meta')
this.interaction.isMetaPressed = true; this.interaction.isMetaPressed = true;
if (e.key === 'Shift') if (e.key === 'Shift')
this.interaction.isShiftPressed = true; this.interaction.isShiftPressed = true;
if (e.key === 'Alt') { if (e.key === 'Alt')
this.interaction.isAltPressed = true; this.interaction.isAltPressed = true;
// Check if canvas is focused before handling any shortcuts
const shouldHandle = this.canvas.isMouseOver ||
this.canvas.canvas.contains(document.activeElement) ||
document.activeElement === this.canvas.canvas;
if (!shouldHandle) {
return;
}
// Canvas-specific key handlers (only when focused)
if (e.key === 'Alt') {
e.preventDefault(); e.preventDefault();
} }
if (e.key.toLowerCase() === 's') { if (e.key.toLowerCase() === 's') {
@@ -494,6 +580,17 @@ export class CanvasInteractions {
this.canvas.canvasLayers.copySelectedLayers(); this.canvas.canvasLayers.copySelectedLayers();
} }
break; break;
case 'v':
// Only handle internal clipboard paste here.
// If internal clipboard is empty, let the paste event bubble
// so handlePasteEvent can access e.clipboardData for system images.
if (this.canvas.canvasLayers.internalClipboard.length > 0) {
this.canvas.canvasLayers.pasteLayers();
} else {
// Don't preventDefault - let paste event fire for system clipboard
handled = false;
}
break;
default: default:
handled = false; handled = false;
break; break;
@@ -590,6 +687,11 @@ export class CanvasInteractions {
this.canvas.canvas.style.cursor = 'grabbing'; this.canvas.canvas.style.cursor = 'grabbing';
return; 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); const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y);
if (transformTarget) { if (transformTarget) {
const handleName = transformTarget.handle; const handleName = transformTarget.handle;
@@ -642,12 +744,11 @@ export class CanvasInteractions {
if (mods.ctrl || mods.meta) { if (mods.ctrl || mods.meta) {
const index = this.canvas.canvasSelection.selectedLayers.indexOf(layer); const index = this.canvas.canvasSelection.selectedLayers.indexOf(layer);
if (index === -1) { if (index === -1) {
// Ctrl-clicking unselected layer: add to selection
this.canvas.canvasSelection.updateSelection([...this.canvas.canvasSelection.selectedLayers, layer]); this.canvas.canvasSelection.updateSelection([...this.canvas.canvasSelection.selectedLayers, layer]);
} }
else { // If already selected, do NOT deselect - allows dragging multiple layers with Ctrl held
const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l) => l !== layer); // User can use right-click in layers panel to deselect individual layers
this.canvas.canvasSelection.updateSelection(newSelection);
}
} }
else { else {
if (!this.canvas.canvasSelection.selectedLayers.includes(layer)) { if (!this.canvas.canvasSelection.selectedLayers.includes(layer)) {
@@ -1084,10 +1185,13 @@ export class CanvasInteractions {
} }
} }
async handlePasteEvent(e) { async handlePasteEvent(e) {
// Check if canvas is connected to DOM and visible
if (!this.canvas.canvas.isConnected || !document.body.contains(this.canvas.canvas)) {
return;
}
const shouldHandle = this.canvas.isMouseOver || const shouldHandle = this.canvas.isMouseOver ||
this.canvas.canvas.contains(document.activeElement) || this.canvas.canvas.contains(document.activeElement) ||
document.activeElement === this.canvas.canvas || document.activeElement === this.canvas.canvas;
document.activeElement === document.body;
if (!shouldHandle) { if (!shouldHandle) {
log.debug("Paste event ignored - not focused on canvas"); log.debug("Paste event ignored - not focused on canvas");
return; return;
@@ -1128,4 +1232,168 @@ export class CanvasInteractions {
} }
await this.canvas.canvasLayers.clipboardManager.handlePaste('mouse', preference); 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.globalCompositeOperation = 'destination-in';
tempCtx.drawImage(maskCanvas, 0, 0); tempCtx.drawImage(maskCanvas, 0, 0);
const newImage = new Image(); const newImage = new Image();
newImage.crossOrigin = 'anonymous';
newImage.src = tempCanvas.toDataURL(); newImage.src = tempCanvas.toDataURL();
layer.image = newImage; layer.image = newImage;
} }
@@ -158,6 +159,7 @@ export class CanvasLayers {
reader.readAsDataURL(blob); reader.readAsDataURL(blob);
}); });
const img = new Image(); const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => { img.onload = () => {
if (!this.canvas.node.imgs) { if (!this.canvas.node.imgs) {
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() { pasteLayers() {
if (this.internalClipboard.length === 0) if (this.internalClipboard.length === 0)
return; return;
@@ -742,6 +855,7 @@ export class CanvasLayers {
} }
// Convert canvas to image // Convert canvas to image
const processedImage = new Image(); const processedImage = new Image();
processedImage.crossOrigin = 'anonymous';
processedImage.src = processedCanvas.toDataURL(); processedImage.src = processedCanvas.toDataURL();
return processedImage; return processedImage;
} }
@@ -986,8 +1100,8 @@ export class CanvasLayers {
this.canvas.width = width; this.canvas.width = width;
this.canvas.height = height; this.canvas.height = height;
this.canvas.maskTool.resize(width, height); this.canvas.maskTool.resize(width, height);
this.canvas.canvas.width = width; // Don't set canvas.width/height - the render loop will handle display size
this.canvas.canvas.height = height; // this.canvas.width/height are for OUTPUT AREA dimensions, not display canvas
this.canvas.render(); this.canvas.render();
if (saveHistory) { if (saveHistory) {
this.canvas.canvasState.saveStateToDB(); this.canvas.canvasState.saveStateToDB();
@@ -1611,6 +1725,7 @@ export class CanvasLayers {
tempCtx.translate(-minX, -minY); tempCtx.translate(-minX, -minY);
this._drawLayers(tempCtx, this.canvas.canvasSelection.selectedLayers); this._drawLayers(tempCtx, this.canvas.canvasSelection.selectedLayers);
const fusedImage = new Image(); const fusedImage = new Image();
fusedImage.crossOrigin = 'anonymous';
fusedImage.src = tempCanvas.toDataURL(); fusedImage.src = tempCanvas.toDataURL();
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
fusedImage.onload = resolve; fusedImage.onload = resolve;

View File

@@ -103,6 +103,7 @@ export class CanvasLayersPanel {
this.container.tabIndex = 0; // Umożliwia fokus na panelu this.container.tabIndex = 0; // Umożliwia fokus na panelu
this.container.innerHTML = ` this.container.innerHTML = `
<div class="layers-panel-header"> <div class="layers-panel-header">
<div class="master-visibility-toggle" title="Toggle all layers visibility"></div>
<span class="layers-panel-title">Layers</span> <span class="layers-panel-title">Layers</span>
<div class="layers-panel-controls"> <div class="layers-panel-controls">
<button class="layers-btn" id="delete-layer-btn" title="Delete layer"></button> <button class="layers-btn" id="delete-layer-btn" title="Delete layer"></button>
@@ -115,12 +116,33 @@ export class CanvasLayersPanel {
this.layersContainer = this.container.querySelector('#layers-container'); this.layersContainer = this.container.querySelector('#layers-container');
// Setup event listeners dla przycisków // Setup event listeners dla przycisków
this.setupControlButtons(); this.setupControlButtons();
this.setupMasterVisibilityToggle();
// Dodaj listener dla klawiatury, aby usuwanie działało z panelu // Dodaj listener dla klawiatury, aby usuwanie działało z panelu
this.container.addEventListener('keydown', (e) => { this.container.addEventListener('keydown', (e) => {
if (e.key === 'Delete' || e.key === 'Backspace') { if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
this.deleteSelectedLayers(); this.deleteSelectedLayers();
return;
}
// Handle Ctrl+C/V for layer copy/paste when panel has focus
if (e.ctrlKey || e.metaKey) {
if (e.key.toLowerCase() === 'c') {
if (this.canvas.canvasSelection.selectedLayers.length > 0) {
e.preventDefault();
e.stopPropagation();
this.canvas.canvasLayers.copySelectedLayers();
log.info('Layers copied from panel');
}
}
else if (e.key.toLowerCase() === 'v') {
e.preventDefault();
e.stopPropagation();
if (this.canvas.canvasLayers.internalClipboard.length > 0) {
this.canvas.canvasLayers.pasteLayers();
log.info('Layers pasted from panel');
}
}
} }
}); });
log.debug('Panel structure created'); log.debug('Panel structure created');
@@ -142,6 +164,67 @@ export class CanvasLayersPanel {
// Initial button state update // Initial button state update
this.updateButtonStates(); this.updateButtonStates();
} }
setupMasterVisibilityToggle() {
if (!this.container)
return;
const toggleContainer = this.container.querySelector('.master-visibility-toggle');
if (!toggleContainer)
return;
const updateToggleState = () => {
const total = this.canvas.layers.length;
const visibleCount = this.canvas.layers.filter(l => l.visible).length;
toggleContainer.innerHTML = '';
const checkboxContainer = document.createElement('div');
checkboxContainer.className = 'checkbox-container';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.id = 'master-visibility-checkbox';
const customCheckbox = document.createElement('span');
customCheckbox.className = 'custom-checkbox';
checkboxContainer.appendChild(checkbox);
checkboxContainer.appendChild(customCheckbox);
if (visibleCount === 0) {
checkbox.checked = false;
checkbox.indeterminate = false;
customCheckbox.classList.remove('checked', 'indeterminate');
}
else if (visibleCount === total) {
checkbox.checked = true;
checkbox.indeterminate = false;
customCheckbox.classList.add('checked');
customCheckbox.classList.remove('indeterminate');
}
else {
checkbox.checked = false;
checkbox.indeterminate = true;
customCheckbox.classList.add('indeterminate');
customCheckbox.classList.remove('checked');
}
checkboxContainer.addEventListener('click', (e) => {
e.stopPropagation();
let newVisible;
if (checkbox.indeterminate) {
newVisible = false; // hide all when mixed
}
else if (checkbox.checked) {
newVisible = false; // toggle to hide all
}
else {
newVisible = true; // toggle to show all
}
this.canvas.layers.forEach(layer => {
layer.visible = newVisible;
});
this.canvas.render();
this.canvas.requestSaveState();
updateToggleState();
this.renderLayers();
});
toggleContainer.appendChild(checkboxContainer);
};
updateToggleState();
this._updateMasterVisibilityToggle = updateToggleState;
}
renderLayers() { renderLayers() {
if (!this.layersContainer) { if (!this.layersContainer) {
log.warn('Layers container not initialized'); log.warn('Layers container not initialized');
@@ -158,6 +241,8 @@ export class CanvasLayersPanel {
if (this.layersContainer) if (this.layersContainer)
this.layersContainer.appendChild(layerElement); this.layersContainer.appendChild(layerElement);
}); });
if (this._updateMasterVisibilityToggle)
this._updateMasterVisibilityToggle();
log.debug(`Rendered ${sortedLayers.length} layers`); log.debug(`Rendered ${sortedLayers.length} layers`);
} }
createLayerElement(layer, index) { createLayerElement(layer, index) {
@@ -264,6 +349,8 @@ export class CanvasLayersPanel {
// Aktualizuj tylko wygląd (klasy CSS), bez niszczenia DOM // Aktualizuj tylko wygląd (klasy CSS), bez niszczenia DOM
this.updateSelectionAppearance(); this.updateSelectionAppearance();
this.updateButtonStates(); this.updateButtonStates();
// Focus the canvas so keyboard shortcuts (like Ctrl+C/V) work for layer operations
this.canvas.canvas.focus();
log.debug(`Layer clicked: ${layer.name}, selection count: ${this.canvas.canvasSelection.selectedLayers.length}`); log.debug(`Layer clicked: ${layer.name}, selection count: ${this.canvas.canvasSelection.selectedLayers.length}`);
} }
startEditingLayerName(nameElement, layer) { startEditingLayerName(nameElement, layer) {

View File

@@ -141,12 +141,17 @@ export class CanvasRenderer {
ctx.restore(); ctx.restore();
} }
}); });
// Draw grab icons for selected layers when hovering
if (this.canvas.canvasInteractions.interaction.hoveringGrabIcon) {
this.drawGrabIcons(ctx);
}
this.drawCanvasOutline(ctx); this.drawCanvasOutline(ctx);
this.drawOutputAreaExtensionPreview(ctx); // Draw extension preview this.drawOutputAreaExtensionPreview(ctx); // Draw extension preview
this.drawPendingGenerationAreas(ctx); // Draw snapshot outlines this.drawPendingGenerationAreas(ctx); // Draw snapshot outlines
this.renderInteractionElements(ctx); this.renderInteractionElements(ctx);
this.canvas.shapeTool.render(ctx); this.canvas.shapeTool.render(ctx);
this.drawMaskAreaBounds(ctx); // Draw mask area bounds when mask tool is active this.drawMaskAreaBounds(ctx); // Draw mask area bounds when mask tool is active
this.renderOutputAreaTransformHandles(ctx); // Draw output area transform handles
this.renderLayerInfo(ctx); this.renderLayerInfo(ctx);
// Update custom shape menu position and visibility // Update custom shape menu position and visibility
if (this.canvas.outputAreaShape) { if (this.canvas.outputAreaShape) {
@@ -652,8 +657,8 @@ export class CanvasRenderer {
this.updateStrokeOverlaySize(); this.updateStrokeOverlaySize();
// Position above main canvas but below cursor overlay // Position above main canvas but below cursor overlay
this.strokeOverlayCanvas.style.position = 'absolute'; this.strokeOverlayCanvas.style.position = 'absolute';
this.strokeOverlayCanvas.style.left = '0px'; this.strokeOverlayCanvas.style.left = '1px';
this.strokeOverlayCanvas.style.top = '0px'; this.strokeOverlayCanvas.style.top = '1px';
this.strokeOverlayCanvas.style.pointerEvents = 'none'; this.strokeOverlayCanvas.style.pointerEvents = 'none';
this.strokeOverlayCanvas.style.zIndex = '19'; // Below cursor overlay (20) this.strokeOverlayCanvas.style.zIndex = '19'; // Below cursor overlay (20)
// Opacity is now controlled by MaskTool.previewOpacity // Opacity is now controlled by MaskTool.previewOpacity
@@ -832,4 +837,89 @@ export class CanvasRenderer {
// Just ensure it's the right size // Just ensure it's the right size
this.updateOverlaySize(); 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

@@ -88,10 +88,10 @@ export class CanvasState {
log.debug(`Output Area resized to ${this.canvas.width}x${this.canvas.height} and viewport set.`); log.debug(`Output Area resized to ${this.canvas.width}x${this.canvas.height} and viewport set.`);
const loadedLayers = await this._loadLayers(savedState.layers); const loadedLayers = await this._loadLayers(savedState.layers);
this.canvas.layers = loadedLayers.filter((l) => l !== null); this.canvas.layers = loadedLayers.filter((l) => l !== null);
log.info(`Loaded ${this.canvas.layers.length} layers.`); log.info(`Loaded ${this.canvas.layers.length} layers from ${savedState.layers.length} saved layers.`);
if (this.canvas.layers.length === 0) { if (this.canvas.layers.length === 0 && savedState.layers.length > 0) {
log.warn("No valid layers loaded, state may be corrupted."); log.warn(`Failed to load any layers. Saved state had ${savedState.layers.length} layers but all failed to load. This may indicate corrupted IndexedDB data.`);
return false; // Don't return false - allow empty canvas to be valid
} }
this.canvas.updateSelectionAfterHistory(); this.canvas.updateSelectionAfterHistory();
this.canvas.render(); this.canvas.render();
@@ -200,6 +200,7 @@ export class CanvasState {
_createLayerFromSrc(layerData, imageSrc, index, resolve) { _createLayerFromSrc(layerData, imageSrc, index, resolve) {
if (typeof imageSrc === 'string') { if (typeof imageSrc === 'string') {
const img = new Image(); const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => { img.onload = () => {
log.debug(`Layer ${index}: Image loaded successfully.`); log.debug(`Layer ${index}: Image loaded successfully.`);
const newLayer = { ...layerData, image: img }; const newLayer = { ...layerData, image: img };
@@ -216,6 +217,7 @@ export class CanvasState {
if (ctx) { if (ctx) {
ctx.drawImage(imageSrc, 0, 0); ctx.drawImage(imageSrc, 0, 0);
const img = new Image(); const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => { img.onload = () => {
log.debug(`Layer ${index}: Image loaded successfully from ImageBitmap.`); log.debug(`Layer ${index}: Image loaded successfully from ImageBitmap.`);
const newLayer = { ...layerData, image: img }; const newLayer = { ...layerData, image: img };

View File

@@ -8,7 +8,7 @@ import { clearAllCanvasStates } from "./db.js";
import { ImageCache } from "./ImageCache.js"; import { ImageCache } from "./ImageCache.js";
import { createCanvas } from "./utils/CommonUtils.js"; import { createCanvas } from "./utils/CommonUtils.js";
import { createModuleLogger } from "./utils/LoggerUtils.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 { iconLoader, LAYERFORGE_TOOLS } from "./utils/IconLoader.js";
import { setupSAMDetectorHook } from "./SAMDetectorIntegration.js"; import { setupSAMDetectorHook } from "./SAMDetectorIntegration.js";
const log = createModuleLogger('Canvas_view'); const log = createModuleLogger('Canvas_view');
@@ -213,88 +213,32 @@ async function createCanvasWidget(node, widget, app) {
]), ]),
$el("div.painter-separator"), $el("div.painter-separator"),
$el("div.painter-button-group", {}, [ $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", { $el("button.painter-button", {
textContent: "Output Area Size", textContent: "Output Area Size",
title: "Set the size of the output area", title: "Transform output area - drag handles to resize",
onclick: () => { onclick: () => {
const dialog = $el("div.painter-dialog", { // Activate output area transform mode
style: { canvas.canvasInteractions.activateOutputAreaTransform();
position: 'fixed', showInfoNotification("Click and drag the handles to resize the output area. Click anywhere else to exit.", 3000);
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);
};
} }
}), }),
$el("button.painter-button.requires-selection", { $el("button.painter-button.requires-selection", {
@@ -399,11 +343,38 @@ async function createCanvasWidget(node, widget, app) {
const button = e.target.closest('.matting-button'); const button = e.target.closest('.matting-button');
if (button.classList.contains('loading')) if (button.classList.contains('loading'))
return; return;
const spinner = $el("div.matting-spinner");
button.appendChild(spinner);
button.classList.add('loading');
showInfoNotification("Starting background removal process...", 2000);
try { 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) { if (canvas.canvasSelection.selectedLayers.length !== 1) {
throw new Error("Please select exactly one image layer for matting."); throw new Error("Please select exactly one image layer for matting.");
} }
@@ -419,7 +390,20 @@ async function createCanvasWidget(node, widget, app) {
if (!response.ok) { if (!response.ok) {
let errorMsg = `Server error: ${response.status} - ${response.statusText}`; let errorMsg = `Server error: ${response.status} - ${response.statusText}`;
if (result && result.error) { 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); throw new Error(errorMsg);
} }
@@ -439,11 +423,16 @@ async function createCanvasWidget(node, widget, app) {
catch (error) { catch (error) {
log.error("Matting error:", error); log.error("Matting error:", error);
const errorMessage = error.message || "An unknown error occurred."; 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 { finally {
button.classList.remove('loading'); button.classList.remove('loading');
if (button.contains(spinner)) { const spinner = button.querySelector('.matting-spinner');
if (spinner && button.contains(spinner)) {
button.removeChild(spinner); button.removeChild(spinner);
} }
} }
@@ -895,6 +884,12 @@ async function createCanvasWidget(node, widget, app) {
if (controlsElement) { if (controlsElement) {
resizeObserver.observe(controlsElement); resizeObserver.observe(controlsElement);
} }
// Watch the canvas container itself to detect size changes and fix canvas dimensions
const canvasContainerResizeObserver = new ResizeObserver(() => {
// Force re-read of canvas dimensions on next render
canvas.render();
});
canvasContainerResizeObserver.observe(canvasContainer);
canvas.canvas.addEventListener('focus', () => { canvas.canvas.addEventListener('focus', () => {
canvasContainer.classList.add('has-focus'); canvasContainer.classList.add('has-focus');
}); });
@@ -1049,13 +1044,20 @@ app.registerExtension({
log.info(`Found ${canvasNodeInstances.size} CanvasNode(s). Sending data via WebSocket...`); log.info(`Found ${canvasNodeInstances.size} CanvasNode(s). Sending data via WebSocket...`);
const sendPromises = []; const sendPromises = [];
for (const [nodeId, canvasWidget] of canvasNodeInstances.entries()) { for (const [nodeId, canvasWidget] of canvasNodeInstances.entries()) {
if (app.graph.getNodeById(nodeId) && canvasWidget.canvas && canvasWidget.canvas.canvasIO) { const node = app.graph.getNodeById(nodeId);
log.debug(`Sending data for canvas node ${nodeId}`); if (!node) {
sendPromises.push(canvasWidget.canvas.canvasIO.sendDataViaWebSocket(nodeId));
}
else {
log.warn(`Node ${nodeId} not found in graph, removing from instances map.`); log.warn(`Node ${nodeId} not found in graph, removing from instances map.`);
canvasNodeInstances.delete(nodeId); canvasNodeInstances.delete(nodeId);
continue;
}
// Skip bypassed nodes
if (node.mode === 4) {
log.debug(`Node ${nodeId} is bypassed, skipping data send.`);
continue;
}
if (canvasWidget.canvas && canvasWidget.canvas.canvasIO) {
log.debug(`Sending data for canvas node ${nodeId}`);
sendPromises.push(canvasWidget.canvas.canvasIO.sendDataViaWebSocket(nodeId));
} }
} }
try { try {
@@ -1074,6 +1076,8 @@ app.registerExtension({
}, },
async beforeRegisterNodeDef(nodeType, nodeData, app) { async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeType.comfyClass === "LayerForgeNode") { if (nodeType.comfyClass === "LayerForgeNode") {
// Map to track pending copy sources across node ID changes
const pendingCopySources = new Map();
const onNodeCreated = nodeType.prototype.onNodeCreated; const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () { nodeType.prototype.onNodeCreated = function () {
log.debug("CanvasNode onNodeCreated: Base widget setup."); log.debug("CanvasNode onNodeCreated: Base widget setup.");
@@ -1104,6 +1108,43 @@ app.registerExtension({
log.info(`Registered CanvasNode instance for ID: ${this.id}`); log.info(`Registered CanvasNode instance for ID: ${this.id}`);
// Store the canvas widget on the node // Store the canvas widget on the node
this.canvasWidget = canvasWidget; this.canvasWidget = canvasWidget;
// Check if this node has a pending copy source (from onConfigure)
// Check both the current ID and -1 (temporary ID during paste)
let sourceNodeId = pendingCopySources.get(this.id);
if (!sourceNodeId) {
sourceNodeId = pendingCopySources.get(-1);
if (sourceNodeId) {
// Transfer from -1 to the real ID and clear -1
pendingCopySources.delete(-1);
}
}
if (sourceNodeId && sourceNodeId !== this.id) {
log.info(`Node ${this.id} will copy canvas state from node ${sourceNodeId}`);
// Clear the flag
pendingCopySources.delete(this.id);
// Copy the canvas state now that the widget is initialized
setTimeout(async () => {
try {
const { getCanvasState, setCanvasState } = await import('./db.js');
let sourceState = await getCanvasState(String(sourceNodeId));
// If source node doesn't exist (cross-workflow paste), try clipboard
if (!sourceState) {
log.debug(`No canvas state found for source node ${sourceNodeId}, checking clipboard`);
sourceState = await getCanvasState('__clipboard__');
}
if (!sourceState) {
log.debug(`No canvas state found in clipboard either`);
return;
}
await setCanvasState(String(this.id), sourceState);
await canvasWidget.canvas.loadInitialState();
log.info(`Canvas state copied successfully to node ${this.id}`);
}
catch (error) {
log.error(`Error copying canvas state:`, error);
}
}, 100);
}
// Check if there are already connected inputs // Check if there are already connected inputs
setTimeout(() => { setTimeout(() => {
if (this.inputs && this.inputs.length > 0) { if (this.inputs && this.inputs.length > 0) {
@@ -1269,6 +1310,47 @@ app.registerExtension({
} }
return onRemoved?.apply(this, arguments); return onRemoved?.apply(this, arguments);
}; };
// Handle copy/paste - save canvas state when copying
const originalSerialize = nodeType.prototype.serialize;
nodeType.prototype.serialize = function () {
const data = originalSerialize ? originalSerialize.apply(this) : {};
// Store a reference to the source node ID so we can copy layer data
data.sourceNodeId = this.id;
log.debug(`Serializing node ${this.id} for copy`);
// Store canvas state in a clipboard entry for cross-workflow paste
// This happens async but that's fine since paste happens later
(async () => {
try {
const { getCanvasState, setCanvasState } = await import('./db.js');
const sourceState = await getCanvasState(String(this.id));
if (sourceState) {
// Store in a special "clipboard" entry
await setCanvasState('__clipboard__', sourceState);
log.debug(`Stored canvas state in clipboard for node ${this.id}`);
}
}
catch (error) {
log.error('Error storing canvas state to clipboard:', error);
}
})();
return data;
};
// Handle copy/paste - load canvas state from source node when pasting
const originalConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = async function (data) {
if (originalConfigure) {
originalConfigure.apply(this, [data]);
}
// Store the source node ID in the map (persists across node ID changes)
// This will be picked up later in onAdded when the canvas widget is ready
if (data.sourceNodeId && data.sourceNodeId !== this.id) {
const existingSource = pendingCopySources.get(this.id);
if (!existingSource) {
pendingCopySources.set(this.id, data.sourceNodeId);
log.debug(`Stored pending copy source: ${data.sourceNodeId} for node ${this.id}`);
}
}
};
const originalGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions; const originalGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function (_, options) { nodeType.prototype.getExtraMenuOptions = function (_, options) {
// FIRST: Call original to let other extensions add their options // FIRST: Call original to let other extensions add their options
@@ -1352,8 +1434,8 @@ app.registerExtension({
callback: async () => { callback: async () => {
try { try {
log.info("Opening LayerForge canvas in MaskEditor"); log.info("Opening LayerForge canvas in MaskEditor");
if (self.canvasWidget && self.canvasWidget.startMaskEditor) { if (self.canvasWidget && self.canvasWidget.canvas) {
await self.canvasWidget.startMaskEditor(null, true); await self.canvasWidget.canvas.startMaskEditor(null, true);
} }
else { else {
log.error("Canvas widget not available"); log.error("Canvas widget not available");
@@ -1370,9 +1452,9 @@ app.registerExtension({
content: "Open Image", content: "Open Image",
callback: async () => { callback: async () => {
try { try {
if (!self.canvasWidget) if (!self.canvasWidget || !self.canvasWidget.canvas)
return; return;
const blob = await self.canvasWidget.getFlattenedCanvasAsBlob(); const blob = await self.canvasWidget.canvas.canvasLayers.getFlattenedCanvasAsBlob();
if (!blob) if (!blob)
return; return;
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@@ -1388,9 +1470,9 @@ app.registerExtension({
content: "Open Image with Mask Alpha", content: "Open Image with Mask Alpha",
callback: async () => { callback: async () => {
try { try {
if (!self.canvasWidget) if (!self.canvasWidget || !self.canvasWidget.canvas)
return; return;
const blob = await self.canvasWidget.getFlattenedCanvasWithMaskAsBlob(); const blob = await self.canvasWidget.canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
if (!blob) if (!blob)
return; return;
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@@ -1406,9 +1488,9 @@ app.registerExtension({
content: "Copy Image", content: "Copy Image",
callback: async () => { callback: async () => {
try { try {
if (!self.canvasWidget) if (!self.canvasWidget || !self.canvasWidget.canvas)
return; return;
const blob = await self.canvasWidget.getFlattenedCanvasAsBlob(); const blob = await self.canvasWidget.canvas.canvasLayers.getFlattenedCanvasAsBlob();
if (!blob) if (!blob)
return; return;
const item = new ClipboardItem({ 'image/png': blob }); const item = new ClipboardItem({ 'image/png': blob });
@@ -1425,9 +1507,9 @@ app.registerExtension({
content: "Copy Image with Mask Alpha", content: "Copy Image with Mask Alpha",
callback: async () => { callback: async () => {
try { try {
if (!self.canvasWidget) if (!self.canvasWidget || !self.canvasWidget.canvas)
return; return;
const blob = await self.canvasWidget.getFlattenedCanvasWithMaskAsBlob(); const blob = await self.canvasWidget.canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
if (!blob) if (!blob)
return; return;
const item = new ClipboardItem({ 'image/png': blob }); const item = new ClipboardItem({ 'image/png': blob });
@@ -1444,9 +1526,9 @@ app.registerExtension({
content: "Save Image", content: "Save Image",
callback: async () => { callback: async () => {
try { try {
if (!self.canvasWidget) if (!self.canvasWidget || !self.canvasWidget.canvas)
return; return;
const blob = await self.canvasWidget.getFlattenedCanvasAsBlob(); const blob = await self.canvasWidget.canvas.canvasLayers.getFlattenedCanvasAsBlob();
if (!blob) if (!blob)
return; return;
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@@ -1467,9 +1549,9 @@ app.registerExtension({
content: "Save Image with Mask Alpha", content: "Save Image with Mask Alpha",
callback: async () => { callback: async () => {
try { try {
if (!self.canvasWidget) if (!self.canvasWidget || !self.canvasWidget.canvas)
return; return;
const blob = await self.canvasWidget.getFlattenedCanvasWithMaskAsBlob(); const blob = await self.canvasWidget.canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
if (!blob) if (!blob)
return; return;
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);

View File

@@ -23,6 +23,85 @@
margin-bottom: 8px; margin-bottom: 8px;
} }
.checkbox-container {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 0;
border-radius: 5px;
cursor: pointer;
transition: background-color 0.2s;
position: relative;
}
.checkbox-container:hover {
background-color: #4a4a4a;
}
.checkbox-container input[type="checkbox"] {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
.checkbox-container .custom-checkbox {
height: 16px;
width: 16px;
background-color: #2a2a2a;
border: 1px solid #666;
border-radius: 3px;
transition: all 0.2s;
position: relative;
flex-shrink: 0;
}
.checkbox-container input:checked ~ .custom-checkbox {
background-color: #3a76d6;
border-color: #3a76d6;
}
.checkbox-container .custom-checkbox::after {
content: "";
position: absolute;
display: none;
left: 5px;
top: 1px;
width: 4px;
height: 9px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.checkbox-container input:checked ~ .custom-checkbox::after {
display: block;
}
.checkbox-container input:indeterminate ~ .custom-checkbox {
background-color: #3a76d6;
border-color: #3a76d6;
}
.checkbox-container input:indeterminate ~ .custom-checkbox::after {
display: block;
content: "";
position: absolute;
top: 7px;
left: 3px;
width: 8px;
height: 2px;
background-color: white;
border: none;
transform: none;
box-shadow: none;
}
.checkbox-container:hover {
background-color: #4a4a4a;
}
.layers-panel-title { .layers-panel-title {
font-weight: bold; font-weight: bold;
color: #ffffff; color: #ffffff;

View File

@@ -1,5 +1,5 @@
import { createModuleLogger } from "./LoggerUtils.js"; 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 { withErrorHandling, createValidationError, createNetworkError, createFileError } from "../ErrorHandler.js";
import { safeClipspacePaste } from "./ClipspaceUtils.js"; import { safeClipspacePaste } from "./ClipspaceUtils.js";
// @ts-ignore // @ts-ignore
@@ -18,6 +18,7 @@ export class ClipboardManager {
if (this.canvas.canvasLayers.internalClipboard.length > 0) { if (this.canvas.canvasLayers.internalClipboard.length > 0) {
log.info("Found layers in internal clipboard, pasting layers"); log.info("Found layers in internal clipboard, pasting layers");
this.canvas.canvasLayers.pasteLayers(); this.canvas.canvasLayers.pasteLayers();
showInfoNotification("Layers pasted from internal clipboard");
return true; return true;
} }
if (preference === 'clipspace') { if (preference === 'clipspace') {
@@ -27,9 +28,20 @@ export class ClipboardManager {
return true; return true;
} }
log.info("No image found in ComfyUI Clipspace"); 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"); 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'); }, 'ClipboardManager.handlePaste');
/** /**
* Attempts to paste from ComfyUI Clipspace * Attempts to paste from ComfyUI Clipspace
@@ -51,6 +63,7 @@ export class ClipboardManager {
const img = new Image(); const img = new Image();
img.onload = async () => { img.onload = async () => {
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode); await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
showInfoNotification("Image pasted from Clipspace");
}; };
img.src = clipspaceImage.src; img.src = clipspaceImage.src;
return true; return true;
@@ -96,6 +109,7 @@ export class ClipboardManager {
img.onload = async () => { img.onload = async () => {
log.info("Successfully loaded image from backend response"); log.info("Successfully loaded image from backend response");
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode); await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
showInfoNotification("Image loaded from file path");
resolve(true); resolve(true);
}; };
img.onerror = () => { img.onerror = () => {
@@ -131,6 +145,7 @@ export class ClipboardManager {
img.onload = async () => { img.onload = async () => {
log.info("Successfully loaded image from system clipboard"); log.info("Successfully loaded image from system clipboard");
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode); await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
showInfoNotification("Image pasted from system clipboard");
}; };
if (event.target?.result) { if (event.target?.result) {
img.src = event.target.result; img.src = event.target.result;
@@ -173,11 +188,22 @@ export class ClipboardManager {
try { try {
const text = await navigator.clipboard.readText(); const text = await navigator.clipboard.readText();
log.debug("Found text in clipboard:", text); log.debug("Found text in clipboard:", text);
if (text && this.isValidImagePath(text)) { if (text) {
log.info("Found valid image path in clipboard:", text); // Check if it's a data URI (base64 encoded image)
const success = await this.loadImageFromPath(text, addMode); if (this.isDataURI(text)) {
if (success) { log.info("Found data URI in clipboard");
return true; 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"); log.debug("No images or valid image paths found in system clipboard");
return false; 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 * Validates if a text string is a valid image file path or URL
* @param {string} text - The text to validate * @param {string} text - The text to validate
@@ -252,10 +320,12 @@ export class ClipboardManager {
img.onload = async () => { img.onload = async () => {
log.info("Successfully loaded image from URL"); log.info("Successfully loaded image from URL");
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode); await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
showInfoNotification("Image loaded from URL");
resolve(true); resolve(true);
}; };
img.onerror = () => { img.onerror = () => {
log.warn("Failed to load image from URL:", filePath); 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); resolve(false);
}; };
img.src = filePath; img.src = filePath;
@@ -313,6 +383,7 @@ export class ClipboardManager {
img.onload = async () => { img.onload = async () => {
log.info("Successfully loaded image from file picker"); log.info("Successfully loaded image from file picker");
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode); await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
showInfoNotification("Image loaded from selected file");
resolve(true); resolve(true);
}; };
img.onerror = () => { img.onerror = () => {

View File

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

View File

@@ -1,5 +1,7 @@
import { createModuleLogger } from "./LoggerUtils.js"; import { createModuleLogger } from "./LoggerUtils.js";
const log = createModuleLogger('NotificationUtils'); const log = createModuleLogger('NotificationUtils');
// Store active notifications for deduplication
const activeNotifications = new Map();
/** /**
* Utility functions for showing notifications to the user * Utility functions for showing notifications to the user
*/ */
@@ -8,10 +10,50 @@ const log = createModuleLogger('NotificationUtils');
* @param message - The message to show * @param message - The message to show
* @param backgroundColor - Background color (default: #4a6cd4) * @param backgroundColor - Background color (default: #4a6cd4)
* @param duration - Duration in milliseconds (default: 3000) * @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 // Remove any existing prefix to avoid double prefixing
message = message.replace(/^\[Layer Forge\]\s*/, ""); 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 // Type-specific config
const config = { const config = {
success: { icon: "✔️", title: "Success", bg: "#1fd18b" }, success: { icon: "✔️", title: "Success", bg: "#1fd18b" },
@@ -148,6 +190,10 @@ export function showNotification(message, backgroundColor = "#4a6cd4", duration
body.classList.add('notification-scrollbar'); body.classList.add('notification-scrollbar');
let dismissTimeout = null; let dismissTimeout = null;
const closeNotification = () => { 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.style.animation = 'lf-fadeout 0.3s ease-out forwards';
notification.addEventListener('animationend', () => { notification.addEventListener('animationend', () => {
if (notification.parentNode) { if (notification.parentNode) {
@@ -171,40 +217,77 @@ export function showNotification(message, backgroundColor = "#4a6cd4", duration
progressBar.style.transform = computedStyle.transform; progressBar.style.transform = computedStyle.transform;
progressBar.style.animation = 'lf-progress-rewind 0.5s ease-out forwards'; progressBar.style.animation = 'lf-progress-rewind 0.5s ease-out forwards';
}; };
notification.addEventListener('mouseenter', pauseAndRewindTimer); notification.addEventListener('mouseenter', () => {
notification.addEventListener('mouseleave', startDismissTimer); 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(); startDismissTimer();
// Store notification if deduplicate is enabled
if (deduplicate) {
activeNotifications.set(message, { element: notification, timeout: dismissTimeout });
}
log.debug(`Notification shown: [Layer Forge] ${message}`); log.debug(`Notification shown: [Layer Forge] ${message}`);
} }
/** /**
* Shows a success notification * 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) { export function showSuccessNotification(message, duration = 3000, deduplicate = false) {
showNotification(message, undefined, duration, "success"); showNotification(message, undefined, duration, "success", deduplicate);
} }
/** /**
* Shows an error notification * 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) { export function showErrorNotification(message, duration = 5000, deduplicate = false) {
showNotification(message, undefined, duration, "error"); showNotification(message, undefined, duration, "error", deduplicate);
} }
/** /**
* Shows an info notification * 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) { export function showInfoNotification(message, duration = 3000, deduplicate = false) {
showNotification(message, undefined, duration, "info"); showNotification(message, undefined, duration, "info", deduplicate);
} }
/** /**
* Shows a warning notification * 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) { export function showWarningNotification(message, duration = 3000, deduplicate = false) {
showNotification(message, undefined, duration, "warning"); showNotification(message, undefined, duration, "warning", deduplicate);
} }
/** /**
* Shows an alert notification * 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) { export function showAlertNotification(message, duration = 3000, deduplicate = false) {
showNotification(message, undefined, duration, "alert"); showNotification(message, undefined, duration, "alert", deduplicate);
} }
/** /**
* Shows a sequence of all notification types for debugging purposes. * Shows a sequence of all notification types for debugging purposes.
@@ -214,7 +297,7 @@ export function showAllNotificationTypes(message) {
types.forEach((type, index) => { types.forEach((type, index) => {
const notificationMessage = message || `This is a '${type}' notification.`; const notificationMessage = message || `This is a '${type}' notification.`;
setTimeout(() => { setTimeout(() => {
showNotification(notificationMessage, undefined, 3000, type); showNotification(notificationMessage, undefined, 3000, type, false);
}, index * 400); // Stagger the notifications }, index * 400); // Stagger the notifications
}); });
} }

View File

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

View File

@@ -578,8 +578,8 @@ export class Canvas {
* Inicjalizuje podstawowe właściwości canvas * Inicjalizuje podstawowe właściwości canvas
*/ */
initCanvas() { initCanvas() {
this.canvas.width = this.width; // Don't set canvas.width/height here - let the render loop handle it based on clientWidth/clientHeight
this.canvas.height = this.height; // this.width and this.height are for the OUTPUT AREA, not the display canvas
this.canvas.style.border = '1px solid black'; this.canvas.style.border = '1px solid black';
this.canvas.style.maxWidth = '100%'; this.canvas.style.maxWidth = '100%';
this.canvas.style.backgroundColor = '#606060'; this.canvas.style.backgroundColor = '#606060';

View File

@@ -218,6 +218,29 @@ export class CanvasIO {
async _renderOutputData(): Promise<{ image: string, mask: string }> { async _renderOutputData(): Promise<{ image: string, mask: string }> {
log.info("=== RENDERING OUTPUT DATA FOR COMFYUI ==="); log.info("=== RENDERING OUTPUT DATA FOR COMFYUI ===");
// Check if layers have valid images loaded, with retry logic
const maxRetries = 5;
const retryDelay = 200;
for (let attempt = 0; attempt < maxRetries; attempt++) {
const layersWithoutImages = this.canvas.layers.filter(layer => !layer.image || !layer.image.complete);
if (layersWithoutImages.length === 0) {
break; // All images loaded
}
if (attempt === 0) {
log.warn(`${layersWithoutImages.length} layer(s) have incomplete image data. Waiting for images to load...`);
}
if (attempt < maxRetries - 1) {
await new Promise(resolve => setTimeout(resolve, retryDelay));
} else {
// Last attempt failed
throw new Error(`Canvas not ready after ${maxRetries} attempts: ${layersWithoutImages.length} layer(s) still have incomplete image data. Try waiting a moment and running again.`);
}
}
// Użyj zunifikowanych funkcji z CanvasLayers // Użyj zunifikowanych funkcji z CanvasLayers
const imageBlob = await this.canvas.canvasLayers.getFlattenedCanvasAsBlob(); const imageBlob = await this.canvas.canvasLayers.getFlattenedCanvasAsBlob();
const maskBlob = await this.canvas.canvasLayers.getFlattenedMaskAsBlob(); const maskBlob = await this.canvas.canvasLayers.getFlattenedMaskAsBlob();

View File

@@ -31,7 +31,7 @@ interface TransformOrigin {
} }
interface InteractionState { 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; panStart: Point;
dragStart: Point; dragStart: Point;
transformOrigin: TransformOrigin | null; transformOrigin: TransformOrigin | null;
@@ -49,6 +49,9 @@ interface InteractionState {
keyMovementInProgress: boolean; keyMovementInProgress: boolean;
canvasResizeRect: { x: number, y: number, width: number, height: number } | null; canvasResizeRect: { x: number, y: number, width: number, height: number } | null;
canvasMoveRect: { 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 { export class CanvasInteractions {
@@ -94,6 +97,9 @@ export class CanvasInteractions {
keyMovementInProgress: false, keyMovementInProgress: false,
canvasResizeRect: null, canvasResizeRect: null,
canvasMoveRect: null, canvasMoveRect: null,
outputAreaTransformHandle: null,
outputAreaTransformAnchor: { x: 0, y: 0 },
hoveringGrabIcon: false,
}; };
this.originalLayerPositions = new Map(); this.originalLayerPositions = new Map();
} }
@@ -170,6 +176,9 @@ export class CanvasInteractions {
document.addEventListener('paste', this.onPaste as unknown as EventListener); document.addEventListener('paste', this.onPaste as unknown as EventListener);
// Intercept Ctrl+V during capture phase to handle layer paste before ComfyUI
document.addEventListener('keydown', this.onKeyDown as EventListener, { capture: true });
this.canvas.canvas.addEventListener('mouseenter', this.onMouseEnter as EventListener); this.canvas.canvas.addEventListener('mouseenter', this.onMouseEnter as EventListener);
this.canvas.canvas.addEventListener('mouseleave', this.onMouseLeave as EventListener); this.canvas.canvas.addEventListener('mouseleave', this.onMouseLeave as EventListener);
@@ -189,6 +198,9 @@ export class CanvasInteractions {
this.canvas.canvas.removeEventListener('keydown', this.onKeyDown as EventListener); this.canvas.canvas.removeEventListener('keydown', this.onKeyDown as EventListener);
this.canvas.canvas.removeEventListener('keyup', this.onKeyUp as EventListener); this.canvas.canvas.removeEventListener('keyup', this.onKeyUp as EventListener);
// Remove document-level capture listener
document.removeEventListener('keydown', this.onKeyDown as EventListener, { capture: true });
window.removeEventListener('blur', this.onBlur); window.removeEventListener('blur', this.onBlur);
document.removeEventListener('paste', this.onPaste as unknown as EventListener); document.removeEventListener('paste', this.onPaste as unknown as EventListener);
@@ -230,6 +242,33 @@ export class CanvasInteractions {
return false; 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 { resetInteractionState(): void {
this.interaction.mode = 'none'; this.interaction.mode = 'none';
this.interaction.resizeHandle = null; this.interaction.resizeHandle = null;
@@ -238,11 +277,20 @@ export class CanvasInteractions {
this.interaction.canvasMoveRect = null; this.interaction.canvasMoveRect = null;
this.interaction.hasClonedInDrag = false; this.interaction.hasClonedInDrag = false;
this.interaction.transformingLayer = null; this.interaction.transformingLayer = null;
this.interaction.outputAreaTransformHandle = null;
this.canvas.canvas.style.cursor = 'default'; this.canvas.canvas.style.cursor = 'default';
} }
handleMouseDown(e: MouseEvent): void { handleMouseDown(e: MouseEvent): void {
this.canvas.canvas.focus(); this.canvas.canvas.focus();
// Sync modifier states with actual event state to prevent "stuck" modifiers
// when focus moves between layers panel and canvas
this.interaction.isCtrlPressed = e.ctrlKey;
this.interaction.isMetaPressed = e.metaKey;
this.interaction.isShiftPressed = e.shiftKey;
this.interaction.isAltPressed = e.altKey;
const coords = this.getMouseCoordinates(e); const coords = this.getMouseCoordinates(e);
const mods = this.getModifierState(e); const mods = this.getModifierState(e);
@@ -252,6 +300,19 @@ export class CanvasInteractions {
return; 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) { if (this.canvas.shapeTool.isActive) {
this.canvas.shapeTool.addPoint(coords.world); this.canvas.shapeTool.addPoint(coords.world);
return; return;
@@ -302,6 +363,15 @@ export class CanvasInteractions {
return; 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); const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(coords.world.x, coords.world.y);
if (clickedLayerResult) { if (clickedLayerResult) {
this.prepareForDrag(clickedLayerResult.layer, coords.world); this.prepareForDrag(clickedLayerResult.layer, coords.world);
@@ -352,7 +422,23 @@ export class CanvasInteractions {
case 'movingCanvas': case 'movingCanvas':
this.updateCanvasMove(coords.world); this.updateCanvasMove(coords.world);
break; break;
case 'transformingOutputArea':
if (this.interaction.outputAreaTransformHandle) {
this.resizeOutputAreaFromHandle(coords.world, e.shiftKey);
} else {
this.updateOutputAreaTransformCursor(coords.world);
}
break;
default: 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); this.updateCursor(coords.world);
// Update brush cursor on overlay if mask tool is active // Update brush cursor on overlay if mask tool is active
if (this.canvas.maskTool.isActive) { if (this.canvas.maskTool.isActive) {
@@ -384,6 +470,11 @@ export class CanvasInteractions {
this.finalizeCanvasMove(); this.finalizeCanvasMove();
} }
if (this.interaction.mode === 'transformingOutputArea' && this.interaction.outputAreaTransformHandle) {
this.finalizeOutputAreaTransform();
return;
}
// Log layer positions when dragging ends // Log layer positions when dragging ends
if (this.interaction.mode === 'dragging' && this.canvas.canvasSelection.selectedLayers.length > 0) { if (this.interaction.mode === 'dragging' && this.canvas.canvasSelection.selectedLayers.length > 0) {
this.logDragCompletion(coords); this.logDragCompletion(coords);
@@ -569,11 +660,23 @@ export class CanvasInteractions {
} }
handleKeyDown(e: KeyboardEvent): void { handleKeyDown(e: KeyboardEvent): void {
// Always track modifier keys regardless of focus
if (e.key === 'Control') this.interaction.isCtrlPressed = true; if (e.key === 'Control') this.interaction.isCtrlPressed = true;
if (e.key === 'Meta') this.interaction.isMetaPressed = true; if (e.key === 'Meta') this.interaction.isMetaPressed = true;
if (e.key === 'Shift') this.interaction.isShiftPressed = true; if (e.key === 'Shift') this.interaction.isShiftPressed = true;
if (e.key === 'Alt') this.interaction.isAltPressed = true;
// Check if canvas is focused before handling any shortcuts
const shouldHandle = this.canvas.isMouseOver ||
this.canvas.canvas.contains(document.activeElement) ||
document.activeElement === this.canvas.canvas;
if (!shouldHandle) {
return;
}
// Canvas-specific key handlers (only when focused)
if (e.key === 'Alt') { if (e.key === 'Alt') {
this.interaction.isAltPressed = true;
e.preventDefault(); e.preventDefault();
} }
if (e.key.toLowerCase() === 's') { if (e.key.toLowerCase() === 's') {
@@ -608,6 +711,17 @@ export class CanvasInteractions {
this.canvas.canvasLayers.copySelectedLayers(); this.canvas.canvasLayers.copySelectedLayers();
} }
break; break;
case 'v':
// Only handle internal clipboard paste here.
// If internal clipboard is empty, let the paste event bubble
// so handlePasteEvent can access e.clipboardData for system images.
if (this.canvas.canvasLayers.internalClipboard.length > 0) {
this.canvas.canvasLayers.pasteLayers();
} else {
// Don't preventDefault - let paste event fire for system clipboard
handled = false;
}
break;
default: default:
handled = false; handled = false;
break; break;
@@ -708,6 +822,12 @@ export class CanvasInteractions {
return; 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); const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y);
if (transformTarget) { if (transformTarget) {
@@ -737,7 +857,7 @@ export class CanvasInteractions {
originalHeight: layer.originalHeight, originalHeight: layer.originalHeight,
cropBounds: layer.cropBounds ? { ...layer.cropBounds } : undefined cropBounds: layer.cropBounds ? { ...layer.cropBounds } : undefined
}; };
this.interaction.dragStart = {...worldCoords}; this.interaction.dragStart = { ...worldCoords };
if (handle === 'rot') { if (handle === 'rot') {
this.interaction.mode = 'rotating'; this.interaction.mode = 'rotating';
@@ -761,11 +881,11 @@ export class CanvasInteractions {
if (mods.ctrl || mods.meta) { if (mods.ctrl || mods.meta) {
const index = this.canvas.canvasSelection.selectedLayers.indexOf(layer); const index = this.canvas.canvasSelection.selectedLayers.indexOf(layer);
if (index === -1) { if (index === -1) {
// Ctrl-clicking unselected layer: add to selection
this.canvas.canvasSelection.updateSelection([...this.canvas.canvasSelection.selectedLayers, layer]); this.canvas.canvasSelection.updateSelection([...this.canvas.canvasSelection.selectedLayers, layer]);
} else {
const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l: Layer) => l !== layer);
this.canvas.canvasSelection.updateSelection(newSelection);
} }
// If already selected, do NOT deselect - allows dragging multiple layers with Ctrl held
// User can use right-click in layers panel to deselect individual layers
} else { } else {
if (!this.canvas.canvasSelection.selectedLayers.includes(layer)) { if (!this.canvas.canvasSelection.selectedLayers.includes(layer)) {
this.canvas.canvasSelection.updateSelection([layer]); this.canvas.canvasSelection.updateSelection([layer]);
@@ -773,7 +893,7 @@ export class CanvasInteractions {
} }
this.interaction.mode = 'potential-drag'; this.interaction.mode = 'potential-drag';
this.interaction.dragStart = {...worldCoords}; this.interaction.dragStart = { ...worldCoords };
} }
startPanning(e: MouseEvent, clearSelection: boolean = true): void { startPanning(e: MouseEvent, clearSelection: boolean = true): void {
@@ -789,8 +909,8 @@ export class CanvasInteractions {
this.interaction.mode = 'resizingCanvas'; this.interaction.mode = 'resizingCanvas';
const startX = snapToGrid(worldCoords.x); const startX = snapToGrid(worldCoords.x);
const startY = snapToGrid(worldCoords.y); const startY = snapToGrid(worldCoords.y);
this.interaction.canvasResizeStart = {x: startX, y: startY}; this.interaction.canvasResizeStart = { x: startX, y: startY };
this.interaction.canvasResizeRect = {x: startX, y: startY, width: 0, height: 0}; this.interaction.canvasResizeRect = { x: startX, y: startY, width: 0, height: 0 };
this.canvas.render(); this.canvas.render();
} }
@@ -842,7 +962,7 @@ export class CanvasInteractions {
const dy = e.clientY - this.interaction.panStart.y; const dy = e.clientY - this.interaction.panStart.y;
this.canvas.viewport.x -= dx / this.canvas.viewport.zoom; this.canvas.viewport.x -= dx / this.canvas.viewport.zoom;
this.canvas.viewport.y -= dy / this.canvas.viewport.zoom; this.canvas.viewport.y -= dy / this.canvas.viewport.zoom;
this.interaction.panStart = {x: e.clientX, y: e.clientY}; this.interaction.panStart = { x: e.clientX, y: e.clientY };
// Update stroke overlay if mask tool is drawing during pan // Update stroke overlay if mask tool is drawing during pan
if (this.canvas.maskTool.isDrawing) { if (this.canvas.maskTool.isDrawing) {
@@ -999,9 +1119,9 @@ export class CanvasInteractions {
} else newCropBounds.height += delta_image_y; } else newCropBounds.height += delta_image_y;
} }
// Clamp crop bounds to stay within the original image and maintain minimum size // Clamp crop bounds to stay within the original image and maintain minimum size
if (newCropBounds.width < 1) { if (newCropBounds.width < 1) {
if (handle?.includes('w')) newCropBounds.x = o.cropBounds.x + o.cropBounds.width -1; if (handle?.includes('w')) newCropBounds.x = o.cropBounds.x + o.cropBounds.width - 1;
newCropBounds.width = 1; newCropBounds.width = 1;
} }
if (newCropBounds.height < 1) { if (newCropBounds.height < 1) {
@@ -1259,11 +1379,14 @@ export class CanvasInteractions {
} }
async handlePasteEvent(e: ClipboardEvent): Promise<void> { async handlePasteEvent(e: ClipboardEvent): Promise<void> {
// Check if canvas is connected to DOM and visible
if (!this.canvas.canvas.isConnected || !document.body.contains(this.canvas.canvas)) {
return;
}
const shouldHandle = this.canvas.isMouseOver || const shouldHandle = this.canvas.isMouseOver ||
this.canvas.canvas.contains(document.activeElement) || this.canvas.canvas.contains(document.activeElement) ||
document.activeElement === this.canvas.canvas || document.activeElement === this.canvas.canvas;
document.activeElement === document.body;
if (!shouldHandle) { if (!shouldHandle) {
log.debug("Paste event ignored - not focused on canvas"); log.debug("Paste event ignored - not focused on canvas");
@@ -1313,4 +1436,189 @@ export class CanvasInteractions {
await this.canvas.canvasLayers.clipboardManager.handlePaste('mouse', preference); 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(); const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => { img.onload = () => {
if (!this.canvas.node.imgs) { if (!this.canvas.node.imgs) {
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 { pasteLayers(): void {
if (this.internalClipboard.length === 0) return; if (this.internalClipboard.length === 0) return;
this.canvas.saveState(); this.canvas.saveState();
@@ -266,6 +403,7 @@ export class CanvasLayers {
tempCtx.drawImage(maskCanvas, 0, 0); tempCtx.drawImage(maskCanvas, 0, 0);
const newImage = new Image(); const newImage = new Image();
newImage.crossOrigin = 'anonymous';
newImage.src = tempCanvas.toDataURL(); newImage.src = tempCanvas.toDataURL();
layer.image = newImage; layer.image = newImage;
} }
@@ -864,6 +1002,7 @@ export class CanvasLayers {
// Convert canvas to image // Convert canvas to image
const processedImage = new Image(); const processedImage = new Image();
processedImage.crossOrigin = 'anonymous';
processedImage.src = processedCanvas.toDataURL(); processedImage.src = processedCanvas.toDataURL();
return processedImage; return processedImage;
} }
@@ -1124,8 +1263,8 @@ export class CanvasLayers {
this.canvas.height = height; this.canvas.height = height;
this.canvas.maskTool.resize(width, height); this.canvas.maskTool.resize(width, height);
this.canvas.canvas.width = width; // Don't set canvas.width/height - the render loop will handle display size
this.canvas.canvas.height = height; // this.canvas.width/height are for OUTPUT AREA dimensions, not display canvas
this.canvas.render(); this.canvas.render();
@@ -1884,6 +2023,7 @@ export class CanvasLayers {
this._drawLayers(tempCtx, this.canvas.canvasSelection.selectedLayers); this._drawLayers(tempCtx, this.canvas.canvasSelection.selectedLayers);
const fusedImage = new Image(); const fusedImage = new Image();
fusedImage.crossOrigin = 'anonymous';
fusedImage.src = tempCanvas.toDataURL(); fusedImage.src = tempCanvas.toDataURL();
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
fusedImage.onload = resolve; fusedImage.onload = resolve;

View File

@@ -121,6 +121,7 @@ export class CanvasLayersPanel {
this.container.tabIndex = 0; // Umożliwia fokus na panelu this.container.tabIndex = 0; // Umożliwia fokus na panelu
this.container.innerHTML = ` this.container.innerHTML = `
<div class="layers-panel-header"> <div class="layers-panel-header">
<div class="master-visibility-toggle" title="Toggle all layers visibility"></div>
<span class="layers-panel-title">Layers</span> <span class="layers-panel-title">Layers</span>
<div class="layers-panel-controls"> <div class="layers-panel-controls">
<button class="layers-btn" id="delete-layer-btn" title="Delete layer"></button> <button class="layers-btn" id="delete-layer-btn" title="Delete layer"></button>
@@ -135,6 +136,7 @@ export class CanvasLayersPanel {
// Setup event listeners dla przycisków // Setup event listeners dla przycisków
this.setupControlButtons(); this.setupControlButtons();
this.setupMasterVisibilityToggle();
// Dodaj listener dla klawiatury, aby usuwanie działało z panelu // Dodaj listener dla klawiatury, aby usuwanie działało z panelu
this.container.addEventListener('keydown', (e: KeyboardEvent) => { this.container.addEventListener('keydown', (e: KeyboardEvent) => {
@@ -142,6 +144,26 @@ export class CanvasLayersPanel {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
this.deleteSelectedLayers(); this.deleteSelectedLayers();
return;
}
// Handle Ctrl+C/V for layer copy/paste when panel has focus
if (e.ctrlKey || e.metaKey) {
if (e.key.toLowerCase() === 'c') {
if (this.canvas.canvasSelection.selectedLayers.length > 0) {
e.preventDefault();
e.stopPropagation();
this.canvas.canvasLayers.copySelectedLayers();
log.info('Layers copied from panel');
}
} else if (e.key.toLowerCase() === 'v') {
e.preventDefault();
e.stopPropagation();
if (this.canvas.canvasLayers.internalClipboard.length > 0) {
this.canvas.canvasLayers.pasteLayers();
log.info('Layers pasted from panel');
}
}
} }
}); });
@@ -169,6 +191,74 @@ export class CanvasLayersPanel {
this.updateButtonStates(); this.updateButtonStates();
} }
setupMasterVisibilityToggle(): void {
if (!this.container) return;
const toggleContainer = this.container.querySelector('.master-visibility-toggle') as HTMLElement;
if (!toggleContainer) return;
const updateToggleState = () => {
const total = this.canvas.layers.length;
const visibleCount = this.canvas.layers.filter(l => l.visible).length;
toggleContainer.innerHTML = '';
const checkboxContainer = document.createElement('div');
checkboxContainer.className = 'checkbox-container';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.id = 'master-visibility-checkbox';
const customCheckbox = document.createElement('span');
customCheckbox.className = 'custom-checkbox';
checkboxContainer.appendChild(checkbox);
checkboxContainer.appendChild(customCheckbox);
if (visibleCount === 0) {
checkbox.checked = false;
checkbox.indeterminate = false;
customCheckbox.classList.remove('checked', 'indeterminate');
} else if (visibleCount === total) {
checkbox.checked = true;
checkbox.indeterminate = false;
customCheckbox.classList.add('checked');
customCheckbox.classList.remove('indeterminate');
} else {
checkbox.checked = false;
checkbox.indeterminate = true;
customCheckbox.classList.add('indeterminate');
customCheckbox.classList.remove('checked');
}
checkboxContainer.addEventListener('click', (e) => {
e.stopPropagation();
let newVisible: boolean;
if (checkbox.indeterminate) {
newVisible = false; // hide all when mixed
} else if (checkbox.checked) {
newVisible = false; // toggle to hide all
} else {
newVisible = true; // toggle to show all
}
this.canvas.layers.forEach(layer => {
layer.visible = newVisible;
});
this.canvas.render();
this.canvas.requestSaveState();
updateToggleState();
this.renderLayers();
});
toggleContainer.appendChild(checkboxContainer);
};
updateToggleState();
this._updateMasterVisibilityToggle = updateToggleState;
}
private _updateMasterVisibilityToggle?: () => void;
renderLayers(): void { renderLayers(): void {
if (!this.layersContainer) { if (!this.layersContainer) {
log.warn('Layers container not initialized'); log.warn('Layers container not initialized');
@@ -186,10 +276,11 @@ export class CanvasLayersPanel {
sortedLayers.forEach((layer: Layer, index: number) => { sortedLayers.forEach((layer: Layer, index: number) => {
const layerElement = this.createLayerElement(layer, index); const layerElement = this.createLayerElement(layer, index);
if(this.layersContainer) if (this.layersContainer)
this.layersContainer.appendChild(layerElement); this.layersContainer.appendChild(layerElement);
}); });
if (this._updateMasterVisibilityToggle) this._updateMasterVisibilityToggle();
log.debug(`Rendered ${sortedLayers.length} layers`); log.debug(`Rendered ${sortedLayers.length} layers`);
} }
@@ -317,6 +408,9 @@ export class CanvasLayersPanel {
this.updateSelectionAppearance(); this.updateSelectionAppearance();
this.updateButtonStates(); this.updateButtonStates();
// Focus the canvas so keyboard shortcuts (like Ctrl+C/V) work for layer operations
this.canvas.canvas.focus();
log.debug(`Layer clicked: ${layer.name}, selection count: ${this.canvas.canvasSelection.selectedLayers.length}`); log.debug(`Layer clicked: ${layer.name}, selection count: ${this.canvas.canvasSelection.selectedLayers.length}`);
} }

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.drawCanvasOutline(ctx);
this.drawOutputAreaExtensionPreview(ctx); // Draw extension preview this.drawOutputAreaExtensionPreview(ctx); // Draw extension preview
this.drawPendingGenerationAreas(ctx); // Draw snapshot outlines this.drawPendingGenerationAreas(ctx); // Draw snapshot outlines
@@ -195,6 +200,7 @@ export class CanvasRenderer {
this.renderInteractionElements(ctx); this.renderInteractionElements(ctx);
this.canvas.shapeTool.render(ctx); this.canvas.shapeTool.render(ctx);
this.drawMaskAreaBounds(ctx); // Draw mask area bounds when mask tool is active this.drawMaskAreaBounds(ctx); // Draw mask area bounds when mask tool is active
this.renderOutputAreaTransformHandles(ctx); // Draw output area transform handles
this.renderLayerInfo(ctx); this.renderLayerInfo(ctx);
// Update custom shape menu position and visibility // Update custom shape menu position and visibility
@@ -796,8 +802,8 @@ export class CanvasRenderer {
// Position above main canvas but below cursor overlay // Position above main canvas but below cursor overlay
this.strokeOverlayCanvas.style.position = 'absolute'; this.strokeOverlayCanvas.style.position = 'absolute';
this.strokeOverlayCanvas.style.left = '0px'; this.strokeOverlayCanvas.style.left = '1px';
this.strokeOverlayCanvas.style.top = '0px'; this.strokeOverlayCanvas.style.top = '1px';
this.strokeOverlayCanvas.style.pointerEvents = 'none'; this.strokeOverlayCanvas.style.pointerEvents = 'none';
this.strokeOverlayCanvas.style.zIndex = '19'; // Below cursor overlay (20) this.strokeOverlayCanvas.style.zIndex = '19'; // Below cursor overlay (20)
// Opacity is now controlled by MaskTool.previewOpacity // Opacity is now controlled by MaskTool.previewOpacity
@@ -1011,4 +1017,106 @@ export class CanvasRenderer {
// Just ensure it's the right size // Just ensure it's the right size
this.updateOverlaySize(); 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

@@ -118,11 +118,11 @@ export class CanvasState {
log.debug(`Output Area resized to ${this.canvas.width}x${this.canvas.height} and viewport set.`); log.debug(`Output Area resized to ${this.canvas.width}x${this.canvas.height} and viewport set.`);
const loadedLayers = await this._loadLayers(savedState.layers); const loadedLayers = await this._loadLayers(savedState.layers);
this.canvas.layers = loadedLayers.filter((l): l is Layer => l !== null); this.canvas.layers = loadedLayers.filter((l): l is Layer => l !== null);
log.info(`Loaded ${this.canvas.layers.length} layers.`); log.info(`Loaded ${this.canvas.layers.length} layers from ${savedState.layers.length} saved layers.`);
if (this.canvas.layers.length === 0) { if (this.canvas.layers.length === 0 && savedState.layers.length > 0) {
log.warn("No valid layers loaded, state may be corrupted."); log.warn(`Failed to load any layers. Saved state had ${savedState.layers.length} layers but all failed to load. This may indicate corrupted IndexedDB data.`);
return false; // Don't return false - allow empty canvas to be valid
} }
this.canvas.updateSelectionAfterHistory(); this.canvas.updateSelectionAfterHistory();
@@ -235,6 +235,7 @@ export class CanvasState {
_createLayerFromSrc(layerData: Layer, imageSrc: string | ImageBitmap, index: number, resolve: (value: Layer | null) => void): void { _createLayerFromSrc(layerData: Layer, imageSrc: string | ImageBitmap, index: number, resolve: (value: Layer | null) => void): void {
if (typeof imageSrc === 'string') { if (typeof imageSrc === 'string') {
const img = new Image(); const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => { img.onload = () => {
log.debug(`Layer ${index}: Image loaded successfully.`); log.debug(`Layer ${index}: Image loaded successfully.`);
const newLayer: Layer = {...layerData, image: img}; const newLayer: Layer = {...layerData, image: img};
@@ -250,6 +251,7 @@ export class CanvasState {
if (ctx) { if (ctx) {
ctx.drawImage(imageSrc, 0, 0); ctx.drawImage(imageSrc, 0, 0);
const img = new Image(); const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => { img.onload = () => {
log.debug(`Layer ${index}: Image loaded successfully from ImageBitmap.`); log.debug(`Layer ${index}: Image loaded successfully from ImageBitmap.`);
const newLayer: Layer = {...layerData, image: img}; 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-separator"),
$el("div.painter-button-group", {}, [ $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", { $el("button.painter-button", {
textContent: "Output Area Size", textContent: "Output Area Size",
title: "Set the size of the output area", title: "Transform output area - drag handles to resize",
onclick: () => { onclick: () => {
const dialog = $el("div.painter-dialog", { // Activate output area transform mode
style: { canvas.canvasInteractions.activateOutputAreaTransform();
position: 'fixed', showInfoNotification("Click and drag the handles to resize the output area. Click anywhere else to exit.", 3000);
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);
};
} }
}), }),
$el("button.painter-button.requires-selection", { $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; const button = (e.target as HTMLElement).closest('.matting-button') as HTMLButtonElement;
if (button.classList.contains('loading')) return; 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 { 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) { if (canvas.canvasSelection.selectedLayers.length !== 1) {
throw new Error("Please select exactly one image layer for matting."); 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) { if (!response.ok) {
let errorMsg = `Server error: ${response.status} - ${response.statusText}`; let errorMsg = `Server error: ${response.status} - ${response.statusText}`;
if (result && result.error) { 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); throw new Error(errorMsg);
} }
@@ -526,10 +512,15 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
} catch (error: any) { } catch (error: any) {
log.error("Matting error:", error); log.error("Matting error:", error);
const errorMessage = error.message || "An unknown error occurred."; 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 { } finally {
button.classList.remove('loading'); button.classList.remove('loading');
if (button.contains(spinner)) { const spinner = button.querySelector('.matting-spinner');
if (spinner && button.contains(spinner)) {
button.removeChild(spinner); button.removeChild(spinner);
} }
} }
@@ -1009,6 +1000,13 @@ $el("label.clipboard-switch.mask-switch", {
resizeObserver.observe(controlsElement); resizeObserver.observe(controlsElement);
} }
// Watch the canvas container itself to detect size changes and fix canvas dimensions
const canvasContainerResizeObserver = new ResizeObserver(() => {
// Force re-read of canvas dimensions on next render
canvas.render();
});
canvasContainerResizeObserver.observe(canvasContainer);
canvas.canvas.addEventListener('focus', () => { canvas.canvas.addEventListener('focus', () => {
canvasContainer.classList.add('has-focus'); canvasContainer.classList.add('has-focus');
}); });
@@ -1204,12 +1202,23 @@ app.registerExtension({
const sendPromises: Promise<any>[] = []; const sendPromises: Promise<any>[] = [];
for (const [nodeId, canvasWidget] of canvasNodeInstances.entries()) { for (const [nodeId, canvasWidget] of canvasNodeInstances.entries()) {
if (app.graph.getNodeById(nodeId) && canvasWidget.canvas && canvasWidget.canvas.canvasIO) { const node = app.graph.getNodeById(nodeId);
log.debug(`Sending data for canvas node ${nodeId}`);
sendPromises.push(canvasWidget.canvas.canvasIO.sendDataViaWebSocket(nodeId)); if (!node) {
} else {
log.warn(`Node ${nodeId} not found in graph, removing from instances map.`); log.warn(`Node ${nodeId} not found in graph, removing from instances map.`);
canvasNodeInstances.delete(nodeId); canvasNodeInstances.delete(nodeId);
continue;
}
// Skip bypassed nodes
if (node.mode === 4) {
log.debug(`Node ${nodeId} is bypassed, skipping data send.`);
continue;
}
if (canvasWidget.canvas && canvasWidget.canvas.canvasIO) {
log.debug(`Sending data for canvas node ${nodeId}`);
sendPromises.push(canvasWidget.canvas.canvasIO.sendDataViaWebSocket(nodeId));
} }
} }
@@ -1230,6 +1239,9 @@ app.registerExtension({
async beforeRegisterNodeDef(nodeType: any, nodeData: any, app: ComfyApp) { async beforeRegisterNodeDef(nodeType: any, nodeData: any, app: ComfyApp) {
if (nodeType.comfyClass === "LayerForgeNode") { if (nodeType.comfyClass === "LayerForgeNode") {
// Map to track pending copy sources across node ID changes
const pendingCopySources = new Map<number, number>();
const onNodeCreated = nodeType.prototype.onNodeCreated; const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function (this: ComfyNode) { nodeType.prototype.onNodeCreated = function (this: ComfyNode) {
log.debug("CanvasNode onNodeCreated: Base widget setup."); log.debug("CanvasNode onNodeCreated: Base widget setup.");
@@ -1266,6 +1278,49 @@ app.registerExtension({
// Store the canvas widget on the node // Store the canvas widget on the node
(this as any).canvasWidget = canvasWidget; (this as any).canvasWidget = canvasWidget;
// Check if this node has a pending copy source (from onConfigure)
// Check both the current ID and -1 (temporary ID during paste)
let sourceNodeId = pendingCopySources.get(this.id);
if (!sourceNodeId) {
sourceNodeId = pendingCopySources.get(-1);
if (sourceNodeId) {
// Transfer from -1 to the real ID and clear -1
pendingCopySources.delete(-1);
}
}
if (sourceNodeId && sourceNodeId !== this.id) {
log.info(`Node ${this.id} will copy canvas state from node ${sourceNodeId}`);
// Clear the flag
pendingCopySources.delete(this.id);
// Copy the canvas state now that the widget is initialized
setTimeout(async () => {
try {
const { getCanvasState, setCanvasState } = await import('./db.js');
let sourceState = await getCanvasState(String(sourceNodeId));
// If source node doesn't exist (cross-workflow paste), try clipboard
if (!sourceState) {
log.debug(`No canvas state found for source node ${sourceNodeId}, checking clipboard`);
sourceState = await getCanvasState('__clipboard__');
}
if (!sourceState) {
log.debug(`No canvas state found in clipboard either`);
return;
}
await setCanvasState(String(this.id), sourceState);
await canvasWidget.canvas.loadInitialState();
log.info(`Canvas state copied successfully to node ${this.id}`);
} catch (error) {
log.error(`Error copying canvas state:`, error);
}
}, 100);
}
// Check if there are already connected inputs // Check if there are already connected inputs
setTimeout(() => { setTimeout(() => {
if (this.inputs && this.inputs.length > 0) { if (this.inputs && this.inputs.length > 0) {
@@ -1449,6 +1504,52 @@ app.registerExtension({
return onRemoved?.apply(this, arguments as any); return onRemoved?.apply(this, arguments as any);
}; };
// Handle copy/paste - save canvas state when copying
const originalSerialize = nodeType.prototype.serialize;
nodeType.prototype.serialize = function (this: ComfyNode) {
const data = originalSerialize ? originalSerialize.apply(this) : {};
// Store a reference to the source node ID so we can copy layer data
data.sourceNodeId = this.id;
log.debug(`Serializing node ${this.id} for copy`);
// Store canvas state in a clipboard entry for cross-workflow paste
// This happens async but that's fine since paste happens later
(async () => {
try {
const { getCanvasState, setCanvasState } = await import('./db.js');
const sourceState = await getCanvasState(String(this.id));
if (sourceState) {
// Store in a special "clipboard" entry
await setCanvasState('__clipboard__', sourceState);
log.debug(`Stored canvas state in clipboard for node ${this.id}`);
}
} catch (error) {
log.error('Error storing canvas state to clipboard:', error);
}
})();
return data;
};
// Handle copy/paste - load canvas state from source node when pasting
const originalConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = async function (this: ComfyNode, data: any) {
if (originalConfigure) {
originalConfigure.apply(this, [data]);
}
// Store the source node ID in the map (persists across node ID changes)
// This will be picked up later in onAdded when the canvas widget is ready
if (data.sourceNodeId && data.sourceNodeId !== this.id) {
const existingSource = pendingCopySources.get(this.id);
if (!existingSource) {
pendingCopySources.set(this.id, data.sourceNodeId);
log.debug(`Stored pending copy source: ${data.sourceNodeId} for node ${this.id}`);
}
}
};
const originalGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions; const originalGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function (this: ComfyNode, _: any, options: any[]) { nodeType.prototype.getExtraMenuOptions = function (this: ComfyNode, _: any, options: any[]) {
// FIRST: Call original to let other extensions add their options // FIRST: Call original to let other extensions add their options
@@ -1548,8 +1649,8 @@ app.registerExtension({
callback: async () => { callback: async () => {
try { try {
log.info("Opening LayerForge canvas in MaskEditor"); log.info("Opening LayerForge canvas in MaskEditor");
if ((self as any).canvasWidget && (self as any).canvasWidget.startMaskEditor) { if ((self as any).canvasWidget && (self as any).canvasWidget.canvas) {
await (self as any).canvasWidget.startMaskEditor(null, true); await (self as any).canvasWidget.canvas.startMaskEditor(null, true);
} else { } else {
log.error("Canvas widget not available"); log.error("Canvas widget not available");
showErrorNotification("Canvas not ready. Please try again."); showErrorNotification("Canvas not ready. Please try again.");
@@ -1564,8 +1665,8 @@ app.registerExtension({
content: "Open Image", content: "Open Image",
callback: async () => { callback: async () => {
try { try {
if (!(self as any).canvasWidget) return; if (!(self as any).canvasWidget || !(self as any).canvasWidget.canvas) return;
const blob = await (self as any).canvasWidget.getFlattenedCanvasAsBlob(); const blob = await (self as any).canvasWidget.canvas.canvasLayers.getFlattenedCanvasAsBlob();
if (!blob) return; if (!blob) return;
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
window.open(url, '_blank'); window.open(url, '_blank');
@@ -1579,8 +1680,8 @@ app.registerExtension({
content: "Open Image with Mask Alpha", content: "Open Image with Mask Alpha",
callback: async () => { callback: async () => {
try { try {
if (!(self as any).canvasWidget) return; if (!(self as any).canvasWidget || !(self as any).canvasWidget.canvas) return;
const blob = await (self as any).canvasWidget.getFlattenedCanvasWithMaskAsBlob(); const blob = await (self as any).canvasWidget.canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
if (!blob) return; if (!blob) return;
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
window.open(url, '_blank'); window.open(url, '_blank');
@@ -1594,8 +1695,8 @@ app.registerExtension({
content: "Copy Image", content: "Copy Image",
callback: async () => { callback: async () => {
try { try {
if (!(self as any).canvasWidget) return; if (!(self as any).canvasWidget || !(self as any).canvasWidget.canvas) return;
const blob = await (self as any).canvasWidget.getFlattenedCanvasAsBlob(); const blob = await (self as any).canvasWidget.canvas.canvasLayers.getFlattenedCanvasAsBlob();
if (!blob) return; if (!blob) return;
const item = new ClipboardItem({'image/png': blob}); const item = new ClipboardItem({'image/png': blob});
await navigator.clipboard.write([item]); await navigator.clipboard.write([item]);
@@ -1610,8 +1711,8 @@ app.registerExtension({
content: "Copy Image with Mask Alpha", content: "Copy Image with Mask Alpha",
callback: async () => { callback: async () => {
try { try {
if (!(self as any).canvasWidget) return; if (!(self as any).canvasWidget || !(self as any).canvasWidget.canvas) return;
const blob = await (self as any).canvasWidget.getFlattenedCanvasWithMaskAsBlob(); const blob = await (self as any).canvasWidget.canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
if (!blob) return; if (!blob) return;
const item = new ClipboardItem({'image/png': blob}); const item = new ClipboardItem({'image/png': blob});
await navigator.clipboard.write([item]); await navigator.clipboard.write([item]);
@@ -1626,8 +1727,8 @@ app.registerExtension({
content: "Save Image", content: "Save Image",
callback: async () => { callback: async () => {
try { try {
if (!(self as any).canvasWidget) return; if (!(self as any).canvasWidget || !(self as any).canvasWidget.canvas) return;
const blob = await (self as any).canvasWidget.getFlattenedCanvasAsBlob(); const blob = await (self as any).canvasWidget.canvas.canvasLayers.getFlattenedCanvasAsBlob();
if (!blob) return; if (!blob) return;
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
@@ -1646,8 +1747,8 @@ app.registerExtension({
content: "Save Image with Mask Alpha", content: "Save Image with Mask Alpha",
callback: async () => { callback: async () => {
try { try {
if (!(self as any).canvasWidget) return; if (!(self as any).canvasWidget || !(self as any).canvasWidget.canvas) return;
const blob = await (self as any).canvasWidget.getFlattenedCanvasWithMaskAsBlob(); const blob = await (self as any).canvasWidget.canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
if (!blob) return; if (!blob) return;
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');

View File

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

View File

@@ -23,6 +23,85 @@
margin-bottom: 8px; margin-bottom: 8px;
} }
.checkbox-container {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 0;
border-radius: 5px;
cursor: pointer;
transition: background-color 0.2s;
position: relative;
}
.checkbox-container:hover {
background-color: #4a4a4a;
}
.checkbox-container input[type="checkbox"] {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
.checkbox-container .custom-checkbox {
height: 16px;
width: 16px;
background-color: #2a2a2a;
border: 1px solid #666;
border-radius: 3px;
transition: all 0.2s;
position: relative;
flex-shrink: 0;
}
.checkbox-container input:checked ~ .custom-checkbox {
background-color: #3a76d6;
border-color: #3a76d6;
}
.checkbox-container .custom-checkbox::after {
content: "";
position: absolute;
display: none;
left: 5px;
top: 1px;
width: 4px;
height: 9px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.checkbox-container input:checked ~ .custom-checkbox::after {
display: block;
}
.checkbox-container input:indeterminate ~ .custom-checkbox {
background-color: #3a76d6;
border-color: #3a76d6;
}
.checkbox-container input:indeterminate ~ .custom-checkbox::after {
display: block;
content: "";
position: absolute;
top: 7px;
left: 3px;
width: 8px;
height: 2px;
background-color: white;
border: none;
transform: none;
box-shadow: none;
}
.checkbox-container:hover {
background-color: #4a4a4a;
}
.layers-panel-title { .layers-panel-title {
font-weight: bold; font-weight: bold;
color: #ffffff; color: #ffffff;

View File

@@ -1,5 +1,5 @@
import {createModuleLogger} from "./LoggerUtils.js"; 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 { withErrorHandling, createValidationError, createNetworkError, createFileError } from "../ErrorHandler.js";
import { safeClipspacePaste } from "./ClipspaceUtils.js"; import { safeClipspacePaste } from "./ClipspaceUtils.js";
@@ -34,6 +34,7 @@ export class ClipboardManager {
if (this.canvas.canvasLayers.internalClipboard.length > 0) { if (this.canvas.canvasLayers.internalClipboard.length > 0) {
log.info("Found layers in internal clipboard, pasting layers"); log.info("Found layers in internal clipboard, pasting layers");
this.canvas.canvasLayers.pasteLayers(); this.canvas.canvasLayers.pasteLayers();
showInfoNotification("Layers pasted from internal clipboard");
return true; return true;
} }
@@ -44,10 +45,22 @@ export class ClipboardManager {
return true; return true;
} }
log.info("No image found in ComfyUI Clipspace"); 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"); 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'); }, 'ClipboardManager.handlePaste');
/** /**
@@ -72,6 +85,7 @@ export class ClipboardManager {
const img = new Image(); const img = new Image();
img.onload = async () => { img.onload = async () => {
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode); await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
showInfoNotification("Image pasted from Clipspace");
}; };
img.src = clipspaceImage.src; img.src = clipspaceImage.src;
return true; return true;
@@ -105,6 +119,7 @@ export class ClipboardManager {
img.onload = async () => { img.onload = async () => {
log.info("Successfully loaded image from system clipboard"); log.info("Successfully loaded image from system clipboard");
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode); await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
showInfoNotification("Image pasted from system clipboard");
}; };
if (event.target?.result) { if (event.target?.result) {
img.src = event.target.result as string; img.src = event.target.result as string;
@@ -148,11 +163,22 @@ export class ClipboardManager {
const text = await navigator.clipboard.readText(); const text = await navigator.clipboard.readText();
log.debug("Found text in clipboard:", text); log.debug("Found text in clipboard:", text);
if (text && this.isValidImagePath(text)) { if (text) {
log.info("Found valid image path in clipboard:", text); // Check if it's a data URI (base64 encoded image)
const success = await this.loadImageFromPath(text, addMode); if (this.isDataURI(text)) {
if (success) { log.info("Found data URI in clipboard");
return true; 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) { } 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 * Validates if a text string is a valid image file path or URL
* @param {string} text - The text to validate * @param {string} text - The text to validate
@@ -240,15 +310,17 @@ export class ClipboardManager {
const img = new Image(); const img = new Image();
img.crossOrigin = 'anonymous'; img.crossOrigin = 'anonymous';
return new Promise((resolve) => { return new Promise((resolve) => {
img.onload = async () => { img.onload = async () => {
log.info("Successfully loaded image from URL"); log.info("Successfully loaded image from URL");
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode); await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
resolve(true); showInfoNotification("Image loaded from URL");
}; resolve(true);
img.onerror = () => { };
log.warn("Failed to load image from URL:", filePath); img.onerror = () => {
resolve(false); 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; img.src = filePath;
}); });
} catch (error) { } catch (error) {
@@ -326,6 +398,7 @@ export class ClipboardManager {
img.onload = async () => { img.onload = async () => {
log.info("Successfully loaded image from backend response"); log.info("Successfully loaded image from backend response");
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode); await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
showInfoNotification("Image loaded from file path");
resolve(true); resolve(true);
}; };
img.onerror = () => { img.onerror = () => {
@@ -366,6 +439,7 @@ export class ClipboardManager {
img.onload = async () => { img.onload = async () => {
log.info("Successfully loaded image from file picker"); log.info("Successfully loaded image from file picker");
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode); await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
showInfoNotification("Image loaded from selected file");
resolve(true); resolve(true);
}; };
img.onerror = () => { img.onerror = () => {

View File

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

View File

@@ -2,6 +2,9 @@ import { createModuleLogger } from "./LoggerUtils.js";
const log = createModuleLogger('NotificationUtils'); 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 * Utility functions for showing notifications to the user
*/ */
@@ -11,16 +14,62 @@ const log = createModuleLogger('NotificationUtils');
* @param message - The message to show * @param message - The message to show
* @param backgroundColor - Background color (default: #4a6cd4) * @param backgroundColor - Background color (default: #4a6cd4)
* @param duration - Duration in milliseconds (default: 3000) * @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( export function showNotification(
message: string, message: string,
backgroundColor: string = "#4a6cd4", backgroundColor: string = "#4a6cd4",
duration: number = 3000, duration: number = 3000,
type: "success" | "error" | "info" | "warning" | "alert" = "info" type: "success" | "error" | "info" | "warning" | "alert" = "info",
deduplicate: boolean = false
): void { ): void {
// Remove any existing prefix to avoid double prefixing // Remove any existing prefix to avoid double prefixing
message = message.replace(/^\[Layer Forge\]\s*/, ""); 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 // Type-specific config
const config = { const config = {
success: { icon: "✔️", title: "Success", bg: "#1fd18b" }, success: { icon: "✔️", title: "Success", bg: "#1fd18b" },
@@ -172,6 +221,11 @@ export function showNotification(
let dismissTimeout: number | null = null; let dismissTimeout: number | null = null;
const closeNotification = () => { 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.style.animation = 'lf-fadeout 0.3s ease-out forwards';
notification.addEventListener('animationend', () => { notification.addEventListener('animationend', () => {
if (notification.parentNode) { if (notification.parentNode) {
@@ -198,46 +252,86 @@ export function showNotification(
progressBar.style.animation = 'lf-progress-rewind 0.5s ease-out forwards'; progressBar.style.animation = 'lf-progress-rewind 0.5s ease-out forwards';
}; };
notification.addEventListener('mouseenter', pauseAndRewindTimer); notification.addEventListener('mouseenter', () => {
notification.addEventListener('mouseleave', startDismissTimer); 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(); startDismissTimer();
// Store notification if deduplicate is enabled
if (deduplicate) {
activeNotifications.set(message, { element: notification, timeout: dismissTimeout });
}
log.debug(`Notification shown: [Layer Forge] ${message}`); log.debug(`Notification shown: [Layer Forge] ${message}`);
} }
/** /**
* Shows a success notification * 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 { export function showSuccessNotification(message: string, duration: number = 3000, deduplicate: boolean = false): void {
showNotification(message, undefined, duration, "success"); showNotification(message, undefined, duration, "success", deduplicate);
} }
/** /**
* Shows an error notification * 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 { export function showErrorNotification(message: string, duration: number = 5000, deduplicate: boolean = false): void {
showNotification(message, undefined, duration, "error"); showNotification(message, undefined, duration, "error", deduplicate);
} }
/** /**
* Shows an info notification * 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 { export function showInfoNotification(message: string, duration: number = 3000, deduplicate: boolean = false): void {
showNotification(message, undefined, duration, "info"); showNotification(message, undefined, duration, "info", deduplicate);
} }
/** /**
* Shows a warning notification * 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 { export function showWarningNotification(message: string, duration: number = 3000, deduplicate: boolean = false): void {
showNotification(message, undefined, duration, "warning"); showNotification(message, undefined, duration, "warning", deduplicate);
} }
/** /**
* Shows an alert notification * 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 { export function showAlertNotification(message: string, duration: number = 3000, deduplicate: boolean = false): void {
showNotification(message, undefined, duration, "alert"); showNotification(message, undefined, duration, "alert", deduplicate);
} }
/** /**
@@ -248,7 +342,7 @@ export function showAllNotificationTypes(message?: string): void {
types.forEach((type, index) => { types.forEach((type, index) => {
const notificationMessage = message || `This is a '${type}' notification.`; const notificationMessage = message || `This is a '${type}' notification.`;
setTimeout(() => { setTimeout(() => {
showNotification(notificationMessage, undefined, 3000, type); showNotification(notificationMessage, undefined, 3000, type, false);
}, index * 400); // Stagger the notifications }, index * 400); // Stagger the notifications
}); });
} }