mirror of
https://github.com/Azornes/Comfyui-LayerForge.git
synced 2026-03-22 05:02:11 -03:00
Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
835d94a11d | ||
|
|
061e2b7a9a | ||
|
|
b1f29eefdb | ||
|
|
b8fbcee67a | ||
|
|
d44d944f2d | ||
|
|
ab5d71597a | ||
|
|
ce4d332987 | ||
|
|
9b04729561 | ||
|
|
27ad139cd5 | ||
|
|
66cbcb641b | ||
|
|
986e0a23a2 | ||
|
|
068ed9ee59 | ||
|
|
4e5ef18d93 | ||
|
|
be37966b45 | ||
|
|
dd5fc5470f | ||
|
|
1f1d0aeb7d | ||
|
|
da55d741d6 | ||
|
|
959c47c29b | ||
|
|
ab7ab9d1a8 | ||
|
|
d8d33089d2 | ||
|
|
de67252a87 | ||
|
|
4acece1602 | ||
|
|
ffa5b136bf | ||
|
|
7a5ecb3919 | ||
|
|
20ab861315 | ||
|
|
6750141bcc | ||
|
|
5ea2562b32 | ||
|
|
079fb7b362 | ||
|
|
e05e2d8d8a | ||
|
|
ae55c8a827 | ||
|
|
e21fab0061 | ||
|
|
36a80bbb7e | ||
|
|
492e06068a | ||
|
|
9af1491c68 | ||
|
|
b04795d6e8 | ||
|
|
8d1545bb7e | ||
|
|
f6a240c535 | ||
|
|
d1ceb6291b | ||
|
|
868221b285 | ||
|
|
0f4f2cb1b0 | ||
|
|
7ce7194cbf | ||
|
|
990853f8c7 | ||
|
|
5fb163cd59 | ||
|
|
19d3238680 | ||
|
|
c9860cac9e |
171
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
171
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -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.)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
8
.github/ISSUE_TEMPLATE/docs_request.yml
vendored
8
.github/ISSUE_TEMPLATE/docs_request.yml
vendored
@@ -3,11 +3,17 @@ description: Suggest improvements or additions to documentation
|
|||||||
title: "[Docs] "
|
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
|
||||||
|
|||||||
16
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
16
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
@@ -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: |
|
||||||
|
|||||||
2
.github/workflows/ComfyUIdownloads.yml
vendored
2
.github/workflows/ComfyUIdownloads.yml
vendored
@@ -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
|
||||||
|
|
||||||
|
|||||||
61
README.md
61
README.md
@@ -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>
|
||||||
|
|
|
||||||
|
<strong>🧩 <a href="https://github.com/Azornes/Comfyui-LayerForge?tab=readme-ov-file#-workflow-example">Workflow Example</a></strong>
|
||||||
|
|
|
||||||
|
<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 doesn’t 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 you’d 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
|
||||||
|
|||||||
109
canvas_node.py
109
canvas_node.py
@@ -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
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
294
js/CanvasView.js
294
js/CanvasView.js
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user